';
- }
-
- // ── 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
deleted file mode 100755
index 713930a..0000000
--- a/deploy-lucee/convert_doc.py
+++ /dev/null
@@ -1,24 +0,0 @@
-#!/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
deleted file mode 100755
index add6a77..0000000
--- a/deploy-lucee/convert_server.py
+++ /dev/null
@@ -1,509 +0,0 @@
-#!/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
deleted file mode 100644
index e69de29..0000000
diff --git a/deploy-lucee/db/connection.py b/deploy-lucee/db/connection.py
deleted file mode 100644
index 6afefb4..0000000
--- a/deploy-lucee/db/connection.py
+++ /dev/null
@@ -1,91 +0,0 @@
-"""
-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
deleted file mode 100644
index 4d13216..0000000
--- a/deploy-lucee/db/contracts.py
+++ /dev/null
@@ -1,25 +0,0 @@
-"""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
deleted file mode 100644
index 76f9e64..0000000
--- a/deploy-lucee/db/documents.py
+++ /dev/null
@@ -1,105 +0,0 @@
-"""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
deleted file mode 100644
index cfbb3ef..0000000
--- a/deploy-lucee/db/prompts.py
+++ /dev/null
@@ -1,151 +0,0 @@
-"""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
deleted file mode 100644
index 63e9de3..0000000
--- a/deploy-lucee/db/spec_current.py
+++ /dev/null
@@ -1,19 +0,0 @@
-"""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
deleted file mode 100644
index e23081e..0000000
--- a/deploy-lucee/db/spec_events.py
+++ /dev/null
@@ -1,160 +0,0 @@
-"""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
deleted file mode 100644
index 41fa40e..0000000
--- a/deploy-lucee/db/supplements.py
+++ /dev/null
@@ -1,59 +0,0 @@
-"""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
deleted file mode 100644
index a025050..0000000
--- a/deploy-lucee/files.js
+++ /dev/null
@@ -1,519 +0,0 @@
-/**
- * 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 + ')' +
- '