Files
contractor/index.cfm
T

554 lines
29 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: 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 <span style="font-weight:400;color:var(--muted);font-size:12px;">v1.0.54 — 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></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;">&#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 LUCEE_PROXY = 'https://contracts.kube5s.ru/lucee'; // SSE через ВМ (без буферизации)
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="6">Нет файлов — выберите .docx / .pdf / .zip</td></tr>';
parseBtn.disabled = true;
} else {
fileTable.innerHTML = fileQueue.map(function(f, i) {
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><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();
}
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();
}
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();
});
// ── 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 = '';
diffStatus.textContent = '⏳ 0.0с';
var llmStart = Date.now();
var timerInterval = setInterval(function() {
diffStatus.textContent = '⏳ ' + ((Date.now() - llmStart) / 1000).toFixed(1) + 'с';
}, 200);
// SSE — прогресс по каждому файлу
var es = new EventSource(LUCEE_PROXY + '/process.cfm?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;" id="ext-' + d.supplement_id + '">⏳ ' + d.filename + ' → LLM... <span class="file-timer" data-start="' + Date.now() + '">0.0с</span></div>';
}
else if (d.type === 'extract_done') {
var el = document.getElementById('ext-' + d.supplement_id);
if (el) el.innerHTML = '✓ <span style="color:var(--green);">' + d.filename + ': ' + d.count + ' строк (' + d.time_s + 'с)</span>';
}
else if (d.type === 'extract_skip') {
diffBody.innerHTML += '<div style="font-size:12px;color:var(--muted);margin-top:2px;">⏭ ' + d.filename + ' (уже извлечено, ' + d.count + ' строк)</div>';
}
else if (d.type === 'extract_error') {
var el = document.getElementById('ext-' + d.supplement_id);
if (el) el.innerHTML = '✗ <span style="color:var(--destructive);">' + d.filename + ': ' + d.error + '</span>';
else diffBody.innerHTML += '<div style="font-size:12px;color:var(--destructive);margin-top:2px;">✗ ' + 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> ✓ Готово';
document.getElementById('chatCard').style.display = 'block';
// Сравнение
diffBody.innerHTML += '<div style="margin-top:8px;font-weight:600;">📊 Сравнение:</div>';
var all = d.all_changes || [];
if (all.length === 0) {
diffBody.innerHTML += '<div style="color:var(--muted);font-size:12px;">Нет допников для сравнения.</div>';
} else {
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>';
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;
}
});
}
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> Сравнить (LLM)';
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> Сравнить (LLM)';
btn.disabled = false;
lucide.createIcons();
};
// Таймер для файловых строк
var fileTimerInterval = setInterval(function() {
var now = Date.now();
document.querySelectorAll('.file-timer').forEach(function(el) {
var start = parseInt(el.dataset.start);
if (start) el.textContent = ((now - start) / 1000).toFixed(1) + 'с';
});
}, 200);
// Сохранить интервалы для очистки
es._fileTimer = fileTimerInterval;
var origClose = es.close;
es.close = function() {
clearInterval(fileTimerInterval);
origClose.call(es);
};
});
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>