Files
RAG_helper/static/sandbox.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

1145 lines
43 KiB
HTML
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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>Chat Agent for Patients — Песочница</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;
--user-bg: #dbeafe;
--bot-bg: #f3f4f6;
}
* { box-sizing: border-box; }
html, body { height: 100%; }
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;
display: flex;
flex-direction: column;
}
header {
background: var(--panel);
border-bottom: 1px solid var(--border);
padding: 14px 24px;
display: flex;
align-items: center;
gap: 16px;
flex-shrink: 0;
}
header h1 { margin: 0; font-size: 16px; font-weight: 600; }
.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; }
.active-config {
margin-left: auto;
padding: 4px 10px;
border-radius: 999px;
background: #ecfdf5;
color: #065f46;
border: 1px solid #a7f3d0;
font-size: 12px;
cursor: pointer;
}
.active-config:empty { display: none; }
.active-config:hover { background: #d1fae5; }
.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.err { background: var(--err); }
main {
flex: 1;
display: grid;
grid-template-columns: 280px 1fr 420px;
gap: 0;
min-height: 0;
}
.col-panel {
background: var(--panel);
border-right: 1px solid var(--border);
display: flex;
flex-direction: column;
min-height: 0;
}
.col-panel:last-child {
border-right: none;
border-left: 1px solid var(--border);
background: var(--bg);
}
/* Правая панель — стек карточек на сером фоне */
.col-panel:last-child .col-body {
padding: 14px 14px 18px 14px;
display: flex;
flex-direction: column;
gap: 12px;
}
.col-head {
padding: 14px 16px 10px;
border-bottom: 1px solid var(--border);
font-size: 13px;
color: var(--fg);
font-weight: 600;
letter-spacing: -0.01em;
display: flex;
align-items: center;
gap: 8px;
}
.col-body {
flex: 1;
overflow-y: auto;
min-height: 0;
}
/* Список диалогов */
.threads-head-btn {
margin-left: auto;
background: var(--accent);
color: #fff;
border: none;
padding: 4px 10px;
border-radius: 6px;
font-size: 12px;
cursor: pointer;
}
.threads-head-btn:hover { background: var(--accent-hover); }
.thread-item {
padding: 12px 16px;
border-bottom: 1px solid var(--border);
cursor: pointer;
display: flex;
flex-direction: column;
gap: 2px;
}
.thread-item:hover { background: #f9fafb; }
.thread-item.active { background: var(--chip-bg); }
.thread-name {
font-weight: 500;
font-size: 13px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.thread-meta {
font-size: 11px;
color: var(--muted);
display: flex;
justify-content: space-between;
gap: 8px;
}
.thread-preview {
font-size: 12px;
color: var(--muted);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.thread-actions {
display: flex;
gap: 6px;
margin-top: 4px;
opacity: 0;
transition: opacity 0.1s;
}
.thread-item:hover .thread-actions,
.thread-item.active .thread-actions { opacity: 1; }
.thread-actions button {
background: none;
border: 1px solid var(--border);
font-size: 11px;
padding: 2px 8px;
border-radius: 4px;
cursor: pointer;
color: var(--muted);
}
.thread-actions button:hover { background: var(--panel); color: var(--fg); }
.thread-actions button.del:hover { border-color: var(--err); color: var(--err); }
/* Чат */
.chat-head {
padding: 12px 20px;
border-bottom: 1px solid var(--border);
display: flex;
align-items: center;
gap: 10px;
background: var(--panel);
}
.chat-title {
font-weight: 500;
font-size: 14px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.chat-title.empty { color: var(--muted); font-weight: normal; }
.chat-messages {
flex: 1;
overflow-y: auto;
padding: 16px 20px;
background: var(--bg);
display: flex;
flex-direction: column;
gap: 10px;
min-height: 0;
}
.msg {
max-width: 75%;
padding: 10px 14px;
border-radius: 12px;
word-wrap: break-word;
}
.msg.user {
background: var(--user-bg);
align-self: flex-end;
white-space: pre-wrap;
}
.msg.assistant { background: var(--bot-bg); align-self: flex-start; }
/* Строка бейджей под сообщением ассистента */
.msg-badge {
display: inline-flex;
align-items: center;
gap: 2px;
padding: 2px 8px;
border-radius: 10px;
font-size: 10px;
margin-right: 5px;
line-height: 1.5;
white-space: nowrap;
}
.msg-badge .badge-label {
font-weight: 400;
opacity: 0.65;
margin-right: 2px;
}
.msg-badge .badge-val {
font-weight: 600;
font-family: var(--mono);
}
.msg-badge .badge-sub {
font-weight: 400;
font-family: var(--mono);
opacity: 0.7;
margin-left: 2px;
}
/* Ветка */
.msg-intent {
background: var(--chip-bg);
color: var(--accent);
}
.msg-intent .badge-sm-tag {
font-weight: 400;
font-size: 9px;
opacity: 0.75;
margin-left: 4px;
background: rgba(99,102,241,0.12);
padding: 0 4px;
border-radius: 6px;
font-family: sans-serif;
}
/* Шаг */
.msg-step {
background: #eef2ff;
color: #3730a3;
}
/* Решение маршрутизатора */
.msg-router {
background: #f3f4f6;
color: #4b5563;
border: 1px solid #e5e7eb;
}
.msg-router .badge-val { font-family: var(--mono); }
.msg-router.router-matches {
background: #ecfdf5;
color: #065f46;
border-color: #a7f3d0;
}
.msg-router.router-differs {
background: #fffbeb;
color: #78350f;
border-color: #fde68a;
}
/* События */
.msg-event {
font-weight: 500;
cursor: help;
}
.msg-event .badge-label { opacity: 0.6; }
.msg-event.sticky { background: #dbeafe; color: #1e40af; }
.msg-event.hard_handoff { background: #ffedd5; color: #9a3412; }
.msg-event.soft_insertion{ background: #fef3c7; color: #78350f; }
.msg-event.resumed { background: #dcfce7; color: #14532d; }
.msg-event.routing_loop { background: #fee2e2; color: #7f1d1d; }
.msg-event.validation_blocked { background: #fee2e2; color: #7f1d1d; }
.msg.assistant p { margin: 0 0 8px 0; }
.msg.assistant p:last-child { margin-bottom: 0; }
.msg.assistant ul, .msg.assistant ol { margin: 6px 0; padding-left: 22px; }
.msg.assistant li { margin: 2px 0; }
.msg.assistant code {
background: #e5e7eb;
padding: 1px 5px;
border-radius: 3px;
font-family: var(--mono);
font-size: 12px;
}
.msg.assistant pre {
background: #e5e7eb;
padding: 8px 10px;
border-radius: 6px;
overflow-x: auto;
font-family: var(--mono);
font-size: 12px;
margin: 6px 0;
}
.msg.assistant pre code { background: none; padding: 0; }
.msg.assistant a { color: var(--accent); text-decoration: underline; }
.msg.assistant h1, .msg.assistant h2, .msg.assistant h3 {
margin: 8px 0 4px 0;
font-size: 14px;
font-weight: 600;
}
.msg.assistant blockquote {
border-left: 3px solid var(--border);
margin: 6px 0;
padding-left: 10px;
color: var(--muted);
}
.msg-meta { font-size: 10px; color: var(--muted); margin-top: 6px; display: flex; flex-wrap: wrap; align-items: center; gap: 3px; }
.chat-empty {
margin: auto;
color: var(--muted);
text-align: center;
font-style: italic;
padding: 40px 20px;
}
.chat-input {
padding: 12px 20px;
border-top: 1px solid var(--border);
background: var(--panel);
display: flex;
gap: 10px;
}
.chat-input textarea {
flex: 1;
border: 1px solid var(--border);
border-radius: 8px;
padding: 10px 12px;
font: inherit;
font-size: 13px;
resize: none;
min-height: 44px;
max-height: 160px;
outline: none;
}
.chat-input textarea:focus { border-color: var(--accent); }
.chat-input button {
background: var(--accent);
color: #fff;
border: none;
padding: 0 20px;
border-radius: 8px;
font-size: 13px;
font-weight: 500;
cursor: pointer;
align-self: stretch;
}
.chat-input button:hover { background: var(--accent-hover); }
.chat-input button:disabled { background: var(--muted); cursor: not-allowed; }
/* Правая панель — карточки */
.debug-section {
background: var(--panel);
border: 1px solid var(--border);
border-radius: 10px;
padding: 12px 14px;
}
.debug-section h3 {
font-size: 13px;
color: var(--fg);
margin: 0 0 10px 0;
font-weight: 600;
letter-spacing: -0.01em;
}
/* Сворачиваемая секция (details/summary) с тем же видом, что и обычная карточка */
.debug-section.collapsible > summary {
list-style: none;
cursor: pointer;
display: flex;
align-items: center;
gap: 8px;
margin: 0;
font-size: 13px;
color: var(--fg);
font-weight: 600;
letter-spacing: -0.01em;
}
.debug-section.collapsible[open] > summary { margin: 0 0 10px 0; }
.debug-section.collapsible > summary::-webkit-details-marker { display: none; }
.debug-section.collapsible > summary::after {
content: "⌄";
margin-left: auto;
font-size: 14px;
line-height: 1;
color: var(--muted);
transition: transform 0.15s;
}
.debug-section.collapsible[open] > summary::after { transform: rotate(180deg); }
.debug-section.collapsible > summary:hover { color: var(--accent); }
.debug-section.collapsible > summary .summary-count {
background: var(--chip-bg);
color: var(--accent);
padding: 1px 8px;
border-radius: 10px;
font-size: 11px;
font-weight: 500;
}
.chunk-card {
background: #fafbfd;
border: 1px solid var(--border);
border-radius: 6px;
margin-bottom: 8px;
font-size: 12px;
overflow: hidden;
}
.chunk-card > summary {
padding: 8px 10px;
list-style: none;
cursor: pointer;
display: flex;
align-items: center;
gap: 6px;
}
.chunk-card > summary::-webkit-details-marker { display: none; }
.chunk-card > summary::before {
content: "▸";
font-size: 10px;
color: var(--muted);
flex-shrink: 0;
transition: transform 0.15s;
}
.chunk-card[open] > summary::before { transform: rotate(90deg); }
.chunk-card > summary:hover { background: #f9fafb; }
.chunk-card-meta {
font-size: 10px;
color: var(--muted);
display: flex;
gap: 8px;
flex-wrap: wrap;
flex: 1;
min-width: 0;
align-items: center;
}
.chunk-card-meta .chunk-doc {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
max-width: 100%;
}
.chunk-score {
background: var(--chip-bg);
color: var(--accent);
padding: 1px 6px;
border-radius: 10px;
font-weight: 600;
flex-shrink: 0;
}
.chunk-text {
padding: 0 10px 10px 10px;
white-space: pre-wrap;
word-break: break-word;
font-size: 11.5px;
color: var(--fg);
border-top: 1px solid var(--border);
padding-top: 8px;
margin-top: 2px;
max-height: 240px;
overflow-y: auto;
}
.prompt-box {
background: #fafbfd;
color: var(--fg);
border: 1px solid var(--border);
padding: 10px 12px;
border-radius: 6px;
font-family: var(--mono);
font-size: 11px;
line-height: 1.5;
white-space: pre-wrap;
word-break: break-word;
max-height: 400px;
overflow-y: auto;
}
.mini { color: var(--muted); font-size: 12px; font-style: italic; }
.toast {
position: fixed;
bottom: 30px;
left: 50%;
transform: translateX(-50%);
background: #111827;
color: #fff;
padding: 10px 16px;
border-radius: 8px;
font-size: 13px;
opacity: 0;
pointer-events: none;
transition: opacity 0.2s;
z-index: 100;
}
.toast.show { opacity: 1; }
.toast.err { background: var(--err); }
.spinner {
display: inline-block;
width: 12px;
height: 12px;
border: 2px solid var(--border);
border-top-color: var(--accent);
border-radius: 50%;
animation: spin 0.8s linear infinite;
vertical-align: middle;
}
@keyframes spin { to { transform: rotate(360deg); } }
</style>
<script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/dompurify@3.0.9/dist/purify.min.js"></script>
</head>
<body>
<header>
<h1>Chat Agent for Patients</h1>
<nav class="nav">
<a href="/" class="nav-link">Отладка</a>
<a href="/sandbox.html" class="nav-link active">Песочница</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" style="margin-left:auto;"><span class="dot" id="dot"></span><span id="status-text">проверяю…</span></span>
</header>
<main>
<aside class="col-panel">
<div class="col-head">
Диалоги
<button class="threads-head-btn" onclick="startNewThread()">+ новый</button>
</div>
<div class="col-body" id="threads-list">
<div class="mini" style="padding:14px;">загружаю…</div>
</div>
</aside>
<section class="col-panel">
<div class="chat-head">
<div class="chat-title empty" id="chat-title">— выберите диалог слева или начните новый —</div>
</div>
<div class="chat-messages" id="chat-messages">
<div class="chat-empty">Здесь появятся сообщения диалога.<br>Напишите что-нибудь снизу, чтобы начать.</div>
</div>
<form class="chat-input" id="chat-form">
<textarea id="chat-text" placeholder="Напишите реплику пациента и нажмите Enter..." rows="1"></textarea>
<button type="submit" id="chat-send">Отправить</button>
</form>
</section>
<aside class="col-panel">
<div class="col-body">
<div class="debug-section">
<h3>Состояние диалога</h3>
<div id="debug-state"><div class="mini">— пока пусто —</div></div>
</div>
<div class="debug-section">
<h3>Решение маршрутизатора</h3>
<div id="debug-router"><div class="mini">— пока пусто —</div></div>
</div>
<div class="debug-section" id="debug-operator-summary" style="display:none;background:#fff1f2;border-radius:6px;padding:10px 14px;font-size:12px;"></div>
<div class="debug-section">
<h3>Срез RAG</h3>
<div id="debug-rag"><div class="mini">— пока пусто —</div></div>
</div>
<details class="debug-section collapsible" id="debug-chunks-section">
<summary>
<span>Найденные фрагменты</span>
<span class="summary-count" id="debug-chunks-count" style="display:none;">0</span>
</summary>
<div id="debug-chunks"><div class="mini">— пока пусто —</div></div>
</details>
<details class="debug-section collapsible" id="debug-prompt-section">
<summary>
<span>Собранный промпт</span>
</summary>
<div id="debug-prompt"><div class="mini">— пока пусто —</div></div>
</details>
</div>
</aside>
</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]));
let activeThreadId = null;
function toast(msg, kind = "ok") {
const t = $("toast");
t.textContent = msg;
t.className = "toast show" + (kind === "err" ? " err" : "");
setTimeout(() => t.className = "toast", 2500);
}
function renderMd(text) {
try {
marked.setOptions({ breaks: true, gfm: true });
const html = marked.parse(text || "");
return DOMPurify.sanitize(html);
} catch (e) {
return esc(text);
}
}
async function api(path, opts = {}) {
const res = await fetch(API + path, opts);
if (!res.ok) {
let msg = `${res.status}`;
try { const d = await res.json(); msg = d.detail || JSON.stringify(d); } catch (_) {}
throw new Error(msg);
}
if (res.status === 204) return null;
return res.json();
}
/* ---------- health ---------- */
async function refreshHealth() {
try {
const h = await api("/health");
$("dot").className = "dot ok";
$("status-text").textContent = `${h.chunks_count} чанков · ${h.documents_count} док.`;
} catch (e) {
$("dot").className = "dot err";
$("status-text").textContent = "недоступен";
}
}
/* ---------- threads list ---------- */
async function refreshThreads() {
try {
const d = await api("/threads");
renderThreads(d.threads);
} catch (e) {
$("threads-list").innerHTML = `<div class="mini" style="padding:14px;color:var(--err)">${esc(e.message)}</div>`;
}
}
function renderThreads(threads) {
if (!threads.length) {
$("threads-list").innerHTML = '<div class="mini" style="padding:14px;">пока нет диалогов</div>';
return;
}
$("threads-list").innerHTML = threads.map(t => `
<div class="thread-item ${t.id === activeThreadId ? 'active' : ''}" onclick="openThread(${t.id})">
<div class="thread-name" title="${esc(t.name)}">${esc(t.name)}</div>
<div class="thread-meta">
<span>${esc(fmtDate(t.updated_at))}</span>
<span>${t.messages_count} сообщ.</span>
</div>
${t.first_message_preview ? `<div class="thread-preview">${esc(t.first_message_preview)}</div>` : ""}
<div class="thread-actions">
<button onclick="event.stopPropagation(); renameThread(${t.id}, ${JSON.stringify(t.name).replace(/"/g, '&quot;')})">переименовать</button>
<button class="del" onclick="event.stopPropagation(); deleteThread(${t.id}, ${JSON.stringify(t.name).replace(/"/g, '&quot;')})">удалить</button>
</div>
</div>
`).join("");
}
function fmtDate(iso) {
try {
const d = new Date(iso);
return d.toLocaleString("ru-RU", { day: "2-digit", month: "2-digit", hour: "2-digit", minute: "2-digit" });
} catch (_) { return iso; }
}
/* ---------- open / new thread ---------- */
async function openThread(id) {
activeThreadId = id;
try {
const d = await api(`/threads/${id}`);
if (activeThreadId !== id) return; // пользователь переключился пока шёл запрос
$("chat-title").className = "chat-title";
$("chat-title").textContent = d.name;
renderMessages(d.messages);
const lastAssistant = [...d.messages].reverse().find(m => m.role === "assistant");
const lastEscalation = [...d.messages].reverse().find(m => m.role === "assistant" && m.escalation_reason);
if (lastAssistant) {
renderDebug(lastAssistant.sources, lastAssistant.assembled_prompt, lastAssistant.intent_code, lastAssistant.intent_name, null, null, null, [], d.thread_state && d.thread_state.current_step_code, null, null, lastAssistant.rag_subscription || (lastAssistant.meta && lastAssistant.meta.rag_subscription) || null);
renderState(d.thread_state, [], [], null, false, false, lastEscalation ? lastEscalation.escalation_reason : null);
} else {
clearDebug();
}
refreshThreads();
} catch (e) {
toast("Ошибка: " + e.message, "err");
}
}
function startNewThread() {
activeThreadId = null;
$("chat-title").className = "chat-title empty";
$("chat-title").textContent = "— новый диалог (начните с первой реплики) —";
$("chat-messages").innerHTML = '<div class="chat-empty">Это новый диалог.<br>Напишите реплику пациента снизу, чтобы начать.</div>';
clearDebug();
refreshThreads();
}
const EVENT_LABELS = {
sticky: { label: "решение:", text: "удержались в ветке", title: "маршрутизатор предлагал другую ветку — модель осталась в текущем сценарии" },
hard_handoff: { label: "решение:", text: "переключили ветку", title: "ветка выдала [INTENT_CHANGE] и передала диалог другой ветке" },
soft_insertion: { label: "тип ответа:", text: "боковой вопрос", title: "модель ответила на побочный вопрос, не продвигая сценарий (шаг не изменился)" },
resumed: { label: "решение:", text: "восстановили сценарий",title: "вернулись в ранее приостановленный сценарий со всеми слотами" },
routing_loop: { label: "защита:", text: "петля маршрутизатора", title: "сработала защита от петли: диалог автоматически передан оператору" },
validation_blocked:{ label: "валидатор:", text: "переход отклонён", title: "защитное условие заблокировало переход на запрошенный шаг" },
};
function renderAssistantBadges(intentCode, intentName, meta) {
// Активная ветка
const displayName = (intentName && intentName !== intentCode) ? intentName : intentCode;
const smTag = (meta && (meta.is_state_machine || meta.step_code))
? `<span class="badge-sm-tag">пошаговая</span>` : "";
const codeHint = (intentName && intentName !== intentCode)
? `<span class="badge-sub">(${esc(intentCode)})</span>` : "";
const intent = intentCode
? `<span class="msg-badge msg-intent" title="Активная ветка: ${esc(intentName || intentCode)}"><span class="badge-label">активная ветка:</span><span class="badge-val">${esc(displayName)}</span>${codeHint}${smTag}</span>`
: "";
if (!meta) return intent;
// Шаг state machine
const stepDisplay = meta.step_name || meta.step_code;
const stepSub = meta.step_name && meta.step_code
? `<span class="badge-sub">(${esc(meta.step_code)})</span>` : "";
const stepBadge = meta.step_code
? `<span class="msg-badge msg-step" title="Текущий шаг пошаговой ветки"><span class="badge-label">шаг ветки:</span><span class="badge-val">${esc(stepDisplay)}</span>${stepSub}</span>`
: "";
// Решение маршрутизатора — показываем ВСЕГДА (даже при совпадении с активной веткой)
const routerCode = meta.router_intent_code;
const routerDiffers = routerCode && routerCode !== meta.served_intent_code;
const routerTitle = routerDiffers
? "Маршрутизатор классифицировал реплику в другую ветку, но модель осталась здесь (удержание в ветке или возврат из отложенного сценария)"
: "Маршрутизатор подтвердил активную ветку";
const router = routerCode
? `<span class="msg-badge msg-router${routerDiffers ? ' router-differs' : ' router-matches'}" title="${esc(routerTitle)}"><span class="badge-label">решение маршрутизатора:</span><span class="badge-val">${esc(routerCode)}</span></span>`
: "";
// События
const events = (meta.events || []).map(e => {
const cfg = EVENT_LABELS[e];
if (!cfg) return "";
return `<span class="msg-badge msg-event ${esc(e)}" title="${esc(cfg.title)}"><span class="badge-label">${esc(cfg.label)}</span><span class="badge-val">${esc(cfg.text)}</span></span>`;
}).join("");
return intent + stepBadge + router + events;
}
function renderMessages(messages) {
const box = $("chat-messages");
if (!messages.length) {
box.innerHTML = '<div class="chat-empty">Пусто. Напишите первую реплику.</div>';
return;
}
box.innerHTML = messages.map(m => {
const isUser = m.role === "user";
const body = isUser ? esc(m.text) : renderMd(m.text);
const badges = isUser
? ""
: renderAssistantBadges(m.intent_code, m.intent_name, m.meta);
return `
<div class="msg ${isUser ? "user" : "assistant"}">
<div class="msg-body">${body}</div>
<div class="msg-meta">${badges}${esc(fmtDate(m.created_at))}</div>
</div>
`;
}).join("");
box.scrollTop = box.scrollHeight;
}
function appendMessage(role, text, iso, intentCode, intentName, meta) {
const box = $("chat-messages");
const empty = box.querySelector(".chat-empty");
if (empty) empty.remove();
const div = document.createElement("div");
const isUser = role === "user";
div.className = "msg " + (isUser ? "user" : "assistant");
const body = isUser ? esc(text) : renderMd(text);
const badges = isUser ? "" : renderAssistantBadges(intentCode, intentName, meta);
div.innerHTML = `<div class="msg-body">${body}</div><div class="msg-meta">${badges}${esc(fmtDate(iso || new Date().toISOString()))}</div>`;
box.appendChild(div);
box.scrollTop = box.scrollHeight;
return div;
}
/* ---------- отладка ---------- */
function renderState(state, bounces, validationEvents, parseError, routingLoopTriggered, resumedFromSuspended, escalationReason) {
const box = $("debug-state");
if (!state || !state.current_intent_code) {
box.innerHTML = '<div class="mini">сценарий ещё не запущен</div>';
return;
}
const handoff = Number(state.handoff_count || 0);
const HANDOFF_CAP = 3;
const softCount = Number(state.soft_insertion_count || 0);
const SOFT_CAP = 3;
const handoffWarn = handoff >= HANDOFF_CAP;
const handoffHtml = `
<div style="margin-top:8px;display:flex;align-items:center;gap:8px;font-size:11px;">
<span style="color:var(--muted);">Переключений:</span>
<span style="display:inline-flex;align-items:center;gap:4px;padding:2px 8px;border-radius:10px;background:${handoffWarn ? '#fee2e2' : '#eef2ff'};color:${handoffWarn ? '#7f1d1d' : '#3730a3'};font-weight:600;">
${handoff} из ${HANDOFF_CAP}
</span>
${state.current_step_code ? `<span style="color:var(--muted);">· боковых вопросов подряд: <b style="color:var(--fg);">${softCount}</b></span>` : ''}
</div>`;
const softNudgeHtml = (state.current_step_code && softCount >= SOFT_CAP)
? `<div style="margin-top:8px;padding:6px 8px;border-radius:4px;background:#fef3c7;color:#78350f;font-size:11px;">
📣 пациент несколько раз подряд уходит в боковые вопросы — на этой реплике ветка получила инструкцию вернуть его к шагу.
</div>`
: "";
const pendingGuard = state.pending_guard;
const pendingGuardHtml = pendingGuard
? `<div style="margin-top:8px;padding:6px 8px;border-radius:4px;background:#fef3c7;color:#78350f;font-size:11px;">
🔒 <b>защитное условие активно: ${esc(pendingGuard.guard_name)}</b> — ждём заполнения: ${(pendingGuard.missing_slots || []).map(s => `<code>${esc(s)}</code>`).join(", ")}.<br>
<span style="opacity:.75;">${esc(pendingGuard.description || "")}</span>
</div>`
: "";
const REASON_LABELS = {
acute_pain: "острая боль / срочное состояние",
surgery: "операция / хирургия / стационар",
angry: "пациент раздражён",
explicit_request: "запросил оператора",
routing_loop: "автоматически (петля маршрутизатора)",
};
const loopHtml = routingLoopTriggered
? `<div style="margin-top:8px;padding:6px 8px;border-radius:4px;background:#fee2e2;color:#7f1d1d;font-size:11px;">
🛑 защита от петли сработала: диалог уведён к оператору.
</div>`
: "";
const effectiveReason = escalationReason || (state.current_intent_code === "escalate_human" ? "explicit_request" : null);
const escalationHtml = effectiveReason
? `<div style="margin-top:8px;padding:6px 8px;border-radius:4px;background:#fee2e2;color:#7f1d1d;font-size:11px;">
🔴 <b>передача оператору</b> · причина: <code>${esc(effectiveReason)}</code>
<span style="opacity:.75;"> — ${esc(REASON_LABELS[effectiveReason] || effectiveReason)}</span>
</div>`
: "";
const suspendedSlotsCount = Object.keys(state.resumable_slots || {}).length;
const suspendedHtml = state.suspended_intent
? `<div style="margin-top:8px;padding:6px 8px;border-radius:4px;background:#eff6ff;color:#1e3a8a;font-size:11px;">
📌 отложен сценарий: <code>${esc(state.suspended_intent)}</code>${state.resumable_step_code ? ' (шаг <code>' + esc(state.resumable_step_code) + '</code>)' : ''}, слотов: <b>${suspendedSlotsCount}</b>. Вернёмся, когда пациент возвратится к этой теме.
</div>`
: "";
const resumedHtml = resumedFromSuspended
? `<div style="margin-top:8px;padding:6px 8px;border-radius:4px;background:#ecfdf5;color:#065f46;font-size:11px;">
↩️ возврат к отложенному сценарию: восстановили шаг и слоты.
</div>`
: "";
const bounceHtml = (bounces && bounces.length)
? `<div style="margin-top:8px;font-size:11px;">
<div style="color:var(--muted);margin-bottom:3px;">переходы в этой реплике:</div>
${bounces.map(b => `<div>• <b>${esc(b.from)}</b> → <b>${esc(b.to)}</b>${b.preface ? ` <span style="color:var(--muted);">(«${esc(b.preface).slice(0,60)}»)</span>` : ''}</div>`).join("")}
</div>`
: "";
const validationHtml = (validationEvents && validationEvents.length)
? `<div style="margin-top:8px;padding:6px 8px;border-radius:4px;background:#fef2f2;color:#991b1b;font-size:11px;">
${validationEvents.map(v => {
if (v.guard_name) {
const missing = (v.missing_slots || []).map(s => `<code>${esc(s)}</code>`).join(", ");
return `🔒 защитное условие <b>${esc(v.guard_name)}</b> не пройдено — ждём: ${missing}.<br><span style="opacity:.75">${esc(v.guard_description || "")}</span>`;
}
return ` модель просилась в <code>${esc(v.requested_step)}</code>, оставили на <code>${esc(v.current_step)}</code>. ${esc(v.reason)}`;
}).join("<br>")}
</div>`
: "";
const parseErrorHtml = parseError
? `<div style="margin-top:8px;padding:6px 8px;border-radius:4px;background:#fef3c7;color:#78350f;font-size:11px;">
⚠️ парсер: ${esc(parseError)}
</div>`
: "";
// Ветки без state machine (general_info, price_question и т.д.) шаги не ведут —
// показываем только intent, чтобы не путать пустым «шаг №0 · {}».
if (!state.current_step_code) {
box.innerHTML = `
<div style="font-size:12px;">
<div>
<b>${esc(state.current_intent_code)}</b>
<span style="color:var(--muted);font-size:11px;margin-left:4px;">— без пошагового сценария</span>
</div>
${handoffHtml}${escalationHtml}${pendingGuardHtml}${softNudgeHtml}${loopHtml}${suspendedHtml}${resumedHtml}${bounceHtml}${validationHtml}${parseErrorHtml}
</div>
`;
return;
}
const slotsJson = JSON.stringify(state.slots || {}, null, 2);
box.innerHTML = `
<div style="font-size:12px;">
<div><b>${esc(state.current_intent_code)}</b> · шаг <code>${esc(state.current_step_code)}</code></div>
<div class="prompt-box" style="margin-top:6px;max-height:200px;">${esc(slotsJson)}</div>
${handoffHtml}${escalationHtml}${pendingGuardHtml}${softNudgeHtml}${loopHtml}${suspendedHtml}${resumedHtml}${bounceHtml}${validationHtml}${parseErrorHtml}
</div>
`;
}
function renderDebug(sources, prompt, intentCode, intentName, configVersion, routerVersion, routerIntentCode, bounces, stepCode, operatorSummary, routerPrompt, ragSubscription) {
const routerVer = routerVersion != null ? `маршрутизатор v${routerVersion}` : "маршрутизатор";
const hasBounces = bounces && bounces.length > 0;
const routerDiffers = routerIntentCode && intentCode && routerIntentCode !== intentCode;
// Три разных исхода — объясняем отдельно, чтобы не путать «sticky» и «bouncing».
let verdict;
if (hasBounces) {
// Ветка сама выдала INTENT_CHANGE — bounce через [INTENT_CHANGE: ...].
const chain = bounces.map(b => `<code>${esc(b.from)}</code> → <code>${esc(b.to)}</code>`).join(", ");
verdict = `<div style="color:var(--muted);font-size:11px;margin-top:4px;line-height:1.5;">
${routerVer} сказал <code>${esc(routerIntentCode)}</code>.<br>
Ветка сама выдала <code>[INTENT_CHANGE]</code> и передала управление: ${chain}.
</div>`;
} else if (routerDiffers) {
// Удержались в ветке: диалог в сценарии, маршрутизатор хотел переключить, но мы остались.
verdict = `<div style="color:var(--muted);font-size:11px;margin-top:4px;line-height:1.5;">
${routerVer} предложил <code>${esc(routerIntentCode)}</code>.<br>
Но диалог идёт по сценарию <code>${esc(intentCode)}</code>${stepCode ? ' (шаг <code>' + esc(stepCode) + '</code>)' : ''}
<b>удержались в ветке</b>: модель получила подсказку и осталась в сценарии.
</div>`;
} else {
// Обычный случай — маршрутизатор попал в ту же ветку.
verdict = `<div style="color:var(--muted);font-size:11px;margin-top:4px;">
${routerVer} → активная ветка совпадает с решением.
</div>`;
}
const routerPromptHtml = routerPrompt
? `<details style="margin-top:6px;">
<summary style="font-size:11px;color:var(--muted);cursor:pointer;">промпт маршрутизатора</summary>
<div class="prompt-box" style="margin-top:4px;max-height:300px;">${esc(routerPrompt)}</div>
</details>`
: "";
const routerLine = intentCode
? `<div style="padding:10px 14px;background:#ecfdf5;font-size:12px;border-radius:6px;">
<div><b>${esc(intentCode)}</b> — ${esc(intentName || '')}${configVersion ? ' · ветка v' + configVersion : ''}</div>
${verdict}
${routerPromptHtml}
</div>`
: "";
$("debug-router").innerHTML = routerLine || '<div class="mini">— маршрутизация пока не выполнена —</div>';
// Срез RAG: видно сколько документов подписано на активную ветку и сколько чанков пришло.
const ragBox = $("debug-rag");
if (ragBox) {
if (ragSubscription) {
const sub = Number(ragSubscription.subscribed_count || 0);
const found = Number(ragSubscription.found_count || 0);
const intentLabel = intentCode ? `<code>${esc(intentCode)}</code>` : "—";
let warn = "";
if (sub === 0) {
warn = `<div style="margin-top:6px;padding:6px 8px;border-radius:4px;background:#fef3c7;color:#78350f;font-size:11px;">
⚠️ у ветки нет подписок — RAG-контекст пустой. Подписать документы можно в «Настройки» → ${intentLabel} или в «Отладка» рядом с документом.
</div>`;
}
ragBox.innerHTML = `
<div style="font-size:12px;">
подписано <b style="color:var(--fg);">${sub}</b> документ(ов) на ветку ${intentLabel} · в этой реплике пришло <b style="color:var(--fg);">${found}</b> чанк(ов)
</div>
${warn}
`;
} else {
ragBox.innerHTML = '<div class="mini">— пока пусто —</div>';
}
}
const count = $("debug-chunks-count");
if (sources && sources.length) {
count.textContent = sources.length;
count.style.display = "";
$("debug-chunks").innerHTML = sources.map(s => `
<details class="chunk-card">
<summary>
<div class="chunk-card-meta">
<span class="chunk-score">${(s.relevance_score * 100).toFixed(1)}%</span>
<span class="chunk-doc">${esc(s.document_name || "—")}</span>
${s.section ? `<span>${esc(s.section)}</span>` : ""}
</div>
</summary>
<div class="chunk-text">${esc(s.chunk_text)}</div>
</details>
`).join("");
} else {
count.style.display = "none";
$("debug-chunks").innerHTML = '<div class="mini">источников нет</div>';
}
$("debug-prompt").innerHTML = prompt
? `<div class="prompt-box">${esc(prompt)}</div>`
: '<div class="mini">промпт пуст</div>';
const summaryBox = $("debug-operator-summary");
if (summaryBox) {
if (operatorSummary) {
summaryBox.style.display = "";
summaryBox.innerHTML = `
<div style="font-size:11px;color:var(--muted);margin-bottom:4px;">саммари для оператора (предпросмотр)</div>
<div style="margin-bottom:4px;"><b>причина:</b> <code>${esc(operatorSummary.reason || "")}</code></div>
<div style="margin-bottom:4px;"><b>слоты:</b> <code>${esc(JSON.stringify(operatorSummary.slots || {}))}</code></div>
<div><b>история:</b>
${(operatorSummary.history_tail || []).map(h =>
`<div style="margin-top:3px;"><span style="color:var(--muted);">${esc(h.role === "user" ? "пациент" : "ассистент")}:</span> ${esc(h.text)}</div>`
).join("")}
</div>`;
} else {
summaryBox.style.display = "none";
}
}
}
function clearDebug() {
$("debug-state").innerHTML = '<div class="mini">— пока пусто —</div>';
$("debug-router").innerHTML = '<div class="mini">— пока пусто —</div>';
$("debug-chunks").innerHTML = '<div class="mini">— пока пусто —</div>';
$("debug-prompt").innerHTML = '<div class="mini">— пока пусто —</div>';
const s = $("debug-operator-summary"); if (s) s.style.display = "none";
}
/* ---------- send message ---------- */
$("chat-form").addEventListener("submit", async (e) => {
e.preventDefault();
sendMessage();
});
$("chat-text").addEventListener("keydown", (e) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
sendMessage();
}
});
async function sendMessage() {
const txt = $("chat-text").value.trim();
if (!txt) return;
$("chat-text").value = "";
$("chat-send").disabled = true;
$("chat-send").innerHTML = '<span class="spinner"></span>';
const userBubble = appendMessage("user", txt);
const pending = appendMessage("assistant", "…");
pending.style.opacity = "0.6";
try {
const body = { text: txt, top_k: 5 };
if (activeThreadId) body.thread_id = activeThreadId;
const r = await api("/chat", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
activeThreadId = r.thread_id;
pending.remove();
appendMessage("assistant", r.answer, null, r.intent_code, r.intent_name, r.message_meta);
$("chat-title").className = "chat-title";
$("chat-title").textContent = r.thread_name;
renderDebug(r.sources, r.assembled_prompt, r.intent_code, r.intent_name, r.config_version, r.router_version, r.router_intent_code, r.bounces, r.thread_state && r.thread_state.current_step_code, r.operator_summary, r.router_assembled_prompt, r.rag_subscription);
renderState(r.thread_state, r.bounces, r.validation_events, r.parse_error, r.routing_loop_triggered, r.resumed_from_suspended, r.escalation_reason);
refreshThreads();
} catch (e) {
// Откатываем визуально: убираем пузырь-заглушку ассистента и только что
// добавленную реплику пациента — на бекенде весь запрос уже откатился (rollback).
pending.remove();
userBubble.remove();
// Если после удаления пузырей чат стал пустым — вернём плейсхолдер.
const box = $("chat-messages");
if (!box.querySelector(".msg")) {
box.innerHTML = activeThreadId
? '<div class="chat-empty">Пусто. Напишите первую реплику.</div>'
: '<div class="chat-empty">Это новый диалог.<br>Напишите реплику пациента снизу, чтобы начать.</div>';
}
// Возвращаем текст в поле ввода — не заставлять пользователя перепечатывать.
$("chat-text").value = txt;
toast("Ошибка: " + e.message, "err");
} finally {
$("chat-send").disabled = false;
$("chat-send").textContent = "Отправить";
}
}
/* ---------- rename / delete ---------- */
async function renameThread(id, currentName) {
const newName = prompt("Новое имя диалога:", currentName);
if (!newName || newName.trim() === currentName) return;
try {
await api(`/threads/${id}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name: newName.trim() }),
});
toast("Переименован");
if (id === activeThreadId) {
$("chat-title").textContent = newName.trim();
}
refreshThreads();
} catch (e) {
toast("Ошибка: " + e.message, "err");
}
}
async function deleteThread(id, name) {
if (!confirm(`Удалить диалог «${name}» со всеми сообщениями?`)) return;
try {
const r = await api(`/threads/${id}`, { method: "DELETE" });
toast(`Удалён (${r.deleted_messages} сообщ.)`);
if (id === activeThreadId) startNewThread();
else refreshThreads();
} catch (e) {
toast("Ошибка: " + e.message, "err");
}
}
/* ---------- init ---------- */
refreshHealth();
refreshThreads();
setInterval(refreshHealth, 15000);
</script>
</body>
</html>