Files
inquiry_robot/inquiry-agent/agent/policy/deal_outcome.py
T

338 lines
12 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.
"""
成交三态落地:改主账六态、推负责人、收下未成交原因、排协商再问。
本文件职责:私聊/群聊共用。不在回调线程等 LLM。推送只入 outbox。
禁止:把图成功当主账成功;成交失败还去推负责人;未成交推负责人。
"""
from __future__ import annotations
import logging
import re
from typing import Any, Callable, Optional
from agent.config import get_settings
from agent.policy import inquiry_copy as copy
from agent.policy.deal_followup import next_followup_at, pick_leaders
logger = logging.getLogger(__name__)
EnqueueFn = Callable[..., tuple[bool, str]]
# 成交跟进里先认未成交,避免被「成交」误伤。
_LOST_MARKERS = ("未成交", "没成交", "没有成交", "没做成", "黄了")
_QUESTION_TAILS = 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:
"""
从「未成交,原因客户不想要了」这类一句里抽出原因。
只写未成交、问句尾巴(吗/呢)不当原因。原因不明整句保留。
调用:成交跟进私聊/群聊;不在回调线程跑。
"""
raw = (text or "").strip()
if not raw:
return ""
compact = raw.replace(" ", "")
marker = ""
at = -1
for token in _LOST_MARKERS:
hit = compact.find(token)
if hit < 0:
continue
if at < 0 or hit < at:
at = hit
marker = token
if at < 0 or not marker:
return ""
rest = compact[at + len(marker) :].strip()
rest = rest.lstrip(",,。.;;::、-/—")
if rest in _KEEP_REASON_AS_IS:
return rest
for label in ("未成交原因是", "未成交原因", "原因是", "理由是", "原因", "理由"):
if rest.startswith(label):
rest = rest[len(label) :].lstrip(",,。.;;::、-/— ")
break
if rest in _QUESTION_TAILS:
return ""
rest = strip_bot_mentions(rest)
if rest in _QUESTION_TAILS:
return ""
return rest
def looks_like_lost_outcome(text: str) -> bool:
"""成交跟进阶段:这句话是在说未成交。"""
blob = (text or "").replace(" ", "")
if not blob:
return False
return any(token in blob for token in _LOST_MARKERS)
def spoken_deal_status(text: str) -> str:
"""
私聊口播成交结果。先认未成交,再认已成交、协商中。
「WO202609240006已成交」整句就是要关单,不是再打开成交卡。
问句(已成交吗)不算。对不上返回空串。
"""
blob = (text or "").replace(" ", "")
if not blob or blob[-1] in _QUESTION_TAILS:
return ""
if looks_like_lost_outcome(blob):
return "未成交"
if "已成交" in blob:
return "已成交"
if "协商中" in blob:
return "协商中"
return ""
def notify_app_ready() -> bool:
"""询价消息服务已配 AgentId+Secret 才推。空则只成交不推。"""
settings = get_settings()
return bool(
str(getattr(settings, "wecom_notify_agent_id", "") or "").strip()
and str(getattr(settings, "wecom_notify_secret", "") or "").strip()
)
def _role_of(business_line: str) -> str:
line = (business_line or "").strip().upper()
if line == "SEA":
return "sea"
if line == "LAND":
return "land"
return "air"
def _enqueue_default(
*,
touser: str,
content: str,
dedupe_key: str,
payload: dict[str, Any],
) -> tuple[bool, str]:
from agent.channel.queue import get_message_store
return get_message_store().enqueue_outbound(
touser=touser,
content=content,
dedupe_key=dedupe_key,
payload=payload,
)
def finish_deal_outcome(
*,
ledger: Any,
work_order_no: str,
outcome: str,
facts: dict[str, str] | None,
quote: dict[str, Any] | None,
business_line: str,
sales_name: str = "",
enqueue: Optional[EnqueueFn] = None,
notify_ready: Optional[bool] = None,
lost_reason: str = "",
) -> dict[str, Any]:
"""
先入主账,再按结果推人或排再问。
返回 ok / phase / notify_summary。phase:
已成交 done;未成交无原因 wait_lost_reason;未成交已带原因 done;协商中 wait_deal。
"""
no = (work_order_no or "").strip()
status = (outcome or "").strip()
extra: dict[str, Any] = {}
summary = ""
if status == "协商中":
extra["next_followup_at"] = next_followup_at().strftime("%Y-%m-%d %H:%M:%S")
if status == "已成交":
staff: list[dict[str, Any]] = []
lister = getattr(ledger, "list_staff_by_role", None)
if callable(lister):
try:
staff = list(lister(role_code=_role_of(business_line)) or [])
except Exception:
logger.exception("成交匹配负责人失败 wo=%s", no)
leaders = pick_leaders(staff, business_line=business_line, facts=facts)
ready = notify_app_ready() if notify_ready is None else bool(notify_ready)
if not leaders:
summary = "无匹配负责人"
elif not ready:
summary = "通知未发出"
else:
names = [str(x.get("name") or x.get("wecomId") or "").strip() for x in leaders]
summary = "已推:" + "、".join(n for n in names if n)
sender = enqueue or _enqueue_default
payload = copy.deal_notify_wecom_payload(
work_order_no=no,
sales_name=sales_name,
facts=facts or {},
quote=quote or {},
business_line=business_line,
)
text = copy.deal_notify_card(
work_order_no=no,
sales_name=sales_name,
facts=facts or {},
quote=quote or {},
business_line=business_line,
)
for row in leaders:
uid = str(row.get("wecomId") or "").strip()
if not uid:
continue
try:
ok, info = sender(
touser=uid,
content=text,
dedupe_key=f"deal-notify:{no}:{uid}",
payload=dict(payload),
)
if not ok:
logger.warning("成交通知入队失败 wo=%s uid=%s info=%s", no, uid, info)
except Exception:
logger.exception("成交通知入队异常 wo=%s uid=%s", no, uid)
extra["deal_notify_summary"] = summary
extra["remark"] = summary
writer = getattr(ledger, "transition", None)
if not callable(writer):
return {"ok": False, "error": "no_transition", "phase": "ledger_fail"}
# 报价文件已出、会话已进入成交跟进,但主账仍停在「询价中」时,
# 直接迁「未成交/已成交/协商中」会被六态拒绝。先补「已报价」再迁目标态。
result = _transition_deal_status(
writer, work_order_no=no, to_status=status, extra=extra, ledger=ledger
)
if not result.get("ok"):
return {"ok": False, "error": result.get("error") or "transition_failed", "phase": "ledger_fail"}
from agent.policy.event_exception import WAIT_LOST_REASON, clear_wait, start_wait
inline_reason = (lost_reason or "").strip()
if status == "未成交" and inline_reason:
captured = capture_lost_reason(ledger=ledger, work_order_no=no, reason=inline_reason)
if captured.get("ok"):
phase = "done"
else:
phase = "wait_lost_reason"
start_wait(ledger, work_order_no=no, wait_kind=WAIT_LOST_REASON)
elif status == "未成交":
phase = "wait_lost_reason"
start_wait(ledger, work_order_no=no, wait_kind=WAIT_LOST_REASON)
elif status == "协商中":
phase = "wait_deal"
clear_wait(ledger, work_order_no=no)
else:
phase = "done"
clear_wait(ledger, work_order_no=no)
return {
"ok": True,
"phase": phase,
"notify_summary": summary,
"status": status,
"next_followup_at": str(extra.get("next_followup_at") or ""),
}
def _transition_deal_status(
writer: Callable[..., dict[str, Any]],
*,
work_order_no: str,
to_status: str,
extra: dict[str, Any],
ledger: Any = None,
) -> dict[str, Any]:
"""
写入成交三态。若主账仍停在询价中,先迁已报价再迁目标态。
运行证据:
- WO022/WO023:文件已发仍停询价中,直迁未成交被六态拒绝;
- HTTP 500 时 http_ledger 常只回 http_500(正文里的「询价中」丢失),
不能只靠错误文案判断,要以工单当前状态为准。
"""
no = (work_order_no or "").strip()
status = (to_status or "").strip()
result = writer(work_order_no=no, to_status=status, **extra) or {}
if result.get("ok"):
return result
if status not in {"已成交", "未成交", "协商中"}:
return result
err = str(result.get("error") or result.get("message") or "")
cur = ""
getter = getattr(ledger, "get_for_agent", None) if ledger is not None else None
if callable(getter):
try:
snap = getter(work_order_no=no, sender_id="") or {}
cur = str(snap.get("status") or "").strip()
except Exception:
cur = ""
# 状态确认询价中,或错误文案带询价中,或仅有 http_5xx 时仍尝试补已报价。
should_promote = (
cur == "询价中"
or "询价中" in err
or err.startswith("http_5")
or err in {"transition_failed", "ledger_error"}
)
if not should_promote:
return result
promoted = writer(
work_order_no=no,
to_status="已报价",
remark="成交跟进前补已报价",
) or {}
if not promoted.get("ok"):
return result
return writer(work_order_no=no, to_status=status, **extra) or {}
def ensure_ticket_quoted(ledger: Any, *, work_order_no: str) -> dict[str, Any]:
"""
报价文件发出 / 进入成交跟进时,保证主账至少是「已报价」。
幂等:已离开「询价中」则不再迁态(已报价→已报价会被六态拒绝)。
"""
no = (work_order_no or "").strip()
writer = getattr(ledger, "transition", None)
if not no or not callable(writer):
return {"ok": False, "error": "no_transition"}
getter = getattr(ledger, "get_for_agent", None)
if callable(getter):
try:
snap = getter(work_order_no=no, sender_id="") or {}
except Exception:
snap = {}
st = str(snap.get("status") or "").strip()
if st and st != "询价中":
return {"ok": True, "status": st, "skipped": True}
return writer(work_order_no=no, to_status="已报价", remark="报价文件已出") or {}
def capture_lost_reason(*, ledger: Any, work_order_no: str, reason: str) -> dict[str, Any]:
"""把销售下一句写进未成交原因。@询价小助手 不是原因。"""
text = strip_bot_mentions(reason or "")
no = (work_order_no or "").strip()
if not no or not text:
return {"ok": False, "error": "blank"}
patcher = getattr(ledger, "patch_lost_reason", None)
if not callable(patcher):
return {"ok": False, "error": "no_patch"}
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"):
from agent.policy.event_exception import clear_wait
clear_wait(ledger, work_order_no=no)
return out