Author SHA1 Message Date
naeel f143894e10 v1.0.178: fix async classify polling — frontend ждёт done>=total через batch-progress, fix путь логов 2026-06-25 14:41:41 +04:00
naeel 4e9dd94433 v1.0.178: логи + lock-файл + guard от двойного classify. Ответ Опуса: минимум сейчас. 2026-06-25 13:29:06 +04:00
naeel aa1b801e94 v1.0.178: classify → subprocess.Popen (classify_worker.py). Изолированный процесс — свои коннекты к БД. HTTP-сервер продолжает отвечать. 2026-06-25 13:14:39 +04:00
naeel 88039fb750 v1.0.178: classify-batch → async (202 Accepted + background thread). При 69 файлах БД задыхается. 2026-06-25 12:18:20 +04:00
naeel 2a5f255c7b v1.0.178: скролл таблицы файлов — max-height:50vh, overflow-y:auto 2026-06-25 11:14:53 +04:00
naeel 9a19b10708 v1.0.178: fix zipIdx → zipEntry (ссылка на объект). ZIP temp entry держится до конца обработки. 2026-06-25 10:36:23 +04:00
naeel 91f22137d7 v1.0.178: fix — onFilesSelected больше не удаляет существущие файлы. reconcileSelection убрана из оркестратора. Файлы из ZIP/предыдущих загрузок сохраняются при добавлении новых. 2026-06-25 10:33:36 +04:00
naeel 60dd40cf05 v1.0.178: унификация ZIP. addZipFile → base64→File→addRegularFile (единый путь). confirm() при дубликате. unzip.py — только распаковка, без БД. 2026-06-25 10:16:06 +04:00
naeel b013c79190 v1.0.178: fix unzip — xhr.send(file) → FormData. Бэкенд ждёт multipart (ищет \r\n\r\n + PK magic), а не сырой файл. 2026-06-25 10:08:16 +04:00
naeel 9b129c3707 v1.0.178: fix двойной проверки дубликатов в addZipFile + console.error для диагностики unzip 2026-06-25 10:06:51 +04:00
naeel 613a154c2b v1.0.178: ZIP в диалоге выбора + предупреждение о перезаписи. accept=.zip, ⚠ под input, инфо в «О сервисе». 2026-06-25 09:51:30 +04:00
naeel 0f6f102664 v1.0.178: addZipFile — дубликаты из ZIP теперь перезаписываются (как addRegularFile). Унифицировано поведение: файл из архива заменяет существующий с тем же именем. 2026-06-25 09:50:06 +04:00
naeel b25c58ae29 v1.0.178: расширенные тесты (131 шт) + чистка app.js. 53 новых теста: краевые случаи statusToHTML, applyParseResult, reconcileSelection, renderGroupCard(*), applyCompareEvent, renderCompareOpsTable, escHtml. Убран неиспользуемый SITE_URL, дубль UNZIP_URL, дубль promptEditor. 2026-06-25 09:42:38 +04:00
naeel 9c08af6a1d v1.0.178: убрал неиспользуемую llmStart после Фазы 3 2026-06-25 09:36:34 +04:00
naeel 691549223f v1.0.178: тесты чистых функций. 78 тестов: statusToHTML, applyParseResult, reconcileSelection, renderGroupCard(*), applyCompareEvent, renderCompareSectionHeader/Body, renderCompareOpsTable. Все PASS. 2026-06-25 09:28:15 +04:00
naeel 7e76224203 v1.0.178: Фаза 4 — renderStepper(state). Степпер через state.ui.steps вместо прямой манипуляции DOM. stepDone/stepActive/resetStepper мутируют state → renderStepper. 2026-06-25 09:25:15 +04:00
naeel c9d55b3d80 v1.0.178: Фаза 3 — compare.js. applyCompareEvent, startCompareSSE, renderCompareOpsTable. ~160 строк дублирования SSE → ~60 строк общих + 2 тонких вызова в runCompareForGroup и llmBtn. 2026-06-25 09:24:26 +04:00
naeel e7917c8af5 v1.0.178: Фаза 2 — groups.js. loadGroupsAction + renderGroups + renderGroupCard + renderGroupCardDone. markGroupDone удалена, готовность = group.compare.status === 'done'. renderGroups пересобирает ВСЕ карточки из state.groups. 2026-06-25 09:15:54 +04:00
naeel ba9a833ce8 v1.0.178: Фаза 1.4 — разбит fileInput handler. Чистые функции: reconcileSelection, applyParseResult. Async-actions: addZipFile, addRegularFile. Оркестратор: onFilesSelected (15 строк). finalizeUpload. app.js fileInput listener: 4 строки. 2026-06-25 08:59:17 +04:00
naeel 67658d0ef1 v1.0.178: Фаза 1.3 — вынос в files.js. statusToHTML, renderFiles, syncDB, toggleClassifyDetail, uploadFile, refreshSupps → files.js. app.js похудел на ~200 строк. index.cfm грузит files.js. 2026-06-25 08:57:22 +04:00
naeel 3200ce749b v1.0.178: Фаза 1.2 — renderTable → renderFiles(state). render(state) теперь вызывает renderFiles(state). Имя отражает ответственность — рендерит ТОЛЬКО таблицу файлов. 2026-06-25 08:54:58 +04:00
naeel 9dd1afd832 v1.0.178: Фаза 1.1 — statusToHTML. Статусы из HTML-строк в структуры {kind,pct,text,count,elapsed}. Чистая функция statusToHTML(st). renderTable использует statusToHTML(f.status). 2026-06-25 08:54:39 +04:00
naeel 7490568b4b v1.0.178: Фаза 0 — state + render(state). Введён state.js (центральное состояние), render(state) вызывает renderTable(). fileQueue/contractId/batchId/_activeCompare → state.*. app_utils.js адаптирован. Подробные комментарии из decoupling-final-plan.md. 2026-06-25 08:44:52 +04:00
naeel b994da8c4c v1.0.177: inline onclick на diff-section-header — переживает markGroupDone 2026-06-24 20:25:07 +04:00
naeel 6806ee5027 v1.0.177: клик на файл сам переключает diff-section-body (addEventListener теряется) 2026-06-24 20:19:21 +04:00
naeel 332fad2e1a v1.0.177: клик на имя файла в обработанной группе — раскрыть/свернуть результаты 2026-06-24 20:18:29 +04:00
naeel 84898e10d6 v1.0.177: необработанная — только кнопка; обработанная — только раскрытие 2026-06-24 20:08:09 +04:00
naeel 38ade38573 v1.0.177: markGroupDone сохраняет HTML результатов — раскрытие работает 2026-06-24 20:05:02 +04:00
naeel 5955c03677 v1.0.177: buildGroupCard + markGroupDone — разная вёрстка для необработанных и готовых 2026-06-24 20:02:46 +04:00
naeel 08220fa4b9 v1.0.177: группы независимы — изменения в одной не касаются других 2026-06-24 20:01:14 +04:00
naeel fa06c758f4 v1.0.177: Готово НЕ сбрасывается при сравнении другой группы 2026-06-24 19:57:55 +04:00
naeel 0562c16897 v1.0.177: блокировка ВСЕХ кнопок сравнения пока идёт процесс 2026-06-24 19:52:23 +04:00
naeel 7f6d912010 v1.0.177: Готово → span, не кнопка — нельзя перезапустить сравнение 2026-06-24 19:47:34 +04:00
naeel a58df099b6 trigger Lucee redeploy v1.0.177 2026-06-24 19:40:09 +04:00
naeel 7a780e457e v1.0.177: Готово — disabled + data-done, клик не перезапускает 2026-06-24 19:37:58 +04:00
naeel 498bee90d8 v1.0.177: сравнение внутри карточки группы, только одно одновременно 2026-06-24 19:34:00 +04:00
naeel c223fc04c0 v1.0.177: О сервисе — полная инструкция с промежуточными результатами 2026-06-24 19:30:50 +04:00
naeel 2a566869aa v1.0.176: classify_input + elements_json в промежуточных результатах 2026-06-24 19:23:34 +04:00
naeel 3974a032a5 v1.0.176: раскрывающиеся результаты классификации прямо в таблице 2026-06-24 18:42:47 +04:00
naeel 3f58f6adcd v1.0.176: FOR UPDATE fix — ORDER BY LIMIT 1 вместо MAX() 2026-06-24 18:37:48 +04:00
naeel d44f8f3e6c v1.0.176: fix self.path → urlparse for ?v= cache bust 2026-06-24 18:27:17 +04:00
naeel 0b27832292 v1.0.176: X-Content-Type-Options nosniff for ORB fix 2026-06-24 18:23:19 +04:00
naeel 8953af0c19 v1.0.176: version bump + cache bust 2026-06-24 18:08:35 +04:00
naeel b26728d9da v1.0.176: remove verify=False — MITM fix 2026-06-24 18:07:44 +04:00
naeel a36c8fa2bf v1.0.176: prompts — seed per-role + _serialize non-mutating 2026-06-24 18:07:02 +04:00
naeel 0e0939bb32 v1.0.176: spec_events — FOR UPDATE + transaction for seq race 2026-06-24 18:06:05 +04:00
naeel cd5ba7470e v1.0.176: spec_current cascade fix + keep_ids DoS limit 2026-06-24 18:04:59 +04:00
naeel 044a83a43d v1.0.176: grouping.py — apply_groups error handling + doc_id null check 2026-06-24 18:04:05 +04:00
naeel 780e52d072 v1.0.176: XSS ×5 fix + syncDB await + escHtml(null) 2026-06-24 18:03:12 +04:00
naeel 666f85b743 v1.0.176: upload.py — path traversal + size limit + UUID + data loss 2026-06-24 18:01:18 +04:00
naeel 60dc41e83f v1.0.175: общее сравнение → compareCard, diffBody только для групп 2026-06-24 17:42:31 +04:00
naeel 8267149190 v1.0.175: cache busting ?v=1.0.175 на скрипты 2026-06-24 17:40:36 +04:00
naeel f205f0f547 v1.0.175: compareCard отдельно от diffCard — группы не исчезают при сравнении 2026-06-24 17:39:19 +04:00
naeel 475d0b8dc7 v1.0.175: виртуальные группы + classify_raw + stepper 2026-06-24 17:06:25 +04:00
naeel b5d35f57fc v1.0.174: reset/list_pending без фильтра status=parsed 2026-06-24 15:21:17 +04:00
naeel e939c320cc v1.0.174: classify_batch сбрасывает статус на pending — работает после удаления файлов 2026-06-24 15:17:45 +04:00
naeel 592041ab89 v1.0.174: 'файлов не распознано' вместо 'не распознано' 2026-06-24 15:13:32 +04:00
naeel 70a7c8bfb4 v1.0.174: /api/sync — БД = зеркало таблицы, удаление ВСЕГО кроме keep_ids 2026-06-24 15:11:57 +04:00
naeel b19e6ba06e v1.0.174: fix groups count — не считаем __unresolved__ за группу 2026-06-24 15:03:17 +04:00
naeel b325a6b2c2 v1.0.174: sync-batch — удаление мусора после загрузки, DELETE /api/documents/<id> 2026-06-24 14:59:46 +04:00
naeel 7538c0a5c9 v1.0.174: fix FK cascade in delete_by_document — spec_current → spec_events → supplement → document 2026-06-24 14:50:31 +04:00
naeel ef4536a437 v1.0.174: classify tab in prompts UI — activePromptId + openPromptModal 2026-06-24 14:43:22 +04:00
naeel cf4fc016bd v1.0.174: fix \n → real newlines в seed-промптах (parameterized queries) 2026-06-24 14:40:38 +04:00
naeel f693acce6f v1.0.174: пояснения 'для чего' к кнопкам промптов 2026-06-24 14:33:58 +04:00
naeel c98f190cbc v1.0.173: О сервисе в топбар, новый контент — инструкция без упоминания Lucee/VM 2026-06-24 14:32:36 +04:00
naeel 9cc6e43868 v1.0.172: вкладка classify в редакторе промптов 2026-06-24 14:29:36 +04:00
naeel b49523677c v1.0.171: detailed SSE results for group compare 2026-06-24 14:07:31 +04:00
naeel cbdfc7a8c1 v1.0.171: ▶ Сравнить группу via SSE + classify timer/progress 2026-06-24 13:55:22 +04:00
naeel e0cff8d85f v1.0.170: green dot instead of checkmark for active prompt 2026-06-24 13:49:43 +04:00
naeel 7cbfdc9589 v1.0.170: show prompt notes in history 2026-06-24 13:47:23 +04:00
naeel 6f2c0fd910 v1.0.170: simple text buttons — активировать / удалить 2026-06-24 13:45:20 +04:00
naeel c21a50cd14 v1.0.170: clear activate/delete buttons with lucide icons 2026-06-24 13:43:37 +04:00
naeel 15bd4ad322 v1.0.170: fix prompts list — datetime serialization 2026-06-24 13:32:13 +04:00
naeel e2348eaf4e v1.0.170: remove fake drag-drop text 2026-06-24 13:28:02 +04:00
naeel 1aa22767ef v1.0.170: допник → допсоглашение 2026-06-24 13:19:08 +04:00
naeel d848caacb2 v1.0.170: UI — Общее сравнение, Классификация и группы, пояснения для нераспознанных 2026-06-24 13:17:50 +04:00
naeel abba9c4f1d v1.0.169: prompts CRUD via VM API — /api/prompts, no more prompt.cfm dependency 2026-06-24 10:27:59 +04:00
naeel dd558e00e7 v1.0.168: detailed О сервисе — architecture, pipeline, Event Sourcing, security 2026-06-24 10:22:41 +04:00
naeel 115e71b2a1 v1.0.167: fix grouping dead code (classified→docs) + max_tokens 500→1000 2026-06-24 09:35:42 +04:00
naeel 49c472b46d v1.0.166: aggressive JSON repair — close unterminated strings/braces 2026-06-24 09:14:07 +04:00
naeel d0326ee9ce v1.0.166: fix missing _smart_extract function 2026-06-24 09:09:26 +04:00
naeel c2c92d0a37 v1.0.166: classify prompt v2 (LLM-suggested) + safe JSON parse with auto-repair 2026-06-24 09:04:31 +04:00
naeel fe1f76fa1d v1.0.165: app_utils.js + detailed comments (Opus analysis) in classify/grouping/connection 2026-06-24 08:46:40 +04:00
naeel d86a369d2b v1.0.164: remove ↕ column — auto-classify determines order 2026-06-24 08:41:15 +04:00
naeel 8a5d93a5c7 v1.0.163: classify UI — batch_id, Классифицировать button, group cards 2026-06-24 08:27:29 +04:00
naeel eb98790802 v1.0.162: /api/classify-batch + batch_id UUID validation 2026-06-24 08:23:40 +04:00
naeel 7a96f5b95b v1.0.162: auto-classify backend — classify/grouping services, 7 new columns, /classify-batch, /api/groups, /apply-groups 2026-06-24 08:22:21 +04:00
20 changed files with 3106 additions and 455 deletions
+363 -373
View File
@@ -1,398 +1,367 @@
// ⛔ НЕ МЕНЯТЬ БЕЗ РАЗРЕШЕНИЯ НАЕЛЯ ⛔
// Contracts App — весь JS на VM (contracts.kube5s.ru) // Contracts App — весь JS на VM (contracts.kube5s.ru)
// Lucee: только домен + index.cfm-скелет // Lucee: только домен + index.cfm-скелет
var VM_API = 'https://contracts.kube5s.ru'; var VM_API = 'https://contracts.kube5s.ru';
var UPLOAD_URL = VM_API + '/upload'; var UPLOAD_URL = VM_API + '/upload';
var CONVERT_URL = VM_API + '/convert-doc'; var CONVERT_URL = VM_API + '/convert-doc';
var UNZIP_URL = VM_API + '/unzip-upload'; var UNZIP_URL = VM_API + '/unzip-upload';
var UNZIP_URL = 'https://contracts.kube5s.ru/unzip-upload'; // SITE_URL удалён (Фаза 4) — не использовался
var SITE_URL = ''; // same origin for api calls
var fileInput = document.getElementById('fileInput'); var fileInput = document.getElementById('fileInput');
var fileTable = document.getElementById('fileTable'); var fileTable = document.getElementById('fileTable');
var fileQueue = []; // state.files — теперь в state.js (Фаза 0: state + render)
var contractId = null; // state.contractId — теперь в state.js (Фаза 0: state + render)
// state.batchId — теперь в state.js (Фаза 0: state + render)
// (batchId = crypto.randomUUID() — уникальный ID сессии для классификации)
function formatSize(bytes) { // Автоочистка старых записей при загрузке страницы
if (!bytes || bytes === 0) return '—'; (async function cleanup() {
if (bytes < 1024) return bytes + ' B'; try {
if (bytes < 1048576) return (bytes / 1024).toFixed(1) + ' KB'; await fetch(VM_API + '/api/cleanup', { method: 'POST' });
return (bytes / 1048576).toFixed(1) + ' MB'; } 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);
} }
function formatDate(ts) { // syncDB, statusToHTML, renderFiles, toggleClassifyDetail, uploadFile, refreshSupps
if (!ts) return '—'; // → вынесены в files.js (Фаза 1)
var d = new Date(ts);
return d.toLocaleDateString('ru-RU') + ' ' + d.toLocaleTimeString('ru-RU', {hour:'2-digit',minute:'2-digit'});
}
function renderTable() { // ── Pipeline stepper (Фаза 4: state-driven) ──────────────────
if (fileQueue.length === 0) {
fileTable.innerHTML = '<tr class="empty-row"><td colspan="7">Нет файлов — выберите .docx / .pdf</td></tr>'; /**
} else { * renderStepper(state) — Рендер степпера из state.ui.steps (Фаза 4).
fileTable.innerHTML = fileQueue.map(function(f, i) { *
var isFirst = (i === 0); * Читает state.ui.steps.{upload,classify,groups,compare}, обновляет DOM.
var isLast = (i === fileQueue.length - 1); * Значения: '○' (не начат) | '⏳' (в процессе) | '✓' (завершён).
var arrows = '<button class="arrow-btn" onclick="moveUp(' + i + ')" ' + (isFirst ? 'disabled' : '') + ' title="Вверх">▲</button>' + */
'<button class="arrow-btn" onclick="moveDown(' + i + ')" ' + (isLast ? 'disabled' : '') + ' title="Вниз">▼</button>'; function renderStepper(state) {
return '<tr id="row_' + i + '" class="' + (isFirst ? 'first-row' : '') + '">' + var steps = state.ui.steps;
'<td class="name-cell">' + f.name + '</td>' + var map = { upload: 'stepUpload', classify: 'stepClassify', groups: 'stepGroups', compare: 'stepCompare' };
'<td style="font-size:12px;color:var(--muted);">' + formatDate(f.lastModified) + '</td>' + var colors = { '○': 'var(--muted)', '⏳': 'var(--brand-primary)', '✓': 'var(--green)' };
'<td class="num-cell" style="font-size:12px;color:var(--muted);">' + formatSize(f.size) + '</td>' + Object.keys(map).forEach(function(key) {
'<td class="status-cell">' + (f.status || '') + '</td>' + var el = document.getElementById(map[key]);
'<td style="text-align:center;">' + arrows + '</td>' + if (el) {
'<td><button class="info-btn' + (f.doc_id ? ' visible' : '') + '" onclick="showText(' + i + ')" title="Текст / Распарсено"><i data-lucide="file-text" style="width:16px;height:16px;"></i></button></td>' + el.textContent = steps[key] + ' ' + {upload:'Загрузка',classify:'Классификация',groups:'Группировка',compare:'Сравнение'}[key];
'<td><button class="remove-btn" onclick="removeFile(' + i + ')" title="Удалить"><i data-lucide="trash-2" style="width:16px;height:16px;"></i></button></td>' + el.style.color = colors[steps[key]] || 'var(--muted)';
'</tr>';
}).join('');
} }
lucide.createIcons(); });
} }
function removeFile(i) { /**
fileQueue.splice(i, 1); * stepDone(id) — Отметить шаг как завершённый (Фаза 4).
if (fileQueue.length === 0) contractId = null; * Мутирует state.ui.steps, вызывает renderStepper.
renderTable(); */
function stepDone(id) {
var key = { stepUpload: 'upload', stepClassify: 'classify', stepGroups: 'groups', stepCompare: 'compare' }[id];
if (key) { state.ui.steps[key] = '✓'; renderStepper(state); }
} }
window.removeFile = removeFile;
function moveUp(i) { /**
if (i <= 0) return; * stepActive(id) — Отметить шаг как активный (Фаза 4).
var tmp = fileQueue[i]; fileQueue[i] = fileQueue[i-1]; fileQueue[i-1] = tmp; * Мутирует state.ui.steps, вызывает renderStepper.
renderTable(); */
function stepActive(id) {
var key = { stepUpload: 'upload', stepClassify: 'classify', stepGroups: 'groups', stepCompare: 'compare' }[id];
if (key) { state.ui.steps[key] = '⏳'; renderStepper(state); }
} }
function moveDown(i) {
if (i >= fileQueue.length - 1) return;
var tmp = fileQueue[i]; fileQueue[i] = fileQueue[i+1]; fileQueue[i+1] = tmp;
renderTable();
}
window.moveUp = moveUp;
window.moveDown = moveDown;
// ── О сервисе ──────────────────────────────────────────────── /**
function openAbout() { * resetStepper(fromId) — Сбросить шаги начиная с fromId (Фаза 4).
document.getElementById('aboutModalOverlay').classList.add('open'); * Все шаги после (и включая) 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);
} }
function closeAbout(e) { // Утилиты (formatSize, formatDate, moveUp, moveDown, escHtml, fmtDate) —
if (e && e.target !== document.getElementById('aboutModalOverlay')) return; // вынесены в app_utils.js для облегчения анализа и отладки.
document.getElementById('aboutModalOverlay').classList.remove('open'); // statusToHTML, renderFiles → files.js (Фаза 1)
}
window.openAbout = openAbout; // ── Раскрыть/скрыть результат классификации под строкой файла ──
window.closeAbout = closeAbout; // 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() { fileInput.addEventListener('change', async function() {
var newFiles = Array.from(fileInput.files); var newFiles = Array.from(fileInput.files);
if (newFiles.length === 0) return; onFilesSelected(newFiles);
fileInput.disabled = true;
for (var i = 0; i < newFiles.length; i++) {
var f = newFiles[i];
var isZip = f.name.toLowerCase().endsWith('.zip');
if (isZip) {
// ZIP: показать временную строку, распаковать, убрать
var zipEntry = { name: f.name, lastModified: f.lastModified, size: f.size, status: '⏳ распаковка...' };
fileQueue.push(zipEntry);
var zipIdx = fileQueue.length - 1;
renderTable();
try {
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;
xhr.send(f);
});
var zipData = zipResp;
if (!zipData.ok || !zipData.files) throw new Error('unzip failed');
// Убрать строку ZIP из таблицы
fileQueue.splice(zipIdx, 1);
renderTable();
// Добавить файлы из архива, пропуская дубликаты
for (var zi = 0; zi < zipData.files.length; zi++) {
var zf = zipData.files[zi];
if (zf.error) continue; // пропускаем ошибки
// Проверить дубликат по имени
var dup = -1;
for (var dj = 0; dj < fileQueue.length; dj++) {
if (fileQueue[dj].name === zf.filename) { dup = dj; break; }
}
if (dup >= 0) continue; // уже есть — не добавляем
var zEntry = {
name: zf.filename, size: zf.size,
doc_id: zf.doc_id, uploaded: true,
status: '⏳ парсинг...'
};
fileQueue.push(zEntry);
var zIdx = fileQueue.length - 1;
if (!contractId && zf.contract_id) contractId = zf.contract_id;
renderTable();
// Авто-парсинг: VM Python парсит ВСЁ
var zpr = zf.parsed;
if (zpr && zpr.status === 'parsed') {
fileQueue[zIdx].parsed = true;
fileQueue[zIdx].status = '<span class="status-ok">✓ ' + zpr.element_count + ' эл.</span>';
} else if (zpr && zpr.status === 'error') {
fileQueue[zIdx].status = '<span class="status-err">✗ ' + (zpr.error || 'ошибка парсинга') + '</span>';
} else {
fileQueue[zIdx].status = '<span class="status-err">✗ Неизвестная ошибка</span>';
}
renderTable();
}
} catch(ze) {
fileQueue[zipIdx].status = '<span class="status-err">✗ ZIP: ' + ze.message + '</span>';
renderTable();
}
continue; // ZIP обработан, переходим к следующему файлу
}
// Обычный файл (не ZIP)
var entry = { name: f.name, lastModified: f.lastModified, size: f.size, file: f, status: '↑ 0%' };
var existingIdx = -1;
for (var j = 0; j < fileQueue.length; j++) {
if (fileQueue[j].name === f.name) { existingIdx = j; break; }
}
var rowIdx;
if (existingIdx >= 0) {
fileQueue[existingIdx] = entry;
rowIdx = existingIdx;
} else {
fileQueue.push(entry);
rowIdx = fileQueue.length - 1;
}
renderTable();
try {
var resp = await uploadFile(f, function(pct) {
fileQueue[rowIdx].status = '↑ ' + pct + '%';
renderTable();
});
// Python API returns lowercase keys (doc_id, contract_id, parsed)
if (resp && resp.contract_id) contractId = resp.contract_id;
fileQueue[rowIdx].doc_id = resp.doc_id;
fileQueue[rowIdx].status = '<span class="status-ok">✓</span>';
fileQueue[rowIdx].uploaded = true;
// Авто-парсинг: VM Python парсит ВСЁ (PDF + DOCX + DOC)
var t0 = Date.now();
var pr = resp.parsed;
if (pr && pr.status === 'parsed') {
var elapsed = ((Date.now() - t0) / 1000).toFixed(1);
fileQueue[rowIdx].parsed = true;
fileQueue[rowIdx].parseInfo = pr;
fileQueue[rowIdx].status = '<span class="status-ok">✓ ' + pr.element_count + ' эл. (' + elapsed + 'с)</span>';
} else if (pr && pr.status === 'error') {
fileQueue[rowIdx].status = '<span class="status-err">✗ ' + (pr.error || 'ошибка парсинга') + '</span>';
fileQueue[rowIdx].parseInfo = pr;
} else {
fileQueue[rowIdx].status = '<span class="status-err">✗ Неизвестная ошибка</span>';
}
} catch(err) {
fileQueue[rowIdx].status = '<span class="status-err">✗ ' + err.message + '</span>';
}
renderTable();
}
await refreshSupps();
fileInput.value = '';
fileInput.disabled = false;
// Показать кнопки LLM после авто-парсинга
var llmBtn = document.getElementById('llmBtn');
llmBtn.style.display = 'flex';
llmBtn.disabled = false;
lucide.createIcons();
}); });
function uploadFile(file, onProgress) { // ── Классификация ──────────────────────────────────────────
return new Promise(function(resolve, reject) { function showClassifyBtn() {
// .doc → конвертация в docx var btn = document.getElementById('classifyBtn');
var isDoc = file.name.toLowerCase().endsWith('.doc') && !file.name.toLowerCase().endsWith('.docx'); if (!btn) {
var uploadFile = file; btn = document.createElement('button');
var uploadName = file.name; btn.id = 'classifyBtn';
btn.className = 'btn btn-primary';
function doUpload() { btn.style.cssText = 'width:100%;justify-content:center;margin-top:8px;background:var(--brand-primary);border-color:var(--brand-primary);';
var xhr = new XMLHttpRequest(); btn.innerHTML = '<i data-lucide="tags" style="width:16px;height:16px;"></i> Классифицировать';
var fd = new FormData(); btn.addEventListener('click', runClassify);
fd.append('files', uploadFile, uploadName); document.getElementById('llmBtn').parentNode.insertBefore(btn, document.getElementById('llmBtn'));
if (contractId) fd.append('contract_id', contractId);
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);
} }
btn.style.display = 'flex';
if (isDoc) { btn.disabled = false;
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();
}
});
} }
// ── LLM: Сравнить (Event Sourcing) ──────────────────────── async function runClassify() {
var btn = document.getElementById('classifyBtn');
btn.disabled = true;
btn.innerHTML = '<span class="spinner"></span> Классификация... 0с';
stepActive('stepClassify');
var start = Date.now();
var timer = setInterval(function() {
btn.innerHTML = '<span class="spinner"></span> Классификация... ' + 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 = '<span class="spinner"></span> Классификация... ' + 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 = '<span class="spinner"></span> Применяю...';
// Заблокировать ВСЕ кнопки сравнения пока идёт процесс
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 = '<span class="spinner"></span> Сравнение...';
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 += '<div style="color:var(--destructive);">✗ ' + d.message + '</div>';
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() { document.getElementById('llmBtn').addEventListener('click', function() {
if (!contractId) return; if (!state.contractId) return;
var btn = this; var btn = this;
btn.disabled = true; btn.disabled = true;
btn.innerHTML = '<span class="spinner"></span> Сравнение...'; btn.innerHTML = '<span class="spinner"></span> Сравнение...';
var diffCard = document.getElementById('diffCard'); var compareCard = document.getElementById('compareCard');
var diffBody = document.getElementById('diffBody'); var compareBody = document.getElementById('compareBody');
var diffStatus = document.getElementById('diffStatus'); var compareStatus = document.getElementById('compareStatus');
diffCard.style.display = 'block'; compareCard.style.display = 'block';
diffBody.innerHTML = ''; compareBody.innerHTML = '';
diffStatus.textContent = '⏳ 0.0с'; compareStatus.textContent = '⏳ 0.0с';
var llmStart = Date.now(); var order = state.files.map(function(f) { return f.supp_id; }).filter(Boolean).join(',');
var timerInterval = setInterval(function() { var url = 'https://contracts.kube5s.ru/process-v2?contract_id=' + state.contractId +
diffStatus.textContent = '⏳ ' + ((Date.now() - llmStart) / 1000).toFixed(1) + 'с'; (order ? '&order=' + encodeURIComponent(order) : '');
}, 200);
var order = fileQueue.map(function(f) { return f.supp_id; }).filter(Boolean).join(','); // Фаза 3: унифицированный SSE через startCompareSSE (compare.js)
var es = new EventSource('https://contracts.kube5s.ru/process-v2?contract_id=' + contractId + (order ? '&order=' + encodeURIComponent(order) : '')); startCompareSSE(url, compareBody, compareStatus, {
var sections = {}; // supplement_id → DOM element onDone: function(d) {
es.onmessage = function(e) {
var d = JSON.parse(e.data);
if (d.type === 'extract_start') {
var sec = document.createElement('div');
sec.style.cssText = 'margin-top:8px;border:1px solid var(--brand-gray);border-radius:6px;overflow:hidden;';
sec.innerHTML = '<div class="diff-section-header" style="padding:8px 12px;background:var(--brand-grey-light);cursor:pointer;font-size:13px;font-weight:600;">⏳ ' + d.filename + '</div><div class="diff-section-body" style="display:none;padding:8px 12px;"></div>';
sec.querySelector('.diff-section-header').addEventListener('click', function() {
var body = sec.querySelector('.diff-section-body');
body.style.display = body.style.display === 'none' ? 'block' : 'none';
});
diffBody.appendChild(sec);
sections[d.supplement_id] = sec;
}
else if (d.type === 'llm_done') {
var sec = sections[d.supplement_id];
if (sec) {
sec.querySelector('.diff-section-header').innerHTML = '✓ ' + d.filename + ' — ' + d.ops_count + ' оп., ' + d.mode + ' (' + d.time_s + 'с)';
sec.querySelector('.diff-section-header').style.color = 'var(--green)';
}
}
else if (d.type === 'applied') {
var sec = sections[d.supplement_id];
if (sec && d.ops && d.ops.length > 0) {
var s = d.summary || {};
var body = sec.querySelector('.diff-section-body');
var html = '<div style="font-size:12px;margin-bottom:6px;">+' + (s.added||0) + ' ~' + (s.updated||0) + ' -' + (s.deleted||0) + ' ?' + (s.unresolved||0) + '</div>';
html += '<div class="table-wrap"><table style="font-size:11px;"><thead><tr><th>Действие</th><th>Услуга</th><th>Цена</th><th>Кол-во</th><th>Сумма</th><th>Дата</th></tr></thead><tbody>';
d.ops.forEach(function(op) {
var action = op.action;
var cls = action === 'ADD' ? 'diff-added' : action === 'DELETE' ? 'diff-deleted' : action === 'UPDATE' ? 'diff-changed' : '';
var nr = op.new_row || {};
var name = nr.name || '';
var price = nr.price != null ? nr.price : '';
var qty = nr.qty != null ? nr.qty : '';
var sum = nr.sum != null ? nr.sum : '';
var ds = nr.date_start || '';
html += '<tr class="' + cls + '"><td>' + action + '</td><td>' + name + '</td><td class="num-cell">' + price + '</td><td class="num-cell">' + qty + '</td><td class="num-cell">' + sum + '</td><td>' + ds + '</td></tr>';
});
html += '</tbody></table></div>';
body.innerHTML = html;
body.style.display = 'block';
}
}
else if (d.type === 'extract_error' || d.type === 'apply_error') {
diffBody.innerHTML += '<div style="font-size:12px;color:var(--destructive);">✗ ' + d.filename + ': ' + d.error + '</div>';
}
else if (d.type === 'done') {
clearInterval(timerInterval);
es.close();
diffStatus.textContent = '✓ ' + d.total_time_s + 'с';
btn.innerHTML = '<i data-lucide="check" style="width:16px;height:16px;"></i> ✓ Готово'; btn.innerHTML = '<i data-lucide="check" style="width:16px;height:16px;"></i> ✓ Готово';
stepDone('stepCompare');
if (d.total_unresolved > 0) { if (d.total_unresolved > 0) {
diffBody.innerHTML += '<div style="margin-top:8px;color:#eab308;">⚠ ' + d.total_unresolved + ' неразрешённых строк — <a href="/resolve.cfm?contract_id=' + contractId + '">разобрать</a></div>'; compareBody.innerHTML += '<div style="margin-top:8px;color:#eab308;">⚠ ' + d.total_unresolved + ' неразрешённых строк — <a href=\"/resolve.cfm?contract_id=' + state.contractId + '\">разобрать</a></div>';
} }
document.getElementById('chatCard').style.display = 'block'; document.getElementById('chatCard').style.display = 'block';
lucide.createIcons(); lucide.createIcons();
} },
else if (d.type === 'error') { onError: function(d) {
clearInterval(timerInterval); compareBody.innerHTML += '<div style="color:var(--destructive);margin-top:8px;">✗ ' + d.message + '</div>';
es.close(); btn.innerHTML = '<i data-lucide="scale" style="width:16px;height:16px;"></i> Общее сравнение';
diffBody.innerHTML += '<div style="color:var(--destructive);margin-top:8px;">✗ ' + d.message + '</div>';
btn.innerHTML = '<i data-lucide="scale" style="width:16px;height:16px;"></i> Сравнить';
btn.disabled = false; btn.disabled = false;
},
onConnectionError: function() {
if (compareBody.innerHTML === '') {
compareBody.innerHTML = '<div style="color:var(--destructive);">✗ Ошибка соединения</div>';
} }
}; btn.innerHTML = '<i data-lucide="scale" style="width:16px;height:16px;"></i> Общее сравнение';
es.onerror = function() {
clearInterval(timerInterval);
es.close();
if (diffBody.innerHTML === '') {
diffBody.innerHTML = '<div style="color:var(--destructive);">✗ Ошибка соединения</div>';
}
btn.innerHTML = '<i data-lucide="scale" style="width:16px;height:16px;"></i> Сравнить';
btn.disabled = false; btn.disabled = false;
lucide.createIcons(); lucide.createIcons();
};
});
// ── Обновить supplement_id/type для radio ──────────────────
async function refreshSupps() {
if (!contractId) { console.log('refreshSupps: no contractId'); return; }
try {
var resp = await fetch(VM_API + '/api/supplements?contract_id=' + 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 < fileQueue.length; i++) {
if (fileQueue[i].doc_id === supp.document_id) {
fileQueue[i].supp_id = supp.id;
fileQueue[i].supp_type = supp.type;
}
} }
}); });
renderTable(); });
}
} catch(e) { console.log('refreshSupps error:', e); } // refreshSupps → files.js (Фаза 1)
}
// ── Чат ────────────────────────────────────────────────────── // ── Чат ──────────────────────────────────────────────────────
document.getElementById('chatSend').addEventListener('click', function() { document.getElementById('chatSend').addEventListener('click', function() {
var input = document.getElementById('chatInput'); var input = document.getElementById('chatInput');
var q = input.value.trim(); var q = input.value.trim();
if (!q || !contractId) return; if (!q || !state.contractId) return;
var msgs = document.getElementById('chatMessages'); var msgs = document.getElementById('chatMessages');
msgs.innerHTML += '<div style="color:var(--brand-primary);margin-top:4px;"><strong>Вы:</strong> ' + q + '</div>'; msgs.innerHTML += '<div style="color:var(--brand-primary);margin-top:4px;"><strong>Вы:</strong> ' + q + '</div>';
input.value = ''; input.value = '';
@@ -402,7 +371,7 @@ document.getElementById('chatSend').addEventListener('click', function() {
var fd = new FormData(); var fd = new FormData();
fd.append('question', q); fd.append('question', q);
fetch('/chat.cfm?contract_id=' + contractId, { method: 'POST', body: fd }) fetch('/chat.cfm?contract_id=' + state.contractId, { method: 'POST', body: fd })
.then(function(r) { return r.json(); }) .then(function(r) { return r.json(); })
.then(function(d) { .then(function(d) {
msgs.lastChild.remove(); msgs.lastChild.remove();
@@ -428,7 +397,7 @@ function closeModal(e) {
} }
async function showText(i) { async function showText(i) {
var f = fileQueue[i]; var f = state.files[i];
var docId = f.doc_id; var docId = f.doc_id;
document.getElementById('modalTitle').textContent = f.name; document.getElementById('modalTitle').textContent = f.name;
document.getElementById('modalBody').innerHTML = '<span style="color:var(--muted);">Загрузка...</span>'; document.getElementById('modalBody').innerHTML = '<span style="color:var(--muted);">Загрузка...</span>';
@@ -488,6 +457,22 @@ async function showText(i) {
if (f.supp_id) { if (f.supp_id) {
rightHtml += '<div class="kv"><span class="k">Тип ДС</span><span class="v">' + (f.supp_type || '—') + '</span></div>'; rightHtml += '<div class="kv"><span class="k">Тип ДС</span><span class="v">' + (f.supp_type || '—') + '</span></div>';
} }
// ── Результаты классификации ──────────────────────────
if (qData.classify_status === 'classified' || qData.classify_raw) {
rightHtml += '<div style="margin-top:12px;font-weight:600;font-size:12px;border-top:1px solid var(--brand-gray);padding-top:8px;">🏷️ Классификация LLM</div>';
if (qData.doc_type) rightHtml += '<div class="kv"><span class="k">Тип</span><span class="v">' + escHtml(qData.doc_type) + '</span></div>';
if (qData.own_number) rightHtml += '<div class="kv"><span class="k">Номер</span><span class="v">' + escHtml(qData.own_number) + '</span></div>';
if (qData.parent_number) rightHtml += '<div class="kv"><span class="k">Родительский</span><span class="v">' + escHtml(qData.parent_number) + '</span></div>';
if (qData.doc_date) rightHtml += '<div class="kv"><span class="k">Дата</span><span class="v">' + escHtml(qData.doc_date) + '</span></div>';
if (qData.counterparty) rightHtml += '<div class="kv"><span class="k">Контрагент</span><span class="v">' + escHtml(qData.counterparty) + '</span></div>';
if (qData.classify_raw) {
var rawId = 'raw_' + docId.replace(/-/g,'');
rightHtml += '<details style="margin-top:8px;"><summary style="cursor:pointer;font-size:11px;color:var(--brand-primary);">Сырой ответ LLM</summary>';
rightHtml += '<pre style="font-size:10px;max-height:200px;overflow:auto;background:var(--brand-grey-light);padding:6px;border-radius:4px;margin-top:4px;">' + escHtml(qData.classify_raw) + '</pre>';
rightHtml += '</details>';
}
}
} catch(e) { } catch(e) {
leftHtml = '<span style="color:var(--destructive);">Ошибка</span>'; leftHtml = '<span style="color:var(--destructive);">Ошибка</span>';
} }
@@ -505,8 +490,6 @@ async function showText(i) {
window.closeModal = closeModal; window.closeModal = closeModal;
window.showText = showText; window.showText = showText;
// ── Промпт ───────────────────────────────────────────────────
var promptEditor = document.getElementById('promptEditor');
// ── Промпты (версионные) ───────────────────────────────────── // ── Промпты (версионные) ─────────────────────────────────────
var promptRole = 'diff'; // текущая выбранная роль var promptRole = 'diff'; // текущая выбранная роль
var promptEditor = document.getElementById('promptEditor'); var promptEditor = document.getElementById('promptEditor');
@@ -516,15 +499,22 @@ var promptModalOverlay = document.getElementById('promptModalOverlay');
var promptModalTitle = document.getElementById('promptModalTitle'); var promptModalTitle = document.getElementById('promptModalTitle');
var promptName = document.getElementById('promptName'); var promptName = document.getElementById('promptName');
var promptNotes = document.getElementById('promptNotes'); var promptNotes = document.getElementById('promptNotes');
var activePromptId = {extract: '', diff: ''}; // Три роли промптов:
// extract — извлечение строк из первого документа (базовый договор)
// diff — сравнение допсоглашений (ADD/UPDATE/DELETE)
// classify — классификация документа (тип, номер, контрагент, дата)
var activePromptId = {extract: '', diff: '', classify: ''};
function openPromptModal(role) { function openPromptModal(role) {
promptRole = role; promptRole = role;
promptModalTitle.textContent = 'Промпт: ' + (role === 'extract' ? 'Извлечение' : 'Сравнение'); var labels = {extract: 'Извлечение', diff: 'Сравнение', classify: 'Классификация'};
promptModalTitle.textContent = 'Промпт: ' + (labels[role] || role);
document.getElementById('promptTabExtract').style.background = role === 'extract' ? 'var(--brand-primary)' : ''; document.getElementById('promptTabExtract').style.background = role === 'extract' ? 'var(--brand-primary)' : '';
document.getElementById('promptTabExtract').style.color = role === 'extract' ? '#fff' : ''; document.getElementById('promptTabExtract').style.color = role === 'extract' ? '#fff' : '';
document.getElementById('promptTabDiff').style.background = role === 'diff' ? 'var(--brand-primary)' : ''; document.getElementById('promptTabDiff').style.background = role === 'diff' ? 'var(--brand-primary)' : '';
document.getElementById('promptTabDiff').style.color = role === 'diff' ? '#fff' : ''; 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 = ''; promptStatus.textContent = '';
promptName.value = ''; promptName.value = '';
promptNotes.value = ''; promptNotes.value = '';
@@ -538,12 +528,12 @@ function closePromptModal(e) {
} }
function loadPrompt(role) { function loadPrompt(role) {
fetch('/prompt.cfm?action=get_active&role=' + role) fetch(VM_API + '/api/prompts?role=' + role)
.then(function(r) { return r.json(); }) .then(function(r) { return r.json(); })
.then(function(d) { .then(function(d) {
if (d.OK) { if (d.ok) {
promptEditor.value = d.BODY || ''; promptEditor.value = d.body || '';
activePromptId[role] = d.ID || ''; activePromptId[role] = d.id || '';
loadHistory(role); loadHistory(role);
} }
}) })
@@ -551,53 +541,52 @@ function loadPrompt(role) {
} }
function loadHistory(role) { function loadHistory(role) {
fetch('/prompt.cfm?action=list&role=' + role) fetch(VM_API + '/api/prompts/list?role=' + role)
.then(function(r) { return r.json(); }) .then(function(r) { return r.json(); })
.then(function(d) { .then(function(d) {
if (!d.OK || !d.VERSIONS || !d.VERSIONS.length) { if (!d.ok || !d.versions || !d.versions.length) {
promptHistory.innerHTML = '<div style="color:var(--muted);">нет версий</div>'; promptHistory.innerHTML = '<div style="color:var(--muted);">нет версий</div>';
return; return;
} }
promptHistory.innerHTML = d.VERSIONS.map(function(v) { promptHistory.innerHTML = d.versions.map(function(v) {
var isActive = v.IS_ACTIVE ? ' ' : ''; var isActive = v.is_active ? '<span style="display:inline-block;width:8px;height:8px;border-radius:50%;background:var(--green);margin-right:4px;" title="активная"></span> ' : '';
var style = v.IS_ACTIVE ? 'font-weight:600;' : ''; var style = v.is_active ? 'font-weight:600;' : '';
return '<div style="display:flex;align-items:center;padding:4px 0;border-bottom:1px solid var(--brand-gray);font-size:11px;' + style + '">' return '<div style="display:flex;align-items:center;padding:4px 0;border-bottom:1px solid var(--brand-gray);font-size:11px;' + style + '">'
+ '<span style="flex:1;">' + isActive + escHtml(v.NAME) + ' <span style="color:var(--muted);">' + fmtDate(v.CREATED_AT) + '</span></span>' + '<span style="flex:1;">' + isActive + escHtml(v.name) + ' <span style="color:var(--muted);">' + fmtDate(v.created_at) + '</span>'
+ (!v.IS_ACTIVE ? '<button class="arrow-btn" onclick="activatePrompt(\'' + v.ID + '\')" title="Активировать" style="font-size:14px;">←</button>' : '') + (v.notes ? ' <span style="color:var(--muted);font-style:italic;">— ' + escHtml(v.notes) + '</span>' : '')
+ (!v.IS_ACTIVE ? '<button class="arrow-btn" onclick="deletePrompt(\'' + v.ID + '\')" title="Удалить" style="font-size:14px;">✕</button>' : '') + '</span>'
+ (!v.is_active ? '<button class="btn" onclick="activatePrompt(\'' + v.id + '\')" style="font-size:11px;height:22px;padding:0 8px;margin-left:4px;">активировать</button>' : '')
+ (!v.is_active ? '<button class="btn" onclick="deletePrompt(\'' + v.id + '\')" style="font-size:11px;height:22px;padding:0 8px;margin-left:2px;color:var(--destructive);">удалить</button>' : '')
+ '</div>'; + '</div>';
}).join(''); }).join('');
}) })
.catch(function(e) { console.error('loadHistory:', e); }); .catch(function(e) { console.error('loadHistory:', e); });
} }
function escHtml(s) { var d = document.createElement('div'); d.textContent = s; return d.innerHTML; }
function fmtDate(d) { if (!d) return ''; return d.replace('T', ' ').substring(0, 16); }
function activatePrompt(id) { function activatePrompt(id) {
fetch('/prompt.cfm?action=activate', { fetch(VM_API + '/api/prompts/activate', {
method: 'POST', method: 'POST',
headers: {'Content-Type': 'application/json'}, headers: {'Content-Type': 'application/json'},
body: JSON.stringify({id: id}) body: JSON.stringify({id: id})
}) })
.then(function(r) { return r.json(); }) .then(function(r) { return r.json(); })
.then(function(d) { .then(function(d) {
if (d.OK) { loadPrompt(promptRole); promptStatus.textContent = '✓ активирован'; } if (d.ok) { loadPrompt(promptRole); promptStatus.textContent = '✓ активирован'; }
else { promptStatus.textContent = '✕ ' + (d.ERROR || 'ошибка'); } else { promptStatus.textContent = '✕ ' + (d.error || 'ошибка'); }
}); });
} }
function deletePrompt(id) { function deletePrompt(id) {
if (!confirm('Удалить эту версию промпта?')) return; if (!confirm('Удалить эту версию промпта?')) return;
fetch('/prompt.cfm?action=delete', { fetch(VM_API + '/api/prompts/delete', {
method: 'POST', method: 'POST',
headers: {'Content-Type': 'application/json'}, headers: {'Content-Type': 'application/json'},
body: JSON.stringify({id: id}) body: JSON.stringify({id: id})
}) })
.then(function(r) { return r.json(); }) .then(function(r) { return r.json(); })
.then(function(d) { .then(function(d) {
if (d.OK) { loadHistory(promptRole); promptStatus.textContent = '✓ удалено'; } if (d.ok) { loadHistory(promptRole); promptStatus.textContent = '✓ удалено'; }
else { promptStatus.textContent = '✕ ' + (d.ERROR || 'ошибка'); } else { promptStatus.textContent = '✕ ' + (d.error || 'ошибка'); }
}); });
} }
@@ -605,29 +594,30 @@ document.getElementById('promptSave').addEventListener('click', function() {
var body = promptEditor.value.trim(); var body = promptEditor.value.trim();
if (!body) { promptStatus.textContent = '✕ тело промпта пустое'; return; } if (!body) { promptStatus.textContent = '✕ тело промпта пустое'; return; }
var name = promptName.value.trim() || ('v' + new Date().toISOString().replace(/[:.]/g,'-').substring(0,16)); var name = promptName.value.trim() || ('v' + new Date().toISOString().replace(/[:.]/g,'-').substring(0,16));
fetch('/prompt.cfm?action=save', { fetch(VM_API + '/api/prompts/save', {
method: 'POST', method: 'POST',
headers: {'Content-Type': 'application/json'}, headers: {'Content-Type': 'application/json'},
body: JSON.stringify({ body: JSON.stringify({
role: promptRole, name: name, body: body, role: promptRole, name: name, body: body,
parent_id: activePromptId[promptRole] || '',
notes: promptNotes.value.trim(), is_active: true notes: promptNotes.value.trim(), is_active: true
}) })
}) })
.then(function(r) { return r.json(); }) .then(function(r) { return r.json(); })
.then(function(d) { .then(function(d) {
if (d.OK) { if (d.ok) {
promptStatus.textContent = '✓ сохранено (новая версия)'; promptStatus.textContent = '✓ сохранено (новая версия)';
promptName.value = ''; promptNotes.value = ''; promptName.value = ''; promptNotes.value = '';
loadPrompt(promptRole); loadPrompt(promptRole);
} else { } else {
promptStatus.textContent = '✕ ' + (d.ERROR || 'ошибка'); promptStatus.textContent = '✕ ' + (d.error || 'ошибка');
} }
}); });
}); });
document.getElementById('promptTabExtract').addEventListener('click', function() { openPromptModal('extract'); }); document.getElementById('promptTabExtract').addEventListener('click', function() { openPromptModal('extract'); });
document.getElementById('promptTabDiff').addEventListener('click', function() { openPromptModal('diff'); }); 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('promptBtnExtract').addEventListener('click', function() { openPromptModal('extract'); });
document.getElementById('promptBtnDiff').addEventListener('click', function() { openPromptModal('diff'); }); document.getElementById('promptBtnDiff').addEventListener('click', function() { openPromptModal('diff'); });
document.getElementById('promptBtnClassify').addEventListener('click', function() { openPromptModal('classify'); });
lucide.createIcons(); lucide.createIcons();
+108
View File
@@ -0,0 +1,108 @@
/**
* app_utils.js — Общие утилиты (форматирование, стрелки, модалки).
* Вынесены из app.js для облегчения анализа и отладки.
* Загружается ДО app.js.
*/
/**
* Форматирует размер файла в человекочитаемый вид.
* Используется в renderTable() для колонки "Размер".
*/
function formatSize(bytes) {
if (!bytes || bytes === 0) return '—';
if (bytes < 1024) return bytes + ' B';
if (bytes < 1048576) return (bytes / 1024).toFixed(1) + ' KB';
return (bytes / 1048576).toFixed(1) + ' MB';
}
/**
* Форматирует timestamp в российскую дату + время.
* Используется в renderTable() для колонки "Изменён".
*/
function formatDate(ts) {
if (!ts) return '—';
var d = new Date(ts);
return d.toLocaleDateString('ru-RU') + ' ' + d.toLocaleTimeString('ru-RU', {hour:'2-digit',minute:'2-digit'});
}
/**
* Удаляет файл из очереди по индексу.
* Вызывается по кнопке ✕ в таблице файлов.
*
* Фаза 0 (state + render): мутирует state.files, затем render(state).
* Если очередь опустела — сбрасывает state.contractId.
*/
function removeFile(i) {
state.files.splice(i, 1);
if (state.files.length === 0) state.contractId = null;
render(state);
// Синхронизировать БД с таблицей
if (typeof syncDB === 'function') syncDB();
// Сбросить прогресс при изменении состава файлов
if (typeof resetStepper === 'function') resetStepper('stepUpload');
// Разблокировать кнопку классификации при изменении состава файлов
if (typeof showClassifyBtn === 'function') showClassifyBtn();
}
window.removeFile = removeFile;
/**
* Переместить файл на одну позицию ВВЕРХ (сохранено для возможного возврата ручной сортировки).
* В текущей версии (v1.0.164+) не используется — порядок определяет авто-классификация.
*
* Фаза 0 (state + render): мутирует state.files, затем render(state).
*/
function moveUp(i) {
if (i <= 0) return;
var tmp = state.files[i]; state.files[i] = state.files[i-1]; state.files[i-1] = tmp;
render(state);
}
/**
* Переместить файл на одну позицию ВНИЗ (сохранено для возможного возврата ручной сортировки).
* В текущей версии (v1.0.164+) не используется — порядок определяет авто-классификация.
*
* Фаза 0 (state + render): мутирует state.files, затем render(state).
*/
function moveDown(i) {
if (i >= state.files.length - 1) return;
var tmp = state.files[i]; state.files[i] = state.files[i+1]; state.files[i+1] = tmp;
render(state);
}
window.moveUp = moveUp;
window.moveDown = moveDown;
/**
* Открыть модальное окно "О сервисе".
*/
function openAbout() {
document.getElementById('aboutModalOverlay').classList.add('open');
}
/**
* Закрыть модальное окно "О сервисе" (по клику на оверлей или кнопку).
*/
function closeAbout(e) {
if (e && e.target !== document.getElementById('aboutModalOverlay')) return;
document.getElementById('aboutModalOverlay').classList.remove('open');
}
window.openAbout = openAbout;
window.closeAbout = closeAbout;
/**
* Экранировать HTML (для безопасного рендеринга пользовательских данных).
* Используется в истории промптов.
*/
function escHtml(s) {
if (s == null) return '';
var d = document.createElement('div');
d.textContent = s;
return d.innerHTML;
}
/**
* Форматировать дату для истории промптов (YYYY-MM-DD HH:MM).
*/
function fmtDate(d) {
if (!d) return '';
return d.replace('T', ' ').substring(0, 16);
}
+38
View File
@@ -0,0 +1,38 @@
"""
classify_worker.py — Фоновый процесс классификации (Фаза async classify).
Запускается через subprocess.Popen из convert_server.py.
Принимает batch_id как аргумент командной строки.
Не зависит от HTTP-сервера — свои коннекты к БД, своя память.
ЗАЧЕМ: threading.Thread внутри HTTP-процесса делит коннекты к БД
с HTTP-потоком. При 50+ файлах ThreadPoolExecutor(4) + HTTP-поток
исчерпывают пул коннектов → сервер не отвечает → 502.
subprocess.Popen создаёт отдельный питон-процесс со своей памятью
и своими коннектами. HTTP-сервер продолжает отвечать на batch-progress.
Процесс живёт пока classify не завершится, потом умирает.
"""
import sys, os
sys.path.insert(0, os.path.dirname(__file__))
from services.classify import classify_batch
if __name__ == "__main__":
if len(sys.argv) < 2:
print("Usage: python3 classify_worker.py <batch_id>", 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)
+243
View File
@@ -0,0 +1,243 @@
/**
* compare.js — Модуль сравнения через SSE (Фаза 3, decoupling-final-plan.md).
*
* УНИФИЦИРУЕТ дублирование (~160 строк) между runCompareForGroup и llmBtn.
*
* ПАТТЕРН:
* applyCompareEvent(sections, event) — чистая мутация: SSE-событие → state
* startCompareSSE(url, container, statusEl, callbacks) — жизненный цикл SSE
* renderCompareOpsTable(ops) — общий рендер таблицы операций
*
* ЗАВИСИМОСТИ:
* state.js → state (для _activeCompare)
*/
/**
* applyCompareEvent(sections, event) — Чистая функция (Фаза 3).
*
* Применяет ОДНО SSE-событие к состоянию sections.
* НЕ трогает DOM — только мутирует объект sections.
*
* Вход: sections — объект { [supplement_id]: sectionState }
* event — { type, supplement_id, filename, ops_count, mode, ... }
* Выход: нет (мутирует sections)
*
* Типы событий:
* extract_start → создаёт запись в sections
* llm_done → обновляет статус, ops_count, mode, time_s
* applied → сохраняет ops и summary
* extract_error/apply_error → сохраняет ошибку
*/
function applyCompareEvent(sections, event) {
var d = event;
var suppId = d.supplement_id;
if (d.type === 'extract_start') {
// Создать новую секцию: извлечение началось
sections[suppId] = {
filename: d.filename,
status: 'extracting',
ops_count: 0,
mode: '',
time_s: 0,
summary: null,
ops: null,
error: null
};
}
else if (d.type === 'llm_done') {
// LLM закончил — сохранить метаданные
var sec = sections[suppId];
if (sec) {
sec.status = 'llm_done';
sec.ops_count = d.ops_count || 0;
sec.mode = d.mode || '';
sec.time_s = d.time_s || 0;
}
}
else if (d.type === 'applied') {
// Результаты сравнения применены — сохранить ops и summary
var sec = sections[suppId];
if (sec) {
sec.status = 'applied';
sec.summary = d.summary || {};
sec.ops = d.ops || [];
}
}
else if (d.type === 'extract_error' || d.type === 'apply_error') {
// Ошибка извлечения или применения
var sec = sections[suppId];
if (sec) {
sec.status = 'error';
sec.error = d.error || 'неизвестная ошибка';
} else {
// Ошибка для ещё не созданной секции
sections[suppId] = {
filename: d.filename || '?',
status: 'error',
error: d.error || 'неизвестная ошибка',
ops_count: 0, mode: '', time_s: 0, summary: null, ops: null
};
}
}
}
/**
* renderCompareSectionHeader(sec) — Чистая функция: заголовок секции (Фаза 3).
*
* Вход: sec — { filename, status, ops_count, mode, time_s, error }
* Выход: HTML-строка для div.diff-section-header
*/
function renderCompareSectionHeader(sec) {
if (sec.status === 'extracting') {
return '⏳ ' + sec.filename;
}
if (sec.status === 'llm_done' || sec.status === 'applied') {
return '✓ ' + sec.filename + ' — ' + sec.ops_count + ' оп., ' + sec.mode + ' (' + sec.time_s + 'с)';
}
if (sec.status === 'error') {
return '✗ ' + sec.filename + ': ' + sec.error;
}
return sec.filename;
}
/**
* renderCompareOpsTable(ops) — Чистая функция: таблица операций (Фаза 3).
*
* Используется ОБОИМИ обработчиками (runCompareForGroup и llmBtn).
* Раньше этот код был продублирован ~30 строк × 2.
*
* Вход: ops — [{ action, new_row: { name, price, qty, sum, date_start } }]
* Выход: HTML-строка <table>
*/
function renderCompareOpsTable(ops) {
var html = '<div class="table-wrap"><table style="font-size:11px;"><thead><tr><th>Действие</th><th>Услуга</th><th>Цена</th><th>Кол-во</th><th>Сумма</th><th>Дата</th></tr></thead><tbody>';
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 += '<tr class="' + cls + '"><td>' + action + '</td><td>' + (nr.name||'') + '</td><td class="num-cell">' + (nr.price!=null?nr.price:'') + '</td><td class="num-cell">' + (nr.qty!=null?nr.qty:'') + '</td><td class="num-cell">' + (nr.sum!=null?nr.sum:'') + '</td><td>' + (nr.date_start||'') + '</td></tr>';
});
html += '</tbody></table></div>';
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 = '<div style="font-size:12px;margin-bottom:6px;">';
html += '+' + (s.added||0) + ' ~' + (s.updated||0) + ' -' + (s.deleted||0);
if (s.unresolved) html += ' ?' + s.unresolved;
html += '</div>';
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 =
'<div class="diff-section-header" style="padding:8px 12px;background:var(--brand-grey-light);cursor:pointer;font-size:13px;font-weight:600;"' +
' onclick="var b=this.nextElementSibling;b.style.display=b.style.display===\'none\'?\'block\':\'none\';">' +
renderCompareSectionHeader(sec) + '</div>' +
'<div class="diff-section-body" style="display:none;padding:8px 12px;"></div>';
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 += '<div style="font-size:12px;color:var(--destructive);">✗ ' + d.filename + ': ' + d.error + '</div>';
}
// ── 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 };
}
+273 -3
View File
@@ -1,14 +1,18 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
# ⛔ НЕ МЕНЯТЬ БЕЗ РАЗРЕШЕНИЯ НАЕЛЯ ⛔
"""Contracts VM server — thin HTTP router. All logic in db/ and services/.""" """Contracts VM server — thin HTTP router. All logic in db/ and services/."""
from http.server import HTTPServer, BaseHTTPRequestHandler from http.server import HTTPServer, BaseHTTPRequestHandler
from socketserver import ThreadingMixIn, TCPServer from socketserver import ThreadingMixIn, TCPServer
from urllib.parse import urlparse, parse_qs from urllib.parse import urlparse, parse_qs
import json, re, os import json, re, os, sys
# ── DB auto-seed ────────────────────────────────────────────────────────── # ── DB auto-seed ──────────────────────────────────────────────────────────
from db import prompts as db_prompts from db import prompts as db_prompts
from db.connection import DB_CONFIG from db.connection import DB_CONFIG, execute
db_prompts.seed_defaults() 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")
# ── DB modules ──────────────────────────────────────────────────────────── # ── DB modules ────────────────────────────────────────────────────────────
from db import supplements as db_supplements from db import supplements as db_supplements
@@ -44,6 +48,14 @@ class Handler(BaseHTTPRequestHandler):
self._handle_app_js() self._handle_app_js()
elif parsed.path == "/api/supplements": elif parsed.path == "/api/supplements":
self._handle_api_supplements(parsed) 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/"): elif parsed.path.startswith("/api/documents/"):
self._handle_api_document(parsed) self._handle_api_document(parsed)
else: else:
@@ -58,6 +70,27 @@ class Handler(BaseHTTPRequestHandler):
self._handle_llm_ops() self._handle_llm_ops()
elif self.path == "/convert-doc": elif self.path == "/convert-doc":
self._handle_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: else:
self.send_error(404) self.send_error(404)
@@ -117,18 +150,255 @@ class Handler(BaseHTTPRequestHandler):
"filename": doc["filename"], "filename": doc["filename"],
"status": doc["status"], "status": doc["status"],
"elements_json": doc.get("elements_json"), "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/<id> — 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) ─────────────────────────────────────────────────── # ── /app.js (static) ───────────────────────────────────────────────────
def _handle_app_js(self): def _handle_app_js(self):
try: try:
fname = self.path.lstrip("/").replace("static/", "") fname = urlparse(self.path).path.lstrip("/").replace("static/", "")
with open(os.path.join(os.path.dirname(__file__), fname), "rb") as f: with open(os.path.join(os.path.dirname(__file__), fname), "rb") as f:
js = f.read() js = f.read()
self.send_response(200) self.send_response(200)
self.send_header("Content-Type", "application/javascript; charset=utf-8") self.send_header("Content-Type", "application/javascript; charset=utf-8")
self.send_header("Content-Length", str(len(js))) self.send_header("Content-Length", str(len(js)))
self.send_header("X-Content-Type-Options", "nosniff")
self._send_cors() self._send_cors()
self.end_headers() self.end_headers()
self.wfile.write(js) self.wfile.write(js)
+25 -4
View File
@@ -1,11 +1,22 @@
"""Database connection — psycopg2 connection pool.""" """
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 os
import psycopg2 import psycopg2
import psycopg2.pool import psycopg2.pool
import psycopg2.extras import psycopg2.extras
# Глобальный пул соединений (singleton)
_pool = None _pool = None
# Параметры подключения из переменных окружения (systemd Environment)
DB_CONFIG = { DB_CONFIG = {
"host": os.getenv("DB_HOST", "127.0.0.1"), "host": os.getenv("DB_HOST", "127.0.0.1"),
"port": int(os.getenv("DB_PORT", "5432")), "port": int(os.getenv("DB_PORT", "5432")),
@@ -16,6 +27,7 @@ DB_CONFIG = {
def get_pool(): def get_pool():
"""Возвращает глобальный пул соединений. Создаёт при первом вызове."""
global _pool global _pool
if _pool is None: if _pool is None:
_pool = psycopg2.pool.ThreadedConnectionPool( _pool = psycopg2.pool.ThreadedConnectionPool(
@@ -25,7 +37,10 @@ def get_pool():
def query(sql, params=None): def query(sql, params=None):
"""SELECT → list of dicts (lowercase keys).""" """
SELECT → list[dict] с lowercase ключами.
Используется всеми db/*.py модулями для чтения данных.
"""
pool = get_pool() pool = get_pool()
conn = pool.getconn() conn = pool.getconn()
try: try:
@@ -38,7 +53,10 @@ def query(sql, params=None):
def execute(sql, params=None): def execute(sql, params=None):
"""INSERT/UPDATE/DELETE → rowcount.""" """
INSERT/UPDATE/DELETE → количество затронутых строк.
Автокоммит. При ошибке — rollback и проброс исключения.
"""
pool = get_pool() pool = get_pool()
conn = pool.getconn() conn = pool.getconn()
try: try:
@@ -54,7 +72,10 @@ def execute(sql, params=None):
def execute_returning(sql, params=None): def execute_returning(sql, params=None):
"""INSERT/UPDATE/DELETE with RETURNING → first row dict.""" """
INSERT/UPDATE/DELETE с RETURNING → dict первой строки.
Используется для insert с автогенерацией UUID (gen_random_uuid()).
"""
pool = get_pool() pool = get_pool()
conn = pool.getconn() conn = pool.getconn()
try: try:
+56 -4
View File
@@ -2,12 +2,12 @@
from .connection import query, execute, execute_returning from .connection import query, execute, execute_returning
def insert(filename, mime_type, original_bytes, status="uploaded"): def insert(filename, mime_type, original_bytes, status="uploaded", batch_id=None):
"""Insert document, return row dict.""" """Insert document, return row dict."""
return execute_returning( return execute_returning(
"""INSERT INTO documents (filename, mime_type, original_bytes, status) """INSERT INTO documents (filename, mime_type, original_bytes, status, batch_id)
VALUES (%s, %s, %s, %s) RETURNING *""", VALUES (%s, %s, %s, %s, %s) RETURNING *""",
(filename, mime_type, original_bytes, status), (filename, mime_type, original_bytes, status, batch_id),
) )
@@ -34,3 +34,55 @@ def set_error(doc_id, error_message):
def delete(doc_id): def delete(doc_id):
return execute("DELETE FROM documents WHERE id = %s", (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 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' для всех документов батча."""
return execute(
"UPDATE documents SET classify_status='pending', error_message=NULL WHERE batch_id=%s",
(batch_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
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}
+130 -16
View File
@@ -2,12 +2,20 @@
from .connection import query, execute, execute_returning 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): def get_active(role):
rows = query( rows = query(
"SELECT * FROM prompts WHERE role = %s AND is_active = true ORDER BY created_at DESC LIMIT 1", "SELECT * FROM prompts WHERE role = %s AND is_active = true ORDER BY created_at DESC LIMIT 1",
(role,), (role,),
) )
return rows[0] if rows else None return _serialize(rows[0]) if rows else None
def get(prompt_id): def get(prompt_id):
@@ -17,21 +25,127 @@ def get(prompt_id):
def seed_defaults(): def seed_defaults():
"""Auto-seed default prompts if table is empty.""" """Auto-seed default prompts if table is empty."""
count = query("SELECT COUNT(*) as cnt FROM prompts") # Check each role separately — don't skip if one is missing
if count[0]["cnt"] > 0: existing_roles = set(r["role"] for r in query("SELECT DISTINCT role FROM prompts"))
return
execute(""" if "extract" not in existing_roles:
INSERT INTO prompts (role, name, body, is_active, notes) VALUES ( extract_body = (
'extract', 'default-v1', "Ты — анализатор договоров облачного провайдера.\n\n"
'Ты — анализатор договоров облачного провайдера.\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ПРАВИЛА:\n1. Извлеки КАЖДУЮ строку таблицы спецификации как отдельную ADD-операцию.\n2. Пропускай итоговые строки ("Итого...") и строки с подписями.\n3. Если ячейка пустая — ставь null (не пиши 0).\n4. price, qty, sum — ЧИСЛА, не строки.\n\nТЕКСТ ДОКУМЕНТА:\n---\n{doc_text}\n---', "Ниже текст спецификации услуг из ПЕРВОГО документа (базовый договор).\n"
true, 'Авто-создан из llm_prompt.py _build_initial' "Извлеки ВСЕ строки спецификации в 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(
execute(""" "INSERT INTO prompts (role, name, body, is_active, notes) VALUES (%s, %s, %s, %s, %s)",
INSERT INTO prompts (role, name, body, is_active, notes) VALUES ( ("extract", "default-v1", extract_body, True, "Авто-создан из llm_prompt.py _build_initial"),
'diff', 'default-v1', )
'Ты — анализатор допсоглашений к договорам облачного провайдера.\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ПРАВИЛА:\n1. mode = "full_replace" — если в тексте есть фразы: «в следующей редакции», «заменить приложение», «излагается в следующей редакции». При full_replace — опиши ВСЕ новые строки как ADD.\n2. mode = "partial" — если ДС меняет только отдельные строки.\n3. Для UPDATE/DELETE — укажи target_id (r1, r2...) из списка текущей спецификации. НЕ придумывай новые id.\n4. new_values в UPDATE — только ИЗМЕНЁННЫЕ поля (не все).\n5. Если не можешь однозначно сопоставить строку — action: UNRESOLVED с reason.\n6. Пропускай итоговые строки и подписи.\n7. price, qty, sum — ЧИСЛА (не строки).\n\nТЕКУЩАЯ СПЕЦИФИКАЦИЯ:\n{spec_current}\n\nТЕКСТ ДОПСОГЛАШЕНИЯ:\n---\n{doc_text}\n---',
true, 'Авто-создан из llm_prompt.py _build_diff' 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 сам предложил"),
) )
""")
+19 -4
View File
@@ -1,6 +1,6 @@
"""spec_events — event sourcing: apply ops, reset contract.""" """spec_events — event sourcing: apply ops, reset contract."""
import json, uuid import json, uuid
from .connection import query, execute from .connection import query, execute, get_pool
def reset(contract_id): def reset(contract_id):
@@ -10,11 +10,26 @@ def reset(contract_id):
def get_next_seq(contract_id): def get_next_seq(contract_id):
rows = query( """Get next sequence number with row lock to prevent race conditions."""
"SELECT COALESCE(MAX(seq), 0) + 1 AS n FROM spec_events WHERE contract_id = %s", 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,), (contract_id,),
) )
return rows[0]["n"] if rows else 1 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): def apply_ops(contract_id, supplement_id, document_id, ops, prompt_id, raw_llm_response):
+17 -5
View File
@@ -28,19 +28,31 @@ def get(supp_id):
def delete_by_document(contract_id, filename): def delete_by_document(contract_id, filename):
"""Delete supplement+document by contract+filename.""" """Delete ALL supplements+documents by contract+filename (cascade: spec_events first).
row = query( Handles duplicates from previously failed uploads."""
rows = query(
"""SELECT s.id as sid, s.document_id FROM supplements s """SELECT s.id as sid, s.document_id FROM supplements s
JOIN documents d ON d.id = s.document_id JOIN documents d ON d.id = s.document_id
WHERE s.contract_id = %s AND d.filename = %s""", WHERE s.contract_id = %s AND d.filename = %s""",
(contract_id, filename), (contract_id, filename),
) )
if row: if not rows:
r = row[0] 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"],)) execute("DELETE FROM supplements WHERE id = %s", (r["sid"],))
# 4. Delete document
execute("DELETE FROM documents WHERE id = %s", (r["document_id"],)) execute("DELETE FROM documents WHERE id = %s", (r["document_id"],))
return True return True
return False
def delete(supp_id): def delete(supp_id):
+473
View File
@@ -0,0 +1,473 @@
/**
* 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 '<span class="status-ok">✓</span>';
case 'unzipping': return '⏳ распаковка...';
case 'parsing': return '⏳ парсинг...';
case 'parsed': return '<span class="status-ok">✓ ' + (st.count || 0) + ' эл.' + (st.elapsed ? ' (' + st.elapsed + 'с)' : '') + '</span>';
case 'error': return '<span class="status-err">✗ ' + (st.text || 'Неизвестная ошибка') + '</span>';
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 = '<tr class="empty-row"><td colspan="7">Нет файлов — выберите .docx / .pdf</td></tr>';
} else {
fileTable.innerHTML = state.files.map(function(f, i) {
var rows = '<tr id="row_' + i + '">' +
'<td class="name-cell" style="cursor:pointer;" onclick="toggleClassifyDetail(' + i + ')">' +
(f.doc_id ? '<span id="expand_' + i + '" style="font-size:10px;margin-right:4px;">▸</span>' : '') +
escHtml(f.name) + '</td>' +
'<td style="font-size:12px;color:var(--muted);">' + formatDate(f.lastModified) + '</td>' +
'<td class="num-cell" style="font-size:12px;color:var(--muted);">' + formatSize(f.size) + '</td>' +
'<td class="status-cell">' + statusToHTML(f.status) + '</td>' +
'<td><button class="info-btn' + (f.doc_id ? ' visible' : '') + '" onclick="showText(' + i + ')" title="Текст / Распарсено"><i data-lucide="file-text" style="width:16px;height:16px;"></i></button></td>' +
'<td><button class="remove-btn" onclick="removeFile(' + i + ')" title="Удалить"><i data-lucide="trash-2" style="width:16px;height:16px;"></i></button></td>' +
'</tr>' +
'<tr id="detail_' + i + '" style="display:none;"><td colspan="7" style="padding:4px 12px;font-size:11px;background:var(--brand-grey-light);"><span style="color:var(--muted);">Загрузка...</span></td></tr>';
return 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 = '<span style="color:var(--destructive);">Ошибка загрузки</span>'; return; }
var html = '';
if (qData.classify_status === 'classified') {
html += '<div style="font-weight:600;margin-bottom:4px;">🏷️ Результат классификации</div>';
html += '<div style="display:flex;gap:16px;flex-wrap:wrap;">';
if (qData.doc_type) html += '<span><b>Тип:</b> ' + escHtml(qData.doc_type) + '</span>';
if (qData.own_number) html += '<span><b>Номер:</b> ' + escHtml(qData.own_number) + '</span>';
if (qData.parent_number) html += '<span><b>Родительский:</b> ' + escHtml(qData.parent_number) + '</span>';
if (qData.doc_date) html += '<span><b>Дата:</b> ' + escHtml(qData.doc_date) + '</span>';
if (qData.counterparty) html += '<span><b>Контрагент:</b> ' + escHtml(qData.counterparty) + '</span>';
html += '</div>';
if (qData.classify_input) {
html += '<details style="margin-top:4px;"><summary style="cursor:pointer;color:var(--brand-primary);">📤 Текст отправленный в LLM</summary>';
html += '<pre style="font-size:10px;max-height:150px;overflow:auto;background:#fff;padding:4px;border-radius:3px;margin-top:2px;">' + escHtml(qData.classify_input) + '</pre>';
html += '</details>';
}
if (qData.classify_raw) {
html += '<details style="margin-top:4px;"><summary style="cursor:pointer;color:var(--brand-primary);">📥 Сырой ответ LLM</summary>';
html += '<pre style="font-size:10px;max-height:150px;overflow:auto;background:#fff;padding:4px;border-radius:3px;margin-top:2px;">' + escHtml(qData.classify_raw) + '</pre>';
html += '</details>';
}
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 += '<details style="margin-top:4px;"><summary style="cursor:pointer;color:var(--brand-primary);">📄 Распарсенные элементы (JSON)</summary>';
html += '<pre style="font-size:10px;max-height:200px;overflow:auto;background:#fff;padding:4px;border-radius:3px;margin-top:2px;">' + escHtml(ejStr.substring(0, 5000)) + (ejStr.length > 5000 ? '\n... (обрезано)' : '') + '</pre>';
html += '</details>';
}
} else if (qData.classify_status === 'failed') {
html += '<span style="color:var(--destructive);">✗ Ошибка классификации: ' + escHtml(qData.error_message || '?') + '</span>';
} else {
html += '<span style="color:var(--muted);">Ещё не классифицирован</span>';
}
detailRow.cells[0].innerHTML = html;
} catch(e) {
detailRow.cells[0].innerHTML = '<span style="color:var(--destructive);">Ошибка: ' + escHtml(e.message) + '</span>';
}
};
/**
* 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) {
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);
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 от <input> (массив 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);
}
// Шаг 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) {
// Создать запись с начальным статусом
var entry = { name: file.name, lastModified: file.lastModified, size: file.size, file: file, status: { kind: 'uploading', pct: 0 } };
// Заменить существующий файл с тем же именем или добавить новый
var existingIdx = -1;
for (var j = 0; j < state.files.length; j++) {
if (state.files[j].name === file.name) { 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);
});
// Бэкенд возвращает 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();
}
+221
View File
@@ -0,0 +1,221 @@
/**
* 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 = '<div style="font-weight:600;margin-bottom:8px;">📋 Найдено групп: ' + realGroups.length +
(unresolvedGroup ? ' | ⚠ ' + unresolvedGroup.documents.length + ' файлов не распознано' : '') +
'</div>';
// Нераспознанная группа — всегда первая
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 '<div style="padding:2px 0;">' +
'<span style="color:var(--muted);">[' + escHtml(d.doc_type || '?') + ']</span> ' +
escHtml(d.filename) +
(d.doc_date ? ' <span style="color:var(--muted);">' + escHtml(d.doc_date) + '</span>' : '') +
'<span style="color:var(--destructive);font-size:11px;">' + reason + '</span>' +
'</div>';
}).join('');
return '<div style="border:1px solid var(--destructive);border-radius:6px;padding:10px;margin-bottom:8px;">' +
'<div style="font-weight:600;margin-bottom:6px;">❓ Не распознано (' + group.documents.length + ' файлов)</div>' +
'<div style="font-size:12px;">' + docsHtml + '</div>' +
'<div style="font-size:11px;color:var(--muted);margin-top:4px;">Загрузите недостающие базовые договоры для этих номеров.</div>' +
'</div>';
}
/**
* 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 = '<div style="border:1px solid var(--brand-gray);border-radius:6px;padding:10px;margin-bottom:8px;">' +
'<div class="cmp-header" style="font-weight:600;margin-bottom:6px;">' +
'📄 Договор №' + escHtml(group.contract_number || '?') + ' — ' + escHtml(group.counterparty || 'контрагент не определён') +
'</div>' +
'<div style="font-size:12px;">' +
group.documents.map(function(d) {
var typeLabel = {contract: 'договор', supplement: 'допсоглашение', specification: 'спецификация'}[d.doc_type] || d.doc_type;
return '<div style="padding:2px 0;">' +
'<span style="color:var(--muted);">' + escHtml(typeLabel || '?') + '</span> ' +
escHtml(d.filename) +
(d.doc_date ? ' <span style="color:var(--muted);">' + escHtml(d.doc_date) + '</span>' : '') +
'</div>';
}).join('') + '</div>' +
'<button class="btn btn-primary cmp-btn" style="margin-top:6px;font-size:12px;height:28px;" data-gi="' + gi + '"' +
(isRunning ? ' disabled' : '') + '>▶ Сравнить эту группу</button>' +
'</div>';
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 = '<div style="border:1px solid var(--brand-gray);border-radius:6px;padding:10px;margin-bottom:8px;">' +
'<div class="cmp-header" style="font-weight:600;margin-bottom:6px;cursor:pointer;" data-gi="' + gi + '">' +
'<span class="cmp-arrow" id="cmpArrow_' + gi + '" style="font-size:10px;margin-right:4px;">▾</span>' +
'📄 Договор №' + escHtml(group.contract_number || '?') + ' — ' + escHtml(group.counterparty || 'контрагент не определён') +
' <span style="font-weight:400;color:var(--green);font-size:11px;">✓ Готово (' + time + ')</span>' +
'</div>' +
'<div style="font-size:12px;">' +
group.documents.map(function(d, di) {
var typeLabel = {contract: 'договор', supplement: 'допсоглашение', specification: 'спецификация'}[d.doc_type] || d.doc_type;
return '<div style="padding:2px 0;cursor:pointer;" onclick="var b=document.querySelectorAll(\'#cmpBody_' + gi + ' .diff-section-body\');if(b[' + di + '])b[' + di + '].style.display=b[' + di + '].style.display===\'none\'?\'block\':\'none\';">' +
'<span style="color:var(--muted);">' + escHtml(typeLabel || '?') + '</span> ' +
escHtml(d.filename) +
(d.doc_date ? ' <span style="color:var(--muted);">' + escHtml(d.doc_date) + '</span>' : '') +
'</div>';
}).join('') + '</div>' +
'<div class="cmp-body" id="cmpBody_' + gi + '" style="margin-top:8px;">' + bodyHTML + '</div>' +
'</div>';
return html;
}
+28 -1
View File
@@ -180,7 +180,7 @@ EDGE-CASES:
def _fetch_prompt(role: str) -> dict | None: def _fetch_prompt(role: str) -> dict | None:
"""Получить активный промпт из БД. Возвращает {id, body} или None.""" """Получить активный промпт из БД. Возвращает {id, body} или None."""
try: try:
with httpx.Client(http2=True, timeout=10, verify=False) as client: with httpx.Client(http2=True, timeout=10) as client:
resp = client.get( resp = client.get(
f"{LUCEE_URL}/prompt.cfm", f"{LUCEE_URL}/prompt.cfm",
params={"action": "get_active", "role": role}, params={"action": "get_active", "role": role},
@@ -232,3 +232,30 @@ def build_prompt(current_spec: list, doc_text: str) -> tuple:
result = result.replace("{spec_current}", _build_spec_text(current_spec)) result = result.replace("{spec_current}", _build_spec_text(current_spec))
return result, prompt_id 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", "other"
2. own_number: номер ЭТОГО документа
3. parent_number: номер родительского договора (для допников/спецификаций)
4. doc_date: дата в формате YYYY-MM-DD
5. counterparty: название контрагента
6. confidence: "ok" или "low"
Верни СТРОГО JSON: {"doc_type":"...","own_number":"...","parent_number":"...","doc_date":"...","counterparty":"...","confidence":"..."}
ДОКУМЕНТ:
---
{header_text}
---""".replace("{header_text}", header_text)
return body, ""
+208
View File
@@ -0,0 +1,208 @@
"""
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"
def _call_llm_classify(header_text):
"""
Прямой вызов LLM для классификации ОДНОГО документа.
Возвращает (parsed_dict, raw_text).
"""
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", "")
return _safe_json_parse(raw), raw
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
def _classify_one(doc):
"""Классифицировать один документ: выжимка → LLM → сохранить результат."""
try:
text = _smart_extract(doc["elements_json"])
result, raw = _call_llm_classify(text)
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}
def _safe_json_parse(raw):
"""Parse LLM response, fixing common JSON errors."""
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)
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)
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)
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]
+160
View File
@@ -0,0 +1,160 @@
"""
Grouping service — match classified documents into contract groups.
Архитектурное решение (Opus, Q4 + Q6):
- Двухпроходный гибрид: LLM извлекает строки (classify.py), Python нормализует и группирует.
- Нормализация номеров: uppercase + только буквы/цифры.
"МЭС-123-2024" == "МЭС 123/2024" после нормализации.
- Группировка на бэкенде (не на фронте): Python regex/unicode надёжнее JS.
- apply_groups(): создаёт contracts + supplements с авто-порядком по дате.
"""
import re
from db import documents as db_docs
from db import contracts as db_contracts
from db import supplements as db_supplements
def normalize_number(num):
"""
Нормализация номера договора для сравнения.
Убирает всё кроме букв и цифр, приводит к uppercase.
Пример: "МЭС-123-2024""МЭС1232024", "МЭС 123/2024""МЭС1232024".
"""
if not num:
return ""
return re.sub(r"[^A-Z0-9А-Я]", "", num.upper())
def group_documents(batch_id):
"""
Сгруппировать классифицированные документы по контрактам.
Алгоритм:
1. Отделить contract от supplement/specification
2. Каждый contract → якорь группы
3. Для каждого supplement: найти contract по parent_number (нормализованный)
4. Оставшиеся supplement/spec → виртуальные группы по parent_number/own_number
5. Совсем без номеров → группа "__unresolved__"
6. Внутри группы сортировка по doc_date
Возвращает {ok, groups: [{contract_number, counterparty, documents: [...]}], total_docs}.
"""
docs = db_docs.list_by_batch(batch_id)
classified = [d for d in docs if d.get("classify_status") == "classified"]
# Separate contracts and supplements
contracts_list = []
supplements_list = []
for d in classified:
if d.get("doc_type") == "contract":
contracts_list.append(d)
else:
supplements_list.append(d)
groups = []
# Each contract becomes a group
for c in contracts_list:
group = {
"contract_number": c.get("own_number") or c.get("filename", ""),
"counterparty": c.get("counterparty") or "",
"documents": [c],
}
# Find supplements matching this contract
c_norm = normalize_number(c.get("own_number"))
for s in supplements_list:
parent = normalize_number(s.get("parent_number") or "")
own = normalize_number(s.get("own_number") or "")
if parent == c_norm or own == c_norm:
if s not in group["documents"]:
group["documents"].append(s)
# Sort by date
group["documents"].sort(key=lambda x: x.get("doc_date") or "")
# Remove matched supplements from the pool
for s in group["documents"]:
if s in supplements_list:
supplements_list.remove(s)
groups.append(group)
# ── Virtual groups: unmatched supplements grouped by number ──────────
# Group remaining supplements/specs by normalized parent_number (priority) or own_number
virtual = {}
for s in supplements_list:
num = normalize_number(s.get("parent_number") or s.get("own_number") or "")
if not num:
continue # no number → stays in supplements_list for unresolved
if num not in virtual:
# Use counterparty from first doc in group
cp = s.get("counterparty") or ""
virtual[num] = {
"contract_number": s.get("parent_number") or s.get("own_number") or "?",
"counterparty": cp,
"documents": [],
}
virtual[num]["documents"].append(s)
# Update counterparty if current doc has a better one
if not virtual[num]["counterparty"] and s.get("counterparty"):
virtual[num]["counterparty"] = s.get("counterparty")
for vnum, vgroup in virtual.items():
vgroup["documents"].sort(key=lambda x: x.get("doc_date") or "")
groups.append(vgroup)
# Remove grouped docs from supplements_list
for s in vgroup["documents"]:
supplements_list.remove(s)
# ── Unresolved: everything left (no number, failed, pending, other) ──
unmatched = [s for s in supplements_list] # remaining after virtual grouping
unmatched += [d for d in docs if d.get("classify_status") != "classified"]
if unmatched:
groups.append({
"contract_number": "__unresolved__",
"counterparty": "",
"documents": unmatched,
})
return {"ok": True, "groups": groups, "total_docs": len(docs)}
def apply_groups(batch_id, groups_data):
"""
Применить подтверждённые группы: создать contracts + supplements.
Вызывается из POST /api/apply-groups.
Для каждой группы (кроме __unresolved__):
1. Создать запись в contracts (number, client)
2. Для каждого документа создать supplement (type='initial' для первого, 'additional' для остальных)
3. Порядок supplements соответствует порядку документов в группе (сортировка по дате уже сделана)
Возвращает {ok, created: количество созданных supplements}.
"""
created = 0
contract_ids = []
for g in groups_data:
contract_number = g.get("contract_number", "")
if contract_number == "__unresolved__":
continue
counterparty = g.get("counterparty", "")
docs = g.get("documents", [])
try:
c = db_contracts.insert(contract_number, counterparty)
contract_id = c["id"]
contract_ids.append(contract_id)
for i, d in enumerate(docs):
doc_id = d.get("id")
if not doc_id:
continue
supp_type = "initial" if i == 0 else "additional"
db_supplements.insert(contract_id, doc_id, supp_type)
created += 1
except Exception as e:
return {"ok": False, "error": f"apply_groups failed at {contract_number}: {e}"}
return {"ok": True, "created": created, "contract_ids": contract_ids}
+3 -6
View File
@@ -1,20 +1,17 @@
"""Unzip service — extract files from ZIP archive.""" """Unzip service — extract files from ZIP archive (no DB, no parse)."""
import zipfile, io, base64, json import zipfile, io, base64
def handle_unzip(rfile, content_length): def handle_unzip(rfile, content_length):
"""Parse ZIP upload, return list of extracted files.""" """Parse ZIP upload, return list of extracted files as base64."""
body = rfile.read(content_length) body = rfile.read(content_length)
# Find file data in raw multipart # Find file data in raw multipart
# Simple approach: find ZIP bytes after header
idx = body.find(b"\r\n\r\n") idx = body.find(b"\r\n\r\n")
if idx < 0: if idx < 0:
return {"ok": False, "error": "invalid multipart"} return {"ok": False, "error": "invalid multipart"}
# Find first boundary end
raw = body[idx + 4:] raw = body[idx + 4:]
# Find the actual ZIP data (after Content-Disposition etc)
zip_start = raw.find(b"PK\x03\x04") zip_start = raw.find(b"PK\x03\x04")
if zip_start < 0: if zip_start < 0:
return {"ok": False, "error": "not a ZIP file"} return {"ok": False, "error": "not a ZIP file"}
+35 -4
View File
@@ -1,12 +1,24 @@
"""Upload service — accept file, store to DB, parse.""" """Upload service — accept file, store to DB, parse."""
import uuid, base64, json, io, cgi import uuid, base64, json, io, cgi, os
from db import documents, contracts, supplements from db import documents, contracts, supplements
from .parse import parse_file from .parse import parse_file
MAX_FILE_SIZE = 100 * 1024 * 1024 # 100 MB
def handle_upload(rfile, content_type, content_length): def handle_upload(rfile, content_type, content_length):
"""Parse multipart upload, store in DB, return result dict.""" """Parse multipart upload, store in DB, return result dict."""
body = rfile.read(content_length) # 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 = { environ = {
"REQUEST_METHOD": "POST", "REQUEST_METHOD": "POST",
@@ -23,13 +35,29 @@ def handle_upload(rfile, content_type, content_length):
item = fs["files"] item = fs["files"]
if isinstance(item, list): if isinstance(item, list):
item = item[0] item = item[0]
filename = item.filename 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 file_data = item.file.read() if hasattr(item, "file") else item.value
if isinstance(file_data, str): if isinstance(file_data, str):
file_data = file_data.encode("utf-8") file_data = file_data.encode("utf-8")
if "contract_id" in fs: if "contract_id" in fs:
contract_id = fs.getfirst("contract_id", "") 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
if not filename or not file_data: if not filename or not file_data:
return {"ok": False, "error": "no file in request"} return {"ok": False, "error": "no file in request"}
@@ -38,9 +66,12 @@ def handle_upload(rfile, content_type, content_length):
b64 = base64.b64encode(file_data).decode() b64 = base64.b64encode(file_data).decode()
if contract_id: if contract_id:
try:
supplements.delete_by_document(contract_id, filename) 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) doc = documents.insert(filename, mime, b64, batch_id=batch_id)
if not contract_id: if not contract_id:
from datetime import datetime from datetime import datetime
+73
View File
@@ -0,0 +1,73 @@
/**
* state.js — Центральное состояние приложения.
*
* ПАТТЕРН (из decoupling-final-plan.md, Фаза 0):
* action → мутация state → render(state)
*
* ПРАВИЛА:
* 1. ВСЕ состояния приложения — только здесь, в объекте state.
* 2. DOM — проекция state, никто не читает данные из DOM.
* Если что-то нужно узнать — смотри в state, а не в element.innerHTML.
* 3. Мутировал любое поле state → обязан вызвать render(state).
* Никаких прямых манипуляций DOM в обход render().
* 4. В любой момент console.log(state) показывает ВСЁ состояние приложения.
*
* СТРУКТУРА (Фаза 0 — минимальная, будет расширяться в Фазах 1-4):
*
* state.files — бывший fileQueue, массив загруженных файлов.
* Каждый элемент: { name, size, lastModified, file, doc_id,
* uploaded, parsed, parseInfo, supp_id, supp_type, status }
*
* 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: '○'
}
}
};
+532
View File
@@ -0,0 +1,532 @@
/**
* tests.js — Тесты чистых функций (Фазы 0-4, decoupling-final-plan.md).
*
* Запуск: node tests.js
*
* Проверяет: statusToHTML, applyParseResult, reconcileSelection,
* renderGroupCard, renderGroupCardDone, renderUnresolvedCard,
* applyCompareEvent, renderCompareSectionHeader, renderCompareOpsTable,
* renderCompareSectionBody.
*/
// ── Моки глобальных переменных ──────────────────────────────
// Браузерные глобалы, нужные модулям при загрузке
global.window = global; // window.* присваивания
// Мок document
global.document = {
getElementById: function(id) { return null; },
createElement: function(tag) {
return {
style: {},
classList: { add: function(){}, remove: function(){} },
innerHTML: '',
textContent: '',
appendChild: function(){},
querySelector: function(){ return null; },
querySelectorAll: function(){ return []; },
addEventListener: function(){},
parentNode: { insertBefore: function(){} },
nextSibling: null
};
},
querySelector: function() { return null; }
};
// Мок crypto.randomUUID
global.crypto = { randomUUID: function() { return 'test-uuid-' + Date.now(); } };
// Мок lucide
global.lucide = { createIcons: function(){} };
// Глобальные переменные приложения
global.fileInput = { disabled: false, value: '', addEventListener: function(){}, files: [] };
global.fileTable = { innerHTML: '' };
global.VM_API = 'https://contracts.kube5s.ru';
global.UPLOAD_URL = VM_API + '/upload';
global.CONVERT_URL = VM_API + '/convert-doc';
global.UNZIP_URL = VM_API + '/unzip-upload';
global.SITE_URL = '';
var state = {
files: [],
contractId: null,
batchId: 'test-batch-id',
groups: null,
_activeCompare: { es: null, timer: null },
ui: { steps: { upload: '○', classify: '○', groups: '○', compare: '○' } }
};
// Мок escHtml (из app_utils.js)
global.escHtml = function(s) {
if (s == null) return '';
return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
};
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' }), '<span class="status-ok">✓</span>', 'uploaded');
eq(statusToHTML({ kind: 'unzipping' }), '⏳ распаковка...', 'unzipping');
eq(statusToHTML({ kind: 'parsing' }), '⏳ парсинг...', 'parsing');
eq(statusToHTML({ kind: 'parsed', count: 5 }), '<span class="status-ok">✓ 5 эл.</span>', 'parsed без elapsed');
eq(statusToHTML({ kind: 'parsed', count: 5, elapsed: '2.3' }), '<span class="status-ok">✓ 5 эл. (2.3с)</span>', 'parsed с elapsed');
eq(statusToHTML({ kind: 'error', text: 'тест' }), '<span class="status-err">✗ тест</span>', 'error с текстом');
eq(statusToHTML({ kind: 'error' }), '<span class="status-err">✗ Неизвестная ошибка</span>', '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 }), '<span class="status-ok">✓ 0 эл.</span>', 'parsed count=0');
eq(statusToHTML({ kind: 'parsed', count: 5, elapsed: '0' }), '<span class="status-ok">✓ 5 эл. (0с)</span>', 'parsed elapsed=0');
eq(statusToHTML({ kind: 'error', text: '' }), '<span class="status-err">✗ Неизвестная ошибка</span>', '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: '<div>результаты сравнения</div>'
}
};
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, '<table', 'пустые ops: таблица рендерится');
contains(emptyTable, '<tbody>', 'пустые ops: tbody есть');
// ops с null new_row
var nullRow = renderCompareOpsTable([{ action: 'ADD' }]);
contains(nullRow, '<td></td>', '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('<script>'), '&lt;script&gt;', 'escHtml экранирует < >');
eq(escHtml('a&b'), 'a&amp;b', 'escHtml экранирует &');
eq(escHtml('"quote"'), '&quot;quote&quot;', 'escHtml экранирует кавычки');
eq(escHtml(123), '123', 'escHtml число → строка');
// ── Дымные тесты API (бэкенд) ────────────────────────────────
console.log('\n=== API smoke tests ===');
var http = require('http');
var https = require('https');
function apiTest(method, url, cb) {
var mod = url.startsWith('https') ? https : http;
var req = mod.request(url, { method: method, timeout: 5000, rejectUnauthorized: false }, function(res) {
var body = '';
res.on('data', function(c) { body += c; });
res.on('end', function() { cb(null, res.statusCode, body); });
});
req.on('error', function(e) { cb(e.message); });
req.on('timeout', function() { req.destroy(); cb('timeout'); });
req.end();
}
// Эти тесты асинхронные — собираем результаты
var apiTests = [];
function addApiTest(name, method, url, check) {
apiTests.push({ name: name, method: method, url: url, check: check });
}
addApiTest('GET / (главная)', 'GET', 'https://contracts.kube5s.ru/', function(code, body) {
assert(code === 200, 'главная: HTTP 200 — got ' + code);
contains(body, '<!DOCTYPE html>', 'главная: HTML');
});
addApiTest('POST /api/cleanup', 'POST', 'https://contracts.kube5s.ru/api/cleanup', function(code, body) {
// cleanup может вернуть 200 или 500 (если БД недоступна) — оба норм для дымного теста
assert(code >= 200 && code < 600, 'cleanup: ответ получен — ' + code);
});
addApiTest('GET /static/state.js', 'GET', 'https://contracts.kube5s.ru/static/state.js', function(code, body) {
eq(code, 200, 'state.js: HTTP 200 — got ' + code);
contains(body, 'Центральное состояние', 'state.js: содержит описание');
});
addApiTest('GET /static/files.js', 'GET', 'https://contracts.kube5s.ru/static/files.js', function(code) {
eq(code, 200, 'files.js: HTTP 200 — got ' + code);
});
addApiTest('GET /static/groups.js', 'GET', 'https://contracts.kube5s.ru/static/groups.js', function(code) {
eq(code, 200, 'groups.js: HTTP 200 — got ' + code);
});
addApiTest('GET /static/compare.js', 'GET', 'https://contracts.kube5s.ru/static/compare.js', function(code) {
eq(code, 200, 'compare.js: HTTP 200 — got ' + code);
});
addApiTest('GET /static/app.js', 'GET', 'https://contracts.kube5s.ru/static/app.js', function(code) {
eq(code, 200, 'app.js: HTTP 200 — got ' + code);
});
// Запускаем API тесты последовательно (чтобы не зафлудить)
var apiIdx = 0;
function runNextApiTest() {
if (apiIdx >= apiTests.length) {
// Все API тесты готовы — выводим итоги
printResults();
return;
}
var t = apiTests[apiIdx];
apiTest(t.method, t.url, function(err, code, body) {
if (err) {
console.log(' FAIL: ' + t.name + ' — ' + err);
failed++;
} else {
t.check(code, body || '');
}
apiIdx++;
runNextApiTest();
});
}
function printResults() {
console.log('\n========================================');
console.log('PASS: ' + passed);
console.log('FAIL: ' + failed);
console.log('========================================');
if (failed > 0) process.exit(1);
}
// Начинаем с API тестов после синхронных
console.log(' (асинхронные — ждём...)');
runNextApiTest();
+81 -15
View File
@@ -23,7 +23,7 @@
.btn-primary { background: var(--brand-primary); border-color: var(--brand-primary); color: #fff; } .btn-primary { background: var(--brand-primary); border-color: var(--brand-primary); color: #fff; }
.btn-primary:hover { background: var(--brand-primary-dark); } .btn-primary:hover { background: var(--brand-primary-dark); }
.btn:disabled { opacity: .5; cursor: not-allowed; } .btn:disabled { opacity: .5; cursor: not-allowed; }
.table-wrap { border-radius: 6px; border: 1px solid var(--brand-gray); overflow: hidden; margin-top: 12px; } .table-wrap { border-radius: 6px; border: 1px solid var(--brand-gray); overflow-y: auto; max-height: 50vh; margin-top: 12px; }
table { width: 100%; border-collapse: collapse; font-size: 13px; } table { width: 100%; border-collapse: collapse; font-size: 13px; }
th { background: var(--brand-grey-light); text-transform: uppercase; padding: 6px 10px; border-right: 1px solid var(--brand-gray); text-align: left; font-weight: 600; font-size: 11px; color: var(--muted); } th { background: var(--brand-grey-light); text-transform: uppercase; padding: 6px 10px; border-right: 1px solid var(--brand-gray); text-align: left; font-weight: 600; font-size: 11px; color: var(--muted); }
td { padding: 6px 10px; border-right: 1px solid var(--brand-gray); border-bottom: 1px solid var(--brand-gray); } td { padding: 6px 10px; border-right: 1px solid var(--brand-gray); border-bottom: 1px solid var(--brand-gray); }
@@ -75,7 +75,14 @@
<body> <body>
<div class="topbar"> <div class="topbar">
<img src="/nubes-logo.svg" alt="Nubes"> <img src="/nubes-logo.svg" alt="Nubes">
<span class="title">Сверка договоров — LLM AI-driven Event Sourcing <span style="font-weight:400;color:var(--muted);font-size:12px;">v1.0.161 — Lucee</span></span> <span class="title">Сверка договоров — LLM AI-driven Event Sourcing <span style="font-weight:400;color:var(--muted);font-size:12px;">v1.0.178</span></span>
<div id="pipelineStepper" style="display:flex;gap:8px;font-size:11px;align-items:center;color:var(--muted);">
<span id="stepUpload">○ Загрузка</span><span>→</span>
<span id="stepClassify">○ Классификация</span><span>→</span>
<span id="stepGroups">○ Группировка</span><span>→</span>
<span id="stepCompare">○ Сравнение</span>
</div>
<span style="margin-left:auto;font-size:12px;color:var(--brand-primary);cursor:pointer;white-space:nowrap;" onclick="openAbout()">О сервисе</span>
</div> </div>
<div class="content"> <div class="content">
@@ -83,23 +90,23 @@
<div class="card-header"> <div class="card-header">
<i data-lucide="folder-open" style="width:18px;height:18px;"></i> <i data-lucide="folder-open" style="width:18px;height:18px;"></i>
Загрузка договоров/приложений/спецификаций Загрузка договоров/приложений/спецификаций
<span style="margin-left:auto;font-size:11px;color:var(--muted);cursor:pointer;" onclick="openAbout()">О сервисе</span>
</div> </div>
<div class="card-body"> <div class="card-body">
<input type="file" id="fileInput" accept=".docx,.doc,.pdf" multiple style="margin-bottom:6px;width:100%;"> <input type="file" id="fileInput" accept=".docx,.doc,.pdf,.zip" multiple style="margin-bottom:6px;width:100%;">
<div style="text-align:right;font-size:11px;color:var(--muted);margin-bottom:6px;">↕ первый в списке — основной договор, порядок меняется стрелками</div> <div style="text-align:right;font-size:11px;color:var(--muted);margin-bottom:6px;">Порядок определяется автоматически при классификации</div>
<div style="font-size:11px;color:var(--muted);margin-bottom:6px;">⚠ При совпадении имён — запрос на перезапись (OK / Отмена). Файлы из ZIP-архивов загружаются через тот же поток.</div>
<div class="table-wrap"> <div class="table-wrap">
<table> <table>
<thead> <thead>
<tr><th>Имя</th><th style="width:130px;">Изменён</th><th style="width:90px;">Размер</th><th style="width:115px;">Парсинг</th><th style="width:30px;">↕</th><th style="width:30px;">Текст</th><th style="width:30px;">✕</th></tr> <tr><th>Имя</th><th style="width:130px;">Изменён</th><th style="width:90px;">Размер</th><th style="width:115px;">Парсинг</th><th style="width:30px;">Текст</th><th style="width:30px;">✕</th></tr>
</thead> </thead>
<tbody id="fileTable"></tbody> <tbody id="fileTable"></tbody>
</table> </table>
</div> </div>
<button class="btn btn-primary" id="llmBtn" style="width:100%;justify-content:center;margin-top:8px;display:none;background:var(--green);border-color:var(--green);"> <button class="btn btn-primary" id="llmBtn" style="width:100%;justify-content:center;margin-top:8px;display:none;background:var(--green);border-color:var(--green);">
<i data-lucide="scale" style="width:16px;height:16px;"></i> Сравнить <i data-lucide="scale" style="width:16px;height:16px;"></i> Общее сравнение
</button> </button>
<details style="margin-top:8px;font-size:12px;"> <details style="margin-top:8px;font-size:12px;">
@@ -107,6 +114,12 @@
<div style="display:flex;gap:8px;margin-top:6px;"> <div style="display:flex;gap:8px;margin-top:6px;">
<button class="btn" id="promptBtnExtract" style="font-size:11px;height:28px;flex:1;">📄 Извлечение</button> <button class="btn" id="promptBtnExtract" style="font-size:11px;height:28px;flex:1;">📄 Извлечение</button>
<button class="btn" id="promptBtnDiff" style="font-size:11px;height:28px;flex:1;">🔄 Сравнение</button> <button class="btn" id="promptBtnDiff" style="font-size:11px;height:28px;flex:1;">🔄 Сравнение</button>
<button class="btn" id="promptBtnClassify" style="font-size:11px;height:28px;flex:1;">🏷️ Классификация</button>
</div>
<div style="display:flex;gap:8px;margin-top:3px;font-size:10px;color:var(--muted);">
<span style="flex:1;">для базового договора</span>
<span style="flex:1;">для допсоглашений</span>
<span style="flex:1;">автоопределение</span>
</div> </div>
</details> </details>
</div> </div>
@@ -115,11 +128,19 @@
<div class="card" id="diffCard" style="display:none;"> <div class="card" id="diffCard" style="display:none;">
<div class="card-header"> <div class="card-header">
<i data-lucide="file-diff" style="width:18px;height:18px;"></i> <i data-lucide="file-diff" style="width:18px;height:18px;"></i>
Результаты сравнения <span id="diffStatus" style="font-weight:400;font-size:12px;margin-left:8px;"></span> Классификация и группы <span id="diffStatus" style="font-weight:400;font-size:12px;margin-left:8px;"></span>
</div> </div>
<div class="card-body" id="diffBody"></div> <div class="card-body" id="diffBody"></div>
</div> </div>
<div class="card" id="compareCard" style="display:none;margin-top:16px;">
<div class="card-header">
<i data-lucide="scale" style="width:18px;height:18px;"></i>
Результаты сравнения <span id="compareStatus" style="font-weight:400;font-size:12px;margin-left:8px;"></span>
</div>
<div class="card-body" id="compareBody"></div>
</div>
<div class="card" id="chatCard" style="display:none;margin-top:16px;"> <div class="card" id="chatCard" style="display:none;margin-top:16px;">
<div class="card-header"> <div class="card-header">
<i data-lucide="message-circle" style="width:18px;height:18px;"></i> <i data-lucide="message-circle" style="width:18px;height:18px;"></i>
@@ -154,9 +175,16 @@
<button class="remove-btn" onclick="closePromptModal()" style="margin-left:auto;font-size:18px;">&#xd7;</button> <button class="remove-btn" onclick="closePromptModal()" style="margin-left:auto;font-size:18px;">&#xd7;</button>
</div> </div>
<div class="modal-body" style="padding:16px;"> <div class="modal-body" style="padding:16px;">
<div style="display:flex;gap:6px;margin-bottom:10px;"> <div style="display:flex;gap:6px;margin-bottom:4px;">
<!-- extract: извлечение строк спецификации из первого документа (базовый договор) -->
<button class="btn" id="promptTabExtract" style="font-size:11px;height:26px;">📄 Извлечение</button> <button class="btn" id="promptTabExtract" style="font-size:11px;height:26px;">📄 Извлечение</button>
<!-- diff: сравнение допсоглашений с текущей спецификацией (ADD/UPDATE/DELETE) -->
<button class="btn" id="promptTabDiff" style="font-size:11px;height:26px;background:var(--brand-primary);color:#fff;">🔄 Сравнение</button> <button class="btn" id="promptTabDiff" style="font-size:11px;height:26px;background:var(--brand-primary);color:#fff;">🔄 Сравнение</button>
<!-- classify: классификация документов (тип, номер, контрагент, дата) -->
<button class="btn" id="promptTabClassify" style="font-size:11px;height:26px;">🏷️ Классификация</button>
</div>
<div style="font-size:10px;color:var(--muted);margin-bottom:10px;">
Извлечение — для базового договора &nbsp;|&nbsp; Сравнение — для допсоглашений &nbsp;|&nbsp; Классификация — автоопределение типа/даты/контрагента
</div> </div>
<textarea id="promptEditor" style="width:100%;height:220px;font-family:monospace;font-size:11px;border:1px solid var(--brand-gray);border-radius:6px;padding:8px;box-sizing:border-box;"></textarea> <textarea id="promptEditor" style="width:100%;height:220px;font-family:monospace;font-size:11px;border:1px solid var(--brand-gray);border-radius:6px;padding:8px;box-sizing:border-box;"></textarea>
<div style="display:flex;gap:8px;margin-top:8px;align-items:center;"> <div style="display:flex;gap:8px;margin-top:8px;align-items:center;">
@@ -172,20 +200,58 @@
</div> </div>
<div class="modal-overlay" id="aboutModalOverlay" onclick="closeAbout(event)"> <div class="modal-overlay" id="aboutModalOverlay" onclick="closeAbout(event)">
<div class="modal" style="max-width:640px;" onclick="event.stopPropagation()"> <div class="modal" style="max-width:720px;" onclick="event.stopPropagation()">
<div class="modal-header"> <div class="modal-header">
<i data-lucide="info" style="width:18px;height:18px;"></i> <i data-lucide="info" style="width:18px;height:18px;"></i>
<span>О сервисе</span> <span>О сервисе — архитектура и принцип работы</span>
<button class="remove-btn" onclick="closeAbout()" style="margin-left:auto;font-size:18px;">&#xd7;</button> <button class="remove-btn" onclick="closeAbout()" style="margin-left:auto;font-size:18px;">&#xd7;</button>
</div> </div>
<div class="modal-body" style="padding:16px;font-size:13px;line-height:1.6;max-height:65vh;overflow-y:auto;" id="aboutBody"> <div class="modal-body" style="padding:16px;font-size:13px;line-height:1.6;max-height:65vh;overflow-y:auto;" id="aboutBody">
<p><b>Что делает:</b> автоматическое сравнение договоров и дополнительных документов облачного провайдера.</p>
<p><b>Пайплайн:</b> Загрузка → Парсинг → Порядок (↕) → LLM-сравнение → Результаты (Event Sourcing)</p> <h3 style="margin-top:0;">Что это</h3>
<p><b>Стек:</b> Lucee (домен) + Python 3 (VM, вся логика) + PostgreSQL 15 + gpt-oss-120b</p> <p>Автоматический разбор и сравнение договоров с помощью ИИ (LLM). Загружаете договоры, допсоглашения и спецификации вперемешку — система сама разбирает что есть что, группирует по контрактам и находит все изменения между версиями.</p>
<h3>Как пользоваться</h3>
<p><b>1. Загрузка</b> — выберите файлы (.docx, .doc, .pdf, .zip). Можно загружать всё вперемешку: договоры, допники, спецификации, архивы. Порядок не важен — классификация разберётся.</p>
<p>ZIP-архивы распаковываются автоматически: каждый файл из архива проходит тот же путь загрузки и парсинга, что и обычный. При совпадении имён — запрос на перезапись.</p>
<p><b>2. Классификация</b> — нажмите «Классифицировать». Система через LLM определит для каждого файла: тип, номер, дату, контрагента и родительский договор. Прогресс и таймер видны на кнопке.</p>
<p><b>3. Просмотр результатов</b> — кликните на имя файла в таблице. Раскроется панель с полным трейсом:</p>
<ul>
<li>🏷️ <b>Результат классификации</b> — тип, номер, дата, контрагент</li>
<li>📤 <b>Текст отправленный в LLM</b> — что попало в промпт</li>
<li>📥 <b>Сырой ответ LLM</b> — что вернула модель</li>
<li>📄 <b>Распарсенные элементы</b> — структура документа из парсера</li>
</ul>
<p><b>4. Группы</b> — после классификации документы автоматически группируются по договорам. Группы создаются даже если базовый договор не загружен — по номерам из допников и спецификаций. Неопознанные файлы — в секции «Не распознано» с причиной.</p>
<p><b>5. Сравнение</b> — для каждой группы нажмите «▶ Сравнить эту группу». Результаты появятся в отдельной карточке ниже, группы останутся на месте. Таблица изменений: ADD (новая услуга), UPDATE (изменение), DELETE (удалена).</p>
<p><b>6. Промпты</b> — в «⚙ Промпты LLM» три вида инструкций для ИИ: Извлечение (базовый договор), Сравнение (допсоглашения), Классификация (автоопределение). Редактируются с версионированием — можно откатиться.</p>
<h3>Индикаторы и подсказки</h3>
<ul>
<li><b>Stepper в топбаре</b> (○ Загрузка → ○ Классификация → ○ Группировка → ○ Сравнение) — показывает на каком вы этапе. ✓ = завершено, ⏳ = в процессе.</li>
<li><b>Кнопка «Текст»</b> (📄) в строке файла — полная модалка с распарсенным текстом, таблицами и классификацией.</li>
<li><b>Кнопка «Общее сравнение»</b> — сравнение ВСЕХ файлов как одного контракта (без группировки).</li>
<li><b>Удаление файла</b> (✕) — состав меняется, кнопка «Классифицировать» разблокируется для переклассификации.</li>
</ul>
<h3>На выходе</h3>
<p>Структурированная спецификация по каждому договору + полная история изменений с привязкой к документам-источникам. Все промежуточные данные (текст отправленный в LLM, сырой ответ, распарсенный JSON) доступны для проверки.</p>
</div> </div>
</div> </div>
</div> </div>
<script src="https://contracts.kube5s.ru/static/app.js"></script> <script src="https://contracts.kube5s.ru/static/state.js?v=1.0.178"></script>
<script src="https://contracts.kube5s.ru/static/app_utils.js?v=1.0.178"></script>
<script src="https://contracts.kube5s.ru/static/files.js?v=1.0.178"></script>
<script src="https://contracts.kube5s.ru/static/groups.js?v=1.0.178"></script>
<script src="https://contracts.kube5s.ru/static/compare.js?v=1.0.178"></script>
<script src="https://contracts.kube5s.ru/static/app.js?v=1.0.178"></script>
</body> </body>
</html> </html>