落地空运群内协同与锁舱出站。

群里按 BOT 通道激活当前工单、航线报价和成交跟进;没有 TMS 报价编号时用工单字段补齐仍调锁舱,避免被本地规则拦住。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-09-16 17:25:42 +08:00
co-authored by Cursor
parent 12219cacb1
commit d9029dec89
62 changed files with 6772 additions and 159 deletions
+1 -1
View File
@@ -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"