Files
memind/health-wechat-turn.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

417 lines
14 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.
import {
HEALTH_ACTIONS,
HEALTH_CHANNEL_STEPS,
createIdleHealthChannelState,
reduceHealthChannel,
} from './health-channel-state.mjs';
import { isHealthEnterText, matchHealthIdleRules } from './health-intent-rules.mjs';
import { matchHealthMenuAlias } from './health-channel-aliases.mjs';
import { inferBloodPressureContext, validateBloodPressurePair } from './health-observation-validate.mjs';
import { isMemindHealthEnabled } from './health-feature.mjs';
import {
buildPendingValuesFromParsed,
formatCommittedReply,
formatGenericConfirmCard,
metricValuePrompt,
parseMetricInput,
} from './health-metric-input.mjs';
export const HEALTH_WECHAT_EVENT_KEY = 'MEMIND_HEALTH';
export const HEALTH_CHANNEL_TTL_MS = 30 * 60 * 1000;
export const HEALTH_MENU_TEXT = [
'已进入【健康助手】专用通道',
'本通道只处理健康相关内容,数据确认后才会写入你的健康档案。',
'',
'请回复数字:',
'1 健康录入(血压/心率/体重/血氧/体温/睡眠/症状)',
'2 上传报告(请发送照片,P0 先归档说明,数值请手输确认)',
'3 健康评估(基于档案的文字摘要)',
'4 查看健康档案',
'0 退出健康通道',
].join('\n');
export { isHealthEnterText } from './health-intent-rules.mjs';
export function isHealthMenuEvent(inbound = {}) {
const event = String(inbound.event ?? inbound.Event ?? '').toLowerCase();
const eventKey = String(inbound.eventKey ?? inbound.EventKey ?? '').trim();
return event === 'click' && eventKey === HEALTH_WECHAT_EVENT_KEY;
}
export function shouldFallThroughWechatHealthMenuEvent(inbound, env = process.env) {
return isMemindHealthEnabled(env) && isHealthMenuEvent(inbound);
}
export function formatConfirmCard({ systolic, diastolic, pulse = null, context = 'other' }) {
const lines = [
`已识别 → 收缩压 ${systolic},舒张压 ${diastolic}${pulse ? `,脉搏 ${pulse}` : ''}`,
`情境:${context === 'morning' ? '晨间' : context === 'evening' ? '晚间' : '其他'}`,
'回复「1」确认保存 | 「2」修改 | 「0」放弃',
];
return lines.join('\n');
}
export function applyHealthChannelTurn({
channel = 'wechat',
session = null,
text = '',
msgType = 'text',
eventKey = '',
forceEnter = false,
now = Date.now(),
observedAt = null,
} = {}) {
const trimmed = String(text ?? '').trim();
const enter = forceEnter || eventKey === HEALTH_WECHAT_EVENT_KEY || isHealthEnterText(trimmed);
let wrapper = session;
if (!wrapper && !enter && msgType !== 'image') {
return { handled: false };
}
if (!wrapper && enter) {
return {
handled: true,
session: {
channelState: createIdleHealthChannelState({ channel }),
pendingValues: null,
},
reply: HEALTH_MENU_TEXT,
commit: null,
};
}
if (!wrapper) {
return { handled: false };
}
if (trimmed === '0' || trimmed === '退出') {
return {
handled: true,
session: null,
reply: '已退出健康通道。之后的消息会回到普通聊天。',
commit: null,
};
}
let channelState = wrapper.channelState ?? createIdleHealthChannelState({ channel: 'wechat' });
let pendingValues = wrapper.pendingValues ?? null;
if (msgType === 'image') {
if (channelState.action === HEALTH_ACTIONS.DOCUMENT) {
return {
handled: true,
session: {
channelState: createIdleHealthChannelState({ channel }),
pendingValues: null,
},
reply: '已收到报告照片。P0 会归档原图到健康区;指标抽取与 OCR 将在后续版本开放。回复「1」继续录入,或「0」退出。',
commit: { type: 'document', confirmed: true, source: channel === 'h5' ? 'manual' : 'wechat' },
};
}
if (channelState.step === HEALTH_CHANNEL_STEPS.AWAIT_VALUE) {
return {
handled: true,
session: { channelState, pendingValues },
reply: '已收到照片。P0 请回复数值(如 137/82)并确认后再保存,避免识别漂移。',
commit: null,
};
}
return {
handled: true,
session: { channelState, pendingValues },
reply: '请先回复「1」选择健康录入,再发送数值。照片不会直接入库。',
commit: null,
};
}
if (channelState.step === HEALTH_CHANNEL_STEPS.IDLE) {
const menuAlias = matchHealthMenuAlias(trimmed);
if (menuAlias === 'menu') {
return {
handled: true,
session: { channelState, pendingValues },
reply: HEALTH_MENU_TEXT,
commit: null,
};
}
if (menuAlias && menuAlias !== 'menu') {
return applyHealthChannelTurn({
channel,
session,
text: menuAlias,
msgType,
eventKey,
now,
observedAt,
});
}
if (trimmed === '1') {
const reduced = reduceHealthChannel(channelState, {
type: 'select_action',
action: HEALTH_ACTIONS.RECORD,
});
return {
handled: true,
session: { channelState: reduced.state, pendingValues: null },
reply: '请选择要录入的指标:\n1 血压 2 心率 3 体重 4 血氧\n5 体温 6 睡眠 7 症状 8 用药',
commit: null,
};
}
if (trimmed === '2') {
const reduced = reduceHealthChannel(channelState, {
type: 'select_action',
action: HEALTH_ACTIONS.DOCUMENT,
});
return {
handled: true,
session: { channelState: reduced.state, pendingValues: null },
reply: '请发送报告照片(体检单/化验单/影像报告/PDF 截图)。收到后会归档到健康区;P0 暂不自动抽取指标,关键数值请手输确认。',
commit: null,
};
}
if (trimmed === '3' && channel !== 'h5') {
return {
handled: true,
session: { channelState, pendingValues },
reply: '健康评估会在个人基线成熟后开启。请先按规程连续记录,下次进入时优先展示未读提醒。',
commit: null,
};
}
if (trimmed === '4') {
return {
handled: true,
session: { channelState, pendingValues },
reply: '请到网页「健康档案区」查看加密档案。本人登录免密,外部访问必须口令。',
commit: null,
};
}
const rule = matchHealthIdleRules(trimmed);
if (rule.matched && rule.intent === 'blood_pressure' && rule.extracted?.systolic && rule.extracted?.diastolic) {
const pair = validateBloodPressurePair(rule.extracted.systolic, rule.extracted.diastolic);
if (!pair.ok) {
return {
handled: true,
session: { channelState, pendingValues },
reply: '数值无法确认(超量程或收缩压不大于舒张压)。请重新发送,如 137/82。',
commit: null,
};
}
const context = inferBloodPressureContext(observedAt ?? now);
const confirmState = reduceHealthChannel(
{
...createIdleHealthChannelState({ channel }),
step: HEALTH_CHANNEL_STEPS.AWAIT_VALUE,
metricSet: 'blood_pressure',
context,
},
{ type: 'submit_value', draftId: `bp-${now}` },
);
return {
handled: true,
session: {
channelState: confirmState.state,
pendingValues: {
metricSet: 'blood_pressure',
systolic: pair.systolic,
diastolic: pair.diastolic,
context,
observedAt: observedAt ?? now,
},
},
reply: formatConfirmCard({
systolic: pair.systolic,
diastolic: pair.diastolic,
context,
}),
commit: null,
};
}
// H5 健康通道:idle 自由文本交给 health-assistant Agent(已注入档案摘要)
if (channel === 'h5' && trimmed) {
return { handled: false };
}
return {
handled: true,
session: { channelState, pendingValues },
reply: HEALTH_MENU_TEXT,
commit: null,
};
}
if (channelState.step === HEALTH_CHANNEL_STEPS.AWAIT_METRIC_TYPE) {
if (trimmed === '8') {
return {
handled: true,
session: { channelState: createIdleHealthChannelState({ channel }), pendingValues: null },
reply: '用药记录将在 P3 与日程助手整合。P0 请先用手输录入其它指标,或到 MindSpace 健康区查看档案。',
commit: null,
};
}
const reduced = reduceHealthChannel(channelState, { type: 'choose', choice: Number(trimmed) });
if (reduced.actions[0]?.type === 'prompt_context') {
return {
handled: true,
session: { channelState: reduced.state, pendingValues: null },
reply: '请选择测量情境:1 晨间 2 晚间 3 其他(可直接发送 137/82',
commit: null,
};
}
if (reduced.actions[0]?.type === 'prompt_value') {
const metricSet = reduced.state.metricSet;
return {
handled: true,
session: { channelState: reduced.state, pendingValues: null },
reply: metricValuePrompt(metricSet),
commit: null,
};
}
return {
handled: true,
session: { channelState, pendingValues },
reply: '请回复 1-8 选择指标。',
commit: null,
};
}
if (channelState.step === HEALTH_CHANNEL_STEPS.AWAIT_CONTEXT) {
const context = trimmed === '1' ? 'morning' : trimmed === '2' ? 'evening' : 'other';
const reduced = reduceHealthChannel(channelState, { type: 'pick_context', context });
return {
handled: true,
session: { channelState: reduced.state, pendingValues: { context } },
reply: '请发送血压数值,如 137/82。',
commit: null,
};
}
if (channelState.step === HEALTH_CHANNEL_STEPS.AWAIT_VALUE) {
const metricSet = channelState.metricSet ?? 'blood_pressure';
if (metricSet === 'blood_pressure') {
const rule = matchHealthIdleRules(trimmed);
if (rule.intent === 'blood_pressure' && rule.extracted?.systolic) {
const pair = validateBloodPressurePair(rule.extracted.systolic, rule.extracted.diastolic);
if (!pair.ok) {
return {
handled: true,
session: { channelState, pendingValues },
reply: '数值无法确认,请按 137/82 格式重发。',
commit: null,
};
}
const context = pendingValues?.context
|| channelState.context
|| inferBloodPressureContext(observedAt ?? now);
const reduced = reduceHealthChannel(channelState, {
type: 'submit_value',
draftId: `bp-${now}`,
});
const pending = buildPendingValuesFromParsed(
{ metricSet: 'blood_pressure', systolic: pair.systolic, diastolic: pair.diastolic, label: `${pair.systolic}/${pair.diastolic}` },
{ context, observedAt: observedAt ?? now },
);
return {
handled: true,
session: {
channelState: reduced.state,
pendingValues: pending,
},
reply: formatConfirmCard({
systolic: pair.systolic,
diastolic: pair.diastolic,
context,
}),
commit: null,
};
}
} else {
const parsed = parseMetricInput(metricSet, trimmed);
if (parsed.ok) {
const reduced = reduceHealthChannel(channelState, {
type: 'submit_value',
draftId: `${metricSet}-${now}`,
});
const pending = buildPendingValuesFromParsed(parsed, { observedAt: observedAt ?? now });
return {
handled: true,
session: {
channelState: reduced.state,
pendingValues: pending,
},
reply: formatGenericConfirmCard({
metricSet,
label: parsed.label,
context: pending.context,
}),
commit: null,
};
}
if (parsed.error === 'out_of_range' || parsed.error === 'not_numeric' || parsed.error === 'unknown_symptom') {
return {
handled: true,
session: { channelState, pendingValues },
reply: `无法确认该数值。${metricValuePrompt(metricSet)}`,
commit: null,
};
}
}
const unexpected = reduceHealthChannel(channelState, { type: 'unexpected', text: trimmed });
return {
handled: true,
session: { channelState: unexpected.state, pendingValues },
reply: unexpected.actions[0]?.prompt || metricValuePrompt(metricSet),
commit: null,
};
}
if (channelState.step === HEALTH_CHANNEL_STEPS.CONFIRM) {
if (trimmed === '1') {
const reduced = reduceHealthChannel(channelState, { type: 'commit' });
return {
handled: true,
session: {
channelState: createIdleHealthChannelState({ channel }),
pendingValues: null,
},
reply: pendingValues
? formatCommittedReply(pendingValues)
: '已记录。回复「0」退出。',
commit: pendingValues
? { confirmed: true, values: pendingValues, source: channel === 'h5' ? 'manual' : 'wechat' }
: null,
};
}
if (trimmed === '2') {
const reduced = reduceHealthChannel(channelState, { type: 'revise' });
return {
handled: true,
session: { channelState: reduced.state, pendingValues },
reply: '请重新发送数值,如 137/82。',
commit: null,
};
}
const discarded = reduceHealthChannel(channelState, { type: 'discard' });
return {
handled: true,
session: { channelState: discarded.state, pendingValues: null },
reply: '已放弃本次录入。' + HEALTH_MENU_TEXT,
commit: null,
};
}
void now;
return {
handled: true,
session: { channelState, pendingValues },
reply: HEALTH_MENU_TEXT,
commit: null,
};
}
export function applyHealthWechatTurn(options = {}) {
return applyHealthChannelTurn({ ...options, channel: 'wechat' });
}