212 lines
7.3 KiB
Python
212 lines
7.3 KiB
Python
"""
|
||
空运协同群文字意图:激活 / 补含电含磁 / 航线报价 / 锁舱释放 / 成交。
|
||
|
||
本文件职责:只分类,不写主账、不发企微。
|
||
禁止:把「锁舱」写成唯一口令;海运解散不走这里。
|
||
"""
|
||
|
||
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_PICK_OPTION = "air_pick_option"
|
||
|
||
_VALID = {
|
||
INTENT_ACTIVATE,
|
||
INTENT_LOCK,
|
||
INTENT_RELEASE,
|
||
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_HINTS = ("释放舱位", "放舱", "解锁舱", "取消锁舱", "释放舱")
|
||
|
||
|
||
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 _hint_lock_or_release(text: str) -> str:
|
||
blob = (text or "").replace(" ", "").replace(" ", "")
|
||
if any(x in blob for x in _RELEASE_HINTS):
|
||
return INTENT_RELEASE
|
||
if any(x in blob for x in _LOCK_HINTS):
|
||
return INTENT_LOCK
|
||
return ""
|
||
|
||
|
||
def _only_work_order(text: str) -> bool:
|
||
"""去掉工单号和 @询价小助手 后几乎没字,当作激活/切单。"""
|
||
no = copy.extract_work_order_no(text)
|
||
rest = (text or "").replace(no, "").replace(no.lower(), "")
|
||
rest = rest.replace("@询价小助手", "").replace("询价小助手", "")
|
||
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_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 ""
|