feat(core): scrub() и pseudo_id() + тесты #31
+437
-4
@@ -1,16 +1,449 @@
|
|||||||
"""PII scrubbing and pseudonymisation (issue #2). Contract stub — implementation lands in #2."""
|
"""PII scrubbing and pseudonymisation (issue #2, guardrail #1).
|
||||||
|
|
||||||
|
Why this module exists
|
||||||
|
----------------------
|
||||||
|
Messages from patients are health data — a special category of personal data under 152-FZ art. 10 —
|
||||||
|
and are covered by medical secrecy (323-FZ art. 13). Guardrail #1: **only text that passed `scrub()`
|
||||||
|
may reach the LLM** (`app/llm/gateway.py` accepts `ScrubbedText` only), and only scrubbed text is
|
||||||
|
stored in `messages.text_scrubbed`. Direct identifiers live solely in the identity DB; the clinical DB
|
||||||
|
is linked to it by `pseudo_id()` — an HMAC token that cannot be reversed without `PSEUDO_KEY`.
|
||||||
|
|
||||||
|
Design
|
||||||
|
------
|
||||||
|
`scrub()` is a deterministic rule-based pipeline (no ML, works offline, explainable to the jury):
|
||||||
|
ordered regex passes replace structured identifiers with bracketed placeholders, then a token pass
|
||||||
|
finds person names using a dictionary (`config/privacy/names_ru.yaml`) plus morphology heuristics.
|
||||||
|
The order matters: longer / more specific number formats go first so that, e.g., a card number is not
|
||||||
|
partially eaten by the phone pattern. Placeholders are Latin and bracketed, so no later pass (which
|
||||||
|
looks for digits or Cyrillic) can match them — this is what makes the function idempotent; on top of
|
||||||
|
that the pipeline is iterated to a fixed point.
|
||||||
|
|
||||||
|
The trade-off is deliberate: over-scrubbing a city or a capitalised surname-like word is cheap,
|
||||||
|
leaking an identifier to an external model is not. At the same time ordinary clinical text (doses,
|
||||||
|
scale scores, durations, times, lowercase complaints) must survive intact, because the LLM extracts
|
||||||
|
symptom data from it — this is covered by false-positive tests in `tests/test_privacy.py`.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
import hmac
|
||||||
|
import re
|
||||||
|
from collections.abc import Callable
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from functools import lru_cache
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import yaml
|
||||||
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
from app.config import get_settings
|
||||||
from app.core.types import ScrubbedText
|
from app.core.types import ScrubbedText
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------------------------------
|
||||||
|
# Regex passes for structured identifiers
|
||||||
|
# ---------------------------------------------------------------------------------------------------
|
||||||
|
|
||||||
|
_SOCIAL = re.compile(
|
||||||
|
r"(?:https?://)?(?:www\.)?"
|
||||||
|
r"(?:t\.me|telegram\.me|vk\.com|vk\.ru|ok\.ru|instagram\.com|facebook\.com|max\.ru)/[^\s]+",
|
||||||
|
re.IGNORECASE,
|
||||||
|
)
|
||||||
|
_URL = re.compile(
|
||||||
|
r"(?:https?://|www\.)[^\s]+"
|
||||||
|
r"|\b[a-z0-9][a-z0-9-]*(?:\.[a-z0-9-]+)*\.(?:ru|com|рф|org|net|su|io|me|info)\b(?:/[^\s]*)?",
|
||||||
|
re.IGNORECASE,
|
||||||
|
)
|
||||||
|
_EMAIL = re.compile(r"[\w.+-]+@[\w-]+(?:\.[\w-]+)+")
|
||||||
|
_HANDLE = re.compile(r"(?<![\w@.])@[A-Za-z0-9_]{3,32}\b")
|
||||||
|
|
||||||
|
# 16-digit bank card, contiguous or in groups of 4.
|
||||||
|
_CARD = re.compile(r"(?<!\d)\d{4}(?:[ \-]?\d{4}){3}(?!\d)")
|
||||||
|
# SNILS in its official layout: 123-456-789 01 (dashes or spaces).
|
||||||
|
_SNILS_FMT = re.compile(r"(?<!\d)\d{3}[ \-]\d{3}[ \-]\d{3}[ \-]\d{2}(?!\d)")
|
||||||
|
# Russian phones: +7 / 8 followed by 10 digits with any separators, e.g. +7 (916) 123-45-67, 8-800-2000-122.
|
||||||
|
_PHONE_FULL = re.compile(r"(?<![\d\w+])(?:\+\s?7|8)(?:[ \-()]{0,3}\d){10}(?!\d)")
|
||||||
|
# Mobile number without the country prefix: 916 123-45-67, (916)1234567.
|
||||||
|
_PHONE_MOBILE = re.compile(r"(?<![\d\w])\(?9\d{2}\)?[ \-]?\d{3}[ \-]?\d{2}[ \-]?\d{2}(?!\d)")
|
||||||
|
# Local 7-digit number with mandatory dashes (123-45-67); dashes keep "4-6 часов" safe.
|
||||||
|
_PHONE_LOCAL = re.compile(r"(?<![\d\w\-])\d{3}-\d{2}-\d{2}(?![\d\-])")
|
||||||
|
# Passport: "серия 4510 номер 123456" or a bare "45 10 123456" / "4510 123456".
|
||||||
|
_PASSPORT_KW = re.compile(
|
||||||
|
r"серия\s*:?\s*\d{2}\s?\d{2}\s*,?\s*(?:(?:№|номер|n)\s*:?\s*)?\d{6}(?!\d)", re.IGNORECASE
|
||||||
|
)
|
||||||
|
_PASSPORT_BARE = re.compile(r"(?<!\d)\d{2}\s?\d{2}\s(?:№\s*)?\d{6}(?!\d)")
|
||||||
|
_SNILS_PLAIN = re.compile(r"(?<!\d)\d{11}(?!\d)")
|
||||||
|
_INN = re.compile(r"(?<!\d)(?:\d{12}|\d{10})(?!\d)")
|
||||||
|
|
||||||
|
_MONTHS = "января|февраля|марта|апреля|мая|июня|июля|августа|сентября|октября|ноября|декабря"
|
||||||
|
_DAY = r"(?:0?[1-9]|[12]\d|3[01])"
|
||||||
|
_MON = r"(?:0?[1-9]|1[0-2])"
|
||||||
|
_DATES = [
|
||||||
|
# 12.03.1985, 1/3/1985, 12-03-1985 — a full date with a 4-digit year.
|
||||||
|
re.compile(rf"(?<![\d.]){_DAY}[./\-]{_MON}[./\-](?:19|20)\d{{2}}(?!\d)"),
|
||||||
|
# 12.03.85 — dots only, so ranges and fractions stay intact.
|
||||||
|
re.compile(r"(?<![\d.])(?:0[1-9]|[12]\d|3[01])\.(?:0[1-9]|1[0-2])\.\d{2}(?![\d.])"),
|
||||||
|
# ISO 1985-03-12.
|
||||||
|
re.compile(r"(?<!\d)(?:19|20)\d{2}-(?:0[1-9]|1[0-2])-(?:0[1-9]|[12]\d|3[01])(?!\d)"),
|
||||||
|
# 12 марта 1985 (г.|года). A day+month without a year ("12 марта") is not identifying and kept.
|
||||||
|
re.compile(rf"(?<!\d){_DAY}\s+(?:{_MONTHS})\s+(?:19|20)\d{{2}}(?:\s*(?:г\.|года\b|г\b))?", re.IGNORECASE),
|
||||||
|
# 1985 г.р. / 1985 года рождения.
|
||||||
|
re.compile(r"(?<!\d)(?:19|20)\d{2}\s*(?:г\.\s*р\.?|г\.р\.?|года\s+рождения)", re.IGNORECASE),
|
||||||
|
]
|
||||||
|
# "родился в 1985", "дата рождения: 1985" — replace only the year.
|
||||||
|
_BIRTH_YEAR = re.compile(
|
||||||
|
r"((?:родил(?:ся|ась)|рождения)\s*:?\s*(?:в\s+)?)((?:19|20)\d{2})(?!\d)", re.IGNORECASE
|
||||||
|
)
|
||||||
|
|
||||||
|
_STREET_TYPE = (
|
||||||
|
r"(?:ул\.|улица|улице|улицу|улицы|пр-т|пр-кт|просп\.|проспект|проспекте|пер\.|переулок|переулке"
|
||||||
|
r"|б-р|бульвар|бульваре|ш\.|шоссе|наб\.|набережная|набережной|пл\.|площадь|площади|мкр\.?|микрорайон"
|
||||||
|
r"|проезд|тупик)"
|
||||||
|
)
|
||||||
|
# Street name must start with a capital or a number ("ул. 8 Марта", "1-я Тверская"): this is what keeps
|
||||||
|
# "на улице страшно" or "проезд оплатил" untouched.
|
||||||
|
_STREET_NAME = r"(?:\d+-?(?:я|й|го|ая|ий)?\s+)?[А-ЯЁ][А-ЯЁа-яё\-]+(?:\s+[А-ЯЁ][а-яё\-]+)?"
|
||||||
|
_CITY = r"(?:(?<![\d\w.])(?<!\d\s)(?:г\.|город)\s*[А-ЯЁ][а-яё\-]+,?\s*)?"
|
||||||
|
_HOUSE_TAIL = (
|
||||||
|
r"(?:\s*,?\s*(?:(?:д\.|дом)\s*)?\d+[а-яА-Я]?(?:/\d+)?)?"
|
||||||
|
r"(?:\s*,?\s*(?:корп\.|корпус|к\.|стр\.|строение)\s*\d+)?"
|
||||||
|
r"(?:\s*,?\s*(?:кв\.|квартира|кв)\s*\d+)?"
|
||||||
|
)
|
||||||
|
_ADDRESSES = [
|
||||||
|
re.compile(rf"{_CITY}(?<![\w\-]){_STREET_TYPE}\s*{_STREET_NAME}{_HOUSE_TAIL}"),
|
||||||
|
# "Тверская улица, 5", "Ленинский проспект".
|
||||||
|
re.compile(
|
||||||
|
rf"{_CITY}\b[А-ЯЁ][а-яё]+(?:ая|ий|ый|ой)\s+(?:улица|проспект|переулок|бульвар|шоссе|набережная|площадь)"
|
||||||
|
rf"{_HOUSE_TAIL}"
|
||||||
|
),
|
||||||
|
# A bare "д. 5, кв. 12" / "дом 5". `(?<![\w.])` keeps "и т.д. 5" safe.
|
||||||
|
re.compile(
|
||||||
|
r"(?<![\w.])(?:д\.|дом)\s*№?\s*\d+[а-я]?(?:/\d+)?(?:\s*,?\s*(?:корп\.|к\.|стр\.)\s*\d+)?"
|
||||||
|
r"(?:\s*,?\s*(?:кв\.|квартира)\s*\d+)?(?!\d)"
|
||||||
|
),
|
||||||
|
re.compile(r"(?<![\w.])(?:кв\.|квартира)\s*№?\s*\d+(?!\d)"),
|
||||||
|
# "г. Москва" on its own; "(?<!\d\s)" keeps "в 2010 г. Мне стало хуже" safe.
|
||||||
|
re.compile(r"(?<![\d\w.])(?<!\d\s)г\.\s*[А-ЯЁ][а-яё]{2,}(?:-[А-ЯЁ][а-яё]+)?"),
|
||||||
|
]
|
||||||
|
|
||||||
|
# Surname + two initials ("Смирнова Е. А.") or initials + surname ("Е.А. Смирнова").
|
||||||
|
_INITIALS = [
|
||||||
|
re.compile(r"\b[А-ЯЁ][а-яё]+(?:-[А-ЯЁ][а-яё]+)?\s+[А-ЯЁ]\.\s?[А-ЯЁ]\.(?!\w)"),
|
||||||
|
re.compile(r"(?<![\w.])[А-ЯЁ]\.\s?[А-ЯЁ]\.\s?[А-ЯЁ][а-яё]+(?:-[А-ЯЁ][а-яё]+)?"),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _snils_checksum_ok(digits: str) -> bool:
|
||||||
|
"""Official SNILS control number: weighted sum of the first 9 digits (weights 9..1) mod 101."""
|
||||||
|
total = sum(int(d) * w for d, w in zip(digits[:9], range(9, 0, -1), strict=True))
|
||||||
|
control = total % 101
|
||||||
|
if control == 100:
|
||||||
|
control = 0
|
||||||
|
return control == int(digits[9:])
|
||||||
|
|
||||||
|
|
||||||
|
def _phone_or_snils(m: re.Match[str]) -> str:
|
||||||
|
# A contiguous 11-digit "8XXXXXXXXXX" could also be a SNILS; the checksum disambiguates.
|
||||||
|
raw = m.group(0)
|
||||||
|
if raw.isdigit() and len(raw) == 11 and _snils_checksum_ok(raw):
|
||||||
|
return "[SNILS]"
|
||||||
|
return "[PHONE]"
|
||||||
|
|
||||||
|
|
||||||
|
def _url_repl(placeholder: str) -> Callable[[re.Match[str]], str]:
|
||||||
|
def repl(m: re.Match[str]) -> str:
|
||||||
|
# Keep sentence punctuation that a greedy `\S+` swallowed: "см. t.me/x." → "см. [HANDLE]."
|
||||||
|
raw = m.group(0)
|
||||||
|
stripped = raw.rstrip(".,;:!?)»\"'")
|
||||||
|
return placeholder + raw[len(stripped) :]
|
||||||
|
|
||||||
|
return repl
|
||||||
|
|
||||||
|
|
||||||
|
_REGEX_PASSES: list[tuple[re.Pattern[str], str | Callable[[re.Match[str]], str]]] = [
|
||||||
|
(_SOCIAL, _url_repl("[HANDLE]")),
|
||||||
|
(_EMAIL, "[EMAIL]"),
|
||||||
|
(_URL, _url_repl("[URL]")),
|
||||||
|
(_HANDLE, "[HANDLE]"),
|
||||||
|
(_CARD, "[CARD]"),
|
||||||
|
(_SNILS_FMT, "[SNILS]"),
|
||||||
|
(_PHONE_FULL, _phone_or_snils),
|
||||||
|
(_PHONE_MOBILE, "[PHONE]"),
|
||||||
|
(_PHONE_LOCAL, "[PHONE]"),
|
||||||
|
(_PASSPORT_KW, "[PASSPORT]"),
|
||||||
|
(_PASSPORT_BARE, "[PASSPORT]"),
|
||||||
|
(_SNILS_PLAIN, "[SNILS]"),
|
||||||
|
(_INN, "[INN]"),
|
||||||
|
*((p, "[DATE]") for p in _DATES),
|
||||||
|
(_BIRTH_YEAR, lambda m: m.group(1) + "[DATE]"),
|
||||||
|
*((p, "[ADDRESS]") for p in _ADDRESSES),
|
||||||
|
*((p, "[NAME]") for p in _INITIALS),
|
||||||
|
]
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------------------------------
|
||||||
|
# Person names: dictionary + morphology
|
||||||
|
# ---------------------------------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class NameLexicon(BaseModel):
|
||||||
|
"""Schema of `config/privacy/names_ru.yaml` (values may be extended by the clinical zone)."""
|
||||||
|
|
||||||
|
names: list[str]
|
||||||
|
ambiguous_names: list[str] = []
|
||||||
|
ambiguous_forms: list[str] = []
|
||||||
|
surname_stopwords: list[str] = []
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class _NameIndex:
|
||||||
|
forms: frozenset[str] # every known name form, normalised (lowercase, ё→е)
|
||||||
|
ambiguous: frozenset[str] # forms that are also ordinary Russian words
|
||||||
|
surname_stop: frozenset[str]
|
||||||
|
|
||||||
|
|
||||||
|
def _norm(word: str) -> str:
|
||||||
|
return word.lower().replace("ё", "е")
|
||||||
|
|
||||||
|
|
||||||
|
_HUSHING = set("жшщчц")
|
||||||
|
_VELAR_HUSH = set("гкхжшщч")
|
||||||
|
|
||||||
|
|
||||||
|
def _declensions(name: str) -> set[str]:
|
||||||
|
"""Generate singular case forms of a Russian first name by its ending.
|
||||||
|
|
||||||
|
Covers the regular paradigms (Иван, Алексей, Дмитрий, Игорь, Ирина, Мария, Таня, Саша); irregular
|
||||||
|
ones (fleeting vowels: Павел→Павла) are listed explicitly in the YAML.
|
||||||
|
"""
|
||||||
|
n = _norm(name)
|
||||||
|
forms = {n}
|
||||||
|
if n.endswith("ий"):
|
||||||
|
stem = n[:-2]
|
||||||
|
forms |= {stem + e for e in ("ия", "ию", "ием", "ии")}
|
||||||
|
elif n.endswith(("ей", "ай", "ой")):
|
||||||
|
stem = n[:-1]
|
||||||
|
forms |= {stem + e for e in ("я", "ю", "ем", "е")}
|
||||||
|
elif n.endswith("ия"):
|
||||||
|
stem = n[:-1]
|
||||||
|
forms |= {stem + e for e in ("и", "ю", "ей")}
|
||||||
|
elif n.endswith("а"):
|
||||||
|
stem = n[:-1]
|
||||||
|
gen = "и" if stem[-1] in _VELAR_HUSH else "ы"
|
||||||
|
ins = "ей" if stem[-1] in _HUSHING else "ой"
|
||||||
|
forms |= {stem + gen, stem + "е", stem + "у", stem + ins}
|
||||||
|
elif n.endswith("я"):
|
||||||
|
stem = n[:-1]
|
||||||
|
forms |= {stem + e for e in ("и", "е", "ю", "ей")}
|
||||||
|
elif n.endswith("ь"):
|
||||||
|
stem = n[:-1]
|
||||||
|
forms |= {stem + e for e in ("я", "ю", "ем", "е", "и")}
|
||||||
|
else:
|
||||||
|
ins = "ем" if n[-1] in _HUSHING else "ом"
|
||||||
|
forms |= {n + "а", n + "у", n + ins, n + "е"}
|
||||||
|
return forms
|
||||||
|
|
||||||
|
|
||||||
|
def _lexicon_path() -> Path:
|
||||||
|
return get_settings().config_dir / "privacy" / "names_ru.yaml"
|
||||||
|
|
||||||
|
|
||||||
|
@lru_cache
|
||||||
|
def _name_index() -> _NameIndex:
|
||||||
|
lex = NameLexicon.model_validate(yaml.safe_load(_lexicon_path().read_text(encoding="utf-8")))
|
||||||
|
forms: set[str] = set()
|
||||||
|
ambiguous = {_norm(f) for f in lex.ambiguous_forms}
|
||||||
|
ambiguous_names = {_norm(n) for n in lex.ambiguous_names}
|
||||||
|
for line in lex.names:
|
||||||
|
head, *extra = line.split()
|
||||||
|
name_forms = _declensions(head) | {_norm(e) for e in extra}
|
||||||
|
forms |= name_forms
|
||||||
|
if _norm(head) in ambiguous_names:
|
||||||
|
ambiguous |= name_forms
|
||||||
|
stop = frozenset(_norm(w) for w in lex.surname_stopwords)
|
||||||
|
return _NameIndex(frozenset(forms), frozenset(ambiguous), stop)
|
||||||
|
|
||||||
|
|
||||||
|
_TOKEN = re.compile(r"[А-ЯЁа-яё]+(?:-[А-ЯЁа-яё]+)*")
|
||||||
|
_CAPITALISED = re.compile(r"^[А-ЯЁ][а-яё]+(?:-[А-ЯЁ][а-яё]+)*$")
|
||||||
|
# Patronymics: -ович/-евич/-ьич (+ case endings), -овна/-евна/-ична (+ case endings).
|
||||||
|
_PATRONYMIC = re.compile(
|
||||||
|
r"^[а-яё]+(?:(?:(?:ов|ев|ёв)ич|ьич|инич)(?:а|у|ем|е)?|(?:(?:ов|ев|ёв)н|ичн)(?:а|ы|е|у|ой))$"
|
||||||
|
)
|
||||||
|
# Strong surname suffixes: -ов/-ев/-ёв, -ский/-цкий, -енко with case endings.
|
||||||
|
_SURNAME_STRONG = re.compile(
|
||||||
|
r"^[а-яё]{2,}(?:(?:ов|ев|ёв)(?:а|у|ым|ой|ою|е|ых)?|(?:ск|цк)(?:ий|ая|ого|ому|им|ой|ую|ом)|енко)$"
|
||||||
|
)
|
||||||
|
# Weak: -ин/-ын are also drug names (Феварин, Глицин), so they count only next to other name evidence.
|
||||||
|
_SURNAME_WEAK = re.compile(r"^[а-яё]{2,}(?:ин|ын)(?:а|у|ым|ой|е)?$")
|
||||||
|
_TRIGGERS = frozenset({"зовут", "звать", "имя", "фамилия", "фио", "отчество"})
|
||||||
|
_MAX_NAME_TOKENS = 4
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class _Tok:
|
||||||
|
text: str
|
||||||
|
start: int
|
||||||
|
end: int
|
||||||
|
low: str # normalised
|
||||||
|
cap: bool
|
||||||
|
initial: bool # starts a sentence
|
||||||
|
|
||||||
|
|
||||||
|
def _sentence_initial(text: str, pos: int) -> bool:
|
||||||
|
i = pos - 1
|
||||||
|
while i >= 0 and text[i] in " \t«\"'(—–-":
|
||||||
|
i -= 1
|
||||||
|
return i < 0 or text[i] in ".!?…\n"
|
||||||
|
|
||||||
|
|
||||||
|
def _is_surname(tok: _Tok, idx: _NameIndex, *, weak: bool) -> bool:
|
||||||
|
if tok.low in idx.surname_stop:
|
||||||
|
return False
|
||||||
|
return bool(_SURNAME_STRONG.match(tok.low) or (weak and _SURNAME_WEAK.match(tok.low)))
|
||||||
|
|
||||||
|
|
||||||
|
def _is_patronymic(tok: _Tok) -> bool:
|
||||||
|
return tok.cap and bool(_PATRONYMIC.match(tok.low))
|
||||||
|
|
||||||
|
|
||||||
|
def _dict_hit(tok: _Tok, idx: _NameIndex) -> tuple[bool, bool]:
|
||||||
|
"""(is a known name form, is ambiguous). Hyphenated names match if any part is a known name."""
|
||||||
|
if tok.low in idx.forms:
|
||||||
|
return True, tok.low in idx.ambiguous
|
||||||
|
parts = tok.low.split("-")
|
||||||
|
if len(parts) > 1 and tok.cap and any(p in idx.forms and p not in idx.ambiguous for p in parts):
|
||||||
|
return True, False
|
||||||
|
return False, False
|
||||||
|
|
||||||
|
|
||||||
|
def _name_spans(text: str) -> list[tuple[int, int]]:
|
||||||
|
idx = _name_index()
|
||||||
|
toks = [
|
||||||
|
_Tok(m.group(0), m.start(), m.end(), _norm(m.group(0)), m.group(0)[0].isupper(), False)
|
||||||
|
for m in _TOKEN.finditer(text)
|
||||||
|
]
|
||||||
|
for t in toks:
|
||||||
|
t.initial = _sentence_initial(text, t.start)
|
||||||
|
|
||||||
|
def adjacent(a: _Tok, b: _Tok) -> bool:
|
||||||
|
gap = text[a.end : b.start]
|
||||||
|
return gap.isspace() and "\n" not in gap
|
||||||
|
|
||||||
|
def after_trigger(i: int) -> bool:
|
||||||
|
if i == 0 or toks[i - 1].low not in _TRIGGERS:
|
||||||
|
return False
|
||||||
|
return re.fullmatch(r"\s*[:—–-]?\s*", text[toks[i - 1].end : toks[i].start]) is not None
|
||||||
|
|
||||||
|
def strong_neighbour(i: int) -> bool:
|
||||||
|
if i + 1 < len(toks) and adjacent(toks[i], toks[i + 1]):
|
||||||
|
nxt = toks[i + 1]
|
||||||
|
return nxt.cap and (_is_patronymic(nxt) or _is_surname(nxt, idx, weak=True))
|
||||||
|
return False
|
||||||
|
|
||||||
|
def is_name_anchor(i: int, t: _Tok) -> bool:
|
||||||
|
known, ambiguous = _dict_hit(t, idx)
|
||||||
|
if after_trigger(i):
|
||||||
|
# "меня зовут Алексей" / "меня зовут алексей" (lowercase only if it is a known name).
|
||||||
|
return bool(_CAPITALISED.match(t.text)) or known
|
||||||
|
if known and not ambiguous:
|
||||||
|
return t.cap or len(t.low) >= 3
|
||||||
|
if known and ambiguous:
|
||||||
|
# "Вера" mid-sentence or before a patronymic is a name; sentence-initial "Вера в себя" is not.
|
||||||
|
return t.cap and (not t.initial or strong_neighbour(i))
|
||||||
|
return _is_patronymic(t)
|
||||||
|
|
||||||
|
anchors: set[int] = set()
|
||||||
|
for i, t in enumerate(toks):
|
||||||
|
cap_word = bool(_CAPITALISED.match(t.text))
|
||||||
|
if is_name_anchor(i, t):
|
||||||
|
anchors.add(i)
|
||||||
|
elif cap_word and not t.initial and _is_surname(t, idx, weak=False):
|
||||||
|
# Mid-sentence capitalised -ов/-ская word: "был у Петрова". May over-scrub a city (Ростов).
|
||||||
|
anchors.add(i)
|
||||||
|
elif (
|
||||||
|
cap_word
|
||||||
|
and not t.initial
|
||||||
|
and i + 1 < len(toks)
|
||||||
|
and adjacent(t, toks[i + 1])
|
||||||
|
and _CAPITALISED.match(toks[i + 1].text)
|
||||||
|
):
|
||||||
|
# Two capitalised words mid-sentence: "встретил Петрова Ивана".
|
||||||
|
anchors.update({i, i + 1})
|
||||||
|
|
||||||
|
spans: list[tuple[int, int]] = []
|
||||||
|
covered: set[int] = set()
|
||||||
|
for i in sorted(anchors):
|
||||||
|
if i in covered:
|
||||||
|
continue
|
||||||
|
lo = hi = i
|
||||||
|
lower_anchor = not toks[i].cap
|
||||||
|
# Extend right over the rest of the full name (surname, patronymic).
|
||||||
|
while hi + 1 < len(toks) and hi - lo + 1 < _MAX_NAME_TOKENS and adjacent(toks[hi], toks[hi + 1]):
|
||||||
|
nxt = toks[hi + 1]
|
||||||
|
capitalised = nxt.cap and _CAPITALISED.match(nxt.text)
|
||||||
|
# "алексей петров" typed in lowercase: extend only over a clearly surname-like word.
|
||||||
|
lower_tail = lower_anchor and (_is_surname(nxt, idx, weak=False) or _PATRONYMIC.match(nxt.low))
|
||||||
|
if capitalised or (hi + 1) in anchors or lower_tail:
|
||||||
|
hi += 1
|
||||||
|
else:
|
||||||
|
break
|
||||||
|
# Extend left only over a surname-looking word ("Иванов Иван"), never over "Сегодня Иван".
|
||||||
|
while lo > 0 and hi - lo + 1 < _MAX_NAME_TOKENS and adjacent(toks[lo - 1], toks[lo]):
|
||||||
|
prv = toks[lo - 1]
|
||||||
|
if prv.cap and _CAPITALISED.match(prv.text) and _is_surname(prv, idx, weak=True):
|
||||||
|
lo -= 1
|
||||||
|
else:
|
||||||
|
break
|
||||||
|
covered.update(range(lo, hi + 1))
|
||||||
|
spans.append((toks[lo].start, toks[hi].end))
|
||||||
|
return spans
|
||||||
|
|
||||||
|
|
||||||
|
def _scrub_names(text: str) -> str:
|
||||||
|
out: list[str] = []
|
||||||
|
pos = 0
|
||||||
|
for start, end in _name_spans(text):
|
||||||
|
out.append(text[pos:start])
|
||||||
|
out.append("[NAME]")
|
||||||
|
pos = end
|
||||||
|
out.append(text[pos:])
|
||||||
|
# "[NAME] [NAME]" produced by separate spans is one person in practice.
|
||||||
|
return re.sub(r"\[NAME\](?:[ \t]+\[NAME\])+", "[NAME]", "".join(out))
|
||||||
|
|
||||||
|
|
||||||
|
def _scrub_once(text: str) -> str:
|
||||||
|
for pattern, repl in _REGEX_PASSES:
|
||||||
|
text = pattern.sub(repl, text)
|
||||||
|
return _scrub_names(text)
|
||||||
|
|
||||||
|
|
||||||
|
_MAX_PASSES = 5
|
||||||
|
|
||||||
|
|
||||||
def scrub(text: str) -> ScrubbedText:
|
def scrub(text: str) -> ScrubbedText:
|
||||||
"""Replace personal data (phones, emails, SNILS, names, addresses...) with placeholders."""
|
"""Replace personal data (phones, emails, SNILS, names, addresses...) with placeholders.
|
||||||
raise NotImplementedError
|
|
||||||
|
Placeholders: [PHONE] [EMAIL] [SNILS] [PASSPORT] [INN] [CARD] [DATE] [ADDRESS] [NAME] [URL] [HANDLE].
|
||||||
|
Idempotent: `scrub(scrub(x)) == scrub(x)`.
|
||||||
|
"""
|
||||||
|
# Removing one identifier can expose another pattern boundary; iterate to a fixed point.
|
||||||
|
for _ in range(_MAX_PASSES):
|
||||||
|
new = _scrub_once(text)
|
||||||
|
if new == text:
|
||||||
|
break
|
||||||
|
text = new
|
||||||
|
return ScrubbedText(text)
|
||||||
|
|
||||||
|
|
||||||
def pseudo_id(snils: str, key: str | None = None) -> str:
|
def pseudo_id(snils: str, key: str | None = None) -> str:
|
||||||
"""HMAC-SHA256(PSEUDO_KEY, normalised SNILS) as 32 hex chars.
|
"""HMAC-SHA256(PSEUDO_KEY, normalised SNILS) as 32 hex chars.
|
||||||
|
|
||||||
The token is the only link between the identity and clinical databases.
|
The token is the only link between the identity and clinical databases.
|
||||||
|
SNILS is normalised to 11 digits (spaces and dashes removed); anything else raises `ValueError`.
|
||||||
|
The checksum is not validated: demo data is synthetic.
|
||||||
"""
|
"""
|
||||||
raise NotImplementedError
|
digits = re.sub(r"[\s\-]", "", snils)
|
||||||
|
if len(digits) != 11 or not digits.isascii() or not digits.isdigit():
|
||||||
|
raise ValueError("SNILS must contain exactly 11 digits")
|
||||||
|
secret = (key if key is not None else get_settings().pseudo_key).encode("utf-8")
|
||||||
|
return hmac.new(secret, digits.encode("ascii"), hashlib.sha256).hexdigest()[:32]
|
||||||
|
|||||||
@@ -0,0 +1,254 @@
|
|||||||
|
# Name lexicon for app.core.privacy.scrub() (issue #2). Synthetic-safe: just a list of common Russian names.
|
||||||
|
#
|
||||||
|
# names — nominative forms; case forms (Ивана, Ирине, Сашей...) are generated in code.
|
||||||
|
# Extra explicit forms may follow on the same line, space-separated
|
||||||
|
# (needed for fleeting vowels: "Павел Павла Павлу...").
|
||||||
|
# ambiguous_names — names that are also ordinary words (вера, надежда, роман...). All their forms are
|
||||||
|
# treated as names only with context: capitalised mid-sentence or next to a
|
||||||
|
# patronymic/surname, or after "меня зовут".
|
||||||
|
# ambiguous_forms — single generated forms that collide with ordinary words (кости, коли, жене...).
|
||||||
|
# surname_stopwords — capitalised words that look like surnames (-ов/-ова...) but are ordinary words.
|
||||||
|
|
||||||
|
names:
|
||||||
|
# male
|
||||||
|
- Александр
|
||||||
|
- Алексей
|
||||||
|
- Анатолий
|
||||||
|
- Андрей
|
||||||
|
- Антон
|
||||||
|
- Аркадий
|
||||||
|
- Арсений
|
||||||
|
- Артём
|
||||||
|
- Артем
|
||||||
|
- Артур
|
||||||
|
- Борис
|
||||||
|
- Вадим
|
||||||
|
- Валентин
|
||||||
|
- Валерий
|
||||||
|
- Василий
|
||||||
|
- Виктор
|
||||||
|
- Виталий
|
||||||
|
- Владимир
|
||||||
|
- Владислав
|
||||||
|
- Вячеслав
|
||||||
|
- Геннадий
|
||||||
|
- Георгий
|
||||||
|
- Глеб
|
||||||
|
- Григорий
|
||||||
|
- Даниил
|
||||||
|
- Данил
|
||||||
|
- Данила
|
||||||
|
- Денис
|
||||||
|
- Дмитрий
|
||||||
|
- Евгений
|
||||||
|
- Егор
|
||||||
|
- Ефим
|
||||||
|
- Иван
|
||||||
|
- Игорь
|
||||||
|
- Илья
|
||||||
|
- Ильдар
|
||||||
|
- Кирилл
|
||||||
|
- Константин
|
||||||
|
- Леонид
|
||||||
|
- Максим
|
||||||
|
- Марат
|
||||||
|
- Матвей
|
||||||
|
- Михаил
|
||||||
|
- Никита
|
||||||
|
- Николай
|
||||||
|
- Олег
|
||||||
|
- Павел Павла Павлу Павлом Павле
|
||||||
|
- Пётр Петра Петру Петром Петре
|
||||||
|
- Петр Петра Петру Петром Петре
|
||||||
|
- Ринат
|
||||||
|
- Ростислав
|
||||||
|
- Руслан
|
||||||
|
- Рустам
|
||||||
|
- Святослав
|
||||||
|
- Семён
|
||||||
|
- Семен
|
||||||
|
- Сергей
|
||||||
|
- Станислав
|
||||||
|
- Степан
|
||||||
|
- Тарас
|
||||||
|
- Тимофей
|
||||||
|
- Тимур
|
||||||
|
- Фёдор
|
||||||
|
- Федор
|
||||||
|
- Филипп
|
||||||
|
- Эдуард
|
||||||
|
- Юрий
|
||||||
|
- Яков
|
||||||
|
- Ярослав
|
||||||
|
# male diminutives
|
||||||
|
- Алёша
|
||||||
|
- Алеша
|
||||||
|
- Лёша
|
||||||
|
- Леша
|
||||||
|
- Боря
|
||||||
|
- Ваня
|
||||||
|
- Вася
|
||||||
|
- Витя
|
||||||
|
- Вова
|
||||||
|
- Володя
|
||||||
|
- Влад
|
||||||
|
- Гоша
|
||||||
|
- Гриша
|
||||||
|
- Даня
|
||||||
|
- Дима
|
||||||
|
- Егорка
|
||||||
|
- Женя
|
||||||
|
- Костя
|
||||||
|
- Коля
|
||||||
|
- Лёня
|
||||||
|
- Леня
|
||||||
|
- Макс
|
||||||
|
- Миша
|
||||||
|
- Митя
|
||||||
|
- Паша
|
||||||
|
- Петя
|
||||||
|
- Рома
|
||||||
|
- Саша
|
||||||
|
- Серёжа
|
||||||
|
- Сережа
|
||||||
|
- Стас
|
||||||
|
- Стёпа
|
||||||
|
- Степа
|
||||||
|
- Толя
|
||||||
|
- Федя
|
||||||
|
- Юра
|
||||||
|
- Гена
|
||||||
|
# female
|
||||||
|
- Алина
|
||||||
|
- Алиса
|
||||||
|
- Алла
|
||||||
|
- Алёна
|
||||||
|
- Алена
|
||||||
|
- Анастасия
|
||||||
|
- Ангелина
|
||||||
|
- Анна
|
||||||
|
- Антонина
|
||||||
|
- Валентина
|
||||||
|
- Варвара
|
||||||
|
- Василиса
|
||||||
|
- Вероника
|
||||||
|
- Галина
|
||||||
|
- Дарья
|
||||||
|
- Диана
|
||||||
|
- Евгения
|
||||||
|
- Екатерина
|
||||||
|
- Елена
|
||||||
|
- Елизавета
|
||||||
|
- Жанна
|
||||||
|
- Зинаида
|
||||||
|
- Инна
|
||||||
|
- Ирина
|
||||||
|
- Карина
|
||||||
|
- Кристина
|
||||||
|
- Ксения
|
||||||
|
- Лариса
|
||||||
|
- Лидия
|
||||||
|
- Людмила
|
||||||
|
- Маргарита
|
||||||
|
- Марина
|
||||||
|
- Мария
|
||||||
|
- Милана
|
||||||
|
- Наталья
|
||||||
|
- Наталия
|
||||||
|
- Нина
|
||||||
|
- Оксана
|
||||||
|
- Олеся
|
||||||
|
- Ольга
|
||||||
|
- Полина
|
||||||
|
- Раиса
|
||||||
|
- Регина
|
||||||
|
- Светлана
|
||||||
|
- Софья
|
||||||
|
- София
|
||||||
|
- Тамара
|
||||||
|
- Татьяна
|
||||||
|
- Ульяна
|
||||||
|
- Эльвира
|
||||||
|
- Юлия
|
||||||
|
- Яна
|
||||||
|
# female diminutives
|
||||||
|
- Аня
|
||||||
|
- Валя
|
||||||
|
- Варя
|
||||||
|
- Вика
|
||||||
|
- Галя
|
||||||
|
- Даша
|
||||||
|
- Ира
|
||||||
|
- Катя
|
||||||
|
- Ксюша
|
||||||
|
- Лена
|
||||||
|
- Лиза
|
||||||
|
- Люда
|
||||||
|
- Маша
|
||||||
|
- Надя
|
||||||
|
- Настя
|
||||||
|
- Наташа
|
||||||
|
- Оля
|
||||||
|
- Поля
|
||||||
|
- Рита
|
||||||
|
- Таня
|
||||||
|
- Юля
|
||||||
|
# ambiguous ones are listed in names too, so their case forms are generated
|
||||||
|
- Вера
|
||||||
|
- Виктория
|
||||||
|
- Лев Льва Льву Львом Льве
|
||||||
|
- Любовь Любови Любовью
|
||||||
|
- Люба
|
||||||
|
- Мила
|
||||||
|
- Надежда
|
||||||
|
- Ника
|
||||||
|
- Роза
|
||||||
|
- Роман
|
||||||
|
- Света
|
||||||
|
- Слава
|
||||||
|
- Соня
|
||||||
|
- Тома
|
||||||
|
|
||||||
|
ambiguous_names:
|
||||||
|
- Вера
|
||||||
|
- Виктория
|
||||||
|
- Лев
|
||||||
|
- Любовь
|
||||||
|
- Люба
|
||||||
|
- Мила
|
||||||
|
- Надежда
|
||||||
|
- Ника
|
||||||
|
- Роза
|
||||||
|
- Роман
|
||||||
|
- Света
|
||||||
|
- Слава
|
||||||
|
- Соня
|
||||||
|
- Тома
|
||||||
|
- Поля
|
||||||
|
|
||||||
|
ambiguous_forms:
|
||||||
|
- коли
|
||||||
|
- вали
|
||||||
|
- кости
|
||||||
|
- костей
|
||||||
|
- жене
|
||||||
|
- жени
|
||||||
|
- гены
|
||||||
|
- машу
|
||||||
|
- семена
|
||||||
|
- вари
|
||||||
|
- кати
|
||||||
|
|
||||||
|
surname_stopwords:
|
||||||
|
- снова
|
||||||
|
- готов
|
||||||
|
- готова
|
||||||
|
- здоров
|
||||||
|
- здорова
|
||||||
|
- слова
|
||||||
|
- основа
|
||||||
|
- один
|
||||||
|
- часов
|
||||||
|
- разов
|
||||||
|
- врачей
|
||||||
|
- сонов
|
||||||
@@ -0,0 +1,199 @@
|
|||||||
|
"""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)
|
||||||
Reference in New Issue
Block a user