gendesign/backend/app/services/llm/redaction.py
Light1YT 4af7ba5a40
All checks were successful
CI / changes (pull_request) Successful in 5s
CI / backend-tests (push) Successful in 6m25s
CI / backend-tests (pull_request) Successful in 6m20s
Deploy / changes (push) Successful in 5s
Deploy / build-frontend (push) Has been skipped
Deploy / build-backend (push) Successful in 1m38s
Deploy / build-worker (push) Successful in 2m42s
Deploy / deploy (push) Successful in 1m13s
CI / changes (push) Successful in 5s
CI / frontend-tests (push) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
feat(llm): foundational LLM infra package with §19 redaction + deterministic fallback (#960)
Optional external-OpenAI layer over the deterministic forecasting engine. Gated by
llm_enabled (default False) so prod makes no network calls until deliberately enabled.
Allowlist-first SafePayload contract + is_confidential hard-block + RU-PII regex scrub
(mandatory on the external path). Abstract LLMProvider seam (is_external) for a future
RU-hosted provider. Sync httpx core (Celery-friendly); tool/function-calling pass-through;
timeout + bounded 429/5xx retry + per-request call cap, all degrading to fallback.
Raw httpx (no openai SDK -> no pyproject/lock drift). 47 tests, ruff + mypy clean.

Refs #960
2026-06-08 15:44:16 +05:00

155 lines
9 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.

"""§19 data-residency redaction layer (#960) — THE critical safety boundary.
Провайдер LLM ВНЕШНИЙ (OpenAI) → данные покидают РФ. Чувствительные данные не
должны попасть наружу НИКОГДА. Защита эшелонирована (defense-in-depth):
1. Allowlist / safe-payload (ПЕРВИЧНОЕ): публичный API заставляет вызывающего
явно собрать ``SafePayload`` из не-чувствительных полей/агрегатов/публичного
текста — а НЕ передавать сырые строки БД. См. ``SafePayload`` ниже и контракт
в ``app/services/llm/__init__.py``.
2. Hard-block (ENFORCED): ``SafePayload(is_confidential=True)`` → ``RedactionRefusedError``.
Контент, помеченный конфиденциальным (напр. insight.is_confidential), НИКОГДА
не уходит во внешнего провайдера — клиент поднимает типизированную ошибку, а не
«тихо» отправляет.
3. Regex-scrub (ВТОРИЧНОЕ, belt-and-suspenders): из свободного текста вырезаются
RU PII-паттерны (телефон / email / СНИЛС / ИНН / паспорт / ФИО-подобное) и
заменяются на ``[REDACTED:<kind>]``. Это НЕ основная гарантия — основная это (1).
``scrub_safe_payload`` — единственная точка, через которую ``client.complete``
прогоняет данные перед внешним вызовом (mandatory на external-пути; будущий
RU-hosted провайдер сможет это ослабить — см. шов ``LLMProvider.is_external``).
"""
from __future__ import annotations
import logging
import re
from dataclasses import dataclass, field
from typing import Any
logger = logging.getLogger(__name__)
class RedactionRefusedError(Exception):
"""Полезная нагрузка помечена конфиденциальной → отправка наружу запрещена.
Поднимается из ``scrub_safe_payload`` для external-провайдера, когда
``SafePayload.is_confidential`` is True. Клиент ловит это и превращает в
детерминированный fallback (система не падает из-за того что данные нельзя слать).
"""
@dataclass(frozen=True, slots=True)
class SafePayload:
"""Явно собранная вызывающим НЕ-чувствительная нагрузка для внешней LLM.
Контракт (allowlist-first): консьюмеры (#956 граддок-extraction, #957 chat)
ОБЯЗАНЫ конструировать это вручную из проверенных полей — НЕ передавать сырые
DB-строки/insight'ы. Структура — единственное, что физически доходит до клиента.
Attributes:
text: Свободный текст для модели (публичный градрегламент, агрегаты, вопрос
пользователя). Прогоняется через regex-scrub перед отправкой.
fields: Структурированные не-чувствительные пары ключ→значение (числа/коды/
короткие строки). Значения-строки тоже скрабятся.
is_confidential: Если True — вся нагрузка под жёстким блоком: ``scrub_safe_payload``
для external-провайдера поднимет ``RedactionRefusedError``. Прокидывать сюда
ЛЮБОЙ confidential-флаг источника (напр. insight.is_confidential).
"""
text: str = ""
fields: dict[str, Any] = field(default_factory=dict)
is_confidential: bool = False
# ── RU PII regex (вторичная защита) ──────────────────────────────────────────
# Порядок применения важен: более специфичные/длинные паттерны раньше, чтобы они не
# «съедались» более общими (СНИЛС/ИНН/паспорт раньше «голых» групп цифр).
# СНИЛС: NNN-NNN-NNN NN (последняя пара — через пробел/дефис).
_SNILS_RE = re.compile(r"\b\d{3}-\d{3}-\d{3}[\s-]\d{2}\b")
# Паспорт РФ: NNNN NNNNNN (серия 4 + номер 6, разделитель пробел/дефис).
_PASSPORT_RE = re.compile(r"\b\d{4}[\s-]\d{6}\b")
# Телефон РФ: +7/8, опц. скобки/пробелы/дефисы, 10 значащих цифр.
_PHONE_RE = re.compile(r"(?:\+7|\b8)[\s\-(]*\d{3}[\s\-)]*\d{3}[\s-]*\d{2}[\s-]*\d{2}\b")
# Email.
_EMAIL_RE = re.compile(r"\b[A-Za-z0-9._%+\-]+@[A-Za-z0-9.\-]+\.[A-Za-z]{2,}\b")
# ИНН: ровно 12 (физлицо) или 10 (юрлицо) цифр, не приклеенные к другим цифрам.
_INN_RE = re.compile(r"(?<!\d)(?:\d{12}|\d{10})(?!\d)")
# ФИО-подобное: три слова с заглавной кириллической буквы подряд (Фамилия Имя Отчество).
# Грубая эвристика — может задеть «Министерство Строительства России» и подобное; это
# приемлемо для вторичной защиты (лучше лишний REDACTED, чем утечка ФИО).
_FULLNAME_RE = re.compile(r"\b[А-ЯЁ][а-яё]+\s+[А-ЯЁ][а-яё]+\s+[А-ЯЁ][а-яё]+\b")
# (regex, placeholder-kind). Применяются последовательно в этом порядке.
_PII_PATTERNS: tuple[tuple[re.Pattern[str], str], ...] = (
(_SNILS_RE, "snils"),
(_PASSPORT_RE, "passport"),
(_PHONE_RE, "phone"),
(_EMAIL_RE, "email"),
(_INN_RE, "inn"),
(_FULLNAME_RE, "name"),
)
def scrub_text(value: str) -> str:
"""Вырезать RU PII из свободного текста → ``[REDACTED:<kind>]``.
Вторичная защита (см. модульный docstring). НИКОГДА не логирует ни исходный, ни
результирующий текст — только факт срабатывания и kind (без значения).
"""
if not value:
return value
redacted = value
for pattern, kind in _PII_PATTERNS:
redacted, n = pattern.subn(f"[REDACTED:{kind}]", redacted)
if n:
# Логируем ТОЛЬКО kind и количество — без самого PII-значения.
logger.info("redaction: scrubbed %d %s token(s) from free text", n, kind)
return redacted
def _scrub_value(value: Any) -> Any:
"""Рекурсивно проскрабить строковые значения в структуре fields."""
if isinstance(value, str):
return scrub_text(value)
if isinstance(value, dict):
return {k: _scrub_value(v) for k, v in value.items()}
if isinstance(value, (list, tuple)):
scrubbed = [_scrub_value(v) for v in value]
return type(value)(scrubbed)
return value
def scrub_safe_payload(payload: SafePayload, *, is_external: bool) -> SafePayload:
"""Mandatory-шаг перед внешним вызовом: hard-block + regex-scrub.
Args:
payload: явно собранная вызывающим нагрузка.
is_external: True для провайдера, выгружающего данные за пределы РФ
(OpenAI). Для будущего RU-hosted провайдера можно передать False, чтобы
ослабить scrub — это и есть задокументированный шов (data остаётся в РФ).
Returns:
Новый ``SafePayload`` с проскрабленными ``text``/``fields``.
Raises:
RedactionRefusedError: если ``is_external`` и ``payload.is_confidential``.
"""
if is_external and payload.is_confidential:
# НЕ логируем содержимое — только факт отказа.
logger.warning("redaction: refusing confidential payload for external LLM provider")
raise RedactionRefusedError(
"payload marked is_confidential=True cannot be sent to an external LLM provider"
)
if not is_external:
# RU-hosted провайдер: данные не покидают РФ — scrub не обязателен.
return payload
return SafePayload(
text=scrub_text(payload.text),
fields=_scrub_value(payload.fields),
is_confidential=payload.is_confidential,
)
__all__ = ["RedactionRefusedError", "SafePayload", "scrub_safe_payload", "scrub_text"]