feat(sprint4): фундамент графа — intents + роутер + переключение веток
Первый шаг графовой архитектуры из GRAPH_ARCHITECTURE.md. Заменили
«один активный промпт на всё» на «свой промпт на каждую ветку +
роутер выбирает ветку на каждой реплике».
Данные:
- Новая таблица intents (code, name, description, is_enabled,
order_index). Коды с префиксом `_` — системные (не responder).
- В agent_configs добавлен intent_id (nullable, FK SET NULL); убрана
глобальная уникальность version, вместо неё UniqueConstraint
(intent_id, version) — у каждой ветки свой счётчик версий.
- В messages добавлен intent_id (nullable, FK) — фиксируем, какую
ветку выбрал роутер для каждой реплики.
- Миграция cd0a88ef9080 в batch-режиме (SQLite не умеет ALTER для
constraints напрямую).
Сид:
- Стартовые 7 веток: new_booking, reschedule, price_question,
medical_question, general_info, escalate_human + `_router` как
системная ветка для промпта классификатора.
- Для каждой ветки — свой v1-промпт из prompts/intents/{code}.md.
- migrate_legacy_config_to_general_info: старый v1 из Спринта 3
(без intent_id) переносится на general_info с сохранением версии.
- ensure_seed_intents досиживает недостающие коды, существующие не
трогает — безопасно при добавлении новых веток.
Оркестрация и роутер:
- services/router_client.RouterClient — отдельный класс от LLMClient
(под будущую смену модели на более дешёвую). Метод classify(session,
history, text) возвращает {code, version}. Промпт классификатора
подтягивается из активного конфига ветки `_router`, fallback —
prompts/intents/_router.md. При сомнении/ошибке возвращает
general_info.
- services/chat_service.send_message теперь идёт через router.classify
→ берёт активный конфиг выбранной ветки → llm.chat. В сообщения
пишется intent_id, в треде фиксируется начальный agent_config_id.
В ответе — intent_code, intent_name, config_version, router_version.
API:
- GET /intents, GET /intents/{code}, PATCH /intents/{code} —
список веток со счётчиком версий, получение и переключение
is_enabled.
- /configs теперь требует intent_code как Query-параметр
(GET /configs, GET /configs/active) — выборка версий в рамках
ветки. POST /configs принимает intent_id.
- get_thread_detail JOIN-ит Intent — каждая реплика возвращает
intent_code + intent_name.
UI:
- settings.html переработан в 3-колоночный макет: слева список веток
с подгруппой «Системные» для `_router` (пометка «система» вместо
свитча), в центре редактор промпта/правил активной версии выбранной
ветки, справа список версий с активировать/удалить/загрузить.
Каждая ветка редактируется независимо — своя история версий,
своя активная.
- sandbox.html: у каждой реплики бейдж с intent_code, в отладке новый
блок «Решение роутера» (подсвеченный зелёным) с названием ветки,
версией её активного конфига и версией промпта роутера. Старый
«активная: v1» индикатор убран — он больше не имеет смысла (активная
у каждой ветки своя).
E2E проверено: разные реплики уходят в корректные ветки, каждая
отвечает по своему узкому промпту, промпт роутера редактируется в UI
как v2/v3 и откатывается — классификация сразу использует новую
версию.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
+35
-27
@@ -214,6 +214,17 @@
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
.msg.assistant { background: var(--bot-bg); align-self: flex-start; }
|
||||
.msg-intent {
|
||||
display: inline-block;
|
||||
background: var(--chip-bg);
|
||||
color: var(--accent);
|
||||
padding: 1px 7px;
|
||||
border-radius: 10px;
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
font-family: var(--mono);
|
||||
margin-right: 6px;
|
||||
}
|
||||
.msg.assistant p { margin: 0 0 8px 0; }
|
||||
.msg.assistant p:last-child { margin-bottom: 0; }
|
||||
.msg.assistant ul, .msg.assistant ol { margin: 6px 0; padding-left: 22px; }
|
||||
@@ -388,8 +399,7 @@
|
||||
<a href="/sandbox.html" class="nav-link active">Песочница</a>
|
||||
<a href="/settings.html" class="nav-link">Настройки</a>
|
||||
</nav>
|
||||
<span class="active-config" id="active-config" title="Нажмите, чтобы перейти в Настройки" onclick="location.href='/settings.html'"></span>
|
||||
<span class="status"><span class="dot" id="dot"></span><span id="status-text">проверяю…</span></span>
|
||||
<span class="status" style="margin-left:auto;"><span class="dot" id="dot"></span><span id="status-text">проверяю…</span></span>
|
||||
</header>
|
||||
|
||||
<main>
|
||||
@@ -420,6 +430,10 @@
|
||||
<aside class="col-panel">
|
||||
<div class="col-head">Отладка ответа</div>
|
||||
<div class="col-body">
|
||||
<div class="debug-section">
|
||||
<h3>Решение роутера</h3>
|
||||
<div id="debug-router"><div class="mini">— пока пусто —</div></div>
|
||||
</div>
|
||||
<div class="debug-section">
|
||||
<h3>Найденные фрагменты (по последней реплике)</h3>
|
||||
<div id="debug-chunks"><div class="mini">— пока пусто —</div></div>
|
||||
@@ -470,22 +484,6 @@ async function api(path, opts = {}) {
|
||||
return res.json();
|
||||
}
|
||||
|
||||
/* ---------- active config ---------- */
|
||||
async function refreshActiveConfig() {
|
||||
try {
|
||||
const res = await fetch("/configs/active");
|
||||
if (!res.ok) {
|
||||
$("active-config").textContent = "";
|
||||
return;
|
||||
}
|
||||
const c = await res.json();
|
||||
const label = `активная: v${c.version}${c.name ? " · " + c.name : ""}`;
|
||||
$("active-config").textContent = label;
|
||||
} catch (_) {
|
||||
$("active-config").textContent = "";
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------- health ---------- */
|
||||
async function refreshHealth() {
|
||||
try {
|
||||
@@ -545,7 +543,7 @@ async function openThread(id) {
|
||||
$("chat-title").textContent = d.name;
|
||||
renderMessages(d.messages);
|
||||
const lastAssistant = [...d.messages].reverse().find(m => m.role === "assistant");
|
||||
if (lastAssistant) renderDebug(lastAssistant.sources, lastAssistant.assembled_prompt);
|
||||
if (lastAssistant) renderDebug(lastAssistant.sources, lastAssistant.assembled_prompt, lastAssistant.intent_code, lastAssistant.intent_name, null, null);
|
||||
else clearDebug();
|
||||
refreshThreads();
|
||||
} catch (e) {
|
||||
@@ -571,17 +569,18 @@ function renderMessages(messages) {
|
||||
box.innerHTML = messages.map(m => {
|
||||
const isUser = m.role === "user";
|
||||
const body = isUser ? esc(m.text) : renderMd(m.text);
|
||||
const intentBadge = m.intent_code ? `<span class="msg-intent" title="${esc(m.intent_name || m.intent_code)}">${esc(m.intent_code)}</span>` : "";
|
||||
return `
|
||||
<div class="msg ${isUser ? "user" : "assistant"}">
|
||||
<div class="msg-body">${body}</div>
|
||||
<div class="msg-meta">${esc(fmtDate(m.created_at))}</div>
|
||||
<div class="msg-meta">${intentBadge}${esc(fmtDate(m.created_at))}</div>
|
||||
</div>
|
||||
`;
|
||||
}).join("");
|
||||
box.scrollTop = box.scrollHeight;
|
||||
}
|
||||
|
||||
function appendMessage(role, text, iso) {
|
||||
function appendMessage(role, text, iso, intentCode, intentName) {
|
||||
const box = $("chat-messages");
|
||||
const empty = box.querySelector(".chat-empty");
|
||||
if (empty) empty.remove();
|
||||
@@ -589,14 +588,24 @@ function appendMessage(role, text, iso) {
|
||||
const isUser = role === "user";
|
||||
div.className = "msg " + (isUser ? "user" : "assistant");
|
||||
const body = isUser ? esc(text) : renderMd(text);
|
||||
div.innerHTML = `<div class="msg-body">${body}</div><div class="msg-meta">${esc(fmtDate(iso || new Date().toISOString()))}</div>`;
|
||||
const intentBadge = intentCode ? `<span class="msg-intent" title="${esc(intentName || intentCode)}">${esc(intentCode)}</span>` : "";
|
||||
div.innerHTML = `<div class="msg-body">${body}</div><div class="msg-meta">${intentBadge}${esc(fmtDate(iso || new Date().toISOString()))}</div>`;
|
||||
box.appendChild(div);
|
||||
box.scrollTop = box.scrollHeight;
|
||||
return div;
|
||||
}
|
||||
|
||||
/* ---------- отладка ---------- */
|
||||
function renderDebug(sources, prompt) {
|
||||
function renderDebug(sources, prompt, intentCode, intentName, configVersion, routerVersion) {
|
||||
const routerTag = routerVersion != null ? ` · роутер v${routerVersion}` : "";
|
||||
const routerLine = intentCode
|
||||
? `<div style="padding:10px 16px;background:#ecfdf5;font-size:12px;">
|
||||
<div><b>${esc(intentCode)}</b> — ${esc(intentName || '')}${configVersion ? ' · ветка v' + configVersion : ''}</div>
|
||||
${routerVersion != null ? `<div style="color:var(--muted);font-size:11px;margin-top:2px;">классифицировано роутером${routerTag.replace(' · роутер', '')}</div>` : ''}
|
||||
</div>`
|
||||
: "";
|
||||
$("debug-router").innerHTML = routerLine || '<div class="mini">— маршрутизация пока не выполнена —</div>';
|
||||
|
||||
if (sources && sources.length) {
|
||||
$("debug-chunks").innerHTML = sources.map(s => `
|
||||
<div class="chunk-card">
|
||||
@@ -617,6 +626,7 @@ function renderDebug(sources, prompt) {
|
||||
}
|
||||
|
||||
function clearDebug() {
|
||||
$("debug-router").innerHTML = '<div class="mini">— пока пусто —</div>';
|
||||
$("debug-chunks").innerHTML = '<div class="mini">— пока пусто —</div>';
|
||||
$("debug-prompt").innerHTML = '<div class="mini">— пока пусто —</div>';
|
||||
}
|
||||
@@ -655,10 +665,10 @@ async function sendMessage() {
|
||||
});
|
||||
activeThreadId = r.thread_id;
|
||||
pending.remove();
|
||||
appendMessage("assistant", r.answer);
|
||||
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);
|
||||
renderDebug(r.sources, r.assembled_prompt, r.intent_code, r.intent_name, r.config_version, r.router_version);
|
||||
refreshThreads();
|
||||
} catch (e) {
|
||||
pending.remove();
|
||||
@@ -703,10 +713,8 @@ async function deleteThread(id, name) {
|
||||
|
||||
/* ---------- init ---------- */
|
||||
refreshHealth();
|
||||
refreshActiveConfig();
|
||||
refreshThreads();
|
||||
setInterval(refreshHealth, 15000);
|
||||
setInterval(refreshActiveConfig, 15000);
|
||||
</script>
|
||||
|
||||
</body>
|
||||
|
||||
Reference in New Issue
Block a user