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>
191 lines
6.7 KiB
JavaScript
191 lines
6.7 KiB
JavaScript
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,
|
||
};
|
||
}
|