From 82bba34937a8924b57e2eeb8dd500cbf753f00c8 Mon Sep 17 00:00:00 2001 From: AR 15 M4 Date: Sun, 26 Apr 2026 20:54:17 +0500 Subject: [PATCH] =?UTF-8?q?feat(sprint6b):=20=D0=B1=D0=BB=D0=BE=D0=BA=20G?= =?UTF-8?q?=20=E2=80=94=20=D1=83=D0=BC=D0=BD=D1=8B=D0=B9=20=D1=80=D0=BE?= =?UTF-8?q?=D1=83=D1=82=D0=B5=D1=80=20=D0=B2=D0=B8=D0=B4=D0=B8=D1=82=20thr?= =?UTF-8?q?ead=5Fstate?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - load_snapshot перенесён до вызова router.classify - RouterClient.classify принимает snapshot; добавляет блок [ТЕКУЩИЙ СЦЕНАРИЙ] в промпт роутера: ветка + шаг + слоты + инструкция предпочитать текущую ветку - Возвращает router_assembled_prompt для отладки - Промпт _router.md: объяснение блока [ТЕКУЩИЙ СЦЕНАРИЙ] и правило «предпочитай» - ChatResponse: поле router_assembled_prompt - Sandbox: раскрывающийся «промпт роутера» в блоке «Решение роутера» Co-Authored-By: Claude Sonnet 4.6 --- models/responses.py | 1 + prompts/intents/_router.md | 4 +++- routers/chat.py | 1 + services/chat_service.py | 12 ++++++++---- services/router_client.py | 40 ++++++++++++++++++++++++++++++++++---- static/sandbox.html | 13 ++++++++++--- 6 files changed, 59 insertions(+), 12 deletions(-) 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 + ? `
+ промпт роутера +
${esc(routerPrompt)}
+
` + : ""; const routerLine = intentCode ? `
${esc(intentCode)} — ${esc(intentName || '')}${configVersion ? ' · ветка v' + configVersion : ''}
${verdict} + ${routerPromptHtml}
` : ""; $("debug-router").innerHTML = routerLine || '
— маршрутизация пока не выполнена —
'; @@ -1022,7 +1029,7 @@ async function sendMessage() { appendMessage("assistant", r.answer, null, r.intent_code, r.intent_name, r.message_meta); $("chat-title").className = "chat-title"; $("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); refreshThreads(); } catch (e) {