feat: 事件异常超时编码与点名对象

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-09-21 18:15:22 +08:00
co-authored by Cursor
parent ef0424a28f
commit 2527901efd
2 changed files with 299 additions and 0 deletions
@@ -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"
+106
View File
@@ -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()