feat(bot): LLM gateway with GigaChat and offline mock providers
CI / ruff · pytest (pull_request) Successful in 23s
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>
This commit is contained in:
@@ -0,0 +1,56 @@
|
||||
"""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)
|
||||
Reference in New Issue
Block a user