400 lines
13 KiB
Python
400 lines
13 KiB
Python
"""
|
|
意图:空运协同群入站(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 (
|
|
is_air_ticket,
|
|
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 (
|
|
capture_group_lost_reason,
|
|
group_client_of,
|
|
is_handoff_ticket,
|
|
send_group_text,
|
|
ticket_view,
|
|
)
|
|
from agent.routing.air_group_intent import (
|
|
INTENT_ACTIVATE,
|
|
INTENT_ADJUST,
|
|
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 也不走 stream;空运群正文一律走桥。
|
|
把这次入站的 aibot_req_id 带给桥,群里才能引用原 @。
|
|
不能复用上一次 @ 的过期 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 ""),
|
|
callback_req_id=str((message.raw or {}).get("aibot_req_id") 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)
|
|
if wo:
|
|
from agent.policy.multi_group_ops import handle_multi_inbound, ticket_is_multi
|
|
|
|
getter = getattr(engine.ledger, "get_ticket", None)
|
|
raw_ticket = getter(work_order_no=wo) if callable(getter) else None
|
|
if raw_ticket is not None and ticket_is_multi(raw_ticket):
|
|
return handle_multi_inbound(
|
|
flow=engine,
|
|
ticket=raw_ticket,
|
|
chat_id=chat_id,
|
|
sender_id=message.sender_id,
|
|
text=text,
|
|
injected_quote=injected_quote,
|
|
)
|
|
from agent.policy.handoff_human import try_commit_air_group_handoff
|
|
|
|
handed = try_commit_air_group_handoff(engine, chat_id=chat_id, work_order_no=wo)
|
|
if handed:
|
|
return handed
|
|
intent = classify_air_group_text(text, injected=injected_intent)
|
|
# 注入/模型若仍判成激活,同一句里的成交跟进不能丢掉。
|
|
if wo and intent == INTENT_ACTIVATE:
|
|
from agent.routing.group_intent import _hint_deal_outcome
|
|
|
|
hinted = _hint_deal_outcome(text)
|
|
if hinted:
|
|
logger.info("air_group 工单号+成交跟进同一句,按 %s 办 wo=%s", hinted, wo)
|
|
intent = hinted
|
|
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 is_handoff_ticket(ticket):
|
|
logger.info("air_group 转人工不再跟 chat=%s", chat_id)
|
|
return "handoff_ignore"
|
|
if intent == INTENT_ACTIVATE:
|
|
return phase
|
|
else:
|
|
ticket = current_ticket(engine, chat_id)
|
|
if ticket is None or not is_air_ticket(ticket):
|
|
send_group_text(group_client_of(engine), chat_id, copy.air_need_work_order())
|
|
return "need_work_order"
|
|
if is_handoff_ticket(ticket):
|
|
logger.info("air_group 转人工不再跟 chat=%s", chat_id)
|
|
return "handoff_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"
|
|
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 or not is_air_ticket(ticket):
|
|
send_group_text(group_client_of(engine), chat_id, copy.air_need_work_order())
|
|
return "need_work_order"
|
|
if is_handoff_ticket(ticket):
|
|
logger.info("air_group 转人工不再跟 chat=%s", chat_id)
|
|
return "handoff_ignore"
|
|
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 "",
|
|
)
|
|
# 「WO…锁舱,锁AIR-OPT01 TH」要带上句中的舱位和航司,不能只靠报价里碰巧只有一条。
|
|
return apply_air_lock(
|
|
flow=engine,
|
|
ticket=ticket,
|
|
sender_id=message.sender_id,
|
|
chat_id=chat_id,
|
|
instruction=message.content or "",
|
|
option_no=_option_from_text(message.content or ""),
|
|
airline=_airline_from_text(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
|
|
lost = capture_group_lost_reason(
|
|
flow=engine,
|
|
ticket=ticket,
|
|
sender_id=message.sender_id,
|
|
text=message.content or "",
|
|
)
|
|
if lost:
|
|
return lost
|
|
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,
|
|
airline=_airline_from_text(message.content or ""),
|
|
)
|
|
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,
|
|
text=message.content or "",
|
|
)
|
|
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_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=None,
|
|
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:
|
|
"""从「锁AIR-OPT01 TH」抽出舱位编号;不要把后面的航司码拼进 optionNo。"""
|
|
import re
|
|
|
|
m = re.search(r"AIR-?OPT-?\d+", (text or "").upper())
|
|
if not m:
|
|
return ""
|
|
raw = m.group(0)
|
|
# 统一成 TMS 常见写法 AIR-OPT01(中间可保留已有的第二根横杠)
|
|
if raw.startswith("AIROPT"):
|
|
return "AIR-OPT" + raw[6:]
|
|
return raw
|
|
|
|
|
|
def _airline_from_text(text: str) -> str:
|
|
"""从「AIR-OPT01 TH」抽出两位航司码;没有就不猜。"""
|
|
import re
|
|
|
|
blob = (text or "").upper()
|
|
m = re.search(r"AIR-?OPT-?\d+\s*([A-Z]{2})(?![A-Z0-9])", blob)
|
|
if m:
|
|
return m.group(1)
|
|
return ""
|