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:
+324
-3
@@ -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 => ({'&':'&','<':'&
|
||||
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);
|
||||
|
||||
Reference in New Issue
Block a user