Compare commits
38
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
07abb86cab | ||
|
|
1635eed33b | ||
|
|
176b302680 | ||
|
|
908dff74a7 | ||
|
|
572067c33a | ||
|
|
7884c51e23 | ||
|
|
a9f67f23fc | ||
|
|
a2e4ad01cf | ||
|
|
075464dc60 | ||
|
|
5c9c68b02b | ||
|
|
f3437b2969 | ||
|
|
b5d4b90048 | ||
|
|
35dfa57ba3 | ||
|
|
ffb9bcbb7d | ||
|
|
98a235b060 | ||
|
|
2b4decdef9 | ||
|
|
0667ac5abf | ||
|
|
a8afbc05d8 | ||
|
|
e376e4c456 | ||
|
|
5f222f29fb | ||
|
|
c2b9518a67 | ||
|
|
e98e158be7 | ||
|
|
5ba70ba9ef | ||
|
|
fcbc20f2db | ||
|
|
b2feb0a34e | ||
|
|
abda2b737d | ||
|
|
faae04c529 | ||
|
|
b79947c082 | ||
|
|
fcb82d2988 | ||
|
|
470cc0a404 | ||
|
|
0e0fb11c28 | ||
|
|
b5b36e08cc | ||
|
|
6f53493adc | ||
|
|
ead3fad73c | ||
|
|
f215e9e13f | ||
|
|
ac0b08a7bf | ||
|
|
21e5473045 | ||
|
|
69e69c7534 |
@@ -4,3 +4,6 @@ dogovora/
|
||||
testgen/out/
|
||||
testgen/out_100files/
|
||||
contracts-flask/hz/
|
||||
contractor-legacy/
|
||||
llm.key
|
||||
loadtest/
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
# Архитектура contracts-flask (актуально на v2.0.16)
|
||||
|
||||
Сервис сверки договоров. Flask + vanilla JS (без frontend-фреймворков).
|
||||
Managed на Nubes (Штурвал), redeploy через панель (не kubectl).
|
||||
|
||||
## Компоненты
|
||||
|
||||
```
|
||||
Браузер (vanilla JS: files.js, compare.js, app.js)
|
||||
│
|
||||
├─ PUT файла → ВМ-буфер (WebDAV /contracts-upload/)
|
||||
│ Браузер кладёт файл на ВМ, т.к. managed-gateway рвёт тело >64 КБ.
|
||||
│ ВМ: 5.172.178.213, домен contracts.kube5s.ru.
|
||||
│
|
||||
├─ /, /static/*, /templates/* → Managed Flask
|
||||
│ Отдаёт HTML + JS.
|
||||
│
|
||||
└─ API (SQLite, WAL) → LLM (api.aillm.ru, gpt-oss-120b)
|
||||
/api/upload_refs — бэк сам тянет файл с ВМ (egress не лимитирован)
|
||||
/process-v2 (SSE) — конвейер сравнения
|
||||
/api/classify-* — классификация
|
||||
/chat — чат по спецификации
|
||||
```
|
||||
|
||||
## БД
|
||||
|
||||
- **SQLite**, WAL-режим, `/tmp/contracts.db`.
|
||||
- Thread-local соединения (`db/connection.py`), API совместим с PostgreSQL
|
||||
через `_pg_to_sqlite()`.
|
||||
- Таблицы: `documents`, `supplements`, `spec_current`, `spec_events`,
|
||||
`prompts`, `upload_chunks`.
|
||||
|
||||
## Event sourcing (спецификация)
|
||||
|
||||
- `spec_events` — append-only журнал операций (история).
|
||||
- `spec_current` — материализованное текущее состояние (для рендера/сравнения).
|
||||
- `_hash(name, date_start)` = sha256(name.strip().lower() + "|" + normalize_date)[:16] — ключ строки.
|
||||
- `apply_ops()` обрабатывает ADD / UPDATE / DELETE / UNRESOLVED.
|
||||
|
||||
## Загрузка файлов
|
||||
|
||||
1. `uploadFile()` (files.js) — браузер PUT файла в ВМ-буфер (WebDAV).
|
||||
2. `POST /api/upload_refs {files:[{name,size,url}]}` — бэк тянет файл с ВМ
|
||||
по `url` (egress без лимита), конвертирует `.doc` → `.docx`
|
||||
(`contracts_upload_sink`), парсит и кладёт в БД.
|
||||
3. Парсинг: pdfplumber / python-docx → `elements_json`.
|
||||
|
||||
## ZIP (важно: клиентское раскрытие)
|
||||
|
||||
⚠️ ZIP раскрывается **на клиенте**, НЕ серверным `/unzip-upload` (тот — legacy).
|
||||
|
||||
- `expandZipClient(f)` (files.js) — рекурсивно раскрывает архив через
|
||||
`window.listZipFiles` (endpoint drhider, подтянут в браузере).
|
||||
- Каждый вложенный файл получает `zip_source = имя архива` для группировки
|
||||
в таблице.
|
||||
- Fallback: если раскрыть не удалось — архив кладётся как есть.
|
||||
- Фильтр вложений: `.pdf`, `.doc`, `.docx`.
|
||||
|
||||
## Сравнение (`/process-v2`, SSE)
|
||||
|
||||
1. `run_pipeline()` шлёт SSE-события браузеру.
|
||||
2. Текущая спецификация → текст → LLM → `{mode, ops[]}`.
|
||||
3. **Трансляция `target_id` → `target_hash`**: LLM возвращает `target_id: "r1"`
|
||||
(индекс строки), а `apply_ops()` читает `target_hash`. Без трансляции
|
||||
UPDATE/DELETE уходили бы в UNRESOLVED.
|
||||
4. `apply_ops()` пишет `spec_events` + обновляет `spec_current`.
|
||||
|
||||
### Режимы LLM
|
||||
|
||||
- `partial` — точечные ADD/UPDATE/DELETE (UPDATE/DELETE по `target_hash`).
|
||||
- `full_replace` — LLM возвращает полную новую редакцию (все ADD).
|
||||
⚠️ Перед применением очищается **только** `spec_current` (`clear_current()`),
|
||||
чтобы ADD-строки новой редакции не дублировали старые. История
|
||||
`spec_events` сохраняется.
|
||||
|
||||
## Классификация
|
||||
|
||||
- `api.aillm.ru`, модель `gpt-oss-120b` (конфиденциальные данные — только своя модель).
|
||||
- `ThreadPoolExecutor(max_workers=4)`.
|
||||
- Поля: `doc_type`, `own_number`, `parent_number`, `counterparty`, `doc_date`.
|
||||
|
||||
## Конфигурация
|
||||
|
||||
`site/config.py`: VERSION, LLM_URL/KEY/MODEL, CONVERT_SERVICE_URL,
|
||||
VM_UPLOAD_URL/VM_UPLOAD_PREFIX/VM_UPLOAD_MAX_BYTES (50 МБ), PULL_RETRIES (3).
|
||||
|
||||
## Модуль `upload/`
|
||||
|
||||
Переиспользован из drhider (слои 1–2). Плаг-ин через `sink`:
|
||||
`create_upload_refs_blueprint(cfg, sink=contracts_upload_sink)`.
|
||||
Слои 3–4 (in-memory session) — НЕ используются в contracts.
|
||||
@@ -0,0 +1,61 @@
|
||||
# Как проверить загрузку файлов в Contracts (для DevOps Nubes)
|
||||
|
||||
Цель: воспроизвести «чистую» загрузку файла с клиента на сервер, без обработки,
|
||||
чтобы поймать обрыв на ингрессе/периметре (граница ~64 КБ, плавает по маршруту).
|
||||
|
||||
## 1. Какой инстанс / где смотреть
|
||||
|
||||
- Деплой и домен: смотри `contracts-flask/deploy/` и `VM.md` (в этой репе).
|
||||
- Внешний путь: NSX Edge + AVI ALB → Kubernetes Ingress (nginx) → поды Flask.
|
||||
- Сервер отдаёт **именно загрузку файлов**: интерфейс = выбор файла в локали →
|
||||
клиент режет на чанки 50 КБ → POST `/chunk` → сервер копит и собирает файл.
|
||||
|
||||
## 2. Что важно знать (контекст из истории)
|
||||
|
||||
- Проблема ингресса уже встречалась: прямой POST файла резался
|
||||
`client_max_body_size` (весной, PDF 153 КБ → «✗ Сеть»).
|
||||
- Сейчас обход — **чанковая загрузка по 50 КБ** (`file.slice(0,50KB)` → `/chunk`).
|
||||
Значит, «большой файл» для проверки = много чанков, а не один POST.
|
||||
- Для диагностики полезно проверить ОБА способа:
|
||||
а) обычная загрузка через UI (чанки);
|
||||
б) прямой большой POST = то, что ближе к «чистому» обрыву на шлюзе.
|
||||
|
||||
## 3. Как проверять (по шагам)
|
||||
|
||||
### Шаг 1. Поднять/найти инстанс
|
||||
- Определи, где сейчас развёрнут сервис (см. `deploy/`, `VM.md`).
|
||||
- Убедись, что сервис отвечает (лендинг/UI).
|
||||
|
||||
### Шаг 2. Чистая загрузка через UI (чанки)
|
||||
- В веб-интерфейсе выбери файл в локали (начни с 1–5 МБ).
|
||||
- Наблюдай прогресс: чанки должны идти последовательно.
|
||||
- Критерий сбоя: прогресс встаёт, «✗ Сеть», или не хватает чанков при сборе.
|
||||
|
||||
### Шаг 3. Прямой большой POST (обнажает границу шлюза)
|
||||
- Одним запросом отправь файл ~1–5 МБ на endpoint загрузки.
|
||||
- Ожидание: если проблема есть — запрос обрывается на ~середине,
|
||||
частичная передача, таймаут.
|
||||
- Сравни с малым файлом (~10 КБ): должен пройти мгновенно.
|
||||
|
||||
### Шаг 4. Серия размеров (найти границу)
|
||||
- Прогони один и тот же файл в размерах: ~10 КБ, ~50 КБ, ~64 КБ, ~100 КБ,
|
||||
~1 МБ, ~5 МБ.
|
||||
- Зафиксируй порог, с которого начинаются сбои (у нас он плавает ~64 КБ).
|
||||
|
||||
### Шаг 5. Снять симптомы «глазами пользователя»
|
||||
- Что видит пользователь: прогресс%, код ошибки браузера, «Сеть»/таймаут.
|
||||
- Если есть доступ к подам: логи nginx-ингресса и пода на момент обрыва
|
||||
(хватит ли байт пришло).
|
||||
|
||||
## 4. Что сообщить DevOps по результатам
|
||||
|
||||
- Размер, на котором воспроизводится обрыв (порог).
|
||||
- Это чанки или прямой POST.
|
||||
- Точное время и домен инстанса (у нас баг нестабилен — плавает по маршруту,
|
||||
поэтому важно поймать момент и передать «на горячем»).
|
||||
|
||||
## 5. Полезные ссылки из репо
|
||||
|
||||
- `History/sessions/session-07-chunks.md` — вся история чанковой загрузки и
|
||||
обхода `client_max_body_size`.
|
||||
- `History/architecture-research-v2-2026-06-27.md` — поток Upload→Parse→…
|
||||
@@ -1,236 +0,0 @@
|
||||
# Архитектура 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`.
|
||||
@@ -0,0 +1,89 @@
|
||||
# Как подключить конвертацию .doc → .docx
|
||||
|
||||
Сервис: **https://liberta.containerk8s.dev.nubes.ru**
|
||||
|
||||
## API
|
||||
|
||||
**Конвертация:**
|
||||
```bash
|
||||
curl -X POST -F "file=@документ.doc" https://liberta.containerk8s.dev.nubes.ru/convert -o результат.docx
|
||||
```
|
||||
|
||||
**Проверка:**
|
||||
```bash
|
||||
curl https://liberta.containerk8s.dev.nubes.ru/health
|
||||
# → {"ok":true,"version":"1.0.0"}
|
||||
```
|
||||
|
||||
## Интеграция в Python (как в Сверке)
|
||||
|
||||
### 1. config.py — URL сервиса
|
||||
```python
|
||||
CONVERT_SERVICE_URL = os.getenv(
|
||||
"CONVERT_SERVICE_URL",
|
||||
"http://containerk8s.df36c8af-1a95-4623-b551-0d37b731ccca.svc.cluster.local:5000"
|
||||
)
|
||||
```
|
||||
|
||||
### 2. upload_bp.py — замена subprocess на HTTP
|
||||
```python
|
||||
import httpx
|
||||
import config
|
||||
|
||||
@upload_bp.route("/convert-doc", methods=["POST"])
|
||||
def convert_doc():
|
||||
f = request.files.get("files")
|
||||
if not f:
|
||||
return jsonify(ok=False, error="no file"), 400
|
||||
try:
|
||||
resp = httpx.post(
|
||||
config.CONVERT_SERVICE_URL + "/convert",
|
||||
files={"file": (f.filename, f.read(), f.content_type or "application/msword")},
|
||||
timeout=120,
|
||||
)
|
||||
if resp.status_code == 200:
|
||||
return send_file(io.BytesIO(resp.content), mimetype="...docx")
|
||||
return jsonify(ok=False, error=resp.json().get("error")), 500
|
||||
except httpx.TimeoutException:
|
||||
return jsonify(ok=False, error="conversion timeout"), 500
|
||||
```
|
||||
|
||||
### 3. files.js — фронтенд
|
||||
```javascript
|
||||
var CONVERT_URL = '/convert-doc';
|
||||
|
||||
// Перед uploadFile: если .doc → сначала convertDoc()
|
||||
async function convertDoc(file, onProgress) {
|
||||
var fd = new FormData();
|
||||
fd.append('files', file, file.name);
|
||||
var resp = await fetch(CONVERT_URL, { method: 'POST', body: fd });
|
||||
if (!resp.ok) throw new Error('Конвертация: HTTP ' + resp.status);
|
||||
var blob = await resp.blob();
|
||||
return new File([blob], file.name.replace(/\.doc$/i, '.docx'), {
|
||||
type: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document'
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
## Топология
|
||||
|
||||
```
|
||||
Браузер → POST /convert-doc (Flask-сервер)
|
||||
↓ httpx
|
||||
liberta:5000/convert (libreoffice)
|
||||
↓
|
||||
.docx обратно
|
||||
↓
|
||||
обычный upload + парсинг
|
||||
```
|
||||
|
||||
## Деплой liberta
|
||||
|
||||
Образ: `gitea.services.ngcloud.ru/nail/contractor-convert:latest`
|
||||
Рецепт: `~/nubes/contracts/convert-service/`
|
||||
- Dockerfile: python:3.12 + libreoffice + flask
|
||||
- Платформа: pythonk8s
|
||||
- Порт: 5000
|
||||
- Память: 1024 MB
|
||||
- CPU: 500m
|
||||
- Реплики: 1
|
||||
@@ -0,0 +1,39 @@
|
||||
# Как подключить лого Nubes в веб-сервис
|
||||
|
||||
## Правильный способ (как в Сверке)
|
||||
|
||||
Использовать `<img src="/static/logo.svg">` — браузер рендерит как картинку,
|
||||
"b" в слове nubes не закрашивается.
|
||||
|
||||
## Файлы
|
||||
|
||||
- `~/nubes/design/logo.svg` — правильный логотип (CorelDRAW, 5 путей)
|
||||
- `~/nubes/design/nubes-favicon.svg` — фавикон U-арка с nubes.ru
|
||||
|
||||
## В Python (Flask)
|
||||
|
||||
```python
|
||||
# Читаем SVG как строку
|
||||
_LOGO_SVG = """<svg ...>...</svg>"""
|
||||
|
||||
# Отдаём через эндпоинт
|
||||
@app.route("/static/logo.svg")
|
||||
def logo():
|
||||
from flask import Response
|
||||
return Response(_LOGO_SVG, mimetype="image/svg+xml")
|
||||
```
|
||||
|
||||
## В HTML
|
||||
|
||||
```html
|
||||
<img src="/static/logo.svg" alt="Nubes" style="height:28px">
|
||||
```
|
||||
|
||||
## НЕПРАВИЛЬНО
|
||||
|
||||
- ❌ Инлайн `<svg>` — fill-rule ломает букву "b"
|
||||
- ❌ Data URI — битый если длинный/неправильно закодирован
|
||||
|
||||
## Фавикон
|
||||
|
||||
Брать из nubes.ru: `https://nubes.ru/themes/custom/nubes/images/nubes-ico.svg`
|
||||
@@ -0,0 +1,610 @@
|
||||
# План миграции: ВМ → Flask (полный перенос, без ВМ)
|
||||
|
||||
**Дата:** 2026-07-14
|
||||
**Цель:** Убрать `deploy/convert_server.py` и весь ВМ-слой. Вся логика — во Flask.
|
||||
**Принцип:** НИКАКОГО монолита. Каждый слой — отдельный модуль/blueprint.
|
||||
|
||||
---
|
||||
|
||||
## 0. Аудит: насколько код уже decoupled
|
||||
|
||||
### ✅ УЖЕ ГОТОВО (можно брать как есть)
|
||||
|
||||
| Модуль | Строк | Статус | Почему |
|
||||
|--------|-------|--------|--------|
|
||||
| `db/connection.py` | ~60 | ✅ Идеально | Connection pool, ноль зависимостей |
|
||||
| `db/documents.py` | ~100 | ✅ Идеально | Чистый CRUD, только `query()/execute()` |
|
||||
| `db/contracts.py` | ~25 | ✅ Идеально | Чистый CRUD |
|
||||
| `db/supplements.py` | ~60 | ✅ Идеально | Чистый CRUD |
|
||||
| `db/spec_current.py` | ~20 | ✅ Идеально | Чистые запросы |
|
||||
| `db/spec_events.py` | ~30 | ✅ Идеально | Чистый CRUD |
|
||||
| `db/prompts.py` | ~80 | ✅ Идеально | Чистый CRUD + seed |
|
||||
| `compare/parse.py` | ~100 | ✅ Идеально | Чистая функция: `(filename, bytes) → dict` |
|
||||
| `compare/llm_client.py` | ~80 | ✅ Идеально | Protocol + Httpx + Fake, DI-ready |
|
||||
| `compare/grouping.py` | ~160 | ✅ Идеально | Чистая логика: `batch_id → groups`, нет HTTP |
|
||||
| `compare/llm.py` | ~40 | ✅ Хорошо | Уже принимает `llm_client` опционально (DI) |
|
||||
| `llm_prompt.py` | ~80 | ✅ Идеально | Чистые функции сборки промптов |
|
||||
| `repository.py` | ~120 | ✅ Хорошо | Protocol есть, PgRepository частично реализован |
|
||||
|
||||
### 🔧 НУЖНА АДАПТАЦИЯ (логика готова, интерфейс — нет)
|
||||
|
||||
| Модуль | Проблема | Что сделать |
|
||||
|--------|----------|-------------|
|
||||
| `compare/upload.py` | Использует `cgi.FieldStorage` | Заменить на `request.files` из Flask |
|
||||
| `compare/unzip.py` | Читает `rfile.read()` сырые байты | Заменить на `request.files` / `request.data` |
|
||||
| `compare/classify.py` | `ThreadPoolExecutor` ок, но запускается из HTTP-метода | Обернуть в Flask background task (см. ниже) |
|
||||
| `compare/process.py` | SSE через `self.wfile.write()` | Заменить на `Response(stream_with_context(...))` |
|
||||
|
||||
### ❌ НУЖНО ПЕРЕПИСАТЬ
|
||||
|
||||
| Модуль | Проблема | Что сделать |
|
||||
|--------|----------|-------------|
|
||||
| `convert_server.py` | Монолит ~500 строк, все роуты в одном классе | Разобрать на Flask blueprints |
|
||||
| `classify_worker.py` | subprocess.Popen — не нужно во Flask | Убрать, classify в отдельном потоке/процессе через `@app.route` |
|
||||
|
||||
---
|
||||
|
||||
## 1. Целевая архитектура Flask
|
||||
|
||||
```
|
||||
contracts-flask/
|
||||
├── site/
|
||||
│ ├── app.py # create_app(), регистрация blueprints
|
||||
│ ├── config.py # Настройки (DB, LLM, лимиты)
|
||||
│ ├── routes/
|
||||
│ │ ├── __init__.py # register_routes(app)
|
||||
│ │ ├── upload_bp.py # POST /upload, /convert-doc, /unzip-upload
|
||||
│ │ ├── pipeline_bp.py # GET /process-v2 (SSE), POST /classify-batch
|
||||
│ │ ├── api_bp.py # GET/POST /api/* (groups, documents, supplements, sync, cleanup)
|
||||
│ │ ├── prompts_bp.py # GET/POST /api/prompts/*
|
||||
│ │ ├── health_bp.py # GET /health
|
||||
│ │ └── pages_bp.py # GET /, /architect (HTML-страницы)
|
||||
│ ├── services/ # Бизнес-логика (перенос из deploy/compare/)
|
||||
│ │ ├── __init__.py
|
||||
│ │ ├── parse.py # ← deploy/compare/parse.py
|
||||
│ │ ├── classify.py # ← deploy/compare/classify.py
|
||||
│ │ ├── grouping.py # ← deploy/compare/grouping.py
|
||||
│ │ ├── llm.py # ← deploy/compare/llm.py
|
||||
│ │ ├── llm_client.py # ← deploy/compare/llm_client.py
|
||||
│ │ └── metrics.py # ← deploy/compare/metrics.py
|
||||
│ ├── db/ # Перенос из deploy/db/
|
||||
│ │ ├── __init__.py
|
||||
│ │ ├── connection.py # ← deploy/db/connection.py
|
||||
│ │ ├── documents.py # ← deploy/db/documents.py
|
||||
│ │ ├── contracts.py # ← deploy/db/contracts.py
|
||||
│ │ ├── supplements.py # ← deploy/db/supplements.py
|
||||
│ │ ├── spec_current.py # ← deploy/db/spec_current.py
|
||||
│ │ ├── spec_events.py # ← deploy/db/spec_events.py
|
||||
│ │ └── prompts.py # ← deploy/db/prompts.py
|
||||
│ ├── repository.py # ← deploy/repository.py (расширить)
|
||||
│ ├── llm_prompt.py # ← deploy/llm_prompt.py
|
||||
│ ├── templates/
|
||||
│ │ └── index.html # УЖЕ ЕСТЬ, почти не меняется
|
||||
│ └── static/ # JS-файлы: state.js, app_utils.js, files.js, groups.js, compare.js, app.js
|
||||
├── requirements.txt # Flask + psycopg2 + httpx + pdfplumber + python-docx
|
||||
├── Dockerfile
|
||||
└── deploy/ # ОСТАЁТСЯ только:
|
||||
├── nginx-contracts.conf
|
||||
├── sync.sh
|
||||
└── convert_doc.py # libreoffice-конвертер (stdin→stdout, НЕ HTTP)
|
||||
```
|
||||
|
||||
### Ключевое правило: ZERO монолита
|
||||
|
||||
- **Blueprints** — каждый на ≤100 строк, одна ответственность
|
||||
- **services/** — чистые функции, никакого `request`/`Response`
|
||||
- **db/** — чистый CRUD, никакого Flask
|
||||
- **repository.py** — единая точка доступа к данным (DI во все services)
|
||||
|
||||
---
|
||||
|
||||
## 2. Пошаговый план (8 шагов)
|
||||
|
||||
### Шаг 1: Перенести `db/` как есть
|
||||
|
||||
**Файлы:** `deploy/db/*.py` → `site/db/*.py`
|
||||
|
||||
**Что делать:** Копировать. Менять НИЧЕГО не надо.
|
||||
- `connection.py` уже использует `psycopg2.pool.ThreadedConnectionPool` — идеально для Flask (каждый request — своё соединение из пула)
|
||||
- Все модули зависят только от `connection.query()` и `connection.execute()`
|
||||
|
||||
**Проверка:** `python3 -c "from site.db import documents; print(documents.list_by_batch('test'))"`
|
||||
|
||||
---
|
||||
|
||||
### Шаг 2: Перенести `services/` (бывший `compare/`)
|
||||
|
||||
**Файлы:** `deploy/compare/{parse,classify,grouping,llm,llm_client,metrics}.py` → `site/services/`
|
||||
|
||||
**Что делать:** Копировать, исправить импорты:
|
||||
- `from db import ...` → `from site.db import ...`
|
||||
- `from .parse import ...` → `from site.services.parse import ...`
|
||||
- `from llm_prompt import ...` → `from site.llm_prompt import ...`
|
||||
|
||||
**НЕ переносить:** `upload.py`, `unzip.py`, `process.py` — они привязаны к HTTP и будут переписаны в blueprints.
|
||||
|
||||
**Проверка:** `python3 -c "from site.services.classify import classify_batch; print('ok')"`
|
||||
|
||||
---
|
||||
|
||||
### Шаг 3: Создать `config.py`
|
||||
|
||||
```python
|
||||
# site/config.py
|
||||
import os
|
||||
|
||||
DB_CONFIG = {
|
||||
"host": os.getenv("DB_HOST", "127.0.0.1"),
|
||||
"port": int(os.getenv("DB_PORT", "5432")),
|
||||
"dbname": os.getenv("DB_NAME", "baza"),
|
||||
"user": os.getenv("DB_USER", "super"),
|
||||
"password": os.getenv("DB_PASS", ""),
|
||||
}
|
||||
|
||||
LLM_URL = os.getenv("LLM_API_URL", "https://api.aillm.ru/v1/chat/completions")
|
||||
LLM_KEY = os.getenv("LLM_API_KEY", "")
|
||||
LLM_MODEL = os.getenv("LLM_MODEL", "gpt-oss-120b")
|
||||
|
||||
MAX_CONTENT_LENGTH = 200 * 1024 * 1024 # 200 MB
|
||||
VERSION = "2.0.0"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Шаг 4: Blueprint `upload_bp.py` — загрузка файлов
|
||||
|
||||
Самый критичный blueprint. Замена `deploy/compare/upload.py` + `deploy/compare/unzip.py`.
|
||||
|
||||
```python
|
||||
# site/routes/upload_bp.py
|
||||
from flask import Blueprint, request, jsonify
|
||||
from site.services.parse import parse_file
|
||||
from site.db import documents, contracts
|
||||
from site.config import MAX_CONTENT_LENGTH
|
||||
import hashlib, base64, zipfile, io, os
|
||||
|
||||
upload_bp = Blueprint("upload", __name__)
|
||||
|
||||
ALLOWED = {"pdf", "docx", "doc", "zip"}
|
||||
|
||||
@upload_bp.route("/upload", methods=["POST"])
|
||||
def upload():
|
||||
"""Загрузка одного файла → парсинг → БД."""
|
||||
f = request.files.get("files")
|
||||
if not f:
|
||||
return jsonify(ok=False, error="no file"), 400
|
||||
|
||||
ext = f.filename.rsplit(".", 1)[-1].lower() if "." in f.filename else ""
|
||||
if ext not in ALLOWED:
|
||||
return jsonify(ok=False, error=f"unsupported: .{ext}"), 400
|
||||
|
||||
data = f.read()
|
||||
content_hash = hashlib.sha256(data).hexdigest()[:16]
|
||||
|
||||
batch_id = request.form.get("batch_id")
|
||||
zip_source = request.form.get("zip_source")
|
||||
|
||||
# Проверка дубликата по хешу
|
||||
if batch_id:
|
||||
existing = documents.get_by_hash(batch_id, content_hash)
|
||||
if existing:
|
||||
return jsonify(ok=False, error="duplicate", doc_id=existing["id"])
|
||||
|
||||
doc = documents.insert(
|
||||
filename=f.filename,
|
||||
mime_type=f.content_type or "application/octet-stream",
|
||||
original_bytes=base64.b64encode(data).decode(),
|
||||
batch_id=batch_id,
|
||||
zip_source=zip_source,
|
||||
content_hash=content_hash,
|
||||
)
|
||||
|
||||
# Парсинг
|
||||
try:
|
||||
result = parse_file(f.filename, data)
|
||||
if result["status"] == "parsed":
|
||||
documents.set_parsed(doc["id"], result["elements"])
|
||||
else:
|
||||
documents.set_error(doc["id"], result.get("error", "parse failed"))
|
||||
except Exception as e:
|
||||
documents.set_error(doc["id"], str(e))
|
||||
result = {"status": "error", "error": str(e)}
|
||||
|
||||
contract_id = request.form.get("contract_id")
|
||||
return jsonify(ok=True, doc_id=doc["id"], contract_id=contract_id,
|
||||
parsed={"status": result["status"], "element_count": result.get("element_count", 0)})
|
||||
|
||||
|
||||
@upload_bp.route("/convert-doc", methods=["POST"])
|
||||
def convert_doc():
|
||||
""".doc → .docx через libreoffice."""
|
||||
import subprocess, tempfile
|
||||
f = request.files.get("files")
|
||||
if not f:
|
||||
return jsonify(ok=False, error="no file"), 400
|
||||
data = f.read()
|
||||
with tempfile.NamedTemporaryFile(suffix=".doc", delete=False) as tmp:
|
||||
tmp.write(data)
|
||||
doc_path = tmp.name
|
||||
tmpdir = tempfile.mkdtemp()
|
||||
try:
|
||||
subprocess.run(["libreoffice", "--headless", "--convert-to", "docx", "--outdir", tmpdir, doc_path],
|
||||
timeout=30, capture_output=True)
|
||||
docx_files = [x for x in os.listdir(tmpdir) if x.endswith(".docx")]
|
||||
if docx_files:
|
||||
with open(os.path.join(tmpdir, docx_files[0]), "rb") as out:
|
||||
from flask import send_file
|
||||
return send_file(io.BytesIO(out.read()), mimetype="application/vnd.openxmlformats-officedocument.wordprocessingml.document")
|
||||
return jsonify(ok=False, error="conversion produced no output"), 500
|
||||
finally:
|
||||
os.unlink(doc_path)
|
||||
for x in os.listdir(tmpdir):
|
||||
os.unlink(os.path.join(tmpdir, x))
|
||||
os.rmdir(tmpdir)
|
||||
|
||||
|
||||
@upload_bp.route("/unzip-upload", methods=["POST"])
|
||||
def unzip_upload():
|
||||
"""Распаковать ZIP → список файлов (base64)."""
|
||||
f = request.files.get("files")
|
||||
if not f:
|
||||
return jsonify(ok=False, error="no file"), 400
|
||||
data = f.read()
|
||||
MAX_FILES = 500
|
||||
MAX_UNCOMPRESSED = 500 * 1024 * 1024
|
||||
files = []
|
||||
total = 0
|
||||
with zipfile.ZipFile(io.BytesIO(data)) as zf:
|
||||
if len(zf.namelist()) > MAX_FILES:
|
||||
return jsonify(ok=False, error=f"too many files (max {MAX_FILES})"), 400
|
||||
for info in zf.infolist():
|
||||
if info.is_dir():
|
||||
continue
|
||||
name = os.path.basename(info.filename)
|
||||
if not name or ".." in name:
|
||||
continue
|
||||
raw = zf.read(info)
|
||||
total += len(raw)
|
||||
if total > MAX_UNCOMPRESSED:
|
||||
return jsonify(ok=False, error="total uncompressed > 500MB"), 400
|
||||
ext = name.rsplit(".", 1)[-1].lower() if "." in name else ""
|
||||
files.append({
|
||||
"filename": name,
|
||||
"ext": ext,
|
||||
"size": len(raw),
|
||||
"data_b64": base64.b64encode(raw).decode(),
|
||||
})
|
||||
return jsonify(ok=True, files=files)
|
||||
```
|
||||
|
||||
**Критично:**
|
||||
- `request.files` вместо `cgi.FieldStorage` — файл уже в памяти
|
||||
- `base64` всё ещё нужен (JS на фронте делает `atob()`)
|
||||
- Таймауты: Flask не режет сам — поставить `timeout` на libreoffice
|
||||
|
||||
---
|
||||
|
||||
### Шаг 5: Blueprint `pipeline_bp.py` — SSE + classify
|
||||
|
||||
```python
|
||||
# site/routes/pipeline_bp.py
|
||||
from flask import Blueprint, request, jsonify, Response, stream_with_context
|
||||
from site.services.process import run_pipeline # адаптированный process.py
|
||||
from site.services.classify import classify_batch
|
||||
from site.llm_prompt import build_prompt
|
||||
from site.db import documents
|
||||
import json, re, os, threading, sys
|
||||
|
||||
pipeline_bp = Blueprint("pipeline", __name__)
|
||||
|
||||
@pipeline_bp.route("/process-v2", methods=["GET"])
|
||||
def process_v2():
|
||||
"""SSE-стриминг сравнения."""
|
||||
cid = request.args.get("contract_id")
|
||||
if not cid or not re.fullmatch(r'[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}', cid, re.I):
|
||||
return jsonify(ok=False, error="invalid contract_id"), 400
|
||||
|
||||
order_ids = request.args.get("order", "")
|
||||
|
||||
def generate():
|
||||
yield ": ok\n\n"
|
||||
try:
|
||||
for event in run_pipeline(cid, order_ids, build_prompt):
|
||||
yield f"data: {json.dumps(event, ensure_ascii=False)}\n\n"
|
||||
except GeneratorExit:
|
||||
return
|
||||
except Exception as e:
|
||||
yield f"data: {json.dumps({'type': 'error', 'message': str(e)}, ensure_ascii=False)}\n\n"
|
||||
|
||||
return Response(
|
||||
stream_with_context(generate()),
|
||||
content_type="text/event-stream; charset=utf-8",
|
||||
headers={
|
||||
"Cache-Control": "no-cache",
|
||||
"X-Accel-Buffering": "no", # ← nginx не буферизует
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@pipeline_bp.route("/api/classify-batch", methods=["POST"])
|
||||
def classify_batch_route():
|
||||
"""Запустить классификацию. Синхронно для малых батчей, 202 для больших."""
|
||||
body = request.get_json()
|
||||
batch_id = body.get("batch_id")
|
||||
if not batch_id:
|
||||
return jsonify(ok=False, error="batch_id required"), 400
|
||||
|
||||
pending = documents.list_pending(batch_id)
|
||||
total = len(pending)
|
||||
if total == 0:
|
||||
return jsonify(ok=False, error="no pending documents"), 400
|
||||
|
||||
# Для ≤10 файлов — синхронно (быстрее, проще)
|
||||
if total <= 10:
|
||||
result = classify_batch(batch_id)
|
||||
return jsonify(result)
|
||||
|
||||
# Для >10 файлов — в отдельном потоке, сразу 202
|
||||
lock_path = f"/tmp/classify_{batch_id}.lock"
|
||||
if os.path.exists(lock_path):
|
||||
return jsonify(ok=False, error="classify already running"), 409
|
||||
|
||||
with open(lock_path, "w") as lf:
|
||||
lf.write(str(os.getpid()))
|
||||
|
||||
def _run():
|
||||
try:
|
||||
classify_batch(batch_id)
|
||||
finally:
|
||||
if os.path.exists(lock_path):
|
||||
os.remove(lock_path)
|
||||
|
||||
threading.Thread(target=_run, daemon=True).start()
|
||||
return jsonify(ok=True, total=total), 202
|
||||
```
|
||||
|
||||
**Критично:**
|
||||
- `compare/process.py` нужно адаптировать: `run_pipeline` должен стать **генератором** (yield события), а не принимать `sse_send` callback
|
||||
- `GeneratorExit` в генераторе — обязательно
|
||||
- `X-Accel-Buffering: no` — иначе nginx буферизует SSE
|
||||
- classify: синхронно для ≤10 файлов, Thread для >10 (вместо subprocess)
|
||||
|
||||
---
|
||||
|
||||
### Шаг 6: Blueprint `api_bp.py` — всё остальное API
|
||||
|
||||
```python
|
||||
# site/routes/api_bp.py
|
||||
from flask import Blueprint, request, jsonify
|
||||
from site.db import documents, supplements, contracts, spec_current
|
||||
from site.services.grouping import group_documents, apply_groups
|
||||
from site.db.connection import execute, query
|
||||
|
||||
api_bp = Blueprint("api", __name__)
|
||||
|
||||
@api_bp.route("/api/supplements")
|
||||
def api_supplements():
|
||||
cid = request.args.get("contract_id")
|
||||
if not cid:
|
||||
return jsonify(ok=False, error="contract_id required"), 400
|
||||
rows = supplements.list_by_contract(cid)
|
||||
return jsonify(ok=True, supplements=rows)
|
||||
|
||||
@api_bp.route("/api/documents/<doc_id>")
|
||||
def api_document(doc_id):
|
||||
doc = documents.get(doc_id)
|
||||
if not doc:
|
||||
return jsonify(ok=False, error="not found"), 404
|
||||
return jsonify(ok=True, **{k: doc.get(k) for k in [
|
||||
"id", "filename", "status", "elements_json", "doc_type",
|
||||
"own_number", "parent_number", "doc_date", "counterparty",
|
||||
"classify_status", "classify_raw", "classify_input"
|
||||
]})
|
||||
|
||||
@api_bp.route("/api/documents/<doc_id>", methods=["DELETE"])
|
||||
def api_document_delete(doc_id):
|
||||
supps = query("SELECT id, contract_id FROM supplements WHERE document_id = %s", (doc_id,))
|
||||
for s in (supps or []):
|
||||
execute("DELETE FROM spec_current WHERE contract_id = %s AND last_event_id IN (SELECT id FROM spec_events WHERE supplement_id = %s)", (s["contract_id"], s["id"]))
|
||||
execute("DELETE FROM spec_events WHERE supplement_id = %s", (s["id"],))
|
||||
execute("DELETE FROM supplements WHERE id = %s", (s["id"],))
|
||||
execute("DELETE FROM documents WHERE id = %s", (doc_id,))
|
||||
return jsonify(ok=True)
|
||||
|
||||
@api_bp.route("/api/sync", methods=["POST"])
|
||||
def api_sync():
|
||||
body = request.get_json()
|
||||
keep_ids = set(body.get("keep_ids", []))
|
||||
if len(keep_ids) > 1000:
|
||||
return jsonify(ok=False, error="too many keep_ids"), 400
|
||||
docs = query("SELECT id FROM documents", ())
|
||||
deleted = 0
|
||||
for d in (docs or []):
|
||||
if d["id"] in keep_ids:
|
||||
continue
|
||||
supps = query("SELECT id, contract_id FROM supplements WHERE document_id = %s", (d["id"],))
|
||||
for s in (supps or []):
|
||||
execute("DELETE FROM spec_current WHERE contract_id = %s AND last_event_id IN (SELECT id FROM spec_events WHERE supplement_id = %s)", (s["contract_id"], s["id"]))
|
||||
execute("DELETE FROM spec_events WHERE supplement_id = %s", (s["id"],))
|
||||
execute("DELETE FROM supplements WHERE id = %s", (s["id"],))
|
||||
execute("DELETE FROM documents WHERE id = %s", (d["id"],))
|
||||
deleted += 1
|
||||
execute("DELETE FROM contracts WHERE id NOT IN (SELECT DISTINCT contract_id FROM supplements)")
|
||||
return jsonify(ok=True, deleted=deleted)
|
||||
|
||||
@api_bp.route("/api/groups")
|
||||
def api_groups():
|
||||
batch_id = request.args.get("batch")
|
||||
if not batch_id:
|
||||
return jsonify(ok=False, error="batch required"), 400
|
||||
result = group_documents(batch_id)
|
||||
return jsonify(result)
|
||||
|
||||
@api_bp.route("/api/batch-progress")
|
||||
def api_batch_progress():
|
||||
batch_id = request.args.get("batch")
|
||||
if not batch_id:
|
||||
return jsonify(ok=False, error="batch required"), 400
|
||||
counts = documents.count_by_status(batch_id)
|
||||
docs = documents.list_by_batch(batch_id)
|
||||
return jsonify(ok=True, counts=counts, total=len(docs))
|
||||
|
||||
@api_bp.route("/api/apply-groups", methods=["POST"])
|
||||
def api_apply_groups():
|
||||
body = request.get_json()
|
||||
batch_id = body.get("batch_id")
|
||||
groups = body.get("groups", [])
|
||||
if not batch_id:
|
||||
return jsonify(ok=False, error="batch_id required"), 400
|
||||
result = apply_groups(batch_id, groups)
|
||||
return jsonify(result)
|
||||
|
||||
@api_bp.route("/api/spec-current")
|
||||
def api_spec_current():
|
||||
cid = request.args.get("contract_id")
|
||||
if not cid:
|
||||
return jsonify(ok=False, error="contract_id required"), 400
|
||||
rows = spec_current.list_by_contract(cid)
|
||||
return jsonify(ok=True, rows=rows)
|
||||
|
||||
@api_bp.route("/api/cleanup", methods=["POST"])
|
||||
def api_cleanup():
|
||||
execute("DELETE FROM spec_current")
|
||||
execute("DELETE FROM spec_events")
|
||||
execute("DELETE FROM supplements")
|
||||
execute("DELETE FROM upload_chunks")
|
||||
execute("DELETE FROM documents")
|
||||
execute("DELETE FROM contracts")
|
||||
return jsonify(ok=True, message="all data cleaned")
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Шаг 7: `app.py` — точка входа
|
||||
|
||||
```python
|
||||
# site/app.py
|
||||
from flask import Flask, render_template
|
||||
from site.config import VERSION, MAX_CONTENT_LENGTH
|
||||
|
||||
def create_app():
|
||||
app = Flask(__name__)
|
||||
app.config["VERSION"] = VERSION
|
||||
app.config["MAX_CONTENT_LENGTH"] = MAX_CONTENT_LENGTH
|
||||
|
||||
# Blueprints
|
||||
from site.routes.upload_bp import upload_bp
|
||||
from site.routes.pipeline_bp import pipeline_bp
|
||||
from site.routes.api_bp import api_bp
|
||||
from site.routes.prompts_bp import prompts_bp
|
||||
from site.routes.health_bp import health_bp
|
||||
from site.routes.pages_bp import pages_bp
|
||||
|
||||
app.register_blueprint(upload_bp)
|
||||
app.register_blueprint(pipeline_bp)
|
||||
app.register_blueprint(api_bp)
|
||||
app.register_blueprint(prompts_bp)
|
||||
app.register_blueprint(health_bp)
|
||||
app.register_blueprint(pages_bp)
|
||||
|
||||
@app.after_request
|
||||
def no_cache(response):
|
||||
response.headers["Cache-Control"] = "no-cache, no-store, must-revalidate"
|
||||
response.headers["Pragma"] = "no-cache"
|
||||
response.headers["Expires"] = "0"
|
||||
return response
|
||||
|
||||
return app
|
||||
|
||||
app = create_app()
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Шаг 8: Адаптировать `compare/process.py` в генератор
|
||||
|
||||
Текущий `run_pipeline(cid, order_ids, sse_send, build_prompt_fn)` принимает callback `sse_send`. Нужно сделать генератором:
|
||||
|
||||
```python
|
||||
# site/services/process.py
|
||||
def run_pipeline(contract_id, order_ids, build_prompt_fn):
|
||||
"""Generator: yield SSE events."""
|
||||
# ... та же логика, но вместо sse_send(event) → yield event
|
||||
# ... GeneratorExit уже обрабатывается во view
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. Что НЕ трогаем (остаётся как есть)
|
||||
|
||||
| Что | Почему |
|
||||
|-----|--------|
|
||||
| `templates/index.html` | Уже работает, меняются только URL (с ВМ на свои) |
|
||||
| `static/*.js` (6 файлов) | Меняется только `VM_API` → `""` (свои же endpoints) |
|
||||
| `convert_doc.py` | Остаётся как утилита (libreoffice), вызывается из upload_bp |
|
||||
| CSS, иконки, модалки | Без изменений |
|
||||
|
||||
---
|
||||
|
||||
## 4. Изменения во фронтенде (минимальные)
|
||||
|
||||
В `deploy/app.js` (статика) поменять ОДНУ строку:
|
||||
|
||||
```javascript
|
||||
// Было:
|
||||
var VM_API = 'https://contracts.kube5s.ru';
|
||||
// Стало:
|
||||
var VM_API = ''; // все API на том же домене
|
||||
```
|
||||
|
||||
ВСЁ. Больше ничего не меняется — XHR, SSE, fetch работают с теми же путями.
|
||||
|
||||
---
|
||||
|
||||
## 5. Последовательность выполнения
|
||||
|
||||
| # | Шаг | Сложность | Риск |
|
||||
|---|-----|-----------|------|
|
||||
| 1 | `db/` → `site/db/` | Низкая | Низкий — просто копирование |
|
||||
| 2 | `compare/` → `site/services/` | Низкая | Низкий — правим импорты |
|
||||
| 3 | `config.py` | Низкая | Низкий |
|
||||
| 4 | `upload_bp.py` | Средняя | **Высокий** — ключевой функционал |
|
||||
| 5 | `pipeline_bp.py` + адаптация `process.py` | Высокая | **Высокий** — SSE критичен |
|
||||
| 6 | `api_bp.py` | Средняя | Средний — много ручек |
|
||||
| 7 | `prompts_bp.py`, `health_bp.py`, `pages_bp.py` | Низкая | Низкий |
|
||||
| 8 | `app.py` + тесты | Средняя | Средний |
|
||||
|
||||
---
|
||||
|
||||
## 6. Чек-лист перед деплоем
|
||||
|
||||
- [ ] `python3 -c "from site.app import app"` — приложение создаётся без ошибок
|
||||
- [ ] `/health` → `{"ok": true}`
|
||||
- [ ] `POST /upload` с реальным PDF → `{"ok": true, "doc_id": "..."}`
|
||||
- [ ] `POST /unzip-upload` с ZIP → список файлов
|
||||
- [ ] `GET /process-v2?contract_id=...` → SSE-поток (curl test)
|
||||
- [ ] `POST /api/classify-batch` → классификация работает
|
||||
- [ ] `GET /api/groups?batch=...` → группы
|
||||
- [ ] `POST /api/apply-groups` → создаются supplements
|
||||
- [ ] `GET /api/spec-current?contract_id=...` → спецификация
|
||||
- [ ] `GET /` → HTML с таблицей файлов
|
||||
- [ ] `no_cache` after_request — есть
|
||||
- [ ] `X-Accel-Buffering: no` на SSE
|
||||
- [ ] `GeneratorExit` в SSE-генераторе
|
||||
- [ ] `stream_with_context` на SSE
|
||||
- [ ] `MAX_CONTENT_LENGTH = 200 MB`
|
||||
- [ ] `VM_API = ''` в app.js (фронтенд)
|
||||
- [ ] Все старые тесты проходят
|
||||
|
||||
---
|
||||
|
||||
## 7. Риски и mitigation
|
||||
|
||||
| Риск | Mitigation |
|
||||
|------|-----------|
|
||||
| SSE зависает под нагрузкой | `stream_with_context` + `X-Accel-Buffering: no` |
|
||||
| classify блокирует HTTP | Thread для >10 файлов, sync для ≤10 |
|
||||
| libreoffice падает | timeout=30, отдельный процесс |
|
||||
| Коннекты к БД исчерпываются | ThreadedConnectionPool уже есть, minconn=1, maxconn=10 |
|
||||
| ZIP-бомба | Проверка MAX_UNCOMPRESSED = 500MB, MAX_RATIO |
|
||||
| Загрузка больших PDF (>100MB) | MAX_CONTENT_LENGTH = 200MB, XHR timeout = 180s |
|
||||
@@ -0,0 +1,62 @@
|
||||
# Вопрос для Sonnet (без ссылок на файлы)
|
||||
|
||||
## Контекст
|
||||
|
||||
Мигрировали сервис "Сверка Договоров" с ВМ на Flask (managed Python, Штурвал).
|
||||
Код готов — 22 эндпоинта, вся логика внутри Flask:
|
||||
- upload (XHR + progress), unzip, convert-doc
|
||||
- классификация (LLM, ThreadPoolExecutor внутри Flask-потока для >10 файлов)
|
||||
- группировка
|
||||
- сравнение через SSE с heartbeat
|
||||
- CRUD документов, supplements, промптов
|
||||
- чат с LLM по результатам спецификации
|
||||
|
||||
Архитектура: db/ (чистый CRUD) → services/ (бизнес-логика) → routes/ (Flask blueprints).
|
||||
|
||||
## Проблема
|
||||
|
||||
Сейчас БД — PostgreSQL. Но данные ВРЕМЕННЫЕ:
|
||||
- При загрузке страницы cleanup() → DELETE ALL из всех таблиц (кроме prompts)
|
||||
- Юзер загрузил файлы → обработал → закрыл → данные не нужны
|
||||
- Файлы хранятся как base64 в БД (конфиденциально, должны умереть с сессией)
|
||||
|
||||
Поднимать отдельный PostgreSQL-сервис в кластере ради временных данных — overkill.
|
||||
|
||||
## Предложение
|
||||
|
||||
Заменить PostgreSQL на SQLite (stdlib sqlite3):
|
||||
- База — файл /tmp/contracts.db внутри Flask-контейнера
|
||||
- connection.py переписать (~80 строк), db/*.py — мелкие правки (~30 строк)
|
||||
- Остальной код (services, routes) — без изменений
|
||||
- cleanup → os.remove() — атомарно, никаких DELETE, данные гарантированно исчезли
|
||||
- Упал контейнер → файл исчез
|
||||
|
||||
## Детальнее про конкурентность
|
||||
|
||||
В НАШЕЙ архитектуре:
|
||||
1. Classify запускается ВНУТРИ Flask-процесса как daemon-поток (threading.Thread), НЕ отдельный процесс
|
||||
2. ThreadPoolExecutor(max_workers=4) внутри classify — 4 потока шлют запросы к LLM API
|
||||
3. SSE-стриминг — читает БД, один writer (classify), один reader (SSE)
|
||||
4. Обычно classify синхронный (≤10 файлов) — вообще один поток
|
||||
|
||||
То есть: ОДИН процесс Flask, несколько потоков. Несколько процессов нет.
|
||||
|
||||
## Что меняется в коде
|
||||
|
||||
connection.py:
|
||||
- psycopg2.pool → sqlite3 с thread-local соединениями
|
||||
- threading.local() + get_conn() на каждый поток
|
||||
- PRAGMA journal_mode=WAL
|
||||
- check_same_thread=False
|
||||
|
||||
db/*.py:
|
||||
- %s → ? (sqlite-стиль placeholders)
|
||||
- RETURNING * → lastrowid + отдельный SELECT
|
||||
- ::jsonb → json.dumps()
|
||||
- BOOLEAN → INTEGER (0/1)
|
||||
|
||||
Остальное без изменений.
|
||||
|
||||
## Вопрос
|
||||
|
||||
Есть ли подводные камни с SQLite для этой конкретной архитектуры (один процесс, несколько потоков, WAL, thread-local connections)?
|
||||
@@ -0,0 +1,116 @@
|
||||
# Вопрос для Sonnet — TCP stall 51s на первом upload (2026-07-16)
|
||||
|
||||
## Ситуация
|
||||
|
||||
Два сервиса на одном кластере (contractor + drhider), оба Flask на Штурвале.
|
||||
После миграции contractor на Flask: **первый upload файла из браузера зависает на ~51 секунду**, потом проходит. Второй и последующие файлы — мгновенно.
|
||||
|
||||
## Симптомы
|
||||
|
||||
- **Браузер**: первый POST /upload (~63KB) висит 51 секунду на ~95% прогресса, потом ок
|
||||
- **Логи ingress (nginx)**: ВСЕ запросы (включая успешные) проходят за 0.06-0.16 сек
|
||||
- **Зависание происходит ДО того как запрос попадает в nginx** — на уровне TCP
|
||||
- **51 секунда** — стабильно, воспроизводится периодически
|
||||
- **Затрагивает оба сервиса**: и contractor и drhider
|
||||
|
||||
## Топология кластера
|
||||
|
||||
```
|
||||
Клиент (браузер)
|
||||
↓
|
||||
185.247.187.151 (kube-vip, LoadBalancer, externalTrafficPolicy: Cluster)
|
||||
↓ на ЛЮБУЮ из 4 нод
|
||||
shturval-ingress-controller (4 реплики, по одной на каждой ноде)
|
||||
↓ upstream к поду сервиса
|
||||
pythonk8s (1 под, нода workers-6f74n, IP 172.16.1.221:5000)
|
||||
```
|
||||
|
||||
4 ноды (k8s 1.34.1):
|
||||
- control-plane-xb699 (10.10.102.2) — поды: 172.16.0.0/24
|
||||
- workers-6f74n (10.10.102.3) — поды: 172.16.1.0/24 ← **contractor здесь**
|
||||
- workers-bhbvs (10.10.102.4) — поды: 172.16.2.0/24
|
||||
- workers-v8zq4 (10.10.102.5) — поды: 172.16.3.0/24
|
||||
|
||||
Ingress-поды (4 шт):
|
||||
- 172.16.0.73 на control-plane (.2)
|
||||
- 172.16.1.65 на workers-6f74n (.3) ← **локально с contractor**
|
||||
- 172.16.2.144 на workers-bhbvs (.4)
|
||||
- 172.16.3.149 на workers-v8zq4 (.5)
|
||||
|
||||
Cilium CNI: Geneve-энкапсуляция, MTU 1400, encryption Disabled.
|
||||
|
||||
kube-vip: DaemonSet (по одному на ноду .2 .3 .4 .5) + provider.
|
||||
|
||||
## КЛЮЧЕВАЯ НАХОДКА
|
||||
|
||||
```yaml
|
||||
# Ingress ConfigMap:
|
||||
upstream-keepalive-connections: "0"
|
||||
upstream-keepalive-time: 60s
|
||||
```
|
||||
|
||||
**Каждый HTTP-запрос от ингресса к бэкенду — новый TCP-рукопожатие.**
|
||||
|
||||
75% запросов проходят через Geneve-туннель (ingress на .2/.4/.5 → backend на .3).
|
||||
|
||||
51 секунда ≈ `tcp_syn_retries=5`: 1+2+4+8+16 = 31с или 3+6+12+24+48 = 93с.
|
||||
Точнее — зависит от начального RTO ядра.
|
||||
|
||||
## Вторая подозрительная находка
|
||||
|
||||
Cilium на ноде .3 (`cilium bpf tunnel list`) показывает Geneve-туннели:
|
||||
|
||||
```
|
||||
172.16.3.0 → 10.10.102.5 (нода .5)
|
||||
172.16.1.0 → 10.10.102.3 (себя)
|
||||
172.16.0.0 → 10.10.102.2 (нода .2)
|
||||
```
|
||||
|
||||
**Туннель к ноде .4 (172.16.2.0 → 10.10.102.4) ОТСУТСТВУЕТ в списке!**
|
||||
|
||||
Всего 3 туннеля при 4 нодах.
|
||||
|
||||
## Что проверено
|
||||
|
||||
- `cilium status --verbose`: все healthy, 0 ошибок
|
||||
- `cilium monitor -t drop`: дропов нет
|
||||
- `cilium bpf ct list global`: 4682 записи, conntrack не переполнен
|
||||
- `cilium encrypt status`: Disabled
|
||||
- `cilium bpf lb list`: сервис pythonk8s (10.98.50.130:80 → 172.16.1.221:5000) — OK
|
||||
- Cilium endpoint 1010 (pythonk8s): Disabled/Disabled, ready
|
||||
- Логи ingress (последние 100 запросов): все 200 OK, 0.007-0.162 сек
|
||||
- Ошибок `upstream timed out`, `connect failed`, `502/504` в логах ingress НЕТ
|
||||
- kube-vip поды: все Running
|
||||
- kube-vip provider: Running (рестарт 46ч назад)
|
||||
|
||||
## Гипотезы
|
||||
|
||||
1. **Первый TCP-рукопожатие ingress→backend через Geneve теряет SYN**
|
||||
- Причина: eBPF-программа Cilium для нового соединения не готова мгновенно
|
||||
- Причина: neighbour discovery для Geneve endpoint
|
||||
- Вопрос: почему именно 51 секунда, а не мгновенный RST или 1-2 секунды?
|
||||
|
||||
2. **Отсутствующий Geneve-туннель к ноде .4**
|
||||
- Если запрос попадает на ingress на .4 → трафик к backend на .3 должен идти через туннель
|
||||
- Если туннеля нет в bpf map — куда идёт трафик? Через хост-сеть? Через другую ноду?
|
||||
|
||||
3. **externalTrafficPolicy: Cluster + 4 ingress реплики**
|
||||
- kube-vip может отправить трафик на ЛЮБУЮ ноду
|
||||
- Если на ноду без ingress-пода → kube-proxy/Cilium форвардит на другую ноду → двойной Geneve
|
||||
- Может ли это добавлять задержку?
|
||||
|
||||
4. **client-body-timeout: 60 + первый upload = гонка**
|
||||
- Если TCP-рукопожатие занимает 51 секунду, а body upload начинается после
|
||||
- Может ли общее время превысить 60 секунд и вызвать 408/413?
|
||||
|
||||
## Вопросы
|
||||
|
||||
1. Почему `upstream-keepalive-connections: "0"` + Geneve вызывает 51-секундный TCP-stall именно на ПЕРВОМ запросе, а последующие идут мгновенно?
|
||||
|
||||
2. Отсутствие Geneve-туннеля к ноде .4 (172.16.2.0) в `cilium bpf tunnel list` на ноде .3 — это норма или баг Cilium? Может ли это быть причиной потери SYN-пакетов?
|
||||
|
||||
3. Как диагностировать ГДЕ именно теряется SYN: между kube-vip и ingress, или между ingress и backend? Какие инструменты Cilium/ядренные использовать?
|
||||
|
||||
4. Рекомендация: менять `upstream-keepalive-connections` на 2-4 вместо 0 — решит ли это проблему первого запроса?
|
||||
|
||||
5. Есть ли ещё что посмотреть в кластере чего мы не проверили?
|
||||
@@ -0,0 +1,70 @@
|
||||
# Вопрос для Sonnet — проблема первого upload (без ссылок на файлы)
|
||||
|
||||
## Ситуация
|
||||
|
||||
Мигрировали сервис "Сверка договоров" с ВМ на Flask (managed Python, Штурвал).
|
||||
Всё работает: классификация LLM, парсинг, группы, сравнение через SSE.
|
||||
НО: **первый файл при upload из браузера падает с ERR_CONNECTION_RESET**.
|
||||
|
||||
Та же проблема прямо сейчас на втором сервисе — drhider (Обфускация документов).
|
||||
Вчера (14 июля) drhider работал, сегодня (15 июля) — оба сервиса сломаны одинаково.
|
||||
|
||||
## Симптомы
|
||||
|
||||
- **Браузер**: первый POST с файлом (~63KB) зависает на 100% прогресса, ответ не приходит
|
||||
- **curl с ВМ (5.172.178.213)**: 5/5 upload успешно, ~80-100ms каждый
|
||||
- **Затрагивает оба сервиса**: contractor и drhider
|
||||
- **Признак**: ERR_CONNECTION_RESET в Chrome/Electron
|
||||
|
||||
## Что проверено (инфраструктура)
|
||||
|
||||
Cilium MTU: 1400
|
||||
Ingress (shturval-ingress-controller, nginx):
|
||||
keep-alive: 75
|
||||
use-http2: false
|
||||
proxy-body-size: 1024m
|
||||
client-body-timeout: 60
|
||||
client-header-timeout: 30
|
||||
Ingress-поды: 2/2 Ready, 1/1 Running
|
||||
Образ ingress: r.shturval.tech/ingress-nginx/controller:v1.12.6
|
||||
|
||||
Размещение подов сейчас:
|
||||
Ingress: control-plane (172.16.0.73) + workers-v8zq4 (172.16.3.149)
|
||||
Contractor: workers-6f74n (172.16.1.182)
|
||||
Drhider: workers-6f74n (172.16.1.99)
|
||||
|
||||
Кластер: 4 ноды (3 worker + 1 control-plane), Cilium с Geneve-энкапсуляцией, kube-vip.
|
||||
|
||||
## Что проверено (код)
|
||||
|
||||
- В JS добавлен warmup fetch('/health') при загрузке страницы
|
||||
- Flask no_cache after_request (Cache-Control: no-cache)
|
||||
- app.run(threaded=True)
|
||||
|
||||
## Хронология
|
||||
|
||||
- 13 июля: Helm ingress revision 7 — drhider работал
|
||||
- 15 июля 02:00 UTC: Helm upgrade → revision 8 — сбросил ingress ConfigMap на дефолты
|
||||
(keep-alive: 10, body-size: 8m, client-body-timeout: 10, client-header-timeout: 10)
|
||||
- 15 июля ~08:00 UTC: патч ConfigMap обратно (keep-alive: 75, etc.) + kubectl rollout restart ingress
|
||||
- После патча: curl работает, браузер — нет
|
||||
- Helm values ИДЕНТИЧНЫ между revision 7 (рабочей) и 8 (сломанной):
|
||||
```
|
||||
controller:
|
||||
hostPort.enabled: true
|
||||
replicaCount: 2
|
||||
service.type: LoadBalancer
|
||||
```
|
||||
|
||||
## Что ещё
|
||||
|
||||
- drhider проходил эту проблему 14 июля — есть документ PROBLEM-AND-SOLUTION.md
|
||||
Фиксы оттуда ВСЕ применены: MTU 1400, keep-alive 75, use-http2 false, warmup, proxy-body-size 1024m
|
||||
- Там же сказано: даже после всех фиксов "5× POST 63KB | 4× OK, 1× ABORTED" — 20% отказов
|
||||
|
||||
## Вопрос
|
||||
|
||||
1. Почему после Helm upgrade (revision 7→8) браузерные upload перестали работать,
|
||||
если все значения ConfigMap восстановлены вручную до прежних?
|
||||
2. Что ещё мог изменить Helm upgrade кроме ConfigMap?
|
||||
3. Как добиться 100% надёжности первого upload из браузера?
|
||||
@@ -0,0 +1,40 @@
|
||||
# Вопрос для Sonnet v2 — первый upload браузер vs curl
|
||||
|
||||
## Ситуация
|
||||
|
||||
Мигрировали сервис на Flask (managed Python, Штурвал). Всё работает кроме ОДНОГО:
|
||||
**первый POST с файлом из браузера зависает на 100% и не завершается.**
|
||||
|
||||
## Ключевые факты (проверено, не врать)
|
||||
|
||||
1. **Curl работает, браузер — нет. С ОДНОЙ машины.**
|
||||
- curl: 20/20, ~80ms каждый
|
||||
- Браузер (Chrome/Electron): первый файл → XHR 100% → завис намертво
|
||||
- Остальные файлы даже не начинают загружаться (первый блокирует очередь)
|
||||
|
||||
2. **Браузерный запрос НЕ доходит до nginx.**
|
||||
- `kubectl logs ingress` показывает ТОЛЬКО curl-запросы
|
||||
- Браузерный XHR: прогресс 100% (данные ушли), но ответ не приходит
|
||||
- onerror НЕ срабатывает (не RST, не таймаут — просто тишина)
|
||||
- xhr.timeout = 180000 (3 минуты)
|
||||
|
||||
3. **Инфраструктура проверена:**
|
||||
- Cilium MTU: 1400 (underlay 1450, Geneve -50 = pod 1400)
|
||||
- Ingress ConfigMap: keep-alive 75, client-body-timeout 60, client-header-timeout 30, proxy-body-size 1024m
|
||||
- Ingress аннотации: proxy-body-size 1024m, proxy-connect-timeout 120, proxy-read-timeout 600, proxy-send-timeout 600
|
||||
- HTTP/2: выключен (use-http2 false)
|
||||
- Cilium: 4/4 ноды reachable, tunnel-protocol geneve
|
||||
- kube-vip: DaemonSet на всех 4 нодах, все Running
|
||||
- Ingress поды: 2/2 Ready, образ v1.12.6
|
||||
- Helm upgrade 15 июля 02:00 UTC НЕ менял значения (revision 7 и 8 идентичны)
|
||||
|
||||
4. **Код:**
|
||||
- warmup `fetch('/health')` при загрузке страницы — есть
|
||||
- Flask no_cache (Cache-Control: no-cache) — есть
|
||||
- app.run(threaded=True)
|
||||
|
||||
5. **Затрагивает ОБА сервиса:** и contractor, и drhider (который работал вчера)
|
||||
|
||||
## Вопрос
|
||||
|
||||
Почему curl (20/20) работает, а браузерный XHR (0/1) — нет, при том что ОБА с одной машины через один и тот же интернет-канал?
|
||||
@@ -0,0 +1,132 @@
|
||||
# Sonnet Review: план миграции ВМ → Flask — анализ и ответ
|
||||
|
||||
**Дата:** 2026-07-14
|
||||
**Источник:** Sonnet (Claude) — ревью `History/migration-vm-to-flask-plan-2026-07-14.md`
|
||||
|
||||
---
|
||||
|
||||
## Резюме Соннета
|
||||
|
||||
План **одобрен архитектурно**. Критичных блокеров — 2, важных дополнений — 3, улучшений — 5.
|
||||
|
||||
---
|
||||
|
||||
## Критичные проблемы (Соннет)
|
||||
|
||||
### 1. `site/` — конфликт с Python stdlib ⛔
|
||||
|
||||
> `from site.db import ...` — `site` это встроенный модуль Python. При запуске не из корня Python найдёт встроенный `site`, а не локальный.
|
||||
|
||||
**Моё мнение:** ✅ **Согласен полностью.** Это реальная проблема. `site` — built-in модуль Python, импортируется при старте интерпретатора. Если `PYTHONPATH` или `sys.path` поставит корень раньше чем `site-packages` — получим наш пакет. Если наоборот — stdlib. Нестабильно.
|
||||
|
||||
**Решение:** переименовать `site/` → `app/` или `contracts_app/`. Я за `app/` — короче, семантически понятно (Flask application factory), не конфликтует ни с чем.
|
||||
|
||||
### 2. `/api/cleanup` — нулевая аутентификация ⛔
|
||||
|
||||
> Любой может POST /api/cleanup → потеря всех данных.
|
||||
|
||||
**Моё мнение:** ✅ **Согласен.** Но с нюансом. Текущий `convert_server.py` тоже имеет этот эндпоинт без auth — и он на внешнем домене `contracts.kube5s.ru`. Так что это не регресс, а существующая дыра.
|
||||
|
||||
**Решение:**
|
||||
- Минимум: `X-Api-Key` в заголовке, сверка с `os.environ["API_KEY"]`
|
||||
- Средний: nginx `allow 127.0.0.1; deny all;` на этот location (если cleanup вызывается только с самого Flask-контейнера)
|
||||
- Правильный: убрать cleanup вообще, заменить на автоочистку по TTL (cron/APScheduler)
|
||||
|
||||
Но это **отдельная задача**, не часть миграции. В рамках миграции — просто не ухудшить ситуацию.
|
||||
|
||||
---
|
||||
|
||||
## Важные дополнения (Соннет)
|
||||
|
||||
### 3. Файловый lock для classify — хрупкий
|
||||
|
||||
> `/tmp/classify_{batch_id}.lock` — если Flask упадёт, lock остаётся навсегда.
|
||||
|
||||
**Моё мнение:** ✅ **Согласен.** Lock-файл — антипаттерн в stateless-приложении. Сейчас в `convert_server.py` та же схема (subprocess + lock-файл), и она работает только потому что ВМ не рестартит.
|
||||
|
||||
**Решение:** in-memory `dict[batch_id] → thread` + проверка `thread.is_alive()`. При старте Flask никаких lock-файлов нет. Лучше: store `classify_status='processing'` в БД и сбрасывать `processing → pending` при старте приложения.
|
||||
|
||||
### 4. SSE — нет heartbeat
|
||||
|
||||
> При медленном LLM nginx и браузеры режут соединение.
|
||||
|
||||
**Моё мнение:** ✅ **Согласен.** Добавить `yield ": heartbeat\n\n"` раз в 15 секунд. SSE-комментарий (строка начинается с `:`) игнорируется EventSource, но сбрасывает таймауты nginx/браузера.
|
||||
|
||||
### 5. Старые proxy-роуты — явно не сказано удалить
|
||||
|
||||
> В плане написано «создать новый app.py», но не сказано явно «удалить старые /api/* proxy-роуты».
|
||||
|
||||
**Моё мнение:** ✅ **Согласен, но это очевидно.** Новый `app.py` полностью заменяет старый — старых роутов `/chat`, `/api/prompts` (proxy на ВМ) не будет, потому что логика теперь локальная. Но в плане стоит написать явно: «удалить ВСЕ proxy-роуты, они больше не нужны».
|
||||
|
||||
---
|
||||
|
||||
## Улучшения (Соннет)
|
||||
|
||||
### 6. `prompts_bp.py` и `pages_bp.py` — не раскрыты
|
||||
|
||||
**Моё мнение:** ✅ **Справедливо.** Но они тривиальны: `prompts_bp.py` = CRUD по таблице `prompts` (4 ручки), `pages_bp.py` = `render_template("index.html")` + `render_template("architect.html")`. Не расписывал детально потому что там нечего расписывать. Можно добавить один абзац для полноты.
|
||||
|
||||
### 7. `drhider/` — не упоминается
|
||||
|
||||
**Моё мнение:** ⚠️ **Справедливо, но осознанно.** `drhider/` — это отдельный сервис (обфускация персональных данных), не часть пайплайна сверки договоров. Он уже работает на Flask в `site/services/drhider.py` и `site/routes/drhider_bp.py`. В план миграции сверки он не входит — это другой сервис на том же хосте. Но стоит упомянуть это явно: «drhider не трогаем, он уже на Flask».
|
||||
|
||||
### 8. `repository.py` — не объяснено как расширить
|
||||
|
||||
**Моё мнение:** ⚠️ **Справедливо, но слишком рано.** Protocol-паттерн в `repository.py` уже есть, `PgRepository` частично реализован. Полное внедрение DI через репозиторий во все services — это Фаза 2, после того как базовая миграция заработает. Сейчас services используют `from site.db import documents` напрямую, и это ОК для первого шага. Переписывать всё на DI сразу = риск сломать работающий код.
|
||||
|
||||
**План:** после миграции — отдельная задача «внедрить Repository DI во все services».
|
||||
|
||||
### 9. Graceful shutdown — in-flight classify потоки
|
||||
|
||||
**Моё мнение:** ✅ **Справедливо.** При `docker stop` daemon-потоки убиваются, документы застревают в `classify_status='processing'`.
|
||||
|
||||
**Решение (уже в коде ВМ!):** `db/documents.reset_classify_status(batch_id)` сбрасывает `processing → pending`. Добавить вызов в `app.py` при старте:
|
||||
|
||||
```python
|
||||
# При старте: сбросить все застрявшие processing → pending
|
||||
from site.db.connection import execute
|
||||
execute("UPDATE documents SET classify_status='pending', error_message=NULL WHERE classify_status='processing'")
|
||||
```
|
||||
|
||||
### 10. Тесты — только curl, нет pytest
|
||||
|
||||
**Моё мнение:** ✅ **Справедливо.** В репо уже есть `tests/` с `conftest.py`. Нужен шаг «адаптировать тесты под `app.test_client()`». Но это **не блокер для миграции** — тесты можно дописать после.
|
||||
|
||||
---
|
||||
|
||||
## Моя итоговая оценка
|
||||
|
||||
| Категория | Пунктов | Критичность |
|
||||
|-----------|---------|-------------|
|
||||
| Критичные (надо исправить ДО миграции) | 2 | `site/` → `app/`, auth на cleanup |
|
||||
| Важные (надо добавить в план) | 3 | lock→in-memory, heartbeat, явное удаление proxy |
|
||||
| Улучшения (можно после миграции) | 5 | prompts/pages детали, drhider, repository DI, graceful, тесты |
|
||||
|
||||
### Что делаем ДО начала миграции:
|
||||
|
||||
1. **Переименовать `site/` → `app/`** — везде в плане, во всех импортах
|
||||
2. **Добавить auth на `/api/cleanup`** — `X-Api-Key` или `require_local`
|
||||
|
||||
### Что добавляем в план:
|
||||
|
||||
3. **in-memory lock** для classify вместо файлового
|
||||
4. **SSE heartbeat** раз в 15 сек
|
||||
5. **Упомянуть явно:** старые proxy-роуты удалить, drhider не трогать
|
||||
6. **Graceful shutdown:** сброс `processing → pending` при старте
|
||||
7. **prompts_bp / pages_bp** — один абзац что там
|
||||
8. **repository DI** — отложить на пост-миграцию
|
||||
|
||||
### Что НЕ блокер:
|
||||
|
||||
- `drhider` — уже на Flask, не часть миграции
|
||||
- Repository DI — Фаза 2
|
||||
- pytest — после миграции
|
||||
- prompts/pages — тривиальны
|
||||
|
||||
---
|
||||
|
||||
## Вердикт
|
||||
|
||||
План **рабочий**. Соннет подтвердил архитектурные решения (blueprints, разделение db/services/routes, генератор для SSE, DI). Критичные замечания — реальные и требуют правки плана. Остальное — улучшения надёжности, которые можно добавить в план сейчас или отложить.
|
||||
|
||||
**Готовность к миграции:** 90%. После правки 2 критичных пунктов → можно начинать.
|
||||
@@ -0,0 +1,49 @@
|
||||
# Sonnet Review: SQLite для Flask-миграции
|
||||
|
||||
**Дата:** 2026-07-15
|
||||
**Вердикт:** Жизнеспособно. Один реальный баг, остальное — конфигурация.
|
||||
|
||||
---
|
||||
|
||||
## ⛔ Критичный баг: inode split-brain
|
||||
|
||||
**Симптом:** `ThreadPoolExecutor` переиспользует потоки. После classify 4 воркера остаются с открытыми соединениями. `cleanup()` делает `os.remove()` → новый `connect()` создаёт новый файл (новый inode). Но старые воркеры продолжают писать в старый inode (Linux не удаляет файл пока открыт fd). Воркеры пишут в призрак, Flask читает из нового пустого файла.
|
||||
|
||||
**Решение:** глобальный ключ сессии в `get_conn()`:
|
||||
|
||||
```python
|
||||
_local = threading.local()
|
||||
_db_inode = None
|
||||
|
||||
def get_conn():
|
||||
conn = getattr(_local, 'conn', None)
|
||||
if conn is None or getattr(_local, 'inode', None) != _db_inode:
|
||||
if conn: conn.close()
|
||||
conn = sqlite3.connect(DB_PATH, check_same_thread=False)
|
||||
conn.execute("PRAGMA journal_mode=WAL")
|
||||
conn.execute("PRAGMA busy_timeout=5000")
|
||||
_local.conn = conn
|
||||
_local.inode = _db_inode
|
||||
return conn
|
||||
```
|
||||
|
||||
## ⚠️ Важные нюансы
|
||||
|
||||
### WAL-сателлиты
|
||||
`os.remove("contracts.db")` не удаляет `.db-wal` и `.db-shm`. SQLite следующего connect'а может применить старый WAL к новому файлу. Удалять все три.
|
||||
|
||||
### Инициализация схемы после cleanup
|
||||
После `os.remove()` → `connect()` создаёт пустую БД. Нужен немедленный `CREATE TABLE IF NOT EXISTS`.
|
||||
|
||||
### busy_timeout
|
||||
4 воркера могут финишировать одновременно → параллельная запись. WAL сериализует, но default timeout = 0 (сразу SQLITE_BUSY). `PRAGMA busy_timeout=5000`.
|
||||
|
||||
### Gunicorn workers = 1
|
||||
Если workers > 1 — несколько процессов поделят один `/tmp/contracts.db`. `cleanup()` одного убьёт файл другого. Зафиксировать `--workers 1`.
|
||||
|
||||
## ✅ Что не проблема
|
||||
|
||||
- WAL + несколько потоков — штатный сценарий, readers не блокируют writers
|
||||
- Большие base64 в SQLite — работает
|
||||
- stdlib sqlite3 — ноль зависимостей
|
||||
- /tmp при перезапуске контейнера — файл исчезает, что и нужно
|
||||
@@ -0,0 +1,26 @@
|
||||
# v2.0.1 — фиксы после деплоя на Штурвал
|
||||
|
||||
**Дата:** 2026-07-16
|
||||
|
||||
## Изменения
|
||||
|
||||
### XHR → fetch() для upload
|
||||
- `files.js`: `uploadFile()` переписан с XMLHttpRequest на `fetch()`
|
||||
- Причина: XHR давал ERR_CONNECTION_RESET на первом запросе (drhider v0.0.29)
|
||||
- Прогресс: имитация через `setInterval` (fetch не даёт реальный upload progress)
|
||||
- Кэш-бастинг: `?_=Date.now()`
|
||||
|
||||
### Убрана поддержка .doc
|
||||
- `files.js`: удалён код конвертации .doc → .docx через libreoffice
|
||||
- `index.html`: `accept=".docx,.pdf,.zip"` (без .doc)
|
||||
- Причина: libreoffice недоступен на Штурвале
|
||||
|
||||
### Инфраструктура
|
||||
- Ingress: 2→4 реплики (hostPort на всех 4 нодах, без Geneve-туннеля)
|
||||
- Ingress ConfigMap: keep-alive 75, proxy-body-size 1024m
|
||||
- Cilium MTU: 1400
|
||||
- CronJob `ingress-fix`: раз в сутки в 03:00 UTC восстанавливает настройки после Helm
|
||||
|
||||
### Версия
|
||||
- `config.py`: VERSION = "2.0.1"
|
||||
- `index.html`: v2.0.1
|
||||
@@ -7,13 +7,37 @@
|
||||
Разбирать спецификации договоров и допников (docx/pdf) в структурированный вид,
|
||||
отслеживать изменения между версиями, восстанавливать историю договора во времени.
|
||||
|
||||
## Состав
|
||||
## Архитектура
|
||||
|
||||
Подробное описание архитектуры (компоненты, БД, event sourcing, загрузка через
|
||||
ВМ-буфер, ZIP, конвейер сравнения) — см. **[`DOC/architecture-contracts-flask.md`](DOC/architecture-contracts-flask.md)**.
|
||||
|
||||
Ключевые моменты:
|
||||
|
||||
- **Основной сервис** — `contracts-flask/` (Flask + SQLite WAL, managed на Nubes).
|
||||
- **Загрузка** — браузер PUT файла в ВМ-буфер (WebDAV), бэк сам тянет с ВМ
|
||||
(managed-gateway рвёт тела > 64 КБ, egress ВМ без лимита).
|
||||
- **ZIP** — раскрывается на клиенте (`expandZipClient` → `listZipFiles`).
|
||||
- **Event sourcing** — `spec_events` (журнал) + `spec_current` (текущее состояние).
|
||||
- **Сравнение** — `/process-v2` (SSE): LLM возвращает ops (ADD/UPDATE/DELETE/
|
||||
UNRESOLVED), применяются через `apply_ops()`.
|
||||
- **LLM** — только своя модель `gpt-oss-120b` (api.aillm.ru).
|
||||
|
||||
## Состав репозитория
|
||||
|
||||
| Папка | Что |
|
||||
|---|---|
|
||||
| `contracts-app/` | Flask-сервис (основной код) |
|
||||
| `contracts-flask/` | Основной сервис (Flask + SQLite, managed на Nubes) |
|
||||
| `contractor-legacy/` | Старый Lucee/CFML-сервис (архив) |
|
||||
| `convert-service/` | Микросервис конвертации doc → docx |
|
||||
| `DOC/` | Документация: архитектура, тестирование |
|
||||
| `History/` | История решений, ревью, протоколы |
|
||||
| `FILES/` | Заметки, ключи LLM, ТЗ |
|
||||
| `dogovora/` | Примеры договоров и допников |
|
||||
| `FILES/` | Заметки, ключи LLM |
|
||||
| `loadtest/` | Нагрузочное тестирование |
|
||||
| `sim/` | Симуляция сценариев |
|
||||
| `testgen/` | Генерация тестовых кейсов |
|
||||
| `hz/` | Разные заметки |
|
||||
|
||||
## Конфиденциальность
|
||||
|
||||
|
||||
-1
Submodule contractor deleted from c56b980501
+1
-1
Submodule contracts-flask updated: ca7b70fe41...87524c54a4
@@ -0,0 +1,14 @@
|
||||
FROM python:3.12
|
||||
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends libreoffice && rm -rf /var/lib/apt/lists/*
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY requirements.txt .
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
COPY app.py .
|
||||
|
||||
EXPOSE 5000
|
||||
|
||||
CMD ["python3", "app.py"]
|
||||
@@ -0,0 +1,190 @@
|
||||
#!/usr/bin/env python3
|
||||
"""LibreOffice converter — Flask HTTP service. .doc → .docx with UI + API."""
|
||||
import subprocess, tempfile, os, base64
|
||||
from flask import Flask, request, send_file, render_template_string
|
||||
|
||||
app = Flask(__name__)
|
||||
VERSION = "1.0.0"
|
||||
|
||||
# Логотип как data URI (как в Сверке — через img, не inline SVG)
|
||||
_LOGO_SVG = """<svg xmlns="http://www.w3.org/2000/svg" xml:space="preserve" width="82.3711mm" height="18.2443mm" version="1.1" style="shape-rendering:geometricPrecision; text-rendering:geometricPrecision; image-rendering:optimizeQuality; fill-rule:evenodd; clip-rule:evenodd" viewBox="0 0 8221.93 1821.07"><defs><style>.fil0{fill:#001C34}</style></defs><g id="Слой_x0020_1"><path class="fil0" d="M477.49 479.69l-413.51 0 -63.98 276.48 178.44 0 1.17 1028.71 0 26.75 0.03 0 276.39 0 0 -871.72c0,-101.28 82.43,-183.69 183.75,-183.69l589.25 3.44c101.34,0 183.76,82.46 183.76,183.74l0 871.72 276.44 0 0 -871.72c0,-253.77 -206.46,-460.15 -460.05,-460.15l-589.52 -3.53 -162.17 0.45 0 -0.48z"/><path class="fil0" d="M6664.65 1813.37l1181.04 0c212.14,0 376.24,-175.01 376.24,-387.08 0,-212.13 -172.55,-384.68 -384.69,-384.68l-653.66 0c-60.22,0 -109.16,-48.94 -109.16,-109.18l0 -66.18c0,-60.19 48.94,-109.14 109.16,-109.14l695.76 0 63.74 -275.49 -759.5 0c-212.13,0 -384.66,172.53 -384.66,384.63l0 66.18c0,212.13 172.53,384.67 384.66,384.67l653.66 0c60.2,0 109.2,49 109.2,109.19 0,60.13 -49,116.1 -109.2,116.1l-1118.9 0 -53.68 270.98z"/><path class="fil0" d="M6200.79 483.5l-723.21 0c-215.88,0 -391.47,175.6 -391.47,391.51l0 485.5c0,248.38 202.05,450.4 450.4,450.4l989.38 0 62.13 -268.51 -1051.51 0c-96.41,0 -174.95,-85.37 -174.95,-181.88l-1.5 -39.46 923.62 0 308.51 -1.84 0 -237.95 0 -206.26c0,-215.91 -175.59,-391.51 -391.41,-391.51zm115.96 560.28l-955.19 0 0 -168.77c0,-63.96 52.07,-116.05 116.02,-116.05l723.21 0c63.91,0 115.96,52.08 115.96,116.05l0 168.77z"/><path class="fil0" d="M4683.49 1194.24l0 168.28c0,100.92 -81.7,178.37 -182.67,178.37l-589.87 1.23c-93.18,0 -170.28,-69.96 -181.63,-160.09l0 -458.13c11.36,-90.1 88.46,-160.07 181.63,-160.07l589.44 3.5c100.97,0 183.1,82.18 183.1,183.1l0 30.42 0 213.4zm-1230.2 -345.53l-0.89 -795.03 276.91 -53.68 0 526.07c55.74,-24.16 117.03,-37.74 181.5,-37.74l589.71 3.5c252.69,0 458.42,205.72 458.42,458.59l0 30.42 0 213.4 0 168.28c0,252.86 -205.73,458.54 -458.42,458.54l-589.71 -3.5c-252.7,0 -458.41,-205.67 -458.41,-458.56l0 -171.61 0 -240.48 0.89 -98.2z"/><path class="fil0" d="M3041.25 1150.84l0 204.28c0,100.93 -82.15,183.05 -183.11,183.05l-582.11 -3.46c-100.96,0 -183.11,-82.16 -183.11,-183.08l0 -67.42 1.48 0.24 0 -862.65 -276.91 53.68 0.12 103.41 -0.12 0.04 0 772.69c0,252.87 205.72,458.54 458.39,458.54l582.4 3.5c252.67,0 458.4,-205.68 458.4,-458.55l0 -70.79 0.71 0.12 0 -862.65 -276.91 53.68 0.76 675.35z"/></g></svg>"""
|
||||
LOGO_DATA_URI = "data:image/svg+xml;base64," + base64.b64encode(_LOGO_SVG.encode()).decode()
|
||||
|
||||
HTML = """<!DOCTYPE html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>Конвертер документов — Nubes</title>
|
||||
<link rel="icon" type="image/svg+xml" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' width='57' height='57' xml:space='preserve' overflow='hidden'><defs><clipPath id='c0'><rect x='652' y='420' width='64' height='75'/></clipPath><clipPath id='c1'><rect x='652' y='420' width='64' height='70'/></clipPath></defs><g clip-path='url(%23c0)' transform='matrix(1.01357 0 0 1.01357 -664.97 -440.306)'><g clip-path='url(%23c1)'><path d='m114.028 43.149v7.659c0 3.785-3.08 6.863-6.866 6.863h-21.825c-3.786 0-6.866-3.08-6.866-6.864v-2.528l0.056.009V15.815l-10.382 2.013 0.004 3.877v28.971c0 9.481 7.713 17.192 17.187 17.192l21.836.132c9.474 0 17.187-7.712 17.187-17.193v-2.654l0.027.004V15.815l-10.382 2.012 0.028 25.322z' fill='%23001c34' fill-rule='evenodd' transform='matrix(1 0 0 1.01337 587.92 420.059)'/></g></g></svg>">
|
||||
<style>
|
||||
:root{--brand-primary:#2563eb;--brand-primary-dark:#1d4ed8;--brand-grey-light:#f3f4f6;--brand-gray:#d1d5db;--nubes-dark:#001C34}
|
||||
*{box-sizing:border-box;margin:0;padding:0}
|
||||
body{font-family:system-ui,-apple-system,sans-serif;color:#1a1a1a;min-height:100vh;background:linear-gradient(135deg,#f0f4ff 0%,#fff 50%,#f0f4ff 100%)}
|
||||
.logo-bar{display:flex;align-items:center;gap:12px;padding:20px 24px}
|
||||
.logo{font-size:22px;font-weight:700;color:var(--nubes-dark);letter-spacing:-0.5px}
|
||||
.logo-bar .badge{background:var(--brand-primary);color:#fff;font-size:10px;padding:2px 8px;border-radius:10px;font-weight:600;letter-spacing:.5px}
|
||||
main{max-width:640px;margin:0 auto;padding:0 16px 40px}
|
||||
.card{background:#fff;border:1px solid var(--brand-gray);border-radius:12px;box-shadow:0 1px 3px rgba(0,0,0,.06);overflow:hidden;margin-bottom:20px}
|
||||
.card-header{background:var(--brand-grey-light);padding:12px 16px;font-size:14px;font-weight:600;color:#374151}
|
||||
.card-body{padding:16px}
|
||||
h1{font-size:22px;font-weight:700;margin-bottom:6px}
|
||||
.sub{color:#6b7280;font-size:14px;line-height:1.5;margin-bottom:20px}
|
||||
.api-info{font-size:13px;color:#6b7280;line-height:1.7}
|
||||
.api-info code{background:#eef2ff;color:var(--brand-primary);padding:2px 6px;border-radius:4px;font-size:12px;font-weight:500}
|
||||
.form-row{display:flex;gap:12px;align-items:center;flex-wrap:wrap}
|
||||
.file-label{flex:1;min-width:200px;display:block;border:2px dashed var(--brand-gray);border-radius:8px;padding:28px 16px;text-align:center;cursor:pointer;transition:border-color .2s;color:#6b7280;font-size:14px}
|
||||
.file-label:hover{border-color:var(--brand-primary)}
|
||||
.file-label.has-file{border-style:solid;border-color:var(--brand-primary);background:#eef2ff}
|
||||
.file-label input{display:none}
|
||||
.file-label .filename{color:#1a1a1a;font-weight:500;word-break:break-all}
|
||||
.btn{background:var(--brand-primary);color:#fff;border:none;padding:10px 28px;border-radius:8px;cursor:pointer;font-size:14px;font-weight:500;white-space:nowrap;transition:background .15s}
|
||||
.btn:hover{background:var(--brand-primary-dark)}
|
||||
.btn:disabled{opacity:.5;cursor:not-allowed}
|
||||
.result{margin-top:16px;padding:12px 16px;border-radius:8px;font-size:14px;display:none}
|
||||
.result.show{display:block}
|
||||
.result.ok{background:#ecfdf5;border:1px solid #a7f3d0;color:#065f46}
|
||||
.result.err{background:#fef2f2;border:1px solid #fecaca;color:#991b1b}
|
||||
.result a{color:var(--brand-primary);font-weight:600}
|
||||
.spinner{display:inline-block;width:14px;height:14px;border:2px solid #ccc;border-top-color:var(--brand-primary);border-radius:50%;animation:spin .8s linear infinite;margin-right:6px;vertical-align:middle}
|
||||
.footer{text-align:center;padding:0 16px 40px;font-size:12px;color:#9ca3af}
|
||||
@keyframes spin{to{transform:rotate(360deg)}}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="logo-bar">
|
||||
<img src="/static/logo.svg" alt="Nubes" style="height:28px">
|
||||
<span class="badge">КОНВЕРТЕР</span>
|
||||
<span class="badge" style="background:var(--brand-grey-light);color:var(--nubes-dark);">v__VERSION__</span>
|
||||
</div>
|
||||
<main>
|
||||
<h1>Конвертация старых документов Word</h1>
|
||||
<p class="sub">
|
||||
Загрузите файл в формате <strong>.doc</strong> (Microsoft Word 97–2003),
|
||||
<strong>.rtf</strong> или <strong>.odt</strong> — получите современный
|
||||
<strong>.docx</strong>. Конвертация происходит на сервере через LibreOffice,
|
||||
исходный файл никуда не сохраняется.
|
||||
</p>
|
||||
<div class="card">
|
||||
<div class="card-header">📦 Конвертировать файл</div>
|
||||
<div class="card-body">
|
||||
<form id="convForm">
|
||||
<div class="form-row">
|
||||
<label class="file-label" id="fileLabel">
|
||||
<span id="fileText">Выберите файл .doc, .rtf или .odt</span>
|
||||
<input type="file" name="file" accept=".doc,.rtf,.odt" required id="fileInput">
|
||||
</label>
|
||||
<button type="submit" class="btn" id="submitBtn">Конвертировать</button>
|
||||
</div>
|
||||
</form>
|
||||
<div class="result" id="result"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<div class="card-header">🔌 API для разработчиков</div>
|
||||
<div class="card-body">
|
||||
<div class="api-info">
|
||||
<strong>Адрес:</strong> <code>https://liberta.containerk8s.dev.nubes.ru/convert</code><br>
|
||||
<strong>Метод:</strong> <code>POST</code>, файл в <code>multipart/form-data</code>, поле <code>file</code>.<br>
|
||||
<strong>Успех:</strong> в ответ придёт готовый <code>.docx</code> — сразу сохраняйте.<br>
|
||||
<strong>Ошибка:</strong> <code>{"ok": false, "error": "..."}</code> с кодом 500.<br>
|
||||
<strong>Проверка:</strong> <code>GET /health</code> → <code>{"ok": true}</code><br><br>
|
||||
<strong>Пример curl:</strong><br>
|
||||
<code style="word-break:break-all">curl -X POST -F "file=@документ.doc" https://liberta.containerk8s.dev.nubes.ru/convert -o результат.docx</code>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
<footer class="footer">Nubes © 2026 · v__VERSION__</footer>
|
||||
<script>
|
||||
var f = document.getElementById('fileInput');
|
||||
var t = document.getElementById('fileText');
|
||||
var l = document.getElementById('fileLabel');
|
||||
f.onchange = function() {
|
||||
if (f.files.length) { t.className='filename'; t.textContent=f.files[0].name; l.classList.add('has-file'); }
|
||||
else { t.className=''; t.textContent='Выберите файл .doc, .rtf или .odt'; l.classList.remove('has-file'); }
|
||||
};
|
||||
document.getElementById('convForm').onsubmit = async function(e) {
|
||||
e.preventDefault();
|
||||
var b = document.getElementById('submitBtn'), r = document.getElementById('result');
|
||||
b.disabled = true; b.innerHTML = '<span class="spinner"></span>Идёт конвертация...';
|
||||
r.className = 'result'; r.innerHTML = '';
|
||||
try {
|
||||
var fd = new FormData(); fd.append('file', f.files[0]);
|
||||
var resp = await fetch('/convert', { method: 'POST', body: fd });
|
||||
if (resp.ok && resp.headers.get('Content-Type').includes('application/vnd')) {
|
||||
var blob = await resp.blob(), url = URL.createObjectURL(blob);
|
||||
var fn = f.files[0].name.replace(/\\.[^.]+$/,'') + '.docx';
|
||||
r.className = 'result ok show';
|
||||
r.innerHTML = '✅ Готово! <a href=\"'+url+'\" download=\"'+fn+'\">Скачать '+fn+'</a>';
|
||||
} else { var j = await resp.json();
|
||||
r.className = 'result err show'; r.textContent = '❌ ' + (j.error || 'Неизвестная ошибка'); }
|
||||
} catch(ex) { r.className = 'result err show'; r.textContent = '❌ Ошибка соединения: ' + ex.message; }
|
||||
b.disabled = false; b.textContent = 'Конвертировать';
|
||||
};
|
||||
</script>
|
||||
</body>
|
||||
</html>"""
|
||||
|
||||
def _convert_file(f):
|
||||
"""Convert one file to .docx, return (bytes, download_name) or raise."""
|
||||
with tempfile.NamedTemporaryFile(suffix=".doc", delete=False) as tmp:
|
||||
f.save(tmp)
|
||||
doc_path = tmp.name
|
||||
tmpdir = tempfile.mkdtemp()
|
||||
try:
|
||||
subprocess.run(
|
||||
["libreoffice", "--headless", "--convert-to", "docx", "--outdir", tmpdir, doc_path],
|
||||
timeout=120, capture_output=True, check=True
|
||||
)
|
||||
docx_files = [x for x in os.listdir(tmpdir) if x.endswith(".docx")]
|
||||
if not docx_files:
|
||||
raise ValueError("conversion produced no .docx")
|
||||
with open(os.path.join(tmpdir, docx_files[0]), "rb") as out:
|
||||
return out.read(), os.path.splitext(f.filename)[0] + ".docx"
|
||||
finally:
|
||||
if os.path.exists(doc_path):
|
||||
os.unlink(doc_path)
|
||||
for x in os.listdir(tmpdir):
|
||||
os.unlink(os.path.join(tmpdir, x))
|
||||
os.rmdir(tmpdir)
|
||||
|
||||
@app.route("/", methods=["GET"])
|
||||
def index():
|
||||
return render_template_string(HTML.replace("__VERSION__", VERSION))
|
||||
|
||||
@app.route("/convert", methods=["POST"])
|
||||
def convert():
|
||||
if "file" not in request.files:
|
||||
return {"ok": False, "error": "no file"}, 400
|
||||
f = request.files["file"]
|
||||
if not f.filename:
|
||||
return {"ok": False, "error": "empty filename"}, 400
|
||||
try:
|
||||
data, name = _convert_file(f)
|
||||
return send_file(
|
||||
io.BytesIO(data),
|
||||
mimetype="application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
||||
as_attachment=True,
|
||||
download_name=name,
|
||||
)
|
||||
except subprocess.TimeoutExpired:
|
||||
return {"ok": False, "error": "conversion timeout"}, 500
|
||||
except subprocess.CalledProcessError as e:
|
||||
err = e.stderr.decode() if e.stderr else "unknown"
|
||||
return {"ok": False, "error": f"libreoffice: {err}"}, 500
|
||||
except ValueError as e:
|
||||
return {"ok": False, "error": str(e)}, 500
|
||||
|
||||
@app.route("/health", methods=["GET"])
|
||||
def health():
|
||||
return {"ok": True, "version": VERSION}
|
||||
|
||||
@app.route("/static/logo.svg")
|
||||
def logo():
|
||||
from flask import Response
|
||||
return Response(_LOGO_SVG, mimetype="image/svg+xml")
|
||||
|
||||
if __name__ == "__main__":
|
||||
import io
|
||||
app.run(host="0.0.0.0", port=5000)
|
||||
@@ -0,0 +1 @@
|
||||
flask
|
||||
@@ -1,10 +0,0 @@
|
||||
Ntazetdinov@nubes.ru
|
||||
https://api.aillm.ru/
|
||||
sk-ucI5YvOticoOQ9Kuj5K9mQ
|
||||
|
||||
|
||||
Deepseek flash
|
||||
sk-78ec529c1eba4ba69995091046c9fa33
|
||||
|
||||
cicd
|
||||
eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJhdXRoLWFwaSIsInN1YiI6IjAxOTllMzI1LTFjZGYtN2NkYS05MzE5LWU1MzAyYTg1ZTI5MSIsImV4cCI6MTc5ODI3MTUyMSwiaWF0IjoxNzgyNzE5NTIxLCJqdGkiOiI5NjQ2MDlmYy05ZGZiLTQ1YjMtYjk0NS1lNmE0NmUzMTA0MzQiLCJhdXRoX3RpbWUiOjAsInR5cCI6IiIsImF6cCI6IiIsInNlc3Npb25fc3RhdGUiOiIiLCJhY3IiOiIiLCJhbGxvd2VkLW9yaWdpbnMiOm51bGwsInJlYWxtX2FjY2VzcyI6eyJyb2xlcyI6bnVsbH0sInJlc291cmNlX2FjY2VzcyI6eyJhY2NvdW50Ijp7InJvbGVzIjpudWxsfX0sInNjb3BlIjoiIiwic2lkIjoiIiwiZW1haWxfdmVyaWZpZWQiOmZhbHNlLCJuYW1lIjoiIiwiQ2xpZW50SUQiOiJXWjAxMzI1IiwiY29tcGFueV9pZCI6IjNlNjRhYWM2LWRjZmMtNDA4Mi04OGRjLWRhMTljODY1NTVhNSIsImNvbXBhbnlfbmFtZSI6ItCi0LXRgdGCIiwidG9rZW5fdHlwZSI6InRlY2giLCJpZHBfdXNyX3VpZCI6IjAxOTllMzI1LTFjZGYtN2NkYS05MzE5LWU1MzAyYTg1ZTI5MSIsImxvZ2luIjoidGF6ZXRAbmFyb2QucnUiLCJmaXJzdG5hbWUiOiLQndCw0LjQu9GMIiwibWlkZGxlbmFtZSI6ItCk0LDRgNC40YHQvtCy0LjRhyDQotC10YHRgtC-0LLQsNGPINGD0YfQtdGC0LrQsCIsImxhc3RuYW1lIjoi0KLQsNC30LXRgtC00LjQvdC-0LIiLCJncm91cHMiOm51bGwsInByZWZlcnJlZF91c2VybmFtZSI6IiIsImdpdmVuX25hbWUiOiIiLCJmYW1pbHlfbmFtZSI6IiIsImVtYWlsIjoidGF6ZXRAbmFyb2QucnUifQ.T2cSkKGlorUTr_ICpInwrZZ2Sqk_D-RHpibrj1VI-7Bg7CPvIKJ7n1QF9bJc9uqWH9cwQczrNsA8sROU3lnqUaa88hl_rMfP7UM_u8X_iG-_pKYD8tsckmmcos6keh2I9muSZ9Viy9LvLCZv3fY6nzMp2YT-KCQh-EDGZPgHSAToWs1uqiaKi99K-OcqckvaFNUsYbpLPVfnD_6UnDDKUmPjP4Ib24R4Z5qlmwUAxmgC6BfUcuqgk-2Mdj37ulWvdlBLd9ZoJv4jAvRffzclv2w-Qa8p3BEooC8wlZjTC3PU-ULR-Cd_N61Y31lkd953kkKE3_yGIQCbwCPYus1TiA
|
||||
Reference in New Issue
Block a user