From 45832e2b37212897aa517c0035f1194ff83d399e Mon Sep 17 00:00:00 2001 From: AR 15 M4 Date: Sat, 25 Apr 2026 21:08:27 +0500 Subject: [PATCH] =?UTF-8?q?feat(ui+docs):=20=D1=87=D0=B8=D1=82=D0=B0=D0=B5?= =?UTF-8?q?=D0=BC=D1=8B=D0=B5=20=D0=B1=D0=B5=D0=B9=D0=B4=D0=B6=D0=B8=20?= =?UTF-8?q?=D1=80=D0=B5=D0=BF=D0=BB=D0=B8=D0=BA=20+=20=D1=80=D0=B0=D0=B7?= =?UTF-8?q?=D0=B4=D0=B5=D0=BB=20=D0=B4=D0=BE=D0=BA=D1=83=D0=BC=D0=B5=D0=BD?= =?UTF-8?q?=D1=82=D0=B0=D1=86=D0=B8=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Бейджи в Песочнице: - Каждый бейдж теперь с русским префиксом-меткой (ветка/шаг/роутер предложил/решение/тип ответа) - Тег «многошаговая» на бейдже ветки при is_state_machine или наличии step_code - Шаг: сначала русское название, потом код в скобках (Повод и специалист (qualify)) - get_thread_detail обогащает старые meta: подтягивает step_name и is_state_machine из БД Документация: - «Удержание в ветке» — пошаговый разбор sticky-механизма, явно что не второй роутер - Новая карточка «Боковой вопрос (soft_insertion)» — откуда берётся, счётчик, nudge - Блок под схемой «Что происходит на каждой реплике»: почему бейджи на ответах ассистента Co-Authored-By: Claude Sonnet 4.6 --- services/chat_service.py | 12 ++++ static/docs.html | 46 ++++++++++++++- static/sandbox.html | 120 +++++++++++++++++++++++++-------------- 3 files changed, 132 insertions(+), 46 deletions(-) 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].
+
+

Защита от ложных переключений внутри пошагового сценария. Это не второй вызов маршрутизатора — один вызов, но с расширенным промптом. Работает пошагово:

+
    +
  1. Пациент пишет что-то вроде «а сколько стоит приём?» внутри записи.
  2. +
  3. Маршрутизатор анализирует реплику и возвращает: general_info (в Песочнице — бейдж «роутер предложил: general_info»).
  4. +
  5. Система видит: тред уже идёт по многошаговой ветке new_booking, шаг qualify. Переключать опасно — потеряем контекст записи.
  6. +
  7. Вместо переключения: effective_code остаётся new_booking, в системный промпт ветки добавляется блок: +
    [ПОДСКАЗКА РОУТЕРА]
    +Роутер счёл, что тема — `general_info`.
    +Ты ведёшь сценарий `new_booking`.
    +Если пациент сменил тему — выдай [INTENT_CHANGE: general_info].
    +Если реплика в сценарии — зафиксируй в слот и продолжай.
    +
  8. +
  9. Модель new_booking получает весь этот контекст и сама решает: +
      +
    • Ответить на вопрос и остаться → вернёт {"state_after": "qualify", "soft_insertion": true} → бейдж «удержались в ветке» + «боковой вопрос».
    • +
    • Решить, что тема реально сменилась → вернёт [INTENT_CHANGE: general_info] → жёсткое переключение.
    • +
    +
  10. +
+

Ключевое: решение принимает модель ветки за один вызов, а не отдельный роутер. Подсказка роутера — просто контекст в промпте.

+
+
+ +
+
Боковой вопрос (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; }