processing: clarify per-file stages and outcomes

Expose extraction, analysis, and replacement progress in the file table; preserve row mapping for duplicates and collapsed archives.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
Repinoid
2026-10-24 11:54:09 +03:00
co-authored by Copilot
parent f476d6cd14
commit 1b0bcd298a
8 changed files with 359 additions and 138 deletions
+39 -21
View File
@@ -32,7 +32,7 @@ class LLMIncompleteError(Exception):
def _dedupe_file_names(
files: List[Tuple[str, bytes, str]]
) -> List[Tuple[str, bytes, str]]:
) -> Tuple[List[Tuple[str, bytes, str]], List[List[int]]]:
"""Уникализировать имена файлов перед обработкой.
Точный дубль (одинаковое имя + одинаковое содержимое) пропускается.
@@ -42,17 +42,24 @@ def _dedupe_file_names(
files: [(filename, content_bytes, content_type), ...]
Returns:
Новый список с уникальными именами (порядок сохранён).
Новый список с уникальными именами (порядок сохранён) и исходные индексы,
соответствующие каждому файлу. Точные дубликаты ссылаются на один результат.
"""
seen: Dict[str, bytes] = {}
out: List[Tuple[str, bytes, str]] = []
source_indices: List[List[int]] = []
source_to_output: Dict[Tuple[str, bytes], int] = {}
for fname, content, ctype in files:
for source_idx, (fname, content, ctype) in enumerate(files):
candidate = resolve_name(fname, content, seen)
if candidate is not None:
out.append((candidate, content, ctype))
source_to_output[(fname, content)] = len(out) - 1
source_indices.append([source_idx])
else:
source_indices[source_to_output[(fname, content)]].append(source_idx)
return out
return out, source_indices
class TwoPassObfuscator:
@@ -89,7 +96,7 @@ class TwoPassObfuscator:
def obfuscate(
self, files: List[Tuple[str, bytes, str]],
progress_cb: Optional[Callable[[str, int, int, str], None]] = None,
progress_cb: Optional[Callable[[str, int, int, str, float, List[int]], None]] = None,
cancel_event=None, file_progress=None
) -> Tuple[bytes, str, Optional[dict]]:
"""Обфусцировать список файлов.
@@ -98,14 +105,18 @@ class TwoPassObfuscator:
Args:
files: [(filename, content_bytes, content_type), ...]
progress_cb: Опциональный коллбек (phase, idx, total, fname, elapsed),
где phase ∈ {"start", "done"}, idx — 0-based индекс,
elapsed — время обработки конкретного файла (сек).
progress_cb: Опциональный коллбек (phase, idx, total, fname, elapsed, source_idxs),
phase — extract_start/extract_done/extract_failed или
replace_start/replace_done/replace_failed; idx — 0-based индекс,
elapsed — время обработки конкретного файла (сек),
source_idxs — индексы строк исходного списка, включая дубликаты.
cancel_event: threading.Event — мягкая остановка. Проверяется между
файлами LLM и между файлами замены; текущий файл добирается.
file_progress: callable(event, fname, **fields) — прогресс по файлам/чанкам.
События: extract_done{total_chars}, file_start{chars,chunks,idx},
file_chunk{chunks_done,chunks_total,idx}, file_done{elapsed,idx}.
События: extract_complete{total_chars},
file_start{chars,chunks,idx,source_idxs},
file_chunk{chunks_done,chunks_total,idx,source_idxs},
file_done{elapsed,idx,source_idxs}.
Returns:
(zip_bytes, csv_string, meta):
@@ -116,7 +127,7 @@ class TwoPassObfuscator:
"""
# ── Предобработка: распаковать ZIP + уникализировать имена ──
files = extractor.expand_zips(files)
files = _dedupe_file_names(files)
files, source_indices = _dedupe_file_names(files)
# имя файла (внутр. ключ all_texts) -> idx в files (для событий file_progress)
name_to_idx = {fname: i for i, (fname, _, _) in enumerate(files)}
@@ -126,7 +137,9 @@ class TwoPassObfuscator:
if event == "file_done":
llm_done.add(fname)
if file_progress:
fields["idx"] = name_to_idx.get(fname)
idx = name_to_idx.get(fname)
fields["idx"] = idx
fields["source_idxs"] = source_indices[idx] if idx is not None else []
file_progress(event, fname, **fields)
try:
@@ -150,7 +163,7 @@ class TwoPassObfuscator:
break
display_name = os.path.basename(fname) or fname
if progress_cb:
progress_cb("start", i, total, display_name, 0.0)
progress_cb("extract_start", i, total, display_name, 0.0, source_indices[i])
t0 = time.time()
try:
@@ -159,9 +172,11 @@ class TwoPassObfuscator:
log.warning("obfuscate: skip file %r: %r", fname, e)
skipped.add(fname)
if progress_cb:
progress_cb("done", i, total, display_name, 0.0)
progress_cb("extract_failed", i, total, display_name, 0.0, source_indices[i])
continue
all_texts[fname] = text
if progress_cb:
progress_cb("extract_done", i, total, display_name, 0.0, source_indices[i])
# Regex-сканирование (быстрое, локальное)
if text and not text.startswith("[DOC binary"):
@@ -171,7 +186,7 @@ class TwoPassObfuscator:
# Объём извлечённого текста — для оценки времени (глобальной ETA)
total_chars = sum(len(t) for t in all_texts.values())
if file_progress:
file_progress("extract_done", None, total_chars=total_chars,
file_progress("extract_complete", None, total_chars=total_chars,
per_file={fname: len(t) for fname, t in all_texts.items()})
# LLM-сканирование (получает уже найденное regex'ом чтобы не дублировать)
@@ -208,8 +223,6 @@ class TwoPassObfuscator:
# Битый файл (не удалось извлечь текст) — пропускаем
if fname in skipped:
if progress_cb:
progress_cb("done", i, total, display_name, 0.0)
continue
# При прерывании в LLM — в результат идут ТОЛЬКО файлы с завершённым LLM
@@ -223,6 +236,8 @@ class TwoPassObfuscator:
cancelled = True
break
if progress_cb:
progress_cb("replace_start", i, total, display_name, 0.0, source_indices[i])
t0 = time.time()
obf_content = content # По умолчанию — без изменений
@@ -235,7 +250,7 @@ class TwoPassObfuscator:
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)
progress_cb("replace_failed", i, total, display_name, 0.0, source_indices[i])
continue
elif fname.endswith('.doc'):
# .doc — конвертируется через liberta, обрабатывается как .docx
@@ -260,7 +275,10 @@ class TwoPassObfuscator:
file_times[i] += time.time() - t0
results.append((fname, obf_content))
if progress_cb:
progress_cb("done", i, total, display_name, round(file_times[i], 2))
progress_cb(
"replace_done", i, total, display_name,
round(file_times[i], 2), source_indices[i],
)
# ── Сборка результата ──
csv_str = builder.build_mapping_csv(self._mapping)
@@ -286,7 +304,7 @@ class TwoPassObfuscator:
def obfuscate_files(
files: List[Tuple[str, bytes, str]], llm_client=None,
progress_cb: Optional[Callable[[str, int, int, str], None]] = None,
progress_cb: Optional[Callable[[str, int, int, str, float, List[int]], None]] = None,
cancel_event=None, file_progress=None
) -> Tuple[bytes, str, Optional[dict]]:
"""Обфусцировать список файлов — удобная функция.
@@ -296,7 +314,7 @@ def obfuscate_files(
Args:
files: [(filename, content_bytes, content_type), ...]
llm_client: Опциональный LLM-клиент
progress_cb: Опциональный коллбек (phase, idx, total, fname)
progress_cb: Опциональный коллбек (phase, idx, total, fname, elapsed, source_idxs)
cancel_event: threading.Event — мягкая остановка (см. TwoPassObfuscator.obfuscate)
file_progress: callable(event, fname, **fields) — прогресс по файлам/чанкам
+13 -4
View File
@@ -14,7 +14,7 @@ import json
import random
import time
import logging
from concurrent.futures import ThreadPoolExecutor
from concurrent.futures import ThreadPoolExecutor, as_completed
from typing import Dict, List, Optional
from .config import (
@@ -294,13 +294,22 @@ def scan_llm_ner(all_texts: Dict[str, str], mapping: Dict[str, str],
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:
chunk_results = [None] * len(futures)
future_indexes = {future: index for index, future in enumerate(futures)}
completed = 0
for fut in as_completed(futures):
index = future_indexes[fut]
try:
all_ent.extend(fut.result())
chunk_results[index] = fut.result()
except Exception as e:
llm_failures += 1
log.warning("LLM NER chunk failed in %r: %s", fname, e)
chunk_results[index] = []
completed += 1
if file_progress:
file_progress("file_chunk", fname,
chunks_done=completed, chunks_total=len(chunks))
all_ent = [ent for chunk_result in chunk_results for ent in chunk_result]
else:
all_ent = []
for k, c in enumerate(chunks):