From 1ecff7c441d998e72d205f3f134eb097c06b7e35 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E2=80=9CNaeel=E2=80=9D?= Date: Sat, 20 Jun 2026 10:52:37 +0400 Subject: [PATCH] =?UTF-8?q?v2:=20SSE=20=D1=87=D0=B5=D1=80=D0=B5=D0=B7=20?= =?UTF-8?q?=D0=92=D0=9C=20(process-v2),=20=D1=83=D0=B1=D1=80=D0=B0=D0=BD?= =?UTF-8?q?=D0=BE=20=D1=81=20Lucee=20(v1.0.75)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- deploy/convert_server.py | 208 +++++++++++++++++++++++++++++++++++- deploy/nginx-contracts.conf | 7 ++ index.cfm | 4 +- process.cfm | 6 -- 4 files changed, 215 insertions(+), 10 deletions(-) diff --git a/deploy/convert_server.py b/deploy/convert_server.py index 3feda60..dbaa4c2 100755 --- a/deploy/convert_server.py +++ b/deploy/convert_server.py @@ -1,10 +1,214 @@ #!/usr/bin/env python3 -"""HTTP-сервер для конвертации .doc → .docx""" +"""HTTP-сервер: конвертация .doc → .docx + LLM-анализ ДС (/llm-ops) + SSE v2 (/process-v2)""" from http.server import HTTPServer, BaseHTTPRequestHandler -import subprocess, tempfile, os, sys +from urllib.parse import urlparse, parse_qs +import subprocess, tempfile, os, sys, json, time + +import httpx +from llm_prompt import build_prompt + +LLM_URL = "https://api.aillm.ru/v1/chat/completions" +LLM_KEY = "sk-ucI5YvOticoOQ9Kuj5K9mQ" +LLM_MODEL = "gpt-oss-120b" +LUCEE_URL = "https://contractor.luceek8s.dev.nubes.ru" + class Handler(BaseHTTPRequestHandler): + def do_GET(self): + parsed = urlparse(self.path) + if parsed.path == "/process-v2": + self._handle_process_v2(parsed) + else: + self.send_error(404) + def do_POST(self): + if self.path == "/llm-ops": + self._handle_llm_ops() + else: + self._handle_doc_convert() + + def _sse(self, data): + """Отправить SSE-событие.""" + self.wfile.write(f"data: {json.dumps(data, ensure_ascii=False)}\n\n".encode()) + self.wfile.flush() + + def _lucee_query(self, sql): + """Запрос к Lucee API.""" + resp = httpx.get(f"{LUCEE_URL}/api.cfm", params={"action": "query", "sql": sql}, timeout=30) + resp.raise_for_status() + d = resp.json() + if not d.get("OK"): + raise Exception(d.get("ERROR", "API error")) + cols = d["COLUMNS"] + rows = [] + for r in d["ROWS"]: + row = {} + for i, c in enumerate(cols): + row[c.lower()] = r[i] if isinstance(r, list) else r[c] + rows.append(row) + return rows + + def _handle_process_v2(self, parsed): + """SSE-стриминг v2 пайплайна.""" + params = parse_qs(parsed.query) + cid = params.get("contract_id", [None])[0] + if not cid: + self.send_error(400, "contract_id required") + return + + self.send_response(200) + self.send_header("Content-Type", "text/event-stream") + self.send_header("Cache-Control", "no-cache") + self.send_header("Connection", "keep-alive") + self.send_header("Access-Control-Allow-Origin", "*") + self.end_headers() + + t0 = time.time() + + try: + # 1. Список supplements + supps = self._lucee_query( + f"SELECT s.id, s.type, d.filename FROM supplements s " + f"JOIN documents d ON s.document_id=d.id " + f"WHERE s.contract_id='{cid}' AND d.elements_json IS NOT NULL ORDER BY s.created_at" + ) + if not supps: + self._sse({"type": "error", "message": "Нет распарсенных файлов"}) + return + + for s in supps: + sid = s["id"] + + # Текущая спецификация + cur = self._lucee_query( + f"SELECT name_hash, name, price, qty, sum, date_start " + f"FROM spec_current WHERE contract_id='{cid}' ORDER BY name" + ) + current_spec = [] + for r in cur: + current_spec.append({ + "hash": r["name_hash"], + "name": r["name"], + "price": float(r["price"]) if r.get("price") else None, + "qty": float(r["qty"]) if r.get("qty") else None, + "sum": float(r["sum"]) if r.get("sum") else None, + "date_start": r["date_start"], + }) + + # elements_json → текст + docs = self._lucee_query( + f"SELECT elements_json FROM documents d " + f"JOIN supplements s ON s.document_id=d.id WHERE s.id='{sid}'" + ) + if not docs: + continue + elements = json.loads(docs[0]["elements_json"]) + lines = [] + for el in elements: + if el.get("type") == "paragraph": + prefix = f"[{el['style']}] " if el.get("style") else "" + lines.append(prefix + el.get("text", "")) + elif el.get("type") == "table": + rows = el.get("rows", []) + if rows: + ncols = len(rows[0]) + lines.append(f"--- Таблица ({len(rows)}×{ncols}) ---") + for row in rows: + cells = [str(c).replace("\n", " ").replace("|", "\\|") for c in row[:ncols]] + lines.append("| " + " | ".join(cells) + " |") + lines.append("") + doc_text = "\n".join(lines) + + self._sse({"type": "extract_start", "supplement_id": sid, "filename": s["filename"]}) + + # LLM + t1 = time.time() + resp = httpx.post(f"http://127.0.0.1:8766/llm-ops", json={ + "contract_id": cid, "supplement_id": sid, + "current_spec": current_spec, "doc_text": doc_text + }, timeout=130) + elapsed = round(time.time() - t1, 1) + + if resp.status_code != 200: + self._sse({"type": "extract_error", "supplement_id": sid, "filename": s["filename"], + "error": f"LLM HTTP {resp.status_code}", "time_s": elapsed}) + continue + + llm_result = resp.json() + if "error" in llm_result: + self._sse({"type": "extract_error", "supplement_id": sid, "filename": s["filename"], + "error": llm_result["error"], "time_s": elapsed}) + continue + + ops = llm_result.get("ops", []) + mode = llm_result.get("mode", "partial") + self._sse({"type": "llm_done", "supplement_id": sid, "filename": s["filename"], + "ops_count": len(ops), "mode": mode, "time_s": elapsed}) + + # Применить + apply_resp = httpx.post( + f"{LUCEE_URL}/apply_events.cfm?contract_id={cid}&mode={mode}", + json={"supplement_id": sid, "ops": ops}, timeout=30 + ) + if apply_resp.status_code == 200: + ar = apply_resp.json() + summary = ar.get("summary", {}) + self._sse({"type": "applied", "supplement_id": sid, "summary": summary}) + else: + self._sse({"type": "apply_error", "supplement_id": sid, + "error": f"apply HTTP {apply_resp.status_code}"}) + + total_time = round(time.time() - t0, 1) + self._sse({"type": "done", "total_time_s": total_time}) + + except Exception as e: + self._sse({"type": "error", "message": str(e)}) + + def _handle_llm_ops(self): + length = int(self.headers.get("Content-Length", 0)) + body = json.loads(self.rfile.read(length)) + + prompt = build_prompt( + body.get("current_spec", []), + body.get("doc_text", ""), + ) + payload = { + "model": LLM_MODEL, + "messages": [{"role": "user", "content": prompt}], + "max_tokens": 8000, + "temperature": 0.1, + } + + try: + with httpx.Client(http2=True, timeout=120, verify=False) as client: + resp = client.post(LLM_URL, json=payload, + headers={"Authorization": f"Bearer {LLM_KEY}", "Content-Type": "application/json"}) + resp.raise_for_status() + data = resp.json() + + raw_text = data.get("choices", [{}])[0].get("message", {}).get("content", "") + + # Вытащить JSON из ответа + json_text = raw_text + if "```json" in json_text: + json_text = json_text.split("```json")[1].split("```")[0] + elif "```" in json_text: + json_text = json_text.split("```")[1].split("```")[0] + + result = json.loads(json_text.strip()) + + self.send_response(200) + self.send_header("Content-Type", "application/json") + self.end_headers() + self.wfile.write(json.dumps(result, ensure_ascii=False).encode()) + + except Exception as e: + self.send_response(502) + self.send_header("Content-Type", "application/json") + self.end_headers() + self.wfile.write(json.dumps({"error": str(e)}, ensure_ascii=False).encode()) + + def _handle_doc_convert(self): length = int(self.headers.get("Content-Length", 0)) data = self.rfile.read(length) with tempfile.NamedTemporaryFile(suffix=".doc", delete=False) as f: diff --git a/deploy/nginx-contracts.conf b/deploy/nginx-contracts.conf index f0970e3..8bf9921 100644 --- a/deploy/nginx-contracts.conf +++ b/deploy/nginx-contracts.conf @@ -35,6 +35,13 @@ server { proxy_read_timeout 130s; } + location /process-v2 { + proxy_pass http://127.0.0.1:8766; + proxy_read_timeout 600s; + proxy_buffering off; + add_header Access-Control-Allow-Origin "*"; + } + listen 443 ssl; # managed by Certbot ssl_certificate /etc/letsencrypt/live/contracts.kube5s.ru/fullchain.pem; # managed by Certbot ssl_certificate_key /etc/letsencrypt/live/contracts.kube5s.ru/privkey.pem; # managed by Certbot diff --git a/index.cfm b/index.cfm index 398a50a..7fabf09 100644 --- a/index.cfm +++ b/index.cfm @@ -61,7 +61,7 @@
Nubes - Сверка договоров — LLM v1.0.74 — Lucee + Сверка договоров — LLM v1.0.75 — Lucee
@@ -348,7 +348,7 @@ document.getElementById('llmBtnV2').addEventListener('click', function() { diffStatus.textContent = '⏳ ' + ((Date.now() - llmStart) / 1000).toFixed(1) + 'с'; }, 200); - var es = new EventSource('/process.cfm?contract_id=' + contractId + '&v=2'); + var es = new EventSource('https://contracts.kube5s.ru/process-v2?contract_id=' + contractId); es.onmessage = function(e) { var d = JSON.parse(e.data); diff --git a/process.cfm b/process.cfm index 796d797..0e5553d 100644 --- a/process.cfm +++ b/process.cfm @@ -6,17 +6,11 @@ - : ok - - - - - data: #serializeJSON({type:"error",message:"contract_id required"})#