落地空运群内协同与锁舱出站。
群里按 BOT 通道激活当前工单、航线报价和成交跟进;没有 TMS 报价编号时用工单字段补齐仍调锁舱,避免被本地规则拦住。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -2,7 +2,7 @@
|
||||
Handler 包:一类意图 / 一类卡片一个模块。
|
||||
|
||||
禁止:单文件处理全部卡片;禁止把 Schema 校验复制粘贴进每个 handler(放 schema/policy)。
|
||||
当前:文字询价空运/海运闭环 + 海运群协同字段 + echo 联调残留。
|
||||
当前:文字询价空运/海运闭环 + 海运应用群协同 + 空运 BOT 群文字/附件 + echo 联调残留。
|
||||
"""
|
||||
|
||||
from agent.handlers.echo_text import handle_echo_text
|
||||
|
||||
@@ -0,0 +1,310 @@
|
||||
"""
|
||||
意图:空运协同群入站(BOT @ 之后的文字或附件)。
|
||||
|
||||
本文件职责:激活/切单后交给 air_group_ops。海运应用群不进这里。
|
||||
禁止:存档文字当业务;锁舱用当前工单去猜;套 sea_group_ops 建群。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Any, Optional
|
||||
|
||||
from agent.channel.wecom.models import InboundMessage
|
||||
from agent.policy import inquiry_copy as copy
|
||||
from agent.policy.air_group_ops import (
|
||||
activate_ticket,
|
||||
apply_air_confirm_tms,
|
||||
apply_air_deal,
|
||||
apply_air_fields,
|
||||
apply_air_file,
|
||||
apply_air_lock,
|
||||
apply_air_quote,
|
||||
apply_air_release,
|
||||
apply_air_tms_choice,
|
||||
current_ticket,
|
||||
peek_air_pending,
|
||||
resolve_cabin_ticket,
|
||||
)
|
||||
from agent.policy.air_text_flow import get_air_text_flow
|
||||
from agent.policy.sea_group_ops import group_client_of, send_group_text, ticket_view
|
||||
from agent.routing.air_group_intent import (
|
||||
INTENT_ACTIVATE,
|
||||
INTENT_CONFIRM_TMS,
|
||||
INTENT_DEAL,
|
||||
INTENT_DROP_TMS,
|
||||
INTENT_FIELDS,
|
||||
INTENT_KEEP_TMS,
|
||||
INTENT_LOCK,
|
||||
INTENT_LOST,
|
||||
INTENT_NEGOTIATE,
|
||||
INTENT_OTHER,
|
||||
INTENT_PICK_OPTION,
|
||||
INTENT_QUOTE,
|
||||
INTENT_RELEASE,
|
||||
classify_air_group_text,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _attach_bot_client(engine, message: InboundMessage) -> None:
|
||||
"""
|
||||
每次入站换一次 BOT 出站客户端。
|
||||
|
||||
测试注入的 FakeBotGroup 不动。
|
||||
有 response_url 走当场流式回;没有(存档附件)走桥主动发言,马上在群里说。
|
||||
不能复用上一次 @ 的过期 url。
|
||||
"""
|
||||
from agent.channel.aibot.reply import AibotReplyClient
|
||||
|
||||
existing = engine.group_client()
|
||||
if existing is not None and not isinstance(existing, AibotReplyClient):
|
||||
return
|
||||
engine._group_client = AibotReplyClient(
|
||||
str((message.raw or {}).get("response_url") or "")
|
||||
)
|
||||
|
||||
|
||||
def handle_air_group(
|
||||
message: InboundMessage,
|
||||
*,
|
||||
flow=None,
|
||||
ledger=None,
|
||||
injected_intent: str = "",
|
||||
injected_quote: Optional[dict[str, Any]] = None,
|
||||
) -> str:
|
||||
"""
|
||||
空运群入站:文字必须 @ BOT;附件走会话存档,不能 @。
|
||||
|
||||
航线 Excel/PDF 当报价;别人的只存档。无 sender_id 拒绝。
|
||||
BOT 与存档 msgid 对不上:同一句只办一遍,存档后到就跳过,再 @ 仍回。
|
||||
"""
|
||||
if (message.chat_type or "") != "group":
|
||||
return "group_ignore"
|
||||
if not message.has_sender():
|
||||
return "reject_no_sender"
|
||||
engine = flow or get_air_text_flow()
|
||||
if ledger is not None:
|
||||
engine.ledger = ledger
|
||||
_attach_bot_client(engine, message)
|
||||
chat_id = (message.chat_id or "").strip()
|
||||
if not chat_id:
|
||||
return "group_ignore"
|
||||
kind = (message.msg_type or "text").lower()
|
||||
if kind in {"file", "image", "video"}:
|
||||
return _handle_file(engine, message)
|
||||
text = message.content or ""
|
||||
from agent.redis_coord.air_inbound import mark_air_text, should_skip_air_text
|
||||
|
||||
if should_skip_air_text(
|
||||
chat_id=chat_id,
|
||||
sender_id=message.sender_id,
|
||||
text=text,
|
||||
raw=message.raw,
|
||||
):
|
||||
return "duplicate_inbound"
|
||||
mark_air_text(chat_id=chat_id, sender_id=message.sender_id, text=text)
|
||||
wo = copy.extract_work_order_no(text)
|
||||
intent = classify_air_group_text(text, injected=injected_intent)
|
||||
logger.info(
|
||||
"air_group 文字 chat=%s sender=%s intent=%s wo=%s",
|
||||
chat_id,
|
||||
message.sender_id,
|
||||
intent,
|
||||
wo or "-",
|
||||
)
|
||||
if intent in {INTENT_LOCK, INTENT_RELEASE}:
|
||||
return _handle_cabin(
|
||||
engine,
|
||||
message,
|
||||
intent=intent,
|
||||
work_order_no=wo,
|
||||
injected_quote=injected_quote,
|
||||
)
|
||||
ticket = None
|
||||
if wo:
|
||||
ticket, phase = activate_ticket(
|
||||
flow=engine,
|
||||
chat_id=chat_id,
|
||||
work_order_no=wo,
|
||||
speak=intent == INTENT_ACTIVATE or _should_speak_activate(intent),
|
||||
)
|
||||
if ticket is None:
|
||||
return phase
|
||||
if intent == INTENT_ACTIVATE:
|
||||
return phase
|
||||
else:
|
||||
ticket = current_ticket(engine, chat_id)
|
||||
if ticket is None:
|
||||
send_group_text(group_client_of(engine), chat_id, copy.air_need_work_order())
|
||||
return "need_work_order"
|
||||
return _handle_bound(
|
||||
engine,
|
||||
message,
|
||||
ticket=ticket,
|
||||
intent=intent,
|
||||
injected_quote=injected_quote,
|
||||
)
|
||||
|
||||
|
||||
def _handle_file(engine, message: InboundMessage) -> str:
|
||||
"""
|
||||
存档听到的附件(发文件不能 @)。句中/文件名有工单号先切单;没有就跟当前单。
|
||||
"""
|
||||
chat_id = message.chat_id
|
||||
media = dict(message.media or {})
|
||||
filename = str(
|
||||
media.get("filename") or media.get("FileName") or (message.content or "")
|
||||
).strip()
|
||||
wo = copy.extract_work_order_no(
|
||||
" ".join([message.content or "", filename])
|
||||
)
|
||||
if wo:
|
||||
ticket, phase = activate_ticket(
|
||||
flow=engine, chat_id=chat_id, work_order_no=wo, speak=False
|
||||
)
|
||||
if ticket is None:
|
||||
return phase
|
||||
else:
|
||||
ticket = current_ticket(engine, chat_id)
|
||||
if ticket is None:
|
||||
send_group_text(group_client_of(engine), chat_id, copy.air_need_work_order())
|
||||
return "need_work_order"
|
||||
return apply_air_file(
|
||||
flow=engine,
|
||||
ticket=ticket,
|
||||
sender_id=message.sender_id,
|
||||
filename=filename,
|
||||
media=media,
|
||||
chat_id=chat_id,
|
||||
)
|
||||
|
||||
|
||||
def _should_speak_activate(intent: str) -> bool:
|
||||
"""带工单号又办事时,先切单再办事;切单摘要只在纯激活时说,避免刷屏。"""
|
||||
return intent in {INTENT_ACTIVATE, INTENT_OTHER}
|
||||
|
||||
|
||||
def _handle_cabin(
|
||||
engine,
|
||||
message: InboundMessage,
|
||||
*,
|
||||
intent: str,
|
||||
work_order_no: str,
|
||||
injected_quote: Optional[dict[str, Any]],
|
||||
) -> str:
|
||||
_ = injected_quote
|
||||
chat_id = message.chat_id
|
||||
if not work_order_no:
|
||||
send_group_text(group_client_of(engine), chat_id, copy.air_lock_need_work_order())
|
||||
return "cabin_need_work_order"
|
||||
ticket, phase = resolve_cabin_ticket(
|
||||
flow=engine,
|
||||
chat_id=chat_id,
|
||||
work_order_no=work_order_no,
|
||||
allow_terminal=intent == INTENT_RELEASE,
|
||||
)
|
||||
if ticket is None:
|
||||
return phase
|
||||
if intent == INTENT_RELEASE:
|
||||
return apply_air_release(
|
||||
flow=engine,
|
||||
ticket=ticket,
|
||||
sender_id=message.sender_id,
|
||||
chat_id=chat_id,
|
||||
instruction=message.content or "",
|
||||
)
|
||||
return apply_air_lock(
|
||||
flow=engine,
|
||||
ticket=ticket,
|
||||
sender_id=message.sender_id,
|
||||
chat_id=chat_id,
|
||||
instruction=message.content or "",
|
||||
)
|
||||
|
||||
|
||||
def _handle_bound(
|
||||
engine,
|
||||
message: InboundMessage,
|
||||
*,
|
||||
ticket: object,
|
||||
intent: str,
|
||||
injected_quote: Optional[dict[str, Any]],
|
||||
) -> str:
|
||||
chat_id = message.chat_id
|
||||
facts = dict(ticket_view(ticket).get("collab_facts") or {})
|
||||
if intent == INTENT_PICK_OPTION or (
|
||||
intent == INTENT_OTHER and str(facts.get("__pending_air_lock") or "") == "1"
|
||||
):
|
||||
option = _option_from_text(message.content or "")
|
||||
if option:
|
||||
return apply_air_lock(
|
||||
flow=engine,
|
||||
ticket=ticket,
|
||||
sender_id=message.sender_id,
|
||||
chat_id=chat_id,
|
||||
instruction=message.content or "",
|
||||
option_no=option,
|
||||
)
|
||||
if intent in {INTENT_DEAL, INTENT_LOST, INTENT_NEGOTIATE}:
|
||||
outcome = {
|
||||
INTENT_DEAL: "已成交",
|
||||
INTENT_LOST: "未成交",
|
||||
INTENT_NEGOTIATE: "协商中",
|
||||
}[intent]
|
||||
return apply_air_deal(
|
||||
flow=engine,
|
||||
ticket=ticket,
|
||||
sender_id=message.sender_id,
|
||||
outcome=outcome,
|
||||
chat_id=chat_id,
|
||||
)
|
||||
if intent == INTENT_CONFIRM_TMS:
|
||||
return apply_air_confirm_tms(
|
||||
flow=engine, ticket=ticket, sender_id=message.sender_id, chat_id=chat_id
|
||||
)
|
||||
if intent in {INTENT_KEEP_TMS, INTENT_DROP_TMS}:
|
||||
return apply_air_tms_choice(
|
||||
flow=engine,
|
||||
ticket=ticket,
|
||||
sender_id=message.sender_id,
|
||||
chat_id=chat_id,
|
||||
keep_tms=intent == INTENT_KEEP_TMS,
|
||||
)
|
||||
if intent == INTENT_FIELDS:
|
||||
return apply_air_fields(
|
||||
flow=engine,
|
||||
ticket=ticket,
|
||||
text=message.content or "",
|
||||
sender_id=message.sender_id,
|
||||
chat_id=chat_id,
|
||||
)
|
||||
if intent == INTENT_QUOTE:
|
||||
return apply_air_quote(
|
||||
flow=engine,
|
||||
ticket=ticket,
|
||||
text=message.content or "",
|
||||
sender_id=message.sender_id,
|
||||
chat_id=chat_id,
|
||||
injected_quote=injected_quote,
|
||||
)
|
||||
if peek_air_pending(ticket):
|
||||
send_group_text(group_client_of(engine), chat_id, copy.group_tms_choice_unclear())
|
||||
return "wait_tms_choice"
|
||||
logger.info("air_group 其它意图 chat=%s intent=%s", chat_id, intent)
|
||||
return "group_ignore"
|
||||
|
||||
|
||||
def _option_from_text(text: str) -> str:
|
||||
blob = (text or "").replace(" ", "").upper()
|
||||
if "AIR-OPT" in blob or "AIROPT" in blob:
|
||||
start = blob.find("AIR")
|
||||
token = []
|
||||
for ch in blob[start:]:
|
||||
if ch.isalnum() or ch == "-":
|
||||
token.append(ch)
|
||||
else:
|
||||
break
|
||||
return "".join(token)
|
||||
return ""
|
||||
@@ -13,10 +13,15 @@ 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,
|
||||
dissolve_group,
|
||||
group_client_of,
|
||||
handle_group_file,
|
||||
hydrate_ticket,
|
||||
peek_pending_product_quote,
|
||||
send_group_text,
|
||||
ticket_no,
|
||||
ticket_view,
|
||||
@@ -76,6 +81,7 @@ def handle_group_inbound(
|
||||
if ticket is None:
|
||||
logger.info("group_inbound 对不上工单 chat=%s", chat_id)
|
||||
return "group_ignore"
|
||||
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"
|
||||
@@ -108,13 +114,54 @@ def handle_group_inbound(
|
||||
)
|
||||
|
||||
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_QUOTE,
|
||||
classify_group_text,
|
||||
)
|
||||
|
||||
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,
|
||||
)
|
||||
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,
|
||||
@@ -134,6 +181,12 @@ def handle_group_inbound(
|
||||
)
|
||||
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"
|
||||
|
||||
|
||||
Reference in New Issue
Block a user