feat(sprint5): state machine + bouncing — thread_state и служебные теги

Таблица thread_state (intent, step, slots) ведётся per-thread. В системный
промпт ветки дописывается текущее состояние, LLM возвращает служебный тег
[STATE: step=N; slots={...}] после основного ответа — парсер в chat_service
вырезает его и обновляет состояние. Если ветка решила, что тема ушла в другую,
она выдаёт [INTENT_CHANGE: code] — делаем один повторный вызов LLM с новой
веткой и сброшенным state (bouncing, MAX_BOUNCES=1). Если роутер сам выбрал
другую ветку, чем в thread_state, — state тоже сбрасывается. Промпт new_booking
переписан под 6-шаговый сценарий (имя → повод → специалист → время → подтверждение
→ запись), в «Песочнице» появился блок «Состояние треда» с intent/step/slots
и списком переходов.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
AR 15 M4
2026-04-24 12:12:36 +05:00
parent b24e985f82
commit cac3d29273
10 changed files with 455 additions and 55 deletions
+38 -6
View File
@@ -430,6 +430,10 @@
<aside class="col-panel">
<div class="col-head">Отладка ответа</div>
<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>
@@ -543,8 +547,12 @@ async function openThread(id) {
$("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);
else clearDebug();
if (lastAssistant) {
renderDebug(lastAssistant.sources, lastAssistant.assembled_prompt, lastAssistant.intent_code, lastAssistant.intent_name, null, null, null);
renderState(d.thread_state, []);
} else {
clearDebug();
}
refreshThreads();
} catch (e) {
toast("Ошибка: " + e.message, "err");
@@ -596,12 +604,34 @@ function appendMessage(role, text, iso, intentCode, intentName) {
}
/* ---------- отладка ---------- */
function renderDebug(sources, prompt, intentCode, intentName, configVersion, routerVersion) {
const routerTag = routerVersion != null ? ` · роутер v${routerVersion}` : "";
function renderState(state, bounces) {
const box = $("debug-state");
if (!state || !state.current_intent_code) {
box.innerHTML = '<div class="mini">state machine ещё не запускалась</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>`
: "";
box.innerHTML = `
<div style="font-size:12px;">
<div><b>${esc(state.current_intent_code)}</b> · шаг <b>${state.current_step}</b></div>
<div class="prompt-box" style="margin-top:6px;max-height:200px;">${esc(slotsJson)}</div>
${bounceHtml}
</div>
`;
}
function renderDebug(sources, prompt, intentCode, intentName, configVersion, routerVersion, routerIntentCode) {
const bounced = routerIntentCode && intentCode && routerIntentCode !== intentCode;
const routerLine = intentCode
? `<div style="padding:10px 16px;background:#ecfdf5;font-size:12px;">
<div><b>${esc(intentCode)}</b> — ${esc(intentName || '')}${configVersion ? ' · ветка v' + configVersion : ''}</div>
${routerVersion != null ? `<div style="color:var(--muted);font-size:11px;margin-top:2px;">классифицировано роутером${routerTag.replace(' · роутер', '')}</div>` : ''}
${routerVersion != null ? `<div style="color:var(--muted);font-size:11px;margin-top:2px;">роутер v${routerVersion}${bounced ? ` сказал <b>${esc(routerIntentCode)}</b>, ветка передала управление` : ''}</div>` : ''}
</div>`
: "";
$("debug-router").innerHTML = routerLine || '<div class="mini">— маршрутизация пока не выполнена —</div>';
@@ -626,6 +656,7 @@ function renderDebug(sources, prompt, intentCode, intentName, configVersion, rou
}
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>';
@@ -668,7 +699,8 @@ 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);
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);
refreshThreads();
} catch (e) {
pending.remove();