Files
2026-06-26 12:48:57 +04:00

504 lines
23 KiB
HTML
Raw Permalink 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.
<!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>