Merge pull request 'fix(llm): INN redaction requires context anchor, stop eating large numbers (#1640)' (#1682) from fix/inn-regex-context-1640 into main
Some checks are pending
Deploy / changes (push) Waiting to run
Deploy / build-backend (push) Blocked by required conditions
Deploy / build-worker (push) Blocked by required conditions
Deploy / build-frontend (push) Blocked by required conditions
Deploy / deploy (push) Blocked by required conditions

This commit is contained in:
lekss361 2026-06-17 17:55:06 +00:00
commit c0454d8905
2 changed files with 76 additions and 20 deletions

View file

@ -87,12 +87,14 @@ _PHONE_BARE_RE = re.compile(r"(?<!\d)[78]\d{10}(?!\d)")
_PHONE_LOCAL_RE = re.compile(r"(?<!\d)9\d{2}[\s-]\d{3}[\s-]\d{2}[\s-]\d{2}(?!\d)")
# Email.
_EMAIL_RE = re.compile(r"\b[A-Za-z0-9._%+\-]+@[A-Za-z0-9.\-]+\.[A-Za-z]{2,}\b")
# ИНН: ровно 12 (физлицо) или 10 (юрлицо) цифр, не приклеенные к другим цифрам.
# ВНИМАНИЕ: regex лишь НАХОДИТ кандидатов; редактируется только то, что проходит
# контроль-сумму ФНС (см. _inn_checksum_valid + _inn_repl). Это сделано, чтобы
# легитимные слитные суммы в рублях (1_000_000_000, 123456789012) НЕ затирались
# маркером [REDACTED:inn] перед отправкой в LLM (#1640).
_INN_RE = re.compile(r"(?<!\d)(?:\d{12}|\d{10})(?!\d)")
# ИНН: требуется контекстный якорь — маркер «ИНН» / «inn» (регистронезависимо) не
# далее ~20 символов перед 10/12-значным блоком цифр (#1640). Голые цифры без якоря
# НЕ редактируются — это исключает легитимные суммы (1 200 000 000 = 10 цифр) и
# прочие числа, которые случайно совпадают с длиной ИНН.
# «Прокладка» (?:[^\d\n]{0,20}) позволяет «ИНН физлица 500100732259» (слово между
# маркером и числом), но не перешагивает через строки или другие цифровые блоки.
# Группа 1 захватывает только цифры — передаётся в _inn_checksum_valid.
_INN_RE = re.compile(r"(?i:инн|inn)\s*[:№]?\s*(?:[^\d\n]{0,20})(\d{12}|\d{10})(?!\d)")
def _inn_checksum_valid(token: str) -> bool:
@ -102,15 +104,10 @@ def _inn_checksum_valid(token: str) -> bool:
12-значный (физлицо/ИП): две контрольные цифры (11-я и 12-я).
Веса стандартные коэффициенты ФНС.
Назначение сузить вторичный regex-scrub так, чтобы случайные слитные
10/12-значные числа (крупные суммы в рублях) НЕ принимались за ИНН (#1640).
Ограничение (accepted limit belt-and-suspenders слоя): контроль-сумма НЕ
разрешает полностью неоднозначность «сумма vs ИНН» примерно 1/11 произвольных
10-значных чисел случайно проходит ИНН-10 контроль (в т.ч. буквальный пример
аудита 1200000000, который остаётся под редактированием). Полное устранение
false-positive на суммах (требовать рядом якорь «ИНН» либо вовсе убрать
INN-scrub из chat free-text) бизнес-решение, оставлено на Leha.
Вызывается из _inn_repl с group(1) только цифровой блок без keyword-префикса.
Дополнительный gate поверх контекстного якоря (_INN_RE): отфильтровывает
синтетические 10/12-значные числа, у которых первые цифры случайно дают верную
контрольную сумму.
"""
digits = [int(c) for c in token]
if len(digits) == 10:
@ -127,13 +124,14 @@ def _inn_checksum_valid(token: str) -> bool:
def _inn_repl(match: re.Match[str]) -> str:
"""re.sub callback: редактировать ИНН-кандидата только при валидной контроль-сумме.
"""re.sub callback: редактировать ИНН только при валидной контрольной сумме.
Иначе вернуть исходный токен без изменений так слитные суммы в рублях
переживают scrub (#1640).
_INN_RE захватывает цифры в group(1) keyword-префикс в group(0). Для замены
нужна только часть с цифрами (group 1). Если checksum не проходит возвращаем
исходную строку целиком (keyword + цифры), не удаляем ничего.
"""
token = match.group(0)
return "[REDACTED:inn]" if _inn_checksum_valid(token) else token
digits = match.group(1)
return "[REDACTED:inn]" if _inn_checksum_valid(digits) else match.group(0)
# СНИЛС «голый»: ровно 11 цифр без разделителей (#1207). _SNILS_RE требует

View file

@ -210,3 +210,61 @@ def test_safe_payload_defaults_are_empty_and_non_confidential() -> None:
assert p.text == ""
assert p.fields == {}
assert p.is_confidential is False
# ── #1640: INN context-guard — large bare numbers must NOT be redacted ────────
def test_large_sum_10_digits_not_redacted_without_inn_keyword() -> None:
"""Слитная 10-значная сумма (1 200 000 000 руб) без якоря «ИНН» — НЕ редактируется.
Регрессия #1640: старый _INN_RE ловил любые 10/12 цифр → «1200000000» уходило в
[REDACTED:inn]. Новый паттерн требует маркер ИНН/inn перед числом.
"""
text = "стоимость участка составила 1200000000 рублей по договору"
out = scrub_text(text)
assert out == text, f"bare large sum was incorrectly redacted: {out!r}"
def test_large_sum_12_digits_not_redacted_without_inn_keyword() -> None:
"""12-значная слитная сумма без якоря — НЕ редактируется."""
text = "капитализация компании 123456789012 рублей"
out = scrub_text(text)
assert out == text
def test_round_billion_not_redacted() -> None:
"""Круглые суммы без контекста — НЕ редактируются."""
for amount in ("5000000000", "1000000000", "9876543210"):
out = scrub_text(f"сумма {amount} руб")
assert "[REDACTED:inn]" not in out, f"amount {amount!r} was falsely redacted"
def test_inn_with_keyword_is_redacted() -> None:
"""Валидный ИНН с явным маркером «ИНН» — редактируется."""
out = scrub_text("ИНН 7707083893 зарегистрирован в ЕГРЮЛ")
assert "[REDACTED:inn]" in out
assert "7707083893" not in out
def test_inn_12_with_keyword_is_redacted() -> None:
"""Валидный 12-значный ИНН физлица с маркером — редактируется."""
out = scrub_text("ИНН физлица 500100732259 подтверждён")
assert "[REDACTED:inn]" in out
assert "500100732259" not in out
def test_inn_keyword_latin_case_insensitive() -> None:
"""Маркер 'inn' (латиница, любой регистр) тоже активирует редакцию."""
out = scrub_text("inn: 7707083893")
assert "[REDACTED:inn]" in out
def test_inn_bad_checksum_with_keyword_not_redacted() -> None:
"""Число с маркером «ИНН», но не прошедшее checksum, НЕ редактируется.
Предотвращает ложные срабатывания на «ИНН 1234567890» (неверная контрольная цифра).
"""
out = scrub_text("ИНН 1234567890 (некорректный)")
assert "[REDACTED:inn]" not in out
assert "1234567890" in out