feat(sprint6b-D): soft-insertion counter + message meta_json
- thread_state.soft_insertion_count: растёт при боковом ответе (soft_insertion=true
в STATE_JSON без смены шага/слотов), сбрасывается при продвижении или handoff
- При soft_insertion_count >= 3 в системный промпт ветки добавляется SOFT_INSERTION_NUDGE
— явная инструкция вернуть пациента к вопросу текущего шага
- state_machine.parse_branch_response читает флаг soft_insertion из STATE_JSON
- Новая колонка message.meta_json: {router_intent_code, served_intent_code, step_code, events}
— хранит снимок маршрутизации каждой реплики ассистента
- «Песочница»: бейджи событий (sticky / soft_insertion / hard_handoff / resumed /
routing_loop / validation_blocked) над каждым ответом ассистента
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
+85
-9
@@ -236,6 +236,47 @@
|
||||
font-family: var(--mono);
|
||||
margin-right: 6px;
|
||||
}
|
||||
.msg-step {
|
||||
display: inline-block;
|
||||
background: #eef2ff;
|
||||
color: #3730a3;
|
||||
padding: 1px 7px;
|
||||
border-radius: 10px;
|
||||
font-size: 10px;
|
||||
font-weight: 500;
|
||||
font-family: var(--mono);
|
||||
margin-right: 6px;
|
||||
}
|
||||
.msg-router {
|
||||
display: inline-block;
|
||||
color: var(--muted);
|
||||
font-size: 10px;
|
||||
margin-right: 6px;
|
||||
}
|
||||
.msg-router code {
|
||||
background: #fafbfd;
|
||||
border: 1px solid var(--border);
|
||||
color: var(--muted);
|
||||
padding: 0 4px;
|
||||
border-radius: 4px;
|
||||
font-family: var(--mono);
|
||||
font-size: 10px;
|
||||
}
|
||||
.msg-event {
|
||||
display: inline-block;
|
||||
padding: 1px 7px;
|
||||
border-radius: 10px;
|
||||
font-size: 10px;
|
||||
font-weight: 500;
|
||||
margin-right: 4px;
|
||||
cursor: help;
|
||||
}
|
||||
.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; }
|
||||
@@ -653,6 +694,32 @@ function startNewThread() {
|
||||
refreshThreads();
|
||||
}
|
||||
|
||||
const EVENT_LABELS = {
|
||||
sticky: { text: "удержались", title: "роутер предлагал другую ветку, ветка осталась в сценарии" },
|
||||
hard_handoff: { text: "переключение", title: "ветка сама выдала [INTENT_CHANGE] и передала диалог другой" },
|
||||
soft_insertion: { text: "боковой вопрос", title: "ответ вне шага: модель ответила на побочный вопрос, не двигая сценарий" },
|
||||
resumed: { text: "возврат", title: "восстановили отложенный сценарий со всеми слотами" },
|
||||
routing_loop: { text: "защита от петли", title: "сработала защита: автоматический перевод на оператора" },
|
||||
validation_blocked: { text: "прыжок отклонён", title: "валидатор не разрешил переход в указанный шаг" },
|
||||
};
|
||||
|
||||
function renderAssistantBadges(intentCode, intentName, meta) {
|
||||
const intent = intentCode ? `<span class="msg-intent" title="${esc(intentName || intentCode)}">${esc(intentCode)}</span>` : "";
|
||||
if (!meta) return intent;
|
||||
const stepBadge = meta.step_code
|
||||
? `<span class="msg-step" title="шаг state machine">${esc(meta.step_code)}</span>`
|
||||
: "";
|
||||
const router = (meta.router_intent_code && meta.router_intent_code !== meta.served_intent_code)
|
||||
? `<span class="msg-router">роутер: <code>${esc(meta.router_intent_code)}</code></span>`
|
||||
: "";
|
||||
const events = (meta.events || []).map(e => {
|
||||
const cfg = EVENT_LABELS[e];
|
||||
if (!cfg) return "";
|
||||
return `<span class="msg-event ${esc(e)}" title="${esc(cfg.title)}">${esc(cfg.text)}</span>`;
|
||||
}).join("");
|
||||
return intent + stepBadge + router + events;
|
||||
}
|
||||
|
||||
function renderMessages(messages) {
|
||||
const box = $("chat-messages");
|
||||
if (!messages.length) {
|
||||
@@ -662,18 +729,20 @@ function renderMessages(messages) {
|
||||
box.innerHTML = messages.map(m => {
|
||||
const isUser = m.role === "user";
|
||||
const body = isUser ? esc(m.text) : renderMd(m.text);
|
||||
const intentBadge = m.intent_code ? `<span class="msg-intent" title="${esc(m.intent_name || m.intent_code)}">${esc(m.intent_code)}</span>` : "";
|
||||
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">${intentBadge}${esc(fmtDate(m.created_at))}</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) {
|
||||
function appendMessage(role, text, iso, intentCode, intentName, meta) {
|
||||
const box = $("chat-messages");
|
||||
const empty = box.querySelector(".chat-empty");
|
||||
if (empty) empty.remove();
|
||||
@@ -681,8 +750,8 @@ function appendMessage(role, text, iso, intentCode, intentName) {
|
||||
const isUser = role === "user";
|
||||
div.className = "msg " + (isUser ? "user" : "assistant");
|
||||
const body = isUser ? esc(text) : renderMd(text);
|
||||
const intentBadge = intentCode ? `<span class="msg-intent" title="${esc(intentName || intentCode)}">${esc(intentCode)}</span>` : "";
|
||||
div.innerHTML = `<div class="msg-body">${body}</div><div class="msg-meta">${intentBadge}${esc(fmtDate(iso || new Date().toISOString()))}</div>`;
|
||||
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;
|
||||
@@ -696,10 +765,17 @@ function renderState(state, bounces, validationEvents, parseError, routingLoopTr
|
||||
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>
|
||||
переключений ветки в диалоге: <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>.
|
||||
@@ -742,7 +818,7 @@ function renderState(state, bounces, validationEvents, parseError, routingLoopTr
|
||||
<b>${esc(state.current_intent_code)}</b>
|
||||
<span style="color:var(--muted);font-size:11px;margin-left:4px;">— без пошагового сценария</span>
|
||||
</div>
|
||||
${handoffHtml}${loopHtml}${suspendedHtml}${resumedHtml}${bounceHtml}${validationHtml}${parseErrorHtml}
|
||||
${handoffHtml}${softNudgeHtml}${loopHtml}${suspendedHtml}${resumedHtml}${bounceHtml}${validationHtml}${parseErrorHtml}
|
||||
</div>
|
||||
`;
|
||||
return;
|
||||
@@ -753,7 +829,7 @@ function renderState(state, bounces, validationEvents, parseError, routingLoopTr
|
||||
<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}${loopHtml}${suspendedHtml}${resumedHtml}${bounceHtml}${validationHtml}${parseErrorHtml}
|
||||
${handoffHtml}${softNudgeHtml}${loopHtml}${suspendedHtml}${resumedHtml}${bounceHtml}${validationHtml}${parseErrorHtml}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
@@ -860,7 +936,7 @@ async function sendMessage() {
|
||||
});
|
||||
activeThreadId = r.thread_id;
|
||||
pending.remove();
|
||||
appendMessage("assistant", r.answer, null, r.intent_code, r.intent_name);
|
||||
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);
|
||||
|
||||
Reference in New Issue
Block a user