feat: интеграция LLAIM Legal NER как третьей ступени распознавания
- Подключение ru-legal-ner (ONNX) для юридических реквизитов: ИНН, ОГРН, СНИЛС, паспорт, телефон, email, банковский счёт, дата. - Отдельный проход LEGAL_CANDIDATE, чтобы не вытеснять кандидатов имён и адресов. - coversAny(policy) в Pipeline вместо проверки только FIO. - Нормализация цифровых ПД (ИНН/СНИЛС/карта/ОГРН) в свободной форме. - Фикс ложного срабатывания ФИО на аббревиатуре «ИНН» (PD_MARKERS). - Рефакторинг конструкторов NameCascade через record EngineConfig (Sonar S107).
This commit is contained in:
@@ -223,9 +223,9 @@ payload_id=doc-1 символов=64 найдено={FIO=1, PASSPORT=1, PHONE=1}
|
|||||||
|
|
||||||
- Хранилище соответствий по умолчанию — в памяти (`pdguard.store.backend=memory`),
|
- Хранилище соответствий по умолчанию — в памяти (`pdguard.store.backend=memory`),
|
||||||
сбрасывается при перезапуске. Для кластера используется Redis.
|
сбрасывается при перезапуске. Для кластера используется Redis.
|
||||||
- NER-модель второй ступени (`models/rubert-ner`) не входит в репозиторий и
|
- NER-модели второй ступени (`models/rubert-ner`, `models/wikineural-ner`,
|
||||||
скачивается скриптом `tools/fetch-ner-model.sh`; без неё сервис работает на
|
`models/ru-legal-ner`) не входят в репозиторий и скачиваются скриптом
|
||||||
правилах.
|
`tools/fetch-ner-model.sh`; без них сервис работает на правилах.
|
||||||
- Демаскирование доступно только системам с `demask: true` и корректным ключом.
|
- Демаскирование доступно только системам с `demask: true` и корректным ключом.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
@@ -142,7 +142,7 @@ public class Pipeline {
|
|||||||
*/
|
*/
|
||||||
public List<Span> findPersonalData(String text, SystemPolicy policy) {
|
public List<Span> findPersonalData(String text, SystemPolicy policy) {
|
||||||
List<Span> spans = resolveOverlaps(registry.detect(text, policy));
|
List<Span> spans = resolveOverlaps(registry.detect(text, policy));
|
||||||
if (policy.allows(PdTypes.FIO)) {
|
if (cascade.coversAny(policy)) {
|
||||||
// Вторая ступень разбирает только то, что не покрыла первая.
|
// Вторая ступень разбирает только то, что не покрыла первая.
|
||||||
spans = resolveOverlaps(cascade.addMissedNames(text, spans));
|
spans = resolveOverlaps(cascade.addMissedNames(text, spans));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import org.slf4j.LoggerFactory;
|
|||||||
import org.springframework.beans.factory.annotation.Autowired;
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
import org.springframework.beans.factory.annotation.Value;
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
import org.springframework.stereotype.Component;
|
import org.springframework.stereotype.Component;
|
||||||
|
import ru.pdguard.config.SystemPolicy;
|
||||||
|
|
||||||
import java.nio.file.Path;
|
import java.nio.file.Path;
|
||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
@@ -17,6 +18,7 @@ import java.util.List;
|
|||||||
import java.util.Locale;
|
import java.util.Locale;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
|
import java.util.Set;
|
||||||
import java.util.concurrent.Semaphore;
|
import java.util.concurrent.Semaphore;
|
||||||
import java.util.concurrent.TimeUnit;
|
import java.util.concurrent.TimeUnit;
|
||||||
import java.util.regex.Matcher;
|
import java.util.regex.Matcher;
|
||||||
@@ -67,11 +69,37 @@ public class NameCascade {
|
|||||||
"STREET", PdTypes.ADDRESS_STREET,
|
"STREET", PdTypes.ADDRESS_STREET,
|
||||||
"HOUSE", PdTypes.ADDRESS_HOUSE);
|
"HOUSE", PdTypes.ADDRESS_HOUSE);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Метки LLAIM Legal NER в типы ПД: юридические реквизиты и документы, которых
|
||||||
|
* нет в общих моделях. ADDRESS не сопоставляется — ruBERT размечает адреса
|
||||||
|
* детальнее. ORG, CASE_NUMBER и POSITION аналогов в {@link PdTypes} не имеют.
|
||||||
|
*/
|
||||||
|
private static final Map<String, String> 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(
|
private static final Pattern CANDIDATE = Pattern.compile(
|
||||||
"\\p{Lu}[\\p{L}-]+(?:\\s+\\p{Lu}[\\p{L}-]+){1,2}",
|
"\\p{Lu}[\\p{L}-]+(?:\\s+\\p{Lu}[\\p{L}-]+){1,2}",
|
||||||
Pattern.UNICODE_CHARACTER_CLASS | Pattern.UNICODE_CASE);
|
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;
|
private static final int PRIORITY = 73;
|
||||||
|
|
||||||
@@ -80,6 +108,7 @@ public class NameCascade {
|
|||||||
|
|
||||||
private final RuBertRecogniser nameRecogniser;
|
private final RuBertRecogniser nameRecogniser;
|
||||||
private final RuBertRecogniser addressRecogniser;
|
private final RuBertRecogniser addressRecogniser;
|
||||||
|
private final RuBertRecogniser legalRecogniser;
|
||||||
private final Semaphore concurrent;
|
private final Semaphore concurrent;
|
||||||
private final int maxCandidates;
|
private final int maxCandidates;
|
||||||
private volatile boolean broken;
|
private volatile boolean broken;
|
||||||
@@ -101,12 +130,15 @@ public class NameCascade {
|
|||||||
@Value("${pdguard.ner.name-model:}") String nameModel,
|
@Value("${pdguard.ner.name-model:}") String nameModel,
|
||||||
@Value("${pdguard.ner.address-engine:off}") String addressEngine,
|
@Value("${pdguard.ner.address-engine:off}") String addressEngine,
|
||||||
@Value("${pdguard.ner.address-model:}") String addressModel,
|
@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.max-candidates:16}") int maxCandidates,
|
||||||
@Value("${pdguard.ner.pool-size:16}") int poolSize,
|
@Value("${pdguard.ner.pool-size:16}") int poolSize,
|
||||||
MeterRegistry meters) {
|
MeterRegistry meters) {
|
||||||
this.maxCandidates = maxCandidates;
|
this.maxCandidates = maxCandidates;
|
||||||
this.nameRecogniser = create(nameEngine, nameModel, NAME_TYPES);
|
this.nameRecogniser = create(nameEngine, nameModel, NAME_TYPES);
|
||||||
this.addressRecogniser = create(addressEngine, addressModel, ADDRESS_TYPES);
|
this.addressRecogniser = create(addressEngine, addressModel, ADDRESS_TYPES);
|
||||||
|
this.legalRecogniser = create(legalEngine, legalModel, LEGAL_TYPES);
|
||||||
this.concurrent = new Semaphore(Math.max(1, poolSize));
|
this.concurrent = new Semaphore(Math.max(1, poolSize));
|
||||||
this.engaged = Counter.builder(NER_REQUESTS_METRIC)
|
this.engaged = Counter.builder(NER_REQUESTS_METRIC)
|
||||||
.description(NER_REQUESTS_DESCRIPTION)
|
.description(NER_REQUESTS_DESCRIPTION)
|
||||||
@@ -123,16 +155,25 @@ public class NameCascade {
|
|||||||
.description("Время работы второй ступени").register(meters);
|
.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<String> modelPath, int maxCandidates, int poolSize) {
|
public NameCascade(String engine, Optional<String> 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<String> nameModel,
|
public NameCascade(String nameEngine, Optional<String> nameModel,
|
||||||
String addressEngine, Optional<String> addressModel,
|
String addressEngine, Optional<String> addressModel,
|
||||||
int maxCandidates, int poolSize) {
|
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());
|
maxCandidates, poolSize, new SimpleMeterRegistry());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -140,10 +181,21 @@ public class NameCascade {
|
|||||||
public NameCascade(String nameEngine, Optional<String> nameModel,
|
public NameCascade(String nameEngine, Optional<String> nameModel,
|
||||||
String addressEngine, Optional<String> addressModel,
|
String addressEngine, Optional<String> addressModel,
|
||||||
int maxCandidates, int poolSize, MeterRegistry meters) {
|
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);
|
maxCandidates, poolSize, meters);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Конструктор для тестов трёх моделей: метрики никуда не отдаются. */
|
||||||
|
public NameCascade(EngineConfig config, int maxCandidates, int poolSize) {
|
||||||
|
this(config, maxCandidates, poolSize, new SimpleMeterRegistry());
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Конфигурация трёх движков второй ступени: имя, адрес и юридические реквизиты. */
|
||||||
|
public record EngineConfig(String nameEngine, Optional<String> nameModel,
|
||||||
|
String addressEngine, Optional<String> addressModel,
|
||||||
|
String legalEngine, Optional<String> legalModel) {
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Выключенная ступень для служебных нужд — прогрева и тестов. Отдельный
|
* Выключенная ступень для служебных нужд — прогрева и тестов. Отдельный
|
||||||
* конструктор, а не обычный путь: иначе в журнале рядом с сообщением о готовности
|
* конструктор, а не обычный путь: иначе в журнале рядом с сообщением о готовности
|
||||||
@@ -154,6 +206,7 @@ public class NameCascade {
|
|||||||
this.maxCandidates = 0;
|
this.maxCandidates = 0;
|
||||||
this.nameRecogniser = null;
|
this.nameRecogniser = null;
|
||||||
this.addressRecogniser = null;
|
this.addressRecogniser = null;
|
||||||
|
this.legalRecogniser = null;
|
||||||
this.concurrent = new Semaphore(1);
|
this.concurrent = new Semaphore(1);
|
||||||
MeterRegistry meters = new SimpleMeterRegistry();
|
MeterRegistry meters = new SimpleMeterRegistry();
|
||||||
this.engaged = meters.counter(NER_REQUESTS_METRIC, OUTCOME_TAG, "engaged");
|
this.engaged = meters.counter(NER_REQUESTS_METRIC, OUTCOME_TAG, "engaged");
|
||||||
@@ -168,7 +221,25 @@ public class NameCascade {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public boolean enabled() {
|
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, nameRecogniser);
|
||||||
collect(text, m.start(), m.end(), found, addressRecogniser);
|
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);
|
candidates.increment(examined);
|
||||||
(examined > 0 ? engaged : withoutCandidates).increment();
|
(examined > 0 ? engaged : withoutCandidates).increment();
|
||||||
duration.record(System.nanoTime() - started, TimeUnit.NANOSECONDS);
|
duration.record(System.nanoTime() - started, TimeUnit.NANOSECONDS);
|
||||||
@@ -242,6 +326,15 @@ private void collect(String text, int candidateStart, int candidateEnd, List<Spa
|
|||||||
return spans.stream().anyMatch(span -> span.start() <= start && end <= span.end());
|
return spans.stream().anyMatch(span -> span.start() <= start && end <= span.end());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Слова-маркеры ПД, которые модель иногда ошибочно помечает как ФИО
|
||||||
|
* («ИНН», «СНИЛС», «паспорт»). Такие находки — шум: это не имена, а
|
||||||
|
* обозначения реквизитов, и маскировать их как ФИО нельзя.
|
||||||
|
*/
|
||||||
|
private static final Set<String> PD_MARKERS = Set.of(
|
||||||
|
"инн", "снилс", "огрн", "огрнип", "кпп", "бик", "паспорт", "счёт", "счет",
|
||||||
|
"телефон", "email", "почта", "дата", "адрес", "полис", "свидетельство");
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Принимает находку модели, если она пересекается с кандидатом и проходит
|
* Принимает находку модели, если она пересекается с кандидатом и проходит
|
||||||
* те же условия, что и находки правил.
|
* те же условия, что и находки правил.
|
||||||
@@ -252,6 +345,12 @@ private void collect(String text, int candidateStart, int candidateEnd, List<Spa
|
|||||||
if (span.start() >= candidateEnd || candidateStart >= span.end()) {
|
if (span.start() >= candidateEnd || candidateStart >= span.end()) {
|
||||||
return false;
|
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<Spa
|
|||||||
LOG.info("Вторая ступень распознавания выключена");
|
LOG.info("Вторая ступень распознавания выключена");
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
if (!"rubert".equals(chosen) && !"wikineural".equals(chosen)) {
|
if (!"rubert".equals(chosen) && !"wikineural".equals(chosen) && !"ru-legal-ner".equals(chosen)) {
|
||||||
LOG.warn("Неизвестный движок второй ступени: {}, ступень выключена", chosen);
|
LOG.warn("Неизвестный движок второй ступени: {}, ступень выключена", chosen);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -284,5 +383,8 @@ private void collect(String text, int candidateStart, int candidateEnd, List<Spa
|
|||||||
if (addressRecogniser != null) {
|
if (addressRecogniser != null) {
|
||||||
addressRecogniser.close();
|
addressRecogniser.close();
|
||||||
}
|
}
|
||||||
|
if (legalRecogniser != null) {
|
||||||
|
legalRecogniser.close();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -83,6 +83,15 @@ public final class NameDictionary {
|
|||||||
.map(stem -> stem.toLowerCase(Locale.ROOT))
|
.map(stem -> stem.toLowerCase(Locale.ROOT))
|
||||||
.collect(Collectors.toUnmodifiableSet());
|
.collect(Collectors.toUnmodifiableSet());
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Слова-маркеры персональных данных и реквизитов, которые по словообразованию
|
||||||
|
* совпадают с основами имён («ИНН» — основа имени «Инна») и потому ложно
|
||||||
|
* распознаются как ФИО. Это аббревиатуры, а не имена.
|
||||||
|
*/
|
||||||
|
private static final Set<String> PD_MARKERS = Set.of(
|
||||||
|
"инн", "снилс", "огрн", "огрнип", "кпп", "бик", "паспорт", "счёт", "счет",
|
||||||
|
"телефон", "email", "почта", "дата", "адрес", "полис", "свидетельство", "ву");
|
||||||
|
|
||||||
private NameDictionary() {
|
private NameDictionary() {
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -163,6 +172,9 @@ public final class NameDictionary {
|
|||||||
*/
|
*/
|
||||||
public static boolean isStandaloneNameCandidate(String word) {
|
public static boolean isStandaloneNameCandidate(String word) {
|
||||||
String lower = word.toLowerCase(Locale.ROOT);
|
String lower = word.toLowerCase(Locale.ROOT);
|
||||||
|
if (PD_MARKERS.contains(lower)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
if (GIVEN_NAMES.contains(lower)) {
|
if (GIVEN_NAMES.contains(lower)) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -52,6 +52,27 @@ public class RuleRegistry {
|
|||||||
PdTypes.ADDRESS_COUNTRY, PdTypes.ADDRESS_REGION, PdTypes.ADDRESS_DISTRICT, PdTypes.ADDRESS_CITY,
|
PdTypes.ADDRESS_COUNTRY, PdTypes.ADDRESS_REGION, PdTypes.ADDRESS_DISTRICT, PdTypes.ADDRESS_CITY,
|
||||||
PdTypes.ADDRESS_STREET, PdTypes.ADDRESS_HOUSE, PdTypes.ADDRESS_FLAT, PdTypes.ADDRESS_POSTCODE);
|
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(
|
||||||
|
"(?<!\\d)\\d(?:[\\s.\\-/()]?\\d){9,18}(?!\\d)");
|
||||||
|
|
||||||
|
/** Вычищает разделители из цифрового кластера: оставляет только цифры. */
|
||||||
|
private static final Pattern NON_DIGIT = Pattern.compile("[^\\d]");
|
||||||
|
|
||||||
public static boolean isAddressType(String type) {
|
public static boolean isAddressType(String type) {
|
||||||
return ADDRESS_TYPES.contains(type);
|
return ADDRESS_TYPES.contains(type);
|
||||||
}
|
}
|
||||||
@@ -89,9 +110,74 @@ public class RuleRegistry {
|
|||||||
}
|
}
|
||||||
collect(rule, text, found);
|
collect(rule, text, found);
|
||||||
}
|
}
|
||||||
|
collectNormalisedDigits(text, policy, found);
|
||||||
return found;
|
return found;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ищет цифровые ПД в свободной форме: последовательности цифр с произвольными
|
||||||
|
* разделителями, которые жёсткие шаблоны правил пропустили. Разделители
|
||||||
|
* вычищаются, и чистая строка проверяется контрольной суммой — ложные
|
||||||
|
* срабатывания отсекаются так же, как и в правилах.
|
||||||
|
*/
|
||||||
|
private static void collectNormalisedDigits(String text, SystemPolicy policy, List<Span> 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<Span> sink) {
|
private static void collect(Rule rule, String text, List<Span> sink) {
|
||||||
Matcher m = rule.pattern().matcher(text);
|
Matcher m = rule.pattern().matcher(text);
|
||||||
while (m.find()) {
|
while (m.find()) {
|
||||||
|
|||||||
@@ -55,6 +55,8 @@ pdguard:
|
|||||||
name-model: models/wikineural-ner
|
name-model: models/wikineural-ner
|
||||||
address-engine: off
|
address-engine: off
|
||||||
address-model: models/rubert-ner
|
address-model: models/rubert-ner
|
||||||
|
legal-engine: off
|
||||||
|
legal-model: models/ru-legal-ner
|
||||||
max-candidates: 16
|
max-candidates: 16
|
||||||
pool-size: 16
|
pool-size: 16
|
||||||
llm:
|
llm:
|
||||||
|
|||||||
@@ -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<Span> 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<Span> spans, String type) {
|
||||||
|
return spans.stream().anyMatch(s -> s.type().equals(type));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void recognisesInn() {
|
||||||
|
String text = "Договор между ООО «Ромашка», ИНН 7701234567, и Ивановым Иваном Ивановичем.";
|
||||||
|
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(hasType(spans, PdTypes.INN), "должно найти ИНН");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void recognisesPassport() {
|
||||||
|
String text = "Паспорт 4509 123456 выдан ОВД, СНИЛС 112-233-445 95.";
|
||||||
|
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(hasType(spans, PdTypes.PASSPORT), "должно найти паспорт");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -30,8 +30,10 @@ class TwoModelBenchmarkTest {
|
|||||||
private final Pipeline pipeline = new Pipeline(new RuleRegistry(), new Masker(),
|
private final Pipeline pipeline = new Pipeline(new RuleRegistry(), new Masker(),
|
||||||
new PayloadStore(10_000_000L, 30),
|
new PayloadStore(10_000_000L, 30),
|
||||||
new NameCascade(
|
new NameCascade(
|
||||||
"wikineural", Optional.of("models/wikineural-ner"),
|
new NameCascade.EngineConfig(
|
||||||
"rubert", Optional.of("models/rubert-ner"),
|
"wikineural", Optional.of("models/wikineural-ner"),
|
||||||
|
"rubert", Optional.of("models/rubert-ner"),
|
||||||
|
"off", Optional.empty()),
|
||||||
16, 4));
|
16, 4));
|
||||||
|
|
||||||
private static final class Score {
|
private static final class Score {
|
||||||
|
|||||||
@@ -20,8 +20,10 @@ class TwoModelCascadeTest {
|
|||||||
|
|
||||||
private List<Span> find(String text) {
|
private List<Span> find(String text) {
|
||||||
NameCascade cascade = new NameCascade(
|
NameCascade cascade = new NameCascade(
|
||||||
"wikineural", Optional.of("models/wikineural-ner"),
|
new NameCascade.EngineConfig(
|
||||||
"rubert", Optional.of("models/rubert-ner"),
|
"wikineural", Optional.of("models/wikineural-ner"),
|
||||||
|
"rubert", Optional.of("models/rubert-ner"),
|
||||||
|
"off", Optional.empty()),
|
||||||
16, 4);
|
16, 4);
|
||||||
Pipeline p = new Pipeline(new RuleRegistry(), new Masker(), new PayloadStore(1_000_000L, 30), cascade);
|
Pipeline p = new Pipeline(new RuleRegistry(), new Masker(), new PayloadStore(1_000_000L, 30), cascade);
|
||||||
return p.findPersonalData(text, SystemPolicy.DEFAULT);
|
return p.findPersonalData(text, SystemPolicy.DEFAULT);
|
||||||
|
|||||||
@@ -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")
|
||||||
@@ -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
|
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
|
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 "Готово. Включить:"
|
||||||
echo " pdguard.ner.name-engine=wikineural, pdguard.ner.name-model=models/wikineural-ner"
|
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"
|
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"
|
||||||
Reference in New Issue
Block a user