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