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
+11 -5
View File
@@ -28,7 +28,7 @@ Browser
-> GET /api/csv/<sid> Browser downloads the mapping separately -> GET /api/csv/<sid> Browser downloads the mapping separately
``` ```
The browser sends files one at a time: it waits for the WebDAV `PUT`, then asks Flask to pull that file before moving to the next one. The buffer therefore holds files only during transit, not for the complete processing session. If the Flask pull fails, the buffer object may remain until manually removed or otherwise cleaned up; the current Nginx config does not define a TTL cleanup job. The browser sends files one at a time: it waits for the WebDAV `PUT`, then asks Flask to pull that file before moving to the next one. The buffer therefore holds files only during transit, not for the complete processing session. If the Flask pull fails, the buffer object may remain until manually removed or otherwise cleaned up; install the TTL cron cleanup described below on VM213.
## Application Components ## Application Components
@@ -40,7 +40,7 @@ The browser sends files one at a time: it waits for the WebDAV `PUT`, then asks
- `upload/frontend/upload/put_to_vm.js` sends a raw file body with `PUT` and reports progress. - `upload/frontend/upload/put_to_vm.js` sends a raw file body with `PUT` and reports progress.
- `upload/frontend/upload/upload_via_vm.js` coordinates the per-file `PUT` and `/api/upload_refs` requests. - `upload/frontend/upload/upload_via_vm.js` coordinates the per-file `PUT` and `/api/upload_refs` requests.
The UI accepts `.pdf`, `.doc`, `.docx`, `.txt`, `.md`, and `.zip` inputs. ZIP archives are expanded by the browser picker, and their allowed inner files are uploaded individually. The backend `drhider/extractor.py` also expands ZIPs received directly (for example, through `POST /api/upload`). Per-file (50 MiB) and per-session (500 MiB) limits are enforced by the backend; the picker's entry/size/depth limits apply only while unpacking ZIP archives. The UI accepts `.pdf`, `.doc`, `.docx`, `.txt`, `.md`, `.csv`, and `.zip` inputs. ZIP archives are expanded by the browser picker, and their allowed inner files are uploaded individually. The backend `drhider/extractor.py` also expands ZIPs received directly (for example, through `POST /api/upload`). Per-file (50 MiB) and per-session (500 MiB) limits are enforced by the backend; the picker's entry/size/depth limits apply only while unpacking ZIP archives.
### Flask composition and routes ### Flask composition and routes
@@ -58,7 +58,7 @@ Main endpoints:
| `GET /upload/<path>`, `GET /dist/<path>` | Serve raw picker ES modules and dist assets | | `GET /upload/<path>`, `GET /dist/<path>` | Serve raw picker ES modules and dist assets |
| `POST /api/upload_refs` | Pull browser-uploaded buffer objects into a Flask session | | `POST /api/upload_refs` | Pull browser-uploaded buffer objects into a Flask session |
| `POST /api/upload` | Direct multipart upload endpoint | | `POST /api/upload` | Direct multipart upload endpoint |
| `GET /api/session_files/<sid>` | Inspect files in a session (diagnostic endpoint) | | `GET /api/session_files/<sid>` | Inspect files in a session (diagnostic endpoint; disabled unless `ENABLE_SESSION_FILES=1`) |
| `GET /api/process_stream/<sid>` | Process a session and stream progress using Server-Sent Events | | `GET /api/process_stream/<sid>` | Process a session and stream progress using Server-Sent Events |
| `POST /api/process/<sid>` | Synchronous/legacy processing endpoint | | `POST /api/process/<sid>` | Synchronous/legacy processing endpoint |
| `POST /api/cancel/<sid>` | Request a cooperative stop between processing steps | | `POST /api/cancel/<sid>` | Request a cooperative stop between processing steps |
@@ -77,7 +77,7 @@ Main endpoints:
The core package is split by responsibility: The core package is split by responsibility:
- `drhider/extractor.py` converts `.docx`, `.pdf`, and `.doc` content to text/Markdown and expands ZIP archives with ZIP-bomb protections. `.doc` conversion calls the configured conversion service. - `drhider/extractor.py` converts `.docx`, `.pdf`, and `.doc` content to text/Markdown, extracts CSV cell values for scanning, obfuscates CSV cells, and expands ZIP archives with ZIP-bomb protections. `.doc` conversion calls the configured conversion service.
- `drhider/scanner.py` detects entities with regular expressions and LLM-based NER, building the original-to-replacement mapping. - `drhider/scanner.py` detects entities with regular expressions and LLM-based NER, building the original-to-replacement mapping.
- `drhider/replacer.py` applies that mapping to extracted text. - `drhider/replacer.py` applies that mapping to extracted text.
- `drhider/builder.py` builds the obfuscated ZIP and mapping CSV. The mapping CSV is deliberately not included inside the ZIP; it is downloaded through its separate endpoint. - `drhider/builder.py` builds the obfuscated ZIP and mapping CSV. The mapping CSV is deliberately not included inside the ZIP; it is downloaded through its separate endpoint.
@@ -85,7 +85,7 @@ The core package is split by responsibility:
Processing runs in a worker thread for the SSE endpoint. Progress, per-file events, LLM activity, completion, cancellation, and errors are sent to the browser as SSE events. Cancellation is cooperative; the current operation/file may finish before the worker stops. Processing runs in a worker thread for the SSE endpoint. Progress, per-file events, LLM activity, completion, cancellation, and errors are sent to the browser as SSE events. Cancellation is cooperative; the current operation/file may finish before the worker stops.
The LLM client reads `LLM_KEY` or `LLM_API_KEY`, with optional `LLM_URL` (default `https://api.aillm.ru/v1/chat/completions`) and `LLM_MODEL` (default `gpt-oss-120b`), from the Managed Flask environment. The `.doc` converter URL is configured with `CONVERT_SERVICE_URL` (default `https://liberta.containerk8s.dev.nubes.ru/convert`); `.doc` files are converted sequentially to avoid overloading the conversion service. Secrets belong in service environment settings, never in this repository. The LLM client reads `LLM_KEY` or `LLM_API_KEY`, with optional `LLM_URL` (default `https://api.aillm.ru/v1/chat/completions`) and `LLM_MODEL` (default `gpt-oss-120b`), from the Managed Flask environment. `LLM_RETRIES` sets the number of retries after the first request (default `3`), and `LLM_RETRY_DELAY` sets the base backoff in seconds (default `2.0`; delays grow linearly). The `.doc` converter URL is configured with `CONVERT_SERVICE_URL` (default `https://liberta.containerk8s.dev.nubes.ru/convert`); `.doc` files are converted sequentially to avoid overloading the conversion service. Secrets belong in service environment settings, never in this repository.
## VM213 WebDAV Buffer ## VM213 WebDAV Buffer
@@ -100,6 +100,12 @@ The source of the Obfuscator Nginx config is [`deploy/vm213/nginx/obfuscator-upl
The CORS allowlist is for the Obfuscator browser origin `https://obfuscator.pythonk8s.services.ngcloud.ru`. CORS is a browser policy, **not authentication**; direct HTTP clients are not blocked by CORS. The VM disk is temporary staging, not durable storage. A VM or disk failure can lose files that have not yet been pulled by Flask. The CORS allowlist is for the Obfuscator browser origin `https://obfuscator.pythonk8s.services.ngcloud.ru`. CORS is a browser policy, **not authentication**; direct HTTP clients are not blocked by CORS. The VM disk is temporary staging, not durable storage. A VM or disk failure can lose files that have not yet been pulled by Flask.
Install a cron job on VM213 to remove orphaned upload-buffer files older than 30 minutes. The cleanup command is:
```sh
find /var/www/obfuscator-upload/ -type f -mmin +30 -delete
```
To update the VM config, update this repository source, install the same file on VM213, run `nginx -t`, and reload Nginx only if validation succeeds. The Managed Flask deployment and Nginx deployment are independent; deploying one does not deploy the other. To update the VM config, update this repository source, install the same file on VM213, run `nginx -t`, and reload Nginx only if validation succeeds. The Managed Flask deployment and Nginx deployment are independent; deploying one does not deploy the other.
## Repository Map ## Repository Map
+5 -1
View File
@@ -55,4 +55,8 @@ server {
return 404; return 404;
} }
} }
# End of Obfuscator's VM213 upload-buffer config. # End of Obfuscator's VM213 upload-buffer config.
# TTL cleanup (configure separately as a cron job on VM213; this file does not
# install or schedule the job):
# find /var/www/obfuscator-upload/ -type f -mmin +30 -delete
+2 -16
View File
@@ -8,13 +8,13 @@
import io import io
import csv import csv
import os
import zipfile import zipfile
import re import re
import time import time
from typing import Dict, List, Tuple from typing import Dict, List, Tuple
from .config import ENTITY_PATTERNS, COMPANY_PATTERN from .config import ENTITY_PATTERNS, COMPANY_PATTERN
from .names import resolve_name
def build_zip(files: List[Tuple[str, bytes]], mapping_csv: str = "") -> bytes: 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] = {} 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: with zipfile.ZipFile(buf, 'w', zipfile.ZIP_DEFLATED) as zf:
# Добавляем обфусцированные файлы # Добавляем обфусцированные файлы
for fname, content in files: for fname, content in files:
out_name = _resolve_name(fname, content) out_name = resolve_name(fname, content, seen)
if out_name is None: if out_name is None:
continue continue
info = zipfile.ZipInfo(out_name) info = zipfile.ZipInfo(out_name)
+87 -8
View File
@@ -1,10 +1,11 @@
""" """
Извлечение текста из документов — все форматы → Markdown. Извлечение текста из документов и подготовка CSV к обфускации.
Поддерживает: Поддерживает:
- .docx → MD (python-docx: стили, жирный/курсив, таблицы) - .docx → MD (python-docx: стили, жирный/курсив, таблицы)
- .pdf → MD (pdfplumber: текст + таблицы, без форматирования) - .pdf → MD (pdfplumber: текст + таблицы, без форматирования)
- .doc → MD (LibreOffice headless: .doc → .docx → штатный docx_to_markdown) - .doc → HTTP-сервис конвертации (CONVERT_SERVICE_URL) → .docx → Markdown
- .csv → текст ячеек для сканирования и обезличенный CSV на выходе
- .txt и прочие → как есть - .txt и прочие → как есть
Также содержит: Также содержит:
@@ -12,13 +13,16 @@
""" """
import io import io
import csv
import os import os
import zipfile import zipfile
import tempfile import tempfile
import shutil import shutil
import subprocess import subprocess
import logging import logging
from typing import List, Tuple from typing import Dict, List, Tuple
from . import replacer
log = logging.getLogger("drhider") 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: def extract_text(fname: str, content: bytes, ctype: str = "") -> str:
"""Извлечь текст из одного файла → Markdown. """Извлечь текст из одного файла → Markdown.
@@ -217,22 +292,26 @@ def extract_text(fname: str, content: bytes, ctype: str = "") -> str:
try: try:
return docx_to_markdown(content) return docx_to_markdown(content)
except ImportError: except ImportError:
return content.decode('utf-8', errors='replace') return decode_text(content)
# ── .pdf: текст + таблицы ── # ── .pdf: текст + таблицы ──
elif ext == '.pdf': elif ext == '.pdf':
try: try:
return pdf_to_markdown(content) return pdf_to_markdown(content)
except ImportError: except ImportError:
return content.decode('utf-8', errors='replace') return decode_text(content)
# ── .doc: бинарный → LibreOffice → DOCX → Markdown ── # ── .doc: бинарный → HTTP-сервис конвертации → DOCX → Markdown ──
elif ext == '.doc': elif ext == '.doc':
return doc_to_markdown(content) return doc_to_markdown(content)
# ── .csv: ячейки для обнаружения сущностей ──
elif ext == '.csv':
return csv_to_text(content)
# ── .txt и прочие ── # ── .txt и прочие ──
else: 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 continue
# Синхронизация с фронтом (listZipFiles): из zip берём ТОЛЬКО документы # Синхронизация с фронтом (listZipFiles): из zip берём ТОЛЬКО документы
low = name.lower() 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 continue
inner_data = zf.read(info) inner_data = zf.read(info)
+39 -6
View File
@@ -11,6 +11,8 @@ LLM-клиент для Obfuscator.
import os import os
import time import time
from typing import Optional
import httpx import httpx
@@ -21,9 +23,12 @@ class LLMClient:
LLM_API_KEY или LLM_KEY — ключ API LLM_API_KEY или LLM_KEY — ключ API
LLM_URL — URL эндпоинта (по умолчанию https://api.aillm.ru/v1/chat/completions) LLM_URL — URL эндпоинта (по умолчанию https://api.aillm.ru/v1/chat/completions)
LLM_MODEL — модель (по умолчанию gpt-oss-120b) 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 импортируется лениво. """Инициализировать клиент. httpx импортируется лениво.
Ведёт кумулятивные счётчики за всё время жизни клиента: Ведёт кумулятивные счётчики за всё время жизни клиента:
@@ -36,6 +41,13 @@ class LLMClient:
llm_started — time.time() начала текущего LLM-вызова llm_started — time.time() начала текущего LLM-вызова
""" """
self._httpx = httpx 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_prompt = 0
self.tokens_completion = 0 self.tokens_completion = 0
self.llm_sec = 0.0 self.llm_sec = 0.0
@@ -52,6 +64,31 @@ class LLMClient:
return time.time() - self.llm_started return time.time() - self.llm_started
return 0.0 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: def complete(self, prompt: str) -> str:
"""Отправить промпт в LLM и вернуть текст ответа. """Отправить промпт в LLM и вернуть текст ответа.
@@ -76,7 +113,7 @@ class LLMClient:
self.llm_active = True self.llm_active = True
self.llm_started = time.time() self.llm_started = time.time()
try: try:
r = self._httpx.post( r = self._post_with_retries(
url, url,
json={ json={
"model": model, "model": model,
@@ -90,10 +127,6 @@ class LLMClient:
}, },
timeout=120, timeout=120,
) )
r.raise_for_status()
# Время запроса (реальное время HTTP-вызова LLM)
self.llm_sec += r.elapsed.total_seconds()
data = r.json() data = r.json()
# Счётчики токенов из usage (могут отсутствовать у некоторых прокси) # Счётчики токенов из 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 scanner
from . import replacer from . import replacer
from . import builder from . import builder
from .names import resolve_name
log = logging.getLogger("drhider") 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( def _dedupe_file_names(
files: List[Tuple[str, bytes, str]] files: List[Tuple[str, bytes, str]]
) -> List[Tuple[str, bytes, str]]: ) -> List[Tuple[str, bytes, str]]:
@@ -39,17 +48,9 @@ def _dedupe_file_names(
out: List[Tuple[str, bytes, str]] = [] out: List[Tuple[str, bytes, str]] = []
for fname, content, ctype in files: for fname, content, ctype in files:
candidate = fname candidate = resolve_name(fname, content, seen)
if candidate in seen: if candidate is not None:
if seen[candidate] == content: out.append((candidate, content, ctype))
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))
return out return out
@@ -177,13 +178,18 @@ class TwoPassObfuscator:
cancelled_llm = cancelled_extract cancelled_llm = cancelled_extract
if not cancelled_llm and self._llm_client: if not cancelled_llm and self._llm_client:
try: try:
scanner.scan_llm_ner( llm_failures = scanner.scan_llm_ner(
all_texts, self._mapping, self._llm_client, self._counters, all_texts, self._mapping, self._llm_client, self._counters,
cancel_event=cancel_event, file_progress=_fp, 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)) log.info("obfuscate: LLM cancelled, llm_done=%d", len(llm_done))
cancelled_llm = True cancelled_llm = True
else:
if llm_failures:
raise LLMIncompleteError(llm_failures)
# Предсортировать ключи один раз (по убыванию длины) # Предсортировать ключи один раз (по убыванию длины)
self._sorted_keys = sorted( self._sorted_keys = sorted(
@@ -220,7 +226,18 @@ class TwoPassObfuscator:
t0 = time.time() t0 = time.time()
obf_content = content # По умолчанию — без изменений 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 # .doc — конвертируется через liberta, обрабатывается как .docx
md_text = all_texts.get(fname, '') md_text = all_texts.get(fname, '')
replaced = replacer.apply_replacements( 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): class CancelRequested(Exception):
"""Обработка прервана пользователем (мягкая остановка между файлами LLM).""" """Обработка прервана пользователем (мягкая остановка между файлами LLM)."""
def __init__(self, llm_failures: int = 0):
self.llm_failures = llm_failures
# Приоритет границ для чанков (от предпочтительных к жёстким) # Приоритет границ для чанков (от предпочтительных к жёстким)
_CHUNK_BOUNDARIES = ['\n\n', '\n', '. ', '? ', '! ', '; ', ', ', ' '] _CHUNK_BOUNDARIES = ['\n\n', '\n', '. ', '? ', '! ', '; ', ', ', ' ']
_CHUNK_SIZE = 6000 # символов на чанк (≈1500-2000 токенов RU — NER-качество) _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]: 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: 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) entities = json.loads(raw)
if not isinstance(entities, list): except json.JSONDecodeError as original_error:
return [] # Модель иногда добавляет текст вокруг массива; raw_decode корректно
return entities # учитывает вложенные массивы и квадратные скобки внутри JSON-строк.
except Exception as e: decoder = json.JSONDecoder()
log.warning("LLM NER failed: %s", e) entities = None
return [] 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], def scan_llm_ner(all_texts: Dict[str, str], mapping: Dict[str, str],
llm_client, counters: Dict[str, int], llm_client, counters: Dict[str, int],
cancel_event=None, file_progress=None) -> None: cancel_event=None, file_progress=None) -> int:
"""Универсальное LLM-обнаружение приватных данных (пофайлово, целиком). """Универсальное LLM-обнаружение приватных данных (пофайлово, целиком).
Каждый файл обрабатывается ПОЛНОСТЬЮ: текст разбивается на чанки Каждый файл обрабатывается ПОЛНОСТЬЮ: текст разбивается на чанки
@@ -243,11 +273,12 @@ def scan_llm_ner(all_texts: Dict[str, str], mapping: Dict[str, str],
file_items = sorted( file_items = sorted(
all_texts.items(), key=lambda kv: len(kv[1]), reverse=True all_texts.items(), key=lambda kv: len(kv[1]), reverse=True
) )
llm_failures = 0
for fname, full_text in file_items: for fname, full_text in file_items:
# Мягкая остановка ТОЛЬКО между файлами — текущий файл добирается до конца # Мягкая остановка ТОЛЬКО между файлами — текущий файл добирается до конца
if cancel_event is not None and cancel_event.is_set(): 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"): if not full_text or full_text.startswith("[DOC binary"):
continue continue
@@ -265,11 +296,19 @@ def scan_llm_ner(all_texts: Dict[str, str], mapping: Dict[str, str],
for c in chunks] for c in chunks]
all_ent = [] all_ent = []
for fut in futures: 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: else:
all_ent = [] all_ent = []
for k, c in enumerate(chunks): 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: if file_progress:
file_progress("file_chunk", fname, file_progress("file_chunk", fname,
chunks_done=k + 1, chunks_total=len(chunks)) 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): if not _verify_entity(val, full_text):
log.debug("LLM hallucination dropped: %r", val) log.debug("LLM hallucination dropped: %r", val)
continue 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) mapping[val] = _next_token(ent_type, counters)
if file_progress: if file_progress:
file_progress("file_done", fname, elapsed=round(time.time() - t0, 2)) file_progress("file_done", fname, elapsed=round(time.time() - t0, 2))
return llm_failures
+2 -3
View File
@@ -22,7 +22,7 @@ if _sys_path_root not in sys.path:
sys.path.insert(0, _sys_path_root) sys.path.insert(0, _sys_path_root)
# Версия приложения (меняется при изменениях) # Версия приложения (меняется при изменениях)
VERSION = "1.0.2" VERSION = "1.0.3"
def setup_logging(): def setup_logging():
@@ -73,6 +73,7 @@ def create_app():
# Конфигурация # Конфигурация
app.config["VERSION"] = VERSION app.config["VERSION"] = VERSION
app.config["MAX_CONTENT_LENGTH"] = 50 * 1024 * 1024 # 50 MB (лимит на один файл) app.config["MAX_CONTENT_LENGTH"] = 50 * 1024 * 1024 # 50 MB (лимит на один файл)
app.config["ENABLE_SESSION_FILES"] = os.environ.get("ENABLE_SESSION_FILES", "0") == "1"
# Регистрируем blueprint'ы # Регистрируем blueprint'ы
from routes import register_routes from routes import register_routes
@@ -98,5 +99,3 @@ if __name__ == "__main__":
from waitress import serve from waitress import serve
serve(app, host="0.0.0.0", port=5000, threads=8) serve(app, host="0.0.0.0", port=5000, threads=8)
+36 -12
View File
@@ -17,12 +17,13 @@ import queue
import threading import threading
import time import time
import zipfile import zipfile
import traceback
import logging import logging
from datetime import datetime, timedelta from datetime import datetime, timedelta
from flask import Blueprint, request, send_file, jsonify, Response, stream_with_context from flask import (Blueprint, current_app, request, send_file, jsonify, Response,
stream_with_context)
from drhider import obfuscate_files, LLMClient from drhider import obfuscate_files, LLMClient
from drhider.obfuscator import LLMIncompleteError
from upload.backend.session import (create_session, add_file, get_files, store_result, from upload.backend.session import (create_session, add_file, get_files, store_result,
get_result, store_csv, get_csv, cleanup, file_count, get_result, store_csv, get_csv, cleanup, file_count,
MAX_FILE_BYTES, pause_ttl, resume_ttl, MAX_FILE_BYTES, pause_ttl, resume_ttl,
@@ -78,6 +79,8 @@ def upload():
@api_bp.route("/session_files/<sid>", methods=["GET"]) @api_bp.route("/session_files/<sid>", methods=["GET"])
def session_files(sid): def session_files(sid):
"""Отладка: список файлов сессии с размерами (для теста загрузки).""" """Отладка: список файлов сессии с размерами (для теста загрузки)."""
if not current_app.config.get("ENABLE_SESSION_FILES"):
return jsonify({"ok": False, "error": "Not found"}), 404
files = get_files(sid) files = get_files(sid)
if files is None: if files is None:
return jsonify({"ok": False, "error": "Session not found"}), 404 return jsonify({"ok": False, "error": "Session not found"}), 404
@@ -118,6 +121,9 @@ def process_stream(sid):
all_files = [(fname, content, "") for fname, content in files] all_files = [(fname, content, "") for fname, content in files]
log.info("process_stream: start sid=%s files=%d", sid, len(all_files)) log.info("process_stream: start sid=%s files=%d", sid, len(all_files))
# A failed rerun must not leave an older ZIP or mapping downloadable.
store_result(sid, None)
store_csv(sid, None)
# Сессия живёт, пока идёт обработка (TTL возобновляется в finally генератора) # Сессия живёт, пока идёт обработка (TTL возобновляется в finally генератора)
pause_ttl(sid) pause_ttl(sid)
@@ -172,9 +178,16 @@ def process_stream(sid):
log.info("worker: done sid=%s in %.1fs tokens=%d llm_sec=%.1f zip_len=%d", log.info("worker: done sid=%s in %.1fs tokens=%d llm_sec=%.1f zip_len=%d",
sid, dt, llm.tokens_total, llm.llm_sec, len(zip_data)) sid, dt, llm.tokens_total, llm.llm_sec, len(zip_data))
q.put(("result", zip_data, csv_str, stats)) q.put(("result", zip_data, csv_str, stats))
except Exception as e: except LLMIncompleteError as e:
log.error("worker: exception sid=%s: %r\n%s", sid, e, traceback.format_exc()) log.exception("worker: LLM incomplete sid=%s chunks=%d", sid, e.llm_errors)
q.put(("error", repr(e))) q.put(("error", {
"error": "Processing stopped because the LLM could not process all fragments",
"llm_incomplete": True,
"llm_errors": e.llm_errors,
}))
except Exception:
log.exception("worker: processing failed sid=%s", sid)
q.put(("error", {"error": "Processing failed"}))
threading.Thread(target=worker, daemon=True).start() threading.Thread(target=worker, daemon=True).start()
log.debug("process_stream: worker thread started sid=%s", sid) log.debug("process_stream: worker thread started sid=%s", sid)
@@ -266,7 +279,7 @@ def process_stream(sid):
store_csv(sid, csv_str) store_csv(sid, csv_str)
count = 0 count = 0
with zipfile.ZipFile(io.BytesIO(zip_data)) as zf: with zipfile.ZipFile(io.BytesIO(zip_data)) as zf:
count = len([n for n in zf.namelist() if n != "mapping.csv"]) count = len(zf.namelist())
log.info("process_stream: complete sid=%s count=%d stats=%r", sid, count, stats) log.info("process_stream: complete sid=%s count=%d stats=%r", sid, count, stats)
try: try:
yield ( yield (
@@ -295,10 +308,11 @@ def process_stream(sid):
return return
elif kind == "error": elif kind == "error":
_, msg = evt _, error_data = evt
log.error("process_stream: error event sid=%s msg=%r", sid, msg) log.error("process_stream: error event sid=%s llm_incomplete=%s",
sid, error_data.get("llm_incomplete", False))
try: try:
yield f"event: proc_error\ndata: {json.dumps({'error': msg})}\n\n" yield f"event: proc_error\ndata: {json.dumps(error_data)}\n\n"
except _disconnect_exceptions() as e: except _disconnect_exceptions() as e:
log.warning("process_stream: disconnect on error sid=%s err=%r", sid, e) log.warning("process_stream: disconnect on error sid=%s err=%r", sid, e)
return return
@@ -322,6 +336,8 @@ def process(sid):
return jsonify({"ok": False, "error": "Session not found"}), 404 return jsonify({"ok": False, "error": "Session not found"}), 404
if not files: if not files:
return jsonify({"ok": False, "error": "No files"}), 400 return jsonify({"ok": False, "error": "No files"}), 400
store_result(sid, None)
store_csv(sid, None)
try: try:
llm = LLMClient() llm = LLMClient()
all_files = [(fname, content, "") for fname, content in files] all_files = [(fname, content, "") for fname, content in files]
@@ -334,9 +350,17 @@ def process(sid):
if csv_str: if csv_str:
store_csv(sid, csv_str) store_csv(sid, csv_str)
return jsonify({"ok": True, "status": "done"}) return jsonify({"ok": True, "status": "done"})
except Exception as e: except LLMIncompleteError as e:
traceback.print_exc() log.exception("process: LLM incomplete sid=%s chunks=%d", sid, e.llm_errors)
return jsonify({"ok": False, "error": str(e)}), 500 return jsonify({
"ok": False,
"error": "Processing stopped because the LLM could not process all fragments",
"llm_incomplete": True,
"llm_errors": e.llm_errors,
}), 500
except Exception:
log.exception("process: processing failed sid=%s", sid)
return jsonify({"ok": False, "error": "Processing failed"}), 500
@api_bp.route("/download/<sid>", methods=["GET"]) @api_bp.route("/download/<sid>", methods=["GET"])
+10 -4
View File
@@ -457,7 +457,7 @@
<div class="card-header">Обфускация документов</div> <div class="card-header">Обфускация документов</div>
<div class="card-body"> <div class="card-body">
<p class="sub"> <p class="sub">
Загрузите документы (.docx, .doc, .pdf, .txt, .md, .zip) — получите ZIP с обезличенными копиями.<br> Загрузите документы (.docx, .doc, .pdf, .txt, .md, .csv, .zip) — получите ZIP с обезличенными копиями.<br>
CSV с таблицей замен скачивается отдельно.<br> CSV с таблицей замен скачивается отдельно.<br>
<span style="color:#c0392b;">Ограничения: один файл не более 50 МБ, суммарно не более 500 МБ. Файлы сверх <span style="color:#c0392b;">Ограничения: один файл не более 50 МБ, суммарно не более 500 МБ. Файлы сверх
лимитов помечаются статусом «пропущен» и не участвуют в обфускации. Пустые, повреждённые файлы и сканы без лимитов помечаются статусом «пропущен» и не участвуют в обфускации. Пустые, повреждённые файлы и сканы без
@@ -507,7 +507,7 @@
</div> </div>
<script src="/file-picker/file-picker.iife.js?v={{ version }}"></script> <script src="/file-picker/file-picker.iife.js?v={{ version }}"></script>
<script> <script>
const DOC_EXTS = ['.pdf', '.doc', '.docx', '.txt', '.md']; const DOC_EXTS = ['.pdf', '.doc', '.docx', '.txt', '.md', '.csv'];
const ub = document.getElementById('uploadBtn'); const ub = document.getElementById('uploadBtn');
const cb = document.getElementById('cancelBtn'); const cb = document.getElementById('cancelBtn');
const nb = document.getElementById('newSessionBtn'); const nb = document.getElementById('newSessionBtn');
@@ -853,8 +853,14 @@
const d = JSON.parse(e.data); const d = JSON.parse(e.data);
finishProcUI(); finishProcUI();
setBusy(false); setBusy(false);
sessionDone = false;
db.classList.remove('show');
document.getElementById('statsBlock').classList.remove('show');
nb.style.display = 'none';
st.className = 'status error'; st.className = 'status error';
st.textContent = 'Ошибка обработки: ' + (d.error || 'неизвестная ошибка'); st.textContent = d.llm_incomplete
? 'Обработка остановлена: LLM не обработал ' + d.llm_errors + ' фрагментов; результат не выдан'
: 'Ошибка обработки: ' + (d.error || 'неизвестная ошибка');
resolve(); resolve();
}); });
activeES.onerror = function () { activeES.onerror = function () {
@@ -926,7 +932,7 @@
onclick="event.stopPropagation()"> onclick="event.stopPropagation()">
<h3 style="margin-bottom:12px">Obfuscator — обфускация документов</h3> <h3 style="margin-bottom:12px">Obfuscator — обфускация документов</h3>
<p style="font-size:13px;color:var(--muted);line-height:1.6"> <p style="font-size:13px;color:var(--muted);line-height:1.6">
Загрузите документы (.docx, .doc, .pdf, .txt, .md, .zip) — получите ZIP-архив с обезличенными копиями. Загрузите документы (.docx, .doc, .pdf, .txt, .md, .csv, .zip) — получите ZIP-архив с обезличенными копиями.
Персональные данные, реквизиты компаний, телефоны, email и другие приватные сведения заменяются на обезличенные Персональные данные, реквизиты компаний, телефоны, email и другие приватные сведения заменяются на обезличенные
коды. коды.
CSV-файл с таблицей соответствия оригинал→замена скачивается отдельно. CSV-файл с таблицей соответствия оригинал→замена скачивается отдельно.
+2 -2
View File
@@ -5,8 +5,8 @@ from typing import Optional
from .state import _sessions, _lock from .state import _sessions, _lock
def store_csv(sid: str, csv_str: str) -> bool: def store_csv(sid: str, csv_str: Optional[str]) -> bool:
"""Сохранить CSV с таблицей замен. """Сохранить mapping CSV или инвалидировать его, передав None.
Returns: Returns:
True если сохранено, False если сессии нет. True если сохранено, False если сессии нет.
+2 -2
View File
@@ -5,8 +5,8 @@ from typing import Optional
from .state import _sessions, _lock from .state import _sessions, _lock
def store_result(sid: str, zip_data: bytes) -> bool: def store_result(sid: str, zip_data: Optional[bytes]) -> bool:
"""Сохранить результат обработки (ZIP-архив). """Сохранить ZIP-результат или инвалидировать его, передав None.
Returns: Returns:
True если сохранено, False если сессии нет. True если сохранено, False если сессии нет.
+2 -1
View File
@@ -77,7 +77,8 @@ def create_upload_refs_blueprint(cfg: dict = None) -> Blueprint:
added = 0 added = 0
transport = cfg.get("httpxTransport") or current_app.config.get("UPLOAD_HTTPX_TRANSPORT") transport = cfg.get("httpxTransport") or current_app.config.get("UPLOAD_HTTPX_TRANSPORT")
try: try:
with httpx.Client(transport=transport, timeout=pull_timeout, follow_redirects=True) as client: # Redirects could bypass validation against the trusted VM-buffer prefix.
with httpx.Client(transport=transport, timeout=pull_timeout, follow_redirects=False) as client:
for ref in refs: for ref in refs:
name = safe_name(ref.get("name") or "") name = safe_name(ref.get("name") or "")
url = ref.get("url") or "" url = ref.get("url") or ""