Compare commits
40
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
55bd7a027d | ||
|
|
37ea5e2c31 | ||
|
|
78045f2c81 | ||
|
|
4653aa5e8e | ||
|
|
956aed0971 | ||
|
|
3b2e576284 | ||
|
|
de05d746cc | ||
|
|
c677206d03 | ||
|
|
d1415d5d21 | ||
|
|
5d8bcd61ea | ||
|
|
6334ca2e3e | ||
|
|
58bc80371d | ||
|
|
a15c3e8e94 | ||
|
|
f4bae5a6b9 | ||
|
|
4f9bee4c1e | ||
|
|
2d5606d6d7 | ||
|
|
cc3a53a229 | ||
|
|
1bd62a51ef | ||
|
|
2fd5f2944f | ||
|
|
b5d97bdd98 | ||
|
|
ae8f6819fc | ||
|
|
04cb3f5bfe | ||
|
|
e4c010acd3 | ||
|
|
b8340a0637 | ||
|
|
9e22182f9a | ||
|
|
abedffe4d0 | ||
|
|
11015fd55b | ||
|
|
ea92730bd8 | ||
|
|
d8d53661d9 | ||
|
|
49dd873db3 | ||
|
|
32660d2590 | ||
|
|
f176ddad71 | ||
|
|
aafb038921 | ||
|
|
f9669755cd | ||
|
|
15137f8e94 | ||
|
|
85a958e2aa | ||
|
|
0e8a8eef66 | ||
|
|
cd77e7d709 | ||
|
|
81aba97304 | ||
|
|
a7b53fde19 |
@@ -0,0 +1,71 @@
|
|||||||
|
# Сессия 2026-08-17 — HTTP 502 при парсинге 19 МБ PDF = OOMKill пода
|
||||||
|
|
||||||
|
_Стенд: ТЕСТ, `contractor.pythonk8s.dev.nubes.ru` (приставка `dev.nubes.ru` общая для dev+test).
|
||||||
|
instanceUid: `b4523aba-b5e6-40f1-be56-bb4d2509357c`, realm `iot-naeel`, domain `contractor`._
|
||||||
|
|
||||||
|
## 1. Симптом (из UI, колонка «Парсинг»)
|
||||||
|
|
||||||
|
Файл `0144-03-2023_отчет об оценке.pdf` (19.0 МБ) в списке **дважды**:
|
||||||
|
- первый проход: **✗ HTTP 502**;
|
||||||
|
- повторный: **✓ 4083 эл. (0.0с)**.
|
||||||
|
|
||||||
|
Остальные файлы (623 КБ, 473 КБ, 596 КБ) парсились нормально.
|
||||||
|
Вывод: загрузка файла проходит, падает **парсинг** (тяжёлая операция в запросе).
|
||||||
|
|
||||||
|
## 2. Диагностика kubectl (с ВМ remote-dev = 5.172.178.213)
|
||||||
|
|
||||||
|
```
|
||||||
|
kubectl get pods -n b4523aba-...
|
||||||
|
pythonk8s-589db8db9c-qjjnc 1/1 Running 1 (5m18s ago) 17m
|
||||||
|
```
|
||||||
|
|
||||||
|
`kubectl describe pod`:
|
||||||
|
```
|
||||||
|
Last State: Terminated
|
||||||
|
Reason: OOMKilled
|
||||||
|
Exit Code: 137
|
||||||
|
Restart Count: 1
|
||||||
|
Limits: cpu: 1, memory: 1Gi
|
||||||
|
Requests: cpu: 1, memory: 1Gi
|
||||||
|
```
|
||||||
|
|
||||||
|
`kubectl top pod` (текущий под, простое): **727Mi из 1Gi (~71%)**.
|
||||||
|
|
||||||
|
События:
|
||||||
|
```
|
||||||
|
17m Killing pod/pythonk8s-5895c478b5-7dcqq — Stopping container app
|
||||||
|
```
|
||||||
|
(убитый OOM-ом под; текущий `pythonk8s-589db8db9c-qjjnc` — новый).
|
||||||
|
|
||||||
|
Логи прежнего контейнера (`--previous`): обычные запросы (health, batch-progress,
|
||||||
|
apply-groups, process-v2, cleanup), обрыв на 14:13:14 → OOM.
|
||||||
|
|
||||||
|
## 3. Вывод (по фактам)
|
||||||
|
|
||||||
|
**HTTP 502 = OOMKill пода (exit 137).** Синхронный парсинг 19 МБ PDF
|
||||||
|
(`site/routes/upload_bp.py`: `parse_file` → `set_parsed` в том же запросе,
|
||||||
|
pdfplumber → 4083 элемента) превышает лимит памяти **1Gi** → kubelet убивает под →
|
||||||
|
шлюз не получает ответ → 502. После рестарта повторный парсинг проходит.
|
||||||
|
|
||||||
|
- **Это НЕ** проблема сети/размера тела (как 64KB на `services`), а **нехватка памяти**.
|
||||||
|
- Базовое потребление уже ~71% лимита, парсинг большого PDF добивает под.
|
||||||
|
|
||||||
|
## 4. Связь с прежними находками
|
||||||
|
|
||||||
|
| Симптом | Причина | Платформа |
|
||||||
|
|---|---|---|
|
||||||
|
| «64KB / HTTP 000, Сеть» (исторически) | ddos-guard + таймауты Ingress + медленная обработка | `pythonk8s.services.ngcloud.ru` |
|
||||||
|
| HTTP 502 на 19 МБ PDF (сейчас) | OOMKill: лимит 1Gi, синхронный парсинг | `pythonk8s.dev.nubes.ru` (ТЕСТ) |
|
||||||
|
|
||||||
|
## 5. Варианты решения (НЕ ВНЕСЕНЫ, ждут команды)
|
||||||
|
|
||||||
|
1. Поднять память инстанса: `clusterConfiguration.memory` 1024 → 2048 (быстрый обходной путь).
|
||||||
|
2. Убрать синхронный парсинг из `/upload` — парсинг в фон (thread / отдельный endpoint),
|
||||||
|
ответ сразу; снижает пик памяти в запросе (правильный фикс).
|
||||||
|
3. Оба варианта.
|
||||||
|
|
||||||
|
## 6. Статус (следующий шаг)
|
||||||
|
|
||||||
|
Пользователь планирует **редеплой на обычном облачном кластере со стандартными настройками**
|
||||||
|
(не на своём `iot-naeel`) — проверить, как ведёт себя парсинг 19 МБ при стандартных лимитах.
|
||||||
|
Результат сравнить с данным диагностикой.
|
||||||
@@ -92,6 +92,22 @@ server {
|
|||||||
client_max_body_size 100m;
|
client_max_body_size 100m;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# ── VM-буфер загрузки contracts (паттерн drhider) ──────────────
|
||||||
|
# Браузер кладёт файл сюда PUT (мимо шлюза кластера ~64КБ), бэк тянет сам.
|
||||||
|
# Файлы: /var/www/contracts-upload/ (нужно создать, chown www-data).
|
||||||
|
# CORS: origin фронтенда = contractor.pythonk8s.dev.nubes.ru (ingress, подтверждено).
|
||||||
|
location /contracts-upload/ {
|
||||||
|
alias /var/www/contracts-upload/;
|
||||||
|
dav_methods PUT DELETE;
|
||||||
|
create_full_put_path on;
|
||||||
|
client_max_body_size 1024m;
|
||||||
|
add_header Access-Control-Allow-Origin https://contractor.pythonk8s.dev.nubes.ru always;
|
||||||
|
add_header Access-Control-Allow-Methods 'PUT, GET, OPTIONS, DELETE' always;
|
||||||
|
add_header Access-Control-Allow-Headers 'Content-Type' always;
|
||||||
|
add_header Access-Control-Max-Age 86400 always;
|
||||||
|
if ($request_method = OPTIONS) { return 204; }
|
||||||
|
}
|
||||||
|
|
||||||
listen 443 ssl; # managed by Certbot
|
listen 443 ssl; # managed by Certbot
|
||||||
ssl_certificate /etc/letsencrypt/live/contracts.kube5s.ru/fullchain.pem; # managed by Certbot
|
ssl_certificate /etc/letsencrypt/live/contracts.kube5s.ru/fullchain.pem; # managed by Certbot
|
||||||
ssl_certificate_key /etc/letsencrypt/live/contracts.kube5s.ru/privkey.pem; # managed by Certbot
|
ssl_certificate_key /etc/letsencrypt/live/contracts.kube5s.ru/privkey.pem; # managed by Certbot
|
||||||
|
|||||||
@@ -3,4 +3,3 @@ gunicorn
|
|||||||
httpx
|
httpx
|
||||||
python-docx
|
python-docx
|
||||||
pdfplumber
|
pdfplumber
|
||||||
psycopg2-binary
|
|
||||||
|
|||||||
+14
-33
@@ -1,9 +1,13 @@
|
|||||||
"""contracts-flask v2.0 — полный перенос с ВМ на Flask.
|
"""contracts-flask v2.0 — полный перенос с ВМ на Flask.
|
||||||
Больше никаких прокси на contracts.kube5s.ru — всё локально.
|
Больше никаких прокси на contracts.kube5s.ru — всё локально.
|
||||||
"""
|
"""
|
||||||
|
import sys, os
|
||||||
|
# site/ в sys.path — импортируем модули напрямую, без префиксов
|
||||||
|
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||||
|
|
||||||
from flask import Flask
|
from flask import Flask
|
||||||
from site.config import VERSION, MAX_CONTENT_LENGTH
|
from config import VERSION, MAX_CONTENT_LENGTH
|
||||||
from site.routes import register_routes
|
from routes import register_routes
|
||||||
|
|
||||||
|
|
||||||
def create_app():
|
def create_app():
|
||||||
@@ -11,13 +15,9 @@ def create_app():
|
|||||||
app.config["VERSION"] = VERSION
|
app.config["VERSION"] = VERSION
|
||||||
app.config["MAX_CONTENT_LENGTH"] = MAX_CONTENT_LENGTH
|
app.config["MAX_CONTENT_LENGTH"] = MAX_CONTENT_LENGTH
|
||||||
|
|
||||||
# DB auto-seed — schema migration + seed prompts + crash recovery
|
|
||||||
_init_db()
|
_init_db()
|
||||||
|
|
||||||
# Регистрация всех blueprint'ов
|
|
||||||
register_routes(app)
|
register_routes(app)
|
||||||
|
|
||||||
# no_cache на все ответы
|
|
||||||
@app.after_request
|
@app.after_request
|
||||||
def no_cache(response):
|
def no_cache(response):
|
||||||
response.headers["Cache-Control"] = "no-cache, no-store, must-revalidate"
|
response.headers["Cache-Control"] = "no-cache, no-store, must-revalidate"
|
||||||
@@ -29,36 +29,17 @@ def create_app():
|
|||||||
|
|
||||||
|
|
||||||
def _init_db():
|
def _init_db():
|
||||||
"""Schema migration + recovery: сброс застрявших classify."""
|
"""Создать БД + схему + seed prompts. SQLite — всё в одном файле /tmp."""
|
||||||
|
from db.connection import init_db
|
||||||
|
init_db()
|
||||||
try:
|
try:
|
||||||
from site.db.connection import execute
|
from db import prompts as db_prompts
|
||||||
|
|
||||||
for sql in [
|
|
||||||
"ALTER TABLE documents ADD COLUMN IF NOT EXISTS classify_raw text",
|
|
||||||
"ALTER TABLE documents ADD COLUMN IF NOT EXISTS classify_input text",
|
|
||||||
"ALTER TABLE documents ADD COLUMN IF NOT EXISTS zip_source text",
|
|
||||||
"ALTER TABLE documents ADD COLUMN IF NOT EXISTS content_hash text",
|
|
||||||
"ALTER TABLE documents ADD COLUMN IF NOT EXISTS batch_id uuid",
|
|
||||||
"ALTER TABLE documents ADD COLUMN IF NOT EXISTS classify_status text DEFAULT 'pending'",
|
|
||||||
"ALTER TABLE documents ALTER COLUMN classify_status SET DEFAULT 'pending'",
|
|
||||||
"ALTER TABLE documents ADD COLUMN IF NOT EXISTS doc_type text",
|
|
||||||
"ALTER TABLE documents ADD COLUMN IF NOT EXISTS own_number text",
|
|
||||||
"ALTER TABLE documents ADD COLUMN IF NOT EXISTS parent_number text",
|
|
||||||
"ALTER TABLE documents ADD COLUMN IF NOT EXISTS doc_date date",
|
|
||||||
"ALTER TABLE documents ADD COLUMN IF NOT EXISTS counterparty text",
|
|
||||||
]:
|
|
||||||
execute(sql)
|
|
||||||
|
|
||||||
from site.db import prompts as db_prompts
|
|
||||||
db_prompts.seed_defaults()
|
db_prompts.seed_defaults()
|
||||||
|
|
||||||
# Crash recovery: сбросить застрявшие classify
|
|
||||||
execute(
|
|
||||||
"UPDATE documents SET classify_status='pending', error_message=NULL "
|
|
||||||
"WHERE classify_status='processing'"
|
|
||||||
)
|
|
||||||
except Exception:
|
except Exception:
|
||||||
pass # БД недоступна при старте — не фатально
|
pass
|
||||||
|
|
||||||
|
|
||||||
app = create_app()
|
app = create_app()
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
app.run(host="0.0.0.0", port=5000, threaded=True)
|
||||||
|
|||||||
+14
-9
@@ -1,15 +1,7 @@
|
|||||||
"""Конфигурация приложения — все настройки в одном месте."""
|
"""Конфигурация приложения — все настройки в одном месте."""
|
||||||
import os
|
import os
|
||||||
|
|
||||||
VERSION = "2.0.0"
|
VERSION = "2.0.11"
|
||||||
|
|
||||||
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_URL = os.getenv("LLM_API_URL", "https://api.aillm.ru/v1/chat/completions")
|
||||||
LLM_KEY = os.getenv("LLM_API_KEY", "")
|
LLM_KEY = os.getenv("LLM_API_KEY", "")
|
||||||
@@ -17,3 +9,16 @@ LLM_MODEL = os.getenv("LLM_MODEL", "gpt-oss-120b")
|
|||||||
|
|
||||||
MAX_CONTENT_LENGTH = 200 * 1024 * 1024 # 200 MB
|
MAX_CONTENT_LENGTH = 200 * 1024 * 1024 # 200 MB
|
||||||
API_KEY = os.getenv("API_KEY", "")
|
API_KEY = os.getenv("API_KEY", "")
|
||||||
|
CONVERT_SERVICE_URL = os.getenv("CONVERT_SERVICE_URL", "http://containerk8s.df36c8af-1a95-4623-b551-0d37b731ccca.svc.cluster.local:5000")
|
||||||
|
|
||||||
|
# ── VM-буфер загрузки (паттерн drhider) ──────────────────────────────
|
||||||
|
# Браузер кладёт файл на ВМ через WebDAV (мимо шлюза кластера ~64КБ),
|
||||||
|
# бэк сам тянет его исходящим GET (egress без лимита).
|
||||||
|
VM_UPLOAD_URL = os.getenv("VM_UPLOAD_URL", "https://contracts.kube5s.ru/contracts-upload/")
|
||||||
|
# SSRF-защита: тянуть можно ТОЛЬКО с этого префикса.
|
||||||
|
VM_UPLOAD_PREFIX = os.getenv("VM_UPLOAD_PREFIX", "https://contracts.kube5s.ru/contracts-upload/")
|
||||||
|
# Лимит на один файл (совпадает с фронтом).
|
||||||
|
VM_UPLOAD_MAX_BYTES = int(os.getenv("VM_UPLOAD_MAX_BYTES", str(50 * 1024 * 1024)))
|
||||||
|
# Ретраи pull с ВМ (разовые DNS/сетевые сбои не роняют загрузку).
|
||||||
|
PULL_RETRIES = int(os.getenv("PULL_RETRIES", "3"))
|
||||||
|
PULL_RETRY_DELAY = 2.0
|
||||||
|
|||||||
+213
-76
@@ -1,91 +1,228 @@
|
|||||||
"""
|
"""Database connection — SQLite with thread-local connections.
|
||||||
Database connection — psycopg2 connection pool.
|
|
||||||
|
|
||||||
Архитектурное решение (Opus):
|
Архитектура (Sonnet review 2026-07-15):
|
||||||
- ThreadedConnectionPool(minconn=1, maxconn=10) — оптимально для ThreadingMixIn HTTP-сервера.
|
- threading.local() — каждому потоку своё соединение
|
||||||
Каждый HTTP-запрос в отдельном потоке получает своё соединение из пула.
|
- WAL-режим — readers не блокируют writer
|
||||||
- RealDictCursor для query() — возвращает dict с lowercase ключами (консистентно с Python API).
|
- _db_session_key — защита от inode split-brain при cleanup+reuse потоков
|
||||||
- execute()/execute_returning() — для INSERT/UPDATE/DELETE с автокоммитом и откатом при ошибке.
|
- busy_timeout=5000 — ждать при конкурентной записи
|
||||||
- Пул создаётся лениво (при первом запросе) через get_pool().
|
|
||||||
"""
|
"""
|
||||||
|
import sqlite3
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
import os
|
import os
|
||||||
import psycopg2
|
|
||||||
import psycopg2.pool
|
|
||||||
import psycopg2.extras
|
|
||||||
|
|
||||||
# Глобальный пул соединений (singleton)
|
DB_PATH = "/tmp/contracts.db"
|
||||||
_pool = None
|
_db_session_key = None
|
||||||
|
_local = threading.local()
|
||||||
# Параметры подключения из переменных окружения (systemd Environment)
|
|
||||||
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", ""),
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def get_pool():
|
def init_db():
|
||||||
"""Возвращает глобальный пул соединений. Создаёт при первом вызове."""
|
"""Создать/пересоздать БД + схему. Вызывается при старте и после cleanup."""
|
||||||
global _pool
|
global _db_session_key
|
||||||
if _pool is None:
|
_db_session_key = time.time()
|
||||||
_pool = psycopg2.pool.ThreadedConnectionPool(
|
|
||||||
minconn=1, maxconn=10, **DB_CONFIG
|
# Удалить старый файл + WAL-сателлиты
|
||||||
)
|
for f in (DB_PATH, DB_PATH + "-wal", DB_PATH + "-shm"):
|
||||||
return _pool
|
try:
|
||||||
|
os.remove(f)
|
||||||
|
except FileNotFoundError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
conn = get_conn()
|
||||||
|
_create_schema(conn)
|
||||||
|
return conn
|
||||||
|
|
||||||
|
|
||||||
|
def _create_schema(conn):
|
||||||
|
"""Создать все таблицы (idempotent)."""
|
||||||
|
conn.executescript("""
|
||||||
|
CREATE TABLE IF NOT EXISTS documents (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
filename TEXT NOT NULL,
|
||||||
|
mime_type TEXT DEFAULT 'application/octet-stream',
|
||||||
|
original_bytes TEXT,
|
||||||
|
status TEXT DEFAULT 'uploaded',
|
||||||
|
error_message TEXT,
|
||||||
|
elements_json TEXT,
|
||||||
|
batch_id TEXT,
|
||||||
|
zip_source TEXT,
|
||||||
|
content_hash TEXT,
|
||||||
|
classify_status TEXT DEFAULT 'pending',
|
||||||
|
doc_type TEXT,
|
||||||
|
own_number TEXT,
|
||||||
|
parent_number TEXT,
|
||||||
|
doc_date TEXT,
|
||||||
|
counterparty TEXT,
|
||||||
|
classify_raw TEXT,
|
||||||
|
classify_input TEXT,
|
||||||
|
created_at TEXT DEFAULT (datetime('now'))
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS contracts (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
number TEXT NOT NULL,
|
||||||
|
client TEXT DEFAULT '',
|
||||||
|
created_at TEXT DEFAULT (datetime('now'))
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS supplements (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
contract_id TEXT NOT NULL REFERENCES contracts(id),
|
||||||
|
document_id TEXT NOT NULL REFERENCES documents(id),
|
||||||
|
type TEXT DEFAULT 'additional',
|
||||||
|
created_at TEXT DEFAULT (datetime('now'))
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS spec_events (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
contract_id TEXT NOT NULL,
|
||||||
|
supplement_id TEXT NOT NULL,
|
||||||
|
seq INTEGER NOT NULL DEFAULT 0,
|
||||||
|
action TEXT NOT NULL DEFAULT 'UNRESOLVED',
|
||||||
|
target_hash TEXT DEFAULT '',
|
||||||
|
new_values TEXT DEFAULT '{}',
|
||||||
|
comment TEXT DEFAULT '',
|
||||||
|
status TEXT DEFAULT 'unresolved',
|
||||||
|
prompt_version TEXT DEFAULT '',
|
||||||
|
source_document_id TEXT DEFAULT '',
|
||||||
|
raw_llm_response TEXT DEFAULT '{}',
|
||||||
|
created_at TEXT DEFAULT (datetime('now'))
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS spec_current (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
contract_id TEXT NOT NULL,
|
||||||
|
name_hash TEXT NOT NULL,
|
||||||
|
name TEXT,
|
||||||
|
price REAL,
|
||||||
|
qty REAL,
|
||||||
|
sum REAL,
|
||||||
|
date_start TEXT,
|
||||||
|
last_event_id TEXT,
|
||||||
|
updated_at TEXT DEFAULT (datetime('now')),
|
||||||
|
created_at TEXT DEFAULT (datetime('now'))
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS prompts (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
role TEXT NOT NULL,
|
||||||
|
name TEXT DEFAULT '',
|
||||||
|
body TEXT NOT NULL,
|
||||||
|
is_active INTEGER DEFAULT 0,
|
||||||
|
notes TEXT,
|
||||||
|
created_at TEXT DEFAULT (datetime('now'))
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS upload_chunks (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
upload_id TEXT NOT NULL,
|
||||||
|
chunk_index INTEGER NOT NULL,
|
||||||
|
data TEXT,
|
||||||
|
created_at TEXT DEFAULT (datetime('now'))
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_documents_batch ON documents(batch_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_documents_hash ON documents(batch_id, content_hash);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_supplements_contract ON supplements(contract_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_spec_current_contract ON spec_current(contract_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_spec_events_supplement ON spec_events(supplement_id);
|
||||||
|
""")
|
||||||
|
|
||||||
|
|
||||||
|
def cleanup_db():
|
||||||
|
"""Удалить БД полностью. Вызывает init_db() для создания новой."""
|
||||||
|
init_db()
|
||||||
|
|
||||||
|
|
||||||
|
def get_conn():
|
||||||
|
"""Thread-local соединение. Пересоздаётся при смене сессии."""
|
||||||
|
global _db_session_key
|
||||||
|
|
||||||
|
conn = getattr(_local, "conn", None)
|
||||||
|
local_key = getattr(_local, "session_key", None)
|
||||||
|
|
||||||
|
if conn is None or local_key != _db_session_key:
|
||||||
|
if conn is not None:
|
||||||
|
try:
|
||||||
|
conn.close()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
conn = sqlite3.connect(DB_PATH, timeout=5, check_same_thread=False)
|
||||||
|
conn.row_factory = sqlite3.Row
|
||||||
|
conn.execute("PRAGMA journal_mode=WAL")
|
||||||
|
conn.execute("PRAGMA busy_timeout=5000")
|
||||||
|
conn.execute("PRAGMA foreign_keys=ON")
|
||||||
|
_local.conn = conn
|
||||||
|
_local.session_key = _db_session_key
|
||||||
|
|
||||||
|
return conn
|
||||||
|
|
||||||
|
|
||||||
def query(sql, params=None):
|
def query(sql, params=None):
|
||||||
"""
|
"""SELECT → list[dict]."""
|
||||||
SELECT → list[dict] с lowercase ключами.
|
conn = get_conn()
|
||||||
Используется всеми db/*.py модулями для чтения данных.
|
sql = _pg_to_sqlite(sql)
|
||||||
"""
|
cur = conn.execute(sql, params or [])
|
||||||
pool = get_pool()
|
return [dict(r) for r in cur.fetchall()]
|
||||||
conn = pool.getconn()
|
|
||||||
try:
|
|
||||||
with conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor) as cur:
|
|
||||||
cur.execute(sql, params)
|
|
||||||
rows = cur.fetchall()
|
|
||||||
return [{k.lower(): v for k, v in r.items()} for r in rows]
|
|
||||||
finally:
|
|
||||||
pool.putconn(conn)
|
|
||||||
|
|
||||||
|
|
||||||
def execute(sql, params=None):
|
def execute(sql, params=None):
|
||||||
"""
|
"""INSERT/UPDATE/DELETE → rowcount."""
|
||||||
INSERT/UPDATE/DELETE → количество затронутых строк.
|
conn = get_conn()
|
||||||
Автокоммит. При ошибке — rollback и проброс исключения.
|
sql = _pg_to_sqlite(sql)
|
||||||
"""
|
cur = conn.execute(sql, params or [])
|
||||||
pool = get_pool()
|
conn.commit()
|
||||||
conn = pool.getconn()
|
return cur.rowcount
|
||||||
try:
|
|
||||||
with conn.cursor() as cur:
|
|
||||||
cur.execute(sql, params)
|
|
||||||
conn.commit()
|
|
||||||
return cur.rowcount
|
|
||||||
except Exception:
|
|
||||||
conn.rollback()
|
|
||||||
raise
|
|
||||||
finally:
|
|
||||||
pool.putconn(conn)
|
|
||||||
|
|
||||||
|
|
||||||
def execute_returning(sql, params=None):
|
def execute_returning(sql, params=None):
|
||||||
"""
|
"""INSERT с RETURNING → dict (эмулируется через lastrowid)."""
|
||||||
INSERT/UPDATE/DELETE с RETURNING → dict первой строки.
|
conn = get_conn()
|
||||||
Используется для insert с автогенерацией UUID (gen_random_uuid()).
|
table = _extract_table(sql)
|
||||||
"""
|
sql = _pg_to_sqlite(sql)
|
||||||
pool = get_pool()
|
|
||||||
conn = pool.getconn()
|
if " RETURNING " in sql.upper():
|
||||||
try:
|
sql = sql[:sql.upper().rfind(" RETURNING ")]
|
||||||
with conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor) as cur:
|
|
||||||
cur.execute(sql, params)
|
cur = conn.execute(sql, params or [])
|
||||||
conn.commit()
|
conn.commit()
|
||||||
row = cur.fetchone()
|
rowid = cur.lastrowid
|
||||||
return {k.lower(): v for k, v in row.items()} if row else None
|
|
||||||
except Exception:
|
if table and rowid:
|
||||||
conn.rollback()
|
row = conn.execute(f"SELECT * FROM {table} WHERE rowid = ?", (rowid,)).fetchone()
|
||||||
raise
|
if row:
|
||||||
finally:
|
return dict(row)
|
||||||
pool.putconn(conn)
|
|
||||||
|
# Fallback: если нет таблицы или rowid — просто вернуть последнюю строку
|
||||||
|
if table:
|
||||||
|
row = conn.execute(f"SELECT * FROM {table} ORDER BY rowid DESC LIMIT 1").fetchone()
|
||||||
|
return dict(row) if row else None
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _pg_to_sqlite(sql):
|
||||||
|
"""Конвертировать PostgreSQL-специфичный SQL в SQLite."""
|
||||||
|
# %s → ?
|
||||||
|
sql = sql.replace("%s", "?")
|
||||||
|
# ::jsonb → убрать (SQLite не типизирует)
|
||||||
|
sql = sql.replace("::jsonb", "")
|
||||||
|
# gen_random_uuid() → хекс-UUID через randomblob
|
||||||
|
if "gen_random_uuid()" in sql:
|
||||||
|
import uuid
|
||||||
|
sql = sql.replace("gen_random_uuid()", "?")
|
||||||
|
# BOOLEAN → INTEGER
|
||||||
|
sql = sql.replace(" BOOLEAN ", " INTEGER ")
|
||||||
|
sql = sql.replace(" bool ", " INTEGER ")
|
||||||
|
# ILIKE → LIKE (SQLite LIKE case-insensitive для ASCII)
|
||||||
|
sql = sql.replace(" ILIKE ", " LIKE ")
|
||||||
|
# FALSE/TRUE → 0/1
|
||||||
|
sql = sql.replace(" FALSE", " 0").replace(" TRUE", " 1")
|
||||||
|
sql = sql.replace(" false", " 0").replace(" true", " 1")
|
||||||
|
return sql
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_table(sql):
|
||||||
|
"""Извлечь имя таблицы из INSERT INTO <table>."""
|
||||||
|
import re
|
||||||
|
m = re.search(r"INSERT\s+INTO\s+(\w+)", sql, re.IGNORECASE)
|
||||||
|
return m.group(1) if m else None
|
||||||
|
|||||||
@@ -1,12 +1,15 @@
|
|||||||
"""Contracts CRUD."""
|
"""Contracts CRUD."""
|
||||||
from .connection import query, execute, execute_returning
|
import uuid
|
||||||
|
from db.connection import query, execute
|
||||||
|
|
||||||
|
|
||||||
def insert(number, client=""):
|
def insert(number, client=""):
|
||||||
return execute_returning(
|
cid = str(uuid.uuid4())
|
||||||
"INSERT INTO contracts (number, client) VALUES (%s, %s) RETURNING *",
|
execute(
|
||||||
(number, client),
|
"INSERT INTO contracts (id, number, client) VALUES (%s, %s, %s)",
|
||||||
|
(cid, number, client),
|
||||||
)
|
)
|
||||||
|
return get(cid)
|
||||||
|
|
||||||
|
|
||||||
def get(contract_id):
|
def get(contract_id):
|
||||||
|
|||||||
@@ -1,14 +1,17 @@
|
|||||||
"""Documents CRUD."""
|
"""Documents CRUD."""
|
||||||
from .connection import query, execute, execute_returning
|
import uuid
|
||||||
|
from db.connection import query, execute, execute_returning
|
||||||
|
|
||||||
|
|
||||||
def insert(filename, mime_type, original_bytes, status="uploaded", batch_id=None, zip_source=None, content_hash=None):
|
def insert(filename, mime_type, original_bytes, status="uploaded", batch_id=None, zip_source=None, content_hash=None):
|
||||||
"""Insert document, return row dict."""
|
"""Insert document, return row dict."""
|
||||||
return execute_returning(
|
doc_id = str(uuid.uuid4())
|
||||||
"""INSERT INTO documents (filename, mime_type, original_bytes, status, batch_id, zip_source, content_hash)
|
execute(
|
||||||
VALUES (%s, %s, %s, %s, %s, %s, %s) RETURNING *""",
|
"""INSERT INTO documents (id, filename, mime_type, original_bytes, status, batch_id, zip_source, content_hash)
|
||||||
(filename, mime_type, original_bytes, status, batch_id, zip_source, content_hash),
|
VALUES (%s, %s, %s, %s, %s, %s, %s, %s)""",
|
||||||
|
(doc_id, filename, mime_type, original_bytes, status, batch_id, zip_source, content_hash),
|
||||||
)
|
)
|
||||||
|
return get(doc_id)
|
||||||
|
|
||||||
|
|
||||||
def get(doc_id):
|
def get(doc_id):
|
||||||
|
|||||||
+12
-9
@@ -1,5 +1,6 @@
|
|||||||
"""Prompts CRUD."""
|
"""Prompts CRUD."""
|
||||||
from .connection import query, execute, execute_returning
|
import uuid
|
||||||
|
from db.connection import query, execute
|
||||||
|
|
||||||
|
|
||||||
def _serialize(row):
|
def _serialize(row):
|
||||||
@@ -46,8 +47,8 @@ def seed_defaults():
|
|||||||
"ТЕКСТ ДОКУМЕНТА:\n---\n{doc_text}\n---"
|
"ТЕКСТ ДОКУМЕНТА:\n---\n{doc_text}\n---"
|
||||||
)
|
)
|
||||||
execute(
|
execute(
|
||||||
"INSERT INTO prompts (role, name, body, is_active, notes) VALUES (%s, %s, %s, %s, %s)",
|
"INSERT INTO prompts (id, role, name, body, is_active, notes) VALUES (%s, %s, %s, %s, %s, %s)",
|
||||||
("extract", "default-v1", extract_body, True, "Авто-создан из llm_prompt.py _build_initial"),
|
(str(uuid.uuid4()), "extract", "default-v1", extract_body, True, "Авто-создан из llm_prompt.py _build_initial"),
|
||||||
)
|
)
|
||||||
|
|
||||||
if "diff" not in existing_roles:
|
if "diff" not in existing_roles:
|
||||||
@@ -75,8 +76,8 @@ def seed_defaults():
|
|||||||
"ТЕКСТ ДОПСОГЛАШЕНИЯ:\n---\n{doc_text}\n---"
|
"ТЕКСТ ДОПСОГЛАШЕНИЯ:\n---\n{doc_text}\n---"
|
||||||
)
|
)
|
||||||
execute(
|
execute(
|
||||||
"INSERT INTO prompts (role, name, body, is_active, notes) VALUES (%s, %s, %s, %s, %s)",
|
"INSERT INTO prompts (id, role, name, body, is_active, notes) VALUES (%s, %s, %s, %s, %s, %s)",
|
||||||
("diff", "default-v1", diff_body, True, "Авто-создан из llm_prompt.py _build_diff"),
|
(str(uuid.uuid4()), "diff", "default-v1", diff_body, True, "Авто-создан из llm_prompt.py _build_diff"),
|
||||||
)
|
)
|
||||||
_ensure_classify_prompt()
|
_ensure_classify_prompt()
|
||||||
|
|
||||||
@@ -97,11 +98,13 @@ def save_new_version(role, name, body, notes="", is_active=True):
|
|||||||
"""Save new prompt version. Deactivates all others for this role, inserts new one."""
|
"""Save new prompt version. Deactivates all others for this role, inserts new one."""
|
||||||
if is_active:
|
if is_active:
|
||||||
execute("UPDATE prompts SET is_active=false WHERE role=%s", (role,))
|
execute("UPDATE prompts SET is_active=false WHERE role=%s", (role,))
|
||||||
return execute_returning(
|
pid = str(uuid.uuid4())
|
||||||
"""INSERT INTO prompts (role, name, body, is_active, notes)
|
execute(
|
||||||
VALUES (%s, %s, %s, %s, %s) RETURNING *""",
|
"""INSERT INTO prompts (id, role, name, body, is_active, notes)
|
||||||
(role, name, body, is_active, notes),
|
VALUES (%s, %s, %s, %s, %s, %s)""",
|
||||||
|
(pid, role, name, body, is_active, notes),
|
||||||
)
|
)
|
||||||
|
return get(pid)
|
||||||
|
|
||||||
|
|
||||||
def activate(prompt_id):
|
def activate(prompt_id):
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
"""spec_current — текущее состояние спецификации."""
|
"""spec_current — текущее состояние спецификации."""
|
||||||
from .connection import query
|
from db.connection import query
|
||||||
|
|
||||||
|
|
||||||
def list_by_contract(contract_id):
|
def list_by_contract(contract_id):
|
||||||
|
|||||||
+30
-42
@@ -1,6 +1,6 @@
|
|||||||
"""spec_events — event sourcing: apply ops, reset contract."""
|
"""spec_events — event sourcing: apply ops, reset contract."""
|
||||||
import json, uuid
|
import json, uuid
|
||||||
from .connection import query, execute, get_pool
|
from db.connection import query, execute, get_conn
|
||||||
|
|
||||||
|
|
||||||
def reset(contract_id):
|
def reset(contract_id):
|
||||||
@@ -10,26 +10,14 @@ def reset(contract_id):
|
|||||||
|
|
||||||
|
|
||||||
def get_next_seq(contract_id):
|
def get_next_seq(contract_id):
|
||||||
"""Get next sequence number with row lock to prevent race conditions."""
|
"""Get next sequence number. WAL serializes writers — no explicit lock needed."""
|
||||||
pool = get_pool()
|
conn = get_conn()
|
||||||
conn = pool.getconn()
|
cur = conn.execute(
|
||||||
try:
|
"SELECT seq FROM spec_events WHERE contract_id = ? ORDER BY seq DESC LIMIT 1",
|
||||||
conn.autocommit = False
|
(contract_id,),
|
||||||
with conn.cursor() as cur:
|
)
|
||||||
cur.execute(
|
row = cur.fetchone()
|
||||||
"SELECT seq FROM spec_events WHERE contract_id = %s ORDER BY seq DESC LIMIT 1 FOR UPDATE",
|
return (row["seq"] + 1) if row else 1
|
||||||
(contract_id,),
|
|
||||||
)
|
|
||||||
row = cur.fetchone()
|
|
||||||
seq = (row[0] + 1) if row else 1
|
|
||||||
conn.commit()
|
|
||||||
return seq
|
|
||||||
except Exception:
|
|
||||||
conn.rollback()
|
|
||||||
raise
|
|
||||||
finally:
|
|
||||||
conn.autocommit = True
|
|
||||||
pool.putconn(conn)
|
|
||||||
|
|
||||||
|
|
||||||
def apply_ops(contract_id, supplement_id, document_id, ops, prompt_id, raw_llm_response):
|
def apply_ops(contract_id, supplement_id, document_id, ops, prompt_id, raw_llm_response):
|
||||||
@@ -52,11 +40,11 @@ def apply_ops(contract_id, supplement_id, document_id, ops, prompt_id, raw_llm_r
|
|||||||
continue
|
continue
|
||||||
name_hash = _hash(name, nr.get("date_start"))
|
name_hash = _hash(name, nr.get("date_start"))
|
||||||
execute(
|
execute(
|
||||||
"""INSERT INTO spec_events (contract_id, supplement_id, seq, action, target_hash,
|
"""INSERT INTO spec_events (id, contract_id, supplement_id, seq, action, target_hash,
|
||||||
new_values, comment, status, prompt_version, source_document_id, raw_llm_response)
|
new_values, comment, status, prompt_version, source_document_id, raw_llm_response)
|
||||||
VALUES (%s, %s, %s, 'ADD', %s, %s, %s, 'applied', %s, %s, %s)""",
|
VALUES (%s, %s, %s, %s, 'ADD', %s, %s, %s, 'applied', %s, %s, %s)""",
|
||||||
(
|
(
|
||||||
contract_id, supplement_id, seq, name_hash,
|
str(uuid.uuid4()), contract_id, supplement_id, seq, name_hash,
|
||||||
json.dumps(nr, ensure_ascii=False),
|
json.dumps(nr, ensure_ascii=False),
|
||||||
op.get("comment", ""), prompt_id, document_id,
|
op.get("comment", ""), prompt_id, document_id,
|
||||||
json.dumps(raw_llm_response, ensure_ascii=False),
|
json.dumps(raw_llm_response, ensure_ascii=False),
|
||||||
@@ -74,11 +62,11 @@ def apply_ops(contract_id, supplement_id, document_id, ops, prompt_id, raw_llm_r
|
|||||||
seq += 1
|
seq += 1
|
||||||
continue
|
continue
|
||||||
execute(
|
execute(
|
||||||
"""INSERT INTO spec_events (contract_id, supplement_id, seq, action, target_hash,
|
"""INSERT INTO spec_events (id, contract_id, supplement_id, seq, action, target_hash,
|
||||||
new_values, comment, status, prompt_version, source_document_id, raw_llm_response)
|
new_values, comment, status, prompt_version, source_document_id, raw_llm_response)
|
||||||
VALUES (%s, %s, %s, 'UPDATE', %s, %s, %s, 'applied', %s, %s, %s)""",
|
VALUES (%s, %s, %s, %s, 'UPDATE', %s, %s, %s, 'applied', %s, %s, %s)""",
|
||||||
(
|
(
|
||||||
contract_id, supplement_id, seq, th,
|
str(uuid.uuid4()), contract_id, supplement_id, seq, th,
|
||||||
json.dumps(nv, ensure_ascii=False),
|
json.dumps(nv, ensure_ascii=False),
|
||||||
op.get("comment", ""), prompt_id, document_id,
|
op.get("comment", ""), prompt_id, document_id,
|
||||||
json.dumps(raw_llm_response, ensure_ascii=False),
|
json.dumps(raw_llm_response, ensure_ascii=False),
|
||||||
@@ -95,11 +83,11 @@ def apply_ops(contract_id, supplement_id, document_id, ops, prompt_id, raw_llm_r
|
|||||||
seq += 1
|
seq += 1
|
||||||
continue
|
continue
|
||||||
execute(
|
execute(
|
||||||
"""INSERT INTO spec_events (contract_id, supplement_id, seq, action, target_hash,
|
"""INSERT INTO spec_events (id, contract_id, supplement_id, seq, action, target_hash,
|
||||||
new_values, comment, status, prompt_version, source_document_id, raw_llm_response)
|
new_values, comment, status, prompt_version, source_document_id, raw_llm_response)
|
||||||
VALUES (%s, %s, %s, 'DELETE', %s, %s, %s, 'applied', %s, %s, %s)""",
|
VALUES (%s, %s, %s, %s, 'DELETE', %s, %s, %s, 'applied', %s, %s, %s)""",
|
||||||
(
|
(
|
||||||
contract_id, supplement_id, seq, th,
|
str(uuid.uuid4()), contract_id, supplement_id, seq, th,
|
||||||
json.dumps({}), op.get("comment", ""),
|
json.dumps({}), op.get("comment", ""),
|
||||||
prompt_id, document_id,
|
prompt_id, document_id,
|
||||||
json.dumps(raw_llm_response, ensure_ascii=False),
|
json.dumps(raw_llm_response, ensure_ascii=False),
|
||||||
@@ -112,11 +100,11 @@ def apply_ops(contract_id, supplement_id, document_id, ops, prompt_id, raw_llm_r
|
|||||||
elif action == "UNRESOLVED":
|
elif action == "UNRESOLVED":
|
||||||
# Log but don't apply
|
# Log but don't apply
|
||||||
execute(
|
execute(
|
||||||
"""INSERT INTO spec_events (contract_id, supplement_id, seq, action, target_hash,
|
"""INSERT INTO spec_events (id, contract_id, supplement_id, seq, action, target_hash,
|
||||||
new_values, comment, status, prompt_version, source_document_id, raw_llm_response)
|
new_values, comment, status, prompt_version, source_document_id, raw_llm_response)
|
||||||
VALUES (%s, %s, %s, 'UNRESOLVED', %s, %s, %s, 'unresolved', %s, %s, %s)""",
|
VALUES (%s, %s, %s, %s, 'UNRESOLVED', %s, %s, %s, 'unresolved', %s, %s, %s)""",
|
||||||
(
|
(
|
||||||
contract_id, supplement_id, seq,
|
str(uuid.uuid4()), contract_id, supplement_id, seq,
|
||||||
op.get("target_hash", ""),
|
op.get("target_hash", ""),
|
||||||
json.dumps(op.get("new_values", {}), ensure_ascii=False),
|
json.dumps(op.get("new_values", {}), ensure_ascii=False),
|
||||||
op.get("reason", op.get("comment", "")),
|
op.get("reason", op.get("comment", "")),
|
||||||
@@ -137,11 +125,11 @@ def apply_ops(contract_id, supplement_id, document_id, ops, prompt_id, raw_llm_r
|
|||||||
def _log_unresolved(contract_id, supplement_id, seq, op, prompt_id, document_id, raw_llm_response, reason):
|
def _log_unresolved(contract_id, supplement_id, seq, op, prompt_id, document_id, raw_llm_response, reason):
|
||||||
"""Log an op as UNRESOLVED instead of silently ignoring it."""
|
"""Log an op as UNRESOLVED instead of silently ignoring it."""
|
||||||
execute(
|
execute(
|
||||||
"""INSERT INTO spec_events (contract_id, supplement_id, seq, action, target_hash,
|
"""INSERT INTO spec_events (id, contract_id, supplement_id, seq, action, target_hash,
|
||||||
new_values, comment, status, prompt_version, source_document_id, raw_llm_response)
|
new_values, comment, status, prompt_version, source_document_id, raw_llm_response)
|
||||||
VALUES (%s, %s, %s, 'UNRESOLVED', %s, %s, %s, 'unresolved', %s, %s, %s)""",
|
VALUES (%s, %s, %s, %s, 'UNRESOLVED', %s, %s, %s, 'unresolved', %s, %s, %s)""",
|
||||||
(
|
(
|
||||||
contract_id, supplement_id, seq,
|
str(uuid.uuid4()), contract_id, supplement_id, seq,
|
||||||
op.get("target_hash", ""),
|
op.get("target_hash", ""),
|
||||||
json.dumps(op.get("new_values", op.get("new_row", {})) or {}, ensure_ascii=False),
|
json.dumps(op.get("new_values", op.get("new_row", {})) or {}, ensure_ascii=False),
|
||||||
reason,
|
reason,
|
||||||
@@ -156,7 +144,7 @@ def _hash(name, date_start=None):
|
|||||||
import hashlib
|
import hashlib
|
||||||
key = name.strip().lower()
|
key = name.strip().lower()
|
||||||
if date_start:
|
if date_start:
|
||||||
from compare.metrics import normalize_date
|
from services.metrics import normalize_date
|
||||||
nd = normalize_date(str(date_start))
|
nd = normalize_date(str(date_start))
|
||||||
if nd:
|
if nd:
|
||||||
key += "|" + nd
|
key += "|" + nd
|
||||||
@@ -171,16 +159,16 @@ def _upsert_spec_current(contract_id, name_hash, row):
|
|||||||
)
|
)
|
||||||
if existing:
|
if existing:
|
||||||
execute(
|
execute(
|
||||||
"""UPDATE spec_current SET name=%s, price=%s, qty=%s, sum=%s, date_start=%s, updated_at=now()
|
"""UPDATE spec_current SET name=%s, price=%s, qty=%s, sum=%s, date_start=%s, updated_at=datetime('now')
|
||||||
WHERE contract_id=%s AND name_hash=%s""",
|
WHERE contract_id=%s AND name_hash=%s""",
|
||||||
(row.get("name"), row.get("price"), row.get("qty"), row.get("sum"),
|
(row.get("name"), row.get("price"), row.get("qty"), row.get("sum"),
|
||||||
row.get("date_start"), contract_id, name_hash),
|
row.get("date_start"), contract_id, name_hash),
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
execute(
|
execute(
|
||||||
"""INSERT INTO spec_current (contract_id, name_hash, name, price, qty, sum, date_start)
|
"""INSERT INTO spec_current (id, contract_id, name_hash, name, price, qty, sum, date_start)
|
||||||
VALUES (%s, %s, %s, %s, %s, %s, %s)""",
|
VALUES (%s, %s, %s, %s, %s, %s, %s, %s)""",
|
||||||
(contract_id, name_hash, row.get("name"), row.get("price"),
|
(str(uuid.uuid4()), contract_id, name_hash, row.get("name"), row.get("price"),
|
||||||
row.get("qty"), row.get("sum"), row.get("date_start")),
|
row.get("qty"), row.get("sum"), row.get("date_start")),
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -194,7 +182,7 @@ def _update_spec_current(contract_id, name_hash, new_values):
|
|||||||
sets.append(f"{field} = %s")
|
sets.append(f"{field} = %s")
|
||||||
params.append(new_values[field])
|
params.append(new_values[field])
|
||||||
if sets:
|
if sets:
|
||||||
sets.append("updated_at = now()")
|
sets.append("updated_at = datetime('now')")
|
||||||
params.extend([contract_id, name_hash])
|
params.extend([contract_id, name_hash])
|
||||||
execute(
|
execute(
|
||||||
f"UPDATE spec_current SET {', '.join(sets)} WHERE contract_id = %s AND name_hash = %s",
|
f"UPDATE spec_current SET {', '.join(sets)} WHERE contract_id = %s AND name_hash = %s",
|
||||||
|
|||||||
@@ -1,13 +1,16 @@
|
|||||||
"""Supplements CRUD."""
|
"""Supplements CRUD."""
|
||||||
from .connection import query, execute, execute_returning
|
import uuid
|
||||||
|
from db.connection import query, execute
|
||||||
|
|
||||||
|
|
||||||
def insert(contract_id, document_id, supp_type="additional"):
|
def insert(contract_id, document_id, supp_type="additional"):
|
||||||
return execute_returning(
|
sid = str(uuid.uuid4())
|
||||||
"""INSERT INTO supplements (contract_id, document_id, type)
|
execute(
|
||||||
VALUES (%s, %s, %s) RETURNING *""",
|
"""INSERT INTO supplements (id, contract_id, document_id, type)
|
||||||
(contract_id, document_id, supp_type),
|
VALUES (%s, %s, %s, %s)""",
|
||||||
|
(sid, contract_id, document_id, supp_type),
|
||||||
)
|
)
|
||||||
|
return get(sid)
|
||||||
|
|
||||||
|
|
||||||
def list_by_contract(contract_id):
|
def list_by_contract(contract_id):
|
||||||
|
|||||||
+17
-17
@@ -95,77 +95,77 @@ class PgRepository:
|
|||||||
"""Реальный доступ к PostgreSQL через существующие db/*.py."""
|
"""Реальный доступ к PostgreSQL через существующие db/*.py."""
|
||||||
|
|
||||||
def insert_document(self, filename, mime_type, original_bytes, batch_id=None, zip_source=None):
|
def insert_document(self, filename, mime_type, original_bytes, batch_id=None, zip_source=None):
|
||||||
from site.db import documents
|
from db import documents
|
||||||
return documents.insert(filename, mime_type, original_bytes,
|
return documents.insert(filename, mime_type, original_bytes,
|
||||||
batch_id=batch_id, zip_source=zip_source)
|
batch_id=batch_id, zip_source=zip_source)
|
||||||
|
|
||||||
def set_document_parsed(self, doc_id, elements):
|
def set_document_parsed(self, doc_id, elements):
|
||||||
from site.db import documents
|
from db import documents
|
||||||
documents.set_parsed(doc_id, elements) # documents.set_parsed уже делает json.dumps
|
documents.set_parsed(doc_id, elements) # documents.set_parsed уже делает json.dumps
|
||||||
|
|
||||||
def set_document_error(self, doc_id, error):
|
def set_document_error(self, doc_id, error):
|
||||||
from site.db import documents
|
from db import documents
|
||||||
documents.set_error(doc_id, error)
|
documents.set_error(doc_id, error)
|
||||||
|
|
||||||
def set_classification(self, doc_id, doc_type, own_number=None, parent_number=None,
|
def set_classification(self, doc_id, doc_type, own_number=None, parent_number=None,
|
||||||
doc_date=None, counterparty=None,
|
doc_date=None, counterparty=None,
|
||||||
classify_raw=None, classify_input=None):
|
classify_raw=None, classify_input=None):
|
||||||
from site.db import documents
|
from db import documents
|
||||||
documents.set_classification(doc_id, doc_type, own_number, parent_number,
|
documents.set_classification(doc_id, doc_type, own_number, parent_number,
|
||||||
doc_date, counterparty,
|
doc_date, counterparty,
|
||||||
classify_raw=classify_raw, classify_input=classify_input)
|
classify_raw=classify_raw, classify_input=classify_input)
|
||||||
|
|
||||||
def set_classify_garbage(self, doc_id, reason=""):
|
def set_classify_garbage(self, doc_id, reason=""):
|
||||||
from site.db import documents
|
from db import documents
|
||||||
documents.set_classify_garbage(doc_id, reason)
|
documents.set_classify_garbage(doc_id, reason)
|
||||||
|
|
||||||
def set_classify_failed(self, doc_id, error):
|
def set_classify_failed(self, doc_id, error):
|
||||||
from site.db import documents
|
from db import documents
|
||||||
documents.set_classify_failed(doc_id, error)
|
documents.set_classify_failed(doc_id, error)
|
||||||
|
|
||||||
def list_pending(self, batch_id):
|
def list_pending(self, batch_id):
|
||||||
from site.db import documents
|
from db import documents
|
||||||
return documents.list_pending(batch_id)
|
return documents.list_pending(batch_id)
|
||||||
|
|
||||||
def insert_contract(self, number, client=""):
|
def insert_contract(self, number, client=""):
|
||||||
from site.db import contracts
|
from db import contracts
|
||||||
c = contracts.insert(number, client)
|
c = contracts.insert(number, client)
|
||||||
return c["id"] if c else ""
|
return c["id"] if c else ""
|
||||||
|
|
||||||
def insert_supplement(self, contract_id, doc_id, supp_type):
|
def insert_supplement(self, contract_id, doc_id, supp_type):
|
||||||
from site.db import supplements
|
from db import supplements
|
||||||
supplements.insert(contract_id, doc_id, supp_type)
|
supplements.insert(contract_id, doc_id, supp_type)
|
||||||
|
|
||||||
def list_supplements(self, contract_id):
|
def list_supplements(self, contract_id):
|
||||||
from site.db import supplements
|
from db import supplements
|
||||||
return supplements.list_by_contract(contract_id)
|
return supplements.list_by_contract(contract_id)
|
||||||
|
|
||||||
def get_spec_current(self, contract_id):
|
def get_spec_current(self, contract_id):
|
||||||
from site.db import spec_current
|
from db import spec_current
|
||||||
return spec_current.list_by_contract(contract_id)
|
return spec_current.list_by_contract(contract_id)
|
||||||
|
|
||||||
def get_document(self, doc_id):
|
def get_document(self, doc_id):
|
||||||
from site.db import documents
|
from db import documents
|
||||||
return documents.get(doc_id)
|
return documents.get(doc_id)
|
||||||
|
|
||||||
def list_by_batch(self, batch_id):
|
def list_by_batch(self, batch_id):
|
||||||
from site.db import documents
|
from db import documents
|
||||||
return documents.list_by_batch(batch_id)
|
return documents.list_by_batch(batch_id)
|
||||||
|
|
||||||
def count_by_status(self, batch_id):
|
def count_by_status(self, batch_id):
|
||||||
from site.db import documents
|
from db import documents
|
||||||
return documents.count_by_status(batch_id)
|
return documents.count_by_status(batch_id)
|
||||||
|
|
||||||
def reset_classify_status(self, batch_id):
|
def reset_classify_status(self, batch_id):
|
||||||
from site.db import documents
|
from db import documents
|
||||||
documents.reset_classify_status(batch_id)
|
documents.reset_classify_status(batch_id)
|
||||||
|
|
||||||
def set_classify_processing(self, doc_id):
|
def set_classify_processing(self, doc_id):
|
||||||
from site.db import documents
|
from db import documents
|
||||||
documents.set_classify_processing(doc_id)
|
documents.set_classify_processing(doc_id)
|
||||||
|
|
||||||
def delete_document(self, doc_id):
|
def delete_document(self, doc_id):
|
||||||
from site.db import documents
|
from db import documents
|
||||||
documents.delete(doc_id)
|
documents.delete(doc_id)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -2,12 +2,12 @@
|
|||||||
|
|
||||||
|
|
||||||
def register_routes(app):
|
def register_routes(app):
|
||||||
from site.routes.upload_bp import upload_bp
|
from routes.upload_bp import upload_bp
|
||||||
from site.routes.pipeline_bp import pipeline_bp
|
from routes.pipeline_bp import pipeline_bp
|
||||||
from site.routes.api_bp import api_bp
|
from routes.api_bp import api_bp
|
||||||
from site.routes.prompts_bp import prompts_bp
|
from routes.prompts_bp import prompts_bp
|
||||||
from site.routes.health_bp import health_bp
|
from routes.health_bp import health_bp
|
||||||
from site.routes.pages_bp import pages_bp
|
from routes.pages_bp import pages_bp
|
||||||
|
|
||||||
app.register_blueprint(upload_bp)
|
app.register_blueprint(upload_bp)
|
||||||
app.register_blueprint(pipeline_bp)
|
app.register_blueprint(pipeline_bp)
|
||||||
|
|||||||
+9
-25
@@ -1,23 +1,14 @@
|
|||||||
"""API blueprint — groups, documents, supplements, sync, cleanup, spec-current, chat."""
|
"""API blueprint — groups, documents, supplements, sync, cleanup, spec-current, chat."""
|
||||||
from flask import Blueprint, request, jsonify
|
from flask import Blueprint, request, jsonify
|
||||||
from site.db import documents, supplements, spec_current
|
from db import documents, supplements, spec_current
|
||||||
from site.db.connection import execute, query
|
from db.connection import execute, query
|
||||||
from site.services.grouping import group_documents, apply_groups
|
from services.grouping import group_documents, apply_groups
|
||||||
from site.config import API_KEY, LLM_URL, LLM_KEY, LLM_MODEL
|
from config import LLM_URL, LLM_KEY, LLM_MODEL
|
||||||
import httpx
|
import httpx
|
||||||
|
|
||||||
api_bp = Blueprint("api", __name__)
|
api_bp = Blueprint("api", __name__)
|
||||||
|
|
||||||
|
|
||||||
def _require_auth():
|
|
||||||
"""Проверка API-ключа для опасных операций."""
|
|
||||||
if API_KEY:
|
|
||||||
key = request.headers.get("X-Api-Key", "")
|
|
||||||
if key != API_KEY:
|
|
||||||
return False
|
|
||||||
return True
|
|
||||||
|
|
||||||
|
|
||||||
# ── Supplements ──────────────────────────────────────────────────
|
# ── Supplements ──────────────────────────────────────────────────
|
||||||
|
|
||||||
@api_bp.route("/api/supplements")
|
@api_bp.route("/api/supplements")
|
||||||
@@ -160,7 +151,7 @@ def chat():
|
|||||||
)
|
)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
with httpx.Client(http2=True, timeout=120) as client:
|
with httpx.Client(timeout=120) as client:
|
||||||
resp = client.post(
|
resp = client.post(
|
||||||
LLM_URL,
|
LLM_URL,
|
||||||
json={
|
json={
|
||||||
@@ -181,18 +172,11 @@ def chat():
|
|||||||
return jsonify(OK=False, ERROR=f"LLM error: {e}")
|
return jsonify(OK=False, ERROR=f"LLM error: {e}")
|
||||||
|
|
||||||
|
|
||||||
# ── Cleanup (требует API-ключ) ────────────────────────────────────
|
# ── Cleanup (атомарное удаление БД) ──────────────────────────────
|
||||||
|
|
||||||
@api_bp.route("/api/cleanup", methods=["POST"])
|
@api_bp.route("/api/cleanup", methods=["POST"])
|
||||||
def api_cleanup():
|
def api_cleanup():
|
||||||
"""Полная очистка всех данных (кроме prompts)."""
|
"""Полная очистка: os.remove(DB) + init новой. Данные гарантированно стёрты."""
|
||||||
if not _require_auth():
|
from db.connection import cleanup_db
|
||||||
return jsonify(ok=False, error="unauthorized"), 401
|
cleanup_db()
|
||||||
|
|
||||||
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")
|
return jsonify(ok=True, message="all data cleaned")
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
"""Health probe — обязательно для Штурвала."""
|
"""Health probe — обязательно для Штурвала."""
|
||||||
from flask import Blueprint, jsonify
|
from flask import Blueprint, jsonify
|
||||||
from site.config import VERSION
|
from config import VERSION
|
||||||
|
|
||||||
health_bp = Blueprint("health", __name__)
|
health_bp = Blueprint("health", __name__)
|
||||||
|
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
"""Pipeline blueprint — SSE-сравнение + classify."""
|
"""Pipeline blueprint — SSE-сравнение + classify."""
|
||||||
import json, re, os, threading
|
import json, re, os, threading
|
||||||
from flask import Blueprint, request, jsonify, Response, stream_with_context
|
from flask import Blueprint, request, jsonify, Response, stream_with_context
|
||||||
from site.services.process import run_pipeline
|
from services.process import run_pipeline
|
||||||
from site.services.classify import classify_batch
|
from services.classify import classify_batch
|
||||||
from site.llm_prompt import build_prompt
|
from llm_prompt import build_prompt
|
||||||
from site.db import documents
|
from db import documents
|
||||||
|
|
||||||
pipeline_bp = Blueprint("pipeline", __name__)
|
pipeline_bp = Blueprint("pipeline", __name__)
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
"""Prompts blueprint — CRUD + activate для версионирования промптов."""
|
"""Prompts blueprint — CRUD + activate для версионирования промптов."""
|
||||||
from flask import Blueprint, request, jsonify
|
from flask import Blueprint, request, jsonify
|
||||||
from site.db import prompts as db_prompts
|
from db import prompts as db_prompts
|
||||||
|
|
||||||
prompts_bp = Blueprint("prompts", __name__)
|
prompts_bp = Blueprint("prompts", __name__)
|
||||||
|
|
||||||
|
|||||||
+232
-100
@@ -1,9 +1,10 @@
|
|||||||
"""Upload blueprint — загрузка, конвертация, распаковка."""
|
"""Upload blueprint — загрузка, конвертация, распаковка."""
|
||||||
import io, os, base64, hashlib, zipfile, tempfile, subprocess
|
import io, os, time, base64, hashlib, zipfile
|
||||||
|
import httpx
|
||||||
from flask import Blueprint, request, jsonify, send_file
|
from flask import Blueprint, request, jsonify, send_file
|
||||||
from site.services.parse import parse_file
|
from services.parse import parse_file
|
||||||
from site.db import documents
|
from db import documents
|
||||||
from site.config import MAX_CONTENT_LENGTH
|
import config
|
||||||
|
|
||||||
upload_bp = Blueprint("upload", __name__)
|
upload_bp = Blueprint("upload", __name__)
|
||||||
|
|
||||||
@@ -17,9 +18,148 @@ def _check_ext(filename: str) -> str | None:
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _safe_name(name: str) -> str:
|
||||||
|
"""Санитизация имени файла: только basename, защита от path traversal."""
|
||||||
|
name = (name or "").replace("\\", "/").rsplit("/", 1)[-1].strip()
|
||||||
|
if not name or name in (".", ".."):
|
||||||
|
return "file.bin"
|
||||||
|
return name[:255]
|
||||||
|
|
||||||
|
|
||||||
|
def _pull_with_retries(url: str, timeout: int = 120) -> bytes:
|
||||||
|
"""Скачать файл с ВМ-буфера с ретраями (egress, без лимита шлюза)."""
|
||||||
|
last: Exception | None = None
|
||||||
|
for attempt in range(1, config.PULL_RETRIES + 1):
|
||||||
|
try:
|
||||||
|
resp = httpx.get(url, timeout=timeout, follow_redirects=False)
|
||||||
|
if resp.status_code == 200:
|
||||||
|
return resp.content
|
||||||
|
last = Exception(f"HTTP {resp.status_code}")
|
||||||
|
except Exception as e:
|
||||||
|
last = e
|
||||||
|
if attempt < config.PULL_RETRIES:
|
||||||
|
time.sleep(config.PULL_RETRY_DELAY)
|
||||||
|
raise last or Exception("pull failed")
|
||||||
|
|
||||||
|
|
||||||
|
def _delete_from_vm(url: str) -> None:
|
||||||
|
"""Best-effort удаление файла с ВМ-буфера."""
|
||||||
|
try:
|
||||||
|
httpx.delete(url, timeout=30)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def _pull_from_ref(ref):
|
||||||
|
"""SSRF-проверка → лимит → pull с ретраями → DELETE с ВМ. Возвращает (name, content)."""
|
||||||
|
name = _safe_name(str(ref.get("name", "") or ""))
|
||||||
|
size = int(ref.get("size") or 0)
|
||||||
|
url = str(ref.get("url", "") or "")
|
||||||
|
|
||||||
|
if not url.startswith(config.VM_UPLOAD_PREFIX):
|
||||||
|
raise Exception("invalid url (SSRF guard)")
|
||||||
|
if size > config.VM_UPLOAD_MAX_BYTES:
|
||||||
|
_delete_from_vm(url)
|
||||||
|
raise Exception(f"file too large: {size} bytes (max {config.VM_UPLOAD_MAX_BYTES})")
|
||||||
|
|
||||||
|
content = _pull_with_retries(url)
|
||||||
|
if len(content) > config.VM_UPLOAD_MAX_BYTES:
|
||||||
|
_delete_from_vm(url)
|
||||||
|
raise Exception("file too large after pull")
|
||||||
|
|
||||||
|
_delete_from_vm(url)
|
||||||
|
return name, content
|
||||||
|
|
||||||
|
|
||||||
|
def _unzip(data: bytes):
|
||||||
|
"""Распаковать ZIP → (ok, files, error)."""
|
||||||
|
MAX_FILES = 500
|
||||||
|
MAX_UNCOMPRESSED = 500 * 1024 * 1024 # 500 MB
|
||||||
|
files = []
|
||||||
|
total = 0
|
||||||
|
try:
|
||||||
|
with zipfile.ZipFile(io.BytesIO(data)) as zf:
|
||||||
|
if len(zf.namelist()) > MAX_FILES:
|
||||||
|
return False, None, f"too many files in ZIP (max {MAX_FILES})"
|
||||||
|
for info in zf.infolist():
|
||||||
|
if info.is_dir():
|
||||||
|
continue
|
||||||
|
name = os.path.basename(info.filename)
|
||||||
|
if not name or ".." in name or "/" in name or "\\" in name:
|
||||||
|
continue
|
||||||
|
raw = zf.read(info)
|
||||||
|
total += len(raw)
|
||||||
|
if total > MAX_UNCOMPRESSED:
|
||||||
|
return False, None, "total uncompressed size exceeds 500 MB"
|
||||||
|
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(),
|
||||||
|
})
|
||||||
|
except zipfile.BadZipFile:
|
||||||
|
return False, None, "invalid ZIP archive"
|
||||||
|
return True, files, None
|
||||||
|
|
||||||
|
|
||||||
|
def _convert(filename: str, data: bytes) -> bytes:
|
||||||
|
""".doc → .docx через внешний libreoffice-сервис. Возвращает docx-байты."""
|
||||||
|
try:
|
||||||
|
resp = httpx.post(
|
||||||
|
config.CONVERT_SERVICE_URL + "/convert",
|
||||||
|
files={"file": (filename, data, "application/msword")},
|
||||||
|
timeout=120,
|
||||||
|
)
|
||||||
|
except httpx.TimeoutException:
|
||||||
|
raise Exception("conversion timeout")
|
||||||
|
if resp.status_code != 200:
|
||||||
|
try:
|
||||||
|
err = resp.json().get("error", "conversion failed")
|
||||||
|
except Exception:
|
||||||
|
err = "conversion failed"
|
||||||
|
raise Exception(err)
|
||||||
|
return resp.content
|
||||||
|
|
||||||
|
|
||||||
|
def _store_and_parse(filename: str, data: bytes, batch_id, contract_id, zip_source=None, mime_type="application/octet-stream"):
|
||||||
|
"""Общая логика: дедуп → insert в БД → авто-парсинг. Возвращает dict-результат."""
|
||||||
|
content_hash = hashlib.sha256(data).hexdigest()[:16]
|
||||||
|
|
||||||
|
# Дедупликация по хешу
|
||||||
|
if batch_id:
|
||||||
|
existing = documents.get_by_hash(batch_id, content_hash)
|
||||||
|
if existing:
|
||||||
|
return {"ok": False, "error": "duplicate", "doc_id": existing["id"], "duplicate_of": True}
|
||||||
|
|
||||||
|
doc = documents.insert(
|
||||||
|
filename=filename,
|
||||||
|
mime_type=mime_type,
|
||||||
|
original_bytes=base64.b64encode(data).decode(),
|
||||||
|
batch_id=batch_id,
|
||||||
|
zip_source=zip_source,
|
||||||
|
content_hash=content_hash,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Авто-парсинг
|
||||||
|
try:
|
||||||
|
result = parse_file(filename, data)
|
||||||
|
if result["status"] == "parsed":
|
||||||
|
documents.set_parsed(doc["id"], result["elements"])
|
||||||
|
parsed = {"status": "parsed", "element_count": result.get("element_count", 0)}
|
||||||
|
else:
|
||||||
|
documents.set_error(doc["id"], result.get("error", "parse failed"))
|
||||||
|
parsed = {"status": "error", "error": result.get("error", "parse failed")}
|
||||||
|
except Exception as e:
|
||||||
|
documents.set_error(doc["id"], str(e))
|
||||||
|
parsed = {"status": "error", "error": str(e)}
|
||||||
|
|
||||||
|
return {"ok": True, "doc_id": doc["id"], "contract_id": contract_id, "parsed": parsed}
|
||||||
|
|
||||||
|
|
||||||
@upload_bp.route("/upload", methods=["POST"])
|
@upload_bp.route("/upload", methods=["POST"])
|
||||||
def upload():
|
def upload():
|
||||||
"""Загрузка одного файла + авто-парсинг → БД."""
|
"""Загрузка одного файла + авто-парсинг → БД (прямой multipart)."""
|
||||||
f = request.files.get("files")
|
f = request.files.get("files")
|
||||||
if not f:
|
if not f:
|
||||||
return jsonify(ok=False, error="no file"), 400
|
return jsonify(ok=False, error="no file"), 400
|
||||||
@@ -29,117 +169,109 @@ def upload():
|
|||||||
return jsonify(ok=False, error=err), 400
|
return jsonify(ok=False, error=err), 400
|
||||||
|
|
||||||
data = f.read()
|
data = f.read()
|
||||||
content_hash = hashlib.sha256(data).hexdigest()[:16]
|
|
||||||
batch_id = request.form.get("batch_id")
|
batch_id = request.form.get("batch_id")
|
||||||
zip_source = request.form.get("zip_source")
|
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")
|
contract_id = request.form.get("contract_id")
|
||||||
return jsonify(
|
|
||||||
ok=True,
|
result = _store_and_parse(f.filename, data, batch_id, contract_id, zip_source, f.content_type or "application/octet-stream")
|
||||||
doc_id=doc["id"],
|
if not result["ok"]:
|
||||||
contract_id=contract_id,
|
return jsonify(ok=result["ok"], error=result.get("error"), doc_id=result.get("doc_id")), 200
|
||||||
parsed={"status": result["status"], "element_count": result.get("element_count", 0)},
|
return jsonify(ok=True, doc_id=result["doc_id"], contract_id=result["contract_id"], parsed=result["parsed"])
|
||||||
)
|
|
||||||
|
|
||||||
|
@upload_bp.route("/api/upload_refs", methods=["POST"])
|
||||||
|
def upload_refs():
|
||||||
|
"""Загрузка через ВМ-буфер (паттерн drhider): бэк тянет файлы с ВМ.
|
||||||
|
|
||||||
|
Тело (маленькое, <64КБ): {batch_id, contract_id, zip_source, files:[{name,size,url}]}.
|
||||||
|
Для каждой ссылки: SSRF-проверка → лимит → pull с ретраями → store+parse → DELETE с ВМ.
|
||||||
|
"""
|
||||||
|
data = request.get_json(silent=True) or {}
|
||||||
|
files = data.get("files") or []
|
||||||
|
batch_id = data.get("batch_id")
|
||||||
|
contract_id = data.get("contract_id")
|
||||||
|
zip_source = data.get("zip_source")
|
||||||
|
|
||||||
|
if not files:
|
||||||
|
return jsonify(ok=False, error="no files"), 400
|
||||||
|
|
||||||
|
results = []
|
||||||
|
for ref in files:
|
||||||
|
ref_name = _safe_name(str(ref.get("name", "") or ""))
|
||||||
|
try:
|
||||||
|
name, content = _pull_from_ref(ref)
|
||||||
|
except Exception as e:
|
||||||
|
results.append({"name": ref_name, "ok": False, "error": str(e)})
|
||||||
|
continue
|
||||||
|
stored = _store_and_parse(name, content, batch_id, contract_id, zip_source)
|
||||||
|
results.append({"name": name, **stored})
|
||||||
|
|
||||||
|
return jsonify(ok=True, results=results)
|
||||||
|
|
||||||
|
|
||||||
@upload_bp.route("/convert-doc", methods=["POST"])
|
@upload_bp.route("/convert-doc", methods=["POST"])
|
||||||
def convert_doc():
|
def convert_doc():
|
||||||
""".doc → .docx через libreoffice (без сохранения на диск)."""
|
""".doc → .docx через внешний libreoffice-сервис (прямой multipart)."""
|
||||||
f = request.files.get("files")
|
f = request.files.get("files")
|
||||||
if not f:
|
if not f:
|
||||||
return jsonify(ok=False, error="no file"), 400
|
return jsonify(ok=False, error="no file"), 400
|
||||||
|
|
||||||
data = f.read()
|
|
||||||
doc_path = None
|
|
||||||
tmpdir = None
|
|
||||||
try:
|
try:
|
||||||
with tempfile.NamedTemporaryFile(suffix=".doc", delete=False) as tmp:
|
content = _convert(f.filename, f.read())
|
||||||
tmp.write(data)
|
except Exception as e:
|
||||||
doc_path = tmp.name
|
return jsonify(ok=False, error=str(e)), 500
|
||||||
tmpdir = tempfile.mkdtemp()
|
return send_file(
|
||||||
subprocess.run(
|
io.BytesIO(content),
|
||||||
["libreoffice", "--headless", "--convert-to", "docx", "--outdir", tmpdir, doc_path],
|
mimetype="application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
||||||
timeout=30, capture_output=True,
|
)
|
||||||
)
|
|
||||||
docx_files = [x for x in os.listdir(tmpdir) if x.endswith(".docx")]
|
|
||||||
if docx_files:
|
@upload_bp.route("/api/convert_refs", methods=["POST"])
|
||||||
with open(os.path.join(tmpdir, docx_files[0]), "rb") as out:
|
def convert_refs():
|
||||||
return send_file(
|
""".doc → .docx через ВМ-буфер (паттерн drhider): pull .doc с ВМ → конвертация → docx."""
|
||||||
io.BytesIO(out.read()),
|
data = request.get_json(silent=True) or {}
|
||||||
mimetype="application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
files = data.get("files") or []
|
||||||
)
|
if not files:
|
||||||
return jsonify(ok=False, error="conversion produced no output"), 500
|
return jsonify(ok=False, error="no files"), 400
|
||||||
finally:
|
ref = files[0]
|
||||||
if doc_path and os.path.exists(doc_path):
|
try:
|
||||||
os.unlink(doc_path)
|
name, content = _pull_from_ref(ref)
|
||||||
if tmpdir and os.path.exists(tmpdir):
|
except Exception as e:
|
||||||
for x in os.listdir(tmpdir):
|
return jsonify(ok=False, error=str(e)), 400
|
||||||
os.unlink(os.path.join(tmpdir, x))
|
try:
|
||||||
os.rmdir(tmpdir)
|
docx = _convert(name, content)
|
||||||
|
except Exception as e:
|
||||||
|
return jsonify(ok=False, error=str(e)), 500
|
||||||
|
return send_file(
|
||||||
|
io.BytesIO(docx),
|
||||||
|
mimetype="application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@upload_bp.route("/unzip-upload", methods=["POST"])
|
@upload_bp.route("/unzip-upload", methods=["POST"])
|
||||||
def unzip_upload():
|
def unzip_upload():
|
||||||
"""Распаковать ZIP → список файлов (base64 для фронтенда)."""
|
"""Распаковать ZIP → список файлов (base64 для фронтенда, прямой multipart)."""
|
||||||
f = request.files.get("files")
|
f = request.files.get("files")
|
||||||
if not f:
|
if not f:
|
||||||
return jsonify(ok=False, error="no file"), 400
|
return jsonify(ok=False, error="no file"), 400
|
||||||
|
ok, files, err = _unzip(f.read())
|
||||||
data = f.read()
|
if not ok:
|
||||||
MAX_FILES = 500
|
return jsonify(ok=False, error=err), 400
|
||||||
MAX_UNCOMPRESSED = 500 * 1024 * 1024 # 500 MB
|
|
||||||
|
|
||||||
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 in ZIP (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 or "/" in name or "\\" in name:
|
|
||||||
continue
|
|
||||||
|
|
||||||
raw = zf.read(info)
|
|
||||||
total += len(raw)
|
|
||||||
if total > MAX_UNCOMPRESSED:
|
|
||||||
return jsonify(ok=False, error="total uncompressed size exceeds 500 MB"), 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)
|
return jsonify(ok=True, files=files)
|
||||||
|
|
||||||
|
|
||||||
|
@upload_bp.route("/api/unzip_refs", methods=["POST"])
|
||||||
|
def unzip_refs():
|
||||||
|
"""Распаковать ZIP через ВМ-буфер (паттерн drhider): pull ZIP с ВМ → распаковка."""
|
||||||
|
data = request.get_json(silent=True) or {}
|
||||||
|
files = data.get("files") or []
|
||||||
|
if not files:
|
||||||
|
return jsonify(ok=False, error="no files"), 400
|
||||||
|
ref = files[0]
|
||||||
|
try:
|
||||||
|
name, content = _pull_from_ref(ref)
|
||||||
|
except Exception as e:
|
||||||
|
return jsonify(ok=False, error=str(e)), 400
|
||||||
|
ok, unzipped, err = _unzip(content)
|
||||||
|
if not ok:
|
||||||
|
return jsonify(ok=False, error=err), 400
|
||||||
|
return jsonify(ok=True, files=unzipped)
|
||||||
|
|||||||
@@ -14,8 +14,8 @@ import json, re, os
|
|||||||
from concurrent.futures import ThreadPoolExecutor, as_completed
|
from concurrent.futures import ThreadPoolExecutor, as_completed
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
from site.db import documents as db_docs
|
from db import documents as db_docs
|
||||||
from site.llm_prompt import build_classify_prompt
|
from llm_prompt import build_classify_prompt
|
||||||
|
|
||||||
log = __import__("logging").getLogger(__name__)
|
log = __import__("logging").getLogger(__name__)
|
||||||
|
|
||||||
@@ -34,7 +34,7 @@ _classify_llm = None
|
|||||||
def _get_classify_client():
|
def _get_classify_client():
|
||||||
global _classify_llm
|
global _classify_llm
|
||||||
if _classify_llm is None:
|
if _classify_llm is None:
|
||||||
from site.services.llm_client import HttpxLLMClient
|
from services.llm_client import HttpxLLMClient
|
||||||
_classify_llm = HttpxLLMClient(url=LLM_URL, key=LLM_KEY, model=LLM_MODEL, max_tokens=1000, timeout=60)
|
_classify_llm = HttpxLLMClient(url=LLM_URL, key=LLM_KEY, model=LLM_MODEL, max_tokens=1000, timeout=60)
|
||||||
return _classify_llm
|
return _classify_llm
|
||||||
|
|
||||||
|
|||||||
@@ -9,9 +9,9 @@ Grouping service — match classified documents into contract groups.
|
|||||||
- apply_groups(): создаёт contracts + supplements с авто-порядком по дате.
|
- apply_groups(): создаёт contracts + supplements с авто-порядком по дате.
|
||||||
"""
|
"""
|
||||||
import re
|
import re
|
||||||
from site.db import documents as db_docs
|
from db import documents as db_docs
|
||||||
from site.db import contracts as db_contracts
|
from db import contracts as db_contracts
|
||||||
from site.db import supplements as db_supplements
|
from db import supplements as db_supplements
|
||||||
|
|
||||||
|
|
||||||
def normalize_number(num):
|
def normalize_number(num):
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ _llm_client = None
|
|||||||
def _get_default_client():
|
def _get_default_client():
|
||||||
global _llm_client
|
global _llm_client
|
||||||
if _llm_client is None:
|
if _llm_client is None:
|
||||||
from site.services.llm_client import HttpxLLMClient
|
from services.llm_client import HttpxLLMClient
|
||||||
_llm_client = HttpxLLMClient(url=LLM_URL, key=LLM_KEY, model=LLM_MODEL)
|
_llm_client = HttpxLLMClient(url=LLM_URL, key=LLM_KEY, model=LLM_MODEL)
|
||||||
return _llm_client
|
return _llm_client
|
||||||
|
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ class HttpxLLMClient:
|
|||||||
"max_tokens": self.max_tokens,
|
"max_tokens": self.max_tokens,
|
||||||
"temperature": self.temperature,
|
"temperature": self.temperature,
|
||||||
}
|
}
|
||||||
with httpx.Client(http2=True, timeout=self.timeout, verify=True) as client:
|
with httpx.Client(timeout=self.timeout, verify=True) as client:
|
||||||
resp = client.post(
|
resp = client.post(
|
||||||
self.url,
|
self.url,
|
||||||
json=payload,
|
json=payload,
|
||||||
|
|||||||
+19
-25
@@ -2,8 +2,8 @@
|
|||||||
Адаптирован из deploy/compare/process.py: callback → generator.
|
Адаптирован из deploy/compare/process.py: callback → generator.
|
||||||
"""
|
"""
|
||||||
import json, time
|
import json, time
|
||||||
from site.db import supplements, spec_current, spec_events
|
from db import supplements, spec_current, spec_events
|
||||||
from site.services.metrics import check_arithmetic
|
from services.metrics import check_arithmetic
|
||||||
|
|
||||||
|
|
||||||
def run_pipeline(contract_id, order_ids, build_prompt_fn):
|
def run_pipeline(contract_id, order_ids, build_prompt_fn):
|
||||||
@@ -31,7 +31,7 @@ def run_pipeline(contract_id, order_ids, build_prompt_fn):
|
|||||||
yield {"type": "error", "message": "Нет распарсенных файлов"}
|
yield {"type": "error", "message": "Нет распарсенных файлов"}
|
||||||
return
|
return
|
||||||
|
|
||||||
from site.services.llm import call_llm
|
from services.llm import call_llm
|
||||||
|
|
||||||
for s in supps:
|
for s in supps:
|
||||||
sid = s["id"]
|
sid = s["id"]
|
||||||
@@ -86,30 +86,24 @@ def run_pipeline(contract_id, order_ids, build_prompt_fn):
|
|||||||
"time_s": round(time.time() - t1, 1),
|
"time_s": round(time.time() - t1, 1),
|
||||||
}
|
}
|
||||||
|
|
||||||
# Apply ops to DB
|
# Apply ops to DB via apply_ops
|
||||||
applied_ops = []
|
try:
|
||||||
summary = {"added": 0, "updated": 0, "deleted": 0, "unresolved": 0}
|
summary = spec_events.apply_ops(
|
||||||
for op in ops:
|
contract_id, sid, s["document_id"], ops, prompt_id, result
|
||||||
action = op.get("action", "UNRESOLVED")
|
)
|
||||||
summary[action.lower()] = summary.get(action.lower(), 0) + 1
|
applied_ops = ops
|
||||||
try:
|
except Exception as e:
|
||||||
if action == "ADD":
|
summary = {"added": 0, "updated": 0, "deleted": 0, "unresolved": len(ops)}
|
||||||
nr = op.get("new_row", {})
|
applied_ops = []
|
||||||
spec_events.add_row(contract_id, sid, nr)
|
yield {
|
||||||
elif action == "UPDATE":
|
"type": "extract_error",
|
||||||
nr = op.get("new_row", {})
|
"supplement_id": sid,
|
||||||
nv = op.get("new_values", {})
|
"filename": filename,
|
||||||
target = op.get("target_hash", "")
|
"error": str(e),
|
||||||
spec_events.update_row(contract_id, sid, target, nr, nv)
|
}
|
||||||
elif action == "DELETE":
|
|
||||||
target = op.get("target_hash", "")
|
|
||||||
spec_events.delete_row(contract_id, sid, target)
|
|
||||||
applied_ops.append(op)
|
|
||||||
except Exception:
|
|
||||||
summary["unresolved"] = summary.get("unresolved", 0) + 1
|
|
||||||
|
|
||||||
# Arithmetic check
|
# Arithmetic check
|
||||||
check_arithmetic(contract_id)
|
check_arithmetic(ops)
|
||||||
|
|
||||||
yield {
|
yield {
|
||||||
"type": "applied",
|
"type": "applied",
|
||||||
|
|||||||
+8
-5
@@ -4,6 +4,9 @@ var VM_API = '';
|
|||||||
var UPLOAD_URL = '/upload';
|
var UPLOAD_URL = '/upload';
|
||||||
var CONVERT_URL = '/convert-doc';
|
var CONVERT_URL = '/convert-doc';
|
||||||
var UNZIP_URL = '/unzip-upload';
|
var UNZIP_URL = '/unzip-upload';
|
||||||
|
// ВМ-буфер загрузки (паттерн drhider): браузер кладёт файл сюда (WebDAV, мимо шлюза),
|
||||||
|
// бэк сам тянет его по /api/upload_refs. Origin должен быть в CORS на nginx ВМ.
|
||||||
|
var VM_UPLOAD_URL = 'https://contracts.kube5s.ru/contracts-upload/';
|
||||||
// SITE_URL удалён (Фаза 4) — не использовался
|
// SITE_URL удалён (Фаза 4) — не использовался
|
||||||
|
|
||||||
var fileInput = document.getElementById('fileInput');
|
var fileInput = document.getElementById('fileInput');
|
||||||
@@ -13,11 +16,11 @@ var fileTable = document.getElementById('fileTable');
|
|||||||
// state.batchId — теперь в state.js (Фаза 0: state + render)
|
// state.batchId — теперь в state.js (Фаза 0: state + render)
|
||||||
// (batchId = crypto.randomUUID() — уникальный ID сессии для классификации)
|
// (batchId = crypto.randomUUID() — уникальный ID сессии для классификации)
|
||||||
|
|
||||||
// Автоочистка старых записей при загрузке страницы
|
// Прогрев upstream + автоочистка при загрузке страницы
|
||||||
(async function cleanup() {
|
// ⚠️ Без прогрева первый POST может упасть с ERR_CONNECTION_RESET (MTU/Geneve)
|
||||||
try {
|
(async function init() {
|
||||||
await fetch(VM_API + '/api/cleanup', { method: 'POST' });
|
try { await fetch('/health'); } catch(e) { /* ignore */ }
|
||||||
} catch(e) { /* ignore */ }
|
try { await fetch(VM_API + '/api/cleanup', { method: 'POST' }); } catch(e) { /* ignore */ }
|
||||||
})();
|
})();
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
+55
-4
@@ -1,4 +1,55 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
|
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||||
<rect width="32" height="32" rx="4" fill="#001C34"/>
|
<svg
|
||||||
<text x="16" y="24" text-anchor="middle" font-size="22" font-weight="bold" fill="white" font-family="sans-serif">N</text>
|
width="57"
|
||||||
</svg>
|
height="57"
|
||||||
|
xml:space="preserve"
|
||||||
|
overflow="hidden"
|
||||||
|
version="1.1"
|
||||||
|
id="svg8"
|
||||||
|
sodipodi:docname="U_v3.svg"
|
||||||
|
inkscape:version="1.3.2 (091e20e, 2023-11-25, custom)"
|
||||||
|
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
|
||||||
|
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
xmlns:svg="http://www.w3.org/2000/svg"><sodipodi:namedview
|
||||||
|
id="namedview8"
|
||||||
|
pagecolor="#ffffff"
|
||||||
|
bordercolor="#000000"
|
||||||
|
borderopacity="0.25"
|
||||||
|
inkscape:showpageshadow="2"
|
||||||
|
inkscape:pageopacity="0.0"
|
||||||
|
inkscape:pagecheckerboard="0"
|
||||||
|
inkscape:deskcolor="#d1d1d1"
|
||||||
|
inkscape:zoom="16.226667"
|
||||||
|
inkscape:cx="27.146672"
|
||||||
|
inkscape:cy="28.317584"
|
||||||
|
inkscape:window-width="2560"
|
||||||
|
inkscape:window-height="1494"
|
||||||
|
inkscape:window-x="-11"
|
||||||
|
inkscape:window-y="-11"
|
||||||
|
inkscape:window-maximized="1"
|
||||||
|
inkscape:current-layer="svg8" /><defs
|
||||||
|
id="defs2"><clipPath
|
||||||
|
id="clip0"><rect
|
||||||
|
x="652"
|
||||||
|
y="420"
|
||||||
|
width="64"
|
||||||
|
height="75"
|
||||||
|
id="rect1" /></clipPath><clipPath
|
||||||
|
id="clip1"><rect
|
||||||
|
x="652"
|
||||||
|
y="420"
|
||||||
|
width="64"
|
||||||
|
height="70"
|
||||||
|
id="rect2" /></clipPath></defs><g
|
||||||
|
clip-path="url(#clip0)"
|
||||||
|
transform="matrix(1.0135748,0,0,1.0135748,-664.97034,-440.30567)"
|
||||||
|
id="g8"><g
|
||||||
|
clip-path="url(#clip1)"
|
||||||
|
id="g7"><g
|
||||||
|
id="g6"><path
|
||||||
|
d="m 114.028,43.1492 v 7.6592 c 0,3.7843 -3.08,6.8632 -6.866,6.8632 L 85.3367,57.5419 c -3.7853,0 -6.8655,-3.0805 -6.8655,-6.8643 v -2.5279 l 0.0555,0.009 V 15.8148 l -10.3823,2.0127 0.0045,3.8772 -0.0045,0.0015 v 28.971 c 0,9.481 7.7132,17.1923 17.1867,17.1923 l 21.8359,0.1313 c 9.474,0 17.187,-7.7117 17.187,-17.1928 v -2.6541 l 0.027,0.0045 V 15.8145 l -10.382,2.0126 0.028,25.3214 z"
|
||||||
|
fill="#001c34"
|
||||||
|
fill-rule="evenodd"
|
||||||
|
transform="matrix(1,0,0,1.01337,587.92,420.059)"
|
||||||
|
id="path6" /></g></g></g></svg>
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 246 B After Width: | Height: | Size: 2.0 KiB |
+176
-102
@@ -1,27 +1,24 @@
|
|||||||
/**
|
/**
|
||||||
* files.js — Модуль работы с файлами (Фаза 1, decoupling-final-plan.md).
|
* files.js — Модуль работы с файлами.
|
||||||
*
|
*
|
||||||
* ВЫНЕСЕНО из app.js:
|
* ⛔⛔⛔ НЕ МЕНЯТЬ БЕЗ РАЗРЕШЕНИЯ НАЕЛЯ ⛔⛔⛔
|
||||||
* - statusToHTML(st) — чистая: структура → HTML
|
|
||||||
* - renderFiles(state) — рендер таблицы файлов
|
|
||||||
* - syncDB() — синхронизация БД с fileQueue
|
|
||||||
* - toggleClassifyDetail(i) — раскрыть результат классификации
|
|
||||||
* - uploadFile(file, cb) — XHR-загрузка одного файла (.doc/.docx/.pdf)
|
|
||||||
* - refreshSupps() — обновить supplement_id для файлов
|
|
||||||
*
|
*
|
||||||
* ЗАВИСИМОСТИ (глобальные, загружаются раньше):
|
* v2.0.3: честный счётчик ⏳ соединение... Nс вместо фейкового ↑N%
|
||||||
* state.js → state (центральное состояние)
|
* Проверено в бою 2026-07-16. Любое изменение = риск сломать загрузку.
|
||||||
* app_utils.js → escHtml, formatSize, formatDate
|
|
||||||
* app.js → render(), stepDone, stepActive, resetStepper, showClassifyBtn
|
|
||||||
*
|
*
|
||||||
* ЗАГРУЖАЕТСЯ: после app_utils.js, перед app.js
|
* КЛЮЧЕВЫЕ ФУНКЦИИ (не трогать):
|
||||||
*/
|
* - uploadFile() — fetch-загрузка с имитацией прогресса
|
||||||
|
* - onFilesSelected() — все строки в таблицу сразу, потом загрузка по одной
|
||||||
|
* - statusToHTML() — рендер статуса (↑ N%, ✓)
|
||||||
|
* - renderFiles() — рендер всей таблицы
|
||||||
|
|
||||||
|
var CONVERT_URL = '/convert-doc';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* statusToHTML(status) — Чистая функция: структура → HTML (Фаза 1).
|
* statusToHTML(status) — Чистая функция: структура → HTML (Фаза 1).
|
||||||
*
|
*
|
||||||
* Вход: { kind, pct?, text?, count?, elapsed? } — ни одного HTML-тега.
|
* Вход: { kind, pct?, text?, count?, elapsed? } — ни одного HTML-тега.
|
||||||
* kind = 'uploading' | 'uploaded' | 'unzipping' | 'parsing' | 'parsed' | 'error' | ''
|
* kind = 'uploading' | 'uploaded' | 'unzipping' | 'parsing' | 'parsed' | 'error' | '' | 'connecting'
|
||||||
* Выход: безопасная HTML-строка (статусы не содержат пользовательских данных).
|
* Выход: безопасная HTML-строка (статусы не содержат пользовательских данных).
|
||||||
*
|
*
|
||||||
* ПАТТЕРН (decoupling-final-plan.md): отделяем данные от представления.
|
* ПАТТЕРН (decoupling-final-plan.md): отделяем данные от представления.
|
||||||
@@ -30,7 +27,9 @@
|
|||||||
function statusToHTML(st) {
|
function statusToHTML(st) {
|
||||||
if (!st || !st.kind) return '';
|
if (!st || !st.kind) return '';
|
||||||
switch (st.kind) {
|
switch (st.kind) {
|
||||||
case 'uploading': return '↑ ' + (st.pct || 0) + '%';
|
case 'connecting': return '⏳ соединение... ' + (st.elapsed || 0) + 'с';
|
||||||
|
case 'converting': return '⏳ конвертация... ' + (st.elapsed || 0) + 'с';
|
||||||
|
case 'uploading': return '⏳ отправка... ' + (st.elapsed || 0) + 'с';
|
||||||
case 'uploaded': return '<span class="status-ok">✓</span>';
|
case 'uploaded': return '<span class="status-ok">✓</span>';
|
||||||
case 'unzipping': return '⏳ распаковка...';
|
case 'unzipping': return '⏳ распаковка...';
|
||||||
case 'parsing': return '⏳ парсинг...';
|
case 'parsing': return '⏳ парсинг...';
|
||||||
@@ -55,7 +54,7 @@ function statusToHTML(st) {
|
|||||||
*/
|
*/
|
||||||
function renderFiles(state) {
|
function renderFiles(state) {
|
||||||
if (state.files.length === 0) {
|
if (state.files.length === 0) {
|
||||||
fileTable.innerHTML = '<tr class="empty-row"><td colspan="7">Нет файлов — выберите .docx / .pdf</td></tr>';
|
fileTable.innerHTML = '<tr class="empty-row"><td colspan="7">Нет файлов — выберите .doc / .docx / .pdf</td></tr>';
|
||||||
lucide.createIcons();
|
lucide.createIcons();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -208,64 +207,105 @@ window.toggleClassifyDetail = async function(i) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* uploadFile(file, onProgress) — XHR-загрузка одного файла на бэкенд.
|
* convertDoc(file, onProgress) — .doc → .docx через ВМ-буфер (паттерн drhider).
|
||||||
|
* Фаза 1: PUT .doc на ВМ. Фаза 2: /api/convert_refs → pull → конвертация → .docx.
|
||||||
|
*/
|
||||||
|
function convertDoc(file, onProgress) {
|
||||||
|
var startTime = Date.now();
|
||||||
|
if (onProgress) onProgress({ kind: 'converting', elapsed: 0 });
|
||||||
|
var token = crypto.randomUUID();
|
||||||
|
var vmUrl = VM_UPLOAD_URL + token + '_0';
|
||||||
|
|
||||||
|
var timer = setInterval(function() {
|
||||||
|
var elapsed = Math.floor((Date.now() - startTime) / 1000);
|
||||||
|
if (onProgress) onProgress({ kind: 'converting', elapsed: elapsed });
|
||||||
|
}, 1000);
|
||||||
|
|
||||||
|
return fetch(vmUrl, { method: 'PUT', body: file, headers: { 'Content-Type': 'application/octet-stream' } })
|
||||||
|
.then(function(r) {
|
||||||
|
if (!r.ok) throw new Error('Конвертация: VM upload HTTP ' + r.status);
|
||||||
|
return fetch('/api/convert_refs?_=' + Date.now(), {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ files: [{ name: file.name, size: file.size, url: vmUrl }] })
|
||||||
|
});
|
||||||
|
})
|
||||||
|
.then(function(r) {
|
||||||
|
if (!r.ok) throw new Error('Конвертация: HTTP ' + r.status);
|
||||||
|
return r.blob();
|
||||||
|
})
|
||||||
|
.then(function(blob) {
|
||||||
|
clearInterval(timer);
|
||||||
|
if (blob.size === 0) throw new Error('Конвертация: пустой результат');
|
||||||
|
return new File([blob], file.name.replace(/\.doc$/i, '.docx'), {
|
||||||
|
type: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
||||||
|
lastModified: Date.now()
|
||||||
|
});
|
||||||
|
})
|
||||||
|
.catch(function(e) {
|
||||||
|
clearInterval(timer);
|
||||||
|
if (e.message === 'Failed to fetch' || e.name === 'TypeError') throw new Error('Конвертация: Сеть');
|
||||||
|
throw e;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ⛔ НЕ МЕНЯТЬ БЕЗ РАЗРЕШЕНИЯ НАЕЛЯ ⛔ uploadFile — загрузка через ВМ-буфер (паттерн drhider).
|
||||||
*
|
*
|
||||||
* Особенности:
|
* Фаза 1: PUT файла на ВМ (WebDAV /contracts-upload/, мимо шлюза кластера ~64КБ).
|
||||||
* - .doc (не .docx!) конвертируется через CONVERT_URL перед загрузкой
|
* Фаза 2: POST /api/upload_refs {files:[{name,size,url}]} — бэк сам тянет файл с ВМ.
|
||||||
* - onProgress(pct) — callback с процентом загрузки (0-100)
|
* Честный счётчик времени: ⏳ соединение... Nс → ⏳ отправка... Nс (fetch не даёт progress).
|
||||||
* - Возвращает Promise<ответ API> с полями doc_id, contract_id, parsed
|
|
||||||
* - Таймаут 180с (большие PDF)
|
|
||||||
*/
|
*/
|
||||||
function uploadFile(file, onProgress, zipSource) {
|
function uploadFile(file, onProgress, zipSource) {
|
||||||
return new Promise(function(resolve, reject) {
|
var startTime = Date.now();
|
||||||
// .doc → конвертация в docx (старый формат Word)
|
var phase = 'connecting'; // connecting → uploading
|
||||||
var isDoc = file.name.toLowerCase().endsWith('.doc') && !file.name.toLowerCase().endsWith('.docx');
|
if (onProgress) onProgress({ kind: 'connecting', elapsed: 0 });
|
||||||
var uploadFile = file;
|
var token = crypto.randomUUID();
|
||||||
var uploadName = file.name;
|
var vmUrl = VM_UPLOAD_URL + token + '_0';
|
||||||
|
|
||||||
function doUpload() {
|
// Честный счётчик: каждую секунду обновляем elapsed
|
||||||
var xhr = new XMLHttpRequest();
|
var timer = setInterval(function() {
|
||||||
var fd = new FormData();
|
var elapsed = Math.floor((Date.now() - startTime) / 1000);
|
||||||
fd.append('files', uploadFile, uploadName);
|
if (onProgress) onProgress({ kind: phase, elapsed: elapsed });
|
||||||
if (state.contractId) fd.append('contract_id', state.contractId);
|
}, 1000);
|
||||||
fd.append('batch_id', state.batchId);
|
|
||||||
if (zipSource) fd.append('zip_source', zipSource);
|
|
||||||
xhr.open('POST', UPLOAD_URL);
|
|
||||||
xhr.upload.onprogress = function(e) {
|
|
||||||
if (e.lengthComputable && onProgress) onProgress(Math.round(e.loaded / e.total * 100));
|
|
||||||
};
|
|
||||||
xhr.onload = function() {
|
|
||||||
try {
|
|
||||||
var r = JSON.parse(xhr.responseText);
|
|
||||||
if (r.ok) resolve(r);
|
|
||||||
else reject(new Error(r.error || 'Неизвестная ошибка'));
|
|
||||||
} catch(e) { reject(new Error('Некорректный ответ')); }
|
|
||||||
};
|
|
||||||
xhr.onerror = function() { reject(new Error('Сеть')); };
|
|
||||||
xhr.ontimeout = function() { reject(new Error('Таймаут')); };
|
|
||||||
xhr.timeout = 180000;
|
|
||||||
xhr.send(fd);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isDoc) {
|
// Фаза 1: PUT файла на ВМ-буфер
|
||||||
var xhr = new XMLHttpRequest();
|
return fetch(vmUrl, { method: 'PUT', body: file, headers: { 'Content-Type': 'application/octet-stream' } })
|
||||||
xhr.open('POST', CONVERT_URL);
|
.then(function(r) {
|
||||||
xhr.responseType = 'blob';
|
if (!r.ok) throw new Error('VM upload HTTP ' + r.status);
|
||||||
xhr.onload = function() {
|
phase = 'uploading';
|
||||||
if (xhr.status === 200 && xhr.response.size > 100) {
|
// Фаза 2: refs на бэкенд → pull с ВМ
|
||||||
uploadFile = xhr.response;
|
var body = { batch_id: state.batchId, files: [{ name: file.name, size: file.size, url: vmUrl }] };
|
||||||
uploadName = file.name.replace(/\.doc$/i, '.docx');
|
if (state.contractId) body.contract_id = state.contractId;
|
||||||
doUpload();
|
if (zipSource) body.zip_source = zipSource;
|
||||||
} else {
|
return fetch('/api/upload_refs?_=' + Date.now(), {
|
||||||
reject(new Error('Конвертация .doc'));
|
method: 'POST',
|
||||||
}
|
headers: { 'Content-Type': 'application/json' },
|
||||||
};
|
body: JSON.stringify(body)
|
||||||
xhr.onerror = function() { reject(new Error('Конвертер')); };
|
});
|
||||||
xhr.send(file);
|
})
|
||||||
} else {
|
.then(function(r) {
|
||||||
doUpload();
|
if (!r.ok) throw new Error('HTTP ' + r.status);
|
||||||
}
|
return r.json();
|
||||||
});
|
})
|
||||||
|
.then(function(data) {
|
||||||
|
clearInterval(timer);
|
||||||
|
if (data.ok && data.results && data.results.length === 1) {
|
||||||
|
var res = data.results[0];
|
||||||
|
if (!res.ok) throw new Error(res.error || 'Неизвестная ошибка');
|
||||||
|
var elapsed = Math.floor((Date.now() - startTime) / 1000);
|
||||||
|
if (onProgress) onProgress({ kind: 'uploading', elapsed: elapsed });
|
||||||
|
return res; // {ok, doc_id, contract_id, parsed}
|
||||||
|
}
|
||||||
|
throw new Error(data.error || 'Неизвестная ошибка');
|
||||||
|
})
|
||||||
|
.catch(function(e) {
|
||||||
|
clearInterval(timer);
|
||||||
|
if (e.message === 'Failed to fetch' || e.name === 'TypeError') {
|
||||||
|
throw new Error('Сеть');
|
||||||
|
}
|
||||||
|
throw e;
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -355,19 +395,18 @@ async function addZipFile(file) {
|
|||||||
render(state);
|
render(state);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Шаг 1: распаковать ZIP на бэкенде
|
// Шаг 1: распаковать ZIP через ВМ-буфер (паттерн drhider)
|
||||||
var zipResp = await new Promise(function(resolve, reject) {
|
var token = crypto.randomUUID();
|
||||||
var xhr = new XMLHttpRequest();
|
var vmUrl = VM_UPLOAD_URL + token + '_0';
|
||||||
xhr.open('POST', UNZIP_URL);
|
var putResp = await fetch(vmUrl, { method: 'PUT', body: file, headers: { 'Content-Type': 'application/octet-stream' } });
|
||||||
xhr.responseType = 'json';
|
if (!putResp.ok) throw new Error('unzip failed: VM upload HTTP ' + putResp.status);
|
||||||
xhr.onload = function() { resolve(xhr.response); };
|
var refsResp = await fetch('/api/unzip_refs?_=' + Date.now(), {
|
||||||
xhr.onerror = function() { reject(new Error('Сеть')); };
|
method: 'POST',
|
||||||
xhr.ontimeout = function() { reject(new Error('Таймаут')); };
|
headers: { 'Content-Type': 'application/json' },
|
||||||
xhr.timeout = 60000;
|
body: JSON.stringify({ files: [{ name: file.name, size: file.size, url: vmUrl }] })
|
||||||
var fd = new FormData();
|
|
||||||
fd.append('files', file);
|
|
||||||
xhr.send(fd);
|
|
||||||
});
|
});
|
||||||
|
if (!refsResp.ok) throw new Error('unzip failed: HTTP ' + refsResp.status);
|
||||||
|
var zipResp = await refsResp.json();
|
||||||
if (!zipResp.ok || !zipResp.files) throw new Error('unzip failed');
|
if (!zipResp.ok || !zipResp.files) throw new Error('unzip failed');
|
||||||
|
|
||||||
// Подтверждение: показать первые 10 файлов + итог
|
// Подтверждение: показать первые 10 файлов + итог
|
||||||
@@ -425,7 +464,7 @@ async function addZipFile(file) {
|
|||||||
*/
|
*/
|
||||||
async function addRegularFile(file, zipSource) {
|
async function addRegularFile(file, zipSource) {
|
||||||
// Создать запись с начальным статусом
|
// Создать запись с начальным статусом
|
||||||
var entry = { name: file.name, lastModified: file.lastModified, size: file.size, file: file, status: { kind: 'uploading', pct: 0 }, zip_source: zipSource || null };
|
var entry = { name: file.name, lastModified: file.lastModified, size: file.size, file: file, status: { kind: 'connecting', elapsed: 0 }, zip_source: zipSource || null };
|
||||||
|
|
||||||
// ДЕДУПЛИКАЦИЯ: ключ = (zip_source, name), чтобы одноимённые файлы из разных ZIP не затирались
|
// ДЕДУПЛИКАЦИЯ: ключ = (zip_source, name), чтобы одноимённые файлы из разных ZIP не затирались
|
||||||
var dupKey = (zipSource || '') + '/' + file.name;
|
var dupKey = (zipSource || '') + '/' + file.name;
|
||||||
@@ -449,9 +488,18 @@ async function addRegularFile(file, zipSource) {
|
|||||||
render(state);
|
render(state);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// XHR-загрузка с прогрессом
|
// .doc → конвертация через libreoffice-сервис
|
||||||
var resp = await uploadFile(file, function(pct) {
|
var uploadTarget = file;
|
||||||
state.files[rowIdx].status = { kind: 'uploading', pct: pct };
|
if (file.name.toLowerCase().endsWith('.doc')) {
|
||||||
|
uploadTarget = await convertDoc(file, function(st) {
|
||||||
|
state.files[rowIdx].status = st;
|
||||||
|
render(state);
|
||||||
|
});
|
||||||
|
state.files[rowIdx].name = uploadTarget.name;
|
||||||
|
}
|
||||||
|
// fetch-загрузка с честным счётчиком времени
|
||||||
|
var resp = await uploadFile(uploadTarget, function(st) {
|
||||||
|
state.files[rowIdx].status = st;
|
||||||
render(state);
|
render(state);
|
||||||
}, zipSource);
|
}, zipSource);
|
||||||
// Бэкенд возвращает doc_id и contract_id (нижний регистр — Python keys)
|
// Бэкенд возвращает doc_id и contract_id (нижний регистр — Python keys)
|
||||||
@@ -511,36 +559,62 @@ async function finalizeUpload() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* onFilesSelected(newFiles) — Оркестратор загрузки (Фаза 1).
|
* ⛔ НЕ МЕНЯТЬ ⛔ onFilesSelected — все строки сразу, потом загрузка.
|
||||||
*
|
*
|
||||||
* ПАТТЕРН:
|
* v2.0.2: Фаза 1 — все строки в таблицу. Фаза 2 — загрузка по одной.
|
||||||
* 1. Для каждого файла: addZipFile (ZIP) или addRegularFile (обычный)
|
* Юзер видит таблицу целиком, каждая строка обновляется независимо.
|
||||||
* 2. finalizeUpload — завершить цикл
|
|
||||||
*
|
|
||||||
* НЕ удаляет существующие файлы — только добавляет новые.
|
|
||||||
* Дубликаты обрабатываются через confirm() в addRegularFile.
|
|
||||||
* Удаление — только вручную (кнопка ✕).
|
|
||||||
*
|
|
||||||
* Вызывается из fileInput.addEventListener('change', ...).
|
|
||||||
*/
|
*/
|
||||||
async function onFilesSelected(newFiles) {
|
async function onFilesSelected(newFiles) {
|
||||||
if (newFiles.length === 0) return;
|
if (newFiles.length === 0) return;
|
||||||
|
|
||||||
fileInput.disabled = true;
|
fileInput.disabled = true;
|
||||||
|
|
||||||
// Сбросить прогресс пайплайна при добавлении новых файлов
|
|
||||||
resetStepper('stepUpload');
|
resetStepper('stepUpload');
|
||||||
|
|
||||||
// Обработать каждый файл
|
// Фаза 1: ВСЕ строки в таблицу сразу
|
||||||
for (var i = 0; i < newFiles.length; i++) {
|
for (var i = 0; i < newFiles.length; i++) {
|
||||||
var f = newFiles[i];
|
var f = newFiles[i];
|
||||||
if (f.name.toLowerCase().endsWith('.zip')) {
|
if (f.name.toLowerCase().endsWith('.zip')) {
|
||||||
await addZipFile(f);
|
await addZipFile(f);
|
||||||
} else {
|
continue;
|
||||||
await addRegularFile(f);
|
|
||||||
}
|
}
|
||||||
|
state.files.push({ name: f.name, lastModified: f.lastModified, size: f.size, file: f, status: { kind: 'connecting', elapsed: 0 }, zip_source: null });
|
||||||
|
state.files[state.files.length - 1]._pendingFile = f;
|
||||||
|
}
|
||||||
|
render(state);
|
||||||
|
|
||||||
|
// Фаза 2: загрузка по одному с обновлением строки
|
||||||
|
for (var j = 0; j < state.files.length; j++) {
|
||||||
|
var entry = state.files[j];
|
||||||
|
var pendingFile = entry._pendingFile;
|
||||||
|
if (!pendingFile) continue;
|
||||||
|
delete entry._pendingFile;
|
||||||
|
var f = pendingFile;
|
||||||
|
try {
|
||||||
|
// .doc → конвертация через libreoffice-сервис
|
||||||
|
var uploadTarget = f;
|
||||||
|
if (f.name.toLowerCase().endsWith('.doc')) {
|
||||||
|
uploadTarget = await convertDoc(f, function(st) {
|
||||||
|
entry.status = st;
|
||||||
|
render(state);
|
||||||
|
});
|
||||||
|
entry.name = uploadTarget.name;
|
||||||
|
}
|
||||||
|
var resp = await uploadFile(uploadTarget, function(st) {
|
||||||
|
entry.status = st;
|
||||||
|
render(state);
|
||||||
|
});
|
||||||
|
if (resp && resp.contract_id) state.contractId = resp.contract_id;
|
||||||
|
entry.doc_id = resp.doc_id;
|
||||||
|
entry.status = { kind: 'uploaded' };
|
||||||
|
entry.uploaded = true;
|
||||||
|
var pr = resp.parsed;
|
||||||
|
var elapsed = pr && pr.status === 'parsed' ? '0.0' : null;
|
||||||
|
applyParseResult(entry, pr, elapsed);
|
||||||
|
} catch(err) {
|
||||||
|
entry.status = { kind: 'error', text: err.message };
|
||||||
|
}
|
||||||
|
render(state);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Завершить цикл
|
|
||||||
await finalizeUpload();
|
await finalizeUpload();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -73,7 +73,7 @@
|
|||||||
<body>
|
<body>
|
||||||
<div class="topbar">
|
<div class="topbar">
|
||||||
<img src="/static/logo.svg" alt="Nubes">
|
<img src="/static/logo.svg" alt="Nubes">
|
||||||
<span class="title">Сверка договоров — LLM AI-driven Event Sourcing <span style="font-weight:400;color:var(--muted);font-size:12px;">v2.0.0</span></span>
|
<span class="title">Сверка договоров — LLM AI-driven Event Sourcing <span style="font-weight:400;color:var(--muted);font-size:12px;">v2.0.11</span></span>
|
||||||
<div id="pipelineStepper" style="display:flex;gap:8px;font-size:11px;align-items:center;color:var(--muted);">
|
<div id="pipelineStepper" style="display:flex;gap:8px;font-size:11px;align-items:center;color:var(--muted);">
|
||||||
<span id="stepUpload">○ Загрузка</span><span>→</span>
|
<span id="stepUpload">○ Загрузка</span><span>→</span>
|
||||||
<span id="stepClassify">○ Классификация</span><span>→</span>
|
<span id="stepClassify">○ Классификация</span><span>→</span>
|
||||||
@@ -90,7 +90,7 @@
|
|||||||
Загрузка договоров/приложений/спецификаций
|
Загрузка договоров/приложений/спецификаций
|
||||||
</div>
|
</div>
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<input type="file" id="fileInput" accept=".docx,.doc,.pdf,.zip" multiple style="margin-bottom:6px;width:100%;">
|
<input type="file" id="fileInput" accept=".doc,.docx,.pdf,.zip" multiple style="margin-bottom:6px;width:100%;">
|
||||||
<div style="text-align:right;font-size:11px;color:var(--muted);margin-bottom:6px;">Порядок определяется автоматически при классификации</div>
|
<div style="text-align:right;font-size:11px;color:var(--muted);margin-bottom:6px;">Порядок определяется автоматически при классификации</div>
|
||||||
<div style="font-size:11px;color:var(--muted);margin-bottom:6px;">⚠ При совпадении имён — запрос на перезапись (OK / Отмена). Файлы из ZIP-архивов загружаются через тот же поток.</div>
|
<div style="font-size:11px;color:var(--muted);margin-bottom:6px;">⚠ При совпадении имён — запрос на перезапись (OK / Отмена). Файлы из ZIP-архивов загружаются через тот же поток.</div>
|
||||||
|
|
||||||
@@ -216,11 +216,11 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script src="/static/state.js?v=1.0.179-flask"></script>
|
<script src="/static/state.js?v=2.0.8"></script>
|
||||||
<script src="/static/app_utils.js?v=1.0.179-flask"></script>
|
<script src="/static/app_utils.js?v=2.0.8"></script>
|
||||||
<script src="/static/files.js?v=1.0.179-flask"></script>
|
<script src="/static/files.js?v=2.0.11"></script>
|
||||||
<script src="/static/groups.js?v=1.0.179-flask"></script>
|
<script src="/static/groups.js?v=2.0.8"></script>
|
||||||
<script src="/static/compare.js?v=1.0.179-flask"></script>
|
<script src="/static/compare.js?v=2.0.8"></script>
|
||||||
<script src="/static/app.js?v=1.0.179-flask"></script>
|
<script src="/static/app.js?v=2.0.10"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
Reference in New Issue
Block a user