291 lines
12 KiB
Python
291 lines
12 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.policy import inquiry_copy as copy
|
||
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__)
|
||
|
||
|
||
def _prepare_model_tool_args(
|
||
args: dict[str, Any] | str,
|
||
*,
|
||
text: str,
|
||
sender_id: str,
|
||
) -> dict[str, Any]:
|
||
"""
|
||
把模型 tool 参数收成可过合同的形态。
|
||
|
||
证据码、来源、order 由本机补;续办时工单号可从原话回填。
|
||
"""
|
||
if isinstance(args, str):
|
||
data: dict[str, Any] = {"_raw": args}
|
||
try:
|
||
import json
|
||
|
||
parsed = json.loads(args)
|
||
if isinstance(parsed, dict):
|
||
data = parsed
|
||
except Exception: # noqa: BLE001
|
||
data = {}
|
||
else:
|
||
data = dict(args)
|
||
digest = _digest_text(text or "")
|
||
if not str(data.get("decision_id") or "").strip():
|
||
data["decision_id"] = f"DS-{digest[:16]}"
|
||
action = data.get("ordered_action")
|
||
if action is None and isinstance(data.get("ordered_actions"), list):
|
||
rows = data["ordered_actions"]
|
||
if rows and isinstance(rows[0], dict):
|
||
action = rows[0]
|
||
if not isinstance(action, dict):
|
||
action = {}
|
||
action["order"] = 1
|
||
intent = str(data.get("intent") or "").strip()
|
||
action_type = str(action.get("action_type") or "").strip()
|
||
if intent == "continue_thread" or action_type == "CONTINUE_HISTORICAL_TICKET":
|
||
data["intent"] = "continue_thread"
|
||
action["action_type"] = "CONTINUE_HISTORICAL_TICKET"
|
||
if intent == "quote_adjust" or action_type == "QUOTE_ADJUST":
|
||
data["intent"] = "quote_adjust"
|
||
action["action_type"] = "QUOTE_ADJUST"
|
||
if intent == "handoff_human" or action_type == "HANDOFF_HUMAN":
|
||
data["intent"] = "handoff_human"
|
||
action["action_type"] = "HANDOFF_HUMAN"
|
||
target = action.get("target") if isinstance(action.get("target"), dict) else {}
|
||
wo = str(target.get("work_order_no") or "").strip()
|
||
if data.get("intent") == "continue_thread" and not wo:
|
||
wo = copy.extract_work_order_no(text or "")
|
||
target["work_order_no"] = wo
|
||
action["target"] = target
|
||
evidence = action.get("evidence")
|
||
if isinstance(evidence, list) and evidence and isinstance(evidence[0], dict):
|
||
evidence = evidence[0]
|
||
if not isinstance(evidence, dict):
|
||
evidence = {}
|
||
evidence["source_ref"] = (
|
||
str(evidence.get("source_ref") or "").strip() or f"inbound:{sender_id}"
|
||
)
|
||
evidence["content_digest"] = digest
|
||
action["evidence"] = evidence
|
||
data["ordered_action"] = action
|
||
return data
|
||
|
||
_SYSTEM = (
|
||
"你是询价路由 DeepSeek-A。必须且只能调用 route_decision 一次。"
|
||
"不要对用户说话,不要抽取起运地/重量等询价字段。"
|
||
"销售怎么说都行,不要求固定口令,也不要求出现「继续工单」这几个字。"
|
||
"【续办】intent=continue_thread,action_type=CONTINUE_HISTORICAL_TICKET。"
|
||
"只要原话是在找回、接着办、再看、再出、卡片没了再发一次、方案再发我、"
|
||
"那个单还没完、只贴一张已有工单号要你处理那张单,都算续办。"
|
||
"工单号形态是 WO 加 12 位数字,尽量填进 target.work_order_no;抽不出则填空字符串。"
|
||
"【新询价】intent=ordinary_text_inquiry,action_type=ORDINARY_TEXT_INQUIRY。"
|
||
"原话在描述新的运输需求(港口/机场、货物、件数重量体积、空运海运陆运等),"
|
||
"即使偶尔拿旧单号当参考,也走新询价,work_order_no 留空。"
|
||
"陆运或海运核对卡之后回复确定、确认、查价、可以查了,仍是新询价,禁止标闲聊。"
|
||
"机器人正在补问缺项时,销售只回件数、品名、重量、体积等短答(如 111、3件),仍是新询价续填,禁止标闲聊。"
|
||
"【调价】intent=quote_adjust,action_type=QUOTE_ADJUST。"
|
||
"inbound_facts 里 session_quoted=true 时,销售用自然语言改当前已有报价:"
|
||
"涨、跌、加、减、上升、上浮、往上抬、改到某金额、在现有报价基础上调整,口令不写死。"
|
||
"只说报价/总价、没点名费用,仍是调价,不是闲聊也不是新询价。"
|
||
"【转人工】intent=handoff_human,action_type=HANDOFF_HUMAN。"
|
||
"销售明确要求人来报价或接管:转人工、人工报价、找产品/航线、别自动查了、不要机器人了。"
|
||
"口令不写死。抱怨慢、骂两句、只问进度不算,那些仍走新询价或闲聊。"
|
||
"【闲聊】intent=ordinary_text_other,action_type=ORDINARY_TEXT_OTHER。"
|
||
"问候、谢谢、无关闲聊。点卡片按钮字(生成Excel/生成PDF/不采用等)也标闲聊。"
|
||
"禁止因为销售没说指定口令就把续办或调价判成新询价或闲聊。"
|
||
)
|
||
|
||
|
||
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,
|
||
session_quoted: bool = False,
|
||
fee_names: Optional[list[str]] = None,
|
||
work_order_no: str = "",
|
||
) -> list[dict[str, Any]]:
|
||
"""构造仅含 inbound_facts 的 messages(不做字段抽取、不算价)。"""
|
||
names = "、".join(str(x).strip() for x in (fee_names or []) if str(x).strip())
|
||
return [
|
||
{"role": "system", "content": _SYSTEM},
|
||
{
|
||
"role": "user",
|
||
"content": (
|
||
"inbound_facts (JSON-like):\n"
|
||
f"sender_id={sender_id}\n"
|
||
f"text_sha256={_digest_text(text or '')}\n"
|
||
f"text={text}\n"
|
||
f"session_quoted={'true' if session_quoted else 'false'}\n"
|
||
f"work_order_no={work_order_no or '-'}\n"
|
||
f"fee_names={names or '-'}\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_from_model_args(
|
||
self,
|
||
args: dict[str, Any] | str,
|
||
*,
|
||
text: str,
|
||
sender_id: str,
|
||
) -> RouteDecision:
|
||
"""
|
||
消化模型 tool 入参:证据码由本机按原文计算,不信模型自编。
|
||
|
||
测服实测模型会乱填 content_digest,信它会导致整句 route_parse_failed。
|
||
"""
|
||
return self.route_from_tool_args(
|
||
_prepare_model_tool_args(args, text=text, sender_id=sender_id)
|
||
)
|
||
|
||
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)
|
||
# 无网夹具:正式路由仍走模型。此处只给单测一个「像续办」的可重复结论。
|
||
wo = copy.extract_work_order_no(content)
|
||
if wo and copy.looks_like_continue_ticket(content):
|
||
intent = "continue_thread"
|
||
action = "CONTINUE_HISTORICAL_TICKET"
|
||
else:
|
||
intent = "ordinary_text_inquiry"
|
||
action = "ORDINARY_TEXT_INQUIRY"
|
||
wo = ""
|
||
args = {
|
||
"decision_id": f"STUB-{digest[:16]}",
|
||
"intent": intent,
|
||
"ordered_action": {
|
||
"action_type": action,
|
||
"order": 1,
|
||
"target": {"work_order_no": wo},
|
||
"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,
|
||
session_quoted: bool = False,
|
||
fee_names: Optional[list[str]] = None,
|
||
work_order_no: str = "",
|
||
) -> RouteDecision:
|
||
"""
|
||
执行 A 路由。
|
||
|
||
无 sender_id → reject;未开网络或未确认数据用途 → stub fixture;
|
||
开网络 → tool_choice 强制 route_decision。
|
||
session_quoted 告诉模型当前私聊已有可调报价,口令不写死。
|
||
"""
|
||
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)
|
||
|
||
logger.info(
|
||
"DeepSeekA 真网路由 sender=%s chars=%s quoted=%s wo=%s",
|
||
sender_id,
|
||
len(text or ""),
|
||
session_quoted,
|
||
work_order_no or "-",
|
||
)
|
||
messages = self.build_messages(
|
||
text=text,
|
||
sender_id=sender_id,
|
||
session_quoted=session_quoted,
|
||
fee_names=fee_names,
|
||
work_order_no=work_order_no,
|
||
)
|
||
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 self.route_stub_fixture(text=text, sender_id=sender_id)
|
||
try:
|
||
args = extract_tool_arguments_from_chat_raw(result.raw)
|
||
return self.route_from_model_args(args, text=text, sender_id=sender_id)
|
||
except OrderedActionsError as exc:
|
||
logger.warning("DeepSeek A 解析失败: %s,改用夹具兜底", exc)
|
||
return self.route_stub_fixture(text=text, sender_id=sender_id)
|