Compare commits

..
4 Commits
10 changed files with 1114 additions and 108 deletions
@@ -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 — вне скоупа этой задачи.
+171
View File
@@ -0,0 +1,171 @@
# Дизайн: TTL-фикс + прерывание с сохранением + оценка времени + UI (2026-08-24)
> План реализации фичи по итогам тестов 24.08 (см. `2026-08-24-tests-upload-and-obfuscation.md`).
> Статус: **ПЛАН, код не менялся**. Порядок: TTL → прерывание → ETA → UI.
---
## 0. Исходная проблема (что тесты вскрыли)
- **TTL-баг**: сессия живёт 30 мин (`TTL_SECONDS = 30*60`), таймер запускается при создании
и **не продлевается** во время обработки. Обфускация 101 файла заняла 1995с (>30 мин) →
сессия удалена TTL → `store_result` молча `False` → `download`/`csv` = **404**, результат потерян.
- **Нет UX для долгих прогонов**: юзер не знает, сколько ждать, и не может прервать с сохранением.
## 1. Требования (от пользователя)
1. Показывать, что обработка долгая «потому что много данных».
2. Возможность **прерывания с сохранением** уже обработанного в выходном ZIP + CSV.
3. Прерывание **не мгновенное** — «пусть завершает, сколько требуется» (мягкий добор).
4. Юзер **точно знает, что будет сохранено** (до нажатия и во время).
5. В начале — **ориентировочное время** обработки всего пакета, чтобы знать, чего ожидать.
---
## 2. Блок A — TTL-фикс (фундамент, обязателен первым)
**Файл:** `site/session.py`
### Текущее поведение
```python
TTL_SECONDS = 30 * 60
def _start_timer(sid):
def _clean(): _sessions.pop(sid, None)
timer = threading.Timer(TTL_SECONDS, _clean); timer.daemon=True; timer.start(); return timer
```
Таймер одноразовый, не продлевается → долгий прогон убивает сессию.
### Новое поведение
- Добавить `def touch(sid)`: отменяет старый таймер и запускает новый (продлевает жизнь).
- При старте обработки (`process_stream`) — **отменить** таймер сессии (во время обработки
сессия живёт «вечно», пока идёт воркер).
- При завершении (`complete` / `error` / `cancelled`) — **запустить** таймер заново
(результат доступен ещё 30 мин после окончания).
- `add_file`, `get_files` — можно тоже `touch()` для единообразия (не обязательно).
**Итог:** долгий прогон больше не теряет сессию; результат доступен 30 мин после завершения.
---
## 3. Блок B — прерывание с сохранением (мягкая остановка)
### 3.1. Модель поведения (согласовано с пользователем)
| Где нажата «Прервать» | Поведение |
|---|---|
| Во время LLM-анализа | Анализ **добарается до конца** (без полного mapping нельзя сохранить ни одного документа). Прерывание срабатывает на границе фазы замены. |
| Во время фазы замены | Дорабатывается текущий файл → стоп. В ZIP+CSV попадают все готовые файлы + полная таблица замен. |
| После завершения | Обычный полный результат (прерывание не актуально). |
- Флаг отмены проверяется **только на границах файлов фазы замены** → результат всегда осмысленный.
- LLM-фаза не прерывается (добор), что соответствует «пусть завершает, сколько требуется».
### 3.2. Изменения по файлам
**`site/session.py`**
- В словаре сессии добавить `"cancel": threading.Event()` при создании.
- Хелперы: `request_cancel(sid)`, `cancel_requested(sid)`.
**`site/routes/api_bp.py`**
- Новый эндпоинт `POST /api/cancel/<sid>`: `request_cancel(sid)` → `{ok:true}` (или 404 если нет сессии).
- `process_stream`:
- При старте — `cancel_event` = событие из сессии, таймер TTL отменяется.
- Воркер передаёт `cancel_event` в `obfuscate_files`.
- Обработка исключения отмены → сборка **частичного результата**:
- ZIP из файлов, прошедших замену (исключая `mapping.csv`) + полный `mapping.csv`.
- `store_result` + `store_csv` (сейчас `store_result` сохраняет только полный zip).
- Новое SSE-событие `cancelled`: `data: {"saved": <кол-во готовых>, "total": <всего>, ...}`.
- После завершения/отмены — перезапуск TTL-таймера.
**`drhider/obfuscator.py`**
- `obfuscate_files(..., cancel_event=None)`.
- В цикле фазы замены (по файлам): `if cancel_event and cancel_event.is_set(): raise CancelRequested()`.
- В LLM-цикле отмену **НЕ** проверяем (добор до конца).
- `CancelRequested` — новое исключение (в `drhider/obfuscator.py` или общий модуль).
- Для сборки частичного ZIP: функция должна уметь вернуть уже обработанные файлы
(список `(имя, bytes)` заменённых) — либо прогресс-колбеком, либо исключение с данными.
### 3.3. Частичный результат — детали
- Каждый файл, прошедший замену, гарантированно консистентен: глобальный mapping один для всех.
- Частичный ZIP = готовые файлы + `mapping.csv` (полная таблица — она собрана к концу LLM).
- Если прервано до конца LLM — прерывание не сработает (добор), значит частичный результат
будет как минимум с теми файлами, которые успели замениться после LLM.
---
## 4. Блок C — оценка времени (ETA)
### 4.1. Уровень 1 — статическая оценка в начале
- Фронт знает `N` файлов и объём `S` (МБ) до обработки.
- Эмпирический коэффициент из замеров: 24.08 — 101 файл / ~150 КБ текста → LLM ≈ 1924с
(~12.8 с/КБ текста); 23.08 — 100 файлов → LLM 841–985с.
- Коэффициент задать константой на фронте/бэке (можно в `config.py`), формула:
`оценка ≈ S_текста(КБ) × K + константа`. Для бинарных/сканов текста нет — оценка грубая,
поэтому это «ориентировочно», с оговоркой в UI.
### 4.2. Уровень 2 — после извлечения (бэк знает объём текста)
- `obfuscate_files` после фазы извлечения знает суммарный объём текста (`total_chars`).
- Через новый колбек (или расширенный `progress_cb`) отдать `{phase:"extract_done", chars:N}`.
- Тогда оценка точнее: `tokens ≈ chars × фактор`, `время ≈ tokens / скорость(истор.)`.
### 4.3. Уровень 3 — адаптивный ETA во время LLM
- В LLM-цикле меряем фактическую скорость: обработано символов / прошедшее время.
- Оставшийся объём = `total_chars − processed_chars`.
- `ETA = оставшиеся символы / скорость`.
- Отдавать в существующем heartbeat `llm`:
`data: {"active":..., "elapsed":..., "tokens":..., "eta_sec": N, "done_chars":..., "total_chars":...}`.
- Фронт обновляет «осталось ~X мин» в live-блоке.
### 4.4. Ограничения (честно)
- Оценка **ориентировочная** (LLM-вывод варьируется). Показывать как «≈», обновлять адаптивно.
- Для сканов/PDF без текстового слоя — извлечённый текст мал, LLM-этап быстрый, оценка завышена.
---
## 5. Блок D — UI (index.html)
### 5.1. Сообщение «много данных»
- После загрузки (перед обработкой): «Загружено N файлов (X МБ). Идёт обработка —
это может занять ~M мин.»
- Держать в статус-строке и в live-блоке.
### 5.2. Кнопка «Прервать»
- Появляется/активна на этапе обработки (Фаза 2).
- По нажатию — **диалог-подтверждение**:
> «Остановить обработку? Будет сохранено: документы, уже прошедшие обработку
> (сейчас готово X из N), и таблица замен. Текущий анализ будет доведён до конца.
> [Остановить] [Отмена]»
- После подтверждения — `POST /api/cancel/<sid>`.
- Во время добора: счётчик «готово X/N» + «завершаем текущий этап…».
### 5.3. Показ частичного результата
- SSE-событие `cancelled` → статус «Сохранено X из N документов + таблица замен».
- Кнопки «Скачать ZIP» / «Скачать CSV» работают как обычно (ведут на download/csv).
### 5.4. ETA в live-блоке
- «Обработка… осталось ~X мин» — из `eta_sec` хартбита.
- Статическая оценка — сразу после старта обработки.
---
## 6. Порядок реализации и проверка
1. **TTL-фикс** (`session.py` + `api_bp.py`): тест — долгий прогон >30 мин, скачивание после.
2. **Прерывание** (`obfuscator.py` + `api_bp.py` + `session.py`): тест — прервать в фазе замены,
проверить частичный ZIP+CSV.
3. **ETA** (`obfuscator.py`/`api_bp.py` + фронт): тест — сверка оценки с фактом.
4. **UI** (кнопка, диалог, счётчик): ручной тест в браузере.
Версия: бамп до **0.0.63** после реализации (всех блоков).
## 7. Затронутые файлы
- `site/session.py` — TTL `touch`, флаг отмены.
- `site/routes/api_bp.py` — `POST /api/cancel/<sid>`, событие `cancelled`, ETA в heartbeat, частичный результат.
- `drhider/obfuscator.py` — `cancel_event`, `CancelRequested`, передача объёма текста, сборка частичного результата.
- `site/templates/index.html` — сообщение, кнопка, диалог, счётчик, ETA.
- `drhider/config.py` (опц.) — коэффициент оценки времени.
## 8. Открытые вопросы
- Точное место сбора «готовых файлов» в `obfuscate_files` (где хранить заменённые байты для частичного ZIP).
- Формат события `cancelled` (поля `saved/total` + причина).
- Значение коэффициента оценки (уточнить по ещё паре замеров).
@@ -0,0 +1,49 @@
# Реализовано: TTL-фикс + прерывание с сохранением + ETA + UI-таблица (v0.0.63)
_2026-08-24. По плану `2026-08-24-implementation-plan-for-flash.md`. Код написан (роль Flash)._
## Что сделано
### session.py — TTL-фикс + отмена
- `touch(sid)`, `pause_ttl(sid)`, `resume_ttl(sid)` — продление/пауза/возобновление TTL.
- В сессии `"cancel": threading.Event()`; `request_cancel(sid)`, `get_cancel_event(sid)`.
### scanner.py — прогресс и мягкая остановка LLM
- `class CancelRequested`.
- `scan_llm_ner(..., cancel_event, file_progress)`: отмена ТОЛЬКО между файлами (текущий добирается);
события `file_start{chars,chunks}`, `file_chunk{chunks_done,chunks_total}`, `file_done{elapsed}`.
### obfuscator.py — частичный результат
- `obfuscate()` возвращает `(zip, csv, meta)`: `meta=None` (штатно) или
`{"cancelled": True, "processed", "total"}` (прерывание).
- Трекинг `llm_done` (файлы с завершённым LLM). При отмене в LLM — в результат идут
ТОЛЬКО файлы из `llm_done` (их обфускация корректна); при отмене в фазе замены —
стоп после текущего файла. Отмена в replace НЕ проверяется, если LLM уже прерван
(файлы из llm_done добираются).
- Событие `extract_done{total_chars, per_file}` (объём текста для ETA).
### api_bp.py — API
- `POST /api/cancel/<sid>`.
- `process_stream`: `pause_ttl` в начале / `resume_ttl` в `finally`; передача `cancel_event`
и `file_progress` в воркер; события `extract_done/file_start/file_chunk/file_done/cancelled`;
heartbeat `llm` расширен: `eta_sec, done_chars, total_chars`; per-file ETA в `file_chunk`.
- При закрытии вкладки (`disconnect`) ставится и `cancel`, и `cancel_event` (воркер останавливается).
- legacy `process()` — фикс 3-значного возврата + TTL.
### index.html — UI
- Кнопка «⏹ Прервать» + модал-подтверждение (объясняет, что сохранится).
- Таблица 3 секций: «✓ Обработанные» (факт время) / «▶ Текущий файл» (прошло / ~осталось) /
«○ Ожидают» (~оценка из скорости текущего файла).
- Live-блок: «осталось ~X» (глобальная ETA), текущий файл с per-file ETA.
- SSE-обработчики: extract_done/file_start/file_chunk/file_done/cancelled; done различает
пропущенные (до extract_done) и готовые (после).
## Проверка
- `py_compile` всех .py — OK; `node --check` (JS из index.html) — OK; `get_errors` — нет.
- Локальные тесты логики отмены (FakeLLM): штатно=4 файла; отмена в LLM=3 сохранено;
отмена до LLM=0..1 (текущий добирается); отмена после завершения=полный.
- Смоук-тест: приложение создаётся, все роуты включая `/api/cancel/<sid>` зарегистрированы.
## ВАЖНО
- Версия 0.0.63. **Код не задеплоен** — нужен редеплой на кластере и прогон реальных тестов
(долгий прогон + прерывание).
@@ -0,0 +1,52 @@
# drhider — тесты загрузки и обфускации (2026-08-24, v0.0.62)
_Кластер `naeel-test-3`, v0.0.62 (лимиты 50МБ/файл, 500МБ/сессия), под 1 CPU / 2 GiB
(requests=limits, подтверждено kubectl). ВМ-буфер: `https://contracts.kube5s.ru/drhider-upload/`._
## Тест 1 — загрузка (upload-only), много + больших файлов
Корпус: 118 бинарных файлов, 480 МБ (8×45МБ + 10×10МБ + 100×200КБ). Файлы >40МБ — 8 шт.
| Этап | Результат |
|---|---|
| PUT на ВМ (P=8, `--resolve` обход DNS) | **119/119 HTTP 201**, 43.4с |
| `POST /api/upload_refs` | HTTP 200, 50.8с, `{count:119, session}` |
| `GET /api/session_files` | 119 файлов, **480.5 МБ**, 8 больших (>40МБ) |
**Вывод:** загрузка через ВМ стабильна на объёме 480МБ/119 файлов под лимитом 500МБ, 0 ошибок.
## Тест 2 — обфускация, много мелких файлов
Корпус: 100 сгенерированных `.txt`-договоров с фейковыми PII (ФИО, телефоны, ИНН, ОГРН, БИК,
р/с, к/с, паспорта, адреса, email). Сессия `bba1a7272aed`.
| Показатель | Значение |
|---|---|
| start / done | **101 / 101** (0 ошибок) |
| complete | `{total: 101, tokens: 1 569 716, llm_sec: 1924.6}` |
| SSE-время | 1995.6с (~33.3 мин) |
| ZIP | **404 — НЕ скачался** |
| CSV | **404 — НЕ скачался** |
Обфускация отработала полностью (101 done, complete пришёл, `zip_len=147861` в логе worker),
но **результат потерян при скачивании** — см. баг ниже.
## ⚠️ Найденный баг: TTL сессии (30 мин) короче долгой обфускации
- `site/session.py`: `TTL_SECONDS = 30*60`, таймер запускается при создании сессии и
**не продлевается** во время обработки (`threading.Timer`, `_clean` просто `pop(sid)`).
- Обфускация заняла 1995с (> 1800с TTL) → таймер удалил сессию посреди обработки.
- `store_result(sid, zip)` при отсутствующей сессии молча возвращает `False` → результат не сохранён.
- `GET /api/download/<sid>` и `/api/csv/<sid>` → `get_result`=None → **404**.
- Подтверждено: `session_files/bba1a7272aed` → 404; лог: `worker: done ... in 1995.0s ... zip_len=147861`.
**Почему раньше не всплыло:** в тесте 23.08 каждая сессия завершалась за 19/16 мин < TTL.
**Предлагаемый фикс:** продлевать TTL при активности обработки (cancel+restart таймера на
каждое событие прогресса, либо отдельный длинный TTL для «в обработке», либо сохранять
результат даже при истёкшей сессии). **Не делалось — ждёт «делай».**
## Прочее
- Первый прогон теста 1 упал из-за бага тест-скрипта (отправил список вместо `{files:[...]}` в
`upload_refs`) — сервис тут ни при чём.
- DNS на Krupski медленный (~5с/резолв) — в тестах обходился `curl --resolve`.
+63 -10
View File
@@ -88,8 +88,9 @@ class TwoPassObfuscator:
def obfuscate( def obfuscate(
self, files: List[Tuple[str, bytes, str]], self, files: List[Tuple[str, bytes, str]],
progress_cb: Optional[Callable[[str, int, int, str], None]] = None progress_cb: Optional[Callable[[str, int, int, str], None]] = None,
) -> Tuple[bytes, str]: cancel_event=None, file_progress=None
) -> Tuple[bytes, str, Optional[dict]]:
"""Обфусцировать список файлов. """Обфусцировать список файлов.
Все форматы → Markdown → замена → результат. Все форматы → Markdown → замена → результат.
@@ -99,16 +100,34 @@ class TwoPassObfuscator:
progress_cb: Опциональный коллбек (phase, idx, total, fname, elapsed), progress_cb: Опциональный коллбек (phase, idx, total, fname, elapsed),
где phase ∈ {"start", "done"}, idx — 0-based индекс, где phase ∈ {"start", "done"}, idx — 0-based индекс,
elapsed — время обработки конкретного файла (сек). elapsed — время обработки конкретного файла (сек).
cancel_event: threading.Event — мягкая остановка. Проверяется между
файлами LLM и между файлами замены; текущий файл добирается.
file_progress: callable(event, fname, **fields) — прогресс по файлам/чанкам.
События: extract_done{total_chars}, file_start{chars,chunks,idx},
file_chunk{chunks_done,chunks_total,idx}, file_done{elapsed,idx}.
Returns: Returns:
(zip_bytes, csv_string): (zip_bytes, csv_string, meta):
zip_bytes — ZIP-архив с обфусцированными .md файлами + mapping.csv zip_bytes — ZIP-архив с обфусцированными .md файлами (без mapping.csv)
csv_string — содержимое mapping.csv как строка csv_string — содержимое mapping.csv как строка
meta — None при штатном завершении; {"cancelled": True, "processed", "total"}
при прерывании (частичный результат)
""" """
# ── Предобработка: распаковать ZIP + уникализировать имена ── # ── Предобработка: распаковать ZIP + уникализировать имена ──
files = extractor.expand_zips(files) files = extractor.expand_zips(files)
files = _dedupe_file_names(files) files = _dedupe_file_names(files)
# имя файла (внутр. ключ all_texts) -> idx в files (для событий file_progress)
name_to_idx = {fname: i for i, (fname, _, _) in enumerate(files)}
llm_done: set = set() # файлы, чей LLM-анализ ПОЛНОСТЬЮ завершён
def _fp(event, fname, **fields):
if event == "file_done":
llm_done.add(fname)
if file_progress:
fields["idx"] = name_to_idx.get(fname)
file_progress(event, fname, **fields)
try: try:
# ── Проход 1: сбор сущностей ── # ── Проход 1: сбор сущностей ──
# Извлекаем Markdown из каждого файла # Извлекаем Markdown из каждого файла
@@ -142,9 +161,23 @@ class TwoPassObfuscator:
scanner.scan_regex(text, self._mapping, self._counters) scanner.scan_regex(text, self._mapping, self._counters)
file_times[i] += time.time() - t0 file_times[i] += time.time() - t0
# Объём извлечённого текста — для оценки времени (глобальной ETA)
total_chars = sum(len(t) for t in all_texts.values())
if file_progress:
file_progress("extract_done", None, total_chars=total_chars,
per_file={fname: len(t) for fname, t in all_texts.items()})
# LLM-сканирование (получает уже найденное regex'ом чтобы не дублировать) # LLM-сканирование (получает уже найденное regex'ом чтобы не дублировать)
cancelled_llm = False
if self._llm_client: if self._llm_client:
scanner.scan_llm_ner(all_texts, self._mapping, self._llm_client, self._counters) try:
scanner.scan_llm_ner(
all_texts, self._mapping, self._llm_client, self._counters,
cancel_event=cancel_event, file_progress=_fp,
)
except scanner.CancelRequested:
log.info("obfuscate: LLM cancelled, llm_done=%d", len(llm_done))
cancelled_llm = True
# Предсортировать ключи один раз (по убыванию длины) # Предсортировать ключи один раз (по убыванию длины)
self._sorted_keys = sorted( self._sorted_keys = sorted(
@@ -154,6 +187,7 @@ class TwoPassObfuscator:
self._compiled_re = replacer._build_combined_re(self._sorted_keys) self._compiled_re = replacer._build_combined_re(self._sorted_keys)
# ── Проход 2: замена сущностей ── # ── Проход 2: замена сущностей ──
cancelled = cancelled_llm
results: List[Tuple[str, bytes]] = [] results: List[Tuple[str, bytes]] = []
for i, (in_fname, content, ctype) in enumerate(files): for i, (in_fname, content, ctype) in enumerate(files):
@@ -166,6 +200,17 @@ class TwoPassObfuscator:
progress_cb("done", i, total, display_name, 0.0) progress_cb("done", i, total, display_name, 0.0)
continue continue
# При прерывании в LLM — в результат идут ТОЛЬКО файлы с завершённым LLM
if cancelled_llm and fname not in llm_done:
continue
# Мягкая остановка в фазе замены (между файлами).
# НЕ проверяем, если LLM уже прерван: файлы из llm_done добираем (сохраняем всё готовое).
if not cancelled_llm and cancel_event is not None and cancel_event.is_set():
log.info("obfuscate: cancelled in replace phase, processed=%d", len(results))
cancelled = True
break
t0 = time.time() t0 = time.time()
obf_content = content # По умолчанию — без изменений obf_content = content # По умолчанию — без изменений
@@ -198,7 +243,11 @@ class TwoPassObfuscator:
csv_str = builder.build_mapping_csv(self._mapping) csv_str = builder.build_mapping_csv(self._mapping)
zip_data = builder.build_zip(results, csv_str) zip_data = builder.build_zip(results, csv_str)
return zip_data, csv_str meta = None
if cancelled:
expected_total = total - len(skipped)
meta = {"cancelled": True, "processed": len(results), "total": expected_total}
return zip_data, csv_str, meta
finally: finally:
# Очистка состояния (обфускатор может использоваться повторно) # Очистка состояния (обфускатор может использоваться повторно)
@@ -214,8 +263,9 @@ class TwoPassObfuscator:
def obfuscate_files( def obfuscate_files(
files: List[Tuple[str, bytes, str]], llm_client=None, files: List[Tuple[str, bytes, str]], llm_client=None,
progress_cb: Optional[Callable[[str, int, int, str], None]] = None progress_cb: Optional[Callable[[str, int, int, str], None]] = None,
) -> Tuple[bytes, str]: cancel_event=None, file_progress=None
) -> Tuple[bytes, str, Optional[dict]]:
"""Обфусцировать список файлов — удобная функция. """Обфусцировать список файлов — удобная функция.
Создаёт экземпляр TwoPassObfuscator и вызывает .obfuscate(). Создаёт экземпляр TwoPassObfuscator и вызывает .obfuscate().
@@ -224,9 +274,12 @@ def obfuscate_files(
files: [(filename, content_bytes, content_type), ...] files: [(filename, content_bytes, content_type), ...]
llm_client: Опциональный LLM-клиент llm_client: Опциональный LLM-клиент
progress_cb: Опциональный коллбек (phase, idx, total, fname) progress_cb: Опциональный коллбек (phase, idx, total, fname)
cancel_event: threading.Event — мягкая остановка (см. TwoPassObfuscator.obfuscate)
file_progress: callable(event, fname, **fields) — прогресс по файлам/чанкам
Returns: Returns:
(zip_bytes, csv_string) (zip_bytes, csv_string, meta)
""" """
obf = TwoPassObfuscator(llm_client=llm_client) obf = TwoPassObfuscator(llm_client=llm_client)
return obf.obfuscate(files, progress_cb=progress_cb) return obf.obfuscate(files, progress_cb=progress_cb,
cancel_event=cancel_event, file_progress=file_progress)
+25 -3
View File
@@ -12,6 +12,7 @@
import re import re
import json import json
import random import random
import time
import logging import logging
from concurrent.futures import ThreadPoolExecutor from concurrent.futures import ThreadPoolExecutor
from typing import Dict, List, Optional from typing import Dict, List, Optional
@@ -111,6 +112,9 @@ def scan_regex(text: str, mapping: Dict[str, str], counters: Dict[str, int]) ->
# LLM-NER: пофайловый чанкинг (Sonnet-схема) # LLM-NER: пофайловый чанкинг (Sonnet-схема)
# ═══════════════════════════════════════════════════════════════════════════ # ═══════════════════════════════════════════════════════════════════════════
class CancelRequested(Exception):
"""Обработка прервана пользователем (мягкая остановка между файлами LLM)."""
# Приоритет границ для чанков (от предпочтительных к жёстким) # Приоритет границ для чанков (от предпочтительных к жёстким)
_CHUNK_BOUNDARIES = ['\n\n', '\n', '. ', '? ', '! ', '; ', ', ', ' '] _CHUNK_BOUNDARIES = ['\n\n', '\n', '. ', '? ', '! ', '; ', ', ', ' ']
_CHUNK_SIZE = 6000 # символов на чанк (≈1500-2000 токенов RU — NER-качество) _CHUNK_SIZE = 6000 # символов на чанк (≈1500-2000 токенов RU — NER-качество)
@@ -216,11 +220,12 @@ def _call_llm(text: str, llm_client) -> List[dict]:
def scan_llm_ner(all_texts: Dict[str, str], mapping: Dict[str, str], def scan_llm_ner(all_texts: Dict[str, str], mapping: Dict[str, str],
llm_client, counters: Dict[str, int]) -> None: llm_client, counters: Dict[str, int],
cancel_event=None, file_progress=None) -> None:
"""Универсальное LLM-обнаружение приватных данных (пофайлово, целиком). """Универсальное LLM-обнаружение приватных данных (пофайлово, целиком).
Каждый файл обрабатывается ПОЛНОСТЬЮ: текст разбивается на чанки Каждый файл обрабатывается ПОЛНОСТЬЮ: текст разбивается на чанки
(_CHUNK_SIZE=6000, overlap=_CHUNK_OVERLAP), чанки обрабатываются параллельно (_CHUNK_SIZE=6000, overlap=_CHUNK_OVERLAP), чанки обрабатываются последовательно
(_LLM_CONCURRENCY), сущности дедуплицируются и верифицируются против полного (_LLM_CONCURRENCY), сущности дедуплицируются и верифицируются против полного
текста файла (отсечка галлюцинаций). regex-найденное не дублируется. текста файла (отсечка галлюцинаций). regex-найденное не дублируется.
@@ -229,6 +234,8 @@ def scan_llm_ner(all_texts: Dict[str, str], mapping: Dict[str, str],
mapping: Словарь замен (мутабельный, пополняется) mapping: Словарь замен (мутабельный, пополняется)
llm_client: Объект с методом .complete(prompt) -> str llm_client: Объект с методом .complete(prompt) -> str
counters: Глобальные счётчики токенов (мутабельный) counters: Глобальные счётчики токенов (мутабельный)
cancel_event: threading.Event — мягкая остановка между файлами (текущий добирается до конца)
file_progress: callable(event, fname, **fields) — прогресс по файлам/чанкам
""" """
already_found = list(mapping.keys()) already_found = list(mapping.keys())
@@ -238,10 +245,19 @@ def scan_llm_ner(all_texts: Dict[str, str], mapping: Dict[str, str],
) )
for fname, full_text in file_items: for fname, full_text in file_items:
# Мягкая остановка ТОЛЬКО между файлами — текущий файл добирается до конца
if cancel_event is not None and cancel_event.is_set():
raise CancelRequested()
if not full_text or full_text.startswith("[DOC binary"): if not full_text or full_text.startswith("[DOC binary"):
continue continue
chunks = split_into_chunks(full_text) chunks = split_into_chunks(full_text)
if file_progress:
file_progress("file_start", fname, chars=len(full_text), chunks=len(chunks))
t0 = time.time()
# Параллельные вызовы LLM по чанкам этого файла # Параллельные вызовы LLM по чанкам этого файла
if len(chunks) > 1 and _LLM_CONCURRENCY > 1: if len(chunks) > 1 and _LLM_CONCURRENCY > 1:
with ThreadPoolExecutor(max_workers=_LLM_CONCURRENCY) as _ex: with ThreadPoolExecutor(max_workers=_LLM_CONCURRENCY) as _ex:
@@ -252,8 +268,11 @@ def scan_llm_ner(all_texts: Dict[str, str], mapping: Dict[str, str],
all_ent.extend(fut.result()) all_ent.extend(fut.result())
else: else:
all_ent = [] all_ent = []
for c in chunks: for k, c in enumerate(chunks):
all_ent.extend(_call_llm(_build_llm_prompt(c, mapping.keys()), llm_client)) all_ent.extend(_call_llm(_build_llm_prompt(c, mapping.keys()), llm_client))
if file_progress:
file_progress("file_chunk", fname,
chunks_done=k + 1, chunks_total=len(chunks))
# Дедуп + верификация + добавление в mapping # Дедуп + верификация + добавление в mapping
seen: set = set() seen: set = set()
@@ -270,3 +289,6 @@ def scan_llm_ner(all_texts: Dict[str, str], mapping: Dict[str, str],
continue continue
ent_type = ent.get("type", "").strip().lower().replace(" ", "_") ent_type = ent.get("type", "").strip().lower().replace(" ", "_")
mapping[val] = _next_token(ent_type, counters) mapping[val] = _next_token(ent_type, counters)
if file_progress:
file_progress("file_done", fname, elapsed=round(time.time() - t0, 2))
+1 -1
View File
@@ -21,7 +21,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.62" VERSION = "0.0.63"
def setup_logging(): def setup_logging():
+178 -65
View File
@@ -13,6 +13,7 @@ import io
import json import json
import queue import queue
import threading import threading
import time
import zipfile import zipfile
import traceback import traceback
import logging import logging
@@ -23,7 +24,8 @@ from flask import Blueprint, request, send_file, jsonify, Response, stream_with_
from drhider import obfuscate_files, LLMClient from drhider import obfuscate_files, LLMClient
from session import (create_session, add_file, get_files, store_result, from session import (create_session, add_file, get_files, store_result,
get_result, store_csv, get_csv, cleanup, file_count, get_result, store_csv, get_csv, cleanup, file_count,
MAX_FILE_BYTES) MAX_FILE_BYTES, pause_ttl, resume_ttl,
request_cancel, get_cancel_event)
api_bp = Blueprint("api", __name__, url_prefix="/api") api_bp = Blueprint("api", __name__, url_prefix="/api")
log = logging.getLogger("routes.api_bp") log = logging.getLogger("routes.api_bp")
@@ -142,6 +144,20 @@ def session_files(sid):
}) })
@api_bp.route("/cancel/<sid>", methods=["POST"])
def cancel(sid):
"""Запросить мягкое прерывание обработки сессии.
Воркер останавливается на ближайшей границе файла (текущий добирается),
собирает частичный результат (готовые файлы + mapping) и шлёт SSE-событие `cancelled`.
"""
if request_cancel(sid):
log.info("cancel: requested sid=%s", sid)
return jsonify({"ok": True}), 200
log.warning("cancel: session not found sid=%s", sid)
return jsonify({"ok": False, "error": "Session not found"}), 404
@api_bp.route("/process_stream/<sid>", methods=["GET"]) @api_bp.route("/process_stream/<sid>", methods=["GET"])
def process_stream(sid): def process_stream(sid):
"""SSE: process all session files, streaming per-file progress. """SSE: process all session files, streaming per-file progress.
@@ -149,7 +165,7 @@ def process_stream(sid):
Все файлы обрабатываются ЕДИНЫМ вызовом obfuscate_files (общий mapping, Все файлы обрабатываются ЕДИНЫМ вызовом obfuscate_files (общий mapping,
согласованные токены). Обработка идёт в отдельном потоке; прогресс согласованные токены). Обработка идёт в отдельном потоке; прогресс
передаётся через очередь. Разрыв соединения клиента корректно передаётся через очередь. Разрыв соединения клиента корректно
перехватывается и останавливает генератор. перехватывается и останавливает генератор (и воркер — через cancel_event).
""" """
files = get_files(sid) files = get_files(sid)
if files is None: if files is None:
@@ -159,30 +175,60 @@ def process_stream(sid):
all_files = [(fname, content, "") for fname, content in files] all_files = [(fname, content, "") for fname, content in files]
log.info("process_stream: start sid=%s files=%d", sid, len(all_files)) log.info("process_stream: start sid=%s files=%d", sid, len(all_files))
# Сессия живёт, пока идёт обработка (TTL возобновляется в finally генератора)
pause_ttl(sid)
def generate(): def generate():
llm = LLMClient() llm = LLMClient()
q = queue.Queue() q = queue.Queue()
cancel = threading.Event() cancel = threading.Event() # локальный: разрыв клиента (стоп heartbeat)
cancel_event = get_cancel_event(sid) # из сессии: мягкая отмена (кнопка «Прервать»)
# Состояние для глобальной ETA (символы)
eta = {"total_chars": 0, "done_chars": 0, "cur_chars": 0, "cur_total": 0, "cur_done": 0}
_TOKENS_PER_CHAR = 8.0 # эмпирический коэффициент символов -> токенов LLM
def progress(phase, idx, total_, name, elapsed): def progress(phase, idx, total_, name, elapsed):
q.put(("progress", phase, idx, name, total_, elapsed)) q.put(("progress", phase, idx, name, total_, elapsed))
# Состояние для per-file ETA (чанки)
fstate = {"t0": 0.0, "total": 0, "done": 0}
def file_progress(event, fname, **fields):
if event == "file_start":
fstate["t0"] = time.time()
fstate["total"] = fields.get("chunks", 0)
fstate["done"] = 0
elif event == "file_chunk":
fstate["done"] = fields.get("chunks_done", 0)
elapsed = time.time() - fstate["t0"]
rate = fstate["done"] / elapsed if elapsed > 0 else 0
rem = fstate["total"] - fstate["done"]
if rate > 0:
fields["eta_sec"] = max(0, round(rem / rate))
q.put(("file", event, fname, fields))
def worker(): def worker():
log.info("worker: start sid=%s files=%d", sid, len(all_files)) log.info("worker: start sid=%s files=%d", sid, len(all_files))
t0 = datetime.utcnow() t0 = datetime.utcnow()
try: try:
zip_data, csv_str = obfuscate_files( zip_data, csv_str, meta = obfuscate_files(
all_files, llm_client=llm, progress_cb=progress all_files, llm_client=llm, progress_cb=progress,
cancel_event=cancel_event, file_progress=file_progress,
) )
stats = { stats = {
"tokens": llm.tokens_total, "tokens": llm.tokens_total,
"llm_sec": round(llm.llm_sec, 1), "llm_sec": round(llm.llm_sec, 1),
} }
dt = (datetime.utcnow() - t0).total_seconds() dt = (datetime.utcnow() - t0).total_seconds()
log.info("worker: done sid=%s in %.1fs tokens=%d llm_sec=%.1f zip_len=%d", if meta and meta.get("cancelled"):
sid, dt, llm.tokens_total, llm.llm_sec, len(zip_data)) log.info("worker: cancelled sid=%s in %.1fs processed=%d/%d",
q.put(("result", zip_data, csv_str, stats)) sid, dt, meta.get("processed", 0), meta.get("total", 0))
q.put(("cancelled", zip_data, csv_str, stats, meta))
else:
log.info("worker: done sid=%s in %.1fs tokens=%d llm_sec=%.1f zip_len=%d",
sid, dt, llm.tokens_total, llm.llm_sec, len(zip_data))
q.put(("result", zip_data, csv_str, stats))
except Exception as e: except Exception as e:
log.error("worker: exception sid=%s: %r\n%s", sid, e, traceback.format_exc()) log.error("worker: exception sid=%s: %r\n%s", sid, e, traceback.format_exc())
q.put(("error", repr(e))) q.put(("error", repr(e)))
@@ -190,70 +236,133 @@ def process_stream(sid):
threading.Thread(target=worker, daemon=True).start() threading.Thread(target=worker, daemon=True).start()
log.debug("process_stream: worker thread started sid=%s", sid) log.debug("process_stream: worker thread started sid=%s", sid)
while True: try:
try: while True:
evt = q.get(timeout=1)
except queue.Empty:
if cancel.is_set():
log.info("process_stream: cancelled sid=%s (generator exit)", sid)
return
# Heartbeat: живая статистика LLM (для таймера в UI)
try: try:
yield ( evt = q.get(timeout=1)
f"event: llm\n" except queue.Empty:
f"data: {json.dumps({'active': llm.llm_active, 'elapsed': round(llm.llm_elapsed_now(), 1), 'tokens': llm.tokens_total})}\n\n" if cancel.is_set():
) log.info("process_stream: cancelled sid=%s (generator exit)", sid)
except _disconnect_exceptions() as e: return
log.warning("process_stream: disconnect during heartbeat sid=%s err=%r", sid, e) # Heartbeat: живая статистика LLM + глобальная ETA
cancel.set() tokens = llm.tokens_total
return elapsed = llm.llm_elapsed_now()
continue eta_sec = None
if eta["total_chars"] > 0 and tokens > 0 and elapsed > 0:
est_total_tokens = eta["total_chars"] * _TOKENS_PER_CHAR
rate = tokens / elapsed
if rate > 0:
eta_sec = max(0, int((est_total_tokens - tokens) / rate))
done_chars = eta["done_chars"]
if eta["cur_total"] > 0:
done_chars += int(eta["cur_chars"] * (eta["cur_done"] / eta["cur_total"]))
try:
yield (
f"event: llm\n"
f"data: {json.dumps({'active': llm.llm_active, 'elapsed': round(elapsed, 1), 'tokens': tokens, 'eta_sec': eta_sec, 'done_chars': done_chars, 'total_chars': eta['total_chars']})}\n\n"
)
except _disconnect_exceptions() as e:
log.warning("process_stream: disconnect during heartbeat sid=%s err=%r", sid, e)
cancel.set()
if cancel_event:
cancel_event.set()
return
continue
kind = evt[0] kind = evt[0]
if kind == "progress": if kind == "progress":
_, phase, idx, name, total_, elapsed = evt _, phase, idx, name, total_, elapsed = evt
log.debug("process_stream: event=%s idx=%d name=%r elapsed=%s sid=%s", log.debug("process_stream: event=%s idx=%d name=%r elapsed=%s sid=%s",
phase, idx, name, elapsed, sid) phase, idx, name, elapsed, sid)
try: try:
yield ( yield (
f"event: {phase}\n" f"event: {phase}\n"
f"data: {json.dumps({'idx': idx, 'name': name, 'total': total_, 'elapsed': elapsed})}\n\n" f"data: {json.dumps({'idx': idx, 'name': name, 'total': total_, 'elapsed': elapsed})}\n\n"
) )
except _disconnect_exceptions() as e: except _disconnect_exceptions() as e:
log.warning("process_stream: disconnect on progress sid=%s phase=%s err=%r", sid, phase, e) log.warning("process_stream: disconnect on progress sid=%s phase=%s err=%r", sid, phase, e)
cancel.set() cancel.set()
if cancel_event:
cancel_event.set()
return
elif kind == "file":
_, event, fname, fields = evt
# Обновляем состояние для глобальной ETA
if event == "extract_done":
eta["total_chars"] = fields.get("total_chars", 0)
elif event == "file_start":
eta["cur_chars"] = fields.get("chars", 0)
eta["cur_total"] = fields.get("chunks", 0)
eta["cur_done"] = 0
elif event == "file_chunk":
eta["cur_done"] = fields.get("chunks_done", 0)
elif event == "file_done":
eta["done_chars"] += eta["cur_chars"]
eta["cur_chars"] = 0
eta["cur_total"] = 0
eta["cur_done"] = 0
try:
yield (
f"event: {event}\n"
f"data: {json.dumps({'name': fname, **fields})}\n\n"
)
except _disconnect_exceptions() as e:
log.warning("process_stream: disconnect on file event sid=%s err=%r", sid, e)
cancel.set()
if cancel_event:
cancel_event.set()
return
elif kind == "result":
_, zip_data, csv_str, stats = evt
log.info("process_stream: result sid=%s, storing result", sid)
store_result(sid, zip_data)
if csv_str:
store_csv(sid, csv_str)
count = 0
with zipfile.ZipFile(io.BytesIO(zip_data)) as zf:
count = len([n for n in zf.namelist() if n != "mapping.csv"])
log.info("process_stream: complete sid=%s count=%d stats=%r", sid, count, stats)
try:
yield (
f"event: complete\n"
f"data: {json.dumps({'total': count, **stats})}\n\n"
)
except _disconnect_exceptions() as e:
log.warning("process_stream: disconnect on complete sid=%s err=%r", sid, e)
return
return return
elif kind == "result": elif kind == "cancelled":
_, zip_data, csv_str, stats = evt _, zip_data, csv_str, stats, meta = evt
log.info("process_stream: result sid=%s, storing result", sid) log.info("process_stream: cancelled sid=%s, storing partial result", sid)
store_result(sid, zip_data) store_result(sid, zip_data)
if csv_str: if csv_str:
store_csv(sid, csv_str) store_csv(sid, csv_str)
count = 0 try:
with zipfile.ZipFile(io.BytesIO(zip_data)) as zf: yield (
count = len([n for n in zf.namelist() if n != "mapping.csv"]) f"event: cancelled\n"
log.info("process_stream: complete sid=%s count=%d stats=%r", sid, count, stats) f"data: {json.dumps({'saved': meta.get('processed', 0), 'total': meta.get('total', 0), **stats})}\n\n"
try: )
yield ( except _disconnect_exceptions() as e:
f"event: complete\n" log.warning("process_stream: disconnect on cancelled sid=%s err=%r", sid, e)
f"data: {json.dumps({'total': count, **stats})}\n\n" return
)
except _disconnect_exceptions() as e:
log.warning("process_stream: disconnect on complete sid=%s err=%r", sid, e)
return return
return
elif kind == "error": elif kind == "error":
_, msg = evt _, msg = evt
log.error("process_stream: error event sid=%s msg=%r", sid, msg) log.error("process_stream: error event sid=%s msg=%r", sid, msg)
try: try:
yield f"event: error\ndata: {json.dumps({'error': msg})}\n\n" yield f"event: error\ndata: {json.dumps({'error': msg})}\n\n"
except _disconnect_exceptions() as e: except _disconnect_exceptions() as e:
log.warning("process_stream: disconnect on error sid=%s err=%r", sid, e) log.warning("process_stream: disconnect on error sid=%s err=%r", sid, e)
return
return return
return finally:
resume_ttl(sid)
log.debug("process_stream: generator exit sid=%s, TTL resumed", sid)
return Response( return Response(
stream_with_context(generate()), stream_with_context(generate()),
@@ -273,7 +382,11 @@ def process(sid):
try: try:
llm = LLMClient() llm = LLMClient()
all_files = [(fname, content, "") for fname, content in files] all_files = [(fname, content, "") for fname, content in files]
zip_data, csv_str = obfuscate_files(all_files, llm_client=llm) pause_ttl(sid)
try:
zip_data, csv_str, _ = obfuscate_files(all_files, llm_client=llm)
finally:
resume_ttl(sid)
store_result(sid, zip_data) store_result(sid, zip_data)
if csv_str: if csv_str:
store_csv(sid, csv_str) store_csv(sid, csv_str)
+53
View File
@@ -42,6 +42,58 @@ def _start_timer(sid: str):
return timer return timer
def touch(sid: str):
"""Продлить жизнь сессии: перезапустить TTL-таймер (если сессия существует)."""
with _lock:
s = _sessions.get(sid)
if not s:
return
if s.get("timer"):
s["timer"].cancel()
s["timer"] = _start_timer(sid)
def pause_ttl(sid: str):
"""Приостановить TTL сессии (во время обработки): сессия живёт, пока идёт воркер."""
with _lock:
s = _sessions.get(sid)
if s and s.get("timer"):
s["timer"].cancel()
s["timer"] = None
def resume_ttl(sid: str):
"""Возобновить TTL сессии (после завершения обработки): результат доступен ещё TTL."""
with _lock:
s = _sessions.get(sid)
if not s:
return
if s.get("timer"):
s["timer"].cancel()
s["timer"] = _start_timer(sid)
def request_cancel(sid: str) -> bool:
"""Запросить мягкое прерывание обработки сессии.
Returns:
True если сессия существует и отмена запрошена, False если нет.
"""
with _lock:
s = _sessions.get(sid)
if not s:
return False
s["cancel"].set()
return True
def get_cancel_event(sid: str) -> Optional[threading.Event]:
"""Получить событие отмены сессии (или None, если сессии нет)."""
with _lock:
s = _sessions.get(sid)
return s["cancel"] if s else None
# ═══════════════════════════════════════════════════════════════════════════ # ═══════════════════════════════════════════════════════════════════════════
# API # API
# ═══════════════════════════════════════════════════════════════════════════ # ═══════════════════════════════════════════════════════════════════════════
@@ -57,6 +109,7 @@ def create_session() -> str:
_sessions[sid] = { _sessions[sid] = {
"files": [], "files": [],
"result": None, "result": None,
"cancel": threading.Event(),
"timer": _start_timer(sid), "timer": _start_timer(sid),
} }
return sid return sid
+212 -29
View File
@@ -62,6 +62,12 @@
.row-over td { background: rgba(220,38,38,.10) !important; color:#c0392b; } .row-over td { background: rgba(220,38,38,.10) !important; color:#c0392b; }
.row-over .num-cell { color:#c0392b; } .row-over .num-cell { color:#c0392b; }
.row-over .name-cell { color:#c0392b; } .row-over .name-cell { color:#c0392b; }
.row-current td { background: rgba(37,99,235,.12) !important; color:#1d4ed8; font-weight: 600; }
.grp-row td {
background: var(--brand-gray); font-size: 12px; font-weight: 700;
letter-spacing: .03em; text-transform: uppercase; color: var(--muted);
padding: 4px 8px; border: none;
}
.remove-btn { .remove-btn {
cursor: pointer; color: #f87171; background: none; border: none; cursor: pointer; color: #f87171; background: none; border: none;
padding: 2px 4px; font-size: 14px; line-height: 1; padding: 2px 4px; font-size: 14px; line-height: 1;
@@ -115,6 +121,7 @@
font-size: 18px; font-weight: 700; width: fit-content; font-size: 18px; font-weight: 700; width: fit-content;
} }
.live-llm.show { display: inline-block; } .live-llm.show { display: inline-block; }
.live-eta { margin-top: 8px; font-size: 14px; color: #1d4ed8; font-weight: 600; }
</style> </style>
</head> </head>
<body> <body>
@@ -153,6 +160,7 @@
</div> </div>
<div class="footer-bar"> <div class="footer-bar">
<span id="fileCount">0 файлов</span> <span id="fileCount">0 файлов</span>
<button class="btn" id="cancelBtn" style="display:none" onclick="confirmCancel()">⏹ Прервать</button>
<button class="btn btn-primary" id="uploadBtn" disabled onclick="uploadFiles()">🛡️ Обфусцировать</button> <button class="btn btn-primary" id="uploadBtn" disabled onclick="uploadFiles()">🛡️ Обфусцировать</button>
</div> </div>
<div class="status" id="status"></div> <div class="status" id="status"></div>
@@ -165,6 +173,7 @@
<div class="live-file" id="liveFile">—</div> <div class="live-file" id="liveFile">—</div>
<div class="live-timer" id="liveTimer">0.0 с</div> <div class="live-timer" id="liveTimer">0.0 с</div>
<div class="live-llm" id="liveLlm">🤖 ИИ обрабатывает… <span id="liveLlmTime">0.0 с</span></div> <div class="live-llm" id="liveLlm">🤖 ИИ обрабатывает… <span id="liveLlmTime">0.0 с</span></div>
<div class="live-eta" id="liveEta"></div>
</div> </div>
<div class="stats-block" id="statsBlock"> <div class="stats-block" id="statsBlock">
<div class="stats-title">📊 Итоги обработки</div> <div class="stats-title">📊 Итоги обработки</div>
@@ -196,6 +205,13 @@ const db = document.getElementById('dlBtns');
let sf = []; let sf = [];
let fileMeta = new Map(); // имя -> {size, mtime} для дедупа/суффиксов let fileMeta = new Map(); // имя -> {size, mtime} для дедупа/суффиксов
let overNames = new Set(); // имена файлов сверх лимита (не участвуют в обфускации) let overNames = new Set(); // имена файлов сверх лимита (не участвуют в обфускации)
// ── Состояние обработки (3-секционная таблица: готово / текущий / ожидают) ──
let procPhase = 'idle'; // 'idle' | 'processing'
let procState = {}; // sfIdx -> {st, elapsed, eta, est, chars, t0}
let procNameIdx = {}; // имя (с бэка) -> sfIdx
let procExtractDone = false; // true после extract_done (далее done = реальная готовность)
let procRefresh = null; // setInterval перерисовки таблицы
let ptimer = null, liveRefresh = null; // таймеры статуса и live-блока
const MAX_FILE_BYTES = 50 * 1024 * 1024; // 50 МБ на один файл const MAX_FILE_BYTES = 50 * 1024 * 1024; // 50 МБ на один файл
const MAX_SESSION_BYTES = 500 * 1024 * 1024; // 500 МБ суммарно на сессию const MAX_SESSION_BYTES = 500 * 1024 * 1024; // 500 МБ суммарно на сессию
const VM_UPLOAD_URL = 'https://contracts.kube5s.ru/drhider-upload/'; // ВМ-буфер: PUT больших файлов (шлюз кластера их рвёт) const VM_UPLOAD_URL = 'https://contracts.kube5s.ru/drhider-upload/'; // ВМ-буфер: PUT больших файлов (шлюз кластера их рвёт)
@@ -208,10 +224,18 @@ let activeXHR = null; // активный XHR
function resetAll() { function resetAll() {
if (activeES) { activeES.close(); activeES = null; } if (activeES) { activeES.close(); activeES = null; }
if (activeXHR) { activeXHR.abort(); activeXHR = null; } if (activeXHR) { activeXHR.abort(); activeXHR = null; }
if (ptimer) { clearInterval(ptimer); ptimer = null; }
if (liveRefresh) { clearInterval(liveRefresh); liveRefresh = null; }
if (procRefresh) { clearInterval(procRefresh); procRefresh = null; }
currentSid = ''; currentSid = '';
sf = []; sf = [];
fileMeta = new Map(); fileMeta = new Map();
overNames = new Set(); overNames = new Set();
procPhase = 'idle';
procState = {};
procNameIdx = {};
procExtractDone = false;
document.getElementById('cancelBtn').style.display = 'none';
fi.value = ''; fi.value = '';
rr(); rr();
st.className = ''; st.className = '';
@@ -227,7 +251,13 @@ window.addEventListener('beforeunload', () => resetAll());
function fs(b) { return b < 1024 ? b + ' B' : b < 1048576 ? (b / 1024).toFixed(1) + ' KB' : (b / 1048576).toFixed(1) + ' MB'; } function fs(b) { return b < 1024 ? b + ' B' : b < 1048576 ? (b / 1024).toFixed(1) + ' KB' : (b / 1048576).toFixed(1) + ' MB'; }
function fmtSec(s) {
s = Math.max(0, Math.round(s));
return s >= 60 ? Math.floor(s / 60) + 'м ' + (s % 60) + 'с' : s + 'с';
}
function rr() { function rr() {
if (procPhase === 'processing') { renderProcTable(); return; }
if (sf.length === 0) { fl.innerHTML = '<tr class="empty-row"><td colspan="4">Нет выбранных файлов</td></tr>'; } if (sf.length === 0) { fl.innerHTML = '<tr class="empty-row"><td colspan="4">Нет выбранных файлов</td></tr>'; }
else { else {
fl.innerHTML = sf.map((f, i) => { fl.innerHTML = sf.map((f, i) => {
@@ -244,6 +274,91 @@ function rr() {
ub.disabled = cntMain === 0; ub.disabled = cntMain === 0;
} }
// ═══ 3-секционная таблица во время обработки (готово / текущий / ожидают) ═══
function procRow(i, stTxt) {
const f = sf[i];
const over = overNames.has(f.name);
const cls = (procState[i] && procState[i].st === 'current') ? ' class="row-current"'
: (over ? ' class="row-over"' : '');
return '<tr' + cls + '><td class="name-cell">' + f.name + '</td><td class="num-cell">' + fs(f.size) + '</td><td class="num-cell" style="font-size:12px;">' + stTxt + '</td><td></td></tr>';
}
function renderProcTable() {
const groups = { done: [], current: [], pending: [] };
for (let i = 0; i < sf.length; i++) {
const st = procState[i] ? procState[i].st : 'pending';
if (st === 'done' || st === 'skipped') groups.done.push(i);
else if (st === 'current') groups.current.push(i);
else groups.pending.push(i);
}
// Оценка скорости из текущего файла (сек/символ) — для «ожидающих»
let rate = null;
for (const i of groups.current) {
const p = procState[i];
const cur = (performance.now() - p.t0) / 1000;
const total = cur + (p.eta != null ? p.eta : 0);
if (p.chars > 0 && total > 0) rate = total / p.chars;
}
const rows = [];
if (groups.done.length) {
rows.push('<tr class="grp-row"><td colspan="4">✓ Обработанные (' + groups.done.length + ')</td></tr>');
for (const i of groups.done) {
const p = procState[i];
const txt = p.st === 'skipped'
? '<span style="color:#c0392b;">пропущен</span>'
: '<span style="color:#22c55e;">✓ ' + (p.elapsed ? p.elapsed.toFixed(1) : '0.0') + 'с</span>';
rows.push(procRow(i, txt));
}
}
if (groups.current.length) {
rows.push('<tr class="grp-row"><td colspan="4">▶ Текущий файл</td></tr>');
for (const i of groups.current) {
const p = procState[i];
const cur = ((performance.now() - p.t0) / 1000).toFixed(1);
const eta = (p.eta != null) ? ' / ~' + fmtSec(p.eta) : '';
rows.push(procRow(i, '<span style="color:#2563eb;">⏳ ' + cur + 'с' + eta + '</span>'));
}
}
if (groups.pending.length) {
rows.push('<tr class="grp-row"><td colspan="4">○ Ожидают обработки (' + groups.pending.length + ')</td></tr>');
for (const i of groups.pending) {
const p = procState[i] || {};
if (rate && !p.est && p.chars > 0) p.est = p.chars * rate;
let txt;
if (overNames.has(sf[i].name)) txt = '<span style="color:#c0392b;">🔥 не учитывается</span>';
else if (p.st === 'analyzed') txt = '<span style="color:#7d3c98;">анализ ✓</span>';
else if (p.est != null) txt = '<span style="color:#999;">~' + fmtSec(p.est) + '</span>';
else txt = '<span style="color:#999;">—</span>';
rows.push(procRow(i, txt));
}
}
fl.innerHTML = rows.join('');
const cntDone = groups.done.length;
fc.textContent = 'Готово ' + cntDone + ' / ' + sf.length + ' файлов';
}
function confirmCancel() {
document.getElementById('cancelModal').style.display = 'flex';
}
function doCancel() {
document.getElementById('cancelModal').style.display = 'none';
if (!currentSid) return;
fetch('/api/cancel/' + currentSid, { method: 'POST' }).catch(() => {});
st.textContent = '⏹ Остановка… завершаем текущий файл';
}
function finishProcUI() {
if (activeES) { activeES.close(); activeES = null; }
if (ptimer) { clearInterval(ptimer); ptimer = null; }
if (liveRefresh) { clearInterval(liveRefresh); liveRefresh = null; }
if (procRefresh) { clearInterval(procRefresh); procRefresh = null; }
document.getElementById('liveBlock').classList.remove('show');
document.getElementById('cancelBtn').style.display = 'none';
procPhase = 'idle';
rr();
}
function rm(i) { const nm = sf[i].name; overNames.delete(nm); fileMeta.delete(nm); sf.splice(i, 1); const d = new DataTransfer(); sf.forEach(f => d.items.add(f)); fi.files = d.files; rr(); } function rm(i) { const nm = sf[i].name; overNames.delete(nm); fileMeta.delete(nm); sf.splice(i, 1); const d = new DataTransfer(); sf.forEach(f => d.items.add(f)); fi.files = d.files; rr(); }
window.addEventListener('load', () => { sf = []; fileMeta = new Map(); fi.value = ''; rr(); /* прогрев upstream-соединения */ fetch('/health').catch(() => {}); }); window.addEventListener('load', () => { sf = []; fileMeta = new Map(); fi.value = ''; rr(); /* прогрев upstream-соединения */ fetch('/health').catch(() => {}); });
@@ -524,12 +639,24 @@ async function uploadFiles() {
// Фаза 2: обработка (SSE — прогресс по каждому файлу) // Фаза 2: обработка (SSE — прогресс по каждому файлу)
// Сброс загрузочных статусов — теперь этап обработки (только отправленные) // Сброс загрузочных статусов — теперь этап обработки (только отправленные)
for (let k = 0; k < total; k++) ss(sendIdx[k], '<span style="color:#2563eb">⏳</span>'); for (let k = 0; k < total; k++) ss(sendIdx[k], '<span style="color:#2563eb">⏳</span>');
// Инициализация 3-секционной таблицы (готово / текущий / ожидают)
procPhase = 'processing';
procState = {};
procNameIdx = {};
procExtractDone = false;
for (let k = 0; k < total; k++) {
procState[sendIdx[k]] = { st: 'pending', elapsed: 0, eta: null, est: null, chars: 0, t0: 0 };
}
document.getElementById('cancelBtn').style.display = 'inline-block';
if (procRefresh) clearInterval(procRefresh);
procRefresh = setInterval(rr, 1000); // тикающий рендер (elapsed текущего файла)
rr();
const fileTimers = {}; // idx -> performance.now() const fileTimers = {}; // idx -> performance.now()
const fileIntervals = {}; // idx -> setInterval id const fileIntervals = {}; // idx -> setInterval id
st.className = 'status progress'; st.className = 'status progress';
st.textContent = 'Обработка (этап 2/2)…'; st.textContent = 'Обработка (этап 2/2)…';
const t0 = performance.now(); const t0 = performance.now();
const ptimer = setInterval(() => { ptimer = setInterval(() => {
const sec = Math.round((performance.now() - t0) / 1000); const sec = Math.round((performance.now() - t0) / 1000);
st.textContent = 'Обработка (этап 2/2)… ' + sec + 'с'; st.textContent = 'Обработка (этап 2/2)… ' + sec + 'с';
}, 1000); }, 1000);
@@ -540,12 +667,14 @@ async function uploadFiles() {
const liveFileEl = document.getElementById('liveFile'); const liveFileEl = document.getElementById('liveFile');
const liveLlm = document.getElementById('liveLlm'); const liveLlm = document.getElementById('liveLlm');
const liveLlmTime = document.getElementById('liveLlmTime'); const liveLlmTime = document.getElementById('liveLlmTime');
const liveEta = document.getElementById('liveEta');
let currentLiveFile = ''; // последнее имя файла из start let currentLiveFile = ''; // последнее имя файла из start
liveLlm.classList.remove('show'); liveLlm.classList.remove('show');
liveEta.textContent = '';
liveTimerEl.textContent = '0.0 с'; liveTimerEl.textContent = '0.0 с';
liveFileEl.textContent = 'Подготовка…'; liveFileEl.textContent = 'Подготовка…';
liveBlock.classList.add('show'); liveBlock.classList.add('show');
const liveRefresh = setInterval(() => { liveRefresh = setInterval(() => {
const sec = ((performance.now() - t0) / 1000).toFixed(1); const sec = ((performance.now() - t0) / 1000).toFixed(1);
liveTimerEl.textContent = sec + ' с'; liveTimerEl.textContent = sec + ' с';
}, 200); }, 200);
@@ -556,29 +685,53 @@ async function uploadFiles() {
activeES.addEventListener('start', function(e) { activeES.addEventListener('start', function(e) {
const d = JSON.parse(e.data); const d = JSON.parse(e.data);
const idx = sendIdx[d.idx]; // индекс в sf для вывода прогресса const idx = sendIdx[d.idx]; // индекс в sf для вывода прогресса
// Таймер тикает ТОЛЬКО у текущего файла; таймеры остальных останавливаем procNameIdx[d.name] = idx;
for (const k in fileIntervals) { const p = procState[idx];
clearInterval(fileIntervals[k]); if (p) { p.st = 'pending'; p.elapsed = 0; }
delete fileIntervals[k];
}
fileTimers[idx] = performance.now();
fileIntervals[idx] = setInterval(function() {
const elapsed = ((performance.now() - fileTimers[idx]) / 1000).toFixed(1);
ss(idx, '<span style="color:#2563eb">⏳ ' + elapsed + 'с</span>');
}, 200);
// Показываем текущий файл и его размер в live-блоке
const f = sf[idx]; const f = sf[idx];
const sz = f ? fs(f.size) : ''; const sz = f ? fs(f.size) : '';
currentLiveFile = 'Файл ' + (d.idx + 1) + '/' + total + ': ' + d.name + (sz ? ' (' + sz + ')' : ''); currentLiveFile = 'Файл ' + (d.idx + 1) + '/' + total + ': ' + d.name + (sz ? ' (' + sz + ')' : '');
liveFileEl.textContent = currentLiveFile; liveFileEl.textContent = currentLiveFile;
}); });
activeES.addEventListener('extract_done', function(e) {
const d = JSON.parse(e.data);
procExtractDone = true;
// per-file символы -> оценка времени для ожидающих файлов
if (d.per_file) {
for (const nm in d.per_file) {
const idx = procNameIdx[nm];
if (idx != null && procState[idx]) procState[idx].chars = d.per_file[nm];
}
}
});
activeES.addEventListener('file_start', function(e) {
const d = JSON.parse(e.data);
const idx = sendIdx[d.idx];
const p = procState[idx];
if (p) { p.st = 'current'; p.t0 = performance.now(); p.eta = null; }
});
activeES.addEventListener('file_chunk', function(e) {
const d = JSON.parse(e.data);
const idx = sendIdx[d.idx];
const p = procState[idx];
if (p && typeof d.eta_sec === 'number') p.eta = d.eta_sec;
liveFileEl.textContent = 'Файл ' + (d.idx + 1) + '/' + total + ': ' + d.name +
(typeof d.eta_sec === 'number' ? ' — осталось ~' + fmtSec(d.eta_sec) : '');
});
activeES.addEventListener('file_done', function(e) {
const d = JSON.parse(e.data);
const idx = sendIdx[d.idx];
const p = procState[idx];
if (p) { p.st = 'analyzed'; }
});
activeES.addEventListener('llm', function(e) { activeES.addEventListener('llm', function(e) {
const d = JSON.parse(e.data); const d = JSON.parse(e.data);
if (d.active) { if (d.active) {
liveLlmTime.textContent = d.elapsed + ' с'; liveLlmTime.textContent = d.elapsed + ' с';
liveLlm.classList.add('show'); liveLlm.classList.add('show');
// LLM анализирует ВСЕ файлы вместе — не показываем имя конкретного
liveFileEl.textContent = '🤖 ИИ анализирует все файлы…'; liveFileEl.textContent = '🤖 ИИ анализирует все файлы…';
if (typeof d.eta_sec === 'number' && d.eta_sec >= 0) liveEta.textContent = '⏳ осталось ~' + fmtSec(d.eta_sec);
else liveEta.textContent = '';
} else { } else {
liveLlm.classList.remove('show'); liveLlm.classList.remove('show');
if (currentLiveFile) liveFileEl.textContent = currentLiveFile; if (currentLiveFile) liveFileEl.textContent = currentLiveFile;
@@ -587,19 +740,41 @@ async function uploadFiles() {
activeES.addEventListener('done', function(e) { activeES.addEventListener('done', function(e) {
const d = JSON.parse(e.data); const d = JSON.parse(e.data);
const idx = sendIdx[d.idx]; const idx = sendIdx[d.idx];
clearInterval(fileIntervals[idx]); if (!procExtractDone) {
// done на этапе извлечения = битый/пропущенный файл
const p0 = procState[idx];
if (p0) p0.st = 'skipped';
return;
}
// Время на КОНКРЕТНЫЙ файл приходит с бэка (extract_text + замена, без общего LLM) // Время на КОНКРЕТНЫЙ файл приходит с бэка (extract_text + замена, без общего LLM)
const sec = (typeof d.elapsed === 'number' && d.elapsed > 0) const sec = (typeof d.elapsed === 'number' && d.elapsed > 0)
? d.elapsed.toFixed(1) ? d.elapsed.toFixed(1)
: ((performance.now() - (fileTimers[idx] || performance.now())) / 1000).toFixed(1); : ((performance.now() - (fileTimers[idx] || performance.now())) / 1000).toFixed(1);
ss(idx, '<span style="color:#22c55e">✓ ' + sec + 'с</span>'); const p = procState[idx];
if (p) { p.st = 'done'; p.elapsed = d.elapsed > 0 ? d.elapsed : parseFloat(sec); }
else procState[idx] = { st: 'done', elapsed: parseFloat(sec), eta: null, est: null, chars: 0, t0: 0 };
});
activeES.addEventListener('cancelled', function(e) {
const d = JSON.parse(e.data);
finishProcUI();
const totalSec = ((performance.now() - t0) / 1000).toFixed(1);
st.className = 'status done';
st.textContent = '⏹ Остановлено. Сохранено ' + d.saved + ' из ' + d.total + ' файлов + таблица замен (общее ' + totalSec + 'с)';
const sb = document.getElementById('statsBlock');
document.getElementById('stTotalTime').textContent = totalSec + ' с';
if (d.llm_sec > 0) {
document.getElementById('stLlmTime').textContent = d.llm_sec + ' с';
document.getElementById('stLlmTokens').textContent = d.tokens > 0 ? d.tokens : '—';
} else {
document.getElementById('stLlmTime').textContent = '—';
document.getElementById('stLlmTokens').textContent = '—';
}
sb.classList.add('show');
db.classList.add('show');
resolve();
}); });
activeES.addEventListener('complete', function(e) { activeES.addEventListener('complete', function(e) {
activeES.close(); finishProcUI();
activeES = null;
clearInterval(ptimer);
clearInterval(liveRefresh);
liveBlock.classList.remove('show');
const d = JSON.parse(e.data); const d = JSON.parse(e.data);
const totalSec = ((performance.now() - t0) / 1000).toFixed(1); const totalSec = ((performance.now() - t0) / 1000).toFixed(1);
st.className = 'status done'; st.className = 'status done';
@@ -619,18 +794,12 @@ async function uploadFiles() {
resolve(); resolve();
}); });
activeES.onerror = function() { activeES.onerror = function() {
activeES.close(); finishProcUI();
activeES = null;
clearInterval(ptimer);
clearInterval(liveRefresh);
liveBlock.classList.remove('show');
reject(new Error('SSE connection failed')); reject(new Error('SSE connection failed'));
}; };
}); });
} catch (err) { } catch (err) {
clearInterval(ptimer); finishProcUI();
clearInterval(liveRefresh);
liveBlock.classList.remove('show');
st.className = 'status error'; st.className = 'status error';
st.textContent = 'Ошибка: ' + err.message; st.textContent = 'Ошибка: ' + err.message;
} }
@@ -686,5 +855,19 @@ function downloadCsv() {
<button class="btn" style="margin-top:12px;width:100%" onclick="document.getElementById('helpModal').style.display='none'">Закрыть</button> <button class="btn" style="margin-top:12px;width:100%" onclick="document.getElementById('helpModal').style.display='none'">Закрыть</button>
</div> </div>
</div> </div>
<div id="cancelModal" style="display:none;position:fixed;top:0;left:0;width:100%;height:100%;background:rgba(0,0,0,.5);z-index:999;justify-content:center;align-items:center" onclick="this.style.display='none'">
<div style="background:var(--card);border-radius:12px;padding:24px;max-width:440px;box-shadow:0 4px 24px rgba(0,0,0,.15)" onclick="event.stopPropagation()">
<h3 style="margin-bottom:12px">⏹ Остановить обработку?</h3>
<p style="font-size:13px;color:var(--muted);line-height:1.6" id="cancelModalText">
Будут сохранены: полностью обработанные файлы и таблица замен.
Необработанные файлы в результат не попадут.
Текущий анализ будет доведён до конца.
</p>
<div style="margin-top:16px;display:flex;gap:8px;justify-content:flex-end">
<button class="btn" onclick="document.getElementById('cancelModal').style.display='none'">Отмена</button>
<button class="btn btn-primary" onclick="doCancel()">Остановить</button>
</div>
</div>
</div>
</body> </body>
</html> </html>