Вот результат обработки искусственного документа нашим локальным 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

ner=False

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 разрешает следующий шаг, любой другой требует остановки. Сам пример текст никуда не отправляет.

Эталонные тесты, ядро маскировки, тесты на типографику.