diff --git a/backend/app/api/endpoints/ai_sessions.py b/backend/app/api/endpoints/ai_sessions.py index d4876be1..2995d671 100644 --- a/backend/app/api/endpoints/ai_sessions.py +++ b/backend/app/api/endpoints/ai_sessions.py @@ -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, ) diff --git a/backend/app/services/assistant_chat_service.py b/backend/app/services/assistant_chat_service.py index 49c44ffe..b29cc3a3 100644 --- a/backend/app/services/assistant_chat_service.py +++ b/backend/app/services/assistant_chat_service.py @@ -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 \ diff --git a/backend/app/services/unified_chat_service.py b/backend/app/services/unified_chat_service.py index 44236e66..ef2a0ff5 100644 --- a/backend/app/services/unified_chat_service.py +++ b/backend/app/services/unified_chat_service.py @@ -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