// ⛔ НЕ МЕНЯТЬ БЕЗ РАЗРЕШЕНИЯ НАЕЛЯ ⛔ // Contracts App v2.0 — backend на Flask; ВМ используется только как upload buffer. var VM_API = ''; // ВМ-буфер загрузки (паттерн drhider): браузер кладёт файл сюда (WebDAV, мимо шлюза), // бэк сам тянет его по /api/upload_refs. Origin должен быть в CORS на nginx ВМ. var VM_UPLOAD_URL = 'https://contracts-upload.sinefo.ru/contracts-upload/'; // SITE_URL удалён (Фаза 4) — не использовался // 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 сессии для классификации) // Прогрев приложения при загрузке страницы. // ⚠️ Без прогрева первый POST может упасть с ERR_CONNECTION_RESET (MTU/Geneve) (async function init() { try { await fetch('/health'); } catch(e) { /* ignore */ } })(); /** * Обновить отображение picker и текущий шаг pipeline после изменения state. */ function render(state) { renderFiles(state); renderStepper(state); } if (!window.FilePicker) throw new Error('Не загружен бандл FilePicker'); filePicker = window.FilePicker.initFilePicker({ mount: '#filePicker', allowedExt: ['.pdf', '.doc', '.docx'], limits: FILE_PICKER_LIMITS, onChange: onPickerFilesChange, onError: function(error, file) { filePicker.setMessage('Ошибка чтения ' + file.name + ': ' + error.message); }, onDetails: function(id) { var index = state.files.findIndex(function(file) { return file.pickerId === id; }); if (index >= 0) toggleClassifyDetail(index); }, onText: function(id) { var index = state.files.findIndex(function(file) { return file.pickerId === id; }); if (index >= 0) showText(index); } }); // Picker selection, upload queue, syncDB, document details and finalization // are wired by files.js. // ── 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(--topbar-muted)', '⏳': 'var(--topbar-active)', '✓': 'var(--topbar-done)' }; 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 для облегчения анализа и отладки. // Picker rows are rendered by the FilePicker integration in files.js. // ── Раскрыть/скрыть результат классификации под строкой файла ── // toggleClassifyDetail → files.js (Фаза 1) // moveUp/moveDown/removeFile/openAbout/closeAbout — вынесены в app_utils.js // ── Классификация ────────────────────────────────────────── 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 progressUrl = state.v3Mode ? '/api/classify-progress?batch=' + encodeURIComponent(state.batchId) : VM_API + '/api/batch-progress?batch=' + encodeURIComponent(state.batchId); var r = await fetch(progressUrl); var d = await r.json(); if (state.v3Mode && d.v3) { var completed = d.done || 0; var total = d.total || 0; btn.innerHTML = ' Извлечение... ' + completed + '/' + total + ' (' + Math.round((Date.now() - start) / 1000) + 'с)'; if (d.finished) { clearInterval(pollTimer); clearInterval(timer); classifyDone = true; if (d.state === 'failed' || d.state === 'interrupted') { btn.innerHTML = '✗ ' + escHtml(d.error || 'ошибка извлечения'); btn.disabled = false; return; } btn.innerHTML = '✓ ' + completed + '/' + total + ' документов извлечено'; stepDone('stepClassify'); stepActive('stepGroups'); loadGroupsAction(); } return; } if (d.ok && d.counts) { var done = (d.counts.classified || 0) + (d.counts.failed || 0) + (d.counts.garbage || 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(); state.v3Mode = Boolean(data.v3); if (data.v3) state.v3Summary = data; // sync classify: ответ уже содержит done if (data.ok && data.done !== undefined) { clearInterval(timer); clearInterval(pollTimer); classifyDone = true; var summary = data.v3 ? data.done + '/' + data.total + ' извлечено; ' + (data.dossier_count || 0) + ' досье' : data.summary || (data.done + '/' + data.total + ' классифицировано'); if (data.v3 && data.ignored_accounting) summary += ' | отсеяно: ' + data.ignored_accounting; if (data.v3) summary += ' | арифметика: ' + data.math_checks_passed; if (data.v3 && data.failed) summary += ' | ошибок: ' + data.failed; if (data.garbage) summary += ' | 🗑️ ' + data.garbage + ' мусор'; if (data.types) summary += ' | ' + Object.entries(data.types).map(function(e){return e[1]+'×'+e[0];}).join(', '); btn.innerHTML = '✓ ' + summary + ' (' + 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 = '✗ ' + escHtml(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 += '
' + textify + ''; // Правая: статистика + textify rightHtml += '
' + textify + ''; if (f.supp_id) { rightHtml += '
' + escHtml(qData.classify_raw) + ''; rightHtml += '