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

291 lines
12 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 调用壳:组装 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)