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:
@@ -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")
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:")
|
||||
|
||||
Reference in New Issue
Block a user