Files
contractor/index.cfm
T
2026-06-21 09:05:51 +04:00

676 lines
35 KiB
Plaintext
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<cfsetting showdebugoutput="no" enablecfoutputonly="no">
<cfheader name="Content-Type" value="text/html; charset=utf-8">
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Сверка договоров — LLM</title>
<link rel="icon" type="image/png" href="/favicon.png">
<script src="https://unpkg.com/lucide@latest"></script>
<style>
:root { --brand-primary: #2563eb; --brand-primary-dark: #1d4ed8; --brand-gray: #d1d5db; --brand-grey-light: #f3f4f6; --background: #ffffff; --foreground: #1a1a1a; --muted: #6b7280; --destructive: #ef4444; --green: #22c55e; }
* { box-sizing: border-box; margin: 0; padding: 0; }
body { font-family: system-ui, sans-serif; font-size: 14px; color: var(--foreground); background: var(--brand-grey-light); min-height: 100vh; }
.topbar { background: var(--background); border-bottom: 1px solid var(--brand-gray); padding: 0 24px; height: 56px; display: flex; align-items: center; gap: 16px; position: sticky; top: 0; z-index: 50; }
.topbar img { height: 28px; }
.topbar .title { font-weight: 600; font-size: 16px; }
.content { max-width: 900px; margin: 32px auto; padding: 0 16px; }
.card { background: var(--background); border-radius: 12px; border: 1px solid var(--brand-gray); box-shadow: 0 1px 2px rgba(0,0,0,.05); overflow: hidden; margin-bottom: 16px; }
.card-header { background: var(--brand-grey-light); padding: 12px 16px; font-weight: 600; font-size: 16px; display: flex; align-items: center; gap: 8px; }
.card-body { padding: 16px; }
.btn { height: 36px; border-radius: 6px; gap: 6px; padding: 0 14px; font-size: 14px; font-family: inherit; cursor: pointer; display: inline-flex; align-items: center; border: 1px solid var(--brand-gray); background: var(--background); }
.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 { 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); }
tr:hover td { background: rgba(243,244,246,.5); }
.name-cell { max-width: 340px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.num-cell { text-align: right; white-space: nowrap; }
.status-ok { color: var(--green); }
.status-err { color: var(--destructive); }
.summary-row td { background: rgba(34,197,94,.05); font-weight: 600; }
.empty-row td { color: var(--muted); text-align: center; padding: 24px; }
.remove-btn { cursor: pointer; color: #f87171; background: none; border: none; padding: 2px 4px; font-size: 16px; line-height: 1; }
.remove-btn:hover { color: #ef4444; }
.info-btn { cursor: pointer; color: var(--muted); background: none; border: none; padding: 2px 4px; font-size: 14px; display: none; }
.info-btn:hover { color: var(--brand-primary); }
.info-btn.visible { display: inline; }
.modal-overlay { display: none; position: fixed; top: 0; left: 0; width: 100%; height: 100%; background: rgba(0,0,0,.4); z-index: 100; justify-content: center; align-items: center; }
.modal-overlay.open { display: flex; }
.modal { background: var(--background); border-radius: 12px; border: 1px solid var(--brand-gray); box-shadow: 0 4px 24px rgba(0,0,0,.15); max-width: 520px; width: 90%; max-height: 80vh; display: flex; flex-direction: column; }
.modal.wide { max-width: 900px; }
.modal-header { padding: 14px 16px; border-bottom: 1px solid var(--brand-gray); display: flex; align-items: center; gap: 8px; font-weight: 600; flex-shrink: 0; }
.modal-body { padding: 16px; overflow-y: auto; }
.modal-body .kv { display: flex; margin-bottom: 6px; font-size: 13px; }
.modal-body .kv .k { color: var(--muted); width: 110px; flex-shrink: 0; }
.modal-body .kv .v { word-break: break-all; }
.diff-added { background: rgba(34,197,94,.1); }
.diff-deleted { background: rgba(239,68,68,.1); }
.diff-changed { background: rgba(234,179,8,.1); }
.diff-added td { color: var(--green); }
.diff-deleted td { color: var(--destructive); text-decoration: line-through; }
.diff-field-old { color: var(--destructive); text-decoration: line-through; font-size: 11px; }
.diff-field-new { color: var(--green); font-weight: 600; }
@keyframes spin { to { transform: rotate(360deg); } }
.spinner { display: inline-block; width: 16px; height: 16px; border: 2px solid rgba(255,255,255,.3); border-top-color: #fff; border-radius: 50%; animation: spin .6s linear infinite; }
.text-panes { display: flex; gap: 0; }
.text-pane { flex: 1; min-width: 0; overflow-y: auto; max-height: 65vh; }
.text-pane:first-child { border-right: 1px solid var(--brand-gray); padding-right: 12px; }
.text-pane:last-child { padding-left: 12px; }
.text-pane pre { background: var(--brand-grey-light); padding: 10px; border-radius: 6px; font-size: 11px; white-space: pre-wrap; word-break: break-word; overflow-x: auto; margin: 0; max-height: 60vh; overflow-y: auto; }
.text-pane table { font-size: 11px; width: 100%; }
.text-pane th { font-size: 10px; padding: 4px 6px; }
.text-pane td { padding: 3px 6px; font-size: 11px; }
.text-pane .pane-title { font-weight: 600; font-size: 12px; margin-bottom: 8px; color: var(--muted); text-transform: uppercase; }
</style>
</head>
<body>
<div class="topbar">
<img src="/nubes-logo.svg" alt="Nubes">
<span class="title">Сверка договоров — LLM <span style="font-weight:400;color:var(--muted);font-size:12px;">v1.0.81 — Lucee</span></span>
</div>
<div class="content">
<div class="card">
<div class="card-header">
<i data-lucide="folder-open" style="width:18px;height:18px;"></i>
Загрузка договоров / допников
</div>
<div class="card-body">
<input type="file" id="fileInput" accept=".docx,.doc,.pdf,.zip" multiple style="margin-bottom:12px;width:100%;">
<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>
</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;">
<i data-lucide="scale" style="width:16px;height:16px;"></i> Сравнить (LLM)
</button>
<button class="btn btn-primary" id="llmBtnV2" 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> Сравнить v2 (Event Sourcing)
</button>
<details style="margin-top:8px;font-size:12px;">
<summary style="cursor:pointer;color:var(--muted);">⚙ Промпт LLM</summary>
<textarea id="promptEditor" style="width:100%;height:200px;font-family:monospace;font-size:11px;border:1px solid var(--brand-gray);border-radius:6px;padding:8px;margin-top:4px;"></textarea>
<div style="display:flex;gap:8px;margin-top:4px;">
<button class="btn" id="promptSave" style="font-size:11px;height:28px;">Сохранить</button>
<button class="btn" id="promptReset" style="font-size:11px;height:28px;">По умолчанию</button>
<span id="promptStatus" style="font-size:11px;color:var(--muted);line-height:28px;"></span>
</div>
</details>
</div>
</div>
<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>
</div>
<div class="card-body" id="diffBody"></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>
Задать вопрос по договору
</div>
<div class="card-body">
<div id="chatMessages" style="margin-bottom:8px;font-size:13px;"></div>
<div style="display:flex;gap:8px;">
<input type="text" id="chatInput" placeholder="Какая общая сумма? Что изменилось? ..." style="flex:1;height:32px;border:1px solid var(--brand-gray);border-radius:6px;padding:0 8px;font-size:13px;">
<button class="btn btn-primary" id="chatSend" style="height:32px;font-size:13px;">→</button>
</div>
</div>
</div>
</div>
<div class="modal-overlay" id="modalOverlay" onclick="closeModal(event)">
<div class="modal" onclick="event.stopPropagation()">
<div class="modal-header">
<i data-lucide="file-text" style="width:18px;height:18px;"></i>
<span id="modalTitle">Информация о файле</span>
<button class="remove-btn" onclick="closeModal()" style="margin-left:auto;font-size:18px;">&#xd7;</button>
</div>
<div class="modal-body" id="modalBody"></div>
</div>
</div>
<script>
lucide.createIcons();
var UPLOAD_URL = 'https://contracts.kube5s.ru/lucee/upload.cfm';
var CONVERT_URL = 'https://contracts.kube5s.ru/convert-doc';
var SITE_URL = ''; // same origin for api calls
var fileInput = document.getElementById('fileInput');
var fileTable = document.getElementById('fileTable');
var fileQueue = [];
var contractId = null;
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';
}
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'});
}
function renderTable() {
if (fileQueue.length === 0) {
fileTable.innerHTML = '<tr class="empty-row"><td colspan="7">Нет файлов — выберите .docx / .pdf / .zip</td></tr>';
} else {
fileTable.innerHTML = fileQueue.map(function(f, i) {
var radio = '';
if (f.supp_id) {
radio = '<input type="radio" name="base_doc" value="' + f.supp_id + '"' + (f.supp_type === 'initial' ? ' checked' : '') + ' onchange="setInitial(\'' + f.supp_id + '\')" title="Выбрать как базовый договор">';
}
return '<tr id="row_' + i + '">' +
'<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;">' + radio + '</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();
}
function removeFile(i) {
fileQueue.splice(i, 1);
if (fileQueue.length === 0) contractId = null;
renderTable();
}
window.removeFile = removeFile;
// ── Автозагрузка при выборе файлов ──────────────────────────
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 entry = { name: f.name, lastModified: f.lastModified, size: f.size, file: f, status: '↑ 0%' };
fileQueue.push(entry);
var rowIdx = fileQueue.length - 1;
renderTable();
try {
var resp = await uploadFile(f, function(pct) {
fileQueue[rowIdx].status = '↑ ' + pct + '%';
renderTable();
});
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;
// Авто-парсинг сразу после загрузки
var t0 = Date.now();
fileQueue[rowIdx].status = '⏳ парсинг...';
renderTable();
try {
var pr = await fetch('/parser.cfm?doc_id=' + resp.DOC_ID);
var pd = await pr.json();
var elapsed = ((Date.now() - t0) / 1000).toFixed(1);
if (pd.OK && pd.STATUS === 'parsed') {
fileQueue[rowIdx].parsed = true;
fileQueue[rowIdx].parseInfo = pd;
fileQueue[rowIdx].status = '<span class="status-ok">✓ ' + pd.ELEMENT_COUNT + ' эл. (' + elapsed + 'с)</span>';
} else {
fileQueue[rowIdx].status = '<span class="status-err">✗ ' + (pd.ERROR_COUNT > 0 ? (pd.ERRORS && pd.ERRORS[0] || 'ошибка') : 'ошибка') + '</span>';
fileQueue[rowIdx].parseInfo = pd;
}
} catch(pe) {
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;
var llmBtnV2 = document.getElementById('llmBtnV2');
llmBtnV2.style.display = 'flex';
llmBtnV2.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();
}
});
}
// ── LLM v2: Event Sourcing ─────────────────────────────────
document.getElementById('llmBtnV2').addEventListener('click', function() {
if (!contractId) return;
var btn = this;
btn.disabled = true;
btn.innerHTML = '<span class="spinner"></span> LLM v2...';
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 llmStart = Date.now();
var timerInterval = setInterval(function() {
diffStatus.textContent = '⏳ ' + ((Date.now() - llmStart) / 1000).toFixed(1) + 'с';
}, 200);
var es = new EventSource('https://contracts.kube5s.ru/process-v2?contract_id=' + contractId);
es.onmessage = function(e) {
var d = JSON.parse(e.data);
if (d.type === 'extract_start') {
diffBody.innerHTML += '<div style="font-size:12px;color:var(--muted);margin-top:2px;">⏳ ' + d.filename + ' → LLM...</div>';
}
else if (d.type === 'llm_done') {
diffBody.innerHTML += '<div style="font-size:12px;color:var(--green);">✓ ' + d.filename + ': ' + d.ops_count + ' операций, ' + d.mode + ' (' + d.time_s + 'с)</div>';
}
else if (d.type === 'applied') {
var s = d.summary || {};
diffBody.innerHTML += '<div style="font-size:12px;margin-left:8px;"> +' + (s.added||0) + ' ~' + (s.updated||0) + ' -' + (s.deleted||0) + ' ?' + (s.unresolved||0) + '</div>';
}
else if (d.type === 'extract_error' || d.type === 'apply_error') {
diffBody.innerHTML += '<div style="font-size:12px;color:var(--destructive);">✗ ' + d.filename + ': ' + d.error + '</div>';
}
else if (d.type === 'done') {
clearInterval(timerInterval);
es.close();
diffStatus.textContent = '✓ ' + d.total_time_s + 'с';
btn.innerHTML = '<i data-lucide="check" style="width:16px;height:16px;"></i> ✓ Готово';
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>';
}
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> Сравнить v2';
btn.disabled = false;
}
};
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> Сравнить v2';
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('/api.cfm?action=query&sql=' + encodeURIComponent("SELECT s.id, s.type, s.document_id FROM supplements s WHERE s.contract_id='" + contractId + "'"));
var data = await resp.json();
console.log('refreshSupps: data=', data);
console.log('refreshSupps: fileQueue doc_ids=', fileQueue.map(function(f){return f.doc_id;}));
if (data.OK && data.ROWS) {
data.ROWS.forEach(function(r) {
var supp = {};
data.COLUMNS.forEach(function(c, ci) { supp[String(c).toLowerCase()] = r[c]; });
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); }
}
function setInitial(suppId) {
fetch('/api.cfm?action=set_initial&supplement_id=' + suppId)
.then(function() { refreshSupps(); });
}
window.setInitial = setInitial;
// ── LLM: Сравнить ────────────────────────────────────────────
document.getElementById('llmBtn').addEventListener('click', async function() {
if (!contractId) return;
var btn = this;
btn.disabled = true;
btn.innerHTML = '<span class="spinner"></span> LLM-анализ...';
// Сохранить промпт на сервер (ДОЖДАТЬСЯ)
var p = promptEditor.value.trim() || DEFAULT_PROMPT;
if (contractId) {
await fetch('/prompt.cfm?contract_id=' + contractId, {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({prompt: p})
});
}
var diffCard = document.getElementById('diffCard');
var diffBody = document.getElementById('diffBody');
var diffStatus = document.getElementById('diffStatus');
diffCard.style.display = 'block';
diffBody.innerHTML = '⏳ LLM-извлечение...';
diffStatus.textContent = '⏳ 0.0с';
var llmStart = Date.now();
var timerInterval = setInterval(function() {
diffStatus.textContent = '⏳ ' + ((Date.now() - llmStart) / 1000).toFixed(1) + 'с';
}, 200);
// 1. Extract for each supplement
try {
var resp = await fetch('/api.cfm?action=query&sql=' + encodeURIComponent("SELECT id, type FROM supplements WHERE contract_id='" + contractId + "' ORDER BY created_at"));
var data = await resp.json();
if (data.OK && data.ROWS) {
for (var i = 0; i < data.ROWS.length; i++) {
try {
var er = await fetch('/extractor.cfm?supplement_id=' + data.ROWS[i].id);
var ed = await er.json();
if (ed.OK) {
var info = (ed.SKIPPED ? '⏭ ' : '✓ ') + (data.ROWS[i].type || '') + ': ' + ed.ROWS_SAVED + ' строк';
diffBody.innerHTML += '<div style="font-size:12px;color:' + (ed.SKIPPED ? 'var(--muted)' : 'var(--green)') + ';">' + info + '</div>';
} else {
diffBody.innerHTML += '<div style="font-size:12px;color:var(--destructive);">✗ ' + (ed.ERROR||'') + '</div>';
}
} catch(e) {}
}
}
} catch(e) {}
// 2. Differ
diffBody.innerHTML += '<div style="margin-top:8px;font-weight:600;">📊 Сравнение:</div>';
try {
var resp = await fetch('/differ.cfm?contract_id=' + contractId);
var data = await resp.json();
if (data.OK && data.ALL_CHANGES) {
var all = data.ALL_CHANGES;
diffBody.innerHTML = '';
all.forEach(function(ch) {
var s = ch.SUMMARY || {};
diffBody.innerHTML += '<div style="font-weight:600;margin-top:12px;">📄 Допник (' + ch.TYPE + ')</div>';
diffBody.innerHTML += '<div style="font-size:12px;margin-left:8px;"><span style="color:var(--green);">+' + (s.ADDED||0) + '</span> <span style="color:#eab308;">~' + (s.CHANGED||0) + '</span> <span style="color:var(--destructive);">-' + (s.DELETED||0) + '</span></div>';
diffStatus.textContent = 'изм:' + (s.CHANGED||0) + ' +' + (s.ADDED||0) + ' -' + (s.DELETED||0);
var changes = ch.CHANGES || [];
if (changes.length === 0) {
diffBody.innerHTML += '<div style="color:var(--muted);font-size:12px;">Нет изменений</div>';
} else {
var tbl = '<div class="table-wrap" style="margin-top:4px;"><table><thead><tr><th>№</th><th>Услуга</th><th>Цена</th><th>Объём</th><th>Сумма</th><th>Начало</th></tr></thead><tbody>';
changes.forEach(function(c) {
var cls = c.CHANGE_TYPE === 'added' ? 'diff-added' : c.CHANGE_TYPE === 'deleted' ? 'diff-deleted' : c.CHANGE_TYPE === 'changed' ? 'diff-changed' : '';
var vals = c.NEW_VALUES && Object.keys(c.NEW_VALUES).length > 0 ? c.NEW_VALUES : (c.OLD_VALUES || {});
tbl += '<tr class="' + cls + '"><td>' + (c.ROW_NUM||'') + '</td><td>' + (vals.NAME||'') + '</td><td class="num-cell">' + _dc(c,'PRICE') + '</td><td class="num-cell">' + _dc(c,'QTY') + '</td><td class="num-cell">' + _dc(c,'SUM') + '</td><td>' + (vals.DATE_START||'') + '</td></tr>';
});
tbl += '</tbody></table></div>';
diffBody.innerHTML += tbl;
}
});
} else {
diffBody.innerHTML += '<div style="color:var(--destructive);">✗ ' + (data.ERROR||'') + '</div>';
}
} catch(e) { diffBody.innerHTML += '<div style="color:var(--destructive);">✗ ' + e.message + '</div>'; }
clearInterval(timerInterval);
diffStatus.textContent = '✓ ' + ((Date.now() - llmStart) / 1000).toFixed(1) + 'с';
btn.innerHTML = '<i data-lucide="check" style="width:16px;height:16px;"></i> ✓ Готово';
document.getElementById('chatCard').style.display = 'block';
lucide.createIcons();
});
function _dc(c, field) {
if (c.CHANGE_TYPE === 'changed' && c.OLD_VALUES && c.NEW_VALUES) {
var ov = (c.OLD_VALUES||{})[field], nv = (c.NEW_VALUES||{})[field];
if (ov !== nv && ov !== undefined)
return '<span class="diff-field-old">' + (ov||'—') + '</span> → <span class="diff-field-new">' + (nv||'—') + '</span>';
}
var v = ((c.NEW_VALUES || c.OLD_VALUES || {})[field]);
return v != null ? v : '—';
}
// ── Чат ──────────────────────────────────────────────────────
document.getElementById('chatSend').addEventListener('click', function() {
var input = document.getElementById('chatInput');
var q = input.value.trim();
if (!q || !contractId) return;
var msgs = document.getElementById('chatMessages');
msgs.innerHTML += '<div style="color:var(--brand-primary);margin-top:4px;"><strong>Вы:</strong> ' + q + '</div>';
input.value = '';
input.disabled = true;
document.getElementById('chatSend').disabled = true;
msgs.innerHTML += '<div style="color:var(--muted);margin-top:2px;">⏳ Думаю...</div>';
var fd = new FormData();
fd.append('question', q);
fetch('/chat.cfm?contract_id=' + contractId, { method: 'POST', body: fd })
.then(function(r) { return r.json(); })
.then(function(d) {
msgs.lastChild.remove();
msgs.innerHTML += '<div style="margin-top:2px;"><strong>Ответ:</strong> ' + (d.OK ? d.ANSWER : (d.ERROR || '—')) + '</div>';
input.disabled = false;
document.getElementById('chatSend').disabled = false;
input.focus();
}).catch(function() {
msgs.lastChild.remove();
msgs.innerHTML += '<div style="color:var(--destructive);margin-top:2px;">Ошибка сети</div>';
input.disabled = false;
document.getElementById('chatSend').disabled = false;
});
});
document.getElementById('chatInput').addEventListener('keydown', function(e) {
if (e.key === 'Enter') document.getElementById('chatSend').click();
});
function closeModal(e) {
if (e && e.target !== document.getElementById('modalOverlay')) return;
document.querySelector('.modal').classList.remove('wide');
document.getElementById('modalOverlay').classList.remove('open');
}
async function showText(i) {
var f = fileQueue[i];
var docId = f.doc_id;
document.getElementById('modalTitle').textContent = f.name;
document.getElementById('modalBody').innerHTML = '<span style="color:var(--muted);">Загрузка...</span>';
var modal = document.querySelector('.modal');
modal.classList.add('wide');
document.getElementById('modalOverlay').classList.add('open');
// Левая панель: сырой текст
var leftHtml = '<span style="color:var(--muted);">Загрузка...</span>';
if (docId) {
try {
var vResp = await fetch('/view.cfm?doc_id=' + docId);
var rawText = await vResp.text();
rawText = rawText.replace(/^\s+/, '');
leftHtml = '<pre>' + rawText.replace(/</g,'&lt;').replace(/>/g,'&gt;') + '</pre>';
} catch(e) {
leftHtml = '<span style="color:var(--destructive);">Ошибка</span>';
}
} else {
leftHtml = '<span style="color:var(--muted);">(ещё не загружен)</span>';
}
// Правая панель: распарсено (статистика + структура)
var rightHtml = '';
// Базовая инфа
rightHtml += '<div class="kv"><span class="k">Размер</span><span class="v">' + formatSize(f.size) + '</span></div>';
rightHtml += '<div class="kv"><span class="k">Изменён</span><span class="v">' + formatDate(f.lastModified) + '</span></div>';
if (f.parseInfo) {
var d = f.parseInfo;
rightHtml += '<div class="kv"><span class="k">Элементов</span><span class="v">' + (d.ELEMENT_COUNT||0) + '</span></div>';
rightHtml += '<div class="kv"><span class="k">Параграфов</span><span class="v">' + (d.PARAGRAPHS||0) + '</span></div>';
rightHtml += '<div class="kv"><span class="k">Таблиц</span><span class="v">' + (d.TABLES||0) + '</span></div>';
rightHtml += '<div class="kv"><span class="k">Строк таблиц</span><span class="v">' + (d.TABLE_ROWS||0) + '</span></div>';
if (d.PAGES) rightHtml += '<div class="kv"><span class="k">Страниц</span><span class="v">' + d.PAGES + '</span></div>';
if (d.PARSE_TIME_MS) {
rightHtml += '<div class="kv"><span class="k">Время парсинга</span><span class="v">' + (d.PARSE_TIME_MS > 1000 ? (d.PARSE_TIME_MS/1000).toFixed(1) + ' с' : d.PARSE_TIME_MS + ' мс') + '</span></div>';
}
if (d.TEXT_LENGTH) rightHtml += '<div class="kv"><span class="k">Символов</span><span class="v">' + d.TEXT_LENGTH.toLocaleString() + '</span></div>';
// Заголовки таблиц
if (d.TABLE_HEADERS && d.TABLE_HEADERS.length > 0) {
rightHtml += '<div style="margin-top:10px;font-weight:600;font-size:12px;">Заголовки таблиц:</div>';
d.TABLE_HEADERS.forEach(function(hdr, ti) {
rightHtml += '<div style="margin-top:2px;font-size:10px;color:var(--muted);">Таблица ' + (ti+1) + ' (' + (hdr||[]).length + ' кол.):</div>';
rightHtml += '<pre>' + (hdr||[]).map(function(c){return c||'(пусто)';}).join(' | ') + '</pre>';
});
}
// Первые строки таблиц
if (d.TABLE_SAMPLES && d.TABLE_SAMPLES.length > 0) {
rightHtml += '<div style="margin-top:10px;font-weight:600;font-size:12px;">Первые строки:</div>';
d.TABLE_SAMPLES.forEach(function(sample, ti) {
rightHtml += '<div style="margin-top:2px;font-size:10px;color:var(--muted);">Таблица ' + (ti+1) + ':</div>';
rightHtml += '<pre>' + sample.map(function(row){ return (row||[]).map(function(c){return c||'(пусто)';}).join(' | '); }).join('\n') + '</pre>';
});
}
// Ошибки
if (d.ERRORS && d.ERRORS.length > 0) {
rightHtml += '<div style="margin-top:8px;color:var(--destructive);font-weight:600;">Ошибки:</div>';
rightHtml += '<pre>' + d.ERRORS.join('\n') + '</pre>';
}
// Превью текста
if (d.TEXT_PREVIEW) {
rightHtml += '<div style="margin-top:10px;font-weight:600;font-size:12px;">Превью текста:</div>';
rightHtml += '<pre>' + d.TEXT_PREVIEW.substring(0, 5000).replace(/</g,'&lt;').replace(/>/g,'&gt;') + '</pre>';
}
} else {
rightHtml += '<div style="color:var(--muted);margin-top:8px;">(ещё не распарсен)</div>';
}
rightHtml += '<div class="kv" style="margin-top:10px;"><span class="k">doc_id</span><span class="v">' + (f.doc_id || '—') + '</span></div>';
if (f.supp_id) {
rightHtml += '<div class="kv"><span class="k">supp_id</span><span class="v">' + f.supp_id + '</span></div>';
rightHtml += '<div class="kv"><span class="k">Тип ДС</span><span class="v">' + (f.supp_type || '—') + '</span></div>';
}
var html = '<div class="text-panes">' +
'<div class="text-pane"><div class="pane-title">Сырой текст</div>' + leftHtml + '</div>' +
'<div class="text-pane"><div class="pane-title">Распарсено</div>' + rightHtml + '</div>' +
'</div>';
document.getElementById('modalBody').innerHTML = html;
}
window.closeModal = closeModal;
window.showText = showText;
// ── Промпт ───────────────────────────────────────────────────
var promptEditor = document.getElementById('promptEditor');
var DEFAULT_PROMPT = 'Ты — анализатор договоров. Ниже текст спецификации услуг из договора облачного провайдера.\n\nНайди ВСЕ таблицы со списком услуг. Извлеки каждую строку в JSON-массив.\n\nДля каждой строки верни:\n- row_num: номер по порядку (целое число)\n- name: полное наименование услуги (весь текст из ячейки)\n- price: цена за единицу в рублях (число, из колонки "Цена")\n- qty: количество/объём (число, из колонки "Объем")\n- sum: итоговая сумма (число, из колонки "Сумма")\n- date_start: дата начала оказания (строка YYYY-MM-DD)\n\nПРАВИЛА:\n1. Пропускай итоговые строки ("Итого...") и строки с подписями.\n2. Если ячейка пустая — ставь null.\n3. Если не можешь определить значение — ставь null и добавь в unresolved.\n4. Верни ТОЛЬКО JSON, без пояснений.\n\nПример ответа:\n{"rows":[{"row_num":1,"name":"Аренда стойко-места","price":213905.44,"qty":3,"sum":641716.32,"date_start":"2026-04-01"}],"unresolved":[]}\n\nТекст документа:\n---\n{parsed_text}\n---';
promptEditor.value = localStorage.getItem('llm_prompt') || DEFAULT_PROMPT;
document.getElementById('promptSave').addEventListener('click', function() {
var p = promptEditor.value.trim();
localStorage.setItem('llm_prompt', p);
document.getElementById('promptStatus').textContent = '✓ сохранено';
setTimeout(function() { document.getElementById('promptStatus').textContent = ''; }, 1500);
if (contractId) {
fetch('/prompt.cfm?contract_id=' + contractId, {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({prompt: p})
});
}
});
document.getElementById('promptReset').addEventListener('click', function() {
promptEditor.value = DEFAULT_PROMPT;
localStorage.removeItem('llm_prompt');
document.getElementById('promptStatus').textContent = '✓ сброшен';
setTimeout(function() { document.getElementById('promptStatus').textContent = ''; }, 1500);
if (contractId) {
fetch('/prompt.cfm?contract_id=' + contractId, {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({prompt: ''})
});
}
});
</script>
</body>
</html>