397 lines
14 KiB
Python
397 lines
14 KiB
Python
"""
|
||
事件异常巡检:Worker HTTP 槽轮询到点工单并告警。
|
||
|
||
本文件职责:读主账等待、按工作日对照超时小时、会话喊人、可选应用卡片、再记摘要。
|
||
禁止:回调线程调用;占 LibreOffice 槽;改六态;反复催已告警的等待。
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import json
|
||
import logging
|
||
import time
|
||
from datetime import datetime
|
||
from pathlib import Path
|
||
from typing import Any, Callable, Optional
|
||
|
||
from agent.config import Settings, get_settings
|
||
from agent.policy import inquiry_copy as copy
|
||
from agent.policy.deal_followup import SHANGHAI, workday_elapsed_hours
|
||
from agent.policy.event_exception import (
|
||
card_title,
|
||
conversation_of,
|
||
exception_type,
|
||
WAIT_ADOPT,
|
||
WAIT_AIR_QUOTE,
|
||
WAIT_DEAL,
|
||
WAIT_LOST_REASON,
|
||
mentions_for_role,
|
||
pick_app_targets,
|
||
pick_mentions,
|
||
timeout_code,
|
||
)
|
||
|
||
logger = logging.getLogger(__name__)
|
||
|
||
# 采用、成交跟进、未成交原因都是在等销售。点名只找本单销售。
|
||
_SALES_WAIT_KINDS = {WAIT_ADOPT, WAIT_DEAL, WAIT_LOST_REASON}
|
||
|
||
SendGroupFn = Callable[..., bool]
|
||
EnqueueFn = Callable[..., tuple[bool, str]]
|
||
TimeoutLookupFn = Callable[..., dict[str, Any]]
|
||
|
||
|
||
def _parse_started(raw: Any) -> Optional[datetime]:
|
||
text = str(raw or "").strip()
|
||
if not text:
|
||
return None
|
||
text = text.replace("T", " ")[:19]
|
||
try:
|
||
naive = datetime.strptime(text, "%Y-%m-%d %H:%M:%S")
|
||
return naive.replace(tzinfo=SHANGHAI)
|
||
except ValueError:
|
||
return None
|
||
|
||
|
||
def _hours_of(row: dict[str, Any]) -> float:
|
||
try:
|
||
return float(row.get("timeoutHours") if row.get("timeoutHours") is not None else row.get("timeout_hours") or 0)
|
||
except (TypeError, ValueError):
|
||
return 0.0
|
||
|
||
|
||
def poll_event_exceptions(
|
||
*,
|
||
ledger: Any = None,
|
||
settings: Optional[Settings] = None,
|
||
now: Optional[datetime] = None,
|
||
enqueue: Optional[EnqueueFn] = None,
|
||
send_group: Optional[SendGroupFn] = None,
|
||
timeout_lookup: Optional[TimeoutLookupFn] = None,
|
||
notify_ready: Optional[bool] = None,
|
||
) -> int:
|
||
"""
|
||
巡检未告警等待。返回本轮成功记下异常的工单数。
|
||
|
||
会话成功才 record;卡片失败不影响会话。禁止进企微回调线程。
|
||
"""
|
||
cfg = settings or get_settings()
|
||
book = ledger
|
||
if book is None:
|
||
if str(getattr(cfg, "ledger_backend", "http") or "http").lower() == "memory":
|
||
return 0
|
||
from agent.ledger.http_ledger import HttpLedger
|
||
|
||
book = HttpLedger()
|
||
lister = getattr(book, "list_event_waits", None)
|
||
if not callable(lister):
|
||
return 0
|
||
stamp = now or datetime.now(SHANGHAI)
|
||
if stamp.tzinfo is None:
|
||
stamp = stamp.replace(tzinfo=SHANGHAI)
|
||
# 测服会话/卡片也不加 [test],与销售询价正文一致。
|
||
test_prefix = False
|
||
lookup = timeout_lookup or getattr(book, "get_timeout_by_code", None)
|
||
ready = notify_app_flag(notify_ready)
|
||
rows = [r for r in (lister() or []) if isinstance(r, dict)]
|
||
sent = 0
|
||
for row in rows:
|
||
try:
|
||
if _handle_one(
|
||
row,
|
||
book=book,
|
||
now=stamp,
|
||
test_prefix=test_prefix,
|
||
enqueue=enqueue,
|
||
send_group=send_group,
|
||
timeout_lookup=lookup,
|
||
notify_ready=ready,
|
||
settings=cfg,
|
||
):
|
||
sent += 1
|
||
except Exception:
|
||
logger.exception(
|
||
"事件异常巡检失败 wo=%s",
|
||
row.get("work_order_no") or row.get("workOrderNo"),
|
||
)
|
||
return sent
|
||
|
||
|
||
def notify_app_flag(override: Optional[bool]) -> bool:
|
||
if override is not None:
|
||
return bool(override)
|
||
from agent.policy.deal_outcome import notify_app_ready
|
||
|
||
return notify_app_ready()
|
||
|
||
|
||
def _skip_sales_timeout(
|
||
*,
|
||
status: str,
|
||
wait_kind: str,
|
||
next_followup_at: str,
|
||
lost_reason: str,
|
||
now: datetime,
|
||
) -> str:
|
||
"""
|
||
成交跟进相关等待在终态/顺延窗内不应再告警。
|
||
|
||
返回跳过原因;空串表示继续按小时判定。
|
||
协商中:只要还排着再问时刻(next_followup_at > now),就不发销售超时;
|
||
再问认领后会清空该时刻并重新 start deal,那时才重新计时。
|
||
"""
|
||
st = (status or "").strip()
|
||
_ = wait_kind
|
||
if st == "未成交" and (lost_reason or "").strip():
|
||
return "lost_reason_recorded"
|
||
if st == "协商中":
|
||
raw = (next_followup_at or "").strip()
|
||
if not raw:
|
||
return ""
|
||
due = _parse_started(raw)
|
||
if due is None or due > now:
|
||
return "negotiate_followup_pending"
|
||
return ""
|
||
|
||
|
||
def _handle_one(
|
||
row: dict[str, Any],
|
||
*,
|
||
book: Any,
|
||
now: datetime,
|
||
test_prefix: bool,
|
||
enqueue: Optional[EnqueueFn],
|
||
send_group: Optional[SendGroupFn],
|
||
timeout_lookup: Any,
|
||
notify_ready: bool,
|
||
settings: Settings,
|
||
) -> bool:
|
||
no = str(row.get("work_order_no") or row.get("workOrderNo") or "").strip()
|
||
kind = str(row.get("event_wait_kind") or row.get("eventWaitKind") or "").strip()
|
||
if not no or not kind:
|
||
return False
|
||
status = str(row.get("status") or "").strip()
|
||
followup = str(row.get("next_followup_at") or row.get("nextFollowupAt") or "").strip()
|
||
lost_reason = str(row.get("lost_reason") or row.get("lostReason") or "").strip()
|
||
# 产品口径:协商中顺延再问期间不算「销售无响应」;未成交已写原因不再催。
|
||
# 即使 clear_wait 失败留下陈旧 deal 等待,这里也要挡住(WO202610060024 / WO202610070034)。
|
||
skip_reason = _skip_sales_timeout(
|
||
status=status, wait_kind=kind, next_followup_at=followup, lost_reason=lost_reason, now=now
|
||
)
|
||
line = str(row.get("business_line") or row.get("businessLine") or "").strip()
|
||
facts = dict(row.get("facts") or {})
|
||
klass = str(facts.get("运输分类") or facts.get("transportType") or "").strip()
|
||
code = timeout_code(wait_kind=kind, business_line=line, transport_class=klass)
|
||
if not code or not callable(timeout_lookup):
|
||
return False
|
||
timeout_row = timeout_lookup(timeout_code=code) or {}
|
||
if not timeout_row.get("found"):
|
||
return False
|
||
hours = _hours_of(timeout_row)
|
||
if hours <= 0:
|
||
return False
|
||
started = _parse_started(row.get("event_wait_started_at") or row.get("eventWaitStartedAt"))
|
||
if started is None:
|
||
return False
|
||
if skip_reason:
|
||
logger.info("事件异常跳过 wo=%s kind=%s reason=%s", no, kind, skip_reason)
|
||
return False
|
||
elapsed = workday_elapsed_hours(started, now)
|
||
if elapsed < hours:
|
||
return False
|
||
watchers = [dict(x) for x in (row.get("watchers") or []) if isinstance(x, dict)]
|
||
sales = _ticket_sales(row, watchers)
|
||
people = pick_mentions(wait_kind=kind, sales=sales, members=watchers, business_line=line)
|
||
# 空运群往往只绑了群号;且航线超时必须按本单「负责线路」点名,禁止整岗广播。
|
||
# 与激活摘要 resolve_air_mentions 同口径。销售超时不能改去 @ 航线。
|
||
if kind == WAIT_AIR_QUOTE:
|
||
route_people = _airline_staff_mentions(book, facts=facts)
|
||
if route_people:
|
||
people = route_people
|
||
names = [str(p.get("name") or "").strip() for p in people if str(p.get("name") or "").strip()]
|
||
chat_id = str(row.get("collab_chat_id") or row.get("collabChatId") or "").strip()
|
||
where = conversation_of(wait_kind=kind, collab_chat_id=chat_id)
|
||
app_targets = pick_app_targets(people)
|
||
# 群里用 @。销售超时即使发到私聊,也写成 @销售姓名,不写「您」或「相关人员」。
|
||
mention = where == "group" or kind in _SALES_WAIT_KINDS
|
||
text = copy.event_exception_session_text(
|
||
exception_type=exception_type(kind),
|
||
work_order_no=no,
|
||
names=names,
|
||
hours=hours,
|
||
mention=mention,
|
||
test_prefix=test_prefix,
|
||
)
|
||
ok_session = False
|
||
if where == "group":
|
||
if not chat_id:
|
||
logger.warning("事件异常无协同群 wo=%s kind=%s", no, kind)
|
||
return False
|
||
sender = send_group or _default_send_group
|
||
ok_session = bool(sender(chat_id=chat_id, content=text, business_line=line))
|
||
else:
|
||
uid = str((people[0].get("wecomId") if people else "") or sales.get("wecomId") or "").strip()
|
||
if not uid:
|
||
logger.warning("事件异常无私聊对象 wo=%s kind=%s", no, kind)
|
||
return False
|
||
enq = enqueue or _default_enqueue
|
||
ok_session, info = enq(
|
||
touser=uid,
|
||
content=text,
|
||
dedupe_key=f"event-ex:{no}:{kind}",
|
||
payload={},
|
||
)
|
||
if not ok_session:
|
||
logger.warning("事件异常私聊入队失败 wo=%s info=%s", no, info)
|
||
if not ok_session:
|
||
return False
|
||
if notify_ready:
|
||
_send_app_cards(
|
||
people=people,
|
||
kind=kind,
|
||
work_order_no=no,
|
||
hours=hours,
|
||
test_prefix=test_prefix,
|
||
enqueue=enqueue or _default_enqueue,
|
||
)
|
||
else:
|
||
pass
|
||
recorder = getattr(book, "record_event_exception", None)
|
||
if callable(recorder):
|
||
recorder(work_order_no=no, event_exception=exception_type(kind))
|
||
return True
|
||
|
||
|
||
def _ticket_sales(row: dict[str, Any], watchers: list[dict[str, Any]]) -> dict[str, Any]:
|
||
"""
|
||
这张工单的销售。销售超时只 @ 这个人。
|
||
|
||
先按工单上的销售企微对上关注人,对不上再用岗位是销售的那条。
|
||
姓名用工单上的销售名,避免点成「销售」两个字或点到航线。
|
||
"""
|
||
sid = str(row.get("sales_wecom_id") or row.get("salesWecomId") or "").strip()
|
||
sname = str(row.get("sales_name") or row.get("salesName") or "").strip()
|
||
matched: dict[str, Any] | None = None
|
||
for raw in watchers:
|
||
uid = str(raw.get("wecomId") or raw.get("wecom_id") or "").strip()
|
||
role = str(raw.get("roleCode") or raw.get("role_code") or "").strip().lower()
|
||
if sid and uid == sid:
|
||
matched = dict(raw)
|
||
break
|
||
if matched is None and role == "sales":
|
||
matched = dict(raw)
|
||
if matched is None:
|
||
matched = {
|
||
"wecomId": sid,
|
||
"roleCode": "sales",
|
||
"notifyEventException": 0,
|
||
}
|
||
name = str(matched.get("name") or "").strip()
|
||
if sname and (not name or name in {sid, "销售"}):
|
||
matched["name"] = sname
|
||
elif not name:
|
||
matched["name"] = sname or "销售"
|
||
if sid and not str(matched.get("wecomId") or "").strip():
|
||
matched["wecomId"] = sid
|
||
matched.setdefault("roleCode", "sales")
|
||
return matched
|
||
|
||
|
||
def _airline_staff_mentions(
|
||
book: Any,
|
||
*,
|
||
facts: dict[str, Any] | None = None,
|
||
) -> list[dict[str, Any]]:
|
||
"""
|
||
主账列出启用航线·空运,再按本单负责线路过滤。
|
||
|
||
与空运群激活摘要同口径:对上起运/目的(或空运全线);对不上则只留全线。
|
||
主账失败或没有接口时返回空,话术仍用「相关人员」。不写工单。
|
||
"""
|
||
from agent.policy.deal_followup import air_staff_route_matches
|
||
|
||
lister = getattr(book, "list_staff_by_role", None)
|
||
if not callable(lister):
|
||
return []
|
||
try:
|
||
rows = [dict(x) for x in (lister(role_code="air") or []) if isinstance(x, dict)]
|
||
except Exception:
|
||
logger.exception("事件异常列航线岗失败")
|
||
return []
|
||
matched = [
|
||
r
|
||
for r in rows
|
||
if air_staff_route_matches(routes=str(r.get("routes") or ""), facts=facts)
|
||
]
|
||
if not matched:
|
||
matched = [
|
||
r
|
||
for r in rows
|
||
if any(
|
||
t.strip() in {"空运全线", "全线"}
|
||
for t in str(r.get("routes") or "").replace(",", ",").replace("、", ",").split(",")
|
||
)
|
||
]
|
||
return mentions_for_role(matched, role_code="air")
|
||
|
||
|
||
def _send_app_cards(
|
||
*,
|
||
people: list[dict[str, Any]],
|
||
kind: str,
|
||
work_order_no: str,
|
||
hours: float,
|
||
test_prefix: bool,
|
||
enqueue: EnqueueFn,
|
||
) -> None:
|
||
payload = copy.event_exception_alert_payload(
|
||
card_title=card_title(kind),
|
||
work_order_no=work_order_no,
|
||
hours=hours,
|
||
test_prefix=test_prefix,
|
||
)
|
||
fallback = copy.event_exception_alert_text(
|
||
card_title=card_title(kind),
|
||
work_order_no=work_order_no,
|
||
hours=hours,
|
||
test_prefix=test_prefix,
|
||
)
|
||
for person in pick_app_targets(people):
|
||
uid = str(person.get("wecomId") or "").strip()
|
||
if not uid:
|
||
continue
|
||
ok, info = enqueue(
|
||
touser=uid,
|
||
content=fallback,
|
||
dedupe_key=f"event-ex-app:{work_order_no}:{kind}:{uid}",
|
||
payload=payload,
|
||
)
|
||
if not ok:
|
||
logger.warning("事件异常应用卡片入队失败 uid=%s info=%s", uid, info)
|
||
|
||
|
||
def _default_enqueue(**kwargs: Any) -> tuple[bool, str]:
|
||
from agent.channel.queue import get_message_store
|
||
|
||
return get_message_store().enqueue_outbound(**kwargs)
|
||
|
||
|
||
def _default_send_group(*, chat_id: str, content: str, business_line: str = "") -> bool:
|
||
line = (business_line or "").strip().upper()
|
||
if line == "AIR":
|
||
from agent.channel.aibot.reply import AibotReplyClient
|
||
|
||
out = AibotReplyClient().send_group(chat_id=chat_id, content=content)
|
||
return bool(out.get("ok"))
|
||
from agent.channel.outbox.sender import WeComAppClient
|
||
from agent.config import get_settings
|
||
|
||
cfg = get_settings()
|
||
client = WeComAppClient(
|
||
corp_id=cfg.wecom_corp_id,
|
||
secret=cfg.wecom_secret,
|
||
agent_id=int(cfg.wecom_agent_id or "0") or 0,
|
||
)
|
||
out = client.send_group(chat_id=chat_id, content=content)
|
||
return bool(out.get("ok"))
|