Files
PTSRaiAgent/app/llm/gateway.py
T
ElebobaandClaude Opus 5.5 924a51c483
CI / ruff · pytest (pull_request) Successful in 23s
feat(bot): LLM gateway with GigaChat and offline mock providers
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>
2026-10-03 15:00:41 +03:00

191 lines
8.5 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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)