feat(sprint6a): блоки A2, B, C — exit_conditions, handoff_count, suspended/resume
Блок A2: вынос условий выхода из основного промпта в отдельное поле agent_configs.exit_conditions_text. compose_full_system_prompt склеивает system_prompt + rules_text + exit_conditions_text перед отправкой в модель. Одноразовая миграция данных при старте: пытаемся выделить блок «Условия выхода» из хвоста существующих system_prompt-ов и перенести в новое поле (поддерживаются три формы заголовка: «## Условия выхода», «**Условия выхода**», просто «Условия выхода:»). В UI «Настройки» — третья textarea с подсказкой ⓘ на отдельной кнопке. Блок B: защита от петель маршрутизации (v2 §4.3). В thread_state добавлена колонка handoff_count, инкрементируется на каждом hard-handoff: либо когда роутер переключает не-sm-ветку (state reset), либо когда sm-ветка сама выдаёт [INTENT_CHANGE: …] (bouncing). При превышении HANDOFF_CAP=3 диалог автоматически уводится в escalate_human с шаблонным ответом «Уточню детали с администратором клиники, свяжемся с вами в течение ближайшего часа», LLM не вызывается, handoff_count сбрасывается. В Песочнице видны счётчик «переключений ветки в диалоге» и красная плашка при срабатывании защиты. Также пофикшен баг: для не-sm-веток snapshot.current_intent_code теперь финализируется на served_code, иначе на следующей реплике prev_intent_code терялся и handoff_count не считался. Блок C: suspended_intent / resumable_step_code / resumable_slots_json в thread_state (v2 §4.4). При hard-handoff из sm-ветки через [INTENT_CHANGE] текущий сценарий запоминается (если suspended ещё не занят). Когда роутер на следующих репликах возвращает intent = suspended_intent — RESUME: восстанавливаем current_intent_code, current_step_code, slots; suspended_* очищается, handoff_count=0. Возврат имеет приоритет над sticky-логикой. В Песочнице — синяя плашка «📌 отложен сценарий X (шаг Y)» во время detour'а и зелёная «↩️ возврат к отложенному сценарию» в момент resume. Routing-loop guard и роутер-driven handoff не теряют suspended (только при authoritative сценариях вроде эскалации он сбрасывается). Прогон вручную: detour из new_booking/qualify в price_question и обратно восстанавливает name=Алексей, reason=болит ухо на исходном шаге. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
+26
-5
@@ -618,7 +618,7 @@ async function openThread(id) {
|
||||
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);
|
||||
renderState(d.thread_state, [], [], null, false, false);
|
||||
} else {
|
||||
clearDebug();
|
||||
}
|
||||
@@ -673,12 +673,33 @@ function appendMessage(role, text, iso, intentCode, intentName) {
|
||||
}
|
||||
|
||||
/* ---------- отладка ---------- */
|
||||
function renderState(state, bounces, validationEvents, parseError) {
|
||||
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 handoffHtml = `
|
||||
<div style="margin-top:6px;font-size:11px;color:var(--muted);">
|
||||
переключений ветки в диалоге: <b style="color:var(--fg);">${handoff}</b>
|
||||
</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>
|
||||
@@ -705,7 +726,7 @@ function renderState(state, bounces, validationEvents, parseError) {
|
||||
<b>${esc(state.current_intent_code)}</b>
|
||||
<span style="color:var(--muted);font-size:11px;margin-left:4px;">— без пошагового сценария</span>
|
||||
</div>
|
||||
${bounceHtml}${validationHtml}${parseErrorHtml}
|
||||
${handoffHtml}${loopHtml}${suspendedHtml}${resumedHtml}${bounceHtml}${validationHtml}${parseErrorHtml}
|
||||
</div>
|
||||
`;
|
||||
return;
|
||||
@@ -716,7 +737,7 @@ function renderState(state, bounces, validationEvents, parseError) {
|
||||
<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>
|
||||
${bounceHtml}${validationHtml}${parseErrorHtml}
|
||||
${handoffHtml}${loopHtml}${suspendedHtml}${resumedHtml}${bounceHtml}${validationHtml}${parseErrorHtml}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
@@ -827,7 +848,7 @@ async function sendMessage() {
|
||||
$("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);
|
||||
renderState(r.thread_state, r.bounces, r.validation_events, r.parse_error, r.routing_loop_triggered, r.resumed_from_suspended);
|
||||
refreshThreads();
|
||||
} catch (e) {
|
||||
// Откатываем визуально: убираем пузырь-заглушку ассистента и только что
|
||||
|
||||
+46
-12
@@ -677,22 +677,32 @@ function switchTab(tab) {
|
||||
renderEditor();
|
||||
}
|
||||
|
||||
function toggleRulesHint(force) {
|
||||
const pop = document.getElementById("rules-hint-popover");
|
||||
const btn = document.getElementById("rules-hint-btn");
|
||||
function toggleHint(key, force) {
|
||||
const pop = document.getElementById(`${key}-hint-popover`);
|
||||
const btn = document.getElementById(`${key}-hint-btn`);
|
||||
if (!pop || !btn) return;
|
||||
const willShow = typeof force === "boolean" ? force : !pop.classList.contains("show");
|
||||
// Закрываем все остальные открытые подсказки — чтобы не накладывались.
|
||||
document.querySelectorAll(".hint-popover.show").forEach(p => {
|
||||
if (p !== pop) p.classList.remove("show");
|
||||
});
|
||||
document.querySelectorAll(".hint-btn.active").forEach(b => {
|
||||
if (b !== btn) b.classList.remove("active");
|
||||
});
|
||||
pop.classList.toggle("show", willShow);
|
||||
btn.classList.toggle("active", willShow);
|
||||
}
|
||||
|
||||
// Клик вне popover-а — закрываем.
|
||||
// Клик вне любого popover-а — закрываем все.
|
||||
document.addEventListener("click", (e) => {
|
||||
const pop = document.getElementById("rules-hint-popover");
|
||||
const btn = document.getElementById("rules-hint-btn");
|
||||
if (!pop || !btn || !pop.classList.contains("show")) return;
|
||||
if (pop.contains(e.target) || btn.contains(e.target)) return;
|
||||
toggleRulesHint(false);
|
||||
const opened = document.querySelectorAll(".hint-popover.show");
|
||||
if (!opened.length) return;
|
||||
for (const pop of opened) {
|
||||
const btn = document.getElementById(pop.id.replace("-popover", "-btn"));
|
||||
if (pop.contains(e.target) || (btn && btn.contains(e.target))) return;
|
||||
}
|
||||
document.querySelectorAll(".hint-popover.show").forEach(p => p.classList.remove("show"));
|
||||
document.querySelectorAll(".hint-btn.active").forEach(b => b.classList.remove("active"));
|
||||
});
|
||||
|
||||
function renderPromptPanel(intent) {
|
||||
@@ -708,10 +718,10 @@ function renderPromptPanel(intent) {
|
||||
<div class="field">
|
||||
<label for="f-rules" class="with-hint">
|
||||
<span>Правила (дополнение к промпту; свободная markdown-форма)</span>
|
||||
<button type="button" class="hint-btn" id="rules-hint-btn" onclick="toggleRulesHint()" aria-label="Подсказка">i</button>
|
||||
<button type="button" class="hint-btn" id="rules-hint-btn" onclick="toggleHint('rules')" aria-label="Подсказка">i</button>
|
||||
</label>
|
||||
<div class="hint-popover" id="rules-hint-popover">
|
||||
<button type="button" class="hint-close" onclick="toggleRulesHint(false)" aria-label="Закрыть">×</button>
|
||||
<button type="button" class="hint-close" onclick="toggleHint('rules', false)" aria-label="Закрыть">×</button>
|
||||
<h4>Что писать в «Правила»</h4>
|
||||
<p>Точечные дополнения к системному промпту в свободной markdown-форме. Технически склеиваются с основным промптом в один текст для модели — граница условная и нужна для оператора, чтобы не лазать в каркас при правке мелочей.</p>
|
||||
<p><b>Что нормально писать:</b></p>
|
||||
@@ -721,10 +731,29 @@ function renderPromptPanel(intent) {
|
||||
<li>«После 19:00 предлагай только следующий рабочий день».</li>
|
||||
<li>«Дети до 14 лет — сразу <code>[INTENT_CHANGE: escalate_human]</code>, у нас нет педиатра».</li>
|
||||
</ul>
|
||||
<p><b>Что сюда не стоит:</b> изменения роли агента, тона или формата ответа — они в основном промпте. Условия выхода (<code>[INTENT_CHANGE: ...]</code>) пока тоже в основном промпте, в Спринте 6a выделим в отдельное поле.</p>
|
||||
<p><b>Что сюда не стоит:</b> изменения роли агента, тона или формата ответа — они в основном промпте. Условия выхода — отдельное поле ниже.</p>
|
||||
</div>
|
||||
<textarea id="f-rules" class="rules" spellcheck="false"></textarea>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="f-exits" class="with-hint">
|
||||
<span>Условия выхода (когда отдать управление другой ветке)</span>
|
||||
<button type="button" class="hint-btn" id="exits-hint-btn" onclick="toggleHint('exits')" aria-label="Подсказка">i</button>
|
||||
</label>
|
||||
<div class="hint-popover" id="exits-hint-popover">
|
||||
<button type="button" class="hint-close" onclick="toggleHint('exits', false)" aria-label="Закрыть">×</button>
|
||||
<h4>Что писать в «Условия выхода»</h4>
|
||||
<p>Список ситуаций, когда ветка должна вместо обычного ответа выдать служебную строку <code>[INTENT_CHANGE: <код_ветки>]</code> и передать диалог другой ветке. Пишется в свободной markdown-форме, склеивается с системным промптом перед отправкой в модель.</p>
|
||||
<p><b>Примеры:</b></p>
|
||||
<ul>
|
||||
<li>«Пациент описывает острое состояние (сильная боль, кровотечение, одышка) → <code>[INTENT_CHANGE: escalate_human]</code>».</li>
|
||||
<li>«Спрашивает про цены, ДМС, оплату → <code>[INTENT_CHANGE: price_question]</code>».</li>
|
||||
<li>«Просит соединить с оператором → <code>[INTENT_CHANGE: escalate_human]</code>».</li>
|
||||
</ul>
|
||||
<p><b>Не нужно:</b> правила для штатного хода диалога — это в «Правила». Тут только переключения между ветками.</p>
|
||||
</div>
|
||||
<textarea id="f-exits" class="rules" spellcheck="false"></textarea>
|
||||
</div>
|
||||
<div class="editor-actions">
|
||||
<button onclick="saveVersion()">Сохранить как новую версию</button>
|
||||
<button class="secondary" onclick="loadActiveIntoEditor()">Перезагрузить активную</button>
|
||||
@@ -840,6 +869,7 @@ async function loadActiveIntoEditor() {
|
||||
$("f-name").value = "";
|
||||
$("f-prompt").value = "";
|
||||
$("f-rules").value = "";
|
||||
if ($("f-exits")) $("f-exits").value = "";
|
||||
}
|
||||
return;
|
||||
}
|
||||
@@ -848,6 +878,7 @@ async function loadActiveIntoEditor() {
|
||||
$("f-name").value = c.name ? `${c.name} (на основе v${c.version})` : `v${c.version} — копия`;
|
||||
$("f-prompt").value = c.system_prompt;
|
||||
$("f-rules").value = c.rules_text || "";
|
||||
if ($("f-exits")) $("f-exits").value = c.exit_conditions_text || "";
|
||||
} catch (e) {
|
||||
toast("Не удалось загрузить активную: " + e.message, "err");
|
||||
}
|
||||
@@ -859,6 +890,7 @@ function loadIntoEditor(configId) {
|
||||
$("f-name").value = c.name ? `${c.name} (на основе v${c.version})` : `v${c.version} — копия`;
|
||||
$("f-prompt").value = c.system_prompt;
|
||||
$("f-rules").value = c.rules_text || "";
|
||||
if ($("f-exits")) $("f-exits").value = c.exit_conditions_text || "";
|
||||
toast(`Загружена v${c.version}`);
|
||||
window.scrollTo({ top: 0, behavior: "smooth" });
|
||||
}
|
||||
@@ -907,6 +939,7 @@ async function saveVersion() {
|
||||
const name = $("f-name").value.trim();
|
||||
const system_prompt = $("f-prompt").value.trim();
|
||||
const rules_text = $("f-rules").value.trim();
|
||||
const exit_conditions_text = $("f-exits") ? $("f-exits").value.trim() : "";
|
||||
const activate = $("chk-activate").checked;
|
||||
|
||||
if (!system_prompt) {
|
||||
@@ -923,6 +956,7 @@ async function saveVersion() {
|
||||
name: name || null,
|
||||
system_prompt,
|
||||
rules_text,
|
||||
exit_conditions_text,
|
||||
activate,
|
||||
}),
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user