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' }); }