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>
68 lines
2.1 KiB
Python
68 lines
2.1 KiB
Python
"""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
|