From 37278aa0dbe2f4a417140de2041565b7e5c104b8 Mon Sep 17 00:00:00 2001 From: jillion <439912596@qq.com> Date: Thu, 24 Sep 2026 16:17:23 +0800 Subject: [PATCH] =?UTF-8?q?=E7=9B=AE=E7=9A=84=E5=9C=B0=E5=8F=AA=E7=95=99?= =?UTF-8?q?=E5=9C=B0=E5=90=8D=EF=BC=8C=E8=B4=A7=E7=89=A9=E6=95=B0=E9=87=8F?= =?UTF-8?q?=E4=B8=8D=E5=86=8D=E7=94=A8=E7=AE=B1=E5=9E=8B=EF=BC=8C=E6=9C=AA?= =?UTF-8?q?=E6=88=90=E4=BA=A4=E5=8E=9F=E5=9B=A0=E5=8E=BB=E6=8E=89=E8=89=BE?= =?UTF-8?q?=E7=89=B9=E3=80=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 口语里逗号后面的品名和包装不再粘在目的地上;件数被抽成箱型时改用件数;群里艾特机器人不记进未成交原因。 Co-authored-by: Cursor --- inquiry-agent/agent/policy/air_text_flow.py | 2 +- inquiry-agent/agent/policy/deal_outcome.py | 20 ++++++- inquiry-agent/agent/policy/sea_group_ops.py | 2 +- inquiry-agent/agent/schema/field_validate.py | 59 ++++++++++++++++++-- inquiry-agent/tests/test_deal_outcome.py | 4 ++ inquiry-agent/tests/test_extract_oral.py | 24 ++++++++ inquiry-agent/tests/test_sea_text_inquiry.py | 17 ++++++ 7 files changed, 119 insertions(+), 9 deletions(-) diff --git a/inquiry-agent/agent/policy/air_text_flow.py b/inquiry-agent/agent/policy/air_text_flow.py index 8e9266d..21f012a 100644 --- a/inquiry-agent/agent/policy/air_text_flow.py +++ b/inquiry-agent/agent/policy/air_text_flow.py @@ -1709,7 +1709,7 @@ class AirTextInquiryFlow: sess.phase = "done" sess.allowed = () self._save(sess) - reply(copy.lost_reason_ack(text)) + reply(copy.lost_reason_ack(str(result.get("lost_reason") or text))) return "done" def mark_wait_deal(self, sender_id: str, work_order_no: str) -> None: diff --git a/inquiry-agent/agent/policy/deal_outcome.py b/inquiry-agent/agent/policy/deal_outcome.py index 7afbb08..1af0cac 100644 --- a/inquiry-agent/agent/policy/deal_outcome.py +++ b/inquiry-agent/agent/policy/deal_outcome.py @@ -8,6 +8,7 @@ from __future__ import annotations import logging +import re from typing import Any, Callable, Optional from agent.config import get_settings @@ -22,6 +23,14 @@ EnqueueFn = Callable[..., tuple[bool, str]] _LOST_MARKERS = ("未成交", "没成交", "没有成交", "没做成", "黄了") _QUESTION_TAILS = frozenset({"吗", "呢", "吧", "啊", "呀", "嘛", "?", "?"}) _KEEP_REASON_AS_IS = frozenset({"原因不明", "原因不详", "理由不明", "理由不详"}) +# 群里 @询价小助手 是用来叫醒机器人的,不是未成交原因。 +_MENTION = re.compile(r"@[^\s,,。.;;::、@]+") + + +def strip_bot_mentions(text: str) -> str: + """去掉 @某人。原因前后都可能带,去掉后不留空标点。""" + cleaned = _MENTION.sub("", text or "") + return cleaned.strip(",,。.;;::、 ") def harvest_inline_lost_reason(text: str) -> str: @@ -56,7 +65,10 @@ def harvest_inline_lost_reason(text: str) -> str: break if rest in _QUESTION_TAILS: return "" - return rest.strip() + rest = strip_bot_mentions(rest) + if rest in _QUESTION_TAILS: + return "" + return rest def looks_like_lost_outcome(text: str) -> bool: @@ -211,8 +223,8 @@ def finish_deal_outcome( def capture_lost_reason(*, ledger: Any, work_order_no: str, reason: str) -> dict[str, Any]: - """把销售下一句写进未成交原因。""" - text = (reason or "").strip() + """把销售下一句写进未成交原因。@询价小助手 不是原因。""" + text = strip_bot_mentions(reason or "") no = (work_order_no or "").strip() if not no or not text: return {"ok": False, "error": "blank"} @@ -220,6 +232,8 @@ def capture_lost_reason(*, ledger: Any, work_order_no: str, reason: str) -> dict if not callable(patcher): return {"ok": False, "error": "no_patch"} out = patcher(work_order_no=no, lost_reason=text) or {"ok": False} + if isinstance(out, dict): + out.setdefault("lost_reason", text) if out.get("ok"): from agent.policy.event_exception import clear_wait diff --git a/inquiry-agent/agent/policy/sea_group_ops.py b/inquiry-agent/agent/policy/sea_group_ops.py index c12157d..3f79680 100644 --- a/inquiry-agent/agent/policy/sea_group_ops.py +++ b/inquiry-agent/agent/policy/sea_group_ops.py @@ -1204,7 +1204,7 @@ def capture_group_lost_reason( sess.phase = "done" sess.allowed = () flow._save(sess) - send_group_text(client, chat_id, copy.lost_reason_ack(text)) + send_group_text(client, chat_id, copy.lost_reason_ack(str(result.get("lost_reason") or text))) return "done" diff --git a/inquiry-agent/agent/schema/field_validate.py b/inquiry-agent/agent/schema/field_validate.py index b62630d..e078126 100644 --- a/inquiry-agent/agent/schema/field_validate.py +++ b/inquiry-agent/agent/schema/field_validate.py @@ -516,16 +516,35 @@ def harvest_oral_collab(text: str) -> dict[str, str]: return out +# 箱型原文:40HQ*1、1x40HQ、40尺高柜。货物数量不能收这种串。 +_CONTAINER_SPEC = re.compile( + r"(?i)(20|40|45)\s*['\"′尺]?\s*(HQ|GP|NOR|RH|HC|高柜|平柜|柜)" +) + + +def looks_like_container_spec(text: str) -> bool: + """这串是箱型箱量,不是件数或吨数。20件、20吨不算。""" + return bool(_CONTAINER_SPEC.search(text or "")) + + def project_sea_cargo_qty(facts: dict[str, str] | None) -> dict[str, str]: """ - 海运货物数量投影:原话「15件」常被抽成空运「件数」。 + 海运货物数量投影。 - 货量已有则不覆盖。件数有、货量空时,货量用件数。不猜吨位、不改空运校验。 + 抽字段常把「20件」放进件数,又把「40HQ*1」写进货量。 + 货量只要长得像箱型,就丢掉,改用件数里的数量;件数也是箱型则不搬。 + 箱型箱量空着时,把这串箱型补到箱型箱量。不限具体件数。 """ merged = dict(facts or {}) qty = (merged.get("货量") or merged.get("货物数量") or "").strip() pieces = (merged.get("件数") or "").strip() - if not qty and pieces: + if looks_like_container_spec(qty): + if not (merged.get("箱型箱量") or "").strip(): + merged["箱型箱量"] = qty + merged.pop("货量", None) + merged.pop("货物数量", None) + qty = "" + if not qty and pieces and not looks_like_container_spec(pieces): merged["货量"] = pieces return merged @@ -562,6 +581,38 @@ def _pricing_required_keys( return [] +_PLACE_KEYS = ("起运港", "目的港") +_PACK_IN_PLACE = ("散货", "纸箱", "木箱", "托盘", "卡板", "木托") +_PRICE_TAIL = re.compile(r"(的价格|的报价|价格|报价)$") + + +def peel_trailing_place_clauses(facts: dict[str, str] | None) -> dict[str, str]: + """ + 起运地/目的地只留逗号前的地名。 + + 口语「目的地吉隆坡,普货,散货的价格」会被抽成一整串目的地。 + 逗号后面的品名、包装、价格不是地名,拆回去;已有品名/包装不覆盖。 + """ + merged = dict(facts or {}) + for key in _PLACE_KEYS: + raw = (merged.get(key) or "").strip() + parts = [p.strip() for p in re.split(r"[,,、]", raw) if p.strip()] + if len(parts) <= 1: + continue + place = parts[0] + for part in parts[1:]: + part = _PRICE_TAIL.sub("", part).strip() + if not part or part in {"价格", "报价", "查价"}: + continue + if part in _PACK_IN_PLACE and not (merged.get("包装方式") or "").strip(): + merged["包装方式"] = part + continue + if not (merged.get("品名") or "").strip() and not looks_like_container_spec(part): + merged["品名"] = part + merged[key] = place + return merged + + def display_name(internal_key: str, business_line: str = "AIR") -> str: """补问时给销售看的名字。""" line = (business_line or "").upper() @@ -589,7 +640,7 @@ def validate_required_fields( """ _ = require_formal contract = require_formal_contract("inquiry-required-fields-v1") - normalized = apply_quote_date_default(normalize_facts(facts)) + normalized = peel_trailing_place_clauses(apply_quote_date_default(normalize_facts(facts))) line = (business_line or "").strip().upper() if line == "SEA": normalized = project_sea_cargo_qty(normalized) diff --git a/inquiry-agent/tests/test_deal_outcome.py b/inquiry-agent/tests/test_deal_outcome.py index 28cbdf8..18fbd8d 100644 --- a/inquiry-agent/tests/test_deal_outcome.py +++ b/inquiry-agent/tests/test_deal_outcome.py @@ -157,6 +157,10 @@ class TestDealOutcome(unittest.TestCase): self.assertEqual(harvest_inline_lost_reason("未成交,客户不想要了"), "客户不想要了") self.assertEqual(harvest_inline_lost_reason("未成交:价格太高"), "价格太高") self.assertEqual(harvest_inline_lost_reason("未成交原因客户不想要了"), "客户不想要了") + self.assertEqual( + harvest_inline_lost_reason("WO202609240010未成交,原因客户不需要了@询价小助手"), + "客户不需要了", + ) self.assertEqual(harvest_inline_lost_reason("未成交"), "") self.assertEqual(harvest_inline_lost_reason("未成交吗"), "") self.assertEqual(harvest_inline_lost_reason("成交"), "") diff --git a/inquiry-agent/tests/test_extract_oral.py b/inquiry-agent/tests/test_extract_oral.py index bf1d8a4..a51a2b7 100644 --- a/inquiry-agent/tests/test_extract_oral.py +++ b/inquiry-agent/tests/test_extract_oral.py @@ -123,6 +123,30 @@ class OralExtractTests(unittest.TestCase): 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 diff --git a/inquiry-agent/tests/test_sea_text_inquiry.py b/inquiry-agent/tests/test_sea_text_inquiry.py index b06c50a..40cfd1a 100644 --- a/inquiry-agent/tests/test_sea_text_inquiry.py +++ b/inquiry-agent/tests/test_sea_text_inquiry.py @@ -143,6 +143,23 @@ class SeaTextInquiryTests(unittest.TestCase): self.assertEqual(check["facts"]["货量"], "15件") self.assertNotIn("货量", check["missing"]) + def test_container_shaped_cargo_qty_uses_pieces(self) -> None: + """货量被抽成箱型时,改用件数。20件、15件同一条规则。""" + facts = { + "起运港": "广州港", + "目的港": "巴生港", + "品名": "机器设备", + "货量": "40HQ*1", + "件数": "20件", + "整柜或拼柜": "整柜", + "箱型箱量": "40HQ*1", + "贸易条款": "DOOR-DOOR", + "运输分类": "Port to Port", + } + check = validate_required_fields(facts=facts, business_line="SEA") + self.assertEqual(check["facts"]["货量"], "20件") + self.assertEqual(check["facts"]["箱型箱量"], "40HQ*1") + def test_sea_missing_container_only_asks_container(self) -> None: facts = { "起运港": "上海",