Files
inquiry_robot/inquiry-agent/agent/routing/ordered_actions.py
T

260 lines
9.1 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
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}")