diff --git a/History/2026-07-13-sonnet-query-architecture.md b/History/2026-07-13-sonnet-query-architecture.md new file mode 100644 index 0000000..0398b40 --- /dev/null +++ b/History/2026-07-13-sonnet-query-architecture.md @@ -0,0 +1,39 @@ +# Запрос к Соннету — архитектура пофайловой обработки с одним ZIP (2026-07-13) + +## Задача + +DrHider (Flask + waitress). Ограничения: +- Фронтенд должен слать файлы **по одному** (сеть не пропускает большие multipart) +- Бэкенд должен обрабатывать **по одному** (без параллельных LLM-запросов) +- Результат: **один ZIP** со всеми обфусцированными .md + **один общий** mapping.csv +- **Никакой обработки данных в JS** — фронтенд только кнопки и скачивание + +## Варианты + +### А. Upload → Process +1. `POST /api/upload` — один файл → `{session:"abc"}` +2. Фронтенд циклом шлёт все, получает session +3. `POST /api/process/abc` — бэкенд обрабатывает всё → ZIP +4. Фронтенд скачивает ZIP + +### Б. Один запрос, все файлы +1. Фронтенд: один POST, все файлы в FormData +2. Бэкенд: получил все → обработал по одному → ZIP +3. Waitress держит долгое соединение, таймаут 600с + +### В. SSE с прогрессом +1. `POST /api/start` → `{session:"abc"}` +2. `POST /api/upload/abc` — по одному файлу +3. `GET /api/process/abc` — SSE стримит прогресс каждого файла и в конце ZIP + +## Вопросы + +1. Какой вариант правильный для Flask + waitress? +2. Вариант Б — пройдёт ли большой multipart через ingress (6 файлов × 30-60 KB)? +3. Если А — как чистить сессии? (пользователь закрыл вкладку, файлы остались в памяти) +4. Нужен ли SSE или достаточно простого XHR с таймером? +5. mapping.csv — генерировать на лету (по мере обработки) или после всех файлов? + +## Сейчас работает (но сломано) + +Сейчас: фронтенд шлёт по одному файлу, каждый получает отдельный ZIP. Пользователь хочет один ZIP в конце. JS обрабатывал ZIP'ы (распаковывал/перепаковывал) — это убрали, всё должно быть на бэкенде. diff --git a/History/2026-07-13-sonnet-response-architecture.md b/History/2026-07-13-sonnet-response-architecture.md new file mode 100644 index 0000000..0583a2d --- /dev/null +++ b/History/2026-07-13-sonnet-response-architecture.md @@ -0,0 +1,17 @@ +# Ответ Соннета — архитектура upload+process (2026-07-13) + +## Рекомендация: Вариант А + блокирующий /process + +``` +POST /api/upload → {session_id} (фронт шлёт по одному, N раз) +POST /api/process/{sid} → {status:"done"} (блокирует до конца обработки) +GET /api/download/{sid} → ZIP +``` + +Без polling — `/process` просто блокирует XHR до готовности. Waitress держит. + +## Детали +- Mapping.csv — на лету, аккумулируется после каждого файла +- Сессии — dict в памяти, TTL 30 минут через threading.Timer +- Фронт: цикл upload → process → download. Три простых XHR. +- Никакого JS-кода обработки данных. diff --git a/site/app.py b/site/app.py index e3973ca..2133c32 100644 --- a/site/app.py +++ b/site/app.py @@ -20,7 +20,7 @@ if _sys_path_root not in sys.path: sys.path.insert(0, _sys_path_root) # Версия приложения (меняется при изменениях) -VERSION = "6.0.0" +VERSION = "7.0.0" def create_app(): diff --git a/site/routes/api_bp.py b/site/routes/api_bp.py index e0563fa..a32b301 100644 --- a/site/routes/api_bp.py +++ b/site/routes/api_bp.py @@ -1,73 +1,83 @@ """ -Blueprint: API обфускации (POST /api/drhider). +Blueprint: API DrHider. -Принимает multipart/form-data с файлами, возвращает ZIP-архив -с обфусцированными документами. +Three endpoints: +- POST /api/upload — upload one file -> {session_id} +- POST /api/process/{sid} — process all session files -> {status:"done"} +- GET /api/download/{sid} — download ZIP """ import io +import zipfile import traceback from flask import Blueprint, request, send_file, jsonify from drhider import obfuscate_files, LLMClient - -# ═══════════════════════════════════════════════════════════════════════════ -# Blueprint: API DrHider -# ═══════════════════════════════════════════════════════════════════════════ +from drhider.builder import build_zip, build_mapping_csv +from session import create_session, add_file, get_files, store_result, get_result, cleanup, file_count api_bp = Blueprint("api", __name__, url_prefix="/api") -# Максимальный размер тела запроса: 200 MB -MAX_BODY = 200 * 1024 * 1024 - -@api_bp.route("/drhider", methods=["POST"]) -def drhider(): - """Обфускация документов. - - Принимает multipart/form-data с полем 'files' (один или несколько файлов). - Возвращает ZIP-архив с обфусцированными документами + mapping.csv. - - Поддерживаемые форматы: - .docx, .pdf, .txt, .doc (бинарный — без изменений) - .zip (распаковывается, содержимое обфусцируется) - - Returns: - 200: ZIP-архив (application/zip) - 400: {"ok": false, "error": "..."} - 500: {"ok": false, "error": "..."} - """ - # ── Получаем файлы из запроса ── +@api_bp.route("/upload", methods=["POST"]) +def upload(): + """Upload one file to session.""" + sid = request.form.get("session", "") + if not sid: + sid = create_session() uploaded = request.files.getlist("files") if not uploaded: - return jsonify({"ok": False, "error": "Нет файлов"}), 400 + return jsonify({"ok": False, "error": "No file"}), 400 + f = uploaded[0] + if not f.filename: + return jsonify({"ok": False, "error": "No filename"}), 400 + ok = add_file(sid, f.filename, f.read()) + if not ok: + return jsonify({"ok": False, "error": "Session not found"}), 404 + return jsonify({"ok": True, "session": sid, "count": file_count(sid)}) - # Формируем список для obfuscate_files: [(name, bytes, mimetype), ...] - files = [] - for f in uploaded: - if f.filename: - files.append((f.filename, f.read(), f.mimetype or "")) +@api_bp.route("/process/", methods=["POST"]) +def process(sid): + """Process all session files one by one -> ZIP.""" + files = get_files(sid) + if files is None: + return jsonify({"ok": False, "error": "Session not found"}), 404 if not files: - return jsonify({"ok": False, "error": "Нет файлов"}), 400 - - # ── Обфускация ── + return jsonify({"ok": False, "error": "No files"}), 400 try: - # Создаём LLM-клиент для NER-сканирования llm = LLMClient() - - # Вызываем ядро обфускации - zip_data, _csv = obfuscate_files(files, llm_client=llm) - - # Отдаём ZIP-архив - return send_file( - io.BytesIO(zip_data), - mimetype="application/zip", - as_attachment=True, - download_name="drhider_output.zip", - ) - + all_results = [] + all_mapping = {} + for fname, content in files: + zip_data, _csv = obfuscate_files([(fname, content, "")], llm_client=llm) + with zipfile.ZipFile(io.BytesIO(zip_data)) as zf: + for name in zf.namelist(): + if name == "mapping.csv": + csv_text = zf.read(name).decode("utf-8") + lines = csv_text.strip().split("\n") + if len(lines) > 1: + for line in lines[1:]: + parts = line.split(",", 2) + if len(parts) >= 3: + all_mapping[parts[1]] = parts[2] + elif not name.endswith("/"): + all_results.append((name, zf.read(name))) + csv_str = build_mapping_csv(all_mapping) if all_mapping else "" + final_zip = build_zip(all_results, mapping_csv=csv_str) + store_result(sid, final_zip) + return jsonify({"ok": True, "status": "done", "files": len(all_results)}) except Exception as e: - # Логируем полный трейсбек на сервере traceback.print_exc() return jsonify({"ok": False, "error": str(e)}), 500 + + +@api_bp.route("/download/", methods=["GET"]) +def download(sid): + """Download result and cleanup session.""" + zip_data = get_result(sid) + if zip_data is None: + return jsonify({"ok": False, "error": "Not found"}), 404 + cleanup(sid) + return send_file(io.BytesIO(zip_data), mimetype="application/zip", + as_attachment=True, download_name="drhider_output.zip") diff --git a/site/session.py b/site/session.py new file mode 100644 index 0000000..4b0b7e5 --- /dev/null +++ b/site/session.py @@ -0,0 +1,138 @@ +""" +Хранилище сессий в памяти с автоочисткой по TTL. + +Использование: + sid = create_session() + add_file(sid, filename, content_bytes) + files = get_files(sid) + store_result(sid, zip_bytes) + zip_data = get_result(sid) + cleanup(sid) +""" + +import uuid +import threading +import time +from typing import Dict, List, Tuple, Optional + +# ═══════════════════════════════════════════════════════════════════════════ +# Данные сессии +# ═══════════════════════════════════════════════════════════════════════════ + +TTL_SECONDS = 30 * 60 # 30 минут + +_sessions: Dict[str, dict] = {} +_lock = threading.Lock() + + +def _start_timer(sid: str): + """Запустить таймер автоочистки сессии через TTL.""" + + def _clean(): + with _lock: + _sessions.pop(sid, None) + + timer = threading.Timer(TTL_SECONDS, _clean) + timer.daemon = True + timer.start() + return timer + + +# ═══════════════════════════════════════════════════════════════════════════ +# API +# ═══════════════════════════════════════════════════════════════════════════ + +def create_session() -> str: + """Создать новую сессию. + + Returns: + Уникальный идентификатор сессии (UUID). + """ + sid = uuid.uuid4().hex[:12] + with _lock: + _sessions[sid] = { + "files": [], + "result": None, + "timer": _start_timer(sid), + } + return sid + + +def add_file(sid: str, filename: str, content: bytes) -> bool: + """Добавить файл в сессию. + + Args: + sid: Идентификатор сессии + filename: Имя файла + content: Бинарное содержимое + + Returns: + True если сессия существует, False если нет. + """ + with _lock: + s = _sessions.get(sid) + if not s: + return False + s["files"].append((filename, content)) + return True + + +def get_files(sid: str) -> Optional[List[Tuple[str, bytes]]]: + """Получить все файлы сессии. + + Args: + sid: Идентификатор сессии + + Returns: + [(filename, content), ...] или None если сессия не найдена. + """ + with _lock: + s = _sessions.get(sid) + return list(s["files"]) if s else None + + +def file_count(sid: str) -> int: + """Количество файлов в сессии.""" + with _lock: + s = _sessions.get(sid) + return len(s["files"]) if s else 0 + + +def store_result(sid: str, zip_data: bytes) -> bool: + """Сохранить результат обработки. + + Args: + sid: Идентификатор сессии + zip_data: ZIP-архив с результатом + + Returns: + True если сессия существует, False если нет. + """ + with _lock: + s = _sessions.get(sid) + if not s: + return False + s["result"] = zip_data + return True + + +def get_result(sid: str) -> Optional[bytes]: + """Получить результат обработки. + + Args: + sid: Идентификатор сессии + + Returns: + ZIP-архив или None если сессия не найдена/результат не готов. + """ + with _lock: + s = _sessions.get(sid) + return s["result"] if s else None + + +def cleanup(sid: str): + """Удалить сессию.""" + with _lock: + s = _sessions.pop(sid, None) + if s and s.get("timer"): + s["timer"].cancel() diff --git a/site/templates/index.html b/site/templates/index.html index 45b5858..5907d49 100644 --- a/site/templates/index.html +++ b/site/templates/index.html @@ -155,51 +155,62 @@ async function uploadFiles() { if (sf.length === 0) return; ub.disabled = true; const total = sf.length; - let ok = 0, fail = 0; + let sid = ''; + // Фаза 1: загрузка всех файлов for (let i = 0; i < total; i++) { const f = sf[i], n = i + 1; - const t0 = performance.now(); - const timer = setInterval(() => { - const sec = Math.round((performance.now() - t0) / 1000); - ss(i, '⏳ ' + sec + 'с'); - }, 200); + ss(i, '⏳ загрузка'); st.className = 'status progress'; - st.textContent = 'Файл ' + n + '/' + total + ': ' + f.name; + st.textContent = 'Загрузка ' + n + '/' + total + ': ' + f.name; const fd = new FormData(); fd.append('files', f, f.name); + if (sid) fd.append('session', sid); try { - const blob = await new Promise((resolve, reject) => { - const x = new XMLHttpRequest(); - x.open('POST', '/api/drhider'); - x.responseType = 'blob'; - x.timeout = 600000; - x.onload = () => x.status === 200 ? resolve(x.response) : reject(new Error('Сервер ' + x.status)); - x.onerror = () => reject(new Error('Сеть')); - x.ontimeout = () => reject(new Error('Таймаут')); - x.send(fd); - }); - clearInterval(timer); - const el = ((performance.now() - t0) / 1000).toFixed(1); - // Скачиваем ZIP как есть — бэкенд всё сделал - const u = URL.createObjectURL(blob); - const a = document.createElement('a'); - a.href = u; - a.download = f.name.replace(/\.[^.]+$/, '') + '_obfuscated.zip'; - a.click(); - URL.revokeObjectURL(u); - ss(i, '✓ ' + el + 'с'); - ok++; + const resp = await fetch('/api/upload', { method: 'POST', body: fd }); + const data = await resp.json(); + if (!data.ok) throw new Error(data.error); + sid = data.session; + ss(i, '✓ загружен'); } catch (err) { - clearInterval(timer); ss(i, ''); - fail++; + st.className = 'status error'; + st.textContent = 'Ошибка загрузки: ' + err.message; + ub.disabled = false; + return; } - if (i < total - 1) await new Promise(r => setTimeout(r, 300)); } - st.className = fail === 0 ? 'status done' : 'status error'; - st.textContent = fail === 0 ? '✅ Готово! ' + ok + '/' + total : 'Готово: ' + ok + ' OK, ' + fail + ' ошибок из ' + total; + // Фаза 2: обработка (один запрос, ждём) + for (let i = 0; i < total; i++) ss(i, ''); + st.className = 'status progress'; + st.textContent = 'Обработка...'; + const t0 = performance.now(); + const ptimer = setInterval(() => { + const sec = Math.round((performance.now() - t0) / 1000); + st.textContent = 'Обработка... ' + sec + 'с'; + }, 1000); + + try { + const resp = await fetch('/api/process/' + sid, { method: 'POST' }); + const data = await resp.json(); + clearInterval(ptimer); + if (!data.ok) throw new Error(data.error); + st.className = 'status done'; + st.textContent = '✅ Обработано ' + data.files + ' файлов за ' + ((performance.now() - t0) / 1000).toFixed(1) + 'с'; + } catch (err) { + clearInterval(ptimer); + st.className = 'status error'; + st.textContent = 'Ошибка: ' + err.message; + ub.disabled = false; + return; + } + + // Фаза 3: скачивание + const a = document.createElement('a'); + a.href = '/api/download/' + sid; + a.download = 'drhider_output.zip'; + a.click(); ub.disabled = false; }