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