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