""" 空运协同群文字意图:激活 / 补含电含磁 / 航线报价 / 锁舱释放 / 成交。 本文件职责:只分类,不写主账、不发企微。 禁止:把「锁舱」写成唯一口令;海运解散不走这里。 """ from __future__ import annotations import logging from typing import Optional from agent.policy import inquiry_copy as copy from agent.routing.group_intent import ( INTENT_CONFIRM_TMS, INTENT_DEAL, INTENT_DROP_TMS, INTENT_FIELDS, INTENT_KEEP_TMS, INTENT_ADJUST, INTENT_LOST, INTENT_NEGOTIATE, INTENT_OTHER, INTENT_QUOTE, _exact_tms_choice, _hint_confirm_tms, _hint_deal_outcome, _hint_tms_choice, _prefer_staff_quote, text_is_staff_fee_quote, ) logger = logging.getLogger(__name__) INTENT_ACTIVATE = "air_activate" INTENT_LOCK = "air_lock" INTENT_RELEASE = "air_release" # 说出和舱位有关的「释放」:先问是否取消,不直接执行。 INTENT_RELEASE_ASK = "air_release_ask" INTENT_PICK_OPTION = "air_pick_option" _VALID = { INTENT_ACTIVATE, INTENT_LOCK, INTENT_RELEASE, INTENT_RELEASE_ASK, INTENT_PICK_OPTION, INTENT_FIELDS, INTENT_QUOTE, INTENT_ADJUST, INTENT_KEEP_TMS, INTENT_DROP_TMS, INTENT_CONFIRM_TMS, INTENT_DEAL, INTENT_LOST, INTENT_NEGOTIATE, INTENT_OTHER, } # 相近说法回退,不是唯一口令。 _LOCK_HINTS = ("锁舱", "锁仓", "锁定舱位", "锁住舱位", "帮我锁舱") # 这四句马上取消舱位。「释放舱位」不在这里。 _RELEASE_DIRECT = ("取消舱位", "放舱", "解锁舱", "取消锁舱") _CONFIRM_YES = ("是", "需要", "确认") _CONFIRM_NO = ("不需要", "不用", "否") def is_missed_air_bot_at(text: str, *, room_already_air: bool = False) -> bool: """ 存档里看到用户已经 @ 询价小助手。BOT 长连接没推到时,用这一刀补听。 海运拉人卡片也带「询价小助手」,必须排除。闲聊没有 @ 仍不当业务。 """ raw = (text or "").strip() if "询价小助手" not in raw: return False if "运输方式:海运" in raw or "运输方式:海运" in raw: return False wo = copy.extract_work_order_no(raw) if wo: compact = "".join(raw.split()) return len(compact) < 80 return bool(room_already_air) def classify_air_group_text( text: str, *, injected: str = "", allow_network: Optional[bool] = None, ) -> str: """ 空运群一句文字的意图。单测可注入;正式走模型。 锁舱/释放优先于成交,避免「锁舱成交」误伤。 """ hint = (injected or "").strip() if hint in _VALID: return hint raw = (text or "").strip() if not raw: return INTENT_OTHER locked = _hint_lock_or_release(raw) if locked: return locked # 补舱位不限写法。凑齐舱位编号、航司、航班号就当选舱,不要先当成报价。 from agent.policy.air_group_ops import text_has_lock_supplement if text_has_lock_supplement(raw): return INTENT_PICK_OPTION # 本单已有 TMS 报价时,航线回「2」是选不用 TMS,不是把价格改成 2。 exact = _exact_tms_choice(raw) if exact: return exact # wo_plus_deal_not_activate:工单号+成交跟进同一句先关单,不能只当激活。 closed = _hint_deal_outcome(raw) if copy.extract_work_order_no(raw) and closed: return closed # 只发工单号先当激活,不要先打模型;WO + 12 位数字也会被费用行误认成金额。 if copy.extract_work_order_no(raw) and _only_work_order(raw): return INTENT_ACTIVATE try: from agent.config import get_settings settings = get_settings() use_net = ( allow_network if allow_network is not None else bool(getattr(settings, "llm_allow_network", False)) ) if use_net and getattr(settings, "llm_data_usage_confirmed", False): got = _classify_with_model(raw, settings) if got in _VALID: return _prefer_staff_quote(got, raw) except Exception: logger.exception("空运群意图模型失败,回退规则") from agent.llm.extract_text import extract_air_collab_fields, extract_product_quote from agent.schema.quote_adjust import extract_adjust # 费用金额先当航线报价。「空运费改为900」不是销售调价。 if text_is_staff_fee_quote(raw): return INTENT_QUOTE if extract_adjust(raw).is_adjust: return INTENT_ADJUST if extract_product_quote(raw): return INTENT_QUOTE confirmed = _hint_confirm_tms(raw) if confirmed: return confirmed hinted = _hint_tms_choice(raw) if hinted: return hinted closed = _hint_deal_outcome(raw) if closed: return closed if extract_air_collab_fields(raw): return INTENT_FIELDS if _looks_like_option(raw): return INTENT_PICK_OPTION return INTENT_OTHER def _compact_cabin_text(text: str) -> str: """去掉空白,方便认「取消舱位」「释放舱位」。""" return (text or "").replace(" ", "").replace(" ", "") def cabin_release_kind(text: str) -> str: """ 舱位取消怎么认。 direct:马上执行。ask:先问「是否需要取消舱位」。 跟舱位有关的「释放」只问不办。没说舱位的「释放」不当取消。 """ blob = _compact_cabin_text(text) # 「释放舱位」里含着「放舱」,必须先认释放,避免被放舱直接取消。 if "释放" in blob and "舱" in blob: return "ask" if any(hint in blob for hint in _RELEASE_DIRECT): return "direct" return "" def cabin_confirm_answer(text: str) -> str: """ 问过之后这一句是确认、否认,还是别的业务。 只认整句(可带工单号和 @)。「不需要」不能被看成「需要」。 返回 yes / no / 空串。空串表示询问作废,调用方按原句继续办。 """ raw = text or "" no = copy.extract_work_order_no(raw) rest = raw.replace(no, "").replace(no.lower(), "") rest = rest.replace("@询价小助手", "").replace("询价小助手", "") rest = "".join(ch for ch in rest if ch.isalnum() or "\u4e00" <= ch <= "\u9fff") if rest in _CONFIRM_NO: return "no" if rest in _CONFIRM_YES or rest == "取消舱位": return "yes" return "" def _hint_lock_or_release(text: str) -> str: kind = cabin_release_kind(text) if kind == "direct": return INTENT_RELEASE if kind == "ask": return INTENT_RELEASE_ASK blob = _compact_cabin_text(text) if any(x in blob for x in _LOCK_HINTS): return INTENT_LOCK return "" def _only_work_order(text: str) -> bool: """ 去掉工单号和 @询价小助手 后几乎没字,当作激活/切单。 销售常按话术复制「工单编号WO…@询价小助手」;残留「工单编号」不算业务意图。 运行证据:only_wo=false 后走 extract_product_quote→product_quote→航线岗拒绝。 """ no = copy.extract_work_order_no(text) rest = (text or "").replace(no, "").replace(no.lower(), "") rest = rest.replace("@询价小助手", "").replace("询价小助手", "") for noise in ("工单编号", "工单号", "编号"): rest = rest.replace(noise, "") rest = "".join(ch for ch in rest if ch.isalnum() or "\u4e00" <= ch <= "\u9fff") return not rest.strip() def _looks_like_option(text: str) -> bool: blob = (text or "").replace(" ", "").upper() return blob.startswith("AIR-OPT") or blob.startswith("AIROPT") def _classify_with_model(text: str, settings) -> str: from agent.llm.http_client import LlmHttpClient from agent.llm.modes import LlmMode client = LlmHttpClient.from_settings(settings) system = ( "你是空运协同群意图分类。只回复一个英文词,不要解释。" "air_lock=航线要锁舱;air_release=航线要取消舱位(取消舱位、放舱、解锁舱、取消锁舱);" "说出释放舱位、释放舱、把舱位释放了时不要回 air_release;" "air_activate=只是指定/切换工单号;" "air_pick_option=在补充或选择舱位编号、航司、航班号,写法不限;" "collab_fields=在补是否含电/是否含磁;" "quote_adjust=销售在按比例改已有费用(上调、上浮、加10%、减5%、加一成),或只改总价/总销售价;" "product_quote=航线在报某一项费用的金额。写出「空运费9900」「空运费改为900USD」是报价,不是调价;" "quote_confirm_tms=确认沿用当前TMS报价;" "quote_keep_tms=要在已有TMS报价上改;" "quote_drop_tms=不要TMS、只用刚报的价;" "deal_won=销售确认成交;deal_lost=销售确认未成交;" "deal_negotiate=销售要继续协商;" "other=闲聊。" "一句话里既有锁舱又有工单号,回 air_lock。" "一句话里既有工单号又确认成交/未成交/协商中,回 deal_won/deal_lost/deal_negotiate,不要回 air_activate。" "有具体费用金额就是 product_quote。只有比例或只改总价才是 quote_adjust。" ) data = client.chat( mode=LlmMode.ORDERED_ACTIONS, messages=[ {"role": "system", "content": system}, {"role": "user", "content": text[:500]}, ], ) got = str((data or {}).get("content") or "").strip().split()[0] if data else "" return got if got in _VALID else ""