295 lines
14 KiB
Python
295 lines
14 KiB
Python
"""
|
||
Проход 1: обнаружение сущностей в тексте документов.
|
||
|
||
Два метода сканирования:
|
||
1. scan_regex — быстрое regex-обнаружение (телефоны, email, ИНН, счета, компании, ФИО)
|
||
2. scan_llm_ner — универсальное LLM-обнаружение (любые приватные данные)
|
||
|
||
Замена: гибридные токены — читаемый шаблон + уникальный номер.
|
||
Пример: Иванов_0001, ООО_Технология_0034, ул_Ленина_0015
|
||
"""
|
||
|
||
import re
|
||
import json
|
||
import random
|
||
import time
|
||
import logging
|
||
from concurrent.futures import ThreadPoolExecutor
|
||
from typing import Dict, List, Optional
|
||
|
||
from .config import (
|
||
ENTITY_PATTERNS, COMPANY_PATTERN, PERSON_PATTERN,
|
||
RU_SURNAMES, COMPANY_NOUNS, CITY_POOL, STREET_POOL,
|
||
)
|
||
|
||
log = logging.getLogger("drhider")
|
||
|
||
|
||
# ═══════════════════════════════════════════════════════════════════════════
|
||
# Гибридные токены: тип → (пул_шаблонов, префикс_для_счётчика)
|
||
# ═══════════════════════════════════════════════════════════════════════════
|
||
|
||
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: Тип сущности (person_name, company, phone...)
|
||
counters: Глобальные счётчики {prefix_key: count}
|
||
|
||
Returns:
|
||
Строка вида "Иванов_0001", "ООО_Технология_0034", "[Адрес_0015]"
|
||
"""
|
||
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], counters: Dict[str, int]) -> None:
|
||
"""Сканировать текст regex-паттернами, заполнить словарь замен.
|
||
|
||
Замена: гибридные токены (шаблон + номер).
|
||
|
||
Args:
|
||
text: Текст документа для сканирования
|
||
mapping: Словарь замен (мутабельный, пополняется)
|
||
counters: Глобальные счётчики токенов (мутабельный)
|
||
"""
|
||
# ── Сущности по 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
|
||
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)
|
||
|
||
# ── ФИО ──
|
||
for match in PERSON_PATTERN.finditer(text):
|
||
original = match.group(0).strip()
|
||
if not original or original in mapping:
|
||
continue
|
||
mapping[original] = _next_token("person_name", counters)
|
||
|
||
|
||
# ═══════════════════════════════════════════════════════════════════════════
|
||
# LLM-NER: пофайловый чанкинг (Sonnet-схема)
|
||
# ═══════════════════════════════════════════════════════════════════════════
|
||
|
||
class CancelRequested(Exception):
|
||
"""Обработка прервана пользователем (мягкая остановка между файлами LLM)."""
|
||
|
||
# Приоритет границ для чанков (от предпочтительных к жёстким)
|
||
_CHUNK_BOUNDARIES = ['\n\n', '\n', '. ', '? ', '! ', '; ', ', ', ' ']
|
||
_CHUNK_SIZE = 6000 # символов на чанк (≈1500-2000 токенов RU — NER-качество)
|
||
_CHUNK_OVERLAP = 500 # перекрытие чанков (сущности на стыке)
|
||
_LLM_CONCURRENCY = 1 # параллельность убрана — всё последовательно (liberta/LLM не тянет конкурентность)
|
||
|
||
|
||
def split_into_chunks(text: str, size: int = _CHUNK_SIZE,
|
||
overlap: int = _CHUNK_OVERLAP) -> List[str]:
|
||
"""Разбить текст на чанки ~size символов с перекрытием overlap.
|
||
|
||
Режем по границам (\n\n, \n, ..., пробел) — чтобы не разрывать слова/сущности.
|
||
"""
|
||
chunks: List[str] = []
|
||
start = 0
|
||
tl = len(text)
|
||
while start < tl:
|
||
end = min(start + size, tl)
|
||
if end == tl:
|
||
chunks.append(text[start:])
|
||
break
|
||
cut = None
|
||
for boundary in _CHUNK_BOUNDARIES:
|
||
pos = text.rfind(boundary, start + size // 2, end)
|
||
if pos != -1:
|
||
cut = pos + len(boundary)
|
||
break
|
||
if cut is None:
|
||
cut = end # жёсткий разрез — нет ни одного пробела/границы
|
||
chunks.append(text[start:cut])
|
||
# overlap: отступить назад, найти начало слова
|
||
ov_start = max(start, cut - overlap)
|
||
space = text.find(' ', ov_start)
|
||
start = (space + 1) if (space != -1 and space < cut) else ov_start
|
||
return chunks
|
||
|
||
|
||
def normalize_entity(s: str) -> str:
|
||
"""Нормализовать строку сущности для надёжного дедупа (регистр/пробелы/тире)."""
|
||
s = s.strip()
|
||
s = re.sub(r'\s+', ' ', s)
|
||
s = s.lower()
|
||
s = re.sub(r'[«»\u201c\u201d\u2018\u2019"\u0027]', '"', s)
|
||
s = re.sub(r'[\u2014\u2013\u2212-]', '-', s)
|
||
s = s.replace('\u00ad', '') # мягкий перенос
|
||
s = s.replace('ё', 'е') # Ё→Е (OCR/PDF)
|
||
return s
|
||
|
||
|
||
def _verify_entity(value: str, full_text: str) -> bool:
|
||
"""Верификация: сущность реально есть в полном тексте файла (отсекает галлюцинации)."""
|
||
if value in full_text:
|
||
return True
|
||
return normalize_entity(value) in normalize_entity(full_text)
|
||
|
||
|
||
def _build_llm_prompt(text: str, already_found: List[str]) -> str:
|
||
"""Собрать промпт для одного текстового фрагмента (чанк/файл)."""
|
||
af = "\n".join(f"- {k}" for k in already_found) if already_found else "(none)"
|
||
return (
|
||
"You are a PII (Personally Identifiable Information) detector for Russian legal documents.\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"{af}\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, 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 terms, not private data):\n"
|
||
"- Roles: Исполнитель, Заказчик, Стороны, Сторона, Покупатель, Поставщик\n"
|
||
"- Titles: Генеральный директор, Директор, действующего на основании\n"
|
||
"- Legal terms: Услуги, Договор, Приложение, Спецификация, НДС, Устава\n\n"
|
||
|
||
"Rules:\n"
|
||
"- Copy value VERBATIM — same spaces, punctuation, case as in text\n"
|
||
"- Same value → include only once\n"
|
||
"- Return ONLY the JSON array, no other text, no markdown wrapping\n\n"
|
||
|
||
f"Text:\n{text}"
|
||
)
|
||
|
||
|
||
def _call_llm(text: str, llm_client) -> List[dict]:
|
||
"""Один вызов LLM по фрагменту → список сущностей (пустой при ошибке)."""
|
||
try:
|
||
raw = llm_client.complete(text)
|
||
raw = raw.strip()
|
||
if raw.startswith("```"):
|
||
raw = raw.split("\n", 1)[1]
|
||
if raw.endswith("```"):
|
||
raw = raw[:-3]
|
||
entities = json.loads(raw)
|
||
if not isinstance(entities, list):
|
||
return []
|
||
return entities
|
||
except Exception as e:
|
||
log.warning("LLM NER failed: %s", e)
|
||
return []
|
||
|
||
|
||
def scan_llm_ner(all_texts: Dict[str, str], mapping: Dict[str, str],
|
||
llm_client, counters: Dict[str, int],
|
||
cancel_event=None, file_progress=None) -> None:
|
||
"""Универсальное LLM-обнаружение приватных данных (пофайлово, целиком).
|
||
|
||
Каждый файл обрабатывается ПОЛНОСТЬЮ: текст разбивается на чанки
|
||
(_CHUNK_SIZE=6000, overlap=_CHUNK_OVERLAP), чанки обрабатываются последовательно
|
||
(_LLM_CONCURRENCY), сущности дедуплицируются и верифицируются против полного
|
||
текста файла (отсечка галлюцинаций). regex-найденное не дублируется.
|
||
|
||
Args:
|
||
all_texts: {filename: text_content} — тексты всех файлов
|
||
mapping: Словарь замен (мутабельный, пополняется)
|
||
llm_client: Объект с методом .complete(prompt) -> str
|
||
counters: Глобальные счётчики токенов (мутабельный)
|
||
cancel_event: threading.Event — мягкая остановка между файлами (текущий добирается до конца)
|
||
file_progress: callable(event, fname, **fields) — прогресс по файлам/чанкам
|
||
"""
|
||
already_found = list(mapping.keys())
|
||
|
||
# Порядок файлов: крупные вперёд (приоритет на долгую обработку)
|
||
file_items = sorted(
|
||
all_texts.items(), key=lambda kv: len(kv[1]), reverse=True
|
||
)
|
||
|
||
for fname, full_text in file_items:
|
||
# Мягкая остановка ТОЛЬКО между файлами — текущий файл добирается до конца
|
||
if cancel_event is not None and cancel_event.is_set():
|
||
raise CancelRequested()
|
||
|
||
if not full_text or full_text.startswith("[DOC binary"):
|
||
continue
|
||
chunks = split_into_chunks(full_text)
|
||
|
||
if file_progress:
|
||
file_progress("file_start", fname, chars=len(full_text), chunks=len(chunks))
|
||
|
||
t0 = time.time()
|
||
|
||
# Параллельные вызовы LLM по чанкам этого файла
|
||
if len(chunks) > 1 and _LLM_CONCURRENCY > 1:
|
||
with ThreadPoolExecutor(max_workers=_LLM_CONCURRENCY) as _ex:
|
||
futures = [_ex.submit(_call_llm, _build_llm_prompt(c, mapping.keys()), llm_client)
|
||
for c in chunks]
|
||
all_ent = []
|
||
for fut in futures:
|
||
all_ent.extend(fut.result())
|
||
else:
|
||
all_ent = []
|
||
for k, c in enumerate(chunks):
|
||
all_ent.extend(_call_llm(_build_llm_prompt(c, mapping.keys()), llm_client))
|
||
if file_progress:
|
||
file_progress("file_chunk", fname,
|
||
chunks_done=k + 1, chunks_total=len(chunks))
|
||
|
||
# Дедуп + верификация + добавление в mapping
|
||
seen: set = set()
|
||
for ent in all_ent:
|
||
val = ent.get("value", "").strip()
|
||
if not val or val in mapping:
|
||
continue
|
||
nk = normalize_entity(val)
|
||
if nk in seen:
|
||
continue
|
||
seen.add(nk)
|
||
if not _verify_entity(val, full_text):
|
||
log.debug("LLM hallucination dropped: %r", val)
|
||
continue
|
||
ent_type = ent.get("type", "").strip().lower().replace(" ", "_")
|
||
mapping[val] = _next_token(ent_type, counters)
|
||
|
||
if file_progress:
|
||
file_progress("file_done", fname, elapsed=round(time.time() - t0, 2))
|