Files
RAG_helper/static/index.html
T
AR 15 M4 a8f7e68795 feat(sprint8a): регрессия роутера в UI с выбором кейсов и кэшем
Оператор-настройщик после правки промпта _router нажимает «Прогнать выбранное»
на странице «Регрессия» и видит, что сломалось. Не CLI, не в обход
интерфейса — встроено в верхнюю навигацию рядом с Настройками.

Backend:
- Таблицы eval_runs / eval_run_cases (с is_pass) / eval_router_predictions
  (кэш text_hash + router_config_id → predicted_intent). Миграции
  k7e9d5c67h34 и l8f0e6d78i45.
- services/eval_run_service.py: start_router_run(text_hashes) запускает
  фоновую корутину через asyncio.create_task, фиксирует активную версию
  _router. Кэш привязан к версии: повторный прогон на той же версии —
  мгновенный, на новой — пересчитывается. compute_diff_vs_previous
  сравнивает с предыдущим прогоном на той же версии (новые fail / pass).
- API: POST /eval/runs (фон, body text_hashes), GET /eval/runs,
  GET /eval/runs/{id}, GET /eval/router-cases-with-status (все 1573 кейса
  + кэш на активной версии).

Frontend (static/regression.html — новая страница, ссылка добавлена в
шапки index/sandbox/settings/docs):
- Сворачиваемый блок «Выбор кейсов»: фильтр по intent, ввод диапазона
  (1-50, 200-300), кнопки «Все видимые», «Снять все», «Только без кэша»,
  «Только FAIL в кэше», «Снять кэшированные». Чекбокс в шапке.
- Таблица 1573 кейсов отсортирована по count desc: #, чекбокс, запрос,
  intent, частота, кэш (PASS / FAIL → predicted / —). Цветной фон строки
  по статусу кэша.
- Счётчик «выбрано N (новых: X, в кэше: Y)»; кнопка
  «Прогнать выбранное (X новых + Y из кэша)» — сразу видно реальный
  объём LLM-работы.
- Polling /eval/runs/{id} раз в 2 секунды, прогресс-бар, drill-down:
  все кейсы прогона + фильтр pass/fail + поиск + diff vs предыдущий
  (новые fail / новые pass).

docs/SPRINTS.md: Спринт 8 разбит на 8a ( закрыт), 8b (регрессия ответов
веток, ждёт базу кейсов от пользователя), 8c (handoff/resumable/loop/
guard/rag — позже).

docs/BACKLOG.md: новый файл для идей на потом. Записаны: просмотр
архивного графа без активации (из 7.7), варианты C (LLM-judge) и D
(эталон + embeddings) для регрессии веток в 8b.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 20:39:22 +05:00

897 lines
29 KiB
HTML

