docs: подробный план реализации для Flash (TTL+трекинг+прерывание+ETA+UI, v0.0.63)
This commit is contained in:
@@ -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/<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 — вне скоупа этой задачи.
|
||||
Reference in New Issue
Block a user