From a9d4139afbed310bf1a4aca52bfc6f0f485521d7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E2=80=9CNaeel=E2=80=9D?= Date: Thu, 20 Aug 2026 10:01:01 +0300 Subject: [PATCH] =?UTF-8?q?v0.0.57:=20LLM=20=D0=BE=D0=B1=D1=80=D0=B0=D0=B1?= =?UTF-8?q?=D0=B0=D1=82=D1=8B=D0=B2=D0=B0=D0=B5=D1=82=20=D0=92=D0=A1=D0=95?= =?UTF-8?q?=20=D0=B4=D0=B0=D0=BD=D0=BD=D1=8B=D0=B5=20=E2=80=94=20=D0=BF?= =?UTF-8?q?=D0=BE=D1=84=D0=B0=D0=B9=D0=BB=D0=BE=D0=B2=D1=8B=D0=B9=20=D1=87?= =?UTF-8?q?=D0=B0=D0=BD=D0=BA=D0=B8=D0=BD=D0=B3=20(6000/overlap=20500),=20?= =?UTF-8?q?=D0=BF=D0=B0=D1=80=D0=B0=D0=BB=D0=BB=D0=B5=D0=BB=D1=8C=D0=BD?= =?UTF-8?q?=D0=BE=D1=81=D1=82=D1=8C=204,=20=D0=B4=D0=B5=D0=B4=D1=83=D0=BF+?= =?UTF-8?q?=D0=B2=D0=B5=D1=80=D0=B8=D1=84=D0=B8=D0=BA=D0=B0=D1=86=D0=B8?= =?UTF-8?q?=D1=8F=20=D0=BF=D1=80=D0=BE=D1=82=D0=B8=D0=B2=20=D0=BF=D0=BE?= =?UTF-8?q?=D0=BB=D0=BD=D0=BE=D0=B3=D0=BE=20=D1=82=D0=B5=D0=BA=D1=81=D1=82?= =?UTF-8?q?=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- History/2026-08-20-llm-full-chunking.md | 41 +++++++ drhider/scanner.py | 155 +++++++++++++++++++----- site/app.py | 2 +- 3 files changed, 166 insertions(+), 32 deletions(-) create mode 100644 History/2026-08-20-llm-full-chunking.md diff --git a/History/2026-08-20-llm-full-chunking.md b/History/2026-08-20-llm-full-chunking.md new file mode 100644 index 0000000..d879243 --- /dev/null +++ b/History/2026-08-20-llm-full-chunking.md @@ -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). diff --git a/drhider/scanner.py b/drhider/scanner.py index 615ecdc..d7d292c 100644 --- a/drhider/scanner.py +++ b/drhider/scanner.py @@ -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) diff --git a/site/app.py b/site/app.py index f8d9118..457451d 100644 --- a/site/app.py +++ b/site/app.py @@ -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():