- 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.
139 lines
8.2 KiB
JavaScript
139 lines
8.2 KiB
JavaScript
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()
|