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:
@@ -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,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -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 \
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user