""" 意图:空运协同群入站(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, lock_params_from_text, text_has_lock_supplement, 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 _follow_activated_multi( engine, *, chat_id: str, sender_id: str, text: str, injected_quote: Optional[dict[str, Any]], named_wo: str = "", ) -> Optional[str]: """ 多段单激活后,后续消息跟这张单,不必再带工单号。 句中写了工单号就认那张;没写就认本群已经绑上的多段单。 纯空运、陆运旧单返回 None,仍走空运自己的当前单。 副作用:可能发群消息、写入分段报价。在入站线程调用。 """ from agent.policy.multi_group_ops import handle_multi_inbound, ticket_is_multi from agent.policy.sea_group_ops import ticket_no ticket = None named = (named_wo or "").strip() if named: getter = getattr(engine.ledger, "get_ticket", None) raw = getter(work_order_no=named) if callable(getter) else None if raw is not None and ticket_is_multi(raw): ticket = raw else: bound = current_ticket(engine, chat_id) if bound is not None and ticket_is_multi(bound): ticket = bound if ticket is None: return None logger.info( "air_group 多段跟单 wo=%s chat=%s named=%s", ticket_no(ticket), chat_id, "1" if named else "0", ) return handle_multi_inbound( flow=engine, ticket=ticket, chat_id=chat_id, sender_id=sender_id, text=text, injected_quote=injected_quote, ) 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) # 句中有多段工单号,或本群已经激活过多段单:跟这张单,不必每句再带工单号。 followed = _follow_activated_multi( engine, chat_id=chat_id, sender_id=message.sender_id, text=text, injected_quote=injected_quote, named_wo=wo, ) if followed is not None: return followed if wo: 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 ( cabin_failure_pause, reply_paused_ticket, ticket_is_paused, ) # 查价失败的暂停仍要拦住。锁舱/释放失败已经告诉过原因,不能挡住后面的报价和成交。 if ( ticket_is_paused(ticket) and not cabin_failure_pause(ticket) and not _deal_ignores_tms_pause(ticket, intent) ): 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]) ) # 多段已激活的附件也跟当前单,不能再要求先发工单号。 followed = _follow_activated_multi( engine, chat_id=chat_id, sender_id=message.sender_id, text=" ".join(part for part in ((message.content or "").strip(), filename) if part), injected_quote=None, named_wo=wo, ) if followed is not None: return followed 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: # 本群已经在锁这张单时,补舱位不用再报工单号。 bound = current_ticket(engine, chat_id) if bound is not None and is_air_ticket(bound): ticket = bound 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 "", ) option_no, airline, flight_no = lock_params_from_text(message.content or "") return apply_air_lock( flow=engine, ticket=ticket, sender_id=message.sender_id, chat_id=chat_id, instruction=message.content or "", option_no=option_no, airline=airline, flight_no=flight_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 "", ) # 「锁 AIR-OPT01 CZ 航班CZ327」要带上舱位、航司、航班号。缺了先补,不能直接锁。 option_no, airline, flight_no = lock_params_from_text(message.content or "") return apply_air_lock( flow=engine, ticket=ticket, sender_id=message.sender_id, chat_id=chat_id, instruction=message.content or "", option_no=option_no, airline=airline, flight_no=flight_no, ) def _deal_ignores_tms_pause(ticket: object, intent: str) -> bool: """ 成交跟进不因 TMS 暂停而改口。 上午锁舱失败会把工单标成暂停。之后群里确认未成交、或补未成交原因, 不能再回「TMS接口异常」。锁舱、释放仍走各自的重试。 """ if intent in {INTENT_DEAL, INTENT_LOST, INTENT_NEGOTIATE}: return True view = ticket_view(ticket) status = str(view.get("status") or "").strip() reason = str(view.get("lost_reason") or "").strip() return status == "未成交" and not reason 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 {}) pending_lock = str(facts.get("__pending_air_lock") or "") == "1" # 等补舱位时,报价/选线路等意图让路:话里带了舱位、航司或航班号就继续锁。 if intent == INTENT_PICK_OPTION or pending_lock or text_has_lock_supplement(message.content or ""): option_no, airline, flight_no = lock_params_from_text(message.content or "") if option_no or airline or flight_no: return apply_air_lock( flow=engine, ticket=ticket, sender_id=message.sender_id, chat_id=chat_id, instruction=message.content or "", option_no=option_no, airline=airline, flight_no=flight_no, ) 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, ) from agent.policy.sea_group_ops import is_sales from agent.routing.group_intent import resolve_price_intent # 航线写出「空运费改为900」是报价。模型若判成调价,这里按说话人改回来。 priced = resolve_price_intent( intent, message.content or "", sales=is_sales(ticket, message.sender_id), ) if priced != intent: logger.info("air_group 报价/调价改判 %s -> %s chat=%s", intent, priced, chat_id) intent = priced 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"