diff --git a/src/main/java/ru/pdguard/detect/Declension.java b/src/main/java/ru/pdguard/detect/Declension.java new file mode 100644 index 0000000..03d1ed4 --- /dev/null +++ b/src/main/java/ru/pdguard/detect/Declension.java @@ -0,0 +1,31 @@ +package ru.pdguard.detect; + +import java.util.Locale; + +/** + * Общий приём для словарей, сравнивающих слово из текста с основой из списка: + * личные имена ({@link NameDictionary}) и города ({@link ToponymDictionary}). + * + *

Слова на согласную склоняются добавлением окончания («Тамбов» → «Тамбове», + * «Пушкин» → «Пушкина») — там основы из списка достаточно как есть. Слова на + * гласную меняют последнюю букву («Москва» → «Москве», «Ольга» → «Ольге») — + * для них сравнение идёт по основе без неё. + */ +final class Declension { + + private Declension() { + } + + /** + * Отбрасывает у основы конечную гласную, которая меняется по падежам. + * Слова короче четырёх букв не трогает — короткая основа и так шире + * большинства падежных форм. + */ + static String withoutInflectedEnding(String word) { + String lower = word.toLowerCase(Locale.ROOT); + if (lower.length() >= 4 && "аяйь".indexOf(lower.charAt(lower.length() - 1)) >= 0) { + return lower.substring(0, lower.length() - 1); + } + return lower; + } +} diff --git a/src/main/java/ru/pdguard/detect/NameDictionary.java b/src/main/java/ru/pdguard/detect/NameDictionary.java index 906f684..2afbbb0 100644 --- a/src/main/java/ru/pdguard/detect/NameDictionary.java +++ b/src/main/java/ru/pdguard/detect/NameDictionary.java @@ -11,8 +11,8 @@ import java.io.UncheckedIOException; import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.Path; -import java.util.ArrayList; import java.util.Comparator; +import java.util.HashSet; import java.util.List; import java.util.Locale; import java.util.Set; @@ -41,7 +41,7 @@ public final class NameDictionary { private static final long RECHECK_MILLIS = 1000; private static final List GIVEN_NAME_STEMS = load("/names/given-names.txt").stream() - .map(NameDictionary::withoutInflectedEnding) + .map(Declension::withoutInflectedEnding) .distinct() .sorted(Comparator.comparingInt(String::length).reversed()) .toList(); @@ -49,15 +49,14 @@ public final class NameDictionary { // творительный падежи образует заменой «-а» на «-ой» («Набиуллиной»), а не // дописыванием — без отсечения «а» их startsWith не поймает. Тот же приём, // что и для личных имён. - private static final List BUNDLED_WELL_KNOWN_STEMS = load("/names/well-known.txt").stream() - .map(NameDictionary::withoutInflectedEnding) - .distinct() - .toList(); + private static final Set BUNDLED_WELL_KNOWN_STEMS = load("/names/well-known.txt").stream() + .map(Declension::withoutInflectedEnding) + .collect(Collectors.toUnmodifiableSet()); private static volatile Path externalFile = Path.of(ConfigProvider.getConfig() .getOptionalValue("pdguard.well-known-file", String.class) .orElse("config/well-known.txt")); - private static volatile List wellKnownStems = BUNDLED_WELL_KNOWN_STEMS; + private static volatile Set wellKnownStems = BUNDLED_WELL_KNOWN_STEMS; private static volatile long externalTimestamp; private static volatile long lastCheck; @@ -76,17 +75,6 @@ public final class NameDictionary { private NameDictionary() { } - /** - * Отбрасывает у основы конечную гласную, которая меняется по падежам: - * Ольга → Ольг (Ольги, Ольге, Ольгой), Николай → Никола (Николая, Николаю). - */ - private static String withoutInflectedEnding(String stem) { - if (stem.length() >= 4 && "аяйь".indexOf(stem.charAt(stem.length() - 1)) >= 0) { - return stem.substring(0, stem.length() - 1); - } - return stem; - } - /** * Есть ли среди слов личное имя из словаря в любом падеже. * @@ -116,14 +104,22 @@ public final class NameDictionary { return false; } - /** Содержит ли текст упоминание известного человека — из сборки или дописанных сверху. */ + /** + * Содержит ли текст упоминание известного человека — из сборки или дописанных + * сверху. + * + *

Проверяются префиксы слова по множеству, а не каждая основа по слову: + * при тысяче с лишним записей (столько городов в {@link ToponymDictionary}, + * тот же приём) перебор списка на каждое слово текста был бы заметен, а + * префиксов у слова — не больше, чем в нём букв. + */ public static boolean isWellKnown(String value) { refreshIfChanged(); - List stems = wellKnownStems; + Set stems = wellKnownStems; for (String word : value.split("\\P{L}+")) { String lower = word.toLowerCase(Locale.ROOT); - for (String stem : stems) { - if (lower.startsWith(stem.toLowerCase(Locale.ROOT))) { + for (int length = lower.length(); length > 0; length--) { + if (stems.contains(lower.substring(0, length))) { return true; } } @@ -152,14 +148,14 @@ public final class NameDictionary { } try { externalTimestamp = Files.getLastModifiedTime(externalFile).toMillis(); - List merged = new ArrayList<>(BUNDLED_WELL_KNOWN_STEMS); + Set merged = new HashSet<>(BUNDLED_WELL_KNOWN_STEMS); for (String line : Files.readAllLines(externalFile, StandardCharsets.UTF_8)) { - String trimmed = withoutInflectedEnding(line.trim()); - if (!trimmed.isEmpty() && !trimmed.startsWith("#") && !merged.contains(trimmed)) { + String trimmed = Declension.withoutInflectedEnding(line.trim()); + if (!trimmed.isEmpty() && !trimmed.startsWith("#")) { merged.add(trimmed); } } - wellKnownStems = List.copyOf(merged); + wellKnownStems = Set.copyOf(merged); LOG.infof("Денилист дополнен из %s: %d имён сверх встроенных", externalFile.toAbsolutePath(), merged.size() - BUNDLED_WELL_KNOWN_STEMS.size()); } catch (IOException e) { diff --git a/src/main/java/ru/pdguard/detect/ToponymDictionary.java b/src/main/java/ru/pdguard/detect/ToponymDictionary.java index 7b8c9e8..98f7a67 100644 --- a/src/main/java/ru/pdguard/detect/ToponymDictionary.java +++ b/src/main/java/ru/pdguard/detect/ToponymDictionary.java @@ -6,9 +6,9 @@ import java.io.InputStream; import java.io.InputStreamReader; import java.io.UncheckedIOException; import java.nio.charset.StandardCharsets; -import java.util.Comparator; -import java.util.List; import java.util.Locale; +import java.util.Set; +import java.util.stream.Collectors; /** * Словарь городов России — проверка того, что значение, пойманное правилом @@ -21,11 +21,9 @@ import java.util.Locale; */ public final class ToponymDictionary { - private static final List CITY_STEMS = load("/names/cities.txt").stream() - .map(ToponymDictionary::withoutInflectedEnding) - .distinct() - .sorted(Comparator.comparingInt(String::length).reversed()) - .toList(); + private static final Set CITY_STEMS = load("/names/cities.txt").stream() + .map(Declension::withoutInflectedEnding) + .collect(Collectors.toUnmodifiableSet()); private ToponymDictionary() { } @@ -38,29 +36,22 @@ public final class ToponymDictionary { * Города на гласную меняют последнюю букву («Москва» → «Москве»), для * них сравнение идёт по основе без неё — так же, как с личными именами * в {@link NameDictionary}. + * + *

Проверяются префиксы значения по множеству, а не каждая из 1111+ + * основ по значению: перебор списка на каждое совпадение правила был бы + * в тысячу раз дороже, чем нужно — префиксов у слова не больше, чем в нём букв. */ public static boolean isKnownCity(String value) { String lower = value.strip().toLowerCase(Locale.ROOT); - if (lower.isEmpty()) { - return false; - } - for (String stem : CITY_STEMS) { - if (lower.startsWith(stem)) { + for (int length = lower.length(); length > 0; length--) { + if (CITY_STEMS.contains(lower.substring(0, length))) { return true; } } return false; } - private static String withoutInflectedEnding(String name) { - String lower = name.toLowerCase(Locale.ROOT); - if (lower.length() >= 4 && "аяйь".indexOf(lower.charAt(lower.length() - 1)) >= 0) { - return lower.substring(0, lower.length() - 1); - } - return lower; - } - - private static List load(String resource) { + private static Set load(String resource) { try (InputStream in = ToponymDictionary.class.getResourceAsStream(resource)) { if (in == null) { throw new IllegalStateException("Словарь не найден в сборке: " + resource); @@ -69,10 +60,7 @@ public final class ToponymDictionary { return reader.lines() .map(String::trim) .filter(line -> !line.isEmpty() && !line.startsWith("#")) - .distinct() - // Длинные основы — раньше: «Санкт-Петербург» не должен проигрывать «Санкт». - .sorted(Comparator.comparingInt(String::length).reversed()) - .toList(); + .collect(Collectors.toUnmodifiableSet()); } } catch (IOException e) { throw new UncheckedIOException("Не удалось прочитать словарь " + resource, e); diff --git a/src/test/java/ru/pdguard/BenchmarkFixtures.java b/src/test/java/ru/pdguard/BenchmarkFixtures.java new file mode 100644 index 0000000..aec8bc4 --- /dev/null +++ b/src/test/java/ru/pdguard/BenchmarkFixtures.java @@ -0,0 +1,67 @@ +package ru.pdguard; + +import ru.pdguard.core.Span; + +import java.io.BufferedReader; +import java.io.IOException; +import java.io.InputStream; +import java.io.InputStreamReader; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.List; +import java.util.Objects; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +/** + * Общий разбор размеченных наборов {@code {{ТИП:значение}}} — используется + * и {@link BenchmarkTest} (замер качества по строкам), и {@link LargeTextTest} + * (те же строки, перемешанные и склеенные в большой текст). + */ +final class BenchmarkFixtures { + + private static final Pattern MARKUP = Pattern.compile("\\{\\{([A-Z_]+):([^}]*)}}"); + + /** Размеченный пример: чистый текст и эталонные фрагменты. */ + record Sample(String text, List gold) { + } + + private BenchmarkFixtures() { + } + + /** Читает набор построчно, пропуская пустые строки и комментарии {@code #}. */ + static List load(String resource) { + List samples = new ArrayList<>(); + try (InputStream in = BenchmarkFixtures.class.getResourceAsStream(resource); + BufferedReader reader = new BufferedReader( + new InputStreamReader(Objects.requireNonNull(in, resource), StandardCharsets.UTF_8))) { + String line; + while ((line = reader.readLine()) != null) { + String trimmed = line.trim(); + if (!trimmed.isEmpty() && !trimmed.startsWith("#")) { + samples.add(parse(trimmed)); + } + } + } catch (IOException e) { + throw new IllegalStateException("Не удалось прочитать " + resource, e); + } + return samples; + } + + /** Разбирает разметку {@code {{ТИП:значение}}} в чистый текст и эталонные фрагменты. */ + static Sample parse(String line) { + StringBuilder text = new StringBuilder(line.length()); + List gold = new ArrayList<>(); + Matcher m = MARKUP.matcher(line); + int cursor = 0; + while (m.find()) { + text.append(line, cursor, m.start()); + int start = text.length(); + text.append(m.group(2)); + gold.add(new Span(start, text.length(), m.group(1), 0)); + cursor = m.end(); + } + text.append(line, cursor, line.length()); + return new Sample(text.toString(), gold); + } +} diff --git a/src/test/java/ru/pdguard/BenchmarkTest.java b/src/test/java/ru/pdguard/BenchmarkTest.java index 3259a6e..f5f39ce 100644 --- a/src/test/java/ru/pdguard/BenchmarkTest.java +++ b/src/test/java/ru/pdguard/BenchmarkTest.java @@ -9,11 +9,6 @@ import ru.pdguard.detect.NameCascade; import ru.pdguard.detect.RuleRegistry; import ru.pdguard.mask.Masker; -import java.io.BufferedReader; -import java.io.IOException; -import java.io.InputStream; -import java.io.InputStreamReader; -import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.Path; import java.util.ArrayList; @@ -21,10 +16,7 @@ import java.util.Comparator; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; -import java.util.Objects; import java.util.Optional; -import java.util.regex.Matcher; -import java.util.regex.Pattern; import static org.junit.jupiter.api.Assertions.assertTrue; import static org.junit.jupiter.api.Assumptions.assumeTrue; @@ -50,12 +42,6 @@ class BenchmarkTest { /** Модель второй ступени; собирается отдельно, см. README. */ private static final String MODEL_PATH = "models/ru-ner-person.bin"; - private static final Pattern MARKUP = Pattern.compile("\\{\\{([A-Z_]+):([^}]*)}}"); - - /** Размеченный пример: чистый текст и эталонные фрагменты. */ - private record Sample(String text, List gold) { - } - /** Итог замера по одному набору. */ private record Result(double fioF1, double overallPrecision, double overallRecall, double falsePositiveRate, int foundFioSpans, int goldFioSpans) { @@ -203,7 +189,7 @@ class BenchmarkTest { } private Result measure(Pipeline stage, String resource, String title) { - List samples = load(resource); + List samples = BenchmarkFixtures.load(resource); Map byType = new LinkedHashMap<>(); Score anyType = new Score(); @@ -215,7 +201,7 @@ class BenchmarkTest { List missedFio = new ArrayList<>(); List overMasked = new ArrayList<>(); - for (Sample sample : samples) { + for (BenchmarkFixtures.Sample sample : samples) { List found = stage.findPersonalData(sample.text(), SystemPolicy.DEFAULT); String[] goldChars = paint(sample.text().length(), sample.gold()); @@ -352,38 +338,4 @@ class BenchmarkTest { lines.forEach(line -> out.append(" ").append(line).append('\n')); } - private static List load(String resource) { - List samples = new ArrayList<>(); - try (InputStream in = BenchmarkTest.class.getResourceAsStream(resource); - BufferedReader reader = new BufferedReader( - new InputStreamReader(Objects.requireNonNull(in, resource), StandardCharsets.UTF_8))) { - String line; - while ((line = reader.readLine()) != null) { - String trimmed = line.trim(); - if (!trimmed.isEmpty() && !trimmed.startsWith("#")) { - samples.add(parse(trimmed)); - } - } - } catch (IOException e) { - throw new IllegalStateException("Не удалось прочитать " + resource, e); - } - return samples; - } - - /** Разбирает разметку {@code {{ТИП:значение}}} в чистый текст и эталонные фрагменты. */ - private static Sample parse(String line) { - StringBuilder text = new StringBuilder(line.length()); - List gold = new ArrayList<>(); - Matcher m = MARKUP.matcher(line); - int cursor = 0; - while (m.find()) { - text.append(line, cursor, m.start()); - int start = text.length(); - text.append(m.group(2)); - gold.add(new Span(start, text.length(), m.group(1), 0)); - cursor = m.end(); - } - text.append(line, cursor, line.length()); - return new Sample(text.toString(), gold); - } } diff --git a/src/test/java/ru/pdguard/LargeTextTest.java b/src/test/java/ru/pdguard/LargeTextTest.java index 37b088a..ec8f47a 100644 --- a/src/test/java/ru/pdguard/LargeTextTest.java +++ b/src/test/java/ru/pdguard/LargeTextTest.java @@ -9,21 +9,14 @@ import ru.pdguard.detect.NameCascade; import ru.pdguard.detect.RuleRegistry; import ru.pdguard.mask.Masker; -import java.io.BufferedReader; import java.io.IOException; -import java.io.InputStream; -import java.io.InputStreamReader; -import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.Path; import java.util.ArrayList; import java.util.Collections; import java.util.List; -import java.util.Objects; import java.util.Optional; import java.util.Random; -import java.util.regex.Matcher; -import java.util.regex.Pattern; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertTrue; @@ -41,66 +34,27 @@ import static org.junit.jupiter.api.Assertions.assertTrue; class LargeTextTest { private static final String MODEL_PATH = "models/ru-ner-person.bin"; - private static final Pattern MARKUP = Pattern.compile("\\{\\{([A-Z_]+):([^}]*)}}"); /** Целевой объём: README оценивает 100 000 токенов как ~400 КБ текста. */ private static final int TARGET_CHARS = 400_000; - private record Line(String text, List gold) { - } - - /** Строки набора без разметки — чистый текст для перемешивания. */ - private static List loadLines() { - List lines = new ArrayList<>(); - try (InputStream in = LargeTextTest.class.getResourceAsStream("/benchmark-generated.txt"); - BufferedReader reader = new BufferedReader( - new InputStreamReader(Objects.requireNonNull(in), StandardCharsets.UTF_8))) { - String raw; - while ((raw = reader.readLine()) != null) { - String trimmed = raw.trim(); - if (!trimmed.isEmpty() && !trimmed.startsWith("#")) { - lines.add(parse(trimmed)); - } - } - } catch (IOException e) { - throw new IllegalStateException(e); - } - return lines; - } - - private static Line parse(String line) { - StringBuilder text = new StringBuilder(line.length()); - List gold = new ArrayList<>(); - Matcher m = MARKUP.matcher(line); - int cursor = 0; - while (m.find()) { - text.append(line, cursor, m.start()); - int start = text.length(); - text.append(m.group(2)); - gold.add(new Span(start, text.length(), m.group(1), 0)); - cursor = m.end(); - } - text.append(line, cursor, line.length()); - return new Line(text.toString(), gold); - } - /** * Перемешивает исходные строки (фиксированный seed — детерминированный * тест) и склеивает их через перенос строки, пока не наберётся целевой * объём. Смещения золотых фрагментов пересчитываются под общий текст. */ - private static Line buildLargeText(int targetChars, long seed) { - List pool = new ArrayList<>(loadLines()); + private static BenchmarkFixtures.Sample buildLargeText(int targetChars, long seed) { + List pool = new ArrayList<>(BenchmarkFixtures.load("/benchmark-generated.txt")); Random random = new Random(seed); StringBuilder text = new StringBuilder(targetChars + 1024); List gold = new ArrayList<>(); while (text.length() < targetChars) { Collections.shuffle(pool, random); - for (Line line : pool) { + for (BenchmarkFixtures.Sample sample : pool) { int offset = text.length(); - text.append(line.text()).append('\n'); - for (Span span : line.gold()) { + text.append(sample.text()).append('\n'); + for (Span span : sample.gold()) { gold.add(new Span(span.start() + offset, span.end() + offset, span.type(), 0)); } if (text.length() >= targetChars) { @@ -108,7 +62,7 @@ class LargeTextTest { } } } - return new Line(text.toString(), gold); + return new BenchmarkFixtures.Sample(text.toString(), gold); } /** @@ -119,7 +73,7 @@ class LargeTextTest { */ @Test void roundTripOnLargeMixedText() { - Line large = buildLargeText(TARGET_CHARS, 1); + BenchmarkFixtures.Sample large = buildLargeText(TARGET_CHARS, 1); Pipeline pipeline = new Pipeline(new RuleRegistry(), new Masker(), new PayloadStore(large.text().length() * 2L, 30)); @@ -146,7 +100,7 @@ class LargeTextTest { */ @Test void recallHoldsAtScale() { - Line large = buildLargeText(TARGET_CHARS, 2); + BenchmarkFixtures.Sample large = buildLargeText(TARGET_CHARS, 2); Pipeline pipeline = new Pipeline(new RuleRegistry(), new Masker(), new PayloadStore(1L, 30)); List found = pipeline.findPersonalData(large.text(), SystemPolicy.DEFAULT); @@ -176,7 +130,7 @@ class LargeTextTest { System.out.println("Модель " + model.toAbsolutePath() + " не собрана, пропускаю"); return; } - Line large = buildLargeText(TARGET_CHARS, 3); + BenchmarkFixtures.Sample large = buildLargeText(TARGET_CHARS, 3); Pipeline pipeline = new Pipeline(new RuleRegistry(), new Masker(), new PayloadStore(large.text().length() * 2L, 30), new NameCascade(Optional.of(MODEL_PATH), 16, 4));