diff --git a/History/2026-08-24-implementation-plan-for-flash.md b/History/2026-08-24-implementation-plan-for-flash.md new file mode 100644 index 0000000..1477830 --- /dev/null +++ b/History/2026-08-24-implementation-plan-for-flash.md @@ -0,0 +1,310 @@ +# План реализации для Флаша — 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/`. + +### 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: `; при `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": , "total": }`. + +`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/", 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: ` с `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 Кнопка «Прервать» +- Новый элемент `` в `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/`. +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` для извлечённого `