"""多段联运:分段、补问、核对建单、按线路拉进同一个群。""" from __future__ import annotations import os import sys import unittest ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "..")) if ROOT not in sys.path: sys.path.insert(0, ROOT) from agent.ledger.memory_ledger import MemoryLedger from agent.policy import inquiry_copy as copy from agent.policy.multi_segments import ( Segment, classify_multi_text, group_brief, merge_supplement, refresh_segments, split_segments, ) from agent.policy.multi_text_flow import MultiTextInquiryFlow CLARIFY_REPLY = """段1:国内运输拼车 段2:运输方式:空运 起运地:ZUH 目的地:CRK 品名:LMM振动测试定位治具 件数:1件 重量:1.5 体积:100CBM 报价日期:2026-09-25 贸易条款:DAP 托盘 """ SEA_AIR = """段1:海运 起运港:广州港 目的港:巴生港 品名:机器设备 货物数量:15件 整柜或拼柜:FCL 箱型箱量:40HQ*1 贸易条款:DOOR-DOOR 运输分类:Port to Port 报价日期:2026-09-23 段2:空运 目的地:CRK 品名:LMM振动测试定位治具 件数:1件 重量:1.5 体积:100CBM 报价日期:2026-09-25 起运地:ZUH 贸易条款:DAP """ SEA_AIR_READY = SEA_AIR + "\n包装类型:纸箱\n" class SplitTests(unittest.TestCase): def test_labeled_segments_keep_order_and_fields(self) -> None: outcome = split_segments(SEA_AIR) self.assertTrue(outcome.ok) self.assertEqual([seg.mode for seg in outcome.segments], ["SEA", "AIR"]) self.assertEqual(outcome.segments[0].facts.get("起运港"), "广州港") self.assertEqual(outcome.segments[1].facts.get("目的地"), "CRK") def test_same_mode_twice_is_rejected(self) -> None: self.assertEqual(classify_multi_text("段1:海运\n起运港:A\n段2:海运\n目的港:B"), "same_mode") def test_one_sea_quote_sheet_is_single(self) -> None: """测服 WO202608250012 海运报价单:文件名标段1,表里再写运输方式,仍是一段。""" oral = ( "段1:海运\n" "运输方式:海运\n" "起运港:纽约港\n" "目的港:巴生西港\n" "品名:机器设备\n" "海运询价" ) self.assertEqual(classify_multi_text(oral), "single") def test_unlabeled_repeated_sea_is_single(self) -> None: """报价单原文没有段标、海运出现两次,不能拆开再发。""" self.assertEqual( classify_multi_text("运输方式:海运\n起运港:A\n备注:海运询价"), "single", ) def test_explicit_markers_ignore_mode_words_in_body(self) -> None: """文件名先标了段,表里再写「运输方式:海运」不能当成又一段海运。""" oral = ( "段1:陆运\n" "运输类型:国内运输拼车\n" "段2:海运\n" "运输方式:海运\n" "起运港:纽约港\n" "目的港:巴生西港" ) self.assertEqual(classify_multi_text(oral), "multi") outcome = split_segments(oral) self.assertEqual([seg.mode for seg in outcome.segments], ["LAND", "SEA"]) self.assertEqual(outcome.segments[1].facts.get("起运港"), "纽约港") def test_sea_sheet_headers_do_not_stick_to_ports(self) -> None: """海运报价单表头不能写进补问,只要格子里的纽约港/CIF。""" oral = ( "段1:陆运\n运输类型:国内运输整车\n" "段2:海运\n" "起运港/POL(有指定起运港口的必填) 纽约港\n" "*品名/Commodity: 机器设备 " "目的港/POD(有指定目的港口的必填) 巴生西港\n" "*货物数量/Quantity (或提供装箱单/Packing List) 18\n" "整柜或拼柜:FCL\n箱型箱量:40HQ*1\n" "贸易条款/Terms: CIF 要求运输时效/Requested Transit Time\n" "运输分类:其他\n" ) outcome = split_segments(oral) sea = outcome.segments[1] self.assertEqual(sea.facts.get("起运港"), "纽约港") self.assertEqual(sea.facts.get("目的港"), "巴生西港") self.assertEqual(sea.facts.get("品名"), "机器设备") self.assertEqual(sea.facts.get("贸易条款"), "CIF") _, _, gaps = refresh_segments(outcome.segments) self.assertNotIn("口的必填", gaps) self.assertNotIn("Commodity", gaps) def test_inline_two_modes(self) -> None: outcome = split_segments("陆运+海运") self.assertTrue(outcome.ok) self.assertEqual([seg.mode for seg in outcome.segments], ["LAND", "SEA"]) def test_cargo_before_ordinal_segments_fills_both(self) -> None: """段前写一次的品名、尺寸、重量、件数,陆运和空运都要带上。""" oral = ( "茶叶,1.1*2.1*2.2,11kg,21件\n" "第一段陆运,北京到上海\n" "第二段空运,上海到北宁" ) outcome = split_segments(oral) self.assertTrue(outcome.ok) self.assertEqual([seg.index for seg in outcome.segments], [1, 2]) self.assertEqual([seg.mode for seg in outcome.segments], ["LAND", "AIR"]) land, air = outcome.segments self.assertEqual(land.facts.get("品名"), "茶叶") self.assertEqual(air.facts.get("品名"), "茶叶") self.assertEqual(air.facts.get("件数"), "21件") self.assertEqual(air.facts.get("毛重"), "11kg") self.assertEqual(air.facts.get("体积"), "1.1*2.1*2.2") _, _, gaps = refresh_segments(outcome.segments) self.assertNotIn("品名(必填):\n(如:茶叶)", gaps) self.assertNotIn("重量(KG)(必填):\n(如:", gaps) self.assertNotIn("体积(CBM)(必填):\n(如:", gaps) self.assertNotIn("件数(必填):\n(如:", gaps) def test_group_brief_has_no_blank_under_mentions(self) -> None: """拉群摘要 @人后直接接段1,中间不要空行。""" segs = split_segments(SEA_AIR).segments text = group_brief( work_order_no="WO1", segments=segs, sales_name="吴季林", mention_names=["林绵助"], ) self.assertIn("@吴季林@林绵助\n段1:", text) self.assertNotIn("@林绵助\n\n段1", text) def test_missing_fields_use_single_chat_examples(self) -> None: outcome = split_segments("段1:海运\n起运港:广州港\n段2:空运\n目的地:CRK") ready, _, text = refresh_segments(outcome.segments) self.assertFalse(ready) self.assertIn("请确认并补充以下信息:", text) self.assertIn(copy.CLARIFY_PASTE_HINT, text) self.assertIn("品名(必填):\n(如:茶叶)", text) self.assertIn("货源地(非必填):\n(如:东莞)", text) self.assertIn("贸易条款(非必填):\n(如:DDP、DAP、CIF、CIP)", text) self.assertNotIn("品名:(如:茶叶)", text) def test_port_to_port_fills_sea_class(self) -> None: """海运只缺运输分类时,回复 Port to Port 要写进该段,和单聊一样。""" sea = Segment( index=2, mode="SEA", facts={ "起运港": "纽约港", "目的港": "巴生西港", "品名": "机器设备", "货量": "20吨", "整柜或拼柜": "FCL", "箱型箱量": "40HQ*1", "贸易条款": "CIF", }, ) sea_ok, _, _ = refresh_segments([sea]) self.assertFalse(sea_ok) merged = merge_supplement([sea], "Port to Port") self.assertEqual(merged[0].facts.get("运输分类"), "Port to Port") def test_segment_marked_supplement_keeps_old_facts(self) -> None: """补问里再写段1/段2是补字段,不能整单重开把已填港口冲掉。""" draft = [ Segment( index=1, mode="LAND", facts={"运输类型": "国内运输整车", "品名": "衣服", "车型/数量": "13米×3"}, ), Segment( index=2, mode="SEA", facts={ "起运港": "纽约港", "目的港": "巴生西港", "品名": "机器设备", "整柜或拼柜": "FCL", "箱型箱量": "40HQ*1", "贸易条款": "CIF", }, ), ] text = ( "段1:陆运:\n国内长途/零担、普件(东南亚、中港常用线路/常规车型)\n\n" "段2:海运\n货物数量:100\n运输分类(必填):其他。" ) merged = merge_supplement(draft, text) land = next(seg for seg in merged if seg.mode == "LAND") sea = next(seg for seg in merged if seg.mode == "SEA") self.assertEqual(land.facts.get("运输类型"), "国内运输整车") self.assertEqual(land.facts.get("品名"), "衣服") self.assertEqual(land.facts.get("线路类别"), "国内长途/零担") self.assertIn("普件", str(land.facts.get("运输分类") or "")) self.assertEqual(sea.facts.get("起运港"), "纽约港") self.assertEqual(sea.facts.get("目的港"), "巴生西港") self.assertEqual(sea.facts.get("品名"), "机器设备") self.assertEqual(sea.facts.get("货量") or sea.facts.get("货物数量"), "100") self.assertEqual(sea.facts.get("运输分类"), "其他") def test_incomplete_land_does_not_confirm_when_sea_ready(self) -> None: """海运齐了、陆运还缺线路/分类、站点是模板码时,不能出确定创建工单。""" segs = [ Segment( index=1, mode="LAND", facts={ "运输类型": "国内运输整车", "起运港": "DOM", "目的港": "FTL", "品名": "衣服;车型/数量:13米×3;体积:100CBM;重量:10KG", "车型/数量": "13米×3", "报价日期": "2026-08-24", }, ), Segment( index=2, mode="SEA", facts={ "起运港": "纽约港", "目的港": "巴生西港", "品名": "机器设备", "货量": "100", "整柜或拼柜": "FCL", "箱型箱量": "40HQ*1", "贸易条款": "CIF", "运输分类": "其他", "报价日期": "2026-09-23", }, ), ] ready, updated, text = refresh_segments(segs) self.assertFalse(ready) self.assertIn("线路类别(必填):", text) self.assertIn("1.1国内长途/零担", text) self.assertIn("国内城配/拖车/打包", text) self.assertIn("运输分类(必填):", text) self.assertIn("1.普件", text) self.assertNotIn("始发站", text) self.assertNotIn("请回复「确定」创建工单", text) filled = merge_supplement( updated, "线路类别:国内长途/零担\n运输分类:急件" ) ready2, _, cargo = refresh_segments(filled) self.assertFalse(ready2) self.assertIn("始发站", cargo) self.assertIn("目的地", cargo) self.assertNotIn("可选择回复", cargo) self.assertNotIn("请回复「确定」创建工单", cargo) def test_clarify_reply_fills_land_type_and_air_pack(self) -> None: draft = split_segments("段1:陆运\n段2:空运\n报价日期:2026-09-23") self.assertTrue(draft.ok) merged = merge_supplement(draft.segments, CLARIFY_REPLY) by_mode = {seg.mode: seg for seg in merged} self.assertEqual(by_mode["LAND"].facts.get("运输类型"), "国内运输拼车") self.assertEqual(by_mode["AIR"].facts.get("目的港") or by_mode["AIR"].facts.get("目的地"), "CRK") self.assertEqual(by_mode["AIR"].facts.get("包装方式") or by_mode["AIR"].facts.get("包装类型"), "托盘") def test_oral_land_line_fills_only_lacking_segment(self) -> None: draft = split_segments( "段1:陆运\n运输类型:国内运输拼车\n段2:空运\n目的地:CRK\n" "品名:治具\n件数:1件\n重量:1.5\n体积:100CBM\n包装类型:托盘" ) oral = "广州-深圳,LMM振动测试定位治具,100CBM,1.5" extra = { "起运港": "广州", "目的港": "深圳", "品名": "LMM振动测试定位治具", "体积": "100CBM", "毛重": "1.5", } merged = merge_supplement(draft.segments, oral, extra_facts=extra) land = next(seg for seg in merged if seg.mode == "LAND") air = next(seg for seg in merged if seg.mode == "AIR") self.assertEqual(land.facts.get("起运港"), "广州") self.assertEqual(land.facts.get("目的港"), "深圳") self.assertEqual(land.facts.get("品名"), "LMM振动测试定位治具") self.assertIn("100", str(land.facts.get("体积") or "")) self.assertEqual(air.facts.get("品名"), "治具") def test_confirm_sea_fix_updates_ready_sea_segment(self) -> None: """核对后只改正海运格子,要写回海运段,不能当重开。""" segs = split_segments(SEA_LAND_READY).segments merged = merge_supplement( segs, "目的港:巴生西港\n货物数量:18\n整柜或拼柜:FCL\n箱型箱量:40HQ*1", ) sea = next(seg for seg in merged if seg.mode == "SEA") land = next(seg for seg in merged if seg.mode == "LAND") self.assertEqual(sea.facts.get("目的港"), "巴生西港") self.assertEqual(str(sea.facts.get("货量") or sea.facts.get("货物数量") or ""), "18") self.assertEqual(sea.facts.get("箱型箱量"), "40HQ*1") self.assertEqual(land.facts.get("品名") or land.facts.get("货物品名"), "零件") class FlowTests(unittest.TestCase): def setUp(self) -> None: self.ledger = MemoryLedger() self.replies: list[str] = [] self.flow = MultiTextInquiryFlow(self.ledger, group_client=_Group()) def reply(self, text: str, extra: dict | None = None) -> str: _ = extra self.replies.append(text) return "ok" def last_prompt(self) -> str: """取带段号或复制填空清单的那条补问。""" for text in reversed(self.replies): if "段1" in text or "Step1:" in text or "请回复「确定」" in text or "待补充" in text: return text return self.replies[-1] if self.replies else "" def test_missing_air_pack_is_asked_once(self) -> None: phase = self.flow.on_text(sender_id="sales", text=SEA_AIR, reply=self.reply) self.assertEqual(phase, "clarify") card = self.replies[-1] self.assertIn("请确认并补充以下信息:", card) self.assertIn("段2:空运", card) self.assertIn(copy.CLARIFY_PASTE_HINT, card) self.assertIn("包装类型(必填):\n(如:", card) self.assertNotIn("TMS", card) def test_clarify_reply_gets_a_followup(self) -> None: phase = self.flow.on_text(sender_id="sales", text="段1:陆运\n段2:空运", reply=self.reply) self.assertEqual(phase, "clarify") phase = self.flow.on_text(sender_id="sales", text=CLARIFY_REPLY, reply=self.reply) self.assertIn(phase, {"clarify", "wait_confirm"}) self.assertTrue(self.replies[-1].strip()) self.assertIn("请确认", self.last_prompt()) def test_open_extracts_unlabeled_attachment_oral(self) -> None: """附件口播常没有「起运港:」,开多段时要走和单聊一样的抽取,不能整段待补充。""" import json from types import SimpleNamespace def chat_fn(**kwargs): msgs = kwargs.get("messages") or [] blob = str((msgs[-1] or {}).get("content") if msgs else "") if "东莞" in blob: payload = { "transport_mode": "LAND", "land_subtype": "国内运输拼车", "fields": {"起运港": "东莞", "目的港": "上海", "品名": "茶叶"}, } else: payload = { "transport_mode": "SEA", "land_subtype": "", "fields": { "起运港": "纽约港", "目的港": "巴生西港", "品名": "机器设备", "整柜或拼柜": "FCL", "箱型箱量": "40HQ*1", }, } return SimpleNamespace( ok=True, content="", error="", tool_calls=[ { "function": { "name": "extract_inquiry_fields", "arguments": json.dumps(payload, ensure_ascii=False), } } ], ) oral = ( "段1:陆运\n东莞到上海 茶叶 2吨\n" "段2:海运\n纽约到巴生 机器设备 40HQ" ) phase = self.flow.on_text( sender_id="sales", text=oral, reply=self.reply, chat_fn=chat_fn, ) card = self.last_prompt() self.assertEqual(phase, "clarify") self.assertIn("线路类别(必填):", card) self.assertIn("纽约港", card) self.assertIn("巴生西港", card) self.assertIn("机器设备", card) self.assertNotIn("东莞", card) self.assertNotIn("起运港:(如:上海)", card) def test_oral_land_supplement_is_recognized(self) -> None: start = ( "段1:陆运\n运输类型:国内运输拼车\n" "段2:空运\n目的地:CRK\n品名:治具\n件数:1件\n重量:1.5\n" "体积:100CBM\n包装类型:托盘\n报价日期:2026-09-23" ) phase = self.flow.on_text(sender_id="sales", text=start, reply=self.reply) self.assertEqual(phase, "clarify") first = self.last_prompt() self.assertIn("线路类别(必填):", first) self.assertIn("运输分类(必填):", first) self.assertNotIn("始发站", first) phase = self.flow.on_text( sender_id="sales", text="广州-深圳,LMM振动测试定位治具,100CBM,1.5", reply=self.reply, injected_facts={ "起运港": "广州", "目的港": "深圳", "品名": "LMM振动测试定位治具", "体积": "100CBM", "毛重": "1.5", }, injected_mode="LAND", ) card = self.last_prompt() self.assertEqual(phase, "clarify") self.assertIn("线路类别(必填):", card) self.assertNotIn("始发站", card) self.assertNotIn("请回复「确定」创建工单", card) def test_ready_confirm_has_no_collab_and_no_tms(self) -> None: phase = self.flow.on_text(sender_id="sales", text=SEA_AIR_READY, reply=self.reply) self.assertEqual(phase, "wait_confirm") card = self.replies[-1] self.assertIn("段1:海运", card) self.assertIn("段2:空运", card) self.assertIn("请回复「确定」创建工单", card) self.assertNotIn("协同补充", card) self.assertNotIn("TMS", card) def test_wait_confirm_field_fix_stays_on_draft(self) -> None: """核对卡上改海运目的港/箱量,不能回「请把多段需求再发一次」。""" phase = self.flow.on_text(sender_id="sales", text=SEA_LAND_READY, reply=self.reply) self.assertEqual(phase, "wait_confirm") phase = self.flow.on_text( sender_id="sales", text="目的港:巴生西港\n货物数量:18\n整柜或拼柜:FCL\n箱型箱量:40HQ*1", reply=self.reply, ) card = self.last_prompt() self.assertEqual(phase, "wait_confirm") self.assertNotIn("请把多段需求再发一次", card) self.assertIn("巴生西港", card) self.assertIn("18", card) sea = next(seg for seg in self.flow._segments(self.flow.session_of("sales")) if seg.mode == "SEA") self.assertEqual(sea.facts.get("目的港"), "巴生西港") self.assertEqual(str(sea.facts.get("货量") or ""), "18") def test_incomplete_land_stays_clarify_when_sea_ready(self) -> None: """销售看到的那张卡:海运齐、陆运空着线路和模板码站点,必须继续补问。""" text = """段1:陆运 运输类型:国内运输整车 始发站:DOM 目的地:FTL 货物品名:衣服 车型/数量:13米×3 报价日期:2026-08-24 段2:海运 起运港:纽约港 目的港:巴生西港 品名:机器设备 货物数量:100 整柜或拼柜:FCL 箱型箱量:40HQ*1 贸易条款:CIF 运输分类:其他 报价日期:2026-09-23 """ phase = self.flow.on_text(sender_id="sales", text=text, reply=self.reply) self.assertEqual(phase, "clarify") card = self.last_prompt() self.assertIn("线路类别(必填):", card) self.assertIn("运输分类(必填):", card) self.assertNotIn("始发站", card) self.assertNotIn("请回复「确定」创建工单", card) def test_clarify_segment_reply_is_not_a_new_inquiry(self) -> None: """补问阶段按段回复线路和货量,要写回原单,不能再要一遍起运港。""" start = """段1:陆运 运输类型:国内运输整车 段2:海运 起运港:纽约港 目的港:巴生西港 品名:机器设备 整柜或拼柜:FCL 箱型箱量:40HQ*1 贸易条款:CIF """ phase = self.flow.on_text(sender_id="sales", text=start, reply=self.reply) self.assertEqual(phase, "clarify") self.replies.clear() phase = self.flow.on_text( sender_id="sales", text=( "段1:陆运:\n国内长途/零担、普件(东南亚、中港常用线路/常规车型)\n\n" "段2:海运\n货物数量:100\n运输分类(必填):其他。" ), reply=self.reply, ) self.assertEqual(phase, "clarify") card = self.last_prompt() self.assertNotIn("起运港:(如:上海)", card) self.assertIn("始发站", card) sess = self.flow.session_of("sales") segs = self.flow._segments(sess) sea = next(seg for seg in segs if seg.mode == "SEA") land = next(seg for seg in segs if seg.mode == "LAND") self.assertEqual(sea.facts.get("起运港"), "纽约港") self.assertEqual(land.facts.get("线路类别"), "国内长途/零担") def test_confirm_creates_one_ticket_and_air_hint(self) -> None: self.flow.on_text(sender_id="sales", text=SEA_AIR_READY, reply=self.reply) phase = self.flow.on_text(sender_id="sales", text="确定", reply=self.reply) self.assertEqual(phase, "wait_manual_group") hint = self.replies[-1] self.assertIn("请手动拉相关人员和智能体到原有群", hint) self.assertIn("WO", hint) tickets = list(self.ledger._tickets.values()) self.assertEqual(len(tickets), 1) self.assertEqual(tickets[0].business_line, "MULTI") def test_new_multi_after_manual_group_starts_fresh(self) -> None: """含空运工单已出号后,再发陆运+海运是新询价,不能回旧工单拉群提示。""" self.flow.on_text(sender_id="sales", text=SEA_AIR_READY, reply=self.reply) self.flow.on_text(sender_id="sales", text="确定", reply=self.reply) old_wo = self.flow.session_of("sales").work_order_no self.assertTrue(old_wo) self.replies.clear() phase = self.flow.on_text( sender_id="sales", text="段1:陆运\n运输类型:国内运输拼车\n段2:海运\n起运港:上海港\n目的港:汉堡", reply=self.reply, ) self.assertNotEqual(phase, "wait_manual_group") self.assertNotIn(old_wo, self.last_prompt()) self.assertNotIn("请手动拉相关人员和智能体", self.last_prompt()) self.assertIn("陆运", self.last_prompt()) self.assertIn("海运", self.last_prompt()) def test_plain_text_after_manual_group_still_hints(self) -> None: """不是新询价时,含空运工单仍提示去原有群发工单号。""" self.flow.on_text(sender_id="sales", text=SEA_AIR_READY, reply=self.reply) self.flow.on_text(sender_id="sales", text="确定", reply=self.reply) old_wo = self.flow.session_of("sales").work_order_no self.replies.clear() phase = self.flow.on_text(sender_id="sales", text="嗯", reply=self.reply) self.assertEqual(phase, "wait_manual_group") self.assertIn("请手动拉相关人员和智能体到原有群", self.replies[-1]) self.assertIn(old_wo, self.replies[-1]) def test_same_mode_does_not_create(self) -> None: phase = self.flow.on_text( sender_id="sales", text="段1:海运\n起运港:广州港\n段2:海运\n目的港:新加坡", reply=self.reply, ) self.assertEqual(phase, "reject_same_mode") self.assertEqual(self.ledger._tickets, {}) SEA_LAND_READY = """段1:海运 起运港:广州港 目的港:巴生港 品名:机器 货物数量:1 整柜或拼柜:FCL 箱型箱量:40HQ*1 贸易条款:FOB 运输分类:Port to Port 报价日期:2026-09-23 段2:陆运 运输类型:国内运输拼车 线路类别:国内长途/零担 运输分类:急件 始发站:上海 目的地:苏州 货物品名:零件 重量:10 体积:1 车型/数量:4.2米/1 报价日期:2026-09-23 """ def _staff_ledger() -> MemoryLedger: """海运+陆运各一个人,点拉群才能建得成。""" ledger = MemoryLedger() ledger.set_sea_staff( [{"wecomId": "sea1", "name": "海产", "roleCode": "sea", "status": "active", "routes": "海运·全部线路"}] ) ledger.set_land_staff( [ { "wecomId": "land1", "name": "陆产", "roleCode": "land", "status": "active", "routes": "国内长途/零担", } ] ) return ledger class _Group: def __init__(self) -> None: self.created: list[tuple[str, list[str]]] = [] self.messages: list[str] = [] def create_group(self, name: str, userids: list[str]) -> dict: self.created.append((name, list(userids))) return {"ok": True, "chat_id": "room-1"} def send_group(self, chat_id: str, content: str, mention_userids: list[str] | None = None) -> dict: _ = chat_id, mention_userids self.messages.append(content) return {"ok": True} class PullTests(unittest.TestCase): def test_sea_and_land_share_one_group_without_tms(self) -> None: ledger = _staff_ledger() group = _Group() flow = MultiTextInquiryFlow(ledger, group_client=group) replies: list[str] = [] def reply(body: str, extra: dict | None = None) -> str: _ = extra replies.append(body) return "ok" phase = flow.on_text(sender_id="sales", text=SEA_LAND_READY, reply=reply) if phase == "clarify": self.fail(replies[-1]) self.assertEqual(phase, "wait_confirm") phase = flow.on_text(sender_id="sales", text="确定", reply=reply) self.assertEqual(phase, "wait_collab") self.assertIn("拉产品进群协同", replies[-1]) self.assertNotIn("TMS", replies[-1]) phase = flow.on_text(sender_id="sales", text="拉产品进群协同", reply=reply) self.assertEqual(phase, "collab_group") self.assertEqual(len(group.created), 1) _name, userids = group.created[0] self.assertIn("sea1", userids) self.assertIn("land1", userids) brief = group.messages[-1] self.assertIn("段1:海运", brief) self.assertIn("段2:陆运", brief) self.assertIn("运输类型(必填)", brief) self.assertNotIn("TMS报价", brief) brief_text = group_brief( work_order_no="WO1", segments=flow._segments(flow.session_of("sales")), ) self.assertNotIn("TMS", brief_text) class CardPullTests(unittest.TestCase): """点卡走 HTTP 进程:内存没有 Worker 书签,不能当空运查价失败丢掉。""" def _open_wait_collab(self, flow: MultiTextInquiryFlow, sender_id: str) -> str: replies: list[str] = [] def reply(body: str, extra: dict | None = None) -> str: _ = extra replies.append(body) return "ok" phase = flow.on_text(sender_id=sender_id, text=SEA_LAND_READY, reply=reply) self.assertEqual(phase, "wait_confirm", replies[-1] if replies else "") phase = flow.on_text(sender_id=sender_id, text="确定", reply=reply) self.assertEqual(phase, "wait_collab") return flow.session_of(sender_id).work_order_no def test_card_click_without_memory_creates_one_group(self) -> None: """模拟 HTTP 刚起来:进程内没有书签,只靠卡上工单号从主账收回。""" from agent.channel.queue import MemoryMessageStore from agent.handlers.card_action import handle_card_action from agent.policy import inquiry_copy as copy ledger = _staff_ledger() group = _Group() worker = MultiTextInquiryFlow(ledger, group_client=group) wo = self._open_wait_collab(worker, "sales-card") http = MultiTextInquiryFlow(ledger, group_client=group) self.assertIsNone(http.session_of("sales-card")) result = handle_card_action( sender_id="sales-card", thread_id="", wait_version=0, action=copy.BTN_PULL_COLLAB, payload={"task_id": f"multi_pull-{wo}-x", "event_key": f"pull_collab:{wo}"}, flow=http, store=MemoryMessageStore(), ) self.assertEqual(result, "collab_group") self.assertEqual(len(group.created), 1) self.assertIn("sea1", group.created[0][1]) self.assertIn("land1", group.created[0][1]) def test_pick_card_flow_uses_multi_not_air(self) -> None: """点卡默认空运会把 MULTI 收回成 tms_miss;必须先认多段。""" from agent.handlers.card_action import _pick_card_flow from agent.policy.multi_text_flow import get_multi_text_flow, reset_multi_text_flow_for_test flow = reset_multi_text_flow_for_test() ledger = _staff_ledger() flow._ledger = ledger group = _Group() flow._group_client = group wo = self._open_wait_collab(flow, "sales-pick") flow._by_sender.clear() flow._by_ticket.clear() picked = _pick_card_flow( "sales-pick", {"task_id": f"multi_pull-{wo}-y", "event_key": f"pull_collab:{wo}"}, ) self.assertIs(picked, get_multi_text_flow()) sess = picked.session_for_action( "sales-pick", {"task_id": f"multi_pull-{wo}-y", "event_key": f"pull_collab:{wo}"}, ) self.assertIsNotNone(sess) self.assertEqual((sess.business_line or "").upper(), "MULTI") self.assertEqual(sess.phase, "wait_collab") class DispatchClarifyTests(unittest.TestCase): def test_multi_segment_intent_is_text_inquiry(self) -> None: from agent.routing.dispatch import HANDLER_REGISTRY, _wrap_text_inquiry self.assertIs(HANDLER_REGISTRY["multi_segment_transport"], _wrap_text_inquiry) def test_clarify_reply_stays_on_multi_even_if_mentions_air(self) -> None: from agent.handlers.text_inquiry import _pick_text_flow from agent.policy.multi_text_flow import get_multi_text_flow flow = get_multi_text_flow() replies: list[str] = [] phase = flow.on_text( sender_id="wu-clarify", text="段1:陆运\n段2:空运", reply=lambda t, extra=None: replies.append(t) or "ok", ) self.assertEqual(phase, "clarify") picked = _pick_text_flow(sender_id="wu-clarify", text=CLARIFY_REPLY) self.assertIs(picked, flow) self.assertEqual((flow.session_of("wu-clarify").phase or ""), "clarify") def test_manual_group_bookmark_does_not_steal_new_land_sea(self) -> None: from agent.handlers.text_inquiry import _pick_text_flow, handle_text_inquiry from agent.channel.wecom.models import InboundMessage from agent.policy.multi_text_flow import reset_multi_text_flow_for_test flow = reset_multi_text_flow_for_test() replies: list[str] = [] flow.on_text( sender_id="wu-attach", text=SEA_AIR_READY, reply=lambda t, extra=None: replies.append(t) or "ok", ) flow.on_text( sender_id="wu-attach", text="确定", reply=lambda t, extra=None: replies.append(t) or "ok", ) self.assertEqual((flow.session_of("wu-attach").phase or ""), "wait_manual_group") old_wo = flow.session_of("wu-attach").work_order_no picked = _pick_text_flow( sender_id="wu-attach", text="段1:陆运\n运输类型:国内运输拼车\n段2:海运\n起运港:宁波", ) self.assertIs(picked, flow) class _Store: def __init__(self) -> None: self.texts: list[str] = [] def enqueue_outbound(self, **kwargs: object) -> tuple[bool, str]: self.texts.append(str(kwargs.get("content") or "")) return True, "ok" store = _Store() phase = handle_text_inquiry( InboundMessage( sender_id="wu-attach", message_id="attach-land-sea-1", content="段1:陆运\n运输类型:国内运输拼车\n段2:海运\n起运港:宁波港\n目的港:鹿特丹", ), store=store, ) self.assertNotEqual(phase, "wait_manual_group") self.assertTrue(store.texts) self.assertNotIn(old_wo, store.texts[-1]) self.assertNotIn("请手动拉相关人员和智能体", store.texts[-1]) def test_spoken_land_after_manual_group_leaves_multi(self) -> None: from agent.handlers.text_inquiry import _pick_text_flow from agent.policy.land_text_flow import get_land_text_flow from agent.policy.multi_text_flow import reset_multi_text_flow_for_test flow = reset_multi_text_flow_for_test() replies: list[str] = [] flow.on_text( sender_id="wu-land", text=SEA_AIR_READY, reply=lambda t, extra=None: replies.append(t) or "ok", ) flow.on_text( sender_id="wu-land", text="确定", reply=lambda t, extra=None: replies.append(t) or "ok", ) self.assertEqual((flow.session_of("wu-land").phase or ""), "wait_manual_group") picked = _pick_text_flow(sender_id="wu-land", text="陆运 国内运输拼车") self.assertIs(picked, get_land_text_flow()) self.assertIsNone(flow.session_of("wu-land")) if __name__ == "__main__": unittest.main()