feat(ui+docs): читаемые бейджи реплик + раздел документации
Бейджи в Песочнице: - Каждый бейдж теперь с русским префиксом-меткой (ветка/шаг/роутер предложил/решение/тип ответа) - Тег «многошаговая» на бейдже ветки при 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 <noreply@anthropic.com>
This commit is contained in:
@@ -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,
|
||||
|
||||
+43
-3
@@ -262,14 +262,48 @@
|
||||
|
||||
<div class="term-card">
|
||||
<div class="term-head"><strong>Удержание в ветке</strong> <span class="term-en">(sticky state machine)</span></div>
|
||||
<div class="term-body">Защита от ложных переключений: если диалог идёт по пошаговому сценарию (например, <code>new_booking · qualify</code>) и маршрутизатор на короткой реплике («Алексей», «болит ухо») предлагает другую ветку — мы НЕ сбрасываем состояние, а передаём модели подсказку «маршрутизатор думает X, но ты в Y». Модель сама решает: остаться в сценарии (заполнить слот) или явно выйти через <code>[INTENT_CHANGE]</code>.</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> (в Песочнице — бейдж «роутер предложил: general_info»).</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">Каждая sm-ветка возвращает не только текст пациенту, но и служебный JSON-блок в хвосте ответа:
|
||||
<pre><code>STATE_JSON: {"state_after": "qualify", "slots_updated": {"name": "Алексей"}}</code></pre>
|
||||
Парсер вырезает этот блок (пациент его не видит), валидатор проверяет легальность <code>state_after</code>, обновляет состояние диалога.</div>
|
||||
Парсер вырезает этот блок (пациент его не видит), валидатор проверяет легальность <code>state_after</code>, обновляет состояние диалога. Необязательный флаг <code>soft_insertion: true</code> сигнализирует, что это был боковой ответ без продвижения сценария.</div>
|
||||
</div>
|
||||
|
||||
<div class="term-card">
|
||||
@@ -345,6 +379,12 @@
|
||||
<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>
|
||||
@@ -364,7 +404,7 @@
|
||||
</ul>
|
||||
|
||||
<div class="callout">
|
||||
Документ описывает текущее состояние после Спринта 6a. Перевод на оператора с указанием причины (acute_pain / surgery / routing_loop / …), сводка для оператора и умный маршрутизатор, видящий состояние диалога — в Спринте 6b.
|
||||
Документ описывает текущее состояние после Спринта 6b (блок D): удержание в ветке, боковые вопросы, структурированный ответ. Следующее: guards в сценарии записи (блок F), причина эскалации (блок E), умный маршрутизатор, видящий состояние диалога (блок G).
|
||||
</div>
|
||||
|
||||
</article>
|
||||
|
||||
+77
-43
@@ -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 ? `<span class="msg-intent" title="${esc(intentName || intentCode)}">${esc(intentCode)}</span>` : "";
|
||||
// Ветка
|
||||
const displayName = (intentName && intentName !== intentCode) ? intentName : intentCode;
|
||||
const smTag = (meta && (meta.is_state_machine || meta.step_code))
|
||||
? `<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>`
|
||||
: "";
|
||||
|
||||
if (!meta) return intent;
|
||||
|
||||
// Шаг state machine
|
||||
const stepDisplay = meta.step_name || meta.step_code;
|
||||
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-step" title="шаг state machine">${esc(meta.step_code)}</span>`
|
||||
? `<span class="msg-badge msg-step" title="Текущий шаг сценария"><span class="badge-label">шаг:</span><span class="badge-val">${esc(stepDisplay)}</span>${stepSub}</span>`
|
||||
: "";
|
||||
const router = (meta.router_intent_code && meta.router_intent_code !== meta.served_intent_code)
|
||||
? `<span class="msg-router">роутер: <code>${esc(meta.router_intent_code)}</code></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 events = (meta.events || []).map(e => {
|
||||
const cfg = EVENT_LABELS[e];
|
||||
if (!cfg) return "";
|
||||
return `<span class="msg-event ${esc(e)}" title="${esc(cfg.title)}">${esc(cfg.text)}</span>`;
|
||||
return `<span class="msg-badge msg-event ${esc(e)}" title="${esc(cfg.title)}"><span class="badge-label">${esc(cfg.label)}</span><span class="badge-val">${esc(cfg.text)}</span></span>`;
|
||||
}).join("");
|
||||
|
||||
return intent + stepBadge + router + events;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user