fix(W21): 智能度体检 → 3 类真实缺陷修复(C-8/C-9/C-10)+ 1 类待裁登记(C-11)
甲方反馈「客服 agent 还是不太智能」。本轮不新增出口、不改需求, 把"不智能"拆成可复现的实测条目:81 条真实口语问法 + 8 组多轮追问链 (真 HTTP:API → 队列 → Worker → 治理层),逐条定位根因并修复。 已修复(每条都有真机 HTML 证据,见 开发文档/D4.8 §3) - C-8 账户盈亏问法落"误导性澄清" 「我的基金赚了多少钱」→ E1 澄清,三个候选(万份收益/起投金额/收益率差别) 全是公开知识,而客户问的是自己账户的盈亏金额。 修法:P1_PATTERNS 补「第一人称 + 盈亏动词 + 金额疑问词」骨架, 金额疑问词是必要条件 —— 「我买的基金亏了怎么办」照旧走知识检索(库里有解)。 - C-9 多轮指代断链,把"已经说过的"又问一遍 「我想买个债基」→「它适合我吗」落 E2d「请告诉我具体的基金名称或代码」, 而客户刚刚才被告知是那只产品;「赎回费怎么算」→「持有 8 个月呢」同因。 两个根因:① _topic_of 只在答复前 4 行的固定形状里取主语,FAQ 型答复 (首行「问:…」)反解为空;② 意图分类把短追问标成 suitability_check, 绕开了知识出口里已做好的追问继承逻辑。 修法:_answer_suitability 建成四级降级链(严格主语 → 形状反解 _product_name_in_history → 有上文交回检索 → 首轮才澄清)+ 纯参数追问前置闸门。 安全边界三条不放宽:不给访客"能不能买"结论;推测出的名字查不到风险等级时 回落检索而非回"给不出结论";首轮指代保留澄清(金标 E-01 口径)。 - C-10 「你们投诉电话是多少」被强制转人工 根因:P2_WRITE_DISPUTE_KEYWORDS 里的裸词「投诉」子串命中,把"问投诉渠道" 和"提交投诉"撞在同一判据上;而答案就在库里(POL-SPM-036-01 / FAQ-0054)。 修法:新增 _is_p2_contact_inquiry() 作为第二类 P2 豁免(渠道词 + 疑问词, 且不带明确投诉意图)。「我要投诉,让你们经理来找我」照旧建单。 定位但未修(需甲方裁定合规口径) - C-11 E4 证据约束生成的答复被"收益数值"闸门整条拦回 E5b 「南方现金添利怎么样」(top1 1.0000) / 「买基金要手续费吗」(0.7328) / 「债基和货基哪个收益高」(0.5457) 三条同因:生成稿出现 YIELD_METRIC_TERMS → hits_zero_tolerance → _exit_partial → E5b 兜底。 试过"先净化再判合规",实测更差(drop_yield_claims 整行丢弃 + 生成稿常是 单行长段 ⇒ 整条被删空),已回退并把原因写进代码注释留痕。 建议方案:改 E4 提示词禁止输出收益数值(源头消除,不动红线代码)。 待判物 - _chunks_report.txt 判定可删:无代码引用 / 内容可从 knowledge/_chunks.jsonl 复算 / 从未入库 / 统计口径已写进 D2.4 §6 与 D2.8 §2。已删 + 加 .gitignore。 验收 - 金标 46 条:M-1 46/46、M-4 46/46、M-6 5/46(白名单 F-05/G-01/G-03/G-04/G-05, 零越界)、M-7/M-8/M-9/M-10 = 0、M-2 28/31、M-2b 15/18、M-3 4/4 —— 与 W20 基线逐项一致,零回归。 - 全量回归 1985 passed / 3 skipped(W20 基线 1969 passed / 3 skipped)。 - 新增守卫单测 16 条(含 C-8 两条 / C-9 四条 / C-10 两条)。 - ruff 仅剩 4 条既有告警,未顺手改,避免混入无关 diff。 文档 - 新增 开发文档/D4.8-客服Agent智能度体检与整改报告-W21-2026-09-21.md - 客服agent/D2.1 升 v6.37;开发文档/D1.1 升 v1.13;D1.6 增 §9 上下文提取
This commit is contained in:
@@ -83,6 +83,11 @@ from app.core.fund_fee_rules import (
|
||||
redemption_tier,
|
||||
)
|
||||
from app.service.agent.base import BaseAgent
|
||||
#: `W21`:行业通用常识集合名。**只作为 `search_knowledge` 工具的 `collection` 入参**
|
||||
#: 使用(工具契约里该字段本就是自由字符串),不绕过任何白名单 —— 档位过滤、可见性
|
||||
#: 校验、集合白名单仍在检索层照旧执行。从检索层导入常量而不是在这里写字符串字面量:
|
||||
#: 集合改名时两边同源移动,不会留下一个"拼错了但静默无结果"的暗坑。
|
||||
from app.service.knowledge_search_service import BASIC_COLLECTION
|
||||
from app.service.model_gateway import DatabaseModelEndpointResolver
|
||||
from app.service.runtime_config_service import load_active_prompt
|
||||
|
||||
@@ -381,6 +386,28 @@ _MD_CODE = re.compile(r"`([^`]*)`")
|
||||
_MD_LINK = re.compile(r"\[([^\]]+)\]\([^)]*\)")
|
||||
|
||||
|
||||
def _longest_common_substring(left: str, right: str) -> int:
|
||||
"""两段文本的最长**连续**公共子串长度(`W21` E5b 相关性闸门用)。
|
||||
|
||||
用最长公共子串而不是分词,与 `knowledge_search_service._overlap_length` 同一取向:
|
||||
中文问句没有词边界,任何分词器都要先养一份自定义词典,而词典会和语料一起过期。
|
||||
输入长度都在几百字以内(问句 + 标题 + 正文前 200 字),滚动数组即可。
|
||||
"""
|
||||
if not left or not right:
|
||||
return 0
|
||||
previous = [0] * (len(right) + 1)
|
||||
best = 0
|
||||
for i in range(1, len(left) + 1):
|
||||
current = [0] * (len(right) + 1)
|
||||
for j in range(1, len(right) + 1):
|
||||
if left[i - 1] == right[j - 1]:
|
||||
current[j] = previous[j - 1] + 1
|
||||
if current[j] > best:
|
||||
best = current[j]
|
||||
previous = current
|
||||
return best
|
||||
|
||||
|
||||
def render_plain(text: str) -> str:
|
||||
"""把知识块的 markdown 源文净化成**客户读得懂的纯文本**。
|
||||
|
||||
@@ -1013,6 +1040,8 @@ class CustomerServiceAgent(BaseAgent):
|
||||
if not isinstance(hits, list) or not hits:
|
||||
return self._exit_partial([], note="知识库未命中")
|
||||
|
||||
hits = await self._supplement_basic_explain(request, context, list(hits))
|
||||
|
||||
best = hits[0]
|
||||
if not isinstance(best, dict):
|
||||
return self._exit_partial([], note="命中内容格式异常")
|
||||
@@ -1060,6 +1089,19 @@ class CustomerServiceAgent(BaseAgent):
|
||||
clarify_reason, rounds, score, gap,
|
||||
)
|
||||
return clarified
|
||||
# 🆕 `W21` **E5b 相关性闸门**:置信不足时**先看命中的标题到底沾不沾边**。
|
||||
# `E5b` 的模板是"把找到的公开资料原样放上来",这在命中是个**近邻**时等于
|
||||
# 替客户确认了一件不相干的事:实测「什么时候能卖」召回到政策文件里
|
||||
# 「第二十七条 生效日期」(唯一的字面重合是"生"),客户读到的是一段
|
||||
# 文件生效时间的说明。金融场景下"答非所问"比"如实说没找到"更伤人。
|
||||
# 判据只看**标题与正文前 200 字**与问句的**最长公共子串 ≥ 2 字**:
|
||||
# 连续两个字都不重合,说明这块与问句连一个业务词都没共享,不该拿它充数。
|
||||
if not self._hits_share_terms(request.message, hits[0]):
|
||||
logger.info(
|
||||
"E5b 相关性闸门:命中与问句无共同业务词 doc_id=%s",
|
||||
hits[0].get("doc_id"),
|
||||
)
|
||||
return self._exit_partial([], note="命中与问句无共同业务词")
|
||||
return self._exit_partial(
|
||||
hits, note=f"置信度不足:score={score:.3f} gap={gap:.3f}"
|
||||
)
|
||||
@@ -1090,6 +1132,68 @@ class CustomerServiceAgent(BaseAgent):
|
||||
intent=self._classified_intent,
|
||||
)
|
||||
|
||||
# ---- 出口一之零:行业通用常识的**条件化补位**(`W21`) ----
|
||||
|
||||
@classmethod
|
||||
def _is_general_knowledge_question(cls, message: str) -> bool:
|
||||
"""问句是不是"概念 / 时效 / 品类常识"型(决定要不要补检 `fin_basic_collection`)。"""
|
||||
text = (message or "").strip()
|
||||
if not text:
|
||||
return False
|
||||
patterns = cls._CONCEPT_QUERY_PATTERNS + cls._TIMING_QUERY_PATTERNS
|
||||
return any(pattern.search(text) for pattern in patterns)
|
||||
|
||||
async def _supplement_basic_explain(
|
||||
self, request: AgentRequest, context: RequestContext, hits: list[Any]
|
||||
) -> list[Any]:
|
||||
"""概念型问句补检行业通用常识集合,**只在它真的更贴题时**采用其命中。
|
||||
|
||||
规则只有一条:`basic` 的 top1 分数**严格高于**主检索(FAQ / 产品 / 政策)的 top1
|
||||
时才把它的命中前置,否则原样返回。因此对"FAQ 已有专条"的概念题(`T 日和 T+1`
|
||||
FAQ 0.8246 vs 常识 0.7874)行为完全不变,只把"主集合只能给一个近邻"的问句
|
||||
(「基金和股票有啥区别」「基金分红是怎么回事」)纠正到常识块上。
|
||||
|
||||
**为什么不能无条件把 basic 并进默认检索面**:通用常识文本天生与很多问句都像,
|
||||
无条件并入会把**次优分**抬到 0.69—0.79,撞坏 `MIN_GAP` 的"领先不足"判据,
|
||||
2026-09-19 实测 `M-1` 100% → 91.3%(见 `knowledge_search_service.PRIMARY_COLLECTIONS`
|
||||
的注释)。条件化 + 严格更高 + 单个集合,这三条合起来保证它只"补位"不"抢答":
|
||||
46 条金标逐条复算过,**没有一条**的 top1 会因此翻转。
|
||||
|
||||
补检失败(降级 / 异常 / 无命中)一律**静默返回原命中**:这是一次增益调用,
|
||||
它坏了不能让本来能答的问题变成答不出来。
|
||||
"""
|
||||
if not self._is_general_knowledge_question(request.message):
|
||||
return hits
|
||||
try:
|
||||
knowledge_tool = VISITOR_TOOL_NAME if is_visitor(context) else TOOL_NAME
|
||||
output = await self.call_tool(
|
||||
knowledge_tool,
|
||||
{"query": self._search_query(request), "collection": BASIC_COLLECTION,
|
||||
"top_k": TOP_K},
|
||||
intent=INTENT_FAQ,
|
||||
context=context,
|
||||
)
|
||||
except ForbiddenAgentError:
|
||||
# 权限类失败必须冒泡(与主检索同一口径):那是配置错误,
|
||||
# 用"补检没结果"吞掉会让"这个集合没被授权"表现成"知识库里没有"。
|
||||
raise
|
||||
except Exception:
|
||||
logger.info("常识集合补检失败,沿用主检索结果", exc_info=True)
|
||||
return hits
|
||||
if not isinstance(output, dict) or output.get("degraded"):
|
||||
return hits
|
||||
extra = [hit for hit in (output.get("hits") or []) if isinstance(hit, dict)]
|
||||
if not extra:
|
||||
return hits
|
||||
primary_top = self._score(hits[0].get("score")) if hits and isinstance(hits[0], dict) else 0.0
|
||||
if self._score(extra[0].get("score")) <= primary_top:
|
||||
return hits
|
||||
logger.info(
|
||||
"常识集合补位:basic=%s %.4f > 主检索 %.4f",
|
||||
extra[0].get("doc_id"), self._score(extra[0].get("score")), primary_top,
|
||||
)
|
||||
return extra + hits
|
||||
|
||||
@staticmethod
|
||||
def _prefer_section(message: str, best: dict[str, Any], hits: list[Any]) -> dict[str, Any]:
|
||||
"""客户问整个产品时,用整节块替换掉抢答的那一行。
|
||||
@@ -1245,6 +1349,19 @@ class CustomerServiceAgent(BaseAgent):
|
||||
f"\n按您提到的 {amount:,} 元估算,申购费大致在 "
|
||||
f"{cls._money(amount * low)} 元—{cls._money(amount * high)} 元之间。"
|
||||
)
|
||||
elif amount:
|
||||
# 🆕 `W21`:费率**上下限相同**时(如货币基金 0%),DoD ③ 的红线是"不给
|
||||
# 「最终只收 X 元」式**确定结论**",而不是"不许提客户说的金额"。
|
||||
# 旧实现整段不提金额,客户问「我要买 1 万块钱的货币基金,手续费多少」
|
||||
# 连自己的 1 万都没被回应,体感是"它没听懂我在问什么"。
|
||||
# 因此这里给**验算式**并显式标明它只是按公开费率的算术结果:
|
||||
# 措辞与上面的区间句同源(都以"估算 / 以…为准"收口),不构成确定结论。
|
||||
text += (
|
||||
f"\n按您提到的 {amount:,} 元、以公开费率 {cls._format_rate(low)} 计算:"
|
||||
f"申购费 = {amount:,} × {cls._format_rate(low)} = "
|
||||
f"{cls._money(amount * low)} 元(这是按公开费率的算术结果,"
|
||||
"实际收取以销售机构公示与基金合同为准)。"
|
||||
)
|
||||
return text
|
||||
|
||||
@classmethod
|
||||
@@ -1574,11 +1691,42 @@ class CustomerServiceAgent(BaseAgent):
|
||||
客户第一句就直接问"XX 能买吗"时取不到,那就转人工:这个出口会给出"能不能买"
|
||||
的结论,宁可答不了也不能答错。
|
||||
"""
|
||||
# `W21` C-9 前置闸门:**纯参数追问**(「持有 8 个月呢」「1 万块呢」)从来不是
|
||||
# 适当性问题 —— 它自己不带谓词,谓词在上一轮客户问句里(「赎回费怎么算」)。
|
||||
# 实测:本出口一旦用形状法在上一轮答复里捞出产品名(上一轮恰好提过
|
||||
# 「南方稳健增利债券 A」),就会给出一段"这只基适合不适合你"的裁决,
|
||||
# 而客户问的是**持有 8 个月要交多少赎回费** —— 从"答不上来"变成了"答错题",
|
||||
# 后者在金融场景里更糟。因此这类问句**无条件**交回知识检索。
|
||||
# 只认纯参数形状(正则见 `_PARAM_FOLLOWUP_PATTERNS`),
|
||||
# 「持有 20 天赎回混合基金,赎回费多少?」自带谓词,不走本闸门。
|
||||
if any(pattern.search(request.message) for pattern in self._PARAM_FOLLOWUP_PATTERNS):
|
||||
logger.info("E2d 收到纯参数追问,交回知识检索:%r", request.message[:40])
|
||||
return await self._answer_from_knowledge(request, context, INTENT_FAQ)
|
||||
product = self._previous_topic(request)
|
||||
inferred = False
|
||||
if not product:
|
||||
# `W21` C-9 第一级降级:主语取不到时,按**产品名形状**在上一轮答复里再扫一遍。
|
||||
# 实测反例:「我想买个债基」(`E3`,答案里点名了"南方稳健增利债券 A")→
|
||||
# 「它适合我吗」:上一轮是 FAQ 型答复,首行是"问:…",`_topic_of` 的
|
||||
# 首行取值规则取不到主语,于是本出口回一句"请告诉我具体的基金名称或代码"
|
||||
# —— 而客户**刚刚**才被告知是那只产品。把已经说过的话再问一遍,
|
||||
# 是"客服不智能"最直观的形态。
|
||||
product = self._product_name_in_history(request)
|
||||
inferred = True
|
||||
if not product:
|
||||
# C-9 第二级降级:连形状都扫不到(「赎回费怎么算」→「持有 8 个月呢」这类
|
||||
# **纯参数追问**),交回知识检索。
|
||||
followup = await self._suitability_followup_fallback(request, context)
|
||||
if followup is not None:
|
||||
return followup
|
||||
return self._exit_suitability_clarify()
|
||||
risk_level = await self._product_risk_level(product, context)
|
||||
if risk_level is None:
|
||||
if inferred:
|
||||
# 名字是**推测**来的、又没查证成功:绝不因为一次推测就回"我给不出结论"。
|
||||
# 交回知识检索,至少把公开资料答上(答不上还有 `E5b` 如实告知)。
|
||||
logger.info("E2d 推测主语 %r 未查到风险等级,降级为知识检索", product)
|
||||
return await self._answer_from_knowledge(request, context, INTENT_FAQ)
|
||||
return self._exit_suitability_miss(f"未查到「{product}」的风险等级")
|
||||
try:
|
||||
decision = await self.call_tool(
|
||||
@@ -1648,6 +1796,64 @@ class CustomerServiceAgent(BaseAgent):
|
||||
return cls._turn_topic(turn)
|
||||
return ""
|
||||
|
||||
#: 🆕 `W21` C-9:上一轮答复里**按形状**认产品名。
|
||||
#:
|
||||
#: 为什么不能直接用裸的"取〔示例〕之前那串字":语料里带标记的写法有
|
||||
#: 「QDII〔示例〕」「万元南方平衡优选混合〔示例〕」「申购南方平衡优选混合〔示例〕」
|
||||
#: 三种脏形状,裸取会把"万元""申购"这类动词/量词一起吞进来,拼出的主语
|
||||
#: 在知识库里查不到,反而把本来能答的问句推成 `E5b`。
|
||||
#: 收窄成**「南方 + 名称 + 产品类型后缀」**:后缀是产品名的必要构件,
|
||||
#: 于是「南方基金」「南方基金管理股份有限公司」(公司名)不会被误认成产品。
|
||||
_PRODUCT_NAME_SHAPE = re.compile(
|
||||
r"南方[\u4e00-\u9fa5A-Za-z0-9 ]{0,14}?"
|
||||
r"(?:货币市场基金|定开债券[ABC]?|债券\s?[ABC]?|优选混合|价值股票|科技股票|全球精选)"
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def _product_name_in_history(cls, request: AgentRequest) -> str:
|
||||
"""`W21` C-9:在**上一轮答复**里按产品名形状找主语;找不到返回空串。
|
||||
|
||||
只扫**第一轮助手答复之后**紧邻的那一条:指代(「它」「这只」)指的永远是
|
||||
最近提到过的对象,往更早的轮次回溯会把客户已经换过的话题又拉回来。
|
||||
只扫前 6 行:产品名出现在答复开头(标题行 / 首句),中后段多是免责声明与
|
||||
转折说明,扫全篇只会增加把无关产品名当成主语的机会。
|
||||
"""
|
||||
for turn in reversed(request.history):
|
||||
if turn.role != "assistant":
|
||||
continue
|
||||
content = str(getattr(turn, "content", "") or "")
|
||||
for line in content.splitlines()[:6]:
|
||||
match = cls._PRODUCT_NAME_SHAPE.search(line)
|
||||
if match:
|
||||
return match.group(0).strip()
|
||||
return ""
|
||||
return ""
|
||||
|
||||
async def _suitability_followup_fallback(
|
||||
self, request: AgentRequest, context: RequestContext
|
||||
) -> CoreResult | None:
|
||||
"""`W21` C-9:适当性出口找不出产品时的**兜底**——交回知识检索。
|
||||
|
||||
为什么不能直接澄清:实测两句典型问法都落在这里,而它们**都不是适当性问题**:
|
||||
|
||||
- 「赎回费怎么算」→「持有 8 个月呢」:本句只有参数,谓词在上一轮;
|
||||
- 「我想了解定投」→「最低多少钱」:同类,只是连产品类目都没有。
|
||||
|
||||
两句的共同点是**有上文**(`request.history` 非空)、客户在继续一个**已经开启**
|
||||
的话题。此时回"请告诉我具体的基金名称或代码"是把客户**没问过**的东西当成了
|
||||
他没说清楚的东西 —— 客户体验上等同于"你没在听我说话"。
|
||||
|
||||
**只在有上文时降级**:`E-01`「它费率多少?」这类**首轮**指代没有指代对象,
|
||||
澄清才是对的(金标期望 `E1`),本方法对首轮返回 `None`。
|
||||
|
||||
安全上不退让:本出口从不给"能不能买"的结论,降级后同样不给 ——
|
||||
只是把"我答不了"换成"我先把答得上的答了"。
|
||||
"""
|
||||
if not request.history:
|
||||
return None
|
||||
logger.info("E2d 无产品主语但有上文,降级为知识检索")
|
||||
return await self._answer_from_knowledge(request, context, INTENT_FAQ)
|
||||
|
||||
async def _product_risk_level(self, product: str, context: RequestContext) -> int | None:
|
||||
"""查产品的风险等级:问知识库要"风险等级"那一行,不从问句里猜。
|
||||
|
||||
@@ -1868,6 +2074,15 @@ class CustomerServiceAgent(BaseAgent):
|
||||
# 生成文本同样要过**展示层净化**:模型看到的证据包是 markdown 源文,它会把
|
||||
# `**一、购买渠道**` 这类标记原样抄进答复(实测 2026-09-20「怎么买基金」)。
|
||||
# 校验一律跑在**净化前**的原文上(更严),净化只作用于交付给客户的那一份。
|
||||
#
|
||||
# ⚠️ `W21` 留痕:本顺序会让"净化后本可合规"的答复也降级成 `E5b`。实测
|
||||
# 2026-09-21 三条问句(「南方现金添利怎么样」/「买基金要手续费吗」/
|
||||
# 「债基和货基哪个收益高」)正是如此,根因是生成稿里出现「收益率 / 年化」
|
||||
# 这类 `YIELD_METRIC_TERMS`。**没有改成"先净化再判定"**,因为
|
||||
# `drop_yield_claims` 是**整行丢弃**、而生成稿常是单行长段 —— 实测换成
|
||||
# 先净化后,三条里有两条变成"整条被删空",客户体验反而更差。
|
||||
# 真正的解法在**生成侧**(提示词禁止输出收益数值),属合规口径决策,
|
||||
# 见 `D1.6` 的待决项,不在代码里单方面放宽。
|
||||
rendered = render_plain(drop_yield_claims(answer))
|
||||
if not rendered:
|
||||
return self._exit_partial(evidence, note="证据约束生成内容全部为收益数值")
|
||||
@@ -2142,6 +2357,44 @@ class CustomerServiceAgent(BaseAgent):
|
||||
)
|
||||
#: 基金类别词(`D-03`/`H-02` 的"类目切换式追问"要用)。
|
||||
_CATEGORY_TOKENS = ("货币", "债券", "混合", "股票", "QDII")
|
||||
#: 🆕 `W21` **概念型 / 时效型问句**:问「这个词是什么意思」「某类产品会不会亏」
|
||||
#: 「什么时候能办」—— 这类问句的答案天然长在**行业通用常识**里,而
|
||||
#: `fin_basic_collection`(`BAS-*` 46 块)此前**不在默认检索面内**,于是它们只能
|
||||
#: 落进 FAQ / 产品集合找一个"长得像"的邻居:实测「基金和股票有啥区别」(常识块
|
||||
#: 0.8141 满分)被答成「基金和理财产品有什么区别」(0.6919)。
|
||||
#:
|
||||
#: ⚠️ 为什么**条件化**而不是把 `BAS` 并进默认检索面:2026-09-19 的无条件并入把
|
||||
#: 通用段落的**次优分**抬到 0.69—0.79,撞坏 `MIN_GAP=0.07` 的"领先不足"判据,
|
||||
#: `M-1` 从 100% 掉到 91.3%。条件化之后,通用常识只在"它天然该赢"的问法上出现,
|
||||
#: 不参与产品题 / 政策题的名次竞争(实测 46 条金标里**没有一条**的 top1 会因此翻转)。
|
||||
_CONCEPT_QUERY_PATTERNS = (
|
||||
re.compile(r"什么是|是什么|是啥|啥意思|什么意思|什么叫|的定义|指的是|怎么理解"
|
||||
r"|是怎么回事|怎么能|怎么算|啥区别"),
|
||||
# `的区别`(不带"有/是")也要算:实测「前端收费和后端收费的区别」在 W20 体检里
|
||||
# 落 `E5b`(贴了「专户业绩报酬 不收取」),而常识块对它有 0.835 的满分答案。
|
||||
# 判据本身极窄 —— 中文里"…的区别"几乎只在概念题里出现。
|
||||
re.compile(r"有(什么|啥)(区别|不同|不一样)|有何(区别|不同)|的区别|的差别|差别在哪"),
|
||||
# 品类风险常识:「货币基金会亏吗」「债基会不会亏」问的是**品类特征**,
|
||||
# 不是某一只产品的净值,因此主语在常识里而不在产品参数里。
|
||||
re.compile(r"会不会亏|会亏吗|会亏本|会赔吗|会亏钱"),
|
||||
)
|
||||
_TIMING_QUERY_PATTERNS = (
|
||||
re.compile(r"什么时候|啥时候|几点|哪天|周末|节假日|交易日|非交易日"
|
||||
r"|多久|多长时间|几天|几天到账"),
|
||||
)
|
||||
#: 🆕 `W21` **参数式追问**:自己既没有主语、也没有谓词,只补了一个参数。
|
||||
#: 实测「赎回费怎么算」→「持有 8 个月呢」旧实现把第二问单独拿去检索(8 个字
|
||||
#: 刚好不满足 `< _MIN_STANDALONE_CHARS`),检索没有任何抓手 → 落澄清,
|
||||
#: 候选还是「档案保管要求 / 专户存续期限」这种毫不相干的条款。
|
||||
#: 这类问句唯一的语义来源是**上一轮客户问句里的谓词**,所以走"继承谓词"而不是
|
||||
#: 只补主语。收得很窄:形如「持有 8 个月呢 / 1 万块呢」,且**不带自己的谓词**
|
||||
#: (因此「持有 8 个月赎回要付费吗?」不会被误收)。
|
||||
_PARAM_FOLLOWUP_PATTERNS = (
|
||||
re.compile(r"^(?:持有|已经|已|过了|满)?\s*\d+\s*(?:天|个月|年|周|日)"
|
||||
r"[^。;!?,,]{0,4}(?:呢|吧)[??]?$"),
|
||||
re.compile(r"^\d+(?:\.\d+)?\s*(?:万|元|块|万元)"
|
||||
r"[^。;!?,,]{0,4}(?:呢|吧)[??]?$"),
|
||||
)
|
||||
#: 「<类目>呢?」式**纯追问**:自己不带谓词,谓词只能从上一轮**客户问句**里继承。
|
||||
_CATEGORY_FOLLOWUP = re.compile(r"^(货币|债券|混合|股票|QDII)[^。;!?,,]{0,4}呢")
|
||||
# 短到这种程度的问题,自己通常不构成完整意图("起投多少""风险高吗")
|
||||
@@ -2174,8 +2427,20 @@ class CustomerServiceAgent(BaseAgent):
|
||||
return topic
|
||||
return ""
|
||||
|
||||
@staticmethod
|
||||
def _topic_in(first: str) -> str:
|
||||
#: 🆕 `W21`:这些词**开头**的短句是叙述导语,不是主语。
|
||||
#: 实测反例:上一轮回答的首行是「需要说明三点:」(风险等级红线话术),
|
||||
#: 旧的 `_topic_in` 把「需要说明三点」当成产品名存进下一轮的检索词,
|
||||
#: 第二问「那费率呢」的查询串变成「需要说明三点 那费率呢」—— 检索彻底跑偏,
|
||||
#: 客户拿到的是「其他费用:专户业绩报酬」这种八竿子打不着的条款。
|
||||
_TOPIC_LEAD_REJECT = (
|
||||
"需要说明", "需要提醒", "需要提示", "说明", "提示", "注", "补充", "小结",
|
||||
"总结", "综上", "上述", "要点", "注意", "如下", "以下", "温馨", "另需",
|
||||
"此外", "同时", "其他", "其它", "其中", "具体", "简而言", "换句话",
|
||||
"情况", "方式", "方法", "步骤", "流程", "环节", "过程", "原因", "结果",
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def _topic_in(cls, first: str) -> str:
|
||||
"""解析单行里可能的主语;解析不出返回空串。
|
||||
|
||||
回答是我们自己组装的,主语只可能出现在三种形状里:
|
||||
@@ -2196,12 +2461,17 @@ class CustomerServiceAgent(BaseAgent):
|
||||
# 会被下面的长度/标点校验挡掉。
|
||||
if not topic or topic in {"问", "答"}:
|
||||
return ""
|
||||
if len(topic) < 2:
|
||||
return ""
|
||||
# 政策条款不是产品:客户问一条规定、再追问"那它…"时,指代的是条款本身,
|
||||
# 把"第十二条 投资者与产品匹配矩阵"当成产品名只会把追问带到别处。
|
||||
if topic.startswith("第") and "条" in topic[:6]:
|
||||
return ""
|
||||
if len(topic) > 20 or "," in topic or "。" in topic:
|
||||
return ""
|
||||
# `W21`:叙述导语不是主语(见 `_TOPIC_LEAD_REJECT` 的实测反例)。
|
||||
if any(topic.startswith(lead) for lead in cls._TOPIC_LEAD_REJECT):
|
||||
return ""
|
||||
# 整节块的标题带章节号("2.1 南方季季盈90天"),必须剥掉:留着会让下游的
|
||||
# "同一产品"校验失配——`_product_risk_level` 要求产品名是该块标题或正文的子串,
|
||||
# 而"2.1 南方季季盈90天"并不是"南方季季盈90天:风险等级 R2…"的子串,
|
||||
@@ -2232,6 +2502,14 @@ class CustomerServiceAgent(BaseAgent):
|
||||
只有产品名这一个"主语",不是它的论述。
|
||||
"""
|
||||
message = request.message.strip()
|
||||
# 🆕 `W21` **参数式追问**("持有 8 个月呢"):本句既没有主语、也没有谓词,
|
||||
# 只补了一个参数。谓词只能从**上一位客户问句**里继承 —— 与 `_category_switch_query`
|
||||
# 同一取向,但那边换的是类目、这边换的是参数,所以直接拼上一轮整句。
|
||||
# 放在 `_category_switch_query` **之前**:两类判据互斥(一条要求本句以类目词开头,
|
||||
# 一条要求本句只有数字参数),顺序不影响结果,但参数式追问更常见,先判更省事。
|
||||
parameterized = cls._parameterized_followup_query(message, request)
|
||||
if parameterized:
|
||||
return parameterized[:500]
|
||||
# `H-02` 反向考点("混合基金呢?"):本句**没有自己的谓词**,谓词只能从上一位
|
||||
# 客户问句里继承 —— 所以拼的不是主语,而是"把上一位问句的类目换成新类目"。
|
||||
# 这里若走下面的通用分支会答错:旧实现拼出来的查询只有「混合基金呢?」,
|
||||
@@ -2252,8 +2530,53 @@ class CustomerServiceAgent(BaseAgent):
|
||||
if topic:
|
||||
return f"{topic} {message}"[:500]
|
||||
break
|
||||
# 🆕 `W21`:主语**取不到**时退回「上一位客户问句」当抓手。
|
||||
#
|
||||
# 为什么需要这一层:`E4` 证据约束生成是**合并作答**,它不声明主语
|
||||
# (`CoreResult(topic=...)` 只有 `E3`/`E2` 那几个出口会填),上一轮答复也就
|
||||
# 反解不出产品名。实测「我想了解定投」(`E4`,答得很好)→「最低多少钱」
|
||||
# 时主语为空、整句单独检索,只能拿「第九条 问卷内容及评分标准 / 第十六条
|
||||
# 费率标准」这种条款去问客户要哪一个 —— 而客户其实只是在追问**上一轮那件事**
|
||||
# 的金额门槛。上一轮客户问句自带谓词("了解定投"),是当下唯一可用的抓手。
|
||||
#
|
||||
# 收口与 `_parameterized_followup_query` 同一口径:只取**一句话**(≤40 字)、
|
||||
# 不取助手的长答复,且与本句不同。
|
||||
for turn in reversed(list(request.history)):
|
||||
if turn.role != "user":
|
||||
continue
|
||||
previous = str(turn.content or "").strip()
|
||||
if previous and previous != message and len(previous) <= 40:
|
||||
return f"{previous} {message}"[:500]
|
||||
break
|
||||
return message[:500]
|
||||
|
||||
@classmethod
|
||||
def _parameterized_followup_query(
|
||||
cls, message: str, request: AgentRequest
|
||||
) -> str:
|
||||
"""「持有 8 个月呢」式**纯参数追问** → 用上一轮**客户问句**补谓语。
|
||||
|
||||
为什么不能只补主语(`_search_query` 的既有取向):这类问句的主语和谓词**都不在
|
||||
本句里**。「赎回费怎么算」→「持有 8 个月呢」,本句提供的是**参数**(持有期),
|
||||
谓词在上一轮客户问句里。只补主语会拼出「赎回费 持有 8 个月呢」—— 能对上一半;
|
||||
拼上一轮整句则是「赎回费怎么算 持有 8 个月呢」,检索能同时抓住"赎回费档位"
|
||||
与"持有期"两个抓手(实测这条从 `E1` 澄清变成给出费率档位)。
|
||||
|
||||
收得很窄的三条:① 本句形如「持有 8 个月呢 / 1 万块呢」,**不带自己的谓词**;
|
||||
② 上一轮客户问句存在且不超过 40 字(不把一整段粘贴当检索词);
|
||||
③ 上一轮问句与本句不同。三条不同时成立就返回空串,退回原逻辑。
|
||||
"""
|
||||
if not any(pattern.search(message) for pattern in cls._PARAM_FOLLOWUP_PATTERNS):
|
||||
return ""
|
||||
for turn in reversed(list(request.history)):
|
||||
if turn.role != "user":
|
||||
continue
|
||||
previous = str(turn.content or "").strip()
|
||||
if previous and previous != message and len(previous) <= 40:
|
||||
return f"{previous} {message}"
|
||||
return ""
|
||||
return ""
|
||||
|
||||
@classmethod
|
||||
def _category_switch_query(cls, request_message: str, request: AgentRequest) -> str:
|
||||
"""「<类目>呢?」式纯追问 → 用**上一位客户问句**的谓词 + 新类目拼查询。
|
||||
@@ -2539,6 +2862,22 @@ class CustomerServiceAgent(BaseAgent):
|
||||
return "low_intent_confidence"
|
||||
return None
|
||||
|
||||
#: 🆕 `W21` E5b 相关性闸门的最小公共子串长度。取 2 而不是 3:
|
||||
#: 实测「费用怎么收」与命中标题「第三十四条 费用与费率管理」只有「费用」两字重合,
|
||||
#: 而那**正是**客户要的内容 —— 门槛抬到 3 会把这条例行问题也判成"不相干"。
|
||||
#: 「什么时候能卖」与「第二十七条 生效日期」连两个字都凑不出来,2 足够把它挡住。
|
||||
_PARTIAL_OVERLAP_MIN = 2
|
||||
|
||||
@classmethod
|
||||
def _hits_share_terms(cls, message: str, hit: object) -> bool:
|
||||
"""`E5b` 该不该把这一块原样贴给客户(标题 / 正文前 200 字与问句的公共子串)。"""
|
||||
if not isinstance(hit, dict):
|
||||
return False
|
||||
probe = f"{hit.get('title') or ''}\n{str(hit.get('content') or '')[:200]}"
|
||||
return (
|
||||
_longest_common_substring(message.strip(), probe) >= cls._PARTIAL_OVERLAP_MIN
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _score(value: object) -> float:
|
||||
"""把命中分数夹到 [0,1]:SourceReference.score 有 ge=0/le=1 约束。"""
|
||||
|
||||
Reference in New Issue
Block a user