feat(W20): 新增 E2c-my 出口 + 展示层净化 + P2 自助流程豁免(金标零回归)

用户口径:既智能又安全,不靠"答不上就转人工";按本人画像测评列出可购买的产品,
但不引导购买。

出口由五个扩为六个:
- 新增 E2c-my:按客户本人权威测评等级给出可购买范围 + 在售清单
  (只列代码/名称/类别/风险等级,按代码升序;出口后置引导词护栏,命中即降级为仅范围)
- 点名档位(「我可以买 R3 的产品吗」)先给一句直接裁决再列范围
  —— 修复前只给清单,"不"字始终没说出来
- 新工具 query_eligible_products(suitability:read)+ 发布 release 220

真实业务缺陷:
- P2_SELF_SERVICE_PATTERNS:问「怎么修改绑定的银行卡」不再建单转人工(FAQ 里就有答案);
  「帮我把绑定银行卡换一下」仍走 P2
- 展示层净化 render_plain / drop_yield_claims / prettify_title 接在 E3/E4/E5b/澄清四处
  (E4 模型会照抄证据包的 markdown 标记)
- 空结果护栏:语料里 15 个纯收益切片被净化清空时回退 E5b,不给空气泡

测试:定向 334 passed;全量回归 1969 passed / 3 skipped / 0 failed(基线 1946);
46 条金标 M-1 46/46=100%、M-4 100%、M-6 5/46=10.9%、M-7~M-10 全 0 —— 与
score_w11b 逐项一致(零回归)。

