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>
This commit is contained in:
john
2026-09-09 18:04:22 +08:00
parent 7bf16500a1
commit 2baf29b3ae
94 changed files with 7283 additions and 194 deletions
+8 -2
View File
@@ -164,7 +164,7 @@ try {
console.log(`==> memind_adm 就绪: ${adminUrl}`);
console.log(`==> 启动 Ops 后台 @ http://127.0.0.1:${opsPort}/ops/`);
ops = spawnChild('npm', ['run', 'dev'], 'ops', opsDir, opsEnv);
ops = spawnChild('npm', ['run', 'dev', '--', '--port', String(opsPort)], 'ops', opsDir, opsEnv);
await waitFor(
`http://127.0.0.1:${opsPort}`,
async (url) => (await fetch(`${url}/ops/`)).ok,
@@ -174,7 +174,13 @@ try {
console.log(`==> Ops 就绪: http://127.0.0.1:${opsPort}/ops/`);
console.log(`==> 启动 Vite @ http://127.0.0.1:${vitePort}`);
vite = spawnChild('npx', ['vite'], 'vite', root, viteEnv);
vite = spawnChild(
'npx',
['vite', '--port', String(vitePort), '--strictPort'],
'vite',
root,
viteEnv,
);
await waitFor(
`http://127.0.0.1:${vitePort}`,
async (url) => {
+19
View File
@@ -0,0 +1,19 @@
#!/usr/bin/env bash
# Health P0 isolated dev stack — never binds 8081/5173 (Memind main).
set -euo pipefail
root="$(cd "$(dirname "$0")/.." && pwd)"
cd "$root"
export MEMIND_HEALTH_ENABLED=1
export MEMIND_HEALTH_PAGE_DATA=0
export MEMIND_DEEPSEEK_DISABLE_THINKING=0
export MINDSPACE_SERVER_ADAPTER=local
export H5_PORT=8087
export VITE_PORT=5175
export ADMIN_PORT=8088
export OPS_PORT=3003
export MEMIND_DEEPSEEK_NO_THINK_PORT=18037
export H5_PUBLIC_BASE_URL=http://127.0.0.1:8087
export H5_DEV_PORTAL=http://127.0.0.1:8087
exec node scripts/dev-core.mjs
+281
View File
@@ -0,0 +1,281 @@
#!/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();