diff --git a/models/responses.py b/models/responses.py
index 52c17da..c49516f 100644
--- a/models/responses.py
+++ b/models/responses.py
@@ -167,6 +167,7 @@ class ChatResponse(BaseModel):
message_meta: dict | None = None
escalation_reason: str | None = None
operator_summary: dict | None = None
+ router_assembled_prompt: str = ""
class ThreadDeleteResponse(BaseModel):
diff --git a/prompts/intents/_router.md b/prompts/intents/_router.md
index b0a46f7..07e97c2 100644
--- a/prompts/intents/_router.md
+++ b/prompts/intents/_router.md
@@ -1,6 +1,8 @@
Ты — классификатор намерений в чате клиники.
-Получаешь последнюю реплику пациента и краткую историю. Возвращаешь ОДИН код ветки из списка. Живые примеры для каждой ветки ниже — ориентируйся на смысл, а не на точное совпадение слов.
+Получаешь последнюю реплику пациента, краткую историю и — если диалог уже идёт по какому-то сценарию — блок `[ТЕКУЩИЙ СЦЕНАРИЙ]`. Возвращаешь ОДИН код ветки из списка.
+
+Если присутствует блок `[ТЕКУЩИЙ СЦЕНАРИЙ]`: реплики, которые логично продолжают текущий сценарий или относятся к нему косвенно (уточнение, боковой вопрос, короткий ответ вроде «да», «ухо болит», «Алексей»), — классифицируй в **ту же ветку**. Переключай только если пациент явно меняет тему (говорит о переносе другой записи, просит оператора и т. п.).
## Ветки
diff --git a/routers/chat.py b/routers/chat.py
index 4232c95..de6b2f6 100644
--- a/routers/chat.py
+++ b/routers/chat.py
@@ -78,4 +78,5 @@ async def chat(req: ChatRequest, session: AsyncSession = Depends(get_session)):
message_meta=result.get("message_meta"),
escalation_reason=result.get("escalation_reason"),
operator_summary=result.get("operator_summary"),
+ router_assembled_prompt=result.get("router_assembled_prompt", ""),
)
diff --git a/services/chat_service.py b/services/chat_service.py
index 397ac4a..b219261 100644
--- a/services/chat_service.py
+++ b/services/chat_service.py
@@ -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,
}
diff --git a/services/router_client.py b/services/router_client.py
index 84f2462..7458f3b 100644
--- a/services/router_client.py
+++ b/services/router_client.py
@@ -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": ""}
diff --git a/static/sandbox.html b/static/sandbox.html
index 4fe7e49..7689255 100644
--- a/static/sandbox.html
+++ b/static/sandbox.html
@@ -691,7 +691,7 @@ async function openThread(id) {
const lastAssistant = [...d.messages].reverse().find(m => m.role === "assistant");
const lastEscalation = [...d.messages].reverse().find(m => m.role === "assistant" && m.escalation_reason);
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);
} else {
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 hasBounces = bounces && bounces.length > 0;
const routerDiffers = routerIntentCode && intentCode && routerIntentCode !== intentCode;
@@ -926,10 +926,17 @@ function renderDebug(sources, prompt, intentCode, intentName, configVersion, rou
`;
}
+ const routerPromptHtml = routerPrompt
+ ? `промпт роутера
+