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:
AR 15 M4
2026-04-25 11:45:42 +05:00
parent 248cb37f8a
commit 9eef2dab3a
28 changed files with 1469 additions and 264 deletions
+168 -29
View File
@@ -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;
+324 -3
View File
@@ -179,7 +179,7 @@
.toolbar button:hover { background: #f9fafb; }
.toolbar .toggle { margin-left: auto; display: inline-flex; align-items: center; gap: 6px; font-size: 12px; color: var(--muted); cursor: pointer; }
.field { margin-bottom: 14px; }
.field { margin-bottom: 14px; position: relative; }
.field label {
display: block;
font-size: 12px;
@@ -187,6 +187,80 @@
color: var(--muted);
margin-bottom: 4px;
}
.field label.with-hint {
display: flex;
align-items: center;
gap: 6px;
}
.hint-btn {
display: inline-flex;
align-items: center;
justify-content: center;
width: 16px;
height: 16px;
border-radius: 50%;
border: 1px solid var(--border);
background: #fff;
color: var(--muted);
cursor: pointer;
font-size: 10px;
font-weight: 600;
line-height: 1;
padding: 0;
font-family: serif;
font-style: italic;
}
.hint-btn:hover { background: var(--chip-bg); color: var(--accent); border-color: var(--accent); }
.hint-btn.active { background: var(--accent); color: #fff; border-color: var(--accent); }
.hint-popover {
display: none;
position: absolute;
z-index: 50;
top: 28px;
left: 0;
right: 0;
max-width: 560px;
background: #fff;
border: 1px solid var(--border);
border-radius: 8px;
padding: 12px 14px;
box-shadow: 0 6px 20px rgba(17, 24, 39, 0.12);
font-size: 12px;
line-height: 1.55;
color: var(--fg);
}
.hint-popover.show { display: block; }
.hint-popover h4 {
margin: 0 0 6px 0;
font-size: 12px;
font-weight: 600;
color: var(--fg);
}
.hint-popover p { margin: 0 0 6px 0; }
.hint-popover ul { margin: 4px 0 6px 0; padding-left: 18px; }
.hint-popover li { margin: 2px 0; }
.hint-popover code {
background: var(--chip-bg);
color: var(--accent);
padding: 1px 5px;
border-radius: 4px;
font-size: 11px;
font-family: var(--mono);
}
.hint-popover .hint-close {
position: absolute;
top: 6px;
right: 8px;
background: none;
border: none;
color: var(--muted);
font-size: 16px;
cursor: pointer;
line-height: 1;
padding: 2px 6px;
}
.hint-popover .hint-close:hover { color: var(--fg); }
.field input[type=text], .field textarea {
width: 100%;
border: 1px solid var(--border);
@@ -309,6 +383,72 @@
.toast.show { opacity: 1; }
.toast.err { background: var(--err); }
/* Вкладки Промпт / Шаги */
.editor-tabs {
display: flex;
gap: 4px;
border-bottom: 1px solid var(--border);
margin-bottom: 16px;
}
.editor-tab {
padding: 6px 14px;
font-size: 13px;
border: none;
background: none;
color: var(--muted);
cursor: pointer;
border-bottom: 2px solid transparent;
margin-bottom: -1px;
}
.editor-tab:hover { color: var(--fg); }
.editor-tab.active {
color: var(--accent);
border-bottom-color: var(--accent);
font-weight: 500;
}
/* Список шагов */
.steps-chips {
display: flex;
flex-wrap: wrap;
gap: 6px;
margin-bottom: 16px;
}
.step-chip {
padding: 5px 11px;
border-radius: 14px;
border: 1px solid var(--border);
background: #fafbfd;
font-size: 12px;
cursor: pointer;
font-family: var(--mono);
}
.step-chip:hover { background: #fff; }
.step-chip.active {
background: var(--accent);
color: #fff;
border-color: var(--accent);
}
.step-order { opacity: 0.6; margin-right: 4px; }
.allowed-next {
display: flex;
flex-wrap: wrap;
gap: 12px;
padding: 10px 12px;
background: #fafbfd;
border: 1px solid var(--border);
border-radius: 6px;
}
.allowed-next label {
display: inline-flex;
align-items: center;
gap: 5px;
font-size: 12px;
font-family: var(--mono);
cursor: pointer;
}
/* Свитч включён/выключен */
.switch {
position: relative;
@@ -386,6 +526,9 @@ const esc = (s) => String(s ?? "").replace(/[&<>"']/g, c => ({'&':'&amp;','<':'&
let intents = [];
let currentIntentCode = null;
let versions = [];
let currentSteps = []; // шаги выбранной ветки (если state machine)
let currentStepCode = null; // выбранный шаг в редакторе
let activeTab = "prompt"; // "prompt" | "steps"
function toast(msg, kind = "ok") {
const t = $("toast");
@@ -472,10 +615,21 @@ async function toggleIntent(code, enabled) {
/* ---------- select intent ---------- */
async function selectIntent(code) {
currentIntentCode = code;
activeTab = "prompt";
currentStepCode = null;
renderIntents();
await refreshSteps(code);
renderEditor();
await refreshVersions(code);
await loadActiveIntoEditor();
}
async function refreshSteps(code) {
try {
const d = await api(`/intents/${encodeURIComponent(code)}/steps`);
currentSteps = d.steps || [];
} catch (_) {
currentSteps = [];
}
}
function renderEditor() {
@@ -486,10 +640,63 @@ function renderEditor() {
return;
}
$("editor-head").textContent = `${intent.name} · редактор`;
const hasSteps = currentSteps.length > 0;
const tabs = hasSteps
? `<div class="editor-tabs">
<button class="editor-tab ${activeTab === 'prompt' ? 'active' : ''}" onclick="switchTab('prompt')">Промпт</button>
<button class="editor-tab ${activeTab === 'steps' ? 'active' : ''}" onclick="switchTab('steps')">Шаги (${currentSteps.length})</button>
</div>`
: "";
let body;
if (hasSteps && activeTab === "steps") {
body = renderStepsPanel();
} else {
body = renderPromptPanel(intent);
}
$("editor").innerHTML = `
<h2>${esc(intent.name)}</h2>
<div class="sub"><code>${esc(intent.code)}</code> · ${esc(intent.description)}</div>
${tabs}
${body}
`;
// Если перешли на вкладку шагов и шаг не выбран — выбираем первый.
if (hasSteps && activeTab === "steps") {
if (!currentStepCode) currentStepCode = currentSteps[0].code;
renderStepEditor();
} else if (activeTab === "prompt") {
loadActiveIntoEditor();
}
}
function switchTab(tab) {
activeTab = tab;
renderEditor();
}
function toggleRulesHint(force) {
const pop = document.getElementById("rules-hint-popover");
const btn = document.getElementById("rules-hint-btn");
if (!pop || !btn) return;
const willShow = typeof force === "boolean" ? force : !pop.classList.contains("show");
pop.classList.toggle("show", willShow);
btn.classList.toggle("active", willShow);
}
// Клик вне 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);
});
function renderPromptPanel(intent) {
return `
<div class="field">
<label for="f-name">Имя версии (необязательно)</label>
<input type="text" id="f-name" placeholder="например: после фидбэка операторов 24.04" maxlength="200">
@@ -499,7 +706,23 @@ function renderEditor() {
<textarea id="f-prompt" class="prompt" spellcheck="false"></textarea>
</div>
<div class="field">
<label for="f-rules">Правила (дополнение к промпту; свободная markdown-форма)</label>
<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>
</label>
<div class="hint-popover" id="rules-hint-popover">
<button type="button" class="hint-close" onclick="toggleRulesHint(false)" aria-label="Закрыть">×</button>
<h4>Что писать в «Правила»</h4>
<p>Точечные дополнения к системному промпту в свободной markdown-форме. Технически склеиваются с основным промптом в один текст для модели — граница условная и нужна для оператора, чтобы не лазать в каркас при правке мелочей.</p>
<p><b>Что нормально писать:</b></p>
<ul>
<li>«Если пациент уже наблюдается у конкретного врача — записывай к нему по умолчанию».</li>
<li>«Бесплатная парковка для пациентов 2 часа — упомяни, если спросят».</li>
<li>«После 19:00 предлагай только следующий рабочий день».</li>
<li>«Дети до 14 лет — сразу <code>[INTENT_CHANGE: escalate_human]</code>, у нас нет педиатра».</li>
</ul>
<p><b>Что сюда не стоит:</b> изменения роли агента, тона или формата ответа — они в основном промпте. Условия выхода (<code>[INTENT_CHANGE: ...]</code>) пока тоже в основном промпте, в Спринте 6a выделим в отдельное поле.</p>
</div>
<textarea id="f-rules" class="rules" spellcheck="false"></textarea>
</div>
<div class="editor-actions">
@@ -510,6 +733,104 @@ function renderEditor() {
`;
}
function renderStepsPanel() {
const chips = currentSteps.map(s => `
<div class="step-chip ${s.code === currentStepCode ? 'active' : ''}"
onclick="selectStep('${esc(s.code)}')">
<span class="step-order">${s.order_index + 1}.</span>${esc(s.code)}
</div>
`).join("");
return `
<div class="steps-chips">${chips}</div>
<div id="step-editor"></div>
`;
}
function renderStepEditor() {
const step = currentSteps.find(s => s.code === currentStepCode);
if (!step) {
$("step-editor").innerHTML = '<div class="mini">Выберите шаг выше.</div>';
return;
}
const otherCodes = currentSteps.map(s => s.code);
const allowedSet = new Set(step.allowed_next || []);
const checkboxes = otherCodes.map(code => `
<label>
<input type="checkbox" value="${esc(code)}" ${allowedSet.has(code) ? 'checked' : ''}>
${esc(code)}
</label>
`).join("");
$("step-editor").innerHTML = `
<div class="field">
<label for="f-step-name">Имя шага</label>
<input type="text" id="f-step-name" maxlength="200" value="${esc(step.name)}">
</div>
<div class="field">
<label for="f-step-prompt">Промпт шага (склеивается с промптом ветки)</label>
<textarea id="f-step-prompt" class="prompt" spellcheck="false">${esc(step.system_prompt)}</textarea>
</div>
<div class="field">
<label>Допустимые переходы (allowed_next)</label>
<div class="allowed-next" id="f-step-allowed">${checkboxes}</div>
</div>
<div class="field">
<label for="f-step-guards">Guards (JSON, наполняется в 6b — пока можно оставить <code>{}</code>)</label>
<textarea id="f-step-guards" class="rules" spellcheck="false">${esc(JSON.stringify(step.guards || {}, null, 2))}</textarea>
</div>
<div class="editor-actions">
<button onclick="saveStep()">Сохранить шаг</button>
<button class="secondary" onclick="selectStep('${esc(step.code)}')">Отменить правки</button>
</div>
`;
}
function selectStep(code) {
currentStepCode = code;
// Rerender chips для подсветки, редактор обновляется отдельно.
const panel = $("editor");
if (!panel) return;
// Перерисуем только секцию шагов.
const chipsRoot = panel.querySelector(".steps-chips");
if (chipsRoot) {
chipsRoot.innerHTML = currentSteps.map(s => `
<div class="step-chip ${s.code === currentStepCode ? 'active' : ''}"
onclick="selectStep('${esc(s.code)}')">
<span class="step-order">${s.order_index + 1}.</span>${esc(s.code)}
</div>
`).join("");
}
renderStepEditor();
}
async function saveStep() {
if (!currentIntentCode || !currentStepCode) return;
const name = $("f-step-name").value.trim();
const system_prompt = $("f-step-prompt").value;
const allowed_next = Array.from($("f-step-allowed").querySelectorAll("input[type=checkbox]:checked"))
.map(cb => cb.value);
let guards = {};
try {
guards = JSON.parse($("f-step-guards").value.trim() || "{}");
if (typeof guards !== "object" || Array.isArray(guards)) throw new Error("JSON должен быть объектом");
} catch (e) {
toast("Guards: невалидный JSON — " + e.message, "err");
return;
}
try {
const r = await api(`/intents/${encodeURIComponent(currentIntentCode)}/steps/${encodeURIComponent(currentStepCode)}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name, system_prompt, allowed_next, guards }),
});
toast(`Шаг ${r.code} сохранён`);
await refreshSteps(currentIntentCode);
renderEditor();
} catch (e) {
toast("Ошибка: " + e.message, "err");
}
}
async function loadActiveIntoEditor() {
if (!currentIntentCode) return;
const intent = intents.find(i => i.code === currentIntentCode);