25 changed files with 360 additions and 879 deletions
-3
View File
@@ -4,6 +4,3 @@ dogovora/
testgen/out/
testgen/out_100files/
contracts-flask/hz/
contractor-legacy/
llm.key
loadtest/
-91
View File
@@ -1,91 +0,0 @@
# Архитектура 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)`.
Слои 34 (in-memory session) — НЕ используются в contracts.
-61
View File
@@ -1,61 +0,0 @@
# Как проверить загрузку файлов в 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→…
-89
View File
@@ -1,89 +0,0 @@
# Как подключить конвертацию .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
-39
View File
@@ -1,39 +0,0 @@
# Как подключить лого 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`
@@ -1,62 +0,0 @@
# Вопрос для 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)?
@@ -1,116 +0,0 @@
# Вопрос для 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. Есть ли ещё что посмотреть в кластере чего мы не проверили?
@@ -1,70 +0,0 @@
# Вопрос для 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 из браузера?
@@ -1,40 +0,0 @@
# Вопрос для 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) — нет, при том что ОБА с одной машины через один и тот же интернет-канал?
@@ -1,49 +0,0 @@
# 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,18 @@
# Step 0: Критические фиксы перед миграцией
**Ветка:** `feature/flask-migration`
**Дата:** 2026-07-14
## Фикс 1: `site/` → `app/` (конфликт с Python stdlib)
`site` — встроенный модуль Python. `from site.db import ...` нестабильно.
Решение: `git mv contracts-flask/site contracts-flask/app`.
Импортов `from site ...` в коде НЕТ (проверено grep), только комментарий в app.js.
## Фикс 2: auth на `/api/cleanup`
Добавить `X-Api-Key` проверку. Значение из `os.environ["API_KEY"]`.
Оба фикса — ДО начала переноса кода.
+25
View File
@@ -0,0 +1,25 @@
# Step 1: Перенос db/ → app/db/
**Ветка:** `feature/flask-migration` (submodule + parent)
**Дата:** 2026-07-14
## Что делаем
Копируем `deploy/db/*.py``app/db/`. Менять НИЧЕГО не надо — чистый CRUD.
## Файлы
- `deploy/db/connection.py``app/db/connection.py` — connection pool
- `deploy/db/documents.py``app/db/documents.py` — documents CRUD
- `deploy/db/contracts.py``app/db/contracts.py` — contracts CRUD
- `deploy/db/supplements.py``app/db/supplements.py` — supplements CRUD
- `deploy/db/spec_current.py``app/db/spec_current.py` — spec queries
- `deploy/db/spec_events.py``app/db/spec_events.py` — events CRUD
- `deploy/db/prompts.py``app/db/prompts.py` — prompts CRUD
## Проверка
```bash
cd contracts-flask
python3 -c "from app.db import documents; print('ok')"
```
@@ -0,0 +1,32 @@
# Step 2: Перенос compare/ → app/services/
**Ветка:** `feature/flask-migration`
**Дата:** 2026-07-14
## Что делаем
Копируем бизнес-логику из `deploy/compare/*.py``app/services/`.
## Переносим (чистая логика, без HTTP)
- `parse.py` — парсинг PDF/DOCX
- `classify.py` — LLM-классификация
- `grouping.py` — группировка документов
- `llm.py` — вызов LLM для сравнения
- `llm_client.py` — протокол + httpx + fake
- `metrics.py` — проверка арифметики
## НЕ переносим (привязаны к HTTP, будут переписаны в blueprints)
- `upload.py` — использует cgi.FieldStorage
- `unzip.py` — читает rfile.read()
- `process.py` — SSE через callback
## Правки импортов
Было → стало:
- `from db import documents``from app.db import documents`
- `from .parse import parse_file``from app.services.parse import parse_file`
- `from llm_prompt import ...``from app.llm_prompt import ...`
- `from .llm_client import ...``from app.services.llm_client import ...`
- `from .metrics import ...``from app.services.metrics import ...`
@@ -0,0 +1,17 @@
# Step 3: config.py + все blueprints + новый app.py
**Ветка:** `feature/flask-migration`
**Дата:** 2026-07-14
## Создаваемые файлы
- `app/config.py` — настройки
- `app/routes/__init__.py` — register_routes()
- `app/routes/upload_bp.py` — /upload, /convert-doc, /unzip-upload
- `app/routes/pipeline_bp.py` — /process-v2 (SSE), /classify-batch
- `app/routes/api_bp.py` — /api/* (groups, documents, supplements, sync, cleanup, spec-current)
- `app/routes/prompts_bp.py` — /api/prompts/*
- `app/routes/health_bp.py` — /health
- `app/routes/pages_bp.py` — /, /architect
- `app/services/process.py` — адаптированный process.py (callback → generator)
- `app/app.py` — новый (замена старого, без VM-прокси)
-26
View File
@@ -1,26 +0,0 @@
# 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
+253
View File
@@ -0,0 +1,253 @@
# Инструкция: перенос UI и загрузки файлов с ВМ на Flask (Штурвал)
**Дата:** 2026-07-14
**Основано на:** drhider v0.0.26 — проверено в бою
---
## Архитектура загрузки (drhider как образец)
```
Браузер (index.html)
│ JS: uploadFiles() — цикл по файлам
│ XHR POST /api/upload (FormData, по одному файлу)
Flask (api_bp.py)
│ upload(): сохраняет в сессию → {session_id}
│ process_stream(): SSE — обработка с прогрессом
Python (drhider/*.py)
│ obfuscate_files() — основная логика
Flask → ZIP + CSV → браузеру
```
## Файлы, отвечающие за UI и загрузку
### 1. `site/templates/index.html` — весь фронтенд
**Ключевые элементы:**
```html
<!-- Файловый инпут — множественный выбор -->
<input type="file" id="fileInput" multiple accept=".docx,.pdf,.txt,.zip">
<!-- Таблица выбранных файлов -->
<table>
<tbody id="fileList"></tbody>
</table>
<!-- Статус-бар -->
<div class="status" id="status"></div>
<!-- Кнопки скачивания (скрыты до готовности) -->
<div class="dl-btns" id="dlBtns">
<button onclick="downloadZip()">📦 Скачать ZIP</button>
<button onclick="downloadCsv()">📋 Скачать CSV</button>
</div>
```
**Ключевые JS-функции:**
| Функция | Что делает |
|---------|------------|
| `resetAll()` | Сброс всего состояния (F5, между загрузками) |
| `rr()` | Перерисовка таблицы файлов |
| `uploadFiles()` | **Главная** — цикл загрузки + SSE-обработка |
| `ss(idx, html)` | Обновление ячейки статуса в таблице |
| `downloadZip()` / `downloadCsv()` | Скачивание результатов |
**Критичные исправления (v0.0.26):**
```javascript
// 1. Прогрев upstream при загрузке страницы — обязательно!
window.addEventListener('load', () => {
sf = []; fi.value = ''; rr();
fetch('/health').catch(() => {}); // ← вот это
});
// 2. Abort предыдущих запросов перед новым
let activeES = null; // активный EventSource
let activeXHR = null; // активный XHR
// 3. Защита от F5 во время загрузки
window.addEventListener('beforeunload', () => resetAll());
// 4. Сохранение списка файлов до очистки
const files = sf.slice(); // копия перед resetAll()
// 5. Цикл загрузки (по одному файлу)
for (let i = 0; i < total; i++) {
const fd = new FormData();
fd.append('files', f, f.name);
if (currentSid) fd.append('session', currentSid);
const resp = await fetch('/api/upload?_=' + Date.now(), {
method: 'POST', body: fd
});
const data = await resp.json();
currentSid = data.session;
}
// 6. SSE-обработка
activeES = new EventSource('/api/process_stream/' + currentSid);
activeES.addEventListener('start', ...); // файл начат
activeES.addEventListener('done', ...); // файл готов
activeES.addEventListener('complete', ...); // всё готово
```
### 2. `site/routes/api_bp.py` — API эндпоинты
```
POST /api/upload — загрузка одного файла → {session_id}
GET /api/process_stream/<sid> — SSE: обработка с прогрессом
POST /api/process/<sid> — обработка без SSE (legacy)
GET /api/download/<sid> — скачать ZIP
GET /api/csv/<sid> — скачать CSV отдельно
```
**Критичные заголовки для SSE:**
```python
return Response(
stream_with_context(generate()),
content_type="text/event-stream",
headers={
"Cache-Control": "no-cache",
"X-Accel-Buffering": "no" # ← без этого nginx буферизует SSE
}
)
```
**Критично: GeneratorExit в SSE-генераторе:**
```python
def generate():
for idx, (fname, content) in enumerate(files):
try:
yield f"event: start\ndata: ...\n\n"
except GeneratorExit:
return # клиент отключился — не обрабатываем дальше
# ... обработка файла ...
try:
yield f"event: done\ndata: ...\n\n"
except GeneratorExit:
return
```
### 3. `site/routes/health_bp.py` — liveness probe
```python
@health_bp.route("/health")
def health():
return jsonify({"ok": True, "version": "0.0.1"})
```
**Обязательно для Штурвала.**
### 4. `site/app.py` — точка входа
**Критичные настройки:**
```python
VERSION = "0.0.1"
def create_app():
app = Flask(__name__)
app.config["VERSION"] = VERSION
app.config["MAX_CONTENT_LENGTH"] = 200 * 1024 * 1024 # 200 MB
from routes import register_routes
register_routes(app)
@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()
```
---
## Пошаговая инструкция миграции
### Шаг 1: Скопировать скелет UI
Скопировать `site/templates/index.html` из drhider как основу.
Заменить:
- Заголовок (`<title>`, `.title`, `.card-header`)
- Текст описания
- `accept` в `<input type="file">` если другие форматы
- Логику в `uploadFiles()` — вызов своего API вместо `obfuscate_files`
### Шаг 2: Адаптировать API
Скопировать `site/routes/api_bp.py`, заменить:
- Название blueprint'а
- Логику в `process_stream()` — вызов своей функции вместо `obfuscate_files()`
- Формат SSE-событий если нужен другой
### Шаг 3: Health endpoint
Скопировать `site/routes/health_bp.py` как есть. Только версию поменять.
### Шаг 4: app.py
Скопировать `site/app.py`, заменить:
- `VERSION`
- `MAX_CONTENT_LENGTH` если нужен другой лимит
- Импорт своих blueprint'ов
### Шаг 5: session.py
Скопировать `site/session.py` как есть. Это in-memory хранилище загруженных файлов и результатов.
### Шаг 6: Интеграция бизнес-логики
Твоя функция обработки должна принимать тот же интерфейс что и `obfuscate_files`:
```python
def твоя_функция(files, **kwargs):
"""
Args:
files: list of (filename: str, content: bytes, mimetype: str)
Returns:
zip_bytes: bytes — ZIP-архив с результатами
csv_str: str — CSV с маппингом (или "")
"""
```
---
## Чек-лист перед деплоем
- [ ] `/health` возвращает `{"ok": true}`
- [ ] `MAX_CONTENT_LENGTH` достаточен для файлов
- [ ] `no_cache` after_request есть
- [ ] `X-Accel-Buffering: no` на SSE-эндпоинте
- [ ] `GeneratorExit` в каждом `yield` SSE-генератора
- [ ] `stream_with_context` оборачивает генератор
- [ ] `fetch('/health')` при загрузке страницы
- [ ] `beforeunload``resetAll()`
- [ ] `activeXHR` и `activeES` очищаются перед новым запуском
- [ ] `Connection: close`**НЕ ставить** (Waitress/PEP 3333 запрещает hop-by-hop)
---
## Ссылки на файлы-образцы
| Что | Где в drhider |
|-----|---------------|
| HTML/JS/CSS фронтенд | `site/templates/index.html` |
| API (upload + SSE + download) | `site/routes/api_bp.py` |
| Health probe | `site/routes/health_bp.py` |
| Точка входа Flask | `site/app.py` |
| In-memory сессии | `site/session.py` |
| Полная архитектура | `docs/ARCHITECTURE.md` |
| Решённые проблемы кластера | `PROBLEM-AND-SOLUTION.md` |
+3 -27
View File
@@ -7,37 +7,13 @@
Разбирать спецификации договоров и допников (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-flask/` | Основной сервис (Flask + SQLite, managed на Nubes) |
| `contractor-legacy/` | Старый Lucee/CFML-сервис (архив) |
| `convert-service/` | Микросервис конвертации doc → docx |
| `DOC/` | Документация: архитектура, тестирование |
| `History/` | История решений, ревью, протоколы |
| `FILES/` | Заметки, ключи LLM, ТЗ |
| `contracts-app/` | Flask-сервис (основной код) |
| `dogovora/` | Примеры договоров и допников |
| `loadtest/` | Нагрузочное тестирование |
| `sim/` | Симуляция сценариев |
| `testgen/` | Генерация тестовых кейсов |
| `hz/` | Разные заметки |
| `FILES/` | Заметки, ключи LLM |
## Конфиденциальность
Submodule
+1
Submodule contractor added at c56b980501
Submodule contracts-flask updated: 87524c54a4...96718fc065
-14
View File
@@ -1,14 +0,0 @@
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"]
-190
View File
@@ -1,190 +0,0 @@
#!/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 972003),
<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)
-1
View File
@@ -1 +0,0 @@
flask
+10
View File
@@ -0,0 +1,10 @@
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