feat(sprint7.7): версионирование графа шагов в БД + UI переключения

Хранить старый 6-шаговый сценарий new_booking параллельно с новым 4-шаговым,
чтобы оператор мог откатиться или сравнить варианты. Активный граф ровно один,
переключение через UI «Настройки → Шаги».

Модель и миграция:
- Таблица intent_step_graphs (id, intent_id, version, name, is_active, created_at).
- intent_steps.graph_id FK + UNIQUE сменён с (intent_id, code) на (graph_id, code).
- Alembic-миграция j6d8c4b56g23 (batch_alter_table для SQLite).

Сервис и сидинг (services/intent_step_graph_service.py):
- ensure_seed_graphs идемпотентен: создаёт активный граф для каждой ветки,
  привязывает существующие шаги (graph_id IS NULL), для new_booking
  восстанавливает архивный v1 из _archived_v1/*.md и _PRE_SPRINT_7_6_ALLOWED_NEXT.
- Активный граф new_booking сжат до 4 шагов: deprecated present/offer_time
  удаляются из активного, остаются только в архивном v1.
- list_graphs / get_active_graph / set_active_graph.

API:
- GET /intents/{code}/step-graphs — список с steps_count и is_active.
- POST /intents/{code}/step-graphs/{graph_id}/activate.
- list_steps_for_intent / get_step_by_code / get_first_step фильтруют
  по активному графу через _active_graph_filter (join с intent_step_graphs).
- ensure_seed_guards и migrate_new_booking_allowed_next_v2 защищены: не
  трогают шаги архивных графов.

UI (static/settings.html):
- Во вкладке «Шаги» вверху блок «Версии графа шагов»: карточки с именем,
  кол-вом шагов, бейджем «активная» или кнопкой «Активировать». При
  переключении заголовок меняется «Шаги (4)» ↔ «Шаги (6)».
- Раздел «Тест-вопрос от пациента» сделан сворачиваемым (details/summary
  в стиле prompt-block).

Промпты архивного графа восстановлены из коммита 60f8a7b^ в
prompts/intents/new_booking/steps/_archived_v1/*.md.

SPRINTS.md: Спринт 7.7 →  Закрыт.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
AR 15 M4
2026-05-02 14:29:07 +05:00
parent 60f8a7b398
commit a79b6f9d05
18 changed files with 799 additions and 65 deletions
+160 -28
View File
@@ -335,6 +335,12 @@
background: var(--panel);
padding: 14px 16px 16px;
}
/* В сворачиваемой обёртке (Спринт 7.7) внешняя рамка/фон не нужны — их даёт .prompt-block */
.test-query-block .test-query {
border: 0;
border-radius: 0;
background: transparent;
}
.test-query h3 {
margin: 0 0 6px;
font-size: 14px;
@@ -643,6 +649,75 @@
font-weight: 500;
}
/* Версии графа шагов (Спринт 7.7) */
.graph-versions-block {
margin-bottom: 16px;
border: 1px solid var(--border);
border-radius: 8px;
background: #fff;
}
.graph-versions-summary {
list-style: none;
cursor: pointer;
padding: 10px 14px;
font-weight: 600;
font-size: 14px;
position: relative;
padding-left: 32px;
}
.graph-versions-summary::-webkit-details-marker { display: none; }
.graph-versions-summary::before {
content: "▶";
position: absolute;
left: 14px;
transition: transform 0.15s;
font-size: 10px;
}
.graph-versions-block[open] .graph-versions-summary::before { transform: rotate(90deg); }
.graph-versions-block[open] .graph-versions-summary { border-bottom: 1px solid var(--border); }
.graph-versions-list {
display: flex;
flex-direction: column;
gap: 8px;
padding: 12px 14px;
}
.graph-card {
border: 1px solid var(--border);
border-radius: 6px;
padding: 10px 12px;
background: #fafbfd;
}
.graph-card.active {
border-color: var(--accent);
background: #f3f6ff;
}
.graph-card-head {
display: flex;
justify-content: space-between;
align-items: center;
gap: 12px;
}
.graph-card-name { font-weight: 600; font-size: 13px; }
.graph-card-meta { color: var(--muted); font-size: 12px; margin-top: 4px; font-family: var(--mono); }
.graph-badge.active {
font-size: 11px;
padding: 2px 8px;
border-radius: 10px;
background: #dcfce7;
color: #166534;
font-weight: 600;
}
.graph-activate {
font-size: 12px;
padding: 4px 10px;
border: 1px solid var(--accent);
background: #fff;
color: var(--accent);
border-radius: 4px;
cursor: pointer;
}
.graph-activate:hover { background: var(--accent); color: #fff; }
/* Список шагов */
.steps-chips {
display: flex;
@@ -837,6 +912,7 @@ let currentIntentCode = null;
let versions = [];
let currentSteps = []; // шаги выбранной ветки (если state machine)
let currentStepCode = null; // выбранный шаг в редакторе
let currentStepGraphs = []; // версии графа шагов выбранной ветки (Спринт 7.7)
let activeTab = "prompt"; // "prompt" | "steps"
function toast(msg, kind = "ok") {
@@ -941,6 +1017,31 @@ async function refreshSteps(code) {
} catch (_) {
currentSteps = [];
}
await refreshStepGraphs(code);
}
async function refreshStepGraphs(code) {
try {
const d = await api(`/intents/${encodeURIComponent(code)}/step-graphs`);
currentStepGraphs = d.graphs || [];
} catch (_) {
currentStepGraphs = [];
}
}
async function activateStepGraph(graphId) {
if (!currentIntentCode) return;
try {
await api(`/intents/${encodeURIComponent(currentIntentCode)}/step-graphs/${graphId}/activate`, {
method: "POST",
});
toast("Версия графа шагов активирована");
currentStepCode = null;
await refreshSteps(currentIntentCode);
renderEditor();
} catch (e) {
toast("Ошибка: " + e.message, "err");
}
}
function renderEditor() {
@@ -1083,38 +1184,40 @@ function renderTestQueryPanel(intent) {
? '<div class="tq-rag-note">У маршрутизатора нет RAG — тест идёт без чанков.</div>'
: '<div class="tq-rag-note">Промпт берётся из черновика выше (даже если он не сохранён). Подписки на документы — те, что сохранены в правом сайдбаре.</div>';
return `
<div class="test-query">
<h3>Тест-вопрос от пациента <span class="tq-meta">— ветка <code>${esc(intent.code)}</code></span></h3>
${ragHint}
<div class="tq-cases" id="tq-cases-bar" style="display:none;">
<span class="tq-cases-label">Готовый кейс:</span>
<input list="tq-cases-list" id="tq-cases-input" placeholder="— выбрать или начать вводить —" autocomplete="off">
<datalist id="tq-cases-list"></datalist>
<button type="button" class="tq-cases-btn" onclick="pickRandomCase()">🎲 Случайный</button>
<span class="tq-cases-count" id="tq-cases-count"></span>
</div>
<textarea id="tq-text" placeholder="Например: где вы находитесь?"></textarea>
<div class="tq-row">
<label>top_k <input type="number" class="tq-num" id="tq-top-k" value="5" min="1" max="20"></label>
<label>temperature <input type="number" class="tq-num" id="tq-temp" value="0.2" min="0" max="2" step="0.1"></label>
<button class="primary" id="tq-btn" onclick="runTestQuery()">Отправить</button>
<span id="tq-status" class="mini"></span>
</div>
<div class="tq-cols">
<div class="tq-col">
<h4>Что нашёл RAG</h4>
<div id="tq-chunks" class="tq-pane"><div class="mini">— пока пусто —</div></div>
<details class="prompt-block test-query-block">
<summary class="prompt-block-summary">Тест-вопрос от пациента <span class="pbs-hint">— ветка <code>${esc(intent.code)}</code></span></summary>
<div class="test-query">
${ragHint}
<div class="tq-cases" id="tq-cases-bar" style="display:none;">
<span class="tq-cases-label">Готовый кейс:</span>
<input list="tq-cases-list" id="tq-cases-input" placeholder="— выбрать или начать вводить —" autocomplete="off">
<datalist id="tq-cases-list"></datalist>
<button type="button" class="tq-cases-btn" onclick="pickRandomCase()">🎲 Случайный</button>
<span class="tq-cases-count" id="tq-cases-count"></span>
</div>
<div class="tq-col">
<h4>Собранный промпт</h4>
<div id="tq-prompt" class="tq-pane"><div class="mini">— пока пусто —</div></div>
<textarea id="tq-text" placeholder="Например: где вы находитесь?"></textarea>
<div class="tq-row">
<label>top_k <input type="number" class="tq-num" id="tq-top-k" value="5" min="1" max="20"></label>
<label>temperature <input type="number" class="tq-num" id="tq-temp" value="0.2" min="0" max="2" step="0.1"></label>
<button class="primary" id="tq-btn" onclick="runTestQuery()">Отправить</button>
<span id="tq-status" class="mini"></span>
</div>
<div class="tq-col">
<h4>Ответ агента</h4>
<div id="tq-answer" class="tq-pane"><div class="mini">— пока пусто —</div></div>
<div class="tq-cols">
<div class="tq-col">
<h4>Что нашёл RAG</h4>
<div id="tq-chunks" class="tq-pane"><div class="mini">— пока пусто —</div></div>
</div>
<div class="tq-col">
<h4>Собранный промпт</h4>
<div id="tq-prompt" class="tq-pane"><div class="mini">— пока пусто —</div></div>
</div>
<div class="tq-col">
<h4>Ответ агента</h4>
<div id="tq-answer" class="tq-pane"><div class="mini">— пока пусто —</div></div>
</div>
</div>
</div>
</div>
</details>
`;
}
@@ -1241,6 +1344,34 @@ async function runTestQuery() {
}
}
function renderStepGraphsBlock() {
if (!currentStepGraphs.length) return "";
const cards = currentStepGraphs.map(g => `
<div class="graph-card ${g.is_active ? 'active' : ''}">
<div class="graph-card-head">
<span class="graph-card-name">${esc(g.name)}</span>
${g.is_active
? '<span class="graph-badge active">активная</span>'
: `<button class="graph-activate" onclick="activateStepGraph(${g.id})">Активировать</button>`}
</div>
<div class="graph-card-meta">v${g.version} · ${g.steps_count} ${pluralSteps(g.steps_count)}</div>
</div>
`).join("");
return `
<details class="graph-versions-block" open>
<summary class="graph-versions-summary">Версии графа шагов <span class="pbs-hint">— активная используется в чате и Песочнице</span></summary>
<div class="graph-versions-list">${cards}</div>
</details>
`;
}
function pluralSteps(n) {
const m10 = n % 10, m100 = n % 100;
if (m10 === 1 && m100 !== 11) return "шаг";
if (m10 >= 2 && m10 <= 4 && (m100 < 12 || m100 > 14)) return "шага";
return "шагов";
}
function renderStepsPanel() {
const chips = currentSteps.map(s => `
<div class="step-chip ${s.code === currentStepCode ? 'active' : ''}"
@@ -1249,6 +1380,7 @@ function renderStepsPanel() {
</div>
`).join("");
return `
${renderStepGraphsBlock()}
<div class="steps-chips">${chips}</div>
<div id="step-editor"></div>
`;