Files
ElebobaandClaude Opus 5.5 924a51c483
CI / ruff · pytest (pull_request) Successful in 23s
feat(bot): LLM gateway with GigaChat and offline mock providers
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>
2026-10-03 15:00:41 +03:00

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)