feat(sprint6c+sprint7): терминология, сверка примеров с кодом, мульти-RAG (часть A)
Спринт 6c — терминология и сверка документации с реальным кодом:
- Словарь терминов в static/docs.html: «маршрутизатор» вместо «роутер»,
«защитное условие» вместо «guard», «пошаговая ветка» вместо «многошаговая».
Разделены концепты «намерение» (intent) и «ветка» (branch) с пометкой,
что в коде они хранятся как одна сущность 1:1.
- Песочница: «Решение маршрутизатора» виден всегда (зелёный/жёлтый),
счётчик переключений «N из 3» отдельной плашкой, бейджи под словарь.
- Настройки: «Условия перехода» → «Защитные условия (guards, JSON)».
- GRAPH_ARCHITECTURE_v4.md: имена полей thread_state и слоты приведены
к реальной БД (db/models/thread_state.py) и таксономии промптов шагов
(prompts/intents/new_booking/steps/). Ссылки на *_v2 примеры. На v3
поставлена шапка «устарело».
- 4 примера переписаны как *_v2: реальные current_intent_code/
current_step_code/slots_json, реальные allowed_next без двойных переходов,
реальная таксономия слотов name/reason/specialist/preferred_time/confirmed.
Удалены вымышленные CRM tool calls и слоты, которых нет в коде.
- static/example.html — параметризованная страница с навигацией между
4 примерами; роут GET /api/docs/examples/{name} в main.py отдаёт
markdown без дублирования файлов.
- Редактирование документов в Отладке: GET/PUT /documents/{id}/raw,
textarea с переразметкой и обновлением Chroma при сохранении.
Спринт 7, часть A — мульти-RAG через подписку ветка↔документы:
- Миграция: таблица intent_documents (M:N), модель IntentDocument,
индекс по document_id для обратного поиска.
- API: GET/PUT /intents/{code}/documents и GET/PUT /documents/{id}/intents
с PUT-семантикой «полный список», атомарно. Сервис
services/intent_document_service.py.
- Retrieval-фильтр в chat_service: подтягивает document_ids активной
ветки и передаёт в vectorstore.query(). Дефолт пустой подписки —
document_ids=[] (= 0 чанков), не «вся коллекция»: пустая подписка
означает «ветка не настроена», подмешивать случайное хуже, чем
ничего. vectorstore.query() различает None (нет фильтра) и [] (0).
- UI Настроек: блок «Документы базы знаний» в правом сайдбаре,
всегда видим независимо от вкладки, сортировка по имени, счётчик
«N из M», PUT при сохранении.
- UI Отладки: третья кнопка «привязка» рядом с «удалить» —
раскрывашка со списком веток (галочки), быстрая привязка прямо
на странице загрузки.
- Песочница: блок «Срез RAG» с подпиской/найдено, ворнинг при пустой
подписке. Поле rag_subscription в QueryResponse и ChatResponse.
- Системный промпт страницы Отладки переехал в обычную ветку _debug
(«Страница отладки»). Удалён prompts/system_prompt.md и логика
DEFAULT_SYSTEM_PROMPT в llm_client. routers/query.py подтягивает
активный конфиг ветки _debug и её подписки. Дефолт пустой подписки
для _debug — None (вся коллекция), не [] как для пациентских — чтобы
Отладка работала «из коробки». На странице Отладки info-bar показывает
активную версию и счётчик подписок, ссылка → Настройки.
- Тест-блок «Тест-вопрос» в центре Настроек: расширил /query
параметрами intent_code (default _debug), system_prompt (override
для теста черновика из textarea), disable_rag (для _router).
Редактор промпта обёрнут в <details open> — можно свернуть до
одной строки. Под ним — три колонки результата (RAG / промпт /
ответ). Для _router показывается подсказка про отсутствие RAG.
Документы:
- data/datasets/*.md — наработки по 6 веткам (рабочие материалы оператора).
- docs/BRANCH_MAP_AND_PROMPTS_v1.md, docs/OPTIMIZATION_CONVERSION_v1.md,
docs/guides/state_machine_and_slots.md.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
+74
-26
@@ -272,13 +272,23 @@
|
||||
background: #eef2ff;
|
||||
color: #3730a3;
|
||||
}
|
||||
/* Роутер предложил */
|
||||
/* Решение маршрутизатора */
|
||||
.msg-router {
|
||||
background: #f3f4f6;
|
||||
color: #4b5563;
|
||||
border: 1px solid #e5e7eb;
|
||||
}
|
||||
.msg-router .badge-val { font-family: var(--mono); }
|
||||
.msg-router.router-matches {
|
||||
background: #ecfdf5;
|
||||
color: #065f46;
|
||||
border-color: #a7f3d0;
|
||||
}
|
||||
.msg-router.router-differs {
|
||||
background: #fffbeb;
|
||||
color: #78350f;
|
||||
border-color: #fde68a;
|
||||
}
|
||||
/* События */
|
||||
.msg-event {
|
||||
font-weight: 500;
|
||||
@@ -570,10 +580,14 @@
|
||||
<div id="debug-state"><div class="mini">— пока пусто —</div></div>
|
||||
</div>
|
||||
<div class="debug-section">
|
||||
<h3>Решение роутера</h3>
|
||||
<h3>Решение маршрутизатора</h3>
|
||||
<div id="debug-router"><div class="mini">— пока пусто —</div></div>
|
||||
</div>
|
||||
<div class="debug-section" id="debug-operator-summary" style="display:none;background:#fff1f2;border-radius:6px;padding:10px 14px;font-size:12px;"></div>
|
||||
<div class="debug-section">
|
||||
<h3>Срез RAG</h3>
|
||||
<div id="debug-rag"><div class="mini">— пока пусто —</div></div>
|
||||
</div>
|
||||
<details class="debug-section collapsible" id="debug-chunks-section">
|
||||
<summary>
|
||||
<span>Найденные фрагменты</span>
|
||||
@@ -691,7 +705,7 @@ async function openThread(id) {
|
||||
const lastAssistant = [...d.messages].reverse().find(m => m.role === "assistant");
|
||||
const lastEscalation = [...d.messages].reverse().find(m => m.role === "assistant" && m.escalation_reason);
|
||||
if (lastAssistant) {
|
||||
renderDebug(lastAssistant.sources, lastAssistant.assembled_prompt, lastAssistant.intent_code, lastAssistant.intent_name, null, null, null, [], d.thread_state && d.thread_state.current_step_code, null, null);
|
||||
renderDebug(lastAssistant.sources, lastAssistant.assembled_prompt, lastAssistant.intent_code, lastAssistant.intent_name, null, null, null, [], d.thread_state && d.thread_state.current_step_code, null, null, lastAssistant.rag_subscription || (lastAssistant.meta && lastAssistant.meta.rag_subscription) || null);
|
||||
renderState(d.thread_state, [], [], null, false, false, lastEscalation ? lastEscalation.escalation_reason : null);
|
||||
} else {
|
||||
clearDebug();
|
||||
@@ -712,23 +726,23 @@ function startNewThread() {
|
||||
}
|
||||
|
||||
const EVENT_LABELS = {
|
||||
sticky: { label: "решение:", text: "удержались в ветке", title: "роутер предлагал другую ветку — модель осталась в текущем сценарии" },
|
||||
sticky: { label: "решение:", text: "удержались в ветке", title: "маршрутизатор предлагал другую ветку — модель осталась в текущем сценарии" },
|
||||
hard_handoff: { label: "решение:", text: "переключили ветку", title: "ветка выдала [INTENT_CHANGE] и передала диалог другой ветке" },
|
||||
soft_insertion: { label: "тип ответа:", text: "боковой вопрос", title: "модель ответила на побочный вопрос, не продвигая сценарий (шаг не изменился)" },
|
||||
resumed: { label: "решение:", text: "восстановили сценарий",title: "вернулись в ранее приостановленный сценарий со всеми слотами" },
|
||||
routing_loop: { label: "защита:", text: "петля роутера", title: "сработала защита от петли: диалог автоматически передан оператору" },
|
||||
validation_blocked:{ label: "валидатор:", text: "переход отклонён", title: "валидатор guard заблокировал переход на запрошенный шаг" },
|
||||
routing_loop: { label: "защита:", text: "петля маршрутизатора", title: "сработала защита от петли: диалог автоматически передан оператору" },
|
||||
validation_blocked:{ label: "валидатор:", text: "переход отклонён", title: "защитное условие заблокировало переход на запрошенный шаг" },
|
||||
};
|
||||
|
||||
function renderAssistantBadges(intentCode, intentName, meta) {
|
||||
// Ветка
|
||||
// Активная ветка
|
||||
const displayName = (intentName && intentName !== intentCode) ? intentName : intentCode;
|
||||
const smTag = (meta && (meta.is_state_machine || meta.step_code))
|
||||
? `<span class="badge-sm-tag">многошаговая</span>` : "";
|
||||
? `<span class="badge-sm-tag">пошаговая</span>` : "";
|
||||
const codeHint = (intentName && intentName !== intentCode)
|
||||
? `<span class="badge-sub">(${esc(intentCode)})</span>` : "";
|
||||
const intent = intentCode
|
||||
? `<span class="msg-badge msg-intent" title="Ветка: ${esc(intentName || intentCode)}"><span class="badge-label">ветка:</span><span class="badge-val">${esc(displayName)}</span>${codeHint}${smTag}</span>`
|
||||
? `<span class="msg-badge msg-intent" title="Активная ветка: ${esc(intentName || intentCode)}"><span class="badge-label">активная ветка:</span><span class="badge-val">${esc(displayName)}</span>${codeHint}${smTag}</span>`
|
||||
: "";
|
||||
|
||||
if (!meta) return intent;
|
||||
@@ -738,13 +752,17 @@ function renderAssistantBadges(intentCode, intentName, meta) {
|
||||
const stepSub = meta.step_name && meta.step_code
|
||||
? `<span class="badge-sub">(${esc(meta.step_code)})</span>` : "";
|
||||
const stepBadge = meta.step_code
|
||||
? `<span class="msg-badge msg-step" title="Текущий шаг сценария"><span class="badge-label">шаг:</span><span class="badge-val">${esc(stepDisplay)}</span>${stepSub}</span>`
|
||||
? `<span class="msg-badge msg-step" title="Текущий шаг пошаговой ветки"><span class="badge-label">шаг ветки:</span><span class="badge-val">${esc(stepDisplay)}</span>${stepSub}</span>`
|
||||
: "";
|
||||
|
||||
// Роутер предложил другую ветку
|
||||
const routerDiffers = meta.router_intent_code && meta.router_intent_code !== meta.served_intent_code;
|
||||
const router = routerDiffers
|
||||
? `<span class="msg-badge msg-router" title="Роутер классифицировал реплику в другую ветку, но модель осталась здесь"><span class="badge-label">роутер предложил:</span><span class="badge-val">${esc(meta.router_intent_code)}</span></span>`
|
||||
// Решение маршрутизатора — показываем ВСЕГДА (даже при совпадении с активной веткой)
|
||||
const routerCode = meta.router_intent_code;
|
||||
const routerDiffers = routerCode && routerCode !== meta.served_intent_code;
|
||||
const routerTitle = routerDiffers
|
||||
? "Маршрутизатор классифицировал реплику в другую ветку, но модель осталась здесь (удержание в ветке или возврат из отложенного сценария)"
|
||||
: "Маршрутизатор подтвердил активную ветку";
|
||||
const router = routerCode
|
||||
? `<span class="msg-badge msg-router${routerDiffers ? ' router-differs' : ' router-matches'}" title="${esc(routerTitle)}"><span class="badge-label">решение маршрутизатора:</span><span class="badge-val">${esc(routerCode)}</span></span>`
|
||||
: "";
|
||||
|
||||
// События
|
||||
@@ -802,11 +820,17 @@ function renderState(state, bounces, validationEvents, parseError, routingLoopTr
|
||||
return;
|
||||
}
|
||||
const handoff = Number(state.handoff_count || 0);
|
||||
const HANDOFF_CAP = 3;
|
||||
const softCount = Number(state.soft_insertion_count || 0);
|
||||
const SOFT_CAP = 3;
|
||||
const handoffWarn = handoff >= HANDOFF_CAP;
|
||||
const handoffHtml = `
|
||||
<div style="margin-top:6px;font-size:11px;color:var(--muted);">
|
||||
переключений ветки в диалоге: <b style="color:var(--fg);">${handoff}</b>${state.current_step_code ? ` · боковых вопросов подряд: <b style="color:var(--fg);">${softCount}</b>` : ''}
|
||||
<div style="margin-top:8px;display:flex;align-items:center;gap:8px;font-size:11px;">
|
||||
<span style="color:var(--muted);">Переключений:</span>
|
||||
<span style="display:inline-flex;align-items:center;gap:4px;padding:2px 8px;border-radius:10px;background:${handoffWarn ? '#fee2e2' : '#eef2ff'};color:${handoffWarn ? '#7f1d1d' : '#3730a3'};font-weight:600;">
|
||||
${handoff} из ${HANDOFF_CAP}
|
||||
</span>
|
||||
${state.current_step_code ? `<span style="color:var(--muted);">· боковых вопросов подряд: <b style="color:var(--fg);">${softCount}</b></span>` : ''}
|
||||
</div>`;
|
||||
const softNudgeHtml = (state.current_step_code && softCount >= SOFT_CAP)
|
||||
? `<div style="margin-top:8px;padding:6px 8px;border-radius:4px;background:#fef3c7;color:#78350f;font-size:11px;">
|
||||
@@ -816,7 +840,7 @@ function renderState(state, bounces, validationEvents, parseError, routingLoopTr
|
||||
const pendingGuard = state.pending_guard;
|
||||
const pendingGuardHtml = pendingGuard
|
||||
? `<div style="margin-top:8px;padding:6px 8px;border-radius:4px;background:#fef3c7;color:#78350f;font-size:11px;">
|
||||
🔒 <b>guard активен: ${esc(pendingGuard.guard_name)}</b> — ждём заполнения: ${(pendingGuard.missing_slots || []).map(s => `<code>${esc(s)}</code>`).join(", ")}.<br>
|
||||
🔒 <b>защитное условие активно: ${esc(pendingGuard.guard_name)}</b> — ждём заполнения: ${(pendingGuard.missing_slots || []).map(s => `<code>${esc(s)}</code>`).join(", ")}.<br>
|
||||
<span style="opacity:.75;">${esc(pendingGuard.description || "")}</span>
|
||||
</div>`
|
||||
: "";
|
||||
@@ -825,7 +849,7 @@ function renderState(state, bounces, validationEvents, parseError, routingLoopTr
|
||||
surgery: "операция / хирургия / стационар",
|
||||
angry: "пациент раздражён",
|
||||
explicit_request: "запросил оператора",
|
||||
routing_loop: "автоматически (петля роутера)",
|
||||
routing_loop: "автоматически (петля маршрутизатора)",
|
||||
};
|
||||
const loopHtml = routingLoopTriggered
|
||||
? `<div style="margin-top:8px;padding:6px 8px;border-radius:4px;background:#fee2e2;color:#7f1d1d;font-size:11px;">
|
||||
@@ -861,7 +885,7 @@ function renderState(state, bounces, validationEvents, parseError, routingLoopTr
|
||||
${validationEvents.map(v => {
|
||||
if (v.guard_name) {
|
||||
const missing = (v.missing_slots || []).map(s => `<code>${esc(s)}</code>`).join(", ");
|
||||
return `🔒 guard <b>${esc(v.guard_name)}</b> не пройден — ждём: ${missing}.<br><span style="opacity:.75">${esc(v.guard_description || "")}</span>`;
|
||||
return `🔒 защитное условие <b>${esc(v.guard_name)}</b> не пройдено — ждём: ${missing}.<br><span style="opacity:.75">${esc(v.guard_description || "")}</span>`;
|
||||
}
|
||||
return `⚠️ модель просилась в <code>${esc(v.requested_step)}</code>, оставили на <code>${esc(v.current_step)}</code>. ${esc(v.reason)}`;
|
||||
}).join("<br>")}
|
||||
@@ -898,8 +922,8 @@ function renderState(state, bounces, validationEvents, parseError, routingLoopTr
|
||||
`;
|
||||
}
|
||||
|
||||
function renderDebug(sources, prompt, intentCode, intentName, configVersion, routerVersion, routerIntentCode, bounces, stepCode, operatorSummary, routerPrompt) {
|
||||
const routerVer = routerVersion != null ? `роутер v${routerVersion}` : "роутер";
|
||||
function renderDebug(sources, prompt, intentCode, intentName, configVersion, routerVersion, routerIntentCode, bounces, stepCode, operatorSummary, routerPrompt, ragSubscription) {
|
||||
const routerVer = routerVersion != null ? `маршрутизатор v${routerVersion}` : "маршрутизатор";
|
||||
const hasBounces = bounces && bounces.length > 0;
|
||||
const routerDiffers = routerIntentCode && intentCode && routerIntentCode !== intentCode;
|
||||
|
||||
@@ -913,22 +937,22 @@ function renderDebug(sources, prompt, intentCode, intentName, configVersion, rou
|
||||
Ветка сама выдала <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} → та же ветка.
|
||||
${routerVer} → активная ветка совпадает с решением.
|
||||
</div>`;
|
||||
}
|
||||
|
||||
const routerPromptHtml = routerPrompt
|
||||
? `<details style="margin-top:6px;">
|
||||
<summary style="font-size:11px;color:var(--muted);cursor:pointer;">промпт роутера</summary>
|
||||
<summary style="font-size:11px;color:var(--muted);cursor:pointer;">промпт маршрутизатора</summary>
|
||||
<div class="prompt-box" style="margin-top:4px;max-height:300px;">${esc(routerPrompt)}</div>
|
||||
</details>`
|
||||
: "";
|
||||
@@ -941,6 +965,30 @@ function renderDebug(sources, prompt, intentCode, intentName, configVersion, rou
|
||||
: "";
|
||||
$("debug-router").innerHTML = routerLine || '<div class="mini">— маршрутизация пока не выполнена —</div>';
|
||||
|
||||
// Срез RAG: видно сколько документов подписано на активную ветку и сколько чанков пришло.
|
||||
const ragBox = $("debug-rag");
|
||||
if (ragBox) {
|
||||
if (ragSubscription) {
|
||||
const sub = Number(ragSubscription.subscribed_count || 0);
|
||||
const found = Number(ragSubscription.found_count || 0);
|
||||
const intentLabel = intentCode ? `<code>${esc(intentCode)}</code>` : "—";
|
||||
let warn = "";
|
||||
if (sub === 0) {
|
||||
warn = `<div style="margin-top:6px;padding:6px 8px;border-radius:4px;background:#fef3c7;color:#78350f;font-size:11px;">
|
||||
⚠️ у ветки нет подписок — RAG-контекст пустой. Подписать документы можно в «Настройки» → ${intentLabel} или в «Отладка» рядом с документом.
|
||||
</div>`;
|
||||
}
|
||||
ragBox.innerHTML = `
|
||||
<div style="font-size:12px;">
|
||||
подписано <b style="color:var(--fg);">${sub}</b> документ(ов) на ветку ${intentLabel} · в этой реплике пришло <b style="color:var(--fg);">${found}</b> чанк(ов)
|
||||
</div>
|
||||
${warn}
|
||||
`;
|
||||
} else {
|
||||
ragBox.innerHTML = '<div class="mini">— пока пусто —</div>';
|
||||
}
|
||||
}
|
||||
|
||||
const count = $("debug-chunks-count");
|
||||
if (sources && sources.length) {
|
||||
count.textContent = sources.length;
|
||||
@@ -1029,7 +1077,7 @@ async function sendMessage() {
|
||||
appendMessage("assistant", r.answer, null, r.intent_code, r.intent_name, r.message_meta);
|
||||
$("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, r.bounces, r.thread_state && r.thread_state.current_step_code, r.operator_summary, r.router_assembled_prompt);
|
||||
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, r.operator_summary, r.router_assembled_prompt, r.rag_subscription);
|
||||
renderState(r.thread_state, r.bounces, r.validation_events, r.parse_error, r.routing_loop_triggered, r.resumed_from_suspended, r.escalation_reason);
|
||||
refreshThreads();
|
||||
} catch (e) {
|
||||
|
||||
Reference in New Issue
Block a user