Прокси между системой банка и LLM. Находит в тексте ПДн 19 типов, заменяет их масками до отправки в модель и возвращает исходные значения в ответ. В LLM уходит только замаскированный текст.
Система-потребитель -> Контур [идентификация -> маскирование] -> LLM -> Контур [демаскирование] -> Система-потребитель
| ^
+-- соответствия в Redis (AES-GCM) +
Результаты на момент сдачи, условия замеров в docs/PERFORMANCE.md:
| Что | Значение |
|---|---|
| Слепой набор v1, 487 примеров | span similarity 0,994, ложные срабатывания на ловушках 0% |
| Слепой набор v2, отложенная половина, 249 примеров | span similarity 0,972, ложные срабатывания 0,9% |
| Открытый бенчмарк redmadrobot, отложенная половина, 1184 примера | span similarity 0,888, полнота сущностей 0,53 |
| Нагрузка, пары маска/демаска | факт RPS 968, p50 18,7 мс, p95 232,5 мс, p99 371,0 мс, ошибок 0%, неточных восстановлений 2 |
| Текст ~100 тыс. токенов (360 тыс. символов) | маска и демаска за 3,7 с |
| Вопрос | Ответ и проверка |
|---|---|
| Насколько хорошо находятся ПДн | 19 типов, span similarity 0,994 на слепом наборе и 0,972 на отложенной половине второго; uv run python -m tools.eval datasets/golden/blind.jsonl |
| Сколько ложных срабатываний | 0% на ловушках первого слепого набора, 0,9% на втором: поэт Пушкин, адрес отделения банка, горячая линия, номер договора остаются открытыми; раздел 5 в docs/JURY.md |
| Сохраняется ли смысл для модели | значения заменяются типизированными метками [ФИО_1], [НОМЕР_КАРТЫ_1], одинаковое значение получает одну метку, служебные слова «серия», «ул.», «года» остаются; режим synthetic подставляет правдоподобные данные того же формата. Модель видит, что в тексте клиент, карта и телефон, и отвечает по делу, ответ демаскируется по меткам |
| Правила для разных систем | config/systems.yaml: типы ПДн, вид маски, запрет демаскирования, комбо-правила для каждой системы; админ-API меняет политику без перезапуска |
| Как подключить нового потребителя | блок в config/systems.yaml или PUT /admin/systems/{id}, ключ в переменной окружения; для LLM достаточно сменить base_url OpenAI-клиента на адрес модуля |
| Что при ошибках и отказах | Redis недоступен: память воркера и degraded в /health/ready; LLM недоступна: 502/504, данные не уходят; перегрузка: 429 с Retry-After; сбой детектора: весь текст закрывается [СКРЫТО_1]. Таблица в разделе 8 docs/JURY.md |
| Не попадают ли ПДн в логи и метрики | в логе только типы, количества и тайминги, поля с текстом заменяются на <redacted>, метки метрик без значений; это проверяет tests/test_no_leaks.py |
| Расширяемость | новый тип ПДн это новый YAML-файл в config/rules, подхватывается без перезапуска (тест test_new_type_from_yaml); новый сценарий это политика в systems.yaml |
Нужны Docker с Compose v2 и свободный порт 8080. Скопируйте .env.example в .env. Без правок
сервис работает, а для внешней LLM впишите LLM_BASE_URL и LLM_API_KEY. Запустите
docker compose up -d --build --wait: поднимутся API на 4 воркерах и Redis. Демо откроется на
http://localhost:8080/demo, описание API на http://localhost:8080/docs. Инструкция для жюри с
примерами запросов, логами и метриками лежит в docs/JURY.md.
Без Docker: uv sync --extra server, затем uv run granian pii_guard.api.app:app --interface asgi --port 8080 --workers 4.
Без REDIS_URL соответствия хранятся в памяти процесса, поэтому демаскирование работает только
при одном воркере.
curl -s localhost:8080/process -H 'Content-Type: application/json' \
-d '{"payload": "Клиент Иванов Иван, паспорт 4509 123456", "payload_id": "demo-1"}'
# {"result":"Клиент [ФИО_1], паспорт [ПАСПОРТ_1]"}
curl -s localhost:8080/process -H 'Content-Type: application/json' \
-d '{"payload": "Клиент [ФИО_1], паспорт [ПАСПОРТ_1]", "payload_id": "demo-1"}'
# {"result":"Клиент Иванов Иван, паспорт 4509 123456"}Первый вызов с новым payload_id маскирует, второй с маской и тем же payload_id демаскирует.
Повтор любого из двух вызовов (после ошибки или 429) возвращает тот же ответ. Запросы без
заголовков обслуживает система default.
| Метод и путь | Назначение |
|---|---|
POST /process |
контракт автопроверки: маска, затем демаска по payload_id |
POST /v1/mask |
маска и session_id для демаскирования, список найденных сущностей с позициями |
POST /v1/unmask |
восстановление по session_id, в том числе ответа LLM с переставленными токенами |
DELETE /v1/sessions/{id} |
досрочно удалить соответствия |
POST /v1/chat/completions |
OpenAI-совместимый прокси: маска, вызов LLM, демаска ответа |
GET /v1/types |
типы ПДн, которые знает модуль |
GET /admin/systems, PUT и DELETE /admin/systems/{id}, POST /admin/reload |
управление системами, нужен X-Admin-Key |
GET /stats |
RPS, TPS, p50/p95/p99 за 60 секунд по всем воркерам |
GET /metrics |
метрики Prometheus |
GET /health/live, GET /health/ready |
живость процесса и готовность зависимостей |
GET /demo |
демо-страница |
Система-потребитель передаёт X-System-Id и X-Api-Key (или Authorization: Bearer <ключ>).
Для LLM-прокси достаточно поменять base_url в OpenAI-клиенте на адрес модуля.
Настройки делятся на три уровня. Переменные окружения задают процесс, config/systems.yaml
задаёт политику каждой системы-потребителя, файлы в config/rules и config/gazetteers задают,
что считать ПДн. Все три уровня меняются без правки кода. YAML-файлы перечитываются на лету
каждым воркером, перезапуск не нужен.
| Переменная | По умолчанию | Смысл |
|---|---|---|
APP_NAME |
Контур |
имя сервиса в логах, заголовке API и метриках |
REDIS_URL |
пусто | хранилище соответствий; пусто означает память процесса |
VAULT_KEY |
пусто | ключ AES-256-GCM (32 байта, urlsafe base64); пусто: ключ создаётся в Redis при первом старте |
KEY_CRM, KEY_SUPPORT |
пусто | ключи систем; имя переменной указывается в systems.yaml полем api_key_env |
ADMIN_KEY |
пусто | ключ админ-API; пусто означает, что админ-API выключен |
LLM_BASE_URL, LLM_API_KEY, LLM_MODEL |
пусто, пусто, deepseek-ai/DeepSeek-V4-Flash-0731 |
внешняя LLM с OpenAI-совместимым API; без адреса прокси отвечает демо-ответом |
LLM_TIMEOUT_SECONDS, LLM_VERIFY_TLS |
90, true |
таймаут и проверка сертификата LLM |
WORKERS, RUNTIME_MODE |
4, mt |
процессы granian и режим рантайма |
MAX_CONCURRENT |
256 |
запросов в работе на воркер; сверх лимита ответ 429 с Retry-After |
MAX_BODY_BYTES, MAX_TEXT_CHARS |
4 МиБ, 1 000 000 | лимиты тела запроса и длины текста, сверх лимита 413 |
LOG_LEVEL |
INFO |
уровень логов; строка request.done на каждый запрос содержит типы ПДн и время каждого этапа |
METRICS_PREFIX |
pii |
префикс имён метрик Prometheus |
PORT, API_CPUS |
8080, 4 |
порт на хосте и лимит CPU контейнера в docker-compose.yml |
Блок defaults применяется ко всем системам, каждая система переопределяет нужные поля.
default_system обслуживает запросы без заголовков.
| Поле | Значения | Что делает |
|---|---|---|
enabled |
true / false |
выключенная система получает 403, текст не обрабатывается |
api_key_env |
имя переменной | ключ системы берётся из окружения, в YAML секретов нет |
entity_types |
all или список |
какие типы ПДн маскировать |
exclude_types |
список | исключения из all |
mask_mode |
placeholder, redact, stars, partial, synthetic |
вид маски: [ФИО_1], [ФИО], **** ****, 4276********5678, правдоподобные подставные данные |
type_modes |
тип: режим | свой режим для отдельных типов, например карта partial |
placeholder_lang |
ru / en |
язык меток: [ФИО_1] или [PERSON_1] |
unmask |
true / false |
разрешено ли системе демаскирование |
contextual |
true / false |
учитывать контекст: публичные персоны, адреса отделений, горячие линии |
single_value_mode |
true / false |
если весь текст одно значение без метки, угадать тип по форме |
bare_date_policy |
never, birth_like, always |
маскировать ли даты без слов «дата рождения» |
combo_rules |
список правил | маскировать тип только рядом с другим, например PIN только рядом с картой |
ttl_seconds |
60 .. 604800 | сколько хранить соответствия |
llm_proxy |
true / false |
доступен ли системе /v1/chat/completions |
Пример из репозитория: crm получает синтетические данные и частично открытую карту,
support_bot не может демаскировать и маскирует PIN и CVV только рядом с номером карты,
analytics выключена.
support_bot:
api_key_env: KEY_SUPPORT
unmask: false
exclude_types: [CITIZENSHIP]
combo_rules:
- {type: PIN, requires: [BANK_CARD], window: 200}Те же поля меняются через админ-API: PUT /admin/systems/{id} с JSON-телом из полей выше.
Модуль проверяет политику, отклоняет неизвестные типы с кодом 422 и атомарно перезаписывает файл.
Каждый файл описывает один тип: метки и список правил. Новый тип добавляется новым файлом,
ядро не меняется. Пример из теста tests/test_process_contract.py:
type: OMS_POLICY
label_ru: ПОЛИС_ОМС
label_en: OMS_POLICY
rules:
- id: oms_ctx
requires_any: ["полис"]
pattern: "полис\\p{L}*\\s+омс{SEP}(?P<v>\\d{16}){END}"
confidence: 0.99
priority: 130Маскируются именованные группы v, v1, v2 и так далее, текст вокруг остаётся. Поля правила:
| Поле | Смысл |
|---|---|
pattern |
регулярное выражение модуля regex, регистр не учитывается, макросы {SEP}, {END}, {DATE} и другие из config/rules/_macros.yaml |
priority, confidence |
кто побеждает при пересечении спанов |
validators |
проверка значения: luhn, inn, inn12, snils, phone_digits, not_repeated, date_any, birth_like, plausible_birth, issue_like |
negative_context, negative_window, negative_after |
слова рядом, при которых значение не ПДн: «горячая линия», «код ошибки» |
required_context, required_window |
слова, без которых правило не срабатывает |
requires_any, requires_regex, min_digits |
префильтр: правило не запускается на тексте без нужных подстрок или цифр |
subtype, gazetteer_check, policy |
подтип адреса, проверка по словарю, подчинение политике bare_date_policy |
Словари и контекст лежат рядом. config/gazetteers хранит имена, фамилии, страны и публичные
персоны, по одному слову в строке. config/context.yaml хранит маркеры публичной персоны,
клиента, организации и слова-триггеры перед ФИО. config/address.yaml хранит триггеры адреса и
места рождения и служебные слова, которые остаются открытыми.
uv sync --all-extras
uv run pytest -q # 38 тестов: контракт, качество, утечки
uv run python -m tools.eval datasets/golden/blind.jsonl --show 20
RATE=1000 DURATION=60s sh load/run.sh # k6 в Docker, сводка p50/p95/p99
docker compose --profile observability up -d # Prometheus :9090, Grafana :3000- docs/JURY.md: как отправить текст, получить маску и демаску, где логи и метрики.
- docs/ARCHITECTURE.md: схема, конвейер детекции, хранение соответствий, безопасность.
- docs/PERFORMANCE.md: нагрузочные прогоны, стенд, доп. возможности.
- docs/LIMITATIONS.md: где подход ошибается и план развития.