v0.0.39: оптимизация apply_replacements — однопроходная замена вместо N×re.sub (ускорение в разы)
Deploy drhider / validate (push) Canceled after 0s
Deploy drhider / validate (push) Canceled after 0s
This commit is contained in:
@@ -0,0 +1,45 @@
|
|||||||
|
# v0.0.39 — оптимизация apply_replacements (однопроходная замена) — 2026-08-19
|
||||||
|
|
||||||
|
**Дата:** 2026-08-19
|
||||||
|
**Версия:** 0.0.38 → 0.0.39
|
||||||
|
**Файл:** `drhider/replacer.py`, `site/app.py` (версия)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Суть
|
||||||
|
|
||||||
|
Обфускация PDF 19 МБ шла 132.7с, из них ИИ (LLM) всего 7с — остальное (~125с)
|
||||||
|
тратил основной код. Замер выявил узкое место: `apply_replacements`.
|
||||||
|
|
||||||
|
## Причина
|
||||||
|
|
||||||
|
`apply_replacements` для **каждой** сущности из mapping делала **2 полных
|
||||||
|
прохода `re.sub` по всему тексту** (основной + фоллбэк `_md_tolerant_pattern`).
|
||||||
|
Сложность O(N_сущностей × размер_текста × 2) — квадратичная по числу сущностей.
|
||||||
|
При 300+ сущностей и тексте в несколько МБ — десятки секунд.
|
||||||
|
|
||||||
|
Замер (0.9 МБ, 304 сущности): **17.3 с**.
|
||||||
|
|
||||||
|
## Решение
|
||||||
|
|
||||||
|
Однопроходная замена:
|
||||||
|
- Все ключи собираются в **один** regex-паттерн через альтернацию `(?:...|...)`
|
||||||
|
(по убыванию длины, с `re.escape`, границы `(?<!\w)...(?!\w)` для alnum-сущностей).
|
||||||
|
- Один `re.sub` с callback (lookup в mapping), O(text).
|
||||||
|
- Фоллбэк `_md_tolerant_pattern` применяется **только к ключам, не найденным**
|
||||||
|
основной заменой (отслеживание через `matched_keys`), а не ко всем.
|
||||||
|
|
||||||
|
## Результат замера
|
||||||
|
- 300 сущностей реально в тексте (1.3 МБ): **1.17 с** (было бы десятки сек).
|
||||||
|
- Реалистичный сценарий (10 сущностей): **0.29 с**.
|
||||||
|
- Согласованность токенов и mapping.csv сохранены (проверено через `obfuscate`).
|
||||||
|
|
||||||
|
## Проверка
|
||||||
|
- `test_replacer` 10/10, `test_builder` 20/20, `test_scanner` 20/20, `test_zip` 28/28.
|
||||||
|
- Пайплайн `obfuscate`: «ООО Ромашка»→«Вектор_0001» в двух файлах согласовано,
|
||||||
|
mapping.csv корректен.
|
||||||
|
|
||||||
|
## Примечание
|
||||||
|
- `scan_regex` (0.4с) и upload (сеть ~1.7 МБ/с) — не узкие места, не трогали.
|
||||||
|
- `pdf_to_markdown` (pdfplumber) локально не замерен (нет библиотеки); остаётся
|
||||||
|
потенциальным вторым узким местом для будущей оптимизации (pypdf/pdfminer).
|
||||||
+33
-11
@@ -55,23 +55,45 @@ def apply_replacements(text: str, mapping: Dict[str, str], sorted_keys: List[str
|
|||||||
"""
|
"""
|
||||||
result = text
|
result = text
|
||||||
|
|
||||||
|
# ── Однопроходная замена: единый regex из всех ключей ──
|
||||||
|
# Вместо N отдельных re.sub по всему тексту (O(N×text)) собираем
|
||||||
|
# один паттерн из всех ключей и делаем один re.sub с callback (O(text)).
|
||||||
|
if not sorted_keys:
|
||||||
|
return result
|
||||||
|
|
||||||
|
alternatives = []
|
||||||
for original in sorted_keys:
|
for original in sorted_keys:
|
||||||
replacement = mapping[original]
|
if not original:
|
||||||
|
continue
|
||||||
# Если сущность обрамлена буквами/цифрами — используем границы слова
|
esc = re.escape(original)
|
||||||
if original and original[0].isalnum() and original[-1].isalnum():
|
# Границы слова — только если сущность обрамлена буквами/цифрами
|
||||||
pattern = r'(?<!\w)' + re.escape(original) + r'(?!\w)'
|
if original[0].isalnum() and original[-1].isalnum():
|
||||||
|
alternatives.append(r'(?<!\w)' + esc + r'(?!\w)')
|
||||||
else:
|
else:
|
||||||
pattern = re.escape(original)
|
alternatives.append(esc)
|
||||||
|
|
||||||
new_result = re.sub(pattern, replacement, result)
|
if not alternatives:
|
||||||
|
return result
|
||||||
|
|
||||||
# Фоллбэк: Markdown-жирность могла разорвать строку
|
combined = re.compile('|'.join(alternatives))
|
||||||
|
matched_keys = set()
|
||||||
|
|
||||||
|
def _replace(match):
|
||||||
|
m = match.group(0)
|
||||||
|
matched_keys.add(m)
|
||||||
|
return mapping.get(m, m)
|
||||||
|
|
||||||
|
result = combined.sub(_replace, result)
|
||||||
|
|
||||||
|
# ── Фоллбэк: Markdown-жирность могла разорвать строку ──
|
||||||
|
# Применяется только к ключам, которые основная замена НЕ нашла
|
||||||
|
# (например, «ООО **"**НУБЕС**"» — ** внутри сущности).
|
||||||
|
for original in sorted_keys:
|
||||||
|
if not original or original in matched_keys:
|
||||||
|
continue
|
||||||
md_pattern = _md_tolerant_pattern(original)
|
md_pattern = _md_tolerant_pattern(original)
|
||||||
if md_pattern is not None:
|
if md_pattern is not None:
|
||||||
new_result = re.sub(md_pattern, replacement, new_result)
|
result = re.sub(md_pattern, mapping[original], result)
|
||||||
|
|
||||||
result = new_result
|
|
||||||
|
|
||||||
return result
|
return result
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -20,7 +20,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.38"
|
VERSION = "0.0.39"
|
||||||
|
|
||||||
|
|
||||||
def create_app():
|
def create_app():
|
||||||
|
|||||||
Reference in New Issue
Block a user