52b46bc53e
Спринт 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>
896 lines
29 KiB
HTML
896 lines
29 KiB
HTML
<!DOCTYPE html>
|
|
<html lang="ru">
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
<title>Chat Agent for Patients — Debug UI</title>
|
|
<style>
|
|
:root {
|
|
--bg: #f5f6f8;
|
|
--panel: #ffffff;
|
|
--border: #e1e4ea;
|
|
--muted: #6b7280;
|
|
--fg: #111827;
|
|
--accent: #2563eb;
|
|
--accent-hover: #1d4ed8;
|
|
--ok: #16a34a;
|
|
--warn: #d97706;
|
|
--err: #dc2626;
|
|
--chip-bg: #eef2ff;
|
|
--mono: ui-monospace, SFMono-Regular, Menlo, monospace;
|
|
}
|
|
* { box-sizing: border-box; }
|
|
body {
|
|
margin: 0;
|
|
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
|
background: var(--bg);
|
|
color: var(--fg);
|
|
font-size: 14px;
|
|
line-height: 1.5;
|
|
}
|
|
header {
|
|
background: var(--panel);
|
|
border-bottom: 1px solid var(--border);
|
|
padding: 14px 24px;
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 16px;
|
|
position: sticky;
|
|
top: 0;
|
|
z-index: 10;
|
|
}
|
|
header h1 {
|
|
margin: 0;
|
|
font-size: 16px;
|
|
font-weight: 600;
|
|
}
|
|
.status {
|
|
display: inline-flex;
|
|
align-items: center;
|
|
gap: 6px;
|
|
padding: 4px 10px;
|
|
border-radius: 999px;
|
|
background: var(--chip-bg);
|
|
font-size: 13px;
|
|
}
|
|
.dot {
|
|
width: 8px;
|
|
height: 8px;
|
|
border-radius: 50%;
|
|
background: var(--muted);
|
|
}
|
|
.dot.ok { background: var(--ok); }
|
|
.dot.warn { background: var(--warn); }
|
|
.dot.err { background: var(--err); }
|
|
.nav {
|
|
display: flex;
|
|
gap: 4px;
|
|
}
|
|
.nav-link {
|
|
text-decoration: none;
|
|
color: var(--muted);
|
|
padding: 6px 12px;
|
|
border-radius: 6px;
|
|
font-size: 13px;
|
|
font-weight: 500;
|
|
}
|
|
.nav-link:hover { background: var(--chip-bg); color: var(--fg); }
|
|
.nav-link.active { background: var(--accent); color: #fff; }
|
|
.stats {
|
|
margin-left: auto;
|
|
font-size: 13px;
|
|
color: var(--muted);
|
|
}
|
|
.stats b { color: var(--fg); }
|
|
main {
|
|
padding: 24px;
|
|
max-width: 1400px;
|
|
margin: 0 auto;
|
|
display: grid;
|
|
gap: 24px;
|
|
}
|
|
.panel {
|
|
background: var(--panel);
|
|
border: 1px solid var(--border);
|
|
border-radius: 12px;
|
|
padding: 20px;
|
|
}
|
|
.panel h2 {
|
|
margin: 0 0 16px 0;
|
|
font-size: 15px;
|
|
font-weight: 600;
|
|
}
|
|
.dropzone {
|
|
border: 2px dashed var(--border);
|
|
border-radius: 10px;
|
|
padding: 28px;
|
|
text-align: center;
|
|
color: var(--muted);
|
|
cursor: pointer;
|
|
transition: all 0.15s;
|
|
}
|
|
.dropzone.drag { border-color: var(--accent); background: var(--chip-bg); color: var(--fg); }
|
|
.dropzone input { display: none; }
|
|
table {
|
|
width: 100%;
|
|
border-collapse: collapse;
|
|
margin-top: 16px;
|
|
font-size: 13px;
|
|
}
|
|
th, td {
|
|
text-align: left;
|
|
padding: 8px 10px;
|
|
border-bottom: 1px solid var(--border);
|
|
vertical-align: top;
|
|
}
|
|
th {
|
|
font-weight: 600;
|
|
color: var(--fg);
|
|
font-size: 13px;
|
|
letter-spacing: -0.01em;
|
|
}
|
|
tr:last-child td { border-bottom: none; }
|
|
tr.doc-row { cursor: pointer; }
|
|
tr.doc-row:hover td:not(:last-child) { background: #f9fafb; }
|
|
tr.doc-row .arrow { display: inline-block; transition: transform 0.15s; color: var(--muted); margin-right: 6px; }
|
|
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);
|
|
border-radius: 8px;
|
|
padding: 10px 12px;
|
|
margin-bottom: 8px;
|
|
}
|
|
.chunk-card-meta {
|
|
font-size: 11px;
|
|
color: var(--muted);
|
|
margin-bottom: 6px;
|
|
display: flex;
|
|
gap: 10px;
|
|
flex-wrap: wrap;
|
|
}
|
|
.chunk-card-text {
|
|
white-space: pre-wrap;
|
|
font-size: 13px;
|
|
word-break: break-word;
|
|
}
|
|
.chunk-card-toggle {
|
|
color: var(--accent);
|
|
cursor: pointer;
|
|
font-size: 12px;
|
|
border: none;
|
|
background: none;
|
|
padding: 4px 0 0 0;
|
|
}
|
|
.chunk-card-actions {
|
|
display: flex;
|
|
gap: 14px;
|
|
align-items: center;
|
|
margin-top: 4px;
|
|
}
|
|
.embedding-box {
|
|
margin-top: 8px;
|
|
padding: 8px 10px;
|
|
background: #f0f4ff;
|
|
border: 1px solid #c7d2fe;
|
|
border-radius: 6px;
|
|
display: none;
|
|
}
|
|
.embedding-box.open { display: block; }
|
|
.emb-header {
|
|
font-size: 11px;
|
|
color: var(--muted);
|
|
margin-bottom: 4px;
|
|
}
|
|
.emb-values {
|
|
font-family: 'SF Mono', 'Menlo', 'Consolas', monospace;
|
|
font-size: 10.5px;
|
|
line-height: 1.4;
|
|
max-height: 140px;
|
|
overflow-y: auto;
|
|
word-break: break-all;
|
|
color: #3730a3;
|
|
}
|
|
.empty {
|
|
padding: 20px;
|
|
color: var(--muted);
|
|
text-align: center;
|
|
font-style: italic;
|
|
}
|
|
button {
|
|
font: inherit;
|
|
padding: 8px 14px;
|
|
border-radius: 8px;
|
|
border: 1px solid var(--border);
|
|
background: var(--panel);
|
|
cursor: pointer;
|
|
}
|
|
button.primary {
|
|
background: var(--accent);
|
|
border-color: var(--accent);
|
|
color: white;
|
|
}
|
|
button.primary:hover:not(:disabled) { background: var(--accent-hover); }
|
|
button.danger {
|
|
color: var(--err);
|
|
border-color: var(--err);
|
|
background: transparent;
|
|
font-size: 12px;
|
|
padding: 4px 10px;
|
|
}
|
|
button:disabled { opacity: 0.5; cursor: not-allowed; }
|
|
.row { display: flex; gap: 12px; align-items: center; flex-wrap: wrap; }
|
|
.row label { color: var(--muted); font-size: 13px; }
|
|
textarea, input[type=number], input[type=text] {
|
|
font: inherit;
|
|
padding: 8px 10px;
|
|
border: 1px solid var(--border);
|
|
border-radius: 8px;
|
|
width: 100%;
|
|
background: white;
|
|
}
|
|
textarea { min-height: 90px; resize: vertical; }
|
|
.num { width: 80px; }
|
|
.columns {
|
|
display: grid;
|
|
grid-template-columns: 1fr 1fr 1fr;
|
|
gap: 16px;
|
|
margin-top: 16px;
|
|
}
|
|
.col {
|
|
border: 1px solid var(--border);
|
|
border-radius: 10px;
|
|
padding: 14px;
|
|
background: #fafbfd;
|
|
min-height: 200px;
|
|
overflow: hidden;
|
|
}
|
|
.col h3 {
|
|
margin: 0 0 10px 0;
|
|
font-size: 13px;
|
|
font-weight: 600;
|
|
color: var(--fg);
|
|
letter-spacing: -0.01em;
|
|
}
|
|
.chunk {
|
|
border: 1px solid var(--border);
|
|
border-radius: 8px;
|
|
padding: 10px;
|
|
margin-bottom: 8px;
|
|
background: white;
|
|
}
|
|
.chunk-head {
|
|
display: flex;
|
|
justify-content: space-between;
|
|
font-size: 11px;
|
|
color: var(--muted);
|
|
margin-bottom: 6px;
|
|
}
|
|
.score {
|
|
background: var(--chip-bg);
|
|
padding: 1px 6px;
|
|
border-radius: 999px;
|
|
color: var(--accent);
|
|
font-weight: 600;
|
|
}
|
|
pre {
|
|
font-family: var(--mono);
|
|
font-size: 12px;
|
|
white-space: pre-wrap;
|
|
word-break: break-word;
|
|
margin: 0;
|
|
background: white;
|
|
padding: 10px;
|
|
border: 1px solid var(--border);
|
|
border-radius: 8px;
|
|
max-height: 500px;
|
|
overflow: auto;
|
|
}
|
|
.answer {
|
|
background: white;
|
|
border: 1px solid var(--border);
|
|
border-radius: 8px;
|
|
padding: 14px;
|
|
white-space: pre-wrap;
|
|
word-break: break-word;
|
|
line-height: 1.6;
|
|
}
|
|
.answer-meta {
|
|
margin-top: 8px;
|
|
font-size: 11px;
|
|
color: var(--muted);
|
|
}
|
|
.toast {
|
|
position: fixed;
|
|
bottom: 20px;
|
|
right: 20px;
|
|
background: #111827;
|
|
color: white;
|
|
padding: 10px 16px;
|
|
border-radius: 8px;
|
|
font-size: 13px;
|
|
opacity: 0;
|
|
transition: opacity 0.2s;
|
|
pointer-events: none;
|
|
}
|
|
.toast.show { opacity: 1; }
|
|
.toast.err { background: var(--err); }
|
|
.mini { font-size: 12px; color: var(--muted); }
|
|
.spinner {
|
|
width: 14px; height: 14px;
|
|
border: 2px solid var(--border);
|
|
border-top-color: var(--accent);
|
|
border-radius: 50%;
|
|
display: inline-block;
|
|
animation: spin 0.8s linear infinite;
|
|
vertical-align: middle;
|
|
}
|
|
@keyframes spin { to { transform: rotate(360deg); } }
|
|
@media (max-width: 900px) {
|
|
.columns { grid-template-columns: 1fr; }
|
|
}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
|
|
<header>
|
|
<h1>Chat Agent for Patients</h1>
|
|
<nav class="nav">
|
|
<a href="/" class="nav-link active">Отладка</a>
|
|
<a href="/sandbox.html" class="nav-link">Песочница</a>
|
|
<a href="/settings.html" class="nav-link">Настройки</a>
|
|
<a href="/docs.html" class="nav-link">Документация</a>
|
|
</nav>
|
|
<span class="status"><span class="dot" id="dot"></span><span id="status-text">проверяю…</span></span>
|
|
<span class="stats" id="stats"></span>
|
|
</header>
|
|
|
|
<main>
|
|
|
|
<section class="panel">
|
|
<h2>База знаний</h2>
|
|
<div id="dropzone" class="dropzone">
|
|
Перетащи файл (.pdf, .docx, .txt, .md) или кликни для выбора
|
|
<input type="file" id="file-input" accept=".pdf,.docx,.doc,.txt,.md">
|
|
</div>
|
|
<div id="upload-status" class="mini" style="margin-top:10px;"></div>
|
|
<table>
|
|
<thead>
|
|
<tr>
|
|
<th>Имя</th>
|
|
<th>Тип</th>
|
|
<th>Чанков</th>
|
|
<th>Загружен</th>
|
|
<th></th>
|
|
</tr>
|
|
</thead>
|
|
<tbody id="docs-tbody">
|
|
<tr><td colspan="5" class="empty">Документы ещё не загружены</td></tr>
|
|
</tbody>
|
|
</table>
|
|
</section>
|
|
|
|
<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>
|
|
<label>temperature <input type="number" class="num" id="temperature" value="0.2" min="0" max="2" step="0.1"></label>
|
|
<button class="primary" id="ask-btn">Отправить</button>
|
|
<span id="ask-status" class="mini"></span>
|
|
</div>
|
|
|
|
<div class="columns">
|
|
<div class="col">
|
|
<h3>Что нашёл RAG</h3>
|
|
<div id="col-chunks"><div class="mini">— пока пусто —</div></div>
|
|
</div>
|
|
<div class="col">
|
|
<h3>Собранный промпт</h3>
|
|
<div id="col-prompt"><div class="mini">— пока пусто —</div></div>
|
|
</div>
|
|
<div class="col">
|
|
<h3>Ответ агента</h3>
|
|
<div id="col-answer"><div class="mini">— пока пусто —</div></div>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
|
|
</main>
|
|
|
|
<div class="toast" id="toast"></div>
|
|
|
|
<script>
|
|
const API = "";
|
|
|
|
const $ = (id) => document.getElementById(id);
|
|
const esc = (s) => String(s ?? "").replace(/[&<>"']/g, c => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]));
|
|
|
|
function toast(msg, kind = "ok") {
|
|
const t = $("toast");
|
|
t.textContent = msg;
|
|
t.className = "toast show" + (kind === "err" ? " err" : "");
|
|
setTimeout(() => t.className = "toast", 2500);
|
|
}
|
|
|
|
async function api(path, opts = {}) {
|
|
const res = await fetch(API + path, opts);
|
|
if (!res.ok) {
|
|
let detail = res.statusText;
|
|
try { const j = await res.json(); detail = j.detail || detail; } catch {}
|
|
throw new Error(detail);
|
|
}
|
|
return res.json();
|
|
}
|
|
|
|
async function refreshHealth() {
|
|
try {
|
|
const h = await api("/health");
|
|
$("dot").className = "dot " + (h.status === "ok" ? "ok" : "warn");
|
|
$("status-text").textContent = h.status === "ok" ? "готов" : h.status;
|
|
$("stats").innerHTML = `модель <b>${esc(h.embedding_model)}</b> · документов <b>${h.documents_count}</b> · чанков <b>${h.chunks_count}</b>`;
|
|
} catch (e) {
|
|
$("dot").className = "dot err";
|
|
$("status-text").textContent = "недоступен";
|
|
$("stats").textContent = "";
|
|
}
|
|
}
|
|
|
|
async function refreshDocs() {
|
|
try {
|
|
const r = await api("/documents");
|
|
const tbody = $("docs-tbody");
|
|
if (!r.documents.length) {
|
|
tbody.innerHTML = '<tr><td colspan="5" class="empty">Документы ещё не загружены</td></tr>';
|
|
return;
|
|
}
|
|
tbody.innerHTML = r.documents.map(d => `
|
|
<tr class="doc-row" id="doc-${d.document_id}" onclick="toggleChunks('${d.document_id}')">
|
|
<td><span class="arrow">▶</span>${esc(d.name)}</td>
|
|
<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 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");
|
|
}
|
|
}
|
|
|
|
async function toggleChunks(id) {
|
|
const row = $("doc-" + id);
|
|
const chunksRow = $("chunks-" + id);
|
|
const isOpen = chunksRow.style.display !== "none";
|
|
if (isOpen) {
|
|
chunksRow.style.display = "none";
|
|
row.classList.remove("open");
|
|
return;
|
|
}
|
|
chunksRow.style.display = "";
|
|
row.classList.add("open");
|
|
const body = chunksRow.querySelector(".chunks-body");
|
|
body.innerHTML = '<div class="mini">загружаю…</div>';
|
|
try {
|
|
const d = await api(`/documents/${id}/chunks`);
|
|
if (!d.chunks.length) {
|
|
body.innerHTML = '<div class="mini">чанков нет</div>';
|
|
return;
|
|
}
|
|
body.innerHTML = d.chunks.map((c, i) => {
|
|
const long = c.text.length > 300;
|
|
const short = long ? c.text.slice(0, 300) + "…" : c.text;
|
|
const safeFull = esc(c.text).replace(/"/g, """);
|
|
const embId = `emb-${id}-${c.index}-${i}`;
|
|
const dim = c.embedding_dim || (c.embedding ? c.embedding.length : 0);
|
|
const embValues = (c.embedding && c.embedding.length)
|
|
? "[" + c.embedding.map(v => v.toFixed(6)).join(", ") + "]"
|
|
: "—";
|
|
return `
|
|
<div class="chunk-card">
|
|
<div class="chunk-card-meta">
|
|
<span>#${c.index}</span>
|
|
<span>раздел: ${esc(c.section || "—")}</span>
|
|
${c.page_number ? `<span>стр. ${c.page_number}</span>` : ""}
|
|
<span>${c.char_length.toLocaleString("ru")} симв.</span>
|
|
</div>
|
|
<div class="chunk-card-text" data-short="${esc(short).replace(/"/g, """)}" data-full="${safeFull}" data-expanded="0">${esc(short)}</div>
|
|
<div class="chunk-card-actions">
|
|
${long ? '<button class="chunk-card-toggle" onclick="toggleChunkText(this)">показать полностью</button>' : ""}
|
|
${dim ? `<button class="chunk-card-toggle" onclick="toggleEmb('${embId}')">вектор (${dim} dim)</button>` : ""}
|
|
</div>
|
|
${dim ? `
|
|
<div class="embedding-box" id="${embId}">
|
|
<div class="emb-header">эмбеддинг · ${dim} компонент · cosine space</div>
|
|
<div class="emb-values">${embValues}</div>
|
|
</div>
|
|
` : ""}
|
|
</div>
|
|
`;
|
|
}).join("");
|
|
} catch (e) {
|
|
body.innerHTML = `<div class="mini" style="color:var(--err)">Ошибка: ${esc(e.message)}</div>`;
|
|
}
|
|
}
|
|
|
|
function toggleChunkText(btn) {
|
|
const card = btn.closest(".chunk-card");
|
|
const textEl = card.querySelector(".chunk-card-text");
|
|
const expanded = textEl.getAttribute("data-expanded") === "1";
|
|
textEl.textContent = expanded ? textEl.getAttribute("data-short") : textEl.getAttribute("data-full");
|
|
textEl.setAttribute("data-expanded", expanded ? "0" : "1");
|
|
btn.textContent = expanded ? "показать полностью" : "свернуть";
|
|
}
|
|
|
|
function toggleEmb(embId) {
|
|
const box = document.getElementById(embId);
|
|
if (box) box.classList.toggle("open");
|
|
}
|
|
|
|
async function deleteDoc(id, name) {
|
|
if (!confirm(`Удалить документ «${name}»?`)) return;
|
|
try {
|
|
await api(`/documents/${id}`, { method: "DELETE" });
|
|
toast("Удалён");
|
|
refreshDocs(); refreshHealth();
|
|
} catch (e) {
|
|
toast("Ошибка: " + e.message, "err");
|
|
}
|
|
}
|
|
|
|
/* ---------- 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();
|
|
fd.append("file", file);
|
|
try {
|
|
const r = await api("/documents/upload", { method: "POST", body: fd });
|
|
$("upload-status").innerHTML = `✓ <b>${esc(r.name)}</b> — ${r.chunks_count} чанков`;
|
|
toast("Загружено");
|
|
refreshDocs(); refreshHealth();
|
|
} catch (e) {
|
|
$("upload-status").innerHTML = `✕ ошибка: ${esc(e.message)}`;
|
|
toast("Ошибка загрузки: " + e.message, "err");
|
|
}
|
|
}
|
|
|
|
function initDropzone() {
|
|
const dz = $("dropzone");
|
|
const input = $("file-input");
|
|
dz.addEventListener("click", () => input.click());
|
|
input.addEventListener("change", () => {
|
|
if (input.files[0]) uploadFile(input.files[0]);
|
|
input.value = "";
|
|
});
|
|
["dragenter", "dragover"].forEach(e =>
|
|
dz.addEventListener(e, ev => { ev.preventDefault(); dz.classList.add("drag"); })
|
|
);
|
|
["dragleave", "drop"].forEach(e =>
|
|
dz.addEventListener(e, ev => { ev.preventDefault(); dz.classList.remove("drag"); })
|
|
);
|
|
dz.addEventListener("drop", ev => {
|
|
if (ev.dataTransfer.files[0]) uploadFile(ev.dataTransfer.files[0]);
|
|
});
|
|
}
|
|
|
|
async function ask() {
|
|
const question = $("question").value.trim();
|
|
if (!question) { toast("Введите вопрос", "err"); return; }
|
|
const btn = $("ask-btn");
|
|
btn.disabled = true;
|
|
$("ask-status").innerHTML = '<span class="spinner"></span> думаю…';
|
|
$("col-chunks").innerHTML = '<div class="mini">…</div>';
|
|
$("col-prompt").innerHTML = '<div class="mini">…</div>';
|
|
$("col-answer").innerHTML = '<div class="mini">…</div>';
|
|
|
|
try {
|
|
const r = await api("/query", {
|
|
method: "POST",
|
|
headers: {"Content-Type": "application/json"},
|
|
body: JSON.stringify({
|
|
text: question,
|
|
top_k: parseInt($("top_k").value, 10) || 5,
|
|
temperature: parseFloat($("temperature").value),
|
|
}),
|
|
});
|
|
|
|
$("col-chunks").innerHTML = r.sources.length
|
|
? r.sources.map((s, i) => `
|
|
<div class="chunk">
|
|
<div class="chunk-head">
|
|
<span>[${i + 1}] ${esc(s.document_name)} · ${esc(s.section || "—")}</span>
|
|
<span class="score">${(s.relevance_score * 100).toFixed(1)}%</span>
|
|
</div>
|
|
<div>${esc(s.chunk_text)}</div>
|
|
</div>`).join("")
|
|
: '<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}${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 = "";
|
|
toast("Ошибка: " + e.message, "err");
|
|
} finally {
|
|
btn.disabled = false;
|
|
}
|
|
}
|
|
|
|
$("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();
|
|
});
|
|
|
|
initDropzone();
|
|
refreshHealth();
|
|
refreshDocs();
|
|
setInterval(refreshHealth, 15000);
|
|
</script>
|
|
</body>
|
|
</html>
|