落地多段联运私聊:齐了才建单拉群,附件只收格子值,核对改正不再整单重开。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-09-23 15:08:15 +08:00
co-authored by Cursor
parent 6e3e09a316
commit 2334524bb8
30 changed files with 4033 additions and 40 deletions
+818
View File
@@ -0,0 +1,818 @@
"""多段联运:分段、补问、核对建单、按线路拉进同一个群。"""
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.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_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_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("待补充:", text)
self.assertIn("品名:(如:茶叶)", text)
self.assertIn("货源地(非必填):(如:东莞)", text)
self.assertIn("贸易条款(非必填):(如:FOB)", 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("国内长途/零担", 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 "可选择回复" 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("待补充:", card)
self.assertIn("包装类型:(如:", 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()