gendesign/backend/tests/services/llm/test_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

154 lines
6 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 redaction tests (#960) — самый важный слой безопасности.
Покрываем: каждый тип RU PII, hard-block is_confidential, allowlist-поведение
(SafePayload как единственный вход), и шов RU-hosted провайдера (is_external=False).
"""
from __future__ import annotations
import pytest
from app.services.llm.redaction import (
RedactionRefusedError,
SafePayload,
scrub_safe_payload,
scrub_text,
)
# ── Per-PII-type scrub ────────────────────────────────────────────────────────
def test_scrub_phone_ru() -> None:
out = scrub_text("Звоните +7 (912) 345-67-89 срочно")
assert "[REDACTED:phone]" in out
assert "912" not in out
def test_scrub_phone_8_prefix() -> None:
out = scrub_text("тел 8 912 345 67 89")
assert "[REDACTED:phone]" in out
assert "345" not in out
def test_scrub_email() -> None:
out = scrub_text("пишите ivan.petrov@example.ru пожалуйста")
assert "[REDACTED:email]" in out
assert "example.ru" not in out
def test_scrub_snils() -> None:
out = scrub_text("СНИЛС 112-233-445 95 в деле")
assert "[REDACTED:snils]" in out
assert "112-233-445" not in out
def test_scrub_inn_12() -> None:
out = scrub_text("ИНН физлица 500100732259 проверен")
assert "[REDACTED:inn]" in out
assert "500100732259" not in out
def test_scrub_inn_10() -> None:
out = scrub_text("ИНН юрлица 7707083893 в ЕГРЮЛ")
assert "[REDACTED:inn]" in out
assert "7707083893" not in out
def test_scrub_passport() -> None:
out = scrub_text("паспорт 6512 123456 выдан")
assert "[REDACTED:passport]" in out
assert "123456" not in out
def test_scrub_fullname() -> None:
out = scrub_text("ответственный Иванов Иван Иванович подписал")
assert "[REDACTED:name]" in out
assert "Иванович" not in out
def test_scrub_multiple_pii_in_one_text() -> None:
out = scrub_text("Иванов Иван Иванович, +7 912 345 67 89, mail@x.ru")
assert "[REDACTED:name]" in out
assert "[REDACTED:phone]" in out
assert "[REDACTED:email]" in out
def test_scrub_preserves_safe_text() -> None:
"""Безопасный текст (без PII) не трогается."""
safe = "Зона Ц-1, максимальная этажность 16, процент застройки 60."
assert scrub_text(safe) == safe
def test_scrub_empty_string_noop() -> None:
assert scrub_text("") == ""
# ── Hard-block: is_confidential ───────────────────────────────────────────────
def test_confidential_payload_refused_for_external() -> None:
"""is_confidential=True + external провайдер → RedactionRefusedError (НЕ отправляем)."""
payload = SafePayload(text="секретная аналитика", is_confidential=True)
with pytest.raises(RedactionRefusedError):
scrub_safe_payload(payload, is_external=True)
def test_non_confidential_payload_passes_external() -> None:
"""is_confidential=False → проходит (и скрабится)."""
payload = SafePayload(text="публичный текст про зону Ц-1", is_confidential=False)
out = scrub_safe_payload(payload, is_external=True)
assert out.text == "публичный текст про зону Ц-1"
assert out.is_confidential is False
# ── Scrub применяется к text И fields на external-пути ─────────────────────────
def test_scrub_safe_payload_scrubs_text_and_fields() -> None:
payload = SafePayload(
text="контакт +7 912 345 67 89",
fields={"note": "почта a@b.ru", "floors": 16, "code": "Ц-1"},
)
out = scrub_safe_payload(payload, is_external=True)
assert "[REDACTED:phone]" in out.text
assert "[REDACTED:email]" in out.fields["note"]
# Не-строковые и безопасные значения сохранены.
assert out.fields["floors"] == 16
assert out.fields["code"] == "Ц-1"
def test_scrub_nested_fields() -> None:
"""Рекурсивный scrub вложенных list/dict в fields."""
payload = SafePayload(fields={"items": [{"phone": "+7 912 345 67 89"}, "a@b.ru"]})
out = scrub_safe_payload(payload, is_external=True)
assert "[REDACTED:phone]" in out.fields["items"][0]["phone"]
assert "[REDACTED:email]" in out.fields["items"][1]
# ── RU-hosted seam: is_external=False ─────────────────────────────────────────
def test_ru_hosted_provider_skips_scrub_and_allows_confidential() -> None:
"""is_external=False (будущий RU-hosted): scrub не обязателен, confidential проходит.
Данные не покидают РФ → §19 не нарушается. Это задокументированный шов.
"""
payload = SafePayload(
text="контакт +7 912 345 67 89",
is_confidential=True,
)
out = scrub_safe_payload(payload, is_external=False)
# Не бросает, текст НЕ скраблен (RU-провайдер может получить как есть).
assert out is payload
assert "+7 912 345 67 89" in out.text
# ── Allowlist contract: SafePayload — единственный вход ────────────────────────
def test_safe_payload_defaults_are_empty_and_non_confidential() -> None:
"""По умолчанию SafePayload пуст и не-конфиденциален — безопасный нейтральный старт."""
p = SafePayload()
assert p.text == ""
assert p.fields == {}
assert p.is_confidential is False