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
+8 -4
View File
@@ -204,19 +204,22 @@ async def send_message(
rows = (await session.execute(stmt)).scalars().all()
history = [{"role": m.role, "content": m.text} for m in reversed(rows)]
# 1. Роутер — куда направляем.
routing = await router.classify(session=session, history=history, text=text)
# 1a. Снимок состояния — нужен роутеру, чтобы предпочитать текущую ветку.
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_version = routing.get("version")
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: восстанавливаем
# прерванный сценарий, очищаем suspended_*, handoff_count=0.
# 2.2. Иначе если диалог идёт по sm-ветке и роутер предлагает другую — sticky:
# НЕ сбрасываем state, передаём LLM [ПОДСКАЗКА РОУТЕРА].
# 2.3. Иначе если prev — не-sm и роутер ведёт в другую ветку — hard-handoff.
snapshot = await thread_state_service.load_snapshot(session, thread.id)
prev_intent_code = snapshot["current_intent_code"]
handoff_count = snapshot.get("handoff_count", 0)
soft_insertion_count = snapshot.get("soft_insertion_count", 0)
@@ -670,6 +673,7 @@ async def send_message(
"message_meta": meta,
"escalation_reason": escalation_reason if served_code == ESCALATE_INTENT_CODE else None,
"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}")
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]:
"""Активный промпт роутера из БД (ветка _router). Возвращает (prompt, version_or_None)."""
pair = await config_service.get_active_config_by_intent_code(
@@ -82,15 +105,20 @@ class RouterClient:
_, cfg = pair
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} — версия роутера для отладки.
При сомнении или парсинг-ошибке general_info (безопасный fallback).
"""
system_prompt, version = await self._get_system_prompt(session)
state_hint = self._format_state_hint(snapshot)
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"Код ветки:"
)
@@ -151,7 +179,11 @@ class RouterClient:
escalation_reason = "explicit_request"
logger.info("Router v%s: %r%s%s", version, text[:80], code,
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)
return {"code": "general_info", "version": version, "escalation_reason": None}
return {"code": "general_info", "version": version, "escalation_reason": None, "router_assembled_prompt": ""}