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
+259 -2
View File
@@ -136,6 +136,82 @@
tr.doc-row.open .arrow { transform: rotate(90deg); }
tr.chunks-row td { padding: 0; background: #fafbfd; }
tr.chunks-row .chunks-body { padding: 14px 16px; }
tr.intents-row td { padding: 0; background: #fef3c7; }
tr.intents-row .intents-body { padding: 12px 16px; }
tr.editor-row td { padding: 0; background: #eff6ff; }
tr.editor-row .editor-body { padding: 12px 16px; }
.editor-body .eb-hint {
font-size: 11.5px;
color: var(--muted);
margin-bottom: 6px;
}
.editor-body textarea.eb-textarea {
width: 100%;
min-height: 360px;
padding: 10px 12px;
border: 1px solid var(--border);
border-radius: 6px;
font-family: var(--mono);
font-size: 12px;
line-height: 1.5;
resize: vertical;
background: white;
}
.editor-body .eb-actions { margin-top: 10px; display: flex; gap: 8px; align-items: center; }
.editor-body .eb-actions button {
padding: 5px 10px;
font-size: 12px;
border: 1px solid var(--border);
background: white;
border-radius: 5px;
cursor: pointer;
}
.editor-body .eb-actions button.primary {
background: var(--accent);
color: white;
border-color: var(--accent);
}
.editor-body .eb-actions .eb-status { color: var(--muted); font-size: 11.5px; }
.intents-body .ib-head {
font-size: 12px;
color: var(--muted);
margin-bottom: 8px;
display: flex;
align-items: baseline;
justify-content: space-between;
}
.intents-body .ib-counter b { color: var(--fg); font-weight: 600; }
.intents-body .ib-list {
display: flex;
flex-wrap: wrap;
gap: 8px 14px;
background: white;
border: 1px solid var(--border);
border-radius: 6px;
padding: 10px 12px;
}
.intents-body .ib-item {
display: inline-flex;
align-items: center;
gap: 5px;
font-size: 12.5px;
cursor: pointer;
}
.intents-body .ib-item code { font-family: var(--mono); font-size: 11.5px; color: var(--accent); }
.intents-body .ib-actions { margin-top: 10px; display: flex; gap: 8px; }
.intents-body .ib-actions button {
padding: 5px 10px;
font-size: 12px;
border: 1px solid var(--border);
background: white;
border-radius: 5px;
cursor: pointer;
}
.intents-body .ib-actions button.primary {
background: var(--accent);
color: white;
border-color: var(--accent);
}
.chunk-card {
background: white;
border: 1px solid var(--border);
@@ -374,6 +450,9 @@
<section class="panel">
<h2>Тест-вопрос от пациента</h2>
<div id="debug-info-bar" style="margin-bottom:10px;padding:8px 12px;background:#eef2ff;border:1px solid #c7d2fe;border-radius:6px;font-size:12px;color:#3730a3;display:flex;align-items:center;gap:10px;flex-wrap:wrap;">
<span>— загружаю настройки страницы отладки —</span>
</div>
<textarea id="question" placeholder="Например: как записать ребёнка к лору?"></textarea>
<div class="row" style="margin-top:12px;">
<label>top_k <input type="number" class="num" id="top_k" value="5" min="1" max="20"></label>
@@ -452,11 +531,21 @@ async function refreshDocs() {
<td>${esc(d.file_type)}</td>
<td>${d.chunks_count}</td>
<td class="mini">${esc((d.created_at || "").slice(0, 19).replace("T", " "))}</td>
<td><button class="danger" onclick="event.stopPropagation(); deleteDoc('${d.document_id}', '${esc(d.name)}')">удалить</button></td>
<td>
<button onclick="event.stopPropagation(); toggleEditor('${d.document_id}')">редактировать</button>
<button onclick="event.stopPropagation(); toggleIntents('${d.document_id}')">привязка</button>
<button class="danger" onclick="event.stopPropagation(); deleteDoc('${d.document_id}', '${esc(d.name)}')">удалить</button>
</td>
</tr>
<tr class="chunks-row" id="chunks-${d.document_id}" style="display:none;">
<td colspan="5"><div class="chunks-body"><div class="mini">загружаю…</div></div></td>
</tr>
<tr class="intents-row" id="intents-${d.document_id}" style="display:none;">
<td colspan="5"><div class="intents-body"><div class="mini">— загружаю —</div></div></td>
</tr>
<tr class="editor-row" id="editor-${d.document_id}" style="display:none;">
<td colspan="5"><div class="editor-body"><div class="mini">— загружаю —</div></div></td>
</tr>
`).join("");
} catch (e) {
toast("Не удалось загрузить список: " + e.message, "err");
@@ -543,6 +632,135 @@ async function deleteDoc(id, name) {
}
}
/* ---------- intents subscription (Спринт 7, часть A) ---------- */
async function toggleIntents(docId) {
const row = $("intents-" + docId);
const isOpen = row.style.display !== "none";
if (isOpen) {
row.style.display = "none";
return;
}
row.style.display = "";
const body = row.querySelector(".intents-body");
body.innerHTML = '<div class="mini">— загружаю —</div>';
try {
const [intentsResp, docResp] = await Promise.all([
api(`/intents`),
api(`/documents/${docId}/intents`),
]);
const allIntents = (intentsResp.intents || [])
.filter(i => !i.code.startsWith("_"))
.sort((a, b) => a.name.localeCompare(b.name, "ru"));
const subscribed = new Set(docResp.intent_codes || []);
const items = allIntents.map(i => `
<label class="ib-item">
<input type="checkbox" data-intent-code="${esc(i.code)}" ${subscribed.has(i.code) ? "checked" : ""} onchange="updateIntentsCounter('${docId}')">
${esc(i.name)} <code>${esc(i.code)}</code>
</label>
`).join("");
body.innerHTML = `
<div class="ib-head">
<span>К каким веткам подключён этот документ для RAG?</span>
<span class="ib-counter" id="intents-counter-${docId}">подключён к <b>${subscribed.size}</b> из <b>${allIntents.length}</b></span>
</div>
<div class="ib-list" id="intents-list-${docId}">${items}</div>
<div class="ib-actions">
<button class="primary" onclick="saveDocIntents('${docId}')">Сохранить</button>
<button onclick="toggleIntents('${docId}')">Отмена</button>
</div>
`;
} catch (e) {
body.innerHTML = `<div class="mini" style="color:var(--err)">Ошибка: ${esc(e.message)}</div>`;
}
}
function updateIntentsCounter(docId) {
const list = $("intents-list-" + docId);
const counter = $("intents-counter-" + docId);
if (!list || !counter) return;
const all = list.querySelectorAll('input[type="checkbox"][data-intent-code]');
const checked = list.querySelectorAll('input[type="checkbox"][data-intent-code]:checked');
counter.innerHTML = `подключён к <b>${checked.length}</b> из <b>${all.length}</b>`;
}
async function saveDocIntents(docId) {
const list = $("intents-list-" + docId);
if (!list) return;
const intent_codes = Array.from(
list.querySelectorAll('input[type="checkbox"][data-intent-code]:checked')
).map(cb => cb.dataset.intentCode);
try {
const r = await api(`/documents/${docId}/intents`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ intent_codes }),
});
toast(`Привязка сохранена: ${r.intent_codes.length} ветка(и)`);
updateIntentsCounter(docId);
} catch (e) {
toast("Не удалось сохранить: " + e.message, "err");
}
}
/* ---------- raw-text editor (Спринт 7) ---------- */
async function toggleEditor(docId) {
const row = $("editor-" + docId);
const isOpen = row.style.display !== "none";
if (isOpen) {
row.style.display = "none";
return;
}
row.style.display = "";
const body = row.querySelector(".editor-body");
body.innerHTML = '<div class="mini">— загружаю —</div>';
try {
const d = await api(`/documents/${docId}/raw`);
const safe = esc(d.raw_text || "");
body.innerHTML = `
<div class="eb-hint">
Правится <b>извлечённый текст</b> документа. Для PDF/docx исходник теряется — после сохранения остаётся только этот текст. Сохранение запускает переразметку и обновляет чанки в Chroma.
</div>
<textarea class="eb-textarea" id="editor-text-${docId}" spellcheck="false">${safe}</textarea>
<div class="eb-actions">
<button class="primary" onclick="saveDocRaw('${docId}')">Сохранить и переиндексировать</button>
<button onclick="toggleEditor('${docId}')">Отмена</button>
<span class="eb-status" id="editor-status-${docId}"></span>
</div>
`;
} catch (e) {
body.innerHTML = `<div class="mini" style="color:var(--err)">Ошибка: ${esc(e.message)}</div>`;
}
}
async function saveDocRaw(docId) {
const ta = $("editor-text-" + docId);
const status = $("editor-status-" + docId);
if (!ta) return;
const raw_text = ta.value;
if (!raw_text.trim()) {
toast("Текст не может быть пустым", "err");
return;
}
if (!confirm("Сохранить и переиндексировать документ? Старые чанки будут удалены, новые соберутся заново.")) {
return;
}
if (status) status.innerHTML = '<span class="spinner"></span> переиндексирую…';
try {
const r = await api(`/documents/${docId}/raw`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ raw_text }),
});
toast(`Переиндексировано: ${r.chunks_count} чанков`);
if (status) status.textContent = "";
refreshDocs();
refreshHealth();
} catch (e) {
if (status) status.textContent = "";
toast("Ошибка: " + e.message, "err");
}
}
async function uploadFile(file) {
$("upload-status").innerHTML = `<span class="spinner"></span> загружаю <b>${esc(file.name)}</b>…`;
const fd = new FormData();
@@ -610,11 +828,16 @@ async function ask() {
: '<div class="mini">— нет релевантных чанков —</div>';
$("col-prompt").innerHTML = `<pre>${esc(r.assembled_prompt)}</pre>`;
const cfgInfo = r.config_version != null ? ` · промпт <code>_debug</code> v${r.config_version}` : "";
const ragInfo = r.rag_subscription
? ` · подписано ${r.rag_subscription.subscribed_count}, найдено ${r.rag_subscription.found_count}`
: "";
$("col-answer").innerHTML = `
<div class="answer">${esc(r.answer)}</div>
<div class="answer-meta">модель: ${esc(r.model_used)} · источников: ${r.sources.length}</div>
<div class="answer-meta">модель: ${esc(r.model_used)} · источников: ${r.sources.length}${cfgInfo}${ragInfo}</div>
`;
$("ask-status").textContent = "";
loadDebugInfo();
} catch (e) {
$("col-answer").innerHTML = `<div class="mini" style="color:var(--err)">Ошибка: ${esc(e.message)}</div>`;
$("ask-status").textContent = "";
@@ -625,6 +848,40 @@ async function ask() {
}
$("ask-btn").addEventListener("click", ask);
/* ---------- _debug intent info bar ---------- */
async function loadDebugInfo() {
const bar = $("debug-info-bar");
if (!bar) return;
try {
const [intentsResp, subsResp, docsResp] = await Promise.all([
api("/intents"),
api("/intents/_debug/documents"),
api("/documents"),
]);
const dbg = (intentsResp.intents || []).find(i => i.code === "_debug");
const subscribed = (subsResp.document_ids || []).length;
const total = (docsResp.documents || []).length;
const ver = dbg && dbg.active_config_version != null ? `v${dbg.active_config_version}` : "нет активной версии";
const noPromptWarning = !dbg || dbg.active_config_version == null;
bar.innerHTML = `
<span>промпт ветки <code style="background:#e0e7ff;padding:1px 5px;border-radius:3px;font-family:var(--mono);">_debug</code> «Страница отладки» · <b>${esc(ver)}</b></span>
<span style="opacity:.7;">·</span>
<span>подписано <b>${subscribed}</b> из <b>${total}</b> документ(ов)${subscribed === 0 ? " — RAG идёт по всей базе" : ""}</span>
<span style="opacity:.7;">·</span>
<a href="/settings.html" style="color:var(--accent);text-decoration:none;">настроить →</a>
`;
if (noPromptWarning) {
bar.style.background = "#fef3c7";
bar.style.borderColor = "#fde68a";
bar.style.color = "#78350f";
bar.innerHTML += '<div style="width:100%;margin-top:4px;">⚠️ у ветки нет активной версии промпта — модель будет отвечать без системных инструкций.</div>';
}
} catch (e) {
bar.innerHTML = `<span style="color:var(--err);">Не удалось загрузить настройки: ${esc(e.message)}</span>`;
}
}
loadDebugInfo();
$("question").addEventListener("keydown", e => {
if ((e.metaKey || e.ctrlKey) && e.key === "Enter") ask();
});