#!/usr/bin/env node /** * 多 persona 话术矩阵:健康通道规则 + H5 状态机 + john 账号 API 抽检。 * 用法:node scripts/verify-health-persona-chat.mjs [--agent] */ import { loginViaApi, createAgentRun, waitForRunTerminal, waitForAssistantGrowth, getSession, extractAssistantTexts, createReporter, resolvePortalBase, } from './scenario-test-lib.mjs'; import { applyHealthChannelTurn, HEALTH_MENU_TEXT } from '../health-wechat-turn.mjs'; import { createIdleHealthChannelState } from '../health-channel-state.mjs'; import { isHealthEnterText, matchHealthIdleRules, shouldRedirectOrdinaryChatToHealth, HEALTH_RULE_INTENTS, } from '../health-intent-rules.mjs'; import { looksLikeHealthReportPageRequest } from '../health-report-page.mjs'; import { looksLikeHealthQuickAssess } from '../health-intent-rules.mjs'; import { wrapHealthAgentUserMessage } from '../health-agent-context.mjs'; import { healthReportPageExists, resolveHealthMaterializeH5Root } from '../health-report-page.mjs'; const base = resolvePortalBase(process.env.H5_PORT || '8087'); const password = process.env.JOHN_PASSWORD || '981122tj'; const runAgentSamples = process.argv.includes('--agent'); const idleSession = { channelState: createIdleHealthChannelState({ channel: 'h5' }), pendingValues: null, }; /** @type {Array<{ persona: string, age: string, text: string, expect: Record }>} */ const MATRIX = [ // 进入通道(无 session 时应弹出菜单) { persona: '退休大爷', age: '65+', text: '健康助手', expect: { enter: true, noSessionMenu: true } }, { persona: '中年妈妈', age: '45', text: '我要录血压', expect: { enter: true, turnHandled: true, replyIncludes: '录入' } }, { persona: '年轻白领', age: '28', text: '进入健康通道', expect: { enter: true, h5Fallthrough: true } }, { persona: '误触', age: '-', text: '健康饮食怎么做', expect: { enter: false } }, // 菜单 { persona: '通用', age: '-', text: '1', expect: { turnHandled: true, replyIncludes: '录入' } }, { persona: '通用', age: '-', text: '2', expect: { turnHandled: true, replyIncludes: '报告' } }, { persona: '通用', age: '-', text: '3', expect: { h5Fallthrough: true } }, { persona: '通用', age: '-', text: '4', expect: { turnHandled: true, replyIncludes: '档案' } }, { persona: '通用', age: '-', text: '0', expect: { turnHandled: true, replyIncludes: '退出' } }, // 血压 — 多话术 { persona: '退休大爷', age: '65+', text: '137/82', expect: { turnHandled: true, replyIncludes: '确认' } }, { persona: '中年男性', age: '50', text: '今早血压 138/85', expect: { intent: HEALTH_RULE_INTENTS.BLOOD_PRESSURE, redirect: true } }, { persona: '中年女性', age: '42', text: '高压132低压84', expect: { intent: HEALTH_RULE_INTENTS.BLOOD_PRESSURE } }, { persona: '年轻女性', age: '25', text: '刚测完 128/80 正常吗', expect: { intent: HEALTH_RULE_INTENTS.BLOOD_PRESSURE, turnHandled: true, replyIncludes: '确认' } }, // 其它指标 { persona: '健身男', age: '30', text: '心率 72', expect: { intent: HEALTH_RULE_INTENTS.HEART_RATE, redirect: true } }, { persona: '宝妈', age: '35', text: '体重 58.5', expect: { intent: HEALTH_RULE_INTENTS.WEIGHT, redirect: true } }, { persona: '学生', age: '20', text: '血氧 98', expect: { intent: HEALTH_RULE_INTENTS.SPO2, redirect: true } }, { persona: '老人', age: '70', text: '体温 36.8', expect: { intent: HEALTH_RULE_INTENTS.TEMPERATURE, redirect: true } }, { persona: '上班族', age: '32', text: '昨晚睡了6小时', expect: { intent: HEALTH_RULE_INTENTS.SLEEP, redirect: true } }, { persona: '老年女性', age: '68', text: '最近老头晕', expect: { intent: HEALTH_RULE_INTENTS.SYMPTOM, redirect: true } }, // 评估 / 报告 / 档案 { persona: '中年女性', age: '45', text: '最近身体怎么样', expect: { intent: HEALTH_RULE_INTENTS.ASSESS, redirect: true } }, { persona: '年轻男', age: '26', text: '帮我看看血压趋势', expect: { intent: HEALTH_RULE_INTENTS.ASSESS, h5Fallthrough: true } }, { persona: '退休教师', age: '60', text: '帮我生成一份健康报告', expect: { reportPage: true, h5Fallthrough: true } }, { persona: '退休大爷', age: '72', text: '血压一百三十七比八十二', expect: { intent: HEALTH_RULE_INTENTS.BLOOD_PRESSURE, turnHandled: true, replyIncludes: '确认' } }, { persona: '老年女性', age: '68', text: '高压一百三十五低压八十', expect: { intent: HEALTH_RULE_INTENTS.BLOOD_PRESSURE } }, { persona: '外企女', age: '38', text: '整一份健康分析呗', expect: { reportPage: false, quickAssess: true } }, { persona: '中年女', age: '40', text: '整一份健康分析报告', expect: { reportPage: true } }, { persona: '通用', age: '-', text: '看档案', expect: { turnHandled: true, replyIncludes: '档案' } }, { persona: '通用', age: '-', text: '菜单', expect: { turnHandled: true, replyIncludes: '专用通道' } }, { persona: '大爷', age: '72', text: '我的历史报告在哪', expect: { intent: HEALTH_RULE_INTENTS.ARCHIVE, h5Fallthrough: true } }, // 自由对话(H5 应交 Agent) { persona: '中年男', age: '48', text: '这半个月血压忽高忽低,该注意啥', expect: { h5Fallthrough: true } }, { persona: '年轻女', age: '22', text: '宝子帮我分析一下最近睡得不好', expect: { h5Fallthrough: true } }, { persona: '爷爷', age: '75', text: '我吃的降压药还要不要继续', expect: { h5Fallthrough: true } }, { persona: '程序员', age: '29', text: 'health report pls', expect: { reportPage: true, h5Fallthrough: true } }, // 不应误触发 { persona: '路人', age: '-', text: '今天天气怎么样', expect: { intent: null, redirect: false, h5Fallthrough: true } }, { persona: '路人', age: '-', text: '帮我写一份工作报告', expect: { reportPage: false, h5Fallthrough: true } }, ]; const AGENT_SAMPLES = [ { persona: '退休大爷', text: '我这几天的血压记录帮我捋一捋,有没有要注意的' }, { persona: '年轻妈妈', text: '能不能根据我的档案说说最近睡眠和血压的关系' }, { persona: '中年男性', text: '给我一份健康报告,重点看血压' }, ]; function evaluateRow(row) { const text = row.text; const rule = matchHealthIdleRules(text); const issues = []; if (row.expect.enter != null && isHealthEnterText(text) !== row.expect.enter) { issues.push(`enter 期望 ${row.expect.enter} 实际 ${isHealthEnterText(text)}`); } if (row.expect.intent === null) { if (rule.matched) issues.push(`不应匹配规则,实际 ${rule.intent}`); } else if (row.expect.intent) { if (rule.intent !== row.expect.intent) { issues.push(`intent 期望 ${row.expect.intent} 实际 ${rule.intent ?? 'none'}`); } } if (row.expect.redirect != null) { const redirect = shouldRedirectOrdinaryChatToHealth(text); if (redirect !== row.expect.redirect) { issues.push(`redirect 期望 ${row.expect.redirect} 实际 ${redirect}`); } } if (row.expect.reportPage != null) { const hit = looksLikeHealthReportPageRequest(text); if (hit !== row.expect.reportPage) { issues.push(`reportPage 期望 ${row.expect.reportPage} 实际 ${hit}`); } } if (row.expect.quickAssess != null) { const hit = looksLikeHealthQuickAssess(text); if (hit !== row.expect.quickAssess) { issues.push(`quickAssess 期望 ${row.expect.quickAssess} 实际 ${hit}`); } } const turn = applyHealthChannelTurn({ channel: 'h5', session: idleSession, text, }); if (row.expect.turnHandled != null && turn.handled !== row.expect.turnHandled) { issues.push(`turnHandled 期望 ${row.expect.turnHandled} 实际 ${turn.handled}`); } if (row.expect.replyIncludes && turn.reply && !turn.reply.includes(row.expect.replyIncludes)) { issues.push(`reply 未含「${row.expect.replyIncludes}」`); } if (row.expect.h5Fallthrough != null) { const fallthrough = turn.handled === false; if (fallthrough !== row.expect.h5Fallthrough) { issues.push(`h5Fallthrough 期望 ${row.expect.h5Fallthrough} 实际 ${fallthrough} (reply=${(turn.reply ?? '').slice(0, 40)})`); } } if (row.expect.noSessionMenu) { const entered = applyHealthChannelTurn({ channel: 'h5', session: null, text: row.text }); if (!entered.handled || !entered.reply?.includes('专用通道')) { issues.push('无 session 进入未展示菜单'); } } return issues; } async function testJohnApis(reporter, cookie, userId) { const headers = { Cookie: cookie }; const status = await fetch(`${base}/auth/status`, { headers }); const statusBody = await status.json(); if (statusBody.health?.enabled) reporter.pass('API health.enabled'); else reporter.fail('API health.enabled', JSON.stringify(statusBody.health)); if ((statusBody.grantedSkills ?? []).includes('health-assistant')) { reporter.pass('API health-assistant granted'); } else reporter.fail('API health-assistant granted'); const ctx = await fetch(`${base}/api/health/agent-context`, { headers }); const ctxBody = await ctx.json(); if (ctx.ok && ctxBody.context?.includes('健康档案')) { reporter.pass('API agent-context', `${ctxBody.context.length} chars`); } else reporter.fail('API agent-context', `${ctx.status}`); const assess = await fetch(`${base}/api/health/assess-summary`, { headers }); if (assess.ok) reporter.pass('API assess-summary'); else reporter.fail('API assess-summary', `${assess.status}`); const timeline = await fetch(`${base}/api/health/timeline?days=14`, { headers }); if (timeline.ok) reporter.pass('API timeline'); else reporter.fail('API timeline', `${timeline.status}`); const report = await fetch(`${base}/api/health/report-page`, { method: 'POST', headers }); const reportBody = await report.json(); if (report.ok && reportBody.url && reportBody.relativePath) { const exists = healthReportPageExists( resolveHealthMaterializeH5Root(process.cwd(), process.env), userId, reportBody.relativePath, ); if (exists) reporter.pass('API report-page 落盘', reportBody.relativePath); else reporter.fail('API report-page 落盘', '文件不存在'); const pageRes = await fetch(reportBody.url, { headers, redirect: 'follow' }); if (pageRes.ok) { reporter.pass('API report-page URL 可访问', `${pageRes.status}`); } else { reporter.fail('API report-page URL 可访问', `${pageRes.status} ${reportBody.url}`); } } else { reporter.fail('API report-page', `${report.status} ${JSON.stringify(reportBody).slice(0, 120)}`); } } async function testAgentSamples(reporter, cookie) { const ctxRes = await fetch(`${base}/api/health/agent-context`, { headers: { Cookie: cookie }, }); const context = (await ctxRes.json()).context ?? ''; for (const sample of AGENT_SAMPLES) { const outgoing = wrapHealthAgentUserMessage(context, sample.text); try { const run = await createAgentRun(base, cookie, { message: outgoing, selectedChatSkill: 'health-assistant', }); const terminal = await waitForRunTerminal(base, cookie, run.runId, 120000); if (terminal.status !== 'succeeded') { reporter.fail(`Agent[${sample.persona}]`, terminal.status); continue; } const sessionId = terminal.sessionId ?? run.sessionId; const growth = await waitForAssistantGrowth(base, cookie, sessionId, { minChars: 40, timeoutMs: 8000, }); let reply = growth?.combined ?? ''; if (reply.length < 40 && sessionId) { const session = await getSession(base, cookie, sessionId); reply = extractAssistantTexts(session.session ?? session).join('\n'); } const badMenu = reply.includes('请回复数字') && reply.includes('专用通道'); if (badMenu) { reporter.fail(`Agent[${sample.persona}]`, '仍返回菜单'); } else if (reply.length >= 40) { reporter.pass(`Agent[${sample.persona}]`, `${reply.length} 字`); } else { reporter.fail(`Agent[${sample.persona}]`, reply.slice(0, 100) || '(空)'); } } catch (error) { reporter.fail(`Agent[${sample.persona}]`, error.message); } } } async function main() { const reporter = createReporter(); let failed = 0; console.log('\n=== 话术矩阵(规则 + H5 状态机)===\n'); for (const row of MATRIX) { const issues = evaluateRow(row); const label = `[${row.persona}/${row.age}] ${row.text}`; if (issues.length === 0) { reporter.pass(label); } else { failed += 1; reporter.fail(label, issues.join('; ')); } } console.log('\n=== john 账号 API ===\n'); try { const { cookie, user } = await loginViaApi(base, { username: 'john', password }, reporter); await testJohnApis(reporter, cookie, user?.id); if (runAgentSamples) { console.log('\n=== Agent 抽检(3 persona)===\n'); await testAgentSamples(reporter, cookie); } else { console.log('\n(跳过 Agent 抽检,加 --agent 可开启)\n'); } } catch (error) { reporter.fail('登录/API', error.message); } console.log(`\n矩阵失败: ${failed}/${MATRIX.length}`); process.exit(reporter.summary()); } main();