Add isolated teach flow on VM

This commit is contained in:
2026-06-26 12:48:57 +04:00
parent bec16def95
commit 8b04eb93bd
4 changed files with 744 additions and 0 deletions
+503
View File
@@ -0,0 +1,503 @@
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Сверка договоров — обучение</title>
<link rel="icon" type="image/png" href="{{ url_for('static', filename='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; --amber: #f59e0b;
}
* { 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: 1200px; 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; }
.split { display: grid; grid-template-columns: 320px 1fr; gap: 16px; align-items: start; }
.sidebar { max-height: 72vh; overflow-y: auto; }
.list { display: flex; flex-direction: column; gap: 8px; }
.item { border: 1px solid var(--brand-gray); border-radius: 8px; padding: 10px 12px; background: #fff; cursor: pointer; }
.item:hover { border-color: var(--brand-primary); }
.item.active { border-color: var(--brand-primary); box-shadow: inset 0 0 0 1px var(--brand-primary); }
.item .small { font-size: 12px; color: var(--muted); margin-top: 2px; }
.table-wrap { border-radius: 6px; border: 1px solid var(--brand-gray); overflow-y: auto; max-height: 50vh; }
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); vertical-align: top; }
tr:hover td { background: rgba(243,244,246,.5); }
.name-cell { max-width: 420px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.num-cell { text-align: right; white-space: nowrap; }
.diff-added { background: rgba(34,197,94,.08); }
.diff-deleted { background: rgba(239,68,68,.08); }
.diff-changed { background: rgba(245,158,11,.08); }
.feedback-btn { cursor: pointer; border: 1px solid var(--brand-gray); border-radius: 6px; background: #fff; height: 28px; padding: 0 8px; font-size: 12px; }
.feedback-btn:hover { border-color: var(--brand-primary); color: var(--brand-primary); }
.feedback-btn.good { border-color: var(--green); color: var(--green); }
.feedback-btn.warn { border-color: var(--amber); color: var(--amber); }
.feedback-panel { margin-top: 8px; padding: 10px; border: 1px solid var(--brand-gray); border-radius: 8px; background: #fff; }
.row-form { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; margin-top: 8px; }
.field { display: flex; flex-direction: column; gap: 4px; font-size: 12px; }
.field input, .field select, .field textarea { width: 100%; border: 1px solid var(--brand-gray); border-radius: 6px; padding: 8px; font: inherit; }
.field textarea { min-height: 70px; resize: vertical; }
.row-actions { display: flex; gap: 8px; margin-top: 8px; flex-wrap: wrap; }
.muted { color: var(--muted); }
.ok { color: var(--green); }
.err { color: var(--destructive); }
.empty-row td { color: var(--muted); text-align: center; padding: 24px; }
.preview { margin-top: 12px; font-size: 12px; color: var(--muted); }
.pill { display: inline-flex; align-items: center; gap: 4px; padding: 3px 8px; border-radius: 999px; background: #eef2ff; color: var(--brand-primary); font-size: 11px; }
.notice { background: #eff6ff; border: 1px solid #bfdbfe; color: #1d4ed8; padding: 10px 12px; border-radius: 8px; font-size: 13px; margin-bottom: 12px; }
.loading { color: var(--muted); font-size: 13px; }
@media (max-width: 980px) { .split { grid-template-columns: 1fr; } .sidebar { max-height: none; } }
</style>
</head>
<body>
<div class="topbar">
<img src="{{ url_for('static', filename='nubes-logo.svg') }}" alt="Nubes">
<span class="title">Сверка договоров — обучение <span style="font-weight:400;color:var(--muted);font-size:12px;">изолированная страница</span></span>
<span style="margin-left:auto;font-size:12px;color:var(--brand-primary);white-space:nowrap;">feedback only</span>
</div>
<div class="content">
<div class="notice">
Здесь не запускается рабочий compare-пайплайн. Страница только читает уже разобранные данные и пишет замечания в отдельную таблицу <b>feedback</b>.
</div>
<div class="card">
<div class="card-header">
<i data-lucide="book-open" style="width:18px;height:18px;"></i>
Выбор договора и допника
</div>
<div class="card-body split">
<div class="sidebar">
<div class="loading" id="contractsLoading">Загрузка списка договоров...</div>
<div class="list" id="contractsList"></div>
</div>
<div>
<div id="contextMeta" class="preview muted">Выберите допник слева</div>
<div class="row-actions" style="margin-top:10px;">
<button class="btn btn-primary" id="markCorrectBtn" disabled>✓ Всё верно</button>
<button class="btn" id="markMissedBtn" disabled> Пропущена позиция</button>
<span class="pill" id="saveStatus">0 замечаний</span>
</div>
<div class="table-wrap" style="margin-top:12px;">
<table>
<thead>
<tr>
<th style="width:56px;"></th>
<th>Действие</th>
<th>Услуга</th>
<th style="width:110px;">Цена</th>
<th style="width:90px;">Кол-во</th>
<th style="width:110px;">Сумма</th>
<th style="width:110px;">Дата</th>
<th style="width:60px;"></th>
</tr>
</thead>
<tbody id="rowsTableBody">
<tr class="empty-row"><td colspan="8">Выберите допник для просмотра строк</td></tr>
</tbody>
</table>
</div>
<div class="feedback-panel" id="feedbackPanel" style="display:none;">
<div style="font-weight:600;">Замечание по строке <span id="feedbackRowLabel"></span></div>
<div class="row-form">
<div class="field">
<label>Тип ошибки</label>
<select id="errorType">
<option value="wrong_price">неверная цена</option>
<option value="wrong_qty">неверное количество</option>
<option value="wrong_name">неверное наименование</option>
<option value="wrong_date">неверная дата</option>
<option value="extra_row">лишняя строка</option>
<option value="missed_row">пропущена строка</option>
<option value="wrong_action">неверное действие</option>
<option value="wrong_mode">неверный режим</option>
</select>
</div>
<div class="field">
<label>Поле</label>
<select id="errorField">
<option value="price">price</option>
<option value="qty">qty</option>
<option value="sum">sum</option>
<option value="name">name</option>
<option value="date_start">date_start</option>
<option value="action">action</option>
<option value="mode">mode</option>
</select>
</div>
</div>
<div class="row-form">
<div class="field">
<label>Правильное значение</label>
<textarea id="correctValue" placeholder='{"price":68000}'></textarea>
</div>
<div class="field">
<label>Комментарий</label>
<textarea id="comment" placeholder="Необязательно"></textarea>
</div>
</div>
<div class="row-actions">
<button class="btn btn-primary" id="saveFeedbackBtn">Сохранить</button>
<button class="btn" id="cancelFeedbackBtn">Отмена</button>
</div>
</div>
<div class="preview" id="feedbackPreview"></div>
</div>
</div>
</div>
</div>
<script>
lucide.createIcons();
const contractsList = document.getElementById('contractsList');
const contractsLoading = document.getElementById('contractsLoading');
const rowsTableBody = document.getElementById('rowsTableBody');
const contextMeta = document.getElementById('contextMeta');
const feedbackPanel = document.getElementById('feedbackPanel');
const feedbackRowLabel = document.getElementById('feedbackRowLabel');
const errorType = document.getElementById('errorType');
const errorField = document.getElementById('errorField');
const correctValue = document.getElementById('correctValue');
const comment = document.getElementById('comment');
const saveFeedbackBtn = document.getElementById('saveFeedbackBtn');
const cancelFeedbackBtn = document.getElementById('cancelFeedbackBtn');
const markCorrectBtn = document.getElementById('markCorrectBtn');
const markMissedBtn = document.getElementById('markMissedBtn');
const saveStatus = document.getElementById('saveStatus');
const feedbackPreview = document.getElementById('feedbackPreview');
const urlParams = new URLSearchParams(window.location.search);
let promptVersion = urlParams.get('prompt_version') || 'vm-teach-v1';
let modelName = urlParams.get('model') || 'gpt-oss-120b';
const appState = {
contracts: [],
currentSupplement: null,
currentRows: [],
previousRows: [],
selectedRow: null,
feedbackMode: 'row',
feedbackCount: 0,
};
function esc(text) {
return String(text == null ? '' : text)
.replaceAll('&', '&amp;')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;')
.replaceAll('"', '&quot;')
.replaceAll("'", '&#39;');
}
function fmtDate(value) {
if (!value) return '—';
const d = new Date(value);
if (Number.isNaN(d.getTime())) return String(value);
return d.toLocaleDateString('ru-RU');
}
function fmtNum(value) {
if (value == null || value === '') return '—';
return String(value);
}
function updateSaveStatus() {
saveStatus.textContent = appState.feedbackCount + ' замечаний';
}
function renderContracts() {
if (!appState.contracts.length) {
contractsList.innerHTML = '<div class="loading">Нет договоров с допниками</div>';
return;
}
contractsList.innerHTML = appState.contracts.map(function(contract, ci) {
const supplements = contract.supplements || [];
return `
<div class="item" data-contract-index="${ci}">
<div style="font-weight:600;">${esc(contract.contract_number || '—')}</div>
<div class="small">${esc(contract.client || '—')} ${contract.date_signed ? '· ' + esc(fmtDate(contract.date_signed)) : ''}</div>
<div style="margin-top:8px; display:flex; flex-direction:column; gap:6px;">
${supplements.map(function(supp, si) {
return `
<div class="item" style="margin-left:0;" data-supplement-id="${esc(supp.supplement_id)}" data-contract-index="${ci}" data-supp-index="${si}">
<div style="font-weight:600;">${esc(supp.supplement_number || 'допник')}</div>
<div class="small">${esc(supp.supplement_type || '—')} · ${supp.supplement_date ? esc(fmtDate(supp.supplement_date)) : '—'}</div>
<div class="small">${esc(supp.document_filename || '—')}</div>
</div>`;
}).join('')}
</div>
</div>`;
}).join('');
contractsList.querySelectorAll('[data-supplement-id]').forEach(function(el) {
el.addEventListener('click', function() {
const supplementId = el.getAttribute('data-supplement-id');
loadContext(supplementId);
contractsList.querySelectorAll('[data-supplement-id]').forEach(function(node) { node.classList.remove('active'); });
el.classList.add('active');
});
});
}
function renderRows() {
if (!appState.currentRows.length) {
rowsTableBody.innerHTML = '<tr class="empty-row"><td colspan="8">У допника нет строк спецификации</td></tr>';
return;
}
rowsTableBody.innerHTML = appState.currentRows.map(function(row) {
const cls = row.change_type === 'added' ? 'diff-added' : row.change_type === 'deleted' ? 'diff-deleted' : row.change_type === 'changed' ? 'diff-changed' : '';
return `
<tr class="${cls}" data-row-num="${esc(row.row_num)}">
<td class="num-cell">${esc(row.row_num)}</td>
<td>${esc(row.change_type || 'row')}</td>
<td class="name-cell">${esc(row.name)}</td>
<td class="num-cell">${esc(fmtNum(row.price))}</td>
<td class="num-cell">${esc(fmtNum(row.qty))}</td>
<td class="num-cell">${esc(fmtNum(row.sum))}</td>
<td class="num-cell">${esc(fmtDate(row.date_start))}</td>
<td><button class="feedback-btn" data-row-num="${esc(row.row_num)}">⚠</button></td>
</tr>`;
}).join('');
rowsTableBody.querySelectorAll('[data-row-num]').forEach(function(btn) {
btn.addEventListener('click', function() {
const rowNum = Number(btn.getAttribute('data-row-num'));
openFeedback(rowNum);
});
});
}
function openFeedback(rowNum) {
const row = appState.currentRows.find(function(r) { return Number(r.row_num) === Number(rowNum); });
if (!row) return;
appState.selectedRow = row;
appState.feedbackMode = 'row';
feedbackPanel.style.display = 'block';
feedbackRowLabel.textContent = '#' + row.row_num + ' · ' + (row.name || 'строка');
errorType.value = 'wrong_price';
errorField.value = 'price';
correctValue.value = row.price != null ? JSON.stringify({ price: row.price }, null, 2) : '';
comment.value = '';
feedbackPreview.textContent = 'LLM: ' + JSON.stringify({ action: row.change_type, row_num: row.row_num, name: row.name, price: row.price, qty: row.qty, sum: row.sum, date_start: row.date_start }, null, 2);
}
function closeFeedback() {
feedbackPanel.style.display = 'none';
appState.selectedRow = null;
appState.feedbackMode = 'row';
}
async function loadContracts() {
contractsLoading.textContent = 'Загрузка списка договоров...';
const resp = await fetch('/teach/api/contracts');
const data = await resp.json();
if (!data.ok) {
contractsLoading.textContent = 'Ошибка: ' + (data.error || 'неизвестно');
return;
}
appState.contracts = data.contracts || [];
contractsLoading.style.display = 'none';
renderContracts();
}
async function loadContext(supplementId) {
const resp = await fetch('/teach/api/context?supplement_id=' + encodeURIComponent(supplementId));
const data = await resp.json();
if (!data.ok) {
contextMeta.innerHTML = '<span class="err">Ошибка: ' + esc(data.error || 'неизвестно') + '</span>';
return;
}
appState.currentSupplement = data.supplement;
appState.currentRows = (data.current_rows || []).map(function(row) {
return {
row_num: row.row_num,
name: row.name,
price: row.price,
qty: row.qty,
sum: row.sum,
date_start: row.date_start,
date_end: row.date_end,
change_type: inferChangeType(row, data.previous_rows || []),
};
});
appState.previousRows = data.previous_rows || [];
contextMeta.innerHTML = '<span class="pill">' + esc(data.supplement.contract_number || '—') + '</span> ' +
'<span class="pill">' + esc(data.supplement.number || 'допник') + '</span> ' +
'<span class="muted">' + esc(data.supplement.document_filename || '—') + '</span>';
markCorrectBtn.disabled = false;
markMissedBtn.disabled = false;
renderRows();
closeFeedback();
}
function inferChangeType(row, previousRows) {
const prev = previousRows.find(function(r) { return Number(r.row_num) === Number(row.row_num); });
if (!prev) return 'added';
const keys = ['name', 'price', 'qty', 'sum', 'date_start'];
const same = keys.every(function(key) { return String(prev[key] ?? '') === String(row[key] ?? ''); });
return same ? 'unchanged' : 'changed';
}
async function saveFeedback(payload) {
const resp = await fetch('/teach/api/feedback', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
});
const data = await resp.json();
if (!data.ok) throw new Error(data.error || 'save failed');
appState.feedbackCount += 1;
updateSaveStatus();
return data;
}
cancelFeedbackBtn.addEventListener('click', closeFeedback);
markCorrectBtn.addEventListener('click', async function() {
if (!appState.currentSupplement) return;
try {
await saveFeedback({
contract_id: appState.currentSupplement.contract_id,
supplement_id: appState.currentSupplement.supplement_id,
event_seq: null,
scope: 'document',
verdict: 'correct',
error_type: null,
field: null,
service_name: null,
llm_value: null,
correct_value: null,
prompt_version: promptVersion,
model_name: modelName,
doc_mode: appState.currentSupplement.supplement_type || null,
comment: null,
});
feedbackPreview.innerHTML = '<span class="ok">Документ помечен как верный</span>';
} catch (err) {
feedbackPreview.innerHTML = '<span class="err">' + esc(err.message) + '</span>';
}
});
markMissedBtn.addEventListener('click', function() {
if (!appState.currentSupplement) return;
appState.feedbackMode = 'missed';
appState.selectedRow = null;
feedbackPanel.style.display = 'block';
feedbackRowLabel.textContent = 'пропущенная позиция';
errorType.value = 'missed_row';
errorField.value = 'name';
correctValue.value = '';
feedbackPreview.textContent = 'Добавьте пропущенную строку в correct value (JSON)';
});
saveFeedbackBtn.addEventListener('click', async function() {
if (!appState.currentSupplement) return;
if (!appState.selectedRow && appState.feedbackMode !== 'missed') return;
if (appState.feedbackMode === 'missed') {
try {
await saveFeedback({
contract_id: appState.currentSupplement.contract_id,
supplement_id: appState.currentSupplement.supplement_id,
event_seq: null,
scope: 'missed',
verdict: 'error',
error_type: 'missed_row',
field: errorField.value,
service_name: null,
llm_value: null,
correct_value: parseJsonOrText(correctValue.value),
prompt_version: promptVersion,
model_name: modelName,
doc_mode: appState.currentSupplement.supplement_type || null,
comment: comment.value || null,
});
feedbackPreview.innerHTML = '<span class="ok">Сохранено</span>';
closeFeedback();
} catch (err) {
feedbackPreview.innerHTML = '<span class="err">' + esc(err.message) + '</span>';
}
return;
}
try {
await saveFeedback({
contract_id: appState.currentSupplement.contract_id,
supplement_id: appState.currentSupplement.supplement_id,
event_seq: appState.selectedRow.row_num,
scope: 'row',
verdict: 'error',
error_type: errorType.value,
field: errorField.value,
service_name: appState.selectedRow.name,
llm_value: {
row_num: appState.selectedRow.row_num,
action: appState.selectedRow.change_type,
name: appState.selectedRow.name,
price: appState.selectedRow.price,
qty: appState.selectedRow.qty,
sum: appState.selectedRow.sum,
date_start: appState.selectedRow.date_start,
},
correct_value: parseJsonOrText(correctValue.value),
prompt_version: promptVersion,
model_name: modelName,
doc_mode: appState.currentSupplement.supplement_type || null,
comment: comment.value || null,
});
feedbackPreview.innerHTML = '<span class="ok">Сохранено</span>';
closeFeedback();
} catch (err) {
feedbackPreview.innerHTML = '<span class="err">' + esc(err.message) + '</span>';
}
});
function parseJsonOrText(value) {
const trimmed = String(value || '').trim();
if (!trimmed) return null;
try {
return JSON.parse(trimmed);
} catch (err) {
return { text: trimmed };
}
}
async function loadMeta() {
try {
const resp = await fetch('/teach/api/meta');
const data = await resp.json();
if (data && data.ok) {
if (!urlParams.get('prompt_version') && data.prompt_version) {
promptVersion = data.prompt_version;
}
if (!urlParams.get('model') && data.model_name) {
modelName = data.model_name;
}
}
} catch (err) {
// Keep defaults if metadata endpoint is unavailable.
}
}
loadMeta()
.then(function() { return loadContracts(); })
.catch(function(err) {
contractsLoading.textContent = 'Ошибка: ' + err.message;
});
</script>
</body>
</html>