Compare commits
36
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 |
@@ -3,4 +3,3 @@ gunicorn
|
||||
httpx
|
||||
python-docx
|
||||
pdfplumber
|
||||
psycopg2-binary
|
||||
|
||||
+14
-33
@@ -1,9 +1,13 @@
|
||||
"""contracts-flask v2.0 — полный перенос с ВМ на Flask.
|
||||
Больше никаких прокси на 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 site.config import VERSION, MAX_CONTENT_LENGTH
|
||||
from site.routes import register_routes
|
||||
from config import VERSION, MAX_CONTENT_LENGTH
|
||||
from routes import register_routes
|
||||
|
||||
|
||||
def create_app():
|
||||
@@ -11,13 +15,9 @@ def create_app():
|
||||
app.config["VERSION"] = VERSION
|
||||
app.config["MAX_CONTENT_LENGTH"] = MAX_CONTENT_LENGTH
|
||||
|
||||
# DB auto-seed — schema migration + seed prompts + crash recovery
|
||||
_init_db()
|
||||
|
||||
# Регистрация всех blueprint'ов
|
||||
register_routes(app)
|
||||
|
||||
# no_cache на все ответы
|
||||
@app.after_request
|
||||
def no_cache(response):
|
||||
response.headers["Cache-Control"] = "no-cache, no-store, must-revalidate"
|
||||
@@ -29,36 +29,17 @@ def create_app():
|
||||
|
||||
|
||||
def _init_db():
|
||||
"""Schema migration + recovery: сброс застрявших classify."""
|
||||
"""Создать БД + схему + seed prompts. SQLite — всё в одном файле /tmp."""
|
||||
from db.connection import init_db
|
||||
init_db()
|
||||
try:
|
||||
from site.db.connection import execute
|
||||
|
||||
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
|
||||
from db import prompts as db_prompts
|
||||
db_prompts.seed_defaults()
|
||||
|
||||
# Crash recovery: сбросить застрявшие classify
|
||||
execute(
|
||||
"UPDATE documents SET classify_status='pending', error_message=NULL "
|
||||
"WHERE classify_status='processing'"
|
||||
)
|
||||
except Exception:
|
||||
pass # БД недоступна при старте — не фатально
|
||||
pass
|
||||
|
||||
|
||||
app = create_app()
|
||||
|
||||
if __name__ == "__main__":
|
||||
app.run(host="0.0.0.0", port=5000, threaded=True)
|
||||
|
||||
+2
-9
@@ -1,15 +1,7 @@
|
||||
"""Конфигурация приложения — все настройки в одном месте."""
|
||||
import os
|
||||
|
||||
VERSION = "2.0.0"
|
||||
|
||||
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", ""),
|
||||
}
|
||||
VERSION = "2.0.9"
|
||||
|
||||
LLM_URL = os.getenv("LLM_API_URL", "https://api.aillm.ru/v1/chat/completions")
|
||||
LLM_KEY = os.getenv("LLM_API_KEY", "")
|
||||
@@ -17,3 +9,4 @@ LLM_MODEL = os.getenv("LLM_MODEL", "gpt-oss-120b")
|
||||
|
||||
MAX_CONTENT_LENGTH = 200 * 1024 * 1024 # 200 MB
|
||||
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")
|
||||
|
||||
+213
-76
@@ -1,91 +1,228 @@
|
||||
"""
|
||||
Database connection — psycopg2 connection pool.
|
||||
"""Database connection — SQLite with thread-local connections.
|
||||
|
||||
Архитектурное решение (Opus):
|
||||
- ThreadedConnectionPool(minconn=1, maxconn=10) — оптимально для ThreadingMixIn HTTP-сервера.
|
||||
Каждый HTTP-запрос в отдельном потоке получает своё соединение из пула.
|
||||
- RealDictCursor для query() — возвращает dict с lowercase ключами (консистентно с Python API).
|
||||
- execute()/execute_returning() — для INSERT/UPDATE/DELETE с автокоммитом и откатом при ошибке.
|
||||
- Пул создаётся лениво (при первом запросе) через get_pool().
|
||||
Архитектура (Sonnet review 2026-07-15):
|
||||
- threading.local() — каждому потоку своё соединение
|
||||
- WAL-режим — readers не блокируют writer
|
||||
- _db_session_key — защита от inode split-brain при cleanup+reuse потоков
|
||||
- busy_timeout=5000 — ждать при конкурентной записи
|
||||
"""
|
||||
import sqlite3
|
||||
import threading
|
||||
import time
|
||||
import os
|
||||
import psycopg2
|
||||
import psycopg2.pool
|
||||
import psycopg2.extras
|
||||
|
||||
# Глобальный пул соединений (singleton)
|
||||
_pool = None
|
||||
|
||||
# Параметры подключения из переменных окружения (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", ""),
|
||||
}
|
||||
DB_PATH = "/tmp/contracts.db"
|
||||
_db_session_key = None
|
||||
_local = threading.local()
|
||||
|
||||
|
||||
def get_pool():
|
||||
"""Возвращает глобальный пул соединений. Создаёт при первом вызове."""
|
||||
global _pool
|
||||
if _pool is None:
|
||||
_pool = psycopg2.pool.ThreadedConnectionPool(
|
||||
minconn=1, maxconn=10, **DB_CONFIG
|
||||
)
|
||||
return _pool
|
||||
def init_db():
|
||||
"""Создать/пересоздать БД + схему. Вызывается при старте и после cleanup."""
|
||||
global _db_session_key
|
||||
_db_session_key = time.time()
|
||||
|
||||
# Удалить старый файл + WAL-сателлиты
|
||||
for f in (DB_PATH, DB_PATH + "-wal", DB_PATH + "-shm"):
|
||||
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):
|
||||
"""
|
||||
SELECT → list[dict] с lowercase ключами.
|
||||
Используется всеми db/*.py модулями для чтения данных.
|
||||
"""
|
||||
pool = get_pool()
|
||||
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)
|
||||
"""SELECT → list[dict]."""
|
||||
conn = get_conn()
|
||||
sql = _pg_to_sqlite(sql)
|
||||
cur = conn.execute(sql, params or [])
|
||||
return [dict(r) for r in cur.fetchall()]
|
||||
|
||||
|
||||
def execute(sql, params=None):
|
||||
"""
|
||||
INSERT/UPDATE/DELETE → количество затронутых строк.
|
||||
Автокоммит. При ошибке — rollback и проброс исключения.
|
||||
"""
|
||||
pool = get_pool()
|
||||
conn = pool.getconn()
|
||||
try:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(sql, params)
|
||||
conn.commit()
|
||||
return cur.rowcount
|
||||
except Exception:
|
||||
conn.rollback()
|
||||
raise
|
||||
finally:
|
||||
pool.putconn(conn)
|
||||
"""INSERT/UPDATE/DELETE → rowcount."""
|
||||
conn = get_conn()
|
||||
sql = _pg_to_sqlite(sql)
|
||||
cur = conn.execute(sql, params or [])
|
||||
conn.commit()
|
||||
return cur.rowcount
|
||||
|
||||
|
||||
def execute_returning(sql, params=None):
|
||||
"""
|
||||
INSERT/UPDATE/DELETE с RETURNING → dict первой строки.
|
||||
Используется для insert с автогенерацией UUID (gen_random_uuid()).
|
||||
"""
|
||||
pool = get_pool()
|
||||
conn = pool.getconn()
|
||||
try:
|
||||
with conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor) as cur:
|
||||
cur.execute(sql, params)
|
||||
conn.commit()
|
||||
row = cur.fetchone()
|
||||
return {k.lower(): v for k, v in row.items()} if row else None
|
||||
except Exception:
|
||||
conn.rollback()
|
||||
raise
|
||||
finally:
|
||||
pool.putconn(conn)
|
||||
"""INSERT с RETURNING → dict (эмулируется через lastrowid)."""
|
||||
conn = get_conn()
|
||||
table = _extract_table(sql)
|
||||
sql = _pg_to_sqlite(sql)
|
||||
|
||||
if " RETURNING " in sql.upper():
|
||||
sql = sql[:sql.upper().rfind(" RETURNING ")]
|
||||
|
||||
cur = conn.execute(sql, params or [])
|
||||
conn.commit()
|
||||
rowid = cur.lastrowid
|
||||
|
||||
if table and rowid:
|
||||
row = conn.execute(f"SELECT * FROM {table} WHERE rowid = ?", (rowid,)).fetchone()
|
||||
if row:
|
||||
return dict(row)
|
||||
|
||||
# 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."""
|
||||
from .connection import query, execute, execute_returning
|
||||
import uuid
|
||||
from db.connection import query, execute
|
||||
|
||||
|
||||
def insert(number, client=""):
|
||||
return execute_returning(
|
||||
"INSERT INTO contracts (number, client) VALUES (%s, %s) RETURNING *",
|
||||
(number, client),
|
||||
cid = str(uuid.uuid4())
|
||||
execute(
|
||||
"INSERT INTO contracts (id, number, client) VALUES (%s, %s, %s)",
|
||||
(cid, number, client),
|
||||
)
|
||||
return get(cid)
|
||||
|
||||
|
||||
def get(contract_id):
|
||||
|
||||
@@ -1,14 +1,17 @@
|
||||
"""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):
|
||||
"""Insert document, return row dict."""
|
||||
return execute_returning(
|
||||
"""INSERT INTO documents (filename, mime_type, original_bytes, status, batch_id, zip_source, content_hash)
|
||||
VALUES (%s, %s, %s, %s, %s, %s, %s) RETURNING *""",
|
||||
(filename, mime_type, original_bytes, status, batch_id, zip_source, content_hash),
|
||||
doc_id = str(uuid.uuid4())
|
||||
execute(
|
||||
"""INSERT INTO documents (id, 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):
|
||||
|
||||
+12
-9
@@ -1,5 +1,6 @@
|
||||
"""Prompts CRUD."""
|
||||
from .connection import query, execute, execute_returning
|
||||
import uuid
|
||||
from db.connection import query, execute
|
||||
|
||||
|
||||
def _serialize(row):
|
||||
@@ -46,8 +47,8 @@ def seed_defaults():
|
||||
"ТЕКСТ ДОКУМЕНТА:\n---\n{doc_text}\n---"
|
||||
)
|
||||
execute(
|
||||
"INSERT INTO prompts (role, name, body, is_active, notes) VALUES (%s, %s, %s, %s, %s)",
|
||||
("extract", "default-v1", extract_body, True, "Авто-создан из llm_prompt.py _build_initial"),
|
||||
"INSERT INTO prompts (id, role, name, body, is_active, notes) VALUES (%s, %s, %s, %s, %s, %s)",
|
||||
(str(uuid.uuid4()), "extract", "default-v1", extract_body, True, "Авто-создан из llm_prompt.py _build_initial"),
|
||||
)
|
||||
|
||||
if "diff" not in existing_roles:
|
||||
@@ -75,8 +76,8 @@ def seed_defaults():
|
||||
"ТЕКСТ ДОПСОГЛАШЕНИЯ:\n---\n{doc_text}\n---"
|
||||
)
|
||||
execute(
|
||||
"INSERT INTO prompts (role, name, body, is_active, notes) VALUES (%s, %s, %s, %s, %s)",
|
||||
("diff", "default-v1", diff_body, True, "Авто-создан из llm_prompt.py _build_diff"),
|
||||
"INSERT INTO prompts (id, role, name, body, is_active, notes) VALUES (%s, %s, %s, %s, %s, %s)",
|
||||
(str(uuid.uuid4()), "diff", "default-v1", diff_body, True, "Авто-создан из llm_prompt.py _build_diff"),
|
||||
)
|
||||
_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."""
|
||||
if is_active:
|
||||
execute("UPDATE prompts SET is_active=false WHERE role=%s", (role,))
|
||||
return execute_returning(
|
||||
"""INSERT INTO prompts (role, name, body, is_active, notes)
|
||||
VALUES (%s, %s, %s, %s, %s) RETURNING *""",
|
||||
(role, name, body, is_active, notes),
|
||||
pid = str(uuid.uuid4())
|
||||
execute(
|
||||
"""INSERT INTO prompts (id, 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):
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
"""spec_current — текущее состояние спецификации."""
|
||||
from .connection import query
|
||||
from db.connection import query
|
||||
|
||||
|
||||
def list_by_contract(contract_id):
|
||||
|
||||
+30
-42
@@ -1,6 +1,6 @@
|
||||
"""spec_events — event sourcing: apply ops, reset contract."""
|
||||
import json, uuid
|
||||
from .connection import query, execute, get_pool
|
||||
from db.connection import query, execute, get_conn
|
||||
|
||||
|
||||
def reset(contract_id):
|
||||
@@ -10,26 +10,14 @@ def reset(contract_id):
|
||||
|
||||
|
||||
def get_next_seq(contract_id):
|
||||
"""Get next sequence number with row lock to prevent race conditions."""
|
||||
pool = get_pool()
|
||||
conn = pool.getconn()
|
||||
try:
|
||||
conn.autocommit = False
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(
|
||||
"SELECT seq FROM spec_events WHERE contract_id = %s ORDER BY seq DESC LIMIT 1 FOR UPDATE",
|
||||
(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)
|
||||
"""Get next sequence number. WAL serializes writers — no explicit lock needed."""
|
||||
conn = get_conn()
|
||||
cur = conn.execute(
|
||||
"SELECT seq FROM spec_events WHERE contract_id = ? ORDER BY seq DESC LIMIT 1",
|
||||
(contract_id,),
|
||||
)
|
||||
row = cur.fetchone()
|
||||
return (row["seq"] + 1) if row else 1
|
||||
|
||||
|
||||
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
|
||||
name_hash = _hash(name, nr.get("date_start"))
|
||||
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)
|
||||
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),
|
||||
op.get("comment", ""), prompt_id, document_id,
|
||||
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
|
||||
continue
|
||||
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)
|
||||
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),
|
||||
op.get("comment", ""), prompt_id, document_id,
|
||||
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
|
||||
continue
|
||||
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)
|
||||
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", ""),
|
||||
prompt_id, document_id,
|
||||
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":
|
||||
# Log but don't apply
|
||||
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)
|
||||
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", ""),
|
||||
json.dumps(op.get("new_values", {}), ensure_ascii=False),
|
||||
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):
|
||||
"""Log an op as UNRESOLVED instead of silently ignoring it."""
|
||||
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)
|
||||
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", ""),
|
||||
json.dumps(op.get("new_values", op.get("new_row", {})) or {}, ensure_ascii=False),
|
||||
reason,
|
||||
@@ -156,7 +144,7 @@ def _hash(name, date_start=None):
|
||||
import hashlib
|
||||
key = name.strip().lower()
|
||||
if date_start:
|
||||
from compare.metrics import normalize_date
|
||||
from services.metrics import normalize_date
|
||||
nd = normalize_date(str(date_start))
|
||||
if nd:
|
||||
key += "|" + nd
|
||||
@@ -171,16 +159,16 @@ def _upsert_spec_current(contract_id, name_hash, row):
|
||||
)
|
||||
if existing:
|
||||
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""",
|
||||
(row.get("name"), row.get("price"), row.get("qty"), row.get("sum"),
|
||||
row.get("date_start"), contract_id, name_hash),
|
||||
)
|
||||
else:
|
||||
execute(
|
||||
"""INSERT INTO spec_current (contract_id, name_hash, name, price, qty, sum, date_start)
|
||||
VALUES (%s, %s, %s, %s, %s, %s, %s)""",
|
||||
(contract_id, name_hash, row.get("name"), row.get("price"),
|
||||
"""INSERT INTO spec_current (id, contract_id, name_hash, name, price, qty, sum, date_start)
|
||||
VALUES (%s, %s, %s, %s, %s, %s, %s, %s)""",
|
||||
(str(uuid.uuid4()), contract_id, name_hash, row.get("name"), row.get("price"),
|
||||
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")
|
||||
params.append(new_values[field])
|
||||
if sets:
|
||||
sets.append("updated_at = now()")
|
||||
sets.append("updated_at = datetime('now')")
|
||||
params.extend([contract_id, name_hash])
|
||||
execute(
|
||||
f"UPDATE spec_current SET {', '.join(sets)} WHERE contract_id = %s AND name_hash = %s",
|
||||
|
||||
@@ -1,13 +1,16 @@
|
||||
"""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"):
|
||||
return execute_returning(
|
||||
"""INSERT INTO supplements (contract_id, document_id, type)
|
||||
VALUES (%s, %s, %s) RETURNING *""",
|
||||
(contract_id, document_id, supp_type),
|
||||
sid = str(uuid.uuid4())
|
||||
execute(
|
||||
"""INSERT INTO supplements (id, contract_id, document_id, type)
|
||||
VALUES (%s, %s, %s, %s)""",
|
||||
(sid, contract_id, document_id, supp_type),
|
||||
)
|
||||
return get(sid)
|
||||
|
||||
|
||||
def list_by_contract(contract_id):
|
||||
|
||||
+17
-17
@@ -95,77 +95,77 @@ class PgRepository:
|
||||
"""Реальный доступ к PostgreSQL через существующие db/*.py."""
|
||||
|
||||
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,
|
||||
batch_id=batch_id, zip_source=zip_source)
|
||||
|
||||
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
|
||||
|
||||
def set_document_error(self, doc_id, error):
|
||||
from site.db import documents
|
||||
from db import documents
|
||||
documents.set_error(doc_id, error)
|
||||
|
||||
def set_classification(self, doc_id, doc_type, own_number=None, parent_number=None,
|
||||
doc_date=None, counterparty=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,
|
||||
doc_date, counterparty,
|
||||
classify_raw=classify_raw, classify_input=classify_input)
|
||||
|
||||
def set_classify_garbage(self, doc_id, reason=""):
|
||||
from site.db import documents
|
||||
from db import documents
|
||||
documents.set_classify_garbage(doc_id, reason)
|
||||
|
||||
def set_classify_failed(self, doc_id, error):
|
||||
from site.db import documents
|
||||
from db import documents
|
||||
documents.set_classify_failed(doc_id, error)
|
||||
|
||||
def list_pending(self, batch_id):
|
||||
from site.db import documents
|
||||
from db import documents
|
||||
return documents.list_pending(batch_id)
|
||||
|
||||
def insert_contract(self, number, client=""):
|
||||
from site.db import contracts
|
||||
from db import contracts
|
||||
c = contracts.insert(number, client)
|
||||
return c["id"] if c else ""
|
||||
|
||||
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)
|
||||
|
||||
def list_supplements(self, contract_id):
|
||||
from site.db import supplements
|
||||
from db import supplements
|
||||
return supplements.list_by_contract(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)
|
||||
|
||||
def get_document(self, doc_id):
|
||||
from site.db import documents
|
||||
from db import documents
|
||||
return documents.get(doc_id)
|
||||
|
||||
def list_by_batch(self, batch_id):
|
||||
from site.db import documents
|
||||
from db import documents
|
||||
return documents.list_by_batch(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)
|
||||
|
||||
def reset_classify_status(self, batch_id):
|
||||
from site.db import documents
|
||||
from db import documents
|
||||
documents.reset_classify_status(batch_id)
|
||||
|
||||
def set_classify_processing(self, doc_id):
|
||||
from site.db import documents
|
||||
from db import documents
|
||||
documents.set_classify_processing(doc_id)
|
||||
|
||||
def delete_document(self, doc_id):
|
||||
from site.db import documents
|
||||
from db import documents
|
||||
documents.delete(doc_id)
|
||||
|
||||
|
||||
|
||||
@@ -2,12 +2,12 @@
|
||||
|
||||
|
||||
def register_routes(app):
|
||||
from site.routes.upload_bp import upload_bp
|
||||
from site.routes.pipeline_bp import pipeline_bp
|
||||
from site.routes.api_bp import api_bp
|
||||
from site.routes.prompts_bp import prompts_bp
|
||||
from site.routes.health_bp import health_bp
|
||||
from site.routes.pages_bp import pages_bp
|
||||
from routes.upload_bp import upload_bp
|
||||
from routes.pipeline_bp import pipeline_bp
|
||||
from routes.api_bp import api_bp
|
||||
from routes.prompts_bp import prompts_bp
|
||||
from routes.health_bp import health_bp
|
||||
from routes.pages_bp import pages_bp
|
||||
|
||||
app.register_blueprint(upload_bp)
|
||||
app.register_blueprint(pipeline_bp)
|
||||
|
||||
+9
-25
@@ -1,23 +1,14 @@
|
||||
"""API blueprint — groups, documents, supplements, sync, cleanup, spec-current, chat."""
|
||||
from flask import Blueprint, request, jsonify
|
||||
from site.db import documents, supplements, spec_current
|
||||
from site.db.connection import execute, query
|
||||
from site.services.grouping import group_documents, apply_groups
|
||||
from site.config import API_KEY, LLM_URL, LLM_KEY, LLM_MODEL
|
||||
from db import documents, supplements, spec_current
|
||||
from db.connection import execute, query
|
||||
from services.grouping import group_documents, apply_groups
|
||||
from config import LLM_URL, LLM_KEY, LLM_MODEL
|
||||
import httpx
|
||||
|
||||
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 ──────────────────────────────────────────────────
|
||||
|
||||
@api_bp.route("/api/supplements")
|
||||
@@ -160,7 +151,7 @@ def chat():
|
||||
)
|
||||
|
||||
try:
|
||||
with httpx.Client(http2=True, timeout=120) as client:
|
||||
with httpx.Client(timeout=120) as client:
|
||||
resp = client.post(
|
||||
LLM_URL,
|
||||
json={
|
||||
@@ -181,18 +172,11 @@ def chat():
|
||||
return jsonify(OK=False, ERROR=f"LLM error: {e}")
|
||||
|
||||
|
||||
# ── Cleanup (требует API-ключ) ────────────────────────────────────
|
||||
# ── Cleanup (атомарное удаление БД) ──────────────────────────────
|
||||
|
||||
@api_bp.route("/api/cleanup", methods=["POST"])
|
||||
def api_cleanup():
|
||||
"""Полная очистка всех данных (кроме prompts)."""
|
||||
if not _require_auth():
|
||||
return jsonify(ok=False, error="unauthorized"), 401
|
||||
|
||||
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")
|
||||
"""Полная очистка: os.remove(DB) + init новой. Данные гарантированно стёрты."""
|
||||
from db.connection import cleanup_db
|
||||
cleanup_db()
|
||||
return jsonify(ok=True, message="all data cleaned")
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"""Health probe — обязательно для Штурвала."""
|
||||
from flask import Blueprint, jsonify
|
||||
from site.config import VERSION
|
||||
from config import VERSION
|
||||
|
||||
health_bp = Blueprint("health", __name__)
|
||||
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
"""Pipeline blueprint — SSE-сравнение + classify."""
|
||||
import json, re, os, threading
|
||||
from flask import Blueprint, request, jsonify, Response, stream_with_context
|
||||
from site.services.process import run_pipeline
|
||||
from site.services.classify import classify_batch
|
||||
from site.llm_prompt import build_prompt
|
||||
from site.db import documents
|
||||
from services.process import run_pipeline
|
||||
from services.classify import classify_batch
|
||||
from llm_prompt import build_prompt
|
||||
from db import documents
|
||||
|
||||
pipeline_bp = Blueprint("pipeline", __name__)
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"""Prompts blueprint — CRUD + activate для версионирования промптов."""
|
||||
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__)
|
||||
|
||||
|
||||
+21
-30
@@ -1,9 +1,10 @@
|
||||
"""Upload blueprint — загрузка, конвертация, распаковка."""
|
||||
import io, os, base64, hashlib, zipfile, tempfile, subprocess
|
||||
import io, os, base64, hashlib, zipfile
|
||||
import httpx
|
||||
from flask import Blueprint, request, jsonify, send_file
|
||||
from site.services.parse import parse_file
|
||||
from site.db import documents
|
||||
from site.config import MAX_CONTENT_LENGTH
|
||||
from services.parse import parse_file
|
||||
from db import documents
|
||||
import config
|
||||
|
||||
upload_bp = Blueprint("upload", __name__)
|
||||
|
||||
@@ -70,38 +71,28 @@ def upload():
|
||||
|
||||
@upload_bp.route("/convert-doc", methods=["POST"])
|
||||
def convert_doc():
|
||||
""".doc → .docx через libreoffice (без сохранения на диск)."""
|
||||
""".doc → .docx через внешний libreoffice-сервис (HTTP)."""
|
||||
f = request.files.get("files")
|
||||
if not f:
|
||||
return jsonify(ok=False, error="no file"), 400
|
||||
|
||||
data = f.read()
|
||||
doc_path = None
|
||||
tmpdir = None
|
||||
try:
|
||||
with tempfile.NamedTemporaryFile(suffix=".doc", delete=False) as tmp:
|
||||
tmp.write(data)
|
||||
doc_path = tmp.name
|
||||
tmpdir = tempfile.mkdtemp()
|
||||
subprocess.run(
|
||||
["libreoffice", "--headless", "--convert-to", "docx", "--outdir", tmpdir, doc_path],
|
||||
timeout=30, capture_output=True,
|
||||
resp = httpx.post(
|
||||
config.CONVERT_SERVICE_URL + "/convert",
|
||||
files={"file": (f.filename, f.read(), f.content_type or "application/msword")},
|
||||
timeout=120,
|
||||
)
|
||||
docx_files = [x for x in os.listdir(tmpdir) if x.endswith(".docx")]
|
||||
if docx_files:
|
||||
with open(os.path.join(tmpdir, docx_files[0]), "rb") as out:
|
||||
return send_file(
|
||||
io.BytesIO(out.read()),
|
||||
mimetype="application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
||||
)
|
||||
return jsonify(ok=False, error="conversion produced no output"), 500
|
||||
finally:
|
||||
if doc_path and os.path.exists(doc_path):
|
||||
os.unlink(doc_path)
|
||||
if tmpdir and os.path.exists(tmpdir):
|
||||
for x in os.listdir(tmpdir):
|
||||
os.unlink(os.path.join(tmpdir, x))
|
||||
os.rmdir(tmpdir)
|
||||
if resp.status_code == 200:
|
||||
return send_file(
|
||||
io.BytesIO(resp.content),
|
||||
mimetype="application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
||||
)
|
||||
data = resp.json()
|
||||
return jsonify(ok=False, error=data.get("error", "conversion failed")), 500
|
||||
except httpx.TimeoutException:
|
||||
return jsonify(ok=False, error="conversion timeout"), 500
|
||||
except Exception as e:
|
||||
return jsonify(ok=False, error=str(e)), 500
|
||||
|
||||
|
||||
@upload_bp.route("/unzip-upload", methods=["POST"])
|
||||
|
||||
@@ -14,8 +14,8 @@ import json, re, os
|
||||
from concurrent.futures import ThreadPoolExecutor, as_completed
|
||||
|
||||
import httpx
|
||||
from site.db import documents as db_docs
|
||||
from site.llm_prompt import build_classify_prompt
|
||||
from db import documents as db_docs
|
||||
from llm_prompt import build_classify_prompt
|
||||
|
||||
log = __import__("logging").getLogger(__name__)
|
||||
|
||||
@@ -34,7 +34,7 @@ _classify_llm = None
|
||||
def _get_classify_client():
|
||||
global _classify_llm
|
||||
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)
|
||||
return _classify_llm
|
||||
|
||||
|
||||
@@ -9,9 +9,9 @@ Grouping service — match classified documents into contract groups.
|
||||
- apply_groups(): создаёт contracts + supplements с авто-порядком по дате.
|
||||
"""
|
||||
import re
|
||||
from site.db import documents as db_docs
|
||||
from site.db import contracts as db_contracts
|
||||
from site.db import supplements as db_supplements
|
||||
from db import documents as db_docs
|
||||
from db import contracts as db_contracts
|
||||
from db import supplements as db_supplements
|
||||
|
||||
|
||||
def normalize_number(num):
|
||||
|
||||
@@ -12,7 +12,7 @@ _llm_client = None
|
||||
def _get_default_client():
|
||||
global _llm_client
|
||||
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)
|
||||
return _llm_client
|
||||
|
||||
|
||||
@@ -36,7 +36,7 @@ class HttpxLLMClient:
|
||||
"max_tokens": self.max_tokens,
|
||||
"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(
|
||||
self.url,
|
||||
json=payload,
|
||||
|
||||
+19
-25
@@ -2,8 +2,8 @@
|
||||
Адаптирован из deploy/compare/process.py: callback → generator.
|
||||
"""
|
||||
import json, time
|
||||
from site.db import supplements, spec_current, spec_events
|
||||
from site.services.metrics import check_arithmetic
|
||||
from db import supplements, spec_current, spec_events
|
||||
from services.metrics import check_arithmetic
|
||||
|
||||
|
||||
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": "Нет распарсенных файлов"}
|
||||
return
|
||||
|
||||
from site.services.llm import call_llm
|
||||
from services.llm import call_llm
|
||||
|
||||
for s in supps:
|
||||
sid = s["id"]
|
||||
@@ -86,30 +86,24 @@ def run_pipeline(contract_id, order_ids, build_prompt_fn):
|
||||
"time_s": round(time.time() - t1, 1),
|
||||
}
|
||||
|
||||
# Apply ops to DB
|
||||
applied_ops = []
|
||||
summary = {"added": 0, "updated": 0, "deleted": 0, "unresolved": 0}
|
||||
for op in ops:
|
||||
action = op.get("action", "UNRESOLVED")
|
||||
summary[action.lower()] = summary.get(action.lower(), 0) + 1
|
||||
try:
|
||||
if action == "ADD":
|
||||
nr = op.get("new_row", {})
|
||||
spec_events.add_row(contract_id, sid, nr)
|
||||
elif action == "UPDATE":
|
||||
nr = op.get("new_row", {})
|
||||
nv = op.get("new_values", {})
|
||||
target = op.get("target_hash", "")
|
||||
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
|
||||
# Apply ops to DB via apply_ops
|
||||
try:
|
||||
summary = spec_events.apply_ops(
|
||||
contract_id, sid, s["document_id"], ops, prompt_id, result
|
||||
)
|
||||
applied_ops = ops
|
||||
except Exception as e:
|
||||
summary = {"added": 0, "updated": 0, "deleted": 0, "unresolved": len(ops)}
|
||||
applied_ops = []
|
||||
yield {
|
||||
"type": "extract_error",
|
||||
"supplement_id": sid,
|
||||
"filename": filename,
|
||||
"error": str(e),
|
||||
}
|
||||
|
||||
# Arithmetic check
|
||||
check_arithmetic(contract_id)
|
||||
check_arithmetic(ops)
|
||||
|
||||
yield {
|
||||
"type": "applied",
|
||||
|
||||
+5
-5
@@ -13,11 +13,11 @@ var fileTable = document.getElementById('fileTable');
|
||||
// state.batchId — теперь в state.js (Фаза 0: state + render)
|
||||
// (batchId = crypto.randomUUID() — уникальный ID сессии для классификации)
|
||||
|
||||
// Автоочистка старых записей при загрузке страницы
|
||||
(async function cleanup() {
|
||||
try {
|
||||
await fetch(VM_API + '/api/cleanup', { method: 'POST' });
|
||||
} catch(e) { /* ignore */ }
|
||||
// Прогрев upstream + автоочистка при загрузке страницы
|
||||
// ⚠️ Без прогрева первый POST может упасть с ERR_CONNECTION_RESET (MTU/Geneve)
|
||||
(async function init() {
|
||||
try { await fetch('/health'); } 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">
|
||||
<rect width="32" height="32" rx="4" fill="#001C34"/>
|
||||
<text x="16" y="24" text-anchor="middle" font-size="22" font-weight="bold" fill="white" font-family="sans-serif">N</text>
|
||||
</svg>
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<svg
|
||||
width="57"
|
||||
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 |
+143
-90
@@ -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 для файлов
|
||||
* ⛔⛔⛔ НЕ МЕНЯТЬ БЕЗ РАЗРЕШЕНИЯ НАЕЛЯ ⛔⛔⛔
|
||||
*
|
||||
* ЗАВИСИМОСТИ (глобальные, загружаются раньше):
|
||||
* state.js → state (центральное состояние)
|
||||
* app_utils.js → escHtml, formatSize, formatDate
|
||||
* app.js → render(), stepDone, stepActive, resetStepper, showClassifyBtn
|
||||
* v2.0.3: честный счётчик ⏳ соединение... Nс вместо фейкового ↑N%
|
||||
* Проверено в бою 2026-07-16. Любое изменение = риск сломать загрузку.
|
||||
*
|
||||
* ЗАГРУЖАЕТСЯ: после app_utils.js, перед app.js
|
||||
*/
|
||||
* КЛЮЧЕВЫЕ ФУНКЦИИ (не трогать):
|
||||
* - uploadFile() — fetch-загрузка с имитацией прогресса
|
||||
* - onFilesSelected() — все строки в таблицу сразу, потом загрузка по одной
|
||||
* - statusToHTML() — рендер статуса (↑ N%, ✓)
|
||||
* - renderFiles() — рендер всей таблицы
|
||||
|
||||
var CONVERT_URL = '/convert-doc';
|
||||
|
||||
/**
|
||||
* statusToHTML(status) — Чистая функция: структура → HTML (Фаза 1).
|
||||
*
|
||||
* Вход: { kind, pct?, text?, count?, elapsed? } — ни одного HTML-тега.
|
||||
* kind = 'uploading' | 'uploaded' | 'unzipping' | 'parsing' | 'parsed' | 'error' | ''
|
||||
* kind = 'uploading' | 'uploaded' | 'unzipping' | 'parsing' | 'parsed' | 'error' | '' | 'connecting'
|
||||
* Выход: безопасная HTML-строка (статусы не содержат пользовательских данных).
|
||||
*
|
||||
* ПАТТЕРН (decoupling-final-plan.md): отделяем данные от представления.
|
||||
@@ -30,7 +27,9 @@
|
||||
function statusToHTML(st) {
|
||||
if (!st || !st.kind) return '';
|
||||
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 'unzipping': return '⏳ распаковка...';
|
||||
case 'parsing': return '⏳ парсинг...';
|
||||
@@ -55,7 +54,7 @@ function statusToHTML(st) {
|
||||
*/
|
||||
function renderFiles(state) {
|
||||
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();
|
||||
return;
|
||||
}
|
||||
@@ -208,64 +207,83 @@ window.toggleClassifyDetail = async function(i) {
|
||||
};
|
||||
|
||||
/**
|
||||
* uploadFile(file, onProgress) — XHR-загрузка одного файла на бэкенд.
|
||||
* convertDoc(file, onProgress) — .doc → .docx через внешний libreoffice-сервис.
|
||||
* С честным счётчиком времени.
|
||||
*/
|
||||
function convertDoc(file, onProgress) {
|
||||
var startTime = Date.now();
|
||||
if (onProgress) onProgress({ kind: 'converting', elapsed: 0 });
|
||||
var fd = new FormData();
|
||||
fd.append('files', file, file.name);
|
||||
|
||||
var timer = setInterval(function() {
|
||||
var elapsed = Math.floor((Date.now() - startTime) / 1000);
|
||||
if (onProgress) onProgress({ kind: 'converting', elapsed: elapsed });
|
||||
}, 1000);
|
||||
|
||||
return fetch(CONVERT_URL + '?_=' + Date.now(), { method: 'POST', body: fd })
|
||||
.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);
|
||||
throw e;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* ⛔ НЕ МЕНЯТЬ ⛔ uploadFile — fetch-загрузка с честным счётчиком времени.
|
||||
*
|
||||
* Особенности:
|
||||
* - .doc (не .docx!) конвертируется через CONVERT_URL перед загрузкой
|
||||
* - onProgress(pct) — callback с процентом загрузки (0-100)
|
||||
* - Возвращает Promise<ответ API> с полями doc_id, contract_id, parsed
|
||||
* - Таймаут 180с (большие PDF)
|
||||
* v2.0.3: вместо фейкового ↑N% — честный счётчик ⏳ соединение... Nс → ⏳ отправка... Nс.
|
||||
* fetch() не даёт реальный upload progress, поэтому считаем секунды.
|
||||
* Кэш-бастинг: ?_=Date.now()
|
||||
*/
|
||||
function uploadFile(file, onProgress, zipSource) {
|
||||
return new Promise(function(resolve, reject) {
|
||||
// .doc → конвертация в docx (старый формат Word)
|
||||
var isDoc = file.name.toLowerCase().endsWith('.doc') && !file.name.toLowerCase().endsWith('.docx');
|
||||
var uploadFile = file;
|
||||
var uploadName = file.name;
|
||||
var startTime = Date.now();
|
||||
var phase = 'connecting'; // connecting → uploading
|
||||
if (onProgress) onProgress({ kind: 'connecting', elapsed: 0 });
|
||||
var fd = new FormData();
|
||||
fd.append('files', file, file.name);
|
||||
if (state.contractId) fd.append('contract_id', state.contractId);
|
||||
fd.append('batch_id', state.batchId);
|
||||
if (zipSource) fd.append('zip_source', zipSource);
|
||||
|
||||
function doUpload() {
|
||||
var xhr = new XMLHttpRequest();
|
||||
var fd = new FormData();
|
||||
fd.append('files', uploadFile, uploadName);
|
||||
if (state.contractId) fd.append('contract_id', state.contractId);
|
||||
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);
|
||||
}
|
||||
// Честный счётчик: каждую секунду обновляем elapsed
|
||||
var timer = setInterval(function() {
|
||||
var elapsed = Math.floor((Date.now() - startTime) / 1000);
|
||||
if (onProgress) onProgress({ kind: phase, elapsed: elapsed });
|
||||
}, 1000);
|
||||
|
||||
if (isDoc) {
|
||||
var xhr = new XMLHttpRequest();
|
||||
xhr.open('POST', CONVERT_URL);
|
||||
xhr.responseType = 'blob';
|
||||
xhr.onload = function() {
|
||||
if (xhr.status === 200 && xhr.response.size > 100) {
|
||||
uploadFile = xhr.response;
|
||||
uploadName = file.name.replace(/\.doc$/i, '.docx');
|
||||
doUpload();
|
||||
} else {
|
||||
reject(new Error('Конвертация .doc'));
|
||||
}
|
||||
};
|
||||
xhr.onerror = function() { reject(new Error('Конвертер')); };
|
||||
xhr.send(file);
|
||||
} else {
|
||||
doUpload();
|
||||
}
|
||||
});
|
||||
return fetch(UPLOAD_URL + '?_=' + Date.now(), { method: 'POST', body: fd })
|
||||
.then(function(r) {
|
||||
if (!r.ok) throw new Error('HTTP ' + r.status);
|
||||
return r.json();
|
||||
})
|
||||
.then(function(data) {
|
||||
clearInterval(timer);
|
||||
if (data.ok) {
|
||||
var elapsed = Math.floor((Date.now() - startTime) / 1000);
|
||||
if (onProgress) onProgress({ kind: 'uploading', elapsed: elapsed });
|
||||
return data;
|
||||
}
|
||||
throw new Error(data.error || 'Неизвестная ошибка');
|
||||
})
|
||||
.catch(function(e) {
|
||||
clearInterval(timer);
|
||||
if (e.message === 'Failed to fetch' || e.name === 'TypeError') {
|
||||
throw new Error('Сеть');
|
||||
}
|
||||
throw e;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -425,7 +443,7 @@ async function addZipFile(file) {
|
||||
*/
|
||||
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 не затирались
|
||||
var dupKey = (zipSource || '') + '/' + file.name;
|
||||
@@ -449,9 +467,18 @@ async function addRegularFile(file, zipSource) {
|
||||
render(state);
|
||||
|
||||
try {
|
||||
// XHR-загрузка с прогрессом
|
||||
var resp = await uploadFile(file, function(pct) {
|
||||
state.files[rowIdx].status = { kind: 'uploading', pct: pct };
|
||||
// .doc → конвертация через libreoffice-сервис
|
||||
var uploadTarget = file;
|
||||
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);
|
||||
}, zipSource);
|
||||
// Бэкенд возвращает doc_id и contract_id (нижний регистр — Python keys)
|
||||
@@ -511,36 +538,62 @@ async function finalizeUpload() {
|
||||
}
|
||||
|
||||
/**
|
||||
* onFilesSelected(newFiles) — Оркестратор загрузки (Фаза 1).
|
||||
* ⛔ НЕ МЕНЯТЬ ⛔ onFilesSelected — все строки сразу, потом загрузка.
|
||||
*
|
||||
* ПАТТЕРН:
|
||||
* 1. Для каждого файла: addZipFile (ZIP) или addRegularFile (обычный)
|
||||
* 2. finalizeUpload — завершить цикл
|
||||
*
|
||||
* НЕ удаляет существующие файлы — только добавляет новые.
|
||||
* Дубликаты обрабатываются через confirm() в addRegularFile.
|
||||
* Удаление — только вручную (кнопка ✕).
|
||||
*
|
||||
* Вызывается из fileInput.addEventListener('change', ...).
|
||||
* v2.0.2: Фаза 1 — все строки в таблицу. Фаза 2 — загрузка по одной.
|
||||
* Юзер видит таблицу целиком, каждая строка обновляется независимо.
|
||||
*/
|
||||
async function onFilesSelected(newFiles) {
|
||||
if (newFiles.length === 0) return;
|
||||
|
||||
fileInput.disabled = true;
|
||||
|
||||
// Сбросить прогресс пайплайна при добавлении новых файлов
|
||||
resetStepper('stepUpload');
|
||||
|
||||
// Обработать каждый файл
|
||||
// Фаза 1: ВСЕ строки в таблицу сразу
|
||||
for (var i = 0; i < newFiles.length; i++) {
|
||||
var f = newFiles[i];
|
||||
if (f.name.toLowerCase().endsWith('.zip')) {
|
||||
await addZipFile(f);
|
||||
} else {
|
||||
await addRegularFile(f);
|
||||
continue;
|
||||
}
|
||||
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();
|
||||
}
|
||||
|
||||
@@ -73,7 +73,7 @@
|
||||
<body>
|
||||
<div class="topbar">
|
||||
<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.9</span></span>
|
||||
<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="stepClassify">○ Классификация</span><span>→</span>
|
||||
@@ -90,7 +90,7 @@
|
||||
Загрузка договоров/приложений/спецификаций
|
||||
</div>
|
||||
<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="font-size:11px;color:var(--muted);margin-bottom:6px;">⚠ При совпадении имён — запрос на перезапись (OK / Отмена). Файлы из ZIP-архивов загружаются через тот же поток.</div>
|
||||
|
||||
@@ -216,11 +216,11 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="/static/state.js?v=1.0.179-flask"></script>
|
||||
<script src="/static/app_utils.js?v=1.0.179-flask"></script>
|
||||
<script src="/static/files.js?v=1.0.179-flask"></script>
|
||||
<script src="/static/groups.js?v=1.0.179-flask"></script>
|
||||
<script src="/static/compare.js?v=1.0.179-flask"></script>
|
||||
<script src="/static/app.js?v=1.0.179-flask"></script>
|
||||
<script src="/static/state.js?v=2.0.8"></script>
|
||||
<script src="/static/app_utils.js?v=2.0.8"></script>
|
||||
<script src="/static/files.js?v=2.0.8"></script>
|
||||
<script src="/static/groups.js?v=2.0.8"></script>
|
||||
<script src="/static/compare.js?v=2.0.8"></script>
|
||||
<script src="/static/app.js?v=2.0.8"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
Reference in New Issue
Block a user