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:
+259
-2
@@ -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();
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user