From 19acbb915633cac8d4eefe197a53339c9b48308d Mon Sep 17 00:00:00 2001 From: jillion <439912596@qq.com> Date: Sun, 20 Sep 2026 18:27:13 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20=E5=B7=B2=E5=87=BA=E5=8F=B7=E5=90=8E?= =?UTF-8?q?=E6=96=B0=E4=B8=80=E8=BD=AE=E8=AF=A2=E4=BB=B7=E4=B8=8D=E5=B8=A6?= =?UTF-8?q?=E4=B8=8A=E4=B8=80=E5=8D=95=E5=AD=97=E6=AE=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 点名海运时丢掉旧陆运补问草稿,避免补字段误发填写样例图。 Co-authored-by: Cursor --- inquiry-agent/agent/handlers/text_inquiry.py | 62 +++++++++++++++-- inquiry-agent/agent/policy/air_text_flow.py | 29 ++++++++ inquiry-agent/agent/policy/land_text_flow.py | 3 + inquiry-agent/agent/policy/sea_text_flow.py | 9 ++- inquiry-agent/agent/schema/sea_options.py | 31 +++++++++ inquiry-agent/tests/test_land_dispatch.py | 51 +++++++++++++- inquiry-agent/tests/test_sea_options.py | 8 +++ inquiry-agent/tests/test_sea_text_inquiry.py | 72 ++++++++++++++++++++ 8 files changed, 256 insertions(+), 9 deletions(-) diff --git a/inquiry-agent/agent/handlers/text_inquiry.py b/inquiry-agent/agent/handlers/text_inquiry.py index 42ab7a6..ce7db55 100644 --- a/inquiry-agent/agent/handlers/text_inquiry.py +++ b/inquiry-agent/agent/handlers/text_inquiry.py @@ -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 diff --git a/inquiry-agent/agent/policy/air_text_flow.py b/inquiry-agent/agent/policy/air_text_flow.py index b774c32..14ba473 100644 --- a/inquiry-agent/agent/policy/air_text_flow.py +++ b/inquiry-agent/agent/policy/air_text_flow.py @@ -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, diff --git a/inquiry-agent/agent/policy/land_text_flow.py b/inquiry-agent/agent/policy/land_text_flow.py index 6c3ee93..c1e055a 100644 --- a/inquiry-agent/agent/policy/land_text_flow.py +++ b/inquiry-agent/agent/policy/land_text_flow.py @@ -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, diff --git a/inquiry-agent/agent/policy/sea_text_flow.py b/inquiry-agent/agent/policy/sea_text_flow.py index 0cb52c2..282d898 100644 --- a/inquiry-agent/agent/policy/sea_text_flow.py +++ b/inquiry-agent/agent/policy/sea_text_flow.py @@ -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) diff --git a/inquiry-agent/agent/schema/sea_options.py b/inquiry-agent/agent/schema/sea_options.py index f121b3e..dc305ac 100644 --- a/inquiry-agent/agent/schema/sea_options.py +++ b/inquiry-agent/agent/schema/sea_options.py @@ -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 或 其他。" diff --git a/inquiry-agent/tests/test_land_dispatch.py b/inquiry-agent/tests/test_land_dispatch.py index dd1acec..2d8ec6d 100644 --- a/inquiry-agent/tests/test_land_dispatch.py +++ b/inquiry-agent/tests/test_land_dispatch.py @@ -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() diff --git a/inquiry-agent/tests/test_sea_options.py b/inquiry-agent/tests/test_sea_options.py index cb2abec..c19b0a5 100644 --- a/inquiry-agent/tests/test_sea_options.py +++ b/inquiry-agent/tests/test_sea_options.py @@ -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) diff --git a/inquiry-agent/tests/test_sea_text_inquiry.py b/inquiry-agent/tests/test_sea_text_inquiry.py index 1cb2544..b4bd465 100644 --- a/inquiry-agent/tests/test_sea_text_inquiry.py +++ b/inquiry-agent/tests/test_sea_text_inquiry.py @@ -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