Files
memind/health-metric-input.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

191 lines
6.7 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 { validateMetricValue } from './health-observation-validate.mjs';
import { assertSymptomRecord, HEALTH_SYMPTOM_LABELS, normalizeSymptomCode } from './health-symptoms.mjs';
const METRIC_SET_LABELS = Object.freeze({
blood_pressure: '血压',
heart_rate: '心率',
weight: '体重',
spo2: '血氧',
temperature: '体温',
sleep: '睡眠',
symptom: '症状',
});
const METRIC_VALUE_PATTERNS = Object.freeze({
heart_rate: /(?:心率|脉搏)?\s*(\d{2,3})\b/,
weight: /(?:体重)?\s*(\d{2,3}(?:\.\d)?)\s*(?:kg|公斤|斤)?/i,
spo2: /(?:血氧|SpO2|spo2)?\s*(\d{2,3})\s*%?/i,
temperature: /(?:体温)?\s*(3\d(?:\.\d)?)\s*(?:度|℃|°C)?/,
sleep: /(?:睡了|睡眠)?\s*(\d+(?:\.\d)?)\s*(?:小时|h|H)/,
});
const METRIC_TYPE_BY_SET = Object.freeze({
heart_rate: 'hr',
weight: 'weight',
spo2: 'spo2',
temperature: 'temperature',
sleep: 'sleep_minutes',
});
export function metricSetLabel(metricSet) {
return METRIC_SET_LABELS[metricSet] ?? metricSet;
}
export function metricValuePrompt(metricSet) {
switch (metricSet) {
case 'blood_pressure':
return '请发送血压数值,如 137/82。';
case 'heart_rate':
return '请发送心率(次/分),如 72 或「心率 72」。';
case 'weight':
return '请发送体重(kg),如 65.5 或「体重 65.5」。';
case 'spo2':
return '请发送血氧(%),如 98。';
case 'temperature':
return '请发送体温(℃),如 36.5。';
case 'sleep':
return '请发送睡眠时长,如「睡了7小时」。';
case 'symptom':
return '请描述症状与严重度,如「头晕 2」(1轻 2中 3重)。';
default:
return '请发送数值,或回复「取消」。';
}
}
export function parseSymptomInput(text) {
const raw = String(text ?? '').trim();
if (!raw) return { ok: false, error: 'empty' };
const severityMatch = raw.match(/(\d)\s*$/);
const severity = severityMatch ? Number(severityMatch[1]) : 2;
const namePart = severityMatch ? raw.slice(0, severityMatch.index).trim() : raw;
for (const [code, label] of Object.entries(HEALTH_SYMPTOM_LABELS)) {
if (code === 'none' || code === 'other') continue;
if (namePart.includes(label) || raw.includes(label)) {
return assertSymptomRecord({ code, severity });
}
}
const normalized = normalizeSymptomCode(namePart);
if (normalized && normalized !== 'none') {
return assertSymptomRecord({ code: normalized, severity });
}
if (/头晕/.test(raw)) return assertSymptomRecord({ code: 'dizziness', severity });
if (/头痛/.test(raw)) return assertSymptomRecord({ code: 'headache', severity });
if (/胸闷/.test(raw)) return assertSymptomRecord({ code: 'chest_tightness', severity });
if (/心慌|心悸/.test(raw)) return assertSymptomRecord({ code: 'palpitation', severity });
if (/乏力/.test(raw)) return assertSymptomRecord({ code: 'fatigue', severity });
if (/气短/.test(raw)) return assertSymptomRecord({ code: 'shortness_of_breath', severity });
return { ok: false, error: 'unknown_symptom' };
}
export function parseMetricInput(metricSet, text) {
const raw = String(text ?? '').trim();
if (!raw) return { ok: false, error: 'empty' };
if (metricSet === 'symptom') {
const symptom = parseSymptomInput(raw);
if (!symptom.ok) return symptom;
return {
ok: true,
metricSet: 'symptom',
symptomCode: symptom.code,
severity: symptom.severity,
label: HEALTH_SYMPTOM_LABELS[symptom.code] ?? symptom.code,
};
}
if (metricSet === 'sleep') {
const match = raw.match(METRIC_VALUE_PATTERNS.sleep) ?? raw.match(/^(\d+(?:\.\d)?)$/);
const hours = match ? Number(match[1]) : NaN;
if (!Number.isFinite(hours)) return { ok: false, error: 'not_numeric' };
const minutes = Math.round(hours * 60);
const checked = validateMetricValue('sleep_minutes', minutes);
if (!checked.ok) return checked;
return {
ok: true,
metricSet: 'sleep',
valueNum: checked.value,
unit: checked.unit,
label: `${hours} 小时`,
};
}
const pattern = METRIC_VALUE_PATTERNS[metricSet];
const metricType = METRIC_TYPE_BY_SET[metricSet];
if (!pattern || !metricType) return { ok: false, error: 'unknown_metric_set' };
const match = raw.match(pattern) ?? raw.match(/^(\d+(?:\.\d)?)$/);
const value = match ? Number(match[1]) : NaN;
if (!Number.isFinite(value)) return { ok: false, error: 'not_numeric' };
const checked = validateMetricValue(metricType, value);
if (!checked.ok) return checked;
return {
ok: true,
metricSet,
valueNum: checked.value,
unit: checked.unit,
label: `${checked.value}${checked.unit ? ` ${checked.unit}` : ''}`,
};
}
export function formatGenericConfirmCard({ metricSet, label, context = 'other' }) {
const contextLabel = context === 'morning' ? '晨间' : context === 'evening' ? '晚间' : '其他';
const name = metricSetLabel(metricSet);
return [
`已识别 → ${name} ${label}`,
metricSet === 'blood_pressure' ? `情境:${contextLabel}` : '',
'回复「1」确认保存 | 「2」修改 | 「0」放弃',
]
.filter(Boolean)
.join('\n');
}
export function formatCommittedReply(pendingValues) {
if (!pendingValues) return '已记录。回复「0」退出。';
if (pendingValues.metricSet === 'blood_pressure') {
return `已记录。收缩压 ${pendingValues.systolic} / 舒张压 ${pendingValues.diastolic}。这不是医疗诊断。回复「1」继续录入,或「0」退出。`;
}
if (pendingValues.metricSet === 'symptom') {
const label = HEALTH_SYMPTOM_LABELS[pendingValues.symptomCode] ?? pendingValues.symptomCode;
return `已记录症状:${label}(严重度 ${pendingValues.severity})。这不是医疗诊断。回复「1」继续录入,或「0」退出。`;
}
const name = metricSetLabel(pendingValues.metricSet);
const value = pendingValues.displayLabel ?? pendingValues.valueNum;
return `已记录${name}${value}。这不是医疗诊断。回复「1」继续录入,或「0」退出。`;
}
export function buildPendingValuesFromParsed(parsed, { context = 'other', observedAt = Date.now() } = {}) {
if (parsed.metricSet === 'blood_pressure') {
return {
metricSet: 'blood_pressure',
systolic: parsed.systolic,
diastolic: parsed.diastolic,
context,
observedAt,
};
}
if (parsed.metricSet === 'symptom') {
return {
metricSet: 'symptom',
symptomCode: parsed.symptomCode,
severity: parsed.severity,
context: 'other',
observedAt,
displayLabel: parsed.label,
};
}
return {
metricSet: parsed.metricSet,
valueNum: parsed.valueNum,
context: parsed.metricSet === 'blood_pressure' ? context : 'any',
observedAt,
displayLabel: parsed.label,
};
}