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,
|
ChatMessageRequest,
|
||||||
ChatMessageResponse,
|
ChatMessageResponse,
|
||||||
SaveTaskLaneRequest,
|
SaveTaskLaneRequest,
|
||||||
|
TriagePatchRequest,
|
||||||
|
TriagePatchResponse,
|
||||||
)
|
)
|
||||||
from app.services import flowpilot_engine
|
from app.services import flowpilot_engine
|
||||||
from app.services import unified_chat_service
|
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,
|
pending_task_lane=session.pending_task_lane,
|
||||||
is_branching=getattr(session, 'is_branching', False),
|
is_branching=getattr(session, 'is_branching', False),
|
||||||
active_branch_id=str(session.active_branch_id) if getattr(session, 'active_branch_id', None) else None,
|
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:
|
try:
|
||||||
from app.services.resolution_output_generator import ResolutionOutputGenerator
|
from app.services.resolution_output_generator import ResolutionOutputGenerator
|
||||||
gen = ResolutionOutputGenerator(db)
|
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:
|
except Exception:
|
||||||
logger.exception(f"Failed to generate resolution outputs for session {session_id}")
|
logger.exception(f"Failed to generate resolution outputs for session {session_id}")
|
||||||
|
|
||||||
@@ -540,6 +552,122 @@ async def save_task_lane(
|
|||||||
await db.commit()
|
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 ──
|
# ── Resume ──
|
||||||
|
|
||||||
@router.post("/{session_id}/resume", status_code=204)
|
@router.post("/{session_id}/resume", status_code=204)
|
||||||
|
|||||||
@@ -137,6 +137,28 @@ class AISession(Base):
|
|||||||
comment="Snapshot of PSA ticket data at session start",
|
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 / Escalation ──
|
||||||
resolution_summary: Mapped[Optional[str]] = mapped_column(
|
resolution_summary: Mapped[Optional[str]] = mapped_column(
|
||||||
Text, nullable=True,
|
Text, nullable=True,
|
||||||
|
|||||||
@@ -102,12 +102,20 @@ class ResolveSessionRequest(BaseModel):
|
|||||||
resolution_action: str | None = None
|
resolution_action: str | None = None
|
||||||
session_rating: int | None = Field(None, ge=1, le=5)
|
session_rating: int | None = Field(None, ge=1, le=5)
|
||||||
session_feedback: str | None = None
|
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):
|
class EscalateSessionRequest(BaseModel):
|
||||||
"""Escalate a session to another engineer."""
|
"""Escalate a session to another engineer."""
|
||||||
escalation_reason: str = Field(..., min_length=5, max_length=2000)
|
escalation_reason: str = Field(..., min_length=5, max_length=2000)
|
||||||
escalated_to_id: UUID | None = None
|
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):
|
class DocumentationStep(BaseModel):
|
||||||
@@ -231,6 +239,12 @@ class AISessionDetail(AISessionSummary):
|
|||||||
pending_task_lane: dict[str, Any] | None = None
|
pending_task_lane: dict[str, Any] | None = None
|
||||||
is_branching: bool = False
|
is_branching: bool = False
|
||||||
active_branch_id: str | None = None
|
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}
|
model_config = {"from_attributes": True}
|
||||||
|
|
||||||
@@ -276,6 +290,16 @@ class QuestionItem(BaseModel):
|
|||||||
"""A question the AI needs answered by the engineer."""
|
"""A question the AI needs answered by the engineer."""
|
||||||
text: str
|
text: str
|
||||||
context: 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):
|
class ChatMessageResponse(BaseModel):
|
||||||
@@ -285,6 +309,7 @@ class ChatMessageResponse(BaseModel):
|
|||||||
fork: ForkMetadata | None = None
|
fork: ForkMetadata | None = None
|
||||||
actions: list[ActionItem] | None = None
|
actions: list[ActionItem] | None = None
|
||||||
questions: list[QuestionItem] | None = None
|
questions: list[QuestionItem] | None = None
|
||||||
|
triage_update: TriageUpdate | None = None
|
||||||
|
|
||||||
|
|
||||||
class SaveTaskLaneRequest(BaseModel):
|
class SaveTaskLaneRequest(BaseModel):
|
||||||
@@ -307,3 +332,24 @@ class AISessionSearchResult(BaseModel):
|
|||||||
created_at: datetime
|
created_at: datetime
|
||||||
|
|
||||||
model_config = {"from_attributes": True}
|
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):
|
def __init__(self, db: AsyncSession):
|
||||||
self.db = db
|
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(
|
result = await self.db.execute(
|
||||||
select(AISession).where(AISession.id == session_id)
|
select(AISession).where(AISession.id == session_id)
|
||||||
)
|
)
|
||||||
@@ -27,7 +33,12 @@ class ResolutionOutputGenerator:
|
|||||||
if not session:
|
if not session:
|
||||||
raise ValueError(f"Session {session_id} not found")
|
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 = []
|
outputs = []
|
||||||
for output_type, prompt in [
|
for output_type, prompt in [
|
||||||
@@ -82,13 +93,41 @@ class ResolutionOutputGenerator:
|
|||||||
await self.db.flush()
|
await self.db.flush()
|
||||||
return output
|
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 = [
|
parts = [
|
||||||
f"Problem: {session.problem_summary or 'Unknown'}",
|
f"Problem: {session.problem_summary or 'Unknown'}",
|
||||||
f"Domain: {session.problem_domain or 'Unknown'}",
|
f"Domain: {session.problem_domain or 'Unknown'}",
|
||||||
f"Resolution: {session.resolution_summary or 'Not specified'}",
|
f"Resolution: {session.resolution_summary or 'Not specified'}",
|
||||||
f"Steps taken: {session.step_count}",
|
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 []
|
msgs = session.conversation_messages or []
|
||||||
if msgs:
|
if msgs:
|
||||||
parts.append("\nConversation highlights:")
|
parts.append("\nConversation highlights:")
|
||||||
|
|||||||
Reference in New Issue
Block a user