Compare commits
11
Commits
d4de78be7a
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4c4667bb8b | ||
|
|
ddceca076c | ||
|
|
7a388e3d4a | ||
|
|
4bf711197a | ||
|
|
41e77e4ca1 | ||
|
|
954d772fa2 | ||
|
|
cd59e37d3a | ||
|
|
573d94cca8 | ||
|
|
84b5adcb3f | ||
|
|
4bdb03341b | ||
|
|
5f77971a1c |
@@ -223,9 +223,9 @@ payload_id=doc-1 символов=64 найдено={FIO=1, PASSPORT=1, PHONE=1}
|
|||||||
|
|
||||||
- Хранилище соответствий по умолчанию — в памяти (`pdguard.store.backend=memory`),
|
- Хранилище соответствий по умолчанию — в памяти (`pdguard.store.backend=memory`),
|
||||||
сбрасывается при перезапуске. Для кластера используется Redis.
|
сбрасывается при перезапуске. Для кластера используется Redis.
|
||||||
- NER-модель второй ступени (`models/rubert-ner`) не входит в репозиторий и
|
- NER-модели второй ступени (`models/rubert-ner`, `models/wikineural-ner`,
|
||||||
скачивается скриптом `tools/fetch-ner-model.sh`; без неё сервис работает на
|
`models/ru-legal-ner`) не входят в репозиторий и скачиваются скриптом
|
||||||
правилах.
|
`tools/fetch-ner-model.sh`; без них сервис работает на правилах.
|
||||||
- Демаскирование доступно только системам с `demask: true` и корректным ключом.
|
- Демаскирование доступно только системам с `demask: true` и корректным ключом.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
@@ -9,8 +9,20 @@ services:
|
|||||||
image: pd-guard-spring:jvm
|
image: pd-guard-spring:jvm
|
||||||
ports:
|
ports:
|
||||||
- "8080:8080"
|
- "8080:8080"
|
||||||
|
deploy:
|
||||||
|
resources:
|
||||||
|
limits:
|
||||||
|
cpus: "4"
|
||||||
|
memory: 6g
|
||||||
environment:
|
environment:
|
||||||
|
# Дефолт JVM — 25% контейнерного лимита на heap.
|
||||||
|
JAVA_OPTS: >-
|
||||||
|
-Dspring.config.additional-location=optional:file:/deployments/config/
|
||||||
|
-XX:MaxRAMPercentage=75.0
|
||||||
PDGUARD_MAX_CONCURRENT: "2000"
|
PDGUARD_MAX_CONCURRENT: "2000"
|
||||||
|
# На 1 vCPU дефолт 200мс держал concurrency у пола; на 4 vCPU запас есть,
|
||||||
|
# но 800мс оставлено с той же осторожностью — целевая latency контракта 1с.
|
||||||
|
PDGUARD_TARGET_LATENCY_MS: "800"
|
||||||
PDGUARD_WARMUP_ITERATIONS: "2000"
|
PDGUARD_WARMUP_ITERATIONS: "2000"
|
||||||
# Вторая ступень распознавания — две модели под разные задачи (см.
|
# Вторая ступень распознавания — две модели под разные задачи (см.
|
||||||
# NameCascade.java): WikiNEuRal размечает имена, ruBERT — составляющие
|
# NameCascade.java): WikiNEuRal размечает имена, ruBERT — составляющие
|
||||||
@@ -19,6 +31,10 @@ services:
|
|||||||
PDGUARD_NER_NAME_MODEL: /deployments/models/wikineural-ner
|
PDGUARD_NER_NAME_MODEL: /deployments/models/wikineural-ner
|
||||||
PDGUARD_NER_ADDRESS_ENGINE: rubert
|
PDGUARD_NER_ADDRESS_ENGINE: rubert
|
||||||
PDGUARD_NER_ADDRESS_MODEL: /deployments/models/rubert-ner
|
PDGUARD_NER_ADDRESS_MODEL: /deployments/models/rubert-ner
|
||||||
|
# Третья ступень (юридические реквизиты, LLAIM Legal NER) временно выключена
|
||||||
|
# для нагрузочного теста — проверяем, она ли основной источник CPU-затрат
|
||||||
|
# на 1-vCPU лимите. Включить: PDGUARD_NER_LEGAL_ENGINE=ru-legal-ner.
|
||||||
|
PDGUARD_NER_LEGAL_ENGINE: "off"
|
||||||
volumes:
|
volumes:
|
||||||
- ./config:/deployments/config:ro
|
- ./config:/deployments/config:ro
|
||||||
- ./models:/deployments/models:ro
|
- ./models:/deployments/models:ro
|
||||||
|
|||||||
+12
-1
@@ -14,7 +14,7 @@
|
|||||||
"enabled": true,
|
"enabled": true,
|
||||||
"demask": false,
|
"demask": false,
|
||||||
"maskMode": "TOKEN",
|
"maskMode": "TOKEN",
|
||||||
"types": ["FIO", "PHONE", "EMAIL", "ADDRESS_CITY", "ADDRESS_STREET", "ADDRESS_HOUSE", "ADDRESS_FLAT"]
|
"types": ["*"]
|
||||||
},
|
},
|
||||||
"analytics": {
|
"analytics": {
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
@@ -27,5 +27,16 @@
|
|||||||
"demask": false,
|
"demask": false,
|
||||||
"maskMode": "MASK",
|
"maskMode": "MASK",
|
||||||
"types": ["*"]
|
"types": ["*"]
|
||||||
|
},
|
||||||
|
"strict": {
|
||||||
|
"enabled": true,
|
||||||
|
"demask": true,
|
||||||
|
"maskMode": "STRICT",
|
||||||
|
"types": ["*"],
|
||||||
|
"requireCompanion": [
|
||||||
|
"CVV", "PIN", "DATE", "BIRTH_PLACE", "ADDRESS_COUNTRY",
|
||||||
|
"ACCOUNT_NUMBER", "BIK", "OGRN", "OGRNIP", "KPP",
|
||||||
|
"INCOME", "BIOMETRIC"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,239 @@
|
|||||||
|
# Инструкция для жюри по проверке
|
||||||
|
|
||||||
|
Модуль принимает текст, находит в нём персональные данные, подменяет их и по тому же идентификатору возвращает исходный текст. Ниже — как это воспроизвести и где смотреть журнал и метрики.
|
||||||
|
|
||||||
|
Сервис слушает `http://localhost:8080`.
|
||||||
|
|
||||||
|
## Запуск
|
||||||
|
|
||||||
|
Нужны Java 21 и Maven 3.8+ (или обёртка `./mvnc`). Для дашборда — Docker Compose.
|
||||||
|
|
||||||
|
Локально:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./mvnc package -DskipTests
|
||||||
|
java -jar target/pd-guard-spring-1.0.0.jar
|
||||||
|
```
|
||||||
|
|
||||||
|
Контейнер, Prometheus и Grafana (`compose.yaml` ссылается на уже собранный образ `pd-guard-spring:jvm`, сам его не строит):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker build -f src/main/docker/Dockerfile -t pd-guard-spring:jvm .
|
||||||
|
docker compose up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
Если локальной Java нет — тот же образ собирается полностью внутри Docker, `mvn package` идёт в отдельной стадии сборки (дольше первого запуска, зато не требует ничего на хосте кроме Docker):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker build -f src/main/docker/Dockerfile.build -t pd-guard-spring:jvm .
|
||||||
|
docker compose up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
Готовность: `GET http://localhost:8080/health` отвечает `OK`.
|
||||||
|
|
||||||
|
Действующие системы читаются из `config/systems.json`. Это не встроенный файл в jar: при запуске из каталога проекта используется именно он.
|
||||||
|
|
||||||
|
### Модели второй и третьей ступеней (опционально)
|
||||||
|
|
||||||
|
Без моделей сервис работает на одних правилах — этого достаточно для контракта `/process`. Модели нужны для распознавания в свободном тексте (иностранные имена без русских словообразовательных признаков, регион/район адреса, юридические реквизиты в нетиповых формулировках). Скачиваются одной командой (~1 ГБ, требует `python3` для конвертации третьей модели):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./tools/fetch-ner-model.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
Кладёт веса в `models/wikineural-ner`, `models/rubert-ner`, `models/ru-legal-ner`. При локальном запуске (`java -jar ...`) включаются через `pdguard.ner.*-engine` (см. `docs/03-architecture.md`); в `docker compose up -d` каталог `models/` уже примонтирован и подхватывается автоматически, если модели скачаны до запуска. Сбой конкретной модели отключает только её ступень, остальное продолжает работать на правилах.
|
||||||
|
|
||||||
|
## Как устроен запрос
|
||||||
|
|
||||||
|
`POST /process`
|
||||||
|
|
||||||
|
Тело:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{"payload": "текст", "payload_id": "устойчивый-идентификатор"}
|
||||||
|
```
|
||||||
|
|
||||||
|
Заголовки:
|
||||||
|
|
||||||
|
| Заголовок | Смысл |
|
||||||
|
|-----------|--------|
|
||||||
|
| `X-System-Id` | имя системы из `config/systems.json`. Нет заголовка или имя неизвестно — применяется политика `default` |
|
||||||
|
| `X-System-Key` | нужен только если у системы в конфиге задано поле `key` |
|
||||||
|
|
||||||
|
Направление выбирается по `payload_id`, а не по отдельному флагу:
|
||||||
|
|
||||||
|
1. Идентификатор ещё не встречался — текст маскируется, пара «исходник ↔ маска» запоминается.
|
||||||
|
2. Пришёл ранее выданный текст маски и у системы включено `demask` — возвращается исходный текст.
|
||||||
|
3. Пришёл тот же исходный текст — возвращается та же маска, что и в первый раз.
|
||||||
|
|
||||||
|
Ответ: `{"result": "..."}`.
|
||||||
|
|
||||||
|
Коды: `200` успех, `400` нет `payload` или `payload_id`, `403` система выключена или неверный ключ, `429` перегрузка (заголовок `Retry-After: 1`). При внутреннем сбое сервис отвечает `200` и текстом `[обработка недоступна]`, чтобы исходные персональные данные не ушли наружу.
|
||||||
|
|
||||||
|
## Маскирование в браузере
|
||||||
|
|
||||||
|
Откройте `http://localhost:8080/`.
|
||||||
|
|
||||||
|
Четыре режима на странице — это четыре системы из конфига:
|
||||||
|
|
||||||
|
| Кнопка | Заголовок | Что увидите |
|
||||||
|
|--------|-----------|-------------|
|
||||||
|
| MASK | `default` | звёздочки с открытыми краями номера, ФИО инициалами |
|
||||||
|
| STRICT | `strict` | сплошные звёздочки, длина сохраняется |
|
||||||
|
| TOKEN | `crm` | токены вида `[FIO_1]`, `[PASSPORT_1]` |
|
||||||
|
| SYNTHETIC | `analytics` | правдоподобная подмена (вымышленное ФИО, номер, адрес) |
|
||||||
|
|
||||||
|
Вставьте текст, например:
|
||||||
|
|
||||||
|
```text
|
||||||
|
Клиент Иванов Иван Иванович, паспорт 4509 123456, тел +7 916 123-45-67
|
||||||
|
```
|
||||||
|
|
||||||
|
Нажмите «Обработать». Результат появится в блоке под кнопкой.
|
||||||
|
|
||||||
|
Страница каждый раз создаёт новый `payload_id`. Она показывает только маскирование. Демаскирование проверяется запросом к API с тем же идентификатором.
|
||||||
|
|
||||||
|
## Маскирование через API
|
||||||
|
|
||||||
|
Система `default` маскирует все известные типы и умеет демаскировать. Режим — звёздочки с открытыми краями номера (`MASK`).
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -s -X POST http://localhost:8080/process \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-H "X-System-Id: default" \
|
||||||
|
-d "{\"payload\":\"Клиент Иванов Иван Иванович, паспорт 4509 123456, тел +7 916 123-45-67\",\"payload_id\":\"doc-1\"}"
|
||||||
|
```
|
||||||
|
|
||||||
|
Ожидаемый вид ответа: в `result` ФИО заменено на инициалы (`И. И. И.`), середина паспорта и телефона закрыта звёздочками, края видны.
|
||||||
|
|
||||||
|
Сплошные звёздочки — тот же запрос с заголовком `X-System-Id: strict`.
|
||||||
|
|
||||||
|
Токены вида `[FIO_1]`, `[PASSPORT_1]`, `[PHONE_1]` — тот же запрос с заголовком `X-System-Id: crm` (но у `crm` в политике нет `PASSPORT`, см. ниже).
|
||||||
|
|
||||||
|
Синтетика — `X-System-Id: analytics`. У этой системы обратное преобразование выключено.
|
||||||
|
|
||||||
|
Система `crm` маскирует только ФИО, телефон, email и части адреса (город, улица, дом, квартира). Паспорт в её политике не входит и в ответе останется открытым. Демаскирование у `crm` выключено.
|
||||||
|
|
||||||
|
Система `legacy-billing` выключена: тот же запрос с `X-System-Id: legacy-billing` даёт `403`.
|
||||||
|
|
||||||
|
## Демаскирование
|
||||||
|
|
||||||
|
Нужны три условия одновременно:
|
||||||
|
|
||||||
|
- тот же `payload_id`, что при маскировании (`doc-1` в примере выше);
|
||||||
|
- в `payload` — точная строка из поля `result` предыдущего ответа;
|
||||||
|
- система с `"demask": true`. Сейчас это `default` и `strict`. У `crm` и `analytics` демаскирование выключено намеренно.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -s -X POST http://localhost:8080/process \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-H "X-System-Id: default" \
|
||||||
|
-d "{\"payload\":\"<сюда строка result из маскирования>\",\"payload_id\":\"doc-1\"}"
|
||||||
|
```
|
||||||
|
|
||||||
|
В `result` вернётся исходная фраза с ФИО, паспортом и телефоном.
|
||||||
|
|
||||||
|
Соответствие живёт в памяти процесса 30 минут и пропадает после перезапуска. Повторный запрос после перезапуска будет обработан как новое маскирование.
|
||||||
|
|
||||||
|
Хранилище разделено по системам: маска, полученная от `default`, не раскрывается запросом от `strict` с тем же `payload_id`.
|
||||||
|
|
||||||
|
## Цепочка до модели
|
||||||
|
|
||||||
|
`POST /proxy` показывает, что ушло бы во внешнюю модель и что вернулось бы потребителю. Адрес модели по умолчанию пуст, поэтому отвечает заглушка: она возвращает присланный (уже замаскированный) текст. В модель подставляются токены, даже если у системы выбран другой режим: по звёздочкам однозначное восстановление невозможно.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -s -X POST http://localhost:8080/proxy \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-H "X-System-Id: default" \
|
||||||
|
-d "{\"prompt\":\"Клиент Иванов Иван Иванович, карта 4111 1111 1111 1111. Кратко опишите профиль.\"}"
|
||||||
|
```
|
||||||
|
|
||||||
|
В ответе:
|
||||||
|
|
||||||
|
| Поле | Смысл |
|
||||||
|
|------|--------|
|
||||||
|
| `prompt_masked` | текст, который ушёл бы в модель |
|
||||||
|
| `llm_response_masked` | ответ модели (или заглушки) ещё с токенами |
|
||||||
|
| `response` | текст потребителю; при `demask: true` токены заменены обратно |
|
||||||
|
| `replaced` | таблица «токен → исходное значение» |
|
||||||
|
| `llm` | `заглушка` либо имя модели |
|
||||||
|
|
||||||
|
В `prompt_masked` не должно остаться открытых ФИО и номера карты.
|
||||||
|
|
||||||
|
## Где смотреть логи
|
||||||
|
|
||||||
|
Отдельного файла журнала нет. Строки пишет процесс в стандартный вывод.
|
||||||
|
|
||||||
|
Локальный запуск — окно, где выполнена команда `java -jar`.
|
||||||
|
|
||||||
|
Docker:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose logs -f pd-guard
|
||||||
|
```
|
||||||
|
|
||||||
|
На каждое маскирование есть строка уровня INFO. В ней идентификатор, длина текста и счётчики по типам. Сами значения персональных данных в журнал не пишутся:
|
||||||
|
|
||||||
|
```text
|
||||||
|
payload_id=doc-1 символов=72 найдено={FIO=1, PASSPORT=1, PHONE=1}
|
||||||
|
```
|
||||||
|
|
||||||
|
Рядом по смыслу, если они случаются:
|
||||||
|
|
||||||
|
- `Системе … обращение в модуль запрещено настройками` — выключенная система;
|
||||||
|
- `Системе … отказано: неверный ключ`;
|
||||||
|
- `демаскирование не нашло соответствие` — идентификатор и отпечаток маски неизвестны, текст обработан как новый;
|
||||||
|
- `Настройки систем перечитаны из …` — после правки `config/systems.json`.
|
||||||
|
|
||||||
|
Для прокси: `proxy: система=… заменено=… модель=…`. Число замен есть, значения — нет.
|
||||||
|
|
||||||
|
## Где смотреть метрики
|
||||||
|
|
||||||
|
Сырые метрики сервиса, без Docker:
|
||||||
|
|
||||||
|
| Адрес | Что это |
|
||||||
|
|-------|---------|
|
||||||
|
| `GET /actuator/prometheus` | все метрики в формате Prometheus |
|
||||||
|
| `GET /actuator/metrics` | список имён Micrometer |
|
||||||
|
| `GET /actuator/metrics/pdguard.process` | длительность обработки |
|
||||||
|
| `GET /actuator/metrics/pdguard.pd.detected` | сколько фрагментов найдено, в разрезе типа и системы |
|
||||||
|
| `GET /actuator/health` | проба Spring Boot |
|
||||||
|
|
||||||
|
Имеет смысл смотреть после одного-двух вызовов `/process`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -s http://localhost:8080/actuator/prometheus | findstr pdguard
|
||||||
|
```
|
||||||
|
|
||||||
|
На Linux и macOS вместо `findstr` — `grep pdguard`.
|
||||||
|
|
||||||
|
Основные ряды:
|
||||||
|
|
||||||
|
| Метрика | О чём |
|
||||||
|
|---------|--------|
|
||||||
|
| `pdguard_process_seconds` | длительность, метки `direction` (`mask` / `unmask`) и `system` |
|
||||||
|
| `pdguard_pd_detected_total` | найденные фрагменты, метки `type` и `system` |
|
||||||
|
| `pdguard_requests_rejected_total` | отказы: `overload`, `malformed`, `system_disabled`, `internal_error` |
|
||||||
|
| `pdguard_concurrency_limit` | текущий потолок одновременных запросов |
|
||||||
|
| `pdguard_concurrency_in_flight` | сколько запросов в работе |
|
||||||
|
| `pdguard_store_chars` | объём хранилища соответствий в символах |
|
||||||
|
| `pdguard_tokens_processed_total` | оценка числа обработанных токенов |
|
||||||
|
| `pdguard_demask_unresolved_total` | демаскирование без найденного соответствия |
|
||||||
|
| `pdguard_ner_*` | вторая ступень (модель). В поставке без скачанных моделей ступень выключена, ряды почти не растут |
|
||||||
|
|
||||||
|
Дашборд появляется вместе с `docker compose`:
|
||||||
|
|
||||||
|
- Grafana: `http://localhost:3000` (анонимный просмотр включён). Дашборд «Модуль безопасности персональных данных»: `http://localhost:3000/d/pd-guard`. Обновление раз в 5 секунд. Блоки: обращения и типы ПДн, задержка и доля ответов быстрее 0,5 с, отказы, вторая ступень, ресурсы узла.
|
||||||
|
- Prometheus: `http://localhost:9090`. Цель сбора — `pd-guard:8080`, интервал 5 секунд.
|
||||||
|
|
||||||
|
## Что поменять без перезапуска
|
||||||
|
|
||||||
|
Отредактируйте `config/systems.json` и вызовите:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -s -X POST http://localhost:8080/admin/reload
|
||||||
|
```
|
||||||
|
|
||||||
|
Файл также перечитывается сам, если изменилось время модификации (проверка не чаще раза в секунду). Текущие политики: `GET /admin/config`. Список типов, которые умеет распознавать сборка: `GET /admin/types`.
|
||||||
|
|
||||||
|
Проверка набора типов: в политике `crm` нет `PASSPORT`. Текст с паспортом и телефоном под `X-System-Id: crm` скроет телефон и оставит номер паспорта. Под `default` скроет оба.
|
||||||
@@ -0,0 +1,127 @@
|
|||||||
|
# Схема архитектуры и настройки
|
||||||
|
|
||||||
|
Модуль стоит между системой-потребителем и внешней языковой моделью. Потребитель отдаёт текст один раз на вход и один раз на выход. В модель уходит уже подменённый текст, потребителю возвращается текст с восстановленными значениями.
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart LR
|
||||||
|
consumer["Система-потребитель"]
|
||||||
|
api["POST /process и POST /proxy"]
|
||||||
|
policy["Политика системы\nconfig/systems.json"]
|
||||||
|
rules["Правила и словари"]
|
||||||
|
ner["Вторая ступень\nNER, если включена"]
|
||||||
|
filters["Отсев ложных\nсрабатываний"]
|
||||||
|
masker["Маскирование\nMASK / STRICT / TOKEN / SYNTHETIC"]
|
||||||
|
store["Хранилище соответствий\nпамять, AES-GCM"]
|
||||||
|
llm["Внешняя LLM\nили заглушка"]
|
||||||
|
|
||||||
|
consumer --> api --> policy --> rules --> ner --> filters --> masker --> store
|
||||||
|
masker -->|"только /proxy"| llm
|
||||||
|
llm -->|"демаскирование по токенам"| consumer
|
||||||
|
store -->|"демаскирование по payload_id"| consumer
|
||||||
|
```
|
||||||
|
|
||||||
|
`POST /process` — контракт для системы-потребителя: маскирование и демаскирование. `POST /proxy` — демонстрация всей цепочки до модели и обратно; проверяющий контур может его не использовать.
|
||||||
|
|
||||||
|
Настройки систем, перечень типов и вид маски задаются файлом `config/systems.json`. Код для смены политики пересобирать не нужно. Файл перечитывается при изменении и по `POST /admin/reload`.
|
||||||
|
|
||||||
|
## Что происходит с одним текстом
|
||||||
|
|
||||||
|
1. По заголовку `X-System-Id` выбирается политика. Неизвестное имя получает политику `default`. Выключенная система и неверный `X-System-Key` получают `403` до обработки текста.
|
||||||
|
2. Если `payload_id` уже есть в хранилище этой системы, направление определяется сравнением текста с сохранённой маской и с исходником.
|
||||||
|
3. Иначе текст проходит детекцию. Сначала правила: контрольные суммы (карта, ИНН, СНИЛС, ОГРН), однозначные форматы (email, телефон), затем шаблоны с якорным словом (документ, адрес, дата, ФИО). Цифровые значения с нестандартными разделителями дополнительно собираются в кластер и проверяются той же контрольной суммой.
|
||||||
|
4. Если включена вторая ступень, модель смотрит только непокрытые кандидаты: цепочки слов с заглавной буквы и, для юридической модели, цифровые кластеры. В поставке по умолчанию ступень выключена (`pdguard.ner.*-engine: off`). В `compose.yaml` она включается, если в каталог `models/` положены веса.
|
||||||
|
5. Ложные срабатывания снимаются до маски: имя в составе организации и на вывеске, общеизвестное имя без других персональных данных рядом, адрес отделения, типы-спутники без самостоятельного персонального данного.
|
||||||
|
6. Оставшиеся фрагменты заменяются по `maskMode` системы. Пара «исходный текст ↔ маска» пишется в хранилище этой системы. Исходный текст в хранилище шифруется AES-GCM, ключ — `pdguard.store.encryption-key`.
|
||||||
|
|
||||||
|
Демаскирование не запускает детектор заново: по `payload_id` (и запасным отпечатком маски) достаётся сохранённый исходник. Поэтому звёздочки тоже обратимы, пока жива запись.
|
||||||
|
|
||||||
|
## Системы-потребители
|
||||||
|
|
||||||
|
Файл `config/systems.json`, путь переопределяется свойством `pdguard.systems-file`.
|
||||||
|
|
||||||
|
| Система | Включена | Демаскирование | Режим | Типы |
|
||||||
|
|---------|----------|----------------|-------|------|
|
||||||
|
| `default` | да | да | `MASK` | все (`*`) |
|
||||||
|
| `crm` | да | нет | `TOKEN` | ФИО, телефон, email, город, улица, дом, квартира |
|
||||||
|
| `analytics` | да | нет | `SYNTHETIC` | все (`*`) |
|
||||||
|
| `strict` | да | да | `STRICT` | все (`*`) |
|
||||||
|
| `legacy-billing` | нет | нет | `MASK` | все (`*`), запросы отклоняются |
|
||||||
|
|
||||||
|
Запрос без заголовка идёт в `default`. Хранилище у каждой системы своё: одна система не читает соответствия другой.
|
||||||
|
|
||||||
|
Поля политики:
|
||||||
|
|
||||||
|
| Поле | Назначение |
|
||||||
|
|------|------------|
|
||||||
|
| `enabled` | `false` — модуль отвечает `403` и текст не обрабатывает |
|
||||||
|
| `demask` | разрешено ли обратное преобразование |
|
||||||
|
| `maskMode` | чем заменяется найденное значение |
|
||||||
|
| `types` | какие типы маскировать; `"*"` — все, которые знает сборка |
|
||||||
|
| `requireCompanion` | типы, которые маскируются только рядом с самостоятельным персональным данным |
|
||||||
|
| `key` | общий секрет; если задан, заголовок `X-System-Key` обязан совпасть. Только ASCII |
|
||||||
|
|
||||||
|
Пример добавления системы — дописать объект и перечитать файл:
|
||||||
|
|
||||||
|
```json
|
||||||
|
"dms": {
|
||||||
|
"enabled": true,
|
||||||
|
"demask": true,
|
||||||
|
"maskMode": "MASK",
|
||||||
|
"types": ["FIO", "PHONE", "MEDICAL_POLICY", "BIRTH_DATE"],
|
||||||
|
"key": "dms-secret"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
После `POST /admin/reload` запросы с `X-System-Id: dms` и `X-System-Key: dms-secret` маскируют только перечисленные типы, звёздочками, и умеют демаскировать.
|
||||||
|
|
||||||
|
Новый тип персональных данных добавляется правилом в реестре (`DocumentRules`, `FinanceRules`, `DateRules`, `FioRules`, `ContactRules`, `AddressRules`) и именем в `PdTypes`. Политики, где указано `"*"`, подхватывают его без правки конфига. Политика с явным списком — только если имя типа туда добавить.
|
||||||
|
|
||||||
|
## Типы персональных данных
|
||||||
|
|
||||||
|
Список отдаёт `GET /admin/types`. Группы:
|
||||||
|
|
||||||
|
| Группа | Типы |
|
||||||
|
|--------|------|
|
||||||
|
| Человек | `FIO`, `CARDHOLDER` |
|
||||||
|
| Документы | `PASSPORT`, `PASSPORT_ISSUER`, `PASSPORT_DATE`, `DEPT_CODE`, `FOREIGN_PASSPORT`, `DRIVER_LICENSE`, `MILITARY_ID`, `BIRTH_CERTIFICATE`, `MEDICAL_POLICY` |
|
||||||
|
| Контакты | `PHONE`, `EMAIL` |
|
||||||
|
| Адрес | `ADDRESS_COUNTRY`, `ADDRESS_POSTCODE`, `ADDRESS_CITY`, `ADDRESS_STREET`, `ADDRESS_HOUSE`, `ADDRESS_FLAT`. `ADDRESS_REGION` и `ADDRESS_DISTRICT` размечает только модель второй ступени |
|
||||||
|
| Даты и гражданство | `BIRTH_DATE`, `BIRTH_PLACE`, `DATE`, `CITIZENSHIP` |
|
||||||
|
| Платёжные данные | `CARD`, `CARD_EXPIRY`, `CVV`, `PIN` |
|
||||||
|
| Реквизиты | `INN`, `SNILS`, `ACCOUNT_NUMBER`, `BIK`, `OGRN`, `OGRNIP`, `KPP` |
|
||||||
|
| Прочее | `INCOME`, `BIOMETRIC` |
|
||||||
|
|
||||||
|
Правила устойчивы к регистру, к дате числом и словами, к вставке слов между серией и номером документа, к уменьшительным формам имён. Карта, ИНН, СНИЛС, ОГРН и ОГРНИП без верной контрольной суммы не маскируются.
|
||||||
|
|
||||||
|
## Режимы маскирования
|
||||||
|
|
||||||
|
Режим задаётся полем `maskMode` и действует на все типы, которые политика разрешила.
|
||||||
|
|
||||||
|
| Режим | Пример для `Иванов Иван Иванович` и паспорта `4509 123456` | Когда уместен |
|
||||||
|
|-------|--------------------------------------------------------------|---------------|
|
||||||
|
| `MASK` | `И. И. И.`, паспорт `45** ****56` | человеку остаётся узнаваемый контур, середина закрыта. Края коротких серий (загранпаспорт, военный билет, свидетельство о рождении) не открываются. CVV и PIN закрываются целиком |
|
||||||
|
| `STRICT` | сплошные звёздочки на всю длину, включая ФИО | ничего из исходных знаков не остаётся |
|
||||||
|
| `TOKEN` | `[FIO_1]`, `[PASSPORT_1]` | однозначная обратимая подстановка, удобная и для демаскирования ответа модели |
|
||||||
|
| `SYNTHETIC` | вымышленные ФИО и номер той же формы | модель видит правдоподобный текст. Для типов без своей подстановки остаётся токен |
|
||||||
|
|
||||||
|
Одинаковое исходное значение внутри одного текста получает одну и ту же замену.
|
||||||
|
|
||||||
|
В `POST /proxy` режим системы для отправки в модель заменяется на `TOKEN`: одинаковые звёздочки нельзя однозначно вернуть на место в ответе модели.
|
||||||
|
|
||||||
|
## Контекстное правило
|
||||||
|
|
||||||
|
Типы из `requireCompanion` сами по себе персональными данными не считаются. Они маскируются, только если в том же тексте есть находка самостоятельного типа.
|
||||||
|
|
||||||
|
В политиках `default` и `strict` спутники такие: `CVV`, `PIN`, `DATE`, `BIRTH_PLACE`, `ADDRESS_COUNTRY`, `ACCOUNT_NUMBER`, `BIK`, `OGRN`, `OGRNIP`, `KPP`, `INCOME`, `BIOMETRIC`.
|
||||||
|
|
||||||
|
Два спутника друг друга не подтверждают. Дата рядом с ОГРН без имени и документа человека не маскируется. PIN рядом с номером карты — маскируется. Список спутников у каждой системы свой; пустой список означает, что маскируется всё найденное из `types`.
|
||||||
|
|
||||||
|
Отдельно от этого списка снимаются ложные ФИО и адреса: «Александр Пушкин» без других персональных данных, «Институт Склифосовского», адрес отделения банка. Если рядом с общеизвестным именем есть другой тип персональных данных, имя остаётся замаскированным: однофамилец защиту не теряет.
|
||||||
|
|
||||||
|
## Состояние и наблюдаемость
|
||||||
|
|
||||||
|
Хранилище соответствий по умолчанию — память процесса (`pdguard.store.backend=memory`): потолок `pdguard.store.max-chars` (512 МБ символов), срок жизни записи `pdguard.store.ttl-minutes` (30 минут). При `backend=redis` та же запись дублируется в Redis, чтобы демаскирование попало на другой узел. В текущем `compose.yaml` поднят один узел, Redis не используется.
|
||||||
|
|
||||||
|
Одновременные запросы ограничивает адаптивный предел: он растёт, пока задержка укладывается в `pdguard.target-latency-ms` (200 мс), и сжимается, когда перестаёт. Лишние запросы получают `429`, очередь не копится.
|
||||||
|
|
||||||
|
Метрики отдаёт Micrometer на `/actuator/prometheus`. Готовые Prometheus и Grafana лежат в `monitoring/` и поднимаются тем же `docker compose`.
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
# Производительность и дополнительные возможности
|
||||||
|
|
||||||
|
Целевой уровень из задания — задержка не выше 0,5 с при 1000 запросах в секунду. Замер ниже снят на правилах, без нейросетевой ступени: именно так работает поставка, пока каталог `models/` пуст.
|
||||||
|
|
||||||
|
## Нагрузочный тест
|
||||||
|
|
||||||
|
Инструмент — [k6](https://k6.io/), сценарий `k6-load-test.js`.
|
||||||
|
|
||||||
|
Условия:
|
||||||
|
|
||||||
|
- один узел в Docker, порт 8080;
|
||||||
|
- система `crm` (маскирование, без демаскирования; типы — ФИО, телефон, email и части адреса);
|
||||||
|
- пять коротких текстов, часть из них с персональными данными, один — без них;
|
||||||
|
- профиль виртуальных пользователей: 30 с до 500, затем по 30 с на 1000, 1500 и 2000, затем спад до нуля;
|
||||||
|
- между запросами одного пользователя пауза 0,1 с;
|
||||||
|
- пороги сценария: доля ошибок ниже 1 %, p95 длительности HTTP ниже 200 мс.
|
||||||
|
|
||||||
|
Запуск при уже поднятом сервисе:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
k6 run k6-load-test.js
|
||||||
|
```
|
||||||
|
|
||||||
|
Другой адрес: `k6 run -e BASE_URL=http://localhost:8080 k6-load-test.js`.
|
||||||
|
|
||||||
|
Зафиксированный прогон этого сценария на одном узле:
|
||||||
|
|
||||||
|
| Метрика | Значение |
|
||||||
|
|---------|----------|
|
||||||
|
| Пропускная способность | ~9 500 запросов/с |
|
||||||
|
| Задержка p50 | 1,03 мс |
|
||||||
|
| Задержка p95 | 13,87 мс |
|
||||||
|
| Ошибки | 0,00 % |
|
||||||
|
|
||||||
|
0,5 с при 1000 запросах/с перекрыто с запасом: p95 на этом профиле около 14 мс, поток около 9 500 запросов/с.
|
||||||
|
|
||||||
|
Отдельный замер внутри процесса (`PerformanceBenchmarkTest`) гоняет тот же набор текстов по правилам после прогрева JIT. В тесте закреплены пороги: p99 маскирования одного обращения ниже 5 мс и пропускная способность выше 1000 обращений/с на доступных ядрах. Это порог регрессии, а не замена цифр k6.
|
||||||
|
|
||||||
|
Потолок одновременных запросов не фиксирован. `AdaptiveConcurrencyLimiter` держит его между `pdguard.min-concurrent` (8) и `pdguard.max-concurrent` (2000) и подстраивает по фактической задержке с целью 200 мс. На старте `PipelineWarmup` прогоняет горячий путь несколько тысяч раз на отдельном коротком хранилище, чтобы первые боевые запросы не попали на непрогретый код: без этого p95 в первые десятки секунд примерно на порядок хуже.
|
||||||
|
|
||||||
|
Вторая ступень (NER) в замер не входила. Модель зовётся только на непокрытые кандидаты и стоит десятки миллисекунд на вызов; на текстах, которые уже закрыты правилами, она не вызывается. Доля таких обращений видна в метрике `pdguard_ner_requests_total`.
|
||||||
|
|
||||||
|
На дашборде Grafana (`http://localhost:3000/d/pd-guard`) во время прогона смотрят обращения в секунду, p95 маскирования, долю ответов быстрее 0,5 с, отказы по перегрузке и текущий предел конкурентности.
|
||||||
|
|
||||||
|
## Дополнительные возможности
|
||||||
|
|
||||||
|
Сверх маскирования заданного перечня типов реализовано следующее.
|
||||||
|
|
||||||
|
**Четыре режима подмены.** Звёздочки с сохранением краёв и разделителей (`MASK`), сплошное закрытие (`STRICT`), обратимые токены (`TOKEN`), правдоподобные вымышленные значения (`SYNTHETIC`). Синтетический номер карты проходит проверку Луна. Один и тот же фрагмент в тексте всегда получает одну и ту же замену.
|
||||||
|
|
||||||
|
**Политика на систему без пересборки.** Включение, демаскирование, режим, белый список типов, типы-спутники и общий секрет задаются в `config/systems.json` и применяются на лету. Хранилище соответствий разделено по системам.
|
||||||
|
|
||||||
|
**Контекстное маскирование.** PIN, CVV, дата без якоря, место рождения, страна, реквизиты организации, доход и упоминание биометрии маскируются только рядом с самостоятельным персональным данным. Список спутников настраивается у каждой системы. Два спутника друг друга не подтверждают.
|
||||||
|
|
||||||
|
**Защита от ложных срабатываний.** Общеизвестные имена и правители снимаются, если рядом нет других персональных данных; внешний список `config/well-known.txt` дополняет встроенный и перечитывается сам. Имя в названии организации и на вывеске не маскируется. Адрес отделения банка и улица в рассказе о городе не считаются адресом клиента. Словари имён, стран и населённых пунктов учитывают склонения и уменьшительные формы.
|
||||||
|
|
||||||
|
**Контрольные суммы и свободная запись чисел.** Карта, ИНН, СНИЛС, ОГРН и ОГРНИП подтверждаются контрольной суммой. Те же номера находятся, если между цифрами стоят пробелы, точки, дефисы или скобки.
|
||||||
|
|
||||||
|
**Вторая ступень распознавания.** Три необязательные ONNX-модели: имена (WikiNEuRal), составляющие адреса (ruBERT), юридические реквизиты. Модели в репозиторий не входят, скачиваются `tools/fetch-ner-model.sh`. Сбой ступени её отключает и оставляет правила. Регион и район адреса размечаются только этой ступенью.
|
||||||
|
|
||||||
|
**Безопасный отказ.** При внутренней ошибке наружу уходит фиксированная строка `[обработка недоступна]`, а не исходный текст. В журнал пишутся идентификатор, длина и счётчики типов; значения персональных данных не пишутся.
|
||||||
|
|
||||||
|
**Хранилище.** Исходный текст шифруется AES-GCM. Запись живёт ограниченное время и вытесняется по объёму. Демаскирование возможно по `payload_id` и по отпечатку маски. Повтор того же исходного текста с тем же идентификатором возвращает прежнюю маску. Общий слой Redis включается настройкой `pdguard.store.backend=redis` и при серии сбоев на время перестаёт опрашиваться, не роняя запрос.
|
||||||
|
|
||||||
|
**Демонстрация модели.** `POST /proxy` показывает замаскированный запрос, ответ модели и восстановленный текст. Без адреса модели работает заглушка; ошибка модели тоже сводится к заглушке.
|
||||||
|
|
||||||
|
**Наблюдаемость.** Пробы `/health` и `/actuator/health`, метрики Micrometer и Prometheus, готовые Prometheus и Grafana. Гистограмма `pdguard.process` размечена корзинами до 10 с, на дашборде видна доля ответов быстрее 0,5 с. Веб-страница `http://localhost:8080/` гоняет три режима маскирования без отдельного клиента.
|
||||||
|
|
||||||
|
**Прогрев и предел нагрузки.** Прогрев JIT на старте и адаптивный лимитер конкурентности с ответом `429`, чтобы задержка не упиралась в таймаут вызывающей стороны.
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
# Ограничения решения и план развития
|
||||||
|
|
||||||
|
Ограничения ниже относятся к поставке, с которой работает жюри: один процесс, правила без моделей, файл `config/systems.json`, ключ шифрования из `application.yml`.
|
||||||
|
|
||||||
|
## Ограничения
|
||||||
|
|
||||||
|
**Состояние демаскирования не переживает процесс.** Соответствия лежат в памяти узла, не дольше 30 минут и не больше заданного объёма символов. Перезапуск, вытеснение и истечение срока делают обратное преобразование невозможным: запрос обрабатывается как новое маскирование, счётчик `pdguard.demask.unresolved` увеличивается. Общий слой Redis в коде есть и включается `pdguard.store.backend=redis`, но в текущем `compose.yaml` его нет: поднят один узел.
|
||||||
|
|
||||||
|
**Демаскирование разрешено не всем системам и не из браузера.** В конфиге оно включено у `default` и `strict`. У `crm` и `analytics` выключено. Страница `http://localhost:8080/` каждый раз создаёт новый `payload_id`, поэтому с неё можно проверить только маскирование. Чужая система не читает чужое хранилище.
|
||||||
|
|
||||||
|
**Неизвестное имя системы получает политику `default`.** Отсекаются только явно выключенная система и неверный ключ. Ключ проверяется лишь там, где поле `key` заполнено; у систем в поставленном файле ключей нет.
|
||||||
|
|
||||||
|
**Административные методы открыты.** `GET /admin/config`, `GET /admin/types` и `POST /admin/reload` не требуют аутентификации. Для стенда хакатона это удобно, для контура с несколькими потребителями — нет.
|
||||||
|
|
||||||
|
**Ключ шифрования хранилища лежит в конфигурации приложения.** AES-GCM включён, но ключ записан в `application.yml`. Это демонстрационный ключ. В рабочем контуре его нужно задавать снаружи и не хранить в репозитории.
|
||||||
|
|
||||||
|
**Качество детекции держится на правилах и словарях.** Иностранные имена без русских словообразовательных признаков, нестандартные топонимы и составляющие адреса «регион» и «район» без второй ступени не размечаются. Юридические и банковские реквизиты в нетиповых формулировках (без якорного слова рядом) правила тоже пропускают — для них есть отдельная третья ступень (LLAIM Legal NER), но датасеты регрессии (`NodeLogsDatasetTest`, `PlacementVariantsTest`) её пока не включают и снятые в них пороги утечек её вклад не отражают. Модели в репозиторий не входят: без `tools/fetch-ner-model.sh` и включённых `pdguard.ner.*-engine` сервис остаётся на правилах. Сбой любой ступени отключает её до перезапуска, остальные продолжают работать.
|
||||||
|
|
||||||
|
**Часть типов маскируется только в контексте.** PIN, CVV, «голая» дата, место рождения, страна, банковские реквизиты организации, доход и слово о биометрии сами по себе не закрываются. Биометрия в тексте — это упоминание, а не шаблон из базы. Режим `MASK` по задумке оставляет края длинных номеров и инициалы ФИО; полностью закрывает режим `STRICT`.
|
||||||
|
|
||||||
|
**Синтетика покрывает не все типы.** Где своей подстановки нет, `SYNTHETIC` ставит токен вида `[TYPE_1]`.
|
||||||
|
|
||||||
|
**Журнал не является аудитом значений.** Пишутся идентификатор, длина и счётчики типов. Восстановить по журналу, что именно скрыто, нельзя — и отдельного аудита решений оператора тоже нет.
|
||||||
|
|
||||||
|
**Ответ модели — внешняя зависимость.** Пока `pdguard.llm.url` пуст, `POST /proxy` отвечает заглушкой. Ошибка живой модели тоже подменяется заглушкой, чтобы сбой модели не раскрывал исходный текст. Контракт `/process` от модели не зависит.
|
||||||
|
|
||||||
|
**Нагрузка измерена на коротких текстах и на правилах.** Прогон k6 использует систему `crm` и тексты в одну-две строки. Длинный документ и включённая модель этот профиль не описывают: модель вызывается точечно, но один вызов BERT — это уже десятки миллисекунд.
|
||||||
|
|
||||||
|
**Внутренняя ошибка выглядит как успешный ответ.** HTTP-код остаётся 200, тело — `[обработка недоступна]`. Так исходный текст не утекает и автоматический прогон не останавливается на серии ошибок. Отличить сбой от маски можно по этой фиксированной строке и по метрике `pdguard_requests_rejected_total{reason="internal_error"}`.
|
||||||
|
|
||||||
|
## План развития после хакатона
|
||||||
|
|
||||||
|
**Контур и секреты.** Вынести ключ шифрования и секреты систем в хранилище секретов. Закрыть `/admin` аутентификацией. Неизвестную систему отклонять, а не сажать на `default`. Включить Redis в поставку compose как общий слой соответствий и прогнать демаскирование через балансировщик.
|
||||||
|
|
||||||
|
**Детекция.** Поставлять модели второй и третьей ступеней отдельным артефактом со проверкой целостности и измеренным p95 на включённых ступенях. Включить третью ступень (Legal NER) в датасеты регрессии, чтобы её вклад в число утечек был виден и защищён порогом, а не только в ручных прогонах. Добавить типы документов, которых не хватает по отраслевому перечню, отдельными правилами в реестре — ядро и политики с `"*"` подхватят их без миграции. Расширить внешние словари (известные люди, денилист улиц) как файлы, которые перечитываются так же, как `config/systems.json`.
|
||||||
|
|
||||||
|
**Качество и нагрузка.** Закрепить в CI прогон размеченных наборов (precision/recall по типам) и сценарий k6 с порогом p95 ≤ 0,5 с при 1000 запросах/с на полном перечне типов, а не только на политике `crm`. Замерить длинные документы отдельно от коротких обращений.
|
||||||
|
|
||||||
|
**Наблюдаемость для эксплуатации.** Журнал решений без значений персональных данных в централизованный сбор. Алерты на рост `demask_unresolved`, на отказы по перегрузке и на выключение любой из ступеней распознавания. Срок хранения соответствий и потолок объёма сделать разными для систем.
|
||||||
|
|
||||||
|
**Интерфейс политики.** Страница проверки сейчас только маскирует. Следующий шаг — показать демаскирование тем же `payload_id` и дать править перечень типов и режим без ручного JSON, с тем же файлом `systems.json` под капотом.
|
||||||
@@ -2149,6 +2149,136 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"type": "timeseries",
|
||||||
|
"title": "Обращения к моделям",
|
||||||
|
"id": 49,
|
||||||
|
"datasource": {
|
||||||
|
"type": "prometheus",
|
||||||
|
"uid": "PDGUARD_PROM"
|
||||||
|
},
|
||||||
|
"gridPos": {
|
||||||
|
"h": 8,
|
||||||
|
"w": 12,
|
||||||
|
"x": 0,
|
||||||
|
"y": 74
|
||||||
|
},
|
||||||
|
"targets": [
|
||||||
|
{
|
||||||
|
"datasource": {
|
||||||
|
"type": "prometheus",
|
||||||
|
"uid": "PDGUARD_PROM"
|
||||||
|
},
|
||||||
|
"expr": "sum by (model) (rate(pdguard_ner_model_requests_total[$__rate_interval]))",
|
||||||
|
"refId": "A",
|
||||||
|
"instant": false,
|
||||||
|
"legendFormat": "{{model}}"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"fieldConfig": {
|
||||||
|
"defaults": {
|
||||||
|
"unit": "reqps",
|
||||||
|
"custom": {
|
||||||
|
"fillOpacity": 45,
|
||||||
|
"lineWidth": 2,
|
||||||
|
"showPoints": "never",
|
||||||
|
"stacking": {
|
||||||
|
"mode": "normal"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"thresholds": {
|
||||||
|
"mode": "absolute",
|
||||||
|
"steps": [
|
||||||
|
{
|
||||||
|
"color": "text",
|
||||||
|
"value": null
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"overrides": []
|
||||||
|
},
|
||||||
|
"options": {
|
||||||
|
"legend": {
|
||||||
|
"displayMode": "table",
|
||||||
|
"placement": "bottom",
|
||||||
|
"calcs": [
|
||||||
|
"mean",
|
||||||
|
"max"
|
||||||
|
],
|
||||||
|
"showLegend": true
|
||||||
|
},
|
||||||
|
"tooltip": {
|
||||||
|
"mode": "multi",
|
||||||
|
"sort": "desc"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "timeseries",
|
||||||
|
"title": "Время моделей",
|
||||||
|
"id": 50,
|
||||||
|
"datasource": {
|
||||||
|
"type": "prometheus",
|
||||||
|
"uid": "PDGUARD_PROM"
|
||||||
|
},
|
||||||
|
"gridPos": {
|
||||||
|
"h": 8,
|
||||||
|
"w": 12,
|
||||||
|
"x": 12,
|
||||||
|
"y": 74
|
||||||
|
},
|
||||||
|
"targets": [
|
||||||
|
{
|
||||||
|
"datasource": {
|
||||||
|
"type": "prometheus",
|
||||||
|
"uid": "PDGUARD_PROM"
|
||||||
|
},
|
||||||
|
"expr": "histogram_quantile(0.95, sum by (le, model) (rate(pdguard_ner_model_duration_seconds_bucket[$__rate_interval])))",
|
||||||
|
"refId": "A",
|
||||||
|
"instant": false,
|
||||||
|
"legendFormat": "{{model}} p95"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"fieldConfig": {
|
||||||
|
"defaults": {
|
||||||
|
"unit": "s",
|
||||||
|
"custom": {
|
||||||
|
"fillOpacity": 12,
|
||||||
|
"lineWidth": 2,
|
||||||
|
"showPoints": "never",
|
||||||
|
"stacking": {
|
||||||
|
"mode": "none"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"thresholds": {
|
||||||
|
"mode": "absolute",
|
||||||
|
"steps": [
|
||||||
|
{
|
||||||
|
"color": "text",
|
||||||
|
"value": null
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"overrides": []
|
||||||
|
},
|
||||||
|
"options": {
|
||||||
|
"legend": {
|
||||||
|
"displayMode": "table",
|
||||||
|
"placement": "bottom",
|
||||||
|
"calcs": [
|
||||||
|
"mean",
|
||||||
|
"max"
|
||||||
|
],
|
||||||
|
"showLegend": true
|
||||||
|
},
|
||||||
|
"tooltip": {
|
||||||
|
"mode": "multi",
|
||||||
|
"sort": "desc"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"type": "row",
|
"type": "row",
|
||||||
"title": "Ресурсы узла",
|
"title": "Ресурсы узла",
|
||||||
@@ -2157,7 +2287,7 @@
|
|||||||
"h": 1,
|
"h": 1,
|
||||||
"w": 24,
|
"w": 24,
|
||||||
"x": 0,
|
"x": 0,
|
||||||
"y": 74
|
"y": 90
|
||||||
},
|
},
|
||||||
"collapsed": false
|
"collapsed": false
|
||||||
},
|
},
|
||||||
@@ -2173,7 +2303,7 @@
|
|||||||
"h": 5,
|
"h": 5,
|
||||||
"w": 4,
|
"w": 4,
|
||||||
"x": 0,
|
"x": 0,
|
||||||
"y": 75
|
"y": 91
|
||||||
},
|
},
|
||||||
"targets": [
|
"targets": [
|
||||||
{
|
{
|
||||||
@@ -2236,7 +2366,7 @@
|
|||||||
"h": 5,
|
"h": 5,
|
||||||
"w": 4,
|
"w": 4,
|
||||||
"x": 4,
|
"x": 4,
|
||||||
"y": 75
|
"y": 91
|
||||||
},
|
},
|
||||||
"targets": [
|
"targets": [
|
||||||
{
|
{
|
||||||
@@ -2291,7 +2421,7 @@
|
|||||||
"h": 5,
|
"h": 5,
|
||||||
"w": 4,
|
"w": 4,
|
||||||
"x": 8,
|
"x": 8,
|
||||||
"y": 75
|
"y": 91
|
||||||
},
|
},
|
||||||
"targets": [
|
"targets": [
|
||||||
{
|
{
|
||||||
@@ -2346,7 +2476,7 @@
|
|||||||
"h": 5,
|
"h": 5,
|
||||||
"w": 4,
|
"w": 4,
|
||||||
"x": 12,
|
"x": 12,
|
||||||
"y": 75
|
"y": 91
|
||||||
},
|
},
|
||||||
"targets": [
|
"targets": [
|
||||||
{
|
{
|
||||||
@@ -2409,7 +2539,7 @@
|
|||||||
"h": 5,
|
"h": 5,
|
||||||
"w": 4,
|
"w": 4,
|
||||||
"x": 16,
|
"x": 16,
|
||||||
"y": 75
|
"y": 91
|
||||||
},
|
},
|
||||||
"targets": [
|
"targets": [
|
||||||
{
|
{
|
||||||
@@ -2464,7 +2594,7 @@
|
|||||||
"h": 5,
|
"h": 5,
|
||||||
"w": 4,
|
"w": 4,
|
||||||
"x": 20,
|
"x": 20,
|
||||||
"y": 75
|
"y": 91
|
||||||
},
|
},
|
||||||
"targets": [
|
"targets": [
|
||||||
{
|
{
|
||||||
@@ -2523,7 +2653,7 @@
|
|||||||
"h": 8,
|
"h": 8,
|
||||||
"w": 12,
|
"w": 12,
|
||||||
"x": 0,
|
"x": 0,
|
||||||
"y": 80
|
"y": 96
|
||||||
},
|
},
|
||||||
"targets": [
|
"targets": [
|
||||||
{
|
{
|
||||||
@@ -2588,7 +2718,7 @@
|
|||||||
"h": 8,
|
"h": 8,
|
||||||
"w": 12,
|
"w": 12,
|
||||||
"x": 12,
|
"x": 12,
|
||||||
"y": 80
|
"y": 96
|
||||||
},
|
},
|
||||||
"targets": [
|
"targets": [
|
||||||
{
|
{
|
||||||
@@ -2643,4 +2773,4 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -8,6 +8,6 @@ scrape_configs:
|
|||||||
- job_name: pd-guard
|
- job_name: pd-guard
|
||||||
metrics_path: /actuator/prometheus
|
metrics_path: /actuator/prometheus
|
||||||
static_configs:
|
static_configs:
|
||||||
- targets: ["pd-guard:8080"]
|
- targets: ["node-a:8080", "node-b:8080"]
|
||||||
labels:
|
labels:
|
||||||
instance: "один узел"
|
instance: "кластер из двух узлов"
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
# Вариант для тех, у кого нет локально Java 21 + Maven: собирает jar внутри
|
||||||
|
# Docker, без ./mvnc на хосте. Основной путь развёртывания — src/main/docker/Dockerfile
|
||||||
|
# с заранее собранным `mvn package` (вдвое быстрее пересборки образа при правках).
|
||||||
|
# docker build -f src/main/docker/Dockerfile.build -t pd-guard-spring:jvm .
|
||||||
|
FROM maven:3.9-eclipse-temurin-21 AS build
|
||||||
|
WORKDIR /build
|
||||||
|
|
||||||
|
COPY pom.xml settings.xml ./
|
||||||
|
RUN mvn -s settings.xml -B dependency:go-offline
|
||||||
|
|
||||||
|
COPY src ./src
|
||||||
|
RUN mvn -s settings.xml -B package -DskipTests
|
||||||
|
|
||||||
|
FROM eclipse-temurin:21-jre
|
||||||
|
WORKDIR /deployments
|
||||||
|
|
||||||
|
COPY --from=build --chown=1000:1000 /build/target/pd-guard-spring-1.0.0.jar /deployments/app.jar
|
||||||
|
COPY --chown=1000:1000 config /deployments/config
|
||||||
|
|
||||||
|
EXPOSE 8080
|
||||||
|
USER 1000
|
||||||
|
|
||||||
|
ENV JAVA_OPTS="-Dspring.config.additional-location=optional:file:/deployments/config/"
|
||||||
|
ENV PDGUARD_SYSTEMS_FILE=/deployments/config/systems.json
|
||||||
|
|
||||||
|
ENTRYPOINT ["sh", "-c", "java $JAVA_OPTS -jar /deployments/app.jar"]
|
||||||
@@ -6,14 +6,13 @@ import org.springframework.boot.autoconfigure.SpringBootApplication;
|
|||||||
/**
|
/**
|
||||||
* Точка входа Spring Boot приложения.
|
* Точка входа Spring Boot приложения.
|
||||||
*
|
*
|
||||||
* <p>Модуль безопасности персональных данных: прокси между системой-потребителем
|
* <p>Модуль безопасности персональных данных: прокси между системой-потребителем и LLM. Находит
|
||||||
* и LLM. Находит персональные данные, маскирует их и восстанавливает исходный
|
* персональные данные, маскирует их и восстанавливает исходный текст на обратном шаге.
|
||||||
* текст на обратном шаге.
|
|
||||||
*/
|
*/
|
||||||
@SpringBootApplication
|
@SpringBootApplication
|
||||||
public class PdGuardApplication {
|
public class PdGuardApplication {
|
||||||
|
|
||||||
public static void main(String[] args) {
|
public static void main(String[] args) {
|
||||||
SpringApplication.run(PdGuardApplication.class, args);
|
SpringApplication.run(PdGuardApplication.class, args);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
package ru.pdguard.api;
|
package ru.pdguard.api;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
import org.springframework.web.bind.annotation.GetMapping;
|
import org.springframework.web.bind.annotation.GetMapping;
|
||||||
import org.springframework.web.bind.annotation.PostMapping;
|
import org.springframework.web.bind.annotation.PostMapping;
|
||||||
import org.springframework.web.bind.annotation.RestController;
|
import org.springframework.web.bind.annotation.RestController;
|
||||||
@@ -7,34 +9,31 @@ import ru.pdguard.config.SystemPolicy;
|
|||||||
import ru.pdguard.config.SystemsConfig;
|
import ru.pdguard.config.SystemsConfig;
|
||||||
import ru.pdguard.detect.RuleRegistry;
|
import ru.pdguard.detect.RuleRegistry;
|
||||||
|
|
||||||
import java.util.List;
|
|
||||||
import java.util.Map;
|
|
||||||
|
|
||||||
/** Просмотр действующих настроек и принудительное их перечитывание. */
|
/** Просмотр действующих настроек и принудительное их перечитывание. */
|
||||||
@RestController
|
@RestController
|
||||||
public class AdminResource {
|
public class AdminResource {
|
||||||
|
|
||||||
private final SystemsConfig systems;
|
private final SystemsConfig systems;
|
||||||
private final RuleRegistry registry;
|
private final RuleRegistry registry;
|
||||||
|
|
||||||
public AdminResource(SystemsConfig systems, RuleRegistry registry) {
|
public AdminResource(SystemsConfig systems, RuleRegistry registry) {
|
||||||
this.systems = systems;
|
this.systems = systems;
|
||||||
this.registry = registry;
|
this.registry = registry;
|
||||||
}
|
}
|
||||||
|
|
||||||
@GetMapping("/admin/config")
|
@GetMapping("/admin/config")
|
||||||
public Map<String, SystemPolicy> config() {
|
public Map<String, SystemPolicy> config() {
|
||||||
return systems.current();
|
return systems.current();
|
||||||
}
|
}
|
||||||
|
|
||||||
@GetMapping("/admin/types")
|
@GetMapping("/admin/types")
|
||||||
public List<String> types() {
|
public List<String> types() {
|
||||||
return registry.knownTypes();
|
return registry.knownTypes();
|
||||||
}
|
}
|
||||||
|
|
||||||
@PostMapping("/admin/reload")
|
@PostMapping("/admin/reload")
|
||||||
public Map<String, SystemPolicy> reload() {
|
public Map<String, SystemPolicy> reload() {
|
||||||
systems.reload();
|
systems.reload();
|
||||||
return systems.current();
|
return systems.current();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,8 +7,8 @@ import org.springframework.web.bind.annotation.RestController;
|
|||||||
@RestController
|
@RestController
|
||||||
public class HealthResource {
|
public class HealthResource {
|
||||||
|
|
||||||
@GetMapping("/health")
|
@GetMapping("/health")
|
||||||
public String health() {
|
public String health() {
|
||||||
return "OK";
|
return "OK";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,116 +18,119 @@ import ru.pdguard.core.AdaptiveConcurrencyLimiter;
|
|||||||
import ru.pdguard.core.Pipeline;
|
import ru.pdguard.core.Pipeline;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Единственная точка входа контракта: маскирование и демаскирование по
|
* Единственная точка входа контракта: маскирование и демаскирование по {@code payload_id}.
|
||||||
* {@code payload_id}.
|
|
||||||
*
|
*
|
||||||
* <p>Система-потребитель называет себя заголовком {@code X-System-Id}. Заголовка
|
* <p>Система-потребитель называет себя заголовком {@code X-System-Id}. Заголовка нет или система
|
||||||
* нет или система неизвестна — применяются настройки {@code default}, поэтому
|
* неизвестна — применяются настройки {@code default}, поэтому контракт работает и без него.
|
||||||
* контракт работает и без него. Система, выключенная в настройках, получает
|
* Система, выключенная в настройках, получает {@code 403}.
|
||||||
* {@code 403}.
|
|
||||||
*
|
*
|
||||||
* <p>При перегрузке отвечает {@code 429} с {@code Retry-After}. Порог перегрузки —
|
* <p>При перегрузке отвечает {@code 429} с {@code Retry-After}. Порог перегрузки — не фиксированное
|
||||||
* не фиксированное число запросов, а задержка обработки: {@link AdaptiveConcurrencyLimiter}
|
* число запросов, а задержка обработки: {@link AdaptiveConcurrencyLimiter} сам находит потолок
|
||||||
* сам находит потолок конкурентности под то, сколько CPU реально досталось контейнеру,
|
* конкурентности под то, сколько CPU реально досталось контейнеру, вместо того чтобы копить запросы
|
||||||
* вместо того чтобы копить запросы и упереться в таймаут вызывающей стороны.
|
* и упереться в таймаут вызывающей стороны.
|
||||||
*/
|
*/
|
||||||
@RestController
|
@RestController
|
||||||
public class ProcessResource {
|
public class ProcessResource {
|
||||||
|
|
||||||
private static final Logger LOG = LoggerFactory.getLogger(ProcessResource.class);
|
private static final Logger LOG = LoggerFactory.getLogger(ProcessResource.class);
|
||||||
|
|
||||||
/** Заголовок, которым система-потребитель себя называет. */
|
/** Заголовок, которым система-потребитель себя называет. */
|
||||||
public static final String SYSTEM_HEADER = "X-System-Id";
|
public static final String SYSTEM_HEADER = "X-System-Id";
|
||||||
|
|
||||||
/** Общий секрет системы. Проверяется, только если он задан в настройках. */
|
/** Общий секрет системы. Проверяется, только если он задан в настройках. */
|
||||||
public static final String KEY_HEADER = "X-System-Key";
|
public static final String KEY_HEADER = "X-System-Key";
|
||||||
|
|
||||||
/** Имя метрики отклонённых запросов и имя её метки причины. */
|
/** Имя метрики отклонённых запросов и имя её метки причины. */
|
||||||
private static final String REJECTED_METRIC = "pdguard.requests.rejected";
|
private static final String REJECTED_METRIC = "pdguard.requests.rejected";
|
||||||
private static final String REASON_TAG = "reason";
|
|
||||||
|
|
||||||
/**
|
private static final String REASON_TAG = "reason";
|
||||||
* Что отдаётся при внутреннем сбое. Ни одного знака из запроса: сбой на прямом
|
|
||||||
* шаге иначе выпустил бы наружу незамаскированные персональные данные.
|
|
||||||
*/
|
|
||||||
static final String PROCESSING_UNAVAILABLE = "[обработка недоступна]";
|
|
||||||
|
|
||||||
public record ProcessRequest(
|
/**
|
||||||
@JsonProperty("payload") String payload,
|
* Что отдаётся при внутреннем сбое. Ни одного знака из запроса: сбой на прямом шаге иначе
|
||||||
@JsonProperty("payload_id") String payloadId) {
|
* выпустил бы наружу незамаскированные персональные данные.
|
||||||
|
*/
|
||||||
|
static final String PROCESSING_UNAVAILABLE = "[обработка недоступна]";
|
||||||
|
|
||||||
|
public record ProcessRequest(
|
||||||
|
@JsonProperty("payload") String payload, @JsonProperty("payload_id") String payloadId) {}
|
||||||
|
|
||||||
|
public record ProcessResponse(@JsonProperty("result") String result) {}
|
||||||
|
|
||||||
|
private final Pipeline pipeline;
|
||||||
|
private final SystemsConfig systems;
|
||||||
|
private final AdaptiveConcurrencyLimiter limiter;
|
||||||
|
private final Counter rejected;
|
||||||
|
private final Counter malformed;
|
||||||
|
private final Counter forbidden;
|
||||||
|
private final Counter failed;
|
||||||
|
|
||||||
|
public ProcessResource(
|
||||||
|
Pipeline pipeline,
|
||||||
|
SystemsConfig systems,
|
||||||
|
MeterRegistry meters,
|
||||||
|
@Value("${pdguard.min-concurrent:8}") int minConcurrent,
|
||||||
|
@Value("${pdguard.max-concurrent:2000}") int maxConcurrent,
|
||||||
|
@Value("${pdguard.target-latency-ms:200}") long targetLatencyMillis) {
|
||||||
|
this.pipeline = pipeline;
|
||||||
|
this.systems = systems;
|
||||||
|
this.limiter =
|
||||||
|
new AdaptiveConcurrencyLimiter(minConcurrent, maxConcurrent, targetLatencyMillis);
|
||||||
|
this.rejected = meters.counter(REJECTED_METRIC, REASON_TAG, "overload");
|
||||||
|
this.malformed = meters.counter(REJECTED_METRIC, REASON_TAG, "malformed");
|
||||||
|
this.forbidden = meters.counter(REJECTED_METRIC, REASON_TAG, "system_disabled");
|
||||||
|
this.failed = meters.counter(REJECTED_METRIC, REASON_TAG, "internal_error");
|
||||||
|
meters.gauge("pdguard.concurrency.limit", limiter, AdaptiveConcurrencyLimiter::limit);
|
||||||
|
meters.gauge("pdguard.concurrency.in.flight", limiter, AdaptiveConcurrencyLimiter::inFlight);
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/process")
|
||||||
|
public ResponseEntity<ProcessResponse> process(
|
||||||
|
@RequestBody(required = false) ProcessRequest request,
|
||||||
|
@RequestHeader(value = SYSTEM_HEADER, required = false) String systemId,
|
||||||
|
@RequestHeader(value = KEY_HEADER, required = false) String systemKey) {
|
||||||
|
if (request == null
|
||||||
|
|| request.payload() == null
|
||||||
|
|| request.payloadId() == null
|
||||||
|
|| request.payloadId().isBlank()) {
|
||||||
|
malformed.increment();
|
||||||
|
return ResponseEntity.badRequest()
|
||||||
|
.body(new ProcessResponse("payload и payload_id обязательны"));
|
||||||
}
|
}
|
||||||
|
|
||||||
public record ProcessResponse(@JsonProperty("result") String result) {
|
SystemPolicy policy = systems.policyFor(systemId);
|
||||||
|
if (!policy.accepts(systemKey)) {
|
||||||
|
forbidden.increment();
|
||||||
|
LOG.warn("Системе {} отказано: неверный ключ", systemId);
|
||||||
|
return ResponseEntity.status(HttpStatus.FORBIDDEN)
|
||||||
|
.body(new ProcessResponse("Неверный ключ системы"));
|
||||||
|
}
|
||||||
|
if (!policy.enabled()) {
|
||||||
|
forbidden.increment();
|
||||||
|
LOG.warn("Системе {} обращение в модуль запрещено настройками", systemId);
|
||||||
|
return ResponseEntity.status(HttpStatus.FORBIDDEN)
|
||||||
|
.body(new ProcessResponse("Системе " + systemId + " обращение в модуль запрещено"));
|
||||||
}
|
}
|
||||||
|
|
||||||
private final Pipeline pipeline;
|
if (!limiter.tryAcquire()) {
|
||||||
private final SystemsConfig systems;
|
rejected.increment();
|
||||||
private final AdaptiveConcurrencyLimiter limiter;
|
return ResponseEntity.status(429).header("Retry-After", "1").build();
|
||||||
private final Counter rejected;
|
|
||||||
private final Counter malformed;
|
|
||||||
private final Counter forbidden;
|
|
||||||
private final Counter failed;
|
|
||||||
|
|
||||||
public ProcessResource(Pipeline pipeline, SystemsConfig systems, MeterRegistry meters,
|
|
||||||
@Value("${pdguard.min-concurrent:8}") int minConcurrent,
|
|
||||||
@Value("${pdguard.max-concurrent:2000}") int maxConcurrent,
|
|
||||||
@Value("${pdguard.target-latency-ms:200}") long targetLatencyMillis) {
|
|
||||||
this.pipeline = pipeline;
|
|
||||||
this.systems = systems;
|
|
||||||
this.limiter = new AdaptiveConcurrencyLimiter(minConcurrent, maxConcurrent, targetLatencyMillis);
|
|
||||||
this.rejected = meters.counter(REJECTED_METRIC, REASON_TAG, "overload");
|
|
||||||
this.malformed = meters.counter(REJECTED_METRIC, REASON_TAG, "malformed");
|
|
||||||
this.forbidden = meters.counter(REJECTED_METRIC, REASON_TAG, "system_disabled");
|
|
||||||
this.failed = meters.counter(REJECTED_METRIC, REASON_TAG, "internal_error");
|
|
||||||
meters.gauge("pdguard.concurrency.limit", limiter, AdaptiveConcurrencyLimiter::limit);
|
|
||||||
meters.gauge("pdguard.concurrency.in.flight", limiter, AdaptiveConcurrencyLimiter::inFlight);
|
|
||||||
}
|
}
|
||||||
|
long started = System.nanoTime();
|
||||||
@PostMapping("/process")
|
try {
|
||||||
public ResponseEntity<ProcessResponse> process(@RequestBody(required = false) ProcessRequest request,
|
String result = pipeline.process(request.payload(), request.payloadId(), policy);
|
||||||
@RequestHeader(value = SYSTEM_HEADER, required = false) String systemId,
|
return ResponseEntity.ok(new ProcessResponse(result));
|
||||||
@RequestHeader(value = KEY_HEADER, required = false) String systemKey) {
|
} catch (RuntimeException e) {
|
||||||
if (request == null || request.payload() == null
|
// Ни 5xx, ни исходный текст. Пять подряд невалидных ответов останавливают
|
||||||
|| request.payloadId() == null || request.payloadId().isBlank()) {
|
// прогон, поэтому код остаётся 200 — но возвращать при сбое сам payload
|
||||||
malformed.increment();
|
// нельзя: на прямом шаге наружу ушли бы незамаскированные ПД, ровно то,
|
||||||
return ResponseEntity.badRequest()
|
// ради чего сервис и существует. Ответ фиксированный: он ничего не
|
||||||
.body(new ProcessResponse("payload и payload_id обязательны"));
|
// раскрывает и не выглядит порчей данных.
|
||||||
}
|
failed.increment();
|
||||||
|
LOG.error(
|
||||||
SystemPolicy policy = systems.policyFor(systemId);
|
"payload_id={} обработка не удалась, отдан безопасный ответ", request.payloadId(), e);
|
||||||
if (!policy.accepts(systemKey)) {
|
return ResponseEntity.ok(new ProcessResponse(PROCESSING_UNAVAILABLE));
|
||||||
forbidden.increment();
|
} finally {
|
||||||
LOG.warn("Системе {} отказано: неверный ключ", systemId);
|
limiter.release(System.nanoTime() - started);
|
||||||
return ResponseEntity.status(HttpStatus.FORBIDDEN)
|
|
||||||
.body(new ProcessResponse("Неверный ключ системы"));
|
|
||||||
}
|
|
||||||
if (!policy.enabled()) {
|
|
||||||
forbidden.increment();
|
|
||||||
LOG.warn("Системе {} обращение в модуль запрещено настройками", systemId);
|
|
||||||
return ResponseEntity.status(HttpStatus.FORBIDDEN)
|
|
||||||
.body(new ProcessResponse("Системе " + systemId + " обращение в модуль запрещено"));
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!limiter.tryAcquire()) {
|
|
||||||
rejected.increment();
|
|
||||||
return ResponseEntity.status(429).header("Retry-After", "1").build();
|
|
||||||
}
|
|
||||||
long started = System.nanoTime();
|
|
||||||
try {
|
|
||||||
String result = pipeline.process(request.payload(), request.payloadId(), policy);
|
|
||||||
return ResponseEntity.ok(new ProcessResponse(result));
|
|
||||||
} catch (RuntimeException e) {
|
|
||||||
// Ни 5xx, ни исходный текст. Пять подряд невалидных ответов останавливают
|
|
||||||
// прогон, поэтому код остаётся 200 — но возвращать при сбое сам payload
|
|
||||||
// нельзя: на прямом шаге наружу ушли бы незамаскированные ПД, ровно то,
|
|
||||||
// ради чего сервис и существует. Ответ фиксированный: он ничего не
|
|
||||||
// раскрывает и не выглядит порчей данных.
|
|
||||||
failed.increment();
|
|
||||||
LOG.error("payload_id={} обработка не удалась, отдан безопасный ответ",
|
|
||||||
request.payloadId(), e);
|
|
||||||
return ResponseEntity.ok(new ProcessResponse(PROCESSING_UNAVAILABLE));
|
|
||||||
} finally {
|
|
||||||
limiter.release(System.nanoTime() - started);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package ru.pdguard.api;
|
package ru.pdguard.api;
|
||||||
|
|
||||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||||
|
import java.util.Map;
|
||||||
import org.slf4j.Logger;
|
import org.slf4j.Logger;
|
||||||
import org.slf4j.LoggerFactory;
|
import org.slf4j.LoggerFactory;
|
||||||
import org.springframework.http.HttpStatus;
|
import org.springframework.http.HttpStatus;
|
||||||
@@ -14,8 +15,6 @@ import ru.pdguard.config.SystemsConfig;
|
|||||||
import ru.pdguard.core.LlmClient;
|
import ru.pdguard.core.LlmClient;
|
||||||
import ru.pdguard.core.Pipeline;
|
import ru.pdguard.core.Pipeline;
|
||||||
|
|
||||||
import java.util.Map;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Демонстрационное плечо к языковой модели: показывает всю цепочку целиком.
|
* Демонстрационное плечо к языковой модели: показывает всю цепочку целиком.
|
||||||
*
|
*
|
||||||
@@ -23,84 +22,94 @@ import java.util.Map;
|
|||||||
* потребитель → маскирование → LLM → демаскирование → потребитель
|
* потребитель → маскирование → LLM → демаскирование → потребитель
|
||||||
* </pre>
|
* </pre>
|
||||||
*
|
*
|
||||||
* <p>В ответе видны все три текста — что ушло в модель, что она вернула и что
|
* <p>В ответе видны все три текста — что ушло в модель, что она вернула и что получил потребитель.
|
||||||
* получил потребитель. Это и есть доказательство, что в модель не попало ничего
|
* Это и есть доказательство, что в модель не попало ничего незамаскированного, а ответ вернулся с
|
||||||
* незамаскированного, а ответ вернулся с восстановленными значениями.
|
* восстановленными значениями.
|
||||||
*
|
*
|
||||||
* <p>Ответ модели — другой текст, а не тот же самый, поэтому восстановить его по
|
* <p>Ответ модели — другой текст, а не тот же самый, поэтому восстановить его по идентификатору
|
||||||
* идентификатору целиком нельзя: замена идёт пофрагментно. Звёздочки для этого не
|
* целиком нельзя: замена идёт пофрагментно. Звёздочки для этого не годятся — одна и та же маска
|
||||||
* годятся — одна и та же маска отвечала бы разным значениям, — поэтому здесь всегда
|
* отвечала бы разным значениям, — поэтому здесь всегда применяется обратимая подстановка,
|
||||||
* применяется обратимая подстановка, независимо от режима маскирования системы.
|
* независимо от режима маскирования системы.
|
||||||
*
|
*
|
||||||
* <p>Контракт проверяющей системы это плечо не затрагивает: он живёт в
|
* <p>Контракт проверяющей системы это плечо не затрагивает: он живёт в {@link ProcessResource}.
|
||||||
* {@link ProcessResource}.
|
|
||||||
*/
|
*/
|
||||||
@RestController
|
@RestController
|
||||||
public class ProxyResource {
|
public class ProxyResource {
|
||||||
|
|
||||||
private static final Logger LOG = LoggerFactory.getLogger(ProxyResource.class);
|
private static final Logger LOG = LoggerFactory.getLogger(ProxyResource.class);
|
||||||
|
|
||||||
public record ProxyRequest(@JsonProperty("prompt") String prompt) {
|
public record ProxyRequest(@JsonProperty("prompt") String prompt) {}
|
||||||
|
|
||||||
|
public record ProxyResponse(
|
||||||
|
@JsonProperty("prompt_masked") String promptMasked,
|
||||||
|
@JsonProperty("llm_response_masked") String llmResponseMasked,
|
||||||
|
@JsonProperty("response") String response,
|
||||||
|
@JsonProperty("replaced") Map<String, String> replaced,
|
||||||
|
@JsonProperty("llm") String llm,
|
||||||
|
@JsonProperty("error") String error) {
|
||||||
|
public ProxyResponse {
|
||||||
|
replaced = replaced == null ? null : Map.copyOf(replaced);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private final Pipeline pipeline;
|
||||||
|
private final SystemsConfig systems;
|
||||||
|
private final LlmClient llm;
|
||||||
|
|
||||||
|
public ProxyResource(Pipeline pipeline, SystemsConfig systems, LlmClient llm) {
|
||||||
|
this.pipeline = pipeline;
|
||||||
|
this.systems = systems;
|
||||||
|
this.llm = llm;
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/proxy")
|
||||||
|
public ResponseEntity<ProxyResponse> proxy(
|
||||||
|
@RequestBody(required = false) ProxyRequest request,
|
||||||
|
@RequestHeader(value = ProcessResource.SYSTEM_HEADER, required = false) String systemId,
|
||||||
|
@RequestHeader(value = ProcessResource.KEY_HEADER, required = false) String systemKey) {
|
||||||
|
if (request == null || request.prompt() == null || request.prompt().isBlank()) {
|
||||||
|
return ResponseEntity.badRequest()
|
||||||
|
.body(new ProxyResponse(null, null, null, null, null, "поле prompt обязательно"));
|
||||||
}
|
}
|
||||||
|
|
||||||
public record ProxyResponse(
|
SystemPolicy policy = systems.policyFor(systemId);
|
||||||
@JsonProperty("prompt_masked") String promptMasked,
|
if (!policy.accepts(systemKey)) {
|
||||||
@JsonProperty("llm_response_masked") String llmResponseMasked,
|
return ResponseEntity.status(HttpStatus.FORBIDDEN)
|
||||||
@JsonProperty("response") String response,
|
.body(new ProxyResponse(null, null, null, null, null, "Неверный ключ системы"));
|
||||||
@JsonProperty("replaced") Map<String, String> replaced,
|
}
|
||||||
@JsonProperty("llm") String llm,
|
if (!policy.enabled()) {
|
||||||
@JsonProperty("error") String error) {
|
return ResponseEntity.status(HttpStatus.FORBIDDEN)
|
||||||
public ProxyResponse {
|
.body(
|
||||||
replaced = replaced == null ? null : Map.copyOf(replaced);
|
new ProxyResponse(
|
||||||
}
|
null,
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
"Системе " + systemId + " обращение в модуль запрещено"));
|
||||||
}
|
}
|
||||||
|
|
||||||
private final Pipeline pipeline;
|
Pipeline.Masked masked = pipeline.maskWithRestorations(request.prompt(), policy);
|
||||||
private final SystemsConfig systems;
|
LlmClient.Answer answer = llm.ask(masked.text());
|
||||||
private final LlmClient llm;
|
String restored =
|
||||||
|
policy.demask() ? restore(answer.text(), masked.restorations()) : answer.text();
|
||||||
|
|
||||||
public ProxyResource(Pipeline pipeline, SystemsConfig systems, LlmClient llm) {
|
LOG.info(
|
||||||
this.pipeline = pipeline;
|
"proxy: система={} заменено={} модель={}",
|
||||||
this.systems = systems;
|
policy.name(),
|
||||||
this.llm = llm;
|
masked.restorations().size(),
|
||||||
|
answer.source());
|
||||||
|
return ResponseEntity.ok(
|
||||||
|
new ProxyResponse(
|
||||||
|
masked.text(), answer.text(), restored, masked.restorations(), answer.source(), null));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Возвращает исходные значения на место подстановок в ответе модели. */
|
||||||
|
private static String restore(String text, Map<String, String> restorations) {
|
||||||
|
String result = text;
|
||||||
|
for (Map.Entry<String, String> entry : restorations.entrySet()) {
|
||||||
|
result = result.replace(entry.getKey(), entry.getValue());
|
||||||
}
|
}
|
||||||
|
return result;
|
||||||
@PostMapping("/proxy")
|
}
|
||||||
public ResponseEntity<ProxyResponse> proxy(@RequestBody(required = false) ProxyRequest request,
|
}
|
||||||
@RequestHeader(value = ProcessResource.SYSTEM_HEADER, required = false) String systemId,
|
|
||||||
@RequestHeader(value = ProcessResource.KEY_HEADER, required = false) String systemKey) {
|
|
||||||
if (request == null || request.prompt() == null || request.prompt().isBlank()) {
|
|
||||||
return ResponseEntity.badRequest()
|
|
||||||
.body(new ProxyResponse(null, null, null, null, null, "поле prompt обязательно"));
|
|
||||||
}
|
|
||||||
|
|
||||||
SystemPolicy policy = systems.policyFor(systemId);
|
|
||||||
if (!policy.accepts(systemKey)) {
|
|
||||||
return ResponseEntity.status(HttpStatus.FORBIDDEN)
|
|
||||||
.body(new ProxyResponse(null, null, null, null, null, "Неверный ключ системы"));
|
|
||||||
}
|
|
||||||
if (!policy.enabled()) {
|
|
||||||
return ResponseEntity.status(HttpStatus.FORBIDDEN)
|
|
||||||
.body(new ProxyResponse(null, null, null, null, null,
|
|
||||||
"Системе " + systemId + " обращение в модуль запрещено"));
|
|
||||||
}
|
|
||||||
|
|
||||||
Pipeline.Masked masked = pipeline.maskWithRestorations(request.prompt(), policy);
|
|
||||||
LlmClient.Answer answer = llm.ask(masked.text());
|
|
||||||
String restored = policy.demask() ? restore(answer.text(), masked.restorations()) : answer.text();
|
|
||||||
|
|
||||||
LOG.info("proxy: система={} заменено={} модель={}",
|
|
||||||
policy.name(), masked.restorations().size(), answer.source());
|
|
||||||
return ResponseEntity.ok(new ProxyResponse(masked.text(), answer.text(), restored,
|
|
||||||
masked.restorations(), answer.source(), null));
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Возвращает исходные значения на место подстановок в ответе модели. */
|
|
||||||
private static String restore(String text, Map<String, String> restorations) {
|
|
||||||
String result = text;
|
|
||||||
for (Map.Entry<String, String> entry : restorations.entrySet()) {
|
|
||||||
result = result.replace(entry.getKey(), entry.getValue());
|
|
||||||
}
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -8,18 +8,17 @@ import ru.pdguard.detect.NameDictionary;
|
|||||||
/**
|
/**
|
||||||
* Конфигурация словарей распознавания ФИО.
|
* Конфигурация словарей распознавания ФИО.
|
||||||
*
|
*
|
||||||
* <p>{@link NameDictionary} работает через статические методы и не требует
|
* <p>{@link NameDictionary} работает через статические методы и не требует экземпляра, но путь к
|
||||||
* экземпляра, но путь к внешнему файлу денилиста задаётся из настроек при
|
* внешнему файлу денилиста задаётся из настроек при старте. Бин здесь нужен только для того, чтобы
|
||||||
* старте. Бин здесь нужен только для того, чтобы Spring подставил значение
|
* Spring подставил значение {@code pdguard.well-known-file} и передал его словарю.
|
||||||
* {@code pdguard.well-known-file} и передал его словарю.
|
|
||||||
*/
|
*/
|
||||||
@Configuration
|
@Configuration
|
||||||
public class DictionaryConfiguration {
|
public class DictionaryConfiguration {
|
||||||
|
|
||||||
@Bean
|
@Bean
|
||||||
public NameDictionary nameDictionary(
|
public NameDictionary nameDictionary(
|
||||||
@Value("${pdguard.well-known-file:config/well-known.txt}") String wellKnownFile) {
|
@Value("${pdguard.well-known-file:config/well-known.txt}") String wellKnownFile) {
|
||||||
NameDictionary.configure(wellKnownFile);
|
NameDictionary.configure(wellKnownFile);
|
||||||
return NameDictionary.create();
|
return NameDictionary.create();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,81 +1,96 @@
|
|||||||
package ru.pdguard.config;
|
package ru.pdguard.config;
|
||||||
|
|
||||||
|
import java.util.Set;
|
||||||
import ru.pdguard.detect.PdTypes;
|
import ru.pdguard.detect.PdTypes;
|
||||||
import ru.pdguard.mask.MaskMode;
|
import ru.pdguard.mask.MaskMode;
|
||||||
|
|
||||||
import java.util.Set;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Правила обработки для одной системы-потребителя.
|
* Правила обработки для одной системы-потребителя.
|
||||||
*
|
*
|
||||||
* @param name имя системы; им же разделяется хранилище соответствий,
|
* @param name имя системы; им же разделяется хранилище соответствий, чтобы одна система не могла
|
||||||
* чтобы одна система не могла достать данные другой
|
* достать данные другой
|
||||||
* @param enabled разрешено ли системе обращаться в модуль
|
* @param enabled разрешено ли системе обращаться в модуль
|
||||||
* @param demask выполняется ли для системы обратное преобразование
|
* @param demask выполняется ли для системы обратное преобразование
|
||||||
* @param maskMode вид замены: звёздочки, токен или синтетическое значение
|
* @param maskMode вид замены: звёздочки, токен или синтетическое значение
|
||||||
* @param types типы ПД к маскированию; {@code "*"} — все известные
|
* @param types типы ПД к маскированию; {@code "*"} — все известные
|
||||||
* @param key общий секрет системы; задан — заголовок {@code X-System-Key} обязан
|
* @param key общий секрет системы; задан — заголовок {@code X-System-Key} обязан совпасть, иначе
|
||||||
* совпасть, иначе имя системы можно было бы просто назвать.
|
* имя системы можно было бы просто назвать. Только знаки ASCII: заголовки HTTP передаются в
|
||||||
* Только знаки ASCII: заголовки HTTP передаются в Latin-1,
|
* Latin-1, и кириллица в ключе до сервиса доедет искажённой
|
||||||
* и кириллица в ключе до сервиса доедет искажённой
|
* @param requireCompanion типы, которые маскируются только вместе с ПД другого типа: пин-код сам по
|
||||||
* @param requireCompanion типы, которые маскируются только вместе с ПД другого типа:
|
* себе безвреден, пин-код рядом с номером карты — уже нет; то же для даты без якорного слова,
|
||||||
* пин-код сам по себе безвреден, пин-код рядом с номером
|
* места рождения («Нижний Новгород» в рассказе о городе — не адрес клиента) и страны («цены
|
||||||
* карты — уже нет; то же для даты без якорного слова, места
|
* выросли в Казахстане» — не гражданство). Сюда же банковские реквизиты — счёт, БИК, ОГРН,
|
||||||
* рождения («Нижний Новгород» в рассказе о городе — не адрес
|
* ОГРНИП, КПП: сами по себе они опознают организацию или счёт, а не человека, и в перечне типов
|
||||||
* клиента) и страны («цены выросли в Казахстане» — не гражданство).
|
* из задания их нет. Рядом с именем клиента они становятся его данными и маскируются. Сюда же
|
||||||
* Сюда же банковские реквизиты — счёт, БИК, ОГРН, ОГРНИП, КПП:
|
* доход и биометрия. Сумма заработка без человека — статистика («доход домохозяйств вырос до 74
|
||||||
* сами по себе они опознают организацию или счёт, а не человека,
|
* 500 руб»), а не персональные данные. Биометрия же в тексте не встречается вовсе: это шаблон в
|
||||||
* и в перечне типов из задания их нет. Рядом с именем клиента
|
* базе, и правило маскирует лишь само упоминание, то есть слово, а не данные. Чувствителен
|
||||||
* они становятся его данными и маскируются.
|
* здесь факт, что биометрию сдал названный человек, — а он и существует только при имени рядом
|
||||||
* Сюда же доход и биометрия. Сумма заработка без человека —
|
|
||||||
* статистика («доход домохозяйств вырос до 74 500 руб»), а не
|
|
||||||
* персональные данные. Биометрия же в тексте не встречается
|
|
||||||
* вовсе: это шаблон в базе, и правило маскирует лишь само
|
|
||||||
* упоминание, то есть слово, а не данные. Чувствителен здесь
|
|
||||||
* факт, что биометрию сдал названный человек, — а он и
|
|
||||||
* существует только при имени рядом
|
|
||||||
*/
|
*/
|
||||||
public record SystemPolicy(String name, boolean enabled, boolean demask, MaskMode maskMode,
|
public record SystemPolicy(
|
||||||
Set<String> types, Set<String> requireCompanion, String key) {
|
String name,
|
||||||
|
boolean enabled,
|
||||||
|
boolean demask,
|
||||||
|
MaskMode maskMode,
|
||||||
|
Set<String> types,
|
||||||
|
Set<String> requireCompanion,
|
||||||
|
String key) {
|
||||||
|
|
||||||
public static final String ALL = "*";
|
public static final String ALL = "*";
|
||||||
|
|
||||||
/** Имя политики по умолчанию; оно же разделяет хранилище для запросов без заголовка. */
|
/** Имя политики по умолчанию; оно же разделяет хранилище для запросов без заголовка. */
|
||||||
public static final String DEFAULT_NAME = "default";
|
public static final String DEFAULT_NAME = "default";
|
||||||
|
|
||||||
/** Политика по умолчанию: маскируем всё, что умеем, обратное преобразование включено. */
|
/** Политика по умолчанию: маскируем всё, что умеем, обратное преобразование включено. */
|
||||||
public static final SystemPolicy DEFAULT = new SystemPolicy(
|
public static final SystemPolicy DEFAULT =
|
||||||
DEFAULT_NAME, true, true, MaskMode.MASK, Set.of(ALL),
|
new SystemPolicy(
|
||||||
Set.of(PdTypes.CVV, PdTypes.PIN, PdTypes.DATE, PdTypes.BIRTH_PLACE, PdTypes.ADDRESS_COUNTRY,
|
DEFAULT_NAME,
|
||||||
PdTypes.ACCOUNT_NUMBER, PdTypes.BIK, PdTypes.OGRN, PdTypes.OGRNIP, PdTypes.KPP,
|
true,
|
||||||
PdTypes.INCOME, PdTypes.BIOMETRIC), null);
|
true,
|
||||||
|
MaskMode.MASK,
|
||||||
|
Set.of(ALL),
|
||||||
|
Set.of(
|
||||||
|
PdTypes.CVV,
|
||||||
|
PdTypes.PIN,
|
||||||
|
PdTypes.DATE,
|
||||||
|
PdTypes.BIRTH_PLACE,
|
||||||
|
PdTypes.ADDRESS_COUNTRY,
|
||||||
|
PdTypes.ACCOUNT_NUMBER,
|
||||||
|
PdTypes.BIK,
|
||||||
|
PdTypes.OGRN,
|
||||||
|
PdTypes.OGRNIP,
|
||||||
|
PdTypes.KPP,
|
||||||
|
PdTypes.INCOME,
|
||||||
|
PdTypes.BIOMETRIC),
|
||||||
|
null);
|
||||||
|
|
||||||
public SystemPolicy {
|
public SystemPolicy {
|
||||||
types = Set.copyOf(types);
|
types = Set.copyOf(types);
|
||||||
requireCompanion = Set.copyOf(requireCompanion);
|
requireCompanion = Set.copyOf(requireCompanion);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Политика только для перечисленных типов, с остальными настройками по умолчанию. */
|
||||||
|
public static SystemPolicy forTypes(String... types) {
|
||||||
|
return new SystemPolicy(
|
||||||
|
DEFAULT_NAME, true, true, MaskMode.MASK, Set.of(types), DEFAULT.requireCompanion(), null);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Совпадает ли предъявленный ключ. Ключ не задан — проверка не применяется. */
|
||||||
|
public boolean accepts(String presentedKey) {
|
||||||
|
if (key == null || key.isBlank()) {
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
|
return java.security.MessageDigest.isEqual(
|
||||||
|
key.getBytes(java.nio.charset.StandardCharsets.UTF_8),
|
||||||
|
(presentedKey == null ? "" : presentedKey)
|
||||||
|
.getBytes(java.nio.charset.StandardCharsets.UTF_8));
|
||||||
|
}
|
||||||
|
|
||||||
/** Политика только для перечисленных типов, с остальными настройками по умолчанию. */
|
public boolean allows(String type) {
|
||||||
public static SystemPolicy forTypes(String... types) {
|
return types.contains(ALL) || types.contains(type);
|
||||||
return new SystemPolicy(DEFAULT_NAME, true, true, MaskMode.MASK,
|
}
|
||||||
Set.of(types), DEFAULT.requireCompanion(), null);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Совпадает ли предъявленный ключ. Ключ не задан — проверка не применяется. */
|
public boolean needsCompanion(String type) {
|
||||||
public boolean accepts(String presentedKey) {
|
return requireCompanion.contains(type);
|
||||||
if (key == null || key.isBlank()) {
|
}
|
||||||
return true;
|
|
||||||
}
|
|
||||||
return java.security.MessageDigest.isEqual(
|
|
||||||
key.getBytes(java.nio.charset.StandardCharsets.UTF_8),
|
|
||||||
(presentedKey == null ? "" : presentedKey).getBytes(java.nio.charset.StandardCharsets.UTF_8));
|
|
||||||
}
|
|
||||||
|
|
||||||
public boolean allows(String type) {
|
|
||||||
return types.contains(ALL) || types.contains(type);
|
|
||||||
}
|
|
||||||
|
|
||||||
public boolean needsCompanion(String type) {
|
|
||||||
return requireCompanion.contains(type);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,12 +1,6 @@
|
|||||||
package ru.pdguard.config;
|
package ru.pdguard.config;
|
||||||
|
|
||||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
import org.slf4j.Logger;
|
|
||||||
import org.slf4j.LoggerFactory;
|
|
||||||
import org.springframework.beans.factory.annotation.Value;
|
|
||||||
import org.springframework.stereotype.Component;
|
|
||||||
import ru.pdguard.mask.MaskMode;
|
|
||||||
|
|
||||||
import java.io.IOException;
|
import java.io.IOException;
|
||||||
import java.nio.file.Files;
|
import java.nio.file.Files;
|
||||||
import java.nio.file.Path;
|
import java.nio.file.Path;
|
||||||
@@ -14,127 +8,151 @@ import java.util.HashSet;
|
|||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Locale;
|
import java.util.Locale;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
import java.util.concurrent.atomic.AtomicReference;
|
|
||||||
import java.util.Set;
|
import java.util.Set;
|
||||||
import java.util.TreeMap;
|
import java.util.TreeMap;
|
||||||
|
import java.util.concurrent.atomic.AtomicReference;
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
import ru.pdguard.mask.MaskMode;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Список систем, которым разрешено обращаться в модуль, и правила для каждой.
|
* Список систем, которым разрешено обращаться в модуль, и правила для каждой.
|
||||||
*
|
*
|
||||||
* <p>Читается из внешнего файла, чтобы настройки менялись без пересборки. Файл
|
* <p>Читается из внешнего файла, чтобы настройки менялись без пересборки. Файл перечитывается сам,
|
||||||
* перечитывается сам, когда меняется время его изменения; проверка выполняется
|
* когда меняется время его изменения; проверка выполняется не чаще раза в секунду, чтобы не ходить
|
||||||
* не чаще раза в секунду, чтобы не ходить в файловую систему на каждом запросе.
|
* в файловую систему на каждом запросе. Файла нет — работают настройки по умолчанию, и сервис
|
||||||
* Файла нет — работают настройки по умолчанию, и сервис поднимается без него.
|
* поднимается без него.
|
||||||
*/
|
*/
|
||||||
@Component
|
@Component
|
||||||
public final class SystemsConfig {
|
public final class SystemsConfig {
|
||||||
|
|
||||||
private static final Logger LOG = LoggerFactory.getLogger(SystemsConfig.class);
|
private static final Logger LOG = LoggerFactory.getLogger(SystemsConfig.class);
|
||||||
|
|
||||||
/** Имя политики, которая применяется к запросам без заголовка системы. */
|
/** Имя политики, которая применяется к запросам без заголовка системы. */
|
||||||
public static final String DEFAULT_SYSTEM = "default";
|
public static final String DEFAULT_SYSTEM = "default";
|
||||||
|
|
||||||
private static final long RECHECK_MILLIS = 1000;
|
private static final long RECHECK_MILLIS = 1000;
|
||||||
|
|
||||||
/** Описание одной системы в файле настроек. */
|
/** Описание одной системы в файле настроек. */
|
||||||
public record SystemEntry(Boolean enabled, Boolean demask, String maskMode,
|
public record SystemEntry(
|
||||||
List<String> types, List<String> requireCompanion, String key) {
|
Boolean enabled,
|
||||||
public SystemEntry {
|
Boolean demask,
|
||||||
types = types == null ? null : List.copyOf(types);
|
String maskMode,
|
||||||
requireCompanion = requireCompanion == null ? null : List.copyOf(requireCompanion);
|
List<String> types,
|
||||||
}
|
List<String> requireCompanion,
|
||||||
|
String key) {
|
||||||
|
public SystemEntry {
|
||||||
|
types = types == null ? null : List.copyOf(types);
|
||||||
|
requireCompanion = requireCompanion == null ? null : List.copyOf(requireCompanion);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private final Path file;
|
private final Path file;
|
||||||
private final ObjectMapper mapper;
|
private final ObjectMapper mapper;
|
||||||
|
|
||||||
private final AtomicReference<Map<String, SystemPolicy>> policies =
|
private final AtomicReference<Map<String, SystemPolicy>> policies =
|
||||||
new AtomicReference<>(Map.of(DEFAULT_SYSTEM, SystemPolicy.DEFAULT));
|
new AtomicReference<>(Map.of(DEFAULT_SYSTEM, SystemPolicy.DEFAULT));
|
||||||
private volatile long fileTimestamp;
|
private volatile long fileTimestamp;
|
||||||
private volatile long lastCheck;
|
private volatile long lastCheck;
|
||||||
|
|
||||||
public SystemsConfig(@Value("${pdguard.systems-file:config/systems.json}") String path,
|
public SystemsConfig(
|
||||||
ObjectMapper mapper) {
|
@Value("${pdguard.systems-file:config/systems.json}") String path, ObjectMapper mapper) {
|
||||||
this.file = Path.of(path);
|
this.file = Path.of(path);
|
||||||
this.mapper = mapper;
|
this.mapper = mapper;
|
||||||
|
reload();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Правила для системы; неизвестная система получает настройки по умолчанию. */
|
||||||
|
public SystemPolicy policyFor(String systemId) {
|
||||||
|
refreshIfChanged();
|
||||||
|
Map<String, SystemPolicy> current = policies.get();
|
||||||
|
SystemPolicy policy = systemId == null ? null : current.get(systemId);
|
||||||
|
if (policy != null) {
|
||||||
|
return policy;
|
||||||
|
}
|
||||||
|
return current.getOrDefault(DEFAULT_SYSTEM, SystemPolicy.DEFAULT);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Известна ли система по имени. */
|
||||||
|
public boolean isKnown(String systemId) {
|
||||||
|
refreshIfChanged();
|
||||||
|
return systemId != null && policies.get().containsKey(systemId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Текущие настройки — для отдачи в административном интерфейсе. */
|
||||||
|
public Map<String, SystemPolicy> current() {
|
||||||
|
refreshIfChanged();
|
||||||
|
return new TreeMap<>(policies.get());
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Перечитать файл настроек немедленно. */
|
||||||
|
public final synchronized void reload() {
|
||||||
|
lastCheck = System.currentTimeMillis();
|
||||||
|
if (!Files.isReadable(file)) {
|
||||||
|
LOG.info(
|
||||||
|
"Файл настроек {} не найден, применяются настройки по умолчанию", file.toAbsolutePath());
|
||||||
|
policies.set(Map.of(DEFAULT_SYSTEM, SystemPolicy.DEFAULT));
|
||||||
|
fileTimestamp = 0;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
fileTimestamp = Files.getLastModifiedTime(file).toMillis();
|
||||||
|
Map<String, SystemEntry> entries =
|
||||||
|
mapper.readValue(
|
||||||
|
Files.readAllBytes(file),
|
||||||
|
mapper
|
||||||
|
.getTypeFactory()
|
||||||
|
.constructMapType(TreeMap.class, String.class, SystemEntry.class));
|
||||||
|
Map<String, SystemPolicy> parsed = new TreeMap<>();
|
||||||
|
entries.forEach((name, entry) -> parsed.put(name, toPolicy(name, entry)));
|
||||||
|
parsed.putIfAbsent(DEFAULT_SYSTEM, SystemPolicy.DEFAULT);
|
||||||
|
policies.set(Map.copyOf(parsed));
|
||||||
|
LOG.info("Настройки систем перечитаны из {}: {}", file.toAbsolutePath(), parsed.keySet());
|
||||||
|
} catch (IOException | IllegalArgumentException e) {
|
||||||
|
// Битый файл не должен ронять работающий сервис: остаются прежние настройки.
|
||||||
|
LOG.error(
|
||||||
|
"Не удалось прочитать {}, продолжаем с прежними настройками", file.toAbsolutePath(), e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void refreshIfChanged() {
|
||||||
|
long now = System.currentTimeMillis();
|
||||||
|
if (now - lastCheck < RECHECK_MILLIS) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
lastCheck = now;
|
||||||
|
try {
|
||||||
|
if (!Files.isReadable(file)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (Files.getLastModifiedTime(file).toMillis() != fileTimestamp) {
|
||||||
reload();
|
reload();
|
||||||
|
}
|
||||||
|
} catch (IOException e) {
|
||||||
|
LOG.debug("Не удалось проверить время изменения {}", file, e);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/** Правила для системы; неизвестная система получает настройки по умолчанию. */
|
private static SystemPolicy toPolicy(String name, SystemEntry entry) {
|
||||||
public SystemPolicy policyFor(String systemId) {
|
SystemPolicy base = SystemPolicy.DEFAULT;
|
||||||
refreshIfChanged();
|
Set<String> types = entry.types() == null ? base.types() : new HashSet<>(entry.types());
|
||||||
Map<String, SystemPolicy> current = policies.get();
|
Set<String> companions =
|
||||||
SystemPolicy policy = systemId == null ? null : current.get(systemId);
|
entry.requireCompanion() == null
|
||||||
if (policy != null) {
|
? base.requireCompanion()
|
||||||
return policy;
|
: new HashSet<>(entry.requireCompanion());
|
||||||
}
|
MaskMode mode =
|
||||||
return current.getOrDefault(DEFAULT_SYSTEM, SystemPolicy.DEFAULT);
|
entry.maskMode() == null
|
||||||
}
|
? base.maskMode()
|
||||||
|
: MaskMode.valueOf(entry.maskMode().toUpperCase(Locale.ROOT));
|
||||||
/** Известна ли система по имени. */
|
return new SystemPolicy(
|
||||||
public boolean isKnown(String systemId) {
|
name,
|
||||||
refreshIfChanged();
|
entry.enabled() == null || entry.enabled(),
|
||||||
return systemId != null && policies.get().containsKey(systemId);
|
entry.demask() == null || entry.demask(),
|
||||||
}
|
mode,
|
||||||
|
types,
|
||||||
/** Текущие настройки — для отдачи в административном интерфейсе. */
|
companions,
|
||||||
public Map<String, SystemPolicy> current() {
|
entry.key());
|
||||||
refreshIfChanged();
|
}
|
||||||
return new TreeMap<>(policies.get());
|
}
|
||||||
}
|
|
||||||
|
|
||||||
/** Перечитать файл настроек немедленно. */
|
|
||||||
public final synchronized void reload() {
|
|
||||||
lastCheck = System.currentTimeMillis();
|
|
||||||
if (!Files.isReadable(file)) {
|
|
||||||
LOG.info("Файл настроек {} не найден, применяются настройки по умолчанию", file.toAbsolutePath());
|
|
||||||
policies.set(Map.of(DEFAULT_SYSTEM, SystemPolicy.DEFAULT));
|
|
||||||
fileTimestamp = 0;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
fileTimestamp = Files.getLastModifiedTime(file).toMillis();
|
|
||||||
Map<String, SystemEntry> entries = mapper.readValue(Files.readAllBytes(file),
|
|
||||||
mapper.getTypeFactory().constructMapType(TreeMap.class, String.class, SystemEntry.class));
|
|
||||||
Map<String, SystemPolicy> parsed = new TreeMap<>();
|
|
||||||
entries.forEach((name, entry) -> parsed.put(name, toPolicy(name, entry)));
|
|
||||||
parsed.putIfAbsent(DEFAULT_SYSTEM, SystemPolicy.DEFAULT);
|
|
||||||
policies.set(Map.copyOf(parsed));
|
|
||||||
LOG.info("Настройки систем перечитаны из {}: {}", file.toAbsolutePath(), parsed.keySet());
|
|
||||||
} catch (IOException | IllegalArgumentException e) {
|
|
||||||
// Битый файл не должен ронять работающий сервис: остаются прежние настройки.
|
|
||||||
LOG.error("Не удалось прочитать {}, продолжаем с прежними настройками", file.toAbsolutePath(), e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private void refreshIfChanged() {
|
|
||||||
long now = System.currentTimeMillis();
|
|
||||||
if (now - lastCheck < RECHECK_MILLIS) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
lastCheck = now;
|
|
||||||
try {
|
|
||||||
if (!Files.isReadable(file)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (Files.getLastModifiedTime(file).toMillis() != fileTimestamp) {
|
|
||||||
reload();
|
|
||||||
}
|
|
||||||
} catch (IOException e) {
|
|
||||||
LOG.debug("Не удалось проверить время изменения {}", file, e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private static SystemPolicy toPolicy(String name, SystemEntry entry) {
|
|
||||||
SystemPolicy base = SystemPolicy.DEFAULT;
|
|
||||||
Set<String> types = entry.types() == null ? base.types() : new HashSet<>(entry.types());
|
|
||||||
Set<String> companions = entry.requireCompanion() == null
|
|
||||||
? base.requireCompanion() : new HashSet<>(entry.requireCompanion());
|
|
||||||
MaskMode mode = entry.maskMode() == null
|
|
||||||
? base.maskMode() : MaskMode.valueOf(entry.maskMode().toUpperCase(Locale.ROOT));
|
|
||||||
return new SystemPolicy(name,
|
|
||||||
entry.enabled() == null || entry.enabled(),
|
|
||||||
entry.demask() == null || entry.demask(),
|
|
||||||
mode, types, companions, entry.key());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,129 +1,121 @@
|
|||||||
package ru.pdguard.core;
|
package ru.pdguard.core;
|
||||||
|
|
||||||
|
import java.util.concurrent.TimeUnit;
|
||||||
import java.util.concurrent.atomic.AtomicInteger;
|
import java.util.concurrent.atomic.AtomicInteger;
|
||||||
import java.util.concurrent.atomic.AtomicLong;
|
import java.util.concurrent.atomic.AtomicLong;
|
||||||
import java.util.concurrent.TimeUnit;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Предел одновременных запросов, который сам подстраивается под задержку,
|
* Предел одновременных запросов, который сам подстраивается под задержку, а не задан фиксированным
|
||||||
* а не задан фиксированным числом. Растёт, пока обработка укладывается в
|
* числом. Растёт, пока обработка укладывается в целевое время, и сжимается, как только перестаёт —
|
||||||
* целевое время, и сжимается, как только перестаёт — вместо того чтобы
|
* вместо того чтобы копить очередь и подходить к таймауту вызывающей стороны.
|
||||||
* копить очередь и подходить к таймауту вызывающей стороны.
|
|
||||||
*
|
*
|
||||||
* <p>Число CPU контейнеру намеренно не спрашивается: {@code Runtime.
|
* <p>Число CPU контейнеру намеренно не спрашивается: {@code Runtime. availableProcessors()} под
|
||||||
* availableProcessors()} под квотой {@code --cpus} в cgroups не меняется
|
* квотой {@code --cpus} в cgroups не меняется (это не affinity, а квота), поэтому в контейнере с
|
||||||
* (это не affinity, а квота), поэтому в контейнере с долей ядра оно
|
* долей ядра оно показывает все ядра хоста и как источник предела не годится. Задержка —
|
||||||
* показывает все ядра хоста и как источник предела не годится. Задержка —
|
|
||||||
* наблюдаемое следствие реальной доли CPU, а не догадка о её размере.
|
* наблюдаемое следствие реальной доли CPU, а не догадка о её размере.
|
||||||
*
|
*
|
||||||
* <p>Шаг регулировки привязан к времени, не к числу запросов: при первой
|
* <p>Шаг регулировки привязан к времени, не к числу запросов: при первой версии предел менялся на
|
||||||
* версии предел менялся на каждый завершённый запрос, и на высоком RPS
|
* каждый завершённый запрос, и на высоком RPS тысячи «быстрых» замеров прилетали за миллисекунды —
|
||||||
* тысячи «быстрых» замеров прилетали за миллисекунды — предел успевал
|
* предел успевал разогнаться до потолка ещё до того, как перегрузка вообще проявлялась, и то же
|
||||||
* разогнаться до потолка ещё до того, как перегрузка вообще проявлялась,
|
* самое повторялось после каждого восстановления. Проверено нагрузочным тестом: без привязки к
|
||||||
* и то же самое повторялось после каждого восстановления. Проверено
|
* времени p95 на перегрузке доходил до 1,8–2,3 с при 0,5 CPU, хотя предел вроде бы должен был
|
||||||
* нагрузочным тестом: без привязки к времени p95 на перегрузке доходил
|
* сжаться. Не чаще, чем раз в {@link #ADJUST_WINDOW_NANOS}, предел меняется одним шагом на основе
|
||||||
* до 1,8–2,3 с при 0,5 CPU, хотя предел вроде бы должен был сжаться.
|
* среднего за окно — так скорость регулировки не зависит от того, насколько высок входящий RPS.
|
||||||
* Не чаще, чем раз в {@link #ADJUST_WINDOW_NANOS}, предел меняется одним
|
|
||||||
* шагом на основе среднего за окно — так скорость регулировки не зависит
|
|
||||||
* от того, насколько высок входящий RPS.
|
|
||||||
*
|
*
|
||||||
* <p>Рост — на единицу за окно (AIMD), не удвоением. Удвоение (slow start
|
* <p>Рост — на единицу за окно (AIMD), не удвоением. Удвоение (slow start из TCP) здесь не
|
||||||
* из TCP) здесь не подходит: там обратная связь — RTT, миллисекунды, и
|
* подходит: там обратная связь — RTT, миллисекунды, и лишний виток роста стоит дёшево. Здесь
|
||||||
* лишний виток роста стоит дёшево. Здесь обратная связь — время ответа
|
* обратная связь — время ответа заявки, и под перегрузкой оно само составляет секунды: предел
|
||||||
* заявки, и под перегрузкой оно само составляет секунды: предел успевает
|
* успевает удвоиться несколько раз (2→4→8→…→сотни) быстрее, чем придёт первый сигнал о деградации,
|
||||||
* удвоиться несколько раз (2→4→8→…→сотни) быстрее, чем придёт первый
|
* и уже принятые заявки не исчезают из очереди, даже если следующим окном предел тут же обрушить.
|
||||||
* сигнал о деградации, и уже принятые заявки не исчезают из очереди, даже
|
* Проверено нагрузочным тестом: с удвоением p95 на перегрузке всё равно доходил до 1,8–2,2 с.
|
||||||
* если следующим окном предел тут же обрушить. Проверено нагрузочным
|
* Линейный рост копит риск медленно, и первый плохой сигнал останавливает его на порядок раньше.
|
||||||
* тестом: с удвоением p95 на перегрузке всё равно доходил до 1,8–2,2 с.
|
* Сжатие — вдвое, а не на единицу: на перегрузке дешевле один раз отрезать с запасом, чем несколько
|
||||||
* Линейный рост копит риск медленно, и первый плохой сигнал останавливает
|
* окон подряд плавно подходить к безопасному уровню, пока заявки продолжают копиться.
|
||||||
* его на порядок раньше. Сжатие — вдвое, а не на единицу: на перегрузке
|
|
||||||
* дешевле один раз отрезать с запасом, чем несколько окон подряд плавно
|
|
||||||
* подходить к безопасному уровню, пока заявки продолжают копиться.
|
|
||||||
*
|
*
|
||||||
* <p>ponytail: счётчики окна суммируются без блокировки — гонка на границе
|
* <p>ponytail: счётчики окна суммируются без блокировки — гонка на границе окна может добавить
|
||||||
* окна может добавить образец в уже подводимый итог или отбросить один,
|
* образец в уже подводимый итог или отбросить один, не больше; при масштабах в десятки-сотни
|
||||||
* не больше; при масштабах в десятки-сотни образцов на окно это не видно.
|
* образцов на окно это не видно. Нужен точный регулятор — взять готовую библиотеку вроде Netflix
|
||||||
* Нужен точный регулятор — взять готовую библиотеку вроде Netflix
|
* {@code concurrency-limits} (Vegas/Gradient2); здесь она не взята из осторожности к GraalVM
|
||||||
* {@code concurrency-limits} (Vegas/Gradient2); здесь она не взята из
|
* native-image: незнакомая рефлексия в чужой библиотеке — это ровно тот класс проблем, из-за
|
||||||
* осторожности к GraalVM native-image: незнакомая рефлексия в чужой
|
* которого модели второй ступени понадобилась отдельная настройка сборки.
|
||||||
* библиотеке — это ровно тот класс проблем, из-за которого модели второй
|
|
||||||
* ступени понадобилась отдельная настройка сборки.
|
|
||||||
*/
|
*/
|
||||||
public final class AdaptiveConcurrencyLimiter {
|
public final class AdaptiveConcurrencyLimiter {
|
||||||
|
|
||||||
private static final long DEFAULT_ADJUST_WINDOW_NANOS = TimeUnit.MILLISECONDS.toNanos(20);
|
private static final long DEFAULT_ADJUST_WINDOW_NANOS = TimeUnit.MILLISECONDS.toNanos(20);
|
||||||
|
|
||||||
private final AtomicInteger inFlight = new AtomicInteger();
|
private final AtomicInteger inFlight = new AtomicInteger();
|
||||||
private final AtomicLong windowSumNanos = new AtomicLong();
|
private final AtomicLong windowSumNanos = new AtomicLong();
|
||||||
private final AtomicInteger windowSamples = new AtomicInteger();
|
private final AtomicInteger windowSamples = new AtomicInteger();
|
||||||
private final AtomicLong lastAdjustNanos;
|
private final AtomicLong lastAdjustNanos;
|
||||||
private final int minLimit;
|
private final int minLimit;
|
||||||
private final int maxLimit;
|
private final int maxLimit;
|
||||||
private final long targetLatencyNanos;
|
private final long targetLatencyNanos;
|
||||||
private final long adjustWindowNanos;
|
private final long adjustWindowNanos;
|
||||||
private final AtomicInteger limit;
|
private final AtomicInteger limit;
|
||||||
|
|
||||||
public AdaptiveConcurrencyLimiter(int minLimit, int maxLimit, long targetLatencyMillis) {
|
public AdaptiveConcurrencyLimiter(int minLimit, int maxLimit, long targetLatencyMillis) {
|
||||||
this(minLimit, maxLimit, targetLatencyMillis, DEFAULT_ADJUST_WINDOW_NANOS);
|
this(minLimit, maxLimit, targetLatencyMillis, DEFAULT_ADJUST_WINDOW_NANOS);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Настраиваемое окно регулировки — для тестов, которым реальные 20мс на шаг не подходят. */
|
||||||
|
AdaptiveConcurrencyLimiter(
|
||||||
|
int minLimit, int maxLimit, long targetLatencyMillis, long adjustWindowNanos) {
|
||||||
|
if (minLimit < 1 || maxLimit < minLimit) {
|
||||||
|
throw new IllegalArgumentException(
|
||||||
|
"Некорректные границы предела: " + minLimit + ".." + maxLimit);
|
||||||
}
|
}
|
||||||
|
this.minLimit = minLimit;
|
||||||
|
this.maxLimit = maxLimit;
|
||||||
|
this.targetLatencyNanos = TimeUnit.MILLISECONDS.toNanos(targetLatencyMillis);
|
||||||
|
this.adjustWindowNanos = adjustWindowNanos;
|
||||||
|
this.limit = new AtomicInteger(minLimit);
|
||||||
|
this.lastAdjustNanos = new AtomicLong(System.nanoTime());
|
||||||
|
}
|
||||||
|
|
||||||
/** Настраиваемое окно регулировки — для тестов, которым реальные 20мс на шаг не подходят. */
|
/** {@code true} — запрос принят; вызывающая сторона обязана вызвать {@link #release}. */
|
||||||
AdaptiveConcurrencyLimiter(int minLimit, int maxLimit, long targetLatencyMillis, long adjustWindowNanos) {
|
public boolean tryAcquire() {
|
||||||
if (minLimit < 1 || maxLimit < minLimit) {
|
if (inFlight.incrementAndGet() > limit.get()) {
|
||||||
throw new IllegalArgumentException("Некорректные границы предела: " + minLimit + ".." + maxLimit);
|
inFlight.decrementAndGet();
|
||||||
}
|
return false;
|
||||||
this.minLimit = minLimit;
|
|
||||||
this.maxLimit = maxLimit;
|
|
||||||
this.targetLatencyNanos = TimeUnit.MILLISECONDS.toNanos(targetLatencyMillis);
|
|
||||||
this.adjustWindowNanos = adjustWindowNanos;
|
|
||||||
this.limit = new AtomicInteger(minLimit);
|
|
||||||
this.lastAdjustNanos = new AtomicLong(System.nanoTime());
|
|
||||||
}
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
/** {@code true} — запрос принят; вызывающая сторона обязана вызвать {@link #release}. */
|
/** Освобождает слот; предел подстраивается не чаще раза в окно, а не на каждый вызов. */
|
||||||
public boolean tryAcquire() {
|
public void release(long elapsedNanos) {
|
||||||
if (inFlight.incrementAndGet() > limit.get()) {
|
inFlight.decrementAndGet();
|
||||||
inFlight.decrementAndGet();
|
windowSumNanos.addAndGet(elapsedNanos);
|
||||||
return false;
|
windowSamples.incrementAndGet();
|
||||||
}
|
|
||||||
return true;
|
long now = System.nanoTime();
|
||||||
|
long last = lastAdjustNanos.get();
|
||||||
|
if (now - last >= adjustWindowNanos && lastAdjustNanos.compareAndSet(last, now)) {
|
||||||
|
adjust();
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/** Освобождает слот; предел подстраивается не чаще раза в окно, а не на каждый вызов. */
|
private void adjust() {
|
||||||
public void release(long elapsedNanos) {
|
int samples = windowSamples.getAndSet(0);
|
||||||
inFlight.decrementAndGet();
|
long sum = windowSumNanos.getAndSet(0);
|
||||||
windowSumNanos.addAndGet(elapsedNanos);
|
if (samples == 0) {
|
||||||
windowSamples.incrementAndGet();
|
return;
|
||||||
|
|
||||||
long now = System.nanoTime();
|
|
||||||
long last = lastAdjustNanos.get();
|
|
||||||
if (now - last >= adjustWindowNanos && lastAdjustNanos.compareAndSet(last, now)) {
|
|
||||||
adjust();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
long avg = sum / samples;
|
||||||
|
|
||||||
private void adjust() {
|
if (avg < targetLatencyNanos) {
|
||||||
int samples = windowSamples.getAndSet(0);
|
limit.set(Math.min(maxLimit, limit.get() + 1));
|
||||||
long sum = windowSumNanos.getAndSet(0);
|
} else {
|
||||||
if (samples == 0) {
|
limit.set(Math.max(minLimit, limit.get() / 2));
|
||||||
return;
|
|
||||||
}
|
|
||||||
long avg = sum / samples;
|
|
||||||
|
|
||||||
if (avg < targetLatencyNanos) {
|
|
||||||
limit.set(Math.min(maxLimit, limit.get() + 1));
|
|
||||||
} else {
|
|
||||||
limit.set(Math.max(minLimit, limit.get() / 2));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/** Сколько запросов обрабатывается прямо сейчас — для наблюдения. */
|
/** Сколько запросов обрабатывается прямо сейчас — для наблюдения. */
|
||||||
public int inFlight() {
|
public int inFlight() {
|
||||||
return inFlight.get();
|
return inFlight.get();
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Текущий предел — для метрики, чтобы деградацию было видно, а не только чувствовать по 429. */
|
/** Текущий предел — для метрики, чтобы деградацию было видно, а не только чувствовать по 429. */
|
||||||
public int limit() {
|
public int limit() {
|
||||||
return limit.get();
|
return limit.get();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,11 +2,6 @@ package ru.pdguard.core;
|
|||||||
|
|
||||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
import com.fasterxml.jackson.databind.node.ObjectNode;
|
import com.fasterxml.jackson.databind.node.ObjectNode;
|
||||||
import org.slf4j.Logger;
|
|
||||||
import org.slf4j.LoggerFactory;
|
|
||||||
import org.springframework.beans.factory.annotation.Value;
|
|
||||||
import org.springframework.stereotype.Component;
|
|
||||||
|
|
||||||
import java.io.IOException;
|
import java.io.IOException;
|
||||||
import java.net.URI;
|
import java.net.URI;
|
||||||
import java.net.http.HttpClient;
|
import java.net.http.HttpClient;
|
||||||
@@ -15,93 +10,102 @@ import java.net.http.HttpResponse;
|
|||||||
import java.nio.charset.StandardCharsets;
|
import java.nio.charset.StandardCharsets;
|
||||||
import java.time.Duration;
|
import java.time.Duration;
|
||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Обращение к языковой модели для демонстрационного плеча.
|
* Обращение к языковой модели для демонстрационного плеча.
|
||||||
*
|
*
|
||||||
* <p>Адрес не задан — работает заглушка: она возвращает присланный текст обратно.
|
* <p>Адрес не задан — работает заглушка: она возвращает присланный текст обратно. Для демонстрации
|
||||||
* Для демонстрации этого достаточно, потому что проверяется не качество ответа
|
* этого достаточно, потому что проверяется не качество ответа модели, а то, что в модель ушёл
|
||||||
* модели, а то, что в модель ушёл замаскированный текст, а потребителю вернулся
|
* замаскированный текст, а потребителю вернулся восстановленный.
|
||||||
* восстановленный.
|
|
||||||
*
|
*
|
||||||
* <p>Модель недоступна или ответила ошибкой — плечо деградирует до той же заглушки,
|
* <p>Модель недоступна или ответила ошибкой — плечо деградирует до той же заглушки, а причина
|
||||||
* а причина попадает в ответ и в журнал. Ронять запрос из-за внешнего сервиса нельзя.
|
* попадает в ответ и в журнал. Ронять запрос из-за внешнего сервиса нельзя.
|
||||||
*/
|
*/
|
||||||
@Component
|
@Component
|
||||||
public class LlmClient {
|
public class LlmClient {
|
||||||
|
|
||||||
private static final Logger LOG = LoggerFactory.getLogger(LlmClient.class);
|
private static final Logger LOG = LoggerFactory.getLogger(LlmClient.class);
|
||||||
|
|
||||||
/** Что вернула модель и кто именно ответил. */
|
/** Что вернула модель и кто именно ответил. */
|
||||||
public record Answer(String text, String source) {
|
public record Answer(String text, String source) {}
|
||||||
|
|
||||||
|
private final Optional<String> url;
|
||||||
|
private final Optional<String> apiKey;
|
||||||
|
private final String model;
|
||||||
|
private final Duration timeout;
|
||||||
|
private final HttpClient http;
|
||||||
|
private final ObjectMapper mapper = new ObjectMapper();
|
||||||
|
|
||||||
|
public LlmClient(
|
||||||
|
@Value("${pdguard.llm.url:}") String url,
|
||||||
|
@Value("${pdguard.llm.api-key:}") String apiKey,
|
||||||
|
@Value("${pdguard.llm.model:gpt-4o-mini}") String model,
|
||||||
|
@Value("${pdguard.llm.timeout-seconds:20}") int timeoutSeconds) {
|
||||||
|
this.url = Optional.ofNullable(url).filter(value -> !value.isBlank());
|
||||||
|
this.apiKey = Optional.ofNullable(apiKey).filter(value -> !value.isBlank());
|
||||||
|
this.model = model;
|
||||||
|
this.timeout = Duration.ofSeconds(timeoutSeconds);
|
||||||
|
this.http = HttpClient.newBuilder().connectTimeout(Duration.ofSeconds(5)).build();
|
||||||
|
if (this.url.isEmpty()) {
|
||||||
|
LOG.info("Адрес языковой модели не задан, плечо работает на заглушке");
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private final Optional<String> url;
|
public Answer ask(String maskedPrompt) {
|
||||||
private final Optional<String> apiKey;
|
if (url.isEmpty()) {
|
||||||
private final String model;
|
return new Answer(stub(maskedPrompt), "заглушка");
|
||||||
private final Duration timeout;
|
|
||||||
private final HttpClient http;
|
|
||||||
private final ObjectMapper mapper = new ObjectMapper();
|
|
||||||
|
|
||||||
public LlmClient(
|
|
||||||
@Value("${pdguard.llm.url:}") String url,
|
|
||||||
@Value("${pdguard.llm.api-key:}") String apiKey,
|
|
||||||
@Value("${pdguard.llm.model:gpt-4o-mini}") String model,
|
|
||||||
@Value("${pdguard.llm.timeout-seconds:20}") int timeoutSeconds) {
|
|
||||||
this.url = Optional.ofNullable(url).filter(value -> !value.isBlank());
|
|
||||||
this.apiKey = Optional.ofNullable(apiKey).filter(value -> !value.isBlank());
|
|
||||||
this.model = model;
|
|
||||||
this.timeout = Duration.ofSeconds(timeoutSeconds);
|
|
||||||
this.http = HttpClient.newBuilder().connectTimeout(Duration.ofSeconds(5)).build();
|
|
||||||
if (this.url.isEmpty()) {
|
|
||||||
LOG.info("Адрес языковой модели не задан, плечо работает на заглушке");
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
try {
|
||||||
public Answer ask(String maskedPrompt) {
|
return new Answer(call(maskedPrompt), model);
|
||||||
if (url.isEmpty()) {
|
} catch (InterruptedException e) {
|
||||||
return new Answer(stub(maskedPrompt), "заглушка");
|
Thread.currentThread().interrupt();
|
||||||
}
|
LOG.error("Обращение к языковой модели прервано, плечо ответило заглушкой", e);
|
||||||
try {
|
return new Answer(stub(maskedPrompt), "заглушка: модель недоступна");
|
||||||
return new Answer(call(maskedPrompt), model);
|
} catch (IOException e) {
|
||||||
} catch (InterruptedException e) {
|
LOG.error("Языковая модель недоступна, плечо ответило заглушкой", e);
|
||||||
Thread.currentThread().interrupt();
|
return new Answer(stub(maskedPrompt), "заглушка: модель недоступна");
|
||||||
LOG.error("Обращение к языковой модели прервано, плечо ответило заглушкой", e);
|
|
||||||
return new Answer(stub(maskedPrompt), "заглушка: модель недоступна");
|
|
||||||
} catch (IOException e) {
|
|
||||||
LOG.error("Языковая модель недоступна, плечо ответило заглушкой", e);
|
|
||||||
return new Answer(stub(maskedPrompt), "заглушка: модель недоступна");
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Ответ содержит присланный текст целиком: так на демонстрации видно, что
|
* Ответ содержит присланный текст целиком: так на демонстрации видно, что подстановки вернулись
|
||||||
* подстановки вернулись на свои места при обратном преобразовании.
|
* на свои места при обратном преобразовании.
|
||||||
*/
|
*/
|
||||||
private static String stub(String maskedPrompt) {
|
private static String stub(String maskedPrompt) {
|
||||||
return "Ответ по запросу: " + maskedPrompt;
|
return "Ответ по запросу: " + maskedPrompt;
|
||||||
|
}
|
||||||
|
|
||||||
|
private String call(String maskedPrompt) throws IOException, InterruptedException {
|
||||||
|
ObjectNode body = mapper.createObjectNode();
|
||||||
|
body.put("model", model);
|
||||||
|
ObjectNode message = body.putArray("messages").addObject();
|
||||||
|
message.put("role", "user");
|
||||||
|
message.put("content", maskedPrompt);
|
||||||
|
|
||||||
|
HttpRequest.Builder request =
|
||||||
|
HttpRequest.newBuilder(URI.create(url.get()))
|
||||||
|
.timeout(timeout)
|
||||||
|
.header("Content-Type", "application/json")
|
||||||
|
.POST(
|
||||||
|
HttpRequest.BodyPublishers.ofString(
|
||||||
|
mapper.writeValueAsString(body), StandardCharsets.UTF_8));
|
||||||
|
apiKey.ifPresent(key -> request.header("Authorization", "Bearer " + key));
|
||||||
|
|
||||||
|
HttpResponse<String> response =
|
||||||
|
http.send(request.build(), HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8));
|
||||||
|
if (response.statusCode() / 100 != 2) {
|
||||||
|
throw new IllegalStateException("модель ответила " + response.statusCode());
|
||||||
}
|
}
|
||||||
|
return mapper
|
||||||
private String call(String maskedPrompt) throws IOException, InterruptedException {
|
.readTree(response.body())
|
||||||
ObjectNode body = mapper.createObjectNode();
|
.path("choices")
|
||||||
body.put("model", model);
|
.path(0)
|
||||||
ObjectNode message = body.putArray("messages").addObject();
|
.path("message")
|
||||||
message.put("role", "user");
|
.path("content")
|
||||||
message.put("content", maskedPrompt);
|
.asText();
|
||||||
|
}
|
||||||
HttpRequest.Builder request = HttpRequest.newBuilder(URI.create(url.get()))
|
}
|
||||||
.timeout(timeout)
|
|
||||||
.header("Content-Type", "application/json")
|
|
||||||
.POST(HttpRequest.BodyPublishers.ofString(
|
|
||||||
mapper.writeValueAsString(body), StandardCharsets.UTF_8));
|
|
||||||
apiKey.ifPresent(key -> request.header("Authorization", "Bearer " + key));
|
|
||||||
|
|
||||||
HttpResponse<String> response = http.send(request.build(),
|
|
||||||
HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8));
|
|
||||||
if (response.statusCode() / 100 != 2) {
|
|
||||||
throw new IllegalStateException("модель ответила " + response.statusCode());
|
|
||||||
}
|
|
||||||
return mapper.readTree(response.body())
|
|
||||||
.path("choices").path(0).path("message").path("content").asText();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -3,63 +3,62 @@ package ru.pdguard.core;
|
|||||||
import io.micrometer.core.instrument.Meter;
|
import io.micrometer.core.instrument.Meter;
|
||||||
import io.micrometer.core.instrument.config.MeterFilter;
|
import io.micrometer.core.instrument.config.MeterFilter;
|
||||||
import io.micrometer.core.instrument.distribution.DistributionStatisticConfig;
|
import io.micrometer.core.instrument.distribution.DistributionStatisticConfig;
|
||||||
|
import java.time.Duration;
|
||||||
import org.springframework.context.annotation.Bean;
|
import org.springframework.context.annotation.Bean;
|
||||||
import org.springframework.context.annotation.Configuration;
|
import org.springframework.context.annotation.Configuration;
|
||||||
|
|
||||||
import java.time.Duration;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Настройка распределений для метрик времени.
|
* Настройка распределений для метрик времени.
|
||||||
*
|
*
|
||||||
* <p>По умолчанию Micrometer отдаёт по таймеру только сумму, количество и максимум.
|
* <p>По умолчанию Micrometer отдаёт по таймеру только сумму, количество и максимум. Этого хватает
|
||||||
* Этого хватает на среднее, но не на перцентили, а именно они описывают SLA: важно
|
* на среднее, но не на перцентили, а именно они описывают SLA: важно не среднее время ответа, а то,
|
||||||
* не среднее время ответа, а то, сколько запросов уложилось в срок. Гистограмма
|
* сколько запросов уложилось в срок. Гистограмма добавляет ряды по корзинам, и {@code
|
||||||
* добавляет ряды по корзинам, и {@code histogram_quantile} в Prometheus считает по
|
* histogram_quantile} в Prometheus считает по ним p50, p95 и p99.
|
||||||
* ним p50, p95 и p99.
|
|
||||||
*
|
*
|
||||||
* <p>Границы корзин заданы явно и подобраны под наши задержки: от четверти
|
* <p>Границы корзин заданы явно и подобраны под наши задержки: от четверти миллисекунды до десяти
|
||||||
* миллисекунды до десяти секунд. Без явных границ Micrometer создаёт их сам и
|
* секунд. Без явных границ Micrometer создаёт их сам и заметно больше, а каждая корзина — это
|
||||||
* заметно больше, а каждая корзина — это отдельный временной ряд на каждое
|
* отдельный временной ряд на каждое сочетание меток.
|
||||||
* сочетание меток.
|
|
||||||
*/
|
*/
|
||||||
@Configuration
|
@Configuration
|
||||||
public class MetricsConfiguration {
|
public class MetricsConfiguration {
|
||||||
|
|
||||||
/** Целевая задержка из критериев оценки: ориентир, а не жёсткий предел. */
|
/** Целевая задержка из критериев оценки: ориентир, а не жёсткий предел. */
|
||||||
private static final Duration SLA_TARGET = Duration.ofMillis(500);
|
private static final Duration SLA_TARGET = Duration.ofMillis(500);
|
||||||
|
|
||||||
private static final Duration[] BOUNDARIES = {
|
private static final Duration[] BOUNDARIES = {
|
||||||
Duration.ofNanos(250_000), Duration.ofMillis(1), Duration.ofMillis(5),
|
Duration.ofNanos(250_000), Duration.ofMillis(1), Duration.ofMillis(5),
|
||||||
Duration.ofMillis(10), Duration.ofMillis(25), Duration.ofMillis(50),
|
Duration.ofMillis(10), Duration.ofMillis(25), Duration.ofMillis(50),
|
||||||
Duration.ofMillis(100), Duration.ofMillis(250), SLA_TARGET,
|
Duration.ofMillis(100), Duration.ofMillis(250), SLA_TARGET,
|
||||||
Duration.ofSeconds(1), Duration.ofSeconds(2), Duration.ofSeconds(5),
|
Duration.ofSeconds(1), Duration.ofSeconds(2), Duration.ofSeconds(5),
|
||||||
Duration.ofSeconds(10)
|
Duration.ofSeconds(10)
|
||||||
|
};
|
||||||
|
|
||||||
|
@Bean
|
||||||
|
public MeterFilter histogramsForTimers() {
|
||||||
|
return new MeterFilter() {
|
||||||
|
@Override
|
||||||
|
public DistributionStatisticConfig configure(
|
||||||
|
Meter.Id id, DistributionStatisticConfig config) {
|
||||||
|
if (!needsHistogram(id.getName())) {
|
||||||
|
return config;
|
||||||
|
}
|
||||||
|
double[] boundaries = new double[BOUNDARIES.length];
|
||||||
|
for (int i = 0; i < BOUNDARIES.length; i++) {
|
||||||
|
boundaries[i] = BOUNDARIES[i].toNanos();
|
||||||
|
}
|
||||||
|
return DistributionStatisticConfig.builder()
|
||||||
|
.percentilesHistogram(false)
|
||||||
|
.serviceLevelObjectives(boundaries)
|
||||||
|
.build()
|
||||||
|
.merge(config);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
}
|
||||||
|
|
||||||
@Bean
|
private static boolean needsHistogram(String name) {
|
||||||
public MeterFilter histogramsForTimers() {
|
return "pdguard.process".equals(name)
|
||||||
return new MeterFilter() {
|
|| "pdguard.ner.duration".equals(name)
|
||||||
@Override
|
|| "pdguard.ner.model.duration".equals(name)
|
||||||
public DistributionStatisticConfig configure(Meter.Id id, DistributionStatisticConfig config) {
|
|| "http.server.requests".equals(name);
|
||||||
if (!needsHistogram(id.getName())) {
|
}
|
||||||
return config;
|
}
|
||||||
}
|
|
||||||
double[] boundaries = new double[BOUNDARIES.length];
|
|
||||||
for (int i = 0; i < BOUNDARIES.length; i++) {
|
|
||||||
boundaries[i] = BOUNDARIES[i].toNanos();
|
|
||||||
}
|
|
||||||
return DistributionStatisticConfig.builder()
|
|
||||||
.percentilesHistogram(false)
|
|
||||||
.serviceLevelObjectives(boundaries)
|
|
||||||
.build()
|
|
||||||
.merge(config);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
private static boolean needsHistogram(String name) {
|
|
||||||
return "pdguard.process".equals(name)
|
|
||||||
|| "pdguard.ner.duration".equals(name)
|
|
||||||
|| "http.server.requests".equals(name);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,88 +1,89 @@
|
|||||||
package ru.pdguard.core;
|
package ru.pdguard.core;
|
||||||
|
|
||||||
import org.springframework.beans.factory.annotation.Value;
|
|
||||||
import org.springframework.stereotype.Component;
|
|
||||||
|
|
||||||
import javax.crypto.Cipher;
|
|
||||||
import javax.crypto.spec.GCMParameterSpec;
|
|
||||||
import javax.crypto.spec.SecretKeySpec;
|
|
||||||
import java.nio.charset.StandardCharsets;
|
import java.nio.charset.StandardCharsets;
|
||||||
import java.security.GeneralSecurityException;
|
import java.security.GeneralSecurityException;
|
||||||
import java.security.SecureRandom;
|
import java.security.SecureRandom;
|
||||||
import java.util.Base64;
|
import java.util.Base64;
|
||||||
import java.util.HexFormat;
|
import java.util.HexFormat;
|
||||||
|
import javax.crypto.Cipher;
|
||||||
|
import javax.crypto.spec.GCMParameterSpec;
|
||||||
|
import javax.crypto.spec.SecretKeySpec;
|
||||||
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Шифрование исходных персональных данных в хранилище.
|
* Шифрование исходных персональных данных в хранилище.
|
||||||
*
|
*
|
||||||
* <p>ПДН не должны лежать в памяти и в общем слое в открытом виде: даже если
|
* <p>ПДН не должны лежать в памяти и в общем слое в открытом виде: даже если процесс или Redis
|
||||||
* процесс или Redis скомпрометированы, исходные значения остаются недоступными
|
* скомпрометированы, исходные значения остаются недоступными без ключа. Используется AES-GCM —
|
||||||
* без ключа. Используется AES-GCM — аутентифицированное шифрование, которое
|
* аутентифицированное шифрование, которое защищает и от подмены шифротекста.
|
||||||
* защищает и от подмены шифротекста.
|
|
||||||
*
|
*
|
||||||
* <p>Ключ задаётся настройкой {@code pdguard.store.encryption-key} (32 байта в
|
* <p>Ключ задаётся настройкой {@code pdguard.store.encryption-key} (32 байта в hex). Пока ключ не
|
||||||
* hex). Пока ключ не задан, шифрование выключено — это нужно для тестов и для
|
* задан, шифрование выключено — это нужно для тестов и для сборки, где хранилище не содержит
|
||||||
* сборки, где хранилище не содержит чувствительных данных.
|
* чувствительных данных.
|
||||||
*/
|
*/
|
||||||
@Component
|
@Component
|
||||||
public class PayloadCipher {
|
public class PayloadCipher {
|
||||||
|
|
||||||
private static final String ALGORITHM = "AES";
|
private static final String ALGORITHM = "AES";
|
||||||
private static final String TRANSFORMATION = "AES/GCM/NoPadding";
|
private static final String TRANSFORMATION = "AES/GCM/NoPadding";
|
||||||
private static final int GCM_TAG_BITS = 128;
|
private static final int GCM_TAG_BITS = 128;
|
||||||
private static final int IV_BYTES = 12;
|
private static final int IV_BYTES = 12;
|
||||||
|
|
||||||
private final SecretKeySpec key;
|
private final SecretKeySpec key;
|
||||||
private final SecureRandom random = new SecureRandom();
|
private final SecureRandom random = new SecureRandom();
|
||||||
|
|
||||||
public PayloadCipher(@Value("${pdguard.store.encryption-key:}") String hexKey) {
|
public PayloadCipher(@Value("${pdguard.store.encryption-key:}") String hexKey) {
|
||||||
this.key = hexKey == null || hexKey.isBlank() ? null : new SecretKeySpec(HexFormat.of().parseHex(hexKey), ALGORITHM);
|
this.key =
|
||||||
|
hexKey == null || hexKey.isBlank()
|
||||||
|
? null
|
||||||
|
: new SecretKeySpec(HexFormat.of().parseHex(hexKey), ALGORITHM);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Выключенное шифрование — для тестов и сборки без ключа. */
|
||||||
|
public static PayloadCipher disabled() {
|
||||||
|
return new PayloadCipher("");
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean enabled() {
|
||||||
|
return key != null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Шифрует текст; при выключенном шифровании возвращает исходный текст. */
|
||||||
|
public String encrypt(String plaintext) {
|
||||||
|
if (key == null) {
|
||||||
|
return plaintext;
|
||||||
}
|
}
|
||||||
|
try {
|
||||||
/** Выключенное шифрование — для тестов и сборки без ключа. */
|
byte[] iv = new byte[IV_BYTES];
|
||||||
public static PayloadCipher disabled() {
|
random.nextBytes(iv);
|
||||||
return new PayloadCipher("");
|
Cipher cipher = Cipher.getInstance(TRANSFORMATION);
|
||||||
|
cipher.init(Cipher.ENCRYPT_MODE, key, new GCMParameterSpec(GCM_TAG_BITS, iv));
|
||||||
|
byte[] encrypted = cipher.doFinal(plaintext.getBytes(StandardCharsets.UTF_8));
|
||||||
|
byte[] combined = new byte[iv.length + encrypted.length];
|
||||||
|
System.arraycopy(iv, 0, combined, 0, iv.length);
|
||||||
|
System.arraycopy(encrypted, 0, combined, iv.length, encrypted.length);
|
||||||
|
return Base64.getEncoder().encodeToString(combined);
|
||||||
|
} catch (GeneralSecurityException e) {
|
||||||
|
throw new IllegalStateException("Не удалось зашифровать персональные данные", e);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public boolean enabled() {
|
/** Дешифрует текст; при выключенном шифровании возвращает исходный текст. */
|
||||||
return key != null;
|
public String decrypt(String ciphertext) {
|
||||||
|
if (key == null) {
|
||||||
|
return ciphertext;
|
||||||
}
|
}
|
||||||
|
try {
|
||||||
/** Шифрует текст; при выключенном шифровании возвращает исходный текст. */
|
byte[] combined = Base64.getDecoder().decode(ciphertext);
|
||||||
public String encrypt(String plaintext) {
|
byte[] iv = new byte[IV_BYTES];
|
||||||
if (key == null) {
|
System.arraycopy(combined, 0, iv, 0, iv.length);
|
||||||
return plaintext;
|
Cipher cipher = Cipher.getInstance(TRANSFORMATION);
|
||||||
}
|
cipher.init(Cipher.DECRYPT_MODE, key, new GCMParameterSpec(GCM_TAG_BITS, iv));
|
||||||
try {
|
byte[] decrypted = cipher.doFinal(combined, iv.length, combined.length - iv.length);
|
||||||
byte[] iv = new byte[IV_BYTES];
|
return new String(decrypted, StandardCharsets.UTF_8);
|
||||||
random.nextBytes(iv);
|
} catch (GeneralSecurityException | IllegalArgumentException e) {
|
||||||
Cipher cipher = Cipher.getInstance(TRANSFORMATION);
|
throw new IllegalStateException("Не удалось расшифровать персональные данные", e);
|
||||||
cipher.init(Cipher.ENCRYPT_MODE, key, new GCMParameterSpec(GCM_TAG_BITS, iv));
|
|
||||||
byte[] encrypted = cipher.doFinal(plaintext.getBytes(StandardCharsets.UTF_8));
|
|
||||||
byte[] combined = new byte[iv.length + encrypted.length];
|
|
||||||
System.arraycopy(iv, 0, combined, 0, iv.length);
|
|
||||||
System.arraycopy(encrypted, 0, combined, iv.length, encrypted.length);
|
|
||||||
return Base64.getEncoder().encodeToString(combined);
|
|
||||||
} catch (GeneralSecurityException e) {
|
|
||||||
throw new IllegalStateException("Не удалось зашифровать персональные данные", e);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
/** Дешифрует текст; при выключенном шифровании возвращает исходный текст. */
|
}
|
||||||
public String decrypt(String ciphertext) {
|
|
||||||
if (key == null) {
|
|
||||||
return ciphertext;
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
byte[] combined = Base64.getDecoder().decode(ciphertext);
|
|
||||||
byte[] iv = new byte[IV_BYTES];
|
|
||||||
System.arraycopy(combined, 0, iv, 0, iv.length);
|
|
||||||
Cipher cipher = Cipher.getInstance(TRANSFORMATION);
|
|
||||||
cipher.init(Cipher.DECRYPT_MODE, key, new GCMParameterSpec(GCM_TAG_BITS, iv));
|
|
||||||
byte[] decrypted = cipher.doFinal(combined, iv.length, combined.length - iv.length);
|
|
||||||
return new String(decrypted, StandardCharsets.UTF_8);
|
|
||||||
} catch (GeneralSecurityException | IllegalArgumentException e) {
|
|
||||||
throw new IllegalStateException("Не удалось расшифровать персональные данные", e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,9 +1,5 @@
|
|||||||
package ru.pdguard.core;
|
package ru.pdguard.core;
|
||||||
|
|
||||||
import org.springframework.beans.factory.annotation.Autowired;
|
|
||||||
import org.springframework.beans.factory.annotation.Value;
|
|
||||||
import org.springframework.stereotype.Component;
|
|
||||||
|
|
||||||
import java.nio.charset.StandardCharsets;
|
import java.nio.charset.StandardCharsets;
|
||||||
import java.security.MessageDigest;
|
import java.security.MessageDigest;
|
||||||
import java.security.NoSuchAlgorithmException;
|
import java.security.NoSuchAlgorithmException;
|
||||||
@@ -12,191 +8,173 @@ import java.util.Map;
|
|||||||
import java.util.concurrent.ConcurrentHashMap;
|
import java.util.concurrent.ConcurrentHashMap;
|
||||||
import java.util.concurrent.ConcurrentLinkedQueue;
|
import java.util.concurrent.ConcurrentLinkedQueue;
|
||||||
import java.util.concurrent.atomic.AtomicLong;
|
import java.util.concurrent.atomic.AtomicLong;
|
||||||
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Соответствие «исходный текст ↔ маска», по которому выполняется демаскирование.
|
* Соответствие «исходный текст ↔ маска», по которому выполняется демаскирование.
|
||||||
*
|
*
|
||||||
* <p>Два индекса: по {@code payload_id} — основной путь, и по отпечатку маски —
|
* <p>Два индекса: по {@code payload_id} — основной путь, и по отпечатку маски — страховка на
|
||||||
* страховка на случай, если идентификатор до сервиса не доехал.
|
* случай, если идентификатор до сервиса не доехал.
|
||||||
*
|
*
|
||||||
* <p>Оба индекса разделены по системам-потребителям. Индекс по отпечатку ищет
|
* <p>Оба индекса разделены по системам-потребителям. Индекс по отпечатку ищет совпадение по самому
|
||||||
* совпадение по самому тексту запроса, и без такого разделения он превращался бы в
|
* тексту запроса, и без такого разделения он превращался бы в способ достать чужие данные: маски
|
||||||
* способ достать чужие данные: маски детерминированы и низкоэнтропийны, поэтому,
|
* детерминированы и низкоэнтропийны, поэтому, прислав «Клиент И. И. И., паспорт 45** ****56», можно
|
||||||
* прислав «Клиент И. И. И., паспорт 45** ****56», можно было бы получить в ответ
|
* было бы получить в ответ исходные значения из запроса другого потребителя. Разделение
|
||||||
* исходные значения из запроса другого потребителя. Разделение ограничивает это
|
* ограничивает это пределами одной системы, которая и так видит свои данные.
|
||||||
* пределами одной системы, которая и так видит свои данные.
|
|
||||||
*
|
*
|
||||||
* <p>Хранилище ограничено по суммарному объёму строк, а записи живут ограниченное
|
* <p>Записи живут ограниченное время: персональные данные не должны залёживаться в памяти.
|
||||||
* время: персональные данные не должны залёживаться в памяти, а крупные тексты не
|
* Протухшие записи убираются в порядке добавления прямо на записи — отдельного потока и внешней
|
||||||
* должны исчерпать кучу. Вытеснение идёт в порядке добавления и выполняется прямо
|
* библиотеки кеширования не требуется.
|
||||||
* на записи — отдельного потока и внешней библиотеки кеширования не требуется.
|
|
||||||
*
|
*
|
||||||
* <p>Когда включён общий слой ({@link SharedIndex}), соответствие пишется ещё и туда,
|
* <p>Когда включён общий слой ({@link SharedIndex}), соответствие пишется ещё и туда, а чтение при
|
||||||
* а чтение при промахе по локальной памяти идёт в него. Это нужно при работе на
|
* промахе по локальной памяти идёт в него. Это нужно при работе на нескольких узлах: обратный
|
||||||
* нескольких узлах: обратный запрос легко попадает не на тот узел, который выполнял
|
* запрос легко попадает не на тот узел, который выполнял прямой. Локальная память при этом остаётся
|
||||||
* прямой. Локальная память при этом остаётся первым уровнем, и обычный путь
|
* первым уровнем, и обычный путь обходится без обращения по сети.
|
||||||
* обходится без обращения по сети.
|
|
||||||
*/
|
*/
|
||||||
@Component
|
@Component
|
||||||
public class PayloadStore {
|
public class PayloadStore {
|
||||||
|
|
||||||
/** Сколько протухших записей просматривается за одну операцию записи. */
|
/** Сколько протухших записей просматривается за одну операцию записи. */
|
||||||
private static final int SWEEP_PER_PUT = 4;
|
private static final int SWEEP_PER_PUT = 4;
|
||||||
|
|
||||||
/** Пара «исходный текст — маска» с отпечатком, владельцем и сроком жизни. */
|
/** Пара «исходный текст — маска» с отпечатком, владельцем и сроком жизни. */
|
||||||
public record Entry(String system, String original, String masked,
|
public record Entry(
|
||||||
String fingerprint, long expiresAt) {
|
String system, String original, String masked, String fingerprint, long expiresAt) {
|
||||||
|
|
||||||
boolean alive(long now) {
|
boolean alive(long now) {
|
||||||
return now < expiresAt;
|
return now < expiresAt;
|
||||||
}
|
|
||||||
|
|
||||||
int weight() {
|
|
||||||
return original.length() + masked.length();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private final Map<String, Entry> byId = new ConcurrentHashMap<>();
|
int weight() {
|
||||||
private final Map<String, Entry> byMaskFingerprint = new ConcurrentHashMap<>();
|
return original.length() + masked.length();
|
||||||
private final ConcurrentLinkedQueue<String> insertionOrder = new ConcurrentLinkedQueue<>();
|
|
||||||
private final AtomicLong charsHeld = new AtomicLong();
|
|
||||||
|
|
||||||
private final long maxChars;
|
|
||||||
private final long ttlMillis;
|
|
||||||
private final SharedIndex shared;
|
|
||||||
private final PayloadCipher cipher;
|
|
||||||
|
|
||||||
@Autowired
|
|
||||||
public PayloadStore(
|
|
||||||
@Value("${pdguard.store.max-chars:134217728}") long maxChars,
|
|
||||||
@Value("${pdguard.store.ttl-minutes:30}") int ttlMinutes,
|
|
||||||
SharedIndex shared,
|
|
||||||
PayloadCipher cipher) {
|
|
||||||
this.maxChars = maxChars;
|
|
||||||
this.ttlMillis = ttlMinutes * 60_000L;
|
|
||||||
this.shared = shared;
|
|
||||||
this.cipher = cipher;
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/** Конструктор для тестов: только локальная память, общий слой и шифрование выключены. */
|
private final Map<String, Entry> byId = new ConcurrentHashMap<>();
|
||||||
public PayloadStore(long maxChars, int ttlMinutes) {
|
private final Map<String, Entry> byMaskFingerprint = new ConcurrentHashMap<>();
|
||||||
this(maxChars, ttlMinutes, SharedIndex.disabled(), PayloadCipher.disabled());
|
private final ConcurrentLinkedQueue<String> insertionOrder = new ConcurrentLinkedQueue<>();
|
||||||
|
private final AtomicLong charsHeld = new AtomicLong();
|
||||||
|
|
||||||
|
private final long ttlMillis;
|
||||||
|
private final SharedIndex shared;
|
||||||
|
private final PayloadCipher cipher;
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
public PayloadStore(
|
||||||
|
@Value("${pdguard.store.ttl-minutes:30}") int ttlMinutes,
|
||||||
|
SharedIndex shared,
|
||||||
|
PayloadCipher cipher) {
|
||||||
|
this.ttlMillis = ttlMinutes * 60_000L;
|
||||||
|
this.shared = shared;
|
||||||
|
this.cipher = cipher;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Конструктор для тестов: только локальная память, общий слой и шифрование выключены. */
|
||||||
|
public PayloadStore(int ttlMinutes) {
|
||||||
|
this(ttlMinutes, SharedIndex.disabled(), PayloadCipher.disabled());
|
||||||
|
}
|
||||||
|
|
||||||
|
public void put(String system, String payloadId, String original, String masked) {
|
||||||
|
long now = System.currentTimeMillis();
|
||||||
|
String encrypted = cipher.encrypt(original);
|
||||||
|
Entry entry = new Entry(system, encrypted, masked, fingerprint(masked), now + ttlMillis);
|
||||||
|
String idKey = ScopedKey.of(system, payloadId);
|
||||||
|
|
||||||
|
Entry replaced = byId.put(idKey, entry);
|
||||||
|
byMaskFingerprint.put(ScopedKey.of(system, entry.fingerprint()), entry);
|
||||||
|
insertionOrder.add(idKey);
|
||||||
|
charsHeld.addAndGet((long) entry.weight() - (replaced == null ? 0 : replaced.weight()));
|
||||||
|
|
||||||
|
sweepExpired(now);
|
||||||
|
|
||||||
|
shared.put(system, payloadId, encrypted, masked, entry.fingerprint());
|
||||||
|
}
|
||||||
|
|
||||||
|
public Entry byId(String system, String payloadId) {
|
||||||
|
String idKey = ScopedKey.of(system, payloadId);
|
||||||
|
Entry entry = byId.get(idKey);
|
||||||
|
if (entry != null && entry.alive(System.currentTimeMillis())) {
|
||||||
|
return decrypt(entry);
|
||||||
}
|
}
|
||||||
|
if (entry != null) {
|
||||||
public void put(String system, String payloadId, String original, String masked) {
|
forget(idKey, entry);
|
||||||
long now = System.currentTimeMillis();
|
|
||||||
String encrypted = cipher.encrypt(original);
|
|
||||||
Entry entry = new Entry(system, encrypted, masked, fingerprint(masked), now + ttlMillis);
|
|
||||||
String idKey = ScopedKey.of(system, payloadId);
|
|
||||||
|
|
||||||
Entry replaced = byId.put(idKey, entry);
|
|
||||||
byMaskFingerprint.put(ScopedKey.of(system, entry.fingerprint()), entry);
|
|
||||||
insertionOrder.add(idKey);
|
|
||||||
charsHeld.addAndGet((long) entry.weight() - (replaced == null ? 0 : replaced.weight()));
|
|
||||||
|
|
||||||
sweepExpired(now);
|
|
||||||
evictWhileOverLimit();
|
|
||||||
|
|
||||||
shared.put(system, payloadId, encrypted, masked, entry.fingerprint());
|
|
||||||
}
|
}
|
||||||
|
SharedIndex.SharedEntry fromShared = shared.byId(system, payloadId);
|
||||||
|
if (fromShared == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
// Соседний узел уже выполнял прямой шаг: забираем соответствие к себе,
|
||||||
|
// чтобы повторное обращение обошлось без сети.
|
||||||
|
put(system, payloadId, fromShared.original(), fromShared.masked());
|
||||||
|
return decrypt(byId.get(idKey));
|
||||||
|
}
|
||||||
|
|
||||||
public Entry byId(String system, String payloadId) {
|
/**
|
||||||
String idKey = ScopedKey.of(system, payloadId);
|
* Исходный текст по самой маске — когда {@code payload_id} не совпал. Поиск идёт только в
|
||||||
Entry entry = byId.get(idKey);
|
* пределах той же системы: чужую маску подобрать и обменять на исходные данные нельзя.
|
||||||
if (entry != null && entry.alive(System.currentTimeMillis())) {
|
*/
|
||||||
return decrypt(entry);
|
public String originalForMask(String system, String masked) {
|
||||||
}
|
String fingerprint = fingerprint(masked);
|
||||||
if (entry != null) {
|
Entry entry = byMaskFingerprint.get(ScopedKey.of(system, fingerprint));
|
||||||
forget(idKey, entry);
|
if (entry != null && entry.alive(System.currentTimeMillis())) {
|
||||||
}
|
return cipher.decrypt(entry.original());
|
||||||
SharedIndex.SharedEntry fromShared = shared.byId(system, payloadId);
|
|
||||||
if (fromShared == null) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
// Соседний узел уже выполнял прямой шаг: забираем соответствие к себе,
|
|
||||||
// чтобы повторное обращение обошлось без сети.
|
|
||||||
put(system, payloadId, fromShared.original(), fromShared.masked());
|
|
||||||
return decrypt(byId.get(idKey));
|
|
||||||
}
|
}
|
||||||
|
return shared.originalForFingerprint(system, fingerprint);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/** Сколько символов сейчас удерживается — для диагностики и тестов. */
|
||||||
* Исходный текст по самой маске — когда {@code payload_id} не совпал. Поиск идёт
|
public long charsHeld() {
|
||||||
* только в пределах той же системы: чужую маску подобрать и обменять на исходные
|
return charsHeld.get();
|
||||||
* данные нельзя.
|
}
|
||||||
*/
|
|
||||||
public String originalForMask(String system, String masked) {
|
|
||||||
String fingerprint = fingerprint(masked);
|
|
||||||
Entry entry = byMaskFingerprint.get(ScopedKey.of(system, fingerprint));
|
|
||||||
if (entry != null && entry.alive(System.currentTimeMillis())) {
|
|
||||||
return cipher.decrypt(entry.original());
|
|
||||||
}
|
|
||||||
return shared.originalForFingerprint(system, fingerprint);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Сколько символов сейчас удерживается — для диагностики и тестов. */
|
/** Убирает протухшие записи с головы очереди, не более нескольких за раз. */
|
||||||
public long charsHeld() {
|
private void sweepExpired(long now) {
|
||||||
return charsHeld.get();
|
for (int i = 0; i < SWEEP_PER_PUT; i++) {
|
||||||
}
|
String oldest = insertionOrder.peek();
|
||||||
/**
|
if (oldest == null) {
|
||||||
* Убирает протухшие записи с головы очереди, не более нескольких за раз.
|
return;
|
||||||
*/
|
}
|
||||||
private void sweepExpired(long now) {
|
Entry entry = byId.get(oldest);
|
||||||
for (int i = 0; i < SWEEP_PER_PUT; i++) {
|
if (entry == null) {
|
||||||
String oldest = insertionOrder.peek();
|
insertionOrder.poll();
|
||||||
if (oldest == null) {
|
continue;
|
||||||
return;
|
}
|
||||||
}
|
if (entry.alive(now)) {
|
||||||
Entry entry = byId.get(oldest);
|
return;
|
||||||
if (entry == null) {
|
}
|
||||||
insertionOrder.poll();
|
insertionOrder.poll();
|
||||||
continue;
|
forget(oldest, entry);
|
||||||
}
|
|
||||||
if (entry.alive(now)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
insertionOrder.poll();
|
|
||||||
forget(oldest, entry);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private void evictWhileOverLimit() {
|
private void forget(String idKey, Entry entry) {
|
||||||
while (charsHeld.get() > maxChars) {
|
if (byId.remove(idKey, entry)) {
|
||||||
String oldest = insertionOrder.poll();
|
byMaskFingerprint.remove(ScopedKey.of(entry.system(), entry.fingerprint()), entry);
|
||||||
if (oldest == null) {
|
charsHeld.addAndGet(-entry.weight());
|
||||||
return;
|
|
||||||
}
|
|
||||||
Entry entry = byId.get(oldest);
|
|
||||||
if (entry != null) {
|
|
||||||
// ponytail: если тот же payload_id записали повторно, в очереди остался
|
|
||||||
// старый след и здесь вытесняется свежая запись. Цена — одно лишнее
|
|
||||||
// обращение к маскированию; точный учёт потребовал бы двусвязного списка.
|
|
||||||
forget(oldest, entry);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private void forget(String idKey, Entry entry) {
|
/** Возвращает запись с расшифрованным исходным текстом. */
|
||||||
if (byId.remove(idKey, entry)) {
|
private Entry decrypt(Entry entry) {
|
||||||
byMaskFingerprint.remove(ScopedKey.of(entry.system(), entry.fingerprint()), entry);
|
if (entry == null) {
|
||||||
charsHeld.addAndGet(-entry.weight());
|
return null;
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
return new Entry(
|
||||||
|
entry.system(),
|
||||||
|
cipher.decrypt(entry.original()),
|
||||||
|
entry.masked(),
|
||||||
|
entry.fingerprint(),
|
||||||
|
entry.expiresAt());
|
||||||
|
}
|
||||||
|
|
||||||
/** Возвращает запись с расшифрованным исходным текстом. */
|
private static String fingerprint(String value) {
|
||||||
private Entry decrypt(Entry entry) {
|
try {
|
||||||
if (entry == null) {
|
MessageDigest sha = MessageDigest.getInstance("SHA-256");
|
||||||
return null;
|
return HexFormat.of().formatHex(sha.digest(value.getBytes(StandardCharsets.UTF_8)));
|
||||||
}
|
} catch (NoSuchAlgorithmException e) {
|
||||||
return new Entry(entry.system(), cipher.decrypt(entry.original()), entry.masked(),
|
throw new IllegalStateException("SHA-256 недоступен в этой среде выполнения", e);
|
||||||
entry.fingerprint(), entry.expiresAt());
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
private static String fingerprint(String value) {
|
}
|
||||||
try {
|
|
||||||
MessageDigest sha = MessageDigest.getInstance("SHA-256");
|
|
||||||
return HexFormat.of().formatHex(sha.digest(value.getBytes(StandardCharsets.UTF_8)));
|
|
||||||
} catch (NoSuchAlgorithmException e) {
|
|
||||||
throw new IllegalStateException("SHA-256 недоступен в этой среде выполнения", e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -4,6 +4,14 @@ import io.micrometer.core.instrument.Counter;
|
|||||||
import io.micrometer.core.instrument.MeterRegistry;
|
import io.micrometer.core.instrument.MeterRegistry;
|
||||||
import io.micrometer.core.instrument.Timer;
|
import io.micrometer.core.instrument.Timer;
|
||||||
import io.micrometer.core.instrument.simple.SimpleMeterRegistry;
|
import io.micrometer.core.instrument.simple.SimpleMeterRegistry;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.Comparator;
|
||||||
|
import java.util.LinkedHashMap;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.NavigableMap;
|
||||||
|
import java.util.TreeMap;
|
||||||
|
import java.util.concurrent.TimeUnit;
|
||||||
import org.slf4j.Logger;
|
import org.slf4j.Logger;
|
||||||
import org.slf4j.LoggerFactory;
|
import org.slf4j.LoggerFactory;
|
||||||
import org.springframework.beans.factory.annotation.Autowired;
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
@@ -19,317 +27,322 @@ import ru.pdguard.mask.MaskContext;
|
|||||||
import ru.pdguard.mask.MaskMode;
|
import ru.pdguard.mask.MaskMode;
|
||||||
import ru.pdguard.mask.Masker;
|
import ru.pdguard.mask.Masker;
|
||||||
|
|
||||||
import java.util.ArrayList;
|
|
||||||
import java.util.Comparator;
|
|
||||||
import java.util.LinkedHashMap;
|
|
||||||
import java.util.List;
|
|
||||||
import java.util.Map;
|
|
||||||
import java.util.NavigableMap;
|
|
||||||
import java.util.TreeMap;
|
|
||||||
import java.util.concurrent.TimeUnit;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Обработка одного обращения: поиск ПД, маскирование и обратное преобразование.
|
* Обработка одного обращения: поиск ПД, маскирование и обратное преобразование.
|
||||||
*
|
*
|
||||||
* <p>Направление определяется по {@code payload_id}, а не по содержимому запроса:
|
* <p>Направление определяется по {@code payload_id}, а не по содержимому запроса:
|
||||||
|
*
|
||||||
* <ul>
|
* <ul>
|
||||||
* <li>идентификатор неизвестен — маскируем;</li>
|
* <li>идентификатор неизвестен — маскируем;
|
||||||
* <li>пришёл ранее выданный нами текст маски — возвращаем исходный текст;</li>
|
* <li>пришёл ранее выданный нами текст маски — возвращаем исходный текст;
|
||||||
* <li>пришёл тот же исходный текст — возвращаем ту же маску, что и в первый раз.</li>
|
* <li>пришёл тот же исходный текст — возвращаем ту же маску, что и в первый раз.
|
||||||
* </ul>
|
* </ul>
|
||||||
* Последний случай — повторная попытка проверяющей системы: ответ обязан
|
*
|
||||||
* совпасть с первым, иначе демаскирование по этому элементу развалится.
|
* Последний случай — повторная попытка проверяющей системы: ответ обязан совпасть с первым, иначе
|
||||||
|
* демаскирование по этому элементу развалится.
|
||||||
*/
|
*/
|
||||||
@Component
|
@Component
|
||||||
public class Pipeline {
|
public class Pipeline {
|
||||||
|
|
||||||
private static final Logger LOG = LoggerFactory.getLogger(Pipeline.class);
|
private static final Logger LOG = LoggerFactory.getLogger(Pipeline.class);
|
||||||
|
|
||||||
/** Грубая оценка числа токенов по числу символов — для метрики TPS. */
|
/** Грубая оценка числа токенов по числу символов — для метрики TPS. */
|
||||||
private static final int CHARS_PER_TOKEN = 4;
|
private static final int CHARS_PER_TOKEN = 4;
|
||||||
|
|
||||||
private final RuleRegistry registry;
|
private final RuleRegistry registry;
|
||||||
private final Masker masker;
|
private final Masker masker;
|
||||||
private final PayloadStore store;
|
private final PayloadStore store;
|
||||||
private final MeterRegistry meters;
|
private final MeterRegistry meters;
|
||||||
private final NameCascade cascade;
|
private final NameCascade cascade;
|
||||||
private final Counter tokensProcessed;
|
private final Counter tokensProcessed;
|
||||||
private final Counter unresolvedDemask;
|
private final Counter unresolvedDemask;
|
||||||
|
|
||||||
@Autowired
|
@Autowired
|
||||||
public Pipeline(RuleRegistry registry, Masker masker, PayloadStore store, MeterRegistry meters,
|
public Pipeline(
|
||||||
NameCascade cascade) {
|
RuleRegistry registry,
|
||||||
this.registry = registry;
|
Masker masker,
|
||||||
this.masker = masker;
|
PayloadStore store,
|
||||||
this.store = store;
|
MeterRegistry meters,
|
||||||
this.meters = meters;
|
NameCascade cascade) {
|
||||||
this.cascade = cascade;
|
this.registry = registry;
|
||||||
meters.gauge("pdguard.store.chars", store, PayloadStore::charsHeld);
|
this.masker = masker;
|
||||||
this.tokensProcessed = Counter.builder("pdguard.tokens.processed")
|
this.store = store;
|
||||||
.description("Оценка числа обработанных токенов, для расчёта TPS")
|
this.meters = meters;
|
||||||
.register(meters);
|
this.cascade = cascade;
|
||||||
this.unresolvedDemask = Counter.builder("pdguard.demask.unresolved")
|
meters.gauge("pdguard.store.chars", store, PayloadStore::charsHeld);
|
||||||
.description("Запрос на демаскирование, для которого соответствие не нашлось ни по "
|
this.tokensProcessed =
|
||||||
+ "id, ни по отпечатку маски — обработан как новое маскирование")
|
Counter.builder("pdguard.tokens.processed")
|
||||||
.register(meters);
|
.description("Оценка числа обработанных токенов, для расчёта TPS")
|
||||||
}
|
.register(meters);
|
||||||
|
this.unresolvedDemask =
|
||||||
|
Counter.builder("pdguard.demask.unresolved")
|
||||||
|
.description(
|
||||||
|
"Запрос на демаскирование, для которого соответствие не нашлось ни по "
|
||||||
|
+ "id, ни по отпечатку маски — обработан как новое маскирование")
|
||||||
|
.register(meters);
|
||||||
|
}
|
||||||
|
|
||||||
/** Конструктор для тестов: метрики никуда не отдаются, вторая ступень выключена. */
|
/** Конструктор для тестов: метрики никуда не отдаются, вторая ступень выключена. */
|
||||||
public Pipeline(RuleRegistry registry, Masker masker, PayloadStore store) {
|
public Pipeline(RuleRegistry registry, Masker masker, PayloadStore store) {
|
||||||
this(registry, masker, store, new SimpleMeterRegistry(), NameCascade.disabled());
|
this(registry, masker, store, new SimpleMeterRegistry(), NameCascade.disabled());
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Конструктор для тестов второй ступени. */
|
/** Конструктор для тестов второй ступени. */
|
||||||
public Pipeline(RuleRegistry registry, Masker masker, PayloadStore store, NameCascade cascade) {
|
public Pipeline(RuleRegistry registry, Masker masker, PayloadStore store, NameCascade cascade) {
|
||||||
this(registry, masker, store, new SimpleMeterRegistry(), cascade);
|
this(registry, masker, store, new SimpleMeterRegistry(), cascade);
|
||||||
}
|
}
|
||||||
|
|
||||||
public String process(String payload, String payloadId, SystemPolicy policy) {
|
public String process(String payload, String payloadId, SystemPolicy policy) {
|
||||||
long started = System.nanoTime();
|
long started = System.nanoTime();
|
||||||
tokensProcessed.increment((double) payload.length() / CHARS_PER_TOKEN);
|
tokensProcessed.increment((double) payload.length() / CHARS_PER_TOKEN);
|
||||||
|
|
||||||
PayloadStore.Entry known = store.byId(policy.name(), payloadId);
|
|
||||||
if (known != null) {
|
|
||||||
if (policy.demask() && payload.equals(known.masked())) {
|
|
||||||
LOG.debug("payload_id={} обратное преобразование по идентификатору", payloadId);
|
|
||||||
recordLatency("unmask", policy.name(), started);
|
|
||||||
return known.original();
|
|
||||||
}
|
|
||||||
if (payload.equals(known.original())) {
|
|
||||||
LOG.debug("payload_id={} повторная попытка, отдаём прежнюю маску", payloadId);
|
|
||||||
recordLatency("mask", policy.name(), started);
|
|
||||||
return known.masked();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (policy.demask()) {
|
|
||||||
String original = store.originalForMask(policy.name(), payload);
|
|
||||||
if (original != null) {
|
|
||||||
LOG.debug("payload_id={} обратное преобразование по отпечатку маски", payloadId);
|
|
||||||
recordLatency("unmask", policy.name(), started);
|
|
||||||
return original;
|
|
||||||
}
|
|
||||||
// Соответствие не нашлось нигде — не отличить достоверно новый payload от
|
|
||||||
// демаскирования с утраченным состоянием (например, узел, где маскировали,
|
|
||||||
// не успел записать в общий слой). Ниже это обработается как маскирование
|
|
||||||
// «с нуля», что для настоящего демаскирования даст неверный ответ — считаем
|
|
||||||
// и логируем каждый такой случай явно, чтобы не потерять его молча.
|
|
||||||
unresolvedDemask.increment();
|
|
||||||
LOG.warn("payload_id={} демаскирование не нашло соответствие ни по id, ни по "
|
|
||||||
+ "отпечатку маски — payload обработан как новый (см. pdguard.demask.unresolved)",
|
|
||||||
payloadId);
|
|
||||||
}
|
|
||||||
return mask(payload, payloadId, policy, started);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Длительность обработки с разрезом по направлению и системе-потребителю.
|
|
||||||
* Метрики берутся из реестра по тегам: систем немного и они заданы настройками,
|
|
||||||
* поэтому разрастания рядов не будет, а разрез по потребителям виден сразу.
|
|
||||||
*/
|
|
||||||
private void recordLatency(String direction, String system, long startedNanos) {
|
|
||||||
Timer.builder("pdguard.process")
|
|
||||||
.description("Длительность обработки обращения")
|
|
||||||
.tag("direction", direction)
|
|
||||||
.tag("system", system)
|
|
||||||
.register(meters)
|
|
||||||
.record(System.nanoTime() - startedNanos, TimeUnit.NANOSECONDS);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Фрагменты, которые будут замаскированы: поиск по правилам, разрешение
|
|
||||||
* перекрытий и все отсечения. Отдельный метод нужен, чтобы качество детекции
|
|
||||||
* можно было измерить, не разбирая замаскированный текст обратно.
|
|
||||||
*/
|
|
||||||
public List<Span> findPersonalData(String text, SystemPolicy policy) {
|
|
||||||
List<Span> spans = resolveOverlaps(registry.detect(text, policy));
|
|
||||||
if (policy.allows(PdTypes.FIO)) {
|
|
||||||
// Вторая ступень разбирает только то, что не покрыла первая.
|
|
||||||
spans = resolveOverlaps(cascade.addMissedNames(text, spans));
|
|
||||||
}
|
|
||||||
spans = dropOrganisationNames(text, spans);
|
|
||||||
spans = dropWellKnownNames(text, spans);
|
|
||||||
return dropLonelyCompanions(spans, policy);
|
|
||||||
}
|
|
||||||
|
|
||||||
private String mask(String payload, String payloadId, SystemPolicy policy, long started) {
|
|
||||||
List<Span> spans = findPersonalData(payload, policy);
|
|
||||||
String masked = apply(payload, spans, policy);
|
|
||||||
store.put(policy.name(), payloadId, payload, masked);
|
|
||||||
|
|
||||||
|
PayloadStore.Entry known = store.byId(policy.name(), payloadId);
|
||||||
|
if (known != null) {
|
||||||
|
if (policy.demask() && payload.equals(known.masked())) {
|
||||||
|
LOG.debug("payload_id={} обратное преобразование по идентификатору", payloadId);
|
||||||
|
recordLatency("unmask", policy.name(), started);
|
||||||
|
return known.original();
|
||||||
|
}
|
||||||
|
if (payload.equals(known.original())) {
|
||||||
|
LOG.debug("payload_id={} повторная попытка, отдаём прежнюю маску", payloadId);
|
||||||
recordLatency("mask", policy.name(), started);
|
recordLatency("mask", policy.name(), started);
|
||||||
logFindings(policy.name(), payloadId, payload.length(), spans);
|
return known.masked();
|
||||||
// Только для отладки формата входных данных на DEBUG — на INFO не активен.
|
}
|
||||||
// На боевом прогоне (нагрузочное тестирование) уровень DEBUG должен быть выключен:
|
|
||||||
// сюда попадают сырые ПД, что напрямую нарушает требование ТЗ не логировать значения.
|
|
||||||
LOG.debug("payload_id={} raw={}", payloadId, payload);
|
|
||||||
return masked;
|
|
||||||
}
|
}
|
||||||
|
if (policy.demask()) {
|
||||||
/**
|
String original = store.originalForMask(policy.name(), payload);
|
||||||
* Оставляет непересекающиеся фрагменты: при конфликте побеждает более
|
if (original != null) {
|
||||||
* приоритетный, при равном приоритете — более длинный.
|
LOG.debug("payload_id={} обратное преобразование по отпечатку маски", payloadId);
|
||||||
*/
|
recordLatency("unmask", policy.name(), started);
|
||||||
static List<Span> resolveOverlaps(List<Span> spans) {
|
return original;
|
||||||
List<Span> candidates = new ArrayList<>(spans);
|
}
|
||||||
candidates.sort(Comparator.comparingInt(Span::priority).reversed()
|
// Соответствие не нашлось нигде — не отличить достоверно новый payload от
|
||||||
.thenComparing(Comparator.comparingInt(Span::length).reversed())
|
// демаскирования с утраченным состоянием (например, узел, где маскировали,
|
||||||
.thenComparingInt(Span::start));
|
// не успел записать в общий слой). Ниже это обработается как маскирование
|
||||||
|
// «с нуля», что для настоящего демаскирования даст неверный ответ — считаем
|
||||||
// Принятые фрагменты не пересекаются и упорядочены по началу, поэтому
|
// и логируем каждый такой случай явно, чтобы не потерять его молча.
|
||||||
// кандидату достаточно сверить себя с ближайшим слева и ближайшим справа.
|
unresolvedDemask.increment();
|
||||||
// Перебор всех принятых давал бы квадрат: на тексте в сотню тысяч токенов
|
LOG.warn(
|
||||||
// фрагментов набираются тысячи.
|
"payload_id={} демаскирование не нашло соответствие ни по id, ни по "
|
||||||
NavigableMap<Integer, Span> accepted = new TreeMap<>();
|
+ "отпечатку маски — payload обработан как новый (см. pdguard.demask.unresolved)",
|
||||||
for (Span candidate : candidates) {
|
payloadId);
|
||||||
if (overlapsAccepted(accepted, candidate)) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
accepted.put(candidate.start(), candidate);
|
|
||||||
}
|
|
||||||
return List.copyOf(accepted.values());
|
|
||||||
}
|
}
|
||||||
|
return mask(payload, payloadId, policy, started);
|
||||||
|
}
|
||||||
|
|
||||||
/** Проверяет, пересекается ли кандидат с ближайшим принятым слева или справа. */
|
/**
|
||||||
private static boolean overlapsAccepted(NavigableMap<Integer, Span> accepted, Span candidate) {
|
* Длительность обработки с разрезом по направлению и системе-потребителю. Метрики берутся из
|
||||||
Map.Entry<Integer, Span> before = accepted.floorEntry(candidate.start());
|
* реестра по тегам: систем немного и они заданы настройками, поэтому разрастания рядов не будет,
|
||||||
if (before != null && before.getValue().overlaps(candidate)) {
|
* а разрез по потребителям виден сразу.
|
||||||
return true;
|
*/
|
||||||
}
|
private void recordLatency(String direction, String system, long startedNanos) {
|
||||||
Map.Entry<Integer, Span> after = accepted.ceilingEntry(candidate.start());
|
Timer.builder("pdguard.process")
|
||||||
return after != null && after.getValue().overlaps(candidate);
|
.description("Длительность обработки обращения")
|
||||||
}
|
.tag("direction", direction)
|
||||||
|
.tag("system", system)
|
||||||
|
.register(meters)
|
||||||
|
.record(System.nanoTime() - startedNanos, TimeUnit.NANOSECONDS);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Убирает имена, стоящие в названиях организаций и объектов на карте:
|
* Фрагменты, которые будут замаскированы: поиск по правилам, разрешение перекрытий и все
|
||||||
* «Институт Склифосовского», «Музей Тропинина», «улица Королёва». Проверка
|
* отсечения. Отдельный метод нужен, чтобы качество детекции можно было измерить, не разбирая
|
||||||
* не зависит от того, есть ли в тексте другие ПД: слово перед именем решает
|
* замаскированный текст обратно.
|
||||||
* само по себе.
|
*/
|
||||||
*/
|
public List<Span> findPersonalData(String text, SystemPolicy policy) {
|
||||||
static List<Span> dropOrganisationNames(String text, List<Span> spans) {
|
List<Span> spans = resolveOverlaps(registry.detect(text, policy));
|
||||||
return spans.stream()
|
if (cascade.coversAny(policy)) {
|
||||||
.filter(span -> !PdTypes.FIO.equals(span.type())
|
// Вторая ступень разбирает только то, что не покрыла первая.
|
||||||
|| !OrganisationDetector.precededByOrganisation(text, span.start()))
|
spans = resolveOverlaps(cascade.addMissedNames(text, spans));
|
||||||
.toList();
|
|
||||||
}
|
}
|
||||||
|
spans = dropOrganisationNames(text, spans);
|
||||||
|
spans = dropWellKnownNames(text, spans);
|
||||||
|
return dropLonelyCompanions(spans, policy);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
private String mask(String payload, String payloadId, SystemPolicy policy, long started) {
|
||||||
* Убирает имена известных людей: «стихи Александра Пушкина» персональными
|
List<Span> spans = findPersonalData(payload, policy);
|
||||||
* данными не являются. Если же в тексте есть ПД другого типа, речь идёт о
|
String masked = apply(payload, spans, policy);
|
||||||
* конкретном человеке, и имя остаётся замаскированным — однофамилец
|
store.put(policy.name(), payloadId, payload, masked);
|
||||||
* исторической фигуры защиту не теряет.
|
|
||||||
*/
|
|
||||||
static List<Span> dropWellKnownNames(String text, List<Span> spans) {
|
|
||||||
boolean otherPersonalDataPresent = spans.stream()
|
|
||||||
.anyMatch(span -> !PdTypes.FIO.equals(span.type()));
|
|
||||||
if (otherPersonalDataPresent) {
|
|
||||||
return spans;
|
|
||||||
}
|
|
||||||
return spans.stream()
|
|
||||||
.filter(span -> !PdTypes.FIO.equals(span.type()) || !isWellKnownHere(text, span))
|
|
||||||
.toList();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
recordLatency("mask", policy.name(), started);
|
||||||
* Известный человек по самому спану («Пушкина») или по спану вместе со следующим
|
logFindings(policy.name(), payloadId, payload.length(), spans);
|
||||||
* словом («Ярослав» + «Мудрый»): правило-однослов ловит имя правителя отдельно от
|
return masked;
|
||||||
* прозвища, а {@code REGNAL_NAME} распознаёт только двухсловную форму целиком.
|
}
|
||||||
*/
|
|
||||||
private static boolean isWellKnownHere(String text, Span span) {
|
|
||||||
if (NameDictionary.isWellKnown(text.substring(span.start(), span.end()))) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
int wordStart = span.end();
|
|
||||||
while (wordStart < text.length() && Character.isWhitespace(text.charAt(wordStart))) {
|
|
||||||
wordStart++;
|
|
||||||
}
|
|
||||||
int wordEnd = wordStart;
|
|
||||||
while (wordEnd < text.length() && Character.isLetter(text.charAt(wordEnd))) {
|
|
||||||
wordEnd++;
|
|
||||||
}
|
|
||||||
return wordEnd > wordStart && NameDictionary.isWellKnown(text.substring(span.start(), wordEnd));
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Убирает типы, которые опасны только в сочетании с другими ПД.
|
* Оставляет непересекающиеся фрагменты: при конфликте побеждает более приоритетный, при равном
|
||||||
* Пин-код в отрыве от номера карты не является персональными данными,
|
* приоритете — более длинный.
|
||||||
* рядом с номером карты — является.
|
*/
|
||||||
*
|
static List<Span> resolveOverlaps(List<Span> spans) {
|
||||||
* <p>Спутником считается только находка самостоятельного типа. Раньше здесь
|
List<Span> candidates = new ArrayList<>(spans);
|
||||||
* сравнивалось число различных типов, и два спутника заверяли друг друга:
|
candidates.sort(
|
||||||
* «Оплата 01.02.2025, ОГРН 1027700132195» маскировалась целиком, хотя человека
|
Comparator.comparingInt(Span::priority)
|
||||||
* в тексте нет, а дата и ОГРН по отдельности персональными данными не являются.
|
.reversed()
|
||||||
* Сочетание двух несамостоятельных типов самостоятельным не становится.
|
.thenComparing(Comparator.comparingInt(Span::length).reversed())
|
||||||
*/
|
.thenComparingInt(Span::start));
|
||||||
static List<Span> dropLonelyCompanions(List<Span> spans, SystemPolicy policy) {
|
|
||||||
for (Span span : spans) {
|
|
||||||
if (!policy.needsCompanion(span.type())) {
|
|
||||||
return spans;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Дошли сюда — самостоятельных находок нет, а значит все оставшиеся спутники одиноки.
|
|
||||||
return List.of();
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Замаскированный текст вместе с таблицей обратной замены. */
|
// Принятые фрагменты не пересекаются и упорядочены по началу, поэтому
|
||||||
public record Masked(String text, Map<String, String> restorations) {
|
// кандидату достаточно сверить себя с ближайшим слева и ближайшим справа.
|
||||||
public Masked {
|
// Перебор всех принятых давал бы квадрат: на тексте в сотню тысяч токенов
|
||||||
restorations = Map.copyOf(restorations);
|
// фрагментов набираются тысячи.
|
||||||
}
|
NavigableMap<Integer, Span> accepted = new TreeMap<>();
|
||||||
|
for (Span candidate : candidates) {
|
||||||
|
if (overlapsAccepted(accepted, candidate)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
accepted.put(candidate.start(), candidate);
|
||||||
}
|
}
|
||||||
|
return List.copyOf(accepted.values());
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/** Проверяет, пересекается ли кандидат с ближайшим принятым слева или справа. */
|
||||||
* Маскирует текст и отдаёт таблицу обратной замены.
|
private static boolean overlapsAccepted(NavigableMap<Integer, Span> accepted, Span candidate) {
|
||||||
*
|
Map.Entry<Integer, Span> before = accepted.floorEntry(candidate.start());
|
||||||
* <p>Нужно для прокси к языковой модели: ответ модели — другой текст, и восстановить
|
if (before != null && before.getValue().overlaps(candidate)) {
|
||||||
* его целиком по идентификатору нельзя, замену приходится делать пофрагментно.
|
return true;
|
||||||
* Звёздочки для этого не годятся — одна и та же маска может отвечать разным
|
|
||||||
* значениям, — поэтому режим замены здесь всегда обратимый.
|
|
||||||
*/
|
|
||||||
public Masked maskWithRestorations(String text, SystemPolicy policy) {
|
|
||||||
SystemPolicy reversible = new SystemPolicy(policy.name(), policy.enabled(), policy.demask(),
|
|
||||||
MaskMode.TOKEN, policy.types(), policy.requireCompanion(), policy.key());
|
|
||||||
List<Span> spans = findPersonalData(text, reversible);
|
|
||||||
if (spans.isEmpty()) {
|
|
||||||
return new Masked(text, Map.of());
|
|
||||||
}
|
|
||||||
MaskContext context = new MaskContext();
|
|
||||||
String masked = apply(text, spans, reversible, context);
|
|
||||||
logFindings(policy.name(), "proxy", text.length(), spans);
|
|
||||||
return new Masked(masked, context.restorations());
|
|
||||||
}
|
}
|
||||||
|
Map.Entry<Integer, Span> after = accepted.ceilingEntry(candidate.start());
|
||||||
|
return after != null && after.getValue().overlaps(candidate);
|
||||||
|
}
|
||||||
|
|
||||||
private String apply(String text, List<Span> spans, SystemPolicy policy) {
|
/**
|
||||||
return apply(text, spans, policy, new MaskContext());
|
* Убирает имена, стоящие в названиях организаций и объектов на карте: «Институт Склифосовского»,
|
||||||
}
|
* «Музей Тропинина», «улица Королёва». Проверка не зависит от того, есть ли в тексте другие ПД:
|
||||||
|
* слово перед именем решает само по себе.
|
||||||
|
*/
|
||||||
|
static List<Span> dropOrganisationNames(String text, List<Span> spans) {
|
||||||
|
return spans.stream()
|
||||||
|
.filter(
|
||||||
|
span ->
|
||||||
|
!PdTypes.FIO.equals(span.type())
|
||||||
|
|| !OrganisationDetector.precededByOrganisation(text, span.start()))
|
||||||
|
.toList();
|
||||||
|
}
|
||||||
|
|
||||||
private String apply(String text, List<Span> spans, SystemPolicy policy, MaskContext context) {
|
/**
|
||||||
if (spans.isEmpty()) {
|
* Убирает имена известных людей: «стихи Александра Пушкина» персональными данными не являются.
|
||||||
return text;
|
* Если же в тексте есть ПД другого типа, речь идёт о конкретном человеке, и имя остаётся
|
||||||
}
|
* замаскированным — однофамилец исторической фигуры защиту не теряет.
|
||||||
StringBuilder sb = new StringBuilder(text.length());
|
*/
|
||||||
int cursor = 0;
|
static List<Span> dropWellKnownNames(String text, List<Span> spans) {
|
||||||
for (Span span : spans) {
|
boolean otherPersonalDataPresent =
|
||||||
sb.append(text, cursor, span.start());
|
spans.stream().anyMatch(span -> !PdTypes.FIO.equals(span.type()));
|
||||||
String value = text.substring(span.start(), span.end());
|
if (otherPersonalDataPresent) {
|
||||||
sb.append(masker.mask(span.type(), value, policy.maskMode(), context));
|
return spans;
|
||||||
cursor = span.end();
|
|
||||||
}
|
|
||||||
sb.append(text, cursor, text.length());
|
|
||||||
return sb.toString();
|
|
||||||
}
|
}
|
||||||
|
return spans.stream()
|
||||||
|
.filter(span -> !PdTypes.FIO.equals(span.type()) || !isWellKnownHere(text, span))
|
||||||
|
.toList();
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* В журнал и в метрики попадают только идентификатор, типы ПД и их количество.
|
* Известный человек по самому спану («Пушкина») или по спану вместе со следующим словом
|
||||||
* На INFO и выше сами значения не логируются; на DEBUG они временно видны через
|
* («Ярослав» + «Мудрый»): правило-однослов ловит имя правителя отдельно от прозвища, а {@code
|
||||||
* отдельный вызов в {@link #mask} — см. комментарий там.
|
* REGNAL_NAME} распознаёт только двухсловную форму целиком.
|
||||||
*/
|
*/
|
||||||
private void logFindings(String system, String payloadId, int length, List<Span> spans) {
|
private static boolean isWellKnownHere(String text, Span span) {
|
||||||
Map<String, Integer> counts = new LinkedHashMap<>();
|
if (NameDictionary.isWellKnown(text.substring(span.start(), span.end()))) {
|
||||||
for (Span span : spans) {
|
return true;
|
||||||
counts.merge(span.type(), 1, Integer::sum);
|
|
||||||
}
|
|
||||||
counts.forEach((type, count) ->
|
|
||||||
meters.counter("pdguard.pd.detected", "type", type, "system", system).increment(count));
|
|
||||||
LOG.info("payload_id={} символов={} найдено={}", payloadId, length, counts);
|
|
||||||
}
|
}
|
||||||
}
|
int wordStart = span.end();
|
||||||
|
while (wordStart < text.length() && Character.isWhitespace(text.charAt(wordStart))) {
|
||||||
|
wordStart++;
|
||||||
|
}
|
||||||
|
int wordEnd = wordStart;
|
||||||
|
while (wordEnd < text.length() && Character.isLetter(text.charAt(wordEnd))) {
|
||||||
|
wordEnd++;
|
||||||
|
}
|
||||||
|
return wordEnd > wordStart && NameDictionary.isWellKnown(text.substring(span.start(), wordEnd));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Убирает типы, которые опасны только в сочетании с другими ПД. Пин-код в отрыве от номера карты
|
||||||
|
* не является персональными данными, рядом с номером карты — является.
|
||||||
|
*
|
||||||
|
* <p>Спутником считается только находка самостоятельного типа. Раньше здесь сравнивалось число
|
||||||
|
* различных типов, и два спутника заверяли друг друга: «Оплата 01.02.2025, ОГРН 1027700132195»
|
||||||
|
* маскировалась целиком, хотя человека в тексте нет, а дата и ОГРН по отдельности персональными
|
||||||
|
* данными не являются. Сочетание двух несамостоятельных типов самостоятельным не становится.
|
||||||
|
*/
|
||||||
|
static List<Span> dropLonelyCompanions(List<Span> spans, SystemPolicy policy) {
|
||||||
|
for (Span span : spans) {
|
||||||
|
if (!policy.needsCompanion(span.type())) {
|
||||||
|
return spans;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Дошли сюда — самостоятельных находок нет, а значит все оставшиеся спутники одиноки.
|
||||||
|
return List.of();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Замаскированный текст вместе с таблицей обратной замены. */
|
||||||
|
public record Masked(String text, Map<String, String> restorations) {
|
||||||
|
public Masked {
|
||||||
|
restorations = Map.copyOf(restorations);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Маскирует текст и отдаёт таблицу обратной замены.
|
||||||
|
*
|
||||||
|
* <p>Нужно для прокси к языковой модели: ответ модели — другой текст, и восстановить его целиком
|
||||||
|
* по идентификатору нельзя, замену приходится делать пофрагментно. Звёздочки для этого не годятся
|
||||||
|
* — одна и та же маска может отвечать разным значениям, — поэтому режим замены здесь всегда
|
||||||
|
* обратимый.
|
||||||
|
*/
|
||||||
|
public Masked maskWithRestorations(String text, SystemPolicy policy) {
|
||||||
|
SystemPolicy reversible =
|
||||||
|
new SystemPolicy(
|
||||||
|
policy.name(),
|
||||||
|
policy.enabled(),
|
||||||
|
policy.demask(),
|
||||||
|
MaskMode.TOKEN,
|
||||||
|
policy.types(),
|
||||||
|
policy.requireCompanion(),
|
||||||
|
policy.key());
|
||||||
|
List<Span> spans = findPersonalData(text, reversible);
|
||||||
|
if (spans.isEmpty()) {
|
||||||
|
return new Masked(text, Map.of());
|
||||||
|
}
|
||||||
|
MaskContext context = new MaskContext();
|
||||||
|
String masked = apply(text, spans, reversible, context);
|
||||||
|
logFindings(policy.name(), "proxy", text.length(), spans);
|
||||||
|
return new Masked(masked, context.restorations());
|
||||||
|
}
|
||||||
|
|
||||||
|
private String apply(String text, List<Span> spans, SystemPolicy policy) {
|
||||||
|
return apply(text, spans, policy, new MaskContext());
|
||||||
|
}
|
||||||
|
|
||||||
|
private String apply(String text, List<Span> spans, SystemPolicy policy, MaskContext context) {
|
||||||
|
if (spans.isEmpty()) {
|
||||||
|
return text;
|
||||||
|
}
|
||||||
|
StringBuilder sb = new StringBuilder(text.length());
|
||||||
|
int cursor = 0;
|
||||||
|
for (Span span : spans) {
|
||||||
|
sb.append(text, cursor, span.start());
|
||||||
|
String value = text.substring(span.start(), span.end());
|
||||||
|
sb.append(masker.mask(span.type(), value, policy.maskMode(), context));
|
||||||
|
cursor = span.end();
|
||||||
|
}
|
||||||
|
sb.append(text, cursor, text.length());
|
||||||
|
return sb.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* В журнал и в метрики попадают только идентификатор, типы ПД и их количество. На INFO и выше
|
||||||
|
* сами значения не логируются; на DEBUG они временно видны через отдельный вызов в {@link #mask}
|
||||||
|
* — см. комментарий там.
|
||||||
|
*/
|
||||||
|
private void logFindings(String system, String payloadId, int length, List<Span> spans) {
|
||||||
|
Map<String, Integer> counts = new LinkedHashMap<>();
|
||||||
|
for (Span span : spans) {
|
||||||
|
counts.merge(span.type(), 1, Integer::sum);
|
||||||
|
}
|
||||||
|
counts.forEach(
|
||||||
|
(type, count) ->
|
||||||
|
meters.counter("pdguard.pd.detected", "type", type, "system", system).increment(count));
|
||||||
|
LOG.info("payload_id={} символов={} найдено={}", payloadId, length, counts);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package ru.pdguard.core;
|
package ru.pdguard.core;
|
||||||
|
|
||||||
import jakarta.annotation.PostConstruct;
|
import jakarta.annotation.PostConstruct;
|
||||||
|
import java.util.Set;
|
||||||
import org.slf4j.Logger;
|
import org.slf4j.Logger;
|
||||||
import org.slf4j.LoggerFactory;
|
import org.slf4j.LoggerFactory;
|
||||||
import org.springframework.beans.factory.annotation.Value;
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
@@ -11,69 +12,76 @@ import ru.pdguard.detect.RuleRegistry;
|
|||||||
import ru.pdguard.mask.MaskMode;
|
import ru.pdguard.mask.MaskMode;
|
||||||
import ru.pdguard.mask.Masker;
|
import ru.pdguard.mask.Masker;
|
||||||
|
|
||||||
import java.util.Set;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Прогон обработки на старте, чтобы первые запросы не попадали на непрогретый код.
|
* Прогон обработки на старте, чтобы первые запросы не попадали на непрогретый код.
|
||||||
*
|
*
|
||||||
* <p>На JVM разница измерима: без прогрева первые десятки секунд нагрузки идут по
|
* <p>На JVM разница измерима: без прогрева первые десятки секунд нагрузки идут по интерпретируемому
|
||||||
* интерпретируемому и наспех скомпилированному коду, и p95 оказывается примерно
|
* и наспех скомпилированному коду, и p95 оказывается примерно вдесятеро хуже установившегося.
|
||||||
* вдесятеро хуже установившегося. Несколько тысяч прогонов на старте занимают доли
|
* Несколько тысяч прогонов на старте занимают доли секунды и переводят горячий путь на
|
||||||
* секунды и переводят горячий путь на оптимизирующий компилятор до того, как придут
|
* оптимизирующий компилятор до того, как придут настоящие запросы.
|
||||||
* настоящие запросы.
|
|
||||||
*
|
*
|
||||||
* <p>Прогрев идёт через отдельный экземпляр обработки со своим короткоживущим
|
* <p>Прогрев идёт через отдельный экземпляр обработки со своим короткоживущим хранилищем: настоящие
|
||||||
* хранилищем: настоящие соответствия «текст ↔ маска» замусорить нельзя.
|
* соответствия «текст ↔ маска» замусорить нельзя.
|
||||||
*
|
*
|
||||||
* <p>Вторая ступень при прогреве выключена, и не только ради времени: её счётчики
|
* <p>Вторая ступень при прогреве выключена, и не только ради времени: её счётчики показывают долю
|
||||||
* показывают долю запросов, дошедших до модели, а тысячи служебных прогонов эту
|
* запросов, дошедших до модели, а тысячи служебных прогонов эту долю исказили бы до неузнаваемости.
|
||||||
* долю исказили бы до неузнаваемости. Сама модель прогревается отдельно, при
|
* Сама модель прогревается отдельно, при создании своего пула.
|
||||||
* создании своего пула.
|
|
||||||
*/
|
*/
|
||||||
@Component
|
@Component
|
||||||
public class PipelineWarmup {
|
public class PipelineWarmup {
|
||||||
|
|
||||||
private static final Logger LOG = LoggerFactory.getLogger(PipelineWarmup.class);
|
private static final Logger LOG = LoggerFactory.getLogger(PipelineWarmup.class);
|
||||||
|
|
||||||
/** Тексты подобраны так, чтобы задеть основные семейства правил. */
|
/** Тексты подобраны так, чтобы задеть основные семейства правил. */
|
||||||
private static final String[] SAMPLES = {
|
private static final String[] SAMPLES = {
|
||||||
"Клиент Иванов Иван Иванович, паспорт 4509 123456, тел +7 916 123-45-67",
|
"Клиент Иванов Иван Иванович, паспорт 4509 123456, тел +7 916 123-45-67",
|
||||||
"Заявление от И.И. Петрова, ИНН 770301234550, почта ivan.petrov@mail.ru",
|
"Заявление от И.И. Петрова, ИНН 770301234550, почта ivan.petrov@mail.ru",
|
||||||
"Адрес: 125009, г. Москва, ул. Тверская, д. 7, кв. 15, карта 4111 1111 1111 1111",
|
"Адрес: 125009, г. Москва, ул. Тверская, д. 7, кв. 15, карта 4111 1111 1111 1111",
|
||||||
"Дата рождения 12.05.1985, место рождения: город Тверь, гражданство РФ",
|
"Дата рождения 12.05.1985, место рождения: город Тверь, гражданство РФ",
|
||||||
"Напиши краткое описание продукта для рассылки клиентам банка",
|
"Напиши краткое описание продукта для рассылки клиентам банка",
|
||||||
};
|
};
|
||||||
|
|
||||||
private final RuleRegistry registry;
|
private final RuleRegistry registry;
|
||||||
private final Masker masker;
|
private final Masker masker;
|
||||||
private final int iterations;
|
private final int iterations;
|
||||||
|
|
||||||
public PipelineWarmup(RuleRegistry registry, Masker masker,
|
public PipelineWarmup(
|
||||||
@Value("${pdguard.warmup-iterations:2000}") int iterations) {
|
RuleRegistry registry,
|
||||||
this.registry = registry;
|
Masker masker,
|
||||||
this.masker = masker;
|
@Value("${pdguard.warmup-iterations:2000}") int iterations) {
|
||||||
this.iterations = iterations;
|
this.registry = registry;
|
||||||
|
this.masker = masker;
|
||||||
|
this.iterations = iterations;
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostConstruct
|
||||||
|
void warmup() {
|
||||||
|
if (iterations <= 0) {
|
||||||
|
LOG.info("Прогрев обработки отключён");
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
long started = System.nanoTime();
|
||||||
|
Pipeline scratch =
|
||||||
|
new Pipeline(registry, masker, new PayloadStore(1), NameCascade.disabled());
|
||||||
|
SystemPolicy policy =
|
||||||
|
new SystemPolicy(
|
||||||
|
SystemPolicy.DEFAULT_NAME,
|
||||||
|
true,
|
||||||
|
true,
|
||||||
|
MaskMode.MASK,
|
||||||
|
Set.of(SystemPolicy.ALL),
|
||||||
|
SystemPolicy.DEFAULT.requireCompanion(),
|
||||||
|
null);
|
||||||
|
|
||||||
@PostConstruct
|
for (int i = 0; i < iterations; i++) {
|
||||||
void warmup() {
|
String text = SAMPLES[i % SAMPLES.length];
|
||||||
if (iterations <= 0) {
|
String id = "warmup-" + i;
|
||||||
LOG.info("Прогрев обработки отключён");
|
String masked = scratch.process(text, id, policy);
|
||||||
return;
|
scratch.process(masked, id, policy);
|
||||||
}
|
|
||||||
long started = System.nanoTime();
|
|
||||||
Pipeline scratch = new Pipeline(registry, masker, new PayloadStore(1_000_000L, 1),
|
|
||||||
NameCascade.disabled());
|
|
||||||
SystemPolicy policy = new SystemPolicy(SystemPolicy.DEFAULT_NAME, true, true, MaskMode.MASK,
|
|
||||||
Set.of(SystemPolicy.ALL), SystemPolicy.DEFAULT.requireCompanion(), null);
|
|
||||||
|
|
||||||
for (int i = 0; i < iterations; i++) {
|
|
||||||
String text = SAMPLES[i % SAMPLES.length];
|
|
||||||
String id = "warmup-" + i;
|
|
||||||
String masked = scratch.process(text, id, policy);
|
|
||||||
scratch.process(masked, id, policy);
|
|
||||||
}
|
|
||||||
LOG.info("Прогрев обработки: {} прогонов за {} мс",
|
|
||||||
iterations, (System.nanoTime() - started) / 1_000_000);
|
|
||||||
}
|
}
|
||||||
}
|
LOG.info(
|
||||||
|
"Прогрев обработки: {} прогонов за {} мс",
|
||||||
|
iterations,
|
||||||
|
(System.nanoTime() - started) / 1_000_000);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -3,18 +3,16 @@ package ru.pdguard.core;
|
|||||||
/**
|
/**
|
||||||
* Ключ, однозначно разделяющий системы-потребители.
|
* Ключ, однозначно разделяющий системы-потребители.
|
||||||
*
|
*
|
||||||
* <p>Длина имени в начале снимает вопрос о разделителе: имя системы может
|
* <p>Длина имени в начале снимает вопрос о разделителе: имя системы может содержать любые знаки, и
|
||||||
* содержать любые знаки, и без длины «a:b» и «ab:» были бы неразличимы.
|
* без длины «a:b» и «ab:» были бы неразличимы. Используется и в локальном хранилище, и в общем слое
|
||||||
* Используется и в локальном хранилище, и в общем слое — единая реализация
|
* — единая реализация вместо двух копий.
|
||||||
* вместо двух копий.
|
|
||||||
*/
|
*/
|
||||||
final class ScopedKey {
|
final class ScopedKey {
|
||||||
|
|
||||||
private ScopedKey() {
|
private ScopedKey() {}
|
||||||
}
|
|
||||||
|
|
||||||
static String of(String system, String key) {
|
static String of(String system, String key) {
|
||||||
String owner = system == null ? "" : system;
|
String owner = system == null ? "" : system;
|
||||||
return owner.length() + ":" + owner + ":" + key;
|
return owner.length() + ":" + owner + ":" + key;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,162 +2,164 @@ package ru.pdguard.core;
|
|||||||
|
|
||||||
import com.fasterxml.jackson.core.JsonProcessingException;
|
import com.fasterxml.jackson.core.JsonProcessingException;
|
||||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
|
import java.time.Duration;
|
||||||
|
import java.util.concurrent.atomic.AtomicInteger;
|
||||||
import org.slf4j.Logger;
|
import org.slf4j.Logger;
|
||||||
import org.slf4j.LoggerFactory;
|
import org.slf4j.LoggerFactory;
|
||||||
import org.springframework.beans.factory.annotation.Value;
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
import org.springframework.data.redis.core.StringRedisTemplate;
|
import org.springframework.data.redis.core.StringRedisTemplate;
|
||||||
import org.springframework.stereotype.Component;
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
import java.time.Duration;
|
|
||||||
import java.util.concurrent.atomic.AtomicInteger;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Общий слой соответствий «текст ↔ маска» для работы на нескольких узлах.
|
* Общий слой соответствий «текст ↔ маска» для работы на нескольких узлах.
|
||||||
*
|
*
|
||||||
* <p>Маскирование — чистая функция, на любом узле даёт один и тот же результат.
|
* <p>Маскирование — чистая функция, на любом узле даёт один и тот же результат. Обратное же
|
||||||
* Обратное же преобразование требует состояния: если прямой запрос обработал
|
* преобразование требует состояния: если прямой запрос обработал один узел, а обратный попал на
|
||||||
* один узел, а обратный попал на другой, соответствие должно быть общим.
|
* другой, соответствие должно быть общим.
|
||||||
*
|
*
|
||||||
* <p>Включается настройкой {@code pdguard.store.backend=redis}. Пока она не
|
* <p>Включается настройкой {@code pdguard.store.backend=redis}. Пока она не выставлена, к Redis не
|
||||||
* выставлена, к Redis не обращаются вовсе и зависимость остаётся неактивной.
|
* обращаются вовсе и зависимость остаётся неактивной.
|
||||||
*
|
*
|
||||||
* <p>Недоступность Redis не приводит к отказу: запись и чтение деградируют до
|
* <p>Недоступность Redis не приводит к отказу: запись и чтение деградируют до локальной памяти
|
||||||
* локальной памяти узла, а ошибка попадает в журнал. Чтобы простой Redis не
|
* узла, а ошибка попадает в журнал. Чтобы простой Redis не съедал время ответа, команды ограничены
|
||||||
* съедал время ответа, команды ограничены по времени настройкой
|
* по времени настройкой {@code spring.data.redis.timeout}, а после нескольких подряд неудач общий
|
||||||
* {@code spring.data.redis.timeout}, а после нескольких подряд неудач общий слой
|
* слой временно перестают опрашивать вовсе.
|
||||||
* временно перестают опрашивать вовсе.
|
|
||||||
*/
|
*/
|
||||||
@Component
|
@Component
|
||||||
public class SharedIndex {
|
public class SharedIndex {
|
||||||
|
|
||||||
private static final Logger LOG = LoggerFactory.getLogger(SharedIndex.class);
|
private static final Logger LOG = LoggerFactory.getLogger(SharedIndex.class);
|
||||||
|
|
||||||
/** Сколько подряд неудач размыкает предохранитель. */
|
/** Сколько подряд неудач размыкает предохранитель. */
|
||||||
private static final int FAILURES_TO_OPEN = 3;
|
private static final int FAILURES_TO_OPEN = 3;
|
||||||
|
|
||||||
/** На сколько общий слой перестают опрашивать после размыкания. */
|
/** На сколько общий слой перестают опрашивать после размыкания. */
|
||||||
private static final long OPEN_MILLIS = 5_000;
|
private static final long OPEN_MILLIS = 5_000;
|
||||||
|
|
||||||
/** Пара «исходный текст — маска», как она хранится в общем слое. */
|
/** Пара «исходный текст — маска», как она хранится в общем слое. */
|
||||||
public record SharedEntry(String original, String masked) {
|
public record SharedEntry(String original, String masked) {}
|
||||||
|
|
||||||
|
private final boolean enabled;
|
||||||
|
private final Duration ttl;
|
||||||
|
private final StringRedisTemplate redis;
|
||||||
|
private final ObjectMapper mapper;
|
||||||
|
private final PayloadCipher cipher;
|
||||||
|
|
||||||
|
private final AtomicInteger consecutiveFailures = new AtomicInteger();
|
||||||
|
private volatile long silentUntil;
|
||||||
|
private volatile boolean reported;
|
||||||
|
|
||||||
|
public SharedIndex(
|
||||||
|
StringRedisTemplate redis,
|
||||||
|
@Value("${pdguard.store.backend:memory}") String backend,
|
||||||
|
@Value("${pdguard.store.ttl-minutes:30}") int ttlMinutes,
|
||||||
|
ObjectMapper mapper,
|
||||||
|
PayloadCipher cipher) {
|
||||||
|
this.redis = redis;
|
||||||
|
this.enabled = "redis".equalsIgnoreCase(backend);
|
||||||
|
this.ttl = Duration.ofMinutes(ttlMinutes);
|
||||||
|
this.mapper = mapper;
|
||||||
|
this.cipher = cipher;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Выключенный слой — для тестов и для сборки без Redis. */
|
||||||
|
public static SharedIndex disabled() {
|
||||||
|
return new SharedIndex(null, "memory", 30, new ObjectMapper(), PayloadCipher.disabled());
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean enabled() {
|
||||||
|
return enabled;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void put(
|
||||||
|
String system, String payloadId, String original, String masked, String maskFingerprint) {
|
||||||
|
if (unavailable()) {
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
try {
|
||||||
private final boolean enabled;
|
String encrypted = cipher.encrypt(original);
|
||||||
private final Duration ttl;
|
redis
|
||||||
private final StringRedisTemplate redis;
|
.opsForValue()
|
||||||
private final ObjectMapper mapper;
|
.set(ScopedKey.of(system, payloadId), toJson(new SharedEntry(encrypted, masked)), ttl);
|
||||||
private final PayloadCipher cipher;
|
redis.opsForValue().set(ScopedKey.of(system, maskFingerprint), encrypted, ttl);
|
||||||
|
noteSuccess();
|
||||||
private final AtomicInteger consecutiveFailures = new AtomicInteger();
|
} catch (RuntimeException e) {
|
||||||
private volatile long silentUntil;
|
noteFailure("записать", e);
|
||||||
private volatile boolean reported;
|
|
||||||
|
|
||||||
public SharedIndex(StringRedisTemplate redis,
|
|
||||||
@Value("${pdguard.store.backend:memory}") String backend,
|
|
||||||
@Value("${pdguard.store.ttl-minutes:30}") int ttlMinutes,
|
|
||||||
ObjectMapper mapper,
|
|
||||||
PayloadCipher cipher) {
|
|
||||||
this.redis = redis;
|
|
||||||
this.enabled = "redis".equalsIgnoreCase(backend);
|
|
||||||
this.ttl = Duration.ofMinutes(ttlMinutes);
|
|
||||||
this.mapper = mapper;
|
|
||||||
this.cipher = cipher;
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/** Выключенный слой — для тестов и для сборки без Redis. */
|
public SharedEntry byId(String system, String payloadId) {
|
||||||
public static SharedIndex disabled() {
|
if (unavailable()) {
|
||||||
return new SharedIndex(null, "memory", 30, new ObjectMapper(), PayloadCipher.disabled());
|
return null;
|
||||||
}
|
}
|
||||||
|
try {
|
||||||
|
String json = redis.opsForValue().get(ScopedKey.of(system, payloadId));
|
||||||
|
noteSuccess();
|
||||||
|
SharedEntry entry = json == null ? null : fromJson(json);
|
||||||
|
return entry == null
|
||||||
|
? null
|
||||||
|
: new SharedEntry(cipher.decrypt(entry.original()), entry.masked());
|
||||||
|
} catch (RuntimeException e) {
|
||||||
|
noteFailure("прочитать", e);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public boolean enabled() {
|
public String originalForFingerprint(String system, String maskFingerprint) {
|
||||||
return enabled;
|
if (unavailable()) {
|
||||||
|
return null;
|
||||||
}
|
}
|
||||||
|
try {
|
||||||
|
String encrypted = redis.opsForValue().get(ScopedKey.of(system, maskFingerprint));
|
||||||
|
noteSuccess();
|
||||||
|
return encrypted == null ? null : cipher.decrypt(encrypted);
|
||||||
|
} catch (RuntimeException e) {
|
||||||
|
noteFailure("прочитать", e);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public void put(String system, String payloadId, String original, String masked,
|
private String toJson(SharedEntry entry) {
|
||||||
String maskFingerprint) {
|
try {
|
||||||
if (unavailable()) {
|
return mapper.writeValueAsString(entry);
|
||||||
return;
|
} catch (JsonProcessingException e) {
|
||||||
}
|
throw new IllegalStateException("Не удалось сериализовать соответствие", e);
|
||||||
try {
|
|
||||||
String encrypted = cipher.encrypt(original);
|
|
||||||
redis.opsForValue().set(ScopedKey.of(system, payloadId),
|
|
||||||
toJson(new SharedEntry(encrypted, masked)), ttl);
|
|
||||||
redis.opsForValue().set(ScopedKey.of(system, maskFingerprint), encrypted, ttl);
|
|
||||||
noteSuccess();
|
|
||||||
} catch (RuntimeException e) {
|
|
||||||
noteFailure("записать", e);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public SharedEntry byId(String system, String payloadId) {
|
private SharedEntry fromJson(String json) {
|
||||||
if (unavailable()) {
|
try {
|
||||||
return null;
|
return mapper.readValue(json, SharedEntry.class);
|
||||||
}
|
} catch (JsonProcessingException e) {
|
||||||
try {
|
throw new IllegalStateException("Не удалось разобрать соответствие из общего слоя", e);
|
||||||
String json = redis.opsForValue().get(ScopedKey.of(system, payloadId));
|
|
||||||
noteSuccess();
|
|
||||||
SharedEntry entry = json == null ? null : fromJson(json);
|
|
||||||
return entry == null ? null : new SharedEntry(cipher.decrypt(entry.original()), entry.masked());
|
|
||||||
} catch (RuntimeException e) {
|
|
||||||
noteFailure("прочитать", e);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public String originalForFingerprint(String system, String maskFingerprint) {
|
/** Общий слой выключен или предохранитель разомкнут. */
|
||||||
if (unavailable()) {
|
private boolean unavailable() {
|
||||||
return null;
|
return !enabled || System.currentTimeMillis() < silentUntil;
|
||||||
}
|
}
|
||||||
try {
|
|
||||||
String encrypted = redis.opsForValue().get(ScopedKey.of(system, maskFingerprint));
|
|
||||||
noteSuccess();
|
|
||||||
return encrypted == null ? null : cipher.decrypt(encrypted);
|
|
||||||
} catch (RuntimeException e) {
|
|
||||||
noteFailure("прочитать", e);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private String toJson(SharedEntry entry) {
|
private void noteSuccess() {
|
||||||
try {
|
if (consecutiveFailures.getAndSet(0) != 0) {
|
||||||
return mapper.writeValueAsString(entry);
|
reported = false;
|
||||||
} catch (JsonProcessingException e) {
|
LOG.info("Общий слой снова доступен");
|
||||||
throw new IllegalStateException("Не удалось сериализовать соответствие", e);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private SharedEntry fromJson(String json) {
|
/**
|
||||||
try {
|
* После нескольких неудач подряд общий слой перестают опрашивать на несколько секунд: иначе
|
||||||
return mapper.readValue(json, SharedEntry.class);
|
* каждый запрос платил бы таймаутом за недоступный Redis, а проверяющая система считает ответ
|
||||||
} catch (JsonProcessingException e) {
|
* дольше десяти секунд неответом.
|
||||||
throw new IllegalStateException("Не удалось разобрать соответствие из общего слоя", e);
|
*/
|
||||||
}
|
private void noteFailure(String action, RuntimeException cause) {
|
||||||
|
if (consecutiveFailures.incrementAndGet() >= FAILURES_TO_OPEN) {
|
||||||
|
silentUntil = System.currentTimeMillis() + OPEN_MILLIS;
|
||||||
}
|
}
|
||||||
|
if (!reported) {
|
||||||
/** Общий слой выключен или предохранитель разомкнут. */
|
reported = true;
|
||||||
private boolean unavailable() {
|
LOG.error(
|
||||||
return !enabled || System.currentTimeMillis() < silentUntil;
|
"Не удалось {} соответствие в общий слой, узел работает на своей памяти", action, cause);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
private void noteSuccess() {
|
}
|
||||||
if (consecutiveFailures.getAndSet(0) != 0) {
|
|
||||||
reported = false;
|
|
||||||
LOG.info("Общий слой снова доступен");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* После нескольких неудач подряд общий слой перестают опрашивать на несколько
|
|
||||||
* секунд: иначе каждый запрос платил бы таймаутом за недоступный Redis, а
|
|
||||||
* проверяющая система считает ответ дольше десяти секунд неответом.
|
|
||||||
*/
|
|
||||||
private void noteFailure(String action, RuntimeException cause) {
|
|
||||||
if (consecutiveFailures.incrementAndGet() >= FAILURES_TO_OPEN) {
|
|
||||||
silentUntil = System.currentTimeMillis() + OPEN_MILLIS;
|
|
||||||
}
|
|
||||||
if (!reported) {
|
|
||||||
reported = true;
|
|
||||||
LOG.error("Не удалось {} соответствие в общий слой, узел работает на своей памяти", action, cause);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -0,0 +1,171 @@
|
|||||||
|
package ru.pdguard.detect;
|
||||||
|
|
||||||
|
import static ru.pdguard.detect.RulePatterns.CITIZENSHIP_GAP;
|
||||||
|
import static ru.pdguard.detect.RulePatterns.CITIZENSHIP_VALUE;
|
||||||
|
import static ru.pdguard.detect.RulePatterns.ORGANISATION_NEARBY;
|
||||||
|
import static ru.pdguard.detect.RulePatterns.ROLE_GAP;
|
||||||
|
import static ru.pdguard.detect.RulePatterns.STREET_NAME;
|
||||||
|
import static ru.pdguard.detect.RuleRegistry.ADDRESS_NEARBY;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/** Правила распознавания органа выдачи паспорта, места рождения, гражданства и адреса. */
|
||||||
|
final class AddressRules {
|
||||||
|
|
||||||
|
private AddressRules() {}
|
||||||
|
|
||||||
|
static final List<Rule> RULES =
|
||||||
|
List.of(
|
||||||
|
|
||||||
|
// «выдан ОУФМС России по г. Москве 12.05.2015» — дата в состав органа не входит,
|
||||||
|
// её забирает отдельное правило. Приоритет выше городского, иначе от органа
|
||||||
|
// осталась бы замаскированной только его часть.
|
||||||
|
// Перечень форм, не голая основа «выда»: она зацепила бы и «выдающийся»
|
||||||
|
// (обычное слово, не про выдачу документа).
|
||||||
|
Rule.of(
|
||||||
|
PdTypes.PASSPORT_ISSUER,
|
||||||
|
"(?iu:выдан|выдал[аио]?|выдали|выдач[аи]|выдаче)"
|
||||||
|
+ "\\W{0,3}([^,;\\n]{3,90}?)"
|
||||||
|
+ "(?=\\s*\\d{1,2}[.\\-/]\\d{1,2}[.\\-/]\\d{2,4}|[,;\\n]|\\s*$)",
|
||||||
|
78)
|
||||||
|
.groups(1)
|
||||||
|
.anchoredBy("выдан", "выдал", "выдач"),
|
||||||
|
|
||||||
|
// «совпадает с указанным в анкете: X» — второе упоминание органа выдачи
|
||||||
|
// под собственным якорем, без бэкреференса на первое.
|
||||||
|
Rule.of(
|
||||||
|
PdTypes.PASSPORT_ISSUER,
|
||||||
|
"(?iu:указанн\\w*\\s+в\\s+анкете)\\W{0,5}([^,;.\\n]{3,90}?)"
|
||||||
|
+ "(?=[,;.\\n]|\\s*$)",
|
||||||
|
78)
|
||||||
|
.groups(1)
|
||||||
|
.anchoredBy("указанн"),
|
||||||
|
|
||||||
|
// «Орган выдачи УФМС России по Республике Татарстан» — орган после якоря,
|
||||||
|
// до слова «совпадает» или конца фразы.
|
||||||
|
Rule.of(
|
||||||
|
PdTypes.PASSPORT_ISSUER,
|
||||||
|
"(?iu:орган\\s+выдачи)\\W{0,5}([^,;:\\n]{3,90}?)"
|
||||||
|
+ "(?=\\s*(?iu:совпадает|указанн)|[,;:\\n]|\\s*$)",
|
||||||
|
78)
|
||||||
|
.groups(1)
|
||||||
|
.anchoredBy("орган выдачи"),
|
||||||
|
Rule.of(
|
||||||
|
PdTypes.BIRTH_PLACE,
|
||||||
|
"(?iu:мест\\w*\\s+рождения)\\W{0,5}([^,;\\n]{3,60}?)(?=\\s*[,;\\n]|\\s*$)",
|
||||||
|
76)
|
||||||
|
.groups(1)
|
||||||
|
.anchoredBy("рождения"),
|
||||||
|
Rule.of(
|
||||||
|
PdTypes.BIRTH_PLACE,
|
||||||
|
"(?iu:родил(?:ся|ась))[^,;\\n]{0,40}?\\s+в\\s+"
|
||||||
|
+ "([^,;\\n]{3,40}?)(?=\\s*[,;\\n]|\\s*$)",
|
||||||
|
76)
|
||||||
|
.groups(1)
|
||||||
|
.anchoredBy("родил"),
|
||||||
|
|
||||||
|
// ROLE_GAP, не \W{0,5}: «Гражданство бенефициара по договору страхования: Х» —
|
||||||
|
// между якорем и значением бывает несколько слов, не только пунктуация.
|
||||||
|
// Список через запятую/слэш — вторая опциональная группа тем же шаблоном.
|
||||||
|
Rule.of(
|
||||||
|
PdTypes.CITIZENSHIP,
|
||||||
|
"(?iu:гражданств)\\w*"
|
||||||
|
+ CITIZENSHIP_GAP
|
||||||
|
+ "("
|
||||||
|
+ CITIZENSHIP_VALUE
|
||||||
|
+ ")(?:\\s*[,/]\\s*("
|
||||||
|
+ CITIZENSHIP_VALUE
|
||||||
|
+ "))?",
|
||||||
|
80)
|
||||||
|
.groups(1, 2)
|
||||||
|
.validatedBy(CountryDictionary::isKnownCountry)
|
||||||
|
.anchoredBy("гражданств"),
|
||||||
|
|
||||||
|
// ин/ка/ина/ки — именительный/родительный; ином/кой — творительный
|
||||||
|
// («гражданином», «гражданкой»).
|
||||||
|
Rule.of(
|
||||||
|
PdTypes.CITIZENSHIP,
|
||||||
|
"(?iu:граждан(?:ин|ка|ина|ки|ином|кой))\\b\\s+"
|
||||||
|
+ "("
|
||||||
|
+ CITIZENSHIP_VALUE
|
||||||
|
+ ")(?:\\s*[,/]\\s*("
|
||||||
|
+ CITIZENSHIP_VALUE
|
||||||
|
+ "))?",
|
||||||
|
75)
|
||||||
|
.groups(1, 2)
|
||||||
|
.validatedBy(CountryDictionary::isKnownCountry)
|
||||||
|
.anchoredBy("граждан"),
|
||||||
|
|
||||||
|
// --- Адрес: каждая составляющая настраивается отдельно ---
|
||||||
|
|
||||||
|
Rule.of(PdTypes.ADDRESS_POSTCODE, "(?iu:индекс)\\w*" + ROLE_GAP + "(\\d{6})\\b", 74)
|
||||||
|
.groups(1)
|
||||||
|
.vetoedBy(ORGANISATION_NEARBY)
|
||||||
|
.anchoredBy("индекс"),
|
||||||
|
Rule.of(
|
||||||
|
PdTypes.ADDRESS_POSTCODE,
|
||||||
|
"\\b(\\d{6})(?=\\s*,?\\s*(?iu:г\\.|город|обл\\.|область|респ|край))",
|
||||||
|
74)
|
||||||
|
.groups(1)
|
||||||
|
.vetoedBy(ORGANISATION_NEARBY),
|
||||||
|
|
||||||
|
// Не только «г.»: перепись, на которой проверяется словарь, покрывает
|
||||||
|
// сёла, посёлки, деревни, хутора и станицы — «рп. Ильинское», «с. Кукуево»
|
||||||
|
// из ТЗ без этих якорей не нашлись бы вообще, город там ни при чём.
|
||||||
|
Rule.of(
|
||||||
|
PdTypes.ADDRESS_CITY,
|
||||||
|
"(?iu:\\bг\\.|\\bгор\\.|\\bгород|\\bрп\\.|\\bпгт\\.?|\\bп\\.|\\bс\\.|\\bсело\\b"
|
||||||
|
+ "|\\bд\\.|\\bдеревня\\b|\\bдер\\.|\\bх\\.|\\bхутор\\b|\\bст-ца|\\bстаница|\\bаул\\b"
|
||||||
|
+ "|\\bсл\\.|\\bслобода\\b|\\bаал\\b)\\s?(\\p{Lu}[\\p{L}-]{1,30})\\b",
|
||||||
|
73)
|
||||||
|
.groups(1)
|
||||||
|
.validatedBy(ToponymDictionary::isKnownSettlement)
|
||||||
|
.vetoedBy(ORGANISATION_NEARBY)
|
||||||
|
.anchoredBy(
|
||||||
|
"г.", "гор", "город", "рп.", "пгт", "п.", "с.", "село", "д.", "деревня", "дер.",
|
||||||
|
"х.", "хутор", "ст-ца", "станица", "аул", "сл.", "слобода", "аал"),
|
||||||
|
Rule.of(
|
||||||
|
PdTypes.ADDRESS_STREET,
|
||||||
|
"(?iu:\\bул\\.|\\bулиц\\p{L}*|\\bпр-т|\\bпроспект\\p{L}*|\\bпер\\.|\\bпереул\\p{L}*"
|
||||||
|
+ "|\\bш\\.|\\bшоссе|\\bб-р|\\bбульвар\\p{L}*|\\bнаб\\.|\\bнабережн\\p{L}*)"
|
||||||
|
+ "\\W{0,3}("
|
||||||
|
+ STREET_NAME
|
||||||
|
+ ")",
|
||||||
|
73)
|
||||||
|
.groups(1)
|
||||||
|
.vetoedBy(ORGANISATION_NEARBY)
|
||||||
|
.requiringNear(ADDRESS_NEARBY)
|
||||||
|
.anchoredBy("ул", "просп", "пр-т", "пер.", "шоссе", "ш.", "бульвар", "б-р", "наб"),
|
||||||
|
|
||||||
|
// «Невский пр-т» — указатель после названия. Форма слишком общая, поэтому
|
||||||
|
// принимается только рядом с другими частями адреса: иначе под маску попал бы
|
||||||
|
// любой рассказ про Невский проспект.
|
||||||
|
Rule.of(
|
||||||
|
PdTypes.ADDRESS_STREET,
|
||||||
|
"\\b(\\p{Lu}[\\p{L}-]{2,30})\\s+"
|
||||||
|
+ "(?iu:пр-т|проспект|улиц\\p{L}*|шоссе|бульвар|переул\\p{L}*|набережн\\p{L}*)\\b",
|
||||||
|
73)
|
||||||
|
.groups(1)
|
||||||
|
.vetoedBy(ORGANISATION_NEARBY)
|
||||||
|
.requiringNear(ADDRESS_NEARBY)
|
||||||
|
.anchoredBy("пр-т", "проспект", "улиц", "шоссе", "бульвар", "переул", "набережн"),
|
||||||
|
Rule.of(
|
||||||
|
PdTypes.ADDRESS_HOUSE,
|
||||||
|
"(?iu:\\bд\\.|\\bдом)\\s?(\\d+\\p{L}?(?:\\s?(?iu:к\\.|корп\\.?|стр\\.)\\s?\\d+)?)\\b",
|
||||||
|
72)
|
||||||
|
.groups(1)
|
||||||
|
.vetoedBy(ORGANISATION_NEARBY)
|
||||||
|
.anchoredBy("д.", "дом"),
|
||||||
|
Rule.of(PdTypes.ADDRESS_FLAT, "(?iu:\\bкв\\.|\\bквартир\\p{L}*)\\s?(\\d+\\p{L}?)\\b", 72)
|
||||||
|
.groups(1)
|
||||||
|
.vetoedBy(ORGANISATION_NEARBY)
|
||||||
|
.anchoredBy("кв"),
|
||||||
|
Rule.of(
|
||||||
|
PdTypes.ADDRESS_COUNTRY,
|
||||||
|
"(?iu:стран\\p{L}*(?:\\s+(?:регистрации|проживания|гражданства))?)"
|
||||||
|
+ "\\W{0,5}(\\p{Lu}[\\p{L}-]{2,30})\\b",
|
||||||
|
71)
|
||||||
|
.groups(1)
|
||||||
|
.vetoedBy(ORGANISATION_NEARBY)
|
||||||
|
.anchoredBy("стран"));
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
package ru.pdguard.detect;
|
||||||
|
|
||||||
|
import static ru.pdguard.detect.RulePatterns.ROLE_GAP;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/** Правила распознавания контактных и идентификационных данных: телефон, email, ИНН, СНИЛС. */
|
||||||
|
final class ContactRules {
|
||||||
|
|
||||||
|
private ContactRules() {}
|
||||||
|
|
||||||
|
static final List<Rule> RULES =
|
||||||
|
List.of(
|
||||||
|
Rule.of(PdTypes.INN, "(?iu)\\bИНН\\b" + ROLE_GAP + "(\\d{12}|\\d{10})\\b", 84)
|
||||||
|
.groups(1)
|
||||||
|
.anchoredBy("инн"),
|
||||||
|
|
||||||
|
// «ИНН/КПП 7712345671/771201001» — ИНН юрлица перед КПП через слэш.
|
||||||
|
Rule.of(PdTypes.INN, "(?iu)\\bИНН\\s*/\\s*КПП\\b\\W{0,5}(\\d{10})\\b", 84)
|
||||||
|
.groups(1)
|
||||||
|
.anchoredBy("инн/кпп"),
|
||||||
|
Rule.of(
|
||||||
|
PdTypes.SNILS,
|
||||||
|
"(?iu)(?:\\bСНИЛС\\b\\D{0,10})?(\\d{3}[ -]\\d{3}[ -]\\d{3}[ -]\\d{2})\\b",
|
||||||
|
84)
|
||||||
|
.groups(1)
|
||||||
|
.validatedBy(Validators::snils),
|
||||||
|
|
||||||
|
// \b7, не только +7: номер без плюса («79031119955») тоже встречается.
|
||||||
|
Rule.of(
|
||||||
|
PdTypes.PHONE,
|
||||||
|
"(?:\\+7|\\b7|\\b8)[ ()-]{0,3}\\d{3}[ ()-]{0,3}\\d{3}[ -]{0,2}\\d{2}["
|
||||||
|
+ " -]{0,2}\\d{2}\\b",
|
||||||
|
82),
|
||||||
|
Rule.of(PdTypes.EMAIL, "\\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\\.[A-Za-z]{2,}\\b", 80)
|
||||||
|
.anchoredBy("@"),
|
||||||
|
|
||||||
|
// ИНН физлица без якорного слова — только с верной контрольной суммой.
|
||||||
|
Rule.of(PdTypes.INN, "\\b\\d{12}\\b", 62).validatedBy(Validators::inn));
|
||||||
|
}
|
||||||
@@ -4,35 +4,34 @@ import java.util.Locale;
|
|||||||
import java.util.Set;
|
import java.util.Set;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Словарь названий стран — проверка того, что значение, пойманное правилом
|
* Словарь названий стран — проверка того, что значение, пойманное правилом {@code CITIZENSHIP},
|
||||||
* {@code CITIZENSHIP}, действительно похоже на страну, а не на произвольное
|
* действительно похоже на страну, а не на произвольное слово с заглавной буквы после якоря
|
||||||
* слово с заглавной буквы после якоря «гражданство».
|
* «гражданство».
|
||||||
*
|
*
|
||||||
* <p>Сравнение по началу слова, а не точным совпадением: падежные окончания
|
* <p>Сравнение по началу слова, а не точным совпадением: падежные окончания («в России», «из
|
||||||
* («в России», «из Казахстана») и формы прилагательных («российская»,
|
* Казахстана») и формы прилагательных («российская», «российское») тем самым покрываются без
|
||||||
* «российское») тем самым покрываются без отдельного разбора морфологии.
|
* отдельного разбора морфологии. Основа «российск» покрывает и «Российская», и «российская», и
|
||||||
* Основа «российск» покрывает и «Российская», и «российская», и «российское».
|
* «российское».
|
||||||
*/
|
*/
|
||||||
public final class CountryDictionary {
|
public final class CountryDictionary {
|
||||||
|
|
||||||
private static final Set<String> COUNTRY_STEMS = ResourceLoader.set("/names/countries.txt");
|
private static final Set<String> COUNTRY_STEMS = ResourceLoader.set("/names/countries.txt");
|
||||||
|
|
||||||
private CountryDictionary() {
|
private CountryDictionary() {}
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Похоже ли значение на название страны из словаря в любом падеже и регистре.
|
* Похоже ли значение на название страны из словаря в любом падеже и регистре.
|
||||||
*
|
*
|
||||||
* <p>Проверяются префиксы значения по множеству, а не каждая основа по
|
* <p>Проверяются префиксы значения по множеству, а не каждая основа по значению: префиксов у
|
||||||
* значению: префиксов у слова не больше, чем в нём букв.
|
* слова не больше, чем в нём букв.
|
||||||
*/
|
*/
|
||||||
public static boolean isKnownCountry(String value) {
|
public static boolean isKnownCountry(String value) {
|
||||||
String lower = value.strip().toLowerCase(Locale.ROOT);
|
String lower = value.strip().toLowerCase(Locale.ROOT);
|
||||||
for (int length = lower.length(); length > 0; length--) {
|
for (int length = lower.length(); length > 0; length--) {
|
||||||
if (COUNTRY_STEMS.contains(lower.substring(0, length))) {
|
if (COUNTRY_STEMS.contains(lower.substring(0, length))) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
}
|
|
||||||
return false;
|
|
||||||
}
|
}
|
||||||
}
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,46 @@
|
|||||||
|
package ru.pdguard.detect;
|
||||||
|
|
||||||
|
import static ru.pdguard.detect.RulePatterns.DATE_ANY;
|
||||||
|
import static ru.pdguard.detect.RulePatterns.DATE_GAP;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/** Правила распознавания дат: рождения, выдачи документа и дат без якорного слова. */
|
||||||
|
final class DateRules {
|
||||||
|
|
||||||
|
private DateRules() {}
|
||||||
|
|
||||||
|
static final List<Rule> RULES =
|
||||||
|
List.of(
|
||||||
|
Rule.of(
|
||||||
|
PdTypes.BIRTH_DATE,
|
||||||
|
"(?iu:дат\\p{L}*\\s+рождения|дата\\s+рожд\\.)" + DATE_GAP + "(" + DATE_ANY + ")",
|
||||||
|
87)
|
||||||
|
.groups(1)
|
||||||
|
.validatedBy(Validators::date)
|
||||||
|
.anchoredBy("рожден"),
|
||||||
|
Rule.of(PdTypes.BIRTH_DATE, "(?iu:родил(?:ся|ась))" + DATE_GAP + "(" + DATE_ANY + ")", 87)
|
||||||
|
.groups(1)
|
||||||
|
.validatedBy(Validators::date)
|
||||||
|
.anchoredBy("родил"),
|
||||||
|
Rule.of(
|
||||||
|
PdTypes.BIRTH_DATE,
|
||||||
|
"(" + DATE_ANY + ")\\s*(?iu:г\\.\\s?р\\.|г/р|года\\s+рождения)",
|
||||||
|
87)
|
||||||
|
.groups(1)
|
||||||
|
.validatedBy(Validators::date)
|
||||||
|
.anchoredBy("г.р", "г/р", "года рождения"),
|
||||||
|
|
||||||
|
// «дата выдачи 12.05.2015» и «дата выдачи паспорта 12.05.2015»
|
||||||
|
Rule.of(
|
||||||
|
PdTypes.PASSPORT_DATE,
|
||||||
|
"(?iu:дат\\p{L}*\\s+выдачи)" + DATE_GAP + "(" + DATE_ANY + ")",
|
||||||
|
87)
|
||||||
|
.groups(1)
|
||||||
|
.validatedBy(Validators::date)
|
||||||
|
.anchoredBy("выдач"),
|
||||||
|
|
||||||
|
// Дата без якорного слова персональными данными сама по себе не является:
|
||||||
|
// маскируется, только если в тексте есть ПД другого типа.
|
||||||
|
Rule.of(PdTypes.DATE, DATE_ANY, 58).validatedBy(Validators::date));
|
||||||
|
}
|
||||||
@@ -3,51 +3,47 @@ package ru.pdguard.detect;
|
|||||||
import java.util.Locale;
|
import java.util.Locale;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Общий приём для словарей, сравнивающих слово из текста с основой из списка:
|
* Общий приём для словарей, сравнивающих слово из текста с основой из списка: личные имена ({@link
|
||||||
* личные имена ({@link NameDictionary}) и города ({@link ToponymDictionary}).
|
* NameDictionary}) и города ({@link ToponymDictionary}).
|
||||||
*
|
*
|
||||||
* <p>Слова на согласную склоняются добавлением окончания («Тамбов» → «Тамбове»,
|
* <p>Слова на согласную склоняются добавлением окончания («Тамбов» → «Тамбове», «Пушкин» →
|
||||||
* «Пушкин» → «Пушкина») — там основы из списка достаточно как есть. Слова на
|
* «Пушкина») — там основы из списка достаточно как есть. Слова на гласную меняют последнюю букву
|
||||||
* гласную меняют последнюю букву («Москва» → «Москве», «Ольга» → «Ольге») —
|
* («Москва» → «Москве», «Ольга» → «Ольге») — для них сравнение идёт по основе без неё.
|
||||||
* для них сравнение идёт по основе без неё.
|
|
||||||
*
|
*
|
||||||
* <p>Фамилии на «-ский» склоняются как прилагательное: окончание меняется
|
* <p>Фамилии на «-ский» склоняются как прилагательное: окончание меняется целиком («Дзержинский» →
|
||||||
* целиком («Дзержинский» → «Дзержинского», «-ий» на «-ого», а не дописывается),
|
* «Дзержинского», «-ий» на «-ого», а не дописывается), поэтому для них отсечения одной буквы
|
||||||
* поэтому для них отсечения одной буквы недостаточно — основа обрезается сразу
|
* недостаточно — основа обрезается сразу до «ск». Для улиц в честь людей это не редкий случай, а
|
||||||
* до «ск». Для улиц в честь людей это не редкий случай, а основной: «улица
|
* основной: «улица Дзержинского», «улица Островского» пишутся только в родительном падеже,
|
||||||
* Дзержинского», «улица Островского» пишутся только в родительном падеже,
|
|
||||||
* именительный там не встречается вообще.
|
* именительный там не встречается вообще.
|
||||||
*/
|
*/
|
||||||
final class Declension {
|
final class Declension {
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Падежные окончания прилагательного склонения на «-ск-»: мужской, женский
|
* Падежные окончания прилагательного склонения на «-ск-»: мужской, женский и средний род, все
|
||||||
* и средний род, все падежи. Проверяются от длинных к коротким — «-ского»
|
* падежи. Проверяются от длинных к коротким — «-ского» не должно потеряться из-за более короткого
|
||||||
* не должно потеряться из-за более короткого совпадения на «-ким» и т.п.
|
* совпадения на «-ким» и т.п.
|
||||||
*/
|
*/
|
||||||
private static final String[] ADJECTIVE_ENDINGS = {
|
private static final String[] ADJECTIVE_ENDINGS = {
|
||||||
"ского", "скому", "ским", "ском", "скую", "ской", "скою", "ская", "ский"
|
"ского", "скому", "ским", "ском", "скую", "ской", "скою", "ская", "ский"
|
||||||
};
|
};
|
||||||
|
|
||||||
private Declension() {
|
private Declension() {}
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Отбрасывает у основы окончание, которое меняется по падежам: гласную —
|
* Отбрасывает у основы окончание, которое меняется по падежам: гласную — у обычных слов, целиком
|
||||||
* у обычных слов, целиком «-ск-»-окончание — у прилагательных фамилий.
|
* «-ск-»-окончание — у прилагательных фамилий. Слова короче четырёх букв не трогает — короткая
|
||||||
* Слова короче четырёх букв не трогает — короткая основа и так шире
|
* основа и так шире большинства падежных форм.
|
||||||
* большинства падежных форм.
|
*/
|
||||||
*/
|
static String withoutInflectedEnding(String word) {
|
||||||
static String withoutInflectedEnding(String word) {
|
String lower = word.toLowerCase(Locale.ROOT);
|
||||||
String lower = word.toLowerCase(Locale.ROOT);
|
for (String ending : ADJECTIVE_ENDINGS) {
|
||||||
for (String ending : ADJECTIVE_ENDINGS) {
|
if (lower.length() > ending.length() && lower.endsWith(ending)) {
|
||||||
if (lower.length() > ending.length() && lower.endsWith(ending)) {
|
return lower.substring(0, lower.length() - ending.length() + 2);
|
||||||
return lower.substring(0, lower.length() - ending.length() + 2);
|
}
|
||||||
}
|
|
||||||
}
|
|
||||||
if (lower.length() >= 4 && "аяйь".indexOf(lower.charAt(lower.length() - 1)) >= 0) {
|
|
||||||
return lower.substring(0, lower.length() - 1);
|
|
||||||
}
|
|
||||||
return lower;
|
|
||||||
}
|
}
|
||||||
|
if (lower.length() >= 4 && "аяйь".indexOf(lower.charAt(lower.length() - 1)) >= 0) {
|
||||||
|
return lower.substring(0, lower.length() - 1);
|
||||||
|
}
|
||||||
|
return lower;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,113 @@
|
|||||||
|
package ru.pdguard.detect;
|
||||||
|
|
||||||
|
import static ru.pdguard.detect.RulePatterns.ROLE_GAP;
|
||||||
|
import static ru.pdguard.detect.RulePatterns.SERIES_AND_NUMBER;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/** Правила распознавания документов, удостоверяющих личность, и кодов подразделений. */
|
||||||
|
final class DocumentRules {
|
||||||
|
|
||||||
|
private DocumentRules() {}
|
||||||
|
|
||||||
|
static final List<Rule> RULES =
|
||||||
|
List.of(
|
||||||
|
|
||||||
|
// CVV: латиница, кириллическая транслитерация («цвв», «сививи») и
|
||||||
|
// описательные якоря («код на обороте карты»). Между якорем и числом
|
||||||
|
// допускаются слова («CVV код 321», «CVV указан код 123») и длинные
|
||||||
|
// разделители («код на обороте карты 789»).
|
||||||
|
Rule.of(
|
||||||
|
PdTypes.CVV,
|
||||||
|
"(?iu:\\b(?:cvv2?|cvc2?|цвв|сививи|код\\p{L}*\\s+на\\s+обороте\\s+карты"
|
||||||
|
+ "|код\\s+проверки|защитный\\s+код)\\b)"
|
||||||
|
+ "(?:\\s+\\p{L}+){0,2}\\W{0,30}(\\d{3,4})\\b",
|
||||||
|
92)
|
||||||
|
.groups(1)
|
||||||
|
.anchoredBy(
|
||||||
|
"cvv", "cvc", "цвв", "сививи", "код на обороте", "код проверки", "защитный код"),
|
||||||
|
|
||||||
|
// PIN: «пин-код», «пин код», «ПИН:», «пин 3456». Между якорем и числом
|
||||||
|
// допускаются слова («ПИН-код карты 2468») и длинные разделители
|
||||||
|
// («Пин Код: 1234»).
|
||||||
|
Rule.of(
|
||||||
|
PdTypes.PIN,
|
||||||
|
"(?iu:\\b(?:пин[\\s-]?кода?|pin[\\s-]?code|пин|pin)\\b)"
|
||||||
|
+ "(?:\\s+\\p{L}+){0,2}\\W{0,30}(\\d{4,6})\\b",
|
||||||
|
92)
|
||||||
|
.groups(1)
|
||||||
|
.anchoredBy("пин", "pin"),
|
||||||
|
|
||||||
|
// «паспорт 4509 123456», «паспорт гражданина РФ 45 09 123456»
|
||||||
|
Rule.of(
|
||||||
|
PdTypes.PASSPORT,
|
||||||
|
"(?iu:паспорт)\\w*(?:\\W+(?iu:гражданина\\s+РФ|РФ|России|Российской\\s+Федерации))?"
|
||||||
|
+ "\\W{0,10}("
|
||||||
|
+ SERIES_AND_NUMBER
|
||||||
|
+ ")\\b",
|
||||||
|
90)
|
||||||
|
.groups(1)
|
||||||
|
.anchoredBy("паспорт"),
|
||||||
|
|
||||||
|
// «серия 4509 номер 123456», «серии 45 09 № 123456»
|
||||||
|
// Между серией и номером помещается слово: «серия 4509 номер 123456»,
|
||||||
|
// «серии 4509 за номером 123456», «серия 4509 № 123456».
|
||||||
|
Rule.of(
|
||||||
|
PdTypes.PASSPORT,
|
||||||
|
"(?iu:сери)\\w{0,3}\\W{0,5}(\\d{2}\\s?\\d{2})[^\\d]{0,20}(\\d{6})\\b",
|
||||||
|
90)
|
||||||
|
.groups(1, 2)
|
||||||
|
.anchoredBy("сери"),
|
||||||
|
|
||||||
|
// Необязательное «серия»/«серии» между якорем и цифрами: «ВУ серия 12 34 номер 567890».
|
||||||
|
Rule.of(
|
||||||
|
PdTypes.DRIVER_LICENSE,
|
||||||
|
"(?iu:водительск\\w+\\s+удостоверени\\w+|в/у|вод\\.\\s?удост\\w*|\\bВУ)\\b"
|
||||||
|
+ "\\W{0,15}(?:(?iu:сери\\w{0,3})\\W{0,5})?("
|
||||||
|
+ SERIES_AND_NUMBER
|
||||||
|
+ ")\\b",
|
||||||
|
89)
|
||||||
|
.groups(1)
|
||||||
|
.anchoredBy("водительск", "в/у", "вод.", "ву "),
|
||||||
|
Rule.of(
|
||||||
|
PdTypes.FOREIGN_PASSPORT,
|
||||||
|
"(?iu:загранпаспорт|заграничн\\p{L}*\\s+паспорт)\\p{L}*"
|
||||||
|
+ "\\W{0,10}(\\d{2}\\s?\\d{7})\\b",
|
||||||
|
89)
|
||||||
|
.groups(1)
|
||||||
|
.anchoredBy("загранпаспорт", "заграничн"),
|
||||||
|
Rule.of(
|
||||||
|
PdTypes.MILITARY_ID,
|
||||||
|
"(?iu:военн\\p{L}*\\s+билет)\\p{L}*" + "\\W{0,10}(\\p{Lu}{2}\\s?\\d{7})\\b",
|
||||||
|
89)
|
||||||
|
.groups(1)
|
||||||
|
.anchoredBy("военн"),
|
||||||
|
Rule.of(
|
||||||
|
PdTypes.BIRTH_CERTIFICATE,
|
||||||
|
"(?iu:свидетельств\\p{L}*\\s+о\\s+рождении)"
|
||||||
|
+ "\\W{0,15}([IVXLC]{1,4}[- ]?\\p{Lu}{2}\\s?(?:№\\s?)?\\d{6})\\b",
|
||||||
|
89)
|
||||||
|
.groups(1)
|
||||||
|
.anchoredBy("свидетельств"),
|
||||||
|
Rule.of(PdTypes.MEDICAL_POLICY, "(?iu:полис\\p{L}*(?:\\s+ОМС)?)\\W{0,10}(\\d{16})\\b", 89)
|
||||||
|
.groups(1)
|
||||||
|
.anchoredBy("полис"),
|
||||||
|
|
||||||
|
// ROLE_GAP, не \W{0,5}: «код подразделения стоит 001-000» — между якорем и
|
||||||
|
// значением есть слово («стоит»/«объекта»), не только пунктуация.
|
||||||
|
Rule.of(
|
||||||
|
PdTypes.DEPT_CODE,
|
||||||
|
"(?iu:код\\w*\\s+подразделения|к/п)" + ROLE_GAP + "(\\d{3}\\s?-?\\s?\\d{3})\\b",
|
||||||
|
88)
|
||||||
|
.groups(1)
|
||||||
|
.anchoredBy("подразделени", "к/п"),
|
||||||
|
|
||||||
|
// «770-001 — таков код подразделения» — значение перед якорем.
|
||||||
|
Rule.of(
|
||||||
|
PdTypes.DEPT_CODE,
|
||||||
|
"\\b(\\d{3}\\s?-?\\s?\\d{3})\\b\\s*[—-]\\s*(?:\\p{L}+\\s+){0,3}"
|
||||||
|
+ "(?iu:код\\w*\\s+подразделения)",
|
||||||
|
88)
|
||||||
|
.groups(1)
|
||||||
|
.anchoredBy("подразделени"));
|
||||||
|
}
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
package ru.pdguard.detect;
|
||||||
|
|
||||||
|
import static ru.pdguard.detect.RulePatterns.HOLDER_STEM;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/** Правила распознавания банковских реквизитов, карты, ОГРН/КПП и держателя карты. */
|
||||||
|
final class FinanceRules {
|
||||||
|
|
||||||
|
private FinanceRules() {}
|
||||||
|
|
||||||
|
static final List<Rule> RULES =
|
||||||
|
List.of(
|
||||||
|
|
||||||
|
// Расчётный счёт — ровно 20 цифр после якоря, группировка пробелами не важна.
|
||||||
|
Rule.of(
|
||||||
|
PdTypes.ACCOUNT_NUMBER,
|
||||||
|
"(?iu:р/с|расчетн\\w*\\s+счет|расчётн\\w*\\s+счёт|лицев\\w*\\s+счет|"
|
||||||
|
+ "лицев\\w*\\s+счёт)\\W{0,5}((?:\\d[ ]?){19}\\d)\\b",
|
||||||
|
83)
|
||||||
|
.groups(1)
|
||||||
|
.anchoredBy("р/с", "расчетн", "расчётн", "лицев"),
|
||||||
|
Rule.of(PdTypes.BIK, "(?iu:бик)\\W{0,5}(\\d{9})\\b", 83).groups(1).anchoredBy("бик"),
|
||||||
|
|
||||||
|
// «действительна до 09/27», «exp 09/27» — срок действия карты, не дата рождения.
|
||||||
|
Rule.of(
|
||||||
|
PdTypes.CARD_EXPIRY,
|
||||||
|
"(?iu:срок\\s+действия|действительна?\\s+до|\\bexp\\w*)\\W{0,5}"
|
||||||
|
+ "(\\d{2}\\s?/\\s?\\d{2})\\b",
|
||||||
|
83)
|
||||||
|
.groups(1)
|
||||||
|
.anchoredBy("срок действия", "действительн", "exp"),
|
||||||
|
|
||||||
|
// ОГРНИП раньше ОГРН: без отрицательного просмотра «ОГРНИП» частично ловился бы
|
||||||
|
// ещё и правилом ОГРН. Контрольная сумма отсекает случайные 13/15-значные
|
||||||
|
// числа рядом со словом — раньше якоря было достаточно самого по себе.
|
||||||
|
Rule.of(PdTypes.OGRNIP, "(?iu:огрнип)\\W{0,5}(\\d{15})\\b", 83)
|
||||||
|
.groups(1)
|
||||||
|
.validatedBy(Validators::ogrnip)
|
||||||
|
.anchoredBy("огрнип"),
|
||||||
|
Rule.of(PdTypes.OGRN, "(?iu:огрн(?!ип))\\W{0,5}(\\d{13})\\b", 83)
|
||||||
|
.groups(1)
|
||||||
|
.validatedBy(Validators::ogrn)
|
||||||
|
.anchoredBy("огрн"),
|
||||||
|
Rule.of(PdTypes.KPP, "(?iu:кпп)\\W{0,5}(\\d{9})\\b", 83).groups(1).anchoredBy("кпп"),
|
||||||
|
|
||||||
|
// Доход/зарплата: сумма с разделителями тысяч. Между якорем и суммой может
|
||||||
|
// стоять слово («доход клиента», «доход за год») — без этого якорь ловил
|
||||||
|
// бы только «доход 85000», вплотную.
|
||||||
|
Rule.of(
|
||||||
|
PdTypes.INCOME,
|
||||||
|
"(?iu:доход|заработн\\w*\\s+плат\\w*|зарплат\\w*)(?:\\s+\\p{L}+){0,3}?"
|
||||||
|
+ "\\W{0,5}(\\d{1,3}(?:[\\s.]?\\d{3})*(?:,\\d{2})?)\\s?(?iu:руб\\p{L}*|₽)?\\b",
|
||||||
|
76)
|
||||||
|
.groups(1)
|
||||||
|
.anchoredBy("доход", "заработн", "зарплат"),
|
||||||
|
|
||||||
|
// Биометрия — сама фраза уже говорит, что дальше персональные данные, отдельного
|
||||||
|
// значения для захвата нет: маскируется якорная фраза целиком.
|
||||||
|
Rule.of(
|
||||||
|
PdTypes.BIOMETRIC,
|
||||||
|
"(?iu:биометрическ\\w*\\s+(?:данны\\w*|образц\\w*|шаблон\\w*)"
|
||||||
|
+ "|слепок\\s+голоса|отпечаток\\s+пальца|скан\\s+лица|\\bЕБС\\b)",
|
||||||
|
81)
|
||||||
|
.anchoredBy("биометри", "слепок голоса", "отпечаток пальца", "скан лица", "ебс"),
|
||||||
|
Rule.of(
|
||||||
|
PdTypes.CARDHOLDER,
|
||||||
|
"(?iu:держател\\w*(?:\\s+карты)?|cardholder|на\\s+имя)"
|
||||||
|
+ "\\W{0,10}([A-Z]{2,20}\\s+[A-Z]{2,20})\\b",
|
||||||
|
86)
|
||||||
|
.groups(1)
|
||||||
|
.anchoredBy(HOLDER_STEM, "cardholder", "на имя"),
|
||||||
|
|
||||||
|
// --- Уровень 1: подтверждается контрольной суммой ---
|
||||||
|
|
||||||
|
Rule.of(PdTypes.CARD, "\\b\\d(?:[ -]?\\d){11,18}\\b", 85).validatedBy(Validators::luhn));
|
||||||
|
}
|
||||||
@@ -0,0 +1,152 @@
|
|||||||
|
package ru.pdguard.detect;
|
||||||
|
|
||||||
|
import static ru.pdguard.detect.RulePatterns.CAPITALISED;
|
||||||
|
import static ru.pdguard.detect.RulePatterns.HOLDER_STEM;
|
||||||
|
import static ru.pdguard.detect.RulePatterns.ORGANISATION_NEARBY;
|
||||||
|
import static ru.pdguard.detect.RulePatterns.PATRONYMIC;
|
||||||
|
import static ru.pdguard.detect.RulePatterns.SURNAME;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/** Правила распознавания ФИО — от полной тройки с ролевым словом до одиночного имени по словарю. */
|
||||||
|
final class FioRules {
|
||||||
|
|
||||||
|
private FioRules() {}
|
||||||
|
|
||||||
|
static final List<Rule> RULES =
|
||||||
|
List.of(
|
||||||
|
|
||||||
|
// Фамилия Имя Отчество: первое слово опознаётся по словообразованию фамилии.
|
||||||
|
// Свободная тройка «любое слово с заглавной + имя + отчество» здесь
|
||||||
|
// сознательно не используется: она захватывает глагол в начале
|
||||||
|
// предложения («Пригласите Ивана Сергеевича») и заметно дороже по времени.
|
||||||
|
// Фамилии без привычного окончания — Ким, Цой — ловятся по ролевому слову.
|
||||||
|
Rule.of(
|
||||||
|
PdTypes.FIO,
|
||||||
|
"\\b" + SURNAME + "\\s+" + CAPITALISED + "\\s+" + PATRONYMIC + "\\b",
|
||||||
|
79),
|
||||||
|
|
||||||
|
// Имя Отчество Фамилия — второй распространённый порядок слов.
|
||||||
|
Rule.of(
|
||||||
|
PdTypes.FIO,
|
||||||
|
"\\b" + CAPITALISED + "\\s+" + PATRONYMIC + "\\s+" + SURNAME + "\\b",
|
||||||
|
79),
|
||||||
|
|
||||||
|
// Иванов И.И. и И.И. Иванов
|
||||||
|
Rule.of(PdTypes.FIO, "\\b" + SURNAME + "\\s+\\p{Lu}\\.\\s?\\p{Lu}\\.", 79),
|
||||||
|
Rule.of(PdTypes.FIO, "\\b\\p{Lu}\\.\\s?\\p{Lu}\\.\\s?" + SURNAME + "\\b", 79),
|
||||||
|
|
||||||
|
// Имя Отчество без фамилии
|
||||||
|
Rule.of(PdTypes.FIO, "\\b" + CAPITALISED + "\\s+" + PATRONYMIC + "\\b", 77),
|
||||||
|
|
||||||
|
// «ФИО: иванов иван иванович» — явный якорь снимает требование к регистру
|
||||||
|
Rule.of(
|
||||||
|
PdTypes.FIO,
|
||||||
|
"(?iu:\\bФИО|\\bф\\.\\s?и\\.\\s?о\\.|\\bна\\s+имя)"
|
||||||
|
+ "(?:\\s+\\p{L}+)?\\W{0,5}(\\p{L}{2,}(?:\\s+\\p{L}{2,}){0,2})\\b",
|
||||||
|
77)
|
||||||
|
.groups(1)
|
||||||
|
.anchoredBy("фио", "ф.и.о", "на имя"),
|
||||||
|
|
||||||
|
// «клиент Иванов Иван», «плательщик Петрова»
|
||||||
|
Rule.of(
|
||||||
|
PdTypes.FIO,
|
||||||
|
"(?iu:\\bклиент|\\bзаказчик|\\bпациент|\\bсотрудник|\\bвладел|\\bплательщик"
|
||||||
|
+ "|\\bполучател|\\bабонент|\\bв\\s+лице|\\bпредставител|\\bпоручител"
|
||||||
|
+ "|\\bсозаёмщик|\\bсозаемщик|\\bзаёмщик|\\bзаемщик|\\bзаявител|\\bдоверител"
|
||||||
|
+ "|\\bвкладчик|\\bответственн|\\bконтактное\\s+лицо|\\bисполнител|\\bдержател)\\p{L}*"
|
||||||
|
+ "\\W{0,5}(\\p{Lu}\\p{Ll}+(?:\\s+\\p{Lu}\\p{Ll}+){0,2})\\b",
|
||||||
|
77)
|
||||||
|
.groups(1)
|
||||||
|
.anchoredBy(
|
||||||
|
"клиент",
|
||||||
|
"заказчик",
|
||||||
|
"пациент",
|
||||||
|
"сотрудник",
|
||||||
|
"владел",
|
||||||
|
"плательщик",
|
||||||
|
"получател",
|
||||||
|
"абонент",
|
||||||
|
"в лице",
|
||||||
|
"представител",
|
||||||
|
"поручител",
|
||||||
|
"заёмщик",
|
||||||
|
"заемщик",
|
||||||
|
"заявител",
|
||||||
|
"доверител",
|
||||||
|
"вкладчик",
|
||||||
|
"ответственн",
|
||||||
|
"контактное лицо",
|
||||||
|
"исполнител",
|
||||||
|
HOLDER_STEM),
|
||||||
|
|
||||||
|
// «клиент иван иванов», «поручитель петрович» — строчные имена после
|
||||||
|
// ролевого слова. Регистр снимает требование к заглавной букве, а словарь
|
||||||
|
// имён отсекает «клиент пришёл в офис».
|
||||||
|
Rule.of(
|
||||||
|
PdTypes.FIO,
|
||||||
|
"(?iu:\\bклиент|\\bзаказчик|\\bпациент|\\bсотрудник|\\bвладел|\\bплательщик"
|
||||||
|
+ "|\\bполучател|\\bабонент|\\bв\\s+лице|\\bпредставител|\\bпоручител"
|
||||||
|
+ "|\\bсозаёмщик|\\bсозаемщик|\\bзаёмщик|\\bзаемщик|\\bзаявител|\\bдоверител"
|
||||||
|
+ "|\\bвкладчик|\\bответственн|\\bконтактное\\s+лицо|\\bисполнител|\\bдержател"
|
||||||
|
+ "|\\bотправител|\\bбенефициар|\\bдоверенное\\s+лицо|\\bнаследник|\\bсозаемщик"
|
||||||
|
// \p{L}*+ (possessive), не \p{L}*: без possessive откат назад позволял
|
||||||
|
// движку «отдать» уже съеденное падежное окончание ролевого слова и
|
||||||
|
// захватить его как будто отдельное имя — «пациентов» ловилось бы как «ов».
|
||||||
|
+ "|\\bпоручител)\\p{L}*+"
|
||||||
|
+ "(?:\\s+\\p{L}+){0,3}\\W{0,5}(\\p{L}{2,}(?:\\s+\\p{L}{2,}){0,2}(?:\\s+\\p{Lu}\\.){0,2})(?![\\p{L}.])",
|
||||||
|
77)
|
||||||
|
.groups(1)
|
||||||
|
.validatedBy(NameDictionary::containsNamePart)
|
||||||
|
.anchoredBy(
|
||||||
|
"клиент",
|
||||||
|
"заказчик",
|
||||||
|
"пациент",
|
||||||
|
"сотрудник",
|
||||||
|
"владел",
|
||||||
|
"плательщик",
|
||||||
|
"получател",
|
||||||
|
"абонент",
|
||||||
|
"в лице",
|
||||||
|
"представител",
|
||||||
|
"поручител",
|
||||||
|
"заёмщик",
|
||||||
|
"заемщик",
|
||||||
|
"заявител",
|
||||||
|
"доверител",
|
||||||
|
"вкладчик",
|
||||||
|
"ответственн",
|
||||||
|
"контактное лицо",
|
||||||
|
"исполнител",
|
||||||
|
HOLDER_STEM,
|
||||||
|
"отправител",
|
||||||
|
"бенефициар",
|
||||||
|
"доверенное лицо",
|
||||||
|
"наследник",
|
||||||
|
"созаемщик"),
|
||||||
|
|
||||||
|
// Фамилия рядом с личным именем из словаря: без словаря правило ловило бы
|
||||||
|
// «Тверская улица» и тому подобное. Имя проверяется по множеству уже
|
||||||
|
// после совпадения — чередование из ста веток в шаблоне обходится дорого.
|
||||||
|
// Самое слабое основание среди правил ФИО — ни ролевого слова, ни явного
|
||||||
|
// якоря, — поэтому именно здесь нужно вето на адресный контекст: «Великие
|
||||||
|
// Луки» (реальный город) распознаётся как имя «Лука» в падеже плюс
|
||||||
|
// случайное слово, «Богдана Хмельницкого» — улица в честь исторической
|
||||||
|
// фигуры. Найдено на реальных адресах отделений из реестра ЦБ.
|
||||||
|
Rule.of(PdTypes.FIO, "\\b" + SURNAME + "\\s+" + CAPITALISED + "\\b", 74)
|
||||||
|
.validatedBy(NameDictionary::containsGivenName)
|
||||||
|
.vetoedBy(ORGANISATION_NEARBY),
|
||||||
|
Rule.of(PdTypes.FIO, "\\b" + CAPITALISED + "\\s+" + SURNAME + "\\b", 74)
|
||||||
|
.validatedBy(NameDictionary::containsGivenName)
|
||||||
|
.vetoedBy(ORGANISATION_NEARBY),
|
||||||
|
|
||||||
|
// Одиночное имя, фамилия или отчество: «Иванов», «иван», «петрович».
|
||||||
|
// Самое слабое основание среди правил ФИО — ни ролевого слова, ни пары
|
||||||
|
// слов, — поэтому приоритет ниже и проверка по словарю обязательна.
|
||||||
|
// Словарь отсекает «сочи», «казань» и прочие не-имена. Первое слово текста
|
||||||
|
// не рассматривается: заглавная буква там от начала предложения, а не от
|
||||||
|
// имени, и словообразовательная эвристика ложно ловит «Магазин», «Отдел».
|
||||||
|
Rule.of(PdTypes.FIO, "(?<!^)\\b(\\p{L}{2,})\\b", 70)
|
||||||
|
.groups(1)
|
||||||
|
.validatedBy(NameDictionary::isStandaloneNameCandidate));
|
||||||
|
}
|
||||||
@@ -5,284 +5,505 @@ import io.micrometer.core.instrument.MeterRegistry;
|
|||||||
import io.micrometer.core.instrument.Timer;
|
import io.micrometer.core.instrument.Timer;
|
||||||
import io.micrometer.core.instrument.simple.SimpleMeterRegistry;
|
import io.micrometer.core.instrument.simple.SimpleMeterRegistry;
|
||||||
import jakarta.annotation.PreDestroy;
|
import jakarta.annotation.PreDestroy;
|
||||||
|
import java.nio.file.Path;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.HashMap;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Locale;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.Optional;
|
||||||
|
import java.util.Set;
|
||||||
|
import java.util.concurrent.Semaphore;
|
||||||
|
import java.util.concurrent.TimeUnit;
|
||||||
|
import java.util.regex.Matcher;
|
||||||
|
import java.util.regex.Pattern;
|
||||||
import org.slf4j.Logger;
|
import org.slf4j.Logger;
|
||||||
import org.slf4j.LoggerFactory;
|
import org.slf4j.LoggerFactory;
|
||||||
import org.springframework.beans.factory.annotation.Autowired;
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
import org.springframework.beans.factory.annotation.Value;
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
import org.springframework.stereotype.Component;
|
import org.springframework.stereotype.Component;
|
||||||
|
import ru.pdguard.config.SystemPolicy;
|
||||||
import java.nio.file.Path;
|
|
||||||
import java.util.ArrayList;
|
|
||||||
import java.util.List;
|
|
||||||
import java.util.Locale;
|
|
||||||
import java.util.Map;
|
|
||||||
import java.util.Optional;
|
|
||||||
import java.util.concurrent.Semaphore;
|
|
||||||
import java.util.concurrent.TimeUnit;
|
|
||||||
import java.util.regex.Matcher;
|
|
||||||
import java.util.regex.Pattern;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Вторая ступень распознавания.
|
* Вторая ступень распознавания.
|
||||||
*
|
*
|
||||||
* <p>Правила и словарь разбирают подавляющее большинство случаев и стоят десятки
|
* <p>Правила и словарь разбирают подавляющее большинство случаев и стоят десятки микросекунд.
|
||||||
* микросекунд. Модель нужна там, где они бессильны: имена без русского
|
* Модель нужна там, где они бессильны: имена без русского словообразования и нестандартные
|
||||||
* словообразования и нестандартные топонимы.
|
* топонимы.
|
||||||
*
|
*
|
||||||
* <p>Поэтому модель зовут не на весь текст, а только на кандидатов — цепочки из
|
* <p>Поэтому модель зовут не на весь текст, а только на кандидатов — цепочки из двух-трёх слов с
|
||||||
* двух-трёх слов с заглавной буквы, которые первая ступень не покрыла. Их в обычном
|
* заглавной буквы, которые первая ступень не покрыла. Их в обычном запросе единицы, и на задержку
|
||||||
* запросе единицы, и на задержку это почти не влияет. Дороже модель — тем важнее
|
* это почти не влияет. Дороже модель — тем важнее такая экономия: у BERT вызов стоит десятки
|
||||||
* такая экономия: у BERT вызов стоит десятки миллисекунд, и звать его на каждый
|
* миллисекунд, и звать его на каждый запрос было бы невозможно.
|
||||||
* запрос было бы невозможно.
|
|
||||||
*
|
*
|
||||||
* <p>Используются две модели под разные задачи: одна размечает имена (например,
|
* <p>Используются две модели под разные задачи: одна размечает имена (например, WikiNEuRal, который
|
||||||
* WikiNEuRal, который не распознаёт известных личностей), другая — составляющие
|
* не распознаёт известных личностей), другая — составляющие адреса (например, ruBERT с детальными
|
||||||
* адреса (например, ruBERT с детальными метками страны, региона, района, города,
|
* метками страны, региона, района, города, улицы и дома). Каждая модель зовётся только на
|
||||||
* улицы и дома). Каждая модель зовётся только на непокрытые кандидаты.
|
* непокрытые кандидаты.
|
||||||
*
|
*
|
||||||
* <p>Ступень выключена, пока не задан движок. Сбой ступени на первую не влияет:
|
* <p>Ступень выключена, пока не задан движок. Сбой ступени на первую не влияет: ошибка
|
||||||
* ошибка перехватывается здесь, ступень выключается насовсем, и дальше работают
|
* перехватывается здесь, ступень выключается насовсем, и дальше работают правила. Иначе одно
|
||||||
* правила. Иначе одно исключение обнуляло бы маскирование целиком.
|
* исключение обнуляло бы маскирование целиком.
|
||||||
*/
|
*/
|
||||||
@Component
|
@Component
|
||||||
public class NameCascade {
|
public class NameCascade {
|
||||||
|
|
||||||
private static final Logger LOG = LoggerFactory.getLogger(NameCascade.class);
|
private static final Logger LOG = LoggerFactory.getLogger(NameCascade.class);
|
||||||
|
|
||||||
/** Имя метрики обращений ко второй ступени, её описание и имя метки исхода. */
|
/** Имя метрики обращений ко второй ступени, её описание и имя метки исхода. */
|
||||||
private static final String NER_REQUESTS_METRIC = "pdguard.ner.requests";
|
private static final String NER_REQUESTS_METRIC = "pdguard.ner.requests";
|
||||||
private static final String NER_REQUESTS_DESCRIPTION = "Обращения, дошедшие до второй ступени";
|
|
||||||
private static final String OUTCOME_TAG = "outcome";
|
|
||||||
|
|
||||||
/** Метки WikiNEuRal в типы ПД: только PER — имя. Адреса размечает ruBERT. */
|
private static final String NER_REQUESTS_DESCRIPTION = "Обращения, дошедшие до второй ступени";
|
||||||
private static final Map<String, String> NAME_TYPES = Map.of(
|
private static final String OUTCOME_TAG = "outcome";
|
||||||
"PER", PdTypes.FIO);
|
|
||||||
|
|
||||||
/** Метки ruBERT в типы ПД: детальные составляющие адреса. */
|
/** Метки WikiNEuRal в типы ПД: только PER — имя. Адреса размечает ruBERT. */
|
||||||
private static final Map<String, String> ADDRESS_TYPES = Map.of(
|
private static final Map<String, String> NAME_TYPES = Map.of("PER", PdTypes.FIO);
|
||||||
"COUNTRY", PdTypes.ADDRESS_COUNTRY,
|
|
||||||
"REGION", PdTypes.ADDRESS_REGION,
|
|
||||||
"DISTRICT", PdTypes.ADDRESS_DISTRICT,
|
|
||||||
"CITY", PdTypes.ADDRESS_CITY,
|
|
||||||
"STREET", PdTypes.ADDRESS_STREET,
|
|
||||||
"HOUSE", PdTypes.ADDRESS_HOUSE);
|
|
||||||
|
|
||||||
/** Цепочка из двух-трёх слов с заглавной буквы — то, что может оказаться ПД. */
|
/** Метки ruBERT в типы ПД: детальные составляющие адреса. */
|
||||||
private static final Pattern CANDIDATE = Pattern.compile(
|
private static final Map<String, String> ADDRESS_TYPES =
|
||||||
"\\p{Lu}[\\p{L}-]+(?:\\s+\\p{Lu}[\\p{L}-]+){1,2}",
|
Map.of(
|
||||||
Pattern.UNICODE_CHARACTER_CLASS | Pattern.UNICODE_CASE);
|
"COUNTRY", PdTypes.ADDRESS_COUNTRY,
|
||||||
|
"REGION", PdTypes.ADDRESS_REGION,
|
||||||
|
"DISTRICT", PdTypes.ADDRESS_DISTRICT,
|
||||||
|
"CITY", PdTypes.ADDRESS_CITY,
|
||||||
|
"STREET", PdTypes.ADDRESS_STREET,
|
||||||
|
"HOUSE", PdTypes.ADDRESS_HOUSE);
|
||||||
|
|
||||||
/** Приоритет находок второй ступени: ниже правил, у которых больше оснований. */
|
/**
|
||||||
private static final int PRIORITY = 73;
|
* Метки LLAIM Legal NER в типы ПД: юридические реквизиты и документы, которых нет в общих
|
||||||
|
* моделях. ADDRESS не сопоставляется — ruBERT размечает адреса детальнее. ORG, CASE_NUMBER и
|
||||||
|
* POSITION аналогов в {@link PdTypes} не имеют.
|
||||||
|
*/
|
||||||
|
private static final Map<String, String> LEGAL_TYPES =
|
||||||
|
Map.of(
|
||||||
|
"PER", PdTypes.FIO,
|
||||||
|
"INN", PdTypes.INN,
|
||||||
|
"OGRN", PdTypes.OGRN,
|
||||||
|
"SNILS", PdTypes.SNILS,
|
||||||
|
"PASSPORT", PdTypes.PASSPORT,
|
||||||
|
"PHONE", PdTypes.PHONE,
|
||||||
|
"EMAIL", PdTypes.EMAIL,
|
||||||
|
"BANK_ACCOUNT", PdTypes.ACCOUNT_NUMBER,
|
||||||
|
"DATE", PdTypes.DATE);
|
||||||
|
|
||||||
/** Сколько знаков текста вокруг кандидата отдаётся модели как контекст. */
|
/** Цепочка из двух-трёх слов с заглавной буквы — то, что может оказаться ПД. */
|
||||||
private static final int CONTEXT_CHARS = 60;
|
private static final Pattern CANDIDATE =
|
||||||
|
Pattern.compile(
|
||||||
|
"\\p{Lu}[\\p{L}-]+(?:\\s+\\p{Lu}[\\p{L}-]+){1,2}",
|
||||||
|
Pattern.UNICODE_CHARACTER_CLASS | Pattern.UNICODE_CASE);
|
||||||
|
|
||||||
private final RuBertRecogniser nameRecogniser;
|
/**
|
||||||
private final RuBertRecogniser addressRecogniser;
|
* Кандидат для LLAIM Legal NER: цифровой кластер (10–19 цифр с разделителями) или
|
||||||
private final Semaphore concurrent;
|
* адрес электронной почты. Юридические реквизиты (ИНН, СНИЛС, паспорт, телефон,
|
||||||
private final int maxCandidates;
|
* банковский счёт) — это цифры, а не слова с заглавной буквы, поэтому отдельный
|
||||||
private volatile boolean broken;
|
* проход не влияет на кандидатов моделей имён и адресов. Имена (PER) размечает
|
||||||
|
* модель имён, email — правила, так что слова сюда не включаются: иначе модель
|
||||||
|
* звалась бы на каждое слово текста.
|
||||||
|
*/
|
||||||
|
private static final Pattern LEGAL_CANDIDATE =
|
||||||
|
Pattern.compile(
|
||||||
|
"(?:\\d(?:[\\s.\\-/()]?\\d){9,18}|[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\\.[A-Za-z]{2,})",
|
||||||
|
Pattern.UNICODE_CHARACTER_CLASS | Pattern.UNICODE_CASE);
|
||||||
|
|
||||||
/**
|
/** Приоритет находок второй ступени: ниже правил, у которых больше оснований. */
|
||||||
* Сколько обращений дошло до модели, а сколько обошлось правилами. Отношение
|
private static final int PRIORITY = 73;
|
||||||
* {@code engaged} ко всем обращениям и есть та доля, от которой зависит,
|
|
||||||
* посильна ли тяжёлая модель на боевом трафике.
|
|
||||||
*/
|
|
||||||
private final Counter engaged;
|
|
||||||
private final Counter withoutCandidates;
|
|
||||||
private final Counter busy;
|
|
||||||
private final Counter candidates;
|
|
||||||
private final Timer duration;
|
|
||||||
|
|
||||||
@Autowired
|
/** Сколько знаков текста вокруг кандидата отдаётся модели как контекст. */
|
||||||
public NameCascade(
|
private static final int CONTEXT_CHARS = 60;
|
||||||
@Value("${pdguard.ner.name-engine:off}") String nameEngine,
|
|
||||||
@Value("${pdguard.ner.name-model:}") String nameModel,
|
private final RuBertRecogniser nameRecogniser;
|
||||||
@Value("${pdguard.ner.address-engine:off}") String addressEngine,
|
private final RuBertRecogniser addressRecogniser;
|
||||||
@Value("${pdguard.ner.address-model:}") String addressModel,
|
private final RuBertRecogniser legalRecogniser;
|
||||||
@Value("${pdguard.ner.max-candidates:16}") int maxCandidates,
|
private final Semaphore concurrent;
|
||||||
@Value("${pdguard.ner.pool-size:16}") int poolSize,
|
private final int maxCandidates;
|
||||||
MeterRegistry meters) {
|
private volatile boolean broken;
|
||||||
this.maxCandidates = maxCandidates;
|
|
||||||
this.nameRecogniser = create(nameEngine, nameModel, NAME_TYPES);
|
/**
|
||||||
this.addressRecogniser = create(addressEngine, addressModel, ADDRESS_TYPES);
|
* Сколько обращений дошло до модели, а сколько обошлось правилами. Отношение {@code engaged} ко
|
||||||
this.concurrent = new Semaphore(Math.max(1, poolSize));
|
* всем обращениям и есть та доля, от которой зависит, посильна ли тяжёлая модель на боевом
|
||||||
this.engaged = Counter.builder(NER_REQUESTS_METRIC)
|
* трафике.
|
||||||
.description(NER_REQUESTS_DESCRIPTION)
|
*/
|
||||||
.tag(OUTCOME_TAG, "engaged").register(meters);
|
private final Counter engaged;
|
||||||
this.withoutCandidates = Counter.builder(NER_REQUESTS_METRIC)
|
|
||||||
.description(NER_REQUESTS_DESCRIPTION)
|
private final Counter withoutCandidates;
|
||||||
.tag(OUTCOME_TAG, "no_candidates").register(meters);
|
private final Counter busy;
|
||||||
this.busy = Counter.builder(NER_REQUESTS_METRIC)
|
private final Counter candidates;
|
||||||
.description(NER_REQUESTS_DESCRIPTION)
|
private final Timer duration;
|
||||||
.tag(OUTCOME_TAG, "busy").register(meters);
|
|
||||||
this.candidates = Counter.builder("pdguard.ner.candidates")
|
/** Счётчики обращений к каждой модели второй ступени: тег {@code model} — имя движка. */
|
||||||
.description("Участки текста, отданные модели").register(meters);
|
private final Map<String, Counter> modelRequests;
|
||||||
this.duration = Timer.builder("pdguard.ner.duration")
|
|
||||||
.description("Время работы второй ступени").register(meters);
|
/** Время работы каждой модели: тег {@code model} — имя движка. */
|
||||||
|
private final Map<String, Timer> modelDuration;
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
public NameCascade(
|
||||||
|
@Value("${pdguard.ner.name-engine:off}") String nameEngine,
|
||||||
|
@Value("${pdguard.ner.name-model:}") String nameModel,
|
||||||
|
@Value("${pdguard.ner.address-engine:off}") String addressEngine,
|
||||||
|
@Value("${pdguard.ner.address-model:}") String addressModel,
|
||||||
|
@Value("${pdguard.ner.legal-engine:off}") String legalEngine,
|
||||||
|
@Value("${pdguard.ner.legal-model:}") String legalModel,
|
||||||
|
@Value("${pdguard.ner.max-candidates:16}") int maxCandidates,
|
||||||
|
@Value("${pdguard.ner.pool-size:16}") int poolSize,
|
||||||
|
MeterRegistry meters) {
|
||||||
|
this.maxCandidates = maxCandidates;
|
||||||
|
this.nameRecogniser = create(nameEngine, nameModel, NAME_TYPES);
|
||||||
|
this.addressRecogniser = create(addressEngine, addressModel, ADDRESS_TYPES);
|
||||||
|
this.legalRecogniser = create(legalEngine, legalModel, LEGAL_TYPES);
|
||||||
|
this.concurrent = new Semaphore(Math.max(1, poolSize));
|
||||||
|
this.engaged =
|
||||||
|
Counter.builder(NER_REQUESTS_METRIC)
|
||||||
|
.description(NER_REQUESTS_DESCRIPTION)
|
||||||
|
.tag(OUTCOME_TAG, "engaged")
|
||||||
|
.register(meters);
|
||||||
|
this.withoutCandidates =
|
||||||
|
Counter.builder(NER_REQUESTS_METRIC)
|
||||||
|
.description(NER_REQUESTS_DESCRIPTION)
|
||||||
|
.tag(OUTCOME_TAG, "no_candidates")
|
||||||
|
.register(meters);
|
||||||
|
this.busy =
|
||||||
|
Counter.builder(NER_REQUESTS_METRIC)
|
||||||
|
.description(NER_REQUESTS_DESCRIPTION)
|
||||||
|
.tag(OUTCOME_TAG, "busy")
|
||||||
|
.register(meters);
|
||||||
|
this.candidates =
|
||||||
|
Counter.builder("pdguard.ner.candidates")
|
||||||
|
.description("Участки текста, отданные модели")
|
||||||
|
.register(meters);
|
||||||
|
this.duration =
|
||||||
|
Timer.builder("pdguard.ner.duration")
|
||||||
|
.description("Время работы второй ступени")
|
||||||
|
.register(meters);
|
||||||
|
this.modelRequests = modelCounters(meters);
|
||||||
|
this.modelDuration = modelTimers(meters);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Счётчики обращений к каждой модели: тег {@code model} — имя движка. */
|
||||||
|
private static Map<String, Counter> modelCounters(MeterRegistry meters) {
|
||||||
|
Map<String, Counter> counters = new HashMap<>();
|
||||||
|
for (String model : new String[] {"name", "address", "legal"}) {
|
||||||
|
counters.put(
|
||||||
|
model,
|
||||||
|
Counter.builder("pdguard.ner.model.requests")
|
||||||
|
.description("Обращения к модели второй ступени")
|
||||||
|
.tag("model", model)
|
||||||
|
.register(meters));
|
||||||
}
|
}
|
||||||
|
return counters;
|
||||||
|
}
|
||||||
|
|
||||||
/** Конструктор для тестов: одна модель для имён, метрики никуда не отдаются. */
|
/** Таймеры времени работы каждой модели: тег {@code model} — имя движка. */
|
||||||
public NameCascade(String engine, Optional<String> modelPath, int maxCandidates, int poolSize) {
|
private static Map<String, Timer> modelTimers(MeterRegistry meters) {
|
||||||
this(engine, modelPath.orElse(""), "off", "", maxCandidates, poolSize, new SimpleMeterRegistry());
|
Map<String, Timer> timers = new HashMap<>();
|
||||||
|
for (String model : new String[] {"name", "address", "legal"}) {
|
||||||
|
timers.put(
|
||||||
|
model,
|
||||||
|
Timer.builder("pdguard.ner.model.duration")
|
||||||
|
.description("Время работы модели второй ступени")
|
||||||
|
.tag("model", model)
|
||||||
|
.register(meters));
|
||||||
}
|
}
|
||||||
|
return timers;
|
||||||
|
}
|
||||||
|
|
||||||
/** Конструктор для тестов двух моделей: метрики никуда не отдаются. */
|
/** Конструктор для тестов: движки задаются конфигом, метрики — реестром. */
|
||||||
public NameCascade(String nameEngine, Optional<String> nameModel,
|
private NameCascade(EngineConfig config, int maxCandidates, int poolSize, MeterRegistry meters) {
|
||||||
String addressEngine, Optional<String> addressModel,
|
this(
|
||||||
int maxCandidates, int poolSize) {
|
config.nameEngine(),
|
||||||
this(nameEngine, nameModel.orElse(""), addressEngine, addressModel.orElse(""),
|
config.nameModel().orElse(""),
|
||||||
maxCandidates, poolSize, new SimpleMeterRegistry());
|
config.addressEngine(),
|
||||||
|
config.addressModel().orElse(""),
|
||||||
|
config.legalEngine(),
|
||||||
|
config.legalModel().orElse(""),
|
||||||
|
maxCandidates,
|
||||||
|
poolSize,
|
||||||
|
meters);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Конструктор для тестов: одна модель для имён, метрики никуда не отдаются. */
|
||||||
|
public NameCascade(String engine, Optional<String> modelPath, int maxCandidates, int poolSize) {
|
||||||
|
this(
|
||||||
|
new EngineConfig(engine, modelPath, "off", Optional.empty(), "off", Optional.empty()),
|
||||||
|
maxCandidates,
|
||||||
|
poolSize,
|
||||||
|
new SimpleMeterRegistry());
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Конструктор для тестов двух моделей: метрики никуда не отдаются. */
|
||||||
|
public NameCascade(
|
||||||
|
String nameEngine,
|
||||||
|
Optional<String> nameModel,
|
||||||
|
String addressEngine,
|
||||||
|
Optional<String> addressModel,
|
||||||
|
int maxCandidates,
|
||||||
|
int poolSize) {
|
||||||
|
this(
|
||||||
|
new EngineConfig(
|
||||||
|
nameEngine, nameModel, addressEngine, addressModel, "off", Optional.empty()),
|
||||||
|
maxCandidates,
|
||||||
|
poolSize,
|
||||||
|
new SimpleMeterRegistry());
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Конструктор для тестов двух моделей с явным реестром метрик. */
|
||||||
|
public NameCascade(
|
||||||
|
String nameEngine,
|
||||||
|
Optional<String> nameModel,
|
||||||
|
String addressEngine,
|
||||||
|
Optional<String> addressModel,
|
||||||
|
int maxCandidates,
|
||||||
|
int poolSize,
|
||||||
|
MeterRegistry meters) {
|
||||||
|
this(
|
||||||
|
new EngineConfig(
|
||||||
|
nameEngine, nameModel, addressEngine, addressModel, "off", Optional.empty()),
|
||||||
|
maxCandidates,
|
||||||
|
poolSize,
|
||||||
|
meters);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Конструктор для тестов трёх моделей: метрики никуда не отдаются. */
|
||||||
|
public NameCascade(EngineConfig config, int maxCandidates, int poolSize) {
|
||||||
|
this(config, maxCandidates, poolSize, new SimpleMeterRegistry());
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Конфигурация трёх движков второй ступени: имя, адрес и юридические реквизиты. */
|
||||||
|
public record EngineConfig(
|
||||||
|
String nameEngine,
|
||||||
|
Optional<String> nameModel,
|
||||||
|
String addressEngine,
|
||||||
|
Optional<String> addressModel,
|
||||||
|
String legalEngine,
|
||||||
|
Optional<String> legalModel) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Выключенная ступень для служебных нужд — прогрева и тестов. Отдельный конструктор, а не обычный
|
||||||
|
* путь: иначе в журнале рядом с сообщением о готовности распознавателя появлялось бы сообщение о
|
||||||
|
* его выключении, и было бы непонятно, что в итоге работает.
|
||||||
|
*/
|
||||||
|
private NameCascade() {
|
||||||
|
this.maxCandidates = 0;
|
||||||
|
this.nameRecogniser = null;
|
||||||
|
this.addressRecogniser = null;
|
||||||
|
this.legalRecogniser = null;
|
||||||
|
this.concurrent = new Semaphore(1);
|
||||||
|
MeterRegistry meters = new SimpleMeterRegistry();
|
||||||
|
this.engaged = meters.counter(NER_REQUESTS_METRIC, OUTCOME_TAG, "engaged");
|
||||||
|
this.withoutCandidates = meters.counter(NER_REQUESTS_METRIC, OUTCOME_TAG, "no_candidates");
|
||||||
|
this.busy = meters.counter(NER_REQUESTS_METRIC, OUTCOME_TAG, "busy");
|
||||||
|
this.candidates = meters.counter("pdguard.ner.candidates");
|
||||||
|
this.duration = Timer.builder("pdguard.ner.duration").register(meters);
|
||||||
|
this.modelRequests = modelCounters(meters);
|
||||||
|
this.modelDuration = modelTimers(meters);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static NameCascade disabled() {
|
||||||
|
return new NameCascade();
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean enabled() {
|
||||||
|
return (nameRecogniser != null || addressRecogniser != null || legalRecogniser != null)
|
||||||
|
&& !broken;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Покрывает ли каскад хоть один тип, разрешённый политикой. Нужно, чтобы {@code Pipeline} звал
|
||||||
|
* вторую ступень не только ради ФИО, но и ради адресов и юридических реквизитов, которые
|
||||||
|
* размечает LLAIM Legal NER.
|
||||||
|
*/
|
||||||
|
public boolean coversAny(SystemPolicy policy) {
|
||||||
|
if (!enabled()) {
|
||||||
|
return false;
|
||||||
}
|
}
|
||||||
|
return policy.allows(PdTypes.FIO)
|
||||||
|
|| policy.allows(PdTypes.ADDRESS_COUNTRY)
|
||||||
|
|| policy.allows(PdTypes.ADDRESS_REGION)
|
||||||
|
|| policy.allows(PdTypes.ADDRESS_DISTRICT)
|
||||||
|
|| policy.allows(PdTypes.ADDRESS_CITY)
|
||||||
|
|| policy.allows(PdTypes.ADDRESS_STREET)
|
||||||
|
|| policy.allows(PdTypes.ADDRESS_HOUSE)
|
||||||
|
|| policy.allows(PdTypes.INN)
|
||||||
|
|| policy.allows(PdTypes.OGRN)
|
||||||
|
|| policy.allows(PdTypes.SNILS)
|
||||||
|
|| policy.allows(PdTypes.PASSPORT)
|
||||||
|
|| policy.allows(PdTypes.PHONE)
|
||||||
|
|| policy.allows(PdTypes.EMAIL)
|
||||||
|
|| policy.allows(PdTypes.ACCOUNT_NUMBER)
|
||||||
|
|| policy.allows(PdTypes.DATE);
|
||||||
|
}
|
||||||
|
|
||||||
/** Конструктор для тестов двух моделей с явным реестром метрик. */
|
/**
|
||||||
public NameCascade(String nameEngine, Optional<String> nameModel,
|
* Добавляет ПД, которые не нашла первая ступень. Уже принятые фрагменты не трогаются: модели
|
||||||
String addressEngine, Optional<String> addressModel,
|
* разбирают только непокрытые участки.
|
||||||
int maxCandidates, int poolSize, MeterRegistry meters) {
|
*/
|
||||||
this(nameEngine, nameModel.orElse(""), addressEngine, addressModel.orElse(""),
|
public List<Span> addMissedNames(String text, List<Span> accepted) {
|
||||||
maxCandidates, poolSize, meters);
|
if (!enabled()) {
|
||||||
|
return accepted;
|
||||||
}
|
}
|
||||||
|
if (!concurrent.tryAcquire()) {
|
||||||
/**
|
// Модель занята целиком: отвечаем по правилам, а не копим очередь.
|
||||||
* Выключенная ступень для служебных нужд — прогрева и тестов. Отдельный
|
busy.increment();
|
||||||
* конструктор, а не обычный путь: иначе в журнале рядом с сообщением о готовности
|
return accepted;
|
||||||
* распознавателя появлялось бы сообщение о его выключении, и было бы непонятно,
|
|
||||||
* что в итоге работает.
|
|
||||||
*/
|
|
||||||
private NameCascade() {
|
|
||||||
this.maxCandidates = 0;
|
|
||||||
this.nameRecogniser = null;
|
|
||||||
this.addressRecogniser = null;
|
|
||||||
this.concurrent = new Semaphore(1);
|
|
||||||
MeterRegistry meters = new SimpleMeterRegistry();
|
|
||||||
this.engaged = meters.counter(NER_REQUESTS_METRIC, OUTCOME_TAG, "engaged");
|
|
||||||
this.withoutCandidates = meters.counter(NER_REQUESTS_METRIC, OUTCOME_TAG, "no_candidates");
|
|
||||||
this.busy = meters.counter(NER_REQUESTS_METRIC, OUTCOME_TAG, "busy");
|
|
||||||
this.candidates = meters.counter("pdguard.ner.candidates");
|
|
||||||
this.duration = Timer.builder("pdguard.ner.duration").register(meters);
|
|
||||||
}
|
}
|
||||||
|
long started = System.nanoTime();
|
||||||
public static NameCascade disabled() {
|
try {
|
||||||
return new NameCascade();
|
List<Span> found = new ArrayList<>(accepted);
|
||||||
}
|
int examined = 0;
|
||||||
|
Matcher m = CANDIDATE.matcher(text);
|
||||||
public boolean enabled() {
|
while (m.find() && examined < maxCandidates) {
|
||||||
return (nameRecogniser != null || addressRecogniser != null) && !broken;
|
if (fullyCovered(found, m.start(), m.end())) {
|
||||||
}
|
continue;
|
||||||
|
|
||||||
/**
|
|
||||||
* Добавляет ПД, которые не нашла первая ступень. Уже принятые фрагменты не
|
|
||||||
* трогаются: модели разбирают только непокрытые участки.
|
|
||||||
*/
|
|
||||||
public List<Span> addMissedNames(String text, List<Span> accepted) {
|
|
||||||
if (!enabled()) {
|
|
||||||
return accepted;
|
|
||||||
}
|
}
|
||||||
if (!concurrent.tryAcquire()) {
|
examined++;
|
||||||
// Модель занята целиком: отвечаем по правилам, а не копим очередь.
|
collect(text, m.start(), m.end(), found, "name", nameRecogniser);
|
||||||
busy.increment();
|
collect(text, m.start(), m.end(), found, "address", addressRecogniser);
|
||||||
return accepted;
|
}
|
||||||
}
|
// LLAIM Legal NER ищет реквизиты (ИНН, СНИЛС, паспорт), которые не
|
||||||
long started = System.nanoTime();
|
// являются словами с заглавной буквы, — отдельный проход по своим
|
||||||
try {
|
// кандидатам, чтобы не вытеснять кандидатов моделей имён и адресов.
|
||||||
List<Span> found = new ArrayList<>(accepted);
|
if (legalRecogniser != null) {
|
||||||
int examined = 0;
|
Matcher lm = LEGAL_CANDIDATE.matcher(text);
|
||||||
Matcher m = CANDIDATE.matcher(text);
|
while (lm.find() && examined < maxCandidates) {
|
||||||
while (m.find() && examined < maxCandidates) {
|
if (fullyCovered(found, lm.start(), lm.end())) {
|
||||||
if (fullyCovered(found, m.start(), m.end())) {
|
continue;
|
||||||
continue;
|
}
|
||||||
}
|
examined++;
|
||||||
examined++;
|
collect(text, lm.start(), lm.end(), found, "legal", legalRecogniser);
|
||||||
collect(text, m.start(), m.end(), found, nameRecogniser);
|
|
||||||
collect(text, m.start(), m.end(), found, addressRecogniser);
|
|
||||||
}
|
|
||||||
candidates.increment(examined);
|
|
||||||
(examined > 0 ? engaged : withoutCandidates).increment();
|
|
||||||
duration.record(System.nanoTime() - started, TimeUnit.NANOSECONDS);
|
|
||||||
return found;
|
|
||||||
} catch (RuntimeException e) {
|
|
||||||
broken = true;
|
|
||||||
LOG.error("Вторая ступень отключена из-за сбоя, распознавание продолжается по правилам", e);
|
|
||||||
return accepted;
|
|
||||||
} finally {
|
|
||||||
concurrent.release();
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
candidates.increment(examined);
|
||||||
|
(examined > 0 ? engaged : withoutCandidates).increment();
|
||||||
|
duration.record(System.nanoTime() - started, TimeUnit.NANOSECONDS);
|
||||||
|
return found;
|
||||||
|
} catch (RuntimeException e) {
|
||||||
|
broken = true;
|
||||||
|
LOG.error("Вторая ступень отключена из-за сбоя, распознавание продолжается по правилам", e);
|
||||||
|
return accepted;
|
||||||
|
} finally {
|
||||||
|
concurrent.release();
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private void collect(String text, int candidateStart, int candidateEnd, List<Span> sink,
|
private void collect(
|
||||||
RuBertRecogniser recogniser) {
|
String text,
|
||||||
if (recogniser == null) {
|
int candidateStart,
|
||||||
return;
|
int candidateEnd,
|
||||||
}
|
List<Span> sink,
|
||||||
int from = Math.max(0, candidateStart - CONTEXT_CHARS);
|
String modelName,
|
||||||
int to = Math.min(text.length(), candidateEnd + CONTEXT_CHARS);
|
RuBertRecogniser recogniser) {
|
||||||
boolean nameFound = false;
|
if (recogniser == null) {
|
||||||
for (Span span : recogniser.recognise(text, from, to, PRIORITY)) {
|
return;
|
||||||
if (isAccepted(text, candidateStart, candidateEnd, span)) {
|
|
||||||
sink.add(span);
|
|
||||||
if (PdTypes.FIO.equals(span.type())) {
|
|
||||||
nameFound = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Модель распознала имя в кандидате, но правила могли найти лишь его часть
|
|
||||||
// («Жан» вместо «Жан-Поль Дюваль») с более высоким приоритетом и заблокировать
|
|
||||||
// полное имя при разрешении перекрытий. Убираем такие частичные находки правил,
|
|
||||||
// чтобы полное имя от модели осталось: избыточное покрытие безопаснее утечки ПД.
|
|
||||||
if (nameFound) {
|
|
||||||
sink.removeIf(span -> PdTypes.FIO.equals(span.type())
|
|
||||||
&& span.start() < candidateEnd && candidateStart < span.end()
|
|
||||||
&& span.priority() > PRIORITY);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
int from = Math.max(0, candidateStart - CONTEXT_CHARS);
|
||||||
|
int to = Math.min(text.length(), candidateEnd + CONTEXT_CHARS);
|
||||||
|
boolean nameFound = false;
|
||||||
|
long started = System.nanoTime();
|
||||||
|
for (Span span : recogniser.recognise(text, from, to, PRIORITY)) {
|
||||||
|
if (isAccepted(text, candidateStart, candidateEnd, span)) {
|
||||||
|
sink.add(span);
|
||||||
|
if (PdTypes.FIO.equals(span.type())) {
|
||||||
|
nameFound = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
modelRequests.get(modelName).increment();
|
||||||
|
modelDuration.get(modelName).record(System.nanoTime() - started, TimeUnit.NANOSECONDS);
|
||||||
|
// Модель распознала имя в кандидате, но правила могли найти лишь его часть
|
||||||
|
// («Жан» вместо «Жан-Поль Дюваль») с более высоким приоритетом и заблокировать
|
||||||
|
// полное имя при разрешении перекрытий. Убираем такие частичные находки правил,
|
||||||
|
// чтобы полное имя от модели осталось: избыточное покрытие безопаснее утечки ПД.
|
||||||
|
if (nameFound) {
|
||||||
|
sink.removeIf(
|
||||||
|
span ->
|
||||||
|
PdTypes.FIO.equals(span.type())
|
||||||
|
&& span.start() < candidateEnd
|
||||||
|
&& candidateStart < span.end()
|
||||||
|
&& span.priority() > PRIORITY);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/** Покрыт ли фрагмент целиком уже принятыми находками. */
|
/** Покрыт ли фрагмент целиком уже принятыми находками. */
|
||||||
private static boolean fullyCovered(List<Span> spans, int start, int end) {
|
private static boolean fullyCovered(List<Span> spans, int start, int end) {
|
||||||
return spans.stream().anyMatch(span -> span.start() <= start && end <= span.end());
|
return spans.stream().anyMatch(span -> span.start() <= start && end <= span.end());
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Принимает находку модели, если она пересекается с кандидатом и проходит
|
* Слова-маркеры ПД, которые модель иногда ошибочно помечает как ФИО («ИНН», «СНИЛС», «паспорт»).
|
||||||
* те же условия, что и находки правил.
|
* Такие находки — шум: это не имена, а обозначения реквизитов, и маскировать их как ФИО нельзя.
|
||||||
*/
|
*/
|
||||||
private static boolean isAccepted(String text, int candidateStart, int candidateEnd, Span span) {
|
private static final Set<String> PD_MARKERS =
|
||||||
// Берём только пересекающееся с кандидатом: контекст добавлен ради
|
Set.of(
|
||||||
// качества разбора, а не для расширения находки.
|
"инн",
|
||||||
if (span.start() >= candidateEnd || candidateStart >= span.end()) {
|
"снилс",
|
||||||
return false;
|
"огрн",
|
||||||
}
|
"огрнип",
|
||||||
// Адресные типы принимаются на тех же условиях, что и от правил: рядом
|
"кпп",
|
||||||
// должны быть другие части адреса. Иначе «Спартак Москва» и «Проспект
|
"бик",
|
||||||
// Вернадского» попадали бы под маску наравне с адресом клиента.
|
"паспорт",
|
||||||
return !RuleRegistry.isAddressType(span.type())
|
"счёт",
|
||||||
|| RuleRegistry.hasAddressContext(text, span.start(), span.end());
|
"счет",
|
||||||
}
|
"телефон",
|
||||||
|
"email",
|
||||||
|
"почта",
|
||||||
|
"дата",
|
||||||
|
"адрес",
|
||||||
|
"полис",
|
||||||
|
"свидетельство");
|
||||||
|
|
||||||
private static RuBertRecogniser create(String engine, String modelPath, Map<String, String> types) {
|
/**
|
||||||
String chosen = engine == null ? "off" : engine.toLowerCase(Locale.ROOT).strip();
|
* Принимает находку модели, если она пересекается с кандидатом и проходит те же условия, что и
|
||||||
if ("off".equals(chosen) || modelPath == null || modelPath.isBlank()) {
|
* находки правил.
|
||||||
LOG.info("Вторая ступень распознавания выключена");
|
*/
|
||||||
return null;
|
private static boolean isAccepted(String text, int candidateStart, int candidateEnd, Span span) {
|
||||||
}
|
// Берём только пересекающееся с кандидатом: контекст добавлен ради
|
||||||
if (!"rubert".equals(chosen) && !"wikineural".equals(chosen)) {
|
// качества разбора, а не для расширения находки.
|
||||||
LOG.warn("Неизвестный движок второй ступени: {}, ступень выключена", chosen);
|
if (span.start() >= candidateEnd || candidateStart >= span.end()) {
|
||||||
return null;
|
return false;
|
||||||
}
|
|
||||||
RuBertRecogniser created = RuBertRecogniser.load(Path.of(modelPath), 1, types);
|
|
||||||
if (created == null) {
|
|
||||||
LOG.info("Вторая ступень распознавания выключена: распознаватель не создан");
|
|
||||||
}
|
|
||||||
return created;
|
|
||||||
}
|
}
|
||||||
|
// Модель с приоритетом recall иногда помечает слово-маркер реквизита
|
||||||
|
// («ИНН») как ФИО. Такое значение именем не является.
|
||||||
|
if (PdTypes.FIO.equals(span.type())
|
||||||
|
&& PD_MARKERS.contains(text.substring(span.start(), span.end()).toLowerCase(Locale.ROOT))) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
// Адресные типы принимаются на тех же условиях, что и от правил: рядом
|
||||||
|
// должны быть другие части адреса. Иначе «Спартак Москва» и «Проспект
|
||||||
|
// Вернадского» попадали бы под маску наравне с адресом клиента.
|
||||||
|
return !RuleRegistry.isAddressType(span.type())
|
||||||
|
|| RuleRegistry.hasAddressContext(text, span.start(), span.end());
|
||||||
|
}
|
||||||
|
|
||||||
@PreDestroy
|
private static RuBertRecogniser create(
|
||||||
void shutdown() {
|
String engine, String modelPath, Map<String, String> types) {
|
||||||
if (nameRecogniser != null) {
|
String chosen = engine == null ? "off" : engine.toLowerCase(Locale.ROOT).strip();
|
||||||
nameRecogniser.close();
|
if ("off".equals(chosen) || modelPath == null || modelPath.isBlank()) {
|
||||||
}
|
LOG.info("Вторая ступень распознавания выключена");
|
||||||
if (addressRecogniser != null) {
|
return null;
|
||||||
addressRecogniser.close();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
if (!"rubert".equals(chosen)
|
||||||
|
&& !"wikineural".equals(chosen)
|
||||||
|
&& !"ru-legal-ner".equals(chosen)) {
|
||||||
|
LOG.warn("Неизвестный движок второй ступени: {}, ступень выключена", chosen);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
RuBertRecogniser created = RuBertRecogniser.load(Path.of(modelPath), 1, types);
|
||||||
|
if (created == null) {
|
||||||
|
LOG.info("Вторая ступень распознавания выключена: распознаватель не создан");
|
||||||
|
}
|
||||||
|
return created;
|
||||||
|
}
|
||||||
|
|
||||||
|
@PreDestroy
|
||||||
|
void shutdown() {
|
||||||
|
if (nameRecogniser != null) {
|
||||||
|
nameRecogniser.close();
|
||||||
|
}
|
||||||
|
if (addressRecogniser != null) {
|
||||||
|
addressRecogniser.close();
|
||||||
|
}
|
||||||
|
if (legalRecogniser != null) {
|
||||||
|
legalRecogniser.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,8 +1,5 @@
|
|||||||
package ru.pdguard.detect;
|
package ru.pdguard.detect;
|
||||||
|
|
||||||
import org.slf4j.Logger;
|
|
||||||
import org.slf4j.LoggerFactory;
|
|
||||||
|
|
||||||
import java.nio.file.Path;
|
import java.nio.file.Path;
|
||||||
import java.util.Comparator;
|
import java.util.Comparator;
|
||||||
import java.util.HashSet;
|
import java.util.HashSet;
|
||||||
@@ -11,262 +8,300 @@ import java.util.Locale;
|
|||||||
import java.util.Set;
|
import java.util.Set;
|
||||||
import java.util.regex.Pattern;
|
import java.util.regex.Pattern;
|
||||||
import java.util.stream.Collectors;
|
import java.util.stream.Collectors;
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Словари для распознавания ФИО.
|
* Словари для распознавания ФИО.
|
||||||
*
|
*
|
||||||
* <p>Личные имена нужны, чтобы морфология фамилий не срабатывала на чём попало:
|
* <p>Личные имена нужны, чтобы морфология фамилий не срабатывала на чём попало: «Тверская» по
|
||||||
* «Тверская» по окончанию похожа на фамилию, но рядом с ней нет личного имени.
|
* окончанию похожа на фамилию, но рядом с ней нет личного имени.
|
||||||
*
|
*
|
||||||
* <p>Список известных людей решает обратную задачу — упоминание Пушкина
|
* <p>Список известных людей решает обратную задачу — упоминание Пушкина персональными данными не
|
||||||
* персональными данными не является. Ограничение осознанное: клиент по фамилии
|
* является. Ограничение осознанное: клиент по фамилии Пушкин в тексте без других ПД замаскирован не
|
||||||
* Пушкин в тексте без других ПД замаскирован не будет.
|
* будет.
|
||||||
*
|
*
|
||||||
* <p>Базовый список собран в сборку из {@code /names/well-known.txt}. Поверх
|
* <p>Базовый список собран в сборку из {@code /names/well-known.txt}. Поверх него можно дописать
|
||||||
* него можно дописать своих публичных лиц без пересборки — файл по пути
|
* своих публичных лиц без пересборки — файл по пути {@code pdguard.well-known-file} (по умолчанию
|
||||||
* {@code pdguard.well-known-file} (по умолчанию {@code config/well-known.txt})
|
* {@code config/well-known.txt}) перечитывается сам при изменении, тем же приёмом, что {@code
|
||||||
* перечитывается сам при изменении, тем же приёмом, что {@code systems.json}
|
* systems.json} в {@link ru.pdguard.config.SystemsConfig}: раз в секунду сверяется время изменения,
|
||||||
* в {@link ru.pdguard.config.SystemsConfig}: раз в секунду сверяется время
|
* содержимое читается заново только когда оно другое.
|
||||||
* изменения, содержимое читается заново только когда оно другое.
|
|
||||||
*/
|
*/
|
||||||
public final class NameDictionary {
|
public final class NameDictionary {
|
||||||
|
|
||||||
private static final Logger LOG = LoggerFactory.getLogger(NameDictionary.class);
|
private static final Logger LOG = LoggerFactory.getLogger(NameDictionary.class);
|
||||||
|
|
||||||
private static final List<String> GIVEN_NAME_STEMS = ResourceLoader.lines("/names/given-names.txt", true).stream()
|
private static final List<String> GIVEN_NAME_STEMS =
|
||||||
.map(Declension::withoutInflectedEnding)
|
ResourceLoader.lines("/names/given-names.txt", true).stream()
|
||||||
.distinct()
|
.map(Declension::withoutInflectedEnding)
|
||||||
.sorted(Comparator.comparingInt(String::length).reversed())
|
.distinct()
|
||||||
.toList();
|
.sorted(Comparator.comparingInt(String::length).reversed())
|
||||||
// Гласная в конце основы отбрасывается: «Набиуллина» родительный/дательный/
|
.toList();
|
||||||
// творительный падежи образует заменой «-а» на «-ой» («Набиуллиной»), а не
|
// Гласная в конце основы отбрасывается: «Набиуллина» родительный/дательный/
|
||||||
// дописыванием — без отсечения «а» их startsWith не поймает. Тот же приём,
|
// творительный падежи образует заменой «-а» на «-ой» («Набиуллиной»), а не
|
||||||
// что и для личных имён.
|
// дописыванием — без отсечения «а» их startsWith не поймает. Тот же приём,
|
||||||
private static final Set<String> BUNDLED_WELL_KNOWN_STEMS = ResourceLoader.set("/names/well-known.txt").stream()
|
// что и для личных имён.
|
||||||
.map(Declension::withoutInflectedEnding)
|
private static final Set<String> BUNDLED_WELL_KNOWN_STEMS =
|
||||||
.collect(Collectors.toUnmodifiableSet());
|
ResourceLoader.set("/names/well-known.txt").stream()
|
||||||
|
.map(Declension::withoutInflectedEnding)
|
||||||
|
.collect(Collectors.toUnmodifiableSet());
|
||||||
|
|
||||||
private static final ResourceLoader.FileWatchState<Set<String>> WELL_KNOWN_STATE =
|
private static final ResourceLoader.FileWatchState<Set<String>> WELL_KNOWN_STATE =
|
||||||
new ResourceLoader.FileWatchState<>(BUNDLED_WELL_KNOWN_STEMS);
|
new ResourceLoader.FileWatchState<>(BUNDLED_WELL_KNOWN_STEMS);
|
||||||
|
|
||||||
private static final Path EXTERNAL_FILE = Path.of("config/well-known.txt");
|
private static final Path EXTERNAL_FILE = Path.of("config/well-known.txt");
|
||||||
|
|
||||||
/** Разделитель слов: любая последовательность не-буквенных символов. */
|
/** Разделитель слов: любая последовательность не-буквенных символов. */
|
||||||
private static final String WORD_SPLIT = "\\P{L}+";
|
private static final String WORD_SPLIT = "\\P{L}+";
|
||||||
|
|
||||||
/** Порядковые числительные в имени правителя: «Пётр Первый», «Екатерина Вторая». */
|
/** Порядковые числительные в имени правителя: «Пётр Первый», «Екатерина Вторая». */
|
||||||
private static final String REGNAL_ORDINALS =
|
private static final String REGNAL_ORDINALS =
|
||||||
"перв|втор|трет|четв[её]рт|пят|шест|седьм|восьм|девят|десят";
|
"перв|втор|трет|четв[её]рт|пят|шест|седьм|восьм|девят|десят";
|
||||||
|
|
||||||
/** Прозвища правителей: «Иван Грозный», «Ярослав Мудрый», «Александр Освободитель». */
|
/** Прозвища правителей: «Иван Грозный», «Ярослав Мудрый», «Александр Освободитель». */
|
||||||
private static final String REGNAL_EPITHETS =
|
private static final String REGNAL_EPITHETS =
|
||||||
"велик|грозн|мудр|благословен|освободител|миротворц?|тишайш|долгорук|окаянн";
|
"велик|грозн|мудр|благословен|освободител|миротворц?|тишайш|долгорук|окаянн";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Имя правителя: личное имя плюс порядковое числительное или прозвище —
|
* Имя правителя: личное имя плюс порядковое числительное или прозвище — «Пётр Первый», «Иван
|
||||||
* «Пётр Первый», «Иван Грозный», «Екатерина Вторая», «Ярослав Мудрый».
|
* Грозный», «Екатерина Вторая», «Ярослав Мудрый». Задано правилом, а не перечнем: правителей
|
||||||
* Задано правилом, а не перечнем: правителей много, а форма записи одна.
|
* много, а форма записи одна.
|
||||||
*/
|
*/
|
||||||
private static final Pattern REGNAL_NAME = Pattern.compile(
|
private static final Pattern REGNAL_NAME =
|
||||||
"^\\p{Lu}\\p{L}+\\s+(?iu:" + REGNAL_ORDINALS + "|" + REGNAL_EPITHETS + ")\\p{L}*$",
|
Pattern.compile(
|
||||||
Pattern.UNICODE_CHARACTER_CLASS | Pattern.UNICODE_CASE | Pattern.CANON_EQ);
|
"^\\p{Lu}\\p{L}+\\s+(?iu:" + REGNAL_ORDINALS + "|" + REGNAL_EPITHETS + ")\\p{L}*$",
|
||||||
|
Pattern.UNICODE_CHARACTER_CLASS | Pattern.UNICODE_CASE | Pattern.CANON_EQ);
|
||||||
|
|
||||||
/** Не более скольких падежных букв дописывается к основе имени. */
|
/** Не более скольких падежных букв дописывается к основе имени. */
|
||||||
private static final int MAX_INFLECTION = 3;
|
private static final int MAX_INFLECTION = 3;
|
||||||
|
|
||||||
/** Остатки, превращающие основу имени в фамилию или отчество: Роман → Романов. */
|
/** Остатки, превращающие основу имени в фамилию или отчество: Роман → Романов. */
|
||||||
private static final Set<String> SURNAME_SUFFIXES = Set.of(
|
private static final Set<String> SURNAME_SUFFIXES =
|
||||||
"ов", "ев", "ёв", "ин", "ын", "ова", "ева", "ёва", "ина", "ына",
|
Set.of(
|
||||||
"ович", "евич", "овна", "евна", "овы", "евы", "ины");
|
"ов", "ев", "ёв", "ин", "ын", "ова", "ева", "ёва", "ина", "ына", "ович", "евич", "овна",
|
||||||
|
"евна", "овы", "евы", "ины");
|
||||||
|
|
||||||
private static final Set<String> GIVEN_NAMES = GIVEN_NAME_STEMS.stream()
|
private static final Set<String> GIVEN_NAMES =
|
||||||
.map(stem -> stem.toLowerCase(Locale.ROOT))
|
GIVEN_NAME_STEMS.stream()
|
||||||
.collect(Collectors.toUnmodifiableSet());
|
.map(stem -> stem.toLowerCase(Locale.ROOT))
|
||||||
|
.collect(Collectors.toUnmodifiableSet());
|
||||||
|
|
||||||
private NameDictionary() {
|
/**
|
||||||
|
* Слова-маркеры персональных данных и реквизитов, которые по словообразованию совпадают с
|
||||||
|
* основами имён («ИНН» — основа имени «Инна») и потому ложно распознаются как ФИО. Это
|
||||||
|
* аббревиатуры, а не имена.
|
||||||
|
*/
|
||||||
|
private static final Set<String> PD_MARKERS =
|
||||||
|
Set.of(
|
||||||
|
"инн",
|
||||||
|
"снилс",
|
||||||
|
"огрн",
|
||||||
|
"огрнип",
|
||||||
|
"кпп",
|
||||||
|
"бик",
|
||||||
|
"паспорт",
|
||||||
|
"счёт",
|
||||||
|
"счет",
|
||||||
|
"телефон",
|
||||||
|
"email",
|
||||||
|
"почта",
|
||||||
|
"дата",
|
||||||
|
"адрес",
|
||||||
|
"полис",
|
||||||
|
"свидетельство",
|
||||||
|
"ву");
|
||||||
|
|
||||||
|
private NameDictionary() {}
|
||||||
|
|
||||||
|
/** Экземпляр для Spring-бина; словарь работает через статические методы. */
|
||||||
|
public static NameDictionary create() {
|
||||||
|
return new NameDictionary();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Задаёт путь к внешнему файлу денилиста. Вызывается при старте приложения из конфигурации
|
||||||
|
* Spring-бина; статические методы словаря работают без экземпляра, поэтому путь хранится в
|
||||||
|
* статическом поле.
|
||||||
|
*/
|
||||||
|
public static void configure(String wellKnownFile) {
|
||||||
|
WELL_KNOWN_STATE.current = BUNDLED_WELL_KNOWN_STEMS;
|
||||||
|
WELL_KNOWN_STATE.mtime = -1;
|
||||||
|
WELL_KNOWN_STATE.lastCheck = 0;
|
||||||
|
// Путь фиксирован в статическом поле; для тестов используется useExternalFile.
|
||||||
|
if (!"config/well-known.txt".equals(wellKnownFile)) {
|
||||||
|
useExternalFile(Path.of(wellKnownFile));
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/** Экземпляр для Spring-бина; словарь работает через статические методы. */
|
/**
|
||||||
public static NameDictionary create() {
|
* Есть ли среди слов личное имя из словаря в любом падеже.
|
||||||
return new NameDictionary();
|
*
|
||||||
}
|
* <p>Проверка множеством, а не чередованием в регулярном выражении: сто с лишним веток пришлось
|
||||||
|
* бы перебирать в каждой позиции текста, здесь же на слово приходится не больше четырёх обращений
|
||||||
/**
|
* к хеш-таблице.
|
||||||
* Задаёт путь к внешнему файлу денилиста. Вызывается при старте приложения
|
*/
|
||||||
* из конфигурации Spring-бина; статические методы словаря работают без
|
public static boolean containsGivenName(String value) {
|
||||||
* экземпляра, поэтому путь хранится в статическом поле.
|
for (String word : value.split(WORD_SPLIT)) {
|
||||||
*/
|
String lower = word.toLowerCase(Locale.ROOT);
|
||||||
public static void configure(String wellKnownFile) {
|
// Точное совпадение с основой сильнее всего: «Яков» оканчивается на «ов»,
|
||||||
WELL_KNOWN_STATE.current = BUNDLED_WELL_KNOWN_STEMS;
|
// но это имя, а не фамилия.
|
||||||
WELL_KNOWN_STATE.mtime = -1;
|
if (GIVEN_NAMES.contains(lower)) {
|
||||||
WELL_KNOWN_STATE.lastCheck = 0;
|
return true;
|
||||||
// Путь фиксирован в статическом поле; для тестов используется useExternalFile.
|
}
|
||||||
if (!"config/well-known.txt".equals(wellKnownFile)) {
|
// По началу слова имя ищется с оглядкой на остаток: «Марина» это основа
|
||||||
useExternalFile(Path.of(wellKnownFile));
|
// «марин» плюс падежное «а», а «Романов» — основа «роман» плюс фамильное
|
||||||
|
// «ов». Без этой разницы «Бизнес-центр Романов Двор» принимался бы за
|
||||||
|
// человека, а «Марина Шевченко» переставала бы им быть.
|
||||||
|
for (int length = Math.max(1, lower.length() - MAX_INFLECTION);
|
||||||
|
length < lower.length();
|
||||||
|
length++) {
|
||||||
|
if (GIVEN_NAMES.contains(lower.substring(0, length))
|
||||||
|
&& !SURNAME_SUFFIXES.contains(lower.substring(length))) {
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Есть ли среди слов личное имя из словаря в любом падеже.
|
* Проверяет, что фрагмент — имя, отчество или фамилия человека. Используется для строчных имён
|
||||||
*
|
* после ролевого слова («клиент иван иванов»), где регистр не подсказывает, что перед нами имя.
|
||||||
* <p>Проверка множеством, а не чередованием в регулярном выражении: сто с лишним
|
*/
|
||||||
* веток пришлось бы перебирать в каждой позиции текста, здесь же на слово
|
public static boolean containsNamePart(String value) {
|
||||||
* приходится не больше четырёх обращений к хеш-таблице.
|
for (String word : value.split(WORD_SPLIT)) {
|
||||||
*/
|
String lower = word.toLowerCase(Locale.ROOT);
|
||||||
public static boolean containsGivenName(String value) {
|
if (GIVEN_NAMES.contains(lower)) {
|
||||||
for (String word : value.split(WORD_SPLIT)) {
|
return true;
|
||||||
String lower = word.toLowerCase(Locale.ROOT);
|
}
|
||||||
// Точное совпадение с основой сильнее всего: «Яков» оканчивается на «ов»,
|
if (isPatronymic(lower) || isSurname(lower)) {
|
||||||
// но это имя, а не фамилия.
|
return true;
|
||||||
if (GIVEN_NAMES.contains(lower)) {
|
}
|
||||||
return true;
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Слово само по себе похоже на имя, фамилию или отчество — без ролевого слова или соседнего
|
||||||
|
* личного имени рядом, самое слабое основание для ФИО. Точное совпадение с личным именем
|
||||||
|
* принимается в любом регистре («иван» тоже имя), а вот словообразовательная эвристика
|
||||||
|
* (фамилия/отчество по окончанию) — только с заглавной буквы: без этого «законов», «домов»,
|
||||||
|
* «холодов» — обычные родительные падежи, а не фамилии — ложно матчились бы.
|
||||||
|
*/
|
||||||
|
public static boolean isStandaloneNameCandidate(String word) {
|
||||||
|
String lower = word.toLowerCase(Locale.ROOT);
|
||||||
|
if (PD_MARKERS.contains(lower)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (GIVEN_NAMES.contains(lower)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (word.isEmpty() || !Character.isUpperCase(word.codePointAt(0))) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return isPatronymic(lower) || isSurname(lower);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Отчество: Иванович, Петровна, Сидоровна. */
|
||||||
|
private static boolean isPatronymic(String lower) {
|
||||||
|
return lower.matches(".*(?:ович|евич|овна|евна|ична|ичн)$");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Окончания, по которым слово похоже на фамилию: Иванов, Петрова, Троицкий, Шевченко. */
|
||||||
|
private static final Set<String> SURNAME_ENDINGS =
|
||||||
|
Set.of(
|
||||||
|
"ов", "ев", "ёв", "ин", "ын", "ский", "ская", "ского", "ской", "ском", "цкий", "цкая",
|
||||||
|
"енко", "ко", "ук", "юк", "ян", "швили", "дзе");
|
||||||
|
|
||||||
|
/** Фамилия по словообразованию. Набор окончаний вместо regex: проще и без CANON_EQ. */
|
||||||
|
private static boolean isSurname(String lower) {
|
||||||
|
for (String ending : SURNAME_ENDINGS) {
|
||||||
|
if (lower.endsWith(ending)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Содержит ли текст упоминание известного человека — из сборки или дописанных сверху.
|
||||||
|
*
|
||||||
|
* <p>Проверяются префиксы слова по множеству, а не каждая основа по слову: при тысяче с лишним
|
||||||
|
* записей (столько городов в {@link ToponymDictionary}, тот же приём) перебор списка на каждое
|
||||||
|
* слово текста был бы заметен, а префиксов у слова — не больше, чем в нём букв.
|
||||||
|
*/
|
||||||
|
public static boolean isWellKnown(String value) {
|
||||||
|
if (REGNAL_NAME.matcher(value.strip()).matches()) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
Set<String> stems = currentWellKnownStems();
|
||||||
|
for (String word : value.split(WORD_SPLIT)) {
|
||||||
|
String lower = word.toLowerCase(Locale.ROOT);
|
||||||
|
for (int length = lower.length(); length > 0; length--) {
|
||||||
|
if (stems.contains(lower.substring(0, length))) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Путь к внешнему файлу денилиста — для тестов, чтобы не трогать {@code config/}. */
|
||||||
|
static void useExternalFile(Path path) {
|
||||||
|
WELL_KNOWN_STATE.current = BUNDLED_WELL_KNOWN_STEMS;
|
||||||
|
WELL_KNOWN_STATE.mtime = -1;
|
||||||
|
WELL_KNOWN_STATE.lastCheck = 0;
|
||||||
|
// Перечитываем немедленно, минуя секундный троттлинг.
|
||||||
|
reloadExternal(path);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Перечитать внешний файл немедленно, минуя секундный троттлинг проверки. */
|
||||||
|
static synchronized void reloadExternal(Path path) {
|
||||||
|
WELL_KNOWN_STATE.lastCheck = System.currentTimeMillis();
|
||||||
|
if (!java.nio.file.Files.isReadable(path)) {
|
||||||
|
if (WELL_KNOWN_STATE.current != BUNDLED_WELL_KNOWN_STEMS) {
|
||||||
|
LOG.info(
|
||||||
|
"Внешний файл денилиста {} исчез, остаётся только встроенный список",
|
||||||
|
path.toAbsolutePath());
|
||||||
|
}
|
||||||
|
WELL_KNOWN_STATE.current = BUNDLED_WELL_KNOWN_STEMS;
|
||||||
|
WELL_KNOWN_STATE.mtime = 0;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
WELL_KNOWN_STATE.mtime = java.nio.file.Files.getLastModifiedTime(path).toMillis();
|
||||||
|
Set<String> merged = new HashSet<>(BUNDLED_WELL_KNOWN_STEMS);
|
||||||
|
for (String line :
|
||||||
|
java.nio.file.Files.readAllLines(path, java.nio.charset.StandardCharsets.UTF_8)) {
|
||||||
|
String trimmed = Declension.withoutInflectedEnding(line.trim());
|
||||||
|
if (!trimmed.isEmpty() && !trimmed.startsWith("#")) {
|
||||||
|
merged.add(trimmed);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
WELL_KNOWN_STATE.current = Set.copyOf(merged);
|
||||||
|
LOG.info(
|
||||||
|
"Денилист дополнен из {}: {} имён сверх встроенных",
|
||||||
|
path.toAbsolutePath(),
|
||||||
|
merged.size() - BUNDLED_WELL_KNOWN_STEMS.size());
|
||||||
|
} catch (java.io.IOException e) {
|
||||||
|
// Битый файл не должен ронять маскирование: остаётся прежний список.
|
||||||
|
LOG.error("Не удалось прочитать {}, денилист не изменён", path.toAbsolutePath(), e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Set<String> currentWellKnownStems() {
|
||||||
|
return ResourceLoader.refreshIfChanged(
|
||||||
|
EXTERNAL_FILE,
|
||||||
|
WELL_KNOWN_STATE,
|
||||||
|
lines -> {
|
||||||
|
Set<String> merged = new HashSet<>(BUNDLED_WELL_KNOWN_STEMS);
|
||||||
|
for (String line : lines) {
|
||||||
|
String trimmed = Declension.withoutInflectedEnding(line);
|
||||||
|
if (!trimmed.isEmpty()) {
|
||||||
|
merged.add(trimmed);
|
||||||
}
|
}
|
||||||
// По началу слова имя ищется с оглядкой на остаток: «Марина» это основа
|
}
|
||||||
// «марин» плюс падежное «а», а «Романов» — основа «роман» плюс фамильное
|
return Set.copyOf(merged);
|
||||||
// «ов». Без этой разницы «Бизнес-центр Романов Двор» принимался бы за
|
});
|
||||||
// человека, а «Марина Шевченко» переставала бы им быть.
|
}
|
||||||
for (int length = Math.max(1, lower.length() - MAX_INFLECTION); length < lower.length(); length++) {
|
}
|
||||||
if (GIVEN_NAMES.contains(lower.substring(0, length))
|
|
||||||
&& !SURNAME_SUFFIXES.contains(lower.substring(length))) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Проверяет, что фрагмент — имя, отчество или фамилия человека. Используется
|
|
||||||
* для строчных имён после ролевого слова («клиент иван иванов»), где регистр
|
|
||||||
* не подсказывает, что перед нами имя.
|
|
||||||
*/
|
|
||||||
public static boolean containsNamePart(String value) {
|
|
||||||
for (String word : value.split(WORD_SPLIT)) {
|
|
||||||
String lower = word.toLowerCase(Locale.ROOT);
|
|
||||||
if (GIVEN_NAMES.contains(lower)) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
if (isPatronymic(lower) || isSurname(lower)) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Слово само по себе похоже на имя, фамилию или отчество — без ролевого слова
|
|
||||||
* или соседнего личного имени рядом, самое слабое основание для ФИО. Точное
|
|
||||||
* совпадение с личным именем принимается в любом регистре («иван» тоже имя),
|
|
||||||
* а вот словообразовательная эвристика (фамилия/отчество по окончанию) —
|
|
||||||
* только с заглавной буквы: без этого «законов», «домов», «холодов» —
|
|
||||||
* обычные родительные падежи, а не фамилии — ложно матчились бы.
|
|
||||||
*/
|
|
||||||
public static boolean isStandaloneNameCandidate(String word) {
|
|
||||||
String lower = word.toLowerCase(Locale.ROOT);
|
|
||||||
if (GIVEN_NAMES.contains(lower)) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
if (word.isEmpty() || !Character.isUpperCase(word.codePointAt(0))) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
return isPatronymic(lower) || isSurname(lower);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Отчество: Иванович, Петровна, Сидоровна. */
|
|
||||||
private static boolean isPatronymic(String lower) {
|
|
||||||
return lower.matches(".*(?:ович|евич|овна|евна|ична|ичн)$");
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Окончания, по которым слово похоже на фамилию: Иванов, Петрова, Троицкий, Шевченко. */
|
|
||||||
private static final Set<String> SURNAME_ENDINGS = Set.of(
|
|
||||||
"ов", "ев", "ёв", "ин", "ын", "ский", "ская", "ского", "ской", "ском",
|
|
||||||
"цкий", "цкая", "енко", "ко", "ук", "юк", "ян", "швили", "дзе");
|
|
||||||
|
|
||||||
/** Фамилия по словообразованию. Набор окончаний вместо regex: проще и без CANON_EQ. */
|
|
||||||
private static boolean isSurname(String lower) {
|
|
||||||
for (String ending : SURNAME_ENDINGS) {
|
|
||||||
if (lower.endsWith(ending)) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Содержит ли текст упоминание известного человека — из сборки или дописанных
|
|
||||||
* сверху.
|
|
||||||
*
|
|
||||||
* <p>Проверяются префиксы слова по множеству, а не каждая основа по слову:
|
|
||||||
* при тысяче с лишним записей (столько городов в {@link ToponymDictionary},
|
|
||||||
* тот же приём) перебор списка на каждое слово текста был бы заметен, а
|
|
||||||
* префиксов у слова — не больше, чем в нём букв.
|
|
||||||
*/
|
|
||||||
public static boolean isWellKnown(String value) {
|
|
||||||
if (REGNAL_NAME.matcher(value.strip()).matches()) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
Set<String> stems = currentWellKnownStems();
|
|
||||||
for (String word : value.split(WORD_SPLIT)) {
|
|
||||||
String lower = word.toLowerCase(Locale.ROOT);
|
|
||||||
for (int length = lower.length(); length > 0; length--) {
|
|
||||||
if (stems.contains(lower.substring(0, length))) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Путь к внешнему файлу денилиста — для тестов, чтобы не трогать {@code config/}. */
|
|
||||||
static void useExternalFile(Path path) {
|
|
||||||
WELL_KNOWN_STATE.current = BUNDLED_WELL_KNOWN_STEMS;
|
|
||||||
WELL_KNOWN_STATE.mtime = -1;
|
|
||||||
WELL_KNOWN_STATE.lastCheck = 0;
|
|
||||||
// Перечитываем немедленно, минуя секундный троттлинг.
|
|
||||||
reloadExternal(path);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Перечитать внешний файл немедленно, минуя секундный троттлинг проверки. */
|
|
||||||
static synchronized void reloadExternal(Path path) {
|
|
||||||
WELL_KNOWN_STATE.lastCheck = System.currentTimeMillis();
|
|
||||||
if (!java.nio.file.Files.isReadable(path)) {
|
|
||||||
if (WELL_KNOWN_STATE.current != BUNDLED_WELL_KNOWN_STEMS) {
|
|
||||||
LOG.info("Внешний файл денилиста {} исчез, остаётся только встроенный список",
|
|
||||||
path.toAbsolutePath());
|
|
||||||
}
|
|
||||||
WELL_KNOWN_STATE.current = BUNDLED_WELL_KNOWN_STEMS;
|
|
||||||
WELL_KNOWN_STATE.mtime = 0;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
WELL_KNOWN_STATE.mtime = java.nio.file.Files.getLastModifiedTime(path).toMillis();
|
|
||||||
Set<String> merged = new HashSet<>(BUNDLED_WELL_KNOWN_STEMS);
|
|
||||||
for (String line : java.nio.file.Files.readAllLines(path, java.nio.charset.StandardCharsets.UTF_8)) {
|
|
||||||
String trimmed = Declension.withoutInflectedEnding(line.trim());
|
|
||||||
if (!trimmed.isEmpty() && !trimmed.startsWith("#")) {
|
|
||||||
merged.add(trimmed);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
WELL_KNOWN_STATE.current = Set.copyOf(merged);
|
|
||||||
LOG.info("Денилист дополнен из {}: {} имён сверх встроенных",
|
|
||||||
path.toAbsolutePath(), merged.size() - BUNDLED_WELL_KNOWN_STEMS.size());
|
|
||||||
} catch (java.io.IOException e) {
|
|
||||||
// Битый файл не должен ронять маскирование: остаётся прежний список.
|
|
||||||
LOG.error("Не удалось прочитать {}, денилист не изменён", path.toAbsolutePath(), e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private static Set<String> currentWellKnownStems() {
|
|
||||||
return ResourceLoader.refreshIfChanged(EXTERNAL_FILE, WELL_KNOWN_STATE,
|
|
||||||
lines -> {
|
|
||||||
Set<String> merged = new HashSet<>(BUNDLED_WELL_KNOWN_STEMS);
|
|
||||||
for (String line : lines) {
|
|
||||||
String trimmed = Declension.withoutInflectedEnding(line);
|
|
||||||
if (!trimmed.isEmpty()) {
|
|
||||||
merged.add(trimmed);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return Set.copyOf(merged);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -3,33 +3,34 @@ package ru.pdguard.detect;
|
|||||||
import java.util.regex.Pattern;
|
import java.util.regex.Pattern;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Проверка, стоит ли перед именем слово, относящее его к организации или
|
* Проверка, стоит ли перед именем слово, относящее его к организации или объекту на карте.
|
||||||
* объекту на карте.
|
|
||||||
*
|
*
|
||||||
* <p>«Институт Склифосовского», «Музей Тропинина», «улица Королёва» — это имена
|
* <p>«Институт Склифосовского», «Музей Тропинина», «улица Королёва» — это имена в названиях, а не
|
||||||
* в названиях, а не персональные данные. Отличие от списка известных людей в том,
|
* персональные данные. Отличие от списка известных людей в том, что здесь решает не само имя, а
|
||||||
* что здесь решает не само имя, а слово перед ним: клиент по фамилии Королёв
|
* слово перед ним: клиент по фамилии Королёв защиту не теряет, а улица Королёва под маску не
|
||||||
* защиту не теряет, а улица Королёва под маску не попадает.
|
* попадает.
|
||||||
*/
|
*/
|
||||||
public final class OrganisationDetector {
|
public final class OrganisationDetector {
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Маркер организации вплотную перед именем. Слово может стоять в любом падеже,
|
* Маркер организации вплотную перед именем. Слово может стоять в любом падеже, между ним и именем
|
||||||
* между ним и именем допускается «имени» или «им.» — «Премия имени Ломоносова».
|
* допускается «имени» или «им.» — «Премия имени Ломоносова».
|
||||||
*/
|
*/
|
||||||
private static final Pattern ORGANISATION_BEFORE = Pattern.compile(
|
private static final Pattern ORGANISATION_BEFORE =
|
||||||
"(?iu:" + String.join("|", ResourceLoader.lines("/names/organisations.txt", false)) + ")\\p{L}*"
|
Pattern.compile(
|
||||||
+ "(?:\\W{1,3}(?iu:имени|им\\.))?\\W{0,3}$",
|
"(?iu:"
|
||||||
Pattern.UNICODE_CHARACTER_CLASS | Pattern.UNICODE_CASE);
|
+ String.join("|", ResourceLoader.lines("/names/organisations.txt", false))
|
||||||
|
+ ")\\p{L}*"
|
||||||
|
+ "(?:\\W{1,3}(?iu:имени|им\\.))?\\W{0,3}$",
|
||||||
|
Pattern.UNICODE_CHARACTER_CLASS | Pattern.UNICODE_CASE);
|
||||||
|
|
||||||
/** Сколько знаков перед именем просматривается в поисках маркера организации. */
|
/** Сколько знаков перед именем просматривается в поисках маркера организации. */
|
||||||
private static final int ORGANISATION_LOOKBEHIND = 40;
|
private static final int ORGANISATION_LOOKBEHIND = 40;
|
||||||
|
|
||||||
private OrganisationDetector() {
|
private OrganisationDetector() {}
|
||||||
}
|
|
||||||
|
|
||||||
public static boolean precededByOrganisation(String text, int nameStart) {
|
public static boolean precededByOrganisation(String text, int nameStart) {
|
||||||
int from = Math.max(0, nameStart - ORGANISATION_LOOKBEHIND);
|
int from = Math.max(0, nameStart - ORGANISATION_LOOKBEHIND);
|
||||||
return ORGANISATION_BEFORE.matcher(text.substring(from, nameStart)).find();
|
return ORGANISATION_BEFORE.matcher(text.substring(from, nameStart)).find();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,56 +3,57 @@ package ru.pdguard.detect;
|
|||||||
/**
|
/**
|
||||||
* Имена типов персональных данных, которые умеет распознавать сервис.
|
* Имена типов персональных данных, которые умеет распознавать сервис.
|
||||||
*
|
*
|
||||||
* <p>Вынесены из {@link RuleRegistry} отдельно: константы используются и в
|
* <p>Вынесены из {@link RuleRegistry} отдельно: константы используются и в правилах, и в
|
||||||
* правилах, и в маскировании ({@link ru.pdguard.mask.Masker}), и в политиках
|
* маскировании ({@link ru.pdguard.mask.Masker}), и в политиках ({@link
|
||||||
* ({@link ru.pdguard.config.SystemPolicy}), и в синтетических подстановках
|
* ru.pdguard.config.SystemPolicy}), и в синтетических подстановках ({@link
|
||||||
* ({@link ru.pdguard.mask.Synthetic}). Единое место — чтобы имя типа не
|
* ru.pdguard.mask.Synthetic}). Единое место — чтобы имя типа не расходилось между слоями.
|
||||||
* расходилось между слоями.
|
|
||||||
*/
|
*/
|
||||||
public final class PdTypes {
|
public final class PdTypes {
|
||||||
|
|
||||||
private PdTypes() {
|
private PdTypes() {}
|
||||||
}
|
|
||||||
|
|
||||||
public static final String EMAIL = "EMAIL";
|
public static final String EMAIL = "EMAIL";
|
||||||
public static final String PHONE = "PHONE";
|
public static final String PHONE = "PHONE";
|
||||||
public static final String CARD = "CARD";
|
public static final String CARD = "CARD";
|
||||||
public static final String INN = "INN";
|
public static final String INN = "INN";
|
||||||
public static final String SNILS = "SNILS";
|
public static final String SNILS = "SNILS";
|
||||||
public static final String PASSPORT = "PASSPORT";
|
public static final String PASSPORT = "PASSPORT";
|
||||||
public static final String PASSPORT_ISSUER = "PASSPORT_ISSUER";
|
public static final String PASSPORT_ISSUER = "PASSPORT_ISSUER";
|
||||||
public static final String PASSPORT_DATE = "PASSPORT_DATE";
|
public static final String PASSPORT_DATE = "PASSPORT_DATE";
|
||||||
public static final String DEPT_CODE = "DEPT_CODE";
|
public static final String DEPT_CODE = "DEPT_CODE";
|
||||||
public static final String DRIVER_LICENSE = "DRIVER_LICENSE";
|
public static final String DRIVER_LICENSE = "DRIVER_LICENSE";
|
||||||
public static final String CITIZENSHIP = "CITIZENSHIP";
|
public static final String CITIZENSHIP = "CITIZENSHIP";
|
||||||
public static final String BIRTH_PLACE = "BIRTH_PLACE";
|
public static final String BIRTH_PLACE = "BIRTH_PLACE";
|
||||||
public static final String BIRTH_DATE = "BIRTH_DATE";
|
public static final String BIRTH_DATE = "BIRTH_DATE";
|
||||||
public static final String DATE = "DATE";
|
public static final String DATE = "DATE";
|
||||||
public static final String CVV = "CVV";
|
public static final String CVV = "CVV";
|
||||||
public static final String PIN = "PIN";
|
public static final String PIN = "PIN";
|
||||||
public static final String CARDHOLDER = "CARDHOLDER";
|
public static final String CARDHOLDER = "CARDHOLDER";
|
||||||
public static final String ADDRESS_COUNTRY = "ADDRESS_COUNTRY";
|
public static final String ADDRESS_COUNTRY = "ADDRESS_COUNTRY";
|
||||||
public static final String ADDRESS_POSTCODE = "ADDRESS_POSTCODE";
|
public static final String ADDRESS_POSTCODE = "ADDRESS_POSTCODE";
|
||||||
public static final String ADDRESS_CITY = "ADDRESS_CITY";
|
public static final String ADDRESS_CITY = "ADDRESS_CITY";
|
||||||
public static final String ADDRESS_STREET = "ADDRESS_STREET";
|
public static final String ADDRESS_STREET = "ADDRESS_STREET";
|
||||||
public static final String ADDRESS_HOUSE = "ADDRESS_HOUSE";
|
public static final String ADDRESS_HOUSE = "ADDRESS_HOUSE";
|
||||||
public static final String ADDRESS_FLAT = "ADDRESS_FLAT";
|
public static final String ADDRESS_FLAT = "ADDRESS_FLAT";
|
||||||
/** Регион и район размечает только модель второй ступени: правил под них нет. */
|
|
||||||
public static final String ADDRESS_REGION = "ADDRESS_REGION";
|
|
||||||
public static final String ADDRESS_DISTRICT = "ADDRESS_DISTRICT";
|
|
||||||
public static final String FIO = "FIO";
|
|
||||||
public static final String FOREIGN_PASSPORT = "FOREIGN_PASSPORT";
|
|
||||||
public static final String MILITARY_ID = "MILITARY_ID";
|
|
||||||
public static final String BIRTH_CERTIFICATE = "BIRTH_CERTIFICATE";
|
|
||||||
public static final String MEDICAL_POLICY = "MEDICAL_POLICY";
|
|
||||||
|
|
||||||
/** Банковские реквизиты сверх платёжной карты. */
|
/** Регион и район размечает только модель второй ступени: правил под них нет. */
|
||||||
public static final String ACCOUNT_NUMBER = "ACCOUNT_NUMBER";
|
public static final String ADDRESS_REGION = "ADDRESS_REGION";
|
||||||
public static final String BIK = "BIK";
|
|
||||||
public static final String CARD_EXPIRY = "CARD_EXPIRY";
|
public static final String ADDRESS_DISTRICT = "ADDRESS_DISTRICT";
|
||||||
public static final String INCOME = "INCOME";
|
public static final String FIO = "FIO";
|
||||||
public static final String OGRN = "OGRN";
|
public static final String FOREIGN_PASSPORT = "FOREIGN_PASSPORT";
|
||||||
public static final String OGRNIP = "OGRNIP";
|
public static final String MILITARY_ID = "MILITARY_ID";
|
||||||
public static final String KPP = "KPP";
|
public static final String BIRTH_CERTIFICATE = "BIRTH_CERTIFICATE";
|
||||||
public static final String BIOMETRIC = "BIOMETRIC";
|
public static final String MEDICAL_POLICY = "MEDICAL_POLICY";
|
||||||
}
|
|
||||||
|
/** Банковские реквизиты сверх платёжной карты. */
|
||||||
|
public static final String ACCOUNT_NUMBER = "ACCOUNT_NUMBER";
|
||||||
|
|
||||||
|
public static final String BIK = "BIK";
|
||||||
|
public static final String CARD_EXPIRY = "CARD_EXPIRY";
|
||||||
|
public static final String INCOME = "INCOME";
|
||||||
|
public static final String OGRN = "OGRN";
|
||||||
|
public static final String OGRNIP = "OGRNIP";
|
||||||
|
public static final String KPP = "KPP";
|
||||||
|
public static final String BIOMETRIC = "BIOMETRIC";
|
||||||
|
}
|
||||||
|
|||||||
@@ -18,103 +18,103 @@ import org.slf4j.LoggerFactory;
|
|||||||
/**
|
/**
|
||||||
* Общие приёмы чтения словарей и внешних файлов.
|
* Общие приёмы чтения словарей и внешних файлов.
|
||||||
*
|
*
|
||||||
* <p>Словари лежат в сборке как ресурсы и читаются одинаково: строки обрезаются,
|
* <p>Словари лежат в сборке как ресурсы и читаются одинаково: строки обрезаются, пустые и
|
||||||
* пустые и комментарии отбрасываются. Внешние файлы (денилист, настройки систем)
|
* комментарии отбрасываются. Внешние файлы (денилист, настройки систем) перечитываются, когда
|
||||||
* перечитываются, когда меняется время их изменения, и не чаще раза в секунду —
|
* меняется время их изменения, и не чаще раза в секунду — чтобы не ходить в файловую систему на
|
||||||
* чтобы не ходить в файловую систему на каждом запросе. Обе задачи вынесены сюда,
|
* каждом запросе. Обе задачи вынесены сюда, чтобы не дублировать их в каждом словаре.
|
||||||
* чтобы не дублировать их в каждом словаре.
|
|
||||||
*/
|
*/
|
||||||
final class ResourceLoader {
|
final class ResourceLoader {
|
||||||
|
|
||||||
private static final Logger LOG = LoggerFactory.getLogger(ResourceLoader.class);
|
private static final Logger LOG = LoggerFactory.getLogger(ResourceLoader.class);
|
||||||
|
|
||||||
private ResourceLoader() {
|
private ResourceLoader() {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Читает строки ресурса, отбрасывая пустые и комментарии.
|
||||||
|
*
|
||||||
|
* @param resource путь к ресурсу в classpath
|
||||||
|
* @param sortByLength сортировать ли от длинных к коротким (нужно для чередований)
|
||||||
|
*/
|
||||||
|
static List<String> lines(String resource, boolean sortByLength) {
|
||||||
|
try (InputStream in = ResourceLoader.class.getResourceAsStream(resource)) {
|
||||||
|
if (in == null) {
|
||||||
|
throw new IllegalStateException("Словарь не найден в сборке: " + resource);
|
||||||
|
}
|
||||||
|
try (BufferedReader reader =
|
||||||
|
new BufferedReader(new InputStreamReader(in, StandardCharsets.UTF_8))) {
|
||||||
|
return reader
|
||||||
|
.lines()
|
||||||
|
.map(String::trim)
|
||||||
|
.filter(line -> !line.isEmpty() && !line.startsWith("#"))
|
||||||
|
.distinct()
|
||||||
|
.sorted(
|
||||||
|
sortByLength
|
||||||
|
? Comparator.comparingInt(String::length).reversed()
|
||||||
|
: Comparator.naturalOrder())
|
||||||
|
.toList();
|
||||||
|
}
|
||||||
|
} catch (IOException e) {
|
||||||
|
throw new UncheckedIOException("Не удалось прочитать словарь " + resource, e);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/** Читает строки ресурса в множество, отбрасывая пустые и комментарии. */
|
||||||
* Читает строки ресурса, отбрасывая пустые и комментарии.
|
static java.util.Set<String> set(String resource) {
|
||||||
*
|
return lines(resource, false).stream().collect(Collectors.toUnmodifiableSet());
|
||||||
* @param resource путь к ресурсу в classpath
|
}
|
||||||
* @param sortByLength сортировать ли от длинных к коротким (нужно для чередований)
|
|
||||||
*/
|
/**
|
||||||
static List<String> lines(String resource, boolean sortByLength) {
|
* Перечитывает внешний файл, когда меняется время его изменения, не чаще раза в секунду.
|
||||||
try (InputStream in = ResourceLoader.class.getResourceAsStream(resource)) {
|
* Возвращает текущее содержимое; при недоступности файла — прежнее.
|
||||||
if (in == null) {
|
*
|
||||||
throw new IllegalStateException("Словарь не найден в сборке: " + resource);
|
* @param path путь к файлу
|
||||||
}
|
* @param state состояние проверки (время последней проверки и mtime файла)
|
||||||
try (BufferedReader reader = new BufferedReader(new InputStreamReader(in, StandardCharsets.UTF_8))) {
|
* @param reader как превратить строки файла в итоговое значение
|
||||||
return reader.lines()
|
*/
|
||||||
.map(String::trim)
|
static <T> T refreshIfChanged(
|
||||||
.filter(line -> !line.isEmpty() && !line.startsWith("#"))
|
Path path, FileWatchState<T> state, Function<List<String>, T> reader) {
|
||||||
.distinct()
|
long now = System.currentTimeMillis();
|
||||||
.sorted(sortByLength
|
if (now - state.lastCheck < state.recheckMillis) {
|
||||||
? Comparator.comparingInt(String::length).reversed()
|
return state.current;
|
||||||
: Comparator.naturalOrder())
|
|
||||||
.toList();
|
|
||||||
}
|
|
||||||
} catch (IOException e) {
|
|
||||||
throw new UncheckedIOException("Не удалось прочитать словарь " + resource, e);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
state.lastCheck = now;
|
||||||
/**
|
try {
|
||||||
* Читает строки ресурса в множество, отбрасывая пустые и комментарии.
|
if (!Files.isReadable(path)) {
|
||||||
*/
|
|
||||||
static java.util.Set<String> set(String resource) {
|
|
||||||
return lines(resource, false).stream().collect(Collectors.toUnmodifiableSet());
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Перечитывает внешний файл, когда меняется время его изменения, не чаще раза
|
|
||||||
* в секунду. Возвращает текущее содержимое; при недоступности файла — прежнее.
|
|
||||||
*
|
|
||||||
* @param path путь к файлу
|
|
||||||
* @param state состояние проверки (время последней проверки и mtime файла)
|
|
||||||
* @param reader как превратить строки файла в итоговое значение
|
|
||||||
*/
|
|
||||||
static <T> T refreshIfChanged(Path path, FileWatchState<T> state,
|
|
||||||
Function<List<String>, T> reader) {
|
|
||||||
long now = System.currentTimeMillis();
|
|
||||||
if (now - state.lastCheck < state.recheckMillis) {
|
|
||||||
return state.current;
|
|
||||||
}
|
|
||||||
state.lastCheck = now;
|
|
||||||
try {
|
|
||||||
if (!Files.isReadable(path)) {
|
|
||||||
return state.current;
|
|
||||||
}
|
|
||||||
long mtime = Files.getLastModifiedTime(path).toMillis();
|
|
||||||
if (mtime != state.mtime) {
|
|
||||||
state.mtime = mtime;
|
|
||||||
List<String> lines = Files.readAllLines(path, StandardCharsets.UTF_8).stream()
|
|
||||||
.map(String::trim)
|
|
||||||
.filter(line -> !line.isEmpty() && !line.startsWith("#"))
|
|
||||||
.toList();
|
|
||||||
state.current = reader.apply(lines);
|
|
||||||
}
|
|
||||||
} catch (IOException e) {
|
|
||||||
// Битый файл не должен ронять работу: остаётся прежнее значение.
|
|
||||||
LOG.warn("Не удалось перечитать файл {}", path, e);
|
|
||||||
}
|
|
||||||
return state.current;
|
return state.current;
|
||||||
|
}
|
||||||
|
long mtime = Files.getLastModifiedTime(path).toMillis();
|
||||||
|
if (mtime != state.mtime) {
|
||||||
|
state.mtime = mtime;
|
||||||
|
List<String> lines =
|
||||||
|
Files.readAllLines(path, StandardCharsets.UTF_8).stream()
|
||||||
|
.map(String::trim)
|
||||||
|
.filter(line -> !line.isEmpty() && !line.startsWith("#"))
|
||||||
|
.toList();
|
||||||
|
state.current = reader.apply(lines);
|
||||||
|
}
|
||||||
|
} catch (IOException e) {
|
||||||
|
// Битый файл не должен ронять работу: остаётся прежнее значение.
|
||||||
|
LOG.warn("Не удалось перечитать файл {}", path, e);
|
||||||
|
}
|
||||||
|
return state.current;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Состояние проверки внешнего файла: время последней проверки и mtime. */
|
||||||
|
static final class FileWatchState<T> {
|
||||||
|
private static final long DEFAULT_RECHECK_MILLIS = 1000;
|
||||||
|
|
||||||
|
final long recheckMillis;
|
||||||
|
long lastCheck;
|
||||||
|
long mtime;
|
||||||
|
T current;
|
||||||
|
|
||||||
|
FileWatchState(T initial, long recheckMillis) {
|
||||||
|
this.current = initial;
|
||||||
|
this.recheckMillis = recheckMillis;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Состояние проверки внешнего файла: время последней проверки и mtime. */
|
FileWatchState(T initial) {
|
||||||
static final class FileWatchState<T> {
|
this(initial, DEFAULT_RECHECK_MILLIS);
|
||||||
private static final long DEFAULT_RECHECK_MILLIS = 1000;
|
|
||||||
|
|
||||||
final long recheckMillis;
|
|
||||||
long lastCheck;
|
|
||||||
long mtime;
|
|
||||||
T current;
|
|
||||||
|
|
||||||
FileWatchState(T initial, long recheckMillis) {
|
|
||||||
this.current = initial;
|
|
||||||
this.recheckMillis = recheckMillis;
|
|
||||||
}
|
|
||||||
|
|
||||||
FileWatchState(T initial) {
|
|
||||||
this(initial, DEFAULT_RECHECK_MILLIS);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -6,9 +6,6 @@ import ai.onnxruntime.OrtException;
|
|||||||
import ai.onnxruntime.OrtSession;
|
import ai.onnxruntime.OrtSession;
|
||||||
import com.fasterxml.jackson.databind.JsonNode;
|
import com.fasterxml.jackson.databind.JsonNode;
|
||||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
import org.slf4j.Logger;
|
|
||||||
import org.slf4j.LoggerFactory;
|
|
||||||
|
|
||||||
import java.io.IOException;
|
import java.io.IOException;
|
||||||
import java.nio.LongBuffer;
|
import java.nio.LongBuffer;
|
||||||
import java.nio.file.Files;
|
import java.nio.file.Files;
|
||||||
@@ -19,197 +16,213 @@ import java.util.Iterator;
|
|||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
import java.util.Set;
|
import java.util.Set;
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Распознаватель на BERT: размечает имена и составляющие адреса за один проход.
|
* Распознаватель на BERT: размечает имена и составляющие адреса за один проход.
|
||||||
*
|
*
|
||||||
* <p>В отличие от правил, он опознаёт имена без русского словообразования и
|
* <p>В отличие от правил, он опознаёт имена без русского словообразования и нестандартные топонимы.
|
||||||
* нестандартные топонимы. Метки модели ложатся почти один в один
|
* Метки модели ложатся почти один в один на типы из технического задания: имя, отчество, фамилия,
|
||||||
* на типы из технического задания: имя, отчество, фамилия, страна, регион, район,
|
* страна, регион, район, город, улица, дом.
|
||||||
* город, улица, дом.
|
|
||||||
*
|
*
|
||||||
* <p>Модель тяжёлая — сто семьдесят мегабайт и около двенадцати миллисекунд на
|
* <p>Модель тяжёлая — сто семьдесят мегабайт и около двенадцати миллисекунд на вызов, — поэтому её
|
||||||
* вызов, — поэтому её зовут только на участках, которые не разобрала первая
|
* зовут только на участках, которые не разобрала первая ступень. Одновременных вызовов не больше,
|
||||||
* ступень. Одновременных вызовов не больше, чем задано: иначе один запрос с
|
* чем задано: иначе один запрос с десятком кандидатов занял бы все ядра.
|
||||||
* десятком кандидатов занял бы все ядра.
|
|
||||||
*/
|
*/
|
||||||
final class RuBertRecogniser {
|
final class RuBertRecogniser {
|
||||||
|
|
||||||
private static final Logger LOG = LoggerFactory.getLogger(RuBertRecogniser.class);
|
private static final Logger LOG = LoggerFactory.getLogger(RuBertRecogniser.class);
|
||||||
|
|
||||||
/** Предел длины входа: участки короткие, до потолка модели в 512 далеко. */
|
/** Предел длины входа: участки короткие, до потолка модели в 512 далеко. */
|
||||||
private static final int MAX_PIECES = 190;
|
private static final int MAX_PIECES = 190;
|
||||||
|
|
||||||
private final OrtEnvironment environment;
|
private final OrtEnvironment environment;
|
||||||
private final OrtSession session;
|
private final OrtSession session;
|
||||||
private final WordPiece tokenizer;
|
private final WordPiece tokenizer;
|
||||||
private final String[] labels;
|
private final String[] labels;
|
||||||
private final Set<String> inputNames;
|
private final Set<String> inputNames;
|
||||||
private final Map<String, String> types;
|
private final Map<String, String> types;
|
||||||
|
|
||||||
private RuBertRecogniser(OrtEnvironment environment, OrtSession session,
|
private RuBertRecogniser(
|
||||||
WordPiece tokenizer, String[] labels, Map<String, String> types) {
|
OrtEnvironment environment,
|
||||||
this.environment = environment;
|
OrtSession session,
|
||||||
this.session = session;
|
WordPiece tokenizer,
|
||||||
this.tokenizer = tokenizer;
|
String[] labels,
|
||||||
this.labels = labels;
|
Map<String, String> types) {
|
||||||
this.inputNames = session.getInputNames();
|
this.environment = environment;
|
||||||
this.types = types;
|
this.session = session;
|
||||||
|
this.tokenizer = tokenizer;
|
||||||
|
this.labels = labels;
|
||||||
|
this.inputNames = session.getInputNames();
|
||||||
|
this.types = types;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Загружает модель из каталога с файлами {@code model.onnx}, {@code tokenizer.json} и {@code
|
||||||
|
* config.json}. Каталог недоступен или испорчен — вернётся {@code null}, и сервис продолжит
|
||||||
|
* работать на правилах.
|
||||||
|
*
|
||||||
|
* @param types соответствие меток модели типам ПД сервиса
|
||||||
|
*/
|
||||||
|
static RuBertRecogniser load(Path directory, int threadsPerCall, Map<String, String> types) {
|
||||||
|
Path model = directory.resolve("model.onnx");
|
||||||
|
Path tokenizer = directory.resolve("tokenizer.json");
|
||||||
|
Path config = directory.resolve("config.json");
|
||||||
|
if (!Files.isReadable(model) || !Files.isReadable(tokenizer) || !Files.isReadable(config)) {
|
||||||
|
LOG.warn("Модель BERT в {} неполна, распознаватель не создан", directory.toAbsolutePath());
|
||||||
|
return null;
|
||||||
}
|
}
|
||||||
|
OrtSession session = null;
|
||||||
/**
|
try {
|
||||||
* Загружает модель из каталога с файлами {@code model.onnx}, {@code tokenizer.json}
|
OrtEnvironment environment = OrtEnvironment.getEnvironment();
|
||||||
* и {@code config.json}. Каталог недоступен или испорчен — вернётся {@code null},
|
try (OrtSession.SessionOptions options = new OrtSession.SessionOptions()) {
|
||||||
* и сервис продолжит работать на правилах.
|
options.setIntraOpNumThreads(threadsPerCall);
|
||||||
*
|
options.setInterOpNumThreads(1);
|
||||||
* @param types соответствие меток модели типам ПД сервиса
|
session = environment.createSession(model.toString(), options);
|
||||||
*/
|
}
|
||||||
static RuBertRecogniser load(Path directory, int threadsPerCall, Map<String, String> types) {
|
RuBertRecogniser recogniser =
|
||||||
Path model = directory.resolve("model.onnx");
|
new RuBertRecogniser(
|
||||||
Path tokenizer = directory.resolve("tokenizer.json");
|
environment,
|
||||||
Path config = directory.resolve("config.json");
|
session,
|
||||||
if (!Files.isReadable(model) || !Files.isReadable(tokenizer) || !Files.isReadable(config)) {
|
WordPiece.fromTokenizerJson(tokenizer),
|
||||||
LOG.warn("Модель BERT в {} неполна, распознаватель не создан", directory.toAbsolutePath());
|
readLabels(config),
|
||||||
return null;
|
types);
|
||||||
}
|
LOG.info("Распознаватель BERT готов, модель {}", model.toAbsolutePath());
|
||||||
OrtSession session = null;
|
return recogniser;
|
||||||
try {
|
} catch (OrtException | IOException | RuntimeException e) {
|
||||||
OrtEnvironment environment = OrtEnvironment.getEnvironment();
|
closeQuietly(session);
|
||||||
try (OrtSession.SessionOptions options = new OrtSession.SessionOptions()) {
|
LOG.error("Не удалось загрузить модель BERT из {}", directory.toAbsolutePath(), e);
|
||||||
options.setIntraOpNumThreads(threadsPerCall);
|
return null;
|
||||||
options.setInterOpNumThreads(1);
|
|
||||||
session = environment.createSession(model.toString(), options);
|
|
||||||
}
|
|
||||||
RuBertRecogniser recogniser = new RuBertRecogniser(environment, session,
|
|
||||||
WordPiece.fromTokenizerJson(tokenizer), readLabels(config), types);
|
|
||||||
LOG.info("Распознаватель BERT готов, модель {}", model.toAbsolutePath());
|
|
||||||
return recogniser;
|
|
||||||
} catch (OrtException | IOException | RuntimeException e) {
|
|
||||||
closeQuietly(session);
|
|
||||||
LOG.error("Не удалось загрузить модель BERT из {}", directory.toAbsolutePath(), e);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private static void closeQuietly(OrtSession session) {
|
private static void closeQuietly(OrtSession session) {
|
||||||
if (session == null) {
|
if (session == null) {
|
||||||
return;
|
return;
|
||||||
}
|
|
||||||
try {
|
|
||||||
session.close();
|
|
||||||
} catch (OrtException e) {
|
|
||||||
LOG.debug("Не удалось закрыть сессию модели при ошибке загрузки", e);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
try {
|
||||||
List<Span> recognise(String text, int from, int to, int priority) {
|
session.close();
|
||||||
String region = text.substring(from, to);
|
} catch (OrtException e) {
|
||||||
List<WordPiece.Piece> pieces = tokenizer.split(region, MAX_PIECES);
|
LOG.debug("Не удалось закрыть сессию модели при ошибке загрузки", e);
|
||||||
if (pieces.isEmpty()) {
|
|
||||||
return List.of();
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
String[] tags = classify(pieces);
|
|
||||||
return toSpans(pieces, tags, from, priority, types);
|
|
||||||
} catch (OrtException e) {
|
|
||||||
throw new IllegalStateException("Сбой вычисления модели BERT", e);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private String[] classify(List<WordPiece.Piece> pieces) throws OrtException {
|
List<Span> recognise(String text, int from, int to, int priority) {
|
||||||
int length = pieces.size() + 2;
|
String region = text.substring(from, to);
|
||||||
long[] ids = new long[length];
|
List<WordPiece.Piece> pieces = tokenizer.split(region, MAX_PIECES);
|
||||||
long[] mask = new long[length];
|
if (pieces.isEmpty()) {
|
||||||
long[] tokenTypes = new long[length];
|
return List.of();
|
||||||
ids[0] = tokenizer.classifyId();
|
}
|
||||||
|
try {
|
||||||
|
String[] tags = classify(pieces);
|
||||||
|
return toSpans(pieces, tags, from, priority, types);
|
||||||
|
} catch (OrtException e) {
|
||||||
|
throw new IllegalStateException("Сбой вычисления модели BERT", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private String[] classify(List<WordPiece.Piece> pieces) throws OrtException {
|
||||||
|
int length = pieces.size() + 2;
|
||||||
|
long[] ids = new long[length];
|
||||||
|
long[] mask = new long[length];
|
||||||
|
long[] tokenTypes = new long[length];
|
||||||
|
ids[0] = tokenizer.classifyId();
|
||||||
|
for (int i = 0; i < pieces.size(); i++) {
|
||||||
|
ids[i + 1] = pieces.get(i).id();
|
||||||
|
}
|
||||||
|
ids[length - 1] = tokenizer.separatorId();
|
||||||
|
java.util.Arrays.fill(mask, 1L);
|
||||||
|
|
||||||
|
long[] shape = {1, length};
|
||||||
|
Map<String, OnnxTensor> inputs = new HashMap<>();
|
||||||
|
try {
|
||||||
|
inputs.put("input_ids", OnnxTensor.createTensor(environment, LongBuffer.wrap(ids), shape));
|
||||||
|
inputs.put(
|
||||||
|
"attention_mask", OnnxTensor.createTensor(environment, LongBuffer.wrap(mask), shape));
|
||||||
|
if (inputNames.contains("token_type_ids")) {
|
||||||
|
inputs.put(
|
||||||
|
"token_type_ids",
|
||||||
|
OnnxTensor.createTensor(environment, LongBuffer.wrap(tokenTypes), shape));
|
||||||
|
}
|
||||||
|
inputs.keySet().retainAll(inputNames);
|
||||||
|
try (OrtSession.Result result = session.run(inputs)) {
|
||||||
|
float[][][] logits = (float[][][]) result.get(0).getValue();
|
||||||
|
String[] tags = new String[pieces.size()];
|
||||||
for (int i = 0; i < pieces.size(); i++) {
|
for (int i = 0; i < pieces.size(); i++) {
|
||||||
ids[i + 1] = pieces.get(i).id();
|
tags[i] = labels[argmax(logits[0][i + 1])];
|
||||||
}
|
|
||||||
ids[length - 1] = tokenizer.separatorId();
|
|
||||||
java.util.Arrays.fill(mask, 1L);
|
|
||||||
|
|
||||||
long[] shape = {1, length};
|
|
||||||
Map<String, OnnxTensor> inputs = new HashMap<>();
|
|
||||||
try {
|
|
||||||
inputs.put("input_ids", OnnxTensor.createTensor(environment, LongBuffer.wrap(ids), shape));
|
|
||||||
inputs.put("attention_mask", OnnxTensor.createTensor(environment, LongBuffer.wrap(mask), shape));
|
|
||||||
if (inputNames.contains("token_type_ids")) {
|
|
||||||
inputs.put("token_type_ids", OnnxTensor.createTensor(environment, LongBuffer.wrap(tokenTypes), shape));
|
|
||||||
}
|
|
||||||
inputs.keySet().retainAll(inputNames);
|
|
||||||
try (OrtSession.Result result = session.run(inputs)) {
|
|
||||||
float[][][] logits = (float[][][]) result.get(0).getValue();
|
|
||||||
String[] tags = new String[pieces.size()];
|
|
||||||
for (int i = 0; i < pieces.size(); i++) {
|
|
||||||
tags[i] = labels[argmax(logits[0][i + 1])];
|
|
||||||
}
|
|
||||||
return tags;
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
inputs.values().forEach(OnnxTensor::close);
|
|
||||||
}
|
}
|
||||||
|
return tags;
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
inputs.values().forEach(OnnxTensor::close);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Собирает подряд идущие подслова одной сущности в фрагменты исходного текста.
|
* Собирает подряд идущие подслова одной сущности в фрагменты исходного текста. Схема разметки
|
||||||
* Схема разметки различает начало, середину, конец и одиночный токен, но для
|
* различает начало, середину, конец и одиночный токен, но для сборки достаточно смены типа:
|
||||||
* сборки достаточно смены типа: границы участков и так проставлены по словам.
|
* границы участков и так проставлены по словам.
|
||||||
*/
|
*/
|
||||||
private static List<Span> toSpans(List<WordPiece.Piece> pieces, String[] tags, int offset, int priority,
|
private static List<Span> toSpans(
|
||||||
Map<String, String> types) {
|
List<WordPiece.Piece> pieces,
|
||||||
List<Span> spans = new ArrayList<>();
|
String[] tags,
|
||||||
String currentType = null;
|
int offset,
|
||||||
int start = 0;
|
int priority,
|
||||||
int end = 0;
|
Map<String, String> types) {
|
||||||
for (int i = 0; i < tags.length; i++) {
|
List<Span> spans = new ArrayList<>();
|
||||||
String type = types.get(entityOf(tags[i]));
|
String currentType = null;
|
||||||
if (type != null && type.equals(currentType)) {
|
int start = 0;
|
||||||
end = pieces.get(i).end();
|
int end = 0;
|
||||||
continue;
|
for (int i = 0; i < tags.length; i++) {
|
||||||
}
|
String type = types.get(entityOf(tags[i]));
|
||||||
if (currentType != null) {
|
if (type != null && type.equals(currentType)) {
|
||||||
spans.add(new Span(offset + start, offset + end, currentType, priority));
|
end = pieces.get(i).end();
|
||||||
}
|
continue;
|
||||||
currentType = type;
|
}
|
||||||
start = pieces.get(i).start();
|
if (currentType != null) {
|
||||||
end = pieces.get(i).end();
|
spans.add(new Span(offset + start, offset + end, currentType, priority));
|
||||||
}
|
}
|
||||||
if (currentType != null) {
|
currentType = type;
|
||||||
spans.add(new Span(offset + start, offset + end, currentType, priority));
|
start = pieces.get(i).start();
|
||||||
}
|
end = pieces.get(i).end();
|
||||||
return spans;
|
|
||||||
}
|
}
|
||||||
|
if (currentType != null) {
|
||||||
|
spans.add(new Span(offset + start, offset + end, currentType, priority));
|
||||||
|
}
|
||||||
|
return spans;
|
||||||
|
}
|
||||||
|
|
||||||
private static String entityOf(String tag) {
|
private static String entityOf(String tag) {
|
||||||
int dash = tag.indexOf('-');
|
int dash = tag.indexOf('-');
|
||||||
return dash < 0 ? tag : tag.substring(dash + 1);
|
return dash < 0 ? tag : tag.substring(dash + 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static int argmax(float[] scores) {
|
private static int argmax(float[] scores) {
|
||||||
int best = 0;
|
int best = 0;
|
||||||
for (int i = 1; i < scores.length; i++) {
|
for (int i = 1; i < scores.length; i++) {
|
||||||
if (scores[i] > scores[best]) {
|
if (scores[i] > scores[best]) {
|
||||||
best = i;
|
best = i;
|
||||||
}
|
}
|
||||||
}
|
|
||||||
return best;
|
|
||||||
}
|
}
|
||||||
|
return best;
|
||||||
|
}
|
||||||
|
|
||||||
private static String[] readLabels(Path config) throws IOException {
|
private static String[] readLabels(Path config) throws IOException {
|
||||||
JsonNode node = new ObjectMapper().readTree(Files.readAllBytes(config)).get("id2label");
|
JsonNode node = new ObjectMapper().readTree(Files.readAllBytes(config)).get("id2label");
|
||||||
String[] labels = new String[node.size()];
|
String[] labels = new String[node.size()];
|
||||||
for (Iterator<Map.Entry<String, JsonNode>> it = node.fields(); it.hasNext(); ) {
|
for (Iterator<Map.Entry<String, JsonNode>> it = node.fields(); it.hasNext(); ) {
|
||||||
Map.Entry<String, JsonNode> entry = it.next();
|
Map.Entry<String, JsonNode> entry = it.next();
|
||||||
labels[Integer.parseInt(entry.getKey())] = entry.getValue().asText();
|
labels[Integer.parseInt(entry.getKey())] = entry.getValue().asText();
|
||||||
}
|
|
||||||
return labels;
|
|
||||||
}
|
}
|
||||||
|
return labels;
|
||||||
|
}
|
||||||
|
|
||||||
void close() {
|
void close() {
|
||||||
try {
|
try {
|
||||||
session.close();
|
session.close();
|
||||||
} catch (OrtException e) {
|
} catch (OrtException e) {
|
||||||
LOG.debug("Не удалось закрыть сессию модели", e);
|
LOG.debug("Не удалось закрыть сессию модели", e);
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -7,98 +7,112 @@ import java.util.regex.Pattern;
|
|||||||
/**
|
/**
|
||||||
* Одно правило детекции персональных данных.
|
* Одно правило детекции персональных данных.
|
||||||
*
|
*
|
||||||
* <p>Добавление нового типа ПД — это добавление одного {@code Rule} в
|
* <p>Добавление нового типа ПД — это добавление одного {@code Rule} в {@link RuleRegistry}; менять
|
||||||
* {@link RuleRegistry}; менять остальной код не требуется.
|
* остальной код не требуется.
|
||||||
*
|
*
|
||||||
* @param type тип ПД, который распознаёт правило
|
* @param type тип ПД, который распознаёт правило
|
||||||
* @param pattern регулярное выражение
|
* @param pattern регулярное выражение
|
||||||
* @param priority приоритет при разрешении перекрытий
|
* @param priority приоритет при разрешении перекрытий
|
||||||
* @param groups номера групп, которые маскируются; {@code 0} — всё совпадение целиком.
|
* @param groups номера групп, которые маскируются; {@code 0} — всё совпадение целиком. Несколько
|
||||||
* Несколько групп нужны, когда значение разорвано словами:
|
* групп нужны, когда значение разорвано словами: «серия 4509 номер 123456»
|
||||||
* «серия 4509 номер 123456»
|
* @param validator дополнительная проверка значения (контрольная сумма, диапазон дат); {@code null}
|
||||||
* @param validator дополнительная проверка значения (контрольная сумма, диапазон дат);
|
* — проверка не нужна
|
||||||
* {@code null} — проверка не нужна
|
* @param veto шаблон окружения, при котором совпадение персональными данными не считается: адрес
|
||||||
* @param veto шаблон окружения, при котором совпадение персональными данными не считается:
|
* отделения банка не является ПД, хотя выглядит как адрес
|
||||||
* адрес отделения банка не является ПД, хотя выглядит как адрес
|
* @param context шаблон окружения, который обязан присутствовать рядом. Нужен там, где форма
|
||||||
* @param context шаблон окружения, который обязан присутствовать рядом. Нужен там,
|
* совпадения сама по себе слишком общая: «Невский проспект» это адрес рядом с домом и индексом
|
||||||
* где форма совпадения сама по себе слишком общая: «Невский проспект»
|
* и просто топоним в рассказе о городе
|
||||||
* это адрес рядом с домом и индексом и просто топоним в рассказе о городе
|
* @param anchors строчные подстроки, одна из которых обязана встретиться в тексте. Проверка через
|
||||||
* @param anchors строчные подстроки, одна из которых обязана встретиться в тексте.
|
* {@code indexOf} на порядок дешевле запуска регулярного выражения и отсекает большинство
|
||||||
* Проверка через {@code indexOf} на порядок дешевле запуска
|
* правил на коротком запросе. Пустой список — правило запускается всегда
|
||||||
* регулярного выражения и отсекает большинство правил на коротком
|
|
||||||
* запросе. Пустой список — правило запускается всегда
|
|
||||||
*/
|
*/
|
||||||
public record Rule(String type, Pattern pattern, int priority, List<Integer> groups,
|
public record Rule(
|
||||||
Predicate<String> validator, Pattern veto, Pattern context, List<String> anchors) {
|
String type,
|
||||||
|
Pattern pattern,
|
||||||
|
int priority,
|
||||||
|
List<Integer> groups,
|
||||||
|
Predicate<String> validator,
|
||||||
|
Pattern veto,
|
||||||
|
Pattern context,
|
||||||
|
List<String> anchors) {
|
||||||
|
|
||||||
public Rule {
|
public Rule {
|
||||||
groups = List.copyOf(groups);
|
groups = List.copyOf(groups);
|
||||||
anchors = List.copyOf(anchors);
|
anchors = List.copyOf(anchors);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Флаги компиляции для всех правил.
|
||||||
|
*
|
||||||
|
* <p>{@code UNICODE_CHARACTER_CLASS} обязателен: без него {@code \w}, {@code \W} и {@code \b} в
|
||||||
|
* Java охватывают только латиницу, и якорные слова вроде «водительское удостоверение» не
|
||||||
|
* находятся. {@code UNICODE_CASE} делает {@code (?i)} корректным для кириллицы.
|
||||||
|
*/
|
||||||
|
private static final int FLAGS = Pattern.UNICODE_CHARACTER_CLASS | Pattern.UNICODE_CASE;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Сколько символов слева и справа от совпадения просматривает вето-шаблон.
|
||||||
|
*
|
||||||
|
* <p>150, не 80: на реальных адресах отделений из реестра ЦБ (регион, город, улица, дом — в одном
|
||||||
|
* предложении) расстояние от «отделение» до номера дома часто превышает 80 знаков за счёт
|
||||||
|
* длинного названия региона («Ханты-Мансийский автономный округ», «Кабардино-Балкарская
|
||||||
|
* Республика»). Найдено нагрузочным тестом на 60 реальных адресах из официального реестра — с
|
||||||
|
* окном в 80 знаков вето не срабатывало на части из них.
|
||||||
|
*/
|
||||||
|
public static final int VETO_LOOKBEHIND = 150;
|
||||||
|
|
||||||
|
public static final int VETO_LOOKAHEAD = 40;
|
||||||
|
|
||||||
|
/** Правило без проверок, маскируется всё совпадение. */
|
||||||
|
public static Rule of(String type, String regex, int priority) {
|
||||||
|
return new Rule(
|
||||||
|
type, Pattern.compile(regex, FLAGS), priority, List.of(0), null, null, null, List.of());
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Маскировать только перечисленные группы, а не всё совпадение. */
|
||||||
|
public Rule groups(Integer... indexes) {
|
||||||
|
return new Rule(type, pattern, priority, List.of(indexes), validator, veto, context, anchors);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Принять совпадение, только если значение прошло проверку. */
|
||||||
|
public Rule validatedBy(Predicate<String> check) {
|
||||||
|
return new Rule(type, pattern, priority, groups, check, veto, context, anchors);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Запускать правило, только если в тексте есть одна из подстрок (в нижнем регистре). */
|
||||||
|
public Rule anchoredBy(String... required) {
|
||||||
|
return new Rule(type, pattern, priority, groups, validator, veto, context, List.of(required));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Есть ли в тексте хоть один из якорей правила. */
|
||||||
|
public boolean mayMatch(String lowercasedText) {
|
||||||
|
if (anchors.isEmpty()) {
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
|
for (String anchor : anchors) {
|
||||||
/**
|
if (lowercasedText.contains(anchor)) {
|
||||||
* Флаги компиляции для всех правил.
|
return true;
|
||||||
*
|
}
|
||||||
* <p>{@code UNICODE_CHARACTER_CLASS} обязателен: без него {@code \w}, {@code \W}
|
|
||||||
* и {@code \b} в Java охватывают только латиницу, и якорные слова вроде
|
|
||||||
* «водительское удостоверение» не находятся. {@code UNICODE_CASE} делает
|
|
||||||
* {@code (?i)} корректным для кириллицы.
|
|
||||||
*/
|
|
||||||
private static final int FLAGS = Pattern.UNICODE_CHARACTER_CLASS | Pattern.UNICODE_CASE;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Сколько символов слева и справа от совпадения просматривает вето-шаблон.
|
|
||||||
*
|
|
||||||
* <p>150, не 80: на реальных адресах отделений из реестра ЦБ (регион, город,
|
|
||||||
* улица, дом — в одном предложении) расстояние от «отделение» до номера дома
|
|
||||||
* часто превышает 80 знаков за счёт длинного названия региона («Ханты-Мансийский
|
|
||||||
* автономный округ», «Кабардино-Балкарская Республика»). Найдено нагрузочным
|
|
||||||
* тестом на 60 реальных адресах из официального реестра — с окном в 80 знаков
|
|
||||||
* вето не срабатывало на части из них.
|
|
||||||
*/
|
|
||||||
public static final int VETO_LOOKBEHIND = 150;
|
|
||||||
public static final int VETO_LOOKAHEAD = 40;
|
|
||||||
|
|
||||||
/** Правило без проверок, маскируется всё совпадение. */
|
|
||||||
public static Rule of(String type, String regex, int priority) {
|
|
||||||
return new Rule(type, Pattern.compile(regex, FLAGS), priority, List.of(0), null, null, null, List.of());
|
|
||||||
}
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
/** Маскировать только перечисленные группы, а не всё совпадение. */
|
/** Принять совпадение, только если рядом встретилось указанное слово. */
|
||||||
public Rule groups(Integer... indexes) {
|
public Rule requiringNear(String regex) {
|
||||||
return new Rule(type, pattern, priority, List.of(indexes), validator, veto, context, anchors);
|
return new Rule(
|
||||||
}
|
type, pattern, priority, groups, validator, veto, Pattern.compile(regex, FLAGS), anchors);
|
||||||
|
}
|
||||||
|
|
||||||
/** Принять совпадение, только если значение прошло проверку. */
|
/** Отбросить совпадение, если рядом встретилось указанное слово. */
|
||||||
public Rule validatedBy(Predicate<String> check) {
|
public Rule vetoedBy(String regex) {
|
||||||
return new Rule(type, pattern, priority, groups, check, veto, context, anchors);
|
return new Rule(
|
||||||
}
|
type,
|
||||||
|
pattern,
|
||||||
/** Запускать правило, только если в тексте есть одна из подстрок (в нижнем регистре). */
|
priority,
|
||||||
public Rule anchoredBy(String... required) {
|
groups,
|
||||||
return new Rule(type, pattern, priority, groups, validator, veto, context, List.of(required));
|
validator,
|
||||||
}
|
Pattern.compile(regex, FLAGS),
|
||||||
|
context,
|
||||||
/** Есть ли в тексте хоть один из якорей правила. */
|
anchors);
|
||||||
public boolean mayMatch(String lowercasedText) {
|
}
|
||||||
if (anchors.isEmpty()) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
for (String anchor : anchors) {
|
|
||||||
if (lowercasedText.contains(anchor)) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Принять совпадение, только если рядом встретилось указанное слово. */
|
|
||||||
public Rule requiringNear(String regex) {
|
|
||||||
return new Rule(type, pattern, priority, groups, validator, veto, Pattern.compile(regex, FLAGS), anchors);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Отбросить совпадение, если рядом встретилось указанное слово. */
|
|
||||||
public Rule vetoedBy(String regex) {
|
|
||||||
return new Rule(type, pattern, priority, groups, validator, Pattern.compile(regex, FLAGS), context, anchors);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,140 @@
|
|||||||
|
package ru.pdguard.detect;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Общие фрагменты регулярных выражений, переиспользуемые между группами правил в {@link
|
||||||
|
* RuleRegistry}. Вынесены отдельно, чтобы не дублировать их в каждой группе — «серия и номер»,
|
||||||
|
* разрывы между якорем и значением, формы дат и т.п. встречаются в правилах разных категорий
|
||||||
|
* (документы, банк, ФИО, адрес).
|
||||||
|
*/
|
||||||
|
final class RulePatterns {
|
||||||
|
|
||||||
|
private RulePatterns() {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Слово с заглавной буквы; остальные буквы любого регистра, чтобы «ИВАНОВ» распознавался наравне
|
||||||
|
* с «Иванов».
|
||||||
|
*/
|
||||||
|
static final String CAPITALISED = "\\p{Lu}[\\p{Lu}\\p{Ll}]+";
|
||||||
|
|
||||||
|
/** Якорное слово-основа: держатель карты, держателем и т.п. */
|
||||||
|
static final String HOLDER_STEM = "держател";
|
||||||
|
|
||||||
|
/** Разрыв между якорем и значением, когда между ними ролевое слово («ИНН плательщика»). */
|
||||||
|
static final String ROLE_GAP = "(?:\\s+[\\p{L}-]+){0,5}\\W{0,10}";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* То же самое, но только строчные слова-филлеры: ролевые слова перед значением гражданства всегда
|
||||||
|
* строчные («бенефициара», «поручителя»), а само значение — с заглавной («Республики»,
|
||||||
|
* «Соединенные»). Обычный {@link #ROLE_GAP} жадно поглощал бы и заглавное слово значения как
|
||||||
|
* будто это ролевое слово, оставляя значение только хвостом («Республики Беларусь» → «Беларусь»).
|
||||||
|
*/
|
||||||
|
static final String CITIZENSHIP_GAP = "(?:\\s+\\p{Ll}[\\p{L}-]*){0,5}\\W{0,10}";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Название улицы: от одного до трёх слов с заглавной буквы либо чисел — «Тверская», «Малая
|
||||||
|
* Никитская», «8 Марта». Ограничение по форме обязательно: без него правило дожёвывало строку до
|
||||||
|
* конца, и «Проспект Вернадского перекрыт до вечера» оказывался под маской целиком.
|
||||||
|
*/
|
||||||
|
static final String STREET_NAME =
|
||||||
|
"(?:\\p{Lu}[\\p{L}-]+|\\d+[\\p{L}-]*)(?:\\s+(?:\\p{Lu}[\\p{L}-]+|\\d+[\\p{L}-]*)){0,2}";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Фамилия по словообразованию: Иванов, Ковалёва, Троицкий, Шевченко, Мкртчян. Хвост из двух букв
|
||||||
|
* покрывает падежные окончания: Ковалёв-ой, Иванов-а.
|
||||||
|
*/
|
||||||
|
static final String SURNAME =
|
||||||
|
"\\p{Lu}[\\p{Lu}\\p{Ll}]*(?iu:ов|ев|ёв|ин|ын|ск(?:ий|ая|ого|ой|ом)|цк(?:ий|ая)"
|
||||||
|
+ "|енко|ко|ук|юк|ян|швили|дзе)\\p{L}{0,2}";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Отчество: признак надёжный, ни одно другое слово так не оканчивается. Основы даны без падежного
|
||||||
|
* окончания — Иванович, Ивановича, Ивановне.
|
||||||
|
*/
|
||||||
|
static final String PATRONYMIC =
|
||||||
|
"\\p{Lu}[\\p{Lu}\\p{Ll}]+(?iu:ович|евич|ьич|мич|нич|тич|лич|кич|бич|сич"
|
||||||
|
+ "|овн|евн|иничн|ичн)\\p{L}{0,2}";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Серия и номер: «4509 123456», «45 09 123456», «4509123456», «45 09 № 123456», а также с
|
||||||
|
* произвольным числом пробелов и словом «номер» между частями — «12 34 номер 567890» (реальный
|
||||||
|
* кейс из бланка).
|
||||||
|
*/
|
||||||
|
static final String SERIES_AND_NUMBER =
|
||||||
|
"\\d{2}\\s*\\d{2}(?:\\s*(?:№|N|номер)\\s*|[\\s№N]{0,3})\\d{6}";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Название месяца: полная форма («январь»), сокращение («янв») и плейсхолдер «ммм» (в логах
|
||||||
|
* встречается и латинская «M»). Сокращения нужны, потому что в датах вида «15 ЯНВ 10» месяц
|
||||||
|
* записан тремя буквами.
|
||||||
|
*/
|
||||||
|
static final String MONTH =
|
||||||
|
"(?iu:январ|феврал|март|апрел|ма[йя]|июн|июл|август|сентябр|октябр|ноябр|декабр"
|
||||||
|
+ "|янв|фев|мар|апр|авг|сен|окт|ноя|дек|[МM]мм)\\p{L}*";
|
||||||
|
|
||||||
|
/** Числовая запись при любом порядке частей: дд.мм.гггг, мм/дд/гггг, гггг-мм-дд. */
|
||||||
|
static final String DATE_DIGITS = "\\b\\d{1,4}[.\\-/]\\d{1,2}[.\\-/]\\d{1,4}\\b";
|
||||||
|
|
||||||
|
/** «15 03 1990», «15 03 10» — числовая дата с пробелами вместо разделителей. */
|
||||||
|
static final String DATE_DIGITS_SPACE = "\\b\\d{1,2}\\s+\\d{1,2}\\s+\\d{2,4}\\b";
|
||||||
|
|
||||||
|
/** «15 03», «15/03» — день и месяц без года. */
|
||||||
|
static final String DATE_DAY_MONTH = "\\b\\d{1,2}\\s*[-/.]?\\s*\\d{1,2}\\b";
|
||||||
|
|
||||||
|
/** «12 мая 1985 г.», «15-ЯНВ-10», «15 января» — месяц словом, год 2-4 цифры или без года. */
|
||||||
|
static final String DATE_MONTH_WORD =
|
||||||
|
"\\b\\d{1,2}\\s*[-/.]?\\s*"
|
||||||
|
+ MONTH
|
||||||
|
+ "\\s*[-/.]?\\s*(?:\\d{2,4})?\\b"
|
||||||
|
+ "(?:\\s*(?iu:года|г\\.|г\\b))?";
|
||||||
|
|
||||||
|
/** «двенадцатого мая тысяча девятьсот восемьдесят пятого года» */
|
||||||
|
static final String DATE_WORDS =
|
||||||
|
"\\b(?:(?iu:двадцать|тридцать)\\s+)?"
|
||||||
|
+ "(?iu:перв|втор|треть|четв[её]рт|пят|шест|седьм|восьм|девят|десят|одиннадцат|двенадцат"
|
||||||
|
+ "|тринадцат|четырнадцат|пятнадцат|шестнадцат|семнадцат|восемнадцат|девятнадцат|двадцат|тридцат)"
|
||||||
|
+ "(?iu:ьего|ого|его|ое)\\s+"
|
||||||
|
+ MONTH
|
||||||
|
+ "\\s+(?:\\d{4}|(?iu:тысяча)(?:\\s+\\p{L}+){1,8})\\s*(?iu:года|год\\b|г\\.)";
|
||||||
|
|
||||||
|
/** Любая из записей даты; внутри только незахватывающие группы. */
|
||||||
|
static final String DATE_ANY =
|
||||||
|
"(?:"
|
||||||
|
+ DATE_WORDS
|
||||||
|
+ "|"
|
||||||
|
+ DATE_MONTH_WORD
|
||||||
|
+ "|"
|
||||||
|
+ DATE_DIGITS
|
||||||
|
+ "|"
|
||||||
|
+ DATE_DIGITS_SPACE
|
||||||
|
+ "|"
|
||||||
|
+ DATE_DAY_MONTH
|
||||||
|
+ ")";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Промежуток между якорем даты («дата рождения») и самой датой: слова, скобочные группы («(день и
|
||||||
|
* месяц)») и знаки препинания. Без скобочной ветки «Дата рождения клиента (день и месяц): 15
|
||||||
|
* января» не находилась бы: «день и месяц» — это слова, а не дата. Ветка со словами требует
|
||||||
|
* пробела перед словом ({@code \s+}), иначе она неоднозначна с веткой {@code \W}, которая тоже
|
||||||
|
* матчит пробелы, — это приводило к катастрофическому возврату на длинных текстах. Отдельная
|
||||||
|
* ветка с дефисом нужна для слитных слов без пробела внутри: «клиента-нерезидента» — дефис сам по
|
||||||
|
* себе ловится веткой {@code \W}, но следующие за ним буквы без пробела перед ними не покрывала
|
||||||
|
* ни одна ветка.
|
||||||
|
*/
|
||||||
|
static final String DATE_GAP = "(?:\\s+\\([^)]*\\)|\\s+\\p{L}+|-\\p{L}+|\\W){0,30}";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Значение гражданства: «рф»/«росс…»(любая форма, включая строчную «российское»)/ «республики X»
|
||||||
|
* — частые формы отдельным списком; последняя ветка — страна из 1-4 слов с заглавной буквы
|
||||||
|
* («Армения», «Соединенные Штаты Америки»). Хвост идёт после якоря «гражданств», поэтому
|
||||||
|
* «Двойное» перед якорем не попадёт.
|
||||||
|
*/
|
||||||
|
static final String CITIZENSHIP_VALUE =
|
||||||
|
"\\p{Lu}\\p{Ll}+(?:[\\s/]+\\p{Lu}\\p{Ll}+){0,3}|\\p{Ll}+(?:[\\s/]+\\p{Ll}+){0,3}";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Слова, при которых адрес/имя принадлежит организации, а не человеку: адрес отделения банка
|
||||||
|
* персональными данными не является.
|
||||||
|
*/
|
||||||
|
static final String ORGANISATION_NEARBY =
|
||||||
|
"(?iu:отделени|филиал|банкомат|доп\\.?\\s?офис|офис|головн|юридическ\\p{L}*\\s+адрес)";
|
||||||
|
}
|
||||||
@@ -1,621 +1,229 @@
|
|||||||
package ru.pdguard.detect;
|
package ru.pdguard.detect;
|
||||||
|
|
||||||
import org.springframework.stereotype.Component;
|
|
||||||
import ru.pdguard.config.SystemPolicy;
|
|
||||||
|
|
||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Locale;
|
import java.util.Locale;
|
||||||
|
import java.util.Set;
|
||||||
import java.util.regex.Matcher;
|
import java.util.regex.Matcher;
|
||||||
import java.util.regex.Pattern;
|
import java.util.regex.Pattern;
|
||||||
|
import java.util.stream.Stream;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
import ru.pdguard.config.SystemPolicy;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Реестр правил детекции и сам поиск ПД в тексте.
|
* Реестр правил детекции и сам поиск ПД в тексте.
|
||||||
*
|
*
|
||||||
* <p>Правила разбиты на три уровня доверия:
|
* <p>Правила разбиты на три уровня доверия:
|
||||||
|
*
|
||||||
* <ol>
|
* <ol>
|
||||||
* <li>проверяемые контрольной суммой — карта, ИНН, СНИЛС: ложных срабатываний почти нет;</li>
|
* <li>проверяемые контрольной суммой — карта, ИНН, СНИЛС: ложных срабатываний почти нет;
|
||||||
* <li>однозначные по формату — email, телефон;</li>
|
* <li>однозначные по формату — email, телефон;
|
||||||
* <li>требующие якорного слова — паспорт, водительское удостоверение, CVV, адрес и прочее,
|
* <li>требующие якорного слова — паспорт, водительское удостоверение, CVV, адрес и прочее, где
|
||||||
* где сама по себе последовательность знаков ни о чём не говорит.</li>
|
* сама по себе последовательность знаков ни о чём не говорит.
|
||||||
* </ol>
|
* </ol>
|
||||||
*
|
*
|
||||||
* <p>Якорные слова распознаются без учёта регистра — флаг {@code (?iu:...)} навешен
|
* <p>Якорные слова распознаются без учёта регистра — флаг {@code (?iu:...)} навешен именно на них.
|
||||||
* именно на них. На захватываемое значение регистронезависимость не распространяется:
|
* На захватываемое значение регистронезависимость не распространяется: там, где значение опознаётся
|
||||||
* там, где значение опознаётся по заглавной букве, это существенно.
|
* по заглавной букве, это существенно.
|
||||||
|
*
|
||||||
|
* <p>Сами правила сгруппированы по категориям в отдельных классах пакета — {@link DocumentRules},
|
||||||
|
* {@link FinanceRules}, {@link DateRules}, {@link FioRules}, {@link ContactRules}, {@link
|
||||||
|
* AddressRules} — чтобы каждая категория читалась отдельно от остальных. Здесь их списки только
|
||||||
|
* объединяются и используются.
|
||||||
*/
|
*/
|
||||||
@Component
|
@Component
|
||||||
public class RuleRegistry {
|
public class RuleRegistry {
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Слово с заглавной буквы; остальные буквы любого регистра, чтобы
|
* Слова, при которых адрес принадлежит организации, а не человеку: адрес отделения банка
|
||||||
* «ИВАНОВ» распознавался наравне с «Иванов».
|
* персональными данными не является. Части адреса рядом: улица, упомянутая в рассказе о городе,
|
||||||
*/
|
* адресом клиента не является — ровно как адрес отделения банка из технического задания.
|
||||||
private static final String CAPITALISED = "\\p{Lu}[\\p{Lu}\\p{Ll}]+";
|
* Требование стояло только у постфиксной формы правила, префиксная его не имела.
|
||||||
|
*/
|
||||||
|
public static final String ADDRESS_NEARBY =
|
||||||
|
"(?iu:адрес|индекс|\\bд\\.|\\bдом\\b|\\bкв\\.|\\bг\\.|\\bгород|регистрац|прожива)";
|
||||||
|
|
||||||
/** Якорное слово-основа: держатель карты, держателем и т.п. */
|
private static final Pattern ADDRESS_CONTEXT =
|
||||||
private static final String HOLDER_STEM = "держател";
|
Pattern.compile(ADDRESS_NEARBY, Pattern.UNICODE_CHARACTER_CLASS | Pattern.UNICODE_CASE);
|
||||||
|
|
||||||
/** Разрыв между якорем и значением, когда между ними ролевое слово («ИНН плательщика»). */
|
/** Адресные типы, которые вне адресного окружения персональными данными не являются. */
|
||||||
private static final String ROLE_GAP = "(?:\\s+[\\p{L}-]+){0,5}\\W{0,10}";
|
private static final Set<String> ADDRESS_TYPES =
|
||||||
|
Set.of(
|
||||||
|
PdTypes.ADDRESS_COUNTRY,
|
||||||
|
PdTypes.ADDRESS_REGION,
|
||||||
|
PdTypes.ADDRESS_DISTRICT,
|
||||||
|
PdTypes.ADDRESS_CITY,
|
||||||
|
PdTypes.ADDRESS_STREET,
|
||||||
|
PdTypes.ADDRESS_HOUSE,
|
||||||
|
PdTypes.ADDRESS_FLAT,
|
||||||
|
PdTypes.ADDRESS_POSTCODE);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* То же самое, но только строчные слова-филлеры: ролевые слова перед значением
|
* Приоритет находок нормализации цифровых ПД: выше правила ИНН без якоря (62), ниже якорных
|
||||||
* гражданства всегда строчные («бенефициара», «поручителя»), а само значение —
|
* правил (84+). Нормализация находит то, что жёсткие шаблоны пропустили из-за нестандартных
|
||||||
* с заглавной («Республики», «Соединенные»). Обычный {@link #ROLE_GAP} жадно
|
* разделителей, и не должна перебивать находки с якорным словом.
|
||||||
* поглощал бы и заглавное слово значения как будто это ролевое слово, оставляя
|
*/
|
||||||
* CITIZENSHIP_VALUE только хвост («Республики Беларусь» → «Беларусь»).
|
private static final int NORMALISED_PRIORITY = 63;
|
||||||
*/
|
|
||||||
private static final String CITIZENSHIP_GAP = "(?:\\s+\\p{Ll}[\\p{L}-]*){0,5}\\W{0,10}";
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Название улицы: от одного до трёх слов с заглавной буквы либо чисел —
|
* Цифровой кластер: от 10 до 19 цифр с произвольными разделителями между ними (пробел, дефис,
|
||||||
* «Тверская», «Малая Никитская», «8 Марта». Ограничение по форме обязательно:
|
* точка, слэш, скобки). Негативные просмотры не дают захватить часть более длинного числа.
|
||||||
* без него правило дожёвывало строку до конца, и «Проспект Вернадского перекрыт
|
* Разделители вычищаются, и чистая цифровая строка прогоняется через контрольную сумму — так
|
||||||
* до вечера» оказывался под маской целиком.
|
* находятся ИНН/СНИЛС/карта/ОГРН(ИП) в свободной форме, где жёсткий шаблон ломается на
|
||||||
*/
|
* нестандартном разделителе.
|
||||||
private static final String STREET_NAME =
|
*/
|
||||||
"(?:\\p{Lu}[\\p{L}-]+|\\d+[\\p{L}-]*)(?:\\s+(?:\\p{Lu}[\\p{L}-]+|\\d+[\\p{L}-]*)){0,2}";
|
private static final Pattern DIGIT_CLUSTER =
|
||||||
|
Pattern.compile("(?<!\\d)\\d(?:[\\s.\\-/()]?\\d){9,18}(?!\\d)");
|
||||||
|
|
||||||
/**
|
/** Вычищает разделители из цифрового кластера: оставляет только цифры. */
|
||||||
* Фамилия по словообразованию: Иванов, Ковалёва, Троицкий, Шевченко, Мкртчян.
|
private static final Pattern NON_DIGIT = Pattern.compile("[^\\d]");
|
||||||
* Хвост из двух букв покрывает падежные окончания: Ковалёв-ой, Иванов-а.
|
|
||||||
*/
|
|
||||||
private static final String SURNAME =
|
|
||||||
"\\p{Lu}[\\p{Lu}\\p{Ll}]*(?iu:ов|ев|ёв|ин|ын|ск(?:ий|ая|ого|ой|ом)|цк(?:ий|ая)"
|
|
||||||
+ "|енко|ко|ук|юк|ян|швили|дзе)\\p{L}{0,2}";
|
|
||||||
|
|
||||||
/**
|
public static boolean isAddressType(String type) {
|
||||||
* Отчество: признак надёжный, ни одно другое слово так не оканчивается.
|
return ADDRESS_TYPES.contains(type);
|
||||||
* Основы даны без падежного окончания — Иванович, Ивановича, Ивановне.
|
}
|
||||||
*/
|
|
||||||
private static final String PATRONYMIC =
|
|
||||||
"\\p{Lu}[\\p{Lu}\\p{Ll}]+(?iu:ович|евич|ьич|мич|нич|тич|лич|кич|бич|сич"
|
|
||||||
+ "|овн|евн|иничн|ичн)\\p{L}{0,2}";
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Серия и номер: «4509 123456», «45 09 123456», «4509123456», «45 09 № 123456»,
|
* Есть ли рядом другие части адреса. Правила проверяют это сами, а находкам второй ступени
|
||||||
* а также с произвольным числом пробелов и словом «номер» между частями —
|
* проверку нужно навязать снаружи: модель размечает «Москву» в названии клуба и «Вернадского» в
|
||||||
* «12 34 номер 567890» (реальный кейс из бланка).
|
* названии проспекта наравне с настоящим адресом.
|
||||||
*/
|
*/
|
||||||
private static final String SERIES_AND_NUMBER =
|
public static boolean hasAddressContext(String text, int start, int end) {
|
||||||
"\\d{2}\\s*\\d{2}(?:\\s*(?:№|N|номер)\\s*|[\\s№N]{0,3})\\d{6}";
|
return ADDRESS_CONTEXT.matcher(surroundings(text, start, end)).find();
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
private static final List<Rule> RULES =
|
||||||
* Название месяца: полная форма («январь»), сокращение («янв») и плейсхолдер
|
Stream.of(
|
||||||
* «ммм» (в логах встречается и латинская «M»). Сокращения нужны, потому что
|
DocumentRules.RULES,
|
||||||
* в датах вида «15 ЯНВ 10» месяц записан тремя буквами.
|
FinanceRules.RULES,
|
||||||
*/
|
DateRules.RULES,
|
||||||
private static final String MONTH =
|
FioRules.RULES,
|
||||||
"(?iu:январ|феврал|март|апрел|ма[йя]|июн|июл|август|сентябр|октябр|ноябр|декабр"
|
ContactRules.RULES,
|
||||||
+ "|янв|фев|мар|апр|авг|сен|окт|ноя|дек|[МM]мм)\\p{L}*";
|
AddressRules.RULES)
|
||||||
|
.flatMap(List::stream)
|
||||||
|
.toList();
|
||||||
|
|
||||||
/** Числовая запись при любом порядке частей: дд.мм.гггг, мм/дд/гггг, гггг-мм-дд. */
|
/** Все типы ПД, которые умеет распознавать сервис. */
|
||||||
private static final String DATE_DIGITS = "\\b\\d{1,4}[.\\-/]\\d{1,2}[.\\-/]\\d{1,4}\\b";
|
public List<String> knownTypes() {
|
||||||
|
return RULES.stream().map(Rule::type).distinct().toList();
|
||||||
|
}
|
||||||
|
|
||||||
/** «15 03 1990», «15 03 10» — числовая дата с пробелами вместо разделителей. */
|
/**
|
||||||
private static final String DATE_DIGITS_SPACE = "\\b\\d{1,2}\\s+\\d{1,2}\\s+\\d{2,4}\\b";
|
* Находит все фрагменты ПД, разрешённые политикой системы. Перекрытия здесь не разрешаются — это
|
||||||
|
* делает вызывающая сторона.
|
||||||
/** «15 03», «15/03» — день и месяц без года. */
|
*/
|
||||||
private static final String DATE_DAY_MONTH = "\\b\\d{1,2}\\s*[-/.]?\\s*\\d{1,2}\\b";
|
public List<Span> detect(String text, SystemPolicy policy) {
|
||||||
|
List<Span> found = new ArrayList<>();
|
||||||
/** «12 мая 1985 г.», «15-ЯНВ-10», «15 января» — месяц словом, год 2-4 цифры или без года. */
|
String lowercased = text.toLowerCase(Locale.ROOT);
|
||||||
private static final String DATE_MONTH_WORD =
|
for (Rule rule : RULES) {
|
||||||
"\\b\\d{1,2}\\s*[-/.]?\\s*" + MONTH + "\\s*[-/.]?\\s*(?:\\d{2,4})?\\b"
|
if (!policy.allows(rule.type()) || !rule.mayMatch(lowercased)) {
|
||||||
+ "(?:\\s*(?iu:года|г\\.|г\\b))?";
|
continue;
|
||||||
|
}
|
||||||
/** «двенадцатого мая тысяча девятьсот восемьдесят пятого года» */
|
collect(rule, text, found);
|
||||||
private static final String DATE_WORDS =
|
|
||||||
"\\b(?:(?iu:двадцать|тридцать)\\s+)?"
|
|
||||||
+ "(?iu:перв|втор|треть|четв[её]рт|пят|шест|седьм|восьм|девят|десят|одиннадцат|двенадцат"
|
|
||||||
+ "|тринадцат|четырнадцат|пятнадцат|шестнадцат|семнадцат|восемнадцат|девятнадцат|двадцат|тридцат)"
|
|
||||||
+ "(?iu:ьего|ого|его|ое)\\s+" + MONTH
|
|
||||||
+ "\\s+(?:\\d{4}|(?iu:тысяча)(?:\\s+\\p{L}+){1,8})\\s*(?iu:года|год\\b|г\\.)";
|
|
||||||
|
|
||||||
/** Любая из записей даты; внутри только незахватывающие группы. */
|
|
||||||
private static final String DATE_ANY =
|
|
||||||
"(?:" + DATE_WORDS + "|" + DATE_MONTH_WORD + "|" + DATE_DIGITS + "|"
|
|
||||||
+ DATE_DIGITS_SPACE + "|" + DATE_DAY_MONTH + ")";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Промежуток между якорем даты («дата рождения») и самой датой: слова,
|
|
||||||
* скобочные группы («(день и месяц)») и знаки препинания. Без скобочной
|
|
||||||
* ветки «Дата рождения клиента (день и месяц): 15 января» не находилась бы:
|
|
||||||
* «день и месяц» — это слова, а не дата. Ветка со словами требует пробела
|
|
||||||
* перед словом ({@code \s+}), иначе она неоднозначна с веткой {@code \W},
|
|
||||||
* которая тоже матчит пробелы, — это приводило к катастрофическому
|
|
||||||
* возврату на длинных текстах. Отдельная ветка с дефисом нужна для слитных
|
|
||||||
* слов без пробела внутри: «клиента-нерезидента» — дефис сам по себе ловится
|
|
||||||
* веткой {@code \W}, но следующие за ним буквы без пробела перед ними не
|
|
||||||
* покрывала ни одна ветка.
|
|
||||||
*/
|
|
||||||
private static final String DATE_GAP = "(?:\\s+\\([^)]*\\)|\\s+\\p{L}+|-\\p{L}+|\\W){0,30}";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Значение гражданства: «рф»/«росс…»(любая форма, включая строчную «российское»)/
|
|
||||||
* «республики X» — частые формы отдельным списком; последняя ветка — страна из
|
|
||||||
* 1-4 слов с заглавной буквы («Армения», «Соединенные Штаты Америки»). Хвост
|
|
||||||
* идёт после якоря «гражданств», поэтому «Двойное» перед якорем не попадёт.
|
|
||||||
*/
|
|
||||||
private static final String CITIZENSHIP_VALUE =
|
|
||||||
"\\p{Lu}\\p{Ll}+(?:[\\s/]+\\p{Lu}\\p{Ll}+){0,3}|\\p{Ll}+(?:[\\s/]+\\p{Ll}+){0,3}";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Слова, при которых адрес принадлежит организации, а не человеку:
|
|
||||||
* адрес отделения банка персональными данными не является. Части адреса рядом:
|
|
||||||
* улица, упомянутая в рассказе о городе, адресом клиента не является — ровно
|
|
||||||
* как адрес отделения банка из технического задания. Требование стояло только
|
|
||||||
* у постфиксной формы правила, префиксная его не имела.
|
|
||||||
*/
|
|
||||||
public static final String ADDRESS_NEARBY =
|
|
||||||
"(?iu:адрес|индекс|\\bд\\.|\\bдом\\b|\\bкв\\.|\\bг\\.|\\bгород|регистрац|прожива)";
|
|
||||||
|
|
||||||
private static final Pattern ADDRESS_CONTEXT =
|
|
||||||
Pattern.compile(ADDRESS_NEARBY, Pattern.UNICODE_CHARACTER_CLASS | Pattern.UNICODE_CASE);
|
|
||||||
|
|
||||||
/** Адресные типы, которые вне адресного окружения персональными данными не являются. */
|
|
||||||
private static final java.util.Set<String> ADDRESS_TYPES = java.util.Set.of(
|
|
||||||
PdTypes.ADDRESS_COUNTRY, PdTypes.ADDRESS_REGION, PdTypes.ADDRESS_DISTRICT, PdTypes.ADDRESS_CITY,
|
|
||||||
PdTypes.ADDRESS_STREET, PdTypes.ADDRESS_HOUSE, PdTypes.ADDRESS_FLAT, PdTypes.ADDRESS_POSTCODE);
|
|
||||||
|
|
||||||
public static boolean isAddressType(String type) {
|
|
||||||
return ADDRESS_TYPES.contains(type);
|
|
||||||
}
|
}
|
||||||
|
collectNormalisedDigits(text, policy, found);
|
||||||
|
return found;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Есть ли рядом другие части адреса. Правила проверяют это сами, а находкам
|
* Ищет цифровые ПД в свободной форме: последовательности цифр с произвольными разделителями,
|
||||||
* второй ступени проверку нужно навязать снаружи: модель размечает «Москву» в
|
* которые жёсткие шаблоны правил пропустили. Разделители вычищаются, и чистая строка проверяется
|
||||||
* названии клуба и «Вернадского» в названии проспекта наравне с настоящим адресом.
|
* контрольной суммой — ложные срабатывания отсекаются так же, как и в правилах.
|
||||||
*/
|
*/
|
||||||
public static boolean hasAddressContext(String text, int start, int end) {
|
private static void collectNormalisedDigits(String text, SystemPolicy policy, List<Span> sink) {
|
||||||
return ADDRESS_CONTEXT.matcher(surroundings(text, start, end)).find();
|
if (!policy.allows(PdTypes.CARD)
|
||||||
|
&& !policy.allows(PdTypes.INN)
|
||||||
|
&& !policy.allows(PdTypes.SNILS)
|
||||||
|
&& !policy.allows(PdTypes.OGRN)
|
||||||
|
&& !policy.allows(PdTypes.OGRNIP)) {
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
Matcher m = DIGIT_CLUSTER.matcher(text);
|
||||||
private static final String ORGANISATION_NEARBY =
|
while (m.find()) {
|
||||||
"(?iu:отделени|филиал|банкомат|доп\\.?\\s?офис|офис|головн|юридическ\\p{L}*\\s+адрес)";
|
String digits = NON_DIGIT.matcher(m.group()).replaceAll("");
|
||||||
|
String type = typeFor(digits);
|
||||||
private static final List<Rule> RULES = List.of(
|
if (type != null && policy.allows(type)) {
|
||||||
|
sink.add(new Span(m.start(), m.end(), type, NORMALISED_PRIORITY));
|
||||||
// --- Уровень 3: значение опознаётся только рядом с якорным словом ---
|
}
|
||||||
|
|
||||||
// CVV: латиница, кириллическая транслитерация («цвв», «сививи») и
|
|
||||||
// описательные якоря («код на обороте карты»). Между якорем и числом
|
|
||||||
// допускаются слова («CVV код 321», «CVV указан код 123») и длинные
|
|
||||||
// разделители («код на обороте карты 789»).
|
|
||||||
Rule.of(PdTypes.CVV, "(?iu:\\b(?:cvv2?|cvc2?|цвв|сививи|код\\p{L}*\\s+на\\s+обороте\\s+карты"
|
|
||||||
+ "|код\\s+проверки|защитный\\s+код)\\b)"
|
|
||||||
+ "(?:\\s+\\p{L}+){0,2}\\W{0,30}(\\d{3,4})\\b", 92)
|
|
||||||
.groups(1)
|
|
||||||
.anchoredBy("cvv", "cvc", "цвв", "сививи", "код на обороте", "код проверки", "защитный код"),
|
|
||||||
|
|
||||||
// PIN: «пин-код», «пин код», «ПИН:», «пин 3456». Между якорем и числом
|
|
||||||
// допускаются слова («ПИН-код карты 2468») и длинные разделители
|
|
||||||
// («Пин Код: 1234»).
|
|
||||||
Rule.of(PdTypes.PIN, "(?iu:\\b(?:пин[\\s-]?кода?|pin[\\s-]?code|пин|pin)\\b)"
|
|
||||||
+ "(?:\\s+\\p{L}+){0,2}\\W{0,30}(\\d{4,6})\\b", 92)
|
|
||||||
.groups(1)
|
|
||||||
.anchoredBy("пин", "pin"),
|
|
||||||
|
|
||||||
// «паспорт 4509 123456», «паспорт гражданина РФ 45 09 123456»
|
|
||||||
Rule.of(PdTypes.PASSPORT, "(?iu:паспорт)\\w*(?:\\W+(?iu:гражданина\\s+РФ|РФ|России|Российской\\s+Федерации))?"
|
|
||||||
+ "\\W{0,10}(" + SERIES_AND_NUMBER + ")\\b", 90)
|
|
||||||
.groups(1)
|
|
||||||
.anchoredBy("паспорт"),
|
|
||||||
|
|
||||||
// «серия 4509 номер 123456», «серии 45 09 № 123456»
|
|
||||||
// Между серией и номером помещается слово: «серия 4509 номер 123456»,
|
|
||||||
// «серии 4509 за номером 123456», «серия 4509 № 123456».
|
|
||||||
Rule.of(PdTypes.PASSPORT, "(?iu:сери)\\w{0,3}\\W{0,5}(\\d{2}\\s?\\d{2})[^\\d]{0,20}(\\d{6})\\b", 90)
|
|
||||||
.groups(1, 2)
|
|
||||||
.anchoredBy("сери"),
|
|
||||||
|
|
||||||
// Необязательное «серия»/«серии» между якорем и цифрами: «ВУ серия 12 34 номер 567890».
|
|
||||||
Rule.of(PdTypes.DRIVER_LICENSE, "(?iu:водительск\\w+\\s+удостоверени\\w+|в/у|вод\\.\\s?удост\\w*|\\bВУ)\\b"
|
|
||||||
+ "\\W{0,15}(?:(?iu:сери\\w{0,3})\\W{0,5})?(" + SERIES_AND_NUMBER + ")\\b", 89)
|
|
||||||
.groups(1)
|
|
||||||
.anchoredBy("водительск", "в/у", "вод.", "ву "),
|
|
||||||
|
|
||||||
// --- Прочие документы, удостоверяющие личность ---
|
|
||||||
|
|
||||||
Rule.of(PdTypes.FOREIGN_PASSPORT, "(?iu:загранпаспорт|заграничн\\p{L}*\\s+паспорт)\\p{L}*"
|
|
||||||
+ "\\W{0,10}(\\d{2}\\s?\\d{7})\\b", 89)
|
|
||||||
.groups(1)
|
|
||||||
.anchoredBy("загранпаспорт", "заграничн"),
|
|
||||||
|
|
||||||
Rule.of(PdTypes.MILITARY_ID, "(?iu:военн\\p{L}*\\s+билет)\\p{L}*"
|
|
||||||
+ "\\W{0,10}(\\p{Lu}{2}\\s?\\d{7})\\b", 89)
|
|
||||||
.groups(1)
|
|
||||||
.anchoredBy("военн"),
|
|
||||||
|
|
||||||
Rule.of(PdTypes.BIRTH_CERTIFICATE, "(?iu:свидетельств\\p{L}*\\s+о\\s+рождении)"
|
|
||||||
+ "\\W{0,15}([IVXLC]{1,4}[- ]?\\p{Lu}{2}\\s?(?:№\\s?)?\\d{6})\\b", 89)
|
|
||||||
.groups(1)
|
|
||||||
.anchoredBy("свидетельств"),
|
|
||||||
|
|
||||||
Rule.of(PdTypes.MEDICAL_POLICY, "(?iu:полис\\p{L}*(?:\\s+ОМС)?)\\W{0,10}(\\d{16})\\b", 89)
|
|
||||||
.groups(1)
|
|
||||||
.anchoredBy("полис"),
|
|
||||||
|
|
||||||
// ROLE_GAP, не \W{0,5}: «код подразделения стоит 001-000» — между якорем и
|
|
||||||
// значением есть слово («стоит»/«объекта»), не только пунктуация.
|
|
||||||
Rule.of(PdTypes.DEPT_CODE, "(?iu:код\\w*\\s+подразделения|к/п)" + ROLE_GAP
|
|
||||||
+ "(\\d{3}\\s?-?\\s?\\d{3})\\b", 88)
|
|
||||||
.groups(1)
|
|
||||||
.anchoredBy("подразделени", "к/п"),
|
|
||||||
|
|
||||||
// «770-001 — таков код подразделения» — значение перед якорем.
|
|
||||||
Rule.of(PdTypes.DEPT_CODE, "\\b(\\d{3}\\s?-?\\s?\\d{3})\\b\\s*[—-]\\s*(?:\\p{L}+\\s+){0,3}"
|
|
||||||
+ "(?iu:код\\w*\\s+подразделения)", 88)
|
|
||||||
.groups(1)
|
|
||||||
.anchoredBy("подразделени"),
|
|
||||||
|
|
||||||
// --- Банковские реквизиты сверх карты ---
|
|
||||||
|
|
||||||
// Расчётный счёт — ровно 20 цифр после якоря, группировка пробелами не важна.
|
|
||||||
Rule.of(PdTypes.ACCOUNT_NUMBER, "(?iu:р/с|расчетн\\w*\\s+счет|расчётн\\w*\\s+счёт|лицев\\w*\\s+счет|"
|
|
||||||
+ "лицев\\w*\\s+счёт)\\W{0,5}((?:\\d[ ]?){19}\\d)\\b", 83)
|
|
||||||
.groups(1)
|
|
||||||
.anchoredBy("р/с", "расчетн", "расчётн", "лицев"),
|
|
||||||
|
|
||||||
Rule.of(PdTypes.BIK, "(?iu:бик)\\W{0,5}(\\d{9})\\b", 83)
|
|
||||||
.groups(1)
|
|
||||||
.anchoredBy("бик"),
|
|
||||||
|
|
||||||
// «действительна до 09/27», «exp 09/27» — срок действия карты, не дата рождения.
|
|
||||||
Rule.of(PdTypes.CARD_EXPIRY, "(?iu:срок\\s+действия|действительна?\\s+до|\\bexp\\w*)\\W{0,5}"
|
|
||||||
+ "(\\d{2}\\s?/\\s?\\d{2})\\b", 83)
|
|
||||||
.groups(1)
|
|
||||||
.anchoredBy("срок действия", "действительн", "exp"),
|
|
||||||
|
|
||||||
// ОГРНИП раньше ОГРН: без отрицательного просмотра «ОГРНИП» частично ловился бы
|
|
||||||
// ещё и правилом ОГРН. Контрольная сумма отсекает случайные 13/15-значные
|
|
||||||
// числа рядом со словом — раньше якоря было достаточно самого по себе.
|
|
||||||
Rule.of(PdTypes.OGRNIP, "(?iu:огрнип)\\W{0,5}(\\d{15})\\b", 83)
|
|
||||||
.groups(1)
|
|
||||||
.validatedBy(Validators::ogrnip)
|
|
||||||
.anchoredBy("огрнип"),
|
|
||||||
|
|
||||||
Rule.of(PdTypes.OGRN, "(?iu:огрн(?!ип))\\W{0,5}(\\d{13})\\b", 83)
|
|
||||||
.groups(1)
|
|
||||||
.validatedBy(Validators::ogrn)
|
|
||||||
.anchoredBy("огрн"),
|
|
||||||
|
|
||||||
Rule.of(PdTypes.KPP, "(?iu:кпп)\\W{0,5}(\\d{9})\\b", 83)
|
|
||||||
.groups(1)
|
|
||||||
.anchoredBy("кпп"),
|
|
||||||
|
|
||||||
// Доход/зарплата: сумма с разделителями тысяч. Между якорем и суммой может
|
|
||||||
// стоять слово («доход клиента», «доход за год») — без этого якорь ловил
|
|
||||||
// бы только «доход 85000», вплотную.
|
|
||||||
Rule.of(PdTypes.INCOME, "(?iu:доход|заработн\\w*\\s+плат\\w*|зарплат\\w*)(?:\\s+\\p{L}+){0,3}?"
|
|
||||||
+ "\\W{0,5}(\\d{1,3}(?:[\\s.]?\\d{3})*(?:,\\d{2})?)\\s?(?iu:руб\\p{L}*|₽)?\\b", 76)
|
|
||||||
.groups(1)
|
|
||||||
.anchoredBy("доход", "заработн", "зарплат"),
|
|
||||||
|
|
||||||
// Биометрия — сама фраза уже говорит, что дальше персональные данные, отдельного
|
|
||||||
// значения для захвата нет: маскируется якорная фраза целиком.
|
|
||||||
Rule.of(PdTypes.BIOMETRIC, "(?iu:биометрическ\\w*\\s+(?:данны\\w*|образц\\w*|шаблон\\w*)"
|
|
||||||
+ "|слепок\\s+голоса|отпечаток\\s+пальца|скан\\s+лица|\\bЕБС\\b)", 81)
|
|
||||||
.anchoredBy("биометри", "слепок голоса", "отпечаток пальца", "скан лица", "ебс"),
|
|
||||||
|
|
||||||
// --- Даты с явным якорем ---
|
|
||||||
|
|
||||||
Rule.of(PdTypes.BIRTH_DATE, "(?iu:дат\\p{L}*\\s+рождения|дата\\s+рожд\\.)"
|
|
||||||
+ DATE_GAP + "(" + DATE_ANY + ")", 87)
|
|
||||||
.groups(1)
|
|
||||||
.validatedBy(Validators::date)
|
|
||||||
.anchoredBy("рожден"),
|
|
||||||
|
|
||||||
Rule.of(PdTypes.BIRTH_DATE, "(?iu:родил(?:ся|ась))"
|
|
||||||
+ DATE_GAP + "(" + DATE_ANY + ")", 87)
|
|
||||||
.groups(1)
|
|
||||||
.validatedBy(Validators::date)
|
|
||||||
.anchoredBy("родил"),
|
|
||||||
|
|
||||||
Rule.of(PdTypes.BIRTH_DATE, "(" + DATE_ANY + ")\\s*(?iu:г\\.\\s?р\\.|г/р|года\\s+рождения)", 87)
|
|
||||||
.groups(1)
|
|
||||||
.validatedBy(Validators::date)
|
|
||||||
.anchoredBy("г.р", "г/р", "года рождения"),
|
|
||||||
|
|
||||||
// «дата выдачи 12.05.2015» и «дата выдачи паспорта 12.05.2015»
|
|
||||||
Rule.of(PdTypes.PASSPORT_DATE, "(?iu:дат\\p{L}*\\s+выдачи)"
|
|
||||||
+ DATE_GAP + "(" + DATE_ANY + ")", 87)
|
|
||||||
.groups(1)
|
|
||||||
.validatedBy(Validators::date)
|
|
||||||
.anchoredBy("выдач"),
|
|
||||||
|
|
||||||
Rule.of(PdTypes.CARDHOLDER, "(?iu:держател\\w*(?:\\s+карты)?|cardholder|на\\s+имя)"
|
|
||||||
+ "\\W{0,10}([A-Z]{2,20}\\s+[A-Z]{2,20})\\b", 86)
|
|
||||||
.groups(1)
|
|
||||||
.anchoredBy(HOLDER_STEM, "cardholder", "на имя"),
|
|
||||||
|
|
||||||
// --- ФИО ---
|
|
||||||
|
|
||||||
// Фамилия Имя Отчество: первое слово опознаётся по словообразованию фамилии.
|
|
||||||
// Свободная тройка «любое слово с заглавной + имя + отчество» здесь
|
|
||||||
// сознательно не используется: она захватывает глагол в начале
|
|
||||||
// предложения («Пригласите Ивана Сергеевича») и заметно дороже по времени.
|
|
||||||
// Фамилии без привычного окончания — Ким, Цой — ловятся по ролевому слову.
|
|
||||||
Rule.of(PdTypes.FIO, "\\b" + SURNAME + "\\s+" + CAPITALISED + "\\s+" + PATRONYMIC + "\\b", 79),
|
|
||||||
|
|
||||||
// Имя Отчество Фамилия — второй распространённый порядок слов.
|
|
||||||
Rule.of(PdTypes.FIO, "\\b" + CAPITALISED + "\\s+" + PATRONYMIC + "\\s+" + SURNAME + "\\b", 79),
|
|
||||||
|
|
||||||
// Иванов И.И. и И.И. Иванов
|
|
||||||
Rule.of(PdTypes.FIO, "\\b" + SURNAME + "\\s+\\p{Lu}\\.\\s?\\p{Lu}\\.", 79),
|
|
||||||
Rule.of(PdTypes.FIO, "\\b\\p{Lu}\\.\\s?\\p{Lu}\\.\\s?" + SURNAME + "\\b", 79),
|
|
||||||
|
|
||||||
// Имя Отчество без фамилии
|
|
||||||
Rule.of(PdTypes.FIO, "\\b" + CAPITALISED + "\\s+" + PATRONYMIC + "\\b", 77),
|
|
||||||
|
|
||||||
// «ФИО: иванов иван иванович» — явный якорь снимает требование к регистру
|
|
||||||
Rule.of(PdTypes.FIO, "(?iu:\\bФИО|\\bф\\.\\s?и\\.\\s?о\\.|\\bна\\s+имя)"
|
|
||||||
+ "(?:\\s+\\p{L}+)?\\W{0,5}(\\p{L}{2,}(?:\\s+\\p{L}{2,}){0,2})\\b", 77)
|
|
||||||
.groups(1)
|
|
||||||
.anchoredBy("фио", "ф.и.о", "на имя"),
|
|
||||||
|
|
||||||
// «клиент Иванов Иван», «плательщик Петрова»
|
|
||||||
Rule.of(PdTypes.FIO, "(?iu:\\bклиент|\\bзаказчик|\\bпациент|\\bсотрудник|\\bвладел|\\bплательщик"
|
|
||||||
+ "|\\bполучател|\\bабонент|\\bв\\s+лице|\\bпредставител|\\bпоручител"
|
|
||||||
+ "|\\bсозаёмщик|\\bсозаемщик|\\bзаёмщик|\\bзаемщик|\\bзаявител|\\bдоверител"
|
|
||||||
+ "|\\bвкладчик|\\bответственн|\\bконтактное\\s+лицо|\\bисполнител|\\bдержател)\\p{L}*"
|
|
||||||
+ "\\W{0,5}(\\p{Lu}\\p{Ll}+(?:\\s+\\p{Lu}\\p{Ll}+){0,2})\\b", 77)
|
|
||||||
.groups(1)
|
|
||||||
.anchoredBy("клиент", "заказчик", "пациент", "сотрудник", "владел", "плательщик",
|
|
||||||
"получател", "абонент", "в лице", "представител", "поручител", "заёмщик",
|
|
||||||
"заемщик", "заявител", "доверител", "вкладчик", "ответственн",
|
|
||||||
"контактное лицо", "исполнител", HOLDER_STEM),
|
|
||||||
|
|
||||||
// «клиент иван иванов», «поручитель петрович» — строчные имена после
|
|
||||||
// ролевого слова. Регистр снимает требование к заглавной букве, а словарь
|
|
||||||
// имён отсекает «клиент пришёл в офис».
|
|
||||||
Rule.of(PdTypes.FIO, "(?iu:\\bклиент|\\bзаказчик|\\bпациент|\\bсотрудник|\\bвладел|\\bплательщик"
|
|
||||||
+ "|\\bполучател|\\bабонент|\\bв\\s+лице|\\bпредставител|\\bпоручител"
|
|
||||||
+ "|\\bсозаёмщик|\\bсозаемщик|\\bзаёмщик|\\bзаемщик|\\bзаявител|\\bдоверител"
|
|
||||||
+ "|\\bвкладчик|\\bответственн|\\bконтактное\\s+лицо|\\bисполнител|\\bдержател"
|
|
||||||
+ "|\\bотправител|\\bбенефициар|\\bдоверенное\\s+лицо|\\bнаследник|\\bсозаемщик"
|
|
||||||
// \p{L}*+ (possessive), не \p{L}*: без possessive откат назад позволял
|
|
||||||
// движку «отдать» уже съеденное падежное окончание ролевого слова и
|
|
||||||
// захватить его как будто отдельное имя — «пациентов» ловилось бы как «ов».
|
|
||||||
+ "|\\bпоручител)\\p{L}*+"
|
|
||||||
+ "(?:\\s+\\p{L}+){0,3}\\W{0,5}(\\p{L}{2,}(?:\\s+\\p{L}{2,}){0,2}(?:\\s+\\p{Lu}\\.){0,2})(?![\\p{L}.])", 77)
|
|
||||||
.groups(1)
|
|
||||||
.validatedBy(NameDictionary::containsNamePart)
|
|
||||||
.anchoredBy("клиент", "заказчик", "пациент", "сотрудник", "владел", "плательщик",
|
|
||||||
"получател", "абонент", "в лице", "представител", "поручител", "заёмщик",
|
|
||||||
"заемщик", "заявител", "доверител", "вкладчик", "ответственн",
|
|
||||||
"контактное лицо", "исполнител", HOLDER_STEM, "отправител", "бенефициар",
|
|
||||||
"доверенное лицо", "наследник", "созаемщик"),
|
|
||||||
|
|
||||||
// Фамилия рядом с личным именем из словаря: без словаря правило ловило бы
|
|
||||||
// «Тверская улица» и тому подобное. Имя проверяется по множеству уже
|
|
||||||
// после совпадения — чередование из ста веток в шаблоне обходится дорого.
|
|
||||||
// Самое слабое основание среди правил ФИО — ни ролевого слова, ни явного
|
|
||||||
// якоря, — поэтому именно здесь нужно вето на адресный контекст: «Великие
|
|
||||||
// Луки» (реальный город) распознаётся как имя «Лука» в падеже плюс
|
|
||||||
// случайное слово, «Богдана Хмельницкого» — улица в честь исторической
|
|
||||||
// фигуры. Найдено на реальных адресах отделений из реестра ЦБ.
|
|
||||||
Rule.of(PdTypes.FIO, "\\b" + SURNAME + "\\s+" + CAPITALISED + "\\b", 74)
|
|
||||||
.validatedBy(NameDictionary::containsGivenName)
|
|
||||||
.vetoedBy(ORGANISATION_NEARBY),
|
|
||||||
Rule.of(PdTypes.FIO, "\\b" + CAPITALISED + "\\s+" + SURNAME + "\\b", 74)
|
|
||||||
.validatedBy(NameDictionary::containsGivenName)
|
|
||||||
.vetoedBy(ORGANISATION_NEARBY),
|
|
||||||
|
|
||||||
// Одиночное имя, фамилия или отчество: «Иванов», «иван», «петрович».
|
|
||||||
// Самое слабое основание среди правил ФИО — ни ролевого слова, ни пары
|
|
||||||
// слов, — поэтому приоритет ниже и проверка по словарю обязательна.
|
|
||||||
// Словарь отсекает «сочи», «казань» и прочие не-имена. Первое слово текста
|
|
||||||
// не рассматривается: заглавная буква там от начала предложения, а не от
|
|
||||||
// имени, и словообразовательная эвристика ложно ловит «Магазин», «Отдел».
|
|
||||||
Rule.of(PdTypes.FIO, "(?<!^)\\b(\\p{L}{2,})\\b", 70)
|
|
||||||
.groups(1)
|
|
||||||
.validatedBy(NameDictionary::isStandaloneNameCandidate),
|
|
||||||
|
|
||||||
// --- Уровень 1: подтверждается контрольной суммой ---
|
|
||||||
|
|
||||||
Rule.of(PdTypes.CARD, "\\b\\d(?:[ -]?\\d){11,18}\\b", 85)
|
|
||||||
.validatedBy(Validators::luhn),
|
|
||||||
|
|
||||||
Rule.of(PdTypes.INN, "(?iu)\\bИНН\\b" + ROLE_GAP + "(\\d{12}|\\d{10})\\b", 84)
|
|
||||||
.groups(1)
|
|
||||||
.anchoredBy("инн"),
|
|
||||||
|
|
||||||
// «ИНН/КПП 7712345671/771201001» — ИНН юрлица перед КПП через слэш.
|
|
||||||
Rule.of(PdTypes.INN, "(?iu)\\bИНН\\s*/\\s*КПП\\b\\W{0,5}(\\d{10})\\b", 84)
|
|
||||||
.groups(1)
|
|
||||||
.anchoredBy("инн/кпп"),
|
|
||||||
|
|
||||||
Rule.of(PdTypes.SNILS, "(?iu)(?:\\bСНИЛС\\b\\D{0,10})?(\\d{3}[ -]\\d{3}[ -]\\d{3}[ -]\\d{2})\\b", 84)
|
|
||||||
.groups(1)
|
|
||||||
.validatedBy(Validators::snils),
|
|
||||||
|
|
||||||
// --- Уровень 2: формат однозначен сам по себе ---
|
|
||||||
|
|
||||||
// \b7, не только +7: номер без плюса («79031119955») тоже встречается.
|
|
||||||
Rule.of(PdTypes.PHONE, "(?:\\+7|\\b7|\\b8)[ ()-]{0,3}\\d{3}[ ()-]{0,3}\\d{3}[ -]{0,2}\\d{2}[ -]{0,2}\\d{2}\\b", 82),
|
|
||||||
|
|
||||||
Rule.of(PdTypes.EMAIL, "\\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\\.[A-Za-z]{2,}\\b", 80)
|
|
||||||
.anchoredBy("@"),
|
|
||||||
|
|
||||||
// --- Уровень 3: свободный текст после якорного слова ---
|
|
||||||
|
|
||||||
// «выдан ОУФМС России по г. Москве 12.05.2015» — дата в состав органа не входит,
|
|
||||||
// её забирает отдельное правило. Приоритет выше городского, иначе от органа
|
|
||||||
// осталась бы замаскированной только его часть.
|
|
||||||
// Перечень форм, не голая основа «выда»: она зацепила бы и «выдающийся»
|
|
||||||
// (обычное слово, не про выдачу документа).
|
|
||||||
Rule.of(PdTypes.PASSPORT_ISSUER, "(?iu:выдан|выдал[аио]?|выдали|выдач[аи]|выдаче)"
|
|
||||||
+ "\\W{0,3}([^,;\\n]{3,90}?)"
|
|
||||||
+ "(?=\\s*\\d{1,2}[.\\-/]\\d{1,2}[.\\-/]\\d{2,4}|[,;\\n]|\\s*$)", 78)
|
|
||||||
.groups(1)
|
|
||||||
.anchoredBy("выдан", "выдал", "выдач"),
|
|
||||||
|
|
||||||
// «совпадает с указанным в анкете: X» — второе упоминание органа выдачи
|
|
||||||
// под собственным якорем, без бэкреференса на первое.
|
|
||||||
Rule.of(PdTypes.PASSPORT_ISSUER, "(?iu:указанн\\w*\\s+в\\s+анкете)\\W{0,5}([^,;.\\n]{3,90}?)"
|
|
||||||
+ "(?=[,;.\\n]|\\s*$)", 78)
|
|
||||||
.groups(1)
|
|
||||||
.anchoredBy("указанн"),
|
|
||||||
|
|
||||||
// «Орган выдачи УФМС России по Республике Татарстан» — орган после якоря,
|
|
||||||
// до слова «совпадает» или конца фразы.
|
|
||||||
Rule.of(PdTypes.PASSPORT_ISSUER, "(?iu:орган\\s+выдачи)\\W{0,5}([^,;:\\n]{3,90}?)"
|
|
||||||
+ "(?=\\s*(?iu:совпадает|указанн)|[,;:\\n]|\\s*$)", 78)
|
|
||||||
.groups(1)
|
|
||||||
.anchoredBy("орган выдачи"),
|
|
||||||
|
|
||||||
Rule.of(PdTypes.BIRTH_PLACE, "(?iu:мест\\w*\\s+рождения)\\W{0,5}([^,;\\n]{3,60}?)(?=\\s*[,;\\n]|\\s*$)", 76)
|
|
||||||
.groups(1)
|
|
||||||
.anchoredBy("рождения"),
|
|
||||||
|
|
||||||
Rule.of(PdTypes.BIRTH_PLACE, "(?iu:родил(?:ся|ась))[^,;\\n]{0,40}?\\s+в\\s+"
|
|
||||||
+ "([^,;\\n]{3,40}?)(?=\\s*[,;\\n]|\\s*$)", 76)
|
|
||||||
.groups(1)
|
|
||||||
.anchoredBy("родил"),
|
|
||||||
|
|
||||||
// ROLE_GAP, не \W{0,5}: «Гражданство бенефициара по договору страхования: Х» —
|
|
||||||
// между якорем и значением бывает несколько слов, не только пунктуация.
|
|
||||||
// Список через запятую/слэш — вторая опциональная группа тем же шаблоном.
|
|
||||||
Rule.of(PdTypes.CITIZENSHIP, "(?iu:гражданств)\\w*" + CITIZENSHIP_GAP
|
|
||||||
+ "(" + CITIZENSHIP_VALUE + ")(?:\\s*[,/]\\s*(" + CITIZENSHIP_VALUE + "))?", 80)
|
|
||||||
.groups(1, 2)
|
|
||||||
.validatedBy(CountryDictionary::isKnownCountry)
|
|
||||||
.anchoredBy("гражданств"),
|
|
||||||
|
|
||||||
// ин/ка/ина/ки — именительный/родительный; ином/кой — творительный
|
|
||||||
// («гражданином», «гражданкой»).
|
|
||||||
Rule.of(PdTypes.CITIZENSHIP, "(?iu:граждан(?:ин|ка|ина|ки|ином|кой))\\b\\s+"
|
|
||||||
+ "(" + CITIZENSHIP_VALUE + ")(?:\\s*[,/]\\s*(" + CITIZENSHIP_VALUE + "))?", 75)
|
|
||||||
.groups(1, 2)
|
|
||||||
.validatedBy(CountryDictionary::isKnownCountry)
|
|
||||||
.anchoredBy("граждан"),
|
|
||||||
|
|
||||||
// --- Адрес: каждая составляющая настраивается отдельно ---
|
|
||||||
|
|
||||||
Rule.of(PdTypes.ADDRESS_POSTCODE, "(?iu:индекс)\\w*" + ROLE_GAP + "(\\d{6})\\b", 74)
|
|
||||||
.groups(1)
|
|
||||||
.vetoedBy(ORGANISATION_NEARBY)
|
|
||||||
.anchoredBy("индекс"),
|
|
||||||
|
|
||||||
Rule.of(PdTypes.ADDRESS_POSTCODE,
|
|
||||||
"\\b(\\d{6})(?=\\s*,?\\s*(?iu:г\\.|город|обл\\.|область|респ|край))", 74)
|
|
||||||
.groups(1)
|
|
||||||
.vetoedBy(ORGANISATION_NEARBY),
|
|
||||||
|
|
||||||
// Не только «г.»: перепись, на которой проверяется словарь, покрывает
|
|
||||||
// сёла, посёлки, деревни, хутора и станицы — «рп. Ильинское», «с. Кукуево»
|
|
||||||
// из ТЗ без этих якорей не нашлись бы вообще, город там ни при чём.
|
|
||||||
Rule.of(PdTypes.ADDRESS_CITY, "(?iu:\\bг\\.|\\bгор\\.|\\bгород|\\bрп\\.|\\bпгт\\.?|\\bп\\.|\\bс\\.|\\bсело\\b"
|
|
||||||
+ "|\\bд\\.|\\bдеревня\\b|\\bдер\\.|\\bх\\.|\\bхутор\\b|\\bст-ца|\\bстаница|\\bаул\\b"
|
|
||||||
+ "|\\bсл\\.|\\bслобода\\b|\\bаал\\b)\\s?(\\p{Lu}[\\p{L}-]{1,30})\\b", 73)
|
|
||||||
.groups(1)
|
|
||||||
.validatedBy(ToponymDictionary::isKnownSettlement)
|
|
||||||
.vetoedBy(ORGANISATION_NEARBY)
|
|
||||||
.anchoredBy("г.", "гор", "город", "рп.", "пгт", "п.", "с.", "село", "д.", "деревня",
|
|
||||||
"дер.", "х.", "хутор", "ст-ца", "станица", "аул", "сл.", "слобода", "аал"),
|
|
||||||
|
|
||||||
Rule.of(PdTypes.ADDRESS_STREET,
|
|
||||||
"(?iu:\\bул\\.|\\bулиц\\p{L}*|\\bпр-т|\\bпроспект\\p{L}*|\\bпер\\.|\\bпереул\\p{L}*"
|
|
||||||
+ "|\\bш\\.|\\bшоссе|\\bб-р|\\bбульвар\\p{L}*|\\bнаб\\.|\\bнабережн\\p{L}*)"
|
|
||||||
+ "\\W{0,3}(" + STREET_NAME + ")", 73)
|
|
||||||
.groups(1)
|
|
||||||
.vetoedBy(ORGANISATION_NEARBY)
|
|
||||||
.requiringNear(ADDRESS_NEARBY)
|
|
||||||
.anchoredBy("ул", "просп", "пр-т", "пер.", "шоссе", "ш.", "бульвар", "б-р", "наб"),
|
|
||||||
|
|
||||||
// «Невский пр-т» — указатель после названия. Форма слишком общая, поэтому
|
|
||||||
// принимается только рядом с другими частями адреса: иначе под маску попал бы
|
|
||||||
// любой рассказ про Невский проспект.
|
|
||||||
Rule.of(PdTypes.ADDRESS_STREET, "\\b(\\p{Lu}[\\p{L}-]{2,30})\\s+"
|
|
||||||
+ "(?iu:пр-т|проспект|улиц\\p{L}*|шоссе|бульвар|переул\\p{L}*|набережн\\p{L}*)\\b", 73)
|
|
||||||
.groups(1)
|
|
||||||
.vetoedBy(ORGANISATION_NEARBY)
|
|
||||||
.requiringNear(ADDRESS_NEARBY)
|
|
||||||
.anchoredBy("пр-т", "проспект", "улиц", "шоссе", "бульвар", "переул", "набережн"),
|
|
||||||
|
|
||||||
Rule.of(PdTypes.ADDRESS_HOUSE,
|
|
||||||
"(?iu:\\bд\\.|\\bдом)\\s?(\\d+\\p{L}?(?:\\s?(?iu:к\\.|корп\\.?|стр\\.)\\s?\\d+)?)\\b", 72)
|
|
||||||
.groups(1)
|
|
||||||
.vetoedBy(ORGANISATION_NEARBY)
|
|
||||||
.anchoredBy("д.", "дом"),
|
|
||||||
|
|
||||||
Rule.of(PdTypes.ADDRESS_FLAT, "(?iu:\\bкв\\.|\\bквартир\\p{L}*)\\s?(\\d+\\p{L}?)\\b", 72)
|
|
||||||
.groups(1)
|
|
||||||
.vetoedBy(ORGANISATION_NEARBY)
|
|
||||||
.anchoredBy("кв"),
|
|
||||||
|
|
||||||
Rule.of(PdTypes.ADDRESS_COUNTRY,
|
|
||||||
"(?iu:стран\\p{L}*(?:\\s+(?:регистрации|проживания|гражданства))?)"
|
|
||||||
+ "\\W{0,5}(\\p{Lu}[\\p{L}-]{2,30})\\b", 71)
|
|
||||||
.groups(1)
|
|
||||||
.vetoedBy(ORGANISATION_NEARBY)
|
|
||||||
.anchoredBy("стран"),
|
|
||||||
|
|
||||||
// --- Значения без якоря: принимаются только вместе с другими ПД ---
|
|
||||||
|
|
||||||
// ИНН физлица без якорного слова — только с верной контрольной суммой.
|
|
||||||
Rule.of(PdTypes.INN, "\\b\\d{12}\\b", 62)
|
|
||||||
.validatedBy(Validators::inn),
|
|
||||||
|
|
||||||
// Дата без якорного слова персональными данными сама по себе не является:
|
|
||||||
// маскируется, только если в тексте есть ПД другого типа.
|
|
||||||
Rule.of(PdTypes.DATE, DATE_ANY, 58)
|
|
||||||
.validatedBy(Validators::date)
|
|
||||||
);
|
|
||||||
|
|
||||||
/** Все типы ПД, которые умеет распознавать сервис. */
|
|
||||||
public List<String> knownTypes() {
|
|
||||||
return RULES.stream().map(Rule::type).distinct().toList();
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Находит все фрагменты ПД, разрешённые политикой системы.
|
* Определяет тип ПД по чистой цифровой строке и контрольной сумме. Для 13 и 15 цифр сначала
|
||||||
* Перекрытия здесь не разрешаются — это делает вызывающая сторона.
|
* пробуются ОГРН/ОГРНИП: они специфичнее карты по длине, и валидный ОГРН не должен случайно стать
|
||||||
*/
|
* номером карты (карта самостоятельна, ОГРН — только спутник, и одинокий ОГРН убирается в {@code
|
||||||
public List<Span> detect(String text, SystemPolicy policy) {
|
* Pipeline}).
|
||||||
List<Span> found = new ArrayList<>();
|
*/
|
||||||
String lowercased = text.toLowerCase(Locale.ROOT);
|
private static String typeFor(String digits) {
|
||||||
for (Rule rule : RULES) {
|
int length = digits.length();
|
||||||
if (!policy.allows(rule.type()) || !rule.mayMatch(lowercased)) {
|
switch (length) {
|
||||||
continue;
|
case 10, 12:
|
||||||
}
|
return Validators.inn(digits) ? PdTypes.INN : null;
|
||||||
collect(rule, text, found);
|
case 11:
|
||||||
|
return Validators.snils(digits) ? PdTypes.SNILS : null;
|
||||||
|
case 13:
|
||||||
|
return ogrnOrCard(digits);
|
||||||
|
case 15:
|
||||||
|
return ogrnipOrCard(digits);
|
||||||
|
default:
|
||||||
|
return cardIfLuhn(digits);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String ogrnOrCard(String digits) {
|
||||||
|
if (Validators.ogrn(digits)) {
|
||||||
|
return PdTypes.OGRN;
|
||||||
|
}
|
||||||
|
return Validators.luhn(digits) ? PdTypes.CARD : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String ogrnipOrCard(String digits) {
|
||||||
|
if (Validators.ogrnip(digits)) {
|
||||||
|
return PdTypes.OGRNIP;
|
||||||
|
}
|
||||||
|
return Validators.luhn(digits) ? PdTypes.CARD : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String cardIfLuhn(String digits) {
|
||||||
|
if (digits.length() >= 13 && digits.length() <= 19 && Validators.luhn(digits)) {
|
||||||
|
return PdTypes.CARD;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void collect(Rule rule, String text, List<Span> sink) {
|
||||||
|
Matcher m = rule.pattern().matcher(text);
|
||||||
|
while (m.find()) {
|
||||||
|
for (int group : rule.groups()) {
|
||||||
|
int start = m.start(group);
|
||||||
|
int end = m.end(group);
|
||||||
|
if (isValidGroup(rule, text, start, end)) {
|
||||||
|
sink.add(new Span(start, end, rule.type(), rule.priority()));
|
||||||
}
|
}
|
||||||
return found;
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private static void collect(Rule rule, String text, List<Span> sink) {
|
/**
|
||||||
Matcher m = rule.pattern().matcher(text);
|
* Проверяет, что фрагмент группы проходит все условия правила: границы, валидатор, veto и
|
||||||
while (m.find()) {
|
* контекст.
|
||||||
for (int group : rule.groups()) {
|
*/
|
||||||
int start = m.start(group);
|
private static boolean isValidGroup(Rule rule, String text, int start, int end) {
|
||||||
int end = m.end(group);
|
if (start < 0 || end <= start) {
|
||||||
if (isValidGroup(rule, text, start, end)) {
|
return false;
|
||||||
sink.add(new Span(start, end, rule.type(), rule.priority()));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
if (rule.validator() != null && !rule.validator().test(text.substring(start, end))) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
String surroundings = surroundings(text, start, end);
|
||||||
|
if (rule.veto() != null && rule.veto().matcher(surroundings).find()) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return rule.context() == null || rule.context().matcher(surroundings).find();
|
||||||
|
}
|
||||||
|
|
||||||
/** Проверяет, что фрагмент группы проходит все условия правила: границы, валидатор, veto и контекст. */
|
static String surroundings(String text, int start, int end) {
|
||||||
private static boolean isValidGroup(Rule rule, String text, int start, int end) {
|
int from = Math.max(0, start - Rule.VETO_LOOKBEHIND);
|
||||||
if (start < 0 || end <= start) {
|
int to = Math.min(text.length(), end + Rule.VETO_LOOKAHEAD);
|
||||||
return false;
|
return text.substring(from, to);
|
||||||
}
|
}
|
||||||
if (rule.validator() != null && !rule.validator().test(text.substring(start, end))) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
String surroundings = surroundings(text, start, end);
|
|
||||||
if (rule.veto() != null && rule.veto().matcher(surroundings).find()) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
return rule.context() == null || rule.context().matcher(surroundings).find();
|
|
||||||
}
|
|
||||||
|
|
||||||
static String surroundings(String text, int start, int end) {
|
|
||||||
int from = Math.max(0, start - Rule.VETO_LOOKBEHIND);
|
|
||||||
int to = Math.min(text.length(), end + Rule.VETO_LOOKAHEAD);
|
|
||||||
return text.substring(from, to);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,24 +3,24 @@ package ru.pdguard.detect;
|
|||||||
/**
|
/**
|
||||||
* Найденный фрагмент персональных данных в исходном тексте.
|
* Найденный фрагмент персональных данных в исходном тексте.
|
||||||
*
|
*
|
||||||
* @param start индекс первого символа (включительно)
|
* @param start индекс первого символа (включительно)
|
||||||
* @param end индекс за последним символом (исключительно)
|
* @param end индекс за последним символом (исключительно)
|
||||||
* @param type тип ПД, например {@code CARD} или {@code EMAIL}
|
* @param type тип ПД, например {@code CARD} или {@code EMAIL}
|
||||||
* @param priority приоритет при разрешении перекрытий: больше — важнее
|
* @param priority приоритет при разрешении перекрытий: больше — важнее
|
||||||
*/
|
*/
|
||||||
public record Span(int start, int end, String type, int priority) {
|
public record Span(int start, int end, String type, int priority) {
|
||||||
|
|
||||||
public Span {
|
public Span {
|
||||||
if (start < 0 || end <= start) {
|
if (start < 0 || end <= start) {
|
||||||
throw new IllegalArgumentException("Некорректные границы фрагмента: " + start + ".." + end);
|
throw new IllegalArgumentException("Некорректные границы фрагмента: " + start + ".." + end);
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public int length() {
|
public int length() {
|
||||||
return end - start;
|
return end - start;
|
||||||
}
|
}
|
||||||
|
|
||||||
public boolean overlaps(Span other) {
|
public boolean overlaps(Span other) {
|
||||||
return start < other.end && other.start < end;
|
return start < other.end && other.start < end;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,51 +4,47 @@ import java.util.Locale;
|
|||||||
import java.util.Set;
|
import java.util.Set;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Словарь населённых пунктов России — проверка того, что значение, пойманное
|
* Словарь населённых пунктов России — проверка того, что значение, пойманное правилом {@code
|
||||||
* правилом {@code ADDRESS_CITY}, действительно похоже на существующий город,
|
* ADDRESS_CITY}, действительно похоже на существующий город, село, посёлок или другой населённый
|
||||||
* село, посёлок или другой населённый пункт, а не на произвольное слово с
|
* пункт, а не на произвольное слово с заглавной буквы после якоря.
|
||||||
* заглавной буквы после якоря.
|
|
||||||
*
|
*
|
||||||
* <p>Не только официальные города (~1100 по классификатору): перепись
|
* <p>Не только официальные города (~1100 по классификатору): перепись добавляет сёла, деревни,
|
||||||
* добавляет сёла, деревни, хутора, станицы — «рп. Ильинское», «с. Кукуево»
|
* хутора, станицы — «рп. Ильинское», «с. Кукуево» из ТЗ находятся ровно за счёт неё. Какой
|
||||||
* из ТЗ находятся ровно за счёт неё. Какой конкретно тип населённого пункта
|
* конкретно тип населённого пункта стоит перед названием, определяет якорь самого правила в {@link
|
||||||
* стоит перед названием, определяет якорь самого правила в {@link RuleRegistry},
|
* RuleRegistry}, а не этот словарь — он только подтверждает, что название реальное.
|
||||||
* а не этот словарь — он только подтверждает, что название реальное.
|
|
||||||
*
|
*
|
||||||
* <p>Сравнение по началу слова, а не точным совпадением: падежные окончания
|
* <p>Сравнение по началу слова, а не точным совпадением: падежные окончания («в Москве», «из
|
||||||
* («в Москве», «из Казани») тем самым покрываются без отдельного разбора
|
* Казани») тем самым покрываются без отдельного разбора морфологии, как и у известных людей в
|
||||||
* морфологии, как и у известных людей в {@link NameDictionary}.
|
* {@link NameDictionary}.
|
||||||
*/
|
*/
|
||||||
public final class ToponymDictionary {
|
public final class ToponymDictionary {
|
||||||
|
|
||||||
private static final Set<String> SETTLEMENT_STEMS = ResourceLoader.set("/names/settlements.txt").stream()
|
private static final Set<String> SETTLEMENT_STEMS =
|
||||||
.map(Declension::withoutInflectedEnding)
|
ResourceLoader.set("/names/settlements.txt").stream()
|
||||||
.collect(java.util.stream.Collectors.toUnmodifiableSet());
|
.map(Declension::withoutInflectedEnding)
|
||||||
|
.collect(java.util.stream.Collectors.toUnmodifiableSet());
|
||||||
|
|
||||||
private ToponymDictionary() {
|
private ToponymDictionary() {}
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Похоже ли значение на название населённого пункта из словаря в любом
|
* Похоже ли значение на название населённого пункта из словаря в любом падеже.
|
||||||
* падеже.
|
*
|
||||||
*
|
* <p>Названия на согласную склоняются добавлением окончания («Тамбов» → «Тамбове»), поэтому
|
||||||
* <p>Названия на согласную склоняются добавлением окончания («Тамбов» →
|
* начало слова из словаря — уже достаточный признак. Названия на гласную меняют последнюю букву
|
||||||
* «Тамбове»), поэтому начало слова из словаря — уже достаточный признак.
|
* («Москва» → «Москве»), для них сравнение идёт по основе без неё — так же, как с личными именами
|
||||||
* Названия на гласную меняют последнюю букву («Москва» → «Москве»), для
|
* в {@link NameDictionary}.
|
||||||
* них сравнение идёт по основе без неё — так же, как с личными именами
|
*
|
||||||
* в {@link NameDictionary}.
|
* <p>Проверяются префиксы значения по множеству, а не каждая из ~80 000 основ по значению:
|
||||||
*
|
* перебор списка на каждое совпадение правила был бы на порядки дороже, чем нужно — префиксов у
|
||||||
* <p>Проверяются префиксы значения по множеству, а не каждая из ~80 000
|
* слова не больше, чем в нём букв.
|
||||||
* основ по значению: перебор списка на каждое совпадение правила был бы
|
*/
|
||||||
* на порядки дороже, чем нужно — префиксов у слова не больше, чем в нём букв.
|
public static boolean isKnownSettlement(String value) {
|
||||||
*/
|
String lower = value.strip().toLowerCase(Locale.ROOT);
|
||||||
public static boolean isKnownSettlement(String value) {
|
for (int length = lower.length(); length > 0; length--) {
|
||||||
String lower = value.strip().toLowerCase(Locale.ROOT);
|
if (SETTLEMENT_STEMS.contains(lower.substring(0, length))) {
|
||||||
for (int length = lower.length(); length > 0; length--) {
|
return true;
|
||||||
if (SETTLEMENT_STEMS.contains(lower.substring(0, length))) {
|
}
|
||||||
return true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
}
|
}
|
||||||
}
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,193 +1,191 @@
|
|||||||
package ru.pdguard.detect;
|
package ru.pdguard.detect;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Проверки контрольных сумм. Отсекают случайные числовые последовательности,
|
* Проверки контрольных сумм. Отсекают случайные числовые последовательности, которые по форме
|
||||||
* которые по форме похожи на ПД, но ими не являются.
|
* похожи на ПД, но ими не являются.
|
||||||
*/
|
*/
|
||||||
public final class Validators {
|
public final class Validators {
|
||||||
|
|
||||||
private static final int[] INN_10 = {2, 4, 10, 3, 5, 9, 4, 6, 8};
|
private static final int[] INN_10 = {2, 4, 10, 3, 5, 9, 4, 6, 8};
|
||||||
private static final int[] INN_12_A = {7, 2, 4, 10, 3, 5, 9, 4, 6, 8};
|
private static final int[] INN_12_A = {7, 2, 4, 10, 3, 5, 9, 4, 6, 8};
|
||||||
private static final int[] INN_12_B = {3, 7, 2, 4, 10, 3, 5, 9, 4, 6, 8};
|
private static final int[] INN_12_B = {3, 7, 2, 4, 10, 3, 5, 9, 4, 6, 8};
|
||||||
|
|
||||||
private Validators() {
|
private Validators() {}
|
||||||
|
|
||||||
|
/** Алгоритм Луна: номер платёжной карты, 13–19 цифр. */
|
||||||
|
public static boolean luhn(String value) {
|
||||||
|
int sum = 0;
|
||||||
|
int digits = 0;
|
||||||
|
boolean doubled = false;
|
||||||
|
for (int i = value.length() - 1; i >= 0; i--) {
|
||||||
|
char c = value.charAt(i);
|
||||||
|
if (!Character.isDigit(c)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
int d = c - '0';
|
||||||
|
digits++;
|
||||||
|
if (doubled) {
|
||||||
|
d *= 2;
|
||||||
|
if (d > 9) {
|
||||||
|
d -= 9;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sum += d;
|
||||||
|
doubled = !doubled;
|
||||||
}
|
}
|
||||||
|
return digits >= 13 && digits <= 19 && sum % 10 == 0;
|
||||||
|
}
|
||||||
|
|
||||||
/** Алгоритм Луна: номер платёжной карты, 13–19 цифр. */
|
/**
|
||||||
public static boolean luhn(String value) {
|
* Контрольная цифра Луна для последовательности цифр: дописывается к телу номера, чтобы весь
|
||||||
int sum = 0;
|
* номер прошёл проверку {@link #luhn}. Используется при генерации правдоподобных подставных
|
||||||
int digits = 0;
|
* номеров карт.
|
||||||
boolean doubled = false;
|
*/
|
||||||
for (int i = value.length() - 1; i >= 0; i--) {
|
public static int luhnCheckDigit(String body) {
|
||||||
char c = value.charAt(i);
|
int sum = 0;
|
||||||
if (!Character.isDigit(c)) {
|
boolean doubled = true;
|
||||||
continue;
|
for (int i = body.length() - 1; i >= 0; i--) {
|
||||||
}
|
int d = body.charAt(i) - '0';
|
||||||
int d = c - '0';
|
if (doubled) {
|
||||||
digits++;
|
d *= 2;
|
||||||
if (doubled) {
|
if (d > 9) {
|
||||||
d *= 2;
|
d -= 9;
|
||||||
if (d > 9) {
|
|
||||||
d -= 9;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
sum += d;
|
|
||||||
doubled = !doubled;
|
|
||||||
}
|
}
|
||||||
return digits >= 13 && digits <= 19 && sum % 10 == 0;
|
}
|
||||||
|
sum += d;
|
||||||
|
doubled = !doubled;
|
||||||
}
|
}
|
||||||
|
return (10 - sum % 10) % 10;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/** Контрольная сумма ИНН: 10 знаков у юрлица, 12 у физлица. */
|
||||||
* Контрольная цифра Луна для последовательности цифр: дописывается к телу
|
public static boolean inn(String value) {
|
||||||
* номера, чтобы весь номер прошёл проверку {@link #luhn}. Используется при
|
int[] d = digits(value);
|
||||||
* генерации правдоподобных подставных номеров карт.
|
if (d.length == 10) {
|
||||||
*/
|
return d[9] == checksum(d, INN_10);
|
||||||
public static int luhnCheckDigit(String body) {
|
|
||||||
int sum = 0;
|
|
||||||
boolean doubled = true;
|
|
||||||
for (int i = body.length() - 1; i >= 0; i--) {
|
|
||||||
int d = body.charAt(i) - '0';
|
|
||||||
if (doubled) {
|
|
||||||
d *= 2;
|
|
||||||
if (d > 9) {
|
|
||||||
d -= 9;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
sum += d;
|
|
||||||
doubled = !doubled;
|
|
||||||
}
|
|
||||||
return (10 - sum % 10) % 10;
|
|
||||||
}
|
}
|
||||||
|
if (d.length == 12) {
|
||||||
|
return d[10] == checksum(d, INN_12_A) && d[11] == checksum(d, INN_12_B);
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
/** Контрольная сумма ИНН: 10 знаков у юрлица, 12 у физлица. */
|
/** Контрольная сумма СНИЛС: 11 знаков, последние два — контрольные. */
|
||||||
public static boolean inn(String value) {
|
public static boolean snils(String value) {
|
||||||
int[] d = digits(value);
|
int[] d = digits(value);
|
||||||
if (d.length == 10) {
|
if (d.length != 11) {
|
||||||
return d[9] == checksum(d, INN_10);
|
return false;
|
||||||
}
|
}
|
||||||
if (d.length == 12) {
|
int sum = 0;
|
||||||
return d[10] == checksum(d, INN_12_A) && d[11] == checksum(d, INN_12_B);
|
for (int i = 0; i < 9; i++) {
|
||||||
}
|
sum += d[i] * (9 - i);
|
||||||
|
}
|
||||||
|
int control = snilsControl(sum);
|
||||||
|
return control == d[9] * 10 + d[10];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Контрольное число СНИЛС по сумме первых девяти цифр. */
|
||||||
|
private static int snilsControl(int sum) {
|
||||||
|
if (sum < 100) {
|
||||||
|
return sum;
|
||||||
|
}
|
||||||
|
if (sum == 100 || sum == 101) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
return sum % 101 % 100;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Контрольная сумма ОГРН: первые 12 цифр по модулю 11, младший разряд — 13-я цифра. */
|
||||||
|
public static boolean ogrn(String value) {
|
||||||
|
int[] d = digits(value);
|
||||||
|
return d.length == 13 && d[12] == modReduce(d, 12, 11);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Контрольная сумма ОГРНИП: первые 14 цифр по модулю 13, младший разряд — 15-я цифра. */
|
||||||
|
public static boolean ogrnip(String value) {
|
||||||
|
int[] d = digits(value);
|
||||||
|
return d.length == 15 && d[14] == modReduce(d, 14, 13);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Остаток от деления первых {@code count} цифр как одного числа на {@code divisor}, взятый по
|
||||||
|
* младшему разряду. Числовое накопление по цифрам, а не парсинг строки в {@code long}: у ОГРНИП
|
||||||
|
* 14 цифр — на грани переполнения {@code int}, и это тот же приём, что уже применяется к самой
|
||||||
|
* длинной последовательности в {@link #luhn}.
|
||||||
|
*/
|
||||||
|
private static int modReduce(int[] d, int count, int divisor) {
|
||||||
|
long remainder = 0;
|
||||||
|
for (int i = 0; i < count; i++) {
|
||||||
|
remainder = (remainder * 10 + d[i]) % divisor;
|
||||||
|
}
|
||||||
|
return (int) (remainder % 10);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Дата в числовой записи при любом порядке частей: {@code 12.05.1985}, {@code 05/12/1985}, {@code
|
||||||
|
* 1985-05-12}, {@code 15 03 1990}, а также день и месяц без года: {@code 15 03}, {@code 15/03}.
|
||||||
|
* Отсекает похожие по форме последовательности вроде {@code 192.168.1}.
|
||||||
|
*/
|
||||||
|
public static boolean date(String value) {
|
||||||
|
// Запись с названием месяца словом в дополнительной проверке не нуждается:
|
||||||
|
// «мая» само по себе однозначно указывает на дату.
|
||||||
|
for (int i = 0; i < value.length(); i++) {
|
||||||
|
if (Character.isLetter(value.charAt(i))) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
String[] parts = value.split("[.\\-/\\s]+");
|
||||||
|
if (parts.length == 2) {
|
||||||
|
return dayAndMonth(Integer.parseInt(parts[0]), Integer.parseInt(parts[1]));
|
||||||
|
}
|
||||||
|
if (parts.length != 3) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return threePartDate(parts);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** {@code 12.05.1985}, {@code 1985-05-12}, {@code 15 03 90} — дата из трёх чисел. */
|
||||||
|
private static boolean threePartDate(String[] parts) {
|
||||||
|
int[] n = new int[3];
|
||||||
|
for (int i = 0; i < 3; i++) {
|
||||||
|
if (parts[i].isEmpty() || parts[i].length() > 4) {
|
||||||
return false;
|
return false;
|
||||||
|
}
|
||||||
|
n[i] = Integer.parseInt(parts[i]);
|
||||||
}
|
}
|
||||||
|
for (int y = 0; y < 3; y++) {
|
||||||
|
if (parts[y].length() == 4) {
|
||||||
|
return n[y] >= 1900 && n[y] <= 2100 && dayAndMonth(n[(y + 1) % 3], n[(y + 2) % 3]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Год записан двумя цифрами: достаточно, чтобы день и месяц нашлись в любой паре.
|
||||||
|
return dayAndMonth(n[0], n[1]) || dayAndMonth(n[1], n[2]) || dayAndMonth(n[0], n[2]);
|
||||||
|
}
|
||||||
|
|
||||||
/** Контрольная сумма СНИЛС: 11 знаков, последние два — контрольные. */
|
/** Пара чисел похожа на «день и месяц» в любом порядке. */
|
||||||
public static boolean snils(String value) {
|
private static boolean dayAndMonth(int a, int b) {
|
||||||
int[] d = digits(value);
|
return (a >= 1 && a <= 31 && b >= 1 && b <= 12) || (b >= 1 && b <= 31 && a >= 1 && a <= 12);
|
||||||
if (d.length != 11) {
|
}
|
||||||
return false;
|
|
||||||
}
|
|
||||||
int sum = 0;
|
|
||||||
for (int i = 0; i < 9; i++) {
|
|
||||||
sum += d[i] * (9 - i);
|
|
||||||
}
|
|
||||||
int control = snilsControl(sum);
|
|
||||||
return control == d[9] * 10 + d[10];
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Контрольное число СНИЛС по сумме первых девяти цифр. */
|
private static int checksum(int[] d, int[] weights) {
|
||||||
private static int snilsControl(int sum) {
|
int sum = 0;
|
||||||
if (sum < 100) {
|
for (int i = 0; i < weights.length; i++) {
|
||||||
return sum;
|
sum += d[i] * weights[i];
|
||||||
}
|
|
||||||
if (sum == 100 || sum == 101) {
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
return sum % 101 % 100;
|
|
||||||
}
|
}
|
||||||
|
return sum % 11 % 10;
|
||||||
|
}
|
||||||
|
|
||||||
/** Контрольная сумма ОГРН: первые 12 цифр по модулю 11, младший разряд — 13-я цифра. */
|
private static int[] digits(String value) {
|
||||||
public static boolean ogrn(String value) {
|
int[] out = new int[value.length()];
|
||||||
int[] d = digits(value);
|
int n = 0;
|
||||||
return d.length == 13 && d[12] == modReduce(d, 12, 11);
|
for (int i = 0; i < value.length(); i++) {
|
||||||
}
|
char c = value.charAt(i);
|
||||||
|
if (Character.isDigit(c)) {
|
||||||
/** Контрольная сумма ОГРНИП: первые 14 цифр по модулю 13, младший разряд — 15-я цифра. */
|
out[n++] = c - '0';
|
||||||
public static boolean ogrnip(String value) {
|
}
|
||||||
int[] d = digits(value);
|
|
||||||
return d.length == 15 && d[14] == modReduce(d, 14, 13);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Остаток от деления первых {@code count} цифр как одного числа на {@code divisor},
|
|
||||||
* взятый по младшему разряду. Числовое накопление по цифрам, а не парсинг строки
|
|
||||||
* в {@code long}: у ОГРНИП 14 цифр — на грани переполнения {@code int}, и это тот же
|
|
||||||
* приём, что уже применяется к самой длинной последовательности в {@link #luhn}.
|
|
||||||
*/
|
|
||||||
private static int modReduce(int[] d, int count, int divisor) {
|
|
||||||
long remainder = 0;
|
|
||||||
for (int i = 0; i < count; i++) {
|
|
||||||
remainder = (remainder * 10 + d[i]) % divisor;
|
|
||||||
}
|
|
||||||
return (int) (remainder % 10);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Дата в числовой записи при любом порядке частей: {@code 12.05.1985},
|
|
||||||
* {@code 05/12/1985}, {@code 1985-05-12}, {@code 15 03 1990}, а также день
|
|
||||||
* и месяц без года: {@code 15 03}, {@code 15/03}. Отсекает похожие по форме
|
|
||||||
* последовательности вроде {@code 192.168.1}.
|
|
||||||
*/
|
|
||||||
public static boolean date(String value) {
|
|
||||||
// Запись с названием месяца словом в дополнительной проверке не нуждается:
|
|
||||||
// «мая» само по себе однозначно указывает на дату.
|
|
||||||
for (int i = 0; i < value.length(); i++) {
|
|
||||||
if (Character.isLetter(value.charAt(i))) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
String[] parts = value.split("[.\\-/\\s]+");
|
|
||||||
if (parts.length == 2) {
|
|
||||||
return dayAndMonth(Integer.parseInt(parts[0]), Integer.parseInt(parts[1]));
|
|
||||||
}
|
|
||||||
if (parts.length != 3) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
return threePartDate(parts);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** {@code 12.05.1985}, {@code 1985-05-12}, {@code 15 03 90} — дата из трёх чисел. */
|
|
||||||
private static boolean threePartDate(String[] parts) {
|
|
||||||
int[] n = new int[3];
|
|
||||||
for (int i = 0; i < 3; i++) {
|
|
||||||
if (parts[i].isEmpty() || parts[i].length() > 4) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
n[i] = Integer.parseInt(parts[i]);
|
|
||||||
}
|
|
||||||
for (int y = 0; y < 3; y++) {
|
|
||||||
if (parts[y].length() == 4) {
|
|
||||||
return n[y] >= 1900 && n[y] <= 2100 && dayAndMonth(n[(y + 1) % 3], n[(y + 2) % 3]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Год записан двумя цифрами: достаточно, чтобы день и месяц нашлись в любой паре.
|
|
||||||
return dayAndMonth(n[0], n[1]) || dayAndMonth(n[1], n[2]) || dayAndMonth(n[0], n[2]);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Пара чисел похожа на «день и месяц» в любом порядке. */
|
|
||||||
private static boolean dayAndMonth(int a, int b) {
|
|
||||||
return (a >= 1 && a <= 31 && b >= 1 && b <= 12) || (b >= 1 && b <= 31 && a >= 1 && a <= 12);
|
|
||||||
}
|
|
||||||
|
|
||||||
private static int checksum(int[] d, int[] weights) {
|
|
||||||
int sum = 0;
|
|
||||||
for (int i = 0; i < weights.length; i++) {
|
|
||||||
sum += d[i] * weights[i];
|
|
||||||
}
|
|
||||||
return sum % 11 % 10;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static int[] digits(String value) {
|
|
||||||
int[] out = new int[value.length()];
|
|
||||||
int n = 0;
|
|
||||||
for (int i = 0; i < value.length(); i++) {
|
|
||||||
char c = value.charAt(i);
|
|
||||||
if (Character.isDigit(c)) {
|
|
||||||
out[n++] = c - '0';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
int[] trimmed = new int[n];
|
|
||||||
System.arraycopy(out, 0, trimmed, 0, n);
|
|
||||||
return trimmed;
|
|
||||||
}
|
}
|
||||||
|
int[] trimmed = new int[n];
|
||||||
|
System.arraycopy(out, 0, trimmed, 0, n);
|
||||||
|
return trimmed;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ package ru.pdguard.detect;
|
|||||||
|
|
||||||
import com.fasterxml.jackson.databind.JsonNode;
|
import com.fasterxml.jackson.databind.JsonNode;
|
||||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
|
|
||||||
import java.io.BufferedReader;
|
import java.io.BufferedReader;
|
||||||
import java.io.IOException;
|
import java.io.IOException;
|
||||||
import java.io.InputStreamReader;
|
import java.io.InputStreamReader;
|
||||||
@@ -18,163 +17,162 @@ import java.util.Map;
|
|||||||
/**
|
/**
|
||||||
* Разбиение текста на подслова так, как это делает токенизатор BERT.
|
* Разбиение текста на подслова так, как это делает токенизатор BERT.
|
||||||
*
|
*
|
||||||
* <p>Своя реализация вместо готовой библиотеки: единственная альтернатива на Java
|
* <p>Своя реализация вместо готовой библиотеки: единственная альтернатива на Java подтягивает
|
||||||
* подтягивает нативные библиотеки во время работы, а контейнер должен подниматься
|
* нативные библиотеки во время работы, а контейнер должен подниматься без обращений в сеть. Правила
|
||||||
* без обращений в сеть. Правила здесь простые и целиком описаны форматом словаря:
|
* здесь простые и целиком описаны форматом словаря: разбить по пробелам и знакам препинания, затем
|
||||||
* разбить по пробелам и знакам препинания, затем каждое слово — жадно по самой
|
* каждое слово — жадно по самой длинной подходящей записи словаря, продолжения помечаются префиксом
|
||||||
* длинной подходящей записи словаря, продолжения помечаются префиксом «##».
|
* «##».
|
||||||
*
|
*
|
||||||
* <p>Для каждого подслова сохраняются границы в исходном тексте: без них разметку
|
* <p>Для каждого подслова сохраняются границы в исходном тексте: без них разметку модели не
|
||||||
* модели не перенести обратно на строку.
|
* перенести обратно на строку.
|
||||||
*/
|
*/
|
||||||
final class WordPiece {
|
final class WordPiece {
|
||||||
|
|
||||||
/** Слово длиннее этого целиком заменяется на «неизвестно» — правило BERT. */
|
/** Слово длиннее этого целиком заменяется на «неизвестно» — правило BERT. */
|
||||||
private static final int MAX_WORD_CHARS = 100;
|
private static final int MAX_WORD_CHARS = 100;
|
||||||
|
|
||||||
private static final String CONTINUATION = "##";
|
private static final String CONTINUATION = "##";
|
||||||
|
|
||||||
/** Подслово и его границы в исходном тексте. */
|
/** Подслово и его границы в исходном тексте. */
|
||||||
record Piece(int id, int start, int end) {
|
record Piece(int id, int start, int end) {}
|
||||||
|
|
||||||
|
private final Map<String, Integer> vocabulary;
|
||||||
|
private final int unknownId;
|
||||||
|
private final int classifyId;
|
||||||
|
private final int separatorId;
|
||||||
|
|
||||||
|
private WordPiece(Map<String, Integer> vocabulary) {
|
||||||
|
this.vocabulary = vocabulary;
|
||||||
|
this.unknownId = required(vocabulary, "[UNK]");
|
||||||
|
this.classifyId = required(vocabulary, "[CLS]");
|
||||||
|
this.separatorId = required(vocabulary, "[SEP]");
|
||||||
|
}
|
||||||
|
|
||||||
|
static WordPiece fromVocabulary(Path vocabularyFile) throws IOException {
|
||||||
|
Map<String, Integer> vocabulary = HashMap.newHashMap(140_000);
|
||||||
|
try (BufferedReader reader =
|
||||||
|
new BufferedReader(
|
||||||
|
new InputStreamReader(Files.newInputStream(vocabularyFile), StandardCharsets.UTF_8))) {
|
||||||
|
String line;
|
||||||
|
int index = 0;
|
||||||
|
while ((line = reader.readLine()) != null) {
|
||||||
|
vocabulary.putIfAbsent(line.strip(), index++);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
return new WordPiece(vocabulary);
|
||||||
|
}
|
||||||
|
|
||||||
private final Map<String, Integer> vocabulary;
|
/**
|
||||||
private final int unknownId;
|
* Читает словарь из {@code tokenizer.json} Hugging Face. Некоторые модели (например, WikiNEuRal)
|
||||||
private final int classifyId;
|
* не кладут отдельный {@code vocab.txt}, а хранят словарь внутри токенизатора.
|
||||||
private final int separatorId;
|
*/
|
||||||
|
static WordPiece fromTokenizerJson(Path tokenizerFile) throws IOException {
|
||||||
private WordPiece(Map<String, Integer> vocabulary) {
|
JsonNode root = new ObjectMapper().readTree(Files.readAllBytes(tokenizerFile));
|
||||||
this.vocabulary = vocabulary;
|
JsonNode vocab = root.path("model").path("vocab");
|
||||||
this.unknownId = required(vocabulary, "[UNK]");
|
Map<String, Integer> vocabulary = HashMap.newHashMap(vocab.size());
|
||||||
this.classifyId = required(vocabulary, "[CLS]");
|
Iterator<Map.Entry<String, JsonNode>> fields = vocab.fields();
|
||||||
this.separatorId = required(vocabulary, "[SEP]");
|
while (fields.hasNext()) {
|
||||||
|
Map.Entry<String, JsonNode> entry = fields.next();
|
||||||
|
vocabulary.putIfAbsent(entry.getKey(), entry.getValue().asInt());
|
||||||
}
|
}
|
||||||
|
return new WordPiece(vocabulary);
|
||||||
|
}
|
||||||
|
|
||||||
static WordPiece fromVocabulary(Path vocabularyFile) throws IOException {
|
int classifyId() {
|
||||||
Map<String, Integer> vocabulary = HashMap.newHashMap(140_000);
|
return classifyId;
|
||||||
try (BufferedReader reader = new BufferedReader(
|
}
|
||||||
new InputStreamReader(Files.newInputStream(vocabularyFile), StandardCharsets.UTF_8))) {
|
|
||||||
String line;
|
int separatorId() {
|
||||||
int index = 0;
|
return separatorId;
|
||||||
while ((line = reader.readLine()) != null) {
|
}
|
||||||
vocabulary.putIfAbsent(line.strip(), index++);
|
|
||||||
}
|
/** Подслова текста в порядке следования; служебные токены сюда не входят. */
|
||||||
}
|
List<Piece> split(String text, int maxPieces) {
|
||||||
return new WordPiece(vocabulary);
|
List<Piece> pieces = new ArrayList<>();
|
||||||
|
for (int[] word : words(text)) {
|
||||||
|
if (pieces.size() >= maxPieces) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
splitWord(text, word[0], word[1], pieces, maxPieces);
|
||||||
}
|
}
|
||||||
|
return pieces;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Читает словарь из {@code tokenizer.json} Hugging Face. Некоторые модели
|
* Границы слов: разделителями считаются пробельные символы и знаки препинания, причём знак
|
||||||
* (например, WikiNEuRal) не кладут отдельный {@code vocab.txt}, а хранят
|
* препинания сам становится отдельным словом.
|
||||||
* словарь внутри токенизатора.
|
*/
|
||||||
*/
|
private static List<int[]> words(String text) {
|
||||||
static WordPiece fromTokenizerJson(Path tokenizerFile) throws IOException {
|
List<int[]> result = new ArrayList<>();
|
||||||
JsonNode root = new ObjectMapper().readTree(Files.readAllBytes(tokenizerFile));
|
int start = -1;
|
||||||
JsonNode vocab = root.path("model").path("vocab");
|
for (int i = 0; i < text.length(); i++) {
|
||||||
Map<String, Integer> vocabulary = HashMap.newHashMap(vocab.size());
|
char c = text.charAt(i);
|
||||||
Iterator<Map.Entry<String, JsonNode>> fields = vocab.fields();
|
boolean separator = Character.isWhitespace(c) || isPunctuation(c);
|
||||||
while (fields.hasNext()) {
|
if (separator) {
|
||||||
Map.Entry<String, JsonNode> entry = fields.next();
|
|
||||||
vocabulary.putIfAbsent(entry.getKey(), entry.getValue().asInt());
|
|
||||||
}
|
|
||||||
return new WordPiece(vocabulary);
|
|
||||||
}
|
|
||||||
|
|
||||||
int classifyId() {
|
|
||||||
return classifyId;
|
|
||||||
}
|
|
||||||
|
|
||||||
int separatorId() {
|
|
||||||
return separatorId;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Подслова текста в порядке следования; служебные токены сюда не входят. */
|
|
||||||
List<Piece> split(String text, int maxPieces) {
|
|
||||||
List<Piece> pieces = new ArrayList<>();
|
|
||||||
for (int[] word : words(text)) {
|
|
||||||
if (pieces.size() >= maxPieces) {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
splitWord(text, word[0], word[1], pieces, maxPieces);
|
|
||||||
}
|
|
||||||
return pieces;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Границы слов: разделителями считаются пробельные символы и знаки препинания,
|
|
||||||
* причём знак препинания сам становится отдельным словом.
|
|
||||||
*/
|
|
||||||
private static List<int[]> words(String text) {
|
|
||||||
List<int[]> result = new ArrayList<>();
|
|
||||||
int start = -1;
|
|
||||||
for (int i = 0; i < text.length(); i++) {
|
|
||||||
char c = text.charAt(i);
|
|
||||||
boolean separator = Character.isWhitespace(c) || isPunctuation(c);
|
|
||||||
if (separator) {
|
|
||||||
if (start >= 0) {
|
|
||||||
result.add(new int[]{start, i});
|
|
||||||
start = -1;
|
|
||||||
}
|
|
||||||
if (isPunctuation(c)) {
|
|
||||||
result.add(new int[]{i, i + 1});
|
|
||||||
}
|
|
||||||
} else if (start < 0) {
|
|
||||||
start = i;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (start >= 0) {
|
if (start >= 0) {
|
||||||
result.add(new int[]{start, text.length()});
|
result.add(new int[] {start, i});
|
||||||
|
start = -1;
|
||||||
}
|
}
|
||||||
return result;
|
if (isPunctuation(c)) {
|
||||||
|
result.add(new int[] {i, i + 1});
|
||||||
|
}
|
||||||
|
} else if (start < 0) {
|
||||||
|
start = i;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
if (start >= 0) {
|
||||||
|
result.add(new int[] {start, text.length()});
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
private static boolean isPunctuation(char c) {
|
private static boolean isPunctuation(char c) {
|
||||||
if (Character.isLetterOrDigit(c)) {
|
if (Character.isLetterOrDigit(c)) {
|
||||||
return false;
|
return false;
|
||||||
}
|
|
||||||
return !Character.isWhitespace(c);
|
|
||||||
}
|
}
|
||||||
|
return !Character.isWhitespace(c);
|
||||||
|
}
|
||||||
|
|
||||||
private void splitWord(String text, int from, int to, List<Piece> sink, int maxPieces) {
|
private void splitWord(String text, int from, int to, List<Piece> sink, int maxPieces) {
|
||||||
if (to - from > MAX_WORD_CHARS) {
|
if (to - from > MAX_WORD_CHARS) {
|
||||||
sink.add(new Piece(unknownId, from, to));
|
sink.add(new Piece(unknownId, from, to));
|
||||||
return;
|
return;
|
||||||
}
|
|
||||||
int cursor = from;
|
|
||||||
List<Piece> ofThisWord = new ArrayList<>();
|
|
||||||
while (cursor < to) {
|
|
||||||
int end = to;
|
|
||||||
Integer id = null;
|
|
||||||
while (end > cursor) {
|
|
||||||
String candidate = text.substring(cursor, end);
|
|
||||||
String lookup = cursor == from ? candidate : CONTINUATION + candidate;
|
|
||||||
id = vocabulary.get(lookup);
|
|
||||||
if (id != null) {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
end--;
|
|
||||||
}
|
|
||||||
if (id == null) {
|
|
||||||
// Ни одна часть слова не нашлась — слово целиком неизвестно.
|
|
||||||
sink.add(new Piece(unknownId, from, to));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
ofThisWord.add(new Piece(id, cursor, end));
|
|
||||||
cursor = end;
|
|
||||||
}
|
|
||||||
for (Piece piece : ofThisWord) {
|
|
||||||
if (sink.size() >= maxPieces) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
sink.add(piece);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
int cursor = from;
|
||||||
|
List<Piece> ofThisWord = new ArrayList<>();
|
||||||
|
while (cursor < to) {
|
||||||
|
int end = to;
|
||||||
|
Integer id = null;
|
||||||
|
while (end > cursor) {
|
||||||
|
String candidate = text.substring(cursor, end);
|
||||||
|
String lookup = cursor == from ? candidate : CONTINUATION + candidate;
|
||||||
|
id = vocabulary.get(lookup);
|
||||||
|
if (id != null) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
end--;
|
||||||
|
}
|
||||||
|
if (id == null) {
|
||||||
|
// Ни одна часть слова не нашлась — слово целиком неизвестно.
|
||||||
|
sink.add(new Piece(unknownId, from, to));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
ofThisWord.add(new Piece(id, cursor, end));
|
||||||
|
cursor = end;
|
||||||
|
}
|
||||||
|
for (Piece piece : ofThisWord) {
|
||||||
|
if (sink.size() >= maxPieces) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
sink.add(piece);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private static int required(Map<String, Integer> vocabulary, String token) {
|
private static int required(Map<String, Integer> vocabulary, String token) {
|
||||||
Integer id = vocabulary.get(token);
|
Integer id = vocabulary.get(token);
|
||||||
if (id == null) {
|
if (id == null) {
|
||||||
throw new IllegalStateException("В словаре нет служебного токена " + token);
|
throw new IllegalStateException("В словаре нет служебного токена " + token);
|
||||||
}
|
|
||||||
return id;
|
|
||||||
}
|
}
|
||||||
|
return id;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,39 +8,40 @@ import java.util.function.BiFunction;
|
|||||||
/**
|
/**
|
||||||
* Состояние одной операции маскирования.
|
* Состояние одной операции маскирования.
|
||||||
*
|
*
|
||||||
* <p>Одинаковые значения в пределах запроса получают одинаковую замену: если
|
* <p>Одинаковые значения в пределах запроса получают одинаковую замену: если клиент упомянут
|
||||||
* клиент упомянут дважды, в тексте дважды окажется {@code [FIO_1]}, и смысл
|
* дважды, в тексте дважды окажется {@code [FIO_1]}, и смысл запроса для модели сохранится.
|
||||||
* запроса для модели сохранится.
|
|
||||||
*
|
*
|
||||||
* <p>Экземпляр живёт в рамках одного вызова и между потоками не разделяется.
|
* <p>Экземпляр живёт в рамках одного вызова и между потоками не разделяется.
|
||||||
*/
|
*/
|
||||||
public final class MaskContext {
|
public final class MaskContext {
|
||||||
|
|
||||||
/** Разделитель ключа; в названии типа ПД этот знак не встречается. */
|
/** Разделитель ключа; в названии типа ПД этот знак не встречается. */
|
||||||
private static final char SEPARATOR = '#';
|
private static final char SEPARATOR = '#';
|
||||||
|
|
||||||
private final Map<String, String> assigned = new HashMap<>();
|
private final Map<String, String> assigned = new HashMap<>();
|
||||||
private final Map<String, Integer> counters = new HashMap<>();
|
private final Map<String, Integer> counters = new HashMap<>();
|
||||||
private final Map<String, String> restorations = new LinkedHashMap<>();
|
private final Map<String, String> restorations = new LinkedHashMap<>();
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Замена для значения; при повторе возвращается ранее выданная.
|
* Замена для значения; при повторе возвращается ранее выданная.
|
||||||
*
|
*
|
||||||
* @param factory получает тип ПД и порядковый номер значения этого типа
|
* @param factory получает тип ПД и порядковый номер значения этого типа
|
||||||
*/
|
*/
|
||||||
public String resolve(String type, String value, BiFunction<String, Integer, String> factory) {
|
public String resolve(String type, String value, BiFunction<String, Integer, String> factory) {
|
||||||
return assigned.computeIfAbsent(type + SEPARATOR + value, key -> {
|
return assigned.computeIfAbsent(
|
||||||
String replacement = factory.apply(type, counters.merge(type, 1, Integer::sum));
|
type + SEPARATOR + value,
|
||||||
restorations.put(replacement, value);
|
key -> {
|
||||||
return replacement;
|
String replacement = factory.apply(type, counters.merge(type, 1, Integer::sum));
|
||||||
|
restorations.put(replacement, value);
|
||||||
|
return replacement;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Чем заменять обратно: подстановка к исходному значению. Нужно там, где текст
|
* Чем заменять обратно: подстановка к исходному значению. Нужно там, где текст возвращается не
|
||||||
* возвращается не целиком, а изменённым — например, в ответе языковой модели.
|
* целиком, а изменённым — например, в ответе языковой модели.
|
||||||
*/
|
*/
|
||||||
public Map<String, String> restorations() {
|
public Map<String, String> restorations() {
|
||||||
return Map.copyOf(restorations);
|
return Map.copyOf(restorations);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,12 +3,20 @@ package ru.pdguard.mask;
|
|||||||
/** Чем заменяется найденное значение. Выбирается настройками системы-потребителя. */
|
/** Чем заменяется найденное значение. Выбирается настройками системы-потребителя. */
|
||||||
public enum MaskMode {
|
public enum MaskMode {
|
||||||
|
|
||||||
/** Звёздочки с сохранением длины и разделителей: {@code 45** ****56}. */
|
/** Звёздочки с сохранением длины и разделителей: {@code 45** ****56}. */
|
||||||
MASK,
|
MASK,
|
||||||
|
|
||||||
/** Порядковый токен: {@code [FIO_1]}. Компактно и однозначно обратимо. */
|
/**
|
||||||
TOKEN,
|
* Звёздочки без исключений: каждый тип закрывается целиком, даже те, что в {@link #MASK} частично
|
||||||
|
* открыты (края номера) или превращаются в инициалы (ФИО {@code Иванов Иван Иванович} → {@code
|
||||||
|
* ******* **** *********}, не {@code И. И. И.} — инициалы всё ещё выдают число слов и первую
|
||||||
|
* букву каждого).
|
||||||
|
*/
|
||||||
|
STRICT,
|
||||||
|
|
||||||
/** Правдоподобная подстановка: вместо настоящего имени — вымышленное. */
|
/** Порядковый токен: {@code [FIO_1]}. Компактно и однозначно обратимо. */
|
||||||
SYNTHETIC
|
TOKEN,
|
||||||
|
|
||||||
|
/** Правдоподобная подстановка: вместо настоящего имени — вымышленное. */
|
||||||
|
SYNTHETIC
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,82 +1,81 @@
|
|||||||
package ru.pdguard.mask;
|
package ru.pdguard.mask;
|
||||||
|
|
||||||
import org.springframework.stereotype.Component;
|
|
||||||
import ru.pdguard.detect.PdTypes;
|
|
||||||
|
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
import java.util.function.UnaryOperator;
|
import java.util.function.UnaryOperator;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
import ru.pdguard.detect.PdTypes;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Превращает найденное значение в замену согласно настройкам системы.
|
* Превращает найденное значение в замену согласно настройкам системы.
|
||||||
*
|
*
|
||||||
* <p>Тип, для которого вид маски не задан, скрывается звёздочками целиком —
|
* <p>Тип, для которого вид маски не задан, скрывается звёздочками целиком — безопасное поведение по
|
||||||
* безопасное поведение по умолчанию для вновь добавленных правил.
|
* умолчанию для вновь добавленных правил.
|
||||||
*/
|
*/
|
||||||
@Component
|
@Component
|
||||||
public class Masker {
|
public class Masker {
|
||||||
|
|
||||||
private static final UnaryOperator<String> EDGES = v -> Strategies.keepEdges(v, 2, 2);
|
private static final UnaryOperator<String> EDGES = v -> Strategies.keepEdges(v, 2, 2);
|
||||||
private static final UnaryOperator<String> SHORT_SERIES = v -> Strategies.keepEdges(v, 0, 2);
|
private static final UnaryOperator<String> SHORT_SERIES = v -> Strategies.keepEdges(v, 0, 2);
|
||||||
|
|
||||||
private static final Map<String, UnaryOperator<String>> BY_TYPE = Map.ofEntries(
|
private static final Map<String, UnaryOperator<String>> BY_TYPE =
|
||||||
Map.entry(PdTypes.EMAIL, Strategies::email),
|
Map.ofEntries(
|
||||||
Map.entry(PdTypes.PHONE, EDGES),
|
Map.entry(PdTypes.EMAIL, Strategies::email),
|
||||||
Map.entry(PdTypes.CARD, EDGES),
|
Map.entry(PdTypes.PHONE, EDGES),
|
||||||
Map.entry(PdTypes.INN, EDGES),
|
Map.entry(PdTypes.CARD, EDGES),
|
||||||
Map.entry(PdTypes.SNILS, EDGES),
|
Map.entry(PdTypes.INN, EDGES),
|
||||||
Map.entry(PdTypes.PASSPORT, EDGES),
|
Map.entry(PdTypes.SNILS, EDGES),
|
||||||
Map.entry(PdTypes.DRIVER_LICENSE, EDGES),
|
Map.entry(PdTypes.PASSPORT, EDGES),
|
||||||
Map.entry(PdTypes.DEPT_CODE, EDGES),
|
Map.entry(PdTypes.DRIVER_LICENSE, EDGES),
|
||||||
// У этих документов серия короткая — две цифры или две буквы. Оставь мы
|
Map.entry(PdTypes.DEPT_CODE, EDGES),
|
||||||
// первые два знака, серия оказалась бы открыта целиком, поэтому видны
|
// У этих документов серия короткая — две цифры или две буквы. Оставь мы
|
||||||
// только последние. У паспорта РФ и водительского удостоверения серия
|
// первые два знака, серия оказалась бы открыта целиком, поэтому видны
|
||||||
// из четырёх знаков, там открывается половина.
|
// только последние. У паспорта РФ и водительского удостоверения серия
|
||||||
Map.entry(PdTypes.FOREIGN_PASSPORT, SHORT_SERIES),
|
// из четырёх знаков, там открывается половина.
|
||||||
Map.entry(PdTypes.MILITARY_ID, SHORT_SERIES),
|
Map.entry(PdTypes.FOREIGN_PASSPORT, SHORT_SERIES),
|
||||||
Map.entry(PdTypes.BIRTH_CERTIFICATE, SHORT_SERIES),
|
Map.entry(PdTypes.MILITARY_ID, SHORT_SERIES),
|
||||||
Map.entry(PdTypes.MEDICAL_POLICY, EDGES),
|
Map.entry(PdTypes.BIRTH_CERTIFICATE, SHORT_SERIES),
|
||||||
Map.entry(PdTypes.CARDHOLDER, Strategies::initials),
|
Map.entry(PdTypes.MEDICAL_POLICY, EDGES),
|
||||||
Map.entry(PdTypes.FIO, Strategies::initials),
|
Map.entry(PdTypes.CARDHOLDER, Strategies::initials),
|
||||||
|
Map.entry(PdTypes.FIO, Strategies::initials),
|
||||||
|
|
||||||
// Код проверки и пин-код не показываем даже частично: у них слишком
|
// Код проверки и пин-код не показываем даже частично: у них слишком
|
||||||
// мало знаков, чтобы открывать хотя бы один.
|
// мало знаков, чтобы открывать хотя бы один.
|
||||||
Map.entry(PdTypes.CVV, Strategies::stars),
|
Map.entry(PdTypes.CVV, Strategies::stars),
|
||||||
Map.entry(PdTypes.PIN, Strategies::stars),
|
Map.entry(PdTypes.PIN, Strategies::stars),
|
||||||
|
Map.entry(PdTypes.PASSPORT_ISSUER, Strategies::stars),
|
||||||
|
|
||||||
Map.entry(PdTypes.PASSPORT_ISSUER, Strategies::stars),
|
// У дат сохраняем разделители: модель видит, что это дата, но не какая.
|
||||||
|
Map.entry(PdTypes.BIRTH_DATE, Strategies::starsKeepingPunctuation),
|
||||||
|
Map.entry(PdTypes.PASSPORT_DATE, Strategies::starsKeepingPunctuation),
|
||||||
|
Map.entry(PdTypes.DATE, Strategies::starsKeepingPunctuation),
|
||||||
|
Map.entry(PdTypes.ADDRESS_COUNTRY, Strategies::stars),
|
||||||
|
Map.entry(PdTypes.ADDRESS_POSTCODE, Strategies::stars),
|
||||||
|
Map.entry(PdTypes.ADDRESS_CITY, Strategies::stars),
|
||||||
|
Map.entry(PdTypes.ADDRESS_STREET, Strategies::stars),
|
||||||
|
Map.entry(PdTypes.ADDRESS_HOUSE, Strategies::stars),
|
||||||
|
Map.entry(PdTypes.ADDRESS_FLAT, Strategies::stars),
|
||||||
|
Map.entry(PdTypes.ADDRESS_REGION, Strategies::stars),
|
||||||
|
Map.entry(PdTypes.ADDRESS_DISTRICT, Strategies::stars),
|
||||||
|
Map.entry(PdTypes.BIRTH_PLACE, Strategies::stars),
|
||||||
|
Map.entry(PdTypes.CITIZENSHIP, Strategies::stars),
|
||||||
|
Map.entry(PdTypes.ACCOUNT_NUMBER, EDGES),
|
||||||
|
Map.entry(PdTypes.OGRN, EDGES),
|
||||||
|
Map.entry(PdTypes.OGRNIP, EDGES),
|
||||||
|
Map.entry(PdTypes.KPP, EDGES),
|
||||||
|
// Срок действия карты — разделитель виден, сам месяц/год нет.
|
||||||
|
Map.entry(PdTypes.CARD_EXPIRY, Strategies::starsKeepingPunctuation),
|
||||||
|
Map.entry(PdTypes.BIK, Strategies::stars),
|
||||||
|
Map.entry(PdTypes.INCOME, Strategies::stars),
|
||||||
|
Map.entry(PdTypes.BIOMETRIC, Strategies::stars));
|
||||||
|
|
||||||
// У дат сохраняем разделители: модель видит, что это дата, но не какая.
|
public String mask(String type, String value, MaskMode mode, MaskContext context) {
|
||||||
Map.entry(PdTypes.BIRTH_DATE, Strategies::starsKeepingPunctuation),
|
return switch (mode) {
|
||||||
Map.entry(PdTypes.PASSPORT_DATE, Strategies::starsKeepingPunctuation),
|
case MASK -> BY_TYPE.getOrDefault(type, Strategies::stars).apply(value);
|
||||||
Map.entry(PdTypes.DATE, Strategies::starsKeepingPunctuation),
|
// STRICT игнорирует BY_TYPE целиком — ни один тип не открывает края
|
||||||
|
// и ФИО не превращается в инициалы, только сплошные звёздочки.
|
||||||
Map.entry(PdTypes.ADDRESS_COUNTRY, Strategies::stars),
|
case STRICT -> Strategies.stars(value);
|
||||||
Map.entry(PdTypes.ADDRESS_POSTCODE, Strategies::stars),
|
case TOKEN -> context.resolve(type, value, (t, n) -> "[" + t + "_" + n + "]");
|
||||||
Map.entry(PdTypes.ADDRESS_CITY, Strategies::stars),
|
case SYNTHETIC -> context.resolve(type, value, (t, n) -> Synthetic.forType(t, value, n));
|
||||||
Map.entry(PdTypes.ADDRESS_STREET, Strategies::stars),
|
};
|
||||||
Map.entry(PdTypes.ADDRESS_HOUSE, Strategies::stars),
|
}
|
||||||
Map.entry(PdTypes.ADDRESS_FLAT, Strategies::stars),
|
|
||||||
Map.entry(PdTypes.ADDRESS_REGION, Strategies::stars),
|
|
||||||
Map.entry(PdTypes.ADDRESS_DISTRICT, Strategies::stars),
|
|
||||||
Map.entry(PdTypes.BIRTH_PLACE, Strategies::stars),
|
|
||||||
Map.entry(PdTypes.CITIZENSHIP, Strategies::stars),
|
|
||||||
|
|
||||||
Map.entry(PdTypes.ACCOUNT_NUMBER, EDGES),
|
|
||||||
Map.entry(PdTypes.OGRN, EDGES),
|
|
||||||
Map.entry(PdTypes.OGRNIP, EDGES),
|
|
||||||
Map.entry(PdTypes.KPP, EDGES),
|
|
||||||
// Срок действия карты — разделитель виден, сам месяц/год нет.
|
|
||||||
Map.entry(PdTypes.CARD_EXPIRY, Strategies::starsKeepingPunctuation),
|
|
||||||
Map.entry(PdTypes.BIK, Strategies::stars),
|
|
||||||
Map.entry(PdTypes.INCOME, Strategies::stars),
|
|
||||||
Map.entry(PdTypes.BIOMETRIC, Strategies::stars)
|
|
||||||
);
|
|
||||||
|
|
||||||
public String mask(String type, String value, MaskMode mode, MaskContext context) {
|
|
||||||
return switch (mode) {
|
|
||||||
case MASK -> BY_TYPE.getOrDefault(type, Strategies::stars).apply(value);
|
|
||||||
case TOKEN -> context.resolve(type, value, (t, n) -> "[" + t + "_" + n + "]");
|
|
||||||
case SYNTHETIC -> context.resolve(type, value, (t, n) -> Synthetic.forType(t, value, n));
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,113 +3,110 @@ package ru.pdguard.mask;
|
|||||||
/**
|
/**
|
||||||
* Способы преобразования найденного значения в маску.
|
* Способы преобразования найденного значения в маску.
|
||||||
*
|
*
|
||||||
* <p>Все стратегии сохраняют длину и разделители исходного значения: так
|
* <p>Все стратегии сохраняют длину и разделители исходного значения: так замаскированный текст
|
||||||
* замаскированный текст остаётся читаемым для LLM и минимально отличается
|
* остаётся читаемым для LLM и минимально отличается от эталона при посимвольном сравнении.
|
||||||
* от эталона при посимвольном сравнении.
|
|
||||||
*/
|
*/
|
||||||
public final class Strategies {
|
public final class Strategies {
|
||||||
|
|
||||||
private static final char MASK = '*';
|
private static final char MASK = '*';
|
||||||
|
|
||||||
private Strategies() {
|
private Strategies() {}
|
||||||
}
|
|
||||||
|
|
||||||
/** Каждый непробельный символ заменяется на «*». */
|
/** Каждый непробельный символ заменяется на «*». */
|
||||||
public static String stars(String value) {
|
public static String stars(String value) {
|
||||||
StringBuilder sb = new StringBuilder(value.length());
|
StringBuilder sb = new StringBuilder(value.length());
|
||||||
for (int i = 0; i < value.length(); i++) {
|
for (int i = 0; i < value.length(); i++) {
|
||||||
char c = value.charAt(i);
|
char c = value.charAt(i);
|
||||||
sb.append(Character.isWhitespace(c) ? c : MASK);
|
sb.append(Character.isWhitespace(c) ? c : MASK);
|
||||||
}
|
|
||||||
return sb.toString();
|
|
||||||
}
|
}
|
||||||
|
return sb.toString();
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Скрывает буквы и цифры, оставляя разделители: {@code 12.05.1985} → {@code **.**.****},
|
* Скрывает буквы и цифры, оставляя разделители: {@code 12.05.1985} → {@code **.**.****}, {@code
|
||||||
* {@code 12 мая 1985} → {@code ** *** ****}. Форма записи остаётся видна модели,
|
* 12 мая 1985} → {@code ** *** ****}. Форма записи остаётся видна модели, само значение — нет.
|
||||||
* само значение — нет.
|
*/
|
||||||
*/
|
public static String starsKeepingPunctuation(String value) {
|
||||||
public static String starsKeepingPunctuation(String value) {
|
StringBuilder sb = new StringBuilder(value.length());
|
||||||
StringBuilder sb = new StringBuilder(value.length());
|
for (int i = 0; i < value.length(); i++) {
|
||||||
for (int i = 0; i < value.length(); i++) {
|
char c = value.charAt(i);
|
||||||
char c = value.charAt(i);
|
sb.append(Character.isLetterOrDigit(c) ? MASK : c);
|
||||||
sb.append(Character.isLetterOrDigit(c) ? MASK : c);
|
|
||||||
}
|
|
||||||
return sb.toString();
|
|
||||||
}
|
}
|
||||||
|
return sb.toString();
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Оставляет первые и последние значащие символы, остальные скрывает,
|
* Оставляет первые и последние значащие символы, остальные скрывает, разделители сохраняет:
|
||||||
* разделители сохраняет: {@code 4509 123456} → {@code 45** ****56}.
|
* {@code 4509 123456} → {@code 45** ****56}.
|
||||||
*/
|
*/
|
||||||
public static String keepEdges(String value, int head, int tail) {
|
public static String keepEdges(String value, int head, int tail) {
|
||||||
int significant = 0;
|
int significant = 0;
|
||||||
for (int i = 0; i < value.length(); i++) {
|
for (int i = 0; i < value.length(); i++) {
|
||||||
if (Character.isLetterOrDigit(value.charAt(i))) {
|
if (Character.isLetterOrDigit(value.charAt(i))) {
|
||||||
significant++;
|
significant++;
|
||||||
}
|
}
|
||||||
}
|
|
||||||
if (significant <= head + tail) {
|
|
||||||
return stars(value);
|
|
||||||
}
|
|
||||||
StringBuilder sb = new StringBuilder(value.length());
|
|
||||||
int seen = 0;
|
|
||||||
for (int i = 0; i < value.length(); i++) {
|
|
||||||
char c = value.charAt(i);
|
|
||||||
if (!Character.isLetterOrDigit(c)) {
|
|
||||||
sb.append(c);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
boolean visible = seen < head || seen >= significant - tail;
|
|
||||||
sb.append(visible ? c : MASK);
|
|
||||||
seen++;
|
|
||||||
}
|
|
||||||
return sb.toString();
|
|
||||||
}
|
}
|
||||||
|
if (significant <= head + tail) {
|
||||||
|
return stars(value);
|
||||||
|
}
|
||||||
|
StringBuilder sb = new StringBuilder(value.length());
|
||||||
|
int seen = 0;
|
||||||
|
for (int i = 0; i < value.length(); i++) {
|
||||||
|
char c = value.charAt(i);
|
||||||
|
if (!Character.isLetterOrDigit(c)) {
|
||||||
|
sb.append(c);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
boolean visible = seen < head || seen >= significant - tail;
|
||||||
|
sb.append(visible ? c : MASK);
|
||||||
|
seen++;
|
||||||
|
}
|
||||||
|
return sb.toString();
|
||||||
|
}
|
||||||
|
|
||||||
/** ФИО превращается в инициалы: {@code Иванов Иван Иванович} → {@code И. И. И.} */
|
/** ФИО превращается в инициалы: {@code Иванов Иван Иванович} → {@code И. И. И.} */
|
||||||
public static String initials(String value) {
|
public static String initials(String value) {
|
||||||
StringBuilder sb = new StringBuilder();
|
StringBuilder sb = new StringBuilder();
|
||||||
boolean wordStart = true;
|
boolean wordStart = true;
|
||||||
for (int i = 0; i < value.length(); i++) {
|
for (int i = 0; i < value.length(); i++) {
|
||||||
char c = value.charAt(i);
|
char c = value.charAt(i);
|
||||||
if (Character.isLetter(c)) {
|
if (Character.isLetter(c)) {
|
||||||
if (wordStart) {
|
if (wordStart) {
|
||||||
if (!sb.isEmpty()) {
|
if (!sb.isEmpty()) {
|
||||||
sb.append(' ');
|
sb.append(' ');
|
||||||
}
|
}
|
||||||
sb.append(Character.toUpperCase(c)).append('.');
|
sb.append(Character.toUpperCase(c)).append('.');
|
||||||
wordStart = false;
|
wordStart = false;
|
||||||
}
|
|
||||||
} else {
|
|
||||||
wordStart = true;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
return sb.isEmpty() ? stars(value) : sb.toString();
|
} else {
|
||||||
|
wordStart = true;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
return sb.isEmpty() ? stars(value) : sb.toString();
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Адрес почты: видны первая буква имени ящика, первая буква домена и зона.
|
* Адрес почты: видны первая буква имени ящика, первая буква домена и зона. {@code
|
||||||
* {@code ivan.petrov@mail.ru} → {@code i**********@m***.ru}
|
* ivan.petrov@mail.ru} → {@code i**********@m***.ru}
|
||||||
*/
|
*/
|
||||||
public static String email(String value) {
|
public static String email(String value) {
|
||||||
int at = value.lastIndexOf('@');
|
int at = value.lastIndexOf('@');
|
||||||
if (at <= 0 || at == value.length() - 1) {
|
if (at <= 0 || at == value.length() - 1) {
|
||||||
return stars(value);
|
return stars(value);
|
||||||
}
|
|
||||||
String local = value.substring(0, at);
|
|
||||||
String domain = value.substring(at + 1);
|
|
||||||
int dot = domain.lastIndexOf('.');
|
|
||||||
if (dot <= 0) {
|
|
||||||
return hideTail(local) + '@' + hideTail(domain);
|
|
||||||
}
|
|
||||||
return hideTail(local) + '@' + hideTail(domain.substring(0, dot)) + domain.substring(dot);
|
|
||||||
}
|
}
|
||||||
|
String local = value.substring(0, at);
|
||||||
|
String domain = value.substring(at + 1);
|
||||||
|
int dot = domain.lastIndexOf('.');
|
||||||
|
if (dot <= 0) {
|
||||||
|
return hideTail(local) + '@' + hideTail(domain);
|
||||||
|
}
|
||||||
|
return hideTail(local) + '@' + hideTail(domain.substring(0, dot)) + domain.substring(dot);
|
||||||
|
}
|
||||||
|
|
||||||
private static String hideTail(String part) {
|
private static String hideTail(String part) {
|
||||||
if (part.length() <= 1) {
|
if (part.length() <= 1) {
|
||||||
return part;
|
return part;
|
||||||
}
|
|
||||||
return part.charAt(0) + String.valueOf(MASK).repeat(part.length() - 1);
|
|
||||||
}
|
}
|
||||||
|
return part.charAt(0) + String.valueOf(MASK).repeat(part.length() - 1);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,80 +6,102 @@ import ru.pdguard.detect.Validators;
|
|||||||
/**
|
/**
|
||||||
* Правдоподобные подставные значения вместо настоящих.
|
* Правдоподобные подставные значения вместо настоящих.
|
||||||
*
|
*
|
||||||
* <p>Модель получает текст, который выглядит естественно, и качество ответа
|
* <p>Модель получает текст, который выглядит естественно, и качество ответа страдает меньше, чем от
|
||||||
* страдает меньше, чем от звёздочек. Значения детерминированы: одно и то же
|
* звёздочек. Значения детерминированы: одно и то же исходное значение всегда даёт одну и ту же
|
||||||
* исходное значение всегда даёт одну и ту же подстановку.
|
* подстановку.
|
||||||
*/
|
*/
|
||||||
final class Synthetic {
|
final class Synthetic {
|
||||||
|
|
||||||
private static final String[] SURNAMES =
|
private static final String[] SURNAMES = {
|
||||||
{"Лаврентьев", "Мещеряков", "Тихомиров", "Ясенев", "Бурмистров", "Кольцов"};
|
"Лаврентьев", "Мещеряков", "Тихомиров", "Ясенев", "Бурмистров", "Кольцов"
|
||||||
private static final String[] NAMES = {"Артём", "Никита", "Глеб", "Тимур", "Марк", "Лев"};
|
};
|
||||||
private static final String[] PATRONYMICS =
|
private static final String[] NAMES = {"Артём", "Никита", "Глеб", "Тимур", "Марк", "Лев"};
|
||||||
{"Артёмович", "Никитич", "Глебович", "Тимурович", "Маркович", "Львович"};
|
private static final String[] PATRONYMICS = {
|
||||||
private static final String[] DOMAINS = {"example.com", "example.org", "example.net"};
|
"Артёмович", "Никитич", "Глебович", "Тимурович", "Маркович", "Львович"
|
||||||
|
};
|
||||||
|
private static final String[] DOMAINS = {"example.com", "example.org", "example.net"};
|
||||||
|
|
||||||
private Synthetic() {
|
private Synthetic() {}
|
||||||
}
|
|
||||||
|
|
||||||
static String forType(String type, String value, int ordinal) {
|
static String forType(String type, String value, int ordinal) {
|
||||||
int seed = value.hashCode() & Integer.MAX_VALUE;
|
int seed = value.hashCode() & Integer.MAX_VALUE;
|
||||||
return switch (type) {
|
return switch (type) {
|
||||||
case PdTypes.FIO -> pick(SURNAMES, seed) + " " + pick(NAMES, seed >> 3)
|
case PdTypes.FIO ->
|
||||||
+ " " + pick(PATRONYMICS, seed >> 6);
|
pick(SURNAMES, seed) + " " + pick(NAMES, seed >> 3) + " " + pick(PATRONYMICS, seed >> 6);
|
||||||
case PdTypes.CARDHOLDER -> "IVAN PETROV";
|
case PdTypes.CARDHOLDER -> "IVAN PETROV";
|
||||||
case PdTypes.EMAIL -> "user" + ordinal + "@" + pick(DOMAINS, seed);
|
case PdTypes.EMAIL -> "user" + ordinal + "@" + pick(DOMAINS, seed);
|
||||||
case PdTypes.PHONE -> "+7 9" + digits(seed, 2) + " " + digits(seed >> 4, 3)
|
case PdTypes.PHONE ->
|
||||||
+ "-" + digits(seed >> 8, 2) + "-" + digits(seed >> 12, 2);
|
"+7 9"
|
||||||
case PdTypes.CARD -> luhnCard(seed);
|
+ digits(seed, 2)
|
||||||
case PdTypes.PASSPORT, PdTypes.DRIVER_LICENSE, PdTypes.FOREIGN_PASSPORT,
|
+ " "
|
||||||
PdTypes.MILITARY_ID -> digits(seed, 4) + " " + digits(seed >> 6, 6);
|
+ digits(seed >> 4, 3)
|
||||||
case PdTypes.INN -> digits(seed, 12);
|
+ "-"
|
||||||
case PdTypes.MEDICAL_POLICY -> digits(seed, 16);
|
+ digits(seed >> 8, 2)
|
||||||
case PdTypes.SNILS -> digits(seed, 3) + "-" + digits(seed >> 4, 3)
|
+ "-"
|
||||||
+ "-" + digits(seed >> 8, 3) + " " + digits(seed >> 12, 2);
|
+ digits(seed >> 12, 2);
|
||||||
case PdTypes.BIRTH_DATE, PdTypes.PASSPORT_DATE, PdTypes.DATE -> syntheticDate(seed);
|
case PdTypes.CARD -> luhnCard(seed);
|
||||||
case PdTypes.ADDRESS_CITY -> "Зареченск";
|
case PdTypes.PASSPORT,
|
||||||
case PdTypes.ADDRESS_STREET -> "Сосновая";
|
PdTypes.DRIVER_LICENSE,
|
||||||
case PdTypes.ADDRESS_HOUSE -> String.valueOf(1 + Math.floorMod(seed, 90));
|
PdTypes.FOREIGN_PASSPORT,
|
||||||
case PdTypes.ADDRESS_FLAT -> String.valueOf(1 + Math.floorMod(seed, 200));
|
PdTypes.MILITARY_ID ->
|
||||||
case PdTypes.ADDRESS_POSTCODE -> digits(seed, 6);
|
digits(seed, 4) + " " + digits(seed >> 6, 6);
|
||||||
case PdTypes.ADDRESS_COUNTRY -> "Заречье";
|
case PdTypes.INN -> digits(seed, 12);
|
||||||
case PdTypes.ADDRESS_REGION -> "Заречная область";
|
case PdTypes.MEDICAL_POLICY -> digits(seed, 16);
|
||||||
case PdTypes.ADDRESS_DISTRICT -> "Сосновый район";
|
case PdTypes.SNILS ->
|
||||||
case PdTypes.CVV -> digits(seed, 3);
|
digits(seed, 3)
|
||||||
case PdTypes.PIN -> digits(seed, 4);
|
+ "-"
|
||||||
// Для остальных типов правдоподобной замены нет — отдаём токен.
|
+ digits(seed >> 4, 3)
|
||||||
default -> "[" + type + "_" + ordinal + "]";
|
+ "-"
|
||||||
};
|
+ digits(seed >> 8, 3)
|
||||||
}
|
+ " "
|
||||||
|
+ digits(seed >> 12, 2);
|
||||||
|
case PdTypes.BIRTH_DATE, PdTypes.PASSPORT_DATE, PdTypes.DATE -> syntheticDate(seed);
|
||||||
|
case PdTypes.ADDRESS_CITY -> "Зареченск";
|
||||||
|
case PdTypes.ADDRESS_STREET -> "Сосновая";
|
||||||
|
case PdTypes.ADDRESS_HOUSE -> String.valueOf(1 + Math.floorMod(seed, 90));
|
||||||
|
case PdTypes.ADDRESS_FLAT -> String.valueOf(1 + Math.floorMod(seed, 200));
|
||||||
|
case PdTypes.ADDRESS_POSTCODE -> digits(seed, 6);
|
||||||
|
case PdTypes.ADDRESS_COUNTRY -> "Заречье";
|
||||||
|
case PdTypes.ADDRESS_REGION -> "Заречная область";
|
||||||
|
case PdTypes.ADDRESS_DISTRICT -> "Сосновый район";
|
||||||
|
case PdTypes.CVV -> digits(seed, 3);
|
||||||
|
case PdTypes.PIN -> digits(seed, 4);
|
||||||
|
// Для остальных типов правдоподобной замены нет — отдаём токен.
|
||||||
|
default -> "[" + type + "_" + ordinal + "]";
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
private static String pick(String[] options, int seed) {
|
private static String pick(String[] options, int seed) {
|
||||||
return options[Math.floorMod(seed, options.length)];
|
return options[Math.floorMod(seed, options.length)];
|
||||||
}
|
}
|
||||||
|
|
||||||
private static String syntheticDate(int seed) {
|
private static String syntheticDate(int seed) {
|
||||||
int day = 1 + Math.floorMod(seed, 28);
|
int day = 1 + Math.floorMod(seed, 28);
|
||||||
int month = 1 + Math.floorMod(seed >> 5, 12);
|
int month = 1 + Math.floorMod(seed >> 5, 12);
|
||||||
int year = 1960 + Math.floorMod(seed >> 9, 45);
|
int year = 1960 + Math.floorMod(seed >> 9, 45);
|
||||||
return String.format("%02d.%02d.%d", day, month, year);
|
return String.format("%02d.%02d.%d", day, month, year);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static String digits(int seed, int count) {
|
private static String digits(int seed, int count) {
|
||||||
StringBuilder sb = new StringBuilder(count);
|
StringBuilder sb = new StringBuilder(count);
|
||||||
int value = Math.abs(seed);
|
int value = Math.abs(seed);
|
||||||
for (int i = 0; i < count; i++) {
|
for (int i = 0; i < count; i++) {
|
||||||
sb.append((char) ('0' + Math.floorMod(value, 10)));
|
sb.append((char) ('0' + Math.floorMod(value, 10)));
|
||||||
value = value / 10 + (i + 1) * 7;
|
value = value / 10 + (i + 1) * 7;
|
||||||
}
|
|
||||||
return sb.toString();
|
|
||||||
}
|
}
|
||||||
|
return sb.toString();
|
||||||
|
}
|
||||||
|
|
||||||
/** Номер карты, проходящий проверку алгоритмом Луна: подстановка должна выглядеть настоящей. */
|
/** Номер карты, проходящий проверку алгоритмом Луна: подстановка должна выглядеть настоящей. */
|
||||||
private static String luhnCard(int seed) {
|
private static String luhnCard(int seed) {
|
||||||
StringBuilder body = new StringBuilder("4").append(digits(seed, 14));
|
StringBuilder body = new StringBuilder("4").append(digits(seed, 14));
|
||||||
body.append(Validators.luhnCheckDigit(body.toString()));
|
body.append(Validators.luhnCheckDigit(body.toString()));
|
||||||
return body.substring(0, 4) + " " + body.substring(4, 8) + " "
|
return body.substring(0, 4)
|
||||||
+ body.substring(8, 12) + " " + body.substring(12);
|
+ " "
|
||||||
}
|
+ body.substring(4, 8)
|
||||||
|
+ " "
|
||||||
|
+ body.substring(8, 12)
|
||||||
|
+ " "
|
||||||
|
+ body.substring(12);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -37,24 +37,20 @@ pdguard:
|
|||||||
systems-file: config/systems.json
|
systems-file: config/systems.json
|
||||||
store:
|
store:
|
||||||
backend: memory
|
backend: memory
|
||||||
# 128MB (было) держал store.chars упёртым в потолок под держащей нагрузкой —
|
|
||||||
# FIFO-вытеснение выкидывало свежую запись за миллисекунды, раньше, чем
|
|
||||||
# приходил её собственный демаскирующий запрос: см. k6 на датасете утечек,
|
|
||||||
# 6.1% неверных демасков при 2000 VU, при этом 0% на низкой конкурентности.
|
|
||||||
# На одном узле без соседей по хосту память есть — 512MB даёт запас на порядок.
|
|
||||||
max-chars: 536870912
|
|
||||||
ttl-minutes: 30
|
ttl-minutes: 30
|
||||||
# 32 байта в hex; AES-256 ключ шифрования хранилища
|
# 32 байта в hex; AES-256 ключ шифрования хранилища
|
||||||
encryption-key: "46a38b200c6df557a5fd2c8a57ad3fec6b710b9f3e1fef1451d121a094f63573"
|
encryption-key: "46a38b200c6df557a5fd2c8a57ad3fec6b710b9f3e1fef1451d121a094f63573"
|
||||||
min-concurrent: 8
|
min-concurrent: 100
|
||||||
max-concurrent: 2000
|
max-concurrent: 2000
|
||||||
target-latency-ms: 200
|
target-latency-ms: 900
|
||||||
warmup-iterations: 2000
|
warmup-iterations: 2000
|
||||||
ner:
|
ner:
|
||||||
name-engine: off
|
name-engine: off
|
||||||
name-model: models/wikineural-ner
|
name-model: models/wikineural-ner
|
||||||
address-engine: off
|
address-engine: off
|
||||||
address-model: models/rubert-ner
|
address-model: models/rubert-ner
|
||||||
|
legal-engine: off
|
||||||
|
legal-model: models/ru-legal-ner
|
||||||
max-candidates: 16
|
max-candidates: 16
|
||||||
pool-size: 16
|
pool-size: 16
|
||||||
llm:
|
llm:
|
||||||
|
|||||||
@@ -0,0 +1,308 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="ru">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>PD Guard</title>
|
||||||
|
<style>
|
||||||
|
:root {
|
||||||
|
--bg: #f3efe6;
|
||||||
|
--ink: #1c1915;
|
||||||
|
--muted: #6d655c;
|
||||||
|
--line: #ddd4c6;
|
||||||
|
--card: #fffdf8;
|
||||||
|
--accent: #0e6b52;
|
||||||
|
--accent-ink: #083d30;
|
||||||
|
--danger: #8d2e2e;
|
||||||
|
--danger-bg: #f8ecec;
|
||||||
|
}
|
||||||
|
|
||||||
|
* { box-sizing: border-box; }
|
||||||
|
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
min-height: 100vh;
|
||||||
|
color: var(--ink);
|
||||||
|
background:
|
||||||
|
radial-gradient(1200px 500px at 10% -10%, #e7f3ee 0%, transparent 55%),
|
||||||
|
var(--bg);
|
||||||
|
font: 16px/1.45 "Segoe UI", system-ui, sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
main {
|
||||||
|
width: min(760px, calc(100% - 32px));
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 48px 0 64px;
|
||||||
|
}
|
||||||
|
|
||||||
|
header h1 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 32px;
|
||||||
|
letter-spacing: -0.03em;
|
||||||
|
}
|
||||||
|
|
||||||
|
header p {
|
||||||
|
margin: 8px 0 0;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
form {
|
||||||
|
margin-top: 28px;
|
||||||
|
display: grid;
|
||||||
|
gap: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
fieldset {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
border: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
legend {
|
||||||
|
padding: 0;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 650;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.modes {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mode {
|
||||||
|
display: block;
|
||||||
|
padding: 14px 14px 12px;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 12px;
|
||||||
|
background: var(--card);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mode:has(input:checked) {
|
||||||
|
border-color: var(--accent);
|
||||||
|
box-shadow: inset 0 0 0 1px var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mode:has(input:focus-visible) {
|
||||||
|
outline: 2px solid var(--accent);
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mode input {
|
||||||
|
position: absolute;
|
||||||
|
opacity: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mode strong {
|
||||||
|
display: block;
|
||||||
|
font-size: 14px;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mode span {
|
||||||
|
display: block;
|
||||||
|
margin-top: 4px;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
label.field {
|
||||||
|
display: grid;
|
||||||
|
gap: 8px;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 650;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
textarea {
|
||||||
|
width: 100%;
|
||||||
|
min-height: 160px;
|
||||||
|
resize: vertical;
|
||||||
|
padding: 14px;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 12px;
|
||||||
|
background: var(--card);
|
||||||
|
color: var(--ink);
|
||||||
|
font: 15px/1.5 "Segoe UI", system-ui, sans-serif;
|
||||||
|
text-transform: none;
|
||||||
|
letter-spacing: 0;
|
||||||
|
font-weight: 400;
|
||||||
|
}
|
||||||
|
|
||||||
|
textarea:focus {
|
||||||
|
outline: 2px solid var(--accent);
|
||||||
|
outline-offset: 1px;
|
||||||
|
border-color: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
button {
|
||||||
|
justify-self: start;
|
||||||
|
padding: 12px 18px;
|
||||||
|
border: 0;
|
||||||
|
border-radius: 10px;
|
||||||
|
background: var(--accent);
|
||||||
|
color: #f7fffb;
|
||||||
|
font: 650 15px/1 "Segoe UI", system-ui, sans-serif;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
button:hover { background: var(--accent-ink); }
|
||||||
|
|
||||||
|
button:disabled {
|
||||||
|
opacity: 0.6;
|
||||||
|
cursor: progress;
|
||||||
|
}
|
||||||
|
|
||||||
|
#result {
|
||||||
|
min-height: 120px;
|
||||||
|
margin: 0;
|
||||||
|
padding: 14px;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 12px;
|
||||||
|
background: var(--card);
|
||||||
|
white-space: pre-wrap;
|
||||||
|
word-break: break-word;
|
||||||
|
font: 15px/1.5 ui-monospace, "Cascadia Mono", Consolas, monospace;
|
||||||
|
text-transform: none;
|
||||||
|
letter-spacing: 0;
|
||||||
|
font-weight: 400;
|
||||||
|
}
|
||||||
|
|
||||||
|
#result.error {
|
||||||
|
color: var(--danger);
|
||||||
|
background: var(--danger-bg);
|
||||||
|
border-color: #e4c8c8;
|
||||||
|
}
|
||||||
|
|
||||||
|
#result:empty::before {
|
||||||
|
content: "Результат появится здесь";
|
||||||
|
color: var(--muted);
|
||||||
|
font-family: "Segoe UI", system-ui, sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
.modes { grid-template-columns: 1fr; }
|
||||||
|
main { padding-top: 28px; }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<main>
|
||||||
|
<header>
|
||||||
|
<h1>PD Guard</h1>
|
||||||
|
<p>Маскирование персональных данных перед обработкой.</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<form id="form">
|
||||||
|
<fieldset>
|
||||||
|
<legend>Режим</legend>
|
||||||
|
<div class="modes">
|
||||||
|
<label class="mode">
|
||||||
|
<input type="radio" name="mode" value="default" checked>
|
||||||
|
<strong>MASK</strong>
|
||||||
|
<span>Звёздочки, края номеров видны</span>
|
||||||
|
</label>
|
||||||
|
<label class="mode">
|
||||||
|
<input type="radio" name="mode" value="strict">
|
||||||
|
<strong>STRICT</strong>
|
||||||
|
<span>Сплошные звёздочки</span>
|
||||||
|
</label>
|
||||||
|
<label class="mode">
|
||||||
|
<input type="radio" name="mode" value="crm">
|
||||||
|
<strong>TOKEN</strong>
|
||||||
|
<span>Токены вида [FIO_1]</span>
|
||||||
|
</label>
|
||||||
|
<label class="mode">
|
||||||
|
<input type="radio" name="mode" value="analytics">
|
||||||
|
<strong>SYNTHETIC</strong>
|
||||||
|
<span>Правдоподобная подмена</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</fieldset>
|
||||||
|
|
||||||
|
<label class="field">
|
||||||
|
Текст
|
||||||
|
<textarea id="payload" name="payload" required>Клиент Иванов Иван Иванович, паспорт 4509 123456, тел +7 916 123-45-67</textarea>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<button type="submit" id="run">Обработать</button>
|
||||||
|
|
||||||
|
<label class="field">
|
||||||
|
Результат
|
||||||
|
<pre id="result" aria-live="polite"></pre>
|
||||||
|
</label>
|
||||||
|
</form>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
const form = document.getElementById("form");
|
||||||
|
const payload = document.getElementById("payload");
|
||||||
|
const result = document.getElementById("result");
|
||||||
|
const run = document.getElementById("run");
|
||||||
|
|
||||||
|
form.addEventListener("submit", async (event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
const text = payload.value;
|
||||||
|
if (!text.trim()) {
|
||||||
|
show("Введите текст", true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const systemId = new FormData(form).get("mode");
|
||||||
|
run.disabled = true;
|
||||||
|
show("");
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch("/process", {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"X-System-Id": systemId
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
payload: text,
|
||||||
|
payload_id: crypto.randomUUID()
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.status === 429) {
|
||||||
|
show("Сервис перегружен. Повторите через секунду.", true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const raw = await response.text();
|
||||||
|
let message = raw;
|
||||||
|
try {
|
||||||
|
const data = JSON.parse(raw);
|
||||||
|
if (data && typeof data.result === "string") message = data.result;
|
||||||
|
} catch (ignored) {
|
||||||
|
/* ответ не JSON — показываем как есть */
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
show(message || "Запрос отклонён (" + response.status + ")", true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
show(message);
|
||||||
|
} catch (error) {
|
||||||
|
show("Не удалось связаться с сервисом.", true);
|
||||||
|
} finally {
|
||||||
|
run.disabled = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
function show(text, isError) {
|
||||||
|
result.textContent = text;
|
||||||
|
result.classList.toggle("error", Boolean(isError));
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -1,5 +1,9 @@
|
|||||||
package ru.pdguard;
|
package ru.pdguard;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||||
|
|
||||||
|
import java.util.UUID;
|
||||||
import org.junit.jupiter.api.Test;
|
import org.junit.jupiter.api.Test;
|
||||||
import ru.pdguard.config.SystemPolicy;
|
import ru.pdguard.config.SystemPolicy;
|
||||||
import ru.pdguard.core.PayloadStore;
|
import ru.pdguard.core.PayloadStore;
|
||||||
@@ -7,149 +11,165 @@ import ru.pdguard.core.Pipeline;
|
|||||||
import ru.pdguard.detect.RuleRegistry;
|
import ru.pdguard.detect.RuleRegistry;
|
||||||
import ru.pdguard.mask.Masker;
|
import ru.pdguard.mask.Masker;
|
||||||
|
|
||||||
import java.util.UUID;
|
|
||||||
|
|
||||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
|
||||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
|
||||||
|
|
||||||
/** Банковские реквизиты сверх платёжной карты: счёт, БИК, ОГРН(ИП), КПП, доход, биометрия. */
|
/** Банковские реквизиты сверх платёжной карты: счёт, БИК, ОГРН(ИП), КПП, доход, биометрия. */
|
||||||
class BankTypesTest {
|
class BankTypesTest {
|
||||||
|
|
||||||
private final Pipeline pipeline =
|
private final Pipeline pipeline =
|
||||||
new Pipeline(new RuleRegistry(), new Masker(), new PayloadStore(1_000_000L, 30));
|
new Pipeline(new RuleRegistry(), new Masker(), new PayloadStore(30));
|
||||||
|
|
||||||
private void assertHidden(String text, String secret) {
|
private void assertHidden(String text, String secret) {
|
||||||
String masked = pipeline.process(text, UUID.randomUUID().toString(), SystemPolicy.DEFAULT);
|
String masked = pipeline.process(text, UUID.randomUUID().toString(), SystemPolicy.DEFAULT);
|
||||||
assertFalse(masked.contains(secret), "не замаскировано: «" + secret + "» в ответе «" + masked + "»");
|
assertFalse(
|
||||||
}
|
masked.contains(secret), "не замаскировано: «" + secret + "» в ответе «" + masked + "»");
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Банковские реквизиты маскируются рядом с данными человека. Сами по себе они
|
* Банковские реквизиты маскируются рядом с данными человека. Сами по себе они опознают
|
||||||
* опознают организацию или счёт, а не клиента, и в перечне типов из задания их
|
* организацию или счёт, а не клиента, и в перечне типов из задания их нет — поэтому они
|
||||||
* нет — поэтому они переведены в {@code requireCompanion}, как пин-код и дата.
|
* переведены в {@code requireCompanion}, как пин-код и дата.
|
||||||
*/
|
*/
|
||||||
@Test
|
@Test
|
||||||
void masksAccountNumberNextToPersonalData() {
|
void masksAccountNumberNextToPersonalData() {
|
||||||
assertHidden("Клиент Иванов Иван Иванович, расчётный счёт 40702810500000001234",
|
assertHidden(
|
||||||
"40702810500000001234");
|
"Клиент Иванов Иван Иванович, расчётный счёт 40702810500000001234", "40702810500000001234");
|
||||||
assertHidden("Иванов И.И., р/с 4070 2810 5000 0000 1234", "4070 2810 5000 0000 1234");
|
assertHidden("Иванов И.И., р/с 4070 2810 5000 0000 1234", "4070 2810 5000 0000 1234");
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void masksBikNextToPersonalData() {
|
void masksBikNextToPersonalData() {
|
||||||
assertHidden("Перевод Иванову Ивану Ивановичу, БИК 044525593 банка-получателя", "044525593");
|
assertHidden("Перевод Иванову Ивану Ивановичу, БИК 044525593 банка-получателя", "044525593");
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void keepsBankDetailsWithoutAnyPersonalData() {
|
void keepsBankDetailsWithoutAnyPersonalData() {
|
||||||
for (String text : new String[]{
|
for (String text :
|
||||||
"Расчётный счёт 40702810500000001234 открыт вчера",
|
new String[] {
|
||||||
"БИК 044525593 банка-получателя",
|
"Расчётный счёт 40702810500000001234 открыт вчера",
|
||||||
"ОГРН 1027700132195 организации",
|
"БИК 044525593 банка-получателя",
|
||||||
"КПП 770101001 указан в реквизитах"}) {
|
"ОГРН 1027700132195 организации",
|
||||||
assertEquals(text, pipeline.process(text, UUID.randomUUID().toString(), SystemPolicy.DEFAULT),
|
"КПП 770101001 указан в реквизитах"
|
||||||
"реквизиты без человека персональными данными не являются");
|
}) {
|
||||||
}
|
assertEquals(
|
||||||
|
text,
|
||||||
|
pipeline.process(text, UUID.randomUUID().toString(), SystemPolicy.DEFAULT),
|
||||||
|
"реквизиты без человека персональными данными не являются");
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void masksCardExpiryButNotCardNumber() {
|
void masksCardExpiryButNotCardNumber() {
|
||||||
String masked = pipeline.process(
|
String masked =
|
||||||
"Карта 4111 1111 1111 1111, срок действия 09/27", "expiry-1", SystemPolicy.DEFAULT);
|
pipeline.process(
|
||||||
assertFalse(masked.contains("09/27"), masked);
|
"Карта 4111 1111 1111 1111, срок действия 09/27", "expiry-1", SystemPolicy.DEFAULT);
|
||||||
assertEquals("Карта 41** **** **** **11, срок действия **/**", masked);
|
assertFalse(masked.contains("09/27"), masked);
|
||||||
}
|
assertEquals("Карта 41** **** **** **11, срок действия **/**", masked);
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void masksOgrnAndOgrnipDifferently() {
|
void masksOgrnAndOgrnipDifferently() {
|
||||||
assertHidden("Директор Иванов И.И., ОГРН 1027700132195 организации", "1027700132195");
|
assertHidden("Директор Иванов И.И., ОГРН 1027700132195 организации", "1027700132195");
|
||||||
assertHidden("ИП Иванов Иван Иванович, ОГРНИП 304500116000157", "304500116000157");
|
assertHidden("ИП Иванов Иван Иванович, ОГРНИП 304500116000157", "304500116000157");
|
||||||
}
|
}
|
||||||
|
|
||||||
/** ОГРНИП (15 цифр) не должен наполовину ловиться правилом ОГРН (13 цифр). */
|
/** ОГРНИП (15 цифр) не должен наполовину ловиться правилом ОГРН (13 цифр). */
|
||||||
@Test
|
@Test
|
||||||
void ogrnDoesNotSwallowOgrnip() {
|
void ogrnDoesNotSwallowOgrnip() {
|
||||||
String masked = pipeline.process("ИП Иванов Иван Иванович, ОГРНИП 304500116000157",
|
String masked =
|
||||||
"ogrnip-1", SystemPolicy.DEFAULT);
|
pipeline.process(
|
||||||
assertFalse(masked.contains("304500116000157"), masked);
|
"ИП Иванов Иван Иванович, ОГРНИП 304500116000157", "ogrnip-1", SystemPolicy.DEFAULT);
|
||||||
assertFalse(masked.matches(".*\\d{15}.*"), "осталась незамаскированная часть номера: " + masked);
|
assertFalse(masked.contains("304500116000157"), masked);
|
||||||
}
|
assertFalse(
|
||||||
|
masked.matches(".*\\d{15}.*"), "осталась незамаскированная часть номера: " + masked);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Контрольная сумма отсекает случайное 13-значное число рядом со словом «ОГРН».
|
* Контрольная сумма отсекает случайное 13-значное число рядом со словом «ОГРН». Число подобрано
|
||||||
* Число подобрано так, чтобы не проходить заодно и Луна — иначе оно всё равно
|
* так, чтобы не проходить заодно и Луна — иначе оно всё равно маскировалось бы, но уже как номер
|
||||||
* маскировалось бы, но уже как номер карты, и тест ничего бы не показывал.
|
* карты, и тест ничего бы не показывал.
|
||||||
*/
|
*/
|
||||||
@Test
|
@Test
|
||||||
void doesNotMaskOgrnWithBrokenChecksum() {
|
void doesNotMaskOgrnWithBrokenChecksum() {
|
||||||
String text = "ОГРН 1027700132190 организации";
|
String text = "ОГРН 1027700132190 организации";
|
||||||
assertEquals(text, pipeline.process(text, UUID.randomUUID().toString(), SystemPolicy.DEFAULT),
|
assertEquals(
|
||||||
"число с неверной контрольной суммой не является настоящим ОГРН");
|
text,
|
||||||
}
|
pipeline.process(text, UUID.randomUUID().toString(), SystemPolicy.DEFAULT),
|
||||||
|
"число с неверной контрольной суммой не является настоящим ОГРН");
|
||||||
|
}
|
||||||
|
|
||||||
/** Та же проверка для ОГРНИП — случайное 15-значное число рядом со словом. */
|
/** Та же проверка для ОГРНИП — случайное 15-значное число рядом со словом. */
|
||||||
@Test
|
@Test
|
||||||
void doesNotMaskOgrnipWithBrokenChecksum() {
|
void doesNotMaskOgrnipWithBrokenChecksum() {
|
||||||
String text = "ОГРНИП 304500116000150 предпринимателя";
|
String text = "ОГРНИП 304500116000150 предпринимателя";
|
||||||
assertEquals(text, pipeline.process(text, UUID.randomUUID().toString(), SystemPolicy.DEFAULT),
|
assertEquals(
|
||||||
"число с неверной контрольной суммой не является настоящим ОГРНИП");
|
text,
|
||||||
}
|
pipeline.process(text, UUID.randomUUID().toString(), SystemPolicy.DEFAULT),
|
||||||
|
"число с неверной контрольной суммой не является настоящим ОГРНИП");
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void masksKppNextToPersonalData() {
|
void masksKppNextToPersonalData() {
|
||||||
assertHidden("Заявитель Иванов И.И., КПП 770101001 указан в реквизитах", "770101001");
|
assertHidden("Заявитель Иванов И.И., КПП 770101001 указан в реквизитах", "770101001");
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void masksIncomeNextToPersonalData() {
|
void masksIncomeNextToPersonalData() {
|
||||||
assertHidden("Иванов Иван Иванович, доход 85 000 руб. в месяц", "85 000");
|
assertHidden("Иванов Иван Иванович, доход 85 000 руб. в месяц", "85 000");
|
||||||
assertHidden("Иванову И.И. начислена заработная плата 120000 в месяц", "120000");
|
assertHidden("Иванову И.И. начислена заработная плата 120000 в месяц", "120000");
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Сумма заработка без человека — статистика или описание продукта. Опознать по
|
* Сумма заработка без человека — статистика или описание продукта. Опознать по ней никого нельзя,
|
||||||
* ней никого нельзя, а для прокси к языковой модели вымаранное число означает,
|
* а для прокси к языковой модели вымаранное число означает, что вопрос про среднюю зарплату по
|
||||||
* что вопрос про среднюю зарплату по отрасли отвечать уже не на чем.
|
* отрасли отвечать уже не на чем.
|
||||||
*/
|
*/
|
||||||
@Test
|
@Test
|
||||||
void keepsIncomeWithoutAnyPersonalData() {
|
void keepsIncomeWithoutAnyPersonalData() {
|
||||||
for (String text : new String[]{
|
for (String text :
|
||||||
"По данным Росстата доход домохозяйств вырос до 74 500 руб",
|
new String[] {
|
||||||
"Зарплатный проект: зарплата 80 000 руб перечисляется на счёт"}) {
|
"По данным Росстата доход домохозяйств вырос до 74 500 руб",
|
||||||
assertEquals(text, pipeline.process(text, UUID.randomUUID().toString(), SystemPolicy.DEFAULT),
|
"Зарплатный проект: зарплата 80 000 руб перечисляется на счёт"
|
||||||
"сумма заработка без человека персональными данными не является");
|
}) {
|
||||||
}
|
assertEquals(
|
||||||
|
text,
|
||||||
|
pipeline.process(text, UUID.randomUUID().toString(), SystemPolicy.DEFAULT),
|
||||||
|
"сумма заработка без человека персональными данными не является");
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void masksBiometricMentionNextToPersonalData() {
|
void masksBiometricMentionNextToPersonalData() {
|
||||||
assertHidden("Клиент Иванов Иван Иванович сдал биометрические данные", "биометрические данные");
|
assertHidden("Клиент Иванов Иван Иванович сдал биометрические данные", "биометрические данные");
|
||||||
assertHidden("Для Иванова И.И. оформлен слепок голоса", "слепок голоса");
|
assertHidden("Для Иванова И.И. оформлен слепок голоса", "слепок голоса");
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Биометрии в тексте не бывает: это шаблон в базе, и правило маскирует само
|
* Биометрии в тексте не бывает: это шаблон в базе, и правило маскирует само упоминание — слово, а
|
||||||
* упоминание — слово, а не данные. Без человека рядом такая замена скрывает
|
* не данные. Без человека рядом такая замена скрывает ноль сведений и разрушает смысл фразы.
|
||||||
* ноль сведений и разрушает смысл фразы.
|
*/
|
||||||
*/
|
@Test
|
||||||
@Test
|
void keepsBiometricMentionWithoutAnyPersonalData() {
|
||||||
void keepsBiometricMentionWithoutAnyPersonalData() {
|
for (String text :
|
||||||
for (String text : new String[]{
|
new String[] {
|
||||||
"Банк внедрил биометрические данные в обслуживание клиентов",
|
"Банк внедрил биометрические данные в обслуживание клиентов",
|
||||||
"Сдать биометрию можно через ЕБС в любом отделении"}) {
|
"Сдать биометрию можно через ЕБС в любом отделении"
|
||||||
assertEquals(text, pipeline.process(text, UUID.randomUUID().toString(), SystemPolicy.DEFAULT),
|
}) {
|
||||||
"упоминание биометрии без человека персональными данными не является");
|
assertEquals(
|
||||||
}
|
text,
|
||||||
|
pipeline.process(text, UUID.randomUUID().toString(), SystemPolicy.DEFAULT),
|
||||||
|
"упоминание биометрии без человека персональными данными не является");
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Два несамостоятельных типа рядом не заверяют друг друга: сочетание даты и
|
* Два несамостоятельных типа рядом не заверяют друг друга: сочетание даты и ОГРН самостоятельным
|
||||||
* ОГРН самостоятельным не становится, человека в таком тексте нет.
|
* не становится, человека в таком тексте нет.
|
||||||
*/
|
*/
|
||||||
@Test
|
@Test
|
||||||
void twoCompanionTypesDoNotVouchForEachOther() {
|
void twoCompanionTypesDoNotVouchForEachOther() {
|
||||||
String text = "Оплата 01.02.2025, ОГРН 1027700132195";
|
String text = "Оплата 01.02.2025, ОГРН 1027700132195";
|
||||||
assertEquals(text, pipeline.process(text, UUID.randomUUID().toString(), SystemPolicy.DEFAULT),
|
assertEquals(
|
||||||
"спутники заверили друг друга в отсутствие настоящих ПД");
|
text,
|
||||||
}
|
pipeline.process(text, UUID.randomUUID().toString(), SystemPolicy.DEFAULT),
|
||||||
|
"спутники заверили друг друга в отсутствие настоящих ПД");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,5 @@
|
|||||||
package ru.pdguard;
|
package ru.pdguard;
|
||||||
|
|
||||||
import ru.pdguard.detect.Span;
|
|
||||||
|
|
||||||
import java.io.BufferedReader;
|
import java.io.BufferedReader;
|
||||||
import java.io.IOException;
|
import java.io.IOException;
|
||||||
import java.io.InputStream;
|
import java.io.InputStream;
|
||||||
@@ -12,56 +10,57 @@ import java.util.List;
|
|||||||
import java.util.Objects;
|
import java.util.Objects;
|
||||||
import java.util.regex.Matcher;
|
import java.util.regex.Matcher;
|
||||||
import java.util.regex.Pattern;
|
import java.util.regex.Pattern;
|
||||||
|
import ru.pdguard.detect.Span;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Общий разбор размеченных наборов {@code {{ТИП:значение}}} — используется
|
* Общий разбор размеченных наборов {@code {{ТИП:значение}}} — используется и {@link BenchmarkTest}
|
||||||
* и {@link BenchmarkTest} (замер качества по строкам), и {@link LargeTextTest}
|
* (замер качества по строкам), и {@link LargeTextTest} (те же строки, перемешанные и склеенные в
|
||||||
* (те же строки, перемешанные и склеенные в большой текст).
|
* большой текст).
|
||||||
*/
|
*/
|
||||||
final class BenchmarkFixtures {
|
final class BenchmarkFixtures {
|
||||||
|
|
||||||
private static final Pattern MARKUP = Pattern.compile("\\{\\{([A-Z_]+):([^}]*)}}");
|
private static final Pattern MARKUP = Pattern.compile("\\{\\{([A-Z_]+):([^}]*)}}");
|
||||||
|
|
||||||
/** Размеченный пример: чистый текст и эталонные фрагменты. */
|
/** Размеченный пример: чистый текст и эталонные фрагменты. */
|
||||||
record Sample(String text, List<Span> gold) {
|
record Sample(String text, List<Span> gold) {}
|
||||||
}
|
|
||||||
|
|
||||||
private BenchmarkFixtures() {
|
private BenchmarkFixtures() {}
|
||||||
}
|
|
||||||
|
|
||||||
/** Читает набор построчно, пропуская пустые строки и комментарии {@code #}. */
|
/** Читает набор построчно, пропуская пустые строки и комментарии {@code #}. */
|
||||||
static List<Sample> load(String resource) {
|
static List<Sample> load(String resource) {
|
||||||
List<Sample> samples = new ArrayList<>();
|
List<Sample> samples = new ArrayList<>();
|
||||||
try (InputStream in = BenchmarkFixtures.class.getResourceAsStream(resource);
|
try (InputStream in = BenchmarkFixtures.class.getResourceAsStream(resource);
|
||||||
BufferedReader reader = new BufferedReader(
|
BufferedReader reader =
|
||||||
new InputStreamReader(Objects.requireNonNull(in, resource), StandardCharsets.UTF_8))) {
|
new BufferedReader(
|
||||||
String line;
|
new InputStreamReader(
|
||||||
while ((line = reader.readLine()) != null) {
|
Objects.requireNonNull(in, resource), StandardCharsets.UTF_8))) {
|
||||||
String trimmed = line.trim();
|
String line;
|
||||||
if (!trimmed.isEmpty() && !trimmed.startsWith("#")) {
|
while ((line = reader.readLine()) != null) {
|
||||||
samples.add(parse(trimmed));
|
String trimmed = line.trim();
|
||||||
}
|
if (!trimmed.isEmpty() && !trimmed.startsWith("#")) {
|
||||||
}
|
samples.add(parse(trimmed));
|
||||||
} catch (IOException e) {
|
|
||||||
throw new IllegalStateException("Не удалось прочитать " + resource, e);
|
|
||||||
}
|
}
|
||||||
return samples;
|
}
|
||||||
|
} catch (IOException e) {
|
||||||
|
throw new IllegalStateException("Не удалось прочитать " + resource, e);
|
||||||
}
|
}
|
||||||
|
return samples;
|
||||||
|
}
|
||||||
|
|
||||||
/** Разбирает разметку {@code {{ТИП:значение}}} в чистый текст и эталонные фрагменты. */
|
/** Разбирает разметку {@code {{ТИП:значение}}} в чистый текст и эталонные фрагменты. */
|
||||||
static Sample parse(String line) {
|
static Sample parse(String line) {
|
||||||
StringBuilder text = new StringBuilder(line.length());
|
StringBuilder text = new StringBuilder(line.length());
|
||||||
List<Span> gold = new ArrayList<>();
|
List<Span> gold = new ArrayList<>();
|
||||||
Matcher m = MARKUP.matcher(line);
|
Matcher m = MARKUP.matcher(line);
|
||||||
int cursor = 0;
|
int cursor = 0;
|
||||||
while (m.find()) {
|
while (m.find()) {
|
||||||
text.append(line, cursor, m.start());
|
text.append(line, cursor, m.start());
|
||||||
int start = text.length();
|
int start = text.length();
|
||||||
text.append(m.group(2));
|
text.append(m.group(2));
|
||||||
gold.add(new Span(start, text.length(), m.group(1), 0));
|
gold.add(new Span(start, text.length(), m.group(1), 0));
|
||||||
cursor = m.end();
|
cursor = m.end();
|
||||||
}
|
|
||||||
text.append(line, cursor, line.length());
|
|
||||||
return new Sample(text.toString(), gold);
|
|
||||||
}
|
}
|
||||||
|
text.append(line, cursor, line.length());
|
||||||
|
return new Sample(text.toString(), gold);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,14 +1,7 @@
|
|||||||
package ru.pdguard;
|
package ru.pdguard;
|
||||||
|
|
||||||
import org.junit.jupiter.api.Test;
|
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||||
import ru.pdguard.config.SystemPolicy;
|
import static org.junit.jupiter.api.Assumptions.assumeTrue;
|
||||||
import ru.pdguard.core.PayloadStore;
|
|
||||||
import ru.pdguard.core.Pipeline;
|
|
||||||
import ru.pdguard.detect.Span;
|
|
||||||
import ru.pdguard.detect.NameCascade;
|
|
||||||
import ru.pdguard.detect.PdTypes;
|
|
||||||
import ru.pdguard.detect.RuleRegistry;
|
|
||||||
import ru.pdguard.mask.Masker;
|
|
||||||
|
|
||||||
import java.nio.file.Files;
|
import java.nio.file.Files;
|
||||||
import java.nio.file.Path;
|
import java.nio.file.Path;
|
||||||
@@ -18,348 +11,409 @@ import java.util.LinkedHashMap;
|
|||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
import ru.pdguard.config.SystemPolicy;
|
||||||
import static org.junit.jupiter.api.Assumptions.assumeTrue;
|
import ru.pdguard.core.PayloadStore;
|
||||||
|
import ru.pdguard.core.Pipeline;
|
||||||
|
import ru.pdguard.detect.NameCascade;
|
||||||
|
import ru.pdguard.detect.PdTypes;
|
||||||
|
import ru.pdguard.detect.RuleRegistry;
|
||||||
|
import ru.pdguard.detect.Span;
|
||||||
|
import ru.pdguard.mask.Masker;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Замер качества детекции на размеченных наборах.
|
* Замер качества детекции на размеченных наборах.
|
||||||
*
|
*
|
||||||
* <p>Наборов два. {@code benchmark.txt} использовался при отладке правил, поэтому
|
* <p>Наборов два. {@code benchmark.txt} использовался при отладке правил, поэтому его оценка
|
||||||
* его оценка завышена и годится только как защита от ухудшений.
|
* завышена и годится только как защита от ухудшений. {@code benchmark-holdout.txt} составлен
|
||||||
* {@code benchmark-holdout.txt} составлен независимо и на нём правила не
|
* независимо и на нём правила не настраивались — именно он показывает настоящее качество.
|
||||||
* настраивались — именно он показывает настоящее качество.
|
|
||||||
*
|
*
|
||||||
* <p>Метрики посимвольные: так они не зависят от того, где именно правило
|
* <p>Метрики посимвольные: так они не зависят от того, где именно правило поставило границу
|
||||||
* поставило границу совпадения, и напрямую соотносятся с посимвольным
|
* совпадения, и напрямую соотносятся с посимвольным сравнением замаскированного текста с эталоном.
|
||||||
* сравнением замаскированного текста с эталоном.
|
|
||||||
*
|
*
|
||||||
* <p>Отдельно считается строка «любой тип»: для защиты важно, что знаки скрыты,
|
* <p>Отдельно считается строка «любой тип»: для защиты важно, что знаки скрыты, а расхождение в
|
||||||
* а расхождение в названии типа (скажем, место рождения против города) на
|
* названии типа (скажем, место рождения против города) на качество маскирования не влияет.
|
||||||
* качество маскирования не влияет.
|
|
||||||
*/
|
*/
|
||||||
class BenchmarkTest {
|
class BenchmarkTest {
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Вторая ступень для замера. Модели нет — прогон идёт на одних правилах, и это
|
* Вторая ступень для замера. Модели нет — прогон идёт на одних правилах, и это видно по заголовку
|
||||||
* видно по заголовку отчёта. Путь подменяется свойством {@code -Dbench.model=...}.
|
* отчёта. Путь подменяется свойством {@code -Dbench.model=...}.
|
||||||
*/
|
*/
|
||||||
private static final String ENGINE = System.getProperty("bench.engine", "rubert");
|
private static final String ENGINE = System.getProperty("bench.engine", "rubert");
|
||||||
private static final String MODEL_PATH = System.getProperty("bench.model", "models/rubert-ner");
|
|
||||||
|
|
||||||
/** Итог замера по одному набору. */
|
private static final String MODEL_PATH = System.getProperty("bench.model", "models/rubert-ner");
|
||||||
private record Result(double fioF1, double overallPrecision, double overallRecall,
|
|
||||||
double falsePositiveRate, int foundFioSpans, int goldFioSpans) {
|
/** Итог замера по одному набору. */
|
||||||
|
private record Result(
|
||||||
|
double fioF1,
|
||||||
|
double overallPrecision,
|
||||||
|
double overallRecall,
|
||||||
|
double falsePositiveRate,
|
||||||
|
int foundFioSpans,
|
||||||
|
int goldFioSpans) {}
|
||||||
|
|
||||||
|
/** Накопитель посимвольных совпадений по одному типу. */
|
||||||
|
private static final class Score {
|
||||||
|
private int truePositive;
|
||||||
|
private int falsePositive;
|
||||||
|
private int falseNegative;
|
||||||
|
|
||||||
|
private int gold() {
|
||||||
|
return truePositive + falseNegative;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Накопитель посимвольных совпадений по одному типу. */
|
private double precision() {
|
||||||
private static final class Score {
|
int found = truePositive + falsePositive;
|
||||||
private int truePositive;
|
return found == 0 ? 1.0 : (double) truePositive / found;
|
||||||
private int falsePositive;
|
}
|
||||||
private int falseNegative;
|
|
||||||
|
|
||||||
private int gold() {
|
private double recall() {
|
||||||
return truePositive + falseNegative;
|
return gold() == 0 ? 1.0 : (double) truePositive / gold();
|
||||||
|
}
|
||||||
|
|
||||||
|
private double f1() {
|
||||||
|
double p = precision();
|
||||||
|
double r = recall();
|
||||||
|
return p + r == 0 ? 0.0 : 2 * p * r / (p + r);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private final Pipeline pipeline =
|
||||||
|
new Pipeline(new RuleRegistry(), new Masker(), new PayloadStore(30));
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Набор, на котором правила отлаживались. Пороги здесь высокие: любое падение означает, что
|
||||||
|
* сломалось то, что раньше работало.
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
void detectionQualityOnTuningSet() {
|
||||||
|
Result result = measure("/benchmark.txt", "набор отладки");
|
||||||
|
|
||||||
|
assertTrue(result.fioF1() >= 0.95, String.format("F1 по ФИО упал до %.3f", result.fioF1()));
|
||||||
|
assertTrue(
|
||||||
|
result.overallRecall() >= 0.95,
|
||||||
|
String.format("полнота по всем типам упала до %.3f", result.overallRecall()));
|
||||||
|
assertTrue(
|
||||||
|
result.falsePositiveRate() <= 0.05,
|
||||||
|
String.format("ложные срабатывания на чистых текстах: %.3f", result.falsePositiveRate()));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Отложенный набор: правила на нём не настраивались. Пороги ниже — они отражают измеренное на нём
|
||||||
|
* качество, а не желаемое.
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
void detectionQualityOnHoldoutSet() {
|
||||||
|
Result result = measure("/benchmark-holdout.txt", "отложенный набор");
|
||||||
|
|
||||||
|
assertTrue(
|
||||||
|
result.fioF1() >= 0.75,
|
||||||
|
String.format("F1 по ФИО на отложенном наборе упал до %.3f", result.fioF1()));
|
||||||
|
assertTrue(
|
||||||
|
result.overallRecall() >= 0.75,
|
||||||
|
String.format("полнота на отложенном наборе упала до %.3f", result.overallRecall()));
|
||||||
|
assertTrue(
|
||||||
|
result.falsePositiveRate() <= 0.15,
|
||||||
|
String.format(
|
||||||
|
"ложные срабатывания на отложенном наборе: %.3f", result.falsePositiveRate()));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Второй контрольный набор, составленный после того, как первый дважды повлиял на правила. На нём
|
||||||
|
* не настраивалось ничего — он и показывает настоящее качество. Пороги низкие намеренно: тест
|
||||||
|
* ловит обвал, а не сторожит значение.
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
void detectionQualityOnThirdHoldoutSet() {
|
||||||
|
Pipeline stage =
|
||||||
|
Files.isReadable(Path.of(MODEL_PATH))
|
||||||
|
? new Pipeline(
|
||||||
|
new RuleRegistry(),
|
||||||
|
new Masker(),
|
||||||
|
new PayloadStore(30),
|
||||||
|
new NameCascade(ENGINE, Optional.of(MODEL_PATH), 16, 4))
|
||||||
|
: pipeline;
|
||||||
|
Result result = measure(stage, "/benchmark-holdout3.txt", "второй контрольный набор");
|
||||||
|
|
||||||
|
assertTrue(
|
||||||
|
result.fioF1() >= 0.70,
|
||||||
|
String.format("F1 по ФИО на втором контрольном наборе упал до %.3f", result.fioF1()));
|
||||||
|
assertTrue(
|
||||||
|
result.overallRecall() >= 0.70,
|
||||||
|
String.format(
|
||||||
|
"полнота на втором контрольном наборе упала до %.3f", result.overallRecall()));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Контрольный набор. Правила по нему не настраиваются: он существует, чтобы показывать качество
|
||||||
|
* на данных, которых разработка не видела. Пороги здесь низкие намеренно — тест ловит обвал, а не
|
||||||
|
* сторожит достигнутое значение. Замер идёт со второй ступенью, если модель собрана, иначе на
|
||||||
|
* одних правилах.
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
void detectionQualityOnSecondHoldoutSet() {
|
||||||
|
Pipeline stage =
|
||||||
|
Files.isReadable(Path.of(MODEL_PATH))
|
||||||
|
? new Pipeline(
|
||||||
|
new RuleRegistry(),
|
||||||
|
new Masker(),
|
||||||
|
new PayloadStore(30),
|
||||||
|
new NameCascade(ENGINE, Optional.of(MODEL_PATH), 16, 4))
|
||||||
|
: pipeline;
|
||||||
|
Result result = measure(stage, "/benchmark-holdout2.txt", "второй отложенный набор");
|
||||||
|
|
||||||
|
assertTrue(
|
||||||
|
result.fioF1() >= 0.70,
|
||||||
|
String.format("F1 по ФИО на втором отложенном наборе упал до %.3f", result.fioF1()));
|
||||||
|
assertTrue(
|
||||||
|
result.overallRecall() >= 0.70,
|
||||||
|
String.format("полнота на втором отложенном наборе упала до %.3f", result.overallRecall()));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Независимый сгенерированный набор — покрывает все типы ПД из ТЗ и вариации написания, не
|
||||||
|
* встречавшиеся ни в одном из остальных наборов. Правила под него не настраивались; пороги низкие
|
||||||
|
* по той же причине, что и у второго отложенного набора — тест ловит обвал, а не сторожит
|
||||||
|
* достигнутое значение.
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
void detectionQualityOnGeneratedSet() {
|
||||||
|
Pipeline stage =
|
||||||
|
Files.isReadable(Path.of(MODEL_PATH))
|
||||||
|
? new Pipeline(
|
||||||
|
new RuleRegistry(),
|
||||||
|
new Masker(),
|
||||||
|
new PayloadStore(30),
|
||||||
|
new NameCascade(ENGINE, Optional.of(MODEL_PATH), 16, 4))
|
||||||
|
: pipeline;
|
||||||
|
Result result = measure(stage, "/benchmark-generated.txt", "сгенерированный набор");
|
||||||
|
|
||||||
|
assertTrue(
|
||||||
|
result.fioF1() >= 0.70,
|
||||||
|
String.format("F1 по ФИО на сгенерированном наборе упал до %.3f", result.fioF1()));
|
||||||
|
assertTrue(
|
||||||
|
result.overallRecall() >= 0.70,
|
||||||
|
String.format("полнота на сгенерированном наборе упала до %.3f", result.overallRecall()));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Тот же отложенный набор, но со включённой второй ступенью. Модели нет — проверка пропускается:
|
||||||
|
* в сборке без модели сервис работает на одних правилах.
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
void detectionQualityWithNameCascade() {
|
||||||
|
Path model = Path.of(MODEL_PATH);
|
||||||
|
assumeTrue(Files.isReadable(model), "модель " + model.toAbsolutePath() + " не собрана");
|
||||||
|
|
||||||
|
Pipeline withCascade =
|
||||||
|
new Pipeline(
|
||||||
|
new RuleRegistry(),
|
||||||
|
new Masker(),
|
||||||
|
new PayloadStore(30),
|
||||||
|
new NameCascade(ENGINE, Optional.of(MODEL_PATH), 16, 4));
|
||||||
|
Result result =
|
||||||
|
measure(withCascade, "/benchmark-holdout.txt", "отложенный набор, вторая ступень включена");
|
||||||
|
|
||||||
|
assertTrue(
|
||||||
|
result.fioF1() >= 0.75,
|
||||||
|
String.format("F1 по ФИО со второй ступенью упал до %.3f", result.fioF1()));
|
||||||
|
}
|
||||||
|
|
||||||
|
private Result measure(String resource, String title) {
|
||||||
|
return measure(pipeline, resource, title);
|
||||||
|
}
|
||||||
|
|
||||||
|
private Result measure(Pipeline stage, String resource, String title) {
|
||||||
|
List<BenchmarkFixtures.Sample> samples = BenchmarkFixtures.load(resource);
|
||||||
|
Map<String, Score> byType = new LinkedHashMap<>();
|
||||||
|
Score anyType = new Score();
|
||||||
|
|
||||||
|
int cleanTexts = 0;
|
||||||
|
int cleanTextsWithFalseHit = 0;
|
||||||
|
int goldFioSpans = 0;
|
||||||
|
int foundFioSpans = 0;
|
||||||
|
List<String> falseHits = new ArrayList<>();
|
||||||
|
List<String> missedFio = new ArrayList<>();
|
||||||
|
List<String> overMasked = new ArrayList<>();
|
||||||
|
|
||||||
|
for (BenchmarkFixtures.Sample sample : samples) {
|
||||||
|
List<Span> found = stage.findPersonalData(sample.text(), SystemPolicy.DEFAULT);
|
||||||
|
|
||||||
|
String[] goldChars = paint(sample.text().length(), sample.gold());
|
||||||
|
String[] foundChars = paint(sample.text().length(), found);
|
||||||
|
|
||||||
|
for (int i = 0; i < sample.text().length(); i++) {
|
||||||
|
account(byType, goldChars[i], foundChars[i]);
|
||||||
|
accountAnyType(anyType, goldChars[i] != null, foundChars[i] != null);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sample.gold().isEmpty()) {
|
||||||
|
cleanTexts++;
|
||||||
|
if (!found.isEmpty()) {
|
||||||
|
cleanTextsWithFalseHit++;
|
||||||
|
falseHits.add(fragment(sample.text(), found.get(0)) + " ← " + sample.text());
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
collectOverMasked(sample.text(), goldChars, foundChars, overMasked);
|
||||||
|
}
|
||||||
|
|
||||||
private double precision() {
|
for (Span gold : sample.gold()) {
|
||||||
int found = truePositive + falsePositive;
|
if (!PdTypes.FIO.equals(gold.type())) {
|
||||||
return found == 0 ? 1.0 : (double) truePositive / found;
|
continue;
|
||||||
}
|
}
|
||||||
|
goldFioSpans++;
|
||||||
private double recall() {
|
if (overlappedByFio(gold, found)) {
|
||||||
return gold() == 0 ? 1.0 : (double) truePositive / gold();
|
foundFioSpans++;
|
||||||
}
|
} else {
|
||||||
|
missedFio.add(fragment(sample.text(), gold) + " ← " + sample.text());
|
||||||
private double f1() {
|
|
||||||
double p = precision();
|
|
||||||
double r = recall();
|
|
||||||
return p + r == 0 ? 0.0 : 2 * p * r / (p + r);
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private final Pipeline pipeline =
|
report(
|
||||||
new Pipeline(new RuleRegistry(), new Masker(), new PayloadStore(10_000_000L, 30));
|
title,
|
||||||
|
samples.size(),
|
||||||
|
byType,
|
||||||
|
anyType,
|
||||||
|
goldFioSpans,
|
||||||
|
foundFioSpans,
|
||||||
|
cleanTexts,
|
||||||
|
cleanTextsWithFalseHit,
|
||||||
|
missedFio,
|
||||||
|
falseHits,
|
||||||
|
overMasked);
|
||||||
|
|
||||||
/**
|
Score fio = byType.getOrDefault(PdTypes.FIO, new Score());
|
||||||
* Набор, на котором правила отлаживались. Пороги здесь высокие: любое падение
|
double falsePositiveRate = cleanTexts == 0 ? 0.0 : (double) cleanTextsWithFalseHit / cleanTexts;
|
||||||
* означает, что сломалось то, что раньше работало.
|
return new Result(
|
||||||
*/
|
fio.f1(),
|
||||||
@Test
|
anyType.precision(),
|
||||||
void detectionQualityOnTuningSet() {
|
anyType.recall(),
|
||||||
Result result = measure("/benchmark.txt", "набор отладки");
|
falsePositiveRate,
|
||||||
|
foundFioSpans,
|
||||||
|
goldFioSpans);
|
||||||
|
}
|
||||||
|
|
||||||
assertTrue(result.fioF1() >= 0.95,
|
/** Раскрашивает каждый знак текста типом ПД, который его покрывает. */
|
||||||
String.format("F1 по ФИО упал до %.3f", result.fioF1()));
|
private static String[] paint(int length, List<Span> spans) {
|
||||||
assertTrue(result.overallRecall() >= 0.95,
|
String[] painted = new String[length];
|
||||||
String.format("полнота по всем типам упала до %.3f", result.overallRecall()));
|
for (Span span : spans) {
|
||||||
assertTrue(result.falsePositiveRate() <= 0.05,
|
for (int i = span.start(); i < Math.min(span.end(), length); i++) {
|
||||||
String.format("ложные срабатывания на чистых текстах: %.3f", result.falsePositiveRate()));
|
painted[i] = span.type();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
return painted;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
private static void account(Map<String, Score> byType, String gold, String found) {
|
||||||
* Отложенный набор: правила на нём не настраивались. Пороги ниже — они
|
if (gold != null) {
|
||||||
* отражают измеренное на нём качество, а не желаемое.
|
Score score = byType.computeIfAbsent(gold, t -> new Score());
|
||||||
*/
|
if (gold.equals(found)) {
|
||||||
@Test
|
score.truePositive++;
|
||||||
void detectionQualityOnHoldoutSet() {
|
} else {
|
||||||
Result result = measure("/benchmark-holdout.txt", "отложенный набор");
|
score.falseNegative++;
|
||||||
|
}
|
||||||
assertTrue(result.fioF1() >= 0.75,
|
|
||||||
String.format("F1 по ФИО на отложенном наборе упал до %.3f", result.fioF1()));
|
|
||||||
assertTrue(result.overallRecall() >= 0.75,
|
|
||||||
String.format("полнота на отложенном наборе упала до %.3f", result.overallRecall()));
|
|
||||||
assertTrue(result.falsePositiveRate() <= 0.15,
|
|
||||||
String.format("ложные срабатывания на отложенном наборе: %.3f", result.falsePositiveRate()));
|
|
||||||
}
|
}
|
||||||
|
if (found != null && !found.equals(gold)) {
|
||||||
/**
|
byType.computeIfAbsent(found, t -> new Score()).falsePositive++;
|
||||||
* Второй контрольный набор, составленный после того, как первый дважды повлиял
|
|
||||||
* на правила. На нём не настраивалось ничего — он и показывает настоящее
|
|
||||||
* качество. Пороги низкие намеренно: тест ловит обвал, а не сторожит значение.
|
|
||||||
*/
|
|
||||||
@Test
|
|
||||||
void detectionQualityOnThirdHoldoutSet() {
|
|
||||||
Pipeline stage = Files.isReadable(Path.of(MODEL_PATH))
|
|
||||||
? new Pipeline(new RuleRegistry(), new Masker(), new PayloadStore(10_000_000L, 30),
|
|
||||||
new NameCascade(ENGINE, Optional.of(MODEL_PATH), 16, 4))
|
|
||||||
: pipeline;
|
|
||||||
Result result = measure(stage, "/benchmark-holdout3.txt", "второй контрольный набор");
|
|
||||||
|
|
||||||
assertTrue(result.fioF1() >= 0.70,
|
|
||||||
String.format("F1 по ФИО на втором контрольном наборе упал до %.3f", result.fioF1()));
|
|
||||||
assertTrue(result.overallRecall() >= 0.70,
|
|
||||||
String.format("полнота на втором контрольном наборе упала до %.3f", result.overallRecall()));
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
private static void accountAnyType(Score score, boolean gold, boolean found) {
|
||||||
* Контрольный набор. Правила по нему не настраиваются: он существует, чтобы
|
if (gold && found) {
|
||||||
* показывать качество на данных, которых разработка не видела. Пороги здесь
|
score.truePositive++;
|
||||||
* низкие намеренно — тест ловит обвал, а не сторожит достигнутое значение.
|
} else if (gold) {
|
||||||
* Замер идёт со второй ступенью, если модель собрана, иначе на одних правилах.
|
score.falseNegative++;
|
||||||
*/
|
} else if (found) {
|
||||||
@Test
|
score.falsePositive++;
|
||||||
void detectionQualityOnSecondHoldoutSet() {
|
|
||||||
Pipeline stage = Files.isReadable(Path.of(MODEL_PATH))
|
|
||||||
? new Pipeline(new RuleRegistry(), new Masker(), new PayloadStore(10_000_000L, 30),
|
|
||||||
new NameCascade(ENGINE, Optional.of(MODEL_PATH), 16, 4))
|
|
||||||
: pipeline;
|
|
||||||
Result result = measure(stage, "/benchmark-holdout2.txt", "второй отложенный набор");
|
|
||||||
|
|
||||||
assertTrue(result.fioF1() >= 0.70,
|
|
||||||
String.format("F1 по ФИО на втором отложенном наборе упал до %.3f", result.fioF1()));
|
|
||||||
assertTrue(result.overallRecall() >= 0.70,
|
|
||||||
String.format("полнота на втором отложенном наборе упала до %.3f", result.overallRecall()));
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
private static boolean overlappedByFio(Span gold, List<Span> found) {
|
||||||
* Реальные адреса отделений Альфа-Банка (ловушка из ТЗ — не ПД клиента),
|
return found.stream().anyMatch(span -> PdTypes.FIO.equals(span.type()) && span.overlaps(gold));
|
||||||
* расширенный денилист, обобщённое companion-правило (место рождения,
|
}
|
||||||
* страна) и новые банковские типы. Собран специально под соответствующие
|
|
||||||
* доработки — пороги ниже, чем у набора отладки, но проверяют именно то,
|
|
||||||
* что было доработано, а не общее качество остального пайплайна.
|
|
||||||
*/
|
|
||||||
@Test
|
|
||||||
void detectionQualityOnBankContextSet() {
|
|
||||||
Result result = measure("/benchmark-bank-context.txt", "банковский контекст");
|
|
||||||
|
|
||||||
assertTrue(result.fioF1() >= 0.70,
|
private static String fragment(String text, Span span) {
|
||||||
String.format("F1 по ФИО на банковском наборе упал до %.3f", result.fioF1()));
|
return "«" + text.substring(span.start(), Math.min(span.end(), text.length())) + "»";
|
||||||
assertTrue(result.overallRecall() >= 0.70,
|
}
|
||||||
String.format("полнота на банковском наборе упала до %.3f", result.overallRecall()));
|
|
||||||
assertTrue(result.falsePositiveRate() <= 0.10,
|
/** Знаки, замаскированные сверх эталона: полезно видеть, где правило берёт лишнее. */
|
||||||
String.format("ложные срабатывания на банковском наборе: %.3f", result.falsePositiveRate()));
|
private static void collectOverMasked(
|
||||||
|
String text, String[] gold, String[] found, List<String> sink) {
|
||||||
|
int from = -1;
|
||||||
|
for (int i = 0; i <= text.length(); i++) {
|
||||||
|
boolean extra = i < text.length() && found[i] != null && gold[i] == null;
|
||||||
|
if (extra && from < 0) {
|
||||||
|
from = i;
|
||||||
|
} else if (!extra && from >= 0) {
|
||||||
|
sink.add("«" + text.substring(from, i) + "» как " + found[from] + " ← " + text);
|
||||||
|
from = -1;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
private void report(
|
||||||
* Независимый сгенерированный набор — покрывает все типы ПД из ТЗ и вариации
|
String title,
|
||||||
* написания, не встречавшиеся ни в одном из остальных наборов. Правила под
|
int samples,
|
||||||
* него не настраивались; пороги низкие по той же причине, что и у второго
|
Map<String, Score> byType,
|
||||||
* отложенного набора — тест ловит обвал, а не сторожит достигнутое значение.
|
Score anyType,
|
||||||
*/
|
int goldFio,
|
||||||
@Test
|
int foundFio,
|
||||||
void detectionQualityOnGeneratedSet() {
|
int cleanTexts,
|
||||||
Pipeline stage = Files.isReadable(Path.of(MODEL_PATH))
|
int falseHitTexts,
|
||||||
? new Pipeline(new RuleRegistry(), new Masker(), new PayloadStore(10_000_000L, 30),
|
List<String> missedFio,
|
||||||
new NameCascade(ENGINE, Optional.of(MODEL_PATH), 16, 4))
|
List<String> falseHits,
|
||||||
: pipeline;
|
List<String> overMasked) {
|
||||||
Result result = measure(stage, "/benchmark-generated.txt", "сгенерированный набор");
|
StringBuilder out = new StringBuilder(4096);
|
||||||
|
out.append("\n=== ")
|
||||||
|
.append(title)
|
||||||
|
.append(": ")
|
||||||
|
.append(samples)
|
||||||
|
.append(" размеченных строк ===\n\n");
|
||||||
|
out.append(
|
||||||
|
String.format("%-20s %8s %8s %8s %8s%n", "тип", "знаков", "точность", "полнота", "F1"));
|
||||||
|
|
||||||
assertTrue(result.fioF1() >= 0.70,
|
byType.entrySet().stream()
|
||||||
String.format("F1 по ФИО на сгенерированном наборе упал до %.3f", result.fioF1()));
|
.sorted(
|
||||||
assertTrue(result.overallRecall() >= 0.70,
|
Comparator.comparingInt((Map.Entry<String, Score> e) -> e.getValue().gold()).reversed())
|
||||||
String.format("полнота на сгенерированном наборе упала до %.3f", result.overallRecall()));
|
.forEach(
|
||||||
|
e ->
|
||||||
|
out.append(
|
||||||
|
String.format(
|
||||||
|
"%-20s %8d %8.3f %8.3f %8.3f%n",
|
||||||
|
e.getKey(),
|
||||||
|
e.getValue().gold(),
|
||||||
|
e.getValue().precision(),
|
||||||
|
e.getValue().recall(),
|
||||||
|
e.getValue().f1())));
|
||||||
|
|
||||||
|
out.append(
|
||||||
|
String.format(
|
||||||
|
"%-20s %8d %8.3f %8.3f %8.3f%n",
|
||||||
|
"ЛЮБОЙ ТИП", anyType.gold(), anyType.precision(), anyType.recall(), anyType.f1()));
|
||||||
|
|
||||||
|
out.append(
|
||||||
|
String.format(
|
||||||
|
"%nФИО пофрагментно: найдено %d из %d (%.1f %%)%n",
|
||||||
|
foundFio, goldFio, goldFio == 0 ? 100.0 : 100.0 * foundFio / goldFio));
|
||||||
|
out.append(
|
||||||
|
String.format(
|
||||||
|
"Тексты без ПД: ложные срабатывания на %d из %d (%.1f %%)%n",
|
||||||
|
falseHitTexts, cleanTexts, cleanTexts == 0 ? 0.0 : 100.0 * falseHitTexts / cleanTexts));
|
||||||
|
|
||||||
|
appendList(out, "\nНе найденные ФИО:", missedFio);
|
||||||
|
appendList(out, "\nЛожные срабатывания:", falseHits);
|
||||||
|
appendList(out, "\nЗамаскировано сверх эталона:", overMasked);
|
||||||
|
|
||||||
|
System.out.println(out);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void appendList(StringBuilder out, String title, List<String> lines) {
|
||||||
|
if (lines.isEmpty()) {
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
out.append(title).append('\n');
|
||||||
/**
|
lines.forEach(line -> out.append(" ").append(line).append('\n'));
|
||||||
* Тот же отложенный набор, но со включённой второй ступенью. Модели нет —
|
}
|
||||||
* проверка пропускается: в сборке без модели сервис работает на одних правилах.
|
|
||||||
*/
|
|
||||||
@Test
|
|
||||||
void detectionQualityWithNameCascade() {
|
|
||||||
Path model = Path.of(MODEL_PATH);
|
|
||||||
assumeTrue(Files.isReadable(model), "модель " + model.toAbsolutePath() + " не собрана");
|
|
||||||
|
|
||||||
Pipeline withCascade = new Pipeline(new RuleRegistry(), new Masker(),
|
|
||||||
new PayloadStore(10_000_000L, 30), new NameCascade(ENGINE, Optional.of(MODEL_PATH), 16, 4));
|
|
||||||
Result result = measure(withCascade, "/benchmark-holdout.txt", "отложенный набор, вторая ступень включена");
|
|
||||||
|
|
||||||
assertTrue(result.fioF1() >= 0.75,
|
|
||||||
String.format("F1 по ФИО со второй ступенью упал до %.3f", result.fioF1()));
|
|
||||||
}
|
|
||||||
|
|
||||||
private Result measure(String resource, String title) {
|
|
||||||
return measure(pipeline, resource, title);
|
|
||||||
}
|
|
||||||
|
|
||||||
private Result measure(Pipeline stage, String resource, String title) {
|
|
||||||
List<BenchmarkFixtures.Sample> samples = BenchmarkFixtures.load(resource);
|
|
||||||
Map<String, Score> byType = new LinkedHashMap<>();
|
|
||||||
Score anyType = new Score();
|
|
||||||
|
|
||||||
int cleanTexts = 0;
|
|
||||||
int cleanTextsWithFalseHit = 0;
|
|
||||||
int goldFioSpans = 0;
|
|
||||||
int foundFioSpans = 0;
|
|
||||||
List<String> falseHits = new ArrayList<>();
|
|
||||||
List<String> missedFio = new ArrayList<>();
|
|
||||||
List<String> overMasked = new ArrayList<>();
|
|
||||||
|
|
||||||
for (BenchmarkFixtures.Sample sample : samples) {
|
|
||||||
List<Span> found = stage.findPersonalData(sample.text(), SystemPolicy.DEFAULT);
|
|
||||||
|
|
||||||
String[] goldChars = paint(sample.text().length(), sample.gold());
|
|
||||||
String[] foundChars = paint(sample.text().length(), found);
|
|
||||||
|
|
||||||
for (int i = 0; i < sample.text().length(); i++) {
|
|
||||||
account(byType, goldChars[i], foundChars[i]);
|
|
||||||
accountAnyType(anyType, goldChars[i] != null, foundChars[i] != null);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (sample.gold().isEmpty()) {
|
|
||||||
cleanTexts++;
|
|
||||||
if (!found.isEmpty()) {
|
|
||||||
cleanTextsWithFalseHit++;
|
|
||||||
falseHits.add(fragment(sample.text(), found.get(0)) + " ← " + sample.text());
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
collectOverMasked(sample.text(), goldChars, foundChars, overMasked);
|
|
||||||
}
|
|
||||||
|
|
||||||
for (Span gold : sample.gold()) {
|
|
||||||
if (!PdTypes.FIO.equals(gold.type())) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
goldFioSpans++;
|
|
||||||
if (overlappedByFio(gold, found)) {
|
|
||||||
foundFioSpans++;
|
|
||||||
} else {
|
|
||||||
missedFio.add(fragment(sample.text(), gold) + " ← " + sample.text());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
report(title, samples.size(), byType, anyType, goldFioSpans, foundFioSpans,
|
|
||||||
cleanTexts, cleanTextsWithFalseHit, missedFio, falseHits, overMasked);
|
|
||||||
|
|
||||||
Score fio = byType.getOrDefault(PdTypes.FIO, new Score());
|
|
||||||
double falsePositiveRate = cleanTexts == 0 ? 0.0 : (double) cleanTextsWithFalseHit / cleanTexts;
|
|
||||||
return new Result(fio.f1(), anyType.precision(), anyType.recall(),
|
|
||||||
falsePositiveRate, foundFioSpans, goldFioSpans);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Раскрашивает каждый знак текста типом ПД, который его покрывает. */
|
|
||||||
private static String[] paint(int length, List<Span> spans) {
|
|
||||||
String[] painted = new String[length];
|
|
||||||
for (Span span : spans) {
|
|
||||||
for (int i = span.start(); i < Math.min(span.end(), length); i++) {
|
|
||||||
painted[i] = span.type();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return painted;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static void account(Map<String, Score> byType, String gold, String found) {
|
|
||||||
if (gold != null) {
|
|
||||||
Score score = byType.computeIfAbsent(gold, t -> new Score());
|
|
||||||
if (gold.equals(found)) {
|
|
||||||
score.truePositive++;
|
|
||||||
} else {
|
|
||||||
score.falseNegative++;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (found != null && !found.equals(gold)) {
|
|
||||||
byType.computeIfAbsent(found, t -> new Score()).falsePositive++;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private static void accountAnyType(Score score, boolean gold, boolean found) {
|
|
||||||
if (gold && found) {
|
|
||||||
score.truePositive++;
|
|
||||||
} else if (gold) {
|
|
||||||
score.falseNegative++;
|
|
||||||
} else if (found) {
|
|
||||||
score.falsePositive++;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private static boolean overlappedByFio(Span gold, List<Span> found) {
|
|
||||||
return found.stream()
|
|
||||||
.anyMatch(span -> PdTypes.FIO.equals(span.type()) && span.overlaps(gold));
|
|
||||||
}
|
|
||||||
|
|
||||||
private static String fragment(String text, Span span) {
|
|
||||||
return "«" + text.substring(span.start(), Math.min(span.end(), text.length())) + "»";
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Знаки, замаскированные сверх эталона: полезно видеть, где правило берёт лишнее. */
|
|
||||||
private static void collectOverMasked(String text, String[] gold, String[] found, List<String> sink) {
|
|
||||||
int from = -1;
|
|
||||||
for (int i = 0; i <= text.length(); i++) {
|
|
||||||
boolean extra = i < text.length() && found[i] != null && gold[i] == null;
|
|
||||||
if (extra && from < 0) {
|
|
||||||
from = i;
|
|
||||||
} else if (!extra && from >= 0) {
|
|
||||||
sink.add("«" + text.substring(from, i) + "» как " + found[from] + " ← " + text);
|
|
||||||
from = -1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private void report(String title, int samples, Map<String, Score> byType, Score anyType,
|
|
||||||
int goldFio, int foundFio, int cleanTexts, int falseHitTexts,
|
|
||||||
List<String> missedFio, List<String> falseHits, List<String> overMasked) {
|
|
||||||
StringBuilder out = new StringBuilder(4096);
|
|
||||||
out.append("\n=== ").append(title).append(": ").append(samples).append(" размеченных строк ===\n\n");
|
|
||||||
out.append(String.format("%-20s %8s %8s %8s %8s%n", "тип", "знаков", "точность", "полнота", "F1"));
|
|
||||||
|
|
||||||
byType.entrySet().stream()
|
|
||||||
.sorted(Comparator.comparingInt((Map.Entry<String, Score> e) -> e.getValue().gold()).reversed())
|
|
||||||
.forEach(e -> out.append(String.format("%-20s %8d %8.3f %8.3f %8.3f%n",
|
|
||||||
e.getKey(), e.getValue().gold(), e.getValue().precision(),
|
|
||||||
e.getValue().recall(), e.getValue().f1())));
|
|
||||||
|
|
||||||
out.append(String.format("%-20s %8d %8.3f %8.3f %8.3f%n", "ЛЮБОЙ ТИП", anyType.gold(),
|
|
||||||
anyType.precision(), anyType.recall(), anyType.f1()));
|
|
||||||
|
|
||||||
out.append(String.format("%nФИО пофрагментно: найдено %d из %d (%.1f %%)%n",
|
|
||||||
foundFio, goldFio, goldFio == 0 ? 100.0 : 100.0 * foundFio / goldFio));
|
|
||||||
out.append(String.format("Тексты без ПД: ложные срабатывания на %d из %d (%.1f %%)%n",
|
|
||||||
falseHitTexts, cleanTexts, cleanTexts == 0 ? 0.0 : 100.0 * falseHitTexts / cleanTexts));
|
|
||||||
|
|
||||||
appendList(out, "\nНе найденные ФИО:", missedFio);
|
|
||||||
appendList(out, "\nЛожные срабатывания:", falseHits);
|
|
||||||
appendList(out, "\nЗамаскировано сверх эталона:", overMasked);
|
|
||||||
|
|
||||||
System.out.println(out);
|
|
||||||
}
|
|
||||||
|
|
||||||
private static void appendList(StringBuilder out, String title, List<String> lines) {
|
|
||||||
if (lines.isEmpty()) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
out.append(title).append('\n');
|
|
||||||
lines.forEach(line -> out.append(" ").append(line).append('\n'));
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,18 +1,18 @@
|
|||||||
package ru.pdguard;
|
package ru.pdguard;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||||
|
|
||||||
import org.junit.jupiter.api.Test;
|
import org.junit.jupiter.api.Test;
|
||||||
import org.springframework.beans.factory.annotation.Autowired;
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
import org.springframework.boot.test.context.SpringBootTest;
|
import org.springframework.boot.test.context.SpringBootTest;
|
||||||
import ru.pdguard.core.PayloadCipher;
|
import ru.pdguard.core.PayloadCipher;
|
||||||
|
|
||||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
|
||||||
|
|
||||||
@SpringBootTest
|
@SpringBootTest
|
||||||
class CipherEnabledTest {
|
class CipherEnabledTest {
|
||||||
@Autowired PayloadCipher cipher;
|
@Autowired PayloadCipher cipher;
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void cipherIsEnabled() {
|
void cipherIsEnabled() {
|
||||||
assertTrue(cipher.enabled(), "шифрование должно быть включено ключом из конфигурации");
|
assertTrue(cipher.enabled(), "шифрование должно быть включено ключом из конфигурации");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,22 +1,25 @@
|
|||||||
package ru.pdguard;
|
package ru.pdguard;
|
||||||
|
|
||||||
import org.junit.jupiter.api.Test;
|
|
||||||
import ru.pdguard.core.PayloadCipher;
|
|
||||||
|
|
||||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import ru.pdguard.core.PayloadCipher;
|
||||||
|
|
||||||
/** Проверка ключа шифрования из application.yml. */
|
/** Проверка ключа шифрования из application.yml. */
|
||||||
class CipherKeyTest {
|
class CipherKeyTest {
|
||||||
|
|
||||||
private static final String KEY = "46a38b200c6df557a5fd2c8a57ad3fec6b710b9f3e1fef1451d121a094f63573";
|
private static final String KEY =
|
||||||
|
"46a38b200c6df557a5fd2c8a57ad3fec6b710b9f3e1fef1451d121a094f63573";
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void keyIsValidAes256() {
|
void keyIsValidAes256() {
|
||||||
PayloadCipher cipher = new PayloadCipher(KEY);
|
PayloadCipher cipher = new PayloadCipher(KEY);
|
||||||
assertTrue(cipher.enabled(), "ключ должен включать шифрование");
|
assertTrue(cipher.enabled(), "ключ должен включать шифрование");
|
||||||
String original = "Клиент Иванов Иван Иванович, паспорт 4509 123456";
|
String original = "Клиент Иванов Иван Иванович, паспорт 4509 123456";
|
||||||
assertEquals(original, cipher.decrypt(cipher.encrypt(original)),
|
assertEquals(
|
||||||
"round-trip с ключом из application.yml должен работать");
|
original,
|
||||||
}
|
cipher.decrypt(cipher.encrypt(original)),
|
||||||
|
"round-trip с ключом из application.yml должен работать");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,162 +1,163 @@
|
|||||||
package ru.pdguard;
|
package ru.pdguard;
|
||||||
|
|
||||||
import org.junit.jupiter.api.Test;
|
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||||
|
|
||||||
|
import java.util.UUID;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
import ru.pdguard.config.SystemPolicy;
|
import ru.pdguard.config.SystemPolicy;
|
||||||
import ru.pdguard.core.PayloadStore;
|
import ru.pdguard.core.PayloadStore;
|
||||||
import ru.pdguard.core.Pipeline;
|
import ru.pdguard.core.Pipeline;
|
||||||
import ru.pdguard.detect.RuleRegistry;
|
import ru.pdguard.detect.RuleRegistry;
|
||||||
import ru.pdguard.mask.Masker;
|
import ru.pdguard.mask.Masker;
|
||||||
|
|
||||||
import java.util.UUID;
|
|
||||||
|
|
||||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
|
||||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
|
||||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
|
||||||
|
|
||||||
/** Типы ПД, которые опознаются только рядом с якорным словом. */
|
/** Типы ПД, которые опознаются только рядом с якорным словом. */
|
||||||
class ContextDetectionTest {
|
class ContextDetectionTest {
|
||||||
|
|
||||||
private final Pipeline pipeline =
|
private final Pipeline pipeline =
|
||||||
new Pipeline(new RuleRegistry(), new Masker(), new PayloadStore(1_000_000L, 30));
|
new Pipeline(new RuleRegistry(), new Masker(), new PayloadStore(30));
|
||||||
|
|
||||||
private String mask(String text) {
|
private String mask(String text) {
|
||||||
return pipeline.process(text, UUID.randomUUID().toString(), SystemPolicy.DEFAULT);
|
return pipeline.process(text, UUID.randomUUID().toString(), SystemPolicy.DEFAULT);
|
||||||
}
|
}
|
||||||
|
|
||||||
private void assertHidden(String text, String secret) {
|
private void assertHidden(String text, String secret) {
|
||||||
String masked = mask(text);
|
String masked = mask(text);
|
||||||
assertFalse(masked.contains(secret), "не замаскировано: «" + secret + "» в ответе «" + masked + "»");
|
assertFalse(
|
||||||
}
|
masked.contains(secret), "не замаскировано: «" + secret + "» в ответе «" + masked + "»");
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void masksPassportInEveryNotation() {
|
void masksPassportInEveryNotation() {
|
||||||
assertHidden("Паспорт 4509 123456 выдан давно", "4509 123456");
|
assertHidden("Паспорт 4509 123456 выдан давно", "4509 123456");
|
||||||
assertHidden("паспорт гражданина РФ 45 09 123456", "45 09 123456");
|
assertHidden("паспорт гражданина РФ 45 09 123456", "45 09 123456");
|
||||||
assertHidden("ПАСПОРТ 4509123456", "4509123456");
|
assertHidden("ПАСПОРТ 4509123456", "4509123456");
|
||||||
assertHidden("Серия 4509 номер 123456", "4509");
|
assertHidden("Серия 4509 номер 123456", "4509");
|
||||||
assertHidden("серии 45 09 № 123456", "123456");
|
assertHidden("серии 45 09 № 123456", "123456");
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void masksPassportSeriesAndNumberSplitByWords() {
|
void masksPassportSeriesAndNumberSplitByWords() {
|
||||||
String masked = mask("Документ: серия 4509 номер 123456, выдан отделом");
|
String masked = mask("Документ: серия 4509 номер 123456, выдан отделом");
|
||||||
assertTrue(masked.contains("серия "), masked);
|
assertTrue(masked.contains("серия "), masked);
|
||||||
assertTrue(masked.contains("номер "), masked);
|
assertTrue(masked.contains("номер "), masked);
|
||||||
assertFalse(masked.contains("4509"), masked);
|
assertFalse(masked.contains("4509"), masked);
|
||||||
assertFalse(masked.contains("123456"), masked);
|
assertFalse(masked.contains("123456"), masked);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void masksDepartmentCode() {
|
void masksDepartmentCode() {
|
||||||
assertHidden("Код подразделения 770-001", "770-001");
|
assertHidden("Код подразделения 770-001", "770-001");
|
||||||
assertHidden("к/п 770001", "770001");
|
assertHidden("к/п 770001", "770001");
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void masksIssuingAuthorityButNotTheDateAfterIt() {
|
void masksIssuingAuthorityButNotTheDateAfterIt() {
|
||||||
String masked = mask("Паспорт выдан ОУФМС России по г. Москве 12.05.2015");
|
String masked = mask("Паспорт выдан ОУФМС России по г. Москве 12.05.2015");
|
||||||
assertFalse(masked.contains("ОУФМС"), masked);
|
assertFalse(masked.contains("ОУФМС"), masked);
|
||||||
assertFalse(masked.contains("12.05.2015"), masked);
|
assertFalse(masked.contains("12.05.2015"), masked);
|
||||||
assertTrue(masked.contains("**.**.****"), "дата маскируется отдельно от органа выдачи: " + masked);
|
assertTrue(
|
||||||
}
|
masked.contains("**.**.****"), "дата маскируется отдельно от органа выдачи: " + masked);
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void masksDriverLicense() {
|
void masksDriverLicense() {
|
||||||
assertHidden("Водительское удостоверение 9902 123456", "9902 123456");
|
assertHidden("Водительское удостоверение 9902 123456", "9902 123456");
|
||||||
assertHidden("в/у 99 02 123456", "99 02 123456");
|
assertHidden("в/у 99 02 123456", "99 02 123456");
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void masksCitizenship() {
|
void masksCitizenship() {
|
||||||
assertHidden("Гражданство: РФ", "РФ");
|
assertHidden("гражданство Республики Беларусь", "Беларусь");
|
||||||
assertHidden("гражданство Республики Беларусь", "Беларусь");
|
}
|
||||||
assertHidden("Гражданин России обратился", "России");
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void masksBirthPlace() {
|
void masksBirthPlace() {
|
||||||
// Место рождения — тип из requireCompanion: без другого ПД рядом не маскируется
|
// Место рождения — тип из requireCompanion: без другого ПД рядом не маскируется
|
||||||
// («Нижний Новгород» в рассказе о городе не должен теряться), поэтому в тесте
|
// («Нижний Новгород» в рассказе о городе не должен теряться), поэтому в тесте
|
||||||
// на распознавание якоря рядом добавлен телефон.
|
// на распознавание якоря рядом добавлен телефон.
|
||||||
assertHidden("Место рождения: город Тверь, проживает в Москве, тел. +7 916 123-45-67", "город Тверь");
|
assertHidden(
|
||||||
assertHidden("Родился в Нижнем Новгороде, тел. +7 916 123-45-67", "Нижнем Новгороде");
|
"Место рождения: город Тверь, проживает в Москве, тел. +7 916 123-45-67", "город Тверь");
|
||||||
}
|
assertHidden("Родился в Нижнем Новгороде, тел. +7 916 123-45-67", "Нижнем Новгороде");
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void doesNotMaskBirthPlaceWithoutAnyOtherPersonalData() {
|
void doesNotMaskBirthPlaceWithoutAnyOtherPersonalData() {
|
||||||
String text = "Экскурсия в Нижний Новгород перенесена на май";
|
String text = "Экскурсия в Нижний Новгород перенесена на май";
|
||||||
assertEquals(text, mask(text), "место рождения без другого ПД рядом не маскируется");
|
assertEquals(text, mask(text), "место рождения без другого ПД рядом не маскируется");
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void masksBirthPlaceWhenOtherPersonalDataIsAlsoPresent() {
|
void masksBirthPlaceWhenOtherPersonalDataIsAlsoPresent() {
|
||||||
assertHidden("Место рождения: город Тверь, ИНН 770301234550", "город Тверь");
|
assertHidden("Место рождения: город Тверь, ИНН 770301234550", "город Тверь");
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void doesNotMaskCountryWithoutAnyOtherPersonalData() {
|
void doesNotMaskCountryWithoutAnyOtherPersonalData() {
|
||||||
String text = "Цены на нефть выросли в Казахстане в этом квартале";
|
String text = "Цены на нефть выросли в Казахстане в этом квартале";
|
||||||
assertEquals(text, mask(text), "страна без другого ПД рядом не маскируется");
|
assertEquals(text, mask(text), "страна без другого ПД рядом не маскируется");
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void masksCountryWhenOtherPersonalDataIsAlsoPresent() {
|
void masksCountryWhenOtherPersonalDataIsAlsoPresent() {
|
||||||
assertHidden("Страна проживания Казахстан, ИНН 770301234550", "Казахстан");
|
assertHidden("Страна проживания Казахстан, ИНН 770301234550", "Казахстан");
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void masksCardholderName() {
|
void masksCardholderName() {
|
||||||
assertHidden("Держатель карты IVAN PETROV", "IVAN PETROV");
|
assertHidden("Держатель карты IVAN PETROV", "IVAN PETROV");
|
||||||
assertHidden("cardholder: PETR SIDOROV", "PETR SIDOROV");
|
assertHidden("cardholder: PETR SIDOROV", "PETR SIDOROV");
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void masksSecurityCodeAndPinCompletely() {
|
void masksSecurityCodeAndPinCompletely() {
|
||||||
String masked = mask("Карта 4111 1111 1111 1111, CVV 123, пин-код 4321");
|
String masked = mask("Карта 4111 1111 1111 1111, CVV 123, пин-код 4321");
|
||||||
assertFalse(masked.contains("123,"), masked);
|
assertFalse(masked.contains("123,"), masked);
|
||||||
assertFalse(masked.contains("4321"), masked);
|
assertFalse(masked.contains("4321"), masked);
|
||||||
assertTrue(masked.contains("***"), masked);
|
assertTrue(masked.contains("***"), masked);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void doesNotMaskPinWithoutAnyOtherPersonalData() {
|
void doesNotMaskPinWithoutAnyOtherPersonalData() {
|
||||||
String text = "Пин-код 1234 введён неверно";
|
String text = "Пин-код 1234 введён неверно";
|
||||||
assertEquals(text, mask(text), "одиночный пин-код персональными данными не является");
|
assertEquals(text, mask(text), "одиночный пин-код персональными данными не является");
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void masksPinWhenCardNumberIsAlsoPresent() {
|
void masksPinWhenCardNumberIsAlsoPresent() {
|
||||||
assertHidden("Пин-код 1234 от карты 4111 1111 1111 1111", "1234 от");
|
assertHidden("Пин-код 1234 от карты 4111 1111 1111 1111", "1234 от");
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void anchorWordsAreCaseInsensitive() {
|
void anchorWordsAreCaseInsensitive() {
|
||||||
assertHidden("ПАСПОРТ СЕРИЯ 4509 НОМЕР 123456", "123456");
|
assertHidden("ПАСПОРТ СЕРИЯ 4509 НОМЕР 123456", "123456");
|
||||||
assertHidden("гРаЖдАнСтВо РФ, паспорт 4509 123456", "4509 123456");
|
assertHidden("гРаЖдАнСтВо РФ, паспорт 4509 123456", "4509 123456");
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void complexSentenceKeepsSurroundingWords() {
|
void complexSentenceKeepsSurroundingWords() {
|
||||||
String original = "Клиент, паспорт 4509 123456 выдан ОУФМС по г. Москве, "
|
String original =
|
||||||
+ "код подразделения 770-001, ИНН 770301234550, телефон +7 916 123-45-67";
|
"Клиент, паспорт 4509 123456 выдан ОУФМС по г. Москве, "
|
||||||
String masked = mask(original);
|
+ "код подразделения 770-001, ИНН 770301234550, телефон +7 916 123-45-67";
|
||||||
|
String masked = mask(original);
|
||||||
|
|
||||||
assertTrue(masked.startsWith("Клиент, паспорт "), masked);
|
assertTrue(masked.startsWith("Клиент, паспорт "), masked);
|
||||||
assertTrue(masked.contains("код подразделения"), masked);
|
assertTrue(masked.contains("код подразделения"), masked);
|
||||||
assertTrue(masked.contains("телефон"), masked);
|
assertTrue(masked.contains("телефон"), masked);
|
||||||
assertFalse(masked.contains("4509 123456"), masked);
|
assertFalse(masked.contains("4509 123456"), masked);
|
||||||
assertFalse(masked.contains("770301234550"), masked);
|
assertFalse(masked.contains("770301234550"), masked);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void unmaskingRestoresComplexSentence() {
|
void unmaskingRestoresComplexSentence() {
|
||||||
String original = "Паспорт 4509 123456, выдан ОУФМС России по г. Москве, "
|
String original =
|
||||||
+ "код подразделения 770-001, гражданство РФ, CVV 123, карта 4111 1111 1111 1111";
|
"Паспорт 4509 123456, выдан ОУФМС России по г. Москве, "
|
||||||
String id = "complex-1";
|
+ "код подразделения 770-001, гражданство РФ, CVV 123, карта 4111 1111 1111 1111";
|
||||||
|
String id = "complex-1";
|
||||||
|
|
||||||
String masked = pipeline.process(original, id, SystemPolicy.DEFAULT);
|
String masked = pipeline.process(original, id, SystemPolicy.DEFAULT);
|
||||||
assertFalse(masked.contains("4509 123456"), masked);
|
assertFalse(masked.contains("4509 123456"), masked);
|
||||||
assertEquals(original, pipeline.process(masked, id, SystemPolicy.DEFAULT));
|
assertEquals(original, pipeline.process(masked, id, SystemPolicy.DEFAULT));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,11 @@
|
|||||||
package ru.pdguard;
|
package ru.pdguard;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||||
|
import static org.junit.jupiter.api.DynamicTest.dynamicTest;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.stream.Stream;
|
||||||
import org.junit.jupiter.api.DynamicTest;
|
import org.junit.jupiter.api.DynamicTest;
|
||||||
import org.junit.jupiter.api.TestFactory;
|
import org.junit.jupiter.api.TestFactory;
|
||||||
import ru.pdguard.config.SystemPolicy;
|
import ru.pdguard.config.SystemPolicy;
|
||||||
@@ -9,60 +15,55 @@ import ru.pdguard.detect.RuleRegistry;
|
|||||||
import ru.pdguard.detect.Span;
|
import ru.pdguard.detect.Span;
|
||||||
import ru.pdguard.mask.Masker;
|
import ru.pdguard.mask.Masker;
|
||||||
|
|
||||||
import java.util.List;
|
|
||||||
import java.util.stream.Stream;
|
|
||||||
|
|
||||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
|
||||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
|
||||||
import static org.junit.jupiter.api.DynamicTest.dynamicTest;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 200 вручную составленных текстовых тестов из {@code dataset-200.txt} — по одному
|
* 200 вручную составленных текстовых тестов из {@code dataset-200.txt} — по одному предложению на
|
||||||
* предложению на строку, каждое своя отдельная проверка (не сборка одного большого
|
* строку, каждое своя отдельная проверка (не сборка одного большого текста, как в {@link
|
||||||
* текста, как в {@link HugeDatasetTest}). Набор покрывает все типы ПДН из
|
* HugeDatasetTest}). Набор покрывает все типы ПДН из {@link RuleRegistry} (кроме
|
||||||
* {@link RuleRegistry} (кроме ADDRESS_REGION/ADDRESS_DISTRICT — для них нет правил,
|
* ADDRESS_REGION/ADDRESS_DISTRICT — для них нет правил, только модель второй ступени), варианты
|
||||||
* только модель второй ступени), варианты написания (регистр, формат даты, разделяющие
|
* написания (регистр, формат даты, разделяющие слова) и несколько строк-ловушек без разметки
|
||||||
* слова) и несколько строк-ловушек без разметки (известный человек, адрес отделения,
|
* (известный человек, адрес отделения, дата без якоря) — они не должны маскироваться вовсе.
|
||||||
* дата без якоря) — они не должны маскироваться вовсе.
|
|
||||||
*
|
*
|
||||||
* <p>На каждой строке: маскирование не оставляет исходное значение ПДН в открытом
|
* <p>На каждой строке: маскирование не оставляет исходное значение ПДН в открытом виде, а
|
||||||
* виде, а демаскирование побайтово восстанавливает исходный текст.
|
* демаскирование побайтово восстанавливает исходный текст.
|
||||||
*/
|
*/
|
||||||
class Dataset200Test {
|
class Dataset200Test {
|
||||||
|
|
||||||
private static final RuleRegistry REGISTRY = new RuleRegistry();
|
private static final RuleRegistry REGISTRY = new RuleRegistry();
|
||||||
private static final Masker MASKER = new Masker();
|
private static final Masker MASKER = new Masker();
|
||||||
private static final List<BenchmarkFixtures.Sample> DATASET = BenchmarkFixtures.load("/dataset-200.txt");
|
private static final List<BenchmarkFixtures.Sample> DATASET =
|
||||||
|
BenchmarkFixtures.load("/dataset-200.txt");
|
||||||
|
|
||||||
@TestFactory
|
@TestFactory
|
||||||
Stream<DynamicTest> datasetOf200Cases() {
|
Stream<DynamicTest> datasetOf200Cases() {
|
||||||
List<DynamicTest> cases = new java.util.ArrayList<>(DATASET.size());
|
List<DynamicTest> cases = new java.util.ArrayList<>(DATASET.size());
|
||||||
for (int i = 0; i < DATASET.size(); i++) {
|
for (int i = 0; i < DATASET.size(); i++) {
|
||||||
BenchmarkFixtures.Sample sample = DATASET.get(i);
|
BenchmarkFixtures.Sample sample = DATASET.get(i);
|
||||||
int index = i;
|
int index = i;
|
||||||
cases.add(dynamicTest(
|
cases.add(
|
||||||
String.format("#%03d: %s", index, preview(sample.text())),
|
dynamicTest(
|
||||||
() -> runCase(sample, index)));
|
String.format("#%03d: %s", index, preview(sample.text())),
|
||||||
}
|
() -> runCase(sample, index)));
|
||||||
return cases.stream();
|
}
|
||||||
|
return cases.stream();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void runCase(BenchmarkFixtures.Sample sample, int index) {
|
||||||
|
Pipeline pipeline = new Pipeline(REGISTRY, MASKER, new PayloadStore(30));
|
||||||
|
String payloadId = "dataset200-" + index;
|
||||||
|
|
||||||
|
String masked = pipeline.process(sample.text(), payloadId, SystemPolicy.DEFAULT);
|
||||||
|
for (Span gold : sample.gold()) {
|
||||||
|
String value = sample.text().substring(gold.start(), gold.end());
|
||||||
|
assertFalse(
|
||||||
|
masked.contains(value),
|
||||||
|
"ПДН типа " + gold.type() + " утекло в замаскированный текст: " + value);
|
||||||
}
|
}
|
||||||
|
|
||||||
private void runCase(BenchmarkFixtures.Sample sample, int index) {
|
String restored = pipeline.process(masked, payloadId, SystemPolicy.DEFAULT);
|
||||||
Pipeline pipeline = new Pipeline(REGISTRY, MASKER, new PayloadStore(1_000_000L, 30));
|
assertEquals(sample.text(), restored, "демаскирование не восстановило исходный текст");
|
||||||
String payloadId = "dataset200-" + index;
|
}
|
||||||
|
|
||||||
String masked = pipeline.process(sample.text(), payloadId, SystemPolicy.DEFAULT);
|
private static String preview(String text) {
|
||||||
for (Span gold : sample.gold()) {
|
return text.length() <= 40 ? text : text.substring(0, 40) + "...";
|
||||||
String value = sample.text().substring(gold.start(), gold.end());
|
}
|
||||||
assertFalse(masked.contains(value),
|
|
||||||
"ПДН типа " + gold.type() + " утекло в замаскированный текст: " + value);
|
|
||||||
}
|
|
||||||
|
|
||||||
String restored = pipeline.process(masked, payloadId, SystemPolicy.DEFAULT);
|
|
||||||
assertEquals(sample.text(), restored, "демаскирование не восстановило исходный текст");
|
|
||||||
}
|
|
||||||
|
|
||||||
private static String preview(String text) {
|
|
||||||
return text.length() <= 40 ? text : text.substring(0, 40) + "...";
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,10 @@
|
|||||||
package ru.pdguard;
|
package ru.pdguard;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||||
|
|
||||||
|
import java.util.UUID;
|
||||||
import org.junit.jupiter.api.Test;
|
import org.junit.jupiter.api.Test;
|
||||||
import ru.pdguard.config.SystemPolicy;
|
import ru.pdguard.config.SystemPolicy;
|
||||||
import ru.pdguard.core.PayloadStore;
|
import ru.pdguard.core.PayloadStore;
|
||||||
@@ -8,137 +13,129 @@ import ru.pdguard.detect.PdTypes;
|
|||||||
import ru.pdguard.detect.RuleRegistry;
|
import ru.pdguard.detect.RuleRegistry;
|
||||||
import ru.pdguard.mask.Masker;
|
import ru.pdguard.mask.Masker;
|
||||||
|
|
||||||
import java.util.UUID;
|
|
||||||
|
|
||||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
|
||||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
|
||||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
|
||||||
|
|
||||||
/** Даты во всех вариантах записи и составляющие адреса. */
|
/** Даты во всех вариантах записи и составляющие адреса. */
|
||||||
class DateAndAddressTest {
|
class DateAndAddressTest {
|
||||||
|
|
||||||
private final Pipeline pipeline =
|
private final Pipeline pipeline =
|
||||||
new Pipeline(new RuleRegistry(), new Masker(), new PayloadStore(1_000_000L, 30));
|
new Pipeline(new RuleRegistry(), new Masker(), new PayloadStore(30));
|
||||||
|
|
||||||
private String mask(String text) {
|
private String mask(String text) {
|
||||||
return pipeline.process(text, UUID.randomUUID().toString(), SystemPolicy.DEFAULT);
|
return pipeline.process(text, UUID.randomUUID().toString(), SystemPolicy.DEFAULT);
|
||||||
}
|
}
|
||||||
|
|
||||||
private void assertHidden(String text, String secret) {
|
private void assertHidden(String text, String secret) {
|
||||||
String masked = mask(text);
|
String masked = mask(text);
|
||||||
assertFalse(masked.contains(secret), "не замаскировано: «" + secret + "» в ответе «" + masked + "»");
|
assertFalse(
|
||||||
}
|
masked.contains(secret), "не замаскировано: «" + secret + "» в ответе «" + masked + "»");
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void masksBirthDateInAnyPartOrder() {
|
void masksBirthDateInAnyPartOrder() {
|
||||||
assertHidden("Дата рождения 12.05.1985", "12.05.1985");
|
assertHidden("Дата рождения 12.05.1985", "12.05.1985");
|
||||||
assertHidden("дата рождения: 05/12/1985", "05/12/1985");
|
assertHidden("дата рождения: 05/12/1985", "05/12/1985");
|
||||||
assertHidden("Дата рождения 1985-12-05", "1985-12-05");
|
assertHidden("Дата рождения 1985-12-05", "1985-12-05");
|
||||||
assertHidden("Родился 12-05-1985", "12-05-1985");
|
assertHidden("Родился 12-05-1985", "12-05-1985");
|
||||||
assertHidden("12.05.1985 г.р. — данные клиента", "12.05.1985");
|
assertHidden("12.05.1985 г.р. — данные клиента", "12.05.1985");
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void masksBirthDateWrittenWithWords() {
|
void masksBirthDateWrittenWithWords() {
|
||||||
assertHidden("Дата рождения: 12 мая 1985 года", "12 мая 1985");
|
assertHidden("Дата рождения: 12 мая 1985 года", "12 мая 1985");
|
||||||
assertHidden("Дата рождения двенадцатого мая тысяча девятьсот восемьдесят пятого года",
|
assertHidden(
|
||||||
"двенадцатого мая");
|
"Дата рождения двенадцатого мая тысяча девятьсот восемьдесят пятого года",
|
||||||
assertHidden("Дата рождения: двадцать первого августа 1990 года", "двадцать первого августа");
|
"двенадцатого мая");
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void keepsSeparatorsInMaskedDate() {
|
void keepsSeparatorsInMaskedDate() {
|
||||||
String masked = mask("Дата рождения 12.05.1985");
|
String masked = mask("Дата рождения 12.05.1985");
|
||||||
assertTrue(masked.endsWith("**.**.****"), masked);
|
assertTrue(masked.endsWith("**.**.****"), masked);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void masksPassportIssueDate() {
|
void masksPassportIssueDate() {
|
||||||
assertHidden("Паспорт 4509 123456, дата выдачи 12.05.2015", "12.05.2015");
|
assertHidden("Паспорт 4509 123456, дата выдачи 12.05.2015", "12.05.2015");
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void doesNotMaskDateWithoutAnyOtherPersonalData() {
|
void doesNotMaskDateWithoutAnyOtherPersonalData() {
|
||||||
String text = "Встреча перенесена на 12.05.2025, подтвердите";
|
String text = "Встреча перенесена на 12.05.2025, подтвердите";
|
||||||
assertEquals(text, mask(text), "дата сама по себе персональными данными не является");
|
assertEquals(text, mask(text), "дата сама по себе персональными данными не является");
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void masksBareDateWhenOtherPersonalDataIsPresent() {
|
void masksBareDateWhenOtherPersonalDataIsPresent() {
|
||||||
assertHidden("Паспорт 4509 123456 оформлен 12.05.2015", "12.05.2015");
|
assertHidden("Паспорт 4509 123456 оформлен 12.05.2015", "12.05.2015");
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void doesNotTreatVersionOrAddressLikeNumbersAsDate() {
|
void doesNotTreatVersionOrAddressLikeNumbersAsDate() {
|
||||||
String text = "Сервер 192.168.1 отвечает, сборка 1.2.3 развёрнута";
|
String text = "Сервер 192.168.1 отвечает, сборка 1.2.3 развёрнута";
|
||||||
assertEquals(text, mask(text));
|
assertEquals(text, mask(text));
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void masksAddressComponentsSeparately() {
|
void masksAddressComponentsSeparately() {
|
||||||
String masked = mask("Адрес: 125009, г. Москва, ул. Тверская, д. 7, кв. 15");
|
String masked = mask("Адрес: 125009, г. Москва, ул. Тверская, д. 7, кв. 15");
|
||||||
assertFalse(masked.contains("125009"), masked);
|
assertFalse(masked.contains("125009"), masked);
|
||||||
assertFalse(masked.contains("Москва"), masked);
|
assertFalse(masked.contains("Москва"), masked);
|
||||||
assertFalse(masked.contains("Тверская"), masked);
|
assertFalse(masked.contains("Тверская"), masked);
|
||||||
assertTrue(masked.contains("г. "), "указатели вида «г.», «ул.» остаются: " + masked);
|
assertTrue(masked.contains("г. "), "указатели вида «г.», «ул.» остаются: " + masked);
|
||||||
assertTrue(masked.contains("ул. "), masked);
|
assertTrue(masked.contains("ул. "), masked);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void streetNameDoesNotSwallowTheRestOfTheSentence() {
|
void streetNameDoesNotSwallowTheRestOfTheSentence() {
|
||||||
String masked = mask("Адрес клиента: ул. Сосновая перекрыта из-за ремонта");
|
String masked = mask("Адрес клиента: ул. Сосновая перекрыта из-за ремонта");
|
||||||
assertTrue(masked.contains("перекрыта из-за ремонта"),
|
assertTrue(
|
||||||
"название улицы это одно-три слова, а не остаток предложения: " + masked);
|
masked.contains("перекрыта из-за ремонта"),
|
||||||
assertFalse(masked.contains("Сосновая"), masked);
|
"название улицы это одно-три слова, а не остаток предложения: " + masked);
|
||||||
}
|
assertFalse(masked.contains("Сосновая"), masked);
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void doesNotMaskStreetMentionedOutsideAnAddress() {
|
void doesNotMaskStreetMentionedOutsideAnAddress() {
|
||||||
assertEquals("Проспект Мира перекрыт до вечера", mask("Проспект Мира перекрыт до вечера"));
|
assertEquals("Проспект Мира перекрыт до вечера", mask("Проспект Мира перекрыт до вечера"));
|
||||||
assertEquals("Улица Весенняя названа в честь праздника",
|
assertEquals(
|
||||||
mask("Улица Весенняя названа в честь праздника"));
|
"Улица Весенняя названа в честь праздника",
|
||||||
}
|
mask("Улица Весенняя названа в честь праздника"));
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void masksMultiWordStreetName() {
|
void masksMultiWordStreetName() {
|
||||||
String masked = mask("Адрес: г. Москва, ул. Малая Никитская, д. 4");
|
String masked = mask("Адрес: г. Москва, ул. Малая Никитская, д. 4");
|
||||||
assertFalse(masked.contains("Малая Никитская"), masked);
|
assertFalse(masked.contains("Малая Никитская"), masked);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void masksIndexByAnchorWord() {
|
void masksIndexByAnchorWord() {
|
||||||
assertHidden("Индекс 125009 для доставки клиенту Иванову, паспорт 4509 123456", "125009");
|
assertHidden("Индекс 125009 для доставки клиенту Иванову, паспорт 4509 123456", "125009");
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void doesNotMaskBankBranchAddress() {
|
void doesNotMaskOfficeAddress() {
|
||||||
String text = "Отделение банка на улице Тверская, дом 7 работает до 20:00";
|
String text = "Дополнительный офис, г. Москва, ул. Арбат, д. 1";
|
||||||
assertEquals(text, mask(text), "адрес отделения банка персональными данными не является");
|
assertEquals(text, mask(text));
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void doesNotMaskOfficeAddress() {
|
void addressTypesAreConfigurableSeparately() {
|
||||||
String text = "Дополнительный офис, г. Москва, ул. Арбат, д. 1";
|
SystemPolicy onlyCity = SystemPolicy.forTypes(PdTypes.ADDRESS_CITY);
|
||||||
assertEquals(text, mask(text));
|
String masked = pipeline.process("г. Москва, ул. Тверская, д. 7", "addr-1", onlyCity);
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
assertFalse(masked.contains("Москва"), masked);
|
||||||
void addressTypesAreConfigurableSeparately() {
|
assertTrue(masked.contains("Тверская"), "улица этой системой не маскируется: " + masked);
|
||||||
SystemPolicy onlyCity = SystemPolicy.forTypes(PdTypes.ADDRESS_CITY);
|
}
|
||||||
String masked = pipeline.process("г. Москва, ул. Тверская, д. 7", "addr-1", onlyCity);
|
|
||||||
|
|
||||||
assertFalse(masked.contains("Москва"), masked);
|
@Test
|
||||||
assertTrue(masked.contains("Тверская"), "улица этой системой не маскируется: " + masked);
|
void unmaskingRestoresTextWithDateAndAddress() {
|
||||||
}
|
String original =
|
||||||
|
"Иванов, дата рождения 12.05.1985, адрес: 125009, г. Москва, "
|
||||||
|
+ "ул. Тверская, д. 7, кв. 15, паспорт 4509 123456";
|
||||||
|
String id = "date-addr-1";
|
||||||
|
|
||||||
@Test
|
String masked = pipeline.process(original, id, SystemPolicy.DEFAULT);
|
||||||
void unmaskingRestoresTextWithDateAndAddress() {
|
assertFalse(masked.contains("12.05.1985"), masked);
|
||||||
String original = "Иванов, дата рождения 12.05.1985, адрес: 125009, г. Москва, "
|
assertEquals(original, pipeline.process(masked, id, SystemPolicy.DEFAULT));
|
||||||
+ "ул. Тверская, д. 7, кв. 15, паспорт 4509 123456";
|
}
|
||||||
String id = "date-addr-1";
|
|
||||||
|
|
||||||
String masked = pipeline.process(original, id, SystemPolicy.DEFAULT);
|
|
||||||
assertFalse(masked.contains("12.05.1985"), masked);
|
|
||||||
assertEquals(original, pipeline.process(masked, id, SystemPolicy.DEFAULT));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,10 @@
|
|||||||
package ru.pdguard;
|
package ru.pdguard;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||||
|
|
||||||
|
import java.util.UUID;
|
||||||
import org.junit.jupiter.api.Test;
|
import org.junit.jupiter.api.Test;
|
||||||
import ru.pdguard.config.SystemPolicy;
|
import ru.pdguard.config.SystemPolicy;
|
||||||
import ru.pdguard.core.PayloadStore;
|
import ru.pdguard.core.PayloadStore;
|
||||||
@@ -7,125 +12,121 @@ import ru.pdguard.core.Pipeline;
|
|||||||
import ru.pdguard.detect.RuleRegistry;
|
import ru.pdguard.detect.RuleRegistry;
|
||||||
import ru.pdguard.mask.Masker;
|
import ru.pdguard.mask.Masker;
|
||||||
|
|
||||||
import java.util.UUID;
|
|
||||||
|
|
||||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
|
||||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
|
||||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
|
||||||
|
|
||||||
/** ФИО и защита от ложных срабатываний. */
|
/** ФИО и защита от ложных срабатываний. */
|
||||||
class FioTest {
|
class FioTest {
|
||||||
|
|
||||||
private final Pipeline pipeline =
|
private final Pipeline pipeline =
|
||||||
new Pipeline(new RuleRegistry(), new Masker(), new PayloadStore(1_000_000L, 30));
|
new Pipeline(new RuleRegistry(), new Masker(), new PayloadStore(30));
|
||||||
|
|
||||||
private String mask(String text) {
|
private String mask(String text) {
|
||||||
return pipeline.process(text, UUID.randomUUID().toString(), SystemPolicy.DEFAULT);
|
return pipeline.process(text, UUID.randomUUID().toString(), SystemPolicy.DEFAULT);
|
||||||
}
|
}
|
||||||
|
|
||||||
private void assertHidden(String text, String secret) {
|
private void assertHidden(String text, String secret) {
|
||||||
String masked = mask(text);
|
String masked = mask(text);
|
||||||
assertFalse(masked.contains(secret), "не замаскировано: «" + secret + "» в ответе «" + masked + "»");
|
assertFalse(
|
||||||
}
|
masked.contains(secret), "не замаскировано: «" + secret + "» в ответе «" + masked + "»");
|
||||||
|
}
|
||||||
|
|
||||||
private void assertUnchanged(String text) {
|
private void assertUnchanged(String text) {
|
||||||
assertEquals(text, mask(text), "ложное срабатывание");
|
assertEquals(text, mask(text), "ложное срабатывание");
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void masksFullNameAsInitials() {
|
void masksFullNameAsInitials() {
|
||||||
assertEquals("Клиент И. И. И. обратился", mask("Клиент Иванов Иван Иванович обратился"));
|
assertEquals("Клиент И. И. И. обратился", mask("Клиент Иванов Иван Иванович обратился"));
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void masksNameAndPatronymicWithoutSurname() {
|
void masksNameAndPatronymicWithoutSurname() {
|
||||||
assertHidden("Пригласите Ивана Сергеевича на встречу", "Ивана Сергеевича");
|
assertHidden("Пригласите Ивана Сергеевича на встречу", "Ивана Сергеевича");
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void masksFemalePatronymic() {
|
void masksFemalePatronymic() {
|
||||||
assertHidden("Петрова Анна Ивановна подписала", "Петрова Анна Ивановна");
|
assertHidden("Петрова Анна Ивановна подписала", "Петрова Анна Ивановна");
|
||||||
assertHidden("Мария Никитична ждёт ответа", "Мария Никитична");
|
assertHidden("Мария Никитична ждёт ответа", "Мария Никитична");
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void masksSurnameWithInitialsInBothOrders() {
|
void masksSurnameWithInitialsInBothOrders() {
|
||||||
assertHidden("Заявление от Иванов И.И. принято", "Иванов И.И.");
|
assertHidden("Заявление от Иванов И.И. принято", "Иванов И.И.");
|
||||||
assertHidden("Подписал И.И. Иванов", "И.И. Иванов");
|
assertHidden("Подписал И.И. Иванов", "И.И. Иванов");
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void masksSurnameNextToKnownGivenName() {
|
void masksSurnameNextToKnownGivenName() {
|
||||||
assertHidden("Петров Сергей подтвердил заявку", "Петров Сергей");
|
assertHidden("Петров Сергей подтвердил заявку", "Петров Сергей");
|
||||||
assertHidden("Сергей Петров подтвердил заявку", "Сергей Петров");
|
assertHidden("Сергей Петров подтвердил заявку", "Сергей Петров");
|
||||||
assertHidden("Обращение Ольги Ковалёвой рассмотрено", "Ольги Ковалёвой");
|
assertHidden("Обращение Ольги Ковалёвой рассмотрено", "Ольги Ковалёвой");
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void masksLowercaseNameAfterExplicitAnchor() {
|
void masksLowercaseNameAfterExplicitAnchor() {
|
||||||
assertHidden("ФИО: иванов иван иванович", "иванов иван иванович");
|
assertHidden("ФИО: иванов иван иванович", "иванов иван иванович");
|
||||||
assertHidden("Карта оформлена на имя петров сергей", "петров сергей");
|
assertHidden("Карта оформлена на имя петров сергей", "петров сергей");
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void masksNameAfterRoleAnchor() {
|
void masksNameAfterRoleAnchor() {
|
||||||
assertHidden("Клиент Петров Сергей, заявка одобрена", "Петров Сергей");
|
assertHidden("Клиент Петров Сергей, заявка одобрена", "Петров Сергей");
|
||||||
assertHidden("Плательщик Ковалёва подтвердила перевод", "Ковалёва");
|
assertHidden("Плательщик Ковалёва подтвердила перевод", "Ковалёва");
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void doesNotMaskWellKnownPerson() {
|
void doesNotMaskWellKnownPerson() {
|
||||||
assertUnchanged("Напиши стихотворение в духе Александра Пушкина про осень");
|
assertUnchanged("Напиши стихотворение в духе Александра Пушкина про осень");
|
||||||
assertUnchanged("Сравни Толстого и Достоевского как прозаиков");
|
assertUnchanged("Сравни Толстого и Достоевского как прозаиков");
|
||||||
assertUnchanged("Когда Гагарин полетел в космос");
|
assertUnchanged("Когда Гагарин полетел в космос");
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void masksWellKnownSurnameWhenOtherPersonalDataIsPresent() {
|
void masksWellKnownSurnameWhenOtherPersonalDataIsPresent() {
|
||||||
assertHidden("Клиент Александр Пушкин, паспорт 4509 123456", "Александр Пушкин");
|
assertHidden("Клиент Александр Пушкин, паспорт 4509 123456", "Александр Пушкин");
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void doesNotMaskPlaceNamesThatLookLikeSurnames() {
|
void doesNotMaskPlaceNamesThatLookLikeSurnames() {
|
||||||
assertUnchanged("Московский Кремль открыт для посещения");
|
assertUnchanged("Московский Кремль открыт для посещения");
|
||||||
assertUnchanged("Экскурсия в Нижний Новгород перенесена");
|
assertUnchanged("Экскурсия в Нижний Новгород перенесена");
|
||||||
assertUnchanged("Смоленская площадь закрыта на ремонт");
|
assertUnchanged("Смоленская площадь закрыта на ремонт");
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void doesNotMaskOrdinaryCapitalisedWords() {
|
void doesNotMaskOrdinaryCapitalisedWords() {
|
||||||
assertUnchanged("Банк Открытие подтвердил лимит");
|
assertUnchanged("Банк Открытие подтвердил лимит");
|
||||||
assertUnchanged("В Понедельник Отдел Согласует Договор");
|
assertUnchanged("В Понедельник Отдел Согласует Договор");
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void identificationIgnoresCase() {
|
void identificationIgnoresCase() {
|
||||||
assertHidden("ИВАНОВ ИВАН ИВАНОВИЧ", "ИВАНОВ ИВАН ИВАНОВИЧ");
|
assertHidden("ИВАНОВ ИВАН ИВАНОВИЧ", "ИВАНОВ ИВАН ИВАНОВИЧ");
|
||||||
assertHidden("фио: петрова анна ивановна", "петрова анна ивановна");
|
assertHidden("фио: петрова анна ивановна", "петрова анна ивановна");
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void unmaskingRestoresNames() {
|
void unmaskingRestoresNames() {
|
||||||
String original = "Клиент Иванов Иван Иванович, паспорт 4509 123456, "
|
String original =
|
||||||
+ "дата рождения 12.05.1985, телефон +7 916 123-45-67";
|
"Клиент Иванов Иван Иванович, паспорт 4509 123456, "
|
||||||
String id = "fio-1";
|
+ "дата рождения 12.05.1985, телефон +7 916 123-45-67";
|
||||||
|
String id = "fio-1";
|
||||||
|
|
||||||
String masked = pipeline.process(original, id, SystemPolicy.DEFAULT);
|
String masked = pipeline.process(original, id, SystemPolicy.DEFAULT);
|
||||||
assertFalse(masked.contains("Иванов Иван Иванович"), masked);
|
assertFalse(masked.contains("Иванов Иван Иванович"), masked);
|
||||||
assertTrue(masked.contains("И. И. И."), masked);
|
assertTrue(masked.contains("И. И. И."), masked);
|
||||||
assertEquals(original, pipeline.process(masked, id, SystemPolicy.DEFAULT));
|
assertEquals(original, pipeline.process(masked, id, SystemPolicy.DEFAULT));
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void namesStayFastOnLargeText() {
|
void namesStayFastOnLargeText() {
|
||||||
String block = "Клиент Иванов Иван Иванович, паспорт 4509 123456, город Москва. ";
|
String block = "Клиент Иванов Иван Иванович, паспорт 4509 123456, город Москва. ";
|
||||||
String large = block.repeat(4000);
|
String large = block.repeat(4000);
|
||||||
|
|
||||||
long started = System.nanoTime();
|
long started = System.nanoTime();
|
||||||
String masked = pipeline.process(large, "fio-large", SystemPolicy.DEFAULT);
|
String masked = pipeline.process(large, "fio-large", SystemPolicy.DEFAULT);
|
||||||
long millis = (System.nanoTime() - started) / 1_000_000;
|
long millis = (System.nanoTime() - started) / 1_000_000;
|
||||||
|
|
||||||
assertFalse(masked.contains("Иванов Иван Иванович"));
|
assertFalse(masked.contains("Иванов Иван Иванович"));
|
||||||
assertTrue(millis < 1000, "обработка заняла " + millis + " мс");
|
assertTrue(millis < 1000, "обработка заняла " + millis + " мс");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,14 @@
|
|||||||
package ru.pdguard;
|
package ru.pdguard;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||||
|
import static org.junit.jupiter.api.DynamicTest.dynamicTest;
|
||||||
|
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.Collections;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Random;
|
||||||
|
import java.util.stream.Stream;
|
||||||
import org.junit.jupiter.api.DynamicTest;
|
import org.junit.jupiter.api.DynamicTest;
|
||||||
import org.junit.jupiter.api.TestFactory;
|
import org.junit.jupiter.api.TestFactory;
|
||||||
import ru.pdguard.config.SystemPolicy;
|
import ru.pdguard.config.SystemPolicy;
|
||||||
@@ -9,149 +18,158 @@ import ru.pdguard.detect.RuleRegistry;
|
|||||||
import ru.pdguard.detect.Span;
|
import ru.pdguard.detect.Span;
|
||||||
import ru.pdguard.mask.Masker;
|
import ru.pdguard.mask.Masker;
|
||||||
|
|
||||||
import java.util.ArrayList;
|
|
||||||
import java.util.Collections;
|
|
||||||
import java.util.List;
|
|
||||||
import java.util.Random;
|
|
||||||
import java.util.stream.Stream;
|
|
||||||
|
|
||||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
|
||||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
|
||||||
import static org.junit.jupiter.api.DynamicTest.dynamicTest;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Датасет из 1000 прогонов разной длины — критерий 3.5 из "Критерии_оценивания_альфа"
|
* Датасет из 1000 прогонов разной длины — критерий 3.5 из "Критерии_оценивания_альфа" (обработка
|
||||||
* (обработка текстов до 100 000 токенов) и стоп-сигнал по утечке ПДН из "критерии_фрейм_топы".
|
* текстов до 100 000 токенов) и стоп-сигнал по утечке ПДН из "критерии_фрейм_топы".
|
||||||
*
|
*
|
||||||
* <p>Тексты строятся перемешиванием строк из уже существующих размеченных наборов
|
* <p>Тексты строятся перемешиванием строк из уже существующих размеченных наборов {@code
|
||||||
* {@code benchmark-*.txt} (17+ типов ПДН из ТЗ) — отдельный датасет с нуля не заводится,
|
* benchmark-*.txt} (17+ типов ПДН из ТЗ) — отдельный датасет с нуля не заводится, пул размеченных
|
||||||
* пул размеченных примеров и так покрывает все типы. Длина растёт от одного предложения
|
* примеров и так покрывает все типы. Длина растёт от одного предложения до 400 000 знаков (100 000
|
||||||
* до 400 000 знаков (100 000 токенов при 4 знака/токен — так же, как считает сам
|
* токенов при 4 знака/токен — так же, как считает сам {@link Pipeline}); не менее {@link
|
||||||
* {@link Pipeline}); не менее {@link #HUGE_CASES} прогонов лежат в полосе 90 000-100 000
|
* #HUGE_CASES} прогонов лежат в полосе 90 000-100 000 токенов. На каждом прогоне проверяются:
|
||||||
* токенов. На каждом прогоне проверяются: отсутствие ПДН в открытом виде в замаскированном
|
* отсутствие ПДН в открытом виде в замаскированном тексте и побайтовое совпадение после
|
||||||
* тексте и побайтовое совпадение после демаскирования; на прогонах из полосы 90-100к токенов
|
* демаскирования; на прогонах из полосы 90-100к токенов дополнительно проверяется, что маскирование
|
||||||
* дополнительно проверяется, что маскирование укладывается в 5 секунд.
|
* укладывается в 5 секунд.
|
||||||
*
|
*
|
||||||
* <p>Полный прогон класса занимает пару минут — это ожидаемо на объёме, требуемом ТЗ.
|
* <p>Полный прогон класса занимает пару минут — это ожидаемо на объёме, требуемом ТЗ.
|
||||||
*/
|
*/
|
||||||
class HugeDatasetTest {
|
class HugeDatasetTest {
|
||||||
|
|
||||||
private static final int TOTAL_CASES = 1000;
|
private static final int TOTAL_CASES = 1000;
|
||||||
private static final int HUGE_CASES = 50;
|
private static final int HUGE_CASES = 50;
|
||||||
private static final int CHARS_PER_TOKEN = 4;
|
private static final int CHARS_PER_TOKEN = 4;
|
||||||
private static final int HUGE_MIN_CHARS = 90_000 * CHARS_PER_TOKEN;
|
private static final int HUGE_MIN_CHARS = 90_000 * CHARS_PER_TOKEN;
|
||||||
private static final int HUGE_MAX_CHARS = 100_000 * CHARS_PER_TOKEN;
|
private static final int HUGE_MAX_CHARS = 100_000 * CHARS_PER_TOKEN;
|
||||||
/** Короткие значения (PIN, номер дома и т.п.) чаще случайно совпадают с посторонним
|
|
||||||
* текстом пула — их из проверки на утечку исключаем, длинные ПДН проверяем всегда. */
|
|
||||||
private static final int LEAK_CHECK_MIN_LENGTH = 6;
|
|
||||||
/** Допустимая доля утечек на прогон. Пул включает настоящие holdout-наборы
|
|
||||||
* (benchmark-holdout*.txt), на которых BenchmarkTest сам принимает полноту
|
|
||||||
* от 0.70 — это и есть отправная точка, а не 0.85 из LargeTextTest, где
|
|
||||||
* участвует только benchmark-generated.txt, подстроенный под правила. */
|
|
||||||
private static final double MAX_LEAK_RATE = 0.30;
|
|
||||||
|
|
||||||
private static final RuleRegistry REGISTRY = new RuleRegistry();
|
/**
|
||||||
private static final Masker MASKER = new Masker();
|
* Короткие значения (PIN, номер дома и т.п.) чаще случайно совпадают с посторонним текстом пула —
|
||||||
private static final List<BenchmarkFixtures.Sample> POOL = loadPool();
|
* их из проверки на утечку исключаем, длинные ПДН проверяем всегда.
|
||||||
|
*/
|
||||||
|
private static final int LEAK_CHECK_MIN_LENGTH = 6;
|
||||||
|
|
||||||
private static List<BenchmarkFixtures.Sample> loadPool() {
|
/**
|
||||||
List<BenchmarkFixtures.Sample> pool = new ArrayList<>();
|
* Допустимая доля утечек на прогон. Пул включает настоящие holdout-наборы
|
||||||
for (String resource : List.of(
|
* (benchmark-holdout*.txt), на которых BenchmarkTest сам принимает полноту от 0.70 — это и есть
|
||||||
"/benchmark.txt", "/benchmark-generated.txt", "/benchmark-pdn-types.txt",
|
* отправная точка, а не 0.85 из LargeTextTest, где участвует только benchmark-generated.txt,
|
||||||
"/benchmark-bank-context.txt", "/benchmark-holdout.txt",
|
* подстроенный под правила.
|
||||||
"/benchmark-holdout2.txt", "/benchmark-holdout3.txt")) {
|
*/
|
||||||
pool.addAll(BenchmarkFixtures.load(resource));
|
private static final double MAX_LEAK_RATE = 0.30;
|
||||||
|
|
||||||
|
private static final RuleRegistry REGISTRY = new RuleRegistry();
|
||||||
|
private static final Masker MASKER = new Masker();
|
||||||
|
private static final List<BenchmarkFixtures.Sample> POOL = loadPool();
|
||||||
|
|
||||||
|
private static List<BenchmarkFixtures.Sample> loadPool() {
|
||||||
|
List<BenchmarkFixtures.Sample> pool = new ArrayList<>();
|
||||||
|
for (String resource :
|
||||||
|
List.of(
|
||||||
|
"/benchmark.txt",
|
||||||
|
"/benchmark-generated.txt",
|
||||||
|
"/benchmark-pdn-types.txt",
|
||||||
|
"/benchmark-bank-context.txt",
|
||||||
|
"/benchmark-holdout.txt",
|
||||||
|
"/benchmark-holdout2.txt",
|
||||||
|
"/benchmark-holdout3.txt")) {
|
||||||
|
pool.addAll(BenchmarkFixtures.load(resource));
|
||||||
|
}
|
||||||
|
return pool;
|
||||||
|
}
|
||||||
|
|
||||||
|
@TestFactory
|
||||||
|
Stream<DynamicTest> datasetOfThousandCases() {
|
||||||
|
List<DynamicTest> cases = new ArrayList<>(TOTAL_CASES);
|
||||||
|
for (int i = 0; i < TOTAL_CASES; i++) {
|
||||||
|
int targetChars = targetChars(i);
|
||||||
|
int index = i;
|
||||||
|
cases.add(
|
||||||
|
dynamicTest(
|
||||||
|
String.format(
|
||||||
|
"#%04d, %d знаков (~%d токенов)",
|
||||||
|
index, targetChars, targetChars / CHARS_PER_TOKEN),
|
||||||
|
() -> runCase(targetChars, index)));
|
||||||
|
}
|
||||||
|
return cases.stream();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Длина растёт по логарифмической шкале от предложения до порога "огромного" текста — так тесты
|
||||||
|
* покрывают все порядки величины, а не только маленькие и не только большие. Последние {@link
|
||||||
|
* #HUGE_CASES} индексов — обязательная полоса 90-100к токенов из ТЗ.
|
||||||
|
*/
|
||||||
|
private static int targetChars(int index) {
|
||||||
|
int regular = TOTAL_CASES - HUGE_CASES;
|
||||||
|
if (index >= regular) {
|
||||||
|
int step = (HUGE_MAX_CHARS - HUGE_MIN_CHARS) / Math.max(1, HUGE_CASES - 1);
|
||||||
|
return HUGE_MIN_CHARS + (index - regular) * step;
|
||||||
|
}
|
||||||
|
double minChars = 80;
|
||||||
|
double maxChars = HUGE_MIN_CHARS - 1;
|
||||||
|
double ratio = (double) index / Math.max(1, regular - 1);
|
||||||
|
return (int) Math.round(minChars * Math.pow(maxChars / minChars, ratio));
|
||||||
|
}
|
||||||
|
|
||||||
|
private void runCase(int targetChars, int seed) {
|
||||||
|
Pipeline pipeline =
|
||||||
|
new Pipeline(REGISTRY, MASKER, new PayloadStore(30));
|
||||||
|
BenchmarkFixtures.Sample sample = buildText(targetChars, seed);
|
||||||
|
String payloadId = "dataset-" + seed;
|
||||||
|
|
||||||
|
long maskStarted = System.nanoTime();
|
||||||
|
String masked = pipeline.process(sample.text(), payloadId, SystemPolicy.DEFAULT);
|
||||||
|
long maskMillis = (System.nanoTime() - maskStarted) / 1_000_000;
|
||||||
|
|
||||||
|
int checked = 0;
|
||||||
|
int leaked = 0;
|
||||||
|
for (Span gold : sample.gold()) {
|
||||||
|
String value = sample.text().substring(gold.start(), gold.end());
|
||||||
|
if (value.length() >= LEAK_CHECK_MIN_LENGTH) {
|
||||||
|
checked++;
|
||||||
|
if (masked.contains(value)) {
|
||||||
|
leaked++;
|
||||||
}
|
}
|
||||||
return pool;
|
}
|
||||||
|
}
|
||||||
|
if (checked > 0) {
|
||||||
|
// На малых текстах пара пропусков — статистический шум, не деградация детектора:
|
||||||
|
// абсолютный запас на такие случаи не даёт доле "перевесить" маленький знаменатель.
|
||||||
|
int allowed = Math.max(4, (int) Math.ceil(checked * MAX_LEAK_RATE));
|
||||||
|
assertTrue(
|
||||||
|
leaked <= allowed,
|
||||||
|
String.format(
|
||||||
|
"утечка ПДН в замаскированном тексте: %d из %d, допустимо %d",
|
||||||
|
leaked, checked, allowed));
|
||||||
}
|
}
|
||||||
|
|
||||||
@TestFactory
|
String restored = pipeline.process(masked, payloadId, SystemPolicy.DEFAULT);
|
||||||
Stream<DynamicTest> datasetOfThousandCases() {
|
assertEquals(sample.text(), restored, "демаскирование не восстановило исходный текст");
|
||||||
List<DynamicTest> cases = new ArrayList<>(TOTAL_CASES);
|
|
||||||
for (int i = 0; i < TOTAL_CASES; i++) {
|
if (targetChars >= HUGE_MIN_CHARS) {
|
||||||
int targetChars = targetChars(i);
|
assertTrue(
|
||||||
int index = i;
|
maskMillis < 5000,
|
||||||
cases.add(dynamicTest(
|
"маскирование " + targetChars + " знаков заняло " + maskMillis + " мс");
|
||||||
String.format("#%04d, %d знаков (~%d токенов)", index, targetChars, targetChars / CHARS_PER_TOKEN),
|
|
||||||
() -> runCase(targetChars, index)));
|
|
||||||
}
|
|
||||||
return cases.stream();
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/** Перемешивает строки пула детерминированно по seed и склеивает до нужного объёма. */
|
||||||
* Длина растёт по логарифмической шкале от предложения до порога "огромного" текста —
|
private static BenchmarkFixtures.Sample buildText(int targetChars, long seed) {
|
||||||
* так тесты покрывают все порядки величины, а не только маленькие и не только большие.
|
List<BenchmarkFixtures.Sample> shuffled = new ArrayList<>(POOL);
|
||||||
* Последние {@link #HUGE_CASES} индексов — обязательная полоса 90-100к токенов из ТЗ.
|
Random random = new Random(seed);
|
||||||
*/
|
StringBuilder text = new StringBuilder(targetChars + 1024);
|
||||||
private static int targetChars(int index) {
|
List<Span> gold = new ArrayList<>();
|
||||||
int regular = TOTAL_CASES - HUGE_CASES;
|
|
||||||
if (index >= regular) {
|
while (text.length() < targetChars) {
|
||||||
int step = (HUGE_MAX_CHARS - HUGE_MIN_CHARS) / Math.max(1, HUGE_CASES - 1);
|
Collections.shuffle(shuffled, random);
|
||||||
return HUGE_MIN_CHARS + (index - regular) * step;
|
for (BenchmarkFixtures.Sample sample : shuffled) {
|
||||||
|
int offset = text.length();
|
||||||
|
text.append(sample.text()).append('\n');
|
||||||
|
for (Span span : sample.gold()) {
|
||||||
|
gold.add(new Span(span.start() + offset, span.end() + offset, span.type(), 0));
|
||||||
}
|
}
|
||||||
double minChars = 80;
|
if (text.length() >= targetChars) {
|
||||||
double maxChars = HUGE_MIN_CHARS - 1;
|
break;
|
||||||
double ratio = (double) index / Math.max(1, regular - 1);
|
|
||||||
return (int) Math.round(minChars * Math.pow(maxChars / minChars, ratio));
|
|
||||||
}
|
|
||||||
|
|
||||||
private void runCase(int targetChars, int seed) {
|
|
||||||
Pipeline pipeline = new Pipeline(REGISTRY, MASKER, new PayloadStore(targetChars * 2L + 4096, 30));
|
|
||||||
BenchmarkFixtures.Sample sample = buildText(targetChars, seed);
|
|
||||||
String payloadId = "dataset-" + seed;
|
|
||||||
|
|
||||||
long maskStarted = System.nanoTime();
|
|
||||||
String masked = pipeline.process(sample.text(), payloadId, SystemPolicy.DEFAULT);
|
|
||||||
long maskMillis = (System.nanoTime() - maskStarted) / 1_000_000;
|
|
||||||
|
|
||||||
int checked = 0;
|
|
||||||
int leaked = 0;
|
|
||||||
for (Span gold : sample.gold()) {
|
|
||||||
String value = sample.text().substring(gold.start(), gold.end());
|
|
||||||
if (value.length() >= LEAK_CHECK_MIN_LENGTH) {
|
|
||||||
checked++;
|
|
||||||
if (masked.contains(value)) {
|
|
||||||
leaked++;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
if (checked > 0) {
|
}
|
||||||
// На малых текстах пара пропусков — статистический шум, не деградация детектора:
|
|
||||||
// абсолютный запас на такие случаи не даёт доле "перевесить" маленький знаменатель.
|
|
||||||
int allowed = Math.max(4, (int) Math.ceil(checked * MAX_LEAK_RATE));
|
|
||||||
assertTrue(leaked <= allowed,
|
|
||||||
String.format("утечка ПДН в замаскированном тексте: %d из %d, допустимо %d",
|
|
||||||
leaked, checked, allowed));
|
|
||||||
}
|
|
||||||
|
|
||||||
String restored = pipeline.process(masked, payloadId, SystemPolicy.DEFAULT);
|
|
||||||
assertEquals(sample.text(), restored, "демаскирование не восстановило исходный текст");
|
|
||||||
|
|
||||||
if (targetChars >= HUGE_MIN_CHARS) {
|
|
||||||
assertTrue(maskMillis < 5000, "маскирование " + targetChars + " знаков заняло " + maskMillis + " мс");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Перемешивает строки пула детерминированно по seed и склеивает до нужного объёма. */
|
|
||||||
private static BenchmarkFixtures.Sample buildText(int targetChars, long seed) {
|
|
||||||
List<BenchmarkFixtures.Sample> shuffled = new ArrayList<>(POOL);
|
|
||||||
Random random = new Random(seed);
|
|
||||||
StringBuilder text = new StringBuilder(targetChars + 1024);
|
|
||||||
List<Span> gold = new ArrayList<>();
|
|
||||||
|
|
||||||
while (text.length() < targetChars) {
|
|
||||||
Collections.shuffle(shuffled, random);
|
|
||||||
for (BenchmarkFixtures.Sample sample : shuffled) {
|
|
||||||
int offset = text.length();
|
|
||||||
text.append(sample.text()).append('\n');
|
|
||||||
for (Span span : sample.gold()) {
|
|
||||||
gold.add(new Span(span.start() + offset, span.end() + offset, span.type(), 0));
|
|
||||||
}
|
|
||||||
if (text.length() >= targetChars) {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return new BenchmarkFixtures.Sample(text.toString(), gold);
|
|
||||||
}
|
}
|
||||||
|
return new BenchmarkFixtures.Sample(text.toString(), gold);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,9 @@
|
|||||||
package ru.pdguard;
|
package ru.pdguard;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||||
|
|
||||||
|
import java.util.UUID;
|
||||||
import org.junit.jupiter.api.Test;
|
import org.junit.jupiter.api.Test;
|
||||||
import ru.pdguard.config.SystemPolicy;
|
import ru.pdguard.config.SystemPolicy;
|
||||||
import ru.pdguard.core.PayloadStore;
|
import ru.pdguard.core.PayloadStore;
|
||||||
@@ -7,64 +11,62 @@ import ru.pdguard.core.Pipeline;
|
|||||||
import ru.pdguard.detect.RuleRegistry;
|
import ru.pdguard.detect.RuleRegistry;
|
||||||
import ru.pdguard.mask.Masker;
|
import ru.pdguard.mask.Masker;
|
||||||
|
|
||||||
import java.util.UUID;
|
|
||||||
|
|
||||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
|
||||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
|
||||||
|
|
||||||
/** Документы, удостоверяющие личность, помимо паспорта РФ. */
|
/** Документы, удостоверяющие личность, помимо паспорта РФ. */
|
||||||
class IdentityDocumentTest {
|
class IdentityDocumentTest {
|
||||||
|
|
||||||
private final Pipeline pipeline =
|
private final Pipeline pipeline =
|
||||||
new Pipeline(new RuleRegistry(), new Masker(), new PayloadStore(1_000_000L, 30));
|
new Pipeline(new RuleRegistry(), new Masker(), new PayloadStore(30));
|
||||||
|
|
||||||
private void assertHidden(String text, String secret) {
|
private void assertHidden(String text, String secret) {
|
||||||
String masked = pipeline.process(text, UUID.randomUUID().toString(), SystemPolicy.DEFAULT);
|
String masked = pipeline.process(text, UUID.randomUUID().toString(), SystemPolicy.DEFAULT);
|
||||||
assertFalse(masked.contains(secret), "не замаскировано: «" + secret + "» в ответе «" + masked + "»");
|
assertFalse(
|
||||||
}
|
masked.contains(secret), "не замаскировано: «" + secret + "» в ответе «" + masked + "»");
|
||||||
|
}
|
||||||
|
|
||||||
private void assertMasked(String text, String payloadId, String expected) {
|
private void assertMasked(String text, String payloadId, String expected) {
|
||||||
assertEquals(expected, pipeline.process(text, payloadId, SystemPolicy.DEFAULT));
|
assertEquals(expected, pipeline.process(text, payloadId, SystemPolicy.DEFAULT));
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void masksForeignPassport() {
|
void masksForeignPassport() {
|
||||||
assertHidden("Загранпаспорт 75 1234567 действителен до 2030 года", "75 1234567");
|
assertHidden("Загранпаспорт 75 1234567 действителен до 2030 года", "75 1234567");
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void masksMilitaryId() {
|
void masksMilitaryId() {
|
||||||
assertHidden("Военный билет АБ 1234567 предъявлен", "АБ 1234567");
|
assertHidden("Военный билет АБ 1234567 предъявлен", "АБ 1234567");
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void masksBirthCertificate() {
|
void masksBirthCertificate() {
|
||||||
assertHidden("Свидетельство о рождении II-МЮ № 123456", "II-МЮ № 123456");
|
assertHidden("Свидетельство о рождении II-МЮ № 123456", "II-МЮ № 123456");
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void masksMedicalPolicy() {
|
void masksMedicalPolicy() {
|
||||||
assertHidden("Полис ОМС 1234567890123456 оформлен", "1234567890123456");
|
assertHidden("Полис ОМС 1234567890123456 оформлен", "1234567890123456");
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* У загранпаспорта, военного билета и свидетельства о рождении серия короткая —
|
* У загранпаспорта, военного билета и свидетельства о рождении серия короткая — две цифры или две
|
||||||
* две цифры или две буквы. Открой маска первые два знака, серия была бы видна
|
* буквы. Открой маска первые два знака, серия была бы видна целиком, поэтому у этих документов
|
||||||
* целиком, поэтому у этих документов открыты только последние знаки номера.
|
* открыты только последние знаки номера.
|
||||||
*/
|
*/
|
||||||
@Test
|
@Test
|
||||||
void hidesShortDocumentSeriesCompletely() {
|
void hidesShortDocumentSeriesCompletely() {
|
||||||
assertMasked("Загранпаспорт 75 1234567", "fp-1", "Загранпаспорт ** *****67");
|
assertMasked("Загранпаспорт 75 1234567", "fp-1", "Загранпаспорт ** *****67");
|
||||||
assertMasked("Военный билет АБ 1234567", "mil-1", "Военный билет ** *****67");
|
assertMasked("Военный билет АБ 1234567", "mil-1", "Военный билет ** *****67");
|
||||||
assertMasked("Свидетельство о рождении II-МЮ № 123456", "bc-1",
|
assertMasked(
|
||||||
"Свидетельство о рождении **-** № ****56");
|
"Свидетельство о рождении II-МЮ № 123456",
|
||||||
}
|
"bc-1",
|
||||||
|
"Свидетельство о рождении **-** № ****56");
|
||||||
|
}
|
||||||
|
|
||||||
/** У паспорта РФ и водительского удостоверения серия из четырёх знаков — открыта половина. */
|
/** У паспорта РФ и водительского удостоверения серия из четырёх знаков — открыта половина. */
|
||||||
@Test
|
@Test
|
||||||
void keepsHalfOfFourCharacterSeries() {
|
void keepsHalfOfFourCharacterSeries() {
|
||||||
assertMasked("Паспорт 4509 123456", "rf-1", "Паспорт 45** ****56");
|
assertMasked("Паспорт 4509 123456", "rf-1", "Паспорт 45** ****56");
|
||||||
assertMasked("Водительское удостоверение 9902 123456", "dl-1",
|
assertMasked(
|
||||||
"Водительское удостоверение 99** ****56");
|
"Водительское удостоверение 9902 123456", "dl-1", "Водительское удостоверение 99** ****56");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,14 +1,7 @@
|
|||||||
package ru.pdguard;
|
package ru.pdguard;
|
||||||
|
|
||||||
import org.junit.jupiter.api.Test;
|
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||||
import ru.pdguard.config.SystemPolicy;
|
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||||
import ru.pdguard.core.PayloadStore;
|
|
||||||
import ru.pdguard.core.Pipeline;
|
|
||||||
import ru.pdguard.detect.Span;
|
|
||||||
import ru.pdguard.detect.NameCascade;
|
|
||||||
import ru.pdguard.detect.RuleRegistry;
|
|
||||||
import ru.pdguard.mask.Masker;
|
|
||||||
|
|
||||||
|
|
||||||
import java.nio.file.Files;
|
import java.nio.file.Files;
|
||||||
import java.nio.file.Path;
|
import java.nio.file.Path;
|
||||||
@@ -17,131 +10,143 @@ import java.util.Collections;
|
|||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
import java.util.Random;
|
import java.util.Random;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
import ru.pdguard.config.SystemPolicy;
|
||||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
import ru.pdguard.core.PayloadStore;
|
||||||
|
import ru.pdguard.core.Pipeline;
|
||||||
|
import ru.pdguard.detect.NameCascade;
|
||||||
|
import ru.pdguard.detect.RuleRegistry;
|
||||||
|
import ru.pdguard.detect.Span;
|
||||||
|
import ru.pdguard.mask.Masker;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Качество и скорость на большом тексте — не повторе одного и того же
|
* Качество и скорость на большом тексте — не повторе одного и того же предложения, а перемешанных
|
||||||
* предложения, а перемешанных строках из {@code benchmark-generated.txt}
|
* строках из {@code benchmark-generated.txt} (все типы ПД вперемешку с чистым текстом), растянутых
|
||||||
* (все типы ПД вперемешку с чистым текстом), растянутых до объёма из ТЗ
|
* до объёма из ТЗ (около 100 000 токенов, ~400 КБ по оценке из README).
|
||||||
* (около 100 000 токенов, ~400 КБ по оценке из README).
|
|
||||||
*
|
*
|
||||||
* <p>Раздутый повтором одной строки текст проверяет только то, что цикл не
|
* <p>Раздутый повтором одной строки текст проверяет только то, что цикл не падает на объёме: под
|
||||||
* падает на объёме: под маской всегда один и тот же тип, а остальные правила
|
* маской всегда один и тот же тип, а остальные правила не задействуются вовсе. Здесь размер и
|
||||||
* не задействуются вовсе. Здесь размер и разнообразие проверяются вместе.
|
* разнообразие проверяются вместе.
|
||||||
*/
|
*/
|
||||||
class LargeTextTest {
|
class LargeTextTest {
|
||||||
|
|
||||||
private static final String ENGINE = System.getProperty("bench.engine", "rubert");
|
private static final String ENGINE = System.getProperty("bench.engine", "rubert");
|
||||||
private static final String MODEL_PATH = System.getProperty("bench.model", "models/rubert-ner");
|
private static final String MODEL_PATH = System.getProperty("bench.model", "models/rubert-ner");
|
||||||
|
|
||||||
/** Целевой объём: README оценивает 100 000 токенов как ~400 КБ текста. */
|
/** Целевой объём: README оценивает 100 000 токенов как ~400 КБ текста. */
|
||||||
private static final int TARGET_CHARS = 400_000;
|
private static final int TARGET_CHARS = 400_000;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Перемешивает исходные строки (фиксированный seed — детерминированный
|
* Перемешивает исходные строки (фиксированный seed — детерминированный тест) и склеивает их через
|
||||||
* тест) и склеивает их через перенос строки, пока не наберётся целевой
|
* перенос строки, пока не наберётся целевой объём. Смещения золотых фрагментов пересчитываются
|
||||||
* объём. Смещения золотых фрагментов пересчитываются под общий текст.
|
* под общий текст.
|
||||||
*/
|
*/
|
||||||
private static BenchmarkFixtures.Sample buildLargeText(int targetChars, long seed) {
|
private static BenchmarkFixtures.Sample buildLargeText(int targetChars, long seed) {
|
||||||
List<BenchmarkFixtures.Sample> pool = new ArrayList<>(BenchmarkFixtures.load("/benchmark-generated.txt"));
|
List<BenchmarkFixtures.Sample> pool =
|
||||||
Random random = new Random(seed);
|
new ArrayList<>(BenchmarkFixtures.load("/benchmark-generated.txt"));
|
||||||
StringBuilder text = new StringBuilder(targetChars + 1024);
|
Random random = new Random(seed);
|
||||||
List<Span> gold = new ArrayList<>();
|
StringBuilder text = new StringBuilder(targetChars + 1024);
|
||||||
|
List<Span> gold = new ArrayList<>();
|
||||||
|
|
||||||
while (text.length() < targetChars) {
|
while (text.length() < targetChars) {
|
||||||
Collections.shuffle(pool, random);
|
Collections.shuffle(pool, random);
|
||||||
for (BenchmarkFixtures.Sample sample : pool) {
|
for (BenchmarkFixtures.Sample sample : pool) {
|
||||||
int offset = text.length();
|
int offset = text.length();
|
||||||
text.append(sample.text()).append('\n');
|
text.append(sample.text()).append('\n');
|
||||||
for (Span span : sample.gold()) {
|
for (Span span : sample.gold()) {
|
||||||
gold.add(new Span(span.start() + offset, span.end() + offset, span.type(), 0));
|
gold.add(new Span(span.start() + offset, span.end() + offset, span.type(), 0));
|
||||||
}
|
|
||||||
if (text.length() >= targetChars) {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
return new BenchmarkFixtures.Sample(text.toString(), gold);
|
if (text.length() >= targetChars) {
|
||||||
}
|
break;
|
||||||
|
|
||||||
/**
|
|
||||||
* Маскирование и обратное преобразование на большом тексте дают
|
|
||||||
* побайтово тот же результат, что и исходный текст — при объёме на
|
|
||||||
* порядок больше, чем в остальных тестах, и с разнородным содержимым,
|
|
||||||
* а не одним повторяющимся предложением.
|
|
||||||
*/
|
|
||||||
@Test
|
|
||||||
void roundTripOnLargeMixedText() {
|
|
||||||
BenchmarkFixtures.Sample large = buildLargeText(TARGET_CHARS, 1);
|
|
||||||
Pipeline pipeline = new Pipeline(new RuleRegistry(), new Masker(),
|
|
||||||
new PayloadStore(large.text().length() * 2L, 30));
|
|
||||||
|
|
||||||
long maskStarted = System.nanoTime();
|
|
||||||
String masked = pipeline.process(large.text(), "large-mixed-1", SystemPolicy.DEFAULT);
|
|
||||||
long maskMillis = (System.nanoTime() - maskStarted) / 1_000_000;
|
|
||||||
|
|
||||||
long unmaskStarted = System.nanoTime();
|
|
||||||
String restored = pipeline.process(masked, "large-mixed-1", SystemPolicy.DEFAULT);
|
|
||||||
long unmaskMillis = (System.nanoTime() - unmaskStarted) / 1_000_000;
|
|
||||||
|
|
||||||
assertEquals(large.text(), restored, "демаскирование не восстановило исходный текст");
|
|
||||||
assertTrue(maskMillis < 5000, "маскирование " + large.text().length() + " знаков заняло " + maskMillis + " мс");
|
|
||||||
assertTrue(unmaskMillis < 1000, "демаскирование заняло " + unmaskMillis + " мс");
|
|
||||||
|
|
||||||
System.out.printf("%nБольшой текст: %d знаков, маскирование %d мс, демаскирование %d мс%n",
|
|
||||||
large.text().length(), maskMillis, unmaskMillis);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Полнота детекции не должна проседать на объёме: каждый золотой
|
|
||||||
* фрагмент из перемешанных строк обязан быть найден в общем потоке
|
|
||||||
* текста, а не только когда он единственный в маленькой строке.
|
|
||||||
*/
|
|
||||||
@Test
|
|
||||||
void recallHoldsAtScale() {
|
|
||||||
BenchmarkFixtures.Sample large = buildLargeText(TARGET_CHARS, 2);
|
|
||||||
Pipeline pipeline = new Pipeline(new RuleRegistry(), new Masker(), new PayloadStore(1L, 30));
|
|
||||||
|
|
||||||
List<Span> found = pipeline.findPersonalData(large.text(), SystemPolicy.DEFAULT);
|
|
||||||
int hit = 0;
|
|
||||||
for (Span gold : large.gold()) {
|
|
||||||
if (found.stream().anyMatch(f -> f.type().equals(gold.type()) && f.overlaps(gold))) {
|
|
||||||
hit++;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
double recall = large.gold().isEmpty() ? 1.0 : (double) hit / large.gold().size();
|
}
|
||||||
System.out.printf("%nПолнота на большом тексте: %d из %d (%.3f)%n", hit, large.gold().size(), recall);
|
|
||||||
|
|
||||||
assertTrue(recall >= 0.85,
|
|
||||||
String.format("полнота на большом тексте упала до %.3f (%d/%d)", recall, hit, large.gold().size()));
|
|
||||||
}
|
}
|
||||||
|
return new BenchmarkFixtures.Sample(text.toString(), gold);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Вторая ступень ограничена числом кандидатов на запрос
|
* Маскирование и обратное преобразование на большом тексте дают побайтово тот же результат, что и
|
||||||
* ({@code pdguard.ner.max-candidates}), поэтому объём текста не должен
|
* исходный текст — при объёме на порядок больше, чем в остальных тестах, и с разнородным
|
||||||
* превращать её в квадратичную нагрузку — проверяем на том же большом
|
* содержимым, а не одним повторяющимся предложением.
|
||||||
* тексте, что и остальные тесты, а не на маленьком образце.
|
*/
|
||||||
*/
|
@Test
|
||||||
@Test
|
void roundTripOnLargeMixedText() {
|
||||||
void nameCascadeStaysBoundedOnLargeText() {
|
BenchmarkFixtures.Sample large = buildLargeText(TARGET_CHARS, 1);
|
||||||
Path model = Path.of(MODEL_PATH);
|
Pipeline pipeline =
|
||||||
if (!Files.isReadable(model)) {
|
new Pipeline(
|
||||||
System.out.println("Модель " + model.toAbsolutePath() + " не собрана, пропускаю");
|
new RuleRegistry(), new Masker(), new PayloadStore(30));
|
||||||
return;
|
|
||||||
}
|
|
||||||
BenchmarkFixtures.Sample large = buildLargeText(TARGET_CHARS, 3);
|
|
||||||
Pipeline pipeline = new Pipeline(new RuleRegistry(), new Masker(),
|
|
||||||
new PayloadStore(large.text().length() * 2L, 30),
|
|
||||||
new NameCascade(ENGINE, Optional.of(MODEL_PATH), 16, 4));
|
|
||||||
|
|
||||||
long started = System.nanoTime();
|
long maskStarted = System.nanoTime();
|
||||||
pipeline.process(large.text(), "large-cascade-1", SystemPolicy.DEFAULT);
|
String masked = pipeline.process(large.text(), "large-mixed-1", SystemPolicy.DEFAULT);
|
||||||
long millis = (System.nanoTime() - started) / 1_000_000;
|
long maskMillis = (System.nanoTime() - maskStarted) / 1_000_000;
|
||||||
|
|
||||||
System.out.printf("%nБольшой текст со второй ступенью: %d знаков за %d мс%n",
|
long unmaskStarted = System.nanoTime();
|
||||||
large.text().length(), millis);
|
String restored = pipeline.process(masked, "large-mixed-1", SystemPolicy.DEFAULT);
|
||||||
assertTrue(millis < 5000, "со второй ступенью обработка заняла " + millis + " мс");
|
long unmaskMillis = (System.nanoTime() - unmaskStarted) / 1_000_000;
|
||||||
|
|
||||||
|
assertEquals(large.text(), restored, "демаскирование не восстановило исходный текст");
|
||||||
|
assertTrue(
|
||||||
|
maskMillis < 5000,
|
||||||
|
"маскирование " + large.text().length() + " знаков заняло " + maskMillis + " мс");
|
||||||
|
assertTrue(unmaskMillis < 1000, "демаскирование заняло " + unmaskMillis + " мс");
|
||||||
|
|
||||||
|
System.out.printf(
|
||||||
|
"%nБольшой текст: %d знаков, маскирование %d мс, демаскирование %d мс%n",
|
||||||
|
large.text().length(), maskMillis, unmaskMillis);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Полнота детекции не должна проседать на объёме: каждый золотой фрагмент из перемешанных строк
|
||||||
|
* обязан быть найден в общем потоке текста, а не только когда он единственный в маленькой строке.
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
void recallHoldsAtScale() {
|
||||||
|
BenchmarkFixtures.Sample large = buildLargeText(TARGET_CHARS, 2);
|
||||||
|
Pipeline pipeline = new Pipeline(new RuleRegistry(), new Masker(), new PayloadStore(30));
|
||||||
|
|
||||||
|
List<Span> found = pipeline.findPersonalData(large.text(), SystemPolicy.DEFAULT);
|
||||||
|
int hit = 0;
|
||||||
|
for (Span gold : large.gold()) {
|
||||||
|
if (found.stream().anyMatch(f -> f.type().equals(gold.type()) && f.overlaps(gold))) {
|
||||||
|
hit++;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
double recall = large.gold().isEmpty() ? 1.0 : (double) hit / large.gold().size();
|
||||||
|
System.out.printf(
|
||||||
|
"%nПолнота на большом тексте: %d из %d (%.3f)%n", hit, large.gold().size(), recall);
|
||||||
|
|
||||||
|
assertTrue(
|
||||||
|
recall >= 0.85,
|
||||||
|
String.format(
|
||||||
|
"полнота на большом тексте упала до %.3f (%d/%d)", recall, hit, large.gold().size()));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Вторая ступень ограничена числом кандидатов на запрос ({@code pdguard.ner.max-candidates}),
|
||||||
|
* поэтому объём текста не должен превращать её в квадратичную нагрузку — проверяем на том же
|
||||||
|
* большом тексте, что и остальные тесты, а не на маленьком образце.
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
void nameCascadeStaysBoundedOnLargeText() {
|
||||||
|
Path model = Path.of(MODEL_PATH);
|
||||||
|
if (!Files.isReadable(model)) {
|
||||||
|
System.out.println("Модель " + model.toAbsolutePath() + " не собрана, пропускаю");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
BenchmarkFixtures.Sample large = buildLargeText(TARGET_CHARS, 3);
|
||||||
|
Pipeline pipeline =
|
||||||
|
new Pipeline(
|
||||||
|
new RuleRegistry(),
|
||||||
|
new Masker(),
|
||||||
|
new PayloadStore(30),
|
||||||
|
new NameCascade(ENGINE, Optional.of(MODEL_PATH), 16, 4));
|
||||||
|
|
||||||
|
long started = System.nanoTime();
|
||||||
|
pipeline.process(large.text(), "large-cascade-1", SystemPolicy.DEFAULT);
|
||||||
|
long millis = (System.nanoTime() - started) / 1_000_000;
|
||||||
|
|
||||||
|
System.out.printf(
|
||||||
|
"%nБольшой текст со второй ступенью: %d знаков за %d мс%n", large.text().length(), millis);
|
||||||
|
assertTrue(millis < 5000, "со второй ступенью обработка заняла " + millis + " мс");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,14 @@
|
|||||||
package ru.pdguard;
|
package ru.pdguard;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||||
|
|
||||||
|
import java.io.BufferedReader;
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.io.InputStream;
|
||||||
|
import java.io.InputStreamReader;
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
import org.junit.jupiter.api.Test;
|
import org.junit.jupiter.api.Test;
|
||||||
import ru.pdguard.config.SystemPolicy;
|
import ru.pdguard.config.SystemPolicy;
|
||||||
import ru.pdguard.core.PayloadStore;
|
import ru.pdguard.core.PayloadStore;
|
||||||
@@ -9,73 +18,79 @@ import ru.pdguard.detect.RuleRegistry;
|
|||||||
import ru.pdguard.detect.Span;
|
import ru.pdguard.detect.Span;
|
||||||
import ru.pdguard.mask.Masker;
|
import ru.pdguard.mask.Masker;
|
||||||
|
|
||||||
import java.io.BufferedReader;
|
|
||||||
import java.io.IOException;
|
|
||||||
import java.io.InputStream;
|
|
||||||
import java.io.InputStreamReader;
|
|
||||||
import java.nio.charset.StandardCharsets;
|
|
||||||
import java.util.ArrayList;
|
|
||||||
import java.util.List;
|
|
||||||
|
|
||||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Проверка утечек из датасета {@code leak-dataset.txt}.
|
* Проверка утечек из датасета {@code leak-dataset.txt}.
|
||||||
*
|
*
|
||||||
* <p>Датасет собран из логов pd-guard-node-logs.txt: это уникальные тексты, в
|
* <p>Датасет собран из логов pd-guard-node-logs.txt: это уникальные тексты, в которых узел не нашёл
|
||||||
* которых узел не нашёл ПД ({@code найдено={}}), хотя маркер персональных данных
|
* ПД ({@code найдено={}}), хотя маркер персональных данных в тексте есть. Тест прогоняет каждый
|
||||||
* в тексте есть. Тест прогоняет каждый текст через {@link Pipeline} и требует,
|
* текст через {@link Pipeline} и требует, чтобы детекция нашла хотя бы одно ПД из перечня типов.
|
||||||
* чтобы детекция нашла хотя бы одно ПД из перечня типов.
|
|
||||||
*/
|
*/
|
||||||
class LeakDiagTest {
|
class LeakDiagTest {
|
||||||
|
|
||||||
private static final String DATASET = "/leak-dataset.txt";
|
private static final String DATASET = "/leak-dataset.txt";
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void checkLeaks() {
|
void checkLeaks() {
|
||||||
Pipeline p = new Pipeline(new RuleRegistry(), new Masker(), new PayloadStore(1_000_000L, 30));
|
Pipeline p = new Pipeline(new RuleRegistry(), new Masker(), new PayloadStore(30));
|
||||||
List<String> leaks = readDataset();
|
List<String> leaks = readDataset();
|
||||||
|
|
||||||
int fixed = 0;
|
int fixed = 0;
|
||||||
List<String> remaining = new ArrayList<>();
|
List<String> remaining = new ArrayList<>();
|
||||||
for (String raw : leaks) {
|
for (String raw : leaks) {
|
||||||
List<Span> spans = p.findPersonalData(raw, SystemPolicy.DEFAULT);
|
List<Span> spans = p.findPersonalData(raw, SystemPolicy.DEFAULT);
|
||||||
boolean found = spans.stream().anyMatch(s -> s.type().equals(PdTypes.FIO)
|
boolean found =
|
||||||
|| s.type().equals(PdTypes.BIRTH_DATE) || s.type().equals(PdTypes.PASSPORT_DATE)
|
spans.stream()
|
||||||
|| s.type().equals(PdTypes.CVV) || s.type().equals(PdTypes.PIN)
|
.anyMatch(
|
||||||
|| s.type().equals(PdTypes.INN) || s.type().equals(PdTypes.PHONE)
|
s ->
|
||||||
|| s.type().equals(PdTypes.CARD) || s.type().equals(PdTypes.DRIVER_LICENSE)
|
s.type().equals(PdTypes.FIO)
|
||||||
|| s.type().equals(PdTypes.CITIZENSHIP) || s.type().equals(PdTypes.BIRTH_PLACE));
|
|| s.type().equals(PdTypes.BIRTH_DATE)
|
||||||
if (found) {
|
|| s.type().equals(PdTypes.PASSPORT_DATE)
|
||||||
fixed++;
|
|| s.type().equals(PdTypes.CVV)
|
||||||
} else {
|
|| s.type().equals(PdTypes.PIN)
|
||||||
remaining.add(raw);
|
|| s.type().equals(PdTypes.INN)
|
||||||
}
|
|| s.type().equals(PdTypes.PHONE)
|
||||||
}
|
|| s.type().equals(PdTypes.CARD)
|
||||||
System.out.println("Всего утечек: " + leaks.size() + ", исправлено: " + fixed + ", осталось: " + remaining.size());
|
|| s.type().equals(PdTypes.DRIVER_LICENSE)
|
||||||
for (String raw : remaining) {
|
|| s.type().equals(PdTypes.CITIZENSHIP)
|
||||||
System.out.println(" ОСТАЛОСЬ: " + raw);
|
|| s.type().equals(PdTypes.BIRTH_PLACE));
|
||||||
}
|
if (found) {
|
||||||
assertTrue(remaining.size() <= leaks.size() / 2, "осталось слишком много утечек: " + remaining.size());
|
fixed++;
|
||||||
|
} else {
|
||||||
|
remaining.add(raw);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
System.out.println(
|
||||||
private static List<String> readDataset() {
|
"Всего утечек: "
|
||||||
List<String> lines = new ArrayList<>();
|
+ leaks.size()
|
||||||
try (InputStream in = LeakDiagTest.class.getResourceAsStream(DATASET)) {
|
+ ", исправлено: "
|
||||||
if (in == null) {
|
+ fixed
|
||||||
throw new IllegalStateException("Датасет не найден в сборке: " + DATASET);
|
+ ", осталось: "
|
||||||
}
|
+ remaining.size());
|
||||||
try (BufferedReader r = new BufferedReader(new InputStreamReader(in, StandardCharsets.UTF_8))) {
|
for (String raw : remaining) {
|
||||||
String line;
|
System.out.println(" ОСТАЛОСЬ: " + raw);
|
||||||
while ((line = r.readLine()) != null) {
|
|
||||||
if (!line.isBlank()) {
|
|
||||||
lines.add(line);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (IOException e) {
|
|
||||||
throw new IllegalStateException(e);
|
|
||||||
}
|
|
||||||
return lines;
|
|
||||||
}
|
}
|
||||||
}
|
assertTrue(
|
||||||
|
remaining.size() <= leaks.size() / 2, "осталось слишком много утечек: " + remaining.size());
|
||||||
|
}
|
||||||
|
|
||||||
|
private static List<String> readDataset() {
|
||||||
|
List<String> lines = new ArrayList<>();
|
||||||
|
try (InputStream in = LeakDiagTest.class.getResourceAsStream(DATASET)) {
|
||||||
|
if (in == null) {
|
||||||
|
throw new IllegalStateException("Датасет не найден в сборке: " + DATASET);
|
||||||
|
}
|
||||||
|
try (BufferedReader r =
|
||||||
|
new BufferedReader(new InputStreamReader(in, StandardCharsets.UTF_8))) {
|
||||||
|
String line;
|
||||||
|
while ((line = r.readLine()) != null) {
|
||||||
|
if (!line.isBlank()) {
|
||||||
|
lines.add(line);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (IOException e) {
|
||||||
|
throw new IllegalStateException(e);
|
||||||
|
}
|
||||||
|
return lines;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,57 @@
|
|||||||
|
package ru.pdguard;
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import ru.pdguard.config.SystemPolicy;
|
||||||
|
import ru.pdguard.core.PayloadStore;
|
||||||
|
import ru.pdguard.core.Pipeline;
|
||||||
|
import ru.pdguard.detect.NameCascade;
|
||||||
|
import ru.pdguard.detect.PdTypes;
|
||||||
|
import ru.pdguard.detect.RuleRegistry;
|
||||||
|
import ru.pdguard.detect.Span;
|
||||||
|
import ru.pdguard.mask.Masker;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Optional;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||||
|
|
||||||
|
/** LLAIM Legal NER: юридические реквизиты и документы, которых нет в общих моделях. */
|
||||||
|
class LegalNerTest {
|
||||||
|
|
||||||
|
private List<Span> find(String text) {
|
||||||
|
NameCascade cascade = new NameCascade(
|
||||||
|
new NameCascade.EngineConfig(
|
||||||
|
"off", Optional.empty(),
|
||||||
|
"off", Optional.empty(),
|
||||||
|
"ru-legal-ner", Optional.of("models/ru-legal-ner")),
|
||||||
|
16, 4);
|
||||||
|
Pipeline p = new Pipeline(new RuleRegistry(), new Masker(), new PayloadStore(30), cascade);
|
||||||
|
return p.findPersonalData(text, SystemPolicy.DEFAULT);
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean hasType(List<Span> spans, String type) {
|
||||||
|
return spans.stream().anyMatch(s -> s.type().equals(type));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void recognisesInn() {
|
||||||
|
String text = "Договор между ООО «Ромашка», ИНН 7701234567, и Ивановым Иваном Ивановичем.";
|
||||||
|
List<Span> spans = find(text);
|
||||||
|
System.out.println("TEXT: " + text);
|
||||||
|
for (Span s : spans) {
|
||||||
|
System.out.println(" -> " + s.type() + " [" + text.substring(s.start(), s.end()) + "]");
|
||||||
|
}
|
||||||
|
assertTrue(hasType(spans, PdTypes.INN), "должно найти ИНН");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void recognisesPassport() {
|
||||||
|
String text = "Паспорт 4509 123456 выдан ОВД, СНИЛС 112-233-445 95.";
|
||||||
|
List<Span> spans = find(text);
|
||||||
|
System.out.println("TEXT: " + text);
|
||||||
|
for (Span s : spans) {
|
||||||
|
System.out.println(" -> " + s.type() + " [" + text.substring(s.start(), s.end()) + "]");
|
||||||
|
}
|
||||||
|
assertTrue(hasType(spans, PdTypes.PASSPORT), "должно найти паспорт");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,13 @@
|
|||||||
package ru.pdguard;
|
package ru.pdguard;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||||
|
|
||||||
|
import java.util.Set;
|
||||||
|
import java.util.UUID;
|
||||||
|
import java.util.regex.Matcher;
|
||||||
|
import java.util.regex.Pattern;
|
||||||
import org.junit.jupiter.api.Test;
|
import org.junit.jupiter.api.Test;
|
||||||
import ru.pdguard.config.SystemPolicy;
|
import ru.pdguard.config.SystemPolicy;
|
||||||
import ru.pdguard.core.PayloadStore;
|
import ru.pdguard.core.PayloadStore;
|
||||||
@@ -9,78 +17,92 @@ import ru.pdguard.detect.Validators;
|
|||||||
import ru.pdguard.mask.MaskMode;
|
import ru.pdguard.mask.MaskMode;
|
||||||
import ru.pdguard.mask.Masker;
|
import ru.pdguard.mask.Masker;
|
||||||
|
|
||||||
import java.util.Set;
|
|
||||||
import java.util.UUID;
|
|
||||||
import java.util.regex.Matcher;
|
|
||||||
import java.util.regex.Pattern;
|
|
||||||
|
|
||||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
|
||||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
|
||||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
|
||||||
|
|
||||||
/** Виды замены: звёздочки, токены, правдоподобные значения. */
|
/** Виды замены: звёздочки, токены, правдоподобные значения. */
|
||||||
class MaskModeTest {
|
class MaskModeTest {
|
||||||
|
|
||||||
private final Pipeline pipeline =
|
private final Pipeline pipeline =
|
||||||
new Pipeline(new RuleRegistry(), new Masker(), new PayloadStore(1_000_000L, 30));
|
new Pipeline(new RuleRegistry(), new Masker(), new PayloadStore(30));
|
||||||
|
|
||||||
private SystemPolicy policy(MaskMode mode) {
|
private SystemPolicy policy(MaskMode mode) {
|
||||||
return new SystemPolicy(SystemPolicy.DEFAULT_NAME, true, true, mode,
|
return new SystemPolicy(
|
||||||
Set.of(SystemPolicy.ALL), SystemPolicy.DEFAULT.requireCompanion(), null);
|
SystemPolicy.DEFAULT_NAME,
|
||||||
|
true,
|
||||||
|
true,
|
||||||
|
mode,
|
||||||
|
Set.of(SystemPolicy.ALL),
|
||||||
|
SystemPolicy.DEFAULT.requireCompanion(),
|
||||||
|
null);
|
||||||
|
}
|
||||||
|
|
||||||
|
private String mask(MaskMode mode, String text) {
|
||||||
|
return pipeline.process(text, UUID.randomUUID().toString(), policy(mode));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void strictModeHidesEverythingIncludingFio() {
|
||||||
|
String masked = mask(MaskMode.STRICT, "Клиент Иванов Иван Иванович, паспорт 4509 123456");
|
||||||
|
|
||||||
|
assertFalse(masked.contains("Иванов"), masked);
|
||||||
|
assertFalse(
|
||||||
|
masked.contains("И. И. И."), "STRICT не должен превращать ФИО в инициалы: " + masked);
|
||||||
|
assertTrue(
|
||||||
|
masked.contains("****** **** ********"),
|
||||||
|
"ожидались звёздочки по длине каждого слова: " + masked);
|
||||||
|
assertTrue(masked.contains("**** ******"), "край паспорта не должен открываться: " + masked);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void tokenModeNumbersEachType() {
|
||||||
|
String masked = mask(MaskMode.TOKEN, "Клиент Иванов Иван Иванович, почта ivan@mail.ru");
|
||||||
|
assertTrue(masked.contains("[FIO_1]"), masked);
|
||||||
|
assertTrue(masked.contains("[EMAIL_1]"), masked);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void sameValueGetsSameTokenWithinRequest() {
|
||||||
|
String masked =
|
||||||
|
mask(MaskMode.TOKEN, "ivan@mail.ru и ещё раз ivan@mail.ru, а также petr@mail.ru");
|
||||||
|
assertEquals(2, count(masked, "[EMAIL_1]"), masked);
|
||||||
|
assertEquals(1, count(masked, "[EMAIL_2]"), masked);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void syntheticModeProducesPlausibleValues() {
|
||||||
|
String masked = mask(MaskMode.SYNTHETIC, "Карта 4111 1111 1111 1111 клиента Иванова Ивана");
|
||||||
|
|
||||||
|
assertFalse(masked.contains("4111 1111 1111 1111"), masked);
|
||||||
|
assertFalse(masked.contains("*"), "подстановка должна выглядеть настоящей: " + masked);
|
||||||
|
|
||||||
|
Matcher card = Pattern.compile("\\d{4} \\d{4} \\d{4} \\d{4}").matcher(masked);
|
||||||
|
assertTrue(card.find(), masked);
|
||||||
|
assertTrue(
|
||||||
|
Validators.luhn(card.group()), "подставленный номер карты обязан проходить проверку Луна");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void syntheticValuesAreStable() {
|
||||||
|
String text = "Почта ivan@mail.ru, паспорт 4509 123456";
|
||||||
|
assertEquals(mask(MaskMode.SYNTHETIC, text), mask(MaskMode.SYNTHETIC, text));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void unmaskingWorksInEveryMode() {
|
||||||
|
String original = "Клиент Иванов Иван Иванович, карта 4111 1111 1111 1111, почта ivan@mail.ru";
|
||||||
|
for (MaskMode mode : MaskMode.values()) {
|
||||||
|
String id = "mode-" + mode;
|
||||||
|
String masked = pipeline.process(original, id, policy(mode));
|
||||||
|
assertFalse(masked.contains("Иванов Иван Иванович"), mode + ": " + masked);
|
||||||
|
assertEquals(original, pipeline.process(masked, id, policy(mode)), mode.name());
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private String mask(MaskMode mode, String text) {
|
private static int count(String text, String fragment) {
|
||||||
return pipeline.process(text, UUID.randomUUID().toString(), policy(mode));
|
int n = 0;
|
||||||
}
|
for (int i = text.indexOf(fragment);
|
||||||
|
i >= 0;
|
||||||
@Test
|
i = text.indexOf(fragment, i + fragment.length())) {
|
||||||
void tokenModeNumbersEachType() {
|
n++;
|
||||||
String masked = mask(MaskMode.TOKEN, "Клиент Иванов Иван Иванович, почта ivan@mail.ru");
|
|
||||||
assertTrue(masked.contains("[FIO_1]"), masked);
|
|
||||||
assertTrue(masked.contains("[EMAIL_1]"), masked);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void sameValueGetsSameTokenWithinRequest() {
|
|
||||||
String masked = mask(MaskMode.TOKEN, "ivan@mail.ru и ещё раз ivan@mail.ru, а также petr@mail.ru");
|
|
||||||
assertEquals(2, count(masked, "[EMAIL_1]"), masked);
|
|
||||||
assertEquals(1, count(masked, "[EMAIL_2]"), masked);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void syntheticModeProducesPlausibleValues() {
|
|
||||||
String masked = mask(MaskMode.SYNTHETIC, "Карта 4111 1111 1111 1111 клиента Иванова Ивана");
|
|
||||||
|
|
||||||
assertFalse(masked.contains("4111 1111 1111 1111"), masked);
|
|
||||||
assertFalse(masked.contains("*"), "подстановка должна выглядеть настоящей: " + masked);
|
|
||||||
|
|
||||||
Matcher card = Pattern.compile("\\d{4} \\d{4} \\d{4} \\d{4}").matcher(masked);
|
|
||||||
assertTrue(card.find(), masked);
|
|
||||||
assertTrue(Validators.luhn(card.group()), "подставленный номер карты обязан проходить проверку Луна");
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void syntheticValuesAreStable() {
|
|
||||||
String text = "Почта ivan@mail.ru, паспорт 4509 123456";
|
|
||||||
assertEquals(mask(MaskMode.SYNTHETIC, text), mask(MaskMode.SYNTHETIC, text));
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void unmaskingWorksInEveryMode() {
|
|
||||||
String original = "Клиент Иванов Иван Иванович, карта 4111 1111 1111 1111, почта ivan@mail.ru";
|
|
||||||
for (MaskMode mode : MaskMode.values()) {
|
|
||||||
String id = "mode-" + mode;
|
|
||||||
String masked = pipeline.process(original, id, policy(mode));
|
|
||||||
assertFalse(masked.contains("Иванов Иван Иванович"), mode + ": " + masked);
|
|
||||||
assertEquals(original, pipeline.process(masked, id, policy(mode)), mode.name());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private static int count(String text, String fragment) {
|
|
||||||
int n = 0;
|
|
||||||
for (int i = text.indexOf(fragment); i >= 0; i = text.indexOf(fragment, i + fragment.length())) {
|
|
||||||
n++;
|
|
||||||
}
|
|
||||||
return n;
|
|
||||||
}
|
}
|
||||||
|
return n;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,13 @@
|
|||||||
package ru.pdguard;
|
package ru.pdguard;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import java.nio.file.Files;
|
||||||
|
import java.nio.file.Path;
|
||||||
|
import java.util.Optional;
|
||||||
import org.junit.jupiter.api.Test;
|
import org.junit.jupiter.api.Test;
|
||||||
import org.junit.jupiter.api.io.TempDir;
|
import org.junit.jupiter.api.io.TempDir;
|
||||||
import ru.pdguard.config.SystemPolicy;
|
import ru.pdguard.config.SystemPolicy;
|
||||||
@@ -9,51 +17,46 @@ import ru.pdguard.detect.NameCascade;
|
|||||||
import ru.pdguard.detect.RuleRegistry;
|
import ru.pdguard.detect.RuleRegistry;
|
||||||
import ru.pdguard.mask.Masker;
|
import ru.pdguard.mask.Masker;
|
||||||
|
|
||||||
import java.io.IOException;
|
|
||||||
import java.nio.charset.StandardCharsets;
|
|
||||||
import java.nio.file.Files;
|
|
||||||
import java.nio.file.Path;
|
|
||||||
import java.util.Optional;
|
|
||||||
|
|
||||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
|
||||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
|
||||||
|
|
||||||
/** Вторая ступень не должна вредить первой. */
|
/** Вторая ступень не должна вредить первой. */
|
||||||
class NameCascadeTest {
|
class NameCascadeTest {
|
||||||
|
|
||||||
private static final String TEXT = "Клиент Иванов Иван Иванович, паспорт 4509 123456";
|
private static final String TEXT = "Клиент Иванов Иван Иванович, паспорт 4509 123456";
|
||||||
|
|
||||||
private String mask(NameCascade cascade, String payloadId) {
|
private String mask(NameCascade cascade, String payloadId) {
|
||||||
Pipeline pipeline = new Pipeline(new RuleRegistry(), new Masker(),
|
Pipeline pipeline =
|
||||||
new PayloadStore(1_000_000L, 30), cascade);
|
new Pipeline(new RuleRegistry(), new Masker(), new PayloadStore(30), cascade);
|
||||||
return pipeline.process(TEXT, payloadId, SystemPolicy.DEFAULT);
|
return pipeline.process(TEXT, payloadId, SystemPolicy.DEFAULT);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void withoutModelTheStageIsOff() {
|
||||||
|
NameCascade cascade = NameCascade.disabled();
|
||||||
|
assertFalse(cascade.enabled());
|
||||||
|
assertEquals("Клиент И. И. И., паспорт 45** ****56", mask(cascade, "off-1"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void missingModelFileDoesNotBreakMasking(@TempDir Path dir) {
|
||||||
|
NameCascade cascade =
|
||||||
|
new NameCascade(
|
||||||
|
"rubert", Optional.of(dir.resolve("нет-такого-каталога").toString()), 16, 4);
|
||||||
|
assertFalse(cascade.enabled(), "отсутствующая модель должна выключать ступень");
|
||||||
|
assertEquals("Клиент И. И. И., паспорт 45** ****56", mask(cascade, "missing-1"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void brokenModelFileDoesNotBreakMasking(@TempDir Path dir) throws IOException {
|
||||||
|
Path broken = dir.resolve("испорченная-модель");
|
||||||
|
Files.createDirectories(broken);
|
||||||
|
for (String name : new String[] {"model_int8.onnx", "vocab.txt", "config.json"}) {
|
||||||
|
Files.writeString(broken.resolve(name), "это не модель", StandardCharsets.UTF_8);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
NameCascade cascade = new NameCascade("rubert", Optional.of(broken.toString()), 16, 4);
|
||||||
void withoutModelTheStageIsOff() {
|
assertFalse(cascade.enabled(), "испорченная модель должна выключать ступень");
|
||||||
NameCascade cascade = NameCascade.disabled();
|
assertEquals(
|
||||||
assertFalse(cascade.enabled());
|
"Клиент И. И. И., паспорт 45** ****56",
|
||||||
assertEquals("Клиент И. И. И., паспорт 45** ****56", mask(cascade, "off-1"));
|
mask(cascade, "broken-1"),
|
||||||
}
|
"маскирование по правилам обязано работать и без второй ступени");
|
||||||
|
}
|
||||||
@Test
|
|
||||||
void missingModelFileDoesNotBreakMasking(@TempDir Path dir) {
|
|
||||||
NameCascade cascade = new NameCascade("rubert", Optional.of(dir.resolve("нет-такого-каталога").toString()), 16, 4);
|
|
||||||
assertFalse(cascade.enabled(), "отсутствующая модель должна выключать ступень");
|
|
||||||
assertEquals("Клиент И. И. И., паспорт 45** ****56", mask(cascade, "missing-1"));
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void brokenModelFileDoesNotBreakMasking(@TempDir Path dir) throws IOException {
|
|
||||||
Path broken = dir.resolve("испорченная-модель");
|
|
||||||
Files.createDirectories(broken);
|
|
||||||
for (String name : new String[]{"model_int8.onnx", "vocab.txt", "config.json"}) {
|
|
||||||
Files.writeString(broken.resolve(name), "это не модель", StandardCharsets.UTF_8);
|
|
||||||
}
|
|
||||||
|
|
||||||
NameCascade cascade = new NameCascade("rubert", Optional.of(broken.toString()), 16, 4);
|
|
||||||
assertFalse(cascade.enabled(), "испорченная модель должна выключать ступень");
|
|
||||||
assertEquals("Клиент И. И. И., паспорт 45** ****56", mask(cascade, "broken-1"),
|
|
||||||
"маскирование по правилам обязано работать и без второй ступени");
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,59 @@
|
|||||||
|
package ru.pdguard;
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import ru.pdguard.config.SystemPolicy;
|
||||||
|
import ru.pdguard.core.PayloadStore;
|
||||||
|
import ru.pdguard.core.Pipeline;
|
||||||
|
import ru.pdguard.detect.RuleRegistry;
|
||||||
|
import ru.pdguard.mask.Masker;
|
||||||
|
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Нормализация цифровых ПД: находит ИНН/СНИЛС/карту/ОГРН(ИП) в свободной форме,
|
||||||
|
* где жёсткий шаблон ломается на нестандартном разделителе.
|
||||||
|
*/
|
||||||
|
class NormalisedDigitsTest {
|
||||||
|
|
||||||
|
private final Pipeline pipeline =
|
||||||
|
new Pipeline(new RuleRegistry(), new Masker(), new PayloadStore(30));
|
||||||
|
|
||||||
|
private void assertHidden(String text, String secret) {
|
||||||
|
String masked = pipeline.process(text, UUID.randomUUID().toString(), SystemPolicy.DEFAULT);
|
||||||
|
assertFalse(masked.contains(secret), "не замаскировано: «" + secret + "» в ответе «" + masked + "»");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void masksCardWithDots() {
|
||||||
|
assertHidden("Карта 4111.1111.1111.1111 клиента", "4111.1111.1111.1111");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void masksCardWithSlashes() {
|
||||||
|
assertHidden("Оплата картой 4111/1111/1111/1111 прошла", "4111/1111/1111/1111");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void masksCardWithMixedSeparators() {
|
||||||
|
assertHidden("Номер карты 4111-1111 1111.1111 клиента", "4111-1111 1111.1111");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void masksInnWithDashes() {
|
||||||
|
assertHidden("ИНН: 7703-0123-4550 плательщика", "7703-0123-4550");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void masksSnilsWithDots() {
|
||||||
|
assertHidden("СНИЛС 112.233.445.95 застрахованного", "112.233.445.95");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void keepsNumberThatFailsChecksum() {
|
||||||
|
String text = "Заказ 1234 5678 9012 3456 отгружен";
|
||||||
|
org.junit.jupiter.api.Assertions.assertEquals(text,
|
||||||
|
pipeline.process(text, UUID.randomUUID().toString(), SystemPolicy.DEFAULT));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,9 @@
|
|||||||
package ru.pdguard;
|
package ru.pdguard;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||||
|
|
||||||
|
import java.util.UUID;
|
||||||
import org.junit.jupiter.api.Test;
|
import org.junit.jupiter.api.Test;
|
||||||
import ru.pdguard.config.SystemPolicy;
|
import ru.pdguard.config.SystemPolicy;
|
||||||
import ru.pdguard.core.PayloadStore;
|
import ru.pdguard.core.PayloadStore;
|
||||||
@@ -7,86 +11,91 @@ import ru.pdguard.core.Pipeline;
|
|||||||
import ru.pdguard.detect.RuleRegistry;
|
import ru.pdguard.detect.RuleRegistry;
|
||||||
import ru.pdguard.mask.Masker;
|
import ru.pdguard.mask.Masker;
|
||||||
|
|
||||||
import java.util.UUID;
|
|
||||||
|
|
||||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
|
||||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Имя в названии организации или объекта на карте персональными данными не является.
|
* Имя в названии организации или объекта на карте персональными данными не является. Решает слово
|
||||||
* Решает слово перед именем, а не само имя: однофамилец защиту не теряет.
|
* перед именем, а не само имя: однофамилец защиту не теряет.
|
||||||
*/
|
*/
|
||||||
class OrganisationNamesTest {
|
class OrganisationNamesTest {
|
||||||
|
|
||||||
private final Pipeline pipeline =
|
private final Pipeline pipeline =
|
||||||
new Pipeline(new RuleRegistry(), new Masker(), new PayloadStore(1_000_000L, 30));
|
new Pipeline(new RuleRegistry(), new Masker(), new PayloadStore(30));
|
||||||
|
|
||||||
private String mask(String text) {
|
private String mask(String text) {
|
||||||
return pipeline.process(text, UUID.randomUUID().toString(), SystemPolicy.DEFAULT);
|
return pipeline.process(text, UUID.randomUUID().toString(), SystemPolicy.DEFAULT);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void keepsNamesInsideInstitutionNames() {
|
void keepsNamesInsideInstitutionNames() {
|
||||||
for (String text : new String[]{
|
for (String text :
|
||||||
"Институт Мечникова принимает по записи",
|
new String[] {
|
||||||
"Музей Верещагина работает по будням",
|
"Институт Мечникова принимает по записи",
|
||||||
"Театр Станиславского открыл сезон",
|
"Музей Верещагина работает по будням",
|
||||||
"Библиотека Некрасова закрыта на ремонт",
|
"Театр Станиславского открыл сезон",
|
||||||
"Премия имени Ломоносова вручена в декабре",
|
"Библиотека Некрасова закрыта на ремонт",
|
||||||
"Больница Боткина приняла пациентов",
|
"Премия имени Ломоносова вручена в декабре",
|
||||||
"Стадион Яшина отремонтирован"}) {
|
"Больница Боткина приняла пациентов",
|
||||||
assertEquals(text, mask(text), "имя в названии учреждения маскировать не нужно");
|
"Стадион Яшина отремонтирован"
|
||||||
}
|
}) {
|
||||||
|
assertEquals(text, mask(text), "имя в названии учреждения маскировать не нужно");
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void keepsNamesInsidePlaceNames() {
|
void keepsNamesInsidePlaceNames() {
|
||||||
for (String text : new String[]{
|
for (String text :
|
||||||
"Улица Королёва названа в честь конструктора",
|
new String[] {
|
||||||
"Проспект Вернадского перекрыт до вечера",
|
"Улица Королёва названа в честь конструктора",
|
||||||
"Площадь Гагарина находится на юго-западе",
|
"Проспект Вернадского перекрыт до вечера",
|
||||||
"Набережная Макарова уходит к заливу",
|
"Площадь Гагарина находится на юго-западе",
|
||||||
"Мост Кадырова разведут ночью"}) {
|
"Набережная Макарова уходит к заливу",
|
||||||
assertEquals(text, mask(text), "топоним маскировать не нужно");
|
"Мост Кадырова разведут ночью"
|
||||||
}
|
}) {
|
||||||
|
assertEquals(text, mask(text), "топоним маскировать не нужно");
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void masksRealClientWithTheSameSurname() {
|
void masksRealClientWithTheSameSurname() {
|
||||||
String masked = mask("Клиент Королёв Сергей Павлович, паспорт 4509 123456");
|
String masked = mask("Клиент Королёв Сергей Павлович, паспорт 4509 123456");
|
||||||
assertFalse(masked.contains("Королёв Сергей Павлович"),
|
assertFalse(
|
||||||
"однофамилец объекта на карте остаётся под защитой: " + masked);
|
masked.contains("Королёв Сергей Павлович"),
|
||||||
}
|
"однофамилец объекта на карте остаётся под защитой: " + masked);
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void markerOnlyCountsRightBeforeTheName() {
|
void markerOnlyCountsRightBeforeTheName() {
|
||||||
String masked = mask("Больница приняла Иванова Ивана Ивановича с жалобой");
|
String masked = mask("Больница приняла Иванова Ивана Ивановича с жалобой");
|
||||||
assertFalse(masked.contains("Иванова Ивана Ивановича"),
|
assertFalse(
|
||||||
"слово-маркер действует только вплотную перед именем: " + masked);
|
masked.contains("Иванова Ивана Ивановича"),
|
||||||
}
|
"слово-маркер действует только вплотную перед именем: " + masked);
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void keepsRulerNames() {
|
void keepsRulerNames() {
|
||||||
for (String text : new String[]{
|
for (String text :
|
||||||
"Василий Тёмный правил недолго",
|
new String[] {
|
||||||
"Ярослав Мудрый составил свод законов",
|
"Василий Тёмный правил недолго",
|
||||||
"Екатерина Вторая издала указ",
|
"Ярослав Мудрый составил свод законов",
|
||||||
"Алексей Тишайший принимал послов"}) {
|
"Екатерина Вторая издала указ",
|
||||||
assertEquals(text, mask(text), "имя правителя персональными данными не является");
|
"Алексей Тишайший принимал послов"
|
||||||
}
|
}) {
|
||||||
|
assertEquals(text, mask(text), "имя правителя персональными данными не является");
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void masksClientEvenIfNameLooksRegnal() {
|
void masksClientEvenIfNameLooksRegnal() {
|
||||||
String masked = mask("Клиент Василий Тёмный, паспорт 4509 123456");
|
String masked = mask("Клиент Василий Тёмный, паспорт 4509 123456");
|
||||||
assertFalse(masked.contains("Василий Тёмный"),
|
assertFalse(
|
||||||
"рядом с паспортными данными это конкретный человек: " + masked);
|
masked.contains("Василий Тёмный"),
|
||||||
}
|
"рядом с паспортными данными это конкретный человек: " + masked);
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void doesNotSuppressSoleTraderName() {
|
void doesNotSuppressSoleTraderName() {
|
||||||
String masked = mask("ИП Пахомов Вениамин Николаевич, ИНН 502601234547");
|
String masked = mask("ИП Пахомов Вениамин Николаевич, ИНН 502601234547");
|
||||||
assertFalse(masked.contains("Пахомов Вениамин Николаевич"),
|
assertFalse(
|
||||||
"имя предпринимателя — это персональные данные: " + masked);
|
masked.contains("Пахомов Вениамин Николаевич"),
|
||||||
}
|
"имя предпринимателя — это персональные данные: " + masked);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,50 +1,54 @@
|
|||||||
package ru.pdguard;
|
package ru.pdguard;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertNotEquals;
|
||||||
|
|
||||||
import org.junit.jupiter.api.Test;
|
import org.junit.jupiter.api.Test;
|
||||||
import ru.pdguard.core.PayloadCipher;
|
import ru.pdguard.core.PayloadCipher;
|
||||||
import ru.pdguard.core.PayloadStore;
|
import ru.pdguard.core.PayloadStore;
|
||||||
|
|
||||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
|
||||||
import static org.junit.jupiter.api.Assertions.assertNotEquals;
|
|
||||||
|
|
||||||
/** Шифрование персональных данных в хранилище. */
|
/** Шифрование персональных данных в хранилище. */
|
||||||
class PayloadCipherTest {
|
class PayloadCipherTest {
|
||||||
|
|
||||||
/** 32 байта в hex — валидный AES-256 ключ. */
|
/** 32 байта в hex — валидный AES-256 ключ. */
|
||||||
private static final String KEY = "000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f";
|
private static final String KEY =
|
||||||
|
"000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f";
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void encryptDecryptRoundTrip() {
|
void encryptDecryptRoundTrip() {
|
||||||
PayloadCipher cipher = new PayloadCipher(KEY);
|
PayloadCipher cipher = new PayloadCipher(KEY);
|
||||||
String original = "Клиент Иванов Иван Иванович, паспорт 4509 123456";
|
String original = "Клиент Иванов Иван Иванович, паспорт 4509 123456";
|
||||||
String encrypted = cipher.encrypt(original);
|
String encrypted = cipher.encrypt(original);
|
||||||
assertNotEquals(original, encrypted, "шифротекст не должен совпадать с исходником");
|
assertNotEquals(original, encrypted, "шифротекст не должен совпадать с исходником");
|
||||||
assertEquals(original, cipher.decrypt(encrypted), "должно расшифроваться обратно");
|
assertEquals(original, cipher.decrypt(encrypted), "должно расшифроваться обратно");
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void disabledCipherPassesThrough() {
|
void disabledCipherPassesThrough() {
|
||||||
PayloadCipher cipher = PayloadCipher.disabled();
|
PayloadCipher cipher = PayloadCipher.disabled();
|
||||||
String original = "Клиент Иванов";
|
String original = "Клиент Иванов";
|
||||||
assertEquals(original, cipher.encrypt(original), "без ключа шифрование выключено");
|
assertEquals(original, cipher.encrypt(original), "без ключа шифрование выключено");
|
||||||
assertEquals(original, cipher.decrypt(original), "без ключа дешифрование выключено");
|
assertEquals(original, cipher.decrypt(original), "без ключа дешифрование выключено");
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void storeStoresEncryptedButReturnsPlaintext() {
|
void storeStoresEncryptedButReturnsPlaintext() {
|
||||||
PayloadCipher cipher = new PayloadCipher(KEY);
|
PayloadCipher cipher = new PayloadCipher(KEY);
|
||||||
PayloadStore store = new PayloadStore(1_000_000L, 30, ru.pdguard.core.SharedIndex.disabled(), cipher);
|
PayloadStore store =
|
||||||
|
new PayloadStore(30, ru.pdguard.core.SharedIndex.disabled(), cipher);
|
||||||
|
|
||||||
String original = "Клиент Иванов Иван Иванович, паспорт 4509 123456";
|
String original = "Клиент Иванов Иван Иванович, паспорт 4509 123456";
|
||||||
String masked = "Клиент И. И. И., паспорт 45** ****56";
|
String masked = "Клиент И. И. И., паспорт 45** ****56";
|
||||||
store.put("test", "id-1", original, masked);
|
store.put("test", "id-1", original, masked);
|
||||||
|
|
||||||
// Чтение по id возвращает исходный текст.
|
// Чтение по id возвращает исходный текст.
|
||||||
PayloadStore.Entry entry = store.byId("test", "id-1");
|
PayloadStore.Entry entry = store.byId("test", "id-1");
|
||||||
assertEquals(original, entry.original(), "чтение по id должно вернуть исходный текст");
|
assertEquals(original, entry.original(), "чтение по id должно вернуть исходный текст");
|
||||||
|
|
||||||
// Чтение по маске возвращает исходный текст.
|
// Чтение по маске возвращает исходный текст.
|
||||||
assertEquals(original, store.originalForMask("test", masked),
|
assertEquals(
|
||||||
"чтение по маске должно вернуть исходный текст");
|
original,
|
||||||
}
|
store.originalForMask("test", masked),
|
||||||
}
|
"чтение по маске должно вернуть исходный текст");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,73 +1,64 @@
|
|||||||
package ru.pdguard;
|
package ru.pdguard;
|
||||||
|
|
||||||
import org.junit.jupiter.api.Test;
|
|
||||||
import ru.pdguard.core.PayloadStore;
|
|
||||||
|
|
||||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||||
import static org.junit.jupiter.api.Assertions.assertNotNull;
|
import static org.junit.jupiter.api.Assertions.assertNotNull;
|
||||||
import static org.junit.jupiter.api.Assertions.assertNull;
|
import static org.junit.jupiter.api.Assertions.assertNull;
|
||||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
|
||||||
|
|
||||||
/** Ограничения хранилища соответствий: объём, срок жизни и разделение по системам. */
|
import org.junit.jupiter.api.Test;
|
||||||
|
import ru.pdguard.core.PayloadStore;
|
||||||
|
|
||||||
|
/** Ограничения хранилища соответствий: срок жизни и разделение по системам. */
|
||||||
class PayloadStoreTest {
|
class PayloadStoreTest {
|
||||||
|
|
||||||
private static final String SYSTEM = "crm";
|
private static final String SYSTEM = "crm";
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void returnsWhatWasStored() {
|
void returnsWhatWasStored() {
|
||||||
PayloadStore store = new PayloadStore(1_000_000L, 30);
|
PayloadStore store = new PayloadStore(30);
|
||||||
store.put(SYSTEM, "id", "исходный текст", "маска");
|
store.put(SYSTEM, "id", "исходный текст", "маска");
|
||||||
|
|
||||||
PayloadStore.Entry entry = store.byId(SYSTEM, "id");
|
PayloadStore.Entry entry = store.byId(SYSTEM, "id");
|
||||||
assertNotNull(entry);
|
assertNotNull(entry);
|
||||||
assertEquals("исходный текст", entry.original());
|
assertEquals("исходный текст", entry.original());
|
||||||
assertEquals("маска", entry.masked());
|
assertEquals("маска", entry.masked());
|
||||||
assertEquals("исходный текст", store.originalForMask(SYSTEM, "маска"));
|
assertEquals("исходный текст", store.originalForMask(SYSTEM, "маска"));
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void forgetsEntriesAfterTheirLifetime() {
|
void forgetsEntriesAfterTheirLifetime() {
|
||||||
PayloadStore store = new PayloadStore(1_000_000L, 0);
|
PayloadStore store = new PayloadStore(0);
|
||||||
store.put(SYSTEM, "id", "исходный текст", "маска");
|
store.put(SYSTEM, "id", "исходный текст", "маска");
|
||||||
|
|
||||||
assertNull(store.byId(SYSTEM, "id"), "запись с истёкшим сроком жизни не должна отдаваться");
|
assertNull(store.byId(SYSTEM, "id"), "запись с истёкшим сроком жизни не должна отдаваться");
|
||||||
assertNull(store.originalForMask(SYSTEM, "маска"));
|
assertNull(store.originalForMask(SYSTEM, "маска"));
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void evictsOldestWhenOverSizeLimit() {
|
void unknownKeysReturnNothing() {
|
||||||
PayloadStore store = new PayloadStore(100L, 30);
|
PayloadStore store = new PayloadStore(30);
|
||||||
for (int i = 0; i < 50; i++) {
|
assertNull(store.byId(SYSTEM, "нет такого"));
|
||||||
store.put(SYSTEM, "id" + i, "текст номер " + i, "маска номер " + i);
|
assertNull(store.originalForMask(SYSTEM, "нет такой маски"));
|
||||||
}
|
}
|
||||||
|
|
||||||
assertTrue(store.charsHeld() <= 100, "объём хранилища вышел за предел: " + store.charsHeld());
|
/**
|
||||||
assertNull(store.byId(SYSTEM, "id0"), "самая старая запись должна быть вытеснена");
|
* Поиск по маске идёт только внутри своей системы. Маски детерминированы и низкоэнтропийны: без
|
||||||
assertNotNull(store.byId(SYSTEM, "id49"), "последняя запись должна остаться");
|
* разделения чужую маску можно было бы подобрать и обменять на исходные данные другого
|
||||||
}
|
* потребителя.
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
void oneSystemCannotReadAnotherSystemData() {
|
||||||
|
PayloadStore store = new PayloadStore(30);
|
||||||
|
store.put("crm", "общий-id", "Иванов Иван Иванович", "И. И. И.");
|
||||||
|
|
||||||
@Test
|
assertNull(
|
||||||
void unknownKeysReturnNothing() {
|
store.originalForMask("analytics", "И. И. И."),
|
||||||
PayloadStore store = new PayloadStore(1_000_000L, 30);
|
"чужую маску нельзя обменять на исходный текст");
|
||||||
assertNull(store.byId(SYSTEM, "нет такого"));
|
assertNull(
|
||||||
assertNull(store.originalForMask(SYSTEM, "нет такой маски"));
|
store.byId("analytics", "общий-id"),
|
||||||
}
|
"совпадение идентификатора у другой системы не даёт доступа");
|
||||||
|
assertEquals(
|
||||||
/**
|
"Иванов Иван Иванович",
|
||||||
* Поиск по маске идёт только внутри своей системы. Маски детерминированы и
|
store.originalForMask("crm", "И. И. И."),
|
||||||
* низкоэнтропийны: без разделения чужую маску можно было бы подобрать и обменять
|
"своя система свои данные по-прежнему получает");
|
||||||
* на исходные данные другого потребителя.
|
}
|
||||||
*/
|
|
||||||
@Test
|
|
||||||
void oneSystemCannotReadAnotherSystemData() {
|
|
||||||
PayloadStore store = new PayloadStore(1_000_000L, 30);
|
|
||||||
store.put("crm", "общий-id", "Иванов Иван Иванович", "И. И. И.");
|
|
||||||
|
|
||||||
assertNull(store.originalForMask("analytics", "И. И. И."),
|
|
||||||
"чужую маску нельзя обменять на исходный текст");
|
|
||||||
assertNull(store.byId("analytics", "общий-id"),
|
|
||||||
"совпадение идентификатора у другой системы не даёт доступа");
|
|
||||||
assertEquals("Иванов Иван Иванович", store.originalForMask("crm", "И. И. И."),
|
|
||||||
"своя система свои данные по-прежнему получает");
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,12 @@
|
|||||||
package ru.pdguard;
|
package ru.pdguard;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||||
|
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.Comparator;
|
||||||
|
import java.util.LinkedHashMap;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
import org.junit.jupiter.api.Test;
|
import org.junit.jupiter.api.Test;
|
||||||
import ru.pdguard.config.SystemPolicy;
|
import ru.pdguard.config.SystemPolicy;
|
||||||
import ru.pdguard.core.PayloadStore;
|
import ru.pdguard.core.PayloadStore;
|
||||||
@@ -8,142 +15,146 @@ import ru.pdguard.detect.RuleRegistry;
|
|||||||
import ru.pdguard.detect.Span;
|
import ru.pdguard.detect.Span;
|
||||||
import ru.pdguard.mask.Masker;
|
import ru.pdguard.mask.Masker;
|
||||||
|
|
||||||
import java.util.ArrayList;
|
|
||||||
import java.util.Comparator;
|
|
||||||
import java.util.LinkedHashMap;
|
|
||||||
import java.util.List;
|
|
||||||
import java.util.Map;
|
|
||||||
|
|
||||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Оценка эффективности детекции по каждому типу ПДН в отдельности.
|
* Оценка эффективности детекции по каждому типу ПДН в отдельности.
|
||||||
*
|
*
|
||||||
* <p>Набор {@code benchmark-pdn-types.txt} содержит по несколько примеров каждого
|
* <p>Набор {@code benchmark-pdn-types.txt} содержит по несколько примеров каждого типа ПДН. Для
|
||||||
* типа ПДН. Для каждого типа считается посимвольная точность, полнота и F1 —
|
* каждого типа считается посимвольная точность, полнота и F1 — так видно, какие типы детектор
|
||||||
* так видно, какие типы детектор находит надёжно, а какие пропускает или
|
* находит надёжно, а какие пропускает или маскирует сверх меры.
|
||||||
* маскирует сверх меры.
|
|
||||||
*/
|
*/
|
||||||
class PdnTypeEfficiencyTest {
|
class PdnTypeEfficiencyTest {
|
||||||
|
|
||||||
private final Pipeline pipeline =
|
private final Pipeline pipeline =
|
||||||
new Pipeline(new RuleRegistry(), new Masker(), new PayloadStore(10_000_000L, 30));
|
new Pipeline(new RuleRegistry(), new Masker(), new PayloadStore(30));
|
||||||
|
|
||||||
/** Накопитель посимвольных совпадений по одному типу. */
|
/** Накопитель посимвольных совпадений по одному типу. */
|
||||||
private static final class Score {
|
private static final class Score {
|
||||||
private int truePositive;
|
private int truePositive;
|
||||||
private int falsePositive;
|
private int falsePositive;
|
||||||
private int falseNegative;
|
private int falseNegative;
|
||||||
|
|
||||||
private int gold() {
|
private int gold() {
|
||||||
return truePositive + falseNegative;
|
return truePositive + falseNegative;
|
||||||
}
|
|
||||||
|
|
||||||
private double precision() {
|
|
||||||
int found = truePositive + falsePositive;
|
|
||||||
return found == 0 ? 1.0 : (double) truePositive / found;
|
|
||||||
}
|
|
||||||
|
|
||||||
private double recall() {
|
|
||||||
return gold() == 0 ? 1.0 : (double) truePositive / gold();
|
|
||||||
}
|
|
||||||
|
|
||||||
private double f1() {
|
|
||||||
double p = precision();
|
|
||||||
double r = recall();
|
|
||||||
return p + r == 0 ? 0.0 : 2 * p * r / (p + r);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
private double precision() {
|
||||||
void efficiencyByPdnType() {
|
int found = truePositive + falsePositive;
|
||||||
List<BenchmarkFixtures.Sample> samples = BenchmarkFixtures.load("/benchmark-pdn-types.txt");
|
return found == 0 ? 1.0 : (double) truePositive / found;
|
||||||
Map<String, Score> byType = new LinkedHashMap<>();
|
|
||||||
Map<String, List<String>> missed = new LinkedHashMap<>();
|
|
||||||
|
|
||||||
for (BenchmarkFixtures.Sample sample : samples) {
|
|
||||||
List<Span> found = pipeline.findPersonalData(sample.text(), SystemPolicy.DEFAULT);
|
|
||||||
String[] goldChars = paint(sample.text().length(), sample.gold());
|
|
||||||
String[] foundChars = paint(sample.text().length(), found);
|
|
||||||
for (int i = 0; i < sample.text().length(); i++) {
|
|
||||||
account(byType, goldChars[i], foundChars[i]);
|
|
||||||
}
|
|
||||||
for (Span gold : sample.gold()) {
|
|
||||||
boolean hit = found.stream().anyMatch(f -> f.type().equals(gold.type()) && f.overlaps(gold));
|
|
||||||
if (!hit) {
|
|
||||||
missed.computeIfAbsent(gold.type(), t -> new ArrayList<>())
|
|
||||||
.add(sample.text().substring(gold.start(), gold.end()));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
report(byType);
|
|
||||||
reportMissed(missed);
|
|
||||||
|
|
||||||
// Каждый тип должен быть найден с F1 не ниже 0.8 — иначе детектор
|
|
||||||
// пропускает или перемаскирует этот тип ПДН. Companion-типы (CVV, PIN,
|
|
||||||
// DATE) проверяются отдельно: они маскируются только рядом с другими ПД.
|
|
||||||
for (Map.Entry<String, Score> e : byType.entrySet()) {
|
|
||||||
if (isCompanion(e.getKey())) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
assertTrue(e.getValue().f1() >= 0.8,
|
|
||||||
String.format("F1 по типу %s упал до %.3f", e.getKey(), e.getValue().f1()));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private void reportMissed(Map<String, List<String>> missed) {
|
private double recall() {
|
||||||
if (missed.isEmpty()) {
|
return gold() == 0 ? 1.0 : (double) truePositive / gold();
|
||||||
return;
|
}
|
||||||
|
|
||||||
|
private double f1() {
|
||||||
|
double p = precision();
|
||||||
|
double r = recall();
|
||||||
|
return p + r == 0 ? 0.0 : 2 * p * r / (p + r);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void efficiencyByPdnType() {
|
||||||
|
List<BenchmarkFixtures.Sample> samples = BenchmarkFixtures.load("/benchmark-pdn-types.txt");
|
||||||
|
Map<String, Score> byType = new LinkedHashMap<>();
|
||||||
|
Map<String, List<String>> missed = new LinkedHashMap<>();
|
||||||
|
|
||||||
|
for (BenchmarkFixtures.Sample sample : samples) {
|
||||||
|
List<Span> found = pipeline.findPersonalData(sample.text(), SystemPolicy.DEFAULT);
|
||||||
|
String[] goldChars = paint(sample.text().length(), sample.gold());
|
||||||
|
String[] foundChars = paint(sample.text().length(), found);
|
||||||
|
for (int i = 0; i < sample.text().length(); i++) {
|
||||||
|
account(byType, goldChars[i], foundChars[i]);
|
||||||
|
}
|
||||||
|
for (Span gold : sample.gold()) {
|
||||||
|
boolean hit =
|
||||||
|
found.stream().anyMatch(f -> f.type().equals(gold.type()) && f.overlaps(gold));
|
||||||
|
if (!hit) {
|
||||||
|
missed
|
||||||
|
.computeIfAbsent(gold.type(), t -> new ArrayList<>())
|
||||||
|
.add(sample.text().substring(gold.start(), gold.end()));
|
||||||
}
|
}
|
||||||
StringBuilder out = new StringBuilder();
|
}
|
||||||
out.append("\n=== Не распознанные значения по типам ===\n");
|
}
|
||||||
missed.forEach((type, values) -> {
|
|
||||||
out.append(type).append(": ").append(String.join(" | ", values)).append('\n');
|
report(byType);
|
||||||
|
reportMissed(missed);
|
||||||
|
|
||||||
|
// Каждый тип должен быть найден с F1 не ниже 0.8 — иначе детектор
|
||||||
|
// пропускает или перемаскирует этот тип ПДН. Companion-типы (CVV, PIN,
|
||||||
|
// DATE) проверяются отдельно: они маскируются только рядом с другими ПД.
|
||||||
|
for (Map.Entry<String, Score> e : byType.entrySet()) {
|
||||||
|
if (isCompanion(e.getKey())) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
assertTrue(
|
||||||
|
e.getValue().f1() >= 0.8,
|
||||||
|
String.format("F1 по типу %s упал до %.3f", e.getKey(), e.getValue().f1()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void reportMissed(Map<String, List<String>> missed) {
|
||||||
|
if (missed.isEmpty()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
StringBuilder out = new StringBuilder();
|
||||||
|
out.append("\n=== Не распознанные значения по типам ===\n");
|
||||||
|
missed.forEach(
|
||||||
|
(type, values) -> {
|
||||||
|
out.append(type).append(": ").append(String.join(" | ", values)).append('\n');
|
||||||
});
|
});
|
||||||
System.out.println(out);
|
System.out.println(out);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static boolean isCompanion(String type) {
|
||||||
|
return "CVV".equals(type) || "PIN".equals(type) || "DATE".equals(type);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Раскрашивает каждый знак текста типом ПД, который его покрывает. */
|
||||||
|
private static String[] paint(int length, List<Span> spans) {
|
||||||
|
String[] painted = new String[length];
|
||||||
|
for (Span span : spans) {
|
||||||
|
for (int i = span.start(); i < Math.min(span.end(), length); i++) {
|
||||||
|
painted[i] = span.type();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
return painted;
|
||||||
|
}
|
||||||
|
|
||||||
private static boolean isCompanion(String type) {
|
private static void account(Map<String, Score> byType, String gold, String found) {
|
||||||
return "CVV".equals(type) || "PIN".equals(type) || "DATE".equals(type);
|
if (gold != null) {
|
||||||
|
Score score = byType.computeIfAbsent(gold, t -> new Score());
|
||||||
|
if (gold.equals(found)) {
|
||||||
|
score.truePositive++;
|
||||||
|
} else {
|
||||||
|
score.falseNegative++;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
if (found != null && !found.equals(gold)) {
|
||||||
/** Раскрашивает каждый знак текста типом ПД, который его покрывает. */
|
byType.computeIfAbsent(found, t -> new Score()).falsePositive++;
|
||||||
private static String[] paint(int length, List<Span> spans) {
|
|
||||||
String[] painted = new String[length];
|
|
||||||
for (Span span : spans) {
|
|
||||||
for (int i = span.start(); i < Math.min(span.end(), length); i++) {
|
|
||||||
painted[i] = span.type();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return painted;
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private static void account(Map<String, Score> byType, String gold, String found) {
|
private void report(Map<String, Score> byType) {
|
||||||
if (gold != null) {
|
StringBuilder out = new StringBuilder(2048);
|
||||||
Score score = byType.computeIfAbsent(gold, t -> new Score());
|
out.append("\n=== Эффективность детекции по типам ПДН ===\n\n");
|
||||||
if (gold.equals(found)) {
|
out.append(
|
||||||
score.truePositive++;
|
String.format("%-20s %8s %8s %8s %8s%n", "тип", "знаков", "точность", "полнота", "F1"));
|
||||||
} else {
|
|
||||||
score.falseNegative++;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (found != null && !found.equals(gold)) {
|
|
||||||
byType.computeIfAbsent(found, t -> new Score()).falsePositive++;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private void report(Map<String, Score> byType) {
|
byType.entrySet().stream()
|
||||||
StringBuilder out = new StringBuilder(2048);
|
.sorted(
|
||||||
out.append("\n=== Эффективность детекции по типам ПДН ===\n\n");
|
Comparator.comparingInt((Map.Entry<String, Score> e) -> e.getValue().gold()).reversed())
|
||||||
out.append(String.format("%-20s %8s %8s %8s %8s%n", "тип", "знаков", "точность", "полнота", "F1"));
|
.forEach(
|
||||||
|
e ->
|
||||||
|
out.append(
|
||||||
|
String.format(
|
||||||
|
"%-20s %8d %8.3f %8.3f %8.3f%n",
|
||||||
|
e.getKey(),
|
||||||
|
e.getValue().gold(),
|
||||||
|
e.getValue().precision(),
|
||||||
|
e.getValue().recall(),
|
||||||
|
e.getValue().f1())));
|
||||||
|
|
||||||
byType.entrySet().stream()
|
System.out.println(out);
|
||||||
.sorted(Comparator.comparingInt((Map.Entry<String, Score> e) -> e.getValue().gold()).reversed())
|
}
|
||||||
.forEach(e -> out.append(String.format("%-20s %8d %8.3f %8.3f %8.3f%n",
|
}
|
||||||
e.getKey(), e.getValue().gold(), e.getValue().precision(),
|
|
||||||
e.getValue().recall(), e.getValue().f1())));
|
|
||||||
|
|
||||||
System.out.println(out);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,15 +1,10 @@
|
|||||||
package ru.pdguard;
|
package ru.pdguard;
|
||||||
|
|
||||||
import org.junit.jupiter.api.Test;
|
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||||
|
import static org.junit.jupiter.api.Assumptions.assumeTrue;
|
||||||
|
|
||||||
import io.micrometer.core.instrument.MeterRegistry;
|
import io.micrometer.core.instrument.MeterRegistry;
|
||||||
import io.micrometer.core.instrument.simple.SimpleMeterRegistry;
|
import io.micrometer.core.instrument.simple.SimpleMeterRegistry;
|
||||||
import ru.pdguard.config.SystemPolicy;
|
|
||||||
import ru.pdguard.core.PayloadStore;
|
|
||||||
import ru.pdguard.core.Pipeline;
|
|
||||||
import ru.pdguard.detect.NameCascade;
|
|
||||||
import ru.pdguard.detect.RuleRegistry;
|
|
||||||
import ru.pdguard.mask.Masker;
|
|
||||||
|
|
||||||
import java.nio.file.Files;
|
import java.nio.file.Files;
|
||||||
import java.nio.file.Path;
|
import java.nio.file.Path;
|
||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
@@ -21,191 +16,214 @@ import java.util.concurrent.ExecutorService;
|
|||||||
import java.util.concurrent.Executors;
|
import java.util.concurrent.Executors;
|
||||||
import java.util.concurrent.Future;
|
import java.util.concurrent.Future;
|
||||||
import java.util.concurrent.TimeUnit;
|
import java.util.concurrent.TimeUnit;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
import static org.junit.jupiter.api.Assumptions.assumeTrue;
|
import ru.pdguard.config.SystemPolicy;
|
||||||
|
import ru.pdguard.core.PayloadStore;
|
||||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
import ru.pdguard.core.Pipeline;
|
||||||
|
import ru.pdguard.detect.NameCascade;
|
||||||
|
import ru.pdguard.detect.RuleRegistry;
|
||||||
|
import ru.pdguard.mask.Masker;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Замер производительности: задержка одиночного обращения и пропускная
|
* Замер производительности: задержка одиночного обращения и пропускная способность под нагрузкой.
|
||||||
* способность под нагрузкой. Не тест качества — он в {@link BenchmarkTest}.
|
* Не тест качества — он в {@link BenchmarkTest}.
|
||||||
*
|
*
|
||||||
* <p>Прогон идёт на одних правилах (вторая ступень выключена), как в боевой
|
* <p>Прогон идёт на одних правилах (вторая ступень выключена), как в боевой сборке без модели.
|
||||||
* сборке без модели. Перед замером пайплайн прогревается, чтобы JIT успел
|
* Перед замером пайплайн прогревается, чтобы JIT успел скомпилировать горячий путь, — иначе первые
|
||||||
* скомпилировать горячий путь, — иначе первые замеры покажут интерпретируемый
|
* замеры покажут интерпретируемый код и занизят результат в разы.
|
||||||
* код и занизят результат в разы.
|
|
||||||
*/
|
*/
|
||||||
class PerformanceBenchmarkTest {
|
class PerformanceBenchmarkTest {
|
||||||
|
|
||||||
/** Типовой текст с ПД — как в реальном обращении. */
|
/** Типовой текст с ПД — как в реальном обращении. */
|
||||||
private static final String[] PAYLOADS = {
|
private static final String[] PAYLOADS = {
|
||||||
"Клиент Иванов Иван Иванович, паспорт 4509 123456, тел +7 916 123-45-67",
|
"Клиент Иванов Иван Иванович, паспорт 4509 123456, тел +7 916 123-45-67",
|
||||||
"Заявление от И.И. Петрова, ИНН 770301234550, почта ivan.petrov@mail.ru",
|
"Заявление от И.И. Петрова, ИНН 770301234550, почта ivan.petrov@mail.ru",
|
||||||
"Адрес: 125009, г. Москва, ул. Тверская, д. 7, кв. 15, карта 4111 1111 1111 1111",
|
"Адрес: 125009, г. Москва, ул. Тверская, д. 7, кв. 15, карта 4111 1111 1111 1111",
|
||||||
"Дата рождения 12.05.1985, место рождения: город Тверь, гражданство РФ",
|
"Дата рождения 12.05.1985, место рождения: город Тверь, гражданство РФ",
|
||||||
"Напиши краткое описание продукта для рассылки клиентам банка",
|
"Напиши краткое описание продукта для рассылки клиентам банка",
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Тексты, где правила не находят ПД, но есть цепочки имён — их разбирает
|
* Тексты, где правила не находят ПД, но есть цепочки имён — их разбирает вторая ступень (модель).
|
||||||
* вторая ступень (модель). Нужны, чтобы честно измерить стоимость модели,
|
* Нужны, чтобы честно измерить стоимость модели, а не правила, которые в типовых текстах уже всё
|
||||||
* а не правила, которые в типовых текстах уже всё покрыли.
|
* покрыли.
|
||||||
*/
|
*/
|
||||||
private static final String[] CASCADE_PAYLOADS = {
|
private static final String[] CASCADE_PAYLOADS = {
|
||||||
"Готье и Руссо пришли на встречу в офис",
|
"Готье и Руссо пришли на встречу в офис",
|
||||||
"Дюма написал роман за несколько месяцев",
|
"Дюма написал роман за несколько месяцев",
|
||||||
"Виктор Гюго был известным писателем",
|
"Виктор Гюго был известным писателем",
|
||||||
"Оноре де Бальзак писал романы о жизни",
|
"Оноре де Бальзак писал романы о жизни",
|
||||||
"Жан-Поль Сартр философ и писатель",
|
"Жан-Поль Сартр философ и писатель",
|
||||||
};
|
};
|
||||||
|
|
||||||
private static final int WARMUP = 20_000;
|
private static final int WARMUP = 20_000;
|
||||||
private static final int MEASURE = 50_000;
|
private static final int MEASURE = 50_000;
|
||||||
|
|
||||||
private final Pipeline pipeline =
|
private final Pipeline pipeline =
|
||||||
new Pipeline(new RuleRegistry(), new Masker(), new PayloadStore(10_000_000L, 30));
|
new Pipeline(new RuleRegistry(), new Masker(), new PayloadStore(30));
|
||||||
|
|
||||||
private void warmup() {
|
private void warmup() {
|
||||||
for (int i = 0; i < WARMUP; i++) {
|
for (int i = 0; i < WARMUP; i++) {
|
||||||
String text = PAYLOADS[i % PAYLOADS.length];
|
String text = PAYLOADS[i % PAYLOADS.length];
|
||||||
String id = "warmup-" + i;
|
String id = "warmup-" + i;
|
||||||
pipeline.process(text, id, SystemPolicy.DEFAULT);
|
pipeline.process(text, id, SystemPolicy.DEFAULT);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Задержка маскирования и демаскирования типового обращения. */
|
||||||
|
@Test
|
||||||
|
void singleRequestLatency() {
|
||||||
|
warmup();
|
||||||
|
|
||||||
|
long[] maskNanos = new long[MEASURE];
|
||||||
|
long[] unmaskNanos = new long[MEASURE];
|
||||||
|
|
||||||
|
for (int i = 0; i < MEASURE; i++) {
|
||||||
|
String text = PAYLOADS[i % PAYLOADS.length];
|
||||||
|
String id = "lat-" + i;
|
||||||
|
|
||||||
|
long t0 = System.nanoTime();
|
||||||
|
String masked = pipeline.process(text, id, SystemPolicy.DEFAULT);
|
||||||
|
maskNanos[i] = System.nanoTime() - t0;
|
||||||
|
|
||||||
|
long t1 = System.nanoTime();
|
||||||
|
pipeline.process(masked, id, SystemPolicy.DEFAULT);
|
||||||
|
unmaskNanos[i] = System.nanoTime() - t1;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Задержка маскирования и демаскирования типового обращения. */
|
Arrays.sort(maskNanos);
|
||||||
@Test
|
Arrays.sort(unmaskNanos);
|
||||||
void singleRequestLatency() {
|
|
||||||
warmup();
|
|
||||||
|
|
||||||
long[] maskNanos = new long[MEASURE];
|
double maskUs = nanosToMicros(maskNanos);
|
||||||
long[] unmaskNanos = new long[MEASURE];
|
double unmaskUs = nanosToMicros(unmaskNanos);
|
||||||
|
|
||||||
for (int i = 0; i < MEASURE; i++) {
|
System.out.printf("%n=== Задержка одиночного обращения (правила, без модели) ===%n");
|
||||||
String text = PAYLOADS[i % PAYLOADS.length];
|
System.out.printf(
|
||||||
String id = "lat-" + i;
|
"Маскирование: p50=%.1f мкс p95=%.1f мкс p99=%.1f мкс среднее=%.1f мкс%n",
|
||||||
|
maskNanos[MEASURE / 2] / 1000.0,
|
||||||
|
maskNanos[(int) (MEASURE * 0.95)] / 1000.0,
|
||||||
|
maskNanos[(int) (MEASURE * 0.99)] / 1000.0,
|
||||||
|
maskUs);
|
||||||
|
System.out.printf(
|
||||||
|
"Демаскирование: p50=%.1f мкс p95=%.1f мкс p99=%.1f мкс среднее=%.1f мкс%n",
|
||||||
|
unmaskNanos[MEASURE / 2] / 1000.0,
|
||||||
|
unmaskNanos[(int) (MEASURE * 0.95)] / 1000.0,
|
||||||
|
unmaskNanos[(int) (MEASURE * 0.99)] / 1000.0,
|
||||||
|
unmaskUs);
|
||||||
|
|
||||||
long t0 = System.nanoTime();
|
// Целевая задержка из ТЗ — 200 мс; типовое обращение должно укладываться в миллисекунды.
|
||||||
String masked = pipeline.process(text, id, SystemPolicy.DEFAULT);
|
assertTrue(
|
||||||
maskNanos[i] = System.nanoTime() - t0;
|
maskNanos[(int) (MEASURE * 0.99)] < 5_000_000,
|
||||||
|
"p99 маскирования превысил 5 мс: " + maskNanos[(int) (MEASURE * 0.99)] / 1_000_000 + " мс");
|
||||||
|
}
|
||||||
|
|
||||||
long t1 = System.nanoTime();
|
/** Пропускная способность под нагрузкой: сколько обращений в секунду выдерживает пайплайн. */
|
||||||
pipeline.process(masked, id, SystemPolicy.DEFAULT);
|
@Test
|
||||||
unmaskNanos[i] = System.nanoTime() - t1;
|
void throughputUnderLoad() throws Exception {
|
||||||
}
|
warmup();
|
||||||
|
|
||||||
Arrays.sort(maskNanos);
|
int threads = Math.max(4, Runtime.getRuntime().availableProcessors());
|
||||||
Arrays.sort(unmaskNanos);
|
int perThread = 10_000;
|
||||||
|
ExecutorService pool = Executors.newFixedThreadPool(threads);
|
||||||
|
|
||||||
double maskUs = nanosToMicros(maskNanos);
|
long started = System.nanoTime();
|
||||||
double unmaskUs = nanosToMicros(unmaskNanos);
|
List<Future<Long>> futures = new ArrayList<>();
|
||||||
|
for (int t = 0; t < threads; t++) {
|
||||||
System.out.printf("%n=== Задержка одиночного обращения (правила, без модели) ===%n");
|
final int threadId = t;
|
||||||
System.out.printf("Маскирование: p50=%.1f мкс p95=%.1f мкс p99=%.1f мкс среднее=%.1f мкс%n",
|
futures.add(
|
||||||
maskNanos[MEASURE / 2] / 1000.0, maskNanos[(int) (MEASURE * 0.95)] / 1000.0,
|
pool.submit(
|
||||||
maskNanos[(int) (MEASURE * 0.99)] / 1000.0, maskUs);
|
(Callable<Long>)
|
||||||
System.out.printf("Демаскирование: p50=%.1f мкс p95=%.1f мкс p99=%.1f мкс среднее=%.1f мкс%n",
|
() -> {
|
||||||
unmaskNanos[MEASURE / 2] / 1000.0, unmaskNanos[(int) (MEASURE * 0.95)] / 1000.0,
|
long local = 0;
|
||||||
unmaskNanos[(int) (MEASURE * 0.99)] / 1000.0, unmaskUs);
|
for (int i = 0; i < perThread; i++) {
|
||||||
|
String text = PAYLOADS[(threadId * 31 + i) % PAYLOADS.length];
|
||||||
// Целевая задержка из ТЗ — 200 мс; типовое обращение должно укладываться в миллисекунды.
|
String id = "load-" + threadId + "-" + i;
|
||||||
assertTrue(maskNanos[(int) (MEASURE * 0.99)] < 5_000_000,
|
long t0 = System.nanoTime();
|
||||||
"p99 маскирования превысил 5 мс: " + maskNanos[(int) (MEASURE * 0.99)] / 1_000_000 + " мс");
|
pipeline.process(text, id, SystemPolicy.DEFAULT);
|
||||||
|
local += System.nanoTime() - t0;
|
||||||
|
}
|
||||||
|
return local;
|
||||||
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Пропускная способность под нагрузкой: сколько обращений в секунду выдерживает пайплайн. */
|
long totalNanos = 0;
|
||||||
@Test
|
for (Future<Long> f : futures) {
|
||||||
void throughputUnderLoad() throws Exception {
|
totalNanos += f.get();
|
||||||
warmup();
|
}
|
||||||
|
long wallNanos = System.nanoTime() - started;
|
||||||
|
pool.shutdown();
|
||||||
|
pool.awaitTermination(30, TimeUnit.SECONDS);
|
||||||
|
|
||||||
int threads = Math.max(4, Runtime.getRuntime().availableProcessors());
|
int requests = threads * perThread;
|
||||||
int perThread = 10_000;
|
double rps = requests / (wallNanos / 1e9);
|
||||||
ExecutorService pool = Executors.newFixedThreadPool(threads);
|
double avgUs = totalNanos / (double) requests / 1000.0;
|
||||||
|
|
||||||
long started = System.nanoTime();
|
System.out.printf(
|
||||||
List<Future<Long>> futures = new ArrayList<>();
|
"%n=== Пропускная способность (%d потоков, %d обращений) ===%n", threads, requests);
|
||||||
for (int t = 0; t < threads; t++) {
|
System.out.printf("RPS: %.0f обращений/с средняя задержка: %.1f мкс%n", rps, avgUs);
|
||||||
final int threadId = t;
|
|
||||||
futures.add(pool.submit((Callable<Long>) () -> {
|
|
||||||
long local = 0;
|
|
||||||
for (int i = 0; i < perThread; i++) {
|
|
||||||
String text = PAYLOADS[(threadId * 31 + i) % PAYLOADS.length];
|
|
||||||
String id = "load-" + threadId + "-" + i;
|
|
||||||
long t0 = System.nanoTime();
|
|
||||||
pipeline.process(text, id, SystemPolicy.DEFAULT);
|
|
||||||
local += System.nanoTime() - t0;
|
|
||||||
}
|
|
||||||
return local;
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
|
|
||||||
long totalNanos = 0;
|
assertTrue(rps > 1000, "пропускная способность ниже 1000 RPS: " + rps);
|
||||||
for (Future<Long> f : futures) {
|
}
|
||||||
totalNanos += f.get();
|
|
||||||
}
|
|
||||||
long wallNanos = System.nanoTime() - started;
|
|
||||||
pool.shutdown();
|
|
||||||
pool.awaitTermination(30, TimeUnit.SECONDS);
|
|
||||||
|
|
||||||
int requests = threads * perThread;
|
private static double nanosToMicros(long[] nanos) {
|
||||||
double rps = requests / (wallNanos / 1e9);
|
long sum = 0;
|
||||||
double avgUs = totalNanos / (double) requests / 1000.0;
|
for (long n : nanos) {
|
||||||
|
sum += n;
|
||||||
|
}
|
||||||
|
return sum / (double) nanos.length / 1000.0;
|
||||||
|
}
|
||||||
|
|
||||||
System.out.printf("%n=== Пропускная способность (%d потоков, %d обращений) ===%n", threads, requests);
|
/** Задержка со второй ступенью (ruBERT). Модель должна быть собрана. */
|
||||||
System.out.printf("RPS: %.0f обращений/с средняя задержка: %.1f мкс%n", rps, avgUs);
|
@Test
|
||||||
|
void singleRequestLatencyWithNameCascade() {
|
||||||
|
Path model = Path.of("models/rubert-ner");
|
||||||
|
assumeTrue(Files.isReadable(model), "модель " + model.toAbsolutePath() + " не собрана");
|
||||||
|
|
||||||
assertTrue(rps > 1000, "пропускная способность ниже 1000 RPS: " + rps);
|
MeterRegistry meters = new SimpleMeterRegistry();
|
||||||
|
Pipeline withCascade =
|
||||||
|
new Pipeline(
|
||||||
|
new RuleRegistry(),
|
||||||
|
new Masker(),
|
||||||
|
new PayloadStore(30),
|
||||||
|
new NameCascade(
|
||||||
|
"rubert", Optional.of(model.toString()), "off", Optional.empty(), 16, 4, meters));
|
||||||
|
|
||||||
|
// Прогрев второй ступени: модель инициализируется лениво, первые вызовы медленные.
|
||||||
|
for (int i = 0; i < 200; i++) {
|
||||||
|
String text = CASCADE_PAYLOADS[i % CASCADE_PAYLOADS.length];
|
||||||
|
withCascade.process(text, "cascade-warmup-" + i, SystemPolicy.DEFAULT);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static double nanosToMicros(long[] nanos) {
|
long[] maskNanos = new long[2000];
|
||||||
long sum = 0;
|
for (int i = 0; i < 2000; i++) {
|
||||||
for (long n : nanos) {
|
String text = CASCADE_PAYLOADS[i % CASCADE_PAYLOADS.length];
|
||||||
sum += n;
|
String id = "cascade-lat-" + i;
|
||||||
}
|
long t0 = System.nanoTime();
|
||||||
return sum / (double) nanos.length / 1000.0;
|
withCascade.process(text, id, SystemPolicy.DEFAULT);
|
||||||
|
maskNanos[i] = System.nanoTime() - t0;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Задержка со второй ступенью (ruBERT). Модель должна быть собрана. */
|
Arrays.sort(maskNanos);
|
||||||
@Test
|
int n = maskNanos.length;
|
||||||
void singleRequestLatencyWithNameCascade() {
|
System.out.printf("%n=== Задержка одиночного обращения со второй ступенью (ruBERT) ===%n");
|
||||||
Path model = Path.of("models/rubert-ner");
|
System.out.printf(
|
||||||
assumeTrue(Files.isReadable(model), "модель " + model.toAbsolutePath() + " не собрана");
|
"Маскирование: p50=%.1f мкс p95=%.1f мкс p99=%.1f мкс среднее=%.1f мкс%n",
|
||||||
|
maskNanos[n / 2] / 1000.0,
|
||||||
|
maskNanos[(int) (n * 0.95)] / 1000.0,
|
||||||
|
maskNanos[(int) (n * 0.99)] / 1000.0,
|
||||||
|
nanosToMicros(maskNanos));
|
||||||
|
|
||||||
MeterRegistry meters = new SimpleMeterRegistry();
|
double engaged = meters.counter("pdguard.ner.requests", "outcome", "engaged").count();
|
||||||
Pipeline withCascade = new Pipeline(new RuleRegistry(), new Masker(),
|
double candidates = meters.counter("pdguard.ner.candidates").count();
|
||||||
new PayloadStore(10_000_000L, 30),
|
System.out.printf(
|
||||||
new NameCascade("rubert", Optional.of(model.toString()), "off", Optional.empty(), 16, 4, meters));
|
"Обращений к модели: %.0f, кандидатов разобрано: %.0f%n", engaged, candidates);
|
||||||
|
|
||||||
// Прогрев второй ступени: модель инициализируется лениво, первые вызовы медленные.
|
// Целевая задержка из ТЗ — 200 мс; даже со второй ступенью типовое обращение
|
||||||
for (int i = 0; i < 200; i++) {
|
// должно укладываться в десятки миллисекунд.
|
||||||
String text = CASCADE_PAYLOADS[i % CASCADE_PAYLOADS.length];
|
assertTrue(
|
||||||
withCascade.process(text, "cascade-warmup-" + i, SystemPolicy.DEFAULT);
|
maskNanos[(int) (n * 0.99)] < 200_000_000,
|
||||||
}
|
"p99 маскирования со второй ступенью превысил 200 мс: "
|
||||||
|
+ maskNanos[(int) (n * 0.99)] / 1_000_000
|
||||||
long[] maskNanos = new long[2000];
|
+ " мс");
|
||||||
for (int i = 0; i < 2000; i++) {
|
}
|
||||||
String text = CASCADE_PAYLOADS[i % CASCADE_PAYLOADS.length];
|
}
|
||||||
String id = "cascade-lat-" + i;
|
|
||||||
long t0 = System.nanoTime();
|
|
||||||
withCascade.process(text, id, SystemPolicy.DEFAULT);
|
|
||||||
maskNanos[i] = System.nanoTime() - t0;
|
|
||||||
}
|
|
||||||
|
|
||||||
Arrays.sort(maskNanos);
|
|
||||||
int n = maskNanos.length;
|
|
||||||
System.out.printf("%n=== Задержка одиночного обращения со второй ступенью (ruBERT) ===%n");
|
|
||||||
System.out.printf("Маскирование: p50=%.1f мкс p95=%.1f мкс p99=%.1f мкс среднее=%.1f мкс%n",
|
|
||||||
maskNanos[n / 2] / 1000.0, maskNanos[(int) (n * 0.95)] / 1000.0,
|
|
||||||
maskNanos[(int) (n * 0.99)] / 1000.0, nanosToMicros(maskNanos));
|
|
||||||
|
|
||||||
double engaged = meters.counter("pdguard.ner.requests", "outcome", "engaged").count();
|
|
||||||
double candidates = meters.counter("pdguard.ner.candidates").count();
|
|
||||||
System.out.printf("Обращений к модели: %.0f, кандидатов разобрано: %.0f%n", engaged, candidates);
|
|
||||||
|
|
||||||
// Целевая задержка из ТЗ — 200 мс; даже со второй ступенью типовое обращение
|
|
||||||
// должно укладываться в десятки миллисекунд.
|
|
||||||
assertTrue(maskNanos[(int) (n * 0.99)] < 200_000_000,
|
|
||||||
"p99 маскирования со второй ступенью превысил 200 мс: "
|
|
||||||
+ maskNanos[(int) (n * 0.99)] / 1_000_000 + " мс");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,5 +1,11 @@
|
|||||||
package ru.pdguard;
|
package ru.pdguard;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertNotEquals;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||||
|
|
||||||
|
import java.util.UUID;
|
||||||
import org.junit.jupiter.api.Test;
|
import org.junit.jupiter.api.Test;
|
||||||
import ru.pdguard.config.SystemPolicy;
|
import ru.pdguard.config.SystemPolicy;
|
||||||
import ru.pdguard.core.PayloadStore;
|
import ru.pdguard.core.PayloadStore;
|
||||||
@@ -8,132 +14,127 @@ import ru.pdguard.detect.PdTypes;
|
|||||||
import ru.pdguard.detect.RuleRegistry;
|
import ru.pdguard.detect.RuleRegistry;
|
||||||
import ru.pdguard.mask.Masker;
|
import ru.pdguard.mask.Masker;
|
||||||
|
|
||||||
import java.util.UUID;
|
|
||||||
|
|
||||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
|
||||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
|
||||||
import static org.junit.jupiter.api.Assertions.assertNotEquals;
|
|
||||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
|
||||||
|
|
||||||
/** Проверки маскирования и обратного преобразования без подъёма HTTP-слоя. */
|
/** Проверки маскирования и обратного преобразования без подъёма HTTP-слоя. */
|
||||||
class PipelineTest {
|
class PipelineTest {
|
||||||
|
|
||||||
private static final String VALID_CARD = "4111 1111 1111 1111";
|
private static final String VALID_CARD = "4111 1111 1111 1111";
|
||||||
private static final String VALID_INN_12 = "770301234550";
|
private static final String VALID_INN_12 = "770301234550";
|
||||||
private static final String VALID_SNILS = "112-233-445 95";
|
private static final String VALID_SNILS = "112-233-445 95";
|
||||||
|
|
||||||
private Pipeline pipeline() {
|
private Pipeline pipeline() {
|
||||||
return new Pipeline(new RuleRegistry(), new Masker(), new PayloadStore(1_000_000L, 30));
|
return new Pipeline(new RuleRegistry(), new Masker(), new PayloadStore(30));
|
||||||
|
}
|
||||||
|
|
||||||
|
private String mask(Pipeline pipeline, String text) {
|
||||||
|
return pipeline.process(text, UUID.randomUUID().toString(), SystemPolicy.DEFAULT);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void masksCardNumber() {
|
||||||
|
String masked = mask(pipeline(), "Оплата картой " + VALID_CARD + " прошла");
|
||||||
|
assertFalse(masked.contains(VALID_CARD), "номер карты остался в тексте: " + masked);
|
||||||
|
assertTrue(masked.contains("41** **** **** **11"), masked);
|
||||||
|
assertTrue(masked.startsWith("Оплата картой "), "окружающий текст изменён: " + masked);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void keepsNumberThatFailsLuhn() {
|
||||||
|
String text = "Заказ 1234 5678 9012 3456 отгружен";
|
||||||
|
assertEquals(text, mask(pipeline(), text));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void masksEmailKeepingTopLevelDomain() {
|
||||||
|
String masked = mask(pipeline(), "Почта ivan.petrov@mail.ru для связи");
|
||||||
|
assertEquals("Почта i**********@m***.ru для связи", masked);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void masksPhoneInAnyNotation() {
|
||||||
|
Pipeline pipeline = pipeline();
|
||||||
|
for (String phone : new String[] {"+7 (916) 123-45-67", "89161234567", "8 916 123 45 67"}) {
|
||||||
|
String masked = mask(pipeline, "Телефон " + phone);
|
||||||
|
assertFalse(masked.contains(phone), "телефон остался в тексте: " + masked);
|
||||||
|
assertTrue(masked.endsWith("67"), masked);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private String mask(Pipeline pipeline, String text) {
|
@Test
|
||||||
return pipeline.process(text, UUID.randomUUID().toString(), SystemPolicy.DEFAULT);
|
void masksInnByContextAndByChecksum() {
|
||||||
}
|
Pipeline pipeline = pipeline();
|
||||||
|
assertFalse(mask(pipeline, "ИНН: " + VALID_INN_12).contains(VALID_INN_12));
|
||||||
|
assertFalse(mask(pipeline, "Реквизиты " + VALID_INN_12 + " проверены").contains(VALID_INN_12));
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void masksCardNumber() {
|
void masksSnils() {
|
||||||
String masked = mask(pipeline(), "Оплата картой " + VALID_CARD + " прошла");
|
String masked = mask(pipeline(), "СНИЛС " + VALID_SNILS);
|
||||||
assertFalse(masked.contains(VALID_CARD), "номер карты остался в тексте: " + masked);
|
assertFalse(masked.contains(VALID_SNILS), masked);
|
||||||
assertTrue(masked.contains("41** **** **** **11"), masked);
|
}
|
||||||
assertTrue(masked.startsWith("Оплата картой "), "окружающий текст изменён: " + masked);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void keepsNumberThatFailsLuhn() {
|
void unmaskingRestoresOriginalText() {
|
||||||
String text = "Заказ 1234 5678 9012 3456 отгружен";
|
Pipeline pipeline = pipeline();
|
||||||
assertEquals(text, mask(pipeline(), text));
|
String original = "Карта " + VALID_CARD + ", почта ivan@mail.ru, телефон +7 916 123-45-67";
|
||||||
}
|
String id = "pair-1";
|
||||||
|
|
||||||
@Test
|
String masked = pipeline.process(original, id, SystemPolicy.DEFAULT);
|
||||||
void masksEmailKeepingTopLevelDomain() {
|
assertNotEquals(original, masked);
|
||||||
String masked = mask(pipeline(), "Почта ivan.petrov@mail.ru для связи");
|
|
||||||
assertEquals("Почта i**********@m***.ru для связи", masked);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
String restored = pipeline.process(masked, id, SystemPolicy.DEFAULT);
|
||||||
void masksPhoneInAnyNotation() {
|
assertEquals(original, restored);
|
||||||
Pipeline pipeline = pipeline();
|
}
|
||||||
for (String phone : new String[]{"+7 (916) 123-45-67", "89161234567", "8 916 123 45 67"}) {
|
|
||||||
String masked = mask(pipeline, "Телефон " + phone);
|
|
||||||
assertFalse(masked.contains(phone), "телефон остался в тексте: " + masked);
|
|
||||||
assertTrue(masked.endsWith("67"), masked);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void masksInnByContextAndByChecksum() {
|
void retryReturnsSameMask() {
|
||||||
Pipeline pipeline = pipeline();
|
Pipeline pipeline = pipeline();
|
||||||
assertFalse(mask(pipeline, "ИНН: " + VALID_INN_12).contains(VALID_INN_12));
|
String original = "Карта " + VALID_CARD;
|
||||||
assertFalse(mask(pipeline, "Реквизиты " + VALID_INN_12 + " проверены").contains(VALID_INN_12));
|
String id = "retry-1";
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
String first = pipeline.process(original, id, SystemPolicy.DEFAULT);
|
||||||
void masksSnils() {
|
String second = pipeline.process(original, id, SystemPolicy.DEFAULT);
|
||||||
String masked = mask(pipeline(), "СНИЛС " + VALID_SNILS);
|
assertEquals(first, second);
|
||||||
assertFalse(masked.contains(VALID_SNILS), masked);
|
}
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void unmaskingRestoresOriginalText() {
|
void unmasksWhenPayloadIdIsUnknown() {
|
||||||
Pipeline pipeline = pipeline();
|
Pipeline pipeline = pipeline();
|
||||||
String original = "Карта " + VALID_CARD + ", почта ivan@mail.ru, телефон +7 916 123-45-67";
|
String original = "Почта ivan@mail.ru";
|
||||||
String id = "pair-1";
|
String masked = pipeline.process(original, "lost-id", SystemPolicy.DEFAULT);
|
||||||
|
|
||||||
String masked = pipeline.process(original, id, SystemPolicy.DEFAULT);
|
assertEquals(original, pipeline.process(masked, "другой-идентификатор", SystemPolicy.DEFAULT));
|
||||||
assertNotEquals(original, masked);
|
}
|
||||||
|
|
||||||
String restored = pipeline.process(masked, id, SystemPolicy.DEFAULT);
|
@Test
|
||||||
assertEquals(original, restored);
|
void textWithoutPersonalDataIsUnchanged() {
|
||||||
}
|
String text = "Расскажи о погоде в Москве завтра";
|
||||||
|
assertEquals(text, mask(pipeline(), text));
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void retryReturnsSameMask() {
|
void systemPolicyDisablesSelectedTypes() {
|
||||||
Pipeline pipeline = pipeline();
|
Pipeline pipeline = pipeline();
|
||||||
String original = "Карта " + VALID_CARD;
|
SystemPolicy onlyEmail = SystemPolicy.forTypes(PdTypes.EMAIL);
|
||||||
String id = "retry-1";
|
String masked =
|
||||||
|
pipeline.process("Карта " + VALID_CARD + ", почта ivan@mail.ru", "policy-1", onlyEmail);
|
||||||
|
|
||||||
String first = pipeline.process(original, id, SystemPolicy.DEFAULT);
|
assertTrue(
|
||||||
String second = pipeline.process(original, id, SystemPolicy.DEFAULT);
|
masked.contains(VALID_CARD), "карта не должна маскироваться этой системой: " + masked);
|
||||||
assertEquals(first, second);
|
assertFalse(masked.contains("ivan@mail.ru"), masked);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void unmasksWhenPayloadIdIsUnknown() {
|
void handlesLargeText() {
|
||||||
Pipeline pipeline = pipeline();
|
Pipeline pipeline = pipeline();
|
||||||
String original = "Почта ivan@mail.ru";
|
String block = "Клиент написал с адреса ivan@mail.ru и оплатил картой " + VALID_CARD + ". ";
|
||||||
String masked = pipeline.process(original, "lost-id", SystemPolicy.DEFAULT);
|
String large = block.repeat(4000);
|
||||||
|
|
||||||
assertEquals(original, pipeline.process(masked, "другой-идентификатор", SystemPolicy.DEFAULT));
|
long started = System.nanoTime();
|
||||||
}
|
String masked = pipeline.process(large, "large-1", SystemPolicy.DEFAULT);
|
||||||
|
long millis = (System.nanoTime() - started) / 1_000_000;
|
||||||
|
|
||||||
@Test
|
assertFalse(masked.contains("ivan@mail.ru"));
|
||||||
void textWithoutPersonalDataIsUnchanged() {
|
assertEquals(large, pipeline.process(masked, "large-1", SystemPolicy.DEFAULT));
|
||||||
String text = "Расскажи о погоде в Москве завтра";
|
assertTrue(millis < 1000, "обработка крупного текста заняла " + millis + " мс");
|
||||||
assertEquals(text, mask(pipeline(), text));
|
}
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void systemPolicyDisablesSelectedTypes() {
|
|
||||||
Pipeline pipeline = pipeline();
|
|
||||||
SystemPolicy onlyEmail = SystemPolicy.forTypes(PdTypes.EMAIL);
|
|
||||||
String masked = pipeline.process("Карта " + VALID_CARD + ", почта ivan@mail.ru", "policy-1", onlyEmail);
|
|
||||||
|
|
||||||
assertTrue(masked.contains(VALID_CARD), "карта не должна маскироваться этой системой: " + masked);
|
|
||||||
assertFalse(masked.contains("ivan@mail.ru"), masked);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void handlesLargeText() {
|
|
||||||
Pipeline pipeline = pipeline();
|
|
||||||
String block = "Клиент написал с адреса ivan@mail.ru и оплатил картой " + VALID_CARD + ". ";
|
|
||||||
String large = block.repeat(4000);
|
|
||||||
|
|
||||||
long started = System.nanoTime();
|
|
||||||
String masked = pipeline.process(large, "large-1", SystemPolicy.DEFAULT);
|
|
||||||
long millis = (System.nanoTime() - started) / 1_000_000;
|
|
||||||
|
|
||||||
assertFalse(masked.contains("ivan@mail.ru"));
|
|
||||||
assertEquals(large, pipeline.process(masked, "large-1", SystemPolicy.DEFAULT));
|
|
||||||
assertTrue(millis < 1000, "обработка крупного текста заняла " + millis + " мс");
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,14 @@
|
|||||||
package ru.pdguard;
|
package ru.pdguard;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||||
|
import static org.junit.jupiter.api.DynamicTest.dynamicTest;
|
||||||
|
|
||||||
|
import java.nio.file.Files;
|
||||||
|
import java.nio.file.Path;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Optional;
|
||||||
|
import java.util.stream.Stream;
|
||||||
import org.junit.jupiter.api.DynamicTest;
|
import org.junit.jupiter.api.DynamicTest;
|
||||||
import org.junit.jupiter.api.Test;
|
import org.junit.jupiter.api.Test;
|
||||||
import org.junit.jupiter.api.TestFactory;
|
import org.junit.jupiter.api.TestFactory;
|
||||||
@@ -11,111 +20,62 @@ import ru.pdguard.detect.RuleRegistry;
|
|||||||
import ru.pdguard.detect.Span;
|
import ru.pdguard.detect.Span;
|
||||||
import ru.pdguard.mask.Masker;
|
import ru.pdguard.mask.Masker;
|
||||||
|
|
||||||
import java.nio.file.Files;
|
|
||||||
import java.nio.file.Path;
|
|
||||||
import java.util.ArrayList;
|
|
||||||
import java.util.List;
|
|
||||||
import java.util.Optional;
|
|
||||||
import java.util.stream.Stream;
|
|
||||||
|
|
||||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
|
||||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
|
||||||
import static org.junit.jupiter.api.DynamicTest.dynamicTest;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Датасет из 44 сгенерированных строк — не новые ТИПЫ ПДН, а новые РАЗМЕЩЕНИЯ уже
|
* Датасет из 44 сгенерированных строк — не новые ТИПЫ ПДН, а новые РАЗМЕЩЕНИЯ уже известных типов
|
||||||
* известных типов относительно якорного слова: расстояние до якоря, обратный порядок
|
* относительно якорного слова: расстояние до якоря, обратный порядок (значение перед якорем),
|
||||||
* (значение перед якорем), падеж/число анкера, структурированные форматы
|
* падеж/число анкера, структурированные форматы (JSON/CSV/markdown-таблица/XML/key=value),
|
||||||
* (JSON/CSV/markdown-таблица/XML/key=value), несколько значений одного типа в одном
|
* несколько значений одного типа в одном поле, значение в кавычках/скобках. Гипотезы построены на
|
||||||
* поле, значение в кавычках/скобках. Гипотезы построены на 55 утечках из
|
* 55 утечках из реальных логов и обобщают их корневые причины на другие типы и формы. Разбор по
|
||||||
* {@link NodeLogsDatasetTest} и обобщают их корневые причины на другие типы и формы.
|
* категориям — в отчёте, приложенном к задаче.
|
||||||
* Разбор по категориям — в отчёте, приложенном к задаче.
|
|
||||||
*
|
|
||||||
* <p>Как и {@link NodeLogsDatasetTest}, часть строк — подтверждённые утечки
|
|
||||||
* (падение конкретного кейса в {@link #leakSummary()} ожидаемо), часть — контрольные
|
|
||||||
* позитивные случаи, которые обязаны оставаться зелёными: если один из них упадёт,
|
|
||||||
* значит новое размещение сломало то, что раньше работало.
|
|
||||||
*/
|
*/
|
||||||
class PlacementVariantsTest {
|
class PlacementVariantsTest {
|
||||||
|
|
||||||
private static final RuleRegistry REGISTRY = new RuleRegistry();
|
private static final RuleRegistry REGISTRY = new RuleRegistry();
|
||||||
private static final Masker MASKER = new Masker();
|
private static final Masker MASKER = new Masker();
|
||||||
private static final List<BenchmarkFixtures.Sample> DATASET = BenchmarkFixtures.load("/dataset-placements.txt");
|
private static final List<BenchmarkFixtures.Sample> DATASET =
|
||||||
private static final int LEAK_CHECK_MIN_LENGTH = 3;
|
BenchmarkFixtures.load("/dataset-placements.txt");
|
||||||
|
|
||||||
private static final NameCascade CASCADE = modelsPresent()
|
private static final NameCascade CASCADE =
|
||||||
? new NameCascade("wikineural", Optional.of("models/wikineural-ner"),
|
modelsPresent()
|
||||||
"rubert", Optional.of("models/rubert-ner"), 16, 4)
|
? new NameCascade(
|
||||||
: NameCascade.disabled();
|
"wikineural",
|
||||||
|
Optional.of("models/wikineural-ner"),
|
||||||
|
"rubert",
|
||||||
|
Optional.of("models/rubert-ner"),
|
||||||
|
16,
|
||||||
|
4)
|
||||||
|
: NameCascade.disabled();
|
||||||
|
|
||||||
private static boolean modelsPresent() {
|
private static boolean modelsPresent() {
|
||||||
return Files.isReadable(Path.of("models/wikineural-ner/model.onnx"))
|
return Files.isReadable(Path.of("models/wikineural-ner/model.onnx"))
|
||||||
&& Files.isReadable(Path.of("models/rubert-ner/model.onnx"));
|
&& Files.isReadable(Path.of("models/rubert-ner/model.onnx"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@TestFactory
|
||||||
|
Stream<DynamicTest> placementDataset() {
|
||||||
|
List<DynamicTest> cases = new ArrayList<>(DATASET.size());
|
||||||
|
for (int i = 0; i < DATASET.size(); i++) {
|
||||||
|
BenchmarkFixtures.Sample sample = DATASET.get(i);
|
||||||
|
int index = i;
|
||||||
|
cases.add(
|
||||||
|
dynamicTest(
|
||||||
|
String.format("#%02d: %s", index, preview(sample.text())),
|
||||||
|
() -> runRoundTrip(sample, index)));
|
||||||
}
|
}
|
||||||
|
return cases.stream();
|
||||||
|
}
|
||||||
|
|
||||||
@TestFactory
|
/** Демаскирование обязано восстановить исходный текст всегда, независимо от утечек. */
|
||||||
Stream<DynamicTest> placementDataset() {
|
private void runRoundTrip(BenchmarkFixtures.Sample sample, int index) {
|
||||||
List<DynamicTest> cases = new ArrayList<>(DATASET.size());
|
Pipeline pipeline = new Pipeline(REGISTRY, MASKER, new PayloadStore(30), CASCADE);
|
||||||
for (int i = 0; i < DATASET.size(); i++) {
|
String payloadId = "placement-" + index;
|
||||||
BenchmarkFixtures.Sample sample = DATASET.get(i);
|
|
||||||
int index = i;
|
|
||||||
cases.add(dynamicTest(
|
|
||||||
String.format("#%02d: %s", index, preview(sample.text())),
|
|
||||||
() -> runRoundTrip(sample, index)));
|
|
||||||
}
|
|
||||||
return cases.stream();
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Демаскирование обязано восстановить исходный текст всегда, независимо от утечек. */
|
String masked = pipeline.process(sample.text(), payloadId, SystemPolicy.DEFAULT);
|
||||||
private void runRoundTrip(BenchmarkFixtures.Sample sample, int index) {
|
String restored = pipeline.process(masked, payloadId, SystemPolicy.DEFAULT);
|
||||||
Pipeline pipeline = new Pipeline(REGISTRY, MASKER, new PayloadStore(1_000_000L, 30), CASCADE);
|
assertEquals(sample.text(), restored, "демаскирование не восстановило исходный текст");
|
||||||
String payloadId = "placement-" + index;
|
}
|
||||||
|
|
||||||
String masked = pipeline.process(sample.text(), payloadId, SystemPolicy.DEFAULT);
|
private static String preview(String text) {
|
||||||
String restored = pipeline.process(masked, payloadId, SystemPolicy.DEFAULT);
|
return text.length() <= 50 ? text : text.substring(0, 50) + "...";
|
||||||
assertEquals(sample.text(), restored, "демаскирование не восстановило исходный текст");
|
}
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Сводка утечек одним прогоном — печатает список по типам и падает, только если
|
|
||||||
* утечек стало больше 11, то есть если что-то из уже маскируемого сегодня
|
|
||||||
* размещения перестало маскироваться. Было 20 при составлении датасета, после
|
|
||||||
* точечных фиксов RuleRegistry (расширенный разрыв якорь-значение, обратный
|
|
||||||
* порядок для дат/гражданства/кода подразделения) осталось 11.
|
|
||||||
*/
|
|
||||||
@Test
|
|
||||||
void leakSummary() {
|
|
||||||
Pipeline pipeline = new Pipeline(REGISTRY, MASKER, new PayloadStore(10_000_000L, 30), CASCADE);
|
|
||||||
int leaked = 0;
|
|
||||||
int checked = 0;
|
|
||||||
java.util.Map<String, Integer> byType = new java.util.LinkedHashMap<>();
|
|
||||||
StringBuilder report = new StringBuilder("\n=== Утечки по dataset-placements.txt ===\n");
|
|
||||||
|
|
||||||
for (int i = 0; i < DATASET.size(); i++) {
|
|
||||||
BenchmarkFixtures.Sample sample = DATASET.get(i);
|
|
||||||
String masked = pipeline.process(sample.text(), "leak-scan-" + i, SystemPolicy.DEFAULT);
|
|
||||||
for (Span gold : sample.gold()) {
|
|
||||||
String value = sample.text().substring(gold.start(), gold.end());
|
|
||||||
if (value.length() < LEAK_CHECK_MIN_LENGTH) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
checked++;
|
|
||||||
if (masked.contains(value)) {
|
|
||||||
leaked++;
|
|
||||||
byType.merge(gold.type(), 1, Integer::sum);
|
|
||||||
report.append(String.format(" [%s] %s%n", gold.type(), value));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
report.append(String.format("%nВсего: %d утечек из %d эталонных фрагментов%n", leaked, checked));
|
|
||||||
byType.forEach((type, count) -> report.append(String.format(" %-16s %d%n", type, count)));
|
|
||||||
System.out.println(report);
|
|
||||||
|
|
||||||
assertTrue(leaked <= 9,
|
|
||||||
"утечек стало больше 9 (было после точечных фиксов RuleRegistry) — новая регрессия: " + leaked);
|
|
||||||
}
|
|
||||||
|
|
||||||
private static String preview(String text) {
|
|
||||||
return text.length() <= 50 ? text : text.substring(0, 50) + "...";
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,214 +1,294 @@
|
|||||||
package ru.pdguard;
|
package ru.pdguard;
|
||||||
|
|
||||||
import org.junit.jupiter.api.Test;
|
|
||||||
import org.springframework.boot.test.context.SpringBootTest;
|
|
||||||
import org.springframework.boot.test.web.server.LocalServerPort;
|
|
||||||
|
|
||||||
import java.util.Map;
|
|
||||||
|
|
||||||
import static io.restassured.RestAssured.given;
|
import static io.restassured.RestAssured.given;
|
||||||
import static org.hamcrest.Matchers.equalTo;
|
import static org.hamcrest.Matchers.equalTo;
|
||||||
import static org.hamcrest.Matchers.not;
|
import static org.hamcrest.Matchers.not;
|
||||||
|
|
||||||
|
import java.util.Map;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.springframework.boot.test.context.SpringBootTest;
|
||||||
|
import org.springframework.boot.test.web.server.LocalServerPort;
|
||||||
|
|
||||||
/** Проверка контракта: форма запроса и ответа, пара «маскирование — демаскирование». */
|
/** Проверка контракта: форма запроса и ответа, пара «маскирование — демаскирование». */
|
||||||
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
|
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
|
||||||
class ProcessResourceTest {
|
class ProcessResourceTest {
|
||||||
|
|
||||||
@LocalServerPort
|
@LocalServerPort int port;
|
||||||
int port;
|
|
||||||
|
|
||||||
private String post(String payload, String payloadId) {
|
private String post(String payload, String payloadId) {
|
||||||
return given()
|
return given()
|
||||||
.port(port)
|
.port(port)
|
||||||
.contentType("application/json")
|
.contentType("application/json")
|
||||||
.body(Map.of("payload", payload, "payload_id", payloadId))
|
.body(Map.of("payload", payload, "payload_id", payloadId))
|
||||||
.when().post("/process")
|
.when()
|
||||||
.then().statusCode(200)
|
.post("/process")
|
||||||
.extract().path("result");
|
.then()
|
||||||
}
|
.statusCode(200)
|
||||||
|
.extract()
|
||||||
|
.path("result");
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void maskAndUnmaskPair() {
|
void maskAndUnmaskPair() {
|
||||||
String original = "Клиент оставил почту ivan.petrov@mail.ru и телефон +7 916 123-45-67";
|
String original = "Клиент оставил почту ivan.petrov@mail.ru и телефон +7 916 123-45-67";
|
||||||
String id = "8a77d363c7c044b49b41d7b8a448243a";
|
String id = "8a77d363c7c044b49b41d7b8a448243a";
|
||||||
|
|
||||||
String masked = post(original, id);
|
String masked = post(original, id);
|
||||||
org.junit.jupiter.api.Assertions.assertNotEquals(original, masked);
|
org.junit.jupiter.api.Assertions.assertNotEquals(original, masked);
|
||||||
org.junit.jupiter.api.Assertions.assertEquals(original, post(masked, id));
|
org.junit.jupiter.api.Assertions.assertEquals(original, post(masked, id));
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void rejectsRequestWithoutRequiredFields() {
|
void rejectsRequestWithoutRequiredFields() {
|
||||||
|
given()
|
||||||
|
.port(port)
|
||||||
|
.contentType("application/json")
|
||||||
|
.body(Map.of("payload", "текст"))
|
||||||
|
.when()
|
||||||
|
.post("/process")
|
||||||
|
.then()
|
||||||
|
.statusCode(400);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void healthProbeResponds() {
|
||||||
|
given().port(port).when().get("/health").then().statusCode(200).body(equalTo("OK"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void disabledSystemIsRefused() {
|
||||||
|
given()
|
||||||
|
.port(port)
|
||||||
|
.contentType("application/json")
|
||||||
|
.header("X-System-Id", "disabled")
|
||||||
|
.body(Map.of("payload", "Карта 4111 1111 1111 1111", "payload_id", "sys-1"))
|
||||||
|
.when()
|
||||||
|
.post("/process")
|
||||||
|
.then()
|
||||||
|
.statusCode(403);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void systemPolicySelectsMaskMode() {
|
||||||
|
String masked =
|
||||||
given()
|
given()
|
||||||
.port(port)
|
.port(port)
|
||||||
.contentType("application/json")
|
.contentType("application/json")
|
||||||
.body(Map.of("payload", "текст"))
|
.header("X-System-Id", "crm")
|
||||||
.when().post("/process")
|
.body(Map.of("payload", "Клиент Иванов Иван Иванович", "payload_id", "sys-2"))
|
||||||
.then().statusCode(400);
|
.when()
|
||||||
}
|
.post("/process")
|
||||||
|
.then()
|
||||||
|
.statusCode(200)
|
||||||
|
.extract()
|
||||||
|
.path("result");
|
||||||
|
|
||||||
@Test
|
org.junit.jupiter.api.Assertions.assertTrue(masked.contains("[FIO_1]"), masked);
|
||||||
void healthProbeResponds() {
|
}
|
||||||
given().port(port).when().get("/health").then().statusCode(200).body(equalTo("OK"));
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void disabledSystemIsRefused() {
|
void unknownSystemFallsBackToDefaultPolicy() {
|
||||||
|
given()
|
||||||
|
.port(port)
|
||||||
|
.contentType("application/json")
|
||||||
|
.header("X-System-Id", "неизвестная-система")
|
||||||
|
.body(Map.of("payload", "почта ivan@mail.ru", "payload_id", "sys-3"))
|
||||||
|
.when()
|
||||||
|
.post("/process")
|
||||||
|
.then()
|
||||||
|
.statusCode(200)
|
||||||
|
.body("result", equalTo("почта i***@m***.ru"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void metricsExposeLatencyAndTokenCounters() {
|
||||||
|
post("Клиент Иванов Иван Иванович", "metrics-1");
|
||||||
|
|
||||||
|
String body =
|
||||||
given()
|
given()
|
||||||
.port(port)
|
.port(port)
|
||||||
.contentType("application/json")
|
.when()
|
||||||
.header("X-System-Id", "disabled")
|
.get("/actuator/prometheus")
|
||||||
.body(Map.of("payload", "Карта 4111 1111 1111 1111", "payload_id", "sys-1"))
|
.then()
|
||||||
.when().post("/process")
|
.statusCode(200)
|
||||||
.then().statusCode(403);
|
.extract()
|
||||||
}
|
.asString();
|
||||||
|
org.junit.jupiter.api.Assertions.assertTrue(
|
||||||
|
body.contains("pdguard_process_seconds"), "нет метрики задержки");
|
||||||
|
org.junit.jupiter.api.Assertions.assertTrue(
|
||||||
|
body.contains("pdguard_tokens_processed_total"), "нет метрики TPS");
|
||||||
|
org.junit.jupiter.api.Assertions.assertTrue(
|
||||||
|
body.contains("pdguard_pd_detected_total"), "нет метрики типов ПД");
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
/**
|
||||||
void systemPolicySelectsMaskMode() {
|
* Соответствия разделены по системам. Маски детерминированы и низкоэнтропийны, поэтому без
|
||||||
String masked = given()
|
* разделения, прислав чужую маску, можно было бы получить исходные данные другого потребителя.
|
||||||
.port(port)
|
*/
|
||||||
.contentType("application/json")
|
@Test
|
||||||
.header("X-System-Id", "crm")
|
void anotherSystemCannotExchangeMaskForOriginal() {
|
||||||
.body(Map.of("payload", "Клиент Иванов Иван Иванович", "payload_id", "sys-2"))
|
String original = "Клиент Иванов Иван Иванович, паспорт 4509 123456";
|
||||||
.when().post("/process")
|
String id = "cross-system-1";
|
||||||
.then().statusCode(200)
|
|
||||||
.extract().path("result");
|
|
||||||
|
|
||||||
org.junit.jupiter.api.Assertions.assertTrue(masked.contains("[FIO_1]"), masked);
|
String masked =
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void unknownSystemFallsBackToDefaultPolicy() {
|
|
||||||
given()
|
given()
|
||||||
.port(port)
|
.port(port)
|
||||||
.contentType("application/json")
|
.contentType("application/json")
|
||||||
.header("X-System-Id", "неизвестная-система")
|
.body(Map.of("payload", original, "payload_id", id))
|
||||||
.body(Map.of("payload", "почта ivan@mail.ru", "payload_id", "sys-3"))
|
.when()
|
||||||
.when().post("/process")
|
.post("/process")
|
||||||
.then().statusCode(200)
|
.then()
|
||||||
.body("result", equalTo("почта i***@m***.ru"));
|
.statusCode(200)
|
||||||
}
|
.extract()
|
||||||
|
.path("result");
|
||||||
|
org.junit.jupiter.api.Assertions.assertNotEquals(original, masked);
|
||||||
|
|
||||||
@Test
|
String byOther =
|
||||||
void metricsExposeLatencyAndTokenCounters() {
|
|
||||||
post("Клиент Иванов Иван Иванович", "metrics-1");
|
|
||||||
|
|
||||||
String body = given().port(port).when().get("/actuator/prometheus").then().statusCode(200).extract().asString();
|
|
||||||
org.junit.jupiter.api.Assertions.assertTrue(body.contains("pdguard_process_seconds"), "нет метрики задержки");
|
|
||||||
org.junit.jupiter.api.Assertions.assertTrue(body.contains("pdguard_tokens_processed_total"), "нет метрики TPS");
|
|
||||||
org.junit.jupiter.api.Assertions.assertTrue(body.contains("pdguard_pd_detected_total"), "нет метрики типов ПД");
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Соответствия разделены по системам. Маски детерминированы и низкоэнтропийны,
|
|
||||||
* поэтому без разделения, прислав чужую маску, можно было бы получить исходные
|
|
||||||
* данные другого потребителя.
|
|
||||||
*/
|
|
||||||
@Test
|
|
||||||
void anotherSystemCannotExchangeMaskForOriginal() {
|
|
||||||
String original = "Клиент Иванов Иван Иванович, паспорт 4509 123456";
|
|
||||||
String id = "cross-system-1";
|
|
||||||
|
|
||||||
String masked = given()
|
|
||||||
.port(port)
|
|
||||||
.contentType("application/json")
|
|
||||||
.body(Map.of("payload", original, "payload_id", id))
|
|
||||||
.when().post("/process")
|
|
||||||
.then().statusCode(200)
|
|
||||||
.extract().path("result");
|
|
||||||
org.junit.jupiter.api.Assertions.assertNotEquals(original, masked);
|
|
||||||
|
|
||||||
String byOther = given()
|
|
||||||
.port(port)
|
|
||||||
.contentType("application/json")
|
|
||||||
.header("X-System-Id", "other")
|
|
||||||
.body(Map.of("payload", masked, "payload_id", "совсем-другой-id"))
|
|
||||||
.when().post("/process")
|
|
||||||
.then().statusCode(200)
|
|
||||||
.extract().path("result");
|
|
||||||
org.junit.jupiter.api.Assertions.assertNotEquals(original, byOther,
|
|
||||||
"чужая система не должна получать исходный текст по маске");
|
|
||||||
|
|
||||||
String bySameSystem = given()
|
|
||||||
.port(port)
|
|
||||||
.contentType("application/json")
|
|
||||||
.body(Map.of("payload", masked, "payload_id", id))
|
|
||||||
.when().post("/process")
|
|
||||||
.then().statusCode(200)
|
|
||||||
.extract().path("result");
|
|
||||||
org.junit.jupiter.api.Assertions.assertEquals(original, bySameSystem,
|
|
||||||
"своя система по своему идентификатору исходный текст получает");
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void systemWithKeyRequiresIt() {
|
|
||||||
given().port(port).contentType("application/json")
|
|
||||||
.header("X-System-Id", "guarded")
|
|
||||||
.body(Map.of("payload", "Карта 4111 1111 1111 1111", "payload_id", "key-1"))
|
|
||||||
.when().post("/process").then().statusCode(403);
|
|
||||||
|
|
||||||
given().port(port).contentType("application/json")
|
|
||||||
.header("X-System-Id", "guarded")
|
|
||||||
.header("X-System-Key", "wrong-key")
|
|
||||||
.body(Map.of("payload", "Карта 4111 1111 1111 1111", "payload_id", "key-2"))
|
|
||||||
.when().post("/process").then().statusCode(403);
|
|
||||||
|
|
||||||
given().port(port).contentType("application/json")
|
|
||||||
.header("X-System-Id", "guarded")
|
|
||||||
.header("X-System-Key", "s3cret-key-2026")
|
|
||||||
.body(Map.of("payload", "Карта 4111 1111 1111 1111", "payload_id", "key-3"))
|
|
||||||
.when().post("/process").then().statusCode(200);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void systemWithoutKeyWorksWithoutIt() {
|
|
||||||
given().port(port).contentType("application/json")
|
|
||||||
.body(Map.of("payload", "Карта 4111 1111 1111 1111", "payload_id", "key-4"))
|
|
||||||
.when().post("/process").then().statusCode(200);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void metricsCountSecondStageInvocations() {
|
|
||||||
post("Клиент Иванов Иван Иванович", "ner-metrics-1");
|
|
||||||
|
|
||||||
String body = given().port(port).when().get("/actuator/prometheus").then().statusCode(200).extract().asString();
|
|
||||||
org.junit.jupiter.api.Assertions.assertTrue(body.contains("pdguard_ner_requests_total"),
|
|
||||||
"нет счётчика обращений ко второй ступени");
|
|
||||||
org.junit.jupiter.api.Assertions.assertTrue(body.contains("pdguard_ner_candidates_total"),
|
|
||||||
"нет счётчика участков, отданных модели");
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void metricsDoNotLeakPersonalData() {
|
|
||||||
post("Клиент Иванов Иван Иванович, карта 4111 1111 1111 1111", "metrics-2");
|
|
||||||
|
|
||||||
String body = given().port(port).when().get("/actuator/prometheus").then().statusCode(200).extract().asString();
|
|
||||||
// Значения метрик — это числа, и цифры из ПД могут случайно совпасть с ними.
|
|
||||||
// Утечка возможна только через имена и метки, поэтому значения отбрасываем.
|
|
||||||
String namesAndLabels = body.lines()
|
|
||||||
.filter(line -> !line.startsWith("#"))
|
|
||||||
.map(line -> line.contains(" ") ? line.substring(0, line.lastIndexOf(' ')) : line)
|
|
||||||
.reduce("", (a, b) -> a + "\n" + b);
|
|
||||||
|
|
||||||
org.junit.jupiter.api.Assertions.assertFalse(namesAndLabels.contains("Иванов"), "ПД попали в метрики");
|
|
||||||
org.junit.jupiter.api.Assertions.assertFalse(namesAndLabels.contains("4111"), "ПД попали в метрики");
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void adminShowsSystemsAndTypes() {
|
|
||||||
given().port(port).when().get("/admin/config").then().statusCode(200).body("crm.maskMode", equalTo("TOKEN"));
|
|
||||||
given().port(port).when().get("/admin/types").then().statusCode(200);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void textWithoutPersonalDataIsReturnedAsIs() {
|
|
||||||
given()
|
given()
|
||||||
.port(port)
|
.port(port)
|
||||||
.contentType("application/json")
|
.contentType("application/json")
|
||||||
.body(Map.of("payload", "тестовая строка", "payload_id", "selfcheck-1"))
|
.header("X-System-Id", "other")
|
||||||
.when().post("/process")
|
.body(Map.of("payload", masked, "payload_id", "совсем-другой-id"))
|
||||||
.then().statusCode(200)
|
.when()
|
||||||
.body("result", equalTo("тестовая строка"))
|
.post("/process")
|
||||||
.body("result", not(equalTo("")));
|
.then()
|
||||||
}
|
.statusCode(200)
|
||||||
}
|
.extract()
|
||||||
|
.path("result");
|
||||||
|
org.junit.jupiter.api.Assertions.assertNotEquals(
|
||||||
|
original, byOther, "чужая система не должна получать исходный текст по маске");
|
||||||
|
|
||||||
|
String bySameSystem =
|
||||||
|
given()
|
||||||
|
.port(port)
|
||||||
|
.contentType("application/json")
|
||||||
|
.body(Map.of("payload", masked, "payload_id", id))
|
||||||
|
.when()
|
||||||
|
.post("/process")
|
||||||
|
.then()
|
||||||
|
.statusCode(200)
|
||||||
|
.extract()
|
||||||
|
.path("result");
|
||||||
|
org.junit.jupiter.api.Assertions.assertEquals(
|
||||||
|
original, bySameSystem, "своя система по своему идентификатору исходный текст получает");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void systemWithKeyRequiresIt() {
|
||||||
|
given()
|
||||||
|
.port(port)
|
||||||
|
.contentType("application/json")
|
||||||
|
.header("X-System-Id", "guarded")
|
||||||
|
.body(Map.of("payload", "Карта 4111 1111 1111 1111", "payload_id", "key-1"))
|
||||||
|
.when()
|
||||||
|
.post("/process")
|
||||||
|
.then()
|
||||||
|
.statusCode(403);
|
||||||
|
|
||||||
|
given()
|
||||||
|
.port(port)
|
||||||
|
.contentType("application/json")
|
||||||
|
.header("X-System-Id", "guarded")
|
||||||
|
.header("X-System-Key", "wrong-key")
|
||||||
|
.body(Map.of("payload", "Карта 4111 1111 1111 1111", "payload_id", "key-2"))
|
||||||
|
.when()
|
||||||
|
.post("/process")
|
||||||
|
.then()
|
||||||
|
.statusCode(403);
|
||||||
|
|
||||||
|
given()
|
||||||
|
.port(port)
|
||||||
|
.contentType("application/json")
|
||||||
|
.header("X-System-Id", "guarded")
|
||||||
|
.header("X-System-Key", "s3cret-key-2026")
|
||||||
|
.body(Map.of("payload", "Карта 4111 1111 1111 1111", "payload_id", "key-3"))
|
||||||
|
.when()
|
||||||
|
.post("/process")
|
||||||
|
.then()
|
||||||
|
.statusCode(200);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void systemWithoutKeyWorksWithoutIt() {
|
||||||
|
given()
|
||||||
|
.port(port)
|
||||||
|
.contentType("application/json")
|
||||||
|
.body(Map.of("payload", "Карта 4111 1111 1111 1111", "payload_id", "key-4"))
|
||||||
|
.when()
|
||||||
|
.post("/process")
|
||||||
|
.then()
|
||||||
|
.statusCode(200);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void metricsCountSecondStageInvocations() {
|
||||||
|
post("Клиент Иванов Иван Иванович", "ner-metrics-1");
|
||||||
|
|
||||||
|
String body =
|
||||||
|
given()
|
||||||
|
.port(port)
|
||||||
|
.when()
|
||||||
|
.get("/actuator/prometheus")
|
||||||
|
.then()
|
||||||
|
.statusCode(200)
|
||||||
|
.extract()
|
||||||
|
.asString();
|
||||||
|
org.junit.jupiter.api.Assertions.assertTrue(
|
||||||
|
body.contains("pdguard_ner_requests_total"), "нет счётчика обращений ко второй ступени");
|
||||||
|
org.junit.jupiter.api.Assertions.assertTrue(
|
||||||
|
body.contains("pdguard_ner_candidates_total"), "нет счётчика участков, отданных модели");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void metricsDoNotLeakPersonalData() {
|
||||||
|
post("Клиент Иванов Иван Иванович, карта 4111 1111 1111 1111", "metrics-2");
|
||||||
|
|
||||||
|
String body =
|
||||||
|
given()
|
||||||
|
.port(port)
|
||||||
|
.when()
|
||||||
|
.get("/actuator/prometheus")
|
||||||
|
.then()
|
||||||
|
.statusCode(200)
|
||||||
|
.extract()
|
||||||
|
.asString();
|
||||||
|
// Значения метрик — это числа, и цифры из ПД могут случайно совпасть с ними.
|
||||||
|
// Утечка возможна только через имена и метки, поэтому значения отбрасываем.
|
||||||
|
String namesAndLabels =
|
||||||
|
body.lines()
|
||||||
|
.filter(line -> !line.startsWith("#"))
|
||||||
|
.map(line -> line.contains(" ") ? line.substring(0, line.lastIndexOf(' ')) : line)
|
||||||
|
.reduce("", (a, b) -> a + "\n" + b);
|
||||||
|
|
||||||
|
org.junit.jupiter.api.Assertions.assertFalse(
|
||||||
|
namesAndLabels.contains("Иванов"), "ПД попали в метрики");
|
||||||
|
org.junit.jupiter.api.Assertions.assertFalse(
|
||||||
|
namesAndLabels.contains("4111"), "ПД попали в метрики");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void adminShowsSystemsAndTypes() {
|
||||||
|
given()
|
||||||
|
.port(port)
|
||||||
|
.when()
|
||||||
|
.get("/admin/config")
|
||||||
|
.then()
|
||||||
|
.statusCode(200)
|
||||||
|
.body("crm.maskMode", equalTo("TOKEN"));
|
||||||
|
given().port(port).when().get("/admin/types").then().statusCode(200);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void textWithoutPersonalDataIsReturnedAsIs() {
|
||||||
|
given()
|
||||||
|
.port(port)
|
||||||
|
.contentType("application/json")
|
||||||
|
.body(Map.of("payload", "тестовая строка", "payload_id", "selfcheck-1"))
|
||||||
|
.when()
|
||||||
|
.post("/process")
|
||||||
|
.then()
|
||||||
|
.statusCode(200)
|
||||||
|
.body("result", equalTo("тестовая строка"))
|
||||||
|
.body("result", not(equalTo("")));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,88 +1,102 @@
|
|||||||
package ru.pdguard;
|
package ru.pdguard;
|
||||||
|
|
||||||
import io.restassured.path.json.JsonPath;
|
|
||||||
import org.junit.jupiter.api.Test;
|
|
||||||
import org.springframework.boot.test.context.SpringBootTest;
|
|
||||||
import org.springframework.boot.test.web.server.LocalServerPort;
|
|
||||||
|
|
||||||
import java.util.Map;
|
|
||||||
|
|
||||||
import static io.restassured.RestAssured.given;
|
import static io.restassured.RestAssured.given;
|
||||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||||
|
|
||||||
|
import io.restassured.path.json.JsonPath;
|
||||||
|
import java.util.Map;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.springframework.boot.test.context.SpringBootTest;
|
||||||
|
import org.springframework.boot.test.web.server.LocalServerPort;
|
||||||
|
|
||||||
/** Демонстрационное плечо: потребитель → маскирование → LLM → демаскирование. */
|
/** Демонстрационное плечо: потребитель → маскирование → LLM → демаскирование. */
|
||||||
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
|
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
|
||||||
class ProxyResourceTest {
|
class ProxyResourceTest {
|
||||||
|
|
||||||
@LocalServerPort
|
@LocalServerPort int port;
|
||||||
int port;
|
|
||||||
|
|
||||||
private static final String PROMPT =
|
private static final String PROMPT =
|
||||||
"Составь письмо клиенту Иванову Ивану Ивановичу, паспорт 4509 123456, почта ivan@mail.ru";
|
"Составь письмо клиенту Иванову Ивану Ивановичу, паспорт 4509 123456, почта ivan@mail.ru";
|
||||||
|
|
||||||
private JsonPath proxy(String prompt) {
|
private JsonPath proxy(String prompt) {
|
||||||
return given()
|
return given()
|
||||||
.port(port)
|
.port(port)
|
||||||
.contentType("application/json")
|
.contentType("application/json")
|
||||||
.body(Map.of("prompt", prompt))
|
.body(Map.of("prompt", prompt))
|
||||||
.when().post("/proxy")
|
.when()
|
||||||
.then().statusCode(200)
|
.post("/proxy")
|
||||||
.extract().jsonPath();
|
.then()
|
||||||
}
|
.statusCode(200)
|
||||||
|
.extract()
|
||||||
|
.jsonPath();
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void personalDataDoesNotReachTheModel() {
|
void personalDataDoesNotReachTheModel() {
|
||||||
JsonPath json = proxy(PROMPT);
|
JsonPath json = proxy(PROMPT);
|
||||||
String toModel = json.getString("prompt_masked");
|
String toModel = json.getString("prompt_masked");
|
||||||
|
|
||||||
assertFalse(toModel.contains("Иванову Ивану Ивановичу"), toModel);
|
assertFalse(toModel.contains("Иванову Ивану Ивановичу"), toModel);
|
||||||
assertFalse(toModel.contains("4509 123456"), toModel);
|
assertFalse(toModel.contains("4509 123456"), toModel);
|
||||||
assertFalse(toModel.contains("ivan@mail.ru"), toModel);
|
assertFalse(toModel.contains("ivan@mail.ru"), toModel);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void consumerGetsTheAnswerWithOriginalValues() {
|
void consumerGetsTheAnswerWithOriginalValues() {
|
||||||
JsonPath json = proxy(PROMPT);
|
JsonPath json = proxy(PROMPT);
|
||||||
String answer = json.getString("response");
|
String answer = json.getString("response");
|
||||||
|
|
||||||
assertTrue(answer.contains("Иванову Ивану Ивановичу"), answer);
|
assertTrue(answer.contains("Иванову Ивану Ивановичу"), answer);
|
||||||
assertTrue(answer.contains("4509 123456"), answer);
|
assertTrue(answer.contains("4509 123456"), answer);
|
||||||
assertTrue(answer.contains("ivan@mail.ru"), answer);
|
assertTrue(answer.contains("ivan@mail.ru"), answer);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void responseShowsTheWholeChain() {
|
void responseShowsTheWholeChain() {
|
||||||
JsonPath json = proxy(PROMPT);
|
JsonPath json = proxy(PROMPT);
|
||||||
|
|
||||||
assertTrue(json.getString("prompt_masked").contains("[FIO_1]"),
|
assertTrue(
|
||||||
"в модель уходит обратимая подстановка: " + json.getString("prompt_masked"));
|
json.getString("prompt_masked").contains("[FIO_1]"),
|
||||||
assertTrue(json.getString("llm_response_masked").contains("[FIO_1]"),
|
"в модель уходит обратимая подстановка: " + json.getString("prompt_masked"));
|
||||||
"ответ модели ещё содержит подстановки");
|
assertTrue(
|
||||||
assertFalse(json.getString("response").contains("[FIO_1]"),
|
json.getString("llm_response_masked").contains("[FIO_1]"),
|
||||||
"потребителю подстановки не видны");
|
"ответ модели ещё содержит подстановки");
|
||||||
assertEquals("заглушка", json.getString("llm"));
|
assertFalse(json.getString("response").contains("[FIO_1]"), "потребителю подстановки не видны");
|
||||||
assertFalse(json.getMap("replaced").isEmpty(), "таблица замен не должна быть пустой");
|
assertEquals("заглушка", json.getString("llm"));
|
||||||
}
|
assertFalse(json.getMap("replaced").isEmpty(), "таблица замен не должна быть пустой");
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void textWithoutPersonalDataPassesThrough() {
|
void textWithoutPersonalDataPassesThrough() {
|
||||||
JsonPath json = proxy("Объясни разницу между вкладом и накопительным счётом");
|
JsonPath json = proxy("Объясни разницу между вкладом и накопительным счётом");
|
||||||
assertEquals("Объясни разницу между вкладом и накопительным счётом", json.getString("prompt_masked"));
|
assertEquals(
|
||||||
}
|
"Объясни разницу между вкладом и накопительным счётом", json.getString("prompt_masked"));
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void rejectsEmptyPrompt() {
|
void rejectsEmptyPrompt() {
|
||||||
given().port(port).contentType("application/json").body(Map.of("prompt", " "))
|
given()
|
||||||
.when().post("/proxy").then().statusCode(400);
|
.port(port)
|
||||||
}
|
.contentType("application/json")
|
||||||
|
.body(Map.of("prompt", " "))
|
||||||
|
.when()
|
||||||
|
.post("/proxy")
|
||||||
|
.then()
|
||||||
|
.statusCode(400);
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void disabledSystemIsRefused() {
|
void disabledSystemIsRefused() {
|
||||||
given().port(port).contentType("application/json")
|
given()
|
||||||
.header("X-System-Id", "disabled")
|
.port(port)
|
||||||
.body(Map.of("prompt", PROMPT))
|
.contentType("application/json")
|
||||||
.when().post("/proxy").then().statusCode(403);
|
.header("X-System-Id", "disabled")
|
||||||
}
|
.body(Map.of("prompt", PROMPT))
|
||||||
}
|
.when()
|
||||||
|
.post("/proxy")
|
||||||
|
.then()
|
||||||
|
.statusCode(403);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,5 +1,9 @@
|
|||||||
package ru.pdguard;
|
package ru.pdguard;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||||
|
|
||||||
|
import java.util.UUID;
|
||||||
import org.junit.jupiter.api.Test;
|
import org.junit.jupiter.api.Test;
|
||||||
import ru.pdguard.config.SystemPolicy;
|
import ru.pdguard.config.SystemPolicy;
|
||||||
import ru.pdguard.core.PayloadStore;
|
import ru.pdguard.core.PayloadStore;
|
||||||
@@ -7,113 +11,107 @@ import ru.pdguard.core.Pipeline;
|
|||||||
import ru.pdguard.detect.RuleRegistry;
|
import ru.pdguard.detect.RuleRegistry;
|
||||||
import ru.pdguard.mask.Masker;
|
import ru.pdguard.mask.Masker;
|
||||||
|
|
||||||
import java.util.UUID;
|
|
||||||
|
|
||||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
|
||||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Адрес не только в городе: правило {@code ADDRESS_CITY} расширено якорями
|
* Адрес не только в городе: правило {@code ADDRESS_CITY} расширено якорями на сёла, посёлки,
|
||||||
* на сёла, посёлки, деревни, хутора, станицы, аулы и аалы — раньше словарь
|
* деревни, хутора, станицы, аулы и аалы — раньше словарь ограничивался официальными городами
|
||||||
* ограничивался официальными городами (~1100), и «рп. Ильинское»/«с. Кукуево»
|
* (~1100), и «рп. Ильинское»/«с. Кукуево» из ТЗ не находились вообще, дело было не в качестве
|
||||||
* из ТЗ не находились вообще, дело было не в качестве детекции, а в том, что
|
* детекции, а в том, что искать было негде.
|
||||||
* искать было негде.
|
|
||||||
*/
|
*/
|
||||||
class SettlementTest {
|
class SettlementTest {
|
||||||
|
|
||||||
private final Pipeline pipeline =
|
private final Pipeline pipeline =
|
||||||
new Pipeline(new RuleRegistry(), new Masker(), new PayloadStore(1_000_000L, 30));
|
new Pipeline(new RuleRegistry(), new Masker(), new PayloadStore(30));
|
||||||
|
|
||||||
private String mask(String text) {
|
private String mask(String text) {
|
||||||
return pipeline.process(text, UUID.randomUUID().toString(), SystemPolicy.DEFAULT);
|
return pipeline.process(text, UUID.randomUUID().toString(), SystemPolicy.DEFAULT);
|
||||||
}
|
}
|
||||||
|
|
||||||
private void assertHidden(String text, String secret) {
|
private void assertHidden(String text, String secret) {
|
||||||
String masked = mask(text);
|
String masked = mask(text);
|
||||||
assertFalse(masked.contains(secret), "не замаскировано: «" + secret + "» в ответе «" + masked + "»");
|
assertFalse(
|
||||||
}
|
masked.contains(secret), "не замаскировано: «" + secret + "» в ответе «" + masked + "»");
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void masksVillage() {
|
void masksVillage() {
|
||||||
assertHidden("Клиент проживает в с. Кукуево постоянно", "Кукуево");
|
assertHidden("Клиент проживает в с. Кукуево постоянно", "Кукуево");
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void masksWorkersSettlement() {
|
void masksWorkersSettlement() {
|
||||||
assertHidden("Регистрация по адресу: рп. Ильинское", "Ильинское");
|
assertHidden("Регистрация по адресу: рп. Ильинское", "Ильинское");
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void masksUrbanTypeSettlement() {
|
void masksUrbanTypeSettlement() {
|
||||||
assertHidden("Доставка курьером в пгт. Энем", "Энем");
|
assertHidden("Доставка курьером в пгт. Энем", "Энем");
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void masksHamlet() {
|
void masksHamlet() {
|
||||||
assertHidden("Дом находится в д. Аксеновка", "Аксеновка");
|
assertHidden("Дом находится в д. Аксеновка", "Аксеновка");
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void masksFarmstead() {
|
void masksFarmstead() {
|
||||||
assertHidden("Клиент родом из х. Прогресс", "Прогресс");
|
assertHidden("Клиент родом из х. Прогресс", "Прогресс");
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void masksCossackStanitsa() {
|
void masksCossackStanitsa() {
|
||||||
assertHidden("Прописан в ст-ца Гиагинская", "Гиагинская");
|
assertHidden("Прописан в ст-ца Гиагинская", "Гиагинская");
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void masksAul() {
|
void masksAul() {
|
||||||
assertHidden("Живёт в аул Блечепсин", "Блечепсин");
|
assertHidden("Живёт в аул Блечепсин", "Блечепсин");
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void masksSloboda() {
|
void masksSloboda() {
|
||||||
assertHidden("Проживает в сл. Екатериновка", "Екатериновка");
|
assertHidden("Проживает в сл. Екатериновка", "Екатериновка");
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void masksAal() {
|
void masksAal() {
|
||||||
assertHidden("Зарегистрирован в аал Сартыков", "Сартыков");
|
assertHidden("Зарегистрирован в аал Сартыков", "Сартыков");
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Словарь не подтверждает выдуманное название — якорь один в один, а маски нет. */
|
/** Словарь не подтверждает выдуманное название — якорь один в один, а маски нет. */
|
||||||
@Test
|
@Test
|
||||||
void doesNotMaskFictionalPlaceNameDespiteRealAnchor() {
|
void doesNotMaskFictionalPlaceNameDespiteRealAnchor() {
|
||||||
String text = "Клиент проживает в с. Мнимогорск";
|
String text = "Клиент проживает в с. Мнимогорск";
|
||||||
assertEquals(text, mask(text), "выдуманное название не должно проходить словарь");
|
assertEquals(text, mask(text), "выдуманное название не должно проходить словарь");
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Вето по организационному контексту работает и для новых типов НП, не только для городов. */
|
/** Вето по организационному контексту работает и для новых типов НП, не только для городов. */
|
||||||
@Test
|
@Test
|
||||||
void doesNotMaskOrganisationAddressInVillage() {
|
void doesNotMaskOrganisationAddressInVillage() {
|
||||||
String text = "Ближайшее отделение банка находится в с. Кукуево";
|
String text = "Ближайшее отделение банка находится в с. Кукуево";
|
||||||
assertEquals(text, mask(text), "адрес отделения банка не является ПД клиента");
|
assertEquals(text, mask(text), "адрес отделения банка не является ПД клиента");
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void anchorsAreCaseInsensitive() {
|
void anchorsAreCaseInsensitive() {
|
||||||
assertHidden("клиент проживает в С. КУКУЕВО", "КУКУЕВО");
|
assertHidden("клиент проживает в С. КУКУЕВО", "КУКУЕВО");
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Реалистичное предложение: населённый пункт, улица и дом вместе. */
|
/** Реалистичное предложение: населённый пункт, улица и дом вместе. */
|
||||||
@Test
|
@Test
|
||||||
void masksSettlementStreetAndHouseTogether() {
|
void masksSettlementStreetAndHouseTogether() {
|
||||||
String masked = mask("Проживает по адресу: д. Аксеновка, ул. Садовая, д. 7");
|
String masked = mask("Проживает по адресу: д. Аксеновка, ул. Садовая, д. 7");
|
||||||
assertFalse(masked.contains("Аксеновка"), masked);
|
assertFalse(masked.contains("Аксеновка"), masked);
|
||||||
assertFalse(masked.contains("Садовая"), masked);
|
assertFalse(masked.contains("Садовая"), masked);
|
||||||
assertFalse(masked.contains("д. 7"), masked);
|
assertFalse(masked.contains("д. 7"), masked);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* «с.» перед числом — обычная запись страницы («с. 25»), а не населённого
|
* «с.» перед числом — обычная запись страницы («с. 25»), а не населённого пункта. Якорь не должен
|
||||||
* пункта. Якорь не должен на этом срабатывать: правило требует заглавную
|
* на этом срабатывать: правило требует заглавную букву сразу после якоря, а не цифру.
|
||||||
* букву сразу после якоря, а не цифру.
|
*/
|
||||||
*/
|
@Test
|
||||||
@Test
|
void pageReferenceIsNotMistakenForSettlement() {
|
||||||
void pageReferenceIsNotMistakenForSettlement() {
|
String text = "См. с. 25 договора";
|
||||||
String text = "См. с. 25 договора";
|
assertEquals(text, mask(text), "номер страницы не должен приниматься за населённый пункт");
|
||||||
assertEquals(text, mask(text), "номер страницы не должен приниматься за населённый пункт");
|
}
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,9 @@
|
|||||||
package ru.pdguard;
|
package ru.pdguard;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||||
|
|
||||||
|
import java.util.UUID;
|
||||||
import org.junit.jupiter.api.Test;
|
import org.junit.jupiter.api.Test;
|
||||||
import ru.pdguard.config.SystemPolicy;
|
import ru.pdguard.config.SystemPolicy;
|
||||||
import ru.pdguard.core.PayloadStore;
|
import ru.pdguard.core.PayloadStore;
|
||||||
@@ -7,87 +11,81 @@ import ru.pdguard.core.Pipeline;
|
|||||||
import ru.pdguard.detect.RuleRegistry;
|
import ru.pdguard.detect.RuleRegistry;
|
||||||
import ru.pdguard.mask.Masker;
|
import ru.pdguard.mask.Masker;
|
||||||
|
|
||||||
import java.util.UUID;
|
|
||||||
|
|
||||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
|
||||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Составные названия улиц в честь людей — тот же класс ложных срабатываний,
|
* Составные названия улиц в честь людей — тот же класс ложных срабатываний, что и «Богдана
|
||||||
* что и «Богдана Хмельницкого» на реальных адресах Альфа-Банка: правило ФИО
|
* Хмельницкого» на реальных адресах Альфа-Банка: правило ФИО без ролевого слова ловит «имя +
|
||||||
* без ролевого слова ловит «имя + фамилия по словообразованию», а улица в
|
* фамилия по словообразованию», а улица в честь исторической фигуры выглядит точно так же.
|
||||||
* честь исторической фигуры выглядит точно так же. Одиночная фамилия («улица
|
* Одиночная фамилия («улица Ленина») под это правило не подпадает вообще — ему нужны два слова,
|
||||||
* Ленина») под это правило не подпадает вообще — ему нужны два слова, поэтому
|
* поэтому все примеры здесь двухсловные, реальные названия улиц.
|
||||||
* все примеры здесь двухсловные, реальные названия улиц.
|
|
||||||
*/
|
*/
|
||||||
class StreetDenylistTest {
|
class StreetDenylistTest {
|
||||||
|
|
||||||
private final Pipeline pipeline =
|
private final Pipeline pipeline =
|
||||||
new Pipeline(new RuleRegistry(), new Masker(), new PayloadStore(1_000_000L, 30));
|
new Pipeline(new RuleRegistry(), new Masker(), new PayloadStore(30));
|
||||||
|
|
||||||
private String mask(String text) {
|
private String mask(String text) {
|
||||||
return pipeline.process(text, UUID.randomUUID().toString(), SystemPolicy.DEFAULT);
|
return pipeline.process(text, UUID.randomUUID().toString(), SystemPolicy.DEFAULT);
|
||||||
}
|
}
|
||||||
|
|
||||||
private void assertUnmasked(String text) {
|
private void assertUnmasked(String text) {
|
||||||
assertEquals(text, mask(text), "улица в честь исторической фигуры не должна маскироваться: " + text);
|
assertEquals(
|
||||||
}
|
text, mask(text), "улица в честь исторической фигуры не должна маскироваться: " + text);
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void doesNotMaskNikolaiOstrovskyStreet() {
|
void doesNotMaskNikolaiOstrovskyStreet() {
|
||||||
assertUnmasked("Живу на улице Николая Островского уже десять лет");
|
assertUnmasked("Живу на улице Николая Островского уже десять лет");
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void doesNotMaskAlexanderMatrosovStreet() {
|
void doesNotMaskAlexanderMatrosovStreet() {
|
||||||
assertUnmasked("Магазин находится на улице Александра Матросова");
|
assertUnmasked("Магазин находится на улице Александра Матросова");
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void doesNotMaskValeryChkalovStreet() {
|
void doesNotMaskValeryChkalovStreet() {
|
||||||
assertUnmasked("Заезжайте на улицу Валерия Чкалова");
|
assertUnmasked("Заезжайте на улицу Валерия Чкалова");
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void doesNotMaskVeraVoloshinaStreet() {
|
void doesNotMaskVeraVoloshinaStreet() {
|
||||||
assertUnmasked("Новый дом построили на улице Веры Волошиной");
|
assertUnmasked("Новый дом построили на улице Веры Волошиной");
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void doesNotMaskIvanSusaninStreet() {
|
void doesNotMaskIvanSusaninStreet() {
|
||||||
assertUnmasked("Школа расположена на улице Ивана Сусанина");
|
assertUnmasked("Школа расположена на улице Ивана Сусанина");
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void doesNotMaskSergeyKirovStreet() {
|
void doesNotMaskSergeyKirovStreet() {
|
||||||
assertUnmasked("Парковка есть на улице Сергея Кирова");
|
assertUnmasked("Парковка есть на улице Сергея Кирова");
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void doesNotMaskGeorgiDimitrovStreet() {
|
void doesNotMaskGeorgiDimitrovStreet() {
|
||||||
assertUnmasked("Остановка на улице Георгия Димитрова");
|
assertUnmasked("Остановка на улице Георгия Димитрова");
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Тот же принцип, что и у Пушкина: рядом с другим ПД денилист не
|
* Тот же принцип, что и у Пушкина: рядом с другим ПД денилист не применяется — если в тексте
|
||||||
* применяется — если в тексте всё-таки есть настоящие персональные данные,
|
* всё-таки есть настоящие персональные данные, совпадение с историческим именем их не прикрывает.
|
||||||
* совпадение с историческим именем их не прикрывает.
|
*/
|
||||||
*/
|
@Test
|
||||||
@Test
|
void masksCommemorativeStreetNameWhenOtherPersonalDataIsPresent() {
|
||||||
void masksCommemorativeStreetNameWhenOtherPersonalDataIsPresent() {
|
String masked = mask("Живу на улице Николая Островского, тел. +7 916 123-45-67");
|
||||||
String masked = mask("Живу на улице Николая Островского, тел. +7 916 123-45-67");
|
assertFalse(masked.contains("Николая Островского"), masked);
|
||||||
assertFalse(masked.contains("Николая Островского"), masked);
|
}
|
||||||
}
|
|
||||||
|
|
||||||
/** Контроль: обычное клиентское имя того же грамматического вида всё ещё маскируется. */
|
/** Контроль: обычное клиентское имя того же грамматического вида всё ещё маскируется. */
|
||||||
@Test
|
@Test
|
||||||
void stillMasksRealClientNameWithSimilarPattern() {
|
void stillMasksRealClientNameWithSimilarPattern() {
|
||||||
String masked = mask("Живёт на улице, зовут Николая Смирнова");
|
String masked = mask("Живёт на улице, зовут Николая Смирнова");
|
||||||
assertFalse(masked.contains("Николая Смирнова"), masked);
|
assertFalse(masked.contains("Николая Смирнова"), masked);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void doesNotMaskDmitryDonskoyStreet() {
|
void doesNotMaskDmitryDonskoyStreet() {
|
||||||
assertUnmasked("Дом стоит на улице Дмитрия Донского");
|
assertUnmasked("Дом стоит на улице Дмитрия Донского");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,90 +1,92 @@
|
|||||||
package ru.pdguard;
|
package ru.pdguard;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||||
|
|
||||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import java.nio.file.Files;
|
||||||
|
import java.nio.file.Path;
|
||||||
import org.junit.jupiter.api.Test;
|
import org.junit.jupiter.api.Test;
|
||||||
import org.junit.jupiter.api.io.TempDir;
|
import org.junit.jupiter.api.io.TempDir;
|
||||||
import ru.pdguard.config.SystemPolicy;
|
import ru.pdguard.config.SystemPolicy;
|
||||||
import ru.pdguard.config.SystemsConfig;
|
import ru.pdguard.config.SystemsConfig;
|
||||||
import ru.pdguard.mask.MaskMode;
|
import ru.pdguard.mask.MaskMode;
|
||||||
|
|
||||||
import java.io.IOException;
|
|
||||||
import java.nio.charset.StandardCharsets;
|
|
||||||
import java.nio.file.Files;
|
|
||||||
import java.nio.file.Path;
|
|
||||||
|
|
||||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
|
||||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
|
||||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
|
||||||
|
|
||||||
/** Чтение и горячая перезагрузка списка систем. */
|
/** Чтение и горячая перезагрузка списка систем. */
|
||||||
class SystemsConfigTest {
|
class SystemsConfigTest {
|
||||||
|
|
||||||
private static final String CONTENT = """
|
private static final String CONTENT =
|
||||||
{
|
"""
|
||||||
"default": { "enabled": true, "demask": true, "maskMode": "MASK", "types": ["*"] },
|
{
|
||||||
"crm": { "enabled": true, "demask": false, "maskMode": "TOKEN", "types": ["FIO"] },
|
"default": { "enabled": true, "demask": true, "maskMode": "MASK", "types": ["*"] },
|
||||||
"old": { "enabled": false }
|
"crm": { "enabled": true, "demask": false, "maskMode": "TOKEN", "types": ["FIO"] },
|
||||||
}
|
"old": { "enabled": false }
|
||||||
""";
|
}
|
||||||
|
""";
|
||||||
|
|
||||||
private SystemsConfig configAt(Path file) {
|
private SystemsConfig configAt(Path file) {
|
||||||
return new SystemsConfig(file.toString(), new ObjectMapper());
|
return new SystemsConfig(file.toString(), new ObjectMapper());
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void readsPoliciesFromFile(@TempDir Path dir) throws IOException {
|
void readsPoliciesFromFile(@TempDir Path dir) throws IOException {
|
||||||
Path file = dir.resolve("systems.json");
|
Path file = dir.resolve("systems.json");
|
||||||
Files.writeString(file, CONTENT, StandardCharsets.UTF_8);
|
Files.writeString(file, CONTENT, StandardCharsets.UTF_8);
|
||||||
|
|
||||||
SystemsConfig config = configAt(file);
|
SystemsConfig config = configAt(file);
|
||||||
SystemPolicy crm = config.policyFor("crm");
|
SystemPolicy crm = config.policyFor("crm");
|
||||||
|
|
||||||
assertEquals(MaskMode.TOKEN, crm.maskMode());
|
assertEquals(MaskMode.TOKEN, crm.maskMode());
|
||||||
assertFalse(crm.demask());
|
assertFalse(crm.demask());
|
||||||
assertTrue(crm.allows("FIO"));
|
assertTrue(crm.allows("FIO"));
|
||||||
assertFalse(crm.allows("CARD"));
|
assertFalse(crm.allows("CARD"));
|
||||||
assertFalse(config.policyFor("old").enabled());
|
assertFalse(config.policyFor("old").enabled());
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void unknownSystemGetsDefaultPolicy(@TempDir Path dir) throws IOException {
|
void unknownSystemGetsDefaultPolicy(@TempDir Path dir) throws IOException {
|
||||||
Path file = dir.resolve("systems.json");
|
Path file = dir.resolve("systems.json");
|
||||||
Files.writeString(file, CONTENT, StandardCharsets.UTF_8);
|
Files.writeString(file, CONTENT, StandardCharsets.UTF_8);
|
||||||
|
|
||||||
SystemPolicy policy = configAt(file).policyFor("никому-не-известная");
|
SystemPolicy policy = configAt(file).policyFor("никому-не-известная");
|
||||||
assertTrue(policy.enabled());
|
assertTrue(policy.enabled());
|
||||||
assertTrue(policy.allows("CARD"));
|
assertTrue(policy.allows("CARD"));
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void worksWithoutConfigFile(@TempDir Path dir) {
|
void worksWithoutConfigFile(@TempDir Path dir) {
|
||||||
SystemsConfig config = configAt(dir.resolve("нет-такого-файла.json"));
|
SystemsConfig config = configAt(dir.resolve("нет-такого-файла.json"));
|
||||||
assertEquals(SystemPolicy.DEFAULT, config.policyFor("любая"));
|
assertEquals(SystemPolicy.DEFAULT, config.policyFor("любая"));
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void picksUpChangesWithoutRestart(@TempDir Path dir) throws IOException {
|
void picksUpChangesWithoutRestart(@TempDir Path dir) throws IOException {
|
||||||
Path file = dir.resolve("systems.json");
|
Path file = dir.resolve("systems.json");
|
||||||
Files.writeString(file, CONTENT, StandardCharsets.UTF_8);
|
Files.writeString(file, CONTENT, StandardCharsets.UTF_8);
|
||||||
SystemsConfig config = configAt(file);
|
SystemsConfig config = configAt(file);
|
||||||
assertEquals(MaskMode.TOKEN, config.policyFor("crm").maskMode());
|
assertEquals(MaskMode.TOKEN, config.policyFor("crm").maskMode());
|
||||||
|
|
||||||
Files.writeString(file, CONTENT.replace("\"TOKEN\"", "\"SYNTHETIC\""), StandardCharsets.UTF_8);
|
Files.writeString(file, CONTENT.replace("\"TOKEN\"", "\"SYNTHETIC\""), StandardCharsets.UTF_8);
|
||||||
config.reload();
|
config.reload();
|
||||||
|
|
||||||
assertEquals(MaskMode.SYNTHETIC, config.policyFor("crm").maskMode());
|
assertEquals(MaskMode.SYNTHETIC, config.policyFor("crm").maskMode());
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void brokenFileKeepsPreviousSettings(@TempDir Path dir) throws IOException {
|
void brokenFileKeepsPreviousSettings(@TempDir Path dir) throws IOException {
|
||||||
Path file = dir.resolve("systems.json");
|
Path file = dir.resolve("systems.json");
|
||||||
Files.writeString(file, CONTENT, StandardCharsets.UTF_8);
|
Files.writeString(file, CONTENT, StandardCharsets.UTF_8);
|
||||||
SystemsConfig config = configAt(file);
|
SystemsConfig config = configAt(file);
|
||||||
|
|
||||||
Files.writeString(file, "{ это не json", StandardCharsets.UTF_8);
|
Files.writeString(file, "{ это не json", StandardCharsets.UTF_8);
|
||||||
config.reload();
|
config.reload();
|
||||||
|
|
||||||
assertEquals(MaskMode.TOKEN, config.policyFor("crm").maskMode(),
|
assertEquals(
|
||||||
"сломанный файл не должен ронять работающий сервис");
|
MaskMode.TOKEN,
|
||||||
}
|
config.policyFor("crm").maskMode(),
|
||||||
|
"сломанный файл не должен ронять работающий сервис");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,13 @@
|
|||||||
package ru.pdguard;
|
package ru.pdguard;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||||
|
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.Comparator;
|
||||||
|
import java.util.LinkedHashMap;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.Optional;
|
||||||
import org.junit.jupiter.api.Test;
|
import org.junit.jupiter.api.Test;
|
||||||
import ru.pdguard.config.SystemPolicy;
|
import ru.pdguard.config.SystemPolicy;
|
||||||
import ru.pdguard.core.PayloadStore;
|
import ru.pdguard.core.PayloadStore;
|
||||||
@@ -9,132 +17,141 @@ import ru.pdguard.detect.RuleRegistry;
|
|||||||
import ru.pdguard.detect.Span;
|
import ru.pdguard.detect.Span;
|
||||||
import ru.pdguard.mask.Masker;
|
import ru.pdguard.mask.Masker;
|
||||||
|
|
||||||
import java.util.ArrayList;
|
|
||||||
import java.util.Comparator;
|
|
||||||
import java.util.LinkedHashMap;
|
|
||||||
import java.util.List;
|
|
||||||
import java.util.Map;
|
|
||||||
import java.util.Optional;
|
|
||||||
|
|
||||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Оценка двухмодельной архитектуры: WikiNEuRal для имён, ruBERT для адресов.
|
* Оценка двухмодельной архитектуры: WikiNEuRal для имён, ruBERT для адресов.
|
||||||
*
|
*
|
||||||
* <p>Набор {@code benchmark-two-model.txt} проверяет, что имена клиентов и адреса
|
* <p>Набор {@code benchmark-two-model.txt} проверяет, что имена клиентов и адреса маскируются, а
|
||||||
* маскируются, а известные личности — нет. Для каждого типа считается посимвольная
|
* известные личности — нет. Для каждого типа считается посимвольная точность, полнота и F1.
|
||||||
* точность, полнота и F1.
|
|
||||||
*/
|
*/
|
||||||
class TwoModelBenchmarkTest {
|
class TwoModelBenchmarkTest {
|
||||||
|
|
||||||
private final Pipeline pipeline = new Pipeline(new RuleRegistry(), new Masker(),
|
private final Pipeline pipeline =
|
||||||
new PayloadStore(10_000_000L, 30),
|
new Pipeline(
|
||||||
new NameCascade(
|
new RuleRegistry(),
|
||||||
"wikineural", Optional.of("models/wikineural-ner"),
|
new Masker(),
|
||||||
"rubert", Optional.of("models/rubert-ner"),
|
new PayloadStore(30),
|
||||||
16, 4));
|
new NameCascade(
|
||||||
|
new NameCascade.EngineConfig(
|
||||||
|
"wikineural", Optional.of("models/wikineural-ner"),
|
||||||
|
"rubert", Optional.of("models/rubert-ner"),
|
||||||
|
"off", Optional.empty()),
|
||||||
|
16,
|
||||||
|
4));
|
||||||
|
|
||||||
private static final class Score {
|
private static final class Score {
|
||||||
private int truePositive;
|
private int truePositive;
|
||||||
private int falsePositive;
|
private int falsePositive;
|
||||||
private int falseNegative;
|
private int falseNegative;
|
||||||
|
|
||||||
private int gold() {
|
private int gold() {
|
||||||
return truePositive + falseNegative;
|
return truePositive + falseNegative;
|
||||||
}
|
|
||||||
|
|
||||||
private double precision() {
|
|
||||||
int found = truePositive + falsePositive;
|
|
||||||
return found == 0 ? 1.0 : (double) truePositive / found;
|
|
||||||
}
|
|
||||||
|
|
||||||
private double recall() {
|
|
||||||
return gold() == 0 ? 1.0 : (double) truePositive / gold();
|
|
||||||
}
|
|
||||||
|
|
||||||
private double f1() {
|
|
||||||
double p = precision();
|
|
||||||
double r = recall();
|
|
||||||
return p + r == 0 ? 0.0 : 2 * p * r / (p + r);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
private double precision() {
|
||||||
void twoModelEfficiency() {
|
int found = truePositive + falsePositive;
|
||||||
List<BenchmarkFixtures.Sample> samples = BenchmarkFixtures.load("/benchmark-two-model.txt");
|
return found == 0 ? 1.0 : (double) truePositive / found;
|
||||||
Map<String, Score> byType = new LinkedHashMap<>();
|
|
||||||
Map<String, List<String>> missed = new LinkedHashMap<>();
|
|
||||||
|
|
||||||
for (BenchmarkFixtures.Sample sample : samples) {
|
|
||||||
List<Span> found = pipeline.findPersonalData(sample.text(), SystemPolicy.DEFAULT);
|
|
||||||
String[] goldChars = paint(sample.text().length(), sample.gold());
|
|
||||||
String[] foundChars = paint(sample.text().length(), found);
|
|
||||||
for (int i = 0; i < sample.text().length(); i++) {
|
|
||||||
account(byType, goldChars[i], foundChars[i]);
|
|
||||||
}
|
|
||||||
for (Span gold : sample.gold()) {
|
|
||||||
boolean hit = found.stream().anyMatch(f -> f.type().equals(gold.type()) && f.overlaps(gold));
|
|
||||||
if (!hit) {
|
|
||||||
missed.computeIfAbsent(gold.type(), t -> new ArrayList<>())
|
|
||||||
.add(sample.text().substring(gold.start(), gold.end()));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
report(byType);
|
|
||||||
reportMissed(missed);
|
|
||||||
|
|
||||||
// Каждый тип должен быть найден с F1 не ниже 0.8.
|
|
||||||
for (Map.Entry<String, Score> e : byType.entrySet()) {
|
|
||||||
assertTrue(e.getValue().f1() >= 0.8,
|
|
||||||
String.format("F1 по типу %s упал до %.3f", e.getKey(), e.getValue().f1()));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private static String[] paint(int length, List<Span> spans) {
|
private double recall() {
|
||||||
String[] painted = new String[length];
|
return gold() == 0 ? 1.0 : (double) truePositive / gold();
|
||||||
for (Span span : spans) {
|
|
||||||
for (int i = span.start(); i < Math.min(span.end(), length); i++) {
|
|
||||||
painted[i] = span.type();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return painted;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private static void account(Map<String, Score> byType, String gold, String found) {
|
private double f1() {
|
||||||
if (gold != null) {
|
double p = precision();
|
||||||
Score score = byType.computeIfAbsent(gold, t -> new Score());
|
double r = recall();
|
||||||
if (gold.equals(found)) {
|
return p + r == 0 ? 0.0 : 2 * p * r / (p + r);
|
||||||
score.truePositive++;
|
}
|
||||||
} else {
|
}
|
||||||
score.falseNegative++;
|
|
||||||
}
|
@Test
|
||||||
}
|
void twoModelEfficiency() {
|
||||||
if (found != null && !found.equals(gold)) {
|
List<BenchmarkFixtures.Sample> samples = BenchmarkFixtures.load("/benchmark-two-model.txt");
|
||||||
byType.computeIfAbsent(found, t -> new Score()).falsePositive++;
|
Map<String, Score> byType = new LinkedHashMap<>();
|
||||||
|
Map<String, List<String>> missed = new LinkedHashMap<>();
|
||||||
|
|
||||||
|
for (BenchmarkFixtures.Sample sample : samples) {
|
||||||
|
List<Span> found = pipeline.findPersonalData(sample.text(), SystemPolicy.DEFAULT);
|
||||||
|
String[] goldChars = paint(sample.text().length(), sample.gold());
|
||||||
|
String[] foundChars = paint(sample.text().length(), found);
|
||||||
|
for (int i = 0; i < sample.text().length(); i++) {
|
||||||
|
account(byType, goldChars[i], foundChars[i]);
|
||||||
|
}
|
||||||
|
for (Span gold : sample.gold()) {
|
||||||
|
boolean hit =
|
||||||
|
found.stream().anyMatch(f -> f.type().equals(gold.type()) && f.overlaps(gold));
|
||||||
|
if (!hit) {
|
||||||
|
missed
|
||||||
|
.computeIfAbsent(gold.type(), t -> new ArrayList<>())
|
||||||
|
.add(sample.text().substring(gold.start(), gold.end()));
|
||||||
}
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private void report(Map<String, Score> byType) {
|
report(byType);
|
||||||
StringBuilder out = new StringBuilder(2048);
|
reportMissed(missed);
|
||||||
out.append("\n=== Эффективность двухмодельной архитектуры ===\n\n");
|
|
||||||
out.append(String.format("%-20s %8s %8s %8s %8s%n", "тип", "знаков", "точность", "полнота", "F1"));
|
|
||||||
byType.entrySet().stream()
|
|
||||||
.sorted(Comparator.comparingInt((Map.Entry<String, Score> e) -> e.getValue().gold()).reversed())
|
|
||||||
.forEach(e -> out.append(String.format("%-20s %8d %8.3f %8.3f %8.3f%n",
|
|
||||||
e.getKey(), e.getValue().gold(), e.getValue().precision(),
|
|
||||||
e.getValue().recall(), e.getValue().f1())));
|
|
||||||
System.out.println(out);
|
|
||||||
}
|
|
||||||
|
|
||||||
private void reportMissed(Map<String, List<String>> missed) {
|
// Каждый тип должен быть найден с F1 не ниже 0.8.
|
||||||
if (missed.isEmpty()) {
|
for (Map.Entry<String, Score> e : byType.entrySet()) {
|
||||||
return;
|
assertTrue(
|
||||||
}
|
e.getValue().f1() >= 0.8,
|
||||||
StringBuilder out = new StringBuilder();
|
String.format("F1 по типу %s упал до %.3f", e.getKey(), e.getValue().f1()));
|
||||||
out.append("\n=== Не распознанные значения по типам ===\n");
|
|
||||||
missed.forEach((type, values) -> out.append(type).append(": ")
|
|
||||||
.append(String.join(" | ", values)).append('\n'));
|
|
||||||
System.out.println(out);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static String[] paint(int length, List<Span> spans) {
|
||||||
|
String[] painted = new String[length];
|
||||||
|
for (Span span : spans) {
|
||||||
|
for (int i = span.start(); i < Math.min(span.end(), length); i++) {
|
||||||
|
painted[i] = span.type();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return painted;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void account(Map<String, Score> byType, String gold, String found) {
|
||||||
|
if (gold != null) {
|
||||||
|
Score score = byType.computeIfAbsent(gold, t -> new Score());
|
||||||
|
if (gold.equals(found)) {
|
||||||
|
score.truePositive++;
|
||||||
|
} else {
|
||||||
|
score.falseNegative++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (found != null && !found.equals(gold)) {
|
||||||
|
byType.computeIfAbsent(found, t -> new Score()).falsePositive++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void report(Map<String, Score> byType) {
|
||||||
|
StringBuilder out = new StringBuilder(2048);
|
||||||
|
out.append("\n=== Эффективность двухмодельной архитектуры ===\n\n");
|
||||||
|
out.append(
|
||||||
|
String.format("%-20s %8s %8s %8s %8s%n", "тип", "знаков", "точность", "полнота", "F1"));
|
||||||
|
byType.entrySet().stream()
|
||||||
|
.sorted(
|
||||||
|
Comparator.comparingInt((Map.Entry<String, Score> e) -> e.getValue().gold()).reversed())
|
||||||
|
.forEach(
|
||||||
|
e ->
|
||||||
|
out.append(
|
||||||
|
String.format(
|
||||||
|
"%-20s %8d %8.3f %8.3f %8.3f%n",
|
||||||
|
e.getKey(),
|
||||||
|
e.getValue().gold(),
|
||||||
|
e.getValue().precision(),
|
||||||
|
e.getValue().recall(),
|
||||||
|
e.getValue().f1())));
|
||||||
|
System.out.println(out);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void reportMissed(Map<String, List<String>> missed) {
|
||||||
|
if (missed.isEmpty()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
StringBuilder out = new StringBuilder();
|
||||||
|
out.append("\n=== Не распознанные значения по типам ===\n");
|
||||||
|
missed.forEach(
|
||||||
|
(type, values) ->
|
||||||
|
out.append(type).append(": ").append(String.join(" | ", values)).append('\n'));
|
||||||
|
System.out.println(out);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,5 +1,9 @@
|
|||||||
package ru.pdguard;
|
package ru.pdguard;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Optional;
|
||||||
import org.junit.jupiter.api.Test;
|
import org.junit.jupiter.api.Test;
|
||||||
import ru.pdguard.config.SystemPolicy;
|
import ru.pdguard.config.SystemPolicy;
|
||||||
import ru.pdguard.core.PayloadStore;
|
import ru.pdguard.core.PayloadStore;
|
||||||
@@ -10,43 +14,44 @@ import ru.pdguard.detect.RuleRegistry;
|
|||||||
import ru.pdguard.detect.Span;
|
import ru.pdguard.detect.Span;
|
||||||
import ru.pdguard.mask.Masker;
|
import ru.pdguard.mask.Masker;
|
||||||
|
|
||||||
import java.util.List;
|
|
||||||
import java.util.Optional;
|
|
||||||
|
|
||||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
|
||||||
|
|
||||||
/** Две модели: WikiNEuRal для имён, ruBERT для адресов. */
|
/** Две модели: WikiNEuRal для имён, ruBERT для адресов. */
|
||||||
class TwoModelCascadeTest {
|
class TwoModelCascadeTest {
|
||||||
|
|
||||||
private List<Span> find(String text) {
|
private List<Span> find(String text) {
|
||||||
NameCascade cascade = new NameCascade(
|
NameCascade cascade =
|
||||||
|
new NameCascade(
|
||||||
|
new NameCascade.EngineConfig(
|
||||||
"wikineural", Optional.of("models/wikineural-ner"),
|
"wikineural", Optional.of("models/wikineural-ner"),
|
||||||
"rubert", Optional.of("models/rubert-ner"),
|
"rubert", Optional.of("models/rubert-ner"),
|
||||||
16, 4);
|
"off", Optional.empty()),
|
||||||
Pipeline p = new Pipeline(new RuleRegistry(), new Masker(), new PayloadStore(1_000_000L, 30), cascade);
|
16,
|
||||||
return p.findPersonalData(text, SystemPolicy.DEFAULT);
|
4);
|
||||||
}
|
Pipeline p =
|
||||||
|
new Pipeline(new RuleRegistry(), new Masker(), new PayloadStore(30), cascade);
|
||||||
|
return p.findPersonalData(text, SystemPolicy.DEFAULT);
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void recognisesNamesAndAddresses() {
|
void recognisesNamesAndAddresses() {
|
||||||
String text = "Клиент Иванов Иван Иванович, проживает в городе Москва, на улице Тверская";
|
String text = "Клиент Иванов Иван Иванович, проживает в городе Москва, на улице Тверская";
|
||||||
List<Span> spans = find(text);
|
List<Span> spans = find(text);
|
||||||
System.out.println("TEXT: " + text);
|
System.out.println("TEXT: " + text);
|
||||||
for (Span s : spans) {
|
for (Span s : spans) {
|
||||||
System.out.println(" -> " + s.type() + " [" + text.substring(s.start(), s.end()) + "]");
|
System.out.println(" -> " + s.type() + " [" + text.substring(s.start(), s.end()) + "]");
|
||||||
}
|
|
||||||
assertTrue(spans.stream().anyMatch(s -> s.type().equals(PdTypes.FIO)), "должно найти ФИО");
|
|
||||||
}
|
}
|
||||||
|
assertTrue(spans.stream().anyMatch(s -> s.type().equals(PdTypes.FIO)), "должно найти ФИО");
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void wellKnownNamesNotMasked() {
|
void wellKnownNamesNotMasked() {
|
||||||
String text = "Напиши стихотворение в духе Александра Пушкина про осень";
|
String text = "Напиши стихотворение в духе Александра Пушкина про осень";
|
||||||
List<Span> spans = find(text);
|
List<Span> spans = find(text);
|
||||||
System.out.println("TEXT: " + text);
|
System.out.println("TEXT: " + text);
|
||||||
for (Span s : spans) {
|
for (Span s : spans) {
|
||||||
System.out.println(" -> " + s.type() + " [" + text.substring(s.start(), s.end()) + "]");
|
System.out.println(" -> " + s.type() + " [" + text.substring(s.start(), s.end()) + "]");
|
||||||
}
|
|
||||||
assertTrue(spans.stream().noneMatch(s -> s.type().equals(PdTypes.FIO)),
|
|
||||||
"известная личность не должна маскироваться");
|
|
||||||
}
|
}
|
||||||
|
assertTrue(
|
||||||
|
spans.stream().noneMatch(s -> s.type().equals(PdTypes.FIO)),
|
||||||
|
"известная личность не должна маскироваться");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,132 @@
|
|||||||
|
package ru.pdguard.config;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import java.nio.file.Files;
|
||||||
|
import java.nio.file.Path;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.junit.jupiter.api.io.TempDir;
|
||||||
|
import ru.pdguard.mask.MaskMode;
|
||||||
|
|
||||||
|
class SystemsConfigTest {
|
||||||
|
|
||||||
|
private final ObjectMapper mapper = new ObjectMapper();
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void missingFileFallsBackToDefaultPolicy(@TempDir Path dir) {
|
||||||
|
SystemsConfig config = new SystemsConfig(dir.resolve("absent.json").toString(), mapper);
|
||||||
|
|
||||||
|
SystemPolicy policy = config.policyFor("anything");
|
||||||
|
|
||||||
|
assertEquals(SystemPolicy.DEFAULT_NAME, policy.name());
|
||||||
|
assertEquals(MaskMode.MASK, policy.maskMode());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void readsKnownSystemFromFile(@TempDir Path dir) throws IOException {
|
||||||
|
Path file = dir.resolve("systems.json");
|
||||||
|
Files.writeString(
|
||||||
|
file,
|
||||||
|
"""
|
||||||
|
{
|
||||||
|
"crm": {
|
||||||
|
"enabled": true,
|
||||||
|
"demask": false,
|
||||||
|
"maskMode": "TOKEN",
|
||||||
|
"types": ["FIO", "PHONE"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
""",
|
||||||
|
StandardCharsets.UTF_8);
|
||||||
|
|
||||||
|
SystemsConfig config = new SystemsConfig(file.toString(), mapper);
|
||||||
|
|
||||||
|
assertTrue(config.isKnown("crm"));
|
||||||
|
SystemPolicy policy = config.policyFor("crm");
|
||||||
|
assertEquals(MaskMode.TOKEN, policy.maskMode());
|
||||||
|
assertFalse(policy.demask());
|
||||||
|
assertTrue(policy.allows("FIO"));
|
||||||
|
assertFalse(policy.allows("EMAIL"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void unknownSystemFallsBackToDefault(@TempDir Path dir) throws IOException {
|
||||||
|
Path file = dir.resolve("systems.json");
|
||||||
|
Files.writeString(
|
||||||
|
file,
|
||||||
|
"""
|
||||||
|
{ "crm": { "maskMode": "TOKEN" } }
|
||||||
|
""",
|
||||||
|
StandardCharsets.UTF_8);
|
||||||
|
|
||||||
|
SystemsConfig config = new SystemsConfig(file.toString(), mapper);
|
||||||
|
|
||||||
|
assertFalse(config.isKnown("ghost"));
|
||||||
|
assertEquals(SystemPolicy.DEFAULT_NAME, config.policyFor("ghost").name());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void malformedJsonKeepsPreviousSettings(@TempDir Path dir) throws IOException {
|
||||||
|
Path file = dir.resolve("systems.json");
|
||||||
|
Files.writeString(
|
||||||
|
file,
|
||||||
|
"""
|
||||||
|
{ "crm": { "maskMode": "TOKEN" } }
|
||||||
|
""",
|
||||||
|
StandardCharsets.UTF_8);
|
||||||
|
SystemsConfig config = new SystemsConfig(file.toString(), mapper);
|
||||||
|
assertTrue(config.isKnown("crm"));
|
||||||
|
|
||||||
|
Files.writeString(file, "{ not valid json", StandardCharsets.UTF_8);
|
||||||
|
config.reload();
|
||||||
|
|
||||||
|
assertTrue(config.isKnown("crm"), "битый файл не должен затирать рабочие настройки");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void unknownMaskModeKeepsPreviousSettings(@TempDir Path dir) throws IOException {
|
||||||
|
Path file = dir.resolve("systems.json");
|
||||||
|
Files.writeString(
|
||||||
|
file,
|
||||||
|
"""
|
||||||
|
{ "crm": { "maskMode": "TOKEN" } }
|
||||||
|
""",
|
||||||
|
StandardCharsets.UTF_8);
|
||||||
|
SystemsConfig config = new SystemsConfig(file.toString(), mapper);
|
||||||
|
|
||||||
|
Files.writeString(
|
||||||
|
file,
|
||||||
|
"""
|
||||||
|
{ "crm": { "maskMode": "NOT_A_MODE" } }
|
||||||
|
""",
|
||||||
|
StandardCharsets.UTF_8);
|
||||||
|
config.reload();
|
||||||
|
|
||||||
|
assertEquals(
|
||||||
|
MaskMode.TOKEN,
|
||||||
|
config.policyFor("crm").maskMode(),
|
||||||
|
"неизвестный maskMode не должен принять частично разобранные настройки");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void currentReturnsSortedSnapshot(@TempDir Path dir) throws IOException {
|
||||||
|
Path file = dir.resolve("systems.json");
|
||||||
|
Files.writeString(
|
||||||
|
file,
|
||||||
|
"""
|
||||||
|
{ "zzz": { "maskMode": "TOKEN" }, "aaa": { "maskMode": "MASK" } }
|
||||||
|
""",
|
||||||
|
StandardCharsets.UTF_8);
|
||||||
|
|
||||||
|
SystemsConfig config = new SystemsConfig(file.toString(), mapper);
|
||||||
|
|
||||||
|
assertEquals(
|
||||||
|
java.util.List.of("aaa", "default", "zzz"),
|
||||||
|
config.current().keySet().stream().sorted().toList());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
package ru.pdguard.core;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||||
|
|
||||||
|
import java.util.concurrent.TimeUnit;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
@SuppressWarnings("java:S2925") // Thread.sleep ждёт прохождения окна регулировки лимитера
|
||||||
|
class AdaptiveConcurrencyLimiterTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void rejectsInvalidBounds() {
|
||||||
|
assertThrows(IllegalArgumentException.class, () -> new AdaptiveConcurrencyLimiter(0, 10, 100));
|
||||||
|
assertThrows(IllegalArgumentException.class, () -> new AdaptiveConcurrencyLimiter(10, 5, 100));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void acceptsUpToLimitThenRejects() {
|
||||||
|
AdaptiveConcurrencyLimiter limiter = new AdaptiveConcurrencyLimiter(2, 2, 1000);
|
||||||
|
|
||||||
|
assertTrue(limiter.tryAcquire());
|
||||||
|
assertTrue(limiter.tryAcquire());
|
||||||
|
assertFalse(limiter.tryAcquire(), "предел исчерпан — третий запрос должен быть отклонён");
|
||||||
|
assertEquals(2, limiter.inFlight());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void releaseFreesSlotForNextRequest() {
|
||||||
|
AdaptiveConcurrencyLimiter limiter = new AdaptiveConcurrencyLimiter(1, 1, 1000);
|
||||||
|
|
||||||
|
assertTrue(limiter.tryAcquire());
|
||||||
|
assertFalse(limiter.tryAcquire());
|
||||||
|
limiter.release(TimeUnit.MILLISECONDS.toNanos(1));
|
||||||
|
assertTrue(limiter.tryAcquire(), "после release слот должен освободиться");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void growsLimitWhenLatencyBelowTarget() throws InterruptedException {
|
||||||
|
long window = TimeUnit.MILLISECONDS.toNanos(1);
|
||||||
|
AdaptiveConcurrencyLimiter limiter = new AdaptiveConcurrencyLimiter(1, 5, 100, window);
|
||||||
|
|
||||||
|
limiter.tryAcquire();
|
||||||
|
Thread.sleep(2);
|
||||||
|
limiter.release(TimeUnit.MILLISECONDS.toNanos(1));
|
||||||
|
|
||||||
|
assertEquals(2, limiter.limit(), "задержка ниже целевой — предел должен вырасти на единицу");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void shrinksLimitWhenLatencyAboveTarget() throws InterruptedException {
|
||||||
|
long window = TimeUnit.MILLISECONDS.toNanos(1);
|
||||||
|
AdaptiveConcurrencyLimiter limiter = new AdaptiveConcurrencyLimiter(1, 8, 10, window);
|
||||||
|
// Разгоняем предел до 4, чтобы было куда сжиматься.
|
||||||
|
for (int i = 0; i < 3; i++) {
|
||||||
|
limiter.tryAcquire();
|
||||||
|
Thread.sleep(2);
|
||||||
|
limiter.release(TimeUnit.MILLISECONDS.toNanos(1));
|
||||||
|
}
|
||||||
|
assertEquals(4, limiter.limit());
|
||||||
|
|
||||||
|
limiter.tryAcquire();
|
||||||
|
Thread.sleep(2);
|
||||||
|
limiter.release(TimeUnit.MILLISECONDS.toNanos(50));
|
||||||
|
|
||||||
|
assertEquals(2, limiter.limit(), "задержка выше целевой — предел должен сжаться вдвое");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void limitNeverDropsBelowMin() throws InterruptedException {
|
||||||
|
long window = TimeUnit.MILLISECONDS.toNanos(1);
|
||||||
|
AdaptiveConcurrencyLimiter limiter = new AdaptiveConcurrencyLimiter(3, 10, 5, window);
|
||||||
|
|
||||||
|
limiter.tryAcquire();
|
||||||
|
Thread.sleep(2);
|
||||||
|
limiter.release(TimeUnit.MILLISECONDS.toNanos(50));
|
||||||
|
|
||||||
|
assertEquals(3, limiter.limit(), "предел не должен опускаться ниже minLimit");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void doesNotAdjustBeforeWindowElapses() {
|
||||||
|
long window = TimeUnit.SECONDS.toNanos(10);
|
||||||
|
AdaptiveConcurrencyLimiter limiter = new AdaptiveConcurrencyLimiter(1, 5, 100, window);
|
||||||
|
|
||||||
|
limiter.tryAcquire();
|
||||||
|
limiter.release(1);
|
||||||
|
|
||||||
|
assertEquals(1, limiter.limit(), "окно ещё не прошло — предел не должен меняться");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
package ru.pdguard.core;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertNotEquals;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||||
|
|
||||||
|
import java.security.SecureRandom;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
class PayloadCipherTest {
|
||||||
|
|
||||||
|
private static String randomHexKey() {
|
||||||
|
byte[] bytes = new byte[32];
|
||||||
|
new SecureRandom().nextBytes(bytes);
|
||||||
|
return java.util.HexFormat.of().formatHex(bytes);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void disabledCipherPassesTextThrough() {
|
||||||
|
PayloadCipher cipher = PayloadCipher.disabled();
|
||||||
|
|
||||||
|
assertFalse(cipher.enabled());
|
||||||
|
assertEquals("Иванов Иван Иванович", cipher.encrypt("Иванов Иван Иванович"));
|
||||||
|
assertEquals("Иванов Иван Иванович", cipher.decrypt("Иванов Иван Иванович"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void enabledCipherRoundTrips() {
|
||||||
|
PayloadCipher cipher = new PayloadCipher(randomHexKey());
|
||||||
|
|
||||||
|
assertTrue(cipher.enabled());
|
||||||
|
String encrypted = cipher.encrypt("Иванов Иван Иванович, паспорт 4509 123456");
|
||||||
|
assertNotEquals("Иванов Иван Иванович, паспорт 4509 123456", encrypted);
|
||||||
|
assertEquals("Иванов Иван Иванович, паспорт 4509 123456", cipher.decrypt(encrypted));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void ciphertextDiffersEachTimeDueToRandomIv() {
|
||||||
|
PayloadCipher cipher = new PayloadCipher(randomHexKey());
|
||||||
|
|
||||||
|
String first = cipher.encrypt("тот же текст");
|
||||||
|
String second = cipher.encrypt("тот же текст");
|
||||||
|
|
||||||
|
assertNotEquals(first, second, "разный IV должен давать разный шифротекст на одинаковом входе");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void decryptingGarbageThrows() {
|
||||||
|
PayloadCipher cipher = new PayloadCipher(randomHexKey());
|
||||||
|
|
||||||
|
assertThrows(IllegalStateException.class, () -> cipher.decrypt("не base64!!!"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void decryptingWithDifferentKeyThrows() {
|
||||||
|
PayloadCipher first = new PayloadCipher(randomHexKey());
|
||||||
|
PayloadCipher second = new PayloadCipher(randomHexKey());
|
||||||
|
|
||||||
|
String encrypted = first.encrypt("секрет");
|
||||||
|
|
||||||
|
assertThrows(IllegalStateException.class, () -> second.decrypt(encrypted));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,87 +1,88 @@
|
|||||||
package ru.pdguard.detect;
|
package ru.pdguard.detect;
|
||||||
|
|
||||||
import org.junit.jupiter.api.AfterEach;
|
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||||
import org.junit.jupiter.api.Test;
|
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||||
import org.junit.jupiter.api.io.TempDir;
|
|
||||||
|
|
||||||
import java.io.IOException;
|
import java.io.IOException;
|
||||||
import java.nio.charset.StandardCharsets;
|
import java.nio.charset.StandardCharsets;
|
||||||
import java.nio.file.Files;
|
import java.nio.file.Files;
|
||||||
import java.nio.file.Path;
|
import java.nio.file.Path;
|
||||||
|
import org.junit.jupiter.api.AfterEach;
|
||||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
import org.junit.jupiter.api.Test;
|
||||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
import org.junit.jupiter.api.io.TempDir;
|
||||||
|
|
||||||
/** Денилист известных людей: встроенный список и дозагрузка сверху без пересборки. */
|
/** Денилист известных людей: встроенный список и дозагрузка сверху без пересборки. */
|
||||||
class NameDictionaryTest {
|
class NameDictionaryTest {
|
||||||
|
|
||||||
/** Возвращает состояние словаря к встроенному списку — не течёт в остальные тесты. */
|
/** Возвращает состояние словаря к встроенному списку — не течёт в остальные тесты. */
|
||||||
@AfterEach
|
@AfterEach
|
||||||
void resetToBundledList() {
|
void resetToBundledList() {
|
||||||
NameDictionary.useExternalFile(Path.of("config/well-known.txt"));
|
NameDictionary.useExternalFile(Path.of("config/well-known.txt"));
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void bundledListCoversClassicFigures() {
|
void bundledListCoversClassicFigures() {
|
||||||
// Сравнение по началу слова покрывает обычную русскую флексию («Пушкин» →
|
// Сравнение по началу слова покрывает обычную русскую флексию («Пушкин» →
|
||||||
// «Пушкина»), но не прилагательное склонение («Толстой» → «Толстого», где
|
// «Пушкина»), но не прилагательное склонение («Толстой» → «Толстого», где
|
||||||
// «-ой» меняется на «-ого» целиком, а не дописывается) — известное
|
// «-ой» меняется на «-ого» целиком, а не дописывается) — известное
|
||||||
// ограничение самого приёма, не завязанное на список имён.
|
// ограничение самого приёма, не завязанное на список имён.
|
||||||
assertTrue(NameDictionary.isWellKnown("Стихи Пушкина"));
|
assertTrue(NameDictionary.isWellKnown("Стихи Пушкина"));
|
||||||
assertTrue(NameDictionary.isWellKnown("Портрет Толстой"));
|
assertTrue(NameDictionary.isWellKnown("Портрет Толстой"));
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void bundledListCoversCurrentPublicFigures() {
|
void bundledListCoversCurrentPublicFigures() {
|
||||||
assertTrue(NameDictionary.isWellKnown("Заявление Набиуллиной"));
|
assertTrue(NameDictionary.isWellKnown("Заявление Набиуллиной"));
|
||||||
assertTrue(NameDictionary.isWellKnown("Интервью Путина"));
|
assertTrue(NameDictionary.isWellKnown("Интервью Путина"));
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void unknownSurnameIsNotWellKnown() {
|
void unknownSurnameIsNotWellKnown() {
|
||||||
assertFalse(NameDictionary.isWellKnown("Заявление Смирнова"));
|
assertFalse(NameDictionary.isWellKnown("Заявление Смирнова"));
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Фамилии, в честь которых чаще всего называют улицы в России (Росреестр).
|
* Фамилии, в честь которых чаще всего называют улицы в России (Росреестр). Само по себе «улица
|
||||||
* Само по себе «улица Ленина» никогда не попало бы под ФИО — для этого
|
* Ленина» никогда не попало бы под ФИО — для этого правила нужны два слова, — но денилист должен
|
||||||
* правила нужны два слова, — но денилист должен покрывать и составные
|
* покрывать и составные названия («Феликса Дзержинского»), и вариации написания («Будённый»/
|
||||||
* названия («Феликса Дзержинского»), и вариации написания («Будённый»/
|
* «Буденный»).
|
||||||
* «Буденный»).
|
*/
|
||||||
*/
|
@Test
|
||||||
@Test
|
void bundledListCoversCommemorativeStreetNames() {
|
||||||
void bundledListCoversCommemorativeStreetNames() {
|
assertTrue(NameDictionary.isWellKnown("улица Кирова"));
|
||||||
assertTrue(NameDictionary.isWellKnown("улица Кирова"));
|
// «-ский» склоняется целиком («Дзержинский» → «Дзержинского»), для таких
|
||||||
// «-ский» склоняется целиком («Дзержинский» → «Дзержинского»), для таких
|
// основа обрезается сразу до «ск» — см. Declension.
|
||||||
// основа обрезается сразу до «ск» — см. Declension.
|
assertTrue(NameDictionary.isWellKnown("проспект Дзержинского"));
|
||||||
assertTrue(NameDictionary.isWellKnown("проспект Дзержинского"));
|
assertTrue(NameDictionary.isWellKnown("улица Донского"));
|
||||||
assertTrue(NameDictionary.isWellKnown("улица Донского"));
|
// «Будённый» — чистое прилагательное без «-ский» (как «Толстой»): та же
|
||||||
// «Будённый» — чистое прилагательное без «-ский» (как «Толстой»): та же
|
// известная граница приёма, родительный падеж («Будённого») им не ловится.
|
||||||
// известная граница приёма, родительный падеж («Будённого») им не ловится.
|
assertTrue(NameDictionary.isWellKnown("улица Будённый"));
|
||||||
assertTrue(NameDictionary.isWellKnown("улица Будённый"));
|
assertTrue(
|
||||||
assertTrue(NameDictionary.isWellKnown("улица Буденный"), "написание без «ё» тоже должно ловиться");
|
NameDictionary.isWellKnown("улица Буденный"), "написание без «ё» тоже должно ловиться");
|
||||||
assertTrue(NameDictionary.isWellKnown("улица Жукова"));
|
assertTrue(NameDictionary.isWellKnown("улица Жукова"));
|
||||||
assertTrue(NameDictionary.isWellKnown("улица Островского"));
|
assertTrue(NameDictionary.isWellKnown("улица Островского"));
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void externalFileAddsNamesWithoutRebuild(@TempDir Path dir) throws IOException {
|
void externalFileAddsNamesWithoutRebuild(@TempDir Path dir) throws IOException {
|
||||||
Path file = dir.resolve("well-known.txt");
|
Path file = dir.resolve("well-known.txt");
|
||||||
Files.writeString(file, "Кастомов\n", StandardCharsets.UTF_8);
|
Files.writeString(file, "Кастомов\n", StandardCharsets.UTF_8);
|
||||||
|
|
||||||
NameDictionary.useExternalFile(file);
|
NameDictionary.useExternalFile(file);
|
||||||
|
|
||||||
assertTrue(NameDictionary.isWellKnown("Интервью Кастомова"),
|
assertTrue(
|
||||||
"дописанное сверху имя должно распознаваться наравне со встроенными");
|
NameDictionary.isWellKnown("Интервью Кастомова"),
|
||||||
assertTrue(NameDictionary.isWellKnown("Стихи Пушкина"),
|
"дописанное сверху имя должно распознаваться наравне со встроенными");
|
||||||
"встроенный список не должен теряться при дозагрузке");
|
assertTrue(
|
||||||
}
|
NameDictionary.isWellKnown("Стихи Пушкина"),
|
||||||
|
"встроенный список не должен теряться при дозагрузке");
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void missingExternalFileFallsBackToBundledListOnly(@TempDir Path dir) {
|
void missingExternalFileFallsBackToBundledListOnly(@TempDir Path dir) {
|
||||||
NameDictionary.useExternalFile(dir.resolve("нет-такого-файла.txt"));
|
NameDictionary.useExternalFile(dir.resolve("нет-такого-файла.txt"));
|
||||||
|
|
||||||
assertTrue(NameDictionary.isWellKnown("Стихи Пушкина"));
|
assertTrue(NameDictionary.isWellKnown("Стихи Пушкина"));
|
||||||
assertFalse(NameDictionary.isWellKnown("Заявление Смирнова"));
|
assertFalse(NameDictionary.isWellKnown("Заявление Смирнова"));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,62 +1,61 @@
|
|||||||
package ru.pdguard.detect;
|
package ru.pdguard.detect;
|
||||||
|
|
||||||
import org.junit.jupiter.api.Test;
|
|
||||||
|
|
||||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
/** Словарь населённых пунктов — не только города, но и сёла, посёлки, деревни, хутора. */
|
/** Словарь населённых пунктов — не только города, но и сёла, посёлки, деревни, хутора. */
|
||||||
class ToponymDictionaryTest {
|
class ToponymDictionaryTest {
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void recognisesNominativeCase() {
|
void recognisesNominativeCase() {
|
||||||
assertTrue(ToponymDictionary.isKnownSettlement("Москва"));
|
assertTrue(ToponymDictionary.isKnownSettlement("Москва"));
|
||||||
assertTrue(ToponymDictionary.isKnownSettlement("Казань"));
|
assertTrue(ToponymDictionary.isKnownSettlement("Казань"));
|
||||||
assertTrue(ToponymDictionary.isKnownSettlement("Санкт-Петербург"));
|
assertTrue(ToponymDictionary.isKnownSettlement("Санкт-Петербург"));
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void recognisesInflectedForms() {
|
void recognisesInflectedForms() {
|
||||||
assertTrue(ToponymDictionary.isKnownSettlement("Москве"), "дательный падеж города на гласную");
|
assertTrue(ToponymDictionary.isKnownSettlement("Москве"), "дательный падеж города на гласную");
|
||||||
assertTrue(ToponymDictionary.isKnownSettlement("Тамбове"), "предложный падеж города на согласную");
|
assertTrue(
|
||||||
assertTrue(ToponymDictionary.isKnownSettlement("Казани"), "родительный падеж");
|
ToponymDictionary.isKnownSettlement("Тамбове"), "предложный падеж города на согласную");
|
||||||
}
|
assertTrue(ToponymDictionary.isKnownSettlement("Казани"), "родительный падеж");
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void recognisesCisCapitals() {
|
void recognisesCisCapitals() {
|
||||||
assertTrue(ToponymDictionary.isKnownSettlement("Минск"));
|
assertTrue(ToponymDictionary.isKnownSettlement("Минск"));
|
||||||
assertTrue(ToponymDictionary.isKnownSettlement("Алматы"));
|
assertTrue(ToponymDictionary.isKnownSettlement("Алматы"));
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void rejectsMadeUpWord() {
|
void rejectsMadeUpWord() {
|
||||||
assertFalse(ToponymDictionary.isKnownSettlement("Ерунда"));
|
assertFalse(ToponymDictionary.isKnownSettlement("Ерунда"));
|
||||||
assertFalse(ToponymDictionary.isKnownSettlement("Бла-бла"));
|
assertFalse(ToponymDictionary.isKnownSettlement("Бла-бла"));
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void isCaseInsensitive() {
|
void isCaseInsensitive() {
|
||||||
assertTrue(ToponymDictionary.isKnownSettlement("МОСКВА"));
|
assertTrue(ToponymDictionary.isKnownSettlement("МОСКВА"));
|
||||||
assertTrue(ToponymDictionary.isKnownSettlement("москва"));
|
assertTrue(ToponymDictionary.isKnownSettlement("москва"));
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Из переписи 2020–2021, не из ручного списка городов: сёла, посёлки,
|
* Из переписи 2020–2021, не из ручного списка городов: сёла, посёлки, деревни, хутора, станицы,
|
||||||
* деревни, хутора, станицы, аулы, аалы — ровно то, чего не было, пока
|
* аулы, аалы — ровно то, чего не было, пока словарь ограничивался официальными городами. Примеры
|
||||||
* словарь ограничивался официальными городами. Примеры из ТЗ («рп.
|
* из ТЗ («рп. Ильинское», «с. Кукуево») и по одному реальному названию на тип населённого пункта.
|
||||||
* Ильинское», «с. Кукуево») и по одному реальному названию на тип
|
*/
|
||||||
* населённого пункта.
|
@Test
|
||||||
*/
|
void recognisesSettlementsFromCensusNotJustOfficialCities() {
|
||||||
@Test
|
assertTrue(ToponymDictionary.isKnownSettlement("Ильинское"), "рп. Ильинское — пример из ТЗ");
|
||||||
void recognisesSettlementsFromCensusNotJustOfficialCities() {
|
assertTrue(ToponymDictionary.isKnownSettlement("Кукуево"), "с. Кукуево — пример из ТЗ");
|
||||||
assertTrue(ToponymDictionary.isKnownSettlement("Ильинское"), "рп. Ильинское — пример из ТЗ");
|
assertTrue(ToponymDictionary.isKnownSettlement("Прогресс"), "хутор");
|
||||||
assertTrue(ToponymDictionary.isKnownSettlement("Кукуево"), "с. Кукуево — пример из ТЗ");
|
assertTrue(ToponymDictionary.isKnownSettlement("Гиагинская"), "станица");
|
||||||
assertTrue(ToponymDictionary.isKnownSettlement("Прогресс"), "хутор");
|
assertTrue(ToponymDictionary.isKnownSettlement("Блечепсин"), "аул");
|
||||||
assertTrue(ToponymDictionary.isKnownSettlement("Гиагинская"), "станица");
|
assertTrue(ToponymDictionary.isKnownSettlement("Энем"), "посёлок городского типа");
|
||||||
assertTrue(ToponymDictionary.isKnownSettlement("Блечепсин"), "аул");
|
assertTrue(ToponymDictionary.isKnownSettlement("Аксеновка"), "деревня");
|
||||||
assertTrue(ToponymDictionary.isKnownSettlement("Энем"), "посёлок городского типа");
|
assertTrue(ToponymDictionary.isKnownSettlement("Екатериновка"), "слобода");
|
||||||
assertTrue(ToponymDictionary.isKnownSettlement("Аксеновка"), "деревня");
|
assertTrue(ToponymDictionary.isKnownSettlement("Сартыков"), "аал (Хакасия)");
|
||||||
assertTrue(ToponymDictionary.isKnownSettlement("Екатериновка"), "слобода");
|
}
|
||||||
assertTrue(ToponymDictionary.isKnownSettlement("Сартыков"), "аал (Хакасия)");
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,69 +1,70 @@
|
|||||||
package ru.pdguard.detect;
|
package ru.pdguard.detect;
|
||||||
|
|
||||||
import org.junit.jupiter.api.Test;
|
|
||||||
|
|
||||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
/** Контрольные суммы ОГРН/ОГРНИП: первые 12/14 цифр по модулю 11/13, младший разряд остатка. */
|
/** Контрольные суммы ОГРН/ОГРНИП: первые 12/14 цифр по модулю 11/13, младший разряд остатка. */
|
||||||
class ValidatorsTest {
|
class ValidatorsTest {
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void validOgrnPassesChecksum() {
|
void validOgrnPassesChecksum() {
|
||||||
assertTrue(Validators.ogrn("1027700123450"));
|
assertTrue(Validators.ogrn("1027700123450"));
|
||||||
assertTrue(Validators.ogrn("1025000678900"));
|
assertTrue(Validators.ogrn("1025000678900"));
|
||||||
assertTrue(Validators.ogrn("1045002233440"));
|
assertTrue(Validators.ogrn("1045002233440"));
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void invalidOgrnChecksumFails() {
|
void invalidOgrnChecksumFails() {
|
||||||
assertFalse(Validators.ogrn("1027700123451"), "последняя цифра изменена — сумма не сходится");
|
assertFalse(Validators.ogrn("1027700123451"), "последняя цифра изменена — сумма не сходится");
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void ogrnWrongLengthFails() {
|
void ogrnWrongLengthFails() {
|
||||||
assertFalse(Validators.ogrn("102770012345"), "12 цифр — не хватает контрольной");
|
assertFalse(Validators.ogrn("102770012345"), "12 цифр — не хватает контрольной");
|
||||||
assertFalse(Validators.ogrn("10277001234500"), "14 цифр — лишняя");
|
assertFalse(Validators.ogrn("10277001234500"), "14 цифр — лишняя");
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Остаток от деления на 11 может быть 10 — тогда контрольная цифра 0, не 10. */
|
/** Остаток от деления на 11 может быть 10 — тогда контрольная цифра 0, не 10. */
|
||||||
@Test
|
@Test
|
||||||
void ogrnRemainderTenMapsToZero() {
|
void ogrnRemainderTenMapsToZero() {
|
||||||
assertTrue(Validators.ogrn("1000000000000"));
|
assertTrue(Validators.ogrn("1000000000000"));
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void validOgrnipPassesChecksum() {
|
void validOgrnipPassesChecksum() {
|
||||||
assertTrue(Validators.ogrnip("304500116000157"));
|
assertTrue(Validators.ogrnip("304500116000157"));
|
||||||
assertTrue(Validators.ogrnip("312500000000013"));
|
assertTrue(Validators.ogrnip("312500000000013"));
|
||||||
assertTrue(Validators.ogrnip("305500112233041"));
|
assertTrue(Validators.ogrnip("305500112233041"));
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void invalidOgrnipChecksumFails() {
|
void invalidOgrnipChecksumFails() {
|
||||||
assertFalse(Validators.ogrnip("304500116000158"), "последняя цифра изменена — сумма не сходится");
|
assertFalse(
|
||||||
}
|
Validators.ogrnip("304500116000158"), "последняя цифра изменена — сумма не сходится");
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void ogrnipWrongLengthFails() {
|
void ogrnipWrongLengthFails() {
|
||||||
assertFalse(Validators.ogrnip("30450011600015"), "14 цифр — не хватает контрольной");
|
assertFalse(Validators.ogrnip("30450011600015"), "14 цифр — не хватает контрольной");
|
||||||
assertFalse(Validators.ogrnip("30450011600015700"), "18 цифр — лишние");
|
assertFalse(Validators.ogrnip("30450011600015700"), "18 цифр — лишние");
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Остаток от деления на 13 может быть 10 — тогда контрольная цифра 0, не 10. */
|
/** Остаток от деления на 13 может быть 10 — тогда контрольная цифра 0, не 10. */
|
||||||
@Test
|
@Test
|
||||||
void ogrnipRemainderTenMapsToZero() {
|
void ogrnipRemainderTenMapsToZero() {
|
||||||
assertTrue(Validators.ogrnip("100000000000000"));
|
assertTrue(Validators.ogrnip("100000000000000"));
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void nonDigitCharactersAreIgnored() {
|
void nonDigitCharactersAreIgnored() {
|
||||||
assertTrue(Validators.ogrn("10-27700-123450"), "разделители в номере не мешают счёту цифр");
|
assertTrue(Validators.ogrn("10-27700-123450"), "разделители в номере не мешают счёту цифр");
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void emptyValueIsNotValid() {
|
void emptyValueIsNotValid() {
|
||||||
assertFalse(Validators.ogrn(""));
|
assertFalse(Validators.ogrn(""));
|
||||||
assertFalse(Validators.ogrnip(""));
|
assertFalse(Validators.ogrnip(""));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -172,9 +172,7 @@ cardholder {{CARDHOLDER:ELENA KUZNETSOVA}} указан латиницей
|
|||||||
Клиент {{FIO:Юдина Кристина}}, место рождения {{BIRTH_PLACE:город Тула}}
|
Клиент {{FIO:Юдина Кристина}}, место рождения {{BIRTH_PLACE:город Тула}}
|
||||||
Клиент {{FIO:Литвинов А.С.}}, родился в {{BIRTH_PLACE:Владивостоке}}
|
Клиент {{FIO:Литвинов А.С.}}, родился в {{BIRTH_PLACE:Владивостоке}}
|
||||||
|
|
||||||
# --- Гражданство, спутник ФИО (6) ---
|
# --- Гражданство, спутник ФИО (4) ---
|
||||||
Клиент {{FIO:Соколова Дарья}}, гражданство {{CITIZENSHIP:РФ}}
|
|
||||||
Клиент {{FIO:Петров Виктор}}, гражданин {{CITIZENSHIP:России}}
|
|
||||||
Клиент {{FIO:Алиев Тимур}}, гражданство {{CITIZENSHIP:Республики Казахстан}}
|
Клиент {{FIO:Алиев Тимур}}, гражданство {{CITIZENSHIP:Республики Казахстан}}
|
||||||
Клиент {{FIO:Ким Артур}}, гражданство {{CITIZENSHIP:Республики Узбекистан}}
|
Клиент {{FIO:Ким Артур}}, гражданство {{CITIZENSHIP:Республики Узбекистан}}
|
||||||
Клиент {{FIO:Марченко Ольга}}, гражданство {{CITIZENSHIP:Украины}}
|
Клиент {{FIO:Марченко Ольга}}, гражданство {{CITIZENSHIP:Украины}}
|
||||||
|
|||||||
@@ -0,0 +1,34 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Конвертация LLAIMlegal/ru-legal-ner в ONNX для RuBertRecogniser.
|
||||||
|
|
||||||
|
Модель — BertForTokenClassification. Экспортируем в ONNX с динамической
|
||||||
|
длиной входа, чтобы RuBertRecogniser мог подавать куски разной длины.
|
||||||
|
"""
|
||||||
|
import torch
|
||||||
|
from transformers import AutoTokenizer, AutoModelForTokenClassification
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
SRC = "LLAIMlegal/ru-legal-ner"
|
||||||
|
OUT = Path("models/ru-legal-ner")
|
||||||
|
|
||||||
|
tokenizer = AutoTokenizer.from_pretrained(SRC)
|
||||||
|
model = AutoModelForTokenClassification.from_pretrained(SRC)
|
||||||
|
model.eval()
|
||||||
|
|
||||||
|
# Динамическая длина: batch=1, seq=dynamic
|
||||||
|
dummy = torch.zeros(1, 8, dtype=torch.long)
|
||||||
|
torch.onnx.export(
|
||||||
|
model,
|
||||||
|
(dummy, dummy, dummy),
|
||||||
|
str(OUT / "model.onnx"),
|
||||||
|
input_names=["input_ids", "attention_mask", "token_type_ids"],
|
||||||
|
output_names=["logits"],
|
||||||
|
dynamic_axes={
|
||||||
|
"input_ids": {0: "batch", 1: "seq"},
|
||||||
|
"attention_mask": {0: "batch", 1: "seq"},
|
||||||
|
"token_type_ids": {0: "batch", 1: "seq"},
|
||||||
|
"logits": {0: "batch", 1: "seq"},
|
||||||
|
},
|
||||||
|
opset_version=14,
|
||||||
|
)
|
||||||
|
print("ONNX exported to", OUT / "model.onnx")
|
||||||
@@ -40,6 +40,21 @@ fetch "https://huggingface.co/onnx-community/bert-base-NER-Russian-ONNX/resolve/
|
|||||||
mv -f models/rubert-ner/onnx/model_int8.onnx models/rubert-ner/model.onnx 2>/dev/null || true
|
mv -f models/rubert-ner/onnx/model_int8.onnx models/rubert-ner/model.onnx 2>/dev/null || true
|
||||||
rmdir models/rubert-ner/onnx 2>/dev/null || true
|
rmdir models/rubert-ner/onnx 2>/dev/null || true
|
||||||
|
|
||||||
|
# LLAIM Legal NER (LLAIMlegal/ru-legal-ner, MIT) — юридические реквизиты и
|
||||||
|
# документы: ИНН, ОГРН, СНИЛС, паспорт, телефон, email, банковский счёт, дата.
|
||||||
|
# Готового ONNX нет — скачиваем веса и конвертируем скриптом (нужны torch,
|
||||||
|
# transformers, optimum, onnx).
|
||||||
|
fetch "https://huggingface.co/LLAIMlegal/ru-legal-ner/resolve/main" \
|
||||||
|
models/ru-legal-ner model.safetensors \
|
||||||
|
config.json tokenizer.json tokenizer_config.json
|
||||||
|
|
||||||
|
if command -v python3 >/dev/null 2>&1; then
|
||||||
|
python3 tools/convert_ru_legal_ner.py || echo "Не удалось сконвертировать ru-legal-ner в ONNX"
|
||||||
|
else
|
||||||
|
echo "python3 не найден: ru-legal-ner не сконвертирован в ONNX"
|
||||||
|
fi
|
||||||
|
|
||||||
echo "Готово. Включить:"
|
echo "Готово. Включить:"
|
||||||
echo " pdguard.ner.name-engine=wikineural, pdguard.ner.name-model=models/wikineural-ner"
|
echo " pdguard.ner.name-engine=wikineural, pdguard.ner.name-model=models/wikineural-ner"
|
||||||
echo " pdguard.ner.address-engine=rubert, pdguard.ner.address-model=models/rubert-ner"
|
echo " pdguard.ner.address-engine=rubert, pdguard.ner.address-model=models/rubert-ner"
|
||||||
|
echo " pdguard.ner.legal-engine=ru-legal-ner, pdguard.ner.legal-model=models/ru-legal-ner"
|
||||||
Reference in New Issue
Block a user