Files
resolutionflow/backend/app/schemas/session.py
chihlasm a0ba253428 feat: add backend validation for fallback steps (Task 16)
Validate fallback_steps in procedural flow validation: required fields,
no nested fallback_steps, no duplicate IDs. Add FallbackStepRecord schema
and fallback_decisions field to SessionResponse.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-16 13:34:22 -04:00

183 lines
6.2 KiB
Python

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
# Fallback step decisions
fallback_decisions: list[dict[str, Any]] = Field(default_factory=list)
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 FallbackStepRecord(BaseModel):
parent_step_id: str
fallback_step_id: str
completed_at: str | None = None
notes: str | None = None
outcome: Literal['resolved', 'not_resolved', 'skipped']
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