Files
resolutionflow/backend/app/schemas/ai_session.py
chihlasm f4143e52a1 feat: overhaul session documentation, PSA notes, and client communications
- Reformat PSA resolution/escalation notes: clean single-line header,
  steps with engineer responses inline, remove duplicate timing blocks,
  remove AI confidence section, add follow-up recommendations
- Standardize time display to decimal hours (e.g. 0.25 hrs) across all
  note formatters and status update context
- Add follow_up_recommendations to SessionDocumentation schema and
  surface in SessionDocView; extracted from resolution suggestion steps
- Add _build_what_we_know() helper: uses session.evidence_items when
  cockpit branch merges, falls back to deriving findings from steps
- Fix option label lookup in generate_status_update (was passing raw
  machine values to AI instead of human-readable labels)
- Add 'What We Know' section to status update ticket notes prompt
- Improve _build_session_context in resolution_output_generator to
  include intake text and full step details instead of truncated chat
- Add request_info audience type: client-facing information request
  that skips the length step and generates a numbered question list
- Improve client_update and email_draft prompts with per-context
  guidance (status/resolution/escalation) and fix escalation subject
  line from 'Specialist Review' to 'Specialist Assistance'

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-05 15:18:31 +00:00

311 lines
9.1 KiB
Python

"""Pydantic schemas for FlowPilot AI sessions."""
from __future__ import annotations
from typing import Optional, Any
from uuid import UUID
from datetime import datetime
from pydantic import BaseModel, Field
# ── Intake ──
class AISessionCreateRequest(BaseModel):
"""Start a new FlowPilot or chat session."""
session_type: str = Field(
"guided",
pattern="^(guided|chat)$",
description="Session type: guided (FlowPilot) or chat (assistant)",
)
intake_type: str = Field(
"free_text",
pattern="^(free_text|psa_ticket|screenshot|log_paste|combined)$",
)
intake_content: dict[str, Any] = Field(
...,
description=(
"Intake payload. Shape depends on intake_type: "
"{text: str} for free_text, "
"{text?: str, image_urls?: list[str]} for screenshot, "
"{text?: str, log_content?: str} for log_paste, "
"{ticket_id: str, psa_connection_id: str} for psa_ticket, "
"any combination for combined."
),
)
psa_ticket_id: Optional[str] = None
psa_connection_id: Optional[UUID] = None
class AISessionCreateResponse(BaseModel):
"""Response after starting a session — includes the first FlowPilot step."""
session_id: UUID
status: str
confidence_tier: str
problem_summary: str | None = None
problem_domain: str | None = None
matched_flow_id: UUID | None = None
matched_flow_name: str | None = None
match_score: float | None = None
first_step: AISessionStepResponse
psa_context_status: str | None = None # loaded | unavailable | None (no PSA)
# ── Step interaction ──
class StepOptionSchema(BaseModel):
"""A selectable option presented to the engineer."""
label: str
value: str
followup_hint: str | None = None
class AISessionStepResponse(BaseModel):
"""A FlowPilot step rendered in the session UI."""
step_id: UUID
step_order: int
step_type: str
content: dict[str, Any]
context_message: str | None = None
options: list[StepOptionSchema] = []
allow_free_text: bool = True
allow_skip: bool = True
confidence_tier: str
confidence_score: float
model_config = {"from_attributes": True}
class StepResponseRequest(BaseModel):
"""Engineer's response to a FlowPilot step."""
selected_option: str | None = None
free_text_input: str | None = None
was_skipped: bool = False
action_result: dict[str, Any] | None = None
class StepResponseResponse(BaseModel):
"""FlowPilot's next step after processing the engineer's response."""
session_id: UUID
status: str
confidence_tier: str
confidence_score: float
next_step: AISessionStepResponse | None = None
resolution_suggested: bool = False
resolution_summary: str | None = None
# ── Resolution / Escalation ──
class ResolveSessionRequest(BaseModel):
"""Close a session as resolved."""
resolution_summary: str = Field(..., min_length=5, max_length=2000)
resolution_action: str | None = None
session_rating: int | None = Field(None, ge=1, le=5)
session_feedback: 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
class DocumentationStep(BaseModel):
"""A step in the documentation trail."""
step_number: int
step_type: str
description: str
engineer_response: str | None = None
outcome: str | None = None
class SessionDocumentation(BaseModel):
"""Auto-generated session documentation."""
problem_summary: str
problem_domain: str | None = None
intake_summary: str
diagnostic_steps: list[DocumentationStep]
resolution_summary: str | None = None
escalation_reason: str | None = None
follow_up_recommendations: list[str] = []
total_steps: int
duration_display: str | None = None
generated_at: datetime
class SessionCloseResponse(BaseModel):
"""Response after resolving or escalating."""
session_id: UUID
status: str
documentation: SessionDocumentation | None = None
psa_push_status: str = "no_psa" # sent | pending_retry | no_psa | failed
psa_push_error: str | None = None
member_mapping_warning: str | None = None
class StatusUpdateRequest(BaseModel):
"""Generate a mid-session or post-session status update."""
audience: str = Field(
...,
pattern="^(ticket_notes|client_update|email_draft|request_info)$",
description="Who is this update for?",
)
length: str = Field(
"detailed",
pattern="^(quick|detailed)$",
description="Quick (1-2 sentences) or detailed breakdown",
)
context: str = Field(
"status",
pattern="^(status|resolution|escalation)$",
description="What type of communication: mid-session status, resolution close-out, or escalation handoff",
)
class StatusUpdateResponse(BaseModel):
"""Generated status update content."""
content: str
audience: str
length: str
context: str
session_status: str
steps_completed: int
time_spent_display: str | None = None
client_name: str | None = None
generated_at: datetime
class RateSessionRequest(BaseModel):
"""Submit post-session rating."""
rating: int = Field(..., ge=1, le=5)
feedback: str | None = None
class PickupSessionRequest(BaseModel):
"""Pick up an escalated session as a new engineer."""
resume_mode: str = Field("continue", pattern="^(continue|fresh)$")
additional_context: str | None = None
class LinkTicketRequest(BaseModel):
"""Link a PSA ticket to an in-progress session."""
psa_ticket_id: str
psa_connection_id: UUID
# ── List / Detail ──
class AISessionSummary(BaseModel):
"""Compact session for list views."""
id: UUID
session_type: str = "guided"
title: str | None = None
status: str
intake_type: str
problem_summary: str | None = None
problem_domain: str | None = None
confidence_tier: str
step_count: int
session_rating: int | None = None
psa_ticket_id: str | None = None
escalation_reason: str | None = None
created_at: datetime
resolved_at: datetime | None = None
model_config = {"from_attributes": True}
class AISessionDetail(AISessionSummary):
"""Full session detail with steps (guided) or messages (chat)."""
intake_content: dict[str, Any]
matched_flow_id: UUID | None = None
match_score: float | None = None
resolution_summary: str | None = None
resolution_action: str | None = None
escalation_reason: str | None = None
session_feedback: str | None = None
psa_ticket_id: str | None = None
psa_connection_id: UUID | None = None
ticket_data: dict[str, Any] | None = None
steps: list[AISessionStepResponse] = []
conversation_messages: list[dict[str, Any]] = [] # Chat sessions store messages here
pending_task_lane: dict[str, Any] | None = None
is_branching: bool = False
active_branch_id: str | None = None
model_config = {"from_attributes": True}
# ── Chat session ──
class ChatSessionCreateResponse(BaseModel):
"""Response after creating a chat session on ai_sessions."""
session_id: UUID
session_type: str = "chat"
title: str
status: str = "active"
class ChatMessageRequest(BaseModel):
"""Send a message in a chat session."""
message: str = Field(..., min_length=1, max_length=8000)
upload_ids: list[UUID] = Field(default_factory=list, max_length=10)
class ForkBranchInfo(BaseModel):
"""Branch info returned when a fork is created."""
branch_id: str
label: str
class ForkMetadata(BaseModel):
"""Metadata returned when the AI suggests a diagnostic fork."""
fork_point_id: str
fork_reason: str
branches: list[ForkBranchInfo]
active_branch_id: str
class ActionItem(BaseModel):
"""A single action item for the engineer."""
label: str
command: str | None = None
description: str = ""
class QuestionItem(BaseModel):
"""A question the AI needs answered by the engineer."""
text: str
context: str = ""
class ChatMessageResponse(BaseModel):
"""AI response to a chat message."""
content: str
suggested_flows: list[dict[str, Any]] = []
fork: ForkMetadata | None = None
actions: list[ActionItem] | None = None
questions: list[QuestionItem] | None = None
class SaveTaskLaneRequest(BaseModel):
"""Save the full task lane state (AI items + user responses)."""
questions: list[QuestionItem] = Field(default_factory=list, max_length=50)
actions: list[ActionItem] = Field(default_factory=list, max_length=50)
responses: list[dict[str, Any]] = Field(
default_factory=list,
max_length=100,
description="User's in-progress task responses with state/value",
)
class AISessionSearchResult(BaseModel):
"""Lightweight session result for Command Palette / autocomplete."""
id: UUID
problem_summary: str | None = None
problem_domain: str | None = None
status: str
created_at: datetime
model_config = {"from_attributes": True}