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:
+160
-28
@@ -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>
|
||||
`;
|
||||
|
||||
Reference in New Issue
Block a user