Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ba10816230 | ||
|
|
119d09beb7 | ||
|
|
71df83bc63 | ||
|
|
2ba92208a5 | ||
|
|
0ec26a2b8c |
@@ -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