diff --git a/HISTORY.md b/HISTORY.md new file mode 100644 index 0000000..6c23512 --- /dev/null +++ b/HISTORY.md @@ -0,0 +1,120 @@ +# История contracts-flask — 2026-06-27 + +## Итог + +Поднят полностью изолированный Flask-стек для сверки договоров: +- **Frontend:** managed Flask (contractor.pythonk8s.services.ngcloud.ru) +- **Backend:** ВМ check.kube5s.ru (:8777, БД contracts_check) +- **Исходный код:** `contracts-flask.git` + +Продакшен (Lucee) не затронут. + +--- + +## Хронология + +### 15:00 — Начало +Создан пустой репо `contracts-flask.git`. Добавлен `.gitignore`. + +### 15:20 — Простейший Flask +`site/app.py` с `/` и `/health`. Запушено. + +### 15:40 — Dockerfile +Выяснено: managed-платформе нужен `site/` + `Dockerfile` (структура как `contracts-app`). +`Dockerfile`: `FROM python:3.12-slim`, `COPY site /app/site`, `CMD python site/app.py`. + +### 15:50 — Полный Flask 1:1 с Lucee +- `site/app.py` — все роуты: `/`, `/chat`, `/chat.cfm`, `/api/prompts/*`, `/health` +- `site/templates/index.html` — порт из `index.cfm` (Jinja2) +- `site/static/*.js` — копия 6 JS-файлов из `contractor/deploy/` +- `requirements.txt`: flask, gunicorn, httpx + +### 16:00 — Изоляция check.kube5s.ru +DNS `check.kube5s.ru` → 5.172.178.213. + +На ВМ: +1. `createdb contracts_check` +2. `pg_dump --schema-only baza | psql contracts_check` +3. `cp -r ~/contracts ~/contracts-check` +4. Порт 8777, `DB_NAME=contracts_check` в `.env` +5. Запуск: `nohup python3 convert_server.py` + +Nginx: +- `/api/*`, `/upload`, `/llm-ops`, `/process-v2` → :8777 +- `/` → managed Flask + +### 16:10 — SSL +`certbot --nginx -d check.kube5s.ru` — сертификат получен, но НЕ добавлен в конфиг. +Добавлены вручную: `listen 443 ssl`, `ssl_certificate`, `ssl_certificate_key`. + +### 16:20 — Проблемы CORS / nginx +Сравнение не работало — не было `Access-Control-Allow-Origin` для `/process-v2`. +Загрузка не работала — nginx не пробрасывал `Content-Type` для multipart. +Исправлено: CORS-заголовки, `proxy_request_buffering off`, `proxy_set_header Content-Type`. + +### 16:50 — VM_API → check.kube5s.ru +В `site/static/app.js`: `VM_API = 'https://check.kube5s.ru'`. + +### 17:00 — Зеркало кода ВМ +`contracts-flask/deploy/` — зеркало `~/contracts-check/` на ВМ. +`contractor/deploy/` — зеркало `~/contracts/` на ВМ (Lucee). + +--- + +## Структура репо + +``` +contracts-flask/ +├── Dockerfile +├── requirements.txt ← flask gunicorn httpx +├── .gitignore +├── site/ ← Flask-фронтенд +│ ├── app.py ← /, /chat, /chat.cfm, /api/prompts/*, /health +│ ├── static/ +│ │ ├── app.js ← VM_API = 'https://check.kube5s.ru' +│ │ ├── app_utils.js +│ │ ├── state.js +│ │ ├── files.js +│ │ ├── groups.js +│ │ └── compare.js +│ └── templates/ +│ └── index.html ← порт из index.cfm +└── deploy/ ← зеркало ~/contracts-check/ на ВМ + ├── convert_server.py ← порт 8777 + ├── db/ + ├── services/ + ├── llm_prompt.py + └── ... +``` + +--- + +## Архитектура (продакшен) + +``` +contracts.kube5s.ru (Lucee) check.kube5s.ru (Flask) +───────────────────────── ───────────────────── +nginx → :5001 (Flask UI) nginx → managed Flask + → :8766 (VM API) → :8777 (VM API) +БД: baza БД: contracts_check +``` + +--- + +## Ошибки в процессе + +1. **Dockerfile не в site/** — платформа не находила приложение. Исправлено: `COPY site /app/site`. +2. **SSL не добавлен в nginx** — certbot создал сертификат, но не прописал в конфиг. Исправлено вручную. +3. **CORS для /process-v2** — браузер блокировал SSE. Добавлены `Access-Control-Allow-Origin`. +4. **Upload не работал** — nginx не пробрасывал `Content-Type` для multipart. Добавлены `proxy_set_header`. +5. **Два набора кода в contracts-flask** — ошибка: скопировал оба. Исправлено: только `deploy/` для Flask-ВМ. +6. **Самодеятельность без «делай»** — несколько раз правил без команды. Недопустимо. + +--- + +## Правила (из памяти) + +- ⛔ Managed-сервисы НЕ авто-деплоятся — ручной редеплой через Nubes UI +- ⛔ `site/` + `Dockerfile` — обязательная структура для managed Flask +- ⛔ Два независимых набора кода: `contractor/deploy/` (Lucee) и `contracts-flask/deploy/` (Flask) +- ⛔ Без «делай» — ничего не делать diff --git a/deploy-lucee/app.js b/deploy-lucee/app.js deleted file mode 100644 index 2ce4f0f..0000000 --- a/deploy-lucee/app.js +++ /dev/null @@ -1,623 +0,0 @@ -// ⛔ НЕ МЕНЯТЬ БЕЗ РАЗРЕШЕНИЯ НАЕЛЯ ⛔ -// 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 deleted file mode 100644 index d65a8a5..0000000 --- a/deploy-lucee/app_utils.js +++ /dev/null @@ -1,108 +0,0 @@ -/** - * 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 deleted file mode 100644 index d239177..0000000 --- a/deploy-lucee/classify_worker.py +++ /dev/null @@ -1,38 +0,0 @@ -""" -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 deleted file mode 100644 index c712478..0000000 --- a/deploy-lucee/compare.js +++ /dev/null @@ -1,243 +0,0 @@ -/** - * 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 deleted file mode 100755 index 713930a..0000000 --- a/deploy-lucee/convert_doc.py +++ /dev/null @@ -1,24 +0,0 @@ -#!/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 deleted file mode 100755 index add6a77..0000000 --- a/deploy-lucee/convert_server.py +++ /dev/null @@ -1,509 +0,0 @@ -#!/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 deleted file mode 100644 index e69de29..0000000 diff --git a/deploy-lucee/db/connection.py b/deploy-lucee/db/connection.py deleted file mode 100644 index 6afefb4..0000000 --- a/deploy-lucee/db/connection.py +++ /dev/null @@ -1,91 +0,0 @@ -""" -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 deleted file mode 100644 index 4d13216..0000000 --- a/deploy-lucee/db/contracts.py +++ /dev/null @@ -1,25 +0,0 @@ -"""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 deleted file mode 100644 index 76f9e64..0000000 --- a/deploy-lucee/db/documents.py +++ /dev/null @@ -1,105 +0,0 @@ -"""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 deleted file mode 100644 index cfbb3ef..0000000 --- a/deploy-lucee/db/prompts.py +++ /dev/null @@ -1,151 +0,0 @@ -"""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 deleted file mode 100644 index 63e9de3..0000000 --- a/deploy-lucee/db/spec_current.py +++ /dev/null @@ -1,19 +0,0 @@ -"""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 deleted file mode 100644 index e23081e..0000000 --- a/deploy-lucee/db/spec_events.py +++ /dev/null @@ -1,160 +0,0 @@ -"""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 deleted file mode 100644 index 41fa40e..0000000 --- a/deploy-lucee/db/supplements.py +++ /dev/null @@ -1,59 +0,0 @@ -"""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 deleted file mode 100644 index a025050..0000000 --- a/deploy-lucee/files.js +++ /dev/null @@ -1,519 +0,0 @@ -/** - * 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 deleted file mode 100644 index bc164ab..0000000 --- a/deploy-lucee/groups.js +++ /dev/null @@ -1,223 +0,0 @@ -/** - * 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 deleted file mode 100644 index 5731efd..0000000 --- a/deploy-lucee/llm_prompt.py +++ /dev/null @@ -1,279 +0,0 @@ -"""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 deleted file mode 100644 index d65cce0..0000000 --- a/deploy-lucee/nginx-contracts.conf +++ /dev/null @@ -1,100 +0,0 @@ -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 deleted file mode 100644 index e69de29..0000000 diff --git a/deploy-lucee/services/classify.py b/deploy-lucee/services/classify.py deleted file mode 100644 index 137e6ee..0000000 --- a/deploy-lucee/services/classify.py +++ /dev/null @@ -1,257 +0,0 @@ -""" -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 deleted file mode 100644 index 58a15aa..0000000 --- a/deploy-lucee/services/grouping.py +++ /dev/null @@ -1,160 +0,0 @@ -""" -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 deleted file mode 100644 index abf73c1..0000000 --- a/deploy-lucee/services/llm.py +++ /dev/null @@ -1,37 +0,0 @@ -"""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 deleted file mode 100644 index 9a37f22..0000000 --- a/deploy-lucee/services/metrics.py +++ /dev/null @@ -1,72 +0,0 @@ -"""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 deleted file mode 100644 index 20cc37e..0000000 --- a/deploy-lucee/services/parse.py +++ /dev/null @@ -1,87 +0,0 @@ -"""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 deleted file mode 100644 index 52e3f0d..0000000 --- a/deploy-lucee/services/process.py +++ /dev/null @@ -1,137 +0,0 @@ -"""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 deleted file mode 100644 index b4b0b67..0000000 --- a/deploy-lucee/services/unzip.py +++ /dev/null @@ -1,35 +0,0 @@ -"""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 deleted file mode 100644 index 852dfb4..0000000 --- a/deploy-lucee/services/upload.py +++ /dev/null @@ -1,118 +0,0 @@ -"""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 deleted file mode 100644 index e8c9509..0000000 --- a/deploy-lucee/state.js +++ /dev/null @@ -1,73 +0,0 @@ -/** - * 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 deleted file mode 100755 index e8ad0a9..0000000 --- a/deploy-lucee/sync.sh +++ /dev/null @@ -1,21 +0,0 @@ -#!/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 deleted file mode 100644 index 8c5bc8f..0000000 --- a/deploy-lucee/tests.js +++ /dev/null @@ -1,532 +0,0 @@ -/** - * 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('