diff --git a/deploy/convert_server.py b/deploy/convert_server.py index dbaa4c2..9f092cb 100755 --- a/deploy/convert_server.py +++ b/deploy/convert_server.py @@ -1,6 +1,11 @@ #!/usr/bin/env python3 """HTTP-сервер: конвертация .doc → .docx + LLM-анализ ДС (/llm-ops) + SSE v2 (/process-v2)""" from http.server import HTTPServer, BaseHTTPRequestHandler +from socketserver import ThreadingMixIn + +class ThreadingHTTPServer(ThreadingMixIn, HTTPServer): + """Многопоточный HTTP-сервер.""" + daemon_threads = True from urllib.parse import urlparse, parse_qs import subprocess, tempfile, os, sys, json, time @@ -13,6 +18,30 @@ LLM_MODEL = "gpt-oss-120b" LUCEE_URL = "https://contractor.luceek8s.dev.nubes.ru" +def call_llm(current_spec, doc_text): + """Вызов LLM API. Возвращает {mode, ops} или кидает исключение.""" + prompt = build_prompt(current_spec, doc_text) + payload = { + "model": LLM_MODEL, + "messages": [{"role": "user", "content": prompt}], + "max_tokens": 8000, + "temperature": 0.1, + } + 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_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] + return json.loads(json_text.strip()) + + class Handler(BaseHTTPRequestHandler): def do_GET(self): parsed = urlparse(self.path) @@ -21,6 +50,13 @@ class Handler(BaseHTTPRequestHandler): else: self.send_error(404) + def do_OPTIONS(self): + self.send_response(200) + self.send_header("Access-Control-Allow-Origin", "*") + self.send_header("Access-Control-Allow-Methods", "GET, OPTIONS") + self.send_header("Access-Control-Allow-Headers", "Content-Type") + self.end_headers() + def do_POST(self): if self.path == "/llm-ops": self._handle_llm_ops() @@ -57,11 +93,12 @@ class Handler(BaseHTTPRequestHandler): return self.send_response(200) - self.send_header("Content-Type", "text/event-stream") + 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.send_header("Access-Control-Allow-Origin", "*") self.end_headers() + self.wfile.write(b": ok\n\n") + self.wfile.flush() t0 = time.time() @@ -102,7 +139,12 @@ class Handler(BaseHTTPRequestHandler): ) if not docs: continue - elements = json.loads(docs[0]["elements_json"]) + ej = docs[0]["elements_json"] + if isinstance(ej, dict) and "Value" in ej: + ej = ej["Value"] + elements = json.loads(ej) + # Lucee serializeJSON → UPPERCASE keys, normalize to lowercase + elements = [{k.lower(): v for k, v in el.items()} for el in elements] lines = [] for el in elements: if el.get("type") == "paragraph": @@ -121,25 +163,37 @@ class Handler(BaseHTTPRequestHandler): self._sse({"type": "extract_start", "supplement_id": sid, "filename": s["filename"]}) - # LLM + # LLM (прямой вызов, не HTTP self-call) + import threading 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) + done = threading.Event() + result = [None] + error = [None] + + def do_llm(): + try: + result[0] = call_llm(current_spec, doc_text) + except Exception as e: + error[0] = str(e) + finally: + done.set() + + t = threading.Thread(target=do_llm) + t.start() + + # Keepalive каждые 15с пока LLM думает + while not done.wait(15): + self._sse({"type": "keepalive"}) + + t.join() elapsed = round(time.time() - t1, 1) - if resp.status_code != 200: + if error[0]: 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}) + "error": error[0], "time_s": elapsed}) continue + llm_result = result[0] ops = llm_result.get("ops", []) mode = llm_result.get("mode", "partial") self._sse({"type": "llm_done", "supplement_id": sid, "filename": s["filename"], @@ -152,7 +206,8 @@ class Handler(BaseHTTPRequestHandler): ) if apply_resp.status_code == 200: ar = apply_resp.json() - summary = ar.get("summary", {}) + # Lucee serializeJSON → UPPERCASE keys + summary = {k.lower(): v for k, v in ar.get("SUMMARY", {}).items()} self._sse({"type": "applied", "supplement_id": sid, "summary": summary}) else: self._sse({"type": "apply_error", "supplement_id": sid, @@ -167,41 +222,12 @@ class Handler(BaseHTTPRequestHandler): 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()) - + result = call_llm(body.get("current_spec", []), body.get("doc_text", "")) 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") @@ -234,4 +260,4 @@ class Handler(BaseHTTPRequestHandler): os.rmdir(tmpdir) def log_message(self, *a): pass -HTTPServer(("127.0.0.1", 8766), Handler).serve_forever() +ThreadingHTTPServer(("127.0.0.1", 8766), Handler).serve_forever() diff --git a/index.cfm b/index.cfm index 7fabf09..ecf2c44 100644 --- a/index.cfm +++ b/index.cfm @@ -34,14 +34,15 @@ .status-err { color: var(--destructive); } .summary-row td { background: rgba(34,197,94,.05); font-weight: 600; } .empty-row td { color: var(--muted); text-align: center; padding: 24px; } - .remove-btn { cursor: pointer; color: var(--muted); background: none; border: none; padding: 2px 4px; font-size: 16px; line-height: 1; } - .remove-btn:hover { color: var(--destructive); } + .remove-btn { cursor: pointer; color: #f87171; background: none; border: none; padding: 2px 4px; font-size: 16px; line-height: 1; } + .remove-btn:hover { color: #ef4444; } .info-btn { cursor: pointer; color: var(--muted); background: none; border: none; padding: 2px 4px; font-size: 14px; display: none; } .info-btn:hover { color: var(--brand-primary); } .info-btn.visible { display: inline; } .modal-overlay { display: none; position: fixed; top: 0; left: 0; width: 100%; height: 100%; background: rgba(0,0,0,.4); z-index: 100; justify-content: center; align-items: center; } .modal-overlay.open { display: flex; } .modal { background: var(--background); border-radius: 12px; border: 1px solid var(--brand-gray); box-shadow: 0 4px 24px rgba(0,0,0,.15); max-width: 520px; width: 90%; max-height: 80vh; display: flex; flex-direction: column; } + .modal.wide { max-width: 900px; } .modal-header { padding: 14px 16px; border-bottom: 1px solid var(--brand-gray); display: flex; align-items: center; gap: 8px; font-weight: 600; flex-shrink: 0; } .modal-body { padding: 16px; overflow-y: auto; } .modal-body .kv { display: flex; margin-bottom: 6px; font-size: 13px; } @@ -56,12 +57,21 @@ .diff-field-new { color: var(--green); font-weight: 600; } @keyframes spin { to { transform: rotate(360deg); } } .spinner { display: inline-block; width: 16px; height: 16px; border: 2px solid rgba(255,255,255,.3); border-top-color: #fff; border-radius: 50%; animation: spin .6s linear infinite; } + .text-panes { display: flex; gap: 0; } + .text-pane { flex: 1; min-width: 0; overflow-y: auto; max-height: 65vh; } + .text-pane:first-child { border-right: 1px solid var(--brand-gray); padding-right: 12px; } + .text-pane:last-child { padding-left: 12px; } + .text-pane pre { background: var(--brand-grey-light); padding: 10px; border-radius: 6px; font-size: 11px; white-space: pre-wrap; margin: 0; } + .text-pane table { font-size: 11px; width: 100%; } + .text-pane th { font-size: 10px; padding: 4px 6px; } + .text-pane td { padding: 3px 6px; font-size: 11px; } + .text-pane .pane-title { font-weight: 600; font-size: 12px; margin-bottom: 8px; color: var(--muted); text-transform: uppercase; }
Nubes - Сверка договоров — LLM v1.0.75 — Lucee + Сверка договоров — LLM v1.0.76 — Lucee
@@ -76,7 +86,7 @@
- +
ИмяИзменёнРазмерСтатусБазовыйView
ИмяИзменёнРазмерПарсингБазовыйViewТекстИнфо
@@ -183,6 +193,7 @@ function renderTable() { '' + (f.status || '') + '' + '' + radio + '' + '' + + '' + '' + '' + ''; @@ -568,29 +579,48 @@ function showInfo(i) { document.getElementById('modalTitle').textContent = f.name; var html = '
Размер' + formatSize(f.size) + '
'; html += '
Изменён' + formatDate(f.lastModified) + '
'; + html += '
Тип' + (f.type || '—') + '
'; if (f.parseInfo) { var d = f.parseInfo; html += '
Элементов' + (d.ELEMENT_COUNT||0) + '
'; html += '
Параграфов' + (d.PARAGRAPHS||0) + '
'; html += '
Таблиц' + (d.TABLES||0) + '
'; html += '
Строк таблиц' + (d.TABLE_ROWS||0) + '
'; + html += '
Страниц' + (d.PAGES||'—') + '
'; + if (d.PARSE_TIME_MS) { + html += '
Время парсинга' + (d.PARSE_TIME_MS > 1000 ? (d.PARSE_TIME_MS/1000).toFixed(1) + ' с' : d.PARSE_TIME_MS + ' мс') + '
'; + } + if (d.TEXT_LENGTH) { + html += '
Символов текста' + d.TEXT_LENGTH.toLocaleString() + '
'; + } if (d.TEXT_PREVIEW) { - html += '
Превью текста:
'; - html += '
' + d.TEXT_PREVIEW + '
'; + html += '
Превью текста (первые 3000 симв.):
'; + html += '
' + d.TEXT_PREVIEW.substring(0, 3000) + '
'; } if (d.TABLE_HEADERS && d.TABLE_HEADERS.length > 0) { - html += '
Первые строки таблиц:
'; + html += '
Заголовки таблиц:
'; d.TABLE_HEADERS.forEach(function(hdr, ti) { - html += '
Таблица ' + (ti+1) + ':
'; + html += '
Таблица ' + (ti+1) + ' (' + (hdr||[]).length + ' колонок):
'; html += '
' + (hdr||[]).map(function(c){return c||'(пусто)';}).join(' | ') + '
'; }); } + if (d.TABLE_SAMPLES && d.TABLE_SAMPLES.length > 0) { + html += '
Первые строки таблиц:
'; + d.TABLE_SAMPLES.forEach(function(sample, ti) { + html += '
Таблица ' + (ti+1) + ':
'; + html += '
' + sample.map(function(row){ return (row||[]).map(function(c){return c||'(пусто)';}).join(' | '); }).join('\n') + '
'; + }); + } if (d.ERRORS && d.ERRORS.length > 0) { html += '
Ошибки:
'; html += '
' + d.ERRORS.join('\n') + '
'; } } html += '
doc_id' + (f.doc_id || '—') + '
'; + if (f.supp_id) { + html += '
supp_id' + f.supp_id + '
'; + html += '
Тип ДС' + (f.supp_type || '—') + '
'; + } document.getElementById('modalBody').innerHTML = html; document.getElementById('modalOverlay').classList.add('open'); } @@ -612,9 +642,64 @@ async function showView(docId) { } } +async function showText(docId) { + document.getElementById('modalTitle').textContent = 'Текст / Распарсено'; + document.getElementById('modalBody').innerHTML = 'Загрузка...'; + var modal = document.querySelector('.modal'); + modal.classList.add('wide'); + document.getElementById('modalOverlay').classList.add('open'); + try { + var resp = await fetch('/view.cfm?doc_id=' + docId); + var rawText = await resp.text(); + // Clean leading whitespace from CFML + rawText = rawText.replace(/^\s+/, ''); + // Fetch parsed elements via API + var qResp = await fetch('/api.cfm?action=query&sql=SELECT%20elements_json%20FROM%20documents%20WHERE%20id%3D\'' + docId + '\''); + var qData = await qResp.json(); + var parsedHtml = 'Нет данных парсинга'; + if (qData.OK && qData.ROWS && qData.ROWS.length > 0) { + var ej = qData.ROWS[0].ELEMENTS_JSON; + if (typeof ej === 'object' && ej.Value) ej = ej.Value; + var elements = JSON.parse(ej); + if (elements.length > 0) { + var rows = ''; + elements.forEach(function(el, idx) { + var etype = el.type || el.TYPE || '?'; + var style = el.style || el.STYLE || ''; + var content = ''; + if (etype === 'paragraph') { + content = (el.text || el.TEXT || '').substring(0, 200); + } else if (etype === 'table') { + var tblRows = el.rows || el.ROWS || []; + content = tblRows.length + '×' + (tblRows[0] ? tblRows[0].length : 0) + ' ячеек'; + } + rows += '' + (idx+1) + '' + etype + '' + style + '' + (content||'').replace(//g,'>') + ''; + }); + parsedHtml = '
' + rows + '
ТипСтильСодержание
'; + } + } + var html = '
' + + '
Сырой текст
' + rawText.replace(//g,'>') + '
' + + '
Распарсено
' + parsedHtml + '
' + + '
'; + document.getElementById('modalBody').innerHTML = html; + } catch(e) { + document.getElementById('modalBody').innerHTML = 'Ошибка загрузки'; + } +} + +// closeModal — сбросить wide при закрытии +var origCloseModal = closeModal; +closeModal = function(e) { + if (e && e.target !== document.getElementById('modalOverlay')) return; + document.querySelector('.modal').classList.remove('wide'); + document.getElementById('modalOverlay').classList.remove('open'); +}; + window.showInfo = showInfo; window.closeModal = closeModal; window.showView = showView; +window.showText = showText; // ── Промпт ─────────────────────────────────────────────────── var promptEditor = document.getElementById('promptEditor');