v0.0.57: LLM обрабатывает ВСЕ данные — пофайловый чанкинг (6000/overlap 500), параллельность 4, дедуп+верификация против полного текста
Deploy drhider / validate (push) Canceled after 0s

This commit is contained in:
“Naeel”
2026-08-20 10:01:01 +03:00
parent da0ea5e964
commit a9d4139afb
3 changed files with 166 additions and 32 deletions
+41
View File
@@ -0,0 +1,41 @@
# 2026-08-20 — LLM обрабатывает ВСЕ данные (чанкинг) — v0.0.57
## Задача
Пользователь: LLM должен обрабатывать ВСЕ данные, а не урезку 8000 символов.
Ветка сохранения пре-LLM конфигурации: `save-pre-llm-v0.0.56` (указывает на прежний master).
## Изменения (drhider/scanner.py)
`scan_llm_ner` переписан:
- Убраны глобальные урезки `t[:3000]` и `combined[:8000]`.
- Каждый файл обрабатывается ПОЛНОСТЬЮ: split_into_chunks(6000, overlap 500).
- Чанки файла — параллельно (ThreadPoolExecutor, concurrency 4), крупные файлы вперёд.
- Для каждого чанка — полный промпт + «Already captured by regex» (mapping).
- Дедуп найденного через normalize_entity (пробелы/регистр/кавычки/тире/ё).
- Верификация _verify_entity против ПОЛНОГО текста файла — отсекает галлюцинации LLM.
- regex-найденное не дублируется.
Новые хелперы (scanner.py):
- split_into_chunks(text, size=6000, overlap=500) — границы \n\n,\n,. ,? ,! ,; ,, ," "
- normalize_entity(s) — канонизация для дедупа
- _verify_entity(value, full_text) — точное/нормализованное вхождение
- _build_llm_prompt(text, already_found)
- _call_llm(text, llm_client) — вызов + разбор JSON (пусто при ошибке)
Параметры верхнего уровня (легко тюнить):
- _CHUNK_SIZE = 6000, _CHUNK_OVERLAP = 500, _LLM_CONCURRENCY = 4
## Ожидаемый эффект
- LLM покрывает все файлы целиком (в т.ч. хвосты и таблицы после 3000 символа).
- Время на ~300 файлов ≈ 3.5 мин при 4 потоках (~390 вызовов).
- Меньше галлюцинаций (верификация).
## Проверка
- py_compile OK.
- test_scanner 20/20.
- Юнит хелперов: split (12К→4 чанка ≤6100), verify (галлюцинация→False), normalize OK.
- Все 106 тестов OK.
- VERSION 0.0.57.
## Примечание
Реальная проверка LLM-покрытия и времени — на наборе T/ при прогоне на проде/локально
с ключом LLM (локально ключ отсутствует → LLM не выполняется, только regex).
+124 -31
View File
@@ -13,7 +13,8 @@ import re
import json
import random
import logging
from typing import Dict
from concurrent.futures import ThreadPoolExecutor
from typing import Dict, List, Optional
from .config import (
ENTITY_PATTERNS, COMPANY_PATTERN, PERSON_PATTERN,
@@ -106,32 +107,75 @@ def scan_regex(text: str, mapping: Dict[str, str], counters: Dict[str, int]) ->
mapping[original] = _next_token("person_name", counters)
def scan_llm_ner(all_texts: Dict[str, str], mapping: Dict[str, str],
llm_client, counters: Dict[str, int]) -> None:
"""Универсальное LLM-обнаружение приватных данных.
# ═══════════════════════════════════════════════════════════════════════════
# LLM-NER: пофайловый чанкинг (Sonnet-схема)
# ═══════════════════════════════════════════════════════════════════════════
LLM САМА определяет типы. Замена — гибридные токены.
# Приоритет границ для чанков (от предпочтительных к жёстким)
_CHUNK_BOUNDARIES = ['\n\n', '\n', '. ', '? ', '! ', '; ', ', ', ' ']
_CHUNK_SIZE = 6000 # символов на чанк (≈1500-2000 токенов RU — NER-качество)
_CHUNK_OVERLAP = 500 # перекрытие чанков (сущности на стыке)
_LLM_CONCURRENCY = 4 # параллельных LLM-вызовов
Args:
all_texts: {filename: text_content} — тексты всех файлов
mapping: Словарь замен (мутабельный, пополняется)
llm_client: Объект с методом .complete(prompt) -> str
counters: Глобальные счётчики токенов (мутабельный)
def split_into_chunks(text: str, size: int = _CHUNK_SIZE,
overlap: int = _CHUNK_OVERLAP) -> List[str]:
"""Разбить текст на чанки ~size символов с перекрытием overlap.
Режем по границам (\n\n, \n, ..., пробел) — чтобы не разрывать слова/сущности.
"""
# Формируем комбинированный текст: имя файла + первые 3000 символов
combined = "\n\n---FILE---\n\n".join(
f"FILE: {fname}\n{t[:3000]}" for fname, t in all_texts.items()
)
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
# ── Универсальный промпт: STEP 1 (Исполнитель) + STEP 2 (ПДн) ──
already_found = "\n".join(f"- {k}" for k in mapping.keys()) if mapping else "(none)"
prompt = (
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"{already_found}\n\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'
@@ -149,31 +193,80 @@ def scan_llm_ner(all_texts: Dict[str, str], mapping: Dict[str, str],
"- Same value → include only once\n"
"- Return ONLY the JSON array, no other text, no markdown wrapping\n\n"
f"Text:\n{combined[:8000]}"
f"Text:\n{text}"
)
try:
# Отправляем запрос в LLM
raw = llm_client.complete(prompt)
# Чистим markdown-обёртку (если LLM вернула ```json ... ```)
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 []
# Добавляем найденные сущности в mapping
for ent in entities:
def scan_llm_ner(all_texts: Dict[str, str], mapping: Dict[str, str],
llm_client, counters: Dict[str, int]) -> None:
"""Универсальное LLM-обнаружение приватных данных (пофайлово, целиком).
Каждый файл обрабатывается ПОЛНОСТЬЮ: текст разбивается на чанки
(_CHUNK_SIZE=6000, overlap=_CHUNK_OVERLAP), чанки обрабатываются параллельно
(_LLM_CONCURRENCY), сущности дедуплицируются и верифицируются против полного
текста файла (отсечка галлюцинаций). regex-найденное не дублируется.
Args:
all_texts: {filename: text_content} — тексты всех файлов
mapping: Словарь замен (мутабельный, пополняется)
llm_client: Объект с методом .complete(prompt) -> str
counters: Глобальные счётчики токенов (мутабельный)
"""
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 not full_text or full_text.startswith("[DOC binary"):
continue
chunks = split_into_chunks(full_text)
# Параллельные вызовы 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 c in chunks:
all_ent.extend(_call_llm(_build_llm_prompt(c, mapping.keys()), llm_client))
# Дедуп + верификация + добавление в mapping
seen: set = set()
for ent in all_ent:
val = ent.get("value", "").strip()
if not val or val in mapping:
continue
# Тип — любой (LLM сама придумала)
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)
except Exception as e:
log.warning("LLM NER failed: %s", e)
+1 -1
View File
@@ -21,7 +21,7 @@ if _sys_path_root not in sys.path:
sys.path.insert(0, _sys_path_root)
# Версия приложения (меняется при изменениях)
VERSION = "0.0.56"
VERSION = "0.0.57"
def setup_logging():