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,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
|
||||
Reference in New Issue
Block a user