+236
View File
@@ -0,0 +1,236 @@
# Архитектура 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`.