# План реализации для Флаша — 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` для извлечённого `