Files
AR 15 M4 a8f7e68795 feat(sprint8a): регрессия роутера в UI с выбором кейсов и кэшем
Оператор-настройщик после правки промпта _router нажимает «Прогнать выбранное»
на странице «Регрессия» и видит, что сломалось. Не CLI, не в обход
интерфейса — встроено в верхнюю навигацию рядом с Настройками.

Backend:
- Таблицы eval_runs / eval_run_cases (с is_pass) / eval_router_predictions
  (кэш text_hash + router_config_id → predicted_intent). Миграции
  k7e9d5c67h34 и l8f0e6d78i45.
- services/eval_run_service.py: start_router_run(text_hashes) запускает
  фоновую корутину через asyncio.create_task, фиксирует активную версию
  _router. Кэш привязан к версии: повторный прогон на той же версии —
  мгновенный, на новой — пересчитывается. compute_diff_vs_previous
  сравнивает с предыдущим прогоном на той же версии (новые fail / pass).
- API: POST /eval/runs (фон, body text_hashes), GET /eval/runs,
  GET /eval/runs/{id}, GET /eval/router-cases-with-status (все 1573 кейса
  + кэш на активной версии).

Frontend (static/regression.html — новая страница, ссылка добавлена в
шапки index/sandbox/settings/docs):
- Сворачиваемый блок «Выбор кейсов»: фильтр по intent, ввод диапазона
  (1-50, 200-300), кнопки «Все видимые», «Снять все», «Только без кэша»,
  «Только FAIL в кэше», «Снять кэшированные». Чекбокс в шапке.
- Таблица 1573 кейсов отсортирована по count desc: #, чекбокс, запрос,
  intent, частота, кэш (PASS / FAIL → predicted / —). Цветной фон строки
  по статусу кэша.
- Счётчик «выбрано N (новых: X, в кэше: Y)»; кнопка
  «Прогнать выбранное (X новых + Y из кэша)» — сразу видно реальный
  объём LLM-работы.
- Polling /eval/runs/{id} раз в 2 секунды, прогресс-бар, drill-down:
  все кейсы прогона + фильтр pass/fail + поиск + diff vs предыдущий
  (новые fail / новые pass).

docs/SPRINTS.md: Спринт 8 разбит на 8a ( закрыт), 8b (регрессия ответов
веток, ждёт базу кейсов от пользователя), 8c (handoff/resumable/loop/
guard/rag — позже).

docs/BACKLOG.md: новый файл для идей на потом. Записаны: просмотр
архивного графа без активации (из 7.7), варианты C (LLM-judge) и D
(эталон + embeddings) для регрессии веток в 8b.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 20:39:22 +05:00

