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>
417 lines
14 KiB
JavaScript
417 lines
14 KiB
JavaScript
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' });
|
||
}
|