Files
group_fqcd_jr/tools/customer_service_check.py
lzf_0626 66595ce080 fix: 客服置信判定改为「绝对阈值 + 相对间隙」混合判定
问题(业务方实测发现):问「我们公司叫什么名字」被引导人工,但公司名称就在知识库里
——FAQ-0001「公司全称是什么?」与 COMP-001 都稳定命中 top1。所以根因不是检索不准,
而是判定规则不完整:客服方案 §2.3 要求的是「绝对阈值 AND(相对间隙 OR 分布优势)」,
实现里只做了绝对阈值 0.60。

实测校准(qwen3.7-text-embedding-flash,COSINE):

  库内问法                top1     top1-top2
    我们公司叫什么名字      0.592    0.100
    你们公司名称是什么      0.579    0.090
    公司全称是什么          0.671    0.098
    你们公司总部在哪        0.764    0.245
    南方科技的全称          0.855    —

  库外 / 越界             top1     top1-top2
    你们公司什么时候上市    0.500    0.046
    推荐明天肯定涨的基金    0.488    0.023
    我要投诉                0.492    0.025
    今天天气怎么样          0.416    0.035
    量子计算机退相干        0.416    0.044

两条结论:一是同为正确命中,口语问法的相似度天然偏低(0.592 vs 0.855),单用绝对阈值
必然误判;二是库内命中的 top1 领先幅度(≥0.09)显著大于库外(≤0.046),间隙是有效判别信号。

新规则:≥0.75 直接答(不再要求间隙);≥0.55 且间隙 ≥0.07 则回答并附「信息可能不完整」
提示;其余一律引导客户致电人工客服。两侧余量:库内最低 0.579、库外最高 0.500。

验证:ruff 通过、mypy 107 文件无错;ask_customer_service 对「我们公司叫什么名字」
正确返回南方科技有限公司;customer_service_check 由 8 项扩为 9 项,全部通过,
其中「推荐明天肯定涨的基金 / 我要投诉 / 量子计算机退相干」三条必须引导人工的用例
未被放宽后的阈值误答。
2026-09-10 20:29:12 +08:00

