feat(W29): 对话内区间涨跌图(零新数字路径)+ 客服业务层事项轴

## 对话内图表(W29)

按「零新数字」路径实现:图只画答复正文里**已经写出来**的数字,不引入任何新数值
(新不变量 INV-8:图内每个数值必须能在同轮文字中找到)。

- E6 出口 data 增 trend_chart(纯增量,只搬运模板已写出的数字:最新净值 /
  四个区间涨跌 / 区间首末净值 / 区间高低 / 数据日期)
- 新增 trend-chart.js:**全 DOM API 构建**(widget.js 的 addMessage 一律 textContent,
  图表若拼 HTML 串等于把那层 XSS 防护重新打开),涨红跌绿(中国市场惯例)
- widget.js 从 result.tool_calls.data 取数 ⇒ **零后端读取改动**
  (result.data 只对 financial_nl2sql 暴露;走 tool_calls 这个已落库的 JSON 列免会签)

验证(真实净值数据 511810 / 160 个净值点、区间 -9.19/5.28/15.27/-6.53):
- jsdom 渲染 11/11 通过;颜色序列 [绿,红,红,绿]
- INV-8 独立断言:图上 18 个可见数字 **100% 命中答复正文**(extra_numbers=[])
- 空数据三态返回 null;label 里塞 <img onerror=...> 后 DOM 中 img 元素数 0

## 客服业务层(W28 遗留,本次一并提交)

- app/core/service_topic.py(新增):业务事项轴 —— 12 个 SVC-* 事项码 ×
  处理主体 × 留痕等级;只做留痕统计,不参与任何判定
- customer_service.py 的 E5b 双段话术(删掉「换个说法再问我一次」= 把问题推回客户)、
  kb_miss 显式声明(不再靠文本比对,话术一变化比对就静默失效)、
  意图漂移护栏(分类器判闲聊但含业务实质则不采信)
- customer_service_rules.py:substantive_business_request 等判据

