"""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"(? 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)