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:
+86
-25
@@ -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><код></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><код></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><reason></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>
|
||||
|
||||
@@ -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 => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[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
@@ -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
@@ -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
@@ -294,6 +294,192 @@
|
||||
.field textarea.prompt { min-height: 300px; }
|
||||
.field textarea.rules { min-height: 140px; }
|
||||
|
||||
/* Сворачиваемый блок промпта — Спринт 7 */
|
||||
.prompt-block {
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
background: var(--panel);
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
.prompt-block > .prompt-block-summary {
|
||||
list-style: none;
|
||||
cursor: pointer;
|
||||
padding: 12px 16px;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
user-select: none;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
.prompt-block > .prompt-block-summary::-webkit-details-marker { display: none; }
|
||||
.prompt-block > .prompt-block-summary::before {
|
||||
content: "▶";
|
||||
font-size: 10px;
|
||||
color: var(--muted);
|
||||
transition: transform 0.15s;
|
||||
}
|
||||
.prompt-block[open] > .prompt-block-summary::before { transform: rotate(90deg); }
|
||||
.prompt-block > .prompt-block-summary:hover { background: #f9fafb; }
|
||||
.prompt-block[open] > .prompt-block-summary { border-bottom: 1px solid var(--border); }
|
||||
.prompt-block .pbs-hint { color: var(--muted); font-weight: 400; font-size: 12px; }
|
||||
.prompt-block > .field,
|
||||
.prompt-block > .editor-actions { padding-left: 16px; padding-right: 16px; }
|
||||
.prompt-block > .field:first-of-type { padding-top: 14px; }
|
||||
.prompt-block > .editor-actions { padding-bottom: 14px; }
|
||||
|
||||
/* Тест-вопрос пациента — секция в центре Настроек, Спринт 7 */
|
||||
.test-query {
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
background: var(--panel);
|
||||
padding: 14px 16px 16px;
|
||||
}
|
||||
.test-query h3 {
|
||||
margin: 0 0 6px;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 8px;
|
||||
}
|
||||
.test-query .tq-meta {
|
||||
font-weight: 400;
|
||||
font-size: 12px;
|
||||
color: var(--muted);
|
||||
}
|
||||
.test-query .tq-meta code {
|
||||
background: var(--chip-bg);
|
||||
padding: 1px 5px;
|
||||
border-radius: 3px;
|
||||
font-family: var(--mono);
|
||||
font-size: 11.5px;
|
||||
color: var(--accent);
|
||||
}
|
||||
.test-query .tq-rag-note {
|
||||
font-size: 11.5px;
|
||||
color: var(--muted);
|
||||
margin-bottom: 10px;
|
||||
padding: 6px 10px;
|
||||
background: #fafbfd;
|
||||
border-radius: 4px;
|
||||
}
|
||||
.test-query textarea {
|
||||
width: 100%;
|
||||
min-height: 70px;
|
||||
padding: 8px 10px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
font-size: 13px;
|
||||
font-family: inherit;
|
||||
resize: vertical;
|
||||
}
|
||||
.test-query .tq-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
margin: 10px 0 14px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.test-query .tq-row label {
|
||||
font-size: 12px;
|
||||
color: var(--muted);
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
.test-query .tq-num {
|
||||
width: 64px;
|
||||
padding: 4px 8px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 5px;
|
||||
font-size: 13px;
|
||||
}
|
||||
.test-query button.primary {
|
||||
background: var(--accent);
|
||||
color: #fff;
|
||||
border: none;
|
||||
padding: 6px 14px;
|
||||
border-radius: 6px;
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.test-query button.primary:hover { background: var(--accent-hover); }
|
||||
.test-query button.primary:disabled { opacity: 0.6; cursor: not-allowed; }
|
||||
.test-query .tq-cols {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr 1fr;
|
||||
gap: 10px;
|
||||
}
|
||||
@media (max-width: 1100px) {
|
||||
.test-query .tq-cols { grid-template-columns: 1fr; }
|
||||
}
|
||||
.test-query .tq-col h4 {
|
||||
margin: 0 0 6px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: var(--muted);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
.test-query .tq-pane {
|
||||
min-height: 80px;
|
||||
max-height: 360px;
|
||||
overflow-y: auto;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
background: #fafbfd;
|
||||
padding: 8px 10px;
|
||||
font-size: 12.5px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
.test-query .tq-pane pre {
|
||||
margin: 0;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
font-family: var(--mono);
|
||||
font-size: 11.5px;
|
||||
line-height: 1.45;
|
||||
}
|
||||
.test-query .tq-chunk {
|
||||
border-bottom: 1px solid var(--border);
|
||||
padding: 6px 0;
|
||||
}
|
||||
.test-query .tq-chunk:first-child { padding-top: 0; }
|
||||
.test-query .tq-chunk:last-child { border-bottom: none; }
|
||||
.test-query .tq-chunk-head {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
font-size: 11px;
|
||||
color: var(--muted);
|
||||
margin-bottom: 3px;
|
||||
}
|
||||
.test-query .tq-score { color: var(--accent); font-weight: 600; }
|
||||
.test-query .tq-chunk-text { font-size: 12px; }
|
||||
.test-query .tq-answer-text {
|
||||
white-space: pre-wrap;
|
||||
font-size: 13px;
|
||||
color: var(--fg);
|
||||
}
|
||||
.test-query .tq-answer-meta {
|
||||
margin-top: 8px;
|
||||
padding-top: 6px;
|
||||
border-top: 1px solid var(--border);
|
||||
font-size: 11px;
|
||||
color: var(--muted);
|
||||
}
|
||||
.spinner {
|
||||
display: inline-block;
|
||||
width: 11px;
|
||||
height: 11px;
|
||||
border: 2px solid var(--border);
|
||||
border-top-color: var(--accent);
|
||||
border-radius: 50%;
|
||||
animation: spin 0.8s linear infinite;
|
||||
vertical-align: middle;
|
||||
}
|
||||
@keyframes spin { to { transform: rotate(360deg); } }
|
||||
|
||||
.editor-actions {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
@@ -465,6 +651,74 @@
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* Подписка ветки на документы (Спринт 7) — в правом сайдбаре */
|
||||
#docs-subscription-counter { color: var(--muted); font-size: 12px; font-weight: normal; }
|
||||
#docs-subscription-counter b { color: var(--fg); font-weight: 600; }
|
||||
.ds-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
background: var(--panel);
|
||||
overflow-y: auto;
|
||||
max-height: 320px;
|
||||
}
|
||||
.ds-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 7px 10px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
cursor: pointer;
|
||||
font-size: 12.5px;
|
||||
line-height: 1.35;
|
||||
}
|
||||
.ds-item:last-child { border-bottom: none; }
|
||||
.ds-item:hover { background: #f9fafb; }
|
||||
.ds-name {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.ds-meta {
|
||||
font-size: 10.5px;
|
||||
color: var(--muted);
|
||||
font-family: var(--mono);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.ds-empty {
|
||||
padding: 12px;
|
||||
text-align: center;
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
}
|
||||
.ds-actions {
|
||||
margin-top: 10px;
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.ds-actions button {
|
||||
padding: 5px 10px;
|
||||
font-size: 12px;
|
||||
border: 1px solid var(--border);
|
||||
background: var(--panel);
|
||||
border-radius: 5px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.ds-actions button.primary {
|
||||
background: var(--accent);
|
||||
color: white;
|
||||
border-color: var(--accent);
|
||||
}
|
||||
.ds-hint {
|
||||
font-size: 11px;
|
||||
color: var(--muted);
|
||||
margin-bottom: 8px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
/* Свитч включён/выключен */
|
||||
.switch {
|
||||
position: relative;
|
||||
@@ -530,6 +784,10 @@
|
||||
<div class="col-body" id="versions">
|
||||
<div class="mini">— выберите ветку —</div>
|
||||
</div>
|
||||
<div class="col-head" style="border-top:1px solid var(--border);">Документы базы знаний <span id="docs-subscription-counter" style="color:var(--fg);text-transform:none;font-weight:normal;"></span></div>
|
||||
<div class="col-body" id="docs-subscription-sidebar">
|
||||
<div class="mini">— выберите ветку —</div>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
</main>
|
||||
@@ -638,6 +896,7 @@ async function selectIntent(code) {
|
||||
await refreshSteps(code);
|
||||
renderEditor();
|
||||
await refreshVersions(code);
|
||||
loadDocumentsForCurrentIntent();
|
||||
}
|
||||
|
||||
async function refreshSteps(code) {
|
||||
@@ -724,6 +983,8 @@ document.addEventListener("click", (e) => {
|
||||
|
||||
function renderPromptPanel(intent) {
|
||||
return `
|
||||
<details class="prompt-block" open>
|
||||
<summary class="prompt-block-summary">Системный промпт ветки <span class="pbs-hint">— редактирование, версии</span></summary>
|
||||
<div class="field">
|
||||
<label for="f-name">Имя версии (необязательно)</label>
|
||||
<input type="text" id="f-name" placeholder="например: после фидбэка операторов 24.04" maxlength="200">
|
||||
@@ -776,9 +1037,109 @@ function renderPromptPanel(intent) {
|
||||
<button class="secondary" onclick="loadActiveIntoEditor()">Перезагрузить активную</button>
|
||||
<label><input type="checkbox" id="chk-activate"> Сразу сделать активной</label>
|
||||
</div>
|
||||
</details>
|
||||
${renderTestQueryPanel(intent)}
|
||||
`;
|
||||
}
|
||||
|
||||
function renderTestQueryPanel(intent) {
|
||||
const isRouter = intent.code === "_router";
|
||||
const ragHint = isRouter
|
||||
? '<div class="tq-rag-note">У маршрутизатора нет RAG — тест идёт без чанков.</div>'
|
||||
: '<div class="tq-rag-note">Промпт берётся из черновика выше (даже если он не сохранён). Подписки на документы — те, что сохранены в правом сайдбаре.</div>';
|
||||
return `
|
||||
<div class="test-query">
|
||||
<h3>Тест-вопрос от пациента <span class="tq-meta">— ветка <code>${esc(intent.code)}</code></span></h3>
|
||||
${ragHint}
|
||||
<textarea id="tq-text" placeholder="Например: где вы находитесь?"></textarea>
|
||||
<div class="tq-row">
|
||||
<label>top_k <input type="number" class="tq-num" id="tq-top-k" value="5" min="1" max="20"></label>
|
||||
<label>temperature <input type="number" class="tq-num" id="tq-temp" value="0.2" min="0" max="2" step="0.1"></label>
|
||||
<button class="primary" id="tq-btn" onclick="runTestQuery()">Отправить</button>
|
||||
<span id="tq-status" class="mini"></span>
|
||||
</div>
|
||||
<div class="tq-cols">
|
||||
<div class="tq-col">
|
||||
<h4>Что нашёл RAG</h4>
|
||||
<div id="tq-chunks" class="tq-pane"><div class="mini">— пока пусто —</div></div>
|
||||
</div>
|
||||
<div class="tq-col">
|
||||
<h4>Собранный промпт</h4>
|
||||
<div id="tq-prompt" class="tq-pane"><div class="mini">— пока пусто —</div></div>
|
||||
</div>
|
||||
<div class="tq-col">
|
||||
<h4>Ответ агента</h4>
|
||||
<div id="tq-answer" class="tq-pane"><div class="mini">— пока пусто —</div></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
async function runTestQuery() {
|
||||
const intent = intents.find(i => i.code === currentIntentCode);
|
||||
if (!intent) return;
|
||||
const text = $("tq-text").value.trim();
|
||||
if (!text) { toast("Введите вопрос", "err"); return; }
|
||||
|
||||
// Собираем черновик промпта из 3 textarea — то, что оператор сейчас видит на экране.
|
||||
const promptParts = [];
|
||||
const fp = $("f-prompt"); if (fp && fp.value.trim()) promptParts.push(fp.value.trim());
|
||||
const fr = $("f-rules"); if (fr && fr.value.trim()) promptParts.push("\n## Правила\n\n" + fr.value.trim());
|
||||
const fe = $("f-exits"); if (fe && fe.value.trim()) promptParts.push("\n## Условия выхода\n\n" + fe.value.trim());
|
||||
const draftPrompt = promptParts.join("\n");
|
||||
|
||||
const isRouter = intent.code === "_router";
|
||||
const btn = $("tq-btn");
|
||||
btn.disabled = true;
|
||||
$("tq-status").innerHTML = '<span class="spinner"></span> думаю…';
|
||||
$("tq-chunks").innerHTML = '<div class="mini">…</div>';
|
||||
$("tq-prompt").innerHTML = '<div class="mini">…</div>';
|
||||
$("tq-answer").innerHTML = '<div class="mini">…</div>';
|
||||
|
||||
try {
|
||||
const r = await api("/query", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
text,
|
||||
intent_code: intent.code,
|
||||
system_prompt: draftPrompt,
|
||||
disable_rag: isRouter,
|
||||
top_k: parseInt($("tq-top-k").value, 10) || 5,
|
||||
temperature: parseFloat($("tq-temp").value),
|
||||
}),
|
||||
});
|
||||
|
||||
$("tq-chunks").innerHTML = r.sources.length
|
||||
? r.sources.map((s, i) => `
|
||||
<div class="tq-chunk">
|
||||
<div class="tq-chunk-head">
|
||||
<span>[${i + 1}] ${esc(s.document_name)}${s.section ? " · " + esc(s.section) : ""}</span>
|
||||
<span class="tq-score">${(s.relevance_score * 100).toFixed(1)}%</span>
|
||||
</div>
|
||||
<div class="tq-chunk-text">${esc(s.chunk_text)}</div>
|
||||
</div>`).join("")
|
||||
: '<div class="mini">— нет чанков —</div>';
|
||||
|
||||
$("tq-prompt").innerHTML = `<pre>${esc(r.assembled_prompt)}</pre>`;
|
||||
const ragInfo = r.rag_subscription
|
||||
? `подписано ${r.rag_subscription.subscribed_count}, найдено ${r.rag_subscription.found_count}`
|
||||
: "";
|
||||
$("tq-answer").innerHTML = `
|
||||
<div class="tq-answer-text">${esc(r.answer)}</div>
|
||||
<div class="tq-answer-meta">модель: ${esc(r.model_used)} · ${ragInfo}</div>
|
||||
`;
|
||||
$("tq-status").textContent = "";
|
||||
} catch (e) {
|
||||
$("tq-answer").innerHTML = `<div class="mini" style="color:var(--err)">Ошибка: ${esc(e.message)}</div>`;
|
||||
$("tq-status").textContent = "";
|
||||
toast("Ошибка: " + e.message, "err");
|
||||
} finally {
|
||||
btn.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
function renderStepsPanel() {
|
||||
const chips = currentSteps.map(s => `
|
||||
<div class="step-chip ${s.code === currentStepCode ? 'active' : ''}"
|
||||
@@ -821,7 +1182,7 @@ function renderStepEditor() {
|
||||
<div class="allowed-next" id="f-step-allowed">${checkboxes}</div>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="f-step-guards">Guards (JSON) — условия, блокирующие переход до заполнения нужных слотов. Пример: <code>{"require_legal_rep": {"trigger_slot": "is_child", "trigger_value": true, "required_slots": ["legal_rep_name", "legal_rep_phone"], "description": "..."}}</code></label>
|
||||
<label for="f-step-guards">Защитные условия (guards, JSON) — блокируют переход на следующий шаг, пока не заполнены нужные слоты. Пример: <code>{"require_legal_rep": {"trigger_slot": "is_child", "trigger_value": true, "required_slots": ["legal_rep_name", "legal_rep_phone"], "description": "..."}}</code></label>
|
||||
<textarea id="f-step-guards" class="rules" spellcheck="false">${esc(JSON.stringify(step.guards || {}, null, 2))}</textarea>
|
||||
</div>
|
||||
<div class="editor-actions">
|
||||
@@ -860,7 +1221,7 @@ async function saveStep() {
|
||||
guards = JSON.parse($("f-step-guards").value.trim() || "{}");
|
||||
if (typeof guards !== "object" || Array.isArray(guards)) throw new Error("JSON должен быть объектом");
|
||||
} catch (e) {
|
||||
toast("Guards: невалидный JSON — " + e.message, "err");
|
||||
toast("Защитные условия: невалидный JSON — " + e.message, "err");
|
||||
return;
|
||||
}
|
||||
try {
|
||||
@@ -912,6 +1273,92 @@ function loadIntoEditor(configId) {
|
||||
window.scrollTo({ top: 0, behavior: "smooth" });
|
||||
}
|
||||
|
||||
/* ---------- docs subscription (Спринт 7, часть A) — в правом сайдбаре ---------- */
|
||||
async function loadDocumentsForCurrentIntent() {
|
||||
const sidebar = $("docs-subscription-sidebar");
|
||||
const counter = $("docs-subscription-counter");
|
||||
if (!sidebar || !counter) return;
|
||||
if (!currentIntentCode) {
|
||||
sidebar.innerHTML = '<div class="mini">— выберите ветку —</div>';
|
||||
counter.textContent = "";
|
||||
return;
|
||||
}
|
||||
sidebar.innerHTML = '<div class="ds-empty">— загружаю —</div>';
|
||||
counter.textContent = "";
|
||||
|
||||
let allDocs = [];
|
||||
let subscribedIds = new Set();
|
||||
try {
|
||||
const [docsResp, subsResp] = await Promise.all([
|
||||
api(`/documents`),
|
||||
api(`/intents/${encodeURIComponent(currentIntentCode)}/documents`),
|
||||
]);
|
||||
allDocs = (docsResp.documents || []).slice().sort((a, b) =>
|
||||
a.name.localeCompare(b.name, "ru")
|
||||
);
|
||||
subscribedIds = new Set(subsResp.document_ids || []);
|
||||
} catch (e) {
|
||||
sidebar.innerHTML = `<div class="ds-empty" style="color:var(--err)">Ошибка: ${esc(e.message)}</div>`;
|
||||
return;
|
||||
}
|
||||
|
||||
if (!allDocs.length) {
|
||||
sidebar.innerHTML = `
|
||||
<div class="ds-hint">только подписанные документы используются в RAG этой ветки</div>
|
||||
<div class="ds-empty">Документов пока нет. Загрузите их на странице «Отладка».</div>
|
||||
`;
|
||||
counter.innerHTML = "<b>0</b> из <b>0</b>";
|
||||
return;
|
||||
}
|
||||
|
||||
const items = allDocs.map(d => `
|
||||
<label class="ds-item">
|
||||
<input type="checkbox" data-doc-id="${esc(d.document_id)}" ${subscribedIds.has(d.document_id) ? "checked" : ""} onchange="updateDocsCounter()">
|
||||
<span class="ds-name" title="${esc(d.name)}">${esc(d.name)}</span>
|
||||
<span class="ds-meta">${d.chunks_count} ч.</span>
|
||||
</label>
|
||||
`).join("");
|
||||
|
||||
sidebar.innerHTML = `
|
||||
<div class="ds-hint">только подписанные документы используются в RAG этой ветки</div>
|
||||
<div class="ds-list" id="docs-subscription-list">${items}</div>
|
||||
<div class="ds-actions">
|
||||
<button class="primary" onclick="saveDocumentsForCurrentIntent()">Сохранить</button>
|
||||
<button onclick="loadDocumentsForCurrentIntent()">Сбросить</button>
|
||||
</div>
|
||||
`;
|
||||
updateDocsCounter();
|
||||
}
|
||||
|
||||
function updateDocsCounter() {
|
||||
const counter = $("docs-subscription-counter");
|
||||
const list = $("docs-subscription-list");
|
||||
if (!counter || !list) return;
|
||||
const all = list.querySelectorAll('input[type="checkbox"][data-doc-id]');
|
||||
const checked = list.querySelectorAll('input[type="checkbox"][data-doc-id]:checked');
|
||||
counter.innerHTML = `<b>${checked.length}</b> из <b>${all.length}</b>`;
|
||||
}
|
||||
|
||||
async function saveDocumentsForCurrentIntent() {
|
||||
if (!currentIntentCode) return;
|
||||
const list = $("docs-subscription-list");
|
||||
if (!list) return;
|
||||
const document_ids = Array.from(
|
||||
list.querySelectorAll('input[type="checkbox"][data-doc-id]:checked')
|
||||
).map(cb => cb.dataset.docId);
|
||||
try {
|
||||
const r = await api(`/intents/${encodeURIComponent(currentIntentCode)}/documents`, {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ document_ids }),
|
||||
});
|
||||
toast(`Подписки сохранены: ${r.document_ids.length} документ(ов)`);
|
||||
updateDocsCounter();
|
||||
} catch (e) {
|
||||
toast("Не удалось сохранить подписки: " + e.message, "err");
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------- versions ---------- */
|
||||
async function refreshVersions(code) {
|
||||
const intent = intents.find(i => i.code === code);
|
||||
|
||||
Reference in New Issue
Block a user