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:
+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