Files
drhider/History/plans/2026-08-24-implementation-plan-for-flash.md
T

311 lines
23 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# План реализации для Флаша — DrHider: TTL-фикс + трекинг файлов + прерывание + ETA + UI
> **Кому:** агент-кодер (Flash). Этот документ самодостаточен — код уже есть, нужно его доработать.
> **Проект:** DrHider — Flask-обфускатор документов. Локально `/home/naeel/nubes/drhider`.
> **Версия:** сейчас 0.0.62 → после реализации поднять до **0.0.63**.
> **Что НЕ делать:** не менять логику обфускации/LLM по сути; только добавить продление TTL, прогресс по файлам/чанкам, прерывание, оценку времени и UI.
---
## 1. Контекст и устройство кода
Точка входа: `site/app.py` (`create_app()`, `VERSION`). Роуты: `site/routes/api_bp.py`.
Сессии: `site/session.py`. Логика обфускации: пакет `drhider/`.
Ключевые файлы и функции (уже существующие):
| Файл | Что внутри |
|---|---|
| `site/session.py` | in-memory сессии, `TTL_SECONDS=30*60`, `MAX_FILE_BYTES`, `MAX_SESSION_BYTES`, `create_session/add_file/get_files/store_result/get_result/store_csv/get_csv/cleanup/file_count`, `_start_timer(sid)` |
| `site/routes/api_bp.py` | `upload`, `upload_refs`, `session_files`, `process_stream` (SSE), `process` (legacy), `download`, `csv_download` |
| `drhider/obfuscator.py` | `TwoPassObfuscator.obfuscate()` + обёртка `obfuscate_files()` |
| `drhider/scanner.py` | `scan_regex`, `split_into_chunks`, `scan_llm_ner`, `_call_llm`, `_LLM_CONCURRENCY=1` |
| `drhider/builder.py` | `build_zip(files, mapping_csv)`, `build_mapping_csv(mapping)` |
| `drhider/replacer.py` | `apply_replacements`, `_build_combined_re`, `replace_in_docx` |
| `site/templates/index.html` | весь фронт (ванильный JS, без фреймворков) |
### 1.1 Как сейчас устроен `obfuscate()` (важно)
`drhider/obfuscator.py`, метод `TwoPassObfuscator.obfuscate(files, progress_cb=None) -> (zip_bytes, csv_str)`:
1. `files = extractor.expand_zips(files)`; `files = _dedupe_file_names(files)`.
2. **Проход 1 (сбор):**
- Цикл по файлам: извлечь текст `extractor.extract_text(...)``all_texts[fname]=text`;
regex-скан `scanner.scan_regex(text, self._mapping, self._counters)`; `progress_cb("start", i, total, display_name, 0.0)` в начале каждого файла; `progress_cb("done", ...)` только для **пропущенных (битых)** файлов (`skipped.add(fname)`).
- После цикла: `scanner.scan_llm_ner(all_texts, self._mapping, self._llm_client, self._counters)`**один вызов на все файлы**, без прогресса.
- `self._sorted_keys = sorted(...)`, `self._compiled_re = replacer._build_combined_re(...)`.
3. **Проход 2 (замена):** цикл по файлам, для каждого `replacer.apply_replacements(...)`
`results.append((fname, obf_content))`; `progress_cb("done", i, total, display_name, round(file_times[i], 2))`.
4. **Сборка:** `csv_str = builder.build_mapping_csv(self._mapping)`; `zip_data = builder.build_zip(results, csv_str)`; return.
5. `finally:` очищает `self._mapping/_sorted_keys/_compiled_re/_counters`.
**Факт:** `mapping.csv` **НЕ кладётся в ZIP** (by design, в `build_zip`); CSV отдаётся отдельно через `/api/csv/<sid>`.
### 1.2 Как сейчас устроен SSE `process_stream`
`api_bp.py`, `process_stream(sid)`:
- `files = get_files(sid)`; `all_files = [(fname, content, "") for ...]`.
- `generate()`: `llm=LLMClient()`, `q=queue.Queue()`, `cancel=threading.Event()`, `progress(phase,idx,total,name,elapsed)` кладёт в `q`.
- `worker()`: `zip_data, csv_str = obfuscate_files(all_files, llm_client=llm, progress_cb=progress)`; `q.put(("result", zip, csv, stats))` / `q.put(("error", repr))`.
- Цикл: `q.get(timeout=1)`; при `Empty`**heartbeat** `event: llm` с `data: {active, elapsed, tokens}`; при `progress``event: <phase>`; при `result``store_result`+`store_csv`+`event: complete`; при `error``event: error`.
SSE-события сейчас: `start`, `done`, `llm` (heartbeat), `complete`, `error`.
### 1.3 Как сейчас устроен фронт (index.html)
Элементы: `fileList` (`fl`), `fileCount` (`fc`), `uploadBtn` (`ub`), `status` (`st`), `dlBtns` (`db`),
`liveBlock/liveTimer/liveFile/liveLlm/liveLlmTime`, `statsBlock/stTotalTime/stLlmTime/stLlmTokens`.
Функции: `fs(b)`, `rr()` (рендер таблицы), `ss(idx,html)` (обновить статус ячейки), `uploadFiles()`,
`downloadZip()`, `downloadCsv()`, `addFileWithDedup()`, `rm(i)`, `resetAll()`.
Обработчики SSE: `start`, `llm`, `done`, `complete`, `onerror`.
`sf` — массив File; `sendIdx[k]` — индекс в `sf` для k-го отправленного файла.
---
## 2. Цель (фича)
1. **TTL-фикс**: сессия не должна умирать во время долгой обработки (сейчас 30 мин → долгий прогон теряет результат).
2. **Трекинг файлов/чанков**: знать, какой файл сейчас обрабатывается, сколько прошло/осталось.
3. **Прерывание с сохранением**: кнопка «Прервать» → мягкая остановка → сохранить в ZIP+CSV **всё, что успело полностью обработаться**.
4. **Оценка времени**: глобальная (весь пакет) + per-file (текущий файл).
5. **UI**: таблица из 3 секций (готово / текущий / ожидают), кнопка «Прервать», диалог-подтверждение.
---
## 3. Блок A — TTL-фикс (`site/session.py`)
### Текущее
```python
TTL_SECONDS = 30 * 60
def _start_timer(sid):
def _clean():
with _lock:
_sessions.pop(sid, None)
timer = threading.Timer(TTL_SECONDS, _clean)
timer.daemon = True
timer.start()
return timer
```
Таймер хранится как `s["timer"]` в `create_session`.
### Изменения
1. Добавить `def touch(sid)`: отменить старый таймер (`s["timer"].cancel()`) и запустить новый (перезаписать `s["timer"]`). `touch` под `_lock`, безопасна при отсутствии сессии.
2. Добавить `def pause_ttl(sid)`: `s["timer"].cancel()` (без перезапуска) — «сессия живёт пока идёт обработка».
3. Добавить `def resume_ttl(sid)`: запустить `_start_timer(sid)` заново.
4. В `create_session` добавить в словарь `"cancel": threading.Event()`.
5. Добавить `def request_cancel(sid) -> bool`: если сессия есть — `s["cancel"].set()`, вернуть True; иначе False.
6. Добавить `def get_cancel_event(sid) -> Optional[threading.Event]`: вернуть `s["cancel"]` или None.
### Где использовать
- `process_stream`: в начале `pause_ttl(sid)`; в конце (complete/error/cancelled) `resume_ttl(sid)`.
- legacy `process()`: то же самое (тот же баг).
---
## 4. Блок B — трекинг файлов/чанков (`drhider/scanner.py`, `drhider/obfuscator.py`)
### 4.1 `scan_llm_ner` — новые параметры
```python
def scan_llm_ner(all_texts, mapping, llm_client, counters,
cancel_event=None, file_progress=None):
```
- `cancel_event`: `threading.Event` или None.
- `file_progress`: callable(event, fname, **fields) или None.
Цикл сейчас: `for fname, full_text in file_items:` (файлы отсортированы по убыванию длины).
Добавить:
- **В начале итерации файла**: `if cancel_event and cancel_event.is_set(): raise CancelRequested(...)` (между файлами — граница отмены).
- `file_progress("file_start", fname, chars=len(full_text), chunks=len(chunks))` (после разбиения на чанки).
- Внутри цикла по чанкам (последовательная ветка `for c in chunks:`): после каждого чанка
`file_progress("file_chunk", fname, chunks_done=k, chunks_total=len(chunks))`.
- После обработки файла: `file_progress("file_done", fname, elapsed=...)`.
**Важно:** отмену проверяем ТОЛЬКО между файлами (не между чанками) — файл добрается до конца,
граница всегда целая.
### 4.2 Новое исключение
В `drhider/obfuscator.py` (или отдельно) определить:
```python
class CancelRequested(Exception):
"""Обработка прервана пользователем."""
```
(данные частичного результата НЕ класть в исключение — см. Блок C: obfuscate сам собирает частичный результат и возвращает его с флагом.)
### 4.3 `obfuscate()` — новый контракт возврата
Поменять возврат с `(zip, csv)` на `(zip, csv, meta)`:
- `meta` — None при штатном завершении;
- при отмене — `{"cancelled": True, "processed": <int>, "total": <int>}`.
`obfuscate` получает новые параметры: `cancel_event=None`, `file_progress=None`.
Логика отмены внутри `obfuscate()`:
- Передать `cancel_event` и `file_progress` в `scan_llm_ner`.
- В проходе 2 (замена) перед каждым файлом: `if cancel_event.is_set(): break` (прервать замену).
- После LLM: если отменено ДО конца LLM — `scan_llm_ner` бросит `CancelRequested`. Поймать её,
вычислить список файлов, чей LLM завершён (см. 4.4), выполнить замену **только для них**,
собрать частичный `results` + `csv_str` и вернуть `(zip, csv, {"cancelled": True, "processed": X, "total": N})`.
- Если отменено во время прохода 2 (после полного LLM): `break` цикла замены → частичный `results` → тот же meta-контракт.
### 4.4 Как понять, какие файлы «полностью обработаны» при отмене в LLM
`scan_llm_ner` итерирует `file_items` (отсортированы по убыванию длины). Завести в `obfuscate`
множество `llm_done: set` — заполняется через `file_progress("file_done", fname)`.
При `CancelRequested` — файлы в `llm_done` считаются полностью проанализированными;
для них выполняется замена (их сущности уже в `self._mapping`). Файлы вне `llm_done` (и в `skipped`)
в частичный результат **не попадают**.
`total` в meta = число файлов после dedup/expand_zips (т.е. `len(files)` до прохода 2);
`processed` = число файлов, попавших в частичный `results`.
---
## 5. Блок C — прерывание в API (`site/routes/api_bp.py`)
### 5.1 Новый эндпоинт
```python
@api_bp.route("/cancel/<sid>", methods=["POST"])
def cancel(sid):
if request_cancel(sid):
return jsonify({"ok": True}), 200
return jsonify({"ok": False, "error": "Session not found"}), 404
```
### 5.2 `process_stream` — изменения
- `pause_ttl(sid)` в начале.
- `cancel_event = get_cancel_event(sid)`; передать в `obfuscate_files(..., cancel_event=cancel_event, file_progress=...)`.
- `file_progress` callback: класть события в `q` (новый тип `("file", event, fname, fields)`).
- `worker()`: принять `(zip, csv, meta)`; если `meta and meta.get("cancelled")`
`q.put(("cancelled", zip, csv, stats, meta))`, иначе как раньше `("result", ...)`.
- В цикле обработки `q`:
- `("file", event, fname, fields)``yield event: <event>` с `data: {"name": fname, **fields}`.
- `("cancelled", zip, csv, stats, meta)``store_result`+`store_csv``yield event: cancelled`
с `data: {"saved": meta["processed"], "total": meta["total"], "tokens": stats["tokens"], "llm_sec": stats["llm_sec"]}`.
- В конце любого исхода (complete/error/cancelled) — `resume_ttl(sid)`.
- **Heartbeat `llm`** — расширить: `data: {active, elapsed, tokens, eta_sec, done_chars, total_chars}`.
`total_chars` вычисляется после прохода 1 (нужно передать его из `obfuscate` в генератор —
например, через `file_progress` событие `extract_done` с `{total_chars}`), `done_chars`/`eta_sec` — из LLM-прогресса.
### 5.3 Схема новых SSE-событий (итоговый контракт)
| event | data (JSON) | когда |
|---|---|---|
| `start` | `{idx,name,total,elapsed}` | проход 1, каждый файл (уже есть) |
| `extract_done` | `{total_chars}` | после извлечения всех текстов |
| `file_start` | `{name, chars, chunks}` | начало LLM файла |
| `file_chunk` | `{name, chunks_done, chunks_total, eta_sec}` | после каждого чанка LLM |
| `file_done` | `{name, elapsed}` | LLM файла завершён |
| `llm` | `{active, elapsed, tokens, eta_sec, done_chars, total_chars}` | heartbeat (1 раз/сек) |
| `done` | `{idx,name,total,elapsed}` | проход 2, каждый файл (уже есть) |
| `complete` | `{total, tokens, llm_sec}` | успех (уже есть) |
| `cancelled` | `{saved, total, tokens, llm_sec}` | прерывание (новое) |
| `error` | `{error}` | ошибка (уже есть) |
---
## 6. Блок D — оценка времени (ETA)
### 6.1 Глобальная
- После `extract_done` известен `total_chars`.
- Во время LLM: `tokens` и `elapsed` уже есть в heartbeat. Скорость = `tokens/elapsed`.
- `est_total_tokens ≈ total_chars × K` (K — эмпирический коэффициент; взять ~7–10, подстроить по замерам).
- `eta_sec = max(0, (est_total_tokens tokens) / (tokens/elapsed))`.
- Вычислять в генераторе (у него есть `llm` и `total_chars`) и класть в heartbeat `eta_sec`.
### 6.2 Per-file (текущий файл)
- `file_start` даёт `chunks`. `file_chunk` даёт `chunks_done`.
- Скорость чанка (сек/чанк) = `elapsed_файла / chunks_done` (меряем по факту текущего файла).
- `file_eta = (chunks_total chunks_done) × (сек/чанк)`.
- На самом старте (chunks_done=0) — грубая оценка `chars × историч. сек/символ`.
- Класть `eta_sec` в `file_chunk` (вычисляет worker, у него точный elapsed).
---
## 7. Блок E — UI (`site/templates/index.html`)
### 7.1 Таблица из 3 секций (этап обработки)
Во время Фазы 2 перестроить таблицу. Завести `procState` — объект: `idx -> {st: 'pending'|'current'|'done'|'skipped', elapsed, eta}`.
Порядок строк сверху вниз:
1. **done** — уже обработанные; в столбце «Статус» фактическое время (`✓ 3.2с`).
2. **current** — подсвечен (класс `row-current`); в столбце «Статус» `прошло 0:42 / ~1:20` (тикает через `setInterval`).
3. **pending** — не обработанные; в столбце «Статус» оценка (`~12с`).
`rr()` модифицировать: если идёт обработка — рендерить 3 группы в этом порядке (по `procState`),
иначе — как сейчас (по `sf`).
Обновления:
- `start` (idx) → `procState[idx]={st:'pending'}` (или current — см. ниже).
- `file_start` (name→idx) → `procState[idx].st='current'`.
- `file_chunk` → обновить `eta` текущего.
- `file_done` → вернуть в pending (LLM-анализ не значит «готов в ZIP»; готовность — событие `done`).
- `done` (idx, elapsed) → `procState[idx]={st:'done', elapsed}`.
Нужен маппинг `name→idx` (или передавать `idx` в событиях `file_*` — проще: добавить `idx` в
`file_start/file_done` на бэке; см. замечание ниже).
### 7.2 Кнопка «Прервать»
- Новый элемент `<button id="cancelBtn" class="btn" style="display:none">⏹ Прервать</button>` в `footer-bar`.
- Показывать на Фазе 2, скрывать на Фазе 1 и после complete/cancelled.
- По клику — диалог-подтверждение (модал или `confirm`):
> «Остановить обработку? Будет сохранено: полностью обработанные файлы (сейчас готово X из N) и таблица замен. Остановить?»
- После подтверждения: `fetch('/api/cancel/' + currentSid, {method:'POST'})`.
- Обработчик SSE `cancelled`: статус «Сохранено X из N файлов + таблица замен», показать `dlBtns`.
### 7.3 Живой блок (liveBlock)
- В `llm` heartbeat — обновлять: `liveLlmTime` = elapsed, и добавить строку
«осталось ~X мин» (из `eta_sec`).
- Текущий файл — из `file_start`/`file_chunk`: «Файл 12/101: name.pdf — осталось ~1:20».
### 7.4 Прочее
- Скрыть кнопку «✕» (remove) на Фазе 2.
- Статическая оценка сразу после `upload_refs` (до SSE): «Загружено N файлов (X МБ). Обработка может занять ~M мин» — по объёму (коэффициент тот же K).
---
## 8. Замечания и ловушки (из код-ревью)
1. **`done`-фаза занята**: сейчас `progress_cb("done")` шлётся и для битых (в проходе 1), и для готовых
(в проходе 2). Не путать с готовностью файла в ZIP. Готовность = `done` в проходе 2.
Для UI-«готово» ориентироваться на события `done`, приходящие ПОСЛЕ всех `start` (т.е. в фазе замены).
2. **Частичные результаты не терять**: `results` — локальная переменная; НЕ полагаться на `finally`.
Возвращать `(zip, csv, meta)` из `obfuscate` (см. 4.3).
3. **`mapping.csv` не в ZIP**: частичный результат = частичный ZIP (только .md) + полный CSV отдельно
(через `/api/csv`). Полный CSV собирается из `self._mapping` (он полон на момент отмены в фазе замены).
4. **LLM-фаза сейчас без прогресса**: `scan_llm_ner` — один монолитный вызов. Именно поэтому добавляется
`file_progress`. Без него «текущий файл» в UI не определить.
5. **Имена в событиях `file_*`**: на бэке имена файлов — уже с `.md`/суффиксами после dedup; фронт
сопоставляет по `idx` (надёжнее, чем по имени). **Рекомендация**: передавать `idx` (0-based в `files`)
в события `file_start/file_done/file_chunk`.
6. **`obfuscate_files`-обёртка**: тоже меняет сигнатуру (`cancel_event`, `file_progress`) и возврат
`(zip, csv, meta)`. Обновить ОБА вызова: `process_stream` и legacy `process()`.
7. **Отключение SSE**: при закрытии вкладки `cancel` в генераторе уже ставится, но воркер его не видит.
Подключить тот же `cancel_event` (передать в `obfuscate_files`), чтобы закрытие вкладки реально
останавливало LLM (не жечь токены).
8. **`_LLM_CONCURRENCY = 1`** — последовательно. Не вводить параллельность (liberta/LLM не тянут).
---
## 9. Порядок реализации (коммитить по шагам)
1. **TTL-фикс** (`session.py` + `api_bp.py`): `touch/pause_ttl/resume_ttl`, `cancel` Event,
`request_cancel/get_cancel_event`, `POST /api/cancel/<sid>`.
2. **Трекинг** (`scanner.py`, `obfuscator.py`): `cancel_event` + `file_progress`, `CancelRequested`,
новый контракт возврата `(zip, csv, meta)`.
3. **Прерывание** (`obfuscator.py`, `api_bp.py`): частичный результат, событие `cancelled`.
4. **ETA** (`api_bp.py`, heartbeat `eta_sec`/`done_chars`/`total_chars`, `file_chunk` eta).
5. **UI** (`index.html`): 3-секционная таблица, кнопка «Прервать», диалог, live-обновления.
6. **Бамп версии** 0.0.62 → 0.0.63, py_compile + node --check, коммит.
---
## 10. Проверка
- `python3 -m py_compile` для всех изменённых `.py`.
- `node --check` для извлечённого `<script>` из `index.html`.
- Ручной тест: загрузить 100+ файлов → проверить 3 секции, ETA, прерывание → частичный ZIP+CSV.
- Регресс: короткий прогон без прерывания → `complete`, ZIP+CSV как раньше.
- Тест TTL: запустить долгий прогон (>30 мин) → скачать результат (не должен быть 404).
---
## 11. Что НЕ трогать
- Логику обфускации/LLM/NER (regex, chunking, dedup) — только прогресс/отмена поверх.
- `drhider/config.py`, `drhider/replacer.py`, `drhider/builder.py`, `drhider/extractor.py` — без изменений
(кроме возможного экспорта `CancelRequested` из `obfuscator.py`).
- Деплой/nginx/vmfiles — вне скоупа этой задачи.