落地销售调价:按自然语言改价并单独记销售价,拉群和后台不再把调后价当成 TMS 原价。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-09-20 14:40:20 +08:00
co-authored by Cursor
parent e4743541f7
commit 26dd5d72a1
36 changed files with 2795 additions and 176 deletions
+40 -5
View File
@@ -55,9 +55,23 @@ class IntentRouter:
"""
return self._a().route_stub_fixture(text=text, sender_id=sender_id)
def route(self, *, text: str, sender_id: str) -> RouteDecision:
"""一条入站一个 RouteDecision。"""
return self._a().route(text=text, sender_id=sender_id)
def route(
self,
*,
text: str,
sender_id: str,
session_quoted: bool = False,
fee_names: Optional[list[str]] = None,
work_order_no: str = "",
) -> RouteDecision:
"""一条入站一个 RouteDecision。已报价时把书签事实交给 A 认调价。"""
return self._a().route(
text=text,
sender_id=sender_id,
session_quoted=session_quoted,
fee_names=fee_names,
work_order_no=work_order_no,
)
def route_inbound(
self,
@@ -70,7 +84,7 @@ class IntentRouter:
入站后再路由。
点卡片是结构化事件,不进大模型。
自由文字交给 DeepSeek A 认意图(续办 / 新询价 / 闲聊),不要求固定口令。
自由文字交给 DeepSeek A 认意图(续办 / 新询价 / 调价 / 闲聊),不要求固定口令。
"""
if not sender_id:
return RouteDecision(intent="reject_no_sender", confidence=1.0)
@@ -92,7 +106,28 @@ class IntentRouter:
logger.info("route 陆运核对卡确定:跳过闲聊模型 sender=%s", sender_id)
return RouteDecision(intent="ordinary_text_inquiry", confidence=1.0)
logger.info("route 确定类短句但无陆运核对卡,仍走模型 sender=%s", sender_id)
return self.route(text=text, sender_id=sender_id)
quoted = False
names: list[str] = []
wo = ""
try:
from agent.policy.quote_adjust_ops import quoted_adjust_flow
from agent.schema.quote_adjust import quote_fee_rows
flow = quoted_adjust_flow(sender_id)
sess = flow.session_of(sender_id) if flow is not None else None
if sess is not None:
quoted = True
wo = str(getattr(sess, "work_order_no", "") or "")
names = [str(r.get("name") or "") for r in quote_fee_rows(getattr(sess, "quote", None))]
except Exception:
logger.exception("route 收集调价书签失败 sender=%s", sender_id)
return self.route(
text=text,
sender_id=sender_id,
session_quoted=quoted,
fee_names=names,
work_order_no=wo,
)
def enqueue_ordered_actions(
self,
@@ -17,6 +17,7 @@ from agent.routing.group_intent import (
INTENT_DROP_TMS,
INTENT_FIELDS,
INTENT_KEEP_TMS,
INTENT_ADJUST,
INTENT_LOST,
INTENT_NEGOTIATE,
INTENT_OTHER,
@@ -40,6 +41,7 @@ _VALID = {
INTENT_PICK_OPTION,
INTENT_FIELDS,
INTENT_QUOTE,
INTENT_ADJUST,
INTENT_KEEP_TMS,
INTENT_DROP_TMS,
INTENT_CONFIRM_TMS,
@@ -111,6 +113,10 @@ def classify_air_group_text(
except Exception:
logger.exception("空运群意图模型失败,回退规则")
from agent.llm.extract_text import extract_air_collab_fields, extract_product_quote
from agent.schema.quote_adjust import extract_adjust
if extract_adjust(raw).is_adjust:
return INTENT_ADJUST
if extract_product_quote(raw):
return INTENT_QUOTE
confirmed = _hint_confirm_tms(raw)
@@ -163,6 +169,7 @@ def _classify_with_model(text: str, settings) -> str:
"air_activate=只是指定/切换工单号;"
"air_pick_option=在选舱位选项编号;"
"collab_fields=在补是否含电/是否含磁;"
"quote_adjust=销售在调已有费用,口令不写死(上升、往上抬、加一成、改到某金额都算),不是新产品报价;"
"product_quote=航线在报价格或费用(含具体金额);"
"quote_confirm_tms=确认沿用当前TMS报价;"
"quote_keep_tms=要在已有TMS报价上改;"
@@ -171,7 +178,7 @@ def _classify_with_model(text: str, settings) -> str:
"deal_negotiate=销售要继续协商;"
"other=闲聊。"
"一句话里既有锁舱又有工单号,回 air_lock。"
"有金额就是 product_quote。"
"改已有费用优先 quote_adjust;新报价格才是 product_quote。"
)
data = client.chat(
mode=LlmMode.ORDERED_ACTIONS,
+49 -14
View File
@@ -67,6 +67,9 @@ def _prepare_model_tool_args(
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"
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:
@@ -98,9 +101,13 @@ _SYSTEM = (
"原话在描述新的运输需求(港口/机场、货物、件数重量体积、空运海运陆运等),"
"即使偶尔拿旧单号当参考,也走新询价,work_order_no 留空。"
"陆运核对卡之后回复确定、确认、查价、可以查了,仍是新询价,禁止标闲聊。"
"【调价】intent=quote_adjust,action_type=QUOTE_ADJUST。"
"inbound_facts 里 session_quoted=true 时,销售用自然语言改当前已有报价:"
"涨、跌、加、减、上升、上浮、往上抬、改到某金额、在现有报价基础上调整,口令不写死。"
"只说报价/总价、没点名费用,仍是调价,不是闲聊也不是新询价。"
"【闲聊】intent=ordinary_text_other,action_type=ORDINARY_TEXT_OTHER。"
"问候、谢谢、无关闲聊。点卡片按钮字(生成Excel/生成PDF/不采用等)也标闲聊。"
"禁止因为销售没说指定口令就把续办判成新询价。"
"禁止因为销售没说指定口令就把续办或调价判成新询价或闲聊。"
)
@@ -134,22 +141,29 @@ class DeepSeekARouter:
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 ""),
}
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={facts['sender_id']}\n"
f"text_sha256={facts['text_sha256']}\n"
f"text={facts['text']}\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."
),
},
@@ -212,12 +226,21 @@ class DeepSeekARouter:
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:
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)
@@ -228,8 +251,20 @@ 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)
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,
+5
View File
@@ -123,6 +123,7 @@ HANDLER_REGISTRY: dict[str, HandlerFn] = {
"echo_text": _wrap_echo,
"ordinary_text_inquiry": _wrap_text_inquiry,
"ordinary_text_other": _wrap_text_other,
"quote_adjust": _wrap_text_inquiry,
"attachment_inquiry": _wrap_attachment,
"image_inquiry": _wrap_image,
"multi_segment_transport": _wrap_new,
@@ -262,6 +263,10 @@ def dispatch_inbound(message: InboundMessage, decision: RouteDecision) -> str:
logger.info("dispatch 陆运核对卡确定:纠正闲聊路由 sender=%s", message.sender_id)
handle_text_inquiry(message)
return "ordinary_text_inquiry"
if decision.intent == "quote_adjust":
logger.info("dispatch 调价意图 sender=%s", message.sender_id)
handle_text_inquiry(message, prefer_quoted=True)
return "quote_adjust"
if decision.intent == "continue_thread":
continue_thread.handle_continue_thread(message, thread_id=decision.thread_id)
return decision.intent
@@ -16,6 +16,7 @@ logger = logging.getLogger(__name__)
INTENT_FIELDS = "collab_fields"
INTENT_QUOTE = "product_quote"
INTENT_ADJUST = "quote_adjust"
INTENT_DISSOLVE = "dissolve"
INTENT_KEEP_TMS = "quote_keep_tms"
INTENT_DROP_TMS = "quote_drop_tms"
@@ -28,6 +29,7 @@ INTENT_OTHER = "other"
_VALID = {
INTENT_FIELDS,
INTENT_QUOTE,
INTENT_ADJUST,
INTENT_DISSOLVE,
INTENT_KEEP_TMS,
INTENT_DROP_TMS,
@@ -161,7 +163,10 @@ def classify_group_text(
except Exception:
logger.exception("群意图模型失败,回退标签字段")
from agent.llm.extract_text import extract_product_quote
from agent.schema.quote_adjust import extract_adjust
if extract_adjust(raw).is_adjust:
return INTENT_ADJUST
if extract_product_quote(raw):
return INTENT_QUOTE
confirmed = _hint_confirm_tms(raw)
@@ -227,6 +232,7 @@ def _classify_with_model(text: str, settings) -> str:
system = (
"你是海运协同群意图分类。只回复一个英文词,不要解释。"
"collab_fields=在补贸易条款/货好时间/商品海关编码/含油含电含磁;"
"quote_adjust=销售在调已有费用,口令不写死(上升、上浮、往上抬、加一成、减、改到某金额都算),不是新产品报价;"
"product_quote=产品在报价格或费用(含具体金额);"
"quote_confirm_tms=产品确认沿用当前TMS报价,没有报新金额;"
"quote_keep_tms=要在已有TMS报价上改,没有报新金额;"
@@ -248,6 +254,7 @@ def _classify_with_model(text: str, settings) -> str:
)
content = str(getattr(data, "content", "") or "").strip().lower()
for token in (
INTENT_ADJUST,
INTENT_QUOTE,
INTENT_CONFIRM_TMS,
INTENT_KEEP_TMS,
@@ -32,6 +32,7 @@ ACTION_INTENTS: dict[str, str] = {
"ORDINARY_TEXT_INQUIRY": "ordinary_text_inquiry",
"ORDINARY_TEXT_OTHER": "ordinary_text_other",
"CONTINUE_HISTORICAL_TICKET": "continue_thread",
"QUOTE_ADJUST": "quote_adjust",
}
@@ -102,7 +103,8 @@ def ordered_actions_tool_definition() -> dict[str, Any]:
"Sales will speak naturally; do not require a command phrase. "
"Resume any past ticket (any wording, or a bare WO+12-digit number) "
"with CONTINUE_HISTORICAL_TICKET / continue_thread and fill "
"target.work_order_no. New shipping demand uses ORDINARY_TEXT_INQUIRY."
"target.work_order_no. New shipping demand uses ORDINARY_TEXT_INQUIRY. "
"Adjusting an existing quoted price (any wording) uses QUOTE_ADJUST."
),
"strict": True,
"parameters": parameters,