feat(sprint6a): блок A — structured output, intent_steps, sticky-удержание
Заменили строковый тег [STATE: ...] из Спринта 5 на структурированный выход
ветки в виде JSON-блока в хвосте ответа: {state_after, slots_updated}, парсимый
балансировкой скобок. Шаги state machine вынесены из монолитного промпта в
таблицу intent_steps (intent_id FK, code, name, order_index, system_prompt,
allowed_next JSON, guards JSON) и редактируются через UI. Валидатор переходов
сверяет state_after с allowed_next и блокирует невалидные прыжки.
Базовый промпт new_booking разбит на base + 6 файлов шагов (intro/qualify/
present/offer_time/book/close), которые сидятся при старте через
ensure_seed_steps. В chat_service промпт собирается как base + step + блок
[ТЕКУЩЕЕ СОСТОЯНИЕ].
Попутно реализован мини-блок G (sticky state machine): когда диалог идёт по
sm-ветке и роутер на новой реплике предлагает другую — state НЕ сбрасывается,
в системный промпт ветки подаётся блок [ПОДСКАЗКА РОУТЕРА], LLM сама решает
(STATE_JSON или INTENT_CHANGE). Это сняло ключевую дыру Спринта 5: «Меня
зовут Алексей» / «болит ухо» внутри записи больше не сбрасывают сценарий.
Промпт ветки new_booking ужесточён: бытовые жалобы — это повод записи (слот
reason + сочувствие), не повод уводить в medical_question. Шаг present теперь
использует reason в формулировке. Промпт _router расширен живыми примерами
для всех 6 веток, особенно для reschedule («не смогу подойти», «перенесите»).
Надёжность внешнего LLM:
- ретрай в LLMClient с паузой 500 мс + новое исключение LLMUnavailableError;
- ретрай в RouterClient (DeepSeek периодически моргает);
- /chat при ошибке делает session.rollback() и возвращает 503 с понятным
сообщением — больше не остаётся «диалогов-призраков» с одной репликой;
- UI убирает свой пузырь и возвращает текст в поле ввода для повторной отправки.
UI «Настройки» — добавлена вкладка «Шаги» для веток с state machine: список
шагов chip-ами, редактор промпта/имени/allowed_next/guards, сохранение через
PATCH /intents/{code}/steps/{step_code} без версионирования. Иконка ⓘ возле
поля «Правила» открывает popover с пояснением, что туда писать.
UI «Песочница»:
- блок «Состояние диалога» показывает имя шага из intent_steps (а не сырое
число), для не-sm-веток пишется «без пошагового сценария»;
- подсветка illegal-переходов (валидатор отклонил state_after) и parse_error
для sm-веток;
- блок «Решение роутера» развёрнут в три исхода: «попал в ту же ветку» /
«удержались в ветке» / «ветка сама передала управление через INTENT_CHANGE»;
- секция «Найденные фрагменты» сворачивается, карточки чанков раскрываются
по клику — правый сайдбар стал компактнее.
Терминология (по договорённости — простой русский в UI):
- «тред» → «диалог» в текстах для оператора (в коде/API thread_id оставлен);
- «sticky state machine» → «удержались в ветке»;
- «state machine» → «пошаговый сценарий» в видимых местах.
SPRINTS.md: блок G в Спринте 6b сокращён — sticky-логика уже сделана здесь,
осталась только вторая линия (передача thread_state в системный промпт самого
роутера для ещё более точной первичной классификации).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
+168
-29
@@ -111,7 +111,7 @@
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
/* Список тредов */
|
||||
/* Список диалогов */
|
||||
.threads-head-btn {
|
||||
margin-left: auto;
|
||||
background: var(--accent);
|
||||
@@ -310,21 +310,82 @@
|
||||
margin: 0 0 10px 0;
|
||||
font-weight: 600;
|
||||
}
|
||||
/* Сворачиваемая секция (details/summary) */
|
||||
.debug-section.collapsible > summary {
|
||||
list-style: none;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
margin: 0 0 10px 0;
|
||||
font-size: 12px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
color: var(--muted);
|
||||
font-weight: 600;
|
||||
}
|
||||
.debug-section.collapsible > summary::-webkit-details-marker { display: none; }
|
||||
.debug-section.collapsible > summary::before {
|
||||
content: "▸";
|
||||
display: inline-block;
|
||||
transition: transform 0.15s;
|
||||
font-size: 10px;
|
||||
color: var(--muted);
|
||||
}
|
||||
.debug-section.collapsible[open] > summary::before { transform: rotate(90deg); }
|
||||
.debug-section.collapsible > summary:hover { color: var(--fg); }
|
||||
.debug-section.collapsible > summary .summary-count {
|
||||
margin-left: auto;
|
||||
background: var(--chip-bg);
|
||||
color: var(--accent);
|
||||
padding: 1px 7px;
|
||||
border-radius: 10px;
|
||||
font-size: 10px;
|
||||
text-transform: none;
|
||||
letter-spacing: 0;
|
||||
}
|
||||
|
||||
.chunk-card {
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
padding: 8px 10px;
|
||||
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;
|
||||
margin-bottom: 4px;
|
||||
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);
|
||||
@@ -332,13 +393,18 @@
|
||||
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);
|
||||
max-height: 100px;
|
||||
border-top: 1px solid var(--border);
|
||||
padding-top: 8px;
|
||||
margin-top: 2px;
|
||||
max-height: 240px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
.prompt-box {
|
||||
@@ -431,17 +497,20 @@
|
||||
<div class="col-head">Отладка ответа</div>
|
||||
<div class="col-body">
|
||||
<div class="debug-section">
|
||||
<h3>Состояние треда</h3>
|
||||
<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">
|
||||
<h3>Найденные фрагменты (по последней реплике)</h3>
|
||||
<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>
|
||||
</div>
|
||||
</details>
|
||||
<div class="debug-section">
|
||||
<h3>Собранный промпт</h3>
|
||||
<div id="debug-prompt"><div class="mini">— пока пусто —</div></div>
|
||||
@@ -548,8 +617,8 @@ async function openThread(id) {
|
||||
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);
|
||||
renderState(d.thread_state, []);
|
||||
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);
|
||||
} else {
|
||||
clearDebug();
|
||||
}
|
||||
@@ -604,50 +673,108 @@ function appendMessage(role, text, iso, intentCode, intentName) {
|
||||
}
|
||||
|
||||
/* ---------- отладка ---------- */
|
||||
function renderState(state, bounces) {
|
||||
function renderState(state, bounces, validationEvents, parseError) {
|
||||
const box = $("debug-state");
|
||||
if (!state || !state.current_intent_code) {
|
||||
box.innerHTML = '<div class="mini">state machine ещё не запускалась</div>';
|
||||
box.innerHTML = '<div class="mini">сценарий ещё не запущен</div>';
|
||||
return;
|
||||
}
|
||||
const slotsJson = JSON.stringify(state.slots || {}, null, 2);
|
||||
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 => `⚠️ модель просилась в <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>
|
||||
${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> · шаг <b>${state.current_step}</b></div>
|
||||
<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>
|
||||
${bounceHtml}
|
||||
${bounceHtml}${validationHtml}${parseErrorHtml}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
function renderDebug(sources, prompt, intentCode, intentName, configVersion, routerVersion, routerIntentCode) {
|
||||
const bounced = routerIntentCode && intentCode && routerIntentCode !== intentCode;
|
||||
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 16px;background:#ecfdf5;font-size:12px;">
|
||||
? `<div style="padding:10px 14px;background:#ecfdf5;font-size:12px;border-radius:6px;">
|
||||
<div><b>${esc(intentCode)}</b> — ${esc(intentName || '')}${configVersion ? ' · ветка v' + configVersion : ''}</div>
|
||||
${routerVersion != null ? `<div style="color:var(--muted);font-size:11px;margin-top:2px;">роутер v${routerVersion}${bounced ? ` сказал <b>${esc(routerIntentCode)}</b>, ветка передала управление` : ''}</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 => `
|
||||
<div class="chunk-card">
|
||||
<div class="chunk-card-meta">
|
||||
<span class="chunk-score">${(s.relevance_score * 100).toFixed(1)}%</span>
|
||||
<span>${esc(s.document_name || "—")}</span>
|
||||
${s.section ? `<span>${esc(s.section)}</span>` : ""}
|
||||
</div>
|
||||
<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>
|
||||
</div>
|
||||
</details>
|
||||
`).join("");
|
||||
} else {
|
||||
count.style.display = "none";
|
||||
$("debug-chunks").innerHTML = '<div class="mini">источников нет</div>';
|
||||
}
|
||||
$("debug-prompt").innerHTML = prompt
|
||||
@@ -682,7 +809,7 @@ async function sendMessage() {
|
||||
$("chat-send").disabled = true;
|
||||
$("chat-send").innerHTML = '<span class="spinner"></span>';
|
||||
|
||||
appendMessage("user", txt);
|
||||
const userBubble = appendMessage("user", txt);
|
||||
const pending = appendMessage("assistant", "…");
|
||||
pending.style.opacity = "0.6";
|
||||
|
||||
@@ -699,11 +826,23 @@ async function sendMessage() {
|
||||
appendMessage("assistant", r.answer, null, r.intent_code, r.intent_name);
|
||||
$("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);
|
||||
renderState(r.thread_state, r.bounces);
|
||||
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);
|
||||
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;
|
||||
|
||||
Reference in New Issue
Block a user