Files
contracts/History/flask-vm-architecture-2026-07-14.md
T

237 lines
12 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Архитектура 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
<script src="/static/state.js"></script>
<script src="/static/app_utils.js"></script>
<script src="/static/files.js"></script>
<script src="/static/groups.js"></script>
<script src="/static/compare.js"></script>
<script src="/static/app.js"></script>
```
### 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
<!-- Верхняя панель: логотип + заголовок + степпер + кнопка "О сервисе" -->
<div class="topbar">...</div>
<!-- Контент -->
<div class="content">
<!-- Карточка 1: Загрузка -->
<div class="card">
<input type="file" id="fileInput" accept=".docx,.doc,.pdf,.zip" multiple>
<table><tbody id="fileTable"></tbody></table>
<button id="llmBtn">Общее сравнение</button>
<!-- Промпты LLM -->
</div>
<!-- Карточка 2: Классификация и группы (скрыта до classify) -->
<div class="card" id="diffCard" style="display:none;">
<div id="diffBody"></div>
</div>
<!-- Карточка 3: Результаты сравнения (скрыта до compare) -->
<div class="card" id="compareCard" style="display:none;">
<div id="compareBody"></div>
</div>
<!-- Карточка 4: Чат с LLM (скрыта) -->
<div class="card" id="chatCard" style="display:none;">...</div>
</div>
<!-- Модалки: инфо о файле, промпты, о сервисе -->
```
## 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`.