# Архитектура contracts-flask — срез 2026-07-14 ## Общая схема ``` Браузер (index.html) │ │ JS-модули (6 файлов, грузятся как статика Flask) │ XHR POST /upload, /convert-doc, /unzip-upload │ EventSource GET /process-v2 (SSE) │ fetch /api/* (классификация, группы, документы, промпты) ▼ Flask (site/app.py) — ~90 строк, ТОЛЬКО ПРОКСИ │ GET / → render_template("index.html") │ POST /chat → прокси на ВМ │ GET /api/prompts → прокси на ВМ │ Весь HTML/JS/CSS → статика /static/* ▼ ВМ (deploy/convert_server.py) — ВСЯ бизнес-логика │ POST /upload → compare/upload.py │ POST /convert-doc → convert_doc.py (libreoffice) │ POST /unzip-upload → compare/unzip.py │ GET /process-v2 → compare/process.py (SSE) │ POST /api/classify-batch → classify_worker.py │ GET /api/groups → db/groups │ GET /api/documents/:id → db/documents │ GET /api/supplements → db/supplements │ POST /api/sync → db/sync │ POST /api/apply-groups → db/supplements │ GET /api/spec-current → db/spec_current │ GET /api/batch-progress → db/classify-progress │ GET /api/prompts → db/prompts │ GET /api/prompts/list → db/prompts │ POST /api/prompts/save → db/prompts │ POST /api/prompts/activate → db/prompts │ POST /api/cleanup → db/cleanup ▼ PostgreSQL (таблицы: documents, supplements, spec_current, prompts, contracts) ``` ## JS-фронтенд — 6 модулей Все лежат в `contracts-flask/deploy/`, грузятся в HTML в этом порядке: ```html ``` ### 1. `state.js` — Центральное состояние ```javascript var state = { files: [], // [{ name, size, lastModified, doc_id, uploaded, parsed, parseInfo, supp_id, supp_type, status, zip_source }] contractId: null, // ID контракта (приходит с бэкенда после первого upload) batchId: crypto.randomUUID(), // Уникальный ID сессии groups: null, // [{ contract_number, counterparty, documents[], compare }] _activeCompare: { es: null, timer: null }, // Одно активное SSE-сравнение ui: { steps: { upload: '○', classify: '○', groups: '○', compare: '○' } } }; ``` **ПАТТЕРН**: `action → mutate state → render(state)`. DOM — проекция state, никогда не читаем из DOM. ### 2. `app_utils.js` — Утилиты | Функция | Назначение | |---------|-----------| | `formatSize(bytes)` | Байты → "1.5 MB" | | `formatDate(ts)` | Timestamp → российская дата+время | | `escHtml(s)` | Экранирование HTML | | `removeFile(i)` | Удалить файл из state.files → render → syncDB | | `moveUp(i)` / `moveDown(i)` | Переместить файл (не используется) | | `openAbout()` / `closeAbout()` | Модальное окно "О сервисе" | ### 3. `files.js` — Загрузка и таблица файлов | Функция | Назначение | |---------|-----------| | `statusToHTML(st)` | Чистая: структура → HTML-статус | | `renderFiles(state)` | Рендер таблицы файлов (группировка по zip_source) | | `syncDB()` | Синхронизация БД с текущим составом | | `toggleClassifyDetail(i)` | Раскрыть результат классификации | | `uploadFile(file, onProgress, zipSource)` | **XHR-загрузка** одного файла | | `refreshSupps()` | Обновить supp_id/supp_type | | `reconcileSelection(files, newFiles)` | Чистая: согласование состава | | `applyParseResult(entry, parsed, elapsed)` | Чистая: применить результат парсинга | | `addZipFile(file)` | Обработка ZIP (unzip → for each → addRegularFile) | | `addRegularFile(file, zipSource)` | Обработка обычного файла (upload → parse → status) | | `finalizeUpload()` | Завершение цикла загрузки | | `onFilesSelected(newFiles)` | **Оркестратор** загрузки | **Критичные детали `uploadFile`**: - `.doc` → конвертация в `.docx` через `CONVERT_URL` перед загрузкой - XHR с `timeout = 180000` (3 минуты для больших PDF) - `onProgress(pct)` — callback с процентом - Дедупликация по ключу `(zip_source, name)` - При совпадении имён: `confirm()` — перезаписать/пропустить ### 4. `groups.js` — Карточки групп | Функция | Назначение | |---------|-----------| | `loadGroupsAction()` | fetch `/api/groups` → state.groups → renderGroups | | `renderGroups(state)` | Пересобрать ВСЕ карточки в #diffBody | | `renderGroupCard(g, gi)` | HTML карточки необработанной группы | | `renderGroupCardDone(g, gi)` | HTML карточки обработанной группы | | `renderUnresolvedCard(g)` | HTML карточки нераспознанных | ### 5. `compare.js` — SSE-сравнение | Функция | Назначение | |---------|-----------| | `applyCompareEvent(sections, event)` | Чистая: SSE-событие → мутация sections | | `renderCompareSectionHeader(sec)` | Чистая: заголовок секции | | `renderCompareOpsTable(ops)` | Чистая: таблица ADD/UPDATE/DELETE | | `renderCompareSectionBody(sec)` | Чистая: тело секции | | `startCompareSSE(url, container, statusEl, callbacks)` | **Унифицированный** жизненный цикл SSE | **Критичные детали SSE**: - `EventSource` → события: `extract_start`, `llm_done`, `applied`, `extract_error`, `apply_error` - Только ОДНО сравнение одновременно (`state._activeCompare`) - Все кнопки блокируются на время сравнения - Таймер обновляет statusEl каждые 200мс ### 6. `app.js` — Оркестратор | Функция | Назначение | |---------|-----------| | `render(state)` | Главная функция рендеринга → `renderFiles` + `renderStepper` | | `renderStepper(state)` | Рендер степпера из `state.ui.steps` | | `stepDone(id)` / `stepActive(id)` | Мутация шагов | | `resetStepper(fromId)` | Сброс шагов | | `showClassifyBtn()` | Создать/показать кнопку классификации | | `runClassify()` | Классификация (sync/async + poll каждые 2с) | | `runCompareForGroup(gi, btn)` | Сравнение группы (apply-groups → SSE) | | `llmBtn` handler | Общее сравнение через SSE | ## HTML-структура (site/templates/index.html) ```html
...
``` ## Flask (site/app.py) ```python # Тонкий прокси, ~90 строк @app.route("/") → render_template("index.html") @app.route("/chat") → прокси на ВМ (LLM API) @app.route("/api/prompts") → прокси на ВМ @app.route("/api/prompts/list") → прокси на ВМ @app.route("/api/prompts/save") → прокси на ВМ @app.route("/api/prompts/activate") → прокси на ВМ @app.route("/architect") → render_template("architect.html") VM_API = "https://contracts.kube5s.ru" LLM_URL = "https://api.aillm.ru/v1/chat/completions" ``` ## ВМ (deploy/convert_server.py) Python HTTP-сервер (не Flask!) на `http.server` + `ThreadingMixIn`. Все эндпоинты перечислены в общей схеме выше. **Критичные модули ВМ**: - `deploy/db/` — PostgreSQL (connection.py, documents.py, supplements.py, spec_current.py, prompts.py) - `deploy/compare/` — upload.py, unzip.py, process.py (SSE) - `deploy/convert_doc.py` — libreoffice .doc → .docx (stdin → stdout) - `deploy/classify_worker.py` — асинхронная классификация - `deploy/llm_prompt.py` — построение промптов ## Pipeline (степпер) ``` ○ Загрузка ──→ ⏳ Загрузка ──→ ✓ Загрузка └─→ ⏳ Классификация ──→ ✓ Классификация └─→ ⏳ Группировка ──→ ✓ Группировка └─→ ⏳ Сравнение ──→ ✓ Сравнение ``` Сброс: при добавлении новых файлов → `resetStepper('stepUpload')` сбрасывает всё начиная с загрузки. ## Что нужно перенести с ВМ на Flask Все API-эндпоинты, которые сейчас на `deploy/convert_server.py`, должны стать Flask-роутами в `site/app.py`: | Эндпоинт | Метод | Назначение | |----------|-------|-----------| | `/upload` | POST | Загрузка файла → парсинг → БД | | `/convert-doc` | POST | .doc → .docx (libreoffice) | | `/unzip-upload` | POST | Распаковка ZIP | | `/process-v2` | GET | SSE сравнения | | `/api/classify-batch` | POST | Классификация батча | | `/api/batch-progress` | GET | Прогресс классификации | | `/api/groups` | GET | Группы по batch_id | | `/api/documents/:id` | GET | Инфо о документе | | `/api/supplements` | GET | Список supplement'ов | | `/api/sync` | POST | Синхронизация БД | | `/api/apply-groups` | POST | Применить группы | | `/api/spec-current` | GET | Текущая спецификация | | `/api/cleanup` | POST | Очистка старых записей | Плюс перенос БД-логики из `deploy/db/` во Flask-приложение и конвертера `convert_doc.py`.