refactor: подтянуть Legal NER, переформатировать код и добавить тесты
Слияние с 685ec97 (третья ступень NER для юридических реквизитов),
код приведён к google-java-format, добавлены юнит-тесты
AdaptiveConcurrencyLimiter/SystemsConfig/PayloadCipher.
This commit is contained in:
@@ -1,5 +1,9 @@
|
||||
package ru.pdguard;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
|
||||
import java.util.UUID;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import ru.pdguard.config.SystemPolicy;
|
||||
import ru.pdguard.core.PayloadStore;
|
||||
@@ -7,149 +11,165 @@ 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 BankTypesTest {
|
||||
|
||||
private final Pipeline pipeline =
|
||||
new Pipeline(new RuleRegistry(), new Masker(), new PayloadStore(1_000_000L, 30));
|
||||
private final Pipeline pipeline =
|
||||
new Pipeline(new RuleRegistry(), new Masker(), new PayloadStore(1_000_000L, 30));
|
||||
|
||||
private void assertHidden(String text, String secret) {
|
||||
String masked = pipeline.process(text, UUID.randomUUID().toString(), SystemPolicy.DEFAULT);
|
||||
assertFalse(masked.contains(secret), "не замаскировано: «" + secret + "» в ответе «" + masked + "»");
|
||||
}
|
||||
private void assertHidden(String text, String secret) {
|
||||
String masked = pipeline.process(text, UUID.randomUUID().toString(), SystemPolicy.DEFAULT);
|
||||
assertFalse(
|
||||
masked.contains(secret), "не замаскировано: «" + secret + "» в ответе «" + masked + "»");
|
||||
}
|
||||
|
||||
/**
|
||||
* Банковские реквизиты маскируются рядом с данными человека. Сами по себе они
|
||||
* опознают организацию или счёт, а не клиента, и в перечне типов из задания их
|
||||
* нет — поэтому они переведены в {@code requireCompanion}, как пин-код и дата.
|
||||
*/
|
||||
@Test
|
||||
void masksAccountNumberNextToPersonalData() {
|
||||
assertHidden("Клиент Иванов Иван Иванович, расчётный счёт 40702810500000001234",
|
||||
"40702810500000001234");
|
||||
assertHidden("Иванов И.И., р/с 4070 2810 5000 0000 1234", "4070 2810 5000 0000 1234");
|
||||
}
|
||||
/**
|
||||
* Банковские реквизиты маскируются рядом с данными человека. Сами по себе они опознают
|
||||
* организацию или счёт, а не клиента, и в перечне типов из задания их нет — поэтому они
|
||||
* переведены в {@code requireCompanion}, как пин-код и дата.
|
||||
*/
|
||||
@Test
|
||||
void masksAccountNumberNextToPersonalData() {
|
||||
assertHidden(
|
||||
"Клиент Иванов Иван Иванович, расчётный счёт 40702810500000001234", "40702810500000001234");
|
||||
assertHidden("Иванов И.И., р/с 4070 2810 5000 0000 1234", "4070 2810 5000 0000 1234");
|
||||
}
|
||||
|
||||
@Test
|
||||
void masksBikNextToPersonalData() {
|
||||
assertHidden("Перевод Иванову Ивану Ивановичу, БИК 044525593 банка-получателя", "044525593");
|
||||
}
|
||||
@Test
|
||||
void masksBikNextToPersonalData() {
|
||||
assertHidden("Перевод Иванову Ивану Ивановичу, БИК 044525593 банка-получателя", "044525593");
|
||||
}
|
||||
|
||||
@Test
|
||||
void keepsBankDetailsWithoutAnyPersonalData() {
|
||||
for (String text : new String[]{
|
||||
"Расчётный счёт 40702810500000001234 открыт вчера",
|
||||
"БИК 044525593 банка-получателя",
|
||||
"ОГРН 1027700132195 организации",
|
||||
"КПП 770101001 указан в реквизитах"}) {
|
||||
assertEquals(text, pipeline.process(text, UUID.randomUUID().toString(), SystemPolicy.DEFAULT),
|
||||
"реквизиты без человека персональными данными не являются");
|
||||
}
|
||||
@Test
|
||||
void keepsBankDetailsWithoutAnyPersonalData() {
|
||||
for (String text :
|
||||
new String[] {
|
||||
"Расчётный счёт 40702810500000001234 открыт вчера",
|
||||
"БИК 044525593 банка-получателя",
|
||||
"ОГРН 1027700132195 организации",
|
||||
"КПП 770101001 указан в реквизитах"
|
||||
}) {
|
||||
assertEquals(
|
||||
text,
|
||||
pipeline.process(text, UUID.randomUUID().toString(), SystemPolicy.DEFAULT),
|
||||
"реквизиты без человека персональными данными не являются");
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void masksCardExpiryButNotCardNumber() {
|
||||
String masked = pipeline.process(
|
||||
"Карта 4111 1111 1111 1111, срок действия 09/27", "expiry-1", SystemPolicy.DEFAULT);
|
||||
assertFalse(masked.contains("09/27"), masked);
|
||||
assertEquals("Карта 41** **** **** **11, срок действия **/**", masked);
|
||||
}
|
||||
@Test
|
||||
void masksCardExpiryButNotCardNumber() {
|
||||
String masked =
|
||||
pipeline.process(
|
||||
"Карта 4111 1111 1111 1111, срок действия 09/27", "expiry-1", SystemPolicy.DEFAULT);
|
||||
assertFalse(masked.contains("09/27"), masked);
|
||||
assertEquals("Карта 41** **** **** **11, срок действия **/**", masked);
|
||||
}
|
||||
|
||||
@Test
|
||||
void masksOgrnAndOgrnipDifferently() {
|
||||
assertHidden("Директор Иванов И.И., ОГРН 1027700132195 организации", "1027700132195");
|
||||
assertHidden("ИП Иванов Иван Иванович, ОГРНИП 304500116000157", "304500116000157");
|
||||
}
|
||||
@Test
|
||||
void masksOgrnAndOgrnipDifferently() {
|
||||
assertHidden("Директор Иванов И.И., ОГРН 1027700132195 организации", "1027700132195");
|
||||
assertHidden("ИП Иванов Иван Иванович, ОГРНИП 304500116000157", "304500116000157");
|
||||
}
|
||||
|
||||
/** ОГРНИП (15 цифр) не должен наполовину ловиться правилом ОГРН (13 цифр). */
|
||||
@Test
|
||||
void ogrnDoesNotSwallowOgrnip() {
|
||||
String masked = pipeline.process("ИП Иванов Иван Иванович, ОГРНИП 304500116000157",
|
||||
"ogrnip-1", SystemPolicy.DEFAULT);
|
||||
assertFalse(masked.contains("304500116000157"), masked);
|
||||
assertFalse(masked.matches(".*\\d{15}.*"), "осталась незамаскированная часть номера: " + masked);
|
||||
}
|
||||
/** ОГРНИП (15 цифр) не должен наполовину ловиться правилом ОГРН (13 цифр). */
|
||||
@Test
|
||||
void ogrnDoesNotSwallowOgrnip() {
|
||||
String masked =
|
||||
pipeline.process(
|
||||
"ИП Иванов Иван Иванович, ОГРНИП 304500116000157", "ogrnip-1", SystemPolicy.DEFAULT);
|
||||
assertFalse(masked.contains("304500116000157"), masked);
|
||||
assertFalse(
|
||||
masked.matches(".*\\d{15}.*"), "осталась незамаскированная часть номера: " + masked);
|
||||
}
|
||||
|
||||
/**
|
||||
* Контрольная сумма отсекает случайное 13-значное число рядом со словом «ОГРН».
|
||||
* Число подобрано так, чтобы не проходить заодно и Луна — иначе оно всё равно
|
||||
* маскировалось бы, но уже как номер карты, и тест ничего бы не показывал.
|
||||
*/
|
||||
@Test
|
||||
void doesNotMaskOgrnWithBrokenChecksum() {
|
||||
String text = "ОГРН 1027700132190 организации";
|
||||
assertEquals(text, pipeline.process(text, UUID.randomUUID().toString(), SystemPolicy.DEFAULT),
|
||||
"число с неверной контрольной суммой не является настоящим ОГРН");
|
||||
}
|
||||
/**
|
||||
* Контрольная сумма отсекает случайное 13-значное число рядом со словом «ОГРН». Число подобрано
|
||||
* так, чтобы не проходить заодно и Луна — иначе оно всё равно маскировалось бы, но уже как номер
|
||||
* карты, и тест ничего бы не показывал.
|
||||
*/
|
||||
@Test
|
||||
void doesNotMaskOgrnWithBrokenChecksum() {
|
||||
String text = "ОГРН 1027700132190 организации";
|
||||
assertEquals(
|
||||
text,
|
||||
pipeline.process(text, UUID.randomUUID().toString(), SystemPolicy.DEFAULT),
|
||||
"число с неверной контрольной суммой не является настоящим ОГРН");
|
||||
}
|
||||
|
||||
/** Та же проверка для ОГРНИП — случайное 15-значное число рядом со словом. */
|
||||
@Test
|
||||
void doesNotMaskOgrnipWithBrokenChecksum() {
|
||||
String text = "ОГРНИП 304500116000150 предпринимателя";
|
||||
assertEquals(text, pipeline.process(text, UUID.randomUUID().toString(), SystemPolicy.DEFAULT),
|
||||
"число с неверной контрольной суммой не является настоящим ОГРНИП");
|
||||
}
|
||||
/** Та же проверка для ОГРНИП — случайное 15-значное число рядом со словом. */
|
||||
@Test
|
||||
void doesNotMaskOgrnipWithBrokenChecksum() {
|
||||
String text = "ОГРНИП 304500116000150 предпринимателя";
|
||||
assertEquals(
|
||||
text,
|
||||
pipeline.process(text, UUID.randomUUID().toString(), SystemPolicy.DEFAULT),
|
||||
"число с неверной контрольной суммой не является настоящим ОГРНИП");
|
||||
}
|
||||
|
||||
@Test
|
||||
void masksKppNextToPersonalData() {
|
||||
assertHidden("Заявитель Иванов И.И., КПП 770101001 указан в реквизитах", "770101001");
|
||||
}
|
||||
@Test
|
||||
void masksKppNextToPersonalData() {
|
||||
assertHidden("Заявитель Иванов И.И., КПП 770101001 указан в реквизитах", "770101001");
|
||||
}
|
||||
|
||||
@Test
|
||||
void masksIncomeNextToPersonalData() {
|
||||
assertHidden("Иванов Иван Иванович, доход 85 000 руб. в месяц", "85 000");
|
||||
assertHidden("Иванову И.И. начислена заработная плата 120000 в месяц", "120000");
|
||||
}
|
||||
@Test
|
||||
void masksIncomeNextToPersonalData() {
|
||||
assertHidden("Иванов Иван Иванович, доход 85 000 руб. в месяц", "85 000");
|
||||
assertHidden("Иванову И.И. начислена заработная плата 120000 в месяц", "120000");
|
||||
}
|
||||
|
||||
/**
|
||||
* Сумма заработка без человека — статистика или описание продукта. Опознать по
|
||||
* ней никого нельзя, а для прокси к языковой модели вымаранное число означает,
|
||||
* что вопрос про среднюю зарплату по отрасли отвечать уже не на чем.
|
||||
*/
|
||||
@Test
|
||||
void keepsIncomeWithoutAnyPersonalData() {
|
||||
for (String text : new String[]{
|
||||
"По данным Росстата доход домохозяйств вырос до 74 500 руб",
|
||||
"Зарплатный проект: зарплата 80 000 руб перечисляется на счёт"}) {
|
||||
assertEquals(text, pipeline.process(text, UUID.randomUUID().toString(), SystemPolicy.DEFAULT),
|
||||
"сумма заработка без человека персональными данными не является");
|
||||
}
|
||||
/**
|
||||
* Сумма заработка без человека — статистика или описание продукта. Опознать по ней никого нельзя,
|
||||
* а для прокси к языковой модели вымаранное число означает, что вопрос про среднюю зарплату по
|
||||
* отрасли отвечать уже не на чем.
|
||||
*/
|
||||
@Test
|
||||
void keepsIncomeWithoutAnyPersonalData() {
|
||||
for (String text :
|
||||
new String[] {
|
||||
"По данным Росстата доход домохозяйств вырос до 74 500 руб",
|
||||
"Зарплатный проект: зарплата 80 000 руб перечисляется на счёт"
|
||||
}) {
|
||||
assertEquals(
|
||||
text,
|
||||
pipeline.process(text, UUID.randomUUID().toString(), SystemPolicy.DEFAULT),
|
||||
"сумма заработка без человека персональными данными не является");
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void masksBiometricMentionNextToPersonalData() {
|
||||
assertHidden("Клиент Иванов Иван Иванович сдал биометрические данные", "биометрические данные");
|
||||
assertHidden("Для Иванова И.И. оформлен слепок голоса", "слепок голоса");
|
||||
}
|
||||
@Test
|
||||
void masksBiometricMentionNextToPersonalData() {
|
||||
assertHidden("Клиент Иванов Иван Иванович сдал биометрические данные", "биометрические данные");
|
||||
assertHidden("Для Иванова И.И. оформлен слепок голоса", "слепок голоса");
|
||||
}
|
||||
|
||||
/**
|
||||
* Биометрии в тексте не бывает: это шаблон в базе, и правило маскирует само
|
||||
* упоминание — слово, а не данные. Без человека рядом такая замена скрывает
|
||||
* ноль сведений и разрушает смысл фразы.
|
||||
*/
|
||||
@Test
|
||||
void keepsBiometricMentionWithoutAnyPersonalData() {
|
||||
for (String text : new String[]{
|
||||
"Банк внедрил биометрические данные в обслуживание клиентов",
|
||||
"Сдать биометрию можно через ЕБС в любом отделении"}) {
|
||||
assertEquals(text, pipeline.process(text, UUID.randomUUID().toString(), SystemPolicy.DEFAULT),
|
||||
"упоминание биометрии без человека персональными данными не является");
|
||||
}
|
||||
/**
|
||||
* Биометрии в тексте не бывает: это шаблон в базе, и правило маскирует само упоминание — слово, а
|
||||
* не данные. Без человека рядом такая замена скрывает ноль сведений и разрушает смысл фразы.
|
||||
*/
|
||||
@Test
|
||||
void keepsBiometricMentionWithoutAnyPersonalData() {
|
||||
for (String text :
|
||||
new String[] {
|
||||
"Банк внедрил биометрические данные в обслуживание клиентов",
|
||||
"Сдать биометрию можно через ЕБС в любом отделении"
|
||||
}) {
|
||||
assertEquals(
|
||||
text,
|
||||
pipeline.process(text, UUID.randomUUID().toString(), SystemPolicy.DEFAULT),
|
||||
"упоминание биометрии без человека персональными данными не является");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Два несамостоятельных типа рядом не заверяют друг друга: сочетание даты и
|
||||
* ОГРН самостоятельным не становится, человека в таком тексте нет.
|
||||
*/
|
||||
@Test
|
||||
void twoCompanionTypesDoNotVouchForEachOther() {
|
||||
String text = "Оплата 01.02.2025, ОГРН 1027700132195";
|
||||
assertEquals(text, pipeline.process(text, UUID.randomUUID().toString(), SystemPolicy.DEFAULT),
|
||||
"спутники заверили друг друга в отсутствие настоящих ПД");
|
||||
}
|
||||
/**
|
||||
* Два несамостоятельных типа рядом не заверяют друг друга: сочетание даты и ОГРН самостоятельным
|
||||
* не становится, человека в таком тексте нет.
|
||||
*/
|
||||
@Test
|
||||
void twoCompanionTypesDoNotVouchForEachOther() {
|
||||
String text = "Оплата 01.02.2025, ОГРН 1027700132195";
|
||||
assertEquals(
|
||||
text,
|
||||
pipeline.process(text, UUID.randomUUID().toString(), SystemPolicy.DEFAULT),
|
||||
"спутники заверили друг друга в отсутствие настоящих ПД");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
package ru.pdguard;
|
||||
|
||||
import ru.pdguard.detect.Span;
|
||||
|
||||
import java.io.BufferedReader;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
@@ -12,56 +10,57 @@ import java.util.List;
|
||||
import java.util.Objects;
|
||||
import java.util.regex.Matcher;
|
||||
import java.util.regex.Pattern;
|
||||
import ru.pdguard.detect.Span;
|
||||
|
||||
/**
|
||||
* Общий разбор размеченных наборов {@code {{ТИП:значение}}} — используется
|
||||
* и {@link BenchmarkTest} (замер качества по строкам), и {@link LargeTextTest}
|
||||
* (те же строки, перемешанные и склеенные в большой текст).
|
||||
* Общий разбор размеченных наборов {@code {{ТИП:значение}}} — используется и {@link BenchmarkTest}
|
||||
* (замер качества по строкам), и {@link LargeTextTest} (те же строки, перемешанные и склеенные в
|
||||
* большой текст).
|
||||
*/
|
||||
final class BenchmarkFixtures {
|
||||
|
||||
private static final Pattern MARKUP = Pattern.compile("\\{\\{([A-Z_]+):([^}]*)}}");
|
||||
private static final Pattern MARKUP = Pattern.compile("\\{\\{([A-Z_]+):([^}]*)}}");
|
||||
|
||||
/** Размеченный пример: чистый текст и эталонные фрагменты. */
|
||||
record Sample(String text, List<Span> gold) {
|
||||
}
|
||||
/** Размеченный пример: чистый текст и эталонные фрагменты. */
|
||||
record Sample(String text, List<Span> gold) {}
|
||||
|
||||
private BenchmarkFixtures() {
|
||||
}
|
||||
private BenchmarkFixtures() {}
|
||||
|
||||
/** Читает набор построчно, пропуская пустые строки и комментарии {@code #}. */
|
||||
static List<Sample> load(String resource) {
|
||||
List<Sample> 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);
|
||||
/** Читает набор построчно, пропуская пустые строки и комментарии {@code #}. */
|
||||
static List<Sample> load(String resource) {
|
||||
List<Sample> 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));
|
||||
}
|
||||
return samples;
|
||||
}
|
||||
} catch (IOException e) {
|
||||
throw new IllegalStateException("Не удалось прочитать " + resource, e);
|
||||
}
|
||||
return samples;
|
||||
}
|
||||
|
||||
/** Разбирает разметку {@code {{ТИП:значение}}} в чистый текст и эталонные фрагменты. */
|
||||
static Sample parse(String line) {
|
||||
StringBuilder text = new StringBuilder(line.length());
|
||||
List<Span> 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);
|
||||
/** Разбирает разметку {@code {{ТИП:значение}}} в чистый текст и эталонные фрагменты. */
|
||||
static Sample parse(String line) {
|
||||
StringBuilder text = new StringBuilder(line.length());
|
||||
List<Span> 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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,14 +1,7 @@
|
||||
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.Span;
|
||||
import ru.pdguard.detect.NameCascade;
|
||||
import ru.pdguard.detect.PdTypes;
|
||||
import ru.pdguard.detect.RuleRegistry;
|
||||
import ru.pdguard.mask.Masker;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
import static org.junit.jupiter.api.Assumptions.assumeTrue;
|
||||
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
@@ -18,348 +11,431 @@ import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
import static org.junit.jupiter.api.Assumptions.assumeTrue;
|
||||
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.NameCascade;
|
||||
import ru.pdguard.detect.PdTypes;
|
||||
import ru.pdguard.detect.RuleRegistry;
|
||||
import ru.pdguard.detect.Span;
|
||||
import ru.pdguard.mask.Masker;
|
||||
|
||||
/**
|
||||
* Замер качества детекции на размеченных наборах.
|
||||
*
|
||||
* <p>Наборов два. {@code benchmark.txt} использовался при отладке правил, поэтому
|
||||
* его оценка завышена и годится только как защита от ухудшений.
|
||||
* {@code benchmark-holdout.txt} составлен независимо и на нём правила не
|
||||
* настраивались — именно он показывает настоящее качество.
|
||||
* <p>Наборов два. {@code benchmark.txt} использовался при отладке правил, поэтому его оценка
|
||||
* завышена и годится только как защита от ухудшений. {@code benchmark-holdout.txt} составлен
|
||||
* независимо и на нём правила не настраивались — именно он показывает настоящее качество.
|
||||
*
|
||||
* <p>Метрики посимвольные: так они не зависят от того, где именно правило
|
||||
* поставило границу совпадения, и напрямую соотносятся с посимвольным
|
||||
* сравнением замаскированного текста с эталоном.
|
||||
* <p>Метрики посимвольные: так они не зависят от того, где именно правило поставило границу
|
||||
* совпадения, и напрямую соотносятся с посимвольным сравнением замаскированного текста с эталоном.
|
||||
*
|
||||
* <p>Отдельно считается строка «любой тип»: для защиты важно, что знаки скрыты,
|
||||
* а расхождение в названии типа (скажем, место рождения против города) на
|
||||
* качество маскирования не влияет.
|
||||
* <p>Отдельно считается строка «любой тип»: для защиты важно, что знаки скрыты, а расхождение в
|
||||
* названии типа (скажем, место рождения против города) на качество маскирования не влияет.
|
||||
*/
|
||||
class BenchmarkTest {
|
||||
|
||||
/**
|
||||
* Вторая ступень для замера. Модели нет — прогон идёт на одних правилах, и это
|
||||
* видно по заголовку отчёта. Путь подменяется свойством {@code -Dbench.model=...}.
|
||||
*/
|
||||
private static final String ENGINE = System.getProperty("bench.engine", "rubert");
|
||||
private static final String MODEL_PATH = System.getProperty("bench.model", "models/rubert-ner");
|
||||
/**
|
||||
* Вторая ступень для замера. Модели нет — прогон идёт на одних правилах, и это видно по заголовку
|
||||
* отчёта. Путь подменяется свойством {@code -Dbench.model=...}.
|
||||
*/
|
||||
private static final String ENGINE = System.getProperty("bench.engine", "rubert");
|
||||
|
||||
/** Итог замера по одному набору. */
|
||||
private record Result(double fioF1, double overallPrecision, double overallRecall,
|
||||
double falsePositiveRate, int foundFioSpans, int goldFioSpans) {
|
||||
private static final String MODEL_PATH = System.getProperty("bench.model", "models/rubert-ner");
|
||||
|
||||
/** Итог замера по одному набору. */
|
||||
private record Result(
|
||||
double fioF1,
|
||||
double overallPrecision,
|
||||
double overallRecall,
|
||||
double falsePositiveRate,
|
||||
int foundFioSpans,
|
||||
int goldFioSpans) {}
|
||||
|
||||
/** Накопитель посимвольных совпадений по одному типу. */
|
||||
private static final class Score {
|
||||
private int truePositive;
|
||||
private int falsePositive;
|
||||
private int falseNegative;
|
||||
|
||||
private int gold() {
|
||||
return truePositive + falseNegative;
|
||||
}
|
||||
|
||||
/** Накопитель посимвольных совпадений по одному типу. */
|
||||
private static final class Score {
|
||||
private int truePositive;
|
||||
private int falsePositive;
|
||||
private int falseNegative;
|
||||
private double precision() {
|
||||
int found = truePositive + falsePositive;
|
||||
return found == 0 ? 1.0 : (double) truePositive / found;
|
||||
}
|
||||
|
||||
private int gold() {
|
||||
return truePositive + falseNegative;
|
||||
private double recall() {
|
||||
return gold() == 0 ? 1.0 : (double) truePositive / gold();
|
||||
}
|
||||
|
||||
private double f1() {
|
||||
double p = precision();
|
||||
double r = recall();
|
||||
return p + r == 0 ? 0.0 : 2 * p * r / (p + r);
|
||||
}
|
||||
}
|
||||
|
||||
private final Pipeline pipeline =
|
||||
new Pipeline(new RuleRegistry(), new Masker(), new PayloadStore(10_000_000L, 30));
|
||||
|
||||
/**
|
||||
* Набор, на котором правила отлаживались. Пороги здесь высокие: любое падение означает, что
|
||||
* сломалось то, что раньше работало.
|
||||
*/
|
||||
@Test
|
||||
void detectionQualityOnTuningSet() {
|
||||
Result result = measure("/benchmark.txt", "набор отладки");
|
||||
|
||||
assertTrue(result.fioF1() >= 0.95, String.format("F1 по ФИО упал до %.3f", result.fioF1()));
|
||||
assertTrue(
|
||||
result.overallRecall() >= 0.95,
|
||||
String.format("полнота по всем типам упала до %.3f", result.overallRecall()));
|
||||
assertTrue(
|
||||
result.falsePositiveRate() <= 0.05,
|
||||
String.format("ложные срабатывания на чистых текстах: %.3f", result.falsePositiveRate()));
|
||||
}
|
||||
|
||||
/**
|
||||
* Отложенный набор: правила на нём не настраивались. Пороги ниже — они отражают измеренное на нём
|
||||
* качество, а не желаемое.
|
||||
*/
|
||||
@Test
|
||||
void detectionQualityOnHoldoutSet() {
|
||||
Result result = measure("/benchmark-holdout.txt", "отложенный набор");
|
||||
|
||||
assertTrue(
|
||||
result.fioF1() >= 0.75,
|
||||
String.format("F1 по ФИО на отложенном наборе упал до %.3f", result.fioF1()));
|
||||
assertTrue(
|
||||
result.overallRecall() >= 0.75,
|
||||
String.format("полнота на отложенном наборе упала до %.3f", result.overallRecall()));
|
||||
assertTrue(
|
||||
result.falsePositiveRate() <= 0.15,
|
||||
String.format(
|
||||
"ложные срабатывания на отложенном наборе: %.3f", result.falsePositiveRate()));
|
||||
}
|
||||
|
||||
/**
|
||||
* Второй контрольный набор, составленный после того, как первый дважды повлиял на правила. На нём
|
||||
* не настраивалось ничего — он и показывает настоящее качество. Пороги низкие намеренно: тест
|
||||
* ловит обвал, а не сторожит значение.
|
||||
*/
|
||||
@Test
|
||||
void detectionQualityOnThirdHoldoutSet() {
|
||||
Pipeline stage =
|
||||
Files.isReadable(Path.of(MODEL_PATH))
|
||||
? new Pipeline(
|
||||
new RuleRegistry(),
|
||||
new Masker(),
|
||||
new PayloadStore(10_000_000L, 30),
|
||||
new NameCascade(ENGINE, Optional.of(MODEL_PATH), 16, 4))
|
||||
: pipeline;
|
||||
Result result = measure(stage, "/benchmark-holdout3.txt", "второй контрольный набор");
|
||||
|
||||
assertTrue(
|
||||
result.fioF1() >= 0.70,
|
||||
String.format("F1 по ФИО на втором контрольном наборе упал до %.3f", result.fioF1()));
|
||||
assertTrue(
|
||||
result.overallRecall() >= 0.70,
|
||||
String.format(
|
||||
"полнота на втором контрольном наборе упала до %.3f", result.overallRecall()));
|
||||
}
|
||||
|
||||
/**
|
||||
* Контрольный набор. Правила по нему не настраиваются: он существует, чтобы показывать качество
|
||||
* на данных, которых разработка не видела. Пороги здесь низкие намеренно — тест ловит обвал, а не
|
||||
* сторожит достигнутое значение. Замер идёт со второй ступенью, если модель собрана, иначе на
|
||||
* одних правилах.
|
||||
*/
|
||||
@Test
|
||||
void detectionQualityOnSecondHoldoutSet() {
|
||||
Pipeline stage =
|
||||
Files.isReadable(Path.of(MODEL_PATH))
|
||||
? new Pipeline(
|
||||
new RuleRegistry(),
|
||||
new Masker(),
|
||||
new PayloadStore(10_000_000L, 30),
|
||||
new NameCascade(ENGINE, Optional.of(MODEL_PATH), 16, 4))
|
||||
: pipeline;
|
||||
Result result = measure(stage, "/benchmark-holdout2.txt", "второй отложенный набор");
|
||||
|
||||
assertTrue(
|
||||
result.fioF1() >= 0.70,
|
||||
String.format("F1 по ФИО на втором отложенном наборе упал до %.3f", result.fioF1()));
|
||||
assertTrue(
|
||||
result.overallRecall() >= 0.70,
|
||||
String.format("полнота на втором отложенном наборе упала до %.3f", result.overallRecall()));
|
||||
}
|
||||
|
||||
/**
|
||||
* Реальные адреса отделений Альфа-Банка (ловушка из ТЗ — не ПД клиента), расширенный денилист,
|
||||
* обобщённое companion-правило (место рождения, страна) и новые банковские типы. Собран
|
||||
* специально под соответствующие доработки — пороги ниже, чем у набора отладки, но проверяют
|
||||
* именно то, что было доработано, а не общее качество остального пайплайна.
|
||||
*/
|
||||
@Test
|
||||
void detectionQualityOnBankContextSet() {
|
||||
Result result = measure("/benchmark-bank-context.txt", "банковский контекст");
|
||||
|
||||
assertTrue(
|
||||
result.fioF1() >= 0.70,
|
||||
String.format("F1 по ФИО на банковском наборе упал до %.3f", result.fioF1()));
|
||||
assertTrue(
|
||||
result.overallRecall() >= 0.70,
|
||||
String.format("полнота на банковском наборе упала до %.3f", result.overallRecall()));
|
||||
assertTrue(
|
||||
result.falsePositiveRate() <= 0.10,
|
||||
String.format(
|
||||
"ложные срабатывания на банковском наборе: %.3f", result.falsePositiveRate()));
|
||||
}
|
||||
|
||||
/**
|
||||
* Независимый сгенерированный набор — покрывает все типы ПД из ТЗ и вариации написания, не
|
||||
* встречавшиеся ни в одном из остальных наборов. Правила под него не настраивались; пороги низкие
|
||||
* по той же причине, что и у второго отложенного набора — тест ловит обвал, а не сторожит
|
||||
* достигнутое значение.
|
||||
*/
|
||||
@Test
|
||||
void detectionQualityOnGeneratedSet() {
|
||||
Pipeline stage =
|
||||
Files.isReadable(Path.of(MODEL_PATH))
|
||||
? new Pipeline(
|
||||
new RuleRegistry(),
|
||||
new Masker(),
|
||||
new PayloadStore(10_000_000L, 30),
|
||||
new NameCascade(ENGINE, Optional.of(MODEL_PATH), 16, 4))
|
||||
: pipeline;
|
||||
Result result = measure(stage, "/benchmark-generated.txt", "сгенерированный набор");
|
||||
|
||||
assertTrue(
|
||||
result.fioF1() >= 0.70,
|
||||
String.format("F1 по ФИО на сгенерированном наборе упал до %.3f", result.fioF1()));
|
||||
assertTrue(
|
||||
result.overallRecall() >= 0.70,
|
||||
String.format("полнота на сгенерированном наборе упала до %.3f", result.overallRecall()));
|
||||
}
|
||||
|
||||
/**
|
||||
* Тот же отложенный набор, но со включённой второй ступенью. Модели нет — проверка пропускается:
|
||||
* в сборке без модели сервис работает на одних правилах.
|
||||
*/
|
||||
@Test
|
||||
void detectionQualityWithNameCascade() {
|
||||
Path model = Path.of(MODEL_PATH);
|
||||
assumeTrue(Files.isReadable(model), "модель " + model.toAbsolutePath() + " не собрана");
|
||||
|
||||
Pipeline withCascade =
|
||||
new Pipeline(
|
||||
new RuleRegistry(),
|
||||
new Masker(),
|
||||
new PayloadStore(10_000_000L, 30),
|
||||
new NameCascade(ENGINE, Optional.of(MODEL_PATH), 16, 4));
|
||||
Result result =
|
||||
measure(withCascade, "/benchmark-holdout.txt", "отложенный набор, вторая ступень включена");
|
||||
|
||||
assertTrue(
|
||||
result.fioF1() >= 0.75,
|
||||
String.format("F1 по ФИО со второй ступенью упал до %.3f", result.fioF1()));
|
||||
}
|
||||
|
||||
private Result measure(String resource, String title) {
|
||||
return measure(pipeline, resource, title);
|
||||
}
|
||||
|
||||
private Result measure(Pipeline stage, String resource, String title) {
|
||||
List<BenchmarkFixtures.Sample> samples = BenchmarkFixtures.load(resource);
|
||||
Map<String, Score> byType = new LinkedHashMap<>();
|
||||
Score anyType = new Score();
|
||||
|
||||
int cleanTexts = 0;
|
||||
int cleanTextsWithFalseHit = 0;
|
||||
int goldFioSpans = 0;
|
||||
int foundFioSpans = 0;
|
||||
List<String> falseHits = new ArrayList<>();
|
||||
List<String> missedFio = new ArrayList<>();
|
||||
List<String> overMasked = new ArrayList<>();
|
||||
|
||||
for (BenchmarkFixtures.Sample sample : samples) {
|
||||
List<Span> found = stage.findPersonalData(sample.text(), SystemPolicy.DEFAULT);
|
||||
|
||||
String[] goldChars = paint(sample.text().length(), sample.gold());
|
||||
String[] foundChars = paint(sample.text().length(), found);
|
||||
|
||||
for (int i = 0; i < sample.text().length(); i++) {
|
||||
account(byType, goldChars[i], foundChars[i]);
|
||||
accountAnyType(anyType, goldChars[i] != null, foundChars[i] != null);
|
||||
}
|
||||
|
||||
if (sample.gold().isEmpty()) {
|
||||
cleanTexts++;
|
||||
if (!found.isEmpty()) {
|
||||
cleanTextsWithFalseHit++;
|
||||
falseHits.add(fragment(sample.text(), found.get(0)) + " ← " + sample.text());
|
||||
}
|
||||
} else {
|
||||
collectOverMasked(sample.text(), goldChars, foundChars, overMasked);
|
||||
}
|
||||
|
||||
private double precision() {
|
||||
int found = truePositive + falsePositive;
|
||||
return found == 0 ? 1.0 : (double) truePositive / found;
|
||||
for (Span gold : sample.gold()) {
|
||||
if (!PdTypes.FIO.equals(gold.type())) {
|
||||
continue;
|
||||
}
|
||||
|
||||
private double recall() {
|
||||
return gold() == 0 ? 1.0 : (double) truePositive / gold();
|
||||
}
|
||||
|
||||
private double f1() {
|
||||
double p = precision();
|
||||
double r = recall();
|
||||
return p + r == 0 ? 0.0 : 2 * p * r / (p + r);
|
||||
goldFioSpans++;
|
||||
if (overlappedByFio(gold, found)) {
|
||||
foundFioSpans++;
|
||||
} else {
|
||||
missedFio.add(fragment(sample.text(), gold) + " ← " + sample.text());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private final Pipeline pipeline =
|
||||
new Pipeline(new RuleRegistry(), new Masker(), new PayloadStore(10_000_000L, 30));
|
||||
report(
|
||||
title,
|
||||
samples.size(),
|
||||
byType,
|
||||
anyType,
|
||||
goldFioSpans,
|
||||
foundFioSpans,
|
||||
cleanTexts,
|
||||
cleanTextsWithFalseHit,
|
||||
missedFio,
|
||||
falseHits,
|
||||
overMasked);
|
||||
|
||||
/**
|
||||
* Набор, на котором правила отлаживались. Пороги здесь высокие: любое падение
|
||||
* означает, что сломалось то, что раньше работало.
|
||||
*/
|
||||
@Test
|
||||
void detectionQualityOnTuningSet() {
|
||||
Result result = measure("/benchmark.txt", "набор отладки");
|
||||
Score fio = byType.getOrDefault(PdTypes.FIO, new Score());
|
||||
double falsePositiveRate = cleanTexts == 0 ? 0.0 : (double) cleanTextsWithFalseHit / cleanTexts;
|
||||
return new Result(
|
||||
fio.f1(),
|
||||
anyType.precision(),
|
||||
anyType.recall(),
|
||||
falsePositiveRate,
|
||||
foundFioSpans,
|
||||
goldFioSpans);
|
||||
}
|
||||
|
||||
assertTrue(result.fioF1() >= 0.95,
|
||||
String.format("F1 по ФИО упал до %.3f", result.fioF1()));
|
||||
assertTrue(result.overallRecall() >= 0.95,
|
||||
String.format("полнота по всем типам упала до %.3f", result.overallRecall()));
|
||||
assertTrue(result.falsePositiveRate() <= 0.05,
|
||||
String.format("ложные срабатывания на чистых текстах: %.3f", result.falsePositiveRate()));
|
||||
/** Раскрашивает каждый знак текста типом ПД, который его покрывает. */
|
||||
private static String[] paint(int length, List<Span> spans) {
|
||||
String[] painted = new String[length];
|
||||
for (Span span : spans) {
|
||||
for (int i = span.start(); i < Math.min(span.end(), length); i++) {
|
||||
painted[i] = span.type();
|
||||
}
|
||||
}
|
||||
return painted;
|
||||
}
|
||||
|
||||
/**
|
||||
* Отложенный набор: правила на нём не настраивались. Пороги ниже — они
|
||||
* отражают измеренное на нём качество, а не желаемое.
|
||||
*/
|
||||
@Test
|
||||
void detectionQualityOnHoldoutSet() {
|
||||
Result result = measure("/benchmark-holdout.txt", "отложенный набор");
|
||||
|
||||
assertTrue(result.fioF1() >= 0.75,
|
||||
String.format("F1 по ФИО на отложенном наборе упал до %.3f", result.fioF1()));
|
||||
assertTrue(result.overallRecall() >= 0.75,
|
||||
String.format("полнота на отложенном наборе упала до %.3f", result.overallRecall()));
|
||||
assertTrue(result.falsePositiveRate() <= 0.15,
|
||||
String.format("ложные срабатывания на отложенном наборе: %.3f", result.falsePositiveRate()));
|
||||
private static void account(Map<String, Score> byType, String gold, String found) {
|
||||
if (gold != null) {
|
||||
Score score = byType.computeIfAbsent(gold, t -> new Score());
|
||||
if (gold.equals(found)) {
|
||||
score.truePositive++;
|
||||
} else {
|
||||
score.falseNegative++;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Второй контрольный набор, составленный после того, как первый дважды повлиял
|
||||
* на правила. На нём не настраивалось ничего — он и показывает настоящее
|
||||
* качество. Пороги низкие намеренно: тест ловит обвал, а не сторожит значение.
|
||||
*/
|
||||
@Test
|
||||
void detectionQualityOnThirdHoldoutSet() {
|
||||
Pipeline stage = Files.isReadable(Path.of(MODEL_PATH))
|
||||
? new Pipeline(new RuleRegistry(), new Masker(), new PayloadStore(10_000_000L, 30),
|
||||
new NameCascade(ENGINE, Optional.of(MODEL_PATH), 16, 4))
|
||||
: pipeline;
|
||||
Result result = measure(stage, "/benchmark-holdout3.txt", "второй контрольный набор");
|
||||
|
||||
assertTrue(result.fioF1() >= 0.70,
|
||||
String.format("F1 по ФИО на втором контрольном наборе упал до %.3f", result.fioF1()));
|
||||
assertTrue(result.overallRecall() >= 0.70,
|
||||
String.format("полнота на втором контрольном наборе упала до %.3f", result.overallRecall()));
|
||||
if (found != null && !found.equals(gold)) {
|
||||
byType.computeIfAbsent(found, t -> new Score()).falsePositive++;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Контрольный набор. Правила по нему не настраиваются: он существует, чтобы
|
||||
* показывать качество на данных, которых разработка не видела. Пороги здесь
|
||||
* низкие намеренно — тест ловит обвал, а не сторожит достигнутое значение.
|
||||
* Замер идёт со второй ступенью, если модель собрана, иначе на одних правилах.
|
||||
*/
|
||||
@Test
|
||||
void detectionQualityOnSecondHoldoutSet() {
|
||||
Pipeline stage = Files.isReadable(Path.of(MODEL_PATH))
|
||||
? new Pipeline(new RuleRegistry(), new Masker(), new PayloadStore(10_000_000L, 30),
|
||||
new NameCascade(ENGINE, Optional.of(MODEL_PATH), 16, 4))
|
||||
: pipeline;
|
||||
Result result = measure(stage, "/benchmark-holdout2.txt", "второй отложенный набор");
|
||||
|
||||
assertTrue(result.fioF1() >= 0.70,
|
||||
String.format("F1 по ФИО на втором отложенном наборе упал до %.3f", result.fioF1()));
|
||||
assertTrue(result.overallRecall() >= 0.70,
|
||||
String.format("полнота на втором отложенном наборе упала до %.3f", result.overallRecall()));
|
||||
private static void accountAnyType(Score score, boolean gold, boolean found) {
|
||||
if (gold && found) {
|
||||
score.truePositive++;
|
||||
} else if (gold) {
|
||||
score.falseNegative++;
|
||||
} else if (found) {
|
||||
score.falsePositive++;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Реальные адреса отделений Альфа-Банка (ловушка из ТЗ — не ПД клиента),
|
||||
* расширенный денилист, обобщённое companion-правило (место рождения,
|
||||
* страна) и новые банковские типы. Собран специально под соответствующие
|
||||
* доработки — пороги ниже, чем у набора отладки, но проверяют именно то,
|
||||
* что было доработано, а не общее качество остального пайплайна.
|
||||
*/
|
||||
@Test
|
||||
void detectionQualityOnBankContextSet() {
|
||||
Result result = measure("/benchmark-bank-context.txt", "банковский контекст");
|
||||
private static boolean overlappedByFio(Span gold, List<Span> found) {
|
||||
return found.stream().anyMatch(span -> PdTypes.FIO.equals(span.type()) && span.overlaps(gold));
|
||||
}
|
||||
|
||||
assertTrue(result.fioF1() >= 0.70,
|
||||
String.format("F1 по ФИО на банковском наборе упал до %.3f", result.fioF1()));
|
||||
assertTrue(result.overallRecall() >= 0.70,
|
||||
String.format("полнота на банковском наборе упала до %.3f", result.overallRecall()));
|
||||
assertTrue(result.falsePositiveRate() <= 0.10,
|
||||
String.format("ложные срабатывания на банковском наборе: %.3f", result.falsePositiveRate()));
|
||||
private static String fragment(String text, Span span) {
|
||||
return "«" + text.substring(span.start(), Math.min(span.end(), text.length())) + "»";
|
||||
}
|
||||
|
||||
/** Знаки, замаскированные сверх эталона: полезно видеть, где правило берёт лишнее. */
|
||||
private static void collectOverMasked(
|
||||
String text, String[] gold, String[] found, List<String> sink) {
|
||||
int from = -1;
|
||||
for (int i = 0; i <= text.length(); i++) {
|
||||
boolean extra = i < text.length() && found[i] != null && gold[i] == null;
|
||||
if (extra && from < 0) {
|
||||
from = i;
|
||||
} else if (!extra && from >= 0) {
|
||||
sink.add("«" + text.substring(from, i) + "» как " + found[from] + " ← " + text);
|
||||
from = -1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Независимый сгенерированный набор — покрывает все типы ПД из ТЗ и вариации
|
||||
* написания, не встречавшиеся ни в одном из остальных наборов. Правила под
|
||||
* него не настраивались; пороги низкие по той же причине, что и у второго
|
||||
* отложенного набора — тест ловит обвал, а не сторожит достигнутое значение.
|
||||
*/
|
||||
@Test
|
||||
void detectionQualityOnGeneratedSet() {
|
||||
Pipeline stage = Files.isReadable(Path.of(MODEL_PATH))
|
||||
? new Pipeline(new RuleRegistry(), new Masker(), new PayloadStore(10_000_000L, 30),
|
||||
new NameCascade(ENGINE, Optional.of(MODEL_PATH), 16, 4))
|
||||
: pipeline;
|
||||
Result result = measure(stage, "/benchmark-generated.txt", "сгенерированный набор");
|
||||
private void report(
|
||||
String title,
|
||||
int samples,
|
||||
Map<String, Score> byType,
|
||||
Score anyType,
|
||||
int goldFio,
|
||||
int foundFio,
|
||||
int cleanTexts,
|
||||
int falseHitTexts,
|
||||
List<String> missedFio,
|
||||
List<String> falseHits,
|
||||
List<String> overMasked) {
|
||||
StringBuilder out = new StringBuilder(4096);
|
||||
out.append("\n=== ")
|
||||
.append(title)
|
||||
.append(": ")
|
||||
.append(samples)
|
||||
.append(" размеченных строк ===\n\n");
|
||||
out.append(
|
||||
String.format("%-20s %8s %8s %8s %8s%n", "тип", "знаков", "точность", "полнота", "F1"));
|
||||
|
||||
assertTrue(result.fioF1() >= 0.70,
|
||||
String.format("F1 по ФИО на сгенерированном наборе упал до %.3f", result.fioF1()));
|
||||
assertTrue(result.overallRecall() >= 0.70,
|
||||
String.format("полнота на сгенерированном наборе упала до %.3f", result.overallRecall()));
|
||||
byType.entrySet().stream()
|
||||
.sorted(
|
||||
Comparator.comparingInt((Map.Entry<String, Score> e) -> e.getValue().gold()).reversed())
|
||||
.forEach(
|
||||
e ->
|
||||
out.append(
|
||||
String.format(
|
||||
"%-20s %8d %8.3f %8.3f %8.3f%n",
|
||||
e.getKey(),
|
||||
e.getValue().gold(),
|
||||
e.getValue().precision(),
|
||||
e.getValue().recall(),
|
||||
e.getValue().f1())));
|
||||
|
||||
out.append(
|
||||
String.format(
|
||||
"%-20s %8d %8.3f %8.3f %8.3f%n",
|
||||
"ЛЮБОЙ ТИП", anyType.gold(), anyType.precision(), anyType.recall(), anyType.f1()));
|
||||
|
||||
out.append(
|
||||
String.format(
|
||||
"%nФИО пофрагментно: найдено %d из %d (%.1f %%)%n",
|
||||
foundFio, goldFio, goldFio == 0 ? 100.0 : 100.0 * foundFio / goldFio));
|
||||
out.append(
|
||||
String.format(
|
||||
"Тексты без ПД: ложные срабатывания на %d из %d (%.1f %%)%n",
|
||||
falseHitTexts, cleanTexts, cleanTexts == 0 ? 0.0 : 100.0 * falseHitTexts / cleanTexts));
|
||||
|
||||
appendList(out, "\nНе найденные ФИО:", missedFio);
|
||||
appendList(out, "\nЛожные срабатывания:", falseHits);
|
||||
appendList(out, "\nЗамаскировано сверх эталона:", overMasked);
|
||||
|
||||
System.out.println(out);
|
||||
}
|
||||
|
||||
private static void appendList(StringBuilder out, String title, List<String> lines) {
|
||||
if (lines.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
|
||||
/**
|
||||
* Тот же отложенный набор, но со включённой второй ступенью. Модели нет —
|
||||
* проверка пропускается: в сборке без модели сервис работает на одних правилах.
|
||||
*/
|
||||
@Test
|
||||
void detectionQualityWithNameCascade() {
|
||||
Path model = Path.of(MODEL_PATH);
|
||||
assumeTrue(Files.isReadable(model), "модель " + model.toAbsolutePath() + " не собрана");
|
||||
|
||||
Pipeline withCascade = new Pipeline(new RuleRegistry(), new Masker(),
|
||||
new PayloadStore(10_000_000L, 30), new NameCascade(ENGINE, Optional.of(MODEL_PATH), 16, 4));
|
||||
Result result = measure(withCascade, "/benchmark-holdout.txt", "отложенный набор, вторая ступень включена");
|
||||
|
||||
assertTrue(result.fioF1() >= 0.75,
|
||||
String.format("F1 по ФИО со второй ступенью упал до %.3f", result.fioF1()));
|
||||
}
|
||||
|
||||
private Result measure(String resource, String title) {
|
||||
return measure(pipeline, resource, title);
|
||||
}
|
||||
|
||||
private Result measure(Pipeline stage, String resource, String title) {
|
||||
List<BenchmarkFixtures.Sample> samples = BenchmarkFixtures.load(resource);
|
||||
Map<String, Score> byType = new LinkedHashMap<>();
|
||||
Score anyType = new Score();
|
||||
|
||||
int cleanTexts = 0;
|
||||
int cleanTextsWithFalseHit = 0;
|
||||
int goldFioSpans = 0;
|
||||
int foundFioSpans = 0;
|
||||
List<String> falseHits = new ArrayList<>();
|
||||
List<String> missedFio = new ArrayList<>();
|
||||
List<String> overMasked = new ArrayList<>();
|
||||
|
||||
for (BenchmarkFixtures.Sample sample : samples) {
|
||||
List<Span> found = stage.findPersonalData(sample.text(), SystemPolicy.DEFAULT);
|
||||
|
||||
String[] goldChars = paint(sample.text().length(), sample.gold());
|
||||
String[] foundChars = paint(sample.text().length(), found);
|
||||
|
||||
for (int i = 0; i < sample.text().length(); i++) {
|
||||
account(byType, goldChars[i], foundChars[i]);
|
||||
accountAnyType(anyType, goldChars[i] != null, foundChars[i] != null);
|
||||
}
|
||||
|
||||
if (sample.gold().isEmpty()) {
|
||||
cleanTexts++;
|
||||
if (!found.isEmpty()) {
|
||||
cleanTextsWithFalseHit++;
|
||||
falseHits.add(fragment(sample.text(), found.get(0)) + " ← " + sample.text());
|
||||
}
|
||||
} else {
|
||||
collectOverMasked(sample.text(), goldChars, foundChars, overMasked);
|
||||
}
|
||||
|
||||
for (Span gold : sample.gold()) {
|
||||
if (!PdTypes.FIO.equals(gold.type())) {
|
||||
continue;
|
||||
}
|
||||
goldFioSpans++;
|
||||
if (overlappedByFio(gold, found)) {
|
||||
foundFioSpans++;
|
||||
} else {
|
||||
missedFio.add(fragment(sample.text(), gold) + " ← " + sample.text());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
report(title, samples.size(), byType, anyType, goldFioSpans, foundFioSpans,
|
||||
cleanTexts, cleanTextsWithFalseHit, missedFio, falseHits, overMasked);
|
||||
|
||||
Score fio = byType.getOrDefault(PdTypes.FIO, new Score());
|
||||
double falsePositiveRate = cleanTexts == 0 ? 0.0 : (double) cleanTextsWithFalseHit / cleanTexts;
|
||||
return new Result(fio.f1(), anyType.precision(), anyType.recall(),
|
||||
falsePositiveRate, foundFioSpans, goldFioSpans);
|
||||
}
|
||||
|
||||
/** Раскрашивает каждый знак текста типом ПД, который его покрывает. */
|
||||
private static String[] paint(int length, List<Span> spans) {
|
||||
String[] painted = new String[length];
|
||||
for (Span span : spans) {
|
||||
for (int i = span.start(); i < Math.min(span.end(), length); i++) {
|
||||
painted[i] = span.type();
|
||||
}
|
||||
}
|
||||
return painted;
|
||||
}
|
||||
|
||||
private static void account(Map<String, Score> byType, String gold, String found) {
|
||||
if (gold != null) {
|
||||
Score score = byType.computeIfAbsent(gold, t -> new Score());
|
||||
if (gold.equals(found)) {
|
||||
score.truePositive++;
|
||||
} else {
|
||||
score.falseNegative++;
|
||||
}
|
||||
}
|
||||
if (found != null && !found.equals(gold)) {
|
||||
byType.computeIfAbsent(found, t -> new Score()).falsePositive++;
|
||||
}
|
||||
}
|
||||
|
||||
private static void accountAnyType(Score score, boolean gold, boolean found) {
|
||||
if (gold && found) {
|
||||
score.truePositive++;
|
||||
} else if (gold) {
|
||||
score.falseNegative++;
|
||||
} else if (found) {
|
||||
score.falsePositive++;
|
||||
}
|
||||
}
|
||||
|
||||
private static boolean overlappedByFio(Span gold, List<Span> found) {
|
||||
return found.stream()
|
||||
.anyMatch(span -> PdTypes.FIO.equals(span.type()) && span.overlaps(gold));
|
||||
}
|
||||
|
||||
private static String fragment(String text, Span span) {
|
||||
return "«" + text.substring(span.start(), Math.min(span.end(), text.length())) + "»";
|
||||
}
|
||||
|
||||
/** Знаки, замаскированные сверх эталона: полезно видеть, где правило берёт лишнее. */
|
||||
private static void collectOverMasked(String text, String[] gold, String[] found, List<String> sink) {
|
||||
int from = -1;
|
||||
for (int i = 0; i <= text.length(); i++) {
|
||||
boolean extra = i < text.length() && found[i] != null && gold[i] == null;
|
||||
if (extra && from < 0) {
|
||||
from = i;
|
||||
} else if (!extra && from >= 0) {
|
||||
sink.add("«" + text.substring(from, i) + "» как " + found[from] + " ← " + text);
|
||||
from = -1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void report(String title, int samples, Map<String, Score> byType, Score anyType,
|
||||
int goldFio, int foundFio, int cleanTexts, int falseHitTexts,
|
||||
List<String> missedFio, List<String> falseHits, List<String> overMasked) {
|
||||
StringBuilder out = new StringBuilder(4096);
|
||||
out.append("\n=== ").append(title).append(": ").append(samples).append(" размеченных строк ===\n\n");
|
||||
out.append(String.format("%-20s %8s %8s %8s %8s%n", "тип", "знаков", "точность", "полнота", "F1"));
|
||||
|
||||
byType.entrySet().stream()
|
||||
.sorted(Comparator.comparingInt((Map.Entry<String, Score> e) -> e.getValue().gold()).reversed())
|
||||
.forEach(e -> out.append(String.format("%-20s %8d %8.3f %8.3f %8.3f%n",
|
||||
e.getKey(), e.getValue().gold(), e.getValue().precision(),
|
||||
e.getValue().recall(), e.getValue().f1())));
|
||||
|
||||
out.append(String.format("%-20s %8d %8.3f %8.3f %8.3f%n", "ЛЮБОЙ ТИП", anyType.gold(),
|
||||
anyType.precision(), anyType.recall(), anyType.f1()));
|
||||
|
||||
out.append(String.format("%nФИО пофрагментно: найдено %d из %d (%.1f %%)%n",
|
||||
foundFio, goldFio, goldFio == 0 ? 100.0 : 100.0 * foundFio / goldFio));
|
||||
out.append(String.format("Тексты без ПД: ложные срабатывания на %d из %d (%.1f %%)%n",
|
||||
falseHitTexts, cleanTexts, cleanTexts == 0 ? 0.0 : 100.0 * falseHitTexts / cleanTexts));
|
||||
|
||||
appendList(out, "\nНе найденные ФИО:", missedFio);
|
||||
appendList(out, "\nЛожные срабатывания:", falseHits);
|
||||
appendList(out, "\nЗамаскировано сверх эталона:", overMasked);
|
||||
|
||||
System.out.println(out);
|
||||
}
|
||||
|
||||
private static void appendList(StringBuilder out, String title, List<String> lines) {
|
||||
if (lines.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
out.append(title).append('\n');
|
||||
lines.forEach(line -> out.append(" ").append(line).append('\n'));
|
||||
}
|
||||
|
||||
out.append(title).append('\n');
|
||||
lines.forEach(line -> out.append(" ").append(line).append('\n'));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,18 +1,18 @@
|
||||
package ru.pdguard;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.boot.test.context.SpringBootTest;
|
||||
import ru.pdguard.core.PayloadCipher;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
@SpringBootTest
|
||||
class CipherEnabledTest {
|
||||
@Autowired PayloadCipher cipher;
|
||||
@Autowired PayloadCipher cipher;
|
||||
|
||||
@Test
|
||||
void cipherIsEnabled() {
|
||||
assertTrue(cipher.enabled(), "шифрование должно быть включено ключом из конфигурации");
|
||||
}
|
||||
@Test
|
||||
void cipherIsEnabled() {
|
||||
assertTrue(cipher.enabled(), "шифрование должно быть включено ключом из конфигурации");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,22 +1,25 @@
|
||||
package ru.pdguard;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import ru.pdguard.core.PayloadCipher;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import ru.pdguard.core.PayloadCipher;
|
||||
|
||||
/** Проверка ключа шифрования из application.yml. */
|
||||
class CipherKeyTest {
|
||||
|
||||
private static final String KEY = "46a38b200c6df557a5fd2c8a57ad3fec6b710b9f3e1fef1451d121a094f63573";
|
||||
private static final String KEY =
|
||||
"46a38b200c6df557a5fd2c8a57ad3fec6b710b9f3e1fef1451d121a094f63573";
|
||||
|
||||
@Test
|
||||
void keyIsValidAes256() {
|
||||
PayloadCipher cipher = new PayloadCipher(KEY);
|
||||
assertTrue(cipher.enabled(), "ключ должен включать шифрование");
|
||||
String original = "Клиент Иванов Иван Иванович, паспорт 4509 123456";
|
||||
assertEquals(original, cipher.decrypt(cipher.encrypt(original)),
|
||||
"round-trip с ключом из application.yml должен работать");
|
||||
}
|
||||
@Test
|
||||
void keyIsValidAes256() {
|
||||
PayloadCipher cipher = new PayloadCipher(KEY);
|
||||
assertTrue(cipher.enabled(), "ключ должен включать шифрование");
|
||||
String original = "Клиент Иванов Иван Иванович, паспорт 4509 123456";
|
||||
assertEquals(
|
||||
original,
|
||||
cipher.decrypt(cipher.encrypt(original)),
|
||||
"round-trip с ключом из application.yml должен работать");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,162 +1,165 @@
|
||||
package ru.pdguard;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
import java.util.UUID;
|
||||
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;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
/** Типы ПД, которые опознаются только рядом с якорным словом. */
|
||||
class ContextDetectionTest {
|
||||
|
||||
private final Pipeline pipeline =
|
||||
new Pipeline(new RuleRegistry(), new Masker(), new PayloadStore(1_000_000L, 30));
|
||||
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 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 + "»");
|
||||
}
|
||||
private void assertHidden(String text, String secret) {
|
||||
String masked = mask(text);
|
||||
assertFalse(
|
||||
masked.contains(secret), "не замаскировано: «" + secret + "» в ответе «" + masked + "»");
|
||||
}
|
||||
|
||||
@Test
|
||||
void masksPassportInEveryNotation() {
|
||||
assertHidden("Паспорт 4509 123456 выдан давно", "4509 123456");
|
||||
assertHidden("паспорт гражданина РФ 45 09 123456", "45 09 123456");
|
||||
assertHidden("ПАСПОРТ 4509123456", "4509123456");
|
||||
assertHidden("Серия 4509 номер 123456", "4509");
|
||||
assertHidden("серии 45 09 № 123456", "123456");
|
||||
}
|
||||
@Test
|
||||
void masksPassportInEveryNotation() {
|
||||
assertHidden("Паспорт 4509 123456 выдан давно", "4509 123456");
|
||||
assertHidden("паспорт гражданина РФ 45 09 123456", "45 09 123456");
|
||||
assertHidden("ПАСПОРТ 4509123456", "4509123456");
|
||||
assertHidden("Серия 4509 номер 123456", "4509");
|
||||
assertHidden("серии 45 09 № 123456", "123456");
|
||||
}
|
||||
|
||||
@Test
|
||||
void masksPassportSeriesAndNumberSplitByWords() {
|
||||
String masked = mask("Документ: серия 4509 номер 123456, выдан отделом");
|
||||
assertTrue(masked.contains("серия "), masked);
|
||||
assertTrue(masked.contains("номер "), masked);
|
||||
assertFalse(masked.contains("4509"), masked);
|
||||
assertFalse(masked.contains("123456"), masked);
|
||||
}
|
||||
@Test
|
||||
void masksPassportSeriesAndNumberSplitByWords() {
|
||||
String masked = mask("Документ: серия 4509 номер 123456, выдан отделом");
|
||||
assertTrue(masked.contains("серия "), masked);
|
||||
assertTrue(masked.contains("номер "), masked);
|
||||
assertFalse(masked.contains("4509"), masked);
|
||||
assertFalse(masked.contains("123456"), masked);
|
||||
}
|
||||
|
||||
@Test
|
||||
void masksDepartmentCode() {
|
||||
assertHidden("Код подразделения 770-001", "770-001");
|
||||
assertHidden("к/п 770001", "770001");
|
||||
}
|
||||
@Test
|
||||
void masksDepartmentCode() {
|
||||
assertHidden("Код подразделения 770-001", "770-001");
|
||||
assertHidden("к/п 770001", "770001");
|
||||
}
|
||||
|
||||
@Test
|
||||
void masksIssuingAuthorityButNotTheDateAfterIt() {
|
||||
String masked = mask("Паспорт выдан ОУФМС России по г. Москве 12.05.2015");
|
||||
assertFalse(masked.contains("ОУФМС"), masked);
|
||||
assertFalse(masked.contains("12.05.2015"), masked);
|
||||
assertTrue(masked.contains("**.**.****"), "дата маскируется отдельно от органа выдачи: " + masked);
|
||||
}
|
||||
@Test
|
||||
void masksIssuingAuthorityButNotTheDateAfterIt() {
|
||||
String masked = mask("Паспорт выдан ОУФМС России по г. Москве 12.05.2015");
|
||||
assertFalse(masked.contains("ОУФМС"), masked);
|
||||
assertFalse(masked.contains("12.05.2015"), masked);
|
||||
assertTrue(
|
||||
masked.contains("**.**.****"), "дата маскируется отдельно от органа выдачи: " + masked);
|
||||
}
|
||||
|
||||
@Test
|
||||
void masksDriverLicense() {
|
||||
assertHidden("Водительское удостоверение 9902 123456", "9902 123456");
|
||||
assertHidden("в/у 99 02 123456", "99 02 123456");
|
||||
}
|
||||
@Test
|
||||
void masksDriverLicense() {
|
||||
assertHidden("Водительское удостоверение 9902 123456", "9902 123456");
|
||||
assertHidden("в/у 99 02 123456", "99 02 123456");
|
||||
}
|
||||
|
||||
@Test
|
||||
void masksCitizenship() {
|
||||
assertHidden("Гражданство: РФ", "РФ");
|
||||
assertHidden("гражданство Республики Беларусь", "Беларусь");
|
||||
assertHidden("Гражданин России обратился", "России");
|
||||
}
|
||||
@Test
|
||||
void masksCitizenship() {
|
||||
assertHidden("Гражданство: РФ", "РФ");
|
||||
assertHidden("гражданство Республики Беларусь", "Беларусь");
|
||||
assertHidden("Гражданин России обратился", "России");
|
||||
}
|
||||
|
||||
@Test
|
||||
void masksBirthPlace() {
|
||||
// Место рождения — тип из requireCompanion: без другого ПД рядом не маскируется
|
||||
// («Нижний Новгород» в рассказе о городе не должен теряться), поэтому в тесте
|
||||
// на распознавание якоря рядом добавлен телефон.
|
||||
assertHidden("Место рождения: город Тверь, проживает в Москве, тел. +7 916 123-45-67", "город Тверь");
|
||||
assertHidden("Родился в Нижнем Новгороде, тел. +7 916 123-45-67", "Нижнем Новгороде");
|
||||
}
|
||||
@Test
|
||||
void masksBirthPlace() {
|
||||
// Место рождения — тип из requireCompanion: без другого ПД рядом не маскируется
|
||||
// («Нижний Новгород» в рассказе о городе не должен теряться), поэтому в тесте
|
||||
// на распознавание якоря рядом добавлен телефон.
|
||||
assertHidden(
|
||||
"Место рождения: город Тверь, проживает в Москве, тел. +7 916 123-45-67", "город Тверь");
|
||||
assertHidden("Родился в Нижнем Новгороде, тел. +7 916 123-45-67", "Нижнем Новгороде");
|
||||
}
|
||||
|
||||
@Test
|
||||
void doesNotMaskBirthPlaceWithoutAnyOtherPersonalData() {
|
||||
String text = "Экскурсия в Нижний Новгород перенесена на май";
|
||||
assertEquals(text, mask(text), "место рождения без другого ПД рядом не маскируется");
|
||||
}
|
||||
@Test
|
||||
void doesNotMaskBirthPlaceWithoutAnyOtherPersonalData() {
|
||||
String text = "Экскурсия в Нижний Новгород перенесена на май";
|
||||
assertEquals(text, mask(text), "место рождения без другого ПД рядом не маскируется");
|
||||
}
|
||||
|
||||
@Test
|
||||
void masksBirthPlaceWhenOtherPersonalDataIsAlsoPresent() {
|
||||
assertHidden("Место рождения: город Тверь, ИНН 770301234550", "город Тверь");
|
||||
}
|
||||
@Test
|
||||
void masksBirthPlaceWhenOtherPersonalDataIsAlsoPresent() {
|
||||
assertHidden("Место рождения: город Тверь, ИНН 770301234550", "город Тверь");
|
||||
}
|
||||
|
||||
@Test
|
||||
void doesNotMaskCountryWithoutAnyOtherPersonalData() {
|
||||
String text = "Цены на нефть выросли в Казахстане в этом квартале";
|
||||
assertEquals(text, mask(text), "страна без другого ПД рядом не маскируется");
|
||||
}
|
||||
@Test
|
||||
void doesNotMaskCountryWithoutAnyOtherPersonalData() {
|
||||
String text = "Цены на нефть выросли в Казахстане в этом квартале";
|
||||
assertEquals(text, mask(text), "страна без другого ПД рядом не маскируется");
|
||||
}
|
||||
|
||||
@Test
|
||||
void masksCountryWhenOtherPersonalDataIsAlsoPresent() {
|
||||
assertHidden("Страна проживания Казахстан, ИНН 770301234550", "Казахстан");
|
||||
}
|
||||
@Test
|
||||
void masksCountryWhenOtherPersonalDataIsAlsoPresent() {
|
||||
assertHidden("Страна проживания Казахстан, ИНН 770301234550", "Казахстан");
|
||||
}
|
||||
|
||||
@Test
|
||||
void masksCardholderName() {
|
||||
assertHidden("Держатель карты IVAN PETROV", "IVAN PETROV");
|
||||
assertHidden("cardholder: PETR SIDOROV", "PETR SIDOROV");
|
||||
}
|
||||
@Test
|
||||
void masksCardholderName() {
|
||||
assertHidden("Держатель карты IVAN PETROV", "IVAN PETROV");
|
||||
assertHidden("cardholder: PETR SIDOROV", "PETR SIDOROV");
|
||||
}
|
||||
|
||||
@Test
|
||||
void masksSecurityCodeAndPinCompletely() {
|
||||
String masked = mask("Карта 4111 1111 1111 1111, CVV 123, пин-код 4321");
|
||||
assertFalse(masked.contains("123,"), masked);
|
||||
assertFalse(masked.contains("4321"), masked);
|
||||
assertTrue(masked.contains("***"), masked);
|
||||
}
|
||||
@Test
|
||||
void masksSecurityCodeAndPinCompletely() {
|
||||
String masked = mask("Карта 4111 1111 1111 1111, CVV 123, пин-код 4321");
|
||||
assertFalse(masked.contains("123,"), masked);
|
||||
assertFalse(masked.contains("4321"), masked);
|
||||
assertTrue(masked.contains("***"), masked);
|
||||
}
|
||||
|
||||
@Test
|
||||
void doesNotMaskPinWithoutAnyOtherPersonalData() {
|
||||
String text = "Пин-код 1234 введён неверно";
|
||||
assertEquals(text, mask(text), "одиночный пин-код персональными данными не является");
|
||||
}
|
||||
@Test
|
||||
void doesNotMaskPinWithoutAnyOtherPersonalData() {
|
||||
String text = "Пин-код 1234 введён неверно";
|
||||
assertEquals(text, mask(text), "одиночный пин-код персональными данными не является");
|
||||
}
|
||||
|
||||
@Test
|
||||
void masksPinWhenCardNumberIsAlsoPresent() {
|
||||
assertHidden("Пин-код 1234 от карты 4111 1111 1111 1111", "1234 от");
|
||||
}
|
||||
@Test
|
||||
void masksPinWhenCardNumberIsAlsoPresent() {
|
||||
assertHidden("Пин-код 1234 от карты 4111 1111 1111 1111", "1234 от");
|
||||
}
|
||||
|
||||
@Test
|
||||
void anchorWordsAreCaseInsensitive() {
|
||||
assertHidden("ПАСПОРТ СЕРИЯ 4509 НОМЕР 123456", "123456");
|
||||
assertHidden("гРаЖдАнСтВо РФ, паспорт 4509 123456", "4509 123456");
|
||||
}
|
||||
@Test
|
||||
void anchorWordsAreCaseInsensitive() {
|
||||
assertHidden("ПАСПОРТ СЕРИЯ 4509 НОМЕР 123456", "123456");
|
||||
assertHidden("гРаЖдАнСтВо РФ, паспорт 4509 123456", "4509 123456");
|
||||
}
|
||||
|
||||
@Test
|
||||
void complexSentenceKeepsSurroundingWords() {
|
||||
String original = "Клиент, паспорт 4509 123456 выдан ОУФМС по г. Москве, "
|
||||
+ "код подразделения 770-001, ИНН 770301234550, телефон +7 916 123-45-67";
|
||||
String masked = mask(original);
|
||||
@Test
|
||||
void complexSentenceKeepsSurroundingWords() {
|
||||
String original =
|
||||
"Клиент, паспорт 4509 123456 выдан ОУФМС по г. Москве, "
|
||||
+ "код подразделения 770-001, ИНН 770301234550, телефон +7 916 123-45-67";
|
||||
String masked = mask(original);
|
||||
|
||||
assertTrue(masked.startsWith("Клиент, паспорт "), masked);
|
||||
assertTrue(masked.contains("код подразделения"), masked);
|
||||
assertTrue(masked.contains("телефон"), masked);
|
||||
assertFalse(masked.contains("4509 123456"), masked);
|
||||
assertFalse(masked.contains("770301234550"), masked);
|
||||
}
|
||||
assertTrue(masked.startsWith("Клиент, паспорт "), masked);
|
||||
assertTrue(masked.contains("код подразделения"), masked);
|
||||
assertTrue(masked.contains("телефон"), masked);
|
||||
assertFalse(masked.contains("4509 123456"), masked);
|
||||
assertFalse(masked.contains("770301234550"), masked);
|
||||
}
|
||||
|
||||
@Test
|
||||
void unmaskingRestoresComplexSentence() {
|
||||
String original = "Паспорт 4509 123456, выдан ОУФМС России по г. Москве, "
|
||||
+ "код подразделения 770-001, гражданство РФ, CVV 123, карта 4111 1111 1111 1111";
|
||||
String id = "complex-1";
|
||||
@Test
|
||||
void unmaskingRestoresComplexSentence() {
|
||||
String original =
|
||||
"Паспорт 4509 123456, выдан ОУФМС России по г. Москве, "
|
||||
+ "код подразделения 770-001, гражданство РФ, CVV 123, карта 4111 1111 1111 1111";
|
||||
String id = "complex-1";
|
||||
|
||||
String masked = pipeline.process(original, id, SystemPolicy.DEFAULT);
|
||||
assertFalse(masked.contains("4509 123456"), masked);
|
||||
assertEquals(original, pipeline.process(masked, id, SystemPolicy.DEFAULT));
|
||||
}
|
||||
String masked = pipeline.process(original, id, SystemPolicy.DEFAULT);
|
||||
assertFalse(masked.contains("4509 123456"), masked);
|
||||
assertEquals(original, pipeline.process(masked, id, SystemPolicy.DEFAULT));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
package ru.pdguard;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.DynamicTest.dynamicTest;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.stream.Stream;
|
||||
import org.junit.jupiter.api.DynamicTest;
|
||||
import org.junit.jupiter.api.TestFactory;
|
||||
import ru.pdguard.config.SystemPolicy;
|
||||
@@ -9,60 +15,55 @@ import ru.pdguard.detect.RuleRegistry;
|
||||
import ru.pdguard.detect.Span;
|
||||
import ru.pdguard.mask.Masker;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.stream.Stream;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.DynamicTest.dynamicTest;
|
||||
|
||||
/**
|
||||
* 200 вручную составленных текстовых тестов из {@code dataset-200.txt} — по одному
|
||||
* предложению на строку, каждое своя отдельная проверка (не сборка одного большого
|
||||
* текста, как в {@link HugeDatasetTest}). Набор покрывает все типы ПДН из
|
||||
* {@link RuleRegistry} (кроме ADDRESS_REGION/ADDRESS_DISTRICT — для них нет правил,
|
||||
* только модель второй ступени), варианты написания (регистр, формат даты, разделяющие
|
||||
* слова) и несколько строк-ловушек без разметки (известный человек, адрес отделения,
|
||||
* дата без якоря) — они не должны маскироваться вовсе.
|
||||
* 200 вручную составленных текстовых тестов из {@code dataset-200.txt} — по одному предложению на
|
||||
* строку, каждое своя отдельная проверка (не сборка одного большого текста, как в {@link
|
||||
* HugeDatasetTest}). Набор покрывает все типы ПДН из {@link RuleRegistry} (кроме
|
||||
* ADDRESS_REGION/ADDRESS_DISTRICT — для них нет правил, только модель второй ступени), варианты
|
||||
* написания (регистр, формат даты, разделяющие слова) и несколько строк-ловушек без разметки
|
||||
* (известный человек, адрес отделения, дата без якоря) — они не должны маскироваться вовсе.
|
||||
*
|
||||
* <p>На каждой строке: маскирование не оставляет исходное значение ПДН в открытом
|
||||
* виде, а демаскирование побайтово восстанавливает исходный текст.
|
||||
* <p>На каждой строке: маскирование не оставляет исходное значение ПДН в открытом виде, а
|
||||
* демаскирование побайтово восстанавливает исходный текст.
|
||||
*/
|
||||
class Dataset200Test {
|
||||
|
||||
private static final RuleRegistry REGISTRY = new RuleRegistry();
|
||||
private static final Masker MASKER = new Masker();
|
||||
private static final List<BenchmarkFixtures.Sample> DATASET = BenchmarkFixtures.load("/dataset-200.txt");
|
||||
private static final RuleRegistry REGISTRY = new RuleRegistry();
|
||||
private static final Masker MASKER = new Masker();
|
||||
private static final List<BenchmarkFixtures.Sample> DATASET =
|
||||
BenchmarkFixtures.load("/dataset-200.txt");
|
||||
|
||||
@TestFactory
|
||||
Stream<DynamicTest> datasetOf200Cases() {
|
||||
List<DynamicTest> cases = new java.util.ArrayList<>(DATASET.size());
|
||||
for (int i = 0; i < DATASET.size(); i++) {
|
||||
BenchmarkFixtures.Sample sample = DATASET.get(i);
|
||||
int index = i;
|
||||
cases.add(dynamicTest(
|
||||
String.format("#%03d: %s", index, preview(sample.text())),
|
||||
() -> runCase(sample, index)));
|
||||
}
|
||||
return cases.stream();
|
||||
@TestFactory
|
||||
Stream<DynamicTest> datasetOf200Cases() {
|
||||
List<DynamicTest> cases = new java.util.ArrayList<>(DATASET.size());
|
||||
for (int i = 0; i < DATASET.size(); i++) {
|
||||
BenchmarkFixtures.Sample sample = DATASET.get(i);
|
||||
int index = i;
|
||||
cases.add(
|
||||
dynamicTest(
|
||||
String.format("#%03d: %s", index, preview(sample.text())),
|
||||
() -> runCase(sample, index)));
|
||||
}
|
||||
return cases.stream();
|
||||
}
|
||||
|
||||
private void runCase(BenchmarkFixtures.Sample sample, int index) {
|
||||
Pipeline pipeline = new Pipeline(REGISTRY, MASKER, new PayloadStore(1_000_000L, 30));
|
||||
String payloadId = "dataset200-" + index;
|
||||
|
||||
String masked = pipeline.process(sample.text(), payloadId, SystemPolicy.DEFAULT);
|
||||
for (Span gold : sample.gold()) {
|
||||
String value = sample.text().substring(gold.start(), gold.end());
|
||||
assertFalse(
|
||||
masked.contains(value),
|
||||
"ПДН типа " + gold.type() + " утекло в замаскированный текст: " + value);
|
||||
}
|
||||
|
||||
private void runCase(BenchmarkFixtures.Sample sample, int index) {
|
||||
Pipeline pipeline = new Pipeline(REGISTRY, MASKER, new PayloadStore(1_000_000L, 30));
|
||||
String payloadId = "dataset200-" + index;
|
||||
String restored = pipeline.process(masked, payloadId, SystemPolicy.DEFAULT);
|
||||
assertEquals(sample.text(), restored, "демаскирование не восстановило исходный текст");
|
||||
}
|
||||
|
||||
String masked = pipeline.process(sample.text(), payloadId, SystemPolicy.DEFAULT);
|
||||
for (Span gold : sample.gold()) {
|
||||
String value = sample.text().substring(gold.start(), gold.end());
|
||||
assertFalse(masked.contains(value),
|
||||
"ПДН типа " + gold.type() + " утекло в замаскированный текст: " + value);
|
||||
}
|
||||
|
||||
String restored = pipeline.process(masked, payloadId, SystemPolicy.DEFAULT);
|
||||
assertEquals(sample.text(), restored, "демаскирование не восстановило исходный текст");
|
||||
}
|
||||
|
||||
private static String preview(String text) {
|
||||
return text.length() <= 40 ? text : text.substring(0, 40) + "...";
|
||||
}
|
||||
private static String preview(String text) {
|
||||
return text.length() <= 40 ? text : text.substring(0, 40) + "...";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
package ru.pdguard;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
import java.util.UUID;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import ru.pdguard.config.SystemPolicy;
|
||||
import ru.pdguard.core.PayloadStore;
|
||||
@@ -8,137 +13,136 @@ import ru.pdguard.detect.PdTypes;
|
||||
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;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
/** Даты во всех вариантах записи и составляющие адреса. */
|
||||
class DateAndAddressTest {
|
||||
|
||||
private final Pipeline pipeline =
|
||||
new Pipeline(new RuleRegistry(), new Masker(), new PayloadStore(1_000_000L, 30));
|
||||
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 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 + "»");
|
||||
}
|
||||
private void assertHidden(String text, String secret) {
|
||||
String masked = mask(text);
|
||||
assertFalse(
|
||||
masked.contains(secret), "не замаскировано: «" + secret + "» в ответе «" + masked + "»");
|
||||
}
|
||||
|
||||
@Test
|
||||
void masksBirthDateInAnyPartOrder() {
|
||||
assertHidden("Дата рождения 12.05.1985", "12.05.1985");
|
||||
assertHidden("дата рождения: 05/12/1985", "05/12/1985");
|
||||
assertHidden("Дата рождения 1985-12-05", "1985-12-05");
|
||||
assertHidden("Родился 12-05-1985", "12-05-1985");
|
||||
assertHidden("12.05.1985 г.р. — данные клиента", "12.05.1985");
|
||||
}
|
||||
@Test
|
||||
void masksBirthDateInAnyPartOrder() {
|
||||
assertHidden("Дата рождения 12.05.1985", "12.05.1985");
|
||||
assertHidden("дата рождения: 05/12/1985", "05/12/1985");
|
||||
assertHidden("Дата рождения 1985-12-05", "1985-12-05");
|
||||
assertHidden("Родился 12-05-1985", "12-05-1985");
|
||||
assertHidden("12.05.1985 г.р. — данные клиента", "12.05.1985");
|
||||
}
|
||||
|
||||
@Test
|
||||
void masksBirthDateWrittenWithWords() {
|
||||
assertHidden("Дата рождения: 12 мая 1985 года", "12 мая 1985");
|
||||
assertHidden("Дата рождения двенадцатого мая тысяча девятьсот восемьдесят пятого года",
|
||||
"двенадцатого мая");
|
||||
assertHidden("Дата рождения: двадцать первого августа 1990 года", "двадцать первого августа");
|
||||
}
|
||||
@Test
|
||||
void masksBirthDateWrittenWithWords() {
|
||||
assertHidden("Дата рождения: 12 мая 1985 года", "12 мая 1985");
|
||||
assertHidden(
|
||||
"Дата рождения двенадцатого мая тысяча девятьсот восемьдесят пятого года",
|
||||
"двенадцатого мая");
|
||||
assertHidden("Дата рождения: двадцать первого августа 1990 года", "двадцать первого августа");
|
||||
}
|
||||
|
||||
@Test
|
||||
void keepsSeparatorsInMaskedDate() {
|
||||
String masked = mask("Дата рождения 12.05.1985");
|
||||
assertTrue(masked.endsWith("**.**.****"), masked);
|
||||
}
|
||||
@Test
|
||||
void keepsSeparatorsInMaskedDate() {
|
||||
String masked = mask("Дата рождения 12.05.1985");
|
||||
assertTrue(masked.endsWith("**.**.****"), masked);
|
||||
}
|
||||
|
||||
@Test
|
||||
void masksPassportIssueDate() {
|
||||
assertHidden("Паспорт 4509 123456, дата выдачи 12.05.2015", "12.05.2015");
|
||||
}
|
||||
@Test
|
||||
void masksPassportIssueDate() {
|
||||
assertHidden("Паспорт 4509 123456, дата выдачи 12.05.2015", "12.05.2015");
|
||||
}
|
||||
|
||||
@Test
|
||||
void doesNotMaskDateWithoutAnyOtherPersonalData() {
|
||||
String text = "Встреча перенесена на 12.05.2025, подтвердите";
|
||||
assertEquals(text, mask(text), "дата сама по себе персональными данными не является");
|
||||
}
|
||||
@Test
|
||||
void doesNotMaskDateWithoutAnyOtherPersonalData() {
|
||||
String text = "Встреча перенесена на 12.05.2025, подтвердите";
|
||||
assertEquals(text, mask(text), "дата сама по себе персональными данными не является");
|
||||
}
|
||||
|
||||
@Test
|
||||
void masksBareDateWhenOtherPersonalDataIsPresent() {
|
||||
assertHidden("Паспорт 4509 123456 оформлен 12.05.2015", "12.05.2015");
|
||||
}
|
||||
@Test
|
||||
void masksBareDateWhenOtherPersonalDataIsPresent() {
|
||||
assertHidden("Паспорт 4509 123456 оформлен 12.05.2015", "12.05.2015");
|
||||
}
|
||||
|
||||
@Test
|
||||
void doesNotTreatVersionOrAddressLikeNumbersAsDate() {
|
||||
String text = "Сервер 192.168.1 отвечает, сборка 1.2.3 развёрнута";
|
||||
assertEquals(text, mask(text));
|
||||
}
|
||||
@Test
|
||||
void doesNotTreatVersionOrAddressLikeNumbersAsDate() {
|
||||
String text = "Сервер 192.168.1 отвечает, сборка 1.2.3 развёрнута";
|
||||
assertEquals(text, mask(text));
|
||||
}
|
||||
|
||||
@Test
|
||||
void masksAddressComponentsSeparately() {
|
||||
String masked = mask("Адрес: 125009, г. Москва, ул. Тверская, д. 7, кв. 15");
|
||||
assertFalse(masked.contains("125009"), masked);
|
||||
assertFalse(masked.contains("Москва"), masked);
|
||||
assertFalse(masked.contains("Тверская"), masked);
|
||||
assertTrue(masked.contains("г. "), "указатели вида «г.», «ул.» остаются: " + masked);
|
||||
assertTrue(masked.contains("ул. "), masked);
|
||||
}
|
||||
@Test
|
||||
void masksAddressComponentsSeparately() {
|
||||
String masked = mask("Адрес: 125009, г. Москва, ул. Тверская, д. 7, кв. 15");
|
||||
assertFalse(masked.contains("125009"), masked);
|
||||
assertFalse(masked.contains("Москва"), masked);
|
||||
assertFalse(masked.contains("Тверская"), masked);
|
||||
assertTrue(masked.contains("г. "), "указатели вида «г.», «ул.» остаются: " + masked);
|
||||
assertTrue(masked.contains("ул. "), masked);
|
||||
}
|
||||
|
||||
@Test
|
||||
void streetNameDoesNotSwallowTheRestOfTheSentence() {
|
||||
String masked = mask("Адрес клиента: ул. Сосновая перекрыта из-за ремонта");
|
||||
assertTrue(masked.contains("перекрыта из-за ремонта"),
|
||||
"название улицы это одно-три слова, а не остаток предложения: " + masked);
|
||||
assertFalse(masked.contains("Сосновая"), masked);
|
||||
}
|
||||
@Test
|
||||
void streetNameDoesNotSwallowTheRestOfTheSentence() {
|
||||
String masked = mask("Адрес клиента: ул. Сосновая перекрыта из-за ремонта");
|
||||
assertTrue(
|
||||
masked.contains("перекрыта из-за ремонта"),
|
||||
"название улицы это одно-три слова, а не остаток предложения: " + masked);
|
||||
assertFalse(masked.contains("Сосновая"), masked);
|
||||
}
|
||||
|
||||
@Test
|
||||
void doesNotMaskStreetMentionedOutsideAnAddress() {
|
||||
assertEquals("Проспект Мира перекрыт до вечера", mask("Проспект Мира перекрыт до вечера"));
|
||||
assertEquals("Улица Весенняя названа в честь праздника",
|
||||
mask("Улица Весенняя названа в честь праздника"));
|
||||
}
|
||||
@Test
|
||||
void doesNotMaskStreetMentionedOutsideAnAddress() {
|
||||
assertEquals("Проспект Мира перекрыт до вечера", mask("Проспект Мира перекрыт до вечера"));
|
||||
assertEquals(
|
||||
"Улица Весенняя названа в честь праздника",
|
||||
mask("Улица Весенняя названа в честь праздника"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void masksMultiWordStreetName() {
|
||||
String masked = mask("Адрес: г. Москва, ул. Малая Никитская, д. 4");
|
||||
assertFalse(masked.contains("Малая Никитская"), masked);
|
||||
}
|
||||
@Test
|
||||
void masksMultiWordStreetName() {
|
||||
String masked = mask("Адрес: г. Москва, ул. Малая Никитская, д. 4");
|
||||
assertFalse(masked.contains("Малая Никитская"), masked);
|
||||
}
|
||||
|
||||
@Test
|
||||
void masksIndexByAnchorWord() {
|
||||
assertHidden("Индекс 125009 для доставки клиенту Иванову, паспорт 4509 123456", "125009");
|
||||
}
|
||||
@Test
|
||||
void masksIndexByAnchorWord() {
|
||||
assertHidden("Индекс 125009 для доставки клиенту Иванову, паспорт 4509 123456", "125009");
|
||||
}
|
||||
|
||||
@Test
|
||||
void doesNotMaskBankBranchAddress() {
|
||||
String text = "Отделение банка на улице Тверская, дом 7 работает до 20:00";
|
||||
assertEquals(text, mask(text), "адрес отделения банка персональными данными не является");
|
||||
}
|
||||
@Test
|
||||
void doesNotMaskBankBranchAddress() {
|
||||
String text = "Отделение банка на улице Тверская, дом 7 работает до 20:00";
|
||||
assertEquals(text, mask(text), "адрес отделения банка персональными данными не является");
|
||||
}
|
||||
|
||||
@Test
|
||||
void doesNotMaskOfficeAddress() {
|
||||
String text = "Дополнительный офис, г. Москва, ул. Арбат, д. 1";
|
||||
assertEquals(text, mask(text));
|
||||
}
|
||||
@Test
|
||||
void doesNotMaskOfficeAddress() {
|
||||
String text = "Дополнительный офис, г. Москва, ул. Арбат, д. 1";
|
||||
assertEquals(text, mask(text));
|
||||
}
|
||||
|
||||
@Test
|
||||
void addressTypesAreConfigurableSeparately() {
|
||||
SystemPolicy onlyCity = SystemPolicy.forTypes(PdTypes.ADDRESS_CITY);
|
||||
String masked = pipeline.process("г. Москва, ул. Тверская, д. 7", "addr-1", onlyCity);
|
||||
@Test
|
||||
void addressTypesAreConfigurableSeparately() {
|
||||
SystemPolicy onlyCity = SystemPolicy.forTypes(PdTypes.ADDRESS_CITY);
|
||||
String masked = pipeline.process("г. Москва, ул. Тверская, д. 7", "addr-1", onlyCity);
|
||||
|
||||
assertFalse(masked.contains("Москва"), masked);
|
||||
assertTrue(masked.contains("Тверская"), "улица этой системой не маскируется: " + masked);
|
||||
}
|
||||
assertFalse(masked.contains("Москва"), masked);
|
||||
assertTrue(masked.contains("Тверская"), "улица этой системой не маскируется: " + masked);
|
||||
}
|
||||
|
||||
@Test
|
||||
void unmaskingRestoresTextWithDateAndAddress() {
|
||||
String original = "Иванов, дата рождения 12.05.1985, адрес: 125009, г. Москва, "
|
||||
+ "ул. Тверская, д. 7, кв. 15, паспорт 4509 123456";
|
||||
String id = "date-addr-1";
|
||||
@Test
|
||||
void unmaskingRestoresTextWithDateAndAddress() {
|
||||
String original =
|
||||
"Иванов, дата рождения 12.05.1985, адрес: 125009, г. Москва, "
|
||||
+ "ул. Тверская, д. 7, кв. 15, паспорт 4509 123456";
|
||||
String id = "date-addr-1";
|
||||
|
||||
String masked = pipeline.process(original, id, SystemPolicy.DEFAULT);
|
||||
assertFalse(masked.contains("12.05.1985"), masked);
|
||||
assertEquals(original, pipeline.process(masked, id, SystemPolicy.DEFAULT));
|
||||
}
|
||||
String masked = pipeline.process(original, id, SystemPolicy.DEFAULT);
|
||||
assertFalse(masked.contains("12.05.1985"), masked);
|
||||
assertEquals(original, pipeline.process(masked, id, SystemPolicy.DEFAULT));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
package ru.pdguard;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
import java.util.UUID;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import ru.pdguard.config.SystemPolicy;
|
||||
import ru.pdguard.core.PayloadStore;
|
||||
@@ -7,125 +12,121 @@ 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;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
/** ФИО и защита от ложных срабатываний. */
|
||||
class FioTest {
|
||||
|
||||
private final Pipeline pipeline =
|
||||
new Pipeline(new RuleRegistry(), new Masker(), new PayloadStore(1_000_000L, 30));
|
||||
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 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 + "»");
|
||||
}
|
||||
private void assertHidden(String text, String secret) {
|
||||
String masked = mask(text);
|
||||
assertFalse(
|
||||
masked.contains(secret), "не замаскировано: «" + secret + "» в ответе «" + masked + "»");
|
||||
}
|
||||
|
||||
private void assertUnchanged(String text) {
|
||||
assertEquals(text, mask(text), "ложное срабатывание");
|
||||
}
|
||||
private void assertUnchanged(String text) {
|
||||
assertEquals(text, mask(text), "ложное срабатывание");
|
||||
}
|
||||
|
||||
@Test
|
||||
void masksFullNameAsInitials() {
|
||||
assertEquals("Клиент И. И. И. обратился", mask("Клиент Иванов Иван Иванович обратился"));
|
||||
}
|
||||
@Test
|
||||
void masksFullNameAsInitials() {
|
||||
assertEquals("Клиент И. И. И. обратился", mask("Клиент Иванов Иван Иванович обратился"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void masksNameAndPatronymicWithoutSurname() {
|
||||
assertHidden("Пригласите Ивана Сергеевича на встречу", "Ивана Сергеевича");
|
||||
}
|
||||
@Test
|
||||
void masksNameAndPatronymicWithoutSurname() {
|
||||
assertHidden("Пригласите Ивана Сергеевича на встречу", "Ивана Сергеевича");
|
||||
}
|
||||
|
||||
@Test
|
||||
void masksFemalePatronymic() {
|
||||
assertHidden("Петрова Анна Ивановна подписала", "Петрова Анна Ивановна");
|
||||
assertHidden("Мария Никитична ждёт ответа", "Мария Никитична");
|
||||
}
|
||||
@Test
|
||||
void masksFemalePatronymic() {
|
||||
assertHidden("Петрова Анна Ивановна подписала", "Петрова Анна Ивановна");
|
||||
assertHidden("Мария Никитична ждёт ответа", "Мария Никитична");
|
||||
}
|
||||
|
||||
@Test
|
||||
void masksSurnameWithInitialsInBothOrders() {
|
||||
assertHidden("Заявление от Иванов И.И. принято", "Иванов И.И.");
|
||||
assertHidden("Подписал И.И. Иванов", "И.И. Иванов");
|
||||
}
|
||||
@Test
|
||||
void masksSurnameWithInitialsInBothOrders() {
|
||||
assertHidden("Заявление от Иванов И.И. принято", "Иванов И.И.");
|
||||
assertHidden("Подписал И.И. Иванов", "И.И. Иванов");
|
||||
}
|
||||
|
||||
@Test
|
||||
void masksSurnameNextToKnownGivenName() {
|
||||
assertHidden("Петров Сергей подтвердил заявку", "Петров Сергей");
|
||||
assertHidden("Сергей Петров подтвердил заявку", "Сергей Петров");
|
||||
assertHidden("Обращение Ольги Ковалёвой рассмотрено", "Ольги Ковалёвой");
|
||||
}
|
||||
@Test
|
||||
void masksSurnameNextToKnownGivenName() {
|
||||
assertHidden("Петров Сергей подтвердил заявку", "Петров Сергей");
|
||||
assertHidden("Сергей Петров подтвердил заявку", "Сергей Петров");
|
||||
assertHidden("Обращение Ольги Ковалёвой рассмотрено", "Ольги Ковалёвой");
|
||||
}
|
||||
|
||||
@Test
|
||||
void masksLowercaseNameAfterExplicitAnchor() {
|
||||
assertHidden("ФИО: иванов иван иванович", "иванов иван иванович");
|
||||
assertHidden("Карта оформлена на имя петров сергей", "петров сергей");
|
||||
}
|
||||
@Test
|
||||
void masksLowercaseNameAfterExplicitAnchor() {
|
||||
assertHidden("ФИО: иванов иван иванович", "иванов иван иванович");
|
||||
assertHidden("Карта оформлена на имя петров сергей", "петров сергей");
|
||||
}
|
||||
|
||||
@Test
|
||||
void masksNameAfterRoleAnchor() {
|
||||
assertHidden("Клиент Петров Сергей, заявка одобрена", "Петров Сергей");
|
||||
assertHidden("Плательщик Ковалёва подтвердила перевод", "Ковалёва");
|
||||
}
|
||||
@Test
|
||||
void masksNameAfterRoleAnchor() {
|
||||
assertHidden("Клиент Петров Сергей, заявка одобрена", "Петров Сергей");
|
||||
assertHidden("Плательщик Ковалёва подтвердила перевод", "Ковалёва");
|
||||
}
|
||||
|
||||
@Test
|
||||
void doesNotMaskWellKnownPerson() {
|
||||
assertUnchanged("Напиши стихотворение в духе Александра Пушкина про осень");
|
||||
assertUnchanged("Сравни Толстого и Достоевского как прозаиков");
|
||||
assertUnchanged("Когда Гагарин полетел в космос");
|
||||
}
|
||||
@Test
|
||||
void doesNotMaskWellKnownPerson() {
|
||||
assertUnchanged("Напиши стихотворение в духе Александра Пушкина про осень");
|
||||
assertUnchanged("Сравни Толстого и Достоевского как прозаиков");
|
||||
assertUnchanged("Когда Гагарин полетел в космос");
|
||||
}
|
||||
|
||||
@Test
|
||||
void masksWellKnownSurnameWhenOtherPersonalDataIsPresent() {
|
||||
assertHidden("Клиент Александр Пушкин, паспорт 4509 123456", "Александр Пушкин");
|
||||
}
|
||||
@Test
|
||||
void masksWellKnownSurnameWhenOtherPersonalDataIsPresent() {
|
||||
assertHidden("Клиент Александр Пушкин, паспорт 4509 123456", "Александр Пушкин");
|
||||
}
|
||||
|
||||
@Test
|
||||
void doesNotMaskPlaceNamesThatLookLikeSurnames() {
|
||||
assertUnchanged("Московский Кремль открыт для посещения");
|
||||
assertUnchanged("Экскурсия в Нижний Новгород перенесена");
|
||||
assertUnchanged("Смоленская площадь закрыта на ремонт");
|
||||
}
|
||||
@Test
|
||||
void doesNotMaskPlaceNamesThatLookLikeSurnames() {
|
||||
assertUnchanged("Московский Кремль открыт для посещения");
|
||||
assertUnchanged("Экскурсия в Нижний Новгород перенесена");
|
||||
assertUnchanged("Смоленская площадь закрыта на ремонт");
|
||||
}
|
||||
|
||||
@Test
|
||||
void doesNotMaskOrdinaryCapitalisedWords() {
|
||||
assertUnchanged("Банк Открытие подтвердил лимит");
|
||||
assertUnchanged("В Понедельник Отдел Согласует Договор");
|
||||
}
|
||||
@Test
|
||||
void doesNotMaskOrdinaryCapitalisedWords() {
|
||||
assertUnchanged("Банк Открытие подтвердил лимит");
|
||||
assertUnchanged("В Понедельник Отдел Согласует Договор");
|
||||
}
|
||||
|
||||
@Test
|
||||
void identificationIgnoresCase() {
|
||||
assertHidden("ИВАНОВ ИВАН ИВАНОВИЧ", "ИВАНОВ ИВАН ИВАНОВИЧ");
|
||||
assertHidden("фио: петрова анна ивановна", "петрова анна ивановна");
|
||||
}
|
||||
@Test
|
||||
void identificationIgnoresCase() {
|
||||
assertHidden("ИВАНОВ ИВАН ИВАНОВИЧ", "ИВАНОВ ИВАН ИВАНОВИЧ");
|
||||
assertHidden("фио: петрова анна ивановна", "петрова анна ивановна");
|
||||
}
|
||||
|
||||
@Test
|
||||
void unmaskingRestoresNames() {
|
||||
String original = "Клиент Иванов Иван Иванович, паспорт 4509 123456, "
|
||||
+ "дата рождения 12.05.1985, телефон +7 916 123-45-67";
|
||||
String id = "fio-1";
|
||||
@Test
|
||||
void unmaskingRestoresNames() {
|
||||
String original =
|
||||
"Клиент Иванов Иван Иванович, паспорт 4509 123456, "
|
||||
+ "дата рождения 12.05.1985, телефон +7 916 123-45-67";
|
||||
String id = "fio-1";
|
||||
|
||||
String masked = pipeline.process(original, id, SystemPolicy.DEFAULT);
|
||||
assertFalse(masked.contains("Иванов Иван Иванович"), masked);
|
||||
assertTrue(masked.contains("И. И. И."), masked);
|
||||
assertEquals(original, pipeline.process(masked, id, SystemPolicy.DEFAULT));
|
||||
}
|
||||
String masked = pipeline.process(original, id, SystemPolicy.DEFAULT);
|
||||
assertFalse(masked.contains("Иванов Иван Иванович"), masked);
|
||||
assertTrue(masked.contains("И. И. И."), masked);
|
||||
assertEquals(original, pipeline.process(masked, id, SystemPolicy.DEFAULT));
|
||||
}
|
||||
|
||||
@Test
|
||||
void namesStayFastOnLargeText() {
|
||||
String block = "Клиент Иванов Иван Иванович, паспорт 4509 123456, город Москва. ";
|
||||
String large = block.repeat(4000);
|
||||
@Test
|
||||
void namesStayFastOnLargeText() {
|
||||
String block = "Клиент Иванов Иван Иванович, паспорт 4509 123456, город Москва. ";
|
||||
String large = block.repeat(4000);
|
||||
|
||||
long started = System.nanoTime();
|
||||
String masked = pipeline.process(large, "fio-large", SystemPolicy.DEFAULT);
|
||||
long millis = (System.nanoTime() - started) / 1_000_000;
|
||||
long started = System.nanoTime();
|
||||
String masked = pipeline.process(large, "fio-large", SystemPolicy.DEFAULT);
|
||||
long millis = (System.nanoTime() - started) / 1_000_000;
|
||||
|
||||
assertFalse(masked.contains("Иванов Иван Иванович"));
|
||||
assertTrue(millis < 1000, "обработка заняла " + millis + " мс");
|
||||
}
|
||||
assertFalse(masked.contains("Иванов Иван Иванович"));
|
||||
assertTrue(millis < 1000, "обработка заняла " + millis + " мс");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,14 @@
|
||||
package ru.pdguard;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
import static org.junit.jupiter.api.DynamicTest.dynamicTest;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
import java.util.Random;
|
||||
import java.util.stream.Stream;
|
||||
import org.junit.jupiter.api.DynamicTest;
|
||||
import org.junit.jupiter.api.TestFactory;
|
||||
import ru.pdguard.config.SystemPolicy;
|
||||
@@ -9,149 +18,158 @@ import ru.pdguard.detect.RuleRegistry;
|
||||
import ru.pdguard.detect.Span;
|
||||
import ru.pdguard.mask.Masker;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
import java.util.Random;
|
||||
import java.util.stream.Stream;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
import static org.junit.jupiter.api.DynamicTest.dynamicTest;
|
||||
|
||||
/**
|
||||
* Датасет из 1000 прогонов разной длины — критерий 3.5 из "Критерии_оценивания_альфа"
|
||||
* (обработка текстов до 100 000 токенов) и стоп-сигнал по утечке ПДН из "критерии_фрейм_топы".
|
||||
* Датасет из 1000 прогонов разной длины — критерий 3.5 из "Критерии_оценивания_альфа" (обработка
|
||||
* текстов до 100 000 токенов) и стоп-сигнал по утечке ПДН из "критерии_фрейм_топы".
|
||||
*
|
||||
* <p>Тексты строятся перемешиванием строк из уже существующих размеченных наборов
|
||||
* {@code benchmark-*.txt} (17+ типов ПДН из ТЗ) — отдельный датасет с нуля не заводится,
|
||||
* пул размеченных примеров и так покрывает все типы. Длина растёт от одного предложения
|
||||
* до 400 000 знаков (100 000 токенов при 4 знака/токен — так же, как считает сам
|
||||
* {@link Pipeline}); не менее {@link #HUGE_CASES} прогонов лежат в полосе 90 000-100 000
|
||||
* токенов. На каждом прогоне проверяются: отсутствие ПДН в открытом виде в замаскированном
|
||||
* тексте и побайтовое совпадение после демаскирования; на прогонах из полосы 90-100к токенов
|
||||
* дополнительно проверяется, что маскирование укладывается в 5 секунд.
|
||||
* <p>Тексты строятся перемешиванием строк из уже существующих размеченных наборов {@code
|
||||
* benchmark-*.txt} (17+ типов ПДН из ТЗ) — отдельный датасет с нуля не заводится, пул размеченных
|
||||
* примеров и так покрывает все типы. Длина растёт от одного предложения до 400 000 знаков (100 000
|
||||
* токенов при 4 знака/токен — так же, как считает сам {@link Pipeline}); не менее {@link
|
||||
* #HUGE_CASES} прогонов лежат в полосе 90 000-100 000 токенов. На каждом прогоне проверяются:
|
||||
* отсутствие ПДН в открытом виде в замаскированном тексте и побайтовое совпадение после
|
||||
* демаскирования; на прогонах из полосы 90-100к токенов дополнительно проверяется, что маскирование
|
||||
* укладывается в 5 секунд.
|
||||
*
|
||||
* <p>Полный прогон класса занимает пару минут — это ожидаемо на объёме, требуемом ТЗ.
|
||||
*/
|
||||
class HugeDatasetTest {
|
||||
|
||||
private static final int TOTAL_CASES = 1000;
|
||||
private static final int HUGE_CASES = 50;
|
||||
private static final int CHARS_PER_TOKEN = 4;
|
||||
private static final int HUGE_MIN_CHARS = 90_000 * CHARS_PER_TOKEN;
|
||||
private static final int HUGE_MAX_CHARS = 100_000 * CHARS_PER_TOKEN;
|
||||
/** Короткие значения (PIN, номер дома и т.п.) чаще случайно совпадают с посторонним
|
||||
* текстом пула — их из проверки на утечку исключаем, длинные ПДН проверяем всегда. */
|
||||
private static final int LEAK_CHECK_MIN_LENGTH = 6;
|
||||
/** Допустимая доля утечек на прогон. Пул включает настоящие holdout-наборы
|
||||
* (benchmark-holdout*.txt), на которых BenchmarkTest сам принимает полноту
|
||||
* от 0.70 — это и есть отправная точка, а не 0.85 из LargeTextTest, где
|
||||
* участвует только benchmark-generated.txt, подстроенный под правила. */
|
||||
private static final double MAX_LEAK_RATE = 0.30;
|
||||
private static final int TOTAL_CASES = 1000;
|
||||
private static final int HUGE_CASES = 50;
|
||||
private static final int CHARS_PER_TOKEN = 4;
|
||||
private static final int HUGE_MIN_CHARS = 90_000 * CHARS_PER_TOKEN;
|
||||
private static final int HUGE_MAX_CHARS = 100_000 * CHARS_PER_TOKEN;
|
||||
|
||||
private static final RuleRegistry REGISTRY = new RuleRegistry();
|
||||
private static final Masker MASKER = new Masker();
|
||||
private static final List<BenchmarkFixtures.Sample> POOL = loadPool();
|
||||
/**
|
||||
* Короткие значения (PIN, номер дома и т.п.) чаще случайно совпадают с посторонним текстом пула —
|
||||
* их из проверки на утечку исключаем, длинные ПДН проверяем всегда.
|
||||
*/
|
||||
private static final int LEAK_CHECK_MIN_LENGTH = 6;
|
||||
|
||||
private static List<BenchmarkFixtures.Sample> loadPool() {
|
||||
List<BenchmarkFixtures.Sample> pool = new ArrayList<>();
|
||||
for (String resource : List.of(
|
||||
"/benchmark.txt", "/benchmark-generated.txt", "/benchmark-pdn-types.txt",
|
||||
"/benchmark-bank-context.txt", "/benchmark-holdout.txt",
|
||||
"/benchmark-holdout2.txt", "/benchmark-holdout3.txt")) {
|
||||
pool.addAll(BenchmarkFixtures.load(resource));
|
||||
/**
|
||||
* Допустимая доля утечек на прогон. Пул включает настоящие holdout-наборы
|
||||
* (benchmark-holdout*.txt), на которых BenchmarkTest сам принимает полноту от 0.70 — это и есть
|
||||
* отправная точка, а не 0.85 из LargeTextTest, где участвует только benchmark-generated.txt,
|
||||
* подстроенный под правила.
|
||||
*/
|
||||
private static final double MAX_LEAK_RATE = 0.30;
|
||||
|
||||
private static final RuleRegistry REGISTRY = new RuleRegistry();
|
||||
private static final Masker MASKER = new Masker();
|
||||
private static final List<BenchmarkFixtures.Sample> POOL = loadPool();
|
||||
|
||||
private static List<BenchmarkFixtures.Sample> loadPool() {
|
||||
List<BenchmarkFixtures.Sample> pool = new ArrayList<>();
|
||||
for (String resource :
|
||||
List.of(
|
||||
"/benchmark.txt",
|
||||
"/benchmark-generated.txt",
|
||||
"/benchmark-pdn-types.txt",
|
||||
"/benchmark-bank-context.txt",
|
||||
"/benchmark-holdout.txt",
|
||||
"/benchmark-holdout2.txt",
|
||||
"/benchmark-holdout3.txt")) {
|
||||
pool.addAll(BenchmarkFixtures.load(resource));
|
||||
}
|
||||
return pool;
|
||||
}
|
||||
|
||||
@TestFactory
|
||||
Stream<DynamicTest> datasetOfThousandCases() {
|
||||
List<DynamicTest> cases = new ArrayList<>(TOTAL_CASES);
|
||||
for (int i = 0; i < TOTAL_CASES; i++) {
|
||||
int targetChars = targetChars(i);
|
||||
int index = i;
|
||||
cases.add(
|
||||
dynamicTest(
|
||||
String.format(
|
||||
"#%04d, %d знаков (~%d токенов)",
|
||||
index, targetChars, targetChars / CHARS_PER_TOKEN),
|
||||
() -> runCase(targetChars, index)));
|
||||
}
|
||||
return cases.stream();
|
||||
}
|
||||
|
||||
/**
|
||||
* Длина растёт по логарифмической шкале от предложения до порога "огромного" текста — так тесты
|
||||
* покрывают все порядки величины, а не только маленькие и не только большие. Последние {@link
|
||||
* #HUGE_CASES} индексов — обязательная полоса 90-100к токенов из ТЗ.
|
||||
*/
|
||||
private static int targetChars(int index) {
|
||||
int regular = TOTAL_CASES - HUGE_CASES;
|
||||
if (index >= regular) {
|
||||
int step = (HUGE_MAX_CHARS - HUGE_MIN_CHARS) / Math.max(1, HUGE_CASES - 1);
|
||||
return HUGE_MIN_CHARS + (index - regular) * step;
|
||||
}
|
||||
double minChars = 80;
|
||||
double maxChars = HUGE_MIN_CHARS - 1;
|
||||
double ratio = (double) index / Math.max(1, regular - 1);
|
||||
return (int) Math.round(minChars * Math.pow(maxChars / minChars, ratio));
|
||||
}
|
||||
|
||||
private void runCase(int targetChars, int seed) {
|
||||
Pipeline pipeline =
|
||||
new Pipeline(REGISTRY, MASKER, new PayloadStore(targetChars * 2L + 4096, 30));
|
||||
BenchmarkFixtures.Sample sample = buildText(targetChars, seed);
|
||||
String payloadId = "dataset-" + seed;
|
||||
|
||||
long maskStarted = System.nanoTime();
|
||||
String masked = pipeline.process(sample.text(), payloadId, SystemPolicy.DEFAULT);
|
||||
long maskMillis = (System.nanoTime() - maskStarted) / 1_000_000;
|
||||
|
||||
int checked = 0;
|
||||
int leaked = 0;
|
||||
for (Span gold : sample.gold()) {
|
||||
String value = sample.text().substring(gold.start(), gold.end());
|
||||
if (value.length() >= LEAK_CHECK_MIN_LENGTH) {
|
||||
checked++;
|
||||
if (masked.contains(value)) {
|
||||
leaked++;
|
||||
}
|
||||
return pool;
|
||||
}
|
||||
}
|
||||
if (checked > 0) {
|
||||
// На малых текстах пара пропусков — статистический шум, не деградация детектора:
|
||||
// абсолютный запас на такие случаи не даёт доле "перевесить" маленький знаменатель.
|
||||
int allowed = Math.max(4, (int) Math.ceil(checked * MAX_LEAK_RATE));
|
||||
assertTrue(
|
||||
leaked <= allowed,
|
||||
String.format(
|
||||
"утечка ПДН в замаскированном тексте: %d из %d, допустимо %d",
|
||||
leaked, checked, allowed));
|
||||
}
|
||||
|
||||
@TestFactory
|
||||
Stream<DynamicTest> datasetOfThousandCases() {
|
||||
List<DynamicTest> cases = new ArrayList<>(TOTAL_CASES);
|
||||
for (int i = 0; i < TOTAL_CASES; i++) {
|
||||
int targetChars = targetChars(i);
|
||||
int index = i;
|
||||
cases.add(dynamicTest(
|
||||
String.format("#%04d, %d знаков (~%d токенов)", index, targetChars, targetChars / CHARS_PER_TOKEN),
|
||||
() -> runCase(targetChars, index)));
|
||||
}
|
||||
return cases.stream();
|
||||
String restored = pipeline.process(masked, payloadId, SystemPolicy.DEFAULT);
|
||||
assertEquals(sample.text(), restored, "демаскирование не восстановило исходный текст");
|
||||
|
||||
if (targetChars >= HUGE_MIN_CHARS) {
|
||||
assertTrue(
|
||||
maskMillis < 5000,
|
||||
"маскирование " + targetChars + " знаков заняло " + maskMillis + " мс");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Длина растёт по логарифмической шкале от предложения до порога "огромного" текста —
|
||||
* так тесты покрывают все порядки величины, а не только маленькие и не только большие.
|
||||
* Последние {@link #HUGE_CASES} индексов — обязательная полоса 90-100к токенов из ТЗ.
|
||||
*/
|
||||
private static int targetChars(int index) {
|
||||
int regular = TOTAL_CASES - HUGE_CASES;
|
||||
if (index >= regular) {
|
||||
int step = (HUGE_MAX_CHARS - HUGE_MIN_CHARS) / Math.max(1, HUGE_CASES - 1);
|
||||
return HUGE_MIN_CHARS + (index - regular) * step;
|
||||
/** Перемешивает строки пула детерминированно по seed и склеивает до нужного объёма. */
|
||||
private static BenchmarkFixtures.Sample buildText(int targetChars, long seed) {
|
||||
List<BenchmarkFixtures.Sample> shuffled = new ArrayList<>(POOL);
|
||||
Random random = new Random(seed);
|
||||
StringBuilder text = new StringBuilder(targetChars + 1024);
|
||||
List<Span> gold = new ArrayList<>();
|
||||
|
||||
while (text.length() < targetChars) {
|
||||
Collections.shuffle(shuffled, random);
|
||||
for (BenchmarkFixtures.Sample sample : shuffled) {
|
||||
int offset = text.length();
|
||||
text.append(sample.text()).append('\n');
|
||||
for (Span span : sample.gold()) {
|
||||
gold.add(new Span(span.start() + offset, span.end() + offset, span.type(), 0));
|
||||
}
|
||||
double minChars = 80;
|
||||
double maxChars = HUGE_MIN_CHARS - 1;
|
||||
double ratio = (double) index / Math.max(1, regular - 1);
|
||||
return (int) Math.round(minChars * Math.pow(maxChars / minChars, ratio));
|
||||
}
|
||||
|
||||
private void runCase(int targetChars, int seed) {
|
||||
Pipeline pipeline = new Pipeline(REGISTRY, MASKER, new PayloadStore(targetChars * 2L + 4096, 30));
|
||||
BenchmarkFixtures.Sample sample = buildText(targetChars, seed);
|
||||
String payloadId = "dataset-" + seed;
|
||||
|
||||
long maskStarted = System.nanoTime();
|
||||
String masked = pipeline.process(sample.text(), payloadId, SystemPolicy.DEFAULT);
|
||||
long maskMillis = (System.nanoTime() - maskStarted) / 1_000_000;
|
||||
|
||||
int checked = 0;
|
||||
int leaked = 0;
|
||||
for (Span gold : sample.gold()) {
|
||||
String value = sample.text().substring(gold.start(), gold.end());
|
||||
if (value.length() >= LEAK_CHECK_MIN_LENGTH) {
|
||||
checked++;
|
||||
if (masked.contains(value)) {
|
||||
leaked++;
|
||||
}
|
||||
}
|
||||
if (text.length() >= targetChars) {
|
||||
break;
|
||||
}
|
||||
if (checked > 0) {
|
||||
// На малых текстах пара пропусков — статистический шум, не деградация детектора:
|
||||
// абсолютный запас на такие случаи не даёт доле "перевесить" маленький знаменатель.
|
||||
int allowed = Math.max(4, (int) Math.ceil(checked * MAX_LEAK_RATE));
|
||||
assertTrue(leaked <= allowed,
|
||||
String.format("утечка ПДН в замаскированном тексте: %d из %d, допустимо %d",
|
||||
leaked, checked, allowed));
|
||||
}
|
||||
|
||||
String restored = pipeline.process(masked, payloadId, SystemPolicy.DEFAULT);
|
||||
assertEquals(sample.text(), restored, "демаскирование не восстановило исходный текст");
|
||||
|
||||
if (targetChars >= HUGE_MIN_CHARS) {
|
||||
assertTrue(maskMillis < 5000, "маскирование " + targetChars + " знаков заняло " + maskMillis + " мс");
|
||||
}
|
||||
}
|
||||
|
||||
/** Перемешивает строки пула детерминированно по seed и склеивает до нужного объёма. */
|
||||
private static BenchmarkFixtures.Sample buildText(int targetChars, long seed) {
|
||||
List<BenchmarkFixtures.Sample> shuffled = new ArrayList<>(POOL);
|
||||
Random random = new Random(seed);
|
||||
StringBuilder text = new StringBuilder(targetChars + 1024);
|
||||
List<Span> gold = new ArrayList<>();
|
||||
|
||||
while (text.length() < targetChars) {
|
||||
Collections.shuffle(shuffled, random);
|
||||
for (BenchmarkFixtures.Sample sample : shuffled) {
|
||||
int offset = text.length();
|
||||
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) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
return new BenchmarkFixtures.Sample(text.toString(), gold);
|
||||
}
|
||||
}
|
||||
return new BenchmarkFixtures.Sample(text.toString(), gold);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
package ru.pdguard;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
|
||||
import java.util.UUID;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import ru.pdguard.config.SystemPolicy;
|
||||
import ru.pdguard.core.PayloadStore;
|
||||
@@ -7,64 +11,62 @@ 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 IdentityDocumentTest {
|
||||
|
||||
private final Pipeline pipeline =
|
||||
new Pipeline(new RuleRegistry(), new Masker(), new PayloadStore(1_000_000L, 30));
|
||||
private final Pipeline pipeline =
|
||||
new Pipeline(new RuleRegistry(), new Masker(), new PayloadStore(1_000_000L, 30));
|
||||
|
||||
private void assertHidden(String text, String secret) {
|
||||
String masked = pipeline.process(text, UUID.randomUUID().toString(), SystemPolicy.DEFAULT);
|
||||
assertFalse(masked.contains(secret), "не замаскировано: «" + secret + "» в ответе «" + masked + "»");
|
||||
}
|
||||
private void assertHidden(String text, String secret) {
|
||||
String masked = pipeline.process(text, UUID.randomUUID().toString(), SystemPolicy.DEFAULT);
|
||||
assertFalse(
|
||||
masked.contains(secret), "не замаскировано: «" + secret + "» в ответе «" + masked + "»");
|
||||
}
|
||||
|
||||
private void assertMasked(String text, String payloadId, String expected) {
|
||||
assertEquals(expected, pipeline.process(text, payloadId, SystemPolicy.DEFAULT));
|
||||
}
|
||||
private void assertMasked(String text, String payloadId, String expected) {
|
||||
assertEquals(expected, pipeline.process(text, payloadId, SystemPolicy.DEFAULT));
|
||||
}
|
||||
|
||||
@Test
|
||||
void masksForeignPassport() {
|
||||
assertHidden("Загранпаспорт 75 1234567 действителен до 2030 года", "75 1234567");
|
||||
}
|
||||
@Test
|
||||
void masksForeignPassport() {
|
||||
assertHidden("Загранпаспорт 75 1234567 действителен до 2030 года", "75 1234567");
|
||||
}
|
||||
|
||||
@Test
|
||||
void masksMilitaryId() {
|
||||
assertHidden("Военный билет АБ 1234567 предъявлен", "АБ 1234567");
|
||||
}
|
||||
@Test
|
||||
void masksMilitaryId() {
|
||||
assertHidden("Военный билет АБ 1234567 предъявлен", "АБ 1234567");
|
||||
}
|
||||
|
||||
@Test
|
||||
void masksBirthCertificate() {
|
||||
assertHidden("Свидетельство о рождении II-МЮ № 123456", "II-МЮ № 123456");
|
||||
}
|
||||
@Test
|
||||
void masksBirthCertificate() {
|
||||
assertHidden("Свидетельство о рождении II-МЮ № 123456", "II-МЮ № 123456");
|
||||
}
|
||||
|
||||
@Test
|
||||
void masksMedicalPolicy() {
|
||||
assertHidden("Полис ОМС 1234567890123456 оформлен", "1234567890123456");
|
||||
}
|
||||
@Test
|
||||
void masksMedicalPolicy() {
|
||||
assertHidden("Полис ОМС 1234567890123456 оформлен", "1234567890123456");
|
||||
}
|
||||
|
||||
/**
|
||||
* У загранпаспорта, военного билета и свидетельства о рождении серия короткая —
|
||||
* две цифры или две буквы. Открой маска первые два знака, серия была бы видна
|
||||
* целиком, поэтому у этих документов открыты только последние знаки номера.
|
||||
*/
|
||||
@Test
|
||||
void hidesShortDocumentSeriesCompletely() {
|
||||
assertMasked("Загранпаспорт 75 1234567", "fp-1", "Загранпаспорт ** *****67");
|
||||
assertMasked("Военный билет АБ 1234567", "mil-1", "Военный билет ** *****67");
|
||||
assertMasked("Свидетельство о рождении II-МЮ № 123456", "bc-1",
|
||||
"Свидетельство о рождении **-** № ****56");
|
||||
}
|
||||
/**
|
||||
* У загранпаспорта, военного билета и свидетельства о рождении серия короткая — две цифры или две
|
||||
* буквы. Открой маска первые два знака, серия была бы видна целиком, поэтому у этих документов
|
||||
* открыты только последние знаки номера.
|
||||
*/
|
||||
@Test
|
||||
void hidesShortDocumentSeriesCompletely() {
|
||||
assertMasked("Загранпаспорт 75 1234567", "fp-1", "Загранпаспорт ** *****67");
|
||||
assertMasked("Военный билет АБ 1234567", "mil-1", "Военный билет ** *****67");
|
||||
assertMasked(
|
||||
"Свидетельство о рождении II-МЮ № 123456",
|
||||
"bc-1",
|
||||
"Свидетельство о рождении **-** № ****56");
|
||||
}
|
||||
|
||||
/** У паспорта РФ и водительского удостоверения серия из четырёх знаков — открыта половина. */
|
||||
@Test
|
||||
void keepsHalfOfFourCharacterSeries() {
|
||||
assertMasked("Паспорт 4509 123456", "rf-1", "Паспорт 45** ****56");
|
||||
assertMasked("Водительское удостоверение 9902 123456", "dl-1",
|
||||
"Водительское удостоверение 99** ****56");
|
||||
}
|
||||
/** У паспорта РФ и водительского удостоверения серия из четырёх знаков — открыта половина. */
|
||||
@Test
|
||||
void keepsHalfOfFourCharacterSeries() {
|
||||
assertMasked("Паспорт 4509 123456", "rf-1", "Паспорт 45** ****56");
|
||||
assertMasked(
|
||||
"Водительское удостоверение 9902 123456", "dl-1", "Водительское удостоверение 99** ****56");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,14 +1,7 @@
|
||||
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.Span;
|
||||
import ru.pdguard.detect.NameCascade;
|
||||
import ru.pdguard.detect.RuleRegistry;
|
||||
import ru.pdguard.mask.Masker;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
@@ -17,131 +10,143 @@ import java.util.Collections;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.Random;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
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.NameCascade;
|
||||
import ru.pdguard.detect.RuleRegistry;
|
||||
import ru.pdguard.detect.Span;
|
||||
import ru.pdguard.mask.Masker;
|
||||
|
||||
/**
|
||||
* Качество и скорость на большом тексте — не повторе одного и того же
|
||||
* предложения, а перемешанных строках из {@code benchmark-generated.txt}
|
||||
* (все типы ПД вперемешку с чистым текстом), растянутых до объёма из ТЗ
|
||||
* (около 100 000 токенов, ~400 КБ по оценке из README).
|
||||
* Качество и скорость на большом тексте — не повторе одного и того же предложения, а перемешанных
|
||||
* строках из {@code benchmark-generated.txt} (все типы ПД вперемешку с чистым текстом), растянутых
|
||||
* до объёма из ТЗ (около 100 000 токенов, ~400 КБ по оценке из README).
|
||||
*
|
||||
* <p>Раздутый повтором одной строки текст проверяет только то, что цикл не
|
||||
* падает на объёме: под маской всегда один и тот же тип, а остальные правила
|
||||
* не задействуются вовсе. Здесь размер и разнообразие проверяются вместе.
|
||||
* <p>Раздутый повтором одной строки текст проверяет только то, что цикл не падает на объёме: под
|
||||
* маской всегда один и тот же тип, а остальные правила не задействуются вовсе. Здесь размер и
|
||||
* разнообразие проверяются вместе.
|
||||
*/
|
||||
class LargeTextTest {
|
||||
|
||||
private static final String ENGINE = System.getProperty("bench.engine", "rubert");
|
||||
private static final String MODEL_PATH = System.getProperty("bench.model", "models/rubert-ner");
|
||||
private static final String ENGINE = System.getProperty("bench.engine", "rubert");
|
||||
private static final String MODEL_PATH = System.getProperty("bench.model", "models/rubert-ner");
|
||||
|
||||
/** Целевой объём: README оценивает 100 000 токенов как ~400 КБ текста. */
|
||||
private static final int TARGET_CHARS = 400_000;
|
||||
/** Целевой объём: README оценивает 100 000 токенов как ~400 КБ текста. */
|
||||
private static final int TARGET_CHARS = 400_000;
|
||||
|
||||
/**
|
||||
* Перемешивает исходные строки (фиксированный seed — детерминированный
|
||||
* тест) и склеивает их через перенос строки, пока не наберётся целевой
|
||||
* объём. Смещения золотых фрагментов пересчитываются под общий текст.
|
||||
*/
|
||||
private static BenchmarkFixtures.Sample buildLargeText(int targetChars, long seed) {
|
||||
List<BenchmarkFixtures.Sample> pool = new ArrayList<>(BenchmarkFixtures.load("/benchmark-generated.txt"));
|
||||
Random random = new Random(seed);
|
||||
StringBuilder text = new StringBuilder(targetChars + 1024);
|
||||
List<Span> gold = new ArrayList<>();
|
||||
/**
|
||||
* Перемешивает исходные строки (фиксированный seed — детерминированный тест) и склеивает их через
|
||||
* перенос строки, пока не наберётся целевой объём. Смещения золотых фрагментов пересчитываются
|
||||
* под общий текст.
|
||||
*/
|
||||
private static BenchmarkFixtures.Sample buildLargeText(int targetChars, long seed) {
|
||||
List<BenchmarkFixtures.Sample> pool =
|
||||
new ArrayList<>(BenchmarkFixtures.load("/benchmark-generated.txt"));
|
||||
Random random = new Random(seed);
|
||||
StringBuilder text = new StringBuilder(targetChars + 1024);
|
||||
List<Span> gold = new ArrayList<>();
|
||||
|
||||
while (text.length() < targetChars) {
|
||||
Collections.shuffle(pool, random);
|
||||
for (BenchmarkFixtures.Sample sample : pool) {
|
||||
int offset = text.length();
|
||||
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) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
while (text.length() < targetChars) {
|
||||
Collections.shuffle(pool, random);
|
||||
for (BenchmarkFixtures.Sample sample : pool) {
|
||||
int offset = text.length();
|
||||
text.append(sample.text()).append('\n');
|
||||
for (Span span : sample.gold()) {
|
||||
gold.add(new Span(span.start() + offset, span.end() + offset, span.type(), 0));
|
||||
}
|
||||
return new BenchmarkFixtures.Sample(text.toString(), gold);
|
||||
}
|
||||
|
||||
/**
|
||||
* Маскирование и обратное преобразование на большом тексте дают
|
||||
* побайтово тот же результат, что и исходный текст — при объёме на
|
||||
* порядок больше, чем в остальных тестах, и с разнородным содержимым,
|
||||
* а не одним повторяющимся предложением.
|
||||
*/
|
||||
@Test
|
||||
void roundTripOnLargeMixedText() {
|
||||
BenchmarkFixtures.Sample large = buildLargeText(TARGET_CHARS, 1);
|
||||
Pipeline pipeline = new Pipeline(new RuleRegistry(), new Masker(),
|
||||
new PayloadStore(large.text().length() * 2L, 30));
|
||||
|
||||
long maskStarted = System.nanoTime();
|
||||
String masked = pipeline.process(large.text(), "large-mixed-1", SystemPolicy.DEFAULT);
|
||||
long maskMillis = (System.nanoTime() - maskStarted) / 1_000_000;
|
||||
|
||||
long unmaskStarted = System.nanoTime();
|
||||
String restored = pipeline.process(masked, "large-mixed-1", SystemPolicy.DEFAULT);
|
||||
long unmaskMillis = (System.nanoTime() - unmaskStarted) / 1_000_000;
|
||||
|
||||
assertEquals(large.text(), restored, "демаскирование не восстановило исходный текст");
|
||||
assertTrue(maskMillis < 5000, "маскирование " + large.text().length() + " знаков заняло " + maskMillis + " мс");
|
||||
assertTrue(unmaskMillis < 1000, "демаскирование заняло " + unmaskMillis + " мс");
|
||||
|
||||
System.out.printf("%nБольшой текст: %d знаков, маскирование %d мс, демаскирование %d мс%n",
|
||||
large.text().length(), maskMillis, unmaskMillis);
|
||||
}
|
||||
|
||||
/**
|
||||
* Полнота детекции не должна проседать на объёме: каждый золотой
|
||||
* фрагмент из перемешанных строк обязан быть найден в общем потоке
|
||||
* текста, а не только когда он единственный в маленькой строке.
|
||||
*/
|
||||
@Test
|
||||
void recallHoldsAtScale() {
|
||||
BenchmarkFixtures.Sample large = buildLargeText(TARGET_CHARS, 2);
|
||||
Pipeline pipeline = new Pipeline(new RuleRegistry(), new Masker(), new PayloadStore(1L, 30));
|
||||
|
||||
List<Span> found = pipeline.findPersonalData(large.text(), SystemPolicy.DEFAULT);
|
||||
int hit = 0;
|
||||
for (Span gold : large.gold()) {
|
||||
if (found.stream().anyMatch(f -> f.type().equals(gold.type()) && f.overlaps(gold))) {
|
||||
hit++;
|
||||
}
|
||||
if (text.length() >= targetChars) {
|
||||
break;
|
||||
}
|
||||
double recall = large.gold().isEmpty() ? 1.0 : (double) hit / large.gold().size();
|
||||
System.out.printf("%nПолнота на большом тексте: %d из %d (%.3f)%n", hit, large.gold().size(), recall);
|
||||
|
||||
assertTrue(recall >= 0.85,
|
||||
String.format("полнота на большом тексте упала до %.3f (%d/%d)", recall, hit, large.gold().size()));
|
||||
}
|
||||
}
|
||||
return new BenchmarkFixtures.Sample(text.toString(), gold);
|
||||
}
|
||||
|
||||
/**
|
||||
* Вторая ступень ограничена числом кандидатов на запрос
|
||||
* ({@code pdguard.ner.max-candidates}), поэтому объём текста не должен
|
||||
* превращать её в квадратичную нагрузку — проверяем на том же большом
|
||||
* тексте, что и остальные тесты, а не на маленьком образце.
|
||||
*/
|
||||
@Test
|
||||
void nameCascadeStaysBoundedOnLargeText() {
|
||||
Path model = Path.of(MODEL_PATH);
|
||||
if (!Files.isReadable(model)) {
|
||||
System.out.println("Модель " + model.toAbsolutePath() + " не собрана, пропускаю");
|
||||
return;
|
||||
}
|
||||
BenchmarkFixtures.Sample large = buildLargeText(TARGET_CHARS, 3);
|
||||
Pipeline pipeline = new Pipeline(new RuleRegistry(), new Masker(),
|
||||
new PayloadStore(large.text().length() * 2L, 30),
|
||||
new NameCascade(ENGINE, Optional.of(MODEL_PATH), 16, 4));
|
||||
/**
|
||||
* Маскирование и обратное преобразование на большом тексте дают побайтово тот же результат, что и
|
||||
* исходный текст — при объёме на порядок больше, чем в остальных тестах, и с разнородным
|
||||
* содержимым, а не одним повторяющимся предложением.
|
||||
*/
|
||||
@Test
|
||||
void roundTripOnLargeMixedText() {
|
||||
BenchmarkFixtures.Sample large = buildLargeText(TARGET_CHARS, 1);
|
||||
Pipeline pipeline =
|
||||
new Pipeline(
|
||||
new RuleRegistry(), new Masker(), new PayloadStore(large.text().length() * 2L, 30));
|
||||
|
||||
long started = System.nanoTime();
|
||||
pipeline.process(large.text(), "large-cascade-1", SystemPolicy.DEFAULT);
|
||||
long millis = (System.nanoTime() - started) / 1_000_000;
|
||||
long maskStarted = System.nanoTime();
|
||||
String masked = pipeline.process(large.text(), "large-mixed-1", SystemPolicy.DEFAULT);
|
||||
long maskMillis = (System.nanoTime() - maskStarted) / 1_000_000;
|
||||
|
||||
System.out.printf("%nБольшой текст со второй ступенью: %d знаков за %d мс%n",
|
||||
large.text().length(), millis);
|
||||
assertTrue(millis < 5000, "со второй ступенью обработка заняла " + millis + " мс");
|
||||
long unmaskStarted = System.nanoTime();
|
||||
String restored = pipeline.process(masked, "large-mixed-1", SystemPolicy.DEFAULT);
|
||||
long unmaskMillis = (System.nanoTime() - unmaskStarted) / 1_000_000;
|
||||
|
||||
assertEquals(large.text(), restored, "демаскирование не восстановило исходный текст");
|
||||
assertTrue(
|
||||
maskMillis < 5000,
|
||||
"маскирование " + large.text().length() + " знаков заняло " + maskMillis + " мс");
|
||||
assertTrue(unmaskMillis < 1000, "демаскирование заняло " + unmaskMillis + " мс");
|
||||
|
||||
System.out.printf(
|
||||
"%nБольшой текст: %d знаков, маскирование %d мс, демаскирование %d мс%n",
|
||||
large.text().length(), maskMillis, unmaskMillis);
|
||||
}
|
||||
|
||||
/**
|
||||
* Полнота детекции не должна проседать на объёме: каждый золотой фрагмент из перемешанных строк
|
||||
* обязан быть найден в общем потоке текста, а не только когда он единственный в маленькой строке.
|
||||
*/
|
||||
@Test
|
||||
void recallHoldsAtScale() {
|
||||
BenchmarkFixtures.Sample large = buildLargeText(TARGET_CHARS, 2);
|
||||
Pipeline pipeline = new Pipeline(new RuleRegistry(), new Masker(), new PayloadStore(1L, 30));
|
||||
|
||||
List<Span> found = pipeline.findPersonalData(large.text(), SystemPolicy.DEFAULT);
|
||||
int hit = 0;
|
||||
for (Span gold : large.gold()) {
|
||||
if (found.stream().anyMatch(f -> f.type().equals(gold.type()) && f.overlaps(gold))) {
|
||||
hit++;
|
||||
}
|
||||
}
|
||||
double recall = large.gold().isEmpty() ? 1.0 : (double) hit / large.gold().size();
|
||||
System.out.printf(
|
||||
"%nПолнота на большом тексте: %d из %d (%.3f)%n", hit, large.gold().size(), recall);
|
||||
|
||||
assertTrue(
|
||||
recall >= 0.85,
|
||||
String.format(
|
||||
"полнота на большом тексте упала до %.3f (%d/%d)", recall, hit, large.gold().size()));
|
||||
}
|
||||
|
||||
/**
|
||||
* Вторая ступень ограничена числом кандидатов на запрос ({@code pdguard.ner.max-candidates}),
|
||||
* поэтому объём текста не должен превращать её в квадратичную нагрузку — проверяем на том же
|
||||
* большом тексте, что и остальные тесты, а не на маленьком образце.
|
||||
*/
|
||||
@Test
|
||||
void nameCascadeStaysBoundedOnLargeText() {
|
||||
Path model = Path.of(MODEL_PATH);
|
||||
if (!Files.isReadable(model)) {
|
||||
System.out.println("Модель " + model.toAbsolutePath() + " не собрана, пропускаю");
|
||||
return;
|
||||
}
|
||||
BenchmarkFixtures.Sample large = buildLargeText(TARGET_CHARS, 3);
|
||||
Pipeline pipeline =
|
||||
new Pipeline(
|
||||
new RuleRegistry(),
|
||||
new Masker(),
|
||||
new PayloadStore(large.text().length() * 2L, 30),
|
||||
new NameCascade(ENGINE, Optional.of(MODEL_PATH), 16, 4));
|
||||
|
||||
long started = System.nanoTime();
|
||||
pipeline.process(large.text(), "large-cascade-1", SystemPolicy.DEFAULT);
|
||||
long millis = (System.nanoTime() - started) / 1_000_000;
|
||||
|
||||
System.out.printf(
|
||||
"%nБольшой текст со второй ступенью: %d знаков за %d мс%n", large.text().length(), millis);
|
||||
assertTrue(millis < 5000, "со второй ступенью обработка заняла " + millis + " мс");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,14 @@
|
||||
package ru.pdguard;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
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 org.junit.jupiter.api.Test;
|
||||
import ru.pdguard.config.SystemPolicy;
|
||||
import ru.pdguard.core.PayloadStore;
|
||||
@@ -9,73 +18,79 @@ import ru.pdguard.detect.RuleRegistry;
|
||||
import ru.pdguard.detect.Span;
|
||||
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.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
/**
|
||||
* Проверка утечек из датасета {@code leak-dataset.txt}.
|
||||
*
|
||||
* <p>Датасет собран из логов pd-guard-node-logs.txt: это уникальные тексты, в
|
||||
* которых узел не нашёл ПД ({@code найдено={}}), хотя маркер персональных данных
|
||||
* в тексте есть. Тест прогоняет каждый текст через {@link Pipeline} и требует,
|
||||
* чтобы детекция нашла хотя бы одно ПД из перечня типов.
|
||||
* <p>Датасет собран из логов pd-guard-node-logs.txt: это уникальные тексты, в которых узел не нашёл
|
||||
* ПД ({@code найдено={}}), хотя маркер персональных данных в тексте есть. Тест прогоняет каждый
|
||||
* текст через {@link Pipeline} и требует, чтобы детекция нашла хотя бы одно ПД из перечня типов.
|
||||
*/
|
||||
class LeakDiagTest {
|
||||
|
||||
private static final String DATASET = "/leak-dataset.txt";
|
||||
private static final String DATASET = "/leak-dataset.txt";
|
||||
|
||||
@Test
|
||||
void checkLeaks() {
|
||||
Pipeline p = new Pipeline(new RuleRegistry(), new Masker(), new PayloadStore(1_000_000L, 30));
|
||||
List<String> leaks = readDataset();
|
||||
@Test
|
||||
void checkLeaks() {
|
||||
Pipeline p = new Pipeline(new RuleRegistry(), new Masker(), new PayloadStore(1_000_000L, 30));
|
||||
List<String> leaks = readDataset();
|
||||
|
||||
int fixed = 0;
|
||||
List<String> remaining = new ArrayList<>();
|
||||
for (String raw : leaks) {
|
||||
List<Span> spans = p.findPersonalData(raw, SystemPolicy.DEFAULT);
|
||||
boolean found = spans.stream().anyMatch(s -> s.type().equals(PdTypes.FIO)
|
||||
|| s.type().equals(PdTypes.BIRTH_DATE) || s.type().equals(PdTypes.PASSPORT_DATE)
|
||||
|| s.type().equals(PdTypes.CVV) || s.type().equals(PdTypes.PIN)
|
||||
|| s.type().equals(PdTypes.INN) || s.type().equals(PdTypes.PHONE)
|
||||
|| s.type().equals(PdTypes.CARD) || s.type().equals(PdTypes.DRIVER_LICENSE)
|
||||
|| s.type().equals(PdTypes.CITIZENSHIP) || s.type().equals(PdTypes.BIRTH_PLACE));
|
||||
if (found) {
|
||||
fixed++;
|
||||
} else {
|
||||
remaining.add(raw);
|
||||
}
|
||||
}
|
||||
System.out.println("Всего утечек: " + leaks.size() + ", исправлено: " + fixed + ", осталось: " + remaining.size());
|
||||
for (String raw : remaining) {
|
||||
System.out.println(" ОСТАЛОСЬ: " + raw);
|
||||
}
|
||||
assertTrue(remaining.size() <= leaks.size() / 2, "осталось слишком много утечек: " + remaining.size());
|
||||
int fixed = 0;
|
||||
List<String> remaining = new ArrayList<>();
|
||||
for (String raw : leaks) {
|
||||
List<Span> spans = p.findPersonalData(raw, SystemPolicy.DEFAULT);
|
||||
boolean found =
|
||||
spans.stream()
|
||||
.anyMatch(
|
||||
s ->
|
||||
s.type().equals(PdTypes.FIO)
|
||||
|| s.type().equals(PdTypes.BIRTH_DATE)
|
||||
|| s.type().equals(PdTypes.PASSPORT_DATE)
|
||||
|| s.type().equals(PdTypes.CVV)
|
||||
|| s.type().equals(PdTypes.PIN)
|
||||
|| s.type().equals(PdTypes.INN)
|
||||
|| s.type().equals(PdTypes.PHONE)
|
||||
|| s.type().equals(PdTypes.CARD)
|
||||
|| s.type().equals(PdTypes.DRIVER_LICENSE)
|
||||
|| s.type().equals(PdTypes.CITIZENSHIP)
|
||||
|| s.type().equals(PdTypes.BIRTH_PLACE));
|
||||
if (found) {
|
||||
fixed++;
|
||||
} else {
|
||||
remaining.add(raw);
|
||||
}
|
||||
}
|
||||
|
||||
private static List<String> readDataset() {
|
||||
List<String> lines = new ArrayList<>();
|
||||
try (InputStream in = LeakDiagTest.class.getResourceAsStream(DATASET)) {
|
||||
if (in == null) {
|
||||
throw new IllegalStateException("Датасет не найден в сборке: " + DATASET);
|
||||
}
|
||||
try (BufferedReader r = new BufferedReader(new InputStreamReader(in, StandardCharsets.UTF_8))) {
|
||||
String line;
|
||||
while ((line = r.readLine()) != null) {
|
||||
if (!line.isBlank()) {
|
||||
lines.add(line);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (IOException e) {
|
||||
throw new IllegalStateException(e);
|
||||
}
|
||||
return lines;
|
||||
System.out.println(
|
||||
"Всего утечек: "
|
||||
+ leaks.size()
|
||||
+ ", исправлено: "
|
||||
+ fixed
|
||||
+ ", осталось: "
|
||||
+ remaining.size());
|
||||
for (String raw : remaining) {
|
||||
System.out.println(" ОСТАЛОСЬ: " + raw);
|
||||
}
|
||||
}
|
||||
assertTrue(
|
||||
remaining.size() <= leaks.size() / 2, "осталось слишком много утечек: " + remaining.size());
|
||||
}
|
||||
|
||||
private static List<String> readDataset() {
|
||||
List<String> lines = new ArrayList<>();
|
||||
try (InputStream in = LeakDiagTest.class.getResourceAsStream(DATASET)) {
|
||||
if (in == null) {
|
||||
throw new IllegalStateException("Датасет не найден в сборке: " + DATASET);
|
||||
}
|
||||
try (BufferedReader r =
|
||||
new BufferedReader(new InputStreamReader(in, StandardCharsets.UTF_8))) {
|
||||
String line;
|
||||
while ((line = r.readLine()) != null) {
|
||||
if (!line.isBlank()) {
|
||||
lines.add(line);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (IOException e) {
|
||||
throw new IllegalStateException(e);
|
||||
}
|
||||
return lines;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,13 @@
|
||||
package ru.pdguard;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
import java.util.Set;
|
||||
import java.util.UUID;
|
||||
import java.util.regex.Matcher;
|
||||
import java.util.regex.Pattern;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import ru.pdguard.config.SystemPolicy;
|
||||
import ru.pdguard.core.PayloadStore;
|
||||
@@ -9,88 +17,92 @@ import ru.pdguard.detect.Validators;
|
||||
import ru.pdguard.mask.MaskMode;
|
||||
import ru.pdguard.mask.Masker;
|
||||
|
||||
import java.util.Set;
|
||||
import java.util.UUID;
|
||||
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.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
/** Виды замены: звёздочки, токены, правдоподобные значения. */
|
||||
class MaskModeTest {
|
||||
|
||||
private final Pipeline pipeline =
|
||||
new Pipeline(new RuleRegistry(), new Masker(), new PayloadStore(1_000_000L, 30));
|
||||
private final Pipeline pipeline =
|
||||
new Pipeline(new RuleRegistry(), new Masker(), new PayloadStore(1_000_000L, 30));
|
||||
|
||||
private SystemPolicy policy(MaskMode mode) {
|
||||
return new SystemPolicy(SystemPolicy.DEFAULT_NAME, true, true, mode,
|
||||
Set.of(SystemPolicy.ALL), SystemPolicy.DEFAULT.requireCompanion(), null);
|
||||
private SystemPolicy policy(MaskMode mode) {
|
||||
return new SystemPolicy(
|
||||
SystemPolicy.DEFAULT_NAME,
|
||||
true,
|
||||
true,
|
||||
mode,
|
||||
Set.of(SystemPolicy.ALL),
|
||||
SystemPolicy.DEFAULT.requireCompanion(),
|
||||
null);
|
||||
}
|
||||
|
||||
private String mask(MaskMode mode, String text) {
|
||||
return pipeline.process(text, UUID.randomUUID().toString(), policy(mode));
|
||||
}
|
||||
|
||||
@Test
|
||||
void strictModeHidesEverythingIncludingFio() {
|
||||
String masked = mask(MaskMode.STRICT, "Клиент Иванов Иван Иванович, паспорт 4509 123456");
|
||||
|
||||
assertFalse(masked.contains("Иванов"), masked);
|
||||
assertFalse(
|
||||
masked.contains("И. И. И."), "STRICT не должен превращать ФИО в инициалы: " + masked);
|
||||
assertTrue(
|
||||
masked.contains("****** **** ********"),
|
||||
"ожидались звёздочки по длине каждого слова: " + masked);
|
||||
assertTrue(masked.contains("**** ******"), "край паспорта не должен открываться: " + masked);
|
||||
}
|
||||
|
||||
@Test
|
||||
void tokenModeNumbersEachType() {
|
||||
String masked = mask(MaskMode.TOKEN, "Клиент Иванов Иван Иванович, почта ivan@mail.ru");
|
||||
assertTrue(masked.contains("[FIO_1]"), masked);
|
||||
assertTrue(masked.contains("[EMAIL_1]"), masked);
|
||||
}
|
||||
|
||||
@Test
|
||||
void sameValueGetsSameTokenWithinRequest() {
|
||||
String masked =
|
||||
mask(MaskMode.TOKEN, "ivan@mail.ru и ещё раз ivan@mail.ru, а также petr@mail.ru");
|
||||
assertEquals(2, count(masked, "[EMAIL_1]"), masked);
|
||||
assertEquals(1, count(masked, "[EMAIL_2]"), masked);
|
||||
}
|
||||
|
||||
@Test
|
||||
void syntheticModeProducesPlausibleValues() {
|
||||
String masked = mask(MaskMode.SYNTHETIC, "Карта 4111 1111 1111 1111 клиента Иванова Ивана");
|
||||
|
||||
assertFalse(masked.contains("4111 1111 1111 1111"), masked);
|
||||
assertFalse(masked.contains("*"), "подстановка должна выглядеть настоящей: " + masked);
|
||||
|
||||
Matcher card = Pattern.compile("\\d{4} \\d{4} \\d{4} \\d{4}").matcher(masked);
|
||||
assertTrue(card.find(), masked);
|
||||
assertTrue(
|
||||
Validators.luhn(card.group()), "подставленный номер карты обязан проходить проверку Луна");
|
||||
}
|
||||
|
||||
@Test
|
||||
void syntheticValuesAreStable() {
|
||||
String text = "Почта ivan@mail.ru, паспорт 4509 123456";
|
||||
assertEquals(mask(MaskMode.SYNTHETIC, text), mask(MaskMode.SYNTHETIC, text));
|
||||
}
|
||||
|
||||
@Test
|
||||
void unmaskingWorksInEveryMode() {
|
||||
String original = "Клиент Иванов Иван Иванович, карта 4111 1111 1111 1111, почта ivan@mail.ru";
|
||||
for (MaskMode mode : MaskMode.values()) {
|
||||
String id = "mode-" + mode;
|
||||
String masked = pipeline.process(original, id, policy(mode));
|
||||
assertFalse(masked.contains("Иванов Иван Иванович"), mode + ": " + masked);
|
||||
assertEquals(original, pipeline.process(masked, id, policy(mode)), mode.name());
|
||||
}
|
||||
}
|
||||
|
||||
private String mask(MaskMode mode, String text) {
|
||||
return pipeline.process(text, UUID.randomUUID().toString(), policy(mode));
|
||||
}
|
||||
|
||||
@Test
|
||||
void strictModeHidesEverythingIncludingFio() {
|
||||
String masked = mask(MaskMode.STRICT, "Клиент Иванов Иван Иванович, паспорт 4509 123456");
|
||||
|
||||
assertFalse(masked.contains("Иванов"), masked);
|
||||
assertFalse(masked.contains("И. И. И."), "STRICT не должен превращать ФИО в инициалы: " + masked);
|
||||
assertTrue(masked.contains("****** **** ********"), "ожидались звёздочки по длине каждого слова: " + masked);
|
||||
assertTrue(masked.contains("**** ******"), "край паспорта не должен открываться: " + masked);
|
||||
}
|
||||
|
||||
@Test
|
||||
void tokenModeNumbersEachType() {
|
||||
String masked = mask(MaskMode.TOKEN, "Клиент Иванов Иван Иванович, почта ivan@mail.ru");
|
||||
assertTrue(masked.contains("[FIO_1]"), masked);
|
||||
assertTrue(masked.contains("[EMAIL_1]"), masked);
|
||||
}
|
||||
|
||||
@Test
|
||||
void sameValueGetsSameTokenWithinRequest() {
|
||||
String masked = mask(MaskMode.TOKEN, "ivan@mail.ru и ещё раз ivan@mail.ru, а также petr@mail.ru");
|
||||
assertEquals(2, count(masked, "[EMAIL_1]"), masked);
|
||||
assertEquals(1, count(masked, "[EMAIL_2]"), masked);
|
||||
}
|
||||
|
||||
@Test
|
||||
void syntheticModeProducesPlausibleValues() {
|
||||
String masked = mask(MaskMode.SYNTHETIC, "Карта 4111 1111 1111 1111 клиента Иванова Ивана");
|
||||
|
||||
assertFalse(masked.contains("4111 1111 1111 1111"), masked);
|
||||
assertFalse(masked.contains("*"), "подстановка должна выглядеть настоящей: " + masked);
|
||||
|
||||
Matcher card = Pattern.compile("\\d{4} \\d{4} \\d{4} \\d{4}").matcher(masked);
|
||||
assertTrue(card.find(), masked);
|
||||
assertTrue(Validators.luhn(card.group()), "подставленный номер карты обязан проходить проверку Луна");
|
||||
}
|
||||
|
||||
@Test
|
||||
void syntheticValuesAreStable() {
|
||||
String text = "Почта ivan@mail.ru, паспорт 4509 123456";
|
||||
assertEquals(mask(MaskMode.SYNTHETIC, text), mask(MaskMode.SYNTHETIC, text));
|
||||
}
|
||||
|
||||
@Test
|
||||
void unmaskingWorksInEveryMode() {
|
||||
String original = "Клиент Иванов Иван Иванович, карта 4111 1111 1111 1111, почта ivan@mail.ru";
|
||||
for (MaskMode mode : MaskMode.values()) {
|
||||
String id = "mode-" + mode;
|
||||
String masked = pipeline.process(original, id, policy(mode));
|
||||
assertFalse(masked.contains("Иванов Иван Иванович"), mode + ": " + masked);
|
||||
assertEquals(original, pipeline.process(masked, id, policy(mode)), mode.name());
|
||||
}
|
||||
}
|
||||
|
||||
private static int count(String text, String fragment) {
|
||||
int n = 0;
|
||||
for (int i = text.indexOf(fragment); i >= 0; i = text.indexOf(fragment, i + fragment.length())) {
|
||||
n++;
|
||||
}
|
||||
return n;
|
||||
private static int count(String text, String fragment) {
|
||||
int n = 0;
|
||||
for (int i = text.indexOf(fragment);
|
||||
i >= 0;
|
||||
i = text.indexOf(fragment, i + fragment.length())) {
|
||||
n++;
|
||||
}
|
||||
return n;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,13 @@
|
||||
package ru.pdguard;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.Optional;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
import ru.pdguard.config.SystemPolicy;
|
||||
@@ -9,51 +17,46 @@ import ru.pdguard.detect.NameCascade;
|
||||
import ru.pdguard.detect.RuleRegistry;
|
||||
import ru.pdguard.mask.Masker;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.Optional;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
|
||||
/** Вторая ступень не должна вредить первой. */
|
||||
class NameCascadeTest {
|
||||
|
||||
private static final String TEXT = "Клиент Иванов Иван Иванович, паспорт 4509 123456";
|
||||
private static final String TEXT = "Клиент Иванов Иван Иванович, паспорт 4509 123456";
|
||||
|
||||
private String mask(NameCascade cascade, String payloadId) {
|
||||
Pipeline pipeline = new Pipeline(new RuleRegistry(), new Masker(),
|
||||
new PayloadStore(1_000_000L, 30), cascade);
|
||||
return pipeline.process(TEXT, payloadId, SystemPolicy.DEFAULT);
|
||||
private String mask(NameCascade cascade, String payloadId) {
|
||||
Pipeline pipeline =
|
||||
new Pipeline(new RuleRegistry(), new Masker(), new PayloadStore(1_000_000L, 30), cascade);
|
||||
return pipeline.process(TEXT, payloadId, SystemPolicy.DEFAULT);
|
||||
}
|
||||
|
||||
@Test
|
||||
void withoutModelTheStageIsOff() {
|
||||
NameCascade cascade = NameCascade.disabled();
|
||||
assertFalse(cascade.enabled());
|
||||
assertEquals("Клиент И. И. И., паспорт 45** ****56", mask(cascade, "off-1"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void missingModelFileDoesNotBreakMasking(@TempDir Path dir) {
|
||||
NameCascade cascade =
|
||||
new NameCascade(
|
||||
"rubert", Optional.of(dir.resolve("нет-такого-каталога").toString()), 16, 4);
|
||||
assertFalse(cascade.enabled(), "отсутствующая модель должна выключать ступень");
|
||||
assertEquals("Клиент И. И. И., паспорт 45** ****56", mask(cascade, "missing-1"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void brokenModelFileDoesNotBreakMasking(@TempDir Path dir) throws IOException {
|
||||
Path broken = dir.resolve("испорченная-модель");
|
||||
Files.createDirectories(broken);
|
||||
for (String name : new String[] {"model_int8.onnx", "vocab.txt", "config.json"}) {
|
||||
Files.writeString(broken.resolve(name), "это не модель", StandardCharsets.UTF_8);
|
||||
}
|
||||
|
||||
@Test
|
||||
void withoutModelTheStageIsOff() {
|
||||
NameCascade cascade = NameCascade.disabled();
|
||||
assertFalse(cascade.enabled());
|
||||
assertEquals("Клиент И. И. И., паспорт 45** ****56", mask(cascade, "off-1"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void missingModelFileDoesNotBreakMasking(@TempDir Path dir) {
|
||||
NameCascade cascade = new NameCascade("rubert", Optional.of(dir.resolve("нет-такого-каталога").toString()), 16, 4);
|
||||
assertFalse(cascade.enabled(), "отсутствующая модель должна выключать ступень");
|
||||
assertEquals("Клиент И. И. И., паспорт 45** ****56", mask(cascade, "missing-1"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void brokenModelFileDoesNotBreakMasking(@TempDir Path dir) throws IOException {
|
||||
Path broken = dir.resolve("испорченная-модель");
|
||||
Files.createDirectories(broken);
|
||||
for (String name : new String[]{"model_int8.onnx", "vocab.txt", "config.json"}) {
|
||||
Files.writeString(broken.resolve(name), "это не модель", StandardCharsets.UTF_8);
|
||||
}
|
||||
|
||||
NameCascade cascade = new NameCascade("rubert", Optional.of(broken.toString()), 16, 4);
|
||||
assertFalse(cascade.enabled(), "испорченная модель должна выключать ступень");
|
||||
assertEquals("Клиент И. И. И., паспорт 45** ****56", mask(cascade, "broken-1"),
|
||||
"маскирование по правилам обязано работать и без второй ступени");
|
||||
}
|
||||
NameCascade cascade = new NameCascade("rubert", Optional.of(broken.toString()), 16, 4);
|
||||
assertFalse(cascade.enabled(), "испорченная модель должна выключать ступень");
|
||||
assertEquals(
|
||||
"Клиент И. И. И., паспорт 45** ****56",
|
||||
mask(cascade, "broken-1"),
|
||||
"маскирование по правилам обязано работать и без второй ступени");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
package ru.pdguard;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
|
||||
import java.util.UUID;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import ru.pdguard.config.SystemPolicy;
|
||||
import ru.pdguard.core.PayloadStore;
|
||||
@@ -7,86 +11,91 @@ 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 OrganisationNamesTest {
|
||||
|
||||
private final Pipeline pipeline =
|
||||
new Pipeline(new RuleRegistry(), new Masker(), new PayloadStore(1_000_000L, 30));
|
||||
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 String mask(String text) {
|
||||
return pipeline.process(text, UUID.randomUUID().toString(), SystemPolicy.DEFAULT);
|
||||
}
|
||||
|
||||
@Test
|
||||
void keepsNamesInsideInstitutionNames() {
|
||||
for (String text : new String[]{
|
||||
"Институт Мечникова принимает по записи",
|
||||
"Музей Верещагина работает по будням",
|
||||
"Театр Станиславского открыл сезон",
|
||||
"Библиотека Некрасова закрыта на ремонт",
|
||||
"Премия имени Ломоносова вручена в декабре",
|
||||
"Больница Боткина приняла пациентов",
|
||||
"Стадион Яшина отремонтирован"}) {
|
||||
assertEquals(text, mask(text), "имя в названии учреждения маскировать не нужно");
|
||||
}
|
||||
@Test
|
||||
void keepsNamesInsideInstitutionNames() {
|
||||
for (String text :
|
||||
new String[] {
|
||||
"Институт Мечникова принимает по записи",
|
||||
"Музей Верещагина работает по будням",
|
||||
"Театр Станиславского открыл сезон",
|
||||
"Библиотека Некрасова закрыта на ремонт",
|
||||
"Премия имени Ломоносова вручена в декабре",
|
||||
"Больница Боткина приняла пациентов",
|
||||
"Стадион Яшина отремонтирован"
|
||||
}) {
|
||||
assertEquals(text, mask(text), "имя в названии учреждения маскировать не нужно");
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void keepsNamesInsidePlaceNames() {
|
||||
for (String text : new String[]{
|
||||
"Улица Королёва названа в честь конструктора",
|
||||
"Проспект Вернадского перекрыт до вечера",
|
||||
"Площадь Гагарина находится на юго-западе",
|
||||
"Набережная Макарова уходит к заливу",
|
||||
"Мост Кадырова разведут ночью"}) {
|
||||
assertEquals(text, mask(text), "топоним маскировать не нужно");
|
||||
}
|
||||
@Test
|
||||
void keepsNamesInsidePlaceNames() {
|
||||
for (String text :
|
||||
new String[] {
|
||||
"Улица Королёва названа в честь конструктора",
|
||||
"Проспект Вернадского перекрыт до вечера",
|
||||
"Площадь Гагарина находится на юго-западе",
|
||||
"Набережная Макарова уходит к заливу",
|
||||
"Мост Кадырова разведут ночью"
|
||||
}) {
|
||||
assertEquals(text, mask(text), "топоним маскировать не нужно");
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void masksRealClientWithTheSameSurname() {
|
||||
String masked = mask("Клиент Королёв Сергей Павлович, паспорт 4509 123456");
|
||||
assertFalse(masked.contains("Королёв Сергей Павлович"),
|
||||
"однофамилец объекта на карте остаётся под защитой: " + masked);
|
||||
}
|
||||
@Test
|
||||
void masksRealClientWithTheSameSurname() {
|
||||
String masked = mask("Клиент Королёв Сергей Павлович, паспорт 4509 123456");
|
||||
assertFalse(
|
||||
masked.contains("Королёв Сергей Павлович"),
|
||||
"однофамилец объекта на карте остаётся под защитой: " + masked);
|
||||
}
|
||||
|
||||
@Test
|
||||
void markerOnlyCountsRightBeforeTheName() {
|
||||
String masked = mask("Больница приняла Иванова Ивана Ивановича с жалобой");
|
||||
assertFalse(masked.contains("Иванова Ивана Ивановича"),
|
||||
"слово-маркер действует только вплотную перед именем: " + masked);
|
||||
}
|
||||
@Test
|
||||
void markerOnlyCountsRightBeforeTheName() {
|
||||
String masked = mask("Больница приняла Иванова Ивана Ивановича с жалобой");
|
||||
assertFalse(
|
||||
masked.contains("Иванова Ивана Ивановича"),
|
||||
"слово-маркер действует только вплотную перед именем: " + masked);
|
||||
}
|
||||
|
||||
@Test
|
||||
void keepsRulerNames() {
|
||||
for (String text : new String[]{
|
||||
"Василий Тёмный правил недолго",
|
||||
"Ярослав Мудрый составил свод законов",
|
||||
"Екатерина Вторая издала указ",
|
||||
"Алексей Тишайший принимал послов"}) {
|
||||
assertEquals(text, mask(text), "имя правителя персональными данными не является");
|
||||
}
|
||||
@Test
|
||||
void keepsRulerNames() {
|
||||
for (String text :
|
||||
new String[] {
|
||||
"Василий Тёмный правил недолго",
|
||||
"Ярослав Мудрый составил свод законов",
|
||||
"Екатерина Вторая издала указ",
|
||||
"Алексей Тишайший принимал послов"
|
||||
}) {
|
||||
assertEquals(text, mask(text), "имя правителя персональными данными не является");
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void masksClientEvenIfNameLooksRegnal() {
|
||||
String masked = mask("Клиент Василий Тёмный, паспорт 4509 123456");
|
||||
assertFalse(masked.contains("Василий Тёмный"),
|
||||
"рядом с паспортными данными это конкретный человек: " + masked);
|
||||
}
|
||||
@Test
|
||||
void masksClientEvenIfNameLooksRegnal() {
|
||||
String masked = mask("Клиент Василий Тёмный, паспорт 4509 123456");
|
||||
assertFalse(
|
||||
masked.contains("Василий Тёмный"),
|
||||
"рядом с паспортными данными это конкретный человек: " + masked);
|
||||
}
|
||||
|
||||
@Test
|
||||
void doesNotSuppressSoleTraderName() {
|
||||
String masked = mask("ИП Пахомов Вениамин Николаевич, ИНН 502601234547");
|
||||
assertFalse(masked.contains("Пахомов Вениамин Николаевич"),
|
||||
"имя предпринимателя — это персональные данные: " + masked);
|
||||
}
|
||||
@Test
|
||||
void doesNotSuppressSoleTraderName() {
|
||||
String masked = mask("ИП Пахомов Вениамин Николаевич, ИНН 502601234547");
|
||||
assertFalse(
|
||||
masked.contains("Пахомов Вениамин Николаевич"),
|
||||
"имя предпринимателя — это персональные данные: " + masked);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,50 +1,54 @@
|
||||
package ru.pdguard;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertNotEquals;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import ru.pdguard.core.PayloadCipher;
|
||||
import ru.pdguard.core.PayloadStore;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertNotEquals;
|
||||
|
||||
/** Шифрование персональных данных в хранилище. */
|
||||
class PayloadCipherTest {
|
||||
|
||||
/** 32 байта в hex — валидный AES-256 ключ. */
|
||||
private static final String KEY = "000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f";
|
||||
/** 32 байта в hex — валидный AES-256 ключ. */
|
||||
private static final String KEY =
|
||||
"000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f";
|
||||
|
||||
@Test
|
||||
void encryptDecryptRoundTrip() {
|
||||
PayloadCipher cipher = new PayloadCipher(KEY);
|
||||
String original = "Клиент Иванов Иван Иванович, паспорт 4509 123456";
|
||||
String encrypted = cipher.encrypt(original);
|
||||
assertNotEquals(original, encrypted, "шифротекст не должен совпадать с исходником");
|
||||
assertEquals(original, cipher.decrypt(encrypted), "должно расшифроваться обратно");
|
||||
}
|
||||
@Test
|
||||
void encryptDecryptRoundTrip() {
|
||||
PayloadCipher cipher = new PayloadCipher(KEY);
|
||||
String original = "Клиент Иванов Иван Иванович, паспорт 4509 123456";
|
||||
String encrypted = cipher.encrypt(original);
|
||||
assertNotEquals(original, encrypted, "шифротекст не должен совпадать с исходником");
|
||||
assertEquals(original, cipher.decrypt(encrypted), "должно расшифроваться обратно");
|
||||
}
|
||||
|
||||
@Test
|
||||
void disabledCipherPassesThrough() {
|
||||
PayloadCipher cipher = PayloadCipher.disabled();
|
||||
String original = "Клиент Иванов";
|
||||
assertEquals(original, cipher.encrypt(original), "без ключа шифрование выключено");
|
||||
assertEquals(original, cipher.decrypt(original), "без ключа дешифрование выключено");
|
||||
}
|
||||
@Test
|
||||
void disabledCipherPassesThrough() {
|
||||
PayloadCipher cipher = PayloadCipher.disabled();
|
||||
String original = "Клиент Иванов";
|
||||
assertEquals(original, cipher.encrypt(original), "без ключа шифрование выключено");
|
||||
assertEquals(original, cipher.decrypt(original), "без ключа дешифрование выключено");
|
||||
}
|
||||
|
||||
@Test
|
||||
void storeStoresEncryptedButReturnsPlaintext() {
|
||||
PayloadCipher cipher = new PayloadCipher(KEY);
|
||||
PayloadStore store = new PayloadStore(1_000_000L, 30, ru.pdguard.core.SharedIndex.disabled(), cipher);
|
||||
@Test
|
||||
void storeStoresEncryptedButReturnsPlaintext() {
|
||||
PayloadCipher cipher = new PayloadCipher(KEY);
|
||||
PayloadStore store =
|
||||
new PayloadStore(1_000_000L, 30, ru.pdguard.core.SharedIndex.disabled(), cipher);
|
||||
|
||||
String original = "Клиент Иванов Иван Иванович, паспорт 4509 123456";
|
||||
String masked = "Клиент И. И. И., паспорт 45** ****56";
|
||||
store.put("test", "id-1", original, masked);
|
||||
String original = "Клиент Иванов Иван Иванович, паспорт 4509 123456";
|
||||
String masked = "Клиент И. И. И., паспорт 45** ****56";
|
||||
store.put("test", "id-1", original, masked);
|
||||
|
||||
// Чтение по id возвращает исходный текст.
|
||||
PayloadStore.Entry entry = store.byId("test", "id-1");
|
||||
assertEquals(original, entry.original(), "чтение по id должно вернуть исходный текст");
|
||||
// Чтение по id возвращает исходный текст.
|
||||
PayloadStore.Entry entry = store.byId("test", "id-1");
|
||||
assertEquals(original, entry.original(), "чтение по id должно вернуть исходный текст");
|
||||
|
||||
// Чтение по маске возвращает исходный текст.
|
||||
assertEquals(original, store.originalForMask("test", masked),
|
||||
"чтение по маске должно вернуть исходный текст");
|
||||
}
|
||||
}
|
||||
// Чтение по маске возвращает исходный текст.
|
||||
assertEquals(
|
||||
original,
|
||||
store.originalForMask("test", masked),
|
||||
"чтение по маске должно вернуть исходный текст");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,73 +1,77 @@
|
||||
package ru.pdguard;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import ru.pdguard.core.PayloadStore;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertNotNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import ru.pdguard.core.PayloadStore;
|
||||
|
||||
/** Ограничения хранилища соответствий: объём, срок жизни и разделение по системам. */
|
||||
class PayloadStoreTest {
|
||||
|
||||
private static final String SYSTEM = "crm";
|
||||
private static final String SYSTEM = "crm";
|
||||
|
||||
@Test
|
||||
void returnsWhatWasStored() {
|
||||
PayloadStore store = new PayloadStore(1_000_000L, 30);
|
||||
store.put(SYSTEM, "id", "исходный текст", "маска");
|
||||
@Test
|
||||
void returnsWhatWasStored() {
|
||||
PayloadStore store = new PayloadStore(1_000_000L, 30);
|
||||
store.put(SYSTEM, "id", "исходный текст", "маска");
|
||||
|
||||
PayloadStore.Entry entry = store.byId(SYSTEM, "id");
|
||||
assertNotNull(entry);
|
||||
assertEquals("исходный текст", entry.original());
|
||||
assertEquals("маска", entry.masked());
|
||||
assertEquals("исходный текст", store.originalForMask(SYSTEM, "маска"));
|
||||
PayloadStore.Entry entry = store.byId(SYSTEM, "id");
|
||||
assertNotNull(entry);
|
||||
assertEquals("исходный текст", entry.original());
|
||||
assertEquals("маска", entry.masked());
|
||||
assertEquals("исходный текст", store.originalForMask(SYSTEM, "маска"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void forgetsEntriesAfterTheirLifetime() {
|
||||
PayloadStore store = new PayloadStore(1_000_000L, 0);
|
||||
store.put(SYSTEM, "id", "исходный текст", "маска");
|
||||
|
||||
assertNull(store.byId(SYSTEM, "id"), "запись с истёкшим сроком жизни не должна отдаваться");
|
||||
assertNull(store.originalForMask(SYSTEM, "маска"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void evictsOldestWhenOverSizeLimit() {
|
||||
PayloadStore store = new PayloadStore(100L, 30);
|
||||
for (int i = 0; i < 50; i++) {
|
||||
store.put(SYSTEM, "id" + i, "текст номер " + i, "маска номер " + i);
|
||||
}
|
||||
|
||||
@Test
|
||||
void forgetsEntriesAfterTheirLifetime() {
|
||||
PayloadStore store = new PayloadStore(1_000_000L, 0);
|
||||
store.put(SYSTEM, "id", "исходный текст", "маска");
|
||||
assertTrue(store.charsHeld() <= 100, "объём хранилища вышел за предел: " + store.charsHeld());
|
||||
assertNull(store.byId(SYSTEM, "id0"), "самая старая запись должна быть вытеснена");
|
||||
assertNotNull(store.byId(SYSTEM, "id49"), "последняя запись должна остаться");
|
||||
}
|
||||
|
||||
assertNull(store.byId(SYSTEM, "id"), "запись с истёкшим сроком жизни не должна отдаваться");
|
||||
assertNull(store.originalForMask(SYSTEM, "маска"));
|
||||
}
|
||||
@Test
|
||||
void unknownKeysReturnNothing() {
|
||||
PayloadStore store = new PayloadStore(1_000_000L, 30);
|
||||
assertNull(store.byId(SYSTEM, "нет такого"));
|
||||
assertNull(store.originalForMask(SYSTEM, "нет такой маски"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void evictsOldestWhenOverSizeLimit() {
|
||||
PayloadStore store = new PayloadStore(100L, 30);
|
||||
for (int i = 0; i < 50; i++) {
|
||||
store.put(SYSTEM, "id" + i, "текст номер " + i, "маска номер " + i);
|
||||
}
|
||||
/**
|
||||
* Поиск по маске идёт только внутри своей системы. Маски детерминированы и низкоэнтропийны: без
|
||||
* разделения чужую маску можно было бы подобрать и обменять на исходные данные другого
|
||||
* потребителя.
|
||||
*/
|
||||
@Test
|
||||
void oneSystemCannotReadAnotherSystemData() {
|
||||
PayloadStore store = new PayloadStore(1_000_000L, 30);
|
||||
store.put("crm", "общий-id", "Иванов Иван Иванович", "И. И. И.");
|
||||
|
||||
assertTrue(store.charsHeld() <= 100, "объём хранилища вышел за предел: " + store.charsHeld());
|
||||
assertNull(store.byId(SYSTEM, "id0"), "самая старая запись должна быть вытеснена");
|
||||
assertNotNull(store.byId(SYSTEM, "id49"), "последняя запись должна остаться");
|
||||
}
|
||||
|
||||
@Test
|
||||
void unknownKeysReturnNothing() {
|
||||
PayloadStore store = new PayloadStore(1_000_000L, 30);
|
||||
assertNull(store.byId(SYSTEM, "нет такого"));
|
||||
assertNull(store.originalForMask(SYSTEM, "нет такой маски"));
|
||||
}
|
||||
|
||||
/**
|
||||
* Поиск по маске идёт только внутри своей системы. Маски детерминированы и
|
||||
* низкоэнтропийны: без разделения чужую маску можно было бы подобрать и обменять
|
||||
* на исходные данные другого потребителя.
|
||||
*/
|
||||
@Test
|
||||
void oneSystemCannotReadAnotherSystemData() {
|
||||
PayloadStore store = new PayloadStore(1_000_000L, 30);
|
||||
store.put("crm", "общий-id", "Иванов Иван Иванович", "И. И. И.");
|
||||
|
||||
assertNull(store.originalForMask("analytics", "И. И. И."),
|
||||
"чужую маску нельзя обменять на исходный текст");
|
||||
assertNull(store.byId("analytics", "общий-id"),
|
||||
"совпадение идентификатора у другой системы не даёт доступа");
|
||||
assertEquals("Иванов Иван Иванович", store.originalForMask("crm", "И. И. И."),
|
||||
"своя система свои данные по-прежнему получает");
|
||||
}
|
||||
assertNull(
|
||||
store.originalForMask("analytics", "И. И. И."),
|
||||
"чужую маску нельзя обменять на исходный текст");
|
||||
assertNull(
|
||||
store.byId("analytics", "общий-id"),
|
||||
"совпадение идентификатора у другой системы не даёт доступа");
|
||||
assertEquals(
|
||||
"Иванов Иван Иванович",
|
||||
store.originalForMask("crm", "И. И. И."),
|
||||
"своя система свои данные по-прежнему получает");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,12 @@
|
||||
package ru.pdguard;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.Comparator;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import ru.pdguard.config.SystemPolicy;
|
||||
import ru.pdguard.core.PayloadStore;
|
||||
@@ -8,142 +15,146 @@ import ru.pdguard.detect.RuleRegistry;
|
||||
import ru.pdguard.detect.Span;
|
||||
import ru.pdguard.mask.Masker;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.Comparator;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
/**
|
||||
* Оценка эффективности детекции по каждому типу ПДН в отдельности.
|
||||
*
|
||||
* <p>Набор {@code benchmark-pdn-types.txt} содержит по несколько примеров каждого
|
||||
* типа ПДН. Для каждого типа считается посимвольная точность, полнота и F1 —
|
||||
* так видно, какие типы детектор находит надёжно, а какие пропускает или
|
||||
* маскирует сверх меры.
|
||||
* <p>Набор {@code benchmark-pdn-types.txt} содержит по несколько примеров каждого типа ПДН. Для
|
||||
* каждого типа считается посимвольная точность, полнота и F1 — так видно, какие типы детектор
|
||||
* находит надёжно, а какие пропускает или маскирует сверх меры.
|
||||
*/
|
||||
class PdnTypeEfficiencyTest {
|
||||
|
||||
private final Pipeline pipeline =
|
||||
new Pipeline(new RuleRegistry(), new Masker(), new PayloadStore(10_000_000L, 30));
|
||||
private final Pipeline pipeline =
|
||||
new Pipeline(new RuleRegistry(), new Masker(), new PayloadStore(10_000_000L, 30));
|
||||
|
||||
/** Накопитель посимвольных совпадений по одному типу. */
|
||||
private static final class Score {
|
||||
private int truePositive;
|
||||
private int falsePositive;
|
||||
private int falseNegative;
|
||||
/** Накопитель посимвольных совпадений по одному типу. */
|
||||
private static final class Score {
|
||||
private int truePositive;
|
||||
private int falsePositive;
|
||||
private int falseNegative;
|
||||
|
||||
private int gold() {
|
||||
return truePositive + falseNegative;
|
||||
}
|
||||
|
||||
private double precision() {
|
||||
int found = truePositive + falsePositive;
|
||||
return found == 0 ? 1.0 : (double) truePositive / found;
|
||||
}
|
||||
|
||||
private double recall() {
|
||||
return gold() == 0 ? 1.0 : (double) truePositive / gold();
|
||||
}
|
||||
|
||||
private double f1() {
|
||||
double p = precision();
|
||||
double r = recall();
|
||||
return p + r == 0 ? 0.0 : 2 * p * r / (p + r);
|
||||
}
|
||||
private int gold() {
|
||||
return truePositive + falseNegative;
|
||||
}
|
||||
|
||||
@Test
|
||||
void efficiencyByPdnType() {
|
||||
List<BenchmarkFixtures.Sample> samples = BenchmarkFixtures.load("/benchmark-pdn-types.txt");
|
||||
Map<String, Score> byType = new LinkedHashMap<>();
|
||||
Map<String, List<String>> missed = new LinkedHashMap<>();
|
||||
|
||||
for (BenchmarkFixtures.Sample sample : samples) {
|
||||
List<Span> found = pipeline.findPersonalData(sample.text(), SystemPolicy.DEFAULT);
|
||||
String[] goldChars = paint(sample.text().length(), sample.gold());
|
||||
String[] foundChars = paint(sample.text().length(), found);
|
||||
for (int i = 0; i < sample.text().length(); i++) {
|
||||
account(byType, goldChars[i], foundChars[i]);
|
||||
}
|
||||
for (Span gold : sample.gold()) {
|
||||
boolean hit = found.stream().anyMatch(f -> f.type().equals(gold.type()) && f.overlaps(gold));
|
||||
if (!hit) {
|
||||
missed.computeIfAbsent(gold.type(), t -> new ArrayList<>())
|
||||
.add(sample.text().substring(gold.start(), gold.end()));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
report(byType);
|
||||
reportMissed(missed);
|
||||
|
||||
// Каждый тип должен быть найден с F1 не ниже 0.8 — иначе детектор
|
||||
// пропускает или перемаскирует этот тип ПДН. Companion-типы (CVV, PIN,
|
||||
// DATE) проверяются отдельно: они маскируются только рядом с другими ПД.
|
||||
for (Map.Entry<String, Score> e : byType.entrySet()) {
|
||||
if (isCompanion(e.getKey())) {
|
||||
continue;
|
||||
}
|
||||
assertTrue(e.getValue().f1() >= 0.8,
|
||||
String.format("F1 по типу %s упал до %.3f", e.getKey(), e.getValue().f1()));
|
||||
}
|
||||
private double precision() {
|
||||
int found = truePositive + falsePositive;
|
||||
return found == 0 ? 1.0 : (double) truePositive / found;
|
||||
}
|
||||
|
||||
private void reportMissed(Map<String, List<String>> missed) {
|
||||
if (missed.isEmpty()) {
|
||||
return;
|
||||
private double recall() {
|
||||
return gold() == 0 ? 1.0 : (double) truePositive / gold();
|
||||
}
|
||||
|
||||
private double f1() {
|
||||
double p = precision();
|
||||
double r = recall();
|
||||
return p + r == 0 ? 0.0 : 2 * p * r / (p + r);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void efficiencyByPdnType() {
|
||||
List<BenchmarkFixtures.Sample> samples = BenchmarkFixtures.load("/benchmark-pdn-types.txt");
|
||||
Map<String, Score> byType = new LinkedHashMap<>();
|
||||
Map<String, List<String>> missed = new LinkedHashMap<>();
|
||||
|
||||
for (BenchmarkFixtures.Sample sample : samples) {
|
||||
List<Span> found = pipeline.findPersonalData(sample.text(), SystemPolicy.DEFAULT);
|
||||
String[] goldChars = paint(sample.text().length(), sample.gold());
|
||||
String[] foundChars = paint(sample.text().length(), found);
|
||||
for (int i = 0; i < sample.text().length(); i++) {
|
||||
account(byType, goldChars[i], foundChars[i]);
|
||||
}
|
||||
for (Span gold : sample.gold()) {
|
||||
boolean hit =
|
||||
found.stream().anyMatch(f -> f.type().equals(gold.type()) && f.overlaps(gold));
|
||||
if (!hit) {
|
||||
missed
|
||||
.computeIfAbsent(gold.type(), t -> new ArrayList<>())
|
||||
.add(sample.text().substring(gold.start(), gold.end()));
|
||||
}
|
||||
StringBuilder out = new StringBuilder();
|
||||
out.append("\n=== Не распознанные значения по типам ===\n");
|
||||
missed.forEach((type, values) -> {
|
||||
out.append(type).append(": ").append(String.join(" | ", values)).append('\n');
|
||||
}
|
||||
}
|
||||
|
||||
report(byType);
|
||||
reportMissed(missed);
|
||||
|
||||
// Каждый тип должен быть найден с F1 не ниже 0.8 — иначе детектор
|
||||
// пропускает или перемаскирует этот тип ПДН. Companion-типы (CVV, PIN,
|
||||
// DATE) проверяются отдельно: они маскируются только рядом с другими ПД.
|
||||
for (Map.Entry<String, Score> e : byType.entrySet()) {
|
||||
if (isCompanion(e.getKey())) {
|
||||
continue;
|
||||
}
|
||||
assertTrue(
|
||||
e.getValue().f1() >= 0.8,
|
||||
String.format("F1 по типу %s упал до %.3f", e.getKey(), e.getValue().f1()));
|
||||
}
|
||||
}
|
||||
|
||||
private void reportMissed(Map<String, List<String>> missed) {
|
||||
if (missed.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
StringBuilder out = new StringBuilder();
|
||||
out.append("\n=== Не распознанные значения по типам ===\n");
|
||||
missed.forEach(
|
||||
(type, values) -> {
|
||||
out.append(type).append(": ").append(String.join(" | ", values)).append('\n');
|
||||
});
|
||||
System.out.println(out);
|
||||
System.out.println(out);
|
||||
}
|
||||
|
||||
private static boolean isCompanion(String type) {
|
||||
return "CVV".equals(type) || "PIN".equals(type) || "DATE".equals(type);
|
||||
}
|
||||
|
||||
/** Раскрашивает каждый знак текста типом ПД, который его покрывает. */
|
||||
private static String[] paint(int length, List<Span> spans) {
|
||||
String[] painted = new String[length];
|
||||
for (Span span : spans) {
|
||||
for (int i = span.start(); i < Math.min(span.end(), length); i++) {
|
||||
painted[i] = span.type();
|
||||
}
|
||||
}
|
||||
return painted;
|
||||
}
|
||||
|
||||
private static boolean isCompanion(String type) {
|
||||
return "CVV".equals(type) || "PIN".equals(type) || "DATE".equals(type);
|
||||
private static void account(Map<String, Score> byType, String gold, String found) {
|
||||
if (gold != null) {
|
||||
Score score = byType.computeIfAbsent(gold, t -> new Score());
|
||||
if (gold.equals(found)) {
|
||||
score.truePositive++;
|
||||
} else {
|
||||
score.falseNegative++;
|
||||
}
|
||||
}
|
||||
|
||||
/** Раскрашивает каждый знак текста типом ПД, который его покрывает. */
|
||||
private static String[] paint(int length, List<Span> spans) {
|
||||
String[] painted = new String[length];
|
||||
for (Span span : spans) {
|
||||
for (int i = span.start(); i < Math.min(span.end(), length); i++) {
|
||||
painted[i] = span.type();
|
||||
}
|
||||
}
|
||||
return painted;
|
||||
if (found != null && !found.equals(gold)) {
|
||||
byType.computeIfAbsent(found, t -> new Score()).falsePositive++;
|
||||
}
|
||||
}
|
||||
|
||||
private static void account(Map<String, Score> byType, String gold, String found) {
|
||||
if (gold != null) {
|
||||
Score score = byType.computeIfAbsent(gold, t -> new Score());
|
||||
if (gold.equals(found)) {
|
||||
score.truePositive++;
|
||||
} else {
|
||||
score.falseNegative++;
|
||||
}
|
||||
}
|
||||
if (found != null && !found.equals(gold)) {
|
||||
byType.computeIfAbsent(found, t -> new Score()).falsePositive++;
|
||||
}
|
||||
}
|
||||
private void report(Map<String, Score> byType) {
|
||||
StringBuilder out = new StringBuilder(2048);
|
||||
out.append("\n=== Эффективность детекции по типам ПДН ===\n\n");
|
||||
out.append(
|
||||
String.format("%-20s %8s %8s %8s %8s%n", "тип", "знаков", "точность", "полнота", "F1"));
|
||||
|
||||
private void report(Map<String, Score> byType) {
|
||||
StringBuilder out = new StringBuilder(2048);
|
||||
out.append("\n=== Эффективность детекции по типам ПДН ===\n\n");
|
||||
out.append(String.format("%-20s %8s %8s %8s %8s%n", "тип", "знаков", "точность", "полнота", "F1"));
|
||||
byType.entrySet().stream()
|
||||
.sorted(
|
||||
Comparator.comparingInt((Map.Entry<String, Score> e) -> e.getValue().gold()).reversed())
|
||||
.forEach(
|
||||
e ->
|
||||
out.append(
|
||||
String.format(
|
||||
"%-20s %8d %8.3f %8.3f %8.3f%n",
|
||||
e.getKey(),
|
||||
e.getValue().gold(),
|
||||
e.getValue().precision(),
|
||||
e.getValue().recall(),
|
||||
e.getValue().f1())));
|
||||
|
||||
byType.entrySet().stream()
|
||||
.sorted(Comparator.comparingInt((Map.Entry<String, Score> e) -> e.getValue().gold()).reversed())
|
||||
.forEach(e -> out.append(String.format("%-20s %8d %8.3f %8.3f %8.3f%n",
|
||||
e.getKey(), e.getValue().gold(), e.getValue().precision(),
|
||||
e.getValue().recall(), e.getValue().f1())));
|
||||
|
||||
System.out.println(out);
|
||||
}
|
||||
}
|
||||
System.out.println(out);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,15 +1,10 @@
|
||||
package ru.pdguard;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
import static org.junit.jupiter.api.Assumptions.assumeTrue;
|
||||
|
||||
import io.micrometer.core.instrument.MeterRegistry;
|
||||
import io.micrometer.core.instrument.simple.SimpleMeterRegistry;
|
||||
import ru.pdguard.config.SystemPolicy;
|
||||
import ru.pdguard.core.PayloadStore;
|
||||
import ru.pdguard.core.Pipeline;
|
||||
import ru.pdguard.detect.NameCascade;
|
||||
import ru.pdguard.detect.RuleRegistry;
|
||||
import ru.pdguard.mask.Masker;
|
||||
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.ArrayList;
|
||||
@@ -21,191 +16,214 @@ import java.util.concurrent.ExecutorService;
|
||||
import java.util.concurrent.Executors;
|
||||
import java.util.concurrent.Future;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
import static org.junit.jupiter.api.Assumptions.assumeTrue;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
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.NameCascade;
|
||||
import ru.pdguard.detect.RuleRegistry;
|
||||
import ru.pdguard.mask.Masker;
|
||||
|
||||
/**
|
||||
* Замер производительности: задержка одиночного обращения и пропускная
|
||||
* способность под нагрузкой. Не тест качества — он в {@link BenchmarkTest}.
|
||||
* Замер производительности: задержка одиночного обращения и пропускная способность под нагрузкой.
|
||||
* Не тест качества — он в {@link BenchmarkTest}.
|
||||
*
|
||||
* <p>Прогон идёт на одних правилах (вторая ступень выключена), как в боевой
|
||||
* сборке без модели. Перед замером пайплайн прогревается, чтобы JIT успел
|
||||
* скомпилировать горячий путь, — иначе первые замеры покажут интерпретируемый
|
||||
* код и занизят результат в разы.
|
||||
* <p>Прогон идёт на одних правилах (вторая ступень выключена), как в боевой сборке без модели.
|
||||
* Перед замером пайплайн прогревается, чтобы JIT успел скомпилировать горячий путь, — иначе первые
|
||||
* замеры покажут интерпретируемый код и занизят результат в разы.
|
||||
*/
|
||||
class PerformanceBenchmarkTest {
|
||||
|
||||
/** Типовой текст с ПД — как в реальном обращении. */
|
||||
private static final String[] PAYLOADS = {
|
||||
"Клиент Иванов Иван Иванович, паспорт 4509 123456, тел +7 916 123-45-67",
|
||||
"Заявление от И.И. Петрова, ИНН 770301234550, почта ivan.petrov@mail.ru",
|
||||
"Адрес: 125009, г. Москва, ул. Тверская, д. 7, кв. 15, карта 4111 1111 1111 1111",
|
||||
"Дата рождения 12.05.1985, место рождения: город Тверь, гражданство РФ",
|
||||
"Напиши краткое описание продукта для рассылки клиентам банка",
|
||||
};
|
||||
/** Типовой текст с ПД — как в реальном обращении. */
|
||||
private static final String[] PAYLOADS = {
|
||||
"Клиент Иванов Иван Иванович, паспорт 4509 123456, тел +7 916 123-45-67",
|
||||
"Заявление от И.И. Петрова, ИНН 770301234550, почта ivan.petrov@mail.ru",
|
||||
"Адрес: 125009, г. Москва, ул. Тверская, д. 7, кв. 15, карта 4111 1111 1111 1111",
|
||||
"Дата рождения 12.05.1985, место рождения: город Тверь, гражданство РФ",
|
||||
"Напиши краткое описание продукта для рассылки клиентам банка",
|
||||
};
|
||||
|
||||
/**
|
||||
* Тексты, где правила не находят ПД, но есть цепочки имён — их разбирает
|
||||
* вторая ступень (модель). Нужны, чтобы честно измерить стоимость модели,
|
||||
* а не правила, которые в типовых текстах уже всё покрыли.
|
||||
*/
|
||||
private static final String[] CASCADE_PAYLOADS = {
|
||||
"Готье и Руссо пришли на встречу в офис",
|
||||
"Дюма написал роман за несколько месяцев",
|
||||
"Виктор Гюго был известным писателем",
|
||||
"Оноре де Бальзак писал романы о жизни",
|
||||
"Жан-Поль Сартр философ и писатель",
|
||||
};
|
||||
/**
|
||||
* Тексты, где правила не находят ПД, но есть цепочки имён — их разбирает вторая ступень (модель).
|
||||
* Нужны, чтобы честно измерить стоимость модели, а не правила, которые в типовых текстах уже всё
|
||||
* покрыли.
|
||||
*/
|
||||
private static final String[] CASCADE_PAYLOADS = {
|
||||
"Готье и Руссо пришли на встречу в офис",
|
||||
"Дюма написал роман за несколько месяцев",
|
||||
"Виктор Гюго был известным писателем",
|
||||
"Оноре де Бальзак писал романы о жизни",
|
||||
"Жан-Поль Сартр философ и писатель",
|
||||
};
|
||||
|
||||
private static final int WARMUP = 20_000;
|
||||
private static final int MEASURE = 50_000;
|
||||
private static final int WARMUP = 20_000;
|
||||
private static final int MEASURE = 50_000;
|
||||
|
||||
private final Pipeline pipeline =
|
||||
new Pipeline(new RuleRegistry(), new Masker(), new PayloadStore(10_000_000L, 30));
|
||||
private final Pipeline pipeline =
|
||||
new Pipeline(new RuleRegistry(), new Masker(), new PayloadStore(10_000_000L, 30));
|
||||
|
||||
private void warmup() {
|
||||
for (int i = 0; i < WARMUP; i++) {
|
||||
String text = PAYLOADS[i % PAYLOADS.length];
|
||||
String id = "warmup-" + i;
|
||||
pipeline.process(text, id, SystemPolicy.DEFAULT);
|
||||
}
|
||||
private void warmup() {
|
||||
for (int i = 0; i < WARMUP; i++) {
|
||||
String text = PAYLOADS[i % PAYLOADS.length];
|
||||
String id = "warmup-" + i;
|
||||
pipeline.process(text, id, SystemPolicy.DEFAULT);
|
||||
}
|
||||
}
|
||||
|
||||
/** Задержка маскирования и демаскирования типового обращения. */
|
||||
@Test
|
||||
void singleRequestLatency() {
|
||||
warmup();
|
||||
|
||||
long[] maskNanos = new long[MEASURE];
|
||||
long[] unmaskNanos = new long[MEASURE];
|
||||
|
||||
for (int i = 0; i < MEASURE; i++) {
|
||||
String text = PAYLOADS[i % PAYLOADS.length];
|
||||
String id = "lat-" + i;
|
||||
|
||||
long t0 = System.nanoTime();
|
||||
String masked = pipeline.process(text, id, SystemPolicy.DEFAULT);
|
||||
maskNanos[i] = System.nanoTime() - t0;
|
||||
|
||||
long t1 = System.nanoTime();
|
||||
pipeline.process(masked, id, SystemPolicy.DEFAULT);
|
||||
unmaskNanos[i] = System.nanoTime() - t1;
|
||||
}
|
||||
|
||||
/** Задержка маскирования и демаскирования типового обращения. */
|
||||
@Test
|
||||
void singleRequestLatency() {
|
||||
warmup();
|
||||
Arrays.sort(maskNanos);
|
||||
Arrays.sort(unmaskNanos);
|
||||
|
||||
long[] maskNanos = new long[MEASURE];
|
||||
long[] unmaskNanos = new long[MEASURE];
|
||||
double maskUs = nanosToMicros(maskNanos);
|
||||
double unmaskUs = nanosToMicros(unmaskNanos);
|
||||
|
||||
for (int i = 0; i < MEASURE; i++) {
|
||||
String text = PAYLOADS[i % PAYLOADS.length];
|
||||
String id = "lat-" + i;
|
||||
System.out.printf("%n=== Задержка одиночного обращения (правила, без модели) ===%n");
|
||||
System.out.printf(
|
||||
"Маскирование: p50=%.1f мкс p95=%.1f мкс p99=%.1f мкс среднее=%.1f мкс%n",
|
||||
maskNanos[MEASURE / 2] / 1000.0,
|
||||
maskNanos[(int) (MEASURE * 0.95)] / 1000.0,
|
||||
maskNanos[(int) (MEASURE * 0.99)] / 1000.0,
|
||||
maskUs);
|
||||
System.out.printf(
|
||||
"Демаскирование: p50=%.1f мкс p95=%.1f мкс p99=%.1f мкс среднее=%.1f мкс%n",
|
||||
unmaskNanos[MEASURE / 2] / 1000.0,
|
||||
unmaskNanos[(int) (MEASURE * 0.95)] / 1000.0,
|
||||
unmaskNanos[(int) (MEASURE * 0.99)] / 1000.0,
|
||||
unmaskUs);
|
||||
|
||||
long t0 = System.nanoTime();
|
||||
String masked = pipeline.process(text, id, SystemPolicy.DEFAULT);
|
||||
maskNanos[i] = System.nanoTime() - t0;
|
||||
// Целевая задержка из ТЗ — 200 мс; типовое обращение должно укладываться в миллисекунды.
|
||||
assertTrue(
|
||||
maskNanos[(int) (MEASURE * 0.99)] < 5_000_000,
|
||||
"p99 маскирования превысил 5 мс: " + maskNanos[(int) (MEASURE * 0.99)] / 1_000_000 + " мс");
|
||||
}
|
||||
|
||||
long t1 = System.nanoTime();
|
||||
pipeline.process(masked, id, SystemPolicy.DEFAULT);
|
||||
unmaskNanos[i] = System.nanoTime() - t1;
|
||||
}
|
||||
/** Пропускная способность под нагрузкой: сколько обращений в секунду выдерживает пайплайн. */
|
||||
@Test
|
||||
void throughputUnderLoad() throws Exception {
|
||||
warmup();
|
||||
|
||||
Arrays.sort(maskNanos);
|
||||
Arrays.sort(unmaskNanos);
|
||||
int threads = Math.max(4, Runtime.getRuntime().availableProcessors());
|
||||
int perThread = 10_000;
|
||||
ExecutorService pool = Executors.newFixedThreadPool(threads);
|
||||
|
||||
double maskUs = nanosToMicros(maskNanos);
|
||||
double unmaskUs = nanosToMicros(unmaskNanos);
|
||||
|
||||
System.out.printf("%n=== Задержка одиночного обращения (правила, без модели) ===%n");
|
||||
System.out.printf("Маскирование: p50=%.1f мкс p95=%.1f мкс p99=%.1f мкс среднее=%.1f мкс%n",
|
||||
maskNanos[MEASURE / 2] / 1000.0, maskNanos[(int) (MEASURE * 0.95)] / 1000.0,
|
||||
maskNanos[(int) (MEASURE * 0.99)] / 1000.0, maskUs);
|
||||
System.out.printf("Демаскирование: p50=%.1f мкс p95=%.1f мкс p99=%.1f мкс среднее=%.1f мкс%n",
|
||||
unmaskNanos[MEASURE / 2] / 1000.0, unmaskNanos[(int) (MEASURE * 0.95)] / 1000.0,
|
||||
unmaskNanos[(int) (MEASURE * 0.99)] / 1000.0, unmaskUs);
|
||||
|
||||
// Целевая задержка из ТЗ — 200 мс; типовое обращение должно укладываться в миллисекунды.
|
||||
assertTrue(maskNanos[(int) (MEASURE * 0.99)] < 5_000_000,
|
||||
"p99 маскирования превысил 5 мс: " + maskNanos[(int) (MEASURE * 0.99)] / 1_000_000 + " мс");
|
||||
long started = System.nanoTime();
|
||||
List<Future<Long>> futures = new ArrayList<>();
|
||||
for (int t = 0; t < threads; t++) {
|
||||
final int threadId = t;
|
||||
futures.add(
|
||||
pool.submit(
|
||||
(Callable<Long>)
|
||||
() -> {
|
||||
long local = 0;
|
||||
for (int i = 0; i < perThread; i++) {
|
||||
String text = PAYLOADS[(threadId * 31 + i) % PAYLOADS.length];
|
||||
String id = "load-" + threadId + "-" + i;
|
||||
long t0 = System.nanoTime();
|
||||
pipeline.process(text, id, SystemPolicy.DEFAULT);
|
||||
local += System.nanoTime() - t0;
|
||||
}
|
||||
return local;
|
||||
}));
|
||||
}
|
||||
|
||||
/** Пропускная способность под нагрузкой: сколько обращений в секунду выдерживает пайплайн. */
|
||||
@Test
|
||||
void throughputUnderLoad() throws Exception {
|
||||
warmup();
|
||||
long totalNanos = 0;
|
||||
for (Future<Long> f : futures) {
|
||||
totalNanos += f.get();
|
||||
}
|
||||
long wallNanos = System.nanoTime() - started;
|
||||
pool.shutdown();
|
||||
pool.awaitTermination(30, TimeUnit.SECONDS);
|
||||
|
||||
int threads = Math.max(4, Runtime.getRuntime().availableProcessors());
|
||||
int perThread = 10_000;
|
||||
ExecutorService pool = Executors.newFixedThreadPool(threads);
|
||||
int requests = threads * perThread;
|
||||
double rps = requests / (wallNanos / 1e9);
|
||||
double avgUs = totalNanos / (double) requests / 1000.0;
|
||||
|
||||
long started = System.nanoTime();
|
||||
List<Future<Long>> futures = new ArrayList<>();
|
||||
for (int t = 0; t < threads; t++) {
|
||||
final int threadId = t;
|
||||
futures.add(pool.submit((Callable<Long>) () -> {
|
||||
long local = 0;
|
||||
for (int i = 0; i < perThread; i++) {
|
||||
String text = PAYLOADS[(threadId * 31 + i) % PAYLOADS.length];
|
||||
String id = "load-" + threadId + "-" + i;
|
||||
long t0 = System.nanoTime();
|
||||
pipeline.process(text, id, SystemPolicy.DEFAULT);
|
||||
local += System.nanoTime() - t0;
|
||||
}
|
||||
return local;
|
||||
}));
|
||||
}
|
||||
System.out.printf(
|
||||
"%n=== Пропускная способность (%d потоков, %d обращений) ===%n", threads, requests);
|
||||
System.out.printf("RPS: %.0f обращений/с средняя задержка: %.1f мкс%n", rps, avgUs);
|
||||
|
||||
long totalNanos = 0;
|
||||
for (Future<Long> f : futures) {
|
||||
totalNanos += f.get();
|
||||
}
|
||||
long wallNanos = System.nanoTime() - started;
|
||||
pool.shutdown();
|
||||
pool.awaitTermination(30, TimeUnit.SECONDS);
|
||||
assertTrue(rps > 1000, "пропускная способность ниже 1000 RPS: " + rps);
|
||||
}
|
||||
|
||||
int requests = threads * perThread;
|
||||
double rps = requests / (wallNanos / 1e9);
|
||||
double avgUs = totalNanos / (double) requests / 1000.0;
|
||||
private static double nanosToMicros(long[] nanos) {
|
||||
long sum = 0;
|
||||
for (long n : nanos) {
|
||||
sum += n;
|
||||
}
|
||||
return sum / (double) nanos.length / 1000.0;
|
||||
}
|
||||
|
||||
System.out.printf("%n=== Пропускная способность (%d потоков, %d обращений) ===%n", threads, requests);
|
||||
System.out.printf("RPS: %.0f обращений/с средняя задержка: %.1f мкс%n", rps, avgUs);
|
||||
/** Задержка со второй ступенью (ruBERT). Модель должна быть собрана. */
|
||||
@Test
|
||||
void singleRequestLatencyWithNameCascade() {
|
||||
Path model = Path.of("models/rubert-ner");
|
||||
assumeTrue(Files.isReadable(model), "модель " + model.toAbsolutePath() + " не собрана");
|
||||
|
||||
assertTrue(rps > 1000, "пропускная способность ниже 1000 RPS: " + rps);
|
||||
MeterRegistry meters = new SimpleMeterRegistry();
|
||||
Pipeline withCascade =
|
||||
new Pipeline(
|
||||
new RuleRegistry(),
|
||||
new Masker(),
|
||||
new PayloadStore(10_000_000L, 30),
|
||||
new NameCascade(
|
||||
"rubert", Optional.of(model.toString()), "off", Optional.empty(), 16, 4, meters));
|
||||
|
||||
// Прогрев второй ступени: модель инициализируется лениво, первые вызовы медленные.
|
||||
for (int i = 0; i < 200; i++) {
|
||||
String text = CASCADE_PAYLOADS[i % CASCADE_PAYLOADS.length];
|
||||
withCascade.process(text, "cascade-warmup-" + i, SystemPolicy.DEFAULT);
|
||||
}
|
||||
|
||||
private static double nanosToMicros(long[] nanos) {
|
||||
long sum = 0;
|
||||
for (long n : nanos) {
|
||||
sum += n;
|
||||
}
|
||||
return sum / (double) nanos.length / 1000.0;
|
||||
long[] maskNanos = new long[2000];
|
||||
for (int i = 0; i < 2000; i++) {
|
||||
String text = CASCADE_PAYLOADS[i % CASCADE_PAYLOADS.length];
|
||||
String id = "cascade-lat-" + i;
|
||||
long t0 = System.nanoTime();
|
||||
withCascade.process(text, id, SystemPolicy.DEFAULT);
|
||||
maskNanos[i] = System.nanoTime() - t0;
|
||||
}
|
||||
|
||||
/** Задержка со второй ступенью (ruBERT). Модель должна быть собрана. */
|
||||
@Test
|
||||
void singleRequestLatencyWithNameCascade() {
|
||||
Path model = Path.of("models/rubert-ner");
|
||||
assumeTrue(Files.isReadable(model), "модель " + model.toAbsolutePath() + " не собрана");
|
||||
Arrays.sort(maskNanos);
|
||||
int n = maskNanos.length;
|
||||
System.out.printf("%n=== Задержка одиночного обращения со второй ступенью (ruBERT) ===%n");
|
||||
System.out.printf(
|
||||
"Маскирование: p50=%.1f мкс p95=%.1f мкс p99=%.1f мкс среднее=%.1f мкс%n",
|
||||
maskNanos[n / 2] / 1000.0,
|
||||
maskNanos[(int) (n * 0.95)] / 1000.0,
|
||||
maskNanos[(int) (n * 0.99)] / 1000.0,
|
||||
nanosToMicros(maskNanos));
|
||||
|
||||
MeterRegistry meters = new SimpleMeterRegistry();
|
||||
Pipeline withCascade = new Pipeline(new RuleRegistry(), new Masker(),
|
||||
new PayloadStore(10_000_000L, 30),
|
||||
new NameCascade("rubert", Optional.of(model.toString()), "off", Optional.empty(), 16, 4, meters));
|
||||
double engaged = meters.counter("pdguard.ner.requests", "outcome", "engaged").count();
|
||||
double candidates = meters.counter("pdguard.ner.candidates").count();
|
||||
System.out.printf(
|
||||
"Обращений к модели: %.0f, кандидатов разобрано: %.0f%n", engaged, candidates);
|
||||
|
||||
// Прогрев второй ступени: модель инициализируется лениво, первые вызовы медленные.
|
||||
for (int i = 0; i < 200; i++) {
|
||||
String text = CASCADE_PAYLOADS[i % CASCADE_PAYLOADS.length];
|
||||
withCascade.process(text, "cascade-warmup-" + i, SystemPolicy.DEFAULT);
|
||||
}
|
||||
|
||||
long[] maskNanos = new long[2000];
|
||||
for (int i = 0; i < 2000; i++) {
|
||||
String text = CASCADE_PAYLOADS[i % CASCADE_PAYLOADS.length];
|
||||
String id = "cascade-lat-" + i;
|
||||
long t0 = System.nanoTime();
|
||||
withCascade.process(text, id, SystemPolicy.DEFAULT);
|
||||
maskNanos[i] = System.nanoTime() - t0;
|
||||
}
|
||||
|
||||
Arrays.sort(maskNanos);
|
||||
int n = maskNanos.length;
|
||||
System.out.printf("%n=== Задержка одиночного обращения со второй ступенью (ruBERT) ===%n");
|
||||
System.out.printf("Маскирование: p50=%.1f мкс p95=%.1f мкс p99=%.1f мкс среднее=%.1f мкс%n",
|
||||
maskNanos[n / 2] / 1000.0, maskNanos[(int) (n * 0.95)] / 1000.0,
|
||||
maskNanos[(int) (n * 0.99)] / 1000.0, nanosToMicros(maskNanos));
|
||||
|
||||
double engaged = meters.counter("pdguard.ner.requests", "outcome", "engaged").count();
|
||||
double candidates = meters.counter("pdguard.ner.candidates").count();
|
||||
System.out.printf("Обращений к модели: %.0f, кандидатов разобрано: %.0f%n", engaged, candidates);
|
||||
|
||||
// Целевая задержка из ТЗ — 200 мс; даже со второй ступенью типовое обращение
|
||||
// должно укладываться в десятки миллисекунд.
|
||||
assertTrue(maskNanos[(int) (n * 0.99)] < 200_000_000,
|
||||
"p99 маскирования со второй ступенью превысил 200 мс: "
|
||||
+ maskNanos[(int) (n * 0.99)] / 1_000_000 + " мс");
|
||||
}
|
||||
}
|
||||
// Целевая задержка из ТЗ — 200 мс; даже со второй ступенью типовое обращение
|
||||
// должно укладываться в десятки миллисекунд.
|
||||
assertTrue(
|
||||
maskNanos[(int) (n * 0.99)] < 200_000_000,
|
||||
"p99 маскирования со второй ступенью превысил 200 мс: "
|
||||
+ maskNanos[(int) (n * 0.99)] / 1_000_000
|
||||
+ " мс");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
package ru.pdguard;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertNotEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
import java.util.UUID;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import ru.pdguard.config.SystemPolicy;
|
||||
import ru.pdguard.core.PayloadStore;
|
||||
@@ -8,132 +14,127 @@ import ru.pdguard.detect.PdTypes;
|
||||
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;
|
||||
import static org.junit.jupiter.api.Assertions.assertNotEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
/** Проверки маскирования и обратного преобразования без подъёма HTTP-слоя. */
|
||||
class PipelineTest {
|
||||
|
||||
private static final String VALID_CARD = "4111 1111 1111 1111";
|
||||
private static final String VALID_INN_12 = "770301234550";
|
||||
private static final String VALID_SNILS = "112-233-445 95";
|
||||
private static final String VALID_CARD = "4111 1111 1111 1111";
|
||||
private static final String VALID_INN_12 = "770301234550";
|
||||
private static final String VALID_SNILS = "112-233-445 95";
|
||||
|
||||
private Pipeline pipeline() {
|
||||
return new Pipeline(new RuleRegistry(), new Masker(), new PayloadStore(1_000_000L, 30));
|
||||
private Pipeline pipeline() {
|
||||
return new Pipeline(new RuleRegistry(), new Masker(), new PayloadStore(1_000_000L, 30));
|
||||
}
|
||||
|
||||
private String mask(Pipeline pipeline, String text) {
|
||||
return pipeline.process(text, UUID.randomUUID().toString(), SystemPolicy.DEFAULT);
|
||||
}
|
||||
|
||||
@Test
|
||||
void masksCardNumber() {
|
||||
String masked = mask(pipeline(), "Оплата картой " + VALID_CARD + " прошла");
|
||||
assertFalse(masked.contains(VALID_CARD), "номер карты остался в тексте: " + masked);
|
||||
assertTrue(masked.contains("41** **** **** **11"), masked);
|
||||
assertTrue(masked.startsWith("Оплата картой "), "окружающий текст изменён: " + masked);
|
||||
}
|
||||
|
||||
@Test
|
||||
void keepsNumberThatFailsLuhn() {
|
||||
String text = "Заказ 1234 5678 9012 3456 отгружен";
|
||||
assertEquals(text, mask(pipeline(), text));
|
||||
}
|
||||
|
||||
@Test
|
||||
void masksEmailKeepingTopLevelDomain() {
|
||||
String masked = mask(pipeline(), "Почта ivan.petrov@mail.ru для связи");
|
||||
assertEquals("Почта i**********@m***.ru для связи", masked);
|
||||
}
|
||||
|
||||
@Test
|
||||
void masksPhoneInAnyNotation() {
|
||||
Pipeline pipeline = pipeline();
|
||||
for (String phone : new String[] {"+7 (916) 123-45-67", "89161234567", "8 916 123 45 67"}) {
|
||||
String masked = mask(pipeline, "Телефон " + phone);
|
||||
assertFalse(masked.contains(phone), "телефон остался в тексте: " + masked);
|
||||
assertTrue(masked.endsWith("67"), masked);
|
||||
}
|
||||
}
|
||||
|
||||
private String mask(Pipeline pipeline, String text) {
|
||||
return pipeline.process(text, UUID.randomUUID().toString(), SystemPolicy.DEFAULT);
|
||||
}
|
||||
@Test
|
||||
void masksInnByContextAndByChecksum() {
|
||||
Pipeline pipeline = pipeline();
|
||||
assertFalse(mask(pipeline, "ИНН: " + VALID_INN_12).contains(VALID_INN_12));
|
||||
assertFalse(mask(pipeline, "Реквизиты " + VALID_INN_12 + " проверены").contains(VALID_INN_12));
|
||||
}
|
||||
|
||||
@Test
|
||||
void masksCardNumber() {
|
||||
String masked = mask(pipeline(), "Оплата картой " + VALID_CARD + " прошла");
|
||||
assertFalse(masked.contains(VALID_CARD), "номер карты остался в тексте: " + masked);
|
||||
assertTrue(masked.contains("41** **** **** **11"), masked);
|
||||
assertTrue(masked.startsWith("Оплата картой "), "окружающий текст изменён: " + masked);
|
||||
}
|
||||
@Test
|
||||
void masksSnils() {
|
||||
String masked = mask(pipeline(), "СНИЛС " + VALID_SNILS);
|
||||
assertFalse(masked.contains(VALID_SNILS), masked);
|
||||
}
|
||||
|
||||
@Test
|
||||
void keepsNumberThatFailsLuhn() {
|
||||
String text = "Заказ 1234 5678 9012 3456 отгружен";
|
||||
assertEquals(text, mask(pipeline(), text));
|
||||
}
|
||||
@Test
|
||||
void unmaskingRestoresOriginalText() {
|
||||
Pipeline pipeline = pipeline();
|
||||
String original = "Карта " + VALID_CARD + ", почта ivan@mail.ru, телефон +7 916 123-45-67";
|
||||
String id = "pair-1";
|
||||
|
||||
@Test
|
||||
void masksEmailKeepingTopLevelDomain() {
|
||||
String masked = mask(pipeline(), "Почта ivan.petrov@mail.ru для связи");
|
||||
assertEquals("Почта i**********@m***.ru для связи", masked);
|
||||
}
|
||||
String masked = pipeline.process(original, id, SystemPolicy.DEFAULT);
|
||||
assertNotEquals(original, masked);
|
||||
|
||||
@Test
|
||||
void masksPhoneInAnyNotation() {
|
||||
Pipeline pipeline = pipeline();
|
||||
for (String phone : new String[]{"+7 (916) 123-45-67", "89161234567", "8 916 123 45 67"}) {
|
||||
String masked = mask(pipeline, "Телефон " + phone);
|
||||
assertFalse(masked.contains(phone), "телефон остался в тексте: " + masked);
|
||||
assertTrue(masked.endsWith("67"), masked);
|
||||
}
|
||||
}
|
||||
String restored = pipeline.process(masked, id, SystemPolicy.DEFAULT);
|
||||
assertEquals(original, restored);
|
||||
}
|
||||
|
||||
@Test
|
||||
void masksInnByContextAndByChecksum() {
|
||||
Pipeline pipeline = pipeline();
|
||||
assertFalse(mask(pipeline, "ИНН: " + VALID_INN_12).contains(VALID_INN_12));
|
||||
assertFalse(mask(pipeline, "Реквизиты " + VALID_INN_12 + " проверены").contains(VALID_INN_12));
|
||||
}
|
||||
@Test
|
||||
void retryReturnsSameMask() {
|
||||
Pipeline pipeline = pipeline();
|
||||
String original = "Карта " + VALID_CARD;
|
||||
String id = "retry-1";
|
||||
|
||||
@Test
|
||||
void masksSnils() {
|
||||
String masked = mask(pipeline(), "СНИЛС " + VALID_SNILS);
|
||||
assertFalse(masked.contains(VALID_SNILS), masked);
|
||||
}
|
||||
String first = pipeline.process(original, id, SystemPolicy.DEFAULT);
|
||||
String second = pipeline.process(original, id, SystemPolicy.DEFAULT);
|
||||
assertEquals(first, second);
|
||||
}
|
||||
|
||||
@Test
|
||||
void unmaskingRestoresOriginalText() {
|
||||
Pipeline pipeline = pipeline();
|
||||
String original = "Карта " + VALID_CARD + ", почта ivan@mail.ru, телефон +7 916 123-45-67";
|
||||
String id = "pair-1";
|
||||
@Test
|
||||
void unmasksWhenPayloadIdIsUnknown() {
|
||||
Pipeline pipeline = pipeline();
|
||||
String original = "Почта ivan@mail.ru";
|
||||
String masked = pipeline.process(original, "lost-id", SystemPolicy.DEFAULT);
|
||||
|
||||
String masked = pipeline.process(original, id, SystemPolicy.DEFAULT);
|
||||
assertNotEquals(original, masked);
|
||||
assertEquals(original, pipeline.process(masked, "другой-идентификатор", SystemPolicy.DEFAULT));
|
||||
}
|
||||
|
||||
String restored = pipeline.process(masked, id, SystemPolicy.DEFAULT);
|
||||
assertEquals(original, restored);
|
||||
}
|
||||
@Test
|
||||
void textWithoutPersonalDataIsUnchanged() {
|
||||
String text = "Расскажи о погоде в Москве завтра";
|
||||
assertEquals(text, mask(pipeline(), text));
|
||||
}
|
||||
|
||||
@Test
|
||||
void retryReturnsSameMask() {
|
||||
Pipeline pipeline = pipeline();
|
||||
String original = "Карта " + VALID_CARD;
|
||||
String id = "retry-1";
|
||||
@Test
|
||||
void systemPolicyDisablesSelectedTypes() {
|
||||
Pipeline pipeline = pipeline();
|
||||
SystemPolicy onlyEmail = SystemPolicy.forTypes(PdTypes.EMAIL);
|
||||
String masked =
|
||||
pipeline.process("Карта " + VALID_CARD + ", почта ivan@mail.ru", "policy-1", onlyEmail);
|
||||
|
||||
String first = pipeline.process(original, id, SystemPolicy.DEFAULT);
|
||||
String second = pipeline.process(original, id, SystemPolicy.DEFAULT);
|
||||
assertEquals(first, second);
|
||||
}
|
||||
assertTrue(
|
||||
masked.contains(VALID_CARD), "карта не должна маскироваться этой системой: " + masked);
|
||||
assertFalse(masked.contains("ivan@mail.ru"), masked);
|
||||
}
|
||||
|
||||
@Test
|
||||
void unmasksWhenPayloadIdIsUnknown() {
|
||||
Pipeline pipeline = pipeline();
|
||||
String original = "Почта ivan@mail.ru";
|
||||
String masked = pipeline.process(original, "lost-id", SystemPolicy.DEFAULT);
|
||||
@Test
|
||||
void handlesLargeText() {
|
||||
Pipeline pipeline = pipeline();
|
||||
String block = "Клиент написал с адреса ivan@mail.ru и оплатил картой " + VALID_CARD + ". ";
|
||||
String large = block.repeat(4000);
|
||||
|
||||
assertEquals(original, pipeline.process(masked, "другой-идентификатор", SystemPolicy.DEFAULT));
|
||||
}
|
||||
long started = System.nanoTime();
|
||||
String masked = pipeline.process(large, "large-1", SystemPolicy.DEFAULT);
|
||||
long millis = (System.nanoTime() - started) / 1_000_000;
|
||||
|
||||
@Test
|
||||
void textWithoutPersonalDataIsUnchanged() {
|
||||
String text = "Расскажи о погоде в Москве завтра";
|
||||
assertEquals(text, mask(pipeline(), text));
|
||||
}
|
||||
|
||||
@Test
|
||||
void systemPolicyDisablesSelectedTypes() {
|
||||
Pipeline pipeline = pipeline();
|
||||
SystemPolicy onlyEmail = SystemPolicy.forTypes(PdTypes.EMAIL);
|
||||
String masked = pipeline.process("Карта " + VALID_CARD + ", почта ivan@mail.ru", "policy-1", onlyEmail);
|
||||
|
||||
assertTrue(masked.contains(VALID_CARD), "карта не должна маскироваться этой системой: " + masked);
|
||||
assertFalse(masked.contains("ivan@mail.ru"), masked);
|
||||
}
|
||||
|
||||
@Test
|
||||
void handlesLargeText() {
|
||||
Pipeline pipeline = pipeline();
|
||||
String block = "Клиент написал с адреса ivan@mail.ru и оплатил картой " + VALID_CARD + ". ";
|
||||
String large = block.repeat(4000);
|
||||
|
||||
long started = System.nanoTime();
|
||||
String masked = pipeline.process(large, "large-1", SystemPolicy.DEFAULT);
|
||||
long millis = (System.nanoTime() - started) / 1_000_000;
|
||||
|
||||
assertFalse(masked.contains("ivan@mail.ru"));
|
||||
assertEquals(large, pipeline.process(masked, "large-1", SystemPolicy.DEFAULT));
|
||||
assertTrue(millis < 1000, "обработка крупного текста заняла " + millis + " мс");
|
||||
}
|
||||
assertFalse(masked.contains("ivan@mail.ru"));
|
||||
assertEquals(large, pipeline.process(masked, "large-1", SystemPolicy.DEFAULT));
|
||||
assertTrue(millis < 1000, "обработка крупного текста заняла " + millis + " мс");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,15 @@
|
||||
package ru.pdguard;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
import static org.junit.jupiter.api.DynamicTest.dynamicTest;
|
||||
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.stream.Stream;
|
||||
import org.junit.jupiter.api.DynamicTest;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.TestFactory;
|
||||
@@ -11,111 +21,109 @@ import ru.pdguard.detect.RuleRegistry;
|
||||
import ru.pdguard.detect.Span;
|
||||
import ru.pdguard.mask.Masker;
|
||||
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.stream.Stream;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
import static org.junit.jupiter.api.DynamicTest.dynamicTest;
|
||||
|
||||
/**
|
||||
* Датасет из 44 сгенерированных строк — не новые ТИПЫ ПДН, а новые РАЗМЕЩЕНИЯ уже
|
||||
* известных типов относительно якорного слова: расстояние до якоря, обратный порядок
|
||||
* (значение перед якорем), падеж/число анкера, структурированные форматы
|
||||
* (JSON/CSV/markdown-таблица/XML/key=value), несколько значений одного типа в одном
|
||||
* поле, значение в кавычках/скобках. Гипотезы построены на 55 утечках из
|
||||
* {@link NodeLogsDatasetTest} и обобщают их корневые причины на другие типы и формы.
|
||||
* Датасет из 44 сгенерированных строк — не новые ТИПЫ ПДН, а новые РАЗМЕЩЕНИЯ уже известных типов
|
||||
* относительно якорного слова: расстояние до якоря, обратный порядок (значение перед якорем),
|
||||
* падеж/число анкера, структурированные форматы (JSON/CSV/markdown-таблица/XML/key=value),
|
||||
* несколько значений одного типа в одном поле, значение в кавычках/скобках. Гипотезы построены на
|
||||
* 55 утечках из {@link NodeLogsDatasetTest} и обобщают их корневые причины на другие типы и формы.
|
||||
* Разбор по категориям — в отчёте, приложенном к задаче.
|
||||
*
|
||||
* <p>Как и {@link NodeLogsDatasetTest}, часть строк — подтверждённые утечки
|
||||
* (падение конкретного кейса в {@link #leakSummary()} ожидаемо), часть — контрольные
|
||||
* позитивные случаи, которые обязаны оставаться зелёными: если один из них упадёт,
|
||||
* значит новое размещение сломало то, что раньше работало.
|
||||
* <p>Как и {@link NodeLogsDatasetTest}, часть строк — подтверждённые утечки (падение конкретного
|
||||
* кейса в {@link #leakSummary()} ожидаемо), часть — контрольные позитивные случаи, которые обязаны
|
||||
* оставаться зелёными: если один из них упадёт, значит новое размещение сломало то, что раньше
|
||||
* работало.
|
||||
*/
|
||||
class PlacementVariantsTest {
|
||||
|
||||
private static final RuleRegistry REGISTRY = new RuleRegistry();
|
||||
private static final Masker MASKER = new Masker();
|
||||
private static final List<BenchmarkFixtures.Sample> DATASET = BenchmarkFixtures.load("/dataset-placements.txt");
|
||||
private static final int LEAK_CHECK_MIN_LENGTH = 3;
|
||||
private static final RuleRegistry REGISTRY = new RuleRegistry();
|
||||
private static final Masker MASKER = new Masker();
|
||||
private static final List<BenchmarkFixtures.Sample> DATASET =
|
||||
BenchmarkFixtures.load("/dataset-placements.txt");
|
||||
private static final int LEAK_CHECK_MIN_LENGTH = 3;
|
||||
|
||||
private static final NameCascade CASCADE = modelsPresent()
|
||||
? new NameCascade("wikineural", Optional.of("models/wikineural-ner"),
|
||||
"rubert", Optional.of("models/rubert-ner"), 16, 4)
|
||||
: NameCascade.disabled();
|
||||
private static final NameCascade CASCADE =
|
||||
modelsPresent()
|
||||
? new NameCascade(
|
||||
"wikineural",
|
||||
Optional.of("models/wikineural-ner"),
|
||||
"rubert",
|
||||
Optional.of("models/rubert-ner"),
|
||||
16,
|
||||
4)
|
||||
: NameCascade.disabled();
|
||||
|
||||
private static boolean modelsPresent() {
|
||||
return Files.isReadable(Path.of("models/wikineural-ner/model.onnx"))
|
||||
&& Files.isReadable(Path.of("models/rubert-ner/model.onnx"));
|
||||
private static boolean modelsPresent() {
|
||||
return Files.isReadable(Path.of("models/wikineural-ner/model.onnx"))
|
||||
&& Files.isReadable(Path.of("models/rubert-ner/model.onnx"));
|
||||
}
|
||||
|
||||
@TestFactory
|
||||
Stream<DynamicTest> placementDataset() {
|
||||
List<DynamicTest> cases = new ArrayList<>(DATASET.size());
|
||||
for (int i = 0; i < DATASET.size(); i++) {
|
||||
BenchmarkFixtures.Sample sample = DATASET.get(i);
|
||||
int index = i;
|
||||
cases.add(
|
||||
dynamicTest(
|
||||
String.format("#%02d: %s", index, preview(sample.text())),
|
||||
() -> runRoundTrip(sample, index)));
|
||||
}
|
||||
return cases.stream();
|
||||
}
|
||||
|
||||
@TestFactory
|
||||
Stream<DynamicTest> placementDataset() {
|
||||
List<DynamicTest> cases = new ArrayList<>(DATASET.size());
|
||||
for (int i = 0; i < DATASET.size(); i++) {
|
||||
BenchmarkFixtures.Sample sample = DATASET.get(i);
|
||||
int index = i;
|
||||
cases.add(dynamicTest(
|
||||
String.format("#%02d: %s", index, preview(sample.text())),
|
||||
() -> runRoundTrip(sample, index)));
|
||||
/** Демаскирование обязано восстановить исходный текст всегда, независимо от утечек. */
|
||||
private void runRoundTrip(BenchmarkFixtures.Sample sample, int index) {
|
||||
Pipeline pipeline = new Pipeline(REGISTRY, MASKER, new PayloadStore(1_000_000L, 30), CASCADE);
|
||||
String payloadId = "placement-" + index;
|
||||
|
||||
String masked = pipeline.process(sample.text(), payloadId, SystemPolicy.DEFAULT);
|
||||
String restored = pipeline.process(masked, payloadId, SystemPolicy.DEFAULT);
|
||||
assertEquals(sample.text(), restored, "демаскирование не восстановило исходный текст");
|
||||
}
|
||||
|
||||
/**
|
||||
* Сводка утечек одним прогоном — печатает список по типам и падает, только если утечек стало
|
||||
* больше 11, то есть если что-то из уже маскируемого сегодня размещения перестало маскироваться.
|
||||
* Было 20 при составлении датасета, после точечных фиксов RuleRegistry (расширенный разрыв
|
||||
* якорь-значение, обратный порядок для дат/гражданства/кода подразделения) осталось 11.
|
||||
*/
|
||||
@Test
|
||||
void leakSummary() {
|
||||
Pipeline pipeline = new Pipeline(REGISTRY, MASKER, new PayloadStore(10_000_000L, 30), CASCADE);
|
||||
int leaked = 0;
|
||||
int checked = 0;
|
||||
java.util.Map<String, Integer> byType = new java.util.LinkedHashMap<>();
|
||||
StringBuilder report = new StringBuilder("\n=== Утечки по dataset-placements.txt ===\n");
|
||||
|
||||
for (int i = 0; i < DATASET.size(); i++) {
|
||||
BenchmarkFixtures.Sample sample = DATASET.get(i);
|
||||
String masked = pipeline.process(sample.text(), "leak-scan-" + i, SystemPolicy.DEFAULT);
|
||||
for (Span gold : sample.gold()) {
|
||||
String value = sample.text().substring(gold.start(), gold.end());
|
||||
if (value.length() < LEAK_CHECK_MIN_LENGTH) {
|
||||
continue;
|
||||
}
|
||||
return cases.stream();
|
||||
}
|
||||
|
||||
/** Демаскирование обязано восстановить исходный текст всегда, независимо от утечек. */
|
||||
private void runRoundTrip(BenchmarkFixtures.Sample sample, int index) {
|
||||
Pipeline pipeline = new Pipeline(REGISTRY, MASKER, new PayloadStore(1_000_000L, 30), CASCADE);
|
||||
String payloadId = "placement-" + index;
|
||||
|
||||
String masked = pipeline.process(sample.text(), payloadId, SystemPolicy.DEFAULT);
|
||||
String restored = pipeline.process(masked, payloadId, SystemPolicy.DEFAULT);
|
||||
assertEquals(sample.text(), restored, "демаскирование не восстановило исходный текст");
|
||||
}
|
||||
|
||||
/**
|
||||
* Сводка утечек одним прогоном — печатает список по типам и падает, только если
|
||||
* утечек стало больше 11, то есть если что-то из уже маскируемого сегодня
|
||||
* размещения перестало маскироваться. Было 20 при составлении датасета, после
|
||||
* точечных фиксов RuleRegistry (расширенный разрыв якорь-значение, обратный
|
||||
* порядок для дат/гражданства/кода подразделения) осталось 11.
|
||||
*/
|
||||
@Test
|
||||
void leakSummary() {
|
||||
Pipeline pipeline = new Pipeline(REGISTRY, MASKER, new PayloadStore(10_000_000L, 30), CASCADE);
|
||||
int leaked = 0;
|
||||
int checked = 0;
|
||||
java.util.Map<String, Integer> byType = new java.util.LinkedHashMap<>();
|
||||
StringBuilder report = new StringBuilder("\n=== Утечки по dataset-placements.txt ===\n");
|
||||
|
||||
for (int i = 0; i < DATASET.size(); i++) {
|
||||
BenchmarkFixtures.Sample sample = DATASET.get(i);
|
||||
String masked = pipeline.process(sample.text(), "leak-scan-" + i, SystemPolicy.DEFAULT);
|
||||
for (Span gold : sample.gold()) {
|
||||
String value = sample.text().substring(gold.start(), gold.end());
|
||||
if (value.length() < LEAK_CHECK_MIN_LENGTH) {
|
||||
continue;
|
||||
}
|
||||
checked++;
|
||||
if (masked.contains(value)) {
|
||||
leaked++;
|
||||
byType.merge(gold.type(), 1, Integer::sum);
|
||||
report.append(String.format(" [%s] %s%n", gold.type(), value));
|
||||
}
|
||||
}
|
||||
checked++;
|
||||
if (masked.contains(value)) {
|
||||
leaked++;
|
||||
byType.merge(gold.type(), 1, Integer::sum);
|
||||
report.append(String.format(" [%s] %s%n", gold.type(), value));
|
||||
}
|
||||
report.append(String.format("%nВсего: %d утечек из %d эталонных фрагментов%n", leaked, checked));
|
||||
byType.forEach((type, count) -> report.append(String.format(" %-16s %d%n", type, count)));
|
||||
System.out.println(report);
|
||||
|
||||
assertTrue(leaked <= 9,
|
||||
"утечек стало больше 9 (было после точечных фиксов RuleRegistry) — новая регрессия: " + leaked);
|
||||
}
|
||||
}
|
||||
report.append(
|
||||
String.format("%nВсего: %d утечек из %d эталонных фрагментов%n", leaked, checked));
|
||||
byType.forEach((type, count) -> report.append(String.format(" %-16s %d%n", type, count)));
|
||||
System.out.println(report);
|
||||
|
||||
private static String preview(String text) {
|
||||
return text.length() <= 50 ? text : text.substring(0, 50) + "...";
|
||||
}
|
||||
assertTrue(
|
||||
leaked <= 9,
|
||||
"утечек стало больше 9 (было после точечных фиксов RuleRegistry) — новая регрессия: "
|
||||
+ leaked);
|
||||
}
|
||||
|
||||
private static String preview(String text) {
|
||||
return text.length() <= 50 ? text : text.substring(0, 50) + "...";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,214 +1,294 @@
|
||||
package ru.pdguard;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.boot.test.context.SpringBootTest;
|
||||
import org.springframework.boot.test.web.server.LocalServerPort;
|
||||
|
||||
import java.util.Map;
|
||||
|
||||
import static io.restassured.RestAssured.given;
|
||||
import static org.hamcrest.Matchers.equalTo;
|
||||
import static org.hamcrest.Matchers.not;
|
||||
|
||||
import java.util.Map;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.boot.test.context.SpringBootTest;
|
||||
import org.springframework.boot.test.web.server.LocalServerPort;
|
||||
|
||||
/** Проверка контракта: форма запроса и ответа, пара «маскирование — демаскирование». */
|
||||
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
|
||||
class ProcessResourceTest {
|
||||
|
||||
@LocalServerPort
|
||||
int port;
|
||||
@LocalServerPort int port;
|
||||
|
||||
private String post(String payload, String payloadId) {
|
||||
return given()
|
||||
.port(port)
|
||||
.contentType("application/json")
|
||||
.body(Map.of("payload", payload, "payload_id", payloadId))
|
||||
.when().post("/process")
|
||||
.then().statusCode(200)
|
||||
.extract().path("result");
|
||||
}
|
||||
private String post(String payload, String payloadId) {
|
||||
return given()
|
||||
.port(port)
|
||||
.contentType("application/json")
|
||||
.body(Map.of("payload", payload, "payload_id", payloadId))
|
||||
.when()
|
||||
.post("/process")
|
||||
.then()
|
||||
.statusCode(200)
|
||||
.extract()
|
||||
.path("result");
|
||||
}
|
||||
|
||||
@Test
|
||||
void maskAndUnmaskPair() {
|
||||
String original = "Клиент оставил почту ivan.petrov@mail.ru и телефон +7 916 123-45-67";
|
||||
String id = "8a77d363c7c044b49b41d7b8a448243a";
|
||||
@Test
|
||||
void maskAndUnmaskPair() {
|
||||
String original = "Клиент оставил почту ivan.petrov@mail.ru и телефон +7 916 123-45-67";
|
||||
String id = "8a77d363c7c044b49b41d7b8a448243a";
|
||||
|
||||
String masked = post(original, id);
|
||||
org.junit.jupiter.api.Assertions.assertNotEquals(original, masked);
|
||||
org.junit.jupiter.api.Assertions.assertEquals(original, post(masked, id));
|
||||
}
|
||||
String masked = post(original, id);
|
||||
org.junit.jupiter.api.Assertions.assertNotEquals(original, masked);
|
||||
org.junit.jupiter.api.Assertions.assertEquals(original, post(masked, id));
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsRequestWithoutRequiredFields() {
|
||||
@Test
|
||||
void rejectsRequestWithoutRequiredFields() {
|
||||
given()
|
||||
.port(port)
|
||||
.contentType("application/json")
|
||||
.body(Map.of("payload", "текст"))
|
||||
.when()
|
||||
.post("/process")
|
||||
.then()
|
||||
.statusCode(400);
|
||||
}
|
||||
|
||||
@Test
|
||||
void healthProbeResponds() {
|
||||
given().port(port).when().get("/health").then().statusCode(200).body(equalTo("OK"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void disabledSystemIsRefused() {
|
||||
given()
|
||||
.port(port)
|
||||
.contentType("application/json")
|
||||
.header("X-System-Id", "disabled")
|
||||
.body(Map.of("payload", "Карта 4111 1111 1111 1111", "payload_id", "sys-1"))
|
||||
.when()
|
||||
.post("/process")
|
||||
.then()
|
||||
.statusCode(403);
|
||||
}
|
||||
|
||||
@Test
|
||||
void systemPolicySelectsMaskMode() {
|
||||
String masked =
|
||||
given()
|
||||
.port(port)
|
||||
.contentType("application/json")
|
||||
.body(Map.of("payload", "текст"))
|
||||
.when().post("/process")
|
||||
.then().statusCode(400);
|
||||
}
|
||||
.port(port)
|
||||
.contentType("application/json")
|
||||
.header("X-System-Id", "crm")
|
||||
.body(Map.of("payload", "Клиент Иванов Иван Иванович", "payload_id", "sys-2"))
|
||||
.when()
|
||||
.post("/process")
|
||||
.then()
|
||||
.statusCode(200)
|
||||
.extract()
|
||||
.path("result");
|
||||
|
||||
@Test
|
||||
void healthProbeResponds() {
|
||||
given().port(port).when().get("/health").then().statusCode(200).body(equalTo("OK"));
|
||||
}
|
||||
org.junit.jupiter.api.Assertions.assertTrue(masked.contains("[FIO_1]"), masked);
|
||||
}
|
||||
|
||||
@Test
|
||||
void disabledSystemIsRefused() {
|
||||
@Test
|
||||
void unknownSystemFallsBackToDefaultPolicy() {
|
||||
given()
|
||||
.port(port)
|
||||
.contentType("application/json")
|
||||
.header("X-System-Id", "неизвестная-система")
|
||||
.body(Map.of("payload", "почта ivan@mail.ru", "payload_id", "sys-3"))
|
||||
.when()
|
||||
.post("/process")
|
||||
.then()
|
||||
.statusCode(200)
|
||||
.body("result", equalTo("почта i***@m***.ru"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void metricsExposeLatencyAndTokenCounters() {
|
||||
post("Клиент Иванов Иван Иванович", "metrics-1");
|
||||
|
||||
String body =
|
||||
given()
|
||||
.port(port)
|
||||
.contentType("application/json")
|
||||
.header("X-System-Id", "disabled")
|
||||
.body(Map.of("payload", "Карта 4111 1111 1111 1111", "payload_id", "sys-1"))
|
||||
.when().post("/process")
|
||||
.then().statusCode(403);
|
||||
}
|
||||
.port(port)
|
||||
.when()
|
||||
.get("/actuator/prometheus")
|
||||
.then()
|
||||
.statusCode(200)
|
||||
.extract()
|
||||
.asString();
|
||||
org.junit.jupiter.api.Assertions.assertTrue(
|
||||
body.contains("pdguard_process_seconds"), "нет метрики задержки");
|
||||
org.junit.jupiter.api.Assertions.assertTrue(
|
||||
body.contains("pdguard_tokens_processed_total"), "нет метрики TPS");
|
||||
org.junit.jupiter.api.Assertions.assertTrue(
|
||||
body.contains("pdguard_pd_detected_total"), "нет метрики типов ПД");
|
||||
}
|
||||
|
||||
@Test
|
||||
void systemPolicySelectsMaskMode() {
|
||||
String masked = given()
|
||||
.port(port)
|
||||
.contentType("application/json")
|
||||
.header("X-System-Id", "crm")
|
||||
.body(Map.of("payload", "Клиент Иванов Иван Иванович", "payload_id", "sys-2"))
|
||||
.when().post("/process")
|
||||
.then().statusCode(200)
|
||||
.extract().path("result");
|
||||
/**
|
||||
* Соответствия разделены по системам. Маски детерминированы и низкоэнтропийны, поэтому без
|
||||
* разделения, прислав чужую маску, можно было бы получить исходные данные другого потребителя.
|
||||
*/
|
||||
@Test
|
||||
void anotherSystemCannotExchangeMaskForOriginal() {
|
||||
String original = "Клиент Иванов Иван Иванович, паспорт 4509 123456";
|
||||
String id = "cross-system-1";
|
||||
|
||||
org.junit.jupiter.api.Assertions.assertTrue(masked.contains("[FIO_1]"), masked);
|
||||
}
|
||||
|
||||
@Test
|
||||
void unknownSystemFallsBackToDefaultPolicy() {
|
||||
String masked =
|
||||
given()
|
||||
.port(port)
|
||||
.contentType("application/json")
|
||||
.header("X-System-Id", "неизвестная-система")
|
||||
.body(Map.of("payload", "почта ivan@mail.ru", "payload_id", "sys-3"))
|
||||
.when().post("/process")
|
||||
.then().statusCode(200)
|
||||
.body("result", equalTo("почта i***@m***.ru"));
|
||||
}
|
||||
.port(port)
|
||||
.contentType("application/json")
|
||||
.body(Map.of("payload", original, "payload_id", id))
|
||||
.when()
|
||||
.post("/process")
|
||||
.then()
|
||||
.statusCode(200)
|
||||
.extract()
|
||||
.path("result");
|
||||
org.junit.jupiter.api.Assertions.assertNotEquals(original, masked);
|
||||
|
||||
@Test
|
||||
void metricsExposeLatencyAndTokenCounters() {
|
||||
post("Клиент Иванов Иван Иванович", "metrics-1");
|
||||
|
||||
String body = given().port(port).when().get("/actuator/prometheus").then().statusCode(200).extract().asString();
|
||||
org.junit.jupiter.api.Assertions.assertTrue(body.contains("pdguard_process_seconds"), "нет метрики задержки");
|
||||
org.junit.jupiter.api.Assertions.assertTrue(body.contains("pdguard_tokens_processed_total"), "нет метрики TPS");
|
||||
org.junit.jupiter.api.Assertions.assertTrue(body.contains("pdguard_pd_detected_total"), "нет метрики типов ПД");
|
||||
}
|
||||
|
||||
/**
|
||||
* Соответствия разделены по системам. Маски детерминированы и низкоэнтропийны,
|
||||
* поэтому без разделения, прислав чужую маску, можно было бы получить исходные
|
||||
* данные другого потребителя.
|
||||
*/
|
||||
@Test
|
||||
void anotherSystemCannotExchangeMaskForOriginal() {
|
||||
String original = "Клиент Иванов Иван Иванович, паспорт 4509 123456";
|
||||
String id = "cross-system-1";
|
||||
|
||||
String masked = given()
|
||||
.port(port)
|
||||
.contentType("application/json")
|
||||
.body(Map.of("payload", original, "payload_id", id))
|
||||
.when().post("/process")
|
||||
.then().statusCode(200)
|
||||
.extract().path("result");
|
||||
org.junit.jupiter.api.Assertions.assertNotEquals(original, masked);
|
||||
|
||||
String byOther = given()
|
||||
.port(port)
|
||||
.contentType("application/json")
|
||||
.header("X-System-Id", "other")
|
||||
.body(Map.of("payload", masked, "payload_id", "совсем-другой-id"))
|
||||
.when().post("/process")
|
||||
.then().statusCode(200)
|
||||
.extract().path("result");
|
||||
org.junit.jupiter.api.Assertions.assertNotEquals(original, byOther,
|
||||
"чужая система не должна получать исходный текст по маске");
|
||||
|
||||
String bySameSystem = given()
|
||||
.port(port)
|
||||
.contentType("application/json")
|
||||
.body(Map.of("payload", masked, "payload_id", id))
|
||||
.when().post("/process")
|
||||
.then().statusCode(200)
|
||||
.extract().path("result");
|
||||
org.junit.jupiter.api.Assertions.assertEquals(original, bySameSystem,
|
||||
"своя система по своему идентификатору исходный текст получает");
|
||||
}
|
||||
|
||||
@Test
|
||||
void systemWithKeyRequiresIt() {
|
||||
given().port(port).contentType("application/json")
|
||||
.header("X-System-Id", "guarded")
|
||||
.body(Map.of("payload", "Карта 4111 1111 1111 1111", "payload_id", "key-1"))
|
||||
.when().post("/process").then().statusCode(403);
|
||||
|
||||
given().port(port).contentType("application/json")
|
||||
.header("X-System-Id", "guarded")
|
||||
.header("X-System-Key", "wrong-key")
|
||||
.body(Map.of("payload", "Карта 4111 1111 1111 1111", "payload_id", "key-2"))
|
||||
.when().post("/process").then().statusCode(403);
|
||||
|
||||
given().port(port).contentType("application/json")
|
||||
.header("X-System-Id", "guarded")
|
||||
.header("X-System-Key", "s3cret-key-2026")
|
||||
.body(Map.of("payload", "Карта 4111 1111 1111 1111", "payload_id", "key-3"))
|
||||
.when().post("/process").then().statusCode(200);
|
||||
}
|
||||
|
||||
@Test
|
||||
void systemWithoutKeyWorksWithoutIt() {
|
||||
given().port(port).contentType("application/json")
|
||||
.body(Map.of("payload", "Карта 4111 1111 1111 1111", "payload_id", "key-4"))
|
||||
.when().post("/process").then().statusCode(200);
|
||||
}
|
||||
|
||||
@Test
|
||||
void metricsCountSecondStageInvocations() {
|
||||
post("Клиент Иванов Иван Иванович", "ner-metrics-1");
|
||||
|
||||
String body = given().port(port).when().get("/actuator/prometheus").then().statusCode(200).extract().asString();
|
||||
org.junit.jupiter.api.Assertions.assertTrue(body.contains("pdguard_ner_requests_total"),
|
||||
"нет счётчика обращений ко второй ступени");
|
||||
org.junit.jupiter.api.Assertions.assertTrue(body.contains("pdguard_ner_candidates_total"),
|
||||
"нет счётчика участков, отданных модели");
|
||||
}
|
||||
|
||||
@Test
|
||||
void metricsDoNotLeakPersonalData() {
|
||||
post("Клиент Иванов Иван Иванович, карта 4111 1111 1111 1111", "metrics-2");
|
||||
|
||||
String body = given().port(port).when().get("/actuator/prometheus").then().statusCode(200).extract().asString();
|
||||
// Значения метрик — это числа, и цифры из ПД могут случайно совпасть с ними.
|
||||
// Утечка возможна только через имена и метки, поэтому значения отбрасываем.
|
||||
String namesAndLabels = body.lines()
|
||||
.filter(line -> !line.startsWith("#"))
|
||||
.map(line -> line.contains(" ") ? line.substring(0, line.lastIndexOf(' ')) : line)
|
||||
.reduce("", (a, b) -> a + "\n" + b);
|
||||
|
||||
org.junit.jupiter.api.Assertions.assertFalse(namesAndLabels.contains("Иванов"), "ПД попали в метрики");
|
||||
org.junit.jupiter.api.Assertions.assertFalse(namesAndLabels.contains("4111"), "ПД попали в метрики");
|
||||
}
|
||||
|
||||
@Test
|
||||
void adminShowsSystemsAndTypes() {
|
||||
given().port(port).when().get("/admin/config").then().statusCode(200).body("crm.maskMode", equalTo("TOKEN"));
|
||||
given().port(port).when().get("/admin/types").then().statusCode(200);
|
||||
}
|
||||
|
||||
@Test
|
||||
void textWithoutPersonalDataIsReturnedAsIs() {
|
||||
String byOther =
|
||||
given()
|
||||
.port(port)
|
||||
.contentType("application/json")
|
||||
.body(Map.of("payload", "тестовая строка", "payload_id", "selfcheck-1"))
|
||||
.when().post("/process")
|
||||
.then().statusCode(200)
|
||||
.body("result", equalTo("тестовая строка"))
|
||||
.body("result", not(equalTo("")));
|
||||
}
|
||||
}
|
||||
.port(port)
|
||||
.contentType("application/json")
|
||||
.header("X-System-Id", "other")
|
||||
.body(Map.of("payload", masked, "payload_id", "совсем-другой-id"))
|
||||
.when()
|
||||
.post("/process")
|
||||
.then()
|
||||
.statusCode(200)
|
||||
.extract()
|
||||
.path("result");
|
||||
org.junit.jupiter.api.Assertions.assertNotEquals(
|
||||
original, byOther, "чужая система не должна получать исходный текст по маске");
|
||||
|
||||
String bySameSystem =
|
||||
given()
|
||||
.port(port)
|
||||
.contentType("application/json")
|
||||
.body(Map.of("payload", masked, "payload_id", id))
|
||||
.when()
|
||||
.post("/process")
|
||||
.then()
|
||||
.statusCode(200)
|
||||
.extract()
|
||||
.path("result");
|
||||
org.junit.jupiter.api.Assertions.assertEquals(
|
||||
original, bySameSystem, "своя система по своему идентификатору исходный текст получает");
|
||||
}
|
||||
|
||||
@Test
|
||||
void systemWithKeyRequiresIt() {
|
||||
given()
|
||||
.port(port)
|
||||
.contentType("application/json")
|
||||
.header("X-System-Id", "guarded")
|
||||
.body(Map.of("payload", "Карта 4111 1111 1111 1111", "payload_id", "key-1"))
|
||||
.when()
|
||||
.post("/process")
|
||||
.then()
|
||||
.statusCode(403);
|
||||
|
||||
given()
|
||||
.port(port)
|
||||
.contentType("application/json")
|
||||
.header("X-System-Id", "guarded")
|
||||
.header("X-System-Key", "wrong-key")
|
||||
.body(Map.of("payload", "Карта 4111 1111 1111 1111", "payload_id", "key-2"))
|
||||
.when()
|
||||
.post("/process")
|
||||
.then()
|
||||
.statusCode(403);
|
||||
|
||||
given()
|
||||
.port(port)
|
||||
.contentType("application/json")
|
||||
.header("X-System-Id", "guarded")
|
||||
.header("X-System-Key", "s3cret-key-2026")
|
||||
.body(Map.of("payload", "Карта 4111 1111 1111 1111", "payload_id", "key-3"))
|
||||
.when()
|
||||
.post("/process")
|
||||
.then()
|
||||
.statusCode(200);
|
||||
}
|
||||
|
||||
@Test
|
||||
void systemWithoutKeyWorksWithoutIt() {
|
||||
given()
|
||||
.port(port)
|
||||
.contentType("application/json")
|
||||
.body(Map.of("payload", "Карта 4111 1111 1111 1111", "payload_id", "key-4"))
|
||||
.when()
|
||||
.post("/process")
|
||||
.then()
|
||||
.statusCode(200);
|
||||
}
|
||||
|
||||
@Test
|
||||
void metricsCountSecondStageInvocations() {
|
||||
post("Клиент Иванов Иван Иванович", "ner-metrics-1");
|
||||
|
||||
String body =
|
||||
given()
|
||||
.port(port)
|
||||
.when()
|
||||
.get("/actuator/prometheus")
|
||||
.then()
|
||||
.statusCode(200)
|
||||
.extract()
|
||||
.asString();
|
||||
org.junit.jupiter.api.Assertions.assertTrue(
|
||||
body.contains("pdguard_ner_requests_total"), "нет счётчика обращений ко второй ступени");
|
||||
org.junit.jupiter.api.Assertions.assertTrue(
|
||||
body.contains("pdguard_ner_candidates_total"), "нет счётчика участков, отданных модели");
|
||||
}
|
||||
|
||||
@Test
|
||||
void metricsDoNotLeakPersonalData() {
|
||||
post("Клиент Иванов Иван Иванович, карта 4111 1111 1111 1111", "metrics-2");
|
||||
|
||||
String body =
|
||||
given()
|
||||
.port(port)
|
||||
.when()
|
||||
.get("/actuator/prometheus")
|
||||
.then()
|
||||
.statusCode(200)
|
||||
.extract()
|
||||
.asString();
|
||||
// Значения метрик — это числа, и цифры из ПД могут случайно совпасть с ними.
|
||||
// Утечка возможна только через имена и метки, поэтому значения отбрасываем.
|
||||
String namesAndLabels =
|
||||
body.lines()
|
||||
.filter(line -> !line.startsWith("#"))
|
||||
.map(line -> line.contains(" ") ? line.substring(0, line.lastIndexOf(' ')) : line)
|
||||
.reduce("", (a, b) -> a + "\n" + b);
|
||||
|
||||
org.junit.jupiter.api.Assertions.assertFalse(
|
||||
namesAndLabels.contains("Иванов"), "ПД попали в метрики");
|
||||
org.junit.jupiter.api.Assertions.assertFalse(
|
||||
namesAndLabels.contains("4111"), "ПД попали в метрики");
|
||||
}
|
||||
|
||||
@Test
|
||||
void adminShowsSystemsAndTypes() {
|
||||
given()
|
||||
.port(port)
|
||||
.when()
|
||||
.get("/admin/config")
|
||||
.then()
|
||||
.statusCode(200)
|
||||
.body("crm.maskMode", equalTo("TOKEN"));
|
||||
given().port(port).when().get("/admin/types").then().statusCode(200);
|
||||
}
|
||||
|
||||
@Test
|
||||
void textWithoutPersonalDataIsReturnedAsIs() {
|
||||
given()
|
||||
.port(port)
|
||||
.contentType("application/json")
|
||||
.body(Map.of("payload", "тестовая строка", "payload_id", "selfcheck-1"))
|
||||
.when()
|
||||
.post("/process")
|
||||
.then()
|
||||
.statusCode(200)
|
||||
.body("result", equalTo("тестовая строка"))
|
||||
.body("result", not(equalTo("")));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,88 +1,102 @@
|
||||
package ru.pdguard;
|
||||
|
||||
import io.restassured.path.json.JsonPath;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.boot.test.context.SpringBootTest;
|
||||
import org.springframework.boot.test.web.server.LocalServerPort;
|
||||
|
||||
import java.util.Map;
|
||||
|
||||
import static io.restassured.RestAssured.given;
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
import io.restassured.path.json.JsonPath;
|
||||
import java.util.Map;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.boot.test.context.SpringBootTest;
|
||||
import org.springframework.boot.test.web.server.LocalServerPort;
|
||||
|
||||
/** Демонстрационное плечо: потребитель → маскирование → LLM → демаскирование. */
|
||||
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
|
||||
class ProxyResourceTest {
|
||||
|
||||
@LocalServerPort
|
||||
int port;
|
||||
@LocalServerPort int port;
|
||||
|
||||
private static final String PROMPT =
|
||||
"Составь письмо клиенту Иванову Ивану Ивановичу, паспорт 4509 123456, почта ivan@mail.ru";
|
||||
private static final String PROMPT =
|
||||
"Составь письмо клиенту Иванову Ивану Ивановичу, паспорт 4509 123456, почта ivan@mail.ru";
|
||||
|
||||
private JsonPath proxy(String prompt) {
|
||||
return given()
|
||||
.port(port)
|
||||
.contentType("application/json")
|
||||
.body(Map.of("prompt", prompt))
|
||||
.when().post("/proxy")
|
||||
.then().statusCode(200)
|
||||
.extract().jsonPath();
|
||||
}
|
||||
private JsonPath proxy(String prompt) {
|
||||
return given()
|
||||
.port(port)
|
||||
.contentType("application/json")
|
||||
.body(Map.of("prompt", prompt))
|
||||
.when()
|
||||
.post("/proxy")
|
||||
.then()
|
||||
.statusCode(200)
|
||||
.extract()
|
||||
.jsonPath();
|
||||
}
|
||||
|
||||
@Test
|
||||
void personalDataDoesNotReachTheModel() {
|
||||
JsonPath json = proxy(PROMPT);
|
||||
String toModel = json.getString("prompt_masked");
|
||||
@Test
|
||||
void personalDataDoesNotReachTheModel() {
|
||||
JsonPath json = proxy(PROMPT);
|
||||
String toModel = json.getString("prompt_masked");
|
||||
|
||||
assertFalse(toModel.contains("Иванову Ивану Ивановичу"), toModel);
|
||||
assertFalse(toModel.contains("4509 123456"), toModel);
|
||||
assertFalse(toModel.contains("ivan@mail.ru"), toModel);
|
||||
}
|
||||
assertFalse(toModel.contains("Иванову Ивану Ивановичу"), toModel);
|
||||
assertFalse(toModel.contains("4509 123456"), toModel);
|
||||
assertFalse(toModel.contains("ivan@mail.ru"), toModel);
|
||||
}
|
||||
|
||||
@Test
|
||||
void consumerGetsTheAnswerWithOriginalValues() {
|
||||
JsonPath json = proxy(PROMPT);
|
||||
String answer = json.getString("response");
|
||||
@Test
|
||||
void consumerGetsTheAnswerWithOriginalValues() {
|
||||
JsonPath json = proxy(PROMPT);
|
||||
String answer = json.getString("response");
|
||||
|
||||
assertTrue(answer.contains("Иванову Ивану Ивановичу"), answer);
|
||||
assertTrue(answer.contains("4509 123456"), answer);
|
||||
assertTrue(answer.contains("ivan@mail.ru"), answer);
|
||||
}
|
||||
assertTrue(answer.contains("Иванову Ивану Ивановичу"), answer);
|
||||
assertTrue(answer.contains("4509 123456"), answer);
|
||||
assertTrue(answer.contains("ivan@mail.ru"), answer);
|
||||
}
|
||||
|
||||
@Test
|
||||
void responseShowsTheWholeChain() {
|
||||
JsonPath json = proxy(PROMPT);
|
||||
@Test
|
||||
void responseShowsTheWholeChain() {
|
||||
JsonPath json = proxy(PROMPT);
|
||||
|
||||
assertTrue(json.getString("prompt_masked").contains("[FIO_1]"),
|
||||
"в модель уходит обратимая подстановка: " + json.getString("prompt_masked"));
|
||||
assertTrue(json.getString("llm_response_masked").contains("[FIO_1]"),
|
||||
"ответ модели ещё содержит подстановки");
|
||||
assertFalse(json.getString("response").contains("[FIO_1]"),
|
||||
"потребителю подстановки не видны");
|
||||
assertEquals("заглушка", json.getString("llm"));
|
||||
assertFalse(json.getMap("replaced").isEmpty(), "таблица замен не должна быть пустой");
|
||||
}
|
||||
assertTrue(
|
||||
json.getString("prompt_masked").contains("[FIO_1]"),
|
||||
"в модель уходит обратимая подстановка: " + json.getString("prompt_masked"));
|
||||
assertTrue(
|
||||
json.getString("llm_response_masked").contains("[FIO_1]"),
|
||||
"ответ модели ещё содержит подстановки");
|
||||
assertFalse(json.getString("response").contains("[FIO_1]"), "потребителю подстановки не видны");
|
||||
assertEquals("заглушка", json.getString("llm"));
|
||||
assertFalse(json.getMap("replaced").isEmpty(), "таблица замен не должна быть пустой");
|
||||
}
|
||||
|
||||
@Test
|
||||
void textWithoutPersonalDataPassesThrough() {
|
||||
JsonPath json = proxy("Объясни разницу между вкладом и накопительным счётом");
|
||||
assertEquals("Объясни разницу между вкладом и накопительным счётом", json.getString("prompt_masked"));
|
||||
}
|
||||
@Test
|
||||
void textWithoutPersonalDataPassesThrough() {
|
||||
JsonPath json = proxy("Объясни разницу между вкладом и накопительным счётом");
|
||||
assertEquals(
|
||||
"Объясни разницу между вкладом и накопительным счётом", json.getString("prompt_masked"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsEmptyPrompt() {
|
||||
given().port(port).contentType("application/json").body(Map.of("prompt", " "))
|
||||
.when().post("/proxy").then().statusCode(400);
|
||||
}
|
||||
@Test
|
||||
void rejectsEmptyPrompt() {
|
||||
given()
|
||||
.port(port)
|
||||
.contentType("application/json")
|
||||
.body(Map.of("prompt", " "))
|
||||
.when()
|
||||
.post("/proxy")
|
||||
.then()
|
||||
.statusCode(400);
|
||||
}
|
||||
|
||||
@Test
|
||||
void disabledSystemIsRefused() {
|
||||
given().port(port).contentType("application/json")
|
||||
.header("X-System-Id", "disabled")
|
||||
.body(Map.of("prompt", PROMPT))
|
||||
.when().post("/proxy").then().statusCode(403);
|
||||
}
|
||||
}
|
||||
@Test
|
||||
void disabledSystemIsRefused() {
|
||||
given()
|
||||
.port(port)
|
||||
.contentType("application/json")
|
||||
.header("X-System-Id", "disabled")
|
||||
.body(Map.of("prompt", PROMPT))
|
||||
.when()
|
||||
.post("/proxy")
|
||||
.then()
|
||||
.statusCode(403);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
package ru.pdguard;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
|
||||
import java.util.UUID;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import ru.pdguard.config.SystemPolicy;
|
||||
import ru.pdguard.core.PayloadStore;
|
||||
@@ -7,113 +11,107 @@ 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), и «рп. Ильинское»/«с. Кукуево»
|
||||
* из ТЗ не находились вообще, дело было не в качестве детекции, а в том, что
|
||||
* искать было негде.
|
||||
* Адрес не только в городе: правило {@code ADDRESS_CITY} расширено якорями на сёла, посёлки,
|
||||
* деревни, хутора, станицы, аулы и аалы — раньше словарь ограничивался официальными городами
|
||||
* (~1100), и «рп. Ильинское»/«с. Кукуево» из ТЗ не находились вообще, дело было не в качестве
|
||||
* детекции, а в том, что искать было негде.
|
||||
*/
|
||||
class SettlementTest {
|
||||
|
||||
private final Pipeline pipeline =
|
||||
new Pipeline(new RuleRegistry(), new Masker(), new PayloadStore(1_000_000L, 30));
|
||||
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 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 + "»");
|
||||
}
|
||||
private void assertHidden(String text, String secret) {
|
||||
String masked = mask(text);
|
||||
assertFalse(
|
||||
masked.contains(secret), "не замаскировано: «" + secret + "» в ответе «" + masked + "»");
|
||||
}
|
||||
|
||||
@Test
|
||||
void masksVillage() {
|
||||
assertHidden("Клиент проживает в с. Кукуево постоянно", "Кукуево");
|
||||
}
|
||||
@Test
|
||||
void masksVillage() {
|
||||
assertHidden("Клиент проживает в с. Кукуево постоянно", "Кукуево");
|
||||
}
|
||||
|
||||
@Test
|
||||
void masksWorkersSettlement() {
|
||||
assertHidden("Регистрация по адресу: рп. Ильинское", "Ильинское");
|
||||
}
|
||||
@Test
|
||||
void masksWorkersSettlement() {
|
||||
assertHidden("Регистрация по адресу: рп. Ильинское", "Ильинское");
|
||||
}
|
||||
|
||||
@Test
|
||||
void masksUrbanTypeSettlement() {
|
||||
assertHidden("Доставка курьером в пгт. Энем", "Энем");
|
||||
}
|
||||
@Test
|
||||
void masksUrbanTypeSettlement() {
|
||||
assertHidden("Доставка курьером в пгт. Энем", "Энем");
|
||||
}
|
||||
|
||||
@Test
|
||||
void masksHamlet() {
|
||||
assertHidden("Дом находится в д. Аксеновка", "Аксеновка");
|
||||
}
|
||||
@Test
|
||||
void masksHamlet() {
|
||||
assertHidden("Дом находится в д. Аксеновка", "Аксеновка");
|
||||
}
|
||||
|
||||
@Test
|
||||
void masksFarmstead() {
|
||||
assertHidden("Клиент родом из х. Прогресс", "Прогресс");
|
||||
}
|
||||
@Test
|
||||
void masksFarmstead() {
|
||||
assertHidden("Клиент родом из х. Прогресс", "Прогресс");
|
||||
}
|
||||
|
||||
@Test
|
||||
void masksCossackStanitsa() {
|
||||
assertHidden("Прописан в ст-ца Гиагинская", "Гиагинская");
|
||||
}
|
||||
@Test
|
||||
void masksCossackStanitsa() {
|
||||
assertHidden("Прописан в ст-ца Гиагинская", "Гиагинская");
|
||||
}
|
||||
|
||||
@Test
|
||||
void masksAul() {
|
||||
assertHidden("Живёт в аул Блечепсин", "Блечепсин");
|
||||
}
|
||||
@Test
|
||||
void masksAul() {
|
||||
assertHidden("Живёт в аул Блечепсин", "Блечепсин");
|
||||
}
|
||||
|
||||
@Test
|
||||
void masksSloboda() {
|
||||
assertHidden("Проживает в сл. Екатериновка", "Екатериновка");
|
||||
}
|
||||
@Test
|
||||
void masksSloboda() {
|
||||
assertHidden("Проживает в сл. Екатериновка", "Екатериновка");
|
||||
}
|
||||
|
||||
@Test
|
||||
void masksAal() {
|
||||
assertHidden("Зарегистрирован в аал Сартыков", "Сартыков");
|
||||
}
|
||||
@Test
|
||||
void masksAal() {
|
||||
assertHidden("Зарегистрирован в аал Сартыков", "Сартыков");
|
||||
}
|
||||
|
||||
/** Словарь не подтверждает выдуманное название — якорь один в один, а маски нет. */
|
||||
@Test
|
||||
void doesNotMaskFictionalPlaceNameDespiteRealAnchor() {
|
||||
String text = "Клиент проживает в с. Мнимогорск";
|
||||
assertEquals(text, mask(text), "выдуманное название не должно проходить словарь");
|
||||
}
|
||||
/** Словарь не подтверждает выдуманное название — якорь один в один, а маски нет. */
|
||||
@Test
|
||||
void doesNotMaskFictionalPlaceNameDespiteRealAnchor() {
|
||||
String text = "Клиент проживает в с. Мнимогорск";
|
||||
assertEquals(text, mask(text), "выдуманное название не должно проходить словарь");
|
||||
}
|
||||
|
||||
/** Вето по организационному контексту работает и для новых типов НП, не только для городов. */
|
||||
@Test
|
||||
void doesNotMaskOrganisationAddressInVillage() {
|
||||
String text = "Ближайшее отделение банка находится в с. Кукуево";
|
||||
assertEquals(text, mask(text), "адрес отделения банка не является ПД клиента");
|
||||
}
|
||||
/** Вето по организационному контексту работает и для новых типов НП, не только для городов. */
|
||||
@Test
|
||||
void doesNotMaskOrganisationAddressInVillage() {
|
||||
String text = "Ближайшее отделение банка находится в с. Кукуево";
|
||||
assertEquals(text, mask(text), "адрес отделения банка не является ПД клиента");
|
||||
}
|
||||
|
||||
@Test
|
||||
void anchorsAreCaseInsensitive() {
|
||||
assertHidden("клиент проживает в С. КУКУЕВО", "КУКУЕВО");
|
||||
}
|
||||
@Test
|
||||
void anchorsAreCaseInsensitive() {
|
||||
assertHidden("клиент проживает в С. КУКУЕВО", "КУКУЕВО");
|
||||
}
|
||||
|
||||
/** Реалистичное предложение: населённый пункт, улица и дом вместе. */
|
||||
@Test
|
||||
void masksSettlementStreetAndHouseTogether() {
|
||||
String masked = mask("Проживает по адресу: д. Аксеновка, ул. Садовая, д. 7");
|
||||
assertFalse(masked.contains("Аксеновка"), masked);
|
||||
assertFalse(masked.contains("Садовая"), masked);
|
||||
assertFalse(masked.contains("д. 7"), masked);
|
||||
}
|
||||
/** Реалистичное предложение: населённый пункт, улица и дом вместе. */
|
||||
@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), "номер страницы не должен приниматься за населённый пункт");
|
||||
}
|
||||
/**
|
||||
* «с.» перед числом — обычная запись страницы («с. 25»), а не населённого пункта. Якорь не должен
|
||||
* на этом срабатывать: правило требует заглавную букву сразу после якоря, а не цифру.
|
||||
*/
|
||||
@Test
|
||||
void pageReferenceIsNotMistakenForSettlement() {
|
||||
String text = "См. с. 25 договора";
|
||||
assertEquals(text, mask(text), "номер страницы не должен приниматься за населённый пункт");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
package ru.pdguard;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
|
||||
import java.util.UUID;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import ru.pdguard.config.SystemPolicy;
|
||||
import ru.pdguard.core.PayloadStore;
|
||||
@@ -7,87 +11,81 @@ 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 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 String mask(String text) {
|
||||
return pipeline.process(text, UUID.randomUUID().toString(), SystemPolicy.DEFAULT);
|
||||
}
|
||||
|
||||
private void assertUnmasked(String text) {
|
||||
assertEquals(text, mask(text), "улица в честь исторической фигуры не должна маскироваться: " + text);
|
||||
}
|
||||
private void assertUnmasked(String text) {
|
||||
assertEquals(
|
||||
text, mask(text), "улица в честь исторической фигуры не должна маскироваться: " + text);
|
||||
}
|
||||
|
||||
@Test
|
||||
void doesNotMaskNikolaiOstrovskyStreet() {
|
||||
assertUnmasked("Живу на улице Николая Островского уже десять лет");
|
||||
}
|
||||
@Test
|
||||
void doesNotMaskNikolaiOstrovskyStreet() {
|
||||
assertUnmasked("Живу на улице Николая Островского уже десять лет");
|
||||
}
|
||||
|
||||
@Test
|
||||
void doesNotMaskAlexanderMatrosovStreet() {
|
||||
assertUnmasked("Магазин находится на улице Александра Матросова");
|
||||
}
|
||||
@Test
|
||||
void doesNotMaskAlexanderMatrosovStreet() {
|
||||
assertUnmasked("Магазин находится на улице Александра Матросова");
|
||||
}
|
||||
|
||||
@Test
|
||||
void doesNotMaskValeryChkalovStreet() {
|
||||
assertUnmasked("Заезжайте на улицу Валерия Чкалова");
|
||||
}
|
||||
@Test
|
||||
void doesNotMaskValeryChkalovStreet() {
|
||||
assertUnmasked("Заезжайте на улицу Валерия Чкалова");
|
||||
}
|
||||
|
||||
@Test
|
||||
void doesNotMaskVeraVoloshinaStreet() {
|
||||
assertUnmasked("Новый дом построили на улице Веры Волошиной");
|
||||
}
|
||||
@Test
|
||||
void doesNotMaskVeraVoloshinaStreet() {
|
||||
assertUnmasked("Новый дом построили на улице Веры Волошиной");
|
||||
}
|
||||
|
||||
@Test
|
||||
void doesNotMaskIvanSusaninStreet() {
|
||||
assertUnmasked("Школа расположена на улице Ивана Сусанина");
|
||||
}
|
||||
@Test
|
||||
void doesNotMaskIvanSusaninStreet() {
|
||||
assertUnmasked("Школа расположена на улице Ивана Сусанина");
|
||||
}
|
||||
|
||||
@Test
|
||||
void doesNotMaskSergeyKirovStreet() {
|
||||
assertUnmasked("Парковка есть на улице Сергея Кирова");
|
||||
}
|
||||
@Test
|
||||
void doesNotMaskSergeyKirovStreet() {
|
||||
assertUnmasked("Парковка есть на улице Сергея Кирова");
|
||||
}
|
||||
|
||||
@Test
|
||||
void doesNotMaskGeorgiDimitrovStreet() {
|
||||
assertUnmasked("Остановка на улице Георгия Димитрова");
|
||||
}
|
||||
@Test
|
||||
void doesNotMaskGeorgiDimitrovStreet() {
|
||||
assertUnmasked("Остановка на улице Георгия Димитрова");
|
||||
}
|
||||
|
||||
/**
|
||||
* Тот же принцип, что и у Пушкина: рядом с другим ПД денилист не
|
||||
* применяется — если в тексте всё-таки есть настоящие персональные данные,
|
||||
* совпадение с историческим именем их не прикрывает.
|
||||
*/
|
||||
@Test
|
||||
void masksCommemorativeStreetNameWhenOtherPersonalDataIsPresent() {
|
||||
String masked = mask("Живу на улице Николая Островского, тел. +7 916 123-45-67");
|
||||
assertFalse(masked.contains("Николая Островского"), masked);
|
||||
}
|
||||
/**
|
||||
* Тот же принцип, что и у Пушкина: рядом с другим ПД денилист не применяется — если в тексте
|
||||
* всё-таки есть настоящие персональные данные, совпадение с историческим именем их не прикрывает.
|
||||
*/
|
||||
@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 stillMasksRealClientNameWithSimilarPattern() {
|
||||
String masked = mask("Живёт на улице, зовут Николая Смирнова");
|
||||
assertFalse(masked.contains("Николая Смирнова"), masked);
|
||||
}
|
||||
|
||||
@Test
|
||||
void doesNotMaskDmitryDonskoyStreet() {
|
||||
assertUnmasked("Дом стоит на улице Дмитрия Донского");
|
||||
}
|
||||
@Test
|
||||
void doesNotMaskDmitryDonskoyStreet() {
|
||||
assertUnmasked("Дом стоит на улице Дмитрия Донского");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,90 +1,92 @@
|
||||
package ru.pdguard;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import java.io.IOException;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
import ru.pdguard.config.SystemPolicy;
|
||||
import ru.pdguard.config.SystemsConfig;
|
||||
import ru.pdguard.mask.MaskMode;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
/** Чтение и горячая перезагрузка списка систем. */
|
||||
class SystemsConfigTest {
|
||||
|
||||
private static final String CONTENT = """
|
||||
{
|
||||
"default": { "enabled": true, "demask": true, "maskMode": "MASK", "types": ["*"] },
|
||||
"crm": { "enabled": true, "demask": false, "maskMode": "TOKEN", "types": ["FIO"] },
|
||||
"old": { "enabled": false }
|
||||
}
|
||||
""";
|
||||
private static final String CONTENT =
|
||||
"""
|
||||
{
|
||||
"default": { "enabled": true, "demask": true, "maskMode": "MASK", "types": ["*"] },
|
||||
"crm": { "enabled": true, "demask": false, "maskMode": "TOKEN", "types": ["FIO"] },
|
||||
"old": { "enabled": false }
|
||||
}
|
||||
""";
|
||||
|
||||
private SystemsConfig configAt(Path file) {
|
||||
return new SystemsConfig(file.toString(), new ObjectMapper());
|
||||
}
|
||||
private SystemsConfig configAt(Path file) {
|
||||
return new SystemsConfig(file.toString(), new ObjectMapper());
|
||||
}
|
||||
|
||||
@Test
|
||||
void readsPoliciesFromFile(@TempDir Path dir) throws IOException {
|
||||
Path file = dir.resolve("systems.json");
|
||||
Files.writeString(file, CONTENT, StandardCharsets.UTF_8);
|
||||
@Test
|
||||
void readsPoliciesFromFile(@TempDir Path dir) throws IOException {
|
||||
Path file = dir.resolve("systems.json");
|
||||
Files.writeString(file, CONTENT, StandardCharsets.UTF_8);
|
||||
|
||||
SystemsConfig config = configAt(file);
|
||||
SystemPolicy crm = config.policyFor("crm");
|
||||
SystemsConfig config = configAt(file);
|
||||
SystemPolicy crm = config.policyFor("crm");
|
||||
|
||||
assertEquals(MaskMode.TOKEN, crm.maskMode());
|
||||
assertFalse(crm.demask());
|
||||
assertTrue(crm.allows("FIO"));
|
||||
assertFalse(crm.allows("CARD"));
|
||||
assertFalse(config.policyFor("old").enabled());
|
||||
}
|
||||
assertEquals(MaskMode.TOKEN, crm.maskMode());
|
||||
assertFalse(crm.demask());
|
||||
assertTrue(crm.allows("FIO"));
|
||||
assertFalse(crm.allows("CARD"));
|
||||
assertFalse(config.policyFor("old").enabled());
|
||||
}
|
||||
|
||||
@Test
|
||||
void unknownSystemGetsDefaultPolicy(@TempDir Path dir) throws IOException {
|
||||
Path file = dir.resolve("systems.json");
|
||||
Files.writeString(file, CONTENT, StandardCharsets.UTF_8);
|
||||
@Test
|
||||
void unknownSystemGetsDefaultPolicy(@TempDir Path dir) throws IOException {
|
||||
Path file = dir.resolve("systems.json");
|
||||
Files.writeString(file, CONTENT, StandardCharsets.UTF_8);
|
||||
|
||||
SystemPolicy policy = configAt(file).policyFor("никому-не-известная");
|
||||
assertTrue(policy.enabled());
|
||||
assertTrue(policy.allows("CARD"));
|
||||
}
|
||||
SystemPolicy policy = configAt(file).policyFor("никому-не-известная");
|
||||
assertTrue(policy.enabled());
|
||||
assertTrue(policy.allows("CARD"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void worksWithoutConfigFile(@TempDir Path dir) {
|
||||
SystemsConfig config = configAt(dir.resolve("нет-такого-файла.json"));
|
||||
assertEquals(SystemPolicy.DEFAULT, config.policyFor("любая"));
|
||||
}
|
||||
@Test
|
||||
void worksWithoutConfigFile(@TempDir Path dir) {
|
||||
SystemsConfig config = configAt(dir.resolve("нет-такого-файла.json"));
|
||||
assertEquals(SystemPolicy.DEFAULT, config.policyFor("любая"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void picksUpChangesWithoutRestart(@TempDir Path dir) throws IOException {
|
||||
Path file = dir.resolve("systems.json");
|
||||
Files.writeString(file, CONTENT, StandardCharsets.UTF_8);
|
||||
SystemsConfig config = configAt(file);
|
||||
assertEquals(MaskMode.TOKEN, config.policyFor("crm").maskMode());
|
||||
@Test
|
||||
void picksUpChangesWithoutRestart(@TempDir Path dir) throws IOException {
|
||||
Path file = dir.resolve("systems.json");
|
||||
Files.writeString(file, CONTENT, StandardCharsets.UTF_8);
|
||||
SystemsConfig config = configAt(file);
|
||||
assertEquals(MaskMode.TOKEN, config.policyFor("crm").maskMode());
|
||||
|
||||
Files.writeString(file, CONTENT.replace("\"TOKEN\"", "\"SYNTHETIC\""), StandardCharsets.UTF_8);
|
||||
config.reload();
|
||||
Files.writeString(file, CONTENT.replace("\"TOKEN\"", "\"SYNTHETIC\""), StandardCharsets.UTF_8);
|
||||
config.reload();
|
||||
|
||||
assertEquals(MaskMode.SYNTHETIC, config.policyFor("crm").maskMode());
|
||||
}
|
||||
assertEquals(MaskMode.SYNTHETIC, config.policyFor("crm").maskMode());
|
||||
}
|
||||
|
||||
@Test
|
||||
void brokenFileKeepsPreviousSettings(@TempDir Path dir) throws IOException {
|
||||
Path file = dir.resolve("systems.json");
|
||||
Files.writeString(file, CONTENT, StandardCharsets.UTF_8);
|
||||
SystemsConfig config = configAt(file);
|
||||
@Test
|
||||
void brokenFileKeepsPreviousSettings(@TempDir Path dir) throws IOException {
|
||||
Path file = dir.resolve("systems.json");
|
||||
Files.writeString(file, CONTENT, StandardCharsets.UTF_8);
|
||||
SystemsConfig config = configAt(file);
|
||||
|
||||
Files.writeString(file, "{ это не json", StandardCharsets.UTF_8);
|
||||
config.reload();
|
||||
Files.writeString(file, "{ это не json", StandardCharsets.UTF_8);
|
||||
config.reload();
|
||||
|
||||
assertEquals(MaskMode.TOKEN, config.policyFor("crm").maskMode(),
|
||||
"сломанный файл не должен ронять работающий сервис");
|
||||
}
|
||||
assertEquals(
|
||||
MaskMode.TOKEN,
|
||||
config.policyFor("crm").maskMode(),
|
||||
"сломанный файл не должен ронять работающий сервис");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,13 @@
|
||||
package ru.pdguard;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.Comparator;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import ru.pdguard.config.SystemPolicy;
|
||||
import ru.pdguard.core.PayloadStore;
|
||||
@@ -9,134 +17,141 @@ import ru.pdguard.detect.RuleRegistry;
|
||||
import ru.pdguard.detect.Span;
|
||||
import ru.pdguard.mask.Masker;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.Comparator;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
/**
|
||||
* Оценка двухмодельной архитектуры: WikiNEuRal для имён, ruBERT для адресов.
|
||||
*
|
||||
* <p>Набор {@code benchmark-two-model.txt} проверяет, что имена клиентов и адреса
|
||||
* маскируются, а известные личности — нет. Для каждого типа считается посимвольная
|
||||
* точность, полнота и F1.
|
||||
* <p>Набор {@code benchmark-two-model.txt} проверяет, что имена клиентов и адреса маскируются, а
|
||||
* известные личности — нет. Для каждого типа считается посимвольная точность, полнота и F1.
|
||||
*/
|
||||
class TwoModelBenchmarkTest {
|
||||
|
||||
private final Pipeline pipeline = new Pipeline(new RuleRegistry(), new Masker(),
|
||||
new PayloadStore(10_000_000L, 30),
|
||||
new NameCascade(
|
||||
new NameCascade.EngineConfig(
|
||||
"wikineural", Optional.of("models/wikineural-ner"),
|
||||
"rubert", Optional.of("models/rubert-ner"),
|
||||
"off", Optional.empty()),
|
||||
16, 4));
|
||||
private final Pipeline pipeline =
|
||||
new Pipeline(
|
||||
new RuleRegistry(),
|
||||
new Masker(),
|
||||
new PayloadStore(10_000_000L, 30),
|
||||
new NameCascade(
|
||||
new NameCascade.EngineConfig(
|
||||
"wikineural", Optional.of("models/wikineural-ner"),
|
||||
"rubert", Optional.of("models/rubert-ner"),
|
||||
"off", Optional.empty()),
|
||||
16,
|
||||
4));
|
||||
|
||||
private static final class Score {
|
||||
private int truePositive;
|
||||
private int falsePositive;
|
||||
private int falseNegative;
|
||||
private static final class Score {
|
||||
private int truePositive;
|
||||
private int falsePositive;
|
||||
private int falseNegative;
|
||||
|
||||
private int gold() {
|
||||
return truePositive + falseNegative;
|
||||
}
|
||||
|
||||
private double precision() {
|
||||
int found = truePositive + falsePositive;
|
||||
return found == 0 ? 1.0 : (double) truePositive / found;
|
||||
}
|
||||
|
||||
private double recall() {
|
||||
return gold() == 0 ? 1.0 : (double) truePositive / gold();
|
||||
}
|
||||
|
||||
private double f1() {
|
||||
double p = precision();
|
||||
double r = recall();
|
||||
return p + r == 0 ? 0.0 : 2 * p * r / (p + r);
|
||||
}
|
||||
private int gold() {
|
||||
return truePositive + falseNegative;
|
||||
}
|
||||
|
||||
@Test
|
||||
void twoModelEfficiency() {
|
||||
List<BenchmarkFixtures.Sample> samples = BenchmarkFixtures.load("/benchmark-two-model.txt");
|
||||
Map<String, Score> byType = new LinkedHashMap<>();
|
||||
Map<String, List<String>> missed = new LinkedHashMap<>();
|
||||
|
||||
for (BenchmarkFixtures.Sample sample : samples) {
|
||||
List<Span> found = pipeline.findPersonalData(sample.text(), SystemPolicy.DEFAULT);
|
||||
String[] goldChars = paint(sample.text().length(), sample.gold());
|
||||
String[] foundChars = paint(sample.text().length(), found);
|
||||
for (int i = 0; i < sample.text().length(); i++) {
|
||||
account(byType, goldChars[i], foundChars[i]);
|
||||
}
|
||||
for (Span gold : sample.gold()) {
|
||||
boolean hit = found.stream().anyMatch(f -> f.type().equals(gold.type()) && f.overlaps(gold));
|
||||
if (!hit) {
|
||||
missed.computeIfAbsent(gold.type(), t -> new ArrayList<>())
|
||||
.add(sample.text().substring(gold.start(), gold.end()));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
report(byType);
|
||||
reportMissed(missed);
|
||||
|
||||
// Каждый тип должен быть найден с F1 не ниже 0.8.
|
||||
for (Map.Entry<String, Score> e : byType.entrySet()) {
|
||||
assertTrue(e.getValue().f1() >= 0.8,
|
||||
String.format("F1 по типу %s упал до %.3f", e.getKey(), e.getValue().f1()));
|
||||
}
|
||||
private double precision() {
|
||||
int found = truePositive + falsePositive;
|
||||
return found == 0 ? 1.0 : (double) truePositive / found;
|
||||
}
|
||||
|
||||
private static String[] paint(int length, List<Span> spans) {
|
||||
String[] painted = new String[length];
|
||||
for (Span span : spans) {
|
||||
for (int i = span.start(); i < Math.min(span.end(), length); i++) {
|
||||
painted[i] = span.type();
|
||||
}
|
||||
}
|
||||
return painted;
|
||||
private double recall() {
|
||||
return gold() == 0 ? 1.0 : (double) truePositive / gold();
|
||||
}
|
||||
|
||||
private static void account(Map<String, Score> byType, String gold, String found) {
|
||||
if (gold != null) {
|
||||
Score score = byType.computeIfAbsent(gold, t -> new Score());
|
||||
if (gold.equals(found)) {
|
||||
score.truePositive++;
|
||||
} else {
|
||||
score.falseNegative++;
|
||||
}
|
||||
}
|
||||
if (found != null && !found.equals(gold)) {
|
||||
byType.computeIfAbsent(found, t -> new Score()).falsePositive++;
|
||||
private double f1() {
|
||||
double p = precision();
|
||||
double r = recall();
|
||||
return p + r == 0 ? 0.0 : 2 * p * r / (p + r);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void twoModelEfficiency() {
|
||||
List<BenchmarkFixtures.Sample> samples = BenchmarkFixtures.load("/benchmark-two-model.txt");
|
||||
Map<String, Score> byType = new LinkedHashMap<>();
|
||||
Map<String, List<String>> missed = new LinkedHashMap<>();
|
||||
|
||||
for (BenchmarkFixtures.Sample sample : samples) {
|
||||
List<Span> found = pipeline.findPersonalData(sample.text(), SystemPolicy.DEFAULT);
|
||||
String[] goldChars = paint(sample.text().length(), sample.gold());
|
||||
String[] foundChars = paint(sample.text().length(), found);
|
||||
for (int i = 0; i < sample.text().length(); i++) {
|
||||
account(byType, goldChars[i], foundChars[i]);
|
||||
}
|
||||
for (Span gold : sample.gold()) {
|
||||
boolean hit =
|
||||
found.stream().anyMatch(f -> f.type().equals(gold.type()) && f.overlaps(gold));
|
||||
if (!hit) {
|
||||
missed
|
||||
.computeIfAbsent(gold.type(), t -> new ArrayList<>())
|
||||
.add(sample.text().substring(gold.start(), gold.end()));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void report(Map<String, Score> byType) {
|
||||
StringBuilder out = new StringBuilder(2048);
|
||||
out.append("\n=== Эффективность двухмодельной архитектуры ===\n\n");
|
||||
out.append(String.format("%-20s %8s %8s %8s %8s%n", "тип", "знаков", "точность", "полнота", "F1"));
|
||||
byType.entrySet().stream()
|
||||
.sorted(Comparator.comparingInt((Map.Entry<String, Score> e) -> e.getValue().gold()).reversed())
|
||||
.forEach(e -> out.append(String.format("%-20s %8d %8.3f %8.3f %8.3f%n",
|
||||
e.getKey(), e.getValue().gold(), e.getValue().precision(),
|
||||
e.getValue().recall(), e.getValue().f1())));
|
||||
System.out.println(out);
|
||||
}
|
||||
report(byType);
|
||||
reportMissed(missed);
|
||||
|
||||
private void reportMissed(Map<String, List<String>> missed) {
|
||||
if (missed.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
StringBuilder out = new StringBuilder();
|
||||
out.append("\n=== Не распознанные значения по типам ===\n");
|
||||
missed.forEach((type, values) -> out.append(type).append(": ")
|
||||
.append(String.join(" | ", values)).append('\n'));
|
||||
System.out.println(out);
|
||||
// Каждый тип должен быть найден с F1 не ниже 0.8.
|
||||
for (Map.Entry<String, Score> e : byType.entrySet()) {
|
||||
assertTrue(
|
||||
e.getValue().f1() >= 0.8,
|
||||
String.format("F1 по типу %s упал до %.3f", e.getKey(), e.getValue().f1()));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static String[] paint(int length, List<Span> spans) {
|
||||
String[] painted = new String[length];
|
||||
for (Span span : spans) {
|
||||
for (int i = span.start(); i < Math.min(span.end(), length); i++) {
|
||||
painted[i] = span.type();
|
||||
}
|
||||
}
|
||||
return painted;
|
||||
}
|
||||
|
||||
private static void account(Map<String, Score> byType, String gold, String found) {
|
||||
if (gold != null) {
|
||||
Score score = byType.computeIfAbsent(gold, t -> new Score());
|
||||
if (gold.equals(found)) {
|
||||
score.truePositive++;
|
||||
} else {
|
||||
score.falseNegative++;
|
||||
}
|
||||
}
|
||||
if (found != null && !found.equals(gold)) {
|
||||
byType.computeIfAbsent(found, t -> new Score()).falsePositive++;
|
||||
}
|
||||
}
|
||||
|
||||
private void report(Map<String, Score> byType) {
|
||||
StringBuilder out = new StringBuilder(2048);
|
||||
out.append("\n=== Эффективность двухмодельной архитектуры ===\n\n");
|
||||
out.append(
|
||||
String.format("%-20s %8s %8s %8s %8s%n", "тип", "знаков", "точность", "полнота", "F1"));
|
||||
byType.entrySet().stream()
|
||||
.sorted(
|
||||
Comparator.comparingInt((Map.Entry<String, Score> e) -> e.getValue().gold()).reversed())
|
||||
.forEach(
|
||||
e ->
|
||||
out.append(
|
||||
String.format(
|
||||
"%-20s %8d %8.3f %8.3f %8.3f%n",
|
||||
e.getKey(),
|
||||
e.getValue().gold(),
|
||||
e.getValue().precision(),
|
||||
e.getValue().recall(),
|
||||
e.getValue().f1())));
|
||||
System.out.println(out);
|
||||
}
|
||||
|
||||
private void reportMissed(Map<String, List<String>> missed) {
|
||||
if (missed.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
StringBuilder out = new StringBuilder();
|
||||
out.append("\n=== Не распознанные значения по типам ===\n");
|
||||
missed.forEach(
|
||||
(type, values) ->
|
||||
out.append(type).append(": ").append(String.join(" | ", values)).append('\n'));
|
||||
System.out.println(out);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
package ru.pdguard;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import ru.pdguard.config.SystemPolicy;
|
||||
import ru.pdguard.core.PayloadStore;
|
||||
@@ -10,45 +14,44 @@ import ru.pdguard.detect.RuleRegistry;
|
||||
import ru.pdguard.detect.Span;
|
||||
import ru.pdguard.mask.Masker;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
/** Две модели: WikiNEuRal для имён, ruBERT для адресов. */
|
||||
class TwoModelCascadeTest {
|
||||
|
||||
private List<Span> find(String text) {
|
||||
NameCascade cascade = new NameCascade(
|
||||
new NameCascade.EngineConfig(
|
||||
"wikineural", Optional.of("models/wikineural-ner"),
|
||||
"rubert", Optional.of("models/rubert-ner"),
|
||||
"off", Optional.empty()),
|
||||
16, 4);
|
||||
Pipeline p = new Pipeline(new RuleRegistry(), new Masker(), new PayloadStore(1_000_000L, 30), cascade);
|
||||
return p.findPersonalData(text, SystemPolicy.DEFAULT);
|
||||
}
|
||||
private List<Span> find(String text) {
|
||||
NameCascade cascade =
|
||||
new NameCascade(
|
||||
new NameCascade.EngineConfig(
|
||||
"wikineural", Optional.of("models/wikineural-ner"),
|
||||
"rubert", Optional.of("models/rubert-ner"),
|
||||
"off", Optional.empty()),
|
||||
16,
|
||||
4);
|
||||
Pipeline p =
|
||||
new Pipeline(new RuleRegistry(), new Masker(), new PayloadStore(1_000_000L, 30), cascade);
|
||||
return p.findPersonalData(text, SystemPolicy.DEFAULT);
|
||||
}
|
||||
|
||||
@Test
|
||||
void recognisesNamesAndAddresses() {
|
||||
String text = "Клиент Иванов Иван Иванович, проживает в городе Москва, на улице Тверская";
|
||||
List<Span> spans = find(text);
|
||||
System.out.println("TEXT: " + text);
|
||||
for (Span s : spans) {
|
||||
System.out.println(" -> " + s.type() + " [" + text.substring(s.start(), s.end()) + "]");
|
||||
}
|
||||
assertTrue(spans.stream().anyMatch(s -> s.type().equals(PdTypes.FIO)), "должно найти ФИО");
|
||||
@Test
|
||||
void recognisesNamesAndAddresses() {
|
||||
String text = "Клиент Иванов Иван Иванович, проживает в городе Москва, на улице Тверская";
|
||||
List<Span> spans = find(text);
|
||||
System.out.println("TEXT: " + text);
|
||||
for (Span s : spans) {
|
||||
System.out.println(" -> " + s.type() + " [" + text.substring(s.start(), s.end()) + "]");
|
||||
}
|
||||
assertTrue(spans.stream().anyMatch(s -> s.type().equals(PdTypes.FIO)), "должно найти ФИО");
|
||||
}
|
||||
|
||||
@Test
|
||||
void wellKnownNamesNotMasked() {
|
||||
String text = "Напиши стихотворение в духе Александра Пушкина про осень";
|
||||
List<Span> spans = find(text);
|
||||
System.out.println("TEXT: " + text);
|
||||
for (Span s : spans) {
|
||||
System.out.println(" -> " + s.type() + " [" + text.substring(s.start(), s.end()) + "]");
|
||||
}
|
||||
assertTrue(spans.stream().noneMatch(s -> s.type().equals(PdTypes.FIO)),
|
||||
"известная личность не должна маскироваться");
|
||||
@Test
|
||||
void wellKnownNamesNotMasked() {
|
||||
String text = "Напиши стихотворение в духе Александра Пушкина про осень";
|
||||
List<Span> spans = find(text);
|
||||
System.out.println("TEXT: " + text);
|
||||
for (Span s : spans) {
|
||||
System.out.println(" -> " + s.type() + " [" + text.substring(s.start(), s.end()) + "]");
|
||||
}
|
||||
assertTrue(
|
||||
spans.stream().noneMatch(s -> s.type().equals(PdTypes.FIO)),
|
||||
"известная личность не должна маскироваться");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
package ru.pdguard.config;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import java.io.IOException;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
import ru.pdguard.mask.MaskMode;
|
||||
|
||||
class SystemsConfigTest {
|
||||
|
||||
private final ObjectMapper mapper = new ObjectMapper();
|
||||
|
||||
@Test
|
||||
void missingFileFallsBackToDefaultPolicy(@TempDir Path dir) {
|
||||
SystemsConfig config = new SystemsConfig(dir.resolve("absent.json").toString(), mapper);
|
||||
|
||||
SystemPolicy policy = config.policyFor("anything");
|
||||
|
||||
assertEquals(SystemPolicy.DEFAULT_NAME, policy.name());
|
||||
assertEquals(MaskMode.MASK, policy.maskMode());
|
||||
}
|
||||
|
||||
@Test
|
||||
void readsKnownSystemFromFile(@TempDir Path dir) throws IOException {
|
||||
Path file = dir.resolve("systems.json");
|
||||
Files.writeString(
|
||||
file,
|
||||
"""
|
||||
{
|
||||
"crm": {
|
||||
"enabled": true,
|
||||
"demask": false,
|
||||
"maskMode": "TOKEN",
|
||||
"types": ["FIO", "PHONE"]
|
||||
}
|
||||
}
|
||||
""",
|
||||
StandardCharsets.UTF_8);
|
||||
|
||||
SystemsConfig config = new SystemsConfig(file.toString(), mapper);
|
||||
|
||||
assertTrue(config.isKnown("crm"));
|
||||
SystemPolicy policy = config.policyFor("crm");
|
||||
assertEquals(MaskMode.TOKEN, policy.maskMode());
|
||||
assertFalse(policy.demask());
|
||||
assertTrue(policy.allows("FIO"));
|
||||
assertFalse(policy.allows("EMAIL"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void unknownSystemFallsBackToDefault(@TempDir Path dir) throws IOException {
|
||||
Path file = dir.resolve("systems.json");
|
||||
Files.writeString(
|
||||
file,
|
||||
"""
|
||||
{ "crm": { "maskMode": "TOKEN" } }
|
||||
""",
|
||||
StandardCharsets.UTF_8);
|
||||
|
||||
SystemsConfig config = new SystemsConfig(file.toString(), mapper);
|
||||
|
||||
assertFalse(config.isKnown("ghost"));
|
||||
assertEquals(SystemPolicy.DEFAULT_NAME, config.policyFor("ghost").name());
|
||||
}
|
||||
|
||||
@Test
|
||||
void malformedJsonKeepsPreviousSettings(@TempDir Path dir) throws IOException {
|
||||
Path file = dir.resolve("systems.json");
|
||||
Files.writeString(
|
||||
file,
|
||||
"""
|
||||
{ "crm": { "maskMode": "TOKEN" } }
|
||||
""",
|
||||
StandardCharsets.UTF_8);
|
||||
SystemsConfig config = new SystemsConfig(file.toString(), mapper);
|
||||
assertTrue(config.isKnown("crm"));
|
||||
|
||||
Files.writeString(file, "{ not valid json", StandardCharsets.UTF_8);
|
||||
config.reload();
|
||||
|
||||
assertTrue(config.isKnown("crm"), "битый файл не должен затирать рабочие настройки");
|
||||
}
|
||||
|
||||
@Test
|
||||
void unknownMaskModeKeepsPreviousSettings(@TempDir Path dir) throws IOException {
|
||||
Path file = dir.resolve("systems.json");
|
||||
Files.writeString(
|
||||
file,
|
||||
"""
|
||||
{ "crm": { "maskMode": "TOKEN" } }
|
||||
""",
|
||||
StandardCharsets.UTF_8);
|
||||
SystemsConfig config = new SystemsConfig(file.toString(), mapper);
|
||||
|
||||
Files.writeString(
|
||||
file,
|
||||
"""
|
||||
{ "crm": { "maskMode": "NOT_A_MODE" } }
|
||||
""",
|
||||
StandardCharsets.UTF_8);
|
||||
config.reload();
|
||||
|
||||
assertEquals(
|
||||
MaskMode.TOKEN,
|
||||
config.policyFor("crm").maskMode(),
|
||||
"неизвестный maskMode не должен принять частично разобранные настройки");
|
||||
}
|
||||
|
||||
@Test
|
||||
void currentReturnsSortedSnapshot(@TempDir Path dir) throws IOException {
|
||||
Path file = dir.resolve("systems.json");
|
||||
Files.writeString(
|
||||
file,
|
||||
"""
|
||||
{ "zzz": { "maskMode": "TOKEN" }, "aaa": { "maskMode": "MASK" } }
|
||||
""",
|
||||
StandardCharsets.UTF_8);
|
||||
|
||||
SystemsConfig config = new SystemsConfig(file.toString(), mapper);
|
||||
|
||||
assertEquals(
|
||||
java.util.List.of("aaa", "default", "zzz"),
|
||||
config.current().keySet().stream().sorted().toList());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
package ru.pdguard.core;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
class AdaptiveConcurrencyLimiterTest {
|
||||
|
||||
@Test
|
||||
void rejectsInvalidBounds() {
|
||||
assertThrows(IllegalArgumentException.class, () -> new AdaptiveConcurrencyLimiter(0, 10, 100));
|
||||
assertThrows(IllegalArgumentException.class, () -> new AdaptiveConcurrencyLimiter(10, 5, 100));
|
||||
}
|
||||
|
||||
@Test
|
||||
void acceptsUpToLimitThenRejects() {
|
||||
AdaptiveConcurrencyLimiter limiter = new AdaptiveConcurrencyLimiter(2, 2, 1000);
|
||||
|
||||
assertTrue(limiter.tryAcquire());
|
||||
assertTrue(limiter.tryAcquire());
|
||||
assertFalse(limiter.tryAcquire(), "предел исчерпан — третий запрос должен быть отклонён");
|
||||
assertEquals(2, limiter.inFlight());
|
||||
}
|
||||
|
||||
@Test
|
||||
void releaseFreesSlotForNextRequest() {
|
||||
AdaptiveConcurrencyLimiter limiter = new AdaptiveConcurrencyLimiter(1, 1, 1000);
|
||||
|
||||
assertTrue(limiter.tryAcquire());
|
||||
assertFalse(limiter.tryAcquire());
|
||||
limiter.release(TimeUnit.MILLISECONDS.toNanos(1));
|
||||
assertTrue(limiter.tryAcquire(), "после release слот должен освободиться");
|
||||
}
|
||||
|
||||
@Test
|
||||
void growsLimitWhenLatencyBelowTarget() throws InterruptedException {
|
||||
long window = TimeUnit.MILLISECONDS.toNanos(1);
|
||||
AdaptiveConcurrencyLimiter limiter = new AdaptiveConcurrencyLimiter(1, 5, 100, window);
|
||||
|
||||
limiter.tryAcquire();
|
||||
Thread.sleep(2);
|
||||
limiter.release(TimeUnit.MILLISECONDS.toNanos(1));
|
||||
|
||||
assertEquals(2, limiter.limit(), "задержка ниже целевой — предел должен вырасти на единицу");
|
||||
}
|
||||
|
||||
@Test
|
||||
void shrinksLimitWhenLatencyAboveTarget() throws InterruptedException {
|
||||
long window = TimeUnit.MILLISECONDS.toNanos(1);
|
||||
AdaptiveConcurrencyLimiter limiter = new AdaptiveConcurrencyLimiter(1, 8, 10, window);
|
||||
// Разгоняем предел до 4, чтобы было куда сжиматься.
|
||||
for (int i = 0; i < 3; i++) {
|
||||
limiter.tryAcquire();
|
||||
Thread.sleep(2);
|
||||
limiter.release(TimeUnit.MILLISECONDS.toNanos(1));
|
||||
}
|
||||
assertEquals(4, limiter.limit());
|
||||
|
||||
limiter.tryAcquire();
|
||||
Thread.sleep(2);
|
||||
limiter.release(TimeUnit.MILLISECONDS.toNanos(50));
|
||||
|
||||
assertEquals(2, limiter.limit(), "задержка выше целевой — предел должен сжаться вдвое");
|
||||
}
|
||||
|
||||
@Test
|
||||
void limitNeverDropsBelowMin() throws InterruptedException {
|
||||
long window = TimeUnit.MILLISECONDS.toNanos(1);
|
||||
AdaptiveConcurrencyLimiter limiter = new AdaptiveConcurrencyLimiter(3, 10, 5, window);
|
||||
|
||||
limiter.tryAcquire();
|
||||
Thread.sleep(2);
|
||||
limiter.release(TimeUnit.MILLISECONDS.toNanos(50));
|
||||
|
||||
assertEquals(3, limiter.limit(), "предел не должен опускаться ниже minLimit");
|
||||
}
|
||||
|
||||
@Test
|
||||
void doesNotAdjustBeforeWindowElapses() {
|
||||
long window = TimeUnit.SECONDS.toNanos(10);
|
||||
AdaptiveConcurrencyLimiter limiter = new AdaptiveConcurrencyLimiter(1, 5, 100, window);
|
||||
|
||||
limiter.tryAcquire();
|
||||
limiter.release(1);
|
||||
|
||||
assertEquals(1, limiter.limit(), "окно ещё не прошло — предел не должен меняться");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
package ru.pdguard.core;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertNotEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
import java.security.SecureRandom;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
class PayloadCipherTest {
|
||||
|
||||
private static String randomHexKey() {
|
||||
byte[] bytes = new byte[32];
|
||||
new SecureRandom().nextBytes(bytes);
|
||||
return java.util.HexFormat.of().formatHex(bytes);
|
||||
}
|
||||
|
||||
@Test
|
||||
void disabledCipherPassesTextThrough() {
|
||||
PayloadCipher cipher = PayloadCipher.disabled();
|
||||
|
||||
assertFalse(cipher.enabled());
|
||||
assertEquals("Иванов Иван Иванович", cipher.encrypt("Иванов Иван Иванович"));
|
||||
assertEquals("Иванов Иван Иванович", cipher.decrypt("Иванов Иван Иванович"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void enabledCipherRoundTrips() {
|
||||
PayloadCipher cipher = new PayloadCipher(randomHexKey());
|
||||
|
||||
assertTrue(cipher.enabled());
|
||||
String encrypted = cipher.encrypt("Иванов Иван Иванович, паспорт 4509 123456");
|
||||
assertNotEquals("Иванов Иван Иванович, паспорт 4509 123456", encrypted);
|
||||
assertEquals("Иванов Иван Иванович, паспорт 4509 123456", cipher.decrypt(encrypted));
|
||||
}
|
||||
|
||||
@Test
|
||||
void ciphertextDiffersEachTimeDueToRandomIv() {
|
||||
PayloadCipher cipher = new PayloadCipher(randomHexKey());
|
||||
|
||||
String first = cipher.encrypt("тот же текст");
|
||||
String second = cipher.encrypt("тот же текст");
|
||||
|
||||
assertNotEquals(first, second, "разный IV должен давать разный шифротекст на одинаковом входе");
|
||||
}
|
||||
|
||||
@Test
|
||||
void decryptingGarbageThrows() {
|
||||
PayloadCipher cipher = new PayloadCipher(randomHexKey());
|
||||
|
||||
assertThrows(IllegalStateException.class, () -> cipher.decrypt("не base64!!!"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void decryptingWithDifferentKeyThrows() {
|
||||
PayloadCipher first = new PayloadCipher(randomHexKey());
|
||||
PayloadCipher second = new PayloadCipher(randomHexKey());
|
||||
|
||||
String encrypted = first.encrypt("секрет");
|
||||
|
||||
assertThrows(IllegalStateException.class, () -> second.decrypt(encrypted));
|
||||
}
|
||||
}
|
||||
@@ -1,87 +1,88 @@
|
||||
package ru.pdguard.detect;
|
||||
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
|
||||
/** Денилист известных людей: встроенный список и дозагрузка сверху без пересборки. */
|
||||
class NameDictionaryTest {
|
||||
|
||||
/** Возвращает состояние словаря к встроенному списку — не течёт в остальные тесты. */
|
||||
@AfterEach
|
||||
void resetToBundledList() {
|
||||
NameDictionary.useExternalFile(Path.of("config/well-known.txt"));
|
||||
}
|
||||
/** Возвращает состояние словаря к встроенному списку — не течёт в остальные тесты. */
|
||||
@AfterEach
|
||||
void resetToBundledList() {
|
||||
NameDictionary.useExternalFile(Path.of("config/well-known.txt"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void bundledListCoversClassicFigures() {
|
||||
// Сравнение по началу слова покрывает обычную русскую флексию («Пушкин» →
|
||||
// «Пушкина»), но не прилагательное склонение («Толстой» → «Толстого», где
|
||||
// «-ой» меняется на «-ого» целиком, а не дописывается) — известное
|
||||
// ограничение самого приёма, не завязанное на список имён.
|
||||
assertTrue(NameDictionary.isWellKnown("Стихи Пушкина"));
|
||||
assertTrue(NameDictionary.isWellKnown("Портрет Толстой"));
|
||||
}
|
||||
@Test
|
||||
void bundledListCoversClassicFigures() {
|
||||
// Сравнение по началу слова покрывает обычную русскую флексию («Пушкин» →
|
||||
// «Пушкина»), но не прилагательное склонение («Толстой» → «Толстого», где
|
||||
// «-ой» меняется на «-ого» целиком, а не дописывается) — известное
|
||||
// ограничение самого приёма, не завязанное на список имён.
|
||||
assertTrue(NameDictionary.isWellKnown("Стихи Пушкина"));
|
||||
assertTrue(NameDictionary.isWellKnown("Портрет Толстой"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void bundledListCoversCurrentPublicFigures() {
|
||||
assertTrue(NameDictionary.isWellKnown("Заявление Набиуллиной"));
|
||||
assertTrue(NameDictionary.isWellKnown("Интервью Путина"));
|
||||
}
|
||||
@Test
|
||||
void bundledListCoversCurrentPublicFigures() {
|
||||
assertTrue(NameDictionary.isWellKnown("Заявление Набиуллиной"));
|
||||
assertTrue(NameDictionary.isWellKnown("Интервью Путина"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void unknownSurnameIsNotWellKnown() {
|
||||
assertFalse(NameDictionary.isWellKnown("Заявление Смирнова"));
|
||||
}
|
||||
@Test
|
||||
void unknownSurnameIsNotWellKnown() {
|
||||
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
|
||||
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
|
||||
void externalFileAddsNamesWithoutRebuild(@TempDir Path dir) throws IOException {
|
||||
Path file = dir.resolve("well-known.txt");
|
||||
Files.writeString(file, "Кастомов\n", StandardCharsets.UTF_8);
|
||||
@Test
|
||||
void externalFileAddsNamesWithoutRebuild(@TempDir Path dir) throws IOException {
|
||||
Path file = dir.resolve("well-known.txt");
|
||||
Files.writeString(file, "Кастомов\n", StandardCharsets.UTF_8);
|
||||
|
||||
NameDictionary.useExternalFile(file);
|
||||
NameDictionary.useExternalFile(file);
|
||||
|
||||
assertTrue(NameDictionary.isWellKnown("Интервью Кастомова"),
|
||||
"дописанное сверху имя должно распознаваться наравне со встроенными");
|
||||
assertTrue(NameDictionary.isWellKnown("Стихи Пушкина"),
|
||||
"встроенный список не должен теряться при дозагрузке");
|
||||
}
|
||||
assertTrue(
|
||||
NameDictionary.isWellKnown("Интервью Кастомова"),
|
||||
"дописанное сверху имя должно распознаваться наравне со встроенными");
|
||||
assertTrue(
|
||||
NameDictionary.isWellKnown("Стихи Пушкина"),
|
||||
"встроенный список не должен теряться при дозагрузке");
|
||||
}
|
||||
|
||||
@Test
|
||||
void missingExternalFileFallsBackToBundledListOnly(@TempDir Path dir) {
|
||||
NameDictionary.useExternalFile(dir.resolve("нет-такого-файла.txt"));
|
||||
@Test
|
||||
void missingExternalFileFallsBackToBundledListOnly(@TempDir Path dir) {
|
||||
NameDictionary.useExternalFile(dir.resolve("нет-такого-файла.txt"));
|
||||
|
||||
assertTrue(NameDictionary.isWellKnown("Стихи Пушкина"));
|
||||
assertFalse(NameDictionary.isWellKnown("Заявление Смирнова"));
|
||||
}
|
||||
assertTrue(NameDictionary.isWellKnown("Стихи Пушкина"));
|
||||
assertFalse(NameDictionary.isWellKnown("Заявление Смирнова"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,62 +1,61 @@
|
||||
package ru.pdguard.detect;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
/** Словарь населённых пунктов — не только города, но и сёла, посёлки, деревни, хутора. */
|
||||
class ToponymDictionaryTest {
|
||||
|
||||
@Test
|
||||
void recognisesNominativeCase() {
|
||||
assertTrue(ToponymDictionary.isKnownSettlement("Москва"));
|
||||
assertTrue(ToponymDictionary.isKnownSettlement("Казань"));
|
||||
assertTrue(ToponymDictionary.isKnownSettlement("Санкт-Петербург"));
|
||||
}
|
||||
@Test
|
||||
void recognisesNominativeCase() {
|
||||
assertTrue(ToponymDictionary.isKnownSettlement("Москва"));
|
||||
assertTrue(ToponymDictionary.isKnownSettlement("Казань"));
|
||||
assertTrue(ToponymDictionary.isKnownSettlement("Санкт-Петербург"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void recognisesInflectedForms() {
|
||||
assertTrue(ToponymDictionary.isKnownSettlement("Москве"), "дательный падеж города на гласную");
|
||||
assertTrue(ToponymDictionary.isKnownSettlement("Тамбове"), "предложный падеж города на согласную");
|
||||
assertTrue(ToponymDictionary.isKnownSettlement("Казани"), "родительный падеж");
|
||||
}
|
||||
@Test
|
||||
void recognisesInflectedForms() {
|
||||
assertTrue(ToponymDictionary.isKnownSettlement("Москве"), "дательный падеж города на гласную");
|
||||
assertTrue(
|
||||
ToponymDictionary.isKnownSettlement("Тамбове"), "предложный падеж города на согласную");
|
||||
assertTrue(ToponymDictionary.isKnownSettlement("Казани"), "родительный падеж");
|
||||
}
|
||||
|
||||
@Test
|
||||
void recognisesCisCapitals() {
|
||||
assertTrue(ToponymDictionary.isKnownSettlement("Минск"));
|
||||
assertTrue(ToponymDictionary.isKnownSettlement("Алматы"));
|
||||
}
|
||||
@Test
|
||||
void recognisesCisCapitals() {
|
||||
assertTrue(ToponymDictionary.isKnownSettlement("Минск"));
|
||||
assertTrue(ToponymDictionary.isKnownSettlement("Алматы"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsMadeUpWord() {
|
||||
assertFalse(ToponymDictionary.isKnownSettlement("Ерунда"));
|
||||
assertFalse(ToponymDictionary.isKnownSettlement("Бла-бла"));
|
||||
}
|
||||
@Test
|
||||
void rejectsMadeUpWord() {
|
||||
assertFalse(ToponymDictionary.isKnownSettlement("Ерунда"));
|
||||
assertFalse(ToponymDictionary.isKnownSettlement("Бла-бла"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void isCaseInsensitive() {
|
||||
assertTrue(ToponymDictionary.isKnownSettlement("МОСКВА"));
|
||||
assertTrue(ToponymDictionary.isKnownSettlement("москва"));
|
||||
}
|
||||
@Test
|
||||
void isCaseInsensitive() {
|
||||
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("Сартыков"), "аал (Хакасия)");
|
||||
}
|
||||
/**
|
||||
* Из переписи 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("Сартыков"), "аал (Хакасия)");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,69 +1,70 @@
|
||||
package ru.pdguard.detect;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
/** Контрольные суммы ОГРН/ОГРНИП: первые 12/14 цифр по модулю 11/13, младший разряд остатка. */
|
||||
class ValidatorsTest {
|
||||
|
||||
@Test
|
||||
void validOgrnPassesChecksum() {
|
||||
assertTrue(Validators.ogrn("1027700123450"));
|
||||
assertTrue(Validators.ogrn("1025000678900"));
|
||||
assertTrue(Validators.ogrn("1045002233440"));
|
||||
}
|
||||
@Test
|
||||
void validOgrnPassesChecksum() {
|
||||
assertTrue(Validators.ogrn("1027700123450"));
|
||||
assertTrue(Validators.ogrn("1025000678900"));
|
||||
assertTrue(Validators.ogrn("1045002233440"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void invalidOgrnChecksumFails() {
|
||||
assertFalse(Validators.ogrn("1027700123451"), "последняя цифра изменена — сумма не сходится");
|
||||
}
|
||||
@Test
|
||||
void invalidOgrnChecksumFails() {
|
||||
assertFalse(Validators.ogrn("1027700123451"), "последняя цифра изменена — сумма не сходится");
|
||||
}
|
||||
|
||||
@Test
|
||||
void ogrnWrongLengthFails() {
|
||||
assertFalse(Validators.ogrn("102770012345"), "12 цифр — не хватает контрольной");
|
||||
assertFalse(Validators.ogrn("10277001234500"), "14 цифр — лишняя");
|
||||
}
|
||||
@Test
|
||||
void ogrnWrongLengthFails() {
|
||||
assertFalse(Validators.ogrn("102770012345"), "12 цифр — не хватает контрольной");
|
||||
assertFalse(Validators.ogrn("10277001234500"), "14 цифр — лишняя");
|
||||
}
|
||||
|
||||
/** Остаток от деления на 11 может быть 10 — тогда контрольная цифра 0, не 10. */
|
||||
@Test
|
||||
void ogrnRemainderTenMapsToZero() {
|
||||
assertTrue(Validators.ogrn("1000000000000"));
|
||||
}
|
||||
/** Остаток от деления на 11 может быть 10 — тогда контрольная цифра 0, не 10. */
|
||||
@Test
|
||||
void ogrnRemainderTenMapsToZero() {
|
||||
assertTrue(Validators.ogrn("1000000000000"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void validOgrnipPassesChecksum() {
|
||||
assertTrue(Validators.ogrnip("304500116000157"));
|
||||
assertTrue(Validators.ogrnip("312500000000013"));
|
||||
assertTrue(Validators.ogrnip("305500112233041"));
|
||||
}
|
||||
@Test
|
||||
void validOgrnipPassesChecksum() {
|
||||
assertTrue(Validators.ogrnip("304500116000157"));
|
||||
assertTrue(Validators.ogrnip("312500000000013"));
|
||||
assertTrue(Validators.ogrnip("305500112233041"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void invalidOgrnipChecksumFails() {
|
||||
assertFalse(Validators.ogrnip("304500116000158"), "последняя цифра изменена — сумма не сходится");
|
||||
}
|
||||
@Test
|
||||
void invalidOgrnipChecksumFails() {
|
||||
assertFalse(
|
||||
Validators.ogrnip("304500116000158"), "последняя цифра изменена — сумма не сходится");
|
||||
}
|
||||
|
||||
@Test
|
||||
void ogrnipWrongLengthFails() {
|
||||
assertFalse(Validators.ogrnip("30450011600015"), "14 цифр — не хватает контрольной");
|
||||
assertFalse(Validators.ogrnip("30450011600015700"), "18 цифр — лишние");
|
||||
}
|
||||
@Test
|
||||
void ogrnipWrongLengthFails() {
|
||||
assertFalse(Validators.ogrnip("30450011600015"), "14 цифр — не хватает контрольной");
|
||||
assertFalse(Validators.ogrnip("30450011600015700"), "18 цифр — лишние");
|
||||
}
|
||||
|
||||
/** Остаток от деления на 13 может быть 10 — тогда контрольная цифра 0, не 10. */
|
||||
@Test
|
||||
void ogrnipRemainderTenMapsToZero() {
|
||||
assertTrue(Validators.ogrnip("100000000000000"));
|
||||
}
|
||||
/** Остаток от деления на 13 может быть 10 — тогда контрольная цифра 0, не 10. */
|
||||
@Test
|
||||
void ogrnipRemainderTenMapsToZero() {
|
||||
assertTrue(Validators.ogrnip("100000000000000"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void nonDigitCharactersAreIgnored() {
|
||||
assertTrue(Validators.ogrn("10-27700-123450"), "разделители в номере не мешают счёту цифр");
|
||||
}
|
||||
@Test
|
||||
void nonDigitCharactersAreIgnored() {
|
||||
assertTrue(Validators.ogrn("10-27700-123450"), "разделители в номере не мешают счёту цифр");
|
||||
}
|
||||
|
||||
@Test
|
||||
void emptyValueIsNotValid() {
|
||||
assertFalse(Validators.ogrn(""));
|
||||
assertFalse(Validators.ogrnip(""));
|
||||
}
|
||||
@Test
|
||||
void emptyValueIsNotValid() {
|
||||
assertFalse(Validators.ogrn(""));
|
||||
assertFalse(Validators.ogrnip(""));
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user