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 os
import time import time
import logging import logging
from concurrent.futures import ThreadPoolExecutor
from typing import Dict, List, Tuple, Callable, Optional from typing import Dict, List, Tuple, Callable, Optional
from . import extractor from . import extractor
@@ -78,6 +79,7 @@ class TwoPassObfuscator:
""" """
self._mapping: Dict[str, str] = {} self._mapping: Dict[str, str] = {}
self._sorted_keys: List[str] = [] self._sorted_keys: List[str] = []
self._compiled_re = None # кэш скомпилированного regex (A2)
self._counters: Dict[str, int] = {} # Глобальные счётчики токенов self._counters: Dict[str, int] = {} # Глобальные счётчики токенов
self._llm_client = llm_client self._llm_client = llm_client
@@ -116,6 +118,20 @@ class TwoPassObfuscator:
file_times: List[float] = [0.0] * total file_times: List[float] = [0.0] * total
skipped: set = set() # имена файлов, которые не удалось обработать (битые) 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): for i, (fname, content, ctype) in enumerate(files):
display_name = os.path.basename(fname) or fname display_name = os.path.basename(fname) or fname
if progress_cb: if progress_cb:
@@ -123,7 +139,10 @@ class TwoPassObfuscator:
t0 = time.time() t0 = time.time()
try: 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: except Exception as e:
log.warning("obfuscate: skip file %r: %r", fname, e) log.warning("obfuscate: skip file %r: %r", fname, e)
skipped.add(fname) skipped.add(fname)
@@ -145,6 +164,8 @@ class TwoPassObfuscator:
self._sorted_keys = sorted( self._sorted_keys = sorted(
self._mapping.keys(), key=len, reverse=True self._mapping.keys(), key=len, reverse=True
) )
# A2: кэш скомпилированного regex — один на все файлы прохода 2
self._compiled_re = replacer._build_combined_re(self._sorted_keys)
# ── Проход 2: замена сущностей ── # ── Проход 2: замена сущностей ──
results: List[Tuple[str, bytes]] = [] results: List[Tuple[str, bytes]] = []
@@ -166,7 +187,7 @@ class TwoPassObfuscator:
# .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(
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' md_name = os.path.splitext(fname)[0] + '.md'
obf_content = replaced.encode('utf-8') obf_content = replaced.encode('utf-8')
@@ -175,7 +196,7 @@ class TwoPassObfuscator:
# Все форматы → Markdown → замена в тексте # Все форматы → Markdown → замена в тексте
md_text = all_texts.get(fname, '') md_text = all_texts.get(fname, '')
replaced = replacer.apply_replacements( replaced = replacer.apply_replacements(
md_text, self._mapping, self._sorted_keys md_text, self._mapping, self._sorted_keys, self._compiled_re
) )
# Имя файла: заменить расширение на .md # Имя файла: заменить расширение на .md
md_name = os.path.splitext(fname)[0] + '.md' md_name = os.path.splitext(fname)[0] + '.md'
@@ -197,6 +218,7 @@ class TwoPassObfuscator:
# Очистка состояния (обфускатор может использоваться повторно) # Очистка состояния (обфускатор может использоваться повторно)
self._mapping.clear() self._mapping.clear()
self._sorted_keys.clear() self._sorted_keys.clear()
self._compiled_re = None
self._counters.clear() 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 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 к строке. """Применить все замены из словаря mapping к строке.
Ключи применяются в порядке убывания длины (sorted_keys). Ключи применяются в порядке убывания длины (sorted_keys).
@@ -49,6 +71,8 @@ def apply_replacements(text: str, mapping: Dict[str, str], sorted_keys: List[str
text: Исходный текст text: Исходный текст
mapping: Словарь {оригинал: замена} mapping: Словарь {оригинал: замена}
sorted_keys: Ключи mapping, отсортированные по длине (убывание) sorted_keys: Ключи mapping, отсортированные по длине (убывание)
compiled_re: Опционально, заранее скомпилированный regex из _build_combined_re
(кэш между файлами). Если None — компилируется здесь.
Returns: Returns:
Текст с заменами Текст с заменами
@@ -61,21 +85,10 @@ def apply_replacements(text: str, mapping: Dict[str, str], sorted_keys: List[str
if not sorted_keys: if not sorted_keys:
return result return result
alternatives = [] combined = compiled_re if compiled_re is not None else _build_combined_re(sorted_keys)
for original in sorted_keys: if combined is None:
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 result return result
combined = re.compile('|'.join(alternatives))
matched_keys = set() matched_keys = set()
def _replace(match): 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) sys.path.insert(0, _sys_path_root)
# Версия приложения (меняется при изменениях) # Версия приложения (меняется при изменениях)
VERSION = "0.0.54" VERSION = "0.0.55"
def setup_logging(): def setup_logging():