feat(sprint6c+sprint7): терминология, сверка примеров с кодом, мульти-RAG (часть A)

Спринт 6c — терминология и сверка документации с реальным кодом:
- Словарь терминов в static/docs.html: «маршрутизатор» вместо «роутер»,
  «защитное условие» вместо «guard», «пошаговая ветка» вместо «многошаговая».
  Разделены концепты «намерение» (intent) и «ветка» (branch) с пометкой,
  что в коде они хранятся как одна сущность 1:1.
- Песочница: «Решение маршрутизатора» виден всегда (зелёный/жёлтый),
  счётчик переключений «N из 3» отдельной плашкой, бейджи под словарь.
- Настройки: «Условия перехода» → «Защитные условия (guards, JSON)».
- GRAPH_ARCHITECTURE_v4.md: имена полей thread_state и слоты приведены
  к реальной БД (db/models/thread_state.py) и таксономии промптов шагов
  (prompts/intents/new_booking/steps/). Ссылки на *_v2 примеры. На v3
  поставлена шапка «устарело».
- 4 примера переписаны как *_v2: реальные current_intent_code/
  current_step_code/slots_json, реальные allowed_next без двойных переходов,
  реальная таксономия слотов name/reason/specialist/preferred_time/confirmed.
  Удалены вымышленные CRM tool calls и слоты, которых нет в коде.
- static/example.html — параметризованная страница с навигацией между
  4 примерами; роут GET /api/docs/examples/{name} в main.py отдаёт
  markdown без дублирования файлов.
- Редактирование документов в Отладке: GET/PUT /documents/{id}/raw,
  textarea с переразметкой и обновлением Chroma при сохранении.

Спринт 7, часть A — мульти-RAG через подписку ветка↔документы:
- Миграция: таблица intent_documents (M:N), модель IntentDocument,
  индекс по document_id для обратного поиска.
- API: GET/PUT /intents/{code}/documents и GET/PUT /documents/{id}/intents
  с PUT-семантикой «полный список», атомарно. Сервис
  services/intent_document_service.py.
- Retrieval-фильтр в chat_service: подтягивает document_ids активной
  ветки и передаёт в vectorstore.query(). Дефолт пустой подписки —
  document_ids=[] (= 0 чанков), не «вся коллекция»: пустая подписка
  означает «ветка не настроена», подмешивать случайное хуже, чем
  ничего. vectorstore.query() различает None (нет фильтра) и [] (0).
- UI Настроек: блок «Документы базы знаний» в правом сайдбаре,
  всегда видим независимо от вкладки, сортировка по имени, счётчик
  «N из M», PUT при сохранении.
- UI Отладки: третья кнопка «привязка» рядом с «удалить» —
  раскрывашка со списком веток (галочки), быстрая привязка прямо
  на странице загрузки.
- Песочница: блок «Срез RAG» с подпиской/найдено, ворнинг при пустой
  подписке. Поле rag_subscription в QueryResponse и ChatResponse.
- Системный промпт страницы Отладки переехал в обычную ветку _debug
  («Страница отладки»). Удалён prompts/system_prompt.md и логика
  DEFAULT_SYSTEM_PROMPT в llm_client. routers/query.py подтягивает
  активный конфиг ветки _debug и её подписки. Дефолт пустой подписки
  для _debug — None (вся коллекция), не [] как для пациентских — чтобы
  Отладка работала «из коробки». На странице Отладки info-bar показывает
  активную версию и счётчик подписок, ссылка → Настройки.
- Тест-блок «Тест-вопрос» в центре Настроек: расширил /query
  параметрами intent_code (default _debug), system_prompt (override
  для теста черновика из textarea), disable_rag (для _router).
  Редактор промпта обёрнут в <details open> — можно свернуть до
  одной строки. Под ним — три колонки результата (RAG / промпт /
  ответ). Для _router показывается подсказка про отсутствие RAG.

