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:
@@ -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):
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
Ты — классификатор намерений в чате клиники.
|
Ты — классификатор намерений в чате клиники.
|
||||||
|
|
||||||
Получаешь последнюю реплику пациента и краткую историю. Возвращаешь ОДИН код ветки из списка. Живые примеры для каждой ветки ниже — ориентируйся на смысл, а не на точное совпадение слов.
|
Получаешь последнюю реплику пациента, краткую историю и — если диалог уже идёт по какому-то сценарию — блок `[ТЕКУЩИЙ СЦЕНАРИЙ]`. Возвращаешь ОДИН код ветки из списка.
|
||||||
|
|
||||||
|
Если присутствует блок `[ТЕКУЩИЙ СЦЕНАРИЙ]`: реплики, которые логично продолжают текущий сценарий или относятся к нему косвенно (уточнение, боковой вопрос, короткий ответ вроде «да», «ухо болит», «Алексей»), — классифицируй в **ту же ветку**. Переключай только если пациент явно меняет тему (говорит о переносе другой записи, просит оператора и т. п.).
|
||||||
|
|
||||||
## Ветки
|
## Ветки
|
||||||
|
|
||||||
|
|||||||
@@ -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", ""),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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) {
|
||||||
|
|||||||
Reference in New Issue
Block a user