Improve LLM and CSV processing

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
Repinoid
2026-10-23 10:09:34 +03:00
co-authored by Copilot
parent aa1d15509b
commit 49171c93f9
14 changed files with 315 additions and 92 deletions
+2 -16
View File
@@ -8,13 +8,13 @@
import io
import csv
import os
import zipfile
import re
import time
from typing import Dict, List, Tuple
from .config import ENTITY_PATTERNS, COMPANY_PATTERN
from .names import resolve_name
def build_zip(files: List[Tuple[str, bytes]], mapping_csv: str = "") -> bytes:
@@ -39,24 +39,10 @@ def build_zip(files: List[Tuple[str, bytes]], mapping_csv: str = "") -> bytes:
# Дедупликация имён: имя+одинаковый контент → пропуск; имя+разный контент → суффикс
seen: Dict[str, bytes] = {}
def _resolve_name(name: str, content: bytes) -> str:
if name not in seen:
seen[name] = content
return name
if seen[name] == content:
return None # точный дубль — пропускаем
base, ext = os.path.splitext(name)
n = 2
while f"{base}_{n}{ext}" in seen:
n += 1
resolved = f"{base}_{n}{ext}"
seen[resolved] = content
return resolved
with zipfile.ZipFile(buf, 'w', zipfile.ZIP_DEFLATED) as zf:
# Добавляем обфусцированные файлы
for fname, content in files:
out_name = _resolve_name(fname, content)
out_name = resolve_name(fname, content, seen)
if out_name is None:
continue
info = zipfile.ZipInfo(out_name)
+87 -8
View File
@@ -1,10 +1,11 @@
"""
Извлечение текста из документов — все форматы → Markdown.
Извлечение текста из документов и подготовка CSV к обфускации.
Поддерживает:
- .docx → MD (python-docx: стили, жирный/курсив, таблицы)
- .pdf → MD (pdfplumber: текст + таблицы, без форматирования)
- .doc → MD (LibreOffice headless: .doc → .docx → штатный docx_to_markdown)
- .doc → HTTP-сервис конвертации (CONVERT_SERVICE_URL) → .docx → Markdown
- .csv → текст ячеек для сканирования и обезличенный CSV на выходе
- .txt и прочие → как есть
Также содержит:
@@ -12,13 +13,16 @@
"""
import io
import csv
import os
import zipfile
import tempfile
import shutil
import subprocess
import logging
from typing import List, Tuple
from typing import Dict, List, Tuple
from . import replacer
log = logging.getLogger("drhider")
@@ -199,6 +203,77 @@ def doc_to_markdown(content: bytes) -> str:
# Единая точка входа
# ═══════════════════════════════════════════════════════════════════════════
def decode_text(content: bytes) -> str:
"""Декодировать текст как UTF-8, затем CP1251, с Latin-1 последним fallback."""
try:
return content.decode("utf-8")
except UnicodeDecodeError:
# Блокнот и выгрузки 1С часто сохраняют кириллицу в CP1251.
try:
return content.decode("cp1251")
except UnicodeDecodeError:
return content.decode("latin-1", errors="replace")
def detect_delimiter(text: str) -> str:
"""Определить CSV-разделитель, используя Sniffer и детерминированный fallback."""
delimiters = [",", ";", "\t"]
nonempty_lines = [line for line in text.splitlines() if line.strip()]
if nonempty_lines:
sample = "\n".join(nonempty_lines[:10])
try:
return csv.Sniffer().sniff(sample, delimiters="".join(delimiters)).delimiter
except csv.Error:
first_line = nonempty_lines[0]
return max(delimiters, key=first_line.count)
return ","
def csv_to_text(content: bytes) -> str:
"""Распарсить CSV и склеить значения ячеек построчно для сканирования."""
text = decode_text(content).removeprefix("\ufeff")
delimiter = detect_delimiter(text)
reader = csv.reader(
io.StringIO(text, newline=""),
delimiter=delimiter,
quotechar='"',
strict=True,
)
# Каждая ячейка отделена переводом строки, чтобы сущности из соседних ячеек
# не объединялись в строку, по которой работают regex и LLM.
return "\n".join(cell for row in reader for cell in row)
def obfuscate_csv(content: bytes, mapping: Dict[str, str],
sorted_keys: List[str], compiled_re) -> bytes:
"""Заменить сущности в CSV-ячейках, сохранив порядок строк и столбцов."""
text = decode_text(content).removeprefix("\ufeff")
delimiter = detect_delimiter(text)
reader = csv.reader(
io.StringIO(text, newline=""),
delimiter=delimiter,
quotechar='"',
strict=True,
)
output = io.StringIO(newline="")
writer = csv.writer(
output,
delimiter=delimiter,
quotechar='"',
lineterminator="\n",
strict=True,
)
for row in reader:
writer.writerow([
replacer.apply_replacements(cell, mapping, sorted_keys, compiled_re)
for cell in row
])
# csv.writer нормализует кавычки/переводы строк; побайтовое сохранение
# исходного формата не является целью. BOM помогает Excel открыть кириллицу.
return ("\ufeff" + output.getvalue()).encode("utf-8")
def extract_text(fname: str, content: bytes, ctype: str = "") -> str:
"""Извлечь текст из одного файла → Markdown.
@@ -217,22 +292,26 @@ def extract_text(fname: str, content: bytes, ctype: str = "") -> str:
try:
return docx_to_markdown(content)
except ImportError:
return content.decode('utf-8', errors='replace')
return decode_text(content)
# ── .pdf: текст + таблицы ──
elif ext == '.pdf':
try:
return pdf_to_markdown(content)
except ImportError:
return content.decode('utf-8', errors='replace')
return decode_text(content)
# ── .doc: бинарный → LibreOffice → DOCX → Markdown ──
# ── .doc: бинарный → HTTP-сервис конвертации → DOCX → Markdown ──
elif ext == '.doc':
return doc_to_markdown(content)
# ── .csv: ячейки для обнаружения сущностей ──
elif ext == '.csv':
return csv_to_text(content)
# ── .txt и прочие ──
else:
return content.decode('utf-8', errors='replace')
return decode_text(content)
# ═══════════════════════════════════════════════════════════════════════════
@@ -338,7 +417,7 @@ def expand_zips(files: List[Tuple[str, bytes, str]]) -> List[Tuple[str, bytes, s
continue
# Синхронизация с фронтом (listZipFiles): из zip берём ТОЛЬКО документы
low = name.lower()
if not (low.endswith('.zip') or low.endswith(('.pdf', '.doc', '.docx', '.txt', '.md'))):
if not (low.endswith('.zip') or low.endswith(('.pdf', '.doc', '.docx', '.txt', '.md', '.csv'))):
continue
inner_data = zf.read(info)
+39 -6
View File
@@ -11,6 +11,8 @@ LLM-клиент для Obfuscator.
import os
import time
from typing import Optional
import httpx
@@ -21,9 +23,12 @@ class LLMClient:
LLM_API_KEY или LLM_KEY — ключ API
LLM_URL — URL эндпоинта (по умолчанию https://api.aillm.ru/v1/chat/completions)
LLM_MODEL — модель (по умолчанию gpt-oss-120b)
LLM_RETRIES — число повторов после первой попытки (по умолчанию 3)
LLM_RETRY_DELAY — базовая задержка backoff в секундах (по умолчанию 2)
"""
def __init__(self):
def __init__(self, retries: Optional[int] = None,
retry_delay: Optional[float] = None):
"""Инициализировать клиент. httpx импортируется лениво.
Ведёт кумулятивные счётчики за всё время жизни клиента:
@@ -36,6 +41,13 @@ class LLMClient:
llm_started — time.time() начала текущего LLM-вызова
"""
self._httpx = httpx
self.retries = int(os.environ.get("LLM_RETRIES", "3")) if retries is None else retries
self.retry_delay = (
float(os.environ.get("LLM_RETRY_DELAY", "2.0"))
if retry_delay is None else retry_delay
)
if self.retries < 0 or self.retry_delay < 0:
raise ValueError("LLM retries and retry delay must be non-negative")
self.tokens_prompt = 0
self.tokens_completion = 0
self.llm_sec = 0.0
@@ -52,6 +64,31 @@ class LLMClient:
return time.time() - self.llm_started
return 0.0
def _post_with_retries(self, url: str, json: dict, headers: dict,
timeout: int) -> httpx.Response:
"""Повторить только временные транспортные ошибки и выбранные HTTP-статусы."""
for attempt in range(self.retries + 1):
try:
response = self._httpx.post(
url, json=json, headers=headers, timeout=timeout
)
self.llm_sec += response.elapsed.total_seconds()
response.raise_for_status()
return response
except (self._httpx.TimeoutException, self._httpx.TransportError):
if attempt >= self.retries:
raise
except self._httpx.HTTPStatusError as e:
# Retry only throttling and server failures; 400/401/403 require
# a corrected request or credentials and will not improve on retry.
if e.response.status_code not in (429, 500, 502, 503, 504):
raise
if attempt >= self.retries:
raise
# 120 секунд на попытку и backoff означают около 8 минут в худшем случае.
time.sleep(self.retry_delay * (attempt + 1))
def complete(self, prompt: str) -> str:
"""Отправить промпт в LLM и вернуть текст ответа.
@@ -76,7 +113,7 @@ class LLMClient:
self.llm_active = True
self.llm_started = time.time()
try:
r = self._httpx.post(
r = self._post_with_retries(
url,
json={
"model": model,
@@ -90,10 +127,6 @@ class LLMClient:
},
timeout=120,
)
r.raise_for_status()
# Время запроса (реальное время HTTP-вызова LLM)
self.llm_sec += r.elapsed.total_seconds()
data = r.json()
# Счётчики токенов из usage (могут отсутствовать у некоторых прокси)
+25
View File
@@ -0,0 +1,25 @@
"""Helpers for assigning stable, collision-free output filenames."""
import os
from typing import Dict, Optional
def resolve_name(name: str, content: bytes, seen: Dict[str, bytes]) -> Optional[str]:
"""Return a unique name, or None when the same name and content were seen.
Name collisions with different content receive numeric suffixes before
the extension, while exact duplicates are omitted.
"""
if name not in seen:
seen[name] = content
return name
if seen[name] == content:
return None
base, ext = os.path.splitext(name)
index = 2
while f"{base}_{index}{ext}" in seen:
index += 1
resolved = f"{base}_{index}{ext}"
seen[resolved] = content
return resolved
+31 -14
View File
@@ -17,10 +17,19 @@ from . import extractor
from . import scanner
from . import replacer
from . import builder
from .names import resolve_name
log = logging.getLogger("drhider")
class LLMIncompleteError(Exception):
"""Processing cannot safely return files when one or more LLM chunks failed."""
def __init__(self, llm_errors: int):
self.llm_errors = llm_errors
super().__init__("LLM processing incomplete")
def _dedupe_file_names(
files: List[Tuple[str, bytes, str]]
) -> List[Tuple[str, bytes, str]]:
@@ -39,17 +48,9 @@ def _dedupe_file_names(
out: List[Tuple[str, bytes, str]] = []
for fname, content, ctype in files:
candidate = fname
if candidate in seen:
if seen[candidate] == content:
continue # точный дубль — пропускаем
base, ext = os.path.splitext(fname)
n = 2
while f"{base}_{n}{ext}" in seen:
n += 1
candidate = f"{base}_{n}{ext}"
seen[candidate] = content
out.append((candidate, content, ctype))
candidate = resolve_name(fname, content, seen)
if candidate is not None:
out.append((candidate, content, ctype))
return out
@@ -177,13 +178,18 @@ class TwoPassObfuscator:
cancelled_llm = cancelled_extract
if not cancelled_llm and self._llm_client:
try:
scanner.scan_llm_ner(
llm_failures = scanner.scan_llm_ner(
all_texts, self._mapping, self._llm_client, self._counters,
cancel_event=cancel_event, file_progress=_fp,
)
except scanner.CancelRequested:
except scanner.CancelRequested as e:
if e.llm_failures:
raise LLMIncompleteError(e.llm_failures)
log.info("obfuscate: LLM cancelled, llm_done=%d", len(llm_done))
cancelled_llm = True
else:
if llm_failures:
raise LLMIncompleteError(llm_failures)
# Предсортировать ключи один раз (по убыванию длины)
self._sorted_keys = sorted(
@@ -220,7 +226,18 @@ class TwoPassObfuscator:
t0 = time.time()
obf_content = content # По умолчанию — без изменений
if fname.endswith('.doc'):
if fname.lower().endswith('.csv'):
try:
obf_content = extractor.obfuscate_csv(
content, self._mapping, self._sorted_keys, self._compiled_re
)
except Exception as e:
log.warning("obfuscate: skip CSV %r during replacement: %r", fname, e)
skipped.add(fname)
if progress_cb:
progress_cb("done", i, total, display_name, 0.0)
continue
elif fname.endswith('.doc'):
# .doc — конвертируется через liberta, обрабатывается как .docx
md_text = all_texts.get(fname, '')
replaced = replacer.apply_replacements(
+61 -18
View File
@@ -115,6 +115,9 @@ def scan_regex(text: str, mapping: Dict[str, str], counters: Dict[str, int]) ->
class CancelRequested(Exception):
"""Обработка прервана пользователем (мягкая остановка между файлами LLM)."""
def __init__(self, llm_failures: int = 0):
self.llm_failures = llm_failures
# Приоритет границ для чанков (от предпочтительных к жёстким)
_CHUNK_BOUNDARIES = ['\n\n', '\n', '. ', '? ', '! ', '; ', ', ', ' ']
_CHUNK_SIZE = 6000 # символов на чанк (≈1500-2000 токенов RU — NER-качество)
@@ -202,26 +205,53 @@ def _build_llm_prompt(text: str, already_found: List[str]) -> str:
def _call_llm(text: str, llm_client) -> List[dict]:
"""Один вызов LLM по фрагменту → список сущностей (пустой при ошибке)."""
"""Один вызов LLM по фрагменту → проверенный список сущностей."""
raw = llm_client.complete(text).strip()
if raw.startswith("```"):
lines = raw.splitlines()
if lines and lines[0].startswith("```"):
lines = lines[1:]
if lines and lines[-1].strip() == "```":
lines = lines[:-1]
raw = "\n".join(lines).strip()
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 []
except json.JSONDecodeError as original_error:
# Модель иногда добавляет текст вокруг массива; raw_decode корректно
# учитывает вложенные массивы и квадратные скобки внутри JSON-строк.
decoder = json.JSONDecoder()
entities = None
for index, char in enumerate(raw):
if char != "[":
continue
try:
candidate, _ = decoder.raw_decode(raw, index)
except json.JSONDecodeError:
continue
if isinstance(candidate, list):
entities = candidate
break
if entities is None:
raise ValueError("LLM response does not contain a JSON array") from original_error
if not isinstance(entities, list):
raise ValueError("LLM response must be a JSON array")
for entity in entities:
if not isinstance(entity, dict):
raise ValueError("LLM entity must be a JSON object")
value = entity.get("value")
if not isinstance(value, str) or not value.strip():
raise ValueError("LLM entity value must be a non-empty string")
entity_type = entity.get("type")
if entity_type is not None and not isinstance(entity_type, str):
raise ValueError("LLM entity type must be a string")
return entities
def scan_llm_ner(all_texts: Dict[str, str], mapping: Dict[str, str],
llm_client, counters: Dict[str, int],
cancel_event=None, file_progress=None) -> None:
cancel_event=None, file_progress=None) -> int:
"""Универсальное LLM-обнаружение приватных данных (пофайлово, целиком).
Каждый файл обрабатывается ПОЛНОСТЬЮ: текст разбивается на чанки
@@ -243,11 +273,12 @@ def scan_llm_ner(all_texts: Dict[str, str], mapping: Dict[str, str],
file_items = sorted(
all_texts.items(), key=lambda kv: len(kv[1]), reverse=True
)
llm_failures = 0
for fname, full_text in file_items:
# Мягкая остановка ТОЛЬКО между файлами — текущий файл добирается до конца
if cancel_event is not None and cancel_event.is_set():
raise CancelRequested()
raise CancelRequested(llm_failures)
if not full_text or full_text.startswith("[DOC binary"):
continue
@@ -265,11 +296,19 @@ def scan_llm_ner(all_texts: Dict[str, str], mapping: Dict[str, str],
for c in chunks]
all_ent = []
for fut in futures:
all_ent.extend(fut.result())
try:
all_ent.extend(fut.result())
except Exception as e:
llm_failures += 1
log.warning("LLM NER chunk failed in %r: %s", fname, e)
else:
all_ent = []
for k, c in enumerate(chunks):
all_ent.extend(_call_llm(_build_llm_prompt(c, mapping.keys()), llm_client))
try:
all_ent.extend(_call_llm(_build_llm_prompt(c, mapping.keys()), llm_client))
except Exception as e:
llm_failures += 1
log.warning("LLM NER chunk failed in %r: %s", fname, e)
if file_progress:
file_progress("file_chunk", fname,
chunks_done=k + 1, chunks_total=len(chunks))
@@ -287,8 +326,12 @@ def scan_llm_ner(all_texts: Dict[str, str], mapping: Dict[str, str],
if not _verify_entity(val, full_text):
log.debug("LLM hallucination dropped: %r", val)
continue
ent_type = ent.get("type", "").strip().lower().replace(" ", "_")
raw_type = ent.get("type")
ent_type = raw_type.strip().lower().replace(" ", "_") if raw_type else ""
ent_type = ent_type or "other_id"
mapping[val] = _next_token(ent_type, counters)
if file_progress:
file_progress("file_done", fname, elapsed=round(time.time() - t0, 2))
return llm_failures