350 lines
12 KiB
Python
350 lines
12 KiB
Python
"""
|
||
海运协同群文字意图:DeepSeek A 判断补字段 / 产品报价 / 解散 / 其它。
|
||
|
||
本文件职责:只分类,不写主账、不发企微。
|
||
禁止:把「解散群」写成唯一口令;私聊按钮不走这里。
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import logging
|
||
from typing import Optional
|
||
|
||
from agent.llm.extract_text import extract_collab_fields
|
||
|
||
logger = logging.getLogger(__name__)
|
||
|
||
INTENT_FIELDS = "collab_fields"
|
||
INTENT_QUOTE = "product_quote"
|
||
INTENT_ADJUST = "quote_adjust"
|
||
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_ADJUST,
|
||
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 text_is_staff_fee_quote(text: str) -> bool:
|
||
"""
|
||
航线/产品在报某一项费用的金额。
|
||
|
||
「空运费 9900」「空运费改为900USD」「报关费改为300CNY」都算手工报价。
|
||
比例调价和只改总价/总销售价不算,那两句仍留给销售调价。
|
||
句里没有费用名、只剩一个数字,也不算,避免把选项编号当成报价。
|
||
不打模型。分类和入站改判都会用。
|
||
"""
|
||
raw = (text or "").strip()
|
||
if not raw:
|
||
return False
|
||
from agent.schema.quote_adjust import text_has_relative_adjust, text_has_total_only_adjust
|
||
|
||
if text_has_relative_adjust(raw) or text_has_total_only_adjust(raw):
|
||
return False
|
||
from agent.llm.extract_text import extract_product_quote
|
||
|
||
quote = extract_product_quote(raw)
|
||
rows = [row for row in (quote.get("fee_rows") or []) if isinstance(row, dict)]
|
||
if not rows:
|
||
return False
|
||
# 「费用合计」里的「费」不能单独把改总价认成报价;上面已经排除只改总价。
|
||
if "费" in raw:
|
||
return True
|
||
names = [str(row.get("name") or "").strip() for row in rows]
|
||
return any(name and name != "海运费" for name in names)
|
||
|
||
|
||
def resolve_price_intent(intent: str, text: str, *, sales: bool) -> str:
|
||
"""
|
||
按说话人把「报价」和「调价」分开。
|
||
|
||
航线/产品写出费用金额:按报价办,不能回「只有销售能调价」。
|
||
销售在做比例、改总价,或点名把某项费用改到某个金额:仍按调价办。
|
||
不打模型。群入站分发前调用,避免手工报价被调价话术拦住。
|
||
"""
|
||
if intent not in {INTENT_ADJUST, INTENT_QUOTE}:
|
||
return intent
|
||
from agent.schema.quote_adjust import extract_adjust
|
||
|
||
# 这里只用本地规则。正式调价抽取仍在调价处理里,必要时再问模型。
|
||
if sales and extract_adjust(text, allow_model=False).is_adjust:
|
||
return INTENT_ADJUST
|
||
if text_is_staff_fee_quote(text):
|
||
return INTENT_QUOTE
|
||
return intent
|
||
|
||
|
||
def _prefer_staff_quote(intent: str, text: str) -> str:
|
||
"""模型或本地规则把费用金额判成调价时,改回报价。比例和改总价不动。"""
|
||
if intent == INTENT_ADJUST and text_is_staff_fee_quote(text):
|
||
logger.info("判成调价,但这是费用报价,改回报价 text=%s", (text or "")[:40])
|
||
return INTENT_QUOTE
|
||
return intent
|
||
|
||
|
||
def classify_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
|
||
# 问句让回 1 或 2。整句就是选项时先认,不能把「2」抽成价格。
|
||
exact = _exact_tms_choice(raw)
|
||
if exact:
|
||
return exact
|
||
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:
|
||
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 in {INTENT_DISSOLVE, INTENT_OTHER, INTENT_FIELDS}:
|
||
logger.warning("群意图模型把成交判成%s,改回 %s text=%s", got, hinted_deal, raw[:40])
|
||
return hinted_deal
|
||
return _prefer_staff_quote(got, raw)
|
||
except Exception:
|
||
logger.exception("群意图模型失败,回退标签字段")
|
||
from agent.llm.extract_text import 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_collab_fields(raw):
|
||
return INTENT_FIELDS
|
||
return INTENT_OTHER
|
||
|
||
|
||
def _norm_intent_text(text: str) -> str:
|
||
"""去掉空格和 @询价小助手,方便整句就是「2」时对上选项。"""
|
||
blob = (text or "").replace(" ", "").replace(" ", "").lower()
|
||
blob = blob.replace("@询价小助手", "").replace("询价小助手", "")
|
||
return blob.strip()
|
||
|
||
|
||
def _exact_tms_choice(text: str) -> str:
|
||
"""整句只是 1 / 2 / 第一个 / 第二个。这是在选题,不是报价金额。"""
|
||
blob = _norm_intent_text(text)
|
||
if blob in _KEEP_CHOICE:
|
||
return INTENT_KEEP_TMS
|
||
if blob in _DROP_CHOICE:
|
||
return INTENT_DROP_TMS
|
||
return ""
|
||
|
||
|
||
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
|
||
from agent.llm.modes import LlmMode
|
||
|
||
client = LlmHttpClient.from_settings(settings)
|
||
system = (
|
||
"你是海运协同群意图分类。只回复一个英文词,不要解释。"
|
||
"collab_fields=在补货好时间/商品海关编码/含油含电含磁;"
|
||
"quote_adjust=销售在按比例改已有费用(上调、上浮、加10%、减5%、加一成),或只改总价/总销售价;"
|
||
"product_quote=产品在报某一项费用的金额。写出「运费1000」「报关费改为300CNY」是报价,不是调价;"
|
||
"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_adjust 或 quote_keep_tms。"
|
||
"只有比例或只改总价才是 quote_adjust。"
|
||
)
|
||
data = client.chat(
|
||
mode=LlmMode.ORDERED_ACTIONS,
|
||
messages=[
|
||
{"role": "system", "content": system},
|
||
{"role": "user", "content": text[:2000]},
|
||
],
|
||
)
|
||
content = str(getattr(data, "content", "") or "").strip().lower()
|
||
for token in (
|
||
INTENT_ADJUST,
|
||
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 ""
|