From 685ec977de9fb42f7542ac119d2cda3ac4c8dc1b Mon Sep 17 00:00:00 2001 From: dakocha3 Date: Wed, 23 Sep 2026 21:40:53 +0300 Subject: [PATCH] =?UTF-8?q?feat:=20=D0=B8=D0=BD=D1=82=D0=B5=D0=B3=D1=80?= =?UTF-8?q?=D0=B0=D1=86=D0=B8=D1=8F=20LLAIM=20Legal=20NER=20=D0=BA=D0=B0?= =?UTF-8?q?=D0=BA=20=D1=82=D1=80=D0=B5=D1=82=D1=8C=D0=B5=D0=B9=20=D1=81?= =?UTF-8?q?=D1=82=D1=83=D0=BF=D0=B5=D0=BD=D0=B8=20=D1=80=D0=B0=D1=81=D0=BF?= =?UTF-8?q?=D0=BE=D0=B7=D0=BD=D0=B0=D0=B2=D0=B0=D0=BD=D0=B8=D1=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Подключение ru-legal-ner (ONNX) для юридических реквизитов: ИНН, ОГРН, СНИЛС, паспорт, телефон, email, банковский счёт, дата. - Отдельный проход LEGAL_CANDIDATE, чтобы не вытеснять кандидатов имён и адресов. - coversAny(policy) в Pipeline вместо проверки только FIO. - Нормализация цифровых ПД (ИНН/СНИЛС/карта/ОГРН) в свободной форме. - Фикс ложного срабатывания ФИО на аббревиатуре «ИНН» (PD_MARKERS). - Рефакторинг конструкторов NameCascade через record EngineConfig (Sonar S107). --- README.md | 6 +- src/main/java/ru/pdguard/core/Pipeline.java | 2 +- .../java/ru/pdguard/detect/NameCascade.java | 112 +++++++++++++++++- .../ru/pdguard/detect/NameDictionary.java | 12 ++ .../java/ru/pdguard/detect/RuleRegistry.java | 86 ++++++++++++++ src/main/resources/application.yml | 2 + src/test/java/ru/pdguard/LegalNerTest.java | 57 +++++++++ .../java/ru/pdguard/NormalisedDigitsTest.java | 59 +++++++++ .../ru/pdguard/TwoModelBenchmarkTest.java | 6 +- .../java/ru/pdguard/TwoModelCascadeTest.java | 6 +- tools/convert_ru_legal_ner.py | 33 ++++++ tools/fetch-ner-model.sh | 17 ++- 12 files changed, 384 insertions(+), 14 deletions(-) create mode 100644 src/test/java/ru/pdguard/LegalNerTest.java create mode 100644 src/test/java/ru/pdguard/NormalisedDigitsTest.java create mode 100644 tools/convert_ru_legal_ner.py diff --git a/README.md b/README.md index f9cb7c3..7bef8f9 100644 --- a/README.md +++ b/README.md @@ -223,9 +223,9 @@ payload_id=doc-1 символов=64 найдено={FIO=1, PASSPORT=1, PHONE=1} - Хранилище соответствий по умолчанию — в памяти (`pdguard.store.backend=memory`), сбрасывается при перезапуске. Для кластера используется Redis. -- NER-модель второй ступени (`models/rubert-ner`) не входит в репозиторий и - скачивается скриптом `tools/fetch-ner-model.sh`; без неё сервис работает на - правилах. +- NER-модели второй ступени (`models/rubert-ner`, `models/wikineural-ner`, + `models/ru-legal-ner`) не входят в репозиторий и скачиваются скриптом + `tools/fetch-ner-model.sh`; без них сервис работает на правилах. - Демаскирование доступно только системам с `demask: true` и корректным ключом. --- diff --git a/src/main/java/ru/pdguard/core/Pipeline.java b/src/main/java/ru/pdguard/core/Pipeline.java index a3562da..a9b9f52 100644 --- a/src/main/java/ru/pdguard/core/Pipeline.java +++ b/src/main/java/ru/pdguard/core/Pipeline.java @@ -142,7 +142,7 @@ public class Pipeline { */ public List findPersonalData(String text, SystemPolicy policy) { List spans = resolveOverlaps(registry.detect(text, policy)); - if (policy.allows(PdTypes.FIO)) { + if (cascade.coversAny(policy)) { // Вторая ступень разбирает только то, что не покрыла первая. spans = resolveOverlaps(cascade.addMissedNames(text, spans)); } diff --git a/src/main/java/ru/pdguard/detect/NameCascade.java b/src/main/java/ru/pdguard/detect/NameCascade.java index 117051f..029bed6 100644 --- a/src/main/java/ru/pdguard/detect/NameCascade.java +++ b/src/main/java/ru/pdguard/detect/NameCascade.java @@ -10,6 +10,7 @@ import org.slf4j.LoggerFactory; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Component; +import ru.pdguard.config.SystemPolicy; import java.nio.file.Path; import java.util.ArrayList; @@ -17,6 +18,7 @@ import java.util.List; import java.util.Locale; import java.util.Map; import java.util.Optional; +import java.util.Set; import java.util.concurrent.Semaphore; import java.util.concurrent.TimeUnit; import java.util.regex.Matcher; @@ -67,11 +69,37 @@ public class NameCascade { "STREET", PdTypes.ADDRESS_STREET, "HOUSE", PdTypes.ADDRESS_HOUSE); + /** + * Метки LLAIM Legal NER в типы ПД: юридические реквизиты и документы, которых + * нет в общих моделях. ADDRESS не сопоставляется — ruBERT размечает адреса + * детальнее. ORG, CASE_NUMBER и POSITION аналогов в {@link PdTypes} не имеют. + */ + private static final Map LEGAL_TYPES = Map.of( + "PER", PdTypes.FIO, + "INN", PdTypes.INN, + "OGRN", PdTypes.OGRN, + "SNILS", PdTypes.SNILS, + "PASSPORT", PdTypes.PASSPORT, + "PHONE", PdTypes.PHONE, + "EMAIL", PdTypes.EMAIL, + "BANK_ACCOUNT", PdTypes.ACCOUNT_NUMBER, + "DATE", PdTypes.DATE); + /** Цепочка из двух-трёх слов с заглавной буквы — то, что может оказаться ПД. */ private static final Pattern CANDIDATE = Pattern.compile( "\\p{Lu}[\\p{L}-]+(?:\\s+\\p{Lu}[\\p{L}-]+){1,2}", Pattern.UNICODE_CHARACTER_CLASS | Pattern.UNICODE_CASE); + /** + * Кандидат для LLAIM Legal NER: одиночное слово или цифровой кластер + * (10–19 цифр с разделителями). Юридические реквизиты (ИНН, СНИЛС, паспорт) + * не являются словами с заглавной буквы, поэтому для них нужен отдельный + * проход, не влияющий на кандидатов моделей имён и адресов. + */ + private static final Pattern LEGAL_CANDIDATE = Pattern.compile( + "(?:\\p{Lu}[\\p{L}-]+|\\p{Ll}[\\p{L}-]+|\\d(?:[\\s.\\-/()]?\\d){9,18})", + Pattern.UNICODE_CHARACTER_CLASS | Pattern.UNICODE_CASE); + /** Приоритет находок второй ступени: ниже правил, у которых больше оснований. */ private static final int PRIORITY = 73; @@ -80,6 +108,7 @@ public class NameCascade { private final RuBertRecogniser nameRecogniser; private final RuBertRecogniser addressRecogniser; + private final RuBertRecogniser legalRecogniser; private final Semaphore concurrent; private final int maxCandidates; private volatile boolean broken; @@ -101,12 +130,15 @@ public class NameCascade { @Value("${pdguard.ner.name-model:}") String nameModel, @Value("${pdguard.ner.address-engine:off}") String addressEngine, @Value("${pdguard.ner.address-model:}") String addressModel, + @Value("${pdguard.ner.legal-engine:off}") String legalEngine, + @Value("${pdguard.ner.legal-model:}") String legalModel, @Value("${pdguard.ner.max-candidates:16}") int maxCandidates, @Value("${pdguard.ner.pool-size:16}") int poolSize, MeterRegistry meters) { this.maxCandidates = maxCandidates; this.nameRecogniser = create(nameEngine, nameModel, NAME_TYPES); this.addressRecogniser = create(addressEngine, addressModel, ADDRESS_TYPES); + this.legalRecogniser = create(legalEngine, legalModel, LEGAL_TYPES); this.concurrent = new Semaphore(Math.max(1, poolSize)); this.engaged = Counter.builder(NER_REQUESTS_METRIC) .description(NER_REQUESTS_DESCRIPTION) @@ -123,16 +155,25 @@ public class NameCascade { .description("Время работы второй ступени").register(meters); } + /** Конструктор для тестов: движки задаются конфигом, метрики — реестром. */ + private NameCascade(EngineConfig config, int maxCandidates, int poolSize, MeterRegistry meters) { + this(config.nameEngine(), config.nameModel().orElse(""), + config.addressEngine(), config.addressModel().orElse(""), + config.legalEngine(), config.legalModel().orElse(""), + maxCandidates, poolSize, meters); + } + /** Конструктор для тестов: одна модель для имён, метрики никуда не отдаются. */ public NameCascade(String engine, Optional modelPath, int maxCandidates, int poolSize) { - this(engine, modelPath.orElse(""), "off", "", maxCandidates, poolSize, new SimpleMeterRegistry()); + this(new EngineConfig(engine, modelPath, "off", Optional.empty(), "off", Optional.empty()), + maxCandidates, poolSize, new SimpleMeterRegistry()); } /** Конструктор для тестов двух моделей: метрики никуда не отдаются. */ public NameCascade(String nameEngine, Optional nameModel, String addressEngine, Optional addressModel, int maxCandidates, int poolSize) { - this(nameEngine, nameModel.orElse(""), addressEngine, addressModel.orElse(""), + this(new EngineConfig(nameEngine, nameModel, addressEngine, addressModel, "off", Optional.empty()), maxCandidates, poolSize, new SimpleMeterRegistry()); } @@ -140,10 +181,21 @@ public class NameCascade { public NameCascade(String nameEngine, Optional nameModel, String addressEngine, Optional addressModel, int maxCandidates, int poolSize, MeterRegistry meters) { - this(nameEngine, nameModel.orElse(""), addressEngine, addressModel.orElse(""), + this(new EngineConfig(nameEngine, nameModel, addressEngine, addressModel, "off", Optional.empty()), maxCandidates, poolSize, meters); } + /** Конструктор для тестов трёх моделей: метрики никуда не отдаются. */ + public NameCascade(EngineConfig config, int maxCandidates, int poolSize) { + this(config, maxCandidates, poolSize, new SimpleMeterRegistry()); + } + + /** Конфигурация трёх движков второй ступени: имя, адрес и юридические реквизиты. */ + public record EngineConfig(String nameEngine, Optional nameModel, + String addressEngine, Optional addressModel, + String legalEngine, Optional legalModel) { + } + /** * Выключенная ступень для служебных нужд — прогрева и тестов. Отдельный * конструктор, а не обычный путь: иначе в журнале рядом с сообщением о готовности @@ -154,6 +206,7 @@ public class NameCascade { this.maxCandidates = 0; this.nameRecogniser = null; this.addressRecogniser = null; + this.legalRecogniser = null; this.concurrent = new Semaphore(1); MeterRegistry meters = new SimpleMeterRegistry(); this.engaged = meters.counter(NER_REQUESTS_METRIC, OUTCOME_TAG, "engaged"); @@ -168,7 +221,25 @@ public class NameCascade { } public boolean enabled() { - return (nameRecogniser != null || addressRecogniser != null) && !broken; + return (nameRecogniser != null || addressRecogniser != null || legalRecogniser != null) && !broken; + } + + /** + * Покрывает ли каскад хоть один тип, разрешённый политикой. Нужно, чтобы + * {@code Pipeline} звал вторую ступень не только ради ФИО, но и ради адресов + * и юридических реквизитов, которые размечает LLAIM Legal NER. + */ + public boolean coversAny(SystemPolicy policy) { + if (!enabled()) { + return false; + } + return policy.allows(PdTypes.FIO) + || policy.allows(PdTypes.ADDRESS_COUNTRY) || policy.allows(PdTypes.ADDRESS_REGION) + || policy.allows(PdTypes.ADDRESS_DISTRICT) || policy.allows(PdTypes.ADDRESS_CITY) + || policy.allows(PdTypes.ADDRESS_STREET) || policy.allows(PdTypes.ADDRESS_HOUSE) + || policy.allows(PdTypes.INN) || policy.allows(PdTypes.OGRN) || policy.allows(PdTypes.SNILS) + || policy.allows(PdTypes.PASSPORT) || policy.allows(PdTypes.PHONE) || policy.allows(PdTypes.EMAIL) + || policy.allows(PdTypes.ACCOUNT_NUMBER) || policy.allows(PdTypes.DATE); } /** @@ -197,6 +268,19 @@ public class NameCascade { collect(text, m.start(), m.end(), found, nameRecogniser); collect(text, m.start(), m.end(), found, addressRecogniser); } + // LLAIM Legal NER ищет реквизиты (ИНН, СНИЛС, паспорт), которые не + // являются словами с заглавной буквы, — отдельный проход по своим + // кандидатам, чтобы не вытеснять кандидатов моделей имён и адресов. + if (legalRecogniser != null) { + Matcher lm = LEGAL_CANDIDATE.matcher(text); + while (lm.find() && examined < maxCandidates) { + if (fullyCovered(found, lm.start(), lm.end())) { + continue; + } + examined++; + collect(text, lm.start(), lm.end(), found, legalRecogniser); + } + } candidates.increment(examined); (examined > 0 ? engaged : withoutCandidates).increment(); duration.record(System.nanoTime() - started, TimeUnit.NANOSECONDS); @@ -242,6 +326,15 @@ private void collect(String text, int candidateStart, int candidateEnd, List span.start() <= start && end <= span.end()); } + /** + * Слова-маркеры ПД, которые модель иногда ошибочно помечает как ФИО + * («ИНН», «СНИЛС», «паспорт»). Такие находки — шум: это не имена, а + * обозначения реквизитов, и маскировать их как ФИО нельзя. + */ + private static final Set PD_MARKERS = Set.of( + "инн", "снилс", "огрн", "огрнип", "кпп", "бик", "паспорт", "счёт", "счет", + "телефон", "email", "почта", "дата", "адрес", "полис", "свидетельство"); + /** * Принимает находку модели, если она пересекается с кандидатом и проходит * те же условия, что и находки правил. @@ -252,6 +345,12 @@ private void collect(String text, int candidateStart, int candidateEnd, List= candidateEnd || candidateStart >= span.end()) { return false; } + // Модель с приоритетом recall иногда помечает слово-маркер реквизита + // («ИНН») как ФИО. Такое значение именем не является. + if (PdTypes.FIO.equals(span.type()) + && PD_MARKERS.contains(text.substring(span.start(), span.end()).toLowerCase(Locale.ROOT))) { + return false; + } // Адресные типы принимаются на тех же условиях, что и от правил: рядом // должны быть другие части адреса. Иначе «Спартак Москва» и «Проспект // Вернадского» попадали бы под маску наравне с адресом клиента. @@ -265,7 +364,7 @@ private void collect(String text, int candidateStart, int candidateEnd, List stem.toLowerCase(Locale.ROOT)) .collect(Collectors.toUnmodifiableSet()); + /** + * Слова-маркеры персональных данных и реквизитов, которые по словообразованию + * совпадают с основами имён («ИНН» — основа имени «Инна») и потому ложно + * распознаются как ФИО. Это аббревиатуры, а не имена. + */ + private static final Set PD_MARKERS = Set.of( + "инн", "снилс", "огрн", "огрнип", "кпп", "бик", "паспорт", "счёт", "счет", + "телефон", "email", "почта", "дата", "адрес", "полис", "свидетельство", "ву"); + private NameDictionary() { } @@ -163,6 +172,9 @@ public final class NameDictionary { */ public static boolean isStandaloneNameCandidate(String word) { String lower = word.toLowerCase(Locale.ROOT); + if (PD_MARKERS.contains(lower)) { + return false; + } if (GIVEN_NAMES.contains(lower)) { return true; } diff --git a/src/main/java/ru/pdguard/detect/RuleRegistry.java b/src/main/java/ru/pdguard/detect/RuleRegistry.java index cbb928e..1ab8dbf 100644 --- a/src/main/java/ru/pdguard/detect/RuleRegistry.java +++ b/src/main/java/ru/pdguard/detect/RuleRegistry.java @@ -52,6 +52,27 @@ public class RuleRegistry { PdTypes.ADDRESS_COUNTRY, PdTypes.ADDRESS_REGION, PdTypes.ADDRESS_DISTRICT, PdTypes.ADDRESS_CITY, PdTypes.ADDRESS_STREET, PdTypes.ADDRESS_HOUSE, PdTypes.ADDRESS_FLAT, PdTypes.ADDRESS_POSTCODE); + /** + * Приоритет находок нормализации цифровых ПД: выше правила ИНН без якоря (62), + * ниже якорных правил (84+). Нормализация находит то, что жёсткие шаблоны + * пропустили из-за нестандартных разделителей, и не должна перебивать находки + * с якорным словом. + */ + private static final int NORMALISED_PRIORITY = 63; + + /** + * Цифровой кластер: от 10 до 19 цифр с произвольными разделителями между ними + * (пробел, дефис, точка, слэш, скобки). Негативные просмотры не дают захватить + * часть более длинного числа. Разделители вычищаются, и чистая цифровая строка + * прогоняется через контрольную сумму — так находятся ИНН/СНИЛС/карта/ОГРН(ИП) + * в свободной форме, где жёсткий шаблон ломается на нестандартном разделителе. + */ + private static final Pattern DIGIT_CLUSTER = Pattern.compile( + "(? sink) { + if (!policy.allows(PdTypes.CARD) && !policy.allows(PdTypes.INN) && !policy.allows(PdTypes.SNILS) + && !policy.allows(PdTypes.OGRN) && !policy.allows(PdTypes.OGRNIP)) { + return; + } + Matcher m = DIGIT_CLUSTER.matcher(text); + while (m.find()) { + String digits = NON_DIGIT.matcher(m.group()).replaceAll(""); + String type = typeFor(digits); + if (type != null && policy.allows(type)) { + sink.add(new Span(m.start(), m.end(), type, NORMALISED_PRIORITY)); + } + } + } + + /** + * Определяет тип ПД по чистой цифровой строке и контрольной сумме. Для 13 и 15 + * цифр сначала пробуются ОГРН/ОГРНИП: они специфичнее карты по длине, и валидный + * ОГРН не должен случайно стать номером карты (карта самостоятельна, ОГРН — только + * спутник, и одинокий ОГРН убирается в {@code Pipeline}). + */ + private static String typeFor(String digits) { + int length = digits.length(); + switch (length) { + case 10, 12: + return Validators.inn(digits) ? PdTypes.INN : null; + case 11: + return Validators.snils(digits) ? PdTypes.SNILS : null; + case 13: + return ogrnOrCard(digits); + case 15: + return ogrnipOrCard(digits); + default: + return cardIfLuhn(digits); + } + } + + private static String ogrnOrCard(String digits) { + if (Validators.ogrn(digits)) { + return PdTypes.OGRN; + } + return Validators.luhn(digits) ? PdTypes.CARD : null; + } + + private static String ogrnipOrCard(String digits) { + if (Validators.ogrnip(digits)) { + return PdTypes.OGRNIP; + } + return Validators.luhn(digits) ? PdTypes.CARD : null; + } + + private static String cardIfLuhn(String digits) { + if (digits.length() >= 13 && digits.length() <= 19 && Validators.luhn(digits)) { + return PdTypes.CARD; + } + return null; + } + private static void collect(Rule rule, String text, List sink) { Matcher m = rule.pattern().matcher(text); while (m.find()) { diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index d1cbfc8..1d227d1 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -55,6 +55,8 @@ pdguard: name-model: models/wikineural-ner address-engine: off address-model: models/rubert-ner + legal-engine: off + legal-model: models/ru-legal-ner max-candidates: 16 pool-size: 16 llm: diff --git a/src/test/java/ru/pdguard/LegalNerTest.java b/src/test/java/ru/pdguard/LegalNerTest.java new file mode 100644 index 0000000..9d6167e --- /dev/null +++ b/src/test/java/ru/pdguard/LegalNerTest.java @@ -0,0 +1,57 @@ +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.NameCascade; +import ru.pdguard.detect.PdTypes; +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; + +/** LLAIM Legal NER: юридические реквизиты и документы, которых нет в общих моделях. */ +class LegalNerTest { + + private List find(String text) { + NameCascade cascade = new NameCascade( + new NameCascade.EngineConfig( + "off", Optional.empty(), + "off", Optional.empty(), + "ru-legal-ner", Optional.of("models/ru-legal-ner")), + 16, 4); + Pipeline p = new Pipeline(new RuleRegistry(), new Masker(), new PayloadStore(1_000_000L, 30), cascade); + return p.findPersonalData(text, SystemPolicy.DEFAULT); + } + + private boolean hasType(List spans, String type) { + return spans.stream().anyMatch(s -> s.type().equals(type)); + } + + @Test + void recognisesInn() { + String text = "Договор между ООО «Ромашка», ИНН 7701234567, и Ивановым Иваном Ивановичем."; + List spans = find(text); + System.out.println("TEXT: " + text); + for (Span s : spans) { + System.out.println(" -> " + s.type() + " [" + text.substring(s.start(), s.end()) + "]"); + } + assertTrue(hasType(spans, PdTypes.INN), "должно найти ИНН"); + } + + @Test + void recognisesPassport() { + String text = "Паспорт 4509 123456 выдан ОВД, СНИЛС 112-233-445 95."; + List spans = find(text); + System.out.println("TEXT: " + text); + for (Span s : spans) { + System.out.println(" -> " + s.type() + " [" + text.substring(s.start(), s.end()) + "]"); + } + assertTrue(hasType(spans, PdTypes.PASSPORT), "должно найти паспорт"); + } +} \ No newline at end of file diff --git a/src/test/java/ru/pdguard/NormalisedDigitsTest.java b/src/test/java/ru/pdguard/NormalisedDigitsTest.java new file mode 100644 index 0000000..809ca68 --- /dev/null +++ b/src/test/java/ru/pdguard/NormalisedDigitsTest.java @@ -0,0 +1,59 @@ +package ru.pdguard; + +import org.junit.jupiter.api.Test; +import ru.pdguard.config.SystemPolicy; +import ru.pdguard.core.PayloadStore; +import ru.pdguard.core.Pipeline; +import ru.pdguard.detect.RuleRegistry; +import ru.pdguard.mask.Masker; + +import java.util.UUID; + +import static org.junit.jupiter.api.Assertions.assertFalse; + +/** + * Нормализация цифровых ПД: находит ИНН/СНИЛС/карту/ОГРН(ИП) в свободной форме, + * где жёсткий шаблон ломается на нестандартном разделителе. + */ +class NormalisedDigitsTest { + + 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 + "»"); + } + + @Test + void masksCardWithDots() { + assertHidden("Карта 4111.1111.1111.1111 клиента", "4111.1111.1111.1111"); + } + + @Test + void masksCardWithSlashes() { + assertHidden("Оплата картой 4111/1111/1111/1111 прошла", "4111/1111/1111/1111"); + } + + @Test + void masksCardWithMixedSeparators() { + assertHidden("Номер карты 4111-1111 1111.1111 клиента", "4111-1111 1111.1111"); + } + + @Test + void masksInnWithDashes() { + assertHidden("ИНН: 7703-0123-4550 плательщика", "7703-0123-4550"); + } + + @Test + void masksSnilsWithDots() { + assertHidden("СНИЛС 112.233.445.95 застрахованного", "112.233.445.95"); + } + + @Test + void keepsNumberThatFailsChecksum() { + String text = "Заказ 1234 5678 9012 3456 отгружен"; + org.junit.jupiter.api.Assertions.assertEquals(text, + pipeline.process(text, UUID.randomUUID().toString(), SystemPolicy.DEFAULT)); + } +} \ No newline at end of file diff --git a/src/test/java/ru/pdguard/TwoModelBenchmarkTest.java b/src/test/java/ru/pdguard/TwoModelBenchmarkTest.java index 511f635..7982958 100644 --- a/src/test/java/ru/pdguard/TwoModelBenchmarkTest.java +++ b/src/test/java/ru/pdguard/TwoModelBenchmarkTest.java @@ -30,8 +30,10 @@ class TwoModelBenchmarkTest { private final Pipeline pipeline = new Pipeline(new RuleRegistry(), new Masker(), new PayloadStore(10_000_000L, 30), new NameCascade( - "wikineural", Optional.of("models/wikineural-ner"), - "rubert", Optional.of("models/rubert-ner"), + 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 { diff --git a/src/test/java/ru/pdguard/TwoModelCascadeTest.java b/src/test/java/ru/pdguard/TwoModelCascadeTest.java index 2f674ef..bd6d75e 100644 --- a/src/test/java/ru/pdguard/TwoModelCascadeTest.java +++ b/src/test/java/ru/pdguard/TwoModelCascadeTest.java @@ -20,8 +20,10 @@ class TwoModelCascadeTest { private List find(String text) { NameCascade cascade = new NameCascade( - "wikineural", Optional.of("models/wikineural-ner"), - "rubert", Optional.of("models/rubert-ner"), + 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); diff --git a/tools/convert_ru_legal_ner.py b/tools/convert_ru_legal_ner.py new file mode 100644 index 0000000..c54932e --- /dev/null +++ b/tools/convert_ru_legal_ner.py @@ -0,0 +1,33 @@ +#!/usr/bin/env python3 +"""Конвертация LLAIMlegal/ru-legal-ner в ONNX для RuBertRecogniser. + +Модель — BertForTokenClassification. Экспортируем в ONNX с динамической +длиной входа, чтобы RuBertRecogniser мог подавать куски разной длины. +""" +import torch +from transformers import AutoTokenizer, AutoModelForTokenClassification +from pathlib import Path + +SRC = "LLAIMlegal/ru-legal-ner" +OUT = Path("models/ru-legal-ner") + +tokenizer = AutoTokenizer.from_pretrained(SRC) +model = AutoModelForTokenClassification.from_pretrained(SRC) +model.eval() + +# Динамическая длина: batch=1, seq=dynamic +dummy = torch.zeros(1, 8, dtype=torch.long) +torch.onnx.export( + model, + (dummy, dummy, dummy), + str(OUT / "model.onnx"), + input_names=["input_ids", "attention_mask", "token_type_ids"], + output_names=["logits"], + dynamic_shapes=[ + {0: "batch", 1: "seq"}, + {0: "batch", 1: "seq"}, + {0: "batch", 1: "seq"}, + ], + opset_version=14, +) +print("ONNX exported to", OUT / "model.onnx") \ No newline at end of file diff --git a/tools/fetch-ner-model.sh b/tools/fetch-ner-model.sh index b2a7d1e..0d66c4c 100755 --- a/tools/fetch-ner-model.sh +++ b/tools/fetch-ner-model.sh @@ -40,6 +40,21 @@ fetch "https://huggingface.co/onnx-community/bert-base-NER-Russian-ONNX/resolve/ mv -f models/rubert-ner/onnx/model_int8.onnx models/rubert-ner/model.onnx 2>/dev/null || true rmdir models/rubert-ner/onnx 2>/dev/null || true +# LLAIM Legal NER (LLAIMlegal/ru-legal-ner, MIT) — юридические реквизиты и +# документы: ИНН, ОГРН, СНИЛС, паспорт, телефон, email, банковский счёт, дата. +# Готового ONNX нет — скачиваем веса и конвертируем скриптом (нужны torch, +# transformers, optimum, onnx). +fetch "https://huggingface.co/LLAIMlegal/ru-legal-ner/resolve/main" \ + models/ru-legal-ner model.safetensors \ + config.json tokenizer.json tokenizer_config.json + +if command -v python3 >/dev/null 2>&1; then + python3 tools/convert_ru_legal_ner.py || echo "Не удалось сконвертировать ru-legal-ner в ONNX" +else + echo "python3 не найден: ru-legal-ner не сконвертирован в ONNX" +fi + echo "Готово. Включить:" echo " pdguard.ner.name-engine=wikineural, pdguard.ner.name-model=models/wikineural-ner" -echo " pdguard.ner.address-engine=rubert, pdguard.ner.address-model=models/rubert-ner" \ No newline at end of file +echo " pdguard.ner.address-engine=rubert, pdguard.ner.address-model=models/rubert-ner" +echo " pdguard.ner.legal-engine=ru-legal-ner, pdguard.ner.legal-model=models/ru-legal-ner" \ No newline at end of file