确定运输方式前的多句询价带到所选方式并按该方式抽字段;成交已定后放舱不再推跟进,未成交原因不再被当成新询价。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-10-03 09:54:33 +08:00
co-authored by Cursor
parent d989154485
commit 0a14b4cc15
18 changed files with 620 additions and 28 deletions
@@ -963,6 +963,7 @@ class AirGroupCollabTests(unittest.TestCase):
ledger=self.ledger,
injected_intent="deal_lost",
)
before = len(self._texts())
phase = handle_air_group(
self._msg(sender="air1", text=f"{wo}释放舱位", chat="wr_air_1", mid="r2"),
flow=self.flow,
@@ -970,6 +971,9 @@ class AirGroupCollabTests(unittest.TestCase):
injected_intent="air_release",
)
self.assertEqual(phase, "release_ok")
added = "\n".join(self._texts()[before:])
self.assertIn("释放", added)
self.assertNotIn("请确认成交状态", added)
def test_sales_cannot_lock(self) -> None:
wo = self._create_air()
@@ -230,6 +230,41 @@ class ContinueTicketTests(unittest.TestCase):
self.assertEqual(phase, "wait_deal")
self.assertFalse(any("成交" in x or copy.BTN_DEAL in x for x in replies))
def test_resume_spoken_deal_closes(self) -> None:
"""报价文件已出后,原话写工单号加已成交,要入账并回复,不能没反应。"""
ledger = MemoryLedger()
created = ledger.create_ticket(
sender_id="u6b", business_line="AIR", facts=COMPLETE_AIR
)
wo = created["work_order_no"]
ledger.upsert_quote(
work_order_no=wo,
quote={"total": "USD 1", "eta": "-", "valid_until": "-"},
to_status="已报价",
)
ledger.render_quote(
work_order_no=wo,
business_line="AIR",
facts=COMPLETE_AIR,
quote={"total": "USD 1"},
)
flow = AirTextInquiryFlow(ledger)
replies: list[str] = []
def reply(text: str, extra=None) -> None:
replies.append(text)
_ = extra
phase = flow.resume_historical(
sender_id="u6b",
work_order_no=wo,
reply=reply,
text=f"{wo}已成交",
)
self.assertEqual(phase, "done")
self.assertTrue(any("已记录为「已成交」" in x for x in replies))
self.assertIn(wo, "\n".join(replies))
def test_resume_missing_wo_asks(self) -> None:
flow = AirTextInquiryFlow(MemoryLedger())
replies: list[str] = []
+12
View File
@@ -70,6 +70,18 @@ class OralExtractTests(unittest.TestCase):
self.assertEqual(snap["business_line"], "AIR")
self.assertFalse(snap["facts"].get("起运港"))
def test_bare_dap_is_trade_term(self) -> None:
"""句尾单独的 DAP 记成贸易条款,不依赖模型有没有这个字段。"""
snap = extract_inquiry_snapshot("查一下空运,出货日期9月30日广州到香港的普货价格,DAP")
self.assertEqual(snap["business_line"], "AIR")
self.assertEqual(snap["facts"].get("贸易条款"), "DAP")
def test_trade_term_example_line_is_not_filled(self) -> None:
snap = extract_inquiry_snapshot("贸易条款(非必填):\n(如:DDP、DAP、CIF、CIP)")
self.assertFalse(snap["facts"].get("贸易条款"))
labeled = extract_inquiry_snapshot("贸易条款:CIF\n(如:DDP、DAP、CIF、CIP)")
self.assertEqual(labeled["facts"].get("贸易条款"), "CIF")
def test_labeled_customs_port(self) -> None:
self.assertEqual(parse_labeled_facts("通关口岸:皇岗").get("通关口岸"), "皇岗")
snap = extract_inquiry_snapshot("通关口岸(非必填):皇岗")
+142
View File
@@ -414,6 +414,148 @@ class LandDispatchTests(unittest.TestCase):
self.assertNotIn("你好,我是询价机器人", last)
self.assertNotIn("请直接描述客户需求", last)
def test_lost_reason_not_taken_as_new_inquiry(self) -> None:
"""未成交后下一句是原因。旧报价单被判成调价时,不能改问运输方式。"""
from agent.policy.air_text_flow import FlowSession
sea = get_sea_text_flow()
created = sea._ledger.create_ticket(
sender_id="lost-1",
business_line="SEA",
facts={"起运港": "广州港", "目的港": "巴生港", "品名": "机器设备"},
)
wo = str(created.get("work_order_no") or "")
sea._ledger.transition(work_order_no=wo, to_status="未成交")
sea._save(
FlowSession(
sender_id="lost-1",
thread_id="lost-1",
phase="wait_lost_reason",
business_line="SEA",
work_order_no=wo,
status="未成交",
wait_version=2,
)
)
get_land_text_flow()._save(
FlowSession(
sender_id="lost-1",
thread_id="lost-1",
phase="wait_deal",
business_line="LAND",
work_order_no="WO202609240045",
status="已报价",
quote={"total": "CNY 1"},
)
)
phase = handle_text_inquiry(
InboundMessage(sender_id="lost-1", message_id="m-lost", content="客户不需要了"),
prefer_quoted=True,
store=self.store,
)
self.assertEqual(phase, "done")
body = self._outbox_text()
self.assertIn("已记下未成交原因:客户不需要了", body)
self.assertNotIn("告知我询价的运输方式", body)
def test_several_turns_before_mode_follow_the_chosen_line(self) -> None:
"""定运输方式前连续几句询价字段都要留下,选定后再整份带上。不只认上一句。"""
from agent.policy.air_text_flow import FlowSession, get_air_text_flow
turns = (
(
"a",
"10月1日,深圳-吉隆坡,散货、普货报价",
{
"起运港": "深圳",
"目的港": "吉隆坡",
"品名": "普货",
"包装方式": "散货",
"报价日期": "2026-10-01",
},
),
(
"b",
"10件,毛重100公斤,体积2方",
{"件数": "10", "毛重": "100", "体积": "2"},
),
)
for label, reply, mode in (
("空运", "3", "AIR"),
("海运", "海运", "SEA"),
("陆运", "1", "LAND"),
):
reset_sea_text_flow_for_test()
reset_land_text_flow_for_test()
reset_air_text_flow_for_test()
sid = "premode-" + mode
# 上一单还在海运,新的几句里没有运输方式,不能把这些字段丢掉。
get_sea_text_flow()._save(
FlowSession(
sender_id=sid,
thread_id=sid,
phase="tms_miss",
business_line="SEA",
work_order_no="WO-OLD",
facts={"目的港": "旧港口"},
)
)
for mid, content, facts in turns:
phase = handle_text_inquiry(
InboundMessage(sender_id=sid, message_id=sid + mid, content=content),
injected_facts=facts,
injected_mode="",
store=self.store,
)
self.assertEqual(phase, "need_mode", label)
waiting = get_sea_text_flow().session_of(sid)
self.assertEqual(waiting.facts.get("目的港"), "吉隆坡", label)
self.assertIn("10", waiting.facts.get("件数") or "", label)
self.assertIn("深圳-吉隆坡", waiting.immutable_text)
self.assertIn("10件", waiting.immutable_text)
before = len(self.store._outbox)
phase = handle_text_inquiry(
InboundMessage(sender_id=sid, message_id=sid + "mode", content=reply),
store=self.store,
)
self.assertNotEqual(phase, "need_mode", label)
added = "\n".join(i.content for i in list(self.store._outbox.values())[before:])
owner = {
"AIR": get_air_text_flow(),
"SEA": get_sea_text_flow(),
"LAND": get_land_text_flow(),
}[mode]
sess = owner.session_of(sid)
self.assertIsNotNone(sess, label)
self.assertEqual((sess.business_line or "").upper(), mode, label)
self.assertEqual(sess.facts.get("起运港"), "深圳", label)
self.assertEqual(sess.facts.get("目的港"), "吉隆坡", label)
self.assertEqual(sess.facts.get("品名"), "普货", label)
self.assertEqual(sess.facts.get("报价日期"), "2026-10-01", label)
self.assertNotEqual(sess.facts.get("目的港"), "旧港口", label)
if mode == "AIR":
self.assertIn("10", sess.facts.get("件数") or "", label)
self.assertNotIn("整柜或拼柜", sess.facts, label)
self.assertNotIn("箱型箱量", sess.facts, label)
elif mode == "SEA":
self.assertIn("10", sess.facts.get("货量") or "", label)
self.assertNotIn("件数", sess.facts, label)
self.assertNotIn("毛重", sess.facts, label)
self.assertNotIn("包装方式", sess.facts, label)
self.assertIn("货物数量", added)
self.assertIn("整柜或拼柜", added)
self.assertIn("箱型箱量", added)
self.assertIn("贸易条款", added)
self.assertNotIn("件数(必填)", added)
else:
self.assertIn("100", sess.facts.get("毛重") or "", label)
self.assertIn("2", sess.facts.get("体积") or "", label)
self.assertNotIn("件数", sess.facts, label)
self.assertNotIn("箱型箱量", sess.facts, label)
self.assertNotIn("贸易条款", sess.facts, label)
self.assertIn("运输类型", added)
self.assertNotIn("件数(必填)", added)
if __name__ == "__main__":
unittest.main()
+11
View File
@@ -60,6 +60,17 @@ class SeaOptionsTest(unittest.TestCase):
kept = drop_invented_door_trade_terms("贸易条款:门到门", {"贸易条款": "门到门"})
self.assertEqual(kept["贸易条款"], "门到门")
def test_short_reply_door_or_port_is_trade_term(self):
from agent.schema.sea_options import apply_spoken_sea_trade_reply
door = apply_spoken_sea_trade_reply("门到门", {})
self.assertEqual(door.get("贸易条款"), "门到门")
port = apply_spoken_sea_trade_reply("港到港", {"运输分类": "Port to Port"})
self.assertEqual(port.get("贸易条款"), "港到港")
self.assertEqual(port.get("运输分类"), "Port to Port")
example = apply_spoken_sea_trade_reply("(如:门到门、港到港)", {})
self.assertNotIn("贸易条款", example)
def test_wo012_trade_port_class_other(self):
"""条款写 Port to Port、分类写其它:二者各归各栏,禁止互盖。"""
text = (
@@ -206,6 +206,13 @@ class SeaTextInquiryTests(unittest.TestCase):
self.assertIn("运输分类", check["missing"])
self.assertIn("贸易条款", check["missing_display"])
self.assertIn("运输分类", check["missing_display"])
text = copy.ask_clarify(
facts=check["facts"],
missing_keys=check["missing"],
transport_mode="海运",
)
self.assertIn("贸易条款(必填):\n(如:门到门、港到港)", text)
self.assertNotIn("如:FOB", text)
def test_sea_rejects_door_to_door_class(self) -> None:
facts = {
@@ -324,6 +331,29 @@ class SeaTextInquiryTests(unittest.TestCase):
self.assertIn("贸易条款", joined)
self.assertNotEqual(self.flow.session_of("s-door").facts.get("贸易条款"), "门到门")
def test_reply_door_to_door_fills_trade_terms(self) -> None:
"""补问后只回门到门,要记成贸易条款,不能回参数提取不全。"""
first = dict(COMPLETE_SEA)
first.pop("贸易条款")
phase = self.flow.on_text(
sender_id="s-door-reply",
text="先缺条款",
reply=self._reply,
injected_facts=first,
injected_mode="SEA",
)
self.assertEqual(phase, "clarify")
self.replies.clear()
phase = self.flow.on_text(
sender_id="s-door-reply",
text="门到门",
reply=self._reply,
)
self.assertEqual(phase, "wait_collab")
joined = "\n".join(self.replies)
self.assertIn("贸易条款:门到门", joined)
self.assertNotIn("参数提取不全", joined)
def test_harvest_class_from_reply_then_quotes(self) -> None:
first = dict(COMPLETE_SEA)
first.pop("运输分类")