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

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

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

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

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

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

502 lines
38 KiB
HTML
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Chat Agent for Patients — Документация</title>
<style>
:root {
--bg: #f5f6f8;
--panel: #ffffff;
--border: #e1e4ea;
--muted: #6b7280;
--fg: #111827;
--accent: #2563eb;
--accent-hover: #1d4ed8;
--ok: #16a34a;
--warn: #d97706;
--err: #dc2626;
--chip-bg: #eef2ff;
--mono: ui-monospace, SFMono-Regular, Menlo, monospace;
}
* { box-sizing: border-box; }
html, body { height: 100%; }
body {
margin: 0;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
background: var(--bg);
color: var(--fg);
font-size: 14px;
line-height: 1.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: 32px 24px 80px 24px;
}
article {
max-width: 820px;
margin: 0 auto;
}
article h1 { font-size: 26px; font-weight: 700; margin: 0 0 6px 0; letter-spacing: -0.02em; }
article .lead {
color: var(--muted);
font-size: 15px;
margin: 0 0 32px 0;
}
article h2 {
font-size: 18px;
font-weight: 600;
letter-spacing: -0.01em;
margin: 36px 0 12px 0;
padding-bottom: 6px;
border-bottom: 1px solid var(--border);
}
article h3 {
font-size: 15px;
font-weight: 600;
margin: 20px 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 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; }
/* Карточка термина */
.term-card {
background: var(--panel);
border: 1px solid var(--border);
border-radius: 10px;
padding: 14px 16px;
margin-bottom: 10px;
}
.term-card .term-head {
font-size: 15px;
margin-bottom: 6px;
}
.term-card .term-head strong { font-weight: 700; }
.term-card .term-en {
color: var(--muted);
font-weight: 400;
}
.term-card .term-body {
font-size: 13.5px;
color: var(--fg);
}
/* Схема пошагово */
.flow-card {
background: var(--panel);
border: 1px solid var(--border);
border-radius: 10px;
padding: 16px 18px;
margin: 12px 0 18px 0;
}
.flow-step {
display: grid;
grid-template-columns: 32px 1fr;
gap: 10px;
padding: 10px 0;
border-bottom: 1px solid var(--border);
}
.flow-step:last-child { border-bottom: none; }
.flow-num {
background: var(--accent);
color: #fff;
width: 24px;
height: 24px;
border-radius: 50%;
display: inline-flex;
align-items: center;
justify-content: center;
font-size: 12px;
font-weight: 600;
}
.flow-step b { font-size: 14px; }
.flow-step .flow-text { font-size: 13.5px; color: var(--fg); }
.flow-step .flow-text .muted { color: var(--muted); font-size: 13px; }
/* TOC */
.toc {
background: var(--panel);
border: 1px solid var(--border);
border-radius: 10px;
padding: 14px 18px;
margin-bottom: 24px;
font-size: 13.5px;
}
.toc strong { display: block; margin-bottom: 6px; font-size: 13px; }
.toc ol { margin: 0; padding-left: 22px; }
.toc a { color: var(--accent); text-decoration: none; }
.toc a:hover { text-decoration: underline; }
.callout {
background: #fffbeb;
border: 1px solid #fde68a;
color: #78350f;
border-radius: 8px;
padding: 10px 14px;
font-size: 13.5px;
margin: 10px 0 16px 0;
}
</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="/regression.html" class="nav-link">Регрессия</a>
<a href="/docs.html" class="nav-link active">Документация</a>
</nav>
</header>
<main>
<article>
<h1>Как работает мультиагентная система</h1>
<p class="lead">Здесь объясняется, что такое <b>намерение (intent)</b> и <b>ветка (branch)</b>, как реплика пациента доходит до ответа, и какие защитные механизмы стоят на пути «петель» и потерянного контекста. Английские термины оставлены в скобках — на них завязан код и логи.</p>
<div class="toc">
<strong>Содержание</strong>
<ol>
<li><a href="#why">Зачем это всё</a></li>
<li><a href="#terms">Главные термины</a></li>
<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>ветка (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>, <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>
<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">
<div class="term-head"><strong>Пошаговый сценарий</strong> <span class="term-en">(state machine)</span></div>
<div class="term-body">Внутренний граф шагов внутри ветки. Сейчас есть только у <code>new_booking</code>: 6 шагов от приветствия до подтверждения записи. Модель на каждой реплике видит, на каком шаге сейчас, и какие <b>слоты</b> уже собраны.</div>
</div>
<div class="term-card">
<div class="term-head"><strong>Шаг</strong> <span class="term-en">(step)</span></div>
<div class="term-body">Узел пошагового сценария. У каждого шага свой код (<code>intro</code>, <code>qualify</code>, <code>present</code>, <code>offer_time</code>, <code>book</code>, <code>close</code>), свой кусок промпта и список <b>допустимых переходов</b>.</div>
</div>
<div class="term-card">
<div class="term-head"><strong>Допустимые переходы</strong> <span class="term-en">(allowed_next)</span></div>
<div class="term-body">Список кодов шагов, в которые можно перейти с текущего. Например, с <code>qualify</code> разрешено в <code>qualify</code> (остаться) или <code>present</code> (двигаться вперёд), но не в <code>close</code>. Если модель попытается перепрыгнуть через шаг — <b>валидатор переходов (transition validator)</b> отклонит запрос, мы останемся на шаге.</div>
</div>
<div class="term-card">
<div class="term-head"><strong>Слоты</strong> <span class="term-en">(slots)</span></div>
<div class="term-body">JSON-словарь данных, которые ветка собирает по ходу разговора. Для записи это <code>name</code>, <code>reason</code>, <code>specialist</code>, <code>preferred_time</code>, <code>confirmed</code>. Модель на каждой реплике их видит и обновляет — старые не переспрашиваются.</div>
</div>
<div class="term-card">
<div class="term-head"><strong>Условия выхода</strong> <span class="term-en">(exit conditions)</span></div>
<div class="term-body">Список ситуаций, когда ветка должна вместо обычного ответа выдать служебный сигнал <code>[INTENT_CHANGE: &lt;код_ветки&gt;]</code> и передать диалог другой ветке. Например, если пациент в записи упомянул хирургию — ветка <code>new_booking</code> сама вернёт <code>[INTENT_CHANGE: escalate_human]</code>.</div>
</div>
<div class="term-card">
<div class="term-head"><strong>Переключение ветки</strong> <span class="term-en">(hard handoff)</span></div>
<div class="term-body">Полная смена ветки внутри одного диалога с обнулением шага и слотов. Бывает в двух случаях: ветка сама выдала <code>[INTENT_CHANGE]</code> или маршрутизатор предложил другую ветку, которая не имеет пошагового сценария.</div>
</div>
<div class="term-card">
<div class="term-head"><strong>Удержание в ветке</strong> <span class="term-en">(sticky state machine)</span></div>
<div class="term-body">
<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> (в Песочнице — бейдж «Решение маршрутизатора: <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`.
Ты ведёшь сценарий `new_booking`.
Если пациент сменил тему — выдай [INTENT_CHANGE: general_info].
Если реплика в сценарии — зафиксируй в слот и продолжай.</code></pre>
</li>
<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>
</ul>
</li>
</ol>
<p style="margin-top:6px;">Ключевое: решение принимает <b>модель ветки за один вызов</b>, а не отдельный маршрутизатор. Подсказка маршрутизатора — просто контекст в промпте.</p>
</div>
</div>
<div class="term-card">
<div class="term-head"><strong>Боковой вопрос</strong> <span class="term-en">(soft insertion)</span></div>
<div class="term-body">
<p>Ситуация, когда модель пошаговой ветки отвечает на вопрос вне сценария, <b>не продвигая шаг и не меняя ветку</b>. Типичные примеры внутри <code>new_booking</code>: «а сколько стоит приём?», «у вас есть парковка?», «как долго идёт приём?».</p>
<p>Откуда система знает, что это боковой вопрос? Модель сама сообщает об этом в структурированном ответе:
<pre><code>STATE_JSON: {"state_after": "qualify", "slots_updated": {}, "soft_insertion": true}</code></pre>
Флаг <code>soft_insertion: true</code> + текущий шаг в <code>state_after</code> + пустые <code>slots_updated</code> = «ответил на отвлечение, сценарий не двинулся».
</p>
<p>Счётчик <code>soft_insertion_count</code> в состоянии диалога инкрементируется на каждом таком ответе и сбрасывается при смене шага или ветки. При трёх подряд — в системный промпт ветки добавляется жёсткое указание: «верни пациента к вопросу текущего шага».</p>
<p>В Песочнице: бейдж <b>«тип ответа: боковой вопрос»</b> (жёлтый) на ответе ассистента.</p>
</div>
</div>
<div class="term-card">
<div class="term-head"><strong>Структурированный ответ ветки</strong> <span class="term-en">(structured output)</span></div>
<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>
<div class="term-card">
<div class="term-head"><strong>Отложенный сценарий</strong> <span class="term-en">(suspended intent / resume)</span></div>
<div class="term-body">Если из пошаговой ветки произошёл переход в другую (например, посреди записи спросили про цены), её состояние (текущий шаг и собранные слоты) запоминается в полях <code>suspended_intent</code>, <code>resumable_step_code</code>, <code>resumable_slots</code>. Когда маршрутизатор увидит, что пациент возвращается к исходной теме («ладно, продолжаем запись»), мы автоматически восстановим шаг и слоты.</div>
</div>
<div class="term-card">
<div class="term-head"><strong>Защитное условие</strong> <span class="term-en">(guard)</span></div>
<div class="term-body">
<p>Правило, которое блокирует переход шага вперёд, пока не заполнены нужные слоты. Хранится в поле <code>guards</code> каждого шага (Настройки → Шаги → поле «Защитные условия»). Проверяется <b>после</b> того, как модель вернула корректный <code>state_after</code> — то есть даже если модель «захотела» перейти, валидатор не пустит без нужных данных.</p>
<p>Формат — JSON-объект, где каждый ключ — имя защитного условия:</p>
<pre><code>{
"require_legal_rep": {
"description": "Для записи ребёнка нужны ФИО и телефон законного представителя",
"trigger_slot": "is_child",
"trigger_value": true,
"required_slots": ["legal_rep_name", "legal_rep_phone"]
}
}</code></pre>
<p>Поля:</p>
<ul style="margin: 4px 0; padding-left: 20px; line-height: 1.8;">
<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>Сейчас защитное условие задано на шаге <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">При превышении 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">Запись в БД (одна на диалог), хранящая активную ветку, текущий шаг (если есть), собранные слоты, счётчик переключений и поля отложенного сценария. Видно в Песочнице справа, в блоке «Состояние диалога».</div>
</div>
<h2 id="flow">Что происходит на каждой реплике</h2>
<div class="flow-card">
<div class="flow-step">
<div class="flow-num">1</div>
<div class="flow-text">
<b>Маршрутизатор классифицирует реплику.</b>
<div class="muted">Отдельный вызов модели с короткой системой и историей. Возвращает один код намерения.</div>
</div>
</div>
<div class="flow-step">
<div class="flow-num">2</div>
<div class="flow-text">
<b>Проверяем отложенный сценарий.</b>
<div class="muted">Если в состоянии диалога есть <code>suspended_intent</code> и маршрутизатор вернул именно его — восстанавливаем шаг и слоты, очищаем поля, обнуляем счётчик переключений.</div>
</div>
</div>
<div class="flow-step">
<div class="flow-num">3</div>
<div class="flow-text">
<b>Применяем удержание в ветке.</b>
<div class="muted">Если диалог уже идёт по пошаговой ветке и маршрутизатор предлагает другую — состояние не сбрасываем, в системный промпт ветки добавляется блок <code>[ПОДСКАЗКА РОУТЕРА]</code>.</div>
</div>
</div>
<div class="flow-step">
<div class="flow-num">4</div>
<div class="flow-text">
<b>Если переключение всё-таки происходит — инкрементим счётчик.</b>
<div class="muted">При превышении 3 — авто-перевод на оператора, без вызова модели.</div>
</div>
</div>
<div class="flow-step">
<div class="flow-num">5</div>
<div class="flow-text">
<b>Собираем системный промпт ветки.</b>
<div class="muted">Базовый промпт + промпт текущего шага (если есть) + блок текущего состояния (шаг, слоты, подсказка). Зовём модель.</div>
</div>
</div>
<div class="flow-step">
<div class="flow-num">6</div>
<div class="flow-text">
<b>Парсим ответ.</b>
<div class="muted">Если есть <code>[INTENT_CHANGE]</code> — переключаемся в новую ветку (если из пошаговой ветки — запоминаем в отложенный сценарий) и зовём модель ещё раз. Если есть <code>STATE_JSON:</code> — валидируем переход, обновляем шаг и сливаем слоты.</div>
</div>
</div>
<div class="flow-step">
<div class="flow-num">7</div>
<div class="flow-text">
<b>Сохраняем сообщение пациента, ответ модели и обновлённое состояние диалога.</b>
<div class="muted">Всё одной транзакцией. Если что-то упадёт — откатываем целиком, «диалог-призрак» в списке не появится.</div>
</div>
</div>
</div>
<div class="callout" style="margin-top: 16px;">
<b>Почему бейджи — на ответах ассистента, а не на репликах пациента?</b><br>
Маршрутизатор обрабатывает каждую реплику пациента, но результат его решения материализуется в <em>следующем</em> ответе ассистента: именно там формируется ветка, шаг, события (sticky, боковой вопрос и т.д.). Поэтому в Песочнице бейджи стоят под ответом бота, а не над вопросом пациента — они описывают обработку предшествующей реплики.
</div>
<h2 id="guards">Защитные механизмы</h2>
<ul>
<li><b>Удержание в ветке</b> защищает от ложного сброса сценария на коротких репликах вроде «Алексей» или «болит ухо».</li>
<li><b>Валидатор переходов</b> блокирует «прыжки через шаг» — модель не сможет уйти из <code>intro</code> сразу в <code>book</code>.</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>
<h2 id="where">Где что настраивается</h2>
<ul>
<li><a href="/settings.html">Настройки</a> — список веток, активные версии промптов, поля «Системный промпт», «Правила», «Условия выхода». Для веток с пошаговым сценарием — вкладка «Шаги» с редактором каждого шага и его допустимых переходов.</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): удержание в ветке, боковые вопросы, структурированный ответ, защитные условия. Следующее: причина передачи оператору с reason (блок E), умный маршрутизатор, видящий состояние диалога (блок G).
</div>
</article>
</main>
</body>
</html>