Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
37278aa0db |
@@ -1709,7 +1709,7 @@ class AirTextInquiryFlow:
|
|||||||
sess.phase = "done"
|
sess.phase = "done"
|
||||||
sess.allowed = ()
|
sess.allowed = ()
|
||||||
self._save(sess)
|
self._save(sess)
|
||||||
reply(copy.lost_reason_ack(text))
|
reply(copy.lost_reason_ack(str(result.get("lost_reason") or text)))
|
||||||
return "done"
|
return "done"
|
||||||
|
|
||||||
def mark_wait_deal(self, sender_id: str, work_order_no: str) -> None:
|
def mark_wait_deal(self, sender_id: str, work_order_no: str) -> None:
|
||||||
|
|||||||
@@ -8,6 +8,7 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
|
import re
|
||||||
from typing import Any, Callable, Optional
|
from typing import Any, Callable, Optional
|
||||||
|
|
||||||
from agent.config import get_settings
|
from agent.config import get_settings
|
||||||
@@ -22,6 +23,14 @@ EnqueueFn = Callable[..., tuple[bool, str]]
|
|||||||
_LOST_MARKERS = ("未成交", "没成交", "没有成交", "没做成", "黄了")
|
_LOST_MARKERS = ("未成交", "没成交", "没有成交", "没做成", "黄了")
|
||||||
_QUESTION_TAILS = frozenset({"吗", "呢", "吧", "啊", "呀", "嘛", "?", "?"})
|
_QUESTION_TAILS = frozenset({"吗", "呢", "吧", "啊", "呀", "嘛", "?", "?"})
|
||||||
_KEEP_REASON_AS_IS = 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:
|
def harvest_inline_lost_reason(text: str) -> str:
|
||||||
@@ -56,7 +65,10 @@ def harvest_inline_lost_reason(text: str) -> str:
|
|||||||
break
|
break
|
||||||
if rest in _QUESTION_TAILS:
|
if rest in _QUESTION_TAILS:
|
||||||
return ""
|
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:
|
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]:
|
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()
|
no = (work_order_no or "").strip()
|
||||||
if not no or not text:
|
if not no or not text:
|
||||||
return {"ok": False, "error": "blank"}
|
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):
|
if not callable(patcher):
|
||||||
return {"ok": False, "error": "no_patch"}
|
return {"ok": False, "error": "no_patch"}
|
||||||
out = patcher(work_order_no=no, lost_reason=text) or {"ok": False}
|
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"):
|
if out.get("ok"):
|
||||||
from agent.policy.event_exception import clear_wait
|
from agent.policy.event_exception import clear_wait
|
||||||
|
|
||||||
|
|||||||
@@ -1204,7 +1204,7 @@ def capture_group_lost_reason(
|
|||||||
sess.phase = "done"
|
sess.phase = "done"
|
||||||
sess.allowed = ()
|
sess.allowed = ()
|
||||||
flow._save(sess)
|
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"
|
return "done"
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -516,16 +516,35 @@ def harvest_oral_collab(text: str) -> dict[str, str]:
|
|||||||
return out
|
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]:
|
def project_sea_cargo_qty(facts: dict[str, str] | None) -> dict[str, str]:
|
||||||
"""
|
"""
|
||||||
海运货物数量投影:原话「15件」常被抽成空运「件数」。
|
海运货物数量投影。
|
||||||
|
|
||||||
货量已有则不覆盖。件数有、货量空时,货量用件数。不猜吨位、不改空运校验。
|
抽字段常把「20件」放进件数,又把「40HQ*1」写进货量。
|
||||||
|
货量只要长得像箱型,就丢掉,改用件数里的数量;件数也是箱型则不搬。
|
||||||
|
箱型箱量空着时,把这串箱型补到箱型箱量。不限具体件数。
|
||||||
"""
|
"""
|
||||||
merged = dict(facts or {})
|
merged = dict(facts or {})
|
||||||
qty = (merged.get("货量") or merged.get("货物数量") or "").strip()
|
qty = (merged.get("货量") or merged.get("货物数量") or "").strip()
|
||||||
pieces = (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
|
merged["货量"] = pieces
|
||||||
return merged
|
return merged
|
||||||
|
|
||||||
@@ -562,6 +581,38 @@ def _pricing_required_keys(
|
|||||||
return []
|
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:
|
def display_name(internal_key: str, business_line: str = "AIR") -> str:
|
||||||
"""补问时给销售看的名字。"""
|
"""补问时给销售看的名字。"""
|
||||||
line = (business_line or "").upper()
|
line = (business_line or "").upper()
|
||||||
@@ -589,7 +640,7 @@ def validate_required_fields(
|
|||||||
"""
|
"""
|
||||||
_ = require_formal
|
_ = require_formal
|
||||||
contract = require_formal_contract("inquiry-required-fields-v1")
|
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()
|
line = (business_line or "").strip().upper()
|
||||||
if line == "SEA":
|
if line == "SEA":
|
||||||
normalized = project_sea_cargo_qty(normalized)
|
normalized = project_sea_cargo_qty(normalized)
|
||||||
|
|||||||
@@ -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("未成交:价格太高"), "价格太高")
|
||||||
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("未成交吗"), "")
|
self.assertEqual(harvest_inline_lost_reason("未成交吗"), "")
|
||||||
self.assertEqual(harvest_inline_lost_reason("成交"), "")
|
self.assertEqual(harvest_inline_lost_reason("成交"), "")
|
||||||
|
|||||||
@@ -123,6 +123,30 @@ class OralExtractTests(unittest.TestCase):
|
|||||||
self.assertEqual(bulk["facts"]["包装方式"], "散货")
|
self.assertEqual(bulk["facts"]["包装方式"], "散货")
|
||||||
self.assertNotIn("包装方式", bulk["missing"])
|
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:
|
def test_fly_route_overwrites_stale_nkg_kul(self) -> None:
|
||||||
from agent.schema.field_validate import harvest_oral_measures
|
from agent.schema.field_validate import harvest_oral_measures
|
||||||
from agent.schema.tms_air_query import assemble_air_query
|
from agent.schema.tms_air_query import assemble_air_query
|
||||||
|
|||||||
@@ -143,6 +143,23 @@ class SeaTextInquiryTests(unittest.TestCase):
|
|||||||
self.assertEqual(check["facts"]["货量"], "15件")
|
self.assertEqual(check["facts"]["货量"], "15件")
|
||||||
self.assertNotIn("货量", check["missing"])
|
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:
|
def test_sea_missing_container_only_asks_container(self) -> None:
|
||||||
facts = {
|
facts = {
|
||||||
"起运港": "上海",
|
"起运港": "上海",
|
||||||
|
|||||||
Reference in New Issue
Block a user