a7f78d71b2
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>
572 lines
17 KiB
HTML
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 => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[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, """);
|
|
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, """)}" 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>
|