Files
RAG_helper/static/sandbox.html
T
AR 15 M4 52b46bc53e feat(sprint6c+sprint7): терминология, сверка примеров с кодом, мульти-RAG (часть A)
Спринт 6c — терминология и сверка документации с реальным кодом:
- Словарь терминов в static/docs.html: «маршрутизатор» вместо «роутер»,
  «защитное условие» вместо «guard», «пошаговая ветка» вместо «многошаговая».
  Разделены концепты «намерение» (intent) и «ветка» (branch) с пометкой,
  что в коде они хранятся как одна сущность 1:1.
- Песочница: «Решение маршрутизатора» виден всегда (зелёный/жёлтый),
  счётчик переключений «N из 3» отдельной плашкой, бейджи под словарь.
- Настройки: «Условия перехода» → «Защитные условия (guards, JSON)».
- GRAPH_ARCHITECTURE_v4.md: имена полей thread_state и слоты приведены
  к реальной БД (db/models/thread_state.py) и таксономии промптов шагов
  (prompts/intents/new_booking/steps/). Ссылки на *_v2 примеры. На v3
  поставлена шапка «устарело».
- 4 примера переписаны как *_v2: реальные current_intent_code/
  current_step_code/slots_json, реальные allowed_next без двойных переходов,
  реальная таксономия слотов name/reason/specialist/preferred_time/confirmed.
  Удалены вымышленные CRM tool calls и слоты, которых нет в коде.
- static/example.html — параметризованная страница с навигацией между
  4 примерами; роут GET /api/docs/examples/{name} в main.py отдаёт
  markdown без дублирования файлов.
- Редактирование документов в Отладке: GET/PUT /documents/{id}/raw,
  textarea с переразметкой и обновлением Chroma при сохранении.

Спринт 7, часть A — мульти-RAG через подписку ветка↔документы:
- Миграция: таблица intent_documents (M:N), модель IntentDocument,
  индекс по document_id для обратного поиска.
- API: GET/PUT /intents/{code}/documents и GET/PUT /documents/{id}/intents
  с PUT-семантикой «полный список», атомарно. Сервис
  services/intent_document_service.py.
- Retrieval-фильтр в chat_service: подтягивает document_ids активной
  ветки и передаёт в vectorstore.query(). Дефолт пустой подписки —
  document_ids=[] (= 0 чанков), не «вся коллекция»: пустая подписка
  означает «ветка не настроена», подмешивать случайное хуже, чем
  ничего. vectorstore.query() различает None (нет фильтра) и [] (0).
- UI Настроек: блок «Документы базы знаний» в правом сайдбаре,
  всегда видим независимо от вкладки, сортировка по имени, счётчик
  «N из M», PUT при сохранении.
- UI Отладки: третья кнопка «привязка» рядом с «удалить» —
  раскрывашка со списком веток (галочки), быстрая привязка прямо
  на странице загрузки.
- Песочница: блок «Срез RAG» с подпиской/найдено, ворнинг при пустой
  подписке. Поле rag_subscription в QueryResponse и ChatResponse.
- Системный промпт страницы Отладки переехал в обычную ветку _debug
  («Страница отладки»). Удалён prompts/system_prompt.md и логика
  DEFAULT_SYSTEM_PROMPT в llm_client. routers/query.py подтягивает
  активный конфиг ветки _debug и её подписки. Дефолт пустой подписки
  для _debug — None (вся коллекция), не [] как для пациентских — чтобы
  Отладка работала «из коробки». На странице Отладки info-bar показывает
  активную версию и счётчик подписок, ссылка → Настройки.
- Тест-блок «Тест-вопрос» в центре Настроек: расширил /query
  параметрами intent_code (default _debug), system_prompt (override
  для теста черновика из textarea), disable_rag (для _router).
  Редактор промпта обёрнут в <details open> — можно свернуть до
  одной строки. Под ним — три колонки результата (RAG / промпт /
  ответ). Для _router показывается подсказка про отсутствие RAG.

Документы:
- data/datasets/*.md — наработки по 6 веткам (рабочие материалы оператора).
- docs/BRANCH_MAP_AND_PROMPTS_v1.md, docs/OPTIMIZATION_CONVERSION_v1.md,
  docs/guides/state_machine_and_slots.md.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-27 20:00:44 +05:00

1144 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="/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>