Files
inquiry_robot/inquiry-agent/tests/test_multi_segments.py
T
jillion886andCursor 4dfd756bcf 销售侧汇总统一叫费用合计,并认准空运费的金额。
企微跳转改为账号密码登录,报价卡上的费用按原金额改回;航线按单价数量金额报价时不再记成海运费。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-24 11:15:33 +08:00

840 lines
34 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""多段联运:分段、补问、核对建单、按线路拉进同一个群。"""
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_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()