From 15781baeb7302c4acd6e73a173b2603045eeac4d Mon Sep 17 00:00:00 2001 From: chihlasm Date: Wed, 1 Apr 2026 22:30:48 +0000 Subject: [PATCH] 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 --- .../071_add_triage_fields_to_ai_sessions.py | 31 +++++ backend/app/api/endpoints/ai_sessions.py | 130 +++++++++++++++++- backend/app/models/ai_session.py | 22 +++ backend/app/schemas/ai_session.py | 46 +++++++ .../services/resolution_output_generator.py | 45 +++++- 5 files changed, 270 insertions(+), 4 deletions(-) create mode 100644 backend/alembic/versions/071_add_triage_fields_to_ai_sessions.py diff --git a/backend/alembic/versions/071_add_triage_fields_to_ai_sessions.py b/backend/alembic/versions/071_add_triage_fields_to_ai_sessions.py new file mode 100644 index 00000000..b4668735 --- /dev/null +++ b/backend/alembic/versions/071_add_triage_fields_to_ai_sessions.py @@ -0,0 +1,31 @@ +"""add triage fields to ai_sessions for cockpit harness + +Revision ID: 071 +Revises: 070 +Create Date: 2026-04-01 +""" +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects.postgresql import JSONB + + +revision = "071" +down_revision = "070" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.add_column("ai_sessions", sa.Column("client_name", sa.String(255), nullable=True)) + op.add_column("ai_sessions", sa.Column("asset_name", sa.String(255), nullable=True)) + op.add_column("ai_sessions", sa.Column("issue_category", sa.String(100), nullable=True)) + op.add_column("ai_sessions", sa.Column("triage_hypothesis", sa.Text(), nullable=True)) + op.add_column("ai_sessions", sa.Column("evidence_items", JSONB(), nullable=True)) + + +def downgrade() -> None: + op.drop_column("ai_sessions", "evidence_items") + op.drop_column("ai_sessions", "triage_hypothesis") + op.drop_column("ai_sessions", "issue_category") + op.drop_column("ai_sessions", "asset_name") + op.drop_column("ai_sessions", "client_name") diff --git a/backend/app/api/endpoints/ai_sessions.py b/backend/app/api/endpoints/ai_sessions.py index 38ca0286..d4876be1 100644 --- a/backend/app/api/endpoints/ai_sessions.py +++ b/backend/app/api/endpoints/ai_sessions.py @@ -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) diff --git a/backend/app/models/ai_session.py b/backend/app/models/ai_session.py index 8bf1684a..5504abbf 100644 --- a/backend/app/models/ai_session.py +++ b/backend/app/models/ai_session.py @@ -137,6 +137,28 @@ class AISession(Base): comment="Snapshot of PSA ticket data at session start", ) + # ── Triage / Cockpit Header ── + client_name: Mapped[Optional[str]] = mapped_column( + String(255), nullable=True, + comment="MSP client name for incident header (AI-inferred or manual)", + ) + asset_name: Mapped[Optional[str]] = mapped_column( + String(255), nullable=True, + comment="Device, asset, or user being worked on", + ) + issue_category: Mapped[Optional[str]] = mapped_column( + String(100), nullable=True, + comment="Human-readable category (e.g. DNS / Networking)", + ) + triage_hypothesis: Mapped[Optional[str]] = mapped_column( + Text, nullable=True, + comment="Current working hypothesis — AI-updated + engineer-editable", + ) + evidence_items: Mapped[Optional[list[dict[str, Any]]]] = mapped_column( + JSONB, nullable=True, + comment='What We Know list: [{"text": str, "status": "confirmed"|"ruled_out"|"pending"}]', + ) + # ── Resolution / Escalation ── resolution_summary: Mapped[Optional[str]] = mapped_column( Text, nullable=True, diff --git a/backend/app/schemas/ai_session.py b/backend/app/schemas/ai_session.py index b66afbbf..8768e48f 100644 --- a/backend/app/schemas/ai_session.py +++ b/backend/app/schemas/ai_session.py @@ -102,12 +102,20 @@ class ResolveSessionRequest(BaseModel): resolution_action: str | None = None session_rating: int | None = Field(None, ge=1, le=5) session_feedback: str | None = None + # Structured handoff fields (from cockpit conclude modal) + root_cause: str | None = None + steps_taken: list[str] | None = None + recommendations: str | None = None class EscalateSessionRequest(BaseModel): """Escalate a session to another engineer.""" escalation_reason: str = Field(..., min_length=5, max_length=2000) escalated_to_id: UUID | None = None + # Structured handoff fields (from cockpit conclude modal) + root_cause: str | None = None + steps_taken: list[str] | None = None + recommendations: str | None = None class DocumentationStep(BaseModel): @@ -231,6 +239,12 @@ class AISessionDetail(AISessionSummary): pending_task_lane: dict[str, Any] | None = None is_branching: bool = False active_branch_id: str | None = None + # Triage / cockpit header fields + client_name: str | None = None + asset_name: str | None = None + issue_category: str | None = None + triage_hypothesis: str | None = None + evidence_items: list[dict[str, Any]] | None = None model_config = {"from_attributes": True} @@ -276,6 +290,16 @@ class QuestionItem(BaseModel): """A question the AI needs answered by the engineer.""" text: str context: str = "" + options: list[str] | None = None # quick-reply button labels; null = free-text input + + +class TriageUpdate(BaseModel): + """AI-inferred triage metadata returned with chat responses.""" + client_name: str | None = None + asset_name: str | None = None + issue_category: str | None = None + triage_hypothesis: str | None = None + evidence_items: list[dict[str, Any]] | None = None # appends to existing list class ChatMessageResponse(BaseModel): @@ -285,6 +309,7 @@ class ChatMessageResponse(BaseModel): fork: ForkMetadata | None = None actions: list[ActionItem] | None = None questions: list[QuestionItem] | None = None + triage_update: TriageUpdate | None = None class SaveTaskLaneRequest(BaseModel): @@ -307,3 +332,24 @@ class AISessionSearchResult(BaseModel): created_at: datetime model_config = {"from_attributes": True} + + +# ── Triage / Cockpit ── + +class TriagePatchRequest(BaseModel): + """Update triage metadata on a session (incident header fields).""" + client_name: str | None = None + asset_name: str | None = None + issue_category: str | None = None + triage_hypothesis: str | None = None + evidence_items: list[dict[str, Any]] | None = None + + +class TriagePatchResponse(BaseModel): + """Updated triage metadata after a PATCH.""" + id: UUID + client_name: str | None = None + asset_name: str | None = None + issue_category: str | None = None + triage_hypothesis: str | None = None + evidence_items: list[dict[str, Any]] | None = None diff --git a/backend/app/services/resolution_output_generator.py b/backend/app/services/resolution_output_generator.py index 4d8f3e3d..c3d5f690 100644 --- a/backend/app/services/resolution_output_generator.py +++ b/backend/app/services/resolution_output_generator.py @@ -19,7 +19,13 @@ class ResolutionOutputGenerator: def __init__(self, db: AsyncSession): self.db = db - async def generate_all(self, session_id: UUID) -> list[SessionResolutionOutput]: + async def generate_all( + self, + session_id: UUID, + root_cause: str | None = None, + steps_taken: list[str] | None = None, + recommendations: str | None = None, + ) -> list[SessionResolutionOutput]: result = await self.db.execute( select(AISession).where(AISession.id == session_id) ) @@ -27,7 +33,12 @@ class ResolutionOutputGenerator: if not session: raise ValueError(f"Session {session_id} not found") - context = self._build_session_context(session) + context = self._build_session_context( + session, + root_cause=root_cause, + steps_taken=steps_taken, + recommendations=recommendations, + ) outputs = [] for output_type, prompt in [ @@ -82,13 +93,41 @@ class ResolutionOutputGenerator: await self.db.flush() return output - def _build_session_context(self, session: AISession) -> str: + def _build_session_context( + self, + session: AISession, + root_cause: str | None = None, + steps_taken: list[str] | None = None, + recommendations: str | None = None, + ) -> str: parts = [ f"Problem: {session.problem_summary or 'Unknown'}", f"Domain: {session.problem_domain or 'Unknown'}", f"Resolution: {session.resolution_summary or 'Not specified'}", f"Steps taken: {session.step_count}", ] + + # Structured handoff fields from cockpit conclude modal + if root_cause: + parts.append(f"Root cause: {root_cause}") + if steps_taken: + parts.append("Steps performed:") + for step in steps_taken: + parts.append(f" - {step}") + if recommendations: + parts.append(f"Recommendations: {recommendations}") + + # Triage metadata + if getattr(session, 'client_name', None): + parts.append(f"Client: {session.client_name}") + if getattr(session, 'triage_hypothesis', None): + parts.append(f"Hypothesis: {session.triage_hypothesis}") + if getattr(session, 'evidence_items', None): + parts.append("Evidence collected:") + for item in session.evidence_items: + icon = {"confirmed": "✓", "ruled_out": "✗", "pending": "?"}.get(item.get("status", ""), "?") + parts.append(f" {icon} {item.get('text', '')}") + msgs = session.conversation_messages or [] if msgs: parts.append("\nConversation highlights:")