160 lines
5.6 KiB
Python
160 lines
5.6 KiB
Python
"""
|
||
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)},
|
||
)
|