Files
group_xinghuo_jinrong/scripts/e2e/06-chat.mjs
T
zhanghongyu_0626 188bf6a438 feat(analyst_agent): Add support for recent days queries and enhance SQL hint generation
- Introduced `_recent_days_series_hint` to handle queries related to "最近N天" for time series data aggregation.
- Added `_cn_num_to_int` function to convert Chinese numerals to integers for better query parsing.
- Updated `_nl_sql_hints` to incorporate the new hint generation logic, ensuring accurate SQL output for recent days queries.
- Enhanced documentation to reflect these changes and improve clarity on the new functionalities.
2026-09-13 22:36:19 +08:00

139 lines
8.2 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.
import { launch, instrument, login, BASE, SHOT_DIR, makeEmitter } from './harness.mjs'
const b = await launch()
const ctx = await b.newContext({ viewport: { width: 1280, height: 900 } })
const p = await ctx.newPage()
const rec = instrument(p)
const out = []
const emit = makeEmitter(out) // F-13:边跑边出,中途异常不丢已完成证据
const ok = (n, pass, d = '') => emit(`${pass ? 'PASS' : 'FAIL'} | ${n}${d ? ' | ' + d : ''}`)
const bodyText = async () => (await p.locator('body').innerText()).replace(/\s+/g, ' ')
/**
* 角色 → 对话页真实路径。
*
* 修复前:`#/app/${role}/chat` 直接拼接 —— 对 `analyst` 拼出不存在的 `/app/analyst/chat`
* (2026-09-13 记录为资产腐化 F-02)。
* 关键事实(读 `web/src/App.tsx` 与 `web/src/routes/menus.tsx` 得):
* 只有 customer / advisor / risk **三个**角色有对话页;
* `/app/analytics/chat` **不是页面**,而是 `<Navigate to="/app/analytics/query" replace/>`
* —— 数据分析线的问答入口就是「问数工作台」(`/app/analytics/query`)。
* 故此处不是"把 analyst 换写成 analytics",而是**承认数据分析线没有独立对话页**。
*/
const CHAT_PATH = {
customer: '/app/customer/chat',
advisor: '/app/advisor/chat',
risk: '/app/risk/chat',
// 数据分析线无对话页:走问数工作台(Q&A 工作台,非流式对话)
analyst: '/app/analytics/query',
}
async function sendChat(role, message, { stream = true } = {}) {
const path = CHAT_PATH[role]
if (!path) throw new Error(`06-chat.mjs 未定义角色 ${role} 的对话页路径`)
await p.goto(`${BASE}/#${path}`, { waitUntil: 'domcontentloaded' })
await p.waitForTimeout(6000)
const before = await bodyText()
const ta = p.locator('textarea').first()
if (!(await ta.count())) return { ok: false, reason: 'no textarea', before }
await ta.fill(message)
// find the send control
const send = p
.locator('button')
.filter({ hasText: /发送|提交|发送消息/ })
.first()
if (await send.count()) await send.click()
else await ta.press('Enter')
await p.waitForTimeout(25000)
const after = await bodyText()
return { ok: after !== before, before, after }
}
// ================= RISK CHAT (SSE stream) =================
await login(p, 'risk')
const r1 = await sendChat('risk', '现在有多少条待处理预警?')
await p.screenshot({ path: `${SHOT_DIR}/chat-risk.png`, fullPage: true })
ok('风控助手能发出消息并收到回复', r1.ok, (r1.after ?? r1.before).slice(-380))
// 保持**硬断言**(本轮实跑 PASS)。但如实标注其确定性来源,勿误当"后端强制兜底":
// 实测命中的「本回复仅供参考,不构成投资建议。市场有风险,投资需谨慎。」**既不等于**
// `RISK_DISCLAIMER`(`compliance_guard.py:26`「【风险提示】以上信息仅供参考…」)
// **也不等于** `CHAT_DISCLAIMER`(`agent_service.py:40`「以上内容由 AI 生成…」)
// ⇒ 它来自风控 Agent 的**系统提示词**,属"模型听话"而非"代码门控"。
// 与客户线(F-14)属同一类风险,只是当前措辞稳定。若哪天提示词改动使其漂移,
// 这条会 FAIL —— 届时按 F-14 的口径先取证 intent/提示词来源,再决定降级还是记缺陷。
ok('风控回复带免责声明', /不构成投资建议|仅供参考|AI 生成/.test(r1.after ?? ''), (r1.after ?? '').slice(-200))
// session list / new session / close
const sessText = await bodyText()
// ================= ADVISOR CHAT =================
await p.goto(`${BASE}/#/login`, { waitUntil: 'domcontentloaded' })
await p.evaluate(() => localStorage.clear())
await p.reload({ waitUntil: 'domcontentloaded' })
await login(p, 'advisor')
const r2 = await sendChat('advisor', 'CUST-9527 现在的持仓情况如何?')
await p.screenshot({ path: `${SHOT_DIR}/chat-advisor.png`, fullPage: true })
ok('顾问助手能发出消息并收到回复', r2.ok, (r2.after ?? r2.before).slice(-380))
// ================= ANALYST(数据分析线)=================
// ⚠️ 本组**不是**对话用例:数据分析线没有独立对话页(见 CHAT_PATH 注释)。
// 原用例 `sendChat('analyst', '你好')` 打的是不存在的 /app/analyst/chat,属 F-02 腐化。
// 改为验证该线的**真实**问答入口「问数工作台」可提交并拿到回答。
await p.goto(`${BASE}/#/login`, { waitUntil: 'domcontentloaded' })
await p.evaluate(() => localStorage.clear())
await p.reload({ waitUntil: 'domcontentloaded' })
await login(p, 'analyst')
const r3 = await sendChat('analyst', '平台客户总数是多少')
await p.screenshot({ path: `${SHOT_DIR}/analytics-query.png`, fullPage: true })
ok('问数工作台能提交问题并拿到回答', r3.ok, (r3.after ?? r3.before).slice(-380))
// ================= CUSTOMER CHAT (sync) =================
await p.goto(`${BASE}/#/login`, { waitUntil: 'domcontentloaded' })
await p.evaluate(() => localStorage.clear())
await p.reload({ waitUntil: 'domcontentloaded' })
await login(p, 'customer')
const r4 = await sendChat('customer', '我的持仓收益怎么样?')
await p.screenshot({ path: `${SHOT_DIR}/chat-customer.png`, fullPage: true })
ok('客户助手能发出消息并收到回复', r4.ok, (r4.after ?? r4.before).slice(-380))
// F-14 修复(本脚本此前**从未跑过**,故该断言从未被校准):
// 原写法 `ok('客户回复带免责声明', /不构成投资建议|仅供参考|AI 生成/.test(...))` 是**硬断言**,
// 本轮首跑 FAIL。取证后确认**不是产品缺陷,是断言越界**,两条独立理由:
// ① 客户线的免责声明是 **intent 门控**的:`customer_service.py:405-407` 只在
// `should_add_disclaimer(intent)` 为真时才把 `RISK_DISCLAIMER` 逐字追加,而
// `compliance_guard.py:108-110` 的名单只有 `("product_consult","policy_interpret","faq")`
// —— 「我的持仓收益怎么样?」解析出的持仓类意图**不在名单内**,按设计不追加。
// (与 08-edge.mjs 的游客试聊是**同一个**设计事实,那边已按 R2 降级为 INFO。)
// ② 断言用的正则是**逐字**匹配。实测本轮回复含 LLM 自撰的
// 「不构成任何买卖建议」——**实质性风险提示在,只是措辞不同**,
// 逐字正则在真 LLM 输出上必然时过时不过(R2:LLM 语义一律软探针)。
// 故拆成两条 INFO(记录实测,不进 FAIL 分母),且**证据比原来更细**:
// 分别记录「实质性提示有无」与「规范文案有无」,后者才是确定性路径的证据。
// ⚠️ 遗留的**产品口径问题**(不因本次降级而消失):返回收益数字的回复是否**应当**
// 强制带规范风险提示,记入横切包 XC 待拍板 —— 本轮只取证不修。
const custTail = (r4.after ?? '')
const custSubstantive = /不构成[^。]{0,12}建议|仅供参考|风险提示|AI 生成/.test(custTail)
const custCanonical = custTail.includes('不构成投资建议') // RISK_DISCLAIMER 逐字
emit(`INFO | 客户回复含实质性风险提示 | ${custSubstantive ? '含' : '未含'}(LLM 自撰措辞也算,如「不构成任何买卖建议」)`)
emit(`INFO | 客户回复含**规范**风险提示(RISK_DISCLAIMER 逐字) | ${custCanonical ? '含' : '未含(intent 门控:持仓类意图不在 compliance_guard.py:109 名单内)'}`)
// empty message must not send
{
const before = await bodyText()
await p.locator('textarea').first().fill(' ')
await p.waitForTimeout(800)
const send = p.locator('button').filter({ hasText: /发送/ }).first()
const disabled = (await send.count()) ? await send.isDisabled() : false
ok('空白输入时发送按钮被禁用', disabled, disabled ? 'disabled' : 'still clickable')
ok('空白消息未发出', (await bodyText()) === before)
}
// F-13:明细已在产生时即时输出;此处只给汇总(即使中断也不丢已完成证据)
const _nP = out.filter((l) => l.startsWith('PASS')).length
const _nF = out.filter((l) => l.startsWith('FAIL')).length
const _nI = out.filter((l) => l.startsWith('INFO')).length
console.log(`
===== ${_nP} PASS / ${_nF} FAIL / ${_nI} INFO =====`)
console.log('\n--- errors ---')
console.log(JSON.stringify({ pageErrors: [...new Set(rec.pageErrors)], httpErrors: rec.httpErrors.slice(0, 10) }))
await b.close()