Files
RAG_helper/static/index.html
T
AR 15 M4 7ec2ba3c8f feat(sprint3): редактор системного промпта и правил с версионированием
Операторы получают веб-редактор: правят системный промпт и правила,
сохраняют как новую версию, активируют, откатываются. Активная версия
используется в «Песочнице» на каждый /chat.

Принципы, согласованные заранее:
- Сохранённые версии не редактируются — только создание новой. Честный
  откат: v1 всегда та же, что была при создании.
- Правила на этом этапе — свободный markdown (textarea). Переход на
  структурированные правила (pattern → instruction) — в бэклог.
- Файл prompts/system_prompt.md становится сид-источником: при первом
  старте, если таблица agent_configs пустая, из него создаётся v1 и
  активируется. Дальше правда идёт из БД, файл не трогаем.
- rules_text конкатенируется с system_prompt в один system-message
  через compose_full_system_prompt: "{prompt}\n\nДополнительные
  правила:\n{rules}".
- Активную версию удалить нельзя — сначала активируют другую.

Модель и миграция:
- db/models/AgentConfig: id, version (unique/indexed), name (nullable),
  system_prompt, rules_text, is_active (indexed), created_at.
  Без updated_at — версии неизменяемы.
- Миграция b4450e33664d_add_agent_configs_table.

Сервисы и роутеры:
- services/config_service: ensure_seed (seed v1 из файла),
  list/get/get_active/create (version=max+1, при activate атомарно
  сбрасывает is_active у остальных и ставит новой),
  activate_config (та же схема), delete_config (возвращает причину
  отказа: not_found / active), compose_full_system_prompt.
- services/chat_service.send_message: берёт active_cfg, собирает
  system_prompt через compose_full_system_prompt, пишет
  thread.agent_config_id при создании треда (колонка была nullable
  ещё со Спринта 2 — пригодилась именно здесь).
- routers/configs: GET /configs, GET /configs/active, GET /configs/{id},
  POST /configs (activate-флаг), POST /configs/{id}/activate,
  DELETE /configs/{id} (404 / 400 если активная).
- Pydantic: AgentConfigCreateRequest, AgentConfigInfo, ListResponse,
  DeleteResponse.
- main.py: ensure_seed в lifespan после инициализации БД/Chroma/LLM.

UI:
- static/settings.html — трёхблочная страница: имя версии, textarea
  промпта, textarea правил, «Сохранить как новую» + галка
  «Сразу активировать», «Загрузить активную в редактор». Справа —
  список версий с бейджем «активная», действиями «Активировать» /
  «Удалить» (disabled у активной) / «Загрузить в редактор». При
  первом заходе активная версия автоматом подгружается в редактор.
- В nav на index.html и sandbox.html добавлена ссылка «Настройки».
- В шапке «Песочницы» — зелёный кликабельный бейдж «активная: vN · имя»
  (ведёт на /settings.html), обновляется раз в 15 с.

E2E проверено: создана v2 с правилом «ВСЕГДА начинай со слов СПАСИБО
ЗА ВОПРОС», активирована; следующий /chat вернул ответ, начинающийся
ровно с этой фразы; assembled_prompt содержит блок «Дополнительные
правила». После отката на v1 тест-v2 удалена.

SPRINTS.md: Спринт 3 помечен закрытым.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 19:59:06 +05:00

640 lines
19 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(--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;
}
.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: 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</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>
</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>
<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, 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");
}
}
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>