fix: 已出号后新一轮询价不带上一单字段

点名海运时丢掉旧陆运补问草稿,避免补字段误发填写样例图。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-09-20 18:27:13 +08:00
co-authored by Cursor
parent b3b3cf2903
commit 19acbb9156
8 changed files with 256 additions and 9 deletions
+55 -7
View File
@@ -36,6 +36,44 @@ def _front_bookmark(sess) -> bool:
return phase in {"clarify", "need_mode", "need_land_options", "wait_confirm"}
def _drop_other_mode_drafts(*, owner: str, sender_id: str) -> None:
"""
本句已点名空运/海运/陆运:丢掉其它方式未出号的补问/选项草稿。
测服复现:旧陆运「选项未齐」书签还在,海运补「贸易条款 / 运输分类」会被陆运抢走,
回「参数提取不全」并发送填写样例图。已出号会话留在工单号索引,不当当前草稿。
"""
kind = (owner or "").upper()
sid = (sender_id or "").strip()
if not sid or kind not in {"SEA", "AIR", "LAND"}:
return
land = get_land_text_flow()
air = get_air_text_flow()
sea = get_sea_text_flow()
others = []
if kind != "LAND":
others.append(land)
if kind != "AIR":
others.append(air)
if kind != "SEA":
others.append(sea)
for flow in others:
sess = flow.session_of(sid)
phase = (getattr(sess, "phase", None) or "").strip() if sess else ""
if _front_bookmark(sess) or phase == "need_mode":
flow.drop_current_bookmark(sid)
logger.info(
"text_inquiry 丢掉其它方式草稿 owner=%s dropped_phase=%s sender=%s",
kind,
phase or "-",
sid,
)
if kind != "LAND":
clearer = getattr(land, "_clear_parked_confirm", None)
if callable(clearer):
clearer(sid)
def should_resume_other_as_inquiry(message: InboundMessage) -> bool:
"""
A 把补问短答标成闲聊时,仍续填当前询价。
@@ -63,7 +101,9 @@ def _pick_text_flow(*, sender_id: str, text: str, injected_mode: str = "", prefe
"""
按当前书签或本句运输方式选空运/海运/陆运流程。
陆运核对卡上的确定优先于旧海运书签,避免「确定」被海运/空运吃掉。
陆运核对卡上的「确定」优先于旧海运书签,避免「确定」被海运/空运吃掉。
本句点名海运/空运/陆运时盖掉其它方式的旧补问草稿。
核对卡优先于选项补问;海运/空运补问优先于旧陆运选项草稿,避免补贸易条款发陆运样例图。
正在补问时,补问书签优先于已报价调价,避免第 3 次反问出不了转人工卡。
A 已判成调价且当前不是补问:交给当前已报价工单,避免旧海运书签抢走。
图片识图在 Worker 写下的补问书签,HTTP 必须从 Redis 读到,否则「托盘」会变成再问运输方式。
@@ -82,11 +122,24 @@ def _pick_text_flow(*, sender_id: str, text: str, injected_mode: str = "", prefe
return land
sea = get_sea_text_flow()
air = get_air_text_flow()
spoken = (injected_mode or detect_transport_mode(text) or "").upper()
# 本句点名运输方式:盖掉其它方式的旧补问,避免海运补字段走陆运样例图。
if spoken in {"SEA", "AIR", "LAND"}:
_drop_other_mode_drafts(owner=spoken, sender_id=sender_id)
if spoken == "SEA":
return sea
if spoken == "LAND":
return land
return air
sea_sess = sea.session_of(sender_id)
land_sess = land.session_of(sender_id)
air_sess = air.session_of(sender_id)
# 补问/核对手稿优先于「已报价调价」,避免第 3 次反问被调价路由冲掉、出不了转人工卡。
# 核对卡仍优先:销售正在改体积/点确定,不能被另一方式的补问草稿抢走。
for sess, flow in ((land_sess, land), (air_sess, air), (sea_sess, sea)):
if sess is not None and (sess.phase or "").strip() == "wait_confirm" and not is_handoff_frozen(sess):
return flow
# 补问书签:海运/空运优先于旧陆运选项草稿。陆运「确定」已在上面拦过。
for sess, flow in ((sea_sess, sea), (air_sess, air), (land_sess, land)):
if _front_bookmark(sess):
return flow
if prefer_quoted:
@@ -97,11 +150,6 @@ def _pick_text_flow(*, sender_id: str, text: str, injected_mode: str = "", prefe
return sea
if land_sess and not is_handoff_frozen(land_sess) and (land_sess.business_line or "").upper() == "LAND":
return land
mode = (injected_mode or detect_transport_mode(text) or "").upper()
if mode == "SEA":
return sea
if mode == "LAND":
return land
return air
@@ -395,6 +395,31 @@ class AirTextInquiryFlow:
self._save(new_sess)
return new_sess.phase or "clarify"
def _detach_ticketed_for_new_inquiry(self, sess: Optional[FlowSession]) -> Optional[FlowSession]:
"""
已经出过正式工单号:这一句按新一轮收字段,不带上一单。
测服 WO202609200012 出号后,下一句海运把旧贸易条款写进 WO202609200013。
点按钮、未成交原因、调价已在前面处理。虚拟号还在补问,要接着填。
"""
if sess is None:
return None
from agent.policy.handoff_human import is_virtual_no
no = (sess.work_order_no or "").strip()
phase = (sess.phase or "").strip()
if not no or is_virtual_no(no):
return sess
if phase in {"clarify", "need_mode", "handoff_offer", "wait_lost_reason"}:
return sess
logger.info(
"%s.new_over_old phase=%s wo=%s",
self.bookmark_kind,
phase or "-",
no,
)
return None
def drop_current_bookmark(self, sender_id: str) -> Optional[FlowSession]:
"""
发图新开:丢掉当前书签,已出号会话仍留在 _by_ticket。
@@ -691,6 +716,7 @@ class AirTextInquiryFlow:
adjusted = try_private_adjust(self, sess, text, reply)
if adjusted:
return adjusted
sess = self._detach_ticketed_for_new_inquiry(sess)
snap = extract_inquiry_snapshot(
text,
@@ -734,6 +760,8 @@ class AirTextInquiryFlow:
if mode == "LAND":
from agent.policy.land_text_flow import get_land_text_flow
# 本句已是陆运:丢掉空运补问草稿。
self.drop_current_bookmark(sender_id)
return get_land_text_flow().on_text(
sender_id=sender_id,
text=text,
@@ -749,6 +777,7 @@ class AirTextInquiryFlow:
# 海运交给海运 Owner,空运不再回「本轮先开通空运」
from agent.policy.sea_text_flow import get_sea_text_flow
self.drop_current_bookmark(sender_id)
return get_sea_text_flow().on_text(
sender_id=sender_id,
text=text,
@@ -169,6 +169,8 @@ class LandTextInquiryFlow(SeaTextInquiryFlow):
if mode == "SEA":
from agent.policy.sea_text_flow import get_sea_text_flow
# 抽成海运后丢掉陆运选项草稿,下一句补字段不能再发陆运样例图。
self.drop_current_bookmark(sender_id)
return get_sea_text_flow().on_text(
sender_id=sender_id,
text=text,
@@ -182,6 +184,7 @@ class LandTextInquiryFlow(SeaTextInquiryFlow):
)
from agent.policy.air_text_flow import get_air_text_flow
self.drop_current_bookmark(sender_id)
return get_air_text_flow().on_text(
sender_id=sender_id,
text=text,
+8 -1
View File
@@ -99,6 +99,8 @@ class SeaTextInquiryFlow(AirTextInquiryFlow):
adjusted = try_private_adjust(self, sess, text, reply)
if adjusted:
return adjusted
# 上一单已出号:新一轮从本句重收,禁止把旧贸易条款/分类带进下一张工单。
sess = self._detach_ticketed_for_new_inquiry(sess)
snap = extract_inquiry_snapshot(
text,
@@ -139,6 +141,8 @@ class SeaTextInquiryFlow(AirTextInquiryFlow):
if mode == "LAND":
from agent.policy.land_text_flow import get_land_text_flow
# 本句已是陆运:丢掉海运补问草稿,避免下一句又被海运吃掉。
self.drop_current_bookmark(sender_id)
return get_land_text_flow().on_text(
sender_id=sender_id,
text=text,
@@ -153,6 +157,7 @@ class SeaTextInquiryFlow(AirTextInquiryFlow):
# 空运交给空运 Owner,避免海运流程半做空运
from agent.policy.air_text_flow import get_air_text_flow
self.drop_current_bookmark(sender_id)
return get_air_text_flow().on_text(
sender_id=sender_id,
text=text,
@@ -165,9 +170,11 @@ class SeaTextInquiryFlow(AirTextInquiryFlow):
inbound_raw=inbound_raw,
)
from agent.schema.sea_options import harvest_sea_class
from agent.schema.sea_options import drop_invented_door_trade_terms, harvest_sea_class
facts = harvest_sea_class(text, facts)
# 旧陆运/补问草稿可能把「门到门」塞进贸易条款;原话没写条款则丢掉。
facts = drop_invented_door_trade_terms(text, facts)
check = validate_required_fields(facts=facts, business_line="SEA")
facts = dict(check["facts"])
tms_ready = assemble_sea_query(facts)
+31
View File
@@ -59,6 +59,37 @@ def harvest_sea_class(text: str, current: dict[str, str] | None = None) -> dict[
return merged
# 运输分类用语,不能冒充贸易条款。销售原话写了「贸易条款」才保留。
_DOOR_TRADE_TERMS = frozenset(
{
"门到门",
"door to door",
"door-to-door",
"doortodoor",
"d2d",
}
)
def drop_invented_door_trade_terms(text: str, facts: dict[str, str] | None = None) -> dict[str, str]:
"""
丢掉冒充贸易条款的「门到门」。
测服 WO202609200013:原话没写贸易条款,确认卡却出现「贸易条款:门到门」。
门到门只是运输分类说法,不是 FOB/CIF 这类条款;原话没有「贸易条款」四字时不准收下。
销售明确写了「贸易条款:门到门」则保留原文,不改六态。
"""
merged = dict(facts or {})
trade = str(merged.get("贸易条款") or "").strip()
if not trade:
return merged
if "贸易条款" in (text or ""):
return merged
if trade.lower().replace(" ", "") in {x.replace(" ", "") for x in _DOOR_TRADE_TERMS} or trade in _DOOR_TRADE_TERMS:
merged.pop("贸易条款", None)
return merged
def sea_class_prompt() -> str:
"""缺运输分类时只列两项,不再追问「其他是什么」。"""
return "运输分类(必填):请回复 Port to Port 或 其他。"
+50 -1
View File
@@ -25,7 +25,7 @@ from agent.channel.wecom.models import InboundMessage
from agent.handlers.text_inquiry import handle_text_inquiry
from agent.policy.air_text_flow import reset_air_text_flow_for_test
from agent.policy.land_text_flow import get_land_text_flow, reset_land_text_flow_for_test
from agent.policy.sea_text_flow import reset_sea_text_flow_for_test
from agent.policy.sea_text_flow import get_sea_text_flow, reset_sea_text_flow_for_test
from agent.routing.decision import RouteDecision
from agent.routing.dispatch import dispatch_inbound
from tests.test_sea_text_inquiry import COMPLETE_SEA
@@ -323,6 +323,55 @@ class LandDispatchTests(unittest.TestCase):
self.assertNotIn("你好,我是询价机器人", last)
self.assertIn("请说明要调哪一项费用", last)
def test_sea_field_supplement_not_stolen_by_stale_land(self) -> None:
"""
旧陆运选项草稿不能抢走海运补问。
测服:先陆运缺选项,再发海运缺贸易条款/运输分类,下一句只补这两项,
不能回陆运「参数提取不全」或填写样例图。
"""
store = get_message_store()
handle_text_inquiry(
InboundMessage(sender_id="mix-sea", message_id="m-land-old", content="陆运询价"),
injected_facts={"起运港": "广州", "目的港": "深圳", "品名": "衣服"},
injected_mode="LAND",
store=store,
)
self.assertEqual(get_land_text_flow().session_of("mix-sea").phase, "need_land_options")
incomplete = {
"起运港": "上海",
"目的港": "洛杉矶",
"品名": "普货",
"货量": "20吨",
"整柜或拼柜": "整柜",
"箱型箱量": "1x40HQ",
}
phase = handle_text_inquiry(
InboundMessage(sender_id="mix-sea", message_id="m-sea-ask", content="海运询价"),
injected_facts=incomplete,
injected_mode="SEA",
store=store,
)
self.assertEqual(phase, "clarify")
self.assertEqual(get_sea_text_flow().session_of("mix-sea").phase, "clarify")
self.assertIsNone(get_land_text_flow().session_of("mix-sea"))
before = len(store._outbox)
phase = handle_text_inquiry(
InboundMessage(
sender_id="mix-sea",
message_id="m-sea-fill",
content="贸易条款:FOB 运输分类:其他",
),
injected_facts={"贸易条款": "FOB", "运输分类": "其他"},
store=store,
)
self.assertNotEqual(phase, "need_land_options")
self.assertEqual(get_sea_text_flow().session_of("mix-sea").business_line, "SEA")
added = "\n".join(i.content for i in list(store._outbox.values())[before:])
self.assertNotIn("参数提取不全", added)
self.assertNotIn("填写样例", added)
self.assertNotIn("运输类型", added)
def test_clarify_short_reply_misrouted_as_other_stays_on_inquiry(self) -> None:
"""补问阶段回「111」即使被标闲聊,也不能发开场白。"""
store = get_message_store()
+8
View File
@@ -14,6 +14,7 @@ if ROOT not in sys.path:
from agent.schema.sea_options import (
canonicalize_sea_class,
drop_invented_door_trade_terms,
harvest_sea_class,
sea_class_prompt,
)
@@ -41,6 +42,13 @@ class SeaOptionsTest(unittest.TestCase):
got = harvest_sea_class("运输分类:其他", {"运输分类": "Port to Port"})
self.assertEqual(got["运输分类"], "其他")
def test_door_to_door_not_kept_as_trade_terms(self):
text = "海运、纽约港到巴生西港,机器设备,40HQ*1,整柜,1.5KG,15CBM,15件"
got = drop_invented_door_trade_terms(text, {"贸易条款": "门到门", "品名": "机器设备"})
self.assertNotIn("贸易条款", got)
kept = drop_invented_door_trade_terms("贸易条款:门到门", {"贸易条款": "门到门"})
self.assertEqual(kept["贸易条款"], "门到门")
def test_prompt_lists_two_options_only(self):
text = sea_class_prompt()
self.assertIn("Port to Port", text)
@@ -28,6 +28,7 @@ from agent.handlers.group_collab import handle_group_collab
from agent.handlers.text_inquiry import handle_text_inquiry
from agent.ledger.memory_ledger import MemoryLedger
from agent.policy import inquiry_copy as copy
from agent.policy.air_text_flow import FlowSession
from agent.policy.sea_text_flow import SeaTextInquiryFlow, reset_sea_text_flow_for_test
from agent.schema.field_validate import SEA_COLLAB_FIELDS, validate_required_fields
from agent.schema.tms_sea_query import assemble_sea_query
@@ -273,6 +274,39 @@ class SeaTextInquiryTests(unittest.TestCase):
self.assertIn("贸易条款", "\n".join(self.replies))
self.assertFalse((self.flow.session_of("s-miss").work_order_no or "").startswith("WO"))
def test_leftover_door_to_door_not_written_as_trade_terms(self) -> None:
"""旧草稿里的门到门不能写到确认卡贸易条款。测服 WO202609200013。"""
leftover = {
"起运港": "纽约港",
"目的港": "巴生西港",
"品名": "机器设备",
"货量": "15件",
"整柜或拼柜": "整柜",
"箱型箱量": "40HQ*1",
"贸易条款": "门到门",
}
self.flow._save(
FlowSession(
sender_id="s-door",
thread_id="s-door",
phase="clarify",
business_line="SEA",
facts=leftover,
)
)
phase = self.flow.on_text(
sender_id="s-door",
text="海运、纽约港到巴生西港,机器设备,40HQ*1,整柜,1.5KG,15CBM,15件",
reply=self._reply,
injected_facts=dict(leftover),
injected_mode="SEA",
)
self.assertEqual(phase, "clarify")
joined = "\n".join(self.replies)
self.assertNotIn("贸易条款:门到门", joined)
self.assertIn("贸易条款", joined)
self.assertNotEqual(self.flow.session_of("s-door").facts.get("贸易条款"), "门到门")
def test_harvest_class_from_reply_then_quotes(self) -> None:
first = dict(COMPLETE_SEA)
first.pop("运输分类")
@@ -321,6 +355,44 @@ class SeaTextInquiryTests(unittest.TestCase):
self.assertEqual(sess.business_line, "SEA")
self.assertEqual(sess.status, "已报价")
def test_new_round_after_ticket_does_not_reuse_old_facts(self) -> None:
"""上一单已出号,新一轮不能带上旧贸易条款。"""
first = self.flow.on_text(
sender_id="s-round",
text="海运第一轮",
reply=self._reply,
injected_facts=COMPLETE_SEA,
injected_mode="SEA",
)
self.assertEqual(first, "wait_collab")
old_no = self.flow.session_of("s-round").work_order_no
self.assertTrue((old_no or "").startswith("WO"))
self.replies.clear()
incomplete = {
"起运港": "纽约港",
"目的港": "巴生西港",
"品名": "机器设备",
"货量": "15件",
"整柜或拼柜": "整柜",
"箱型箱量": "40HQ*1",
"运输分类": "Port to Port",
}
phase = self.flow.on_text(
sender_id="s-round",
text="海运、纽约港到巴生西港,机器设备,40HQ*1,整柜,1.5KG,15CBM,15件,Port to Port",
reply=self._reply,
injected_facts=incomplete,
injected_mode="SEA",
)
self.assertEqual(phase, "clarify")
joined = "\n".join(self.replies)
self.assertNotIn("贸易条款:FOB", joined)
self.assertNotIn("贸易条款:门到门", joined)
self.assertIn("贸易条款", joined)
sess = self.flow.session_of("s-round")
self.assertNotEqual(sess.facts.get("贸易条款"), "FOB")
self.assertNotEqual(sess.work_order_no, old_no)
def test_handler_routes_sea_not_air_deferred(self) -> None:
reset_sea_text_flow_for_test()
from agent.channel.queue import MemoryMessageStore