落档:D1.6 §4.49 / D2.1 v6.36 / D1.1 §30
This commit is contained in:
张胜宇
2026-09-20 18:55:22 +08:00
parent dc83f4f4a7
commit 35effab70f
10 changed files with 1537 additions and 27 deletions
@@ -57,7 +57,9 @@ from app.core.customer_service_rules import (
TRANSFER_REASONS,
SafetyRoute,
hits_zero_tolerance,
is_own_eligibility_question,
is_risk_level_change_request,
promotional_wording_violation,
route_message,
visitor_advice_violation,
)
@@ -106,6 +108,11 @@ VISITOR_TOOL_NAME = "query_knowledge"
# (来自 fin_risk_assessment,且带测评有效期),Agent 自己判分等于绕开合规链路。
SUITABILITY_TOOL = "check_suitability"
#: `E2c-my` 的可买清单工具(`W20`)。它只读 `fin_product` 的公开字段(代码 / 名称 /
#: 类别 / 风险等级),**不含净值、收益与费率** —— 字段收窄本身就是把"清单"与"推介"分开
#: 的第一道保证。
ELIGIBLE_TOOL_NAME = "query_eligible_products"
# 风险等级名称:国标五级,稳定不变,只用于把裁决结果说成人话。
RISK_LEVEL_NAMES = {
1: "R1(低风险)", 2: "R2(中低风险)", 3: "R3(中风险)",
@@ -348,19 +355,121 @@ TRANSFER_TEMPLATE = (
f"请拨打客服热线 {HOTLINE}({SERVICE_HOURS}),我们会为您核实并跟进。"
)
# ---------------------------------------------------------------------------
# 展示层净化(`W20` 真实场景扫描)
# ---------------------------------------------------------------------------
#
# 扫描 28 条真实问法时发现三个**看起来就很笨**的展示缺陷,都不是"答错",而是"答对了
# 但客户看不懂":
#
# 1. **markdown 源文原样发给客户**:E3 是原文直返,而知识块是 markdown 源文,
# 客户看到的是「### 第二条 适用范围」「**一、购买渠道**」「| 费用类型 | 货币基金 |」;
# 2. **澄清候选被截断成半句**:标题 `[:40]` 硬切,出现
# 「…产品手册 · 一、公募基金产品 · 1.」这种以编号结尾的碎片;
# 3. **知识块自带的收益数字被摊到客户面前**:「近一年收益率 7.60%」。
#
# 三条都在**展示层**修,不动知识库、不动检索、不动判分口径。
_MD_HEADING = re.compile(r"^\s{0,3}#{1,6}\s*", re.M)
_MD_BULLET = re.compile(r"^(\s*)[-*+]\s+", re.M)
_MD_QUOTE = re.compile(r"^\s{0,3}>\s?", re.M)
_MD_HR = re.compile(r"^\s{0,3}(?:-{3,}|\*{3,}|_{3,})\s*$", re.M)
_MD_TABLE_SEP = re.compile(r"^\s*\|?[\s:|-]*\|[\s:|-]*$", re.M)
_MD_BOLD = re.compile(r"\*\*(.+?)\*\*|__(.+?)__", re.S)
_MD_CODE = re.compile(r"`([^`]*)`")
_MD_LINK = re.compile(r"\[([^\]]+)\]\([^)]*\)")
def render_plain(text: str) -> str:
"""把知识块的 markdown 源文净化成**客户读得懂的纯文本**。
只做"去标记",不删内容:标题留字、列表改「· 」、加粗去星号、表格转全角分隔。
前端是纯文本气泡,`###` 与 `**` 只会显示成噪声字符 —— 客户看到的是"客服把源文件
贴出来了",这比答错更伤可信度。
"""
if not text:
return ""
out = _MD_LINK.sub(r"\1", text)
out = _MD_BOLD.sub(lambda m: m.group(1) or m.group(2) or "", out)
out = _MD_CODE.sub(r"\1", out)
lines: list[str] = []
for line in out.split("\n"):
if _MD_TABLE_SEP.match(line):
continue # `|---|---|` 分隔行,纯噪声
stripped = line.strip()
if stripped.startswith("|") and stripped.endswith("|") and stripped.count("|") >= 2:
cells = [c.strip() for c in stripped.strip("|").split("|")]
line = " | ".join(c for c in cells if c)
lines.append(line)
out = "\n".join(lines)
out = _MD_HR.sub("", out)
out = _MD_HEADING.sub("", out)
out = _MD_QUOTE.sub("", out)
out = _MD_BULLET.sub(r"\1· ", out)
return re.sub(r"\n{3,}", "\n\n", out).strip()
#: 收益 + 数值同句的两种语序。**只匹配同一行**,避免误伤费率表。
_YIELD_NUMBER_A = re.compile(r"(收益率|年化|回报率|涨幅|业绩)[^\n]{0,12}?\d+(?:\.\d+)?\s*%")
_YIELD_NUMBER_B = re.compile(r"\d+(?:\.\d+)?\s*%[^\n]{0,10}(收益率|年化|回报率|涨幅)")
def drop_yield_claims(text: str) -> str:
"""丢掉**带具体数值的收益表述**所在行。
为什么必须在展示层做:输入侧 `YIELD_TRAP_PATTERNS` 只拦得住"问句自带数字口径"的问法;
而**知识块里本来就写着收益数字**(产品手册的业绩示例),`E3` 原文直返会把
「近一年收益率 7.60%」原样送到客户眼前 —— 实测 2026-09-20 问「我想找一个收益高一点的
产品」即命中。收益数值属"不得输出的承诺类内容",宁可这一行不给,也不能让客户看到
任何像是收益承诺的数字。费率表不受影响(它讲的是「费率」不是「收益率」)。
"""
kept = [
line for line in text.split("\n")
if not (_YIELD_NUMBER_A.search(line) or _YIELD_NUMBER_B.search(line))
]
return "\n".join(kept).strip()
#: 「文档名」段:品牌名开头或含这些词的一段,在候选标题里对客户没有辨识价值。
_DOC_NAME_MARKERS = ("手册", "指南", "规范", "管理办法", "服务协议", "业务规则")
_DANGLING_NUMBER = re.compile(r"\d+[.、]?")
def prettify_title(title: str, limit: int = 34) -> str:
"""把知识块标题整理成**客户能认出来的一项**(澄清候选用)。
原来只做 `title[:40]` 硬切,于是候选里出现
「南方基金管理股份有限公司 公募基金与专户产品手册 · 一、公募基金产品 · 1.」——
以编号结尾的碎片。这里按「 · 」分段后:丢掉悬空编号段、丢掉文档名段、只留章节与小节。
"""
text = render_plain(str(title or "")).strip()
parts = [p.strip(" ·") for p in text.split(" · ")]
parts = [p for p in parts if p and not _DANGLING_NUMBER.fullmatch(p)]
trimmed = [
p for p in parts
if not p.startswith(COMPANY) and not any(k in p for k in _DOC_NAME_MARKERS)
]
parts = trimmed or parts
if len(parts) > 2:
parts = parts[-2:]
merged = " · ".join(parts) if parts else text
return merged[:limit] + ("…" if len(merged) > limit else "")
#: E5b 部分答 + 引导。**不置 `transfer_required`**(不建单):知识未命中、置信度不足、
#: 检索降级、画像查不到都属"这次没查到",不是"必须人工办的事"。原实现把这四类一律渲染成
#: 同一句兜底话术并建单,是转人工率偏高的主因。
PARTIAL_TEMPLATE = (
"这个问题我暂时只能提供以下公开资料供您参考:\n\n{content}\n\n"
"如果需要更确切的答复,可以换个说法再问我一次(例如带上具体产品名称或条款名),"
"我先帮您把找到的公开资料放上来:\n\n{content}\n\n"
"如果这还不是您想问的那一条,把具体产品名或条款名告诉我,我接着帮您查;"
f"也可以拨打客服热线 {HOTLINE}({SERVICE_HOURS})咨询。"
)
#: E5b 在连"部分命中"都没有时的说法(仍不建单)。
PARTIAL_EMPTY_TEMPLATE = (
"这个问题我没有找到对应的公开资料,也不想凭猜测回答您。"
"您可以换个说法再问我一次(例如带上具体产品名称、费率或条款名),"
"这一条我暂时没找到对应的公开资料,也不想凭猜测回答您。"
"换个说法再问我一次就行(带上具体产品名、费率或条款名都能帮我定位),"
f"也可以拨打客服热线 {HOTLINE}({SERVICE_HOURS})咨询。"
)
@@ -374,8 +483,8 @@ KNOWLEDGE_MISS_TEXTS = (
#: E5a 澄清:**一次只问一个问题**,候选必须来自当前主体可见档位——命中列表本身已按档位
#: 裁剪,因此这里天然满足"不得暗示不可见条目存在性"。
CLARIFY_TEMPLATE = (
"您的意思我还不确定,方便确认一下您想了解的是哪一项吗?\n{candidates}\n"
"回复序号或直接补充说明都可以。"
"您想了解的是下面哪一项呢?\n{candidates}\n"
"回复序号就行,也可以直接补充说明。"
)
#: 画像查不到时的说明(E5b,不建单):引导自助查看,不推给人工建单。
@@ -466,6 +575,92 @@ CALC_RULE_FOOTNOTE = (
CALC_RULE_NEXT_VISITOR = "如需按您本人的测评结果核对,请先登录客户账户。"
CALC_RULE_NEXT_CUSTOMER = "如需核对您本人与某只具体产品的匹配情况,请告诉我产品名称或代码。"
# ---------------------------------------------------------------------------
# `E2c-my`:以**本人**等级为对象的可购买范围(`W20`)
# ---------------------------------------------------------------------------
#
# 这一出口要修的是一处**把该答的问题答成了拒答**的缺陷(2026-09-20 实测):客户
# `cust_t`(C1)问「我现在可以买什么等级的产品」,拿到的是 `ADVICE_BOUNDARY_REPLY`
# 「不能为您推荐具体产品…」。而这与 `suitability_service` 里 2026-09-11 的裁定
# 「**客服回答按矩阵**」直接冲突 —— 该注释的原文举例正是「问『C1 能买什么产品』
# 答『R1、R2 可买』」。
#
# 界线(两个概念必须分清):
# · **陈述可买范围** = 公开的 C—R 匹配矩阵(`PROD-012` §4.2 为 `public` 档)叠加本人
# 权威等级 ⇒ 该答。它由**等级**决定,与客户想买哪只无关;
# · **引导购买** = 「建议您买这只」「这只更适合您」「那只收益最高」「抓紧申购」⇒ 红线。
# 它由**指向性结论**构成。
# 因此本出口的产物是「按你的等级,这些产品在范围内(代码 / 名称 / 类别 / 风险等级)」,
# 而不是「我建议你买哪些」。出口后置校验 `promotional_wording_violation` 是这条界线的
# **确定性**保证:不靠提示词,靠机器判据。
#
#: 与画像出口同理:白名单 key 必须落在**已发布**的 `agent_tools` 上。
#: `suitability_check` 是本出口的语义归属(它答的就是适当性匹配范围)。
ELIGIBLE_WHITELIST_INTENT = INTENT_SUITABILITY
#: 访客问同一句:给**公开规则表**(`DEC-I8` 口径:通用规则对访客 open),
#: 并说明"按本人等级列清单"需要登录 —— 那一步要读本人测评结果。
ELIGIBLE_VISITOR_TEMPLATE = (
"按《个人投资者适当性管理指南》第十二条的投资者与产品匹配矩阵,"
"可购买的产品等级范围是:C1 保守型 R1—R2;C2 稳健型 R1—R3;"
"C3 平衡型 R1—R4;C4 成长型与 C5 进取型 R1—R5。\n"
"要按您本人的测评等级列出在售产品清单,需要先登录 —— 等级只能取自您留存的、"
"在有效期内的风险测评结果。"
)
#: 取不到权威等级 → `E5b` 如实告知(**不猜范围、不建单**)。
ELIGIBLE_MISS_TEMPLATE = (
"要按您本人的测评等级列出可购买的产品清单,需要先取到您留存的、在有效期内的"
"风险测评结果,但这次没有取到,所以不能给您一份清单(列错范围比不列更糟)。"
"您可以登录后在「我的账户」核对测评状态,"
f"或拨打客服热线 {HOTLINE}({SERVICE_HOURS})由人工协助。"
)
#: 测评过期:单独一条,把"为什么列不出来"讲清楚(与画像出口同一口径)。
ELIGIBLE_EXPIRED_TEMPLATE = (
"您的风险测评已过有效期,需要重新完成测评后才能按等级匹配可购买的产品范围。"
"请登录后在「我的账户」重新完成风险测评;"
f"如需人工协助,可拨打客服热线 {HOTLINE}({SERVICE_HOURS})。"
)
#: 结尾声明。**"不构成推荐"是紧邻否定式**,出口护栏对此豁免(见
#: `customer_service_rules._locally_negated`)—— 也就是说这句话不会被自己拦掉。
ELIGIBLE_FOOTNOTE = (
"本清单由系统按您留存的、在有效期内的风险测评等级自动筛选,仅列产品代码、名称、"
"类别与风险等级,不含净值、收益与费率,不构成推荐。"
)
def _level_numbers(levels: Sequence[str]) -> set[int]:
"""把 `"R1"` 这类档位标签还原成数字集合(解析失败的一律忽略,不猜)。"""
numbers: set[int] = set()
for item in levels:
token = str(item).strip().upper()
if token.startswith("R") and token[1:].isdigit():
numbers.add(int(token[1:]))
return numbers
def _asked_level_verdict(
asked: int | None, allowed: Sequence[str], disclosure: Sequence[str]
) -> str:
"""问句里**点名了某一档**时的那一句直接裁决(「我可以买 R3 的产品吗」)。
措辞与 `_suitability_rule_text`(`E2c`)**同源** —— 两条出口给同一件事的答案不能不一样。
裁决只由矩阵结果反推(`allowed` / `disclosure` 都是服务端 `MATRIX_ALLOWED` /
`MATRIX_NEEDS_DISCLOSURE` 的投影),不含任何个性化推断,也不含指向性表述。
"""
if asked is None:
return ""
name = RISK_LEVEL_NAMES.get(asked, f"R{asked}")
if asked in _level_numbers(allowed):
conclusion = "可以购买"
elif asked in _level_numbers(disclosure):
conclusion = "需签署产品风险揭示书后可以购买"
else:
conclusion = "不可以购买(超出您当前等级可购买的范围)"
return f"您问的 {name} 等级产品:{conclusion}。"
#: 闲聊出口在模型不可用时的回退(E5b,不建单):礼貌收尾并把话题引回业务。
CHITCHAT_FALLBACK_TEMPLATE = (
"抱歉,我这边刚刚没能正常回应。"
@@ -597,7 +792,10 @@ class CustomerServiceAgent(BaseAgent):
allowed_roles=("visitor", "customer"),
allowed_portals=("api",),
# 代码上限:实际可用范围由发布配置的意图白名单收窄(两者取交集)
allowed_tools=(TOOL_NAME, VISITOR_TOOL_NAME, SUITABILITY_TOOL, PROFILE_TOOL_NAME),
allowed_tools=(
TOOL_NAME, VISITOR_TOOL_NAME, SUITABILITY_TOOL, PROFILE_TOOL_NAME,
ELIGIBLE_TOOL_NAME,
),
supported_intents=(
INTENT_FAQ, INTENT_PRODUCT, INTENT_POLICY, INTENT_SUITABILITY,
INTENT_CHITCHAT, INTENT_TRANSFER,
@@ -705,6 +903,13 @@ class CustomerServiceAgent(BaseAgent):
calculation = await self._answer_calculation(request, context)
if calculation is not None:
return calculation
# `E2c-my`:以**本人等级**为对象的可购买范围。与 `E2c` 的分工是「问句里有没有
# 自带参数」—— `E2c` 收「C1 能买 R3 吗」这类自带 C/R 的规则题;本出口收
# 「我能买什么等级的产品」这类**只问范围**的问法,等级从本人测评里取。
# 访客同样能走到这里(给公开规则表),所以**放在访客意图白名单之前**。
eligibility = await self._answer_eligibility(request, context)
if eligibility is not None:
return eligibility
intent = self._intent_code()
if is_visitor(context) and intent not in VISITOR_INTENTS:
if intent == INTENT_SUITABILITY:
@@ -873,8 +1078,14 @@ class CustomerServiceAgent(BaseAgent):
# 与消息表留痕,只是不面向客户展示。若将来要把出处给客户看,应当走
# source_references 的 knowledge 类型(需先让 ToolExecutor 登记本次可引用的 doc_id),
# 而不是继续往正文里拼字符串。
# 展示层净化后**可能整块消失**:实测语料里有 15 个切片整个块只写了一个收益数字
# (如「近三年收益率 11.85%(虚构)」),`drop_yield_claims` 按"收益数值不得输出"
# 把整块清空。这时**不能回一个空气泡**(前端就是一条空白消息),交回 `E5b`。
rendered = render_plain(drop_yield_claims(content))
if not rendered:
return self._exit_partial(hits, note="命中内容全部为收益数值,按禁止输出处理")
return CoreResult(
text=self._clamp_answer(content),
text=self._clamp_answer(rendered),
topic=self._declared_topic(content),
intent=self._classified_intent,
)
@@ -1157,6 +1368,116 @@ class CustomerServiceAgent(BaseAgent):
return cell
return None
async def _answer_eligibility(
self, request: AgentRequest, context: RequestContext
) -> CoreResult | None:
"""`E2c-my`:按**本人**权威等级给出可购买范围与在售产品清单。
返回 `None` 表示「这不是本人可购范围问题」,交回主分发。
触发用**确定性词法**(`is_own_eligibility_question`)而不是意图分类器:本出口会
给出产品清单,触发条件必须可复核、可回归;而且它必须与 `route_message()` 里
推介门禁的**同一套豁免判据**同源,否则会出现"门禁放行了、出口却不接"的空档。
清单本身**不是**投资建议:它由「公开的 C—R 匹配矩阵」× 「本人权威等级」决定,
不含任何指向性结论 —— 客户等级不同,清单不同,与服务端 `MATRIX_ALLOWED` 逐格一致。
"""
if not is_own_eligibility_question(request.message):
return None
if is_visitor(context):
# 通用规则对访客 open(`DEC-I8`);"按本人等级列清单"要读测评结果,必须登录。
return CoreResult(text=ELIGIBLE_VISITOR_TEMPLATE, intent=self._classified_intent)
try:
output = await self.call_tool(
ELIGIBLE_TOOL_NAME,
{"customer_id": str(context.user_id)},
intent=ELIGIBLE_WHITELIST_INTENT,
context=context,
)
except ForbiddenAgentError:
# 与知识出口、画像出口同口径:权限/白名单失败必须冒泡 —— 那是配置错误,
# 用兜底话术吞掉会让"工具没被授权"表现成"查不到您的等级"。
raise
except Exception:
logger.info("E2c-my 可买清单调用失败", exc_info=True)
return self._exit_eligible_miss("可买清单调用失败")
return self._render_eligible(output, request.message)
def _render_eligible(self, output: Any, message: str = "") -> CoreResult:
"""把工具返回渲染成答复;取不到权威等级一律 `E5b`。"""
if not isinstance(output, dict):
return self._exit_eligible_miss("可买清单返回格式异常")
reason = str(output.get("authority_reason") or "")
level = output.get("customer_risk_level")
if reason == "ASSESSMENT_EXPIRED":
return CoreResult(text=ELIGIBLE_EXPIRED_TEMPLATE, intent=self._classified_intent)
if reason != "AUTHORITY_OK" or not isinstance(level, int):
return self._exit_eligible_miss(f"权威等级不可用:{reason or '未知'}")
# 问句**点名了某一档**(「我可以买 R3 的产品吗」)时先给一句直接裁决:客户问的是
# "能不能买 R3",只回一份"您可买 R1、R2"的清单,那个"不"字始终没说出来 ——
# 实测 2026-09-20 该问法给出的是一份清单,客户读不出"我不能买 R3"。
# 判据复用 `parse_product_level`(与 `E2c` 同一套解析),裁决由矩阵结果反推。
asked_level = parse_product_level(message)
text = self._eligible_text(level, output, asked_level=asked_level)
hit = promotional_wording_violation(text)
if hit is not None:
# 理论上不会发生(文案是固定模板)。留作**回归哨兵**:一旦有人往模板里加了
# 「推荐 / 建议购买 / 更适合您 / 收益最高」这类引导语,这里立刻降级为只讲范围,
# 客户至少不会拿到一段引导话术。
logger.warning("E2c-my 文案命中引导词,降级为仅范围答复:%r", hit)
text = self._eligible_text(level, output, with_products=False)
return CoreResult(text=text, intent=self._classified_intent)
@staticmethod
def _eligible_text(
level: int,
output: dict[str, Any],
*,
with_products: bool = True,
asked_level: int | None = None,
) -> str:
"""把可买清单说成**陈述句**。
措辞纪律(这就是「陈述」与「引导」的分界):只讲"在范围内"与字段事实,不出现
推荐 / 建议购买 / 更适合您 / 收益最高 / 抓紧申购这类指向性表述;产品按**代码升序**
(服务端已排好),不按收益或热度重排 —— 排序就是隐性推荐。
"""
label = RISK_LEVEL_LABELS.get(f"C{level}", f"C{level}")
allowed = [str(item) for item in (output.get("allowed_levels") or ())]
disclosure = [str(item) for item in (output.get("disclosure_levels") or ())]
products = [item for item in (output.get("products") or ()) if isinstance(item, dict)]
excluded = output.get("excluded_count")
scope = "、".join(allowed) if allowed else "(该等级暂无可购买的产品等级)"
lines: list[str] = []
verdict = _asked_level_verdict(asked_level, allowed, disclosure)
if verdict:
lines.append(verdict)
lines.append(
"按《个人投资者适当性管理指南》第十二条的投资者与产品匹配矩阵,"
f"您留存的、在有效期内的风险测评等级是 {label},可购买 {scope} 等级的产品。"
)
if disclosure:
lines.append("其中 " + "、".join(disclosure) + " 等级需签署产品风险揭示书后才可购买。")
if with_products and products:
lines.append(
f"符合该范围的在售产品共 {len(products)} 只"
"(按产品代码升序,仅列代码 / 名称 / 类别 / 风险等级):"
)
lines.extend(
f"- {item.get('product_code')} {item.get('product_name')}"
f"({item.get('product_category')} · {item.get('risk_level')})"
for item in products
)
if with_products and isinstance(excluded, int) and excluded > 0:
lines.append(f"另有 {excluded} 只在售产品超出该范围,未列入。")
lines.append(ELIGIBLE_FOOTNOTE)
return "\n".join(lines)
def _exit_eligible_miss(self, note: str) -> CoreResult:
"""`E2c-my` 取不到权威等级 → `E5b` 如实告知(**不猜范围、不建单**)。"""
logger.info("E5b 可买清单无权威等级:%s", note)
return CoreResult(text=ELIGIBLE_MISS_TEMPLATE, intent=self._classified_intent)
async def _answer_suitability_rule(
self, message: str, context: RequestContext
) -> CoreResult:
@@ -1544,8 +1865,14 @@ class CustomerServiceAgent(BaseAgent):
logger.info("E4 未过合规校验,拦回 E5b")
return self._exit_partial(evidence, note="证据约束生成未过合规校验")
logger.info("E4 证据约束生成命中:块数=%s 引用=%s", len(evidence), used)
# 生成文本同样要过**展示层净化**:模型看到的证据包是 markdown 源文,它会把
# `**一、购买渠道**` 这类标记原样抄进答复(实测 2026-09-20「怎么买基金」)。
# 校验一律跑在**净化前**的原文上(更严),净化只作用于交付给客户的那一份。
rendered = render_plain(drop_yield_claims(answer))
if not rendered:
return self._exit_partial(evidence, note="证据约束生成内容全部为收益数值")
return CoreResult(
text=self._clamp_answer(answer), intent=self._classified_intent
text=self._clamp_answer(rendered), intent=self._classified_intent
)
@classmethod
@@ -1986,17 +2313,26 @@ class CustomerServiceAgent(BaseAgent):
# 「其他费用:认购费 认购时一次性收取」,而真正贴题的 `PROD-018`
# (6.3 费用计算示例,全局 top1)排在包尾,问「举个例子说明费率怎么查」
# 却拿到一段答非所问的碎片。降级时客户手里只剩这一块,更要给最好的那块。
# 挑块时**先净化再看有没有内容**:整块只有一个收益数字的切片会被清空,把它当
# "最佳证据"展示等于给客户一个空气泡;跳过它,下一个有真实内容的块才是最好的那块。
best: dict[str, Any] | None = None
best_score = -1.0
best_text = ""
for hit in hits:
if not isinstance(hit, dict):
continue
score = self._score(hit.get("score"))
if score > best_score:
best, best_score = hit, score
if score <= best_score:
continue
candidate = render_plain(
drop_yield_claims(str(hit.get("content") or "").strip())
)
if not candidate:
continue
best, best_score, best_text = hit, score, candidate
content = ""
if best is not None and best_score >= PARTIAL_FLOOR:
content = self._clamp_answer(str(best.get("content") or "").strip())
content = self._clamp_answer(best_text)
return CoreResult(
text=PARTIAL_TEMPLATE.format(content=content) if content else PARTIAL_EMPTY_TEMPLATE,
intent=self._classified_intent,
@@ -2080,7 +2416,7 @@ class CustomerServiceAgent(BaseAgent):
continue
if self._score(hit.get("score")) < CLARIFY_SCORE:
break
title = str(hit.get("title") or "").strip()[:40]
title = prettify_title(hit.get("title"))
if title and title not in candidates:
candidates.append(title)
if not candidates: