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 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
@@ -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():
|
||||||
|
|||||||
Reference in New Issue
Block a user