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));