落地私聊空运询价与TMS报价回填,按后台模板出单。

查到价后把币种、单位、数量、单价、备注写入报价单,避免销售只看到光秃秃的金额。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-09-14 20:25:35 +08:00
co-authored by Cursor
parent cea3a6e51e
commit 99da1faf30
78 changed files with 8838 additions and 321 deletions
+107 -19
View File
@@ -16,6 +16,7 @@ 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,
@@ -26,10 +27,79 @@ from agent.routing.ordered_actions import (
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"
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 = (
"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."
"你是询价路由 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 留空。"
"【闲聊】intent=ordinary_text_other,action_type=ORDINARY_TEXT_OTHER。"
"问候、谢谢、无关闲聊。点卡片按钮字(生成Excel/生成PDF/不采用等)也标闲聊。"
"禁止因为销售没说指定口令就把续办判成新询价。"
)
@@ -88,6 +158,22 @@ class DeepSeekARouter:
"""仅解析已有 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。
@@ -100,13 +186,22 @@ class DeepSeekARouter:
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": "ordinary_text_inquiry",
"intent": intent,
"ordered_action": {
"action_type": "ORDINARY_TEXT_INQUIRY",
"action_type": action,
"order": 1,
"target": {"work_order_no": ""},
"target": {"work_order_no": wo},
"evidence": {
"source_ref": f"inbound:{sender_id}",
"content_digest": digest,
@@ -132,6 +227,7 @@ class DeepSeekARouter:
)
return self.route_stub_fixture(text=text, sender_id=sender_id)
logger.info("DeepSeekA 真网路由 sender=%s chars=%s", sender_id, len(text or ""))
messages = self.build_messages(text=text, sender_id=sender_id)
tools = [ordered_actions_tool_definition()]
result: LlmHttpResult = self.http.chat(
@@ -141,19 +237,11 @@ class DeepSeekARouter:
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},
)
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_tool_args(args)
return self.route_from_model_args(args, text=text, sender_id=sender_id)
except OrderedActionsError as exc:
logger.warning("DeepSeek A 解析失败: %s", exc)
return RouteDecision(
intent="route_parse_failed",
confidence=0.0,
raw={"error": str(exc)},
)
logger.warning("DeepSeek A 解析失败: %s,改用夹具兜底", exc)
return self.route_stub_fixture(text=text, sender_id=sender_id)