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}"
|
message = f"{message}\n\n[Attached document content]\n{doc_context}"
|
||||||
|
|
||||||
try:
|
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,
|
session_id=session_id,
|
||||||
user_id=user_id,
|
user_id=user_id,
|
||||||
account_id=account_id,
|
account_id=account_id,
|
||||||
@@ -353,6 +353,7 @@ async def send_chat_message(
|
|||||||
fork=fork_metadata,
|
fork=fork_metadata,
|
||||||
actions=actions_data,
|
actions=actions_data,
|
||||||
questions=questions_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
|
- Commands should be PowerShell unless context indicates Linux/Mac
|
||||||
- For GUI-only steps, omit `command`
|
- 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, \
|
**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.
|
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
|
## Using the Team's Flow Library
|
||||||
Your team has built troubleshooting flows in ResolutionFlow. When relevant flows \
|
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 \
|
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 = []
|
valid_questions = []
|
||||||
for q in questions:
|
for q in questions:
|
||||||
if isinstance(q, dict) and q.get("text"):
|
if isinstance(q, dict) and q.get("text"):
|
||||||
valid_questions.append({
|
item = {
|
||||||
"text": q["text"],
|
"text": q["text"],
|
||||||
"context": q.get("context", ""),
|
"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:
|
if not valid_questions:
|
||||||
return ai_content, None
|
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
|
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(
|
async def create_chat_session(
|
||||||
user_id: UUID,
|
user_id: UUID,
|
||||||
account_id: UUID,
|
account_id: UUID,
|
||||||
@@ -190,7 +230,7 @@ async def send_chat_message(
|
|||||||
images: Optional list of {"media_type": str, "data": str (base64)}
|
images: Optional list of {"media_type": str, "data": str (base64)}
|
||||||
for vision content attached to this message.
|
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(
|
result = await db.execute(
|
||||||
select(AISession).where(
|
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_fork_data = _parse_fork_marker(ai_content)
|
||||||
branch_display, branch_actions_data = _parse_actions_marker(branch_display)
|
branch_display, branch_actions_data = _parse_actions_marker(branch_display)
|
||||||
branch_display, branch_questions_data = _parse_questions_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:
|
if branch_display != ai_content:
|
||||||
# Store stripped content in branch history
|
# Store stripped content in branch history
|
||||||
msgs[-1] = {"role": "assistant", "content": branch_display}
|
msgs[-1] = {"role": "assistant", "content": branch_display}
|
||||||
@@ -298,7 +351,7 @@ async def send_chat_message(
|
|||||||
suggested_flows = extract_suggested_flows(
|
suggested_flows = extract_suggested_flows(
|
||||||
await rag_search(query=message, account_id=account_id, db=db, limit=8)
|
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
|
# Auto-title from first message if still default
|
||||||
if session.step_count == 0 and message.strip():
|
if session.step_count == 0 and message.strip():
|
||||||
@@ -341,12 +394,29 @@ async def send_chat_message(
|
|||||||
# Check for questions marker in AI response
|
# Check for questions marker in AI response
|
||||||
display_content, questions_data = _parse_questions_marker(display_content)
|
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(
|
logger.info(
|
||||||
"Marker parsing results — actions: %s, questions: %s, fork: %s, raw_length: %d, display_length: %d",
|
"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(actions_data), bool(questions_data), bool(fork_data), bool(triage_update_data),
|
||||||
len(ai_content), len(display_content),
|
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.
|
# Store DISPLAY content (markers stripped) in conversation_messages.
|
||||||
# The format reminder in the user message + system prompt final reminder
|
# The format reminder in the user message + system prompt final reminder
|
||||||
# are sufficient to keep the AI emitting markers on subsequent turns.
|
# 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)
|
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