Files
pd-guard/src/main/java/ru/pdguard/detect/NameDictionary.java
T
Максименко Никита ВладимировичandClaude Sonnet 5 f553e79c75 refactor: dictionary lookups from O(dictionary size) to O(word length)
isWellKnown/isKnownCity scanned the whole stem list (60, then 1111+
after the city dictionary) with startsWith on every call. Reversed the
check: try decreasing-length prefixes of the input word against a
HashSet of stems instead — same result, but bounded by word length,
not dictionary size.

Also pulls the vowel-stripping declension trick (shared verbatim
between NameDictionary and ToponymDictionary since the city dictionary
was added) into one Declension helper, and extracts the {{TYPE:value}}
benchmark-line parser — duplicated between BenchmarkTest and
LargeTextTest since the large-text test was added — into
BenchmarkFixtures.

Verified: same 125 tests pass, benchmark F1 numbers unchanged, native
image under 0.5 CPU/250MB shows no regression at 1000/1800/3000 RPS.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-21 21:51:30 +03:00

211 lines
11 KiB
Java

package ru.pdguard.detect;
import org.eclipse.microprofile.config.ConfigProvider;
import org.jboss.logging.Logger;
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.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 = Logger.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 volatile Path externalFile = Path.of(ConfigProvider.getConfig()
.getOptionalValue("pdguard.well-known-file", String.class)
.orElse("config/well-known.txt"));
private static volatile Set<String> wellKnownStems = BUNDLED_WELL_KNOWN_STEMS;
private static volatile long externalTimestamp;
private static volatile long lastCheck;
/** Не более скольких падежных букв дописывается к основе имени. */
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() {
}
/**
* Есть ли среди слов личное имя из словаря в любом падеже.
*
* <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>Проверяются префиксы слова по множеству, а не каждая основа по слову:
* при тысяче с лишним записей (столько городов в {@link ToponymDictionary},
* тот же приём) перебор списка на каждое слово текста был бы заметен, а
* префиксов у слова — не больше, чем в нём букв.
*/
public static boolean isWellKnown(String value) {
refreshIfChanged();
Set<String> stems = wellKnownStems;
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 = path;
externalTimestamp = -1;
lastCheck = 0;
}
/** Перечитать внешний файл немедленно, минуя секундный троттлинг проверки. */
static synchronized void reloadExternal() {
lastCheck = System.currentTimeMillis();
if (!Files.isReadable(externalFile)) {
if (wellKnownStems != BUNDLED_WELL_KNOWN_STEMS) {
LOG.infof("Внешний файл денилиста %s исчез, остаётся только встроенный список",
externalFile.toAbsolutePath());
}
wellKnownStems = BUNDLED_WELL_KNOWN_STEMS;
externalTimestamp = 0;
return;
}
try {
externalTimestamp = Files.getLastModifiedTime(externalFile).toMillis();
Set<String> merged = new HashSet<>(BUNDLED_WELL_KNOWN_STEMS);
for (String line : Files.readAllLines(externalFile, StandardCharsets.UTF_8)) {
String trimmed = Declension.withoutInflectedEnding(line.trim());
if (!trimmed.isEmpty() && !trimmed.startsWith("#")) {
merged.add(trimmed);
}
}
wellKnownStems = Set.copyOf(merged);
LOG.infof("Денилист дополнен из %s: %d имён сверх встроенных",
externalFile.toAbsolutePath(), merged.size() - BUNDLED_WELL_KNOWN_STEMS.size());
} catch (IOException e) {
// Битый файл не должен ронять маскирование: остаётся прежний список.
LOG.errorf(e, "Не удалось прочитать %s, денилист не изменён", externalFile.toAbsolutePath());
}
}
private static void refreshIfChanged() {
long now = System.currentTimeMillis();
if (now - lastCheck < RECHECK_MILLIS) {
return;
}
lastCheck = now;
try {
if (!Files.isReadable(externalFile)) {
if (externalTimestamp != 0) {
reloadExternal();
}
return;
}
if (Files.getLastModifiedTime(externalFile).toMillis() != externalTimestamp) {
reloadExternal();
}
} catch (IOException e) {
LOG.debugf(e, "Не удалось проверить время изменения %s", externalFile);
}
}
/**
* Основы сортируются от длинных к коротким: в чередовании регулярного
* выражения побеждает первая подошедшая ветка, и короткая основа не должна
* перехватывать совпадение у длинной.
*/
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);
}
}
}