""" 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}")