feat: denylist streets named after people, fix adjectival surname declension
"Богдана Хмельницкого" and "Великие Луки" (a real city, misread as the
given name "Лука") were already handled by the ORGANISATION_NEARBY veto
added earlier — but only when an organization word is nearby. A street
name with no such word close by ("Живу на улице Карла Маркса") had no
protection, since single-word commemorative streets ("улица Ленина")
never matched the two-word FIO patterns to begin with — it's only the
two-word ones (person's first + last name) that are at risk. Added
~24 of Russia's most common commemorative street-name surnames
(Rosreestr's own ranking: Lenin is #1, Gagarin #2, Kirov #3, ...) to
the existing well-known-people denylist, reusing the mechanism already
built for Pushkin/Tolstoy.
Testing it surfaced a real gap in Declension: surnames declining as an
adjective ("Дзержинский" → "Дзержинского", "-ий" replaced by "-ого"
wholesale, not extended) weren't matching at all in genitive case —
and genitive is the ONLY case a street name is ever written in
("улица Дзержинского", never "улица Дзержинский"). Single-vowel
stripping fixed regular surnames (Пушкин → Пушкина) but not this
pattern. Now strips the whole "-ск-" ending down to the "ск" stem
before falling back to the vowel-strip. Adjectives without "-ск-"
("Толстой" → "Толстого") remain a known, documented limitation.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 5
parent
c25ab9c99f
commit
7f8e0d2d2e
@@ -10,19 +10,41 @@ import java.util.Locale;
|
|||||||
* «Пушкин» → «Пушкина») — там основы из списка достаточно как есть. Слова на
|
* «Пушкин» → «Пушкина») — там основы из списка достаточно как есть. Слова на
|
||||||
* гласную меняют последнюю букву («Москва» → «Москве», «Ольга» → «Ольге») —
|
* гласную меняют последнюю букву («Москва» → «Москве», «Ольга» → «Ольге») —
|
||||||
* для них сравнение идёт по основе без неё.
|
* для них сравнение идёт по основе без неё.
|
||||||
|
*
|
||||||
|
* <p>Фамилии на «-ский» склоняются как прилагательное: окончание меняется
|
||||||
|
* целиком («Дзержинский» → «Дзержинского», «-ий» на «-ого», а не дописывается),
|
||||||
|
* поэтому для них отсечения одной буквы недостаточно — основа обрезается сразу
|
||||||
|
* до «ск». Для улиц в честь людей это не редкий случай, а основной: «улица
|
||||||
|
* Дзержинского», «улица Островского» пишутся только в родительном падеже,
|
||||||
|
* именительный там не встречается вообще.
|
||||||
*/
|
*/
|
||||||
final class Declension {
|
final class Declension {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Падежные окончания прилагательного склонения на «-ск-»: мужской, женский
|
||||||
|
* и средний род, все падежи. Проверяются от длинных к коротким — «-ского»
|
||||||
|
* не должно потеряться из-за более короткого совпадения на «-ким» и т.п.
|
||||||
|
*/
|
||||||
|
private static final String[] ADJECTIVE_ENDINGS = {
|
||||||
|
"ского", "скому", "ским", "ском", "скую", "ской", "скою", "ская", "ский"
|
||||||
|
};
|
||||||
|
|
||||||
private Declension() {
|
private Declension() {
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Отбрасывает у основы конечную гласную, которая меняется по падежам.
|
* Отбрасывает у основы окончание, которое меняется по падежам: гласную —
|
||||||
|
* у обычных слов, целиком «-ск-»-окончание — у прилагательных фамилий.
|
||||||
* Слова короче четырёх букв не трогает — короткая основа и так шире
|
* Слова короче четырёх букв не трогает — короткая основа и так шире
|
||||||
* большинства падежных форм.
|
* большинства падежных форм.
|
||||||
*/
|
*/
|
||||||
static String withoutInflectedEnding(String word) {
|
static String withoutInflectedEnding(String word) {
|
||||||
String lower = word.toLowerCase(Locale.ROOT);
|
String lower = word.toLowerCase(Locale.ROOT);
|
||||||
|
for (String ending : ADJECTIVE_ENDINGS) {
|
||||||
|
if (lower.length() > ending.length() && lower.endsWith(ending)) {
|
||||||
|
return lower.substring(0, lower.length() - ending.length() + 2);
|
||||||
|
}
|
||||||
|
}
|
||||||
if (lower.length() >= 4 && "аяйь".indexOf(lower.charAt(lower.length() - 1)) >= 0) {
|
if (lower.length() >= 4 && "аяйь".indexOf(lower.charAt(lower.length() - 1)) >= 0) {
|
||||||
return lower.substring(0, lower.length() - 1);
|
return lower.substring(0, lower.length() - 1);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -59,3 +59,34 @@
|
|||||||
Дерипаска
|
Дерипаска
|
||||||
Абрамович
|
Абрамович
|
||||||
Усманов
|
Усманов
|
||||||
|
|
||||||
|
# Фамилии, в честь которых чаще всего называют улицы в России (Росреестр,
|
||||||
|
# Яндекс.Исследования). Упоминание «улица Ленина» само по себе не задевает
|
||||||
|
# распознавание ФИО — для него нужны два слова, — но составные названия
|
||||||
|
# («Феликса Дзержинского», «Александра Матросова») попадают под то же
|
||||||
|
# правило, что и «Богдана Хмельницкого»: в честь человека, а не клиент.
|
||||||
|
Ленин
|
||||||
|
Киров
|
||||||
|
Свердлов
|
||||||
|
Дзержинский
|
||||||
|
Фрунзе
|
||||||
|
Куйбышев
|
||||||
|
Ворошилов
|
||||||
|
Будённый
|
||||||
|
Буденный
|
||||||
|
Чапаев
|
||||||
|
Жуков
|
||||||
|
Чкалов
|
||||||
|
Терешкова
|
||||||
|
Мичурин
|
||||||
|
Шевченко
|
||||||
|
Орджоникидзе
|
||||||
|
Калинин
|
||||||
|
Энгельс
|
||||||
|
Маркс
|
||||||
|
Островский
|
||||||
|
Матросов
|
||||||
|
Волошина
|
||||||
|
Сусанин
|
||||||
|
Димитров
|
||||||
|
Донской
|
||||||
|
|||||||
@@ -0,0 +1,93 @@
|
|||||||
|
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;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Составные названия улиц в честь людей — тот же класс ложных срабатываний,
|
||||||
|
* что и «Богдана Хмельницкого» на реальных адресах Альфа-Банка: правило ФИО
|
||||||
|
* без ролевого слова ловит «имя + фамилия по словообразованию», а улица в
|
||||||
|
* честь исторической фигуры выглядит точно так же. Одиночная фамилия («улица
|
||||||
|
* Ленина») под это правило не подпадает вообще — ему нужны два слова, поэтому
|
||||||
|
* все примеры здесь двухсловные, реальные названия улиц.
|
||||||
|
*/
|
||||||
|
class StreetDenylistTest {
|
||||||
|
|
||||||
|
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 assertUnmasked(String text) {
|
||||||
|
assertEquals(text, mask(text), "улица в честь исторической фигуры не должна маскироваться: " + text);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void doesNotMaskNikolaiOstrovskyStreet() {
|
||||||
|
assertUnmasked("Живу на улице Николая Островского уже десять лет");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void doesNotMaskAlexanderMatrosovStreet() {
|
||||||
|
assertUnmasked("Магазин находится на улице Александра Матросова");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void doesNotMaskValeryChkalovStreet() {
|
||||||
|
assertUnmasked("Заезжайте на улицу Валерия Чкалова");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void doesNotMaskVeraVoloshinaStreet() {
|
||||||
|
assertUnmasked("Новый дом построили на улице Веры Волошиной");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void doesNotMaskIvanSusaninStreet() {
|
||||||
|
assertUnmasked("Школа расположена на улице Ивана Сусанина");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void doesNotMaskSergeyKirovStreet() {
|
||||||
|
assertUnmasked("Парковка есть на улице Сергея Кирова");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void doesNotMaskGeorgiDimitrovStreet() {
|
||||||
|
assertUnmasked("Остановка на улице Георгия Димитрова");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Тот же принцип, что и у Пушкина: рядом с другим ПД денилист не
|
||||||
|
* применяется — если в тексте всё-таки есть настоящие персональные данные,
|
||||||
|
* совпадение с историческим именем их не прикрывает.
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
void masksCommemorativeStreetNameWhenOtherPersonalDataIsPresent() {
|
||||||
|
String masked = mask("Живу на улице Николая Островского, тел. +7 916 123-45-67");
|
||||||
|
assertFalse(masked.contains("Николая Островского"), masked);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Контроль: обычное клиентское имя того же грамматического вида всё ещё маскируется. */
|
||||||
|
@Test
|
||||||
|
void stillMasksRealClientNameWithSimilarPattern() {
|
||||||
|
String masked = mask("Живёт на улице, зовут Николая Смирнова");
|
||||||
|
assertFalse(masked.contains("Николая Смирнова"), masked);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void doesNotMaskDmitryDonskoyStreet() {
|
||||||
|
assertUnmasked("Дом стоит на улице Дмитрия Донского");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -42,6 +42,28 @@ class NameDictionaryTest {
|
|||||||
assertFalse(NameDictionary.isWellKnown("Заявление Смирнова"));
|
assertFalse(NameDictionary.isWellKnown("Заявление Смирнова"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Фамилии, в честь которых чаще всего называют улицы в России (Росреестр).
|
||||||
|
* Само по себе «улица Ленина» никогда не попало бы под ФИО — для этого
|
||||||
|
* правила нужны два слова, — но денилист должен покрывать и составные
|
||||||
|
* названия («Феликса Дзержинского»), и вариации написания («Будённый»/
|
||||||
|
* «Буденный»).
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
void bundledListCoversCommemorativeStreetNames() {
|
||||||
|
assertTrue(NameDictionary.isWellKnown("улица Кирова"));
|
||||||
|
// «-ский» склоняется целиком («Дзержинский» → «Дзержинского»), для таких
|
||||||
|
// основа обрезается сразу до «ск» — см. Declension.
|
||||||
|
assertTrue(NameDictionary.isWellKnown("проспект Дзержинского"));
|
||||||
|
assertTrue(NameDictionary.isWellKnown("улица Донского"));
|
||||||
|
// «Будённый» — чистое прилагательное без «-ский» (как «Толстой»): та же
|
||||||
|
// известная граница приёма, родительный падеж («Будённого») им не ловится.
|
||||||
|
assertTrue(NameDictionary.isWellKnown("улица Будённый"));
|
||||||
|
assertTrue(NameDictionary.isWellKnown("улица Буденный"), "написание без «ё» тоже должно ловиться");
|
||||||
|
assertTrue(NameDictionary.isWellKnown("улица Жукова"));
|
||||||
|
assertTrue(NameDictionary.isWellKnown("улица Островского"));
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void externalFileAddsNamesWithoutRebuild(@TempDir Path dir) throws IOException {
|
void externalFileAddsNamesWithoutRebuild(@TempDir Path dir) throws IOException {
|
||||||
Path file = dir.resolve("well-known.txt");
|
Path file = dir.resolve("well-known.txt");
|
||||||
|
|||||||
Reference in New Issue
Block a user