v2: SSE через ВМ (process-v2), убрано с Lucee (v1.0.75)
This commit is contained in:
+206
-2
@@ -1,10 +1,214 @@
|
|||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
"""HTTP-сервер для конвертации .doc → .docx"""
|
"""HTTP-сервер: конвертация .doc → .docx + LLM-анализ ДС (/llm-ops) + SSE v2 (/process-v2)"""
|
||||||
from http.server import HTTPServer, BaseHTTPRequestHandler
|
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):
|
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):
|
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))
|
length = int(self.headers.get("Content-Length", 0))
|
||||||
data = self.rfile.read(length)
|
data = self.rfile.read(length)
|
||||||
with tempfile.NamedTemporaryFile(suffix=".doc", delete=False) as f:
|
with tempfile.NamedTemporaryFile(suffix=".doc", delete=False) as f:
|
||||||
|
|||||||
@@ -35,6 +35,13 @@ server {
|
|||||||
proxy_read_timeout 130s;
|
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
|
listen 443 ssl; # managed by Certbot
|
||||||
ssl_certificate /etc/letsencrypt/live/contracts.kube5s.ru/fullchain.pem; # managed by Certbot
|
ssl_certificate /etc/letsencrypt/live/contracts.kube5s.ru/fullchain.pem; # managed by Certbot
|
||||||
ssl_certificate_key /etc/letsencrypt/live/contracts.kube5s.ru/privkey.pem; # managed by Certbot
|
ssl_certificate_key /etc/letsencrypt/live/contracts.kube5s.ru/privkey.pem; # managed by Certbot
|
||||||
|
|||||||
@@ -61,7 +61,7 @@
|
|||||||
<body>
|
<body>
|
||||||
<div class="topbar">
|
<div class="topbar">
|
||||||
<img src="/nubes-logo.svg" alt="Nubes">
|
<img src="/nubes-logo.svg" alt="Nubes">
|
||||||
<span class="title">Сверка договоров — LLM <span style="font-weight:400;color:var(--muted);font-size:12px;">v1.0.74 — Lucee</span></span>
|
<span class="title">Сверка договоров — LLM <span style="font-weight:400;color:var(--muted);font-size:12px;">v1.0.75 — Lucee</span></span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="content">
|
<div class="content">
|
||||||
@@ -348,7 +348,7 @@ document.getElementById('llmBtnV2').addEventListener('click', function() {
|
|||||||
diffStatus.textContent = '⏳ ' + ((Date.now() - llmStart) / 1000).toFixed(1) + 'с';
|
diffStatus.textContent = '⏳ ' + ((Date.now() - llmStart) / 1000).toFixed(1) + 'с';
|
||||||
}, 200);
|
}, 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) {
|
es.onmessage = function(e) {
|
||||||
var d = JSON.parse(e.data);
|
var d = JSON.parse(e.data);
|
||||||
|
|||||||
@@ -6,17 +6,11 @@
|
|||||||
<cfheader name="Access-Control-Allow-Origin" value="*">
|
<cfheader name="Access-Control-Allow-Origin" value="*">
|
||||||
|
|
||||||
<cfparam name="url.contract_id" default="">
|
<cfparam name="url.contract_id" default="">
|
||||||
<cfparam name="url.v" default="1">
|
|
||||||
|
|
||||||
<cfoutput>: ok
|
<cfoutput>: ok
|
||||||
|
|
||||||
</cfoutput><cfflush>
|
</cfoutput><cfflush>
|
||||||
|
|
||||||
<cfif url.v EQ "2">
|
|
||||||
<cfinclude template="process_v2.cfm">
|
|
||||||
<cfabort>
|
|
||||||
</cfif>
|
|
||||||
|
|
||||||
<cftry>
|
<cftry>
|
||||||
<cfif NOT len(url.contract_id)>
|
<cfif NOT len(url.contract_id)>
|
||||||
<cfoutput>data: #serializeJSON({type:"error",message:"contract_id required"})#
|
<cfoutput>data: #serializeJSON({type:"error",message:"contract_id required"})#
|
||||||
|
|||||||
Reference in New Issue
Block a user