diff --git a/requirements.txt b/requirements.txt index c3eee8e..5e95fec 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,2 +1,3 @@ -flask>=3.0 -gunicorn>=21.0 +flask +gunicorn +httpx diff --git a/site/app.py b/site/app.py index 74f0236..48a148a 100644 --- a/site/app.py +++ b/site/app.py @@ -1,11 +1,96 @@ -from flask import Flask +"""contracts-flask — Flask-фронтенд для сверки договоров. +1:1 функционал с Lucee-версией. +Все тяжёлые операции — на ВМ (contracts.kube5s.ru). +""" +import os +import httpx +from flask import Flask, render_template, request, jsonify app = Flask(__name__) +VM_API = os.environ.get("VM_API", "https://contracts.kube5s.ru") +LLM_URL = os.environ.get("LLM_API_URL", "https://api.aillm.ru/v1/chat/completions") +LLM_KEY = os.environ.get("LLM_API_KEY", "") +LLM_MODEL = os.environ.get("LLM_MODEL", "gpt-oss-120b") + @app.route("/") def index(): - return "contracts-flask OK" + return render_template("index.html", vm_api=VM_API) + + +@app.route("/chat", methods=["POST"]) +def chat(): + contract_id = request.args.get("contract_id", "") + question = request.form.get("question", "") + if not contract_id or not question: + return jsonify({"ok": False, "error": "contract_id and question required"}) + + try: + with httpx.Client(timeout=10) as client: + resp = client.get(f"{VM_API}/api/spec-current", params={"contract_id": contract_id}) + resp.raise_for_status() + rows = resp.json().get("rows", []) + except Exception as e: + return jsonify({"ok": False, "error": f"VM error: {e}"}) + + if not rows: + return jsonify({"ok": True, "answer": "Нет данных. Сначала запустите сравнение."}) + + ctx = "\n".join(f"{r.get('name','')} | цена={r.get('price')} | объём={r.get('qty')} | сумма={r.get('sum')} | начало={r.get('date_start')}" for r in rows) + prompt = f"Ты — анализатор договоров. Данные:\n{ctx}\n\nВопрос: {question}\nОтветь кратко, только по данным." + + try: + with httpx.Client(http2=True, timeout=120) as client: + resp = client.post(LLM_URL, json={ + "model": LLM_MODEL, + "messages": [{"role": "user", "content": prompt}], + "max_tokens": 1000, "temperature": 0.1, + }, headers={"Authorization": f"Bearer {LLM_KEY}", "Content-Type": "application/json"}) + resp.raise_for_status() + return jsonify({"ok": True, "answer": resp.json()["choices"][0]["message"]["content"]}) + except Exception as e: + return jsonify({"ok": False, "error": f"LLM error: {e}"}) + + +@app.route("/api/prompts", methods=["GET"]) +def prompts_get(): + try: + with httpx.Client(timeout=10) as client: + resp = client.get(f"{VM_API}/api/prompts", params=request.args) + return jsonify(resp.json()) + except Exception as e: + return jsonify({"ok": False, "error": str(e)}) + + +@app.route("/api/prompts/list", methods=["GET"]) +def prompts_list(): + try: + with httpx.Client(timeout=10) as client: + resp = client.get(f"{VM_API}/api/prompts/list") + return jsonify(resp.json()) + except Exception as e: + return jsonify({"ok": False, "error": str(e)}) + + +@app.route("/api/prompts/save", methods=["POST"]) +def prompts_save(): + try: + with httpx.Client(timeout=10) as client: + resp = client.post(f"{VM_API}/api/prompts/save", json=request.get_json()) + return jsonify(resp.json()) + except Exception as e: + return jsonify({"ok": False, "error": str(e)}) + + +@app.route("/api/prompts/activate", methods=["POST"]) +def prompts_activate(): + try: + with httpx.Client(timeout=10) as client: + resp = client.post(f"{VM_API}/api/prompts/activate", json=request.get_json()) + return jsonify(resp.json()) + except Exception as e: + return jsonify({"ok": False, "error": str(e)}) @app.route("/health") @@ -14,4 +99,14 @@ def health(): if __name__ == "__main__": - app.run(debug=False, host="0.0.0.0", port=5000) + app.run(host="0.0.0.0", port=5000) + +# ── Совместимость с Lucee-путями ────────────────────────────── + +@app.route("/chat.cfm", methods=["POST"]) +def chat_cfm(): + return chat() + +@app.route("/prompt.cfm", methods=["GET"]) +def prompt_cfm(): + return prompts_get() diff --git a/site/static/app.js b/site/static/app.js new file mode 100644 index 0000000..2ce4f0f --- /dev/null +++ b/site/static/app.js @@ -0,0 +1,623 @@ +// ⛔ НЕ МЕНЯТЬ БЕЗ РАЗРЕШЕНИЯ НАЕЛЯ ⛔ +// Contracts App — весь JS на VM (contracts.kube5s.ru) +// Lucee: только домен + index.cfm-скелет +var VM_API = 'https://contracts.kube5s.ru'; +var UPLOAD_URL = VM_API + '/upload'; +var CONVERT_URL = VM_API + '/convert-doc'; +var UNZIP_URL = VM_API + '/unzip-upload'; +// SITE_URL удалён (Фаза 4) — не использовался + +var fileInput = document.getElementById('fileInput'); +var fileTable = document.getElementById('fileTable'); +// state.files — теперь в state.js (Фаза 0: state + render) +// state.contractId — теперь в state.js (Фаза 0: state + render) +// state.batchId — теперь в state.js (Фаза 0: state + render) +// (batchId = crypto.randomUUID() — уникальный ID сессии для классификации) + +// Автоочистка старых записей при загрузке страницы +(async function cleanup() { + try { + await fetch(VM_API + '/api/cleanup', { method: 'POST' }); + } catch(e) { /* ignore */ } +})(); + +/** + * render(state) — Главная функция рендеринга (Фаза 0, decoupling-final-plan.md). + * + * ПАТТЕРН: action → мутация state → render(state) + * + * Фаза 0: вызывает существующий renderTable(). + * renderTable читает state.files напрямую (state — глобальный объект). + * Фаза 1+: будет вызывать renderFiles(state), renderGroups(state), renderStepper(state) и т.д. + * + * ПРАВИЛО: любое изменение state ДОЛЖНО завершаться вызовом render(state). + * Никакой прямой манипуляции DOM в обход render(). + * console.log(state) показывает ВСЁ состояние в любой момент. + */ +function render(state) { + renderFiles(state); + renderStepper(state); +} + +// syncDB, statusToHTML, renderFiles, toggleClassifyDetail, uploadFile, refreshSupps +// → вынесены в files.js (Фаза 1) + +// ── Pipeline stepper (Фаза 4: state-driven) ────────────────── + +/** + * renderStepper(state) — Рендер степпера из state.ui.steps (Фаза 4). + * + * Читает state.ui.steps.{upload,classify,groups,compare}, обновляет DOM. + * Значения: '○' (не начат) | '⏳' (в процессе) | '✓' (завершён). + */ +function renderStepper(state) { + var steps = state.ui.steps; + var map = { upload: 'stepUpload', classify: 'stepClassify', groups: 'stepGroups', compare: 'stepCompare' }; + var colors = { '○': 'var(--muted)', '⏳': 'var(--brand-primary)', '✓': 'var(--green)' }; + Object.keys(map).forEach(function(key) { + var el = document.getElementById(map[key]); + if (el) { + el.textContent = steps[key] + ' ' + {upload:'Загрузка',classify:'Классификация',groups:'Группировка',compare:'Сравнение'}[key]; + el.style.color = colors[steps[key]] || 'var(--muted)'; + } + }); +} + +/** + * stepDone(id) — Отметить шаг как завершённый (Фаза 4). + * Мутирует state.ui.steps, вызывает renderStepper. + */ +function stepDone(id) { + var key = { stepUpload: 'upload', stepClassify: 'classify', stepGroups: 'groups', stepCompare: 'compare' }[id]; + if (key) { state.ui.steps[key] = '✓'; renderStepper(state); } +} + +/** + * stepActive(id) — Отметить шаг как активный (Фаза 4). + * Мутирует state.ui.steps, вызывает renderStepper. + */ +function stepActive(id) { + var key = { stepUpload: 'upload', stepClassify: 'classify', stepGroups: 'groups', stepCompare: 'compare' }[id]; + if (key) { state.ui.steps[key] = '⏳'; renderStepper(state); } +} + +/** + * resetStepper(fromId) — Сбросить шаги начиная с fromId (Фаза 4). + * Все шаги после (и включая) fromId получают '○'. + * Мутирует state.ui.steps, вызывает renderStepper. + */ +function resetStepper(fromId) { + var order = ['stepUpload','stepClassify','stepGroups','stepCompare']; + var keys = ['upload','classify','groups','compare']; + var reset = false; + order.forEach(function(id, i) { + if (id === fromId) reset = true; + if (reset) { state.ui.steps[keys[i]] = '○'; } + }); + renderStepper(state); +} +// Утилиты (formatSize, formatDate, moveUp, moveDown, escHtml, fmtDate) — +// вынесены в app_utils.js для облегчения анализа и отладки. +// statusToHTML, renderFiles → files.js (Фаза 1) + +// ── Раскрыть/скрыть результат классификации под строкой файла ── +// toggleClassifyDetail → files.js (Фаза 1) + +// moveUp/moveDown/removeFile/openAbout/closeAbout — вынесены в app_utils.js + +// ── Автозагрузка при выборе файлов ────────────────────────── +// Фаза 1 (decoupling-final-plan.md): fileInput handler разбит на чистые функции +// onFilesSelected → reconcileSelection → addZipFile/addRegularFile → finalizeUpload +// Вся логика — в files.js. Здесь только тонкая обёртка. +fileInput.addEventListener('change', async function() { + var newFiles = Array.from(fileInput.files); + onFilesSelected(newFiles); +}); + +// ── Классификация ────────────────────────────────────────── +function showClassifyBtn() { + var btn = document.getElementById('classifyBtn'); + if (!btn) { + btn = document.createElement('button'); + btn.id = 'classifyBtn'; + btn.className = 'btn btn-primary'; + btn.style.cssText = 'width:100%;justify-content:center;margin-top:8px;background:var(--brand-primary);border-color:var(--brand-primary);'; + btn.innerHTML = ' Классифицировать'; + btn.addEventListener('click', runClassify); + document.getElementById('llmBtn').parentNode.insertBefore(btn, document.getElementById('llmBtn')); + } + btn.style.display = 'flex'; + btn.disabled = false; +} + +async function runClassify() { + var btn = document.getElementById('classifyBtn'); + btn.disabled = true; + btn.innerHTML = ' Классификация... 0с'; + stepActive('stepClassify'); + + var start = Date.now(); + var timer = setInterval(function() { + btn.innerHTML = ' Классификация... ' + Math.round((Date.now() - start) / 1000) + 'с'; + }, 1000); + + var totalFiles = 0; + var classifyDone = false; + + // Poll progress каждые 2с — работает и для sync, и для async classify + var pollTimer = setInterval(async function() { + try { + var r = await fetch(VM_API + '/api/batch-progress?batch=' + state.batchId); + var d = await r.json(); + if (d.ok && d.counts) { + var done = (d.counts.classified || 0) + (d.counts.failed || 0); + var total = d.total || 0; + if (total > 0) totalFiles = total; + btn.innerHTML = ' Классификация... ' + done + '/' + total + ' (' + Math.round((Date.now() - start) / 1000) + 'с)'; + if (done >= total && total > 0) { + clearInterval(pollTimer); + clearInterval(timer); + classifyDone = true; + btn.innerHTML = '✓ ' + done + '/' + total + ' классифицировано (' + Math.round((Date.now() - start) / 1000) + 'с)'; + stepDone('stepClassify'); + stepActive('stepGroups'); + loadGroupsAction(); + } + } + } catch(e) {} + }, 2000); + + try { + var resp = await fetch(VM_API + '/api/classify-batch', { + method: 'POST', + headers: {'Content-Type': 'application/json'}, + body: JSON.stringify({batch_id: state.batchId}) + }); + var data = await resp.json(); + + // sync classify: ответ уже содержит done + if (data.ok && data.done !== undefined) { + clearInterval(timer); + clearInterval(pollTimer); + classifyDone = true; + btn.innerHTML = '✓ ' + data.done + '/' + data.total + ' классифицировано (' + Math.round((Date.now() - start) / 1000) + 'с)'; + stepDone('stepClassify'); + stepActive('stepGroups'); + loadGroupsAction(); + } else if (data.ok) { + // async classify: 202 — ждём poll до done>=total + totalFiles = data.total || 0; + if (totalFiles === 0) { + clearInterval(timer); + clearInterval(pollTimer); + btn.innerHTML = '✗ нет файлов для классификации'; + btn.disabled = false; + } + } else { + clearInterval(timer); + clearInterval(pollTimer); + btn.innerHTML = '✗ ' + (data.error || 'ошибка'); + btn.disabled = false; + } + } catch(e) { + if (!classifyDone) { + clearInterval(timer); + clearInterval(pollTimer); + btn.innerHTML = '✗ ' + e.message; + btn.disabled = false; + } + } +} + +// loadGroups, buildGroupCard, markGroupDone → groups.js (Фаза 2) + +// Текущий активный EventSource (только один одновременно) +// state._activeCompare — теперь в state.js (Фаза 0: state + render) + +window.runCompareForGroup = async function(gi, btn) { + var group = state.groups[gi]; + if (!group || group.contract_number === '__unresolved__') return; + + // Фаза 2: отметить группу как «в процессе» + // renderGroups НЕ вызываем — инкрементальные DOM-мутации внутри SSE + group.compare = { status: 'running' }; + + // Остановить предыдущее сравнение + if (state._activeCompare.es) { state._activeCompare.es.close(); state._activeCompare.es = null; } + if (state._activeCompare.timer) { clearInterval(state._activeCompare.timer); state._activeCompare.timer = null; } + + // Свернуть ТОЛЬКО тело текущей группы + var curBody = document.getElementById('cmpBody_' + gi); + if (curBody) curBody.style.display = 'none'; + + btn.disabled = true; + btn.innerHTML = ' Применяю...'; + // Заблокировать ВСЕ кнопки сравнения пока идёт процесс + document.querySelectorAll('.cmp-btn').forEach(function(b) { b.disabled = true; }); + + var ar = await fetch(VM_API + '/api/apply-groups', { + method: 'POST', + headers: {'Content-Type': 'application/json'}, + body: JSON.stringify({batch_id: state.batchId, groups: [group]}) + }); + var ad = await ar.json(); + if (!ad.ok || !ad.contract_ids || !ad.contract_ids.length) { + btn.innerHTML = '✗ ошибка'; btn.disabled = false; + document.querySelectorAll('.cmp-btn').forEach(function(b) { b.disabled = false; }); + return; + } + + var cid = ad.contract_ids[0]; + + // Создать контейнер для результатов сравнения + var cmpBody = document.getElementById('cmpBody_' + gi); + if (!cmpBody) { + cmpBody = document.createElement('div'); + cmpBody.className = 'cmp-body'; + cmpBody.id = 'cmpBody_' + gi; + cmpBody.style.cssText = 'margin-top:8px;'; + btn.parentNode.insertBefore(cmpBody, btn.nextSibling); + } + cmpBody.innerHTML = ''; + cmpBody.style.display = 'block'; + btn.innerHTML = ' Сравнение...'; + stepActive('stepCompare'); + + var statusEl = document.createElement('div'); + statusEl.style.cssText = 'font-size:11px;color:var(--muted);margin-bottom:4px;'; + statusEl.textContent = '⏳ 0.0с'; + cmpBody.appendChild(statusEl); + + // Фаза 3: унифицированный SSE через startCompareSSE (compare.js) + var result = startCompareSSE( + VM_API + '/process-v2?contract_id=' + cid, + cmpBody, + statusEl, + { + onDone: function(d, sections) { + state._activeCompare.timer = null; + state._activeCompare.es = null; + group.compare = { + status: 'done', + totalTime: d.total_time_s + 'с', + bodyHTML: cmpBody.innerHTML, + sections: sections + }; + renderGroups(state); + document.querySelectorAll('.cmp-btn').forEach(function(b) { b.disabled = false; }); + stepDone('stepCompare'); + }, + onError: function(d) { + state._activeCompare.timer = null; + state._activeCompare.es = null; + group.compare = null; + cmpBody.innerHTML += '
✗ ' + d.message + '
'; + btn.innerHTML = '✗'; btn.disabled = false; + document.querySelectorAll('.cmp-btn').forEach(function(b) { b.disabled = false; }); + }, + onConnectionError: function() { + state._activeCompare.timer = null; + state._activeCompare.es = null; + group.compare = null; + btn.innerHTML = '✗ соединение'; btn.disabled = false; + document.querySelectorAll('.cmp-btn').forEach(function(b) { b.disabled = false; }); + } + } + ); + state._activeCompare.es = result.es; + state._activeCompare.timer = result.timer; +}; + +// uploadFile → files.js (Фаза 1) + +// ── LLM: Общее сравнение (Event Sourcing) ──────────────────────── +document.getElementById('llmBtn').addEventListener('click', function() { + if (!state.contractId) return; + var btn = this; + btn.disabled = true; + btn.innerHTML = ' Сравнение...'; + + var compareCard = document.getElementById('compareCard'); + var compareBody = document.getElementById('compareBody'); + var compareStatus = document.getElementById('compareStatus'); + compareCard.style.display = 'block'; + compareBody.innerHTML = ''; + compareStatus.textContent = '⏳ 0.0с'; + + var order = state.files.map(function(f) { return f.supp_id; }).filter(Boolean).join(','); + var url = 'https://contracts.kube5s.ru/process-v2?contract_id=' + state.contractId + + (order ? '&order=' + encodeURIComponent(order) : ''); + + // Фаза 3: унифицированный SSE через startCompareSSE (compare.js) + startCompareSSE(url, compareBody, compareStatus, { + onDone: function(d) { + btn.innerHTML = ' ✓ Готово'; + stepDone('stepCompare'); + if (d.total_unresolved > 0) { + compareBody.innerHTML += '
⚠ ' + d.total_unresolved + ' неразрешённых строк — разобрать
'; + } + document.getElementById('chatCard').style.display = 'block'; + lucide.createIcons(); + }, + onError: function(d) { + compareBody.innerHTML += '
✗ ' + d.message + '
'; + btn.innerHTML = ' Общее сравнение'; + btn.disabled = false; + }, + onConnectionError: function() { + if (compareBody.innerHTML === '') { + compareBody.innerHTML = '
✗ Ошибка соединения
'; + } + btn.innerHTML = ' Общее сравнение'; + btn.disabled = false; + lucide.createIcons(); + } + }); +}); + +// refreshSupps → files.js (Фаза 1) + +// ── Чат ────────────────────────────────────────────────────── +document.getElementById('chatSend').addEventListener('click', function() { + var input = document.getElementById('chatInput'); + var q = input.value.trim(); + if (!q || !state.contractId) return; + var msgs = document.getElementById('chatMessages'); + msgs.innerHTML += '
Вы: ' + q + '
'; + input.value = ''; + input.disabled = true; + document.getElementById('chatSend').disabled = true; + msgs.innerHTML += '
⏳ Думаю...
'; + + var fd = new FormData(); + fd.append('question', q); + fetch('/chat.cfm?contract_id=' + state.contractId, { method: 'POST', body: fd }) + .then(function(r) { return r.json(); }) + .then(function(d) { + msgs.lastChild.remove(); + msgs.innerHTML += '
Ответ: ' + (d.OK ? d.ANSWER : (d.ERROR || '—')) + '
'; + input.disabled = false; + document.getElementById('chatSend').disabled = false; + input.focus(); + }).catch(function() { + msgs.lastChild.remove(); + msgs.innerHTML += '
Ошибка сети
'; + input.disabled = false; + document.getElementById('chatSend').disabled = false; + }); +}); +document.getElementById('chatInput').addEventListener('keydown', function(e) { + if (e.key === 'Enter') document.getElementById('chatSend').click(); +}); + +function closeModal(e) { + if (e && e.target !== document.getElementById('modalOverlay')) return; + document.querySelector('.modal').classList.remove('wide'); + document.getElementById('modalOverlay').classList.remove('open'); +} + +async function showText(i) { + var f = state.files[i]; + var docId = f.doc_id; + document.getElementById('modalTitle').textContent = f.name; + document.getElementById('modalBody').innerHTML = 'Загрузка...'; + var modal = document.querySelector('.modal'); + modal.classList.add('wide'); + document.getElementById('modalOverlay').classList.add('open'); + + var leftHtml = 'Загрузка...'; + var rightHtml = ''; + + if (docId) { + try { + var qResp = await fetch(VM_API + '/api/documents/' + docId); + var qData = await qResp.json(); + var elements = []; + if (qData.ok && qData.elements_json) { + var ej = qData.elements_json; + if (typeof ej === 'object' && ej.Value) ej = ej.Value; + if (typeof ej === 'string') elements = JSON.parse(ej); + else elements = ej; + } + + // Общий textify для обеих панелей + var textLines = []; + elements.forEach(function(el) { + var etype = el.type || el.TYPE || '?'; + if (etype === 'paragraph') { + textLines.push(el.text || el.TEXT || ''); + } else if (etype === 'table') { + var rows = el.rows || el.ROWS || []; + if (rows.length > 0) { + var ncols = (rows[0]||[]).length; + textLines.push('--- Таблица ' + rows.length + '×' + ncols + ' ---'); + rows.forEach(function(row) { + var cells = (row||[]).map(function(c){ return String(c||'').replace(/\n/g,' ').replace(/\|/g,'\\|'); }); + textLines.push('| ' + cells.join(' | ') + ' |'); + }); + textLines.push(''); + } + } + }); + var textify = textLines.join('\n').replace(//g,'>'); + + // Левая: сырой текст (textify) + leftHtml = '
' + textify + '
'; + + // Правая: статистика + textify + rightHtml += '
Размер' + formatSize(f.size) + '
'; + rightHtml += '
Изменён' + formatDate(f.lastModified) + '
'; + if (f.parseInfo) { + var d = f.parseInfo; + rightHtml += '
Элементов' + (d.element_count||0) + '
'; + if (d.error) rightHtml += '
' + d.error + '
'; + } + rightHtml += '
Все элементы:
'; + rightHtml += '
' + textify + '
'; + if (f.supp_id) { + rightHtml += '
Тип ДС' + (f.supp_type || '—') + '
'; + } + + // ── Результаты классификации ────────────────────────── + if (qData.classify_status === 'classified' || qData.classify_raw) { + rightHtml += '
🏷️ Классификация LLM
'; + if (qData.doc_type) rightHtml += '
Тип' + escHtml(qData.doc_type) + '
'; + if (qData.own_number) rightHtml += '
Номер' + escHtml(qData.own_number) + '
'; + if (qData.parent_number) rightHtml += '
Родительский' + escHtml(qData.parent_number) + '
'; + if (qData.doc_date) rightHtml += '
Дата' + escHtml(qData.doc_date) + '
'; + if (qData.counterparty) rightHtml += '
Контрагент' + escHtml(qData.counterparty) + '
'; + if (qData.classify_raw) { + var rawId = 'raw_' + docId.replace(/-/g,''); + rightHtml += '
Сырой ответ LLM'; + rightHtml += '
' + escHtml(qData.classify_raw) + '
'; + rightHtml += '
'; + } + } + } catch(e) { + leftHtml = 'Ошибка'; + } + } else { + leftHtml = '(ещё не загружен)'; + } + + var html = '
' + + '
Сырой текст
' + leftHtml + '
' + + '
Распарсено
' + rightHtml + '
' + + '
'; + document.getElementById('modalBody').innerHTML = html; +} + +window.closeModal = closeModal; +window.showText = showText; + +// ── Промпты (версионные) ───────────────────────────────────── +var promptRole = 'diff'; // текущая выбранная роль +var promptEditor = document.getElementById('promptEditor'); +var promptStatus = document.getElementById('promptStatus'); +var promptHistory = document.getElementById('promptHistory'); +var promptModalOverlay = document.getElementById('promptModalOverlay'); +var promptModalTitle = document.getElementById('promptModalTitle'); +var promptName = document.getElementById('promptName'); +var promptNotes = document.getElementById('promptNotes'); +// Три роли промптов: +// extract — извлечение строк из первого документа (базовый договор) +// diff — сравнение допсоглашений (ADD/UPDATE/DELETE) +// classify — классификация документа (тип, номер, контрагент, дата) +var activePromptId = {extract: '', diff: '', classify: ''}; + +function openPromptModal(role) { + promptRole = role; + var labels = {extract: 'Извлечение', diff: 'Сравнение', classify: 'Классификация'}; + promptModalTitle.textContent = 'Промпт: ' + (labels[role] || role); + document.getElementById('promptTabExtract').style.background = role === 'extract' ? 'var(--brand-primary)' : ''; + document.getElementById('promptTabExtract').style.color = role === 'extract' ? '#fff' : ''; + document.getElementById('promptTabDiff').style.background = role === 'diff' ? 'var(--brand-primary)' : ''; + document.getElementById('promptTabDiff').style.color = role === 'diff' ? '#fff' : ''; + document.getElementById('promptTabClassify').style.background = role === 'classify' ? 'var(--brand-primary)' : ''; + document.getElementById('promptTabClassify').style.color = role === 'classify' ? '#fff' : ''; + promptStatus.textContent = ''; + promptName.value = ''; + promptNotes.value = ''; + loadPrompt(role); + promptModalOverlay.classList.add('open'); +} + +function closePromptModal(e) { + if (e && e.target !== promptModalOverlay) return; + promptModalOverlay.classList.remove('open'); +} + +function loadPrompt(role) { + fetch(VM_API + '/api/prompts?role=' + role) + .then(function(r) { return r.json(); }) + .then(function(d) { + if (d.ok) { + promptEditor.value = d.body || ''; + activePromptId[role] = d.id || ''; + loadHistory(role); + } + }) + .catch(function(e) { console.error('loadPrompt:', e); }); +} + +function loadHistory(role) { + fetch(VM_API + '/api/prompts/list?role=' + role) + .then(function(r) { return r.json(); }) + .then(function(d) { + if (!d.ok || !d.versions || !d.versions.length) { + promptHistory.innerHTML = '
нет версий
'; + return; + } + promptHistory.innerHTML = d.versions.map(function(v) { + var isActive = v.is_active ? ' ' : ''; + var style = v.is_active ? 'font-weight:600;' : ''; + return '
' + + '' + isActive + escHtml(v.name) + ' ' + fmtDate(v.created_at) + '' + + (v.notes ? ' — ' + escHtml(v.notes) + '' : '') + + '' + + (!v.is_active ? '' : '') + + (!v.is_active ? '' : '') + + '
'; + }).join(''); + }) + .catch(function(e) { console.error('loadHistory:', e); }); +} + +function activatePrompt(id) { + fetch(VM_API + '/api/prompts/activate', { + method: 'POST', + headers: {'Content-Type': 'application/json'}, + body: JSON.stringify({id: id}) + }) + .then(function(r) { return r.json(); }) + .then(function(d) { + if (d.ok) { loadPrompt(promptRole); promptStatus.textContent = '✓ активирован'; } + else { promptStatus.textContent = '✕ ' + (d.error || 'ошибка'); } + }); +} + +function deletePrompt(id) { + if (!confirm('Удалить эту версию промпта?')) return; + fetch(VM_API + '/api/prompts/delete', { + method: 'POST', + headers: {'Content-Type': 'application/json'}, + body: JSON.stringify({id: id}) + }) + .then(function(r) { return r.json(); }) + .then(function(d) { + if (d.ok) { loadHistory(promptRole); promptStatus.textContent = '✓ удалено'; } + else { promptStatus.textContent = '✕ ' + (d.error || 'ошибка'); } + }); +} + +document.getElementById('promptSave').addEventListener('click', function() { + var body = promptEditor.value.trim(); + if (!body) { promptStatus.textContent = '✕ тело промпта пустое'; return; } + var name = promptName.value.trim() || ('v' + new Date().toISOString().replace(/[:.]/g,'-').substring(0,16)); + fetch(VM_API + '/api/prompts/save', { + method: 'POST', + headers: {'Content-Type': 'application/json'}, + body: JSON.stringify({ + role: promptRole, name: name, body: body, + notes: promptNotes.value.trim(), is_active: true + }) + }) + .then(function(r) { return r.json(); }) + .then(function(d) { + if (d.ok) { + promptStatus.textContent = '✓ сохранено (новая версия)'; + promptName.value = ''; promptNotes.value = ''; + loadPrompt(promptRole); + } else { + promptStatus.textContent = '✕ ' + (d.error || 'ошибка'); + } + }); +}); + +document.getElementById('promptTabExtract').addEventListener('click', function() { openPromptModal('extract'); }); +document.getElementById('promptTabDiff').addEventListener('click', function() { openPromptModal('diff'); }); +document.getElementById('promptTabClassify').addEventListener('click', function() { openPromptModal('classify'); }); +document.getElementById('promptBtnExtract').addEventListener('click', function() { openPromptModal('extract'); }); +document.getElementById('promptBtnDiff').addEventListener('click', function() { openPromptModal('diff'); }); +document.getElementById('promptBtnClassify').addEventListener('click', function() { openPromptModal('classify'); }); +lucide.createIcons(); diff --git a/site/static/app_utils.js b/site/static/app_utils.js new file mode 100644 index 0000000..d65a8a5 --- /dev/null +++ b/site/static/app_utils.js @@ -0,0 +1,108 @@ +/** + * app_utils.js — Общие утилиты (форматирование, стрелки, модалки). + * Вынесены из app.js для облегчения анализа и отладки. + * Загружается ДО app.js. + */ + +/** + * Форматирует размер файла в человекочитаемый вид. + * Используется в renderTable() для колонки "Размер". + */ +function formatSize(bytes) { + if (!bytes || bytes === 0) return '—'; + if (bytes < 1024) return bytes + ' B'; + if (bytes < 1048576) return (bytes / 1024).toFixed(1) + ' KB'; + return (bytes / 1048576).toFixed(1) + ' MB'; +} + +/** + * Форматирует timestamp в российскую дату + время. + * Используется в renderTable() для колонки "Изменён". + */ +function formatDate(ts) { + if (!ts) return '—'; + var d = new Date(ts); + return d.toLocaleDateString('ru-RU') + ' ' + d.toLocaleTimeString('ru-RU', {hour:'2-digit',minute:'2-digit'}); +} + +/** + * Удаляет файл из очереди по индексу. + * Вызывается по кнопке ✕ в таблице файлов. + * + * Фаза 0 (state + render): мутирует state.files, затем render(state). + * Если очередь опустела — сбрасывает state.contractId. + */ +function removeFile(i) { + state.files.splice(i, 1); + if (state.files.length === 0) state.contractId = null; + render(state); + // Синхронизировать БД с таблицей + if (typeof syncDB === 'function') syncDB(); + // Сбросить прогресс при изменении состава файлов + if (typeof resetStepper === 'function') resetStepper('stepUpload'); + // Разблокировать кнопку классификации при изменении состава файлов + if (typeof showClassifyBtn === 'function') showClassifyBtn(); +} +window.removeFile = removeFile; + +/** + * Переместить файл на одну позицию ВВЕРХ (сохранено для возможного возврата ручной сортировки). + * В текущей версии (v1.0.164+) не используется — порядок определяет авто-классификация. + * + * Фаза 0 (state + render): мутирует state.files, затем render(state). + */ +function moveUp(i) { + if (i <= 0) return; + var tmp = state.files[i]; state.files[i] = state.files[i-1]; state.files[i-1] = tmp; + render(state); +} + +/** + * Переместить файл на одну позицию ВНИЗ (сохранено для возможного возврата ручной сортировки). + * В текущей версии (v1.0.164+) не используется — порядок определяет авто-классификация. + * + * Фаза 0 (state + render): мутирует state.files, затем render(state). + */ +function moveDown(i) { + if (i >= state.files.length - 1) return; + var tmp = state.files[i]; state.files[i] = state.files[i+1]; state.files[i+1] = tmp; + render(state); +} +window.moveUp = moveUp; +window.moveDown = moveDown; + +/** + * Открыть модальное окно "О сервисе". + */ +function openAbout() { + document.getElementById('aboutModalOverlay').classList.add('open'); +} + +/** + * Закрыть модальное окно "О сервисе" (по клику на оверлей или кнопку). + */ +function closeAbout(e) { + if (e && e.target !== document.getElementById('aboutModalOverlay')) return; + document.getElementById('aboutModalOverlay').classList.remove('open'); +} +window.openAbout = openAbout; +window.closeAbout = closeAbout; + +/** + * Экранировать HTML (для безопасного рендеринга пользовательских данных). + * Используется в истории промптов. + */ +function escHtml(s) { + if (s == null) return ''; + var d = document.createElement('div'); + d.textContent = s; + return d.innerHTML; +} + +/** + * Форматировать дату для истории промптов (YYYY-MM-DD HH:MM). + */ +function fmtDate(d) { + if (!d) return ''; + return d.replace('T', ' ').substring(0, 16); +} diff --git a/site/static/compare.js b/site/static/compare.js new file mode 100644 index 0000000..c712478 --- /dev/null +++ b/site/static/compare.js @@ -0,0 +1,243 @@ +/** + * compare.js — Модуль сравнения через SSE (Фаза 3, decoupling-final-plan.md). + * + * УНИФИЦИРУЕТ дублирование (~160 строк) между runCompareForGroup и llmBtn. + * + * ПАТТЕРН: + * applyCompareEvent(sections, event) — чистая мутация: SSE-событие → state + * startCompareSSE(url, container, statusEl, callbacks) — жизненный цикл SSE + * renderCompareOpsTable(ops) — общий рендер таблицы операций + * + * ЗАВИСИМОСТИ: + * state.js → state (для _activeCompare) + */ + +/** + * applyCompareEvent(sections, event) — Чистая функция (Фаза 3). + * + * Применяет ОДНО SSE-событие к состоянию sections. + * НЕ трогает DOM — только мутирует объект sections. + * + * Вход: sections — объект { [supplement_id]: sectionState } + * event — { type, supplement_id, filename, ops_count, mode, ... } + * Выход: нет (мутирует sections) + * + * Типы событий: + * extract_start → создаёт запись в sections + * llm_done → обновляет статус, ops_count, mode, time_s + * applied → сохраняет ops и summary + * extract_error/apply_error → сохраняет ошибку + */ +function applyCompareEvent(sections, event) { + var d = event; + var suppId = d.supplement_id; + + if (d.type === 'extract_start') { + // Создать новую секцию: извлечение началось + sections[suppId] = { + filename: d.filename, + status: 'extracting', + ops_count: 0, + mode: '', + time_s: 0, + summary: null, + ops: null, + error: null + }; + } + else if (d.type === 'llm_done') { + // LLM закончил — сохранить метаданные + var sec = sections[suppId]; + if (sec) { + sec.status = 'llm_done'; + sec.ops_count = d.ops_count || 0; + sec.mode = d.mode || ''; + sec.time_s = d.time_s || 0; + } + } + else if (d.type === 'applied') { + // Результаты сравнения применены — сохранить ops и summary + var sec = sections[suppId]; + if (sec) { + sec.status = 'applied'; + sec.summary = d.summary || {}; + sec.ops = d.ops || []; + } + } + else if (d.type === 'extract_error' || d.type === 'apply_error') { + // Ошибка извлечения или применения + var sec = sections[suppId]; + if (sec) { + sec.status = 'error'; + sec.error = d.error || 'неизвестная ошибка'; + } else { + // Ошибка для ещё не созданной секции + sections[suppId] = { + filename: d.filename || '?', + status: 'error', + error: d.error || 'неизвестная ошибка', + ops_count: 0, mode: '', time_s: 0, summary: null, ops: null + }; + } + } +} + +/** + * renderCompareSectionHeader(sec) — Чистая функция: заголовок секции (Фаза 3). + * + * Вход: sec — { filename, status, ops_count, mode, time_s, error } + * Выход: HTML-строка для div.diff-section-header + */ +function renderCompareSectionHeader(sec) { + if (sec.status === 'extracting') { + return '⏳ ' + sec.filename; + } + if (sec.status === 'llm_done' || sec.status === 'applied') { + return '✓ ' + sec.filename + ' — ' + sec.ops_count + ' оп., ' + sec.mode + ' (' + sec.time_s + 'с)'; + } + if (sec.status === 'error') { + return '✗ ' + sec.filename + ': ' + sec.error; + } + return sec.filename; +} + +/** + * renderCompareOpsTable(ops) — Чистая функция: таблица операций (Фаза 3). + * + * Используется ОБОИМИ обработчиками (runCompareForGroup и llmBtn). + * Раньше этот код был продублирован ~30 строк × 2. + * + * Вход: ops — [{ action, new_row: { name, price, qty, sum, date_start } }] + * Выход: HTML-строка + */ +function renderCompareOpsTable(ops) { + var html = '
'; + ops.forEach(function(op) { + var action = op.action; + // Цвет строки зависит от действия: ADD=зелёный, DELETE=красный, UPDATE=жёлтый + var cls = action === 'ADD' ? 'diff-added' : action === 'DELETE' ? 'diff-deleted' : action === 'UPDATE' ? 'diff-changed' : ''; + var nr = op.new_row || {}; + html += ''; + }); + html += '
ДействиеУслугаЦенаКол-воСуммаДата
' + action + '' + (nr.name||'') + '' + (nr.price!=null?nr.price:'') + '' + (nr.qty!=null?nr.qty:'') + '' + (nr.sum!=null?nr.sum:'') + '' + (nr.date_start||'') + '
'; + return html; +} + +/** + * renderCompareSectionBody(sec) — Чистая функция: тело секции (Фаза 3). + * + * Рендерит summary (статистику) + таблицу ops. + * + * Вход: sec — { summary: { added, updated, deleted, unresolved }, ops } + * Выход: HTML-строка + */ +function renderCompareSectionBody(sec) { + if (!sec || sec.status !== 'applied' || !sec.ops || !sec.ops.length) return ''; + var s = sec.summary || {}; + var html = '
'; + html += '+' + (s.added||0) + ' ~' + (s.updated||0) + ' -' + (s.deleted||0); + if (s.unresolved) html += ' ?' + s.unresolved; + html += '
'; + html += renderCompareOpsTable(sec.ops); + return html; +} + +/** + * startCompareSSE(url, container, statusEl, callbacks) — Жизненный цикл SSE (Фаза 3). + * + * УНИФИЦИРОВАННЫЙ обработчик SSE для ОБОИХ режимов сравнения. + * Раньше ~80 строк дублировалось в runCompareForGroup и llmBtn. + * + * Параметры: + * url — URL для EventSource (/process-v2?contract_id=...) + * container — DOM-элемент, куда добавлять секции (cmpBody или compareBody) + * statusEl — DOM-элемент для отображения таймера/статуса + * callbacks — { onDone(d, sections), onError(d), onConnectionError() } + * Все колбэки опциональны. + * Таймер и EventSource УЖЕ остановлены к моменту вызова колбэков. + * + * Возвращает: { es, timer, sections } — для сохранения в state._activeCompare + */ +function startCompareSSE(url, container, statusEl, callbacks) { + callbacks = callbacks || {}; + + // Таймер: обновляет statusEl каждые 200мс + var start = Date.now(); + var timer = setInterval(function() { + statusEl.textContent = '⏳ ' + ((Date.now() - start) / 1000).toFixed(1) + 'с'; + }, 200); + + var es = new EventSource(url); + var sections = {}; // supplement_id → { filename, status, ops_count, mode, time_s, summary, ops, error, _el } + + es.onmessage = function(e) { + var d = JSON.parse(e.data); + + // ── extract_start: создать DOM-секцию ── + if (d.type === 'extract_start') { + applyCompareEvent(sections, d); + var sec = sections[d.supplement_id]; + var secEl = document.createElement('div'); + secEl.style.cssText = 'margin-top:8px;border:1px solid var(--brand-gray);border-radius:6px;overflow:hidden;'; + secEl.innerHTML = + '
' + + renderCompareSectionHeader(sec) + '
' + + ''; + container.appendChild(secEl); + sec._el = secEl; // ссылка на DOM для последующих обновлений + } + + // ── llm_done: обновить заголовок секции ── + else if (d.type === 'llm_done') { + applyCompareEvent(sections, d); + var sec = sections[d.supplement_id]; + if (sec && sec._el) { + sec._el.querySelector('.diff-section-header').innerHTML = renderCompareSectionHeader(sec); + sec._el.querySelector('.diff-section-header').style.color = 'var(--green)'; + } + } + + // ── applied: заполнить тело секции таблицей ops ── + else if (d.type === 'applied') { + applyCompareEvent(sections, d); + var sec = sections[d.supplement_id]; + if (sec && sec._el && sec.ops && sec.ops.length > 0) { + var body = sec._el.querySelector('.diff-section-body'); + body.innerHTML = renderCompareSectionBody(sec); + body.style.display = 'block'; + } + } + + // ── extract_error / apply_error: показать ошибку ── + else if (d.type === 'extract_error' || d.type === 'apply_error') { + applyCompareEvent(sections, d); + container.innerHTML += '
✗ ' + d.filename + ': ' + d.error + '
'; + } + + // ── done: завершение ── + else if (d.type === 'done') { + clearInterval(timer); + es.close(); + statusEl.textContent = '✓ ' + d.total_time_s + 'с'; + statusEl.style.color = 'var(--green)'; + if (callbacks.onDone) callbacks.onDone(d, sections); + } + + // ── error: фатальная ошибка ── + else if (d.type === 'error') { + clearInterval(timer); + es.close(); + if (callbacks.onError) callbacks.onError(d); + } + }; + + // ── Сетевая ошибка ── + es.onerror = function() { + clearInterval(timer); + es.close(); + if (callbacks.onConnectionError) callbacks.onConnectionError(); + }; + + return { es: es, timer: timer, sections: sections }; +} diff --git a/site/static/files.js b/site/static/files.js new file mode 100644 index 0000000..a025050 --- /dev/null +++ b/site/static/files.js @@ -0,0 +1,519 @@ +/** + * files.js — Модуль работы с файлами (Фаза 1, decoupling-final-plan.md). + * + * ВЫНЕСЕНО из app.js: + * - statusToHTML(st) — чистая: структура → HTML + * - renderFiles(state) — рендер таблицы файлов + * - syncDB() — синхронизация БД с fileQueue + * - toggleClassifyDetail(i) — раскрыть результат классификации + * - uploadFile(file, cb) — XHR-загрузка одного файла (.doc/.docx/.pdf) + * - refreshSupps() — обновить supplement_id для файлов + * + * ЗАВИСИМОСТИ (глобальные, загружаются раньше): + * state.js → state (центральное состояние) + * app_utils.js → escHtml, formatSize, formatDate + * app.js → render(), stepDone, stepActive, resetStepper, showClassifyBtn + * + * ЗАГРУЖАЕТСЯ: после app_utils.js, перед app.js + */ + +/** + * statusToHTML(status) — Чистая функция: структура → HTML (Фаза 1). + * + * Вход: { kind, pct?, text?, count?, elapsed? } — ни одного HTML-тега. + * kind = 'uploading' | 'uploaded' | 'unzipping' | 'parsing' | 'parsed' | 'error' | '' + * Выход: безопасная HTML-строка (статусы не содержат пользовательских данных). + * + * ПАТТЕРН (decoupling-final-plan.md): отделяем данные от представления. + * state хранит чистые данные, statusToHTML — чистая функция рендеринга. + */ +function statusToHTML(st) { + if (!st || !st.kind) return ''; + switch (st.kind) { + case 'uploading': return '↑ ' + (st.pct || 0) + '%'; + case 'uploaded': return ''; + case 'unzipping': return '⏳ распаковка...'; + case 'parsing': return '⏳ парсинг...'; + case 'parsed': return '✓ ' + (st.count || 0) + ' эл.' + (st.elapsed ? ' (' + st.elapsed + 'с)' : '') + ''; + case 'error': return '✗ ' + (st.text || 'Неизвестная ошибка') + ''; + default: return ''; + } +} + +/** + * renderFiles(state) — Рендер таблицы файлов (бывший renderTable, Фаза 1). + * + * Читает state.files, генерирует HTML для #fileTable. + * Использует statusToHTML() для рендеринга статусов (чистые данные → HTML). + * Вызывает lucide.createIcons() для иконок. + * + * Вход: state (весь объект состояния, но читает только state.files). + * Выход: мутирует DOM (fileTable.innerHTML). + */ +function renderFiles(state) { + if (state.files.length === 0) { + fileTable.innerHTML = 'Нет файлов — выберите .docx / .pdf'; + lucide.createIcons(); + return; + } + + // Сгруппировать файлы по zip_source + var groups = []; // [{zip: "name.zip"|null, files: [f, ...]}] + var seen = {}; + for (var i = 0; i < state.files.length; i++) { + var f = state.files[i]; + var zip = f.zip_source || null; + var key = zip || '__naked__'; + if (!seen[key]) { + seen[key] = { zip: zip, files: [] }; + groups.push(seen[key]); + } + // Сохраняем оригинальный индекс для id строк + f._idx = i; + seen[key].files.push(f); + } + + // Рендер: заголовок ZIP → файлы с отступом + var rows = []; + for (var gi = 0; gi < groups.length; gi++) { + var g = groups[gi]; + + if (g.zip) { + // Заголовок-секция ZIP + rows.push( + '' + + '' + + '📦 ' + escHtml(g.zip) + ' — ' + g.files.length + ' файл.' + + (g.files.length === 1 ? '' : 'ов') + + '' + ); + } + + // Файлы внутри группы (с отступом если из ZIP) + for (var fi = 0; fi < g.files.length; fi++) { + var f = g.files[fi]; + var i = f._idx; + var indent = g.zip ? 'padding-left:24px;' : ''; + + rows.push( + '' + + '' + + (f.doc_id ? '' : '') + + escHtml(f.name) + '' + + '' + formatDate(f.lastModified) + '' + + '' + formatSize(f.size) + '' + + '' + statusToHTML(f.status) + '' + + '' + + '' + + '' + + 'Загрузка...' + ); + } + } + + fileTable.innerHTML = rows.join(''); + lucide.createIcons(); +} + +/** + * syncDB() — Синхронизация БД с текущим состоянием файлов. + * + * Отправляет на бэкенд список doc_id, которые должны остаться в БД. + * Всё, чего нет в state.files, будет удалено из БД (cascade). + * Вызывается после каждого изменения состава файлов (removeFile, upload). + */ +async function syncDB() { + var keepIds = state.files.map(function(f) { return f.doc_id; }).filter(Boolean); + try { + await fetch(VM_API + '/api/sync', { + method: 'POST', + headers: {'Content-Type': 'application/json'}, + body: JSON.stringify({keep_ids: keepIds}) + }); + } catch(e) { /* сервер недоступен — не критично */ } +} + +/** + * toggleClassifyDetail(i) — Раскрыть/скрыть результат классификации под строкой файла. + * + * Вызывается по клику на имя файла в таблице. + * Загружает данные с бэкенда (/api/documents/:id) и показывает: + * - Тип, номер, родительский номер, дату, контрагента + * - Текст отправленный в LLM (📤) + * - Сырой ответ LLM (📥) + * - Распарсенные элементы JSON (📄) + */ +window.toggleClassifyDetail = async function(i) { + var detailRow = document.getElementById('detail_' + i); + var expandIcon = document.getElementById('expand_' + i); + if (!detailRow) return; + + if (detailRow.style.display !== 'none') { + detailRow.style.display = 'none'; + if (expandIcon) expandIcon.textContent = '▸'; + return; + } + + var f = state.files[i]; + if (!f || !f.doc_id) return; + + detailRow.style.display = ''; + if (expandIcon) expandIcon.textContent = '▾'; + + try { + var resp = await fetch(VM_API + '/api/documents/' + f.doc_id); + var qData = await resp.json(); + if (!qData.ok) { detailRow.cells[0].innerHTML = 'Ошибка загрузки'; return; } + + var html = ''; + if (qData.classify_status === 'classified') { + html += '
🏷️ Результат классификации
'; + html += '
'; + if (qData.doc_type) html += 'Тип: ' + escHtml(qData.doc_type) + ''; + if (qData.own_number) html += 'Номер: ' + escHtml(qData.own_number) + ''; + if (qData.parent_number) html += 'Родительский: ' + escHtml(qData.parent_number) + ''; + if (qData.doc_date) html += 'Дата: ' + escHtml(qData.doc_date) + ''; + if (qData.counterparty) html += 'Контрагент: ' + escHtml(qData.counterparty) + ''; + html += '
'; + if (qData.classify_input) { + html += '
📤 Текст отправленный в LLM'; + html += '
' + escHtml(qData.classify_input) + '
'; + html += '
'; + } + if (qData.classify_raw) { + html += '
📥 Сырой ответ LLM'; + html += '
' + escHtml(qData.classify_raw) + '
'; + html += '
'; + } + if (qData.elements_json) { + var ej = qData.elements_json; + if (typeof ej === 'object' && ej.Value) ej = ej.Value; + var ejStr = typeof ej === 'string' ? ej : JSON.stringify(ej, null, 2); + html += '
📄 Распарсенные элементы (JSON)'; + html += '
' + escHtml(ejStr.substring(0, 5000)) + (ejStr.length > 5000 ? '\n... (обрезано)' : '') + '
'; + html += '
'; + } + } else if (qData.classify_status === 'failed') { + html += '✗ Ошибка классификации: ' + escHtml(qData.error_message || '?') + ''; + } else { + html += 'Ещё не классифицирован'; + } + detailRow.cells[0].innerHTML = html; + } catch(e) { + detailRow.cells[0].innerHTML = 'Ошибка: ' + escHtml(e.message) + ''; + } +}; + +/** + * uploadFile(file, onProgress) — XHR-загрузка одного файла на бэкенд. + * + * Особенности: + * - .doc (не .docx!) конвертируется через CONVERT_URL перед загрузкой + * - onProgress(pct) — callback с процентом загрузки (0-100) + * - Возвращает Promise<ответ API> с полями doc_id, contract_id, parsed + * - Таймаут 180с (большие PDF) + */ +function uploadFile(file, onProgress, zipSource) { + return new Promise(function(resolve, reject) { + // .doc → конвертация в docx (старый формат Word) + var isDoc = file.name.toLowerCase().endsWith('.doc') && !file.name.toLowerCase().endsWith('.docx'); + var uploadFile = file; + var uploadName = file.name; + + function doUpload() { + var xhr = new XMLHttpRequest(); + var fd = new FormData(); + fd.append('files', uploadFile, uploadName); + if (state.contractId) fd.append('contract_id', state.contractId); + fd.append('batch_id', state.batchId); + if (zipSource) fd.append('zip_source', zipSource); + xhr.open('POST', UPLOAD_URL); + xhr.upload.onprogress = function(e) { + if (e.lengthComputable && onProgress) onProgress(Math.round(e.loaded / e.total * 100)); + }; + xhr.onload = function() { + try { + var r = JSON.parse(xhr.responseText); + if (r.ok) resolve(r); + else reject(new Error(r.error || 'Неизвестная ошибка')); + } catch(e) { reject(new Error('Некорректный ответ')); } + }; + xhr.onerror = function() { reject(new Error('Сеть')); }; + xhr.ontimeout = function() { reject(new Error('Таймаут')); }; + xhr.timeout = 180000; + xhr.send(fd); + } + + if (isDoc) { + var xhr = new XMLHttpRequest(); + xhr.open('POST', CONVERT_URL); + xhr.responseType = 'blob'; + xhr.onload = function() { + if (xhr.status === 200 && xhr.response.size > 100) { + uploadFile = xhr.response; + uploadName = file.name.replace(/\.doc$/i, '.docx'); + doUpload(); + } else { + reject(new Error('Конвертация .doc')); + } + }; + xhr.onerror = function() { reject(new Error('Конвертер')); }; + xhr.send(file); + } else { + doUpload(); + } + }); +} + +/** + * refreshSupps() — Обновить supplement_id/type для файлов в state.files. + * + * Запрашивает с бэкенда список supplement-записей для текущего contractId, + * сопоставляет их с файлами по doc_id → document_id и проставляет supp_id, supp_type. + * Вызывается после каждого upload и после apply-groups. + */ +async function refreshSupps() { + if (!state.contractId) { console.log('refreshSupps: no state.contractId'); return; } + try { + var resp = await fetch(VM_API + '/api/supplements?contract_id=' + state.contractId); + var data = await resp.json(); + console.log('refreshSupps: data=', data); + if (data.ok && data.supplements) { + data.supplements.forEach(function(supp) { + console.log('refreshSupps: supp=', supp); + for (var i = 0; i < state.files.length; i++) { + if (state.files[i].doc_id === supp.document_id) { + state.files[i].supp_id = supp.id; + state.files[i].supp_type = supp.type; + } + } + }); + render(state); + } + } catch(e) { console.log('refreshSupps error:', e); } +} + +/** + * reconcileSelection(files, newFiles) — Чистая функция (Фаза 1). + * + * Согласование состава: оставляет в массиве files только те, + * имена которых есть в newFiles. Остальные удаляются. + * + * Вход: files — текущий state.files (массив) + * newFiles — FileList от (массив File-объектов) + * Выход: новый массив (НЕ мутирует входной) + * + * Вызывается из onFilesSelected() ПЕРЕД обработкой каждого файла. + */ +function reconcileSelection(files, newFiles) { + var newNames = new Set(newFiles.map(function(f) { return f.name; })); + return files.filter(function(f) { + return newNames.has(f.name); + }); +} + +/** + * applyParseResult(entry, parsed, elapsed) — Чистая функция (Фаза 1). + * + * Применяет результат парсинга к файлу. Унифицирует две ветки (ZIP и обычную), + * которые раньше дублировали этот код. + * + * Вход: entry — элемент state.files (мутируется) + * parsed — { status, element_count, error } от бэкенда + * elapsed — время парсинга в секундах (только для обычных файлов) + * Выход: нет (мутирует entry на месте) + */ +function applyParseResult(entry, parsed, elapsed) { + if (parsed && parsed.status === 'parsed') { + entry.parsed = true; + entry.parseInfo = parsed; + entry.status = { kind: 'parsed', count: parsed.element_count }; + if (elapsed) entry.status.elapsed = elapsed; + } else if (parsed && parsed.status === 'error') { + entry.parseInfo = parsed; + entry.status = { kind: 'error', text: parsed.error || 'ошибка парсинга' }; + } else { + entry.status = { kind: 'error', text: 'Неизвестная ошибка' }; + } +} + +/** + * addZipFile(file) — Async-action: обработка ZIP-файла (Фаза 1, упрощена). + * + * 1. Отправляет ZIP на бэкенд (только распаковка, без БД/парсинга) + * 2. Для каждого файла из архива: base64 → Blob → File → addRegularFile() + * + * addRegularFile делает ВСЁ: upload, parse, дубликаты, статусы, render. + * Никакого дублирования логики — единый путь для обычных и ZIP-файлов. + */ +async function addZipFile(file) { + var zipEntry = { name: file.name, lastModified: file.lastModified, size: file.size, status: { kind: 'unzipping' } }; + state.files.push(zipEntry); + render(state); + + try { + // Шаг 1: распаковать ZIP на бэкенде + var zipResp = await new Promise(function(resolve, reject) { + var xhr = new XMLHttpRequest(); + xhr.open('POST', UNZIP_URL); + xhr.responseType = 'json'; + xhr.onload = function() { resolve(xhr.response); }; + xhr.onerror = function() { reject(new Error('Сеть')); }; + xhr.ontimeout = function() { reject(new Error('Таймаут')); }; + xhr.timeout = 60000; + var fd = new FormData(); + fd.append('files', file); + xhr.send(fd); + }); + if (!zipResp.ok || !zipResp.files) throw new Error('unzip failed'); + + // Шаг 2: обработать каждый файл из архива + // Временная строка ZIP остаётся в таблице — обновляем её статус + var total = zipResp.files.length; + for (var zi = 0; zi < total; zi++) { + var zf = zipResp.files[zi]; + zipEntry.status = { kind: 'unzipping' }; // обновляем статус + render(state); + + if (zf.error) continue; + + // base64 → File → addRegularFile (единый путь) + var byteStr = atob(zf.data_b64); + var bytes = new Uint8Array(byteStr.length); + for (var b = 0; b < byteStr.length; b++) bytes[b] = byteStr.charCodeAt(b); + var mime = {docx:'application/vnd.openxmlformats-officedocument.wordprocessingml.document',doc:'application/msword',pdf:'application/pdf'}[zf.ext] || 'application/octet-stream'; + var extractedFile = new File([bytes], zf.filename, { type: mime, lastModified: Date.now() }); + + await addRegularFile(extractedFile, file.name); + } + + // Шаг 3: убрать временную строку ZIP (только после успешной обработки всех файлов) + var zipPos = state.files.indexOf(zipEntry); + if (zipPos >= 0) state.files.splice(zipPos, 1); + render(state); + + } catch(ze) { + // Ошибка — показать на строке ZIP (zipEntry всё ещё в state.files) + zipEntry.status = { kind: 'error', text: 'ZIP: ' + ze.message }; + render(state); + } +} + +/** + * addRegularFile(file) — Async-action: обработка обычного файла (Фаза 1). + * + * 1. Добавляет/заменяет файл в state.files + * 2. Загружает через XHR (uploadFile) с индикатором прогресса + * 3. После загрузки: проставляет doc_id, contractId + * 4. applyParseResult — применяет результат парсинга + * + * Мутирует state.files, вызывает render(state) после каждого изменения. + */ +async function addRegularFile(file, zipSource) { + // Создать запись с начальным статусом + var entry = { name: file.name, lastModified: file.lastModified, size: file.size, file: file, status: { kind: 'uploading', pct: 0 }, zip_source: zipSource || null }; + + // ДЕДУПЛИКАЦИЯ: ключ = (zip_source, name), чтобы одноимённые файлы из разных ZIP не затирались + var dupKey = (zipSource || '') + '/' + file.name; + var existingIdx = -1; + for (var j = 0; j < state.files.length; j++) { + var ejKey = (state.files[j].zip_source || '') + '/' + state.files[j].name; + if (ejKey === dupKey) { existingIdx = j; break; } + } + var rowIdx; + if (existingIdx >= 0) { + // Файл с таким именем уже есть — спросить пользователя + if (!confirm('Файл «' + file.name + '» уже есть в списке.\n\nOK — перезаписать\nОтмена — пропустить')) { + return; // пользователь выбрал «пропустить» + } + state.files[existingIdx] = entry; + rowIdx = existingIdx; + } else { + state.files.push(entry); + rowIdx = state.files.length - 1; + } + render(state); + + try { + // XHR-загрузка с прогрессом + var resp = await uploadFile(file, function(pct) { + state.files[rowIdx].status = { kind: 'uploading', pct: pct }; + render(state); + }, zipSource); + // Бэкенд возвращает doc_id и contract_id (нижний регистр — Python keys) + if (resp && resp.contract_id) state.contractId = resp.contract_id; + state.files[rowIdx].doc_id = resp.doc_id; + state.files[rowIdx].status = { kind: 'uploaded' }; + state.files[rowIdx].uploaded = true; + + // Авто-парсинг: бэкенд парсит всё (PDF + DOCX + DOC) + var t0 = Date.now(); + var pr = resp.parsed; + var elapsed = pr && pr.status === 'parsed' ? ((Date.now() - t0) / 1000).toFixed(1) : null; + applyParseResult(state.files[rowIdx], pr, elapsed); + } catch(err) { + state.files[rowIdx].status = { kind: 'error', text: err.message }; + } + render(state); +} + +/** + * finalizeUpload() — Завершение цикла загрузки (Фаза 1). + * + * Вызывается ПОСЛЕ обработки всех файлов в onFilesSelected(). + * 1. Обновляет supplement_id/type через refreshSupps() + * 2. Отмечает шаг «Загрузка» как готовый + * 3. Активирует шаг «Классификация» + * 4. Синхронизирует БД + * 5. Показывает кнопки (LLM, классификация) + */ +async function finalizeUpload() { + await refreshSupps(); + stepDone('stepUpload'); + stepActive('stepClassify'); + syncDB(); + + fileInput.value = ''; + fileInput.disabled = false; + + var llmBtn = document.getElementById('llmBtn'); + llmBtn.style.display = 'flex'; + llmBtn.disabled = false; + showClassifyBtn(); + lucide.createIcons(); +} + +/** + * onFilesSelected(newFiles) — Оркестратор загрузки (Фаза 1). + * + * ПАТТЕРН: + * 1. Для каждого файла: addZipFile (ZIP) или addRegularFile (обычный) + * 2. finalizeUpload — завершить цикл + * + * НЕ удаляет существующие файлы — только добавляет новые. + * Дубликаты обрабатываются через confirm() в addRegularFile. + * Удаление — только вручную (кнопка ✕). + * + * Вызывается из fileInput.addEventListener('change', ...). + */ +async function onFilesSelected(newFiles) { + if (newFiles.length === 0) return; + + fileInput.disabled = true; + + // Сбросить прогресс пайплайна при добавлении новых файлов + resetStepper('stepUpload'); + + // Обработать каждый файл + for (var i = 0; i < newFiles.length; i++) { + var f = newFiles[i]; + if (f.name.toLowerCase().endsWith('.zip')) { + await addZipFile(f); + } else { + await addRegularFile(f); + } + } + + // Завершить цикл + await finalizeUpload(); +} diff --git a/site/static/groups.js b/site/static/groups.js new file mode 100644 index 0000000..bc164ab --- /dev/null +++ b/site/static/groups.js @@ -0,0 +1,223 @@ +/** + * groups.js — Модуль групп и карточек договоров (Фаза 2, decoupling-final-plan.md). + * + * ВЫНЕСЕНО из app.js: + * - loadGroupsAction() — fetch групп, сохранить в state.groups, renderGroups + * - renderGroups(state) — пересобрать ВСЕ карточки групп в diffBody + * - renderGroupCard(g, gi) — чистая HTML-функция: карточка необработанной группы + * - renderGroupCardDone(g, gi)— чистая HTML-функция: карточка обработанной группы + * + * ПАТТЕРН (decoupling-final-plan.md): + * renderGroups пересобирает ВСЕ карточки целиком из state.groups. + * Готовность группы: group.compare.status === 'done' (вместо markGroupDone). + * Никакого window._groupsData — всё в state.groups. + * + * ЗАВИСИМОСТИ: + * state.js → state (центральное состояние) + * app_utils.js → escHtml + */ + +/** + * loadGroupsAction() — Загрузить группы с бэкенда (Фаза 2). + * + * 1. fetch /api/groups?batch=... + * 2. Сохранить в state.groups (бывший window._groupsData) + * 3. Вызвать renderGroups(state) — пересобрать карточки + * + * Вызывается из runClassify() после успешной классификации. + */ +async function loadGroupsAction() { + var resp = await fetch(VM_API + '/api/groups?batch=' + state.batchId); + var data = await resp.json(); + if (!data.ok || !data.groups) return; + + // Сохраняем в центральное состояние (вместо window._groupsData) + state.groups = data.groups; + + // Пересобрать ВСЕ карточки + renderGroups(state); + + stepDone('stepGroups'); +} + +/** + * renderGroups(state) — Пересобрать ВСЕ карточки групп (Фаза 2). + * + * ПЕРЕСОБИРАЕТ весь diffBody.innerHTML заново из state.groups. + * Никакого инкрементального обновления — всегда полная перестройка. + * + * Для каждой группы: + * - Необработанная (group.compare.status !== 'done') → renderGroupCard() + * - Обработанная (group.compare.status === 'done') → renderGroupCardDone() + * - Нераспознанная (contract_number === '__unresolved__') → отдельный рендер + */ +function renderGroups(state) { + var groups = state.groups; + if (!groups) return; + + var diffBody = document.getElementById('diffBody'); + var diffCard = document.getElementById('diffCard'); + diffCard.style.display = 'block'; + + var realGroups = groups.filter(function(g) { return g.contract_number !== '__unresolved__'; }); + var unresolvedGroup = groups.find(function(g) { return g.contract_number === '__unresolved__'; }); + + // Заголовок: количество групп + нераспознанные + var html = '
📋 Найдено групп: ' + realGroups.length + + (unresolvedGroup ? ' | ⚠ ' + unresolvedGroup.documents.length + ' файлов не распознано' : '') + + '
'; + + // Нераспознанная группа — всегда первая + if (unresolvedGroup) { + html += renderUnresolvedCard(unresolvedGroup); + } + + // Карточки обычных групп + groups.forEach(function(g, gi) { + if (g.contract_number === '__unresolved__') return; + if (g.compare && g.compare.status === 'done') { + html += renderGroupCardDone(g, gi); + } else { + html += renderGroupCard(g, gi); + } + }); + + diffBody.innerHTML = html; + + // Один обработчик на все кнопки сравнения (event delegation не используется — + // кнопки пересоздаются при каждом renderGroups) + diffBody.querySelectorAll('.cmp-btn').forEach(function(btn) { + btn.addEventListener('click', function() { + var gi = parseInt(this.getAttribute('data-gi')); + runCompareForGroup(gi, this); + }); + }); + + // Обработчики сворачивания для готовых карточек + diffBody.querySelectorAll('.cmp-header').forEach(function(header) { + var gi = parseInt(header.getAttribute('data-gi')); + if (isNaN(gi)) return; + header.addEventListener('click', function() { + var body = document.getElementById('cmpBody_' + gi); + var arrow = document.getElementById('cmpArrow_' + gi); + if (body) { + if (body.style.display === 'none') { + body.style.display = 'block'; + if (arrow) arrow.textContent = '▾'; + } else { + body.style.display = 'none'; + if (arrow) arrow.textContent = '▸'; + } + } + }); + }); + + lucide.createIcons(); +} + +/** + * renderUnresolvedCard(group) — Чистая HTML-функция: карточка нераспознанных файлов. + * + * Вход: group — { contract_number: '__unresolved__', documents: [...] } + * Выход: HTML-строка + */ +function renderUnresolvedCard(group) { + var docsHtml = group.documents.map(function(d) { + // Определить причину, почему файл не попал ни в одну группу + var reason = ''; + if (d.parent_number) { + reason = ' — нет базового договора №' + escHtml(d.parent_number); + } else if (d.classify_status === 'failed') { + reason = ' — ошибка классификации: ' + escHtml(d.error_message || '?'); + } else if (!d.doc_type || d.doc_type === 'other') { + reason = ' — тип не определён'; + } else { + reason = ' — нет matching-договора'; + } + return '
' + + '[' + escHtml(d.doc_type || '?') + '] ' + + escHtml(d.filename) + + (d.doc_date ? ' ' + escHtml(d.doc_date) + '' : '') + + '' + reason + '' + + '
'; + }).join(''); + + return '
' + + '
❓ Не распознано (' + group.documents.length + ' файлов)
' + + '
' + docsHtml + '
' + + '
Загрузите недостающие базовые договоры для этих номеров.
' + + '
'; +} + +/** + * renderGroupCard(group, gi) — Чистая HTML-функция: необработанная группа (Фаза 2). + * + * Показывает: номер договора, контрагента, список документов, кнопку «Сравнить». + * Если группа в процессе сравнения (group.compare.status === 'running') — кнопка disabled. + * + * Вход: group — элемент state.groups + * gi — индекс группы (для data-gi атрибута кнопки) + * Выход: HTML-строка + */ +function renderGroupCard(group, gi) { + var isRunning = group.compare && group.compare.status === 'running'; + + var html = '
' + + '
' + + '📄 Договор №' + escHtml(group.contract_number || '?') + ' — ' + escHtml(group.counterparty || 'контрагент не определён') + + '
' + + '
' + + group.documents.map(function(d) { + var typeLabel = {contract: 'договор', supplement: 'допсоглашение', specification: 'спецификация'}[d.doc_type] || d.doc_type; + return '
' + + '' + escHtml(typeLabel || '?') + ' ' + + escHtml(d.filename) + + (d.zip_source ? ' [' + escHtml(d.zip_source) + ']' : '') + + (d.doc_date ? ' ' + escHtml(d.doc_date) + '' : '') + + '
'; + }).join('') + '
' + + '' + + '
'; + + return html; +} + +/** + * renderGroupCardDone(group, gi) — Чистая HTML-функция: обработанная группа (Фаза 2). + * + * Показывает: ✓ Готово (время), список документов (кликабельные — раскрывают секции), + * тело сравнения (cmpBody) — сворачивается по клику на заголовок. + * + * Вход: group — элемент state.groups (group.compare.status === 'done') + * gi — индекс группы + * Выход: HTML-строка + * + * ЗАМЕНЯЕТ удалённую markGroupDone(). + * Готовность определяется по group.compare.status, а не по наличию кнопки. + */ +function renderGroupCardDone(group, gi) { + var time = group.compare.totalTime || '?с'; + var bodyHTML = group.compare.bodyHTML || ''; + + var html = '
' + + '
' + + '' + + '📄 Договор №' + escHtml(group.contract_number || '?') + ' — ' + escHtml(group.counterparty || 'контрагент не определён') + + ' ✓ Готово (' + time + ')' + + '
' + + '
' + + group.documents.map(function(d, di) { + var typeLabel = {contract: 'договор', supplement: 'допсоглашение', specification: 'спецификация'}[d.doc_type] || d.doc_type; + return '
' + + '' + escHtml(typeLabel || '?') + ' ' + + escHtml(d.filename) + + (d.zip_source ? ' [' + escHtml(d.zip_source) + ']' : '') + + (d.doc_date ? ' ' + escHtml(d.doc_date) + '' : '') + + '
'; + }).join('') + '
' + + '
' + bodyHTML + '
' + + '
'; + + return html; +} diff --git a/site/static/state.js b/site/static/state.js new file mode 100644 index 0000000..e8c9509 --- /dev/null +++ b/site/static/state.js @@ -0,0 +1,73 @@ +/** + * state.js — Центральное состояние приложения. + * + * ПАТТЕРН (из decoupling-final-plan.md, Фаза 0): + * action → мутация state → render(state) + * + * ПРАВИЛА: + * 1. ВСЕ состояния приложения — только здесь, в объекте state. + * 2. DOM — проекция state, никто не читает данные из DOM. + * Если что-то нужно узнать — смотри в state, а не в element.innerHTML. + * 3. Мутировал любое поле state → обязан вызвать render(state). + * Никаких прямых манипуляций DOM в обход render(). + * 4. В любой момент console.log(state) показывает ВСЁ состояние приложения. + * + * СТРУКТУРА (Фаза 0 — минимальная, будет расширяться в Фазах 1-4): + * + * state.files — бывший fileQueue, массив загруженных файлов. + * Каждый элемент: { name, size, lastModified, file, doc_id, + * uploaded, parsed, parseInfo, supp_id, supp_type, status, zip_source } + * + * state.contractId — бывший contractId, ID контракта в БД. + * Приходит с бэкенда после первого успешного upload. + * null пока не загружен ни один файл. + * + * state.batchId — бывший batchId, уникальный ID сессии (UUID). + * Используется для привязки всех файлов сессии при классификации. + * Генерируется один раз при загрузке страницы. + * + * state.groups — бывший window._groupsData. + * Массив групп после классификации. Каждая группа: + * { contract_number, counterparty, documents[], unresolved? } + * null если классификация ещё не запускалась. + * + * state._activeCompare — бывшие _activeCompareES и _activeCompareTimer. + * Управляет ОДНИМ активным SSE-сравнением. + * Правило: только одно сравнение одновременно — все кнопки блокируются. + * { es: EventSource|null, timer: intervalId|null } + * + * state.ui — UI-состояние, не связанное с данными. + * ui.steps: { upload, classify, groups, compare } + * Каждый шаг: '○' (не начат) | '⏳' (в процессе) | '✓' (завершён) + * В Фазе 4 будет renderStepper(state). + */ +var state = { + // ── Файлы (бывший fileQueue) ────────────────────────────── + files: [], + + // ── Контракт (бывший contractId) ────────────────────────── + contractId: null, + + // ── Сессия (бывший batchId) ─────────────────────────────── + // crypto.randomUUID() — браузерный API, поддержка 92%+ (Chrome 92+, FF 95+, Safari 15.4+) + batchId: crypto.randomUUID(), + + // ── Группы (бывший window._groupsData) ──────────────────── + groups: null, + + // ── Активное сравнение ──────────────────────────────────── + _activeCompare: { + es: null, // EventSource (SSE-соединение с /process-v2) + timer: null // setInterval ID для индикатора времени + }, + + // ── UI-состояние ────────────────────────────────────────── + ui: { + steps: { + upload: '○', + classify: '○', + groups: '○', + compare: '○' + } + } +}; diff --git a/site/templates/index.html b/site/templates/index.html new file mode 100644 index 0000000..97efdcc --- /dev/null +++ b/site/templates/index.html @@ -0,0 +1,226 @@ + + + + + + Сверка договоров — LLM + + + + + +
+ Nubes + Сверка договоров — LLM AI-driven Event Sourcing v1.0.179-flask +
+ ○ Загрузка + ○ Классификация + ○ Группировка + ○ Сравнение +
+ ℹ️ О сервисе +
+ +
+
+
+ + Загрузка договоров/приложений/спецификаций +
+
+ +
Порядок определяется автоматически при классификации
+
⚠ При совпадении имён — запрос на перезапись (OK / Отмена). Файлы из ZIP-архивов загружаются через тот же поток.
+ +
+ + + + + +
ИмяИзменёнРазмерПарсингТекст
+
+ + + +
+ ⚙ Промпты LLM +
+ + + +
+
+ для базового договора + для допсоглашений + автоопределение +
+
+
+
+ + + + + + +
+ + + + + + + + + + + + + + +