v5.0.0: гибридные токены (Иванов_0001, ООО_Технология_0034), удалены generators/, checksum, random_utils

This commit is contained in:
2026-07-12 14:30:12 +04:00
parent 62c7ea0162
commit 370a869aef
19 changed files with 102 additions and 582 deletions
+69 -51
View File
@@ -5,90 +5,118 @@
1. scan_regex — быстрое regex-обнаружение (телефоны, email, ИНН, счета, компании, ФИО)
2. scan_llm_ner — универсальное LLM-обнаружение (любые приватные данные)
LLM САМА определяет какие типы сущностей присутствуют в документе.
Никакого фиксированного списка типов.
Замена: гибридные токены — читаемый шаблон + уникальный номер.
Пример: Иванов_0001, ООО_Технология_0034, ул_Ленина_0015
"""
import re
import json
import random
import logging
from typing import Dict
from .config import ENTITY_PATTERNS, COMPANY_PATTERN, PERSON_PATTERN
from .generators import ENTITY_GENERATORS, FALLBACK_GENERATOR
from .generators.company import generate_company
from .generators.person import generate_person
from .config import (
ENTITY_PATTERNS, COMPANY_PATTERN, PERSON_PATTERN,
RU_SURNAMES, COMPANY_NOUNS, CITY_POOL, STREET_POOL,
)
log = logging.getLogger("drhider")
def _get_generator(entity_type: str):
"""Получить генератор по типу сущности. Если тип неизвестен — fallback.
# ═══════════════════════════════════════════════════════════════════════════
# Гибридные токены: тип → (пул_шаблонов, префикс_для_счётчика)
# ═══════════════════════════════════════════════════════════════════════════
TYPE_POOLS = {
"person_name": (RU_SURNAMES, "person"),
"person": (RU_SURNAMES, "person"),
"company": (COMPANY_NOUNS, "company"),
"phone": (["+7_000_000"], "phone"),
"email": (["email"], "email"),
"address": (CITY_POOL, "address"),
"inn": (["ИНН"], "inn"),
"inn_ul": (["ИНН"], "inn"),
"inn_fl": (["ИНН"], "inn"),
"kpp": (["КПП"], "kpp"),
"ogrn": (["ОГРН"], "ogrn"),
"bik": (["БИК"], "bik"),
"passport": (["Паспорт"], "passport"),
"bank_account": (["Счёт"], "bank"),
"rs": (["РС"], "bank"),
"ks": (["КС"], "bank"),
"contract_number": (["Договор"], "contract"),
"other_id": (["ID"], "other"),
}
_FALLBACK_POOL = (["Данные"], "data")
def _next_token(entity_type: str, counters: Dict[str, int]) -> str:
"""Сгенерировать токен замены: случайный шаблон из пула + номер.
Одна и та же сущность в mapping получает один и тот же токен
(mapping dict гарантирует согласованность).
Разные сущности одного типа получают РАЗНЫЕ шаблоны из пула.
Args:
entity_type: Строковый идентификатор типа (например, "phone", "person_name")
entity_type: Тип сущности (person_name, company, phone...)
counters: Глобальные счётчики {prefix_key: count}
Returns:
Функция-генератор: (str) -> str
Строка вида "Иванов_0001", "ООО_Технология_0034", "[Адрес_0015]"
"""
return ENTITY_GENERATORS.get(entity_type, FALLBACK_GENERATOR)
pool, prefix_key = TYPE_POOLS.get(entity_type, _FALLBACK_POOL)
template = random.choice(pool)
key = f"{prefix_key}_{template}"
counters[key] = counters.get(key, 0) + 1
return f"{template}_{counters[key]:04d}"
log = logging.getLogger("drhider")
def scan_regex(text: str, mapping: Dict[str, str]) -> None:
def scan_regex(text: str, mapping: Dict[str, str], counters: Dict[str, int]) -> None:
"""Сканировать текст regex-паттернами, заполнить словарь замен.
Ищет в тексте:
- Сущности из ENTITY_PATTERNS (телефоны, email, ИНН, ОГРН, КПП, БИК, счета, паспорта)
- Названия компаний (кроме «НУБЕС» — это Исполнитель, не заменяем)
- ФИО в формате «Фамилия И.О.»
Найденные значения добавляются в mapping: оригинал → фиктивное значение.
Если сущность уже есть в mapping — не перезаписываем (согласованность).
Замена: гибридные токены (шаблон + номер).
Args:
text: Текст документа для сканирования
mapping: Словарь замен (мутабельный, пополняется)
counters: Глобальные счётчики токенов (мутабельный)
"""
# ── Сущности по regex-паттернам (телефоны, email, реквизиты) ──
# ── Сущности по regex-паттернам ──
for entity_type, pattern in ENTITY_PATTERNS.items():
for match in re.finditer(pattern, text, re.IGNORECASE | re.MULTILINE):
original = match.group(0).strip()
if not original or original in mapping:
continue
# Генератор: специализированный (если есть) или fallback
generator = _get_generator(entity_type)
mapping[original] = generator(original)
mapping[original] = _next_token(entity_type, counters)
# ── Названия компаний ──
for match in COMPANY_PATTERN.finditer(text):
original = match.group(0).strip()
if not original or original in mapping:
continue
mapping[original] = _next_token("company", counters)
mapping[original] = generate_company(original)
# ── ФИО (Фамилия И.О.) ──
# ── ФИО ──
for match in PERSON_PATTERN.finditer(text):
original = match.group(0).strip()
if not original or original in mapping:
continue
mapping[original] = generate_person(original)
mapping[original] = _next_token("person_name", counters)
def scan_llm_ner(all_texts: Dict[str, str], mapping: Dict[str, str], llm_client) -> None:
"""LLM NER для обнаружения имён, адресов, паспортных данных.
def scan_llm_ner(all_texts: Dict[str, str], mapping: Dict[str, str],
llm_client, counters: Dict[str, int]) -> None:
"""Универсальное LLM-обнаружение приватных данных.
Отправляет объединённый текст всех файлов в LLM, получает JSON-список
найденных сущностей, добавляет их в словарь замен.
Отправляются первые 3000 символов каждого файла (экономия токенов).
Общий размер промпта ограничен 8000 символами.
LLM САМА определяет типы. Замена — гибридные токены.
Args:
all_texts: {filename: text_content} — тексты всех файлов
mapping: Словарь замен (мутабельный, пополняется)
llm_client: Объект с методом .complete(prompt) -> str
counters: Глобальные счётчики токенов (мутабельный)
"""
# Формируем комбинированный текст: имя файла + первые 3000 символов
combined = "\n\n---FILE---\n\n".join(
@@ -100,28 +128,21 @@ def scan_llm_ner(all_texts: Dict[str, str], mapping: Dict[str, str], llm_client)
prompt = (
"You are a PII (Personally Identifiable Information) detector for Russian legal documents.\n\n"
"STEP 1 — Identify the service provider:\n"
"Read the contract and find which company/person is labeled 'Исполнитель' "
"(the party providing services). "
"This party's own name is NOT PII — do NOT include it in results.\n\n"
"STEP 2 — Find all PII that is NOT already captured below.\n\n"
"Find ALL sensitive or private information that is NOT already captured below.\n\n"
"Already captured by regex (skip these exact strings):\n"
f"{already_found}\n\n"
"Return a JSON array. Each item must have:\n"
' - "type": MUST be one of: person_name, company, phone, email, address,\n'
' inn, kpp, ogrn, bik, passport, contract_number, bank_account, other_id\n'
' inn, kpp, ogrn, bik, passport, bank_account, contract_number, other_id\n'
' Use "other_id" only if nothing else fits. If unsure — do NOT include.\n'
' - "value": the EXACT string from the text (copy verbatim)\n\n'
"NOT PII — do NOT include these (legal roles and terms, not private data):\n"
"NOT PII — do NOT include these (legal terms, not private data):\n"
"- Roles: Исполнитель, Заказчик, Стороны, Сторона, Покупатель, Поставщик\n"
"- Titles: Генеральный директор, Директор, Президент, действующего на основании\n"
"- Legal terms: Услуги, Договор, Приложение, Спецификация, НДС, Устава\n"
"- The 'Исполнитель' company name identified in STEP 1\n\n"
"- Titles: Генеральный директор, Директор, действующего на основании\n"
"- Legal terms: Услуги, Договор, Приложение, Спецификация, НДС, Устава\n\n"
"Rules:\n"
"- Copy value VERBATIM — same spaces, punctuation, case as in text\n"
@@ -152,10 +173,7 @@ def scan_llm_ner(all_texts: Dict[str, str], mapping: Dict[str, str], llm_client)
# Тип — любой (LLM сама придумала)
ent_type = ent.get("type", "").strip().lower().replace(" ", "_")
# Генератор: специализированный (если тип совпал) или fallback
generator = _get_generator(ent_type)
mapping[val] = generator(val)
mapping[val] = _next_token(ent_type, counters)
except Exception as e:
log.warning("LLM NER failed: %s", e)