Files
drhider/History/plans/2026-08-24-implementation-plan-for-flash.md
T

23 KiB
Raw Blame History

План реализации для Флаша — 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); при Emptyheartbeat event: llm с data: {active, elapsed, tokens}; при progressevent: <phase>; при resultstore_result+store_csv+event: complete; при errorevent: 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)

Текущее

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 — новые параметры

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_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_csvyield 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 — вне скоупа этой задачи.