## 对话内图表(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 改动, 两者无法按文件切分,故同批提交。
149 lines
5.5 KiB
JavaScript
149 lines
5.5 KiB
JavaScript
/**
|
||
* 客服浮窗内的「区间涨跌图」——只画答复正文里已经说过的数字。
|
||
*
|
||
* ## 这个组件为什么长这样
|
||
*
|
||
* **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;
|
||
}
|