Files
PTSRaiAgent/tests/test_privacy.py
T
ElebobaandClaude Opus 5.5 585f0af273
CI / ruff · pytest (pull_request) Successful in 24s
feat(core): implement scrub() and pseudo_id() with tests
Rule-based PII scrubber: phones, e-mails, SNILS (checksum-aware), passport,
INN, cards, dates of birth, addresses, URLs/handles and person names
(dictionary in config/privacy/names_ru.yaml + declension, patronymic and
surname heuristics). Idempotent via fixed-point iteration; false-positive
guards for ordinary clinical text. pseudo_id normalises SNILS to 11 digits
and returns HMAC-SHA256 truncated to 32 hex chars.

Refs #2

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 15:00:21 +03:00

200 lines
9.4 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.
"""Tests for app.core.privacy (issue #2, guardrail #1: only scrubbed text reaches the LLM)."""
import pytest
from app.core.privacy import pseudo_id, scrub
# ---------------------------------------------------------------------------------------------------
# scrub(): identifiers are replaced
# ---------------------------------------------------------------------------------------------------
@pytest.mark.parametrize(
("text", "expected"),
[
# phones
("Мой телефон +7 (916) 123-45-67, звоните", "Мой телефон [PHONE], звоните"),
("номер 8-916-123-45-67", "номер [PHONE]"),
("номер 89161234567", "номер [PHONE]"),
("+79161234567", "[PHONE]"),
("горячая линия 8 800 2000 122", "горячая линия [PHONE]"),
("мобильный 916 123 45 67", "мобильный [PHONE]"),
("домашний 123-45-67", "домашний [PHONE]"),
# e-mail, links, handles
("пишите на ivan.petrov@mail.ru", "пишите на [EMAIL]"),
("или почта@мэйл.рф", "или [EMAIL]"),
("см. https://example.com/page?x=1.", "см. [URL]."),
("сайт www.clinic.ru", "сайт [URL]"),
("мой канал t.me/ivan_99", "мой канал [HANDLE]"),
("страница https://vk.com/id123", "страница [HANDLE]"),
("ник @ivan_petrov в телеге", "ник [HANDLE] в телеге"),
# documents
("СНИЛС 112-233-445 95", "СНИЛС [SNILS]"),
("СНИЛС 11223344595", "СНИЛС [SNILS]"),
("паспорт серия 4510 номер 123456", "паспорт [PASSPORT]"),
("паспорт 45 10 123456", "паспорт [PASSPORT]"),
("паспорт 4510 123456", "паспорт [PASSPORT]"),
("ИНН 7707083893", "ИНН [INN]"),
("ИНН 500100732259", "ИНН [INN]"),
("карта 4276 3800 1234 5678", "карта [CARD]"),
("карта 4276-3800-1234-5678", "карта [CARD]"),
("карта 4276380012345678", "карта [CARD]"),
# dates of birth
("родился 12.03.1985", "родился [DATE]"),
("д.р. 1985-03-12", "д.р. [DATE]"),
("родилась 5 мая 1988 г.", "родилась [DATE]"),
("1985 г.р.", "[DATE]"),
("дата рождения: 1990", "дата рождения: [DATE]"),
("родился в 1990 году", "родился в [DATE] году"),
# addresses
("живу: г. Москва, ул. Ленина, д. 5, кв. 12", "живу: [ADDRESS]"),
("улица Тверская 7", "[ADDRESS]"),
("Ленинский проспект, 32", "[ADDRESS]"),
("пр-т Мира, д. 10, корп. 2, кв. 45", "[ADDRESS]"),
("ул. 8 Марта 3", "[ADDRESS]"),
("а потом дом 7 кв. 3", "а потом [ADDRESS]"),
],
)
def test_scrub_identifiers(text: str, expected: str) -> None:
assert scrub(text) == expected
@pytest.mark.parametrize(
("text", "expected"),
[
("Меня зовут Алексей Петров", "Меня зовут [NAME]"),
("меня зовут алексей", "меня зовут [NAME]"),
("Иванов Иван Иванович пришёл", "[NAME] пришёл"),
("врач д-р Смирнова Е. А., психиатр", "врач д-р [NAME], психиатр"),
("Е.А. Смирнова назначила", "[NAME] назначила"),
("Сегодня Саша опять не спала", "Сегодня [NAME] опять не спала"),
("мой брат саша приехал", "мой брат [NAME] приехал"),
("звонил Алексею вчера", "звонил [NAME] вчера"),
("встретил Ирину Сергеевну", "встретил [NAME]"),
("был у Петрова", "был у [NAME]"),
("Вера Ивановна сказала", "[NAME] сказала"),
("Вчера звонила Вера.", "Вчера звонила [NAME]."),
("Надежда Петрова пришла", "[NAME] пришла"),
("встретил Петрова Ивана", "встретил [NAME]"),
("его зовут Тимофей", "его зовут [NAME]"),
("Анна-Мария приехала", "[NAME] приехала"),
("с Павлом и Леной", "с [NAME] и [NAME]"),
],
)
def test_scrub_names(text: str, expected: str) -> None:
assert scrub(text) == expected
def test_scrub_full_message() -> None:
text = (
"Здравствуйте, я Алексей Смирнов, СНИЛС 112-233-445 95, тел. +7 916 123 45 67.\n"
"Живу на ул. Ленина, д. 5, кв. 12. Сплю плохо, тошнота 3 дня."
)
assert scrub(text) == (
"Здравствуйте, я [NAME], СНИЛС [SNILS], тел. [PHONE].\nЖиву на [ADDRESS]. Сплю плохо, тошнота 3 дня."
)
# ---------------------------------------------------------------------------------------------------
# scrub(): ordinary clinical text is not mangled (false-positive guards)
# ---------------------------------------------------------------------------------------------------
@pytest.mark.parametrize(
"text",
[
"сплю плохо, кошмары почти каждую ночь",
"Сплю плохо, кошмары почти каждую ночь",
"выпил 2 бутылки пива",
"сертралин 50 мг 1 р/сут утром после еды",
"PHQ-9 = 14, п.9 = 0",
"GAD-7: 9, PCL-5 = 45",
"тошнота 3 дня подряд",
"принял таблетку в 08:00, уснул в 23:30",
"сплю 4-6 часов",
"давление 120/80, пульс 90, вес 75.5 кг",
"0.5 мг на ночь, полтаблетки 1/2",
"12 марта был у врача",
"в 2010 г. Мне стало хуже",
"и т.д. 5 раз за ночь",
"на улице страшно, проезд оплатил",
"Снова не спал. Сегодня лучше.",
"Слава богу, лучше. Роман читаю.",
"Надежда есть.",
"Любой ценой. Света нет, боюсь темноты.",
"кости ломит, коли укол",
"Принимаю Феварин и Глицин",
"Не могу уснуть без пива.",
"Всё бессмысленно, устал от всего",
"От таблеток только хуже, брошу",
"Был в ПНД, сказали прийти через 4 недели",
"день 13, настроение 2 из 10",
],
)
def test_scrub_keeps_clinical_text(text: str) -> None:
assert scrub(text) == text
# ---------------------------------------------------------------------------------------------------
# scrub(): idempotency and type
# ---------------------------------------------------------------------------------------------------
@pytest.mark.parametrize(
"text",
[
"Меня зовут Алексей Петров, тел. 8 (916) 123-45-67, ivan@mail.ru",
"СНИЛС 112-233-445 95, паспорт 4510 123456, ИНН 7707083893, карта 4276 3800 1234 5678",
"г. Москва, ул. Ленина, д. 5, кв. 12; родился 12.03.1985; t.me/ivan; @ivan_petrov",
"д-р Смирнова Е. А. и Иванов Иван Иванович",
"сертралин 50 мг, PHQ-9 = 14, сплю 4-6 часов",
"[NAME] [PHONE] [EMAIL]",
],
)
def test_scrub_is_idempotent(text: str) -> None:
once = scrub(text)
assert scrub(once) == once
def test_scrub_returns_str_and_handles_empty() -> None:
assert scrub("") == ""
assert isinstance(scrub("Алексей"), str)
# ---------------------------------------------------------------------------------------------------
# pseudo_id()
# ---------------------------------------------------------------------------------------------------
KEY = "test-key-0123456789abcdef0123456789abcdef"
def test_pseudo_id_format_and_determinism() -> None:
token = pseudo_id("112-233-445 95", key=KEY)
assert len(token) == 32
assert all(c in "0123456789abcdef" for c in token)
assert pseudo_id("112-233-445 95", key=KEY) == token
@pytest.mark.parametrize("variant", ["11223344595", "112-233-445 95", "112 233 445 95", " 112-233-445-95 "])
def test_pseudo_id_normalises_snils(variant: str) -> None:
assert pseudo_id(variant, key=KEY) == pseudo_id("11223344595", key=KEY)
def test_pseudo_id_depends_on_key_and_snils() -> None:
assert pseudo_id("11223344595", key=KEY) != pseudo_id("11223344595", key=KEY + "x")
assert pseudo_id("11223344595", key=KEY) != pseudo_id("11223344596", key=KEY)
def test_pseudo_id_default_key_from_settings() -> None:
from app.config import get_settings
assert pseudo_id("11223344595") == pseudo_id("11223344595", key=get_settings().pseudo_key)
@pytest.mark.parametrize(
"bad", ["", "1234567890", "123456789012", "112-233-445 9X", "11223344595"]
)
def test_pseudo_id_rejects_invalid_snils(bad: str) -> None:
with pytest.raises(ValueError):
pseudo_id(bad, key=KEY)