1701 lines
56 KiB
HTML
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!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 — Настройки</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; }
html, body { height: 100%; }
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;
display: flex;
flex-direction: column;
}
header {
background: var(--panel);
border-bottom: 1px solid var(--border);
padding: 14px 24px;
display: flex;
align-items: center;
gap: 16px;
flex-shrink: 0;
}
header h1 { margin: 0; font-size: 16px; font-weight: 600; }
.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);
}
main {
flex: 1;
display: grid;
grid-template-columns: 280px 1fr 380px;
gap: 0;
min-height: 0;
}
.col-panel {
background: var(--panel);
border-right: 1px solid var(--border);
display: flex;
flex-direction: column;
min-height: 0;
}
.col-panel:last-child {
border-right: none;
border-left: 1px solid var(--border);
background: var(--bg);
}
/* Правая колонка Настроек — серый фон, заголовок без рамки и стек карточек */
.col-panel:last-child > .col-head {
border-bottom: none;
padding: 16px 14px 8px 14px;
}
.col-panel:last-child > .col-head #versions-intent {
color: var(--muted);
font-weight: normal;
}
.col-panel:last-child > .col-body {
padding: 0 14px 18px 14px;
}
.col-head {
padding: 14px 16px 10px;
border-bottom: 1px solid var(--border);
font-size: 13px;
color: var(--fg);
font-weight: 600;
letter-spacing: -0.01em;
}
.col-body {
flex: 1;
overflow-y: auto;
min-height: 0;
}
/* Список веток */
.section-header {
padding: 10px 16px 6px;
font-size: 11px;
color: var(--muted);
font-weight: 600;
background: #fafbfd;
border-bottom: 1px solid var(--border);
}
.system-badge {
background: #fef3c7;
color: #92400e;
padding: 1px 6px;
border-radius: 10px;
font-size: 9px;
font-weight: 600;
text-transform: uppercase;
}
.intent-item {
padding: 12px 16px;
border-bottom: 1px solid var(--border);
cursor: pointer;
display: flex;
flex-direction: column;
gap: 2px;
}
.intent-item:hover { background: #f9fafb; }
.intent-item.active { background: var(--chip-bg); }
.intent-item.disabled .intent-name { color: var(--muted); text-decoration: line-through; }
.intent-top {
display: flex;
align-items: center;
gap: 8px;
}
.intent-name { font-weight: 500; font-size: 13px; flex: 1; }
.intent-code { font-family: var(--mono); font-size: 10px; color: var(--muted); }
.intent-desc {
font-size: 11px;
color: var(--muted);
margin-top: 2px;
}
.intent-version {
font-size: 11px;
color: var(--accent);
font-weight: 500;
margin-top: 2px;
}
.intent-version.empty { color: var(--muted); font-weight: normal; }
/* Редактор */
.editor {
padding: 20px 24px;
max-width: 900px;
}
.editor-empty {
margin: auto;
text-align: center;
color: var(--muted);
padding: 60px 20px;
font-style: italic;
}
.editor h2 {
margin: 0 0 4px 0;
font-size: 18px;
font-weight: 600;
}
.editor .sub {
font-size: 12px;
color: var(--muted);
margin-bottom: 18px;
}
.toolbar {
display: flex;
gap: 10px;
margin-bottom: 14px;
flex-wrap: wrap;
}
.toolbar button {
background: none;
border: 1px solid var(--border);
padding: 5px 12px;
border-radius: 6px;
font-size: 12px;
cursor: pointer;
color: var(--fg);
}
.toolbar button:hover { background: #f9fafb; }
.toolbar .toggle { margin-left: auto; display: inline-flex; align-items: center; gap: 6px; font-size: 12px; color: var(--muted); cursor: pointer; }
.field { margin-bottom: 14px; position: relative; }
.field label {
display: block;
font-size: 13px;
font-weight: 600;
color: var(--fg);
letter-spacing: -0.01em;
margin-bottom: 6px;
}
.field label.with-hint {
display: flex;
align-items: center;
gap: 6px;
}
.hint-btn {
display: inline-flex;
align-items: center;
justify-content: center;
width: 16px;
height: 16px;
border-radius: 50%;
border: 1px solid var(--border);
background: #fff;
color: var(--muted);
cursor: pointer;
font-size: 10px;
font-weight: 600;
line-height: 1;
padding: 0;
font-family: serif;
font-style: italic;
}
.hint-btn:hover { background: var(--chip-bg); color: var(--accent); border-color: var(--accent); }
.hint-btn.active { background: var(--accent); color: #fff; border-color: var(--accent); }
.hint-popover {
display: none;
position: absolute;
z-index: 50;
top: 28px;
left: 0;
right: 0;
max-width: 560px;
background: #fff;
border: 1px solid var(--border);
border-radius: 8px;
padding: 12px 14px;
box-shadow: 0 6px 20px rgba(17, 24, 39, 0.12);
font-size: 12px;
line-height: 1.55;
color: var(--fg);
}
.hint-popover.show { display: block; }
.hint-popover h4 {
margin: 0 0 6px 0;
font-size: 12px;
font-weight: 600;
color: var(--fg);
}
.hint-popover p { margin: 0 0 6px 0; }
.hint-popover ul { margin: 4px 0 6px 0; padding-left: 18px; }
.hint-popover li { margin: 2px 0; }
.hint-popover code {
background: var(--chip-bg);
color: var(--accent);
padding: 1px 5px;
border-radius: 4px;
font-size: 11px;
font-family: var(--mono);
}
.hint-popover .hint-close {
position: absolute;
top: 6px;
right: 8px;
background: none;
border: none;
color: var(--muted);
font-size: 16px;
cursor: pointer;
line-height: 1;
padding: 2px 6px;
}
.hint-popover .hint-close:hover { color: var(--fg); }
.field input[type=text], .field textarea {
width: 100%;
border: 1px solid var(--border);
border-radius: 6px;
padding: 8px 10px;
font: inherit;
font-size: 13px;
background: #fff;
outline: none;
}
.field input[type=text]:focus, .field textarea:focus { border-color: var(--accent); }
.field textarea {
font-family: var(--mono);
resize: vertical;
min-height: 200px;
}
.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;
}
/* В сворачиваемой обёртке (Спринт 7.7) внешняя рамка/фон не нужны — их даёт .prompt-block */
.test-query-block .test-query {
border: 0;
border-radius: 0;
background: transparent;
}
.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 .tq-cases {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 8px;
flex-wrap: wrap;
font-size: 12px;
}
.test-query .tq-cases-label {
color: var(--muted);
flex-shrink: 0;
}
.test-query .tq-cases input {
flex: 1;
min-width: 200px;
padding: 5px 9px;
border: 1px solid var(--border);
border-radius: 5px;
font-size: 12.5px;
}
.test-query .tq-cases-btn {
padding: 5px 10px;
font-size: 12px;
border: 1px solid var(--border);
background: var(--panel);
border-radius: 5px;
cursor: pointer;
}
.test-query .tq-cases-btn:hover { background: #f9fafb; }
.test-query .tq-cases-count {
color: var(--muted);
font-size: 11px;
flex-shrink: 0;
}
.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;
align-items: center;
flex-wrap: wrap;
padding-top: 6px;
border-top: 1px solid var(--border);
margin-top: 10px;
padding: 14px 0 0 0;
}
.editor-actions button {
background: var(--accent);
color: #fff;
border: none;
padding: 8px 16px;
border-radius: 6px;
font-size: 13px;
font-weight: 500;
cursor: pointer;
}
.editor-actions button:hover { background: var(--accent-hover); }
.editor-actions button.secondary { background: none; color: var(--fg); border: 1px solid var(--border); }
.editor-actions button.secondary:hover { background: #f9fafb; }
.editor-actions label {
display: inline-flex;
align-items: center;
gap: 6px;
font-size: 13px;
color: var(--muted);
cursor: pointer;
}
/* Версии */
.versions {
display: flex;
flex-direction: column;
gap: 10px;
}
.version-card {
background: var(--panel);
border: 1px solid var(--border);
border-radius: 10px;
padding: 12px 14px;
}
.version-card.active {
border-color: var(--accent);
box-shadow: 0 0 0 1px var(--accent);
}
.version-head { display: flex; align-items: center; gap: 6px; margin-bottom: 4px; }
.v-num {
background: var(--chip-bg);
color: var(--accent);
padding: 1px 7px;
border-radius: 10px;
font-size: 10px;
font-weight: 600;
}
.v-name { font-weight: 500; font-size: 12px; flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.v-name.empty { color: var(--muted); font-style: italic; font-weight: normal; }
.v-active-badge {
background: var(--ok);
color: #fff;
padding: 1px 6px;
border-radius: 10px;
font-size: 9px;
font-weight: 600;
text-transform: uppercase;
}
.v-meta { font-size: 10px; color: var(--muted); margin-bottom: 6px; }
.v-actions { display: flex; gap: 4px; flex-wrap: wrap; }
.v-actions button {
background: none;
border: 1px solid var(--border);
padding: 2px 8px;
border-radius: 4px;
font-size: 10px;
cursor: pointer;
color: var(--fg);
}
.v-actions button:hover { background: #fff; }
.v-actions button.primary { background: var(--accent); color: #fff; border-color: var(--accent); }
.v-actions button.primary:hover { background: var(--accent-hover); }
.v-actions button.del:hover { border-color: var(--err); color: var(--err); }
.v-actions button:disabled { opacity: 0.4; cursor: not-allowed; }
.mini { color: var(--muted); font-size: 12px; font-style: italic; padding: 14px; }
.toast {
position: fixed;
bottom: 30px;
left: 50%;
transform: translateX(-50%);
background: #111827;
color: #fff;
padding: 10px 16px;
border-radius: 8px;
font-size: 13px;
opacity: 0;
pointer-events: none;
transition: opacity 0.2s;
z-index: 100;
}
.toast.show { opacity: 1; }
.toast.err { background: var(--err); }
/* Вкладки Промпт / Шаги */
.editor-tabs {
display: flex;
gap: 4px;
border-bottom: 1px solid var(--border);
margin-bottom: 16px;
}
.editor-tab {
padding: 6px 14px;
font-size: 13px;
border: none;
background: none;
color: var(--muted);
cursor: pointer;
border-bottom: 2px solid transparent;
margin-bottom: -1px;
}
.editor-tab:hover { color: var(--fg); }
.editor-tab.active {
color: var(--accent);
border-bottom-color: var(--accent);
font-weight: 500;
}
/* Версии графа шагов (Спринт 7.7) */
.graph-versions-block {
margin-bottom: 16px;
border: 1px solid var(--border);
border-radius: 8px;
background: #fff;
}
.graph-versions-summary {
list-style: none;
cursor: pointer;
padding: 10px 14px;
font-weight: 600;
font-size: 14px;
position: relative;
padding-left: 32px;
}
.graph-versions-summary::-webkit-details-marker { display: none; }
.graph-versions-summary::before {
content: "▶";
position: absolute;
left: 14px;
transition: transform 0.15s;
font-size: 10px;
}
.graph-versions-block[open] .graph-versions-summary::before { transform: rotate(90deg); }
.graph-versions-block[open] .graph-versions-summary { border-bottom: 1px solid var(--border); }
.graph-versions-list {
display: flex;
flex-direction: column;
gap: 8px;
padding: 12px 14px;
}
.graph-card {
border: 1px solid var(--border);
border-radius: 6px;
padding: 10px 12px;
background: #fafbfd;
}
.graph-card.active {
border-color: var(--accent);
background: #f3f6ff;
}
.graph-card-head {
display: flex;
justify-content: space-between;
align-items: center;
gap: 12px;
}
.graph-card-name { font-weight: 600; font-size: 13px; }
.graph-card-meta { color: var(--muted); font-size: 12px; margin-top: 4px; font-family: var(--mono); }
.graph-badge.active {
font-size: 11px;
padding: 2px 8px;
border-radius: 10px;
background: #dcfce7;
color: #166534;
font-weight: 600;
}
.graph-activate {
font-size: 12px;
padding: 4px 10px;
border: 1px solid var(--accent);
background: #fff;
color: var(--accent);
border-radius: 4px;
cursor: pointer;
}
.graph-activate:hover { background: var(--accent); color: #fff; }
/* Список шагов */
.steps-chips {
display: flex;
flex-wrap: wrap;
gap: 6px;
margin-bottom: 16px;
}
.step-chip {
padding: 5px 11px;
border-radius: 14px;
border: 1px solid var(--border);
background: #fafbfd;
font-size: 12px;
cursor: pointer;
font-family: var(--mono);
}
.step-chip:hover { background: #fff; }
.step-chip.active {
background: var(--accent);
color: #fff;
border-color: var(--accent);
}
.step-order { opacity: 0.6; margin-right: 4px; }
.allowed-next {
display: flex;
flex-wrap: wrap;
gap: 12px;
padding: 10px 12px;
background: #fafbfd;
border: 1px solid var(--border);
border-radius: 6px;
}
.allowed-next label {
display: inline-flex;
align-items: center;
gap: 5px;
font-size: 12px;
font-family: var(--mono);
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;
display: inline-block;
width: 34px;
height: 18px;
}
.switch input { opacity: 0; width: 0; height: 0; }
.slider {
position: absolute;
cursor: pointer;
inset: 0;
background: #cbd5e1;
border-radius: 10px;
transition: 0.2s;
}
.slider:before {
content: "";
position: absolute;
height: 14px; width: 14px;
left: 2px; bottom: 2px;
background: white;
border-radius: 50%;
transition: 0.2s;
}
.switch input:checked + .slider { background: var(--ok); }
.switch input:checked + .slider:before { transform: translateX(16px); }
</style>
</head>
<body>
<header>
<h1>Chat Agent for Patients</h1>
<nav class="nav">
<a href="/" class="nav-link">Отладка</a>
<a href="/sandbox.html" class="nav-link">Песочница</a>
<a href="/settings.html" class="nav-link active">Настройки</a>
<a href="/regression.html" class="nav-link">Регрессия</a>
<a href="/docs.html" class="nav-link">Документация</a>
</nav>
<span class="stats" id="stats"></span>
</header>
<main>
<aside class="col-panel">
<div class="col-head">Ветки (intents)</div>
<div class="col-body" id="intents-list">
<div class="mini">загружаю…</div>
</div>
</aside>
<section class="col-panel">
<div class="col-head" id="editor-head">Выберите ветку слева</div>
<div class="col-body">
<div class="editor" id="editor">
<div class="editor-empty">Слева — список веток. Выберите, чтобы увидеть и отредактировать её активный промпт.</div>
</div>
</div>
</section>
<aside class="col-panel">
<div class="col-head">Версии <span id="versions-intent" style="color:var(--fg);text-transform:none;font-weight:normal;"></span></div>
<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>
<div class="toast" id="toast"></div>
<script>
const $ = (id) => document.getElementById(id);
const esc = (s) => String(s ?? "").replace(/[&<>"']/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));
let intents = [];
let currentIntentCode = null;
let versions = [];
let currentSteps = []; // шаги выбранной ветки (если state machine)
let currentStepCode = null; // выбранный шаг в редакторе
let currentStepGraphs = []; // версии графа шагов выбранной ветки (Спринт 7.7)
let activeTab = "prompt"; // "prompt" | "steps"
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(path, opts);
if (!res.ok) {
let msg = `${res.status}`;
try { const d = await res.json(); msg = d.detail || JSON.stringify(d); } catch (_) {}
throw new Error(msg);
}
if (res.status === 204) return null;
return res.json();
}
function fmtDate(iso) {
try {
const d = new Date(iso);
return d.toLocaleString("ru-RU", { day: "2-digit", month: "2-digit", year: "2-digit", hour: "2-digit", minute: "2-digit" });
} catch (_) { return iso; }
}
/* ---------- intents list ---------- */
async function refreshIntents() {
try {
const d = await api("/intents");
intents = d.intents;
renderIntents();
} catch (e) {
$("intents-list").innerHTML = `<div class="mini" style="color:var(--err)">${esc(e.message)}</div>`;
}
}
function renderIntents() {
const responders = intents.filter(i => !i.code.startsWith("_"));
const system = intents.filter(i => i.code.startsWith("_"));
const renderItem = (i, isSystem) => `
<div class="intent-item ${i.code === currentIntentCode ? 'active' : ''} ${i.is_enabled ? '' : 'disabled'}" onclick="selectIntent('${i.code}')">
<div class="intent-top">
<span class="intent-name">${esc(i.name)}</span>
${isSystem
? '<span class="system-badge" title="Системная ветка — не выключается">система</span>'
: `<label class="switch" onclick="event.stopPropagation();">
<input type="checkbox" ${i.is_enabled ? 'checked' : ''} onchange="toggleIntent('${i.code}', this.checked)">
<span class="slider"></span>
</label>`}
</div>
<div class="intent-desc">${esc(i.description)}</div>
<div class="intent-version ${i.active_config_version ? '' : 'empty'}">
<span class="intent-code">${esc(i.code)}</span>
${i.active_config_version ? `· активна v${i.active_config_version}` : '· нет активной'}
</div>
</div>
`;
let html = '<div class="section-header">Ветки-ответчики</div>';
html += responders.map(i => renderItem(i, false)).join("");
if (system.length) {
html += '<div class="section-header">Системные</div>';
html += system.map(i => renderItem(i, true)).join("");
}
$("intents-list").innerHTML = html;
}
async function toggleIntent(code, enabled) {
try {
await api(`/intents/${code}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ is_enabled: enabled }),
});
toast(`${code}: ${enabled ? 'включена' : 'выключена'}`);
refreshIntents();
} catch (e) {
toast("Ошибка: " + e.message, "err");
}
}
/* ---------- select intent ---------- */
async function selectIntent(code) {
currentIntentCode = code;
activeTab = "prompt";
currentStepCode = null;
renderIntents();
await refreshSteps(code);
renderEditor();
await refreshVersions(code);
loadDocumentsForCurrentIntent();
loadEvalCasesForCurrentIntent();
}
async function refreshSteps(code) {
try {
const d = await api(`/intents/${encodeURIComponent(code)}/steps`);
currentSteps = d.steps || [];
} catch (_) {
currentSteps = [];
}
await refreshStepGraphs(code);
}
async function refreshStepGraphs(code) {
try {
const d = await api(`/intents/${encodeURIComponent(code)}/step-graphs`);
currentStepGraphs = d.graphs || [];
} catch (_) {
currentStepGraphs = [];
}
}
async function activateStepGraph(graphId) {
if (!currentIntentCode) return;
try {
await api(`/intents/${encodeURIComponent(currentIntentCode)}/step-graphs/${graphId}/activate`, {
method: "POST",
});
toast("Версия графа шагов активирована");
currentStepCode = null;
await refreshSteps(currentIntentCode);
renderEditor();
} catch (e) {
toast("Ошибка: " + e.message, "err");
}
}
function renderEditor() {
const intent = intents.find(i => i.code === currentIntentCode);
if (!intent) {
$("editor").innerHTML = '<div class="editor-empty">Выберите ветку слева.</div>';
$("editor-head").textContent = "Выберите ветку слева";
return;
}
$("editor-head").textContent = `${intent.name} · редактор`;
const hasSteps = currentSteps.length > 0;
const tabs = hasSteps
? `<div class="editor-tabs">
<button class="editor-tab ${activeTab === 'prompt' ? 'active' : ''}" onclick="switchTab('prompt')">Промпт</button>
<button class="editor-tab ${activeTab === 'steps' ? 'active' : ''}" onclick="switchTab('steps')">Шаги (${currentSteps.length})</button>
</div>`
: "";
let body;
if (hasSteps && activeTab === "steps") {
body = renderStepsPanel();
} else {
body = renderPromptPanel(intent);
}
$("editor").innerHTML = `
<h2>${esc(intent.name)}</h2>
<div class="sub"><code>${esc(intent.code)}</code> · ${esc(intent.description)}</div>
${tabs}
${body}
`;
// Если перешли на вкладку шагов и шаг не выбран — выбираем первый.
if (hasSteps && activeTab === "steps") {
if (!currentStepCode) currentStepCode = currentSteps[0].code;
renderStepEditor();
} else if (activeTab === "prompt") {
loadActiveIntoEditor();
}
}
function switchTab(tab) {
activeTab = tab;
renderEditor();
}
function toggleHint(key, force) {
const pop = document.getElementById(`${key}-hint-popover`);
const btn = document.getElementById(`${key}-hint-btn`);
if (!pop || !btn) return;
const willShow = typeof force === "boolean" ? force : !pop.classList.contains("show");
// Закрываем все остальные открытые подсказки — чтобы не накладывались.
document.querySelectorAll(".hint-popover.show").forEach(p => {
if (p !== pop) p.classList.remove("show");
});
document.querySelectorAll(".hint-btn.active").forEach(b => {
if (b !== btn) b.classList.remove("active");
});
pop.classList.toggle("show", willShow);
btn.classList.toggle("active", willShow);
}
// Клик вне любого popover-а — закрываем все.
document.addEventListener("click", (e) => {
const opened = document.querySelectorAll(".hint-popover.show");
if (!opened.length) return;
for (const pop of opened) {
const btn = document.getElementById(pop.id.replace("-popover", "-btn"));
if (pop.contains(e.target) || (btn && btn.contains(e.target))) return;
}
document.querySelectorAll(".hint-popover.show").forEach(p => p.classList.remove("show"));
document.querySelectorAll(".hint-btn.active").forEach(b => b.classList.remove("active"));
});
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">
</div>
<div class="field">
<label for="f-prompt">Системный промпт ветки</label>
<textarea id="f-prompt" class="prompt" spellcheck="false"></textarea>
</div>
<div class="field">
<label for="f-rules" class="with-hint">
<span>Правила (дополнение к промпту; свободная markdown-форма)</span>
<button type="button" class="hint-btn" id="rules-hint-btn" onclick="toggleHint('rules')" aria-label="Подсказка">i</button>
</label>
<div class="hint-popover" id="rules-hint-popover">
<button type="button" class="hint-close" onclick="toggleHint('rules', false)" aria-label="Закрыть">×</button>
<h4>Что писать в «Правила»</h4>
<p>Точечные дополнения к системному промпту в свободной markdown-форме. Технически склеиваются с основным промптом в один текст для модели — граница условная и нужна для оператора, чтобы не лазать в каркас при правке мелочей.</p>
<p><b>Что нормально писать:</b></p>
<ul>
<li>«Если пациент уже наблюдается у конкретного врача — записывай к нему по умолчанию».</li>
<li>«Бесплатная парковка для пациентов 2 часа — упомяни, если спросят».</li>
<li>«После 19:00 предлагай только следующий рабочий день».</li>
<li>«Дети до 14 лет — сразу <code>[INTENT_CHANGE: escalate_human]</code>, у нас нет педиатра».</li>
</ul>
<p><b>Что сюда не стоит:</b> изменения роли агента, тона или формата ответа — они в основном промпте. Условия выхода — отдельное поле ниже.</p>
</div>
<textarea id="f-rules" class="rules" spellcheck="false"></textarea>
</div>
<div class="field">
<label for="f-exits" class="with-hint">
<span>Условия выхода (когда отдать управление другой ветке)</span>
<button type="button" class="hint-btn" id="exits-hint-btn" onclick="toggleHint('exits')" aria-label="Подсказка">i</button>
</label>
<div class="hint-popover" id="exits-hint-popover">
<button type="button" class="hint-close" onclick="toggleHint('exits', false)" aria-label="Закрыть">×</button>
<h4>Что писать в «Условия выхода»</h4>
<p>Список ситуаций, когда ветка должна вместо обычного ответа выдать служебную строку <code>[INTENT_CHANGE: &lt;код_ветки&gt;]</code> и передать диалог другой ветке. Пишется в свободной markdown-форме, склеивается с системным промптом перед отправкой в модель.</p>
<p><b>Примеры:</b></p>
<ul>
<li>«Пациент описывает острое состояние (сильная боль, кровотечение, одышка) → <code>[INTENT_CHANGE: escalate_human]</code>».</li>
<li>«Спрашивает про цены, ДМС, оплату → <code>[INTENT_CHANGE: price_question]</code>».</li>
<li>«Просит соединить с оператором → <code>[INTENT_CHANGE: escalate_human]</code>».</li>
</ul>
<p><b>Не нужно:</b> правила для штатного хода диалога — это в «Правила». Тут только переключения между ветками.</p>
</div>
<textarea id="f-exits" class="rules" spellcheck="false"></textarea>
</div>
<div class="editor-actions">
<button onclick="saveVersion()">Сохранить как новую версию</button>
<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 `
<details class="prompt-block test-query-block">
<summary class="prompt-block-summary">Тест-вопрос от пациента <span class="pbs-hint">— ветка <code>${esc(intent.code)}</code></span></summary>
<div class="test-query">
${ragHint}
<div class="tq-cases" id="tq-cases-bar" style="display:none;">
<span class="tq-cases-label">Готовый кейс:</span>
<input list="tq-cases-list" id="tq-cases-input" placeholder="— выбрать или начать вводить —" autocomplete="off">
<datalist id="tq-cases-list"></datalist>
<button type="button" class="tq-cases-btn" onclick="pickRandomCase()">🎲 Случайный</button>
<span class="tq-cases-count" id="tq-cases-count"></span>
</div>
<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>
</details>
`;
}
// Готовые кейсы маршрутизатора для текущей ветки — заполняются loadEvalCasesForCurrentIntent.
let currentEvalCases = [];
async function loadEvalCasesForCurrentIntent() {
const bar = $("tq-cases-bar");
const list = $("tq-cases-list");
const input = $("tq-cases-input");
const count = $("tq-cases-count");
if (!bar || !list || !input || !count) return;
currentEvalCases = [];
if (!currentIntentCode) {
bar.style.display = "none";
return;
}
try {
const r = await api(`/eval/router-cases?intent_code=${encodeURIComponent(currentIntentCode)}&limit=500`);
currentEvalCases = r.cases || [];
} catch (e) {
bar.style.display = "none";
return;
}
if (!currentEvalCases.length) {
bar.style.display = "none";
return;
}
bar.style.display = "";
// datalist: значение = текст кейса. Браузер показывает выпадашку при фокусе/наборе.
list.innerHTML = currentEvalCases.map(c => {
const note = c.count > 1 ? ` (×${c.count})` : "";
return `<option value="${esc(c.text)}">${esc(c.text)}${note}</option>`;
}).join("");
count.textContent = `${currentEvalCases.length} кейсов`;
input.value = "";
// При выборе значения из datalist — копируем в textarea вопроса.
input.oninput = () => {
const picked = currentEvalCases.find(c => c.text === input.value);
if (picked) {
const ta = $("tq-text");
if (ta) {
ta.value = picked.text;
ta.focus();
}
// Сбрасываем поле выбора, чтобы было видно «свободное» состояние.
input.value = "";
}
};
}
function pickRandomCase() {
if (!currentEvalCases.length) return;
const c = currentEvalCases[Math.floor(Math.random() * currentEvalCases.length)];
const ta = $("tq-text");
if (ta) {
ta.value = c.text;
ta.focus();
}
}
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 renderStepGraphsBlock() {
if (!currentStepGraphs.length) return "";
const cards = currentStepGraphs.map(g => `
<div class="graph-card ${g.is_active ? 'active' : ''}">
<div class="graph-card-head">
<span class="graph-card-name">${esc(g.name)}</span>
${g.is_active
? '<span class="graph-badge active">активная</span>'
: `<button class="graph-activate" onclick="activateStepGraph(${g.id})">Активировать</button>`}
</div>
<div class="graph-card-meta">v${g.version} · ${g.steps_count} ${pluralSteps(g.steps_count)}</div>
</div>
`).join("");
return `
<details class="graph-versions-block" open>
<summary class="graph-versions-summary">Версии графа шагов <span class="pbs-hint">— активная используется в чате и Песочнице</span></summary>
<div class="graph-versions-list">${cards}</div>
</details>
`;
}
function pluralSteps(n) {
const m10 = n % 10, m100 = n % 100;
if (m10 === 1 && m100 !== 11) return "шаг";
if (m10 >= 2 && m10 <= 4 && (m100 < 12 || m100 > 14)) return "шага";
return "шагов";
}
function renderStepsPanel() {
const chips = currentSteps.map(s => `
<div class="step-chip ${s.code === currentStepCode ? 'active' : ''}"
onclick="selectStep('${esc(s.code)}')">
<span class="step-order">${s.order_index + 1}.</span>${esc(s.code)}
</div>
`).join("");
return `
${renderStepGraphsBlock()}
<div class="steps-chips">${chips}</div>
<div id="step-editor"></div>
`;
}
function renderStepEditor() {
const step = currentSteps.find(s => s.code === currentStepCode);
if (!step) {
$("step-editor").innerHTML = '<div class="mini">Выберите шаг выше.</div>';
return;
}
const otherCodes = currentSteps.map(s => s.code);
const allowedSet = new Set(step.allowed_next || []);
const checkboxes = otherCodes.map(code => `
<label>
<input type="checkbox" value="${esc(code)}" ${allowedSet.has(code) ? 'checked' : ''}>
${esc(code)}
</label>
`).join("");
$("step-editor").innerHTML = `
<div class="field">
<label for="f-step-name">Имя шага</label>
<input type="text" id="f-step-name" maxlength="200" value="${esc(step.name)}">
</div>
<div class="field">
<label for="f-step-prompt">Промпт шага (склеивается с промптом ветки)</label>
<textarea id="f-step-prompt" class="prompt" spellcheck="false">${esc(step.system_prompt)}</textarea>
</div>
<div class="field">
<label>Допустимые переходы (allowed_next)</label>
<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>
<textarea id="f-step-guards" class="rules" spellcheck="false">${esc(JSON.stringify(step.guards || {}, null, 2))}</textarea>
</div>
<div class="editor-actions">
<button onclick="saveStep()">Сохранить шаг</button>
<button class="secondary" onclick="selectStep('${esc(step.code)}')">Отменить правки</button>
</div>
`;
}
function selectStep(code) {
currentStepCode = code;
// Rerender chips для подсветки, редактор обновляется отдельно.
const panel = $("editor");
if (!panel) return;
// Перерисуем только секцию шагов.
const chipsRoot = panel.querySelector(".steps-chips");
if (chipsRoot) {
chipsRoot.innerHTML = currentSteps.map(s => `
<div class="step-chip ${s.code === currentStepCode ? 'active' : ''}"
onclick="selectStep('${esc(s.code)}')">
<span class="step-order">${s.order_index + 1}.</span>${esc(s.code)}
</div>
`).join("");
}
renderStepEditor();
}
async function saveStep() {
if (!currentIntentCode || !currentStepCode) return;
const name = $("f-step-name").value.trim();
const system_prompt = $("f-step-prompt").value;
const allowed_next = Array.from($("f-step-allowed").querySelectorAll("input[type=checkbox]:checked"))
.map(cb => cb.value);
let guards = {};
try {
guards = JSON.parse($("f-step-guards").value.trim() || "{}");
if (typeof guards !== "object" || Array.isArray(guards)) throw new Error("JSON должен быть объектом");
} catch (e) {
toast("Защитные условия: невалидный JSON — " + e.message, "err");
return;
}
try {
const r = await api(`/intents/${encodeURIComponent(currentIntentCode)}/steps/${encodeURIComponent(currentStepCode)}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name, system_prompt, allowed_next, guards }),
});
toast(`Шаг ${r.code} сохранён`);
await refreshSteps(currentIntentCode);
renderEditor();
} catch (e) {
toast("Ошибка: " + e.message, "err");
}
}
async function loadActiveIntoEditor() {
if (!currentIntentCode) return;
const intent = intents.find(i => i.code === currentIntentCode);
if (!intent || !intent.active_config_id) {
// Новая ветка без активной версии — пусто.
if ($("f-name")) {
$("f-name").value = "";
$("f-prompt").value = "";
$("f-rules").value = "";
if ($("f-exits")) $("f-exits").value = "";
}
return;
}
try {
const c = await api(`/configs/active?intent_code=${encodeURIComponent(currentIntentCode)}`);
$("f-name").value = c.name ? `${c.name} (на основе v${c.version})` : `v${c.version} — копия`;
$("f-prompt").value = c.system_prompt;
$("f-rules").value = c.rules_text || "";
if ($("f-exits")) $("f-exits").value = c.exit_conditions_text || "";
} catch (e) {
toast("Не удалось загрузить активную: " + e.message, "err");
}
}
function loadIntoEditor(configId) {
const c = versions.find(x => x.id === configId);
if (!c) return;
$("f-name").value = c.name ? `${c.name} (на основе v${c.version})` : `v${c.version} — копия`;
$("f-prompt").value = c.system_prompt;
$("f-rules").value = c.rules_text || "";
if ($("f-exits")) $("f-exits").value = c.exit_conditions_text || "";
toast(`Загружена v${c.version}`);
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);
$("versions-intent").textContent = intent ? `${intent.name}` : "";
try {
const d = await api(`/configs?intent_code=${encodeURIComponent(code)}`);
versions = d.configs;
renderVersions();
} catch (e) {
$("versions").innerHTML = `<div class="mini" style="color:var(--err)">${esc(e.message)}</div>`;
}
}
function renderVersions() {
if (!versions.length) {
$("versions").innerHTML = '<div class="mini">версий ещё нет</div>';
return;
}
$("versions").innerHTML = `<div class="versions">` + versions.map(c => `
<div class="version-card ${c.is_active ? "active" : ""}">
<div class="version-head">
<span class="v-num">v${c.version}</span>
<span class="v-name ${c.name ? "" : "empty"}" title="${esc(c.name || '')}">${esc(c.name || "без имени")}</span>
${c.is_active ? '<span class="v-active-badge">активная</span>' : ""}
</div>
<div class="v-meta">${esc(fmtDate(c.created_at))} · промпт ${c.system_prompt.length} симв.${c.rules_text ? " · правил " + c.rules_text.length : ""}</div>
<div class="v-actions">
<button onclick="loadIntoEditor(${c.id})">Загрузить</button>
${!c.is_active ? `<button class="primary" onclick="activateVersion(${c.id})">Активировать</button>` : ""}
${!c.is_active ? `<button class="del" onclick="deleteVersion(${c.id}, ${c.version})">Удалить</button>` : '<button class="del" disabled title="Активную удалить нельзя">Удалить</button>'}
</div>
</div>
`).join("") + `</div>`;
}
/* ---------- save / activate / delete ---------- */
async function saveVersion() {
if (!currentIntentCode) return;
const intent = intents.find(i => i.code === currentIntentCode);
if (!intent) return;
const name = $("f-name").value.trim();
const system_prompt = $("f-prompt").value.trim();
const rules_text = $("f-rules").value.trim();
const exit_conditions_text = $("f-exits") ? $("f-exits").value.trim() : "";
const activate = $("chk-activate").checked;
if (!system_prompt) {
toast("Системный промпт не может быть пустым", "err");
return;
}
try {
const r = await api("/configs", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
intent_id: intent.id,
name: name || null,
system_prompt,
rules_text,
exit_conditions_text,
activate,
}),
});
toast(`v${r.version} сохранена${activate ? " · активирована" : ""}`);
$("chk-activate").checked = false;
await refreshIntents();
await refreshVersions(currentIntentCode);
} catch (e) {
toast("Ошибка: " + e.message, "err");
}
}
async function activateVersion(id) {
try {
const r = await api(`/configs/${id}/activate`, { method: "POST" });
toast(`Активирована v${r.version}`);
await refreshIntents();
await refreshVersions(currentIntentCode);
} catch (e) {
toast("Ошибка: " + e.message, "err");
}
}
async function deleteVersion(id, version) {
if (!confirm(`Удалить v${version}?`)) return;
try {
await api(`/configs/${id}`, { method: "DELETE" });
toast(`v${version} удалена`);
await refreshVersions(currentIntentCode);
} catch (e) {
toast("Ошибка: " + e.message, "err");
}
}
/* ---------- init ---------- */
(async function init() {
await refreshIntents();
if (intents.length) selectIntent(intents[0].code);
})();
</script>
</body>
</html>