from datetime import datetime from typing import Optional, Any, Literal from uuid import UUID from pydantic import BaseModel, Field, validator SessionOutcome = Literal["resolved", "escalated", "workaround", "unresolved", "cancelled", "resolved_externally"] class CustomStepSchema(BaseModel): """Enhanced custom step with source tracking. Backward compatible: old sessions without new fields load with defaults. """ type: str # "decision" | "action" | "solution" content: str notes: Optional[str] = None # Source tracking (new fields, optional for backward compatibility) source: Literal["ad-hoc", "step-library", "forked-tree"] = "ad-hoc" source_step_id: Optional[UUID] = None inserted_at: Optional[datetime] = None inserted_after_node_id: Optional[str] = None class DecisionRecord(BaseModel): node_id: str question: Optional[str] = None answer: Optional[str] = None action_performed: Optional[str] = None notes: Optional[str] = None command_output: Optional[str] = Field(None, max_length=10000) automation_used: Optional[bool] = False timestamp: datetime entered_at: Optional[datetime] = None exited_at: Optional[datetime] = None duration_seconds: Optional[int] = Field(None, ge=0) attachments: list[str] = Field(default_factory=list) class SessionCreate(BaseModel): tree_id: UUID ticket_number: Optional[str] = Field(None, max_length=100) client_name: Optional[str] = Field(None, max_length=255) session_variables: Optional[dict[str, str]] = Field(None, description="Intake form values for procedural flows") class SessionUpdate(BaseModel): path_taken: Optional[list[str]] = None decisions: Optional[list[DecisionRecord]] = None custom_steps: Optional[list[CustomStepSchema]] = None ticket_number: Optional[str] = Field(None, max_length=100) client_name: Optional[str] = Field(None, max_length=255) scratchpad: Optional[str] = None next_steps: Optional[str] = None session_variables: Optional[dict[str, str]] = None class PrepareSessionRequest(BaseModel): """Create a prepared session with pre-filled variables and optional assignee.""" tree_id: UUID session_variables: Optional[dict[str, str]] = Field(None, description="Pre-filled intake form values") assigned_to_id: Optional[UUID] = Field(None, description="User ID of the engineer to assign this session to") ticket_number: Optional[str] = Field(None, max_length=100) client_name: Optional[str] = Field(None, max_length=255) class SessionResponse(BaseModel): id: UUID tree_id: UUID user_id: UUID tree_snapshot: dict[str, Any] path_taken: list[str] decisions: list[dict[str, Any]] custom_steps: list[dict[str, Any]] = Field(default_factory=list) started_at: Optional[datetime] = None completed_at: Optional[datetime] = None outcome: Optional[SessionOutcome] = None outcome_notes: Optional[str] = None next_steps: str = "" ticket_number: Optional[str] = None client_name: Optional[str] = None exported: bool scratchpad: str = "" session_variables: dict[str, str] = Field(default_factory=dict) # Prepared session fields prepared_by_id: Optional[UUID] = None assigned_to_id: Optional[UUID] = None @validator('scratchpad', 'next_steps', pre=True, always=True) def normalize_text_fields(cls, v): return v or "" batch_id: Optional[UUID] = None target_label: Optional[str] = None # PSA ticket link psa_ticket_id: Optional[str] = None psa_connection_id: Optional[UUID] = None class Config: from_attributes = True class SessionExport(BaseModel): format: str = Field(default="markdown", pattern="^(text|markdown|html|psa)$") include_timestamps: bool = True include_tree_info: bool = True # Phase A include_outcome_notes: bool = True include_next_steps: bool = True max_step_index: Optional[int] = Field(None, ge=1, description="1-based inclusive step cutoff") # Phase B include_summary: bool = False detail_level: Literal["standard", "full"] = "standard" # Phase C redaction_mode: Literal["none", "mask"] = "none" class SessionComplete(BaseModel): outcome: SessionOutcome outcome_notes: Optional[str] = None next_steps: Optional[str] = None class SessionVariablesUpdate(BaseModel): """Partial update to session variables (dict merge).""" variables: dict[str, str] = Field(..., description="Key-value pairs to merge into session_variables") class ScratchpadUpdate(BaseModel): scratchpad: str class SaveAsTreeRequest(BaseModel): """Request to save a session as a tree.""" tree_name: Optional[str] = Field(None, max_length=255, description="Custom name for the saved tree (auto-generated if not provided)") description: Optional[str] = Field(None, description="Description for the saved tree") status: Literal["draft", "published"] = Field("draft", description="Status of the saved tree") class SaveAsTreeResponse(BaseModel): """Response after saving a session as a tree.""" tree_id: UUID tree_name: str message: str # ── PSA ticket link ────────────────────────────────────────────────── class TicketLinkRequest(BaseModel): """Link or unlink a PSA ticket to a session.""" psa_ticket_id: Optional[str] = None # null to unlink class PSATicketResponse(BaseModel): """PSA ticket details returned when linking.""" id: str summary: str company_name: Optional[str] = None board_name: Optional[str] = None status_name: Optional[str] = None priority_name: Optional[str] = None class TicketLinkResponse(BaseModel): """Response after linking/unlinking a ticket.""" session_id: str psa_ticket_id: Optional[str] = None ticket: Optional[PSATicketResponse] = None