Files
group_fqcd_jr/app/static/portal/common/customer-service-widget/trend-chart.js
T
张胜宇 2b408dc602 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 改动,
两者无法按文件切分,故同批提交。
2026-09-22 10:12:14 +08:00

149 lines
5.5 KiB
JavaScript
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.
/**
* 客服浮窗内的「区间涨跌图」——只画答复正文里已经说过的数字。
*
* ## 这个组件为什么长这样
*
* **1. 只画正文已声明的数字**(不变量 `INV-8`)。
* 柱高 = 正文那句「近 N 个净值日涨跌 X%」;悬停提示 = 正文那对「起始净值 → 末净值」。
* **不引入任何新数值** —— `M-9`(无出处数字 = 0)的取证面**只看答复文本**,
* 图里的数字它看不见;图一旦画出正文没有的数,它就成了唯一能绕过数字校验的通道。
*
* **2. 涨红跌绿**。中国市场惯例,与欧美相反 —— 这里的客户看到绿色会理解成"跌"。
*
* **3. 全部走 DOM API 构造,绝不字符串拼 HTML**。
* `widget.js` 的 `addMessage` 一律用 `textContent`,注释里写明「用 innerHTML 等于把
* 回答内容当标记语言解析」。图表组件若图省事拼 HTML 串,等于把这层 XSS 防护重新打开,
* 而且图里的数字还会绕过数字校验。
*
* **4. 不带任何预测性元素**:没有趋势线、没有目标线、没有均线 ——
* 只呈现**已经发生**的区间涨跌(`D3.9` §4.3 的禁止项:不得把区间涨跌表述为预期)。
*/
const SVG_NS = 'http://www.w3.org/2000/svg';
//: 涨 = 红 / 跌 = 绿(中国惯例)/ 持平 = 中性灰。
const UP_FILL = '#c0392b';
const DOWN_FILL = '#1d9e75';
const FLAT_FILL = '#7c8b87';
const AXIS_STROKE = '#cfe1db';
const VALUE_FILL = '#29423b';
const LABEL_FILL = '#5b716b';
const VIEW_W = 300;
const VIEW_H = 128;
const BASE_Y = 72;
const BAR_W = 44;
const MAX_UP = 40;
const MAX_DOWN = 20;
function svgNode(tag, attrs) {
const node = document.createElementNS(SVG_NS, tag);
Object.entries(attrs || {}).forEach(([key, value]) => node.setAttribute(key, String(value)));
return node;
}
function svgText(attrs, content) {
const node = svgNode('text', attrs);
node.textContent = content;
return node;
}
/** 「近 5 个净值日」→「5日」:只截取期数,不新造说法,也不让标签互相压到。 */
function shortIntervalLabel(label) {
const matched = String(label || '').match(/\d+/);
return matched ? `${matched[0]}日` : String(label || '');
}
/**
* 构建区间涨跌图。
*
* @param {object} spec `E6` 出口写入 `CoreResult.data.trend_chart` 的结构
* @returns {HTMLElement|null} 数据不足时返回 `null`(调用方据此不渲染任何东西)
*/
export function buildTrendChart(spec) {
if (!spec || !Array.isArray(spec.intervals) || spec.intervals.length === 0) return null;
const rows = spec.intervals
.filter((row) => row && row.change_pct !== null && row.change_pct !== undefined)
.slice(0, 4);
if (rows.length === 0) return null;
// 后端给的是字符串(`Decimal` 保精度),这里只用于**画图**,不回写文本。
const values = rows.map((row) => {
const value = Number(row.change_pct);
return Number.isFinite(value) ? value : 0;
});
const peak = Math.max(...values.map((value) => Math.abs(value)), 1);
const figure = document.createElement('figure');
figure.className = 'cs-widget__chart';
const svg = svgNode('svg', {
viewBox: `0 0 ${VIEW_W} ${VIEW_H}`,
width: '100%',
role: 'img',
'aria-label': `区间涨跌图:${rows
.map((row, index) => `${row.label || ''}${values[index]}%`)
.join(',')}`,
});
// 零基线(不是坐标轴:不标刻度,避免被读成"走势图/预测图")。
svg.appendChild(svgNode('line', {
x1: 6, y1: BASE_Y, x2: VIEW_W - 6, y2: BASE_Y,
stroke: AXIS_STROKE, 'stroke-width': '1',
}));
rows.forEach((row, index) => {
const value = values[index];
const x = 8 + index * 80;
const positive = value >= 0;
const height = Math.max(2, (Math.abs(value) / peak) * (positive ? MAX_UP : MAX_DOWN));
const y = positive ? BASE_Y - height : BASE_Y;
const fill = value > 0 ? UP_FILL : (value < 0 ? DOWN_FILL : FLAT_FILL);
const group = svgNode('g', {});
group.appendChild(svgNode('rect', { x, y, width: BAR_W, height, rx: 3, fill }));
// 悬停提示:用正文里那对首末净值,不额外算任何数。
const title = svgNode('title', {});
title.textContent = `${row.label || ''}涨跌 ${row.change_pct}%`
+ (row.start_nav && row.end_nav ? `(${row.start_nav} → ${row.end_nav})` : '');
group.appendChild(title);
// 数值标注:正数加 "+",负数原样(后端 `Decimal` 已带负号)。
const raw = String(row.change_pct);
const shown = raw.startsWith('-') ? `${raw}%` : `+${raw}%`;
group.appendChild(svgText({
x: x + BAR_W / 2,
y: positive ? y - 7 : y + height + 12,
'text-anchor': 'middle',
'font-size': '10',
fill: VALUE_FILL,
}, shown));
group.appendChild(svgText({
x: x + BAR_W / 2,
y: 116,
'text-anchor': 'middle',
'font-size': '10',
fill: LABEL_FILL,
}, shortIntervalLabel(row.label)));
svg.appendChild(group);
});
figure.appendChild(svg);
// 图注 = 可核验信息(`D3.9` §4.3「必带数据日期与来源」)。文案全部来自服务端字段。
const caption = document.createElement('figcaption');
caption.className = 'cs-widget__chart-caption';
const parts = [];
if (spec.source) parts.push(`来源 ${spec.source}`);
if (spec.from_date && spec.to_date) parts.push(`${spec.from_date}—${spec.to_date}`);
if (spec.series_points) parts.push(`${spec.series_points} 个净值日`);
caption.textContent = parts.join('|');
figure.appendChild(caption);
return figure;
}