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(
self, files: List[Tuple[str, bytes, str]],
progress_cb: Optional[Callable[[str, int, int, str], None]] = None
) -> Tuple[bytes, str]:
progress_cb: Optional[Callable[[str, int, int, str], None]] = None,
cancel_event=None, file_progress=None
) -> Tuple[bytes, str, Optional[dict]]:
"""Обфусцировать список файлов.
Все форматы → Markdown → замена → результат.
@@ -99,16 +100,34 @@ class TwoPassObfuscator:
progress_cb: Опциональный коллбек (phase, idx, total, fname, elapsed),
где phase ∈ {"start", "done"}, idx — 0-based индекс,
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:
(zip_bytes, csv_string):
zip_bytes — ZIP-архив с обфусцированными .md файлами + mapping.csv
(zip_bytes, csv_string, meta):
zip_bytes — ZIP-архив с обфусцированными .md файлами (без mapping.csv)
csv_string — содержимое mapping.csv как строка
meta — None при штатном завершении; {"cancelled": True, "processed", "total"}
при прерывании (частичный результат)
"""
# ── Предобработка: распаковать ZIP + уникализировать имена ──
files = extractor.expand_zips(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:
# ── Проход 1: сбор сущностей ──
# Извлекаем Markdown из каждого файла
@@ -142,9 +161,23 @@ class TwoPassObfuscator:
scanner.scan_regex(text, self._mapping, self._counters)
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'ом чтобы не дублировать)
cancelled_llm = False
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(
@@ -154,6 +187,7 @@ class TwoPassObfuscator:
self._compiled_re = replacer._build_combined_re(self._sorted_keys)
# ── Проход 2: замена сущностей ──
cancelled = cancelled_llm
results: List[Tuple[str, bytes]] = []
for i, (in_fname, content, ctype) in enumerate(files):
@@ -166,6 +200,17 @@ class TwoPassObfuscator:
progress_cb("done", i, total, display_name, 0.0)
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()
obf_content = content # По умолчанию — без изменений
@@ -198,7 +243,11 @@ class TwoPassObfuscator:
csv_str = builder.build_mapping_csv(self._mapping)
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:
# Очистка состояния (обфускатор может использоваться повторно)
@@ -214,8 +263,9 @@ class TwoPassObfuscator:
def obfuscate_files(
files: List[Tuple[str, bytes, str]], llm_client=None,
progress_cb: Optional[Callable[[str, int, int, str], None]] = None
) -> Tuple[bytes, str]:
progress_cb: Optional[Callable[[str, int, int, str], None]] = None,
cancel_event=None, file_progress=None
) -> Tuple[bytes, str, Optional[dict]]:
"""Обфусцировать список файлов — удобная функция.
Создаёт экземпляр TwoPassObfuscator и вызывает .obfuscate().
@@ -224,9 +274,12 @@ def obfuscate_files(
files: [(filename, content_bytes, content_type), ...]
llm_client: Опциональный LLM-клиент
progress_cb: Опциональный коллбек (phase, idx, total, fname)
cancel_event: threading.Event — мягкая остановка (см. TwoPassObfuscator.obfuscate)
file_progress: callable(event, fname, **fields) — прогресс по файлам/чанкам
Returns:
(zip_bytes, csv_string)
(zip_bytes, csv_string, meta)
"""
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 json
import random
import time
import logging
from concurrent.futures import ThreadPoolExecutor
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-схема)
# ═══════════════════════════════════════════════════════════════════════════
class CancelRequested(Exception):
"""Обработка прервана пользователем (мягкая остановка между файлами LLM)."""
# Приоритет границ для чанков (от предпочтительных к жёстким)
_CHUNK_BOUNDARIES = ['\n\n', '\n', '. ', '? ', '! ', '; ', ', ', ' ']
_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],
llm_client, counters: Dict[str, int]) -> None:
llm_client, counters: Dict[str, int],
cancel_event=None, file_progress=None) -> None:
"""Универсальное LLM-обнаружение приватных данных (пофайлово, целиком).
Каждый файл обрабатывается ПОЛНОСТЬЮ: текст разбивается на чанки
(_CHUNK_SIZE=6000, overlap=_CHUNK_OVERLAP), чанки обрабатываются параллельно
(_CHUNK_SIZE=6000, overlap=_CHUNK_OVERLAP), чанки обрабатываются последовательно
(_LLM_CONCURRENCY), сущности дедуплицируются и верифицируются против полного
текста файла (отсечка галлюцинаций). regex-найденное не дублируется.
@@ -229,6 +234,8 @@ def scan_llm_ner(all_texts: Dict[str, str], mapping: Dict[str, str],
mapping: Словарь замен (мутабельный, пополняется)
llm_client: Объект с методом .complete(prompt) -> str
counters: Глобальные счётчики токенов (мутабельный)
cancel_event: threading.Event — мягкая остановка между файлами (текущий добирается до конца)
file_progress: callable(event, fname, **fields) — прогресс по файлам/чанкам
"""
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:
# Мягкая остановка ТОЛЬКО между файлами — текущий файл добирается до конца
if cancel_event is not None and cancel_event.is_set():
raise CancelRequested()
if not full_text or full_text.startswith("[DOC binary"):
continue
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 по чанкам этого файла
if len(chunks) > 1 and _LLM_CONCURRENCY > 1:
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())
else:
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))
if file_progress:
file_progress("file_chunk", fname,
chunks_done=k + 1, chunks_total=len(chunks))
# Дедуп + верификация + добавление в mapping
seen: set = set()
@@ -270,3 +289,6 @@ def scan_llm_ner(all_texts: Dict[str, str], mapping: Dict[str, str],
continue
ent_type = ent.get("type", "").strip().lower().replace(" ", "_")
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)
# Версия приложения (меняется при изменениях)
VERSION = "0.0.62"
VERSION = "0.0.63"
def setup_logging():
+178 -65
View File
@@ -13,6 +13,7 @@ import io
import json
import queue
import threading
import time
import zipfile
import traceback
import logging
@@ -23,7 +24,8 @@ from flask import Blueprint, request, send_file, jsonify, Response, stream_with_
from drhider import obfuscate_files, LLMClient
from session import (create_session, add_file, get_files, store_result,
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")
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"])
def process_stream(sid):
"""SSE: process all session files, streaming per-file progress.
@@ -149,7 +165,7 @@ def process_stream(sid):
Все файлы обрабатываются ЕДИНЫМ вызовом obfuscate_files (общий mapping,
согласованные токены). Обработка идёт в отдельном потоке; прогресс
передаётся через очередь. Разрыв соединения клиента корректно
перехватывается и останавливает генератор.
перехватывается и останавливает генератор (и воркер — через cancel_event).
"""
files = get_files(sid)
if files is None:
@@ -159,30 +175,60 @@ def process_stream(sid):
all_files = [(fname, content, "") for fname, content in files]
log.info("process_stream: start sid=%s files=%d", sid, len(all_files))
# Сессия живёт, пока идёт обработка (TTL возобновляется в finally генератора)
pause_ttl(sid)
def generate():
llm = LLMClient()
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):
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():
log.info("worker: start sid=%s files=%d", sid, len(all_files))
t0 = datetime.utcnow()
try:
zip_data, csv_str = obfuscate_files(
all_files, llm_client=llm, progress_cb=progress
zip_data, csv_str, meta = obfuscate_files(
all_files, llm_client=llm, progress_cb=progress,
cancel_event=cancel_event, file_progress=file_progress,
)
stats = {
"tokens": llm.tokens_total,
"llm_sec": round(llm.llm_sec, 1),
}
dt = (datetime.utcnow() - t0).total_seconds()
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))
if meta and meta.get("cancelled"):
log.info("worker: cancelled sid=%s in %.1fs processed=%d/%d",
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:
log.error("worker: exception sid=%s: %r\n%s", sid, e, traceback.format_exc())
q.put(("error", repr(e)))
@@ -190,70 +236,133 @@ def process_stream(sid):
threading.Thread(target=worker, daemon=True).start()
log.debug("process_stream: worker thread started sid=%s", sid)
while True:
try:
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:
while True:
try:
yield (
f"event: llm\n"
f"data: {json.dumps({'active': llm.llm_active, 'elapsed': round(llm.llm_elapsed_now(), 1), 'tokens': llm.tokens_total})}\n\n"
)
except _disconnect_exceptions() as e:
log.warning("process_stream: disconnect during heartbeat sid=%s err=%r", sid, e)
cancel.set()
return
continue
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 + глобальная ETA
tokens = llm.tokens_total
elapsed = llm.llm_elapsed_now()
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":
_, phase, idx, name, total_, elapsed = evt
log.debug("process_stream: event=%s idx=%d name=%r elapsed=%s sid=%s",
phase, idx, name, elapsed, sid)
try:
yield (
f"event: {phase}\n"
f"data: {json.dumps({'idx': idx, 'name': name, 'total': total_, 'elapsed': elapsed})}\n\n"
)
except _disconnect_exceptions() as e:
log.warning("process_stream: disconnect on progress sid=%s phase=%s err=%r", sid, phase, e)
cancel.set()
if kind == "progress":
_, phase, idx, name, total_, elapsed = evt
log.debug("process_stream: event=%s idx=%d name=%r elapsed=%s sid=%s",
phase, idx, name, elapsed, sid)
try:
yield (
f"event: {phase}\n"
f"data: {json.dumps({'idx': idx, 'name': name, 'total': total_, 'elapsed': elapsed})}\n\n"
)
except _disconnect_exceptions() as e:
log.warning("process_stream: disconnect on progress sid=%s phase=%s err=%r", sid, phase, e)
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
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)
elif kind == "cancelled":
_, zip_data, csv_str, stats, meta = evt
log.info("process_stream: cancelled sid=%s, storing partial result", sid)
store_result(sid, zip_data)
if csv_str:
store_csv(sid, csv_str)
try:
yield (
f"event: cancelled\n"
f"data: {json.dumps({'saved': meta.get('processed', 0), 'total': meta.get('total', 0), **stats})}\n\n"
)
except _disconnect_exceptions() as e:
log.warning("process_stream: disconnect on cancelled sid=%s err=%r", sid, e)
return
return
return
elif kind == "error":
_, msg = evt
log.error("process_stream: error event sid=%s msg=%r", sid, msg)
try:
yield f"event: error\ndata: {json.dumps({'error': msg})}\n\n"
except _disconnect_exceptions() as e:
log.warning("process_stream: disconnect on error sid=%s err=%r", sid, e)
elif kind == "error":
_, msg = evt
log.error("process_stream: error event sid=%s msg=%r", sid, msg)
try:
yield f"event: error\ndata: {json.dumps({'error': msg})}\n\n"
except _disconnect_exceptions() as e:
log.warning("process_stream: disconnect on error sid=%s err=%r", sid, e)
return
return
return
finally:
resume_ttl(sid)
log.debug("process_stream: generator exit sid=%s, TTL resumed", sid)
return Response(
stream_with_context(generate()),
@@ -273,7 +382,11 @@ def process(sid):
try:
llm = LLMClient()
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)
if csv_str:
store_csv(sid, csv_str)
+53
View File
@@ -42,6 +42,58 @@ def _start_timer(sid: str):
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
# ═══════════════════════════════════════════════════════════════════════════
@@ -57,6 +109,7 @@ def create_session() -> str:
_sessions[sid] = {
"files": [],
"result": None,
"cancel": threading.Event(),
"timer": _start_timer(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 .num-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 {
cursor: pointer; color: #f87171; background: none; border: none;
padding: 2px 4px; font-size: 14px; line-height: 1;
@@ -115,6 +121,7 @@
font-size: 18px; font-weight: 700; width: fit-content;
}
.live-llm.show { display: inline-block; }
.live-eta { margin-top: 8px; font-size: 14px; color: #1d4ed8; font-weight: 600; }
</style>
</head>
<body>
@@ -153,6 +160,7 @@
</div>
<div class="footer-bar">
<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>
</div>
<div class="status" id="status"></div>
@@ -165,6 +173,7 @@
<div class="live-file" id="liveFile">—</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-eta" id="liveEta"></div>
</div>
<div class="stats-block" id="statsBlock">
<div class="stats-title">📊 Итоги обработки</div>
@@ -196,6 +205,13 @@ const db = document.getElementById('dlBtns');
let sf = [];
let fileMeta = new Map(); // имя -> {size, mtime} для дедупа/суффиксов
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_SESSION_BYTES = 500 * 1024 * 1024; // 500 МБ суммарно на сессию
const VM_UPLOAD_URL = 'https://contracts.kube5s.ru/drhider-upload/'; // ВМ-буфер: PUT больших файлов (шлюз кластера их рвёт)
@@ -208,10 +224,18 @@ let activeXHR = null; // активный XHR
function resetAll() {
if (activeES) { activeES.close(); activeES = 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 = '';
sf = [];
fileMeta = new Map();
overNames = new Set();
procPhase = 'idle';
procState = {};
procNameIdx = {};
procExtractDone = false;
document.getElementById('cancelBtn').style.display = 'none';
fi.value = '';
rr();
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 fmtSec(s) {
s = Math.max(0, Math.round(s));
return s >= 60 ? Math.floor(s / 60) + 'м ' + (s % 60) + 'с' : s + 'с';
}
function rr() {
if (procPhase === 'processing') { renderProcTable(); return; }
if (sf.length === 0) { fl.innerHTML = '<tr class="empty-row"><td colspan="4">Нет выбранных файлов</td></tr>'; }
else {
fl.innerHTML = sf.map((f, i) => {
@@ -244,6 +274,91 @@ function rr() {
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(); }
window.addEventListener('load', () => { sf = []; fileMeta = new Map(); fi.value = ''; rr(); /* прогрев upstream-соединения */ fetch('/health').catch(() => {}); });
@@ -524,12 +639,24 @@ async function uploadFiles() {
// Фаза 2: обработка (SSE — прогресс по каждому файлу)
// Сброс загрузочных статусов — теперь этап обработки (только отправленные)
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 fileIntervals = {}; // idx -> setInterval id
st.className = 'status progress';
st.textContent = 'Обработка (этап 2/2)…';
const t0 = performance.now();
const ptimer = setInterval(() => {
ptimer = setInterval(() => {
const sec = Math.round((performance.now() - t0) / 1000);
st.textContent = 'Обработка (этап 2/2)… ' + sec + 'с';
}, 1000);
@@ -540,12 +667,14 @@ async function uploadFiles() {
const liveFileEl = document.getElementById('liveFile');
const liveLlm = document.getElementById('liveLlm');
const liveLlmTime = document.getElementById('liveLlmTime');
const liveEta = document.getElementById('liveEta');
let currentLiveFile = ''; // последнее имя файла из start
liveLlm.classList.remove('show');
liveEta.textContent = '';
liveTimerEl.textContent = '0.0 с';
liveFileEl.textContent = 'Подготовка…';
liveBlock.classList.add('show');
const liveRefresh = setInterval(() => {
liveRefresh = setInterval(() => {
const sec = ((performance.now() - t0) / 1000).toFixed(1);
liveTimerEl.textContent = sec + ' с';
}, 200);
@@ -556,29 +685,53 @@ async function uploadFiles() {
activeES.addEventListener('start', function(e) {
const d = JSON.parse(e.data);
const idx = sendIdx[d.idx]; // индекс в sf для вывода прогресса
// Таймер тикает ТОЛЬКО у текущего файла; таймеры остальных останавливаем
for (const k in fileIntervals) {
clearInterval(fileIntervals[k]);
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-блоке
procNameIdx[d.name] = idx;
const p = procState[idx];
if (p) { p.st = 'pending'; p.elapsed = 0; }
const f = sf[idx];
const sz = f ? fs(f.size) : '';
currentLiveFile = 'Файл ' + (d.idx + 1) + '/' + total + ': ' + d.name + (sz ? ' (' + sz + ')' : '');
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) {
const d = JSON.parse(e.data);
if (d.active) {
liveLlmTime.textContent = d.elapsed + ' с';
liveLlm.classList.add('show');
// LLM анализирует ВСЕ файлы вместе — не показываем имя конкретного
liveFileEl.textContent = '🤖 ИИ анализирует все файлы…';
if (typeof d.eta_sec === 'number' && d.eta_sec >= 0) liveEta.textContent = '⏳ осталось ~' + fmtSec(d.eta_sec);
else liveEta.textContent = '';
} else {
liveLlm.classList.remove('show');
if (currentLiveFile) liveFileEl.textContent = currentLiveFile;
@@ -587,19 +740,41 @@ async function uploadFiles() {
activeES.addEventListener('done', function(e) {
const d = JSON.parse(e.data);
const idx = sendIdx[d.idx];
clearInterval(fileIntervals[idx]);
if (!procExtractDone) {
// done на этапе извлечения = битый/пропущенный файл
const p0 = procState[idx];
if (p0) p0.st = 'skipped';
return;
}
// Время на КОНКРЕТНЫЙ файл приходит с бэка (extract_text + замена, без общего LLM)
const sec = (typeof d.elapsed === 'number' && d.elapsed > 0)
? d.elapsed.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.close();
activeES = null;
clearInterval(ptimer);
clearInterval(liveRefresh);
liveBlock.classList.remove('show');
finishProcUI();
const d = JSON.parse(e.data);
const totalSec = ((performance.now() - t0) / 1000).toFixed(1);
st.className = 'status done';
@@ -619,18 +794,12 @@ async function uploadFiles() {
resolve();
});
activeES.onerror = function() {
activeES.close();
activeES = null;
clearInterval(ptimer);
clearInterval(liveRefresh);
liveBlock.classList.remove('show');
finishProcUI();
reject(new Error('SSE connection failed'));
};
});
} catch (err) {
clearInterval(ptimer);
clearInterval(liveRefresh);
liveBlock.classList.remove('show');
finishProcUI();
st.className = 'status error';
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>
</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>
</html>