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:
AR 15 M4
2026-04-27 20:00:44 +05:00
parent f348570b1b
commit 52b46bc53e
43 changed files with 5914 additions and 105 deletions
+449 -2
View File
@@ -294,6 +294,192 @@
.field textarea.prompt { min-height: 300px; }
.field textarea.rules { min-height: 140px; }
/* Сворачиваемый блок промпта — Спринт 7 */
.prompt-block {
border: 1px solid var(--border);
border-radius: 8px;
background: var(--panel);
margin-bottom: 24px;
}
.prompt-block > .prompt-block-summary {
list-style: none;
cursor: pointer;
padding: 12px 16px;
font-size: 14px;
font-weight: 600;
user-select: none;
display: flex;
align-items: center;
gap: 6px;
}
.prompt-block > .prompt-block-summary::-webkit-details-marker { display: none; }
.prompt-block > .prompt-block-summary::before {
content: "▶";
font-size: 10px;
color: var(--muted);
transition: transform 0.15s;
}
.prompt-block[open] > .prompt-block-summary::before { transform: rotate(90deg); }
.prompt-block > .prompt-block-summary:hover { background: #f9fafb; }
.prompt-block[open] > .prompt-block-summary { border-bottom: 1px solid var(--border); }
.prompt-block .pbs-hint { color: var(--muted); font-weight: 400; font-size: 12px; }
.prompt-block > .field,
.prompt-block > .editor-actions { padding-left: 16px; padding-right: 16px; }
.prompt-block > .field:first-of-type { padding-top: 14px; }
.prompt-block > .editor-actions { padding-bottom: 14px; }
/* Тест-вопрос пациента — секция в центре Настроек, Спринт 7 */
.test-query {
border: 1px solid var(--border);
border-radius: 8px;
background: var(--panel);
padding: 14px 16px 16px;
}
.test-query h3 {
margin: 0 0 6px;
font-size: 14px;
font-weight: 600;
display: flex;
align-items: baseline;
gap: 8px;
}
.test-query .tq-meta {
font-weight: 400;
font-size: 12px;
color: var(--muted);
}
.test-query .tq-meta code {
background: var(--chip-bg);
padding: 1px 5px;
border-radius: 3px;
font-family: var(--mono);
font-size: 11.5px;
color: var(--accent);
}
.test-query .tq-rag-note {
font-size: 11.5px;
color: var(--muted);
margin-bottom: 10px;
padding: 6px 10px;
background: #fafbfd;
border-radius: 4px;
}
.test-query textarea {
width: 100%;
min-height: 70px;
padding: 8px 10px;
border: 1px solid var(--border);
border-radius: 6px;
font-size: 13px;
font-family: inherit;
resize: vertical;
}
.test-query .tq-row {
display: flex;
align-items: center;
gap: 14px;
margin: 10px 0 14px;
flex-wrap: wrap;
}
.test-query .tq-row label {
font-size: 12px;
color: var(--muted);
display: inline-flex;
align-items: center;
gap: 6px;
}
.test-query .tq-num {
width: 64px;
padding: 4px 8px;
border: 1px solid var(--border);
border-radius: 5px;
font-size: 13px;
}
.test-query button.primary {
background: var(--accent);
color: #fff;
border: none;
padding: 6px 14px;
border-radius: 6px;
font-size: 13px;
cursor: pointer;
}
.test-query button.primary:hover { background: var(--accent-hover); }
.test-query button.primary:disabled { opacity: 0.6; cursor: not-allowed; }
.test-query .tq-cols {
display: grid;
grid-template-columns: 1fr 1fr 1fr;
gap: 10px;
}
@media (max-width: 1100px) {
.test-query .tq-cols { grid-template-columns: 1fr; }
}
.test-query .tq-col h4 {
margin: 0 0 6px;
font-size: 12px;
font-weight: 600;
color: var(--muted);
text-transform: uppercase;
letter-spacing: 0.04em;
}
.test-query .tq-pane {
min-height: 80px;
max-height: 360px;
overflow-y: auto;
border: 1px solid var(--border);
border-radius: 6px;
background: #fafbfd;
padding: 8px 10px;
font-size: 12.5px;
line-height: 1.5;
}
.test-query .tq-pane pre {
margin: 0;
white-space: pre-wrap;
word-break: break-word;
font-family: var(--mono);
font-size: 11.5px;
line-height: 1.45;
}
.test-query .tq-chunk {
border-bottom: 1px solid var(--border);
padding: 6px 0;
}
.test-query .tq-chunk:first-child { padding-top: 0; }
.test-query .tq-chunk:last-child { border-bottom: none; }
.test-query .tq-chunk-head {
display: flex;
justify-content: space-between;
font-size: 11px;
color: var(--muted);
margin-bottom: 3px;
}
.test-query .tq-score { color: var(--accent); font-weight: 600; }
.test-query .tq-chunk-text { font-size: 12px; }
.test-query .tq-answer-text {
white-space: pre-wrap;
font-size: 13px;
color: var(--fg);
}
.test-query .tq-answer-meta {
margin-top: 8px;
padding-top: 6px;
border-top: 1px solid var(--border);
font-size: 11px;
color: var(--muted);
}
.spinner {
display: inline-block;
width: 11px;
height: 11px;
border: 2px solid var(--border);
border-top-color: var(--accent);
border-radius: 50%;
animation: spin 0.8s linear infinite;
vertical-align: middle;
}
@keyframes spin { to { transform: rotate(360deg); } }
.editor-actions {
display: flex;
gap: 10px;
@@ -465,6 +651,74 @@
cursor: pointer;
}
/* Подписка ветки на документы (Спринт 7) — в правом сайдбаре */
#docs-subscription-counter { color: var(--muted); font-size: 12px; font-weight: normal; }
#docs-subscription-counter b { color: var(--fg); font-weight: 600; }
.ds-list {
display: flex;
flex-direction: column;
border: 1px solid var(--border);
border-radius: 6px;
background: var(--panel);
overflow-y: auto;
max-height: 320px;
}
.ds-item {
display: flex;
align-items: center;
gap: 8px;
padding: 7px 10px;
border-bottom: 1px solid var(--border);
cursor: pointer;
font-size: 12.5px;
line-height: 1.35;
}
.ds-item:last-child { border-bottom: none; }
.ds-item:hover { background: #f9fafb; }
.ds-name {
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.ds-meta {
font-size: 10.5px;
color: var(--muted);
font-family: var(--mono);
flex-shrink: 0;
}
.ds-empty {
padding: 12px;
text-align: center;
color: var(--muted);
font-size: 12px;
}
.ds-actions {
margin-top: 10px;
display: flex;
gap: 6px;
flex-wrap: wrap;
}
.ds-actions button {
padding: 5px 10px;
font-size: 12px;
border: 1px solid var(--border);
background: var(--panel);
border-radius: 5px;
cursor: pointer;
}
.ds-actions button.primary {
background: var(--accent);
color: white;
border-color: var(--accent);
}
.ds-hint {
font-size: 11px;
color: var(--muted);
margin-bottom: 8px;
line-height: 1.4;
}
/* Свитч включён/выключен */
.switch {
position: relative;
@@ -530,6 +784,10 @@
<div class="col-body" id="versions">
<div class="mini">— выберите ветку —</div>
</div>
<div class="col-head" style="border-top:1px solid var(--border);">Документы базы знаний <span id="docs-subscription-counter" style="color:var(--fg);text-transform:none;font-weight:normal;"></span></div>
<div class="col-body" id="docs-subscription-sidebar">
<div class="mini">— выберите ветку —</div>
</div>
</aside>
</main>
@@ -638,6 +896,7 @@ async function selectIntent(code) {
await refreshSteps(code);
renderEditor();
await refreshVersions(code);
loadDocumentsForCurrentIntent();
}
async function refreshSteps(code) {
@@ -724,6 +983,8 @@ document.addEventListener("click", (e) => {
function renderPromptPanel(intent) {
return `
<details class="prompt-block" open>
<summary class="prompt-block-summary">Системный промпт ветки <span class="pbs-hint">— редактирование, версии</span></summary>
<div class="field">
<label for="f-name">Имя версии (необязательно)</label>
<input type="text" id="f-name" placeholder="например: после фидбэка операторов 24.04" maxlength="200">
@@ -776,9 +1037,109 @@ function renderPromptPanel(intent) {
<button class="secondary" onclick="loadActiveIntoEditor()">Перезагрузить активную</button>
<label><input type="checkbox" id="chk-activate"> Сразу сделать активной</label>
</div>
</details>
${renderTestQueryPanel(intent)}
`;
}
function renderTestQueryPanel(intent) {
const isRouter = intent.code === "_router";
const ragHint = isRouter
? '<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}
<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>
</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>
`;
}
async function runTestQuery() {
const intent = intents.find(i => i.code === currentIntentCode);
if (!intent) return;
const text = $("tq-text").value.trim();
if (!text) { toast("Введите вопрос", "err"); return; }
// Собираем черновик промпта из 3 textarea — то, что оператор сейчас видит на экране.
const promptParts = [];
const fp = $("f-prompt"); if (fp && fp.value.trim()) promptParts.push(fp.value.trim());
const fr = $("f-rules"); if (fr && fr.value.trim()) promptParts.push("\n## Правила\n\n" + fr.value.trim());
const fe = $("f-exits"); if (fe && fe.value.trim()) promptParts.push("\n## Условия выхода\n\n" + fe.value.trim());
const draftPrompt = promptParts.join("\n");
const isRouter = intent.code === "_router";
const btn = $("tq-btn");
btn.disabled = true;
$("tq-status").innerHTML = '<span class="spinner"></span> думаю…';
$("tq-chunks").innerHTML = '<div class="mini">…</div>';
$("tq-prompt").innerHTML = '<div class="mini">…</div>';
$("tq-answer").innerHTML = '<div class="mini">…</div>';
try {
const r = await api("/query", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
text,
intent_code: intent.code,
system_prompt: draftPrompt,
disable_rag: isRouter,
top_k: parseInt($("tq-top-k").value, 10) || 5,
temperature: parseFloat($("tq-temp").value),
}),
});
$("tq-chunks").innerHTML = r.sources.length
? r.sources.map((s, i) => `
<div class="tq-chunk">
<div class="tq-chunk-head">
<span>[${i + 1}] ${esc(s.document_name)}${s.section ? " · " + esc(s.section) : ""}</span>
<span class="tq-score">${(s.relevance_score * 100).toFixed(1)}%</span>
</div>
<div class="tq-chunk-text">${esc(s.chunk_text)}</div>
</div>`).join("")
: '<div class="mini">— нет чанков —</div>';
$("tq-prompt").innerHTML = `<pre>${esc(r.assembled_prompt)}</pre>`;
const ragInfo = r.rag_subscription
? `подписано ${r.rag_subscription.subscribed_count}, найдено ${r.rag_subscription.found_count}`
: "";
$("tq-answer").innerHTML = `
<div class="tq-answer-text">${esc(r.answer)}</div>
<div class="tq-answer-meta">модель: ${esc(r.model_used)} · ${ragInfo}</div>
`;
$("tq-status").textContent = "";
} catch (e) {
$("tq-answer").innerHTML = `<div class="mini" style="color:var(--err)">Ошибка: ${esc(e.message)}</div>`;
$("tq-status").textContent = "";
toast("Ошибка: " + e.message, "err");
} finally {
btn.disabled = false;
}
}
function renderStepsPanel() {
const chips = currentSteps.map(s => `
<div class="step-chip ${s.code === currentStepCode ? 'active' : ''}"
@@ -821,7 +1182,7 @@ function renderStepEditor() {
<div class="allowed-next" id="f-step-allowed">${checkboxes}</div>
</div>
<div class="field">
<label for="f-step-guards">Guards (JSON) — условия, блокирующие переход до заполнения нужных слотов. Пример: <code>{"require_legal_rep": {"trigger_slot": "is_child", "trigger_value": true, "required_slots": ["legal_rep_name", "legal_rep_phone"], "description": "..."}}</code></label>
<label for="f-step-guards">Защитные условия (guards, JSON) — блокируют переход на следующий шаг, пока не заполнены нужные слоты. Пример: <code>{"require_legal_rep": {"trigger_slot": "is_child", "trigger_value": true, "required_slots": ["legal_rep_name", "legal_rep_phone"], "description": "..."}}</code></label>
<textarea id="f-step-guards" class="rules" spellcheck="false">${esc(JSON.stringify(step.guards || {}, null, 2))}</textarea>
</div>
<div class="editor-actions">
@@ -860,7 +1221,7 @@ async function saveStep() {
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");
toast("Защитные условия: невалидный JSON — " + e.message, "err");
return;
}
try {
@@ -912,6 +1273,92 @@ function loadIntoEditor(configId) {
window.scrollTo({ top: 0, behavior: "smooth" });
}
/* ---------- docs subscription (Спринт 7, часть A) — в правом сайдбаре ---------- */
async function loadDocumentsForCurrentIntent() {
const sidebar = $("docs-subscription-sidebar");
const counter = $("docs-subscription-counter");
if (!sidebar || !counter) return;
if (!currentIntentCode) {
sidebar.innerHTML = '<div class="mini">— выберите ветку —</div>';
counter.textContent = "";
return;
}
sidebar.innerHTML = '<div class="ds-empty">— загружаю —</div>';
counter.textContent = "";
let allDocs = [];
let subscribedIds = new Set();
try {
const [docsResp, subsResp] = await Promise.all([
api(`/documents`),
api(`/intents/${encodeURIComponent(currentIntentCode)}/documents`),
]);
allDocs = (docsResp.documents || []).slice().sort((a, b) =>
a.name.localeCompare(b.name, "ru")
);
subscribedIds = new Set(subsResp.document_ids || []);
} catch (e) {
sidebar.innerHTML = `<div class="ds-empty" style="color:var(--err)">Ошибка: ${esc(e.message)}</div>`;
return;
}
if (!allDocs.length) {
sidebar.innerHTML = `
<div class="ds-hint">только подписанные документы используются в RAG этой ветки</div>
<div class="ds-empty">Документов пока нет. Загрузите их на странице «Отладка».</div>
`;
counter.innerHTML = "<b>0</b> из <b>0</b>";
return;
}
const items = allDocs.map(d => `
<label class="ds-item">
<input type="checkbox" data-doc-id="${esc(d.document_id)}" ${subscribedIds.has(d.document_id) ? "checked" : ""} onchange="updateDocsCounter()">
<span class="ds-name" title="${esc(d.name)}">${esc(d.name)}</span>
<span class="ds-meta">${d.chunks_count} ч.</span>
</label>
`).join("");
sidebar.innerHTML = `
<div class="ds-hint">только подписанные документы используются в RAG этой ветки</div>
<div class="ds-list" id="docs-subscription-list">${items}</div>
<div class="ds-actions">
<button class="primary" onclick="saveDocumentsForCurrentIntent()">Сохранить</button>
<button onclick="loadDocumentsForCurrentIntent()">Сбросить</button>
</div>
`;
updateDocsCounter();
}
function updateDocsCounter() {
const counter = $("docs-subscription-counter");
const list = $("docs-subscription-list");
if (!counter || !list) return;
const all = list.querySelectorAll('input[type="checkbox"][data-doc-id]');
const checked = list.querySelectorAll('input[type="checkbox"][data-doc-id]:checked');
counter.innerHTML = `<b>${checked.length}</b> из <b>${all.length}</b>`;
}
async function saveDocumentsForCurrentIntent() {
if (!currentIntentCode) return;
const list = $("docs-subscription-list");
if (!list) return;
const document_ids = Array.from(
list.querySelectorAll('input[type="checkbox"][data-doc-id]:checked')
).map(cb => cb.dataset.docId);
try {
const r = await api(`/intents/${encodeURIComponent(currentIntentCode)}/documents`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ document_ids }),
});
toast(`Подписки сохранены: ${r.document_ids.length} документ(ов)`);
updateDocsCounter();
} catch (e) {
toast("Не удалось сохранить подписки: " + e.message, "err");
}
}
/* ---------- versions ---------- */
async function refreshVersions(code) {
const intent = intents.find(i => i.code === code);