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 json
import random import random
import logging import logging
from typing import Dict from concurrent.futures import ThreadPoolExecutor
from typing import Dict, List, Optional
from .config import ( from .config import (
ENTITY_PATTERNS, COMPANY_PATTERN, PERSON_PATTERN, 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) 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-NER: пофайловый чанкинг (Sonnet-схема)
"""Универсальное LLM-обнаружение приватных данных. # ═══════════════════════════════════════════════════════════════════════════
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} — тексты всех файлов def split_into_chunks(text: str, size: int = _CHUNK_SIZE,
mapping: Словарь замен (мутабельный, пополняется) overlap: int = _CHUNK_OVERLAP) -> List[str]:
llm_client: Объект с методом .complete(prompt) -> str """Разбить текст на чанки ~size символов с перекрытием overlap.
counters: Глобальные счётчики токенов (мутабельный)
Режем по границам (\n\n, \n, ..., пробел) — чтобы не разрывать слова/сущности.
""" """
# Формируем комбинированный текст: имя файла + первые 3000 символов chunks: List[str] = []
combined = "\n\n---FILE---\n\n".join( start = 0
f"FILE: {fname}\n{t[:3000]}" for fname, t in all_texts.items() 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" "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" "Find ALL sensitive or private information that is NOT already captured below.\n\n"
"Already captured by regex (skip these exact strings):\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" "Return a JSON array. Each item must have:\n"
' - "type": MUST be one of: person_name, company, phone, email, address,\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" "- Same value → include only once\n"
"- Return ONLY the JSON array, no other text, no markdown wrapping\n\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() raw = raw.strip()
if raw.startswith("```"): if raw.startswith("```"):
raw = raw.split("\n", 1)[1] raw = raw.split("\n", 1)[1]
if raw.endswith("```"): if raw.endswith("```"):
raw = raw[:-3] raw = raw[:-3]
entities = json.loads(raw) 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() val = ent.get("value", "").strip()
if not val or val in mapping: if not val or val in mapping:
continue continue
nk = normalize_entity(val)
# Тип — любой (LLM сама придумала) 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(" ", "_") ent_type = ent.get("type", "").strip().lower().replace(" ", "_")
mapping[val] = _next_token(ent_type, counters) 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) sys.path.insert(0, _sys_path_root)
# Версия приложения (меняется при изменениях) # Версия приложения (меняется при изменениях)
VERSION = "0.0.56" VERSION = "0.0.57"
def setup_logging(): def setup_logging():