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; }

-
Сверка договоров — 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');