Files
inquiry_robot/inquiry-agent/agent/routing/group_intent.py
T

350 lines
12 KiB
Python
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
海运协同群文字意图: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 ""