From dfa325a5c57c193b474ec6e4b1d1088e32dfc306 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E2=80=9CNaeel=E2=80=9D?= Date: Thu, 20 Aug 2026 09:20:30 +0300 Subject: [PATCH] =?UTF-8?q?v0.0.55:=20A1=20threading=20=D0=B4=D0=BB=D1=8F?= =?UTF-8?q?=20.doc=20(liberta),=20A2=20=D0=BA=D1=8D=D1=88=20compiled=20reg?= =?UTF-8?q?ex=20=E2=80=94=20=D0=B1=D0=B5=D0=B7=D0=BE=D0=BF=D0=B0=D1=81?= =?UTF-8?q?=D0=BD=D0=BE=D0=B5=20=D1=83=D1=81=D0=BA=D0=BE=D1=80=D0=B5=D0=BD?= =?UTF-8?q?=D0=B8=D0=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...6-08-20-honest-answer-and-A-implemented.md | 44 +++++++++++++++++++ .../2026-08-20-sonnet-query-honest-speed.md | 42 ++++++++++++++++++ drhider/obfuscator.py | 28 ++++++++++-- drhider/replacer.py | 41 +++++++++++------ site/app.py | 2 +- 5 files changed, 139 insertions(+), 18 deletions(-) create mode 100644 History/2026-08-20-honest-answer-and-A-implemented.md create mode 100644 History/2026-08-20-sonnet-query-honest-speed.md diff --git a/History/2026-08-20-honest-answer-and-A-implemented.md b/History/2026-08-20-honest-answer-and-A-implemented.md new file mode 100644 index 0000000..2878b1d --- /dev/null +++ b/History/2026-08-20-honest-answer-and-A-implemented.md @@ -0,0 +1,44 @@ +# 2026-08-20 — Честный ответ Sonnet об ускорении + реализация А (v0.0.55) + +## Честный ответ Sonnet (History/2026-08-20-sonnet-query-honest-speed.md) +Вопрос: можно ли значительно ускорить обработку, не рискуя устойчивостью/таблицами? + +Ответ Sonnet (принято, согласуется с замерами): +- **Безопасно ускорить extract_text() на pdfplumber НЕЛЬЗЯ** — pdfminer pure Python, + CPU-bound, GIL полностью блокирует threading. (Подтверждено: extract_text 64.4с на Spartan10.) +- A) Безопасно-просто: + - A1. Threading для .doc (liberta, IO-bound) — N×~120с → ~120с при нескольких .doc. + - A2. Кэш compiled regex между файлами (проход 2). +- B) Заметный выигрыш, но с рисками (ОТЛОЖЕНО): + - B1. ProcessPool по файлам (spawn) — 3 PDF × 25с → ~30с (только 2+ PDF, CPU=2). + - B2. ProcessPool по страницам (spawn) — Spartan10 64с → ~35с (сложно: content, порядок). +- C) Не стоит: + - fork ProcessPool при session в памяти (COW-раздутие, 4Gi на пределе) — только spawn. + - Threading по страницам/файлам PDF — GIL, ноль. +- Итог Sonnet: безопасного значительного ускорения ОДНОГО PDF нет. Для набора 2+ PDF — + B1. При CPU=2 потолок 2x. + +## Решение пользователя +«Делай по А, безопасно» — реализованы ТОЛЬКО A1 и A2. B — НЕ отложено (не делаем). + +## Реализация (v0.0.55) + +### A1 — threading для .doc (obfuscator.py, проход 1) +- .doc файлы (конвертация через HTTP liberta, IO-bound) запускаются в + ThreadPoolExecutor(max_workers=min(4, N_doc)). +- Основной цикл сохраняет порядок: для .doc берёт future.result(), остальные — + последовательно. progress_cb/порядок start/done не меняются. +- Выигрыш: HTTP-конвертация .doc перекрывается с CPU-обработкой PDF/docx. + +### A2 — кэш compiled regex (replacer.py + obfuscator.py) +- replacer.py: вынесен `_build_combined_re(sorted_keys)`, `apply_replacements` получил + параметр `compiled_re=None` (если None — компилирует сам). +- obfuscator.py: после формирования `self._sorted_keys` компилируется один раз + `self._compiled_re`, передаётся во все вызовы apply_replacements (проход 2). +- __init__/finally: _compiled_re инициализируется/очищается. + +## Проверка +- Все 106 тестов OK (test_replacer 10/10 — apply_replacements с compiled_re). +- Интеграция: flat/ (10 файлов) → 5 обработано (EB.md, EB1.md, Spartan10, TKM, mini_78b), + битые пропущены, .doc через liberta в потоках. 78.3с (в основном Spartan10 64с extract_text). +- VERSION 0.0.55. diff --git a/History/2026-08-20-sonnet-query-honest-speed.md b/History/2026-08-20-sonnet-query-honest-speed.md new file mode 100644 index 0000000..995c908 --- /dev/null +++ b/History/2026-08-20-sonnet-query-honest-speed.md @@ -0,0 +1,42 @@ +# Промпт для Sonnet — честный ответ: можно ли ускорить без риска устойчивости? + +## Роль +Отвечай ЧЕСТНО и ПО ДЕЛУ. Без воды, без лирики. Если ускорение невозможно без +компромисса (устойчивость/таблицы/риск) — прямо скажи «нет» и объясни почему. +Не предлагай «смену библиотеки» — уже пробовали, PyMuPDF теряет таблицы (40 vs 94), +ТАБЛИЦЫ КРИТИЧНЫ для пользователя. Учитывай это ограничение железно. + +## ФАКТЫ (результаты наших замеров, НЕ догадки) +1. `Spartan10Manual.pdf` (14.8 МБ, 619 страниц, скан/руководство): + - `extract_text()` суммарно = **64.4с** (104мс/стр) — УЗКОЕ МЕСТО + - `extract_tables()` суммарно = **0.2с** (0мс/стр) — ничтожно + - на 272 страницах без линий extract_tables = 0.0с + → Твоё прошлое предположение «на сканах дорогой extract_tables» НЕ подтвердилось. + Узкое место — ИЗВЛЕЧЕНИЕ ТЕКСТА, а не таблиц. Не повторяй эту ошибку. +2. Большой PDF 19 МБ (0144-03-2023_отчет об оценке.pdf, 141 стр, 94 таблицы) — ~25-26с. +3. CPU пода = 2 ядра, Memory = 4Gi. Воркер один, последовательный. +4. Разброс времени одного файла между запусками: 70с → 470с (Spartan10). Причины + не установлены точно (подозрение: CPU throttling/нагрузка пода, декомпрессия битмапов). +5. `.doc` обрабатывается через HTTP-сервис liberta (IO-bound). +6. apply_replacements — один regex из всех ключей (уже оптимизирован). +7. Сессия хранит файлы в памяти до 500 МБ (session.py). ProcessPool с fork — + риск COW-копии памяти. + +## Вопрос (ответь честно) +Можно ли ЗНАЧИТЕЛЬНО ускорить обработку (цель — сократить время на больших PDF/наборах), +НЕ рискуя: +- устойчивостью (битые файлы, SSE, память пода 4Gi), +- потерей таблиц (критично), +- сложностью поддержки (код должен остаться понятным)? + +Требования к ответу: +- Дай КОНКРЕТНЫЕ пункты, каждый: что менять / где / ожидаемый эффект (с цифрами из фактов выше) / риски. +- Раздели на: + A) безопасно и просто (готов внедрить сейчас, низкий риск), + B) заметный выигрыш, но с рисками/сложностью (нужен осознанный выбор), + C) рискованно/не стоит (объясни почему). +- Если для БЕЗОПАСНОГО варианта реального выигрыша нет — так и скажи: «безопасно ускорить + значительно нельзя», и предложи что реально можно сделать без риска (даже если эффект мал). +- НЕ предлагай смену pdfplumber/PyMuPDF и не предлагай то, что ломает таблицы. +- Оцени РЕАЛЬНО: при CPU=2 параллельность текста по страницам даст хоть что-то? + (GIL/процессы, overhead fork/spawn, память). С цифрами. diff --git a/drhider/obfuscator.py b/drhider/obfuscator.py index 8f51c89..8bb2071 100644 --- a/drhider/obfuscator.py +++ b/drhider/obfuscator.py @@ -11,6 +11,7 @@ import io import os import time import logging +from concurrent.futures import ThreadPoolExecutor from typing import Dict, List, Tuple, Callable, Optional from . import extractor @@ -78,6 +79,7 @@ class TwoPassObfuscator: """ self._mapping: Dict[str, str] = {} self._sorted_keys: List[str] = [] + self._compiled_re = None # кэш скомпилированного regex (A2) self._counters: Dict[str, int] = {} # Глобальные счётчики токенов self._llm_client = llm_client @@ -116,6 +118,20 @@ class TwoPassObfuscator: file_times: List[float] = [0.0] * total skipped: set = set() # имена файлов, которые не удалось обработать (битые) + # A1: .doc конвертируются через HTTP-сервис liberta (IO-bound). + # Запускаем их в потоках — GIL не мешает (ожидание сети), а основное + # время (PDF/docx, CPU-bound) перекрывается с HTTP-конвертацией .doc. + doc_futures: Dict[int, object] = {} + doc_indices = [i for i, (f, _, _) in enumerate(files) + if f.lower().endswith('.doc')] + if doc_indices: + with ThreadPoolExecutor(max_workers=min(4, len(doc_indices))) as _ex: + for i in doc_indices: + fname, content, ctype = files[i] + doc_futures[i] = _ex.submit( + extractor.extract_text, fname, content, ctype + ) + for i, (fname, content, ctype) in enumerate(files): display_name = os.path.basename(fname) or fname if progress_cb: @@ -123,7 +139,10 @@ class TwoPassObfuscator: t0 = time.time() try: - text = extractor.extract_text(fname, content, ctype) + if i in doc_futures: + text = doc_futures[i].result() + else: + text = extractor.extract_text(fname, content, ctype) except Exception as e: log.warning("obfuscate: skip file %r: %r", fname, e) skipped.add(fname) @@ -145,6 +164,8 @@ class TwoPassObfuscator: self._sorted_keys = sorted( self._mapping.keys(), key=len, reverse=True ) + # A2: кэш скомпилированного regex — один на все файлы прохода 2 + self._compiled_re = replacer._build_combined_re(self._sorted_keys) # ── Проход 2: замена сущностей ── results: List[Tuple[str, bytes]] = [] @@ -166,7 +187,7 @@ class TwoPassObfuscator: # .doc — конвертируется через liberta, обрабатывается как .docx md_text = all_texts.get(fname, '') replaced = replacer.apply_replacements( - md_text, self._mapping, self._sorted_keys + md_text, self._mapping, self._sorted_keys, self._compiled_re ) md_name = os.path.splitext(fname)[0] + '.md' obf_content = replaced.encode('utf-8') @@ -175,7 +196,7 @@ class TwoPassObfuscator: # Все форматы → Markdown → замена в тексте md_text = all_texts.get(fname, '') replaced = replacer.apply_replacements( - md_text, self._mapping, self._sorted_keys + md_text, self._mapping, self._sorted_keys, self._compiled_re ) # Имя файла: заменить расширение на .md md_name = os.path.splitext(fname)[0] + '.md' @@ -197,6 +218,7 @@ class TwoPassObfuscator: # Очистка состояния (обфускатор может использоваться повторно) self._mapping.clear() self._sorted_keys.clear() + self._compiled_re = None self._counters.clear() diff --git a/drhider/replacer.py b/drhider/replacer.py index 47d4879..8e445e8 100644 --- a/drhider/replacer.py +++ b/drhider/replacer.py @@ -35,7 +35,29 @@ def _md_tolerant_pattern(original: str) -> str: return MD + MD.join(re.escape(p) for p in parts) + MD -def apply_replacements(text: str, mapping: Dict[str, str], sorted_keys: List[str]) -> str: +def _build_combined_re(sorted_keys: List[str]): + """Построить единый regex из всех ключей (убывание длины, границы слова). + + Возвращает None, если нет ни одного применимого ключа. + """ + alternatives = [] + for original in sorted_keys: + if not original: + continue + esc = re.escape(original) + # Границы слова — только если сущность обрамлена буквами/цифрами + if original[0].isalnum() and original[-1].isalnum(): + alternatives.append(r'(? str: """Применить все замены из словаря mapping к строке. Ключи применяются в порядке убывания длины (sorted_keys). @@ -49,6 +71,8 @@ def apply_replacements(text: str, mapping: Dict[str, str], sorted_keys: List[str text: Исходный текст mapping: Словарь {оригинал: замена} sorted_keys: Ключи mapping, отсортированные по длине (убывание) + compiled_re: Опционально, заранее скомпилированный regex из _build_combined_re + (кэш между файлами). Если None — компилируется здесь. Returns: Текст с заменами @@ -61,21 +85,10 @@ def apply_replacements(text: str, mapping: Dict[str, str], sorted_keys: List[str if not sorted_keys: return result - alternatives = [] - for original in sorted_keys: - if not original: - continue - esc = re.escape(original) - # Границы слова — только если сущность обрамлена буквами/цифрами - if original[0].isalnum() and original[-1].isalnum(): - alternatives.append(r'(?