Files
RAG_helper/static/sandbox.html
T
AR 15 M4 4977199cd4 feat(sprint6b-F): guards в new_booking — require_legal_rep
- check_guards() в state_machine.py: проверяет guards_json шага при переходе;
  trigger_slot/trigger_value/required_slots; нормализует "true"/"false"-строки
- qualify step: guard require_legal_rep — блокирует переход в present, если
  is_child=true и не заполнены legal_rep_name / legal_rep_phone
- Промпт qualify обновлён: инструкции по is_child, legal_rep, requested_doctor,
  waitlist_flag, needs_surgologist_first
- ensure_seed_guards() патчит guards_json существующих шагов при старте
- Sandbox: блок валидации показывает guard_name + missing_slots + description
- Settings: обновлён лейбл поля guards с примером формата

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-26 18:27:10 +05:00

1046 lines
37 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-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="/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>
<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}`);
$("chat-title").className = "chat-title";
$("chat-title").textContent = d.name;
renderMessages(d.messages);
const lastAssistant = [...d.messages].reverse().find(m => m.role === "assistant");
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);
renderState(d.thread_state, [], [], null, false, false);
} 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: "валидатор guard заблокировал переход на запрошенный шаг" },
};
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 routerDiffers = meta.router_intent_code && meta.router_intent_code !== meta.served_intent_code;
const router = routerDiffers
? `<span class="msg-badge msg-router" title="Роутер классифицировал реплику в другую ветку, но модель осталась здесь"><span class="badge-label">роутер предложил:</span><span class="badge-val">${esc(meta.router_intent_code)}</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) {
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 softCount = Number(state.soft_insertion_count || 0);
const SOFT_CAP = 3;
const handoffHtml = `
<div style="margin-top:6px;font-size:11px;color:var(--muted);">
переключений ветки в диалоге: <b style="color:var(--fg);">${handoff}</b>${state.current_step_code ? ` · боковых вопросов подряд: <b style="color:var(--fg);">${softCount}</b>` : ''}
</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 loopHtml = routingLoopTriggered
? `<div style="margin-top:8px;padding:6px 8px;border-radius:4px;background:#fee2e2;color:#7f1d1d;font-size:11px;">
🛑 защита от петли сработала: диалог уведён в <code>escalate_human</code>.
</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 `🔒 guard <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}${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}${softNudgeHtml}${loopHtml}${suspendedHtml}${resumedHtml}${bounceHtml}${validationHtml}${parseErrorHtml}
</div>
`;
}
function renderDebug(sources, prompt, intentCode, intentName, configVersion, routerVersion, routerIntentCode, bounces, stepCode) {
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 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}
</div>`
: "";
$("debug-router").innerHTML = routerLine || '<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>';
}
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>';
}
/* ---------- 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);
renderState(r.thread_state, r.bounces, r.validation_events, r.parse_error, r.routing_loop_triggered, r.resumed_from_suspended);
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>