diff --git a/inquiry-agent/agent/llm/extract_text.py b/inquiry-agent/agent/llm/extract_text.py index 177ebd9..a14d492 100644 --- a/inquiry-agent/agent/llm/extract_text.py +++ b/inquiry-agent/agent/llm/extract_text.py @@ -22,13 +22,13 @@ _MODE_WORDS = ( # 仅「键:值」一行一条,键必须是合同字段或展示名 _LABEL_LINE = re.compile( r"^(起运地|起运港|目的地|目的港|品名|件数|毛重|体积|重量|重量\(KG\)|体积\(CBM\)|" - r"包装方式|包装类型|货量|货物数量|整柜或拼柜|箱型箱量|报价日期|车型/数量|车型数量|" + r"包装方式|包装类型|货量|货物数量|整柜或拼柜|箱型箱量|报价日期|运输分类|车型/数量|车型数量|" r"贸易条款|货好时间|商品海关编码|海关编码|HS编码|HS|是否含油|是否含电|是否含磁|" r"客户名称|货值|是否为危险品|通关口岸(非必填)|通关口岸)\s*[::]\s*(.+)$" ) _LABEL_INLINE = re.compile( r"(起运地|起运港|目的地|目的港|品名|件数|毛重|体积|重量|重量\(KG\)|体积\(CBM\)|" - r"包装方式|包装类型|货量|货物数量|整柜或拼柜|箱型箱量|报价日期|车型/数量|车型数量|" + r"包装方式|包装类型|货量|货物数量|整柜或拼柜|箱型箱量|报价日期|运输分类|车型/数量|车型数量|" r"贸易条款|货好时间|商品海关编码|海关编码|HS编码|HS|是否含油|是否含电|是否含磁|" r"客户名称|货值|是否为危险品|通关口岸(非必填)|通关口岸)\s*[::]\s*(\S+)" ) diff --git a/inquiry-agent/agent/policy/inquiry_copy.py b/inquiry-agent/agent/policy/inquiry_copy.py index 857ecca..3437f8c 100644 --- a/inquiry-agent/agent/policy/inquiry_copy.py +++ b/inquiry-agent/agent/policy/inquiry_copy.py @@ -41,7 +41,7 @@ _AIR_CLARIFY = tuple( (key, label, _AIR_EXAMPLES[key]) for key, label in AIR_REQUIRED_FIELDS ) -# 海运补问:与合同 7 项一致。报价日期默认当天,不进待补充。 +# 海运补问:与合同 10 项一致。报价日期默认当天,不进待补充。 _SEA_EXAMPLES = { "起运港": "上海", "目的港": "洛杉矶", @@ -49,6 +49,8 @@ _SEA_EXAMPLES = { "货量": "20吨", "整柜或拼柜": "整柜", "箱型箱量": "1x40HQ", + "贸易条款": "FOB", + "运输分类": "Port to Port", "报价日期": "2026-09-15", } _SEA_CLARIFY = tuple( diff --git a/inquiry-agent/agent/schema/field_validate.py b/inquiry-agent/agent/schema/field_validate.py index 2798f2b..48cb01d 100644 --- a/inquiry-agent/agent/schema/field_validate.py +++ b/inquiry-agent/agent/schema/field_validate.py @@ -32,7 +32,7 @@ AIR_REQUIRED_FIELDS = ( ) AIR_DISPLAY = {key: label for key, label in AIR_REQUIRED_FIELDS} -# 海运询价必填 7 项:销售侧名称。内部键「货量」对外叫「货物数量」。 +# 海运询价必填 10 项(销售侧含运输方式):内部键「货量」对外叫「货物数量」。 SEA_REQUIRED_FIELDS = ( ("起运港", "起运港"), ("目的港", "目的港"), @@ -40,6 +40,8 @@ SEA_REQUIRED_FIELDS = ( ("货量", "货物数量"), ("整柜或拼柜", "整柜或拼柜"), ("箱型箱量", "箱型箱量"), + ("贸易条款", "贸易条款"), + ("运输分类", "运输分类"), ("报价日期", "报价日期"), ) SEA_DISPLAY = {key: label for key, label in SEA_REQUIRED_FIELDS} @@ -57,7 +59,7 @@ LAND_DISPLAY = { } # 协同补充字段:拉群后可静默写入,不挡首次查价。内部键仍是 HS编码。 -SEA_COLLAB_FIELDS = ("贸易条款", "货好时间", "HS编码", "是否含油", "是否含电", "是否含磁") +SEA_COLLAB_FIELDS = ("货好时间", "HS编码", "是否含油", "是否含电", "是否含磁") # 空运群只补含电/含磁,摘要不要把海运六项搬过去。 AIR_COLLAB_FIELDS = ("是否含电", "是否含磁") # 群摘要对外展示名。内部继续用 HS编码,避免改合同键。 @@ -485,6 +487,13 @@ def validate_required_fields( line = (business_line or "").strip().upper() if line == "SEA": normalized = project_sea_cargo_qty(normalized) + from agent.schema.sea_options import canonicalize_sea_class + + klass = canonicalize_sea_class(str(normalized.get("运输分类") or "")) + if klass: + normalized["运输分类"] = klass + else: + normalized.pop("运输分类", None) if line not in {"SEA", "AIR", "LAND"}: return { "ok": False, diff --git a/inquiry-agent/tests/test_sea_text_inquiry.py b/inquiry-agent/tests/test_sea_text_inquiry.py index 8566709..e0863ae 100644 --- a/inquiry-agent/tests/test_sea_text_inquiry.py +++ b/inquiry-agent/tests/test_sea_text_inquiry.py @@ -29,7 +29,7 @@ from agent.handlers.text_inquiry import handle_text_inquiry from agent.ledger.memory_ledger import MemoryLedger from agent.policy import inquiry_copy as copy from agent.policy.sea_text_flow import SeaTextInquiryFlow, reset_sea_text_flow_for_test -from agent.schema.field_validate import validate_required_fields +from agent.schema.field_validate import SEA_COLLAB_FIELDS, validate_required_fields from agent.schema.tms_sea_query import assemble_sea_query @@ -43,6 +43,15 @@ COMPLETE_SEA = { } +class SeaCollabContractTests(unittest.TestCase): + def test_collab_no_longer_asks_trade_terms(self): + self.assertNotIn("贸易条款", SEA_COLLAB_FIELDS) + self.assertEqual( + SEA_COLLAB_FIELDS, + ("货好时间", "HS编码", "是否含油", "是否含电", "是否含磁"), + ) + + class FakeGroupClient: """单测假企微建群:不打外网。""" @@ -123,6 +132,8 @@ class SeaTextInquiryTests(unittest.TestCase): "件数": "15件", "整柜或拼柜": "整柜", "箱型箱量": "1x40HQ", + "贸易条款": "FOB", + "运输分类": "Port to Port", } check = validate_required_fields(facts=facts, business_line="SEA") self.assertTrue(check["ok"]) @@ -136,6 +147,8 @@ class SeaTextInquiryTests(unittest.TestCase): "品名": "茶叶", "货量": "20吨", "整柜或拼柜": "整柜", + "贸易条款": "FOB", + "运输分类": "Port to Port", } check = validate_required_fields(facts=facts, business_line="SEA") self.assertFalse(check["ok"]) @@ -157,6 +170,52 @@ class SeaTextInquiryTests(unittest.TestCase): self.assertNotIn("报价日期:(如", text) self.assertIn("报价日期:", text) + def test_sea_requires_trade_terms_and_class(self) -> None: + facts = { + "起运港": "上海", + "目的港": "洛杉矶", + "品名": "普货", + "货量": "20吨", + "整柜或拼柜": "整柜", + "箱型箱量": "1x40HQ", + } + check = validate_required_fields(facts=facts, business_line="SEA") + self.assertFalse(check["ok"]) + self.assertIn("贸易条款", check["missing"]) + self.assertIn("运输分类", check["missing"]) + self.assertIn("贸易条款", check["missing_display"]) + self.assertIn("运输分类", check["missing_display"]) + + def test_sea_rejects_door_to_door_class(self) -> None: + facts = { + "起运港": "上海", + "目的港": "洛杉矶", + "品名": "普货", + "货量": "20吨", + "整柜或拼柜": "整柜", + "箱型箱量": "1x40HQ", + "贸易条款": "FOB", + "运输分类": "门到门", + } + check = validate_required_fields(facts=facts, business_line="SEA") + self.assertFalse(check["ok"]) + self.assertIn("运输分类", check["missing"]) + + def test_sea_canonicalizes_p2p(self) -> None: + facts = { + "起运港": "上海", + "目的港": "洛杉矶", + "品名": "普货", + "货量": "20吨", + "整柜或拼柜": "整柜", + "箱型箱量": "1x40HQ", + "贸易条款": "到岸", + "运输分类": "p2p", + } + check = validate_required_fields(facts=facts, business_line="SEA") + self.assertTrue(check["ok"]) + self.assertEqual(check["facts"]["运输分类"], "Port to Port") + def test_complete_sea_inquiry_card_uses_seven_fields(self) -> None: phase = self.flow.on_text( sender_id="s1",