v0.0.57: LLM обрабатывает ВСЕ данные — пофайловый чанкинг (6000/overlap 500), параллельность 4, дедуп+верификация против полного текста
Deploy drhider / validate (push) Canceled after 0s
Deploy drhider / validate (push) Canceled after 0s
This commit is contained in:
@@ -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
@@ -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
@@ -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():
|
||||
|
||||
Reference in New Issue
Block a user