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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user