Документы:
- data/datasets/*.md — наработки по 6 веткам (рабочие материалы оператора).
- docs/BRANCH_MAP_AND_PROMPTS_v1.md, docs/OPTIMIZATION_CONVERSION_v1.md,
  docs/guides/state_machine_and_slots.md.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
AR 15 M4
2026-04-27 20:00:44 +05:00
parent f348570b1b
commit 52b46bc53e
43 changed files with 5914 additions and 105 deletions
+86 -25
View File
@@ -201,7 +201,7 @@
<article>
<h1>Как работает мультиагентная система</h1>
<p class="lead">Здесь объясняется, что такое <b>ветка (intent)</b>, как реплика пациента доходит до ответа, и какие защитные механизмы стоят на пути «петель» и потерянного контекста. Английские термины оставлены в скобках — на них завязан код и логи.</p>
<p class="lead">Здесь объясняется, что такое <b>намерение (intent)</b> и <b>ветка (branch)</b>, как реплика пациента доходит до ответа, и какие защитные механизмы стоят на пути «петель» и потерянного контекста. Английские термины оставлены в скобках — на них завязан код и логи.</p>
<div class="toc">
<strong>Содержание</strong>
@@ -211,23 +211,42 @@
<li><a href="#flow">Что происходит на каждой реплике</a></li>
<li><a href="#guards">Защитные механизмы</a></li>
<li><a href="#where">Где что настраивается</a></li>
<li><a href="#examples">Разобранные примеры</a></li>
</ol>
</div>
<h2 id="why">Зачем это всё</h2>
<p>На пилоте у нас был «один большой системный промпт» — модель пыталась одновременно записывать на приём, отвечать на вопросы по ценам и эскалировать острые случаи. По мере усложнения скрипта запись начинала «плыть»: модель забывала шаги, путала ветки, перескакивала через мини-интервью.</p>
<p>Мы перешли на <b>графовую архитектуру (graph-based routing)</b>: реплика пациента сначала идёт в <b>маршрутизатор (router)</b>, который определяет тему, а потом — в <b>ветку (intent)</b>, отвечающую только за свой узкий сценарий. У сложных веток внутри есть собственный <b>пошаговый сценарий (state machine)</b>.</p>
<p>Мы перешли на <b>графовую архитектуру (graph-based routing)</b>: реплика пациента сначала идёт в <b>маршрутизатор (router)</b>, который определяет <b>намерение (intent)</b> — категорию темы. По коду намерения подбирается <b>ветка (branch)</b>, отвечающая только за свой узкий сценарий. У сложных веток внутри есть собственный <b>пошаговый сценарий (state machine)</b>.</p>
<h2 id="terms">Главные термины</h2>
<div class="term-card">
<div class="term-head"><strong>Ветка</strong> <span class="term-en">(intent)</span></div>
<div class="term-body">Изолированный «под-агент» с собственным системным промптом. Отвечает за одну тему: запись, перенос, цены, медицинский вопрос, общая справка, перевод на оператора. У каждой ветки — свой код (<code>new_booking</code>, <code>price_question</code> и т. п.) и активная версия настроек.</div>
<div class="term-head"><strong>Намерение</strong> <span class="term-en">(intent)</span></div>
<div class="term-body">Категория темы, которую распознал маршрутизатор: <code>new_booking</code>, <code>price_question</code>, <code>general_info</code>, <code>medical_question</code>, <code>reschedule</code>, <code>escalate_human</code>. У нас намерение жёстко связано с <b>веткой</b> 1:1 — код намерения совпадает с кодом ветки.</div>
</div>
<div class="term-card">
<div class="term-head"><strong>Ветка</strong> <span class="term-en">(branch)</span></div>
<div class="term-body">
<p>Изолированный «под-агент» с собственным системным промптом. Отвечает за одно намерение: запись, перенос, цены, медицинский вопрос, общая справка, перевод на оператора. У каждой ветки — свой код, активная версия настроек, опционально — пошаговый сценарий и защитные условия.</p>
<p style="font-size:12.5px; color:var(--muted); margin-top:8px;"><em>В коде и в БД ветка хранится в таблице <code>intents</code> — исторически. Концептуально это branch, но из-за связи 1:1 с намерением мы пока не разделяли их в коде.</em></p>
</div>
</div>
<div class="term-card">
<div class="term-head"><strong>Маршрутизатор</strong> <span class="term-en">(router)</span></div>
<div class="term-body">Системная ветка <code>_router</code>: отдельный, дешёвый вызов модели, который по последней реплике пациента возвращает один из кодов веток. Не отвечает пациенту напрямую — только классифицирует. Вызывается на КАЖДОЙ реплике, не один раз при входе.</div>
<div class="term-body">Системная ветка <code>_router</code>: отдельный, дешёвый вызов модели, который по последней реплике пациента возвращает один из кодов намерений. Не отвечает пациенту напрямую — только классифицирует. Вызывается на КАЖДОЙ реплике, не один раз при входе.</div>
</div>
<div class="term-card">
<div class="term-head"><strong>Решение маршрутизатора</strong> <span class="term-en">(router decision)</span></div>
<div class="term-body">Код намерения, который маршрутизатор вернул на конкретной реплике. Видно в Песочнице бейджем <b>«Решение маршрутизатора: <code>&lt;код&gt;</code>»</b> под ответом ассистента. Если решение совпало с активной веткой — мы остались в ней; если разошлось — сработало либо переключение, либо удержание в ветке.</div>
</div>
<div class="term-card">
<div class="term-head"><strong>Активная ветка</strong> <span class="term-en">(active branch)</span></div>
<div class="term-body">Ветка, которая реально сформировала ответ ассистента на этой реплике. Может совпадать с решением маршрутизатора (норма), а может расходиться (если сработало удержание в ветке или возврат из отложенного сценария). Видно в Песочнице бейджем <b>«Активная ветка: <code>&lt;код&gt;</code>»</b>.</div>
</div>
<div class="term-card">
@@ -266,11 +285,11 @@
<p>Защита от ложных переключений внутри пошагового сценария. Это <b>не второй вызов маршрутизатора</b> — один вызов, но с расширенным промптом. Работает пошагово:</p>
<ol style="margin: 8px 0; padding-left: 20px; line-height: 1.8;">
<li>Пациент пишет что-то вроде <em>«а сколько стоит приём?»</em> внутри записи.</li>
<li>Маршрутизатор анализирует реплику и возвращает: <code>general_info</code> (в Песочнице — бейдж «роутер предложил: general_info»).</li>
<li>Система видит: тред уже идёт по многошаговой ветке <code>new_booking</code>, шаг <code>qualify</code>. Переключать опасно — потеряем контекст записи.</li>
<li>Маршрутизатор анализирует реплику и возвращает: <code>general_info</code> (в Песочнице — бейдж «Решение маршрутизатора: <code>general_info</code>»).</li>
<li>Система видит: тред уже идёт по пошаговой ветке <code>new_booking</code>, шаг <code>qualify</code>. Переключать опасно — потеряем контекст записи.</li>
<li>Вместо переключения: <code>effective_code</code> остаётся <code>new_booking</code>, в системный промпт ветки добавляется блок:
<pre><code>[ПОДСКАЗКА РОУТЕРА]
Роутер счёл, что тема — `general_info`.
Маршрутизатор счёл, что тема — `general_info`.
Ты ведёшь сценарий `new_booking`.
Если пациент сменил тему — выдай [INTENT_CHANGE: general_info].
Если реплика в сценарии — зафиксируй в слот и продолжай.</code></pre>
@@ -278,11 +297,11 @@
<li>Модель <code>new_booking</code> получает весь этот контекст и сама решает:
<ul style="margin: 4px 0; padding-left: 18px;">
<li>Ответить на вопрос и остаться → вернёт <code>{"state_after": "qualify", "soft_insertion": true}</code> → бейдж «удержались в ветке» + «боковой вопрос».</li>
<li>Решить, что тема реально сменилась → вернёт <code>[INTENT_CHANGE: general_info]</code> жёсткое переключение.</li>
<li>Решить, что тема реально сменилась → вернёт <code>[INTENT_CHANGE: general_info]</code> → переключение ветки.</li>
</ul>
</li>
</ol>
<p style="margin-top:6px;">Ключевое: решение принимает <b>модель ветки за один вызов</b>, а не отдельный роутер. Подсказка роутера — просто контекст в промпте.</p>
<p style="margin-top:6px;">Ключевое: решение принимает <b>модель ветки за один вызов</b>, а не отдельный маршрутизатор. Подсказка маршрутизатора — просто контекст в промпте.</p>
</div>
</div>
@@ -301,7 +320,7 @@
<div class="term-card">
<div class="term-head"><strong>Структурированный ответ ветки</strong> <span class="term-en">(structured output)</span></div>
<div class="term-body">Каждая sm-ветка возвращает не только текст пациенту, но и служебный JSON-блок в хвосте ответа:
<div class="term-body">Каждая пошаговая ветка возвращает не только текст пациенту, но и служебный JSON-блок в хвосте ответа:
<pre><code>STATE_JSON: {"state_after": "qualify", "slots_updated": {"name": "Алексей"}}</code></pre>
Парсер вырезает этот блок (пациент его не видит), валидатор проверяет легальность <code>state_after</code>, обновляет состояние диалога. Необязательный флаг <code>soft_insertion: true</code> сигнализирует, что это был боковой ответ без продвижения сценария.</div>
</div>
@@ -312,10 +331,10 @@
</div>
<div class="term-card">
<div class="term-head"><strong>Guard (условие перехода)</strong> <span class="term-en">(guard)</span></div>
<div class="term-head"><strong>Защитное условие</strong> <span class="term-en">(guard)</span></div>
<div class="term-body">
<p>Правило, которое блокирует переход шага вперёд, пока не заполнены нужные слоты. Хранится в поле <code>guards</code> каждого шага (Настройки → Шаги → поле Guards). Проверяется <b>после</b> того, как модель вернула корректный <code>state_after</code> — то есть даже если модель «захотела» перейти, валидатор не пустит без нужных данных.</p>
<p>Формат — JSON-объект, где каждый ключ — имя guard'а:</p>
<p>Правило, которое блокирует переход шага вперёд, пока не заполнены нужные слоты. Хранится в поле <code>guards</code> каждого шага (Настройки → Шаги → поле «Защитные условия»). Проверяется <b>после</b> того, как модель вернула корректный <code>state_after</code> — то есть даже если модель «захотела» перейти, валидатор не пустит без нужных данных.</p>
<p>Формат — JSON-объект, где каждый ключ — имя защитного условия:</p>
<pre><code>{
"require_legal_rep": {
"description": "Для записи ребёнка нужны ФИО и телефон законного представителя",
@@ -326,23 +345,43 @@
}</code></pre>
<p>Поля:</p>
<ul style="margin: 4px 0; padding-left: 20px; line-height: 1.8;">
<li><code>trigger_slot</code> — слот, при значении которого guard активируется. Если опущен — guard активен всегда.</li>
<li><code>trigger_slot</code> — слот, при значении которого защитное условие активируется. Если опущен — условие активно всегда.</li>
<li><code>trigger_value</code> — значение, которое должен иметь <code>trigger_slot</code> для активации (например, <code>true</code>).</li>
<li><code>required_slots</code> — список слотов, которые должны быть заполнены для разрешения перехода.</li>
<li><code>description</code> — пояснение для операторов; показывается в «Состоянии диалога» в Песочнице при срабатывании.</li>
</ul>
<p>Сейчас guard задан на шаге <code>qualify</code> ветки <code>new_booking</code>: при <code>is_child: true</code> нельзя перейти в <code>present</code>, пока не заполнены <code>legal_rep_name</code> и <code>legal_rep_phone</code>. В Песочнице при срабатывании появляется красный блок: <b>«🔒 guard require_legal_rep не пройден — ждём: legal_rep_name, legal_rep_phone»</b>.</p>
<p>Сейчас защитное условие задано на шаге <code>qualify</code> ветки <code>new_booking</code>: при <code>is_child: true</code> нельзя перейти в <code>present</code>, пока не заполнены <code>legal_rep_name</code> и <code>legal_rep_phone</code>. В Песочнице при срабатывании появляется красный блок: <b>«🔒 защитное условие <code>require_legal_rep</code> не пройдено — ждём: <code>legal_rep_name</code>, <code>legal_rep_phone</code>»</b>.</p>
</div>
</div>
<div class="term-card">
<div class="term-head"><strong>Счётчик переключений</strong> <span class="term-en">(handoff_count)</span></div>
<div class="term-body">Сколько раз в этом диалоге произошло переключение ветки. Растёт при каждом переключении и сбрасывается в 0 при возврате из отложенного сценария. В Песочнице показан как <b>«Переключений: N из 3»</b> в карточке состояния диалога. По достижении кап-значения срабатывает защита от петли.</div>
</div>
<div class="term-card">
<div class="term-head"><strong>Защита от петли</strong> <span class="term-en">(routing loop guard)</span></div>
<div class="term-body">Счётчик <code>handoff_count</code> в состоянии диалога считает все переключения ветки. При превышении 3 переключений за диалог следующее переключение блокируется: диалог автоматически уходит в <code>escalate_human</code> с шаблонным ответом «Уточню детали с администратором клиники, свяжемся с вами в течение ближайшего часа». Это страховка от циклов вроде «запись ↔ цены ↔ запись ↔ цены».</div>
<div class="term-body">При превышении 3 переключений за диалог следующее переключение блокируется: диалог автоматически уходит в <code>escalate_human</code> с причиной <code>routing_loop</code> и шаблонным ответом «Уточню детали с администратором клиники, свяжемся с вами в течение ближайшего часа». Это страховка от циклов вроде «запись ↔ цены ↔ запись ↔ цены».</div>
</div>
<div class="term-card">
<div class="term-head"><strong>Причина передачи оператору</strong> <span class="term-en">(escalation_reason)</span></div>
<div class="term-body">
<p>Когда диалог уходит в ветку <code>escalate_human</code>, фиксируется причина — для статистики и для оператора, который примет диалог. Возможные значения:</p>
<ul style="margin: 4px 0; padding-left: 20px; line-height: 1.8;">
<li><code>acute_pain</code> — пациент описывает острое состояние.</li>
<li><code>surgery</code> — упоминание операции, наркоза, стационара.</li>
<li><code>angry</code> — агрессивный или раздражённый тон.</li>
<li><code>explicit_request</code> — пациент явно просит оператора («дайте человека»).</li>
<li><code>routing_loop</code> — сработала защита от петли (3+ переключений ветки).</li>
</ul>
<p>В Песочнице видно в карточке ответа: <b>«Передача оператору · причина: <code>&lt;reason&gt;</code>»</b>. Само значение модель возвращает в служебном сигнале <code>[INTENT_CHANGE: escalate_human|<em>reason</em>]</code> либо его подставляет код (например, <code>routing_loop</code> при срабатывании защиты).</p>
</div>
</div>
<div class="term-card">
<div class="term-head"><strong>Состояние диалога</strong> <span class="term-en">(thread state)</span></div>
<div class="term-body">Запись в БД (одна на диалог), хранящая текущую ветку, текущий шаг (если есть), собранные слоты, <code>handoff_count</code> и поля отложенного сценария. Видно в Песочнице справа, в блоке «Состояние диалога».</div>
<div class="term-body">Запись в БД (одна на диалог), хранящая активную ветку, текущий шаг (если есть), собранные слоты, счётчик переключений и поля отложенного сценария. Видно в Песочнице справа, в блоке «Состояние диалога».</div>
</div>
<h2 id="flow">Что происходит на каждой реплике</h2>
@@ -352,7 +391,7 @@
<div class="flow-num">1</div>
<div class="flow-text">
<b>Маршрутизатор классифицирует реплику.</b>
<div class="muted">Отдельный вызов модели с короткой системой и историей. Возвращает один код ветки.</div>
<div class="muted">Отдельный вызов модели с короткой системой и историей. Возвращает один код намерения.</div>
</div>
</div>
@@ -368,7 +407,7 @@
<div class="flow-num">3</div>
<div class="flow-text">
<b>Применяем удержание в ветке.</b>
<div class="muted">Если диалог уже идёт по sm-ветке и маршрутизатор предлагает другую — состояние не сбрасываем, в системный промпт ветки добавляется блок <code>[ПОДСКАЗКА РОУТЕРА]</code>.</div>
<div class="muted">Если диалог уже идёт по пошаговой ветке и маршрутизатор предлагает другую — состояние не сбрасываем, в системный промпт ветки добавляется блок <code>[ПОДСКАЗКА РОУТЕРА]</code>.</div>
</div>
</div>
@@ -392,7 +431,7 @@
<div class="flow-num">6</div>
<div class="flow-text">
<b>Парсим ответ.</b>
<div class="muted">Если есть <code>[INTENT_CHANGE]</code> — переключаемся в новую ветку (если из sm-ветки — запоминаем в отложенный сценарий) и зовём модель ещё раз. Если есть <code>STATE_JSON:</code> — валидируем переход, обновляем шаг и сливаем слоты.</div>
<div class="muted">Если есть <code>[INTENT_CHANGE]</code> — переключаемся в новую ветку (если из пошаговой ветки — запоминаем в отложенный сценарий) и зовём модель ещё раз. Если есть <code>STATE_JSON:</code> — валидируем переход, обновляем шаг и сливаем слоты.</div>
</div>
</div>
@@ -415,8 +454,8 @@
<ul>
<li><b>Удержание в ветке</b> защищает от ложного сброса сценария на коротких репликах вроде «Алексей» или «болит ухо».</li>
<li><b>Валидатор переходов</b> блокирует «прыжки через шаг» — модель не сможет уйти из <code>intro</code> сразу в <code>book</code>.</li>
<li><b>Guard (условие перехода)</b> блокирует переход вперёд до заполнения обязательных слотов. Настраивается в поле Guards каждого шага. Пример: при записи ребёнка нельзя уйти с шага <code>qualify</code>, пока не указаны ФИО и телефон родителя.</li>
<li><b>Защита от петли</b> ограничивает число переключений ветки за диалог. После 3-го — авто-перевод на оператора.</li>
<li><b>Защитное условие</b> блокирует переход вперёд до заполнения обязательных слотов. Настраивается в поле «Защитные условия» каждого шага. Пример: при записи ребёнка нельзя уйти с шага <code>qualify</code>, пока не указаны ФИО и телефон родителя.</li>
<li><b>Защита от петли</b> ограничивает число переключений ветки за диалог. После 3-го — авто-перевод на оператора с причиной <code>routing_loop</code>.</li>
<li><b>Отложенный сценарий</b> возвращает прерванный сценарий с теми же слотами и шагом — пациент не должен повторять имя или повод.</li>
<li><b>Ретрай LLM</b>: и маршрутизатор, и ветка делают один повтор при сетевом сбое DeepSeek. При полном падении — откат транзакции и понятный ответ «модель временно недоступна».</li>
</ul>
@@ -424,12 +463,34 @@
<h2 id="where">Где что настраивается</h2>
<ul>
<li><a href="/settings.html">Настройки</a> — список веток, активные версии промптов, поля «Системный промпт», «Правила», «Условия выхода». Для веток с пошаговым сценарием — вкладка «Шаги» с редактором каждого шага и его допустимых переходов.</li>
<li><a href="/sandbox.html">Песочница</a> — живые диалоги от лица пациента. В правой панели видны: состояние диалога (ветка, шаг, слоты, счётчик переключений, отложенный сценарий), решение маршрутизатора, RAG-фрагменты и собранный системный промпт.</li>
<li><a href="/sandbox.html">Песочница</a> — живые диалоги от лица пациента. В правой панели видны: состояние диалога (активная ветка, шаг, слоты, счётчик переключений, отложенный сценарий), решение маршрутизатора, RAG-фрагменты и собранный системный промпт.</li>
<li><a href="/">Отладка</a> — база знаний (загрузка / переразметка документов), одиночные тестовые вопросы без памяти диалога.</li>
</ul>
<h2 id="examples">Разобранные примеры</h2>
<p>Четыре пошаговых разбора реальных сценариев: что делает маршрутизатор, какая ветка отвечает, на каком шаге, какие слоты заполняются и что происходит в состоянии диалога после каждой реплики. Полезно для понимания архитектуры в работе и как ориентир для будущего eval-набора.</p>
<div class="flow-card" style="padding:0;">
<a href="/example.html?id=01_basic_booking_v2" style="display:block;padding:14px 18px;border-bottom:1px solid var(--border);text-decoration:none;color:var(--fg);">
<div style="font-size:14px;font-weight:600;color:var(--accent);margin-bottom:2px;">Пример 01 · Базовая запись к ЛОР-врачу</div>
<div style="font-size:13px;color:var(--muted);">Линейный happy path: <code>intro → qualify → present → offer_time → book → close</code>. Никаких защитных условий, никаких боковых вопросов, никаких переключений. Самый простой случай — на нём удобно увидеть базовое поведение системы.</div>
</a>
<a href="/example.html?id=02_price_during_booking_v2" style="display:block;padding:14px 18px;border-bottom:1px solid var(--border);text-decoration:none;color:var(--fg);">
<div style="font-size:14px;font-weight:600;color:var(--accent);margin-bottom:2px;">Пример 02 · Вопрос про цену в середине записи</div>
<div style="font-size:13px;color:var(--muted);">Один сценарий разобран в двух вариантах: <b>боковой вопрос</b> (без выхода из ветки) и <b>переключение ветки с возвратом</b> через отложенный сценарий. Лучший пример для понимания различий между этими двумя механизмами.</div>
</a>
<a href="/example.html?id=03_child_patient_guard_v2" style="display:block;padding:14px 18px;border-bottom:1px solid var(--border);text-decoration:none;color:var(--fg);">
<div style="font-size:14px;font-weight:600;color:var(--accent);margin-bottom:2px;">Пример 03 · Запись ребёнка — защитное условие</div>
<div style="font-size:13px;color:var(--muted);">Срабатывание единственного реального защитного условия <code>require_legal_rep</code> на шаге <code>qualify</code>: при <code>is_child=true</code> диалог не уходит в <code>present</code>, пока не собраны <code>legal_rep_name</code> и <code>legal_rep_phone</code>.</div>
</a>
<a href="/example.html?id=04_general_info_simple_v2" style="display:block;padding:14px 18px;text-decoration:none;color:var(--fg);">
<div style="font-size:14px;font-weight:600;color:var(--accent);margin-bottom:2px;">Пример 04 · Простые информационные запросы</div>
<div style="font-size:13px;color:var(--muted);">Короткие диалоги про часы, адрес, проезд, услуги, которых клиника не делает, и переход «справка → запись». Самый дешёвый путь: одна реплика → ветка <code>general_info</code> без машины состояний и без слотов → ответ.</div>
</a>
</div>
<div class="callout">
Документ описывает текущее состояние после Спринта 6b (блоки D + F): удержание в ветке, боковые вопросы, структурированный ответ, guards. Следующее: причина эскалации с reason (блок E), умный маршрутизатор, видящий состояние диалога (блок G).
Документ описывает текущее состояние после Спринта 6b (блоки D + F): удержание в ветке, боковые вопросы, структурированный ответ, защитные условия. Следующее: причина передачи оператору с reason (блок E), умный маршрутизатор, видящий состояние диалога (блок G).
</div>
</article>
+263
View File
@@ -0,0 +1,263 @@
<!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.6;
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; }
main {
flex: 1;
overflow-y: auto;
padding: 24px 24px 80px 24px;
}
article {
max-width: 860px;
margin: 0 auto;
background: var(--panel);
border: 1px solid var(--border);
border-radius: 10px;
padding: 28px 36px;
}
.breadcrumbs {
max-width: 860px;
margin: 0 auto 14px auto;
font-size: 12.5px;
color: var(--muted);
}
.breadcrumbs a {
color: var(--accent);
text-decoration: none;
}
.breadcrumbs a:hover { text-decoration: underline; }
.examples-nav {
max-width: 860px;
margin: 0 auto 14px auto;
background: var(--panel);
border: 1px solid var(--border);
border-radius: 10px;
padding: 10px 16px;
display: flex;
flex-wrap: wrap;
gap: 4px;
font-size: 13px;
}
.examples-nav .ex-link {
padding: 6px 10px;
border-radius: 6px;
text-decoration: none;
color: var(--muted);
font-weight: 500;
}
.examples-nav .ex-link:hover { background: var(--chip-bg); color: var(--fg); }
.examples-nav .ex-link.active {
background: var(--accent);
color: #fff;
}
.examples-nav .ex-num {
font-family: var(--mono);
font-size: 11.5px;
opacity: 0.7;
margin-right: 4px;
}
article h1 { font-size: 24px; font-weight: 700; margin: 0 0 6px 0; letter-spacing: -0.02em; }
article h2 {
font-size: 18px;
font-weight: 600;
letter-spacing: -0.01em;
margin: 28px 0 10px 0;
padding-bottom: 6px;
border-bottom: 1px solid var(--border);
}
article h3 {
font-size: 15px;
font-weight: 600;
margin: 18px 0 8px 0;
}
article p { margin: 0 0 12px 0; }
article ul, article ol { margin: 0 0 12px 0; padding-left: 22px; }
article li { margin: 4px 0; }
article blockquote {
border-left: 3px solid var(--border);
margin: 8px 0 14px 0;
padding: 4px 12px;
color: var(--muted);
background: #fafbfd;
border-radius: 0 6px 6px 0;
font-size: 13.5px;
}
article blockquote p { margin: 0 0 6px 0; }
article blockquote p:last-child { margin-bottom: 0; }
article code {
background: var(--chip-bg);
color: var(--accent);
padding: 1px 6px;
border-radius: 4px;
font-family: var(--mono);
font-size: 12.5px;
}
article pre {
background: #fafbfd;
border: 1px solid var(--border);
border-radius: 8px;
padding: 12px 14px;
overflow-x: auto;
font-family: var(--mono);
font-size: 12px;
line-height: 1.55;
margin: 8px 0 16px 0;
}
article pre code { background: none; color: var(--fg); padding: 0; font-size: 12px; }
article hr {
border: none;
border-top: 1px solid var(--border);
margin: 24px 0;
}
article table {
border-collapse: collapse;
width: 100%;
margin: 8px 0 14px 0;
font-size: 13px;
}
article table th, article table td {
border: 1px solid var(--border);
padding: 6px 10px;
text-align: left;
}
article table th {
background: var(--chip-bg);
font-weight: 600;
}
.loading, .err {
max-width: 860px;
margin: 0 auto;
padding: 30px;
text-align: center;
color: var(--muted);
font-size: 13px;
}
.err { color: var(--err); }
</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">Настройки</a>
<a href="/docs.html" class="nav-link active">Документация</a>
</nav>
</header>
<main>
<div class="breadcrumbs">
<a href="/docs.html">Документация</a> · <span id="bc-title">Разобранный пример</span>
</div>
<nav class="examples-nav" id="ex-nav"></nav>
<article id="content">
<div class="loading">загружаю пример…</div>
</article>
</main>
<script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/dompurify@3.0.9/dist/purify.min.js"></script>
<script>
const EXAMPLES = [
{ id: "01_basic_booking_v2", num: "01", title: "Базовая запись к ЛОР-врачу" },
{ id: "02_price_during_booking_v2", num: "02", title: "Вопрос про цену в середине записи" },
{ id: "03_child_patient_guard_v2", num: "03", title: "Запись ребёнка — защитное условие" },
{ id: "04_general_info_simple_v2", num: "04", title: "Простые информационные запросы" },
];
const $ = (id) => document.getElementById(id);
const esc = (s) => String(s ?? "").replace(/[&<>"']/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));
function renderNav(activeId) {
$("ex-nav").innerHTML = EXAMPLES.map(e =>
`<a class="ex-link${e.id === activeId ? ' active' : ''}" href="/example.html?id=${esc(e.id)}">
<span class="ex-num">${esc(e.num)}</span>${esc(e.title)}
</a>`
).join("");
}
async function loadExample(id) {
const meta = EXAMPLES.find(e => e.id === id);
if (!meta) {
$("content").innerHTML = `<div class="err">Пример «${esc(id)}» не найден.</div>`;
return;
}
$("bc-title").textContent = `Пример ${meta.num} · ${meta.title}`;
document.title = `Пример ${meta.num} · ${meta.title}`;
try {
const res = await fetch(`/api/docs/examples/${encodeURIComponent(id)}`);
if (!res.ok) throw new Error(`${res.status}`);
const md = await res.text();
marked.setOptions({ breaks: false, gfm: true });
const html = marked.parse(md);
$("content").innerHTML = DOMPurify.sanitize(html);
} catch (e) {
$("content").innerHTML = `<div class="err">Не удалось загрузить пример: ${esc(e.message)}</div>`;
}
}
const params = new URLSearchParams(window.location.search);
const requestedId = params.get("id") || EXAMPLES[0].id;
renderNav(requestedId);
loadExample(requestedId);
</script>
</body>
</html>
+259 -2
View File
@@ -136,6 +136,82 @@
tr.doc-row.open .arrow { transform: rotate(90deg); }
tr.chunks-row td { padding: 0; background: #fafbfd; }
tr.chunks-row .chunks-body { padding: 14px 16px; }
tr.intents-row td { padding: 0; background: #fef3c7; }
tr.intents-row .intents-body { padding: 12px 16px; }
tr.editor-row td { padding: 0; background: #eff6ff; }
tr.editor-row .editor-body { padding: 12px 16px; }
.editor-body .eb-hint {
font-size: 11.5px;
color: var(--muted);
margin-bottom: 6px;
}
.editor-body textarea.eb-textarea {
width: 100%;
min-height: 360px;
padding: 10px 12px;
border: 1px solid var(--border);
border-radius: 6px;
font-family: var(--mono);
font-size: 12px;
line-height: 1.5;
resize: vertical;
background: white;
}
.editor-body .eb-actions { margin-top: 10px; display: flex; gap: 8px; align-items: center; }
.editor-body .eb-actions button {
padding: 5px 10px;
font-size: 12px;
border: 1px solid var(--border);
background: white;
border-radius: 5px;
cursor: pointer;
}
.editor-body .eb-actions button.primary {
background: var(--accent);
color: white;
border-color: var(--accent);
}
.editor-body .eb-actions .eb-status { color: var(--muted); font-size: 11.5px; }
.intents-body .ib-head {
font-size: 12px;
color: var(--muted);
margin-bottom: 8px;
display: flex;
align-items: baseline;
justify-content: space-between;
}
.intents-body .ib-counter b { color: var(--fg); font-weight: 600; }
.intents-body .ib-list {
display: flex;
flex-wrap: wrap;
gap: 8px 14px;
background: white;
border: 1px solid var(--border);
border-radius: 6px;
padding: 10px 12px;
}
.intents-body .ib-item {
display: inline-flex;
align-items: center;
gap: 5px;
font-size: 12.5px;
cursor: pointer;
}
.intents-body .ib-item code { font-family: var(--mono); font-size: 11.5px; color: var(--accent); }
.intents-body .ib-actions { margin-top: 10px; display: flex; gap: 8px; }
.intents-body .ib-actions button {
padding: 5px 10px;
font-size: 12px;
border: 1px solid var(--border);
background: white;
border-radius: 5px;
cursor: pointer;
}
.intents-body .ib-actions button.primary {
background: var(--accent);
color: white;
border-color: var(--accent);
}
.chunk-card {
background: white;
border: 1px solid var(--border);
@@ -374,6 +450,9 @@
<section class="panel">
<h2>Тест-вопрос от пациента</h2>
<div id="debug-info-bar" style="margin-bottom:10px;padding:8px 12px;background:#eef2ff;border:1px solid #c7d2fe;border-radius:6px;font-size:12px;color:#3730a3;display:flex;align-items:center;gap:10px;flex-wrap:wrap;">
<span>— загружаю настройки страницы отладки —</span>
</div>
<textarea id="question" placeholder="Например: как записать ребёнка к лору?"></textarea>
<div class="row" style="margin-top:12px;">
<label>top_k <input type="number" class="num" id="top_k" value="5" min="1" max="20"></label>
@@ -452,11 +531,21 @@ async function refreshDocs() {
<td>${esc(d.file_type)}</td>
<td>${d.chunks_count}</td>
<td class="mini">${esc((d.created_at || "").slice(0, 19).replace("T", " "))}</td>
<td><button class="danger" onclick="event.stopPropagation(); deleteDoc('${d.document_id}', '${esc(d.name)}')">удалить</button></td>
<td>
<button onclick="event.stopPropagation(); toggleEditor('${d.document_id}')">редактировать</button>
<button onclick="event.stopPropagation(); toggleIntents('${d.document_id}')">привязка</button>
<button class="danger" onclick="event.stopPropagation(); deleteDoc('${d.document_id}', '${esc(d.name)}')">удалить</button>
</td>
</tr>
<tr class="chunks-row" id="chunks-${d.document_id}" style="display:none;">
<td colspan="5"><div class="chunks-body"><div class="mini">загружаю…</div></div></td>
</tr>
<tr class="intents-row" id="intents-${d.document_id}" style="display:none;">
<td colspan="5"><div class="intents-body"><div class="mini">— загружаю —</div></div></td>
</tr>
<tr class="editor-row" id="editor-${d.document_id}" style="display:none;">
<td colspan="5"><div class="editor-body"><div class="mini">— загружаю —</div></div></td>
</tr>
`).join("");
} catch (e) {
toast("Не удалось загрузить список: " + e.message, "err");
@@ -543,6 +632,135 @@ async function deleteDoc(id, name) {
}
}
/* ---------- intents subscription (Спринт 7, часть A) ---------- */
async function toggleIntents(docId) {
const row = $("intents-" + docId);
const isOpen = row.style.display !== "none";
if (isOpen) {
row.style.display = "none";
return;
}
row.style.display = "";
const body = row.querySelector(".intents-body");
body.innerHTML = '<div class="mini">— загружаю —</div>';
try {
const [intentsResp, docResp] = await Promise.all([
api(`/intents`),
api(`/documents/${docId}/intents`),
]);
const allIntents = (intentsResp.intents || [])
.filter(i => !i.code.startsWith("_"))
.sort((a, b) => a.name.localeCompare(b.name, "ru"));
const subscribed = new Set(docResp.intent_codes || []);
const items = allIntents.map(i => `
<label class="ib-item">
<input type="checkbox" data-intent-code="${esc(i.code)}" ${subscribed.has(i.code) ? "checked" : ""} onchange="updateIntentsCounter('${docId}')">
${esc(i.name)} <code>${esc(i.code)}</code>
</label>
`).join("");
body.innerHTML = `
<div class="ib-head">
<span>К каким веткам подключён этот документ для RAG?</span>
<span class="ib-counter" id="intents-counter-${docId}">подключён к <b>${subscribed.size}</b> из <b>${allIntents.length}</b></span>
</div>
<div class="ib-list" id="intents-list-${docId}">${items}</div>
<div class="ib-actions">
<button class="primary" onclick="saveDocIntents('${docId}')">Сохранить</button>
<button onclick="toggleIntents('${docId}')">Отмена</button>
</div>
`;
} catch (e) {
body.innerHTML = `<div class="mini" style="color:var(--err)">Ошибка: ${esc(e.message)}</div>`;
}
}
function updateIntentsCounter(docId) {
const list = $("intents-list-" + docId);
const counter = $("intents-counter-" + docId);
if (!list || !counter) return;
const all = list.querySelectorAll('input[type="checkbox"][data-intent-code]');
const checked = list.querySelectorAll('input[type="checkbox"][data-intent-code]:checked');
counter.innerHTML = `подключён к <b>${checked.length}</b> из <b>${all.length}</b>`;
}
async function saveDocIntents(docId) {
const list = $("intents-list-" + docId);
if (!list) return;
const intent_codes = Array.from(
list.querySelectorAll('input[type="checkbox"][data-intent-code]:checked')
).map(cb => cb.dataset.intentCode);
try {
const r = await api(`/documents/${docId}/intents`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ intent_codes }),
});
toast(`Привязка сохранена: ${r.intent_codes.length} ветка(и)`);
updateIntentsCounter(docId);
} catch (e) {
toast("Не удалось сохранить: " + e.message, "err");
}
}
/* ---------- raw-text editor (Спринт 7) ---------- */
async function toggleEditor(docId) {
const row = $("editor-" + docId);
const isOpen = row.style.display !== "none";
if (isOpen) {
row.style.display = "none";
return;
}
row.style.display = "";
const body = row.querySelector(".editor-body");
body.innerHTML = '<div class="mini">— загружаю —</div>';
try {
const d = await api(`/documents/${docId}/raw`);
const safe = esc(d.raw_text || "");
body.innerHTML = `
<div class="eb-hint">
Правится <b>извлечённый текст</b> документа. Для PDF/docx исходник теряется — после сохранения остаётся только этот текст. Сохранение запускает переразметку и обновляет чанки в Chroma.
</div>
<textarea class="eb-textarea" id="editor-text-${docId}" spellcheck="false">${safe}</textarea>
<div class="eb-actions">
<button class="primary" onclick="saveDocRaw('${docId}')">Сохранить и переиндексировать</button>
<button onclick="toggleEditor('${docId}')">Отмена</button>
<span class="eb-status" id="editor-status-${docId}"></span>
</div>
`;
} catch (e) {
body.innerHTML = `<div class="mini" style="color:var(--err)">Ошибка: ${esc(e.message)}</div>`;
}
}
async function saveDocRaw(docId) {
const ta = $("editor-text-" + docId);
const status = $("editor-status-" + docId);
if (!ta) return;
const raw_text = ta.value;
if (!raw_text.trim()) {
toast("Текст не может быть пустым", "err");
return;
}
if (!confirm("Сохранить и переиндексировать документ? Старые чанки будут удалены, новые соберутся заново.")) {
return;
}
if (status) status.innerHTML = '<span class="spinner"></span> переиндексирую…';
try {
const r = await api(`/documents/${docId}/raw`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ raw_text }),
});
toast(`Переиндексировано: ${r.chunks_count} чанков`);
if (status) status.textContent = "";
refreshDocs();
refreshHealth();
} catch (e) {
if (status) status.textContent = "";
toast("Ошибка: " + e.message, "err");
}
}
async function uploadFile(file) {
$("upload-status").innerHTML = `<span class="spinner"></span> загружаю <b>${esc(file.name)}</b>…`;
const fd = new FormData();
@@ -610,11 +828,16 @@ async function ask() {
: '<div class="mini">— нет релевантных чанков —</div>';
$("col-prompt").innerHTML = `<pre>${esc(r.assembled_prompt)}</pre>`;
const cfgInfo = r.config_version != null ? ` · промпт <code>_debug</code> v${r.config_version}` : "";
const ragInfo = r.rag_subscription
? ` · подписано ${r.rag_subscription.subscribed_count}, найдено ${r.rag_subscription.found_count}`
: "";
$("col-answer").innerHTML = `
<div class="answer">${esc(r.answer)}</div>
<div class="answer-meta">модель: ${esc(r.model_used)} · источников: ${r.sources.length}</div>
<div class="answer-meta">модель: ${esc(r.model_used)} · источников: ${r.sources.length}${cfgInfo}${ragInfo}</div>
`;
$("ask-status").textContent = "";
loadDebugInfo();
} catch (e) {
$("col-answer").innerHTML = `<div class="mini" style="color:var(--err)">Ошибка: ${esc(e.message)}</div>`;
$("ask-status").textContent = "";
@@ -625,6 +848,40 @@ async function ask() {
}
$("ask-btn").addEventListener("click", ask);
/* ---------- _debug intent info bar ---------- */
async function loadDebugInfo() {
const bar = $("debug-info-bar");
if (!bar) return;
try {
const [intentsResp, subsResp, docsResp] = await Promise.all([
api("/intents"),
api("/intents/_debug/documents"),
api("/documents"),
]);
const dbg = (intentsResp.intents || []).find(i => i.code === "_debug");
const subscribed = (subsResp.document_ids || []).length;
const total = (docsResp.documents || []).length;
const ver = dbg && dbg.active_config_version != null ? `v${dbg.active_config_version}` : "нет активной версии";
const noPromptWarning = !dbg || dbg.active_config_version == null;
bar.innerHTML = `
<span>промпт ветки <code style="background:#e0e7ff;padding:1px 5px;border-radius:3px;font-family:var(--mono);">_debug</code> «Страница отладки» · <b>${esc(ver)}</b></span>
<span style="opacity:.7;">·</span>
<span>подписано <b>${subscribed}</b> из <b>${total}</b> документ(ов)${subscribed === 0 ? " — RAG идёт по всей базе" : ""}</span>
<span style="opacity:.7;">·</span>
<a href="/settings.html" style="color:var(--accent);text-decoration:none;">настроить →</a>
`;
if (noPromptWarning) {
bar.style.background = "#fef3c7";
bar.style.borderColor = "#fde68a";
bar.style.color = "#78350f";
bar.innerHTML += '<div style="width:100%;margin-top:4px;">⚠️ у ветки нет активной версии промпта — модель будет отвечать без системных инструкций.</div>';
}
} catch (e) {
bar.innerHTML = `<span style="color:var(--err);">Не удалось загрузить настройки: ${esc(e.message)}</span>`;
}
}
loadDebugInfo();
$("question").addEventListener("keydown", e => {
if ((e.metaKey || e.ctrlKey) && e.key === "Enter") ask();
});
+74 -26
View File
@@ -272,13 +272,23 @@
background: #eef2ff;
color: #3730a3;
}
/* Роутер предложил */
/* Решение маршрутизатора */
.msg-router {
background: #f3f4f6;
color: #4b5563;
border: 1px solid #e5e7eb;
}
.msg-router .badge-val { font-family: var(--mono); }
.msg-router.router-matches {
background: #ecfdf5;
color: #065f46;
border-color: #a7f3d0;
}
.msg-router.router-differs {
background: #fffbeb;
color: #78350f;
border-color: #fde68a;
}
/* События */
.msg-event {
font-weight: 500;
@@ -570,10 +580,14 @@
<div id="debug-state"><div class="mini">— пока пусто —</div></div>
</div>
<div class="debug-section">
<h3>Решение роутера</h3>
<h3>Решение маршрутизатора</h3>
<div id="debug-router"><div class="mini">— пока пусто —</div></div>
</div>
<div class="debug-section" id="debug-operator-summary" style="display:none;background:#fff1f2;border-radius:6px;padding:10px 14px;font-size:12px;"></div>
<div class="debug-section">
<h3>Срез RAG</h3>
<div id="debug-rag"><div class="mini">— пока пусто —</div></div>
</div>
<details class="debug-section collapsible" id="debug-chunks-section">
<summary>
<span>Найденные фрагменты</span>
@@ -691,7 +705,7 @@ async function openThread(id) {
const lastAssistant = [...d.messages].reverse().find(m => m.role === "assistant");
const lastEscalation = [...d.messages].reverse().find(m => m.role === "assistant" && m.escalation_reason);
if (lastAssistant) {
renderDebug(lastAssistant.sources, lastAssistant.assembled_prompt, lastAssistant.intent_code, lastAssistant.intent_name, null, null, null, [], d.thread_state && d.thread_state.current_step_code, null, null);
renderDebug(lastAssistant.sources, lastAssistant.assembled_prompt, lastAssistant.intent_code, lastAssistant.intent_name, null, null, null, [], d.thread_state && d.thread_state.current_step_code, null, null, lastAssistant.rag_subscription || (lastAssistant.meta && lastAssistant.meta.rag_subscription) || null);
renderState(d.thread_state, [], [], null, false, false, lastEscalation ? lastEscalation.escalation_reason : null);
} else {
clearDebug();
@@ -712,23 +726,23 @@ function startNewThread() {
}
const EVENT_LABELS = {
sticky: { label: "решение:", text: "удержались в ветке", title: "роутер предлагал другую ветку — модель осталась в текущем сценарии" },
sticky: { label: "решение:", text: "удержались в ветке", title: "маршрутизатор предлагал другую ветку — модель осталась в текущем сценарии" },
hard_handoff: { label: "решение:", text: "переключили ветку", title: "ветка выдала [INTENT_CHANGE] и передала диалог другой ветке" },
soft_insertion: { label: "тип ответа:", text: "боковой вопрос", title: "модель ответила на побочный вопрос, не продвигая сценарий (шаг не изменился)" },
resumed: { label: "решение:", text: "восстановили сценарий",title: "вернулись в ранее приостановленный сценарий со всеми слотами" },
routing_loop: { label: "защита:", text: "петля роутера", title: "сработала защита от петли: диалог автоматически передан оператору" },
validation_blocked:{ label: "валидатор:", text: "переход отклонён", title: "валидатор guard заблокировал переход на запрошенный шаг" },
routing_loop: { label: "защита:", text: "петля маршрутизатора", title: "сработала защита от петли: диалог автоматически передан оператору" },
validation_blocked:{ label: "валидатор:", text: "переход отклонён", title: "защитное условие заблокировало переход на запрошенный шаг" },
};
function renderAssistantBadges(intentCode, intentName, meta) {
// Ветка
// Активная ветка
const displayName = (intentName && intentName !== intentCode) ? intentName : intentCode;
const smTag = (meta && (meta.is_state_machine || meta.step_code))
? `<span class="badge-sm-tag">многошаговая</span>` : "";
? `<span class="badge-sm-tag">пошаговая</span>` : "";
const codeHint = (intentName && intentName !== intentCode)
? `<span class="badge-sub">(${esc(intentCode)})</span>` : "";
const intent = intentCode
? `<span class="msg-badge msg-intent" title="Ветка: ${esc(intentName || intentCode)}"><span class="badge-label">ветка:</span><span class="badge-val">${esc(displayName)}</span>${codeHint}${smTag}</span>`
? `<span class="msg-badge msg-intent" title="Активная ветка: ${esc(intentName || intentCode)}"><span class="badge-label">активная ветка:</span><span class="badge-val">${esc(displayName)}</span>${codeHint}${smTag}</span>`
: "";
if (!meta) return intent;
@@ -738,13 +752,17 @@ function renderAssistantBadges(intentCode, intentName, meta) {
const stepSub = meta.step_name && meta.step_code
? `<span class="badge-sub">(${esc(meta.step_code)})</span>` : "";
const stepBadge = meta.step_code
? `<span class="msg-badge msg-step" title="Текущий шаг сценария"><span class="badge-label">шаг:</span><span class="badge-val">${esc(stepDisplay)}</span>${stepSub}</span>`
? `<span class="msg-badge msg-step" title="Текущий шаг пошаговой ветки"><span class="badge-label">шаг ветки:</span><span class="badge-val">${esc(stepDisplay)}</span>${stepSub}</span>`
: "";
// Роутер предложил другую ветку
const routerDiffers = meta.router_intent_code && meta.router_intent_code !== meta.served_intent_code;
const router = routerDiffers
? `<span class="msg-badge msg-router" title="Роутер классифицировал реплику в другую ветку, но модель осталась здесь"><span class="badge-label">роутер предложил:</span><span class="badge-val">${esc(meta.router_intent_code)}</span></span>`
// Решение маршрутизатора — показываем ВСЕГДА (даже при совпадении с активной веткой)
const routerCode = meta.router_intent_code;
const routerDiffers = routerCode && routerCode !== meta.served_intent_code;
const routerTitle = routerDiffers
? "Маршрутизатор классифицировал реплику в другую ветку, но модель осталась здесь (удержание в ветке или возврат из отложенного сценария)"
: "Маршрутизатор подтвердил активную ветку";
const router = routerCode
? `<span class="msg-badge msg-router${routerDiffers ? ' router-differs' : ' router-matches'}" title="${esc(routerTitle)}"><span class="badge-label">решение маршрутизатора:</span><span class="badge-val">${esc(routerCode)}</span></span>`
: "";
// События
@@ -802,11 +820,17 @@ function renderState(state, bounces, validationEvents, parseError, routingLoopTr
return;
}
const handoff = Number(state.handoff_count || 0);
const HANDOFF_CAP = 3;
const softCount = Number(state.soft_insertion_count || 0);
const SOFT_CAP = 3;
const handoffWarn = handoff >= HANDOFF_CAP;
const handoffHtml = `
<div style="margin-top:6px;font-size:11px;color:var(--muted);">
переключений ветки в диалоге: <b style="color:var(--fg);">${handoff}</b>${state.current_step_code ? ` · боковых вопросов подряд: <b style="color:var(--fg);">${softCount}</b>` : ''}
<div style="margin-top:8px;display:flex;align-items:center;gap:8px;font-size:11px;">
<span style="color:var(--muted);">Переключений:</span>
<span style="display:inline-flex;align-items:center;gap:4px;padding:2px 8px;border-radius:10px;background:${handoffWarn ? '#fee2e2' : '#eef2ff'};color:${handoffWarn ? '#7f1d1d' : '#3730a3'};font-weight:600;">
${handoff} из ${HANDOFF_CAP}
</span>
${state.current_step_code ? `<span style="color:var(--muted);">· боковых вопросов подряд: <b style="color:var(--fg);">${softCount}</b></span>` : ''}
</div>`;
const softNudgeHtml = (state.current_step_code && softCount >= SOFT_CAP)
? `<div style="margin-top:8px;padding:6px 8px;border-radius:4px;background:#fef3c7;color:#78350f;font-size:11px;">
@@ -816,7 +840,7 @@ function renderState(state, bounces, validationEvents, parseError, routingLoopTr
const pendingGuard = state.pending_guard;
const pendingGuardHtml = pendingGuard
? `<div style="margin-top:8px;padding:6px 8px;border-radius:4px;background:#fef3c7;color:#78350f;font-size:11px;">
🔒 <b>guard активен: ${esc(pendingGuard.guard_name)}</b> — ждём заполнения: ${(pendingGuard.missing_slots || []).map(s => `<code>${esc(s)}</code>`).join(", ")}.<br>
🔒 <b>защитное условие активно: ${esc(pendingGuard.guard_name)}</b> — ждём заполнения: ${(pendingGuard.missing_slots || []).map(s => `<code>${esc(s)}</code>`).join(", ")}.<br>
<span style="opacity:.75;">${esc(pendingGuard.description || "")}</span>
</div>`
: "";
@@ -825,7 +849,7 @@ function renderState(state, bounces, validationEvents, parseError, routingLoopTr
surgery: "операция / хирургия / стационар",
angry: "пациент раздражён",
explicit_request: "запросил оператора",
routing_loop: "автоматически (петля роутера)",
routing_loop: "автоматически (петля маршрутизатора)",
};
const loopHtml = routingLoopTriggered
? `<div style="margin-top:8px;padding:6px 8px;border-radius:4px;background:#fee2e2;color:#7f1d1d;font-size:11px;">
@@ -861,7 +885,7 @@ function renderState(state, bounces, validationEvents, parseError, routingLoopTr
${validationEvents.map(v => {
if (v.guard_name) {
const missing = (v.missing_slots || []).map(s => `<code>${esc(s)}</code>`).join(", ");
return `🔒 guard <b>${esc(v.guard_name)}</b> не пройден — ждём: ${missing}.<br><span style="opacity:.75">${esc(v.guard_description || "")}</span>`;
return `🔒 защитное условие <b>${esc(v.guard_name)}</b> не пройдено — ждём: ${missing}.<br><span style="opacity:.75">${esc(v.guard_description || "")}</span>`;
}
return ` модель просилась в <code>${esc(v.requested_step)}</code>, оставили на <code>${esc(v.current_step)}</code>. ${esc(v.reason)}`;
}).join("<br>")}
@@ -898,8 +922,8 @@ function renderState(state, bounces, validationEvents, parseError, routingLoopTr
`;
}
function renderDebug(sources, prompt, intentCode, intentName, configVersion, routerVersion, routerIntentCode, bounces, stepCode, operatorSummary, routerPrompt) {
const routerVer = routerVersion != null ? `роутер v${routerVersion}` : "роутер";
function renderDebug(sources, prompt, intentCode, intentName, configVersion, routerVersion, routerIntentCode, bounces, stepCode, operatorSummary, routerPrompt, ragSubscription) {
const routerVer = routerVersion != null ? `маршрутизатор v${routerVersion}` : "маршрутизатор";
const hasBounces = bounces && bounces.length > 0;
const routerDiffers = routerIntentCode && intentCode && routerIntentCode !== intentCode;
@@ -913,22 +937,22 @@ function renderDebug(sources, prompt, intentCode, intentName, configVersion, rou
Ветка сама выдала <code>[INTENT_CHANGE]</code> и передала управление: ${chain}.
</div>`;
} else if (routerDiffers) {
// Удержались в ветке: диалог в сценарии, роутер хотел переключить, но мы остались.
// Удержались в ветке: диалог в сценарии, маршрутизатор хотел переключить, но мы остались.
verdict = `<div style="color:var(--muted);font-size:11px;margin-top:4px;line-height:1.5;">
${routerVer} предложил <code>${esc(routerIntentCode)}</code>.<br>
Но диалог идёт по сценарию <code>${esc(intentCode)}</code>${stepCode ? ' (шаг <code>' + esc(stepCode) + '</code>)' : ''}
<b>удержались в ветке</b>: модель получила подсказку и осталась в сценарии.
</div>`;
} else {
// Обычный случай — роутер попал в ту же ветку.
// Обычный случай — маршрутизатор попал в ту же ветку.
verdict = `<div style="color:var(--muted);font-size:11px;margin-top:4px;">
${routerVer}та же ветка.
${routerVer}активная ветка совпадает с решением.
</div>`;
}
const routerPromptHtml = routerPrompt
? `<details style="margin-top:6px;">
<summary style="font-size:11px;color:var(--muted);cursor:pointer;">промпт роутера</summary>
<summary style="font-size:11px;color:var(--muted);cursor:pointer;">промпт маршрутизатора</summary>
<div class="prompt-box" style="margin-top:4px;max-height:300px;">${esc(routerPrompt)}</div>
</details>`
: "";
@@ -941,6 +965,30 @@ function renderDebug(sources, prompt, intentCode, intentName, configVersion, rou
: "";
$("debug-router").innerHTML = routerLine || '<div class="mini">— маршрутизация пока не выполнена —</div>';
// Срез RAG: видно сколько документов подписано на активную ветку и сколько чанков пришло.
const ragBox = $("debug-rag");
if (ragBox) {
if (ragSubscription) {
const sub = Number(ragSubscription.subscribed_count || 0);
const found = Number(ragSubscription.found_count || 0);
const intentLabel = intentCode ? `<code>${esc(intentCode)}</code>` : "—";
let warn = "";
if (sub === 0) {
warn = `<div style="margin-top:6px;padding:6px 8px;border-radius:4px;background:#fef3c7;color:#78350f;font-size:11px;">
⚠️ у ветки нет подписок — RAG-контекст пустой. Подписать документы можно в «Настройки» → ${intentLabel} или в «Отладка» рядом с документом.
</div>`;
}
ragBox.innerHTML = `
<div style="font-size:12px;">
подписано <b style="color:var(--fg);">${sub}</b> документ(ов) на ветку ${intentLabel} · в этой реплике пришло <b style="color:var(--fg);">${found}</b> чанк(ов)
</div>
${warn}
`;
} else {
ragBox.innerHTML = '<div class="mini">— пока пусто —</div>';
}
}
const count = $("debug-chunks-count");
if (sources && sources.length) {
count.textContent = sources.length;
@@ -1029,7 +1077,7 @@ async function sendMessage() {
appendMessage("assistant", r.answer, null, r.intent_code, r.intent_name, r.message_meta);
$("chat-title").className = "chat-title";
$("chat-title").textContent = r.thread_name;
renderDebug(r.sources, r.assembled_prompt, r.intent_code, r.intent_name, r.config_version, r.router_version, r.router_intent_code, r.bounces, r.thread_state && r.thread_state.current_step_code, r.operator_summary, r.router_assembled_prompt);
renderDebug(r.sources, r.assembled_prompt, r.intent_code, r.intent_name, r.config_version, r.router_version, r.router_intent_code, r.bounces, r.thread_state && r.thread_state.current_step_code, r.operator_summary, r.router_assembled_prompt, r.rag_subscription);
renderState(r.thread_state, r.bounces, r.validation_events, r.parse_error, r.routing_loop_triggered, r.resumed_from_suspended, r.escalation_reason);
refreshThreads();
} catch (e) {
+449 -2
View File
@@ -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);