feat: settlements from the 2020-2021 census, not just the 1111 official cities
The city dictionary only covered cities with formal status (~1100). Real client addresses are village/settlement, and those never had an anchor to match on: "рп. Ильинское", "с. Кукуево" from the spec were undetectable no matter how good the dictionary got, because ADDRESS_CITY only recognized "г./город". Replaces cities.txt with settlements.txt (79,362 names, tochno.st's processing of Rosstat's 2020-2021 census + CIS capitals) and extends the rule's anchors to рп./пгт/с./д./дер./х./ст-ца/станица/аул/сл./ слобода/аал. isKnownCity renamed to isKnownSettlement to match what it actually validates now. Verified: native image startup time and idle memory unchanged despite the ~70x larger dictionary (prefix-set lookup from the last pass is O(word length), not O(dictionary size)); no regression in existing benchmark false-positive thresholds or load-test latency at 1000/1800/ 3000 RPS. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 5
parent
f553e79c75
commit
c1a1319088
@@ -378,11 +378,17 @@ public class RuleRegistry {
|
||||
.groups(1)
|
||||
.vetoedBy(ORGANISATION_NEARBY),
|
||||
|
||||
Rule.of(ADDRESS_CITY, "(?iu:\\bг\\.|\\bгор\\.|\\bгород)\\s?(\\p{Lu}[\\p{L}-]{1,30})\\b", 73)
|
||||
// Не только «г.»: перепись, на которой проверяется словарь, покрывает
|
||||
// сёла, посёлки, деревни, хутора и станицы — «рп. Ильинское», «с. Кукуево»
|
||||
// из ТЗ без этих якорей не нашлись бы вообще, город там ни при чём.
|
||||
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::isKnownCity)
|
||||
.validatedBy(ToponymDictionary::isKnownSettlement)
|
||||
.vetoedBy(ORGANISATION_NEARBY)
|
||||
.anchoredBy("г.", "гор", "город"),
|
||||
.anchoredBy("г.", "гор", "город", "рп.", "пгт", "п.", "с.", "село", "д.", "деревня",
|
||||
"дер.", "х.", "хутор", "ст-ца", "станица", "аул", "сл.", "слобода", "аал"),
|
||||
|
||||
Rule.of(ADDRESS_STREET,
|
||||
"(?iu:\\bул\\.|\\bулиц\\p{L}*|\\bпр-т|\\bпроспект\\p{L}*|\\bпер\\.|\\bпереул\\p{L}*"
|
||||
|
||||
@@ -11,9 +11,16 @@ import java.util.Set;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
/**
|
||||
* Словарь городов России — проверка того, что значение, пойманное правилом
|
||||
* {@code ADDRESS_CITY}, действительно похоже на существующий город, а не на
|
||||
* произвольное слово с заглавной буквы после «г.».
|
||||
* Словарь населённых пунктов России — проверка того, что значение, пойманное
|
||||
* правилом {@code ADDRESS_CITY}, действительно похоже на существующий город,
|
||||
* село, посёлок или другой населённый пункт, а не на произвольное слово с
|
||||
* заглавной буквы после якоря.
|
||||
*
|
||||
* <p>Не только официальные города (~1100 по классификатору): перепись
|
||||
* добавляет сёла, деревни, хутора, станицы — «рп. Ильинское», «с. Кукуево»
|
||||
* из ТЗ находятся ровно за счёт неё. Какой конкретно тип населённого пункта
|
||||
* стоит перед названием, определяет якорь самого правила в {@link RuleRegistry},
|
||||
* а не этот словарь — он только подтверждает, что название реальное.
|
||||
*
|
||||
* <p>Сравнение по началу слова, а не точным совпадением: падежные окончания
|
||||
* («в Москве», «из Казани») тем самым покрываются без отдельного разбора
|
||||
@@ -21,7 +28,7 @@ import java.util.stream.Collectors;
|
||||
*/
|
||||
public final class ToponymDictionary {
|
||||
|
||||
private static final Set<String> CITY_STEMS = load("/names/cities.txt").stream()
|
||||
private static final Set<String> SETTLEMENT_STEMS = load("/names/settlements.txt").stream()
|
||||
.map(Declension::withoutInflectedEnding)
|
||||
.collect(Collectors.toUnmodifiableSet());
|
||||
|
||||
@@ -29,22 +36,23 @@ public final class ToponymDictionary {
|
||||
}
|
||||
|
||||
/**
|
||||
* Похоже ли значение на название города из словаря в любом падеже.
|
||||
* Похоже ли значение на название населённого пункта из словаря в любом
|
||||
* падеже.
|
||||
*
|
||||
* <p>Города на согласную склоняются добавлением окончания («Тамбов» →
|
||||
* <p>Названия на согласную склоняются добавлением окончания («Тамбов» →
|
||||
* «Тамбове»), поэтому начало слова из словаря — уже достаточный признак.
|
||||
* Города на гласную меняют последнюю букву («Москва» → «Москве»), для
|
||||
* Названия на гласную меняют последнюю букву («Москва» → «Москве»), для
|
||||
* них сравнение идёт по основе без неё — так же, как с личными именами
|
||||
* в {@link NameDictionary}.
|
||||
*
|
||||
* <p>Проверяются префиксы значения по множеству, а не каждая из 1111+
|
||||
* <p>Проверяются префиксы значения по множеству, а не каждая из ~80 000
|
||||
* основ по значению: перебор списка на каждое совпадение правила был бы
|
||||
* в тысячу раз дороже, чем нужно — префиксов у слова не больше, чем в нём букв.
|
||||
* на порядки дороже, чем нужно — префиксов у слова не больше, чем в нём букв.
|
||||
*/
|
||||
public static boolean isKnownCity(String value) {
|
||||
public static boolean isKnownSettlement(String value) {
|
||||
String lower = value.strip().toLowerCase(Locale.ROOT);
|
||||
for (int length = lower.length(); length > 0; length--) {
|
||||
if (CITY_STEMS.contains(lower.substring(0, length))) {
|
||||
if (SETTLEMENT_STEMS.contains(lower.substring(0, length))) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,119 @@
|
||||
package ru.pdguard;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import ru.pdguard.config.SystemPolicy;
|
||||
import ru.pdguard.core.PayloadStore;
|
||||
import ru.pdguard.core.Pipeline;
|
||||
import ru.pdguard.detect.RuleRegistry;
|
||||
import ru.pdguard.mask.Masker;
|
||||
|
||||
import java.util.UUID;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
|
||||
/**
|
||||
* Адрес не только в городе: правило {@code ADDRESS_CITY} расширено якорями
|
||||
* на сёла, посёлки, деревни, хутора, станицы, аулы и аалы — раньше словарь
|
||||
* ограничивался официальными городами (~1100), и «рп. Ильинское»/«с. Кукуево»
|
||||
* из ТЗ не находились вообще, дело было не в качестве детекции, а в том, что
|
||||
* искать было негде.
|
||||
*/
|
||||
class SettlementTest {
|
||||
|
||||
private final Pipeline pipeline =
|
||||
new Pipeline(new RuleRegistry(), new Masker(), new PayloadStore(1_000_000L, 30));
|
||||
|
||||
private String mask(String text) {
|
||||
return pipeline.process(text, UUID.randomUUID().toString(), SystemPolicy.DEFAULT);
|
||||
}
|
||||
|
||||
private void assertHidden(String text, String secret) {
|
||||
String masked = mask(text);
|
||||
assertFalse(masked.contains(secret), "не замаскировано: «" + secret + "» в ответе «" + masked + "»");
|
||||
}
|
||||
|
||||
@Test
|
||||
void masksVillage() {
|
||||
assertHidden("Клиент проживает в с. Кукуево постоянно", "Кукуево");
|
||||
}
|
||||
|
||||
@Test
|
||||
void masksWorkersSettlement() {
|
||||
assertHidden("Регистрация по адресу: рп. Ильинское", "Ильинское");
|
||||
}
|
||||
|
||||
@Test
|
||||
void masksUrbanTypeSettlement() {
|
||||
assertHidden("Доставка курьером в пгт. Энем", "Энем");
|
||||
}
|
||||
|
||||
@Test
|
||||
void masksHamlet() {
|
||||
assertHidden("Дом находится в д. Аксеновка", "Аксеновка");
|
||||
}
|
||||
|
||||
@Test
|
||||
void masksFarmstead() {
|
||||
assertHidden("Клиент родом из х. Прогресс", "Прогресс");
|
||||
}
|
||||
|
||||
@Test
|
||||
void masksCossackStanitsa() {
|
||||
assertHidden("Прописан в ст-ца Гиагинская", "Гиагинская");
|
||||
}
|
||||
|
||||
@Test
|
||||
void masksAul() {
|
||||
assertHidden("Живёт в аул Блечепсин", "Блечепсин");
|
||||
}
|
||||
|
||||
@Test
|
||||
void masksSloboda() {
|
||||
assertHidden("Проживает в сл. Екатериновка", "Екатериновка");
|
||||
}
|
||||
|
||||
@Test
|
||||
void masksAal() {
|
||||
assertHidden("Зарегистрирован в аал Сартыков", "Сартыков");
|
||||
}
|
||||
|
||||
/** Словарь не подтверждает выдуманное название — якорь один в один, а маски нет. */
|
||||
@Test
|
||||
void doesNotMaskFictionalPlaceNameDespiteRealAnchor() {
|
||||
String text = "Клиент проживает в с. Мнимогорск";
|
||||
assertEquals(text, mask(text), "выдуманное название не должно проходить словарь");
|
||||
}
|
||||
|
||||
/** Вето по организационному контексту работает и для новых типов НП, не только для городов. */
|
||||
@Test
|
||||
void doesNotMaskOrganisationAddressInVillage() {
|
||||
String text = "Ближайшее отделение банка находится в с. Кукуево";
|
||||
assertEquals(text, mask(text), "адрес отделения банка не является ПД клиента");
|
||||
}
|
||||
|
||||
@Test
|
||||
void anchorsAreCaseInsensitive() {
|
||||
assertHidden("клиент проживает в С. КУКУЕВО", "КУКУЕВО");
|
||||
}
|
||||
|
||||
/** Реалистичное предложение: населённый пункт, улица и дом вместе. */
|
||||
@Test
|
||||
void masksSettlementStreetAndHouseTogether() {
|
||||
String masked = mask("Проживает по адресу: д. Аксеновка, ул. Садовая, д. 7");
|
||||
assertFalse(masked.contains("Аксеновка"), masked);
|
||||
assertFalse(masked.contains("Садовая"), masked);
|
||||
assertFalse(masked.contains("д. 7"), masked);
|
||||
}
|
||||
|
||||
/**
|
||||
* «с.» перед числом — обычная запись страницы («с. 25»), а не населённого
|
||||
* пункта. Якорь не должен на этом срабатывать: правило требует заглавную
|
||||
* букву сразу после якоря, а не цифру.
|
||||
*/
|
||||
@Test
|
||||
void pageReferenceIsNotMistakenForSettlement() {
|
||||
String text = "См. с. 25 договора";
|
||||
assertEquals(text, mask(text), "номер страницы не должен приниматься за населённый пункт");
|
||||
}
|
||||
}
|
||||
@@ -5,38 +5,58 @@ import org.junit.jupiter.api.Test;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
/** Словарь городов — проверка вместо любого слова с заглавной буквы после «г.». */
|
||||
/** Словарь населённых пунктов — не только города, но и сёла, посёлки, деревни, хутора. */
|
||||
class ToponymDictionaryTest {
|
||||
|
||||
@Test
|
||||
void recognisesNominativeCase() {
|
||||
assertTrue(ToponymDictionary.isKnownCity("Москва"));
|
||||
assertTrue(ToponymDictionary.isKnownCity("Казань"));
|
||||
assertTrue(ToponymDictionary.isKnownCity("Санкт-Петербург"));
|
||||
assertTrue(ToponymDictionary.isKnownSettlement("Москва"));
|
||||
assertTrue(ToponymDictionary.isKnownSettlement("Казань"));
|
||||
assertTrue(ToponymDictionary.isKnownSettlement("Санкт-Петербург"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void recognisesInflectedForms() {
|
||||
assertTrue(ToponymDictionary.isKnownCity("Москве"), "дательный падеж города на гласную");
|
||||
assertTrue(ToponymDictionary.isKnownCity("Тамбове"), "предложный падеж города на согласную");
|
||||
assertTrue(ToponymDictionary.isKnownCity("Казани"), "родительный падеж");
|
||||
assertTrue(ToponymDictionary.isKnownSettlement("Москве"), "дательный падеж города на гласную");
|
||||
assertTrue(ToponymDictionary.isKnownSettlement("Тамбове"), "предложный падеж города на согласную");
|
||||
assertTrue(ToponymDictionary.isKnownSettlement("Казани"), "родительный падеж");
|
||||
}
|
||||
|
||||
@Test
|
||||
void recognisesCisCapitals() {
|
||||
assertTrue(ToponymDictionary.isKnownCity("Минск"));
|
||||
assertTrue(ToponymDictionary.isKnownCity("Алматы"));
|
||||
assertTrue(ToponymDictionary.isKnownSettlement("Минск"));
|
||||
assertTrue(ToponymDictionary.isKnownSettlement("Алматы"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsMadeUpWord() {
|
||||
assertFalse(ToponymDictionary.isKnownCity("Ерунда"));
|
||||
assertFalse(ToponymDictionary.isKnownCity("Бла-бла"));
|
||||
assertFalse(ToponymDictionary.isKnownSettlement("Ерунда"));
|
||||
assertFalse(ToponymDictionary.isKnownSettlement("Бла-бла"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void isCaseInsensitive() {
|
||||
assertTrue(ToponymDictionary.isKnownCity("МОСКВА"));
|
||||
assertTrue(ToponymDictionary.isKnownCity("москва"));
|
||||
assertTrue(ToponymDictionary.isKnownSettlement("МОСКВА"));
|
||||
assertTrue(ToponymDictionary.isKnownSettlement("москва"));
|
||||
}
|
||||
|
||||
/**
|
||||
* Из переписи 2020–2021, не из ручного списка городов: сёла, посёлки,
|
||||
* деревни, хутора, станицы, аулы, аалы — ровно то, чего не было, пока
|
||||
* словарь ограничивался официальными городами. Примеры из ТЗ («рп.
|
||||
* Ильинское», «с. Кукуево») и по одному реальному названию на тип
|
||||
* населённого пункта.
|
||||
*/
|
||||
@Test
|
||||
void recognisesSettlementsFromCensusNotJustOfficialCities() {
|
||||
assertTrue(ToponymDictionary.isKnownSettlement("Ильинское"), "рп. Ильинское — пример из ТЗ");
|
||||
assertTrue(ToponymDictionary.isKnownSettlement("Кукуево"), "с. Кукуево — пример из ТЗ");
|
||||
assertTrue(ToponymDictionary.isKnownSettlement("Прогресс"), "хутор");
|
||||
assertTrue(ToponymDictionary.isKnownSettlement("Гиагинская"), "станица");
|
||||
assertTrue(ToponymDictionary.isKnownSettlement("Блечепсин"), "аул");
|
||||
assertTrue(ToponymDictionary.isKnownSettlement("Энем"), "посёлок городского типа");
|
||||
assertTrue(ToponymDictionary.isKnownSettlement("Аксеновка"), "деревня");
|
||||
assertTrue(ToponymDictionary.isKnownSettlement("Екатериновка"), "слобода");
|
||||
assertTrue(ToponymDictionary.isKnownSettlement("Сартыков"), "аал (Хакасия)");
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user