feat: add cockpit triage backend foundation (Phase 1)
- Migration 071: add client_name, asset_name, issue_category,
triage_hypothesis, evidence_items columns to ai_sessions
- TriageUpdate schema for AI-inferred header updates in chat responses
- QuestionItem.options field for quick-reply buttons
- PATCH /ai-sessions/{id}/triage endpoint for manual header edits
- POST /ai-sessions/{id}/handoff-draft streaming endpoint for conclude modal
- Structured handoff fields (root_cause, steps_taken, recommendations)
on resolve/escalate requests, passed through to ResolutionOutputGenerator
- Triage fields exposed in AISessionDetail response for session resume
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -49,6 +49,8 @@ from app.schemas.ai_session import (
|
||||
ChatMessageRequest,
|
||||
ChatMessageResponse,
|
||||
SaveTaskLaneRequest,
|
||||
TriagePatchRequest,
|
||||
TriagePatchResponse,
|
||||
)
|
||||
from app.services import flowpilot_engine
|
||||
from app.services import unified_chat_service
|
||||
@@ -120,6 +122,11 @@ def _build_session_detail(session: AISession) -> AISessionDetail:
|
||||
pending_task_lane=session.pending_task_lane,
|
||||
is_branching=getattr(session, 'is_branching', False),
|
||||
active_branch_id=str(session.active_branch_id) if getattr(session, 'active_branch_id', None) else None,
|
||||
client_name=getattr(session, 'client_name', None),
|
||||
asset_name=getattr(session, 'asset_name', None),
|
||||
issue_category=getattr(session, 'issue_category', None),
|
||||
triage_hypothesis=getattr(session, 'triage_hypothesis', None),
|
||||
evidence_items=getattr(session, 'evidence_items', None),
|
||||
)
|
||||
|
||||
|
||||
@@ -442,7 +449,12 @@ async def resolve_session(
|
||||
try:
|
||||
from app.services.resolution_output_generator import ResolutionOutputGenerator
|
||||
gen = ResolutionOutputGenerator(db)
|
||||
await gen.generate_all(session_id)
|
||||
await gen.generate_all(
|
||||
session_id,
|
||||
root_cause=data.root_cause,
|
||||
steps_taken=data.steps_taken,
|
||||
recommendations=data.recommendations,
|
||||
)
|
||||
except Exception:
|
||||
logger.exception(f"Failed to generate resolution outputs for session {session_id}")
|
||||
|
||||
@@ -540,6 +552,122 @@ async def save_task_lane(
|
||||
await db.commit()
|
||||
|
||||
|
||||
# ── Triage Metadata ──
|
||||
|
||||
@router.patch("/{session_id}/triage", response_model=TriagePatchResponse)
|
||||
@limiter.limit("30/minute")
|
||||
async def update_triage(
|
||||
request: Request,
|
||||
session_id: UUID,
|
||||
body: TriagePatchRequest,
|
||||
current_user: Annotated[User, Depends(get_current_active_user)],
|
||||
db: Annotated[AsyncSession, Depends(get_db)],
|
||||
_: None = Depends(require_engineer_or_admin),
|
||||
):
|
||||
"""Update triage metadata on a session (incident header fields)."""
|
||||
session = await db.get(AISession, session_id)
|
||||
if not session:
|
||||
raise HTTPException(status_code=404, detail="Session not found")
|
||||
if session.user_id != current_user.id:
|
||||
raise HTTPException(status_code=403, detail="Not your session")
|
||||
|
||||
patch_data = body.model_dump(exclude_unset=True)
|
||||
for field, value in patch_data.items():
|
||||
setattr(session, field, value)
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(session)
|
||||
|
||||
return TriagePatchResponse(
|
||||
id=session.id,
|
||||
client_name=session.client_name,
|
||||
asset_name=session.asset_name,
|
||||
issue_category=session.issue_category,
|
||||
triage_hypothesis=session.triage_hypothesis,
|
||||
evidence_items=session.evidence_items,
|
||||
)
|
||||
|
||||
|
||||
# ── Handoff Draft ──
|
||||
|
||||
@router.post("/{session_id}/handoff-draft")
|
||||
@limiter.limit("10/minute")
|
||||
async def handoff_draft(
|
||||
request: Request,
|
||||
session_id: UUID,
|
||||
current_user: Annotated[User, Depends(get_current_active_user)],
|
||||
db: Annotated[AsyncSession, Depends(get_db)],
|
||||
_: None = Depends(require_engineer_or_admin),
|
||||
):
|
||||
"""Stream a structured handoff draft for the conclude modal."""
|
||||
from fastapi.responses import StreamingResponse
|
||||
from app.services.assistant_chat_service import _call_ai
|
||||
|
||||
session = await db.get(AISession, session_id)
|
||||
if not session:
|
||||
raise HTTPException(status_code=404, detail="Session not found")
|
||||
if session.user_id != current_user.id:
|
||||
raise HTTPException(status_code=403, detail="Not your session")
|
||||
|
||||
# Build context from session data
|
||||
context_parts = [
|
||||
f"Problem: {session.problem_summary or 'Unknown'}",
|
||||
f"Domain: {session.problem_domain or 'Unknown'}",
|
||||
f"Client: {session.client_name or 'Unknown'}",
|
||||
f"Asset: {session.asset_name or 'Unknown'}",
|
||||
f"Hypothesis: {session.triage_hypothesis or 'None'}",
|
||||
]
|
||||
|
||||
if session.evidence_items:
|
||||
context_parts.append("\nEvidence collected:")
|
||||
for item in session.evidence_items:
|
||||
status_icon = {"confirmed": "✓", "ruled_out": "✗", "pending": "?"}.get(item.get("status", ""), "?")
|
||||
context_parts.append(f" {status_icon} {item.get('text', '')}")
|
||||
|
||||
# Include task lane steps if available
|
||||
if session.pending_task_lane:
|
||||
actions = session.pending_task_lane.get("actions", [])
|
||||
if actions:
|
||||
context_parts.append("\nSteps taken:")
|
||||
for a in actions:
|
||||
context_parts.append(f" - {a.get('label', '')}")
|
||||
|
||||
# Include last 20 conversation messages
|
||||
msgs = session.conversation_messages or []
|
||||
if msgs:
|
||||
context_parts.append("\nRecent conversation:")
|
||||
for msg in msgs[-20:]:
|
||||
role = msg.get("role", "unknown")
|
||||
content = msg.get("content", "")[:300]
|
||||
context_parts.append(f" [{role}]: {content}")
|
||||
|
||||
context = "\n".join(context_parts)
|
||||
|
||||
prompt = (
|
||||
"Generate a structured handoff summary for this troubleshooting session.\n"
|
||||
"Return ONLY valid JSON with exactly these four fields:\n"
|
||||
'{"root_cause": "...", "resolution": "...", "steps_taken": ["step1", "step2"], "recommendations": "..."}\n\n'
|
||||
f"Session context:\n{context}"
|
||||
)
|
||||
|
||||
async def generate():
|
||||
try:
|
||||
content, _, _ = await _call_ai(
|
||||
system_base="You are a concise technical documentation assistant for MSP teams. Return only JSON.",
|
||||
rag_context="",
|
||||
history=[],
|
||||
new_message=prompt,
|
||||
max_tokens=1024,
|
||||
)
|
||||
yield f"data: {content}\n\n"
|
||||
except Exception as e:
|
||||
logger.exception(f"Handoff draft generation failed for session {session_id}")
|
||||
import json
|
||||
yield f"data: {json.dumps({'error': str(e)})}\n\n"
|
||||
|
||||
return StreamingResponse(generate(), media_type="text/event-stream")
|
||||
|
||||
|
||||
# ── Resume ──
|
||||
|
||||
@router.post("/{session_id}/resume", status_code=204)
|
||||
|
||||
Reference in New Issue
Block a user