Files
inquiry_robot/inquiry-agent/tests/test_extract_oral.py
T

538 lines
22 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.
"""
口述空运抽字段:解析 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 (
detect_transport_mode,
extract_inquiry_snapshot,
parse_labeled_facts,
)
from agent.schema.field_validate import normalize_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_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("通关口岸(非必填):皇岗")
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_place_stops_before_cargo_and_price(self) -> None:
"""目的地只留地名。后面的品名、包装、的价格不能粘在地名上。"""
from agent.schema.field_validate import validate_required_fields
check = validate_required_fields(
facts={
"目的港": "吉隆坡,普货,散货的价格",
"品名": "普货",
"包装方式": "散货",
"报价日期": "2026-09-29",
},
business_line="AIR",
)
self.assertEqual(check["facts"]["目的港"], "吉隆坡")
self.assertEqual(check["facts"]["品名"], "普货")
self.assertEqual(check["facts"]["包装方式"], "散货")
bare = validate_required_fields(
facts={"目的港": "洛杉矶,茶叶,纸箱的价格"},
business_line="AIR",
)
self.assertEqual(bare["facts"]["目的港"], "洛杉矶")
self.assertEqual(bare["facts"]["品名"], "茶叶")
self.assertEqual(bare["facts"]["包装方式"], "纸箱")
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("重量(KG):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)
def test_mode_index_maps_land_sea_air(self) -> None:
self.assertEqual(detect_transport_mode("1"), "LAND")
self.assertEqual(detect_transport_mode("2"), "SEA")
self.assertEqual(detect_transport_mode("3"), "AIR")
self.assertEqual(detect_transport_mode("1.陆运"), "LAND")
self.assertEqual(detect_transport_mode("空运"), "AIR")
self.assertEqual(detect_transport_mode("10件"), "")
self.assertEqual(detect_transport_mode("1.件数(必填):10"), "")
class ClarifyPasteTests(unittest.TestCase):
def test_paste_numbered_required_lines_and_skip_examples(self) -> None:
"""复制补问清单:只收冒号后的值,示例行和空项都不算填了。"""
text = """
1.件数(必填):10
(如:10)
2.重量(KG)(必填):10
(如:100)
3.体积(CBM)(必填):10
(如:2)
4.包装类型(必填):
(如:托盘、散货)
"""
facts = normalize_facts(parse_labeled_facts(text))
self.assertEqual(facts.get("件数"), "10")
self.assertEqual(facts.get("毛重"), "10")
self.assertEqual(facts.get("体积"), "10")
self.assertNotIn("包装方式", facts)
self.assertNotEqual(facts.get("包装方式"), "托盘、散货")
class ImageOptionalClipTests(unittest.TestCase):
def test_numbered_extra_lines_do_not_enter_fields(self) -> None:
oral = """
运输方式:空运
目的地:印尼巴淡
件数:1PLT
重量:55.8KGS
体积:1.23CBM
包装类型:托盘
报价日期:2026-09-23
起运地:深圳坪山保税仓
货好时间:今天货好
4. 希望送到目的地工厂时间: 尽快
5.
提货地址:深圳市坪山区龙田街道大工业区绿荫北路7号601、
602、603 (保税仓库)
6. 送货地址: PT SAT NUSAPERSADA TBK----JL. PELITA VI NO.99
"""
snap = extract_inquiry_snapshot(oral, allow_b=False)
facts = snap["facts"]
self.assertEqual(facts.get("货好时间"), "今天货好")
self.assertIn("坪山区", facts.get("提货地址") or "")
self.assertNotIn("希望送到", facts.get("货好时间") or "")
self.assertNotIn("希望送到", facts.get("提货地址") or "")
self.assertNotIn("送货地址", facts.get("提货地址") or "")
self.assertIn("PT SAT", facts.get("收货地址") or "")
from agent.policy import inquiry_copy as copy
card = copy.ask_clarify(
facts=facts,
missing_keys=["品名"],
transport_mode="空运",
)
self.assertNotIn("希望送到", card)
self.assertNotIn("4. 希望送到", card)
self.assertIn("收货地址", card)
# 测服手工海运报价单:表头和格子在同一行,还带 POL/POD/Terms。
SEA_SHEET_ORAL = """
起运港/POL(有指定起运港口的必填) 纽约港
*品名/Commodity: 机器设备 目的港/POD(有指定目的港口的必填) 巴生西港
*货物数量/Quantity (或提供装箱单/Packing List) 18
整柜或拼柜:FCL
箱型箱量:40HQ*1
贸易条款/Terms: CIF 要求运输时效/Requested Transit Time
运输分类:其他
报价日期:2026-09-07
"""
class SeaSheetHeaderClipTests(unittest.TestCase):
def test_quote_sheet_headers_do_not_stick_to_values(self) -> None:
"""报价单表头不能进补问:起运港只要纽约港,贸易条款只要 CIF。"""
snap = extract_inquiry_snapshot(SEA_SHEET_ORAL, allow_b=False)
facts = snap["facts"]
self.assertEqual(facts.get("起运港"), "纽约港")
self.assertEqual(facts.get("目的港"), "巴生西港")
self.assertEqual(facts.get("品名"), "机器设备")
self.assertEqual(str(facts.get("货量") or facts.get("货物数量") or ""), "18")
self.assertEqual(facts.get("整柜或拼柜"), "FCL")
self.assertEqual(facts.get("箱型箱量"), "40HQ*1")
self.assertEqual(facts.get("贸易条款"), "CIF")
self.assertNotIn("必填", facts.get("起运港") or "")
self.assertNotIn("Terms", facts.get("贸易条款") or "")
self.assertNotIn("Commodity", facts.get("品名") or "")
self.assertNotIn("时效", facts.get("贸易条款") or "")
from agent.policy import inquiry_copy as copy
card = "\n".join(
copy.inquiry_field_lines(
work_order_no="",
facts=facts,
transport_mode="海运",
)
)
self.assertIn("纽约港", card)
self.assertNotIn("口的必填", card)
self.assertNotIn("Commodity", card)
self.assertNotIn("Requested Transit", card)
def test_next_column_header_is_not_the_value(self) -> None:
"""
手工海运单同一行是「字段、格子、下一列表头」。
目的港只要巴生西港,不能收成费用汇总/POD Local Charges。
"""
oral = """
起运港/POL(有指定起运港口的必填) 纽约港 目的港/POD(有指定目的港口的必填) 巴生西港 费用汇总/POD Local Charges
*品名/Commodity: 机器设备 *货物数量/Quantity (或提供装箱单/Packing List) 18 提货地址/Place of Receipt
整柜或拼柜:FCL 箱型箱量:40HQ*1 送货地址/Place of Delivery
贸易条款/Terms: CIF 要求货物抵达时间/Requested Arrival Time
运输分类:其他
报价日期:2026-09-07
"""
facts = extract_inquiry_snapshot(oral, allow_b=False)["facts"]
self.assertEqual(facts.get("起运港"), "纽约港")
self.assertEqual(facts.get("目的港"), "巴生西港")
self.assertEqual(facts.get("品名"), "机器设备")
self.assertEqual(str(facts.get("货量") or facts.get("货物数量") or ""), "18")
self.assertEqual(facts.get("整柜或拼柜"), "FCL")
self.assertEqual(facts.get("箱型箱量"), "40HQ*1")
self.assertEqual(facts.get("贸易条款"), "CIF")
self.assertNotIn("费用汇总", facts.get("目的港") or "")
self.assertNotIn("提货地址", facts.get("货量") or facts.get("货物数量") or "")
self.assertNotIn("送货地址", facts.get("整柜或拼柜") or "")
self.assertNotIn("抵达", facts.get("箱型箱量") or "")
def test_quote_date_synonyms_for_sea_land_air(self) -> None:
"""询价/走货/发货/出货/提货的日期或时间,海运陆运空运都记成报价日期。"""
from agent.schema.field_validate import QUOTE_DATE_SYNONYMS, normalize_facts
modes = (("海运", "SEA"), ("陆运", "LAND"), ("空运", "AIR"))
for label in QUOTE_DATE_SYNONYMS:
self.assertEqual(normalize_facts({label: "2026-09-23"})["报价日期"], "2026-09-23")
for word, mode in modes:
snap = extract_inquiry_snapshot(f"{word}\n{label}:2026-09-23", allow_b=False)
self.assertEqual(snap["business_line"], mode, label)
self.assertEqual(snap["facts"].get("报价日期"), "2026-09-23", label)
self.assertNotIn(label, snap["facts"])
def test_quote_date_synonym_does_not_steal_ready_or_pickup(self) -> None:
"""货好时间、提货地址仍是原字段;同义词日期不被前面的日期抢走。"""
snap = extract_inquiry_snapshot(
"海运\n货好时间:9月20日\n提货地址:上海仓库\n询价日期:2026-09-15",
allow_b=False,
)
facts = snap["facts"]
self.assertEqual(facts.get("报价日期"), "2026-09-15")
self.assertEqual(facts.get("货好时间"), "9月20日")
self.assertEqual(facts.get("提货地址"), "上海仓库")
oral = extract_inquiry_snapshot("空运 发货日期9月15日", allow_b=False)
self.assertEqual(oral["facts"].get("报价日期"), "2026-09-15")
if __name__ == "__main__":
unittest.main()