Files
contractor/deploy/convert_server.py
T

238 lines
9.8 KiB
Python
Executable File
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python3
"""HTTP-сервер: конвертация .doc → .docx + LLM-анализ ДС (/llm-ops) + SSE v2 (/process-v2)"""
from http.server import HTTPServer, BaseHTTPRequestHandler
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:
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:
out = f.read()
self.send_response(200)
self.send_header("Content-Type", "application/vnd.openxmlformats-officedocument.wordprocessingml.document")
self.send_header("Content-Length", len(out))
self.end_headers()
self.wfile.write(out)
else:
self.send_error(500, "Conversion failed")
finally:
os.unlink(doc_path)
for x in os.listdir(tmpdir): os.unlink(os.path.join(tmpdir, x))
os.rmdir(tmpdir)
def log_message(self, *a): pass
HTTPServer(("127.0.0.1", 8766), Handler).serve_forever()