// ⛔ НЕ МЕНЯТЬ БЕЗ РАЗРЕШЕНИЯ НАЕЛЯ ⛔ // Contracts App — весь JS на VM (contracts.kube5s.ru) // Lucee: только домен + index.cfm-скелет var VM_API = 'https://check.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://check.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();