diff --git a/inquiry-agent/agent/policy/event_exception.py b/inquiry-agent/agent/policy/event_exception.py new file mode 100644 index 0000000..ffdc094 --- /dev/null +++ b/inquiry-agent/agent/policy/event_exception.py @@ -0,0 +1,193 @@ +""" +事件异常纯规则:等待种类、超时编码、点名对象、卡片接收人。 + +本文件职责:给 Worker 巡检判定用,不发企微、不写主账。 +禁止:在回调线程做 IO;把六态写进本文件;把系统异常混进来。 +""" + +from __future__ import annotations + +from typing import Any + +from agent.schema.land_options import canonicalize_land_class +from agent.schema.sea_options import canonicalize_sea_class + +WAIT_AIR_QUOTE = "air_quote" +WAIT_PRODUCT_QUOTE = "product_quote" +WAIT_ADOPT = "adopt" +WAIT_DEAL = "deal" +WAIT_LOST_REASON = "lost_reason" + +TYPE_AIR = "航线响应超时" +TYPE_PRODUCT = "产品响应超时" +TYPE_SALES = "销售响应超时" + +CARD_AIR = "航线响应异常" +CARD_PRODUCT = "产品响应异常" +CARD_SALES = "销售响应异常" + +_SALES_CODES = { + "AIR": "air_sales", + "SEA": "sea_sales", + "LAND": "land_sales", +} + +_LAND_PRODUCT_CODES = { + "普件(东南亚、中港常用线路/常规车型)": "land_hk_common", + "普件(东南亚、中亚、中欧涉及国内+国外段)": "land_hk_cross", + "普件(东南亚、中亚、中欧涉及境外段清派等特殊要求)": "land_hk_special", + "急件": "land_hk_express", +} + +_SALES_WAITS = {WAIT_ADOPT, WAIT_DEAL, WAIT_LOST_REASON} + + +def timeout_code(*, wait_kind: str, business_line: str, transport_class: str) -> str: + """ + 按等待种类 + 运输方式 + 询价分类,对上后台超时规则编码。 + + 对不上返回空串,调用方视为本步不监测。副作用:无。 + """ + line = (business_line or "").strip().upper() + kind = (wait_kind or "").strip() + klass = (transport_class or "").strip() + if kind in _SALES_WAITS: + return _SALES_CODES.get(line, "") + if kind == WAIT_AIR_QUOTE: + return "air_route" + if kind == WAIT_PRODUCT_QUOTE: + if line == "SEA": + canon = canonicalize_sea_class(klass) or klass + if canon == "Port to Port": + return "sea_product_ptp" + if canon == "其他": + return "sea_product_other" + return "" + if line == "LAND": + canon = canonicalize_land_class(klass) or klass + return _LAND_PRODUCT_CODES.get(canon, "") + return "" + return "" + + +def exception_type(wait_kind: str) -> str: + """工单「事件异常」字段用的类型名。""" + kind = (wait_kind or "").strip() + if kind == WAIT_AIR_QUOTE: + return TYPE_AIR + if kind == WAIT_PRODUCT_QUOTE: + return TYPE_PRODUCT + if kind in _SALES_WAITS: + return TYPE_SALES + return "" + + +def card_title(wait_kind: str) -> str: + """询价消息服务卡片标题。""" + kind = (wait_kind or "").strip() + if kind == WAIT_AIR_QUOTE: + return CARD_AIR + if kind == WAIT_PRODUCT_QUOTE: + return CARD_PRODUCT + if kind in _SALES_WAITS: + return CARD_SALES + return "" + + +def format_timeout_hours(hours: float) -> str: + """话术里的小时数:整数去小数点,0.25 保持原样。""" + try: + n = float(hours) + except (TypeError, ValueError): + return "0" + if n == int(n): + return str(int(n)) + text = f"{n:.4f}".rstrip("0").rstrip(".") + return text or "0" + + +def pick_mentions( + *, + wait_kind: str, + sales: dict[str, Any] | None, + members: list[dict[str, Any]] | None, + business_line: str, +) -> list[dict[str, Any]]: + """ + 本单这次应回而未回、需要被点名的人。 + + 航线只盯群里航线岗;海运/陆运产品盯对应产品岗;销售三类只盯本单销售。 + 不要询价机器人、不要群里其他人。 + """ + kind = (wait_kind or "").strip() + line = (business_line or "").strip().upper() + if kind in _SALES_WAITS: + person = dict(sales or {}) + uid = str(person.get("wecomId") or person.get("wecom_id") or "").strip() + if not uid: + return [] + person.setdefault("wecomId", uid) + person.setdefault("name", str(person.get("name") or "").strip() or "销售") + return [person] + want = "" + if kind == WAIT_AIR_QUOTE: + want = "air" + elif kind == WAIT_PRODUCT_QUOTE: + if line == "SEA": + want = "sea" + elif line == "LAND": + want = "land" + if not want: + return [] + out: list[dict[str, Any]] = [] + seen: set[str] = set() + for raw in members or []: + if not isinstance(raw, dict): + continue + role = str(raw.get("roleCode") or raw.get("role_code") or "").strip().lower() + if role != want: + continue + uid = str(raw.get("wecomId") or raw.get("wecom_id") or "").strip() + if not uid or uid in seen: + continue + seen.add(uid) + row = dict(raw) + row.setdefault("wecomId", uid) + row.setdefault("name", str(raw.get("name") or "").strip() or uid) + out.append(row) + return out + + +def pick_app_targets(people: list[dict[str, Any]] | None) -> list[dict[str, Any]]: + """勾了「接收事件异常消息提醒」且绑了企微的人。""" + out: list[dict[str, Any]] = [] + seen: set[str] = set() + for raw in people or []: + if not isinstance(raw, dict): + continue + flag = raw.get("notifyEventException") + if flag not in (1, True, "1"): + continue + uid = str(raw.get("wecomId") or raw.get("wecom_id") or "").strip() + if not uid or uid in seen: + continue + seen.add(uid) + out.append(raw) + return out + + +def conversation_of(*, wait_kind: str, collab_chat_id: str) -> str: + """ + 告警发在哪个会话:group 或 private。 + + 采用/不采用永远私聊;航线/产品永远群;成交跟进和未成交原因有协同群才群。 + """ + kind = (wait_kind or "").strip() + chat = (collab_chat_id or "").strip() + if kind == WAIT_ADOPT: + return "private" + if kind in {WAIT_AIR_QUOTE, WAIT_PRODUCT_QUOTE}: + return "group" + if kind in {WAIT_DEAL, WAIT_LOST_REASON}: + return "group" if chat else "private" + return "private" diff --git a/inquiry-agent/tests/test_event_exception.py b/inquiry-agent/tests/test_event_exception.py new file mode 100644 index 0000000..f8945d4 --- /dev/null +++ b/inquiry-agent/tests/test_event_exception.py @@ -0,0 +1,106 @@ +""" +事件异常纯规则:超时编码、点名对象、卡片接收人(不打外网)。 +""" + +from __future__ import annotations + +import os +import sys +import unittest + +ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "..")) +if ROOT not in sys.path: + sys.path.insert(0, ROOT) + +from agent.policy.event_exception import ( + WAIT_ADOPT, + WAIT_AIR_QUOTE, + WAIT_DEAL, + WAIT_PRODUCT_QUOTE, + conversation_of, + format_timeout_hours, + pick_app_targets, + pick_mentions, + timeout_code, +) + + +class TestTimeoutCode(unittest.TestCase): + def test_sales_unified(self) -> None: + self.assertEqual( + timeout_code(wait_kind=WAIT_ADOPT, business_line="SEA", transport_class="急件"), + "sea_sales", + ) + self.assertEqual( + timeout_code(wait_kind=WAIT_DEAL, business_line="AIR", transport_class=""), + "air_sales", + ) + self.assertEqual( + timeout_code(wait_kind="lost_reason", business_line="LAND", transport_class="急件"), + "land_sales", + ) + + def test_air_route(self) -> None: + self.assertEqual( + timeout_code(wait_kind=WAIT_AIR_QUOTE, business_line="AIR", transport_class=""), + "air_route", + ) + + def test_sea_and_land_product_class(self) -> None: + self.assertEqual( + timeout_code( + wait_kind=WAIT_PRODUCT_QUOTE, business_line="SEA", transport_class="Port to Port" + ), + "sea_product_ptp", + ) + self.assertEqual( + timeout_code(wait_kind=WAIT_PRODUCT_QUOTE, business_line="SEA", transport_class="其他"), + "sea_product_other", + ) + self.assertEqual( + timeout_code(wait_kind=WAIT_PRODUCT_QUOTE, business_line="LAND", transport_class="急件"), + "land_hk_express", + ) + self.assertEqual( + timeout_code(wait_kind=WAIT_PRODUCT_QUOTE, business_line="SEA", transport_class=""), + "", + ) + + def test_hours_format(self) -> None: + self.assertEqual(format_timeout_hours(0.25), "0.25") + self.assertEqual(format_timeout_hours(48), "48") + + +class TestMentions(unittest.TestCase): + def test_product_all_unanswered(self) -> None: + sales = { + "wecomId": "s1", + "name": "销售甲", + "roleCode": "sales", + "notifyEventException": 1, + } + members = [ + {"wecomId": "p1", "name": "张三", "roleCode": "sea", "notifyEventException": 1}, + {"wecomId": "p2", "name": "李四", "roleCode": "sea", "notifyEventException": 0}, + {"wecomId": "s1", "name": "销售甲", "roleCode": "sales", "notifyEventException": 1}, + ] + hit = pick_mentions( + wait_kind=WAIT_PRODUCT_QUOTE, + sales=sales, + members=members, + business_line="SEA", + ) + self.assertEqual([p["name"] for p in hit], ["张三", "李四"]) + apps = pick_app_targets(hit) + self.assertEqual([p["wecomId"] for p in apps], ["p1"]) + + def test_adopt_is_private(self) -> None: + self.assertEqual(conversation_of(wait_kind=WAIT_ADOPT, collab_chat_id="wrxxx"), "private") + + def test_deal_uses_group_when_present(self) -> None: + self.assertEqual(conversation_of(wait_kind=WAIT_DEAL, collab_chat_id="wrxxx"), "group") + self.assertEqual(conversation_of(wait_kind=WAIT_DEAL, collab_chat_id=""), "private") + + +if __name__ == "__main__": + unittest.main()