fix(llm): INN redaction requires context anchor, stop eating large numbers (#1640) #1682
2 changed files with 76 additions and 20 deletions
|
|
@ -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 требует
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue