diff --git a/db/models/message.py b/db/models/message.py index d74cd9c..54494d6 100644 --- a/db/models/message.py +++ b/db/models/message.py @@ -32,6 +32,9 @@ class Message(Base): # JSON со снимком обработки реплики: решение роутера, шаг, список событий. # Используется в Песочнице для отображения подробных пилюль (со Спринта 6b). meta_json: Mapped[str | None] = mapped_column(Text, nullable=True) + # Причина передачи оператору: acute_pain / surgery / angry / explicit_request / routing_loop. + # Проставляется только на реплике ассистента в ветке escalate_human. + escalation_reason: Mapped[str | None] = mapped_column(String(50), nullable=True) created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_utcnow, nullable=False) thread: Mapped["Thread"] = relationship(back_populates="messages") diff --git a/migrations/versions/h4b52e9dc0f83_add_escalation_reason.py b/migrations/versions/h4b52e9dc0f83_add_escalation_reason.py new file mode 100644 index 0000000..6b55f5c --- /dev/null +++ b/migrations/versions/h4b52e9dc0f83_add_escalation_reason.py @@ -0,0 +1,30 @@ +"""add escalation_reason to messages (Спринт 6b блок E) + +Revision ID: h4b52e9dc0f83 +Revises: g3a71d4fc285 +Create Date: 2026-04-26 10:00:00.000000 + +Причина передачи оператору: acute_pain / surgery / angry / +explicit_request / routing_loop. Хранится на реплике ассистента, +в которой ветка escalate_human первый раз ответила в треде. +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +revision: str = 'h4b52e9dc0f83' +down_revision: Union[str, None] = 'g3a71d4fc285' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + with op.batch_alter_table('messages', recreate='always') as batch: + batch.add_column(sa.Column('escalation_reason', sa.String(50), nullable=True)) + + +def downgrade() -> None: + with op.batch_alter_table('messages', recreate='always') as batch: + batch.drop_column('escalation_reason') diff --git a/models/responses.py b/models/responses.py index 16c3d79..52c17da 100644 --- a/models/responses.py +++ b/models/responses.py @@ -89,6 +89,7 @@ class MessageInfo(BaseModel): intent_code: str = "" intent_name: str = "" meta: dict | None = None + escalation_reason: str | None = None class ThreadInfo(BaseModel): @@ -164,6 +165,8 @@ class ChatResponse(BaseModel): routing_loop_triggered: bool = False resumed_from_suspended: bool = False message_meta: dict | None = None + escalation_reason: str | None = None + operator_summary: dict | None = None class ThreadDeleteResponse(BaseModel): diff --git a/prompts/intents/_router.md b/prompts/intents/_router.md index 77d52de..b0a46f7 100644 --- a/prompts/intents/_router.md +++ b/prompts/intents/_router.md @@ -49,11 +49,26 @@ - «кровотечение, что делать?» - «у меня операция, наркоз, нужна консультация по подготовке» +Для этой ветки возвращай **два значения через вертикальную черту**: `escalate_human|`. +Возможные значения reason: +- `acute_pain` — острая боль, не может терпеть, срочное состояние +- `surgery` — операция, хирургия, наркоз, стационар, подготовка к операции +- `angry` — пациент явно раздражён, требует, скандалит +- `explicit_request` — просто просит оператора («живого человека», «администратора») +- `routing_loop` — не используй вручную, проставляется автоматически + +Примеры: +- «у меня очень сильная боль» → `escalate_human|acute_pain` +- «нужна консультация по операции» → `escalate_human|surgery` +- «позовите оператора» → `escalate_human|explicit_request` +- «я уже устал это объяснять, дайте человека» → `escalate_human|angry` + ## Правила -- Отвечай ТОЛЬКО кодом ветки, без пояснений, без пунктуации, без кавычек. +- Для всех веток, кроме `escalate_human`: отвечай ТОЛЬКО кодом ветки, без пояснений, без пунктуации, без кавычек. +- Для `escalate_human`: отвечай в формате `escalate_human|` (одна строка, без пробелов вокруг `|`). - Если реплика содержит признаки конкретного процесса (записаться / перенести / оплатить / симптомы / оператор) — выбирай соответствующую ветку, а не `general_info`. - `general_info` — только для действительно общих вопросов без признаков перечисленных выше процессов. -- Любое упоминание операции, наркоза, стационара, хирургии → `escalate_human`. -- Любое явное «позовите оператора / переключите на человека» → `escalate_human`. +- Любое упоминание операции, наркоза, стационара, хирургии → `escalate_human|surgery`. +- Любое явное «позовите оператора / переключите на человека» → `escalate_human|explicit_request`. - Если фраза подходит одновременно под `new_booking` и `reschedule`, смотри: упоминает ли пациент УЖЕ существующую запись (время, дату, визит) — тогда `reschedule`; если нет или хочет новую — `new_booking`. diff --git a/prompts/intents/escalate_human.md b/prompts/intents/escalate_human.md index b00a212..e92ae5c 100644 --- a/prompts/intents/escalate_human.md +++ b/prompts/intents/escalate_human.md @@ -1,12 +1,32 @@ -Ты — виртуальный ассистент клиники. Эта ветка срабатывает, когда нужно немедленно передать диалог живому оператору. +Ты — виртуальный ассистент клиники. Эта ветка срабатывает, когда нужно передать диалог живому оператору. -Твоя задача простая и короткая: -- Признай ситуацию коротко и по-человечески (без многословия). -- Скажи, что сейчас передаёшь оператору. -- Если пациент описал острое состояние (боль, ребёнок задыхается, кровотечение и т. п.) — скажи «пожалуйста, если состояние ухудшается — сразу звоните в 103». -- Не пытайся вести длинный диалог, не задавай много вопросов. Две-три короткие реплики максимум. +Твоя задача — коротко и по-человечески ответить пациенту и дать понять, что оператор скоро подключится. + +## Поведение в зависимости от причины (escalation_reason из блока [ТЕКУЩЕЕ СОСТОЯНИЕ]) + +**acute_pain** — острая боль или срочное состояние: +- Признай ситуацию с сочувствием. +- Скажи, что передаёшь оператору прямо сейчас. +- Обязательно добавь: «Если состояние ухудшается — немедленно звоните в 103». + +**surgery** — вопрос про операцию, хирургию, наркоз, стационар: +- Скажи, что такие вопросы лучше обсудить с сотрудником клиники лично. +- Передай оператору, который ответит подробно. + +**angry** — пациент раздражён или требует человека в резкой форме: +- Не оправдывайся, не спорь. +- Коротко: «Понимаю, сейчас переключу на оператора». + +**explicit_request** — пациент просто попросил оператора: +- Скажи, что передаёшь диалог оператору. +- Можно добавить короткое «Он ответит вам в ближайшее время». + +**routing_loop** (автоматическая передача после петли роутера): +- Скажи, что не удалось до конца разобраться с запросом, и передаёшь оператору. + +## Общие правила -Правила: - Никогда не ставь диагнозы, не давай медицинских рекомендаций. - Не называй конкретных цен, времени приёма, имён врачей. -- Ответ — обычный текст, как в чате, на «вы». +- Ответ — две-три короткие реплики максимум, обычный текст, на «вы». +- Не задавай уточняющих вопросов — просто мягко завершай диалог. diff --git a/routers/chat.py b/routers/chat.py index 7493508..4232c95 100644 --- a/routers/chat.py +++ b/routers/chat.py @@ -76,4 +76,6 @@ async def chat(req: ChatRequest, session: AsyncSession = Depends(get_session)): routing_loop_triggered=result.get("routing_loop_triggered", False), resumed_from_suspended=result.get("resumed_from_suspended", False), message_meta=result.get("message_meta"), + escalation_reason=result.get("escalation_reason"), + operator_summary=result.get("operator_summary"), ) diff --git a/routers/threads.py b/routers/threads.py index f20badf..a335014 100644 --- a/routers/threads.py +++ b/routers/threads.py @@ -52,6 +52,7 @@ async def get_thread(thread_id: int, session: AsyncSession = Depends(get_session intent_code=m.get("intent_code", ""), intent_name=m.get("intent_name", ""), meta=m.get("meta"), + escalation_reason=m.get("escalation_reason"), ) for m in data["messages"] ], diff --git a/services/chat_service.py b/services/chat_service.py index ec0e441..397ac4a 100644 --- a/services/chat_service.py +++ b/services/chat_service.py @@ -60,6 +60,7 @@ def _format_state_context( current_step: IntentStep | None, router_hint: str | None = None, soft_nudge: bool = False, + escalation_reason: str | None = None, ) -> str: """Блок с текущим состоянием треда для дописывания в системный промпт.""" slots = snapshot.get("slots", {}) or {} @@ -72,6 +73,8 @@ def _format_state_context( else: lines.append("step_code: —") lines.append(f"slots: {slots_json}") + if escalation_reason: + lines.append(f"escalation_reason: {escalation_reason}") if router_hint: lines.append("") lines.append("[ПОДСКАЗКА РОУТЕРА]") @@ -82,6 +85,25 @@ def _format_state_context( return "\n" + "\n".join(lines) +def _build_operator_summary( + reason: str | None, + history: list[dict], + slots: dict, + suspended_slots: dict, +) -> dict: + """Саммари для оператора при передаче диалога.""" + combined_slots = {**suspended_slots, **slots} + summary = { + "reason": reason or "explicit_request", + "slots": combined_slots, + "history_tail": [ + {"role": m["role"], "text": m["content"][:300]} + for m in history[-8:] + ], + } + return summary + + async def _resolve_intent_with_fallback( session: AsyncSession, intent_code: str ) -> tuple[str, object, object]: @@ -186,6 +208,7 @@ async def send_message( routing = await router.classify(session=session, history=history, text=text) router_code = routing["code"] router_version = routing.get("version") + escalation_reason: str | None = routing.get("escalation_reason") # 2. Снимок состояния. Логика выбора effective_code: # 2.1. Если есть suspended_intent и роутер вернулся в него — RESUME: восстанавливаем @@ -293,6 +316,7 @@ async def send_message( resumable_step_code = None resumable_slots = {} routing_loop_triggered = True + escalation_reason = "routing_loop" # 3. Разрешаем ветку (с fallback) и шаг. served_code, intent, active_cfg = await _resolve_intent_with_fallback(session, effective_code) @@ -348,7 +372,10 @@ async def send_message( base_prompt = config_service.compose_full_system_prompt(active_cfg) step_prompt = f"\n\n{current_step.system_prompt}" if current_step else "" soft_nudge = is_state_machine and soft_insertion_count >= SOFT_INSERTION_CAP - state_context = _format_state_context(snapshot, current_step, router_hint, soft_nudge) + state_context = _format_state_context( + snapshot, current_step, router_hint, soft_nudge, + escalation_reason=escalation_reason if served_code == ESCALATE_INTENT_CODE else None, + ) system_prompt = base_prompt + step_prompt + state_context llm_result = await llm.chat( @@ -566,6 +593,20 @@ async def send_message( "events": events, } + # Саммари для оператора — формируется при передаче в escalate_human. + operator_summary: dict | None = None + if served_code == ESCALATE_INTENT_CODE: + operator_summary = _build_operator_summary( + reason=escalation_reason, + history=history, + slots=snapshot.get("slots", {}), + suspended_slots=snapshot.get("resumable_slots", {}), + ) + logger.info( + "Operator summary for thread %d: %s", + thread.id, json.dumps(operator_summary, ensure_ascii=False), + ) + assistant_msg = Message( thread_id=thread.id, role="assistant", @@ -574,6 +615,7 @@ async def send_message( assembled_prompt=last_assembled_prompt, intent_id=intent.id, meta_json=json.dumps(meta, ensure_ascii=False), + escalation_reason=escalation_reason if served_code == ESCALATE_INTENT_CODE else None, ) session.add(assistant_msg) @@ -626,6 +668,8 @@ async def send_message( "routing_loop_triggered": routing_loop_triggered, "resumed_from_suspended": resumed_from_suspended, "message_meta": meta, + "escalation_reason": escalation_reason if served_code == ESCALATE_INTENT_CODE else None, + "operator_summary": operator_summary, } @@ -720,6 +764,7 @@ async def get_thread_detail(session: AsyncSession, thread_id: int) -> dict | Non "intent_code": intent_code or "", "intent_name": intent_name or "", "meta": meta, + "escalation_reason": m.escalation_reason, }) state = await thread_state_service.load_snapshot(session, thread_id) diff --git a/services/router_client.py b/services/router_client.py index 64df587..84f2462 100644 --- a/services/router_client.py +++ b/services/router_client.py @@ -43,7 +43,10 @@ VALID_CODES = { "escalate_human", } +ESCALATION_REASONS = {"acute_pain", "surgery", "angry", "explicit_request", "routing_loop"} + CODE_RE = re.compile(r"\b(new_booking|reschedule|price_question|medical_question|general_info|escalate_human)\b") +REASON_RE = re.compile(r"escalate_human\|([a-z_]+)") class RouterClient: @@ -139,8 +142,16 @@ class RouterClient: match = CODE_RE.search(raw) if match: code = match.group(1) - logger.info("Router v%s: %r → %s", version, text[:80], code) - return {"code": code, "version": version} + escalation_reason: str | None = None + if code == "escalate_human": + reason_match = REASON_RE.search(raw) + if reason_match and reason_match.group(1) in ESCALATION_REASONS: + escalation_reason = reason_match.group(1) + else: + 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} logger.warning("Router returned unrecognized response %r, falling back to general_info", raw) - return {"code": "general_info", "version": version} + return {"code": "general_info", "version": version, "escalation_reason": None} diff --git a/static/sandbox.html b/static/sandbox.html index 693947e..4fe7e49 100644 --- a/static/sandbox.html +++ b/static/sandbox.html @@ -573,6 +573,7 @@

