Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ba10816230 | ||
|
|
119d09beb7 | ||
|
|
71df83bc63 | ||
|
|
2ba92208a5 | ||
|
|
0ec26a2b8c | ||
|
|
21e5473045 | ||
|
|
69e69c7534 |
@@ -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,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,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"]`.
|
||||
|
||||
Оба фикса — ДО начала переноса кода.
|
||||
@@ -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-прокси)
|
||||
@@ -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` |
|
||||
+1
-1
Submodule contracts-flask updated: ca7b70fe41...96718fc065
Reference in New Issue
Block a user