23 KiB
План реализации для Флаша — 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):
files = extractor.expand_zips(files);files = _dedupe_file_names(files).- Проход 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(...).
- Цикл по файлам: извлечь текст
- Проход 2 (замена): цикл по файлам, для каждого
replacer.apply_replacements(...)→results.append((fname, obf_content));progress_cb("done", i, total, display_name, round(file_times[i], 2)). - Сборка:
csv_str = builder.build_mapping_csv(self._mapping);zip_data = builder.build_zip(results, csv_str); return. 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— heartbeatevent: 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. Цель (фича)
- TTL-фикс: сессия не должна умирать во время долгой обработки (сейчас 30 мин → долгий прогон теряет результат).
- Трекинг файлов/чанков: знать, какой файл сейчас обрабатывается, сколько прошло/осталось.
- Прерывание с сохранением: кнопка «Прервать» → мягкая остановка → сохранить в ZIP+CSV всё, что успело полностью обработаться.
- Оценка времени: глобальная (весь пакет) + per-file (текущий файл).
- UI: таблица из 3 секций (готово / текущий / ожидают), кнопка «Прервать», диалог-подтверждение.
3. Блок A — TTL-фикс (site/session.py)
Текущее
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.
Изменения
- Добавить
def touch(sid): отменить старый таймер (s["timer"].cancel()) и запустить новый (перезаписатьs["timer"]).touchпод_lock, безопасна при отсутствии сессии. - Добавить
def pause_ttl(sid):s["timer"].cancel()(без перезапуска) — «сессия живёт пока идёт обработка». - Добавить
def resume_ttl(sid): запустить_start_timer(sid)заново. - В
create_sessionдобавить в словарь"cancel": threading.Event(). - Добавить
def request_cancel(sid) -> bool: если сессия есть —s["cancel"].set(), вернуть True; иначе False. - Добавить
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 — новые параметры
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 (или отдельно) определить:
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 Новый эндпоинт
@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_progresscallback: класть события в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) и класть в heartbeateta_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}.
Порядок строк сверху вниз:
- done — уже обработанные; в столбце «Статус» фактическое время (
✓ 3.2с). - current — подсвечен (класс
row-current); в столбце «Статус»прошло 0:42 / ~1:20(тикает черезsetInterval). - 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)
- В
llmheartbeat — обновлять: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. Замечания и ловушки (из код-ревью)
done-фаза занята: сейчасprogress_cb("done")шлётся и для битых (в проходе 1), и для готовых (в проходе 2). Не путать с готовностью файла в ZIP. Готовность =doneв проходе 2. Для UI-«готово» ориентироваться на событияdone, приходящие ПОСЛЕ всехstart(т.е. в фазе замены).- Частичные результаты не терять:
results— локальная переменная; НЕ полагаться наfinally. Возвращать(zip, csv, meta)изobfuscate(см. 4.3). mapping.csvне в ZIP: частичный результат = частичный ZIP (только .md) + полный CSV отдельно (через/api/csv). Полный CSV собирается изself._mapping(он полон на момент отмены в фазе замены).- LLM-фаза сейчас без прогресса:
scan_llm_ner— один монолитный вызов. Именно поэтому добавляетсяfile_progress. Без него «текущий файл» в UI не определить. - Имена в событиях
file_*: на бэке имена файлов — уже с.md/суффиксами после dedup; фронт сопоставляет поidx(надёжнее, чем по имени). Рекомендация: передаватьidx(0-based вfiles) в событияfile_start/file_done/file_chunk. obfuscate_files-обёртка: тоже меняет сигнатуру (cancel_event,file_progress) и возврат(zip, csv, meta). Обновить ОБА вызова:process_streamи legacyprocess().- Отключение SSE: при закрытии вкладки
cancelв генераторе уже ставится, но воркер его не видит. Подключить тот жеcancel_event(передать вobfuscate_files), чтобы закрытие вкладки реально останавливало LLM (не жечь токены). _LLM_CONCURRENCY = 1— последовательно. Не вводить параллельность (liberta/LLM не тянут).
9. Порядок реализации (коммитить по шагам)
- TTL-фикс (
session.py+api_bp.py):touch/pause_ttl/resume_ttl,cancelEvent,request_cancel/get_cancel_event,POST /api/cancel/<sid>. - Трекинг (
scanner.py,obfuscator.py):cancel_event+file_progress,CancelRequested, новый контракт возврата(zip, csv, meta). - Прерывание (
obfuscator.py,api_bp.py): частичный результат, событиеcancelled. - ETA (
api_bp.py, heartbeateta_sec/done_chars/total_chars,file_chunketa). - UI (
index.html): 3-секционная таблица, кнопка «Прервать», диалог, live-обновления. - Бамп версии 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 — вне скоупа этой задачи.