Files
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

32 lines
1.3 KiB
Python

"""Defence in depth for guardrail #1 ("only scrubbed text goes to the LLM").
`ScrubbedText` is a `NewType`, i.e. a plain `str` at runtime, so the type checker is the primary guard.
`assert_scrubbed` is a cheap runtime backstop right before text leaves the process: if an obvious
identifier (phone, e-mail, SNILS) is still present, the call to the provider is refused. It is NOT a
replacement for `app.core.privacy.scrub()` — names, addresses etc. are not detected here.
"""
import re
from app.core.types import ScrubbedText
_LEAK_PATTERNS: tuple[re.Pattern[str], ...] = (
# Russian phone numbers: +7 / 8 followed by 10 digits with optional separators.
re.compile(r"(?:\+7|(?<!\d)8)[\s\-()]*\d{3}[\s\-()]*\d{3}[\s\-]*\d{2}[\s\-]*\d{2}(?!\d)"),
re.compile(r"[\w.+-]+@[\w-]+\.[\w.-]+"),
# SNILS: 123-456-789 01
re.compile(r"(?<!\d)\d{3}-\d{3}-\d{3}[\s-]\d{2}(?!\d)"),
)
class UnscrubbedTextError(ValueError):
"""Raised when text that looks unscrubbed is about to be sent to the LLM."""
def assert_scrubbed(text: str) -> ScrubbedText:
for pattern in _LEAK_PATTERNS:
if pattern.search(text):
# Never include the text itself in the message: it would end up in logs.
raise UnscrubbedTextError("identifier pattern found in text bound for LLM")
return ScrubbedText(text)