diff --git a/deploy/app.js b/deploy-check/app.js similarity index 100% rename from deploy/app.js rename to deploy-check/app.js diff --git a/deploy/app_utils.js b/deploy-check/app_utils.js similarity index 100% rename from deploy/app_utils.js rename to deploy-check/app_utils.js diff --git a/deploy/classify_worker.py b/deploy-check/classify_worker.py similarity index 100% rename from deploy/classify_worker.py rename to deploy-check/classify_worker.py diff --git a/deploy/compare.js b/deploy-check/compare.js similarity index 100% rename from deploy/compare.js rename to deploy-check/compare.js diff --git a/deploy/convert_doc.py b/deploy-check/convert_doc.py similarity index 100% rename from deploy/convert_doc.py rename to deploy-check/convert_doc.py diff --git a/deploy/convert_server.py b/deploy-check/convert_server.py similarity index 100% rename from deploy/convert_server.py rename to deploy-check/convert_server.py diff --git a/deploy/db/__init__.py b/deploy-check/db/__init__.py similarity index 100% rename from deploy/db/__init__.py rename to deploy-check/db/__init__.py diff --git a/deploy/db/connection.py b/deploy-check/db/connection.py similarity index 100% rename from deploy/db/connection.py rename to deploy-check/db/connection.py diff --git a/deploy/db/contracts.py b/deploy-check/db/contracts.py similarity index 100% rename from deploy/db/contracts.py rename to deploy-check/db/contracts.py diff --git a/deploy/db/documents.py b/deploy-check/db/documents.py similarity index 100% rename from deploy/db/documents.py rename to deploy-check/db/documents.py diff --git a/deploy/db/prompts.py b/deploy-check/db/prompts.py similarity index 100% rename from deploy/db/prompts.py rename to deploy-check/db/prompts.py diff --git a/deploy/db/spec_current.py b/deploy-check/db/spec_current.py similarity index 100% rename from deploy/db/spec_current.py rename to deploy-check/db/spec_current.py diff --git a/deploy/db/spec_events.py b/deploy-check/db/spec_events.py similarity index 100% rename from deploy/db/spec_events.py rename to deploy-check/db/spec_events.py diff --git a/deploy/db/supplements.py b/deploy-check/db/supplements.py similarity index 100% rename from deploy/db/supplements.py rename to deploy-check/db/supplements.py diff --git a/deploy/files.js b/deploy-check/files.js similarity index 100% rename from deploy/files.js rename to deploy-check/files.js diff --git a/deploy/groups.js b/deploy-check/groups.js similarity index 100% rename from deploy/groups.js rename to deploy-check/groups.js diff --git a/deploy/llm_prompt.py b/deploy-check/llm_prompt.py similarity index 100% rename from deploy/llm_prompt.py rename to deploy-check/llm_prompt.py diff --git a/deploy/nginx-contracts.conf b/deploy-check/nginx-contracts.conf similarity index 100% rename from deploy/nginx-contracts.conf rename to deploy-check/nginx-contracts.conf diff --git a/deploy/services/__init__.py b/deploy-check/services/__init__.py similarity index 100% rename from deploy/services/__init__.py rename to deploy-check/services/__init__.py diff --git a/deploy/services/classify.py b/deploy-check/services/classify.py similarity index 100% rename from deploy/services/classify.py rename to deploy-check/services/classify.py diff --git a/deploy/services/grouping.py b/deploy-check/services/grouping.py similarity index 100% rename from deploy/services/grouping.py rename to deploy-check/services/grouping.py diff --git a/deploy/services/llm.py b/deploy-check/services/llm.py similarity index 100% rename from deploy/services/llm.py rename to deploy-check/services/llm.py diff --git a/deploy/services/metrics.py b/deploy-check/services/metrics.py similarity index 100% rename from deploy/services/metrics.py rename to deploy-check/services/metrics.py diff --git a/deploy/services/parse.py b/deploy-check/services/parse.py similarity index 100% rename from deploy/services/parse.py rename to deploy-check/services/parse.py diff --git a/deploy/services/process.py b/deploy-check/services/process.py similarity index 100% rename from deploy/services/process.py rename to deploy-check/services/process.py diff --git a/deploy/services/unzip.py b/deploy-check/services/unzip.py similarity index 100% rename from deploy/services/unzip.py rename to deploy-check/services/unzip.py diff --git a/deploy/services/upload.py b/deploy-check/services/upload.py similarity index 100% rename from deploy/services/upload.py rename to deploy-check/services/upload.py diff --git a/deploy/state.js b/deploy-check/state.js similarity index 100% rename from deploy/state.js rename to deploy-check/state.js diff --git a/deploy/sync.sh b/deploy-check/sync.sh similarity index 100% rename from deploy/sync.sh rename to deploy-check/sync.sh diff --git a/deploy/tests.js b/deploy-check/tests.js similarity index 100% rename from deploy/tests.js rename to deploy-check/tests.js diff --git a/deploy-lucee/app.js b/deploy-lucee/app.js new file mode 100644 index 0000000..2ce4f0f --- /dev/null +++ b/deploy-lucee/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/deploy-lucee/app_utils.js b/deploy-lucee/app_utils.js new file mode 100644 index 0000000..d65a8a5 --- /dev/null +++ b/deploy-lucee/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/deploy-lucee/classify_worker.py b/deploy-lucee/classify_worker.py new file mode 100644 index 0000000..d239177 --- /dev/null +++ b/deploy-lucee/classify_worker.py @@ -0,0 +1,38 @@ +""" +classify_worker.py — Фоновый процесс классификации (Фаза async classify). + +Запускается через subprocess.Popen из convert_server.py. +Принимает batch_id как аргумент командной строки. +Не зависит от HTTP-сервера — свои коннекты к БД, своя память. + +ЗАЧЕМ: threading.Thread внутри HTTP-процесса делит коннекты к БД +с HTTP-потоком. При 50+ файлах ThreadPoolExecutor(4) + HTTP-поток +исчерпывают пул коннектов → сервер не отвечает → 502. + +subprocess.Popen создаёт отдельный питон-процесс со своей памятью +и своими коннектами. HTTP-сервер продолжает отвечать на batch-progress. +Процесс живёт пока classify не завершится, потом умирает. +""" + +import sys, os +sys.path.insert(0, os.path.dirname(__file__)) + +from services.classify import classify_batch + +if __name__ == "__main__": + if len(sys.argv) < 2: + print("Usage: python3 classify_worker.py ", file=sys.stderr) + sys.exit(1) + + batch_id = sys.argv[1] + lock_path = f"/tmp/classify_{batch_id}.lock" + try: + result = classify_batch(batch_id) + print(f"classify_worker DONE: {result}") + except Exception as e: + print(f"classify_worker FAILED: {e}", file=sys.stderr) + sys.exit(1) + finally: + # Убрать lock-файл в любом случае + if os.path.exists(lock_path): + os.remove(lock_path) diff --git a/deploy-lucee/compare.js b/deploy-lucee/compare.js new file mode 100644 index 0000000..c712478 --- /dev/null +++ b/deploy-lucee/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/deploy-lucee/convert_doc.py b/deploy-lucee/convert_doc.py new file mode 100755 index 0000000..713930a --- /dev/null +++ b/deploy-lucee/convert_doc.py @@ -0,0 +1,24 @@ +#!/usr/bin/env python3 +"""Конвертер .doc → .docx через libreoffice. Принимает POST с файлом, возвращает docx.""" +import subprocess, tempfile, os, sys + +data = sys.stdin.buffer.read() +with tempfile.NamedTemporaryFile(suffix=".doc", delete=False) as f: + f.write(data) + doc_path = f.name + +tmpdir = tempfile.mkdtemp() +try: + subprocess.run(["libreoffice", "--headless", "--convert-to", "docx", "--outdir", tmpdir, doc_path], + timeout=30, capture_output=True) + docx = [x for x in os.listdir(tmpdir) if x.endswith(".docx")] + if docx: + with open(os.path.join(tmpdir, docx[0]), "rb") as f: + sys.stdout.buffer.write(f.read()) + else: + sys.stdout.buffer.write(b"") +finally: + os.unlink(doc_path) + for x in os.listdir(tmpdir): + os.unlink(os.path.join(tmpdir, x)) + os.rmdir(tmpdir) diff --git a/deploy-lucee/convert_server.py b/deploy-lucee/convert_server.py new file mode 100755 index 0000000..add6a77 --- /dev/null +++ b/deploy-lucee/convert_server.py @@ -0,0 +1,509 @@ +#!/usr/bin/env python3 +# ⛔ НЕ МЕНЯТЬ БЕЗ РАЗРЕШЕНИЯ НАЕЛЯ ⛔ +"""Contracts VM server — thin HTTP router. All logic in db/ and services/.""" +from http.server import HTTPServer, BaseHTTPRequestHandler +from socketserver import ThreadingMixIn, TCPServer +from urllib.parse import urlparse, parse_qs +import json, re, os, sys + +# ── DB auto-seed ────────────────────────────────────────────────────────── +from db import prompts as db_prompts +from db.connection import DB_CONFIG, execute +db_prompts.seed_defaults() +# Schema migration: classify_raw column (idempotent) +execute("ALTER TABLE documents ADD COLUMN IF NOT EXISTS classify_raw text") +execute("ALTER TABLE documents ADD COLUMN IF NOT EXISTS classify_input text") +execute("ALTER TABLE documents ADD COLUMN IF NOT EXISTS zip_source text") + +# ── DB modules ──────────────────────────────────────────────────────────── +from db import supplements as db_supplements +from db import documents as db_documents +from db import spec_current as db_spec_current + +# ── Services ────────────────────────────────────────────────────────────── +from services.upload import handle_upload +from services.unzip import handle_unzip +from services.process import run_pipeline +from llm_prompt import build_prompt + + +class ThreadingHTTPServer(ThreadingMixIn, HTTPServer): + daemon_threads = True + + +class Handler(BaseHTTPRequestHandler): + def do_OPTIONS(self): + self.send_response(200) + self._send_cors() + self.send_header("Access-Control-Allow-Methods", "GET, POST, OPTIONS") + self.send_header("Access-Control-Allow-Headers", "Content-Type") + self.end_headers() + + def do_GET(self): + parsed = urlparse(self.path) + if parsed.path == "/process-v2": + self._handle_process_v2(parsed) + elif parsed.path == "/health": + self._json({"ok": True, "db": DB_CONFIG["dbname"]}) + elif parsed.path == "/app.js" or parsed.path.startswith("/static/"): + self._handle_app_js() + elif parsed.path == "/api/supplements": + self._handle_api_supplements(parsed) + elif parsed.path == "/api/groups": + self._handle_api_groups(parsed) + elif parsed.path == "/api/batch-progress": + self._handle_api_batch_progress(parsed) + elif parsed.path == "/api/prompts": + self._handle_api_prompts_get(parsed) + elif parsed.path == "/api/prompts/list": + self._handle_api_prompts_list(parsed) + elif parsed.path.startswith("/api/documents/"): + self._handle_api_document(parsed) + else: + self.send_error(404) + + def do_POST(self): + if self.path == "/upload": + self._handle_upload() + elif self.path == "/unzip-upload": + self._handle_unzip() + elif self.path == "/llm-ops": + self._handle_llm_ops() + elif self.path == "/convert-doc": + self._handle_convert_doc() + elif self.path == "/api/classify-batch": + self._handle_classify_batch() + elif self.path == "/api/apply-groups": + self._handle_apply_groups() + elif self.path == "/api/cleanup": + self._handle_cleanup() + elif self.path == "/api/prompts/save": + self._handle_api_prompts_save() + elif self.path == "/api/prompts/activate": + self._handle_api_prompts_activate() + elif self.path == "/api/prompts/delete": + self._handle_api_prompts_delete() + elif self.path == "/api/sync": + self._handle_api_sync() + else: + self.send_error(404) + + def do_DELETE(self): + parsed = urlparse(self.path) + if parsed.path.startswith("/api/documents/"): + self._handle_api_document_delete(parsed) + else: + self.send_error(404) + + # ── /process-v2 (SSE) ───────────────────────────────────────────────── + + def _handle_process_v2(self, parsed): + params = parse_qs(parsed.query) + cid = params.get("contract_id", [None])[0] + if not cid: + self.send_error(400, "contract_id required") + return + if not re.fullmatch(r'[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}', cid, re.I): + self.send_error(400, "invalid contract_id format") + return + + self.send_response(200) + self.send_header("Content-Type", "text/event-stream; charset=utf-8") + self.send_header("Cache-Control", "no-cache") + self.send_header("Connection", "keep-alive") + self.end_headers() + self.wfile.write(b": ok\n\n") + self.wfile.flush() + + order_ids = params.get("order", [None])[0] or "" + + try: + run_pipeline(cid, order_ids, self._sse, build_prompt) + except Exception as e: + self._sse({"type": "error", "message": str(e)}) + + def _sse(self, data): + self.wfile.write(f"data: {json.dumps(data, ensure_ascii=False)}\n\n".encode()) + self.wfile.flush() + + # ── /api/supplements ────────────────────────────────────────────────── + + def _handle_api_supplements(self, parsed): + params = parse_qs(parsed.query) + cid = params.get("contract_id", [None])[0] + if not cid: + self._json({"ok": False, "error": "contract_id required"}, 400) + return + rows = db_supplements.list_by_contract(cid) + self._json({"ok": True, "supplements": rows}) + + # ── /api/documents/ ────────────────────────────────────────────── + + def _handle_api_document(self, parsed): + doc_id = parsed.path.split("/")[-1] + doc = db_documents.get(doc_id) + if not doc: + self._json({"ok": False, "error": "not found"}, 404) + return + self._json({ + "ok": True, + "id": doc["id"], + "filename": doc["filename"], + "status": doc["status"], + "elements_json": doc.get("elements_json"), + "doc_type": doc.get("doc_type"), + "own_number": doc.get("own_number"), + "parent_number": doc.get("parent_number"), + "doc_date": doc.get("doc_date"), + "counterparty": doc.get("counterparty"), + "classify_status": doc.get("classify_status"), + "classify_raw": doc.get("classify_raw"), + "classify_input": doc.get("classify_input"), + }) + + def _handle_api_document_delete(self, parsed): + """DELETE /api/documents/ — cascade: spec_current, spec_events, supplements, document.""" + from db.connection import execute, query + doc_id = parsed.path.split("/")[-1] + supps = query("SELECT id, contract_id FROM supplements WHERE document_id = %s", (doc_id,)) + for s in (supps or []): + execute( + """DELETE FROM spec_current WHERE contract_id = %s + AND last_event_id IN (SELECT id FROM spec_events WHERE supplement_id = %s)""", + (s["contract_id"], s["id"]), + ) + execute("DELETE FROM spec_events WHERE supplement_id = %s", (s["id"],)) + execute("DELETE FROM supplements WHERE id = %s", (s["id"],)) + execute("DELETE FROM documents WHERE id = %s", (doc_id,)) + self._json({"ok": True}) + + # ── POST /api/sync ────────────────────────────────────────────────── + + def _handle_api_sync(self): + """Удалить ВСЕ документы, НЕ входящие в keep_ids. БД = зеркало таблицы.""" + from db.connection import execute, query + length = int(self.headers.get("Content-Length", 0)) + body = json.loads(self.rfile.read(length)) if length > 0 else {} + keep_ids = set(body.get("keep_ids", [])) + # Prevent DoS: too many IDs + if len(keep_ids) > 1000: + self._json({"ok": False, "error": "too many keep_ids (max 1000)"}, 400) + return + docs = query("SELECT id FROM documents", ()) + deleted = 0 + for d in (docs or []): + if d["id"] in keep_ids: + continue + supps = query("SELECT id, contract_id FROM supplements WHERE document_id = %s", (d["id"],)) + for s in (supps or []): + execute( + """DELETE FROM spec_current WHERE contract_id = %s + AND last_event_id IN (SELECT id FROM spec_events WHERE supplement_id = %s)""", + (s["contract_id"], s["id"]), + ) + execute("DELETE FROM spec_events WHERE supplement_id = %s", (s["id"],)) + execute("DELETE FROM supplements WHERE id = %s", (s["id"],)) + execute("DELETE FROM documents WHERE id = %s", (d["id"],)) + deleted += 1 + # Удалить осиротевшие contracts (без supplements) + execute("DELETE FROM contracts WHERE id NOT IN (SELECT DISTINCT contract_id FROM supplements)") + self._json({"ok": True, "deleted": deleted}) + + # ── /api/groups ?batch=X ───────────────────────────────────────────── + + def _handle_api_groups(self, parsed): + params = parse_qs(parsed.query) + batch_id = params.get("batch", [None])[0] + if not batch_id: + self._json({"ok": False, "error": "batch required"}, 400) + return + from services.grouping import group_documents + result = group_documents(batch_id) + self._json(result) + + # ── /api/batch-progress ?batch=X ───────────────────────────────────── + + def _handle_api_batch_progress(self, parsed): + params = parse_qs(parsed.query) + batch_id = params.get("batch", [None])[0] + if not batch_id: + self._json({"ok": False, "error": "batch required"}, 400) + return + counts = db_documents.count_by_status(batch_id) + docs = db_documents.list_by_batch(batch_id) + self._json({"ok": True, "counts": counts, "total": len(docs)}) + + # ── POST /classify-batch ───────────────────────────────────────────── + + def _handle_classify_batch(self): + """ + POST /api/classify-batch — запустить классификацию в фоне. + + ЗАЧЕМ subprocess вместо threading.Thread: + ThreadPoolExecutor(4) + HTTP-поток делят коннекты к PostgreSQL. + При 50+ файлах пул исчерпывается → сервер не отвечает → 502. + Отдельный процесс — свои коннекты, своя память. + HTTP-сервер продолжает отвечать на batch-progress. + """ + length = int(self.headers.get("Content-Length", 0)) + body = json.loads(self.rfile.read(length)) + batch_id = body.get("batch_id") + if not batch_id: + self._json({"ok": False, "error": "batch_id required"}, 400) + return + from db import documents as db_docs + import subprocess, os + + # Guard: если classify для этого batch уже запущен — не запускать повторно + lock_path = f"/tmp/classify_{batch_id}.lock" + if os.path.exists(lock_path): + self._json({"ok": False, "error": "classify already running for this batch"}, 409) + return + + # Посчитать сколько файлов — ответить сразу, classify в отдельном процессе + pending = db_docs.list_pending(batch_id) + total = len(pending) + if total == 0: + self._json({"ok": False, "error": "no pending documents"}, 400) + return + + # Запустить classify_worker.py в отдельном процессе + # Лог в /home/naeel/contracts/logs/ вместо DEVNULL + log_dir = "/home/naeel/nubes/contracts/logs" + os.makedirs(log_dir, exist_ok=True) + log_path = os.path.join(log_dir, f"classify_{batch_id}.log") + log_file = open(log_path, "w") + + # Создать lock-файл (воркер удалит при завершении) + with open(lock_path, "w") as lf: + lf.write(str(os.getpid())) + + worker_path = os.path.join(os.path.dirname(__file__), "classify_worker.py") + subprocess.Popen( + [sys.executable, worker_path, batch_id], + stdout=log_file, stderr=subprocess.STDOUT, + ) + self._json({"ok": True, "total": total, "log": log_path}, 202) + + # ── POST /apply-groups ─────────────────────────────────────────────── + + def _handle_apply_groups(self): + length = int(self.headers.get("Content-Length", 0)) + body = json.loads(self.rfile.read(length)) + batch_id = body.get("batch_id") + groups = body.get("groups", []) + if not batch_id: + self._json({"ok": False, "error": "batch_id required"}, 400) + return + from services.grouping import apply_groups + result = apply_groups(batch_id, groups) + self._json(result) + + # ── POST /api/cleanup ──────────────────────────────────────────────── + + def _handle_cleanup(self): + """Полная очистка всех данных (кроме prompts). Вызывается при загрузке страницы.""" + from db.connection import execute + execute("DELETE FROM spec_current") + execute("DELETE FROM spec_events") + execute("DELETE FROM supplements") + execute("DELETE FROM upload_chunks") + execute("DELETE FROM documents") + execute("DELETE FROM contracts") + self._json({"ok": True, "message": "all data cleaned"}) + + # ── GET /api/cleanup ─────────────────────────────────────────────────── + + def _handle_cleanup(self): + """Полная очистка всех данных (кроме prompts).""" + from db.connection import execute + execute("DELETE FROM spec_current") + execute("DELETE FROM spec_events") + execute("DELETE FROM supplements") + execute("DELETE FROM upload_chunks") + execute("DELETE FROM documents") + execute("DELETE FROM contracts") + self._json({"ok": True, "message": "all data cleaned"}) + + # ── Prompts CRUD ────────────────────────────────────────────────────── + + def _handle_api_prompts_get(self, parsed): + from db import prompts as db_p + params = parse_qs(parsed.query) + role = params.get("role", [None])[0] + if not role: + self._json({"ok": False, "error": "role required"}, 400) + return + p = db_p.get_active(role) + if p: + self._json({"ok": True, "id": p["id"], "role": p["role"], "name": p["name"], + "body": p["body"], "is_active": p["is_active"], "notes": p.get("notes"), + "created_at": str(p.get("created_at", ""))}) + else: + self._json({"ok": False, "error": "not found"}, 404) + + def _handle_api_prompts_list(self, parsed): + from db import prompts as db_p + params = parse_qs(parsed.query) + role = params.get("role", [None])[0] + if not role: + self._json({"ok": False, "error": "role required"}, 400) + return + versions = db_p.list_by_role(role) + self._json({"ok": True, "versions": versions}) + + def _handle_api_prompts_save(self): + from db import prompts as db_p + length = int(self.headers.get("Content-Length", 0)) + body = json.loads(self.rfile.read(length)) + role = body.get("role", "") + name = body.get("name", "v" + __import__("datetime").datetime.now().isoformat()[:16]) + prompt_body = body.get("body", "") + notes = body.get("notes", "") + is_active = body.get("is_active", True) + if not role or not prompt_body: + self._json({"ok": False, "error": "role and body required"}, 400) + return + p = db_p.save_new_version(role, name, prompt_body, notes, is_active) + self._json({"ok": True, "id": p["id"]}) + + def _handle_api_prompts_activate(self): + from db import prompts as db_p + length = int(self.headers.get("Content-Length", 0)) + body = json.loads(self.rfile.read(length)) + pid = body.get("id", "") + if not pid: + self._json({"ok": False, "error": "id required"}, 400) + return + ok = db_p.activate(pid) + self._json({"ok": ok}) + + def _handle_api_prompts_delete(self): + from db import prompts as db_p + length = int(self.headers.get("Content-Length", 0)) + body = json.loads(self.rfile.read(length)) + pid = body.get("id", "") + if not pid: + self._json({"ok": False, "error": "id required"}, 400) + return + ok = db_p.delete_prompt(pid) + self._json({"ok": ok}) + + # ── /app.js (static) ─────────────────────────────────────────────────── + + def _handle_app_js(self): + try: + fname = urlparse(self.path).path.lstrip("/").replace("static/", "") + with open(os.path.join(os.path.dirname(__file__), fname), "rb") as f: + js = f.read() + self.send_response(200) + self.send_header("Content-Type", "application/javascript; charset=utf-8") + self.send_header("Content-Length", str(len(js))) + self.send_header("X-Content-Type-Options", "nosniff") + self._send_cors() + self.end_headers() + self.wfile.write(js) + except FileNotFoundError: + self.send_error(404) + + # ── /upload ─────────────────────────────────────────────────────────── + + def _handle_upload(self): + try: + content_type = self.headers.get("Content-Type", "") + content_length = int(self.headers.get("Content-Length", 0)) + result = handle_upload(self.rfile, content_type, content_length) + self._json(result) + except Exception as e: + self._json({"ok": False, "error": str(e)}, 500) + + # ── /unzip-upload ───────────────────────────────────────────────────── + + def _handle_unzip(self): + try: + content_length = int(self.headers.get("Content-Length", 0)) + result = handle_unzip(self.rfile, content_length) + self._json(result) + except Exception as e: + self._json({"ok": False, "error": str(e)}, 500) + + # ── /convert-doc ────────────────────────────────────────────────────── + + def _handle_convert_doc(self): + """DOC → DOCX через LibreOffice.""" + import subprocess, tempfile + length = int(self.headers.get("Content-Length", 0)) + data = self.rfile.read(length) + + with tempfile.NamedTemporaryFile(suffix=".doc", delete=False) as f: + f.write(data) + doc_path = f.name + + tmpdir = tempfile.mkdtemp() + try: + subprocess.run( + ["libreoffice", "--headless", "--convert-to", "docx", "--outdir", tmpdir, doc_path], + timeout=30, capture_output=True, + ) + docx_files = [x for x in os.listdir(tmpdir) if x.endswith(".docx")] + if docx_files: + with open(os.path.join(tmpdir, docx_files[0]), "rb") as f: + result = f.read() + self.send_response(200) + self.send_header("Content-Type", "application/vnd.openxmlformats-officedocument.wordprocessingml.document") + self.send_header("Content-Length", str(len(result))) + self._send_cors() + self.end_headers() + self.wfile.write(result) + else: + self.send_error(500, "conversion produced no output") + except Exception as e: + self.send_error(500, str(e)) + finally: + os.unlink(doc_path) + for x in os.listdir(tmpdir): + os.unlink(os.path.join(tmpdir, x)) + os.rmdir(tmpdir) + + # ── /llm-ops (debug) ────────────────────────────────────────────────── + + def _handle_llm_ops(self): + from services.llm import call_llm + length = int(self.headers.get("Content-Length", 0)) + body = json.loads(self.rfile.read(length)) + try: + result, prompt_id = call_llm( + body.get("current_spec", []), + body.get("doc_text", ""), + build_prompt, + ) + self._json(result) + except Exception as e: + self._json({"error": str(e)}, 502) + + # ── Helpers ─────────────────────────────────────────────────────────── + + def _json(self, data, status=200): + self.send_response(status) + self._send_cors() + self.send_header("Content-Type", "application/json; charset=utf-8") + self.end_headers() + self.wfile.write(json.dumps(data, ensure_ascii=False).encode()) + + def _send_cors(self): + self.send_header("Access-Control-Allow-Origin", "*") + + def log_message(self, format, *args): + pass + + +if __name__ == "__main__": + import os + TCPServer.allow_reuse_address = True + port = int(os.environ.get("PORT", "8766")) + server = ThreadingHTTPServer(("0.0.0.0", port), Handler) + print(f"Contracts VM server on :{port}, db={DB_CONFIG['dbname']}") + try: + server.serve_forever() + except KeyboardInterrupt: + server.shutdown() diff --git a/deploy-lucee/db/__init__.py b/deploy-lucee/db/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/deploy-lucee/db/connection.py b/deploy-lucee/db/connection.py new file mode 100644 index 0000000..6afefb4 --- /dev/null +++ b/deploy-lucee/db/connection.py @@ -0,0 +1,91 @@ +""" +Database connection — psycopg2 connection pool. + +Архитектурное решение (Opus): +- ThreadedConnectionPool(minconn=1, maxconn=10) — оптимально для ThreadingMixIn HTTP-сервера. + Каждый HTTP-запрос в отдельном потоке получает своё соединение из пула. +- RealDictCursor для query() — возвращает dict с lowercase ключами (консистентно с Python API). +- execute()/execute_returning() — для INSERT/UPDATE/DELETE с автокоммитом и откатом при ошибке. +- Пул создаётся лениво (при первом запросе) через get_pool(). +""" +import os +import psycopg2 +import psycopg2.pool +import psycopg2.extras + +# Глобальный пул соединений (singleton) +_pool = None + +# Параметры подключения из переменных окружения (systemd Environment) +DB_CONFIG = { + "host": os.getenv("DB_HOST", "127.0.0.1"), + "port": int(os.getenv("DB_PORT", "5432")), + "dbname": os.getenv("DB_NAME", "baza"), + "user": os.getenv("DB_USER", "super"), + "password": os.getenv("DB_PASS", ""), +} + + +def get_pool(): + """Возвращает глобальный пул соединений. Создаёт при первом вызове.""" + global _pool + if _pool is None: + _pool = psycopg2.pool.ThreadedConnectionPool( + minconn=1, maxconn=10, **DB_CONFIG + ) + return _pool + + +def query(sql, params=None): + """ + SELECT → list[dict] с lowercase ключами. + Используется всеми db/*.py модулями для чтения данных. + """ + pool = get_pool() + conn = pool.getconn() + try: + with conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor) as cur: + cur.execute(sql, params) + rows = cur.fetchall() + return [{k.lower(): v for k, v in r.items()} for r in rows] + finally: + pool.putconn(conn) + + +def execute(sql, params=None): + """ + INSERT/UPDATE/DELETE → количество затронутых строк. + Автокоммит. При ошибке — rollback и проброс исключения. + """ + pool = get_pool() + conn = pool.getconn() + try: + with conn.cursor() as cur: + cur.execute(sql, params) + conn.commit() + return cur.rowcount + except Exception: + conn.rollback() + raise + finally: + pool.putconn(conn) + + +def execute_returning(sql, params=None): + """ + INSERT/UPDATE/DELETE с RETURNING → dict первой строки. + Используется для insert с автогенерацией UUID (gen_random_uuid()). + """ + pool = get_pool() + conn = pool.getconn() + try: + with conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor) as cur: + cur.execute(sql, params) + conn.commit() + row = cur.fetchone() + return {k.lower(): v for k, v in row.items()} if row else None + except Exception: + conn.rollback() + raise + finally: + pool.putconn(conn) diff --git a/deploy-lucee/db/contracts.py b/deploy-lucee/db/contracts.py new file mode 100644 index 0000000..4d13216 --- /dev/null +++ b/deploy-lucee/db/contracts.py @@ -0,0 +1,25 @@ +"""Contracts CRUD.""" +from .connection import query, execute, execute_returning + + +def insert(number, client=""): + return execute_returning( + "INSERT INTO contracts (number, client) VALUES (%s, %s) RETURNING *", + (number, client), + ) + + +def get(contract_id): + rows = query("SELECT * FROM contracts WHERE id = %s", (contract_id,)) + return rows[0] if rows else None + + +def delete(contract_id): + return execute("DELETE FROM contracts WHERE id = %s", (contract_id,)) + + +def delete_orphaned(): + """Remove contracts with no supplements.""" + execute( + "DELETE FROM contracts WHERE id NOT IN (SELECT DISTINCT contract_id FROM supplements)" + ) diff --git a/deploy-lucee/db/documents.py b/deploy-lucee/db/documents.py new file mode 100644 index 0000000..76f9e64 --- /dev/null +++ b/deploy-lucee/db/documents.py @@ -0,0 +1,105 @@ +"""Documents CRUD.""" +from .connection import query, execute, execute_returning + + +def insert(filename, mime_type, original_bytes, status="uploaded", batch_id=None, zip_source=None): + """Insert document, return row dict.""" + return execute_returning( + """INSERT INTO documents (filename, mime_type, original_bytes, status, batch_id, zip_source) + VALUES (%s, %s, %s, %s, %s, %s) RETURNING *""", + (filename, mime_type, original_bytes, status, batch_id, zip_source), + ) + + +def get(doc_id): + rows = query("SELECT * FROM documents WHERE id = %s", (doc_id,)) + return rows[0] if rows else None + + +def set_parsed(doc_id, elements_json): + """Update elements_json + status='parsed'.""" + import json + return execute( + "UPDATE documents SET elements_json = %s::jsonb, status = 'parsed' WHERE id = %s", + (json.dumps(elements_json, ensure_ascii=False), doc_id), + ) + + +def set_error(doc_id, error_message): + return execute( + "UPDATE documents SET status = 'error', error_message = %s WHERE id = %s", + (error_message, doc_id), + ) + + +def delete(doc_id): + return execute("DELETE FROM documents WHERE id = %s", (doc_id,)) + + +def set_classification(doc_id, doc_type, own_number, parent_number, doc_date, counterparty, classify_raw=None, classify_input=None): + """Store LLM classification results + raw response + input text.""" + return execute( + """UPDATE documents SET doc_type=%s, own_number=%s, parent_number=%s, + doc_date=%s, counterparty=%s, classify_status='classified', classify_raw=%s, classify_input=%s + WHERE id=%s""", + (doc_type, own_number, parent_number, doc_date, counterparty, classify_raw, classify_input, doc_id), + ) + + +def set_classify_failed(doc_id, error): + return execute( + "UPDATE documents SET classify_status='failed', error_message=%s WHERE id=%s", + (error, doc_id), + ) + + +def set_classify_garbage(doc_id, reason=""): + """Mark document as garbage (Stage 1-2 filter, no LLM call).""" + return execute( + "UPDATE documents SET doc_type='garbage', classify_status='garbage', error_message=%s WHERE id=%s", + (f"garbage: {reason}", doc_id), + ) + + +def list_pending(batch_id): + """Documents waiting for classification.""" + return query( + "SELECT id, filename, elements_json FROM documents WHERE batch_id=%s AND classify_status='pending'", + (batch_id,), + ) + + +def reset_classify_status(batch_id): + """Сбросить classify_status на 'pending' для всех документов батча (включая 'processing' — crash recovery).""" + return execute( + "UPDATE documents SET classify_status='pending', error_message=NULL WHERE batch_id=%s", + (batch_id,), + ) + + +def set_classify_processing(doc_id): + """Mark document as being processed (for crash recovery).""" + return execute( + "UPDATE documents SET classify_status='processing' WHERE id=%s", + (doc_id,), + ) + + +def list_by_batch(batch_id): + """All documents in a batch with classification fields.""" + return query( + """SELECT id, filename, status, doc_type, own_number, parent_number, + doc_date, counterparty, classify_status, error_message, classify_raw, classify_input, + zip_source + FROM documents WHERE batch_id=%s ORDER BY created_at""", + (batch_id,), + ) + + +def count_by_status(batch_id): + """Count documents by classify_status.""" + rows = query( + "SELECT classify_status, count(*) as cnt FROM documents WHERE batch_id=%s GROUP BY classify_status", + (batch_id,), + ) + return {r["classify_status"]: r["cnt"] for r in rows} diff --git a/deploy-lucee/db/prompts.py b/deploy-lucee/db/prompts.py new file mode 100644 index 0000000..cfbb3ef --- /dev/null +++ b/deploy-lucee/db/prompts.py @@ -0,0 +1,151 @@ +"""Prompts CRUD.""" +from .connection import query, execute, execute_returning + + +def _serialize(row): + """Convert datetime fields to strings for JSON serialization (non-mutating).""" + if row and row.get("created_at"): + row = dict(row) + row["created_at"] = str(row["created_at"]) + return row + + +def get_active(role): + rows = query( + "SELECT * FROM prompts WHERE role = %s AND is_active = true ORDER BY created_at DESC LIMIT 1", + (role,), + ) + return _serialize(rows[0]) if rows else None + + +def get(prompt_id): + rows = query("SELECT * FROM prompts WHERE id = %s", (prompt_id,)) + return rows[0] if rows else None + + +def seed_defaults(): + """Auto-seed default prompts if table is empty.""" + # Check each role separately — don't skip if one is missing + existing_roles = set(r["role"] for r in query("SELECT DISTINCT role FROM prompts")) + + if "extract" not in existing_roles: + extract_body = ( + "Ты — анализатор договоров облачного провайдера.\n\n" + "Ниже текст спецификации услуг из ПЕРВОГО документа (базовый договор).\n" + "Извлеки ВСЕ строки спецификации в JSON-массив.\n\n" + "Верни СТРОГО JSON без пояснений. Не используй markdown-блоки.\n\n" + "ФОРМАТ:\n" + '{\n "mode": "partial",\n "ops": [\n' + ' {"action": "ADD", "new_row": {"name": "полное наименование", "price": число, "qty": число, "sum": число, "date_start": "YYYY-MM-DD"}, "comment": ""}\n' + " ]\n}\n\n" + "ПРАВИЛА:\n" + "1. Извлеки КАЖДУЮ строку таблицы спецификации как отдельную ADD-операцию.\n" + '2. Пропускай итоговые строки ("Итого...") и строки с подписями.\n' + "3. Если ячейка пустая — ставь null (не пиши 0).\n" + "4. price, qty, sum — ЧИСЛА, не строки.\n\n" + "ТЕКСТ ДОКУМЕНТА:\n---\n{doc_text}\n---" + ) + execute( + "INSERT INTO prompts (role, name, body, is_active, notes) VALUES (%s, %s, %s, %s, %s)", + ("extract", "default-v1", extract_body, True, "Авто-создан из llm_prompt.py _build_initial"), + ) + + if "diff" not in existing_roles: + diff_body = ( + "Ты — анализатор допсоглашений к договорам облачного провайдера.\n\n" + "У тебя есть текущая спецификация услуг и текст нового допсоглашения (ДС).\n" + "Твоя задача — определить, какие изменения вносит ДС в текущую спецификацию.\n\n" + "Верни СТРОГО JSON без пояснений. Не используй markdown-блоки.\n\n" + "ФОРМАТ:\n" + '{\n "mode": "partial" | "full_replace",\n "ops": [\n' + ' {"action": "ADD", "new_row": {"name": "...", "price": число, "qty": число, "sum": число, "date_start": "YYYY-MM-DD"}, "comment": "..."},\n' + ' {"action": "UPDATE", "target_id": "rN", "new_values": {"price": число}, "comment": "..."},\n' + ' {"action": "DELETE", "target_id": "rN", "comment": "..."},\n' + ' {"action": "UNRESOLVED", "new_values": {"name": "...", "price": число, ...}, "reason": "почему не смог сопоставить"}\n' + " ]\n}\n\n" + "ПРАВИЛА:\n" + '1. mode = "full_replace" — если в тексте есть фразы: «в следующей редакции», «заменить приложение», «излагается в следующей редакции». При full_replace — опиши ВСЕ новые строки как ADD.\n' + '2. mode = "partial" — если ДС меняет только отдельные строки.\n' + "3. Для UPDATE/DELETE — укажи target_id (r1, r2...) из списка текущей спецификации. НЕ придумывай новые id.\n" + "4. new_values в UPDATE — только ИЗМЕНЁННЫЕ поля (не все).\n" + "5. Если не можешь однозначно сопоставить строку — action: UNRESOLVED с reason.\n" + "6. Пропускай итоговые строки и подписи.\n" + "7. price, qty, sum — ЧИСЛА (не строки).\n\n" + "ТЕКУЩАЯ СПЕЦИФИКАЦИЯ:\n{spec_current}\n\n" + "ТЕКСТ ДОПСОГЛАШЕНИЯ:\n---\n{doc_text}\n---" + ) + execute( + "INSERT INTO prompts (role, name, body, is_active, notes) VALUES (%s, %s, %s, %s, %s)", + ("diff", "default-v1", diff_body, True, "Авто-создан из llm_prompt.py _build_diff"), + ) + _ensure_classify_prompt() + + +def list_by_role(role): + """List all versions for a role, newest first.""" + rows = query( + "SELECT id, role, name, is_active, created_by, notes, created_at FROM prompts WHERE role=%s ORDER BY created_at DESC", + (role,), + ) + for r in rows: + if r.get("created_at"): + r["created_at"] = str(r["created_at"]) + return rows + + +def save_new_version(role, name, body, notes="", is_active=True): + """Save new prompt version. Deactivates all others for this role, inserts new one.""" + if is_active: + execute("UPDATE prompts SET is_active=false WHERE role=%s", (role,)) + return execute_returning( + """INSERT INTO prompts (role, name, body, is_active, notes) + VALUES (%s, %s, %s, %s, %s) RETURNING *""", + (role, name, body, is_active, notes), + ) + + +def activate(prompt_id): + """Activate a prompt version (deactivates others for same role).""" + row = query("SELECT role FROM prompts WHERE id=%s", (prompt_id,)) + if not row: + return False + role = row[0]["role"] + execute("UPDATE prompts SET is_active=false WHERE role=%s", (role,)) + execute("UPDATE prompts SET is_active=true WHERE id=%s", (prompt_id,)) + return True + + +def delete_prompt(prompt_id): + """Delete a prompt version (cannot delete active one).""" + row = query("SELECT is_active FROM prompts WHERE id=%s", (prompt_id,)) + if not row: + return False + if row[0]["is_active"]: + return False # cannot delete active + execute("DELETE FROM prompts WHERE id=%s", (prompt_id,)) + return True + + +def _ensure_classify_prompt(): + """Ensure classify prompt exists (idempotent).""" + existing = query("SELECT id FROM prompts WHERE role = 'classify' AND is_active = true LIMIT 1") + if existing: + return + body = ( + "Ты — система классификации договорных документов. " + "Проанализируй текст и верни СТРОГО ВАЛИДНЫЙ JSON ОДНОЙ СТРОКОЙ " + "(без переносов строк, без markdown, без лишних пробелов в начале/конце).\n\n" + "Поля:\n" + '- doc_type: "contract" (договор) / "supplement" (допсоглашение) / "specification" (спецификация/приложение) / "other"\n' + "- own_number: номер ЭТОГО документа, строка без лишних пробелов (или null)\n" + '- parent_number: номер родительского договора из фразы «к Договору №...» (или null)\n' + '- doc_date: дата в YYYY-MM-DD. «27 февраля 2026» → 2026-02-27 (или null)\n' + "- counterparty: полное название контрагента (Заказчик/Арендатор), без сокращений (или null)\n\n" + "Пример вывода:\n" + '{"doc_type":"supplement","own_number":"1","parent_number":"01300_2","doc_date":"2026-02-27","counterparty":"АО XXX003"}\n\n' + "ДОКУМЕНТ:\n---\n{header_text}\n---" + ) + execute( + "INSERT INTO prompts (role, name, body, is_active, notes) VALUES (%s, %s, %s, %s, %s)", + ("classify", "default-v1", body, True, "Авто-создан: classify prompt v2 — LLM сам предложил"), + ) diff --git a/deploy-lucee/db/spec_current.py b/deploy-lucee/db/spec_current.py new file mode 100644 index 0000000..63e9de3 --- /dev/null +++ b/deploy-lucee/db/spec_current.py @@ -0,0 +1,19 @@ +"""spec_current — текущее состояние спецификации.""" +from .connection import query + + +def list_by_contract(contract_id): + """Return list of dicts with name_hash, name, price, qty, sum, date_start.""" + return query( + """SELECT name_hash, name, price, qty, sum, date_start + FROM spec_current WHERE contract_id = %s ORDER BY name""", + (contract_id,), + ) + + +def get_elements_json(document_id): + """Get elements_json for a document.""" + rows = query( + "SELECT elements_json FROM documents WHERE id = %s", (document_id,) + ) + return rows[0]["elements_json"] if rows and rows[0]["elements_json"] else None diff --git a/deploy-lucee/db/spec_events.py b/deploy-lucee/db/spec_events.py new file mode 100644 index 0000000..e23081e --- /dev/null +++ b/deploy-lucee/db/spec_events.py @@ -0,0 +1,160 @@ +"""spec_events — event sourcing: apply ops, reset contract.""" +import json, uuid +from .connection import query, execute, get_pool + + +def reset(contract_id): + """Clear spec_events + spec_current for contract.""" + execute("DELETE FROM spec_current WHERE contract_id = %s", (contract_id,)) + execute("DELETE FROM spec_events WHERE contract_id = %s", (contract_id,)) + + +def get_next_seq(contract_id): + """Get next sequence number with row lock to prevent race conditions.""" + pool = get_pool() + conn = pool.getconn() + try: + conn.autocommit = False + with conn.cursor() as cur: + cur.execute( + "SELECT seq FROM spec_events WHERE contract_id = %s ORDER BY seq DESC LIMIT 1 FOR UPDATE", + (contract_id,), + ) + row = cur.fetchone() + seq = (row[0] + 1) if row else 1 + conn.commit() + return seq + except Exception: + conn.rollback() + raise + finally: + conn.autocommit = True + pool.putconn(conn) + + +def apply_ops(contract_id, supplement_id, document_id, ops, prompt_id, raw_llm_response): + """Apply ADD/UPDATE/DELETE ops. Returns summary dict.""" + added = 0 + updated = 0 + deleted = 0 + seq = get_next_seq(contract_id) + + for op in ops: + action = op.get("action", "").upper() + + if action == "ADD": + nr = op.get("new_row", {}) + name = nr.get("name", "") + name_hash = _hash(name) + execute( + """INSERT INTO spec_events (contract_id, supplement_id, seq, action, target_hash, + new_values, comment, status, prompt_version, source_document_id, raw_llm_response) + VALUES (%s, %s, %s, 'ADD', %s, %s, %s, 'applied', %s, %s, %s)""", + ( + contract_id, supplement_id, seq, name_hash, + json.dumps(nr, ensure_ascii=False), + op.get("comment", ""), prompt_id, document_id, + json.dumps(raw_llm_response, ensure_ascii=False), + ), + ) + _upsert_spec_current(contract_id, name_hash, nr) + seq += 1 + added += 1 + + elif action == "UPDATE": + nv = op.get("new_values", {}) + th = op.get("target_hash", "") + execute( + """INSERT INTO spec_events (contract_id, supplement_id, seq, action, target_hash, + new_values, comment, status, prompt_version, source_document_id, raw_llm_response) + VALUES (%s, %s, %s, 'UPDATE', %s, %s, %s, 'applied', %s, %s, %s)""", + ( + contract_id, supplement_id, seq, th, + json.dumps(nv, ensure_ascii=False), + op.get("comment", ""), prompt_id, document_id, + json.dumps(raw_llm_response, ensure_ascii=False), + ), + ) + _update_spec_current(contract_id, th, nv) + seq += 1 + updated += 1 + + elif action == "DELETE": + th = op.get("target_hash", "") + execute( + """INSERT INTO spec_events (contract_id, supplement_id, seq, action, target_hash, + new_values, comment, status, prompt_version, source_document_id, raw_llm_response) + VALUES (%s, %s, %s, 'DELETE', %s, %s, %s, 'applied', %s, %s, %s)""", + ( + contract_id, supplement_id, seq, th, + json.dumps({}), op.get("comment", ""), + prompt_id, document_id, + json.dumps(raw_llm_response, ensure_ascii=False), + ), + ) + execute("DELETE FROM spec_current WHERE contract_id = %s AND name_hash = %s", (contract_id, th)) + seq += 1 + deleted += 1 + + elif action == "UNRESOLVED": + # Log but don't apply + execute( + """INSERT INTO spec_events (contract_id, supplement_id, seq, action, target_hash, + new_values, comment, status, prompt_version, source_document_id, raw_llm_response) + VALUES (%s, %s, %s, 'UNRESOLVED', %s, %s, %s, 'unresolved', %s, %s, %s)""", + ( + contract_id, supplement_id, seq, + op.get("target_hash", ""), + json.dumps(op.get("new_values", {}), ensure_ascii=False), + op.get("reason", op.get("comment", "")), + prompt_id, document_id, + json.dumps(raw_llm_response, ensure_ascii=False), + ), + ) + seq += 1 + + return {"added": added, "updated": updated, "deleted": deleted} + + +def _hash(name): + import hashlib + return hashlib.sha256(name.strip().lower().encode()).hexdigest()[:16] + + +def _upsert_spec_current(contract_id, name_hash, row): + """INSERT or UPDATE spec_current.""" + existing = query( + "SELECT id FROM spec_current WHERE contract_id = %s AND name_hash = %s", + (contract_id, name_hash), + ) + if existing: + execute( + """UPDATE spec_current SET name=%s, price=%s, qty=%s, sum=%s, date_start=%s, updated_at=now() + WHERE contract_id=%s AND name_hash=%s""", + (row.get("name"), row.get("price"), row.get("qty"), row.get("sum"), + row.get("date_start"), contract_id, name_hash), + ) + else: + execute( + """INSERT INTO spec_current (contract_id, name_hash, name, price, qty, sum, date_start) + VALUES (%s, %s, %s, %s, %s, %s, %s)""", + (contract_id, name_hash, row.get("name"), row.get("price"), + row.get("qty"), row.get("sum"), row.get("date_start")), + ) + + +def _update_spec_current(contract_id, name_hash, new_values): + """Update specific fields in spec_current.""" + sets = [] + params = [] + for field in ("name", "price", "qty", "sum", "date_start"): + if field in new_values: + sets.append(f"{field} = %s") + params.append(new_values[field]) + if sets: + sets.append("updated_at = now()") + params.extend([contract_id, name_hash]) + execute( + f"UPDATE spec_current SET {', '.join(sets)} WHERE contract_id = %s AND name_hash = %s", + params, + ) diff --git a/deploy-lucee/db/supplements.py b/deploy-lucee/db/supplements.py new file mode 100644 index 0000000..41fa40e --- /dev/null +++ b/deploy-lucee/db/supplements.py @@ -0,0 +1,59 @@ +"""Supplements CRUD.""" +from .connection import query, execute, execute_returning + + +def insert(contract_id, document_id, supp_type="additional"): + return execute_returning( + """INSERT INTO supplements (contract_id, document_id, type) + VALUES (%s, %s, %s) RETURNING *""", + (contract_id, document_id, supp_type), + ) + + +def list_by_contract(contract_id): + """Supplements with parsed documents, ordered by created_at.""" + return query( + """SELECT s.id, s.type, s.document_id, d.filename + FROM supplements s + JOIN documents d ON s.document_id = d.id + WHERE s.contract_id = %s AND d.elements_json IS NOT NULL + ORDER BY s.created_at""", + (contract_id,), + ) + + +def get(supp_id): + rows = query("SELECT * FROM supplements WHERE id = %s", (supp_id,)) + return rows[0] if rows else None + + +def delete_by_document(contract_id, filename): + """Delete ALL supplements+documents by contract+filename (cascade: spec_events first). + Handles duplicates from previously failed uploads.""" + rows = query( + """SELECT s.id as sid, s.document_id FROM supplements s + JOIN documents d ON d.id = s.document_id + WHERE s.contract_id = %s AND d.filename = %s""", + (contract_id, filename), + ) + if not rows: + return False + + for r in rows: + # 1. Delete spec_current rows referencing this supplement's events + execute( + """DELETE FROM spec_current WHERE contract_id = %s + AND last_event_id IN (SELECT id FROM spec_events WHERE supplement_id = %s)""", + (contract_id, r["sid"]), + ) + # 2. Delete spec_events referencing this supplement + execute("DELETE FROM spec_events WHERE supplement_id = %s", (r["sid"],)) + # 3. Delete supplement + execute("DELETE FROM supplements WHERE id = %s", (r["sid"],)) + # 4. Delete document + execute("DELETE FROM documents WHERE id = %s", (r["document_id"],)) + return True + + +def delete(supp_id): + return execute("DELETE FROM supplements WHERE id = %s", (supp_id,)) diff --git a/deploy-lucee/files.js b/deploy-lucee/files.js new file mode 100644 index 0000000..a025050 --- /dev/null +++ b/deploy-lucee/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/deploy-lucee/groups.js b/deploy-lucee/groups.js new file mode 100644 index 0000000..bc164ab --- /dev/null +++ b/deploy-lucee/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/deploy-lucee/llm_prompt.py b/deploy-lucee/llm_prompt.py new file mode 100644 index 0000000..5731efd --- /dev/null +++ b/deploy-lucee/llm_prompt.py @@ -0,0 +1,279 @@ +"""llm_prompt.py — Формирование промпта для LLM-анализа ДС. +Читает активный промпт из БД напрямую (db.prompts). При ошибке — fallback на хардкод.""" + +import os +from db import prompts as db_prompts + +# ── Fallback-промпты (если БД недоступна) ────────────────────── + +FALLBACK_EXTRACT = """Ты — анализатор договоров облачного провайдера и ЦОД (дата-центра). +Ты разбираешь спецификации услуг colocation, аренды стоек, питания, каналов связи и облачных ресурсов. + +ЗАДАЧА: ниже текст спецификации услуг из ПЕРВОГО документа (базовый договор). +Извлеки ВСЕ строки спецификации, каждую как отдельную ADD-операцию. + +Верни СТРОГО JSON без пояснений. Не используй markdown-блоки, не добавляй текст до или после JSON. + +ФОРМАТ: +{{ + "mode": "partial", + "ops": [ + {{"action": "ADD", "new_row": {{"name": "полное наименование", "price": число, "qty": число, "sum": число, "date_start": "YYYY-MM-DD"}}, "comment": ""}} + ] +}} + +ДОМЕННЫЙ ГЛОССАРИЙ (для корректного разбора): +- Единицы измерения: + \u2022 кВт — мощность электропитания (номинальная/гарантированная). + \u2022 юнит, U — высота места в стойке (1U, 2U, 10U). + \u2022 шт. — счётные позиции (IP-адреса, кросс-соединения, порты). + \u2022 Мбит/с, Гбит/с — пропускная способность канала связи. + \u2022 ГБ, ТБ — объём диска/хранилища; vCPU — виртуальные ядра; RAM ГБ — память. +- Типичные услуги: + \u2022 «Стойко-место» / «Аренда стойко-места» / «Colocation» — размещение оборудования в стойке ЦОД. + \u2022 «Электропитание» / «Питание» — выделенная мощность в кВт. + \u2022 «IP-адрес» (IPv4/IPv6) — считается в шт. + \u2022 «Канал связи» / «Порт» / «Интернет» — пропускная способность. + \u2022 «Кросс-соединение» (cross-connect) — физическая коммутация, шт. + \u2022 «Облачные ресурсы» — vCPU, RAM, диск. +- Мощность и габариты часто входят В СОСТАВ названия услуги: + «Аренда стойко-места, в составе: Номинальная мощность – 10 кВт». Сохраняй такое название ЦЕЛИКОМ. + +ПРАВИЛА: +1. Извлеки КАЖДУЮ строку таблицы спецификации как отдельную ADD-операцию. +2. name — полное наименование услуги дословно, со всеми уточнениями (мощность, объём, кол-во в составе). Не сокращай. +3. Пропускай итоговые строки («Итого», «Всего», «НДС», «К оплате») и строки с подписями/реквизитами. +4. Если ячейка пустая или значение не указано — ставь null (НЕ пиши 0). +5. price, qty, sum — ЧИСЛА (без пробелов, без «руб.», точка как десятичный разделитель). «50 000,00 руб.» \u2192 50000. +6. date_start — дата начала оказания услуги в формате YYYY-MM-DD. Если в документе нет — null. +7. Не вычисляй и не «исправляй» суммы. Бери значения как в документе. + +ПРИМЕР: +Текст: «1. Аренда стойко-места, в составе: Номинальная мощность – 10 кВт — 1 шт. — 50 000,00 руб. — 50 000,00 руб. Дата начала: 01.01.2025 +2. IP-адрес IPv4 — 8 шт. — 300,00 руб. — 2 400,00 руб.» +Ответ: +{{ + "mode": "partial", + "ops": [ + {{"action": "ADD", "new_row": {{"name": "Аренда стойко-места, в составе: Номинальная мощность – 10 кВт", "price": 50000, "qty": 1, "sum": 50000, "date_start": "2025-01-01"}}, "comment": ""}}, + {{"action": "ADD", "new_row": {{"name": "IP-адрес IPv4", "price": 300, "qty": 8, "sum": 2400, "date_start": null}}, "comment": ""}} + ] +}} + +ТЕКСТ ДОКУМЕНТА: +--- +{doc_text} +---""" + +FALLBACK_DIFF = """Ты — анализатор допсоглашений (ДС) к договорам облачного провайдера и ЦОД. +У тебя есть ТЕКУЩАЯ спецификация услуг (с готовыми id строк) и текст нового ДС. +Задача — определить, какие изменения ДС вносит в текущую спецификацию. + +Верни СТРОГО JSON без пояснений. Не используй markdown-блоки, не добавляй текст до или после JSON. + +ФОРМАТ: +{{ + "mode": "partial" | "full_replace", + "ops": [ + {{"action": "ADD", "new_row": {{"name": "...", "price": число, "qty": число, "sum": число, "date_start": "YYYY-MM-DD"}}, "comment": "..."}}, + {{"action": "UPDATE", "target_id": "rN", "new_values": {{"price": число}}, "comment": "..."}}, + {{"action": "DELETE", "target_id": "rN", "comment": "..."}}, + {{"action": "UNRESOLVED", "new_values": {{"name": "...", "price": число}}, "reason": "почему не смог сопоставить"}} + ] +}} + +ДОМЕННЫЙ ГЛОССАРИЙ: +- Единицы: кВт (мощность), юнит/U (высота в стойке), шт. (IP, кросс-соединения, порты), Мбит/с\u00b7Гбит/с (канал), ГБ\u00b7ТБ\u00b7vCPU (облако). +- Услуги: стойко-место / colocation (размещение в стойке); электропитание / питание (мощность кВт); IP-адрес IPv4/IPv6 (шт.); канал связи / порт (пропускная способность); кросс-соединение (шт.); облачные ресурсы (vCPU/RAM/диск). +- Мощность/объём часто ВНУТРИ названия услуги: «Аренда стойко-места, в составе: Номинальная мощность – 10 кВт». + Если ДС меняет мощность (10 кВт \u2192 15 кВт) — это UPDATE той же строки, причём меняется и name, и, как правило, price/sum. + +СОПОСТАВЛЕНИЕ СТРОК: +1. Для UPDATE/DELETE укажи target_id (r1, r2\u2026) ИЗ списка текущей спецификации ниже. НЕ придумывай новые id. +2. Сопоставляй по СМЫСЛУ услуги, а не по точному совпадению символов. «Аренда стойко-места» = «Размещение оборудования в стойке» = одна услуга. Различие тире/пробелов/кавычек игнорируй. +3. new_values в UPDATE — ТОЛЬКО изменённые поля (не дублируй неизменные). +4. Если ДС увеличивает количество той же услуги (было 8 IP, стало 12) — это UPDATE qty (и sum), а не новая ADD. + +РЕЖИМ mode: +5. mode = "full_replace" — если ДС полностью переиздаёт приложение/спецификацию. Признаки: «Приложение \u2026 излагается в следующей редакции», «изложить в новой редакции», «заменить приложение \u2116\u2026». При full_replace опиши ВСЕ строки новой редакции как ADD (UPDATE/DELETE не используй). +6. mode = "partial" — если ДС точечно меняет отдельные позиции (изменить цену, добавить/удалить услугу, изменить мощность/кол-во). +7. Если в тексте есть и фраза о новой редакции, и точечные правки — приоритет за «новой редакцией»: full_replace. + +EDGE-CASES: +8. UNRESOLVED — если ДС упоминает изменение услуги, которой НЕТ в текущей спецификации, ИЛИ название настолько отличается, что нельзя уверенно сопоставить с конкретным id. ВАЖНО: если СОМНЕВАЕШЬСЯ в сопоставлении — делай UNRESOLVED, а НЕ ADD. Лучше unresolved, чем ложный дубликат. В reason укажи причину. +9. Частичные данные: если в ДС нет цены/кол-ва/даты — ставь null для этих полей, не выдумывай. +10. Пропускай итоговые строки («Итого», «НДС», «К оплате») и подписи/реквизиты. +11. price, qty, sum — ЧИСЛА (без «руб.», без пробелов; «55 000,00» \u2192 55000). Не пересчитывай суммы сам — бери из ДС. +12. date_start — YYYY-MM-DD; используй дату вступления изменения в силу из ДС, если она указана. + +ПРИМЕР 1 (partial, UPDATE цены): +Текущая спецификация: +[id: r1] Аренда стойко-места, в составе: Номинальная мощность – 10 кВт | цена=50000 | объём=1 | сумма=50000 | начало=2025-01-01 +[id: r2] IP-адрес IPv4 | цена=300 | объём=8 | сумма=2400 | начало=2025-01-01 +Текст ДС: «С 01.03.2025 стоимость аренды стойко-места устанавливается в размере 55 000,00 руб. в месяц.» +Ответ: +{{ + "mode": "partial", + "ops": [ + {{"action": "UPDATE", "target_id": "r1", "new_values": {{"price": 55000, "sum": 55000, "date_start": "2025-03-01"}}, "comment": "Изменение стоимости аренды стойко-места"}} + ] +}} + +ПРИМЕР 2 (partial: ADD новая услуга + UPDATE количества + UPDATE мощности): +Текущая спецификация: +[id: r1] Аренда стойко-места, в составе: Номинальная мощность – 10 кВт | цена=50000 | объём=1 | сумма=50000 | начало=2025-01-01 +[id: r2] IP-адрес IPv4 | цена=300 | объём=8 | сумма=2400 | начало=2025-01-01 +Текст ДС: «С 01.04.2025: 1) увеличить номинальную мощность стойко-места до 15 кВт, стоимость — 70 000,00 руб.; +2) предоставить дополнительно 4 IP-адреса IPv4 (итого 12 шт., сумма 3 600,00 руб.); +3) предоставить услугу "Кросс-соединение" — 2 шт. по 1 500,00 руб., сумма 3 000,00 руб.» +Ответ: +{{ + "mode": "partial", + "ops": [ + {{"action": "UPDATE", "target_id": "r1", "new_values": {{"name": "Аренда стойко-места, в составе: Номинальная мощность – 15 кВт", "price": 70000, "sum": 70000, "date_start": "2025-04-01"}}, "comment": "Увеличение мощности 10\u219215 кВт"}}, + {{"action": "UPDATE", "target_id": "r2", "new_values": {{"qty": 12, "sum": 3600, "date_start": "2025-04-01"}}, "comment": "Увеличение количества IP-адресов 8\u219212"}}, + {{"action": "ADD", "new_row": {{"name": "Кросс-соединение", "price": 1500, "qty": 2, "sum": 3000, "date_start": "2025-04-01"}}, "comment": "Новая услуга"}} + ] +}} + +ПРИМЕР 3 (full_replace): +Текущая спецификация: +[id: r1] Аренда стойко-места, в составе: Номинальная мощность – 10 кВт | цена=50000 | объём=1 | сумма=50000 | начало=2025-01-01 +[id: r2] IP-адрес IPv4 | цена=300 | объём=8 | сумма=2400 | начало=2025-01-01 +Текст ДС: «Приложение №1 (Спецификация услуг) излагается в следующей редакции: +1. Аренда стойко-места, номинальная мощность 15 кВт — 1 шт. — 70 000,00 руб. +2. IP-адрес IPv4 — 12 шт. — 300,00 руб. — 3 600,00 руб. +3. Канал связи 1 Гбит/с — 1 шт. — 20 000,00 руб. Дата: 01.05.2025» +Ответ: +{{ + "mode": "full_replace", + "ops": [ + {{"action": "ADD", "new_row": {{"name": "Аренда стойко-места, номинальная мощность 15 кВт", "price": 70000, "qty": 1, "sum": 70000, "date_start": "2025-05-01"}}, "comment": "Новая редакция приложения"}}, + {{"action": "ADD", "new_row": {{"name": "IP-адрес IPv4", "price": 300, "qty": 12, "sum": 3600, "date_start": "2025-05-01"}}, "comment": "Новая редакция приложения"}}, + {{"action": "ADD", "new_row": {{"name": "Канал связи 1 Гбит/с", "price": 20000, "qty": 1, "sum": 20000, "date_start": "2025-05-01"}}, "comment": "Новая редакция приложения"}} + ] +}} + +ПРИМЕР 4 (UNRESOLVED): +Текущая спецификация: +[id: r1] Аренда стойко-места, в составе: Номинальная мощность – 10 кВт | цена=50000 | объём=1 | сумма=50000 | начало=2025-01-01 +Текст ДС: «Снизить стоимость услуги резервного копирования до 4 000,00 руб.» +Ответ: +{{ + "mode": "partial", + "ops": [ + {{"action": "UNRESOLVED", "new_values": {{"name": "Резервное копирование", "price": 4000}}, "reason": "В текущей спецификации нет услуги резервного копирования — не с чем сопоставить"}} + ] +}} + +ТЕКУЩАЯ СПЕЦИФИКАЦИЯ: +{spec_current} + +ТЕКСТ ДОПСОГЛАШЕНИЯ: +--- +{doc_text} +---""" + + +def _fetch_prompt(role: str) -> dict | None: + """Получить активный промпт из БД напрямую (а не через Lucee HTTP).""" + try: + row = db_prompts.get_active(role) + if row and row.get("body"): + return {"id": row.get("id", ""), "body": row["body"]} + except Exception: + pass + return None + + +def _build_spec_text(current_spec: list) -> str: + """Перечисление строк спецификации для подстановки в {spec_current}.""" + lines = [] + for i, r in enumerate(current_spec): + lines.append( + f"[id: r{i+1}] {r.get('name', '?')} | " + f"цена={r.get('price', '')} | объём={r.get('qty', '')} | " + f"сумма={r.get('sum', '')} | начало={r.get('date_start', '')}" + ) + return "\n".join(lines) + + +def build_prompt(current_spec: list, doc_text: str) -> tuple: + """ + Формирует промпт для LLM. + 1. Пробует получить активный промпт из БД (Lucee API). + 2. При неудаче — fallback на хардкод. + Возвращает (текст_промпта, prompt_id). + prompt_id — UUID версии промпта из БД, или "" если fallback. + """ + is_first = len(current_spec) == 0 + role = "extract" if is_first else "diff" + + db = _fetch_prompt(role) + if db: + template = db["body"] + prompt_id = db.get("id", "") + else: + template = FALLBACK_EXTRACT if is_first else FALLBACK_DIFF + prompt_id = "" + + # Подстановка плейсхолдеров + result = template.replace("{doc_text}", doc_text) + result = result.replace("{spec_current}", _build_spec_text(current_spec)) + + return result, prompt_id + + +def build_classify_prompt(header_text): + """Build classify prompt. Returns (prompt, prompt_id).""" + from db import prompts as db_prompts + prompt = db_prompts.get_active("classify") + if prompt: + body = prompt["body"].replace("{header_text}", header_text) + return body, prompt.get("id", "") + # Fallback + body = """Ты — классификатор договорных документов облачного провайдера НУБЕС. + +Ниже фрагмент текста документа. Определи: + +1. doc_type: + - "contract" — договор (заголовок «Договор», «Соглашение», преамбула с условиями) + - "supplement" — допсоглашение (ссылается на родительский договор, меняет условия) + - "specification" — спецификация / приложение с таблицей услуг (стойко-места, IP, каналы, питание) + - "other" — НЕ договорной документ: акт сверки, счёт, счёт-фактура, УПД, акт оказанных услуг, платёжное поручение, доверенность, письмо + +2. own_number — номер ЭТОГО документа (например «XXX001-03700», «МЭС-123/2024», «1» для допника). + Если номер не указан — null. + +3. parent_number — номер родительского договора (для supplement и specification). + Для doc_type="contract": ВСЕГДА null. + +4. doc_date — дата документа в формате YYYY-MM-DD. Если дата прописью — переведи в цифры. + Если нет даты — null. + +5. counterparty — название КОНТРАГЕНТА (Заказчика). + ВАЖНО: НУБЕС — всегда Исполнитель. НЕ возвращай НУБЕС как counterparty. + НУБЕС известен как: «НУБЕС», «ООО НУБЕС», «ООО "НУБЕС"», «Nubes». + counterparty — ВСЕГДА другая сторона (Заказчик/Покупатель/Абонент). + Если документ не содержит контрагента — null. + +Верни СТРОГО JSON без пояснений: +{"doc_type":"...","own_number":"...","parent_number":"...","doc_date":"...","counterparty":"..."} + +ПРИМЕР 1 (договор): +Текст: «Договор № XXX001-03700 от 15.03.2025. ООО "НУБЕС" (Исполнитель) и ЗАО "ТехноПлюс" (Заказчик)...» +Ответ: {"doc_type":"contract","own_number":"XXX001-03700","parent_number":null,"doc_date":"2025-03-15","counterparty":"ЗАО \"ТехноПлюс\""} + +ПРИМЕР 2 (допсоглашение): +Текст: «Допсоглашение №1 к Договору № XXX003-01300 от 05.06.2024...» +Ответ: {"doc_type":"supplement","own_number":"1","parent_number":"XXX003-01300","doc_date":"2024-06-05","counterparty":"АО XXX003"} + +ПРИМЕР 3 (мусор): +Текст: «Акт сверки взаимных расчётов за 1 квартал 2025 г. Стороны: НУБЕС и ООО Ромашка. Сальдо 150 000 руб.» +Ответ: {"doc_type":"other","own_number":null,"parent_number":null,"doc_date":"2025-03-31","counterparty":"ООО Ромашка"} + +ДОКУМЕНТ: +--- +{header_text} +---""".replace("{header_text}", header_text) + return body, "" diff --git a/deploy-lucee/nginx-contracts.conf b/deploy-lucee/nginx-contracts.conf new file mode 100644 index 0000000..d65cce0 --- /dev/null +++ b/deploy-lucee/nginx-contracts.conf @@ -0,0 +1,100 @@ +server { + server_name contracts.kube5s.ru; + + client_max_body_size 100M; + + location / { + proxy_pass http://127.0.0.1:5001; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_read_timeout 120s; + } + + location /static/ { + proxy_pass http://127.0.0.1:8766; + proxy_read_timeout 10s; + } + + location /api/ { + proxy_pass http://127.0.0.1:8766; + proxy_read_timeout 30s; + } + + location /lucee/ { + rewrite ^/lucee/(.*) /$1 break; + proxy_pass https://contractor.luceek8s.dev.nubes.ru; + proxy_set_header Host contractor.luceek8s.dev.nubes.ru; + proxy_set_header X-Real-IP $remote_addr; + proxy_read_timeout 600s; + proxy_buffering off; + client_max_body_size 100m; + } + + location /convert-doc { + proxy_pass http://127.0.0.1:8766; + proxy_read_timeout 120s; + client_max_body_size 50m; + } + + location /llm-ops { + proxy_pass http://127.0.0.1:8766; + proxy_read_timeout 130s; + } + + location /process-v2 { + proxy_pass http://127.0.0.1:8766; + proxy_read_timeout 600s; + proxy_buffering off; + add_header Access-Control-Allow-Origin "*" always; + add_header Access-Control-Allow-Methods "GET, OPTIONS" always; + } + + location /parse-pdf { + proxy_pass http://127.0.0.1:8766; + proxy_read_timeout 60s; + } + + location /upload { + if ($request_method = OPTIONS) { + add_header Access-Control-Allow-Origin "*"; + add_header Access-Control-Allow-Methods "POST, OPTIONS"; + add_header Access-Control-Allow-Headers "*"; + add_header Content-Length 0; + return 204; + } + proxy_pass http://127.0.0.1:8766; + proxy_read_timeout 300s; + client_max_body_size 100m; + } + + location /unzip-upload { + if ($request_method = OPTIONS) { + add_header Access-Control-Allow-Origin "*"; + add_header Access-Control-Allow-Methods "POST, OPTIONS"; + add_header Access-Control-Allow-Headers "*"; + add_header Content-Length 0; + return 204; + } + proxy_pass http://127.0.0.1:8766; + client_max_body_size 100m; + } + + listen 443 ssl; # managed by Certbot + ssl_certificate /etc/letsencrypt/live/contracts.kube5s.ru/fullchain.pem; # managed by Certbot + ssl_certificate_key /etc/letsencrypt/live/contracts.kube5s.ru/privkey.pem; # managed by Certbot + include /etc/letsencrypt/options-ssl-nginx.conf; # managed by Certbot + ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem; # managed by Certbot + +} +server { + if ($host = contracts.kube5s.ru) { + return 301 https://$host$request_uri; + } # managed by Certbot + + + listen 80; + server_name contracts.kube5s.ru; + return 404; # managed by Certbot + + +} diff --git a/deploy-lucee/services/__init__.py b/deploy-lucee/services/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/deploy-lucee/services/classify.py b/deploy-lucee/services/classify.py new file mode 100644 index 0000000..137e6ee --- /dev/null +++ b/deploy-lucee/services/classify.py @@ -0,0 +1,257 @@ +""" +Classify service — LLM-based document classification. + +Архитектурное решение (Opus): +- Отдельный сервис, не встроен в upload. Upload быстрый (0.5с), classify — медленный (2-10с/файл). +- ThreadPoolExecutor(max_workers=4) — параллельная классификация с ограничением конкурентности, + чтобы не положить api.aillm.ru при 2000 файлах. +- Умная выжимка (_smart_extract): header ~1500 симв + regex-хиты по маркерам (договор/№/соглашение) + из всего документа. Экономия токенов в 5-10 раз при сохранении точности. +- Двухпроходная архитектура: LLM извлекает строки (тип/номер/дата/контрагент), + Python в grouping.py нормализует и группирует детерминированно. +""" +import json, re, os +from concurrent.futures import ThreadPoolExecutor, as_completed + +import httpx +from db import documents as db_docs +from llm_prompt import build_classify_prompt + +log = __import__("logging").getLogger(__name__) + +# Лимит одновременных запросов к LLM API +# Увеличивать осторожно — api.aillm.ru может троттлить +MAX_WORKERS = 4 + +LLM_URL = "https://api.aillm.ru/v1/chat/completions" +LLM_KEY = os.environ.get("LLM_KEY") or os.environ.get("LLM_API_KEY", "") +LLM_MODEL = "gpt-oss-120b" + +# ── Garbage filter (Stage 1: filename regex) ──────────────────── +_GARBAGE_FILENAME_RE = re.compile( + r'(сч[её]т|акт|плат[её]ж|УПД|сверк|инвойс|invoice|payment|act)', + re.IGNORECASE, +) + +# ── Garbage filter (Stage 2: header keywords) ─────────────────── +_GARBAGE_HEADER_MARKERS = [ + 'СЧЕТ-ФАКТУРА', 'СЧЕТ НА ОПЛАТУ', 'АКТ СВЕРКИ', + 'АКТ ОКАЗАННЫХ УСЛУГ', 'АКТ ВЫПОЛНЕННЫХ РАБОТ', + 'ПЛАТЁЖНОЕ ПОРУЧЕНИЕ', 'УНИВЕРСАЛЬНЫЙ ПЕРЕДАТОЧНЫЙ', + 'УПД', 'ПЛАТЕЖНОЕ ПОРУЧЕНИЕ', +] + + +def _is_garbage_by_filename(filename: str) -> bool: + """Stage 1: regex по имени файла — быстро, 0 токенов.""" + return bool(_GARBAGE_FILENAME_RE.search(filename)) + + +def _is_garbage_by_header(text: str) -> bool: + """Stage 2: ключевые слова в первых 2KB текста — быстро, 0 токенов.""" + header = text[:2000].upper() + return any(marker in header for marker in _GARBAGE_HEADER_MARKERS) + + +def _call_llm_classify(header_text): + """ + Прямой вызов LLM для классификации ОДНОГО документа. + Возвращает (parsed_dict, raw_text, needed_fix). + """ + prompt, _ = build_classify_prompt(header_text) + payload = { + "model": LLM_MODEL, + "messages": [{"role": "user", "content": prompt}], + "max_tokens": 1000, + "temperature": 0.1, + } + with httpx.Client(http2=True, timeout=60) as client: + resp = client.post( + LLM_URL, json=payload, + headers={"Authorization": f"Bearer {LLM_KEY}", "Content-Type": "application/json"}, + ) + resp.raise_for_status() + data = resp.json() + + raw = data.get("choices", [{}])[0].get("message", {}).get("content", "") + parsed, needed_fix = _safe_json_parse(raw) + return parsed, raw, needed_fix + + +def classify_batch(batch_id): + """ + Классифицировать все документы в batch. + Сбрасывает статус на 'pending' для всех перед началом. + Параллельно (ThreadPoolExecutor) обрабатывает до MAX_WORKERS документов. + Возвращает {ok, total, done, failed}. + """ + # Сбросить статус — allow re-classify after file changes + db_docs.reset_classify_status(batch_id) + pending = db_docs.list_pending(batch_id) + if not pending: + return {"ok": False, "error": "no pending documents"} + + total = len(pending) + done = 0 + failed = 0 + garbage = 0 + json_fixes = 0 + json_total = 0 + + def _classify_one(doc): + """Классифицировать один документ: фильтр → выжимка → LLM → сохранить.""" + nonlocal garbage, json_fixes, json_total + try: + # Stage 1: garbage by filename (0 tokens) + if _is_garbage_by_filename(doc["filename"]): + db_docs.set_classify_garbage(doc["id"], "filename_regex") + garbage += 1 + return True + + # Stage 2: garbage by header keywords (0 tokens) + text = _smart_extract(doc["elements_json"]) + if _is_garbage_by_header(text): + db_docs.set_classify_garbage(doc["id"], "header_keywords") + garbage += 1 + return True + + # Stage 3: LLM classification (only for remaining) + db_docs.set_classify_processing(doc["id"]) # crash recovery marker + result, raw, needed_fix = _call_llm_classify(text) + json_total += 1 + if needed_fix: + json_fixes += 1 + db_docs.set_classification( + doc["id"], + result.get("doc_type", "other"), + result.get("own_number"), + result.get("parent_number"), + result.get("doc_date"), + result.get("counterparty"), + classify_raw=raw, + classify_input=text, + ) + return True + except Exception as e: + db_docs.set_classify_failed(doc["id"], str(e)) + return False + + with ThreadPoolExecutor(max_workers=MAX_WORKERS) as pool: + futures = {pool.submit(_classify_one, d): d for d in pending} + for f in as_completed(futures): + if f.result(): + done += 1 + else: + failed += 1 + + return {"ok": True, "total": total, "done": done, "failed": failed, + "garbage": garbage, "json_fix_rate": round(json_fixes / max(json_total, 1), 3)} + + +def _safe_json_parse(raw): + """Parse LLM response, fixing common JSON errors. + Returns (parsed_dict, needed_fix: bool).""" + if not raw: + raise ValueError("empty LLM response") + + text = raw.strip() + # Strip markdown + if "```json" in text: + text = text.split("```json")[1].split("```")[0].strip() + elif "```" in text: + text = text.split("```")[1].split("```")[0].strip() + + # Remove non-JSON prefix/suffix (LLM chatter) + brace_start = text.find("{") + brace_end = text.rfind("}") + if brace_start >= 0 and brace_end > brace_start: + text = text[brace_start:brace_end + 1] + + # Try strict parse + try: + return json.loads(text), False + except json.JSONDecodeError: + pass + + import re as _re + # Collapse multiline + text = _re.sub(r"\n\s*", " ", text) + # Remove trailing commas + text = _re.sub(r",\s*}", "}", text) + text = _re.sub(r",\s*]", "]", text) + + # Try again + try: + return json.loads(text), True + except json.JSONDecodeError: + pass + + # Aggressive: try adding missing closing quotes/braces + text = text.rstrip() + if not text.endswith("}"): + # Count unclosed quotes + in_string = False + for i, ch in enumerate(text): + if ch == '"' and (i == 0 or text[i-1] != "\\"): + in_string = not in_string + if in_string: + text += '"' + text += "}" + + return json.loads(text), True + + +def _smart_extract(elements_json): + """ + Умная выжимка текста для классификации (решение Q3 от Opus). + + Вместо отправки всего документа (дорого) или только header (теряет зарытые номера), + используется гибрид: + 1. Первые ~1500 симв (титул, преамбула, стороны) + 2. Regex-хиты по маркерам «договор|№|соглашение|приложение|спецификация» + из ВСЕГО документа + 3. Дедупликация, лимит 10 строк, склейка → ~3000 симв на вход LLM + + Это покрывает и титульную зону, и зарытые ссылки в середине документа. + """ + if not elements_json: + return "" + + if isinstance(elements_json, str): + try: + elements = json.loads(elements_json) + except json.JSONDecodeError: + return elements_json[:2000] + elif isinstance(elements_json, list): + elements = elements_json + else: + return str(elements_json)[:2000] + + # Build full text + lines = [] + for el in elements: + if isinstance(el, dict): + t = el.get("type") or el.get("TYPE", "") + if t == "paragraph": + txt = el.get("text") or el.get("TEXT", "") + if txt: + lines.append(txt) + elif t == "table": + rows = el.get("rows") or el.get("ROWS", []) + for row in rows: + lines.append(" | ".join(str(c) for c in row)) + + full_text = "\n".join(lines) + + # Header: first ~1500 chars + header = full_text[:1500] + + # Marker lines: grep for key patterns + markers = re.findall( + r'.{0,200}(?:договор|№|соглашен|приложен|специф|контрагент|заказчик|арендатор).{0,200}', + full_text, re.IGNORECASE, + ) + unique_markers = list(dict.fromkeys(markers))[:10] + + combined = header + "\n---\n" + "\n".join(unique_markers) + return combined[:3000] diff --git a/deploy-lucee/services/grouping.py b/deploy-lucee/services/grouping.py new file mode 100644 index 0000000..58a15aa --- /dev/null +++ b/deploy-lucee/services/grouping.py @@ -0,0 +1,160 @@ +""" +Grouping service — match classified documents into contract groups. + +Архитектурное решение (Opus, Q4 + Q6): +- Двухпроходный гибрид: LLM извлекает строки (classify.py), Python нормализует и группирует. +- Нормализация номеров: uppercase + только буквы/цифры. + "МЭС-123-2024" == "МЭС 123/2024" после нормализации. +- Группировка на бэкенде (не на фронте): Python regex/unicode надёжнее JS. +- apply_groups(): создаёт contracts + supplements с авто-порядком по дате. +""" +import re +from db import documents as db_docs +from db import contracts as db_contracts +from db import supplements as db_supplements + + +def normalize_number(num): + """ + Нормализация номера договора для сравнения. + Убирает всё кроме букв и цифр, приводит к uppercase. + Пример: "МЭС-123-2024" → "МЭС1232024", "МЭС 123/2024" → "МЭС1232024". + """ + if not num: + return "" + return re.sub(r"[^A-Z0-9А-Я]", "", num.upper()) + + +def group_documents(batch_id): + """ + Сгруппировать классифицированные документы по контрактам. + + Алгоритм: + 1. Отделить contract от supplement/specification + 2. Каждый contract → якорь группы + 3. Для каждого supplement: найти contract по parent_number (нормализованный) + 4. Оставшиеся supplement/spec → виртуальные группы по parent_number/own_number + 5. Совсем без номеров → группа "__unresolved__" + 6. Внутри группы сортировка по doc_date + + Возвращает {ok, groups: [{contract_number, counterparty, documents: [...]}], total_docs}. + """ + docs = db_docs.list_by_batch(batch_id) + classified = [d for d in docs if d.get("classify_status") == "classified"] + + # Separate contracts and supplements + contracts_list = [] + supplements_list = [] + + for d in classified: + if d.get("doc_type") == "contract": + contracts_list.append(d) + else: + supplements_list.append(d) + + groups = [] + + # Each contract becomes a group + for c in contracts_list: + group = { + "contract_number": c.get("own_number") or c.get("filename", ""), + "counterparty": c.get("counterparty") or "", + "documents": [c], + } + # Find supplements matching this contract + c_norm = normalize_number(c.get("own_number")) + for s in supplements_list: + parent = normalize_number(s.get("parent_number") or "") + own = normalize_number(s.get("own_number") or "") + if parent == c_norm or own == c_norm: + if s not in group["documents"]: + group["documents"].append(s) + + # Sort by date + group["documents"].sort(key=lambda x: x.get("doc_date") or "") + + # Remove matched supplements from the pool + for s in group["documents"]: + if s in supplements_list: + supplements_list.remove(s) + + groups.append(group) + + # ── Virtual groups: unmatched supplements grouped by number ────────── + # Group remaining supplements/specs by normalized parent_number (priority) or own_number + virtual = {} + for s in supplements_list: + num = normalize_number(s.get("parent_number") or s.get("own_number") or "") + if not num: + continue # no number → stays in supplements_list for unresolved + if num not in virtual: + # Use counterparty from first doc in group + cp = s.get("counterparty") or "" + virtual[num] = { + "contract_number": s.get("parent_number") or s.get("own_number") or "?", + "counterparty": cp, + "documents": [], + } + virtual[num]["documents"].append(s) + # Update counterparty if current doc has a better one + if not virtual[num]["counterparty"] and s.get("counterparty"): + virtual[num]["counterparty"] = s.get("counterparty") + + for vnum, vgroup in virtual.items(): + vgroup["documents"].sort(key=lambda x: x.get("doc_date") or "") + groups.append(vgroup) + # Remove grouped docs from supplements_list + for s in vgroup["documents"]: + supplements_list.remove(s) + + # ── Unresolved: everything left (no number, failed, pending, other) ── + unmatched = [s for s in supplements_list] # remaining after virtual grouping + unmatched += [d for d in docs if d.get("classify_status") != "classified"] + + if unmatched: + groups.append({ + "contract_number": "__unresolved__", + "counterparty": "", + "documents": unmatched, + }) + + return {"ok": True, "groups": groups, "total_docs": len(docs)} + + +def apply_groups(batch_id, groups_data): + """ + Применить подтверждённые группы: создать contracts + supplements. + + Вызывается из POST /api/apply-groups. + Для каждой группы (кроме __unresolved__): + 1. Создать запись в contracts (number, client) + 2. Для каждого документа создать supplement (type='initial' для первого, 'additional' для остальных) + 3. Порядок supplements соответствует порядку документов в группе (сортировка по дате уже сделана) + + Возвращает {ok, created: количество созданных supplements}. + """ + created = 0 + contract_ids = [] + for g in groups_data: + contract_number = g.get("contract_number", "") + if contract_number == "__unresolved__": + continue + counterparty = g.get("counterparty", "") + docs = g.get("documents", []) + + try: + c = db_contracts.insert(contract_number, counterparty) + contract_id = c["id"] + contract_ids.append(contract_id) + + for i, d in enumerate(docs): + doc_id = d.get("id") + if not doc_id: + continue + supp_type = "initial" if i == 0 else "additional" + db_supplements.insert(contract_id, doc_id, supp_type) + created += 1 + except Exception as e: + return {"ok": False, "error": f"apply_groups failed at {contract_number}: {e}"} + + return {"ok": True, "created": created, "contract_ids": contract_ids} diff --git a/deploy-lucee/services/llm.py b/deploy-lucee/services/llm.py new file mode 100644 index 0000000..abf73c1 --- /dev/null +++ b/deploy-lucee/services/llm.py @@ -0,0 +1,37 @@ +"""LLM service — call LLM API.""" +import os, json +import httpx + +LLM_URL = "https://api.aillm.ru/v1/chat/completions" +LLM_KEY = os.environ.get("LLM_KEY") or os.environ.get("LLM_API_KEY", "") +LLM_MODEL = "gpt-oss-120b" + + +def call_llm(current_spec, doc_text, build_prompt_fn): + """Call LLM. Returns (parsed_result, prompt_id).""" + prompt, prompt_id = build_prompt_fn(current_spec, doc_text) + payload = { + "model": LLM_MODEL, + "messages": [{"role": "user", "content": prompt}], + "max_tokens": 8000, + "temperature": 0.1, + } + with httpx.Client(http2=True, timeout=120, verify=True) as client: + resp = client.post( + LLM_URL, + json=payload, + headers={ + "Authorization": f"Bearer {LLM_KEY}", + "Content-Type": "application/json", + }, + ) + resp.raise_for_status() + data = resp.json() + + raw_text = data.get("choices", [{}])[0].get("message", {}).get("content", "") + json_text = raw_text + if "```json" in json_text: + json_text = json_text.split("```json")[1].split("```")[0] + elif "```" in json_text: + json_text = json_text.split("```")[1].split("```")[0] + return json.loads(json_text.strip()), prompt_id diff --git a/deploy-lucee/services/metrics.py b/deploy-lucee/services/metrics.py new file mode 100644 index 0000000..9a37f22 --- /dev/null +++ b/deploy-lucee/services/metrics.py @@ -0,0 +1,72 @@ +"""Metrics — quality signals without golden dataset. + +Checks that work immediately: +- Arithmetic: sum == price * qty (free signal of LLM/data errors) +- JSON fix rate: how often _safe_json_parse has to repair LLM output +""" +from decimal import Decimal, InvalidOperation + + +def check_arithmetic(ops: list) -> list[dict]: + """Check sum == price * qty for ADD/UPDATE operations. + Returns list of mismatches: [{action, name, price, qty, expected_sum, actual_sum, diff}] + """ + mismatches = [] + for op in ops: + action = op.get("action", "") + if action not in ("ADD", "UPDATE"): + continue + + row = op.get("new_row") or op.get("new_values") or {} + price = _to_decimal(row.get("price")) + qty = _to_decimal(row.get("qty")) + actual_sum = _to_decimal(row.get("sum")) + + if price is None or qty is None or actual_sum is None: + continue # can't check without all three + + expected = price * qty + if expected != actual_sum: + mismatches.append({ + "action": action, + "name": row.get("name", "")[:100], + "price": float(price), + "qty": float(qty), + "expected_sum": float(expected), + "actual_sum": float(actual_sum), + "diff": float(actual_sum - expected), + }) + return mismatches + + +class ClassifyMetrics: + """Track _safe_json_parse fix rate per batch.""" + def __init__(self): + self.total = 0 + self.fixes = 0 # how many times JSON needed repair + + def record(self, needed_fix: bool): + self.total += 1 + if needed_fix: + self.fixes += 1 + + @property + def fix_rate(self) -> float: + return self.fixes / self.total if self.total else 0.0 + + def summary(self) -> dict: + return { + "total_classifications": self.total, + "json_fixes": self.fixes, + "json_fix_rate": round(self.fix_rate, 3), + } + + +def _to_decimal(val) -> Decimal | None: + """Safe conversion to Decimal.""" + if val is None or val == "": + return None + try: + return Decimal(str(val)) + except (InvalidOperation, ValueError): + return None diff --git a/deploy-lucee/services/parse.py b/deploy-lucee/services/parse.py new file mode 100644 index 0000000..20cc37e --- /dev/null +++ b/deploy-lucee/services/parse.py @@ -0,0 +1,87 @@ +"""Parse service — PDF (pdfplumber), DOCX (python-docx), plain text fallback.""" +import io + + +def parse_file(filename, data): + """Parse file bytes → {status, elements, element_count}.""" + ext = filename.rsplit(".", 1)[-1].lower() if "." in filename else "" + try: + if ext == "pdf": + return _parse_pdf(data) + elif ext == "docx": + return _parse_docx(data) + elif ext == "doc": + return _parse_docx(data) # python-docx handles most .doc + else: + return _parse_text(data) + except Exception as e: + return {"status": "error", "error": str(e), "element_count": 0} + + +def _parse_pdf(data): + """Parse PDF with pdfplumber — preserves table structure (unlike PyPDF2).""" + import pdfplumber + elements = [] + with pdfplumber.open(io.BytesIO(data)) as pdf: + for page in pdf.pages: + # Tables first — preserves column structure critical for specs + tables = page.extract_tables() + for table in tables: + if table: + rows = [] + for row in table: + if row: + cells = [str(cell or "").strip() for cell in row] + if any(cells): + rows.append(cells) + if rows: + elements.append({"type": "table", "rows": rows}) + # Remaining text as paragraphs + text = page.extract_text() + if text: + for line in text.split("\n"): + line = line.strip() + if line: + elements.append({"type": "paragraph", "text": line, "style": ""}) + return {"status": "parsed", "element_count": len(elements), "elements": elements} + + +def _parse_docx(data): + import docx + doc = docx.Document(io.BytesIO(data)) + elements = [] + + for block in doc.element.body: + tag = block.tag.split("}")[-1] if "}" in block.tag else block.tag + if tag == "p": + text = _extract_paragraph_text(block) + if text: + elements.append({"type": "paragraph", "text": text, "style": ""}) + elif tag == "tbl": + rows = [] + for tr in block.findall(".//{http://schemas.openxmlformats.org/wordprocessingml/2006/main}tr"): + cells = [] + for tc in tr.findall(".//{http://schemas.openxmlformats.org/wordprocessingml/2006/main}tc"): + cell_text = "".join(t.text or "" for t in tc.findall(".//{http://schemas.openxmlformats.org/wordprocessingml/2006/main}t")) + cells.append(cell_text.strip()) + if cells: + rows.append(cells) + if rows: + elements.append({"type": "table", "rows": rows}) + + return {"status": "parsed", "element_count": len(elements), "elements": elements} + + +def _extract_paragraph_text(p_elem): + texts = [] + for t in p_elem.findall(".//{http://schemas.openxmlformats.org/wordprocessingml/2006/main}t"): + if t.text: + texts.append(t.text) + return "".join(texts).strip() + + +def _parse_text(data): + text = data.decode("utf-8", errors="ignore")[:5000] + lines = [l.strip() for l in text.split("\n") if l.strip()] + return {"status": "parsed", "element_count": len(lines), + "elements": [{"type": "paragraph", "text": l, "style": ""} for l in lines]} diff --git a/deploy-lucee/services/process.py b/deploy-lucee/services/process.py new file mode 100644 index 0000000..52e3f0d --- /dev/null +++ b/deploy-lucee/services/process.py @@ -0,0 +1,137 @@ +"""Process service — SSE pipeline: reset → supplements → LLM → apply.""" +import json, time, threading +from db import supplements, spec_current, spec_events +from .metrics import check_arithmetic + + +def run_pipeline(contract_id, order_ids, sse_send, build_prompt_fn): + """Run full SSE comparison pipeline. sse_send is a callback(dict).""" + t0 = time.time() + + # 0. Reset + spec_events.reset(contract_id) + + # 1. Get supplements with parsed documents + supps = supplements.list_by_contract(contract_id) + if order_ids: + order_list = [x.strip() for x in order_ids.split(",") if x.strip()] + order_map = {oid: i for i, oid in enumerate(order_list)} + supps.sort(key=lambda s: order_map.get(s["id"], 999999)) + + if not supps: + sse_send({"type": "error", "message": "Нет распарсенных файлов"}) + return + + from .llm import call_llm + + for s in supps: + sid = s["id"] + + # Current spec + cur = spec_current.list_by_contract(contract_id) + current_spec = [] + for r in cur: + current_spec.append({ + "hash": r["name_hash"], + "name": r["name"], + "price": float(r["price"]) if r.get("price") is not None else None, + "qty": float(r["qty"]) if r.get("qty") is not None else None, + "sum": float(r["sum"]) if r.get("sum") is not None else None, + "date_start": r["date_start"], + }) + + # Get elements_json + ej = spec_current.get_elements_json(s["document_id"]) + if not ej: + continue + + if isinstance(ej, dict) and "Value" in ej: + ej = ej["Value"] + if isinstance(ej, str): + elements = json.loads(ej) + elif isinstance(ej, list): + elements = ej + else: + elements = [] + + elements = [{k.lower(): v for k, v in el.items()} for el in elements] + doc_text = _elements_to_text(elements) + + sse_send({"type": "extract_start", "supplement_id": sid, "filename": s["filename"]}) + + # LLM call in thread with keepalive + t1 = time.time() + done = threading.Event() + result = [None] + error = [None] + + def do_llm(): + try: + result[0] = call_llm(current_spec, doc_text, build_prompt_fn) + except Exception as e: + error[0] = str(e) + finally: + done.set() + + t = threading.Thread(target=do_llm) + t.start() + + while not done.wait(15): + sse_send({"type": "keepalive"}) + + t.join() + elapsed = round(time.time() - t1, 1) + + if error[0]: + sse_send({"type": "extract_error", "supplement_id": sid, + "filename": s["filename"], "error": error[0], "time_s": elapsed}) + continue + + llm_result, prompt_id = result[0] + ops = llm_result.get("ops", []) + mode = llm_result.get("mode", "partial") + + # Enrich ops + id_map = {f"r{i+1}": r["hash"] for i, r in enumerate(current_spec)} + spec_names = {r["hash"]: r["name"] for r in current_spec} + for op in ops: + tid = op.get("target_id") + if tid and tid in id_map: + op["target_hash"] = id_map[tid] + th = op.get("target_hash") + if th and th in spec_names and not op.get("new_row", {}).get("name"): + op.setdefault("new_row", {})["name"] = spec_names[th] + + sse_send({"type": "llm_done", "supplement_id": sid, "filename": s["filename"], + "ops_count": len(ops), "mode": mode, "time_s": elapsed}) + + # Apply events + summary = spec_events.apply_ops( + contract_id, sid, s.get("document_id", ""), ops, prompt_id, llm_result + ) + # Arithmetic quality check (free signal, no golden dataset needed) + arith_mismatches = check_arithmetic(ops) + if arith_mismatches: + summary["arithmetic_mismatches"] = len(arith_mismatches) + sse_send({"type": "applied", "supplement_id": sid, "summary": summary, "ops": ops}) + + total_time = round(time.time() - t0, 1) + sse_send({"type": "done", "total_time_s": total_time}) + + +def _elements_to_text(elements): + lines = [] + for el in elements: + if el.get("type") == "paragraph": + prefix = f"[{el['style']}] " if el.get("style") else "" + lines.append(prefix + el.get("text", "")) + elif el.get("type") == "table": + rows = el.get("rows", []) + if rows: + ncols = len(rows[0]) + lines.append(f"--- Таблица ({len(rows)}×{ncols}) ---") + for row in rows: + cells = [str(c).replace("\n", " ").replace("|", "\\|") for c in row[:ncols]] + lines.append("| " + " | ".join(cells) + " |") + lines.append("") + return "\n".join(lines) diff --git a/deploy-lucee/services/unzip.py b/deploy-lucee/services/unzip.py new file mode 100644 index 0000000..b4b0b67 --- /dev/null +++ b/deploy-lucee/services/unzip.py @@ -0,0 +1,35 @@ +"""Unzip service — extract files from ZIP archive (no DB, no parse).""" +import zipfile, io, base64 + + +def handle_unzip(rfile, content_length): + """Parse ZIP upload, return list of extracted files as base64.""" + body = rfile.read(content_length) + + # Find file data in raw multipart + idx = body.find(b"\r\n\r\n") + if idx < 0: + return {"ok": False, "error": "invalid multipart"} + + raw = body[idx + 4:] + zip_start = raw.find(b"PK\x03\x04") + if zip_start < 0: + return {"ok": False, "error": "not a ZIP file"} + + zip_data = raw[zip_start:] + + files = [] + with zipfile.ZipFile(io.BytesIO(zip_data)) as zf: + for name in zf.namelist(): + if name.endswith("/"): + continue + data = zf.read(name) + ext = name.rsplit(".", 1)[-1].lower() if "." in name else "" + files.append({ + "filename": name, + "data_b64": base64.b64encode(data).decode(), + "ext": ext, + "size": len(data), + }) + + return {"ok": True, "files": files} diff --git a/deploy-lucee/services/upload.py b/deploy-lucee/services/upload.py new file mode 100644 index 0000000..852dfb4 --- /dev/null +++ b/deploy-lucee/services/upload.py @@ -0,0 +1,118 @@ +"""Upload service — accept file, store to DB, parse.""" +import uuid, base64, json, io, cgi, os +from db import documents, contracts, supplements +from .parse import parse_file + +MAX_FILE_SIZE = 100 * 1024 * 1024 # 100 MB + + +def handle_upload(rfile, content_type, content_length): + """Parse multipart upload, store in DB, return result dict.""" + # Validate content_length + try: + cl = int(content_length) + except (TypeError, ValueError): + return {"ok": False, "error": "invalid content-length"} + if cl <= 0: + return {"ok": False, "error": "empty request"} + if cl > MAX_FILE_SIZE: + return {"ok": False, "error": f"file too large (max {MAX_FILE_SIZE // 1024 // 1024}MB)"} + + body = rfile.read(cl) + + environ = { + "REQUEST_METHOD": "POST", + "CONTENT_TYPE": content_type, + "CONTENT_LENGTH": str(content_length), + } + fs = cgi.FieldStorage(fp=io.BytesIO(body), environ=environ, keep_blank_values=True) + + filename = None + file_data = None + contract_id = "" + + if "files" in fs: + item = fs["files"] + if isinstance(item, list): + item = item[0] + filename = os.path.basename(item.filename) if item.filename else None + if filename and (".." in filename or "/" in filename or "\\" in filename): + return {"ok": False, "error": "invalid filename"} + file_data = item.file.read() if hasattr(item, "file") else item.value + if isinstance(file_data, str): + file_data = file_data.encode("utf-8") + + if "contract_id" in fs: + contract_id = fs.getfirst("contract_id", "") + # Validate UUID + if contract_id: + try: + uuid.UUID(contract_id) + except (ValueError, AttributeError): + contract_id = "" + + batch_id = fs.getfirst("batch_id", None) if "batch_id" in fs else None + # Validate UUID + if batch_id: + try: + uuid.UUID(batch_id) + except (ValueError, AttributeError): + batch_id = None + + # zip_source — имя родительского ZIP-архива (для визуальной группировки) + zip_source = None + if "zip_source" in fs: + raw_zip = fs.getfirst("zip_source", "") + if raw_zip: + zip_source = os.path.basename(raw_zip)[:255] # защита от path traversal + лимит + + if not filename or not file_data: + return {"ok": False, "error": "no file in request"} + + mime = _mime_for(filename) + b64 = base64.b64encode(file_data).decode() + + if contract_id: + try: + supplements.delete_by_document(contract_id, filename) + except Exception: + pass # old record may not exist or FK issue — proceed with insert + + doc = documents.insert(filename, mime, b64, batch_id=batch_id, zip_source=zip_source) + + if not contract_id: + from datetime import datetime + now = datetime.now() + c = contracts.insert(f"б/н {now.strftime('%Y%m%d')}-{now.strftime('%H%M')}") + contract_id = c["id"] + supp_type = "initial" + else: + supp_type = "additional" + + supplements.insert(contract_id, doc["id"], supp_type) + + parsed = parse_file(filename, file_data) + if parsed and parsed.get("status") == "parsed": + documents.set_parsed(doc["id"], parsed["elements"]) + elif parsed and parsed.get("status") == "error": + documents.set_error(doc["id"], parsed.get("error", "parse failed")) + + return { + "ok": True, + "doc_id": doc["id"], + "contract_id": contract_id, + "filename": filename, + "size": len(file_data), + "parsed": parsed, + } + + +def _mime_for(filename): + ext = filename.rsplit(".", 1)[-1].lower() if "." in filename else "" + mime_map = { + "pdf": "application/pdf", + "docx": "application/vnd.openxmlformats-officedocument.wordprocessingml.document", + "doc": "application/msword", + "zip": "application/zip", + } + return mime_map.get(ext, "application/octet-stream") diff --git a/deploy-lucee/state.js b/deploy-lucee/state.js new file mode 100644 index 0000000..e8c9509 --- /dev/null +++ b/deploy-lucee/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/deploy-lucee/sync.sh b/deploy-lucee/sync.sh new file mode 100755 index 0000000..e8ad0a9 --- /dev/null +++ b/deploy-lucee/sync.sh @@ -0,0 +1,21 @@ +#!/bin/bash +# Деплой прокси-слоя на ВМ (5.172.178.213) +# Запускать из папки contractor/ + +VM="naeel@5.172.178.213" +SSH_KEY="$HOME/.ssh/naeel_vm_id_ed25519" +SSH="ssh -i $SSH_KEY" +SCP="scp -i $SSH_KEY" + +echo "=== Копирую конвертер .doc ===" +$SCP deploy/convert_server.py $VM:/home/naeel/contracts/convert_server.py +$SCP deploy/convert_doc.py $VM:/home/naeel/contracts/convert_doc.py + +echo "=== Перезапуск конвертера ===" +$SSH $VM 'pkill -f convert_server.py; sleep 1; nohup python3 /home/naeel/contracts/convert_server.py &>/dev/null &' + +echo "=== Проверка nginx конфига ===" +$SSH $VM 'sudo nginx -t 2>&1' + +echo "=== Готово ===" +echo "Проверка: curl https://contracts.kube5s.ru/convert-doc" diff --git a/deploy-lucee/tests.js b/deploy-lucee/tests.js new file mode 100644 index 0000000..8c5bc8f --- /dev/null +++ b/deploy-lucee/tests.js @@ -0,0 +1,532 @@ +/** + * tests.js — Тесты чистых функций (Фазы 0-4, decoupling-final-plan.md). + * + * Запуск: node tests.js + * + * Проверяет: statusToHTML, applyParseResult, reconcileSelection, + * renderGroupCard, renderGroupCardDone, renderUnresolvedCard, + * applyCompareEvent, renderCompareSectionHeader, renderCompareOpsTable, + * renderCompareSectionBody. + */ + +// ── Моки глобальных переменных ────────────────────────────── + +// Браузерные глобалы, нужные модулям при загрузке +global.window = global; // window.* присваивания + +// Мок document +global.document = { + getElementById: function(id) { return null; }, + createElement: function(tag) { + return { + style: {}, + classList: { add: function(){}, remove: function(){} }, + innerHTML: '', + textContent: '', + appendChild: function(){}, + querySelector: function(){ return null; }, + querySelectorAll: function(){ return []; }, + addEventListener: function(){}, + parentNode: { insertBefore: function(){} }, + nextSibling: null + }; + }, + querySelector: function() { return null; } +}; + +// Мок crypto.randomUUID +global.crypto = { randomUUID: function() { return 'test-uuid-' + Date.now(); } }; + +// Мок lucide +global.lucide = { createIcons: function(){} }; + +// Глобальные переменные приложения +global.fileInput = { disabled: false, value: '', addEventListener: function(){}, files: [] }; +global.fileTable = { innerHTML: '' }; +global.VM_API = 'https://contracts.kube5s.ru'; +global.UPLOAD_URL = VM_API + '/upload'; +global.CONVERT_URL = VM_API + '/convert-doc'; +global.UNZIP_URL = VM_API + '/unzip-upload'; +global.SITE_URL = ''; + +var state = { + files: [], + contractId: null, + batchId: 'test-batch-id', + groups: null, + _activeCompare: { es: null, timer: null }, + ui: { steps: { upload: '○', classify: '○', groups: '○', compare: '○' } } +}; + +// Мок escHtml (из app_utils.js) +global.escHtml = function(s) { + if (s == null) return ''; + return String(s).replace(/&/g,'&').replace(//g,'>').replace(/"/g,'"'); +}; + +var passed = 0, failed = 0; + +function assert(cond, msg) { + if (cond) { passed++; } + else { console.log(' FAIL: ' + msg); failed++; } +} + +function eq(actual, expected, msg) { + if (actual === expected) { passed++; } + else { console.log(' FAIL: ' + msg + ' — expected ' + JSON.stringify(expected) + ', got ' + JSON.stringify(actual)); failed++; } +} + +function contains(str, substr, msg) { + if (str.indexOf(substr) !== -1) { passed++; } + else { console.log(' FAIL: ' + msg + ' — string does not contain "' + substr + '"'); console.log(' got: ' + str.substring(0, 200)); failed++; } +} + +// ── Загружаем модули ───────────────────────────────────────── +// Модули используют function declaration (глобальные в браузере). +// В Node.js загружаем через eval в глобальном контексте. + +var fs = require('fs'); + +function loadModule(path) { + var code = fs.readFileSync(path, 'utf-8'); + // (0, eval) — indirect eval, выполняется в глобальном скоупе (не strict) + (0, eval)(code); +} + +console.log('=== Загрузка модулей ==='); +loadModule('./files.js'); +console.log(' files.js — OK'); +loadModule('./groups.js'); +console.log(' groups.js — OK'); +loadModule('./compare.js'); +console.log(' compare.js — OK'); + +// ── Тесты: statusToHTML ────────────────────────────────────── +console.log('\n=== statusToHTML ==='); + +eq(statusToHTML(null), '', 'null → пусто'); +eq(statusToHTML({}), '', 'пустой объект → пусто'); +eq(statusToHTML({ kind: '' }), '', 'пустой kind → пусто'); +eq(statusToHTML({ kind: 'uploading', pct: 0 }), '↑ 0%', 'uploading 0%'); +eq(statusToHTML({ kind: 'uploading', pct: 75 }), '↑ 75%', 'uploading 75%'); +eq(statusToHTML({ kind: 'uploaded' }), '', 'uploaded'); +eq(statusToHTML({ kind: 'unzipping' }), '⏳ распаковка...', 'unzipping'); +eq(statusToHTML({ kind: 'parsing' }), '⏳ парсинг...', 'parsing'); +eq(statusToHTML({ kind: 'parsed', count: 5 }), '✓ 5 эл.', 'parsed без elapsed'); +eq(statusToHTML({ kind: 'parsed', count: 5, elapsed: '2.3' }), '✓ 5 эл. (2.3с)', 'parsed с elapsed'); +eq(statusToHTML({ kind: 'error', text: 'тест' }), '✗ тест', 'error с текстом'); +eq(statusToHTML({ kind: 'error' }), '✗ Неизвестная ошибка', 'error без текста'); + +// Краевые случаи +eq(statusToHTML(undefined), '', 'undefined → пусто'); +eq(statusToHTML({ kind: 'uploading' }), '↑ 0%', 'uploading без pct → 0%'); +eq(statusToHTML({ kind: 'uploading', pct: -5 }), '↑ -5%', 'uploading отрицательный pct'); +eq(statusToHTML({ kind: 'uploading', pct: 100 }), '↑ 100%', 'uploading 100%'); +eq(statusToHTML({ kind: 'parsed', count: 0 }), '✓ 0 эл.', 'parsed count=0'); +eq(statusToHTML({ kind: 'parsed', count: 5, elapsed: '0' }), '✓ 5 эл. (0с)', 'parsed elapsed=0'); +eq(statusToHTML({ kind: 'error', text: '' }), '✗ Неизвестная ошибка', 'error с пустым текстом → дефолт'); +eq(statusToHTML({ kind: 'unknown_kind' }), '', 'неизвестный kind → пусто'); + +// ── Тесты: applyParseResult ────────────────────────────────── +console.log('\n=== applyParseResult ==='); + +// parsed success +var e1 = {}; +applyParseResult(e1, { status: 'parsed', element_count: 10 }, '1.5'); +assert(e1.parsed === true, 'parsed=true'); +assert(e1.parseInfo.element_count === 10, 'parseInfo сохранён'); +eq(e1.status.kind, 'parsed', 'status.kind=parsed'); +eq(e1.status.count, 10, 'status.count=10'); +eq(e1.status.elapsed, '1.5', 'status.elapsed=1.5'); + +// parsed without elapsed +var e2 = {}; +applyParseResult(e2, { status: 'parsed', element_count: 3 }); +eq(e2.status.kind, 'parsed', 'без elapsed: kind=parsed'); +eq(e2.status.count, 3, 'без elapsed: count=3'); +assert(e2.status.elapsed === undefined, 'без elapsed: elapsed отсутствует'); + +// error +var e3 = {}; +applyParseResult(e3, { status: 'error', error: 'битый PDF' }); +eq(e3.status.kind, 'error', 'error: kind=error'); +eq(e3.status.text, 'битый PDF', 'error: text сохранён'); +eq(e3.parseInfo.error, 'битый PDF', 'error: parseInfo сохранён'); + +// null parsed (неизвестная ошибка) +var e4 = {}; +applyParseResult(e4, null); +eq(e4.status.kind, 'error', 'null: kind=error'); +eq(e4.status.text, 'Неизвестная ошибка', 'null: дефолтный текст'); + +// parsed с пустыми полями +var e5 = {}; +applyParseResult(e5, { status: 'parsed' }); +eq(e5.status.kind, 'parsed', 'parsed без element_count: kind=parsed'); +eq(e5.status.count, undefined, 'parsed без element_count: count=undefined'); +// parsed с element_count=0 +var e6 = {}; +applyParseResult(e6, { status: 'parsed', element_count: 0 }); +eq(e6.status.count, 0, 'parsed element_count=0'); +// error без текста ошибки +var e7 = {}; +applyParseResult(e7, { status: 'error' }); +eq(e7.status.text, 'ошибка парсинга', 'error без текста: дефолт "ошибка парсинга"'); +// entry с уже существующими полями (не должны мешать) +var e8 = { existing: true, parsed: false }; +applyParseResult(e8, { status: 'parsed', element_count: 7 }, '3.0'); +assert(e8.existing === true, 'старое поле не затёрто'); +eq(e8.status.count, 7, 'новые данные поверх старых'); + +// ── Тесты: reconcileSelection ──────────────────────────────── +console.log('\n=== reconcileSelection ==='); + +var files = [ + { name: 'a.docx' }, { name: 'b.pdf' }, { name: 'c.docx' } +]; +var newFiles = [ + { name: 'a.docx' }, { name: 'c.docx' }, { name: 'd.pdf' } +]; +var result = reconcileSelection(files, newFiles); +eq(result.length, 2, 'должно остаться 2 файла'); +eq(result[0].name, 'a.docx', 'a.docx остался'); +eq(result[1].name, 'c.docx', 'c.docx остался'); + +// Исходный массив не мутирован +eq(files.length, 3, 'исходный массив не мутирован'); + +// Пустые входы +var emptyResult = reconcileSelection([], []); +eq(emptyResult.length, 0, 'пустые массивы → пусто'); + +// Все совпадают +var allMatch = reconcileSelection([{name:'a'}], [{name:'a'}]); +eq(allMatch.length, 1, 'все совпадают → 1'); + +// Ни один не совпадает +var noMatch = reconcileSelection([{name:'a'},{name:'b'}], [{name:'c'},{name:'d'}]); +eq(noMatch.length, 0, 'нет совпадений → пусто'); + +// Дубликаты имён в newFiles (должен работать — Set) +var dupNames = [{name:'a'}, {name:'a'}, {name:'b'}]; +var dupResult = reconcileSelection([{name:'a'},{name:'b'}], dupNames); +eq(dupResult.length, 2, 'дубликаты в newFiles — Set обрабатывает'); + +// ── Тесты: renderGroupCard ─────────────────────────────────── +console.log('\n=== renderGroupCard ==='); + +var group = { + contract_number: '123', + counterparty: 'ООО Тест', + documents: [ + { doc_type: 'contract', filename: 'договор.docx', doc_date: '2024-01-15' }, + { doc_type: 'supplement', filename: 'дс1.docx' } + ] +}; + +var html = renderGroupCard(group, 0); +contains(html, 'Договор №123', 'номер договора'); +contains(html, 'ООО Тест', 'контрагент'); +contains(html, 'договор', 'тип contract → договор'); +contains(html, 'допсоглашение', 'тип supplement → допсоглашение'); +contains(html, '2024-01-15', 'дата'); +contains(html, 'data-gi="0"', 'data-gi атрибут'); +contains(html, 'Сравнить эту группу', 'кнопка сравнения'); +// НЕ должно быть ✓ Готово +assert(html.indexOf('✓ Готово') === -1, 'нет "✓ Готово" для необработанной'); + +// Группа с compare.running +var groupRunning = { + contract_number: '456', + counterparty: 'ЗАО Бег', + documents: [{ doc_type: 'contract', filename: 'дог.docx' }], + compare: { status: 'running' } +}; +var htmlRunning = renderGroupCard(groupRunning, 1); +contains(htmlRunning, 'disabled', 'кнопка disabled при running'); + +// Без контрагента +var groupNoCP = { contract_number: '001', documents: [] }; +var htmlNoCP = renderGroupCard(groupNoCP, 5); +contains(htmlNoCP, 'контрагент не определён', 'без counterparty: заглушка'); + +// Неизвестный doc_type +var groupUnknown = { contract_number: 'X', counterparty: 'Y', documents: [{ doc_type: 'unknown_type', filename: 'f.docx' }] }; +var htmlUnknown = renderGroupCard(groupUnknown, 6); +contains(htmlUnknown, 'unknown_type', 'неизвестный тип: выводится как есть'); + +// Пустой список документов +var groupEmpty = { contract_number: 'E', counterparty: 'Empty', documents: [] }; +var htmlEmpty = renderGroupCard(groupEmpty, 7); +contains(htmlEmpty, 'Договор №E', 'пустые документы: карточка рендерится'); +contains(htmlEmpty, 'Сравнить', 'пустые документы: кнопка есть'); + +// ── Тесты: renderGroupCardDone ─────────────────────────────── +console.log('\n=== renderGroupCardDone ==='); + +var groupDone = { + contract_number: '789', + counterparty: 'ИП Готово', + documents: [ + { doc_type: 'contract', filename: 'base.docx' }, + { doc_type: 'specification', filename: 'spec.docx' } + ], + compare: { + status: 'done', + totalTime: '12.5с', + bodyHTML: '
результаты сравнения
' + } +}; + +var htmlDone = renderGroupCardDone(groupDone, 2); +contains(htmlDone, '✓ Готово (12.5с)', '✓ Готово с временем'); +contains(htmlDone, 'cmpArrow_2', 'стрелка сворачивания'); +contains(htmlDone, 'спецификация', 'тип specification → спецификация'); +contains(htmlDone, 'результаты сравнения', 'bodyHTML вставлен'); +contains(htmlDone, 'data-gi="2"', 'data-gi на заголовке'); + +// ── Тесты: renderUnresolvedCard ────────────────────────────── +console.log('\n=== renderUnresolvedCard ==='); + +var unresolved = { + contract_number: '__unresolved__', + documents: [ + { doc_type: 'other', filename: 'unknown.docx', parent_number: '999' }, + { doc_type: 'contract', filename: 'bad.docx', classify_status: 'failed', error_message: 'LLM error' } + ] +}; + +var htmlUnres = renderUnresolvedCard(unresolved); +contains(htmlUnres, 'Не распознано', 'заголовок нераспознанных'); +contains(htmlUnres, 'нет базового договора №999', 'причина: нет базового'); +contains(htmlUnres, 'ошибка классификации: LLM error', 'причина: ошибка классификации'); + +// ── Тесты: applyCompareEvent ───────────────────────────────── +console.log('\n=== applyCompareEvent ==='); + +var sections = {}; + +// extract_start +applyCompareEvent(sections, { type: 'extract_start', supplement_id: 's1', filename: 'test.docx' }); +assert(sections['s1'] !== undefined, 'секция создана'); +eq(sections['s1'].filename, 'test.docx', 'filename сохранён'); +eq(sections['s1'].status, 'extracting', 'status=extracting'); + +// llm_done +applyCompareEvent(sections, { type: 'llm_done', supplement_id: 's1', ops_count: 15, mode: 'llm', time_s: 3.2 }); +eq(sections['s1'].status, 'llm_done', 'status=llm_done'); +eq(sections['s1'].ops_count, 15, 'ops_count=15'); +eq(sections['s1'].mode, 'llm', 'mode=llm'); +eq(sections['s1'].time_s, 3.2, 'time_s=3.2'); + +// applied +applyCompareEvent(sections, { + type: 'applied', supplement_id: 's1', + summary: { added: 5, updated: 2, deleted: 1 }, + ops: [{ action: 'ADD', new_row: { name: 'Услуга 1' } }] +}); +eq(sections['s1'].status, 'applied', 'status=applied'); +eq(sections['s1'].summary.added, 5, 'summary.added=5'); +eq(sections['s1'].ops.length, 1, 'ops.length=1'); + +// extract_error на существующей секции +applyCompareEvent(sections, { type: 'extract_error', supplement_id: 's1', error: 'parse failed' }); +eq(sections['s1'].status, 'error', 'после ошибки: status=error'); +eq(sections['s1'].error, 'parse failed', 'текст ошибки'); + +// extract_error на НЕсуществующей секции +applyCompareEvent(sections, { type: 'extract_error', supplement_id: 's2', filename: 'bad.docx', error: 'boom' }); +eq(sections['s2'].status, 'error', 'новая секция с ошибкой'); +eq(sections['s2'].filename, 'bad.docx', 'filename для ошибочной секции'); + +// Неизвестный тип события — не должен падать +applyCompareEvent(sections, { type: 'unknown_type', supplement_id: 's3' }); +assert(sections['s3'] === undefined, 'неизвестный тип: секция НЕ создана'); + +// llm_done для несуществующей секции — не должен падать +applyCompareEvent(sections, { type: 'llm_done', supplement_id: 's99', ops_count: 1 }); + +// apply_error (должен работать как extract_error) +applyCompareEvent(sections, { type: 'apply_error', supplement_id: 's4', filename: 'apply_err.docx', error: 'apply boom' }); +eq(sections['s4'].status, 'error', 'apply_error: секция с ошибкой создана'); +eq(sections['s4'].error, 'apply boom', 'apply_error: текст ошибки'); + +// applied без summary +applyCompareEvent(sections, { type: 'applied', supplement_id: 's1', ops: [] }); +eq(sections['s1'].status, 'applied', 'applied без summary: status ок'); +eq(sections['s1'].ops.length, 0, 'applied с пустыми ops'); + +// ── Тесты: renderCompareSectionHeader ──────────────────────── +console.log('\n=== renderCompareSectionHeader ==='); + +contains(renderCompareSectionHeader({ filename: 'f.docx', status: 'extracting' }), '⏳', 'extracting: ⏳'); +contains(renderCompareSectionHeader({ filename: 'f.docx', status: 'llm_done', ops_count: 5, mode: 'llm', time_s: 2 }), '✓', 'llm_done: ✓'); +contains(renderCompareSectionHeader({ filename: 'f.docx', status: 'llm_done', ops_count: 5, mode: 'llm', time_s: 2 }), '5 оп.', 'llm_done: ops_count'); +contains(renderCompareSectionHeader({ filename: 'f.docx', status: 'error', error: 'fail' }), '✗', 'error: ✗'); +contains(renderCompareSectionHeader({ filename: 'f.docx', status: 'error', error: 'fail' }), 'fail', 'error: текст'); + +// ── Тесты: renderCompareOpsTable ───────────────────────────── +console.log('\n=== renderCompareOpsTable ==='); + +var ops = [ + { action: 'ADD', new_row: { name: 'Стойка', price: 100, qty: 2, sum: 200, date_start: '2024-01-01' } }, + { action: 'DELETE', new_row: { name: 'IP', price: 50, qty: 1, sum: 50, date_start: '' } } +]; +var tableHtml = renderCompareOpsTable(ops); +contains(tableHtml, 'diff-added', 'ADD → diff-added'); +contains(tableHtml, 'diff-deleted', 'DELETE → diff-deleted'); +contains(tableHtml, 'Стойка', 'имя услуги'); +contains(tableHtml, '100', 'цена'); +contains(tableHtml, '2024-01-01', 'дата'); + +// Пустые ops +var emptyTable = renderCompareOpsTable([]); +contains(emptyTable, '', 'пустые ops: tbody есть'); + +// ops с null new_row +var nullRow = renderCompareOpsTable([{ action: 'ADD' }]); +contains(nullRow, '', 'null new_row: пустые ячейки (не падает)'); + +// ops с неизвестным action (без класса) +var unknownAct = renderCompareOpsTable([{ action: 'MERGE', new_row: {} }]); +assert(unknownAct.indexOf('diff-added') === -1, 'MERGE: нет diff-added'); +assert(unknownAct.indexOf('diff-deleted') === -1, 'MERGE: нет diff-deleted'); +assert(unknownAct.indexOf('diff-changed') === -1, 'MERGE: нет diff-changed'); + +// ── Тесты: renderCompareSectionBody ────────────────────────── +console.log('\n=== renderCompareSectionBody ==='); + +eq(renderCompareSectionBody(null), '', 'null → пусто'); +eq(renderCompareSectionBody({}), '', 'пустой → пусто'); +eq(renderCompareSectionBody({ status: 'llm_done' }), '', 'llm_done → пусто'); + +var secApplied = { + status: 'applied', + summary: { added: 3, updated: 1, deleted: 0, unresolved: 2 }, + ops: [{ action: 'UPDATE', new_row: { name: 'Стойка 42U' } }] +}; +var bodyHtml = renderCompareSectionBody(secApplied); +contains(bodyHtml, '+3', 'added=3'); +contains(bodyHtml, '~1', 'updated=1'); +contains(bodyHtml, '-0', 'deleted=0'); +contains(bodyHtml, '?2', 'unresolved=2'); +contains(bodyHtml, 'diff-changed', 'UPDATE → diff-changed'); + +// Без unresolved +var secNoUnresolved = { status: 'applied', summary: { added: 1, updated: 0, deleted: 0 }, ops: [] }; +var bodyNoUnr = renderCompareSectionBody(secNoUnresolved); +assert(bodyNoUnr.indexOf('?') === -1, 'без unresolved: нет знака ?'); + +// Отрицательные значения summary (не должно быть, но не падать) +var secNeg = { status: 'applied', summary: { added: -1, updated: 0, deleted: 0 }, ops: [{ action: 'ADD', new_row: {} }] }; +var bodyNeg = renderCompareSectionBody(secNeg); +contains(bodyNeg, '+-1', 'отрицательные summary: рендерится без ошибок'); + +// ops с частичным new_row (не все поля) +var secPartial = { status: 'applied', summary: { added: 1 }, ops: [ + { action: 'ADD', new_row: { name: 'Только имя' } } +]}; +var bodyPartial = renderCompareSectionBody(secPartial); +contains(bodyPartial, 'Только имя', 'частичный new_row: имя есть'); +// Проверим что нет undefined в выводе +assert(bodyPartial.indexOf('undefined') === -1, 'нет undefined в выводе'); + +// ── Тесты: escHtml ─────────────────────────────────────────── +console.log('\n=== escHtml ==='); + +eq(escHtml(null), '', 'escHtml null → пусто'); +eq(escHtml(undefined), '', 'escHtml undefined → пусто'); +eq(escHtml('hello'), 'hello', 'escHtml обычный текст'); +eq(escHtml('