diff --git a/config/systems.json b/config/systems.json index ace4733..5376fc8 100644 --- a/config/systems.json +++ b/config/systems.json @@ -2,7 +2,7 @@ "default": { "enabled": true, "demask": true, - "maskMode": "TOKEN", + "maskMode": "MASK", "types": ["*"], "requireCompanion": [ "CVV", "PIN", "DATE", "BIRTH_PLACE", "ADDRESS_COUNTRY", diff --git a/src/main/java/ru/pdguard/PdGuardApplication.java b/src/main/java/ru/pdguard/PdGuardApplication.java index 99b309a..601b0a0 100644 --- a/src/main/java/ru/pdguard/PdGuardApplication.java +++ b/src/main/java/ru/pdguard/PdGuardApplication.java @@ -6,14 +6,13 @@ import org.springframework.boot.autoconfigure.SpringBootApplication; /** * Точка входа Spring Boot приложения. * - *

Модуль безопасности персональных данных: прокси между системой-потребителем - * и LLM. Находит персональные данные, маскирует их и восстанавливает исходный - * текст на обратном шаге. + *

Модуль безопасности персональных данных: прокси между системой-потребителем и LLM. Находит + * персональные данные, маскирует их и восстанавливает исходный текст на обратном шаге. */ @SpringBootApplication public class PdGuardApplication { - public static void main(String[] args) { - SpringApplication.run(PdGuardApplication.class, args); - } -} \ No newline at end of file + public static void main(String[] args) { + SpringApplication.run(PdGuardApplication.class, args); + } +} diff --git a/src/main/java/ru/pdguard/api/AdminResource.java b/src/main/java/ru/pdguard/api/AdminResource.java index a8ab9dd..935c9e2 100644 --- a/src/main/java/ru/pdguard/api/AdminResource.java +++ b/src/main/java/ru/pdguard/api/AdminResource.java @@ -1,5 +1,7 @@ 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.PostMapping; import org.springframework.web.bind.annotation.RestController; @@ -7,34 +9,31 @@ import ru.pdguard.config.SystemPolicy; import ru.pdguard.config.SystemsConfig; import ru.pdguard.detect.RuleRegistry; -import java.util.List; -import java.util.Map; - /** Просмотр действующих настроек и принудительное их перечитывание. */ @RestController public class AdminResource { - private final SystemsConfig systems; - private final RuleRegistry registry; + private final SystemsConfig systems; + private final RuleRegistry registry; - public AdminResource(SystemsConfig systems, RuleRegistry registry) { - this.systems = systems; - this.registry = registry; - } + public AdminResource(SystemsConfig systems, RuleRegistry registry) { + this.systems = systems; + this.registry = registry; + } - @GetMapping("/admin/config") - public Map config() { - return systems.current(); - } + @GetMapping("/admin/config") + public Map config() { + return systems.current(); + } - @GetMapping("/admin/types") - public List types() { - return registry.knownTypes(); - } + @GetMapping("/admin/types") + public List types() { + return registry.knownTypes(); + } - @PostMapping("/admin/reload") - public Map reload() { - systems.reload(); - return systems.current(); - } -} \ No newline at end of file + @PostMapping("/admin/reload") + public Map reload() { + systems.reload(); + return systems.current(); + } +} diff --git a/src/main/java/ru/pdguard/api/HealthResource.java b/src/main/java/ru/pdguard/api/HealthResource.java index cda0c1b..8ac108e 100644 --- a/src/main/java/ru/pdguard/api/HealthResource.java +++ b/src/main/java/ru/pdguard/api/HealthResource.java @@ -7,8 +7,8 @@ import org.springframework.web.bind.annotation.RestController; @RestController public class HealthResource { - @GetMapping("/health") - public String health() { - return "OK"; - } -} \ No newline at end of file + @GetMapping("/health") + public String health() { + return "OK"; + } +} diff --git a/src/main/java/ru/pdguard/api/ProcessResource.java b/src/main/java/ru/pdguard/api/ProcessResource.java index f2bebcc..d48e58d 100644 --- a/src/main/java/ru/pdguard/api/ProcessResource.java +++ b/src/main/java/ru/pdguard/api/ProcessResource.java @@ -18,116 +18,119 @@ import ru.pdguard.core.AdaptiveConcurrencyLimiter; import ru.pdguard.core.Pipeline; /** - * Единственная точка входа контракта: маскирование и демаскирование по - * {@code payload_id}. + * Единственная точка входа контракта: маскирование и демаскирование по {@code payload_id}. * - *

Система-потребитель называет себя заголовком {@code X-System-Id}. Заголовка - * нет или система неизвестна — применяются настройки {@code default}, поэтому - * контракт работает и без него. Система, выключенная в настройках, получает - * {@code 403}. + *

Система-потребитель называет себя заголовком {@code X-System-Id}. Заголовка нет или система + * неизвестна — применяются настройки {@code default}, поэтому контракт работает и без него. + * Система, выключенная в настройках, получает {@code 403}. * - *

При перегрузке отвечает {@code 429} с {@code Retry-After}. Порог перегрузки — - * не фиксированное число запросов, а задержка обработки: {@link AdaptiveConcurrencyLimiter} - * сам находит потолок конкурентности под то, сколько CPU реально досталось контейнеру, - * вместо того чтобы копить запросы и упереться в таймаут вызывающей стороны. + *

При перегрузке отвечает {@code 429} с {@code Retry-After}. Порог перегрузки — не фиксированное + * число запросов, а задержка обработки: {@link AdaptiveConcurrencyLimiter} сам находит потолок + * конкурентности под то, сколько CPU реально досталось контейнеру, вместо того чтобы копить запросы + * и упереться в таймаут вызывающей стороны. */ @RestController 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 REASON_TAG = "reason"; + /** Имя метрики отклонённых запросов и имя её метки причины. */ + private static final String REJECTED_METRIC = "pdguard.requests.rejected"; - /** - * Что отдаётся при внутреннем сбое. Ни одного знака из запроса: сбой на прямом - * шаге иначе выпустил бы наружу незамаскированные персональные данные. - */ - static final String PROCESSING_UNAVAILABLE = "[обработка недоступна]"; + private static final String REASON_TAG = "reason"; - 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 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; - 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); + if (!limiter.tryAcquire()) { + rejected.increment(); + return ResponseEntity.status(429).header("Retry-After", "1").build(); } - - @PostMapping("/process") - public ResponseEntity 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 обязательны")); - } - - 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 + " обращение в модуль запрещено")); - } - - 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); - } + 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); } -} \ No newline at end of file + } +} diff --git a/src/main/java/ru/pdguard/api/ProxyResource.java b/src/main/java/ru/pdguard/api/ProxyResource.java index fd8d227..d697842 100644 --- a/src/main/java/ru/pdguard/api/ProxyResource.java +++ b/src/main/java/ru/pdguard/api/ProxyResource.java @@ -1,6 +1,7 @@ package ru.pdguard.api; import com.fasterxml.jackson.annotation.JsonProperty; +import java.util.Map; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.http.HttpStatus; @@ -14,8 +15,6 @@ import ru.pdguard.config.SystemsConfig; import ru.pdguard.core.LlmClient; import ru.pdguard.core.Pipeline; -import java.util.Map; - /** * Демонстрационное плечо к языковой модели: показывает всю цепочку целиком. * @@ -23,84 +22,94 @@ import java.util.Map; * потребитель → маскирование → LLM → демаскирование → потребитель * * - *

В ответе видны все три текста — что ушло в модель, что она вернула и что - * получил потребитель. Это и есть доказательство, что в модель не попало ничего - * незамаскированного, а ответ вернулся с восстановленными значениями. + *

В ответе видны все три текста — что ушло в модель, что она вернула и что получил потребитель. + * Это и есть доказательство, что в модель не попало ничего незамаскированного, а ответ вернулся с + * восстановленными значениями. * - *

Ответ модели — другой текст, а не тот же самый, поэтому восстановить его по - * идентификатору целиком нельзя: замена идёт пофрагментно. Звёздочки для этого не - * годятся — одна и та же маска отвечала бы разным значениям, — поэтому здесь всегда - * применяется обратимая подстановка, независимо от режима маскирования системы. + *

Ответ модели — другой текст, а не тот же самый, поэтому восстановить его по идентификатору + * целиком нельзя: замена идёт пофрагментно. Звёздочки для этого не годятся — одна и та же маска + * отвечала бы разным значениям, — поэтому здесь всегда применяется обратимая подстановка, + * независимо от режима маскирования системы. * - *

Контракт проверяющей системы это плечо не затрагивает: он живёт в - * {@link ProcessResource}. + *

Контракт проверяющей системы это плечо не затрагивает: он живёт в {@link ProcessResource}. */ @RestController 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 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 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( - @JsonProperty("prompt_masked") String promptMasked, - @JsonProperty("llm_response_masked") String llmResponseMasked, - @JsonProperty("response") String response, - @JsonProperty("replaced") Map replaced, - @JsonProperty("llm") String llm, - @JsonProperty("error") String error) { - public ProxyResponse { - replaced = replaced == null ? null : Map.copyOf(replaced); - } + 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 + " обращение в модуль запрещено")); } - private final Pipeline pipeline; - private final SystemsConfig systems; - private final LlmClient llm; + 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(); - public ProxyResource(Pipeline pipeline, SystemsConfig systems, LlmClient llm) { - this.pipeline = pipeline; - this.systems = systems; - this.llm = llm; + 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 restorations) { + String result = text; + for (Map.Entry entry : restorations.entrySet()) { + result = result.replace(entry.getKey(), entry.getValue()); } - - @PostMapping("/proxy") - public ResponseEntity 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 restorations) { - String result = text; - for (Map.Entry entry : restorations.entrySet()) { - result = result.replace(entry.getKey(), entry.getValue()); - } - return result; - } -} \ No newline at end of file + return result; + } +} diff --git a/src/main/java/ru/pdguard/config/DictionaryConfiguration.java b/src/main/java/ru/pdguard/config/DictionaryConfiguration.java index 748dada..2b01c54 100644 --- a/src/main/java/ru/pdguard/config/DictionaryConfiguration.java +++ b/src/main/java/ru/pdguard/config/DictionaryConfiguration.java @@ -8,18 +8,17 @@ import ru.pdguard.detect.NameDictionary; /** * Конфигурация словарей распознавания ФИО. * - *

{@link NameDictionary} работает через статические методы и не требует - * экземпляра, но путь к внешнему файлу денилиста задаётся из настроек при - * старте. Бин здесь нужен только для того, чтобы Spring подставил значение - * {@code pdguard.well-known-file} и передал его словарю. + *

{@link NameDictionary} работает через статические методы и не требует экземпляра, но путь к + * внешнему файлу денилиста задаётся из настроек при старте. Бин здесь нужен только для того, чтобы + * Spring подставил значение {@code pdguard.well-known-file} и передал его словарю. */ @Configuration public class DictionaryConfiguration { - @Bean - public NameDictionary nameDictionary( - @Value("${pdguard.well-known-file:config/well-known.txt}") String wellKnownFile) { - NameDictionary.configure(wellKnownFile); - return NameDictionary.create(); - } -} \ No newline at end of file + @Bean + public NameDictionary nameDictionary( + @Value("${pdguard.well-known-file:config/well-known.txt}") String wellKnownFile) { + NameDictionary.configure(wellKnownFile); + return NameDictionary.create(); + } +} diff --git a/src/main/java/ru/pdguard/config/SystemPolicy.java b/src/main/java/ru/pdguard/config/SystemPolicy.java index 575f2ff..ee84fae 100644 --- a/src/main/java/ru/pdguard/config/SystemPolicy.java +++ b/src/main/java/ru/pdguard/config/SystemPolicy.java @@ -1,81 +1,96 @@ package ru.pdguard.config; +import java.util.Set; import ru.pdguard.detect.PdTypes; import ru.pdguard.mask.MaskMode; -import java.util.Set; - /** * Правила обработки для одной системы-потребителя. * - * @param name имя системы; им же разделяется хранилище соответствий, - * чтобы одна система не могла достать данные другой - * @param enabled разрешено ли системе обращаться в модуль - * @param demask выполняется ли для системы обратное преобразование - * @param maskMode вид замены: звёздочки, токен или синтетическое значение - * @param types типы ПД к маскированию; {@code "*"} — все известные - * @param key общий секрет системы; задан — заголовок {@code X-System-Key} обязан - * совпасть, иначе имя системы можно было бы просто назвать. - * Только знаки ASCII: заголовки HTTP передаются в Latin-1, - * и кириллица в ключе до сервиса доедет искажённой - * @param requireCompanion типы, которые маскируются только вместе с ПД другого типа: - * пин-код сам по себе безвреден, пин-код рядом с номером - * карты — уже нет; то же для даты без якорного слова, места - * рождения («Нижний Новгород» в рассказе о городе — не адрес - * клиента) и страны («цены выросли в Казахстане» — не гражданство). - * Сюда же банковские реквизиты — счёт, БИК, ОГРН, ОГРНИП, КПП: - * сами по себе они опознают организацию или счёт, а не человека, - * и в перечне типов из задания их нет. Рядом с именем клиента - * они становятся его данными и маскируются. - * Сюда же доход и биометрия. Сумма заработка без человека — - * статистика («доход домохозяйств вырос до 74 500 руб»), а не - * персональные данные. Биометрия же в тексте не встречается - * вовсе: это шаблон в базе, и правило маскирует лишь само - * упоминание, то есть слово, а не данные. Чувствителен здесь - * факт, что биометрию сдал названный человек, — а он и - * существует только при имени рядом + * @param name имя системы; им же разделяется хранилище соответствий, чтобы одна система не могла + * достать данные другой + * @param enabled разрешено ли системе обращаться в модуль + * @param demask выполняется ли для системы обратное преобразование + * @param maskMode вид замены: звёздочки, токен или синтетическое значение + * @param types типы ПД к маскированию; {@code "*"} — все известные + * @param key общий секрет системы; задан — заголовок {@code X-System-Key} обязан совпасть, иначе + * имя системы можно было бы просто назвать. Только знаки ASCII: заголовки HTTP передаются в + * Latin-1, и кириллица в ключе до сервиса доедет искажённой + * @param requireCompanion типы, которые маскируются только вместе с ПД другого типа: пин-код сам по + * себе безвреден, пин-код рядом с номером карты — уже нет; то же для даты без якорного слова, + * места рождения («Нижний Новгород» в рассказе о городе — не адрес клиента) и страны («цены + * выросли в Казахстане» — не гражданство). Сюда же банковские реквизиты — счёт, БИК, ОГРН, + * ОГРНИП, КПП: сами по себе они опознают организацию или счёт, а не человека, и в перечне типов + * из задания их нет. Рядом с именем клиента они становятся его данными и маскируются. Сюда же + * доход и биометрия. Сумма заработка без человека — статистика («доход домохозяйств вырос до 74 + * 500 руб»), а не персональные данные. Биометрия же в тексте не встречается вовсе: это шаблон в + * базе, и правило маскирует лишь само упоминание, то есть слово, а не данные. Чувствителен + * здесь факт, что биометрию сдал названный человек, — а он и существует только при имени рядом */ -public record SystemPolicy(String name, boolean enabled, boolean demask, MaskMode maskMode, - Set types, Set requireCompanion, String key) { +public record SystemPolicy( + String name, + boolean enabled, + boolean demask, + MaskMode maskMode, + Set types, + Set 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( - DEFAULT_NAME, true, 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 static final SystemPolicy DEFAULT = + new SystemPolicy( + DEFAULT_NAME, + true, + 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 { - types = Set.copyOf(types); - requireCompanion = Set.copyOf(requireCompanion); + public SystemPolicy { + types = Set.copyOf(types); + 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 static SystemPolicy forTypes(String... types) { - return new SystemPolicy(DEFAULT_NAME, true, true, MaskMode.MASK, - Set.of(types), DEFAULT.requireCompanion(), null); - } + public boolean allows(String type) { + return types.contains(ALL) || types.contains(type); + } - /** Совпадает ли предъявленный ключ. Ключ не задан — проверка не применяется. */ - 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) { - return types.contains(ALL) || types.contains(type); - } - - public boolean needsCompanion(String type) { - return requireCompanion.contains(type); - } + public boolean needsCompanion(String type) { + return requireCompanion.contains(type); + } } diff --git a/src/main/java/ru/pdguard/config/SystemsConfig.java b/src/main/java/ru/pdguard/config/SystemsConfig.java index b9552cb..ca72b5a 100644 --- a/src/main/java/ru/pdguard/config/SystemsConfig.java +++ b/src/main/java/ru/pdguard/config/SystemsConfig.java @@ -1,12 +1,6 @@ package ru.pdguard.config; 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.nio.file.Files; import java.nio.file.Path; @@ -14,127 +8,151 @@ import java.util.HashSet; import java.util.List; import java.util.Locale; import java.util.Map; -import java.util.concurrent.atomic.AtomicReference; import java.util.Set; 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; /** * Список систем, которым разрешено обращаться в модуль, и правила для каждой. * - *

Читается из внешнего файла, чтобы настройки менялись без пересборки. Файл - * перечитывается сам, когда меняется время его изменения; проверка выполняется - * не чаще раза в секунду, чтобы не ходить в файловую систему на каждом запросе. - * Файла нет — работают настройки по умолчанию, и сервис поднимается без него. + *

Читается из внешнего файла, чтобы настройки менялись без пересборки. Файл перечитывается сам, + * когда меняется время его изменения; проверка выполняется не чаще раза в секунду, чтобы не ходить + * в файловую систему на каждом запросе. Файла нет — работают настройки по умолчанию, и сервис + * поднимается без него. */ @Component 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, - List types, List requireCompanion, String key) { - public SystemEntry { - types = types == null ? null : List.copyOf(types); - requireCompanion = requireCompanion == null ? null : List.copyOf(requireCompanion); - } + /** Описание одной системы в файле настроек. */ + public record SystemEntry( + Boolean enabled, + Boolean demask, + String maskMode, + List types, + List 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 ObjectMapper mapper; + private final Path file; + private final ObjectMapper mapper; - private final AtomicReference> policies = - new AtomicReference<>(Map.of(DEFAULT_SYSTEM, SystemPolicy.DEFAULT)); - private volatile long fileTimestamp; - private volatile long lastCheck; + private final AtomicReference> policies = + new AtomicReference<>(Map.of(DEFAULT_SYSTEM, SystemPolicy.DEFAULT)); + private volatile long fileTimestamp; + private volatile long lastCheck; - public SystemsConfig(@Value("${pdguard.systems-file:config/systems.json}") String path, - ObjectMapper mapper) { - this.file = Path.of(path); - this.mapper = mapper; + public SystemsConfig( + @Value("${pdguard.systems-file:config/systems.json}") String path, ObjectMapper mapper) { + this.file = Path.of(path); + this.mapper = mapper; + reload(); + } + + /** Правила для системы; неизвестная система получает настройки по умолчанию. */ + public SystemPolicy policyFor(String systemId) { + refreshIfChanged(); + Map 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 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 entries = + mapper.readValue( + Files.readAllBytes(file), + mapper + .getTypeFactory() + .constructMapType(TreeMap.class, String.class, SystemEntry.class)); + Map 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); } + } - /** Правила для системы; неизвестная система получает настройки по умолчанию. */ - public SystemPolicy policyFor(String systemId) { - refreshIfChanged(); - Map 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 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 entries = mapper.readValue(Files.readAllBytes(file), - mapper.getTypeFactory().constructMapType(TreeMap.class, String.class, SystemEntry.class)); - Map 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 types = entry.types() == null ? base.types() : new HashSet<>(entry.types()); - Set 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()); - } -} \ No newline at end of file + private static SystemPolicy toPolicy(String name, SystemEntry entry) { + SystemPolicy base = SystemPolicy.DEFAULT; + Set types = entry.types() == null ? base.types() : new HashSet<>(entry.types()); + Set 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()); + } +} diff --git a/src/main/java/ru/pdguard/core/AdaptiveConcurrencyLimiter.java b/src/main/java/ru/pdguard/core/AdaptiveConcurrencyLimiter.java index 28d688c..82f1eba 100644 --- a/src/main/java/ru/pdguard/core/AdaptiveConcurrencyLimiter.java +++ b/src/main/java/ru/pdguard/core/AdaptiveConcurrencyLimiter.java @@ -1,129 +1,121 @@ package ru.pdguard.core; +import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicInteger; import java.util.concurrent.atomic.AtomicLong; -import java.util.concurrent.TimeUnit; /** - * Предел одновременных запросов, который сам подстраивается под задержку, - * а не задан фиксированным числом. Растёт, пока обработка укладывается в - * целевое время, и сжимается, как только перестаёт — вместо того чтобы - * копить очередь и подходить к таймауту вызывающей стороны. + * Предел одновременных запросов, который сам подстраивается под задержку, а не задан фиксированным + * числом. Растёт, пока обработка укладывается в целевое время, и сжимается, как только перестаёт — + * вместо того чтобы копить очередь и подходить к таймауту вызывающей стороны. * - *

Число CPU контейнеру намеренно не спрашивается: {@code Runtime. - * availableProcessors()} под квотой {@code --cpus} в cgroups не меняется - * (это не affinity, а квота), поэтому в контейнере с долей ядра оно - * показывает все ядра хоста и как источник предела не годится. Задержка — + *

Число CPU контейнеру намеренно не спрашивается: {@code Runtime. availableProcessors()} под + * квотой {@code --cpus} в cgroups не меняется (это не affinity, а квота), поэтому в контейнере с + * долей ядра оно показывает все ядра хоста и как источник предела не годится. Задержка — * наблюдаемое следствие реальной доли CPU, а не догадка о её размере. * - *

Шаг регулировки привязан к времени, не к числу запросов: при первой - * версии предел менялся на каждый завершённый запрос, и на высоком RPS - * тысячи «быстрых» замеров прилетали за миллисекунды — предел успевал - * разогнаться до потолка ещё до того, как перегрузка вообще проявлялась, - * и то же самое повторялось после каждого восстановления. Проверено - * нагрузочным тестом: без привязки к времени p95 на перегрузке доходил - * до 1,8–2,3 с при 0,5 CPU, хотя предел вроде бы должен был сжаться. - * Не чаще, чем раз в {@link #ADJUST_WINDOW_NANOS}, предел меняется одним - * шагом на основе среднего за окно — так скорость регулировки не зависит - * от того, насколько высок входящий RPS. + *

Шаг регулировки привязан к времени, не к числу запросов: при первой версии предел менялся на + * каждый завершённый запрос, и на высоком RPS тысячи «быстрых» замеров прилетали за миллисекунды — + * предел успевал разогнаться до потолка ещё до того, как перегрузка вообще проявлялась, и то же + * самое повторялось после каждого восстановления. Проверено нагрузочным тестом: без привязки к + * времени p95 на перегрузке доходил до 1,8–2,3 с при 0,5 CPU, хотя предел вроде бы должен был + * сжаться. Не чаще, чем раз в {@link #ADJUST_WINDOW_NANOS}, предел меняется одним шагом на основе + * среднего за окно — так скорость регулировки не зависит от того, насколько высок входящий RPS. * - *

Рост — на единицу за окно (AIMD), не удвоением. Удвоение (slow start - * из TCP) здесь не подходит: там обратная связь — RTT, миллисекунды, и - * лишний виток роста стоит дёшево. Здесь обратная связь — время ответа - * заявки, и под перегрузкой оно само составляет секунды: предел успевает - * удвоиться несколько раз (2→4→8→…→сотни) быстрее, чем придёт первый - * сигнал о деградации, и уже принятые заявки не исчезают из очереди, даже - * если следующим окном предел тут же обрушить. Проверено нагрузочным - * тестом: с удвоением p95 на перегрузке всё равно доходил до 1,8–2,2 с. - * Линейный рост копит риск медленно, и первый плохой сигнал останавливает - * его на порядок раньше. Сжатие — вдвое, а не на единицу: на перегрузке - * дешевле один раз отрезать с запасом, чем несколько окон подряд плавно - * подходить к безопасному уровню, пока заявки продолжают копиться. + *

Рост — на единицу за окно (AIMD), не удвоением. Удвоение (slow start из TCP) здесь не + * подходит: там обратная связь — RTT, миллисекунды, и лишний виток роста стоит дёшево. Здесь + * обратная связь — время ответа заявки, и под перегрузкой оно само составляет секунды: предел + * успевает удвоиться несколько раз (2→4→8→…→сотни) быстрее, чем придёт первый сигнал о деградации, + * и уже принятые заявки не исчезают из очереди, даже если следующим окном предел тут же обрушить. + * Проверено нагрузочным тестом: с удвоением p95 на перегрузке всё равно доходил до 1,8–2,2 с. + * Линейный рост копит риск медленно, и первый плохой сигнал останавливает его на порядок раньше. + * Сжатие — вдвое, а не на единицу: на перегрузке дешевле один раз отрезать с запасом, чем несколько + * окон подряд плавно подходить к безопасному уровню, пока заявки продолжают копиться. * - *

ponytail: счётчики окна суммируются без блокировки — гонка на границе - * окна может добавить образец в уже подводимый итог или отбросить один, - * не больше; при масштабах в десятки-сотни образцов на окно это не видно. - * Нужен точный регулятор — взять готовую библиотеку вроде Netflix - * {@code concurrency-limits} (Vegas/Gradient2); здесь она не взята из - * осторожности к GraalVM native-image: незнакомая рефлексия в чужой - * библиотеке — это ровно тот класс проблем, из-за которого модели второй - * ступени понадобилась отдельная настройка сборки. + *

ponytail: счётчики окна суммируются без блокировки — гонка на границе окна может добавить + * образец в уже подводимый итог или отбросить один, не больше; при масштабах в десятки-сотни + * образцов на окно это не видно. Нужен точный регулятор — взять готовую библиотеку вроде Netflix + * {@code concurrency-limits} (Vegas/Gradient2); здесь она не взята из осторожности к GraalVM + * native-image: незнакомая рефлексия в чужой библиотеке — это ровно тот класс проблем, из-за + * которого модели второй ступени понадобилась отдельная настройка сборки. */ 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 AtomicLong windowSumNanos = new AtomicLong(); - private final AtomicInteger windowSamples = new AtomicInteger(); - private final AtomicLong lastAdjustNanos; - private final int minLimit; - private final int maxLimit; - private final long targetLatencyNanos; - private final long adjustWindowNanos; - private final AtomicInteger limit; + private final AtomicInteger inFlight = new AtomicInteger(); + private final AtomicLong windowSumNanos = new AtomicLong(); + private final AtomicInteger windowSamples = new AtomicInteger(); + private final AtomicLong lastAdjustNanos; + private final int minLimit; + private final int maxLimit; + private final long targetLatencyNanos; + private final long adjustWindowNanos; + private final AtomicInteger limit; - public AdaptiveConcurrencyLimiter(int minLimit, int maxLimit, long targetLatencyMillis) { - this(minLimit, maxLimit, targetLatencyMillis, DEFAULT_ADJUST_WINDOW_NANOS); + public AdaptiveConcurrencyLimiter(int minLimit, int maxLimit, long targetLatencyMillis) { + 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мс на шаг не подходят. */ - 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()); + /** {@code true} — запрос принят; вызывающая сторона обязана вызвать {@link #release}. */ + public boolean tryAcquire() { + if (inFlight.incrementAndGet() > limit.get()) { + inFlight.decrementAndGet(); + return false; } + return true; + } - /** {@code true} — запрос принят; вызывающая сторона обязана вызвать {@link #release}. */ - public boolean tryAcquire() { - if (inFlight.incrementAndGet() > limit.get()) { - inFlight.decrementAndGet(); - return false; - } - return true; + /** Освобождает слот; предел подстраивается не чаще раза в окно, а не на каждый вызов. */ + public void release(long elapsedNanos) { + inFlight.decrementAndGet(); + windowSumNanos.addAndGet(elapsedNanos); + windowSamples.incrementAndGet(); + + long now = System.nanoTime(); + long last = lastAdjustNanos.get(); + if (now - last >= adjustWindowNanos && lastAdjustNanos.compareAndSet(last, now)) { + adjust(); } + } - /** Освобождает слот; предел подстраивается не чаще раза в окно, а не на каждый вызов. */ - public void release(long elapsedNanos) { - inFlight.decrementAndGet(); - windowSumNanos.addAndGet(elapsedNanos); - windowSamples.incrementAndGet(); - - long now = System.nanoTime(); - long last = lastAdjustNanos.get(); - if (now - last >= adjustWindowNanos && lastAdjustNanos.compareAndSet(last, now)) { - adjust(); - } + private void adjust() { + int samples = windowSamples.getAndSet(0); + long sum = windowSumNanos.getAndSet(0); + if (samples == 0) { + return; } + long avg = sum / samples; - private void adjust() { - int samples = windowSamples.getAndSet(0); - long sum = windowSumNanos.getAndSet(0); - if (samples == 0) { - 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)); - } + if (avg < targetLatencyNanos) { + limit.set(Math.min(maxLimit, limit.get() + 1)); + } else { + limit.set(Math.max(minLimit, limit.get() / 2)); } + } - /** Сколько запросов обрабатывается прямо сейчас — для наблюдения. */ - public int inFlight() { - return inFlight.get(); - } + /** Сколько запросов обрабатывается прямо сейчас — для наблюдения. */ + public int inFlight() { + return inFlight.get(); + } - /** Текущий предел — для метрики, чтобы деградацию было видно, а не только чувствовать по 429. */ - public int limit() { - return limit.get(); - } + /** Текущий предел — для метрики, чтобы деградацию было видно, а не только чувствовать по 429. */ + public int limit() { + return limit.get(); + } } diff --git a/src/main/java/ru/pdguard/core/LlmClient.java b/src/main/java/ru/pdguard/core/LlmClient.java index b41a6ce..6682d86 100644 --- a/src/main/java/ru/pdguard/core/LlmClient.java +++ b/src/main/java/ru/pdguard/core/LlmClient.java @@ -2,11 +2,6 @@ package ru.pdguard.core; import com.fasterxml.jackson.databind.ObjectMapper; 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.net.URI; import java.net.http.HttpClient; @@ -15,93 +10,102 @@ import java.net.http.HttpResponse; import java.nio.charset.StandardCharsets; import java.time.Duration; import java.util.Optional; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; /** * Обращение к языковой модели для демонстрационного плеча. * - *

Адрес не задан — работает заглушка: она возвращает присланный текст обратно. - * Для демонстрации этого достаточно, потому что проверяется не качество ответа - * модели, а то, что в модель ушёл замаскированный текст, а потребителю вернулся - * восстановленный. + *

Адрес не задан — работает заглушка: она возвращает присланный текст обратно. Для демонстрации + * этого достаточно, потому что проверяется не качество ответа модели, а то, что в модель ушёл + * замаскированный текст, а потребителю вернулся восстановленный. * - *

Модель недоступна или ответила ошибкой — плечо деградирует до той же заглушки, - * а причина попадает в ответ и в журнал. Ронять запрос из-за внешнего сервиса нельзя. + *

Модель недоступна или ответила ошибкой — плечо деградирует до той же заглушки, а причина + * попадает в ответ и в журнал. Ронять запрос из-за внешнего сервиса нельзя. */ @Component 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 url; + private final Optional 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 url; - private final Optional 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("Адрес языковой модели не задан, плечо работает на заглушке"); - } + public Answer ask(String maskedPrompt) { + if (url.isEmpty()) { + return new Answer(stub(maskedPrompt), "заглушка"); } - - public Answer ask(String maskedPrompt) { - if (url.isEmpty()) { - return new Answer(stub(maskedPrompt), "заглушка"); - } - try { - return new Answer(call(maskedPrompt), model); - } catch (InterruptedException e) { - Thread.currentThread().interrupt(); - LOG.error("Обращение к языковой модели прервано, плечо ответило заглушкой", e); - return new Answer(stub(maskedPrompt), "заглушка: модель недоступна"); - } catch (IOException e) { - LOG.error("Языковая модель недоступна, плечо ответило заглушкой", e); - return new Answer(stub(maskedPrompt), "заглушка: модель недоступна"); - } + try { + return new Answer(call(maskedPrompt), model); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + 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) { - return "Ответ по запросу: " + maskedPrompt; + /** + * Ответ содержит присланный текст целиком: так на демонстрации видно, что подстановки вернулись + * на свои места при обратном преобразовании. + */ + private static String stub(String 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 response = + http.send(request.build(), HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8)); + if (response.statusCode() / 100 != 2) { + throw new IllegalStateException("модель ответила " + response.statusCode()); } - - 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 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(); - } -} \ No newline at end of file + return mapper + .readTree(response.body()) + .path("choices") + .path(0) + .path("message") + .path("content") + .asText(); + } +} diff --git a/src/main/java/ru/pdguard/core/MetricsConfiguration.java b/src/main/java/ru/pdguard/core/MetricsConfiguration.java index ec60d57..0dfd242 100644 --- a/src/main/java/ru/pdguard/core/MetricsConfiguration.java +++ b/src/main/java/ru/pdguard/core/MetricsConfiguration.java @@ -3,63 +3,61 @@ package ru.pdguard.core; import io.micrometer.core.instrument.Meter; import io.micrometer.core.instrument.config.MeterFilter; import io.micrometer.core.instrument.distribution.DistributionStatisticConfig; +import java.time.Duration; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; -import java.time.Duration; - /** * Настройка распределений для метрик времени. * - *

По умолчанию Micrometer отдаёт по таймеру только сумму, количество и максимум. - * Этого хватает на среднее, но не на перцентили, а именно они описывают SLA: важно - * не среднее время ответа, а то, сколько запросов уложилось в срок. Гистограмма - * добавляет ряды по корзинам, и {@code histogram_quantile} в Prometheus считает по - * ним p50, p95 и p99. + *

По умолчанию Micrometer отдаёт по таймеру только сумму, количество и максимум. Этого хватает + * на среднее, но не на перцентили, а именно они описывают SLA: важно не среднее время ответа, а то, + * сколько запросов уложилось в срок. Гистограмма добавляет ряды по корзинам, и {@code + * histogram_quantile} в Prometheus считает по ним p50, p95 и p99. * - *

Границы корзин заданы явно и подобраны под наши задержки: от четверти - * миллисекунды до десяти секунд. Без явных границ Micrometer создаёт их сам и - * заметно больше, а каждая корзина — это отдельный временной ряд на каждое - * сочетание меток. + *

Границы корзин заданы явно и подобраны под наши задержки: от четверти миллисекунды до десяти + * секунд. Без явных границ Micrometer создаёт их сам и заметно больше, а каждая корзина — это + * отдельный временной ряд на каждое сочетание меток. */ @Configuration 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 = { - Duration.ofNanos(250_000), Duration.ofMillis(1), Duration.ofMillis(5), - Duration.ofMillis(10), Duration.ofMillis(25), Duration.ofMillis(50), - Duration.ofMillis(100), Duration.ofMillis(250), SLA_TARGET, - Duration.ofSeconds(1), Duration.ofSeconds(2), Duration.ofSeconds(5), - Duration.ofSeconds(10) + private static final Duration[] BOUNDARIES = { + Duration.ofNanos(250_000), Duration.ofMillis(1), Duration.ofMillis(5), + Duration.ofMillis(10), Duration.ofMillis(25), Duration.ofMillis(50), + Duration.ofMillis(100), Duration.ofMillis(250), SLA_TARGET, + Duration.ofSeconds(1), Duration.ofSeconds(2), Duration.ofSeconds(5), + 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 - 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); - } - }; - } - - private static boolean needsHistogram(String name) { - return "pdguard.process".equals(name) - || "pdguard.ner.duration".equals(name) - || "http.server.requests".equals(name); - } -} \ No newline at end of file + private static boolean needsHistogram(String name) { + return "pdguard.process".equals(name) + || "pdguard.ner.duration".equals(name) + || "http.server.requests".equals(name); + } +} diff --git a/src/main/java/ru/pdguard/core/PayloadCipher.java b/src/main/java/ru/pdguard/core/PayloadCipher.java index 6ffd239..c8f450a 100644 --- a/src/main/java/ru/pdguard/core/PayloadCipher.java +++ b/src/main/java/ru/pdguard/core/PayloadCipher.java @@ -1,88 +1,89 @@ 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.security.GeneralSecurityException; import java.security.SecureRandom; import java.util.Base64; 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; /** * Шифрование исходных персональных данных в хранилище. * - *

ПДН не должны лежать в памяти и в общем слое в открытом виде: даже если - * процесс или Redis скомпрометированы, исходные значения остаются недоступными - * без ключа. Используется AES-GCM — аутентифицированное шифрование, которое - * защищает и от подмены шифротекста. + *

ПДН не должны лежать в памяти и в общем слое в открытом виде: даже если процесс или Redis + * скомпрометированы, исходные значения остаются недоступными без ключа. Используется AES-GCM — + * аутентифицированное шифрование, которое защищает и от подмены шифротекста. * - *

Ключ задаётся настройкой {@code pdguard.store.encryption-key} (32 байта в - * hex). Пока ключ не задан, шифрование выключено — это нужно для тестов и для - * сборки, где хранилище не содержит чувствительных данных. + *

Ключ задаётся настройкой {@code pdguard.store.encryption-key} (32 байта в hex). Пока ключ не + * задан, шифрование выключено — это нужно для тестов и для сборки, где хранилище не содержит + * чувствительных данных. */ @Component public class PayloadCipher { - private static final String ALGORITHM = "AES"; - private static final String TRANSFORMATION = "AES/GCM/NoPadding"; - private static final int GCM_TAG_BITS = 128; - private static final int IV_BYTES = 12; + private static final String ALGORITHM = "AES"; + private static final String TRANSFORMATION = "AES/GCM/NoPadding"; + private static final int GCM_TAG_BITS = 128; + private static final int IV_BYTES = 12; - private final SecretKeySpec key; - private final SecureRandom random = new SecureRandom(); + private final SecretKeySpec key; + private final SecureRandom random = new SecureRandom(); - public PayloadCipher(@Value("${pdguard.store.encryption-key:}") String hexKey) { - this.key = hexKey == null || hexKey.isBlank() ? null : new SecretKeySpec(HexFormat.of().parseHex(hexKey), ALGORITHM); + public PayloadCipher(@Value("${pdguard.store.encryption-key:}") String hexKey) { + 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; } - - /** Выключенное шифрование — для тестов и сборки без ключа. */ - public static PayloadCipher disabled() { - return new PayloadCipher(""); + try { + byte[] iv = new byte[IV_BYTES]; + random.nextBytes(iv); + 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; } - - /** Шифрует текст; при выключенном шифровании возвращает исходный текст. */ - public String encrypt(String plaintext) { - if (key == null) { - return plaintext; - } - try { - byte[] iv = new byte[IV_BYTES]; - random.nextBytes(iv); - 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); - } + 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); } - - /** Дешифрует текст; при выключенном шифровании возвращает исходный текст. */ - 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); - } - } -} \ No newline at end of file + } +} diff --git a/src/main/java/ru/pdguard/core/PayloadStore.java b/src/main/java/ru/pdguard/core/PayloadStore.java index f7546ea..317c0f4 100644 --- a/src/main/java/ru/pdguard/core/PayloadStore.java +++ b/src/main/java/ru/pdguard/core/PayloadStore.java @@ -1,9 +1,5 @@ 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.security.MessageDigest; import java.security.NoSuchAlgorithmException; @@ -12,191 +8,194 @@ import java.util.Map; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ConcurrentLinkedQueue; 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; /** * Соответствие «исходный текст ↔ маска», по которому выполняется демаскирование. * - *

Два индекса: по {@code payload_id} — основной путь, и по отпечатку маски — - * страховка на случай, если идентификатор до сервиса не доехал. + *

Два индекса: по {@code payload_id} — основной путь, и по отпечатку маски — страховка на + * случай, если идентификатор до сервиса не доехал. * - *

Оба индекса разделены по системам-потребителям. Индекс по отпечатку ищет - * совпадение по самому тексту запроса, и без такого разделения он превращался бы в - * способ достать чужие данные: маски детерминированы и низкоэнтропийны, поэтому, - * прислав «Клиент И. И. И., паспорт 45** ****56», можно было бы получить в ответ - * исходные значения из запроса другого потребителя. Разделение ограничивает это - * пределами одной системы, которая и так видит свои данные. + *

Оба индекса разделены по системам-потребителям. Индекс по отпечатку ищет совпадение по самому + * тексту запроса, и без такого разделения он превращался бы в способ достать чужие данные: маски + * детерминированы и низкоэнтропийны, поэтому, прислав «Клиент И. И. И., паспорт 45** ****56», можно + * было бы получить в ответ исходные значения из запроса другого потребителя. Разделение + * ограничивает это пределами одной системы, которая и так видит свои данные. * - *

Хранилище ограничено по суммарному объёму строк, а записи живут ограниченное - * время: персональные данные не должны залёживаться в памяти, а крупные тексты не - * должны исчерпать кучу. Вытеснение идёт в порядке добавления и выполняется прямо - * на записи — отдельного потока и внешней библиотеки кеширования не требуется. + *

Хранилище ограничено по суммарному объёму строк, а записи живут ограниченное время: + * персональные данные не должны залёживаться в памяти, а крупные тексты не должны исчерпать кучу. + * Вытеснение идёт в порядке добавления и выполняется прямо на записи — отдельного потока и внешней + * библиотеки кеширования не требуется. * - *

Когда включён общий слой ({@link SharedIndex}), соответствие пишется ещё и туда, - * а чтение при промахе по локальной памяти идёт в него. Это нужно при работе на - * нескольких узлах: обратный запрос легко попадает не на тот узел, который выполнял - * прямой. Локальная память при этом остаётся первым уровнем, и обычный путь - * обходится без обращения по сети. + *

Когда включён общий слой ({@link SharedIndex}), соответствие пишется ещё и туда, а чтение при + * промахе по локальной памяти идёт в него. Это нужно при работе на нескольких узлах: обратный + * запрос легко попадает не на тот узел, который выполнял прямой. Локальная память при этом остаётся + * первым уровнем, и обычный путь обходится без обращения по сети. */ @Component 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, - String fingerprint, long expiresAt) { + /** Пара «исходный текст — маска» с отпечатком, владельцем и сроком жизни. */ + public record Entry( + String system, String original, String masked, String fingerprint, long expiresAt) { - boolean alive(long now) { - return now < expiresAt; - } - - int weight() { - return original.length() + masked.length(); - } + boolean alive(long now) { + return now < expiresAt; } - private final Map byId = new ConcurrentHashMap<>(); - private final Map byMaskFingerprint = new ConcurrentHashMap<>(); - private final ConcurrentLinkedQueue 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; + int weight() { + return original.length() + masked.length(); } + } - /** Конструктор для тестов: только локальная память, общий слой и шифрование выключены. */ - public PayloadStore(long maxChars, int ttlMinutes) { - this(maxChars, ttlMinutes, SharedIndex.disabled(), PayloadCipher.disabled()); + private final Map byId = new ConcurrentHashMap<>(); + private final Map byMaskFingerprint = new ConcurrentHashMap<>(); + private final ConcurrentLinkedQueue 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; + } + + /** Конструктор для тестов: только локальная память, общий слой и шифрование выключены. */ + public PayloadStore(long maxChars, int ttlMinutes) { + this(maxChars, 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); + evictWhileOverLimit(); + + 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); } - - 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); - evictWhileOverLimit(); - - shared.put(system, payloadId, encrypted, masked, entry.fingerprint()); + if (entry != null) { + forget(idKey, entry); } + 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); - Entry entry = byId.get(idKey); - if (entry != null && entry.alive(System.currentTimeMillis())) { - return decrypt(entry); - } - if (entry != null) { - forget(idKey, entry); - } - SharedIndex.SharedEntry fromShared = shared.byId(system, payloadId); - if (fromShared == null) { - return null; - } - // Соседний узел уже выполнял прямой шаг: забираем соответствие к себе, - // чтобы повторное обращение обошлось без сети. - put(system, payloadId, fromShared.original(), fromShared.masked()); - return decrypt(byId.get(idKey)); + /** + * Исходный текст по самой маске — когда {@code payload_id} не совпал. Поиск идёт только в + * пределах той же системы: чужую маску подобрать и обменять на исходные данные нельзя. + */ + 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); + } - /** - * Исходный текст по самой маске — когда {@code payload_id} не совпал. Поиск идёт - * только в пределах той же системы: чужую маску подобрать и обменять на исходные - * данные нельзя. - */ - 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() { + return charsHeld.get(); + } - /** Сколько символов сейчас удерживается — для диагностики и тестов. */ - public long charsHeld() { - return charsHeld.get(); - } -/** - * Убирает протухшие записи с головы очереди, не более нескольких за раз. - */ - private void sweepExpired(long now) { - for (int i = 0; i < SWEEP_PER_PUT; i++) { - String oldest = insertionOrder.peek(); - if (oldest == null) { - return; - } - Entry entry = byId.get(oldest); - if (entry == null) { - insertionOrder.poll(); - continue; - } - if (entry.alive(now)) { - return; - } - insertionOrder.poll(); - forget(oldest, entry); - } + /** Убирает протухшие записи с головы очереди, не более нескольких за раз. */ + private void sweepExpired(long now) { + for (int i = 0; i < SWEEP_PER_PUT; i++) { + String oldest = insertionOrder.peek(); + if (oldest == null) { + return; + } + Entry entry = byId.get(oldest); + if (entry == null) { + insertionOrder.poll(); + continue; + } + if (entry.alive(now)) { + return; + } + insertionOrder.poll(); + forget(oldest, entry); } + } - private void evictWhileOverLimit() { - while (charsHeld.get() > maxChars) { - String oldest = insertionOrder.poll(); - if (oldest == null) { - return; - } - Entry entry = byId.get(oldest); - if (entry != null) { - // ponytail: если тот же payload_id записали повторно, в очереди остался - // старый след и здесь вытесняется свежая запись. Цена — одно лишнее - // обращение к маскированию; точный учёт потребовал бы двусвязного списка. - forget(oldest, entry); - } - } + private void evictWhileOverLimit() { + while (charsHeld.get() > maxChars) { + String oldest = insertionOrder.poll(); + if (oldest == null) { + 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)) { - byMaskFingerprint.remove(ScopedKey.of(entry.system(), entry.fingerprint()), entry); - charsHeld.addAndGet(-entry.weight()); - } + private void forget(String idKey, Entry entry) { + if (byId.remove(idKey, entry)) { + byMaskFingerprint.remove(ScopedKey.of(entry.system(), entry.fingerprint()), entry); + charsHeld.addAndGet(-entry.weight()); } + } - /** Возвращает запись с расшифрованным исходным текстом. */ - private Entry decrypt(Entry entry) { - if (entry == null) { - return null; - } - return new Entry(entry.system(), cipher.decrypt(entry.original()), entry.masked(), - entry.fingerprint(), entry.expiresAt()); + /** Возвращает запись с расшифрованным исходным текстом. */ + private Entry decrypt(Entry entry) { + if (entry == null) { + return null; } + return new Entry( + entry.system(), + cipher.decrypt(entry.original()), + entry.masked(), + 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); - } + 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); } -} \ No newline at end of file + } +} diff --git a/src/main/java/ru/pdguard/core/Pipeline.java b/src/main/java/ru/pdguard/core/Pipeline.java index a9b9f52..9feec48 100644 --- a/src/main/java/ru/pdguard/core/Pipeline.java +++ b/src/main/java/ru/pdguard/core/Pipeline.java @@ -4,6 +4,14 @@ import io.micrometer.core.instrument.Counter; import io.micrometer.core.instrument.MeterRegistry; import io.micrometer.core.instrument.Timer; 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.LoggerFactory; import org.springframework.beans.factory.annotation.Autowired; @@ -19,313 +27,322 @@ import ru.pdguard.mask.MaskContext; import ru.pdguard.mask.MaskMode; 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; - /** * Обработка одного обращения: поиск ПД, маскирование и обратное преобразование. * *

Направление определяется по {@code payload_id}, а не по содержимому запроса: + * *

- * Последний случай — повторная попытка проверяющей системы: ответ обязан - * совпасть с первым, иначе демаскирование по этому элементу развалится. + * + * Последний случай — повторная попытка проверяющей системы: ответ обязан совпасть с первым, иначе + * демаскирование по этому элементу развалится. */ @Component public class Pipeline { - private static final Logger LOG = LoggerFactory.getLogger(Pipeline.class); + private static final Logger LOG = LoggerFactory.getLogger(Pipeline.class); - /** Грубая оценка числа токенов по числу символов — для метрики TPS. */ - private static final int CHARS_PER_TOKEN = 4; + /** Грубая оценка числа токенов по числу символов — для метрики TPS. */ + private static final int CHARS_PER_TOKEN = 4; - private final RuleRegistry registry; - private final Masker masker; - private final PayloadStore store; - private final MeterRegistry meters; - private final NameCascade cascade; - private final Counter tokensProcessed; - private final Counter unresolvedDemask; + private final RuleRegistry registry; + private final Masker masker; + private final PayloadStore store; + private final MeterRegistry meters; + private final NameCascade cascade; + private final Counter tokensProcessed; + private final Counter unresolvedDemask; - @Autowired - public Pipeline(RuleRegistry registry, Masker masker, PayloadStore store, MeterRegistry meters, - NameCascade cascade) { - this.registry = registry; - this.masker = masker; - this.store = store; - this.meters = meters; - this.cascade = cascade; - meters.gauge("pdguard.store.chars", store, PayloadStore::charsHeld); - this.tokensProcessed = Counter.builder("pdguard.tokens.processed") - .description("Оценка числа обработанных токенов, для расчёта TPS") - .register(meters); - this.unresolvedDemask = Counter.builder("pdguard.demask.unresolved") - .description("Запрос на демаскирование, для которого соответствие не нашлось ни по " - + "id, ни по отпечатку маски — обработан как новое маскирование") - .register(meters); - } + @Autowired + public Pipeline( + RuleRegistry registry, + Masker masker, + PayloadStore store, + MeterRegistry meters, + NameCascade cascade) { + this.registry = registry; + this.masker = masker; + this.store = store; + this.meters = meters; + this.cascade = cascade; + meters.gauge("pdguard.store.chars", store, PayloadStore::charsHeld); + this.tokensProcessed = + Counter.builder("pdguard.tokens.processed") + .description("Оценка числа обработанных токенов, для расчёта TPS") + .register(meters); + this.unresolvedDemask = + Counter.builder("pdguard.demask.unresolved") + .description( + "Запрос на демаскирование, для которого соответствие не нашлось ни по " + + "id, ни по отпечатку маски — обработан как новое маскирование") + .register(meters); + } - /** Конструктор для тестов: метрики никуда не отдаются, вторая ступень выключена. */ - public Pipeline(RuleRegistry registry, Masker masker, PayloadStore store) { - this(registry, masker, store, new SimpleMeterRegistry(), NameCascade.disabled()); - } + /** Конструктор для тестов: метрики никуда не отдаются, вторая ступень выключена. */ + public Pipeline(RuleRegistry registry, Masker masker, PayloadStore store) { + this(registry, masker, store, new SimpleMeterRegistry(), NameCascade.disabled()); + } - /** Конструктор для тестов второй ступени. */ - public Pipeline(RuleRegistry registry, Masker masker, PayloadStore store, NameCascade cascade) { - this(registry, masker, store, new SimpleMeterRegistry(), cascade); - } + /** Конструктор для тестов второй ступени. */ + public Pipeline(RuleRegistry registry, Masker masker, PayloadStore store, NameCascade cascade) { + this(registry, masker, store, new SimpleMeterRegistry(), cascade); + } - public String process(String payload, String payloadId, SystemPolicy policy) { - long started = System.nanoTime(); - 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 findPersonalData(String text, SystemPolicy policy) { - List spans = resolveOverlaps(registry.detect(text, policy)); - if (cascade.coversAny(policy)) { - // Вторая ступень разбирает только то, что не покрыла первая. - 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 spans = findPersonalData(payload, policy); - String masked = apply(payload, spans, policy); - store.put(policy.name(), payloadId, payload, masked); + public String process(String payload, String payloadId, SystemPolicy policy) { + long started = System.nanoTime(); + 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); - logFindings(policy.name(), payloadId, payload.length(), spans); - return masked; + return known.masked(); + } } - - /** - * Оставляет непересекающиеся фрагменты: при конфликте побеждает более - * приоритетный, при равном приоритете — более длинный. - */ - static List resolveOverlaps(List spans) { - List candidates = new ArrayList<>(spans); - candidates.sort(Comparator.comparingInt(Span::priority).reversed() - .thenComparing(Comparator.comparingInt(Span::length).reversed()) - .thenComparingInt(Span::start)); - - // Принятые фрагменты не пересекаются и упорядочены по началу, поэтому - // кандидату достаточно сверить себя с ближайшим слева и ближайшим справа. - // Перебор всех принятых давал бы квадрат: на тексте в сотню тысяч токенов - // фрагментов набираются тысячи. - NavigableMap accepted = new TreeMap<>(); - for (Span candidate : candidates) { - if (overlapsAccepted(accepted, candidate)) { - continue; - } - accepted.put(candidate.start(), candidate); - } - return List.copyOf(accepted.values()); + 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 static boolean overlapsAccepted(NavigableMap accepted, Span candidate) { - Map.Entry before = accepted.floorEntry(candidate.start()); - if (before != null && before.getValue().overlaps(candidate)) { - return true; - } - Map.Entry after = accepted.ceilingEntry(candidate.start()); - return after != null && after.getValue().overlaps(candidate); - } + /** + * Длительность обработки с разрезом по направлению и системе-потребителю. Метрики берутся из + * реестра по тегам: систем немного и они заданы настройками, поэтому разрастания рядов не будет, + * а разрез по потребителям виден сразу. + */ + 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); + } - /** - * Убирает имена, стоящие в названиях организаций и объектов на карте: - * «Институт Склифосовского», «Музей Тропинина», «улица Королёва». Проверка - * не зависит от того, есть ли в тексте другие ПД: слово перед именем решает - * само по себе. - */ - static List dropOrganisationNames(String text, List spans) { - return spans.stream() - .filter(span -> !PdTypes.FIO.equals(span.type()) - || !OrganisationDetector.precededByOrganisation(text, span.start())) - .toList(); + /** + * Фрагменты, которые будут замаскированы: поиск по правилам, разрешение перекрытий и все + * отсечения. Отдельный метод нужен, чтобы качество детекции можно было измерить, не разбирая + * замаскированный текст обратно. + */ + public List findPersonalData(String text, SystemPolicy policy) { + List spans = resolveOverlaps(registry.detect(text, policy)); + if (cascade.coversAny(policy)) { + // Вторая ступень разбирает только то, что не покрыла первая. + spans = resolveOverlaps(cascade.addMissedNames(text, spans)); } + spans = dropOrganisationNames(text, spans); + spans = dropWellKnownNames(text, spans); + return dropLonelyCompanions(spans, policy); + } - /** - * Убирает имена известных людей: «стихи Александра Пушкина» персональными - * данными не являются. Если же в тексте есть ПД другого типа, речь идёт о - * конкретном человеке, и имя остаётся замаскированным — однофамилец - * исторической фигуры защиту не теряет. - */ - static List dropWellKnownNames(String text, List 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(); - } + private String mask(String payload, String payloadId, SystemPolicy policy, long started) { + List spans = findPersonalData(payload, policy); + String masked = apply(payload, spans, policy); + store.put(policy.name(), payloadId, payload, 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)); - } + recordLatency("mask", policy.name(), started); + logFindings(policy.name(), payloadId, payload.length(), spans); + return masked; + } - /** - * Убирает типы, которые опасны только в сочетании с другими ПД. - * Пин-код в отрыве от номера карты не является персональными данными, - * рядом с номером карты — является. - * - *

Спутником считается только находка самостоятельного типа. Раньше здесь - * сравнивалось число различных типов, и два спутника заверяли друг друга: - * «Оплата 01.02.2025, ОГРН 1027700132195» маскировалась целиком, хотя человека - * в тексте нет, а дата и ОГРН по отдельности персональными данными не являются. - * Сочетание двух несамостоятельных типов самостоятельным не становится. - */ - static List dropLonelyCompanions(List spans, SystemPolicy policy) { - for (Span span : spans) { - if (!policy.needsCompanion(span.type())) { - return spans; - } - } - // Дошли сюда — самостоятельных находок нет, а значит все оставшиеся спутники одиноки. - return List.of(); - } + /** + * Оставляет непересекающиеся фрагменты: при конфликте побеждает более приоритетный, при равном + * приоритете — более длинный. + */ + static List resolveOverlaps(List spans) { + List candidates = new ArrayList<>(spans); + candidates.sort( + Comparator.comparingInt(Span::priority) + .reversed() + .thenComparing(Comparator.comparingInt(Span::length).reversed()) + .thenComparingInt(Span::start)); - /** Замаскированный текст вместе с таблицей обратной замены. */ - public record Masked(String text, Map restorations) { - public Masked { - restorations = Map.copyOf(restorations); - } + // Принятые фрагменты не пересекаются и упорядочены по началу, поэтому + // кандидату достаточно сверить себя с ближайшим слева и ближайшим справа. + // Перебор всех принятых давал бы квадрат: на тексте в сотню тысяч токенов + // фрагментов набираются тысячи. + NavigableMap accepted = new TreeMap<>(); + for (Span candidate : candidates) { + if (overlapsAccepted(accepted, candidate)) { + continue; + } + accepted.put(candidate.start(), candidate); } + return List.copyOf(accepted.values()); + } - /** - * Маскирует текст и отдаёт таблицу обратной замены. - * - *

Нужно для прокси к языковой модели: ответ модели — другой текст, и восстановить - * его целиком по идентификатору нельзя, замену приходится делать пофрагментно. - * Звёздочки для этого не годятся — одна и та же маска может отвечать разным - * значениям, — поэтому режим замены здесь всегда обратимый. - */ - 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 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 static boolean overlapsAccepted(NavigableMap accepted, Span candidate) { + Map.Entry before = accepted.floorEntry(candidate.start()); + if (before != null && before.getValue().overlaps(candidate)) { + return true; } + Map.Entry after = accepted.ceilingEntry(candidate.start()); + return after != null && after.getValue().overlaps(candidate); + } - private String apply(String text, List spans, SystemPolicy policy) { - return apply(text, spans, policy, new MaskContext()); - } + /** + * Убирает имена, стоящие в названиях организаций и объектов на карте: «Институт Склифосовского», + * «Музей Тропинина», «улица Королёва». Проверка не зависит от того, есть ли в тексте другие ПД: + * слово перед именем решает само по себе. + */ + static List dropOrganisationNames(String text, List spans) { + return spans.stream() + .filter( + span -> + !PdTypes.FIO.equals(span.type()) + || !OrganisationDetector.precededByOrganisation(text, span.start())) + .toList(); + } - private String apply(String text, List 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(); + /** + * Убирает имена известных людей: «стихи Александра Пушкина» персональными данными не являются. + * Если же в тексте есть ПД другого типа, речь идёт о конкретном человеке, и имя остаётся + * замаскированным — однофамилец исторической фигуры защиту не теряет. + */ + static List dropWellKnownNames(String text, List 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(); + } - /** - * В журнал и в метрики попадают только идентификатор, типы ПД и их количество. - * На INFO и выше сами значения не логируются; на DEBUG они временно видны через - * отдельный вызов в {@link #mask} — см. комментарий там. - */ - private void logFindings(String system, String payloadId, int length, List spans) { - Map 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); + /** + * Известный человек по самому спану («Пушкина») или по спану вместе со следующим словом + * («Ярослав» + «Мудрый»): правило-однослов ловит имя правителя отдельно от прозвища, а {@code + * REGNAL_NAME} распознаёт только двухсловную форму целиком. + */ + private static boolean isWellKnownHere(String text, Span span) { + if (NameDictionary.isWellKnown(text.substring(span.start(), span.end()))) { + return true; } -} \ No newline at end of file + 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)); + } + + /** + * Убирает типы, которые опасны только в сочетании с другими ПД. Пин-код в отрыве от номера карты + * не является персональными данными, рядом с номером карты — является. + * + *

Спутником считается только находка самостоятельного типа. Раньше здесь сравнивалось число + * различных типов, и два спутника заверяли друг друга: «Оплата 01.02.2025, ОГРН 1027700132195» + * маскировалась целиком, хотя человека в тексте нет, а дата и ОГРН по отдельности персональными + * данными не являются. Сочетание двух несамостоятельных типов самостоятельным не становится. + */ + static List dropLonelyCompanions(List spans, SystemPolicy policy) { + for (Span span : spans) { + if (!policy.needsCompanion(span.type())) { + return spans; + } + } + // Дошли сюда — самостоятельных находок нет, а значит все оставшиеся спутники одиноки. + return List.of(); + } + + /** Замаскированный текст вместе с таблицей обратной замены. */ + public record Masked(String text, Map restorations) { + public Masked { + restorations = Map.copyOf(restorations); + } + } + + /** + * Маскирует текст и отдаёт таблицу обратной замены. + * + *

Нужно для прокси к языковой модели: ответ модели — другой текст, и восстановить его целиком + * по идентификатору нельзя, замену приходится делать пофрагментно. Звёздочки для этого не годятся + * — одна и та же маска может отвечать разным значениям, — поэтому режим замены здесь всегда + * обратимый. + */ + 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 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 spans, SystemPolicy policy) { + return apply(text, spans, policy, new MaskContext()); + } + + private String apply(String text, List 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 spans) { + Map 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); + } +} diff --git a/src/main/java/ru/pdguard/core/PipelineWarmup.java b/src/main/java/ru/pdguard/core/PipelineWarmup.java index 99cb886..16484f7 100644 --- a/src/main/java/ru/pdguard/core/PipelineWarmup.java +++ b/src/main/java/ru/pdguard/core/PipelineWarmup.java @@ -1,6 +1,7 @@ package ru.pdguard.core; import jakarta.annotation.PostConstruct; +import java.util.Set; import org.slf4j.Logger; import org.slf4j.LoggerFactory; 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.Masker; -import java.util.Set; - /** * Прогон обработки на старте, чтобы первые запросы не попадали на непрогретый код. * - *

На JVM разница измерима: без прогрева первые десятки секунд нагрузки идут по - * интерпретируемому и наспех скомпилированному коду, и p95 оказывается примерно - * вдесятеро хуже установившегося. Несколько тысяч прогонов на старте занимают доли - * секунды и переводят горячий путь на оптимизирующий компилятор до того, как придут - * настоящие запросы. + *

На JVM разница измерима: без прогрева первые десятки секунд нагрузки идут по интерпретируемому + * и наспех скомпилированному коду, и p95 оказывается примерно вдесятеро хуже установившегося. + * Несколько тысяч прогонов на старте занимают доли секунды и переводят горячий путь на + * оптимизирующий компилятор до того, как придут настоящие запросы. * - *

Прогрев идёт через отдельный экземпляр обработки со своим короткоживущим - * хранилищем: настоящие соответствия «текст ↔ маска» замусорить нельзя. + *

Прогрев идёт через отдельный экземпляр обработки со своим короткоживущим хранилищем: настоящие + * соответствия «текст ↔ маска» замусорить нельзя. * - *

Вторая ступень при прогреве выключена, и не только ради времени: её счётчики - * показывают долю запросов, дошедших до модели, а тысячи служебных прогонов эту - * долю исказили бы до неузнаваемости. Сама модель прогревается отдельно, при - * создании своего пула. + *

Вторая ступень при прогреве выключена, и не только ради времени: её счётчики показывают долю + * запросов, дошедших до модели, а тысячи служебных прогонов эту долю исказили бы до неузнаваемости. + * Сама модель прогревается отдельно, при создании своего пула. */ @Component 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 = { - "Клиент Иванов Иван Иванович, паспорт 4509 123456, тел +7 916 123-45-67", - "Заявление от И.И. Петрова, ИНН 770301234550, почта ivan.petrov@mail.ru", - "Адрес: 125009, г. Москва, ул. Тверская, д. 7, кв. 15, карта 4111 1111 1111 1111", - "Дата рождения 12.05.1985, место рождения: город Тверь, гражданство РФ", - "Напиши краткое описание продукта для рассылки клиентам банка", - }; + /** Тексты подобраны так, чтобы задеть основные семейства правил. */ + private static final String[] SAMPLES = { + "Клиент Иванов Иван Иванович, паспорт 4509 123456, тел +7 916 123-45-67", + "Заявление от И.И. Петрова, ИНН 770301234550, почта ivan.petrov@mail.ru", + "Адрес: 125009, г. Москва, ул. Тверская, д. 7, кв. 15, карта 4111 1111 1111 1111", + "Дата рождения 12.05.1985, место рождения: город Тверь, гражданство РФ", + "Напиши краткое описание продукта для рассылки клиентам банка", + }; - private final RuleRegistry registry; - private final Masker masker; - private final int iterations; + private final RuleRegistry registry; + private final Masker masker; + private final int iterations; - public PipelineWarmup(RuleRegistry registry, Masker masker, - @Value("${pdguard.warmup-iterations:2000}") int iterations) { - this.registry = registry; - this.masker = masker; - this.iterations = iterations; + public PipelineWarmup( + RuleRegistry registry, + Masker masker, + @Value("${pdguard.warmup-iterations:2000}") int 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_000_000L, 1), NameCascade.disabled()); + SystemPolicy policy = + new SystemPolicy( + SystemPolicy.DEFAULT_NAME, + true, + true, + MaskMode.MASK, + Set.of(SystemPolicy.ALL), + SystemPolicy.DEFAULT.requireCompanion(), + null); - @PostConstruct - void warmup() { - if (iterations <= 0) { - LOG.info("Прогрев обработки отключён"); - return; - } - 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); + 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); } -} \ No newline at end of file + LOG.info( + "Прогрев обработки: {} прогонов за {} мс", + iterations, + (System.nanoTime() - started) / 1_000_000); + } +} diff --git a/src/main/java/ru/pdguard/core/ScopedKey.java b/src/main/java/ru/pdguard/core/ScopedKey.java index c843a57..c8c3002 100644 --- a/src/main/java/ru/pdguard/core/ScopedKey.java +++ b/src/main/java/ru/pdguard/core/ScopedKey.java @@ -3,18 +3,16 @@ package ru.pdguard.core; /** * Ключ, однозначно разделяющий системы-потребители. * - *

Длина имени в начале снимает вопрос о разделителе: имя системы может - * содержать любые знаки, и без длины «a:b» и «ab:» были бы неразличимы. - * Используется и в локальном хранилище, и в общем слое — единая реализация - * вместо двух копий. + *

Длина имени в начале снимает вопрос о разделителе: имя системы может содержать любые знаки, и + * без длины «a:b» и «ab:» были бы неразличимы. Используется и в локальном хранилище, и в общем слое + * — единая реализация вместо двух копий. */ final class ScopedKey { - private ScopedKey() { - } + private ScopedKey() {} - static String of(String system, String key) { - String owner = system == null ? "" : system; - return owner.length() + ":" + owner + ":" + key; - } -} \ No newline at end of file + static String of(String system, String key) { + String owner = system == null ? "" : system; + return owner.length() + ":" + owner + ":" + key; + } +} diff --git a/src/main/java/ru/pdguard/core/SharedIndex.java b/src/main/java/ru/pdguard/core/SharedIndex.java index 9c10d9a..dec2f46 100644 --- a/src/main/java/ru/pdguard/core/SharedIndex.java +++ b/src/main/java/ru/pdguard/core/SharedIndex.java @@ -2,162 +2,164 @@ package ru.pdguard.core; import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.databind.ObjectMapper; +import java.time.Duration; +import java.util.concurrent.atomic.AtomicInteger; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.beans.factory.annotation.Value; import org.springframework.data.redis.core.StringRedisTemplate; import org.springframework.stereotype.Component; -import java.time.Duration; -import java.util.concurrent.atomic.AtomicInteger; - /** * Общий слой соответствий «текст ↔ маска» для работы на нескольких узлах. * - *

Маскирование — чистая функция, на любом узле даёт один и тот же результат. - * Обратное же преобразование требует состояния: если прямой запрос обработал - * один узел, а обратный попал на другой, соответствие должно быть общим. + *

Маскирование — чистая функция, на любом узле даёт один и тот же результат. Обратное же + * преобразование требует состояния: если прямой запрос обработал один узел, а обратный попал на + * другой, соответствие должно быть общим. * - *

Включается настройкой {@code pdguard.store.backend=redis}. Пока она не - * выставлена, к Redis не обращаются вовсе и зависимость остаётся неактивной. + *

Включается настройкой {@code pdguard.store.backend=redis}. Пока она не выставлена, к Redis не + * обращаются вовсе и зависимость остаётся неактивной. * - *

Недоступность Redis не приводит к отказу: запись и чтение деградируют до - * локальной памяти узла, а ошибка попадает в журнал. Чтобы простой Redis не - * съедал время ответа, команды ограничены по времени настройкой - * {@code spring.data.redis.timeout}, а после нескольких подряд неудач общий слой - * временно перестают опрашивать вовсе. + *

Недоступность Redis не приводит к отказу: запись и чтение деградируют до локальной памяти + * узла, а ошибка попадает в журнал. Чтобы простой Redis не съедал время ответа, команды ограничены + * по времени настройкой {@code spring.data.redis.timeout}, а после нескольких подряд неудач общий + * слой временно перестают опрашивать вовсе. */ @Component 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; } - - 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; + 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); } + } - /** Выключенный слой — для тестов и для сборки без Redis. */ - public static SharedIndex disabled() { - return new SharedIndex(null, "memory", 30, new ObjectMapper(), PayloadCipher.disabled()); + public SharedEntry byId(String system, String payloadId) { + if (unavailable()) { + 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() { - return enabled; + public String originalForFingerprint(String system, String maskFingerprint) { + 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, - String maskFingerprint) { - if (unavailable()) { - return; - } - 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); - } + private String toJson(SharedEntry entry) { + try { + return mapper.writeValueAsString(entry); + } catch (JsonProcessingException e) { + throw new IllegalStateException("Не удалось сериализовать соответствие", e); } + } - public SharedEntry byId(String system, String payloadId) { - if (unavailable()) { - 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; - } + private SharedEntry fromJson(String json) { + try { + return mapper.readValue(json, SharedEntry.class); + } catch (JsonProcessingException e) { + throw new IllegalStateException("Не удалось разобрать соответствие из общего слоя", e); } + } - public String originalForFingerprint(String system, String maskFingerprint) { - 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; - } - } + /** Общий слой выключен или предохранитель разомкнут. */ + private boolean unavailable() { + return !enabled || System.currentTimeMillis() < silentUntil; + } - private String toJson(SharedEntry entry) { - try { - return mapper.writeValueAsString(entry); - } catch (JsonProcessingException e) { - throw new IllegalStateException("Не удалось сериализовать соответствие", e); - } + private void noteSuccess() { + if (consecutiveFailures.getAndSet(0) != 0) { + reported = false; + LOG.info("Общий слой снова доступен"); } + } - private SharedEntry fromJson(String json) { - try { - return mapper.readValue(json, SharedEntry.class); - } catch (JsonProcessingException e) { - throw new IllegalStateException("Не удалось разобрать соответствие из общего слоя", e); - } + /** + * После нескольких неудач подряд общий слой перестают опрашивать на несколько секунд: иначе + * каждый запрос платил бы таймаутом за недоступный Redis, а проверяющая система считает ответ + * дольше десяти секунд неответом. + */ + private void noteFailure(String action, RuntimeException cause) { + if (consecutiveFailures.incrementAndGet() >= FAILURES_TO_OPEN) { + silentUntil = System.currentTimeMillis() + OPEN_MILLIS; } - - /** Общий слой выключен или предохранитель разомкнут. */ - private boolean unavailable() { - return !enabled || System.currentTimeMillis() < silentUntil; + if (!reported) { + reported = true; + LOG.error( + "Не удалось {} соответствие в общий слой, узел работает на своей памяти", 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); - } - } -} \ No newline at end of file + } +} diff --git a/src/main/java/ru/pdguard/detect/AddressRules.java b/src/main/java/ru/pdguard/detect/AddressRules.java index 378644e..5b7284a 100644 --- a/src/main/java/ru/pdguard/detect/AddressRules.java +++ b/src/main/java/ru/pdguard/detect/AddressRules.java @@ -1,7 +1,5 @@ package ru.pdguard.detect; -import java.util.List; - import static ru.pdguard.detect.RulePatterns.CITIZENSHIP_GAP; import static ru.pdguard.detect.RulePatterns.CITIZENSHIP_VALUE; import static ru.pdguard.detect.RulePatterns.ORGANISATION_NEARBY; @@ -9,124 +7,165 @@ 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() { - } + private AddressRules() {} - static final List RULES = List.of( + static final List 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("выдан", "выдал", "выдач"), + // «выдан ОУФМС России по г. Москве 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("указанн"), + // «совпадает с указанным в анкете: 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.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("родил"), - Rule.of(PdTypes.BIRTH_PLACE, "(?iu:мест\\w*\\s+рождения)\\W{0,5}([^,;\\n]{3,60}?)(?=\\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.BIRTH_PLACE, "(?iu:родил(?:ся|ась))[^,;\\n]{0,40}?\\s+в\\s+" - + "([^,;\\n]{3,40}?)(?=\\s*[,;\\n]|\\s*$)", 76) - .groups(1) - .anchoredBy("родил"), + // ин/ка/ина/ки — именительный/родительный; ином/кой — творительный + // («гражданином», «гражданкой»). + Rule.of( + PdTypes.CITIZENSHIP, + "(?iu:граждан(?:ин|ка|ина|ки|ином|кой))\\b\\s+" + + "(" + + CITIZENSHIP_VALUE + + ")(?:\\s*[,/]\\s*(" + + CITIZENSHIP_VALUE + + "))?", + 75) + .groups(1, 2) + .validatedBy(CountryDictionary::isKnownCountry) + .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_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.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("стран")); } diff --git a/src/main/java/ru/pdguard/detect/ContactRules.java b/src/main/java/ru/pdguard/detect/ContactRules.java index f163d06..f771155 100644 --- a/src/main/java/ru/pdguard/detect/ContactRules.java +++ b/src/main/java/ru/pdguard/detect/ContactRules.java @@ -1,38 +1,40 @@ package ru.pdguard.detect; -import java.util.List; - import static ru.pdguard.detect.RulePatterns.ROLE_GAP; +import java.util.List; + /** Правила распознавания контактных и идентификационных данных: телефон, email, ИНН, СНИЛС. */ final class ContactRules { - private ContactRules() { - } + private ContactRules() {} - static final List RULES = List.of( + static final List RULES = + List.of( + Rule.of(PdTypes.INN, "(?iu)\\bИНН\\b" + ROLE_GAP + "(\\d{12}|\\d{10})\\b", 84) + .groups(1) + .anchoredBy("инн"), - 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), - // «ИНН/КПП 7712345671/771201001» — ИНН юрлица перед КПП через слэш. - Rule.of(PdTypes.INN, "(?iu)\\bИНН\\s*/\\s*КПП\\b\\W{0,5}(\\d{10})\\b", 84) - .groups(1) - .anchoredBy("инн/кпп"), + // \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.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) - ); + // ИНН физлица без якорного слова — только с верной контрольной суммой. + Rule.of(PdTypes.INN, "\\b\\d{12}\\b", 62).validatedBy(Validators::inn)); } diff --git a/src/main/java/ru/pdguard/detect/CountryDictionary.java b/src/main/java/ru/pdguard/detect/CountryDictionary.java index 2f9c4e9..19734ae 100644 --- a/src/main/java/ru/pdguard/detect/CountryDictionary.java +++ b/src/main/java/ru/pdguard/detect/CountryDictionary.java @@ -4,35 +4,34 @@ import java.util.Locale; import java.util.Set; /** - * Словарь названий стран — проверка того, что значение, пойманное правилом - * {@code CITIZENSHIP}, действительно похоже на страну, а не на произвольное - * слово с заглавной буквы после якоря «гражданство». + * Словарь названий стран — проверка того, что значение, пойманное правилом {@code CITIZENSHIP}, + * действительно похоже на страну, а не на произвольное слово с заглавной буквы после якоря + * «гражданство». * - *

Сравнение по началу слова, а не точным совпадением: падежные окончания - * («в России», «из Казахстана») и формы прилагательных («российская», - * «российское») тем самым покрываются без отдельного разбора морфологии. - * Основа «российск» покрывает и «Российская», и «российская», и «российское». + *

Сравнение по началу слова, а не точным совпадением: падежные окончания («в России», «из + * Казахстана») и формы прилагательных («российская», «российское») тем самым покрываются без + * отдельного разбора морфологии. Основа «российск» покрывает и «Российская», и «российская», и + * «российское». */ public final class CountryDictionary { - private static final Set COUNTRY_STEMS = ResourceLoader.set("/names/countries.txt"); + private static final Set COUNTRY_STEMS = ResourceLoader.set("/names/countries.txt"); - private CountryDictionary() { - } + private CountryDictionary() {} - /** - * Похоже ли значение на название страны из словаря в любом падеже и регистре. - * - *

Проверяются префиксы значения по множеству, а не каждая основа по - * значению: префиксов у слова не больше, чем в нём букв. - */ - public static boolean isKnownCountry(String value) { - String lower = value.strip().toLowerCase(Locale.ROOT); - for (int length = lower.length(); length > 0; length--) { - if (COUNTRY_STEMS.contains(lower.substring(0, length))) { - return true; - } - } - return false; + /** + * Похоже ли значение на название страны из словаря в любом падеже и регистре. + * + *

Проверяются префиксы значения по множеству, а не каждая основа по значению: префиксов у + * слова не больше, чем в нём букв. + */ + public static boolean isKnownCountry(String value) { + String lower = value.strip().toLowerCase(Locale.ROOT); + for (int length = lower.length(); length > 0; length--) { + if (COUNTRY_STEMS.contains(lower.substring(0, length))) { + return true; + } } -} \ No newline at end of file + return false; + } +} diff --git a/src/main/java/ru/pdguard/detect/DateRules.java b/src/main/java/ru/pdguard/detect/DateRules.java index a2c2348..478850f 100644 --- a/src/main/java/ru/pdguard/detect/DateRules.java +++ b/src/main/java/ru/pdguard/detect/DateRules.java @@ -1,45 +1,46 @@ package ru.pdguard.detect; -import java.util.List; - 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() { - } + private DateRules() {} - static final List RULES = List.of( + static final List 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("г.р", "г/р", "года рождения"), - Rule.of(PdTypes.BIRTH_DATE, "(?iu:дат\\p{L}*\\s+рождения|дата\\s+рожд\\.)" - + DATE_GAP + "(" + DATE_ANY + ")", 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.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) - ); + // Дата без якорного слова персональными данными сама по себе не является: + // маскируется, только если в тексте есть ПД другого типа. + Rule.of(PdTypes.DATE, DATE_ANY, 58).validatedBy(Validators::date)); } diff --git a/src/main/java/ru/pdguard/detect/Declension.java b/src/main/java/ru/pdguard/detect/Declension.java index 7de39c6..eba01bb 100644 --- a/src/main/java/ru/pdguard/detect/Declension.java +++ b/src/main/java/ru/pdguard/detect/Declension.java @@ -3,51 +3,47 @@ package ru.pdguard.detect; import java.util.Locale; /** - * Общий приём для словарей, сравнивающих слово из текста с основой из списка: - * личные имена ({@link NameDictionary}) и города ({@link ToponymDictionary}). + * Общий приём для словарей, сравнивающих слово из текста с основой из списка: личные имена ({@link + * NameDictionary}) и города ({@link ToponymDictionary}). * - *

Слова на согласную склоняются добавлением окончания («Тамбов» → «Тамбове», - * «Пушкин» → «Пушкина») — там основы из списка достаточно как есть. Слова на - * гласную меняют последнюю букву («Москва» → «Москве», «Ольга» → «Ольге») — - * для них сравнение идёт по основе без неё. + *

Слова на согласную склоняются добавлением окончания («Тамбов» → «Тамбове», «Пушкин» → + * «Пушкина») — там основы из списка достаточно как есть. Слова на гласную меняют последнюю букву + * («Москва» → «Москве», «Ольга» → «Ольге») — для них сравнение идёт по основе без неё. * - *

Фамилии на «-ский» склоняются как прилагательное: окончание меняется - * целиком («Дзержинский» → «Дзержинского», «-ий» на «-ого», а не дописывается), - * поэтому для них отсечения одной буквы недостаточно — основа обрезается сразу - * до «ск». Для улиц в честь людей это не редкий случай, а основной: «улица - * Дзержинского», «улица Островского» пишутся только в родительном падеже, + *

Фамилии на «-ский» склоняются как прилагательное: окончание меняется целиком («Дзержинский» → + * «Дзержинского», «-ий» на «-ого», а не дописывается), поэтому для них отсечения одной буквы + * недостаточно — основа обрезается сразу до «ск». Для улиц в честь людей это не редкий случай, а + * основной: «улица Дзержинского», «улица Островского» пишутся только в родительном падеже, * именительный там не встречается вообще. */ final class Declension { - /** - * Падежные окончания прилагательного склонения на «-ск-»: мужской, женский - * и средний род, все падежи. Проверяются от длинных к коротким — «-ского» - * не должно потеряться из-за более короткого совпадения на «-ким» и т.п. - */ - private static final String[] ADJECTIVE_ENDINGS = { - "ского", "скому", "ским", "ском", "скую", "ской", "скою", "ская", "ский" - }; + /** + * Падежные окончания прилагательного склонения на «-ск-»: мужской, женский и средний род, все + * падежи. Проверяются от длинных к коротким — «-ского» не должно потеряться из-за более короткого + * совпадения на «-ким» и т.п. + */ + private static final String[] ADJECTIVE_ENDINGS = { + "ского", "скому", "ским", "ском", "скую", "ской", "скою", "ская", "ский" + }; - private Declension() { - } + private Declension() {} - /** - * Отбрасывает у основы окончание, которое меняется по падежам: гласную — - * у обычных слов, целиком «-ск-»-окончание — у прилагательных фамилий. - * Слова короче четырёх букв не трогает — короткая основа и так шире - * большинства падежных форм. - */ - static String withoutInflectedEnding(String word) { - String lower = word.toLowerCase(Locale.ROOT); - for (String ending : ADJECTIVE_ENDINGS) { - if (lower.length() > ending.length() && lower.endsWith(ending)) { - 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; + /** + * Отбрасывает у основы окончание, которое меняется по падежам: гласную — у обычных слов, целиком + * «-ск-»-окончание — у прилагательных фамилий. Слова короче четырёх букв не трогает — короткая + * основа и так шире большинства падежных форм. + */ + static String withoutInflectedEnding(String word) { + String lower = word.toLowerCase(Locale.ROOT); + for (String ending : ADJECTIVE_ENDINGS) { + if (lower.length() > ending.length() && lower.endsWith(ending)) { + 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; + } } diff --git a/src/main/java/ru/pdguard/detect/DocumentRules.java b/src/main/java/ru/pdguard/detect/DocumentRules.java index c70219a..08f2c7d 100644 --- a/src/main/java/ru/pdguard/detect/DocumentRules.java +++ b/src/main/java/ru/pdguard/detect/DocumentRules.java @@ -1,85 +1,113 @@ package ru.pdguard.detect; -import java.util.List; - 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() { - } + private DocumentRules() {} - static final List RULES = List.of( + static final List 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", "цвв", "сививи", "код на обороте", "код проверки", "защитный код"), + // 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"), + // 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» + 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("сери"), + // «серия 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("водительск", "в/у", "вод.", "ву "), + // Необязательное «серия»/«серии» между якорем и цифрами: «ВУ серия 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("полис"), - Rule.of(PdTypes.FOREIGN_PASSPORT, "(?iu:загранпаспорт|заграничн\\p{L}*\\s+паспорт)\\p{L}*" - + "\\W{0,10}(\\d{2}\\s?\\d{7})\\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("подразделени", "к/п"), - 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("подразделени") - ); + // «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("подразделени")); } diff --git a/src/main/java/ru/pdguard/detect/FinanceRules.java b/src/main/java/ru/pdguard/detect/FinanceRules.java index 8bbae30..4b11364 100644 --- a/src/main/java/ru/pdguard/detect/FinanceRules.java +++ b/src/main/java/ru/pdguard/detect/FinanceRules.java @@ -1,72 +1,77 @@ package ru.pdguard.detect; -import java.util.List; - import static ru.pdguard.detect.RulePatterns.HOLDER_STEM; +import java.util.List; + /** Правила распознавания банковских реквизитов, карты, ОГРН/КПП и держателя карты. */ final class FinanceRules { - private FinanceRules() { - } + private FinanceRules() {} - static final List RULES = List.of( + static final List 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("р/с", "расчетн", "расчётн", "лицев"), + // Расчётный счёт — ровно 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("бик"), - 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"), - // «действительна до 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("кпп"), - // ОГРНИП раньше ОГРН: без отрицательного просмотра «ОГРНИП» частично ловился бы - // ещё и правилом ОГРН. Контрольная сумма отсекает случайные 13/15-значные - // числа рядом со словом — раньше якоря было достаточно самого по себе. - Rule.of(PdTypes.OGRNIP, "(?iu:огрнип)\\W{0,5}(\\d{15})\\b", 83) - .groups(1) - .validatedBy(Validators::ogrnip) - .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.OGRN, "(?iu:огрн(?!ип))\\W{0,5}(\\d{13})\\b", 83) - .groups(1) - .validatedBy(Validators::ogrn) - .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", "на имя"), - Rule.of(PdTypes.KPP, "(?iu:кпп)\\W{0,5}(\\d{9})\\b", 83) - .groups(1) - .anchoredBy("кпп"), + // --- Уровень 1: подтверждается контрольной суммой --- - // Доход/зарплата: сумма с разделителями тысяч. Между якорем и суммой может - // стоять слово («доход клиента», «доход за год») — без этого якорь ловил - // бы только «доход 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) - ); + Rule.of(PdTypes.CARD, "\\b\\d(?:[ -]?\\d){11,18}\\b", 85).validatedBy(Validators::luhn)); } diff --git a/src/main/java/ru/pdguard/detect/FioRules.java b/src/main/java/ru/pdguard/detect/FioRules.java index e5190c3..4f9134e 100644 --- a/src/main/java/ru/pdguard/detect/FioRules.java +++ b/src/main/java/ru/pdguard/detect/FioRules.java @@ -1,100 +1,152 @@ package ru.pdguard.detect; -import java.util.List; - 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() { - } + private FioRules() {} - static final List RULES = List.of( + static final List RULES = + List.of( - // Фамилия Имя Отчество: первое слово опознаётся по словообразованию фамилии. - // Свободная тройка «любое слово с заглавной + имя + отчество» здесь - // сознательно не используется: она захватывает глагол в начале - // предложения («Пригласите Ивана Сергеевича») и заметно дороже по времени. - // Фамилии без привычного окончания — Ким, Цой — ловятся по ролевому слову. - Rule.of(PdTypes.FIO, "\\b" + SURNAME + "\\s+" + CAPITALISED + "\\s+" + PATRONYMIC + "\\b", 79), + // Фамилия Имя Отчество: первое слово опознаётся по словообразованию фамилии. + // Свободная тройка «любое слово с заглавной + имя + отчество» здесь + // сознательно не используется: она захватывает глагол в начале + // предложения («Пригласите Ивана Сергеевича») и заметно дороже по времени. + // Фамилии без привычного окончания — Ким, Цой — ловятся по ролевому слову. + 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" + 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" + 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, "\\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ф\\.\\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держател)\\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, + "(?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" + 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, "(?Правила и словарь разбирают подавляющее большинство случаев и стоят десятки - * микросекунд. Модель нужна там, где они бессильны: имена без русского - * словообразования и нестандартные топонимы. + *

Правила и словарь разбирают подавляющее большинство случаев и стоят десятки микросекунд. + * Модель нужна там, где они бессильны: имена без русского словообразования и нестандартные + * топонимы. * - *

Поэтому модель зовут не на весь текст, а только на кандидатов — цепочки из - * двух-трёх слов с заглавной буквы, которые первая ступень не покрыла. Их в обычном - * запросе единицы, и на задержку это почти не влияет. Дороже модель — тем важнее - * такая экономия: у BERT вызов стоит десятки миллисекунд, и звать его на каждый - * запрос было бы невозможно. + *

Поэтому модель зовут не на весь текст, а только на кандидатов — цепочки из двух-трёх слов с + * заглавной буквы, которые первая ступень не покрыла. Их в обычном запросе единицы, и на задержку + * это почти не влияет. Дороже модель — тем важнее такая экономия: у BERT вызов стоит десятки + * миллисекунд, и звать его на каждый запрос было бы невозможно. * - *

Используются две модели под разные задачи: одна размечает имена (например, - * WikiNEuRal, который не распознаёт известных личностей), другая — составляющие - * адреса (например, ruBERT с детальными метками страны, региона, района, города, - * улицы и дома). Каждая модель зовётся только на непокрытые кандидаты. + *

Используются две модели под разные задачи: одна размечает имена (например, WikiNEuRal, который + * не распознаёт известных личностей), другая — составляющие адреса (например, ruBERT с детальными + * метками страны, региона, района, города, улицы и дома). Каждая модель зовётся только на + * непокрытые кандидаты. * - *

Ступень выключена, пока не задан движок. Сбой ступени на первую не влияет: - * ошибка перехватывается здесь, ступень выключается насовсем, и дальше работают - * правила. Иначе одно исключение обнуляло бы маскирование целиком. + *

Ступень выключена, пока не задан движок. Сбой ступени на первую не влияет: ошибка + * перехватывается здесь, ступень выключается насовсем, и дальше работают правила. Иначе одно + * исключение обнуляло бы маскирование целиком. */ @Component 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_DESCRIPTION = "Обращения, дошедшие до второй ступени"; - private static final String OUTCOME_TAG = "outcome"; + /** Имя метрики обращений ко второй ступени, её описание и имя метки исхода. */ + private static final String NER_REQUESTS_METRIC = "pdguard.ner.requests"; - /** Метки WikiNEuRal в типы ПД: только PER — имя. Адреса размечает ruBERT. */ - private static final Map NAME_TYPES = Map.of( - "PER", PdTypes.FIO); + private static final String NER_REQUESTS_DESCRIPTION = "Обращения, дошедшие до второй ступени"; + private static final String OUTCOME_TAG = "outcome"; - /** Метки ruBERT в типы ПД: детальные составляющие адреса. */ - private static final Map ADDRESS_TYPES = Map.of( - "COUNTRY", PdTypes.ADDRESS_COUNTRY, - "REGION", PdTypes.ADDRESS_REGION, - "DISTRICT", PdTypes.ADDRESS_DISTRICT, - "CITY", PdTypes.ADDRESS_CITY, - "STREET", PdTypes.ADDRESS_STREET, - "HOUSE", PdTypes.ADDRESS_HOUSE); + /** Метки WikiNEuRal в типы ПД: только PER — имя. Адреса размечает ruBERT. */ + private static final Map NAME_TYPES = Map.of("PER", PdTypes.FIO); - /** - * Метки LLAIM Legal NER в типы ПД: юридические реквизиты и документы, которых - * нет в общих моделях. ADDRESS не сопоставляется — ruBERT размечает адреса - * детальнее. ORG, CASE_NUMBER и POSITION аналогов в {@link PdTypes} не имеют. - */ - private static final Map 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); + /** Метки ruBERT в типы ПД: детальные составляющие адреса. */ + private static final Map ADDRESS_TYPES = + Map.of( + "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 Pattern CANDIDATE = Pattern.compile( - "\\p{Lu}[\\p{L}-]+(?:\\s+\\p{Lu}[\\p{L}-]+){1,2}", - Pattern.UNICODE_CHARACTER_CLASS | Pattern.UNICODE_CASE); + /** + * Метки LLAIM Legal NER в типы ПД: юридические реквизиты и документы, которых нет в общих + * моделях. ADDRESS не сопоставляется — ruBERT размечает адреса детальнее. ORG, CASE_NUMBER и + * POSITION аналогов в {@link PdTypes} не имеют. + */ + private static final Map 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); - /** - * Кандидат для LLAIM Legal NER: одиночное слово или цифровой кластер - * (10–19 цифр с разделителями). Юридические реквизиты (ИНН, СНИЛС, паспорт) - * не являются словами с заглавной буквы, поэтому для них нужен отдельный - * проход, не влияющий на кандидатов моделей имён и адресов. - */ - private static final Pattern LEGAL_CANDIDATE = Pattern.compile( - "(?:\\p{Lu}[\\p{L}-]+|\\p{Ll}[\\p{L}-]+|\\d(?:[\\s.\\-/()]?\\d){9,18})", - Pattern.UNICODE_CHARACTER_CLASS | Pattern.UNICODE_CASE); + /** Цепочка из двух-трёх слов с заглавной буквы — то, что может оказаться ПД. */ + 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 static final int PRIORITY = 73; + /** + * Кандидат для LLAIM Legal NER: одиночное слово или цифровой кластер (10–19 цифр с + * разделителями). Юридические реквизиты (ИНН, СНИЛС, паспорт) не являются словами с заглавной + * буквы, поэтому для них нужен отдельный проход, не влияющий на кандидатов моделей имён и + * адресов. + */ + private static final Pattern LEGAL_CANDIDATE = + Pattern.compile( + "(?:\\p{Lu}[\\p{L}-]+|\\p{Ll}[\\p{L}-]+|\\d(?:[\\s.\\-/()]?\\d){9,18})", + Pattern.UNICODE_CHARACTER_CLASS | Pattern.UNICODE_CASE); - /** Сколько знаков текста вокруг кандидата отдаётся модели как контекст. */ - private static final int CONTEXT_CHARS = 60; + /** Приоритет находок второй ступени: ниже правил, у которых больше оснований. */ + private static final int PRIORITY = 73; - private final RuBertRecogniser nameRecogniser; - private final RuBertRecogniser addressRecogniser; - private final RuBertRecogniser legalRecogniser; - private final Semaphore concurrent; - private final int maxCandidates; - private volatile boolean broken; + /** Сколько знаков текста вокруг кандидата отдаётся модели как контекст. */ + private static final int CONTEXT_CHARS = 60; - /** - * Сколько обращений дошло до модели, а сколько обошлось правилами. Отношение - * {@code engaged} ко всем обращениям и есть та доля, от которой зависит, - * посильна ли тяжёлая модель на боевом трафике. - */ - private final Counter engaged; - private final Counter withoutCandidates; - private final Counter busy; - private final Counter candidates; - private final Timer duration; + private final RuBertRecogniser nameRecogniser; + private final RuBertRecogniser addressRecogniser; + private final RuBertRecogniser legalRecogniser; + private final Semaphore concurrent; + private final int maxCandidates; + private volatile boolean broken; - @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); + /** + * Сколько обращений дошло до модели, а сколько обошлось правилами. Отношение {@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( + @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); + } + + /** Конструктор для тестов: движки задаются конфигом, метрики — реестром. */ + private NameCascade(EngineConfig config, int maxCandidates, int poolSize, MeterRegistry meters) { + this( + config.nameEngine(), + config.nameModel().orElse(""), + config.addressEngine(), + config.addressModel().orElse(""), + config.legalEngine(), + config.legalModel().orElse(""), + maxCandidates, + poolSize, + meters); + } + + /** Конструктор для тестов: одна модель для имён, метрики никуда не отдаются. */ + public NameCascade(String engine, Optional 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 nameModel, + String addressEngine, + Optional addressModel, + int maxCandidates, + int poolSize) { + this( + new EngineConfig( + nameEngine, nameModel, addressEngine, addressModel, "off", Optional.empty()), + maxCandidates, + poolSize, + new SimpleMeterRegistry()); + } + + /** Конструктор для тестов двух моделей с явным реестром метрик. */ + public NameCascade( + String nameEngine, + Optional nameModel, + String addressEngine, + Optional 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 nameModel, + String addressEngine, + Optional addressModel, + String legalEngine, + Optional 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); + } + + 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); + } - /** Конструктор для тестов: движки задаются конфигом, метрики — реестром. */ - private NameCascade(EngineConfig config, int maxCandidates, int poolSize, MeterRegistry meters) { - this(config.nameEngine(), config.nameModel().orElse(""), - config.addressEngine(), config.addressModel().orElse(""), - config.legalEngine(), config.legalModel().orElse(""), - maxCandidates, poolSize, meters); + /** + * Добавляет ПД, которые не нашла первая ступень. Уже принятые фрагменты не трогаются: модели + * разбирают только непокрытые участки. + */ + public List addMissedNames(String text, List accepted) { + if (!enabled()) { + return accepted; } - - /** Конструктор для тестов: одна модель для имён, метрики никуда не отдаются. */ - public NameCascade(String engine, Optional modelPath, int maxCandidates, int poolSize) { - this(new EngineConfig(engine, modelPath, "off", Optional.empty(), "off", Optional.empty()), - maxCandidates, poolSize, new SimpleMeterRegistry()); + if (!concurrent.tryAcquire()) { + // Модель занята целиком: отвечаем по правилам, а не копим очередь. + busy.increment(); + return accepted; } - - /** Конструктор для тестов двух моделей: метрики никуда не отдаются. */ - public NameCascade(String nameEngine, Optional nameModel, - String addressEngine, Optional addressModel, - int maxCandidates, int poolSize) { - this(new EngineConfig(nameEngine, nameModel, addressEngine, addressModel, "off", Optional.empty()), - maxCandidates, poolSize, new SimpleMeterRegistry()); - } - - /** Конструктор для тестов двух моделей с явным реестром метрик. */ - public NameCascade(String nameEngine, Optional nameModel, - String addressEngine, Optional 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 nameModel, - String addressEngine, Optional addressModel, - String legalEngine, Optional 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); - } - - 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; + long started = System.nanoTime(); + try { + List found = new ArrayList<>(accepted); + int examined = 0; + Matcher m = CANDIDATE.matcher(text); + while (m.find() && examined < maxCandidates) { + if (fullyCovered(found, m.start(), m.end())) { + continue; } - 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); + examined++; + collect(text, m.start(), m.end(), found, nameRecogniser); + collect(text, m.start(), m.end(), found, addressRecogniser); + } + // LLAIM Legal NER ищет реквизиты (ИНН, СНИЛС, паспорт), которые не + // являются словами с заглавной буквы, — отдельный проход по своим + // кандидатам, чтобы не вытеснять кандидатов моделей имён и адресов. + if (legalRecogniser != null) { + Matcher lm = LEGAL_CANDIDATE.matcher(text); + while (lm.find() && examined < maxCandidates) { + if (fullyCovered(found, lm.start(), lm.end())) { + continue; + } + examined++; + collect(text, lm.start(), lm.end(), found, legalRecogniser); + } + } + 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(); } + } - /** - * Добавляет ПД, которые не нашла первая ступень. Уже принятые фрагменты не - * трогаются: модели разбирают только непокрытые участки. - */ - public List addMissedNames(String text, List accepted) { - if (!enabled()) { - return accepted; - } - if (!concurrent.tryAcquire()) { - // Модель занята целиком: отвечаем по правилам, а не копим очередь. - busy.increment(); - return accepted; - } - long started = System.nanoTime(); - try { - List found = new ArrayList<>(accepted); - int examined = 0; - Matcher m = CANDIDATE.matcher(text); - while (m.find() && examined < maxCandidates) { - if (fullyCovered(found, m.start(), m.end())) { - continue; - } - examined++; - collect(text, m.start(), m.end(), found, nameRecogniser); - collect(text, m.start(), m.end(), found, addressRecogniser); - } - // LLAIM Legal NER ищет реквизиты (ИНН, СНИЛС, паспорт), которые не - // являются словами с заглавной буквы, — отдельный проход по своим - // кандидатам, чтобы не вытеснять кандидатов моделей имён и адресов. - if (legalRecogniser != null) { - Matcher lm = LEGAL_CANDIDATE.matcher(text); - while (lm.find() && examined < maxCandidates) { - if (fullyCovered(found, lm.start(), lm.end())) { - continue; - } - examined++; - collect(text, lm.start(), lm.end(), found, legalRecogniser); - } - } - 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 sink, + RuBertRecogniser recogniser) { + if (recogniser == null) { + return; } - -private void collect(String text, int candidateStart, int candidateEnd, List sink, - RuBertRecogniser recogniser) { - if (recogniser == null) { - return; - } - int from = Math.max(0, candidateStart - CONTEXT_CHARS); - int to = Math.min(text.length(), candidateEnd + CONTEXT_CHARS); - boolean nameFound = false; - 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; - } - } - } - // Модель распознала имя в кандидате, но правила могли найти лишь его часть - // («Жан» вместо «Жан-Поль Дюваль») с более высоким приоритетом и заблокировать - // полное имя при разрешении перекрытий. Убираем такие частичные находки правил, - // чтобы полное имя от модели осталось: избыточное покрытие безопаснее утечки ПД. - 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; + 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; } + } } - - /** Покрыт ли фрагмент целиком уже принятыми находками. */ - private static boolean fullyCovered(List spans, int start, int end) { - return spans.stream().anyMatch(span -> span.start() <= start && end <= span.end()); + // Модель распознала имя в кандидате, но правила могли найти лишь его часть + // («Жан» вместо «Жан-Поль Дюваль») с более высоким приоритетом и заблокировать + // полное имя при разрешении перекрытий. Убираем такие частичные находки правил, + // чтобы полное имя от модели осталось: избыточное покрытие безопаснее утечки ПД. + if (nameFound) { + sink.removeIf( + span -> + PdTypes.FIO.equals(span.type()) + && span.start() < candidateEnd + && candidateStart < span.end() + && span.priority() > PRIORITY); } + } - /** - * Слова-маркеры ПД, которые модель иногда ошибочно помечает как ФИО - * («ИНН», «СНИЛС», «паспорт»). Такие находки — шум: это не имена, а - * обозначения реквизитов, и маскировать их как ФИО нельзя. - */ - private static final Set PD_MARKERS = Set.of( - "инн", "снилс", "огрн", "огрнип", "кпп", "бик", "паспорт", "счёт", "счет", - "телефон", "email", "почта", "дата", "адрес", "полис", "свидетельство"); + /** Покрыт ли фрагмент целиком уже принятыми находками. */ + private static boolean fullyCovered(List spans, int start, int end) { + return spans.stream().anyMatch(span -> span.start() <= start && end <= span.end()); + } - /** - * Принимает находку модели, если она пересекается с кандидатом и проходит - * те же условия, что и находки правил. - */ - private static boolean isAccepted(String text, int candidateStart, int candidateEnd, Span span) { - // Берём только пересекающееся с кандидатом: контекст добавлен ради - // качества разбора, а не для расширения находки. - if (span.start() >= candidateEnd || candidateStart >= span.end()) { - return false; - } - // Модель с приоритетом 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()); + /** + * Слова-маркеры ПД, которые модель иногда ошибочно помечает как ФИО («ИНН», «СНИЛС», «паспорт»). + * Такие находки — шум: это не имена, а обозначения реквизитов, и маскировать их как ФИО нельзя. + */ + private static final Set PD_MARKERS = + Set.of( + "инн", + "снилс", + "огрн", + "огрнип", + "кпп", + "бик", + "паспорт", + "счёт", + "счет", + "телефон", + "email", + "почта", + "дата", + "адрес", + "полис", + "свидетельство"); + + /** + * Принимает находку модели, если она пересекается с кандидатом и проходит те же условия, что и + * находки правил. + */ + private static boolean isAccepted(String text, int candidateStart, int candidateEnd, Span span) { + // Берём только пересекающееся с кандидатом: контекст добавлен ради + // качества разбора, а не для расширения находки. + if (span.start() >= candidateEnd || candidateStart >= span.end()) { + return false; } - - private static RuBertRecogniser create(String engine, String modelPath, Map types) { - String chosen = engine == null ? "off" : engine.toLowerCase(Locale.ROOT).strip(); - if ("off".equals(chosen) || modelPath == null || modelPath.isBlank()) { - LOG.info("Вторая ступень распознавания выключена"); - return null; - } - 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; + // Модель с приоритетом 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 - void shutdown() { - if (nameRecogniser != null) { - nameRecogniser.close(); - } - if (addressRecogniser != null) { - addressRecogniser.close(); - } - if (legalRecogniser != null) { - legalRecogniser.close(); - } + private static RuBertRecogniser create( + String engine, String modelPath, Map types) { + String chosen = engine == null ? "off" : engine.toLowerCase(Locale.ROOT).strip(); + if ("off".equals(chosen) || modelPath == null || modelPath.isBlank()) { + LOG.info("Вторая ступень распознавания выключена"); + return null; } -} \ No newline at end of file + 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(); + } + } +} diff --git a/src/main/java/ru/pdguard/detect/NameDictionary.java b/src/main/java/ru/pdguard/detect/NameDictionary.java index a96a1d3..07bd1de 100644 --- a/src/main/java/ru/pdguard/detect/NameDictionary.java +++ b/src/main/java/ru/pdguard/detect/NameDictionary.java @@ -1,8 +1,5 @@ package ru.pdguard.detect; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - import java.nio.file.Path; import java.util.Comparator; import java.util.HashSet; @@ -11,274 +8,300 @@ import java.util.Locale; import java.util.Set; import java.util.regex.Pattern; import java.util.stream.Collectors; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; /** * Словари для распознавания ФИО. * - *

Личные имена нужны, чтобы морфология фамилий не срабатывала на чём попало: - * «Тверская» по окончанию похожа на фамилию, но рядом с ней нет личного имени. + *

Личные имена нужны, чтобы морфология фамилий не срабатывала на чём попало: «Тверская» по + * окончанию похожа на фамилию, но рядом с ней нет личного имени. * - *

Список известных людей решает обратную задачу — упоминание Пушкина - * персональными данными не является. Ограничение осознанное: клиент по фамилии - * Пушкин в тексте без других ПД замаскирован не будет. + *

Список известных людей решает обратную задачу — упоминание Пушкина персональными данными не + * является. Ограничение осознанное: клиент по фамилии Пушкин в тексте без других ПД замаскирован не + * будет. * - *

Базовый список собран в сборку из {@code /names/well-known.txt}. Поверх - * него можно дописать своих публичных лиц без пересборки — файл по пути - * {@code pdguard.well-known-file} (по умолчанию {@code config/well-known.txt}) - * перечитывается сам при изменении, тем же приёмом, что {@code systems.json} - * в {@link ru.pdguard.config.SystemsConfig}: раз в секунду сверяется время - * изменения, содержимое читается заново только когда оно другое. + *

Базовый список собран в сборку из {@code /names/well-known.txt}. Поверх него можно дописать + * своих публичных лиц без пересборки — файл по пути {@code pdguard.well-known-file} (по умолчанию + * {@code config/well-known.txt}) перечитывается сам при изменении, тем же приёмом, что {@code + * systems.json} в {@link ru.pdguard.config.SystemsConfig}: раз в секунду сверяется время изменения, + * содержимое читается заново только когда оно другое. */ 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 GIVEN_NAME_STEMS = ResourceLoader.lines("/names/given-names.txt", true).stream() - .map(Declension::withoutInflectedEnding) - .distinct() - .sorted(Comparator.comparingInt(String::length).reversed()) - .toList(); - // Гласная в конце основы отбрасывается: «Набиуллина» родительный/дательный/ - // творительный падежи образует заменой «-а» на «-ой» («Набиуллиной»), а не - // дописыванием — без отсечения «а» их startsWith не поймает. Тот же приём, - // что и для личных имён. - private static final Set BUNDLED_WELL_KNOWN_STEMS = ResourceLoader.set("/names/well-known.txt").stream() - .map(Declension::withoutInflectedEnding) - .collect(Collectors.toUnmodifiableSet()); + private static final List GIVEN_NAME_STEMS = + ResourceLoader.lines("/names/given-names.txt", true).stream() + .map(Declension::withoutInflectedEnding) + .distinct() + .sorted(Comparator.comparingInt(String::length).reversed()) + .toList(); + // Гласная в конце основы отбрасывается: «Набиуллина» родительный/дательный/ + // творительный падежи образует заменой «-а» на «-ой» («Набиуллиной»), а не + // дописыванием — без отсечения «а» их startsWith не поймает. Тот же приём, + // что и для личных имён. + private static final Set BUNDLED_WELL_KNOWN_STEMS = + ResourceLoader.set("/names/well-known.txt").stream() + .map(Declension::withoutInflectedEnding) + .collect(Collectors.toUnmodifiableSet()); - private static final ResourceLoader.FileWatchState> WELL_KNOWN_STATE = - new ResourceLoader.FileWatchState<>(BUNDLED_WELL_KNOWN_STEMS); + private static final ResourceLoader.FileWatchState> WELL_KNOWN_STATE = + 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( - "^\\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 Pattern REGNAL_NAME = + Pattern.compile( + "^\\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 SURNAME_SUFFIXES = Set.of( - "ов", "ев", "ёв", "ин", "ын", "ова", "ева", "ёва", "ина", "ына", - "ович", "евич", "овна", "евна", "овы", "евы", "ины"); + /** Остатки, превращающие основу имени в фамилию или отчество: Роман → Романов. */ + private static final Set SURNAME_SUFFIXES = + Set.of( + "ов", "ев", "ёв", "ин", "ын", "ова", "ева", "ёва", "ина", "ына", "ович", "евич", "овна", + "евна", "овы", "евы", "ины"); - private static final Set GIVEN_NAMES = GIVEN_NAME_STEMS.stream() - .map(stem -> stem.toLowerCase(Locale.ROOT)) - .collect(Collectors.toUnmodifiableSet()); + private static final Set GIVEN_NAMES = + GIVEN_NAME_STEMS.stream() + .map(stem -> stem.toLowerCase(Locale.ROOT)) + .collect(Collectors.toUnmodifiableSet()); - /** - * Слова-маркеры персональных данных и реквизитов, которые по словообразованию - * совпадают с основами имён («ИНН» — основа имени «Инна») и потому ложно - * распознаются как ФИО. Это аббревиатуры, а не имена. - */ - private static final Set PD_MARKERS = Set.of( - "инн", "снилс", "огрн", "огрнип", "кпп", "бик", "паспорт", "счёт", "счет", - "телефон", "email", "почта", "дата", "адрес", "полис", "свидетельство", "ву"); + /** + * Слова-маркеры персональных данных и реквизитов, которые по словообразованию совпадают с + * основами имён («ИНН» — основа имени «Инна») и потому ложно распознаются как ФИО. Это + * аббревиатуры, а не имена. + */ + private static final Set PD_MARKERS = + Set.of( + "инн", + "снилс", + "огрн", + "огрнип", + "кпп", + "бик", + "паспорт", + "счёт", + "счет", + "телефон", + "email", + "почта", + "дата", + "адрес", + "полис", + "свидетельство", + "ву"); - private NameDictionary() { + 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(); - } - - /** - * Задаёт путь к внешнему файлу денилиста. Вызывается при старте приложения - * из конфигурации 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)); + /** + * Есть ли среди слов личное имя из словаря в любом падеже. + * + *

Проверка множеством, а не чередованием в регулярном выражении: сто с лишним веток пришлось + * бы перебирать в каждой позиции текста, здесь же на слово приходится не больше четырёх обращений + * к хеш-таблице. + */ + public static boolean containsGivenName(String value) { + for (String word : value.split(WORD_SPLIT)) { + String lower = word.toLowerCase(Locale.ROOT); + // Точное совпадение с основой сильнее всего: «Яков» оканчивается на «ов», + // но это имя, а не фамилия. + if (GIVEN_NAMES.contains(lower)) { + return true; + } + // По началу слова имя ищется с оглядкой на остаток: «Марина» это основа + // «марин» плюс падежное «а», а «Романов» — основа «роман» плюс фамильное + // «ов». Без этой разницы «Бизнес-центр Романов Двор» принимался бы за + // человека, а «Марина Шевченко» переставала бы им быть. + 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 containsGivenName(String value) { - for (String word : value.split(WORD_SPLIT)) { - String lower = word.toLowerCase(Locale.ROOT); - // Точное совпадение с основой сильнее всего: «Яков» оканчивается на «ов», - // но это имя, а не фамилия. - if (GIVEN_NAMES.contains(lower)) { - return true; + /** + * Проверяет, что фрагмент — имя, отчество или фамилия человека. Используется для строчных имён + * после ролевого слова («клиент иван иванов»), где регистр не подсказывает, что перед нами имя. + */ + 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 (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 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; + } + + /** + * Содержит ли текст упоминание известного человека — из сборки или дописанных сверху. + * + *

Проверяются префиксы слова по множеству, а не каждая основа по слову: при тысяче с лишним + * записей (столько городов в {@link ToponymDictionary}, тот же приём) перебор списка на каждое + * слово текста был бы заметен, а префиксов у слова — не больше, чем в нём букв. + */ + public static boolean isWellKnown(String value) { + if (REGNAL_NAME.matcher(value.strip()).matches()) { + return true; + } + Set 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 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 currentWellKnownStems() { + return ResourceLoader.refreshIfChanged( + EXTERNAL_FILE, + WELL_KNOWN_STATE, + lines -> { + Set merged = new HashSet<>(BUNDLED_WELL_KNOWN_STEMS); + for (String line : lines) { + String trimmed = Declension.withoutInflectedEnding(line); + if (!trimmed.isEmpty()) { + merged.add(trimmed); } - // По началу слова имя ищется с оглядкой на остаток: «Марина» это основа - // «марин» плюс падежное «а», а «Романов» — основа «роман» плюс фамильное - // «ов». Без этой разницы «Бизнес-центр Романов Двор» принимался бы за - // человека, а «Марина Шевченко» переставала бы им быть. - 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 (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 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; - } - - /** - * Содержит ли текст упоминание известного человека — из сборки или дописанных - * сверху. - * - *

Проверяются префиксы слова по множеству, а не каждая основа по слову: - * при тысяче с лишним записей (столько городов в {@link ToponymDictionary}, - * тот же приём) перебор списка на каждое слово текста был бы заметен, а - * префиксов у слова — не больше, чем в нём букв. - */ - public static boolean isWellKnown(String value) { - if (REGNAL_NAME.matcher(value.strip()).matches()) { - return true; - } - Set 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 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 currentWellKnownStems() { - return ResourceLoader.refreshIfChanged(EXTERNAL_FILE, WELL_KNOWN_STATE, - lines -> { - Set 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); - }); - } -} \ No newline at end of file + } + return Set.copyOf(merged); + }); + } +} diff --git a/src/main/java/ru/pdguard/detect/OrganisationDetector.java b/src/main/java/ru/pdguard/detect/OrganisationDetector.java index e2eabe7..acf9f12 100644 --- a/src/main/java/ru/pdguard/detect/OrganisationDetector.java +++ b/src/main/java/ru/pdguard/detect/OrganisationDetector.java @@ -3,33 +3,34 @@ package ru.pdguard.detect; import java.util.regex.Pattern; /** - * Проверка, стоит ли перед именем слово, относящее его к организации или - * объекту на карте. + * Проверка, стоит ли перед именем слово, относящее его к организации или объекту на карте. * - *

«Институт Склифосовского», «Музей Тропинина», «улица Королёва» — это имена - * в названиях, а не персональные данные. Отличие от списка известных людей в том, - * что здесь решает не само имя, а слово перед ним: клиент по фамилии Королёв - * защиту не теряет, а улица Королёва под маску не попадает. + *

«Институт Склифосовского», «Музей Тропинина», «улица Королёва» — это имена в названиях, а не + * персональные данные. Отличие от списка известных людей в том, что здесь решает не само имя, а + * слово перед ним: клиент по фамилии Королёв защиту не теряет, а улица Королёва под маску не + * попадает. */ public final class OrganisationDetector { - /** - * Маркер организации вплотную перед именем. Слово может стоять в любом падеже, - * между ним и именем допускается «имени» или «им.» — «Премия имени Ломоносова». - */ - private static final Pattern ORGANISATION_BEFORE = Pattern.compile( - "(?iu:" + 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 Pattern ORGANISATION_BEFORE = + Pattern.compile( + "(?iu:" + + 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) { - int from = Math.max(0, nameStart - ORGANISATION_LOOKBEHIND); - return ORGANISATION_BEFORE.matcher(text.substring(from, nameStart)).find(); - } -} \ No newline at end of file + public static boolean precededByOrganisation(String text, int nameStart) { + int from = Math.max(0, nameStart - ORGANISATION_LOOKBEHIND); + return ORGANISATION_BEFORE.matcher(text.substring(from, nameStart)).find(); + } +} diff --git a/src/main/java/ru/pdguard/detect/PdTypes.java b/src/main/java/ru/pdguard/detect/PdTypes.java index 88089f0..28a6fbb 100644 --- a/src/main/java/ru/pdguard/detect/PdTypes.java +++ b/src/main/java/ru/pdguard/detect/PdTypes.java @@ -3,56 +3,57 @@ package ru.pdguard.detect; /** * Имена типов персональных данных, которые умеет распознавать сервис. * - *

Вынесены из {@link RuleRegistry} отдельно: константы используются и в - * правилах, и в маскировании ({@link ru.pdguard.mask.Masker}), и в политиках - * ({@link ru.pdguard.config.SystemPolicy}), и в синтетических подстановках - * ({@link ru.pdguard.mask.Synthetic}). Единое место — чтобы имя типа не - * расходилось между слоями. + *

Вынесены из {@link RuleRegistry} отдельно: константы используются и в правилах, и в + * маскировании ({@link ru.pdguard.mask.Masker}), и в политиках ({@link + * ru.pdguard.config.SystemPolicy}), и в синтетических подстановках ({@link + * ru.pdguard.mask.Synthetic}). Единое место — чтобы имя типа не расходилось между слоями. */ public final class PdTypes { - private PdTypes() { - } + private PdTypes() {} - public static final String EMAIL = "EMAIL"; - public static final String PHONE = "PHONE"; - public static final String CARD = "CARD"; - public static final String INN = "INN"; - public static final String SNILS = "SNILS"; - public static final String PASSPORT = "PASSPORT"; - public static final String PASSPORT_ISSUER = "PASSPORT_ISSUER"; - public static final String PASSPORT_DATE = "PASSPORT_DATE"; - public static final String DEPT_CODE = "DEPT_CODE"; - public static final String DRIVER_LICENSE = "DRIVER_LICENSE"; - public static final String CITIZENSHIP = "CITIZENSHIP"; - public static final String BIRTH_PLACE = "BIRTH_PLACE"; - public static final String BIRTH_DATE = "BIRTH_DATE"; - public static final String DATE = "DATE"; - public static final String CVV = "CVV"; - public static final String PIN = "PIN"; - public static final String CARDHOLDER = "CARDHOLDER"; - public static final String ADDRESS_COUNTRY = "ADDRESS_COUNTRY"; - public static final String ADDRESS_POSTCODE = "ADDRESS_POSTCODE"; - public static final String ADDRESS_CITY = "ADDRESS_CITY"; - public static final String ADDRESS_STREET = "ADDRESS_STREET"; - public static final String ADDRESS_HOUSE = "ADDRESS_HOUSE"; - 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 EMAIL = "EMAIL"; + public static final String PHONE = "PHONE"; + public static final String CARD = "CARD"; + public static final String INN = "INN"; + public static final String SNILS = "SNILS"; + public static final String PASSPORT = "PASSPORT"; + public static final String PASSPORT_ISSUER = "PASSPORT_ISSUER"; + public static final String PASSPORT_DATE = "PASSPORT_DATE"; + public static final String DEPT_CODE = "DEPT_CODE"; + public static final String DRIVER_LICENSE = "DRIVER_LICENSE"; + public static final String CITIZENSHIP = "CITIZENSHIP"; + public static final String BIRTH_PLACE = "BIRTH_PLACE"; + public static final String BIRTH_DATE = "BIRTH_DATE"; + public static final String DATE = "DATE"; + public static final String CVV = "CVV"; + public static final String PIN = "PIN"; + public static final String CARDHOLDER = "CARDHOLDER"; + public static final String ADDRESS_COUNTRY = "ADDRESS_COUNTRY"; + public static final String ADDRESS_POSTCODE = "ADDRESS_POSTCODE"; + public static final String ADDRESS_CITY = "ADDRESS_CITY"; + public static final String ADDRESS_STREET = "ADDRESS_STREET"; + public static final String ADDRESS_HOUSE = "ADDRESS_HOUSE"; + public static final String ADDRESS_FLAT = "ADDRESS_FLAT"; - /** Банковские реквизиты сверх платёжной карты. */ - 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"; -} \ No newline at end of file + /** Регион и район размечает только модель второй ступени: правил под них нет. */ + 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 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"; +} diff --git a/src/main/java/ru/pdguard/detect/ResourceLoader.java b/src/main/java/ru/pdguard/detect/ResourceLoader.java index 32af586..9d4700a 100644 --- a/src/main/java/ru/pdguard/detect/ResourceLoader.java +++ b/src/main/java/ru/pdguard/detect/ResourceLoader.java @@ -18,103 +18,103 @@ import org.slf4j.LoggerFactory; /** * Общие приёмы чтения словарей и внешних файлов. * - *

Словари лежат в сборке как ресурсы и читаются одинаково: строки обрезаются, - * пустые и комментарии отбрасываются. Внешние файлы (денилист, настройки систем) - * перечитываются, когда меняется время их изменения, и не чаще раза в секунду — - * чтобы не ходить в файловую систему на каждом запросе. Обе задачи вынесены сюда, - * чтобы не дублировать их в каждом словаре. + *

Словари лежат в сборке как ресурсы и читаются одинаково: строки обрезаются, пустые и + * комментарии отбрасываются. Внешние файлы (денилист, настройки систем) перечитываются, когда + * меняется время их изменения, и не чаще раза в секунду — чтобы не ходить в файловую систему на + * каждом запросе. Обе задачи вынесены сюда, чтобы не дублировать их в каждом словаре. */ 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 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); } + } - /** - * Читает строки ресурса, отбрасывая пустые и комментарии. - * - * @param resource путь к ресурсу в classpath - * @param sortByLength сортировать ли от длинных к коротким (нужно для чередований) - */ - static List 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 set(String resource) { + return lines(resource, false).stream().collect(Collectors.toUnmodifiableSet()); + } + + /** + * Перечитывает внешний файл, когда меняется время его изменения, не чаще раза в секунду. + * Возвращает текущее содержимое; при недоступности файла — прежнее. + * + * @param path путь к файлу + * @param state состояние проверки (время последней проверки и mtime файла) + * @param reader как превратить строки файла в итоговое значение + */ + static T refreshIfChanged( + Path path, FileWatchState state, Function, T> reader) { + long now = System.currentTimeMillis(); + if (now - state.lastCheck < state.recheckMillis) { + return state.current; } - - /** - * Читает строки ресурса в множество, отбрасывая пустые и комментарии. - */ - static java.util.Set set(String resource) { - return lines(resource, false).stream().collect(Collectors.toUnmodifiableSet()); - } - - /** - * Перечитывает внешний файл, когда меняется время его изменения, не чаще раза - * в секунду. Возвращает текущее содержимое; при недоступности файла — прежнее. - * - * @param path путь к файлу - * @param state состояние проверки (время последней проверки и mtime файла) - * @param reader как превратить строки файла в итоговое значение - */ - static T refreshIfChanged(Path path, FileWatchState state, - Function, 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 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); - } + 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 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 { + 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. */ - static final class FileWatchState { - 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); - } + FileWatchState(T initial) { + this(initial, DEFAULT_RECHECK_MILLIS); } -} \ No newline at end of file + } +} diff --git a/src/main/java/ru/pdguard/detect/RuBertRecogniser.java b/src/main/java/ru/pdguard/detect/RuBertRecogniser.java index 41c8802..d9fcbda 100644 --- a/src/main/java/ru/pdguard/detect/RuBertRecogniser.java +++ b/src/main/java/ru/pdguard/detect/RuBertRecogniser.java @@ -6,9 +6,6 @@ import ai.onnxruntime.OrtException; import ai.onnxruntime.OrtSession; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - import java.io.IOException; import java.nio.LongBuffer; import java.nio.file.Files; @@ -19,197 +16,213 @@ import java.util.Iterator; import java.util.List; import java.util.Map; import java.util.Set; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; /** * Распознаватель на BERT: размечает имена и составляющие адреса за один проход. * - *

В отличие от правил, он опознаёт имена без русского словообразования и - * нестандартные топонимы. Метки модели ложатся почти один в один - * на типы из технического задания: имя, отчество, фамилия, страна, регион, район, - * город, улица, дом. + *

В отличие от правил, он опознаёт имена без русского словообразования и нестандартные топонимы. + * Метки модели ложатся почти один в один на типы из технического задания: имя, отчество, фамилия, + * страна, регион, район, город, улица, дом. * - *

Модель тяжёлая — сто семьдесят мегабайт и около двенадцати миллисекунд на - * вызов, — поэтому её зовут только на участках, которые не разобрала первая - * ступень. Одновременных вызовов не больше, чем задано: иначе один запрос с - * десятком кандидатов занял бы все ядра. + *

Модель тяжёлая — сто семьдесят мегабайт и около двенадцати миллисекунд на вызов, — поэтому её + * зовут только на участках, которые не разобрала первая ступень. Одновременных вызовов не больше, + * чем задано: иначе один запрос с десятком кандидатов занял бы все ядра. */ final class RuBertRecogniser { - private static final Logger LOG = LoggerFactory.getLogger(RuBertRecogniser.class); + private static final Logger LOG = LoggerFactory.getLogger(RuBertRecogniser.class); - /** Предел длины входа: участки короткие, до потолка модели в 512 далеко. */ - private static final int MAX_PIECES = 190; + /** Предел длины входа: участки короткие, до потолка модели в 512 далеко. */ + private static final int MAX_PIECES = 190; - private final OrtEnvironment environment; - private final OrtSession session; - private final WordPiece tokenizer; - private final String[] labels; - private final Set inputNames; - private final Map types; + private final OrtEnvironment environment; + private final OrtSession session; + private final WordPiece tokenizer; + private final String[] labels; + private final Set inputNames; + private final Map types; - private RuBertRecogniser(OrtEnvironment environment, OrtSession session, - WordPiece tokenizer, String[] labels, Map types) { - this.environment = environment; - this.session = session; - this.tokenizer = tokenizer; - this.labels = labels; - this.inputNames = session.getInputNames(); - this.types = types; + private RuBertRecogniser( + OrtEnvironment environment, + OrtSession session, + WordPiece tokenizer, + String[] labels, + Map types) { + this.environment = environment; + 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 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; } - - /** - * Загружает модель из каталога с файлами {@code model.onnx}, {@code tokenizer.json} - * и {@code config.json}. Каталог недоступен или испорчен — вернётся {@code null}, - * и сервис продолжит работать на правилах. - * - * @param types соответствие меток модели типам ПД сервиса - */ - static RuBertRecogniser load(Path directory, int threadsPerCall, Map 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 { - OrtEnvironment environment = OrtEnvironment.getEnvironment(); - try (OrtSession.SessionOptions options = new OrtSession.SessionOptions()) { - options.setIntraOpNumThreads(threadsPerCall); - 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; - } + OrtSession session = null; + try { + OrtEnvironment environment = OrtEnvironment.getEnvironment(); + try (OrtSession.SessionOptions options = new OrtSession.SessionOptions()) { + options.setIntraOpNumThreads(threadsPerCall); + 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) { - if (session == null) { - return; - } - try { - session.close(); - } catch (OrtException e) { - LOG.debug("Не удалось закрыть сессию модели при ошибке загрузки", e); - } + private static void closeQuietly(OrtSession session) { + if (session == null) { + return; } - - List recognise(String text, int from, int to, int priority) { - String region = text.substring(from, to); - List pieces = tokenizer.split(region, MAX_PIECES); - 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); - } + try { + session.close(); + } catch (OrtException e) { + LOG.debug("Не удалось закрыть сессию модели при ошибке загрузки", e); } + } - private String[] classify(List 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(); + List recognise(String text, int from, int to, int priority) { + String region = text.substring(from, to); + List pieces = tokenizer.split(region, MAX_PIECES); + 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 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 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++) { - ids[i + 1] = pieces.get(i).id(); - } - ids[length - 1] = tokenizer.separatorId(); - java.util.Arrays.fill(mask, 1L); - - long[] shape = {1, length}; - Map 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); + tags[i] = labels[argmax(logits[0][i + 1])]; } + return tags; + } + } finally { + inputs.values().forEach(OnnxTensor::close); } + } - /** - * Собирает подряд идущие подслова одной сущности в фрагменты исходного текста. - * Схема разметки различает начало, середину, конец и одиночный токен, но для - * сборки достаточно смены типа: границы участков и так проставлены по словам. - */ - private static List toSpans(List pieces, String[] tags, int offset, int priority, - Map types) { - List spans = new ArrayList<>(); - String currentType = null; - int start = 0; - int end = 0; - for (int i = 0; i < tags.length; i++) { - String type = types.get(entityOf(tags[i])); - if (type != null && type.equals(currentType)) { - end = pieces.get(i).end(); - continue; - } - if (currentType != null) { - spans.add(new Span(offset + start, offset + end, currentType, priority)); - } - currentType = type; - start = pieces.get(i).start(); - end = pieces.get(i).end(); - } - if (currentType != null) { - spans.add(new Span(offset + start, offset + end, currentType, priority)); - } - return spans; + /** + * Собирает подряд идущие подслова одной сущности в фрагменты исходного текста. Схема разметки + * различает начало, середину, конец и одиночный токен, но для сборки достаточно смены типа: + * границы участков и так проставлены по словам. + */ + private static List toSpans( + List pieces, + String[] tags, + int offset, + int priority, + Map types) { + List spans = new ArrayList<>(); + String currentType = null; + int start = 0; + int end = 0; + for (int i = 0; i < tags.length; i++) { + String type = types.get(entityOf(tags[i])); + if (type != null && type.equals(currentType)) { + end = pieces.get(i).end(); + continue; + } + if (currentType != null) { + spans.add(new Span(offset + start, offset + end, currentType, priority)); + } + currentType = type; + start = pieces.get(i).start(); + end = pieces.get(i).end(); } + if (currentType != null) { + spans.add(new Span(offset + start, offset + end, currentType, priority)); + } + return spans; + } - private static String entityOf(String tag) { - int dash = tag.indexOf('-'); - return dash < 0 ? tag : tag.substring(dash + 1); - } + private static String entityOf(String tag) { + int dash = tag.indexOf('-'); + return dash < 0 ? tag : tag.substring(dash + 1); + } - private static int argmax(float[] scores) { - int best = 0; - for (int i = 1; i < scores.length; i++) { - if (scores[i] > scores[best]) { - best = i; - } - } - return best; + private static int argmax(float[] scores) { + int best = 0; + for (int i = 1; i < scores.length; i++) { + if (scores[i] > scores[best]) { + best = i; + } } + return best; + } - private static String[] readLabels(Path config) throws IOException { - JsonNode node = new ObjectMapper().readTree(Files.readAllBytes(config)).get("id2label"); - String[] labels = new String[node.size()]; - for (Iterator> it = node.fields(); it.hasNext(); ) { - Map.Entry entry = it.next(); - labels[Integer.parseInt(entry.getKey())] = entry.getValue().asText(); - } - return labels; + private static String[] readLabels(Path config) throws IOException { + JsonNode node = new ObjectMapper().readTree(Files.readAllBytes(config)).get("id2label"); + String[] labels = new String[node.size()]; + for (Iterator> it = node.fields(); it.hasNext(); ) { + Map.Entry entry = it.next(); + labels[Integer.parseInt(entry.getKey())] = entry.getValue().asText(); } + return labels; + } - void close() { - try { - session.close(); - } catch (OrtException e) { - LOG.debug("Не удалось закрыть сессию модели", e); - } + void close() { + try { + session.close(); + } catch (OrtException e) { + LOG.debug("Не удалось закрыть сессию модели", e); } -} \ No newline at end of file + } +} diff --git a/src/main/java/ru/pdguard/detect/Rule.java b/src/main/java/ru/pdguard/detect/Rule.java index 6347e91..e391e5d 100644 --- a/src/main/java/ru/pdguard/detect/Rule.java +++ b/src/main/java/ru/pdguard/detect/Rule.java @@ -7,98 +7,112 @@ import java.util.regex.Pattern; /** * Одно правило детекции персональных данных. * - *

Добавление нового типа ПД — это добавление одного {@code Rule} в - * {@link RuleRegistry}; менять остальной код не требуется. + *

Добавление нового типа ПД — это добавление одного {@code Rule} в {@link RuleRegistry}; менять + * остальной код не требуется. * - * @param type тип ПД, который распознаёт правило - * @param pattern регулярное выражение - * @param priority приоритет при разрешении перекрытий - * @param groups номера групп, которые маскируются; {@code 0} — всё совпадение целиком. - * Несколько групп нужны, когда значение разорвано словами: - * «серия 4509 номер 123456» - * @param validator дополнительная проверка значения (контрольная сумма, диапазон дат); - * {@code null} — проверка не нужна - * @param veto шаблон окружения, при котором совпадение персональными данными не считается: - * адрес отделения банка не является ПД, хотя выглядит как адрес - * @param context шаблон окружения, который обязан присутствовать рядом. Нужен там, - * где форма совпадения сама по себе слишком общая: «Невский проспект» - * это адрес рядом с домом и индексом и просто топоним в рассказе о городе - * @param anchors строчные подстроки, одна из которых обязана встретиться в тексте. - * Проверка через {@code indexOf} на порядок дешевле запуска - * регулярного выражения и отсекает большинство правил на коротком - * запросе. Пустой список — правило запускается всегда + * @param type тип ПД, который распознаёт правило + * @param pattern регулярное выражение + * @param priority приоритет при разрешении перекрытий + * @param groups номера групп, которые маскируются; {@code 0} — всё совпадение целиком. Несколько + * групп нужны, когда значение разорвано словами: «серия 4509 номер 123456» + * @param validator дополнительная проверка значения (контрольная сумма, диапазон дат); {@code null} + * — проверка не нужна + * @param veto шаблон окружения, при котором совпадение персональными данными не считается: адрес + * отделения банка не является ПД, хотя выглядит как адрес + * @param context шаблон окружения, который обязан присутствовать рядом. Нужен там, где форма + * совпадения сама по себе слишком общая: «Невский проспект» это адрес рядом с домом и индексом + * и просто топоним в рассказе о городе + * @param anchors строчные подстроки, одна из которых обязана встретиться в тексте. Проверка через + * {@code indexOf} на порядок дешевле запуска регулярного выражения и отсекает большинство + * правил на коротком запросе. Пустой список — правило запускается всегда */ -public record Rule(String type, Pattern pattern, int priority, List groups, - Predicate validator, Pattern veto, Pattern context, List anchors) { +public record Rule( + String type, + Pattern pattern, + int priority, + List groups, + Predicate validator, + Pattern veto, + Pattern context, + List anchors) { - public Rule { - groups = List.copyOf(groups); - anchors = List.copyOf(anchors); + public Rule { + groups = List.copyOf(groups); + anchors = List.copyOf(anchors); + } + + /** + * Флаги компиляции для всех правил. + * + *

{@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; + + /** + * Сколько символов слева и справа от совпадения просматривает вето-шаблон. + * + *

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 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; } - - /** - * Флаги компиляции для всех правил. - * - *

{@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; - - /** - * Сколько символов слева и справа от совпадения просматривает вето-шаблон. - * - *

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()); + for (String anchor : anchors) { + if (lowercasedText.contains(anchor)) { + return true; + } } + return false; + } - /** Маскировать только перечисленные группы, а не всё совпадение. */ - public Rule groups(Integer... indexes) { - return new Rule(type, pattern, priority, List.of(indexes), validator, veto, context, anchors); - } + /** Принять совпадение, только если рядом встретилось указанное слово. */ + public Rule requiringNear(String regex) { + return new Rule( + type, pattern, priority, groups, validator, veto, Pattern.compile(regex, FLAGS), anchors); + } - /** Принять совпадение, только если значение прошло проверку. */ - public Rule validatedBy(Predicate 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; - } - } - 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); - } + /** Отбросить совпадение, если рядом встретилось указанное слово. */ + public Rule vetoedBy(String regex) { + return new Rule( + type, + pattern, + priority, + groups, + validator, + Pattern.compile(regex, FLAGS), + context, + anchors); + } } diff --git a/src/main/java/ru/pdguard/detect/RulePatterns.java b/src/main/java/ru/pdguard/detect/RulePatterns.java index 808a7c4..6075718 100644 --- a/src/main/java/ru/pdguard/detect/RulePatterns.java +++ b/src/main/java/ru/pdguard/detect/RulePatterns.java @@ -1,133 +1,140 @@ package ru.pdguard.detect; /** - * Общие фрагменты регулярных выражений, переиспользуемые между группами правил - * в {@link RuleRegistry}. Вынесены отдельно, чтобы не дублировать их в каждой - * группе — «серия и номер», разрывы между якорем и значением, формы дат и т.п. - * встречаются в правилах разных категорий (документы, банк, ФИО, адрес). + * Общие фрагменты регулярных выражений, переиспользуемые между группами правил в {@link + * RuleRegistry}. Вынесены отдельно, чтобы не дублировать их в каждой группе — «серия и номер», + * разрывы между якорем и значением, формы дат и т.п. встречаются в правилах разных категорий + * (документы, банк, ФИО, адрес). */ final class RulePatterns { - private RulePatterns() { - } + private RulePatterns() {} - /** - * Слово с заглавной буквы; остальные буквы любого регистра, чтобы - * «ИВАНОВ» распознавался наравне с «Иванов». - */ - static final String CAPITALISED = "\\p{Lu}[\\p{Lu}\\p{Ll}]+"; + /** + * Слово с заглавной буквы; остальные буквы любого регистра, чтобы «ИВАНОВ» распознавался наравне + * с «Иванов». + */ + static final String CAPITALISED = "\\p{Lu}[\\p{Lu}\\p{Ll}]+"; - /** Якорное слово-основа: держатель карты, держателем и т.п. */ - static final String HOLDER_STEM = "держател"; + /** Якорное слово-основа: держатель карты, держателем и т.п. */ + static final String HOLDER_STEM = "держател"; - /** Разрыв между якорем и значением, когда между ними ролевое слово («ИНН плательщика»). */ - static final String ROLE_GAP = "(?:\\s+[\\p{L}-]+){0,5}\\W{0,10}"; + /** Разрыв между якорем и значением, когда между ними ролевое слово («ИНН плательщика»). */ + 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}"; + /** + * То же самое, но только строчные слова-филлеры: ролевые слова перед значением гражданства всегда + * строчные («бенефициара», «поручителя»), а само значение — с заглавной («Республики», + * «Соединенные»). Обычный {@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}"; + /** + * Название улицы: от одного до трёх слов с заглавной буквы либо чисел — «Тверская», «Малая + * Никитская», «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 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}"; + /** + * Отчество: признак надёжный, ни одно другое слово так не оканчивается. Основы даны без падежного + * окончания — Иванович, Ивановича, Ивановне. + */ + 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}"; + /** + * Серия и номер: «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}*"; + /** + * Название месяца: полная форма («январь»), сокращение («янв») и плейсхолдер «ммм» (в логах + * встречается и латинская «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"; + /** Числовая запись при любом порядке частей: дд.мм.гггг, мм/дд/гггг, гггг-мм-дд. */ + 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 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"; + /** «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))?"; + /** «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_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 + ")"; + /** Любая из записей даты; внутри только незахватывающие группы. */ + 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}"; + /** + * Промежуток между якорем даты («дата рождения») и самой датой: слова, скобочные группы («(день и + * месяц)») и знаки препинания. Без скобочной ветки «Дата рождения клиента (день и месяц): 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}"; + /** + * Значение гражданства: «рф»/«росс…»(любая форма, включая строчную «российское»)/ «республики 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+адрес)"; + /** + * Слова, при которых адрес/имя принадлежит организации, а не человеку: адрес отделения банка + * персональными данными не является. + */ + static final String ORGANISATION_NEARBY = + "(?iu:отделени|филиал|банкомат|доп\\.?\\s?офис|офис|головн|юридическ\\p{L}*\\s+адрес)"; } diff --git a/src/main/java/ru/pdguard/detect/RuleRegistry.java b/src/main/java/ru/pdguard/detect/RuleRegistry.java index 1ab8dbf..079185e 100644 --- a/src/main/java/ru/pdguard/detect/RuleRegistry.java +++ b/src/main/java/ru/pdguard/detect/RuleRegistry.java @@ -1,8 +1,5 @@ package ru.pdguard.detect; -import org.springframework.stereotype.Component; -import ru.pdguard.config.SystemPolicy; - import java.util.ArrayList; import java.util.List; import java.util.Locale; @@ -10,205 +7,223 @@ import java.util.Set; import java.util.regex.Matcher; import java.util.regex.Pattern; import java.util.stream.Stream; +import org.springframework.stereotype.Component; +import ru.pdguard.config.SystemPolicy; /** * Реестр правил детекции и сам поиск ПД в тексте. * *

Правила разбиты на три уровня доверия: + * *

    - *
  1. проверяемые контрольной суммой — карта, ИНН, СНИЛС: ложных срабатываний почти нет;
  2. - *
  3. однозначные по формату — email, телефон;
  4. - *
  5. требующие якорного слова — паспорт, водительское удостоверение, CVV, адрес и прочее, - * где сама по себе последовательность знаков ни о чём не говорит.
  6. + *
  7. проверяемые контрольной суммой — карта, ИНН, СНИЛС: ложных срабатываний почти нет; + *
  8. однозначные по формату — email, телефон; + *
  9. требующие якорного слова — паспорт, водительское удостоверение, CVV, адрес и прочее, где + * сама по себе последовательность знаков ни о чём не говорит. *
* - *

Якорные слова распознаются без учёта регистра — флаг {@code (?iu:...)} навешен - * именно на них. На захватываемое значение регистронезависимость не распространяется: - * там, где значение опознаётся по заглавной букве, это существенно. + *

Якорные слова распознаются без учёта регистра — флаг {@code (?iu:...)} навешен именно на них. + * На захватываемое значение регистронезависимость не распространяется: там, где значение опознаётся + * по заглавной букве, это существенно. * - *

Сами правила сгруппированы по категориям в отдельных классах пакета — - * {@link DocumentRules}, {@link FinanceRules}, {@link DateRules}, {@link FioRules}, - * {@link ContactRules}, {@link AddressRules} — чтобы каждая категория читалась - * отдельно от остальных. Здесь их списки только объединяются и используются. + *

Сами правила сгруппированы по категориям в отдельных классах пакета — {@link DocumentRules}, + * {@link FinanceRules}, {@link DateRules}, {@link FioRules}, {@link ContactRules}, {@link + * AddressRules} — чтобы каждая категория читалась отдельно от остальных. Здесь их списки только + * объединяются и используются. */ @Component public class RuleRegistry { - /** - * Слова, при которых адрес принадлежит организации, а не человеку: - * адрес отделения банка персональными данными не является. Части адреса рядом: - * улица, упомянутая в рассказе о городе, адресом клиента не является — ровно - * как адрес отделения банка из технического задания. Требование стояло только - * у постфиксной формы правила, префиксная его не имела. - */ - public static final String ADDRESS_NEARBY = - "(?iu:адрес|индекс|\\bд\\.|\\bдом\\b|\\bкв\\.|\\bг\\.|\\bгород|регистрац|прожива)"; + /** + * Слова, при которых адрес принадлежит организации, а не человеку: адрес отделения банка + * персональными данными не является. Части адреса рядом: улица, упомянутая в рассказе о городе, + * адресом клиента не является — ровно как адрес отделения банка из технического задания. + * Требование стояло только у постфиксной формы правила, префиксная его не имела. + */ + 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 Pattern ADDRESS_CONTEXT = + Pattern.compile(ADDRESS_NEARBY, Pattern.UNICODE_CHARACTER_CLASS | Pattern.UNICODE_CASE); - /** Адресные типы, которые вне адресного окружения персональными данными не являются. */ - private static final Set 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); + /** Адресные типы, которые вне адресного окружения персональными данными не являются. */ + private static final Set 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+). Нормализация находит то, что жёсткие шаблоны - * пропустили из-за нестандартных разделителей, и не должна перебивать находки - * с якорным словом. - */ - private static final int NORMALISED_PRIORITY = 63; + /** + * Приоритет находок нормализации цифровых ПД: выше правила ИНН без якоря (62), ниже якорных + * правил (84+). Нормализация находит то, что жёсткие шаблоны пропустили из-за нестандартных + * разделителей, и не должна перебивать находки с якорным словом. + */ + private static final int NORMALISED_PRIORITY = 63; - /** - * Цифровой кластер: от 10 до 19 цифр с произвольными разделителями между ними - * (пробел, дефис, точка, слэш, скобки). Негативные просмотры не дают захватить - * часть более длинного числа. Разделители вычищаются, и чистая цифровая строка - * прогоняется через контрольную сумму — так находятся ИНН/СНИЛС/карта/ОГРН(ИП) - * в свободной форме, где жёсткий шаблон ломается на нестандартном разделителе. - */ - private static final Pattern DIGIT_CLUSTER = Pattern.compile( - "(? RULES = + Stream.of( + DocumentRules.RULES, + FinanceRules.RULES, + DateRules.RULES, + FioRules.RULES, + ContactRules.RULES, + AddressRules.RULES) + .flatMap(List::stream) + .toList(); + + /** Все типы ПД, которые умеет распознавать сервис. */ + public List knownTypes() { + return RULES.stream().map(Rule::type).distinct().toList(); + } + + /** + * Находит все фрагменты ПД, разрешённые политикой системы. Перекрытия здесь не разрешаются — это + * делает вызывающая сторона. + */ + public List detect(String text, SystemPolicy policy) { + List found = new ArrayList<>(); + String lowercased = text.toLowerCase(Locale.ROOT); + for (Rule rule : RULES) { + if (!policy.allows(rule.type()) || !rule.mayMatch(lowercased)) { + continue; + } + collect(rule, text, found); } + collectNormalisedDigits(text, policy, found); + return found; + } - /** - * Есть ли рядом другие части адреса. Правила проверяют это сами, а находкам - * второй ступени проверку нужно навязать снаружи: модель размечает «Москву» в - * названии клуба и «Вернадского» в названии проспекта наравне с настоящим адресом. - */ - public static boolean hasAddressContext(String text, int start, int end) { - return ADDRESS_CONTEXT.matcher(surroundings(text, start, end)).find(); + /** + * Ищет цифровые ПД в свободной форме: последовательности цифр с произвольными разделителями, + * которые жёсткие шаблоны правил пропустили. Разделители вычищаются, и чистая строка проверяется + * контрольной суммой — ложные срабатывания отсекаются так же, как и в правилах. + */ + private static void collectNormalisedDigits(String text, SystemPolicy policy, List sink) { + if (!policy.allows(PdTypes.CARD) + && !policy.allows(PdTypes.INN) + && !policy.allows(PdTypes.SNILS) + && !policy.allows(PdTypes.OGRN) + && !policy.allows(PdTypes.OGRNIP)) { + return; } - - private static final List RULES = Stream.of( - DocumentRules.RULES, FinanceRules.RULES, DateRules.RULES, - FioRules.RULES, ContactRules.RULES, AddressRules.RULES) - .flatMap(List::stream) - .toList(); - - /** Все типы ПД, которые умеет распознавать сервис. */ - public List knownTypes() { - return RULES.stream().map(Rule::type).distinct().toList(); + Matcher m = DIGIT_CLUSTER.matcher(text); + while (m.find()) { + String digits = NON_DIGIT.matcher(m.group()).replaceAll(""); + String type = typeFor(digits); + if (type != null && policy.allows(type)) { + sink.add(new Span(m.start(), m.end(), type, NORMALISED_PRIORITY)); + } } + } - /** - * Находит все фрагменты ПД, разрешённые политикой системы. - * Перекрытия здесь не разрешаются — это делает вызывающая сторона. - */ - public List detect(String text, SystemPolicy policy) { - List found = new ArrayList<>(); - String lowercased = text.toLowerCase(Locale.ROOT); - for (Rule rule : RULES) { - if (!policy.allows(rule.type()) || !rule.mayMatch(lowercased)) { - continue; - } - collect(rule, text, found); + /** + * Определяет тип ПД по чистой цифровой строке и контрольной сумме. Для 13 и 15 цифр сначала + * пробуются ОГРН/ОГРНИП: они специфичнее карты по длине, и валидный ОГРН не должен случайно стать + * номером карты (карта самостоятельна, ОГРН — только спутник, и одинокий ОГРН убирается в {@code + * Pipeline}). + */ + private static String typeFor(String digits) { + int length = digits.length(); + switch (length) { + case 10, 12: + return Validators.inn(digits) ? PdTypes.INN : null; + 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 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())); } - collectNormalisedDigits(text, policy, found); - return found; + } } + } - /** - * Ищет цифровые ПД в свободной форме: последовательности цифр с произвольными - * разделителями, которые жёсткие шаблоны правил пропустили. Разделители - * вычищаются, и чистая строка проверяется контрольной суммой — ложные - * срабатывания отсекаются так же, как и в правилах. - */ - private static void collectNormalisedDigits(String text, SystemPolicy policy, List sink) { - 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); - while (m.find()) { - String digits = NON_DIGIT.matcher(m.group()).replaceAll(""); - String type = typeFor(digits); - if (type != null && policy.allows(type)) { - sink.add(new Span(m.start(), m.end(), type, NORMALISED_PRIORITY)); - } - } + /** + * Проверяет, что фрагмент группы проходит все условия правила: границы, валидатор, veto и + * контекст. + */ + private static boolean isValidGroup(Rule rule, String text, int start, int end) { + if (start < 0 || end <= start) { + return false; } + 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(); + } - /** - * Определяет тип ПД по чистой цифровой строке и контрольной сумме. Для 13 и 15 - * цифр сначала пробуются ОГРН/ОГРНИП: они специфичнее карты по длине, и валидный - * ОГРН не должен случайно стать номером карты (карта самостоятельна, ОГРН — только - * спутник, и одинокий ОГРН убирается в {@code Pipeline}). - */ - private static String typeFor(String digits) { - int length = digits.length(); - switch (length) { - case 10, 12: - return Validators.inn(digits) ? PdTypes.INN : null; - 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 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())); - } - } - } - } - - /** Проверяет, что фрагмент группы проходит все условия правила: границы, валидатор, veto и контекст. */ - private static boolean isValidGroup(Rule rule, String text, int start, int end) { - if (start < 0 || end <= start) { - return false; - } - 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); - } + 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); + } } diff --git a/src/main/java/ru/pdguard/detect/Span.java b/src/main/java/ru/pdguard/detect/Span.java index bb6a775..21c17b5 100644 --- a/src/main/java/ru/pdguard/detect/Span.java +++ b/src/main/java/ru/pdguard/detect/Span.java @@ -3,24 +3,24 @@ package ru.pdguard.detect; /** * Найденный фрагмент персональных данных в исходном тексте. * - * @param start индекс первого символа (включительно) - * @param end индекс за последним символом (исключительно) - * @param type тип ПД, например {@code CARD} или {@code EMAIL} + * @param start индекс первого символа (включительно) + * @param end индекс за последним символом (исключительно) + * @param type тип ПД, например {@code CARD} или {@code EMAIL} * @param priority приоритет при разрешении перекрытий: больше — важнее */ public record Span(int start, int end, String type, int priority) { - public Span { - if (start < 0 || end <= start) { - throw new IllegalArgumentException("Некорректные границы фрагмента: " + start + ".." + end); - } + public Span { + if (start < 0 || end <= start) { + throw new IllegalArgumentException("Некорректные границы фрагмента: " + start + ".." + end); } + } - public int length() { - return end - start; - } + public int length() { + return end - start; + } - public boolean overlaps(Span other) { - return start < other.end && other.start < end; - } + public boolean overlaps(Span other) { + return start < other.end && other.start < end; + } } diff --git a/src/main/java/ru/pdguard/detect/ToponymDictionary.java b/src/main/java/ru/pdguard/detect/ToponymDictionary.java index 1bdb8f4..ed46d94 100644 --- a/src/main/java/ru/pdguard/detect/ToponymDictionary.java +++ b/src/main/java/ru/pdguard/detect/ToponymDictionary.java @@ -4,51 +4,47 @@ import java.util.Locale; import java.util.Set; /** - * Словарь населённых пунктов России — проверка того, что значение, пойманное - * правилом {@code ADDRESS_CITY}, действительно похоже на существующий город, - * село, посёлок или другой населённый пункт, а не на произвольное слово с - * заглавной буквы после якоря. + * Словарь населённых пунктов России — проверка того, что значение, пойманное правилом {@code + * ADDRESS_CITY}, действительно похоже на существующий город, село, посёлок или другой населённый + * пункт, а не на произвольное слово с заглавной буквы после якоря. * - *

Не только официальные города (~1100 по классификатору): перепись - * добавляет сёла, деревни, хутора, станицы — «рп. Ильинское», «с. Кукуево» - * из ТЗ находятся ровно за счёт неё. Какой конкретно тип населённого пункта - * стоит перед названием, определяет якорь самого правила в {@link RuleRegistry}, - * а не этот словарь — он только подтверждает, что название реальное. + *

Не только официальные города (~1100 по классификатору): перепись добавляет сёла, деревни, + * хутора, станицы — «рп. Ильинское», «с. Кукуево» из ТЗ находятся ровно за счёт неё. Какой + * конкретно тип населённого пункта стоит перед названием, определяет якорь самого правила в {@link + * RuleRegistry}, а не этот словарь — он только подтверждает, что название реальное. * - *

Сравнение по началу слова, а не точным совпадением: падежные окончания - * («в Москве», «из Казани») тем самым покрываются без отдельного разбора - * морфологии, как и у известных людей в {@link NameDictionary}. + *

Сравнение по началу слова, а не точным совпадением: падежные окончания («в Москве», «из + * Казани») тем самым покрываются без отдельного разбора морфологии, как и у известных людей в + * {@link NameDictionary}. */ public final class ToponymDictionary { - private static final Set SETTLEMENT_STEMS = ResourceLoader.set("/names/settlements.txt").stream() - .map(Declension::withoutInflectedEnding) - .collect(java.util.stream.Collectors.toUnmodifiableSet()); + private static final Set SETTLEMENT_STEMS = + ResourceLoader.set("/names/settlements.txt").stream() + .map(Declension::withoutInflectedEnding) + .collect(java.util.stream.Collectors.toUnmodifiableSet()); - private ToponymDictionary() { - } + private ToponymDictionary() {} - /** - * Похоже ли значение на название населённого пункта из словаря в любом - * падеже. - * - *

Названия на согласную склоняются добавлением окончания («Тамбов» → - * «Тамбове»), поэтому начало слова из словаря — уже достаточный признак. - * Названия на гласную меняют последнюю букву («Москва» → «Москве»), для - * них сравнение идёт по основе без неё — так же, как с личными именами - * в {@link NameDictionary}. - * - *

Проверяются префиксы значения по множеству, а не каждая из ~80 000 - * основ по значению: перебор списка на каждое совпадение правила был бы - * на порядки дороже, чем нужно — префиксов у слова не больше, чем в нём букв. - */ - public static boolean isKnownSettlement(String value) { - String lower = value.strip().toLowerCase(Locale.ROOT); - for (int length = lower.length(); length > 0; length--) { - if (SETTLEMENT_STEMS.contains(lower.substring(0, length))) { - return true; - } - } - return false; + /** + * Похоже ли значение на название населённого пункта из словаря в любом падеже. + * + *

Названия на согласную склоняются добавлением окончания («Тамбов» → «Тамбове»), поэтому + * начало слова из словаря — уже достаточный признак. Названия на гласную меняют последнюю букву + * («Москва» → «Москве»), для них сравнение идёт по основе без неё — так же, как с личными именами + * в {@link NameDictionary}. + * + *

Проверяются префиксы значения по множеству, а не каждая из ~80 000 основ по значению: + * перебор списка на каждое совпадение правила был бы на порядки дороже, чем нужно — префиксов у + * слова не больше, чем в нём букв. + */ + public static boolean isKnownSettlement(String value) { + String lower = value.strip().toLowerCase(Locale.ROOT); + for (int length = lower.length(); length > 0; length--) { + if (SETTLEMENT_STEMS.contains(lower.substring(0, length))) { + return true; + } } -} \ No newline at end of file + return false; + } +} diff --git a/src/main/java/ru/pdguard/detect/Validators.java b/src/main/java/ru/pdguard/detect/Validators.java index cb1e705..d1e1a7d 100644 --- a/src/main/java/ru/pdguard/detect/Validators.java +++ b/src/main/java/ru/pdguard/detect/Validators.java @@ -1,193 +1,191 @@ package ru.pdguard.detect; /** - * Проверки контрольных сумм. Отсекают случайные числовые последовательности, - * которые по форме похожи на ПД, но ими не являются. + * Проверки контрольных сумм. Отсекают случайные числовые последовательности, которые по форме + * похожи на ПД, но ими не являются. */ public final class Validators { - 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_B = {3, 7, 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_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; - 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; + /** + * Контрольная цифра Луна для последовательности цифр: дописывается к телу номера, чтобы весь + * номер прошёл проверку {@link #luhn}. Используется при генерации правдоподобных подставных + * номеров карт. + */ + 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; } - return digits >= 13 && digits <= 19 && sum % 10 == 0; + } + sum += d; + doubled = !doubled; } + return (10 - sum % 10) % 10; + } - /** - * Контрольная цифра Луна для последовательности цифр: дописывается к телу - * номера, чтобы весь номер прошёл проверку {@link #luhn}. Используется при - * генерации правдоподобных подставных номеров карт. - */ - 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; + /** Контрольная сумма ИНН: 10 знаков у юрлица, 12 у физлица. */ + public static boolean inn(String value) { + int[] d = digits(value); + if (d.length == 10) { + return d[9] == checksum(d, INN_10); } + if (d.length == 12) { + return d[10] == checksum(d, INN_12_A) && d[11] == checksum(d, INN_12_B); + } + return false; + } - /** Контрольная сумма ИНН: 10 знаков у юрлица, 12 у физлица. */ - public static boolean inn(String value) { - int[] d = digits(value); - if (d.length == 10) { - return d[9] == checksum(d, INN_10); - } - if (d.length == 12) { - return d[10] == checksum(d, INN_12_A) && d[11] == checksum(d, INN_12_B); - } + /** Контрольная сумма СНИЛС: 11 знаков, последние два — контрольные. */ + public static boolean snils(String value) { + int[] d = digits(value); + 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 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; + } + 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) { - int[] d = digits(value); - 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 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 snilsControl(int sum) { - if (sum < 100) { - return sum; - } - if (sum == 100 || sum == 101) { - return 0; - } - return sum % 101 % 100; + 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; + } - /** Контрольная сумма ОГРН: первые 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; - } - 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; + 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; + } } diff --git a/src/main/java/ru/pdguard/detect/WordPiece.java b/src/main/java/ru/pdguard/detect/WordPiece.java index 6074e88..0303280 100644 --- a/src/main/java/ru/pdguard/detect/WordPiece.java +++ b/src/main/java/ru/pdguard/detect/WordPiece.java @@ -2,7 +2,6 @@ package ru.pdguard.detect; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; - import java.io.BufferedReader; import java.io.IOException; import java.io.InputStreamReader; @@ -18,163 +17,162 @@ import java.util.Map; /** * Разбиение текста на подслова так, как это делает токенизатор BERT. * - *

Своя реализация вместо готовой библиотеки: единственная альтернатива на Java - * подтягивает нативные библиотеки во время работы, а контейнер должен подниматься - * без обращений в сеть. Правила здесь простые и целиком описаны форматом словаря: - * разбить по пробелам и знакам препинания, затем каждое слово — жадно по самой - * длинной подходящей записи словаря, продолжения помечаются префиксом «##». + *

Своя реализация вместо готовой библиотеки: единственная альтернатива на Java подтягивает + * нативные библиотеки во время работы, а контейнер должен подниматься без обращений в сеть. Правила + * здесь простые и целиком описаны форматом словаря: разбить по пробелам и знакам препинания, затем + * каждое слово — жадно по самой длинной подходящей записи словаря, продолжения помечаются префиксом + * «##». * - *

Для каждого подслова сохраняются границы в исходном тексте: без них разметку - * модели не перенести обратно на строку. + *

Для каждого подслова сохраняются границы в исходном тексте: без них разметку модели не + * перенести обратно на строку. */ final class WordPiece { - /** Слово длиннее этого целиком заменяется на «неизвестно» — правило BERT. */ - private static final int MAX_WORD_CHARS = 100; + /** Слово длиннее этого целиком заменяется на «неизвестно» — правило BERT. */ + 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 vocabulary; + private final int unknownId; + private final int classifyId; + private final int separatorId; + + private WordPiece(Map 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 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 vocabulary; - private final int unknownId; - private final int classifyId; - private final int separatorId; - - private WordPiece(Map vocabulary) { - this.vocabulary = vocabulary; - this.unknownId = required(vocabulary, "[UNK]"); - this.classifyId = required(vocabulary, "[CLS]"); - this.separatorId = required(vocabulary, "[SEP]"); + /** + * Читает словарь из {@code tokenizer.json} Hugging Face. Некоторые модели (например, WikiNEuRal) + * не кладут отдельный {@code vocab.txt}, а хранят словарь внутри токенизатора. + */ + static WordPiece fromTokenizerJson(Path tokenizerFile) throws IOException { + JsonNode root = new ObjectMapper().readTree(Files.readAllBytes(tokenizerFile)); + JsonNode vocab = root.path("model").path("vocab"); + Map vocabulary = HashMap.newHashMap(vocab.size()); + Iterator> fields = vocab.fields(); + while (fields.hasNext()) { + Map.Entry entry = fields.next(); + vocabulary.putIfAbsent(entry.getKey(), entry.getValue().asInt()); } + return new WordPiece(vocabulary); + } - static WordPiece fromVocabulary(Path vocabularyFile) throws IOException { - Map 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); + int classifyId() { + return classifyId; + } + + int separatorId() { + return separatorId; + } + + /** Подслова текста в порядке следования; служебные токены сюда не входят. */ + List split(String text, int maxPieces) { + List 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}, а хранят - * словарь внутри токенизатора. - */ - static WordPiece fromTokenizerJson(Path tokenizerFile) throws IOException { - JsonNode root = new ObjectMapper().readTree(Files.readAllBytes(tokenizerFile)); - JsonNode vocab = root.path("model").path("vocab"); - Map vocabulary = HashMap.newHashMap(vocab.size()); - Iterator> fields = vocab.fields(); - while (fields.hasNext()) { - Map.Entry entry = fields.next(); - vocabulary.putIfAbsent(entry.getKey(), entry.getValue().asInt()); - } - return new WordPiece(vocabulary); - } - - int classifyId() { - return classifyId; - } - - int separatorId() { - return separatorId; - } - - /** Подслова текста в порядке следования; служебные токены сюда не входят. */ - List split(String text, int maxPieces) { - List 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 words(String text) { - List 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; - } - } + /** + * Границы слов: разделителями считаются пробельные символы и знаки препинания, причём знак + * препинания сам становится отдельным словом. + */ + private static List words(String text) { + List 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, 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) { - if (Character.isLetterOrDigit(c)) { - return false; - } - return !Character.isWhitespace(c); + private static boolean isPunctuation(char c) { + if (Character.isLetterOrDigit(c)) { + return false; } + return !Character.isWhitespace(c); + } - private void splitWord(String text, int from, int to, List sink, int maxPieces) { - if (to - from > MAX_WORD_CHARS) { - sink.add(new Piece(unknownId, from, to)); - return; - } - int cursor = from; - List 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 void splitWord(String text, int from, int to, List sink, int maxPieces) { + if (to - from > MAX_WORD_CHARS) { + sink.add(new Piece(unknownId, from, to)); + return; } + int cursor = from; + List 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 vocabulary, String token) { - Integer id = vocabulary.get(token); - if (id == null) { - throw new IllegalStateException("В словаре нет служебного токена " + token); - } - return id; + private static int required(Map vocabulary, String token) { + Integer id = vocabulary.get(token); + if (id == null) { + throw new IllegalStateException("В словаре нет служебного токена " + token); } + return id; + } } diff --git a/src/main/java/ru/pdguard/mask/MaskContext.java b/src/main/java/ru/pdguard/mask/MaskContext.java index b21e59f..0286472 100644 --- a/src/main/java/ru/pdguard/mask/MaskContext.java +++ b/src/main/java/ru/pdguard/mask/MaskContext.java @@ -8,39 +8,40 @@ import java.util.function.BiFunction; /** * Состояние одной операции маскирования. * - *

Одинаковые значения в пределах запроса получают одинаковую замену: если - * клиент упомянут дважды, в тексте дважды окажется {@code [FIO_1]}, и смысл - * запроса для модели сохранится. + *

Одинаковые значения в пределах запроса получают одинаковую замену: если клиент упомянут + * дважды, в тексте дважды окажется {@code [FIO_1]}, и смысл запроса для модели сохранится. * *

Экземпляр живёт в рамках одного вызова и между потоками не разделяется. */ public final class MaskContext { - /** Разделитель ключа; в названии типа ПД этот знак не встречается. */ - private static final char SEPARATOR = '#'; + /** Разделитель ключа; в названии типа ПД этот знак не встречается. */ + private static final char SEPARATOR = '#'; - private final Map assigned = new HashMap<>(); - private final Map counters = new HashMap<>(); - private final Map restorations = new LinkedHashMap<>(); + private final Map assigned = new HashMap<>(); + private final Map counters = new HashMap<>(); + private final Map restorations = new LinkedHashMap<>(); - /** - * Замена для значения; при повторе возвращается ранее выданная. - * - * @param factory получает тип ПД и порядковый номер значения этого типа - */ - public String resolve(String type, String value, BiFunction factory) { - return assigned.computeIfAbsent(type + SEPARATOR + value, key -> { - String replacement = factory.apply(type, counters.merge(type, 1, Integer::sum)); - restorations.put(replacement, value); - return replacement; + /** + * Замена для значения; при повторе возвращается ранее выданная. + * + * @param factory получает тип ПД и порядковый номер значения этого типа + */ + public String resolve(String type, String value, BiFunction factory) { + return assigned.computeIfAbsent( + type + SEPARATOR + value, + key -> { + String replacement = factory.apply(type, counters.merge(type, 1, Integer::sum)); + restorations.put(replacement, value); + return replacement; }); - } + } - /** - * Чем заменять обратно: подстановка к исходному значению. Нужно там, где текст - * возвращается не целиком, а изменённым — например, в ответе языковой модели. - */ - public Map restorations() { - return Map.copyOf(restorations); - } + /** + * Чем заменять обратно: подстановка к исходному значению. Нужно там, где текст возвращается не + * целиком, а изменённым — например, в ответе языковой модели. + */ + public Map restorations() { + return Map.copyOf(restorations); + } } diff --git a/src/main/java/ru/pdguard/mask/MaskMode.java b/src/main/java/ru/pdguard/mask/MaskMode.java index c05a603..d919c63 100644 --- a/src/main/java/ru/pdguard/mask/MaskMode.java +++ b/src/main/java/ru/pdguard/mask/MaskMode.java @@ -3,21 +3,20 @@ package ru.pdguard.mask; /** Чем заменяется найденное значение. Выбирается настройками системы-потребителя. */ public enum MaskMode { - /** Звёздочки с сохранением длины и разделителей: {@code 45** ****56}. */ - MASK, + /** Звёздочки с сохранением длины и разделителей: {@code 45** ****56}. */ + MASK, - /** - * Звёздочки без исключений: каждый тип закрывается целиком, даже те, что в - * {@link #MASK} частично открыты (края номера) или превращаются в инициалы - * (ФИО {@code Иванов Иван Иванович} → {@code ******* **** *********}, не - * {@code И. И. И.} — инициалы всё ещё выдают число слов и первую букву - * каждого). - */ - STRICT, + /** + * Звёздочки без исключений: каждый тип закрывается целиком, даже те, что в {@link #MASK} частично + * открыты (края номера) или превращаются в инициалы (ФИО {@code Иванов Иван Иванович} → {@code + * ******* **** *********}, не {@code И. И. И.} — инициалы всё ещё выдают число слов и первую + * букву каждого). + */ + STRICT, - /** Порядковый токен: {@code [FIO_1]}. Компактно и однозначно обратимо. */ - TOKEN, + /** Порядковый токен: {@code [FIO_1]}. Компактно и однозначно обратимо. */ + TOKEN, - /** Правдоподобная подстановка: вместо настоящего имени — вымышленное. */ - SYNTHETIC + /** Правдоподобная подстановка: вместо настоящего имени — вымышленное. */ + SYNTHETIC } diff --git a/src/main/java/ru/pdguard/mask/Masker.java b/src/main/java/ru/pdguard/mask/Masker.java index 83b93d2..df4a5ab 100644 --- a/src/main/java/ru/pdguard/mask/Masker.java +++ b/src/main/java/ru/pdguard/mask/Masker.java @@ -1,85 +1,81 @@ package ru.pdguard.mask; -import org.springframework.stereotype.Component; -import ru.pdguard.detect.PdTypes; - import java.util.Map; import java.util.function.UnaryOperator; +import org.springframework.stereotype.Component; +import ru.pdguard.detect.PdTypes; /** * Превращает найденное значение в замену согласно настройкам системы. * - *

Тип, для которого вид маски не задан, скрывается звёздочками целиком — - * безопасное поведение по умолчанию для вновь добавленных правил. + *

Тип, для которого вид маски не задан, скрывается звёздочками целиком — безопасное поведение по + * умолчанию для вновь добавленных правил. */ @Component public class Masker { - private static final UnaryOperator EDGES = v -> Strategies.keepEdges(v, 2, 2); - private static final UnaryOperator SHORT_SERIES = v -> Strategies.keepEdges(v, 0, 2); + private static final UnaryOperator EDGES = v -> Strategies.keepEdges(v, 2, 2); + private static final UnaryOperator SHORT_SERIES = v -> Strategies.keepEdges(v, 0, 2); - private static final Map> BY_TYPE = Map.ofEntries( - Map.entry(PdTypes.EMAIL, Strategies::email), - Map.entry(PdTypes.PHONE, EDGES), - Map.entry(PdTypes.CARD, EDGES), - Map.entry(PdTypes.INN, EDGES), - Map.entry(PdTypes.SNILS, EDGES), - Map.entry(PdTypes.PASSPORT, 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.BIRTH_CERTIFICATE, SHORT_SERIES), - Map.entry(PdTypes.MEDICAL_POLICY, EDGES), - Map.entry(PdTypes.CARDHOLDER, Strategies::initials), - Map.entry(PdTypes.FIO, Strategies::initials), + private static final Map> BY_TYPE = + Map.ofEntries( + Map.entry(PdTypes.EMAIL, Strategies::email), + Map.entry(PdTypes.PHONE, EDGES), + Map.entry(PdTypes.CARD, EDGES), + Map.entry(PdTypes.INN, EDGES), + Map.entry(PdTypes.SNILS, EDGES), + Map.entry(PdTypes.PASSPORT, 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.BIRTH_CERTIFICATE, SHORT_SERIES), + Map.entry(PdTypes.MEDICAL_POLICY, EDGES), + Map.entry(PdTypes.CARDHOLDER, Strategies::initials), + Map.entry(PdTypes.FIO, Strategies::initials), - // Код проверки и пин-код не показываем даже частично: у них слишком - // мало знаков, чтобы открывать хотя бы один. - Map.entry(PdTypes.CVV, Strategies::stars), - Map.entry(PdTypes.PIN, Strategies::stars), + // Код проверки и пин-код не показываем даже частично: у них слишком + // мало знаков, чтобы открывать хотя бы один. + Map.entry(PdTypes.CVV, 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)); - // У дат сохраняем разделители: модель видит, что это дата, но не какая. - 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) { - return switch (mode) { - case MASK -> BY_TYPE.getOrDefault(type, Strategies::stars).apply(value); - // STRICT игнорирует BY_TYPE целиком — ни один тип не открывает края - // и ФИО не превращается в инициалы, только сплошные звёздочки. - case STRICT -> Strategies.stars(value); - case TOKEN -> context.resolve(type, value, (t, n) -> "[" + t + "_" + n + "]"); - case SYNTHETIC -> context.resolve(type, value, (t, n) -> Synthetic.forType(t, value, n)); - }; - } + public String mask(String type, String value, MaskMode mode, MaskContext context) { + return switch (mode) { + case MASK -> BY_TYPE.getOrDefault(type, Strategies::stars).apply(value); + // STRICT игнорирует BY_TYPE целиком — ни один тип не открывает края + // и ФИО не превращается в инициалы, только сплошные звёздочки. + case STRICT -> Strategies.stars(value); + case TOKEN -> context.resolve(type, value, (t, n) -> "[" + t + "_" + n + "]"); + case SYNTHETIC -> context.resolve(type, value, (t, n) -> Synthetic.forType(t, value, n)); + }; + } } diff --git a/src/main/java/ru/pdguard/mask/Strategies.java b/src/main/java/ru/pdguard/mask/Strategies.java index 4c69594..60fcd85 100644 --- a/src/main/java/ru/pdguard/mask/Strategies.java +++ b/src/main/java/ru/pdguard/mask/Strategies.java @@ -3,113 +3,110 @@ package ru.pdguard.mask; /** * Способы преобразования найденного значения в маску. * - *

Все стратегии сохраняют длину и разделители исходного значения: так - * замаскированный текст остаётся читаемым для LLM и минимально отличается - * от эталона при посимвольном сравнении. + *

Все стратегии сохраняют длину и разделители исходного значения: так замаскированный текст + * остаётся читаемым для LLM и минимально отличается от эталона при посимвольном сравнении. */ public final class Strategies { - private static final char MASK = '*'; + private static final char MASK = '*'; - private Strategies() { - } + private Strategies() {} - /** Каждый непробельный символ заменяется на «*». */ - public static String stars(String value) { - StringBuilder sb = new StringBuilder(value.length()); - for (int i = 0; i < value.length(); i++) { - char c = value.charAt(i); - sb.append(Character.isWhitespace(c) ? c : MASK); - } - return sb.toString(); + /** Каждый непробельный символ заменяется на «*». */ + public static String stars(String value) { + StringBuilder sb = new StringBuilder(value.length()); + for (int i = 0; i < value.length(); i++) { + char c = value.charAt(i); + sb.append(Character.isWhitespace(c) ? c : MASK); } + return sb.toString(); + } - /** - * Скрывает буквы и цифры, оставляя разделители: {@code 12.05.1985} → {@code **.**.****}, - * {@code 12 мая 1985} → {@code ** *** ****}. Форма записи остаётся видна модели, - * само значение — нет. - */ - public static String starsKeepingPunctuation(String value) { - StringBuilder sb = new StringBuilder(value.length()); - for (int i = 0; i < value.length(); i++) { - char c = value.charAt(i); - sb.append(Character.isLetterOrDigit(c) ? MASK : c); - } - return sb.toString(); + /** + * Скрывает буквы и цифры, оставляя разделители: {@code 12.05.1985} → {@code **.**.****}, {@code + * 12 мая 1985} → {@code ** *** ****}. Форма записи остаётся видна модели, само значение — нет. + */ + public static String starsKeepingPunctuation(String value) { + StringBuilder sb = new StringBuilder(value.length()); + for (int i = 0; i < value.length(); i++) { + char c = value.charAt(i); + sb.append(Character.isLetterOrDigit(c) ? MASK : c); } + return sb.toString(); + } - /** - * Оставляет первые и последние значащие символы, остальные скрывает, - * разделители сохраняет: {@code 4509 123456} → {@code 45** ****56}. - */ - public static String keepEdges(String value, int head, int tail) { - int significant = 0; - for (int i = 0; i < value.length(); i++) { - if (Character.isLetterOrDigit(value.charAt(i))) { - 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(); + /** + * Оставляет первые и последние значащие символы, остальные скрывает, разделители сохраняет: + * {@code 4509 123456} → {@code 45** ****56}. + */ + public static String keepEdges(String value, int head, int tail) { + int significant = 0; + for (int i = 0; i < value.length(); i++) { + if (Character.isLetterOrDigit(value.charAt(i))) { + 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(); + } - /** ФИО превращается в инициалы: {@code Иванов Иван Иванович} → {@code И. И. И.} */ - public static String initials(String value) { - StringBuilder sb = new StringBuilder(); - boolean wordStart = true; - for (int i = 0; i < value.length(); i++) { - char c = value.charAt(i); - if (Character.isLetter(c)) { - if (wordStart) { - if (!sb.isEmpty()) { - sb.append(' '); - } - sb.append(Character.toUpperCase(c)).append('.'); - wordStart = false; - } - } else { - wordStart = true; - } + /** ФИО превращается в инициалы: {@code Иванов Иван Иванович} → {@code И. И. И.} */ + public static String initials(String value) { + StringBuilder sb = new StringBuilder(); + boolean wordStart = true; + for (int i = 0; i < value.length(); i++) { + char c = value.charAt(i); + if (Character.isLetter(c)) { + if (wordStart) { + if (!sb.isEmpty()) { + sb.append(' '); + } + sb.append(Character.toUpperCase(c)).append('.'); + wordStart = false; } - return sb.isEmpty() ? stars(value) : sb.toString(); + } else { + wordStart = true; + } } + return sb.isEmpty() ? stars(value) : sb.toString(); + } - /** - * Адрес почты: видны первая буква имени ящика, первая буква домена и зона. - * {@code ivan.petrov@mail.ru} → {@code i**********@m***.ru} - */ - public static String email(String value) { - int at = value.lastIndexOf('@'); - if (at <= 0 || at == value.length() - 1) { - 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); + /** + * Адрес почты: видны первая буква имени ящика, первая буква домена и зона. {@code + * ivan.petrov@mail.ru} → {@code i**********@m***.ru} + */ + public static String email(String value) { + int at = value.lastIndexOf('@'); + if (at <= 0 || at == value.length() - 1) { + 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); + } - private static String hideTail(String part) { - if (part.length() <= 1) { - return part; - } - return part.charAt(0) + String.valueOf(MASK).repeat(part.length() - 1); + private static String hideTail(String part) { + if (part.length() <= 1) { + return part; } + return part.charAt(0) + String.valueOf(MASK).repeat(part.length() - 1); + } } diff --git a/src/main/java/ru/pdguard/mask/Synthetic.java b/src/main/java/ru/pdguard/mask/Synthetic.java index 9112ccc..583ec8a 100644 --- a/src/main/java/ru/pdguard/mask/Synthetic.java +++ b/src/main/java/ru/pdguard/mask/Synthetic.java @@ -6,80 +6,102 @@ import ru.pdguard.detect.Validators; /** * Правдоподобные подставные значения вместо настоящих. * - *

Модель получает текст, который выглядит естественно, и качество ответа - * страдает меньше, чем от звёздочек. Значения детерминированы: одно и то же - * исходное значение всегда даёт одну и ту же подстановку. + *

Модель получает текст, который выглядит естественно, и качество ответа страдает меньше, чем от + * звёздочек. Значения детерминированы: одно и то же исходное значение всегда даёт одну и ту же + * подстановку. */ final class Synthetic { - private static final String[] SURNAMES = - {"Лаврентьев", "Мещеряков", "Тихомиров", "Ясенев", "Бурмистров", "Кольцов"}; - 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[] SURNAMES = { + "Лаврентьев", "Мещеряков", "Тихомиров", "Ясенев", "Бурмистров", "Кольцов" + }; + private static final String[] NAMES = {"Артём", "Никита", "Глеб", "Тимур", "Марк", "Лев"}; + private static final String[] PATRONYMICS = { + "Артёмович", "Никитич", "Глебович", "Тимурович", "Маркович", "Львович" + }; + private static final String[] DOMAINS = {"example.com", "example.org", "example.net"}; - private Synthetic() { - } + private Synthetic() {} - static String forType(String type, String value, int ordinal) { - int seed = value.hashCode() & Integer.MAX_VALUE; - return switch (type) { - case PdTypes.FIO -> pick(SURNAMES, seed) + " " + pick(NAMES, seed >> 3) - + " " + pick(PATRONYMICS, seed >> 6); - case PdTypes.CARDHOLDER -> "IVAN PETROV"; - case PdTypes.EMAIL -> "user" + ordinal + "@" + pick(DOMAINS, seed); - case PdTypes.PHONE -> "+7 9" + digits(seed, 2) + " " + digits(seed >> 4, 3) - + "-" + digits(seed >> 8, 2) + "-" + digits(seed >> 12, 2); - case PdTypes.CARD -> luhnCard(seed); - case PdTypes.PASSPORT, PdTypes.DRIVER_LICENSE, PdTypes.FOREIGN_PASSPORT, - PdTypes.MILITARY_ID -> digits(seed, 4) + " " + digits(seed >> 6, 6); - case PdTypes.INN -> digits(seed, 12); - case PdTypes.MEDICAL_POLICY -> digits(seed, 16); - case PdTypes.SNILS -> digits(seed, 3) + "-" + digits(seed >> 4, 3) - + "-" + 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 + "]"; - }; - } + static String forType(String type, String value, int ordinal) { + int seed = value.hashCode() & Integer.MAX_VALUE; + return switch (type) { + case PdTypes.FIO -> + pick(SURNAMES, seed) + " " + pick(NAMES, seed >> 3) + " " + pick(PATRONYMICS, seed >> 6); + case PdTypes.CARDHOLDER -> "IVAN PETROV"; + case PdTypes.EMAIL -> "user" + ordinal + "@" + pick(DOMAINS, seed); + case PdTypes.PHONE -> + "+7 9" + + digits(seed, 2) + + " " + + digits(seed >> 4, 3) + + "-" + + digits(seed >> 8, 2) + + "-" + + digits(seed >> 12, 2); + case PdTypes.CARD -> luhnCard(seed); + case PdTypes.PASSPORT, + PdTypes.DRIVER_LICENSE, + PdTypes.FOREIGN_PASSPORT, + PdTypes.MILITARY_ID -> + digits(seed, 4) + " " + digits(seed >> 6, 6); + case PdTypes.INN -> digits(seed, 12); + case PdTypes.MEDICAL_POLICY -> digits(seed, 16); + case PdTypes.SNILS -> + digits(seed, 3) + + "-" + + digits(seed >> 4, 3) + + "-" + + 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) { - return options[Math.floorMod(seed, options.length)]; - } + private static String pick(String[] options, int seed) { + return options[Math.floorMod(seed, options.length)]; + } - private static String syntheticDate(int seed) { - int day = 1 + Math.floorMod(seed, 28); - int month = 1 + Math.floorMod(seed >> 5, 12); - int year = 1960 + Math.floorMod(seed >> 9, 45); - return String.format("%02d.%02d.%d", day, month, year); - } + private static String syntheticDate(int seed) { + int day = 1 + Math.floorMod(seed, 28); + int month = 1 + Math.floorMod(seed >> 5, 12); + int year = 1960 + Math.floorMod(seed >> 9, 45); + return String.format("%02d.%02d.%d", day, month, year); + } - private static String digits(int seed, int count) { - StringBuilder sb = new StringBuilder(count); - int value = Math.abs(seed); - for (int i = 0; i < count; i++) { - sb.append((char) ('0' + Math.floorMod(value, 10))); - value = value / 10 + (i + 1) * 7; - } - return sb.toString(); + private static String digits(int seed, int count) { + StringBuilder sb = new StringBuilder(count); + int value = Math.abs(seed); + for (int i = 0; i < count; i++) { + sb.append((char) ('0' + Math.floorMod(value, 10))); + value = value / 10 + (i + 1) * 7; } + return sb.toString(); + } - /** Номер карты, проходящий проверку алгоритмом Луна: подстановка должна выглядеть настоящей. */ - private static String luhnCard(int seed) { - StringBuilder body = new StringBuilder("4").append(digits(seed, 14)); - body.append(Validators.luhnCheckDigit(body.toString())); - return body.substring(0, 4) + " " + body.substring(4, 8) + " " - + body.substring(8, 12) + " " + body.substring(12); - } + /** Номер карты, проходящий проверку алгоритмом Луна: подстановка должна выглядеть настоящей. */ + private static String luhnCard(int seed) { + StringBuilder body = new StringBuilder("4").append(digits(seed, 14)); + body.append(Validators.luhnCheckDigit(body.toString())); + return body.substring(0, 4) + + " " + + body.substring(4, 8) + + " " + + body.substring(8, 12) + + " " + + body.substring(12); + } } diff --git a/src/test/java/ru/pdguard/BankTypesTest.java b/src/test/java/ru/pdguard/BankTypesTest.java index 1ca1ce3..b6a15dc 100644 --- a/src/test/java/ru/pdguard/BankTypesTest.java +++ b/src/test/java/ru/pdguard/BankTypesTest.java @@ -1,5 +1,9 @@ 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 ru.pdguard.config.SystemPolicy; import ru.pdguard.core.PayloadStore; @@ -7,149 +11,165 @@ 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.assertEquals; -import static org.junit.jupiter.api.Assertions.assertFalse; - /** Банковские реквизиты сверх платёжной карты: счёт, БИК, ОГРН(ИП), КПП, доход, биометрия. */ class BankTypesTest { - private final Pipeline pipeline = - new Pipeline(new RuleRegistry(), new Masker(), new PayloadStore(1_000_000L, 30)); + private final Pipeline pipeline = + new Pipeline(new RuleRegistry(), new Masker(), new PayloadStore(1_000_000L, 30)); - private void assertHidden(String text, String secret) { - String masked = pipeline.process(text, UUID.randomUUID().toString(), SystemPolicy.DEFAULT); - assertFalse(masked.contains(secret), "не замаскировано: «" + secret + "» в ответе «" + masked + "»"); - } + private void assertHidden(String text, String secret) { + String masked = pipeline.process(text, UUID.randomUUID().toString(), SystemPolicy.DEFAULT); + assertFalse( + masked.contains(secret), "не замаскировано: «" + secret + "» в ответе «" + masked + "»"); + } - /** - * Банковские реквизиты маскируются рядом с данными человека. Сами по себе они - * опознают организацию или счёт, а не клиента, и в перечне типов из задания их - * нет — поэтому они переведены в {@code requireCompanion}, как пин-код и дата. - */ - @Test - void masksAccountNumberNextToPersonalData() { - assertHidden("Клиент Иванов Иван Иванович, расчётный счёт 40702810500000001234", - "40702810500000001234"); - assertHidden("Иванов И.И., р/с 4070 2810 5000 0000 1234", "4070 2810 5000 0000 1234"); - } + /** + * Банковские реквизиты маскируются рядом с данными человека. Сами по себе они опознают + * организацию или счёт, а не клиента, и в перечне типов из задания их нет — поэтому они + * переведены в {@code requireCompanion}, как пин-код и дата. + */ + @Test + void masksAccountNumberNextToPersonalData() { + assertHidden( + "Клиент Иванов Иван Иванович, расчётный счёт 40702810500000001234", "40702810500000001234"); + assertHidden("Иванов И.И., р/с 4070 2810 5000 0000 1234", "4070 2810 5000 0000 1234"); + } - @Test - void masksBikNextToPersonalData() { - assertHidden("Перевод Иванову Ивану Ивановичу, БИК 044525593 банка-получателя", "044525593"); - } + @Test + void masksBikNextToPersonalData() { + assertHidden("Перевод Иванову Ивану Ивановичу, БИК 044525593 банка-получателя", "044525593"); + } - @Test - void keepsBankDetailsWithoutAnyPersonalData() { - for (String text : new String[]{ - "Расчётный счёт 40702810500000001234 открыт вчера", - "БИК 044525593 банка-получателя", - "ОГРН 1027700132195 организации", - "КПП 770101001 указан в реквизитах"}) { - assertEquals(text, pipeline.process(text, UUID.randomUUID().toString(), SystemPolicy.DEFAULT), - "реквизиты без человека персональными данными не являются"); - } + @Test + void keepsBankDetailsWithoutAnyPersonalData() { + for (String text : + new String[] { + "Расчётный счёт 40702810500000001234 открыт вчера", + "БИК 044525593 банка-получателя", + "ОГРН 1027700132195 организации", + "КПП 770101001 указан в реквизитах" + }) { + assertEquals( + text, + pipeline.process(text, UUID.randomUUID().toString(), SystemPolicy.DEFAULT), + "реквизиты без человека персональными данными не являются"); } + } - @Test - void masksCardExpiryButNotCardNumber() { - String masked = pipeline.process( - "Карта 4111 1111 1111 1111, срок действия 09/27", "expiry-1", SystemPolicy.DEFAULT); - assertFalse(masked.contains("09/27"), masked); - assertEquals("Карта 41** **** **** **11, срок действия **/**", masked); - } + @Test + void masksCardExpiryButNotCardNumber() { + String masked = + pipeline.process( + "Карта 4111 1111 1111 1111, срок действия 09/27", "expiry-1", SystemPolicy.DEFAULT); + assertFalse(masked.contains("09/27"), masked); + assertEquals("Карта 41** **** **** **11, срок действия **/**", masked); + } - @Test - void masksOgrnAndOgrnipDifferently() { - assertHidden("Директор Иванов И.И., ОГРН 1027700132195 организации", "1027700132195"); - assertHidden("ИП Иванов Иван Иванович, ОГРНИП 304500116000157", "304500116000157"); - } + @Test + void masksOgrnAndOgrnipDifferently() { + assertHidden("Директор Иванов И.И., ОГРН 1027700132195 организации", "1027700132195"); + assertHidden("ИП Иванов Иван Иванович, ОГРНИП 304500116000157", "304500116000157"); + } - /** ОГРНИП (15 цифр) не должен наполовину ловиться правилом ОГРН (13 цифр). */ - @Test - void ogrnDoesNotSwallowOgrnip() { - String masked = pipeline.process("ИП Иванов Иван Иванович, ОГРНИП 304500116000157", - "ogrnip-1", SystemPolicy.DEFAULT); - assertFalse(masked.contains("304500116000157"), masked); - assertFalse(masked.matches(".*\\d{15}.*"), "осталась незамаскированная часть номера: " + masked); - } + /** ОГРНИП (15 цифр) не должен наполовину ловиться правилом ОГРН (13 цифр). */ + @Test + void ogrnDoesNotSwallowOgrnip() { + String masked = + pipeline.process( + "ИП Иванов Иван Иванович, ОГРНИП 304500116000157", "ogrnip-1", SystemPolicy.DEFAULT); + assertFalse(masked.contains("304500116000157"), masked); + assertFalse( + masked.matches(".*\\d{15}.*"), "осталась незамаскированная часть номера: " + masked); + } - /** - * Контрольная сумма отсекает случайное 13-значное число рядом со словом «ОГРН». - * Число подобрано так, чтобы не проходить заодно и Луна — иначе оно всё равно - * маскировалось бы, но уже как номер карты, и тест ничего бы не показывал. - */ - @Test - void doesNotMaskOgrnWithBrokenChecksum() { - String text = "ОГРН 1027700132190 организации"; - assertEquals(text, pipeline.process(text, UUID.randomUUID().toString(), SystemPolicy.DEFAULT), - "число с неверной контрольной суммой не является настоящим ОГРН"); - } + /** + * Контрольная сумма отсекает случайное 13-значное число рядом со словом «ОГРН». Число подобрано + * так, чтобы не проходить заодно и Луна — иначе оно всё равно маскировалось бы, но уже как номер + * карты, и тест ничего бы не показывал. + */ + @Test + void doesNotMaskOgrnWithBrokenChecksum() { + String text = "ОГРН 1027700132190 организации"; + assertEquals( + text, + pipeline.process(text, UUID.randomUUID().toString(), SystemPolicy.DEFAULT), + "число с неверной контрольной суммой не является настоящим ОГРН"); + } - /** Та же проверка для ОГРНИП — случайное 15-значное число рядом со словом. */ - @Test - void doesNotMaskOgrnipWithBrokenChecksum() { - String text = "ОГРНИП 304500116000150 предпринимателя"; - assertEquals(text, pipeline.process(text, UUID.randomUUID().toString(), SystemPolicy.DEFAULT), - "число с неверной контрольной суммой не является настоящим ОГРНИП"); - } + /** Та же проверка для ОГРНИП — случайное 15-значное число рядом со словом. */ + @Test + void doesNotMaskOgrnipWithBrokenChecksum() { + String text = "ОГРНИП 304500116000150 предпринимателя"; + assertEquals( + text, + pipeline.process(text, UUID.randomUUID().toString(), SystemPolicy.DEFAULT), + "число с неверной контрольной суммой не является настоящим ОГРНИП"); + } - @Test - void masksKppNextToPersonalData() { - assertHidden("Заявитель Иванов И.И., КПП 770101001 указан в реквизитах", "770101001"); - } + @Test + void masksKppNextToPersonalData() { + assertHidden("Заявитель Иванов И.И., КПП 770101001 указан в реквизитах", "770101001"); + } - @Test - void masksIncomeNextToPersonalData() { - assertHidden("Иванов Иван Иванович, доход 85 000 руб. в месяц", "85 000"); - assertHidden("Иванову И.И. начислена заработная плата 120000 в месяц", "120000"); - } + @Test + void masksIncomeNextToPersonalData() { + assertHidden("Иванов Иван Иванович, доход 85 000 руб. в месяц", "85 000"); + assertHidden("Иванову И.И. начислена заработная плата 120000 в месяц", "120000"); + } - /** - * Сумма заработка без человека — статистика или описание продукта. Опознать по - * ней никого нельзя, а для прокси к языковой модели вымаранное число означает, - * что вопрос про среднюю зарплату по отрасли отвечать уже не на чем. - */ - @Test - void keepsIncomeWithoutAnyPersonalData() { - for (String text : new String[]{ - "По данным Росстата доход домохозяйств вырос до 74 500 руб", - "Зарплатный проект: зарплата 80 000 руб перечисляется на счёт"}) { - assertEquals(text, pipeline.process(text, UUID.randomUUID().toString(), SystemPolicy.DEFAULT), - "сумма заработка без человека персональными данными не является"); - } + /** + * Сумма заработка без человека — статистика или описание продукта. Опознать по ней никого нельзя, + * а для прокси к языковой модели вымаранное число означает, что вопрос про среднюю зарплату по + * отрасли отвечать уже не на чем. + */ + @Test + void keepsIncomeWithoutAnyPersonalData() { + for (String text : + new String[] { + "По данным Росстата доход домохозяйств вырос до 74 500 руб", + "Зарплатный проект: зарплата 80 000 руб перечисляется на счёт" + }) { + assertEquals( + text, + pipeline.process(text, UUID.randomUUID().toString(), SystemPolicy.DEFAULT), + "сумма заработка без человека персональными данными не является"); } + } - @Test - void masksBiometricMentionNextToPersonalData() { - assertHidden("Клиент Иванов Иван Иванович сдал биометрические данные", "биометрические данные"); - assertHidden("Для Иванова И.И. оформлен слепок голоса", "слепок голоса"); - } + @Test + void masksBiometricMentionNextToPersonalData() { + assertHidden("Клиент Иванов Иван Иванович сдал биометрические данные", "биометрические данные"); + assertHidden("Для Иванова И.И. оформлен слепок голоса", "слепок голоса"); + } - /** - * Биометрии в тексте не бывает: это шаблон в базе, и правило маскирует само - * упоминание — слово, а не данные. Без человека рядом такая замена скрывает - * ноль сведений и разрушает смысл фразы. - */ - @Test - void keepsBiometricMentionWithoutAnyPersonalData() { - for (String text : new String[]{ - "Банк внедрил биометрические данные в обслуживание клиентов", - "Сдать биометрию можно через ЕБС в любом отделении"}) { - assertEquals(text, pipeline.process(text, UUID.randomUUID().toString(), SystemPolicy.DEFAULT), - "упоминание биометрии без человека персональными данными не является"); - } + /** + * Биометрии в тексте не бывает: это шаблон в базе, и правило маскирует само упоминание — слово, а + * не данные. Без человека рядом такая замена скрывает ноль сведений и разрушает смысл фразы. + */ + @Test + void keepsBiometricMentionWithoutAnyPersonalData() { + for (String text : + new String[] { + "Банк внедрил биометрические данные в обслуживание клиентов", + "Сдать биометрию можно через ЕБС в любом отделении" + }) { + assertEquals( + text, + pipeline.process(text, UUID.randomUUID().toString(), SystemPolicy.DEFAULT), + "упоминание биометрии без человека персональными данными не является"); } + } - /** - * Два несамостоятельных типа рядом не заверяют друг друга: сочетание даты и - * ОГРН самостоятельным не становится, человека в таком тексте нет. - */ - @Test - void twoCompanionTypesDoNotVouchForEachOther() { - String text = "Оплата 01.02.2025, ОГРН 1027700132195"; - assertEquals(text, pipeline.process(text, UUID.randomUUID().toString(), SystemPolicy.DEFAULT), - "спутники заверили друг друга в отсутствие настоящих ПД"); - } + /** + * Два несамостоятельных типа рядом не заверяют друг друга: сочетание даты и ОГРН самостоятельным + * не становится, человека в таком тексте нет. + */ + @Test + void twoCompanionTypesDoNotVouchForEachOther() { + String text = "Оплата 01.02.2025, ОГРН 1027700132195"; + assertEquals( + text, + pipeline.process(text, UUID.randomUUID().toString(), SystemPolicy.DEFAULT), + "спутники заверили друг друга в отсутствие настоящих ПД"); + } } diff --git a/src/test/java/ru/pdguard/BenchmarkFixtures.java b/src/test/java/ru/pdguard/BenchmarkFixtures.java index 45c1d9c..721ead6 100644 --- a/src/test/java/ru/pdguard/BenchmarkFixtures.java +++ b/src/test/java/ru/pdguard/BenchmarkFixtures.java @@ -1,7 +1,5 @@ package ru.pdguard; -import ru.pdguard.detect.Span; - import java.io.BufferedReader; import java.io.IOException; import java.io.InputStream; @@ -12,56 +10,57 @@ import java.util.List; import java.util.Objects; import java.util.regex.Matcher; import java.util.regex.Pattern; +import ru.pdguard.detect.Span; /** - * Общий разбор размеченных наборов {@code {{ТИП:значение}}} — используется - * и {@link BenchmarkTest} (замер качества по строкам), и {@link LargeTextTest} - * (те же строки, перемешанные и склеенные в большой текст). + * Общий разбор размеченных наборов {@code {{ТИП:значение}}} — используется и {@link BenchmarkTest} + * (замер качества по строкам), и {@link LargeTextTest} (те же строки, перемешанные и склеенные в + * большой текст). */ 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 gold) { - } + /** Размеченный пример: чистый текст и эталонные фрагменты. */ + record Sample(String text, List gold) {} - private BenchmarkFixtures() { - } + private BenchmarkFixtures() {} - /** Читает набор построчно, пропуская пустые строки и комментарии {@code #}. */ - static List load(String resource) { - List samples = new ArrayList<>(); - try (InputStream in = BenchmarkFixtures.class.getResourceAsStream(resource); - BufferedReader reader = new BufferedReader( - new InputStreamReader(Objects.requireNonNull(in, resource), StandardCharsets.UTF_8))) { - String line; - while ((line = reader.readLine()) != null) { - String trimmed = line.trim(); - if (!trimmed.isEmpty() && !trimmed.startsWith("#")) { - samples.add(parse(trimmed)); - } - } - } catch (IOException e) { - throw new IllegalStateException("Не удалось прочитать " + resource, e); + /** Читает набор построчно, пропуская пустые строки и комментарии {@code #}. */ + static List load(String resource) { + List samples = new ArrayList<>(); + try (InputStream in = BenchmarkFixtures.class.getResourceAsStream(resource); + BufferedReader reader = + new BufferedReader( + new InputStreamReader( + Objects.requireNonNull(in, resource), StandardCharsets.UTF_8))) { + String line; + while ((line = reader.readLine()) != null) { + String trimmed = line.trim(); + if (!trimmed.isEmpty() && !trimmed.startsWith("#")) { + samples.add(parse(trimmed)); } - return samples; + } + } catch (IOException e) { + throw new IllegalStateException("Не удалось прочитать " + resource, e); } + return samples; + } - /** Разбирает разметку {@code {{ТИП:значение}}} в чистый текст и эталонные фрагменты. */ - static Sample parse(String line) { - StringBuilder text = new StringBuilder(line.length()); - List gold = new ArrayList<>(); - Matcher m = MARKUP.matcher(line); - int cursor = 0; - while (m.find()) { - text.append(line, cursor, m.start()); - int start = text.length(); - text.append(m.group(2)); - gold.add(new Span(start, text.length(), m.group(1), 0)); - cursor = m.end(); - } - text.append(line, cursor, line.length()); - return new Sample(text.toString(), gold); + /** Разбирает разметку {@code {{ТИП:значение}}} в чистый текст и эталонные фрагменты. */ + static Sample parse(String line) { + StringBuilder text = new StringBuilder(line.length()); + List gold = new ArrayList<>(); + Matcher m = MARKUP.matcher(line); + int cursor = 0; + while (m.find()) { + text.append(line, cursor, m.start()); + int start = text.length(); + text.append(m.group(2)); + gold.add(new Span(start, text.length(), m.group(1), 0)); + cursor = m.end(); } + text.append(line, cursor, line.length()); + return new Sample(text.toString(), gold); + } } diff --git a/src/test/java/ru/pdguard/BenchmarkTest.java b/src/test/java/ru/pdguard/BenchmarkTest.java index 88a794f..7d22109 100644 --- a/src/test/java/ru/pdguard/BenchmarkTest.java +++ b/src/test/java/ru/pdguard/BenchmarkTest.java @@ -1,14 +1,7 @@ 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.Span; -import ru.pdguard.detect.NameCascade; -import ru.pdguard.detect.PdTypes; -import ru.pdguard.detect.RuleRegistry; -import ru.pdguard.mask.Masker; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.junit.jupiter.api.Assumptions.assumeTrue; import java.nio.file.Files; import java.nio.file.Path; @@ -18,348 +11,431 @@ import java.util.LinkedHashMap; import java.util.List; import java.util.Map; import java.util.Optional; - -import static org.junit.jupiter.api.Assertions.assertTrue; -import static org.junit.jupiter.api.Assumptions.assumeTrue; +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; /** * Замер качества детекции на размеченных наборах. * - *

Наборов два. {@code benchmark.txt} использовался при отладке правил, поэтому - * его оценка завышена и годится только как защита от ухудшений. - * {@code benchmark-holdout.txt} составлен независимо и на нём правила не - * настраивались — именно он показывает настоящее качество. + *

Наборов два. {@code benchmark.txt} использовался при отладке правил, поэтому его оценка + * завышена и годится только как защита от ухудшений. {@code benchmark-holdout.txt} составлен + * независимо и на нём правила не настраивались — именно он показывает настоящее качество. * - *

Метрики посимвольные: так они не зависят от того, где именно правило - * поставило границу совпадения, и напрямую соотносятся с посимвольным - * сравнением замаскированного текста с эталоном. + *

Метрики посимвольные: так они не зависят от того, где именно правило поставило границу + * совпадения, и напрямую соотносятся с посимвольным сравнением замаскированного текста с эталоном. * - *

Отдельно считается строка «любой тип»: для защиты важно, что знаки скрыты, - * а расхождение в названии типа (скажем, место рождения против города) на - * качество маскирования не влияет. + *

Отдельно считается строка «любой тип»: для защиты важно, что знаки скрыты, а расхождение в + * названии типа (скажем, место рождения против города) на качество маскирования не влияет. */ class BenchmarkTest { - /** - * Вторая ступень для замера. Модели нет — прогон идёт на одних правилах, и это - * видно по заголовку отчёта. Путь подменяется свойством {@code -Dbench.model=...}. - */ - private static final String ENGINE = System.getProperty("bench.engine", "rubert"); - private static final String MODEL_PATH = System.getProperty("bench.model", "models/rubert-ner"); + /** + * Вторая ступень для замера. Модели нет — прогон идёт на одних правилах, и это видно по заголовку + * отчёта. Путь подменяется свойством {@code -Dbench.model=...}. + */ + private static final String ENGINE = System.getProperty("bench.engine", "rubert"); - /** Итог замера по одному набору. */ - private record Result(double fioF1, double overallPrecision, double overallRecall, - double falsePositiveRate, int foundFioSpans, int goldFioSpans) { + 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 static final class Score { + private int truePositive; + private int falsePositive; + private int falseNegative; + + private int gold() { + return truePositive + falseNegative; } - /** Накопитель посимвольных совпадений по одному типу. */ - private static final class Score { - private int truePositive; - private int falsePositive; - private int falseNegative; + private double precision() { + int found = truePositive + falsePositive; + return found == 0 ? 1.0 : (double) truePositive / found; + } - private int gold() { - return truePositive + falseNegative; + 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); + } + } + + private final Pipeline pipeline = + new Pipeline(new RuleRegistry(), new Masker(), new PayloadStore(10_000_000L, 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(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())); + } + + /** + * Контрольный набор. Правила по нему не настраиваются: он существует, чтобы показывать качество + * на данных, которых разработка не видела. Пороги здесь низкие намеренно — тест ловит обвал, а не + * сторожит достигнутое значение. Замер идёт со второй ступенью, если модель собрана, иначе на + * одних правилах. + */ + @Test + 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())); + } + + /** + * Реальные адреса отделений Альфа-Банка (ловушка из ТЗ — не ПД клиента), расширенный денилист, + * обобщённое companion-правило (место рождения, страна) и новые банковские типы. Собран + * специально под соответствующие доработки — пороги ниже, чем у набора отладки, но проверяют + * именно то, что было доработано, а не общее качество остального пайплайна. + */ + @Test + void detectionQualityOnBankContextSet() { + Result result = measure("/benchmark-bank-context.txt", "банковский контекст"); + + assertTrue( + result.fioF1() >= 0.70, + String.format("F1 по ФИО на банковском наборе упал до %.3f", result.fioF1())); + assertTrue( + result.overallRecall() >= 0.70, + String.format("полнота на банковском наборе упала до %.3f", result.overallRecall())); + assertTrue( + result.falsePositiveRate() <= 0.10, + String.format( + "ложные срабатывания на банковском наборе: %.3f", result.falsePositiveRate())); + } + + /** + * Независимый сгенерированный набор — покрывает все типы ПД из ТЗ и вариации написания, не + * встречавшиеся ни в одном из остальных наборов. Правила под него не настраивались; пороги низкие + * по той же причине, что и у второго отложенного набора — тест ловит обвал, а не сторожит + * достигнутое значение. + */ + @Test + void detectionQualityOnGeneratedSet() { + 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-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(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 samples = BenchmarkFixtures.load(resource); + Map byType = new LinkedHashMap<>(); + Score anyType = new Score(); + + int cleanTexts = 0; + int cleanTextsWithFalseHit = 0; + int goldFioSpans = 0; + int foundFioSpans = 0; + List falseHits = new ArrayList<>(); + List missedFio = new ArrayList<>(); + List overMasked = new ArrayList<>(); + + for (BenchmarkFixtures.Sample sample : samples) { + List 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() { - int found = truePositive + falsePositive; - return found == 0 ? 1.0 : (double) truePositive / found; + for (Span gold : sample.gold()) { + if (!PdTypes.FIO.equals(gold.type())) { + continue; } - - 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); + goldFioSpans++; + if (overlappedByFio(gold, found)) { + foundFioSpans++; + } else { + missedFio.add(fragment(sample.text(), gold) + " ← " + sample.text()); } + } } - private final Pipeline pipeline = - new Pipeline(new RuleRegistry(), new Masker(), new PayloadStore(10_000_000L, 30)); + report( + title, + samples.size(), + byType, + anyType, + goldFioSpans, + foundFioSpans, + cleanTexts, + cleanTextsWithFalseHit, + missedFio, + falseHits, + overMasked); - /** - * Набор, на котором правила отлаживались. Пороги здесь высокие: любое падение - * означает, что сломалось то, что раньше работало. - */ - @Test - void detectionQualityOnTuningSet() { - Result result = measure("/benchmark.txt", "набор отладки"); + 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); + } - 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())); + /** Раскрашивает каждый знак текста типом ПД, который его покрывает. */ + private static String[] paint(int length, List 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; + } - /** - * Отложенный набор: правила на нём не настраивались. Пороги ниже — они - * отражают измеренное на нём качество, а не желаемое. - */ - @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())); + private static void account(Map 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++; + } } - - /** - * Второй контрольный набор, составленный после того, как первый дважды повлиял - * на правила. На нём не настраивалось ничего — он и показывает настоящее - * качество. Пороги низкие намеренно: тест ловит обвал, а не сторожит значение. - */ - @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())); + if (found != null && !found.equals(gold)) { + byType.computeIfAbsent(found, t -> new Score()).falsePositive++; } + } - /** - * Контрольный набор. Правила по нему не настраиваются: он существует, чтобы - * показывать качество на данных, которых разработка не видела. Пороги здесь - * низкие намеренно — тест ловит обвал, а не сторожит достигнутое значение. - * Замер идёт со второй ступенью, если модель собрана, иначе на одних правилах. - */ - @Test - 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 void accountAnyType(Score score, boolean gold, boolean found) { + if (gold && found) { + score.truePositive++; + } else if (gold) { + score.falseNegative++; + } else if (found) { + score.falsePositive++; } + } - /** - * Реальные адреса отделений Альфа-Банка (ловушка из ТЗ — не ПД клиента), - * расширенный денилист, обобщённое companion-правило (место рождения, - * страна) и новые банковские типы. Собран специально под соответствующие - * доработки — пороги ниже, чем у набора отладки, но проверяют именно то, - * что было доработано, а не общее качество остального пайплайна. - */ - @Test - void detectionQualityOnBankContextSet() { - Result result = measure("/benchmark-bank-context.txt", "банковский контекст"); + private static boolean overlappedByFio(Span gold, List found) { + return found.stream().anyMatch(span -> PdTypes.FIO.equals(span.type()) && span.overlaps(gold)); + } - assertTrue(result.fioF1() >= 0.70, - String.format("F1 по ФИО на банковском наборе упал до %.3f", result.fioF1())); - assertTrue(result.overallRecall() >= 0.70, - String.format("полнота на банковском наборе упала до %.3f", result.overallRecall())); - assertTrue(result.falsePositiveRate() <= 0.10, - String.format("ложные срабатывания на банковском наборе: %.3f", result.falsePositiveRate())); + 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 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; + } } + } - /** - * Независимый сгенерированный набор — покрывает все типы ПД из ТЗ и вариации - * написания, не встречавшиеся ни в одном из остальных наборов. Правила под - * него не настраивались; пороги низкие по той же причине, что и у второго - * отложенного набора — тест ловит обвал, а не сторожит достигнутое значение. - */ - @Test - void detectionQualityOnGeneratedSet() { - 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-generated.txt", "сгенерированный набор"); + private void report( + String title, + int samples, + Map byType, + Score anyType, + int goldFio, + int foundFio, + int cleanTexts, + int falseHitTexts, + List missedFio, + List falseHits, + List 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")); - assertTrue(result.fioF1() >= 0.70, - String.format("F1 по ФИО на сгенерированном наборе упал до %.3f", result.fioF1())); - assertTrue(result.overallRecall() >= 0.70, - String.format("полнота на сгенерированном наборе упала до %.3f", result.overallRecall())); + byType.entrySet().stream() + .sorted( + Comparator.comparingInt((Map.Entry 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 lines) { + if (lines.isEmpty()) { + return; } - - /** - * Тот же отложенный набор, но со включённой второй ступенью. Модели нет — - * проверка пропускается: в сборке без модели сервис работает на одних правилах. - */ - @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 samples = BenchmarkFixtures.load(resource); - Map byType = new LinkedHashMap<>(); - Score anyType = new Score(); - - int cleanTexts = 0; - int cleanTextsWithFalseHit = 0; - int goldFioSpans = 0; - int foundFioSpans = 0; - List falseHits = new ArrayList<>(); - List missedFio = new ArrayList<>(); - List overMasked = new ArrayList<>(); - - for (BenchmarkFixtures.Sample sample : samples) { - List 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 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 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 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 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 byType, Score anyType, - int goldFio, int foundFio, int cleanTexts, int falseHitTexts, - List missedFio, List falseHits, List 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 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 lines) { - if (lines.isEmpty()) { - return; - } - out.append(title).append('\n'); - lines.forEach(line -> out.append(" ").append(line).append('\n')); - } - + out.append(title).append('\n'); + lines.forEach(line -> out.append(" ").append(line).append('\n')); + } } diff --git a/src/test/java/ru/pdguard/CipherEnabledTest.java b/src/test/java/ru/pdguard/CipherEnabledTest.java index 609c172..54a8d2f 100644 --- a/src/test/java/ru/pdguard/CipherEnabledTest.java +++ b/src/test/java/ru/pdguard/CipherEnabledTest.java @@ -1,18 +1,18 @@ package ru.pdguard; +import static org.junit.jupiter.api.Assertions.assertTrue; + import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.context.SpringBootTest; import ru.pdguard.core.PayloadCipher; -import static org.junit.jupiter.api.Assertions.assertTrue; - @SpringBootTest class CipherEnabledTest { - @Autowired PayloadCipher cipher; + @Autowired PayloadCipher cipher; - @Test - void cipherIsEnabled() { - assertTrue(cipher.enabled(), "шифрование должно быть включено ключом из конфигурации"); - } + @Test + void cipherIsEnabled() { + assertTrue(cipher.enabled(), "шифрование должно быть включено ключом из конфигурации"); + } } diff --git a/src/test/java/ru/pdguard/CipherKeyTest.java b/src/test/java/ru/pdguard/CipherKeyTest.java index f74ba27..9047da7 100644 --- a/src/test/java/ru/pdguard/CipherKeyTest.java +++ b/src/test/java/ru/pdguard/CipherKeyTest.java @@ -1,22 +1,25 @@ 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.assertTrue; +import org.junit.jupiter.api.Test; +import ru.pdguard.core.PayloadCipher; + /** Проверка ключа шифрования из application.yml. */ class CipherKeyTest { - private static final String KEY = "46a38b200c6df557a5fd2c8a57ad3fec6b710b9f3e1fef1451d121a094f63573"; + private static final String KEY = + "46a38b200c6df557a5fd2c8a57ad3fec6b710b9f3e1fef1451d121a094f63573"; - @Test - void keyIsValidAes256() { - PayloadCipher cipher = new PayloadCipher(KEY); - assertTrue(cipher.enabled(), "ключ должен включать шифрование"); - String original = "Клиент Иванов Иван Иванович, паспорт 4509 123456"; - assertEquals(original, cipher.decrypt(cipher.encrypt(original)), - "round-trip с ключом из application.yml должен работать"); - } + @Test + void keyIsValidAes256() { + PayloadCipher cipher = new PayloadCipher(KEY); + assertTrue(cipher.enabled(), "ключ должен включать шифрование"); + String original = "Клиент Иванов Иван Иванович, паспорт 4509 123456"; + assertEquals( + original, + cipher.decrypt(cipher.encrypt(original)), + "round-trip с ключом из application.yml должен работать"); + } } diff --git a/src/test/java/ru/pdguard/ContextDetectionTest.java b/src/test/java/ru/pdguard/ContextDetectionTest.java index f1b6439..89bc763 100644 --- a/src/test/java/ru/pdguard/ContextDetectionTest.java +++ b/src/test/java/ru/pdguard/ContextDetectionTest.java @@ -1,162 +1,165 @@ 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.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.assertEquals; -import static org.junit.jupiter.api.Assertions.assertFalse; -import static org.junit.jupiter.api.Assertions.assertTrue; - /** Типы ПД, которые опознаются только рядом с якорным словом. */ class ContextDetectionTest { - private final Pipeline pipeline = - new Pipeline(new RuleRegistry(), new Masker(), new PayloadStore(1_000_000L, 30)); + private final Pipeline pipeline = + new Pipeline(new RuleRegistry(), new Masker(), new PayloadStore(1_000_000L, 30)); - private String mask(String text) { - return pipeline.process(text, UUID.randomUUID().toString(), SystemPolicy.DEFAULT); - } + private String mask(String text) { + return pipeline.process(text, UUID.randomUUID().toString(), SystemPolicy.DEFAULT); + } - private void assertHidden(String text, String secret) { - String masked = mask(text); - assertFalse(masked.contains(secret), "не замаскировано: «" + secret + "» в ответе «" + masked + "»"); - } + private void assertHidden(String text, String secret) { + String masked = mask(text); + assertFalse( + masked.contains(secret), "не замаскировано: «" + secret + "» в ответе «" + masked + "»"); + } - @Test - void masksPassportInEveryNotation() { - assertHidden("Паспорт 4509 123456 выдан давно", "4509 123456"); - assertHidden("паспорт гражданина РФ 45 09 123456", "45 09 123456"); - assertHidden("ПАСПОРТ 4509123456", "4509123456"); - assertHidden("Серия 4509 номер 123456", "4509"); - assertHidden("серии 45 09 № 123456", "123456"); - } + @Test + void masksPassportInEveryNotation() { + assertHidden("Паспорт 4509 123456 выдан давно", "4509 123456"); + assertHidden("паспорт гражданина РФ 45 09 123456", "45 09 123456"); + assertHidden("ПАСПОРТ 4509123456", "4509123456"); + assertHidden("Серия 4509 номер 123456", "4509"); + assertHidden("серии 45 09 № 123456", "123456"); + } - @Test - void masksPassportSeriesAndNumberSplitByWords() { - String masked = mask("Документ: серия 4509 номер 123456, выдан отделом"); - assertTrue(masked.contains("серия "), masked); - assertTrue(masked.contains("номер "), masked); - assertFalse(masked.contains("4509"), masked); - assertFalse(masked.contains("123456"), masked); - } + @Test + void masksPassportSeriesAndNumberSplitByWords() { + String masked = mask("Документ: серия 4509 номер 123456, выдан отделом"); + assertTrue(masked.contains("серия "), masked); + assertTrue(masked.contains("номер "), masked); + assertFalse(masked.contains("4509"), masked); + assertFalse(masked.contains("123456"), masked); + } - @Test - void masksDepartmentCode() { - assertHidden("Код подразделения 770-001", "770-001"); - assertHidden("к/п 770001", "770001"); - } + @Test + void masksDepartmentCode() { + assertHidden("Код подразделения 770-001", "770-001"); + assertHidden("к/п 770001", "770001"); + } - @Test - void masksIssuingAuthorityButNotTheDateAfterIt() { - String masked = mask("Паспорт выдан ОУФМС России по г. Москве 12.05.2015"); - assertFalse(masked.contains("ОУФМС"), masked); - assertFalse(masked.contains("12.05.2015"), masked); - assertTrue(masked.contains("**.**.****"), "дата маскируется отдельно от органа выдачи: " + masked); - } + @Test + void masksIssuingAuthorityButNotTheDateAfterIt() { + String masked = mask("Паспорт выдан ОУФМС России по г. Москве 12.05.2015"); + assertFalse(masked.contains("ОУФМС"), masked); + assertFalse(masked.contains("12.05.2015"), masked); + assertTrue( + masked.contains("**.**.****"), "дата маскируется отдельно от органа выдачи: " + masked); + } - @Test - void masksDriverLicense() { - assertHidden("Водительское удостоверение 9902 123456", "9902 123456"); - assertHidden("в/у 99 02 123456", "99 02 123456"); - } + @Test + void masksDriverLicense() { + assertHidden("Водительское удостоверение 9902 123456", "9902 123456"); + assertHidden("в/у 99 02 123456", "99 02 123456"); + } - @Test - void masksCitizenship() { - assertHidden("Гражданство: РФ", "РФ"); - assertHidden("гражданство Республики Беларусь", "Беларусь"); - assertHidden("Гражданин России обратился", "России"); - } + @Test + void masksCitizenship() { + assertHidden("Гражданство: РФ", "РФ"); + assertHidden("гражданство Республики Беларусь", "Беларусь"); + assertHidden("Гражданин России обратился", "России"); + } - @Test - void masksBirthPlace() { - // Место рождения — тип из requireCompanion: без другого ПД рядом не маскируется - // («Нижний Новгород» в рассказе о городе не должен теряться), поэтому в тесте - // на распознавание якоря рядом добавлен телефон. - assertHidden("Место рождения: город Тверь, проживает в Москве, тел. +7 916 123-45-67", "город Тверь"); - assertHidden("Родился в Нижнем Новгороде, тел. +7 916 123-45-67", "Нижнем Новгороде"); - } + @Test + void masksBirthPlace() { + // Место рождения — тип из requireCompanion: без другого ПД рядом не маскируется + // («Нижний Новгород» в рассказе о городе не должен теряться), поэтому в тесте + // на распознавание якоря рядом добавлен телефон. + assertHidden( + "Место рождения: город Тверь, проживает в Москве, тел. +7 916 123-45-67", "город Тверь"); + assertHidden("Родился в Нижнем Новгороде, тел. +7 916 123-45-67", "Нижнем Новгороде"); + } - @Test - void doesNotMaskBirthPlaceWithoutAnyOtherPersonalData() { - String text = "Экскурсия в Нижний Новгород перенесена на май"; - assertEquals(text, mask(text), "место рождения без другого ПД рядом не маскируется"); - } + @Test + void doesNotMaskBirthPlaceWithoutAnyOtherPersonalData() { + String text = "Экскурсия в Нижний Новгород перенесена на май"; + assertEquals(text, mask(text), "место рождения без другого ПД рядом не маскируется"); + } - @Test - void masksBirthPlaceWhenOtherPersonalDataIsAlsoPresent() { - assertHidden("Место рождения: город Тверь, ИНН 770301234550", "город Тверь"); - } + @Test + void masksBirthPlaceWhenOtherPersonalDataIsAlsoPresent() { + assertHidden("Место рождения: город Тверь, ИНН 770301234550", "город Тверь"); + } - @Test - void doesNotMaskCountryWithoutAnyOtherPersonalData() { - String text = "Цены на нефть выросли в Казахстане в этом квартале"; - assertEquals(text, mask(text), "страна без другого ПД рядом не маскируется"); - } + @Test + void doesNotMaskCountryWithoutAnyOtherPersonalData() { + String text = "Цены на нефть выросли в Казахстане в этом квартале"; + assertEquals(text, mask(text), "страна без другого ПД рядом не маскируется"); + } - @Test - void masksCountryWhenOtherPersonalDataIsAlsoPresent() { - assertHidden("Страна проживания Казахстан, ИНН 770301234550", "Казахстан"); - } + @Test + void masksCountryWhenOtherPersonalDataIsAlsoPresent() { + assertHidden("Страна проживания Казахстан, ИНН 770301234550", "Казахстан"); + } - @Test - void masksCardholderName() { - assertHidden("Держатель карты IVAN PETROV", "IVAN PETROV"); - assertHidden("cardholder: PETR SIDOROV", "PETR SIDOROV"); - } + @Test + void masksCardholderName() { + assertHidden("Держатель карты IVAN PETROV", "IVAN PETROV"); + assertHidden("cardholder: PETR SIDOROV", "PETR SIDOROV"); + } - @Test - void masksSecurityCodeAndPinCompletely() { - String masked = mask("Карта 4111 1111 1111 1111, CVV 123, пин-код 4321"); - assertFalse(masked.contains("123,"), masked); - assertFalse(masked.contains("4321"), masked); - assertTrue(masked.contains("***"), masked); - } + @Test + void masksSecurityCodeAndPinCompletely() { + String masked = mask("Карта 4111 1111 1111 1111, CVV 123, пин-код 4321"); + assertFalse(masked.contains("123,"), masked); + assertFalse(masked.contains("4321"), masked); + assertTrue(masked.contains("***"), masked); + } - @Test - void doesNotMaskPinWithoutAnyOtherPersonalData() { - String text = "Пин-код 1234 введён неверно"; - assertEquals(text, mask(text), "одиночный пин-код персональными данными не является"); - } + @Test + void doesNotMaskPinWithoutAnyOtherPersonalData() { + String text = "Пин-код 1234 введён неверно"; + assertEquals(text, mask(text), "одиночный пин-код персональными данными не является"); + } - @Test - void masksPinWhenCardNumberIsAlsoPresent() { - assertHidden("Пин-код 1234 от карты 4111 1111 1111 1111", "1234 от"); - } + @Test + void masksPinWhenCardNumberIsAlsoPresent() { + assertHidden("Пин-код 1234 от карты 4111 1111 1111 1111", "1234 от"); + } - @Test - void anchorWordsAreCaseInsensitive() { - assertHidden("ПАСПОРТ СЕРИЯ 4509 НОМЕР 123456", "123456"); - assertHidden("гРаЖдАнСтВо РФ, паспорт 4509 123456", "4509 123456"); - } + @Test + void anchorWordsAreCaseInsensitive() { + assertHidden("ПАСПОРТ СЕРИЯ 4509 НОМЕР 123456", "123456"); + assertHidden("гРаЖдАнСтВо РФ, паспорт 4509 123456", "4509 123456"); + } - @Test - void complexSentenceKeepsSurroundingWords() { - String original = "Клиент, паспорт 4509 123456 выдан ОУФМС по г. Москве, " - + "код подразделения 770-001, ИНН 770301234550, телефон +7 916 123-45-67"; - String masked = mask(original); + @Test + void complexSentenceKeepsSurroundingWords() { + String original = + "Клиент, паспорт 4509 123456 выдан ОУФМС по г. Москве, " + + "код подразделения 770-001, ИНН 770301234550, телефон +7 916 123-45-67"; + String masked = mask(original); - assertTrue(masked.startsWith("Клиент, паспорт "), masked); - assertTrue(masked.contains("код подразделения"), masked); - assertTrue(masked.contains("телефон"), masked); - assertFalse(masked.contains("4509 123456"), masked); - assertFalse(masked.contains("770301234550"), masked); - } + assertTrue(masked.startsWith("Клиент, паспорт "), masked); + assertTrue(masked.contains("код подразделения"), masked); + assertTrue(masked.contains("телефон"), masked); + assertFalse(masked.contains("4509 123456"), masked); + assertFalse(masked.contains("770301234550"), masked); + } - @Test - void unmaskingRestoresComplexSentence() { - String original = "Паспорт 4509 123456, выдан ОУФМС России по г. Москве, " - + "код подразделения 770-001, гражданство РФ, CVV 123, карта 4111 1111 1111 1111"; - String id = "complex-1"; + @Test + void unmaskingRestoresComplexSentence() { + String original = + "Паспорт 4509 123456, выдан ОУФМС России по г. Москве, " + + "код подразделения 770-001, гражданство РФ, CVV 123, карта 4111 1111 1111 1111"; + String id = "complex-1"; - String masked = pipeline.process(original, id, SystemPolicy.DEFAULT); - assertFalse(masked.contains("4509 123456"), masked); - assertEquals(original, pipeline.process(masked, id, SystemPolicy.DEFAULT)); - } + String masked = pipeline.process(original, id, SystemPolicy.DEFAULT); + assertFalse(masked.contains("4509 123456"), masked); + assertEquals(original, pipeline.process(masked, id, SystemPolicy.DEFAULT)); + } } diff --git a/src/test/java/ru/pdguard/Dataset200Test.java b/src/test/java/ru/pdguard/Dataset200Test.java index 847a4b3..278898c 100644 --- a/src/test/java/ru/pdguard/Dataset200Test.java +++ b/src/test/java/ru/pdguard/Dataset200Test.java @@ -1,5 +1,11 @@ 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.TestFactory; import ru.pdguard.config.SystemPolicy; @@ -9,60 +15,55 @@ import ru.pdguard.detect.RuleRegistry; import ru.pdguard.detect.Span; 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} — по одному - * предложению на строку, каждое своя отдельная проверка (не сборка одного большого - * текста, как в {@link HugeDatasetTest}). Набор покрывает все типы ПДН из - * {@link RuleRegistry} (кроме ADDRESS_REGION/ADDRESS_DISTRICT — для них нет правил, - * только модель второй ступени), варианты написания (регистр, формат даты, разделяющие - * слова) и несколько строк-ловушек без разметки (известный человек, адрес отделения, - * дата без якоря) — они не должны маскироваться вовсе. + * 200 вручную составленных текстовых тестов из {@code dataset-200.txt} — по одному предложению на + * строку, каждое своя отдельная проверка (не сборка одного большого текста, как в {@link + * HugeDatasetTest}). Набор покрывает все типы ПДН из {@link RuleRegistry} (кроме + * ADDRESS_REGION/ADDRESS_DISTRICT — для них нет правил, только модель второй ступени), варианты + * написания (регистр, формат даты, разделяющие слова) и несколько строк-ловушек без разметки + * (известный человек, адрес отделения, дата без якоря) — они не должны маскироваться вовсе. * - *

На каждой строке: маскирование не оставляет исходное значение ПДН в открытом - * виде, а демаскирование побайтово восстанавливает исходный текст. + *

На каждой строке: маскирование не оставляет исходное значение ПДН в открытом виде, а + * демаскирование побайтово восстанавливает исходный текст. */ class Dataset200Test { - private static final RuleRegistry REGISTRY = new RuleRegistry(); - private static final Masker MASKER = new Masker(); - private static final List DATASET = BenchmarkFixtures.load("/dataset-200.txt"); + private static final RuleRegistry REGISTRY = new RuleRegistry(); + private static final Masker MASKER = new Masker(); + private static final List DATASET = + BenchmarkFixtures.load("/dataset-200.txt"); - @TestFactory - Stream datasetOf200Cases() { - List cases = new java.util.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("#%03d: %s", index, preview(sample.text())), - () -> runCase(sample, index))); - } - return cases.stream(); + @TestFactory + Stream datasetOf200Cases() { + List cases = new java.util.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("#%03d: %s", index, preview(sample.text())), + () -> runCase(sample, index))); + } + return cases.stream(); + } + + private void runCase(BenchmarkFixtures.Sample sample, int index) { + Pipeline pipeline = new Pipeline(REGISTRY, MASKER, new PayloadStore(1_000_000L, 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) { - Pipeline pipeline = new Pipeline(REGISTRY, MASKER, new PayloadStore(1_000_000L, 30)); - String payloadId = "dataset200-" + index; + String restored = pipeline.process(masked, payloadId, SystemPolicy.DEFAULT); + assertEquals(sample.text(), restored, "демаскирование не восстановило исходный текст"); + } - 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); - } - - 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) + "..."; - } + private static String preview(String text) { + return text.length() <= 40 ? text : text.substring(0, 40) + "..."; + } } diff --git a/src/test/java/ru/pdguard/DateAndAddressTest.java b/src/test/java/ru/pdguard/DateAndAddressTest.java index 719178c..cc2cd50 100644 --- a/src/test/java/ru/pdguard/DateAndAddressTest.java +++ b/src/test/java/ru/pdguard/DateAndAddressTest.java @@ -1,5 +1,10 @@ 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 ru.pdguard.config.SystemPolicy; import ru.pdguard.core.PayloadStore; @@ -8,137 +13,136 @@ import ru.pdguard.detect.PdTypes; import ru.pdguard.detect.RuleRegistry; 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 { - private final Pipeline pipeline = - new Pipeline(new RuleRegistry(), new Masker(), new PayloadStore(1_000_000L, 30)); + private final Pipeline pipeline = + new Pipeline(new RuleRegistry(), new Masker(), new PayloadStore(1_000_000L, 30)); - private String mask(String text) { - return pipeline.process(text, UUID.randomUUID().toString(), SystemPolicy.DEFAULT); - } + private String mask(String text) { + return pipeline.process(text, UUID.randomUUID().toString(), SystemPolicy.DEFAULT); + } - private void assertHidden(String text, String secret) { - String masked = mask(text); - assertFalse(masked.contains(secret), "не замаскировано: «" + secret + "» в ответе «" + masked + "»"); - } + private void assertHidden(String text, String secret) { + String masked = mask(text); + assertFalse( + masked.contains(secret), "не замаскировано: «" + secret + "» в ответе «" + masked + "»"); + } - @Test - void masksBirthDateInAnyPartOrder() { - assertHidden("Дата рождения 12.05.1985", "12.05.1985"); - assertHidden("дата рождения: 05/12/1985", "05/12/1985"); - assertHidden("Дата рождения 1985-12-05", "1985-12-05"); - assertHidden("Родился 12-05-1985", "12-05-1985"); - assertHidden("12.05.1985 г.р. — данные клиента", "12.05.1985"); - } + @Test + void masksBirthDateInAnyPartOrder() { + assertHidden("Дата рождения 12.05.1985", "12.05.1985"); + assertHidden("дата рождения: 05/12/1985", "05/12/1985"); + assertHidden("Дата рождения 1985-12-05", "1985-12-05"); + assertHidden("Родился 12-05-1985", "12-05-1985"); + assertHidden("12.05.1985 г.р. — данные клиента", "12.05.1985"); + } - @Test - void masksBirthDateWrittenWithWords() { - assertHidden("Дата рождения: 12 мая 1985 года", "12 мая 1985"); - assertHidden("Дата рождения двенадцатого мая тысяча девятьсот восемьдесят пятого года", - "двенадцатого мая"); - assertHidden("Дата рождения: двадцать первого августа 1990 года", "двадцать первого августа"); - } + @Test + void masksBirthDateWrittenWithWords() { + assertHidden("Дата рождения: 12 мая 1985 года", "12 мая 1985"); + assertHidden( + "Дата рождения двенадцатого мая тысяча девятьсот восемьдесят пятого года", + "двенадцатого мая"); + assertHidden("Дата рождения: двадцать первого августа 1990 года", "двадцать первого августа"); + } - @Test - void keepsSeparatorsInMaskedDate() { - String masked = mask("Дата рождения 12.05.1985"); - assertTrue(masked.endsWith("**.**.****"), masked); - } + @Test + void keepsSeparatorsInMaskedDate() { + String masked = mask("Дата рождения 12.05.1985"); + assertTrue(masked.endsWith("**.**.****"), masked); + } - @Test - void masksPassportIssueDate() { - assertHidden("Паспорт 4509 123456, дата выдачи 12.05.2015", "12.05.2015"); - } + @Test + void masksPassportIssueDate() { + assertHidden("Паспорт 4509 123456, дата выдачи 12.05.2015", "12.05.2015"); + } - @Test - void doesNotMaskDateWithoutAnyOtherPersonalData() { - String text = "Встреча перенесена на 12.05.2025, подтвердите"; - assertEquals(text, mask(text), "дата сама по себе персональными данными не является"); - } + @Test + void doesNotMaskDateWithoutAnyOtherPersonalData() { + String text = "Встреча перенесена на 12.05.2025, подтвердите"; + assertEquals(text, mask(text), "дата сама по себе персональными данными не является"); + } - @Test - void masksBareDateWhenOtherPersonalDataIsPresent() { - assertHidden("Паспорт 4509 123456 оформлен 12.05.2015", "12.05.2015"); - } + @Test + void masksBareDateWhenOtherPersonalDataIsPresent() { + assertHidden("Паспорт 4509 123456 оформлен 12.05.2015", "12.05.2015"); + } - @Test - void doesNotTreatVersionOrAddressLikeNumbersAsDate() { - String text = "Сервер 192.168.1 отвечает, сборка 1.2.3 развёрнута"; - assertEquals(text, mask(text)); - } + @Test + void doesNotTreatVersionOrAddressLikeNumbersAsDate() { + String text = "Сервер 192.168.1 отвечает, сборка 1.2.3 развёрнута"; + assertEquals(text, mask(text)); + } - @Test - void masksAddressComponentsSeparately() { - String masked = mask("Адрес: 125009, г. Москва, ул. Тверская, д. 7, кв. 15"); - assertFalse(masked.contains("125009"), masked); - assertFalse(masked.contains("Москва"), masked); - assertFalse(masked.contains("Тверская"), masked); - assertTrue(masked.contains("г. "), "указатели вида «г.», «ул.» остаются: " + masked); - assertTrue(masked.contains("ул. "), masked); - } + @Test + void masksAddressComponentsSeparately() { + String masked = mask("Адрес: 125009, г. Москва, ул. Тверская, д. 7, кв. 15"); + assertFalse(masked.contains("125009"), masked); + assertFalse(masked.contains("Москва"), masked); + assertFalse(masked.contains("Тверская"), masked); + assertTrue(masked.contains("г. "), "указатели вида «г.», «ул.» остаются: " + masked); + assertTrue(masked.contains("ул. "), masked); + } - @Test - void streetNameDoesNotSwallowTheRestOfTheSentence() { - String masked = mask("Адрес клиента: ул. Сосновая перекрыта из-за ремонта"); - assertTrue(masked.contains("перекрыта из-за ремонта"), - "название улицы это одно-три слова, а не остаток предложения: " + masked); - assertFalse(masked.contains("Сосновая"), masked); - } + @Test + void streetNameDoesNotSwallowTheRestOfTheSentence() { + String masked = mask("Адрес клиента: ул. Сосновая перекрыта из-за ремонта"); + assertTrue( + masked.contains("перекрыта из-за ремонта"), + "название улицы это одно-три слова, а не остаток предложения: " + masked); + assertFalse(masked.contains("Сосновая"), masked); + } - @Test - void doesNotMaskStreetMentionedOutsideAnAddress() { - assertEquals("Проспект Мира перекрыт до вечера", mask("Проспект Мира перекрыт до вечера")); - assertEquals("Улица Весенняя названа в честь праздника", - mask("Улица Весенняя названа в честь праздника")); - } + @Test + void doesNotMaskStreetMentionedOutsideAnAddress() { + assertEquals("Проспект Мира перекрыт до вечера", mask("Проспект Мира перекрыт до вечера")); + assertEquals( + "Улица Весенняя названа в честь праздника", + mask("Улица Весенняя названа в честь праздника")); + } - @Test - void masksMultiWordStreetName() { - String masked = mask("Адрес: г. Москва, ул. Малая Никитская, д. 4"); - assertFalse(masked.contains("Малая Никитская"), masked); - } + @Test + void masksMultiWordStreetName() { + String masked = mask("Адрес: г. Москва, ул. Малая Никитская, д. 4"); + assertFalse(masked.contains("Малая Никитская"), masked); + } - @Test - void masksIndexByAnchorWord() { - assertHidden("Индекс 125009 для доставки клиенту Иванову, паспорт 4509 123456", "125009"); - } + @Test + void masksIndexByAnchorWord() { + assertHidden("Индекс 125009 для доставки клиенту Иванову, паспорт 4509 123456", "125009"); + } - @Test - void doesNotMaskBankBranchAddress() { - String text = "Отделение банка на улице Тверская, дом 7 работает до 20:00"; - assertEquals(text, mask(text), "адрес отделения банка персональными данными не является"); - } + @Test + void doesNotMaskBankBranchAddress() { + String text = "Отделение банка на улице Тверская, дом 7 работает до 20:00"; + assertEquals(text, mask(text), "адрес отделения банка персональными данными не является"); + } - @Test - void doesNotMaskOfficeAddress() { - String text = "Дополнительный офис, г. Москва, ул. Арбат, д. 1"; - assertEquals(text, mask(text)); - } + @Test + void doesNotMaskOfficeAddress() { + String text = "Дополнительный офис, г. Москва, ул. Арбат, д. 1"; + assertEquals(text, mask(text)); + } - @Test - void addressTypesAreConfigurableSeparately() { - SystemPolicy onlyCity = SystemPolicy.forTypes(PdTypes.ADDRESS_CITY); - String masked = pipeline.process("г. Москва, ул. Тверская, д. 7", "addr-1", onlyCity); + @Test + void addressTypesAreConfigurableSeparately() { + SystemPolicy onlyCity = SystemPolicy.forTypes(PdTypes.ADDRESS_CITY); + String masked = pipeline.process("г. Москва, ул. Тверская, д. 7", "addr-1", onlyCity); - assertFalse(masked.contains("Москва"), masked); - assertTrue(masked.contains("Тверская"), "улица этой системой не маскируется: " + masked); - } + assertFalse(masked.contains("Москва"), masked); + assertTrue(masked.contains("Тверская"), "улица этой системой не маскируется: " + masked); + } - @Test - void unmaskingRestoresTextWithDateAndAddress() { - String original = "Иванов, дата рождения 12.05.1985, адрес: 125009, г. Москва, " - + "ул. Тверская, д. 7, кв. 15, паспорт 4509 123456"; - String id = "date-addr-1"; + @Test + void unmaskingRestoresTextWithDateAndAddress() { + String original = + "Иванов, дата рождения 12.05.1985, адрес: 125009, г. Москва, " + + "ул. Тверская, д. 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)); - } + String masked = pipeline.process(original, id, SystemPolicy.DEFAULT); + assertFalse(masked.contains("12.05.1985"), masked); + assertEquals(original, pipeline.process(masked, id, SystemPolicy.DEFAULT)); + } } diff --git a/src/test/java/ru/pdguard/FioTest.java b/src/test/java/ru/pdguard/FioTest.java index deb63a4..88d1f57 100644 --- a/src/test/java/ru/pdguard/FioTest.java +++ b/src/test/java/ru/pdguard/FioTest.java @@ -1,5 +1,10 @@ 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 ru.pdguard.config.SystemPolicy; import ru.pdguard.core.PayloadStore; @@ -7,125 +12,121 @@ 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.assertEquals; -import static org.junit.jupiter.api.Assertions.assertFalse; -import static org.junit.jupiter.api.Assertions.assertTrue; - /** ФИО и защита от ложных срабатываний. */ class FioTest { - private final Pipeline pipeline = - new Pipeline(new RuleRegistry(), new Masker(), new PayloadStore(1_000_000L, 30)); + private final Pipeline pipeline = + new Pipeline(new RuleRegistry(), new Masker(), new PayloadStore(1_000_000L, 30)); - private String mask(String text) { - return pipeline.process(text, UUID.randomUUID().toString(), SystemPolicy.DEFAULT); - } + private String mask(String text) { + return pipeline.process(text, UUID.randomUUID().toString(), SystemPolicy.DEFAULT); + } - private void assertHidden(String text, String secret) { - String masked = mask(text); - assertFalse(masked.contains(secret), "не замаскировано: «" + secret + "» в ответе «" + masked + "»"); - } + private void assertHidden(String text, String secret) { + String masked = mask(text); + assertFalse( + masked.contains(secret), "не замаскировано: «" + secret + "» в ответе «" + masked + "»"); + } - private void assertUnchanged(String text) { - assertEquals(text, mask(text), "ложное срабатывание"); - } + private void assertUnchanged(String text) { + assertEquals(text, mask(text), "ложное срабатывание"); + } - @Test - void masksFullNameAsInitials() { - assertEquals("Клиент И. И. И. обратился", mask("Клиент Иванов Иван Иванович обратился")); - } + @Test + void masksFullNameAsInitials() { + assertEquals("Клиент И. И. И. обратился", mask("Клиент Иванов Иван Иванович обратился")); + } - @Test - void masksNameAndPatronymicWithoutSurname() { - assertHidden("Пригласите Ивана Сергеевича на встречу", "Ивана Сергеевича"); - } + @Test + void masksNameAndPatronymicWithoutSurname() { + assertHidden("Пригласите Ивана Сергеевича на встречу", "Ивана Сергеевича"); + } - @Test - void masksFemalePatronymic() { - assertHidden("Петрова Анна Ивановна подписала", "Петрова Анна Ивановна"); - assertHidden("Мария Никитична ждёт ответа", "Мария Никитична"); - } + @Test + void masksFemalePatronymic() { + assertHidden("Петрова Анна Ивановна подписала", "Петрова Анна Ивановна"); + assertHidden("Мария Никитична ждёт ответа", "Мария Никитична"); + } - @Test - void masksSurnameWithInitialsInBothOrders() { - assertHidden("Заявление от Иванов И.И. принято", "Иванов И.И."); - assertHidden("Подписал И.И. Иванов", "И.И. Иванов"); - } + @Test + void masksSurnameWithInitialsInBothOrders() { + assertHidden("Заявление от Иванов И.И. принято", "Иванов И.И."); + assertHidden("Подписал И.И. Иванов", "И.И. Иванов"); + } - @Test - void masksSurnameNextToKnownGivenName() { - assertHidden("Петров Сергей подтвердил заявку", "Петров Сергей"); - assertHidden("Сергей Петров подтвердил заявку", "Сергей Петров"); - assertHidden("Обращение Ольги Ковалёвой рассмотрено", "Ольги Ковалёвой"); - } + @Test + void masksSurnameNextToKnownGivenName() { + assertHidden("Петров Сергей подтвердил заявку", "Петров Сергей"); + assertHidden("Сергей Петров подтвердил заявку", "Сергей Петров"); + assertHidden("Обращение Ольги Ковалёвой рассмотрено", "Ольги Ковалёвой"); + } - @Test - void masksLowercaseNameAfterExplicitAnchor() { - assertHidden("ФИО: иванов иван иванович", "иванов иван иванович"); - assertHidden("Карта оформлена на имя петров сергей", "петров сергей"); - } + @Test + void masksLowercaseNameAfterExplicitAnchor() { + assertHidden("ФИО: иванов иван иванович", "иванов иван иванович"); + assertHidden("Карта оформлена на имя петров сергей", "петров сергей"); + } - @Test - void masksNameAfterRoleAnchor() { - assertHidden("Клиент Петров Сергей, заявка одобрена", "Петров Сергей"); - assertHidden("Плательщик Ковалёва подтвердила перевод", "Ковалёва"); - } + @Test + void masksNameAfterRoleAnchor() { + assertHidden("Клиент Петров Сергей, заявка одобрена", "Петров Сергей"); + assertHidden("Плательщик Ковалёва подтвердила перевод", "Ковалёва"); + } - @Test - void doesNotMaskWellKnownPerson() { - assertUnchanged("Напиши стихотворение в духе Александра Пушкина про осень"); - assertUnchanged("Сравни Толстого и Достоевского как прозаиков"); - assertUnchanged("Когда Гагарин полетел в космос"); - } + @Test + void doesNotMaskWellKnownPerson() { + assertUnchanged("Напиши стихотворение в духе Александра Пушкина про осень"); + assertUnchanged("Сравни Толстого и Достоевского как прозаиков"); + assertUnchanged("Когда Гагарин полетел в космос"); + } - @Test - void masksWellKnownSurnameWhenOtherPersonalDataIsPresent() { - assertHidden("Клиент Александр Пушкин, паспорт 4509 123456", "Александр Пушкин"); - } + @Test + void masksWellKnownSurnameWhenOtherPersonalDataIsPresent() { + assertHidden("Клиент Александр Пушкин, паспорт 4509 123456", "Александр Пушкин"); + } - @Test - void doesNotMaskPlaceNamesThatLookLikeSurnames() { - assertUnchanged("Московский Кремль открыт для посещения"); - assertUnchanged("Экскурсия в Нижний Новгород перенесена"); - assertUnchanged("Смоленская площадь закрыта на ремонт"); - } + @Test + void doesNotMaskPlaceNamesThatLookLikeSurnames() { + assertUnchanged("Московский Кремль открыт для посещения"); + assertUnchanged("Экскурсия в Нижний Новгород перенесена"); + assertUnchanged("Смоленская площадь закрыта на ремонт"); + } - @Test - void doesNotMaskOrdinaryCapitalisedWords() { - assertUnchanged("Банк Открытие подтвердил лимит"); - assertUnchanged("В Понедельник Отдел Согласует Договор"); - } + @Test + void doesNotMaskOrdinaryCapitalisedWords() { + assertUnchanged("Банк Открытие подтвердил лимит"); + assertUnchanged("В Понедельник Отдел Согласует Договор"); + } - @Test - void identificationIgnoresCase() { - assertHidden("ИВАНОВ ИВАН ИВАНОВИЧ", "ИВАНОВ ИВАН ИВАНОВИЧ"); - assertHidden("фио: петрова анна ивановна", "петрова анна ивановна"); - } + @Test + void identificationIgnoresCase() { + assertHidden("ИВАНОВ ИВАН ИВАНОВИЧ", "ИВАНОВ ИВАН ИВАНОВИЧ"); + assertHidden("фио: петрова анна ивановна", "петрова анна ивановна"); + } - @Test - void unmaskingRestoresNames() { - String original = "Клиент Иванов Иван Иванович, паспорт 4509 123456, " - + "дата рождения 12.05.1985, телефон +7 916 123-45-67"; - String id = "fio-1"; + @Test + void unmaskingRestoresNames() { + String original = + "Клиент Иванов Иван Иванович, паспорт 4509 123456, " + + "дата рождения 12.05.1985, телефон +7 916 123-45-67"; + String id = "fio-1"; - String masked = pipeline.process(original, id, SystemPolicy.DEFAULT); - assertFalse(masked.contains("Иванов Иван Иванович"), masked); - assertTrue(masked.contains("И. И. И."), masked); - assertEquals(original, pipeline.process(masked, id, SystemPolicy.DEFAULT)); - } + String masked = pipeline.process(original, id, SystemPolicy.DEFAULT); + assertFalse(masked.contains("Иванов Иван Иванович"), masked); + assertTrue(masked.contains("И. И. И."), masked); + assertEquals(original, pipeline.process(masked, id, SystemPolicy.DEFAULT)); + } - @Test - void namesStayFastOnLargeText() { - String block = "Клиент Иванов Иван Иванович, паспорт 4509 123456, город Москва. "; - String large = block.repeat(4000); + @Test + void namesStayFastOnLargeText() { + String block = "Клиент Иванов Иван Иванович, паспорт 4509 123456, город Москва. "; + String large = block.repeat(4000); - long started = System.nanoTime(); - String masked = pipeline.process(large, "fio-large", SystemPolicy.DEFAULT); - long millis = (System.nanoTime() - started) / 1_000_000; + long started = System.nanoTime(); + String masked = pipeline.process(large, "fio-large", SystemPolicy.DEFAULT); + long millis = (System.nanoTime() - started) / 1_000_000; - assertFalse(masked.contains("Иванов Иван Иванович")); - assertTrue(millis < 1000, "обработка заняла " + millis + " мс"); - } + assertFalse(masked.contains("Иванов Иван Иванович")); + assertTrue(millis < 1000, "обработка заняла " + millis + " мс"); + } } diff --git a/src/test/java/ru/pdguard/HugeDatasetTest.java b/src/test/java/ru/pdguard/HugeDatasetTest.java index 3b377a9..950cae8 100644 --- a/src/test/java/ru/pdguard/HugeDatasetTest.java +++ b/src/test/java/ru/pdguard/HugeDatasetTest.java @@ -1,5 +1,14 @@ 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.TestFactory; import ru.pdguard.config.SystemPolicy; @@ -9,149 +18,158 @@ import ru.pdguard.detect.RuleRegistry; import ru.pdguard.detect.Span; 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 из "Критерии_оценивания_альфа" - * (обработка текстов до 100 000 токенов) и стоп-сигнал по утечке ПДН из "критерии_фрейм_топы". + * Датасет из 1000 прогонов разной длины — критерий 3.5 из "Критерии_оценивания_альфа" (обработка + * текстов до 100 000 токенов) и стоп-сигнал по утечке ПДН из "критерии_фрейм_топы". * - *

Тексты строятся перемешиванием строк из уже существующих размеченных наборов - * {@code benchmark-*.txt} (17+ типов ПДН из ТЗ) — отдельный датасет с нуля не заводится, - * пул размеченных примеров и так покрывает все типы. Длина растёт от одного предложения - * до 400 000 знаков (100 000 токенов при 4 знака/токен — так же, как считает сам - * {@link Pipeline}); не менее {@link #HUGE_CASES} прогонов лежат в полосе 90 000-100 000 - * токенов. На каждом прогоне проверяются: отсутствие ПДН в открытом виде в замаскированном - * тексте и побайтовое совпадение после демаскирования; на прогонах из полосы 90-100к токенов - * дополнительно проверяется, что маскирование укладывается в 5 секунд. + *

Тексты строятся перемешиванием строк из уже существующих размеченных наборов {@code + * benchmark-*.txt} (17+ типов ПДН из ТЗ) — отдельный датасет с нуля не заводится, пул размеченных + * примеров и так покрывает все типы. Длина растёт от одного предложения до 400 000 знаков (100 000 + * токенов при 4 знака/токен — так же, как считает сам {@link Pipeline}); не менее {@link + * #HUGE_CASES} прогонов лежат в полосе 90 000-100 000 токенов. На каждом прогоне проверяются: + * отсутствие ПДН в открытом виде в замаскированном тексте и побайтовое совпадение после + * демаскирования; на прогонах из полосы 90-100к токенов дополнительно проверяется, что маскирование + * укладывается в 5 секунд. * *

Полный прогон класса занимает пару минут — это ожидаемо на объёме, требуемом ТЗ. */ class HugeDatasetTest { - private static final int TOTAL_CASES = 1000; - private static final int HUGE_CASES = 50; - 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_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 int TOTAL_CASES = 1000; + private static final int HUGE_CASES = 50; + 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_MAX_CHARS = 100_000 * CHARS_PER_TOKEN; - private static final RuleRegistry REGISTRY = new RuleRegistry(); - private static final Masker MASKER = new Masker(); - private static final List POOL = loadPool(); + /** + * Короткие значения (PIN, номер дома и т.п.) чаще случайно совпадают с посторонним текстом пула — + * их из проверки на утечку исключаем, длинные ПДН проверяем всегда. + */ + private static final int LEAK_CHECK_MIN_LENGTH = 6; - private static List loadPool() { - List 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)); + /** + * Допустимая доля утечек на прогон. Пул включает настоящие 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(); + private static final List POOL = loadPool(); + + private static List loadPool() { + List 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 datasetOfThousandCases() { + List 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(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++; } - 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 - Stream datasetOfThousandCases() { - List 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(); + String restored = pipeline.process(masked, payloadId, SystemPolicy.DEFAULT); + assertEquals(sample.text(), restored, "демаскирование не восстановило исходный текст"); + + if (targetChars >= HUGE_MIN_CHARS) { + assertTrue( + maskMillis < 5000, + "маскирование " + targetChars + " знаков заняло " + maskMillis + " мс"); } + } - /** - * Длина растёт по логарифмической шкале от предложения до порога "огромного" текста — - * так тесты покрывают все порядки величины, а не только маленькие и не только большие. - * Последние {@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; + /** Перемешивает строки пула детерминированно по seed и склеивает до нужного объёма. */ + private static BenchmarkFixtures.Sample buildText(int targetChars, long seed) { + List shuffled = new ArrayList<>(POOL); + Random random = new Random(seed); + StringBuilder text = new StringBuilder(targetChars + 1024); + List 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)); } - 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(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 (text.length() >= targetChars) { + break; } - 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 shuffled = new ArrayList<>(POOL); - Random random = new Random(seed); - StringBuilder text = new StringBuilder(targetChars + 1024); - List 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); + } } diff --git a/src/test/java/ru/pdguard/IdentityDocumentTest.java b/src/test/java/ru/pdguard/IdentityDocumentTest.java index 6355f8b..b04ee25 100644 --- a/src/test/java/ru/pdguard/IdentityDocumentTest.java +++ b/src/test/java/ru/pdguard/IdentityDocumentTest.java @@ -1,5 +1,9 @@ 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 ru.pdguard.config.SystemPolicy; import ru.pdguard.core.PayloadStore; @@ -7,64 +11,62 @@ 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.assertEquals; -import static org.junit.jupiter.api.Assertions.assertFalse; - /** Документы, удостоверяющие личность, помимо паспорта РФ. */ class IdentityDocumentTest { - private final Pipeline pipeline = - new Pipeline(new RuleRegistry(), new Masker(), new PayloadStore(1_000_000L, 30)); + private final Pipeline pipeline = + new Pipeline(new RuleRegistry(), new Masker(), new PayloadStore(1_000_000L, 30)); - private void assertHidden(String text, String secret) { - String masked = pipeline.process(text, UUID.randomUUID().toString(), SystemPolicy.DEFAULT); - assertFalse(masked.contains(secret), "не замаскировано: «" + secret + "» в ответе «" + masked + "»"); - } + private void assertHidden(String text, String secret) { + String masked = pipeline.process(text, UUID.randomUUID().toString(), SystemPolicy.DEFAULT); + assertFalse( + masked.contains(secret), "не замаскировано: «" + secret + "» в ответе «" + masked + "»"); + } - private void assertMasked(String text, String payloadId, String expected) { - assertEquals(expected, pipeline.process(text, payloadId, SystemPolicy.DEFAULT)); - } + private void assertMasked(String text, String payloadId, String expected) { + assertEquals(expected, pipeline.process(text, payloadId, SystemPolicy.DEFAULT)); + } - @Test - void masksForeignPassport() { - assertHidden("Загранпаспорт 75 1234567 действителен до 2030 года", "75 1234567"); - } + @Test + void masksForeignPassport() { + assertHidden("Загранпаспорт 75 1234567 действителен до 2030 года", "75 1234567"); + } - @Test - void masksMilitaryId() { - assertHidden("Военный билет АБ 1234567 предъявлен", "АБ 1234567"); - } + @Test + void masksMilitaryId() { + assertHidden("Военный билет АБ 1234567 предъявлен", "АБ 1234567"); + } - @Test - void masksBirthCertificate() { - assertHidden("Свидетельство о рождении II-МЮ № 123456", "II-МЮ № 123456"); - } + @Test + void masksBirthCertificate() { + assertHidden("Свидетельство о рождении II-МЮ № 123456", "II-МЮ № 123456"); + } - @Test - void masksMedicalPolicy() { - assertHidden("Полис ОМС 1234567890123456 оформлен", "1234567890123456"); - } + @Test + void masksMedicalPolicy() { + assertHidden("Полис ОМС 1234567890123456 оформлен", "1234567890123456"); + } - /** - * У загранпаспорта, военного билета и свидетельства о рождении серия короткая — - * две цифры или две буквы. Открой маска первые два знака, серия была бы видна - * целиком, поэтому у этих документов открыты только последние знаки номера. - */ - @Test - void hidesShortDocumentSeriesCompletely() { - assertMasked("Загранпаспорт 75 1234567", "fp-1", "Загранпаспорт ** *****67"); - assertMasked("Военный билет АБ 1234567", "mil-1", "Военный билет ** *****67"); - assertMasked("Свидетельство о рождении II-МЮ № 123456", "bc-1", - "Свидетельство о рождении **-** № ****56"); - } + /** + * У загранпаспорта, военного билета и свидетельства о рождении серия короткая — две цифры или две + * буквы. Открой маска первые два знака, серия была бы видна целиком, поэтому у этих документов + * открыты только последние знаки номера. + */ + @Test + void hidesShortDocumentSeriesCompletely() { + assertMasked("Загранпаспорт 75 1234567", "fp-1", "Загранпаспорт ** *****67"); + assertMasked("Военный билет АБ 1234567", "mil-1", "Военный билет ** *****67"); + assertMasked( + "Свидетельство о рождении II-МЮ № 123456", + "bc-1", + "Свидетельство о рождении **-** № ****56"); + } - /** У паспорта РФ и водительского удостоверения серия из четырёх знаков — открыта половина. */ - @Test - void keepsHalfOfFourCharacterSeries() { - assertMasked("Паспорт 4509 123456", "rf-1", "Паспорт 45** ****56"); - assertMasked("Водительское удостоверение 9902 123456", "dl-1", - "Водительское удостоверение 99** ****56"); - } + /** У паспорта РФ и водительского удостоверения серия из четырёх знаков — открыта половина. */ + @Test + void keepsHalfOfFourCharacterSeries() { + assertMasked("Паспорт 4509 123456", "rf-1", "Паспорт 45** ****56"); + assertMasked( + "Водительское удостоверение 9902 123456", "dl-1", "Водительское удостоверение 99** ****56"); + } } diff --git a/src/test/java/ru/pdguard/LargeTextTest.java b/src/test/java/ru/pdguard/LargeTextTest.java index e24fb36..61bcd13 100644 --- a/src/test/java/ru/pdguard/LargeTextTest.java +++ b/src/test/java/ru/pdguard/LargeTextTest.java @@ -1,14 +1,7 @@ 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.Span; -import ru.pdguard.detect.NameCascade; -import ru.pdguard.detect.RuleRegistry; -import ru.pdguard.mask.Masker; - +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; import java.nio.file.Files; import java.nio.file.Path; @@ -17,131 +10,143 @@ import java.util.Collections; import java.util.List; import java.util.Optional; import java.util.Random; - -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertTrue; +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.RuleRegistry; +import ru.pdguard.detect.Span; +import ru.pdguard.mask.Masker; /** - * Качество и скорость на большом тексте — не повторе одного и того же - * предложения, а перемешанных строках из {@code benchmark-generated.txt} - * (все типы ПД вперемешку с чистым текстом), растянутых до объёма из ТЗ - * (около 100 000 токенов, ~400 КБ по оценке из README). + * Качество и скорость на большом тексте — не повторе одного и того же предложения, а перемешанных + * строках из {@code benchmark-generated.txt} (все типы ПД вперемешку с чистым текстом), растянутых + * до объёма из ТЗ (около 100 000 токенов, ~400 КБ по оценке из README). * - *

Раздутый повтором одной строки текст проверяет только то, что цикл не - * падает на объёме: под маской всегда один и тот же тип, а остальные правила - * не задействуются вовсе. Здесь размер и разнообразие проверяются вместе. + *

Раздутый повтором одной строки текст проверяет только то, что цикл не падает на объёме: под + * маской всегда один и тот же тип, а остальные правила не задействуются вовсе. Здесь размер и + * разнообразие проверяются вместе. */ class LargeTextTest { - 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 ENGINE = System.getProperty("bench.engine", "rubert"); + private static final String MODEL_PATH = System.getProperty("bench.model", "models/rubert-ner"); - /** Целевой объём: README оценивает 100 000 токенов как ~400 КБ текста. */ - private static final int TARGET_CHARS = 400_000; + /** Целевой объём: README оценивает 100 000 токенов как ~400 КБ текста. */ + private static final int TARGET_CHARS = 400_000; - /** - * Перемешивает исходные строки (фиксированный seed — детерминированный - * тест) и склеивает их через перенос строки, пока не наберётся целевой - * объём. Смещения золотых фрагментов пересчитываются под общий текст. - */ - private static BenchmarkFixtures.Sample buildLargeText(int targetChars, long seed) { - List pool = new ArrayList<>(BenchmarkFixtures.load("/benchmark-generated.txt")); - Random random = new Random(seed); - StringBuilder text = new StringBuilder(targetChars + 1024); - List gold = new ArrayList<>(); + /** + * Перемешивает исходные строки (фиксированный seed — детерминированный тест) и склеивает их через + * перенос строки, пока не наберётся целевой объём. Смещения золотых фрагментов пересчитываются + * под общий текст. + */ + private static BenchmarkFixtures.Sample buildLargeText(int targetChars, long seed) { + List pool = + new ArrayList<>(BenchmarkFixtures.load("/benchmark-generated.txt")); + Random random = new Random(seed); + StringBuilder text = new StringBuilder(targetChars + 1024); + List gold = new ArrayList<>(); - while (text.length() < targetChars) { - Collections.shuffle(pool, random); - for (BenchmarkFixtures.Sample sample : pool) { - 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; - } - } + while (text.length() < targetChars) { + Collections.shuffle(pool, random); + for (BenchmarkFixtures.Sample sample : pool) { + 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)); } - return new BenchmarkFixtures.Sample(text.toString(), gold); - } - - /** - * Маскирование и обратное преобразование на большом тексте дают - * побайтово тот же результат, что и исходный текст — при объёме на - * порядок больше, чем в остальных тестах, и с разнородным содержимым, - * а не одним повторяющимся предложением. - */ - @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 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++; - } + if (text.length() >= targetChars) { + break; } - 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 - 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(large.text().length() * 2L, 30), - new NameCascade(ENGINE, Optional.of(MODEL_PATH), 16, 4)); + /** + * Маскирование и обратное преобразование на большом тексте дают побайтово тот же результат, что и + * исходный текст — при объёме на порядок больше, чем в остальных тестах, и с разнородным + * содержимым, а не одним повторяющимся предложением. + */ + @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 started = System.nanoTime(); - pipeline.process(large.text(), "large-cascade-1", SystemPolicy.DEFAULT); - long millis = (System.nanoTime() - started) / 1_000_000; + long maskStarted = System.nanoTime(); + String masked = pipeline.process(large.text(), "large-mixed-1", SystemPolicy.DEFAULT); + long maskMillis = (System.nanoTime() - maskStarted) / 1_000_000; - System.out.printf("%nБольшой текст со второй ступенью: %d знаков за %d мс%n", - large.text().length(), millis); - assertTrue(millis < 5000, "со второй ступенью обработка заняла " + millis + " мс"); + 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 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(large.text().length() * 2L, 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 + " мс"); + } } diff --git a/src/test/java/ru/pdguard/LeakDiagTest.java b/src/test/java/ru/pdguard/LeakDiagTest.java index d4699fb..83dd845 100644 --- a/src/test/java/ru/pdguard/LeakDiagTest.java +++ b/src/test/java/ru/pdguard/LeakDiagTest.java @@ -1,5 +1,14 @@ 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 ru.pdguard.config.SystemPolicy; import ru.pdguard.core.PayloadStore; @@ -9,73 +18,79 @@ import ru.pdguard.detect.RuleRegistry; import ru.pdguard.detect.Span; 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}. * - *

Датасет собран из логов pd-guard-node-logs.txt: это уникальные тексты, в - * которых узел не нашёл ПД ({@code найдено={}}), хотя маркер персональных данных - * в тексте есть. Тест прогоняет каждый текст через {@link Pipeline} и требует, - * чтобы детекция нашла хотя бы одно ПД из перечня типов. + *

Датасет собран из логов pd-guard-node-logs.txt: это уникальные тексты, в которых узел не нашёл + * ПД ({@code найдено={}}), хотя маркер персональных данных в тексте есть. Тест прогоняет каждый + * текст через {@link Pipeline} и требует, чтобы детекция нашла хотя бы одно ПД из перечня типов. */ class LeakDiagTest { - private static final String DATASET = "/leak-dataset.txt"; + private static final String DATASET = "/leak-dataset.txt"; - @Test - void checkLeaks() { - Pipeline p = new Pipeline(new RuleRegistry(), new Masker(), new PayloadStore(1_000_000L, 30)); - List leaks = readDataset(); + @Test + void checkLeaks() { + Pipeline p = new Pipeline(new RuleRegistry(), new Masker(), new PayloadStore(1_000_000L, 30)); + List leaks = readDataset(); - int fixed = 0; - List remaining = new ArrayList<>(); - for (String raw : leaks) { - List spans = p.findPersonalData(raw, SystemPolicy.DEFAULT); - boolean found = spans.stream().anyMatch(s -> s.type().equals(PdTypes.FIO) - || s.type().equals(PdTypes.BIRTH_DATE) || s.type().equals(PdTypes.PASSPORT_DATE) - || s.type().equals(PdTypes.CVV) || s.type().equals(PdTypes.PIN) - || s.type().equals(PdTypes.INN) || s.type().equals(PdTypes.PHONE) - || s.type().equals(PdTypes.CARD) || s.type().equals(PdTypes.DRIVER_LICENSE) - || s.type().equals(PdTypes.CITIZENSHIP) || s.type().equals(PdTypes.BIRTH_PLACE)); - if (found) { - fixed++; - } else { - remaining.add(raw); - } - } - System.out.println("Всего утечек: " + leaks.size() + ", исправлено: " + fixed + ", осталось: " + remaining.size()); - for (String raw : remaining) { - System.out.println(" ОСТАЛОСЬ: " + raw); - } - assertTrue(remaining.size() <= leaks.size() / 2, "осталось слишком много утечек: " + remaining.size()); + int fixed = 0; + List remaining = new ArrayList<>(); + for (String raw : leaks) { + List spans = p.findPersonalData(raw, SystemPolicy.DEFAULT); + boolean found = + spans.stream() + .anyMatch( + s -> + s.type().equals(PdTypes.FIO) + || s.type().equals(PdTypes.BIRTH_DATE) + || s.type().equals(PdTypes.PASSPORT_DATE) + || s.type().equals(PdTypes.CVV) + || s.type().equals(PdTypes.PIN) + || s.type().equals(PdTypes.INN) + || s.type().equals(PdTypes.PHONE) + || s.type().equals(PdTypes.CARD) + || s.type().equals(PdTypes.DRIVER_LICENSE) + || s.type().equals(PdTypes.CITIZENSHIP) + || s.type().equals(PdTypes.BIRTH_PLACE)); + if (found) { + fixed++; + } else { + remaining.add(raw); + } } - - private static List readDataset() { - List 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; + System.out.println( + "Всего утечек: " + + leaks.size() + + ", исправлено: " + + fixed + + ", осталось: " + + remaining.size()); + for (String raw : remaining) { + System.out.println(" ОСТАЛОСЬ: " + raw); } -} \ No newline at end of file + assertTrue( + remaining.size() <= leaks.size() / 2, "осталось слишком много утечек: " + remaining.size()); + } + + private static List readDataset() { + List 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; + } +} diff --git a/src/test/java/ru/pdguard/MaskModeTest.java b/src/test/java/ru/pdguard/MaskModeTest.java index c7c0dad..b1ae168 100644 --- a/src/test/java/ru/pdguard/MaskModeTest.java +++ b/src/test/java/ru/pdguard/MaskModeTest.java @@ -1,5 +1,13 @@ 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 ru.pdguard.config.SystemPolicy; import ru.pdguard.core.PayloadStore; @@ -9,88 +17,92 @@ import ru.pdguard.detect.Validators; import ru.pdguard.mask.MaskMode; 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 { - private final Pipeline pipeline = - new Pipeline(new RuleRegistry(), new Masker(), new PayloadStore(1_000_000L, 30)); + private final Pipeline pipeline = + new Pipeline(new RuleRegistry(), new Masker(), new PayloadStore(1_000_000L, 30)); - private SystemPolicy policy(MaskMode mode) { - return new SystemPolicy(SystemPolicy.DEFAULT_NAME, true, true, mode, - Set.of(SystemPolicy.ALL), SystemPolicy.DEFAULT.requireCompanion(), null); + private SystemPolicy policy(MaskMode mode) { + return new SystemPolicy( + 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) { - 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 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; + 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; + } } diff --git a/src/test/java/ru/pdguard/NameCascadeTest.java b/src/test/java/ru/pdguard/NameCascadeTest.java index d6f67d1..9d952d7 100644 --- a/src/test/java/ru/pdguard/NameCascadeTest.java +++ b/src/test/java/ru/pdguard/NameCascadeTest.java @@ -1,5 +1,13 @@ 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.io.TempDir; import ru.pdguard.config.SystemPolicy; @@ -9,51 +17,46 @@ import ru.pdguard.detect.NameCascade; import ru.pdguard.detect.RuleRegistry; 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 { - private static final String TEXT = "Клиент Иванов Иван Иванович, паспорт 4509 123456"; + private static final String TEXT = "Клиент Иванов Иван Иванович, паспорт 4509 123456"; - private String mask(NameCascade cascade, String payloadId) { - Pipeline pipeline = new Pipeline(new RuleRegistry(), new Masker(), - new PayloadStore(1_000_000L, 30), cascade); - return pipeline.process(TEXT, payloadId, SystemPolicy.DEFAULT); + private String mask(NameCascade cascade, String payloadId) { + Pipeline pipeline = + new Pipeline(new RuleRegistry(), new Masker(), new PayloadStore(1_000_000L, 30), cascade); + 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 - 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); - } - - NameCascade cascade = new NameCascade("rubert", Optional.of(broken.toString()), 16, 4); - assertFalse(cascade.enabled(), "испорченная модель должна выключать ступень"); - assertEquals("Клиент И. И. И., паспорт 45** ****56", mask(cascade, "broken-1"), - "маскирование по правилам обязано работать и без второй ступени"); - } + NameCascade cascade = new NameCascade("rubert", Optional.of(broken.toString()), 16, 4); + assertFalse(cascade.enabled(), "испорченная модель должна выключать ступень"); + assertEquals( + "Клиент И. И. И., паспорт 45** ****56", + mask(cascade, "broken-1"), + "маскирование по правилам обязано работать и без второй ступени"); + } } diff --git a/src/test/java/ru/pdguard/OrganisationNamesTest.java b/src/test/java/ru/pdguard/OrganisationNamesTest.java index 2c938ef..11ac6df 100644 --- a/src/test/java/ru/pdguard/OrganisationNamesTest.java +++ b/src/test/java/ru/pdguard/OrganisationNamesTest.java @@ -1,5 +1,9 @@ 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 ru.pdguard.config.SystemPolicy; import ru.pdguard.core.PayloadStore; @@ -7,86 +11,91 @@ 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.assertEquals; -import static org.junit.jupiter.api.Assertions.assertFalse; - /** - * Имя в названии организации или объекта на карте персональными данными не является. - * Решает слово перед именем, а не само имя: однофамилец защиту не теряет. + * Имя в названии организации или объекта на карте персональными данными не является. Решает слово + * перед именем, а не само имя: однофамилец защиту не теряет. */ class OrganisationNamesTest { - private final Pipeline pipeline = - new Pipeline(new RuleRegistry(), new Masker(), new PayloadStore(1_000_000L, 30)); + private final Pipeline pipeline = + new Pipeline(new RuleRegistry(), new Masker(), new PayloadStore(1_000_000L, 30)); - private String mask(String text) { - return pipeline.process(text, UUID.randomUUID().toString(), SystemPolicy.DEFAULT); - } + private String mask(String text) { + return pipeline.process(text, UUID.randomUUID().toString(), SystemPolicy.DEFAULT); + } - @Test - void keepsNamesInsideInstitutionNames() { - for (String text : new String[]{ - "Институт Мечникова принимает по записи", - "Музей Верещагина работает по будням", - "Театр Станиславского открыл сезон", - "Библиотека Некрасова закрыта на ремонт", - "Премия имени Ломоносова вручена в декабре", - "Больница Боткина приняла пациентов", - "Стадион Яшина отремонтирован"}) { - assertEquals(text, mask(text), "имя в названии учреждения маскировать не нужно"); - } + @Test + void keepsNamesInsideInstitutionNames() { + for (String text : + new String[] { + "Институт Мечникова принимает по записи", + "Музей Верещагина работает по будням", + "Театр Станиславского открыл сезон", + "Библиотека Некрасова закрыта на ремонт", + "Премия имени Ломоносова вручена в декабре", + "Больница Боткина приняла пациентов", + "Стадион Яшина отремонтирован" + }) { + assertEquals(text, mask(text), "имя в названии учреждения маскировать не нужно"); } + } - @Test - void keepsNamesInsidePlaceNames() { - for (String text : new String[]{ - "Улица Королёва названа в честь конструктора", - "Проспект Вернадского перекрыт до вечера", - "Площадь Гагарина находится на юго-западе", - "Набережная Макарова уходит к заливу", - "Мост Кадырова разведут ночью"}) { - assertEquals(text, mask(text), "топоним маскировать не нужно"); - } + @Test + void keepsNamesInsidePlaceNames() { + for (String text : + new String[] { + "Улица Королёва названа в честь конструктора", + "Проспект Вернадского перекрыт до вечера", + "Площадь Гагарина находится на юго-западе", + "Набережная Макарова уходит к заливу", + "Мост Кадырова разведут ночью" + }) { + assertEquals(text, mask(text), "топоним маскировать не нужно"); } + } - @Test - void masksRealClientWithTheSameSurname() { - String masked = mask("Клиент Королёв Сергей Павлович, паспорт 4509 123456"); - assertFalse(masked.contains("Королёв Сергей Павлович"), - "однофамилец объекта на карте остаётся под защитой: " + masked); - } + @Test + void masksRealClientWithTheSameSurname() { + String masked = mask("Клиент Королёв Сергей Павлович, паспорт 4509 123456"); + assertFalse( + masked.contains("Королёв Сергей Павлович"), + "однофамилец объекта на карте остаётся под защитой: " + masked); + } - @Test - void markerOnlyCountsRightBeforeTheName() { - String masked = mask("Больница приняла Иванова Ивана Ивановича с жалобой"); - assertFalse(masked.contains("Иванова Ивана Ивановича"), - "слово-маркер действует только вплотную перед именем: " + masked); - } + @Test + void markerOnlyCountsRightBeforeTheName() { + String masked = mask("Больница приняла Иванова Ивана Ивановича с жалобой"); + assertFalse( + masked.contains("Иванова Ивана Ивановича"), + "слово-маркер действует только вплотную перед именем: " + masked); + } - @Test - void keepsRulerNames() { - for (String text : new String[]{ - "Василий Тёмный правил недолго", - "Ярослав Мудрый составил свод законов", - "Екатерина Вторая издала указ", - "Алексей Тишайший принимал послов"}) { - assertEquals(text, mask(text), "имя правителя персональными данными не является"); - } + @Test + void keepsRulerNames() { + for (String text : + new String[] { + "Василий Тёмный правил недолго", + "Ярослав Мудрый составил свод законов", + "Екатерина Вторая издала указ", + "Алексей Тишайший принимал послов" + }) { + assertEquals(text, mask(text), "имя правителя персональными данными не является"); } + } - @Test - void masksClientEvenIfNameLooksRegnal() { - String masked = mask("Клиент Василий Тёмный, паспорт 4509 123456"); - assertFalse(masked.contains("Василий Тёмный"), - "рядом с паспортными данными это конкретный человек: " + masked); - } + @Test + void masksClientEvenIfNameLooksRegnal() { + String masked = mask("Клиент Василий Тёмный, паспорт 4509 123456"); + assertFalse( + masked.contains("Василий Тёмный"), + "рядом с паспортными данными это конкретный человек: " + masked); + } - @Test - void doesNotSuppressSoleTraderName() { - String masked = mask("ИП Пахомов Вениамин Николаевич, ИНН 502601234547"); - assertFalse(masked.contains("Пахомов Вениамин Николаевич"), - "имя предпринимателя — это персональные данные: " + masked); - } + @Test + void doesNotSuppressSoleTraderName() { + String masked = mask("ИП Пахомов Вениамин Николаевич, ИНН 502601234547"); + assertFalse( + masked.contains("Пахомов Вениамин Николаевич"), + "имя предпринимателя — это персональные данные: " + masked); + } } diff --git a/src/test/java/ru/pdguard/PayloadCipherTest.java b/src/test/java/ru/pdguard/PayloadCipherTest.java index 014e5e6..060f645 100644 --- a/src/test/java/ru/pdguard/PayloadCipherTest.java +++ b/src/test/java/ru/pdguard/PayloadCipherTest.java @@ -1,50 +1,54 @@ 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 ru.pdguard.core.PayloadCipher; import ru.pdguard.core.PayloadStore; -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertNotEquals; - /** Шифрование персональных данных в хранилище. */ class PayloadCipherTest { - /** 32 байта в hex — валидный AES-256 ключ. */ - private static final String KEY = "000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f"; + /** 32 байта в hex — валидный AES-256 ключ. */ + private static final String KEY = + "000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f"; - @Test - void encryptDecryptRoundTrip() { - PayloadCipher cipher = new PayloadCipher(KEY); - String original = "Клиент Иванов Иван Иванович, паспорт 4509 123456"; - String encrypted = cipher.encrypt(original); - assertNotEquals(original, encrypted, "шифротекст не должен совпадать с исходником"); - assertEquals(original, cipher.decrypt(encrypted), "должно расшифроваться обратно"); - } + @Test + void encryptDecryptRoundTrip() { + PayloadCipher cipher = new PayloadCipher(KEY); + String original = "Клиент Иванов Иван Иванович, паспорт 4509 123456"; + String encrypted = cipher.encrypt(original); + assertNotEquals(original, encrypted, "шифротекст не должен совпадать с исходником"); + assertEquals(original, cipher.decrypt(encrypted), "должно расшифроваться обратно"); + } - @Test - void disabledCipherPassesThrough() { - PayloadCipher cipher = PayloadCipher.disabled(); - String original = "Клиент Иванов"; - assertEquals(original, cipher.encrypt(original), "без ключа шифрование выключено"); - assertEquals(original, cipher.decrypt(original), "без ключа дешифрование выключено"); - } + @Test + void disabledCipherPassesThrough() { + PayloadCipher cipher = PayloadCipher.disabled(); + String original = "Клиент Иванов"; + assertEquals(original, cipher.encrypt(original), "без ключа шифрование выключено"); + assertEquals(original, cipher.decrypt(original), "без ключа дешифрование выключено"); + } - @Test - void storeStoresEncryptedButReturnsPlaintext() { - PayloadCipher cipher = new PayloadCipher(KEY); - PayloadStore store = new PayloadStore(1_000_000L, 30, ru.pdguard.core.SharedIndex.disabled(), cipher); + @Test + void storeStoresEncryptedButReturnsPlaintext() { + PayloadCipher cipher = new PayloadCipher(KEY); + PayloadStore store = + new PayloadStore(1_000_000L, 30, ru.pdguard.core.SharedIndex.disabled(), cipher); - String original = "Клиент Иванов Иван Иванович, паспорт 4509 123456"; - String masked = "Клиент И. И. И., паспорт 45** ****56"; - store.put("test", "id-1", original, masked); + String original = "Клиент Иванов Иван Иванович, паспорт 4509 123456"; + String masked = "Клиент И. И. И., паспорт 45** ****56"; + store.put("test", "id-1", original, masked); - // Чтение по id возвращает исходный текст. - PayloadStore.Entry entry = store.byId("test", "id-1"); - assertEquals(original, entry.original(), "чтение по id должно вернуть исходный текст"); + // Чтение по id возвращает исходный текст. + PayloadStore.Entry entry = store.byId("test", "id-1"); + assertEquals(original, entry.original(), "чтение по id должно вернуть исходный текст"); - // Чтение по маске возвращает исходный текст. - assertEquals(original, store.originalForMask("test", masked), - "чтение по маске должно вернуть исходный текст"); - } -} \ No newline at end of file + // Чтение по маске возвращает исходный текст. + assertEquals( + original, + store.originalForMask("test", masked), + "чтение по маске должно вернуть исходный текст"); + } +} diff --git a/src/test/java/ru/pdguard/PayloadStoreTest.java b/src/test/java/ru/pdguard/PayloadStoreTest.java index a2c0b26..019d70a 100644 --- a/src/test/java/ru/pdguard/PayloadStoreTest.java +++ b/src/test/java/ru/pdguard/PayloadStoreTest.java @@ -1,73 +1,77 @@ 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.assertNotNull; 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 { - private static final String SYSTEM = "crm"; + private static final String SYSTEM = "crm"; - @Test - void returnsWhatWasStored() { - PayloadStore store = new PayloadStore(1_000_000L, 30); - store.put(SYSTEM, "id", "исходный текст", "маска"); + @Test + void returnsWhatWasStored() { + PayloadStore store = new PayloadStore(1_000_000L, 30); + store.put(SYSTEM, "id", "исходный текст", "маска"); - PayloadStore.Entry entry = store.byId(SYSTEM, "id"); - assertNotNull(entry); - assertEquals("исходный текст", entry.original()); - assertEquals("маска", entry.masked()); - assertEquals("исходный текст", store.originalForMask(SYSTEM, "маска")); + PayloadStore.Entry entry = store.byId(SYSTEM, "id"); + assertNotNull(entry); + assertEquals("исходный текст", entry.original()); + assertEquals("маска", entry.masked()); + assertEquals("исходный текст", store.originalForMask(SYSTEM, "маска")); + } + + @Test + void forgetsEntriesAfterTheirLifetime() { + PayloadStore store = new PayloadStore(1_000_000L, 0); + store.put(SYSTEM, "id", "исходный текст", "маска"); + + assertNull(store.byId(SYSTEM, "id"), "запись с истёкшим сроком жизни не должна отдаваться"); + assertNull(store.originalForMask(SYSTEM, "маска")); + } + + @Test + void evictsOldestWhenOverSizeLimit() { + PayloadStore store = new PayloadStore(100L, 30); + for (int i = 0; i < 50; i++) { + store.put(SYSTEM, "id" + i, "текст номер " + i, "маска номер " + i); } - @Test - void forgetsEntriesAfterTheirLifetime() { - PayloadStore store = new PayloadStore(1_000_000L, 0); - store.put(SYSTEM, "id", "исходный текст", "маска"); + assertTrue(store.charsHeld() <= 100, "объём хранилища вышел за предел: " + store.charsHeld()); + assertNull(store.byId(SYSTEM, "id0"), "самая старая запись должна быть вытеснена"); + assertNotNull(store.byId(SYSTEM, "id49"), "последняя запись должна остаться"); + } - assertNull(store.byId(SYSTEM, "id"), "запись с истёкшим сроком жизни не должна отдаваться"); - assertNull(store.originalForMask(SYSTEM, "маска")); - } + @Test + void unknownKeysReturnNothing() { + PayloadStore store = new PayloadStore(1_000_000L, 30); + assertNull(store.byId(SYSTEM, "нет такого")); + assertNull(store.originalForMask(SYSTEM, "нет такой маски")); + } - @Test - void evictsOldestWhenOverSizeLimit() { - PayloadStore store = new PayloadStore(100L, 30); - for (int i = 0; i < 50; i++) { - store.put(SYSTEM, "id" + i, "текст номер " + i, "маска номер " + i); - } + /** + * Поиск по маске идёт только внутри своей системы. Маски детерминированы и низкоэнтропийны: без + * разделения чужую маску можно было бы подобрать и обменять на исходные данные другого + * потребителя. + */ + @Test + void oneSystemCannotReadAnotherSystemData() { + PayloadStore store = new PayloadStore(1_000_000L, 30); + store.put("crm", "общий-id", "Иванов Иван Иванович", "И. И. И."); - assertTrue(store.charsHeld() <= 100, "объём хранилища вышел за предел: " + store.charsHeld()); - assertNull(store.byId(SYSTEM, "id0"), "самая старая запись должна быть вытеснена"); - assertNotNull(store.byId(SYSTEM, "id49"), "последняя запись должна остаться"); - } - - @Test - void unknownKeysReturnNothing() { - PayloadStore store = new PayloadStore(1_000_000L, 30); - assertNull(store.byId(SYSTEM, "нет такого")); - assertNull(store.originalForMask(SYSTEM, "нет такой маски")); - } - - /** - * Поиск по маске идёт только внутри своей системы. Маски детерминированы и - * низкоэнтропийны: без разделения чужую маску можно было бы подобрать и обменять - * на исходные данные другого потребителя. - */ - @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", "И. И. И."), - "своя система свои данные по-прежнему получает"); - } + assertNull( + store.originalForMask("analytics", "И. И. И."), + "чужую маску нельзя обменять на исходный текст"); + assertNull( + store.byId("analytics", "общий-id"), + "совпадение идентификатора у другой системы не даёт доступа"); + assertEquals( + "Иванов Иван Иванович", + store.originalForMask("crm", "И. И. И."), + "своя система свои данные по-прежнему получает"); + } } diff --git a/src/test/java/ru/pdguard/PdnTypeEfficiencyTest.java b/src/test/java/ru/pdguard/PdnTypeEfficiencyTest.java index 1d36059..3427074 100644 --- a/src/test/java/ru/pdguard/PdnTypeEfficiencyTest.java +++ b/src/test/java/ru/pdguard/PdnTypeEfficiencyTest.java @@ -1,5 +1,12 @@ 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 ru.pdguard.config.SystemPolicy; import ru.pdguard.core.PayloadStore; @@ -8,142 +15,146 @@ import ru.pdguard.detect.RuleRegistry; import ru.pdguard.detect.Span; 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; - /** * Оценка эффективности детекции по каждому типу ПДН в отдельности. * - *

Набор {@code benchmark-pdn-types.txt} содержит по несколько примеров каждого - * типа ПДН. Для каждого типа считается посимвольная точность, полнота и F1 — - * так видно, какие типы детектор находит надёжно, а какие пропускает или - * маскирует сверх меры. + *

Набор {@code benchmark-pdn-types.txt} содержит по несколько примеров каждого типа ПДН. Для + * каждого типа считается посимвольная точность, полнота и F1 — так видно, какие типы детектор + * находит надёжно, а какие пропускает или маскирует сверх меры. */ class PdnTypeEfficiencyTest { - private final Pipeline pipeline = - new Pipeline(new RuleRegistry(), new Masker(), new PayloadStore(10_000_000L, 30)); + private final Pipeline pipeline = + new Pipeline(new RuleRegistry(), new Masker(), new PayloadStore(10_000_000L, 30)); - /** Накопитель посимвольных совпадений по одному типу. */ - private static final class Score { - private int truePositive; - private int falsePositive; - private int falseNegative; + /** Накопитель посимвольных совпадений по одному типу. */ + private static final class Score { + private int truePositive; + private int falsePositive; + private int falseNegative; - private int gold() { - 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); - } + private int gold() { + return truePositive + falseNegative; } - @Test - void efficiencyByPdnType() { - List samples = BenchmarkFixtures.load("/benchmark-pdn-types.txt"); - Map byType = new LinkedHashMap<>(); - Map> missed = new LinkedHashMap<>(); - - for (BenchmarkFixtures.Sample sample : samples) { - List 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 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 double precision() { + int found = truePositive + falsePositive; + return found == 0 ? 1.0 : (double) truePositive / found; } - private void reportMissed(Map> missed) { - if (missed.isEmpty()) { - return; + 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 + void efficiencyByPdnType() { + List samples = BenchmarkFixtures.load("/benchmark-pdn-types.txt"); + Map byType = new LinkedHashMap<>(); + Map> missed = new LinkedHashMap<>(); + + for (BenchmarkFixtures.Sample sample : samples) { + List 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 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> 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 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) { - return "CVV".equals(type) || "PIN".equals(type) || "DATE".equals(type); + private static void account(Map 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++; + } } - - /** Раскрашивает каждый знак текста типом ПД, который его покрывает. */ - private static String[] paint(int length, List 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; + if (found != null && !found.equals(gold)) { + byType.computeIfAbsent(found, t -> new Score()).falsePositive++; } + } - private static void account(Map 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 byType) { + StringBuilder out = new StringBuilder(2048); + out.append("\n=== Эффективность детекции по типам ПДН ===\n\n"); + out.append( + String.format("%-20s %8s %8s %8s %8s%n", "тип", "знаков", "точность", "полнота", "F1")); - private void report(Map 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 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()))); - byType.entrySet().stream() - .sorted(Comparator.comparingInt((Map.Entry 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); - } -} \ No newline at end of file + System.out.println(out); + } +} diff --git a/src/test/java/ru/pdguard/PerformanceBenchmarkTest.java b/src/test/java/ru/pdguard/PerformanceBenchmarkTest.java index dddbf33..367e7d8 100644 --- a/src/test/java/ru/pdguard/PerformanceBenchmarkTest.java +++ b/src/test/java/ru/pdguard/PerformanceBenchmarkTest.java @@ -1,15 +1,10 @@ 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.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.Path; import java.util.ArrayList; @@ -21,191 +16,214 @@ import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; import java.util.concurrent.Future; import java.util.concurrent.TimeUnit; - -import static org.junit.jupiter.api.Assumptions.assumeTrue; - -import static org.junit.jupiter.api.Assertions.assertTrue; +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.RuleRegistry; +import ru.pdguard.mask.Masker; /** - * Замер производительности: задержка одиночного обращения и пропускная - * способность под нагрузкой. Не тест качества — он в {@link BenchmarkTest}. + * Замер производительности: задержка одиночного обращения и пропускная способность под нагрузкой. + * Не тест качества — он в {@link BenchmarkTest}. * - *

Прогон идёт на одних правилах (вторая ступень выключена), как в боевой - * сборке без модели. Перед замером пайплайн прогревается, чтобы JIT успел - * скомпилировать горячий путь, — иначе первые замеры покажут интерпретируемый - * код и занизят результат в разы. + *

Прогон идёт на одних правилах (вторая ступень выключена), как в боевой сборке без модели. + * Перед замером пайплайн прогревается, чтобы JIT успел скомпилировать горячий путь, — иначе первые + * замеры покажут интерпретируемый код и занизят результат в разы. */ class PerformanceBenchmarkTest { - /** Типовой текст с ПД — как в реальном обращении. */ - private static final String[] PAYLOADS = { - "Клиент Иванов Иван Иванович, паспорт 4509 123456, тел +7 916 123-45-67", - "Заявление от И.И. Петрова, ИНН 770301234550, почта ivan.petrov@mail.ru", - "Адрес: 125009, г. Москва, ул. Тверская, д. 7, кв. 15, карта 4111 1111 1111 1111", - "Дата рождения 12.05.1985, место рождения: город Тверь, гражданство РФ", - "Напиши краткое описание продукта для рассылки клиентам банка", - }; + /** Типовой текст с ПД — как в реальном обращении. */ + private static final String[] PAYLOADS = { + "Клиент Иванов Иван Иванович, паспорт 4509 123456, тел +7 916 123-45-67", + "Заявление от И.И. Петрова, ИНН 770301234550, почта ivan.petrov@mail.ru", + "Адрес: 125009, г. Москва, ул. Тверская, д. 7, кв. 15, карта 4111 1111 1111 1111", + "Дата рождения 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 MEASURE = 50_000; + private static final int WARMUP = 20_000; + private static final int MEASURE = 50_000; - private final Pipeline pipeline = - new Pipeline(new RuleRegistry(), new Masker(), new PayloadStore(10_000_000L, 30)); + private final Pipeline pipeline = + new Pipeline(new RuleRegistry(), new Masker(), new PayloadStore(10_000_000L, 30)); - private void warmup() { - for (int i = 0; i < WARMUP; i++) { - String text = PAYLOADS[i % PAYLOADS.length]; - String id = "warmup-" + i; - pipeline.process(text, id, SystemPolicy.DEFAULT); - } + private void warmup() { + for (int i = 0; i < WARMUP; i++) { + String text = PAYLOADS[i % PAYLOADS.length]; + String id = "warmup-" + i; + 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; } - /** Задержка маскирования и демаскирования типового обращения. */ - @Test - void singleRequestLatency() { - warmup(); + Arrays.sort(maskNanos); + Arrays.sort(unmaskNanos); - long[] maskNanos = new long[MEASURE]; - long[] unmaskNanos = new long[MEASURE]; + double maskUs = nanosToMicros(maskNanos); + double unmaskUs = nanosToMicros(unmaskNanos); - for (int i = 0; i < MEASURE; i++) { - String text = PAYLOADS[i % PAYLOADS.length]; - String id = "lat-" + i; + System.out.printf("%n=== Задержка одиночного обращения (правила, без модели) ===%n"); + System.out.printf( + "Маскирование: 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(); - String masked = pipeline.process(text, id, SystemPolicy.DEFAULT); - maskNanos[i] = System.nanoTime() - t0; + // Целевая задержка из ТЗ — 200 мс; типовое обращение должно укладываться в миллисекунды. + assertTrue( + 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); - unmaskNanos[i] = System.nanoTime() - t1; - } + /** Пропускная способность под нагрузкой: сколько обращений в секунду выдерживает пайплайн. */ + @Test + void throughputUnderLoad() throws Exception { + warmup(); - Arrays.sort(maskNanos); - Arrays.sort(unmaskNanos); + int threads = Math.max(4, Runtime.getRuntime().availableProcessors()); + int perThread = 10_000; + ExecutorService pool = Executors.newFixedThreadPool(threads); - double maskUs = nanosToMicros(maskNanos); - double unmaskUs = nanosToMicros(unmaskNanos); - - System.out.printf("%n=== Задержка одиночного обращения (правила, без модели) ===%n"); - System.out.printf("Маскирование: 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); - - // Целевая задержка из ТЗ — 200 мс; типовое обращение должно укладываться в миллисекунды. - assertTrue(maskNanos[(int) (MEASURE * 0.99)] < 5_000_000, - "p99 маскирования превысил 5 мс: " + maskNanos[(int) (MEASURE * 0.99)] / 1_000_000 + " мс"); + long started = System.nanoTime(); + List> futures = new ArrayList<>(); + for (int t = 0; t < threads; t++) { + final int threadId = t; + futures.add( + pool.submit( + (Callable) + () -> { + 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; + })); } - /** Пропускная способность под нагрузкой: сколько обращений в секунду выдерживает пайплайн. */ - @Test - void throughputUnderLoad() throws Exception { - warmup(); + long totalNanos = 0; + for (Future f : futures) { + totalNanos += f.get(); + } + long wallNanos = System.nanoTime() - started; + pool.shutdown(); + pool.awaitTermination(30, TimeUnit.SECONDS); - int threads = Math.max(4, Runtime.getRuntime().availableProcessors()); - int perThread = 10_000; - ExecutorService pool = Executors.newFixedThreadPool(threads); + int requests = threads * perThread; + double rps = requests / (wallNanos / 1e9); + double avgUs = totalNanos / (double) requests / 1000.0; - long started = System.nanoTime(); - List> futures = new ArrayList<>(); - for (int t = 0; t < threads; t++) { - final int threadId = t; - futures.add(pool.submit((Callable) () -> { - 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; - })); - } + System.out.printf( + "%n=== Пропускная способность (%d потоков, %d обращений) ===%n", threads, requests); + System.out.printf("RPS: %.0f обращений/с средняя задержка: %.1f мкс%n", rps, avgUs); - long totalNanos = 0; - for (Future f : futures) { - totalNanos += f.get(); - } - long wallNanos = System.nanoTime() - started; - pool.shutdown(); - pool.awaitTermination(30, TimeUnit.SECONDS); + assertTrue(rps > 1000, "пропускная способность ниже 1000 RPS: " + rps); + } - int requests = threads * perThread; - double rps = requests / (wallNanos / 1e9); - double avgUs = totalNanos / (double) requests / 1000.0; + private static double nanosToMicros(long[] nanos) { + long sum = 0; + for (long n : nanos) { + sum += n; + } + return sum / (double) nanos.length / 1000.0; + } - System.out.printf("%n=== Пропускная способность (%d потоков, %d обращений) ===%n", threads, requests); - System.out.printf("RPS: %.0f обращений/с средняя задержка: %.1f мкс%n", rps, avgUs); + /** Задержка со второй ступенью (ruBERT). Модель должна быть собрана. */ + @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(10_000_000L, 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 sum = 0; - for (long n : nanos) { - sum += n; - } - return sum / (double) nanos.length / 1000.0; + 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; } - /** Задержка со второй ступенью (ruBERT). Модель должна быть собрана. */ - @Test - void singleRequestLatencyWithNameCascade() { - Path model = Path.of("models/rubert-ner"); - assumeTrue(Files.isReadable(model), "модель " + model.toAbsolutePath() + " не собрана"); + 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)); - MeterRegistry meters = new SimpleMeterRegistry(); - Pipeline withCascade = new Pipeline(new RuleRegistry(), new Masker(), - new PayloadStore(10_000_000L, 30), - new NameCascade("rubert", Optional.of(model.toString()), "off", Optional.empty(), 16, 4, meters)); + 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); - // Прогрев второй ступени: модель инициализируется лениво, первые вызовы медленные. - for (int i = 0; i < 200; i++) { - String text = CASCADE_PAYLOADS[i % CASCADE_PAYLOADS.length]; - withCascade.process(text, "cascade-warmup-" + i, SystemPolicy.DEFAULT); - } - - 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 + " мс"); - } -} \ No newline at end of file + // Целевая задержка из ТЗ — 200 мс; даже со второй ступенью типовое обращение + // должно укладываться в десятки миллисекунд. + assertTrue( + maskNanos[(int) (n * 0.99)] < 200_000_000, + "p99 маскирования со второй ступенью превысил 200 мс: " + + maskNanos[(int) (n * 0.99)] / 1_000_000 + + " мс"); + } +} diff --git a/src/test/java/ru/pdguard/PipelineTest.java b/src/test/java/ru/pdguard/PipelineTest.java index b5f55c1..bdfb181 100644 --- a/src/test/java/ru/pdguard/PipelineTest.java +++ b/src/test/java/ru/pdguard/PipelineTest.java @@ -1,5 +1,11 @@ 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 ru.pdguard.config.SystemPolicy; import ru.pdguard.core.PayloadStore; @@ -8,132 +14,127 @@ import ru.pdguard.detect.PdTypes; import ru.pdguard.detect.RuleRegistry; 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-слоя. */ class PipelineTest { - private static final String VALID_CARD = "4111 1111 1111 1111"; - private static final String VALID_INN_12 = "770301234550"; - private static final String VALID_SNILS = "112-233-445 95"; + private static final String VALID_CARD = "4111 1111 1111 1111"; + private static final String VALID_INN_12 = "770301234550"; + private static final String VALID_SNILS = "112-233-445 95"; - private Pipeline pipeline() { - return new Pipeline(new RuleRegistry(), new Masker(), new PayloadStore(1_000_000L, 30)); + private Pipeline pipeline() { + return new Pipeline(new RuleRegistry(), new Masker(), new PayloadStore(1_000_000L, 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) { - return pipeline.process(text, UUID.randomUUID().toString(), SystemPolicy.DEFAULT); - } + @Test + 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 - 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 masksSnils() { + String masked = mask(pipeline(), "СНИЛС " + VALID_SNILS); + assertFalse(masked.contains(VALID_SNILS), masked); + } - @Test - void keepsNumberThatFailsLuhn() { - String text = "Заказ 1234 5678 9012 3456 отгружен"; - assertEquals(text, mask(pipeline(), text)); - } + @Test + void unmaskingRestoresOriginalText() { + Pipeline pipeline = pipeline(); + String original = "Карта " + VALID_CARD + ", почта ivan@mail.ru, телефон +7 916 123-45-67"; + String id = "pair-1"; - @Test - void masksEmailKeepingTopLevelDomain() { - String masked = mask(pipeline(), "Почта ivan.petrov@mail.ru для связи"); - assertEquals("Почта i**********@m***.ru для связи", masked); - } + String masked = pipeline.process(original, id, SystemPolicy.DEFAULT); + assertNotEquals(original, 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); - } - } + String restored = pipeline.process(masked, id, SystemPolicy.DEFAULT); + assertEquals(original, restored); + } - @Test - 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 + void retryReturnsSameMask() { + Pipeline pipeline = pipeline(); + String original = "Карта " + VALID_CARD; + String id = "retry-1"; - @Test - void masksSnils() { - String masked = mask(pipeline(), "СНИЛС " + VALID_SNILS); - assertFalse(masked.contains(VALID_SNILS), masked); - } + String first = pipeline.process(original, id, SystemPolicy.DEFAULT); + String second = pipeline.process(original, id, SystemPolicy.DEFAULT); + assertEquals(first, second); + } - @Test - void unmaskingRestoresOriginalText() { - Pipeline pipeline = pipeline(); - String original = "Карта " + VALID_CARD + ", почта ivan@mail.ru, телефон +7 916 123-45-67"; - String id = "pair-1"; + @Test + void unmasksWhenPayloadIdIsUnknown() { + Pipeline pipeline = pipeline(); + String original = "Почта ivan@mail.ru"; + String masked = pipeline.process(original, "lost-id", SystemPolicy.DEFAULT); - String masked = pipeline.process(original, id, SystemPolicy.DEFAULT); - assertNotEquals(original, masked); + assertEquals(original, pipeline.process(masked, "другой-идентификатор", SystemPolicy.DEFAULT)); + } - String restored = pipeline.process(masked, id, SystemPolicy.DEFAULT); - assertEquals(original, restored); - } + @Test + void textWithoutPersonalDataIsUnchanged() { + String text = "Расскажи о погоде в Москве завтра"; + assertEquals(text, mask(pipeline(), text)); + } - @Test - void retryReturnsSameMask() { - Pipeline pipeline = pipeline(); - String original = "Карта " + VALID_CARD; - String id = "retry-1"; + @Test + void systemPolicyDisablesSelectedTypes() { + Pipeline pipeline = pipeline(); + SystemPolicy onlyEmail = SystemPolicy.forTypes(PdTypes.EMAIL); + String masked = + pipeline.process("Карта " + VALID_CARD + ", почта ivan@mail.ru", "policy-1", onlyEmail); - String first = pipeline.process(original, id, SystemPolicy.DEFAULT); - String second = pipeline.process(original, id, SystemPolicy.DEFAULT); - assertEquals(first, second); - } + assertTrue( + masked.contains(VALID_CARD), "карта не должна маскироваться этой системой: " + masked); + assertFalse(masked.contains("ivan@mail.ru"), masked); + } - @Test - void unmasksWhenPayloadIdIsUnknown() { - Pipeline pipeline = pipeline(); - String original = "Почта ivan@mail.ru"; - String masked = pipeline.process(original, "lost-id", SystemPolicy.DEFAULT); + @Test + void handlesLargeText() { + Pipeline pipeline = pipeline(); + String block = "Клиент написал с адреса ivan@mail.ru и оплатил картой " + VALID_CARD + ". "; + 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 - void textWithoutPersonalDataIsUnchanged() { - String text = "Расскажи о погоде в Москве завтра"; - 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 + " мс"); - } + assertFalse(masked.contains("ivan@mail.ru")); + assertEquals(large, pipeline.process(masked, "large-1", SystemPolicy.DEFAULT)); + assertTrue(millis < 1000, "обработка крупного текста заняла " + millis + " мс"); + } } diff --git a/src/test/java/ru/pdguard/PlacementVariantsTest.java b/src/test/java/ru/pdguard/PlacementVariantsTest.java index c1fb9ca..ee02cf1 100644 --- a/src/test/java/ru/pdguard/PlacementVariantsTest.java +++ b/src/test/java/ru/pdguard/PlacementVariantsTest.java @@ -1,5 +1,15 @@ 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.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.Test; import org.junit.jupiter.api.TestFactory; @@ -11,111 +21,109 @@ import ru.pdguard.detect.RuleRegistry; import ru.pdguard.detect.Span; 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 сгенерированных строк — не новые ТИПЫ ПДН, а новые РАЗМЕЩЕНИЯ уже - * известных типов относительно якорного слова: расстояние до якоря, обратный порядок - * (значение перед якорем), падеж/число анкера, структурированные форматы - * (JSON/CSV/markdown-таблица/XML/key=value), несколько значений одного типа в одном - * поле, значение в кавычках/скобках. Гипотезы построены на 55 утечках из - * {@link NodeLogsDatasetTest} и обобщают их корневые причины на другие типы и формы. + * Датасет из 44 сгенерированных строк — не новые ТИПЫ ПДН, а новые РАЗМЕЩЕНИЯ уже известных типов + * относительно якорного слова: расстояние до якоря, обратный порядок (значение перед якорем), + * падеж/число анкера, структурированные форматы (JSON/CSV/markdown-таблица/XML/key=value), + * несколько значений одного типа в одном поле, значение в кавычках/скобках. Гипотезы построены на + * 55 утечках из {@link NodeLogsDatasetTest} и обобщают их корневые причины на другие типы и формы. * Разбор по категориям — в отчёте, приложенном к задаче. * - *

Как и {@link NodeLogsDatasetTest}, часть строк — подтверждённые утечки - * (падение конкретного кейса в {@link #leakSummary()} ожидаемо), часть — контрольные - * позитивные случаи, которые обязаны оставаться зелёными: если один из них упадёт, - * значит новое размещение сломало то, что раньше работало. + *

Как и {@link NodeLogsDatasetTest}, часть строк — подтверждённые утечки (падение конкретного + * кейса в {@link #leakSummary()} ожидаемо), часть — контрольные позитивные случаи, которые обязаны + * оставаться зелёными: если один из них упадёт, значит новое размещение сломало то, что раньше + * работало. */ class PlacementVariantsTest { - private static final RuleRegistry REGISTRY = new RuleRegistry(); - private static final Masker MASKER = new Masker(); - private static final List DATASET = BenchmarkFixtures.load("/dataset-placements.txt"); - private static final int LEAK_CHECK_MIN_LENGTH = 3; + private static final RuleRegistry REGISTRY = new RuleRegistry(); + private static final Masker MASKER = new Masker(); + private static final List DATASET = + BenchmarkFixtures.load("/dataset-placements.txt"); + private static final int LEAK_CHECK_MIN_LENGTH = 3; - private static final NameCascade CASCADE = modelsPresent() - ? new NameCascade("wikineural", Optional.of("models/wikineural-ner"), - "rubert", Optional.of("models/rubert-ner"), 16, 4) - : NameCascade.disabled(); + private static final NameCascade CASCADE = + modelsPresent() + ? new NameCascade( + "wikineural", + Optional.of("models/wikineural-ner"), + "rubert", + Optional.of("models/rubert-ner"), + 16, + 4) + : NameCascade.disabled(); - private static boolean modelsPresent() { - return Files.isReadable(Path.of("models/wikineural-ner/model.onnx")) - && Files.isReadable(Path.of("models/rubert-ner/model.onnx")); + private static boolean modelsPresent() { + return Files.isReadable(Path.of("models/wikineural-ner/model.onnx")) + && Files.isReadable(Path.of("models/rubert-ner/model.onnx")); + } + + @TestFactory + Stream placementDataset() { + List 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 placementDataset() { - List 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))); + /** Демаскирование обязано восстановить исходный текст всегда, независимо от утечек. */ + private void runRoundTrip(BenchmarkFixtures.Sample sample, int index) { + Pipeline pipeline = new Pipeline(REGISTRY, MASKER, new PayloadStore(1_000_000L, 30), CASCADE); + String payloadId = "placement-" + index; + + String masked = pipeline.process(sample.text(), payloadId, SystemPolicy.DEFAULT); + String restored = pipeline.process(masked, payloadId, SystemPolicy.DEFAULT); + 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 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; } - return cases.stream(); - } - - /** Демаскирование обязано восстановить исходный текст всегда, независимо от утечек. */ - private void runRoundTrip(BenchmarkFixtures.Sample sample, int index) { - Pipeline pipeline = new Pipeline(REGISTRY, MASKER, new PayloadStore(1_000_000L, 30), CASCADE); - String payloadId = "placement-" + index; - - String masked = pipeline.process(sample.text(), payloadId, SystemPolicy.DEFAULT); - String restored = pipeline.process(masked, payloadId, SystemPolicy.DEFAULT); - 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 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)); - } - } + 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); + } } + 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); - private static String preview(String text) { - return text.length() <= 50 ? text : text.substring(0, 50) + "..."; - } + assertTrue( + leaked <= 9, + "утечек стало больше 9 (было после точечных фиксов RuleRegistry) — новая регрессия: " + + leaked); + } + + private static String preview(String text) { + return text.length() <= 50 ? text : text.substring(0, 50) + "..."; + } } diff --git a/src/test/java/ru/pdguard/ProcessResourceTest.java b/src/test/java/ru/pdguard/ProcessResourceTest.java index 6ed03b4..4044de2 100644 --- a/src/test/java/ru/pdguard/ProcessResourceTest.java +++ b/src/test/java/ru/pdguard/ProcessResourceTest.java @@ -1,214 +1,294 @@ 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 org.hamcrest.Matchers.equalTo; 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) class ProcessResourceTest { - @LocalServerPort - int port; + @LocalServerPort int port; - private String post(String payload, String payloadId) { - return given() - .port(port) - .contentType("application/json") - .body(Map.of("payload", payload, "payload_id", payloadId)) - .when().post("/process") - .then().statusCode(200) - .extract().path("result"); - } + private String post(String payload, String payloadId) { + return given() + .port(port) + .contentType("application/json") + .body(Map.of("payload", payload, "payload_id", payloadId)) + .when() + .post("/process") + .then() + .statusCode(200) + .extract() + .path("result"); + } - @Test - void maskAndUnmaskPair() { - String original = "Клиент оставил почту ivan.petrov@mail.ru и телефон +7 916 123-45-67"; - String id = "8a77d363c7c044b49b41d7b8a448243a"; + @Test + void maskAndUnmaskPair() { + String original = "Клиент оставил почту ivan.petrov@mail.ru и телефон +7 916 123-45-67"; + String id = "8a77d363c7c044b49b41d7b8a448243a"; - String masked = post(original, id); - org.junit.jupiter.api.Assertions.assertNotEquals(original, masked); - org.junit.jupiter.api.Assertions.assertEquals(original, post(masked, id)); - } + String masked = post(original, id); + org.junit.jupiter.api.Assertions.assertNotEquals(original, masked); + org.junit.jupiter.api.Assertions.assertEquals(original, post(masked, id)); + } - @Test - void rejectsRequestWithoutRequiredFields() { + @Test + 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() - .port(port) - .contentType("application/json") - .body(Map.of("payload", "текст")) - .when().post("/process") - .then().statusCode(400); - } + .port(port) + .contentType("application/json") + .header("X-System-Id", "crm") + .body(Map.of("payload", "Клиент Иванов Иван Иванович", "payload_id", "sys-2")) + .when() + .post("/process") + .then() + .statusCode(200) + .extract() + .path("result"); - @Test - void healthProbeResponds() { - given().port(port).when().get("/health").then().statusCode(200).body(equalTo("OK")); - } + org.junit.jupiter.api.Assertions.assertTrue(masked.contains("[FIO_1]"), masked); + } - @Test - void disabledSystemIsRefused() { + @Test + 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() - .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); - } + .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 systemPolicySelectsMaskMode() { - String masked = given() - .port(port) - .contentType("application/json") - .header("X-System-Id", "crm") - .body(Map.of("payload", "Клиент Иванов Иван Иванович", "payload_id", "sys-2")) - .when().post("/process") - .then().statusCode(200) - .extract().path("result"); + /** + * Соответствия разделены по системам. Маски детерминированы и низкоэнтропийны, поэтому без + * разделения, прислав чужую маску, можно было бы получить исходные данные другого потребителя. + */ + @Test + void anotherSystemCannotExchangeMaskForOriginal() { + String original = "Клиент Иванов Иван Иванович, паспорт 4509 123456"; + String id = "cross-system-1"; - org.junit.jupiter.api.Assertions.assertTrue(masked.contains("[FIO_1]"), masked); - } - - @Test - void unknownSystemFallsBackToDefaultPolicy() { + String masked = 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")); - } + .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); - @Test - 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() { + String byOther = 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(""))); - } -} \ No newline at end of file + .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() + .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(""))); + } +} diff --git a/src/test/java/ru/pdguard/ProxyResourceTest.java b/src/test/java/ru/pdguard/ProxyResourceTest.java index d1432d4..1a6de5f 100644 --- a/src/test/java/ru/pdguard/ProxyResourceTest.java +++ b/src/test/java/ru/pdguard/ProxyResourceTest.java @@ -1,88 +1,102 @@ 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 org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; 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 → демаскирование. */ @SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) class ProxyResourceTest { - @LocalServerPort - int port; + @LocalServerPort int port; - private static final String PROMPT = - "Составь письмо клиенту Иванову Ивану Ивановичу, паспорт 4509 123456, почта ivan@mail.ru"; + private static final String PROMPT = + "Составь письмо клиенту Иванову Ивану Ивановичу, паспорт 4509 123456, почта ivan@mail.ru"; - private JsonPath proxy(String prompt) { - return given() - .port(port) - .contentType("application/json") - .body(Map.of("prompt", prompt)) - .when().post("/proxy") - .then().statusCode(200) - .extract().jsonPath(); - } + private JsonPath proxy(String prompt) { + return given() + .port(port) + .contentType("application/json") + .body(Map.of("prompt", prompt)) + .when() + .post("/proxy") + .then() + .statusCode(200) + .extract() + .jsonPath(); + } - @Test - void personalDataDoesNotReachTheModel() { - JsonPath json = proxy(PROMPT); - String toModel = json.getString("prompt_masked"); + @Test + void personalDataDoesNotReachTheModel() { + JsonPath json = proxy(PROMPT); + String toModel = json.getString("prompt_masked"); - assertFalse(toModel.contains("Иванову Ивану Ивановичу"), toModel); - assertFalse(toModel.contains("4509 123456"), toModel); - assertFalse(toModel.contains("ivan@mail.ru"), toModel); - } + assertFalse(toModel.contains("Иванову Ивану Ивановичу"), toModel); + assertFalse(toModel.contains("4509 123456"), toModel); + assertFalse(toModel.contains("ivan@mail.ru"), toModel); + } - @Test - void consumerGetsTheAnswerWithOriginalValues() { - JsonPath json = proxy(PROMPT); - String answer = json.getString("response"); + @Test + void consumerGetsTheAnswerWithOriginalValues() { + JsonPath json = proxy(PROMPT); + String answer = json.getString("response"); - assertTrue(answer.contains("Иванову Ивану Ивановичу"), answer); - assertTrue(answer.contains("4509 123456"), answer); - assertTrue(answer.contains("ivan@mail.ru"), answer); - } + assertTrue(answer.contains("Иванову Ивану Ивановичу"), answer); + assertTrue(answer.contains("4509 123456"), answer); + assertTrue(answer.contains("ivan@mail.ru"), answer); + } - @Test - void responseShowsTheWholeChain() { - JsonPath json = proxy(PROMPT); + @Test + void responseShowsTheWholeChain() { + JsonPath json = proxy(PROMPT); - assertTrue(json.getString("prompt_masked").contains("[FIO_1]"), - "в модель уходит обратимая подстановка: " + json.getString("prompt_masked")); - assertTrue(json.getString("llm_response_masked").contains("[FIO_1]"), - "ответ модели ещё содержит подстановки"); - assertFalse(json.getString("response").contains("[FIO_1]"), - "потребителю подстановки не видны"); - assertEquals("заглушка", json.getString("llm")); - assertFalse(json.getMap("replaced").isEmpty(), "таблица замен не должна быть пустой"); - } + assertTrue( + json.getString("prompt_masked").contains("[FIO_1]"), + "в модель уходит обратимая подстановка: " + json.getString("prompt_masked")); + assertTrue( + json.getString("llm_response_masked").contains("[FIO_1]"), + "ответ модели ещё содержит подстановки"); + assertFalse(json.getString("response").contains("[FIO_1]"), "потребителю подстановки не видны"); + assertEquals("заглушка", json.getString("llm")); + assertFalse(json.getMap("replaced").isEmpty(), "таблица замен не должна быть пустой"); + } - @Test - void textWithoutPersonalDataPassesThrough() { - JsonPath json = proxy("Объясни разницу между вкладом и накопительным счётом"); - assertEquals("Объясни разницу между вкладом и накопительным счётом", json.getString("prompt_masked")); - } + @Test + void textWithoutPersonalDataPassesThrough() { + JsonPath json = proxy("Объясни разницу между вкладом и накопительным счётом"); + assertEquals( + "Объясни разницу между вкладом и накопительным счётом", json.getString("prompt_masked")); + } - @Test - void rejectsEmptyPrompt() { - given().port(port).contentType("application/json").body(Map.of("prompt", " ")) - .when().post("/proxy").then().statusCode(400); - } + @Test + void rejectsEmptyPrompt() { + given() + .port(port) + .contentType("application/json") + .body(Map.of("prompt", " ")) + .when() + .post("/proxy") + .then() + .statusCode(400); + } - @Test - void disabledSystemIsRefused() { - given().port(port).contentType("application/json") - .header("X-System-Id", "disabled") - .body(Map.of("prompt", PROMPT)) - .when().post("/proxy").then().statusCode(403); - } -} \ No newline at end of file + @Test + void disabledSystemIsRefused() { + given() + .port(port) + .contentType("application/json") + .header("X-System-Id", "disabled") + .body(Map.of("prompt", PROMPT)) + .when() + .post("/proxy") + .then() + .statusCode(403); + } +} diff --git a/src/test/java/ru/pdguard/SettlementTest.java b/src/test/java/ru/pdguard/SettlementTest.java index 98fe341..4032497 100644 --- a/src/test/java/ru/pdguard/SettlementTest.java +++ b/src/test/java/ru/pdguard/SettlementTest.java @@ -1,5 +1,9 @@ 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 ru.pdguard.config.SystemPolicy; import ru.pdguard.core.PayloadStore; @@ -7,113 +11,107 @@ 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.assertEquals; -import static org.junit.jupiter.api.Assertions.assertFalse; - /** - * Адрес не только в городе: правило {@code ADDRESS_CITY} расширено якорями - * на сёла, посёлки, деревни, хутора, станицы, аулы и аалы — раньше словарь - * ограничивался официальными городами (~1100), и «рп. Ильинское»/«с. Кукуево» - * из ТЗ не находились вообще, дело было не в качестве детекции, а в том, что - * искать было негде. + * Адрес не только в городе: правило {@code ADDRESS_CITY} расширено якорями на сёла, посёлки, + * деревни, хутора, станицы, аулы и аалы — раньше словарь ограничивался официальными городами + * (~1100), и «рп. Ильинское»/«с. Кукуево» из ТЗ не находились вообще, дело было не в качестве + * детекции, а в том, что искать было негде. */ class SettlementTest { - private final Pipeline pipeline = - new Pipeline(new RuleRegistry(), new Masker(), new PayloadStore(1_000_000L, 30)); + private final Pipeline pipeline = + new Pipeline(new RuleRegistry(), new Masker(), new PayloadStore(1_000_000L, 30)); - private String mask(String text) { - return pipeline.process(text, UUID.randomUUID().toString(), SystemPolicy.DEFAULT); - } + private String mask(String text) { + return pipeline.process(text, UUID.randomUUID().toString(), SystemPolicy.DEFAULT); + } - private void assertHidden(String text, String secret) { - String masked = mask(text); - assertFalse(masked.contains(secret), "не замаскировано: «" + secret + "» в ответе «" + masked + "»"); - } + private void assertHidden(String text, String secret) { + String masked = mask(text); + assertFalse( + masked.contains(secret), "не замаскировано: «" + secret + "» в ответе «" + masked + "»"); + } - @Test - void masksVillage() { - assertHidden("Клиент проживает в с. Кукуево постоянно", "Кукуево"); - } + @Test + void masksVillage() { + assertHidden("Клиент проживает в с. Кукуево постоянно", "Кукуево"); + } - @Test - void masksWorkersSettlement() { - assertHidden("Регистрация по адресу: рп. Ильинское", "Ильинское"); - } + @Test + void masksWorkersSettlement() { + assertHidden("Регистрация по адресу: рп. Ильинское", "Ильинское"); + } - @Test - void masksUrbanTypeSettlement() { - assertHidden("Доставка курьером в пгт. Энем", "Энем"); - } + @Test + void masksUrbanTypeSettlement() { + assertHidden("Доставка курьером в пгт. Энем", "Энем"); + } - @Test - void masksHamlet() { - assertHidden("Дом находится в д. Аксеновка", "Аксеновка"); - } + @Test + void masksHamlet() { + assertHidden("Дом находится в д. Аксеновка", "Аксеновка"); + } - @Test - void masksFarmstead() { - assertHidden("Клиент родом из х. Прогресс", "Прогресс"); - } + @Test + void masksFarmstead() { + assertHidden("Клиент родом из х. Прогресс", "Прогресс"); + } - @Test - void masksCossackStanitsa() { - assertHidden("Прописан в ст-ца Гиагинская", "Гиагинская"); - } + @Test + void masksCossackStanitsa() { + assertHidden("Прописан в ст-ца Гиагинская", "Гиагинская"); + } - @Test - void masksAul() { - assertHidden("Живёт в аул Блечепсин", "Блечепсин"); - } + @Test + void masksAul() { + assertHidden("Живёт в аул Блечепсин", "Блечепсин"); + } - @Test - void masksSloboda() { - assertHidden("Проживает в сл. Екатериновка", "Екатериновка"); - } + @Test + void masksSloboda() { + assertHidden("Проживает в сл. Екатериновка", "Екатериновка"); + } - @Test - void masksAal() { - assertHidden("Зарегистрирован в аал Сартыков", "Сартыков"); - } + @Test + void masksAal() { + assertHidden("Зарегистрирован в аал Сартыков", "Сартыков"); + } - /** Словарь не подтверждает выдуманное название — якорь один в один, а маски нет. */ - @Test - void doesNotMaskFictionalPlaceNameDespiteRealAnchor() { - String text = "Клиент проживает в с. Мнимогорск"; - assertEquals(text, mask(text), "выдуманное название не должно проходить словарь"); - } + /** Словарь не подтверждает выдуманное название — якорь один в один, а маски нет. */ + @Test + void doesNotMaskFictionalPlaceNameDespiteRealAnchor() { + String text = "Клиент проживает в с. Мнимогорск"; + assertEquals(text, mask(text), "выдуманное название не должно проходить словарь"); + } - /** Вето по организационному контексту работает и для новых типов НП, не только для городов. */ - @Test - void doesNotMaskOrganisationAddressInVillage() { - String text = "Ближайшее отделение банка находится в с. Кукуево"; - assertEquals(text, mask(text), "адрес отделения банка не является ПД клиента"); - } + /** Вето по организационному контексту работает и для новых типов НП, не только для городов. */ + @Test + void doesNotMaskOrganisationAddressInVillage() { + String text = "Ближайшее отделение банка находится в с. Кукуево"; + assertEquals(text, mask(text), "адрес отделения банка не является ПД клиента"); + } - @Test - void anchorsAreCaseInsensitive() { - assertHidden("клиент проживает в С. КУКУЕВО", "КУКУЕВО"); - } + @Test + void anchorsAreCaseInsensitive() { + assertHidden("клиент проживает в С. КУКУЕВО", "КУКУЕВО"); + } - /** Реалистичное предложение: населённый пункт, улица и дом вместе. */ - @Test - void masksSettlementStreetAndHouseTogether() { - String masked = mask("Проживает по адресу: д. Аксеновка, ул. Садовая, д. 7"); - assertFalse(masked.contains("Аксеновка"), masked); - assertFalse(masked.contains("Садовая"), masked); - assertFalse(masked.contains("д. 7"), masked); - } + /** Реалистичное предложение: населённый пункт, улица и дом вместе. */ + @Test + void masksSettlementStreetAndHouseTogether() { + String masked = mask("Проживает по адресу: д. Аксеновка, ул. Садовая, д. 7"); + assertFalse(masked.contains("Аксеновка"), masked); + assertFalse(masked.contains("Садовая"), masked); + assertFalse(masked.contains("д. 7"), masked); + } - /** - * «с.» перед числом — обычная запись страницы («с. 25»), а не населённого - * пункта. Якорь не должен на этом срабатывать: правило требует заглавную - * букву сразу после якоря, а не цифру. - */ - @Test - void pageReferenceIsNotMistakenForSettlement() { - String text = "См. с. 25 договора"; - assertEquals(text, mask(text), "номер страницы не должен приниматься за населённый пункт"); - } + /** + * «с.» перед числом — обычная запись страницы («с. 25»), а не населённого пункта. Якорь не должен + * на этом срабатывать: правило требует заглавную букву сразу после якоря, а не цифру. + */ + @Test + void pageReferenceIsNotMistakenForSettlement() { + String text = "См. с. 25 договора"; + assertEquals(text, mask(text), "номер страницы не должен приниматься за населённый пункт"); + } } diff --git a/src/test/java/ru/pdguard/StreetDenylistTest.java b/src/test/java/ru/pdguard/StreetDenylistTest.java index 8c0cb9a..acc00ec 100644 --- a/src/test/java/ru/pdguard/StreetDenylistTest.java +++ b/src/test/java/ru/pdguard/StreetDenylistTest.java @@ -1,5 +1,9 @@ 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 ru.pdguard.config.SystemPolicy; import ru.pdguard.core.PayloadStore; @@ -7,87 +11,81 @@ 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.assertEquals; -import static org.junit.jupiter.api.Assertions.assertFalse; - /** - * Составные названия улиц в честь людей — тот же класс ложных срабатываний, - * что и «Богдана Хмельницкого» на реальных адресах Альфа-Банка: правило ФИО - * без ролевого слова ловит «имя + фамилия по словообразованию», а улица в - * честь исторической фигуры выглядит точно так же. Одиночная фамилия («улица - * Ленина») под это правило не подпадает вообще — ему нужны два слова, поэтому - * все примеры здесь двухсловные, реальные названия улиц. + * Составные названия улиц в честь людей — тот же класс ложных срабатываний, что и «Богдана + * Хмельницкого» на реальных адресах Альфа-Банка: правило ФИО без ролевого слова ловит «имя + + * фамилия по словообразованию», а улица в честь исторической фигуры выглядит точно так же. + * Одиночная фамилия («улица Ленина») под это правило не подпадает вообще — ему нужны два слова, + * поэтому все примеры здесь двухсловные, реальные названия улиц. */ class StreetDenylistTest { - private final Pipeline pipeline = - new Pipeline(new RuleRegistry(), new Masker(), new PayloadStore(1_000_000L, 30)); + private final Pipeline pipeline = + new Pipeline(new RuleRegistry(), new Masker(), new PayloadStore(1_000_000L, 30)); - private String mask(String text) { - return pipeline.process(text, UUID.randomUUID().toString(), SystemPolicy.DEFAULT); - } + private String mask(String text) { + return pipeline.process(text, UUID.randomUUID().toString(), SystemPolicy.DEFAULT); + } - private void assertUnmasked(String text) { - assertEquals(text, mask(text), "улица в честь исторической фигуры не должна маскироваться: " + text); - } + private void assertUnmasked(String text) { + assertEquals( + text, mask(text), "улица в честь исторической фигуры не должна маскироваться: " + text); + } - @Test - void doesNotMaskNikolaiOstrovskyStreet() { - assertUnmasked("Живу на улице Николая Островского уже десять лет"); - } + @Test + void doesNotMaskNikolaiOstrovskyStreet() { + assertUnmasked("Живу на улице Николая Островского уже десять лет"); + } - @Test - void doesNotMaskAlexanderMatrosovStreet() { - assertUnmasked("Магазин находится на улице Александра Матросова"); - } + @Test + void doesNotMaskAlexanderMatrosovStreet() { + assertUnmasked("Магазин находится на улице Александра Матросова"); + } - @Test - void doesNotMaskValeryChkalovStreet() { - assertUnmasked("Заезжайте на улицу Валерия Чкалова"); - } + @Test + void doesNotMaskValeryChkalovStreet() { + assertUnmasked("Заезжайте на улицу Валерия Чкалова"); + } - @Test - void doesNotMaskVeraVoloshinaStreet() { - assertUnmasked("Новый дом построили на улице Веры Волошиной"); - } + @Test + void doesNotMaskVeraVoloshinaStreet() { + assertUnmasked("Новый дом построили на улице Веры Волошиной"); + } - @Test - void doesNotMaskIvanSusaninStreet() { - assertUnmasked("Школа расположена на улице Ивана Сусанина"); - } + @Test + void doesNotMaskIvanSusaninStreet() { + assertUnmasked("Школа расположена на улице Ивана Сусанина"); + } - @Test - void doesNotMaskSergeyKirovStreet() { - assertUnmasked("Парковка есть на улице Сергея Кирова"); - } + @Test + void doesNotMaskSergeyKirovStreet() { + assertUnmasked("Парковка есть на улице Сергея Кирова"); + } - @Test - void doesNotMaskGeorgiDimitrovStreet() { - assertUnmasked("Остановка на улице Георгия Димитрова"); - } + @Test + void doesNotMaskGeorgiDimitrovStreet() { + assertUnmasked("Остановка на улице Георгия Димитрова"); + } - /** - * Тот же принцип, что и у Пушкина: рядом с другим ПД денилист не - * применяется — если в тексте всё-таки есть настоящие персональные данные, - * совпадение с историческим именем их не прикрывает. - */ - @Test - void masksCommemorativeStreetNameWhenOtherPersonalDataIsPresent() { - String masked = mask("Живу на улице Николая Островского, тел. +7 916 123-45-67"); - assertFalse(masked.contains("Николая Островского"), masked); - } + /** + * Тот же принцип, что и у Пушкина: рядом с другим ПД денилист не применяется — если в тексте + * всё-таки есть настоящие персональные данные, совпадение с историческим именем их не прикрывает. + */ + @Test + void masksCommemorativeStreetNameWhenOtherPersonalDataIsPresent() { + String masked = mask("Живу на улице Николая Островского, тел. +7 916 123-45-67"); + assertFalse(masked.contains("Николая Островского"), masked); + } - /** Контроль: обычное клиентское имя того же грамматического вида всё ещё маскируется. */ - @Test - void stillMasksRealClientNameWithSimilarPattern() { - String masked = mask("Живёт на улице, зовут Николая Смирнова"); - assertFalse(masked.contains("Николая Смирнова"), masked); - } + /** Контроль: обычное клиентское имя того же грамматического вида всё ещё маскируется. */ + @Test + void stillMasksRealClientNameWithSimilarPattern() { + String masked = mask("Живёт на улице, зовут Николая Смирнова"); + assertFalse(masked.contains("Николая Смирнова"), masked); + } - @Test - void doesNotMaskDmitryDonskoyStreet() { - assertUnmasked("Дом стоит на улице Дмитрия Донского"); - } + @Test + void doesNotMaskDmitryDonskoyStreet() { + assertUnmasked("Дом стоит на улице Дмитрия Донского"); + } } diff --git a/src/test/java/ru/pdguard/SystemsConfigTest.java b/src/test/java/ru/pdguard/SystemsConfigTest.java index 3fa885b..b11355e 100644 --- a/src/test/java/ru/pdguard/SystemsConfigTest.java +++ b/src/test/java/ru/pdguard/SystemsConfigTest.java @@ -1,90 +1,92 @@ 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 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.config.SystemPolicy; import ru.pdguard.config.SystemsConfig; 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 { - private static final String CONTENT = """ - { - "default": { "enabled": true, "demask": true, "maskMode": "MASK", "types": ["*"] }, - "crm": { "enabled": true, "demask": false, "maskMode": "TOKEN", "types": ["FIO"] }, - "old": { "enabled": false } - } - """; + private static final String CONTENT = + """ + { + "default": { "enabled": true, "demask": true, "maskMode": "MASK", "types": ["*"] }, + "crm": { "enabled": true, "demask": false, "maskMode": "TOKEN", "types": ["FIO"] }, + "old": { "enabled": false } + } + """; - private SystemsConfig configAt(Path file) { - return new SystemsConfig(file.toString(), new ObjectMapper()); - } + private SystemsConfig configAt(Path file) { + return new SystemsConfig(file.toString(), new ObjectMapper()); + } - @Test - void readsPoliciesFromFile(@TempDir Path dir) throws IOException { - Path file = dir.resolve("systems.json"); - Files.writeString(file, CONTENT, StandardCharsets.UTF_8); + @Test + void readsPoliciesFromFile(@TempDir Path dir) throws IOException { + Path file = dir.resolve("systems.json"); + Files.writeString(file, CONTENT, StandardCharsets.UTF_8); - SystemsConfig config = configAt(file); - SystemPolicy crm = config.policyFor("crm"); + SystemsConfig config = configAt(file); + SystemPolicy crm = config.policyFor("crm"); - assertEquals(MaskMode.TOKEN, crm.maskMode()); - assertFalse(crm.demask()); - assertTrue(crm.allows("FIO")); - assertFalse(crm.allows("CARD")); - assertFalse(config.policyFor("old").enabled()); - } + assertEquals(MaskMode.TOKEN, crm.maskMode()); + assertFalse(crm.demask()); + assertTrue(crm.allows("FIO")); + assertFalse(crm.allows("CARD")); + assertFalse(config.policyFor("old").enabled()); + } - @Test - void unknownSystemGetsDefaultPolicy(@TempDir Path dir) throws IOException { - Path file = dir.resolve("systems.json"); - Files.writeString(file, CONTENT, StandardCharsets.UTF_8); + @Test + void unknownSystemGetsDefaultPolicy(@TempDir Path dir) throws IOException { + Path file = dir.resolve("systems.json"); + Files.writeString(file, CONTENT, StandardCharsets.UTF_8); - SystemPolicy policy = configAt(file).policyFor("никому-не-известная"); - assertTrue(policy.enabled()); - assertTrue(policy.allows("CARD")); - } + SystemPolicy policy = configAt(file).policyFor("никому-не-известная"); + assertTrue(policy.enabled()); + assertTrue(policy.allows("CARD")); + } - @Test - void worksWithoutConfigFile(@TempDir Path dir) { - SystemsConfig config = configAt(dir.resolve("нет-такого-файла.json")); - assertEquals(SystemPolicy.DEFAULT, config.policyFor("любая")); - } + @Test + void worksWithoutConfigFile(@TempDir Path dir) { + SystemsConfig config = configAt(dir.resolve("нет-такого-файла.json")); + assertEquals(SystemPolicy.DEFAULT, config.policyFor("любая")); + } - @Test - void picksUpChangesWithoutRestart(@TempDir Path dir) throws IOException { - Path file = dir.resolve("systems.json"); - Files.writeString(file, CONTENT, StandardCharsets.UTF_8); - SystemsConfig config = configAt(file); - assertEquals(MaskMode.TOKEN, config.policyFor("crm").maskMode()); + @Test + void picksUpChangesWithoutRestart(@TempDir Path dir) throws IOException { + Path file = dir.resolve("systems.json"); + Files.writeString(file, CONTENT, StandardCharsets.UTF_8); + SystemsConfig config = configAt(file); + assertEquals(MaskMode.TOKEN, config.policyFor("crm").maskMode()); - Files.writeString(file, CONTENT.replace("\"TOKEN\"", "\"SYNTHETIC\""), StandardCharsets.UTF_8); - config.reload(); + Files.writeString(file, CONTENT.replace("\"TOKEN\"", "\"SYNTHETIC\""), StandardCharsets.UTF_8); + config.reload(); - assertEquals(MaskMode.SYNTHETIC, config.policyFor("crm").maskMode()); - } + assertEquals(MaskMode.SYNTHETIC, config.policyFor("crm").maskMode()); + } - @Test - void brokenFileKeepsPreviousSettings(@TempDir Path dir) throws IOException { - Path file = dir.resolve("systems.json"); - Files.writeString(file, CONTENT, StandardCharsets.UTF_8); - SystemsConfig config = configAt(file); + @Test + void brokenFileKeepsPreviousSettings(@TempDir Path dir) throws IOException { + Path file = dir.resolve("systems.json"); + Files.writeString(file, CONTENT, StandardCharsets.UTF_8); + SystemsConfig config = configAt(file); - Files.writeString(file, "{ это не json", StandardCharsets.UTF_8); - config.reload(); + Files.writeString(file, "{ это не json", StandardCharsets.UTF_8); + config.reload(); - assertEquals(MaskMode.TOKEN, config.policyFor("crm").maskMode(), - "сломанный файл не должен ронять работающий сервис"); - } + assertEquals( + MaskMode.TOKEN, + config.policyFor("crm").maskMode(), + "сломанный файл не должен ронять работающий сервис"); + } } diff --git a/src/test/java/ru/pdguard/TwoModelBenchmarkTest.java b/src/test/java/ru/pdguard/TwoModelBenchmarkTest.java index 7982958..d1013fd 100644 --- a/src/test/java/ru/pdguard/TwoModelBenchmarkTest.java +++ b/src/test/java/ru/pdguard/TwoModelBenchmarkTest.java @@ -1,5 +1,13 @@ 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 ru.pdguard.config.SystemPolicy; import ru.pdguard.core.PayloadStore; @@ -9,134 +17,141 @@ import ru.pdguard.detect.RuleRegistry; import ru.pdguard.detect.Span; 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 для адресов. * - *

Набор {@code benchmark-two-model.txt} проверяет, что имена клиентов и адреса - * маскируются, а известные личности — нет. Для каждого типа считается посимвольная - * точность, полнота и F1. + *

Набор {@code benchmark-two-model.txt} проверяет, что имена клиентов и адреса маскируются, а + * известные личности — нет. Для каждого типа считается посимвольная точность, полнота и F1. */ class TwoModelBenchmarkTest { - private final Pipeline pipeline = new Pipeline(new RuleRegistry(), new Masker(), - new PayloadStore(10_000_000L, 30), - new NameCascade( - new NameCascade.EngineConfig( - "wikineural", Optional.of("models/wikineural-ner"), - "rubert", Optional.of("models/rubert-ner"), - "off", Optional.empty()), - 16, 4)); + private final Pipeline pipeline = + new Pipeline( + new RuleRegistry(), + new Masker(), + new PayloadStore(10_000_000L, 30), + 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 int truePositive; - private int falsePositive; - private int falseNegative; + private static final class Score { + private int truePositive; + private int falsePositive; + private int falseNegative; - private int gold() { - 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); - } + private int gold() { + return truePositive + falseNegative; } - @Test - void twoModelEfficiency() { - List samples = BenchmarkFixtures.load("/benchmark-two-model.txt"); - Map byType = new LinkedHashMap<>(); - Map> missed = new LinkedHashMap<>(); - - for (BenchmarkFixtures.Sample sample : samples) { - List 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 e : byType.entrySet()) { - assertTrue(e.getValue().f1() >= 0.8, - String.format("F1 по типу %s упал до %.3f", e.getKey(), e.getValue().f1())); - } + private double precision() { + int found = truePositive + falsePositive; + return found == 0 ? 1.0 : (double) truePositive / found; } - private static String[] paint(int length, List 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 double recall() { + return gold() == 0 ? 1.0 : (double) truePositive / gold(); } - private static void account(Map 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 double f1() { + double p = precision(); + double r = recall(); + return p + r == 0 ? 0.0 : 2 * p * r / (p + r); + } + } + + @Test + void twoModelEfficiency() { + List samples = BenchmarkFixtures.load("/benchmark-two-model.txt"); + Map byType = new LinkedHashMap<>(); + Map> missed = new LinkedHashMap<>(); + + for (BenchmarkFixtures.Sample sample : samples) { + List 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 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 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); - } + report(byType); + reportMissed(missed); - private void reportMissed(Map> 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); + // Каждый тип должен быть найден с F1 не ниже 0.8. + for (Map.Entry e : byType.entrySet()) { + assertTrue( + e.getValue().f1() >= 0.8, + String.format("F1 по типу %s упал до %.3f", e.getKey(), e.getValue().f1())); } -} \ No newline at end of file + } + + private static String[] paint(int length, List 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 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 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 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> 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); + } +} diff --git a/src/test/java/ru/pdguard/TwoModelCascadeTest.java b/src/test/java/ru/pdguard/TwoModelCascadeTest.java index bd6d75e..5872b0f 100644 --- a/src/test/java/ru/pdguard/TwoModelCascadeTest.java +++ b/src/test/java/ru/pdguard/TwoModelCascadeTest.java @@ -1,5 +1,9 @@ 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 ru.pdguard.config.SystemPolicy; import ru.pdguard.core.PayloadStore; @@ -10,45 +14,44 @@ 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; - /** Две модели: WikiNEuRal для имён, ruBERT для адресов. */ class TwoModelCascadeTest { - private List find(String text) { - NameCascade cascade = new NameCascade( - new NameCascade.EngineConfig( - "wikineural", Optional.of("models/wikineural-ner"), - "rubert", Optional.of("models/rubert-ner"), - "off", Optional.empty()), - 16, 4); - Pipeline p = new Pipeline(new RuleRegistry(), new Masker(), new PayloadStore(1_000_000L, 30), cascade); - return p.findPersonalData(text, SystemPolicy.DEFAULT); - } + private List find(String text) { + NameCascade cascade = + new NameCascade( + new NameCascade.EngineConfig( + "wikineural", Optional.of("models/wikineural-ner"), + "rubert", Optional.of("models/rubert-ner"), + "off", Optional.empty()), + 16, + 4); + Pipeline p = + new Pipeline(new RuleRegistry(), new Masker(), new PayloadStore(1_000_000L, 30), cascade); + return p.findPersonalData(text, SystemPolicy.DEFAULT); + } - @Test - void recognisesNamesAndAddresses() { - String text = "Клиент Иванов Иван Иванович, проживает в городе Москва, на улице Тверская"; - List spans = find(text); - System.out.println("TEXT: " + text); - for (Span s : spans) { - System.out.println(" -> " + s.type() + " [" + text.substring(s.start(), s.end()) + "]"); - } - assertTrue(spans.stream().anyMatch(s -> s.type().equals(PdTypes.FIO)), "должно найти ФИО"); + @Test + void recognisesNamesAndAddresses() { + String text = "Клиент Иванов Иван Иванович, проживает в городе Москва, на улице Тверская"; + List spans = find(text); + System.out.println("TEXT: " + text); + for (Span s : spans) { + System.out.println(" -> " + s.type() + " [" + text.substring(s.start(), s.end()) + "]"); } + assertTrue(spans.stream().anyMatch(s -> s.type().equals(PdTypes.FIO)), "должно найти ФИО"); + } - @Test - void wellKnownNamesNotMasked() { - String text = "Напиши стихотворение в духе Александра Пушкина про осень"; - List spans = find(text); - System.out.println("TEXT: " + text); - for (Span s : spans) { - System.out.println(" -> " + s.type() + " [" + text.substring(s.start(), s.end()) + "]"); - } - assertTrue(spans.stream().noneMatch(s -> s.type().equals(PdTypes.FIO)), - "известная личность не должна маскироваться"); + @Test + void wellKnownNamesNotMasked() { + String text = "Напиши стихотворение в духе Александра Пушкина про осень"; + List spans = find(text); + System.out.println("TEXT: " + text); + for (Span s : spans) { + System.out.println(" -> " + s.type() + " [" + text.substring(s.start(), s.end()) + "]"); } + assertTrue( + spans.stream().noneMatch(s -> s.type().equals(PdTypes.FIO)), + "известная личность не должна маскироваться"); + } } diff --git a/src/test/java/ru/pdguard/config/SystemsConfigTest.java b/src/test/java/ru/pdguard/config/SystemsConfigTest.java new file mode 100644 index 0000000..b7f1000 --- /dev/null +++ b/src/test/java/ru/pdguard/config/SystemsConfigTest.java @@ -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()); + } +} diff --git a/src/test/java/ru/pdguard/core/AdaptiveConcurrencyLimiterTest.java b/src/test/java/ru/pdguard/core/AdaptiveConcurrencyLimiterTest.java new file mode 100644 index 0000000..5bc329b --- /dev/null +++ b/src/test/java/ru/pdguard/core/AdaptiveConcurrencyLimiterTest.java @@ -0,0 +1,92 @@ +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; + +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(), "окно ещё не прошло — предел не должен меняться"); + } +} diff --git a/src/test/java/ru/pdguard/core/PayloadCipherTest.java b/src/test/java/ru/pdguard/core/PayloadCipherTest.java new file mode 100644 index 0000000..7633159 --- /dev/null +++ b/src/test/java/ru/pdguard/core/PayloadCipherTest.java @@ -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)); + } +} diff --git a/src/test/java/ru/pdguard/detect/NameDictionaryTest.java b/src/test/java/ru/pdguard/detect/NameDictionaryTest.java index dede0b1..14e1cc3 100644 --- a/src/test/java/ru/pdguard/detect/NameDictionaryTest.java +++ b/src/test/java/ru/pdguard/detect/NameDictionaryTest.java @@ -1,87 +1,88 @@ package ru.pdguard.detect; -import org.junit.jupiter.api.AfterEach; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.io.TempDir; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; 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.assertFalse; -import static org.junit.jupiter.api.Assertions.assertTrue; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; /** Денилист известных людей: встроенный список и дозагрузка сверху без пересборки. */ class NameDictionaryTest { - /** Возвращает состояние словаря к встроенному списку — не течёт в остальные тесты. */ - @AfterEach - void resetToBundledList() { - NameDictionary.useExternalFile(Path.of("config/well-known.txt")); - } + /** Возвращает состояние словаря к встроенному списку — не течёт в остальные тесты. */ + @AfterEach + void resetToBundledList() { + NameDictionary.useExternalFile(Path.of("config/well-known.txt")); + } - @Test - void bundledListCoversClassicFigures() { - // Сравнение по началу слова покрывает обычную русскую флексию («Пушкин» → - // «Пушкина»), но не прилагательное склонение («Толстой» → «Толстого», где - // «-ой» меняется на «-ого» целиком, а не дописывается) — известное - // ограничение самого приёма, не завязанное на список имён. - assertTrue(NameDictionary.isWellKnown("Стихи Пушкина")); - assertTrue(NameDictionary.isWellKnown("Портрет Толстой")); - } + @Test + void bundledListCoversClassicFigures() { + // Сравнение по началу слова покрывает обычную русскую флексию («Пушкин» → + // «Пушкина»), но не прилагательное склонение («Толстой» → «Толстого», где + // «-ой» меняется на «-ого» целиком, а не дописывается) — известное + // ограничение самого приёма, не завязанное на список имён. + assertTrue(NameDictionary.isWellKnown("Стихи Пушкина")); + assertTrue(NameDictionary.isWellKnown("Портрет Толстой")); + } - @Test - void bundledListCoversCurrentPublicFigures() { - assertTrue(NameDictionary.isWellKnown("Заявление Набиуллиной")); - assertTrue(NameDictionary.isWellKnown("Интервью Путина")); - } + @Test + void bundledListCoversCurrentPublicFigures() { + assertTrue(NameDictionary.isWellKnown("Заявление Набиуллиной")); + assertTrue(NameDictionary.isWellKnown("Интервью Путина")); + } - @Test - void unknownSurnameIsNotWellKnown() { - assertFalse(NameDictionary.isWellKnown("Заявление Смирнова")); - } + @Test + void unknownSurnameIsNotWellKnown() { + assertFalse(NameDictionary.isWellKnown("Заявление Смирнова")); + } - /** - * Фамилии, в честь которых чаще всего называют улицы в России (Росреестр). - * Само по себе «улица Ленина» никогда не попало бы под ФИО — для этого - * правила нужны два слова, — но денилист должен покрывать и составные - * названия («Феликса Дзержинского»), и вариации написания («Будённый»/ - * «Буденный»). - */ - @Test - void bundledListCoversCommemorativeStreetNames() { - assertTrue(NameDictionary.isWellKnown("улица Кирова")); - // «-ский» склоняется целиком («Дзержинский» → «Дзержинского»), для таких - // основа обрезается сразу до «ск» — см. Declension. - assertTrue(NameDictionary.isWellKnown("проспект Дзержинского")); - assertTrue(NameDictionary.isWellKnown("улица Донского")); - // «Будённый» — чистое прилагательное без «-ский» (как «Толстой»): та же - // известная граница приёма, родительный падеж («Будённого») им не ловится. - assertTrue(NameDictionary.isWellKnown("улица Будённый")); - assertTrue(NameDictionary.isWellKnown("улица Буденный"), "написание без «ё» тоже должно ловиться"); - assertTrue(NameDictionary.isWellKnown("улица Жукова")); - assertTrue(NameDictionary.isWellKnown("улица Островского")); - } + /** + * Фамилии, в честь которых чаще всего называют улицы в России (Росреестр). Само по себе «улица + * Ленина» никогда не попало бы под ФИО — для этого правила нужны два слова, — но денилист должен + * покрывать и составные названия («Феликса Дзержинского»), и вариации написания («Будённый»/ + * «Буденный»). + */ + @Test + void bundledListCoversCommemorativeStreetNames() { + assertTrue(NameDictionary.isWellKnown("улица Кирова")); + // «-ский» склоняется целиком («Дзержинский» → «Дзержинского»), для таких + // основа обрезается сразу до «ск» — см. Declension. + assertTrue(NameDictionary.isWellKnown("проспект Дзержинского")); + assertTrue(NameDictionary.isWellKnown("улица Донского")); + // «Будённый» — чистое прилагательное без «-ский» (как «Толстой»): та же + // известная граница приёма, родительный падеж («Будённого») им не ловится. + assertTrue(NameDictionary.isWellKnown("улица Будённый")); + assertTrue( + NameDictionary.isWellKnown("улица Буденный"), "написание без «ё» тоже должно ловиться"); + assertTrue(NameDictionary.isWellKnown("улица Жукова")); + assertTrue(NameDictionary.isWellKnown("улица Островского")); + } - @Test - void externalFileAddsNamesWithoutRebuild(@TempDir Path dir) throws IOException { - Path file = dir.resolve("well-known.txt"); - Files.writeString(file, "Кастомов\n", StandardCharsets.UTF_8); + @Test + void externalFileAddsNamesWithoutRebuild(@TempDir Path dir) throws IOException { + Path file = dir.resolve("well-known.txt"); + 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 - void missingExternalFileFallsBackToBundledListOnly(@TempDir Path dir) { - NameDictionary.useExternalFile(dir.resolve("нет-такого-файла.txt")); + @Test + void missingExternalFileFallsBackToBundledListOnly(@TempDir Path dir) { + NameDictionary.useExternalFile(dir.resolve("нет-такого-файла.txt")); - assertTrue(NameDictionary.isWellKnown("Стихи Пушкина")); - assertFalse(NameDictionary.isWellKnown("Заявление Смирнова")); - } + assertTrue(NameDictionary.isWellKnown("Стихи Пушкина")); + assertFalse(NameDictionary.isWellKnown("Заявление Смирнова")); + } } diff --git a/src/test/java/ru/pdguard/detect/ToponymDictionaryTest.java b/src/test/java/ru/pdguard/detect/ToponymDictionaryTest.java index 953c317..a180c95 100644 --- a/src/test/java/ru/pdguard/detect/ToponymDictionaryTest.java +++ b/src/test/java/ru/pdguard/detect/ToponymDictionaryTest.java @@ -1,62 +1,61 @@ 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.assertTrue; +import org.junit.jupiter.api.Test; + /** Словарь населённых пунктов — не только города, но и сёла, посёлки, деревни, хутора. */ class ToponymDictionaryTest { - @Test - void recognisesNominativeCase() { - assertTrue(ToponymDictionary.isKnownSettlement("Москва")); - assertTrue(ToponymDictionary.isKnownSettlement("Казань")); - assertTrue(ToponymDictionary.isKnownSettlement("Санкт-Петербург")); - } + @Test + void recognisesNominativeCase() { + assertTrue(ToponymDictionary.isKnownSettlement("Москва")); + assertTrue(ToponymDictionary.isKnownSettlement("Казань")); + assertTrue(ToponymDictionary.isKnownSettlement("Санкт-Петербург")); + } - @Test - void recognisesInflectedForms() { - assertTrue(ToponymDictionary.isKnownSettlement("Москве"), "дательный падеж города на гласную"); - assertTrue(ToponymDictionary.isKnownSettlement("Тамбове"), "предложный падеж города на согласную"); - assertTrue(ToponymDictionary.isKnownSettlement("Казани"), "родительный падеж"); - } + @Test + void recognisesInflectedForms() { + assertTrue(ToponymDictionary.isKnownSettlement("Москве"), "дательный падеж города на гласную"); + assertTrue( + ToponymDictionary.isKnownSettlement("Тамбове"), "предложный падеж города на согласную"); + assertTrue(ToponymDictionary.isKnownSettlement("Казани"), "родительный падеж"); + } - @Test - void recognisesCisCapitals() { - assertTrue(ToponymDictionary.isKnownSettlement("Минск")); - assertTrue(ToponymDictionary.isKnownSettlement("Алматы")); - } + @Test + void recognisesCisCapitals() { + assertTrue(ToponymDictionary.isKnownSettlement("Минск")); + assertTrue(ToponymDictionary.isKnownSettlement("Алматы")); + } - @Test - void rejectsMadeUpWord() { - assertFalse(ToponymDictionary.isKnownSettlement("Ерунда")); - assertFalse(ToponymDictionary.isKnownSettlement("Бла-бла")); - } + @Test + void rejectsMadeUpWord() { + assertFalse(ToponymDictionary.isKnownSettlement("Ерунда")); + assertFalse(ToponymDictionary.isKnownSettlement("Бла-бла")); + } - @Test - void isCaseInsensitive() { - assertTrue(ToponymDictionary.isKnownSettlement("МОСКВА")); - assertTrue(ToponymDictionary.isKnownSettlement("москва")); - } + @Test + void isCaseInsensitive() { + assertTrue(ToponymDictionary.isKnownSettlement("МОСКВА")); + assertTrue(ToponymDictionary.isKnownSettlement("москва")); + } - /** - * Из переписи 2020–2021, не из ручного списка городов: сёла, посёлки, - * деревни, хутора, станицы, аулы, аалы — ровно то, чего не было, пока - * словарь ограничивался официальными городами. Примеры из ТЗ («рп. - * Ильинское», «с. Кукуево») и по одному реальному названию на тип - * населённого пункта. - */ - @Test - 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("Сартыков"), "аал (Хакасия)"); - } + /** + * Из переписи 2020–2021, не из ручного списка городов: сёла, посёлки, деревни, хутора, станицы, + * аулы, аалы — ровно то, чего не было, пока словарь ограничивался официальными городами. Примеры + * из ТЗ («рп. Ильинское», «с. Кукуево») и по одному реальному названию на тип населённого пункта. + */ + @Test + 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("Сартыков"), "аал (Хакасия)"); + } } diff --git a/src/test/java/ru/pdguard/detect/ValidatorsTest.java b/src/test/java/ru/pdguard/detect/ValidatorsTest.java index 261c1c9..33466a0 100644 --- a/src/test/java/ru/pdguard/detect/ValidatorsTest.java +++ b/src/test/java/ru/pdguard/detect/ValidatorsTest.java @@ -1,69 +1,70 @@ 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.assertTrue; +import org.junit.jupiter.api.Test; + /** Контрольные суммы ОГРН/ОГРНИП: первые 12/14 цифр по модулю 11/13, младший разряд остатка. */ class ValidatorsTest { - @Test - void validOgrnPassesChecksum() { - assertTrue(Validators.ogrn("1027700123450")); - assertTrue(Validators.ogrn("1025000678900")); - assertTrue(Validators.ogrn("1045002233440")); - } + @Test + void validOgrnPassesChecksum() { + assertTrue(Validators.ogrn("1027700123450")); + assertTrue(Validators.ogrn("1025000678900")); + assertTrue(Validators.ogrn("1045002233440")); + } - @Test - void invalidOgrnChecksumFails() { - assertFalse(Validators.ogrn("1027700123451"), "последняя цифра изменена — сумма не сходится"); - } + @Test + void invalidOgrnChecksumFails() { + assertFalse(Validators.ogrn("1027700123451"), "последняя цифра изменена — сумма не сходится"); + } - @Test - void ogrnWrongLengthFails() { - assertFalse(Validators.ogrn("102770012345"), "12 цифр — не хватает контрольной"); - assertFalse(Validators.ogrn("10277001234500"), "14 цифр — лишняя"); - } + @Test + void ogrnWrongLengthFails() { + assertFalse(Validators.ogrn("102770012345"), "12 цифр — не хватает контрольной"); + assertFalse(Validators.ogrn("10277001234500"), "14 цифр — лишняя"); + } - /** Остаток от деления на 11 может быть 10 — тогда контрольная цифра 0, не 10. */ - @Test - void ogrnRemainderTenMapsToZero() { - assertTrue(Validators.ogrn("1000000000000")); - } + /** Остаток от деления на 11 может быть 10 — тогда контрольная цифра 0, не 10. */ + @Test + void ogrnRemainderTenMapsToZero() { + assertTrue(Validators.ogrn("1000000000000")); + } - @Test - void validOgrnipPassesChecksum() { - assertTrue(Validators.ogrnip("304500116000157")); - assertTrue(Validators.ogrnip("312500000000013")); - assertTrue(Validators.ogrnip("305500112233041")); - } + @Test + void validOgrnipPassesChecksum() { + assertTrue(Validators.ogrnip("304500116000157")); + assertTrue(Validators.ogrnip("312500000000013")); + assertTrue(Validators.ogrnip("305500112233041")); + } - @Test - void invalidOgrnipChecksumFails() { - assertFalse(Validators.ogrnip("304500116000158"), "последняя цифра изменена — сумма не сходится"); - } + @Test + void invalidOgrnipChecksumFails() { + assertFalse( + Validators.ogrnip("304500116000158"), "последняя цифра изменена — сумма не сходится"); + } - @Test - void ogrnipWrongLengthFails() { - assertFalse(Validators.ogrnip("30450011600015"), "14 цифр — не хватает контрольной"); - assertFalse(Validators.ogrnip("30450011600015700"), "18 цифр — лишние"); - } + @Test + void ogrnipWrongLengthFails() { + assertFalse(Validators.ogrnip("30450011600015"), "14 цифр — не хватает контрольной"); + assertFalse(Validators.ogrnip("30450011600015700"), "18 цифр — лишние"); + } - /** Остаток от деления на 13 может быть 10 — тогда контрольная цифра 0, не 10. */ - @Test - void ogrnipRemainderTenMapsToZero() { - assertTrue(Validators.ogrnip("100000000000000")); - } + /** Остаток от деления на 13 может быть 10 — тогда контрольная цифра 0, не 10. */ + @Test + void ogrnipRemainderTenMapsToZero() { + assertTrue(Validators.ogrnip("100000000000000")); + } - @Test - void nonDigitCharactersAreIgnored() { - assertTrue(Validators.ogrn("10-27700-123450"), "разделители в номере не мешают счёту цифр"); - } + @Test + void nonDigitCharactersAreIgnored() { + assertTrue(Validators.ogrn("10-27700-123450"), "разделители в номере не мешают счёту цифр"); + } - @Test - void emptyValueIsNotValid() { - assertFalse(Validators.ogrn("")); - assertFalse(Validators.ogrnip("")); - } + @Test + void emptyValueIsNotValid() { + assertFalse(Validators.ogrn("")); + assertFalse(Validators.ogrnip("")); + } }