"""Pydantic contracts for LLM outputs (SPEC ยง7). Anything the model returns is validated against these.""" from typing import Literal from pydantic import BaseModel, Field, ValidationInfo, field_validator SideEffect = Literal["nausea", "insomnia", "headache", "dizziness", "sexual", "agitation", "other"] AlcoholBucket = Literal["none", "1-2", "more"] SleepBucket = Literal["<4", "4-6", "6-8", ">8"] class ItemScore(BaseModel): """Score of a single scale item extracted from a free-text answer. `score` is bounded by 4 (PCL-5); the per-scale maximum (3 for PHQ-9/GAD-7) is enforced via the validation context key `max_score`, because the same model serves scales with different ranges. """ item: str score: int = Field(ge=0, le=4) confidence: float = Field(ge=0, le=1) evidence: str = "" @field_validator("score") @classmethod def _score_within_item_max(cls, v: int, info: ValidationInfo) -> int: max_score = (info.context or {}).get("max_score") if max_score is not None and v > max_score: raise ValueError(f"score {v} exceeds item max {max_score}") return v class CheckinFacts(BaseModel): """Facts from a free-text daily check-in (ADR-002). `None` means "not mentioned", not "no".""" mood: int | None = Field(default=None, ge=0, le=10) med_taken: bool | None = None side_effects: list[SideEffect] = Field(default_factory=list) quit_intent: bool = False alcohol: AlcoholBucket | None = None sleep_hours_bucket: SleepBucket | None = None nightmares: bool | None = None med_question: bool = False confidence: float = Field(default=0.0, ge=0, le=1) @field_validator("side_effects") @classmethod def _dedupe(cls, v: list[SideEffect]) -> list[SideEffect]: return list(dict.fromkeys(v)) class RiskVerdict(BaseModel): """Raw risk classification as returned by the model; the gateway maps it to `RiskLevel`.""" level: Literal["none", "possible", "acute"] confidence: float = Field(default=0.5, ge=0, le=1)