feat(bot): LLM gateway with GigaChat and offline mock providers
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:
2026-10-03 15:00:41 +03:00
co-authored by Claude Opus 5.5
parent 84317bcfce
commit 924a51c483
12 changed files with 1419 additions and 0 deletions
+67
View File
@@ -0,0 +1,67 @@
"""Template texts from `config/texts/phrases.yaml`: base meaning for `phrase`, mock output, fallbacks."""
from collections.abc import Mapping
from functools import lru_cache
from pathlib import Path
from typing import Any
import yaml
from pydantic import BaseModel, Field
from app.config import get_settings
DEFAULT_INTENT = "default"
class SummaryTexts(BaseModel):
empty: str
labels: dict[str, str] = Field(default_factory=dict)
class PhraseTexts(BaseModel):
phrases: dict[str, str]
summary: SummaryTexts
class _BlankMissing(dict[str, Any]):
def __missing__(self, key: str) -> str:
return ""
@lru_cache
def load_texts(path: Path | None = None) -> PhraseTexts:
path = path or get_settings().config_dir / "texts" / "phrases.yaml"
texts = PhraseTexts.model_validate(yaml.safe_load(path.read_text(encoding="utf-8")))
if DEFAULT_INTENT not in texts.phrases:
raise ValueError(f"{path}: phrases.{DEFAULT_INTENT} is required")
return texts
def render_phrase(intent: str, context: Mapping[str, Any]) -> str:
phrases = load_texts().phrases
template = phrases.get(intent, phrases[DEFAULT_INTENT])
return " ".join(template.format_map(_BlankMissing(context)).split())
def _fmt(value: Any) -> str:
if isinstance(value, bool):
return "да" if value else "нет"
if isinstance(value, list | tuple):
sep = " → " if all(isinstance(v, int | float) for v in value) else ", "
return sep.join(_fmt(v) for v in value)
if isinstance(value, Mapping):
return ", ".join(f"{k}: {_fmt(v)}" for k, v in value.items())
return str(value)
def render_summary(structured: Mapping[str, Any]) -> str:
"""Deterministic doctor summary: one "label: value" line per non-empty field, known labels first."""
texts = load_texts().summary
labels = texts.labels
ordered = [k for k in labels if k in structured] + [k for k in structured if k not in labels]
lines = [
f"{labels.get(key, key)}: {_fmt(structured[key])}"
for key in ordered
if structured[key] not in (None, "", [], {})
]
return "\n".join(lines) if lines else texts.empty