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
+377 -387
View File
@@ -1,398 +1,367 @@
// ⛔ НЕ МЕНЯТЬ БЕЗ РАЗРЕШЕНИЯ НАЕЛЯ ⛔
// Contracts App — весь JS на VM (contracts.kube5s.ru)
// Lucee: только домен + index.cfm-скелет
var VM_API = 'https://contracts.kube5s.ru';
var UPLOAD_URL = VM_API + '/upload';
var CONVERT_URL = VM_API + '/convert-doc';
var UNZIP_URL = VM_API + '/unzip-upload';
var UNZIP_URL = 'https://contracts.kube5s.ru/unzip-upload';
var SITE_URL = ''; // same origin for api calls
// SITE_URL удалён (Фаза 4) — не использовался
var fileInput = document.getElementById('fileInput');
var fileTable = document.getElementById('fileTable');
var fileQueue = [];
var contractId = null;
// state.files — теперь в state.js (Фаза 0: state + render)
// state.contractId — теперь в state.js (Фаза 0: state + render)
// state.batchId — теперь в state.js (Фаза 0: state + render)
// (batchId = crypto.randomUUID() — уникальный ID сессии для классификации)
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';
// Автоочистка старых записей при загрузке страницы
(async function cleanup() {
try {
await fetch(VM_API + '/api/cleanup', { method: 'POST' });
} catch(e) { /* ignore */ }
})();
/**
* render(state) — Главная функция рендеринга (Фаза 0, decoupling-final-plan.md).
*
* ПАТТЕРН: action → мутация state → render(state)
*
* Фаза 0: вызывает существующий renderTable().
* renderTable читает state.files напрямую (state — глобальный объект).
* Фаза 1+: будет вызывать renderFiles(state), renderGroups(state), renderStepper(state) и т.д.
*
* ПРАВИЛО: любое изменение state ДОЛЖНО завершаться вызовом render(state).
* Никакой прямой манипуляции DOM в обход render().
* console.log(state) показывает ВСЁ состояние в любой момент.
*/
function render(state) {
renderFiles(state);
renderStepper(state);
}
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'});
}
// syncDB, statusToHTML, renderFiles, toggleClassifyDetail, uploadFile, refreshSupps
// → вынесены в files.js (Фаза 1)
function renderTable() {
if (fileQueue.length === 0) {
fileTable.innerHTML = '<tr class="empty-row"><td colspan="7">Нет файлов — выберите .docx / .pdf</td></tr>';
} else {
fileTable.innerHTML = fileQueue.map(function(f, i) {
var isFirst = (i === 0);
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>';
return '<tr id="row_' + i + '" class="' + (isFirst ? 'first-row' : '') + '">' +
'<td class="name-cell">' + 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">' + (f.status || '') + '</td>' +
'<td style="text-align:center;">' + arrows + '</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>';
}).join('');
}
lucide.createIcons();
}
// ── Pipeline stepper (Фаза 4: state-driven) ──────────────────
function removeFile(i) {
fileQueue.splice(i, 1);
if (fileQueue.length === 0) contractId = null;
renderTable();
}
window.removeFile = removeFile;
function moveUp(i) {
if (i <= 0) return;
var tmp = fileQueue[i]; fileQueue[i] = fileQueue[i-1]; fileQueue[i-1] = tmp;
renderTable();
}
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() {
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;
// ── Автозагрузка при выборе файлов ──────────────────────────
fileInput.addEventListener('change', async function() {
var newFiles = Array.from(fileInput.files);
if (newFiles.length === 0) return;
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) {
// .doc → конвертация в docx
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 (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);
}
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();
/**
* renderStepper(state) — Рендер степпера из state.ui.steps (Фаза 4).
*
* Читает state.ui.steps.{upload,classify,groups,compare}, обновляет DOM.
* Значения: '○' (не начат) | '⏳' (в процессе) | '✓' (завершён).
*/
function renderStepper(state) {
var steps = state.ui.steps;
var map = { upload: 'stepUpload', classify: 'stepClassify', groups: 'stepGroups', compare: 'stepCompare' };
var colors = { '○': 'var(--muted)', '⏳': 'var(--brand-primary)', '✓': 'var(--green)' };
Object.keys(map).forEach(function(key) {
var el = document.getElementById(map[key]);
if (el) {
el.textContent = steps[key] + ' ' + {upload:'Загрузка',classify:'Классификация',groups:'Группировка',compare:'Сравнение'}[key];
el.style.color = colors[steps[key]] || 'var(--muted)';
}
});
}
// ── LLM: Сравнить (Event Sourcing) ────────────────────────
/**
* stepDone(id) — Отметить шаг как завершённый (Фаза 4).
* Мутирует state.ui.steps, вызывает renderStepper.
*/
function stepDone(id) {
var key = { stepUpload: 'upload', stepClassify: 'classify', stepGroups: 'groups', stepCompare: 'compare' }[id];
if (key) { state.ui.steps[key] = '✓'; renderStepper(state); }
}
/**
* stepActive(id) — Отметить шаг как активный (Фаза 4).
* Мутирует state.ui.steps, вызывает renderStepper.
*/
function stepActive(id) {
var key = { stepUpload: 'upload', stepClassify: 'classify', stepGroups: 'groups', stepCompare: 'compare' }[id];
if (key) { state.ui.steps[key] = '⏳'; renderStepper(state); }
}
/**
* resetStepper(fromId) — Сбросить шаги начиная с fromId (Фаза 4).
* Все шаги после (и включая) fromId получают '○'.
* Мутирует state.ui.steps, вызывает renderStepper.
*/
function resetStepper(fromId) {
var order = ['stepUpload','stepClassify','stepGroups','stepCompare'];
var keys = ['upload','classify','groups','compare'];
var reset = false;
order.forEach(function(id, i) {
if (id === fromId) reset = true;
if (reset) { state.ui.steps[keys[i]] = '○'; }
});
renderStepper(state);
}
// Утилиты (formatSize, formatDate, moveUp, moveDown, escHtml, fmtDate) —
// вынесены в app_utils.js для облегчения анализа и отладки.
// statusToHTML, renderFiles → files.js (Фаза 1)
// ── Раскрыть/скрыть результат классификации под строкой файла ──
// toggleClassifyDetail → files.js (Фаза 1)
// moveUp/moveDown/removeFile/openAbout/closeAbout — вынесены в app_utils.js
// ── Автозагрузка при выборе файлов ──────────────────────────
// Фаза 1 (decoupling-final-plan.md): fileInput handler разбит на чистые функции
// onFilesSelected → reconcileSelection → addZipFile/addRegularFile → finalizeUpload
// Вся логика — в files.js. Здесь только тонкая обёртка.
fileInput.addEventListener('change', async function() {
var newFiles = Array.from(fileInput.files);
onFilesSelected(newFiles);
});
// ── Классификация ──────────────────────────────────────────
function showClassifyBtn() {
var btn = document.getElementById('classifyBtn');
if (!btn) {
btn = document.createElement('button');
btn.id = 'classifyBtn';
btn.className = 'btn btn-primary';
btn.style.cssText = 'width:100%;justify-content:center;margin-top:8px;background:var(--brand-primary);border-color:var(--brand-primary);';
btn.innerHTML = '<i data-lucide="tags" style="width:16px;height:16px;"></i> Классифицировать';
btn.addEventListener('click', runClassify);
document.getElementById('llmBtn').parentNode.insertBefore(btn, document.getElementById('llmBtn'));
}
btn.style.display = 'flex';
btn.disabled = false;
}
async function runClassify() {
var btn = document.getElementById('classifyBtn');
btn.disabled = true;
btn.innerHTML = '<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() {
if (!contractId) return;
if (!state.contractId) return;
var btn = this;
btn.disabled = true;
btn.innerHTML = '<span class="spinner"></span> Сравнение...';
var diffCard = document.getElementById('diffCard');
var diffBody = document.getElementById('diffBody');
var diffStatus = document.getElementById('diffStatus');
diffCard.style.display = 'block';
diffBody.innerHTML = '';
diffStatus.textContent = '⏳ 0.0с';
var compareCard = document.getElementById('compareCard');
var compareBody = document.getElementById('compareBody');
var compareStatus = document.getElementById('compareStatus');
compareCard.style.display = 'block';
compareBody.innerHTML = '';
compareStatus.textContent = '⏳ 0.0с';
var llmStart = Date.now();
var timerInterval = setInterval(function() {
diffStatus.textContent = '⏳ ' + ((Date.now() - llmStart) / 1000).toFixed(1) + 'с';
}, 200);
var order = state.files.map(function(f) { return f.supp_id; }).filter(Boolean).join(',');
var url = 'https://contracts.kube5s.ru/process-v2?contract_id=' + state.contractId +
(order ? '&order=' + encodeURIComponent(order) : '');
var order = fileQueue.map(function(f) { return f.supp_id; }).filter(Boolean).join(',');
var es = new EventSource('https://contracts.kube5s.ru/process-v2?contract_id=' + contractId + (order ? '&order=' + encodeURIComponent(order) : ''));
var sections = {}; // supplement_id → DOM element
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 + 'с';
// Фаза 3: унифицированный SSE через startCompareSSE (compare.js)
startCompareSSE(url, compareBody, compareStatus, {
onDone: function(d) {
btn.innerHTML = '<i data-lucide="check" style="width:16px;height:16px;"></i> ✓ Готово';
stepDone('stepCompare');
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';
lucide.createIcons();
}
else if (d.type === 'error') {
clearInterval(timerInterval);
es.close();
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> Сравнить';
},
onError: function(d) {
compareBody.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;
},
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> Общее сравнение';
btn.disabled = false;
lucide.createIcons();
}
};
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;
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() {
var input = document.getElementById('chatInput');
var q = input.value.trim();
if (!q || !contractId) return;
if (!q || !state.contractId) return;
var msgs = document.getElementById('chatMessages');
msgs.innerHTML += '<div style="color:var(--brand-primary);margin-top:4px;"><strong>Вы:</strong> ' + q + '</div>';
input.value = '';
@@ -402,7 +371,7 @@ document.getElementById('chatSend').addEventListener('click', function() {
var fd = new FormData();
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(d) {
msgs.lastChild.remove();
@@ -428,7 +397,7 @@ function closeModal(e) {
}
async function showText(i) {
var f = fileQueue[i];
var f = state.files[i];
var docId = f.doc_id;
document.getElementById('modalTitle').textContent = f.name;
document.getElementById('modalBody').innerHTML = '<span style="color:var(--muted);">Загрузка...</span>';
@@ -488,6 +457,22 @@ async function showText(i) {
if (f.supp_id) {
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) {
leftHtml = '<span style="color:var(--destructive);">Ошибка</span>';
}
@@ -505,8 +490,6 @@ async function showText(i) {
window.closeModal = closeModal;
window.showText = showText;
// ── Промпт ───────────────────────────────────────────────────
var promptEditor = document.getElementById('promptEditor');
// ── Промпты (версионные) ─────────────────────────────────────
var promptRole = 'diff'; // текущая выбранная роль
var promptEditor = document.getElementById('promptEditor');
@@ -516,15 +499,22 @@ var promptModalOverlay = document.getElementById('promptModalOverlay');
var promptModalTitle = document.getElementById('promptModalTitle');
var promptName = document.getElementById('promptName');
var promptNotes = document.getElementById('promptNotes');
var activePromptId = {extract: '', diff: ''};
// Три роли промптов:
// extract — извлечение строк из первого документа (базовый договор)
// diff — сравнение допсоглашений (ADD/UPDATE/DELETE)
// classify — классификация документа (тип, номер, контрагент, дата)
var activePromptId = {extract: '', diff: '', classify: ''};
function openPromptModal(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.color = role === 'extract' ? '#fff' : '';
document.getElementById('promptTabDiff').style.background = role === 'diff' ? 'var(--brand-primary)' : '';
document.getElementById('promptTabDiff').style.color = role === 'diff' ? '#fff' : '';
document.getElementById('promptTabClassify').style.background = role === 'classify' ? 'var(--brand-primary)' : '';
document.getElementById('promptTabClassify').style.color = role === 'classify' ? '#fff' : '';
promptStatus.textContent = '';
promptName.value = '';
promptNotes.value = '';
@@ -538,12 +528,12 @@ function closePromptModal(e) {
}
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(d) {
if (d.OK) {
promptEditor.value = d.BODY || '';
activePromptId[role] = d.ID || '';
if (d.ok) {
promptEditor.value = d.body || '';
activePromptId[role] = d.id || '';
loadHistory(role);
}
})
@@ -551,53 +541,52 @@ function loadPrompt(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(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>';
return;
}
promptHistory.innerHTML = d.VERSIONS.map(function(v) {
var isActive = v.IS_ACTIVE ? ' ' : '';
var style = v.IS_ACTIVE ? 'font-weight:600;' : '';
promptHistory.innerHTML = d.versions.map(function(v) {
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;' : '';
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>'
+ (!v.IS_ACTIVE ? '<button class="arrow-btn" onclick="activatePrompt(\'' + v.ID + '\')" title="Активировать" style="font-size:14px;">←</button>' : '')
+ (!v.IS_ACTIVE ? '<button class="arrow-btn" onclick="deletePrompt(\'' + v.ID + '\')" title="Удалить" style="font-size:14px;">✕</button>' : '')
+ '<span style="flex:1;">' + isActive + escHtml(v.name) + ' <span style="color:var(--muted);">' + fmtDate(v.created_at) + '</span>'
+ (v.notes ? ' <span style="color:var(--muted);font-style:italic;">— ' + escHtml(v.notes) + '</span>' : '')
+ '</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>';
}).join('');
})
.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) {
fetch('/prompt.cfm?action=activate', {
fetch(VM_API + '/api/prompts/activate', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({id: id})
})
.then(function(r) { return r.json(); })
.then(function(d) {
if (d.OK) { loadPrompt(promptRole); promptStatus.textContent = '✓ активирован'; }
else { promptStatus.textContent = '✕ ' + (d.ERROR || 'ошибка'); }
if (d.ok) { loadPrompt(promptRole); promptStatus.textContent = '✓ активирован'; }
else { promptStatus.textContent = '✕ ' + (d.error || 'ошибка'); }
});
}
function deletePrompt(id) {
if (!confirm('Удалить эту версию промпта?')) return;
fetch('/prompt.cfm?action=delete', {
fetch(VM_API + '/api/prompts/delete', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({id: id})
})
.then(function(r) { return r.json(); })
.then(function(d) {
if (d.OK) { loadHistory(promptRole); promptStatus.textContent = '✓ удалено'; }
else { promptStatus.textContent = '✕ ' + (d.ERROR || 'ошибка'); }
if (d.ok) { loadHistory(promptRole); promptStatus.textContent = '✓ удалено'; }
else { promptStatus.textContent = '✕ ' + (d.error || 'ошибка'); }
});
}
@@ -605,29 +594,30 @@ document.getElementById('promptSave').addEventListener('click', function() {
var body = promptEditor.value.trim();
if (!body) { promptStatus.textContent = '✕ тело промпта пустое'; return; }
var name = promptName.value.trim() || ('v' + new Date().toISOString().replace(/[:.]/g,'-').substring(0,16));
fetch('/prompt.cfm?action=save', {
fetch(VM_API + '/api/prompts/save', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({
role: promptRole, name: name, body: body,
parent_id: activePromptId[promptRole] || '',
notes: promptNotes.value.trim(), is_active: true
})
})
.then(function(r) { return r.json(); })
.then(function(d) {
if (d.OK) {
if (d.ok) {
promptStatus.textContent = '✓ сохранено (новая версия)';
promptName.value = ''; promptNotes.value = '';
loadPrompt(promptRole);
} else {
promptStatus.textContent = '✕ ' + (d.ERROR || 'ошибка');
promptStatus.textContent = '✕ ' + (d.error || 'ошибка');
}
});
});
document.getElementById('promptTabExtract').addEventListener('click', function() { openPromptModal('extract'); });
document.getElementById('promptTabDiff').addEventListener('click', function() { openPromptModal('diff'); });
document.getElementById('promptTabClassify').addEventListener('click', function() { openPromptModal('classify'); });
document.getElementById('promptBtnExtract').addEventListener('click', function() { openPromptModal('extract'); });
document.getElementById('promptBtnDiff').addEventListener('click', function() { openPromptModal('diff'); });
document.getElementById('promptBtnClassify').addEventListener('click', function() { openPromptModal('classify'); });
lucide.createIcons();
+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
# ⛔ НЕ МЕНЯТЬ БЕЗ РАЗРЕШЕНИЯ НАЕЛЯ ⛔
"""Contracts VM server — thin HTTP router. All logic in db/ and services/."""
from http.server import HTTPServer, BaseHTTPRequestHandler
from socketserver import ThreadingMixIn, TCPServer
from urllib.parse import urlparse, parse_qs
import json, re, os
import json, re, os, sys
# ── DB auto-seed ──────────────────────────────────────────────────────────
from db import prompts as db_prompts
from db.connection import DB_CONFIG
from db.connection import DB_CONFIG, execute
db_prompts.seed_defaults()
# Schema migration: classify_raw column (idempotent)
execute("ALTER TABLE documents ADD COLUMN IF NOT EXISTS classify_raw text")
execute("ALTER TABLE documents ADD COLUMN IF NOT EXISTS classify_input text")
# ── DB modules ────────────────────────────────────────────────────────────
from db import supplements as db_supplements
@@ -44,6 +48,14 @@ class Handler(BaseHTTPRequestHandler):
self._handle_app_js()
elif parsed.path == "/api/supplements":
self._handle_api_supplements(parsed)
elif parsed.path == "/api/groups":
self._handle_api_groups(parsed)
elif parsed.path == "/api/batch-progress":
self._handle_api_batch_progress(parsed)
elif parsed.path == "/api/prompts":
self._handle_api_prompts_get(parsed)
elif parsed.path == "/api/prompts/list":
self._handle_api_prompts_list(parsed)
elif parsed.path.startswith("/api/documents/"):
self._handle_api_document(parsed)
else:
@@ -58,6 +70,27 @@ class Handler(BaseHTTPRequestHandler):
self._handle_llm_ops()
elif self.path == "/convert-doc":
self._handle_convert_doc()
elif self.path == "/api/classify-batch":
self._handle_classify_batch()
elif self.path == "/api/apply-groups":
self._handle_apply_groups()
elif self.path == "/api/cleanup":
self._handle_cleanup()
elif self.path == "/api/prompts/save":
self._handle_api_prompts_save()
elif self.path == "/api/prompts/activate":
self._handle_api_prompts_activate()
elif self.path == "/api/prompts/delete":
self._handle_api_prompts_delete()
elif self.path == "/api/sync":
self._handle_api_sync()
else:
self.send_error(404)
def do_DELETE(self):
parsed = urlparse(self.path)
if parsed.path.startswith("/api/documents/"):
self._handle_api_document_delete(parsed)
else:
self.send_error(404)
@@ -117,18 +150,255 @@ class Handler(BaseHTTPRequestHandler):
"filename": doc["filename"],
"status": doc["status"],
"elements_json": doc.get("elements_json"),
"doc_type": doc.get("doc_type"),
"own_number": doc.get("own_number"),
"parent_number": doc.get("parent_number"),
"doc_date": doc.get("doc_date"),
"counterparty": doc.get("counterparty"),
"classify_status": doc.get("classify_status"),
"classify_raw": doc.get("classify_raw"),
"classify_input": doc.get("classify_input"),
})
def _handle_api_document_delete(self, parsed):
"""DELETE /api/documents/<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) ───────────────────────────────────────────────────
def _handle_app_js(self):
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:
js = f.read()
self.send_response(200)
self.send_header("Content-Type", "application/javascript; charset=utf-8")
self.send_header("Content-Length", str(len(js)))
self.send_header("X-Content-Type-Options", "nosniff")
self._send_cors()
self.end_headers()
self.wfile.write(js)
+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 psycopg2
import psycopg2.pool
import psycopg2.extras
# Глобальный пул соединений (singleton)
_pool = None
# Параметры подключения из переменных окружения (systemd Environment)
DB_CONFIG = {
"host": os.getenv("DB_HOST", "127.0.0.1"),
"port": int(os.getenv("DB_PORT", "5432")),
@@ -16,6 +27,7 @@ DB_CONFIG = {
def get_pool():
"""Возвращает глобальный пул соединений. Создаёт при первом вызове."""
global _pool
if _pool is None:
_pool = psycopg2.pool.ThreadedConnectionPool(
@@ -25,7 +37,10 @@ def get_pool():
def query(sql, params=None):
"""SELECT → list of dicts (lowercase keys)."""
"""
SELECT → list[dict] с lowercase ключами.
Используется всеми db/*.py модулями для чтения данных.
"""
pool = get_pool()
conn = pool.getconn()
try:
@@ -38,7 +53,10 @@ def query(sql, params=None):
def execute(sql, params=None):
"""INSERT/UPDATE/DELETE → rowcount."""
"""
INSERT/UPDATE/DELETE → количество затронутых строк.
Автокоммит. При ошибке — rollback и проброс исключения.
"""
pool = get_pool()
conn = pool.getconn()
try:
@@ -54,7 +72,10 @@ def execute(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()
conn = pool.getconn()
try:
+56 -4
View File
@@ -2,12 +2,12 @@
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."""
return execute_returning(
"""INSERT INTO documents (filename, mime_type, original_bytes, status)
VALUES (%s, %s, %s, %s) RETURNING *""",
(filename, mime_type, original_bytes, status),
"""INSERT INTO documents (filename, mime_type, original_bytes, status, batch_id)
VALUES (%s, %s, %s, %s, %s) RETURNING *""",
(filename, mime_type, original_bytes, status, batch_id),
)
@@ -34,3 +34,55 @@ def set_error(doc_id, error_message):
def delete(doc_id):
return execute("DELETE FROM documents WHERE id = %s", (doc_id,))
def set_classification(doc_id, doc_type, own_number, parent_number, doc_date, counterparty, classify_raw=None, classify_input=None):
"""Store LLM classification results + raw response + input text."""
return execute(
"""UPDATE documents SET doc_type=%s, own_number=%s, parent_number=%s,
doc_date=%s, counterparty=%s, classify_status='classified', classify_raw=%s, classify_input=%s
WHERE id=%s""",
(doc_type, own_number, parent_number, doc_date, counterparty, classify_raw, classify_input, doc_id),
)
def set_classify_failed(doc_id, error):
return execute(
"UPDATE documents SET classify_status='failed', error_message=%s WHERE id=%s",
(error, doc_id),
)
def 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}
+132 -18
View File
@@ -2,12 +2,20 @@
from .connection import query, execute, execute_returning
def _serialize(row):
"""Convert datetime fields to strings for JSON serialization (non-mutating)."""
if row and row.get("created_at"):
row = dict(row)
row["created_at"] = str(row["created_at"])
return row
def get_active(role):
rows = query(
"SELECT * FROM prompts WHERE role = %s AND is_active = true ORDER BY created_at DESC LIMIT 1",
(role,),
)
return rows[0] if rows else None
return _serialize(rows[0]) if rows else None
def get(prompt_id):
@@ -17,21 +25,127 @@ def get(prompt_id):
def seed_defaults():
"""Auto-seed default prompts if table is empty."""
count = query("SELECT COUNT(*) as cnt FROM prompts")
if count[0]["cnt"] > 0:
return
# Check each role separately — don't skip if one is missing
existing_roles = set(r["role"] for r in query("SELECT DISTINCT role FROM prompts"))
if "extract" not in existing_roles:
extract_body = (
"Ты — анализатор договоров облачного провайдера.\n\n"
"Ниже текст спецификации услуг из ПЕРВОГО документа (базовый договор).\n"
"Извлеки ВСЕ строки спецификации в JSON-массив.\n\n"
"Верни СТРОГО JSON без пояснений. Не используй markdown-блоки.\n\n"
"ФОРМАТ:\n"
'{\n "mode": "partial",\n "ops": [\n'
' {"action": "ADD", "new_row": {"name": "полное наименование", "price": число, "qty": число, "sum": число, "date_start": "YYYY-MM-DD"}, "comment": ""}\n'
" ]\n}\n\n"
"ПРАВИЛА:\n"
"1. Извлеки КАЖДУЮ строку таблицы спецификации как отдельную ADD-операцию.\n"
'2. Пропускай итоговые строки ("Итого...") и строки с подписями.\n'
"3. Если ячейка пустая — ставь null (не пиши 0).\n"
"4. price, qty, sum — ЧИСЛА, не строки.\n\n"
"ТЕКСТ ДОКУМЕНТА:\n---\n{doc_text}\n---"
)
execute(
"INSERT INTO prompts (role, name, body, is_active, notes) VALUES (%s, %s, %s, %s, %s)",
("extract", "default-v1", extract_body, True, "Авто-создан из llm_prompt.py _build_initial"),
)
if "diff" not in existing_roles:
diff_body = (
"Ты — анализатор допсоглашений к договорам облачного провайдера.\n\n"
"У тебя есть текущая спецификация услуг и текст нового допсоглашения (ДС).\n"
"Твоя задача — определить, какие изменения вносит ДС в текущую спецификацию.\n\n"
"Верни СТРОГО JSON без пояснений. Не используй markdown-блоки.\n\n"
"ФОРМАТ:\n"
'{\n "mode": "partial" | "full_replace",\n "ops": [\n'
' {"action": "ADD", "new_row": {"name": "...", "price": число, "qty": число, "sum": число, "date_start": "YYYY-MM-DD"}, "comment": "..."},\n'
' {"action": "UPDATE", "target_id": "rN", "new_values": {"price": число}, "comment": "..."},\n'
' {"action": "DELETE", "target_id": "rN", "comment": "..."},\n'
' {"action": "UNRESOLVED", "new_values": {"name": "...", "price": число, ...}, "reason": "почему не смог сопоставить"}\n'
" ]\n}\n\n"
"ПРАВИЛА:\n"
'1. mode = "full_replace" — если в тексте есть фразы: «в следующей редакции», «заменить приложение», «излагается в следующей редакции». При full_replace — опиши ВСЕ новые строки как ADD.\n'
'2. mode = "partial" — если ДС меняет только отдельные строки.\n'
"3. Для UPDATE/DELETE — укажи target_id (r1, r2...) из списка текущей спецификации. НЕ придумывай новые id.\n"
"4. new_values в UPDATE — только ИЗМЕНЁННЫЕ поля (не все).\n"
"5. Если не можешь однозначно сопоставить строку — action: UNRESOLVED с reason.\n"
"6. Пропускай итоговые строки и подписи.\n"
"7. price, qty, sum — ЧИСЛА (не строки).\n\n"
"ТЕКУЩАЯ СПЕЦИФИКАЦИЯ:\n{spec_current}\n\n"
"ТЕКСТ ДОПСОГЛАШЕНИЯ:\n---\n{doc_text}\n---"
)
execute(
"INSERT INTO prompts (role, name, body, is_active, notes) VALUES (%s, %s, %s, %s, %s)",
("diff", "default-v1", diff_body, True, "Авто-создан из llm_prompt.py _build_diff"),
)
_ensure_classify_prompt()
execute("""
INSERT INTO prompts (role, name, body, is_active, notes) VALUES (
'extract', 'default-v1',
'Ты — анализатор договоров облачного провайдера.\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---',
true, 'Авто-создан из llm_prompt.py _build_initial'
)
""")
execute("""
INSERT INTO prompts (role, name, body, is_active, notes) VALUES (
'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'
)
""")
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 сам предложил"),
)
+21 -6
View File
@@ -1,6 +1,6 @@
"""spec_events — event sourcing: apply ops, reset contract."""
import json, uuid
from .connection import query, execute
from .connection import query, execute, get_pool
def reset(contract_id):
@@ -10,11 +10,26 @@ def reset(contract_id):
def get_next_seq(contract_id):
rows = query(
"SELECT COALESCE(MAX(seq), 0) + 1 AS n FROM spec_events WHERE contract_id = %s",
(contract_id,),
)
return rows[0]["n"] if rows else 1
"""Get next sequence number with row lock to prevent race conditions."""
pool = get_pool()
conn = pool.getconn()
try:
conn.autocommit = False
with conn.cursor() as cur:
cur.execute(
"SELECT seq FROM spec_events WHERE contract_id = %s ORDER BY seq DESC LIMIT 1 FOR UPDATE",
(contract_id,),
)
row = cur.fetchone()
seq = (row[0] + 1) if row else 1
conn.commit()
return seq
except Exception:
conn.rollback()
raise
finally:
conn.autocommit = True
pool.putconn(conn)
def apply_ops(contract_id, supplement_id, document_id, ops, prompt_id, raw_llm_response):
+18 -6
View File
@@ -28,19 +28,31 @@ def get(supp_id):
def delete_by_document(contract_id, filename):
"""Delete supplement+document by contract+filename."""
row = query(
"""Delete ALL supplements+documents by contract+filename (cascade: spec_events first).
Handles duplicates from previously failed uploads."""
rows = query(
"""SELECT s.id as sid, s.document_id FROM supplements s
JOIN documents d ON d.id = s.document_id
WHERE s.contract_id = %s AND d.filename = %s""",
(contract_id, filename),
)
if row:
r = row[0]
if not rows:
return False
for r in rows:
# 1. Delete spec_current rows referencing this supplement's events
execute(
"""DELETE FROM spec_current WHERE contract_id = %s
AND last_event_id IN (SELECT id FROM spec_events WHERE supplement_id = %s)""",
(contract_id, r["sid"]),
)
# 2. Delete spec_events referencing this supplement
execute("DELETE FROM spec_events WHERE supplement_id = %s", (r["sid"],))
# 3. Delete supplement
execute("DELETE FROM supplements WHERE id = %s", (r["sid"],))
# 4. Delete document
execute("DELETE FROM documents WHERE id = %s", (r["document_id"],))
return True
return False
return True
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:
"""Получить активный промпт из БД. Возвращает {id, body} или None."""
try:
with httpx.Client(http2=True, timeout=10, verify=False) as client:
with httpx.Client(http2=True, timeout=10) as client:
resp = client.get(
f"{LUCEE_URL}/prompt.cfm",
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))
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."""
import zipfile, io, base64, json
"""Unzip service — extract files from ZIP archive (no DB, no parse)."""
import zipfile, io, base64
def handle_unzip(rfile, content_length):
"""Parse ZIP upload, return list of extracted files."""
"""Parse ZIP upload, return list of extracted files as base64."""
body = rfile.read(content_length)
# Find file data in raw multipart
# Simple approach: find ZIP bytes after header
idx = body.find(b"\r\n\r\n")
if idx < 0:
return {"ok": False, "error": "invalid multipart"}
# Find first boundary end
raw = body[idx + 4:]
# Find the actual ZIP data (after Content-Disposition etc)
zip_start = raw.find(b"PK\x03\x04")
if zip_start < 0:
return {"ok": False, "error": "not a ZIP file"}
+36 -5
View File
@@ -1,12 +1,24 @@
"""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 .parse import parse_file
MAX_FILE_SIZE = 100 * 1024 * 1024 # 100 MB
def handle_upload(rfile, content_type, content_length):
"""Parse multipart upload, store in DB, return result dict."""
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 = {
"REQUEST_METHOD": "POST",
@@ -23,13 +35,29 @@ def handle_upload(rfile, content_type, content_length):
item = fs["files"]
if isinstance(item, list):
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
if isinstance(file_data, str):
file_data = file_data.encode("utf-8")
if "contract_id" in fs:
contract_id = fs.getfirst("contract_id", "")
# Validate UUID
if contract_id:
try:
uuid.UUID(contract_id)
except (ValueError, AttributeError):
contract_id = ""
batch_id = fs.getfirst("batch_id", None) if "batch_id" in fs else None
# Validate UUID
if batch_id:
try:
uuid.UUID(batch_id)
except (ValueError, AttributeError):
batch_id = None
if not filename or not file_data:
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()
if contract_id:
supplements.delete_by_document(contract_id, filename)
try:
supplements.delete_by_document(contract_id, filename)
except Exception:
pass # old record may not exist or FK issue — proceed with insert
doc = documents.insert(filename, mime, b64)
doc = documents.insert(filename, mime, b64, batch_id=batch_id)
if not contract_id:
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:hover { background: var(--brand-primary-dark); }
.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; }
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); }
@@ -75,7 +75,14 @@
<body>
<div class="topbar">
<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 class="content">
@@ -83,23 +90,23 @@
<div class="card-header">
<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 class="card-body">
<input type="file" id="fileInput" accept=".docx,.doc,.pdf" multiple style="margin-bottom:6px;width:100%;">
<div style="text-align:right;font-size:11px;color:var(--muted);margin-bottom:6px;">↕ первый в списке — основной договор, порядок меняется стрелками</div>
<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="font-size:11px;color:var(--muted);margin-bottom:6px;">⚠ При совпадении имён — запрос на перезапись (OK / Отмена). Файлы из ZIP-архивов загружаются через тот же поток.</div>
<div class="table-wrap">
<table>
<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>
<tbody id="fileTable"></tbody>
</table>
</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);">
<i data-lucide="scale" style="width:16px;height:16px;"></i> Сравнить
<i data-lucide="scale" style="width:16px;height:16px;"></i> Общее сравнение
</button>
<details style="margin-top:8px;font-size:12px;">
@@ -107,6 +114,12 @@
<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="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>
</details>
</div>
@@ -115,11 +128,19 @@
<div class="card" id="diffCard" style="display:none;">
<div class="card-header">
<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 class="card-body" id="diffBody"></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-header">
<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>
</div>
<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>
<!-- diff: сравнение допсоглашений с текущей спецификацией (ADD/UPDATE/DELETE) -->
<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>
<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;">
@@ -172,20 +200,58 @@
</div>
<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">
<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>
</div>
<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>
<p><b>Стек:</b> Lucee (домен) + Python 3 (VM, вся логика) + PostgreSQL 15 + gpt-oss-120b</p>
<h3 style="margin-top:0;">Что это</h3>
<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>
<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>
</html>