InquirerPiston protests World Bank-led transport conference in BaguioCNN TürkBeşiktaş'a yıldız futbolcularından iyi haberInquirer EntertainmentMiss Universe factions clash over 2027 host country claimsThe Jerusalem PostThree killed, several wounded following two attacks on Saudi Arabia's King Khalid airportESPN DeportesMessi entrenó con Inter Miami tras homenajePunchMother, daughter die in Anambra three-storey building collapseBollywood HungamaMeezaan Jafri headlines Killer Jeans' Genes of India campaignEl ComercioTrump niega un ataque contra Irán antes de las elecciones, pero el Pentágono prepara opciones de combateZDF heuteEntdecken Sie das ZDF-NachrichtenstudioStraits Times SportMaddinson's test return for Australia after cancer treatment ends in duckCollider10 Essential Anime Shows That Belong on Every Fan's Bucket ListBBC News BrasilQuem é Navi Pillay, sul-africana que ganhou o Nobel da Paz 2026 e julgou genocídio em Ruanda
The Daily Newsstand · Free, Always
Friday, October 9, 2026

Как мы проверяем, какие данные остаются в тексте после маскировки перед LLM

Translate

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

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

View the original on Хабр →

KioskNews shows a cleaned-up reading view extracted from the publisher’s page — the original always lives on their site, not ours.