Files
group_fqcd_jr/app/service/agent/implementations/customer_service.py
T
lzf_0626 4c2b147793 feat(knowledge): 产品知识拆到表格行级,并按问句选粒度
问题(客户实测反馈):同一会话里问「季季盈90天起投多少」和「那它风险高吗」,两次回答
**一模一样**——都是整个产品小节的表格。客户问的是风险,收到的是整张说明书,看起来像
客服没听懂问题。

根因是切分粒度:原来"一个叶子标题 = 一块",产品手册里就是整个产品小节(表格 + 说明)
成一块。这既让两个不同的问题命中同一块,也让整节几百字的向量成了"整节的混合语义",
与"起投多少"这种具体小问题相似度天然偏低(实测该问句向量 top1 仅 0.6291,够不到 0.75
硬门槛,只能靠与次优的差值勉强通过)。

改动三处:
1. 切分:Markdown 表格的每一行额外生成一个**自解释**的小块("南方季季盈90天:起投金额
   1万元"),挂在父块 doc_id 下(PROD-007-04),父块照旧保留。知识块 160 → 631。
   效果:该问句的命中分从 0.6291 升到 0.869,命中的正是"起投金额"那一行。
2. 检索:命中行级子块时把它的整节父块一并带回(分数按 0.9 折算),供调用方按问句选粒度。
   整节块保底占最后一个名额,且不参与 top1/top2 判定——实测它挤到第 2 位会把 gap 从
   0.090 压到 0.076,几乎跌破 0.07 的转人工门槛。
3. 客服:命中的是行级子块时,看问句与子块标签是否真的对得上——「起投多少」对「起投金额」
   对得上,用那一行;「介绍一下」对不上,换成整节。

过程中两次判据写错并已修正(都固化进了测试):用"含连字符"认子块时,整节块自己的编号
PROD-901 被误判成子块;用"不含两位数字后缀"认整节块时,FAQ 块全被误判成整节块排到后面,
把正确答案挤出 top1、害得「基金赎回几天到账」转人工。

验证:起投/管理费等字段问法给出聚焦的单行答案;"介绍一下"给出整节;FAQ 与政策问法不受
影响(换话题、指代追问等此前修好的场景复测通过);
ruff / mypy(113 文件) / 468 unit+contract / 29 integration 全绿。
2026-09-10 22:29:23 +08:00

361 lines
18 KiB
Python
Raw 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:只回答能溯源到公司资料的问题,答不了就引导客户致电人工客服。
设计取向(金融场景,由业务方确定):
- **确定性优先**:命中知识块后**直接返回原文**,不经模型改写。答案的字面内容来自公司
已发布的资料,模型不参与事实生成,因此不存在"编一个看起来合理的答案"的通路。
- **答不了就引导**:检索降级、未命中、置信度不足、意图未覆盖、模型异常——一律返回
引导客户拨打客服热线的固定话术,并置 `transfer_required=True` 留痕;
绝不用模型猜测答案。
- **本体保持薄**:只有意图分发与四条出口,不做多轮推理、不自主决策。这是业务方明确
要求的取向——"客服 Agent 本身不会很多内容,不会的就转人工"。
四条出口:
`faq` → 检索直返(不经模型)|`product_inquiry` / `policy_explain` → 检索直返 + 来源引用
|`chitchat` → 模型生成(提示词走发布配置)|其余与异常 → 引导人工客服
"""
from typing import Any
from app.core.contracts import (
AgentDefinition,
AgentRequest,
CoreResult,
RequestContext,
SourceReference,
)
from app.core.errors import ForbiddenAgentError
from app.service.agent.base import BaseAgent
from app.service.model_gateway import DatabaseModelEndpointResolver
from app.service.runtime_config_service import load_active_prompt
AGENT_TYPE = "customer_service"
# 意图码必须三处对齐:AgentDefinition.supported_intents、agent_intent_config 的
# (agent_type, intent_code)、以及发布版 config_release 里 agent_tools 的
# `customer_service:<intent>` 白名单 key。缺任一处即失败关闭,这是底座的有意设计。
INTENT_FAQ = "faq"
INTENT_PRODUCT = "product_inquiry"
INTENT_POLICY = "policy_explain"
INTENT_CHITCHAT = "chitchat"
INTENT_TRANSFER = "transfer_human"
BUSINESS_INTENTS = (INTENT_FAQ, INTENT_PRODUCT, INTENT_POLICY)
TOOL_NAME = "search_knowledge"
# 三档置信阈值:方案 §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
REFERENCE_LIMIT = 3
COMPANY = "南方科技"
# 客服热线:正式号码确定后改这里(或改为读配置项,避免改代码)
HOTLINE = "400-XXX-XXXX"
SERVICE_HOURS = "每日 7:00-22:00"
FALLBACK_TEMPLATE = (
"抱歉,这个问题我暂时无法给出准确答复。为避免给您错误信息,"
f"建议您拨打客服热线 {HOTLINE}({SERVICE_HOURS})转人工客服咨询。"
)
# 客户侧只展示这一句固定话术(业务方确定)。原中置信的"信息可能不完整"提示已按此移除,
# 即中置信回答不再对客户标注不确定性——这是调整时知情的取舍,不是遗漏。
DISCLAIMER = "(以上内容由智能客服依据公司公开资料整理,不构成投资建议)"
CHITCHAT_PROMPT_CODE = "customer_service_chitchat"
CHITCHAT_TASK_TYPE = "chat"
DEFAULT_CHITCHAT_SYSTEM = (
f"你是{COMPANY}的智能客服助手。回应要简短、礼貌,并自然引导用户提出与基金、理财、"
"账户相关的问题。禁止承诺收益,禁止出现「保本」「稳赚」「无风险」「保证收益」"
"「预期收益率」「年化收益率」「安全」等表述。"
)
DEFAULT_CHITCHAT_TEMPLATE = "用户说:{message}\n请用不超过 40 字回应,并把话题引导到业务上。"
class CustomerServiceAgent(BaseAgent):
definition = AgentDefinition(
agent_type=AGENT_TYPE,
version="1.0.0",
allowed_roles=("customer",),
allowed_portals=("api",),
# 代码上限:实际可用范围由发布配置的意图白名单收窄(两者取交集)
allowed_tools=(TOOL_NAME,),
supported_intents=(
INTENT_FAQ, INTENT_PRODUCT, INTENT_POLICY, INTENT_CHITCHAT, INTENT_TRANSFER,
),
)
async def handle(self, request: AgentRequest, context: RequestContext) -> CoreResult:
intent = self._intent_code()
if intent == INTENT_CHITCHAT:
return await self._chitchat(request)
if intent == INTENT_TRANSFER:
return self._guide_to_human("客户主动要求转人工")
if intent not in BUSINESS_INTENTS:
# 分类失败或意图未覆盖:不猜,直接引导人工
return self._guide_to_human(f"意图未覆盖:{intent or '未识别'}")
return await self._answer_from_knowledge(request, context, intent)
# ---- 出口一:知识直返(faq / 产品 / 政策) ----
async def _answer_from_knowledge(
self, request: AgentRequest, context: RequestContext, intent: str
) -> CoreResult:
try:
output = await self.call_tool(
TOOL_NAME,
{"query": self._search_query(request), "top_k": TOP_K},
intent=intent,
context=context,
)
except ForbiddenAgentError:
# 白名单/权限类失败必须冒泡:那是配置错误,若被兜底话术吞掉,
# 运维会看到"客服一直引导人工"却查不出原因。
raise
except Exception:
return self._guide_to_human("知识检索调用失败")
if not isinstance(output, dict):
return self._guide_to_human("知识检索返回格式异常")
if output.get("degraded"):
reason = str(output.get("reason") or "未知")
return self._guide_to_human(f"知识检索降级:{reason}")
hits = output.get("hits")
if not isinstance(hits, list) or not hits:
return self._guide_to_human("知识库未命中")
best = hits[0]
if not isinstance(best, dict):
return self._guide_to_human("命中内容格式异常")
score = self._score(best.get("score"))
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}")
# 命中的是行级子块时,先分辨客户问的是"某个字段"还是"整个产品":
# 子块让「起投多少」拿到聚焦答案,但「介绍一下」会被某一行抢答。
best = self._prefer_section(request.message, best, hits)
content = str(best.get("content") or "").strip()
if not content:
return self._guide_to_human("命中内容为空")
# 正文只保留「答案 + 固定免责声明」:业务方要求客户侧只看到这一句固定话术,
# 因此不确定性提示与出处行都不再出现在正文里。
#
# 可追溯性不受影响:本次命中哪个知识块仍由审计(agent.tool_executed 的工具调用记录)
# 与消息表留痕,只是不面向客户展示。若将来要把出处给客户看,应当走
# source_references 的 knowledge 类型(需先让 ToolExecutor 登记本次可引用的 doc_id),
# 而不是继续往正文里拼字符串。
return CoreResult(
text=f"{content[:MAX_ANSWER_CHARS]}\n{DISCLAIMER}",
intent=self._classified_intent,
)
@staticmethod
def _prefer_section(message: str, best: dict[str, Any], hits: list[Any]) -> dict[str, Any]:
"""客户问整个产品时,用整节块替换掉抢答的那一行。
行级子块是为了让「起投多少」拿到聚焦答案,但「介绍一下」会被某一行抢答
(实测返回了"产品期限 90天封闭期",而客户要的是整个产品)。
判据不用问句分类器,而是看问句与子块标签是否真的对得上。标签取自子块的
section 末段("起投金额""风险等级"):「起投多少」含"起投"、「风险高吗」含"风险",
都算对得上;「介绍一下」与任何标签都不重合,说明客户要的是整节。
父块由检索层按子块分数的 0.9 折算后一并带回。万一没带回来就仍用子块——
宁可答得窄一点,也不要拿不相干的块去搪塞。
"""
doc_id = str(best.get("doc_id") or "")
# 末段恰为 2 位数字才是行级子块(PROD-007-04);整节块自己的编号形如 PROD-901,
# 用"含连字符"判断会把整节块误判成子块。
parent_id, _, tail = doc_id.rpartition("-")
if not (parent_id and tail.isdigit() and len(tail) == 2):
return best # 命中的本来就是整节
# 标签取自子块 title 的末段(title 由 " · " 连接),如"起投金额""风险等级"
label = str(best.get("title") or "").split(" · ")[-1].strip()
if label and label[:2] in message:
return best # 客户问的正是这个字段
for hit in hits:
if isinstance(hit, dict) and str(hit.get("doc_id") or "") == parent_id:
return hit
return best
# ---- 出口二:闲聊(提示词走发布配置) ----
async def _chitchat(self, request: AgentRequest) -> CoreResult:
system, template = await self._chitchat_prompt()
message = request.message[:500]
try:
prompt = template.format(message=message, company=COMPANY)
except (KeyError, IndexError, ValueError):
# 发布配置里的占位符与代码不一致时回落默认模板:配置写错不应该让运行期崩
system = DEFAULT_CHITCHAT_SYSTEM
prompt = DEFAULT_CHITCHAT_TEMPLATE.format(message=message, company=COMPANY)
full_prompt = f"{system}\n\n{prompt}" if system else prompt
try:
endpoints = await DatabaseModelEndpointResolver().resolve(
agent_type=AGENT_TYPE, task_type="text_generation"
)
# 显式转成 list[object]:generate_with_model 形参是 list[object],
# 而 list 不变型(invariant),直接传 list[ModelEndpointConfig] 过不了类型检查
endpoint_list: list[object] = list(endpoints)
execution = await self.generate_with_model(endpoint_list, full_prompt)
except Exception:
return self._guide_to_human("模型不可用")
text = (execution.text or "").strip()
if not text:
return self._guide_to_human("模型返回为空")
return CoreResult(
text=f"{text[:MAX_ANSWER_CHARS]}\n{DISCLAIMER}",
intent=self._classified_intent,
)
async def _chitchat_prompt(self) -> tuple[str, str]:
"""读取当前发布版本的闲聊提示词;未发布或读取失败时回落代码内置默认值。"""
try:
row = await load_active_prompt(CHITCHAT_PROMPT_CODE, CHITCHAT_TASK_TYPE, AGENT_TYPE)
except Exception:
row = None
if row is None:
return DEFAULT_CHITCHAT_SYSTEM, DEFAULT_CHITCHAT_TEMPLATE
return (
row.system_prompt or DEFAULT_CHITCHAT_SYSTEM,
row.user_prompt_template or DEFAULT_CHITCHAT_TEMPLATE,
)
# 客户这一句里出现这些词,说明主语要靠上文补全("这个产品""该基金")。
# 刻意不收"它/他":单字指代在中文里极易误伤("其他产品""其它"都含"他/它"),
# 而这类短句已经由 _MIN_STANDALONE_CHARS 覆盖,不需要靠它兜。
_REFERRING_WORDS = (
"这个", "那个", "这款", "这只", "该产品", "该基金", "上述", "前面提", "刚刚说",
)
# 短到这种程度的问题,自己通常不构成完整意图("起投多少""风险高吗")
_MIN_STANDALONE_CHARS = 8
@staticmethod
def _topic_of(answer: str) -> str:
"""从客服上一轮的回答里取出"这一轮在说哪个产品",取不出来就返回空串。
回答是我们自己组装的,只有两种形状,都取自知识块的字段:
- 行级块:`南方季季盈90天:起投金额 1万元`(首个冒号前就是产品名)
- 整节块:`### 2.1 南方季季盈90天`(首个标题行去掉 # 号)
取不出来时宁可返回空、退化成不带主语,也不要拿兜底话术当主语:
「抱歉,这个问题我暂时无法给出准确答复」这种句子拿去检索只会把问题带偏。
"""
first = next((line.strip() for line in answer.splitlines() if line.strip()), "")
first = first.lstrip("#").strip()
topic = first.split(":", 1)[0].strip() if ":" in first else first
if not topic or len(topic) > 20 or "," in topic or "。" in topic:
return ""
return topic
@classmethod
def _search_query(cls, request: AgentRequest) -> str:
"""构造交给检索的查询串。
什么时候带上文(实测决定,两条都不能少):
1. **客户这一句自己说不清楚时**才带。同一会话先问「季季盈90天的起投金额是多少」、
再问「基金赎回几天到账」,若无条件带上文,第二问会命中季季盈的产品块、
答出产品介绍——**答非所问**。在金融场景里这比"引导转人工"糟得多。
2. **带上文时只带"在说哪个产品",不带上一轮的原话**。把上一轮整句拼进来会让
检索词语义变"宽",反而只能命中粗粒度的整节块:实测「季季盈90天起投多少
那它风险高吗」命中的是整个产品小节,客户问的"风险"完全没有被聚焦;
换成「南方季季盈90天 那它风险高吗」才命中"风险等级"那一行。
换句话说:上文的作用是**补主语**,不是**补内容**。
只取客户的话、不取 Agent 自己的回答当内容:把 Agent 的措辞也拼进来会让检索偏向
自己上一轮的说法,而客户的真实意图可能已经在下一句里被修正过。这里从回答里取的
只有产品名这一个"主语",不是它的论述。
"""
message = request.message.strip()
needs_context = (
len(message) < cls._MIN_STANDALONE_CHARS
or any(word in message for word in cls._REFERRING_WORDS)
)
if not needs_context:
return message[:500]
for turn in reversed(request.history):
if turn.role == "assistant":
topic = cls._topic_of(turn.content)
if topic:
return f"{topic} {message}"[:500]
break
return message[:500]
# ---- 出口三:引导人工客服(不做工单,只回话并留痕) ----
def _guide_to_human(self, reason: str) -> CoreResult:
return CoreResult(
text=FALLBACK_TEMPLATE,
intent=self._classified_intent,
transfer_required=True,
transfer_reason=reason[:200],
)
# ---- 辅助 ----
def _intent_code(self) -> str:
classified = self._classified_intent
return classified.intent if classified is not None else ""
@staticmethod
def _score(value: object) -> float:
"""把命中分数夹到 [0,1]:SourceReference.score 有 ge=0/le=1 约束。"""
try:
number = float(value) # type: ignore[arg-type]
except (TypeError, ValueError):
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"))
def _references(self, hits: list[object]) -> tuple[SourceReference, ...]:
"""保留待用:等基座支持 knowledge 类型引用后再启用。
目前 `governance.review_output` 只认可 memory / tool 两类来源,直接返回
knowledge 引用会被判为「引用未来自本次已授权召回结果」而让整个 run 失败。
启用前提是让 ToolExecutor 把工具返回的知识 doc_id 登记为本次可引用来源。
"""
references: list[SourceReference] = []
for hit in hits[:REFERENCE_LIMIT]:
if not isinstance(hit, dict):
continue
doc_id = str(hit.get("doc_id") or "").strip()
if not doc_id:
continue # 没有标识的命中无法回溯,丢弃而不是造一个假来源
title = str(hit.get("title") or "").strip()
references.append(SourceReference(
source_type="knowledge",
source_id=doc_id,
title=title[:200] or None,
score=self._score(hit.get("score")),
))
return tuple(references)