Files
inquiry_robot/inquiry-agent/agent/jobs/event_exception_poll.py
T

397 lines
14 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
事件异常巡检: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"))