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