From db58a433fbfd9956d3dd96e6391c18f7c97c048a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E2=80=9CNaeel=E2=80=9D?= Date: Wed, 26 Aug 2026 08:07:43 +0300 Subject: [PATCH] =?UTF-8?q?=D1=8D=D1=82=D0=B0=D0=BF=202:=20=D0=BF=D0=B5?= =?UTF-8?q?=D1=80=D0=B5=D0=B8=D1=81=D0=BF=D0=BE=D0=BB=D1=8C=D0=B7=D1=83?= =?UTF-8?q?=D0=B5=D0=BC=D1=8B=D0=B9=20=D0=BC=D0=BE=D0=B4=D1=83=D0=BB=D1=8C?= =?UTF-8?q?=20upload=20(sink)=20=D0=B2=D0=BC=D0=B5=D1=81=D1=82=D0=BE=20?= =?UTF-8?q?=D1=80=D1=83=D0=BA=D0=BE=D0=BF=D0=B8=D1=81=D0=BD=D0=BE=D0=B3?= =?UTF-8?q?=D0=BE=20=D1=82=D1=80=D0=B0=D0=BD=D1=81=D0=BF=D0=BE=D1=80=D1=82?= =?UTF-8?q?=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - копирую модуль upload/ из drhider (слои 1-2) - blueprint: параметр sink (drhider-сессия по умолчанию, сверка — DB+парсинг) - upload_bp: contracts_upload_sink = _store_and_parse - routes: регистрирую create_upload_refs_blueprint(cfg, sink=...) - app.py: корень репо в sys.path (для import upload) - History: план переиспользования + ревью Соннета --- .../2026-08-26-upload-module-reuse-plan.md | 100 +++++ site/app.py | 4 + site/routes/__init__.py | 13 +- site/routes/upload_bp.py | 31 +- upload/README.md | 183 ++++++++++ upload/__init__.py | 8 + upload/backend/__init__.py | 1 + upload/backend/session/__init__.py | 38 ++ upload/backend/session/add_file.py | 25 ++ upload/backend/session/cancel.py | 27 ++ upload/backend/session/cleanup.py | 11 + upload/backend/session/create_session.py | 23 ++ upload/backend/session/get_files.py | 23 ++ upload/backend/session/state.py | 45 +++ upload/backend/session/store_csv.py | 30 ++ upload/backend/session/store_result.py | 30 ++ upload/backend/session/ttl.py | 34 ++ upload/backend/upload_refs/__init__.py | 20 + upload/backend/upload_refs/blueprint.py | 160 ++++++++ upload/backend/upload_refs/config.py | 11 + upload/backend/upload_refs/pull_file.py | 39 ++ upload/backend/upload_refs/safe_name.py | 16 + upload/config.example.json | 13 + upload/frontend/package.json | 6 + upload/frontend/table/add_file_with_dedup.js | 47 +++ upload/frontend/table/esc.js | 5 + upload/frontend/table/est_for_file.js | 8 + upload/frontend/table/fmt_sec.js | 6 + upload/frontend/table/fs.js | 7 + upload/frontend/table/init_upload_table.js | 56 +++ upload/frontend/table/on_files_change.js | 56 +++ upload/frontend/table/on_folder_change.js | 58 +++ upload/frontend/table/proc_row.js | 13 + upload/frontend/table/render.js | 29 ++ upload/frontend/table/render_proc_table.js | 74 ++++ upload/frontend/table/rm_file.js | 19 + upload/frontend/table/set_status.js | 6 + upload/frontend/test_upload_frontend.mjs | 344 ++++++++++++++++++ upload/frontend/test_upload_layer2.mjs | 199 ++++++++++ upload/frontend/upload/put_to_vm.js | 23 ++ upload/frontend/upload/upload_via_vm.js | 59 +++ upload/frontend/zip/decode_zip_name.js | 22 ++ upload/frontend/zip/dos_to_ms.js | 11 + upload/frontend/zip/inflate_raw.js | 8 + upload/frontend/zip/list_zip_files.js | 22 ++ upload/frontend/zip/parse_zip.js | 50 +++ 46 files changed, 1986 insertions(+), 27 deletions(-) create mode 100644 History/2026-08-26-upload-module-reuse-plan.md create mode 100644 upload/README.md create mode 100644 upload/__init__.py create mode 100644 upload/backend/__init__.py create mode 100644 upload/backend/session/__init__.py create mode 100644 upload/backend/session/add_file.py create mode 100644 upload/backend/session/cancel.py create mode 100644 upload/backend/session/cleanup.py create mode 100644 upload/backend/session/create_session.py create mode 100644 upload/backend/session/get_files.py create mode 100644 upload/backend/session/state.py create mode 100644 upload/backend/session/store_csv.py create mode 100644 upload/backend/session/store_result.py create mode 100644 upload/backend/session/ttl.py create mode 100644 upload/backend/upload_refs/__init__.py create mode 100644 upload/backend/upload_refs/blueprint.py create mode 100644 upload/backend/upload_refs/config.py create mode 100644 upload/backend/upload_refs/pull_file.py create mode 100644 upload/backend/upload_refs/safe_name.py create mode 100644 upload/config.example.json create mode 100644 upload/frontend/package.json create mode 100644 upload/frontend/table/add_file_with_dedup.js create mode 100644 upload/frontend/table/esc.js create mode 100644 upload/frontend/table/est_for_file.js create mode 100644 upload/frontend/table/fmt_sec.js create mode 100644 upload/frontend/table/fs.js create mode 100644 upload/frontend/table/init_upload_table.js create mode 100644 upload/frontend/table/on_files_change.js create mode 100644 upload/frontend/table/on_folder_change.js create mode 100644 upload/frontend/table/proc_row.js create mode 100644 upload/frontend/table/render.js create mode 100644 upload/frontend/table/render_proc_table.js create mode 100644 upload/frontend/table/rm_file.js create mode 100644 upload/frontend/table/set_status.js create mode 100644 upload/frontend/test_upload_frontend.mjs create mode 100644 upload/frontend/test_upload_layer2.mjs create mode 100644 upload/frontend/upload/put_to_vm.js create mode 100644 upload/frontend/upload/upload_via_vm.js create mode 100644 upload/frontend/zip/decode_zip_name.js create mode 100644 upload/frontend/zip/dos_to_ms.js create mode 100644 upload/frontend/zip/inflate_raw.js create mode 100644 upload/frontend/zip/list_zip_files.js create mode 100644 upload/frontend/zip/parse_zip.js diff --git a/History/2026-08-26-upload-module-reuse-plan.md b/History/2026-08-26-upload-module-reuse-plan.md new file mode 100644 index 0000000..48f06ac --- /dev/null +++ b/History/2026-08-26-upload-module-reuse-plan.md @@ -0,0 +1,100 @@ +# Переиспользование модуля загрузки drhider в contracts-flask + +Дата: 2026-08-26 + +## Контекст + +- Шлюз managed-кластера рвёт тела запросов >~64 КБ, egress не ограничен. +- Паттерн загрузки: браузер `PUT` файла на ВМ-буфер (WebDAV) → Flask `pull` (egress GET) → обработка. +- В drhider этот паттерн отлажен и вынесен в переиспользуемый модуль `upload/` + (слой 1 — выбор файлов/папок/архивов → таблица; слой 2 — закачка PUT→pull; слой 3 — логика приложения, НЕ в модуле). +- Задача: взять из drhider выбор+загрузку, сшить с логикой сверки contracts-flask, + **НЕ меняя саму логику сверки** (классификация/группы/сравнение). + +Текущее состояние contracts-flask: v2.0.11 (рабочая загрузка через ВМ, написана вручную в этой сессии). + +--- + +## Ревью Соннета (итог) + +Вердикт: **«с оговорками»** — одна критическая: слои 1+2 нельзя взять AS-IS для +фронт-части слоя 2. `uploadViaVM.js` шлёт `{session, files:[...]}`, а сверка ожидает +`{batch_id, contract_id, zip_source, files}`. Несовместимые форматы. + +### Ключевые находки Соннета + +1. **Привязка к in-memory сессии** (blueprint.py, 4 точки): `create_session()`, + `add_file(sid, name, content)`, `get_files(sid) is None`, `file_count(sid)`. + → заменить одним `sink(name, content, **ctx)`, где `ctx = {batch_id, contract_id, zip_source}`. + +2. **Граница «логика сверки» — НЕЛЬЗЯ трогать:** + | Файл | Функции | + |---|---| + | `pipeline_bp.py` | `process_v2`, `classify_batch_route` (SSE + classify) | + | `services/process.py` | `run_pipeline` | + | `services/classify.py` | `classify_batch`, `_call_llm_classify`, garbage filters | + | `services/grouping.py` | `group_documents`, `apply_groups`, `normalize_number` | + | `db/documents.py` | ВСЕ (контракт данных) | + | `db/supplements.py` | ВСЕ | + +3. **Риски:** + - клиентский ZIP — полная распаковка в память браузера (у сверки PDF ~19 МБ); + - `allowedExt` разный: drhider `[.pdf,.doc,.docx,.txt,.md]` vs сверка `{pdf,docx,doc,zip}`; + - `parse.py` уже пытается парсить `.doc` напрямую (`elif ext=="doc": _parse_docx(data)`) — sink должен перехватывать `.doc` ДО `parse_file`; + - дедуп: name+size (слой 1) vs content-hash (sink) — не ошибка, двойная защита; + - `session` (drhider) vs `batch_id` (сверка, `crypto.randomUUID()` в state.js); + - `zip_source` — поле documents, одно на вызов (для UI-группировки). + +4. **Безопасный порядок (Соннет):** расширить blueprint (sink) → extra-поля → contracts_sink → backend → frontend layer1 → frontend layer2 → удалить `/api/unzip_refs`,`/api/convert_refs`. + +### Противоречия в плане (Соннет) + +- «Слои 1+2 КАК ЕСТЬ» неверно для фронт-части слоя 2 (`{session}` vs `{batch_id,...}`). +- `.doc` в sink неполно описан (порядок «конвертация ДО parse»). +- `allowedExt` при интеграции не упомянут. + +--- + +## Моё решение (3 этапа) + +| Этап | Что | Риск | +|---|---|---| +| **1. Транспорт** | скопировать `upload/` в contracts-flask; заменить рукописные `_safe_name` + `_pull_with_retries` на модульные | низкий | +| **2. Sink** | `create_upload_refs_blueprint(cfg, sink=...)`; sink = `_store_and_parse` + перехват `.doc` → внешний LibreOffice → `parse_file` | средний | +| **3. UI** | `initUploadTable` (файлы+папки+архивы) | высокий, последним | + +### Принятые решения + +- **ZIP → серверный** (оставить `/api/unzip_refs`): у сверки крупные PDF, клиентская распаковка = регресс по памяти (осознанное отступление от «как в хайдере»). +- **`.doc` → конвертация в sink** через внешний LibreOffice-сервис (`CONVERT_SERVICE_URL`), последовательно (один тяжёлый `.doc` блокирует пакет; параллелить потом). +- **UI → последним, изолированно** от «загрузка не работает». +- **Реализация:** я (спецификация + sink), субагент-младшая модель (механика этапа 1), я ревьюю дифф. + +### Что НЕ переносить из drhider + +- in-memory сессию (сверка хранит в SQLite `documents` по `batch_id`/`doc_id`); +- клиентскую распаковку ZIP; +- обфускацию/слой 3 drhider. + +--- + +## Находка при чтении транспортных файлов модуля (2026-08-26) + +Модуль — это **целостный Blueprint**, а не набор drop-in функций: + +- `safe_name(name)` **сохраняет подпапки** и возвращает `""` при небезопасном имени. + У сверки `_safe_name` — **basename-only** (rsplit) и возвращает `"file.bin"`. НЕ 1:1. +- `pull_file(client, url, ...)` требует `httpx.Client` + стриминг (`client.stream`). + У сверки `_pull_with_retries(url)` — `httpx.get` внутри. Разные сигнатуры. +- `blueprint.py` жёстко завязан на сессию: `create_session`, `add_file`, `get_files`, + `file_count`. + +**Следствие:** «этап 1: заменить 2 функции 1:1» НЕ выполним. Переиспользование идёт +на уровне **blueprint через sink** (этап 2), с адаптацией формы запроса/ответа. + +## Статус + +- [x] Скопировать `upload/` в contracts-flask +- [ ] Этап 2 — sink (доработка blueprint + contracts-sink + регистрация) +- [ ] Удалить рукописный транспорт (после этапа 2) +- [ ] Этап 3 — UI diff --git a/site/app.py b/site/app.py index c5a4ce8..f9cfb4d 100644 --- a/site/app.py +++ b/site/app.py @@ -4,6 +4,10 @@ import sys, os # site/ в sys.path — импортируем модули напрямую, без префиксов sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +# корень репо — для переиспользуемого модуля upload/ (как в drhider) +_REPO_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +if _REPO_ROOT not in sys.path: + sys.path.insert(0, _REPO_ROOT) from flask import Flask from config import VERSION, MAX_CONTENT_LENGTH diff --git a/site/routes/__init__.py b/site/routes/__init__.py index 33ab456..dd32119 100644 --- a/site/routes/__init__.py +++ b/site/routes/__init__.py @@ -2,12 +2,14 @@ def register_routes(app): - from routes.upload_bp import upload_bp + import config + from routes.upload_bp import upload_bp, contracts_upload_sink from routes.pipeline_bp import pipeline_bp from routes.api_bp import api_bp from routes.prompts_bp import prompts_bp from routes.health_bp import health_bp from routes.pages_bp import pages_bp + from upload.backend.upload_refs import create_upload_refs_blueprint app.register_blueprint(upload_bp) app.register_blueprint(pipeline_bp) @@ -15,3 +17,12 @@ def register_routes(app): app.register_blueprint(prompts_bp) app.register_blueprint(health_bp) app.register_blueprint(pages_bp) + + # Переиспользуемый слой закачки через ВМ (модуль upload, паттерн drhider) + app.register_blueprint(create_upload_refs_blueprint({ + "apiPrefix": "/api", + "vmUploadPrefix": config.VM_UPLOAD_PREFIX, + "maxFileBytes": config.VM_UPLOAD_MAX_BYTES, + "pullRetries": config.PULL_RETRIES, + "pullRetryDelay": config.PULL_RETRY_DELAY, + }, sink=contracts_upload_sink)) diff --git a/site/routes/upload_bp.py b/site/routes/upload_bp.py index e40abfc..d84f6b8 100644 --- a/site/routes/upload_bp.py +++ b/site/routes/upload_bp.py @@ -179,34 +179,13 @@ def upload(): return jsonify(ok=True, doc_id=result["doc_id"], contract_id=result["contract_id"], parsed=result["parsed"]) -@upload_bp.route("/api/upload_refs", methods=["POST"]) -def upload_refs(): - """Загрузка через ВМ-буфер (паттерн drhider): бэк тянет файлы с ВМ. +def contracts_upload_sink(name, content, batch_id=None, contract_id=None, zip_source=None): + """Sink для переиспользуемого модуля upload: вставить файл в documents + авто-парсинг. - Тело (маленькое, <64КБ): {batch_id, contract_id, zip_source, files:[{name,size,url}]}. - Для каждой ссылки: SSRF-проверка → лимит → pull с ретраями → store+parse → DELETE с ВМ. + Вызывается модулем create_upload_refs_blueprint(cfg, sink=...) на каждый + вытянутый с ВМ файл. Возвращает dict с ключами ok/doc_id/contract_id/parsed. """ - data = request.get_json(silent=True) or {} - files = data.get("files") or [] - batch_id = data.get("batch_id") - contract_id = data.get("contract_id") - zip_source = data.get("zip_source") - - if not files: - return jsonify(ok=False, error="no files"), 400 - - results = [] - for ref in files: - ref_name = _safe_name(str(ref.get("name", "") or "")) - try: - name, content = _pull_from_ref(ref) - except Exception as e: - results.append({"name": ref_name, "ok": False, "error": str(e)}) - continue - stored = _store_and_parse(name, content, batch_id, contract_id, zip_source) - results.append({"name": name, **stored}) - - return jsonify(ok=True, results=results) + return _store_and_parse(name, content, batch_id, contract_id, zip_source) @upload_bp.route("/convert-doc", methods=["POST"]) diff --git a/upload/README.md b/upload/README.md new file mode 100644 index 0000000..f0826d5 --- /dev/null +++ b/upload/README.md @@ -0,0 +1,183 @@ +# upload — переиспользуемые слои загрузки через ВМ + +Два самодостаточных слоя для выноса в любой другой проект БЕЗ изменения кода +(меняется только конфиг). Поведение 1:1 с drhider v0.0.75. + +``` +upload/ + frontend/ + zip/ # распаковка ZIP (чистые функции) + table/ # слой 1: выбор файлов/папки/архива, дедуп, статусы, таблица + upload/ # слой 2 (фронт): PUT на ВМ + POST /api/upload_refs + backend/ + upload_refs/ # слой 2 (бэк): Blueprint upload_refs (SSRF, _safe_name, ретраи) + session/ # in-memory сессия с TTL и лимитами + config.example.json +``` + +## Что это + +| Слой | Где | Ответственность | +|---|---|---| +| **1. Выбор файлов** | фронт | таблица, дедуп, раскрытие ZIP, путь, статусы, кнопки | +| **2. Закачка через ВМ** | фронт + бэк | PUT на ВМ-буфер (фронт) → `upload_refs` pull (бэк) → сессия | +| **3. Логика приложения** | — | у каждого приложения своя (обфускация, SSE и т.п.). В модуле её НЕТ | + +Паттерн (зачем ВМ): шлюз managed-кластера рвёт тела >64КБ, egress не ограничен. +Поэтому: браузер → `PUT` на ВМ-буфер → Flask `POST /api/upload_refs` → egress `GET` → сессия. + +--- + +## Подключение фронта + +Подключить ES-модули (`