PD Guard: модуль безопасности персональных данных
This commit is contained in:
@@ -0,0 +1,19 @@
|
||||
package ru.pdguard;
|
||||
|
||||
import org.springframework.boot.SpringApplication;
|
||||
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
||||
|
||||
/**
|
||||
* Точка входа Spring Boot приложения.
|
||||
*
|
||||
* <p>Модуль безопасности персональных данных: прокси между системой-потребителем
|
||||
* и LLM. Находит персональные данные, маскирует их и восстанавливает исходный
|
||||
* текст на обратном шаге.
|
||||
*/
|
||||
@SpringBootApplication
|
||||
public class PdGuardApplication {
|
||||
|
||||
public static void main(String[] args) {
|
||||
SpringApplication.run(PdGuardApplication.class, args);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
package ru.pdguard.api;
|
||||
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
import ru.pdguard.config.SystemPolicy;
|
||||
import ru.pdguard.config.SystemsConfig;
|
||||
import ru.pdguard.detect.RuleRegistry;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/** Просмотр действующих настроек и принудительное их перечитывание. */
|
||||
@RestController
|
||||
public class AdminResource {
|
||||
|
||||
private final SystemsConfig systems;
|
||||
private final RuleRegistry registry;
|
||||
|
||||
public AdminResource(SystemsConfig systems, RuleRegistry registry) {
|
||||
this.systems = systems;
|
||||
this.registry = registry;
|
||||
}
|
||||
|
||||
@GetMapping("/admin/config")
|
||||
public Map<String, SystemPolicy> config() {
|
||||
return systems.current();
|
||||
}
|
||||
|
||||
@GetMapping("/admin/types")
|
||||
public List<String> types() {
|
||||
return registry.knownTypes();
|
||||
}
|
||||
|
||||
@PostMapping("/admin/reload")
|
||||
public Map<String, SystemPolicy> reload() {
|
||||
systems.reload();
|
||||
return systems.current();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
package ru.pdguard.api;
|
||||
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
/** Проба готовности для балансировщика и проверяющей системы. */
|
||||
@RestController
|
||||
public class HealthResource {
|
||||
|
||||
@GetMapping("/health")
|
||||
public String health() {
|
||||
return "OK";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
package ru.pdguard.api;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||
import io.micrometer.core.instrument.Counter;
|
||||
import io.micrometer.core.instrument.MeterRegistry;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RequestHeader;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
import ru.pdguard.config.SystemPolicy;
|
||||
import ru.pdguard.config.SystemsConfig;
|
||||
import ru.pdguard.core.AdaptiveConcurrencyLimiter;
|
||||
import ru.pdguard.core.Pipeline;
|
||||
|
||||
/**
|
||||
* Единственная точка входа контракта: маскирование и демаскирование по
|
||||
* {@code payload_id}.
|
||||
*
|
||||
* <p>Система-потребитель называет себя заголовком {@code X-System-Id}. Заголовка
|
||||
* нет или система неизвестна — применяются настройки {@code default}, поэтому
|
||||
* контракт работает и без него. Система, выключенная в настройках, получает
|
||||
* {@code 403}.
|
||||
*
|
||||
* <p>При перегрузке отвечает {@code 429} с {@code Retry-After}. Порог перегрузки —
|
||||
* не фиксированное число запросов, а задержка обработки: {@link AdaptiveConcurrencyLimiter}
|
||||
* сам находит потолок конкурентности под то, сколько CPU реально досталось контейнеру,
|
||||
* вместо того чтобы копить запросы и упереться в таймаут вызывающей стороны.
|
||||
*/
|
||||
@RestController
|
||||
public class ProcessResource {
|
||||
|
||||
private static final Logger LOG = LoggerFactory.getLogger(ProcessResource.class);
|
||||
|
||||
/** Заголовок, которым система-потребитель себя называет. */
|
||||
public static final String SYSTEM_HEADER = "X-System-Id";
|
||||
|
||||
/** Общий секрет системы. Проверяется, только если он задан в настройках. */
|
||||
public static final String KEY_HEADER = "X-System-Key";
|
||||
|
||||
/** Имя метрики отклонённых запросов и имя её метки причины. */
|
||||
private static final String REJECTED_METRIC = "pdguard.requests.rejected";
|
||||
private static final String REASON_TAG = "reason";
|
||||
|
||||
/**
|
||||
* Что отдаётся при внутреннем сбое. Ни одного знака из запроса: сбой на прямом
|
||||
* шаге иначе выпустил бы наружу незамаскированные персональные данные.
|
||||
*/
|
||||
static final String PROCESSING_UNAVAILABLE = "[обработка недоступна]";
|
||||
|
||||
public record ProcessRequest(
|
||||
@JsonProperty("payload") String payload,
|
||||
@JsonProperty("payload_id") String payloadId) {
|
||||
}
|
||||
|
||||
public record ProcessResponse(@JsonProperty("result") String result) {
|
||||
}
|
||||
|
||||
private final Pipeline pipeline;
|
||||
private final SystemsConfig systems;
|
||||
private final AdaptiveConcurrencyLimiter limiter;
|
||||
private final Counter rejected;
|
||||
private final Counter malformed;
|
||||
private final Counter forbidden;
|
||||
private final Counter failed;
|
||||
|
||||
public ProcessResource(Pipeline pipeline, SystemsConfig systems, MeterRegistry meters,
|
||||
@Value("${pdguard.min-concurrent:8}") int minConcurrent,
|
||||
@Value("${pdguard.max-concurrent:2000}") int maxConcurrent,
|
||||
@Value("${pdguard.target-latency-ms:200}") long targetLatencyMillis) {
|
||||
this.pipeline = pipeline;
|
||||
this.systems = systems;
|
||||
this.limiter = new AdaptiveConcurrencyLimiter(minConcurrent, maxConcurrent, targetLatencyMillis);
|
||||
this.rejected = meters.counter(REJECTED_METRIC, REASON_TAG, "overload");
|
||||
this.malformed = meters.counter(REJECTED_METRIC, REASON_TAG, "malformed");
|
||||
this.forbidden = meters.counter(REJECTED_METRIC, REASON_TAG, "system_disabled");
|
||||
this.failed = meters.counter(REJECTED_METRIC, REASON_TAG, "internal_error");
|
||||
meters.gauge("pdguard.concurrency.limit", limiter, AdaptiveConcurrencyLimiter::limit);
|
||||
meters.gauge("pdguard.concurrency.in.flight", limiter, AdaptiveConcurrencyLimiter::inFlight);
|
||||
}
|
||||
|
||||
@PostMapping("/process")
|
||||
public ResponseEntity<ProcessResponse> process(@RequestBody(required = false) ProcessRequest request,
|
||||
@RequestHeader(value = SYSTEM_HEADER, required = false) String systemId,
|
||||
@RequestHeader(value = KEY_HEADER, required = false) String systemKey) {
|
||||
if (request == null || request.payload() == null
|
||||
|| request.payloadId() == null || request.payloadId().isBlank()) {
|
||||
malformed.increment();
|
||||
return ResponseEntity.badRequest()
|
||||
.body(new ProcessResponse("payload и payload_id обязательны"));
|
||||
}
|
||||
|
||||
SystemPolicy policy = systems.policyFor(systemId);
|
||||
if (!policy.accepts(systemKey)) {
|
||||
forbidden.increment();
|
||||
LOG.warn("Системе {} отказано: неверный ключ", systemId);
|
||||
return ResponseEntity.status(HttpStatus.FORBIDDEN)
|
||||
.body(new ProcessResponse("Неверный ключ системы"));
|
||||
}
|
||||
if (!policy.enabled()) {
|
||||
forbidden.increment();
|
||||
LOG.warn("Системе {} обращение в модуль запрещено настройками", systemId);
|
||||
return ResponseEntity.status(HttpStatus.FORBIDDEN)
|
||||
.body(new ProcessResponse("Системе " + systemId + " обращение в модуль запрещено"));
|
||||
}
|
||||
|
||||
if (!limiter.tryAcquire()) {
|
||||
rejected.increment();
|
||||
return ResponseEntity.status(429).header("Retry-After", "1").build();
|
||||
}
|
||||
long started = System.nanoTime();
|
||||
try {
|
||||
String result = pipeline.process(request.payload(), request.payloadId(), policy);
|
||||
return ResponseEntity.ok(new ProcessResponse(result));
|
||||
} catch (RuntimeException e) {
|
||||
// Ни 5xx, ни исходный текст. Пять подряд невалидных ответов останавливают
|
||||
// прогон, поэтому код остаётся 200 — но возвращать при сбое сам payload
|
||||
// нельзя: на прямом шаге наружу ушли бы незамаскированные ПД, ровно то,
|
||||
// ради чего сервис и существует. Ответ фиксированный: он ничего не
|
||||
// раскрывает и не выглядит порчей данных.
|
||||
failed.increment();
|
||||
LOG.error("payload_id={} обработка не удалась, отдан безопасный ответ",
|
||||
request.payloadId(), e);
|
||||
return ResponseEntity.ok(new ProcessResponse(PROCESSING_UNAVAILABLE));
|
||||
} finally {
|
||||
limiter.release(System.nanoTime() - started);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
package ru.pdguard.api;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RequestHeader;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
import ru.pdguard.config.SystemPolicy;
|
||||
import ru.pdguard.config.SystemsConfig;
|
||||
import ru.pdguard.core.LlmClient;
|
||||
import ru.pdguard.core.Pipeline;
|
||||
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* Демонстрационное плечо к языковой модели: показывает всю цепочку целиком.
|
||||
*
|
||||
* <pre>
|
||||
* потребитель → маскирование → LLM → демаскирование → потребитель
|
||||
* </pre>
|
||||
*
|
||||
* <p>В ответе видны все три текста — что ушло в модель, что она вернула и что
|
||||
* получил потребитель. Это и есть доказательство, что в модель не попало ничего
|
||||
* незамаскированного, а ответ вернулся с восстановленными значениями.
|
||||
*
|
||||
* <p>Ответ модели — другой текст, а не тот же самый, поэтому восстановить его по
|
||||
* идентификатору целиком нельзя: замена идёт пофрагментно. Звёздочки для этого не
|
||||
* годятся — одна и та же маска отвечала бы разным значениям, — поэтому здесь всегда
|
||||
* применяется обратимая подстановка, независимо от режима маскирования системы.
|
||||
*
|
||||
* <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 ProxyResponse(
|
||||
@JsonProperty("prompt_masked") String promptMasked,
|
||||
@JsonProperty("llm_response_masked") String llmResponseMasked,
|
||||
@JsonProperty("response") String response,
|
||||
@JsonProperty("replaced") Map<String, String> replaced,
|
||||
@JsonProperty("llm") String llm,
|
||||
@JsonProperty("error") String error) {
|
||||
}
|
||||
|
||||
private final Pipeline pipeline;
|
||||
private final SystemsConfig systems;
|
||||
private final LlmClient llm;
|
||||
|
||||
public ProxyResource(Pipeline pipeline, SystemsConfig systems, LlmClient llm) {
|
||||
this.pipeline = pipeline;
|
||||
this.systems = systems;
|
||||
this.llm = llm;
|
||||
}
|
||||
|
||||
@PostMapping("/proxy")
|
||||
public ResponseEntity<ProxyResponse> proxy(@RequestBody(required = false) ProxyRequest request,
|
||||
@RequestHeader(value = ProcessResource.SYSTEM_HEADER, required = false) String systemId,
|
||||
@RequestHeader(value = ProcessResource.KEY_HEADER, required = false) String systemKey) {
|
||||
if (request == null || request.prompt() == null || request.prompt().isBlank()) {
|
||||
return ResponseEntity.badRequest()
|
||||
.body(new ProxyResponse(null, null, null, null, null, "поле prompt обязательно"));
|
||||
}
|
||||
|
||||
SystemPolicy policy = systems.policyFor(systemId);
|
||||
if (!policy.accepts(systemKey)) {
|
||||
return ResponseEntity.status(HttpStatus.FORBIDDEN)
|
||||
.body(new ProxyResponse(null, null, null, null, null, "Неверный ключ системы"));
|
||||
}
|
||||
if (!policy.enabled()) {
|
||||
return ResponseEntity.status(HttpStatus.FORBIDDEN)
|
||||
.body(new ProxyResponse(null, null, null, null, null,
|
||||
"Системе " + systemId + " обращение в модуль запрещено"));
|
||||
}
|
||||
|
||||
Pipeline.Masked masked = pipeline.maskWithRestorations(request.prompt(), policy);
|
||||
LlmClient.Answer answer = llm.ask(masked.text());
|
||||
String restored = policy.demask() ? restore(answer.text(), masked.restorations()) : answer.text();
|
||||
|
||||
LOG.info("proxy: система={} заменено={} модель={}",
|
||||
policy.name(), masked.restorations().size(), answer.source());
|
||||
return ResponseEntity.ok(new ProxyResponse(masked.text(), answer.text(), restored,
|
||||
masked.restorations(), answer.source(), null));
|
||||
}
|
||||
|
||||
/** Возвращает исходные значения на место подстановок в ответе модели. */
|
||||
private static String restore(String text, Map<String, String> restorations) {
|
||||
String result = text;
|
||||
for (Map.Entry<String, String> entry : restorations.entrySet()) {
|
||||
result = result.replace(entry.getKey(), entry.getValue());
|
||||
}
|
||||
return result;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
package ru.pdguard.config;
|
||||
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import ru.pdguard.detect.NameDictionary;
|
||||
|
||||
/**
|
||||
* Конфигурация словарей распознавания ФИО.
|
||||
*
|
||||
* <p>{@link NameDictionary} работает через статические методы и не требует
|
||||
* экземпляра, но путь к внешнему файлу денилиста задаётся из настроек при
|
||||
* старте. Бин здесь нужен только для того, чтобы Spring подставил значение
|
||||
* {@code pdguard.well-known-file} и передал его словарю.
|
||||
*/
|
||||
@Configuration
|
||||
public class DictionaryConfiguration {
|
||||
|
||||
@Bean
|
||||
public NameDictionary nameDictionary(
|
||||
@Value("${pdguard.well-known-file:config/well-known.txt}") String wellKnownFile) {
|
||||
NameDictionary.configure(wellKnownFile);
|
||||
return NameDictionary.create();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
package ru.pdguard.config;
|
||||
|
||||
import ru.pdguard.mask.MaskMode;
|
||||
|
||||
import java.util.Set;
|
||||
|
||||
/**
|
||||
* Правила обработки для одной системы-потребителя.
|
||||
*
|
||||
* @param name имя системы; им же разделяется хранилище соответствий,
|
||||
* чтобы одна система не могла достать данные другой
|
||||
* @param enabled разрешено ли системе обращаться в модуль
|
||||
* @param demask выполняется ли для системы обратное преобразование
|
||||
* @param maskMode вид замены: звёздочки, токен или синтетическое значение
|
||||
* @param types типы ПД к маскированию; {@code "*"} — все известные
|
||||
* @param key общий секрет системы; задан — заголовок {@code X-System-Key} обязан
|
||||
* совпасть, иначе имя системы можно было бы просто назвать.
|
||||
* Только знаки ASCII: заголовки HTTP передаются в Latin-1,
|
||||
* и кириллица в ключе до сервиса доедет искажённой
|
||||
* @param requireCompanion типы, которые маскируются только вместе с ПД другого типа:
|
||||
* пин-код сам по себе безвреден, пин-код рядом с номером
|
||||
* карты — уже нет; то же для даты без якорного слова, места
|
||||
* рождения («Нижний Новгород» в рассказе о городе — не адрес
|
||||
* клиента) и страны («цены выросли в Казахстане» — не гражданство).
|
||||
* Сюда же банковские реквизиты — счёт, БИК, ОГРН, ОГРНИП, КПП:
|
||||
* сами по себе они опознают организацию или счёт, а не человека,
|
||||
* и в перечне типов из задания их нет. Рядом с именем клиента
|
||||
* они становятся его данными и маскируются.
|
||||
* Сюда же доход и биометрия. Сумма заработка без человека —
|
||||
* статистика («доход домохозяйств вырос до 74 500 руб»), а не
|
||||
* персональные данные. Биометрия же в тексте не встречается
|
||||
* вовсе: это шаблон в базе, и правило маскирует лишь само
|
||||
* упоминание, то есть слово, а не данные. Чувствителен здесь
|
||||
* факт, что биометрию сдал названный человек, — а он и
|
||||
* существует только при имени рядом
|
||||
*/
|
||||
public record SystemPolicy(String name, boolean enabled, boolean demask, MaskMode maskMode,
|
||||
Set<String> types, Set<String> requireCompanion, String key) {
|
||||
|
||||
public static final String ALL = "*";
|
||||
|
||||
/** Имя политики по умолчанию; оно же разделяет хранилище для запросов без заголовка. */
|
||||
public static final String DEFAULT_NAME = "default";
|
||||
|
||||
/** Политика по умолчанию: маскируем всё, что умеем, обратное преобразование включено. */
|
||||
public static final SystemPolicy DEFAULT = new SystemPolicy(
|
||||
DEFAULT_NAME, true, true, MaskMode.MASK, Set.of(ALL),
|
||||
Set.of("CVV", "PIN", "DATE", "BIRTH_PLACE", "ADDRESS_COUNTRY",
|
||||
"ACCOUNT_NUMBER", "BIK", "OGRN", "OGRNIP", "KPP",
|
||||
"INCOME", "BIOMETRIC"), null);
|
||||
|
||||
public SystemPolicy {
|
||||
types = Set.copyOf(types);
|
||||
requireCompanion = Set.copyOf(requireCompanion);
|
||||
}
|
||||
|
||||
/** Политика только для перечисленных типов, с остальными настройками по умолчанию. */
|
||||
public static SystemPolicy forTypes(String... types) {
|
||||
return new SystemPolicy(DEFAULT_NAME, true, true, MaskMode.MASK,
|
||||
Set.of(types), DEFAULT.requireCompanion(), null);
|
||||
}
|
||||
|
||||
/** Совпадает ли предъявленный ключ. Ключ не задан — проверка не применяется. */
|
||||
public boolean accepts(String presentedKey) {
|
||||
if (key == null || key.isBlank()) {
|
||||
return true;
|
||||
}
|
||||
return java.security.MessageDigest.isEqual(
|
||||
key.getBytes(java.nio.charset.StandardCharsets.UTF_8),
|
||||
(presentedKey == null ? "" : presentedKey).getBytes(java.nio.charset.StandardCharsets.UTF_8));
|
||||
}
|
||||
|
||||
public boolean allows(String type) {
|
||||
return types.contains(ALL) || types.contains(type);
|
||||
}
|
||||
|
||||
public boolean needsCompanion(String type) {
|
||||
return requireCompanion.contains(type);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
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;
|
||||
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;
|
||||
|
||||
/**
|
||||
* Список систем, которым разрешено обращаться в модуль, и правила для каждой.
|
||||
*
|
||||
* <p>Читается из внешнего файла, чтобы настройки менялись без пересборки. Файл
|
||||
* перечитывается сам, когда меняется время его изменения; проверка выполняется
|
||||
* не чаще раза в секунду, чтобы не ходить в файловую систему на каждом запросе.
|
||||
* Файла нет — работают настройки по умолчанию, и сервис поднимается без него.
|
||||
*/
|
||||
@Component
|
||||
public class SystemsConfig {
|
||||
|
||||
private static final Logger LOG = LoggerFactory.getLogger(SystemsConfig.class);
|
||||
|
||||
/** Имя политики, которая применяется к запросам без заголовка системы. */
|
||||
public static final String DEFAULT_SYSTEM = "default";
|
||||
|
||||
private static final long RECHECK_MILLIS = 1000;
|
||||
|
||||
/** Описание одной системы в файле настроек. */
|
||||
public record SystemEntry(Boolean enabled, Boolean demask, String maskMode,
|
||||
List<String> types, List<String> requireCompanion, String key) {
|
||||
}
|
||||
|
||||
private final Path file;
|
||||
private final ObjectMapper mapper;
|
||||
|
||||
private final AtomicReference<Map<String, SystemPolicy>> policies =
|
||||
new AtomicReference<>(Map.of(DEFAULT_SYSTEM, SystemPolicy.DEFAULT));
|
||||
private volatile long fileTimestamp;
|
||||
private volatile long lastCheck;
|
||||
|
||||
public SystemsConfig(@Value("${pdguard.systems-file:config/systems.json}") String path,
|
||||
ObjectMapper mapper) {
|
||||
this.file = Path.of(path);
|
||||
this.mapper = mapper;
|
||||
reload();
|
||||
}
|
||||
|
||||
/** Правила для системы; неизвестная система получает настройки по умолчанию. */
|
||||
public SystemPolicy policyFor(String systemId) {
|
||||
refreshIfChanged();
|
||||
Map<String, SystemPolicy> current = policies.get();
|
||||
SystemPolicy policy = systemId == null ? null : current.get(systemId);
|
||||
if (policy != null) {
|
||||
return policy;
|
||||
}
|
||||
return current.getOrDefault(DEFAULT_SYSTEM, SystemPolicy.DEFAULT);
|
||||
}
|
||||
|
||||
/** Известна ли система по имени. */
|
||||
public boolean isKnown(String systemId) {
|
||||
refreshIfChanged();
|
||||
return systemId != null && policies.get().containsKey(systemId);
|
||||
}
|
||||
|
||||
/** Текущие настройки — для отдачи в административном интерфейсе. */
|
||||
public Map<String, SystemPolicy> current() {
|
||||
refreshIfChanged();
|
||||
return new TreeMap<>(policies.get());
|
||||
}
|
||||
|
||||
/** Перечитать файл настроек немедленно. */
|
||||
public final synchronized void reload() {
|
||||
lastCheck = System.currentTimeMillis();
|
||||
if (!Files.isReadable(file)) {
|
||||
LOG.info("Файл настроек {} не найден, применяются настройки по умолчанию", file.toAbsolutePath());
|
||||
policies.set(Map.of(DEFAULT_SYSTEM, SystemPolicy.DEFAULT));
|
||||
fileTimestamp = 0;
|
||||
return;
|
||||
}
|
||||
try {
|
||||
fileTimestamp = Files.getLastModifiedTime(file).toMillis();
|
||||
Map<String, SystemEntry> entries = mapper.readValue(Files.readAllBytes(file),
|
||||
mapper.getTypeFactory().constructMapType(TreeMap.class, String.class, SystemEntry.class));
|
||||
Map<String, SystemPolicy> parsed = new TreeMap<>();
|
||||
entries.forEach((name, entry) -> parsed.put(name, toPolicy(name, entry)));
|
||||
parsed.putIfAbsent(DEFAULT_SYSTEM, SystemPolicy.DEFAULT);
|
||||
policies.set(Map.copyOf(parsed));
|
||||
LOG.info("Настройки систем перечитаны из {}: {}", file.toAbsolutePath(), parsed.keySet());
|
||||
} catch (IOException | IllegalArgumentException e) {
|
||||
// Битый файл не должен ронять работающий сервис: остаются прежние настройки.
|
||||
LOG.error("Не удалось прочитать {}, продолжаем с прежними настройками", file.toAbsolutePath(), e);
|
||||
}
|
||||
}
|
||||
|
||||
private void refreshIfChanged() {
|
||||
long now = System.currentTimeMillis();
|
||||
if (now - lastCheck < RECHECK_MILLIS) {
|
||||
return;
|
||||
}
|
||||
lastCheck = now;
|
||||
try {
|
||||
if (!Files.isReadable(file)) {
|
||||
return;
|
||||
}
|
||||
if (Files.getLastModifiedTime(file).toMillis() != fileTimestamp) {
|
||||
reload();
|
||||
}
|
||||
} catch (IOException e) {
|
||||
LOG.debug("Не удалось проверить время изменения {}", file, e);
|
||||
}
|
||||
}
|
||||
|
||||
private static SystemPolicy toPolicy(String name, SystemEntry entry) {
|
||||
SystemPolicy base = SystemPolicy.DEFAULT;
|
||||
Set<String> types = entry.types() == null ? base.types() : new HashSet<>(entry.types());
|
||||
Set<String> companions = entry.requireCompanion() == null
|
||||
? base.requireCompanion() : new HashSet<>(entry.requireCompanion());
|
||||
MaskMode mode = entry.maskMode() == null
|
||||
? base.maskMode() : MaskMode.valueOf(entry.maskMode().toUpperCase(Locale.ROOT));
|
||||
return new SystemPolicy(name,
|
||||
entry.enabled() == null || entry.enabled(),
|
||||
entry.demask() == null || entry.demask(),
|
||||
mode, types, companions, entry.key());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
package ru.pdguard.core;
|
||||
|
||||
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, а квота), поэтому в контейнере с долей ядра оно
|
||||
* показывает все ядра хоста и как источник предела не годится. Задержка —
|
||||
* наблюдаемое следствие реальной доли CPU, а не догадка о её размере.
|
||||
*
|
||||
* <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>ponytail: счётчики окна суммируются без блокировки — гонка на границе
|
||||
* окна может добавить образец в уже подводимый итог или отбросить один,
|
||||
* не больше; при масштабах в десятки-сотни образцов на окно это не видно.
|
||||
* Нужен точный регулятор — взять готовую библиотеку вроде Netflix
|
||||
* {@code concurrency-limits} (Vegas/Gradient2); здесь она не взята из
|
||||
* осторожности к GraalVM native-image: незнакомая рефлексия в чужой
|
||||
* библиотеке — это ровно тот класс проблем, из-за которого модели второй
|
||||
* ступени понадобилась отдельная настройка сборки.
|
||||
*/
|
||||
public class AdaptiveConcurrencyLimiter {
|
||||
|
||||
private static final long DEFAULT_ADJUST_WINDOW_NANOS = TimeUnit.MILLISECONDS.toNanos(20);
|
||||
|
||||
private final AtomicInteger inFlight = new AtomicInteger();
|
||||
private final AtomicLong windowSumNanos = new AtomicLong();
|
||||
private final AtomicInteger windowSamples = new AtomicInteger();
|
||||
private final AtomicLong lastAdjustNanos;
|
||||
private final int minLimit;
|
||||
private final int maxLimit;
|
||||
private final long targetLatencyNanos;
|
||||
private final long adjustWindowNanos;
|
||||
private final AtomicInteger limit;
|
||||
|
||||
public AdaptiveConcurrencyLimiter(int minLimit, int maxLimit, long targetLatencyMillis) {
|
||||
this(minLimit, maxLimit, targetLatencyMillis, DEFAULT_ADJUST_WINDOW_NANOS);
|
||||
}
|
||||
|
||||
/** Настраиваемое окно регулировки — для тестов, которым реальные 20мс на шаг не подходят. */
|
||||
AdaptiveConcurrencyLimiter(int minLimit, int maxLimit, long targetLatencyMillis, long adjustWindowNanos) {
|
||||
if (minLimit < 1 || maxLimit < minLimit) {
|
||||
throw new IllegalArgumentException("Некорректные границы предела: " + minLimit + ".." + maxLimit);
|
||||
}
|
||||
this.minLimit = minLimit;
|
||||
this.maxLimit = maxLimit;
|
||||
this.targetLatencyNanos = TimeUnit.MILLISECONDS.toNanos(targetLatencyMillis);
|
||||
this.adjustWindowNanos = adjustWindowNanos;
|
||||
this.limit = new AtomicInteger(minLimit);
|
||||
this.lastAdjustNanos = new AtomicLong(System.nanoTime());
|
||||
}
|
||||
|
||||
/** {@code true} — запрос принят; вызывающая сторона обязана вызвать {@link #release}. */
|
||||
public boolean tryAcquire() {
|
||||
if (inFlight.incrementAndGet() > limit.get()) {
|
||||
inFlight.decrementAndGet();
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Освобождает слот; предел подстраивается не чаще раза в окно, а не на каждый вызов. */
|
||||
public void release(long elapsedNanos) {
|
||||
inFlight.decrementAndGet();
|
||||
windowSumNanos.addAndGet(elapsedNanos);
|
||||
windowSamples.incrementAndGet();
|
||||
|
||||
long now = System.nanoTime();
|
||||
long last = lastAdjustNanos.get();
|
||||
if (now - last >= adjustWindowNanos && lastAdjustNanos.compareAndSet(last, now)) {
|
||||
adjust();
|
||||
}
|
||||
}
|
||||
|
||||
private void adjust() {
|
||||
int samples = windowSamples.getAndSet(0);
|
||||
long sum = windowSumNanos.getAndSet(0);
|
||||
if (samples == 0) {
|
||||
return;
|
||||
}
|
||||
long avg = sum / samples;
|
||||
|
||||
if (avg < targetLatencyNanos) {
|
||||
limit.set(Math.min(maxLimit, limit.get() + 1));
|
||||
} else {
|
||||
limit.set(Math.max(minLimit, limit.get() / 2));
|
||||
}
|
||||
}
|
||||
|
||||
/** Сколько запросов обрабатывается прямо сейчас — для наблюдения. */
|
||||
public int inFlight() {
|
||||
return inFlight.get();
|
||||
}
|
||||
|
||||
/** Текущий предел — для метрики, чтобы деградацию было видно, а не только чувствовать по 429. */
|
||||
public int limit() {
|
||||
return limit.get();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
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.net.URI;
|
||||
import java.net.http.HttpClient;
|
||||
import java.net.http.HttpRequest;
|
||||
import java.net.http.HttpResponse;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.time.Duration;
|
||||
import java.util.Optional;
|
||||
|
||||
/**
|
||||
* Обращение к языковой модели для демонстрационного плеча.
|
||||
*
|
||||
* <p>Адрес не задан — работает заглушка: она возвращает присланный текст обратно.
|
||||
* Для демонстрации этого достаточно, потому что проверяется не качество ответа
|
||||
* модели, а то, что в модель ушёл замаскированный текст, а потребителю вернулся
|
||||
* восстановленный.
|
||||
*
|
||||
* <p>Модель недоступна или ответила ошибкой — плечо деградирует до той же заглушки,
|
||||
* а причина попадает в ответ и в журнал. Ронять запрос из-за внешнего сервиса нельзя.
|
||||
*/
|
||||
@Component
|
||||
public class LlmClient {
|
||||
|
||||
private static final Logger LOG = LoggerFactory.getLogger(LlmClient.class);
|
||||
|
||||
/** Что вернула модель и кто именно ответил. */
|
||||
public record Answer(String text, String source) {
|
||||
}
|
||||
|
||||
private final Optional<String> url;
|
||||
private final Optional<String> apiKey;
|
||||
private final String model;
|
||||
private final Duration timeout;
|
||||
private final HttpClient http;
|
||||
private final ObjectMapper mapper = new ObjectMapper();
|
||||
|
||||
public LlmClient(
|
||||
@Value("${pdguard.llm.url:}") String url,
|
||||
@Value("${pdguard.llm.api-key:}") String apiKey,
|
||||
@Value("${pdguard.llm.model:gpt-4o-mini}") String model,
|
||||
@Value("${pdguard.llm.timeout-seconds:20}") int timeoutSeconds) {
|
||||
this.url = Optional.ofNullable(url).filter(value -> !value.isBlank());
|
||||
this.apiKey = Optional.ofNullable(apiKey).filter(value -> !value.isBlank());
|
||||
this.model = model;
|
||||
this.timeout = Duration.ofSeconds(timeoutSeconds);
|
||||
this.http = HttpClient.newBuilder().connectTimeout(Duration.ofSeconds(5)).build();
|
||||
if (this.url.isEmpty()) {
|
||||
LOG.info("Адрес языковой модели не задан, плечо работает на заглушке");
|
||||
}
|
||||
}
|
||||
|
||||
public Answer ask(String maskedPrompt) {
|
||||
if (url.isEmpty()) {
|
||||
return new Answer(stub(maskedPrompt), "заглушка");
|
||||
}
|
||||
try {
|
||||
return new Answer(call(maskedPrompt), model);
|
||||
} catch (Exception e) {
|
||||
LOG.error("Языковая модель недоступна, плечо ответило заглушкой", e);
|
||||
return new Answer(stub(maskedPrompt), "заглушка: модель недоступна");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Ответ содержит присланный текст целиком: так на демонстрации видно, что
|
||||
* подстановки вернулись на свои места при обратном преобразовании.
|
||||
*/
|
||||
private static String stub(String maskedPrompt) {
|
||||
return "Ответ по запросу: " + maskedPrompt;
|
||||
}
|
||||
|
||||
private String call(String maskedPrompt) throws Exception {
|
||||
ObjectNode body = mapper.createObjectNode();
|
||||
body.put("model", model);
|
||||
ObjectNode message = body.putArray("messages").addObject();
|
||||
message.put("role", "user");
|
||||
message.put("content", maskedPrompt);
|
||||
|
||||
HttpRequest.Builder request = HttpRequest.newBuilder(URI.create(url.get()))
|
||||
.timeout(timeout)
|
||||
.header("Content-Type", "application/json")
|
||||
.POST(HttpRequest.BodyPublishers.ofString(
|
||||
mapper.writeValueAsString(body), StandardCharsets.UTF_8));
|
||||
apiKey.ifPresent(key -> request.header("Authorization", "Bearer " + key));
|
||||
|
||||
HttpResponse<String> response = http.send(request.build(),
|
||||
HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8));
|
||||
if (response.statusCode() / 100 != 2) {
|
||||
throw new IllegalStateException("модель ответила " + response.statusCode());
|
||||
}
|
||||
return mapper.readTree(response.body())
|
||||
.path("choices").path(0).path("message").path("content").asText();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
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 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 создаёт их сам и
|
||||
* заметно больше, а каждая корзина — это отдельный временной ряд на каждое
|
||||
* сочетание меток.
|
||||
*/
|
||||
@Configuration
|
||||
public class MetricsConfiguration {
|
||||
|
||||
/** Целевая задержка из критериев оценки: ориентир, а не жёсткий предел. */
|
||||
private static final Duration SLA_TARGET = Duration.ofMillis(500);
|
||||
|
||||
private static final Duration[] BOUNDARIES = {
|
||||
Duration.ofNanos(250_000), Duration.ofMillis(1), Duration.ofMillis(5),
|
||||
Duration.ofMillis(10), Duration.ofMillis(25), Duration.ofMillis(50),
|
||||
Duration.ofMillis(100), Duration.ofMillis(250), SLA_TARGET,
|
||||
Duration.ofSeconds(1), Duration.ofSeconds(2), Duration.ofSeconds(5),
|
||||
Duration.ofSeconds(10)
|
||||
};
|
||||
|
||||
@Bean
|
||||
public MeterFilter histogramsForTimers() {
|
||||
return new MeterFilter() {
|
||||
@Override
|
||||
public DistributionStatisticConfig configure(Meter.Id id, DistributionStatisticConfig config) {
|
||||
if (!needsHistogram(id.getName())) {
|
||||
return config;
|
||||
}
|
||||
double[] boundaries = new double[BOUNDARIES.length];
|
||||
for (int i = 0; i < BOUNDARIES.length; i++) {
|
||||
boundaries[i] = BOUNDARIES[i].toNanos();
|
||||
}
|
||||
return DistributionStatisticConfig.builder()
|
||||
.percentilesHistogram(false)
|
||||
.serviceLevelObjectives(boundaries)
|
||||
.build()
|
||||
.merge(config);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
private static boolean needsHistogram(String name) {
|
||||
return "pdguard.process".equals(name)
|
||||
|| "pdguard.ner.duration".equals(name)
|
||||
|| "http.server.requests".equals(name);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,197 @@
|
||||
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;
|
||||
import java.util.HexFormat;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.util.concurrent.ConcurrentLinkedQueue;
|
||||
import java.util.concurrent.atomic.AtomicLong;
|
||||
|
||||
/**
|
||||
* Соответствие «исходный текст ↔ маска», по которому выполняется демаскирование.
|
||||
*
|
||||
* <p>Два индекса: по {@code payload_id} — основной путь, и по отпечатку маски —
|
||||
* страховка на случай, если идентификатор до сервиса не доехал.
|
||||
*
|
||||
* <p>Оба индекса разделены по системам-потребителям. Индекс по отпечатку ищет
|
||||
* совпадение по самому тексту запроса, и без такого разделения он превращался бы в
|
||||
* способ достать чужие данные: маски детерминированы и низкоэнтропийны, поэтому,
|
||||
* прислав «Клиент И. И. И., паспорт 45** ****56», можно было бы получить в ответ
|
||||
* исходные значения из запроса другого потребителя. Разделение ограничивает это
|
||||
* пределами одной системы, которая и так видит свои данные.
|
||||
*
|
||||
* <p>Хранилище ограничено по суммарному объёму строк, а записи живут ограниченное
|
||||
* время: персональные данные не должны залёживаться в памяти, а крупные тексты не
|
||||
* должны исчерпать кучу. Вытеснение идёт в порядке добавления и выполняется прямо
|
||||
* на записи — отдельного потока и внешней библиотеки кеширования не требуется.
|
||||
*
|
||||
* <p>Когда включён общий слой ({@link SharedIndex}), соответствие пишется ещё и туда,
|
||||
* а чтение при промахе по локальной памяти идёт в него. Это нужно при работе на
|
||||
* нескольких узлах: обратный запрос легко попадает не на тот узел, который выполнял
|
||||
* прямой. Локальная память при этом остаётся первым уровнем, и обычный путь
|
||||
* обходится без обращения по сети.
|
||||
*/
|
||||
@Component
|
||||
public class PayloadStore {
|
||||
|
||||
/** Сколько протухших записей просматривается за одну операцию записи. */
|
||||
private static final int SWEEP_PER_PUT = 4;
|
||||
|
||||
/** Пара «исходный текст — маска» с отпечатком, владельцем и сроком жизни. */
|
||||
public record Entry(String system, String original, String masked,
|
||||
String fingerprint, long expiresAt) {
|
||||
|
||||
boolean alive(long now) {
|
||||
return now < expiresAt;
|
||||
}
|
||||
|
||||
int weight() {
|
||||
return original.length() + masked.length();
|
||||
}
|
||||
}
|
||||
|
||||
private final Map<String, Entry> byId = new ConcurrentHashMap<>();
|
||||
private final Map<String, Entry> byMaskFingerprint = new ConcurrentHashMap<>();
|
||||
private final ConcurrentLinkedQueue<String> insertionOrder = new ConcurrentLinkedQueue<>();
|
||||
private final AtomicLong charsHeld = new AtomicLong();
|
||||
|
||||
private final long maxChars;
|
||||
private final long ttlMillis;
|
||||
private final SharedIndex shared;
|
||||
|
||||
@Autowired
|
||||
public PayloadStore(
|
||||
@Value("${pdguard.store.max-chars:134217728}") long maxChars,
|
||||
@Value("${pdguard.store.ttl-minutes:30}") int ttlMinutes,
|
||||
SharedIndex shared) {
|
||||
this.maxChars = maxChars;
|
||||
this.ttlMillis = ttlMinutes * 60_000L;
|
||||
this.shared = shared;
|
||||
}
|
||||
|
||||
/** Конструктор для тестов: только локальная память, общий слой выключен. */
|
||||
public PayloadStore(long maxChars, int ttlMinutes) {
|
||||
this(maxChars, ttlMinutes, SharedIndex.disabled());
|
||||
}
|
||||
|
||||
public void put(String system, String payloadId, String original, String masked) {
|
||||
long now = System.currentTimeMillis();
|
||||
Entry entry = new Entry(system, original, masked, fingerprint(masked), now + ttlMillis);
|
||||
String idKey = scoped(system, payloadId);
|
||||
|
||||
Entry replaced = byId.put(idKey, entry);
|
||||
byMaskFingerprint.put(scoped(system, entry.fingerprint()), entry);
|
||||
insertionOrder.add(idKey);
|
||||
charsHeld.addAndGet((long) entry.weight() - (replaced == null ? 0 : replaced.weight()));
|
||||
|
||||
sweepExpired(now);
|
||||
evictWhileOverLimit();
|
||||
|
||||
shared.put(system, payloadId, original, masked, entry.fingerprint());
|
||||
}
|
||||
|
||||
public Entry byId(String system, String payloadId) {
|
||||
String idKey = scoped(system, payloadId);
|
||||
Entry entry = byId.get(idKey);
|
||||
if (entry != null && entry.alive(System.currentTimeMillis())) {
|
||||
return entry;
|
||||
}
|
||||
if (entry != null) {
|
||||
forget(idKey, entry);
|
||||
}
|
||||
SharedIndex.SharedEntry fromShared = shared.byId(system, payloadId);
|
||||
if (fromShared == null) {
|
||||
return null;
|
||||
}
|
||||
// Соседний узел уже выполнял прямой шаг: забираем соответствие к себе,
|
||||
// чтобы повторное обращение обошлось без сети.
|
||||
put(system, payloadId, fromShared.original(), fromShared.masked());
|
||||
return byId.get(idKey);
|
||||
}
|
||||
|
||||
/**
|
||||
* Исходный текст по самой маске — когда {@code payload_id} не совпал. Поиск идёт
|
||||
* только в пределах той же системы: чужую маску подобрать и обменять на исходные
|
||||
* данные нельзя.
|
||||
*/
|
||||
public String originalForMask(String system, String masked) {
|
||||
String fingerprint = fingerprint(masked);
|
||||
Entry entry = byMaskFingerprint.get(scoped(system, fingerprint));
|
||||
if (entry != null && entry.alive(System.currentTimeMillis())) {
|
||||
return entry.original();
|
||||
}
|
||||
return shared.originalForFingerprint(system, fingerprint);
|
||||
}
|
||||
|
||||
/** Сколько символов сейчас удерживается — для диагностики и тестов. */
|
||||
public long charsHeld() {
|
||||
return charsHeld.get();
|
||||
}
|
||||
|
||||
/**
|
||||
* Ключ, однозначно разделяющий системы. Длина имени в начале снимает вопрос о
|
||||
* разделителе: имя системы может содержать любые знаки.
|
||||
*/
|
||||
private static String scoped(String system, String key) {
|
||||
String owner = system == null ? "" : system;
|
||||
return owner.length() + ":" + owner + ":" + key;
|
||||
}
|
||||
|
||||
/** Убирает протухшие записи с головы очереди, не более нескольких за раз. */
|
||||
private void sweepExpired(long now) {
|
||||
for (int i = 0; i < SWEEP_PER_PUT; i++) {
|
||||
String oldest = insertionOrder.peek();
|
||||
if (oldest == null) {
|
||||
return;
|
||||
}
|
||||
Entry entry = byId.get(oldest);
|
||||
if (entry == null) {
|
||||
insertionOrder.poll();
|
||||
continue;
|
||||
}
|
||||
if (entry.alive(now)) {
|
||||
return;
|
||||
}
|
||||
insertionOrder.poll();
|
||||
forget(oldest, entry);
|
||||
}
|
||||
}
|
||||
|
||||
private void evictWhileOverLimit() {
|
||||
while (charsHeld.get() > maxChars) {
|
||||
String oldest = insertionOrder.poll();
|
||||
if (oldest == null) {
|
||||
return;
|
||||
}
|
||||
Entry entry = byId.get(oldest);
|
||||
if (entry != null) {
|
||||
// ponytail: если тот же payload_id записали повторно, в очереди остался
|
||||
// старый след и здесь вытесняется свежая запись. Цена — одно лишнее
|
||||
// обращение к маскированию; точный учёт потребовал бы двусвязного списка.
|
||||
forget(oldest, entry);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void forget(String idKey, Entry entry) {
|
||||
if (byId.remove(idKey, entry)) {
|
||||
byMaskFingerprint.remove(scoped(entry.system(), entry.fingerprint()), entry);
|
||||
charsHeld.addAndGet(-entry.weight());
|
||||
}
|
||||
}
|
||||
|
||||
private static String fingerprint(String value) {
|
||||
try {
|
||||
MessageDigest sha = MessageDigest.getInstance("SHA-256");
|
||||
return HexFormat.of().formatHex(sha.digest(value.getBytes(StandardCharsets.UTF_8)));
|
||||
} catch (NoSuchAlgorithmException e) {
|
||||
throw new IllegalStateException("SHA-256 недоступен в этой среде выполнения", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,291 @@
|
||||
package ru.pdguard.core;
|
||||
|
||||
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 org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.stereotype.Component;
|
||||
import ru.pdguard.config.SystemPolicy;
|
||||
import ru.pdguard.detect.NameCascade;
|
||||
import ru.pdguard.detect.NameDictionary;
|
||||
import ru.pdguard.detect.RuleRegistry;
|
||||
import ru.pdguard.detect.Span;
|
||||
import ru.pdguard.mask.MaskContext;
|
||||
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>
|
||||
* </ul>
|
||||
* Последний случай — повторная попытка проверяющей системы: ответ обязан
|
||||
* совпасть с первым, иначе демаскирование по этому элементу развалится.
|
||||
*/
|
||||
@Component
|
||||
public class Pipeline {
|
||||
|
||||
private static final Logger LOG = LoggerFactory.getLogger(Pipeline.class);
|
||||
|
||||
/** Грубая оценка числа токенов по числу символов — для метрики TPS. */
|
||||
private static final int CHARS_PER_TOKEN = 4;
|
||||
|
||||
private final RuleRegistry registry;
|
||||
private final Masker masker;
|
||||
private final PayloadStore store;
|
||||
private final MeterRegistry meters;
|
||||
private final NameCascade cascade;
|
||||
private final Counter tokensProcessed;
|
||||
|
||||
@Autowired
|
||||
public Pipeline(RuleRegistry registry, Masker masker, PayloadStore store, MeterRegistry meters,
|
||||
NameCascade cascade) {
|
||||
this.registry = registry;
|
||||
this.masker = masker;
|
||||
this.store = store;
|
||||
this.meters = meters;
|
||||
this.cascade = cascade;
|
||||
meters.gauge("pdguard.store.chars", store, PayloadStore::charsHeld);
|
||||
this.tokensProcessed = Counter.builder("pdguard.tokens.processed")
|
||||
.description("Оценка числа обработанных токенов, для расчёта TPS")
|
||||
.register(meters);
|
||||
}
|
||||
|
||||
/** Конструктор для тестов: метрики никуда не отдаются, вторая ступень выключена. */
|
||||
public Pipeline(RuleRegistry registry, Masker masker, PayloadStore store) {
|
||||
this(registry, masker, store, new SimpleMeterRegistry(), NameCascade.disabled());
|
||||
}
|
||||
|
||||
/** Конструктор для тестов второй ступени. */
|
||||
public Pipeline(RuleRegistry registry, Masker masker, PayloadStore store, NameCascade cascade) {
|
||||
this(registry, masker, store, new SimpleMeterRegistry(), cascade);
|
||||
}
|
||||
|
||||
public String process(String payload, String payloadId, SystemPolicy policy) {
|
||||
long started = System.nanoTime();
|
||||
tokensProcessed.increment((double) payload.length() / CHARS_PER_TOKEN);
|
||||
|
||||
PayloadStore.Entry known = store.byId(policy.name(), payloadId);
|
||||
if (known != null) {
|
||||
if (policy.demask() && payload.equals(known.masked())) {
|
||||
LOG.debug("payload_id={} обратное преобразование по идентификатору", payloadId);
|
||||
recordLatency("unmask", policy.name(), started);
|
||||
return known.original();
|
||||
}
|
||||
if (payload.equals(known.original())) {
|
||||
LOG.debug("payload_id={} повторная попытка, отдаём прежнюю маску", payloadId);
|
||||
recordLatency("mask", policy.name(), started);
|
||||
return known.masked();
|
||||
}
|
||||
}
|
||||
if (policy.demask()) {
|
||||
String original = store.originalForMask(policy.name(), payload);
|
||||
if (original != null) {
|
||||
LOG.debug("payload_id={} обратное преобразование по отпечатку маски", payloadId);
|
||||
recordLatency("unmask", policy.name(), started);
|
||||
return original;
|
||||
}
|
||||
}
|
||||
return mask(payload, payloadId, policy, started);
|
||||
}
|
||||
|
||||
/**
|
||||
* Длительность обработки с разрезом по направлению и системе-потребителю.
|
||||
* Метрики берутся из реестра по тегам: систем немного и они заданы настройками,
|
||||
* поэтому разрастания рядов не будет, а разрез по потребителям виден сразу.
|
||||
*/
|
||||
private void recordLatency(String direction, String system, long startedNanos) {
|
||||
Timer.builder("pdguard.process")
|
||||
.description("Длительность обработки обращения")
|
||||
.tag("direction", direction)
|
||||
.tag("system", system)
|
||||
.register(meters)
|
||||
.record(System.nanoTime() - startedNanos, TimeUnit.NANOSECONDS);
|
||||
}
|
||||
|
||||
/**
|
||||
* Фрагменты, которые будут замаскированы: поиск по правилам, разрешение
|
||||
* перекрытий и все отсечения. Отдельный метод нужен, чтобы качество детекции
|
||||
* можно было измерить, не разбирая замаскированный текст обратно.
|
||||
*/
|
||||
public List<Span> findPersonalData(String text, SystemPolicy policy) {
|
||||
List<Span> spans = resolveOverlaps(registry.detect(text, policy));
|
||||
if (policy.allows(RuleRegistry.FIO)) {
|
||||
// Вторая ступень разбирает только то, что не покрыла первая.
|
||||
spans = resolveOverlaps(cascade.addMissedNames(text, spans));
|
||||
}
|
||||
spans = dropOrganisationNames(text, spans);
|
||||
spans = dropWellKnownNames(text, spans);
|
||||
return dropLonelyCompanions(spans, policy);
|
||||
}
|
||||
|
||||
private String mask(String payload, String payloadId, SystemPolicy policy, long started) {
|
||||
List<Span> spans = findPersonalData(payload, policy);
|
||||
String masked = apply(payload, spans, policy);
|
||||
store.put(policy.name(), payloadId, payload, masked);
|
||||
|
||||
recordLatency("mask", policy.name(), started);
|
||||
logFindings(policy.name(), payloadId, payload.length(), spans);
|
||||
return masked;
|
||||
}
|
||||
|
||||
/**
|
||||
* Оставляет непересекающиеся фрагменты: при конфликте побеждает более
|
||||
* приоритетный, при равном приоритете — более длинный.
|
||||
*/
|
||||
static List<Span> resolveOverlaps(List<Span> spans) {
|
||||
List<Span> candidates = new ArrayList<>(spans);
|
||||
candidates.sort(Comparator.comparingInt(Span::priority).reversed()
|
||||
.thenComparing(Comparator.comparingInt(Span::length).reversed())
|
||||
.thenComparingInt(Span::start));
|
||||
|
||||
// Принятые фрагменты не пересекаются и упорядочены по началу, поэтому
|
||||
// кандидату достаточно сверить себя с ближайшим слева и ближайшим справа.
|
||||
// Перебор всех принятых давал бы квадрат: на тексте в сотню тысяч токенов
|
||||
// фрагментов набираются тысячи.
|
||||
NavigableMap<Integer, Span> accepted = new TreeMap<>();
|
||||
for (Span candidate : candidates) {
|
||||
if (overlapsAccepted(accepted, candidate)) {
|
||||
continue;
|
||||
}
|
||||
accepted.put(candidate.start(), candidate);
|
||||
}
|
||||
return List.copyOf(accepted.values());
|
||||
}
|
||||
|
||||
/** Проверяет, пересекается ли кандидат с ближайшим принятым слева или справа. */
|
||||
private static boolean overlapsAccepted(NavigableMap<Integer, Span> accepted, Span candidate) {
|
||||
Map.Entry<Integer, Span> before = accepted.floorEntry(candidate.start());
|
||||
if (before != null && before.getValue().overlaps(candidate)) {
|
||||
return true;
|
||||
}
|
||||
Map.Entry<Integer, Span> after = accepted.ceilingEntry(candidate.start());
|
||||
return after != null && after.getValue().overlaps(candidate);
|
||||
}
|
||||
|
||||
/**
|
||||
* Убирает имена, стоящие в названиях организаций и объектов на карте:
|
||||
* «Институт Склифосовского», «Музей Тропинина», «улица Королёва». Проверка
|
||||
* не зависит от того, есть ли в тексте другие ПД: слово перед именем решает
|
||||
* само по себе.
|
||||
*/
|
||||
static List<Span> dropOrganisationNames(String text, List<Span> spans) {
|
||||
return spans.stream()
|
||||
.filter(span -> !RuleRegistry.FIO.equals(span.type())
|
||||
|| !NameDictionary.precededByOrganisation(text, span.start()))
|
||||
.toList();
|
||||
}
|
||||
|
||||
/**
|
||||
* Убирает имена известных людей: «стихи Александра Пушкина» персональными
|
||||
* данными не являются. Если же в тексте есть ПД другого типа, речь идёт о
|
||||
* конкретном человеке, и имя остаётся замаскированным — однофамилец
|
||||
* исторической фигуры защиту не теряет.
|
||||
*/
|
||||
static List<Span> dropWellKnownNames(String text, List<Span> spans) {
|
||||
boolean otherPersonalDataPresent = spans.stream()
|
||||
.anyMatch(span -> !RuleRegistry.FIO.equals(span.type()));
|
||||
if (otherPersonalDataPresent) {
|
||||
return spans;
|
||||
}
|
||||
return spans.stream()
|
||||
.filter(span -> !RuleRegistry.FIO.equals(span.type())
|
||||
|| !NameDictionary.isWellKnown(text.substring(span.start(), span.end())))
|
||||
.toList();
|
||||
}
|
||||
|
||||
/**
|
||||
* Убирает типы, которые опасны только в сочетании с другими ПД.
|
||||
* Пин-код в отрыве от номера карты не является персональными данными,
|
||||
* рядом с номером карты — является.
|
||||
*
|
||||
* <p>Спутником считается только находка самостоятельного типа. Раньше здесь
|
||||
* сравнивалось число различных типов, и два спутника заверяли друг друга:
|
||||
* «Оплата 01.02.2025, ОГРН 1027700132195» маскировалась целиком, хотя человека
|
||||
* в тексте нет, а дата и ОГРН по отдельности персональными данными не являются.
|
||||
* Сочетание двух несамостоятельных типов самостоятельным не становится.
|
||||
*/
|
||||
static List<Span> dropLonelyCompanions(List<Span> spans, SystemPolicy policy) {
|
||||
for (Span span : spans) {
|
||||
if (!policy.needsCompanion(span.type())) {
|
||||
return spans;
|
||||
}
|
||||
}
|
||||
// Дошли сюда — самостоятельных находок нет, а значит все оставшиеся спутники одиноки.
|
||||
return List.of();
|
||||
}
|
||||
|
||||
/** Замаскированный текст вместе с таблицей обратной замены. */
|
||||
public record Masked(String text, java.util.Map<String, String> restorations) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Маскирует текст и отдаёт таблицу обратной замены.
|
||||
*
|
||||
* <p>Нужно для прокси к языковой модели: ответ модели — другой текст, и восстановить
|
||||
* его целиком по идентификатору нельзя, замену приходится делать пофрагментно.
|
||||
* Звёздочки для этого не годятся — одна и та же маска может отвечать разным
|
||||
* значениям, — поэтому режим замены здесь всегда обратимый.
|
||||
*/
|
||||
public Masked maskWithRestorations(String text, SystemPolicy policy) {
|
||||
SystemPolicy reversible = new SystemPolicy(policy.name(), policy.enabled(), policy.demask(),
|
||||
ru.pdguard.mask.MaskMode.TOKEN, policy.types(), policy.requireCompanion(), policy.key());
|
||||
List<Span> spans = findPersonalData(text, reversible);
|
||||
if (spans.isEmpty()) {
|
||||
return new Masked(text, java.util.Map.of());
|
||||
}
|
||||
MaskContext context = new MaskContext();
|
||||
String masked = apply(text, spans, reversible, context);
|
||||
logFindings(policy.name(), "proxy", text.length(), spans);
|
||||
return new Masked(masked, context.restorations());
|
||||
}
|
||||
|
||||
private String apply(String text, List<Span> spans, SystemPolicy policy) {
|
||||
return apply(text, spans, policy, new MaskContext());
|
||||
}
|
||||
|
||||
private String apply(String text, List<Span> spans, SystemPolicy policy, MaskContext context) {
|
||||
if (spans.isEmpty()) {
|
||||
return text;
|
||||
}
|
||||
StringBuilder sb = new StringBuilder(text.length());
|
||||
int cursor = 0;
|
||||
for (Span span : spans) {
|
||||
sb.append(text, cursor, span.start());
|
||||
String value = text.substring(span.start(), span.end());
|
||||
sb.append(masker.mask(span.type(), value, policy.maskMode(), context));
|
||||
cursor = span.end();
|
||||
}
|
||||
sb.append(text, cursor, text.length());
|
||||
return sb.toString();
|
||||
}
|
||||
|
||||
/**
|
||||
* В журнал и в метрики попадают только идентификатор, типы ПД и их количество.
|
||||
* Сами значения не логируются ни на одном уровне.
|
||||
*/
|
||||
private void logFindings(String system, String payloadId, int length, List<Span> spans) {
|
||||
Map<String, Integer> counts = new LinkedHashMap<>();
|
||||
for (Span span : spans) {
|
||||
counts.merge(span.type(), 1, Integer::sum);
|
||||
}
|
||||
counts.forEach((type, count) ->
|
||||
meters.counter("pdguard.pd.detected", "type", type, "system", system).increment(count));
|
||||
LOG.info("payload_id={} символов={} найдено={}", payloadId, length, counts);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
package ru.pdguard.core;
|
||||
|
||||
import jakarta.annotation.PostConstruct;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.stereotype.Component;
|
||||
import ru.pdguard.config.SystemPolicy;
|
||||
import ru.pdguard.detect.NameCascade;
|
||||
import ru.pdguard.detect.RuleRegistry;
|
||||
import ru.pdguard.mask.MaskMode;
|
||||
import ru.pdguard.mask.Masker;
|
||||
|
||||
import java.util.Set;
|
||||
|
||||
/**
|
||||
* Прогон обработки на старте, чтобы первые запросы не попадали на непрогретый код.
|
||||
*
|
||||
* <p>На JVM разница измерима: без прогрева первые десятки секунд нагрузки идут по
|
||||
* интерпретируемому и наспех скомпилированному коду, и p95 оказывается примерно
|
||||
* вдесятеро хуже установившегося. Несколько тысяч прогонов на старте занимают доли
|
||||
* секунды и переводят горячий путь на оптимизирующий компилятор до того, как придут
|
||||
* настоящие запросы.
|
||||
*
|
||||
* <p>Прогрев идёт через отдельный экземпляр обработки со своим короткоживущим
|
||||
* хранилищем: настоящие соответствия «текст ↔ маска» замусорить нельзя.
|
||||
*
|
||||
* <p>Вторая ступень при прогреве выключена, и не только ради времени: её счётчики
|
||||
* показывают долю запросов, дошедших до модели, а тысячи служебных прогонов эту
|
||||
* долю исказили бы до неузнаваемости. Сама модель прогревается отдельно, при
|
||||
* создании своего пула.
|
||||
*/
|
||||
@Component
|
||||
public class PipelineWarmup {
|
||||
|
||||
private static final Logger LOG = LoggerFactory.getLogger(PipelineWarmup.class);
|
||||
|
||||
/** Тексты подобраны так, чтобы задеть основные семейства правил. */
|
||||
private static final String[] SAMPLES = {
|
||||
"Клиент Иванов Иван Иванович, паспорт 4509 123456, тел +7 916 123-45-67",
|
||||
"Заявление от И.И. Петрова, ИНН 770301234550, почта ivan.petrov@mail.ru",
|
||||
"Адрес: 125009, г. Москва, ул. Тверская, д. 7, кв. 15, карта 4111 1111 1111 1111",
|
||||
"Дата рождения 12.05.1985, место рождения: город Тверь, гражданство РФ",
|
||||
"Напиши краткое описание продукта для рассылки клиентам банка",
|
||||
};
|
||||
|
||||
private final RuleRegistry registry;
|
||||
private final Masker masker;
|
||||
private final int iterations;
|
||||
|
||||
public PipelineWarmup(RuleRegistry registry, Masker masker,
|
||||
@Value("${pdguard.warmup-iterations:2000}") int iterations) {
|
||||
this.registry = registry;
|
||||
this.masker = masker;
|
||||
this.iterations = iterations;
|
||||
}
|
||||
|
||||
@PostConstruct
|
||||
void warmup() {
|
||||
if (iterations <= 0) {
|
||||
LOG.info("Прогрев обработки отключён");
|
||||
return;
|
||||
}
|
||||
long started = System.nanoTime();
|
||||
Pipeline scratch = new Pipeline(registry, masker, new PayloadStore(1_000_000L, 1),
|
||||
NameCascade.disabled());
|
||||
SystemPolicy policy = new SystemPolicy(SystemPolicy.DEFAULT_NAME, true, true, MaskMode.MASK,
|
||||
Set.of(SystemPolicy.ALL), SystemPolicy.DEFAULT.requireCompanion(), null);
|
||||
|
||||
for (int i = 0; i < iterations; i++) {
|
||||
String text = SAMPLES[i % SAMPLES.length];
|
||||
String id = "warmup-" + i;
|
||||
String masked = scratch.process(text, id, policy);
|
||||
scratch.process(masked, id, policy);
|
||||
}
|
||||
LOG.info("Прогрев обработки: {} прогонов за {} мс",
|
||||
iterations, (System.nanoTime() - started) / 1_000_000);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
package ru.pdguard.core;
|
||||
|
||||
import com.fasterxml.jackson.core.JsonProcessingException;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
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>Включается настройкой {@code pdguard.store.backend=redis}. Пока она не
|
||||
* выставлена, к Redis не обращаются вовсе и зависимость остаётся неактивной.
|
||||
*
|
||||
* <p>Недоступность Redis не приводит к отказу: запись и чтение деградируют до
|
||||
* локальной памяти узла, а ошибка попадает в журнал. Чтобы простой Redis не
|
||||
* съедал время ответа, команды ограничены по времени настройкой
|
||||
* {@code spring.data.redis.timeout}, а после нескольких подряд неудач общий слой
|
||||
* временно перестают опрашивать вовсе.
|
||||
*/
|
||||
@Component
|
||||
public class SharedIndex {
|
||||
|
||||
private static final Logger LOG = LoggerFactory.getLogger(SharedIndex.class);
|
||||
|
||||
private static final String KEY_BY_ID = "pdg:id:";
|
||||
private static final String KEY_BY_MASK = "pdg:mask:";
|
||||
|
||||
/** Сколько подряд неудач размыкает предохранитель. */
|
||||
private static final int FAILURES_TO_OPEN = 3;
|
||||
|
||||
/** На сколько общий слой перестают опрашивать после размыкания. */
|
||||
private static final long OPEN_MILLIS = 5_000;
|
||||
|
||||
/** Пара «исходный текст — маска», как она хранится в общем слое. */
|
||||
public record SharedEntry(String original, String masked) {
|
||||
}
|
||||
|
||||
private final boolean enabled;
|
||||
private final Duration ttl;
|
||||
private final StringRedisTemplate redis;
|
||||
private final ObjectMapper mapper;
|
||||
|
||||
private final AtomicInteger consecutiveFailures = new AtomicInteger();
|
||||
private volatile long silentUntil;
|
||||
private volatile boolean reported;
|
||||
|
||||
public SharedIndex(StringRedisTemplate redis,
|
||||
@Value("${pdguard.store.backend:memory}") String backend,
|
||||
@Value("${pdguard.store.ttl-minutes:30}") int ttlMinutes,
|
||||
ObjectMapper mapper) {
|
||||
this.redis = redis;
|
||||
this.enabled = "redis".equalsIgnoreCase(backend);
|
||||
this.ttl = Duration.ofMinutes(ttlMinutes);
|
||||
this.mapper = mapper;
|
||||
}
|
||||
|
||||
/** Выключенный слой — для тестов и для сборки без Redis. */
|
||||
public static SharedIndex disabled() {
|
||||
return new SharedIndex(null, "memory", 30, new ObjectMapper());
|
||||
}
|
||||
|
||||
public boolean enabled() {
|
||||
return enabled;
|
||||
}
|
||||
|
||||
public void put(String system, String payloadId, String original, String masked,
|
||||
String maskFingerprint) {
|
||||
if (unavailable()) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
redis.opsForValue().set(KEY_BY_ID + scoped(system, payloadId),
|
||||
toJson(new SharedEntry(original, masked)), ttl);
|
||||
redis.opsForValue().set(KEY_BY_MASK + scoped(system, maskFingerprint), original, ttl);
|
||||
noteSuccess();
|
||||
} catch (RuntimeException e) {
|
||||
noteFailure("записать", e);
|
||||
}
|
||||
}
|
||||
|
||||
public SharedEntry byId(String system, String payloadId) {
|
||||
if (unavailable()) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
String json = redis.opsForValue().get(KEY_BY_ID + scoped(system, payloadId));
|
||||
noteSuccess();
|
||||
return json == null ? null : fromJson(json);
|
||||
} catch (RuntimeException e) {
|
||||
noteFailure("прочитать", e);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
public String originalForFingerprint(String system, String maskFingerprint) {
|
||||
if (unavailable()) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
String original = redis.opsForValue().get(KEY_BY_MASK + scoped(system, maskFingerprint));
|
||||
noteSuccess();
|
||||
return original;
|
||||
} catch (RuntimeException e) {
|
||||
noteFailure("прочитать", e);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private String toJson(SharedEntry entry) {
|
||||
try {
|
||||
return mapper.writeValueAsString(entry);
|
||||
} catch (JsonProcessingException e) {
|
||||
throw new IllegalStateException("Не удалось сериализовать соответствие", e);
|
||||
}
|
||||
}
|
||||
|
||||
private SharedEntry fromJson(String json) {
|
||||
try {
|
||||
return mapper.readValue(json, SharedEntry.class);
|
||||
} catch (JsonProcessingException e) {
|
||||
throw new IllegalStateException("Не удалось разобрать соответствие из общего слоя", e);
|
||||
}
|
||||
}
|
||||
|
||||
/** Ключ с разделением по системам: длина имени в начале снимает вопрос о разделителе. */
|
||||
private static String scoped(String system, String key) {
|
||||
String owner = system == null ? "" : system;
|
||||
return owner.length() + ":" + owner + ":" + key;
|
||||
}
|
||||
|
||||
/** Общий слой выключен или предохранитель разомкнут. */
|
||||
private boolean unavailable() {
|
||||
return !enabled || System.currentTimeMillis() < silentUntil;
|
||||
}
|
||||
|
||||
private void noteSuccess() {
|
||||
if (consecutiveFailures.getAndSet(0) != 0) {
|
||||
reported = false;
|
||||
LOG.info("Общий слой снова доступен");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* После нескольких неудач подряд общий слой перестают опрашивать на несколько
|
||||
* секунд: иначе каждый запрос платил бы таймаутом за недоступный Redis, а
|
||||
* проверяющая система считает ответ дольше десяти секунд неответом.
|
||||
*/
|
||||
private void noteFailure(String action, RuntimeException cause) {
|
||||
if (consecutiveFailures.incrementAndGet() >= FAILURES_TO_OPEN) {
|
||||
silentUntil = System.currentTimeMillis() + OPEN_MILLIS;
|
||||
}
|
||||
if (!reported) {
|
||||
reported = true;
|
||||
LOG.error("Не удалось {} соответствие в общий слой, узел работает на своей памяти", action, cause);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
package ru.pdguard.detect;
|
||||
|
||||
import java.util.Locale;
|
||||
|
||||
/**
|
||||
* Общий приём для словарей, сравнивающих слово из текста с основой из списка:
|
||||
* личные имена ({@link NameDictionary}) и города ({@link ToponymDictionary}).
|
||||
*
|
||||
* <p>Слова на согласную склоняются добавлением окончания («Тамбов» → «Тамбове»,
|
||||
* «Пушкин» → «Пушкина») — там основы из списка достаточно как есть. Слова на
|
||||
* гласную меняют последнюю букву («Москва» → «Москве», «Ольга» → «Ольге») —
|
||||
* для них сравнение идёт по основе без неё.
|
||||
*
|
||||
* <p>Фамилии на «-ский» склоняются как прилагательное: окончание меняется
|
||||
* целиком («Дзержинский» → «Дзержинского», «-ий» на «-ого», а не дописывается),
|
||||
* поэтому для них отсечения одной буквы недостаточно — основа обрезается сразу
|
||||
* до «ск». Для улиц в честь людей это не редкий случай, а основной: «улица
|
||||
* Дзержинского», «улица Островского» пишутся только в родительном падеже,
|
||||
* именительный там не встречается вообще.
|
||||
*/
|
||||
final class Declension {
|
||||
|
||||
/**
|
||||
* Падежные окончания прилагательного склонения на «-ск-»: мужской, женский
|
||||
* и средний род, все падежи. Проверяются от длинных к коротким — «-ского»
|
||||
* не должно потеряться из-за более короткого совпадения на «-ким» и т.п.
|
||||
*/
|
||||
private static final String[] ADJECTIVE_ENDINGS = {
|
||||
"ского", "скому", "ским", "ском", "скую", "ской", "скою", "ская", "ский"
|
||||
};
|
||||
|
||||
private Declension() {
|
||||
}
|
||||
|
||||
/**
|
||||
* Отбрасывает у основы окончание, которое меняется по падежам: гласную —
|
||||
* у обычных слов, целиком «-ск-»-окончание — у прилагательных фамилий.
|
||||
* Слова короче четырёх букв не трогает — короткая основа и так шире
|
||||
* большинства падежных форм.
|
||||
*/
|
||||
static String withoutInflectedEnding(String word) {
|
||||
String lower = word.toLowerCase(Locale.ROOT);
|
||||
for (String ending : ADJECTIVE_ENDINGS) {
|
||||
if (lower.length() > ending.length() && lower.endsWith(ending)) {
|
||||
return lower.substring(0, lower.length() - ending.length() + 2);
|
||||
}
|
||||
}
|
||||
if (lower.length() >= 4 && "аяйь".indexOf(lower.charAt(lower.length() - 1)) >= 0) {
|
||||
return lower.substring(0, lower.length() - 1);
|
||||
}
|
||||
return lower;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,231 @@
|
||||
package ru.pdguard.detect;
|
||||
|
||||
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 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.detect.Span;
|
||||
|
||||
import java.nio.file.Path;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Optional;
|
||||
import java.util.concurrent.Semaphore;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.regex.Matcher;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
/**
|
||||
* Вторая ступень распознавания.
|
||||
*
|
||||
* <p>Правила и словарь разбирают подавляющее большинство случаев и стоят десятки
|
||||
* микросекунд. Модель нужна там, где они бессильны: имена без русского
|
||||
* словообразования и нестандартные топонимы.
|
||||
*
|
||||
* <p>Поэтому модель зовут не на весь текст, а только на кандидатов — цепочки из
|
||||
* двух-трёх слов с заглавной буквы, которые первая ступень не покрыла. Их в обычном
|
||||
* запросе единицы, и на задержку это почти не влияет. Дороже модель — тем важнее
|
||||
* такая экономия: у BERT вызов стоит десятки миллисекунд, и звать его на каждый
|
||||
* запрос было бы невозможно.
|
||||
*
|
||||
* <p>Движок один — модель BERT, размечающая имена и составляющие адреса. Настройка
|
||||
* {@code pdguard.ner.engine} принимает {@code off} или {@code rubert}; ступень
|
||||
* выключена, пока движок не задан.
|
||||
*
|
||||
* <p>Ступень выключена, пока не задан движок. Сбой ступени на первую не влияет:
|
||||
* ошибка перехватывается здесь, ступень выключается насовсем, и дальше работают
|
||||
* правила. Иначе одно исключение обнуляло бы маскирование целиком.
|
||||
*/
|
||||
@Component
|
||||
public class NameCascade {
|
||||
|
||||
private static final Logger LOG = LoggerFactory.getLogger(NameCascade.class);
|
||||
|
||||
/** Имя метрики обращений ко второй ступени, её описание и имя метки исхода. */
|
||||
private static final String NER_REQUESTS_METRIC = "pdguard.ner.requests";
|
||||
private static final String NER_REQUESTS_DESCRIPTION = "Обращения, дошедшие до второй ступени";
|
||||
private static final String OUTCOME_TAG = "outcome";
|
||||
|
||||
/** Цепочка из двух-трёх слов с заглавной буквы — то, что может оказаться ПД. */
|
||||
private static final Pattern CANDIDATE = Pattern.compile(
|
||||
"\\p{Lu}[\\p{L}-]+(?:\\s+\\p{Lu}[\\p{L}-]+){1,2}",
|
||||
Pattern.UNICODE_CHARACTER_CLASS | Pattern.UNICODE_CASE);
|
||||
|
||||
/** Приоритет находок второй ступени: ниже правил, у которых больше оснований. */
|
||||
private static final int PRIORITY = 73;
|
||||
|
||||
/** Сколько знаков текста вокруг кандидата отдаётся модели как контекст. */
|
||||
private static final int CONTEXT_CHARS = 60;
|
||||
|
||||
private final RuBertRecogniser recogniser;
|
||||
private final Semaphore concurrent;
|
||||
private final int maxCandidates;
|
||||
private volatile boolean broken;
|
||||
|
||||
/**
|
||||
* Сколько обращений дошло до модели, а сколько обошлось правилами. Отношение
|
||||
* {@code engaged} ко всем обращениям и есть та доля, от которой зависит,
|
||||
* посильна ли тяжёлая модель на боевом трафике.
|
||||
*/
|
||||
private final Counter engaged;
|
||||
private final Counter withoutCandidates;
|
||||
private final Counter busy;
|
||||
private final Counter candidates;
|
||||
private final Timer duration;
|
||||
|
||||
@Autowired
|
||||
public NameCascade(
|
||||
@Value("${pdguard.ner.engine:off}") String engine,
|
||||
@Value("${pdguard.ner.model:}") String modelPath,
|
||||
@Value("${pdguard.ner.max-candidates:16}") int maxCandidates,
|
||||
@Value("${pdguard.ner.pool-size:16}") int poolSize,
|
||||
MeterRegistry meters) {
|
||||
this.maxCandidates = maxCandidates;
|
||||
this.recogniser = create(engine, Optional.ofNullable(modelPath).filter(p -> !p.isBlank()));
|
||||
this.concurrent = new Semaphore(Math.max(1, poolSize));
|
||||
this.engaged = Counter.builder(NER_REQUESTS_METRIC)
|
||||
.description(NER_REQUESTS_DESCRIPTION)
|
||||
.tag(OUTCOME_TAG, "engaged").register(meters);
|
||||
this.withoutCandidates = Counter.builder(NER_REQUESTS_METRIC)
|
||||
.description(NER_REQUESTS_DESCRIPTION)
|
||||
.tag(OUTCOME_TAG, "no_candidates").register(meters);
|
||||
this.busy = Counter.builder(NER_REQUESTS_METRIC)
|
||||
.description(NER_REQUESTS_DESCRIPTION)
|
||||
.tag(OUTCOME_TAG, "busy").register(meters);
|
||||
this.candidates = Counter.builder("pdguard.ner.candidates")
|
||||
.description("Участки текста, отданные модели").register(meters);
|
||||
this.duration = Timer.builder("pdguard.ner.duration")
|
||||
.description("Время работы второй ступени").register(meters);
|
||||
}
|
||||
|
||||
/** Конструктор для тестов: метрики никуда не отдаются. */
|
||||
public NameCascade(String engine, Optional<String> modelPath, int maxCandidates, int poolSize) {
|
||||
this(engine, modelPath.orElse(""), maxCandidates, poolSize, new SimpleMeterRegistry());
|
||||
}
|
||||
|
||||
/**
|
||||
* Выключенная ступень для служебных нужд — прогрева и тестов. Отдельный
|
||||
* конструктор, а не обычный путь: иначе в журнале рядом с сообщением о готовности
|
||||
* распознавателя появлялось бы сообщение о его выключении, и было бы непонятно,
|
||||
* что в итоге работает.
|
||||
*/
|
||||
private NameCascade() {
|
||||
this.maxCandidates = 0;
|
||||
this.recogniser = null;
|
||||
this.concurrent = new Semaphore(1);
|
||||
MeterRegistry meters = new SimpleMeterRegistry();
|
||||
this.engaged = meters.counter(NER_REQUESTS_METRIC, OUTCOME_TAG, "engaged");
|
||||
this.withoutCandidates = meters.counter(NER_REQUESTS_METRIC, OUTCOME_TAG, "no_candidates");
|
||||
this.busy = meters.counter(NER_REQUESTS_METRIC, OUTCOME_TAG, "busy");
|
||||
this.candidates = meters.counter("pdguard.ner.candidates");
|
||||
this.duration = Timer.builder("pdguard.ner.duration").register(meters);
|
||||
}
|
||||
|
||||
public static NameCascade disabled() {
|
||||
return new NameCascade();
|
||||
}
|
||||
|
||||
public boolean enabled() {
|
||||
return recogniser != null && !broken;
|
||||
}
|
||||
|
||||
/**
|
||||
* Добавляет ПД, которые не нашла первая ступень. Уже принятые фрагменты не
|
||||
* трогаются: модель разбирает только непокрытые участки.
|
||||
*/
|
||||
public List<Span> addMissedNames(String text, List<Span> accepted) {
|
||||
if (!enabled()) {
|
||||
return accepted;
|
||||
}
|
||||
if (!concurrent.tryAcquire()) {
|
||||
// Модель занята целиком: отвечаем по правилам, а не копим очередь.
|
||||
busy.increment();
|
||||
return accepted;
|
||||
}
|
||||
long started = System.nanoTime();
|
||||
try {
|
||||
List<Span> found = new ArrayList<>(accepted);
|
||||
int examined = 0;
|
||||
Matcher m = CANDIDATE.matcher(text);
|
||||
while (m.find() && examined < maxCandidates) {
|
||||
if (coveredBy(accepted, m.start(), m.end())) {
|
||||
continue;
|
||||
}
|
||||
examined++;
|
||||
collect(text, m.start(), m.end(), found);
|
||||
}
|
||||
candidates.increment(examined);
|
||||
(examined > 0 ? engaged : withoutCandidates).increment();
|
||||
duration.record(System.nanoTime() - started, TimeUnit.NANOSECONDS);
|
||||
return found;
|
||||
} catch (RuntimeException e) {
|
||||
broken = true;
|
||||
LOG.error("Вторая ступень отключена из-за сбоя, распознавание продолжается по правилам", e);
|
||||
return accepted;
|
||||
} finally {
|
||||
concurrent.release();
|
||||
}
|
||||
}
|
||||
|
||||
private void collect(String text, int candidateStart, int candidateEnd, List<Span> sink) {
|
||||
int from = Math.max(0, candidateStart - CONTEXT_CHARS);
|
||||
int to = Math.min(text.length(), candidateEnd + CONTEXT_CHARS);
|
||||
for (Span span : recogniser.recognise(text, from, to, PRIORITY)) {
|
||||
if (isAccepted(text, candidateStart, candidateEnd, span)) {
|
||||
sink.add(span);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Принимает находку модели, если она пересекается с кандидатом и проходит
|
||||
* те же условия, что и находки правил.
|
||||
*/
|
||||
private static boolean isAccepted(String text, int candidateStart, int candidateEnd, Span span) {
|
||||
// Берём только пересекающееся с кандидатом: контекст добавлен ради
|
||||
// качества разбора, а не для расширения находки.
|
||||
if (span.start() >= candidateEnd || candidateStart >= span.end()) {
|
||||
return false;
|
||||
}
|
||||
// Адресные типы принимаются на тех же условиях, что и от правил: рядом
|
||||
// должны быть другие части адреса. Иначе «Спартак Москва» и «Проспект
|
||||
// Вернадского» попадали бы под маску наравне с адресом клиента.
|
||||
return !RuleRegistry.isAddressType(span.type())
|
||||
|| RuleRegistry.hasAddressContext(text, span.start(), span.end());
|
||||
}
|
||||
|
||||
private static boolean coveredBy(List<Span> accepted, int start, int end) {
|
||||
return accepted.stream().anyMatch(span -> span.start() < end && start < span.end());
|
||||
}
|
||||
|
||||
private static RuBertRecogniser create(String engine, Optional<String> modelPath) {
|
||||
String chosen = engine == null ? "off" : engine.toLowerCase(Locale.ROOT).strip();
|
||||
if ("off".equals(chosen) || modelPath.isEmpty() || modelPath.get().isBlank()) {
|
||||
LOG.info("Вторая ступень распознавания выключена");
|
||||
return null;
|
||||
}
|
||||
if (!"rubert".equals(chosen)) {
|
||||
LOG.warn("Неизвестный движок второй ступени: {}, ступень выключена", chosen);
|
||||
return null;
|
||||
}
|
||||
RuBertRecogniser created = RuBertRecogniser.load(Path.of(modelPath.get()), 1);
|
||||
if (created == null) {
|
||||
LOG.info("Вторая ступень распознавания выключена: распознаватель не создан");
|
||||
}
|
||||
return created;
|
||||
}
|
||||
|
||||
@PreDestroy
|
||||
void shutdown() {
|
||||
if (recogniser != null) {
|
||||
recogniser.close();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,272 @@
|
||||
package ru.pdguard.detect;
|
||||
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
import java.io.BufferedReader;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.io.InputStreamReader;
|
||||
import java.io.UncheckedIOException;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.Comparator;
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Set;
|
||||
import java.util.concurrent.atomic.AtomicReference;
|
||||
import java.util.regex.Pattern;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
/**
|
||||
* Словари для распознавания ФИО.
|
||||
*
|
||||
* <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}: раз в секунду сверяется время
|
||||
* изменения, содержимое читается заново только когда оно другое.
|
||||
*/
|
||||
public final class NameDictionary {
|
||||
|
||||
private static final Logger LOG = LoggerFactory.getLogger(NameDictionary.class);
|
||||
private static final long RECHECK_MILLIS = 1000;
|
||||
|
||||
private static final List<String> GIVEN_NAME_STEMS = load("/names/given-names.txt").stream()
|
||||
.map(Declension::withoutInflectedEnding)
|
||||
.distinct()
|
||||
.sorted(Comparator.comparingInt(String::length).reversed())
|
||||
.toList();
|
||||
// Гласная в конце основы отбрасывается: «Набиуллина» родительный/дательный/
|
||||
// творительный падежи образует заменой «-а» на «-ой» («Набиуллиной»), а не
|
||||
// дописыванием — без отсечения «а» их startsWith не поймает. Тот же приём,
|
||||
// что и для личных имён.
|
||||
private static final Set<String> BUNDLED_WELL_KNOWN_STEMS = load("/names/well-known.txt").stream()
|
||||
.map(Declension::withoutInflectedEnding)
|
||||
.collect(Collectors.toUnmodifiableSet());
|
||||
|
||||
private static final AtomicReference<Path> externalFile =
|
||||
new AtomicReference<>(Path.of("config/well-known.txt"));
|
||||
private static final AtomicReference<Set<String>> wellKnownStems =
|
||||
new AtomicReference<>(BUNDLED_WELL_KNOWN_STEMS);
|
||||
private static volatile long externalTimestamp;
|
||||
private static volatile long lastCheck;
|
||||
|
||||
/**
|
||||
* Маркер организации вплотную перед именем. Слово может стоять в любом падеже,
|
||||
* между ним и именем допускается «имени» или «им.» — «Премия имени Ломоносова».
|
||||
*/
|
||||
private static final Pattern ORGANISATION_BEFORE = Pattern.compile(
|
||||
"(?iu:" + String.join("|", load("/names/organisations.txt")) + ")\\p{L}*"
|
||||
+ "(?:\\W{1,3}(?iu:имени|им\\.))?\\W{0,3}$",
|
||||
Pattern.UNICODE_CHARACTER_CLASS | Pattern.UNICODE_CASE);
|
||||
|
||||
/** Сколько знаков перед именем просматривается в поисках маркера организации. */
|
||||
private static final int ORGANISATION_LOOKBEHIND = 40;
|
||||
|
||||
/** Порядковые числительные в имени правителя: «Пётр Первый», «Екатерина Вторая». */
|
||||
private static final String REGNAL_ORDINALS =
|
||||
"перв|втор|трет|четв[её]рт|пят|шест|седьм|восьм|девят|десят";
|
||||
|
||||
/** Прозвища правителей: «Иван Грозный», «Ярослав Мудрый», «Александр Освободитель». */
|
||||
private static final String REGNAL_EPITHETS =
|
||||
"велик|грозн|мудр|благословен|освободител|миротворц?|тишайш|долгорук|окаянн";
|
||||
|
||||
/**
|
||||
* Имя правителя: личное имя плюс порядковое числительное или прозвище —
|
||||
* «Пётр Первый», «Иван Грозный», «Екатерина Вторая», «Ярослав Мудрый».
|
||||
* Задано правилом, а не перечнем: правителей много, а форма записи одна.
|
||||
*/
|
||||
private static final Pattern REGNAL_NAME = Pattern.compile(
|
||||
"^\\p{Lu}\\p{L}+\\s+(?iu:" + REGNAL_ORDINALS + "|" + REGNAL_EPITHETS + ")\\p{L}*$",
|
||||
Pattern.UNICODE_CHARACTER_CLASS | Pattern.UNICODE_CASE | Pattern.CANON_EQ);
|
||||
|
||||
/** Не более скольких падежных букв дописывается к основе имени. */
|
||||
private static final int MAX_INFLECTION = 3;
|
||||
|
||||
/** Остатки, превращающие основу имени в фамилию или отчество: Роман → Романов. */
|
||||
private static final Set<String> SURNAME_SUFFIXES = Set.of(
|
||||
"ов", "ев", "ёв", "ин", "ын", "ова", "ева", "ёва", "ина", "ына",
|
||||
"ович", "евич", "овна", "евна", "овы", "евы", "ины");
|
||||
|
||||
private static final Set<String> GIVEN_NAMES = GIVEN_NAME_STEMS.stream()
|
||||
.map(stem -> stem.toLowerCase(Locale.ROOT))
|
||||
.collect(Collectors.toUnmodifiableSet());
|
||||
|
||||
private NameDictionary() {
|
||||
}
|
||||
|
||||
/** Экземпляр для Spring-бина; словарь работает через статические методы. */
|
||||
public static NameDictionary create() {
|
||||
return new NameDictionary();
|
||||
}
|
||||
|
||||
/**
|
||||
* Задаёт путь к внешнему файлу денилиста. Вызывается при старте приложения
|
||||
* из конфигурации Spring-бина; статические методы словаря работают без
|
||||
* экземпляра, поэтому путь хранится в статическом поле.
|
||||
*/
|
||||
public static void configure(String wellKnownFile) {
|
||||
externalFile.set(Path.of(wellKnownFile));
|
||||
}
|
||||
|
||||
/**
|
||||
* Есть ли среди слов личное имя из словаря в любом падеже.
|
||||
*
|
||||
* <p>Проверка множеством, а не чередованием в регулярном выражении: сто с лишним
|
||||
* веток пришлось бы перебирать в каждой позиции текста, здесь же на слово
|
||||
* приходится не больше четырёх обращений к хеш-таблице.
|
||||
*/
|
||||
public static boolean containsGivenName(String value) {
|
||||
for (String word : value.split("\\P{L}+")) {
|
||||
String lower = word.toLowerCase(Locale.ROOT);
|
||||
// Точное совпадение с основой сильнее всего: «Яков» оканчивается на «ов»,
|
||||
// но это имя, а не фамилия.
|
||||
if (GIVEN_NAMES.contains(lower)) {
|
||||
return true;
|
||||
}
|
||||
// По началу слова имя ищется с оглядкой на остаток: «Марина» это основа
|
||||
// «марин» плюс падежное «а», а «Романов» — основа «роман» плюс фамильное
|
||||
// «ов». Без этой разницы «Бизнес-центр Романов Двор» принимался бы за
|
||||
// человека, а «Марина Шевченко» переставала бы им быть.
|
||||
for (int length = Math.max(1, lower.length() - MAX_INFLECTION); length < lower.length(); length++) {
|
||||
if (GIVEN_NAMES.contains(lower.substring(0, length))
|
||||
&& !SURNAME_SUFFIXES.contains(lower.substring(length))) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Стоит ли перед именем слово, относящее его к организации или объекту на карте.
|
||||
*
|
||||
* <p>«Институт Склифосовского», «Музей Тропинина», «улица Королёва» — это имена
|
||||
* в названиях, а не персональные данные. Отличие от списка известных людей в том,
|
||||
* что здесь решает не само имя, а слово перед ним: клиент по фамилии Королёв
|
||||
* защиту не теряет, а улица Королёва под маску не попадает.
|
||||
*/
|
||||
public static boolean precededByOrganisation(String text, int nameStart) {
|
||||
int from = Math.max(0, nameStart - ORGANISATION_LOOKBEHIND);
|
||||
return ORGANISATION_BEFORE.matcher(text.substring(from, nameStart)).find();
|
||||
}
|
||||
|
||||
/**
|
||||
* Содержит ли текст упоминание известного человека — из сборки или дописанных
|
||||
* сверху.
|
||||
*
|
||||
* <p>Проверяются префиксы слова по множеству, а не каждая основа по слову:
|
||||
* при тысяче с лишним записей (столько городов в {@link ToponymDictionary},
|
||||
* тот же приём) перебор списка на каждое слово текста был бы заметен, а
|
||||
* префиксов у слова — не больше, чем в нём букв.
|
||||
*/
|
||||
public static boolean isWellKnown(String value) {
|
||||
if (REGNAL_NAME.matcher(value.strip()).matches()) {
|
||||
return true;
|
||||
}
|
||||
refreshIfChanged();
|
||||
Set<String> stems = wellKnownStems.get();
|
||||
for (String word : value.split("\\P{L}+")) {
|
||||
String lower = word.toLowerCase(Locale.ROOT);
|
||||
for (int length = lower.length(); length > 0; length--) {
|
||||
if (stems.contains(lower.substring(0, length))) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/** Путь к внешнему файлу денилиста — для тестов, чтобы не трогать {@code config/}. */
|
||||
static void useExternalFile(Path path) {
|
||||
externalFile.set(path);
|
||||
externalTimestamp = -1;
|
||||
lastCheck = 0;
|
||||
}
|
||||
|
||||
/** Перечитать внешний файл немедленно, минуя секундный троттлинг проверки. */
|
||||
static synchronized void reloadExternal() {
|
||||
lastCheck = System.currentTimeMillis();
|
||||
if (!Files.isReadable(externalFile.get())) {
|
||||
if (wellKnownStems.get() != BUNDLED_WELL_KNOWN_STEMS) {
|
||||
LOG.info("Внешний файл денилиста {} исчез, остаётся только встроенный список",
|
||||
externalFile.get().toAbsolutePath());
|
||||
}
|
||||
wellKnownStems.set(BUNDLED_WELL_KNOWN_STEMS);
|
||||
externalTimestamp = 0;
|
||||
return;
|
||||
}
|
||||
try {
|
||||
externalTimestamp = Files.getLastModifiedTime(externalFile.get()).toMillis();
|
||||
Set<String> merged = new HashSet<>(BUNDLED_WELL_KNOWN_STEMS);
|
||||
for (String line : Files.readAllLines(externalFile.get(), StandardCharsets.UTF_8)) {
|
||||
String trimmed = Declension.withoutInflectedEnding(line.trim());
|
||||
if (!trimmed.isEmpty() && !trimmed.startsWith("#")) {
|
||||
merged.add(trimmed);
|
||||
}
|
||||
}
|
||||
wellKnownStems.set(Set.copyOf(merged));
|
||||
LOG.info("Денилист дополнен из {}: {} имён сверх встроенных",
|
||||
externalFile.get().toAbsolutePath(), merged.size() - BUNDLED_WELL_KNOWN_STEMS.size());
|
||||
} catch (IOException e) {
|
||||
// Битый файл не должен ронять маскирование: остаётся прежний список.
|
||||
LOG.error("Не удалось прочитать {}, денилист не изменён", externalFile.get().toAbsolutePath(), e);
|
||||
}
|
||||
}
|
||||
|
||||
private static void refreshIfChanged() {
|
||||
long now = System.currentTimeMillis();
|
||||
if (now - lastCheck < RECHECK_MILLIS) {
|
||||
return;
|
||||
}
|
||||
lastCheck = now;
|
||||
try {
|
||||
if (!Files.isReadable(externalFile.get())) {
|
||||
if (externalTimestamp != 0) {
|
||||
reloadExternal();
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (Files.getLastModifiedTime(externalFile.get()).toMillis() != externalTimestamp) {
|
||||
reloadExternal();
|
||||
}
|
||||
} catch (IOException e) {
|
||||
LOG.debug("Не удалось проверить время изменения {}", externalFile.get(), e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Основы сортируются от длинных к коротким: в чередовании регулярного
|
||||
* выражения побеждает первая подошедшая ветка, и короткая основа не должна
|
||||
* перехватывать совпадение у длинной.
|
||||
*/
|
||||
private static List<String> load(String resource) {
|
||||
try (InputStream in = NameDictionary.class.getResourceAsStream(resource)) {
|
||||
if (in == null) {
|
||||
throw new IllegalStateException("Словарь не найден в сборке: " + resource);
|
||||
}
|
||||
try (BufferedReader reader = new BufferedReader(new InputStreamReader(in, StandardCharsets.UTF_8))) {
|
||||
return reader.lines()
|
||||
.map(String::trim)
|
||||
.filter(line -> !line.isEmpty() && !line.startsWith("#"))
|
||||
.distinct()
|
||||
.sorted(Comparator.comparingInt(String::length).reversed())
|
||||
.toList();
|
||||
}
|
||||
} catch (IOException e) {
|
||||
throw new UncheckedIOException("Не удалось прочитать словарь " + resource, e);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,209 @@
|
||||
package ru.pdguard.detect;
|
||||
|
||||
import ai.onnxruntime.OnnxTensor;
|
||||
import ai.onnxruntime.OrtEnvironment;
|
||||
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 ru.pdguard.detect.Span;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.LongBuffer;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
import java.util.Iterator;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
|
||||
/**
|
||||
* Распознаватель на BERT: размечает имена и составляющие адреса за один проход.
|
||||
*
|
||||
* <p>В отличие от правил, он опознаёт имена без русского словообразования и
|
||||
* нестандартные топонимы. Метки модели ложатся почти один в один
|
||||
* на типы из технического задания: имя, отчество, фамилия, страна, регион, район,
|
||||
* город, улица, дом.
|
||||
*
|
||||
* <p>Модель тяжёлая — сто семьдесят мегабайт и около двенадцати миллисекунд на
|
||||
* вызов, — поэтому её зовут только на участках, которые не разобрала первая
|
||||
* ступень. Одновременных вызовов не больше, чем задано: иначе один запрос с
|
||||
* десятком кандидатов занял бы все ядра.
|
||||
*/
|
||||
final class RuBertRecogniser {
|
||||
|
||||
private static final Logger LOG = LoggerFactory.getLogger(RuBertRecogniser.class);
|
||||
|
||||
/** Предел длины входа: участки короткие, до потолка модели в 512 далеко. */
|
||||
private static final int MAX_PIECES = 190;
|
||||
|
||||
/** Метки модели в типы ПД сервиса. Имя, отчество и фамилия — один тип. */
|
||||
private static final Map<String, String> TYPES = Map.of(
|
||||
"FIRST_NAME", RuleRegistry.FIO,
|
||||
"MIDDLE_NAME", RuleRegistry.FIO,
|
||||
"LAST_NAME", RuleRegistry.FIO,
|
||||
"COUNTRY", RuleRegistry.ADDRESS_COUNTRY,
|
||||
"REGION", RuleRegistry.ADDRESS_REGION,
|
||||
"DISTRICT", RuleRegistry.ADDRESS_DISTRICT,
|
||||
"CITY", RuleRegistry.ADDRESS_CITY,
|
||||
"STREET", RuleRegistry.ADDRESS_STREET,
|
||||
"HOUSE", RuleRegistry.ADDRESS_HOUSE);
|
||||
|
||||
private final OrtEnvironment environment;
|
||||
private final OrtSession session;
|
||||
private final WordPiece tokenizer;
|
||||
private final String[] labels;
|
||||
private final Set<String> inputNames;
|
||||
|
||||
private RuBertRecogniser(OrtEnvironment environment, OrtSession session,
|
||||
WordPiece tokenizer, String[] labels) throws OrtException {
|
||||
this.environment = environment;
|
||||
this.session = session;
|
||||
this.tokenizer = tokenizer;
|
||||
this.labels = labels;
|
||||
this.inputNames = session.getInputNames();
|
||||
}
|
||||
|
||||
/**
|
||||
* Загружает модель из каталога с файлами {@code model_int8.onnx}, {@code vocab.txt}
|
||||
* и {@code config.json}. Каталог недоступен или испорчен — вернётся {@code null},
|
||||
* и сервис продолжит работать на правилах.
|
||||
*/
|
||||
static RuBertRecogniser load(Path directory, int threadsPerCall) {
|
||||
Path model = directory.resolve("model_int8.onnx");
|
||||
Path vocabulary = directory.resolve("vocab.txt");
|
||||
Path config = directory.resolve("config.json");
|
||||
if (!Files.isReadable(model) || !Files.isReadable(vocabulary) || !Files.isReadable(config)) {
|
||||
LOG.warn("Модель BERT в {} неполна, распознаватель не создан", directory.toAbsolutePath());
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
OrtEnvironment environment = OrtEnvironment.getEnvironment();
|
||||
OrtSession.SessionOptions options = new OrtSession.SessionOptions();
|
||||
options.setIntraOpNumThreads(threadsPerCall);
|
||||
options.setInterOpNumThreads(1);
|
||||
OrtSession session = environment.createSession(model.toString(), options);
|
||||
RuBertRecogniser recogniser = new RuBertRecogniser(environment, session,
|
||||
WordPiece.fromVocabulary(vocabulary), readLabels(config));
|
||||
LOG.info("Распознаватель BERT готов, модель {}", model.toAbsolutePath());
|
||||
return recogniser;
|
||||
} catch (OrtException | IOException | RuntimeException e) {
|
||||
LOG.error("Не удалось загрузить модель BERT из {}", directory.toAbsolutePath(), e);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
List<Span> recognise(String text, int from, int to, int priority) {
|
||||
String region = text.substring(from, to);
|
||||
List<WordPiece.Piece> pieces = tokenizer.split(region, MAX_PIECES);
|
||||
if (pieces.isEmpty()) {
|
||||
return List.of();
|
||||
}
|
||||
try {
|
||||
String[] tags = classify(pieces);
|
||||
return toSpans(pieces, tags, from, priority);
|
||||
} catch (OrtException e) {
|
||||
throw new IllegalStateException("Сбой вычисления модели BERT", e);
|
||||
}
|
||||
}
|
||||
|
||||
private String[] classify(List<WordPiece.Piece> pieces) throws OrtException {
|
||||
int length = pieces.size() + 2;
|
||||
long[] ids = new long[length];
|
||||
long[] mask = new long[length];
|
||||
long[] types = new long[length];
|
||||
ids[0] = tokenizer.classifyId();
|
||||
for (int i = 0; i < pieces.size(); i++) {
|
||||
ids[i + 1] = pieces.get(i).id();
|
||||
}
|
||||
ids[length - 1] = tokenizer.separatorId();
|
||||
java.util.Arrays.fill(mask, 1L);
|
||||
|
||||
long[] shape = {1, length};
|
||||
Map<String, OnnxTensor> inputs = new HashMap<>();
|
||||
try {
|
||||
inputs.put("input_ids", OnnxTensor.createTensor(environment, LongBuffer.wrap(ids), shape));
|
||||
inputs.put("attention_mask", OnnxTensor.createTensor(environment, LongBuffer.wrap(mask), shape));
|
||||
if (inputNames.contains("token_type_ids")) {
|
||||
inputs.put("token_type_ids", OnnxTensor.createTensor(environment, LongBuffer.wrap(types), shape));
|
||||
}
|
||||
inputs.keySet().retainAll(inputNames);
|
||||
try (OrtSession.Result result = session.run(inputs)) {
|
||||
float[][][] logits = (float[][][]) result.get(0).getValue();
|
||||
String[] tags = new String[pieces.size()];
|
||||
for (int i = 0; i < pieces.size(); i++) {
|
||||
tags[i] = labels[argmax(logits[0][i + 1])];
|
||||
}
|
||||
return tags;
|
||||
}
|
||||
} finally {
|
||||
inputs.values().forEach(OnnxTensor::close);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Собирает подряд идущие подслова одной сущности в фрагменты исходного текста.
|
||||
* Схема разметки различает начало, середину, конец и одиночный токен, но для
|
||||
* сборки достаточно смены типа: границы участков и так проставлены по словам.
|
||||
*/
|
||||
private static List<Span> toSpans(List<WordPiece.Piece> pieces, String[] tags, int offset, int priority) {
|
||||
List<Span> spans = new ArrayList<>();
|
||||
String currentType = null;
|
||||
int start = 0;
|
||||
int end = 0;
|
||||
for (int i = 0; i < tags.length; i++) {
|
||||
String type = TYPES.get(entityOf(tags[i]));
|
||||
if (type != null && type.equals(currentType)) {
|
||||
end = pieces.get(i).end();
|
||||
continue;
|
||||
}
|
||||
if (currentType != null) {
|
||||
spans.add(new Span(offset + start, offset + end, currentType, priority));
|
||||
}
|
||||
currentType = type;
|
||||
start = pieces.get(i).start();
|
||||
end = pieces.get(i).end();
|
||||
}
|
||||
if (currentType != null) {
|
||||
spans.add(new Span(offset + start, offset + end, currentType, priority));
|
||||
}
|
||||
return spans;
|
||||
}
|
||||
|
||||
private static String entityOf(String tag) {
|
||||
int dash = tag.indexOf('-');
|
||||
return dash < 0 ? tag : tag.substring(dash + 1);
|
||||
}
|
||||
|
||||
private static int argmax(float[] scores) {
|
||||
int best = 0;
|
||||
for (int i = 1; i < scores.length; i++) {
|
||||
if (scores[i] > scores[best]) {
|
||||
best = i;
|
||||
}
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
private static String[] readLabels(Path config) throws IOException {
|
||||
JsonNode node = new ObjectMapper().readTree(Files.readAllBytes(config)).get("id2label");
|
||||
String[] labels = new String[node.size()];
|
||||
for (Iterator<Map.Entry<String, JsonNode>> it = node.fields(); it.hasNext(); ) {
|
||||
Map.Entry<String, JsonNode> entry = it.next();
|
||||
labels[Integer.parseInt(entry.getKey())] = entry.getValue().asText();
|
||||
}
|
||||
return labels;
|
||||
}
|
||||
|
||||
void close() {
|
||||
try {
|
||||
session.close();
|
||||
} catch (OrtException e) {
|
||||
LOG.debug("Не удалось закрыть сессию модели", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
package ru.pdguard.detect;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.function.Predicate;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
/**
|
||||
* Одно правило детекции персональных данных.
|
||||
*
|
||||
* <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} на порядок дешевле запуска
|
||||
* регулярного выражения и отсекает большинство правил на коротком
|
||||
* запросе. Пустой список — правило запускается всегда
|
||||
*/
|
||||
public record Rule(String type, Pattern pattern, int priority, List<Integer> groups,
|
||||
Predicate<String> validator, Pattern veto, Pattern context, List<String> anchors) {
|
||||
|
||||
/**
|
||||
* Флаги компиляции для всех правил.
|
||||
*
|
||||
* <p>{@code UNICODE_CHARACTER_CLASS} обязателен: без него {@code \w}, {@code \W}
|
||||
* и {@code \b} в Java охватывают только латиницу, и якорные слова вроде
|
||||
* «водительское удостоверение» не находятся. {@code UNICODE_CASE} делает
|
||||
* {@code (?i)} корректным для кириллицы.
|
||||
*/
|
||||
private static final int FLAGS = Pattern.UNICODE_CHARACTER_CLASS | Pattern.UNICODE_CASE;
|
||||
|
||||
/**
|
||||
* Сколько символов слева и справа от совпадения просматривает вето-шаблон.
|
||||
*
|
||||
* <p>150, не 80: на реальных адресах отделений из реестра ЦБ (регион, город,
|
||||
* улица, дом — в одном предложении) расстояние от «отделение» до номера дома
|
||||
* часто превышает 80 знаков за счёт длинного названия региона («Ханты-Мансийский
|
||||
* автономный округ», «Кабардино-Балкарская Республика»). Найдено нагрузочным
|
||||
* тестом на 60 реальных адресах из официального реестра — с окном в 80 знаков
|
||||
* вето не срабатывало на части из них.
|
||||
*/
|
||||
public static final int VETO_LOOKBEHIND = 150;
|
||||
public static final int VETO_LOOKAHEAD = 40;
|
||||
|
||||
/** Правило без проверок, маскируется всё совпадение. */
|
||||
public static Rule of(String type, String regex, int priority) {
|
||||
return new Rule(type, Pattern.compile(regex, FLAGS), priority, List.of(0), null, null, null, List.of());
|
||||
}
|
||||
|
||||
/** Маскировать только перечисленные группы, а не всё совпадение. */
|
||||
public Rule groups(Integer... indexes) {
|
||||
return new Rule(type, pattern, priority, List.of(indexes), validator, veto, context, anchors);
|
||||
}
|
||||
|
||||
/** Принять совпадение, только если значение прошло проверку. */
|
||||
public Rule validatedBy(Predicate<String> check) {
|
||||
return new Rule(type, pattern, priority, groups, check, veto, context, anchors);
|
||||
}
|
||||
|
||||
/** Запускать правило, только если в тексте есть одна из подстрок (в нижнем регистре). */
|
||||
public Rule anchoredBy(String... required) {
|
||||
return new Rule(type, pattern, priority, groups, validator, veto, context, List.of(required));
|
||||
}
|
||||
|
||||
/** Есть ли в тексте хоть один из якорей правила. */
|
||||
public boolean mayMatch(String lowercasedText) {
|
||||
if (anchors.isEmpty()) {
|
||||
return true;
|
||||
}
|
||||
for (String anchor : anchors) {
|
||||
if (lowercasedText.contains(anchor)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/** Принять совпадение, только если рядом встретилось указанное слово. */
|
||||
public Rule requiringNear(String regex) {
|
||||
return new Rule(type, pattern, priority, groups, validator, veto, Pattern.compile(regex, FLAGS), anchors);
|
||||
}
|
||||
|
||||
/** Отбросить совпадение, если рядом встретилось указанное слово. */
|
||||
public Rule vetoedBy(String regex) {
|
||||
return new Rule(type, pattern, priority, groups, validator, Pattern.compile(regex, FLAGS), context, anchors);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,522 @@
|
||||
package ru.pdguard.detect;
|
||||
|
||||
import org.springframework.stereotype.Component;
|
||||
import ru.pdguard.config.SystemPolicy;
|
||||
import ru.pdguard.detect.Span;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.regex.Matcher;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
/**
|
||||
* Реестр правил детекции и сам поиск ПД в тексте.
|
||||
*
|
||||
* <p>Правила разбиты на три уровня доверия:
|
||||
* <ol>
|
||||
* <li>проверяемые контрольной суммой — карта, ИНН, СНИЛС: ложных срабатываний почти нет;</li>
|
||||
* <li>однозначные по формату — email, телефон;</li>
|
||||
* <li>требующие якорного слова — паспорт, водительское удостоверение, CVV, адрес и прочее,
|
||||
* где сама по себе последовательность знаков ни о чём не говорит.</li>
|
||||
* </ol>
|
||||
*
|
||||
* <p>Якорные слова распознаются без учёта регистра — флаг {@code (?iu:...)} навешен
|
||||
* именно на них. На захватываемое значение регистронезависимость не распространяется:
|
||||
* там, где значение опознаётся по заглавной букве, это существенно.
|
||||
*/
|
||||
@Component
|
||||
public class RuleRegistry {
|
||||
|
||||
public static final String EMAIL = "EMAIL";
|
||||
public static final String PHONE = "PHONE";
|
||||
public static final String CARD = "CARD";
|
||||
public static final String INN = "INN";
|
||||
public static final String SNILS = "SNILS";
|
||||
public static final String PASSPORT = "PASSPORT";
|
||||
public static final String PASSPORT_ISSUER = "PASSPORT_ISSUER";
|
||||
public static final String PASSPORT_DATE = "PASSPORT_DATE";
|
||||
public static final String DEPT_CODE = "DEPT_CODE";
|
||||
public static final String DRIVER_LICENSE = "DRIVER_LICENSE";
|
||||
public static final String CITIZENSHIP = "CITIZENSHIP";
|
||||
public static final String BIRTH_PLACE = "BIRTH_PLACE";
|
||||
public static final String BIRTH_DATE = "BIRTH_DATE";
|
||||
public static final String DATE = "DATE";
|
||||
public static final String CVV = "CVV";
|
||||
public static final String PIN = "PIN";
|
||||
public static final String CARDHOLDER = "CARDHOLDER";
|
||||
public static final String ADDRESS_COUNTRY = "ADDRESS_COUNTRY";
|
||||
public static final String ADDRESS_POSTCODE = "ADDRESS_POSTCODE";
|
||||
public static final String ADDRESS_CITY = "ADDRESS_CITY";
|
||||
public static final String ADDRESS_STREET = "ADDRESS_STREET";
|
||||
public static final String ADDRESS_HOUSE = "ADDRESS_HOUSE";
|
||||
public static final String ADDRESS_FLAT = "ADDRESS_FLAT";
|
||||
/** Регион и район размечает только модель второй ступени: правил под них нет. */
|
||||
public static final String ADDRESS_REGION = "ADDRESS_REGION";
|
||||
public static final String ADDRESS_DISTRICT = "ADDRESS_DISTRICT";
|
||||
public static final String FIO = "FIO";
|
||||
public static final String FOREIGN_PASSPORT = "FOREIGN_PASSPORT";
|
||||
public static final String MILITARY_ID = "MILITARY_ID";
|
||||
public static final String BIRTH_CERTIFICATE = "BIRTH_CERTIFICATE";
|
||||
public static final String MEDICAL_POLICY = "MEDICAL_POLICY";
|
||||
|
||||
/** Банковские реквизиты сверх платёжной карты. */
|
||||
public static final String ACCOUNT_NUMBER = "ACCOUNT_NUMBER";
|
||||
public static final String BIK = "BIK";
|
||||
public static final String CARD_EXPIRY = "CARD_EXPIRY";
|
||||
public static final String INCOME = "INCOME";
|
||||
public static final String OGRN = "OGRN";
|
||||
public static final String OGRNIP = "OGRNIP";
|
||||
public static final String KPP = "KPP";
|
||||
public static final String BIOMETRIC = "BIOMETRIC";
|
||||
|
||||
/**
|
||||
* Слово с заглавной буквы; остальные буквы любого регистра, чтобы
|
||||
* «ИВАНОВ» распознавался наравне с «Иванов».
|
||||
*/
|
||||
private static final String CAPITALISED = "\\p{Lu}[\\p{Lu}\\p{Ll}]+";
|
||||
|
||||
/**
|
||||
* Название улицы: от одного до трёх слов с заглавной буквы либо чисел —
|
||||
* «Тверская», «Малая Никитская», «8 Марта». Ограничение по форме обязательно:
|
||||
* без него правило дожёвывало строку до конца, и «Проспект Вернадского перекрыт
|
||||
* до вечера» оказывался под маской целиком.
|
||||
*/
|
||||
private static final String STREET_NAME =
|
||||
"(?:\\p{Lu}[\\p{L}-]+|\\d+[\\p{L}-]*)(?:\\s+(?:\\p{Lu}[\\p{L}-]+|\\d+[\\p{L}-]*)){0,2}";
|
||||
|
||||
/**
|
||||
* Фамилия по словообразованию: Иванов, Ковалёва, Троицкий, Шевченко, Мкртчян.
|
||||
* Хвост из двух букв покрывает падежные окончания: Ковалёв-ой, Иванов-а.
|
||||
*/
|
||||
private static final String SURNAME =
|
||||
"\\p{Lu}[\\p{Lu}\\p{Ll}]*(?iu:ов|ев|ёв|ин|ын|ск(?:ий|ая|ого|ой|ом)|цк(?:ий|ая)"
|
||||
+ "|енко|ко|ук|юк|ян|швили|дзе)\\p{L}{0,2}";
|
||||
|
||||
/**
|
||||
* Отчество: признак надёжный, ни одно другое слово так не оканчивается.
|
||||
* Основы даны без падежного окончания — Иванович, Ивановича, Ивановне.
|
||||
*/
|
||||
private static final String PATRONYMIC =
|
||||
"\\p{Lu}[\\p{Lu}\\p{Ll}]+(?iu:ович|евич|ьич|мич|нич|тич|лич|кич|бич|сич"
|
||||
+ "|овн|евн|иничн|ичн)\\p{L}{0,2}";
|
||||
|
||||
/** Серия и номер: «4509 123456», «45 09 123456», «4509123456», «45 09 № 123456». */
|
||||
private static final String SERIES_AND_NUMBER = "\\d{2}\\s?\\d{2}[\\s№N]{0,3}\\d{6}";
|
||||
|
||||
private static final String MONTH =
|
||||
"(?iu:январ|феврал|март|апрел|ма[йя]|июн|июл|август|сентябр|октябр|ноябр|декабр)\\p{L}*";
|
||||
|
||||
/** Числовая запись при любом порядке частей: дд.мм.гггг, мм/дд/гггг, гггг-мм-дд. */
|
||||
private static final String DATE_DIGITS = "\\b\\d{1,4}[.\\-/]\\d{1,2}[.\\-/]\\d{1,4}\\b";
|
||||
|
||||
/** «12 мая 1985 г.» */
|
||||
private static final String DATE_MONTH_WORD =
|
||||
"\\b\\d{1,2}\\s+" + MONTH + "\\s+\\d{4}\\b(?:\\s*(?iu:года|г\\.|г\\b))?";
|
||||
|
||||
/** «двенадцатого мая тысяча девятьсот восемьдесят пятого года» */
|
||||
private static final String DATE_WORDS =
|
||||
"\\b(?:(?iu:двадцать|тридцать)\\s+)?"
|
||||
+ "(?iu:перв|втор|треть|четв[её]рт|пят|шест|седьм|восьм|девят|десят|одиннадцат|двенадцат"
|
||||
+ "|тринадцат|четырнадцат|пятнадцат|шестнадцат|семнадцат|восемнадцат|девятнадцат|двадцат|тридцат)"
|
||||
+ "(?iu:ьего|ого|его)\\s+" + MONTH
|
||||
+ "\\s+(?:\\d{4}|(?iu:тысяча)[\\p{L}\\s]{5,60}?)\\s*(?iu:года|год\\b|г\\.)";
|
||||
|
||||
/** Любая из трёх записей даты; внутри только незахватывающие группы. */
|
||||
private static final String DATE_ANY = "(?:" + DATE_WORDS + "|" + DATE_MONTH_WORD + "|" + DATE_DIGITS + ")";
|
||||
|
||||
/**
|
||||
* Слова, при которых адрес принадлежит организации, а не человеку:
|
||||
* адрес отделения банка персональными данными не является. Части адреса рядом:
|
||||
* улица, упомянутая в рассказе о городе, адресом клиента не является — ровно
|
||||
* как адрес отделения банка из технического задания. Требование стояло только
|
||||
* у постфиксной формы правила, префиксная его не имела.
|
||||
*/
|
||||
public static final String ADDRESS_NEARBY =
|
||||
"(?iu:адрес|индекс|\\bд\\.|\\bдом\\b|\\bкв\\.|\\bг\\.|\\bгород|регистрац|прожива)";
|
||||
|
||||
private static final Pattern ADDRESS_CONTEXT =
|
||||
Pattern.compile(ADDRESS_NEARBY, Pattern.UNICODE_CHARACTER_CLASS | Pattern.UNICODE_CASE);
|
||||
|
||||
/** Адресные типы, которые вне адресного окружения персональными данными не являются. */
|
||||
private static final java.util.Set<String> ADDRESS_TYPES = java.util.Set.of(
|
||||
ADDRESS_COUNTRY, ADDRESS_REGION, ADDRESS_DISTRICT, ADDRESS_CITY,
|
||||
ADDRESS_STREET, ADDRESS_HOUSE, ADDRESS_FLAT, ADDRESS_POSTCODE);
|
||||
|
||||
public static boolean isAddressType(String type) {
|
||||
return ADDRESS_TYPES.contains(type);
|
||||
}
|
||||
|
||||
/**
|
||||
* Есть ли рядом другие части адреса. Правила проверяют это сами, а находкам
|
||||
* второй ступени проверку нужно навязать снаружи: модель размечает «Москву» в
|
||||
* названии клуба и «Вернадского» в названии проспекта наравне с настоящим адресом.
|
||||
*/
|
||||
public static boolean hasAddressContext(String text, int start, int end) {
|
||||
return ADDRESS_CONTEXT.matcher(surroundings(text, start, end)).find();
|
||||
}
|
||||
|
||||
private static final String ORGANISATION_NEARBY =
|
||||
"(?iu:отделени|филиал|банкомат|доп\\.?\\s?офис|офис|головн|юридическ\\p{L}*\\s+адрес)";
|
||||
|
||||
private static final List<Rule> RULES = List.of(
|
||||
|
||||
// --- Уровень 3: значение опознаётся только рядом с якорным словом ---
|
||||
|
||||
Rule.of(CVV, "(?iu:\\b(?:cvv2?|cvc2?|код\\s+проверки|защитный\\s+код))\\W{0,5}(\\d{3,4})\\b", 92)
|
||||
.groups(1)
|
||||
.anchoredBy("cvv", "cvc", "код проверки", "защитный код"),
|
||||
|
||||
Rule.of(PIN, "(?iu:\\bпин[\\s-]?кода?|\\bpin[\\s-]?code|\\bpin)\\b\\W{0,5}(\\d{4,6})\\b", 92)
|
||||
.groups(1)
|
||||
.anchoredBy("пин", "pin"),
|
||||
|
||||
// «паспорт 4509 123456», «паспорт гражданина РФ 45 09 123456»
|
||||
Rule.of(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(PASSPORT, "(?iu:сери)\\w{0,3}\\W{0,5}(\\d{2}\\s?\\d{2})[^\\d]{0,20}(\\d{6})\\b", 90)
|
||||
.groups(1, 2)
|
||||
.anchoredBy("сери"),
|
||||
|
||||
Rule.of(DRIVER_LICENSE, "(?iu:водительск\\w+\\s+удостоверени\\w+|в/у|вод\\.\\s?удост\\w*|\\bВУ)\\b"
|
||||
+ "\\W{0,15}(" + SERIES_AND_NUMBER + ")\\b", 89)
|
||||
.groups(1)
|
||||
.anchoredBy("водительск", "в/у", "вод.", "ву "),
|
||||
|
||||
// --- Прочие документы, удостоверяющие личность ---
|
||||
|
||||
Rule.of(FOREIGN_PASSPORT, "(?iu:загранпаспорт|заграничн\\p{L}*\\s+паспорт)\\p{L}*"
|
||||
+ "\\W{0,10}(\\d{2}\\s?\\d{7})\\b", 89)
|
||||
.groups(1)
|
||||
.anchoredBy("загранпаспорт", "заграничн"),
|
||||
|
||||
Rule.of(MILITARY_ID, "(?iu:военн\\p{L}*\\s+билет)\\p{L}*"
|
||||
+ "\\W{0,10}(\\p{Lu}{2}\\s?\\d{7})\\b", 89)
|
||||
.groups(1)
|
||||
.anchoredBy("военн"),
|
||||
|
||||
Rule.of(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(MEDICAL_POLICY, "(?iu:полис\\p{L}*(?:\\s+ОМС)?)\\W{0,10}(\\d{16})\\b", 89)
|
||||
.groups(1)
|
||||
.anchoredBy("полис"),
|
||||
|
||||
Rule.of(DEPT_CODE, "(?iu:код\\w*\\s+подразделения|к/п)\\W{0,5}(\\d{3}\\s?-?\\s?\\d{3})\\b", 88)
|
||||
.groups(1)
|
||||
.anchoredBy("подразделени", "к/п"),
|
||||
|
||||
// --- Банковские реквизиты сверх карты ---
|
||||
|
||||
// Расчётный счёт — ровно 20 цифр после якоря, группировка пробелами не важна.
|
||||
Rule.of(ACCOUNT_NUMBER, "(?iu:р/с|расчетн\\w*\\s+счет|расчётн\\w*\\s+счёт|лицев\\w*\\s+счет|"
|
||||
+ "лицев\\w*\\s+счёт)\\W{0,5}((?:\\d[ ]?){19}\\d)\\b", 83)
|
||||
.groups(1)
|
||||
.anchoredBy("р/с", "расчетн", "расчётн", "лицев"),
|
||||
|
||||
Rule.of(BIK, "(?iu:бик)\\W{0,5}(\\d{9})\\b", 83)
|
||||
.groups(1)
|
||||
.anchoredBy("бик"),
|
||||
|
||||
// «действительна до 09/27», «exp 09/27» — срок действия карты, не дата рождения.
|
||||
Rule.of(CARD_EXPIRY, "(?iu:срок\\s+действия|действительна?\\s+до|\\bexp\\w*)\\W{0,5}"
|
||||
+ "(\\d{2}\\s?/\\s?\\d{2})\\b", 83)
|
||||
.groups(1)
|
||||
.anchoredBy("срок действия", "действительн", "exp"),
|
||||
|
||||
// ОГРНИП раньше ОГРН: без отрицательного просмотра «ОГРНИП» частично ловился бы
|
||||
// ещё и правилом ОГРН. Контрольная сумма отсекает случайные 13/15-значные
|
||||
// числа рядом со словом — раньше якоря было достаточно самого по себе.
|
||||
Rule.of(OGRNIP, "(?iu:огрнип)\\W{0,5}(\\d{15})\\b", 83)
|
||||
.groups(1)
|
||||
.validatedBy(Validators::ogrnip)
|
||||
.anchoredBy("огрнип"),
|
||||
|
||||
Rule.of(OGRN, "(?iu:огрн(?!ип))\\W{0,5}(\\d{13})\\b", 83)
|
||||
.groups(1)
|
||||
.validatedBy(Validators::ogrn)
|
||||
.anchoredBy("огрн"),
|
||||
|
||||
Rule.of(KPP, "(?iu:кпп)\\W{0,5}(\\d{9})\\b", 83)
|
||||
.groups(1)
|
||||
.anchoredBy("кпп"),
|
||||
|
||||
// Доход/зарплата: сумма с разделителями тысяч. Между якорем и суммой может
|
||||
// стоять слово («доход клиента», «доход за год») — без этого якорь ловил
|
||||
// бы только «доход 85000», вплотную.
|
||||
Rule.of(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(BIOMETRIC, "(?iu:биометрическ\\w*\\s+(?:данны\\w*|образц\\w*|шаблон\\w*)"
|
||||
+ "|слепок\\s+голоса|отпечаток\\s+пальца|скан\\s+лица|\\bЕБС\\b)", 81)
|
||||
.anchoredBy("биометри", "слепок голоса", "отпечаток пальца", "скан лица", "ебс"),
|
||||
|
||||
// --- Даты с явным якорем ---
|
||||
|
||||
Rule.of(BIRTH_DATE, "(?iu:дат\\p{L}*\\s+рождения|дата\\s+рожд\\.)\\W{0,5}(" + DATE_ANY + ")", 87)
|
||||
.groups(1)
|
||||
.validatedBy(Validators::date)
|
||||
.anchoredBy("рожден"),
|
||||
|
||||
Rule.of(BIRTH_DATE, "(?iu:родил(?:ся|ась))\\W{0,5}(" + DATE_ANY + ")", 87)
|
||||
.groups(1)
|
||||
.validatedBy(Validators::date)
|
||||
.anchoredBy("родил"),
|
||||
|
||||
Rule.of(BIRTH_DATE, "(" + DATE_ANY + ")\\s*(?iu:г\\.\\s?р\\.|г/р|года\\s+рождения)", 87)
|
||||
.groups(1)
|
||||
.validatedBy(Validators::date)
|
||||
.anchoredBy("г.р", "г/р", "года рождения"),
|
||||
|
||||
// «дата выдачи 12.05.2015» и «дата выдачи паспорта 12.05.2015»
|
||||
Rule.of(PASSPORT_DATE, "(?iu:дат\\p{L}*\\s+выдачи)(?:\\s+\\p{L}+)?\\W{0,5}(" + DATE_ANY + ")", 87)
|
||||
.groups(1)
|
||||
.validatedBy(Validators::date)
|
||||
.anchoredBy("выдач"),
|
||||
|
||||
Rule.of(CARDHOLDER, "(?iu:держател\\w*(?:\\s+карты)?|cardholder|на\\s+имя)"
|
||||
+ "\\W{0,10}([A-Z]{2,20}\\s+[A-Z]{2,20})\\b", 86)
|
||||
.groups(1)
|
||||
.anchoredBy("держател", "cardholder", "на имя"),
|
||||
|
||||
// --- ФИО ---
|
||||
|
||||
// Фамилия Имя Отчество: первое слово опознаётся по словообразованию фамилии.
|
||||
// Свободная тройка «любое слово с заглавной + имя + отчество» здесь
|
||||
// сознательно не используется: она захватывает глагол в начале
|
||||
// предложения («Пригласите Ивана Сергеевича») и заметно дороже по времени.
|
||||
// Фамилии без привычного окончания — Ким, Цой — ловятся по ролевому слову.
|
||||
Rule.of(FIO, "\\b" + SURNAME + "\\s+" + CAPITALISED + "\\s+" + PATRONYMIC + "\\b", 79),
|
||||
|
||||
// Имя Отчество Фамилия — второй распространённый порядок слов.
|
||||
Rule.of(FIO, "\\b" + CAPITALISED + "\\s+" + PATRONYMIC + "\\s+" + SURNAME + "\\b", 79),
|
||||
|
||||
// Иванов И.И. и И.И. Иванов
|
||||
Rule.of(FIO, "\\b" + SURNAME + "\\s+\\p{Lu}\\.\\s?\\p{Lu}\\.", 79),
|
||||
Rule.of(FIO, "\\b\\p{Lu}\\.\\s?\\p{Lu}\\.\\s?" + SURNAME + "\\b", 79),
|
||||
|
||||
// Имя Отчество без фамилии
|
||||
Rule.of(FIO, "\\b" + CAPITALISED + "\\s+" + PATRONYMIC + "\\b", 77),
|
||||
|
||||
// «ФИО: иванов иван иванович» — явный якорь снимает требование к регистру
|
||||
Rule.of(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(FIO, "(?iu:\\bклиент|\\bзаказчик|\\bпациент|\\bсотрудник|\\bвладел|\\bплательщик"
|
||||
+ "|\\bполучател|\\bабонент|\\bв\\s+лице|\\bпредставител|\\bпоручител"
|
||||
+ "|\\bсозаёмщик|\\bсозаемщик|\\bзаёмщик|\\bзаемщик|\\bзаявител|\\bдоверител"
|
||||
+ "|\\bвкладчик|\\bответственн|\\bконтактное\\s+лицо|\\bисполнител|\\bдержател)\\p{L}*"
|
||||
+ "\\W{0,5}(\\p{Lu}\\p{Ll}+(?:\\s+\\p{Lu}\\p{Ll}+){0,2})\\b", 77)
|
||||
.groups(1)
|
||||
.anchoredBy("клиент", "заказчик", "пациент", "сотрудник", "владел", "плательщик",
|
||||
"получател", "абонент", "в лице", "представител", "поручител", "заёмщик",
|
||||
"заемщик", "заявител", "доверител", "вкладчик", "ответственн",
|
||||
"контактное лицо", "исполнител", "держател"),
|
||||
|
||||
// Фамилия рядом с личным именем из словаря: без словаря правило ловило бы
|
||||
// «Тверская улица» и тому подобное. Имя проверяется по множеству уже
|
||||
// после совпадения — чередование из ста веток в шаблоне обходится дорого.
|
||||
// Самое слабое основание среди правил ФИО — ни ролевого слова, ни явного
|
||||
// якоря, — поэтому именно здесь нужно вето на адресный контекст: «Великие
|
||||
// Луки» (реальный город) распознаётся как имя «Лука» в падеже плюс
|
||||
// случайное слово, «Богдана Хмельницкого» — улица в честь исторической
|
||||
// фигуры. Найдено на реальных адресах отделений из реестра ЦБ.
|
||||
Rule.of(FIO, "\\b" + SURNAME + "\\s+" + CAPITALISED + "\\b", 74)
|
||||
.validatedBy(NameDictionary::containsGivenName)
|
||||
.vetoedBy(ORGANISATION_NEARBY),
|
||||
Rule.of(FIO, "\\b" + CAPITALISED + "\\s+" + SURNAME + "\\b", 74)
|
||||
.validatedBy(NameDictionary::containsGivenName)
|
||||
.vetoedBy(ORGANISATION_NEARBY),
|
||||
|
||||
// --- Уровень 1: подтверждается контрольной суммой ---
|
||||
|
||||
Rule.of(CARD, "\\b\\d(?:[ -]?\\d){11,18}\\b", 85)
|
||||
.validatedBy(Validators::luhn),
|
||||
|
||||
Rule.of(INN, "(?iu)\\bИНН\\b\\D{0,10}(\\d{12}|\\d{10})\\b", 84)
|
||||
.groups(1)
|
||||
.anchoredBy("инн"),
|
||||
|
||||
Rule.of(SNILS, "(?iu)(?:\\bСНИЛС\\b\\D{0,10})?(\\d{3}[ -]\\d{3}[ -]\\d{3}[ -]\\d{2})\\b", 84)
|
||||
.groups(1)
|
||||
.validatedBy(Validators::snils),
|
||||
|
||||
// --- Уровень 2: формат однозначен сам по себе ---
|
||||
|
||||
Rule.of(PHONE, "(?:\\+7|\\b8)[ ()-]{0,3}\\d{3}[ ()-]{0,3}\\d{3}[ -]{0,2}\\d{2}[ -]{0,2}\\d{2}\\b", 82),
|
||||
|
||||
Rule.of(EMAIL, "\\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\\.[A-Za-z]{2,}\\b", 80)
|
||||
.anchoredBy("@"),
|
||||
|
||||
// --- Уровень 3: свободный текст после якорного слова ---
|
||||
|
||||
// «выдан ОУФМС России по г. Москве 12.05.2015» — дата в состав органа не входит,
|
||||
// её забирает отдельное правило. Приоритет выше городского, иначе от органа
|
||||
// осталась бы замаскированной только его часть.
|
||||
Rule.of(PASSPORT_ISSUER, "(?iu:выдан)[\\p{L}]*\\W{0,3}([^,;\\n]{3,90}?)"
|
||||
+ "(?=\\s*\\d{1,2}[.\\-/]\\d{1,2}[.\\-/]\\d{2,4}|[,;\\n]|\\s*$)", 78)
|
||||
.groups(1)
|
||||
.anchoredBy("выдан"),
|
||||
|
||||
Rule.of(BIRTH_PLACE, "(?iu:мест\\w*\\s+рождения)\\W{0,5}([^,;\\n]{3,60}?)(?=\\s*[,;\\n]|\\s*$)", 76)
|
||||
.groups(1)
|
||||
.anchoredBy("рождения"),
|
||||
|
||||
Rule.of(BIRTH_PLACE, "(?iu:родил(?:ся|ась))[^,;\\n]{0,40}?\\s+в\\s+"
|
||||
+ "([^,;\\n]{3,40}?)(?=\\s*[,;\\n]|\\s*$)", 76)
|
||||
.groups(1)
|
||||
.anchoredBy("родил"),
|
||||
|
||||
Rule.of(CITIZENSHIP, "(?iu:гражданств)\\w*\\W{0,5}"
|
||||
+ "((?iu:рф|россии|российской\\s+федерации|республики\\s+\\p{L}+)|\\p{Lu}\\p{Ll}+)\\b", 75)
|
||||
.groups(1)
|
||||
.anchoredBy("гражданств"),
|
||||
|
||||
Rule.of(CITIZENSHIP, "(?iu:граждан(?:ин|ка|ина|ки))\\b\\s+"
|
||||
+ "((?iu:рф|россии|российской\\s+федерации|республики\\s+\\p{L}+)|\\p{Lu}\\p{Ll}+)\\b", 75)
|
||||
.groups(1)
|
||||
.anchoredBy("граждан"),
|
||||
|
||||
// --- Адрес: каждая составляющая настраивается отдельно ---
|
||||
|
||||
Rule.of(ADDRESS_POSTCODE, "(?iu:индекс)\\W{0,5}(\\d{6})\\b", 74)
|
||||
.groups(1)
|
||||
.vetoedBy(ORGANISATION_NEARBY)
|
||||
.anchoredBy("индекс"),
|
||||
|
||||
Rule.of(ADDRESS_POSTCODE,
|
||||
"\\b(\\d{6})(?=\\s*,?\\s*(?iu:г\\.|город|обл\\.|область|респ|край))", 74)
|
||||
.groups(1)
|
||||
.vetoedBy(ORGANISATION_NEARBY),
|
||||
|
||||
// Не только «г.»: перепись, на которой проверяется словарь, покрывает
|
||||
// сёла, посёлки, деревни, хутора и станицы — «рп. Ильинское», «с. Кукуево»
|
||||
// из ТЗ без этих якорей не нашлись бы вообще, город там ни при чём.
|
||||
Rule.of(ADDRESS_CITY, "(?iu:\\bг\\.|\\bгор\\.|\\bгород|\\bрп\\.|\\bпгт\\.?|\\bп\\.|\\bс\\.|\\bсело\\b"
|
||||
+ "|\\bд\\.|\\bдеревня\\b|\\bдер\\.|\\bх\\.|\\bхутор\\b|\\bст-ца|\\bстаница|\\bаул\\b"
|
||||
+ "|\\bсл\\.|\\bслобода\\b|\\bаал\\b)\\s?(\\p{Lu}[\\p{L}-]{1,30})\\b", 73)
|
||||
.groups(1)
|
||||
.validatedBy(ToponymDictionary::isKnownSettlement)
|
||||
.vetoedBy(ORGANISATION_NEARBY)
|
||||
.anchoredBy("г.", "гор", "город", "рп.", "пгт", "п.", "с.", "село", "д.", "деревня",
|
||||
"дер.", "х.", "хутор", "ст-ца", "станица", "аул", "сл.", "слобода", "аал"),
|
||||
|
||||
Rule.of(ADDRESS_STREET,
|
||||
"(?iu:\\bул\\.|\\bулиц\\p{L}*|\\bпр-т|\\bпроспект\\p{L}*|\\bпер\\.|\\bпереул\\p{L}*"
|
||||
+ "|\\bш\\.|\\bшоссе|\\bб-р|\\bбульвар\\p{L}*|\\bнаб\\.|\\bнабережн\\p{L}*)"
|
||||
+ "\\W{0,3}(" + STREET_NAME + ")", 73)
|
||||
.groups(1)
|
||||
.vetoedBy(ORGANISATION_NEARBY)
|
||||
.requiringNear(ADDRESS_NEARBY)
|
||||
.anchoredBy("ул", "просп", "пр-т", "пер.", "шоссе", "ш.", "бульвар", "б-р", "наб"),
|
||||
|
||||
// «Невский пр-т» — указатель после названия. Форма слишком общая, поэтому
|
||||
// принимается только рядом с другими частями адреса: иначе под маску попал бы
|
||||
// любой рассказ про Невский проспект.
|
||||
Rule.of(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(ADDRESS_HOUSE,
|
||||
"(?iu:\\bд\\.|\\bдом)\\s?(\\d+\\p{L}?(?:\\s?(?iu:к\\.|корп\\.?|стр\\.)\\s?\\d+)?)\\b", 72)
|
||||
.groups(1)
|
||||
.vetoedBy(ORGANISATION_NEARBY)
|
||||
.anchoredBy("д.", "дом"),
|
||||
|
||||
Rule.of(ADDRESS_FLAT, "(?iu:\\bкв\\.|\\bквартир\\p{L}*)\\s?(\\d+\\p{L}?)\\b", 72)
|
||||
.groups(1)
|
||||
.vetoedBy(ORGANISATION_NEARBY)
|
||||
.anchoredBy("кв"),
|
||||
|
||||
Rule.of(ADDRESS_COUNTRY,
|
||||
"(?iu:стран\\p{L}*(?:\\s+(?:регистрации|проживания|гражданства))?)"
|
||||
+ "\\W{0,5}(\\p{Lu}[\\p{L}-]{2,30})\\b", 71)
|
||||
.groups(1)
|
||||
.vetoedBy(ORGANISATION_NEARBY)
|
||||
.anchoredBy("стран"),
|
||||
|
||||
// --- Значения без якоря: принимаются только вместе с другими ПД ---
|
||||
|
||||
// ИНН физлица без якорного слова — только с верной контрольной суммой.
|
||||
Rule.of(INN, "\\b\\d{12}\\b", 62)
|
||||
.validatedBy(Validators::inn),
|
||||
|
||||
// Дата без якорного слова персональными данными сама по себе не является:
|
||||
// маскируется, только если в тексте есть ПД другого типа.
|
||||
Rule.of(DATE, DATE_ANY, 58)
|
||||
.validatedBy(Validators::date)
|
||||
);
|
||||
|
||||
/** Все типы ПД, которые умеет распознавать сервис. */
|
||||
public List<String> knownTypes() {
|
||||
return RULES.stream().map(Rule::type).distinct().toList();
|
||||
}
|
||||
|
||||
/**
|
||||
* Находит все фрагменты ПД, разрешённые политикой системы.
|
||||
* Перекрытия здесь не разрешаются — это делает вызывающая сторона.
|
||||
*/
|
||||
public List<Span> detect(String text, SystemPolicy policy) {
|
||||
List<Span> found = new ArrayList<>();
|
||||
String lowercased = text.toLowerCase(Locale.ROOT);
|
||||
for (Rule rule : RULES) {
|
||||
if (!policy.allows(rule.type()) || !rule.mayMatch(lowercased)) {
|
||||
continue;
|
||||
}
|
||||
collect(rule, text, found);
|
||||
}
|
||||
return found;
|
||||
}
|
||||
|
||||
private static void collect(Rule rule, String text, List<Span> sink) {
|
||||
Matcher m = rule.pattern().matcher(text);
|
||||
while (m.find()) {
|
||||
for (int group : rule.groups()) {
|
||||
int start = m.start(group);
|
||||
int end = m.end(group);
|
||||
if (isValidGroup(rule, text, start, end)) {
|
||||
sink.add(new Span(start, end, rule.type(), rule.priority()));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Проверяет, что фрагмент группы проходит все условия правила: границы, валидатор, veto и контекст. */
|
||||
private static boolean isValidGroup(Rule rule, String text, int start, int end) {
|
||||
if (start < 0 || end <= start) {
|
||||
return false;
|
||||
}
|
||||
if (rule.validator() != null && !rule.validator().test(text.substring(start, end))) {
|
||||
return false;
|
||||
}
|
||||
String surroundings = surroundings(text, start, end);
|
||||
if (rule.veto() != null && rule.veto().matcher(surroundings).find()) {
|
||||
return false;
|
||||
}
|
||||
return rule.context() == null || rule.context().matcher(surroundings).find();
|
||||
}
|
||||
|
||||
static String surroundings(String text, int start, int end) {
|
||||
int from = Math.max(0, start - Rule.VETO_LOOKBEHIND);
|
||||
int to = Math.min(text.length(), end + Rule.VETO_LOOKAHEAD);
|
||||
return text.substring(from, to);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
package ru.pdguard.detect;
|
||||
|
||||
/**
|
||||
* Найденный фрагмент персональных данных в исходном тексте.
|
||||
*
|
||||
* @param start индекс первого символа (включительно)
|
||||
* @param end индекс за последним символом (исключительно)
|
||||
* @param type тип ПД, например {@code CARD} или {@code EMAIL}
|
||||
* @param priority приоритет при разрешении перекрытий: больше — важнее
|
||||
*/
|
||||
public record Span(int start, int end, String type, int priority) {
|
||||
|
||||
public Span {
|
||||
if (start < 0 || end <= start) {
|
||||
throw new IllegalArgumentException("Некорректные границы фрагмента: " + start + ".." + end);
|
||||
}
|
||||
}
|
||||
|
||||
public int length() {
|
||||
return end - start;
|
||||
}
|
||||
|
||||
public boolean overlaps(Span other) {
|
||||
return start < other.end && other.start < end;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
package ru.pdguard.detect;
|
||||
|
||||
import java.io.BufferedReader;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.io.InputStreamReader;
|
||||
import java.io.UncheckedIOException;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.Locale;
|
||||
import java.util.Set;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
/**
|
||||
* Словарь населённых пунктов России — проверка того, что значение, пойманное
|
||||
* правилом {@code ADDRESS_CITY}, действительно похоже на существующий город,
|
||||
* село, посёлок или другой населённый пункт, а не на произвольное слово с
|
||||
* заглавной буквы после якоря.
|
||||
*
|
||||
* <p>Не только официальные города (~1100 по классификатору): перепись
|
||||
* добавляет сёла, деревни, хутора, станицы — «рп. Ильинское», «с. Кукуево»
|
||||
* из ТЗ находятся ровно за счёт неё. Какой конкретно тип населённого пункта
|
||||
* стоит перед названием, определяет якорь самого правила в {@link RuleRegistry},
|
||||
* а не этот словарь — он только подтверждает, что название реальное.
|
||||
*
|
||||
* <p>Сравнение по началу слова, а не точным совпадением: падежные окончания
|
||||
* («в Москве», «из Казани») тем самым покрываются без отдельного разбора
|
||||
* морфологии, как и у известных людей в {@link NameDictionary}.
|
||||
*/
|
||||
public final class ToponymDictionary {
|
||||
|
||||
private static final Set<String> SETTLEMENT_STEMS = load("/names/settlements.txt").stream()
|
||||
.map(Declension::withoutInflectedEnding)
|
||||
.collect(Collectors.toUnmodifiableSet());
|
||||
|
||||
private ToponymDictionary() {
|
||||
}
|
||||
|
||||
/**
|
||||
* Похоже ли значение на название населённого пункта из словаря в любом
|
||||
* падеже.
|
||||
*
|
||||
* <p>Названия на согласную склоняются добавлением окончания («Тамбов» →
|
||||
* «Тамбове»), поэтому начало слова из словаря — уже достаточный признак.
|
||||
* Названия на гласную меняют последнюю букву («Москва» → «Москве»), для
|
||||
* них сравнение идёт по основе без неё — так же, как с личными именами
|
||||
* в {@link NameDictionary}.
|
||||
*
|
||||
* <p>Проверяются префиксы значения по множеству, а не каждая из ~80 000
|
||||
* основ по значению: перебор списка на каждое совпадение правила был бы
|
||||
* на порядки дороже, чем нужно — префиксов у слова не больше, чем в нём букв.
|
||||
*/
|
||||
public static boolean isKnownSettlement(String value) {
|
||||
String lower = value.strip().toLowerCase(Locale.ROOT);
|
||||
for (int length = lower.length(); length > 0; length--) {
|
||||
if (SETTLEMENT_STEMS.contains(lower.substring(0, length))) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private static Set<String> load(String resource) {
|
||||
try (InputStream in = ToponymDictionary.class.getResourceAsStream(resource)) {
|
||||
if (in == null) {
|
||||
throw new IllegalStateException("Словарь не найден в сборке: " + resource);
|
||||
}
|
||||
try (BufferedReader reader = new BufferedReader(new InputStreamReader(in, StandardCharsets.UTF_8))) {
|
||||
return reader.lines()
|
||||
.map(String::trim)
|
||||
.filter(line -> !line.isEmpty() && !line.startsWith("#"))
|
||||
.collect(Collectors.toUnmodifiableSet());
|
||||
}
|
||||
} catch (IOException e) {
|
||||
throw new UncheckedIOException("Не удалось прочитать словарь " + resource, e);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
package ru.pdguard.detect;
|
||||
|
||||
/**
|
||||
* Проверки контрольных сумм. Отсекают случайные числовые последовательности,
|
||||
* которые по форме похожи на ПД, но ими не являются.
|
||||
*/
|
||||
public final class Validators {
|
||||
|
||||
private static final int[] INN_10 = {2, 4, 10, 3, 5, 9, 4, 6, 8};
|
||||
private static final int[] INN_12_A = {7, 2, 4, 10, 3, 5, 9, 4, 6, 8};
|
||||
private static final int[] INN_12_B = {3, 7, 2, 4, 10, 3, 5, 9, 4, 6, 8};
|
||||
|
||||
private Validators() {
|
||||
}
|
||||
|
||||
/** Алгоритм Луна: номер платёжной карты, 13–19 цифр. */
|
||||
public static boolean luhn(String value) {
|
||||
int sum = 0;
|
||||
int digits = 0;
|
||||
boolean doubled = false;
|
||||
for (int i = value.length() - 1; i >= 0; i--) {
|
||||
char c = value.charAt(i);
|
||||
if (!Character.isDigit(c)) {
|
||||
continue;
|
||||
}
|
||||
int d = c - '0';
|
||||
digits++;
|
||||
if (doubled) {
|
||||
d *= 2;
|
||||
if (d > 9) {
|
||||
d -= 9;
|
||||
}
|
||||
}
|
||||
sum += d;
|
||||
doubled = !doubled;
|
||||
}
|
||||
return digits >= 13 && digits <= 19 && sum % 10 == 0;
|
||||
}
|
||||
|
||||
/** Контрольная сумма ИНН: 10 знаков у юрлица, 12 у физлица. */
|
||||
public static boolean inn(String value) {
|
||||
int[] d = digits(value);
|
||||
if (d.length == 10) {
|
||||
return d[9] == checksum(d, INN_10);
|
||||
}
|
||||
if (d.length == 12) {
|
||||
return d[10] == checksum(d, INN_12_A) && d[11] == checksum(d, INN_12_B);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/** Контрольная сумма СНИЛС: 11 знаков, последние два — контрольные. */
|
||||
public static boolean snils(String value) {
|
||||
int[] d = digits(value);
|
||||
if (d.length != 11) {
|
||||
return false;
|
||||
}
|
||||
int sum = 0;
|
||||
for (int i = 0; i < 9; i++) {
|
||||
sum += d[i] * (9 - i);
|
||||
}
|
||||
int control = snilsControl(sum);
|
||||
return control == d[9] * 10 + d[10];
|
||||
}
|
||||
|
||||
/** Контрольное число СНИЛС по сумме первых девяти цифр. */
|
||||
private static int snilsControl(int sum) {
|
||||
if (sum < 100) {
|
||||
return sum;
|
||||
}
|
||||
if (sum == 100 || sum == 101) {
|
||||
return 0;
|
||||
}
|
||||
return sum % 101 % 100;
|
||||
}
|
||||
|
||||
/** Контрольная сумма ОГРН: первые 12 цифр по модулю 11, младший разряд — 13-я цифра. */
|
||||
public static boolean ogrn(String value) {
|
||||
int[] d = digits(value);
|
||||
return d.length == 13 && d[12] == modReduce(d, 12, 11);
|
||||
}
|
||||
|
||||
/** Контрольная сумма ОГРНИП: первые 14 цифр по модулю 13, младший разряд — 15-я цифра. */
|
||||
public static boolean ogrnip(String value) {
|
||||
int[] d = digits(value);
|
||||
return d.length == 15 && d[14] == modReduce(d, 14, 13);
|
||||
}
|
||||
|
||||
/**
|
||||
* Остаток от деления первых {@code count} цифр как одного числа на {@code divisor},
|
||||
* взятый по младшему разряду. Числовое накопление по цифрам, а не парсинг строки
|
||||
* в {@code long}: у ОГРНИП 14 цифр — на грани переполнения {@code int}, и это тот же
|
||||
* приём, что уже применяется к самой длинной последовательности в {@link #luhn}.
|
||||
*/
|
||||
private static int modReduce(int[] d, int count, int divisor) {
|
||||
long remainder = 0;
|
||||
for (int i = 0; i < count; i++) {
|
||||
remainder = (remainder * 10 + d[i]) % divisor;
|
||||
}
|
||||
return (int) (remainder % 10);
|
||||
}
|
||||
|
||||
/**
|
||||
* Дата в числовой записи при любом порядке частей: {@code 12.05.1985},
|
||||
* {@code 05/12/1985}, {@code 1985-05-12}. Отсекает похожие по форме
|
||||
* последовательности вроде {@code 192.168.1}.
|
||||
*/
|
||||
public static boolean date(String value) {
|
||||
// Запись с названием месяца словом в дополнительной проверке не нуждается:
|
||||
// «мая» само по себе однозначно указывает на дату.
|
||||
for (int i = 0; i < value.length(); i++) {
|
||||
if (Character.isLetter(value.charAt(i))) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
String[] parts = value.split("[.\\-/]");
|
||||
if (parts.length != 3) {
|
||||
return false;
|
||||
}
|
||||
int[] n = new int[3];
|
||||
for (int i = 0; i < 3; i++) {
|
||||
if (parts[i].isEmpty() || parts[i].length() > 4) {
|
||||
return false;
|
||||
}
|
||||
n[i] = Integer.parseInt(parts[i]);
|
||||
}
|
||||
for (int y = 0; y < 3; y++) {
|
||||
if (parts[y].length() == 4) {
|
||||
return n[y] >= 1900 && n[y] <= 2100 && dayAndMonth(n[(y + 1) % 3], n[(y + 2) % 3]);
|
||||
}
|
||||
}
|
||||
// Год записан двумя цифрами: достаточно, чтобы день и месяц нашлись в любой паре.
|
||||
return dayAndMonth(n[0], n[1]) || dayAndMonth(n[1], n[2]) || dayAndMonth(n[0], n[2]);
|
||||
}
|
||||
|
||||
/** Пара чисел похожа на «день и месяц» в любом порядке. */
|
||||
private static boolean dayAndMonth(int a, int b) {
|
||||
return (a >= 1 && a <= 31 && b >= 1 && b <= 12) || (b >= 1 && b <= 31 && a >= 1 && a <= 12);
|
||||
}
|
||||
|
||||
private static int checksum(int[] d, int[] weights) {
|
||||
int sum = 0;
|
||||
for (int i = 0; i < weights.length; i++) {
|
||||
sum += d[i] * weights[i];
|
||||
}
|
||||
return sum % 11 % 10;
|
||||
}
|
||||
|
||||
private static int[] digits(String value) {
|
||||
int[] out = new int[value.length()];
|
||||
int n = 0;
|
||||
for (int i = 0; i < value.length(); i++) {
|
||||
char c = value.charAt(i);
|
||||
if (Character.isDigit(c)) {
|
||||
out[n++] = c - '0';
|
||||
}
|
||||
}
|
||||
int[] trimmed = new int[n];
|
||||
System.arraycopy(out, 0, trimmed, 0, n);
|
||||
return trimmed;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
package ru.pdguard.detect;
|
||||
|
||||
import java.io.BufferedReader;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStreamReader;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* Разбиение текста на подслова так, как это делает токенизатор BERT.
|
||||
*
|
||||
* <p>Своя реализация вместо готовой библиотеки: единственная альтернатива на Java
|
||||
* подтягивает нативные библиотеки во время работы, а контейнер должен подниматься
|
||||
* без обращений в сеть. Правила здесь простые и целиком описаны форматом словаря:
|
||||
* разбить по пробелам и знакам препинания, затем каждое слово — жадно по самой
|
||||
* длинной подходящей записи словаря, продолжения помечаются префиксом «##».
|
||||
*
|
||||
* <p>Для каждого подслова сохраняются границы в исходном тексте: без них разметку
|
||||
* модели не перенести обратно на строку.
|
||||
*/
|
||||
final class WordPiece {
|
||||
|
||||
/** Слово длиннее этого целиком заменяется на «неизвестно» — правило BERT. */
|
||||
private static final int MAX_WORD_CHARS = 100;
|
||||
|
||||
private static final String CONTINUATION = "##";
|
||||
|
||||
/** Подслово и его границы в исходном тексте. */
|
||||
record Piece(int id, int start, int end) {
|
||||
}
|
||||
|
||||
private final Map<String, Integer> vocabulary;
|
||||
private final int unknownId;
|
||||
private final int classifyId;
|
||||
private final int separatorId;
|
||||
|
||||
private WordPiece(Map<String, Integer> vocabulary) {
|
||||
this.vocabulary = vocabulary;
|
||||
this.unknownId = required(vocabulary, "[UNK]");
|
||||
this.classifyId = required(vocabulary, "[CLS]");
|
||||
this.separatorId = required(vocabulary, "[SEP]");
|
||||
}
|
||||
|
||||
static WordPiece fromVocabulary(Path vocabularyFile) throws IOException {
|
||||
Map<String, Integer> vocabulary = HashMap.newHashMap(140_000);
|
||||
try (BufferedReader reader = new BufferedReader(
|
||||
new InputStreamReader(Files.newInputStream(vocabularyFile), StandardCharsets.UTF_8))) {
|
||||
String line;
|
||||
int index = 0;
|
||||
while ((line = reader.readLine()) != null) {
|
||||
vocabulary.putIfAbsent(line.strip(), index++);
|
||||
}
|
||||
}
|
||||
return new WordPiece(vocabulary);
|
||||
}
|
||||
|
||||
int classifyId() {
|
||||
return classifyId;
|
||||
}
|
||||
|
||||
int separatorId() {
|
||||
return separatorId;
|
||||
}
|
||||
|
||||
/** Подслова текста в порядке следования; служебные токены сюда не входят. */
|
||||
List<Piece> split(String text, int maxPieces) {
|
||||
List<Piece> pieces = new ArrayList<>();
|
||||
for (int[] word : words(text)) {
|
||||
if (pieces.size() >= maxPieces) {
|
||||
break;
|
||||
}
|
||||
splitWord(text, word[0], word[1], pieces, maxPieces);
|
||||
}
|
||||
return pieces;
|
||||
}
|
||||
|
||||
/**
|
||||
* Границы слов: разделителями считаются пробельные символы и знаки препинания,
|
||||
* причём знак препинания сам становится отдельным словом.
|
||||
*/
|
||||
private static List<int[]> words(String text) {
|
||||
List<int[]> result = new ArrayList<>();
|
||||
int start = -1;
|
||||
for (int i = 0; i < text.length(); i++) {
|
||||
char c = text.charAt(i);
|
||||
boolean separator = Character.isWhitespace(c) || isPunctuation(c);
|
||||
if (separator) {
|
||||
if (start >= 0) {
|
||||
result.add(new int[]{start, i});
|
||||
start = -1;
|
||||
}
|
||||
if (isPunctuation(c)) {
|
||||
result.add(new int[]{i, i + 1});
|
||||
}
|
||||
} else if (start < 0) {
|
||||
start = i;
|
||||
}
|
||||
}
|
||||
if (start >= 0) {
|
||||
result.add(new int[]{start, text.length()});
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
private static boolean isPunctuation(char c) {
|
||||
if (Character.isLetterOrDigit(c)) {
|
||||
return false;
|
||||
}
|
||||
return !Character.isWhitespace(c);
|
||||
}
|
||||
|
||||
private void splitWord(String text, int from, int to, List<Piece> sink, int maxPieces) {
|
||||
if (to - from > MAX_WORD_CHARS) {
|
||||
sink.add(new Piece(unknownId, from, to));
|
||||
return;
|
||||
}
|
||||
int cursor = from;
|
||||
List<Piece> ofThisWord = new ArrayList<>();
|
||||
while (cursor < to) {
|
||||
int end = to;
|
||||
Integer id = null;
|
||||
while (end > cursor) {
|
||||
String candidate = text.substring(cursor, end);
|
||||
String lookup = cursor == from ? candidate : CONTINUATION + candidate;
|
||||
id = vocabulary.get(lookup);
|
||||
if (id != null) {
|
||||
break;
|
||||
}
|
||||
end--;
|
||||
}
|
||||
if (id == null) {
|
||||
// Ни одна часть слова не нашлась — слово целиком неизвестно.
|
||||
sink.add(new Piece(unknownId, from, to));
|
||||
return;
|
||||
}
|
||||
ofThisWord.add(new Piece(id, cursor, end));
|
||||
cursor = end;
|
||||
}
|
||||
for (Piece piece : ofThisWord) {
|
||||
if (sink.size() >= maxPieces) {
|
||||
return;
|
||||
}
|
||||
sink.add(piece);
|
||||
}
|
||||
}
|
||||
|
||||
private static int required(Map<String, Integer> vocabulary, String token) {
|
||||
Integer id = vocabulary.get(token);
|
||||
if (id == null) {
|
||||
throw new IllegalStateException("В словаре нет служебного токена " + token);
|
||||
}
|
||||
return id;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
package ru.pdguard.mask;
|
||||
|
||||
import java.util.HashMap;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Map;
|
||||
import java.util.function.BiFunction;
|
||||
|
||||
/**
|
||||
* Состояние одной операции маскирования.
|
||||
*
|
||||
* <p>Одинаковые значения в пределах запроса получают одинаковую замену: если
|
||||
* клиент упомянут дважды, в тексте дважды окажется {@code [FIO_1]}, и смысл
|
||||
* запроса для модели сохранится.
|
||||
*
|
||||
* <p>Экземпляр живёт в рамках одного вызова и между потоками не разделяется.
|
||||
*/
|
||||
public final class MaskContext {
|
||||
|
||||
/** Разделитель ключа; в названии типа ПД этот знак не встречается. */
|
||||
private static final char SEPARATOR = '#';
|
||||
|
||||
private final Map<String, String> assigned = new HashMap<>();
|
||||
private final Map<String, Integer> counters = new HashMap<>();
|
||||
private final Map<String, String> restorations = new LinkedHashMap<>();
|
||||
|
||||
/**
|
||||
* Замена для значения; при повторе возвращается ранее выданная.
|
||||
*
|
||||
* @param factory получает тип ПД и порядковый номер значения этого типа
|
||||
*/
|
||||
public String resolve(String type, String value, BiFunction<String, Integer, String> factory) {
|
||||
return assigned.computeIfAbsent(type + SEPARATOR + value, key -> {
|
||||
String replacement = factory.apply(type, counters.merge(type, 1, Integer::sum));
|
||||
restorations.put(replacement, value);
|
||||
return replacement;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Чем заменять обратно: подстановка к исходному значению. Нужно там, где текст
|
||||
* возвращается не целиком, а изменённым — например, в ответе языковой модели.
|
||||
*/
|
||||
public Map<String, String> restorations() {
|
||||
return Map.copyOf(restorations);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
package ru.pdguard.mask;
|
||||
|
||||
/** Чем заменяется найденное значение. Выбирается настройками системы-потребителя. */
|
||||
public enum MaskMode {
|
||||
|
||||
/** Звёздочки с сохранением длины и разделителей: {@code 45** ****56}. */
|
||||
MASK,
|
||||
|
||||
/** Порядковый токен: {@code [FIO_1]}. Компактно и однозначно обратимо. */
|
||||
TOKEN,
|
||||
|
||||
/** Правдоподобная подстановка: вместо настоящего имени — вымышленное. */
|
||||
SYNTHETIC
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
package ru.pdguard.mask;
|
||||
|
||||
import org.springframework.stereotype.Component;
|
||||
import ru.pdguard.detect.RuleRegistry;
|
||||
|
||||
import java.util.Map;
|
||||
import java.util.function.UnaryOperator;
|
||||
|
||||
/**
|
||||
* Превращает найденное значение в замену согласно настройкам системы.
|
||||
*
|
||||
* <p>Тип, для которого вид маски не задан, скрывается звёздочками целиком —
|
||||
* безопасное поведение по умолчанию для вновь добавленных правил.
|
||||
*/
|
||||
@Component
|
||||
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(
|
||||
Map.entry(RuleRegistry.EMAIL, Strategies::email),
|
||||
Map.entry(RuleRegistry.PHONE, EDGES),
|
||||
Map.entry(RuleRegistry.CARD, EDGES),
|
||||
Map.entry(RuleRegistry.INN, EDGES),
|
||||
Map.entry(RuleRegistry.SNILS, EDGES),
|
||||
Map.entry(RuleRegistry.PASSPORT, EDGES),
|
||||
Map.entry(RuleRegistry.DRIVER_LICENSE, EDGES),
|
||||
Map.entry(RuleRegistry.DEPT_CODE, EDGES),
|
||||
// У этих документов серия короткая — две цифры или две буквы. Оставь мы
|
||||
// первые два знака, серия оказалась бы открыта целиком, поэтому видны
|
||||
// только последние. У паспорта РФ и водительского удостоверения серия
|
||||
// из четырёх знаков, там открывается половина.
|
||||
Map.entry(RuleRegistry.FOREIGN_PASSPORT, SHORT_SERIES),
|
||||
Map.entry(RuleRegistry.MILITARY_ID, SHORT_SERIES),
|
||||
Map.entry(RuleRegistry.BIRTH_CERTIFICATE, SHORT_SERIES),
|
||||
Map.entry(RuleRegistry.MEDICAL_POLICY, EDGES),
|
||||
Map.entry(RuleRegistry.CARDHOLDER, Strategies::initials),
|
||||
Map.entry(RuleRegistry.FIO, Strategies::initials),
|
||||
|
||||
// Код проверки и пин-код не показываем даже частично: у них слишком
|
||||
// мало знаков, чтобы открывать хотя бы один.
|
||||
Map.entry(RuleRegistry.CVV, Strategies::stars),
|
||||
Map.entry(RuleRegistry.PIN, Strategies::stars),
|
||||
|
||||
Map.entry(RuleRegistry.PASSPORT_ISSUER, Strategies::stars),
|
||||
|
||||
// У дат сохраняем разделители: модель видит, что это дата, но не какая.
|
||||
Map.entry(RuleRegistry.BIRTH_DATE, Strategies::starsKeepingPunctuation),
|
||||
Map.entry(RuleRegistry.PASSPORT_DATE, Strategies::starsKeepingPunctuation),
|
||||
Map.entry(RuleRegistry.DATE, Strategies::starsKeepingPunctuation),
|
||||
|
||||
Map.entry(RuleRegistry.ADDRESS_COUNTRY, Strategies::stars),
|
||||
Map.entry(RuleRegistry.ADDRESS_POSTCODE, Strategies::stars),
|
||||
Map.entry(RuleRegistry.ADDRESS_CITY, Strategies::stars),
|
||||
Map.entry(RuleRegistry.ADDRESS_STREET, Strategies::stars),
|
||||
Map.entry(RuleRegistry.ADDRESS_HOUSE, Strategies::stars),
|
||||
Map.entry(RuleRegistry.ADDRESS_FLAT, Strategies::stars),
|
||||
Map.entry(RuleRegistry.ADDRESS_REGION, Strategies::stars),
|
||||
Map.entry(RuleRegistry.ADDRESS_DISTRICT, Strategies::stars),
|
||||
Map.entry(RuleRegistry.BIRTH_PLACE, Strategies::stars),
|
||||
Map.entry(RuleRegistry.CITIZENSHIP, Strategies::stars),
|
||||
|
||||
Map.entry(RuleRegistry.ACCOUNT_NUMBER, EDGES),
|
||||
Map.entry(RuleRegistry.OGRN, EDGES),
|
||||
Map.entry(RuleRegistry.OGRNIP, EDGES),
|
||||
Map.entry(RuleRegistry.KPP, EDGES),
|
||||
// Срок действия карты — разделитель виден, сам месяц/год нет.
|
||||
Map.entry(RuleRegistry.CARD_EXPIRY, Strategies::starsKeepingPunctuation),
|
||||
Map.entry(RuleRegistry.BIK, Strategies::stars),
|
||||
Map.entry(RuleRegistry.INCOME, Strategies::stars),
|
||||
Map.entry(RuleRegistry.BIOMETRIC, Strategies::stars)
|
||||
);
|
||||
|
||||
public String mask(String type, String value, MaskMode mode, MaskContext context) {
|
||||
return switch (mode) {
|
||||
case MASK -> BY_TYPE.getOrDefault(type, Strategies::stars).apply(value);
|
||||
case TOKEN -> context.resolve(type, value, (t, n) -> "[" + t + "_" + n + "]");
|
||||
case SYNTHETIC -> context.resolve(type, value, (t, n) -> Synthetic.forType(t, value, n));
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
package ru.pdguard.mask;
|
||||
|
||||
/**
|
||||
* Способы преобразования найденного значения в маску.
|
||||
*
|
||||
* <p>Все стратегии сохраняют длину и разделители исходного значения: так
|
||||
* замаскированный текст остаётся читаемым для LLM и минимально отличается
|
||||
* от эталона при посимвольном сравнении.
|
||||
*/
|
||||
public final class Strategies {
|
||||
|
||||
private static final char MASK = '*';
|
||||
|
||||
private Strategies() {
|
||||
}
|
||||
|
||||
/** Каждый непробельный символ заменяется на «*». */
|
||||
public static String stars(String value) {
|
||||
StringBuilder sb = new StringBuilder(value.length());
|
||||
for (int i = 0; i < value.length(); i++) {
|
||||
char c = value.charAt(i);
|
||||
sb.append(Character.isWhitespace(c) ? c : MASK);
|
||||
}
|
||||
return sb.toString();
|
||||
}
|
||||
|
||||
/**
|
||||
* Скрывает буквы и цифры, оставляя разделители: {@code 12.05.1985} → {@code **.**.****},
|
||||
* {@code 12 мая 1985} → {@code ** *** ****}. Форма записи остаётся видна модели,
|
||||
* само значение — нет.
|
||||
*/
|
||||
public static String starsKeepingPunctuation(String value) {
|
||||
StringBuilder sb = new StringBuilder(value.length());
|
||||
for (int i = 0; i < value.length(); i++) {
|
||||
char c = value.charAt(i);
|
||||
sb.append(Character.isLetterOrDigit(c) ? MASK : c);
|
||||
}
|
||||
return sb.toString();
|
||||
}
|
||||
|
||||
/**
|
||||
* Оставляет первые и последние значащие символы, остальные скрывает,
|
||||
* разделители сохраняет: {@code 4509 123456} → {@code 45** ****56}.
|
||||
*/
|
||||
public static String keepEdges(String value, int head, int tail) {
|
||||
int significant = 0;
|
||||
for (int i = 0; i < value.length(); i++) {
|
||||
if (Character.isLetterOrDigit(value.charAt(i))) {
|
||||
significant++;
|
||||
}
|
||||
}
|
||||
if (significant <= head + tail) {
|
||||
return stars(value);
|
||||
}
|
||||
StringBuilder sb = new StringBuilder(value.length());
|
||||
int seen = 0;
|
||||
for (int i = 0; i < value.length(); i++) {
|
||||
char c = value.charAt(i);
|
||||
if (!Character.isLetterOrDigit(c)) {
|
||||
sb.append(c);
|
||||
continue;
|
||||
}
|
||||
boolean visible = seen < head || seen >= significant - tail;
|
||||
sb.append(visible ? c : MASK);
|
||||
seen++;
|
||||
}
|
||||
return sb.toString();
|
||||
}
|
||||
|
||||
/** ФИО превращается в инициалы: {@code Иванов Иван Иванович} → {@code И. И. И.} */
|
||||
public static String initials(String value) {
|
||||
StringBuilder sb = new StringBuilder();
|
||||
boolean wordStart = true;
|
||||
for (int i = 0; i < value.length(); i++) {
|
||||
char c = value.charAt(i);
|
||||
if (Character.isLetter(c)) {
|
||||
if (wordStart) {
|
||||
if (!sb.isEmpty()) {
|
||||
sb.append(' ');
|
||||
}
|
||||
sb.append(Character.toUpperCase(c)).append('.');
|
||||
wordStart = false;
|
||||
}
|
||||
} else {
|
||||
wordStart = true;
|
||||
}
|
||||
}
|
||||
return sb.isEmpty() ? stars(value) : sb.toString();
|
||||
}
|
||||
|
||||
/**
|
||||
* Адрес почты: видны первая буква имени ящика, первая буква домена и зона.
|
||||
* {@code ivan.petrov@mail.ru} → {@code i**********@m***.ru}
|
||||
*/
|
||||
public static String email(String value) {
|
||||
int at = value.lastIndexOf('@');
|
||||
if (at <= 0 || at == value.length() - 1) {
|
||||
return stars(value);
|
||||
}
|
||||
String local = value.substring(0, at);
|
||||
String domain = value.substring(at + 1);
|
||||
int dot = domain.lastIndexOf('.');
|
||||
if (dot <= 0) {
|
||||
return hideTail(local) + '@' + hideTail(domain);
|
||||
}
|
||||
return hideTail(local) + '@' + hideTail(domain.substring(0, dot)) + domain.substring(dot);
|
||||
}
|
||||
|
||||
private static String hideTail(String part) {
|
||||
if (part.length() <= 1) {
|
||||
return part;
|
||||
}
|
||||
return part.charAt(0) + String.valueOf(MASK).repeat(part.length() - 1);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
package ru.pdguard.mask;
|
||||
|
||||
import ru.pdguard.detect.RuleRegistry;
|
||||
|
||||
/**
|
||||
* Правдоподобные подставные значения вместо настоящих.
|
||||
*
|
||||
* <p>Модель получает текст, который выглядит естественно, и качество ответа
|
||||
* страдает меньше, чем от звёздочек. Значения детерминированы: одно и то же
|
||||
* исходное значение всегда даёт одну и ту же подстановку.
|
||||
*/
|
||||
final class Synthetic {
|
||||
|
||||
private static final String[] SURNAMES =
|
||||
{"Лаврентьев", "Мещеряков", "Тихомиров", "Ясенев", "Бурмистров", "Кольцов"};
|
||||
private static final String[] NAMES = {"Артём", "Никита", "Глеб", "Тимур", "Марк", "Лев"};
|
||||
private static final String[] PATRONYMICS =
|
||||
{"Артёмович", "Никитич", "Глебович", "Тимурович", "Маркович", "Львович"};
|
||||
private static final String[] DOMAINS = {"example.com", "example.org", "example.net"};
|
||||
|
||||
private Synthetic() {
|
||||
}
|
||||
|
||||
static String forType(String type, String value, int ordinal) {
|
||||
int seed = value.hashCode() & Integer.MAX_VALUE;
|
||||
return switch (type) {
|
||||
case RuleRegistry.FIO -> pick(SURNAMES, seed) + " " + pick(NAMES, seed >> 3)
|
||||
+ " " + pick(PATRONYMICS, seed >> 6);
|
||||
case RuleRegistry.CARDHOLDER -> "IVAN PETROV";
|
||||
case RuleRegistry.EMAIL -> "user" + ordinal + "@" + pick(DOMAINS, seed);
|
||||
case RuleRegistry.PHONE -> "+7 9" + digits(seed, 2) + " " + digits(seed >> 4, 3)
|
||||
+ "-" + digits(seed >> 8, 2) + "-" + digits(seed >> 12, 2);
|
||||
case RuleRegistry.CARD -> luhnCard(seed);
|
||||
case RuleRegistry.PASSPORT, RuleRegistry.DRIVER_LICENSE, RuleRegistry.FOREIGN_PASSPORT,
|
||||
RuleRegistry.MILITARY_ID -> digits(seed, 4) + " " + digits(seed >> 6, 6);
|
||||
case RuleRegistry.INN -> digits(seed, 12);
|
||||
case RuleRegistry.MEDICAL_POLICY -> digits(seed, 16);
|
||||
case RuleRegistry.SNILS -> digits(seed, 3) + "-" + digits(seed >> 4, 3)
|
||||
+ "-" + digits(seed >> 8, 3) + " " + digits(seed >> 12, 2);
|
||||
case RuleRegistry.BIRTH_DATE, RuleRegistry.PASSPORT_DATE, RuleRegistry.DATE -> syntheticDate(seed);
|
||||
case RuleRegistry.ADDRESS_CITY -> "Зареченск";
|
||||
case RuleRegistry.ADDRESS_STREET -> "Сосновая";
|
||||
case RuleRegistry.ADDRESS_HOUSE -> String.valueOf(1 + Math.floorMod(seed, 90));
|
||||
case RuleRegistry.ADDRESS_FLAT -> String.valueOf(1 + Math.floorMod(seed, 200));
|
||||
case RuleRegistry.ADDRESS_POSTCODE -> digits(seed, 6);
|
||||
case RuleRegistry.ADDRESS_COUNTRY -> "Заречье";
|
||||
case RuleRegistry.ADDRESS_REGION -> "Заречная область";
|
||||
case RuleRegistry.ADDRESS_DISTRICT -> "Сосновый район";
|
||||
case RuleRegistry.CVV -> digits(seed, 3);
|
||||
case RuleRegistry.PIN -> digits(seed, 4);
|
||||
// Для остальных типов правдоподобной замены нет — отдаём токен.
|
||||
default -> "[" + type + "_" + ordinal + "]";
|
||||
};
|
||||
}
|
||||
|
||||
private static String pick(String[] options, int seed) {
|
||||
return options[Math.floorMod(seed, options.length)];
|
||||
}
|
||||
|
||||
private static String syntheticDate(int seed) {
|
||||
int day = 1 + Math.floorMod(seed, 28);
|
||||
int month = 1 + Math.floorMod(seed >> 5, 12);
|
||||
int year = 1960 + Math.floorMod(seed >> 9, 45);
|
||||
return String.format("%02d.%02d.%d", day, month, year);
|
||||
}
|
||||
|
||||
private static String digits(int seed, int count) {
|
||||
StringBuilder sb = new StringBuilder(count);
|
||||
int value = Math.abs(seed);
|
||||
for (int i = 0; i < count; i++) {
|
||||
sb.append((char) ('0' + Math.floorMod(value, 10)));
|
||||
value = value / 10 + (i + 1) * 7;
|
||||
}
|
||||
return sb.toString();
|
||||
}
|
||||
|
||||
/** Номер карты, проходящий проверку алгоритмом Луна: подстановка должна выглядеть настоящей. */
|
||||
private static String luhnCard(int seed) {
|
||||
StringBuilder body = new StringBuilder("4").append(digits(seed, 14));
|
||||
int sum = 0;
|
||||
boolean doubled = true;
|
||||
for (int i = body.length() - 1; i >= 0; i--) {
|
||||
int d = body.charAt(i) - '0';
|
||||
if (doubled) {
|
||||
d *= 2;
|
||||
if (d > 9) {
|
||||
d -= 9;
|
||||
}
|
||||
}
|
||||
sum += d;
|
||||
doubled = !doubled;
|
||||
}
|
||||
body.append((10 - sum % 10) % 10);
|
||||
return body.substring(0, 4) + " " + body.substring(4, 8) + " "
|
||||
+ body.substring(8, 12) + " " + body.substring(12);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user