diff --git a/app/service/agent/implementations/customer_service.py b/app/service/agent/implementations/customer_service.py index 2820072..990fbea 100644 --- a/app/service/agent/implementations/customer_service.py +++ b/app/service/agent/implementations/customer_service.py @@ -41,10 +41,19 @@ BUSINESS_INTENTS = (INTENT_FAQ, INTENT_PRODUCT, INTENT_POLICY) TOOL_NAME = "search_knowledge" -# 三档置信阈值(方案 §2.3,业务方已确认先按此跑通、后续用语料校准)。 -# 分数为 COSINE 相似度,落在 [0, 1]。 -HIGH_SCORE = 0.75 # ≥ 直接答 -MID_SCORE = 0.60 # ≥ 且 < 高置信:答 + 提示可能不完整;< 该值:不硬答,引导人工 +# 三档置信阈值:方案 §2.3 要求的是「绝对阈值 AND(相对间隙 OR 分布优势)」混合判定, +# 只做绝对阈值会把口语化问法误判成"答不了"(这是实测踩到的坑)。 +# +# 数值按本模型(qwen3.7-text-embedding-flash)**实测校准**,不是照搬经验值: +# · 库内问法:口语「我们公司叫什么名字」top1=0.592、标准「公司全称是什么」0.671、 +# 带品牌名「南方科技的全称」0.855 —— 同样的正确答案,口语问法相似度天然更低; +# · 库外/越界:「你们公司什么时候上市」top1 最高 0.500、「今天天气怎么样」0.416, +# 且这些问题的 top1 与次优间隙都在 0.046 以内,而库内命中普遍在 0.09 以上。 +# 于是:库内最低 0.579 / 库外最高 0.500,绝对分两侧都有余量;间隙 0.07 又能挡住 +# 「存在并列候选」的不确定情形,避免因为一次高相似度的巧合就硬答。 +HIGH_SCORE = 0.75 # ≥ 直接答(高置信不再要求间隙:分数已足够说明问题) +MID_SCORE = 0.55 # 需与 MIN_GAP 同时满足才答,答时附"信息可能不完整"提示 +MIN_GAP = 0.07 # top1 领先次优的最小间隙;领先不足说明有并列候选,不硬答 TOP_K = 5 MAX_ANSWER_CHARS = 1200 @@ -128,15 +137,20 @@ class CustomerServiceAgent(BaseAgent): if not isinstance(best, dict): return self._guide_to_human("命中内容格式异常") score = self._score(best.get("score")) - if score < MID_SCORE: - return self._guide_to_human(f"置信度不足:{score:.3f}") + gap = score - self._second_score(hits) + # 混合判定:高置信直接答;中置信必须同时满足「分数够」与「领先次优够多」。 + # 只满足其一的(分数够但两三个候选并驾齐驱)宁可引导人工——金融场景下 + # "答不了"可接受,"答错"不可接受。 + confident = score >= HIGH_SCORE + if not confident and not (score >= MID_SCORE and gap >= MIN_GAP): + return self._guide_to_human(f"置信度不足:score={score:.3f} gap={gap:.3f}") content = str(best.get("content") or "").strip() if not content: return self._guide_to_human("命中内容为空") answer = content[:MAX_ANSWER_CHARS] - if score < HIGH_SCORE: + if not confident: answer = f"{answer}\n{INCOMPLETE_NOTICE}" # 知识出处写进正文,而不是塞进 source_references: # `governance.review_output` 只接受「本次召回的记忆」与「本次成功调用的工具」两类引用 @@ -218,6 +232,16 @@ class CustomerServiceAgent(BaseAgent): return 0.0 return min(1.0, max(0.0, number)) + def _second_score(self, hits: list[object]) -> float: + """次优命中分数,用于「绝对阈值 + 相对间隙」的混合判定。 + + 只有一个命中时返回 0:此时间隙最大,是否回答交由绝对阈值把关, + 而不是仅凭"没有竞争者"就认定可信。 + """ + if len(hits) < 2 or not isinstance(hits[1], dict): + return 0.0 + return self._score(hits[1].get("score")) + @staticmethod def _source_note(hit: dict[str, object]) -> str: """把知识出处写成一行正文(文件标题 + 内部文件编号),便于客户与人工核对。""" diff --git a/tools/customer_service_check.py b/tools/customer_service_check.py index 276230a..43e4eca 100644 --- a/tools/customer_service_check.py +++ b/tools/customer_service_check.py @@ -39,6 +39,9 @@ CASES: list[tuple[str, str]] = [ ("南方季季盈90天的起投金额是多少?", "product"), ("C1 保守型客户可以买什么风险等级的产品?", "policy"), ("你们公司的客服电话是多少?", "company"), + # 回归用例:业务方实测发现「我们公司叫什么名字」被误判成答不了(口语问法相似度天然 + # 偏低,0.592 低于旧的绝对阈值 0.60),据此把判定改成「绝对阈值 + 相对间隙」混合判定。 + ("我们公司叫什么名字", "口语问法-回归"), ("你好呀,今天心情不错", "chitchat"), ("帮我推荐一只明天肯定涨的基金", "越界请求"), ("我买的基金亏了,我要投诉!", "投诉"),