refactor: подтянуть Legal NER, переформатировать код и добавить тесты

Слияние с 685ec97 (третья ступень NER для юридических реквизитов),
код приведён к google-java-format, добавлены юнит-тесты
AdaptiveConcurrencyLimiter/SystemsConfig/PayloadCipher.
This commit is contained in:
Максименко Никита Владимирович
2026-09-23 22:02:45 +03:00
parent 685ec977de
commit 1328a7b234
79 changed files with 7385 additions and 6430 deletions
+1 -1
View File
@@ -2,7 +2,7 @@
"default": {
"enabled": true,
"demask": true,
"maskMode": "TOKEN",
"maskMode": "MASK",
"types": ["*"],
"requireCompanion": [
"CVV", "PIN", "DATE", "BIRTH_PLACE", "ADDRESS_COUNTRY",
@@ -6,9 +6,8 @@ import org.springframework.boot.autoconfigure.SpringBootApplication;
/**
* Точка входа Spring Boot приложения.
*
* <p>Модуль безопасности персональных данных: прокси между системой-потребителем
* и LLM. Находит персональные данные, маскирует их и восстанавливает исходный
* текст на обратном шаге.
* <p>Модуль безопасности персональных данных: прокси между системой-потребителем и LLM. Находит
* персональные данные, маскирует их и восстанавливает исходный текст на обратном шаге.
*/
@SpringBootApplication
public class PdGuardApplication {
@@ -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,9 +9,6 @@ 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 {
@@ -18,18 +18,16 @@ import ru.pdguard.core.AdaptiveConcurrencyLimiter;
import ru.pdguard.core.Pipeline;
/**
* Единственная точка входа контракта: маскирование и демаскирование по
* {@code payload_id}.
* Единственная точка входа контракта: маскирование и демаскирование по {@code payload_id}.
*
* <p>Система-потребитель называет себя заголовком {@code X-System-Id}. Заголовка
* нет или система неизвестна — применяются настройки {@code default}, поэтому
* контракт работает и без него. Система, выключенная в настройках, получает
* {@code 403}.
* <p>Система-потребитель называет себя заголовком {@code X-System-Id}. Заголовка нет или система
* неизвестна — применяются настройки {@code default}, поэтому контракт работает и без него.
* Система, выключенная в настройках, получает {@code 403}.
*
* <p>При перегрузке отвечает {@code 429} с {@code Retry-After}. Порог перегрузки —
* не фиксированное число запросов, а задержка обработки: {@link AdaptiveConcurrencyLimiter}
* сам находит потолок конкурентности под то, сколько CPU реально досталось контейнеру,
* вместо того чтобы копить запросы и упереться в таймаут вызывающей стороны.
* <p>При перегрузке отвечает {@code 429} с {@code Retry-After}. Порог перегрузки — не фиксированное
* число запросов, а задержка обработки: {@link AdaptiveConcurrencyLimiter} сам находит потолок
* конкурентности под то, сколько CPU реально досталось контейнеру, вместо того чтобы копить запросы
* и упереться в таймаут вызывающей стороны.
*/
@RestController
public class ProcessResource {
@@ -44,21 +42,19 @@ public class ProcessResource {
/** Имя метрики отклонённых запросов и имя её метки причины. */
private static final String REJECTED_METRIC = "pdguard.requests.rejected";
private static final String REASON_TAG = "reason";
/**
* Что отдаётся при внутреннем сбое. Ни одного знака из запроса: сбой на прямом
* шаге иначе выпустил бы наружу незамаскированные персональные данные.
* Что отдаётся при внутреннем сбое. Ни одного знака из запроса: сбой на прямом шаге иначе
* выпустил бы наружу незамаскированные персональные данные.
*/
static final String PROCESSING_UNAVAILABLE = "[обработка недоступна]";
public record ProcessRequest(
@JsonProperty("payload") String payload,
@JsonProperty("payload_id") String payloadId) {
}
@JsonProperty("payload") String payload, @JsonProperty("payload_id") String payloadId) {}
public record ProcessResponse(@JsonProperty("result") String result) {
}
public record ProcessResponse(@JsonProperty("result") String result) {}
private final Pipeline pipeline;
private final SystemsConfig systems;
@@ -68,13 +64,17 @@ public class ProcessResource {
private final Counter forbidden;
private final Counter failed;
public ProcessResource(Pipeline pipeline, SystemsConfig systems, MeterRegistry meters,
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.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");
@@ -84,11 +84,14 @@ public class ProcessResource {
}
@PostMapping("/process")
public ResponseEntity<ProcessResponse> process(@RequestBody(required = false) ProcessRequest request,
public ResponseEntity<ProcessResponse> process(
@RequestBody(required = false) ProcessRequest request,
@RequestHeader(value = SYSTEM_HEADER, required = false) String systemId,
@RequestHeader(value = KEY_HEADER, required = false) String systemKey) {
if (request == null || request.payload() == null
|| request.payloadId() == null || request.payloadId().isBlank()) {
if (request == null
|| request.payload() == null
|| request.payloadId() == null
|| request.payloadId().isBlank()) {
malformed.increment();
return ResponseEntity.badRequest()
.body(new ProcessResponse("payload и payload_id обязательны"));
@@ -123,8 +126,8 @@ public class ProcessResource {
// ради чего сервис и существует. Ответ фиксированный: он ничего не
// раскрывает и не выглядит порчей данных.
failed.increment();
LOG.error("payload_id={} обработка не удалась, отдан безопасный ответ",
request.payloadId(), e);
LOG.error(
"payload_id={} обработка не удалась, отдан безопасный ответ", request.payloadId(), e);
return ResponseEntity.ok(new ProcessResponse(PROCESSING_UNAVAILABLE));
} finally {
limiter.release(System.nanoTime() - started);
+29 -20
View File
@@ -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,25 +22,23 @@ import java.util.Map;
* потребитель → маскирование → LLM → демаскирование → потребитель
* </pre>
*
* <p>В ответе видны все три текста — что ушло в модель, что она вернула и что
* получил потребитель. Это и есть доказательство, что в модель не попало ничего
* незамаскированного, а ответ вернулся с восстановленными значениями.
* <p>В ответе видны все три текста — что ушло в модель, что она вернула и что получил потребитель.
* Это и есть доказательство, что в модель не попало ничего незамаскированного, а ответ вернулся с
* восстановленными значениями.
*
* <p>Ответ модели — другой текст, а не тот же самый, поэтому восстановить его по
* идентификатору целиком нельзя: замена идёт пофрагментно. Звёздочки для этого не
* годятся — одна и та же маска отвечала бы разным значениям, — поэтому здесь всегда
* применяется обратимая подстановка, независимо от режима маскирования системы.
* <p>Ответ модели — другой текст, а не тот же самый, поэтому восстановить его по идентификатору
* целиком нельзя: замена идёт пофрагментно. Звёздочки для этого не годятся — одна и та же маска
* отвечала бы разным значениям, — поэтому здесь всегда применяется обратимая подстановка,
* независимо от режима маскирования системы.
*
* <p>Контракт проверяющей системы это плечо не затрагивает: он живёт в
* {@link ProcessResource}.
* <p>Контракт проверяющей системы это плечо не затрагивает: он живёт в {@link ProcessResource}.
*/
@RestController
public class ProxyResource {
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,
@@ -66,7 +63,8 @@ public class ProxyResource {
}
@PostMapping("/proxy")
public ResponseEntity<ProxyResponse> proxy(@RequestBody(required = false) ProxyRequest request,
public ResponseEntity<ProxyResponse> proxy(
@RequestBody(required = false) ProxyRequest request,
@RequestHeader(value = ProcessResource.SYSTEM_HEADER, required = false) String systemId,
@RequestHeader(value = ProcessResource.KEY_HEADER, required = false) String systemKey) {
if (request == null || request.prompt() == null || request.prompt().isBlank()) {
@@ -81,18 +79,29 @@ public class ProxyResource {
}
if (!policy.enabled()) {
return ResponseEntity.status(HttpStatus.FORBIDDEN)
.body(new ProxyResponse(null, null, null, null, null,
.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();
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));
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));
}
/** Возвращает исходные значения на место подстановок в ответе модели. */
@@ -8,10 +8,9 @@ import ru.pdguard.detect.NameDictionary;
/**
* Конфигурация словарей распознавания ФИО.
*
* <p>{@link NameDictionary} работает через статические методы и не требует
* экземпляра, но путь к внешнему файлу денилиста задаётся из настроек при
* старте. Бин здесь нужен только для того, чтобы Spring подставил значение
* {@code pdguard.well-known-file} и передал его словарю.
* <p>{@link NameDictionary} работает через статические методы и не требует экземпляра, но путь к
* внешнему файлу денилиста задаётся из настроек при старте. Бин здесь нужен только для того, чтобы
* Spring подставил значение {@code pdguard.well-known-file} и передал его словарю.
*/
@Configuration
public class DictionaryConfiguration {
@@ -1,42 +1,40 @@
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 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 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<String> types, Set<String> requireCompanion, String key) {
public record SystemPolicy(
String name,
boolean enabled,
boolean demask,
MaskMode maskMode,
Set<String> types,
Set<String> requireCompanion,
String key) {
public static final String ALL = "*";
@@ -44,11 +42,27 @@ public record SystemPolicy(String name, boolean enabled, boolean demask, MaskMod
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);
@@ -57,8 +71,8 @@ public record SystemPolicy(String name, boolean enabled, boolean demask, MaskMod
/** Политика только для перечисленных типов, с остальными настройками по умолчанию. */
public static SystemPolicy forTypes(String... types) {
return new SystemPolicy(DEFAULT_NAME, true, true, MaskMode.MASK,
Set.of(types), DEFAULT.requireCompanion(), null);
return new SystemPolicy(
DEFAULT_NAME, true, true, MaskMode.MASK, Set.of(types), DEFAULT.requireCompanion(), null);
}
/** Совпадает ли предъявленный ключ. Ключ не задан — проверка не применяется. */
@@ -68,7 +82,8 @@ public record SystemPolicy(String name, boolean enabled, boolean demask, MaskMod
}
return java.security.MessageDigest.isEqual(
key.getBytes(java.nio.charset.StandardCharsets.UTF_8),
(presentedKey == null ? "" : presentedKey).getBytes(java.nio.charset.StandardCharsets.UTF_8));
(presentedKey == null ? "" : presentedKey)
.getBytes(java.nio.charset.StandardCharsets.UTF_8));
}
public boolean allows(String type) {
@@ -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,17 +8,22 @@ 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;
/**
* Список систем, которым разрешено обращаться в модуль, и правила для каждой.
*
* <p>Читается из внешнего файла, чтобы настройки менялись без пересборки. Файл
* перечитывается сам, когда меняется время его изменения; проверка выполняется
* не чаще раза в секунду, чтобы не ходить в файловую систему на каждом запросе.
* Файла нет — работают настройки по умолчанию, и сервис поднимается без него.
* <p>Читается из внешнего файла, чтобы настройки менялись без пересборки. Файл перечитывается сам,
* когда меняется время его изменения; проверка выполняется не чаще раза в секунду, чтобы не ходить
* в файловую систему на каждом запросе. Файла нет — работают настройки по умолчанию, и сервис
* поднимается без него.
*/
@Component
public final class SystemsConfig {
@@ -37,8 +36,13 @@ public final class SystemsConfig {
private static final long RECHECK_MILLIS = 1000;
/** Описание одной системы в файле настроек. */
public record SystemEntry(Boolean enabled, Boolean demask, String maskMode,
List<String> types, List<String> requireCompanion, String key) {
public record SystemEntry(
Boolean enabled,
Boolean demask,
String maskMode,
List<String> types,
List<String> requireCompanion,
String key) {
public SystemEntry {
types = types == null ? null : List.copyOf(types);
requireCompanion = requireCompanion == null ? null : List.copyOf(requireCompanion);
@@ -53,8 +57,8 @@ public final class SystemsConfig {
private volatile long fileTimestamp;
private volatile long lastCheck;
public SystemsConfig(@Value("${pdguard.systems-file:config/systems.json}") String path,
ObjectMapper mapper) {
public SystemsConfig(
@Value("${pdguard.systems-file:config/systems.json}") String path, ObjectMapper mapper) {
this.file = Path.of(path);
this.mapper = mapper;
reload();
@@ -87,15 +91,20 @@ public final class SystemsConfig {
public final synchronized void reload() {
lastCheck = System.currentTimeMillis();
if (!Files.isReadable(file)) {
LOG.info("Файл настроек {} не найден, применяются настройки по умолчанию", file.toAbsolutePath());
LOG.info(
"Файл настроек {} не найден, применяются настройки по умолчанию", file.toAbsolutePath());
policies.set(Map.of(DEFAULT_SYSTEM, SystemPolicy.DEFAULT));
fileTimestamp = 0;
return;
}
try {
fileTimestamp = Files.getLastModifiedTime(file).toMillis();
Map<String, SystemEntry> entries = mapper.readValue(Files.readAllBytes(file),
mapper.getTypeFactory().constructMapType(TreeMap.class, String.class, SystemEntry.class));
Map<String, SystemEntry> entries =
mapper.readValue(
Files.readAllBytes(file),
mapper
.getTypeFactory()
.constructMapType(TreeMap.class, String.class, SystemEntry.class));
Map<String, SystemPolicy> parsed = new TreeMap<>();
entries.forEach((name, entry) -> parsed.put(name, toPolicy(name, entry)));
parsed.putIfAbsent(DEFAULT_SYSTEM, SystemPolicy.DEFAULT);
@@ -103,7 +112,8 @@ public final class SystemsConfig {
LOG.info("Настройки систем перечитаны из {}: {}", file.toAbsolutePath(), parsed.keySet());
} catch (IOException | IllegalArgumentException e) {
// Битый файл не должен ронять работающий сервис: остаются прежние настройки.
LOG.error("Не удалось прочитать {}, продолжаем с прежними настройками", file.toAbsolutePath(), e);
LOG.error(
"Не удалось прочитать {}, продолжаем с прежними настройками", file.toAbsolutePath(), e);
}
}
@@ -128,13 +138,21 @@ public final class SystemsConfig {
private static SystemPolicy toPolicy(String name, SystemEntry entry) {
SystemPolicy base = SystemPolicy.DEFAULT;
Set<String> types = entry.types() == null ? base.types() : new HashSet<>(entry.types());
Set<String> companions = entry.requireCompanion() == null
? base.requireCompanion() : new HashSet<>(entry.requireCompanion());
MaskMode mode = entry.maskMode() == null
? base.maskMode() : MaskMode.valueOf(entry.maskMode().toUpperCase(Locale.ROOT));
return new SystemPolicy(name,
Set<String> companions =
entry.requireCompanion() == null
? base.requireCompanion()
: new HashSet<>(entry.requireCompanion());
MaskMode mode =
entry.maskMode() == null
? base.maskMode()
: MaskMode.valueOf(entry.maskMode().toUpperCase(Locale.ROOT));
return new SystemPolicy(
name,
entry.enabled() == null || entry.enabled(),
entry.demask() == null || entry.demask(),
mode, types, companions, entry.key());
mode,
types,
companions,
entry.key());
}
}
@@ -1,53 +1,43 @@
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;
/**
* Предел одновременных запросов, который сам подстраивается под задержку,
* а не задан фиксированным числом. Растёт, пока обработка укладывается в
* целевое время, и сжимается, как только перестаёт — вместо того чтобы
* копить очередь и подходить к таймауту вызывающей стороны.
* Предел одновременных запросов, который сам подстраивается под задержку, а не задан фиксированным
* числом. Растёт, пока обработка укладывается в целевое время, и сжимается, как только перестаёт —
* вместо того чтобы копить очередь и подходить к таймауту вызывающей стороны.
*
* <p>Число CPU контейнеру намеренно не спрашивается: {@code Runtime.
* availableProcessors()} под квотой {@code --cpus} в cgroups не меняется
* (это не affinity, а квота), поэтому в контейнере с долей ядра оно
* показывает все ядра хоста и как источник предела не годится. Задержка —
* <p>Число CPU контейнеру намеренно не спрашивается: {@code Runtime. availableProcessors()} под
* квотой {@code --cpus} в cgroups не меняется (это не affinity, а квота), поэтому в контейнере с
* долей ядра оно показывает все ядра хоста и как источник предела не годится. Задержка —
* наблюдаемое следствие реальной доли CPU, а не догадка о её размере.
*
* <p>Шаг регулировки привязан к времени, не к числу запросов: при первой
* версии предел менялся на каждый завершённый запрос, и на высоком RPS
* тысячи «быстрых» замеров прилетали за миллисекунды — предел успевал
* разогнаться до потолка ещё до того, как перегрузка вообще проявлялась,
* и то же самое повторялось после каждого восстановления. Проверено
* нагрузочным тестом: без привязки к времени p95 на перегрузке доходил
* до 1,8–2,3 с при 0,5 CPU, хотя предел вроде бы должен был сжаться.
* Не чаще, чем раз в {@link #ADJUST_WINDOW_NANOS}, предел меняется одним
* шагом на основе среднего за окно — так скорость регулировки не зависит
* от того, насколько высок входящий RPS.
* <p>Шаг регулировки привязан к времени, не к числу запросов: при первой версии предел менялся на
* каждый завершённый запрос, и на высоком RPS тысячи «быстрых» замеров прилетали за миллисекунды —
* предел успевал разогнаться до потолка ещё до того, как перегрузка вообще проявлялась, и то же
* самое повторялось после каждого восстановления. Проверено нагрузочным тестом: без привязки к
* времени p95 на перегрузке доходил до 1,8–2,3 с при 0,5 CPU, хотя предел вроде бы должен был
* сжаться. Не чаще, чем раз в {@link #ADJUST_WINDOW_NANOS}, предел меняется одним шагом на основе
* среднего за окно — так скорость регулировки не зависит от того, насколько высок входящий RPS.
*
* <p>Рост — на единицу за окно (AIMD), не удвоением. Удвоение (slow start
* из TCP) здесь не подходит: там обратная связь — RTT, миллисекунды, и
* лишний виток роста стоит дёшево. Здесь обратная связь — время ответа
* заявки, и под перегрузкой оно само составляет секунды: предел успевает
* удвоиться несколько раз (2→4→8→…→сотни) быстрее, чем придёт первый
* сигнал о деградации, и уже принятые заявки не исчезают из очереди, даже
* если следующим окном предел тут же обрушить. Проверено нагрузочным
* тестом: с удвоением p95 на перегрузке всё равно доходил до 1,8–2,2 с.
* Линейный рост копит риск медленно, и первый плохой сигнал останавливает
* его на порядок раньше. Сжатие — вдвое, а не на единицу: на перегрузке
* дешевле один раз отрезать с запасом, чем несколько окон подряд плавно
* подходить к безопасному уровню, пока заявки продолжают копиться.
* <p>Рост — на единицу за окно (AIMD), не удвоением. Удвоение (slow start из TCP) здесь не
* подходит: там обратная связь — RTT, миллисекунды, и лишний виток роста стоит дёшево. Здесь
* обратная связь — время ответа заявки, и под перегрузкой оно само составляет секунды: предел
* успевает удвоиться несколько раз (2→4→8→…→сотни) быстрее, чем придёт первый сигнал о деградации,
* и уже принятые заявки не исчезают из очереди, даже если следующим окном предел тут же обрушить.
* Проверено нагрузочным тестом: с удвоением p95 на перегрузке всё равно доходил до 1,8–2,2 с.
* Линейный рост копит риск медленно, и первый плохой сигнал останавливает его на порядок раньше.
* Сжатие — вдвое, а не на единицу: на перегрузке дешевле один раз отрезать с запасом, чем несколько
* окон подряд плавно подходить к безопасному уровню, пока заявки продолжают копиться.
*
* <p>ponytail: счётчики окна суммируются без блокировки — гонка на границе
* окна может добавить образец в уже подводимый итог или отбросить один,
* не больше; при масштабах в десятки-сотни образцов на окно это не видно.
* Нужен точный регулятор — взять готовую библиотеку вроде Netflix
* {@code concurrency-limits} (Vegas/Gradient2); здесь она не взята из
* осторожности к GraalVM native-image: незнакомая рефлексия в чужой
* библиотеке — это ровно тот класс проблем, из-за которого модели второй
* ступени понадобилась отдельная настройка сборки.
* <p>ponytail: счётчики окна суммируются без блокировки — гонка на границе окна может добавить
* образец в уже подводимый итог или отбросить один, не больше; при масштабах в десятки-сотни
* образцов на окно это не видно. Нужен точный регулятор — взять готовую библиотеку вроде Netflix
* {@code concurrency-limits} (Vegas/Gradient2); здесь она не взята из осторожности к GraalVM
* native-image: незнакомая рефлексия в чужой библиотеке — это ровно тот класс проблем, из-за
* которого модели второй ступени понадобилась отдельная настройка сборки.
*/
public final class AdaptiveConcurrencyLimiter {
@@ -68,9 +58,11 @@ public final class AdaptiveConcurrencyLimiter {
}
/** Настраиваемое окно регулировки — для тестов, которым реальные 20мс на шаг не подходят. */
AdaptiveConcurrencyLimiter(int minLimit, int maxLimit, long targetLatencyMillis, long adjustWindowNanos) {
AdaptiveConcurrencyLimiter(
int minLimit, int maxLimit, long targetLatencyMillis, long adjustWindowNanos) {
if (minLimit < 1 || maxLimit < minLimit) {
throw new IllegalArgumentException("Некорректные границы предела: " + minLimit + ".." + maxLimit);
throw new IllegalArgumentException(
"Некорректные границы предела: " + minLimit + ".." + maxLimit);
}
this.minLimit = minLimit;
this.maxLimit = maxLimit;
+25 -21
View File
@@ -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,17 +10,20 @@ 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;
/**
* Обращение к языковой модели для демонстрационного плеча.
*
* <p>Адрес не задан — работает заглушка: она возвращает присланный текст обратно.
* Для демонстрации этого достаточно, потому что проверяется не качество ответа
* модели, а то, что в модель ушёл замаскированный текст, а потребителю вернулся
* восстановленный.
* <p>Адрес не задан — работает заглушка: она возвращает присланный текст обратно. Для демонстрации
* этого достаточно, потому что проверяется не качество ответа модели, а то, что в модель ушёл
* замаскированный текст, а потребителю вернулся восстановленный.
*
* <p>Модель недоступна или ответила ошибкой — плечо деградирует до той же заглушки,
* а причина попадает в ответ и в журнал. Ронять запрос из-за внешнего сервиса нельзя.
* <p>Модель недоступна или ответила ошибкой — плечо деградирует до той же заглушки, а причина
* попадает в ответ и в журнал. Ронять запрос из-за внешнего сервиса нельзя.
*/
@Component
public class LlmClient {
@@ -33,8 +31,7 @@ public class LlmClient {
private static final Logger LOG = LoggerFactory.getLogger(LlmClient.class);
/** Что вернула модель и кто именно ответил. */
public record Answer(String text, String source) {
}
public record Answer(String text, String source) {}
private final Optional<String> url;
private final Optional<String> apiKey;
@@ -75,8 +72,8 @@ public class LlmClient {
}
/**
* Ответ содержит присланный текст целиком: так на демонстрации видно, что
* подстановки вернулись на свои места при обратном преобразовании.
* Ответ содержит присланный текст целиком: так на демонстрации видно, что подстановки вернулись
* на свои места при обратном преобразовании.
*/
private static String stub(String maskedPrompt) {
return "Ответ по запросу: " + maskedPrompt;
@@ -89,19 +86,26 @@ public class LlmClient {
message.put("role", "user");
message.put("content", maskedPrompt);
HttpRequest.Builder request = HttpRequest.newBuilder(URI.create(url.get()))
HttpRequest.Builder request =
HttpRequest.newBuilder(URI.create(url.get()))
.timeout(timeout)
.header("Content-Type", "application/json")
.POST(HttpRequest.BodyPublishers.ofString(
.POST(
HttpRequest.BodyPublishers.ofString(
mapper.writeValueAsString(body), StandardCharsets.UTF_8));
apiKey.ifPresent(key -> request.header("Authorization", "Bearer " + key));
HttpResponse<String> response = http.send(request.build(),
HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8));
HttpResponse<String> response =
http.send(request.build(), HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8));
if (response.statusCode() / 100 != 2) {
throw new IllegalStateException("модель ответила " + response.statusCode());
}
return mapper.readTree(response.body())
.path("choices").path(0).path("message").path("content").asText();
return mapper
.readTree(response.body())
.path("choices")
.path(0)
.path("message")
.path("content")
.asText();
}
}
@@ -3,24 +3,21 @@ 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;
/**
* Настройка распределений для метрик времени.
*
* <p>По умолчанию Micrometer отдаёт по таймеру только сумму, количество и максимум.
* Этого хватает на среднее, но не на перцентили, а именно они описывают SLA: важно
* не среднее время ответа, а то, сколько запросов уложилось в срок. Гистограмма
* добавляет ряды по корзинам, и {@code histogram_quantile} в Prometheus считает по
* ним p50, p95 и p99.
* <p>По умолчанию Micrometer отдаёт по таймеру только сумму, количество и максимум. Этого хватает
* на среднее, но не на перцентили, а именно они описывают SLA: важно не среднее время ответа, а то,
* сколько запросов уложилось в срок. Гистограмма добавляет ряды по корзинам, и {@code
* histogram_quantile} в Prometheus считает по ним p50, p95 и p99.
*
* <p>Границы корзин заданы явно и подобраны под наши задержки: от четверти
* миллисекунды до десяти секунд. Без явных границ Micrometer создаёт их сам и
* заметно больше, а каждая корзина — это отдельный временной ряд на каждое
* сочетание меток.
* <p>Границы корзин заданы явно и подобраны под наши задержки: от четверти миллисекунды до десяти
* секунд. Без явных границ Micrometer создаёт их сам и заметно больше, а каждая корзина — это
* отдельный временной ряд на каждое сочетание меток.
*/
@Configuration
public class MetricsConfiguration {
@@ -40,7 +37,8 @@ public class MetricsConfiguration {
public MeterFilter histogramsForTimers() {
return new MeterFilter() {
@Override
public DistributionStatisticConfig configure(Meter.Id id, DistributionStatisticConfig config) {
public DistributionStatisticConfig configure(
Meter.Id id, DistributionStatisticConfig config) {
if (!needsHistogram(id.getName())) {
return config;
}
@@ -1,28 +1,26 @@
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;
/**
* Шифрование исходных персональных данных в хранилище.
*
* <p>ПДН не должны лежать в памяти и в общем слое в открытом виде: даже если
* процесс или Redis скомпрометированы, исходные значения остаются недоступными
* без ключа. Используется AES-GCM — аутентифицированное шифрование, которое
* защищает и от подмены шифротекста.
* <p>ПДН не должны лежать в памяти и в общем слое в открытом виде: даже если процесс или Redis
* скомпрометированы, исходные значения остаются недоступными без ключа. Используется AES-GCM —
* аутентифицированное шифрование, которое защищает и от подмены шифротекста.
*
* <p>Ключ задаётся настройкой {@code pdguard.store.encryption-key} (32 байта в
* hex). Пока ключ не задан, шифрование выключено — это нужно для тестов и для
* сборки, где хранилище не содержит чувствительных данных.
* <p>Ключ задаётся настройкой {@code pdguard.store.encryption-key} (32 байта в hex). Пока ключ не
* задан, шифрование выключено — это нужно для тестов и для сборки, где хранилище не содержит
* чувствительных данных.
*/
@Component
public class PayloadCipher {
@@ -36,7 +34,10 @@ public class PayloadCipher {
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);
this.key =
hexKey == null || hexKey.isBlank()
? null
: new SecretKeySpec(HexFormat.of().parseHex(hexKey), ALGORITHM);
}
/** Выключенное шифрование — для тестов и сборки без ключа. */
+30 -31
View File
@@ -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,30 +8,31 @@ 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;
/**
* Соответствие «исходный текст ↔ маска», по которому выполняется демаскирование.
*
* <p>Два индекса: по {@code payload_id} — основной путь, и по отпечатку маски —
* страховка на случай, если идентификатор до сервиса не доехал.
* <p>Два индекса: по {@code payload_id} — основной путь, и по отпечатку маски — страховка на
* случай, если идентификатор до сервиса не доехал.
*
* <p>Оба индекса разделены по системам-потребителям. Индекс по отпечатку ищет
* совпадение по самому тексту запроса, и без такого разделения он превращался бы в
* способ достать чужие данные: маски детерминированы и низкоэнтропийны, поэтому,
* прислав «Клиент И. И. И., паспорт 45** ****56», можно было бы получить в ответ
* исходные значения из запроса другого потребителя. Разделение ограничивает это
* пределами одной системы, которая и так видит свои данные.
* <p>Оба индекса разделены по системам-потребителям. Индекс по отпечатку ищет совпадение по самому
* тексту запроса, и без такого разделения он превращался бы в способ достать чужие данные: маски
* детерминированы и низкоэнтропийны, поэтому, прислав «Клиент И. И. И., паспорт 45** ****56», можно
* было бы получить в ответ исходные значения из запроса другого потребителя. Разделение
* ограничивает это пределами одной системы, которая и так видит свои данные.
*
* <p>Хранилище ограничено по суммарному объёму строк, а записи живут ограниченное
* время: персональные данные не должны залёживаться в памяти, а крупные тексты не
* должны исчерпать кучу. Вытеснение идёт в порядке добавления и выполняется прямо
* на записи — отдельного потока и внешней библиотеки кеширования не требуется.
* <p>Хранилище ограничено по суммарному объёму строк, а записи живут ограниченное время:
* персональные данные не должны залёживаться в памяти, а крупные тексты не должны исчерпать кучу.
* Вытеснение идёт в порядке добавления и выполняется прямо на записи — отдельного потока и внешней
* библиотеки кеширования не требуется.
*
* <p>Когда включён общий слой ({@link SharedIndex}), соответствие пишется ещё и туда,
* а чтение при промахе по локальной памяти идёт в него. Это нужно при работе на
* нескольких узлах: обратный запрос легко попадает не на тот узел, который выполнял
* прямой. Локальная память при этом остаётся первым уровнем, и обычный путь
* обходится без обращения по сети.
* <p>Когда включён общий слой ({@link SharedIndex}), соответствие пишется ещё и туда, а чтение при
* промахе по локальной памяти идёт в него. Это нужно при работе на нескольких узлах: обратный
* запрос легко попадает не на тот узел, который выполнял прямой. Локальная память при этом остаётся
* первым уровнем, и обычный путь обходится без обращения по сети.
*/
@Component
public class PayloadStore {
@@ -44,8 +41,8 @@ public class PayloadStore {
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;
@@ -120,9 +117,8 @@ public class PayloadStore {
}
/**
* Исходный текст по самой маске — когда {@code payload_id} не совпал. Поиск идёт
* только в пределах той же системы: чужую маску подобрать и обменять на исходные
* данные нельзя.
* Исходный текст по самой маске — когда {@code payload_id} не совпал. Поиск идёт только в
* пределах той же системы: чужую маску подобрать и обменять на исходные данные нельзя.
*/
public String originalForMask(String system, String masked) {
String fingerprint = fingerprint(masked);
@@ -137,9 +133,8 @@ public class PayloadStore {
public long charsHeld() {
return charsHeld.get();
}
/**
* Убирает протухшие записи с головы очереди, не более нескольких за раз.
*/
/** Убирает протухшие записи с головы очереди, не более нескольких за раз. */
private void sweepExpired(long now) {
for (int i = 0; i < SWEEP_PER_PUT; i++) {
String oldest = insertionOrder.peek();
@@ -187,8 +182,12 @@ public class PayloadStore {
if (entry == null) {
return null;
}
return new Entry(entry.system(), cipher.decrypt(entry.original()), entry.masked(),
entry.fingerprint(), entry.expiresAt());
return new Entry(
entry.system(),
cipher.decrypt(entry.original()),
entry.masked(),
entry.fingerprint(),
entry.expiresAt());
}
private static String fingerprint(String value) {
+77 -60
View File
@@ -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,26 +27,19 @@ 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;
/**
* Обработка одного обращения: поиск ПД, маскирование и обратное преобразование.
*
* <p>Направление определяется по {@code payload_id}, а не по содержимому запроса:
*
* <ul>
* <li>идентификатор неизвестен — маскируем;</li>
* <li>пришёл ранее выданный нами текст маски — возвращаем исходный текст;</li>
* <li>пришёл тот же исходный текст — возвращаем ту же маску, что и в первый раз.</li>
* <li>идентификатор неизвестен — маскируем;
* <li>пришёл ранее выданный нами текст маски — возвращаем исходный текст;
* <li>пришёл тот же исходный текст — возвращаем ту же маску, что и в первый раз.
* </ul>
* Последний случай — повторная попытка проверяющей системы: ответ обязан
* совпасть с первым, иначе демаскирование по этому элементу развалится.
*
* Последний случай — повторная попытка проверяющей системы: ответ обязан совпасть с первым, иначе
* демаскирование по этому элементу развалится.
*/
@Component
public class Pipeline {
@@ -57,7 +58,11 @@ public class Pipeline {
private final Counter unresolvedDemask;
@Autowired
public Pipeline(RuleRegistry registry, Masker masker, PayloadStore store, MeterRegistry meters,
public Pipeline(
RuleRegistry registry,
Masker masker,
PayloadStore store,
MeterRegistry meters,
NameCascade cascade) {
this.registry = registry;
this.masker = masker;
@@ -65,11 +70,14 @@ public class Pipeline {
this.meters = meters;
this.cascade = cascade;
meters.gauge("pdguard.store.chars", store, PayloadStore::charsHeld);
this.tokensProcessed = Counter.builder("pdguard.tokens.processed")
this.tokensProcessed =
Counter.builder("pdguard.tokens.processed")
.description("Оценка числа обработанных токенов, для расчёта TPS")
.register(meters);
this.unresolvedDemask = Counter.builder("pdguard.demask.unresolved")
.description("Запрос на демаскирование, для которого соответствие не нашлось ни по "
this.unresolvedDemask =
Counter.builder("pdguard.demask.unresolved")
.description(
"Запрос на демаскирование, для которого соответствие не нашлось ни по "
+ "id, ни по отпечатку маски — обработан как новое маскирование")
.register(meters);
}
@@ -114,7 +122,8 @@ public class Pipeline {
// «с нуля», что для настоящего демаскирования даст неверный ответ — считаем
// и логируем каждый такой случай явно, чтобы не потерять его молча.
unresolvedDemask.increment();
LOG.warn("payload_id={} демаскирование не нашло соответствие ни по id, ни по "
LOG.warn(
"payload_id={} демаскирование не нашло соответствие ни по id, ни по "
+ "отпечатку маски — payload обработан как новый (см. pdguard.demask.unresolved)",
payloadId);
}
@@ -122,9 +131,9 @@ public class Pipeline {
}
/**
* Длительность обработки с разрезом по направлению и системе-потребителю.
* Метрики берутся из реестра по тегам: систем немного и они заданы настройками,
* поэтому разрастания рядов не будет, а разрез по потребителям виден сразу.
* Длительность обработки с разрезом по направлению и системе-потребителю. Метрики берутся из
* реестра по тегам: систем немного и они заданы настройками, поэтому разрастания рядов не будет,
* а разрез по потребителям виден сразу.
*/
private void recordLatency(String direction, String system, long startedNanos) {
Timer.builder("pdguard.process")
@@ -136,9 +145,9 @@ public class Pipeline {
}
/**
* Фрагменты, которые будут замаскированы: поиск по правилам, разрешение
* перекрытий и все отсечения. Отдельный метод нужен, чтобы качество детекции
* можно было измерить, не разбирая замаскированный текст обратно.
* Фрагменты, которые будут замаскированы: поиск по правилам, разрешение перекрытий и все
* отсечения. Отдельный метод нужен, чтобы качество детекции можно было измерить, не разбирая
* замаскированный текст обратно.
*/
public List<Span> findPersonalData(String text, SystemPolicy policy) {
List<Span> spans = resolveOverlaps(registry.detect(text, policy));
@@ -162,12 +171,14 @@ public class Pipeline {
}
/**
* Оставляет непересекающиеся фрагменты: при конфликте побеждает более
* приоритетный, при равном приоритете — более длинный.
* Оставляет непересекающиеся фрагменты: при конфликте побеждает более приоритетный, при равном
* приоритете — более длинный.
*/
static List<Span> resolveOverlaps(List<Span> spans) {
List<Span> candidates = new ArrayList<>(spans);
candidates.sort(Comparator.comparingInt(Span::priority).reversed()
candidates.sort(
Comparator.comparingInt(Span::priority)
.reversed()
.thenComparing(Comparator.comparingInt(Span::length).reversed())
.thenComparingInt(Span::start));
@@ -196,27 +207,27 @@ public class Pipeline {
}
/**
* Убирает имена, стоящие в названиях организаций и объектов на карте:
* «Институт Склифосовского», «Музей Тропинина», «улица Королёва». Проверка
* не зависит от того, есть ли в тексте другие ПД: слово перед именем решает
* само по себе.
* Убирает имена, стоящие в названиях организаций и объектов на карте: «Институт Склифосовского»,
* «Музей Тропинина», «улица Королёва». Проверка не зависит от того, есть ли в тексте другие ПД:
* слово перед именем решает само по себе.
*/
static List<Span> dropOrganisationNames(String text, List<Span> spans) {
return spans.stream()
.filter(span -> !PdTypes.FIO.equals(span.type())
.filter(
span ->
!PdTypes.FIO.equals(span.type())
|| !OrganisationDetector.precededByOrganisation(text, span.start()))
.toList();
}
/**
* Убирает имена известных людей: «стихи Александра Пушкина» персональными
* данными не являются. Если же в тексте есть ПД другого типа, речь идёт о
* конкретном человеке, и имя остаётся замаскированным — однофамилец
* исторической фигуры защиту не теряет.
* Убирает имена известных людей: «стихи Александра Пушкина» персональными данными не являются.
* Если же в тексте есть ПД другого типа, речь идёт о конкретном человеке, и имя остаётся
* замаскированным — однофамилец исторической фигуры защиту не теряет.
*/
static List<Span> dropWellKnownNames(String text, List<Span> spans) {
boolean otherPersonalDataPresent = spans.stream()
.anyMatch(span -> !PdTypes.FIO.equals(span.type()));
boolean otherPersonalDataPresent =
spans.stream().anyMatch(span -> !PdTypes.FIO.equals(span.type()));
if (otherPersonalDataPresent) {
return spans;
}
@@ -226,9 +237,9 @@ public class Pipeline {
}
/**
* Известный человек по самому спану («Пушкина») или по спану вместе со следующим
* словом («Ярослав» + «Мудрый»): правило-однослов ловит имя правителя отдельно от
* прозвища, а {@code REGNAL_NAME} распознаёт только двухсловную форму целиком.
* Известный человек по самому спану («Пушкина») или по спану вместе со следующим словом
* («Ярослав» + «Мудрый»): правило-однослов ловит имя правителя отдельно от прозвища, а {@code
* REGNAL_NAME} распознаёт только двухсловную форму целиком.
*/
private static boolean isWellKnownHere(String text, Span span) {
if (NameDictionary.isWellKnown(text.substring(span.start(), span.end()))) {
@@ -246,15 +257,13 @@ public class Pipeline {
}
/**
* Убирает типы, которые опасны только в сочетании с другими ПД.
* Пин-код в отрыве от номера карты не является персональными данными,
* рядом с номером карты — является.
* Убирает типы, которые опасны только в сочетании с другими ПД. Пин-код в отрыве от номера карты
* не является персональными данными, рядом с номером карты — является.
*
* <p>Спутником считается только находка самостоятельного типа. Раньше здесь
* сравнивалось число различных типов, и два спутника заверяли друг друга:
* «Оплата 01.02.2025, ОГРН 1027700132195» маскировалась целиком, хотя человека
* в тексте нет, а дата и ОГРН по отдельности персональными данными не являются.
* Сочетание двух несамостоятельных типов самостоятельным не становится.
* <p>Спутником считается только находка самостоятельного типа. Раньше здесь сравнивалось число
* различных типов, и два спутника заверяли друг друга: «Оплата 01.02.2025, ОГРН 1027700132195»
* маскировалась целиком, хотя человека в тексте нет, а дата и ОГРН по отдельности персональными
* данными не являются. Сочетание двух несамостоятельных типов самостоятельным не становится.
*/
static List<Span> dropLonelyCompanions(List<Span> spans, SystemPolicy policy) {
for (Span span : spans) {
@@ -276,14 +285,21 @@ public class Pipeline {
/**
* Маскирует текст и отдаёт таблицу обратной замены.
*
* <p>Нужно для прокси к языковой модели: ответ модели — другой текст, и восстановить
* его целиком по идентификатору нельзя, замену приходится делать пофрагментно.
* Звёздочки для этого не годятся — одна и та же маска может отвечать разным
* значениям, — поэтому режим замены здесь всегда обратимый.
* <p>Нужно для прокси к языковой модели: ответ модели — другой текст, и восстановить его целиком
* по идентификатору нельзя, замену приходится делать пофрагментно. Звёздочки для этого не годятся
* — одна и та же маска может отвечать разным значениям, — поэтому режим замены здесь всегда
* обратимый.
*/
public Masked maskWithRestorations(String text, SystemPolicy policy) {
SystemPolicy reversible = new SystemPolicy(policy.name(), policy.enabled(), policy.demask(),
MaskMode.TOKEN, policy.types(), policy.requireCompanion(), policy.key());
SystemPolicy reversible =
new SystemPolicy(
policy.name(),
policy.enabled(),
policy.demask(),
MaskMode.TOKEN,
policy.types(),
policy.requireCompanion(),
policy.key());
List<Span> spans = findPersonalData(text, reversible);
if (spans.isEmpty()) {
return new Masked(text, Map.of());
@@ -315,16 +331,17 @@ public class Pipeline {
}
/**
* В журнал и в метрики попадают только идентификатор, типы ПД и их количество.
* На INFO и выше сами значения не логируются; на DEBUG они временно видны через
* отдельный вызов в {@link #mask} — см. комментарий там.
* В журнал и в метрики попадают только идентификатор, типы ПД и их количество. На INFO и выше
* сами значения не логируются; на DEBUG они временно видны через отдельный вызов в {@link #mask}
* — см. комментарий там.
*/
private void logFindings(String system, String payloadId, int length, List<Span> spans) {
Map<String, Integer> counts = new LinkedHashMap<>();
for (Span span : spans) {
counts.merge(span.type(), 1, Integer::sum);
}
counts.forEach((type, count) ->
counts.forEach(
(type, count) ->
meters.counter("pdguard.pd.detected", "type", type, "system", system).increment(count));
LOG.info("payload_id={} символов={} найдено={}", payloadId, length, counts);
}
@@ -1,6 +1,7 @@
package ru.pdguard.core;
import jakarta.annotation.PostConstruct;
import java.util.Set;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Value;
@@ -11,24 +12,20 @@ import ru.pdguard.detect.RuleRegistry;
import ru.pdguard.mask.MaskMode;
import ru.pdguard.mask.Masker;
import java.util.Set;
/**
* Прогон обработки на старте, чтобы первые запросы не попадали на непрогретый код.
*
* <p>На JVM разница измерима: без прогрева первые десятки секунд нагрузки идут по
* интерпретируемому и наспех скомпилированному коду, и p95 оказывается примерно
* вдесятеро хуже установившегося. Несколько тысяч прогонов на старте занимают доли
* секунды и переводят горячий путь на оптимизирующий компилятор до того, как придут
* настоящие запросы.
* <p>На JVM разница измерима: без прогрева первые десятки секунд нагрузки идут по интерпретируемому
* и наспех скомпилированному коду, и p95 оказывается примерно вдесятеро хуже установившегося.
* Несколько тысяч прогонов на старте занимают доли секунды и переводят горячий путь на
* оптимизирующий компилятор до того, как придут настоящие запросы.
*
* <p>Прогрев идёт через отдельный экземпляр обработки со своим короткоживущим
* хранилищем: настоящие соответствия «текст ↔ маска» замусорить нельзя.
* <p>Прогрев идёт через отдельный экземпляр обработки со своим короткоживущим хранилищем: настоящие
* соответствия «текст ↔ маска» замусорить нельзя.
*
* <p>Вторая ступень при прогреве выключена, и не только ради времени: её счётчики
* показывают долю запросов, дошедших до модели, а тысячи служебных прогонов эту
* долю исказили бы до неузнаваемости. Сама модель прогревается отдельно, при
* создании своего пула.
* <p>Вторая ступень при прогреве выключена, и не только ради времени: её счётчики показывают долю
* запросов, дошедших до модели, а тысячи служебных прогонов эту долю исказили бы до неузнаваемости.
* Сама модель прогревается отдельно, при создании своего пула.
*/
@Component
public class PipelineWarmup {
@@ -48,7 +45,9 @@ public class PipelineWarmup {
private final Masker masker;
private final int iterations;
public PipelineWarmup(RuleRegistry registry, Masker masker,
public PipelineWarmup(
RuleRegistry registry,
Masker masker,
@Value("${pdguard.warmup-iterations:2000}") int iterations) {
this.registry = registry;
this.masker = masker;
@@ -62,10 +61,17 @@ public class PipelineWarmup {
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);
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];
@@ -73,7 +79,9 @@ public class PipelineWarmup {
String masked = scratch.process(text, id, policy);
scratch.process(masked, id, policy);
}
LOG.info("Прогрев обработки: {} прогонов за {} мс",
iterations, (System.nanoTime() - started) / 1_000_000);
LOG.info(
"Прогрев обработки: {} прогонов за {} мс",
iterations,
(System.nanoTime() - started) / 1_000_000);
}
}
+4 -6
View File
@@ -3,15 +3,13 @@ package ru.pdguard.core;
/**
* Ключ, однозначно разделяющий системы-потребители.
*
* <p>Длина имени в начале снимает вопрос о разделителе: имя системы может
* содержать любые знаки, и без длины «a:b» и «ab:» были бы неразличимы.
* Используется и в локальном хранилище, и в общем слое — единая реализация
* вместо двух копий.
* <p>Длина имени в начале снимает вопрос о разделителе: имя системы может содержать любые знаки, и
* без длины «a:b» и «ab:» были бы неразличимы. Используется и в локальном хранилище, и в общем слое
* — единая реализация вместо двух копий.
*/
final class ScopedKey {
private ScopedKey() {
}
private ScopedKey() {}
static String of(String system, String key) {
String owner = system == null ? "" : system;
+27 -25
View File
@@ -2,30 +2,28 @@ 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;
/**
* Общий слой соответствий «текст ↔ маска» для работы на нескольких узлах.
*
* <p>Маскирование — чистая функция, на любом узле даёт один и тот же результат.
* Обратное же преобразование требует состояния: если прямой запрос обработал
* один узел, а обратный попал на другой, соответствие должно быть общим.
* <p>Маскирование — чистая функция, на любом узле даёт один и тот же результат. Обратное же
* преобразование требует состояния: если прямой запрос обработал один узел, а обратный попал на
* другой, соответствие должно быть общим.
*
* <p>Включается настройкой {@code pdguard.store.backend=redis}. Пока она не
* выставлена, к Redis не обращаются вовсе и зависимость остаётся неактивной.
* <p>Включается настройкой {@code pdguard.store.backend=redis}. Пока она не выставлена, к Redis не
* обращаются вовсе и зависимость остаётся неактивной.
*
* <p>Недоступность Redis не приводит к отказу: запись и чтение деградируют до
* локальной памяти узла, а ошибка попадает в журнал. Чтобы простой Redis не
* съедал время ответа, команды ограничены по времени настройкой
* {@code spring.data.redis.timeout}, а после нескольких подряд неудач общий слой
* временно перестают опрашивать вовсе.
* <p>Недоступность Redis не приводит к отказу: запись и чтение деградируют до локальной памяти
* узла, а ошибка попадает в журнал. Чтобы простой Redis не съедал время ответа, команды ограничены
* по времени настройкой {@code spring.data.redis.timeout}, а после нескольких подряд неудач общий
* слой временно перестают опрашивать вовсе.
*/
@Component
public class SharedIndex {
@@ -39,8 +37,7 @@ public class SharedIndex {
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;
@@ -52,7 +49,8 @@ public class SharedIndex {
private volatile long silentUntil;
private volatile boolean reported;
public SharedIndex(StringRedisTemplate redis,
public SharedIndex(
StringRedisTemplate redis,
@Value("${pdguard.store.backend:memory}") String backend,
@Value("${pdguard.store.ttl-minutes:30}") int ttlMinutes,
ObjectMapper mapper,
@@ -73,15 +71,16 @@ public class SharedIndex {
return enabled;
}
public void put(String system, String payloadId, String original, String masked,
String maskFingerprint) {
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, payloadId), toJson(new SharedEntry(encrypted, masked)), ttl);
redis.opsForValue().set(ScopedKey.of(system, maskFingerprint), encrypted, ttl);
noteSuccess();
} catch (RuntimeException e) {
@@ -97,7 +96,9 @@ public void put(String system, String payloadId, String original, String masked,
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());
return entry == null
? null
: new SharedEntry(cipher.decrypt(entry.original()), entry.masked());
} catch (RuntimeException e) {
noteFailure("прочитать", e);
return null;
@@ -147,9 +148,9 @@ public void put(String system, String payloadId, String original, String masked,
}
/**
* После нескольких неудач подряд общий слой перестают опрашивать на несколько
* секунд: иначе каждый запрос платил бы таймаутом за недоступный Redis, а
* проверяющая система считает ответ дольше десяти секунд неответом.
* После нескольких неудач подряд общий слой перестают опрашивать на несколько секунд: иначе
* каждый запрос платил бы таймаутом за недоступный Redis, а проверяющая система считает ответ
* дольше десяти секунд неответом.
*/
private void noteFailure(String action, RuntimeException cause) {
if (consecutiveFailures.incrementAndGet() >= FAILURES_TO_OPEN) {
@@ -157,7 +158,8 @@ public void put(String system, String payloadId, String original, String masked,
}
if (!reported) {
reported = true;
LOG.error("Не удалось {} соответствие в общий слой, узел работает на своей памяти", action, cause);
LOG.error(
"Не удалось {} соответствие в общий слой, узел работает на своей памяти", action, cause);
}
}
}
@@ -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,61 +7,91 @@ 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<Rule> RULES = List.of(
static final List<Rule> RULES =
List.of(
// «выдан ОУФМС России по г. Москве 12.05.2015» — дата в состав органа не входит,
// её забирает отдельное правило. Приоритет выше городского, иначе от органа
// осталась бы замаскированной только его часть.
// Перечень форм, не голая основа «выда»: она зацепила бы и «выдающийся»
// (обычное слово, не про выдачу документа).
Rule.of(PdTypes.PASSPORT_ISSUER, "(?iu:выдан|выдал[аио]?|выдали|выдач[аи]|выдаче)"
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)
+ "(?=\\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)
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)
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)
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)
Rule.of(
PdTypes.BIRTH_PLACE,
"(?iu:родил(?:ся|ась))[^,;\\n]{0,40}?\\s+в\\s+"
+ "([^,;\\n]{3,40}?)(?=\\s*[,;\\n]|\\s*$)",
76)
.groups(1)
.anchoredBy("родил"),
// ROLE_GAP, не \W{0,5}: «Гражданство бенефициара по договору страхования: Х» —
// между якорем и значением бывает несколько слов, не только пунктуация.
// Список через запятую/слэш — вторая опциональная группа тем же шаблоном.
Rule.of(PdTypes.CITIZENSHIP, "(?iu:гражданств)\\w*" + CITIZENSHIP_GAP
+ "(" + CITIZENSHIP_VALUE + ")(?:\\s*[,/]\\s*(" + CITIZENSHIP_VALUE + "))?", 80)
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)
Rule.of(
PdTypes.CITIZENSHIP,
"(?iu:граждан(?:ин|ка|ина|ки|ином|кой))\\b\\s+"
+ "("
+ CITIZENSHIP_VALUE
+ ")(?:\\s*[,/]\\s*("
+ CITIZENSHIP_VALUE
+ "))?",
75)
.groups(1, 2)
.validatedBy(CountryDictionary::isKnownCountry)
.anchoredBy("граждан"),
@@ -74,28 +102,36 @@ final class AddressRules {
.groups(1)
.vetoedBy(ORGANISATION_NEARBY)
.anchoredBy("индекс"),
Rule.of(PdTypes.ADDRESS_POSTCODE,
"\\b(\\d{6})(?=\\s*,?\\s*(?iu:г\\.|город|обл\\.|область|респ|край))", 74)
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"
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)
+ "|\\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,
.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)
+ "\\W{0,3}("
+ STREET_NAME
+ ")",
73)
.groups(1)
.vetoedBy(ORGANISATION_NEARBY)
.requiringNear(ADDRESS_NEARBY)
@@ -104,29 +140,32 @@ final class AddressRules {
// «Невский пр-т» — указатель после названия. Форма слишком общая, поэтому
// принимается только рядом с другими частями адреса: иначе под маску попал бы
// любой рассказ про Невский проспект.
Rule.of(PdTypes.ADDRESS_STREET, "\\b(\\p{Lu}[\\p{L}-]{2,30})\\s+"
+ "(?iu:пр-т|проспект|улиц\\p{L}*|шоссе|бульвар|переул\\p{L}*|набережн\\p{L}*)\\b", 73)
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)
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,
Rule.of(
PdTypes.ADDRESS_COUNTRY,
"(?iu:стран\\p{L}*(?:\\s+(?:регистрации|проживания|гражданства))?)"
+ "\\W{0,5}(\\p{Lu}[\\p{L}-]{2,30})\\b", 71)
+ "\\W{0,5}(\\p{Lu}[\\p{L}-]{2,30})\\b",
71)
.groups(1)
.vetoedBy(ORGANISATION_NEARBY)
.anchoredBy("стран")
);
.anchoredBy("стран"));
}
@@ -1,17 +1,16 @@
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() {
}
static final List<Rule> RULES = List.of(
private ContactRules() {}
static final List<Rule> RULES =
List.of(
Rule.of(PdTypes.INN, "(?iu)\\bИНН\\b" + ROLE_GAP + "(\\d{12}|\\d{10})\\b", 84)
.groups(1)
.anchoredBy("инн"),
@@ -20,19 +19,22 @@ final class ContactRules {
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)
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.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));
}
@@ -4,27 +4,26 @@ import java.util.Locale;
import java.util.Set;
/**
* Словарь названий стран — проверка того, что значение, пойманное правилом
* {@code CITIZENSHIP}, действительно похоже на страну, а не на произвольное
* слово с заглавной буквы после якоря «гражданство».
* Словарь названий стран — проверка того, что значение, пойманное правилом {@code CITIZENSHIP},
* действительно похоже на страну, а не на произвольное слово с заглавной буквы после якоря
* «гражданство».
*
* <p>Сравнение по началу слова, а не точным совпадением: падежные окончания
* («в России», «из Казахстана») и формы прилагательных («российская»,
* «российское») тем самым покрываются без отдельного разбора морфологии.
* Основа «российск» покрывает и «Российская», и «российская», и «российское».
* <p>Сравнение по началу слова, а не точным совпадением: падежные окончания («в России», «из
* Казахстана») и формы прилагательных («российская», «российское») тем самым покрываются без
* отдельного разбора морфологии. Основа «российск» покрывает и «Российская», и «российская», и
* «российское».
*/
public final class CountryDictionary {
private static final Set<String> COUNTRY_STEMS = ResourceLoader.set("/names/countries.txt");
private CountryDictionary() {
}
private CountryDictionary() {}
/**
* Похоже ли значение на название страны из словаря в любом падеже и регистре.
*
* <p>Проверяются префиксы значения по множеству, а не каждая основа по
* значению: префиксов у слова не больше, чем в нём букв.
* <p>Проверяются префиксы значения по множеству, а не каждая основа по значению: префиксов у
* слова не больше, чем в нём букв.
*/
public static boolean isKnownCountry(String value) {
String lower = value.strip().toLowerCase(Locale.ROOT);
+19 -18
View File
@@ -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<Rule> RULES = List.of(
Rule.of(PdTypes.BIRTH_DATE, "(?iu:дат\\p{L}*\\s+рождения|дата\\s+рожд\\.)"
+ DATE_GAP + "(" + DATE_ANY + ")", 87)
static final List<Rule> RULES =
List.of(
Rule.of(
PdTypes.BIRTH_DATE,
"(?iu:дат\\p{L}*\\s+рождения|дата\\s+рожд\\.)" + DATE_GAP + "(" + DATE_ANY + ")",
87)
.groups(1)
.validatedBy(Validators::date)
.anchoredBy("рожден"),
Rule.of(PdTypes.BIRTH_DATE, "(?iu:родил(?:ся|ась))"
+ DATE_GAP + "(" + DATE_ANY + ")", 87)
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)
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)
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));
}
+16 -20
View File
@@ -3,40 +3,36 @@ package ru.pdguard.detect;
import java.util.Locale;
/**
* Общий приём для словарей, сравнивающих слово из текста с основой из списка:
* личные имена ({@link NameDictionary}) и города ({@link ToponymDictionary}).
* Общий приём для словарей, сравнивающих слово из текста с основой из списка: личные имена ({@link
* NameDictionary}) и города ({@link ToponymDictionary}).
*
* <p>Слова на согласную склоняются добавлением окончания («Тамбов» → «Тамбове»,
* «Пушкин» → «Пушкина») — там основы из списка достаточно как есть. Слова на
* гласную меняют последнюю букву («Москва» → «Москве», «Ольга» → «Ольге») —
* для них сравнение идёт по основе без неё.
* <p>Слова на согласную склоняются добавлением окончания («Тамбов» → «Тамбове», «Пушкин» →
* «Пушкина») — там основы из списка достаточно как есть. Слова на гласную меняют последнюю букву
* («Москва» → «Москве», «Ольга» → «Ольге») — для них сравнение идёт по основе без неё.
*
* <p>Фамилии на «-ский» склоняются как прилагательное: окончание меняется
* целиком («Дзержинский» → «Дзержинского», «-ий» на «-ого», а не дописывается),
* поэтому для них отсечения одной буквы недостаточно — основа обрезается сразу
* до «ск». Для улиц в честь людей это не редкий случай, а основной: «улица
* Дзержинского», «улица Островского» пишутся только в родительном падеже,
* <p>Фамилии на «-ский» склоняются как прилагательное: окончание меняется целиком («Дзержинский» →
* «Дзержинского», «-ий» на «-ого», а не дописывается), поэтому для них отсечения одной буквы
* недостаточно — основа обрезается сразу до «ск». Для улиц в честь людей это не редкий случай, а
* основной: «улица Дзержинского», «улица Островского» пишутся только в родительном падеже,
* именительный там не встречается вообще.
*/
final class Declension {
/**
* Падежные окончания прилагательного склонения на «-ск-»: мужской, женский
* и средний род, все падежи. Проверяются от длинных к коротким — «-ского»
* не должно потеряться из-за более короткого совпадения на «-ким» и т.п.
* Падежные окончания прилагательного склонения на «-ск-»: мужской, женский и средний род, все
* падежи. Проверяются от длинных к коротким — «-ского» не должно потеряться из-за более короткого
* совпадения на «-ким» и т.п.
*/
private static final String[] ADJECTIVE_ENDINGS = {
"ского", "скому", "ским", "ском", "скую", "ской", "скою", "ская", "ский"
};
private Declension() {
}
private Declension() {}
/**
* Отбрасывает у основы окончание, которое меняется по падежам: гласную —
* у обычных слов, целиком «-ск-»-окончание — у прилагательных фамилий.
* Слова короче четырёх букв не трогает — короткая основа и так шире
* большинства падежных форм.
* Отбрасывает у основы окончание, которое меняется по падежам: гласную — у обычных слов, целиком
* «-ск-»-окончание — у прилагательных фамилий. Слова короче четырёх букв не трогает — короткая
* основа и так шире большинства падежных форм.
*/
static String withoutInflectedEnding(String word) {
String lower = word.toLowerCase(Locale.ROOT);
@@ -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<Rule> RULES = List.of(
static final List<Rule> RULES =
List.of(
// CVV: латиница, кириллическая транслитерация («цвв», «сививи») и
// описательные якоря («код на обороте карты»). Между якорем и числом
// допускаются слова («CVV код 321», «CVV указан код 123») и длинные
// разделители («код на обороте карты 789»).
Rule.of(PdTypes.CVV, "(?iu:\\b(?:cvv2?|cvc2?|цвв|сививи|код\\p{L}*\\s+на\\s+обороте\\s+карты"
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)
+ "(?:\\s+\\p{L}+){0,2}\\W{0,30}(\\d{3,4})\\b",
92)
.groups(1)
.anchoredBy("cvv", "cvc", "цвв", "сививи", "код на обороте", "код проверки", "защитный код"),
.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)
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)
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)
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)
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)
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)
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)
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)
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)
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("подразделени")
);
.anchoredBy("подразделени"));
}
@@ -1,30 +1,33 @@
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<Rule> RULES = List.of(
static final List<Rule> RULES =
List.of(
// Расчётный счёт — ровно 20 цифр после якоря, группировка пробелами не важна.
Rule.of(PdTypes.ACCOUNT_NUMBER, "(?iu:р/с|расчетн\\w*\\s+счет|расчётн\\w*\\s+счёт|лицев\\w*\\s+счет|"
+ "лицев\\w*\\s+счёт)\\W{0,5}((?:\\d[ ]?){19}\\d)\\b", 83)
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)
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"),
@@ -35,38 +38,40 @@ final class FinanceRules {
.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("кпп"),
Rule.of(PdTypes.KPP, "(?iu:кпп)\\W{0,5}(\\d{9})\\b", 83).groups(1).anchoredBy("кпп"),
// Доход/зарплата: сумма с разделителями тысяч. Между якорем и суммой может
// стоять слово («доход клиента», «доход за год») — без этого якорь ловил
// бы только «доход 85000», вплотную.
Rule.of(PdTypes.INCOME, "(?iu:доход|заработн\\w*\\s+плат\\w*|зарплат\\w*)(?:\\s+\\p{L}+){0,3}?"
+ "\\W{0,5}(\\d{1,3}(?:[\\s.]?\\d{3})*(?:,\\d{2})?)\\s?(?iu:руб\\p{L}*|₽)?\\b", 76)
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)
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)
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));
}
+76 -24
View File
@@ -1,30 +1,36 @@
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<Rule> RULES = List.of(
static final List<Rule> 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),
@@ -34,27 +40,52 @@ final class FioRules {
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)
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плательщик"
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)
+ "\\W{0,5}(\\p{Lu}\\p{Ll}+(?:\\s+\\p{Lu}\\p{Ll}+){0,2})\\b",
77)
.groups(1)
.anchoredBy("клиент", "заказчик", "пациент", "сотрудник", "владел", "плательщик",
"получател", "абонент", "в лице", "представител", "поручител", "заёмщик",
"заемщик", "заявител", "доверител", "вкладчик", "ответственн",
"контактное лицо", "исполнител", HOLDER_STEM),
.anchoredBy(
"клиент",
"заказчик",
"пациент",
"сотрудник",
"владел",
"плательщик",
"получател",
"абонент",
"в лице",
"представител",
"поручител",
"заёмщик",
"заемщик",
"заявител",
"доверител",
"вкладчик",
"ответственн",
"контактное лицо",
"исполнител",
HOLDER_STEM),
// «клиент иван иванов», «поручитель петрович» — строчные имена после
// ролевого слова. Регистр снимает требование к заглавной букве, а словарь
// имён отсекает «клиент пришёл в офис».
Rule.of(PdTypes.FIO, "(?iu:\\bклиент|\\bзаказчик|\\bпациент|\\bсотрудник|\\bвладел|\\bплательщик"
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держател"
@@ -63,14 +94,36 @@ final class FioRules {
// движку «отдать» уже съеденное падежное окончание ролевого слова и
// захватить его как будто отдельное имя — «пациентов» ловилось бы как «ов».
+ "|\\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)
+ "(?:\\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, "отправител", "бенефициар",
"доверенное лицо", "наследник", "созаемщик"),
.anchoredBy(
"клиент",
"заказчик",
"пациент",
"сотрудник",
"владел",
"плательщик",
"получател",
"абонент",
"в лице",
"представител",
"поручител",
"заёмщик",
"заемщик",
"заявител",
"доверител",
"вкладчик",
"ответственн",
"контактное лицо",
"исполнител",
HOLDER_STEM,
"отправител",
"бенефициар",
"доверенное лицо",
"наследник",
"созаемщик"),
// Фамилия рядом с личным именем из словаря: без словаря правило ловило бы
// «Тверская улица» и тому подобное. Имя проверяется по множеству уже
@@ -95,6 +148,5 @@ final class FioRules {
// имени, и словообразовательная эвристика ложно ловит «Магазин», «Отдел».
Rule.of(PdTypes.FIO, "(?<!^)\\b(\\p{L}{2,})\\b", 70)
.groups(1)
.validatedBy(NameDictionary::isStandaloneNameCandidate)
);
.validatedBy(NameDictionary::isStandaloneNameCandidate));
}
+171 -97
View File
@@ -5,13 +5,6 @@ import io.micrometer.core.instrument.MeterRegistry;
import io.micrometer.core.instrument.Timer;
import io.micrometer.core.instrument.simple.SimpleMeterRegistry;
import jakarta.annotation.PreDestroy;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
import ru.pdguard.config.SystemPolicy;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.List;
@@ -23,28 +16,33 @@ import java.util.concurrent.Semaphore;
import java.util.concurrent.TimeUnit;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
import ru.pdguard.config.SystemPolicy;
/**
* Вторая ступень распознавания.
*
* <p>Правила и словарь разбирают подавляющее большинство случаев и стоят десятки
* микросекунд. Модель нужна там, где они бессильны: имена без русского
* словообразования и нестандартные топонимы.
* <p>Правила и словарь разбирают подавляющее большинство случаев и стоят десятки микросекунд.
* Модель нужна там, где они бессильны: имена без русского словообразования и нестандартные
* топонимы.
*
* <p>Поэтому модель зовут не на весь текст, а только на кандидатов — цепочки из
* двух-трёх слов с заглавной буквы, которые первая ступень не покрыла. Их в обычном
* запросе единицы, и на задержку это почти не влияет. Дороже модель — тем важнее
* такая экономия: у BERT вызов стоит десятки миллисекунд, и звать его на каждый
* запрос было бы невозможно.
* <p>Поэтому модель зовут не на весь текст, а только на кандидатов — цепочки из двух-трёх слов с
* заглавной буквы, которые первая ступень не покрыла. Их в обычном запросе единицы, и на задержку
* это почти не влияет. Дороже модель — тем важнее такая экономия: у BERT вызов стоит десятки
* миллисекунд, и звать его на каждый запрос было бы невозможно.
*
* <p>Используются две модели под разные задачи: одна размечает имена (например,
* WikiNEuRal, который не распознаёт известных личностей), другая — составляющие
* адреса (например, ruBERT с детальными метками страны, региона, района, города,
* улицы и дома). Каждая модель зовётся только на непокрытые кандидаты.
* <p>Используются две модели под разные задачи: одна размечает имена (например, WikiNEuRal, который
* не распознаёт известных личностей), другая — составляющие адреса (например, ruBERT с детальными
* метками страны, региона, района, города, улицы и дома). Каждая модель зовётся только на
* непокрытые кандидаты.
*
* <p>Ступень выключена, пока не задан движок. Сбой ступени на первую не влияет:
* ошибка перехватывается здесь, ступень выключается насовсем, и дальше работают
* правила. Иначе одно исключение обнуляло бы маскирование целиком.
* <p>Ступень выключена, пока не задан движок. Сбой ступени на первую не влияет: ошибка
* перехватывается здесь, ступень выключается насовсем, и дальше работают правила. Иначе одно
* исключение обнуляло бы маскирование целиком.
*/
@Component
public class NameCascade {
@@ -53,15 +51,16 @@ public class NameCascade {
/** Имя метрики обращений ко второй ступени, её описание и имя метки исхода. */
private static final String NER_REQUESTS_METRIC = "pdguard.ner.requests";
private static final String NER_REQUESTS_DESCRIPTION = "Обращения, дошедшие до второй ступени";
private static final String OUTCOME_TAG = "outcome";
/** Метки WikiNEuRal в типы ПД: только PER — имя. Адреса размечает ruBERT. */
private static final Map<String, String> NAME_TYPES = Map.of(
"PER", PdTypes.FIO);
private static final Map<String, String> NAME_TYPES = Map.of("PER", PdTypes.FIO);
/** Метки ruBERT в типы ПД: детальные составляющие адреса. */
private static final Map<String, String> ADDRESS_TYPES = Map.of(
private static final Map<String, String> ADDRESS_TYPES =
Map.of(
"COUNTRY", PdTypes.ADDRESS_COUNTRY,
"REGION", PdTypes.ADDRESS_REGION,
"DISTRICT", PdTypes.ADDRESS_DISTRICT,
@@ -70,11 +69,12 @@ public class NameCascade {
"HOUSE", PdTypes.ADDRESS_HOUSE);
/**
* Метки LLAIM Legal NER в типы ПД: юридические реквизиты и документы, которых
* нет в общих моделях. ADDRESS не сопоставляется — ruBERT размечает адреса
* детальнее. ORG, CASE_NUMBER и POSITION аналогов в {@link PdTypes} не имеют.
* Метки LLAIM Legal NER в типы ПД: юридические реквизиты и документы, которых нет в общих
* моделях. ADDRESS не сопоставляется — ruBERT размечает адреса детальнее. ORG, CASE_NUMBER и
* POSITION аналогов в {@link PdTypes} не имеют.
*/
private static final Map<String, String> LEGAL_TYPES = Map.of(
private static final Map<String, String> LEGAL_TYPES =
Map.of(
"PER", PdTypes.FIO,
"INN", PdTypes.INN,
"OGRN", PdTypes.OGRN,
@@ -86,17 +86,19 @@ public class NameCascade {
"DATE", PdTypes.DATE);
/** Цепочка из двух-трёх слов с заглавной буквы — то, что может оказаться ПД. */
private static final Pattern CANDIDATE = Pattern.compile(
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: одиночное слово или цифровой кластер
* (10–19 цифр с разделителями). Юридические реквизиты (ИНН, СНИЛС, паспорт)
* не являются словами с заглавной буквы, поэтому для них нужен отдельный
* проход, не влияющий на кандидатов моделей имён и адресов.
* Кандидат для LLAIM Legal NER: одиночное слово или цифровой кластер (10–19 цифр с
* разделителями). Юридические реквизиты (ИНН, СНИЛС, паспорт) не являются словами с заглавной
* буквы, поэтому для них нужен отдельный проход, не влияющий на кандидатов моделей имён и
* адресов.
*/
private static final Pattern LEGAL_CANDIDATE = Pattern.compile(
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);
@@ -114,11 +116,12 @@ public class NameCascade {
private volatile boolean broken;
/**
* Сколько обращений дошло до модели, а сколько обошлось правилами. Отношение
* {@code engaged} ко всем обращениям и есть та доля, от которой зависит,
* посильна ли тяжёлая модель на боевом трафике.
* Сколько обращений дошло до модели, а сколько обошлось правилами. Отношение {@code engaged} ко
* всем обращениям и есть та доля, от которой зависит, посильна ли тяжёлая модель на боевом
* трафике.
*/
private final Counter engaged;
private final Counter withoutCandidates;
private final Counter busy;
private final Counter candidates;
@@ -140,49 +143,85 @@ public class NameCascade {
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)
this.engaged =
Counter.builder(NER_REQUESTS_METRIC)
.description(NER_REQUESTS_DESCRIPTION)
.tag(OUTCOME_TAG, "engaged").register(meters);
this.withoutCandidates = Counter.builder(NER_REQUESTS_METRIC)
.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)
.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);
.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);
this(
config.nameEngine(),
config.nameModel().orElse(""),
config.addressEngine(),
config.addressModel().orElse(""),
config.legalEngine(),
config.legalModel().orElse(""),
maxCandidates,
poolSize,
meters);
}
/** Конструктор для тестов: одна модель для имён, метрики никуда не отдаются. */
public NameCascade(String engine, Optional<String> modelPath, int maxCandidates, int poolSize) {
this(new EngineConfig(engine, modelPath, "off", Optional.empty(), "off", Optional.empty()),
maxCandidates, poolSize, new SimpleMeterRegistry());
this(
new EngineConfig(engine, modelPath, "off", Optional.empty(), "off", Optional.empty()),
maxCandidates,
poolSize,
new SimpleMeterRegistry());
}
/** Конструктор для тестов двух моделей: метрики никуда не отдаются. */
public NameCascade(String nameEngine, Optional<String> nameModel,
String addressEngine, Optional<String> addressModel,
int maxCandidates, int poolSize) {
this(new EngineConfig(nameEngine, nameModel, addressEngine, addressModel, "off", Optional.empty()),
maxCandidates, poolSize, new SimpleMeterRegistry());
public NameCascade(
String nameEngine,
Optional<String> nameModel,
String addressEngine,
Optional<String> addressModel,
int maxCandidates,
int poolSize) {
this(
new EngineConfig(
nameEngine, nameModel, addressEngine, addressModel, "off", Optional.empty()),
maxCandidates,
poolSize,
new SimpleMeterRegistry());
}
/** Конструктор для тестов двух моделей с явным реестром метрик. */
public NameCascade(String nameEngine, Optional<String> nameModel,
String addressEngine, Optional<String> addressModel,
int maxCandidates, int poolSize, MeterRegistry meters) {
this(new EngineConfig(nameEngine, nameModel, addressEngine, addressModel, "off", Optional.empty()),
maxCandidates, poolSize, meters);
public NameCascade(
String nameEngine,
Optional<String> nameModel,
String addressEngine,
Optional<String> addressModel,
int maxCandidates,
int poolSize,
MeterRegistry meters) {
this(
new EngineConfig(
nameEngine, nameModel, addressEngine, addressModel, "off", Optional.empty()),
maxCandidates,
poolSize,
meters);
}
/** Конструктор для тестов трёх моделей: метрики никуда не отдаются. */
@@ -191,16 +230,18 @@ public class NameCascade {
}
/** Конфигурация трёх движков второй ступени: имя, адрес и юридические реквизиты. */
public record EngineConfig(String nameEngine, Optional<String> nameModel,
String addressEngine, Optional<String> addressModel,
String legalEngine, Optional<String> legalModel) {
}
public record EngineConfig(
String nameEngine,
Optional<String> nameModel,
String addressEngine,
Optional<String> addressModel,
String legalEngine,
Optional<String> legalModel) {}
/**
* Выключенная ступень для служебных нужд — прогрева и тестов. Отдельный
* конструктор, а не обычный путь: иначе в журнале рядом с сообщением о готовности
* распознавателя появлялось бы сообщение о его выключении, и было бы непонятно,
* что в итоге работает.
* Выключенная ступень для служебных нужд — прогрева и тестов. Отдельный конструктор, а не обычный
* путь: иначе в журнале рядом с сообщением о готовности распознавателя появлялось бы сообщение о
* его выключении, и было бы непонятно, что в итоге работает.
*/
private NameCascade() {
this.maxCandidates = 0;
@@ -221,30 +262,39 @@ public class NameCascade {
}
public boolean enabled() {
return (nameRecogniser != null || addressRecogniser != null || legalRecogniser != null) && !broken;
return (nameRecogniser != null || addressRecogniser != null || legalRecogniser != null)
&& !broken;
}
/**
* Покрывает ли каскад хоть один тип, разрешённый политикой. Нужно, чтобы
* {@code Pipeline} звал вторую ступень не только ради ФИО, но и ради адресов
* и юридических реквизитов, которые размечает LLAIM Legal NER.
* Покрывает ли каскад хоть один тип, разрешённый политикой. Нужно, чтобы {@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);
|| policy.allows(PdTypes.ADDRESS_COUNTRY)
|| policy.allows(PdTypes.ADDRESS_REGION)
|| policy.allows(PdTypes.ADDRESS_DISTRICT)
|| policy.allows(PdTypes.ADDRESS_CITY)
|| policy.allows(PdTypes.ADDRESS_STREET)
|| policy.allows(PdTypes.ADDRESS_HOUSE)
|| policy.allows(PdTypes.INN)
|| policy.allows(PdTypes.OGRN)
|| policy.allows(PdTypes.SNILS)
|| policy.allows(PdTypes.PASSPORT)
|| policy.allows(PdTypes.PHONE)
|| policy.allows(PdTypes.EMAIL)
|| policy.allows(PdTypes.ACCOUNT_NUMBER)
|| policy.allows(PdTypes.DATE);
}
/**
* Добавляет ПД, которые не нашла первая ступень. Уже принятые фрагменты не
* трогаются: модели разбирают только непокрытые участки.
* Добавляет ПД, которые не нашла первая ступень. Уже принятые фрагменты не трогаются: модели
* разбирают только непокрытые участки.
*/
public List<Span> addMissedNames(String text, List<Span> accepted) {
if (!enabled()) {
@@ -294,7 +344,11 @@ public class NameCascade {
}
}
private void collect(String text, int candidateStart, int candidateEnd, List<Span> sink,
private void collect(
String text,
int candidateStart,
int candidateEnd,
List<Span> sink,
RuBertRecogniser recogniser) {
if (recogniser == null) {
return;
@@ -315,8 +369,11 @@ private void collect(String text, int candidateStart, int candidateEnd, List<Spa
// полное имя при разрешении перекрытий. Убираем такие частичные находки правил,
// чтобы полное имя от модели осталось: избыточное покрытие безопаснее утечки ПД.
if (nameFound) {
sink.removeIf(span -> PdTypes.FIO.equals(span.type())
&& span.start() < candidateEnd && candidateStart < span.end()
sink.removeIf(
span ->
PdTypes.FIO.equals(span.type())
&& span.start() < candidateEnd
&& candidateStart < span.end()
&& span.priority() > PRIORITY);
}
}
@@ -327,17 +384,31 @@ private void collect(String text, int candidateStart, int candidateEnd, List<Spa
}
/**
* Слова-маркеры ПД, которые модель иногда ошибочно помечает как ФИО
* («ИНН», «СНИЛС», «паспорт»). Такие находки — шум: это не имена, а
* обозначения реквизитов, и маскировать их как ФИО нельзя.
* Слова-маркеры ПД, которые модель иногда ошибочно помечает как ФИО («ИНН», «СНИЛС», «паспорт»).
* Такие находки — шум: это не имена, а обозначения реквизитов, и маскировать их как ФИО нельзя.
*/
private static final Set<String> PD_MARKERS = Set.of(
"инн", "снилс", "огрн", "огрнип", "кпп", "бик", "паспорт", "счёт", "счет",
"телефон", "email", "почта", "дата", "адрес", "полис", "свидетельство");
private static final Set<String> PD_MARKERS =
Set.of(
"инн",
"снилс",
"огрн",
"огрнип",
"кпп",
"бик",
"паспорт",
"счёт",
"счет",
"телефон",
"email",
"почта",
"дата",
"адрес",
"полис",
"свидетельство");
/**
* Принимает находку модели, если она пересекается с кандидатом и проходит
* те же условия, что и находки правил.
* Принимает находку модели, если она пересекается с кандидатом и проходит те же условия, что и
* находки правил.
*/
private static boolean isAccepted(String text, int candidateStart, int candidateEnd, Span span) {
// Берём только пересекающееся с кандидатом: контекст добавлен ради
@@ -358,13 +429,16 @@ private void collect(String text, int candidateStart, int candidateEnd, List<Spa
|| RuleRegistry.hasAddressContext(text, span.start(), span.end());
}
private static RuBertRecogniser create(String engine, String modelPath, Map<String, String> types) {
private static RuBertRecogniser create(
String engine, String modelPath, Map<String, String> types) {
String chosen = engine == null ? "off" : engine.toLowerCase(Locale.ROOT).strip();
if ("off".equals(chosen) || modelPath == null || modelPath.isBlank()) {
LOG.info("Вторая ступень распознавания выключена");
return null;
}
if (!"rubert".equals(chosen) && !"wikineural".equals(chosen) && !"ru-legal-ner".equals(chosen)) {
if (!"rubert".equals(chosen)
&& !"wikineural".equals(chosen)
&& !"ru-legal-ner".equals(chosen)) {
LOG.warn("Неизвестный движок второй ступени: {}, ступень выключена", chosen);
return null;
}
@@ -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,29 +8,31 @@ 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;
/**
* Словари для распознавания ФИО.
*
* <p>Личные имена нужны, чтобы морфология фамилий не срабатывала на чём попало:
* «Тверская» по окончанию похожа на фамилию, но рядом с ней нет личного имени.
* <p>Личные имена нужны, чтобы морфология фамилий не срабатывала на чём попало: «Тверская» по
* окончанию похожа на фамилию, но рядом с ней нет личного имени.
*
* <p>Список известных людей решает обратную задачу — упоминание Пушкина
* персональными данными не является. Ограничение осознанное: клиент по фамилии
* Пушкин в тексте без других ПД замаскирован не будет.
* <p>Список известных людей решает обратную задачу — упоминание Пушкина персональными данными не
* является. Ограничение осознанное: клиент по фамилии Пушкин в тексте без других ПД замаскирован не
* будет.
*
* <p>Базовый список собран в сборку из {@code /names/well-known.txt}. Поверх
* него можно дописать своих публичных лиц без пересборки — файл по пути
* {@code pdguard.well-known-file} (по умолчанию {@code config/well-known.txt})
* перечитывается сам при изменении, тем же приёмом, что {@code systems.json}
* в {@link ru.pdguard.config.SystemsConfig}: раз в секунду сверяется время
* изменения, содержимое читается заново только когда оно другое.
* <p>Базовый список собран в сборку из {@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 List<String> GIVEN_NAME_STEMS = ResourceLoader.lines("/names/given-names.txt", true).stream()
private static final List<String> GIVEN_NAME_STEMS =
ResourceLoader.lines("/names/given-names.txt", true).stream()
.map(Declension::withoutInflectedEnding)
.distinct()
.sorted(Comparator.comparingInt(String::length).reversed())
@@ -42,7 +41,8 @@ public final class NameDictionary {
// творительный падежи образует заменой «-а» на «-ой» («Набиуллиной»), а не
// дописыванием — без отсечения «а» их startsWith не поймает. Тот же приём,
// что и для личных имён.
private static final Set<String> BUNDLED_WELL_KNOWN_STEMS = ResourceLoader.set("/names/well-known.txt").stream()
private static final Set<String> BUNDLED_WELL_KNOWN_STEMS =
ResourceLoader.set("/names/well-known.txt").stream()
.map(Declension::withoutInflectedEnding)
.collect(Collectors.toUnmodifiableSet());
@@ -63,11 +63,12 @@ public final class NameDictionary {
"велик|грозн|мудр|благословен|освободител|миротворц?|тишайш|долгорук|окаянн";
/**
* Имя правителя: личное имя плюс порядковое числительное или прозвище —
* «Пётр Первый», «Иван Грозный», «Екатерина Вторая», «Ярослав Мудрый».
* Задано правилом, а не перечнем: правителей много, а форма записи одна.
* Имя правителя: личное имя плюс порядковое числительное или прозвище — «Пётр Первый», «Иван
* Грозный», «Екатерина Вторая», «Ярослав Мудрый». Задано правилом, а не перечнем: правителей
* много, а форма записи одна.
*/
private static final Pattern REGNAL_NAME = Pattern.compile(
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);
@@ -75,25 +76,42 @@ public final class NameDictionary {
private static final int MAX_INFLECTION = 3;
/** Остатки, превращающие основу имени в фамилию или отчество: Роман → Романов. */
private static final Set<String> SURNAME_SUFFIXES = Set.of(
"ов", "ев", "ёв", "ин", "ын", "ова", "ева", "ёва", "ина", "ына",
"ович", "евич", "овна", "евна", "овы", "евы", "ины");
private static final Set<String> SURNAME_SUFFIXES =
Set.of(
"ов", "ев", "ёв", "ин", "ын", "ова", "ева", "ёва", "ина", "ына", "ович", "евич", "овна",
"евна", "овы", "евы", "ины");
private static final Set<String> GIVEN_NAMES = GIVEN_NAME_STEMS.stream()
private static final Set<String> GIVEN_NAMES =
GIVEN_NAME_STEMS.stream()
.map(stem -> stem.toLowerCase(Locale.ROOT))
.collect(Collectors.toUnmodifiableSet());
/**
* Слова-маркеры персональных данных и реквизитов, которые по словообразованию
* совпадают с основами имён («ИНН» — основа имени «Инна») и потому ложно
* распознаются как ФИО. Это аббревиатуры, а не имена.
* Слова-маркеры персональных данных и реквизитов, которые по словообразованию совпадают с
* основами имён («ИНН» — основа имени «Инна») и потому ложно распознаются как ФИО. Это
* аббревиатуры, а не имена.
*/
private static final Set<String> PD_MARKERS = Set.of(
"инн", "снилс", "огрн", "огрнип", "кпп", "бик", "паспорт", "счёт", "счет",
"телефон", "email", "почта", "дата", "адрес", "полис", "свидетельство", "ву");
private static final Set<String> PD_MARKERS =
Set.of(
"инн",
"снилс",
"огрн",
"огрнип",
"кпп",
"бик",
"паспорт",
"счёт",
"счет",
"телефон",
"email",
"почта",
"дата",
"адрес",
"полис",
"свидетельство",
"ву");
private NameDictionary() {
}
private NameDictionary() {}
/** Экземпляр для Spring-бина; словарь работает через статические методы. */
public static NameDictionary create() {
@@ -101,9 +119,9 @@ public final class NameDictionary {
}
/**
* Задаёт путь к внешнему файлу денилиста. Вызывается при старте приложения
* из конфигурации Spring-бина; статические методы словаря работают без
* экземпляра, поэтому путь хранится в статическом поле.
* Задаёт путь к внешнему файлу денилиста. Вызывается при старте приложения из конфигурации
* Spring-бина; статические методы словаря работают без экземпляра, поэтому путь хранится в
* статическом поле.
*/
public static void configure(String wellKnownFile) {
WELL_KNOWN_STATE.current = BUNDLED_WELL_KNOWN_STEMS;
@@ -118,9 +136,9 @@ public final class NameDictionary {
/**
* Есть ли среди слов личное имя из словаря в любом падеже.
*
* <p>Проверка множеством, а не чередованием в регулярном выражении: сто с лишним
* веток пришлось бы перебирать в каждой позиции текста, здесь же на слово
* приходится не больше четырёх обращений к хеш-таблице.
* <p>Проверка множеством, а не чередованием в регулярном выражении: сто с лишним веток пришлось
* бы перебирать в каждой позиции текста, здесь же на слово приходится не больше четырёх обращений
* к хеш-таблице.
*/
public static boolean containsGivenName(String value) {
for (String word : value.split(WORD_SPLIT)) {
@@ -134,7 +152,9 @@ public final class NameDictionary {
// «марин» плюс падежное «а», а «Романов» — основа «роман» плюс фамильное
// «ов». Без этой разницы «Бизнес-центр Романов Двор» принимался бы за
// человека, а «Марина Шевченко» переставала бы им быть.
for (int length = Math.max(1, lower.length() - MAX_INFLECTION); length < lower.length(); length++) {
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;
@@ -145,9 +165,8 @@ public final class NameDictionary {
}
/**
* Проверяет, что фрагмент — имя, отчество или фамилия человека. Используется
* для строчных имён после ролевого слова («клиент иван иванов»), где регистр
* не подсказывает, что перед нами имя.
* Проверяет, что фрагмент — имя, отчество или фамилия человека. Используется для строчных имён
* после ролевого слова («клиент иван иванов»), где регистр не подсказывает, что перед нами имя.
*/
public static boolean containsNamePart(String value) {
for (String word : value.split(WORD_SPLIT)) {
@@ -163,12 +182,11 @@ public final class NameDictionary {
}
/**
* Слово само по себе похоже на имя, фамилию или отчество — без ролевого слова
* или соседнего личного имени рядом, самое слабое основание для ФИО. Точное
* совпадение с личным именем принимается в любом регистре («иван» тоже имя),
* а вот словообразовательная эвристика (фамилия/отчество по окончанию) —
* только с заглавной буквы: без этого «законов», «домов», «холодов» —
* обычные родительные падежи, а не фамилии — ложно матчились бы.
* Слово само по себе похоже на имя, фамилию или отчество — без ролевого слова или соседнего
* личного имени рядом, самое слабое основание для ФИО. Точное совпадение с личным именем
* принимается в любом регистре («иван» тоже имя), а вот словообразовательная эвристика
* (фамилия/отчество по окончанию) — только с заглавной буквы: без этого «законов», «домов»,
* «холодов» — обычные родительные падежи, а не фамилии — ложно матчились бы.
*/
public static boolean isStandaloneNameCandidate(String word) {
String lower = word.toLowerCase(Locale.ROOT);
@@ -190,9 +208,10 @@ public final class NameDictionary {
}
/** Окончания, по которым слово похоже на фамилию: Иванов, Петрова, Троицкий, Шевченко. */
private static final Set<String> SURNAME_ENDINGS = Set.of(
"ов", "ев", "ёв", "ин", "ын", "ский", "ская", "ского", "ской", "ском",
"цкий", "цкая", "енко", "ко", "ук", "юк", "ян", "швили", "дзе");
private static final Set<String> SURNAME_ENDINGS =
Set.of(
"ов", "ев", "ёв", "ин", "ын", "ский", "ская", "ского", "ской", "ском", "цкий", "цкая",
"енко", "ко", "ук", "юк", "ян", "швили", "дзе");
/** Фамилия по словообразованию. Набор окончаний вместо regex: проще и без CANON_EQ. */
private static boolean isSurname(String lower) {
@@ -205,13 +224,11 @@ public final class NameDictionary {
}
/**
* Содержит ли текст упоминание известного человека — из сборки или дописанных
* сверху.
* Содержит ли текст упоминание известного человека — из сборки или дописанных сверху.
*
* <p>Проверяются префиксы слова по множеству, а не каждая основа по слову:
* при тысяче с лишним записей (столько городов в {@link ToponymDictionary},
* тот же приём) перебор списка на каждое слово текста был бы заметен, а
* префиксов у слова — не больше, чем в нём букв.
* <p>Проверяются префиксы слова по множеству, а не каждая основа по слову: при тысяче с лишним
* записей (столько городов в {@link ToponymDictionary}, тот же приём) перебор списка на каждое
* слово текста был бы заметен, а префиксов у слова — не больше, чем в нём букв.
*/
public static boolean isWellKnown(String value) {
if (REGNAL_NAME.matcher(value.strip()).matches()) {
@@ -243,7 +260,8 @@ public final class NameDictionary {
WELL_KNOWN_STATE.lastCheck = System.currentTimeMillis();
if (!java.nio.file.Files.isReadable(path)) {
if (WELL_KNOWN_STATE.current != BUNDLED_WELL_KNOWN_STEMS) {
LOG.info("Внешний файл денилиста {} исчез, остаётся только встроенный список",
LOG.info(
"Внешний файл денилиста {} исчез, остаётся только встроенный список",
path.toAbsolutePath());
}
WELL_KNOWN_STATE.current = BUNDLED_WELL_KNOWN_STEMS;
@@ -253,15 +271,18 @@ public final class NameDictionary {
try {
WELL_KNOWN_STATE.mtime = java.nio.file.Files.getLastModifiedTime(path).toMillis();
Set<String> merged = new HashSet<>(BUNDLED_WELL_KNOWN_STEMS);
for (String line : java.nio.file.Files.readAllLines(path, java.nio.charset.StandardCharsets.UTF_8)) {
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());
LOG.info(
"Денилист дополнен из {}: {} имён сверх встроенных",
path.toAbsolutePath(),
merged.size() - BUNDLED_WELL_KNOWN_STEMS.size());
} catch (java.io.IOException e) {
// Битый файл не должен ронять маскирование: остаётся прежний список.
LOG.error("Не удалось прочитать {}, денилист не изменён", path.toAbsolutePath(), e);
@@ -269,7 +290,9 @@ public final class NameDictionary {
}
private static Set<String> currentWellKnownStems() {
return ResourceLoader.refreshIfChanged(EXTERNAL_FILE, WELL_KNOWN_STATE,
return ResourceLoader.refreshIfChanged(
EXTERNAL_FILE,
WELL_KNOWN_STATE,
lines -> {
Set<String> merged = new HashSet<>(BUNDLED_WELL_KNOWN_STEMS);
for (String line : lines) {
@@ -3,30 +3,31 @@ package ru.pdguard.detect;
import java.util.regex.Pattern;
/**
* Проверка, стоит ли перед именем слово, относящее его к организации или
* объекту на карте.
* Проверка, стоит ли перед именем слово, относящее его к организации или объекту на карте.
*
* <p>«Институт Склифосовского», «Музей Тропинина», «улица Королёва» — это имена
* в названиях, а не персональные данные. Отличие от списка известных людей в том,
* что здесь решает не само имя, а слово перед ним: клиент по фамилии Королёв
* защиту не теряет, а улица Королёва под маску не попадает.
* <p>«Институт Склифосовского», «Музей Тропинина», «улица Королёва» — это имена в названиях, а не
* персональные данные. Отличие от списка известных людей в том, что здесь решает не само имя, а
* слово перед ним: клиент по фамилии Королёв защиту не теряет, а улица Королёва под маску не
* попадает.
*/
public final class OrganisationDetector {
/**
* Маркер организации вплотную перед именем. Слово может стоять в любом падеже,
* между ним и именем допускается «имени» или «им.» — «Премия имени Ломоносова».
* Маркер организации вплотную перед именем. Слово может стоять в любом падеже, между ним и именем
* допускается «имени» или «им.» — «Премия имени Ломоносова».
*/
private static final Pattern ORGANISATION_BEFORE = Pattern.compile(
"(?iu:" + String.join("|", ResourceLoader.lines("/names/organisations.txt", false)) + ")\\p{L}*"
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 OrganisationDetector() {
}
private OrganisationDetector() {}
public static boolean precededByOrganisation(String text, int nameStart) {
int from = Math.max(0, nameStart - ORGANISATION_LOOKBEHIND);
+8 -7
View File
@@ -3,16 +3,14 @@ package ru.pdguard.detect;
/**
* Имена типов персональных данных, которые умеет распознавать сервис.
*
* <p>Вынесены из {@link RuleRegistry} отдельно: константы используются и в
* правилах, и в маскировании ({@link ru.pdguard.mask.Masker}), и в политиках
* ({@link ru.pdguard.config.SystemPolicy}), и в синтетических подстановках
* ({@link ru.pdguard.mask.Synthetic}). Единое место — чтобы имя типа не
* расходилось между слоями.
* <p>Вынесены из {@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";
@@ -37,8 +35,10 @@ public final class PdTypes {
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";
@@ -48,6 +48,7 @@ public final class PdTypes {
/** Банковские реквизиты сверх платёжной карты. */
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";
@@ -18,18 +18,16 @@ import org.slf4j.LoggerFactory;
/**
* Общие приёмы чтения словарей и внешних файлов.
*
* <p>Словари лежат в сборке как ресурсы и читаются одинаково: строки обрезаются,
* пустые и комментарии отбрасываются. Внешние файлы (денилист, настройки систем)
* перечитываются, когда меняется время их изменения, и не чаще раза в секунду —
* чтобы не ходить в файловую систему на каждом запросе. Обе задачи вынесены сюда,
* чтобы не дублировать их в каждом словаре.
* <p>Словари лежат в сборке как ресурсы и читаются одинаково: строки обрезаются, пустые и
* комментарии отбрасываются. Внешние файлы (денилист, настройки систем) перечитываются, когда
* меняется время их изменения, и не чаще раза в секунду — чтобы не ходить в файловую систему на
* каждом запросе. Обе задачи вынесены сюда, чтобы не дублировать их в каждом словаре.
*/
final class ResourceLoader {
private static final Logger LOG = LoggerFactory.getLogger(ResourceLoader.class);
private ResourceLoader() {
}
private ResourceLoader() {}
/**
* Читает строки ресурса, отбрасывая пустые и комментарии.
@@ -42,12 +40,15 @@ final class ResourceLoader {
if (in == null) {
throw new IllegalStateException("Словарь не найден в сборке: " + resource);
}
try (BufferedReader reader = new BufferedReader(new InputStreamReader(in, StandardCharsets.UTF_8))) {
return reader.lines()
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
.sorted(
sortByLength
? Comparator.comparingInt(String::length).reversed()
: Comparator.naturalOrder())
.toList();
@@ -57,23 +58,21 @@ final class ResourceLoader {
}
}
/**
* Читает строки ресурса в множество, отбрасывая пустые и комментарии.
*/
/** Читает строки ресурса в множество, отбрасывая пустые и комментарии. */
static java.util.Set<String> set(String resource) {
return lines(resource, false).stream().collect(Collectors.toUnmodifiableSet());
}
/**
* Перечитывает внешний файл, когда меняется время его изменения, не чаще раза
* в секунду. Возвращает текущее содержимое; при недоступности файла — прежнее.
* Перечитывает внешний файл, когда меняется время его изменения, не чаще раза в секунду.
* Возвращает текущее содержимое; при недоступности файла — прежнее.
*
* @param path путь к файлу
* @param state состояние проверки (время последней проверки и mtime файла)
* @param reader как превратить строки файла в итоговое значение
*/
static <T> T refreshIfChanged(Path path, FileWatchState<T> state,
Function<List<String>, T> reader) {
static <T> T refreshIfChanged(
Path path, FileWatchState<T> state, Function<List<String>, T> reader) {
long now = System.currentTimeMillis();
if (now - state.lastCheck < state.recheckMillis) {
return state.current;
@@ -86,7 +85,8 @@ final class ResourceLoader {
long mtime = Files.getLastModifiedTime(path).toMillis();
if (mtime != state.mtime) {
state.mtime = mtime;
List<String> lines = Files.readAllLines(path, StandardCharsets.UTF_8).stream()
List<String> lines =
Files.readAllLines(path, StandardCharsets.UTF_8).stream()
.map(String::trim)
.filter(line -> !line.isEmpty() && !line.startsWith("#"))
.toList();
@@ -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,19 +16,19 @@ 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: размечает имена и составляющие адреса за один проход.
*
* <p>В отличие от правил, он опознаёт имена без русского словообразования и
* нестандартные топонимы. Метки модели ложатся почти один в один
* на типы из технического задания: имя, отчество, фамилия, страна, регион, район,
* город, улица, дом.
* <p>В отличие от правил, он опознаёт имена без русского словообразования и нестандартные топонимы.
* Метки модели ложатся почти один в один на типы из технического задания: имя, отчество, фамилия,
* страна, регион, район, город, улица, дом.
*
* <p>Модель тяжёлая — сто семьдесят мегабайт и около двенадцати миллисекунд на
* вызов, — поэтому её зовут только на участках, которые не разобрала первая
* ступень. Одновременных вызовов не больше, чем задано: иначе один запрос с
* десятком кандидатов занял бы все ядра.
* <p>Модель тяжёлая — сто семьдесят мегабайт и около двенадцати миллисекунд на вызов, — поэтому её
* зовут только на участках, которые не разобрала первая ступень. Одновременных вызовов не больше,
* чем задано: иначе один запрос с десятком кандидатов занял бы все ядра.
*/
final class RuBertRecogniser {
@@ -47,8 +44,12 @@ final class RuBertRecogniser {
private final Set<String> inputNames;
private final Map<String, String> types;
private RuBertRecogniser(OrtEnvironment environment, OrtSession session,
WordPiece tokenizer, String[] labels, Map<String, String> types) {
private RuBertRecogniser(
OrtEnvironment environment,
OrtSession session,
WordPiece tokenizer,
String[] labels,
Map<String, String> types) {
this.environment = environment;
this.session = session;
this.tokenizer = tokenizer;
@@ -58,9 +59,9 @@ final class RuBertRecogniser {
}
/**
* Загружает модель из каталога с файлами {@code model.onnx}, {@code tokenizer.json}
* и {@code config.json}. Каталог недоступен или испорчен — вернётся {@code null},
* и сервис продолжит работать на правилах.
* Загружает модель из каталога с файлами {@code model.onnx}, {@code tokenizer.json} и {@code
* config.json}. Каталог недоступен или испорчен — вернётся {@code null}, и сервис продолжит
* работать на правилах.
*
* @param types соответствие меток модели типам ПД сервиса
*/
@@ -80,8 +81,13 @@ final class RuBertRecogniser {
options.setInterOpNumThreads(1);
session = environment.createSession(model.toString(), options);
}
RuBertRecogniser recogniser = new RuBertRecogniser(environment, session,
WordPiece.fromTokenizerJson(tokenizer), readLabels(config), types);
RuBertRecogniser recogniser =
new RuBertRecogniser(
environment,
session,
WordPiece.fromTokenizerJson(tokenizer),
readLabels(config),
types);
LOG.info("Распознаватель BERT готов, модель {}", model.toAbsolutePath());
return recogniser;
} catch (OrtException | IOException | RuntimeException e) {
@@ -132,9 +138,12 @@ final class RuBertRecogniser {
Map<String, OnnxTensor> inputs = new HashMap<>();
try {
inputs.put("input_ids", OnnxTensor.createTensor(environment, LongBuffer.wrap(ids), shape));
inputs.put("attention_mask", OnnxTensor.createTensor(environment, LongBuffer.wrap(mask), shape));
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.put(
"token_type_ids",
OnnxTensor.createTensor(environment, LongBuffer.wrap(tokenTypes), shape));
}
inputs.keySet().retainAll(inputNames);
try (OrtSession.Result result = session.run(inputs)) {
@@ -151,11 +160,15 @@ final class RuBertRecogniser {
}
/**
* Собирает подряд идущие подслова одной сущности в фрагменты исходного текста.
* Схема разметки различает начало, середину, конец и одиночный токен, но для
* сборки достаточно смены типа: границы участков и так проставлены по словам.
* Собирает подряд идущие подслова одной сущности в фрагменты исходного текста. Схема разметки
* различает начало, середину, конец и одиночный токен, но для сборки достаточно смены типа:
* границы участков и так проставлены по словам.
*/
private static List<Span> toSpans(List<WordPiece.Piece> pieces, String[] tags, int offset, int priority,
private static List<Span> toSpans(
List<WordPiece.Piece> pieces,
String[] tags,
int offset,
int priority,
Map<String, String> types) {
List<Span> spans = new ArrayList<>();
String currentType = null;
+45 -31
View File
@@ -7,29 +7,34 @@ import java.util.regex.Pattern;
/**
* Одно правило детекции персональных данных.
*
* <p>Добавление нового типа ПД — это добавление одного {@code Rule} в
* {@link RuleRegistry}; менять остальной код не требуется.
* <p>Добавление нового типа ПД — это добавление одного {@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 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<Integer> groups,
Predicate<String> validator, Pattern veto, Pattern context, List<String> anchors) {
public record Rule(
String type,
Pattern pattern,
int priority,
List<Integer> groups,
Predicate<String> validator,
Pattern veto,
Pattern context,
List<String> anchors) {
public Rule {
groups = List.copyOf(groups);
@@ -39,29 +44,29 @@ public record Rule(String type, Pattern pattern, int priority, List<Integer> gro
/**
* Флаги компиляции для всех правил.
*
* <p>{@code UNICODE_CHARACTER_CLASS} обязателен: без него {@code \w}, {@code \W}
* и {@code \b} в Java охватывают только латиницу, и якорные слова вроде
* «водительское удостоверение» не находятся. {@code UNICODE_CASE} делает
* {@code (?i)} корректным для кириллицы.
* <p>{@code UNICODE_CHARACTER_CLASS} обязателен: без него {@code \w}, {@code \W} и {@code \b} в
* Java охватывают только латиницу, и якорные слова вроде «водительское удостоверение» не
* находятся. {@code UNICODE_CASE} делает {@code (?i)} корректным для кириллицы.
*/
private static final int FLAGS = Pattern.UNICODE_CHARACTER_CLASS | Pattern.UNICODE_CASE;
/**
* Сколько символов слева и справа от совпадения просматривает вето-шаблон.
*
* <p>150, не 80: на реальных адресах отделений из реестра ЦБ (регион, город,
* улица, дом — в одном предложении) расстояние от «отделение» до номера дома
* часто превышает 80 знаков за счёт длинного названия региона («Ханты-Мансийский
* автономный округ», «Кабардино-Балкарская Республика»). Найдено нагрузочным
* тестом на 60 реальных адресах из официального реестра — с окном в 80 знаков
* вето не срабатывало на части из них.
* <p>150, не 80: на реальных адресах отделений из реестра ЦБ (регион, город, улица, дом — в одном
* предложении) расстояние от «отделение» до номера дома часто превышает 80 знаков за счёт
* длинного названия региона («Ханты-Мансийский автономный округ», «Кабардино-Балкарская
* Республика»). Найдено нагрузочным тестом на 60 реальных адресах из официального реестра — с
* окном в 80 знаков вето не срабатывало на части из них.
*/
public static final int VETO_LOOKBEHIND = 150;
public static final int VETO_LOOKAHEAD = 40;
/** Правило без проверок, маскируется всё совпадение. */
public static Rule of(String type, String regex, int priority) {
return new Rule(type, Pattern.compile(regex, FLAGS), priority, List.of(0), null, null, null, List.of());
return new Rule(
type, Pattern.compile(regex, FLAGS), priority, List.of(0), null, null, null, List.of());
}
/** Маскировать только перечисленные группы, а не всё совпадение. */
@@ -94,11 +99,20 @@ public record Rule(String type, Pattern pattern, int priority, List<Integer> gro
/** Принять совпадение, только если рядом встретилось указанное слово. */
public Rule requiringNear(String regex) {
return new Rule(type, pattern, priority, groups, validator, veto, Pattern.compile(regex, FLAGS), anchors);
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);
return new Rule(
type,
pattern,
priority,
groups,
validator,
Pattern.compile(regex, FLAGS),
context,
anchors);
}
}
@@ -1,19 +1,18 @@
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}]+";
@@ -24,51 +23,49 @@ final class RulePatterns {
static final String ROLE_GAP = "(?:\\s+[\\p{L}-]+){0,5}\\W{0,10}";
/**
* То же самое, но только строчные слова-филлеры: ролевые слова перед значением
* гражданства всегда строчные («бенефициара», «поручителя»), а само значение —
* с заглавной («Республики», «Соединенные»). Обычный {@link #ROLE_GAP} жадно
* поглощал бы и заглавное слово значения как будто это ролевое слово, оставляя
* значение только хвостом («Республики Беларусь» → «Беларусь»).
* То же самое, но только строчные слова-филлеры: ролевые слова перед значением гражданства всегда
* строчные («бенефициара», «поручителя»), а само значение — с заглавной («Республики»,
* «Соединенные»). Обычный {@link #ROLE_GAP} жадно поглощал бы и заглавное слово значения как
* будто это ролевое слово, оставляя значение только хвостом («Республики Беларусь» → «Беларусь»).
*/
static final String CITIZENSHIP_GAP = "(?:\\s+\\p{Ll}[\\p{L}-]*){0,5}\\W{0,10}";
/**
* Название улицы: от одного до трёх слов с заглавной буквы либо чисел —
* «Тверская», «Малая Никитская», «8 Марта». Ограничение по форме обязательно:
* без него правило дожёвывало строку до конца, и «Проспект Вернадского перекрыт
* до вечера» оказывался под маской целиком.
* Название улицы: от одного до трёх слов с заглавной буквы либо чисел — «Тверская», «Малая
* Никитская», «8 Марта». Ограничение по форме обязательно: без него правило дожёвывало строку до
* конца, и «Проспект Вернадского перекрыт до вечера» оказывался под маской целиком.
*/
static final String STREET_NAME =
"(?:\\p{Lu}[\\p{L}-]+|\\d+[\\p{L}-]*)(?:\\s+(?:\\p{Lu}[\\p{L}-]+|\\d+[\\p{L}-]*)){0,2}";
/**
* Фамилия по словообразованию: Иванов, Ковалёва, Троицкий, Шевченко, Мкртчян.
* Хвост из двух букв покрывает падежные окончания: Ковалёв-ой, Иванов-а.
* Фамилия по словообразованию: Иванов, Ковалёва, Троицкий, Шевченко, Мкртчян. Хвост из двух букв
* покрывает падежные окончания: Ковалёв-ой, Иванов-а.
*/
static final String SURNAME =
"\\p{Lu}[\\p{Lu}\\p{Ll}]*(?iu:ов|ев|ёв|ин|ын|ск(?:ий|ая|ого|ой|ом)|цк(?:ий|ая)"
+ "|енко|ко|ук|юк|ян|швили|дзе)\\p{L}{0,2}";
/**
* Отчество: признак надёжный, ни одно другое слово так не оканчивается.
* Основы даны без падежного окончания — Иванович, Ивановича, Ивановне.
* Отчество: признак надёжный, ни одно другое слово так не оканчивается. Основы даны без падежного
* окончания — Иванович, Ивановича, Ивановне.
*/
static final String PATRONYMIC =
"\\p{Lu}[\\p{Lu}\\p{Ll}]+(?iu:ович|евич|ьич|мич|нич|тич|лич|кич|бич|сич"
+ "|овн|евн|иничн|ичн)\\p{L}{0,2}";
/**
* Серия и номер: «4509 123456», «45 09 123456», «4509123456», «45 09 № 123456»,
* а также с произвольным числом пробелов и словом «номер» между частями —
* «12 34 номер 567890» (реальный кейс из бланка).
* Серия и номер: «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» месяц записан тремя буквами.
* Название месяца: полная форма («январь»), сокращение («янв») и плейсхолдер «ммм» (в логах
* встречается и латинская «M»). Сокращения нужны, потому что в датах вида «15 ЯНВ 10» месяц
* записан тремя буквами.
*/
static final String MONTH =
"(?iu:январ|феврал|март|апрел|ма[йя]|июн|июл|август|сентябр|октябр|ноябр|декабр"
@@ -85,7 +82,9 @@ final class RulePatterns {
/** «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"
"\\b\\d{1,2}\\s*[-/.]?\\s*"
+ MONTH
+ "\\s*[-/.]?\\s*(?:\\d{2,4})?\\b"
+ "(?:\\s*(?iu:года|г\\.|г\\b))?";
/** «двенадцатого мая тысяча девятьсот восемьдесят пятого года» */
@@ -93,40 +92,48 @@ final class RulePatterns {
"\\b(?:(?iu:двадцать|тридцать)\\s+)?"
+ "(?iu:перв|втор|треть|четв[её]рт|пят|шест|седьм|восьм|девят|десят|одиннадцат|двенадцат"
+ "|тринадцат|четырнадцат|пятнадцат|шестнадцат|семнадцат|восемнадцат|девятнадцат|двадцат|тридцат)"
+ "(?iu:ьего|ого|его|ое)\\s+" + MONTH
+ "(?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 + ")";
"(?:"
+ DATE_WORDS
+ "|"
+ DATE_MONTH_WORD
+ "|"
+ DATE_DIGITS
+ "|"
+ DATE_DIGITS_SPACE
+ "|"
+ DATE_DAY_MONTH
+ ")";
/**
* Промежуток между якорем даты («дата рождения») и самой датой: слова,
* скобочные группы («(день и месяц)») и знаки препинания. Без скобочной
* ветки «Дата рождения клиента (день и месяц): 15 января» не находилась бы:
* «день и месяц» — это слова, а не дата. Ветка со словами требует пробела
* перед словом ({@code \s+}), иначе она неоднозначна с веткой {@code \W},
* которая тоже матчит пробелы, — это приводило к катастрофическому
* возврату на длинных текстах. Отдельная ветка с дефисом нужна для слитных
* слов без пробела внутри: «клиента-нерезидента» — дефис сам по себе ловится
* веткой {@code \W}, но следующие за ним буквы без пробела перед ними не
* покрывала ни одна ветка.
* Промежуток между якорем даты («дата рождения») и самой датой: слова, скобочные группы («(день и
* месяц)») и знаки препинания. Без скобочной ветки «Дата рождения клиента (день и месяц): 15
* января» не находилась бы: «день и месяц» — это слова, а не дата. Ветка со словами требует
* пробела перед словом ({@code \s+}), иначе она неоднозначна с веткой {@code \W}, которая тоже
* матчит пробелы, — это приводило к катастрофическому возврату на длинных текстах. Отдельная
* ветка с дефисом нужна для слитных слов без пробела внутри: «клиента-нерезидента» — дефис сам по
* себе ловится веткой {@code \W}, но следующие за ним буквы без пробела перед ними не покрывала
* ни одна ветка.
*/
static final String DATE_GAP = "(?:\\s+\\([^)]*\\)|\\s+\\p{L}+|-\\p{L}+|\\W){0,30}";
/**
* Значение гражданства: «рф»/«росс…»(любая форма, включая строчную «российское»)/
* «республики X» — частые формы отдельным списком; последняя ветка — страна из
* 1-4 слов с заглавной буквы («Армения», «Соединенные Штаты Америки»). Хвост
* идёт после якоря «гражданств», поэтому «Двойное» перед якорем не попадёт.
* Значение гражданства: «рф»/«росс…»(любая форма, включая строчную «российское»)/ «республики X»
* — частые формы отдельным списком; последняя ветка — страна из 1-4 слов с заглавной буквы
* («Армения», «Соединенные Штаты Америки»). Хвост идёт после якоря «гражданств», поэтому
* «Двойное» перед якорем не попадёт.
*/
static final String CITIZENSHIP_VALUE =
"\\p{Lu}\\p{Ll}+(?:[\\s/]+\\p{Lu}\\p{Ll}+){0,3}|\\p{Ll}+(?:[\\s/]+\\p{Ll}+){0,3}";
/**
* Слова, при которых адрес/имя принадлежит организации, а не человеку:
* адрес отделения банка персональными данными не является.
* Слова, при которых адрес/имя принадлежит организации, а не человеку: адрес отделения банка
* персональными данными не является.
*/
static final String ORGANISATION_NEARBY =
"(?iu:отделени|филиал|банкомат|доп\\.?\\s?офис|офис|головн|юридическ\\p{L}*\\s+адрес)";
@@ -1,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,36 +7,38 @@ 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;
/**
* Реестр правил детекции и сам поиск ПД в тексте.
*
* <p>Правила разбиты на три уровня доверия:
*
* <ol>
* <li>проверяемые контрольной суммой — карта, ИНН, СНИЛС: ложных срабатываний почти нет;</li>
* <li>однозначные по формату — email, телефон;</li>
* <li>требующие якорного слова — паспорт, водительское удостоверение, CVV, адрес и прочее,
* где сама по себе последовательность знаков ни о чём не говорит.</li>
* <li>проверяемые контрольной суммой — карта, ИНН, СНИЛС: ложных срабатываний почти нет;
* <li>однозначные по формату — email, телефон;
* <li>требующие якорного слова — паспорт, водительское удостоверение, CVV, адрес и прочее, где
* сама по себе последовательность знаков ни о чём не говорит.
* </ol>
*
* <p>Якорные слова распознаются без учёта регистра — флаг {@code (?iu:...)} навешен
* именно на них. На захватываемое значение регистронезависимость не распространяется:
* там, где значение опознаётся по заглавной букве, это существенно.
* <p>Якорные слова распознаются без учёта регистра — флаг {@code (?iu:...)} навешен именно на них.
* На захватываемое значение регистронезависимость не распространяется: там, где значение опознаётся
* по заглавной букве, это существенно.
*
* <p>Сами правила сгруппированы по категориям в отдельных классах пакета —
* {@link DocumentRules}, {@link FinanceRules}, {@link DateRules}, {@link FioRules},
* {@link ContactRules}, {@link AddressRules} — чтобы каждая категория читалась
* отдельно от остальных. Здесь их списки только объединяются и используются.
* <p>Сами правила сгруппированы по категориям в отдельных классах пакета — {@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город|регистрац|прожива)";
@@ -48,27 +47,33 @@ public class RuleRegistry {
Pattern.compile(ADDRESS_NEARBY, Pattern.UNICODE_CHARACTER_CLASS | Pattern.UNICODE_CASE);
/** Адресные типы, которые вне адресного окружения персональными данными не являются. */
private static final Set<String> ADDRESS_TYPES = Set.of(
PdTypes.ADDRESS_COUNTRY, PdTypes.ADDRESS_REGION, PdTypes.ADDRESS_DISTRICT, PdTypes.ADDRESS_CITY,
PdTypes.ADDRESS_STREET, PdTypes.ADDRESS_HOUSE, PdTypes.ADDRESS_FLAT, PdTypes.ADDRESS_POSTCODE);
private static final Set<String> ADDRESS_TYPES =
Set.of(
PdTypes.ADDRESS_COUNTRY,
PdTypes.ADDRESS_REGION,
PdTypes.ADDRESS_DISTRICT,
PdTypes.ADDRESS_CITY,
PdTypes.ADDRESS_STREET,
PdTypes.ADDRESS_HOUSE,
PdTypes.ADDRESS_FLAT,
PdTypes.ADDRESS_POSTCODE);
/**
* Приоритет находок нормализации цифровых ПД: выше правила ИНН без якоря (62),
* ниже якорных правил (84+). Нормализация находит то, что жёсткие шаблоны
* пропустили из-за нестандартных разделителей, и не должна перебивать находки
* с якорным словом.
* Приоритет находок нормализации цифровых ПД: выше правила ИНН без якоря (62), ниже якорных
* правил (84+). Нормализация находит то, что жёсткие шаблоны пропустили из-за нестандартных
* разделителей, и не должна перебивать находки с якорным словом.
*/
private static final int NORMALISED_PRIORITY = 63;
/**
* Цифровой кластер: от 10 до 19 цифр с произвольными разделителями между ними
* (пробел, дефис, точка, слэш, скобки). Негативные просмотры не дают захватить
* часть более длинного числа. Разделители вычищаются, и чистая цифровая строка
* прогоняется через контрольную сумму — так находятся ИНН/СНИЛС/карта/ОГРН(ИП)
* в свободной форме, где жёсткий шаблон ломается на нестандартном разделителе.
* Цифровой кластер: от 10 до 19 цифр с произвольными разделителями между ними (пробел, дефис,
* точка, слэш, скобки). Негативные просмотры не дают захватить часть более длинного числа.
* Разделители вычищаются, и чистая цифровая строка прогоняется через контрольную сумму — так
* находятся ИНН/СНИЛС/карта/ОГРН(ИП) в свободной форме, где жёсткий шаблон ломается на
* нестандартном разделителе.
*/
private static final Pattern DIGIT_CLUSTER = Pattern.compile(
"(?<!\\d)\\d(?:[\\s.\\-/()]?\\d){9,18}(?!\\d)");
private static final Pattern DIGIT_CLUSTER =
Pattern.compile("(?<!\\d)\\d(?:[\\s.\\-/()]?\\d){9,18}(?!\\d)");
/** Вычищает разделители из цифрового кластера: оставляет только цифры. */
private static final Pattern NON_DIGIT = Pattern.compile("[^\\d]");
@@ -78,17 +83,22 @@ public class RuleRegistry {
}
/**
* Есть ли рядом другие части адреса. Правила проверяют это сами, а находкам
* второй ступени проверку нужно навязать снаружи: модель размечает «Москву» в
* названии клуба и «Вернадского» в названии проспекта наравне с настоящим адресом.
* Есть ли рядом другие части адреса. Правила проверяют это сами, а находкам второй ступени
* проверку нужно навязать снаружи: модель размечает «Москву» в названии клуба и «Вернадского» в
* названии проспекта наравне с настоящим адресом.
*/
public static boolean hasAddressContext(String text, int start, int end) {
return ADDRESS_CONTEXT.matcher(surroundings(text, start, end)).find();
}
private static final List<Rule> RULES = Stream.of(
DocumentRules.RULES, FinanceRules.RULES, DateRules.RULES,
FioRules.RULES, ContactRules.RULES, AddressRules.RULES)
private static final List<Rule> RULES =
Stream.of(
DocumentRules.RULES,
FinanceRules.RULES,
DateRules.RULES,
FioRules.RULES,
ContactRules.RULES,
AddressRules.RULES)
.flatMap(List::stream)
.toList();
@@ -98,8 +108,8 @@ public class RuleRegistry {
}
/**
* Находит все фрагменты ПД, разрешённые политикой системы.
* Перекрытия здесь не разрешаются — это делает вызывающая сторона.
* Находит все фрагменты ПД, разрешённые политикой системы. Перекрытия здесь не разрешаются — это
* делает вызывающая сторона.
*/
public List<Span> detect(String text, SystemPolicy policy) {
List<Span> found = new ArrayList<>();
@@ -115,14 +125,16 @@ public class RuleRegistry {
}
/**
* Ищет цифровые ПД в свободной форме: последовательности цифр с произвольными
* разделителями, которые жёсткие шаблоны правил пропустили. Разделители
* вычищаются, и чистая строка проверяется контрольной суммой — ложные
* срабатывания отсекаются так же, как и в правилах.
* Ищет цифровые ПД в свободной форме: последовательности цифр с произвольными разделителями,
* которые жёсткие шаблоны правил пропустили. Разделители вычищаются, и чистая строка проверяется
* контрольной суммой — ложные срабатывания отсекаются так же, как и в правилах.
*/
private static void collectNormalisedDigits(String text, SystemPolicy policy, List<Span> sink) {
if (!policy.allows(PdTypes.CARD) && !policy.allows(PdTypes.INN) && !policy.allows(PdTypes.SNILS)
&& !policy.allows(PdTypes.OGRN) && !policy.allows(PdTypes.OGRNIP)) {
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);
@@ -136,10 +148,10 @@ public class RuleRegistry {
}
/**
* Определяет тип ПД по чистой цифровой строке и контрольной сумме. Для 13 и 15
* цифр сначала пробуются ОГРН/ОГРНИП: они специфичнее карты по длине, и валидный
* ОГРН не должен случайно стать номером карты (карта самостоятельна, ОГРН — только
* спутник, и одинокий ОГРН убирается в {@code Pipeline}).
* Определяет тип ПД по чистой цифровой строке и контрольной сумме. Для 13 и 15 цифр сначала
* пробуются ОГРН/ОГРНИП: они специфичнее карты по длине, и валидный ОГРН не должен случайно стать
* номером карты (карта самостоятельна, ОГРН — только спутник, и одинокий ОГРН убирается в {@code
* Pipeline}).
*/
private static String typeFor(String digits) {
int length = digits.length();
@@ -191,7 +203,10 @@ public class RuleRegistry {
}
}
/** Проверяет, что фрагмент группы проходит все условия правила: границы, валидатор, veto и контекст. */
/**
* Проверяет, что фрагмент группы проходит все условия правила: границы, валидатор, veto и
* контекст.
*/
private static boolean isValidGroup(Rule rule, String text, int start, int end) {
if (start < 0 || end <= start) {
return false;
@@ -4,43 +4,39 @@ import java.util.Locale;
import java.util.Set;
/**
* Словарь населённых пунктов России — проверка того, что значение, пойманное
* правилом {@code ADDRESS_CITY}, действительно похоже на существующий город,
* село, посёлок или другой населённый пункт, а не на произвольное слово с
* заглавной буквы после якоря.
* Словарь населённых пунктов России — проверка того, что значение, пойманное правилом {@code
* ADDRESS_CITY}, действительно похоже на существующий город, село, посёлок или другой населённый
* пункт, а не на произвольное слово с заглавной буквы после якоря.
*
* <p>Не только официальные города (~1100 по классификатору): перепись
* добавляет сёла, деревни, хутора, станицы — «рп. Ильинское», «с. Кукуево»
* из ТЗ находятся ровно за счёт неё. Какой конкретно тип населённого пункта
* стоит перед названием, определяет якорь самого правила в {@link RuleRegistry},
* а не этот словарь — он только подтверждает, что название реальное.
* <p>Не только официальные города (~1100 по классификатору): перепись добавляет сёла, деревни,
* хутора, станицы — «рп. Ильинское», «с. Кукуево» из ТЗ находятся ровно за счёт неё. Какой
* конкретно тип населённого пункта стоит перед названием, определяет якорь самого правила в {@link
* RuleRegistry}, а не этот словарь — он только подтверждает, что название реальное.
*
* <p>Сравнение по началу слова, а не точным совпадением: падежные окончания
* («в Москве», «из Казани») тем самым покрываются без отдельного разбора
* морфологии, как и у известных людей в {@link NameDictionary}.
* <p>Сравнение по началу слова, а не точным совпадением: падежные окончания («в Москве», «из
* Казани») тем самым покрываются без отдельного разбора морфологии, как и у известных людей в
* {@link NameDictionary}.
*/
public final class ToponymDictionary {
private static final Set<String> SETTLEMENT_STEMS = ResourceLoader.set("/names/settlements.txt").stream()
private static final Set<String> SETTLEMENT_STEMS =
ResourceLoader.set("/names/settlements.txt").stream()
.map(Declension::withoutInflectedEnding)
.collect(java.util.stream.Collectors.toUnmodifiableSet());
private ToponymDictionary() {
}
private ToponymDictionary() {}
/**
* Похоже ли значение на название населённого пункта из словаря в любом
* падеже.
* Похоже ли значение на название населённого пункта из словаря в любом падеже.
*
* <p>Названия на согласную склоняются добавлением окончания («Тамбов» →
* «Тамбове»), поэтому начало слова из словаря — уже достаточный признак.
* Названия на гласную меняют последнюю букву («Москва» → «Москве»), для
* них сравнение идёт по основе без неё — так же, как с личными именами
* <p>Названия на согласную склоняются добавлением окончания («Тамбов» → «Тамбове»), поэтому
* начало слова из словаря — уже достаточный признак. Названия на гласную меняют последнюю букву
* («Москва» → «Москве»), для них сравнение идёт по основе без неё — так же, как с личными именами
* в {@link NameDictionary}.
*
* <p>Проверяются префиксы значения по множеству, а не каждая из ~80 000
* основ по значению: перебор списка на каждое совпадение правила был бы
* на порядки дороже, чем нужно — префиксов у слова не больше, чем в нём букв.
* <p>Проверяются префиксы значения по множеству, а не каждая из ~80 000 основ по значению:
* перебор списка на каждое совпадение правила был бы на порядки дороже, чем нужно — префиксов у
* слова не больше, чем в нём букв.
*/
public static boolean isKnownSettlement(String value) {
String lower = value.strip().toLowerCase(Locale.ROOT);
+13 -15
View File
@@ -1,8 +1,8 @@
package ru.pdguard.detect;
/**
* Проверки контрольных сумм. Отсекают случайные числовые последовательности,
* которые по форме похожи на ПД, но ими не являются.
* Проверки контрольных сумм. Отсекают случайные числовые последовательности, которые по форме
* похожи на ПД, но ими не являются.
*/
public final class Validators {
@@ -10,8 +10,7 @@ public final class Validators {
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) {
@@ -38,9 +37,9 @@ public final class Validators {
}
/**
* Контрольная цифра Луна для последовательности цифр: дописывается к телу
* номера, чтобы весь номер прошёл проверку {@link #luhn}. Используется при
* генерации правдоподобных подставных номеров карт.
* Контрольная цифра Луна для последовательности цифр: дописывается к телу номера, чтобы весь
* номер прошёл проверку {@link #luhn}. Используется при генерации правдоподобных подставных
* номеров карт.
*/
public static int luhnCheckDigit(String body) {
int sum = 0;
@@ -109,10 +108,10 @@ public final class Validators {
}
/**
* Остаток от деления первых {@code count} цифр как одного числа на {@code divisor},
* взятый по младшему разряду. Числовое накопление по цифрам, а не парсинг строки
* в {@code long}: у ОГРНИП 14 цифр — на грани переполнения {@code int}, и это тот же
* приём, что уже применяется к самой длинной последовательности в {@link #luhn}.
* Остаток от деления первых {@code count} цифр как одного числа на {@code divisor}, взятый по
* младшему разряду. Числовое накопление по цифрам, а не парсинг строки в {@code long}: у ОГРНИП
* 14 цифр — на грани переполнения {@code int}, и это тот же приём, что уже применяется к самой
* длинной последовательности в {@link #luhn}.
*/
private static int modReduce(int[] d, int count, int divisor) {
long remainder = 0;
@@ -123,10 +122,9 @@ public final class Validators {
}
/**
* Дата в числовой записи при любом порядке частей: {@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}.
* Дата в числовой записи при любом порядке частей: {@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) {
// Запись с названием месяца словом в дополнительной проверке не нуждается:
+17 -19
View File
@@ -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,14 +17,14 @@ import java.util.Map;
/**
* Разбиение текста на подслова так, как это делает токенизатор BERT.
*
* <p>Своя реализация вместо готовой библиотеки: единственная альтернатива на Java
* подтягивает нативные библиотеки во время работы, а контейнер должен подниматься
* без обращений в сеть. Правила здесь простые и целиком описаны форматом словаря:
* разбить по пробелам и знакам препинания, затем каждое слово — жадно по самой
* длинной подходящей записи словаря, продолжения помечаются префиксом «##».
* <p>Своя реализация вместо готовой библиотеки: единственная альтернатива на Java подтягивает
* нативные библиотеки во время работы, а контейнер должен подниматься без обращений в сеть. Правила
* здесь простые и целиком описаны форматом словаря: разбить по пробелам и знакам препинания, затем
* каждое слово — жадно по самой длинной подходящей записи словаря, продолжения помечаются префиксом
* «##».
*
* <p>Для каждого подслова сохраняются границы в исходном тексте: без них разметку
* модели не перенести обратно на строку.
* <p>Для каждого подслова сохраняются границы в исходном тексте: без них разметку модели не
* перенести обратно на строку.
*/
final class WordPiece {
@@ -35,8 +34,7 @@ final class WordPiece {
private static final String CONTINUATION = "##";
/** Подслово и его границы в исходном тексте. */
record Piece(int id, int start, int end) {
}
record Piece(int id, int start, int end) {}
private final Map<String, Integer> vocabulary;
private final int unknownId;
@@ -52,7 +50,8 @@ final class WordPiece {
static WordPiece fromVocabulary(Path vocabularyFile) throws IOException {
Map<String, Integer> vocabulary = HashMap.newHashMap(140_000);
try (BufferedReader reader = new BufferedReader(
try (BufferedReader reader =
new BufferedReader(
new InputStreamReader(Files.newInputStream(vocabularyFile), StandardCharsets.UTF_8))) {
String line;
int index = 0;
@@ -64,9 +63,8 @@ final class WordPiece {
}
/**
* Читает словарь из {@code tokenizer.json} Hugging Face. Некоторые модели
* (например, WikiNEuRal) не кладут отдельный {@code vocab.txt}, а хранят
* словарь внутри токенизатора.
* Читает словарь из {@code tokenizer.json} Hugging Face. Некоторые модели (например, WikiNEuRal)
* не кладут отдельный {@code vocab.txt}, а хранят словарь внутри токенизатора.
*/
static WordPiece fromTokenizerJson(Path tokenizerFile) throws IOException {
JsonNode root = new ObjectMapper().readTree(Files.readAllBytes(tokenizerFile));
@@ -101,8 +99,8 @@ final class WordPiece {
}
/**
* Границы слов: разделителями считаются пробельные символы и знаки препинания,
* причём знак препинания сам становится отдельным словом.
* Границы слов: разделителями считаются пробельные символы и знаки препинания, причём знак
* препинания сам становится отдельным словом.
*/
private static List<int[]> words(String text) {
List<int[]> result = new ArrayList<>();
@@ -112,18 +110,18 @@ final class WordPiece {
boolean separator = Character.isWhitespace(c) || isPunctuation(c);
if (separator) {
if (start >= 0) {
result.add(new int[]{start, i});
result.add(new int[] {start, i});
start = -1;
}
if (isPunctuation(c)) {
result.add(new int[]{i, i + 1});
result.add(new int[] {i, i + 1});
}
} else if (start < 0) {
start = i;
}
}
if (start >= 0) {
result.add(new int[]{start, text.length()});
result.add(new int[] {start, text.length()});
}
return result;
}
@@ -8,9 +8,8 @@ import java.util.function.BiFunction;
/**
* Состояние одной операции маскирования.
*
* <p>Одинаковые значения в пределах запроса получают одинаковую замену: если
* клиент упомянут дважды, в тексте дважды окажется {@code [FIO_1]}, и смысл
* запроса для модели сохранится.
* <p>Одинаковые значения в пределах запроса получают одинаковую замену: если клиент упомянут
* дважды, в тексте дважды окажется {@code [FIO_1]}, и смысл запроса для модели сохранится.
*
* <p>Экземпляр живёт в рамках одного вызова и между потоками не разделяется.
*/
@@ -29,7 +28,9 @@ public final class MaskContext {
* @param factory получает тип ПД и порядковый номер значения этого типа
*/
public String resolve(String type, String value, BiFunction<String, Integer, String> factory) {
return assigned.computeIfAbsent(type + SEPARATOR + value, key -> {
return assigned.computeIfAbsent(
type + SEPARATOR + value,
key -> {
String replacement = factory.apply(type, counters.merge(type, 1, Integer::sum));
restorations.put(replacement, value);
return replacement;
@@ -37,8 +38,8 @@ public final class MaskContext {
}
/**
* Чем заменять обратно: подстановка к исходному значению. Нужно там, где текст
* возвращается не целиком, а изменённым — например, в ответе языковой модели.
* Чем заменять обратно: подстановка к исходному значению. Нужно там, где текст возвращается не
* целиком, а изменённым — например, в ответе языковой модели.
*/
public Map<String, String> restorations() {
return Map.copyOf(restorations);
+4 -5
View File
@@ -7,11 +7,10 @@ public enum MaskMode {
MASK,
/**
* Звёздочки без исключений: каждый тип закрывается целиком, даже те, что в
* {@link #MASK} частично открыты (края номера) или превращаются в инициалы
* (ФИО {@code Иванов Иван Иванович} → {@code ******* **** *********}, не
* {@code И. И. И.} — инициалы всё ещё выдают число слов и первую букву
* каждого).
* Звёздочки без исключений: каждый тип закрывается целиком, даже те, что в {@link #MASK} частично
* открыты (края номера) или превращаются в инициалы (ФИО {@code Иванов Иван Иванович} → {@code
* ******* **** *********}, не {@code И. И. И.} — инициалы всё ещё выдают число слов и первую
* букву каждого).
*/
STRICT,
+7 -11
View File
@@ -1,16 +1,15 @@
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;
/**
* Превращает найденное значение в замену согласно настройкам системы.
*
* <p>Тип, для которого вид маски не задан, скрывается звёздочками целиком —
* безопасное поведение по умолчанию для вновь добавленных правил.
* <p>Тип, для которого вид маски не задан, скрывается звёздочками целиком — безопасное поведение по
* умолчанию для вновь добавленных правил.
*/
@Component
public class Masker {
@@ -18,7 +17,8 @@ public class Masker {
private static final UnaryOperator<String> EDGES = v -> Strategies.keepEdges(v, 2, 2);
private static final UnaryOperator<String> SHORT_SERIES = v -> Strategies.keepEdges(v, 0, 2);
private static final Map<String, UnaryOperator<String>> BY_TYPE = Map.ofEntries(
private static final Map<String, UnaryOperator<String>> BY_TYPE =
Map.ofEntries(
Map.entry(PdTypes.EMAIL, Strategies::email),
Map.entry(PdTypes.PHONE, EDGES),
Map.entry(PdTypes.CARD, EDGES),
@@ -42,14 +42,12 @@ public class Masker {
// мало знаков, чтобы открывать хотя бы один.
Map.entry(PdTypes.CVV, Strategies::stars),
Map.entry(PdTypes.PIN, 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),
@@ -60,7 +58,6 @@ public class Masker {
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),
@@ -69,8 +66,7 @@ public class Masker {
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.BIOMETRIC, Strategies::stars));
public String mask(String type, String value, MaskMode mode, MaskContext context) {
return switch (mode) {
+9 -12
View File
@@ -3,16 +3,14 @@ package ru.pdguard.mask;
/**
* Способы преобразования найденного значения в маску.
*
* <p>Все стратегии сохраняют длину и разделители исходного значения: так
* замаскированный текст остаётся читаемым для LLM и минимально отличается
* от эталона при посимвольном сравнении.
* <p>Все стратегии сохраняют длину и разделители исходного значения: так замаскированный текст
* остаётся читаемым для LLM и минимально отличается от эталона при посимвольном сравнении.
*/
public final class Strategies {
private static final char MASK = '*';
private Strategies() {
}
private Strategies() {}
/** Каждый непробельный символ заменяется на «*». */
public static String stars(String value) {
@@ -25,9 +23,8 @@ public final class Strategies {
}
/**
* Скрывает буквы и цифры, оставляя разделители: {@code 12.05.1985} → {@code **.**.****},
* {@code 12 мая 1985} → {@code ** *** ****}. Форма записи остаётся видна модели,
* само значение — нет.
* Скрывает буквы и цифры, оставляя разделители: {@code 12.05.1985} → {@code **.**.****}, {@code
* 12 мая 1985} → {@code ** *** ****}. Форма записи остаётся видна модели, само значение — нет.
*/
public static String starsKeepingPunctuation(String value) {
StringBuilder sb = new StringBuilder(value.length());
@@ -39,8 +36,8 @@ public final class Strategies {
}
/**
* Оставляет первые и последние значащие символы, остальные скрывает,
* разделители сохраняет: {@code 4509 123456} → {@code 45** ****56}.
* Оставляет первые и последние значащие символы, остальные скрывает, разделители сохраняет:
* {@code 4509 123456} → {@code 45** ****56}.
*/
public static String keepEdges(String value, int head, int tail) {
int significant = 0;
@@ -89,8 +86,8 @@ public final class Strategies {
}
/**
* Адрес почты: видны первая буква имени ящика, первая буква домена и зона.
* {@code ivan.petrov@mail.ru} → {@code i**********@m***.ru}
* Адрес почты: видны первая буква имени ящика, первая буква домена и зона. {@code
* ivan.petrov@mail.ru} → {@code i**********@m***.ru}
*/
public static String email(String value) {
int at = value.lastIndexOf('@');
+41 -19
View File
@@ -6,38 +6,55 @@ import ru.pdguard.detect.Validators;
/**
* Правдоподобные подставные значения вместо настоящих.
*
* <p>Модель получает текст, который выглядит естественно, и качество ответа
* страдает меньше, чем от звёздочек. Значения детерминированы: одно и то же
* исходное значение всегда даёт одну и ту же подстановку.
* <p>Модель получает текст, который выглядит естественно, и качество ответа страдает меньше, чем от
* звёздочек. Значения детерминированы: одно и то же исходное значение всегда даёт одну и ту же
* подстановку.
*/
final class Synthetic {
private static final String[] SURNAMES =
{"Лаврентьев", "Мещеряков", "Тихомиров", "Ясенев", "Бурмистров", "Кольцов"};
private static final String[] SURNAMES = {
"Лаврентьев", "Мещеряков", "Тихомиров", "Ясенев", "Бурмистров", "Кольцов"
};
private static final String[] NAMES = {"Артём", "Никита", "Глеб", "Тимур", "Марк", "Лев"};
private static final String[] PATRONYMICS =
{"Артёмович", "Никитич", "Глебович", "Тимурович", "Маркович", "Львович"};
private static final String[] 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.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.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.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.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 -> "Сосновая";
@@ -79,7 +96,12 @@ final class Synthetic {
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);
return body.substring(0, 4)
+ " "
+ body.substring(4, 8)
+ " "
+ body.substring(8, 12)
+ " "
+ body.substring(12);
}
}
+58 -38
View File
@@ -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,11 +11,6 @@ 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 {
@@ -20,18 +19,19 @@ class BankTypesTest {
private void assertHidden(String text, String secret) {
String masked = pipeline.process(text, UUID.randomUUID().toString(), SystemPolicy.DEFAULT);
assertFalse(masked.contains(secret), "не замаскировано: «" + secret + "» в ответе «" + masked + "»");
assertFalse(
masked.contains(secret), "не замаскировано: «" + secret + "» в ответе «" + masked + "»");
}
/**
* Банковские реквизиты маскируются рядом с данными человека. Сами по себе они
* опознают организацию или счёт, а не клиента, и в перечне типов из задания их
* нет — поэтому они переведены в {@code requireCompanion}, как пин-код и дата.
* Банковские реквизиты маскируются рядом с данными человека. Сами по себе они опознают
* организацию или счёт, а не клиента, и в перечне типов из задания их нет — поэтому они
* переведены в {@code requireCompanion}, как пин-код и дата.
*/
@Test
void masksAccountNumberNextToPersonalData() {
assertHidden("Клиент Иванов Иван Иванович, расчётный счёт 40702810500000001234",
"40702810500000001234");
assertHidden(
"Клиент Иванов Иван Иванович, расчётный счёт 40702810500000001234", "40702810500000001234");
assertHidden("Иванов И.И., р/с 4070 2810 5000 0000 1234", "4070 2810 5000 0000 1234");
}
@@ -42,19 +42,24 @@ class BankTypesTest {
@Test
void keepsBankDetailsWithoutAnyPersonalData() {
for (String text : new String[]{
for (String text :
new String[] {
"Расчётный счёт 40702810500000001234 открыт вчера",
"БИК 044525593 банка-получателя",
"ОГРН 1027700132195 организации",
"КПП 770101001 указан в реквизитах"}) {
assertEquals(text, pipeline.process(text, UUID.randomUUID().toString(), SystemPolicy.DEFAULT),
"КПП 770101001 указан в реквизитах"
}) {
assertEquals(
text,
pipeline.process(text, UUID.randomUUID().toString(), SystemPolicy.DEFAULT),
"реквизиты без человека персональными данными не являются");
}
}
@Test
void masksCardExpiryButNotCardNumber() {
String masked = pipeline.process(
String masked =
pipeline.process(
"Карта 4111 1111 1111 1111, срок действия 09/27", "expiry-1", SystemPolicy.DEFAULT);
assertFalse(masked.contains("09/27"), masked);
assertEquals("Карта 41** **** **** **11, срок действия **/**", masked);
@@ -69,21 +74,25 @@ class BankTypesTest {
/** ОГРНИП (15 цифр) не должен наполовину ловиться правилом ОГРН (13 цифр). */
@Test
void ogrnDoesNotSwallowOgrnip() {
String masked = pipeline.process("ИП Иванов Иван Иванович, ОГРНИП 304500116000157",
"ogrnip-1", SystemPolicy.DEFAULT);
String masked =
pipeline.process(
"ИП Иванов Иван Иванович, ОГРНИП 304500116000157", "ogrnip-1", SystemPolicy.DEFAULT);
assertFalse(masked.contains("304500116000157"), masked);
assertFalse(masked.matches(".*\\d{15}.*"), "осталась незамаскированная часть номера: " + masked);
assertFalse(
masked.matches(".*\\d{15}.*"), "осталась незамаскированная часть номера: " + masked);
}
/**
* Контрольная сумма отсекает случайное 13-значное число рядом со словом «ОГРН».
* Число подобрано так, чтобы не проходить заодно и Луна — иначе оно всё равно
* маскировалось бы, но уже как номер карты, и тест ничего бы не показывал.
* Контрольная сумма отсекает случайное 13-значное число рядом со словом «ОГРН». Число подобрано
* так, чтобы не проходить заодно и Луна — иначе оно всё равно маскировалось бы, но уже как номер
* карты, и тест ничего бы не показывал.
*/
@Test
void doesNotMaskOgrnWithBrokenChecksum() {
String text = "ОГРН 1027700132190 организации";
assertEquals(text, pipeline.process(text, UUID.randomUUID().toString(), SystemPolicy.DEFAULT),
assertEquals(
text,
pipeline.process(text, UUID.randomUUID().toString(), SystemPolicy.DEFAULT),
"число с неверной контрольной суммой не является настоящим ОГРН");
}
@@ -91,7 +100,9 @@ class BankTypesTest {
@Test
void doesNotMaskOgrnipWithBrokenChecksum() {
String text = "ОГРНИП 304500116000150 предпринимателя";
assertEquals(text, pipeline.process(text, UUID.randomUUID().toString(), SystemPolicy.DEFAULT),
assertEquals(
text,
pipeline.process(text, UUID.randomUUID().toString(), SystemPolicy.DEFAULT),
"число с неверной контрольной суммой не является настоящим ОГРНИП");
}
@@ -107,16 +118,20 @@ class BankTypesTest {
}
/**
* Сумма заработка без человека — статистика или описание продукта. Опознать по
* ней никого нельзя, а для прокси к языковой модели вымаранное число означает,
* что вопрос про среднюю зарплату по отрасли отвечать уже не на чем.
* Сумма заработка без человека — статистика или описание продукта. Опознать по ней никого нельзя,
* а для прокси к языковой модели вымаранное число означает, что вопрос про среднюю зарплату по
* отрасли отвечать уже не на чем.
*/
@Test
void keepsIncomeWithoutAnyPersonalData() {
for (String text : new String[]{
for (String text :
new String[] {
"По данным Росстата доход домохозяйств вырос до 74 500 руб",
"Зарплатный проект: зарплата 80 000 руб перечисляется на счёт"}) {
assertEquals(text, pipeline.process(text, UUID.randomUUID().toString(), SystemPolicy.DEFAULT),
"Зарплатный проект: зарплата 80 000 руб перечисляется на счёт"
}) {
assertEquals(
text,
pipeline.process(text, UUID.randomUUID().toString(), SystemPolicy.DEFAULT),
"сумма заработка без человека персональными данными не является");
}
}
@@ -128,28 +143,33 @@ class BankTypesTest {
}
/**
* Биометрии в тексте не бывает: это шаблон в базе, и правило маскирует само
* упоминание — слово, а не данные. Без человека рядом такая замена скрывает
* ноль сведений и разрушает смысл фразы.
* Биометрии в тексте не бывает: это шаблон в базе, и правило маскирует само упоминание — слово, а
* не данные. Без человека рядом такая замена скрывает ноль сведений и разрушает смысл фразы.
*/
@Test
void keepsBiometricMentionWithoutAnyPersonalData() {
for (String text : new String[]{
for (String text :
new String[] {
"Банк внедрил биометрические данные в обслуживание клиентов",
"Сдать биометрию можно через ЕБС в любом отделении"}) {
assertEquals(text, pipeline.process(text, UUID.randomUUID().toString(), SystemPolicy.DEFAULT),
"Сдать биометрию можно через ЕБС в любом отделении"
}) {
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),
assertEquals(
text,
pipeline.process(text, UUID.randomUUID().toString(), SystemPolicy.DEFAULT),
"спутники заверили друг друга в отсутствие настоящих ПД");
}
}
+10 -11
View File
@@ -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,29 +10,30 @@ 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_]+):([^}]*)}}");
/** Размеченный пример: чистый текст и эталонные фрагменты. */
record Sample(String text, List<Span> gold) {
}
record Sample(String text, List<Span> gold) {}
private BenchmarkFixtures() {
}
private BenchmarkFixtures() {}
/** Читает набор построчно, пропуская пустые строки и комментарии {@code #}. */
static List<Sample> load(String resource) {
List<Sample> samples = new ArrayList<>();
try (InputStream in = BenchmarkFixtures.class.getResourceAsStream(resource);
BufferedReader reader = new BufferedReader(
new InputStreamReader(Objects.requireNonNull(in, resource), StandardCharsets.UTF_8))) {
BufferedReader reader =
new BufferedReader(
new InputStreamReader(
Objects.requireNonNull(in, resource), StandardCharsets.UTF_8))) {
String line;
while ((line = reader.readLine()) != null) {
String trimmed = line.trim();
+175 -99
View File
@@ -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,39 +11,47 @@ 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;
/**
* Замер качества детекции на размеченных наборах.
*
* <p>Наборов два. {@code benchmark.txt} использовался при отладке правил, поэтому
* его оценка завышена и годится только как защита от ухудшений.
* {@code benchmark-holdout.txt} составлен независимо и на нём правила не
* настраивались — именно он показывает настоящее качество.
* <p>Наборов два. {@code benchmark.txt} использовался при отладке правил, поэтому его оценка
* завышена и годится только как защита от ухудшений. {@code benchmark-holdout.txt} составлен
* независимо и на нём правила не настраивались — именно он показывает настоящее качество.
*
* <p>Метрики посимвольные: так они не зависят от того, где именно правило
* поставило границу совпадения, и напрямую соотносятся с посимвольным
* сравнением замаскированного текста с эталоном.
* <p>Метрики посимвольные: так они не зависят от того, где именно правило поставило границу
* совпадения, и напрямую соотносятся с посимвольным сравнением замаскированного текста с эталоном.
*
* <p>Отдельно считается строка «любой тип»: для защиты важно, что знаки скрыты,
* а расхождение в названии типа (скажем, место рождения против города) на
* качество маскирования не влияет.
* <p>Отдельно считается строка «любой тип»: для защиты важно, что знаки скрыты, а расхождение в
* названии типа (скажем, место рождения против города) на качество маскирования не влияет.
*/
class BenchmarkTest {
/**
* Вторая ступень для замера. Модели нет — прогон идёт на одних правилах, и это
* видно по заголовку отчёта. Путь подменяется свойством {@code -Dbench.model=...}.
* Вторая ступень для замера. Модели нет — прогон идёт на одних правилах, и это видно по заголовку
* отчёта. Путь подменяется свойством {@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");
/** Итог замера по одному набору. */
private record Result(double fioF1, double overallPrecision, double overallRecall,
double falsePositiveRate, int foundFioSpans, int goldFioSpans) {
}
private record Result(
double fioF1,
double overallPrecision,
double overallRecall,
double falsePositiveRate,
int foundFioSpans,
int goldFioSpans) {}
/** Накопитель посимвольных совпадений по одному типу. */
private static final class Score {
@@ -82,129 +83,162 @@ class BenchmarkTest {
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,
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,
assertTrue(
result.falsePositiveRate() <= 0.05,
String.format("ложные срабатывания на чистых текстах: %.3f", result.falsePositiveRate()));
}
/**
* Отложенный набор: правила на нём не настраивались. Пороги ниже — они
* отражают измеренное на нём качество, а не желаемое.
* Отложенный набор: правила на нём не настраивались. Пороги ниже — они отражают измеренное на нём
* качество, а не желаемое.
*/
@Test
void detectionQualityOnHoldoutSet() {
Result result = measure("/benchmark-holdout.txt", "отложенный набор");
assertTrue(result.fioF1() >= 0.75,
assertTrue(
result.fioF1() >= 0.75,
String.format("F1 по ФИО на отложенном наборе упал до %.3f", result.fioF1()));
assertTrue(result.overallRecall() >= 0.75,
assertTrue(
result.overallRecall() >= 0.75,
String.format("полнота на отложенном наборе упала до %.3f", result.overallRecall()));
assertTrue(result.falsePositiveRate() <= 0.15,
String.format("ложные срабатывания на отложенном наборе: %.3f", result.falsePositiveRate()));
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),
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,
assertTrue(
result.fioF1() >= 0.70,
String.format("F1 по ФИО на втором контрольном наборе упал до %.3f", result.fioF1()));
assertTrue(result.overallRecall() >= 0.70,
String.format("полнота на втором контрольном наборе упала до %.3f", result.overallRecall()));
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),
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,
assertTrue(
result.fioF1() >= 0.70,
String.format("F1 по ФИО на втором отложенном наборе упал до %.3f", result.fioF1()));
assertTrue(result.overallRecall() >= 0.70,
assertTrue(
result.overallRecall() >= 0.70,
String.format("полнота на втором отложенном наборе упала до %.3f", result.overallRecall()));
}
/**
* Реальные адреса отделений Альфа-Банка (ловушка из ТЗ — не ПД клиента),
* расширенный денилист, обобщённое companion-правило (место рождения,
* страна) и новые банковские типы. Собран специально под соответствующие
* доработки — пороги ниже, чем у набора отладки, но проверяют именно то,
* что было доработано, а не общее качество остального пайплайна.
* Реальные адреса отделений Альфа-Банка (ловушка из ТЗ — не ПД клиента), расширенный денилист,
* обобщённое companion-правило (место рождения, страна) и новые банковские типы. Собран
* специально под соответствующие доработки — пороги ниже, чем у набора отладки, но проверяют
* именно то, что было доработано, а не общее качество остального пайплайна.
*/
@Test
void detectionQualityOnBankContextSet() {
Result result = measure("/benchmark-bank-context.txt", "банковский контекст");
assertTrue(result.fioF1() >= 0.70,
assertTrue(
result.fioF1() >= 0.70,
String.format("F1 по ФИО на банковском наборе упал до %.3f", result.fioF1()));
assertTrue(result.overallRecall() >= 0.70,
assertTrue(
result.overallRecall() >= 0.70,
String.format("полнота на банковском наборе упала до %.3f", result.overallRecall()));
assertTrue(result.falsePositiveRate() <= 0.10,
String.format("ложные срабатывания на банковском наборе: %.3f", result.falsePositiveRate()));
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),
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,
assertTrue(
result.fioF1() >= 0.70,
String.format("F1 по ФИО на сгенерированном наборе упал до %.3f", result.fioF1()));
assertTrue(result.overallRecall() >= 0.70,
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", "отложенный набор, вторая ступень включена");
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,
assertTrue(
result.fioF1() >= 0.75,
String.format("F1 по ФИО со второй ступенью упал до %.3f", result.fioF1()));
}
@@ -259,13 +293,28 @@ class BenchmarkTest {
}
}
report(title, samples.size(), byType, anyType, goldFioSpans, foundFioSpans,
cleanTexts, cleanTextsWithFalseHit, missedFio, falseHits, overMasked);
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);
return new Result(
fio.f1(),
anyType.precision(),
anyType.recall(),
falsePositiveRate,
foundFioSpans,
goldFioSpans);
}
/** Раскрашивает каждый знак текста типом ПД, который его покрывает. */
@@ -304,8 +353,7 @@ class BenchmarkTest {
}
private static boolean overlappedByFio(Span gold, List<Span> found) {
return found.stream()
.anyMatch(span -> PdTypes.FIO.equals(span.type()) && span.overlaps(gold));
return found.stream().anyMatch(span -> PdTypes.FIO.equals(span.type()) && span.overlaps(gold));
}
private static String fragment(String text, Span span) {
@@ -313,7 +361,8 @@ class BenchmarkTest {
}
/** Знаки, замаскированные сверх эталона: полезно видеть, где правило берёт лишнее. */
private static void collectOverMasked(String text, String[] gold, String[] found, List<String> sink) {
private static void collectOverMasked(
String text, String[] gold, String[] found, List<String> sink) {
int from = -1;
for (int i = 0; i <= text.length(); i++) {
boolean extra = i < text.length() && found[i] != null && gold[i] == null;
@@ -326,25 +375,53 @@ class BenchmarkTest {
}
}
private void report(String title, int samples, Map<String, Score> byType, Score anyType,
int goldFio, int foundFio, int cleanTexts, int falseHitTexts,
List<String> missedFio, List<String> falseHits, List<String> overMasked) {
private void report(
String title,
int samples,
Map<String, Score> byType,
Score anyType,
int goldFio,
int foundFio,
int cleanTexts,
int falseHitTexts,
List<String> missedFio,
List<String> falseHits,
List<String> overMasked) {
StringBuilder out = new StringBuilder(4096);
out.append("\n=== ").append(title).append(": ").append(samples).append(" размеченных строк ===\n\n");
out.append(String.format("%-20s %8s %8s %8s %8s%n", "тип", "знаков", "точность", "полнота", "F1"));
out.append("\n=== ")
.append(title)
.append(": ")
.append(samples)
.append(" размеченных строк ===\n\n");
out.append(
String.format("%-20s %8s %8s %8s %8s%n", "тип", "знаков", "точность", "полнота", "F1"));
byType.entrySet().stream()
.sorted(Comparator.comparingInt((Map.Entry<String, Score> e) -> e.getValue().gold()).reversed())
.forEach(e -> out.append(String.format("%-20s %8d %8.3f %8.3f %8.3f%n",
e.getKey(), e.getValue().gold(), e.getValue().precision(),
e.getValue().recall(), e.getValue().f1())));
.sorted(
Comparator.comparingInt((Map.Entry<String, Score> e) -> e.getValue().gold()).reversed())
.forEach(
e ->
out.append(
String.format(
"%-20s %8d %8.3f %8.3f %8.3f%n",
e.getKey(),
e.getValue().gold(),
e.getValue().precision(),
e.getValue().recall(),
e.getValue().f1())));
out.append(String.format("%-20s %8d %8.3f %8.3f %8.3f%n", "ЛЮБОЙ ТИП", anyType.gold(),
anyType.precision(), anyType.recall(), anyType.f1()));
out.append(
String.format(
"%-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",
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",
out.append(
String.format(
"Тексты без ПД: ложные срабатывания на %d из %d (%.1f %%)%n",
falseHitTexts, cleanTexts, cleanTexts == 0 ? 0.0 : 100.0 * falseHitTexts / cleanTexts));
appendList(out, "\nНе найденные ФИО:", missedFio);
@@ -361,5 +438,4 @@ class BenchmarkTest {
out.append(title).append('\n');
lines.forEach(line -> out.append(" ").append(line).append('\n'));
}
}
@@ -1,12 +1,12 @@
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;
+8 -5
View File
@@ -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)),
assertEquals(
original,
cipher.decrypt(cipher.encrypt(original)),
"round-trip с ключом из application.yml должен работать");
}
}
@@ -1,19 +1,17 @@
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 {
@@ -26,7 +24,8 @@ class ContextDetectionTest {
private void assertHidden(String text, String secret) {
String masked = mask(text);
assertFalse(masked.contains(secret), "не замаскировано: «" + secret + "» в ответе «" + masked + "»");
assertFalse(
masked.contains(secret), "не замаскировано: «" + secret + "» в ответе «" + masked + "»");
}
@Test
@@ -58,7 +57,8 @@ class ContextDetectionTest {
String masked = mask("Паспорт выдан ОУФМС России по г. Москве 12.05.2015");
assertFalse(masked.contains("ОУФМС"), masked);
assertFalse(masked.contains("12.05.2015"), masked);
assertTrue(masked.contains("**.**.****"), "дата маскируется отдельно от органа выдачи: " + masked);
assertTrue(
masked.contains("**.**.****"), "дата маскируется отдельно от органа выдачи: " + masked);
}
@Test
@@ -79,7 +79,8 @@ class ContextDetectionTest {
// Место рождения — тип из requireCompanion: без другого ПД рядом не маскируется
// («Нижний Новгород» в рассказе о городе не должен теряться), поэтому в тесте
// на распознавание якоря рядом добавлен телефон.
assertHidden("Место рождения: город Тверь, проживает в Москве, тел. +7 916 123-45-67", "город Тверь");
assertHidden(
"Место рождения: город Тверь, проживает в Москве, тел. +7 916 123-45-67", "город Тверь");
assertHidden("Родился в Нижнем Новгороде, тел. +7 916 123-45-67", "Нижнем Новгороде");
}
@@ -138,7 +139,8 @@ class ContextDetectionTest {
@Test
void complexSentenceKeepsSurroundingWords() {
String original = "Клиент, паспорт 4509 123456 выдан ОУФМС по г. Москве, "
String original =
"Клиент, паспорт 4509 123456 выдан ОУФМС по г. Москве, "
+ "код подразделения 770-001, ИНН 770301234550, телефон +7 916 123-45-67";
String masked = mask(original);
@@ -151,7 +153,8 @@ class ContextDetectionTest {
@Test
void unmaskingRestoresComplexSentence() {
String original = "Паспорт 4509 123456, выдан ОУФМС России по г. Москве, "
String original =
"Паспорт 4509 123456, выдан ОУФМС России по г. Москве, "
+ "код подразделения 770-001, гражданство РФ, CVV 123, карта 4111 1111 1111 1111";
String id = "complex-1";
+20 -19
View File
@@ -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,30 +15,23 @@ 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 — для них нет правил, только модель второй ступени), варианты
* написания (регистр, формат даты, разделяющие слова) и несколько строк-ловушек без разметки
* (известный человек, адрес отделения, дата без якоря) — они не должны маскироваться вовсе.
*
* <p>На каждой строке: маскирование не оставляет исходное значение ПДН в открытом
* виде, а демаскирование побайтово восстанавливает исходный текст.
* <p>На каждой строке: маскирование не оставляет исходное значение ПДН в открытом виде, а
* демаскирование побайтово восстанавливает исходный текст.
*/
class Dataset200Test {
private static final RuleRegistry REGISTRY = new RuleRegistry();
private static final Masker MASKER = new Masker();
private static final List<BenchmarkFixtures.Sample> DATASET = BenchmarkFixtures.load("/dataset-200.txt");
private static final List<BenchmarkFixtures.Sample> DATASET =
BenchmarkFixtures.load("/dataset-200.txt");
@TestFactory
Stream<DynamicTest> datasetOf200Cases() {
@@ -40,7 +39,8 @@ class Dataset200Test {
for (int i = 0; i < DATASET.size(); i++) {
BenchmarkFixtures.Sample sample = DATASET.get(i);
int index = i;
cases.add(dynamicTest(
cases.add(
dynamicTest(
String.format("#%03d: %s", index, preview(sample.text())),
() -> runCase(sample, index)));
}
@@ -54,7 +54,8 @@ class Dataset200Test {
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),
assertFalse(
masked.contains(value),
"ПДН типа " + gold.type() + " утекло в замаскированный текст: " + value);
}
@@ -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,12 +13,6 @@ 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 {
@@ -26,7 +25,8 @@ class DateAndAddressTest {
private void assertHidden(String text, String secret) {
String masked = mask(text);
assertFalse(masked.contains(secret), "не замаскировано: «" + secret + "» в ответе «" + masked + "»");
assertFalse(
masked.contains(secret), "не замаскировано: «" + secret + "» в ответе «" + masked + "»");
}
@Test
@@ -41,7 +41,8 @@ class DateAndAddressTest {
@Test
void masksBirthDateWrittenWithWords() {
assertHidden("Дата рождения: 12 мая 1985 года", "12 мая 1985");
assertHidden("Дата рождения двенадцатого мая тысяча девятьсот восемьдесят пятого года",
assertHidden(
"Дата рождения двенадцатого мая тысяча девятьсот восемьдесят пятого года",
"двенадцатого мая");
assertHidden("Дата рождения: двадцать первого августа 1990 года", "двадцать первого августа");
}
@@ -87,7 +88,8 @@ class DateAndAddressTest {
@Test
void streetNameDoesNotSwallowTheRestOfTheSentence() {
String masked = mask("Адрес клиента: ул. Сосновая перекрыта из-за ремонта");
assertTrue(masked.contains("перекрыта из-за ремонта"),
assertTrue(
masked.contains("перекрыта из-за ремонта"),
"название улицы это одно-три слова, а не остаток предложения: " + masked);
assertFalse(masked.contains("Сосновая"), masked);
}
@@ -95,7 +97,8 @@ class DateAndAddressTest {
@Test
void doesNotMaskStreetMentionedOutsideAnAddress() {
assertEquals("Проспект Мира перекрыт до вечера", mask("Проспект Мира перекрыт до вечера"));
assertEquals("Улица Весенняя названа в честь праздника",
assertEquals(
"Улица Весенняя названа в честь праздника",
mask("Улица Весенняя названа в честь праздника"));
}
@@ -133,7 +136,8 @@ class DateAndAddressTest {
@Test
void unmaskingRestoresTextWithDateAndAddress() {
String original = "Иванов, дата рождения 12.05.1985, адрес: 125009, г. Москва, "
String original =
"Иванов, дата рождения 12.05.1985, адрес: 125009, г. Москва, "
+ "ул. Тверская, д. 7, кв. 15, паспорт 4509 123456";
String id = "date-addr-1";
+9 -8
View File
@@ -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,12 +12,6 @@ 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 {
@@ -25,7 +24,8 @@ class FioTest {
private void assertHidden(String text, String secret) {
String masked = mask(text);
assertFalse(masked.contains(secret), "не замаскировано: «" + secret + "» в ответе «" + masked + "»");
assertFalse(
masked.contains(secret), "не замаскировано: «" + secret + "» в ответе «" + masked + "»");
}
private void assertUnchanged(String text) {
@@ -106,7 +106,8 @@ class FioTest {
@Test
void unmaskingRestoresNames() {
String original = "Клиент Иванов Иван Иванович, паспорт 4509 123456, "
String original =
"Клиент Иванов Иван Иванович, паспорт 4509 123456, "
+ "дата рождения 12.05.1985, телефон +7 916 123-45-67";
String id = "fio-1";
+57 -39
View File
@@ -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,28 +18,18 @@ 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 токенов) и стоп-сигнал по утечке ПДН из "критерии_фрейм_топы".
*
* <p>Тексты строятся перемешиванием строк из уже существующих размеченных наборов
* {@code benchmark-*.txt} (17+ типов ПДН из ТЗ) — отдельный датасет с нуля не заводится,
* пул размеченных примеров и так покрывает все типы. Длина растёт от одного предложения
* до 400 000 знаков (100 000 токенов при 4 знака/токен — так же, как считает сам
* {@link Pipeline}); не менее {@link #HUGE_CASES} прогонов лежат в полосе 90 000-100 000
* токенов. На каждом прогоне проверяются: отсутствие ПДН в открытом виде в замаскированном
* тексте и побайтовое совпадение после демаскирования; на прогонах из полосы 90-100к токенов
* дополнительно проверяется, что маскирование укладывается в 5 секунд.
* <p>Тексты строятся перемешиванием строк из уже существующих размеченных наборов {@code
* benchmark-*.txt} (17+ типов ПДН из ТЗ) — отдельный датасет с нуля не заводится, пул размеченных
* примеров и так покрывает все типы. Длина растёт от одного предложения до 400 000 знаков (100 000
* токенов при 4 знака/токен — так же, как считает сам {@link Pipeline}); не менее {@link
* #HUGE_CASES} прогонов лежат в полосе 90 000-100 000 токенов. На каждом прогоне проверяются:
* отсутствие ПДН в открытом виде в замаскированном тексте и побайтовое совпадение после
* демаскирования; на прогонах из полосы 90-100к токенов дополнительно проверяется, что маскирование
* укладывается в 5 секунд.
*
* <p>Полный прогон класса занимает пару минут — это ожидаемо на объёме, требуемом ТЗ.
*/
@@ -41,13 +40,19 @@ class HugeDatasetTest {
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, номер дома и т.п.) чаще случайно совпадают с посторонним
* текстом пула — их из проверки на утечку исключаем, длинные ПДН проверяем всегда. */
/**
* Короткие значения (PIN, номер дома и т.п.) чаще случайно совпадают с посторонним текстом пула —
* их из проверки на утечку исключаем, длинные ПДН проверяем всегда.
*/
private static final int LEAK_CHECK_MIN_LENGTH = 6;
/** Допустимая доля утечек на прогон. Пул включает настоящие holdout-наборы
* (benchmark-holdout*.txt), на которых BenchmarkTest сам принимает полноту
* от 0.70 — это и есть отправная точка, а не 0.85 из LargeTextTest, где
* участвует только benchmark-generated.txt, подстроенный под правила. */
/**
* Допустимая доля утечек на прогон. Пул включает настоящие 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();
@@ -56,10 +61,15 @@ class HugeDatasetTest {
private static List<BenchmarkFixtures.Sample> loadPool() {
List<BenchmarkFixtures.Sample> pool = new ArrayList<>();
for (String resource : List.of(
"/benchmark.txt", "/benchmark-generated.txt", "/benchmark-pdn-types.txt",
"/benchmark-bank-context.txt", "/benchmark-holdout.txt",
"/benchmark-holdout2.txt", "/benchmark-holdout3.txt")) {
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;
@@ -71,17 +81,20 @@ class HugeDatasetTest {
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),
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к токенов из ТЗ.
* Длина растёт по логарифмической шкале от предложения до порога "огромного" текста — так тесты
* покрывают все порядки величины, а не только маленькие и не только большие. Последние {@link
* #HUGE_CASES} индексов — обязательная полоса 90-100к токенов из ТЗ.
*/
private static int targetChars(int index) {
int regular = TOTAL_CASES - HUGE_CASES;
@@ -96,7 +109,8 @@ class HugeDatasetTest {
}
private void runCase(int targetChars, int seed) {
Pipeline pipeline = new Pipeline(REGISTRY, MASKER, new PayloadStore(targetChars * 2L + 4096, 30));
Pipeline pipeline =
new Pipeline(REGISTRY, MASKER, new PayloadStore(targetChars * 2L + 4096, 30));
BenchmarkFixtures.Sample sample = buildText(targetChars, seed);
String payloadId = "dataset-" + seed;
@@ -119,8 +133,10 @@ class HugeDatasetTest {
// На малых текстах пара пропусков — статистический шум, не деградация детектора:
// абсолютный запас на такие случаи не даёт доле "перевесить" маленький знаменатель.
int allowed = Math.max(4, (int) Math.ceil(checked * MAX_LEAK_RATE));
assertTrue(leaked <= allowed,
String.format("утечка ПДН в замаскированном тексте: %d из %d, допустимо %d",
assertTrue(
leaked <= allowed,
String.format(
"утечка ПДН в замаскированном тексте: %d из %d, допустимо %d",
leaked, checked, allowed));
}
@@ -128,7 +144,9 @@ class HugeDatasetTest {
assertEquals(sample.text(), restored, "демаскирование не восстановило исходный текст");
if (targetChars >= HUGE_MIN_CHARS) {
assertTrue(maskMillis < 5000, "маскирование " + targetChars + " знаков заняло " + maskMillis + " мс");
assertTrue(
maskMillis < 5000,
"маскирование " + targetChars + " знаков заняло " + maskMillis + " мс");
}
}
@@ -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,11 +11,6 @@ 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 {
@@ -20,7 +19,8 @@ class IdentityDocumentTest {
private void assertHidden(String text, String secret) {
String masked = pipeline.process(text, UUID.randomUUID().toString(), SystemPolicy.DEFAULT);
assertFalse(masked.contains(secret), "не замаскировано: «" + secret + "» в ответе «" + masked + "»");
assertFalse(
masked.contains(secret), "не замаскировано: «" + secret + "» в ответе «" + masked + "»");
}
private void assertMasked(String text, String payloadId, String expected) {
@@ -48,15 +48,17 @@ class IdentityDocumentTest {
}
/**
* У загранпаспорта, военного билета и свидетельства о рождении серия короткая —
* две цифры или две буквы. Открой маска первые два знака, серия была бы видна
* целиком, поэтому у этих документов открыты только последние знаки номера.
* У загранпаспорта, военного билета и свидетельства о рождении серия короткая — две цифры или две
* буквы. Открой маска первые два знака, серия была бы видна целиком, поэтому у этих документов
* открыты только последние знаки номера.
*/
@Test
void hidesShortDocumentSeriesCompletely() {
assertMasked("Загранпаспорт 75 1234567", "fp-1", "Загранпаспорт ** *****67");
assertMasked("Военный билет АБ 1234567", "mil-1", "Военный билет ** *****67");
assertMasked("Свидетельство о рождении II-МЮ № 123456", "bc-1",
assertMasked(
"Свидетельство о рождении II-МЮ № 123456",
"bc-1",
"Свидетельство о рождении **-** № ****56");
}
@@ -64,7 +66,7 @@ class IdentityDocumentTest {
@Test
void keepsHalfOfFourCharacterSeries() {
assertMasked("Паспорт 4509 123456", "rf-1", "Паспорт 45** ****56");
assertMasked("Водительское удостоверение 9902 123456", "dl-1",
"Водительское удостоверение 99** ****56");
assertMasked(
"Водительское удостоверение 9902 123456", "dl-1", "Водительское удостоверение 99** ****56");
}
}
+49 -44
View File
@@ -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,19 +10,23 @@ 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).
*
* <p>Раздутый повтором одной строки текст проверяет только то, что цикл не
* падает на объёме: под маской всегда один и тот же тип, а остальные правила
* не задействуются вовсе. Здесь размер и разнообразие проверяются вместе.
* <p>Раздутый повтором одной строки текст проверяет только то, что цикл не падает на объёме: под
* маской всегда один и тот же тип, а остальные правила не задействуются вовсе. Здесь размер и
* разнообразие проверяются вместе.
*/
class LargeTextTest {
@@ -40,12 +37,13 @@ class LargeTextTest {
private static final int TARGET_CHARS = 400_000;
/**
* Перемешивает исходные строки (фиксированный seed — детерминированный
* тест) и склеивает их через перенос строки, пока не наберётся целевой
* объём. Смещения золотых фрагментов пересчитываются под общий текст.
* Перемешивает исходные строки (фиксированный seed — детерминированный тест) и склеивает их через
* перенос строки, пока не наберётся целевой объём. Смещения золотых фрагментов пересчитываются
* под общий текст.
*/
private static BenchmarkFixtures.Sample buildLargeText(int targetChars, long seed) {
List<BenchmarkFixtures.Sample> pool = new ArrayList<>(BenchmarkFixtures.load("/benchmark-generated.txt"));
List<BenchmarkFixtures.Sample> pool =
new ArrayList<>(BenchmarkFixtures.load("/benchmark-generated.txt"));
Random random = new Random(seed);
StringBuilder text = new StringBuilder(targetChars + 1024);
List<Span> gold = new ArrayList<>();
@@ -67,16 +65,16 @@ class LargeTextTest {
}
/**
* Маскирование и обратное преобразование на большом тексте дают
* побайтово тот же результат, что и исходный текст — при объёме на
* порядок больше, чем в остальных тестах, и с разнородным содержимым,
* а не одним повторяющимся предложением.
* Маскирование и обратное преобразование на большом тексте дают побайтово тот же результат, что и
* исходный текст — при объёме на порядок больше, чем в остальных тестах, и с разнородным
* содержимым, а не одним повторяющимся предложением.
*/
@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));
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);
@@ -87,17 +85,19 @@ class LargeTextTest {
long unmaskMillis = (System.nanoTime() - unmaskStarted) / 1_000_000;
assertEquals(large.text(), restored, "демаскирование не восстановило исходный текст");
assertTrue(maskMillis < 5000, "маскирование " + large.text().length() + " знаков заняло " + maskMillis + " мс");
assertTrue(
maskMillis < 5000,
"маскирование " + large.text().length() + " знаков заняло " + maskMillis + " мс");
assertTrue(unmaskMillis < 1000, "демаскирование заняло " + unmaskMillis + " мс");
System.out.printf("%nБольшой текст: %d знаков, маскирование %d мс, демаскирование %d мс%n",
System.out.printf(
"%nБольшой текст: %d знаков, маскирование %d мс, демаскирование %d мс%n",
large.text().length(), maskMillis, unmaskMillis);
}
/**
* Полнота детекции не должна проседать на объёме: каждый золотой
* фрагмент из перемешанных строк обязан быть найден в общем потоке
* текста, а не только когда он единственный в маленькой строке.
* Полнота детекции не должна проседать на объёме: каждый золотой фрагмент из перемешанных строк
* обязан быть найден в общем потоке текста, а не только когда он единственный в маленькой строке.
*/
@Test
void recallHoldsAtScale() {
@@ -112,17 +112,19 @@ class LargeTextTest {
}
}
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);
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()));
assertTrue(
recall >= 0.85,
String.format(
"полнота на большом тексте упала до %.3f (%d/%d)", recall, hit, large.gold().size()));
}
/**
* Вторая ступень ограничена числом кандидатов на запрос
* ({@code pdguard.ner.max-candidates}), поэтому объём текста не должен
* превращать её в квадратичную нагрузку — проверяем на том же большом
* тексте, что и остальные тесты, а не на маленьком образце.
* Вторая ступень ограничена числом кандидатов на запрос ({@code pdguard.ner.max-candidates}),
* поэтому объём текста не должен превращать её в квадратичную нагрузку — проверяем на том же
* большом тексте, что и остальные тесты, а не на маленьком образце.
*/
@Test
void nameCascadeStaysBoundedOnLargeText() {
@@ -132,7 +134,10 @@ class LargeTextTest {
return;
}
BenchmarkFixtures.Sample large = buildLargeText(TARGET_CHARS, 3);
Pipeline pipeline = new Pipeline(new RuleRegistry(), new Masker(),
Pipeline pipeline =
new Pipeline(
new RuleRegistry(),
new Masker(),
new PayloadStore(large.text().length() * 2L, 30),
new NameCascade(ENGINE, Optional.of(MODEL_PATH), 16, 4));
@@ -140,8 +145,8 @@ class LargeTextTest {
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);
System.out.printf(
"%nБольшой текст со второй ступенью: %d знаков за %d мс%n", large.text().length(), millis);
assertTrue(millis < 5000, "со второй ступенью обработка заняла " + millis + " мс");
}
}
+38 -23
View File
@@ -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,23 +18,12 @@ 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}.
*
* <p>Датасет собран из логов pd-guard-node-logs.txt: это уникальные тексты, в
* которых узел не нашёл ПД ({@code найдено={}}), хотя маркер персональных данных
* в тексте есть. Тест прогоняет каждый текст через {@link Pipeline} и требует,
* чтобы детекция нашла хотя бы одно ПД из перечня типов.
* <p>Датасет собран из логов pd-guard-node-logs.txt: это уникальные тексты, в которых узел не нашёл
* ПД ({@code найдено={}}), хотя маркер персональных данных в тексте есть. Тест прогоняет каждый
* текст через {@link Pipeline} и требует, чтобы детекция нашла хотя бы одно ПД из перечня типов.
*/
class LeakDiagTest {
@@ -40,23 +38,39 @@ class LeakDiagTest {
List<String> remaining = new ArrayList<>();
for (String raw : leaks) {
List<Span> 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));
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());
System.out.println(
"Всего утечек: "
+ leaks.size()
+ ", исправлено: "
+ fixed
+ ", осталось: "
+ remaining.size());
for (String raw : remaining) {
System.out.println(" ОСТАЛОСЬ: " + raw);
}
assertTrue(remaining.size() <= leaks.size() / 2, "осталось слишком много утечек: " + remaining.size());
assertTrue(
remaining.size() <= leaks.size() / 2, "осталось слишком много утечек: " + remaining.size());
}
private static List<String> readDataset() {
@@ -65,7 +79,8 @@ class LeakDiagTest {
if (in == null) {
throw new IllegalStateException("Датасет не найден в сборке: " + DATASET);
}
try (BufferedReader r = new BufferedReader(new InputStreamReader(in, StandardCharsets.UTF_8))) {
try (BufferedReader r =
new BufferedReader(new InputStreamReader(in, StandardCharsets.UTF_8))) {
String line;
while ((line = r.readLine()) != null) {
if (!line.isBlank()) {
+28 -16
View File
@@ -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,15 +17,6 @@ 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 {
@@ -25,8 +24,14 @@ class MaskModeTest {
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);
return new SystemPolicy(
SystemPolicy.DEFAULT_NAME,
true,
true,
mode,
Set.of(SystemPolicy.ALL),
SystemPolicy.DEFAULT.requireCompanion(),
null);
}
private String mask(MaskMode mode, String text) {
@@ -38,8 +43,11 @@ class MaskModeTest {
String masked = mask(MaskMode.STRICT, "Клиент Иванов Иван Иванович, паспорт 4509 123456");
assertFalse(masked.contains("Иванов"), masked);
assertFalse(masked.contains("И. И. И."), "STRICT не должен превращать ФИО в инициалы: " + masked);
assertTrue(masked.contains("****** **** ********"), "ожидались звёздочки по длине каждого слова: " + masked);
assertFalse(
masked.contains("И. И. И."), "STRICT не должен превращать ФИО в инициалы: " + masked);
assertTrue(
masked.contains("****** **** ********"),
"ожидались звёздочки по длине каждого слова: " + masked);
assertTrue(masked.contains("**** ******"), "край паспорта не должен открываться: " + masked);
}
@@ -52,7 +60,8 @@ class MaskModeTest {
@Test
void sameValueGetsSameTokenWithinRequest() {
String masked = mask(MaskMode.TOKEN, "ivan@mail.ru и ещё раз ivan@mail.ru, а также petr@mail.ru");
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);
}
@@ -66,7 +75,8 @@ class MaskModeTest {
Matcher card = Pattern.compile("\\d{4} \\d{4} \\d{4} \\d{4}").matcher(masked);
assertTrue(card.find(), masked);
assertTrue(Validators.luhn(card.group()), "подставленный номер карты обязан проходить проверку Луна");
assertTrue(
Validators.luhn(card.group()), "подставленный номер карты обязан проходить проверку Луна");
}
@Test
@@ -88,7 +98,9 @@ class MaskModeTest {
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())) {
for (int i = text.indexOf(fragment);
i >= 0;
i = text.indexOf(fragment, i + fragment.length())) {
n++;
}
return n;
+17 -14
View File
@@ -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,23 +17,14 @@ 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 String mask(NameCascade cascade, String payloadId) {
Pipeline pipeline = new Pipeline(new RuleRegistry(), new Masker(),
new PayloadStore(1_000_000L, 30), cascade);
Pipeline pipeline =
new Pipeline(new RuleRegistry(), new Masker(), new PayloadStore(1_000_000L, 30), cascade);
return pipeline.process(TEXT, payloadId, SystemPolicy.DEFAULT);
}
@@ -38,7 +37,9 @@ class NameCascadeTest {
@Test
void missingModelFileDoesNotBreakMasking(@TempDir Path dir) {
NameCascade cascade = new NameCascade("rubert", Optional.of(dir.resolve("нет-такого-каталога").toString()), 16, 4);
NameCascade cascade =
new NameCascade(
"rubert", Optional.of(dir.resolve("нет-такого-каталога").toString()), 16, 4);
assertFalse(cascade.enabled(), "отсутствующая модель должна выключать ступень");
assertEquals("Клиент И. И. И., паспорт 45** ****56", mask(cascade, "missing-1"));
}
@@ -47,13 +48,15 @@ class NameCascadeTest {
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"}) {
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"),
assertEquals(
"Клиент И. И. И., паспорт 45** ****56",
mask(cascade, "broken-1"),
"маскирование по правилам обязано работать и без второй ступени");
}
}
@@ -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,14 +11,9 @@ 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 {
@@ -27,26 +26,30 @@ class OrganisationNamesTest {
@Test
void keepsNamesInsideInstitutionNames() {
for (String text : new String[]{
for (String text :
new String[] {
"Институт Мечникова принимает по записи",
"Музей Верещагина работает по будням",
"Театр Станиславского открыл сезон",
"Библиотека Некрасова закрыта на ремонт",
"Премия имени Ломоносова вручена в декабре",
"Больница Боткина приняла пациентов",
"Стадион Яшина отремонтирован"}) {
"Стадион Яшина отремонтирован"
}) {
assertEquals(text, mask(text), "имя в названии учреждения маскировать не нужно");
}
}
@Test
void keepsNamesInsidePlaceNames() {
for (String text : new String[]{
for (String text :
new String[] {
"Улица Королёва названа в честь конструктора",
"Проспект Вернадского перекрыт до вечера",
"Площадь Гагарина находится на юго-западе",
"Набережная Макарова уходит к заливу",
"Мост Кадырова разведут ночью"}) {
"Мост Кадырова разведут ночью"
}) {
assertEquals(text, mask(text), "топоним маскировать не нужно");
}
}
@@ -54,24 +57,28 @@ class OrganisationNamesTest {
@Test
void masksRealClientWithTheSameSurname() {
String masked = mask("Клиент Королёв Сергей Павлович, паспорт 4509 123456");
assertFalse(masked.contains("Королёв Сергей Павлович"),
assertFalse(
masked.contains("Королёв Сергей Павлович"),
"однофамилец объекта на карте остаётся под защитой: " + masked);
}
@Test
void markerOnlyCountsRightBeforeTheName() {
String masked = mask("Больница приняла Иванова Ивана Ивановича с жалобой");
assertFalse(masked.contains("Иванова Ивана Ивановича"),
assertFalse(
masked.contains("Иванова Ивана Ивановича"),
"слово-маркер действует только вплотную перед именем: " + masked);
}
@Test
void keepsRulerNames() {
for (String text : new String[]{
for (String text :
new String[] {
"Василий Тёмный правил недолго",
"Ярослав Мудрый составил свод законов",
"Екатерина Вторая издала указ",
"Алексей Тишайший принимал послов"}) {
"Алексей Тишайший принимал послов"
}) {
assertEquals(text, mask(text), "имя правителя персональными данными не является");
}
}
@@ -79,14 +86,16 @@ class OrganisationNamesTest {
@Test
void masksClientEvenIfNameLooksRegnal() {
String masked = mask("Клиент Василий Тёмный, паспорт 4509 123456");
assertFalse(masked.contains("Василий Тёмный"),
assertFalse(
masked.contains("Василий Тёмный"),
"рядом с паспортными данными это конкретный человек: " + masked);
}
@Test
void doesNotSuppressSoleTraderName() {
String masked = mask("ИП Пахомов Вениамин Николаевич, ИНН 502601234547");
assertFalse(masked.contains("Пахомов Вениамин Николаевич"),
assertFalse(
masked.contains("Пахомов Вениамин Николаевич"),
"имя предпринимателя — это персональные данные: " + masked);
}
}
@@ -1,17 +1,18 @@
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";
private static final String KEY =
"000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f";
@Test
void encryptDecryptRoundTrip() {
@@ -33,7 +34,8 @@ class PayloadCipherTest {
@Test
void storeStoresEncryptedButReturnsPlaintext() {
PayloadCipher cipher = new PayloadCipher(KEY);
PayloadStore store = new PayloadStore(1_000_000L, 30, ru.pdguard.core.SharedIndex.disabled(), cipher);
PayloadStore store =
new PayloadStore(1_000_000L, 30, ru.pdguard.core.SharedIndex.disabled(), cipher);
String original = "Клиент Иванов Иван Иванович, паспорт 4509 123456";
String masked = "Клиент И. И. И., паспорт 45** ****56";
@@ -44,7 +46,9 @@ class PayloadCipherTest {
assertEquals(original, entry.original(), "чтение по id должно вернуть исходный текст");
// Чтение по маске возвращает исходный текст.
assertEquals(original, store.originalForMask("test", masked),
assertEquals(
original,
store.originalForMask("test", masked),
"чтение по маске должно вернуть исходный текст");
}
}
+13 -9
View File
@@ -1,13 +1,13 @@
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 {
@@ -54,20 +54,24 @@ class PayloadStoreTest {
}
/**
* Поиск по маске идёт только внутри своей системы. Маски детерминированы и
* низкоэнтропийны: без разделения чужую маску можно было бы подобрать и обменять
* на исходные данные другого потребителя.
* Поиск по маске идёт только внутри своей системы. Маски детерминированы и низкоэнтропийны: без
* разделения чужую маску можно было бы подобрать и обменять на исходные данные другого
* потребителя.
*/
@Test
void oneSystemCannotReadAnotherSystemData() {
PayloadStore store = new PayloadStore(1_000_000L, 30);
store.put("crm", "общий-id", "Иванов Иван Иванович", "И. И. И.");
assertNull(store.originalForMask("analytics", "И. И. И."),
assertNull(
store.originalForMask("analytics", "И. И. И."),
"чужую маску нельзя обменять на исходный текст");
assertNull(store.byId("analytics", "общий-id"),
assertNull(
store.byId("analytics", "общий-id"),
"совпадение идентификатора у другой системы не даёт доступа");
assertEquals("Иванов Иван Иванович", store.originalForMask("crm", "И. И. И."),
assertEquals(
"Иванов Иван Иванович",
store.originalForMask("crm", "И. И. И."),
"своя система свои данные по-прежнему получает");
}
}
@@ -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,21 +15,12 @@ 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;
/**
* Оценка эффективности детекции по каждому типу ПДН в отдельности.
*
* <p>Набор {@code benchmark-pdn-types.txt} содержит по несколько примеров каждого
* типа ПДН. Для каждого типа считается посимвольная точность, полнота и F1 —
* так видно, какие типы детектор находит надёжно, а какие пропускает или
* маскирует сверх меры.
* <p>Набор {@code benchmark-pdn-types.txt} содержит по несколько примеров каждого типа ПДН. Для
* каждого типа считается посимвольная точность, полнота и F1 — так видно, какие типы детектор
* находит надёжно, а какие пропускает или маскирует сверх меры.
*/
class PdnTypeEfficiencyTest {
@@ -69,9 +67,11 @@ class PdnTypeEfficiencyTest {
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));
boolean hit =
found.stream().anyMatch(f -> f.type().equals(gold.type()) && f.overlaps(gold));
if (!hit) {
missed.computeIfAbsent(gold.type(), t -> new ArrayList<>())
missed
.computeIfAbsent(gold.type(), t -> new ArrayList<>())
.add(sample.text().substring(gold.start(), gold.end()));
}
}
@@ -87,7 +87,8 @@ class PdnTypeEfficiencyTest {
if (isCompanion(e.getKey())) {
continue;
}
assertTrue(e.getValue().f1() >= 0.8,
assertTrue(
e.getValue().f1() >= 0.8,
String.format("F1 по типу %s упал до %.3f", e.getKey(), e.getValue().f1()));
}
}
@@ -98,7 +99,8 @@ class PdnTypeEfficiencyTest {
}
StringBuilder out = new StringBuilder();
out.append("\n=== Не распознанные значения по типам ===\n");
missed.forEach((type, values) -> {
missed.forEach(
(type, values) -> {
out.append(type).append(": ").append(String.join(" | ", values)).append('\n');
});
System.out.println(out);
@@ -136,13 +138,22 @@ class PdnTypeEfficiencyTest {
private void report(Map<String, Score> byType) {
StringBuilder out = new StringBuilder(2048);
out.append("\n=== Эффективность детекции по типам ПДН ===\n\n");
out.append(String.format("%-20s %8s %8s %8s %8s%n", "тип", "знаков", "точность", "полнота", "F1"));
out.append(
String.format("%-20s %8s %8s %8s %8s%n", "тип", "знаков", "точность", "полнота", "F1"));
byType.entrySet().stream()
.sorted(Comparator.comparingInt((Map.Entry<String, Score> e) -> e.getValue().gold()).reversed())
.forEach(e -> out.append(String.format("%-20s %8d %8.3f %8.3f %8.3f%n",
e.getKey(), e.getValue().gold(), e.getValue().precision(),
e.getValue().recall(), e.getValue().f1())));
.sorted(
Comparator.comparingInt((Map.Entry<String, Score> e) -> e.getValue().gold()).reversed())
.forEach(
e ->
out.append(
String.format(
"%-20s %8d %8.3f %8.3f %8.3f%n",
e.getKey(),
e.getValue().gold(),
e.getValue().precision(),
e.getValue().recall(),
e.getValue().f1())));
System.out.println(out);
}
@@ -1,15 +1,10 @@
package ru.pdguard;
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,19 +16,21 @@ 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}.
*
* <p>Прогон идёт на одних правилах (вторая ступень выключена), как в боевой
* сборке без модели. Перед замером пайплайн прогревается, чтобы JIT успел
* скомпилировать горячий путь, — иначе первые замеры покажут интерпретируемый
* код и занизят результат в разы.
* <p>Прогон идёт на одних правилах (вторая ступень выключена), как в боевой сборке без модели.
* Перед замером пайплайн прогревается, чтобы JIT успел скомпилировать горячий путь, — иначе первые
* замеры покажут интерпретируемый код и занизят результат в разы.
*/
class PerformanceBenchmarkTest {
@@ -47,9 +44,9 @@ class PerformanceBenchmarkTest {
};
/**
* Тексты, где правила не находят ПД, но есть цепочки имён — их разбирает
* вторая ступень (модель). Нужны, чтобы честно измерить стоимость модели,
* а не правила, которые в типовых текстах уже всё покрыли.
* Тексты, где правила не находят ПД, но есть цепочки имён — их разбирает вторая ступень (модель).
* Нужны, чтобы честно измерить стоимость модели, а не правила, которые в типовых текстах уже всё
* покрыли.
*/
private static final String[] CASCADE_PAYLOADS = {
"Готье и Руссо пришли на встречу в офис",
@@ -101,15 +98,22 @@ class PerformanceBenchmarkTest {
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);
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,
assertTrue(
maskNanos[(int) (MEASURE * 0.99)] < 5_000_000,
"p99 маскирования превысил 5 мс: " + maskNanos[(int) (MEASURE * 0.99)] / 1_000_000 + " мс");
}
@@ -126,7 +130,10 @@ class PerformanceBenchmarkTest {
List<Future<Long>> futures = new ArrayList<>();
for (int t = 0; t < threads; t++) {
final int threadId = t;
futures.add(pool.submit((Callable<Long>) () -> {
futures.add(
pool.submit(
(Callable<Long>)
() -> {
long local = 0;
for (int i = 0; i < perThread; i++) {
String text = PAYLOADS[(threadId * 31 + i) % PAYLOADS.length];
@@ -151,7 +158,8 @@ class PerformanceBenchmarkTest {
double rps = requests / (wallNanos / 1e9);
double avgUs = totalNanos / (double) requests / 1000.0;
System.out.printf("%n=== Пропускная способность (%d потоков, %d обращений) ===%n", threads, requests);
System.out.printf(
"%n=== Пропускная способность (%d потоков, %d обращений) ===%n", threads, requests);
System.out.printf("RPS: %.0f обращений/с средняя задержка: %.1f мкс%n", rps, avgUs);
assertTrue(rps > 1000, "пропускная способность ниже 1000 RPS: " + rps);
@@ -172,9 +180,13 @@ class PerformanceBenchmarkTest {
assumeTrue(Files.isReadable(model), "модель " + model.toAbsolutePath() + " не собрана");
MeterRegistry meters = new SimpleMeterRegistry();
Pipeline withCascade = new Pipeline(new RuleRegistry(), new Masker(),
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));
new NameCascade(
"rubert", Optional.of(model.toString()), "off", Optional.empty(), 16, 4, meters));
// Прогрев второй ступени: модель инициализируется лениво, первые вызовы медленные.
for (int i = 0; i < 200; i++) {
@@ -194,18 +206,24 @@ class PerformanceBenchmarkTest {
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));
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);
System.out.printf(
"Обращений к модели: %.0f, кандидатов разобрано: %.0f%n", engaged, candidates);
// Целевая задержка из ТЗ — 200 мс; даже со второй ступенью типовое обращение
// должно укладываться в десятки миллисекунд.
assertTrue(maskNanos[(int) (n * 0.99)] < 200_000_000,
assertTrue(
maskNanos[(int) (n * 0.99)] < 200_000_000,
"p99 маскирования со второй ступенью превысил 200 мс: "
+ maskNanos[(int) (n * 0.99)] / 1_000_000 + " мс");
+ maskNanos[(int) (n * 0.99)] / 1_000_000
+ " мс");
}
}
+11 -10
View File
@@ -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,13 +14,6 @@ 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 {
@@ -53,7 +52,7 @@ class PipelineTest {
@Test
void masksPhoneInAnyNotation() {
Pipeline pipeline = pipeline();
for (String phone : new String[]{"+7 (916) 123-45-67", "89161234567", "8 916 123 45 67"}) {
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);
@@ -116,9 +115,11 @@ class PipelineTest {
void systemPolicyDisablesSelectedTypes() {
Pipeline pipeline = pipeline();
SystemPolicy onlyEmail = SystemPolicy.forTypes(PdTypes.EMAIL);
String masked = pipeline.process("Карта " + VALID_CARD + ", почта ivan@mail.ru", "policy-1", onlyEmail);
String masked =
pipeline.process("Карта " + VALID_CARD + ", почта ivan@mail.ru", "policy-1", onlyEmail);
assertTrue(masked.contains(VALID_CARD), "карта не должна маскироваться этой системой: " + masked);
assertTrue(
masked.contains(VALID_CARD), "карта не должна маскироваться этой системой: " + masked);
assertFalse(masked.contains("ivan@mail.ru"), masked);
}
@@ -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,41 +21,36 @@ 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} и обобщают их корневые причины на другие типы и формы.
* Разбор по категориям — в отчёте, приложенном к задаче.
*
* <p>Как и {@link NodeLogsDatasetTest}, часть строк — подтверждённые утечки
* (падение конкретного кейса в {@link #leakSummary()} ожидаемо), часть — контрольные
* позитивные случаи, которые обязаны оставаться зелёными: если один из них упадёт,
* значит новое размещение сломало то, что раньше работало.
* <p>Как и {@link NodeLogsDatasetTest}, часть строк — подтверждённые утечки (падение конкретного
* кейса в {@link #leakSummary()} ожидаемо), часть — контрольные позитивные случаи, которые обязаны
* оставаться зелёными: если один из них упадёт, значит новое размещение сломало то, что раньше
* работало.
*/
class PlacementVariantsTest {
private static final RuleRegistry REGISTRY = new RuleRegistry();
private static final Masker MASKER = new Masker();
private static final List<BenchmarkFixtures.Sample> DATASET = BenchmarkFixtures.load("/dataset-placements.txt");
private static final List<BenchmarkFixtures.Sample> DATASET =
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)
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() {
@@ -59,7 +64,8 @@ class PlacementVariantsTest {
for (int i = 0; i < DATASET.size(); i++) {
BenchmarkFixtures.Sample sample = DATASET.get(i);
int index = i;
cases.add(dynamicTest(
cases.add(
dynamicTest(
String.format("#%02d: %s", index, preview(sample.text())),
() -> runRoundTrip(sample, index)));
}
@@ -77,11 +83,10 @@ class PlacementVariantsTest {
}
/**
* Сводка утечек одним прогоном — печатает список по типам и падает, только если
* утечек стало больше 11, то есть если что-то из уже маскируемого сегодня
* размещения перестало маскироваться. Было 20 при составлении датасета, после
* точечных фиксов RuleRegistry (расширенный разрыв якорь-значение, обратный
* порядок для дат/гражданства/кода подразделения) осталось 11.
* Сводка утечек одним прогоном — печатает список по типам и падает, только если утечек стало
* больше 11, то есть если что-то из уже маскируемого сегодня размещения перестало маскироваться.
* Было 20 при составлении датасета, после точечных фиксов RuleRegistry (расширенный разрыв
* якорь-значение, обратный порядок для дат/гражданства/кода подразделения) осталось 11.
*/
@Test
void leakSummary() {
@@ -107,12 +112,15 @@ class PlacementVariantsTest {
}
}
}
report.append(String.format("%nВсего: %d утечек из %d эталонных фрагментов%n", leaked, checked));
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);
assertTrue(
leaked <= 9,
"утечек стало больше 9 (было после точечных фиксов RuleRegistry) — новая регрессия: "
+ leaked);
}
private static String preview(String text) {
+144 -64
View File
@@ -1,30 +1,31 @@
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");
.when()
.post("/process")
.then()
.statusCode(200)
.extract()
.path("result");
}
@Test
@@ -43,8 +44,10 @@ class ProcessResourceTest {
.port(port)
.contentType("application/json")
.body(Map.of("payload", "текст"))
.when().post("/process")
.then().statusCode(400);
.when()
.post("/process")
.then()
.statusCode(400);
}
@Test
@@ -59,20 +62,26 @@ class ProcessResourceTest {
.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);
.when()
.post("/process")
.then()
.statusCode(403);
}
@Test
void systemPolicySelectsMaskMode() {
String masked = given()
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");
.when()
.post("/process")
.then()
.statusCode(200)
.extract()
.path("result");
org.junit.jupiter.api.Assertions.assertTrue(masked.contains("[FIO_1]"), masked);
}
@@ -84,8 +93,10 @@ class ProcessResourceTest {
.contentType("application/json")
.header("X-System-Id", "неизвестная-система")
.body(Map.of("payload", "почта ivan@mail.ru", "payload_id", "sys-3"))
.when().post("/process")
.then().statusCode(200)
.when()
.post("/process")
.then()
.statusCode(200)
.body("result", equalTo("почта i***@m***.ru"));
}
@@ -93,110 +104,177 @@ class ProcessResourceTest {
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"), "нет метрики типов ПД");
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()
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");
.when()
.post("/process")
.then()
.statusCode(200)
.extract()
.path("result");
org.junit.jupiter.api.Assertions.assertNotEquals(original, masked);
String byOther = given()
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,
"чужая система не должна получать исходный текст по маске");
.when()
.post("/process")
.then()
.statusCode(200)
.extract()
.path("result");
org.junit.jupiter.api.Assertions.assertNotEquals(
original, byOther, "чужая система не должна получать исходный текст по маске");
String bySameSystem = given()
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,
"своя система по своему идентификатору исходный текст получает");
.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")
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);
.when()
.post("/process")
.then()
.statusCode(403);
given().port(port).contentType("application/json")
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);
.when()
.post("/process")
.then()
.statusCode(403);
given().port(port).contentType("application/json")
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);
.when()
.post("/process")
.then()
.statusCode(200);
}
@Test
void systemWithoutKeyWorksWithoutIt() {
given().port(port).contentType("application/json")
given()
.port(port)
.contentType("application/json")
.body(Map.of("payload", "Карта 4111 1111 1111 1111", "payload_id", "key-4"))
.when().post("/process").then().statusCode(200);
.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"),
"нет счётчика участков, отданных модели");
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 body =
given()
.port(port)
.when()
.get("/actuator/prometheus")
.then()
.statusCode(200)
.extract()
.asString();
// Значения метрик — это числа, и цифры из ПД могут случайно совпасть с ними.
// Утечка возможна только через имена и метки, поэтому значения отбрасываем.
String namesAndLabels = body.lines()
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"), "ПД попали в метрики");
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/config")
.then()
.statusCode(200)
.body("crm.maskMode", equalTo("TOKEN"));
given().port(port).when().get("/admin/types").then().statusCode(200);
}
@@ -206,8 +284,10 @@ class ProcessResourceTest {
.port(port)
.contentType("application/json")
.body(Map.of("payload", "тестовая строка", "payload_id", "selfcheck-1"))
.when().post("/process")
.then().statusCode(200)
.when()
.post("/process")
.then()
.statusCode(200)
.body("result", equalTo("тестовая строка"))
.body("result", not(equalTo("")));
}
+35 -21
View File
@@ -1,23 +1,21 @@
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";
@@ -27,9 +25,12 @@ class ProxyResourceTest {
.port(port)
.contentType("application/json")
.body(Map.of("prompt", prompt))
.when().post("/proxy")
.then().statusCode(200)
.extract().jsonPath();
.when()
.post("/proxy")
.then()
.statusCode(200)
.extract()
.jsonPath();
}
@Test
@@ -56,12 +57,13 @@ class ProxyResourceTest {
void responseShowsTheWholeChain() {
JsonPath json = proxy(PROMPT);
assertTrue(json.getString("prompt_masked").contains("[FIO_1]"),
assertTrue(
json.getString("prompt_masked").contains("[FIO_1]"),
"в модель уходит обратимая подстановка: " + json.getString("prompt_masked"));
assertTrue(json.getString("llm_response_masked").contains("[FIO_1]"),
assertTrue(
json.getString("llm_response_masked").contains("[FIO_1]"),
"ответ модели ещё содержит подстановки");
assertFalse(json.getString("response").contains("[FIO_1]"),
"потребителю подстановки не видны");
assertFalse(json.getString("response").contains("[FIO_1]"), "потребителю подстановки не видны");
assertEquals("заглушка", json.getString("llm"));
assertFalse(json.getMap("replaced").isEmpty(), "таблица замен не должна быть пустой");
}
@@ -69,20 +71,32 @@ class ProxyResourceTest {
@Test
void textWithoutPersonalDataPassesThrough() {
JsonPath json = proxy("Объясни разницу между вкладом и накопительным счётом");
assertEquals("Объясни разницу между вкладом и накопительным счётом", json.getString("prompt_masked"));
assertEquals(
"Объясни разницу между вкладом и накопительным счётом", json.getString("prompt_masked"));
}
@Test
void rejectsEmptyPrompt() {
given().port(port).contentType("application/json").body(Map.of("prompt", " "))
.when().post("/proxy").then().statusCode(400);
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")
given()
.port(port)
.contentType("application/json")
.header("X-System-Id", "disabled")
.body(Map.of("prompt", PROMPT))
.when().post("/proxy").then().statusCode(403);
.when()
.post("/proxy")
.then()
.statusCode(403);
}
}
+12 -14
View File
@@ -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,17 +11,11 @@ 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 {
@@ -30,7 +28,8 @@ class SettlementTest {
private void assertHidden(String text, String secret) {
String masked = mask(text);
assertFalse(masked.contains(secret), "не замаскировано: «" + secret + "» в ответе «" + masked + "»");
assertFalse(
masked.contains(secret), "не замаскировано: «" + secret + "» в ответе «" + masked + "»");
}
@Test
@@ -107,9 +106,8 @@ class SettlementTest {
}
/**
* «с.» перед числом — обычная запись страницы («с. 25»), а не населённого
* пункта. Якорь не должен на этом срабатывать: правило требует заглавную
* букву сразу после якоря, а не цифру.
* «с.» перед числом — обычная запись страницы («с. 25»), а не населённого пункта. Якорь не должен
* на этом срабатывать: правило требует заглавную букву сразу после якоря, а не цифру.
*/
@Test
void pageReferenceIsNotMistakenForSettlement() {
@@ -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,18 +11,12 @@ 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 {
@@ -30,7 +28,8 @@ class StreetDenylistTest {
}
private void assertUnmasked(String text) {
assertEquals(text, mask(text), "улица в честь исторической фигуры не должна маскироваться: " + text);
assertEquals(
text, mask(text), "улица в честь исторической фигуры не должна маскироваться: " + text);
}
@Test
@@ -69,9 +68,8 @@ class StreetDenylistTest {
}
/**
* Тот же принцип, что и у Пушкина: рядом с другим ПД денилист не
* применяется — если в тексте всё-таки есть настоящие персональные данные,
* совпадение с историческим именем их не прикрывает.
* Тот же принцип, что и у Пушкина: рядом с другим ПД денилист не применяется — если в тексте
* всё-таки есть настоящие персональные данные, совпадение с историческим именем их не прикрывает.
*/
@Test
void masksCommemorativeStreetNameWhenOtherPersonalDataIsPresent() {
+13 -11
View File
@@ -1,25 +1,25 @@
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 = """
private static final String CONTENT =
"""
{
"default": { "enabled": true, "demask": true, "maskMode": "MASK", "types": ["*"] },
"crm": { "enabled": true, "demask": false, "maskMode": "TOKEN", "types": ["FIO"] },
@@ -84,7 +84,9 @@ class SystemsConfigTest {
Files.writeString(file, "{ это не json", StandardCharsets.UTF_8);
config.reload();
assertEquals(MaskMode.TOKEN, config.policyFor("crm").maskMode(),
assertEquals(
MaskMode.TOKEN,
config.policyFor("crm").maskMode(),
"сломанный файл не должен ронять работающий сервис");
}
}
@@ -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,32 +17,26 @@ 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 для адресов.
*
* <p>Набор {@code benchmark-two-model.txt} проверяет, что имена клиентов и адреса
* маскируются, а известные личности — нет. Для каждого типа считается посимвольная
* точность, полнота и F1.
* <p>Набор {@code benchmark-two-model.txt} проверяет, что имена клиентов и адреса маскируются, а
* известные личности — нет. Для каждого типа считается посимвольная точность, полнота и F1.
*/
class TwoModelBenchmarkTest {
private final Pipeline pipeline = new Pipeline(new RuleRegistry(), new Masker(),
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));
16,
4));
private static final class Score {
private int truePositive;
@@ -75,9 +77,11 @@ class TwoModelBenchmarkTest {
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));
boolean hit =
found.stream().anyMatch(f -> f.type().equals(gold.type()) && f.overlaps(gold));
if (!hit) {
missed.computeIfAbsent(gold.type(), t -> new ArrayList<>())
missed
.computeIfAbsent(gold.type(), t -> new ArrayList<>())
.add(sample.text().substring(gold.start(), gold.end()));
}
}
@@ -88,7 +92,8 @@ class TwoModelBenchmarkTest {
// Каждый тип должен быть найден с F1 не ниже 0.8.
for (Map.Entry<String, Score> e : byType.entrySet()) {
assertTrue(e.getValue().f1() >= 0.8,
assertTrue(
e.getValue().f1() >= 0.8,
String.format("F1 по типу %s упал до %.3f", e.getKey(), e.getValue().f1()));
}
}
@@ -120,12 +125,21 @@ class TwoModelBenchmarkTest {
private void report(Map<String, Score> byType) {
StringBuilder out = new StringBuilder(2048);
out.append("\n=== Эффективность двухмодельной архитектуры ===\n\n");
out.append(String.format("%-20s %8s %8s %8s %8s%n", "тип", "знаков", "точность", "полнота", "F1"));
out.append(
String.format("%-20s %8s %8s %8s %8s%n", "тип", "знаков", "точность", "полнота", "F1"));
byType.entrySet().stream()
.sorted(Comparator.comparingInt((Map.Entry<String, Score> e) -> e.getValue().gold()).reversed())
.forEach(e -> out.append(String.format("%-20s %8d %8.3f %8.3f %8.3f%n",
e.getKey(), e.getValue().gold(), e.getValue().precision(),
e.getValue().recall(), e.getValue().f1())));
.sorted(
Comparator.comparingInt((Map.Entry<String, Score> e) -> e.getValue().gold()).reversed())
.forEach(
e ->
out.append(
String.format(
"%-20s %8d %8.3f %8.3f %8.3f%n",
e.getKey(),
e.getValue().gold(),
e.getValue().precision(),
e.getValue().recall(),
e.getValue().f1())));
System.out.println(out);
}
@@ -135,8 +149,9 @@ class TwoModelBenchmarkTest {
}
StringBuilder out = new StringBuilder();
out.append("\n=== Не распознанные значения по типам ===\n");
missed.forEach((type, values) -> out.append(type).append(": ")
.append(String.join(" | ", values)).append('\n'));
missed.forEach(
(type, values) ->
out.append(type).append(": ").append(String.join(" | ", values)).append('\n'));
System.out.println(out);
}
}
@@ -1,5 +1,9 @@
package ru.pdguard;
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,22 +14,20 @@ 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<Span> find(String text) {
NameCascade cascade = new NameCascade(
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);
16,
4);
Pipeline p =
new Pipeline(new RuleRegistry(), new Masker(), new PayloadStore(1_000_000L, 30), cascade);
return p.findPersonalData(text, SystemPolicy.DEFAULT);
}
@@ -48,7 +50,8 @@ class TwoModelCascadeTest {
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)),
assertTrue(
spans.stream().noneMatch(s -> s.type().equals(PdTypes.FIO)),
"известная личность не должна маскироваться");
}
}
@@ -0,0 +1,132 @@
package ru.pdguard.config;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
import com.fasterxml.jackson.databind.ObjectMapper;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import ru.pdguard.mask.MaskMode;
class SystemsConfigTest {
private final ObjectMapper mapper = new ObjectMapper();
@Test
void missingFileFallsBackToDefaultPolicy(@TempDir Path dir) {
SystemsConfig config = new SystemsConfig(dir.resolve("absent.json").toString(), mapper);
SystemPolicy policy = config.policyFor("anything");
assertEquals(SystemPolicy.DEFAULT_NAME, policy.name());
assertEquals(MaskMode.MASK, policy.maskMode());
}
@Test
void readsKnownSystemFromFile(@TempDir Path dir) throws IOException {
Path file = dir.resolve("systems.json");
Files.writeString(
file,
"""
{
"crm": {
"enabled": true,
"demask": false,
"maskMode": "TOKEN",
"types": ["FIO", "PHONE"]
}
}
""",
StandardCharsets.UTF_8);
SystemsConfig config = new SystemsConfig(file.toString(), mapper);
assertTrue(config.isKnown("crm"));
SystemPolicy policy = config.policyFor("crm");
assertEquals(MaskMode.TOKEN, policy.maskMode());
assertFalse(policy.demask());
assertTrue(policy.allows("FIO"));
assertFalse(policy.allows("EMAIL"));
}
@Test
void unknownSystemFallsBackToDefault(@TempDir Path dir) throws IOException {
Path file = dir.resolve("systems.json");
Files.writeString(
file,
"""
{ "crm": { "maskMode": "TOKEN" } }
""",
StandardCharsets.UTF_8);
SystemsConfig config = new SystemsConfig(file.toString(), mapper);
assertFalse(config.isKnown("ghost"));
assertEquals(SystemPolicy.DEFAULT_NAME, config.policyFor("ghost").name());
}
@Test
void malformedJsonKeepsPreviousSettings(@TempDir Path dir) throws IOException {
Path file = dir.resolve("systems.json");
Files.writeString(
file,
"""
{ "crm": { "maskMode": "TOKEN" } }
""",
StandardCharsets.UTF_8);
SystemsConfig config = new SystemsConfig(file.toString(), mapper);
assertTrue(config.isKnown("crm"));
Files.writeString(file, "{ not valid json", StandardCharsets.UTF_8);
config.reload();
assertTrue(config.isKnown("crm"), "битый файл не должен затирать рабочие настройки");
}
@Test
void unknownMaskModeKeepsPreviousSettings(@TempDir Path dir) throws IOException {
Path file = dir.resolve("systems.json");
Files.writeString(
file,
"""
{ "crm": { "maskMode": "TOKEN" } }
""",
StandardCharsets.UTF_8);
SystemsConfig config = new SystemsConfig(file.toString(), mapper);
Files.writeString(
file,
"""
{ "crm": { "maskMode": "NOT_A_MODE" } }
""",
StandardCharsets.UTF_8);
config.reload();
assertEquals(
MaskMode.TOKEN,
config.policyFor("crm").maskMode(),
"неизвестный maskMode не должен принять частично разобранные настройки");
}
@Test
void currentReturnsSortedSnapshot(@TempDir Path dir) throws IOException {
Path file = dir.resolve("systems.json");
Files.writeString(
file,
"""
{ "zzz": { "maskMode": "TOKEN" }, "aaa": { "maskMode": "MASK" } }
""",
StandardCharsets.UTF_8);
SystemsConfig config = new SystemsConfig(file.toString(), mapper);
assertEquals(
java.util.List.of("aaa", "default", "zzz"),
config.current().keySet().stream().sorted().toList());
}
}
@@ -0,0 +1,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(), "окно ещё не прошло — предел не должен меняться");
}
}
@@ -0,0 +1,65 @@
package ru.pdguard.core;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNotEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
import java.security.SecureRandom;
import org.junit.jupiter.api.Test;
class PayloadCipherTest {
private static String randomHexKey() {
byte[] bytes = new byte[32];
new SecureRandom().nextBytes(bytes);
return java.util.HexFormat.of().formatHex(bytes);
}
@Test
void disabledCipherPassesTextThrough() {
PayloadCipher cipher = PayloadCipher.disabled();
assertFalse(cipher.enabled());
assertEquals("Иванов Иван Иванович", cipher.encrypt("Иванов Иван Иванович"));
assertEquals("Иванов Иван Иванович", cipher.decrypt("Иванов Иван Иванович"));
}
@Test
void enabledCipherRoundTrips() {
PayloadCipher cipher = new PayloadCipher(randomHexKey());
assertTrue(cipher.enabled());
String encrypted = cipher.encrypt("Иванов Иван Иванович, паспорт 4509 123456");
assertNotEquals("Иванов Иван Иванович, паспорт 4509 123456", encrypted);
assertEquals("Иванов Иван Иванович, паспорт 4509 123456", cipher.decrypt(encrypted));
}
@Test
void ciphertextDiffersEachTimeDueToRandomIv() {
PayloadCipher cipher = new PayloadCipher(randomHexKey());
String first = cipher.encrypt("тот же текст");
String second = cipher.encrypt("тот же текст");
assertNotEquals(first, second, "разный IV должен давать разный шифротекст на одинаковом входе");
}
@Test
void decryptingGarbageThrows() {
PayloadCipher cipher = new PayloadCipher(randomHexKey());
assertThrows(IllegalStateException.class, () -> cipher.decrypt("не base64!!!"));
}
@Test
void decryptingWithDifferentKeyThrows() {
PayloadCipher first = new PayloadCipher(randomHexKey());
PayloadCipher second = new PayloadCipher(randomHexKey());
String encrypted = first.encrypt("секрет");
assertThrows(IllegalStateException.class, () -> second.decrypt(encrypted));
}
}
@@ -1,16 +1,15 @@
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 {
@@ -43,10 +42,9 @@ class NameDictionaryTest {
}
/**
* Фамилии, в честь которых чаще всего называют улицы в России (Росреестр).
* Само по себе «улица Ленина» никогда не попало бы под ФИО — для этого
* правила нужны два слова, — но денилист должен покрывать и составные
* названия («Феликса Дзержинского»), и вариации написания («Будённый»/
* Фамилии, в честь которых чаще всего называют улицы в России (Росреестр). Само по себе «улица
* Ленина» никогда не попало бы под ФИО — для этого правила нужны два слова, — но денилист должен
* покрывать и составные названия («Феликса Дзержинского»), и вариации написания («Будённый»/
* «Буденный»).
*/
@Test
@@ -59,7 +57,8 @@ class NameDictionaryTest {
// «Будённый» — чистое прилагательное без «-ский» (как «Толстой»): та же
// известная граница приёма, родительный падеж («Будённого») им не ловится.
assertTrue(NameDictionary.isWellKnown("улица Будённый"));
assertTrue(NameDictionary.isWellKnown("улица Буденный"), "написание без «ё» тоже должно ловиться");
assertTrue(
NameDictionary.isWellKnown("улица Буденный"), "написание без «ё» тоже должно ловиться");
assertTrue(NameDictionary.isWellKnown("улица Жукова"));
assertTrue(NameDictionary.isWellKnown("улица Островского"));
}
@@ -71,9 +70,11 @@ class NameDictionaryTest {
NameDictionary.useExternalFile(file);
assertTrue(NameDictionary.isWellKnown("Интервью Кастомова"),
assertTrue(
NameDictionary.isWellKnown("Интервью Кастомова"),
"дописанное сверху имя должно распознаваться наравне со встроенными");
assertTrue(NameDictionary.isWellKnown("Стихи Пушкина"),
assertTrue(
NameDictionary.isWellKnown("Стихи Пушкина"),
"встроенный список не должен теряться при дозагрузке");
}
@@ -1,10 +1,10 @@
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 {
@@ -18,7 +18,8 @@ class ToponymDictionaryTest {
@Test
void recognisesInflectedForms() {
assertTrue(ToponymDictionary.isKnownSettlement("Москве"), "дательный падеж города на гласную");
assertTrue(ToponymDictionary.isKnownSettlement("Тамбове"), "предложный падеж города на согласную");
assertTrue(
ToponymDictionary.isKnownSettlement("Тамбове"), "предложный падеж города на согласную");
assertTrue(ToponymDictionary.isKnownSettlement("Казани"), "родительный падеж");
}
@@ -41,11 +42,9 @@ class ToponymDictionaryTest {
}
/**
* Из переписи 2020–2021, не из ручного списка городов: сёла, посёлки,
* деревни, хутора, станицы, аулы, аалы — ровно то, чего не было, пока
* словарь ограничивался официальными городами. Примеры из ТЗ («рп.
* Ильинское», «с. Кукуево») и по одному реальному названию на тип
* населённого пункта.
* Из переписи 2020–2021, не из ручного списка городов: сёла, посёлки, деревни, хутора, станицы,
* аулы, аалы — ровно то, чего не было, пока словарь ограничивался официальными городами. Примеры
* из ТЗ («рп. Ильинское», «с. Кукуево») и по одному реальному названию на тип населённого пункта.
*/
@Test
void recognisesSettlementsFromCensusNotJustOfficialCities() {
@@ -1,10 +1,10 @@
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 {
@@ -41,7 +41,8 @@ class ValidatorsTest {
@Test
void invalidOgrnipChecksumFails() {
assertFalse(Validators.ogrnip("304500116000158"), "последняя цифра изменена — сумма не сходится");
assertFalse(
Validators.ogrnip("304500116000158"), "последняя цифра изменена — сумма не сходится");
}
@Test