"""Pydantic schemas for the /l1/* endpoint surface.""" from datetime import datetime from typing import Any, Literal, Optional from uuid import UUID from pydantic import BaseModel, Field, model_validator class IntakeRequest(BaseModel): problem_statement: str = Field(..., min_length=1) customer_name: Optional[str] = None customer_contact: Optional[str] = None # When set, bypass the matcher and start this published flow directly (the # suggest card's "Use this flow" — the client already holds the flow id). flow_id: Optional[UUID] = None # When True, start an ad-hoc free-form walk (the out_of_scope prompt's # "Walk it ad-hoc" fallback). Mutually informative with flow_id/force_build; # flow_id takes precedence if both are somehow set. adhoc: bool = False force_build: bool = False # Outcomes that start a session (and therefore must carry session_id + ticket). _SESSION_OUTCOMES = {"matched", "build", "adhoc"} class IntakeResponse(BaseModel): outcome: Literal["matched", "suggest", "out_of_scope", "build", "adhoc"] session_id: Optional[UUID] = None session_kind: Optional[Literal["flow", "proposal", "adhoc", "ai_build"]] = None ticket_id: Optional[str] = None ticket_kind: Optional[Literal["psa", "internal"]] = None flow_id: Optional[UUID] = None # for 'matched' near_miss: Optional[dict] = None # for 'suggest' category: Optional[str] = None # for 'out_of_scope' @model_validator(mode="after") def _check_outcome_invariants(self) -> "IntakeResponse": """Restore the per-outcome contract the frontend depends on: a session outcome MUST carry the session_id + ticket the walker navigates to, so a backend regression surfaces here instead of as /l1/walk/undefined.""" if self.outcome in _SESSION_OUTCOMES: if self.session_id is None or self.ticket_id is None: raise ValueError( f"intake outcome '{self.outcome}' requires session_id + ticket_id" ) return self class NextNodeRequest(BaseModel): node_id: Optional[str] = None node_text: Optional[str] = None # rendered text of the node being answered (carry-forward Task 8) answer: Optional[str] = None # 'yes' | 'no' for questions; None acks an instruction note: Optional[str] = None class NextNodeResponse(BaseModel): node: dict session_status: str class StepRequest(BaseModel): node_id: str question: str answer: str note: Optional[str] = None class NotesRequest(BaseModel): notes: list[dict[str, Any]] class ResolveRequest(BaseModel): helpful: bool resolution_notes: str class EscalateRequest(BaseModel): reason: Optional[str] = None reason_category: str = Field(..., min_length=1) class EscalateWithoutWalkRequest(BaseModel): problem_statement: str = Field(..., min_length=1) customer_name: Optional[str] = None customer_contact: Optional[str] = None reason_category: str = Field(..., min_length=1) reason: Optional[str] = None class WalkSessionResponse(BaseModel): id: UUID session_kind: str category: Optional[str] = None problem_text: Optional[str] = None flow_id: Optional[UUID] flow_proposal_id: Optional[UUID] current_node_id: Optional[str] walked_path: list[dict[str, Any]] walk_notes: list[dict[str, Any]] status: str started_at: datetime last_step_at: datetime resolved_at: Optional[datetime] class QueueRow(BaseModel): ticket_id: str ticket_kind: Literal["psa", "internal"] problem_statement: Optional[str] = None customer_name: Optional[str] = None status: str created_at: Optional[datetime] = None