Files
resolutionflow/backend/app/schemas/ai_session.py
chihlasm 80af408f2d feat: persist task lane user responses to backend
Add PUT /ai-sessions/{id}/task-lane endpoint that saves the full task
lane state (AI questions/actions + user's in-progress responses) to
the pending_task_lane JSONB column. TaskLane debounce-saves to the
backend every 2s after changes. On session load, user responses are
restored from the backend into sessionStorage so TaskLane picks them
up on mount. Users can now close the browser, come back later, and
find their task lane exactly where they left it.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-28 19:11:13 +00:00

309 lines
8.9 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
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
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)$",
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] = []
actions: list[ActionItem] = []
responses: list[dict[str, Any]] = Field(
default_factory=list,
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}