v1.0.110: prompts versioning — DB schema + API + UI (extract/diff tabs, history, activate)

This commit is contained in:
2026-06-23 01:06:12 +04:00
parent ded5071dae
commit 69cfb3457b
2 changed files with 382 additions and 57 deletions
+150 -31
View File
@@ -75,7 +75,7 @@
<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.109 — Lucee</span></span>
<span class="title">Сверка договоров — LLM AI-driven Event Sourcing <span style="font-weight:400;color:var(--muted);font-size:12px;">v1.0.110 — Lucee</span></span>
</div>
<div class="content">
@@ -101,12 +101,10 @@
</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>
<summary style="cursor:pointer;color:var(--muted);">⚙ Промпты LLM</summary>
<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>
</div>
</details>
</div>
@@ -146,6 +144,32 @@
</div>
</div>
<!-- ── Модалка промптов ── -->
<div class="modal-overlay" id="promptModalOverlay" onclick="closePromptModal(event)">
<div class="modal" style="max-width:680px;" onclick="event.stopPropagation()">
<div class="modal-header">
<i data-lucide="settings" style="width:18px;height:18px;"></i>
<span id="promptModalTitle">Промпт: Сравнение</span>
<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;">
<button class="btn" id="promptTabExtract" style="font-size:11px;height:26px;">📄 Извлечение</button>
<button class="btn" id="promptTabDiff" style="font-size:11px;height:26px;background:var(--brand-primary);color:#fff;">🔄 Сравнение</button>
</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;">
<input id="promptName" placeholder="Имя версии" style="width:140px;font-size:11px;border:1px solid var(--brand-gray);border-radius:4px;padding:4px 6px;">
<input id="promptNotes" placeholder="Что изменили" style="flex:1;font-size:11px;border:1px solid var(--brand-gray);border-radius:4px;padding:4px 6px;">
<button class="btn" id="promptSave" style="font-size:11px;height:28px;background:var(--green);color:#fff;border-color:var(--green);">Сохранить</button>
</div>
<span id="promptStatus" style="font-size:11px;color:var(--muted);display:block;margin-top:4px;"></span>
<div style="margin-top:12px;font-size:11px;color:var(--muted);font-weight:600;">История версий</div>
<div id="promptHistory" style="max-height:180px;overflow-y:auto;margin-top:4px;"></div>
</div>
</div>
</div>
<script>
lucide.createIcons();
@@ -591,34 +615,129 @@ 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;
// ── Промпты (версионные) ─────────────────────────────────────
var promptRole = 'diff'; // текущая выбранная роль
var promptEditor = document.getElementById('promptEditor');
var promptStatus = document.getElementById('promptStatus');
var promptHistory = document.getElementById('promptHistory');
var promptModalOverlay = document.getElementById('promptModalOverlay');
var promptModalTitle = document.getElementById('promptModalTitle');
var promptName = document.getElementById('promptName');
var promptNotes = document.getElementById('promptNotes');
var activePromptId = {extract: '', diff: ''};
function openPromptModal(role) {
promptRole = role;
promptModalTitle.textContent = 'Промпт: ' + (role === 'extract' ? 'Извлечение' : 'Сравнение');
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' : '';
promptStatus.textContent = '';
promptName.value = '';
promptNotes.value = '';
loadPrompt(role);
promptModalOverlay.classList.add('open');
}
function closePromptModal(e) {
if (e && e.target !== promptModalOverlay) return;
promptModalOverlay.classList.remove('open');
}
function loadPrompt(role) {
fetch('/prompt.cfm?action=get_active&role=' + role)
.then(function(r) { return r.json(); })
.then(function(d) {
if (d.ok) {
promptEditor.value = d.body || '';
activePromptId[role] = d.id || '';
loadHistory(role);
}
})
.catch(function(e) { console.error('loadPrompt:', e); });
}
function loadHistory(role) {
fetch('/prompt.cfm?action=list&role=' + role)
.then(function(r) { return r.json(); })
.then(function(d) {
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;' : '';
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>' : '')
+ '</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', {
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 || 'ошибка'); }
});
}
function deletePrompt(id) {
if (!confirm('Удалить эту версию промпта?')) return;
fetch('/prompt.cfm?action=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 || 'ошибка'); }
});
}
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})
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', {
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) {
promptStatus.textContent = '✓ сохранено (новая версия)';
promptName.value = ''; promptNotes.value = '';
loadPrompt(promptRole);
} else {
promptStatus.textContent = '✕ ' + (d.error || 'ошибка');
}
});
}
});
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: ''})
});
}
document.getElementById('promptTabExtract').addEventListener('click', function() { openPromptModal('extract'); });
document.getElementById('promptTabDiff').addEventListener('click', function() { openPromptModal('diff'); });
document.getElementById('promptBtnExtract').addEventListener('click', function() { openPromptModal('extract'); });
document.getElementById('promptBtnDiff').addEventListener('click', function() { openPromptModal('diff'); });
});
</script>
</body>