<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Chat Agent for Patients — Debug UI</title>
<style>
:root {
--bg: #f5f6f8;
--panel: #ffffff;
--border: #e1e4ea;
--muted: #6b7280;
--fg: #111827;
--accent: #2563eb;
--accent-hover: #1d4ed8;
--ok: #16a34a;
--warn: #d97706;
--err: #dc2626;
--chip-bg: #eef2ff;
--mono: ui-monospace, SFMono-Regular, Menlo, monospace;
}
* { box-sizing: border-box; }
body {
margin: 0;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
background: var(--bg);
color: var(--fg);
font-size: 14px;
line-height: 1.5;
}
header {
background: var(--panel);
border-bottom: 1px solid var(--border);
padding: 14px 24px;
display: flex;
align-items: center;
gap: 16px;
position: sticky;
top: 0;
z-index: 10;
}
header h1 {
margin: 0;
font-size: 16px;
font-weight: 600;
}
.status {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 4px 10px;
border-radius: 999px;
background: var(--chip-bg);
font-size: 13px;
}
.dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--muted);
}
.dot.ok { background: var(--ok); }
.dot.warn { background: var(--warn); }
.dot.err { background: var(--err); }
.nav {
display: flex;
gap: 4px;
}
.nav-link {
text-decoration: none;
color: var(--muted);
padding: 6px 12px;
border-radius: 6px;
font-size: 13px;
font-weight: 500;
}
.nav-link:hover { background: var(--chip-bg); color: var(--fg); }
.nav-link.active { background: var(--accent); color: #fff; }
.stats {
margin-left: auto;
font-size: 13px;
color: var(--muted);
}
.stats b { color: var(--fg); }
main {
padding: 24px;
max-width: 1400px;
margin: 0 auto;
display: grid;
gap: 24px;
}
.panel {
background: var(--panel);
border: 1px solid var(--border);
border-radius: 12px;
padding: 20px;
}
.panel h2 {
margin: 0 0 16px 0;
font-size: 15px;
font-weight: 600;
}
.dropzone {
border: 2px dashed var(--border);
border-radius: 10px;
padding: 28px;
text-align: center;
color: var(--muted);
cursor: pointer;
transition: all 0.15s;
}
.dropzone.drag { border-color: var(--accent); background: var(--chip-bg); color: var(--fg); }
.dropzone input { display: none; }
table {
width: 100%;
border-collapse: collapse;
margin-top: 16px;
font-size: 13px;
}
th, td {
text-align: left;
padding: 8px 10px;
border-bottom: 1px solid var(--border);
vertical-align: top;
}
th {
font-weight: 600;
color: var(--fg);
font-size: 13px;
letter-spacing: -0.01em;
}
tr:last-child td { border-bottom: none; }
tr.doc-row { cursor: pointer; }
tr.doc-row:hover td:not(:last-child) { background: #f9fafb; }
tr.doc-row .arrow { display: inline-block; transition: transform 0.15s; color: var(--muted); margin-right: 6px; }
tr.doc-row.open .arrow { transform: rotate(90deg); }
tr.chunks-row td { padding: 0; background: #fafbfd; }
tr.chunks-row .chunks-body { padding: 14px 16px; }
tr.intents-row td { padding: 0; background: #fef3c7; }
tr.intents-row .intents-body { padding: 12px 16px; }
tr.editor-row td { padding: 0; background: #eff6ff; }
tr.editor-row .editor-body { padding: 12px 16px; }
.editor-body .eb-hint {
font-size: 11.5px;
color: var(--muted);
margin-bottom: 6px;
}
.editor-body textarea.eb-textarea {
width: 100%;
min-height: 360px;
padding: 10px 12px;
border: 1px solid var(--border);
border-radius: 6px;
font-family: var(--mono);
font-size: 12px;
line-height: 1.5;
resize: vertical;
background: white;
}
.editor-body .eb-actions { margin-top: 10px; display: flex; gap: 8px; align-items: center; }
.editor-body .eb-actions button {
padding: 5px 10px;
font-size: 12px;
border: 1px solid var(--border);
background: white;
border-radius: 5px;
cursor: pointer;
}
.editor-body .eb-actions button.primary {
background: var(--accent);
color: white;
border-color: var(--accent);
}
.editor-body .eb-actions .eb-status { color: var(--muted); font-size: 11.5px; }
.intents-body .ib-head {
font-size: 12px;
color: var(--muted);
margin-bottom: 8px;
display: flex;
align-items: baseline;
justify-content: space-between;
}
.intents-body .ib-counter b { color: var(--fg); font-weight: 600; }
.intents-body .ib-list {
display: flex;
flex-wrap: wrap;
gap: 8px 14px;
background: white;
border: 1px solid var(--border);
border-radius: 6px;
padding: 10px 12px;
}
.intents-body .ib-item {
display: inline-flex;
align-items: center;
gap: 5px;
font-size: 12.5px;
cursor: pointer;
}
.intents-body .ib-item code { font-family: var(--mono); font-size: 11.5px; color: var(--accent); }
.intents-body .ib-actions { margin-top: 10px; display: flex; gap: 8px; }
.intents-body .ib-actions button {
padding: 5px 10px;
font-size: 12px;
border: 1px solid var(--border);
background: white;
border-radius: 5px;
cursor: pointer;
}
.intents-body .ib-actions button.primary {
background: var(--accent);
color: white;
border-color: var(--accent);
}
.chunk-card {
background: white;
border: 1px solid var(--border);
border-radius: 8px;
padding: 10px 12px;
margin-bottom: 8px;
}
.chunk-card-meta {
font-size: 11px;
color: var(--muted);
margin-bottom: 6px;
display: flex;
gap: 10px;
flex-wrap: wrap;
}
.chunk-card-text {
white-space: pre-wrap;
font-size: 13px;
word-break: break-word;
}
.chunk-card-toggle {
color: var(--accent);
cursor: pointer;
font-size: 12px;
border: none;
background: none;
padding: 4px 0 0 0;
}
.chunk-card-actions {
display: flex;
gap: 14px;
align-items: center;
margin-top: 4px;
}
.embedding-box {
margin-top: 8px;
padding: 8px 10px;
background: #f0f4ff;
border: 1px solid #c7d2fe;
border-radius: 6px;
display: none;
}
.embedding-box.open { display: block; }
.emb-header {
font-size: 11px;
color: var(--muted);
margin-bottom: 4px;
}
.emb-values {
font-family: 'SF Mono', 'Menlo', 'Consolas', monospace;
font-size: 10.5px;
line-height: 1.4;
max-height: 140px;
overflow-y: auto;
word-break: break-all;
color: #3730a3;
}
.empty {
padding: 20px;
color: var(--muted);
text-align: center;
font-style: italic;
}
button {
font: inherit;
padding: 8px 14px;
border-radius: 8px;
border: 1px solid var(--border);
background: var(--panel);
cursor: pointer;
}
button.primary {
background: var(--accent);
border-color: var(--accent);
color: white;
}
button.primary:hover:not(:disabled) { background: var(--accent-hover); }
button.danger {
color: var(--err);
border-color: var(--err);
background: transparent;
font-size: 12px;
padding: 4px 10px;
}
button:disabled { opacity: 0.5; cursor: not-allowed; }
.row { display: flex; gap: 12px; align-items: center; flex-wrap: wrap; }
.row label { color: var(--muted); font-size: 13px; }
textarea, input[type=number], input[type=text] {
font: inherit;
padding: 8px 10px;
border: 1px solid var(--border);
border-radius: 8px;
width: 100%;
background: white;
}
textarea { min-height: 90px; resize: vertical; }
.num { width: 80px; }
.columns {
display: grid;
grid-template-columns: 1fr 1fr 1fr;
gap: 16px;
margin-top: 16px;
}
.col {
border: 1px solid var(--border);
border-radius: 10px;
padding: 14px;
background: #fafbfd;
min-height: 200px;
overflow: hidden;
}
.col h3 {
margin: 0 0 10px 0;
font-size: 13px;
font-weight: 600;
color: var(--fg);
letter-spacing: -0.01em;
}
.chunk {
border: 1px solid var(--border);
border-radius: 8px;
padding: 10px;
margin-bottom: 8px;
background: white;
}
.chunk-head {
display: flex;
justify-content: space-between;
font-size: 11px;
color: var(--muted);
margin-bottom: 6px;
}
.score {
background: var(--chip-bg);
padding: 1px 6px;
border-radius: 999px;
color: var(--accent);
font-weight: 600;
}
pre {
font-family: var(--mono);
font-size: 12px;
white-space: pre-wrap;
word-break: break-word;
margin: 0;
background: white;
padding: 10px;
border: 1px solid var(--border);
border-radius: 8px;
max-height: 500px;
overflow: auto;
}
.answer {
background: white;
border: 1px solid var(--border);
border-radius: 8px;
padding: 14px;
white-space: pre-wrap;
word-break: break-word;
line-height: 1.6;
}
.answer-meta {
margin-top: 8px;
font-size: 11px;
color: var(--muted);
}
.toast {
position: fixed;
bottom: 20px;
right: 20px;
background: #111827;
color: white;
padding: 10px 16px;
border-radius: 8px;
font-size: 13px;
opacity: 0;
transition: opacity 0.2s;
pointer-events: none;
}
.toast.show { opacity: 1; }
.toast.err { background: var(--err); }
.mini { font-size: 12px; color: var(--muted); }
.spinner {
width: 14px; height: 14px;
border: 2px solid var(--border);
border-top-color: var(--accent);
border-radius: 50%;
display: inline-block;
animation: spin 0.8s linear infinite;
vertical-align: middle;
}
@keyframes spin { to { transform: rotate(360deg); } }
@media (max-width: 900px) {
.columns { grid-template-columns: 1fr; }
}
</style>
</head>
<body>
<header>
<h1>Chat Agent for Patients</h1>
<nav class="nav">
<a href="/" class="nav-link active">Отладка</a>
<a href="/sandbox.html" class="nav-link">Песочница</a>
<a href="/settings.html" class="nav-link">Настройки</a>
<a href="/regression.html" class="nav-link">Регрессия</a>
<a href="/docs.html" class="nav-link">Документация</a>
</nav>
<span class="status"><span class="dot" id="dot"></span><span id="status-text">проверяю…</span></span>
<span class="stats" id="stats"></span>
</header>
<main>
<section class="panel">
<h2>База знаний</h2>
<div id="dropzone" class="dropzone">
Перетащи файл (.pdf, .docx, .txt, .md) или кликни для выбора
<input type="file" id="file-input" accept=".pdf,.docx,.doc,.txt,.md">
</div>
<div id="upload-status" class="mini" style="margin-top:10px;"></div>
<table>
<thead>
<tr>
<th>Имя</th>
<th>Тип</th>
<th>Чанков</th>
<th>Загружен</th>
<th></th>
</tr>
</thead>
<tbody id="docs-tbody">
<tr><td colspan="5" class="empty">Документы ещё не загружены</td></tr>
</tbody>
</table>
</section>
<section class="panel">
<h2>Тест-вопрос от пациента</h2>
<div id="debug-info-bar" style="margin-bottom:10px;padding:8px 12px;background:#eef2ff;border:1px solid #c7d2fe;border-radius:6px;font-size:12px;color:#3730a3;display:flex;align-items:center;gap:10px;flex-wrap:wrap;">
<span>— загружаю настройки страницы отладки —</span>
</div>
<textarea id="question" placeholder="Например: как записать ребёнка к лору?"></textarea>
<div class="row" style="margin-top:12px;">
<label>top_k <input type="number" class="num" id="top_k" value="5" min="1" max="20"></label>
<label>temperature <input type="number" class="num" id="temperature" value="0.2" min="0" max="2" step="0.1"></label>
<button class="primary" id="ask-btn">Отправить</button>
<span id="ask-status" class="mini"></span>
</div>
<div class="columns">
<div class="col">
<h3>Что нашёл RAG</h3>
<div id="col-chunks"><div class="mini">— пока пусто —</div></div>
</div>
<div class="col">
<h3>Собранный промпт</h3>
<div id="col-prompt"><div class="mini">— пока пусто —</div></div>
</div>
<div class="col">
<h3>Ответ агента</h3>
<div id="col-answer"><div class="mini">— пока пусто —</div></div>
</div>
</div>
</section>
</main>
<div class="toast" id="toast"></div>
<script>
const API = "";
const $ = (id) => document.getElementById(id);
const esc = (s) => String(s ?? "").replace(/[&<>"']/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));
function toast(msg, kind = "ok") {
const t = $("toast");
t.textContent = msg;
t.className = "toast show" + (kind === "err" ? " err" : "");
setTimeout(() => t.className = "toast", 2500);
}
async function api(path, opts = {}) {
const res = await fetch(API + path, opts);
if (!res.ok) {
let detail = res.statusText;
try { const j = await res.json(); detail = j.detail || detail; } catch {}
throw new Error(detail);
}
return res.json();
}
async function refreshHealth() {
try {
const h = await api("/health");
$("dot").className = "dot " + (h.status === "ok" ? "ok" : "warn");
$("status-text").textContent = h.status === "ok" ? "готов" : h.status;
$("stats").innerHTML = `модель <b>${esc(h.embedding_model)}</b> · документов <b>${h.documents_count}</b> · чанков <b>${h.chunks_count}</b>`;
} catch (e) {
$("dot").className = "dot err";
$("status-text").textContent = "недоступен";
$("stats").textContent = "";
}
}
async function refreshDocs() {
try {
const r = await api("/documents");
const tbody = $("docs-tbody");
if (!r.documents.length) {
tbody.innerHTML = '<tr><td colspan="5" class="empty">Документы ещё не загружены</td></tr>';
return;
}
tbody.innerHTML = r.documents.map(d => `
<tr class="doc-row" id="doc-${d.document_id}" onclick="toggleChunks('${d.document_id}')">
<td><span class="arrow">▶</span>${esc(d.name)}</td>
<td>${esc(d.file_type)}</td>
<td>${d.chunks_count}</td>
<td class="mini">${esc((d.created_at || "").slice(0, 19).replace("T", " "))}</td>
<td>
<button onclick="event.stopPropagation(); toggleEditor('${d.document_id}')">редактировать</button>
<button onclick="event.stopPropagation(); toggleIntents('${d.document_id}')">привязка</button>
<button class="danger" onclick="event.stopPropagation(); deleteDoc('${d.document_id}', '${esc(d.name)}')">удалить</button>
</td>
</tr>
<tr class="chunks-row" id="chunks-${d.document_id}" style="display:none;">
<td colspan="5"><div class="chunks-body"><div class="mini">загружаю…</div></div></td>
</tr>
<tr class="intents-row" id="intents-${d.document_id}" style="display:none;">
<td colspan="5"><div class="intents-body"><div class="mini">— загружаю —</div></div></td>
</tr>
<tr class="editor-row" id="editor-${d.document_id}" style="display:none;">
<td colspan="5"><div class="editor-body"><div class="mini">— загружаю —</div></div></td>
</tr>
`).join("");
} catch (e) {
toast("Не удалось загрузить список: " + e.message, "err");
}
}
async function toggleChunks(id) {
const row = $("doc-" + id);
const chunksRow = $("chunks-" + id);
const isOpen = chunksRow.style.display !== "none";
if (isOpen) {
chunksRow.style.display = "none";
row.classList.remove("open");
return;
}
chunksRow.style.display = "";
row.classList.add("open");
const body = chunksRow.querySelector(".chunks-body");
body.innerHTML = '<div class="mini">загружаю…</div>';
try {
const d = await api(`/documents/${id}/chunks`);
if (!d.chunks.length) {
body.innerHTML = '<div class="mini">чанков нет</div>';
return;
}
body.innerHTML = d.chunks.map((c, i) => {
const long = c.text.length > 300;
const short = long ? c.text.slice(0, 300) + "…" : c.text;
const safeFull = esc(c.text).replace(/"/g, "&quot;");
const embId = `emb-${id}-${c.index}-${i}`;
const dim = c.embedding_dim || (c.embedding ? c.embedding.length : 0);
const embValues = (c.embedding && c.embedding.length)
? "[" + c.embedding.map(v => v.toFixed(6)).join(", ") + "]"
: "—";
return `
<div class="chunk-card">
<div class="chunk-card-meta">
<span>#${c.index}</span>
<span>раздел: ${esc(c.section || "—")}</span>
${c.page_number ? `<span>стр. ${c.page_number}</span>` : ""}
<span>${c.char_length.toLocaleString("ru")} симв.</span>
</div>
<div class="chunk-card-text" data-short="${esc(short).replace(/"/g, "&quot;")}" data-full="${safeFull}" data-expanded="0">${esc(short)}</div>
<div class="chunk-card-actions">
${long ? '<button class="chunk-card-toggle" onclick="toggleChunkText(this)">показать полностью</button>' : ""}
${dim ? `<button class="chunk-card-toggle" onclick="toggleEmb('${embId}')">вектор (${dim} dim)</button>` : ""}
</div>
${dim ? `
<div class="embedding-box" id="${embId}">
<div class="emb-header">эмбеддинг · ${dim} компонент · cosine space</div>
<div class="emb-values">${embValues}</div>
</div>
` : ""}
</div>
`;
}).join("");
} catch (e) {
body.innerHTML = `<div class="mini" style="color:var(--err)">Ошибка: ${esc(e.message)}</div>`;
}
}
function toggleChunkText(btn) {
const card = btn.closest(".chunk-card");
const textEl = card.querySelector(".chunk-card-text");
const expanded = textEl.getAttribute("data-expanded") === "1";
textEl.textContent = expanded ? textEl.getAttribute("data-short") : textEl.getAttribute("data-full");
textEl.setAttribute("data-expanded", expanded ? "0" : "1");
btn.textContent = expanded ? "показать полностью" : "свернуть";
}
function toggleEmb(embId) {
const box = document.getElementById(embId);
if (box) box.classList.toggle("open");
}
async function deleteDoc(id, name) {
if (!confirm(`Удалить документ «${name}»?`)) return;
try {
await api(`/documents/${id}`, { method: "DELETE" });
toast("Удалён");
refreshDocs(); refreshHealth();
} catch (e) {
toast("Ошибка: " + e.message, "err");
}
}
/* ---------- intents subscription (Спринт 7, часть A) ---------- */
async function toggleIntents(docId) {
const row = $("intents-" + docId);
const isOpen = row.style.display !== "none";
if (isOpen) {
row.style.display = "none";
return;
}
row.style.display = "";
const body = row.querySelector(".intents-body");
body.innerHTML = '<div class="mini">— загружаю —</div>';
try {
const [intentsResp, docResp] = await Promise.all([
api(`/intents`),
api(`/documents/${docId}/intents`),
]);
const allIntents = (intentsResp.intents || [])
.filter(i => !i.code.startsWith("_"))
.sort((a, b) => a.name.localeCompare(b.name, "ru"));
const subscribed = new Set(docResp.intent_codes || []);
const items = allIntents.map(i => `
<label class="ib-item">
<input type="checkbox" data-intent-code="${esc(i.code)}" ${subscribed.has(i.code) ? "checked" : ""} onchange="updateIntentsCounter('${docId}')">
${esc(i.name)} <code>${esc(i.code)}</code>
</label>
`).join("");
body.innerHTML = `
<div class="ib-head">
<span>К каким веткам подключён этот документ для RAG?</span>
<span class="ib-counter" id="intents-counter-${docId}">подключён к <b>${subscribed.size}</b> из <b>${allIntents.length}</b></span>
</div>
<div class="ib-list" id="intents-list-${docId}">${items}</div>
<div class="ib-actions">
<button class="primary" onclick="saveDocIntents('${docId}')">Сохранить</button>
<button onclick="toggleIntents('${docId}')">Отмена</button>
</div>
`;
} catch (e) {
body.innerHTML = `<div class="mini" style="color:var(--err)">Ошибка: ${esc(e.message)}</div>`;
}
}
function updateIntentsCounter(docId) {
const list = $("intents-list-" + docId);
const counter = $("intents-counter-" + docId);
if (!list || !counter) return;
const all = list.querySelectorAll('input[type="checkbox"][data-intent-code]');
const checked = list.querySelectorAll('input[type="checkbox"][data-intent-code]:checked');
counter.innerHTML = `подключён к <b>${checked.length}</b> из <b>${all.length}</b>`;
}
async function saveDocIntents(docId) {
const list = $("intents-list-" + docId);
if (!list) return;
const intent_codes = Array.from(
list.querySelectorAll('input[type="checkbox"][data-intent-code]:checked')
).map(cb => cb.dataset.intentCode);
try {
const r = await api(`/documents/${docId}/intents`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ intent_codes }),
});
toast(`Привязка сохранена: ${r.intent_codes.length} ветка(и)`);
updateIntentsCounter(docId);
} catch (e) {
toast("Не удалось сохранить: " + e.message, "err");
}
}
/* ---------- raw-text editor (Спринт 7) ---------- */
async function toggleEditor(docId) {
const row = $("editor-" + docId);
const isOpen = row.style.display !== "none";
if (isOpen) {
row.style.display = "none";
return;
}
row.style.display = "";
const body = row.querySelector(".editor-body");
body.innerHTML = '<div class="mini">— загружаю —</div>';
try {
const d = await api(`/documents/${docId}/raw`);
const safe = esc(d.raw_text || "");
body.innerHTML = `
<div class="eb-hint">
Правится <b>извлечённый текст</b> документа. Для PDF/docx исходник теряется — после сохранения остаётся только этот текст. Сохранение запускает переразметку и обновляет чанки в Chroma.
</div>
<textarea class="eb-textarea" id="editor-text-${docId}" spellcheck="false">${safe}</textarea>
<div class="eb-actions">
<button class="primary" onclick="saveDocRaw('${docId}')">Сохранить и переиндексировать</button>
<button onclick="toggleEditor('${docId}')">Отмена</button>
<span class="eb-status" id="editor-status-${docId}"></span>
</div>
`;
} catch (e) {
body.innerHTML = `<div class="mini" style="color:var(--err)">Ошибка: ${esc(e.message)}</div>`;
}
}
async function saveDocRaw(docId) {
const ta = $("editor-text-" + docId);
const status = $("editor-status-" + docId);
if (!ta) return;
const raw_text = ta.value;
if (!raw_text.trim()) {
toast("Текст не может быть пустым", "err");
return;
}
if (!confirm("Сохранить и переиндексировать документ? Старые чанки будут удалены, новые соберутся заново.")) {
return;
}
if (status) status.innerHTML = '<span class="spinner"></span> переиндексирую…';
try {
const r = await api(`/documents/${docId}/raw`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ raw_text }),
});
toast(`Переиндексировано: ${r.chunks_count} чанков`);
if (status) status.textContent = "";
refreshDocs();
refreshHealth();
} catch (e) {
if (status) status.textContent = "";
toast("Ошибка: " + e.message, "err");
}
}
async function uploadFile(file) {
$("upload-status").innerHTML = `<span class="spinner"></span> загружаю <b>${esc(file.name)}</b>…`;
const fd = new FormData();
fd.append("file", file);
try {
const r = await api("/documents/upload", { method: "POST", body: fd });
$("upload-status").innerHTML = `✓ <b>${esc(r.name)}</b> — ${r.chunks_count} чанков`;
toast("Загружено");
refreshDocs(); refreshHealth();
} catch (e) {
$("upload-status").innerHTML = `✕ ошибка: ${esc(e.message)}`;
toast("Ошибка загрузки: " + e.message, "err");
}
}
function initDropzone() {
const dz = $("dropzone");
const input = $("file-input");
dz.addEventListener("click", () => input.click());
input.addEventListener("change", () => {
if (input.files[0]) uploadFile(input.files[0]);
input.value = "";
});
["dragenter", "dragover"].forEach(e =>
dz.addEventListener(e, ev => { ev.preventDefault(); dz.classList.add("drag"); })
);
["dragleave", "drop"].forEach(e =>
dz.addEventListener(e, ev => { ev.preventDefault(); dz.classList.remove("drag"); })
);
dz.addEventListener("drop", ev => {
if (ev.dataTransfer.files[0]) uploadFile(ev.dataTransfer.files[0]);
});
}
async function ask() {
const question = $("question").value.trim();
if (!question) { toast("Введите вопрос", "err"); return; }
const btn = $("ask-btn");
btn.disabled = true;
$("ask-status").innerHTML = '<span class="spinner"></span> думаю…';
$("col-chunks").innerHTML = '<div class="mini">…</div>';
$("col-prompt").innerHTML = '<div class="mini">…</div>';
$("col-answer").innerHTML = '<div class="mini">…</div>';
try {
const r = await api("/query", {
method: "POST",
headers: {"Content-Type": "application/json"},
body: JSON.stringify({
text: question,
top_k: parseInt($("top_k").value, 10) || 5,
temperature: parseFloat($("temperature").value),
}),
});
$("col-chunks").innerHTML = r.sources.length
? r.sources.map((s, i) => `
<div class="chunk">
<div class="chunk-head">
<span>[${i + 1}] ${esc(s.document_name)} · ${esc(s.section || "—")}</span>
<span class="score">${(s.relevance_score * 100).toFixed(1)}%</span>
</div>
<div>${esc(s.chunk_text)}</div>
</div>`).join("")
: '<div class="mini">— нет релевантных чанков —</div>';
$("col-prompt").innerHTML = `<pre>${esc(r.assembled_prompt)}</pre>`;
const cfgInfo = r.config_version != null ? ` · промпт <code>_debug</code> v${r.config_version}` : "";
const ragInfo = r.rag_subscription
? ` · подписано ${r.rag_subscription.subscribed_count}, найдено ${r.rag_subscription.found_count}`
: "";
$("col-answer").innerHTML = `
<div class="answer">${esc(r.answer)}</div>
<div class="answer-meta">модель: ${esc(r.model_used)} · источников: ${r.sources.length}${cfgInfo}${ragInfo}</div>
`;
$("ask-status").textContent = "";
loadDebugInfo();
} catch (e) {
$("col-answer").innerHTML = `<div class="mini" style="color:var(--err)">Ошибка: ${esc(e.message)}</div>`;
$("ask-status").textContent = "";
toast("Ошибка: " + e.message, "err");
} finally {
btn.disabled = false;
}
}
$("ask-btn").addEventListener("click", ask);
/* ---------- _debug intent info bar ---------- */
async function loadDebugInfo() {
const bar = $("debug-info-bar");
if (!bar) return;
try {
const [intentsResp, subsResp, docsResp] = await Promise.all([
api("/intents"),
api("/intents/_debug/documents"),
api("/documents"),
]);
const dbg = (intentsResp.intents || []).find(i => i.code === "_debug");
const subscribed = (subsResp.document_ids || []).length;
const total = (docsResp.documents || []).length;
const ver = dbg && dbg.active_config_version != null ? `v${dbg.active_config_version}` : "нет активной версии";
const noPromptWarning = !dbg || dbg.active_config_version == null;
bar.innerHTML = `
<span>промпт ветки <code style="background:#e0e7ff;padding:1px 5px;border-radius:3px;font-family:var(--mono);">_debug</code> «Страница отладки» · <b>${esc(ver)}</b></span>
<span style="opacity:.7;">·</span>
<span>подписано <b>${subscribed}</b> из <b>${total}</b> документ(ов)${subscribed === 0 ? " — RAG идёт по всей базе" : ""}</span>
<span style="opacity:.7;">·</span>
<a href="/settings.html" style="color:var(--accent);text-decoration:none;">настроить →</a>
`;
if (noPromptWarning) {
bar.style.background = "#fef3c7";
bar.style.borderColor = "#fde68a";
bar.style.color = "#78350f";
bar.innerHTML += '<div style="width:100%;margin-top:4px;">⚠️ у ветки нет активной версии промпта — модель будет отвечать без системных инструкций.</div>';
}
} catch (e) {
bar.innerHTML = `<span style="color:var(--err);">Не удалось загрузить настройки: ${esc(e.message)}</span>`;
}
}
loadDebugInfo();
$("question").addEventListener("keydown", e => {
if ((e.metaKey || e.ctrlKey) && e.key === "Enter") ask();
});
initDropzone();
refreshHealth();
refreshDocs();
setInterval(refreshHealth, 15000);
</script>
</body>
</html>