Files
memind/scripts/verify-health-persona-chat.mjs
T
john 2baf29b3ae feat(health): complete P0 health channel — baseline engine, page-data, MindSpace UI
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>
2026-09-09 18:04:57 +08:00

282 lines
13 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.
#!/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();