必填核对后再建单;多条线路走 H5 点选;费用只展示该线路 TMS 回包,不套用整单空运费。 Co-authored-by: Cursor <cursoragent@cursor.com>
332 lines
13 KiB
Python
332 lines
13 KiB
Python
"""
|
||
口述空运抽字段:解析 DeepSeek B JSON,不打外网。
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import os
|
||
import sys
|
||
import unittest
|
||
from types import SimpleNamespace
|
||
|
||
ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))
|
||
if ROOT not in sys.path:
|
||
sys.path.insert(0, ROOT)
|
||
|
||
os.environ.setdefault("LEDGER_BACKEND", "memory")
|
||
|
||
from agent.llm.extract_text import extract_inquiry_snapshot, parse_labeled_facts
|
||
from agent.llm.mode_extract_fields import invoke_shell, parse_extract_payload
|
||
from agent.policy.air_text_flow import AirTextInquiryFlow
|
||
from agent.ledger.memory_ledger import MemoryLedger
|
||
|
||
ORAL = "上海到洛杉矶空运,3件 180kg,体积2.5,普货纸箱"
|
||
|
||
B_JSON = {
|
||
"transport_mode": "AIR",
|
||
"fields": {
|
||
"起运港": "上海",
|
||
"目的港": "洛杉矶",
|
||
"品名": "普货",
|
||
"件数": "3",
|
||
"毛重": "180",
|
||
"体积": "2.5",
|
||
"包装方式": "纸箱",
|
||
"报价日期": "",
|
||
},
|
||
}
|
||
|
||
|
||
class OralExtractTests(unittest.TestCase):
|
||
def test_parse_b_json(self) -> None:
|
||
parsed = parse_extract_payload(B_JSON)
|
||
self.assertTrue(parsed["ok"])
|
||
self.assertEqual(parsed["business_line"], "AIR")
|
||
self.assertEqual(parsed["facts"]["起运港"], "上海")
|
||
self.assertNotIn("报价日期", parsed["facts"])
|
||
|
||
def test_invoke_shell_with_json(self) -> None:
|
||
out = invoke_shell(payload={"assistant_json": B_JSON})
|
||
self.assertTrue(out["ok"])
|
||
self.assertEqual(out["facts"]["目的港"], "洛杉矶")
|
||
|
||
def test_invoke_shell_without_json_keeps_legacy(self) -> None:
|
||
out = invoke_shell(payload={"a": 1})
|
||
self.assertEqual(out.get("error"), "extract_fields_shell_not_implemented")
|
||
|
||
def test_oral_snapshot_uses_b_not_regex(self) -> None:
|
||
snap = extract_inquiry_snapshot(ORAL, b_result=parse_extract_payload(B_JSON))
|
||
self.assertEqual(snap["business_line"], "AIR")
|
||
self.assertEqual(snap["facts"]["件数"], "3件")
|
||
self.assertEqual(snap["source"], "deepseek_b")
|
||
|
||
def test_oral_without_b_does_not_guess_ports(self) -> None:
|
||
snap = extract_inquiry_snapshot(ORAL)
|
||
self.assertEqual(snap["business_line"], "AIR")
|
||
self.assertFalse(snap["facts"].get("起运港"))
|
||
|
||
def test_labeled_customs_port(self) -> None:
|
||
self.assertEqual(parse_labeled_facts("通关口岸:皇岗").get("通关口岸"), "皇岗")
|
||
snap = extract_inquiry_snapshot("通关口岸(非必填):皇岗")
|
||
self.assertEqual(snap["facts"].get("通关口岸"), "皇岗")
|
||
snap2 = extract_inquiry_snapshot("通关口岸皇岗")
|
||
self.assertEqual(snap2["facts"].get("通关口岸"), "皇岗")
|
||
|
||
def test_b_keeps_land_customs_port(self) -> None:
|
||
parsed = parse_extract_payload(
|
||
{
|
||
"transport_mode": "LAND",
|
||
"fields": {"起运港": "广州", "通关口岸": "皇岗"},
|
||
}
|
||
)
|
||
self.assertEqual(parsed["facts"].get("通关口岸"), "皇岗")
|
||
|
||
def test_assemble_air_query_zhuhai_clark(self) -> None:
|
||
from agent.schema.tms_air_query import assemble_air_query
|
||
|
||
out = assemble_air_query(
|
||
{
|
||
"起运港": "珠海",
|
||
"目的港": "克拉克",
|
||
"品名": "茶叶",
|
||
"件数": "10件",
|
||
"毛重": "100KGS",
|
||
"体积": "2CBM",
|
||
"包装方式": "托盘",
|
||
"报价日期": "2026-09-12",
|
||
}
|
||
)
|
||
self.assertTrue(out["ok"])
|
||
self.assertEqual(out["facts"]["originCode"], "ZUH")
|
||
self.assertEqual(out["facts"]["destinationCode"], "CRK")
|
||
self.assertEqual(out["facts"]["packageType"], "托盘")
|
||
self.assertEqual(out["facts"]["件数"], "10件")
|
||
|
||
bulk = assemble_air_query(
|
||
{
|
||
"起运港": "珠海",
|
||
"目的港": "克拉克",
|
||
"品名": "茶叶",
|
||
"件数": "10",
|
||
"毛重": "100",
|
||
"体积": "2",
|
||
"包装方式": "散货",
|
||
"报价日期": "2026-09-12",
|
||
}
|
||
)
|
||
self.assertTrue(bulk["ok"])
|
||
self.assertEqual(bulk["facts"]["包装方式"], "散货")
|
||
self.assertNotIn("包装方式", bulk["missing"])
|
||
|
||
def test_fly_route_overwrites_stale_nkg_kul(self) -> None:
|
||
from agent.schema.field_validate import harvest_oral_measures
|
||
from agent.schema.tms_air_query import assemble_air_query
|
||
from agent.policy import inquiry_copy as copy
|
||
|
||
first = harvest_oral_measures(
|
||
"从南京飞吉隆坡,9月14日茶叶,托盘,4.2cbm,1.5KG",
|
||
{},
|
||
)
|
||
self.assertEqual(first["起运港"], "南京")
|
||
self.assertEqual(first["目的港"], "吉隆坡")
|
||
assembled = assemble_air_query(
|
||
{**first, "originCode": "NKG", "destinationCode": "KUL", "件数": "10"}
|
||
)
|
||
card = copy.inquiry_card(work_order_no="WO1", facts=assembled["facts"])
|
||
self.assertIn("起运地:南京", card)
|
||
self.assertIn("目的地:吉隆坡", card)
|
||
self.assertNotIn("起运地:NKG", card)
|
||
self.assertNotIn("目的地:KUL", card)
|
||
second = harvest_oral_measures(
|
||
"北京飞吉隆坡,9月14日茶叶,4.2cbm,1.5KG",
|
||
{**assembled["facts"]},
|
||
)
|
||
again = assemble_air_query(second)
|
||
self.assertEqual(again["facts"]["起运港"], "北京")
|
||
self.assertEqual(again["facts"]["originCode"], "PEK")
|
||
self.assertEqual(again["facts"]["destinationCode"], "KUL")
|
||
shown = copy.inquiry_card(work_order_no="WO2", facts=again["facts"])
|
||
self.assertIn("起运地:北京", shown)
|
||
self.assertNotIn("起运地:NKG", shown)
|
||
self.assertNotIn("起运地:南京", shown)
|
||
|
||
def test_iata_pair_kept_not_rewritten_to_city(self) -> None:
|
||
from agent.schema.field_validate import harvest_oral_measures
|
||
from agent.schema.tms_air_query import assemble_air_query
|
||
from agent.policy import inquiry_copy as copy
|
||
|
||
oral = "空运 SZX-CGK 发光二级管组件 11P 2400KG 20cbm"
|
||
facts = harvest_oral_measures(
|
||
oral,
|
||
{"起运港": "深圳", "目的港": "雅加达", "品名": "发光二级管组件"},
|
||
)
|
||
self.assertEqual(facts["起运港"], "SZX")
|
||
self.assertEqual(facts["目的港"], "CGK")
|
||
assembled = assemble_air_query(
|
||
{
|
||
**facts,
|
||
"件数": "11P",
|
||
"毛重": "2400KG",
|
||
"体积": "20cbm",
|
||
"包装方式": "托盘",
|
||
"报价日期": "2026-09-18",
|
||
}
|
||
)
|
||
self.assertEqual(assembled["facts"]["originCode"], "SZX")
|
||
self.assertEqual(assembled["facts"]["destinationCode"], "CGK")
|
||
card = copy.inquiry_card(work_order_no="WO1", facts=assembled["facts"])
|
||
self.assertIn("起运地:SZX", card)
|
||
self.assertIn("目的地:CGK", card)
|
||
self.assertNotIn("深圳", card)
|
||
self.assertNotIn("雅加达", card)
|
||
|
||
def test_currency_pair_is_not_air_route(self) -> None:
|
||
from agent.schema.field_validate import harvest_oral_measures
|
||
|
||
facts = harvest_oral_measures(
|
||
"空运 货值 USD-CNY 茶叶",
|
||
{"起运港": "上海", "目的港": "洛杉矶"},
|
||
)
|
||
self.assertEqual(facts["起运港"], "上海")
|
||
self.assertEqual(facts["目的港"], "洛杉矶")
|
||
|
||
def test_oral_pack_and_quote_date_from_current_sentence(self) -> None:
|
||
from agent.schema.field_validate import harvest_oral_measures
|
||
|
||
first = harvest_oral_measures(
|
||
"南京飞吉隆坡,9月15日茶叶,托盘,4.2cbm,1.5KG",
|
||
{},
|
||
)
|
||
self.assertEqual(first["包装方式"], "托盘")
|
||
self.assertEqual(first["报价日期"], "2026-09-15")
|
||
self.assertEqual(first["毛重"], "1.5KG")
|
||
self.assertEqual(first["体积"], "4.2cbm")
|
||
second = harvest_oral_measures(
|
||
"京飞吉隆坡,9月16日茶叶,散货,5.2cbm,2.5KG",
|
||
first,
|
||
)
|
||
self.assertEqual(second["包装方式"], "散货")
|
||
self.assertEqual(second["报价日期"], "2026-09-16")
|
||
self.assertEqual(second["毛重"], "2.5KG")
|
||
self.assertEqual(second["体积"], "5.2cbm")
|
||
|
||
def test_clarify_replaces_pallet_with_bulk_and_keeps_quote_date(self) -> None:
|
||
replies: list[str] = []
|
||
|
||
def reply(text: str, extra=None) -> None:
|
||
replies.append(text)
|
||
_ = extra
|
||
|
||
flow = AirTextInquiryFlow(MemoryLedger())
|
||
flow.on_text(
|
||
sender_id="u_date",
|
||
text="南京飞吉隆坡,9月15日茶叶,托盘,4.2cbm,1.5KG",
|
||
reply=reply,
|
||
injected_facts={
|
||
"起运港": "南京",
|
||
"目的港": "吉隆坡",
|
||
"品名": "茶叶",
|
||
"包装方式": "托盘",
|
||
},
|
||
injected_mode="AIR",
|
||
)
|
||
self.assertEqual(flow.session_of("u_date").facts["包装方式"], "托盘")
|
||
self.assertEqual(flow.session_of("u_date").facts["报价日期"], "2026-09-15")
|
||
flow.on_text(
|
||
sender_id="u_date",
|
||
text="京飞吉隆坡,9月16日茶叶,散货,5.2cbm,2.5KG",
|
||
reply=reply,
|
||
injected_facts={
|
||
"起运港": "南京",
|
||
"目的港": "吉隆坡",
|
||
"品名": "茶叶",
|
||
},
|
||
injected_mode="AIR",
|
||
)
|
||
sess = flow.session_of("u_date")
|
||
self.assertEqual(sess.facts["包装方式"], "散货")
|
||
self.assertEqual(sess.facts["报价日期"], "2026-09-16")
|
||
last = replies[-1]
|
||
self.assertIn("包装类型:散货", last)
|
||
self.assertIn("报价日期:2026-09-16", last)
|
||
|
||
def test_weight_keeps_user_kg_not_kgs(self) -> None:
|
||
from agent.policy import inquiry_copy as copy
|
||
from agent.schema.field_validate import harvest_oral_measures
|
||
|
||
facts = harvest_oral_measures("从南京飞吉隆坡,茶叶,1.5KG,4.2cbm,托盘", {})
|
||
self.assertEqual(facts["毛重"], "1.5KG")
|
||
shown = copy.inquiry_card(
|
||
work_order_no="WO1",
|
||
facts={**facts, "起运港": "南京", "目的港": "吉隆坡", "品名": "茶叶", "件数": "10"},
|
||
)
|
||
self.assertIn("重量:1.5KG", shown)
|
||
self.assertNotIn("1.5KGS", shown)
|
||
|
||
def test_oral_units_fill_weight_volume_without_b(self) -> None:
|
||
snap = extract_inquiry_snapshot(
|
||
"珠海到克拉克空运,茶叶 10件 100KGS 2CBM 托盘"
|
||
)
|
||
self.assertEqual(snap["facts"]["件数"], "10件")
|
||
self.assertEqual(snap["facts"]["毛重"], "100KGS")
|
||
self.assertEqual(snap["facts"]["体积"], "2CBM")
|
||
self.assertFalse(snap["facts"].get("起运港"))
|
||
|
||
def test_labeled_weight_alias(self) -> None:
|
||
from agent.llm.extract_text import parse_labeled_facts
|
||
from agent.schema.field_validate import normalize_facts
|
||
|
||
facts = parse_labeled_facts("重量(KG):180\n体积(CBM):2.5")
|
||
norm = normalize_facts(facts)
|
||
self.assertEqual(norm["毛重"], "180")
|
||
self.assertEqual(norm["体积"], "2.5")
|
||
|
||
def test_extract_prompt_keeps_iata(self) -> None:
|
||
from agent.llm.mode_extract_fields import build_extract_messages
|
||
|
||
blob = str(build_extract_messages("空运 SZX-CGK"))
|
||
self.assertIn("SZX-CGK", blob)
|
||
self.assertIn("禁止翻译成城市名", blob)
|
||
|
||
def test_chat_fn_fixture_drives_flow(self) -> None:
|
||
def chat_fn(**kwargs):
|
||
return SimpleNamespace(
|
||
ok=True,
|
||
content="",
|
||
error="",
|
||
tool_calls=[
|
||
{
|
||
"function": {
|
||
"name": "extract_inquiry_fields",
|
||
"arguments": __import__("json").dumps(B_JSON, ensure_ascii=False),
|
||
}
|
||
}
|
||
],
|
||
)
|
||
|
||
replies: list[str] = []
|
||
|
||
def reply(text: str, extra=None) -> None:
|
||
replies.append(text)
|
||
_ = extra
|
||
|
||
flow = AirTextInquiryFlow(MemoryLedger())
|
||
phase = flow.on_text(
|
||
sender_id="oral1",
|
||
text=ORAL,
|
||
reply=reply,
|
||
chat_fn=chat_fn,
|
||
)
|
||
if phase == "wait_confirm":
|
||
phase = flow.on_text(sender_id="oral1", text="确定", reply=reply)
|
||
self.assertEqual(phase, "wait_collab")
|
||
joined = "\n".join(replies)
|
||
self.assertIn("工单号:", joined)
|
||
self.assertIn("起运地:上海", joined)
|
||
self.assertIn("目的地:洛杉矶", joined)
|
||
self.assertNotIn("【询价卡片】", joined)
|
||
|
||
|
||
if __name__ == "__main__":
|
||
unittest.main()
|