Решение роутера

— пока пусто —
+
Найденные фрагменты @@ -688,9 +689,10 @@ async function openThread(id) { $("chat-title").textContent = d.name; renderMessages(d.messages); 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); - renderState(d.thread_state, [], [], null, false, false); + 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); + renderState(d.thread_state, [], [], null, false, false, lastEscalation ? lastEscalation.escalation_reason : null); } else { clearDebug(); } @@ -793,7 +795,7 @@ function appendMessage(role, text, iso, intentCode, intentName, meta) { } /* ---------- отладка ---------- */ -function renderState(state, bounces, validationEvents, parseError, routingLoopTriggered, resumedFromSuspended) { +function renderState(state, bounces, validationEvents, parseError, routingLoopTriggered, resumedFromSuspended, escalationReason) { const box = $("debug-state"); if (!state || !state.current_intent_code) { box.innerHTML = '
сценарий ещё не запущен
'; @@ -818,9 +820,23 @@ function renderState(state, bounces, validationEvents, parseError, routingLoopTr ${esc(pendingGuard.description || "")} ` : ""; + const REASON_LABELS = { + acute_pain: "острая боль / срочное состояние", + surgery: "операция / хирургия / стационар", + angry: "пациент раздражён", + explicit_request: "запросил оператора", + routing_loop: "автоматически (петля роутера)", + }; const loopHtml = routingLoopTriggered ? `
- 🛑 защита от петли сработала: диалог уведён в escalate_human. + 🛑 защита от петли сработала: диалог уведён к оператору. +
` + : ""; + const effectiveReason = escalationReason || (state.current_intent_code === "escalate_human" ? "explicit_request" : null); + const escalationHtml = effectiveReason + ? `
+ 🔴 передача оператору · причина: ${esc(effectiveReason)} + — ${esc(REASON_LABELS[effectiveReason] || effectiveReason)}
` : ""; const suspendedSlotsCount = Object.keys(state.resumable_slots || {}).length; @@ -866,7 +882,7 @@ function renderState(state, bounces, validationEvents, parseError, routingLoopTr ${esc(state.current_intent_code)} — без пошагового сценария - ${handoffHtml}${pendingGuardHtml}${softNudgeHtml}${loopHtml}${suspendedHtml}${resumedHtml}${bounceHtml}${validationHtml}${parseErrorHtml} + ${handoffHtml}${escalationHtml}${pendingGuardHtml}${softNudgeHtml}${loopHtml}${suspendedHtml}${resumedHtml}${bounceHtml}${validationHtml}${parseErrorHtml} `; return; @@ -877,12 +893,12 @@ function renderState(state, bounces, validationEvents, parseError, routingLoopTr
${esc(state.current_intent_code)} · шаг ${esc(state.current_step_code)}
${esc(slotsJson)}
- ${handoffHtml}${pendingGuardHtml}${softNudgeHtml}${loopHtml}${suspendedHtml}${resumedHtml}${bounceHtml}${validationHtml}${parseErrorHtml} + ${handoffHtml}${escalationHtml}${pendingGuardHtml}${softNudgeHtml}${loopHtml}${suspendedHtml}${resumedHtml}${bounceHtml}${validationHtml}${parseErrorHtml}
`; } -function renderDebug(sources, prompt, intentCode, intentName, configVersion, routerVersion, routerIntentCode, bounces, stepCode) { +function renderDebug(sources, prompt, intentCode, intentName, configVersion, routerVersion, routerIntentCode, bounces, stepCode, operatorSummary) { const routerVer = routerVersion != null ? `роутер v${routerVersion}` : "роутер"; const hasBounces = bounces && bounces.length > 0; const routerDiffers = routerIntentCode && intentCode && routerIntentCode !== intentCode; @@ -941,6 +957,24 @@ function renderDebug(sources, prompt, intentCode, intentName, configVersion, rou $("debug-prompt").innerHTML = prompt ? `
${esc(prompt)}
` : '
промпт пуст
'; + + const summaryBox = $("debug-operator-summary"); + if (summaryBox) { + if (operatorSummary) { + summaryBox.style.display = ""; + summaryBox.innerHTML = ` +
саммари для оператора (предпросмотр)
+
причина: ${esc(operatorSummary.reason || "")}
+
слоты: ${esc(JSON.stringify(operatorSummary.slots || {}))}
+
история: + ${(operatorSummary.history_tail || []).map(h => + `
${esc(h.role === "user" ? "пациент" : "ассистент")}: ${esc(h.text)}
` + ).join("")} +
`; + } else { + summaryBox.style.display = "none"; + } + } } function clearDebug() { @@ -948,6 +982,7 @@ function clearDebug() { $("debug-router").innerHTML = '
— пока пусто —
'; $("debug-chunks").innerHTML = '
— пока пусто —
'; $("debug-prompt").innerHTML = '
— пока пусто —
'; + const s = $("debug-operator-summary"); if (s) s.style.display = "none"; } /* ---------- send message ---------- */ @@ -987,8 +1022,8 @@ 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); - renderState(r.thread_state, r.bounces, r.validation_events, r.parse_error, r.routing_loop_triggered, r.resumed_from_suspended); + 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); + 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) { // Откатываем визуально: убираем пузырь-заглушку ассистента и только что