Files
group_fqcd_jr/app/static/portal/common/customer-service-widget/widget.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

253 lines
12 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.
/**
* 智能客服浮窗 —— 公开页(访客)与客户工作台(登录客户)**共用同一份实现**。
*
* ## 为什么抽成公共模块而不是各页复制
*
* 浮窗挂在公开首页 / 产品列表 / 产品详情与 6 个客户页上。复制多份的话,
* 「访客身份怎么取」「轮询预算多久」这类口径迟早分叉,而症状是
* 「某个页面莫名其妙超时 / 一问就 401」——很难查。
*
* ## 身份口径(与 `portal/README.md` 一致)
*
* - **公开页**:一律用**访客令牌**,即使当前浏览器里还留着员工或客户的登录令牌。
* 公开页面的行为对所有人必须一致;混用登录令牌会让「访客能看到什么」随登录状态漂移。
* 令牌来自 `common/visitor-token.js`(访客令牌的**唯一实现**),不在这里另写一份。
* - **客户工作台**:走登录令牌(`apiClient` 自动附加),会话落库到 `C001`,
* 这样多轮澄清与「转人工」都能挂到同一个 `session_id` 上。
*
* 访客侧刻意**不建会话**:访客令牌只带 `agent:run` + `knowledge:query`,
* 没有 `conversation:create`,所以用一个进程内的临时 `session_id`(页面刷新即重置)。
*
* ## 为什么是轮询而不是 SSE
*
* `GET /api/v1/agent-runs/{runId}/events`(SSE)已可用,但轮询的断线行为更可预期。
* 实测端到端「受理 0.05s + 意图分类 + 检索 + 落库」约 4.1–4.8 秒;
* 本文件的预算是「300ms 后首次、之后每 500ms 一次、共 40 次」≈ 20 秒,
* 既把感知延迟压到半秒内,又给模型/检索抖动留出余量。
*
* ⚠️ 若这里仍然超时,先确认 Agent Worker 已启动(`python -m app.worker`)——
* 没有 Worker 时 run 会一直停在 queued,任何轮询预算都不够。
*/
import { apiClient, ApiError } from '/static/portal/common/api-client.js?v=20260913';
import { getAccessToken, getAuthContext } from '/static/portal/common/auth.js?v=20260913';
import { visitorHeaders } from '/static/portal/common/visitor-token.js';
import { buildTrendChart } from '/static/portal/common/customer-service-widget/trend-chart.js?v=20260922';
const AGENT_TYPE = 'customer_service';
//: 首次轮询前的等待:后端受理是同步的,先等一小段能让"短问题"一轮就出结果。
const POLL_FIRST_DELAY_MS = 300;
const POLL_DELAY_MS = 500;
const POLL_ATTEMPTS = 40;
function mountMarkup(mode) {
const isCustomer = mode === 'customer';
document.body.insertAdjacentHTML('beforeend', `
<aside class="cs-widget" data-cs-widget data-mode="${isCustomer ? 'customer' : 'visitor'}">
<button class="cs-widget__launcher" type="button" aria-label="打开智能客服" aria-expanded="false" data-cs-launcher>
<span class="cs-widget__orb" aria-hidden="true"><span></span></span>
<span class="cs-widget__launcher-copy"><strong>南方小助</strong><small>${isCustomer ? '专属服务' : '基金问答'}</small></span>
</button>
<section class="cs-widget__panel" aria-label="智能客服对话" hidden data-cs-panel>
<header class="cs-widget__header">
<div><p class="cs-widget__eyebrow">${isCustomer ? '已登录客户' : '公开服务'}</p><h2>智能客服</h2></div>
<button class="cs-widget__close" type="button" aria-label="关闭客服" data-cs-close>×</button>
</header>
<div class="cs-widget__notice">${isCustomer
? '可查询您的测评、账户与基金服务信息。'
: '可查询公开基金与服务规则,账户问题请先登录。'}</div>
<div class="cs-widget__messages" role="log" aria-live="polite" data-cs-messages>
<div class="cs-widget__message cs-widget__message--assistant">您好,我是南方小助。${isCustomer
? '可以帮您查询账户相关服务。'
: '可以帮您了解公开基金与服务规则。'}</div>
</div>
<form class="cs-widget__composer" data-cs-form>
<input name="message" maxlength="8000" autocomplete="off" placeholder="请输入您的问题" aria-label="输入问题" required>
<button type="submit" aria-label="发送问题">发送</button>
</form>
<div class="cs-widget__footer">
<button type="button" data-cs-action>${isCustomer ? '转人工客服' : '登录后查询账户'}</button>
<span data-cs-status>内容来自已发布知识与权限范围</span>
</div>
</section>
</aside>`);
}
export function mountCustomerServiceWidget(mode = 'public') {
if (document.querySelector('[data-cs-widget]')) return;
const isCustomer = mode === 'customer';
mountMarkup(mode);
const root = document.querySelector('[data-cs-widget]');
const launcher = root.querySelector('[data-cs-launcher]');
const panel = root.querySelector('[data-cs-panel]');
const messages = root.querySelector('[data-cs-messages]');
const form = root.querySelector('[data-cs-form]');
const input = form.elements.message;
const status = root.querySelector('[data-cs-status]');
let sessionId = '';
let busy = false;
const setOpen = (open) => {
panel.hidden = !open;
launcher.setAttribute('aria-expanded', String(open));
root.classList.toggle('cs-widget--open', open);
if (open) window.setTimeout(() => input.focus(), 60);
};
const addMessage = (text, role = 'assistant') => {
const item = document.createElement('div');
item.className = `cs-widget__message cs-widget__message--${role}`;
// 一律走 textContent:模型输出可能含 `<` 之类的字符,用 innerHTML 等于把
// 回答内容当标记语言解析。
item.textContent = text;
messages.appendChild(item);
messages.scrollTop = messages.scrollHeight;
return item;
};
// ⚠️ **刻意不渲染「参考:」行**。`result.source_references` 目前只有
// `source_type="tool"`(标题就是工具名,如 `query_knowledge`),知识来源引用属于
// `C-10` 乙的**降级项**:`governance.review_output` 不认可 `knowledge` 来源,
// 一旦输出会被判「引用未来自本次已召回结果」而**整个 run 失败**。
// 展示「参考:query_knowledge」对客户毫无意义,所以这里宁可不显示;
// 可追溯性由审计(`agent.tool_executed` 含命中 doc_id 与分数)承接。
// 启用前提见 `D2.2 FR-CS-010`(需底座方会签)。
/**
* 访客模式**总是**显式带访客令牌:`apiClient` 遇到显式 `Authorization` 不再覆盖
* (见 `api-client.js` 的同名注释),所以公开页不会因为浏览器里恰好有登录令牌
* 而看到登录后才能看的内容。
*/
const requestHeaders = () => (isCustomer ? Promise.resolve({}) : visitorHeaders());
async function ensureSession() {
if (sessionId) return sessionId;
if (!isCustomer) {
sessionId = crypto.randomUUID();
return sessionId;
}
const response = await apiClient.post(
'C001', { agent_type: AGENT_TYPE }, { headers: await requestHeaders() },
);
sessionId = response.data?.session_id || '';
if (!sessionId) throw new ApiError('客服会话未建立');
return sessionId;
}
/**
* 从 run 快照里取出「正文 + 可选图表数据」。
*
* 图表走 `result.tool_calls.data.trend_chart`:`CoreResult.data` 会落进
* `conversation_message.tool_calls` 这个 **JSON 列**(零 DDL),读侧把整个
* `tool_calls` 透传给客户端,所以这里**不必改后端**就能取到。
*
* ⚠️ 不能读 `result.data` —— 那个顶层字段只对 `financial_nl2sql` 的 run 暴露,
* 客服 Agent 的 `result` 里没有它(见 `RunQueryService.get`)。
*/
function readAnswer(snapshot) {
const result = snapshot.result || {};
const content = result.content || '暂时没有可展示的回复。';
const calls = result.tool_calls;
const stored = calls && typeof calls === 'object' && !Array.isArray(calls) ? calls.data : null;
const trendChart = stored && typeof stored === 'object' ? stored.trend_chart : null;
return { content, trendChart: trendChart || null };
}
async function waitForRun(runId) {
for (let attempt = 0; attempt < POLL_ATTEMPTS; attempt += 1) {
await new Promise((resolve) => {
window.setTimeout(resolve, attempt ? POLL_DELAY_MS : POLL_FIRST_DELAY_MS);
});
const response = await apiClient.get('R002', {
pathParams: { runId }, headers: await requestHeaders(),
});
const snapshot = response.data || {};
if (snapshot.status === 'succeeded') {
return readAnswer(snapshot);
}
if (snapshot.status === 'failed' || snapshot.status === 'cancelled') {
throw new ApiError('客服暂时无法完成回答,请稍后重试。');
}
}
throw new ApiError('客服繁忙,暂时没能给出答复,请稍后重试。');
}
form.addEventListener('submit', async (event) => {
event.preventDefault();
const message = String(input.value || '').trim();
if (!message || busy) return;
busy = true;
input.value = '';
input.disabled = true;
addMessage(message, 'user');
const pending = addMessage('正在查询已发布资料…', 'assistant');
status.textContent = '正在安全处理';
try {
const currentSession = await ensureSession();
const accepted = await apiClient.post('R001', {
agent_type: AGENT_TYPE,
session_id: currentSession,
message,
idempotency_key: crypto.randomUUID().replaceAll('-', ''),
}, { headers: await requestHeaders() });
const answer = await waitForRun(accepted.data?.run_id);
pending.textContent = answer.content;
// 图表作为**独立一块**追加。全程 DOM API(见 `trend-chart.js`),不经 innerHTML ——
// `addMessage` 刻意用 `textContent` 就是为了不把回答内容当标记语言解析,
// 图表组件若拼 HTML 串等于把这层防护重新打开。
// 图只呈现正文已声明的数字(不变量 `INV-8`);数据不足时它返回 `null`,这里不画。
if (answer.trendChart) {
const chart = buildTrendChart(answer.trendChart);
if (chart) {
messages.appendChild(chart);
messages.scrollTop = messages.scrollHeight;
}
}
status.textContent = '已完成回答';
} catch (error) {
pending.remove();
if (error.status === 401 || error.code === 'AUTHENTICATION_REQUIRED') {
addMessage(isCustomer
? '登录状态已过期,请重新登录后继续。'
: '访客服务已过期,请刷新页面后重试。');
} else if (error.code === 'AGENT_PERMISSION_DENIED') {
addMessage('当前问题超出该入口的服务范围,建议转人工客服。');
} else {
addMessage(error.message || '请求未完成,请稍后重试。');
}
status.textContent = '请求未完成';
apiClient.reportError(error);
} finally {
busy = false;
input.disabled = false;
input.focus();
}
});
launcher.addEventListener('click', () => setOpen(panel.hidden));
root.querySelector('[data-cs-close]').addEventListener('click', () => setOpen(false));
root.querySelector('[data-cs-action]').addEventListener('click', async () => {
if (!isCustomer) {
const next = encodeURIComponent(window.location.pathname);
window.location.assign(`/portal/customer/login/?next=${next}`);
return;
}
if (!sessionId) {
addMessage('您可以直接输入问题,我会先查询已发布资料。');
input.focus();
return;
}
try {
const response = await apiClient.post('C005', {
reason_code: 'user_requested',
reason_detail: '客户通过智能客服浮窗申请人工协助',
}, { pathParams: { sessionId } });
addMessage(`已记录转人工请求(${response.data?.handover_id || '处理中'}),请稍候由客服人员接入。`);
} catch (error) {
addMessage(error.message || '转人工请求未完成,请稍后重试。');
apiClient.reportError(error);
}
});
// 账号切换后浮窗身份也要跟着换;不暴露任何账户数据,只做"重新挂载"。
window.addEventListener('portal:auth-changed', () => {
const context = getAuthContext();
if (!context && isCustomer) window.location.reload();
});
}