Files
RAG_helper/static/index.html
T
AR 15 M4 a7f78d71b2 feat: Спринт 1 — RAG-ядро, загрузка wiki и Debug UI
FastAPI + ChromaDB + E5-large + DeepSeek по паттерну work-pcs-dr-cdss,
адаптированному под пациентский контекст:

- services: embeddings (E5-large с префиксами), vectorstore (коллекция
  operators_wiki), document_processor (PDF/DOCX/TXT/MD + чанкер с FAQ-
  паттерном под wiki), llm_client (системный промпт ассистента клиники),
  rag_pipeline (одиночный вопрос → retrieval → ответ).
- routers: /health, /documents (upload, list, chunks, delete), /query.
- static/index.html: шапка со статусом, блок базы знаний с раскрытием
  чанков по клику, блок тест-вопроса с 3-колоночным ответом
  (чанки со score / собранный промпт / ответ LLM).
- Порт 8003 (8001 занят CDSS, 8002 — voicenote).

E2E проверен: загрузка wiki_test.md → 2 чанка, вопрос «как записать
ребёнка к лору?» → top score 84.8%, корректный ответ DeepSeek.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 14:57:34 +05:00

572 lines
17 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); }
.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(--muted);
font-size: 12px;
text-transform: uppercase;
letter-spacing: 0.03em;
}
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; }
.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;
}
.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: 12px;
font-weight: 600;
color: var(--muted);
text-transform: uppercase;
letter-spacing: 0.03em;
}
.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 — Debug</h1>
<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>
<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 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>
`).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 => {
const long = c.text.length > 300;
const short = long ? c.text.slice(0, 300) + "…" : c.text;
const safeFull = esc(c.text).replace(/"/g, "&quot;");
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>
${long ? '<button class="chunk-card-toggle" onclick="toggleChunkText(this)">показать полностью</button>' : ""}
</div>
`;
}).join("");
} catch (e) {
body.innerHTML = `<div class="mini" style="color:var(--err)">Ошибка: ${esc(e.message)}</div>`;
}
}
function toggleChunkText(btn) {
const textEl = btn.previousElementSibling;
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 ? "показать полностью" : "свернуть";
}
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");
}
}
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>`;
$("col-answer").innerHTML = `
<div class="answer">${esc(r.answer)}</div>
<div class="answer-meta">модель: ${esc(r.model_used)} · источников: ${r.sources.length}</div>
`;
$("ask-status").textContent = "";
} 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);
$("question").addEventListener("keydown", e => {
if ((e.metaKey || e.ctrlKey) && e.key === "Enter") ask();
});
initDropzone();
refreshHealth();
refreshDocs();
setInterval(refreshHealth, 15000);
</script>
</body>
</html>