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:
chihlasm
2026-04-01 22:30:48 +00:00
parent cb750d52f6
commit 15781baeb7
5 changed files with 270 additions and 4 deletions

View File

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

View File

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

View File

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

View File

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

View File

@@ -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:")