feat: add [TRIAGE_UPDATE] marker extraction and auto-PATCH (Phase 2)

- Add _parse_triage_update_marker() parser following existing marker pattern
- Add [TRIAGE_UPDATE] instructions to system prompt with grounding rules
- Add QuestionItem.options support in question parser
- Wire triage extraction into both main and branch-aware chat paths
- Auto-PATCH session: AI only fills null fields (manual edits win)
- Evidence items: AI appends only, never modifies existing
- Return triage_update in ChatMessageResponse for frontend header sync

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
chihlasm
2026-04-01 22:40:49 +00:00
parent 15781baeb7
commit 92cc62bcbd
3 changed files with 108 additions and 8 deletions

View File

@@ -308,7 +308,7 @@ async def send_chat_message(
message = f"{message}\n\n[Attached document content]\n{doc_context}"
try:
ai_content, suggested_flows, session, fork_metadata, actions_data, questions_data = await unified_chat_service.send_chat_message(
ai_content, suggested_flows, session, fork_metadata, actions_data, questions_data, triage_update_data = await unified_chat_service.send_chat_message(
session_id=session_id,
user_id=user_id,
account_id=account_id,
@@ -353,6 +353,7 @@ async def send_chat_message(
fork=fork_metadata,
actions=actions_data,
questions=questions_data,
triage_update=triage_update_data,
)

View File

@@ -84,9 +84,38 @@ scope narrows it to this endpoint.
- Commands should be PowerShell unless context indicates Linux/Mac
- For GUI-only steps, omit `command`
**[QUESTIONS] `options` field:**
When a question has a small, constrained set of answers (yes/no, 2-4 choices), include \
an `options` array with the answer labels. The engineer will see these as quick-reply buttons. \
Example: `{"text": "Did nslookup time out or return a wrong IP?", "options": ["Timed out", "Wrong IP", "Both"]}`
Omit `options` when the answer is open-ended.
**Both markers are stripped from display** — the engineer sees them as interactive UI cards, \
not raw JSON. Put analysis BEFORE markers. Markers go at the END of your response.
## Triage Context Extraction
When you learn NEW facts about the case from the engineer's messages, emit a \
[TRIAGE_UPDATE] marker with a JSON object containing ONLY the fields that changed. \
Do NOT repeat unchanged fields. Only emit this marker when you have grounded evidence — \
never guess or fabricate. If you are not confident, do not emit the marker.
Fields:
- `client_name` — the MSP client/company being helped (only from explicit mention or ticket data)
- `asset_name` — the device, user, or asset being troubleshot
- `issue_category` — human-readable category like "DNS / Networking", "Microsoft 365", "Active Directory"
- `triage_hypothesis` — your current working hypothesis about the root cause (update as evidence changes)
- `evidence_items` — NEW evidence to append: `[{"text": "description", "status": "confirmed|ruled_out|pending"}]`
Example (only include fields that have new information):
[TRIAGE_UPDATE]
{"issue_category": "DNS / Networking", "triage_hypothesis": "Corrupted DNS cache on NIC", "evidence_items": [{"text": "Gateway 192.168.1.1 reachable", "status": "confirmed"}, {"text": "DNS 1.1.1.1 timeout", "status": "ruled_out"}]}
[/TRIAGE_UPDATE]
Place [TRIAGE_UPDATE] AFTER [QUESTIONS]/[ACTIONS] markers, before [FORK] if present. \
This marker is optional — only emit it when you learn something new.
## Using the Team's Flow Library
Your team has built troubleshooting flows in ResolutionFlow. When relevant flows \
appear in the context below, reference them by name so the engineer can launch them \

View File

@@ -133,10 +133,13 @@ def _parse_questions_marker(ai_content: str) -> tuple[str, list[dict[str, Any]]
valid_questions = []
for q in questions:
if isinstance(q, dict) and q.get("text"):
valid_questions.append({
item = {
"text": q["text"],
"context": q.get("context", ""),
})
}
if q.get("options") and isinstance(q["options"], list):
item["options"] = q["options"]
valid_questions.append(item)
if not valid_questions:
return ai_content, None
@@ -147,6 +150,43 @@ def _parse_questions_marker(ai_content: str) -> tuple[str, list[dict[str, Any]]
return cleaned, valid_questions
def _parse_triage_update_marker(ai_content: str) -> tuple[str, dict[str, Any] | None]:
"""Extract [TRIAGE_UPDATE]...[/TRIAGE_UPDATE] JSON from AI response.
Returns (cleaned_content, triage_update_dict_or_None).
The marker is stripped from display text.
"""
match = re.search(r'\[TRIAGE_UPDATE\]\s*([\s\S]*?)\s*\[/TRIAGE_UPDATE\]', ai_content)
if not match:
return ai_content, None
try:
raw = match.group(1).strip()
if raw.startswith("```"):
raw = re.sub(r'^```(?:json)?\s*', '', raw)
raw = re.sub(r'\s*```$', '', raw)
triage = json.loads(raw)
except (json.JSONDecodeError, ValueError) as e:
logger.warning("Failed to parse [TRIAGE_UPDATE] marker: %s", e)
return ai_content, None
if not isinstance(triage, dict):
logger.warning("Invalid [TRIAGE_UPDATE] data — expected object")
return ai_content, None
# Only keep recognized fields
valid_fields = {"client_name", "asset_name", "issue_category", "triage_hypothesis", "evidence_items"}
filtered = {k: v for k, v in triage.items() if k in valid_fields and v is not None}
if not filtered:
return ai_content, None
cleaned = ai_content[:match.start()] + ai_content[match.end():]
cleaned = cleaned.strip()
return cleaned, filtered
async def create_chat_session(
user_id: UUID,
account_id: UUID,
@@ -190,7 +230,7 @@ async def send_chat_message(
images: Optional list of {"media_type": str, "data": str (base64)}
for vision content attached to this message.
Returns (ai_content, suggested_flows, session, fork_metadata, actions_data, questions_data).
Returns (ai_content, suggested_flows, session, fork_metadata, actions_data, questions_data, triage_update_data).
"""
result = await db.execute(
select(AISession).where(
@@ -253,6 +293,19 @@ async def send_chat_message(
branch_display, branch_fork_data = _parse_fork_marker(ai_content)
branch_display, branch_actions_data = _parse_actions_marker(branch_display)
branch_display, branch_questions_data = _parse_questions_marker(branch_display)
branch_display, branch_triage_data = _parse_triage_update_marker(branch_display)
# Auto-PATCH triage from branch response
if branch_triage_data:
for field in ("client_name", "asset_name", "issue_category", "triage_hypothesis"):
if field in branch_triage_data and getattr(session, field) is None:
setattr(session, field, branch_triage_data[field])
new_evidence = branch_triage_data.get("evidence_items")
if new_evidence and isinstance(new_evidence, list):
existing = list(session.evidence_items or [])
existing.extend(new_evidence)
session.evidence_items = existing
if branch_display != ai_content:
# Store stripped content in branch history
msgs[-1] = {"role": "assistant", "content": branch_display}
@@ -298,7 +351,7 @@ async def send_chat_message(
suggested_flows = extract_suggested_flows(
await rag_search(query=message, account_id=account_id, db=db, limit=8)
)
return branch_display, suggested_flows, session, branch_fork_metadata, branch_actions_data, branch_questions_data
return branch_display, suggested_flows, session, branch_fork_metadata, branch_actions_data, branch_questions_data, branch_triage_data
# Auto-title from first message if still default
if session.step_count == 0 and message.strip():
@@ -341,12 +394,29 @@ async def send_chat_message(
# Check for questions marker in AI response
display_content, questions_data = _parse_questions_marker(display_content)
# Check for triage update marker in AI response
display_content, triage_update_data = _parse_triage_update_marker(display_content)
logger.info(
"Marker parsing results — actions: %s, questions: %s, fork: %s, raw_length: %d, display_length: %d",
bool(actions_data), bool(questions_data), bool(fork_data),
"Marker parsing results — actions: %s, questions: %s, fork: %s, triage: %s, raw_length: %d, display_length: %d",
bool(actions_data), bool(questions_data), bool(fork_data), bool(triage_update_data),
len(ai_content), len(display_content),
)
# Auto-PATCH session with triage metadata if AI inferred new fields
if triage_update_data:
# Apply non-evidence fields directly (AI only fills null fields — manual edits win)
for field in ("client_name", "asset_name", "issue_category", "triage_hypothesis"):
if field in triage_update_data and getattr(session, field) is None:
setattr(session, field, triage_update_data[field])
# Append new evidence items (never modify existing)
new_evidence = triage_update_data.get("evidence_items")
if new_evidence and isinstance(new_evidence, list):
existing = list(session.evidence_items or [])
existing.extend(new_evidence)
session.evidence_items = existing
# Store DISPLAY content (markers stripped) in conversation_messages.
# The format reminder in the user message + system prompt final reminder
# are sufficient to keep the AI emitting markers on subsequent turns.
@@ -413,4 +483,4 @@ async def send_chat_message(
suggested_flows = extract_suggested_flows(rag_results)
return display_content, suggested_flows, session, fork_metadata, actions_data, questions_data
return display_content, suggested_flows, session, fork_metadata, actions_data, questions_data, triage_update_data