diff --git a/services/chat_service.py b/services/chat_service.py
index 5894205..7ff94c9 100644
--- a/services/chat_service.py
+++ b/services/chat_service.py
@@ -508,6 +508,8 @@ async def send_message(
"router_intent_code": router_code,
"served_intent_code": served_code,
"step_code": snapshot.get("current_step_code"),
+ "step_name": current_step.name if current_step else None,
+ "is_state_machine": is_state_machine,
"events": events,
}
@@ -630,6 +632,10 @@ async def get_thread_detail(session: AsyncSession, thread_id: int) -> dict | Non
)
rows = (await session.execute(stmt)).all()
+ # Lookup для обогащения старых meta: (intent_id, step_code) -> step_name
+ step_rows = (await session.execute(select(IntentStep.intent_id, IntentStep.code, IntentStep.name))).all()
+ step_name_lookup: dict[tuple, str] = {(iid, sc): sn for iid, sc, sn in step_rows}
+
messages = []
for m, intent_code, intent_name in rows:
sources = []
@@ -644,6 +650,12 @@ async def get_thread_detail(session: AsyncSession, thread_id: int) -> dict | Non
meta = json.loads(m.meta_json)
except json.JSONDecodeError:
logger.warning("Bad meta_json for message %d", m.id)
+ # Обогащаем meta полями, которых не было в старых сообщениях
+ if meta and meta.get("step_code"):
+ if "step_name" not in meta and m.intent_id:
+ meta["step_name"] = step_name_lookup.get((m.intent_id, meta["step_code"]))
+ if "is_state_machine" not in meta:
+ meta["is_state_machine"] = True
messages.append({
"id": m.id,
"role": m.role,
diff --git a/static/docs.html b/static/docs.html
index 19d9cab..02717b4 100644
--- a/static/docs.html
+++ b/static/docs.html
@@ -262,14 +262,48 @@
Удержание в ветке (sticky state machine)
-
Защита от ложных переключений: если диалог идёт по пошаговому сценарию (например, new_booking · qualify) и маршрутизатор на короткой реплике («Алексей», «болит ухо») предлагает другую ветку — мы НЕ сбрасываем состояние, а передаём модели подсказку «маршрутизатор думает X, но ты в Y». Модель сама решает: остаться в сценарии (заполнить слот) или явно выйти через [INTENT_CHANGE].
+
+
Защита от ложных переключений внутри пошагового сценария. Это не второй вызов маршрутизатора — один вызов, но с расширенным промптом. Работает пошагово:
+
+ - Пациент пишет что-то вроде «а сколько стоит приём?» внутри записи.
+ - Маршрутизатор анализирует реплику и возвращает:
general_info (в Песочнице — бейдж «роутер предложил: general_info»).
+ - Система видит: тред уже идёт по многошаговой ветке
new_booking, шаг qualify. Переключать опасно — потеряем контекст записи.
+ - Вместо переключения:
effective_code остаётся new_booking, в системный промпт ветки добавляется блок:
+ [ПОДСКАЗКА РОУТЕРА]
+Роутер счёл, что тема — `general_info`.
+Ты ведёшь сценарий `new_booking`.
+Если пациент сменил тему — выдай [INTENT_CHANGE: general_info].
+Если реплика в сценарии — зафиксируй в слот и продолжай.
+
+ - Модель
new_booking получает весь этот контекст и сама решает:
+
+ - Ответить на вопрос и остаться → вернёт
{"state_after": "qualify", "soft_insertion": true} → бейдж «удержались в ветке» + «боковой вопрос».
+ - Решить, что тема реально сменилась → вернёт
[INTENT_CHANGE: general_info] → жёсткое переключение.
+
+
+
+
Ключевое: решение принимает модель ветки за один вызов, а не отдельный роутер. Подсказка роутера — просто контекст в промпте.
+
+
+
+
+
Боковой вопрос (soft insertion)
+
+
Ситуация, когда модель пошаговой ветки отвечает на вопрос вне сценария, не продвигая шаг и не меняя ветку. Типичные примеры внутри new_booking: «а сколько стоит приём?», «у вас есть парковка?», «как долго идёт приём?».
+
Откуда система знает, что это боковой вопрос? Модель сама сообщает об этом в структурированном ответе:
+
STATE_JSON: {"state_after": "qualify", "slots_updated": {}, "soft_insertion": true}
+ Флаг
soft_insertion: true + текущий шаг в
state_after + пустые
slots_updated = «ответил на отвлечение, сценарий не двинулся».
+
+
Счётчик soft_insertion_count в состоянии диалога инкрементируется на каждом таком ответе и сбрасывается при смене шага или ветки. При трёх подряд — в системный промпт ветки добавляется жёсткое указание: «верни пациента к вопросу текущего шага».
+
В Песочнице: бейдж «тип ответа: боковой вопрос» (жёлтый) на ответе ассистента.
+
Структурированный ответ ветки (structured output)
Каждая sm-ветка возвращает не только текст пациенту, но и служебный JSON-блок в хвосте ответа:
STATE_JSON: {"state_after": "qualify", "slots_updated": {"name": "Алексей"}}
- Парсер вырезает этот блок (пациент его не видит), валидатор проверяет легальность
state_after, обновляет состояние диалога.
+ Парсер вырезает этот блок (пациент его не видит), валидатор проверяет легальность
state_after, обновляет состояние диалога. Необязательный флаг
soft_insertion: true сигнализирует, что это был боковой ответ без продвижения сценария.
@@ -345,6 +379,12 @@
Всё одной транзакцией. Если что-то упадёт — откатываем целиком, «диалог-призрак» в списке не появится.
+
+
+
+
+ Почему бейджи — на ответах ассистента, а не на репликах пациента?
+ Маршрутизатор обрабатывает каждую реплику пациента, но результат его решения материализуется в следующем ответе ассистента: именно там формируется ветка, шаг, события (sticky, боковой вопрос и т.д.). Поэтому в Песочнице бейджи стоят под ответом бота, а не над вопросом пациента — они описывают обработку предшествующей реплики.
Защитные механизмы
@@ -364,7 +404,7 @@
- Документ описывает текущее состояние после Спринта 6a. Перевод на оператора с указанием причины (acute_pain / surgery / routing_loop / …), сводка для оператора и умный маршрутизатор, видящий состояние диалога — в Спринте 6b.
+ Документ описывает текущее состояние после Спринта 6b (блок D): удержание в ветке, боковые вопросы, структурированный ответ. Следующее: guards в сценарии записи (блок F), причина эскалации (блок E), умный маршрутизатор, видящий состояние диалога (блок G).
diff --git a/static/sandbox.html b/static/sandbox.html
index f730e86..026c034 100644
--- a/static/sandbox.html
+++ b/static/sandbox.html
@@ -225,52 +225,66 @@
white-space: pre-wrap;
}
.msg.assistant { background: var(--bot-bg); align-self: flex-start; }
- .msg-intent {
- display: inline-block;
- background: var(--chip-bg);
- color: var(--accent);
- padding: 1px 7px;
+ /* Строка бейджей под сообщением ассистента */
+ .msg-badge {
+ display: inline-flex;
+ align-items: center;
+ gap: 2px;
+ padding: 2px 8px;
border-radius: 10px;
font-size: 10px;
+ margin-right: 5px;
+ line-height: 1.5;
+ white-space: nowrap;
+ }
+ .msg-badge .badge-label {
+ font-weight: 400;
+ opacity: 0.65;
+ margin-right: 2px;
+ }
+ .msg-badge .badge-val {
font-weight: 600;
font-family: var(--mono);
- margin-right: 6px;
}
+ .msg-badge .badge-sub {
+ font-weight: 400;
+ font-family: var(--mono);
+ opacity: 0.7;
+ margin-left: 2px;
+ }
+ /* Ветка */
+ .msg-intent {
+ background: var(--chip-bg);
+ color: var(--accent);
+ }
+ .msg-intent .badge-sm-tag {
+ font-weight: 400;
+ font-size: 9px;
+ opacity: 0.75;
+ margin-left: 4px;
+ background: rgba(99,102,241,0.12);
+ padding: 0 4px;
+ border-radius: 6px;
+ font-family: sans-serif;
+ }
+ /* Шаг */
.msg-step {
- display: inline-block;
background: #eef2ff;
color: #3730a3;
- padding: 1px 7px;
- border-radius: 10px;
- font-size: 10px;
- font-weight: 500;
- font-family: var(--mono);
- margin-right: 6px;
}
+ /* Роутер предложил */
.msg-router {
- display: inline-block;
- color: var(--muted);
- font-size: 10px;
- margin-right: 6px;
- }
- .msg-router code {
- background: #fafbfd;
- border: 1px solid var(--border);
- color: var(--muted);
- padding: 0 4px;
- border-radius: 4px;
- font-family: var(--mono);
- font-size: 10px;
+ background: #f3f4f6;
+ color: #4b5563;
+ border: 1px solid #e5e7eb;
}
+ .msg-router .badge-val { font-family: var(--mono); }
+ /* События */
.msg-event {
- display: inline-block;
- padding: 1px 7px;
- border-radius: 10px;
- font-size: 10px;
font-weight: 500;
- margin-right: 4px;
cursor: help;
}
+ .msg-event .badge-label { opacity: 0.6; }
.msg-event.sticky { background: #dbeafe; color: #1e40af; }
.msg-event.hard_handoff { background: #ffedd5; color: #9a3412; }
.msg-event.soft_insertion{ background: #fef3c7; color: #78350f; }
@@ -310,7 +324,7 @@
padding-left: 10px;
color: var(--muted);
}
- .msg-meta { font-size: 10px; color: var(--muted); margin-top: 4px; }
+ .msg-meta { font-size: 10px; color: var(--muted); margin-top: 6px; display: flex; flex-wrap: wrap; align-items: center; gap: 3px; }
.chat-empty {
margin: auto;
color: var(--muted);
@@ -695,28 +709,48 @@ function startNewThread() {
}
const EVENT_LABELS = {
- sticky: { text: "удержались", title: "роутер предлагал другую ветку, ветка осталась в сценарии" },
- hard_handoff: { text: "переключение", title: "ветка сама выдала [INTENT_CHANGE] и передала диалог другой" },
- soft_insertion: { text: "боковой вопрос", title: "ответ вне шага: модель ответила на побочный вопрос, не двигая сценарий" },
- resumed: { text: "возврат", title: "восстановили отложенный сценарий со всеми слотами" },
- routing_loop: { text: "защита от петли", title: "сработала защита: автоматический перевод на оператора" },
- validation_blocked: { 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 заблокировал переход на запрошенный шаг" },
};
function renderAssistantBadges(intentCode, intentName, meta) {
- const intent = intentCode ? `${esc(intentCode)}` : "";
+ // Ветка
+ const displayName = (intentName && intentName !== intentCode) ? intentName : intentCode;
+ const smTag = (meta && (meta.is_state_machine || meta.step_code))
+ ? `многошаговая` : "";
+ const codeHint = (intentName && intentName !== intentCode)
+ ? `(${esc(intentCode)})` : "";
+ const intent = intentCode
+ ? `ветка:${esc(displayName)}${codeHint}${smTag}`
+ : "";
+
if (!meta) return intent;
+
+ // Шаг state machine
+ const stepDisplay = meta.step_name || meta.step_code;
+ const stepSub = meta.step_name && meta.step_code
+ ? `(${esc(meta.step_code)})` : "";
const stepBadge = meta.step_code
- ? `${esc(meta.step_code)}`
+ ? `шаг:${esc(stepDisplay)}${stepSub}`
: "";
- const router = (meta.router_intent_code && meta.router_intent_code !== meta.served_intent_code)
- ? `роутер: ${esc(meta.router_intent_code)}`
+
+ // Роутер предложил другую ветку
+ const routerDiffers = meta.router_intent_code && meta.router_intent_code !== meta.served_intent_code;
+ const router = routerDiffers
+ ? `роутер предложил:${esc(meta.router_intent_code)}`
: "";
+
+ // События
const events = (meta.events || []).map(e => {
const cfg = EVENT_LABELS[e];
if (!cfg) return "";
- return `${esc(cfg.text)}`;
+ return `${esc(cfg.label)}${esc(cfg.text)}`;
}).join("");
+
return intent + stepBadge + router + events;
}