2baf29b3ae
Deliver encrypted health zone, observation API, baseline maturity pipeline, page-data bindings, and H5/WeChat channel integration for health P0. Co-authored-by: Cursor <cursoragent@cursor.com>
282 lines
13 KiB
JavaScript
282 lines
13 KiB
JavaScript
#!/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<string, unknown> }>} */
|
||
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();
|