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:
@@ -142,7 +142,7 @@ public class Pipeline {
|
||||
*/
|
||||
public List<Span> findPersonalData(String text, SystemPolicy policy) {
|
||||
List<Span> spans = resolveOverlaps(registry.detect(text, policy));
|
||||
if (policy.allows(PdTypes.FIO)) {
|
||||
if (cascade.coversAny(policy)) {
|
||||
// Вторая ступень разбирает только то, что не покрыла первая.
|
||||
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.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<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(
|
||||
"\\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<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,
|
||||
String addressEngine, Optional<String> 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<String> nameModel,
|
||||
String addressEngine, Optional<String> 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<String> nameModel,
|
||||
String addressEngine, Optional<String> addressModel,
|
||||
String legalEngine, Optional<String> 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<Spa
|
||||
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()) {
|
||||
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("Вторая ступень распознавания выключена");
|
||||
return null;
|
||||
}
|
||||
if (!"rubert".equals(chosen) && !"wikineural".equals(chosen)) {
|
||||
if (!"rubert".equals(chosen) && !"wikineural".equals(chosen) && !"ru-legal-ner".equals(chosen)) {
|
||||
LOG.warn("Неизвестный движок второй ступени: {}, ступень выключена", chosen);
|
||||
return null;
|
||||
}
|
||||
@@ -284,5 +383,8 @@ private void collect(String text, int candidateStart, int candidateEnd, List<Spa
|
||||
if (addressRecogniser != null) {
|
||||
addressRecogniser.close();
|
||||
}
|
||||
if (legalRecogniser != null) {
|
||||
legalRecogniser.close();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -83,6 +83,15 @@ public final class NameDictionary {
|
||||
.map(stem -> stem.toLowerCase(Locale.ROOT))
|
||||
.collect(Collectors.toUnmodifiableSet());
|
||||
|
||||
/**
|
||||
* Слова-маркеры персональных данных и реквизитов, которые по словообразованию
|
||||
* совпадают с основами имён («ИНН» — основа имени «Инна») и потому ложно
|
||||
* распознаются как ФИО. Это аббревиатуры, а не имена.
|
||||
*/
|
||||
private static final Set<String> 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;
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
"(?<!\\d)\\d(?:[\\s.\\-/()]?\\d){9,18}(?!\\d)");
|
||||
|
||||
/** Вычищает разделители из цифрового кластера: оставляет только цифры. */
|
||||
private static final Pattern NON_DIGIT = Pattern.compile("[^\\d]");
|
||||
|
||||
public static boolean isAddressType(String type) {
|
||||
return ADDRESS_TYPES.contains(type);
|
||||
}
|
||||
@@ -89,9 +110,74 @@ public class RuleRegistry {
|
||||
}
|
||||
collect(rule, text, found);
|
||||
}
|
||||
collectNormalisedDigits(text, policy, 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) {
|
||||
Matcher m = rule.pattern().matcher(text);
|
||||
while (m.find()) {
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user