138 lines
6.2 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""客服 Agent 端到端验收:验证「能答的答得准」与「答不了的引导客户打电话」。
跑法:先停掉常驻 Worker(它和本脚本共享 agent_run 队列,会抢走任务),再执行
python tools/customer_service_check.py
判定标准(与业务方确认的口径一致):
- 答得上来的:run 成功、正文来自公司资料、**带知识来源引用**、不出现违规词;
- 答不上来的:正文是引导客户致电客服热线的固定话术,且 `transfer_required=True`;
- 任何情况下都不允许"用模型猜一个答案"——因此本脚本会逐条检查是否走了兜底。
"""
import asyncio
import datetime as dt
import sys
import uuid
from pathlib import Path
import httpx
import jwt
from app.core.config import get_settings
from app.main import create_app
from app.worker.runtime import WorkerRuntime
# GBK 控制台下知识块正文可能含 emoji 等不可编码字符,直接打印会让验收脚本**自身崩掉**,
# 从而掩盖真正的验收结论(真实的踩坑:🏆 一类字符在打印回答时抛 UnicodeEncodeError)。
# 改成遇不可编码字符即替换,保证脚本总能把结果打完。
sys.stdout.reconfigure(errors="replace")
CUSTOMER = "9001"
AGENT_TYPE = "customer_service"
# 方案 §6.5 的 7 个零容忍负面词:回答里出现任何一个都是合规事故
FORBIDDEN = ("保本", "稳赚", "无风险", "保证收益", "预期收益率", "年化收益率", "安全")
FALLBACK_MARK = "客服热线"
CASES: list[tuple[str, str]] = [
("基金赎回到账需要多长时间?", "faq"),
("南方季季盈90天的起投金额是多少?", "product"),
("C1 保守型客户可以买什么风险等级的产品?", "policy"),
("你们公司的客服电话是多少?", "company"),
# 回归用例:业务方实测发现「我们公司叫什么名字」被误判成答不了(口语问法相似度天然
# 偏低,0.592 低于旧的绝对阈值 0.60),据此把判定改成「绝对阈值 + 相对间隙」混合判定。
("我们公司叫什么名字", "口语问法-回归"),
("你好呀,今天心情不错", "chitchat"),
("帮我推荐一只明天肯定涨的基金", "越界请求"),
("我买的基金亏了,我要投诉!", "投诉"),
("量子计算机的退相干时间怎么算?", "知识库外"),
]
def token(subject: str) -> str:
settings = get_settings()
private_key = Path(settings.jwt_private_key_path).read_text(encoding="utf-8")
now = dt.datetime.now(dt.UTC)
return jwt.encode(
{
"sub": subject, "iss": settings.jwt_issuer, "aud": settings.jwt_audience,
"exp": now + dt.timedelta(minutes=30), "nbf": now - dt.timedelta(seconds=5),
"jti": str(uuid.uuid4()),
},
private_key,
algorithm="RS256",
)
async def main() -> int:
app = create_app()
auth = {"Authorization": f"Bearer {token(CUSTOMER)}"}
passed = failed = 0
async with httpx.AsyncClient(
transport=httpx.ASGITransport(app=app), base_url="http://test", timeout=120
) as client:
for message, label in CASES:
session_id = f"cs-check-{uuid.uuid4().hex[:12]}"
accepted = await client.post(
"/api/v1/agent-runs",
json={
"agent_type": AGENT_TYPE, "message": message,
"session_id": session_id, "idempotency_key": uuid.uuid4().hex,
},
headers=auth,
)
if accepted.status_code != 202:
print(f"[失败] {label}:受理返回 {accepted.status_code} {accepted.text[:120]}")
failed += 1
continue
run_id = accepted.json()["data"]["run_id"]
await WorkerRuntime().execute(run_id)
body = (await client.get(f"/api/v1/agent-runs/{run_id}", headers=auth)).json()["data"]
result = body.get("result") or {}
text = str(result.get("content") or "")
# intent 在 run 结果里是意图码字符串(RunQueryService 取 message.intent),
# 这里兼容对象形态,序列化形式变化不该让验收脚本崩
raw_intent = result.get("intent")
intent_code = raw_intent.get("intent") if isinstance(raw_intent, dict) else raw_intent
confidence: object = (
raw_intent.get("confidence") if isinstance(raw_intent, dict)
else result.get("confidence")
)
references = result.get("source_references") or []
# run 结果不含 transfer_required(conversation_message 不存该标记),
# 因此用兜底话术的固定特征判断是否走了「引导人工客服」这条出口
is_fallback = FALLBACK_MARK in text
transfer = is_fallback
problems: list[str] = []
if body.get("status") != "succeeded":
problems.append(f"run 状态={body.get('status')} 错误码={body.get('error_code')}")
if not text.strip():
problems.append("回答为空")
hits = [word for word in FORBIDDEN if word in text]
if hits:
problems.append(f"出现违规词 {hits}")
# 闲聊走模型生成、不查知识库,因此不要求知识来源引用
if not is_fallback and not references and label != "chitchat":
problems.append("给了答案但没有知识来源引用")
verdict = "通过" if not problems else "失败"
passed += not problems
failed += bool(problems)
print(f"\n[{verdict}] {label}:{message}")
print(f" 意图={intent_code} 置信={confidence} "
f"引导人工={transfer} 来源={len(references)} 条")
print(f" 回答:{text[:180]}")
if references:
top = references[0]
print(f" 来源:{top.get('source_id')} {str(top.get('title'))[:44]} "
f"score={top.get('score')}")
if problems:
print(f" [问题] {';'.join(problems)}")
print(f"\n合计 {len(CASES)} 项:通过 {passed},失败 {failed}")
return 0 if failed == 0 else 1
sys.exit(asyncio.run(main()))