落地并存测服全端:智能体 Graph/路由、后台切 8180、主账与部署脚本齐备,便于线上重发与验收。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
linmianxun
2026-09-13 21:15:58 +08:00
co-authored by Cursor
parent 0eb1adecc8
commit 8103b3b651
143 changed files with 8968 additions and 277 deletions
+93 -1
View File
@@ -2,5 +2,97 @@
意图路由:DeepSeek A → 唯一 RouteDecision。
禁止:在本包产生业务副作用(建单、发消息、改六态)。
骨架:仅占位。
正式路径:DeepSeekARouter(strict tool);route_stub 仅兼容旧自检名。
"""
from __future__ import annotations
import logging
from dataclasses import dataclass
from typing import Optional
from agent.llm import LlmGateway, LlmMode
from agent.routing.decision import RouteDecision
from agent.routing.deepseek_a import DeepSeekARouter
from agent.routing.dispatch import HANDLER_REGISTRY, dispatch_inbound, resolve_handler
from agent.routing.ordered_actions import (
ORDERED_ACTIONS_TOOL_NAME,
OrderedActionsError,
OrderedActionsResult,
ordered_actions_tool_definition,
parse_ordered_actions_arguments,
)
# IntentRouter 定义见下
logger = logging.getLogger(__name__)
@dataclass
class IntentRouter:
"""
路由门面。
- route():优先 DeepSeek A(默认 stub,不开费)
- enqueue_ordered_actions():快回路径入 LLM Stream
"""
llm: Optional[LlmGateway] = None
deepseek_a: Optional[DeepSeekARouter] = None
allow_network: bool = False
def _a(self) -> DeepSeekARouter:
if self.deepseek_a is not None:
return self.deepseek_a
return DeepSeekARouter.from_settings(allow_network=self.allow_network)
def route_stub(self, *, text: str, sender_id: str) -> RouteDecision:
"""
兼容旧名:等价于无网络的 A stub fixture。
正式生产请用 route() 并在 Worker 开 allow_network + 数据确认。
"""
return self._a().route_stub_fixture(text=text, sender_id=sender_id)
def route(self, *, text: str, sender_id: str) -> RouteDecision:
"""一条入站一个 RouteDecision。"""
return self._a().route(text=text, sender_id=sender_id)
def enqueue_ordered_actions(
self,
*,
text: str,
sender_id: str,
idempotency_key: str,
) -> str:
"""
将 A 路由任务入 LLM 队列(快回路径用)。
返回 stream entry id;无 LlmGateway 时抛错。
"""
if self.llm is None:
raise RuntimeError("IntentRouter 未注入 LlmGateway")
result = self.llm.enqueue(
mode=LlmMode.ORDERED_ACTIONS,
payload={"text": text, "sender_id": sender_id},
idempotency_key=idempotency_key,
)
if not result.accepted:
raise RuntimeError(result.reason or "enqueue_rejected")
return result.entry_id
__all__ = [
"RouteDecision",
"IntentRouter",
"DeepSeekARouter",
"ORDERED_ACTIONS_TOOL_NAME",
"OrderedActionsError",
"OrderedActionsResult",
"ordered_actions_tool_definition",
"parse_ordered_actions_arguments",
"HANDLER_REGISTRY",
"dispatch_inbound",
"resolve_handler",
]
+25
View File
@@ -0,0 +1,25 @@
"""
RouteDecision 值对象。
本文件只放冻结路由结论;解析逻辑见 ordered_actions.py;副作用在 handlers。
"""
from __future__ import annotations
from dataclasses import dataclass, field
from typing import Any
@dataclass(frozen=True)
class RouteDecision:
"""
唯一路由结论(一条入站对应一个)。
intent:如 ordinary_text_inquiry / attachment_inquiry / reject_no_sender
thread_id:继续本单时填工单号;新开可空由 Handler 生成。
"""
intent: str
thread_id: str = ""
confidence: float = 0.0
raw: dict[str, Any] = field(default_factory=dict)
+159
View File
@@ -0,0 +1,159 @@
"""
DeepSeek A 调用壳:组装 messages + route_decision tool,解析为 RouteDecision。
本文件职责:Worker 槽内跑 A 路由;默认不打外网(需 LLM_DATA_USAGE_CONFIRMED)。
禁止:在企微回调线程同步调用本模块的网络路径。
"""
from __future__ import annotations
import hashlib
import logging
from dataclasses import dataclass
from typing import Any, Optional
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.routing.ordered_actions import (
ORDERED_ACTIONS_TOOL_NAME,
OrderedActionsError,
extract_tool_arguments_from_chat_raw,
ordered_actions_tool_definition,
parse_ordered_actions_arguments,
)
logger = logging.getLogger(__name__)
_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."
)
def _digest_text(text: str) -> str:
return hashlib.sha256(text.encode("utf-8")).hexdigest()
@dataclass
class DeepSeekARouter:
"""
DeepSeek A 路由器。
allow_network:是否允许真 HTTP;仍受 settings.llm_data_usage_confirmed 约束。
"""
settings: Settings
http: LlmHttpClient
allow_network: bool = False
@classmethod
def from_settings(
cls,
settings: Optional[Settings] = None,
*,
allow_network: bool = False,
) -> "DeepSeekARouter":
cfg = settings or get_settings()
return cls(
settings=cfg,
http=LlmHttpClient.from_settings(cfg),
allow_network=allow_network,
)
def build_messages(self, *, text: str, sender_id: str) -> list[dict[str, Any]]:
"""构造仅含 inbound_facts 的 messages(不做字段抽取)。"""
facts = {
"sender_id": sender_id,
"text": text,
"text_sha256": _digest_text(text or ""),
}
return [
{"role": "system", "content": _SYSTEM},
{
"role": "user",
"content": (
"inbound_facts (JSON-like):\n"
f"sender_id={facts['sender_id']}\n"
f"text_sha256={facts['text_sha256']}\n"
f"text={facts['text']}\n"
"Call route_decision once."
),
},
]
def route_from_tool_args(self, args: dict[str, Any] | str) -> RouteDecision:
"""仅解析已有 tool 参数(单测 / stub / 回放)。"""
return parse_ordered_actions_arguments(args).to_route_decision()
def route_stub_fixture(self, *, text: str, sender_id: str) -> RouteDecision:
"""
无外网固定夹具:把非空文本标成 ordinary_text_inquiry。
仅骨架联调;正式生产必须走模型或已落库的 tool 回放。
"""
if not sender_id:
return RouteDecision(intent="reject_no_sender", confidence=1.0)
content = (text or "").strip()
if not content:
return RouteDecision(intent="ignore_empty", confidence=1.0)
digest = _digest_text(content)
args = {
"decision_id": f"STUB-{digest[:16]}",
"intent": "ordinary_text_inquiry",
"ordered_action": {
"action_type": "ORDINARY_TEXT_INQUIRY",
"order": 1,
"target": {"work_order_no": ""},
"evidence": {
"source_ref": f"inbound:{sender_id}",
"content_digest": digest,
},
},
}
logger.info("DeepSeekA stub fixture sender=%s", sender_id)
return self.route_from_tool_args(args)
def route(self, *, text: str, sender_id: str) -> RouteDecision:
"""
执行 A 路由。
无 sender_id → reject;未开网络或未确认数据用途 → stub fixture;
开网络 → tool_choice 强制 route_decision。
"""
if not sender_id:
return RouteDecision(intent="reject_no_sender", confidence=1.0)
if not self.allow_network or not self.settings.llm_data_usage_confirmed:
if self.allow_network and not self.settings.llm_data_usage_confirmed:
logger.warning(
"LLM_DATA_USAGE_CONFIRMED 未确认,拒绝打真实 DeepSeek A,改用 stub"
)
return self.route_stub_fixture(text=text, sender_id=sender_id)
messages = self.build_messages(text=text, sender_id=sender_id)
tools = [ordered_actions_tool_definition()]
result: LlmHttpResult = self.http.chat(
mode=LlmMode.ORDERED_ACTIONS,
messages=messages,
tools=tools,
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},
)
try:
args = extract_tool_arguments_from_chat_raw(result.raw)
return self.route_from_tool_args(args)
except OrderedActionsError as exc:
logger.warning("DeepSeek A 解析失败: %s", exc)
return RouteDecision(
intent="route_parse_failed",
confidence=0.0,
raw={"error": str(exc)},
)
+128
View File
@@ -0,0 +1,128 @@
"""
入站分发壳: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.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(正式应换成 new_inquiry / Graph)。
框架接线用;不含字段抽取。
"""
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_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_card(message: InboundMessage) -> None:
# 框架:卡片字段尚未从 payload 解析;空壳用默认
card_action.handle_card_action(
sender_id=message.sender_id,
thread_id="",
wait_version=0,
action="unknown",
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")
# 意图 → handler(空壳或联调 echo);扩展时只改表,不改 ChannelRuntime 分支森林
HANDLER_REGISTRY: dict[str, HandlerFn] = {
"echo_text": _wrap_echo,
"ordinary_text_inquiry": _wrap_echo, # 联调:有回复;正式换业务 handler
"ordinary_text_other": _wrap_echo,
"attachment_inquiry": _wrap_new,
"image_inquiry": _wrap_new,
"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,
}
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 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"
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",
decision.intent,
message.sender_id,
message.message_id,
)
fn(message)
return decision.intent
@@ -0,0 +1,251 @@
"""
DeepSeek A:`ordered_actions` / `route_decision` 工具合同与解析。
本文件职责:
- 给出唯一 strict tool schema(对齐现网 framework_route_contract 垂直切片);
- 把模型 tool_call 参数校验并投影为 RouteDecision;
- 不产生业务副作用(不建单、不发消息、不改六态)。
为何独立成文件:schema 与 IntentRouter / HTTP 客户端解耦,便于单测与合同演进。
"""
from __future__ import annotations
import json
import logging
import unicodedata
from dataclasses import dataclass, field
from typing import Any, Mapping, Optional
from agent.routing.decision import RouteDecision
logger = logging.getLogger(__name__)
ORDERED_ACTIONS_TOOL_NAME = "route_decision"
# action_type(模型枚举)→ 业务意图(RouteDecision.intent)
ACTION_INTENTS: dict[str, str] = {
"ATTACHMENT_DOCUMENT_INQUIRY": "attachment_inquiry",
"ATTACHMENT_IMAGE_INQUIRY": "image_inquiry",
"MULTI_SEGMENT_TRANSPORT": "multi_segment_transport",
"TMS_READ_ONLY_QUOTE": "tms_read_only_quote",
"ORDINARY_TEXT_INQUIRY": "ordinary_text_inquiry",
"ORDINARY_TEXT_OTHER": "ordinary_text_other",
}
class OrderedActionsError(ValueError):
"""ordered_actions 合同被违反(缺键、枚举不符、证据非法等)。"""
def _nfc(value: str) -> str:
"""非空 NFC 规范化;首尾空白视为非法(与现网一致)。"""
result = unicodedata.normalize("NFC", str(value))
if not result or result != result.strip():
raise OrderedActionsError("字符串须为非空 NFC 且无首尾空白")
return result
def ordered_actions_tool_definition() -> dict[str, Any]:
"""
返回 DeepSeek A 唯一 strict tool。
模型必须且只能调用本 tool;不得输出用户可见正文。
"""
action_types = sorted(ACTION_INTENTS)
intents = sorted(set(ACTION_INTENTS.values()))
target_schema = {
"type": "object",
"additionalProperties": False,
"properties": {"work_order_no": {"type": "string"}},
"required": ["work_order_no"],
}
evidence_schema = {
"type": "object",
"additionalProperties": False,
"properties": {
"source_ref": {"type": "string"},
"content_digest": {"type": "string"},
},
"required": ["source_ref", "content_digest"],
}
action_schema = {
"type": "object",
"additionalProperties": False,
"properties": {
"action_type": {"type": "string", "enum": action_types},
"order": {"type": "integer", "const": 1},
"target": target_schema,
"evidence": evidence_schema,
},
"required": ["action_type", "order", "target", "evidence"],
}
parameters = {
"type": "object",
"additionalProperties": False,
"properties": {
"decision_id": {"type": "string"},
"intent": {"type": "string", "enum": intents},
# 工具入参用单数;解析后统一成 ordered_actions 列表
"ordered_action": action_schema,
},
"required": ["decision_id", "intent", "ordered_action"],
}
return {
"type": "function",
"function": {
"name": ORDERED_ACTIONS_TOOL_NAME,
"description": (
"Return exactly one frozen business route decision "
"for the current inbound event."
),
"strict": True,
"parameters": parameters,
},
}
@dataclass(frozen=True)
class OrderedActionsResult:
"""校验后的 A 路由冻结结果(一条入站一条)。"""
decision_id: str
intent: str
action_type: str
work_order_no: str
evidence_source_ref: str
evidence_content_digest: str
raw: dict[str, Any] = field(default_factory=dict)
def to_route_decision(self, *, confidence: float = 1.0) -> RouteDecision:
"""投影为唯一 RouteDecision(仍无副作用)。"""
return RouteDecision(
intent=self.intent,
thread_id=self.work_order_no or "",
confidence=confidence,
raw={
"decision_id": self.decision_id,
"action_type": self.action_type,
"ordered_actions": self.raw,
"source": "deepseek_a",
},
)
def _coerce_tool_args(value: Any) -> dict[str, Any]:
"""接受 dict 或 JSON 字符串。"""
if isinstance(value, dict):
return value
if isinstance(value, str):
try:
data = json.loads(value)
except json.JSONDecodeError as exc:
raise OrderedActionsError("tool arguments 不是合法 JSON") from exc
if not isinstance(data, dict):
raise OrderedActionsError("tool arguments 必须是对象")
return data
raise OrderedActionsError("tool arguments 类型非法")
def parse_ordered_actions_arguments(
value: Mapping[str, Any] | str | dict[str, Any],
) -> OrderedActionsResult:
"""
解析并校验 route_decision tool 参数。
副作用:无。失败抛 OrderedActionsError。
"""
data = _coerce_tool_args(value)
decision_id = _nfc(str(data.get("decision_id") or ""))
intent = _nfc(str(data.get("intent") or ""))
action = data.get("ordered_action")
if action is None and isinstance(data.get("ordered_actions"), list):
actions = data["ordered_actions"]
if len(actions) != 1 or not isinstance(actions[0], dict):
raise OrderedActionsError("ordered_actions 必须恰好一条")
action = actions[0]
if not isinstance(action, dict):
raise OrderedActionsError("缺少 ordered_action")
action_type = _nfc(str(action.get("action_type") or ""))
if action_type not in ACTION_INTENTS:
raise OrderedActionsError(f"未知 action_type: {action_type}")
if ACTION_INTENTS[action_type] != intent:
raise OrderedActionsError("action_type 与 intent 不一致")
if action.get("order") != 1:
raise OrderedActionsError("order 必须为 1(当前垂直切片)")
target = action.get("target")
if not isinstance(target, dict):
raise OrderedActionsError("target 必须为对象")
work_order_no = str(target.get("work_order_no") or "").strip()
# 允许空工单号(新询价);若有值则 NFC
if work_order_no:
work_order_no = _nfc(work_order_no)
evidence = action.get("evidence")
# 工具 schema 是单对象;兼容列表写法
if isinstance(evidence, list):
if len(evidence) != 1 or not isinstance(evidence[0], dict):
raise OrderedActionsError("evidence 须恰好一条")
evidence = evidence[0]
if not isinstance(evidence, dict):
raise OrderedActionsError("evidence 必须为对象")
source_ref = _nfc(str(evidence.get("source_ref") or ""))
digest = str(evidence.get("content_digest") or "").lower()
if len(digest) != 64 or any(c not in "0123456789abcdef" for c in digest):
raise OrderedActionsError("content_digest 必须是 64 位小写 hex")
frozen = {
"decision_id": decision_id,
"intent": intent,
"ordered_actions": [
{
"action_type": action_type,
"order": 1,
"target": {"work_order_no": work_order_no or None},
"evidence": [
{"source_ref": source_ref, "content_digest": digest},
],
}
],
}
logger.info(
"ordered_actions 已校验 decision_id=%s intent=%s action=%s",
decision_id,
intent,
action_type,
)
return OrderedActionsResult(
decision_id=decision_id,
intent=intent,
action_type=action_type,
work_order_no=work_order_no,
evidence_source_ref=source_ref,
evidence_content_digest=digest,
raw=frozen,
)
def extract_tool_arguments_from_chat_raw(
raw: Optional[Mapping[str, Any]],
*,
tool_name: str = ORDERED_ACTIONS_TOOL_NAME,
) -> dict[str, Any]:
"""
从 OpenAI 兼容 chat 响应中取出指定 tool 的 arguments。
未找到则抛 OrderedActionsError。
"""
if not raw:
raise OrderedActionsError("空模型响应")
choices = raw.get("choices") or []
if not choices:
raise OrderedActionsError("无 choices")
message = (choices[0] or {}).get("message") or {}
tool_calls = message.get("tool_calls") or []
for call in tool_calls:
fn = (call or {}).get("function") or {}
if str(fn.get("name") or "") == tool_name:
return _coerce_tool_args(fn.get("arguments"))
raise OrderedActionsError(f"未调用 tool: {tool_name}")