注:customer_service.py 同时承载 W29 的 trend_chart 与上述 W28 改动,
两者无法按文件切分,故同批提交。
This commit is contained in:
张胜宇
2026-09-22 10:12:14 +08:00
parent 9dcfa64bc5
commit 2b408dc602
6 changed files with 724 additions and 16 deletions
@@ -64,6 +64,7 @@ from app.core.customer_service_rules import (
is_risk_level_change_request,
promotional_wording_violation,
route_message,
substantive_business_request,
visitor_advice_violation,
)
from app.core.errors import ForbiddenAgentError
@@ -104,6 +105,12 @@ from app.core.fund_fee_rules import (
purchase_fee_range,
redemption_tier,
)
from app.core.service_topic import (
next_step_block,
owner_of,
retention_of,
topic_of,
)
from app.service.agent.base import BaseAgent
#: `W21`:行业通用常识集合名。**只作为 `search_knowledge` 工具的 `collection` 入参**
#: 使用(工具契约里该字段本就是自由字符串),不绕过任何白名单 —— 档位过滤、可见性
@@ -671,9 +678,12 @@ PARTIAL_FAQ_TEMPLATE = (
#: E5b 在连"部分命中"都没有时的说法(仍不建单)。
PARTIAL_EMPTY_TEMPLATE = (
"这一条我暂时没找到对应的公开资料,也不想凭猜测回答您。"
"换个说法再问我一次就行(带上具体产品名、费率或条款名都能帮我定位),"
f"也可以拨打客服热线 {HOTLINE}({SERVICE_HOURS})咨询。"
f"您也可以直接拨打客服热线 {HOTLINE}({SERVICE_HOURS})咨询。"
)
#: ⚠️ 上面这句**刻意不含**「换个说法再问我一次」—— `W28` 删掉了它。
#: 理由:那句话在真实业务里等于**把问题推回客户**(客户不知道该换什么说法,
#: 也不知道自己有三条现成的路)。「下一步」改由 `service_topic.next_step_block()`
#: 按**业务事项**给出具体动作,见 `_exit_partial()` 的说明。
#: `E5b` **空答**(连部分资料都没给出)的两种确切文案 —— `F-2` 用它判定「答不上来」。
#: 直接比对上面两个模板常量本身:改文案时两边同源移动,不会出现「改了模板、判据静默失效」。
@@ -1022,10 +1032,32 @@ class CustomerServiceAgent(BaseAgent):
super().__init__(definition or self.definition)
async def handle(self, request: AgentRequest, context: RequestContext) -> CoreResult:
"""出口入口:跑完所有分支,再做一次**访客侧投资建议护栏**(`C-09`)。"""
"""出口入口:跑完所有分支,打事项码,再做一次**访客侧投资建议护栏**(`C-09`)。"""
result = await self._route_and_answer(request, context)
result = self._tag_service_topic(result, request)
return self._guard_visitor_advice(result, context)
def _tag_service_topic(self, result: CoreResult, request: AgentRequest) -> CoreResult:
"""给本轮答复打上**业务事项码**(`W28`,见 `app/core/service_topic.py`)。
为什么打在 `data` 而不新增字段:`data` 的既有语义就是「业务 Agent 可返回的结构化结果」;
而 `CoreResult.topic` 已被 `_declared_topic()` 占用 —— 它声明的是**本轮答复在说哪个
主语**(`E-05`)。两者含义不同,不能挤进同一个字段。
为什么只加数据、不改行为:事项码是**留痕与统计**口径(业务上「这次受理的是什么事」),
消费方是报表与复盘。它**不得参与任何判定** —— 否则「事项码判错」会连带把答复改错,
而事项码的关键词组比出口判据粗得多,不该获得那种权力。
"""
topic = topic_of(request.message)
return result.model_copy(update={
"data": {
**result.data,
"svc_topic": topic,
"svc_owner": owner_of(topic),
"svc_retention": retention_of(topic),
}
})
def _guard_visitor_advice(
self, result: CoreResult, context: RequestContext
) -> CoreResult:
@@ -1197,6 +1229,23 @@ class CustomerServiceAgent(BaseAgent):
return await self._answer_from_knowledge(request, context, INTENT_FAQ)
return self._guide_to_login("访客请求超出公开服务范围")
if intent == INTENT_CHITCHAT:
# `W28` 意图漂移护栏:分类器判「闲聊」,但问句里**带业务实体** ⇒ 不采信这次分类。
#
# 为什么需要它:`L0-a` 用的是**确定性**判据(词表 + 业务实体边界),但它只在
# **分类之前**跑一次。分类器若把一个真业务问题判成 `chitchat`,本分支会直接走
# 闲聊出口 —— 闲聊出口**不查知识库、不给素材**,客户于是在一个产品费率问题上
# 收到一句寒暄。
#
# 为什么方向取「有实体就不闲聊」:两类误判的代价**不对称**。把「好的」误判成业务
# 只是多查一次库(最多落 `E5b`);把「费率是多少」误判成闲聊是**答非所问**,
# 而 `E5b` 至少还会说「我没找到」并给下一步。
if substantive_business_request(request.message):
logger.info(
"意图漂移护栏:分类=%s 但问句含业务实体,改走知识检索 %r",
INTENT_CHITCHAT,
request.message[:40],
)
return await self._answer_from_knowledge(request, context, INTENT_FAQ)
return await self._chitchat(request)
if intent == INTENT_TRANSFER:
# `F-2`(2026-09-19 裁定):转人工**只由「用户显式要求」触发**,意图标签不再直通。
@@ -1277,22 +1326,24 @@ class CustomerServiceAgent(BaseAgent):
# 运维会看到"客服一直引导人工"却查不出原因。
raise
except Exception:
return self._exit_partial([], note="知识检索调用失败")
return self._exit_partial([], note="知识检索调用失败", topic_source=request.message)
if not isinstance(output, dict):
return self._exit_partial([], note="知识检索返回格式异常")
return self._exit_partial([], note="知识检索返回格式异常", topic_source=request.message)
if output.get("degraded"):
reason = str(output.get("reason") or "未知")
return self._exit_partial([], note=f"知识检索降级:{reason}")
return self._exit_partial(
[], note=f"知识检索降级:{reason}", topic_source=request.message
)
hits = output.get("hits")
if not isinstance(hits, list) or not hits:
return self._exit_partial([], note="知识库未命中")
return self._exit_partial([], note="知识库未命中", topic_source=request.message)
hits = await self._supplement_basic_explain(request, context, list(hits))
best = hits[0]
if not isinstance(best, dict):
return self._exit_partial([], note="命中内容格式异常")
return self._exit_partial([], note="命中内容格式异常", topic_source=request.message)
# `F-3` 主体相关性闸门:问句点名了某个主题(如「投顾服务」),但**一块命中都没提到它**
# —— 这是"检索拿相近概念凑数",直返原文或合并生成都会答非所问(实测 `B-04`)。
# 放在 `E4` 与置信判定**之前**:这类答复不是"置信度不够",而是"证据与问题无关"。
@@ -1358,7 +1409,7 @@ class CustomerServiceAgent(BaseAgent):
else "命中与问句无共同业务词"
)
logger.info("E5b 相关性闸门:%s doc_id=%s", note, hits[0].get("doc_id"))
return self._exit_partial([], note=note)
return self._exit_partial([], note=note, topic_source=request.message)
return self._exit_partial(
hits, note=f"置信度不足:score={score:.3f} gap={gap:.3f}"
)
@@ -1368,7 +1419,7 @@ class CustomerServiceAgent(BaseAgent):
best = self._prefer_section(request.message, best, hits)
content = str(best.get("content") or "").strip()
if not content:
return self._exit_partial(hits, note="命中内容为空")
return self._exit_partial(hits, note="命中内容为空", topic_source=request.message)
# 正文只保留答案本身:固定免责声明由**治理层**统一追加(见文件头 `DISCLAIMER` 说明),
# 业务代码不再拼字符串——否则会出现两条重复声明,且合规文案变成不可配置的硬编码。
@@ -1382,7 +1433,11 @@ class CustomerServiceAgent(BaseAgent):
# 把整块清空。这时**不能回一个空气泡**(前端就是一条空白消息),交回 `E5b`。
rendered = render_plain(drop_yield_claims(content))
if not rendered:
return self._exit_partial(hits, note="命中内容全部为收益数值,按禁止输出处理")
return self._exit_partial(
hits,
note="命中内容全部为收益数值,按禁止输出处理",
topic_source=request.message,
)
return CoreResult(
text=self._clamp_answer(rendered),
topic=self._declared_topic(content),
@@ -3196,6 +3251,43 @@ class CustomerServiceAgent(BaseAgent):
f"- 区间最高:{high.get('nav')}({high.get('nav_date')})"
f"|区间最低:{low.get('nav')}({low.get('nav_date')})"
)
# `W29`:给前端「区间涨跌图」的结构化数据(新不变量 `INV-8` 的落地)。
#
# 🔑 这里**只搬运正文上面已经写出来的数字** ——
# 柱高 = 正文那句「近 N 个净值日涨跌 X%」,
# 柱下标注 = 正文那对「start_nav → end_nav」。
# **不引入任何新数值**:`M-9`(无出处数字 = 0)的取证面**只看答复文本**,
# 图里的数字它看不见 —— 一旦图画出正文没有的数,图就成了唯一能绕过
# 数字校验的合规通道。这条约束是 `INV-8` 的全部内容。
#
# 为什么放 `data` 而不是新字段:`data` 的既有语义就是「业务 Agent 可返回的
# 结构化结果」,且它已随 `conversation_message.tool_calls` 这个 JSON 列落库
# (零 DDL),读侧 `RunQueryService` 整块透传 `tool_calls` ⇒ **前端零后端改动可取到**。
chart_intervals: list[dict[str, Any]] = []
for row in (intervals if isinstance(intervals, list) else []):
if not isinstance(row, dict):
continue
pct = row.get("change_pct")
start_nav = row.get("start_nav")
end_nav = row.get("end_nav")
chart_intervals.append({
"label": str(row.get("name") or ""),
"change_pct": str(pct) if pct is not None else None,
"start_nav": str(start_nav) if start_nav is not None else None,
"end_nav": str(end_nav) if end_nav is not None else None,
})
chart: dict[str, Any] = {
"kind": "interval_change",
"latest_nav": str(latest_nav) if latest_nav is not None else None,
"latest_nav_date": latest_date,
"from_date": str(output.get("from_date") or ""),
"to_date": str(output.get("to_date") or ""),
"series_points": output.get("series_points"),
"source": str(output.get("source") or ""),
"intervals": chart_intervals[:4],
"high": high if isinstance(high, dict) else None,
"low": low if isinstance(low, dict) else None,
}
lines.append(
f"- 数据来源:{output.get('source')}|数据区间 "
f"{output.get('from_date')}—{output.get('to_date')},"
@@ -3206,7 +3298,12 @@ class CustomerServiceAgent(BaseAgent):
intent=self._classified_intent,
exit_code=EXIT_QUOTE,
topic=name,
data={"fund_code": code, "fund_name": name, "source": output.get("source")},
data={
"fund_code": code,
"fund_name": name,
"source": output.get("source"),
"trend_chart": chart,
},
)
def _exit_trend_miss(self, subject: str, *, reason: str = "", note: str = "") -> CoreResult:
@@ -3261,7 +3358,9 @@ class CustomerServiceAgent(BaseAgent):
exit_code=EXIT_TRANSFER,
)
def _exit_partial(self, hits: list[Any], *, note: str = "") -> CoreResult:
def _exit_partial(
self, hits: list[Any], *, note: str = "", topic_source: str = ""
) -> CoreResult:
"""E5b 部分答 + 引导。**不建单**:这是"这次没查到",不是"必须人工办的事"。
有达到 ``PARTIAL_FLOOR`` 的命中时展示其正文(原文直返,不经模型);
@@ -3297,16 +3396,35 @@ class CustomerServiceAgent(BaseAgent):
content = self._clamp_answer(best_text)
if not content:
text = PARTIAL_EMPTY_TEMPLATE
missed = True
elif best_text.lstrip().startswith("问:"):
# `W21-D4`:命中块是 FAQ 问答对 ⇒ 以答案正文开场,不加兜底式前言。
text = PARTIAL_FAQ_TEMPLATE.format(content=content)
missed = False
else:
text = PARTIAL_TEMPLATE.format(content=content)
missed = False
if missed and topic_source:
# `W28` 双段话术:**「办不了」必须跟一个具体动作**。
#
# 原话术是「换个说法再问我一次就行」—— 在真实业务里这句等于**把问题推回客户**:
# 客户不知道该换什么说法,也不知道自己其实有现成的路可走(APP 自助 / 客户服务
# 中心 / 热线转人工)。真实客服的「办不了」后面永远跟一个**动作**。
#
# 动作按**业务事项**给(`service_topic.next_step_block`),不按检索结果给 ——
# 检索结果只说明「这次没查到」,不说明「该去哪办」。
text = text + "\n\n" + next_step_block(
topic_of(topic_source), phone=HOTLINE, hours=SERVICE_HOURS
)
return CoreResult(
text=text,
intent=self._classified_intent,
topic=self._declared_topic(content) if content else "",
exit_code=EXIT_PARTIAL,
# `kb_miss` 由出口**显式声明**「本轮什么都没答上来」,供 `F-2` 的转人工判据直接读。
# 为什么不再用文本比对:话术一旦按事项变化,`result.text in KNOWLEDGE_MISS_TEXTS`
# 会**静默失效**(判据恒为假 ⇒ 该转人工的不转),而失效时没有任何报错。
data={"kb_miss": missed},
)
@staticmethod
@@ -3316,6 +3434,9 @@ class CustomerServiceAgent(BaseAgent):
只有「连部分资料都没有」才算答不上来:`E5b` 带内容的、给出澄清候选的,都算答到了
一部分,**不转人工**——`E5b` 与澄清本身也从不建单(`D3.7` §5「部分作答即合格」)。
"""
flag = result.data.get("kb_miss")
if isinstance(flag, bool):
return flag
return result.text in KNOWLEDGE_MISS_TEXTS
@staticmethod