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:
dakocha3
2026-09-23 21:40:53 +03:00
parent c209c4a366
commit 685ec977de
12 changed files with 384 additions and 14 deletions
+3 -3
View File
@@ -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` и корректным ключом.
---
+1 -1
View File
@@ -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()) {
+2
View File
@@ -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:
@@ -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(),
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 {
@@ -20,8 +20,10 @@ class TwoModelCascadeTest {
private List<Span> 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);
+33
View File
@@ -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")
+15
View File
@@ -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"
echo " pdguard.ner.legal-engine=ru-legal-ner, pdguard.ner.legal-model=models/ru-legal-ner"