CI / ruff · pytest (pull_request) Successful in 23s
Single entry point app/llm/gateway.py: extract_item, extract_checkin, risk_classify, phrase, summarize_for_doctor. Two-level design: LLMProvider (transport, GigaChat) and LLMTasks (PromptedTasks over a provider, or rule-based MockTasks). Shared policy in the gateway: assert_scrubbed backstop, confidence threshold 0.5, risk failure -> NONE, forbidden-output filter with config/texts/phrases.yaml fallbacks. Refs #7 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
57 lines
2.0 KiB
Python
57 lines
2.0 KiB
Python
"""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)
|