260 lines
9.1 KiB
Python
260 lines
9.1 KiB
Python
"""
|
||
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",
|
||
"CONTINUE_HISTORICAL_TICKET": "continue_thread",
|
||
"QUOTE_ADJUST": "quote_adjust",
|
||
"HANDOFF_HUMAN": "handoff_human",
|
||
}
|
||
|
||
|
||
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. "
|
||
"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. "
|
||
"Adjusting an existing quoted price (any wording) uses QUOTE_ADJUST."
|
||
),
|
||
"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}")
|