落地并存测服全端:智能体 Graph/路由、后台切 8180、主账与部署脚本齐备,便于线上重发与验收。
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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}")
|
||||
Reference in New Issue
Block a user