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

@@ -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