From 5c5be890ab8bd3e956006fc1b36fe96e2678419e Mon Sep 17 00:00:00 2001 From: bot-backend Date: Wed, 17 Jun 2026 20:41:39 +0300 Subject: [PATCH] fix(llm): INN redaction requires context/checksum, stop eating large numbers (#1640) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit _INN_RE now requires an explicit ИНН/inn keyword anchor (case-insensitive) within ~20 chars before a 10/12-digit block. Bare digit sequences without the keyword are no longer candidates — eliminates false-positives on large monetary amounts such as 1 200 000 000 (which coincidentally passes the ФНС checksum). Checksum validation is kept as a second gate to avoid redacting e.g. «ИНН 1234567890» with bad digits. _inn_repl updated to use match.group(1) (digit-only capture group) instead of the full match that now also includes the keyword prefix. 7 new regression tests in test_redaction.py: bare large numbers not redacted, keyword-cued real INNs (10/12 digit) still redacted, bad-checksum + keyword left intact, latin «inn:» accepted. --- backend/app/services/llm/redaction.py | 38 ++++++------- backend/tests/services/llm/test_redaction.py | 58 ++++++++++++++++++++ 2 files changed, 76 insertions(+), 20 deletions(-) diff --git a/backend/app/services/llm/redaction.py b/backend/app/services/llm/redaction.py index 5ace6d7f..b64deae3 100644 --- a/backend/app/services/llm/redaction.py +++ b/backend/app/services/llm/redaction.py @@ -87,12 +87,14 @@ _PHONE_BARE_RE = re.compile(r"(? 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 требует diff --git a/backend/tests/services/llm/test_redaction.py b/backend/tests/services/llm/test_redaction.py index cd8d9750..ced78899 100644 --- a/backend/tests/services/llm/test_redaction.py +++ b/backend/tests/services/llm/test_redaction.py @@ -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