';
+ }
+
+ // ── done: завершение ──
+ else if (d.type === 'done') {
+ clearInterval(timer);
+ es.close();
+ statusEl.textContent = '✓ ' + d.total_time_s + 'с';
+ statusEl.style.color = 'var(--green)';
+ if (callbacks.onDone) callbacks.onDone(d, sections);
+ }
+
+ // ── error: фатальная ошибка ──
+ else if (d.type === 'error') {
+ clearInterval(timer);
+ es.close();
+ if (callbacks.onError) callbacks.onError(d);
+ }
+ };
+
+ // ── Сетевая ошибка ──
+ es.onerror = function() {
+ clearInterval(timer);
+ es.close();
+ if (callbacks.onConnectionError) callbacks.onConnectionError();
+ };
+
+ return { es: es, timer: timer, sections: sections };
+}
diff --git a/deploy-lucee/convert_doc.py b/deploy-lucee/convert_doc.py
new file mode 100755
index 0000000..713930a
--- /dev/null
+++ b/deploy-lucee/convert_doc.py
@@ -0,0 +1,24 @@
+#!/usr/bin/env python3
+"""Конвертер .doc → .docx через libreoffice. Принимает POST с файлом, возвращает docx."""
+import subprocess, tempfile, os, sys
+
+data = sys.stdin.buffer.read()
+with tempfile.NamedTemporaryFile(suffix=".doc", delete=False) as f:
+ f.write(data)
+ doc_path = f.name
+
+tmpdir = tempfile.mkdtemp()
+try:
+ subprocess.run(["libreoffice", "--headless", "--convert-to", "docx", "--outdir", tmpdir, doc_path],
+ timeout=30, capture_output=True)
+ docx = [x for x in os.listdir(tmpdir) if x.endswith(".docx")]
+ if docx:
+ with open(os.path.join(tmpdir, docx[0]), "rb") as f:
+ sys.stdout.buffer.write(f.read())
+ else:
+ sys.stdout.buffer.write(b"")
+finally:
+ os.unlink(doc_path)
+ for x in os.listdir(tmpdir):
+ os.unlink(os.path.join(tmpdir, x))
+ os.rmdir(tmpdir)
diff --git a/deploy-lucee/convert_server.py b/deploy-lucee/convert_server.py
new file mode 100755
index 0000000..add6a77
--- /dev/null
+++ b/deploy-lucee/convert_server.py
@@ -0,0 +1,509 @@
+#!/usr/bin/env python3
+# ⛔ НЕ МЕНЯТЬ БЕЗ РАЗРЕШЕНИЯ НАЕЛЯ ⛔
+"""Contracts VM server — thin HTTP router. All logic in db/ and services/."""
+from http.server import HTTPServer, BaseHTTPRequestHandler
+from socketserver import ThreadingMixIn, TCPServer
+from urllib.parse import urlparse, parse_qs
+import json, re, os, sys
+
+# ── DB auto-seed ──────────────────────────────────────────────────────────
+from db import prompts as db_prompts
+from db.connection import DB_CONFIG, execute
+db_prompts.seed_defaults()
+# Schema migration: classify_raw column (idempotent)
+execute("ALTER TABLE documents ADD COLUMN IF NOT EXISTS classify_raw text")
+execute("ALTER TABLE documents ADD COLUMN IF NOT EXISTS classify_input text")
+execute("ALTER TABLE documents ADD COLUMN IF NOT EXISTS zip_source text")
+
+# ── DB modules ────────────────────────────────────────────────────────────
+from db import supplements as db_supplements
+from db import documents as db_documents
+from db import spec_current as db_spec_current
+
+# ── Services ──────────────────────────────────────────────────────────────
+from services.upload import handle_upload
+from services.unzip import handle_unzip
+from services.process import run_pipeline
+from llm_prompt import build_prompt
+
+
+class ThreadingHTTPServer(ThreadingMixIn, HTTPServer):
+ daemon_threads = True
+
+
+class Handler(BaseHTTPRequestHandler):
+ def do_OPTIONS(self):
+ self.send_response(200)
+ self._send_cors()
+ self.send_header("Access-Control-Allow-Methods", "GET, POST, OPTIONS")
+ self.send_header("Access-Control-Allow-Headers", "Content-Type")
+ self.end_headers()
+
+ def do_GET(self):
+ parsed = urlparse(self.path)
+ if parsed.path == "/process-v2":
+ self._handle_process_v2(parsed)
+ elif parsed.path == "/health":
+ self._json({"ok": True, "db": DB_CONFIG["dbname"]})
+ elif parsed.path == "/app.js" or parsed.path.startswith("/static/"):
+ self._handle_app_js()
+ elif parsed.path == "/api/supplements":
+ self._handle_api_supplements(parsed)
+ elif parsed.path == "/api/groups":
+ self._handle_api_groups(parsed)
+ elif parsed.path == "/api/batch-progress":
+ self._handle_api_batch_progress(parsed)
+ elif parsed.path == "/api/prompts":
+ self._handle_api_prompts_get(parsed)
+ elif parsed.path == "/api/prompts/list":
+ self._handle_api_prompts_list(parsed)
+ elif parsed.path.startswith("/api/documents/"):
+ self._handle_api_document(parsed)
+ else:
+ self.send_error(404)
+
+ def do_POST(self):
+ if self.path == "/upload":
+ self._handle_upload()
+ elif self.path == "/unzip-upload":
+ self._handle_unzip()
+ elif self.path == "/llm-ops":
+ self._handle_llm_ops()
+ elif self.path == "/convert-doc":
+ self._handle_convert_doc()
+ elif self.path == "/api/classify-batch":
+ self._handle_classify_batch()
+ elif self.path == "/api/apply-groups":
+ self._handle_apply_groups()
+ elif self.path == "/api/cleanup":
+ self._handle_cleanup()
+ elif self.path == "/api/prompts/save":
+ self._handle_api_prompts_save()
+ elif self.path == "/api/prompts/activate":
+ self._handle_api_prompts_activate()
+ elif self.path == "/api/prompts/delete":
+ self._handle_api_prompts_delete()
+ elif self.path == "/api/sync":
+ self._handle_api_sync()
+ else:
+ self.send_error(404)
+
+ def do_DELETE(self):
+ parsed = urlparse(self.path)
+ if parsed.path.startswith("/api/documents/"):
+ self._handle_api_document_delete(parsed)
+ else:
+ self.send_error(404)
+
+ # ── /process-v2 (SSE) ─────────────────────────────────────────────────
+
+ def _handle_process_v2(self, parsed):
+ params = parse_qs(parsed.query)
+ cid = params.get("contract_id", [None])[0]
+ if not cid:
+ self.send_error(400, "contract_id required")
+ return
+ if not re.fullmatch(r'[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}', cid, re.I):
+ self.send_error(400, "invalid contract_id format")
+ return
+
+ self.send_response(200)
+ self.send_header("Content-Type", "text/event-stream; charset=utf-8")
+ self.send_header("Cache-Control", "no-cache")
+ self.send_header("Connection", "keep-alive")
+ self.end_headers()
+ self.wfile.write(b": ok\n\n")
+ self.wfile.flush()
+
+ order_ids = params.get("order", [None])[0] or ""
+
+ try:
+ run_pipeline(cid, order_ids, self._sse, build_prompt)
+ except Exception as e:
+ self._sse({"type": "error", "message": str(e)})
+
+ def _sse(self, data):
+ self.wfile.write(f"data: {json.dumps(data, ensure_ascii=False)}\n\n".encode())
+ self.wfile.flush()
+
+ # ── /api/supplements ──────────────────────────────────────────────────
+
+ def _handle_api_supplements(self, parsed):
+ params = parse_qs(parsed.query)
+ cid = params.get("contract_id", [None])[0]
+ if not cid:
+ self._json({"ok": False, "error": "contract_id required"}, 400)
+ return
+ rows = db_supplements.list_by_contract(cid)
+ self._json({"ok": True, "supplements": rows})
+
+ # ── /api/documents/ ──────────────────────────────────────────────
+
+ def _handle_api_document(self, parsed):
+ doc_id = parsed.path.split("/")[-1]
+ doc = db_documents.get(doc_id)
+ if not doc:
+ self._json({"ok": False, "error": "not found"}, 404)
+ return
+ self._json({
+ "ok": True,
+ "id": doc["id"],
+ "filename": doc["filename"],
+ "status": doc["status"],
+ "elements_json": doc.get("elements_json"),
+ "doc_type": doc.get("doc_type"),
+ "own_number": doc.get("own_number"),
+ "parent_number": doc.get("parent_number"),
+ "doc_date": doc.get("doc_date"),
+ "counterparty": doc.get("counterparty"),
+ "classify_status": doc.get("classify_status"),
+ "classify_raw": doc.get("classify_raw"),
+ "classify_input": doc.get("classify_input"),
+ })
+
+ def _handle_api_document_delete(self, parsed):
+ """DELETE /api/documents/ — cascade: spec_current, spec_events, supplements, document."""
+ from db.connection import execute, query
+ doc_id = parsed.path.split("/")[-1]
+ supps = query("SELECT id, contract_id FROM supplements WHERE document_id = %s", (doc_id,))
+ for s in (supps or []):
+ execute(
+ """DELETE FROM spec_current WHERE contract_id = %s
+ AND last_event_id IN (SELECT id FROM spec_events WHERE supplement_id = %s)""",
+ (s["contract_id"], s["id"]),
+ )
+ execute("DELETE FROM spec_events WHERE supplement_id = %s", (s["id"],))
+ execute("DELETE FROM supplements WHERE id = %s", (s["id"],))
+ execute("DELETE FROM documents WHERE id = %s", (doc_id,))
+ self._json({"ok": True})
+
+ # ── POST /api/sync ──────────────────────────────────────────────────
+
+ def _handle_api_sync(self):
+ """Удалить ВСЕ документы, НЕ входящие в keep_ids. БД = зеркало таблицы."""
+ from db.connection import execute, query
+ length = int(self.headers.get("Content-Length", 0))
+ body = json.loads(self.rfile.read(length)) if length > 0 else {}
+ keep_ids = set(body.get("keep_ids", []))
+ # Prevent DoS: too many IDs
+ if len(keep_ids) > 1000:
+ self._json({"ok": False, "error": "too many keep_ids (max 1000)"}, 400)
+ return
+ docs = query("SELECT id FROM documents", ())
+ deleted = 0
+ for d in (docs or []):
+ if d["id"] in keep_ids:
+ continue
+ supps = query("SELECT id, contract_id FROM supplements WHERE document_id = %s", (d["id"],))
+ for s in (supps or []):
+ execute(
+ """DELETE FROM spec_current WHERE contract_id = %s
+ AND last_event_id IN (SELECT id FROM spec_events WHERE supplement_id = %s)""",
+ (s["contract_id"], s["id"]),
+ )
+ execute("DELETE FROM spec_events WHERE supplement_id = %s", (s["id"],))
+ execute("DELETE FROM supplements WHERE id = %s", (s["id"],))
+ execute("DELETE FROM documents WHERE id = %s", (d["id"],))
+ deleted += 1
+ # Удалить осиротевшие contracts (без supplements)
+ execute("DELETE FROM contracts WHERE id NOT IN (SELECT DISTINCT contract_id FROM supplements)")
+ self._json({"ok": True, "deleted": deleted})
+
+ # ── /api/groups ?batch=X ─────────────────────────────────────────────
+
+ def _handle_api_groups(self, parsed):
+ params = parse_qs(parsed.query)
+ batch_id = params.get("batch", [None])[0]
+ if not batch_id:
+ self._json({"ok": False, "error": "batch required"}, 400)
+ return
+ from services.grouping import group_documents
+ result = group_documents(batch_id)
+ self._json(result)
+
+ # ── /api/batch-progress ?batch=X ─────────────────────────────────────
+
+ def _handle_api_batch_progress(self, parsed):
+ params = parse_qs(parsed.query)
+ batch_id = params.get("batch", [None])[0]
+ if not batch_id:
+ self._json({"ok": False, "error": "batch required"}, 400)
+ return
+ counts = db_documents.count_by_status(batch_id)
+ docs = db_documents.list_by_batch(batch_id)
+ self._json({"ok": True, "counts": counts, "total": len(docs)})
+
+ # ── POST /classify-batch ─────────────────────────────────────────────
+
+ def _handle_classify_batch(self):
+ """
+ POST /api/classify-batch — запустить классификацию в фоне.
+
+ ЗАЧЕМ subprocess вместо threading.Thread:
+ ThreadPoolExecutor(4) + HTTP-поток делят коннекты к PostgreSQL.
+ При 50+ файлах пул исчерпывается → сервер не отвечает → 502.
+ Отдельный процесс — свои коннекты, своя память.
+ HTTP-сервер продолжает отвечать на batch-progress.
+ """
+ length = int(self.headers.get("Content-Length", 0))
+ body = json.loads(self.rfile.read(length))
+ batch_id = body.get("batch_id")
+ if not batch_id:
+ self._json({"ok": False, "error": "batch_id required"}, 400)
+ return
+ from db import documents as db_docs
+ import subprocess, os
+
+ # Guard: если classify для этого batch уже запущен — не запускать повторно
+ lock_path = f"/tmp/classify_{batch_id}.lock"
+ if os.path.exists(lock_path):
+ self._json({"ok": False, "error": "classify already running for this batch"}, 409)
+ return
+
+ # Посчитать сколько файлов — ответить сразу, classify в отдельном процессе
+ pending = db_docs.list_pending(batch_id)
+ total = len(pending)
+ if total == 0:
+ self._json({"ok": False, "error": "no pending documents"}, 400)
+ return
+
+ # Запустить classify_worker.py в отдельном процессе
+ # Лог в /home/naeel/contracts/logs/ вместо DEVNULL
+ log_dir = "/home/naeel/nubes/contracts/logs"
+ os.makedirs(log_dir, exist_ok=True)
+ log_path = os.path.join(log_dir, f"classify_{batch_id}.log")
+ log_file = open(log_path, "w")
+
+ # Создать lock-файл (воркер удалит при завершении)
+ with open(lock_path, "w") as lf:
+ lf.write(str(os.getpid()))
+
+ worker_path = os.path.join(os.path.dirname(__file__), "classify_worker.py")
+ subprocess.Popen(
+ [sys.executable, worker_path, batch_id],
+ stdout=log_file, stderr=subprocess.STDOUT,
+ )
+ self._json({"ok": True, "total": total, "log": log_path}, 202)
+
+ # ── POST /apply-groups ───────────────────────────────────────────────
+
+ def _handle_apply_groups(self):
+ length = int(self.headers.get("Content-Length", 0))
+ body = json.loads(self.rfile.read(length))
+ batch_id = body.get("batch_id")
+ groups = body.get("groups", [])
+ if not batch_id:
+ self._json({"ok": False, "error": "batch_id required"}, 400)
+ return
+ from services.grouping import apply_groups
+ result = apply_groups(batch_id, groups)
+ self._json(result)
+
+ # ── POST /api/cleanup ────────────────────────────────────────────────
+
+ def _handle_cleanup(self):
+ """Полная очистка всех данных (кроме prompts). Вызывается при загрузке страницы."""
+ from db.connection import execute
+ 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")
+ self._json({"ok": True, "message": "all data cleaned"})
+
+ # ── GET /api/cleanup ───────────────────────────────────────────────────
+
+ def _handle_cleanup(self):
+ """Полная очистка всех данных (кроме prompts)."""
+ from db.connection import execute
+ 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")
+ self._json({"ok": True, "message": "all data cleaned"})
+
+ # ── Prompts CRUD ──────────────────────────────────────────────────────
+
+ def _handle_api_prompts_get(self, parsed):
+ from db import prompts as db_p
+ params = parse_qs(parsed.query)
+ role = params.get("role", [None])[0]
+ if not role:
+ self._json({"ok": False, "error": "role required"}, 400)
+ return
+ p = db_p.get_active(role)
+ if p:
+ self._json({"ok": True, "id": p["id"], "role": p["role"], "name": p["name"],
+ "body": p["body"], "is_active": p["is_active"], "notes": p.get("notes"),
+ "created_at": str(p.get("created_at", ""))})
+ else:
+ self._json({"ok": False, "error": "not found"}, 404)
+
+ def _handle_api_prompts_list(self, parsed):
+ from db import prompts as db_p
+ params = parse_qs(parsed.query)
+ role = params.get("role", [None])[0]
+ if not role:
+ self._json({"ok": False, "error": "role required"}, 400)
+ return
+ versions = db_p.list_by_role(role)
+ self._json({"ok": True, "versions": versions})
+
+ def _handle_api_prompts_save(self):
+ from db import prompts as db_p
+ length = int(self.headers.get("Content-Length", 0))
+ body = json.loads(self.rfile.read(length))
+ role = body.get("role", "")
+ name = body.get("name", "v" + __import__("datetime").datetime.now().isoformat()[:16])
+ prompt_body = body.get("body", "")
+ notes = body.get("notes", "")
+ is_active = body.get("is_active", True)
+ if not role or not prompt_body:
+ self._json({"ok": False, "error": "role and body required"}, 400)
+ return
+ p = db_p.save_new_version(role, name, prompt_body, notes, is_active)
+ self._json({"ok": True, "id": p["id"]})
+
+ def _handle_api_prompts_activate(self):
+ from db import prompts as db_p
+ length = int(self.headers.get("Content-Length", 0))
+ body = json.loads(self.rfile.read(length))
+ pid = body.get("id", "")
+ if not pid:
+ self._json({"ok": False, "error": "id required"}, 400)
+ return
+ ok = db_p.activate(pid)
+ self._json({"ok": ok})
+
+ def _handle_api_prompts_delete(self):
+ from db import prompts as db_p
+ length = int(self.headers.get("Content-Length", 0))
+ body = json.loads(self.rfile.read(length))
+ pid = body.get("id", "")
+ if not pid:
+ self._json({"ok": False, "error": "id required"}, 400)
+ return
+ ok = db_p.delete_prompt(pid)
+ self._json({"ok": ok})
+
+ # ── /app.js (static) ───────────────────────────────────────────────────
+
+ def _handle_app_js(self):
+ try:
+ fname = urlparse(self.path).path.lstrip("/").replace("static/", "")
+ with open(os.path.join(os.path.dirname(__file__), fname), "rb") as f:
+ js = f.read()
+ self.send_response(200)
+ self.send_header("Content-Type", "application/javascript; charset=utf-8")
+ self.send_header("Content-Length", str(len(js)))
+ self.send_header("X-Content-Type-Options", "nosniff")
+ self._send_cors()
+ self.end_headers()
+ self.wfile.write(js)
+ except FileNotFoundError:
+ self.send_error(404)
+
+ # ── /upload ───────────────────────────────────────────────────────────
+
+ def _handle_upload(self):
+ try:
+ content_type = self.headers.get("Content-Type", "")
+ content_length = int(self.headers.get("Content-Length", 0))
+ result = handle_upload(self.rfile, content_type, content_length)
+ self._json(result)
+ except Exception as e:
+ self._json({"ok": False, "error": str(e)}, 500)
+
+ # ── /unzip-upload ─────────────────────────────────────────────────────
+
+ def _handle_unzip(self):
+ try:
+ content_length = int(self.headers.get("Content-Length", 0))
+ result = handle_unzip(self.rfile, content_length)
+ self._json(result)
+ except Exception as e:
+ self._json({"ok": False, "error": str(e)}, 500)
+
+ # ── /convert-doc ──────────────────────────────────────────────────────
+
+ def _handle_convert_doc(self):
+ """DOC → DOCX через LibreOffice."""
+ import subprocess, tempfile
+ length = int(self.headers.get("Content-Length", 0))
+ data = self.rfile.read(length)
+
+ with tempfile.NamedTemporaryFile(suffix=".doc", delete=False) as f:
+ f.write(data)
+ doc_path = f.name
+
+ tmpdir = tempfile.mkdtemp()
+ try:
+ subprocess.run(
+ ["libreoffice", "--headless", "--convert-to", "docx", "--outdir", tmpdir, doc_path],
+ timeout=30, capture_output=True,
+ )
+ docx_files = [x for x in os.listdir(tmpdir) if x.endswith(".docx")]
+ if docx_files:
+ with open(os.path.join(tmpdir, docx_files[0]), "rb") as f:
+ result = f.read()
+ self.send_response(200)
+ self.send_header("Content-Type", "application/vnd.openxmlformats-officedocument.wordprocessingml.document")
+ self.send_header("Content-Length", str(len(result)))
+ self._send_cors()
+ self.end_headers()
+ self.wfile.write(result)
+ else:
+ self.send_error(500, "conversion produced no output")
+ except Exception as e:
+ self.send_error(500, str(e))
+ finally:
+ os.unlink(doc_path)
+ for x in os.listdir(tmpdir):
+ os.unlink(os.path.join(tmpdir, x))
+ os.rmdir(tmpdir)
+
+ # ── /llm-ops (debug) ──────────────────────────────────────────────────
+
+ def _handle_llm_ops(self):
+ from services.llm import call_llm
+ length = int(self.headers.get("Content-Length", 0))
+ body = json.loads(self.rfile.read(length))
+ try:
+ result, prompt_id = call_llm(
+ body.get("current_spec", []),
+ body.get("doc_text", ""),
+ build_prompt,
+ )
+ self._json(result)
+ except Exception as e:
+ self._json({"error": str(e)}, 502)
+
+ # ── Helpers ───────────────────────────────────────────────────────────
+
+ def _json(self, data, status=200):
+ self.send_response(status)
+ self._send_cors()
+ self.send_header("Content-Type", "application/json; charset=utf-8")
+ self.end_headers()
+ self.wfile.write(json.dumps(data, ensure_ascii=False).encode())
+
+ def _send_cors(self):
+ self.send_header("Access-Control-Allow-Origin", "*")
+
+ def log_message(self, format, *args):
+ pass
+
+
+if __name__ == "__main__":
+ import os
+ TCPServer.allow_reuse_address = True
+ port = int(os.environ.get("PORT", "8766"))
+ server = ThreadingHTTPServer(("0.0.0.0", port), Handler)
+ print(f"Contracts VM server on :{port}, db={DB_CONFIG['dbname']}")
+ try:
+ server.serve_forever()
+ except KeyboardInterrupt:
+ server.shutdown()
diff --git a/deploy-lucee/db/__init__.py b/deploy-lucee/db/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/deploy-lucee/db/connection.py b/deploy-lucee/db/connection.py
new file mode 100644
index 0000000..6afefb4
--- /dev/null
+++ b/deploy-lucee/db/connection.py
@@ -0,0 +1,91 @@
+"""
+Database connection — psycopg2 connection pool.
+
+Архитектурное решение (Opus):
+- ThreadedConnectionPool(minconn=1, maxconn=10) — оптимально для ThreadingMixIn HTTP-сервера.
+ Каждый HTTP-запрос в отдельном потоке получает своё соединение из пула.
+- RealDictCursor для query() — возвращает dict с lowercase ключами (консистентно с Python API).
+- execute()/execute_returning() — для INSERT/UPDATE/DELETE с автокоммитом и откатом при ошибке.
+- Пул создаётся лениво (при первом запросе) через get_pool().
+"""
+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", ""),
+}
+
+
+def get_pool():
+ """Возвращает глобальный пул соединений. Создаёт при первом вызове."""
+ global _pool
+ if _pool is None:
+ _pool = psycopg2.pool.ThreadedConnectionPool(
+ minconn=1, maxconn=10, **DB_CONFIG
+ )
+ return _pool
+
+
+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)
+
+
+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)
+
+
+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)
diff --git a/deploy-lucee/db/contracts.py b/deploy-lucee/db/contracts.py
new file mode 100644
index 0000000..4d13216
--- /dev/null
+++ b/deploy-lucee/db/contracts.py
@@ -0,0 +1,25 @@
+"""Contracts CRUD."""
+from .connection import query, execute, execute_returning
+
+
+def insert(number, client=""):
+ return execute_returning(
+ "INSERT INTO contracts (number, client) VALUES (%s, %s) RETURNING *",
+ (number, client),
+ )
+
+
+def get(contract_id):
+ rows = query("SELECT * FROM contracts WHERE id = %s", (contract_id,))
+ return rows[0] if rows else None
+
+
+def delete(contract_id):
+ return execute("DELETE FROM contracts WHERE id = %s", (contract_id,))
+
+
+def delete_orphaned():
+ """Remove contracts with no supplements."""
+ execute(
+ "DELETE FROM contracts WHERE id NOT IN (SELECT DISTINCT contract_id FROM supplements)"
+ )
diff --git a/deploy-lucee/db/documents.py b/deploy-lucee/db/documents.py
new file mode 100644
index 0000000..76f9e64
--- /dev/null
+++ b/deploy-lucee/db/documents.py
@@ -0,0 +1,105 @@
+"""Documents CRUD."""
+from .connection import query, execute, execute_returning
+
+
+def insert(filename, mime_type, original_bytes, status="uploaded", batch_id=None, zip_source=None):
+ """Insert document, return row dict."""
+ return execute_returning(
+ """INSERT INTO documents (filename, mime_type, original_bytes, status, batch_id, zip_source)
+ VALUES (%s, %s, %s, %s, %s, %s) RETURNING *""",
+ (filename, mime_type, original_bytes, status, batch_id, zip_source),
+ )
+
+
+def get(doc_id):
+ rows = query("SELECT * FROM documents WHERE id = %s", (doc_id,))
+ return rows[0] if rows else None
+
+
+def set_parsed(doc_id, elements_json):
+ """Update elements_json + status='parsed'."""
+ import json
+ return execute(
+ "UPDATE documents SET elements_json = %s::jsonb, status = 'parsed' WHERE id = %s",
+ (json.dumps(elements_json, ensure_ascii=False), doc_id),
+ )
+
+
+def set_error(doc_id, error_message):
+ return execute(
+ "UPDATE documents SET status = 'error', error_message = %s WHERE id = %s",
+ (error_message, doc_id),
+ )
+
+
+def delete(doc_id):
+ return execute("DELETE FROM documents WHERE id = %s", (doc_id,))
+
+
+def set_classification(doc_id, doc_type, own_number, parent_number, doc_date, counterparty, classify_raw=None, classify_input=None):
+ """Store LLM classification results + raw response + input text."""
+ return execute(
+ """UPDATE documents SET doc_type=%s, own_number=%s, parent_number=%s,
+ doc_date=%s, counterparty=%s, classify_status='classified', classify_raw=%s, classify_input=%s
+ WHERE id=%s""",
+ (doc_type, own_number, parent_number, doc_date, counterparty, classify_raw, classify_input, doc_id),
+ )
+
+
+def set_classify_failed(doc_id, error):
+ return execute(
+ "UPDATE documents SET classify_status='failed', error_message=%s WHERE id=%s",
+ (error, doc_id),
+ )
+
+
+def set_classify_garbage(doc_id, reason=""):
+ """Mark document as garbage (Stage 1-2 filter, no LLM call)."""
+ return execute(
+ "UPDATE documents SET doc_type='garbage', classify_status='garbage', error_message=%s WHERE id=%s",
+ (f"garbage: {reason}", doc_id),
+ )
+
+
+def list_pending(batch_id):
+ """Documents waiting for classification."""
+ return query(
+ "SELECT id, filename, elements_json FROM documents WHERE batch_id=%s AND classify_status='pending'",
+ (batch_id,),
+ )
+
+
+def reset_classify_status(batch_id):
+ """Сбросить classify_status на 'pending' для всех документов батча (включая 'processing' — crash recovery)."""
+ return execute(
+ "UPDATE documents SET classify_status='pending', error_message=NULL WHERE batch_id=%s",
+ (batch_id,),
+ )
+
+
+def set_classify_processing(doc_id):
+ """Mark document as being processed (for crash recovery)."""
+ return execute(
+ "UPDATE documents SET classify_status='processing' WHERE id=%s",
+ (doc_id,),
+ )
+
+
+def list_by_batch(batch_id):
+ """All documents in a batch with classification fields."""
+ return query(
+ """SELECT id, filename, status, doc_type, own_number, parent_number,
+ doc_date, counterparty, classify_status, error_message, classify_raw, classify_input,
+ zip_source
+ FROM documents WHERE batch_id=%s ORDER BY created_at""",
+ (batch_id,),
+ )
+
+
+def count_by_status(batch_id):
+ """Count documents by classify_status."""
+ rows = query(
+ "SELECT classify_status, count(*) as cnt FROM documents WHERE batch_id=%s GROUP BY classify_status",
+ (batch_id,),
+ )
+ return {r["classify_status"]: r["cnt"] for r in rows}
diff --git a/deploy-lucee/db/prompts.py b/deploy-lucee/db/prompts.py
new file mode 100644
index 0000000..cfbb3ef
--- /dev/null
+++ b/deploy-lucee/db/prompts.py
@@ -0,0 +1,151 @@
+"""Prompts CRUD."""
+from .connection import query, execute, execute_returning
+
+
+def _serialize(row):
+ """Convert datetime fields to strings for JSON serialization (non-mutating)."""
+ if row and row.get("created_at"):
+ row = dict(row)
+ row["created_at"] = str(row["created_at"])
+ return row
+
+
+def get_active(role):
+ rows = query(
+ "SELECT * FROM prompts WHERE role = %s AND is_active = true ORDER BY created_at DESC LIMIT 1",
+ (role,),
+ )
+ return _serialize(rows[0]) if rows else None
+
+
+def get(prompt_id):
+ rows = query("SELECT * FROM prompts WHERE id = %s", (prompt_id,))
+ return rows[0] if rows else None
+
+
+def seed_defaults():
+ """Auto-seed default prompts if table is empty."""
+ # Check each role separately — don't skip if one is missing
+ existing_roles = set(r["role"] for r in query("SELECT DISTINCT role FROM prompts"))
+
+ if "extract" not in existing_roles:
+ extract_body = (
+ "Ты — анализатор договоров облачного провайдера.\n\n"
+ "Ниже текст спецификации услуг из ПЕРВОГО документа (базовый договор).\n"
+ "Извлеки ВСЕ строки спецификации в JSON-массив.\n\n"
+ "Верни СТРОГО JSON без пояснений. Не используй markdown-блоки.\n\n"
+ "ФОРМАТ:\n"
+ '{\n "mode": "partial",\n "ops": [\n'
+ ' {"action": "ADD", "new_row": {"name": "полное наименование", "price": число, "qty": число, "sum": число, "date_start": "YYYY-MM-DD"}, "comment": ""}\n'
+ " ]\n}\n\n"
+ "ПРАВИЛА:\n"
+ "1. Извлеки КАЖДУЮ строку таблицы спецификации как отдельную ADD-операцию.\n"
+ '2. Пропускай итоговые строки ("Итого...") и строки с подписями.\n'
+ "3. Если ячейка пустая — ставь null (не пиши 0).\n"
+ "4. price, qty, sum — ЧИСЛА, не строки.\n\n"
+ "ТЕКСТ ДОКУМЕНТА:\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"),
+ )
+
+ if "diff" not in existing_roles:
+ diff_body = (
+ "Ты — анализатор допсоглашений к договорам облачного провайдера.\n\n"
+ "У тебя есть текущая спецификация услуг и текст нового допсоглашения (ДС).\n"
+ "Твоя задача — определить, какие изменения вносит ДС в текущую спецификацию.\n\n"
+ "Верни СТРОГО JSON без пояснений. Не используй markdown-блоки.\n\n"
+ "ФОРМАТ:\n"
+ '{\n "mode": "partial" | "full_replace",\n "ops": [\n'
+ ' {"action": "ADD", "new_row": {"name": "...", "price": число, "qty": число, "sum": число, "date_start": "YYYY-MM-DD"}, "comment": "..."},\n'
+ ' {"action": "UPDATE", "target_id": "rN", "new_values": {"price": число}, "comment": "..."},\n'
+ ' {"action": "DELETE", "target_id": "rN", "comment": "..."},\n'
+ ' {"action": "UNRESOLVED", "new_values": {"name": "...", "price": число, ...}, "reason": "почему не смог сопоставить"}\n'
+ " ]\n}\n\n"
+ "ПРАВИЛА:\n"
+ '1. mode = "full_replace" — если в тексте есть фразы: «в следующей редакции», «заменить приложение», «излагается в следующей редакции». При full_replace — опиши ВСЕ новые строки как ADD.\n'
+ '2. mode = "partial" — если ДС меняет только отдельные строки.\n'
+ "3. Для UPDATE/DELETE — укажи target_id (r1, r2...) из списка текущей спецификации. НЕ придумывай новые id.\n"
+ "4. new_values в UPDATE — только ИЗМЕНЁННЫЕ поля (не все).\n"
+ "5. Если не можешь однозначно сопоставить строку — action: UNRESOLVED с reason.\n"
+ "6. Пропускай итоговые строки и подписи.\n"
+ "7. price, qty, sum — ЧИСЛА (не строки).\n\n"
+ "ТЕКУЩАЯ СПЕЦИФИКАЦИЯ:\n{spec_current}\n\n"
+ "ТЕКСТ ДОПСОГЛАШЕНИЯ:\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"),
+ )
+ _ensure_classify_prompt()
+
+
+def list_by_role(role):
+ """List all versions for a role, newest first."""
+ rows = query(
+ "SELECT id, role, name, is_active, created_by, notes, created_at FROM prompts WHERE role=%s ORDER BY created_at DESC",
+ (role,),
+ )
+ for r in rows:
+ if r.get("created_at"):
+ r["created_at"] = str(r["created_at"])
+ return rows
+
+
+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),
+ )
+
+
+def activate(prompt_id):
+ """Activate a prompt version (deactivates others for same role)."""
+ row = query("SELECT role FROM prompts WHERE id=%s", (prompt_id,))
+ if not row:
+ return False
+ role = row[0]["role"]
+ execute("UPDATE prompts SET is_active=false WHERE role=%s", (role,))
+ execute("UPDATE prompts SET is_active=true WHERE id=%s", (prompt_id,))
+ return True
+
+
+def delete_prompt(prompt_id):
+ """Delete a prompt version (cannot delete active one)."""
+ row = query("SELECT is_active FROM prompts WHERE id=%s", (prompt_id,))
+ if not row:
+ return False
+ if row[0]["is_active"]:
+ return False # cannot delete active
+ execute("DELETE FROM prompts WHERE id=%s", (prompt_id,))
+ return True
+
+
+def _ensure_classify_prompt():
+ """Ensure classify prompt exists (idempotent)."""
+ existing = query("SELECT id FROM prompts WHERE role = 'classify' AND is_active = true LIMIT 1")
+ if existing:
+ return
+ body = (
+ "Ты — система классификации договорных документов. "
+ "Проанализируй текст и верни СТРОГО ВАЛИДНЫЙ JSON ОДНОЙ СТРОКОЙ "
+ "(без переносов строк, без markdown, без лишних пробелов в начале/конце).\n\n"
+ "Поля:\n"
+ '- doc_type: "contract" (договор) / "supplement" (допсоглашение) / "specification" (спецификация/приложение) / "other"\n'
+ "- own_number: номер ЭТОГО документа, строка без лишних пробелов (или null)\n"
+ '- parent_number: номер родительского договора из фразы «к Договору №...» (или null)\n'
+ '- doc_date: дата в YYYY-MM-DD. «27 февраля 2026» → 2026-02-27 (или null)\n'
+ "- counterparty: полное название контрагента (Заказчик/Арендатор), без сокращений (или null)\n\n"
+ "Пример вывода:\n"
+ '{"doc_type":"supplement","own_number":"1","parent_number":"01300_2","doc_date":"2026-02-27","counterparty":"АО XXX003"}\n\n'
+ "ДОКУМЕНТ:\n---\n{header_text}\n---"
+ )
+ execute(
+ "INSERT INTO prompts (role, name, body, is_active, notes) VALUES (%s, %s, %s, %s, %s)",
+ ("classify", "default-v1", body, True, "Авто-создан: classify prompt v2 — LLM сам предложил"),
+ )
diff --git a/deploy-lucee/db/spec_current.py b/deploy-lucee/db/spec_current.py
new file mode 100644
index 0000000..63e9de3
--- /dev/null
+++ b/deploy-lucee/db/spec_current.py
@@ -0,0 +1,19 @@
+"""spec_current — текущее состояние спецификации."""
+from .connection import query
+
+
+def list_by_contract(contract_id):
+ """Return list of dicts with name_hash, name, price, qty, sum, date_start."""
+ return query(
+ """SELECT name_hash, name, price, qty, sum, date_start
+ FROM spec_current WHERE contract_id = %s ORDER BY name""",
+ (contract_id,),
+ )
+
+
+def get_elements_json(document_id):
+ """Get elements_json for a document."""
+ rows = query(
+ "SELECT elements_json FROM documents WHERE id = %s", (document_id,)
+ )
+ return rows[0]["elements_json"] if rows and rows[0]["elements_json"] else None
diff --git a/deploy-lucee/db/spec_events.py b/deploy-lucee/db/spec_events.py
new file mode 100644
index 0000000..e23081e
--- /dev/null
+++ b/deploy-lucee/db/spec_events.py
@@ -0,0 +1,160 @@
+"""spec_events — event sourcing: apply ops, reset contract."""
+import json, uuid
+from .connection import query, execute, get_pool
+
+
+def reset(contract_id):
+ """Clear spec_events + spec_current for contract."""
+ execute("DELETE FROM spec_current WHERE contract_id = %s", (contract_id,))
+ execute("DELETE FROM spec_events WHERE contract_id = %s", (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)
+
+
+def apply_ops(contract_id, supplement_id, document_id, ops, prompt_id, raw_llm_response):
+ """Apply ADD/UPDATE/DELETE ops. Returns summary dict."""
+ added = 0
+ updated = 0
+ deleted = 0
+ seq = get_next_seq(contract_id)
+
+ for op in ops:
+ action = op.get("action", "").upper()
+
+ if action == "ADD":
+ nr = op.get("new_row", {})
+ name = nr.get("name", "")
+ name_hash = _hash(name)
+ execute(
+ """INSERT INTO spec_events (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)""",
+ (
+ 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),
+ ),
+ )
+ _upsert_spec_current(contract_id, name_hash, nr)
+ seq += 1
+ added += 1
+
+ elif action == "UPDATE":
+ nv = op.get("new_values", {})
+ th = op.get("target_hash", "")
+ execute(
+ """INSERT INTO spec_events (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)""",
+ (
+ 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),
+ ),
+ )
+ _update_spec_current(contract_id, th, nv)
+ seq += 1
+ updated += 1
+
+ elif action == "DELETE":
+ th = op.get("target_hash", "")
+ execute(
+ """INSERT INTO spec_events (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)""",
+ (
+ contract_id, supplement_id, seq, th,
+ json.dumps({}), op.get("comment", ""),
+ prompt_id, document_id,
+ json.dumps(raw_llm_response, ensure_ascii=False),
+ ),
+ )
+ execute("DELETE FROM spec_current WHERE contract_id = %s AND name_hash = %s", (contract_id, th))
+ seq += 1
+ deleted += 1
+
+ elif action == "UNRESOLVED":
+ # Log but don't apply
+ execute(
+ """INSERT INTO spec_events (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)""",
+ (
+ contract_id, supplement_id, seq,
+ op.get("target_hash", ""),
+ json.dumps(op.get("new_values", {}), ensure_ascii=False),
+ op.get("reason", op.get("comment", "")),
+ prompt_id, document_id,
+ json.dumps(raw_llm_response, ensure_ascii=False),
+ ),
+ )
+ seq += 1
+
+ return {"added": added, "updated": updated, "deleted": deleted}
+
+
+def _hash(name):
+ import hashlib
+ return hashlib.sha256(name.strip().lower().encode()).hexdigest()[:16]
+
+
+def _upsert_spec_current(contract_id, name_hash, row):
+ """INSERT or UPDATE spec_current."""
+ existing = query(
+ "SELECT id FROM spec_current WHERE contract_id = %s AND name_hash = %s",
+ (contract_id, name_hash),
+ )
+ if existing:
+ execute(
+ """UPDATE spec_current SET name=%s, price=%s, qty=%s, sum=%s, date_start=%s, updated_at=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"),
+ row.get("qty"), row.get("sum"), row.get("date_start")),
+ )
+
+
+def _update_spec_current(contract_id, name_hash, new_values):
+ """Update specific fields in spec_current."""
+ sets = []
+ params = []
+ for field in ("name", "price", "qty", "sum", "date_start"):
+ if field in new_values:
+ sets.append(f"{field} = %s")
+ params.append(new_values[field])
+ if sets:
+ sets.append("updated_at = now()")
+ params.extend([contract_id, name_hash])
+ execute(
+ f"UPDATE spec_current SET {', '.join(sets)} WHERE contract_id = %s AND name_hash = %s",
+ params,
+ )
diff --git a/deploy-lucee/db/supplements.py b/deploy-lucee/db/supplements.py
new file mode 100644
index 0000000..41fa40e
--- /dev/null
+++ b/deploy-lucee/db/supplements.py
@@ -0,0 +1,59 @@
+"""Supplements CRUD."""
+from .connection import query, execute, execute_returning
+
+
+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),
+ )
+
+
+def list_by_contract(contract_id):
+ """Supplements with parsed documents, ordered by created_at."""
+ return query(
+ """SELECT s.id, s.type, s.document_id, d.filename
+ FROM supplements s
+ JOIN documents d ON s.document_id = d.id
+ WHERE s.contract_id = %s AND d.elements_json IS NOT NULL
+ ORDER BY s.created_at""",
+ (contract_id,),
+ )
+
+
+def get(supp_id):
+ rows = query("SELECT * FROM supplements WHERE id = %s", (supp_id,))
+ return rows[0] if rows else None
+
+
+def delete_by_document(contract_id, filename):
+ """Delete ALL supplements+documents by contract+filename (cascade: spec_events first).
+ Handles duplicates from previously failed uploads."""
+ rows = query(
+ """SELECT s.id as sid, s.document_id FROM supplements s
+ JOIN documents d ON d.id = s.document_id
+ WHERE s.contract_id = %s AND d.filename = %s""",
+ (contract_id, filename),
+ )
+ if not rows:
+ return False
+
+ for r in rows:
+ # 1. Delete spec_current rows referencing this supplement's events
+ execute(
+ """DELETE FROM spec_current WHERE contract_id = %s
+ AND last_event_id IN (SELECT id FROM spec_events WHERE supplement_id = %s)""",
+ (contract_id, r["sid"]),
+ )
+ # 2. Delete spec_events referencing this supplement
+ execute("DELETE FROM spec_events WHERE supplement_id = %s", (r["sid"],))
+ # 3. Delete supplement
+ execute("DELETE FROM supplements WHERE id = %s", (r["sid"],))
+ # 4. Delete document
+ execute("DELETE FROM documents WHERE id = %s", (r["document_id"],))
+ return True
+
+
+def delete(supp_id):
+ return execute("DELETE FROM supplements WHERE id = %s", (supp_id,))
diff --git a/deploy-lucee/files.js b/deploy-lucee/files.js
new file mode 100644
index 0000000..a025050
--- /dev/null
+++ b/deploy-lucee/files.js
@@ -0,0 +1,519 @@
+/**
+ * files.js — Модуль работы с файлами (Фаза 1, decoupling-final-plan.md).
+ *
+ * ВЫНЕСЕНО из 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
+ *
+ * ЗАГРУЖАЕТСЯ: после app_utils.js, перед app.js
+ */
+
+/**
+ * statusToHTML(status) — Чистая функция: структура → HTML (Фаза 1).
+ *
+ * Вход: { kind, pct?, text?, count?, elapsed? } — ни одного HTML-тега.
+ * kind = 'uploading' | 'uploaded' | 'unzipping' | 'parsing' | 'parsed' | 'error' | ''
+ * Выход: безопасная HTML-строка (статусы не содержат пользовательских данных).
+ *
+ * ПАТТЕРН (decoupling-final-plan.md): отделяем данные от представления.
+ * state хранит чистые данные, statusToHTML — чистая функция рендеринга.
+ */
+function statusToHTML(st) {
+ if (!st || !st.kind) return '';
+ switch (st.kind) {
+ case 'uploading': return '↑ ' + (st.pct || 0) + '%';
+ case 'uploaded': return '✓';
+ case 'unzipping': return '⏳ распаковка...';
+ case 'parsing': return '⏳ парсинг...';
+ case 'parsed': return '✓ ' + (st.count || 0) + ' эл.' + (st.elapsed ? ' (' + st.elapsed + 'с)' : '') + '';
+ case 'error': return '✗ ' + (st.text || 'Неизвестная ошибка') + '';
+ default: return '';
+ }
+}
+
+/**
+ * renderFiles(state) — Рендер таблицы файлов (бывший renderTable, Фаза 1).
+ *
+ * Читает state.files, генерирует HTML для #fileTable.
+ * Использует statusToHTML() для рендеринга статусов (чистые данные → HTML).
+ * Вызывает lucide.createIcons() для иконок.
+ *
+ * Вход: state (весь объект состояния, но читает только state.files).
+ * Выход: мутирует DOM (fileTable.innerHTML).
+ */
+function renderFiles(state) {
+ if (state.files.length === 0) {
+ fileTable.innerHTML = '
Нет файлов — выберите .docx / .pdf
';
+ lucide.createIcons();
+ return;
+ }
+
+ // Сгруппировать файлы по zip_source
+ var groups = []; // [{zip: "name.zip"|null, files: [f, ...]}]
+ var seen = {};
+ for (var i = 0; i < state.files.length; i++) {
+ var f = state.files[i];
+ var zip = f.zip_source || null;
+ var key = zip || '__naked__';
+ if (!seen[key]) {
+ seen[key] = { zip: zip, files: [] };
+ groups.push(seen[key]);
+ }
+ // Сохраняем оригинальный индекс для id строк
+ f._idx = i;
+ seen[key].files.push(f);
+ }
+
+ // Рендер: заголовок ZIP → файлы с отступом
+ var rows = [];
+ for (var gi = 0; gi < groups.length; gi++) {
+ var g = groups[gi];
+
+ if (g.zip) {
+ // Заголовок-секция ZIP
+ rows.push(
+ '
'
+ );
+ }
+
+ // Файлы внутри группы (с отступом если из ZIP)
+ for (var fi = 0; fi < g.files.length; fi++) {
+ var f = g.files[fi];
+ var i = f._idx;
+ var indent = g.zip ? 'padding-left:24px;' : '';
+
+ rows.push(
+ '
'
+ );
+ }
+ }
+
+ fileTable.innerHTML = rows.join('');
+ lucide.createIcons();
+}
+
+/**
+ * syncDB() — Синхронизация БД с текущим состоянием файлов.
+ *
+ * Отправляет на бэкенд список doc_id, которые должны остаться в БД.
+ * Всё, чего нет в state.files, будет удалено из БД (cascade).
+ * Вызывается после каждого изменения состава файлов (removeFile, upload).
+ */
+async function syncDB() {
+ var keepIds = state.files.map(function(f) { return f.doc_id; }).filter(Boolean);
+ try {
+ await fetch(VM_API + '/api/sync', {
+ method: 'POST',
+ headers: {'Content-Type': 'application/json'},
+ body: JSON.stringify({keep_ids: keepIds})
+ });
+ } catch(e) { /* сервер недоступен — не критично */ }
+}
+
+/**
+ * toggleClassifyDetail(i) — Раскрыть/скрыть результат классификации под строкой файла.
+ *
+ * Вызывается по клику на имя файла в таблице.
+ * Загружает данные с бэкенда (/api/documents/:id) и показывает:
+ * - Тип, номер, родительский номер, дату, контрагента
+ * - Текст отправленный в LLM (📤)
+ * - Сырой ответ LLM (📥)
+ * - Распарсенные элементы JSON (📄)
+ */
+window.toggleClassifyDetail = async function(i) {
+ var detailRow = document.getElementById('detail_' + i);
+ var expandIcon = document.getElementById('expand_' + i);
+ if (!detailRow) return;
+
+ if (detailRow.style.display !== 'none') {
+ detailRow.style.display = 'none';
+ if (expandIcon) expandIcon.textContent = '▸';
+ return;
+ }
+
+ var f = state.files[i];
+ if (!f || !f.doc_id) return;
+
+ detailRow.style.display = '';
+ if (expandIcon) expandIcon.textContent = '▾';
+
+ try {
+ var resp = await fetch(VM_API + '/api/documents/' + f.doc_id);
+ var qData = await resp.json();
+ if (!qData.ok) { detailRow.cells[0].innerHTML = 'Ошибка загрузки'; return; }
+
+ var html = '';
+ if (qData.classify_status === 'classified') {
+ html += '
🏷️ Результат классификации
';
+ html += '
';
+ if (qData.doc_type) html += 'Тип: ' + escHtml(qData.doc_type) + '';
+ if (qData.own_number) html += 'Номер: ' + escHtml(qData.own_number) + '';
+ if (qData.parent_number) html += 'Родительский: ' + escHtml(qData.parent_number) + '';
+ if (qData.doc_date) html += 'Дата: ' + escHtml(qData.doc_date) + '';
+ if (qData.counterparty) html += 'Контрагент: ' + escHtml(qData.counterparty) + '';
+ html += '
';
+ if (qData.classify_input) {
+ html += '📤 Текст отправленный в LLM';
+ html += '
' + escHtml(qData.classify_input) + '
';
+ html += '';
+ }
+ if (qData.classify_raw) {
+ html += '📥 Сырой ответ LLM';
+ html += '
' + escHtml(qData.classify_raw) + '
';
+ html += '';
+ }
+ if (qData.elements_json) {
+ var ej = qData.elements_json;
+ if (typeof ej === 'object' && ej.Value) ej = ej.Value;
+ var ejStr = typeof ej === 'string' ? ej : JSON.stringify(ej, null, 2);
+ html += '📄 Распарсенные элементы (JSON)';
+ html += '
';
+
+ return html;
+}
+
+/**
+ * renderGroupCardDone(group, gi) — Чистая HTML-функция: обработанная группа (Фаза 2).
+ *
+ * Показывает: ✓ Готово (время), список документов (кликабельные — раскрывают секции),
+ * тело сравнения (cmpBody) — сворачивается по клику на заголовок.
+ *
+ * Вход: group — элемент state.groups (group.compare.status === 'done')
+ * gi — индекс группы
+ * Выход: HTML-строка
+ *
+ * ЗАМЕНЯЕТ удалённую markGroupDone().
+ * Готовность определяется по group.compare.status, а не по наличию кнопки.
+ */
+function renderGroupCardDone(group, gi) {
+ var time = group.compare.totalTime || '?с';
+ var bodyHTML = group.compare.bodyHTML || '';
+
+ var html = '
' +
+ '
' +
+ '▾' +
+ '📄 Договор №' + escHtml(group.contract_number || '?') + ' — ' + escHtml(group.counterparty || 'контрагент не определён') +
+ ' ✓ Готово (' + time + ')' +
+ '