## 对话内图表(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 改动, 两者无法按文件切分,故同批提交。
253 lines
12 KiB
JavaScript
253 lines
12 KiB
JavaScript
/**
|
||
* 智能客服浮窗 —— 公开页(访客)与客户工作台(登录客户)**共用同一份实现**。
|
||
*
|
||
* ## 为什么抽成公共模块而不是各页复制
|
||
*
|
||
* 浮窗挂在公开首页 / 产品列表 / 产品详情与 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();
|
||
});
|
||
}
|