Вот результат обработки искусственного документа нашим локальным Pii-Mask:
{{PERSON_1}}, инженер. Номер в отраслевом реестре: Z-99-65432104.
Маскер закрыл ФИО, но оставил номер. Здесь он вымышленный; в настоящем документе такой идентификатор может позволить найти человека в открытом реестре. Успешное завершение маскера этого не исключает.
Перед передачей текста в модель мы ищем в нем известные идентификаторы документа. Список для этой проверки составляется по исходному материалу: если брать только найденные маскером сущности, пропущенный идентификатор не попадет в список для проверки.
Что проверяем
В эталонных тестах Pii-Mask есть списки MUST_MASK и MUST_KEEP: фрагменты, которые должны исчезнуть, и контекст, который должен сохраниться.
Для эксперимента я подготовил восемь искусственных примеров: контакты, составную фамилию с неразрывным дефисом, ФИО по строкам, организацию в двух падежах, фамилию с инициалами, Telegram-ник, номер реестра и повторное ФИО в другом падеже. Всего 22 требования к скрытию фрагментов и 13 к сохранению контекста. Клиентские документы не использовались.
Здесь мы скрываем также работодателя. Нужные ограничения определяются задачей: инструмент может распознавать лишь часть типов данных, которые нужно скрыть.
В рабочем процессе список хранится локально рядом с материалами. Его приходится составлять и обновлять вручную; проверка особенно полезна для документов с заранее известными именами и номерами. В эксперименте путь к списку всегда передается явно через --case. Рабочий скрипт умеет искать его и в родительских каталогах, но так можно выбрать список от другого документа.
Поиск и отказ
Проверка ищет подстроки без учета регистра, заменяя U+0451 на е. Для идентификаторов с шестью и более цифрами проверка дополнительно ищет последовательность цифр в тексте, из которого удалены все нецифровые символы. У 11-значных номеров, начинающихся с 7 или 8, отбрасывается первая цифра. Это помогает сравнить разные записи телефона, но применяется ко всем таким номерам.
Правило грубое. Фамилия Ким найдется внутри Акимов, а номер 123456 - в строке Бюджет 123 рубля. Срок 456 дней. из-за склейки цифр. Это ложные срабатывания проверки; ноль потерь полезного контекста в таблице ниже их не измеряет.
Результаты рабочего скрипта:
0- перечисленные идентификаторы не найдены;3- найден хотя бы один;2- список отсутствует или пуст.
Для строки с номером реестра получили 3, для отсутствующего списка и файла из одного комментария - 2. Отправки в LLM в эксперименте не было. Управляющий скрипт должен разрешать продолжение только при коде 0, включая отказ при любой другой ошибке.
Здесь легко потерять статус: check | tail -1 без pipefail возвращает код последней команды. Если tail завершился успешно, ошибка проверки скрывается. Проще запускать проверку напрямую и обрабатывать ее код.
Результаты
Прогон выполнен на коммите c2a03fc841be638898ae12b96b219f8b3aebe0ac, Python 3.12, с обычными настройками Masker(), без LLM-аудитора. На том же наборе отдельно отключены нормализация типографики и настройка ner. NER означает распознавание именованных сущностей; в Pii-Mask отключение этой настройки затрагивает и связанные этапы обработки организаций и географических названий.
Режим | Остатки из 22 | Примеров с остатками из 8 | Потери контекста из 13 |
|---|---|---|---|
Текущий Pii-Mask | 1 | 1 | 0 |
Отключена нормализация | 2 | 2 | 0 |
| 12 | 4 | 0 |
В текущем режиме остался номер реестра. Без нормализации добавилась первая половина фамилии с неразрывным дефисом. При ner=False добавились имена и фамилии в обычных строках и повторных упоминаниях. Телефон, email и инициалы продолжали скрываться форматными правилами.
В этих прогонах отключались отдельные этапы обработки на одном коммите. Исторические релизы не сравнивались. Проверка отреагировала на ослабление маскировки. Число 22 обозначает требования к фрагментам, включая части одного ФИО и повторные упоминания. Оно не дает recall распознавания или вероятности безопасной передачи произвольного документа: набор небольшой и содержит известные классы ошибок.
Восстановление и границы проверки
Дополнительно я сравнил исходные строки с восстановленными после обратной подстановки меток, то есть проверил roundtrip. Два примера не совпали. В составной фамилии неразрывный дефис стал обычным. Организация, указанная в двух падежах, получила одну метку с одним сохраненным написанием: обе формы при восстановлении стали одинаковыми. Оба результата относятся к указанному коммиту.
Точное восстановление само по себе не проверяет полноту маскировки. Открытый номер реестра проходит через оба шага без изменения: строки совпадут, хотя идентификатор остался.
Поиск ограничен заданными значениями. Если в списке есть только Анна Валерьевна, остаток {{PERSON_1}} Валерьевна он пропустит. Фамилии, части ФИО, склонения и альтернативные записи приходится задавать отдельно. Даже полный список прямых идентификаторов не исключает узнавания по редкому сочетанию должности, города и проекта; избыточную детализацию нужно оценивать отдельно.
Для PDF проверяется извлеченный текст, который пойдет в модель. Pii-Mask использует pdftotext -layout; маскировка текста не удаляет данные из исходного PDF и не проверяет изображения, вложения или метаданные. Здесь PDF не создавался: варианты верстки заданы строками.
На этом наборе есть конкретный результат: маскер оставил номер реестра, а список ожиданий позволил его обнаружить. Если при отключении защиты ваша проверка продолжает показывать тот же результат, стоит проверить, какие пропуски она способна замечать.
Повторить эксперимент: код и установка
Установка проверенной версии:
git clone https://github.com/dewil/pii-mask.git cd pii-mask git checkout c2a03fc841be638898ae12b96b219f8b3aebe0ac python3 -m venv .venv .venv/bin/pip install -e .
Сохраните код в check_residuals.py и запустите .venv/bin/python check_residuals.py. Он повторяет восемь примеров и таблицу. ner=True соответствует настройке по умолчанию.
import re from unittest.mock import patch from pii_mask.core import Masker # Все значения искусственные. Текст, скрываемые фрагменты, сохраняемые фрагменты. cases = [ ("Смирнова Анна Валерьевна, аналитик. Почта: anna.test@example.org. Телефон: +7 (912) 3456789. Навыки: Python, SQL.", ["Смирнова", "Анна Валерьевна", "anna.test@example.org", "+7 (912) 3456789"], ["аналитик", "Python", "SQL"]), ("Корнеева\u2011Ким Алина Рустамовна, руководитель. Бюджет 200 млн рублей.", ["Корнеева", "Ким", "Алина Рустамовна"], ["руководитель", "200 млн рублей"]), ("Исполнитель:\nИВАНОВ ПЕТР\nСЕРГЕЕВИЧ\nДолжность: аналитик.", ["ИВАНОВ", "ПЕТР", "СЕРГЕЕВИЧ"], ["Должность", "аналитик"]), ('ООО "Ромашка" - основное место работы. Работал в Ромашке 11 лет. Навыки: SQL.', ["Ромашка", "Ромашке"], ["11 лет", "SQL"]), ("Документ подписала Страхова М.Е. Дата: 01.09.2026.", ["Страхова", "М.Е."], ["01.09.2026"]), ("Контакт в Telegram: @sample_mask_user. Обсуждаем Python.", ["@sample_mask_user"], ["Python"]), ("Смирнова Анна Валерьевна, инженер. Номер в отраслевом реестре: Z-99-65432104.", ["Смирнова", "Анна Валерьевна", "Z-99-65432104"], ["инженер"]), ("Смирнова Анна Валерьевна ведет проект. Ответ направить Смирновой Анне Валерьевне. Срок: 10 дней.", ["Смирнова", "Анна Валерьевна", "Смирновой", "Анне Валерьевне"], ["10 дней"]), ] def norm(s): return s.lower().replace("\u0451", "е") def remains(ident, text): # Подстроки и склейка цифр могут давать ложные совпадения. if norm(ident) in norm(text): return True digits = re.sub(r"\D", "", ident) if len(digits) == 11 and digits[0] in "78": digits = digits[1:] return len(digits) >= 6 and digits in re.sub(r"\D", "", text) if __name__ == "__main__": for mode in ("current", "normalization_off", "ner_off"): residuals = affected = lost = 0 for source, hide, keep in cases: masker = Masker(ner=mode != "ner_off") if mode == "normalization_off": with patch("pii_mask.core.normalize_for_analysis", lambda s: s): masked, _ = masker.mask(source) else: masked, _ = masker.mask(source) hits = [value for value in hide if remains(value, masked)] residuals += len(hits) affected += bool(hits) lost += sum(value not in masked for value in keep) print(mode, residuals, affected, lost)
Ожидаемый вывод:
current 1 1 0 normalization_off 2 2 0 ner_off 12 4 0
Для проверки кодов отказа сохраните рядом guard_example.py. Это самостоятельный пример на той же функции поиска. Он требует явный путь к списку и возвращает 2 также при ошибке чтения; реализацию рабочего скрипта целиком здесь не воспроизводим.
import sys from pathlib import Path from check_residuals import remains def check(text_path, ids_path): try: # Путь к списку обязателен; поиск по родителям не используется. identifiers = [s.strip() for s in Path(ids_path).read_text().splitlines() if s.strip() and not s.lstrip().startswith("#")] if not identifiers: return 2 text = Path(text_path).read_text() except (OSError, UnicodeError): return 2 return 3 if any(remains(value, text) for value in identifiers) else 0 if __name__ == "__main__": if len(sys.argv) != 3: sys.exit(2) sys.exit(check(sys.argv[1], sys.argv[2]))
Запуск: .venv/bin/python guard_example.py masked.txt identifiers.txt. В identifiers.txt - по одному ожидаемому фрагменту на строку; пустые строки и комментарии с # игнорируются. Код 0 разрешает следующий шаг, любой другой требует остановки. Сам пример текст никуда не отправляет.

