落地私聊空运询价与TMS报价回填,按后台模板出单。
查到价后把币种、单位、数量、单价、备注写入报价单,避免销售只看到光秃秃的金额。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -59,6 +59,26 @@ class IntentRouter:
|
||||
"""一条入站一个 RouteDecision。"""
|
||||
return self._a().route(text=text, sender_id=sender_id)
|
||||
|
||||
def route_inbound(
|
||||
self,
|
||||
*,
|
||||
text: str,
|
||||
sender_id: str,
|
||||
event: str = "",
|
||||
) -> RouteDecision:
|
||||
"""
|
||||
入站后再路由。
|
||||
|
||||
点卡片是结构化事件,不进大模型。
|
||||
自由文字交给 DeepSeek A 认意图(续办 / 新询价 / 闲聊),不要求固定口令。
|
||||
"""
|
||||
if not sender_id:
|
||||
return RouteDecision(intent="reject_no_sender", confidence=1.0)
|
||||
kind = (event or "").strip().lower()
|
||||
if kind in {"template_card_event", "click", "templatecardevent"}:
|
||||
return RouteDecision(intent="card_action", confidence=1.0)
|
||||
return self.route(text=text, sender_id=sender_id)
|
||||
|
||||
def enqueue_ordered_actions(
|
||||
self,
|
||||
*,
|
||||
|
||||
@@ -16,6 +16,7 @@ from agent.config import Settings, get_settings
|
||||
from agent.llm.http_client import LlmHttpClient, LlmHttpResult
|
||||
from agent.llm.modes import LlmMode
|
||||
from agent.routing.decision import RouteDecision
|
||||
from agent.policy import inquiry_copy as copy
|
||||
from agent.routing.ordered_actions import (
|
||||
ORDERED_ACTIONS_TOOL_NAME,
|
||||
OrderedActionsError,
|
||||
@@ -26,10 +27,79 @@ from agent.routing.ordered_actions import (
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _prepare_model_tool_args(
|
||||
args: dict[str, Any] | str,
|
||||
*,
|
||||
text: str,
|
||||
sender_id: str,
|
||||
) -> dict[str, Any]:
|
||||
"""
|
||||
把模型 tool 参数收成可过合同的形态。
|
||||
|
||||
证据码、来源、order 由本机补;续办时工单号可从原话回填。
|
||||
"""
|
||||
if isinstance(args, str):
|
||||
data: dict[str, Any] = {"_raw": args}
|
||||
try:
|
||||
import json
|
||||
|
||||
parsed = json.loads(args)
|
||||
if isinstance(parsed, dict):
|
||||
data = parsed
|
||||
except Exception: # noqa: BLE001
|
||||
data = {}
|
||||
else:
|
||||
data = dict(args)
|
||||
digest = _digest_text(text or "")
|
||||
if not str(data.get("decision_id") or "").strip():
|
||||
data["decision_id"] = f"DS-{digest[:16]}"
|
||||
action = data.get("ordered_action")
|
||||
if action is None and isinstance(data.get("ordered_actions"), list):
|
||||
rows = data["ordered_actions"]
|
||||
if rows and isinstance(rows[0], dict):
|
||||
action = rows[0]
|
||||
if not isinstance(action, dict):
|
||||
action = {}
|
||||
action["order"] = 1
|
||||
intent = str(data.get("intent") or "").strip()
|
||||
action_type = str(action.get("action_type") or "").strip()
|
||||
if intent == "continue_thread" or action_type == "CONTINUE_HISTORICAL_TICKET":
|
||||
data["intent"] = "continue_thread"
|
||||
action["action_type"] = "CONTINUE_HISTORICAL_TICKET"
|
||||
target = action.get("target") if isinstance(action.get("target"), dict) else {}
|
||||
wo = str(target.get("work_order_no") or "").strip()
|
||||
if data.get("intent") == "continue_thread" and not wo:
|
||||
wo = copy.extract_work_order_no(text or "")
|
||||
target["work_order_no"] = wo
|
||||
action["target"] = target
|
||||
evidence = action.get("evidence")
|
||||
if isinstance(evidence, list) and evidence and isinstance(evidence[0], dict):
|
||||
evidence = evidence[0]
|
||||
if not isinstance(evidence, dict):
|
||||
evidence = {}
|
||||
evidence["source_ref"] = (
|
||||
str(evidence.get("source_ref") or "").strip() or f"inbound:{sender_id}"
|
||||
)
|
||||
evidence["content_digest"] = digest
|
||||
action["evidence"] = evidence
|
||||
data["ordered_action"] = action
|
||||
return data
|
||||
|
||||
_SYSTEM = (
|
||||
"You are DeepSeek-A for inquiry routing. "
|
||||
"You must call the function tool route_decision exactly once. "
|
||||
"Do not write user-facing text. Do not extract business fields."
|
||||
"你是询价路由 DeepSeek-A。必须且只能调用 route_decision 一次。"
|
||||
"不要对用户说话,不要抽取起运地/重量等询价字段。"
|
||||
"销售怎么说都行,不要求固定口令,也不要求出现「继续工单」这几个字。"
|
||||
"【续办】intent=continue_thread,action_type=CONTINUE_HISTORICAL_TICKET。"
|
||||
"只要原话是在找回、接着办、再看、再出、卡片没了再发一次、方案再发我、"
|
||||
"那个单还没完、只贴一张已有工单号要你处理那张单,都算续办。"
|
||||
"工单号形态是 WO 加 12 位数字,尽量填进 target.work_order_no;抽不出则填空字符串。"
|
||||
"【新询价】intent=ordinary_text_inquiry,action_type=ORDINARY_TEXT_INQUIRY。"
|
||||
"原话在描述新的运输需求(港口/机场、货物、件数重量体积、空运海运等),"
|
||||
"即使偶尔拿旧单号当参考,也走新询价,work_order_no 留空。"
|
||||
"【闲聊】intent=ordinary_text_other,action_type=ORDINARY_TEXT_OTHER。"
|
||||
"问候、谢谢、无关闲聊。点卡片按钮字(生成Excel/生成PDF/不采用等)也标闲聊。"
|
||||
"禁止因为销售没说指定口令就把续办判成新询价。"
|
||||
)
|
||||
|
||||
|
||||
@@ -88,6 +158,22 @@ class DeepSeekARouter:
|
||||
"""仅解析已有 tool 参数(单测 / stub / 回放)。"""
|
||||
return parse_ordered_actions_arguments(args).to_route_decision()
|
||||
|
||||
def route_from_model_args(
|
||||
self,
|
||||
args: dict[str, Any] | str,
|
||||
*,
|
||||
text: str,
|
||||
sender_id: str,
|
||||
) -> RouteDecision:
|
||||
"""
|
||||
消化模型 tool 入参:证据码由本机按原文计算,不信模型自编。
|
||||
|
||||
测服实测模型会乱填 content_digest,信它会导致整句 route_parse_failed。
|
||||
"""
|
||||
return self.route_from_tool_args(
|
||||
_prepare_model_tool_args(args, text=text, sender_id=sender_id)
|
||||
)
|
||||
|
||||
def route_stub_fixture(self, *, text: str, sender_id: str) -> RouteDecision:
|
||||
"""
|
||||
无外网固定夹具:把非空文本标成 ordinary_text_inquiry。
|
||||
@@ -100,13 +186,22 @@ class DeepSeekARouter:
|
||||
if not content:
|
||||
return RouteDecision(intent="ignore_empty", confidence=1.0)
|
||||
digest = _digest_text(content)
|
||||
# 无网夹具:正式路由仍走模型。此处只给单测一个「像续办」的可重复结论。
|
||||
wo = copy.extract_work_order_no(content)
|
||||
if wo and copy.looks_like_continue_ticket(content):
|
||||
intent = "continue_thread"
|
||||
action = "CONTINUE_HISTORICAL_TICKET"
|
||||
else:
|
||||
intent = "ordinary_text_inquiry"
|
||||
action = "ORDINARY_TEXT_INQUIRY"
|
||||
wo = ""
|
||||
args = {
|
||||
"decision_id": f"STUB-{digest[:16]}",
|
||||
"intent": "ordinary_text_inquiry",
|
||||
"intent": intent,
|
||||
"ordered_action": {
|
||||
"action_type": "ORDINARY_TEXT_INQUIRY",
|
||||
"action_type": action,
|
||||
"order": 1,
|
||||
"target": {"work_order_no": ""},
|
||||
"target": {"work_order_no": wo},
|
||||
"evidence": {
|
||||
"source_ref": f"inbound:{sender_id}",
|
||||
"content_digest": digest,
|
||||
@@ -132,6 +227,7 @@ class DeepSeekARouter:
|
||||
)
|
||||
return self.route_stub_fixture(text=text, sender_id=sender_id)
|
||||
|
||||
logger.info("DeepSeekA 真网路由 sender=%s chars=%s", sender_id, len(text or ""))
|
||||
messages = self.build_messages(text=text, sender_id=sender_id)
|
||||
tools = [ordered_actions_tool_definition()]
|
||||
result: LlmHttpResult = self.http.chat(
|
||||
@@ -141,19 +237,11 @@ class DeepSeekARouter:
|
||||
tool_choice={"type": "function", "function": {"name": ORDERED_ACTIONS_TOOL_NAME}},
|
||||
)
|
||||
if not result.ok:
|
||||
logger.warning("DeepSeek A HTTP 失败 err=%s", result.error)
|
||||
return RouteDecision(
|
||||
intent="route_model_failed",
|
||||
confidence=0.0,
|
||||
raw={"error": result.error, "provider": result.provider},
|
||||
)
|
||||
logger.warning("DeepSeek A HTTP 失败 err=%s,改用夹具兜底", result.error)
|
||||
return self.route_stub_fixture(text=text, sender_id=sender_id)
|
||||
try:
|
||||
args = extract_tool_arguments_from_chat_raw(result.raw)
|
||||
return self.route_from_tool_args(args)
|
||||
return self.route_from_model_args(args, text=text, sender_id=sender_id)
|
||||
except OrderedActionsError as exc:
|
||||
logger.warning("DeepSeek A 解析失败: %s", exc)
|
||||
return RouteDecision(
|
||||
intent="route_parse_failed",
|
||||
confidence=0.0,
|
||||
raw={"error": str(exc)},
|
||||
)
|
||||
logger.warning("DeepSeek A 解析失败: %s,改用夹具兜底", exc)
|
||||
return self.route_stub_fixture(text=text, sender_id=sender_id)
|
||||
|
||||
@@ -13,6 +13,7 @@ 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.text_inquiry import handle_text_inquiry, handle_text_other
|
||||
from agent.routing.decision import RouteDecision
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -27,11 +28,7 @@ def _noop(message: InboundMessage) -> None:
|
||||
|
||||
|
||||
def _wrap_echo(message: InboundMessage) -> None:
|
||||
"""
|
||||
联调占位:文字意图暂走 echo(正式应换成 new_inquiry / Graph)。
|
||||
|
||||
框架接线用;不含字段抽取。
|
||||
"""
|
||||
"""联调残留:仅 echo_text 意图使用。"""
|
||||
from agent.channel.queue import get_message_store
|
||||
from agent.config import get_settings
|
||||
|
||||
@@ -44,22 +41,38 @@ def _wrap_echo(message: InboundMessage) -> None:
|
||||
)
|
||||
|
||||
|
||||
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_continue(message: InboundMessage) -> None:
|
||||
continue_thread.handle_continue_thread(message, thread_id="")
|
||||
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:
|
||||
# 框架:卡片字段尚未从 payload 解析;空壳用默认
|
||||
"""
|
||||
卡片意图:把企微 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()
|
||||
card_action.handle_card_action(
|
||||
sender_id=message.sender_id,
|
||||
thread_id="",
|
||||
wait_version=0,
|
||||
action="unknown",
|
||||
payload={},
|
||||
action=action or "unknown",
|
||||
payload=dict(message.raw or {}),
|
||||
)
|
||||
|
||||
|
||||
@@ -75,11 +88,23 @@ 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_echo, # 联调:有回复;正式换业务 handler
|
||||
"ordinary_text_other": _wrap_echo,
|
||||
"ordinary_text_inquiry": _wrap_text_inquiry,
|
||||
"ordinary_text_other": _wrap_text_other,
|
||||
"attachment_inquiry": _wrap_new,
|
||||
"image_inquiry": _wrap_new,
|
||||
"multi_segment_transport": _wrap_new,
|
||||
@@ -92,6 +117,8 @@ HANDLER_REGISTRY: dict[str, HandlerFn] = {
|
||||
"reject_no_sender": _noop,
|
||||
"ignore_empty": _noop,
|
||||
"stub_needs_model": _wrap_echo,
|
||||
"route_model_failed": _wrap_route_failed,
|
||||
"route_parse_failed": _wrap_route_failed,
|
||||
}
|
||||
|
||||
|
||||
@@ -119,10 +146,14 @@ def dispatch_inbound(message: InboundMessage, decision: RouteDecision) -> str:
|
||||
return "unknown_intent"
|
||||
|
||||
logger.info(
|
||||
"dispatch intent=%s sender=%s msg=%s",
|
||||
"dispatch intent=%s sender=%s msg=%s wo=%s",
|
||||
decision.intent,
|
||||
message.sender_id,
|
||||
message.message_id,
|
||||
decision.thread_id or "-",
|
||||
)
|
||||
if decision.intent == "continue_thread":
|
||||
continue_thread.handle_continue_thread(message, thread_id=decision.thread_id)
|
||||
return decision.intent
|
||||
fn(message)
|
||||
return decision.intent
|
||||
|
||||
@@ -31,6 +31,7 @@ ACTION_INTENTS: dict[str, str] = {
|
||||
"TMS_READ_ONLY_QUOTE": "tms_read_only_quote",
|
||||
"ORDINARY_TEXT_INQUIRY": "ordinary_text_inquiry",
|
||||
"ORDINARY_TEXT_OTHER": "ordinary_text_other",
|
||||
"CONTINUE_HISTORICAL_TICKET": "continue_thread",
|
||||
}
|
||||
|
||||
|
||||
@@ -97,7 +98,11 @@ def ordered_actions_tool_definition() -> dict[str, Any]:
|
||||
"name": ORDERED_ACTIONS_TOOL_NAME,
|
||||
"description": (
|
||||
"Return exactly one frozen business route decision "
|
||||
"for the current inbound event."
|
||||
"for the current inbound event. "
|
||||
"Sales will speak naturally; do not require a command phrase. "
|
||||
"Resume any past ticket (any wording, or a bare WO+12-digit number) "
|
||||
"with CONTINUE_HISTORICAL_TICKET / continue_thread and fill "
|
||||
"target.work_order_no. New shipping demand uses ORDINARY_TEXT_INQUIRY."
|
||||
),
|
||||
"strict": True,
|
||||
"parameters": parameters,
|
||||
|
||||
Reference in New Issue
Block a user