Files
inquiry_robot/inquiry-agent/agent/handlers/group_collab.py
T
jillion886andCursor 43e633f359 修好多段含空运的群跟单、锁舱话术、相册报价通道,以及附件上传成功后电脑端关不掉 H5。
多段激活后无需再带工单号;含空运群回复走机器人;复制补问清单时空货好时间不再吃成下一行编号。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-08 15:23:06 +08:00

321 lines
9.8 KiB
Python

"""
意图:海运协同群入站(文字意图 / 附件)。
本文件职责:对上工单后交给 sea_group_ops;一类动作一条分支。
禁止:改询价七项;终态装没看见;把销售附件当报价。
"""
from __future__ import annotations
import logging
from typing import Any, Optional
from agent.channel.wecom.models import InboundMessage
from agent.policy.sea_group_ops import (
apply_collab_update,
apply_confirm_tms_quote,
apply_group_deal,
apply_product_quote,
apply_tms_quote_choice,
capture_group_lost_reason,
dissolve_group,
group_client_of,
handle_group_file,
hydrate_ticket,
is_handoff_ticket,
peek_pending_product_quote,
send_group_text,
ticket_no,
ticket_view,
)
from agent.policy.sea_text_flow import get_sea_text_flow
logger = logging.getLogger(__name__)
def handle_group_collab(
message: InboundMessage,
*,
flow=None,
ledger=None,
injected_intent: str = "",
injected_quote: Optional[dict[str, Any]] = None,
) -> str:
"""
处理一条群聊入站。兼容旧测试名。
必须能对上已绑定工单,且发言人是被拉进来的销售或产品。
"""
return handle_group_inbound(
message,
flow=flow,
ledger=ledger,
injected_intent=injected_intent,
injected_quote=injected_quote,
)
def handle_group_inbound(
message: InboundMessage,
*,
flow=None,
ledger=None,
injected_intent: str = "",
injected_quote: Optional[dict[str, Any]] = None,
) -> str:
"""
群里一条文字或附件。
文字走 DeepSeek A(单测可注入);附件只看扩展名。
已成交/未成交/已关闭要回话。真正转人工后群里不再跟、不回。
"""
if (message.chat_type or "") != "group":
return "group_ignore"
if not message.has_sender():
return "reject_no_sender"
engine = flow or get_sea_text_flow()
book = ledger or engine.ledger
chat_id = (message.chat_id or "").strip()
if not chat_id:
return "group_ignore"
ticket = _find_ticket(book, chat_id)
if ticket is None:
logger.info("group_inbound 对不上工单 chat=%s", chat_id)
return "group_ignore"
if flow is None:
view0 = ticket_view(ticket)
line = str(
view0.get("business_line")
or view0.get("businessLine")
or getattr(ticket, "business_line", "")
or ""
).upper()
if line == "LAND":
from agent.policy.land_text_flow import get_land_text_flow
engine = get_land_text_flow()
view0 = ticket_view(ticket)
line = str(
view0.get("business_line")
or view0.get("businessLine")
or getattr(ticket, "business_line", "")
or ""
).upper()
if line == "MULTI":
from agent.policy.multi_group_ops import bot_sender_when_air_multi, handle_multi_inbound, sender_mode
if not (
_sender_allowed(ticket, message.sender_id)
or sender_mode(engine, message.sender_id)
):
logger.info("group_inbound 多段忽略非成员 sender=%s", message.sender_id)
return "group_ignore"
# 会话存档听到的报价也走这里。含空运时群是机器人建的,必须改走桥才能发得出去。
with bot_sender_when_air_multi(ticket):
return handle_multi_inbound(
flow=engine,
ticket=ticket,
chat_id=chat_id,
sender_id=message.sender_id,
text=message.content or "",
injected_quote=injected_quote,
)
ticket = hydrate_ticket(engine, ticket)
if not _sender_allowed(ticket, message.sender_id):
logger.info("group_inbound 忽略非成员 sender=%s chat=%s", message.sender_id, chat_id)
return "group_ignore"
from agent.policy.system_exception import reply_paused_ticket, ticket_is_paused
if ticket_is_paused(ticket):
reply_paused_ticket(
ticket=ticket,
reply=lambda text: send_group_text(group_client_of(engine), chat_id, text),
)
return "system_exception_paused"
sess = _session_of_ticket(engine, ticket)
if is_handoff_ticket(ticket, sess):
logger.info(
"group_inbound 转人工不再跟 wo=%s chat=%s",
ticket_no(ticket),
chat_id,
)
return "handoff_ignore"
view = ticket_view(ticket)
if view.get("collab_ended") or (sess is not None and getattr(sess, "collab_ended", False)):
from agent.policy import inquiry_copy as copy
send_group_text(
group_client_of(engine),
chat_id,
copy.group_dissolved(work_order_no=ticket_no(ticket)),
)
return "group_already_ended"
kind = (message.msg_type or "text").lower()
if kind in {"file", "image", "video"}:
media = dict(message.media or {})
filename = str(
media.get("filename") or media.get("FileName") or (message.content or "")
).strip()
return handle_group_file(
flow=engine,
ticket=ticket,
sender_id=message.sender_id,
filename=filename,
media=media,
sess=sess,
)
from agent.routing.group_intent import (
INTENT_CONFIRM_TMS,
INTENT_DEAL,
INTENT_DISSOLVE,
INTENT_DROP_TMS,
INTENT_FIELDS,
INTENT_KEEP_TMS,
INTENT_LOST,
INTENT_NEGOTIATE,
INTENT_ADJUST,
INTENT_QUOTE,
classify_group_text,
)
lost = capture_group_lost_reason(
flow=engine,
ticket=ticket,
sender_id=message.sender_id,
text=message.content or "",
sess=sess,
)
if lost:
return lost
intent = classify_group_text(message.content or "", injected=injected_intent)
logger.info(
"group_inbound 文字 chat=%s sender=%s intent=%s chars=%s",
chat_id,
message.sender_id,
intent,
len((message.content or "").strip()),
)
if intent in {INTENT_DEAL, INTENT_LOST, INTENT_NEGOTIATE}:
outcome = {
INTENT_DEAL: "已成交",
INTENT_LOST: "未成交",
INTENT_NEGOTIATE: "协商中",
}[intent]
return apply_group_deal(
flow=engine,
ticket=ticket,
sender_id=message.sender_id,
outcome=outcome,
sess=sess,
text=message.content or "",
)
if intent == INTENT_CONFIRM_TMS:
return apply_confirm_tms_quote(
flow=engine,
ticket=ticket,
sender_id=message.sender_id,
sess=sess,
)
if intent in {INTENT_KEEP_TMS, INTENT_DROP_TMS}:
return apply_tms_quote_choice(
flow=engine,
ticket=ticket,
sender_id=message.sender_id,
keep_tms=intent == INTENT_KEEP_TMS,
sess=sess,
)
if intent == INTENT_FIELDS:
return apply_collab_update(
flow=engine,
ticket=ticket,
text=message.content or "",
sender_id=message.sender_id,
sess=sess,
)
if intent == INTENT_ADJUST:
from agent.policy.quote_adjust_ops import apply_group_adjust
return apply_group_adjust(
flow=engine,
ticket=ticket,
sender_id=message.sender_id,
text=message.content or "",
sess=sess,
chat_id=chat_id,
)
if intent == INTENT_QUOTE:
return apply_product_quote(
flow=engine,
ticket=ticket,
text=message.content or "",
sender_id=message.sender_id,
sess=sess,
injected_quote=injected_quote,
)
if intent == INTENT_DISSOLVE:
return dissolve_group(flow=engine, ticket=ticket, sess=sess)
parked = peek_pending_product_quote(ticket_no(ticket), sess)
if parked:
from agent.policy import inquiry_copy as copy
send_group_text(group_client_of(engine), chat_id, copy.group_tms_choice_unclear())
return "wait_tms_choice"
logger.info("group_inbound 其它意图 chat=%s intent=%s", chat_id, intent)
return "group_ignore"
def _find_ticket(book: object, chat_id: str):
finder = getattr(book, "find_by_collab_chat", None)
if callable(finder):
hit = finder(chat_id=chat_id)
if hit is not None:
return hit
try:
from agent.channel.wshoto.rooms import ticket_no_for_room
no = ticket_no_for_room(chat_id)
except Exception:
logger.exception("存档房间反查工单失败 chat=%s", chat_id)
no = ""
if not no:
return None
getter = getattr(book, "get_ticket", None)
if callable(getter):
hit = getter(work_order_no=no)
if hit is not None:
return hit
getter = getattr(book, "get_for_agent", None)
if callable(getter):
return getter(work_order_no=no, sender_id="")
return None
def _session_of_ticket(engine: object, ticket: object):
"""用工单号找回销售侧书签,方便写 collab_facts / deal_version。"""
no = ticket_no(ticket)
getter = getattr(engine, "session_by_work_order", None)
if callable(getter) and no:
return getter(no)
by_ticket = getattr(engine, "_by_ticket", None)
if isinstance(by_ticket, dict) and no:
return by_ticket.get(no)
return None
def _sender_allowed(ticket: object, sender_id: str) -> bool:
uid = (sender_id or "").strip()
if not uid:
return False
view = ticket_view(ticket)
sales = str(view.get("sales_wecom_id") or view.get("salesWecomId") or "")
members = view.get("collab_member_ids") or view.get("memberIds") or []
if uid == sales:
return True
return uid in {str(x) for x in members}