Files
inquiry_robot/inquiry-agent/agent/handlers/card_action.py
T

142 lines
5.5 KiB
Python

"""
意图:询价卡片按钮(复制工单号 / 拉产品进群 / 跳过协同 / 采用 / 不采用 / 成交)。
本文件职责:校验当前会话允许的动作后交给流程执行;卡片按钮集中防抖入口。
禁止:不校验 wait/允许集合就改主账;禁止一个函数处理未声明的新按钮。
"""
from __future__ import annotations
import logging
from typing import Any
from agent.channel.queue import get_message_store
from agent.policy import inquiry_copy as copy
from agent.policy.air_text_flow import get_air_text_flow
from agent.policy.land_text_flow import get_land_text_flow
from agent.policy.sea_text_flow import get_sea_text_flow
from agent.redis_coord.card_debounce import debounce_key, try_acquire as try_card_debounce
logger = logging.getLogger(__name__)
def _pick_card_flow(sender_id: str, payload: dict | None):
"""
点卡:先认多段联运,再认海运/陆运,最后才空运。
多段工单主账停点常被收成 tms_miss。若先走空运,「拉产品进群」会被丢掉、销售点了没回声。
"""
from agent.policy.multi_text_flow import get_multi_text_flow
multi = get_multi_text_flow()
multi_sess = multi.session_for_action(sender_id, payload)
if multi_sess and (multi_sess.business_line or "").upper() == "MULTI":
return multi
sea = get_sea_text_flow()
sess = sea.session_for_action(sender_id, payload)
if sess and (sess.business_line or "").upper() == "SEA":
return sea
land = get_land_text_flow()
land_sess = land.session_for_action(sender_id, payload)
if land_sess and (land_sess.business_line or "").upper() == "LAND":
return land
return get_air_text_flow()
def handle_card_action(
*,
sender_id: str,
thread_id: str,
wait_version: int,
action: str,
payload: dict[str, Any] | None = None,
flow=None,
store=None,
) -> str:
"""
处理一次卡片动作。
wait_version:会话书签版本;错版本丢弃,不重放副作用。
payload 须带 task_id / event_key,才能找回点卡当时的工单。
连点:同一 task_id+按钮在防抖 TTL 内只跑一次业务,避免多条智能体提示。
"""
_ = thread_id
if not (sender_id or "").strip():
return "reject_no_sender"
engine = flow or _pick_card_flow(sender_id, payload)
sess = engine.session_for_action(sender_id, payload)
if sess and wait_version and sess.wait_version != wait_version:
logger.info("card waitVersion 不匹配 expect=%s got=%s", sess.wait_version, wait_version)
return "card_discard"
msg_store = store or get_message_store()
wo_hint = ""
if sess is not None:
wo_hint = (sess.work_order_no or "").strip()
if not wo_hint:
wo_hint = copy.work_order_from_card_meta(payload)
# 集中防抖:键用 task_id+动作,不用 ResponseCode(企微每次点击换新码)
dkey = debounce_key(
action=action,
task_id=str((payload or {}).get("task_id") or ""),
work_order_no=wo_hint,
sender_id=sender_id,
event_key=str((payload or {}).get("event_key") or ""),
)
if not try_card_debounce(dkey):
logger.info(
"card 防抖丢弃 sender=%s action=%s key=%s",
sender_id,
action,
dkey,
)
# 仍用新 ResponseCode 置灰,避免企微按钮一直可点;不发业务 ack
code = str((payload or {}).get("response_code") or "").strip()
if code:
msg_store.enqueue_outbound(
touser=sender_id,
content=copy.BTN_SKIP_COLLAB_DONE,
dedupe_key=f"card-grey:{sender_id}:{wo_hint}:{code}",
payload=copy.skip_collab_card_update(
response_code=code,
replace_name=action if action in {
copy.BTN_DEAL, copy.BTN_LOST, copy.BTN_NEGOTIATE
} else "",
),
)
return "card_debounced"
def reply(text: str, extra: dict | None = None) -> str:
# 销售询价正文不加 [test];[test] 只给运维告警
body = text
extra = dict(extra or {})
wo = ""
if sess is not None:
wo = (sess.work_order_no or "").strip()
if not wo:
wo = copy.work_order_from_card_meta(payload)
code = str((payload or {}).get("response_code") or extra.get("response_code") or "")[:24]
msgtype = str(extra.get("msgtype") or "text")
# 文本/方案卡按工单+动作+waitVersion 去重。
# 调价后再点「生成」时正文前缀相同,若不带 waitVersion,第二次方案卡会被 PG 唯一键吃掉,
# 价格卡已置灰成「已提交」但销售收不到生成 Excel/PDF 的方案卡。
wait_ver = int(getattr(sess, "wait_version", 0) or 0) if sess is not None else 0
if msgtype == "update_template_card":
dedupe_key = f"card-grey:{sender_id}:{wo}:{code}:{action}"
else:
dedupe_key = f"card-ack:{sender_id}:{wo}:{action}:v{wait_ver}:{body[:24]}"
ok, info = msg_store.enqueue_outbound(
touser=sender_id,
content=body,
dedupe_key=dedupe_key,
payload=extra,
)
if not ok:
logger.warning("card 出站被去重 sender=%s wo=%s info=%s", sender_id, wo or "-", info)
return ""
return info
return engine.on_button(
sender_id=sender_id, action=action, reply=reply, card_meta=payload
)