feat: улучшение regexp + деплой одного инстанса
This commit is contained in:
@@ -50,6 +50,9 @@ public class ProxyResource {
|
||||
@JsonProperty("replaced") Map<String, String> replaced,
|
||||
@JsonProperty("llm") String llm,
|
||||
@JsonProperty("error") String error) {
|
||||
public ProxyResponse {
|
||||
replaced = replaced == null ? null : Map.copyOf(replaced);
|
||||
}
|
||||
}
|
||||
|
||||
private final Pipeline pipeline;
|
||||
|
||||
@@ -39,6 +39,10 @@ public class SystemsConfig {
|
||||
/** Описание одной системы в файле настроек. */
|
||||
public record SystemEntry(Boolean enabled, Boolean demask, String maskMode,
|
||||
List<String> types, List<String> requireCompanion, String key) {
|
||||
public SystemEntry {
|
||||
types = types == null ? null : List.copyOf(types);
|
||||
requireCompanion = requireCompanion == null ? null : List.copyOf(requireCompanion);
|
||||
}
|
||||
}
|
||||
|
||||
private final Path file;
|
||||
|
||||
@@ -7,6 +7,7 @@ import javax.crypto.Cipher;
|
||||
import javax.crypto.spec.GCMParameterSpec;
|
||||
import javax.crypto.spec.SecretKeySpec;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.security.GeneralSecurityException;
|
||||
import java.security.SecureRandom;
|
||||
import java.util.Base64;
|
||||
import java.util.HexFormat;
|
||||
@@ -62,7 +63,7 @@ public class PayloadCipher {
|
||||
System.arraycopy(iv, 0, combined, 0, iv.length);
|
||||
System.arraycopy(encrypted, 0, combined, iv.length, encrypted.length);
|
||||
return Base64.getEncoder().encodeToString(combined);
|
||||
} catch (Exception e) {
|
||||
} catch (GeneralSecurityException e) {
|
||||
throw new IllegalStateException("Не удалось зашифровать персональные данные", e);
|
||||
}
|
||||
}
|
||||
@@ -80,7 +81,7 @@ public class PayloadCipher {
|
||||
cipher.init(Cipher.DECRYPT_MODE, key, new GCMParameterSpec(GCM_TAG_BITS, iv));
|
||||
byte[] decrypted = cipher.doFinal(combined, iv.length, combined.length - iv.length);
|
||||
return new String(decrypted, StandardCharsets.UTF_8);
|
||||
} catch (Exception e) {
|
||||
} catch (GeneralSecurityException | IllegalArgumentException e) {
|
||||
throw new IllegalStateException("Не удалось расшифровать персональные данные", e);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -224,11 +224,30 @@ public class Pipeline {
|
||||
return spans;
|
||||
}
|
||||
return spans.stream()
|
||||
.filter(span -> !PdTypes.FIO.equals(span.type())
|
||||
|| !NameDictionary.isWellKnown(text.substring(span.start(), span.end())))
|
||||
.filter(span -> !PdTypes.FIO.equals(span.type()) || !isWellKnownHere(text, span))
|
||||
.toList();
|
||||
}
|
||||
|
||||
/**
|
||||
* Известный человек по самому спану («Пушкина») или по спану вместе со следующим
|
||||
* словом («Ярослав» + «Мудрый»): правило-однослов ловит имя правителя отдельно от
|
||||
* прозвища, а {@code REGNAL_NAME} распознаёт только двухсловную форму целиком.
|
||||
*/
|
||||
private static boolean isWellKnownHere(String text, Span span) {
|
||||
if (NameDictionary.isWellKnown(text.substring(span.start(), span.end()))) {
|
||||
return true;
|
||||
}
|
||||
int wordStart = span.end();
|
||||
while (wordStart < text.length() && Character.isWhitespace(text.charAt(wordStart))) {
|
||||
wordStart++;
|
||||
}
|
||||
int wordEnd = wordStart;
|
||||
while (wordEnd < text.length() && Character.isLetter(text.charAt(wordEnd))) {
|
||||
wordEnd++;
|
||||
}
|
||||
return wordEnd > wordStart && NameDictionary.isWellKnown(text.substring(span.start(), wordEnd));
|
||||
}
|
||||
|
||||
/**
|
||||
* Убирает типы, которые опасны только в сочетании с другими ПД.
|
||||
* Пин-код в отрыве от номера карты не является персональными данными,
|
||||
@@ -251,7 +270,7 @@ public class Pipeline {
|
||||
}
|
||||
|
||||
/** Замаскированный текст вместе с таблицей обратной замены. */
|
||||
public record Masked(String text, java.util.Map<String, String> restorations) {
|
||||
public record Masked(String text, Map<String, String> restorations) {
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -267,7 +286,7 @@ public class Pipeline {
|
||||
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());
|
||||
return new Masked(text, Map.of());
|
||||
}
|
||||
MaskContext context = new MaskContext();
|
||||
String masked = apply(text, spans, reversible, context);
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
package ru.pdguard.detect;
|
||||
|
||||
import java.util.Locale;
|
||||
import java.util.Set;
|
||||
|
||||
/**
|
||||
* Словарь названий стран — проверка того, что значение, пойманное правилом
|
||||
* {@code CITIZENSHIP}, действительно похоже на страну, а не на произвольное
|
||||
* слово с заглавной буквы после якоря «гражданство».
|
||||
*
|
||||
* <p>Сравнение по началу слова, а не точным совпадением: падежные окончания
|
||||
* («в России», «из Казахстана») и формы прилагательных («российская»,
|
||||
* «российское») тем самым покрываются без отдельного разбора морфологии.
|
||||
* Основа «российск» покрывает и «Российская», и «российская», и «российское».
|
||||
*/
|
||||
public final class CountryDictionary {
|
||||
|
||||
private static final Set<String> COUNTRY_STEMS = ResourceLoader.set("/names/countries.txt");
|
||||
|
||||
private CountryDictionary() {
|
||||
}
|
||||
|
||||
/**
|
||||
* Похоже ли значение на название страны из словаря в любом падеже и регистре.
|
||||
*
|
||||
* <p>Проверяются префиксы значения по множеству, а не каждая основа по
|
||||
* значению: префиксов у слова не больше, чем в нём букв.
|
||||
*/
|
||||
public static boolean isKnownCountry(String value) {
|
||||
String lower = value.strip().toLowerCase(Locale.ROOT);
|
||||
for (int length = lower.length(); length > 0; length--) {
|
||||
if (COUNTRY_STEMS.contains(lower.substring(0, length))) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -153,6 +153,25 @@ public final class NameDictionary {
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Слово само по себе похоже на имя, фамилию или отчество — без ролевого слова
|
||||
* или соседнего личного имени рядом, самое слабое основание для ФИО. Точное
|
||||
* совпадение с личным именем принимается в любом регистре («иван» тоже имя),
|
||||
* а вот словообразовательная эвристика (фамилия/отчество по окончанию) —
|
||||
* только с заглавной буквы: без этого «законов», «домов», «холодов» —
|
||||
* обычные родительные падежи, а не фамилии — ложно матчились бы.
|
||||
*/
|
||||
public static boolean isStandaloneNameCandidate(String word) {
|
||||
String lower = word.toLowerCase(Locale.ROOT);
|
||||
if (GIVEN_NAMES.contains(lower)) {
|
||||
return true;
|
||||
}
|
||||
if (word.isEmpty() || !Character.isUpperCase(word.codePointAt(0))) {
|
||||
return false;
|
||||
}
|
||||
return isPatronymic(lower) || isSurname(lower);
|
||||
}
|
||||
|
||||
/** Отчество: Иванович, Петровна, Сидоровна. */
|
||||
private static boolean isPatronymic(String lower) {
|
||||
return lower.matches(".*(?:ович|евич|овна|евна|ична|ичн)$");
|
||||
|
||||
@@ -90,6 +90,8 @@ final class ResourceLoader {
|
||||
}
|
||||
} catch (IOException e) {
|
||||
// Битый файл не должен ронять работу: остаётся прежнее значение.
|
||||
java.util.logging.Logger.getLogger(ResourceLoader.class.getName())
|
||||
.fine("Не удалось перечитать файл " + path + ": " + e.getMessage());
|
||||
}
|
||||
return state.current;
|
||||
}
|
||||
|
||||
@@ -36,6 +36,18 @@ public class RuleRegistry {
|
||||
/** Якорное слово-основа: держатель карты, держателем и т.п. */
|
||||
private static final String HOLDER_STEM = "держател";
|
||||
|
||||
/** Разрыв между якорем и значением, когда между ними ролевое слово («ИНН плательщика»). */
|
||||
private static final String ROLE_GAP = "(?:\\s+[\\p{L}-]+){0,5}\\W{0,10}";
|
||||
|
||||
/**
|
||||
* То же самое, но только строчные слова-филлеры: ролевые слова перед значением
|
||||
* гражданства всегда строчные («бенефициара», «поручителя»), а само значение —
|
||||
* с заглавной («Республики», «Соединенные»). Обычный {@link #ROLE_GAP} жадно
|
||||
* поглощал бы и заглавное слово значения как будто это ролевое слово, оставляя
|
||||
* CITIZENSHIP_VALUE только хвост («Республики Беларусь» → «Беларусь»).
|
||||
*/
|
||||
private static final String CITIZENSHIP_GAP = "(?:\\s+\\p{Ll}[\\p{L}-]*){0,5}\\W{0,10}";
|
||||
|
||||
/**
|
||||
* Название улицы: от одного до трёх слов с заглавной буквы либо чисел —
|
||||
* «Тверская», «Малая Никитская», «8 Марта». Ограничение по форме обязательно:
|
||||
@@ -61,8 +73,13 @@ public class RuleRegistry {
|
||||
"\\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}";
|
||||
/**
|
||||
* Серия и номер: «4509 123456», «45 09 123456», «4509123456», «45 09 № 123456»,
|
||||
* а также с произвольным числом пробелов и словом «номер» между частями —
|
||||
* «12 34 номер 567890» (реальный кейс из бланка).
|
||||
*/
|
||||
private static final String SERIES_AND_NUMBER =
|
||||
"\\d{2}\\s*\\d{2}(?:\\s*(?:№|N|номер)\\s*|[\\s№N]{0,3})\\d{6}";
|
||||
|
||||
/**
|
||||
* Название месяца: полная форма («январь»), сокращение («янв») и плейсхолдер
|
||||
@@ -107,9 +124,21 @@ public class RuleRegistry {
|
||||
* «день и месяц» — это слова, а не дата. Ветка со словами требует пробела
|
||||
* перед словом ({@code \s+}), иначе она неоднозначна с веткой {@code \W},
|
||||
* которая тоже матчит пробелы, — это приводило к катастрофическому
|
||||
* возврату на длинных текстах.
|
||||
* возврату на длинных текстах. Отдельная ветка с дефисом нужна для слитных
|
||||
* слов без пробела внутри: «клиента-нерезидента» — дефис сам по себе ловится
|
||||
* веткой {@code \W}, но следующие за ним буквы без пробела перед ними не
|
||||
* покрывала ни одна ветка.
|
||||
*/
|
||||
private static final String DATE_GAP = "(?:\\s+\\([^)]*\\)|\\s+\\p{L}+|\\W){0,30}";
|
||||
private static final String DATE_GAP = "(?:\\s+\\([^)]*\\)|\\s+\\p{L}+|-\\p{L}+|\\W){0,30}";
|
||||
|
||||
/**
|
||||
* Значение гражданства: «рф»/«росс…»(любая форма, включая строчную «российское»)/
|
||||
* «республики X» — частые формы отдельным списком; последняя ветка — страна из
|
||||
* 1-4 слов с заглавной буквы («Армения», «Соединенные Штаты Америки»). Хвост
|
||||
* идёт после якоря «гражданств», поэтому «Двойное» перед якорем не попадёт.
|
||||
*/
|
||||
private static final String CITIZENSHIP_VALUE =
|
||||
"\\p{Lu}\\p{Ll}+(?:[\\s/]+\\p{Lu}\\p{Ll}+){0,3}|\\p{Ll}+(?:[\\s/]+\\p{Ll}+){0,3}";
|
||||
|
||||
/**
|
||||
* Слова, при которых адрес принадлежит организации, а не человеку:
|
||||
@@ -180,8 +209,9 @@ public class RuleRegistry {
|
||||
.groups(1, 2)
|
||||
.anchoredBy("сери"),
|
||||
|
||||
// Необязательное «серия»/«серии» между якорем и цифрами: «ВУ серия 12 34 номер 567890».
|
||||
Rule.of(PdTypes.DRIVER_LICENSE, "(?iu:водительск\\w+\\s+удостоверени\\w+|в/у|вод\\.\\s?удост\\w*|\\bВУ)\\b"
|
||||
+ "\\W{0,15}(" + SERIES_AND_NUMBER + ")\\b", 89)
|
||||
+ "\\W{0,15}(?:(?iu:сери\\w{0,3})\\W{0,5})?(" + SERIES_AND_NUMBER + ")\\b", 89)
|
||||
.groups(1)
|
||||
.anchoredBy("водительск", "в/у", "вод.", "ву "),
|
||||
|
||||
@@ -206,10 +236,19 @@ public class RuleRegistry {
|
||||
.groups(1)
|
||||
.anchoredBy("полис"),
|
||||
|
||||
Rule.of(PdTypes.DEPT_CODE, "(?iu:код\\w*\\s+подразделения|к/п)\\W{0,5}(\\d{3}\\s?-?\\s?\\d{3})\\b", 88)
|
||||
// ROLE_GAP, не \W{0,5}: «код подразделения стоит 001-000» — между якорем и
|
||||
// значением есть слово («стоит»/«объекта»), не только пунктуация.
|
||||
Rule.of(PdTypes.DEPT_CODE, "(?iu:код\\w*\\s+подразделения|к/п)" + ROLE_GAP
|
||||
+ "(\\d{3}\\s?-?\\s?\\d{3})\\b", 88)
|
||||
.groups(1)
|
||||
.anchoredBy("подразделени", "к/п"),
|
||||
|
||||
// «770-001 — таков код подразделения» — значение перед якорем.
|
||||
Rule.of(PdTypes.DEPT_CODE, "\\b(\\d{3}\\s?-?\\s?\\d{3})\\b\\s*[—-]\\s*(?:\\p{L}+\\s+){0,3}"
|
||||
+ "(?iu:код\\w*\\s+подразделения)", 88)
|
||||
.groups(1)
|
||||
.anchoredBy("подразделени"),
|
||||
|
||||
// --- Банковские реквизиты сверх карты ---
|
||||
|
||||
// Расчётный счёт — ровно 20 цифр после якоря, группировка пробелами не важна.
|
||||
@@ -335,7 +374,10 @@ public class RuleRegistry {
|
||||
+ "|\\bсозаёмщик|\\bсозаемщик|\\bзаёмщик|\\bзаемщик|\\bзаявител|\\bдоверител"
|
||||
+ "|\\bвкладчик|\\bответственн|\\bконтактное\\s+лицо|\\bисполнител|\\bдержател"
|
||||
+ "|\\bотправител|\\bбенефициар|\\bдоверенное\\s+лицо|\\bнаследник|\\bсозаемщик"
|
||||
+ "|\\bпоручител)\\p{L}*"
|
||||
// \p{L}*+ (possessive), не \p{L}*: без possessive откат назад позволял
|
||||
// движку «отдать» уже съеденное падежное окончание ролевого слова и
|
||||
// захватить его как будто отдельное имя — «пациентов» ловилось бы как «ов».
|
||||
+ "|\\bпоручител)\\p{L}*+"
|
||||
+ "(?:\\s+\\p{L}+){0,3}\\W{0,5}(\\p{L}{2,}(?:\\s+\\p{L}{2,}){0,2}(?:\\s+\\p{Lu}\\.){0,2})(?![\\p{L}.])", 77)
|
||||
.groups(1)
|
||||
.validatedBy(NameDictionary::containsNamePart)
|
||||
@@ -363,27 +405,35 @@ public class RuleRegistry {
|
||||
// Одиночное имя, фамилия или отчество: «Иванов», «иван», «петрович».
|
||||
// Самое слабое основание среди правил ФИО — ни ролевого слова, ни пары
|
||||
// слов, — поэтому приоритет ниже и проверка по словарю обязательна.
|
||||
// Словарь отсекает «сочи», «казань» и прочие не-имена.
|
||||
Rule.of(PdTypes.FIO, "\\b(\\p{L}{2,})\\b", 70)
|
||||
// Словарь отсекает «сочи», «казань» и прочие не-имена. Первое слово текста
|
||||
// не рассматривается: заглавная буква там от начала предложения, а не от
|
||||
// имени, и словообразовательная эвристика ложно ловит «Магазин», «Отдел».
|
||||
Rule.of(PdTypes.FIO, "(?<!^)\\b(\\p{L}{2,})\\b", 70)
|
||||
.groups(1)
|
||||
.validatedBy(NameDictionary::containsNamePart),
|
||||
.validatedBy(NameDictionary::isStandaloneNameCandidate),
|
||||
|
||||
// --- Уровень 1: подтверждается контрольной суммой ---
|
||||
|
||||
Rule.of(PdTypes.CARD, "\\b\\d(?:[ -]?\\d){11,18}\\b", 85)
|
||||
.validatedBy(Validators::luhn),
|
||||
|
||||
Rule.of(PdTypes.INN, "(?iu)\\bИНН\\b\\D{0,10}(\\d{12}|\\d{10})\\b", 84)
|
||||
Rule.of(PdTypes.INN, "(?iu)\\bИНН\\b" + ROLE_GAP + "(\\d{12}|\\d{10})\\b", 84)
|
||||
.groups(1)
|
||||
.anchoredBy("инн"),
|
||||
|
||||
// «ИНН/КПП 7712345671/771201001» — ИНН юрлица перед КПП через слэш.
|
||||
Rule.of(PdTypes.INN, "(?iu)\\bИНН\\s*/\\s*КПП\\b\\W{0,5}(\\d{10})\\b", 84)
|
||||
.groups(1)
|
||||
.anchoredBy("инн/кпп"),
|
||||
|
||||
Rule.of(PdTypes.SNILS, "(?iu)(?:\\bСНИЛС\\b\\D{0,10})?(\\d{3}[ -]\\d{3}[ -]\\d{3}[ -]\\d{2})\\b", 84)
|
||||
.groups(1)
|
||||
.validatedBy(Validators::snils),
|
||||
|
||||
// --- Уровень 2: формат однозначен сам по себе ---
|
||||
|
||||
Rule.of(PdTypes.PHONE, "(?:\\+7|\\b8)[ ()-]{0,3}\\d{3}[ ()-]{0,3}\\d{3}[ -]{0,2}\\d{2}[ -]{0,2}\\d{2}\\b", 82),
|
||||
// \b7, не только +7: номер без плюса («79031119955») тоже встречается.
|
||||
Rule.of(PdTypes.PHONE, "(?:\\+7|\\b7|\\b8)[ ()-]{0,3}\\d{3}[ ()-]{0,3}\\d{3}[ -]{0,2}\\d{2}[ -]{0,2}\\d{2}\\b", 82),
|
||||
|
||||
Rule.of(PdTypes.EMAIL, "\\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\\.[A-Za-z]{2,}\\b", 80)
|
||||
.anchoredBy("@"),
|
||||
@@ -393,10 +443,27 @@ public class RuleRegistry {
|
||||
// «выдан ОУФМС России по г. Москве 12.05.2015» — дата в состав органа не входит,
|
||||
// её забирает отдельное правило. Приоритет выше городского, иначе от органа
|
||||
// осталась бы замаскированной только его часть.
|
||||
Rule.of(PdTypes.PASSPORT_ISSUER, "(?iu:выдан)[\\p{L}]*\\W{0,3}([^,;\\n]{3,90}?)"
|
||||
// Перечень форм, не голая основа «выда»: она зацепила бы и «выдающийся»
|
||||
// (обычное слово, не про выдачу документа).
|
||||
Rule.of(PdTypes.PASSPORT_ISSUER, "(?iu:выдан|выдал[аио]?|выдали|выдач[аи]|выдаче)"
|
||||
+ "\\W{0,3}([^,;\\n]{3,90}?)"
|
||||
+ "(?=\\s*\\d{1,2}[.\\-/]\\d{1,2}[.\\-/]\\d{2,4}|[,;\\n]|\\s*$)", 78)
|
||||
.groups(1)
|
||||
.anchoredBy("выдан"),
|
||||
.anchoredBy("выдан", "выдал", "выдач"),
|
||||
|
||||
// «совпадает с указанным в анкете: X» — второе упоминание органа выдачи
|
||||
// под собственным якорем, без бэкреференса на первое.
|
||||
Rule.of(PdTypes.PASSPORT_ISSUER, "(?iu:указанн\\w*\\s+в\\s+анкете)\\W{0,5}([^,;.\\n]{3,90}?)"
|
||||
+ "(?=[,;.\\n]|\\s*$)", 78)
|
||||
.groups(1)
|
||||
.anchoredBy("указанн"),
|
||||
|
||||
// «Орган выдачи УФМС России по Республике Татарстан» — орган после якоря,
|
||||
// до слова «совпадает» или конца фразы.
|
||||
Rule.of(PdTypes.PASSPORT_ISSUER, "(?iu:орган\\s+выдачи)\\W{0,5}([^,;:\\n]{3,90}?)"
|
||||
+ "(?=\\s*(?iu:совпадает|указанн)|[,;:\\n]|\\s*$)", 78)
|
||||
.groups(1)
|
||||
.anchoredBy("орган выдачи"),
|
||||
|
||||
Rule.of(PdTypes.BIRTH_PLACE, "(?iu:мест\\w*\\s+рождения)\\W{0,5}([^,;\\n]{3,60}?)(?=\\s*[,;\\n]|\\s*$)", 76)
|
||||
.groups(1)
|
||||
@@ -407,19 +474,26 @@ public class RuleRegistry {
|
||||
.groups(1)
|
||||
.anchoredBy("родил"),
|
||||
|
||||
Rule.of(PdTypes.CITIZENSHIP, "(?iu:гражданств)\\w*\\W{0,5}"
|
||||
+ "((?iu:рф|россии|российской\\s+федерации|республики\\s+\\p{L}+)|\\p{Lu}\\p{Ll}+)\\b", 75)
|
||||
.groups(1)
|
||||
// ROLE_GAP, не \W{0,5}: «Гражданство бенефициара по договору страхования: Х» —
|
||||
// между якорем и значением бывает несколько слов, не только пунктуация.
|
||||
// Список через запятую/слэш — вторая опциональная группа тем же шаблоном.
|
||||
Rule.of(PdTypes.CITIZENSHIP, "(?iu:гражданств)\\w*" + CITIZENSHIP_GAP
|
||||
+ "(" + CITIZENSHIP_VALUE + ")(?:\\s*[,/]\\s*(" + CITIZENSHIP_VALUE + "))?", 80)
|
||||
.groups(1, 2)
|
||||
.validatedBy(CountryDictionary::isKnownCountry)
|
||||
.anchoredBy("гражданств"),
|
||||
|
||||
Rule.of(PdTypes.CITIZENSHIP, "(?iu:граждан(?:ин|ка|ина|ки))\\b\\s+"
|
||||
+ "((?iu:рф|россии|российской\\s+федерации|республики\\s+\\p{L}+)|\\p{Lu}\\p{Ll}+)\\b", 75)
|
||||
.groups(1)
|
||||
// ин/ка/ина/ки — именительный/родительный; ином/кой — творительный
|
||||
// («гражданином», «гражданкой»).
|
||||
Rule.of(PdTypes.CITIZENSHIP, "(?iu:граждан(?:ин|ка|ина|ки|ином|кой))\\b\\s+"
|
||||
+ "(" + CITIZENSHIP_VALUE + ")(?:\\s*[,/]\\s*(" + CITIZENSHIP_VALUE + "))?", 75)
|
||||
.groups(1, 2)
|
||||
.validatedBy(CountryDictionary::isKnownCountry)
|
||||
.anchoredBy("граждан"),
|
||||
|
||||
// --- Адрес: каждая составляющая настраивается отдельно ---
|
||||
|
||||
Rule.of(PdTypes.ADDRESS_POSTCODE, "(?iu:индекс)\\W{0,5}(\\d{6})\\b", 74)
|
||||
Rule.of(PdTypes.ADDRESS_POSTCODE, "(?iu:индекс)\\w*" + ROLE_GAP + "(\\d{6})\\b", 74)
|
||||
.groups(1)
|
||||
.vetoedBy(ORGANISATION_NEARBY)
|
||||
.anchoredBy("индекс"),
|
||||
|
||||
@@ -0,0 +1,143 @@
|
||||
# Основы названий стран для распознавания гражданства.
|
||||
# В нижнем регистре, без падежных окончаний (Declension::withoutInflectedEnding).
|
||||
# Сравнение идёт по началу слова, поэтому «Российская»/«российской» покрываются
|
||||
# основой «российск», «Федерация»/«федерации» — «федераци».
|
||||
российск
|
||||
федераци
|
||||
республик
|
||||
соединенн
|
||||
штат
|
||||
америк
|
||||
армени
|
||||
казахстан
|
||||
белорус
|
||||
украин
|
||||
грузи
|
||||
азербайджан
|
||||
узбекистан
|
||||
таджикистан
|
||||
туркменистан
|
||||
киргиз
|
||||
молдов
|
||||
молдав
|
||||
литв
|
||||
латви
|
||||
эстони
|
||||
польш
|
||||
германи
|
||||
франци
|
||||
итали
|
||||
испани
|
||||
португали
|
||||
нидерланд
|
||||
бельги
|
||||
швейцари
|
||||
австри
|
||||
чехи
|
||||
словаки
|
||||
венгри
|
||||
румыни
|
||||
болгари
|
||||
серби
|
||||
хорвати
|
||||
словени
|
||||
босни
|
||||
македони
|
||||
черногори
|
||||
греци
|
||||
турци
|
||||
кипр
|
||||
израил
|
||||
иордани
|
||||
ливан
|
||||
сири
|
||||
ирак
|
||||
иран
|
||||
афганистан
|
||||
пакистан
|
||||
инди
|
||||
кита
|
||||
япони
|
||||
коре
|
||||
монголи
|
||||
вьетнам
|
||||
таиланд
|
||||
индонези
|
||||
малайзи
|
||||
сингапур
|
||||
филиппин
|
||||
австрали
|
||||
новозеланд
|
||||
канад
|
||||
мексик
|
||||
бразили
|
||||
аргентин
|
||||
чили
|
||||
перу
|
||||
колумби
|
||||
венесуэл
|
||||
эквадор
|
||||
уругва
|
||||
парагва
|
||||
боливи
|
||||
куб
|
||||
доминикан
|
||||
гаити
|
||||
ямайк
|
||||
египет
|
||||
алжир
|
||||
марокко
|
||||
тунис
|
||||
ливи
|
||||
судан
|
||||
эфиопи
|
||||
кени
|
||||
нигери
|
||||
ган
|
||||
юар
|
||||
ангол
|
||||
мозамбик
|
||||
танзани
|
||||
уганд
|
||||
замби
|
||||
зимбабве
|
||||
ботсван
|
||||
намиби
|
||||
сенегал
|
||||
кот
|
||||
д'ивуар
|
||||
камерун
|
||||
конго
|
||||
габон
|
||||
экваториальн
|
||||
мадагаскар
|
||||
маврики
|
||||
сейшельск
|
||||
мальдив
|
||||
шри
|
||||
ланк
|
||||
непал
|
||||
бутан
|
||||
бангладеш
|
||||
мьянм
|
||||
камбодж
|
||||
лаос
|
||||
финлянди
|
||||
швеци
|
||||
норвеги
|
||||
дани
|
||||
исланди
|
||||
ирланди
|
||||
великобритан
|
||||
британ
|
||||
англи
|
||||
шотланд
|
||||
уэльс
|
||||
люксембург
|
||||
мальт
|
||||
андорр
|
||||
монако
|
||||
сан
|
||||
марин
|
||||
ватикан
|
||||
лихтенштейн
|
||||
@@ -0,0 +1,121 @@
|
||||
package ru.pdguard;
|
||||
|
||||
import org.junit.jupiter.api.DynamicTest;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.TestFactory;
|
||||
import ru.pdguard.config.SystemPolicy;
|
||||
import ru.pdguard.core.PayloadStore;
|
||||
import ru.pdguard.core.Pipeline;
|
||||
import ru.pdguard.detect.NameCascade;
|
||||
import ru.pdguard.detect.RuleRegistry;
|
||||
import ru.pdguard.detect.Span;
|
||||
import ru.pdguard.mask.Masker;
|
||||
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.stream.Stream;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
import static org.junit.jupiter.api.DynamicTest.dynamicTest;
|
||||
|
||||
/**
|
||||
* Датасет из 44 сгенерированных строк — не новые ТИПЫ ПДН, а новые РАЗМЕЩЕНИЯ уже
|
||||
* известных типов относительно якорного слова: расстояние до якоря, обратный порядок
|
||||
* (значение перед якорем), падеж/число анкера, структурированные форматы
|
||||
* (JSON/CSV/markdown-таблица/XML/key=value), несколько значений одного типа в одном
|
||||
* поле, значение в кавычках/скобках. Гипотезы построены на 55 утечках из
|
||||
* {@link NodeLogsDatasetTest} и обобщают их корневые причины на другие типы и формы.
|
||||
* Разбор по категориям — в отчёте, приложенном к задаче.
|
||||
*
|
||||
* <p>Как и {@link NodeLogsDatasetTest}, часть строк — подтверждённые утечки
|
||||
* (падение конкретного кейса в {@link #leakSummary()} ожидаемо), часть — контрольные
|
||||
* позитивные случаи, которые обязаны оставаться зелёными: если один из них упадёт,
|
||||
* значит новое размещение сломало то, что раньше работало.
|
||||
*/
|
||||
class PlacementVariantsTest {
|
||||
|
||||
private static final RuleRegistry REGISTRY = new RuleRegistry();
|
||||
private static final Masker MASKER = new Masker();
|
||||
private static final List<BenchmarkFixtures.Sample> DATASET = BenchmarkFixtures.load("/dataset-placements.txt");
|
||||
private static final int LEAK_CHECK_MIN_LENGTH = 3;
|
||||
|
||||
private static final NameCascade CASCADE = modelsPresent()
|
||||
? new NameCascade("wikineural", Optional.of("models/wikineural-ner"),
|
||||
"rubert", Optional.of("models/rubert-ner"), 16, 4)
|
||||
: NameCascade.disabled();
|
||||
|
||||
private static boolean modelsPresent() {
|
||||
return Files.isReadable(Path.of("models/wikineural-ner/model.onnx"))
|
||||
&& Files.isReadable(Path.of("models/rubert-ner/model.onnx"));
|
||||
}
|
||||
|
||||
@TestFactory
|
||||
Stream<DynamicTest> placementDataset() {
|
||||
List<DynamicTest> cases = new ArrayList<>(DATASET.size());
|
||||
for (int i = 0; i < DATASET.size(); i++) {
|
||||
BenchmarkFixtures.Sample sample = DATASET.get(i);
|
||||
int index = i;
|
||||
cases.add(dynamicTest(
|
||||
String.format("#%02d: %s", index, preview(sample.text())),
|
||||
() -> runRoundTrip(sample, index)));
|
||||
}
|
||||
return cases.stream();
|
||||
}
|
||||
|
||||
/** Демаскирование обязано восстановить исходный текст всегда, независимо от утечек. */
|
||||
private void runRoundTrip(BenchmarkFixtures.Sample sample, int index) {
|
||||
Pipeline pipeline = new Pipeline(REGISTRY, MASKER, new PayloadStore(1_000_000L, 30), CASCADE);
|
||||
String payloadId = "placement-" + index;
|
||||
|
||||
String masked = pipeline.process(sample.text(), payloadId, SystemPolicy.DEFAULT);
|
||||
String restored = pipeline.process(masked, payloadId, SystemPolicy.DEFAULT);
|
||||
assertEquals(sample.text(), restored, "демаскирование не восстановило исходный текст");
|
||||
}
|
||||
|
||||
/**
|
||||
* Сводка утечек одним прогоном — печатает список по типам и падает, только если
|
||||
* утечек стало больше 11, то есть если что-то из уже маскируемого сегодня
|
||||
* размещения перестало маскироваться. Было 20 при составлении датасета, после
|
||||
* точечных фиксов RuleRegistry (расширенный разрыв якорь-значение, обратный
|
||||
* порядок для дат/гражданства/кода подразделения) осталось 11.
|
||||
*/
|
||||
@Test
|
||||
void leakSummary() {
|
||||
Pipeline pipeline = new Pipeline(REGISTRY, MASKER, new PayloadStore(10_000_000L, 30), CASCADE);
|
||||
int leaked = 0;
|
||||
int checked = 0;
|
||||
java.util.Map<String, Integer> byType = new java.util.LinkedHashMap<>();
|
||||
StringBuilder report = new StringBuilder("\n=== Утечки по dataset-placements.txt ===\n");
|
||||
|
||||
for (int i = 0; i < DATASET.size(); i++) {
|
||||
BenchmarkFixtures.Sample sample = DATASET.get(i);
|
||||
String masked = pipeline.process(sample.text(), "leak-scan-" + i, SystemPolicy.DEFAULT);
|
||||
for (Span gold : sample.gold()) {
|
||||
String value = sample.text().substring(gold.start(), gold.end());
|
||||
if (value.length() < LEAK_CHECK_MIN_LENGTH) {
|
||||
continue;
|
||||
}
|
||||
checked++;
|
||||
if (masked.contains(value)) {
|
||||
leaked++;
|
||||
byType.merge(gold.type(), 1, Integer::sum);
|
||||
report.append(String.format(" [%s] %s%n", gold.type(), value));
|
||||
}
|
||||
}
|
||||
}
|
||||
report.append(String.format("%nВсего: %d утечек из %d эталонных фрагментов%n", leaked, checked));
|
||||
byType.forEach((type, count) -> report.append(String.format(" %-16s %d%n", type, count)));
|
||||
System.out.println(report);
|
||||
|
||||
assertTrue(leaked <= 9,
|
||||
"утечек стало больше 9 (было после точечных фиксов RuleRegistry) — новая регрессия: " + leaked);
|
||||
}
|
||||
|
||||
private static String preview(String text) {
|
||||
return text.length() <= 50 ? text : text.substring(0, 50) + "...";
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user