561 lines
29 KiB
Plaintext
561 lines
29 KiB
Plaintext
<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: var(--muted); background: none; border: none; padding: 2px 4px; font-size: 16px; line-height: 1; }
|
||
.remove-btn:hover { color: var(--destructive); }
|
||
.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-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; }
|
||
</style>
|
||
</head>
|
||
<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.93 — 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="parseBtn" style="width:100%;justify-content:center;margin-top:12px;" disabled>
|
||
<i data-lucide="play" style="width:16px;height:16px;"></i> Парсинг
|
||
</button>
|
||
|
||
<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>
|
||
|
||
<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;">×</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 parseBtn = document.getElementById('parseBtn');
|
||
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>';
|
||
parseBtn.disabled = true;
|
||
} 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' : '') + '" id="info_' + i + '" onclick="showInfo(' + i + ')" title="Инфо"><i data-lucide="info" 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;
|
||
parseBtn.disabled = true;
|
||
parseBtn.innerHTML = '<span class="spinner"></span> Загрузка...';
|
||
|
||
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;
|
||
} catch(err) {
|
||
fileQueue[rowIdx].status = '<span class="status-err">✗ ' + err.message + '</span>';
|
||
}
|
||
renderTable();
|
||
}
|
||
|
||
await refreshSupps();
|
||
|
||
fileInput.value = '';
|
||
fileInput.disabled = false;
|
||
parseBtn.disabled = (contractId === null);
|
||
parseBtn.innerHTML = '<i data-lucide="play" style="width:16px;height:16px;"></i> Парсинг';
|
||
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();
|
||
}
|
||
});
|
||
}
|
||
|
||
// ── Парсинг ──────────────────────────────────────────────────
|
||
parseBtn.addEventListener('click', async function() {
|
||
if (!contractId) return;
|
||
parseBtn.disabled = true;
|
||
parseBtn.innerHTML = '<span class="spinner"></span> Парсинг...';
|
||
|
||
for (var i = 0; i < fileQueue.length; i++) {
|
||
var f = fileQueue[i];
|
||
if (!f.doc_id || f.parsed) continue;
|
||
f.status = '⏳';
|
||
renderTable();
|
||
try {
|
||
var resp = await fetch('/parser.cfm?doc_id=' + f.doc_id);
|
||
var d = await resp.json();
|
||
if (d.OK && d.STATUS === 'parsed') {
|
||
f.parsed = true;
|
||
f.parseInfo = d;
|
||
f.status = '<span class="status-ok">✓ ' + d.ELEMENT_COUNT + ' эл.</span>';
|
||
} else {
|
||
f.status = '<span class="status-err">✗ ' + (d.ERROR_COUNT > 0 ? (d.ERRORS && d.ERRORS[0] || 'ошибка') : 'ошибка') + '</span>';
|
||
f.parseInfo = d;
|
||
}
|
||
} catch(e) {
|
||
f.status = '<span class="status-err">✗ сеть</span>';
|
||
}
|
||
renderTable();
|
||
}
|
||
|
||
// Обновить информацию о supplement_id/type для radio
|
||
await refreshSupps();
|
||
|
||
parseBtn.innerHTML = '<i data-lucide="check" style="width:16px;height:16px;"></i> ✓ Готово';
|
||
var llmBtn = document.getElementById('llmBtn');
|
||
llmBtn.style.display = 'flex';
|
||
llmBtn.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 showInfo(i) {
|
||
var f = fileQueue[i];
|
||
document.getElementById('modalTitle').textContent = f.name;
|
||
var html = '<div class="kv"><span class="k">Размер</span><span class="v">' + formatSize(f.size) + '</span></div>';
|
||
html += '<div class="kv"><span class="k">Изменён</span><span class="v">' + formatDate(f.lastModified) + '</span></div>';
|
||
if (f.parseInfo) {
|
||
var d = f.parseInfo;
|
||
html += '<div class="kv"><span class="k">Элементов</span><span class="v">' + (d.ELEMENT_COUNT||0) + '</span></div>';
|
||
html += '<div class="kv"><span class="k">Параграфов</span><span class="v">' + (d.PARAGRAPHS||0) + '</span></div>';
|
||
html += '<div class="kv"><span class="k">Таблиц</span><span class="v">' + (d.TABLES||0) + '</span></div>';
|
||
html += '<div class="kv"><span class="k">Строк таблиц</span><span class="v">' + (d.TABLE_ROWS||0) + '</span></div>';
|
||
if (d.TEXT_PREVIEW) {
|
||
html += '<div style="margin-top:10px;font-weight:600;font-size:13px;">Превью текста:</div>';
|
||
html += '<pre>' + d.TEXT_PREVIEW + '</pre>';
|
||
}
|
||
if (d.TABLE_HEADERS && d.TABLE_HEADERS.length > 0) {
|
||
html += '<div style="margin-top:10px;font-weight:600;font-size:13px;">Первые строки таблиц:</div>';
|
||
d.TABLE_HEADERS.forEach(function(hdr, ti) {
|
||
html += '<div style="margin-top:4px;font-size:11px;color:var(--muted);">Таблица ' + (ti+1) + ':</div>';
|
||
html += '<pre>' + (hdr||[]).map(function(c){return c||'(пусто)';}).join(' | ') + '</pre>';
|
||
});
|
||
}
|
||
if (d.ERRORS && d.ERRORS.length > 0) {
|
||
html += '<div style="margin-top:8px;color:var(--destructive);font-weight:600;">Ошибки:</div>';
|
||
html += '<pre>' + d.ERRORS.join('\n') + '</pre>';
|
||
}
|
||
}
|
||
html += '<div class="kv"><span class="k">doc_id</span><span class="v">' + (f.doc_id || '—') + '</span></div>';
|
||
document.getElementById('modalBody').innerHTML = html;
|
||
document.getElementById('modalOverlay').classList.add('open');
|
||
}
|
||
function closeModal(e) {
|
||
if (e && e.target !== document.getElementById('modalOverlay')) return;
|
||
document.getElementById('modalOverlay').classList.remove('open');
|
||
}
|
||
window.showInfo = showInfo;
|
||
window.closeModal = closeModal;
|
||
|
||
// ── Промпт ───────────────────────────────────────────────────
|
||
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>
|