v0.0.55: A1 threading для .doc (liberta), A2 кэш compiled regex — безопасное ускорение
Deploy drhider / validate (push) Canceled after 0s

This commit is contained in:
“Naeel”
2026-08-20 09:20:30 +03:00
parent 59a1924bd0
commit dfa325a5c5
5 changed files with 139 additions and 18 deletions
@@ -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.
@@ -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, память). С цифрами.
+25 -3
View File
@@ -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()
+27 -14
View File
@@ -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'(?<!\w)' + esc + r'(?!\w)')
else:
alternatives.append(esc)
if not alternatives:
return None
return re.compile('|'.join(alternatives))
def apply_replacements(text: str, mapping: Dict[str, str], sorted_keys: List[str],
compiled_re=None) -> 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'(?<!\w)' + esc + r'(?!\w)')
else:
alternatives.append(esc)
if not alternatives:
combined = compiled_re if compiled_re is not None else _build_combined_re(sorted_keys)
if combined is None:
return result
combined = re.compile('|'.join(alternatives))
matched_keys = set()
def _replace(match):
+1 -1
View File
@@ -21,7 +21,7 @@ if _sys_path_root not in sys.path:
sys.path.insert(0, _sys_path_root)
# Версия приложения (меняется при изменениях)
VERSION = "0.0.54"
VERSION = "0.0.55"
def setup_logging():