落地空运群内协同与锁舱出站。
群里按 BOT 通道激活当前工单、航线报价和成交跟进;没有 TMS 报价编号时用工单字段补齐仍调锁舱,避免被本地规则拦住。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,184 @@
|
||||
"""
|
||||
空运协同群文字意图:激活 / 补含电含磁 / 航线报价 / 锁舱释放 / 成交。
|
||||
|
||||
本文件职责:只分类,不写主账、不发企微。
|
||||
禁止:把「锁舱」写成唯一口令;海运解散不走这里。
|
||||
"""
|
||||
|
||||
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_LOST,
|
||||
INTENT_NEGOTIATE,
|
||||
INTENT_OTHER,
|
||||
INTENT_QUOTE,
|
||||
_hint_confirm_tms,
|
||||
_hint_deal_outcome,
|
||||
_hint_tms_choice,
|
||||
)
|
||||
|
||||
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_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
|
||||
# 只发工单号先当激活,不要先打模型;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 got
|
||||
except Exception:
|
||||
logger.exception("空运群意图模型失败,回退规则")
|
||||
from agent.llm.extract_text import extract_air_collab_fields, extract_product_quote
|
||||
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=在补是否含电/是否含磁;"
|
||||
"product_quote=航线在报价格或费用(含具体金额);"
|
||||
"quote_confirm_tms=确认沿用当前TMS报价;"
|
||||
"quote_keep_tms=要在已有TMS报价上改;"
|
||||
"quote_drop_tms=不要TMS、只用刚报的价;"
|
||||
"deal_won=销售确认成交;deal_lost=销售确认未成交;"
|
||||
"deal_negotiate=销售要继续协商;"
|
||||
"other=闲聊。"
|
||||
"一句话里既有锁舱又有工单号,回 air_lock。"
|
||||
"有金额就是 product_quote。"
|
||||
)
|
||||
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 ""
|
||||
@@ -128,6 +128,18 @@ HANDLER_REGISTRY: dict[str, HandlerFn] = {
|
||||
}
|
||||
|
||||
|
||||
def _is_air_collab_room(message: InboundMessage) -> bool:
|
||||
"""当前群已激活空运工单。查不到或海运群返回 False。"""
|
||||
try:
|
||||
from agent.handlers.air_group_collab import get_air_text_flow
|
||||
from agent.policy.air_group_ops import bound_air_ticket
|
||||
|
||||
return bound_air_ticket(get_air_text_flow(), message.chat_id or "") is not None
|
||||
except Exception:
|
||||
logger.exception("判断空运协同群失败 chat=%s", getattr(message, "chat_id", ""))
|
||||
return False
|
||||
|
||||
|
||||
def resolve_handler(intent: str) -> Optional[HandlerFn]:
|
||||
"""查表;未知意图返回 None。"""
|
||||
return HANDLER_REGISTRY.get(intent)
|
||||
@@ -144,14 +156,44 @@ def dispatch_inbound(message: InboundMessage, decision: RouteDecision) -> str:
|
||||
from agent.policy import inquiry_copy as copy
|
||||
|
||||
event = str((message.raw or {}).get("event") or "").lower()
|
||||
action = copy.canonical_button(message.content or "")
|
||||
if event in {"template_card_event", "click", "templatecardevent"} or action in {
|
||||
copy.BTN_DEAL,
|
||||
copy.BTN_LOST,
|
||||
copy.BTN_NEGOTIATE,
|
||||
}:
|
||||
raw_text = (message.content or "").strip()
|
||||
# 只有真点卡才走成交卡;群里打「成交」两个字要当文字成交,不能当私聊点卡丢掉。
|
||||
is_card_event = event in {"template_card_event", "click", "templatecardevent"}
|
||||
is_card_key = raw_text.startswith(
|
||||
(copy.BTN_DEAL_KEY + ":", copy.BTN_LOST_KEY + ":", copy.BTN_NEGOTIATE_KEY + ":")
|
||||
)
|
||||
if is_card_event or is_card_key:
|
||||
_wrap_card(message)
|
||||
return "card_action"
|
||||
source = str((message.raw or {}).get("source") or "").lower()
|
||||
if source in {"aibot", "aibot_bridge"}:
|
||||
from agent.handlers.air_group_collab import handle_air_group
|
||||
|
||||
phase = handle_air_group(message)
|
||||
logger.info("dispatch air_group phase=%s chat=%s", phase, message.chat_id)
|
||||
return "air_group_collab"
|
||||
# 发文件不能 @:空运群附件靠会话存档听见。
|
||||
# 文字本应走 BOT;BOT 没推到时,存档里已经带 @询价小助手 的短句按空运补听。
|
||||
kind = (message.msg_type or "text").lower()
|
||||
air_room = _is_air_collab_room(message)
|
||||
if source == "wshoto_archive" and kind in {"text", ""}:
|
||||
from agent.routing.air_group_intent import is_missed_air_bot_at
|
||||
|
||||
if is_missed_air_bot_at(raw_text, room_already_air=air_room):
|
||||
from agent.handlers.air_group_collab import handle_air_group
|
||||
|
||||
phase = handle_air_group(message)
|
||||
logger.info("dispatch air_group archive_at phase=%s chat=%s", phase, message.chat_id)
|
||||
return "air_group_collab"
|
||||
if air_room:
|
||||
if kind in {"file", "image", "video"}:
|
||||
from agent.handlers.air_group_collab import handle_air_group
|
||||
|
||||
phase = handle_air_group(message)
|
||||
logger.info("dispatch air_group archive_file phase=%s chat=%s", phase, message.chat_id)
|
||||
return "air_group_collab"
|
||||
logger.info("dispatch 空运群存档文字忽略,须 @ BOT chat=%s", message.chat_id)
|
||||
return "air_archive_text_ignore"
|
||||
phase = handle_group_collab(message)
|
||||
logger.info("dispatch group_collab phase=%s chat=%s", phase, message.chat_id)
|
||||
return "group_collab"
|
||||
|
||||
@@ -17,9 +17,102 @@ logger = logging.getLogger(__name__)
|
||||
INTENT_FIELDS = "collab_fields"
|
||||
INTENT_QUOTE = "product_quote"
|
||||
INTENT_DISSOLVE = "dissolve"
|
||||
INTENT_KEEP_TMS = "quote_keep_tms"
|
||||
INTENT_DROP_TMS = "quote_drop_tms"
|
||||
INTENT_CONFIRM_TMS = "quote_confirm_tms"
|
||||
INTENT_DEAL = "deal_won"
|
||||
INTENT_LOST = "deal_lost"
|
||||
INTENT_NEGOTIATE = "deal_negotiate"
|
||||
INTENT_OTHER = "other"
|
||||
|
||||
_VALID = {INTENT_FIELDS, INTENT_QUOTE, INTENT_DISSOLVE, INTENT_OTHER}
|
||||
_VALID = {
|
||||
INTENT_FIELDS,
|
||||
INTENT_QUOTE,
|
||||
INTENT_DISSOLVE,
|
||||
INTENT_KEEP_TMS,
|
||||
INTENT_DROP_TMS,
|
||||
INTENT_CONFIRM_TMS,
|
||||
INTENT_DEAL,
|
||||
INTENT_LOST,
|
||||
INTENT_NEGOTIATE,
|
||||
INTENT_OTHER,
|
||||
}
|
||||
|
||||
# 口令不写死:这两句是建议说法,相近意思也认。有金额的仍当报价。
|
||||
# 问句列了 1/2 时,产品常回「第一个」而不是整句口令。
|
||||
_KEEP_HINTS = (
|
||||
"在tms基础上",
|
||||
"在tms上改",
|
||||
"保留tms",
|
||||
"按tms改",
|
||||
"按tms的改",
|
||||
"留着tms",
|
||||
"在原来基础上",
|
||||
"在原报价上",
|
||||
"在原基础上",
|
||||
)
|
||||
_KEEP_CHOICE = {
|
||||
"1",
|
||||
"1.",
|
||||
"1、",
|
||||
"一",
|
||||
"第一",
|
||||
"第一个",
|
||||
"第一条",
|
||||
"选项1",
|
||||
"选项一",
|
||||
"选1",
|
||||
"选一",
|
||||
"第1",
|
||||
"第1个",
|
||||
}
|
||||
_DROP_CHOICE = {
|
||||
"2",
|
||||
"2.",
|
||||
"2、",
|
||||
"二",
|
||||
"第二",
|
||||
"第二个",
|
||||
"第二条",
|
||||
"选项2",
|
||||
"选项二",
|
||||
"选2",
|
||||
"选二",
|
||||
"第2",
|
||||
"第2个",
|
||||
}
|
||||
# 先认未成交,避免「未成交」被「成交」误伤。
|
||||
_LOST_HINTS = ("未成交", "没成交", "没有成交", "没做成", "黄了")
|
||||
_NEGOTIATE_HINTS = ("协商中", "继续谈", "再谈谈", "还在谈")
|
||||
_DEAL_HINTS = ("成交", "成了", "做成了", "已经成交", "搞定了")
|
||||
_CONFIRM_CHOICE = {
|
||||
"确认报价",
|
||||
"确认该报价",
|
||||
"确认这个报价",
|
||||
"确认tms报价",
|
||||
"沿用该价",
|
||||
"沿用tms",
|
||||
"沿用tms报价",
|
||||
}
|
||||
_CONFIRM_HINTS = (
|
||||
"确认报价",
|
||||
"确认该报价",
|
||||
"确认这个报价",
|
||||
"确认tms报价",
|
||||
"沿用该价",
|
||||
"沿用tms",
|
||||
)
|
||||
_DROP_HINTS = (
|
||||
"不用tms报价",
|
||||
"不要tms报价",
|
||||
"不用tms",
|
||||
"不要tms",
|
||||
"不要tms的价",
|
||||
"按我的报价",
|
||||
"按我刚才报",
|
||||
"只用我的",
|
||||
"全部按我",
|
||||
)
|
||||
|
||||
|
||||
def classify_group_text(
|
||||
@@ -51,18 +144,80 @@ def classify_group_text(
|
||||
if use_net and getattr(settings, "llm_data_usage_confirmed", False):
|
||||
got = _classify_with_model(raw, settings)
|
||||
if got in _VALID:
|
||||
hinted_confirm = _hint_confirm_tms(raw)
|
||||
if hinted_confirm and got in {INTENT_OTHER, INTENT_FIELDS}:
|
||||
logger.info("群意图模型判成%s,按口令改回 %s text=%s", got, hinted_confirm, raw[:40])
|
||||
return hinted_confirm
|
||||
hinted_tms = _hint_tms_choice(raw)
|
||||
if hinted_tms and got in {INTENT_OTHER, INTENT_FIELDS}:
|
||||
logger.info("群意图模型判成%s,按口令改回 %s text=%s", got, hinted_tms, raw[:40])
|
||||
return hinted_tms
|
||||
hinted_deal = _hint_deal_outcome(raw)
|
||||
# 销售说成交时,模型不能把群拆了。
|
||||
if hinted_deal and got == INTENT_DISSOLVE:
|
||||
logger.warning("群意图模型把成交判成解散,改回 %s text=%s", hinted_deal, raw[:40])
|
||||
return hinted_deal
|
||||
return got
|
||||
except Exception:
|
||||
logger.exception("群意图模型失败,回退标签字段")
|
||||
if extract_collab_fields(raw):
|
||||
return INTENT_FIELDS
|
||||
from agent.llm.extract_text import extract_product_quote
|
||||
|
||||
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_collab_fields(raw):
|
||||
return INTENT_FIELDS
|
||||
return INTENT_OTHER
|
||||
|
||||
|
||||
def _norm_intent_text(text: str) -> str:
|
||||
return (text or "").replace(" ", "").replace(" ", "").lower()
|
||||
|
||||
|
||||
def _hint_confirm_tms(text: str) -> str:
|
||||
"""沿用当前 TMS 价。口令不写死,确认报价 / 沿用该价都认。"""
|
||||
blob = _norm_intent_text(text)
|
||||
if blob in _CONFIRM_CHOICE:
|
||||
return INTENT_CONFIRM_TMS
|
||||
if any(x in blob for x in _CONFIRM_HINTS):
|
||||
return INTENT_CONFIRM_TMS
|
||||
return ""
|
||||
|
||||
|
||||
def _hint_tms_choice(text: str) -> str:
|
||||
"""相近口令回退。有价格的句子不会走到这里。整句是 1/第一个 也认。"""
|
||||
blob = _norm_intent_text(text)
|
||||
if blob in _KEEP_CHOICE:
|
||||
return INTENT_KEEP_TMS
|
||||
if blob in _DROP_CHOICE:
|
||||
return INTENT_DROP_TMS
|
||||
if any(x in blob for x in _KEEP_HINTS):
|
||||
return INTENT_KEEP_TMS
|
||||
if any(x in blob for x in _DROP_HINTS):
|
||||
return INTENT_DROP_TMS
|
||||
return ""
|
||||
|
||||
|
||||
def _hint_deal_outcome(text: str) -> str:
|
||||
"""成交/未成交/协商中:建议说法 + 相近口语。"""
|
||||
blob = _norm_intent_text(text)
|
||||
if any(x in blob for x in _LOST_HINTS):
|
||||
return INTENT_LOST
|
||||
if any(x in blob for x in _NEGOTIATE_HINTS):
|
||||
return INTENT_NEGOTIATE
|
||||
if any(x in blob for x in _DEAL_HINTS):
|
||||
return INTENT_DEAL
|
||||
return ""
|
||||
|
||||
|
||||
def _classify_with_model(text: str, settings) -> str:
|
||||
"""DeepSeek 只回一个意图词。失败空串。"""
|
||||
from agent.llm.http_client import LlmHttpClient
|
||||
@@ -72,10 +227,17 @@ def _classify_with_model(text: str, settings) -> str:
|
||||
system = (
|
||||
"你是海运协同群意图分类。只回复一个英文词,不要解释。"
|
||||
"collab_fields=在补贸易条款/货好时间/商品海关编码/含油含电含磁;"
|
||||
"product_quote=产品在报价格或费用;"
|
||||
"product_quote=产品在报价格或费用(含具体金额);"
|
||||
"quote_confirm_tms=产品确认沿用当前TMS报价,没有报新金额;"
|
||||
"quote_keep_tms=要在已有TMS报价上改,没有报新金额;"
|
||||
"quote_drop_tms=不要TMS、只用产品刚报的价,没有报新金额;"
|
||||
"deal_won=销售确认这单成交了;"
|
||||
"deal_lost=销售确认这单未成交;"
|
||||
"deal_negotiate=销售要继续协商;"
|
||||
"dissolve=要把这个协同群解散;"
|
||||
"other=闲聊、问候、对询价港口的随口更正(不当改单)。"
|
||||
"一句话里既补字段又报价,回 product_quote。"
|
||||
"有金额就是 product_quote,不要判成 quote_keep_tms。"
|
||||
)
|
||||
data = client.chat(
|
||||
mode=LlmMode.ORDERED_ACTIONS,
|
||||
@@ -85,7 +247,18 @@ def _classify_with_model(text: str, settings) -> str:
|
||||
],
|
||||
)
|
||||
content = str(getattr(data, "content", "") or "").strip().lower()
|
||||
for token in (INTENT_QUOTE, INTENT_DISSOLVE, INTENT_FIELDS, INTENT_OTHER):
|
||||
for token in (
|
||||
INTENT_QUOTE,
|
||||
INTENT_CONFIRM_TMS,
|
||||
INTENT_KEEP_TMS,
|
||||
INTENT_DROP_TMS,
|
||||
INTENT_LOST,
|
||||
INTENT_NEGOTIATE,
|
||||
INTENT_DEAL,
|
||||
INTENT_DISSOLVE,
|
||||
INTENT_FIELDS,
|
||||
INTENT_OTHER,
|
||||
):
|
||||
if token in content:
|
||||
return token
|
||||
return ""
|
||||
|
||||
Reference in New Issue
Block a user