Files
inquiry_robot/inquiry-agent/agent/routing/dispatch.py
T

251 lines
9.6 KiB
Python

"""
入站分发壳:RouteDecision.intent → 唯一 handler。
本文件职责:维护意图→可调用对象表;dispatch_inbound 只查表调用。
禁止:在本文件写补问/建单/TMS 业务;禁止一个函数处理全部卡片。
"""
from __future__ import annotations
import logging
from typing import Callable, Optional
from agent.channel.wecom.models import InboundMessage
from agent.handlers import abnormal, card_action, close_deal, continue_thread, new_inquiry
from agent.handlers.echo_text import handle_echo_text
from agent.handlers.group_collab import handle_group_collab
from agent.handlers.image_inquiry import handle_image_inquiry
from agent.handlers.text_inquiry import handle_text_inquiry, handle_text_other, is_land_confirm_action
from agent.routing.decision import RouteDecision
logger = logging.getLogger(__name__)
# Handler 签名约定:只收 InboundMessage(出站/store 由 handler 自行取依赖)
HandlerFn = Callable[[InboundMessage], None]
def _noop(message: InboundMessage) -> None:
"""忽略类意图:不做事。"""
logger.info("dispatch.noop sender=%s msg=%s", message.sender_id, message.message_id)
def _wrap_echo(message: InboundMessage) -> None:
"""联调残留:仅 echo_text 意图使用。"""
from agent.channel.queue import get_message_store
from agent.config import get_settings
settings = get_settings()
handle_echo_text(
message,
get_message_store(),
ytd_env=settings.ytd_env,
reply_template=settings.wecom_callback_reply_text,
)
def _wrap_text_inquiry(message: InboundMessage) -> None:
"""普通文字询价:空运闭环。"""
handle_text_inquiry(message)
def _wrap_text_other(message: InboundMessage) -> None:
"""闲聊:回首次引导;若正等按钮则当按钮。"""
handle_text_other(message)
def _wrap_new(message: InboundMessage) -> None:
new_inquiry.handle_new_inquiry(message)
def _wrap_image(message: InboundMessage) -> None:
"""私聊图片询价:认图后当口播新开,复用文字流程。"""
handle_image_inquiry(message)
def _wrap_continue(message: InboundMessage, *, thread_id: str = "") -> None:
continue_thread.handle_continue_thread(message, thread_id=thread_id)
def _wrap_card(message: InboundMessage) -> None:
"""
卡片意图:把企微 TaskId / EventKey 原样交给流程,按当时工单号找回会话。
action 用正文(EventKey 或按钮文案),禁止写死 unknown。
"""
from agent.policy import inquiry_copy as copy
action = copy.canonical_button(message.content or "") or (message.content or "").strip()
payload = dict(message.raw or {})
payload.setdefault("event_key", message.content or "")
if message.chat_id:
payload["chat_id"] = message.chat_id
payload["chat_type"] = message.chat_type
card_action.handle_card_action(
sender_id=message.sender_id,
thread_id="",
wait_version=0,
action=action or "unknown",
payload=payload,
)
def _wrap_close(message: InboundMessage) -> None:
close_deal.handle_close_deal(
sender_id=message.sender_id,
inquiry_no="",
outcome="unknown",
)
def _wrap_abnormal(message: InboundMessage) -> None:
abnormal.handle_abnormal(sender_id=message.sender_id, inquiry_no="", reason="dispatch")
def _wrap_route_failed(message: InboundMessage) -> None:
"""模型没给出路由结论:请销售再说一次,不假装听懂。"""
from agent.channel.queue import get_message_store
from agent.policy import inquiry_copy as copy
get_message_store().enqueue_outbound(
touser=message.sender_id,
content=copy.ROUTE_MODEL_FAILED,
dedupe_key=f"route-fail:{message.message_id}",
)
# 意图 → handler(空壳或联调 echo);扩展时只改表,不改 ChannelRuntime 分支森林
HANDLER_REGISTRY: dict[str, HandlerFn] = {
"echo_text": _wrap_echo,
"ordinary_text_inquiry": _wrap_text_inquiry,
"ordinary_text_other": _wrap_text_other,
"attachment_inquiry": _wrap_new,
"image_inquiry": _wrap_image,
"multi_segment_transport": _wrap_new,
"tms_read_only_quote": _wrap_continue,
"new_inquiry": _wrap_new,
"continue_thread": _wrap_continue,
"card_action": _wrap_card,
"close_deal": _wrap_close,
"abnormal": _wrap_abnormal,
"reject_no_sender": _noop,
"ignore_empty": _noop,
"stub_needs_model": _wrap_echo,
"route_model_failed": _wrap_route_failed,
"route_parse_failed": _wrap_route_failed,
}
def _is_air_collab_room(message: InboundMessage) -> bool:
"""当前群已激活空运工单。查不到或海运群返回 False。"""
try:
from agent.handlers.air_group_collab import get_air_text_flow
from agent.policy.air_group_ops import bound_air_ticket
return bound_air_ticket(get_air_text_flow(), message.chat_id or "") is not None
except Exception:
logger.exception("判断空运协同群失败 chat=%s", getattr(message, "chat_id", ""))
return False
def resolve_handler(intent: str) -> Optional[HandlerFn]:
"""查表;未知意图返回 None。"""
return HANDLER_REGISTRY.get(intent)
def dispatch_inbound(message: InboundMessage, decision: RouteDecision) -> str:
"""
按 RouteDecision 调用唯一 handler。
返回:实际调用的 intent 名,或 unknown_intent / skipped。
副作用:仅 handler 内产生;本函数不做业务。
"""
if (message.chat_type or "") == "group":
from agent.policy import inquiry_copy as copy
event = str((message.raw or {}).get("event") or "").lower()
raw_text = (message.content or "").strip()
# 只有真点卡才走成交卡;群里打「成交」两个字要当文字成交,不能当私聊点卡丢掉。
is_card_event = event in {"template_card_event", "click", "templatecardevent"}
is_card_key = raw_text.startswith(
(copy.BTN_DEAL_KEY + ":", copy.BTN_LOST_KEY + ":", copy.BTN_NEGOTIATE_KEY + ":")
)
if is_card_event or is_card_key:
_wrap_card(message)
return "card_action"
source = str((message.raw or {}).get("source") or "").lower()
if source in {"aibot", "aibot_bridge"}:
from agent.handlers.air_group_collab import handle_air_group
phase = handle_air_group(message)
logger.info("dispatch air_group phase=%s chat=%s", phase, message.chat_id)
return "air_group_collab"
# 发文件不能 @:空运群附件靠会话存档听见。
# 文字本应走 BOT;BOT 没推到时,存档里已经带 @询价小助手 的短句按空运补听。
kind = (message.msg_type or "text").lower()
air_room = _is_air_collab_room(message)
if source == "wshoto_archive" and kind in {"text", ""}:
from agent.routing.air_group_intent import is_missed_air_bot_at
if is_missed_air_bot_at(raw_text, room_already_air=air_room):
from agent.handlers.air_group_collab import handle_air_group
phase = handle_air_group(message)
logger.info("dispatch air_group archive_at phase=%s chat=%s", phase, message.chat_id)
return "air_group_collab"
if air_room:
if kind in {"file", "image", "video"}:
from agent.handlers.air_group_collab import handle_air_group
phase = handle_air_group(message)
logger.info("dispatch air_group archive_file phase=%s chat=%s", phase, message.chat_id)
return "air_group_collab"
logger.info("dispatch 空运群存档文字忽略,须 @ BOT chat=%s", message.chat_id)
return "air_archive_text_ignore"
phase = handle_group_collab(message)
logger.info("dispatch group_collab phase=%s chat=%s", phase, message.chat_id)
return "group_collab"
if not message.has_sender() and decision.intent != "reject_no_sender":
logger.warning("dispatch 拒绝:无 sender_id")
HANDLER_REGISTRY["reject_no_sender"](message)
return "reject_no_sender"
kind = (message.msg_type or "text").lower()
if kind == "image":
handle_image_inquiry(message)
return "image_inquiry"
if kind in {"text", ""}:
from agent.handlers.image_inquiry import append_wave_text, is_active_image_wave
if is_active_image_wave(message.sender_id) and (message.content or "").strip():
append_wave_text(message.sender_id, message.content or "")
logger.info("dispatch 图片波次收字 sender=%s", message.sender_id)
return "image_wave_follow_text"
fn = resolve_handler(decision.intent)
if fn is None:
logger.warning("dispatch 未知意图 intent=%s → noop", decision.intent)
_noop(message)
return "unknown_intent"
logger.info(
"dispatch intent=%s sender=%s msg=%s wo=%s land_confirm=%s",
decision.intent,
message.sender_id,
message.message_id,
decision.thread_id or "-",
is_land_confirm_action(message) if message.has_sender() else False,
)
if decision.intent in {
"ordinary_text_other",
"route_model_failed",
"route_parse_failed",
} and is_land_confirm_action(message):
logger.info("dispatch 陆运核对卡确定:纠正闲聊路由 sender=%s", message.sender_id)
handle_text_inquiry(message)
return "ordinary_text_inquiry"
if decision.intent == "continue_thread":
continue_thread.handle_continue_thread(message, thread_id=decision.thread_id)
return decision.intent
fn(message)
return decision.intent