feat(sprint6b): блок G — умный роутер видит thread_state

- load_snapshot перенесён до вызова router.classify
- RouterClient.classify принимает snapshot; добавляет блок [ТЕКУЩИЙ СЦЕНАРИЙ]
  в промпт роутера: ветка + шаг + слоты + инструкция предпочитать текущую ветку
- Возвращает router_assembled_prompt для отладки
- Промпт _router.md: объяснение блока [ТЕКУЩИЙ СЦЕНАРИЙ] и правило «предпочитай»
- ChatResponse: поле router_assembled_prompt
- Sandbox: раскрывающийся «промпт роутера» в блоке «Решение роутера»

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
AR 15 M4
2026-04-26 20:54:17 +05:00
parent 231e1f2d01
commit 82bba34937
6 changed files with 59 additions and 12 deletions
+1
View File
@@ -167,6 +167,7 @@ class ChatResponse(BaseModel):
message_meta: dict | None = None message_meta: dict | None = None
escalation_reason: str | None = None escalation_reason: str | None = None
operator_summary: dict | None = None operator_summary: dict | None = None
router_assembled_prompt: str = ""
class ThreadDeleteResponse(BaseModel): class ThreadDeleteResponse(BaseModel):
+3 -1
View File
@@ -1,6 +1,8 @@
Ты — классификатор намерений в чате клиники. Ты — классификатор намерений в чате клиники.
Получаешь последнюю реплику пациента и краткую историю. Возвращаешь ОДИН код ветки из списка. Живые примеры для каждой ветки ниже — ориентируйся на смысл, а не на точное совпадение слов. Получаешь последнюю реплику пациента, краткую историю и — если диалог уже идёт по какому-то сценарию — блок `[ТЕКУЩИЙ СЦЕНАРИЙ]`. Возвращаешь ОДИН код ветки из списка.
Если присутствует блок `[ТЕКУЩИЙ СЦЕНАРИЙ]`: реплики, которые логично продолжают текущий сценарий или относятся к нему косвенно (уточнение, боковой вопрос, короткий ответ вроде «да», «ухо болит», «Алексей»), — классифицируй в **ту же ветку**. Переключай только если пациент явно меняет тему (говорит о переносе другой записи, просит оператора и т. п.).
## Ветки ## Ветки
+1
View File
@@ -78,4 +78,5 @@ async def chat(req: ChatRequest, session: AsyncSession = Depends(get_session)):
message_meta=result.get("message_meta"), message_meta=result.get("message_meta"),
escalation_reason=result.get("escalation_reason"), escalation_reason=result.get("escalation_reason"),
operator_summary=result.get("operator_summary"), operator_summary=result.get("operator_summary"),
router_assembled_prompt=result.get("router_assembled_prompt", ""),
) )
+8 -4
View File
@@ -204,19 +204,22 @@ async def send_message(
rows = (await session.execute(stmt)).scalars().all() rows = (await session.execute(stmt)).scalars().all()
history = [{"role": m.role, "content": m.text} for m in reversed(rows)] history = [{"role": m.role, "content": m.text} for m in reversed(rows)]
# 1. Роутер — куда направляем. # 1a. Снимок состояния — нужен роутеру, чтобы предпочитать текущую ветку.
routing = await router.classify(session=session, history=history, text=text) snapshot = await thread_state_service.load_snapshot(session, thread.id)
# 1b. Роутер — куда направляем.
routing = await router.classify(session=session, history=history, text=text, snapshot=snapshot)
router_code = routing["code"] router_code = routing["code"]
router_version = routing.get("version") router_version = routing.get("version")
escalation_reason: str | None = routing.get("escalation_reason") escalation_reason: str | None = routing.get("escalation_reason")
router_assembled_prompt: str = routing.get("router_assembled_prompt", "")
# 2. Снимок состояния. Логика выбора effective_code: # 2. Логика выбора effective_code:
# 2.1. Если есть suspended_intent и роутер вернулся в него — RESUME: восстанавливаем # 2.1. Если есть suspended_intent и роутер вернулся в него — RESUME: восстанавливаем
# прерванный сценарий, очищаем suspended_*, handoff_count=0. # прерванный сценарий, очищаем suspended_*, handoff_count=0.
# 2.2. Иначе если диалог идёт по sm-ветке и роутер предлагает другую — sticky: # 2.2. Иначе если диалог идёт по sm-ветке и роутер предлагает другую — sticky:
# НЕ сбрасываем state, передаём LLM [ПОДСКАЗКА РОУТЕРА]. # НЕ сбрасываем state, передаём LLM [ПОДСКАЗКА РОУТЕРА].
# 2.3. Иначе если prev — не-sm и роутер ведёт в другую ветку — hard-handoff. # 2.3. Иначе если prev — не-sm и роутер ведёт в другую ветку — hard-handoff.
snapshot = await thread_state_service.load_snapshot(session, thread.id)
prev_intent_code = snapshot["current_intent_code"] prev_intent_code = snapshot["current_intent_code"]
handoff_count = snapshot.get("handoff_count", 0) handoff_count = snapshot.get("handoff_count", 0)
soft_insertion_count = snapshot.get("soft_insertion_count", 0) soft_insertion_count = snapshot.get("soft_insertion_count", 0)
@@ -670,6 +673,7 @@ async def send_message(
"message_meta": meta, "message_meta": meta,
"escalation_reason": escalation_reason if served_code == ESCALATE_INTENT_CODE else None, "escalation_reason": escalation_reason if served_code == ESCALATE_INTENT_CODE else None,
"operator_summary": operator_summary, "operator_summary": operator_summary,
"router_assembled_prompt": router_assembled_prompt,
} }
+36 -4
View File
@@ -72,6 +72,29 @@ class RouterClient:
lines.append(f"{role_ru}: {content}") lines.append(f"{role_ru}: {content}")
return "\n".join(lines) return "\n".join(lines)
def _format_state_hint(self, snapshot: dict | None) -> str:
"""Блок [ТЕКУЩИЙ СЦЕНАРИЙ] для промпта роутера."""
if not snapshot:
return ""
intent = snapshot.get("current_intent_code")
if not intent:
return ""
step = snapshot.get("current_step_code")
slots = snapshot.get("slots") or {}
lines = [
"",
"[ТЕКУЩИЙ СЦЕНАРИЙ]",
f"Сейчас в диалоге активна ветка: {intent}" + (f", шаг: {step}" if step else ""),
]
if slots:
import json as _json
lines.append(f"Собранные слоты: {_json.dumps(slots, ensure_ascii=False)}")
lines.append(
"Если новая реплика пациента логично продолжает этот сценарий "
"или относится к нему косвенно — предпочитай ту же ветку."
)
return "\n".join(lines)
async def _get_system_prompt(self, session: AsyncSession) -> tuple[str, int | None]: async def _get_system_prompt(self, session: AsyncSession) -> tuple[str, int | None]:
"""Активный промпт роутера из БД (ветка _router). Возвращает (prompt, version_or_None).""" """Активный промпт роутера из БД (ветка _router). Возвращает (prompt, version_or_None)."""
pair = await config_service.get_active_config_by_intent_code( pair = await config_service.get_active_config_by_intent_code(
@@ -82,15 +105,20 @@ class RouterClient:
_, cfg = pair _, cfg = pair
return config_service.compose_full_system_prompt(cfg), cfg.version return config_service.compose_full_system_prompt(cfg), cfg.version
async def classify(self, session: AsyncSession, history: list[dict], text: str) -> dict: async def classify(
self, session: AsyncSession, history: list[dict], text: str,
snapshot: dict | None = None,
) -> dict:
"""Классифицировать реплику. Возвращает {code, version} — версия роутера для отладки. """Классифицировать реплику. Возвращает {code, version} — версия роутера для отладки.
При сомнении или парсинг-ошибке general_info (безопасный fallback). При сомнении или парсинг-ошибке general_info (безопасный fallback).
""" """
system_prompt, version = await self._get_system_prompt(session) system_prompt, version = await self._get_system_prompt(session)
state_hint = self._format_state_hint(snapshot)
user_message = ( user_message = (
f"История последних реплик:\n{self._format_history(history)}\n\n" f"История последних реплик:\n{self._format_history(history)}"
f"{state_hint}\n\n"
f"Новая реплика пациента:\n{text}\n\n" f"Новая реплика пациента:\n{text}\n\n"
f"Код ветки:" f"Код ветки:"
) )
@@ -151,7 +179,11 @@ class RouterClient:
escalation_reason = "explicit_request" escalation_reason = "explicit_request"
logger.info("Router v%s: %r%s%s", version, text[:80], code, logger.info("Router v%s: %r%s%s", version, text[:80], code,
f"|{escalation_reason}" if escalation_reason else "") f"|{escalation_reason}" if escalation_reason else "")
return {"code": code, "version": version, "escalation_reason": escalation_reason} return {
"code": code, "version": version,
"escalation_reason": escalation_reason,
"router_assembled_prompt": f"[system]\n{system_prompt}\n\n[user]\n{user_message}",
}
logger.warning("Router returned unrecognized response %r, falling back to general_info", raw) logger.warning("Router returned unrecognized response %r, falling back to general_info", raw)
return {"code": "general_info", "version": version, "escalation_reason": None} return {"code": "general_info", "version": version, "escalation_reason": None, "router_assembled_prompt": ""}
+10 -3
View File
@@ -691,7 +691,7 @@ async function openThread(id) {
const lastAssistant = [...d.messages].reverse().find(m => m.role === "assistant"); const lastAssistant = [...d.messages].reverse().find(m => m.role === "assistant");
const lastEscalation = [...d.messages].reverse().find(m => m.role === "assistant" && m.escalation_reason); const lastEscalation = [...d.messages].reverse().find(m => m.role === "assistant" && m.escalation_reason);
if (lastAssistant) { 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); 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);
renderState(d.thread_state, [], [], null, false, false, lastEscalation ? lastEscalation.escalation_reason : null); renderState(d.thread_state, [], [], null, false, false, lastEscalation ? lastEscalation.escalation_reason : null);
} else { } else {
clearDebug(); clearDebug();
@@ -898,7 +898,7 @@ function renderState(state, bounces, validationEvents, parseError, routingLoopTr
`; `;
} }
function renderDebug(sources, prompt, intentCode, intentName, configVersion, routerVersion, routerIntentCode, bounces, stepCode, operatorSummary) { function renderDebug(sources, prompt, intentCode, intentName, configVersion, routerVersion, routerIntentCode, bounces, stepCode, operatorSummary, routerPrompt) {
const routerVer = routerVersion != null ? `роутер v${routerVersion}` : "роутер"; const routerVer = routerVersion != null ? `роутер v${routerVersion}` : "роутер";
const hasBounces = bounces && bounces.length > 0; const hasBounces = bounces && bounces.length > 0;
const routerDiffers = routerIntentCode && intentCode && routerIntentCode !== intentCode; const routerDiffers = routerIntentCode && intentCode && routerIntentCode !== intentCode;
@@ -926,10 +926,17 @@ function renderDebug(sources, prompt, intentCode, intentName, configVersion, rou
</div>`; </div>`;
} }
const routerPromptHtml = routerPrompt
? `<details style="margin-top:6px;">
<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>`
: "";
const routerLine = intentCode const routerLine = intentCode
? `<div style="padding:10px 14px;background:#ecfdf5;font-size:12px;border-radius:6px;"> ? `<div style="padding:10px 14px;background:#ecfdf5;font-size:12px;border-radius:6px;">
<div><b>${esc(intentCode)}</b> — ${esc(intentName || '')}${configVersion ? ' · ветка v' + configVersion : ''}</div> <div><b>${esc(intentCode)}</b> — ${esc(intentName || '')}${configVersion ? ' · ветка v' + configVersion : ''}</div>
${verdict} ${verdict}
${routerPromptHtml}
</div>` </div>`
: ""; : "";
$("debug-router").innerHTML = routerLine || '<div class="mini">— маршрутизация пока не выполнена —</div>'; $("debug-router").innerHTML = routerLine || '<div class="mini">— маршрутизация пока не выполнена —</div>';
@@ -1022,7 +1029,7 @@ async function sendMessage() {
appendMessage("assistant", r.answer, null, r.intent_code, r.intent_name, r.message_meta); appendMessage("assistant", r.answer, null, r.intent_code, r.intent_name, r.message_meta);
$("chat-title").className = "chat-title"; $("chat-title").className = "chat-title";
$("chat-title").textContent = r.thread_name; $("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); 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);
renderState(r.thread_state, r.bounces, r.validation_events, r.parse_error, r.routing_loop_triggered, r.resumed_from_suspended, r.escalation_reason); renderState(r.thread_state, r.bounces, r.validation_events, r.parse_error, r.routing_loop_triggered, r.resumed_from_suspended, r.escalation_reason);
refreshThreads(); refreshThreads();
} catch (e) { } catch (e) {