feat(sprint6b): блок E — причина передачи оператору + саммари
- Роутер возвращает escalate_human|reason (acute_pain/surgery/angry/explicit_request/routing_loop) - RouterClient парсит reason; дефолт explicit_request при неразобранном - _format_state_context получает escalation_reason → подставляется в промпт escalate_human - Промпт escalate_human переписан: разное поведение по reason - _build_operator_summary: reason + 8 реплик истории + слоты, логируется при передаче - Message.escalation_reason (String 50, nullable) + миграция h4b52e9dc0f83 - ChatResponse и MessageInfo получили escalation_reason и operator_summary - Sandbox: красный блок «передача оператору · причина» в состоянии треда - Sandbox: блок саммари для оператора (предпросмотр) в панели отладки Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user