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
+190
View File
@@ -0,0 +1,190 @@
"""LLM gateway — the ONLY entry point to the LLM layer (guardrail #1).
Callers pass text that has already gone through `app.core.privacy.scrub()` (`ScrubbedText`). The
gateway adds the shared policy on top of the selected task implementation (GigaChat or offline mock):
* `assert_scrubbed` runtime backstop before anything reaches a provider;
* SPEC §7 confidence threshold for scale items (below it the FSM asks a clarifying question);
* risk classification failures degrade to `RiskLevel.NONE` — it is only an auxiliary signal, the
lexicon in `app.core.safety` is primary and alerts are decided by rules (ADR-007);
* patient-facing replies pass a forbidden-content filter and fall back to `config/texts` templates.
No method raises on provider failure; every one has a deterministic fallback.
"""
import json
import logging
import re
from collections.abc import Mapping
from functools import lru_cache
from typing import Any
from app.config import Settings, get_settings
from app.core.types import RiskLevel, ScrubbedText
from app.llm.base import LLMTasks
from app.llm.guard import UnscrubbedTextError, assert_scrubbed
from app.llm.mock import MockTasks
from app.llm.schemas import CheckinFacts, ItemScore
from app.llm.texts import render_phrase, render_summary
log = logging.getLogger(__name__)
# SPEC §3/§7: a scale item with confidence below this is treated as "not extracted" -> clarify.
ITEM_CONFIDENCE_THRESHOLD = 0.5
MAX_REPLY_CHARS = 600
_RISK_MAP = {"none": RiskLevel.NONE, "possible": RiskLevel.POSSIBLE, "acute": RiskLevel.ACUTE}
# Output filter backing up the SPEC §7 prompt prohibitions: diagnoses, naming disorders, dose/drug
# advice, confidentiality promises. Patterns target affirmative imperatives, so the safe phrasing
# "не прекращайте приём самостоятельно" from our own templates is not caught.
FORBIDDEN_OUTPUT: tuple[re.Pattern[str], ...] = tuple(
re.compile(p, re.IGNORECASE)
for p in (
r"диагноз",
r"у (вас|тебя) (\w+ )?(депресси|птср|тревожн\w* расстройств|расстройств|биполяр|шизофрен)",
r"(это|похоже на|признаки) (\w+ )?(депресси|птср|посттравматическ|расстройств)",
r"(увеличьте|уменьшите|снизьте|повысьте|удвойте|измените|увеличь|уменьши|снизь) (\w+ )?доз",
r"(?<!не )отмен(ите|и) ",
r"(?<!не )(прекратите|перестаньте|бросьте) (\w+ )?(приём|прием|принимать|пить)",
r"(примите|принимайте|попробуйте|выпейте) (\w+ )?"
r"(таблетк|препарат|снотворн|антидепрессант|успокоительн)",
r"никто не узнает",
r"останется между нами",
r"полностью конфиденциальн",
r"(?<!\w)как (ваш )?(психотерапевт|терапевт)",
)
)
def is_forbidden(text: str) -> bool:
return any(p.search(text) for p in FORBIDDEN_OUTPUT)
def _dump(data: Mapping[str, Any]) -> str:
return json.dumps(data, ensure_ascii=False, default=str, sort_keys=True)
class LLMGateway:
def __init__(self, tasks: LLMTasks) -> None:
self._tasks = tasks
@property
def provider_name(self) -> str:
return self._tasks.name
async def extract_item(
self, scale_item_id: str, item_text: str, max_score: int, answer: ScrubbedText
) -> ItemScore | None:
"""Free-text answer -> item score, or `None` if unsure/failed (FSM then asks to clarify)."""
try:
safe = assert_scrubbed(answer)
result = await self._tasks.extract_item(scale_item_id, item_text, max_score, safe)
except UnscrubbedTextError:
log.error("llm refused: unscrubbed text task=extract_item")
return None
except Exception as exc:
log.warning("llm task failed task=extract_item error=%s", type(exc).__name__)
return None
if result is None or result.score > max_score or result.confidence < ITEM_CONFIDENCE_THRESHOLD:
return None
return result
async def extract_checkin(self, text: ScrubbedText) -> CheckinFacts | None:
try:
return await self._tasks.extract_checkin(assert_scrubbed(text))
except UnscrubbedTextError:
log.error("llm refused: unscrubbed text task=extract_checkin")
except Exception as exc:
log.warning("llm task failed task=extract_checkin error=%s", type(exc).__name__)
return None
async def risk_classify(self, text: ScrubbedText) -> RiskLevel:
"""Auxiliary risk signal; the caller combines it as `max(lexicon, llm)`. Failure -> NONE."""
try:
verdict = await self._tasks.risk_classify(assert_scrubbed(text))
except UnscrubbedTextError:
log.error("llm refused: unscrubbed text task=risk_classify")
return RiskLevel.NONE
except Exception as exc:
log.warning("llm task failed task=risk_classify error=%s", type(exc).__name__)
return RiskLevel.NONE
return RiskLevel.NONE if verdict is None else _RISK_MAP[verdict.level]
async def phrase(self, intent: str, context: Mapping[str, Any] | None = None) -> str:
"""Short empathetic reply for an already-decided action. Never raises; falls back to template.
`context` must contain only structured, non-identifying values (drug name, day number...).
"""
context = context or {}
base = render_phrase(intent, context)
try:
payload = assert_scrubbed(_dump({"intent": intent, "context": context, "base": base}))
text = await self._tasks.phrase(intent, payload, base)
except UnscrubbedTextError:
log.error("llm refused: unscrubbed text task=phrase")
return base
except Exception as exc:
log.warning("llm task failed task=phrase error=%s", type(exc).__name__)
return base
if not text or len(text) > MAX_REPLY_CHARS or is_forbidden(text):
if text:
log.warning("llm reply rejected by output filter intent=%s", intent)
return base
return text.strip()
async def summarize_for_doctor(self, structured: Mapping[str, Any]) -> str:
"""Doctor summary from structured data only (never raw patient text). Falls back to template."""
base = render_summary(structured)
try:
text = await self._tasks.summarize_for_doctor(assert_scrubbed(_dump(structured)), base)
except UnscrubbedTextError:
log.error("llm refused: unscrubbed text task=summarize_for_doctor")
return base
except Exception as exc:
log.warning("llm task failed task=summarize_for_doctor error=%s", type(exc).__name__)
return base
if not text or is_forbidden(text):
return base
return text.strip()
def build_gateway(settings: Settings) -> LLMGateway:
if settings.llm_provider == "gigachat":
if not settings.gigachat_credentials:
# Offline-first (ADR-004): a misconfigured key must not break the demo.
log.warning("LLM_PROVIDER=gigachat but GIGACHAT_CREDENTIALS is empty; using mock")
return LLMGateway(MockTasks())
from app.llm.giga import GigaChatProvider
from app.llm.tasks import PromptedTasks
return LLMGateway(PromptedTasks(GigaChatProvider(settings), name="gigachat"))
return LLMGateway(MockTasks())
@lru_cache
def get_gateway() -> LLMGateway:
return build_gateway(get_settings())
# Module-level shortcuts, so callers can `from app.llm import gateway; await gateway.extract_item(...)`.
async def extract_item(
scale_item_id: str, item_text: str, max_score: int, answer: ScrubbedText
) -> ItemScore | None:
return await get_gateway().extract_item(scale_item_id, item_text, max_score, answer)
async def extract_checkin(text: ScrubbedText) -> CheckinFacts | None:
return await get_gateway().extract_checkin(text)
async def risk_classify(text: ScrubbedText) -> RiskLevel:
return await get_gateway().risk_classify(text)
async def phrase(intent: str, context: Mapping[str, Any] | None = None) -> str:
return await get_gateway().phrase(intent, context)
async def summarize_for_doctor(structured: Mapping[str, Any]) -> str:
return await get_gateway().summarize_for_doctor(structured)