test: independent generated benchmark + tests on large mixed text
benchmark-generated.txt: a fresh dataset (not used to tune the rules)
covering every PD type from the spec plus format variations — date
order, "серия ... номер ...", register-independence. Surfaced two real
gaps worth tracking: FIO detection needs a capitalized first letter,
so "клиент иванова мария петровна" (all lowercase) isn't found; the
NER cascade flags plain capitalized nouns ("Портрет", "Рим") as names
when the base rules alone don't.
LargeTextTest: the existing large-text test just repeated one
email+card sentence 4000 times, so 27 of 28 PD types never ran at
scale. Replaces it with ~400,000 characters built from shuffled lines
of the new dataset — recall on it holds at 0.970, matching small-scale
numbers, and the NER cascade stays sub-second on the whole thing.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 5
parent
832738891c
commit
a758c5ef05
@@ -142,6 +142,26 @@ class BenchmarkTest {
|
||||
String.format("полнота на втором отложенном наборе упала до %.3f", result.overallRecall()));
|
||||
}
|
||||
|
||||
/**
|
||||
* Независимый сгенерированный набор — покрывает все типы ПД из ТЗ и вариации
|
||||
* написания, не встречавшиеся ни в одном из остальных наборов. Правила под
|
||||
* него не настраивались; пороги низкие по той же причине, что и у второго
|
||||
* отложенного набора — тест ловит обвал, а не сторожит достигнутое значение.
|
||||
*/
|
||||
@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(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()));
|
||||
}
|
||||
|
||||
/**
|
||||
* Тот же отложенный набор, но со включённой второй ступенью. Модели нет —
|
||||
* проверка пропускается: в сборке без модели сервис работает на одних правилах.
|
||||
|
||||
@@ -0,0 +1,192 @@
|
||||
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.core.Span;
|
||||
import ru.pdguard.detect.NameCascade;
|
||||
import ru.pdguard.detect.RuleRegistry;
|
||||
import ru.pdguard.mask.Masker;
|
||||
|
||||
import java.io.BufferedReader;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.io.InputStreamReader;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
import java.util.Optional;
|
||||
import java.util.Random;
|
||||
import java.util.regex.Matcher;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
/**
|
||||
* Качество и скорость на большом тексте — не повторе одного и того же
|
||||
* предложения, а перемешанных строках из {@code benchmark-generated.txt}
|
||||
* (все типы ПД вперемешку с чистым текстом), растянутых до объёма из ТЗ
|
||||
* (около 100 000 токенов, ~400 КБ по оценке из README).
|
||||
*
|
||||
* <p>Раздутый повтором одной строки текст проверяет только то, что цикл не
|
||||
* падает на объёме: под маской всегда один и тот же тип, а остальные правила
|
||||
* не задействуются вовсе. Здесь размер и разнообразие проверяются вместе.
|
||||
*/
|
||||
class LargeTextTest {
|
||||
|
||||
private static final String MODEL_PATH = "models/ru-ner-person.bin";
|
||||
private static final Pattern MARKUP = Pattern.compile("\\{\\{([A-Z_]+):([^}]*)}}");
|
||||
|
||||
/** Целевой объём: README оценивает 100 000 токенов как ~400 КБ текста. */
|
||||
private static final int TARGET_CHARS = 400_000;
|
||||
|
||||
private record Line(String text, List<Span> gold) {
|
||||
}
|
||||
|
||||
/** Строки набора без разметки — чистый текст для перемешивания. */
|
||||
private static List<Line> loadLines() {
|
||||
List<Line> lines = new ArrayList<>();
|
||||
try (InputStream in = LargeTextTest.class.getResourceAsStream("/benchmark-generated.txt");
|
||||
BufferedReader reader = new BufferedReader(
|
||||
new InputStreamReader(Objects.requireNonNull(in), StandardCharsets.UTF_8))) {
|
||||
String raw;
|
||||
while ((raw = reader.readLine()) != null) {
|
||||
String trimmed = raw.trim();
|
||||
if (!trimmed.isEmpty() && !trimmed.startsWith("#")) {
|
||||
lines.add(parse(trimmed));
|
||||
}
|
||||
}
|
||||
} catch (IOException e) {
|
||||
throw new IllegalStateException(e);
|
||||
}
|
||||
return lines;
|
||||
}
|
||||
|
||||
private static Line parse(String line) {
|
||||
StringBuilder text = new StringBuilder(line.length());
|
||||
List<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 Line(text.toString(), gold);
|
||||
}
|
||||
|
||||
/**
|
||||
* Перемешивает исходные строки (фиксированный seed — детерминированный
|
||||
* тест) и склеивает их через перенос строки, пока не наберётся целевой
|
||||
* объём. Смещения золотых фрагментов пересчитываются под общий текст.
|
||||
*/
|
||||
private static Line buildLargeText(int targetChars, long seed) {
|
||||
List<Line> pool = new ArrayList<>(loadLines());
|
||||
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 (Line line : pool) {
|
||||
int offset = text.length();
|
||||
text.append(line.text()).append('\n');
|
||||
for (Span span : line.gold()) {
|
||||
gold.add(new Span(span.start() + offset, span.end() + offset, span.type(), 0));
|
||||
}
|
||||
if (text.length() >= targetChars) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
return new Line(text.toString(), gold);
|
||||
}
|
||||
|
||||
/**
|
||||
* Маскирование и обратное преобразование на большом тексте дают
|
||||
* побайтово тот же результат, что и исходный текст — при объёме на
|
||||
* порядок больше, чем в остальных тестах, и с разнородным содержимым,
|
||||
* а не одним повторяющимся предложением.
|
||||
*/
|
||||
@Test
|
||||
void roundTripOnLargeMixedText() {
|
||||
Line 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() {
|
||||
Line 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() throws IOException {
|
||||
Path model = Path.of(MODEL_PATH);
|
||||
if (!Files.isReadable(model)) {
|
||||
System.out.println("Модель " + model.toAbsolutePath() + " не собрана, пропускаю");
|
||||
return;
|
||||
}
|
||||
Line large = buildLargeText(TARGET_CHARS, 3);
|
||||
Pipeline pipeline = new Pipeline(new RuleRegistry(), new Masker(),
|
||||
new PayloadStore(large.text().length() * 2L, 30),
|
||||
new NameCascade(Optional.of(MODEL_PATH), 16, 4));
|
||||
|
||||
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 + " мс");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
# СГЕНЕРИРОВАННЫЙ НЕЗАВИСИМЫЙ НАБОР. Правила по нему не настраивались — составлен
|
||||
# после того, как код уже был написан, специально для проверки. Имена, номера
|
||||
# документов и формулировки не пересекаются с benchmark.txt, benchmark-holdout.txt
|
||||
# и benchmark-holdout2.txt. Номера карт/ИНН/СНИЛС подобраны так, чтобы проходить
|
||||
# контрольную сумму — иначе строка проверяла бы не детектор, а качество случайных
|
||||
# цифр. Покрывает все типы ПД из ТЗ, вариации написания дат и серии/номера,
|
||||
# регистронезависимость, companion-правило (CVV/PIN/DATE без других ПД рядом)
|
||||
# и ложные срабатывания (адрес отделения банка, известные исторические фигуры).
|
||||
|
||||
# --- ФИО: порядок слов, инициалы, ролевые слова ---
|
||||
Заявление подал {{FIO:Смирнов Артём Викторович}}
|
||||
{{FIO:Виктория Романовна Кузнецова}} оформила вклад на три месяца
|
||||
Документы подписал {{FIO:Соколов А.П.}}
|
||||
С уважением, {{FIO:Т.Н. Морозова}}
|
||||
Клиент {{FIO:Дмитрий Волков}} закрыл счёт досрочно
|
||||
Заёмщик {{FIO:Орлова}} внесла платёж по графику
|
||||
Созаёмщик {{FIO:Николай Беляев}} подтвердил согласие
|
||||
Поручитель {{FIO:Егоров}} не ответил на звонок
|
||||
ФИО: {{FIO:Григорьев Максим Олегович}}
|
||||
Ф.И.О. {{FIO:Лебедева Полина Сергеевна}}
|
||||
Обращение принял оператор, на имя {{FIO:Захарова}} оформлена заявка
|
||||
Комиссия в составе {{FIO:Козлов В.С.}}, {{FIO:Морозова Т.Н.}}, {{FIO:Беляев Н.А.}}
|
||||
{{FIO:ИВАНОВ ИВАН ИВАНОВИЧ}} обратился повторно
|
||||
{{FIO:Марк Соловьёв}} и {{FIO:Анна Григорьева}} подали заявку вместе
|
||||
Клиент {{FIO:иванова мария петровна}} обратилась повторно
|
||||
|
||||
# --- Ложные срабатывания: известные фигуры, адрес отделения, обычные слова ---
|
||||
Собрание сочинений Толстого стоит на верхней полке
|
||||
Портрет Пушкина висит в кабинете литературы
|
||||
Мы изучаем сонеты Шекспира на этой неделе
|
||||
Иван Грозный правил в шестнадцатом веке
|
||||
Третий Рим — известная историческая концепция
|
||||
Ближайшее отделение банка находится по адресу: г. Казань, ул. Баумана, д. 7
|
||||
Юридический адрес банка: г. Москва, ул. Ильинка, д. 9
|
||||
Реквизиты головного офиса опубликованы на сайте банка
|
||||
Расскажи, как оформить кредитную карту онлайн
|
||||
Какая процентная ставка по вкладу на полгода
|
||||
Составь напоминание про оплату коммунальных услуг
|
||||
|
||||
# --- Даты: числом в разном порядке, словом, с разными якорями ---
|
||||
Дата рождения {{BIRTH_DATE:15.03.1990}}
|
||||
Дата рождения {{BIRTH_DATE:1990-03-15}}
|
||||
Родился {{BIRTH_DATE:05.11.1988}} в {{BIRTH_PLACE:Новосибирске}}
|
||||
{{BIRTH_DATE:22.07.1979}} г.р.
|
||||
Дата рождения {{BIRTH_DATE:двенадцатого мая тысяча девятьсот восемьдесят пятого года}}
|
||||
Дата выдачи {{PASSPORT_DATE:12.05.2015}}
|
||||
Дата выдачи паспорта {{PASSPORT_DATE:03.09.2018}}
|
||||
|
||||
# --- Паспорт, серия/номер разными способами, орган выдачи, код подразделения ---
|
||||
Паспорт гражданина РФ {{PASSPORT:4510 654321}}
|
||||
Паспорт {{PASSPORT:4511987654}}
|
||||
серия {{PASSPORT:4512}} номер {{PASSPORT:112233}}
|
||||
серии {{PASSPORT:45 13}} за номером {{PASSPORT:445566}}
|
||||
Код подразделения {{DEPT_CODE:770-123}}
|
||||
к/п {{DEPT_CODE:500011}}
|
||||
выдан {{PASSPORT_ISSUER:ОУФМС России по г. Москве}} 12.05.2015
|
||||
выдан {{PASSPORT_ISSUER:Отделом УФМС по Республике Татарстан}}, действителен
|
||||
|
||||
# --- Документы, удостоверяющие личность, кроме паспорта РФ ---
|
||||
Водительское удостоверение {{DRIVER_LICENSE:5014 778899}}
|
||||
в/у {{DRIVER_LICENSE:5015112233}}
|
||||
Загранпаспорт {{FOREIGN_PASSPORT:71 2233445}}
|
||||
Военный билет {{MILITARY_ID:АБ 3344556}}
|
||||
Свидетельство о рождении {{BIRTH_CERTIFICATE:II-АБ №778899}}
|
||||
Полис ОМС {{MEDICAL_POLICY:1122334455667788}}
|
||||
|
||||
# --- Гражданство, место рождения ---
|
||||
Гражданство {{CITIZENSHIP:РФ}}
|
||||
Гражданин {{CITIZENSHIP:России}}
|
||||
Гражданство {{CITIZENSHIP:Республики Беларусь}}
|
||||
Место рождения {{BIRTH_PLACE:город Тверь}}
|
||||
|
||||
# --- Адрес: составляющие по отдельности и вместе ---
|
||||
Индекс {{ADDRESS_POSTCODE:614000}}
|
||||
Проживает по адресу: г. {{ADDRESS_CITY:Пермь}}, ул. {{ADDRESS_STREET:Ленина}}, д. {{ADDRESS_HOUSE:14}}, кв. {{ADDRESS_FLAT:56}}
|
||||
Зарегистрирован по адресу: {{ADDRESS_POSTCODE:220015}}, г. {{ADDRESS_CITY:Минск}}, ул. {{ADDRESS_STREET:Немига}}, д. {{ADDRESS_HOUSE:3}}
|
||||
Страна проживания {{ADDRESS_COUNTRY:Казахстан}}
|
||||
Курьер привезёт документы на Малую Бронную улицу к вечеру
|
||||
|
||||
# --- Email, телефон ---
|
||||
Свяжитесь по адресу {{EMAIL:artem.smirnov90@example.com}}
|
||||
Напишите на {{EMAIL:v.kuznetsova@mail.example}}
|
||||
Телефон {{PHONE:+7 903 445-67-12}}
|
||||
Позвоните на {{PHONE:8 (912) 334-56-78}}
|
||||
Мобильный: {{PHONE:+79261234567}}
|
||||
|
||||
# --- ИНН, СНИЛС, карта: контрольная сумма ---
|
||||
ИНН {{INN:7707083893}}
|
||||
ИНН физического лица {{INN:501001233116}}
|
||||
СНИЛС {{SNILS:112-233-445-95}}
|
||||
Номер карты {{CARD:4276 1234 5678 1235}}
|
||||
Оплата картой {{CARD:4276123456781235}} прошла успешно
|
||||
Держатель карты {{CARDHOLDER:IVAN PETROV}}
|
||||
на имя {{CARDHOLDER:ANNA VOLKOVA}}
|
||||
|
||||
# --- CVV/PIN: companion-правило (одни, без карты рядом, не маскируются) ---
|
||||
CVV 482
|
||||
Код проверки 917
|
||||
PIN 4821
|
||||
Пин-код 773311
|
||||
Оплата картой {{CARD:4276123456781235}}, CVV {{CVV:482}}, держатель {{CARDHOLDER:IVAN PETROV}}
|
||||
Оплата картой {{CARD:4276123456781235}}, пин-код {{PIN:4821}} не подошёл
|
||||
|
||||
# --- Дата без якоря: companion-правило ---
|
||||
Встреча назначена на 14.02.2026
|
||||
Клиент {{FIO:Соколов А.П.}} заходил {{DATE:14.02.2026}}
|
||||
|
||||
# --- Сложные предложения: несколько типов ПД в одной строке ---
|
||||
Клиент {{FIO:Гаврилов Степан Ильич}}, паспорт {{PASSPORT:4520 998877}}, тел. {{PHONE:+7 985 221-33-44}}, e-mail {{EMAIL:s.gavrilov@example.com}}
|
||||
{{FIO:Кузнецова Виктория Романовна}}, {{BIRTH_DATE:03.04.1992}} г.р., зарегистрирована по адресу: г. {{ADDRESS_CITY:Тверь}}, ул. {{ADDRESS_STREET:Советская}}, д. {{ADDRESS_HOUSE:21}}, кв. {{ADDRESS_FLAT:9}}
|
||||
Заёмщик {{FIO:Петров Виктор Николаевич}}, гражданство {{CITIZENSHIP:РФ}}, ИНН {{INN:7707083893}}, СНИЛС {{SNILS:112-233-445-95}}
|
||||
|
||||
# --- Обычные банковские вопросы без ПД ---
|
||||
Как заблокировать карту при утере
|
||||
Чем отличается ипотека от потребительского кредита
|
||||
Подготовь черновик ответа на жалобу клиента
|
||||
Объясни, что такое кредитный рейтинг
|
||||
Сколько действует справка о доходах
|
||||
Как подключить SMS-информирование
|
||||
Расскажи про программу лояльности банка
|
||||
Какие тарифы действуют для малого бизнеса
|
||||
Reference in New Issue
Block a user