# Архитектура 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