import { canEmitHealthEvent, resolveBaselineMaturity } from './health-baseline-maturity.mjs'; import { deviationFromBaseline } from './health-baseline-engine.mjs'; import { isValidObservationForBaseline } from './health-baseline-maturity.mjs'; const ABSOLUTE_RULES = Object.freeze([ { ruleId: 'spo2_low', metricType: 'spo2', op: 'lt', threshold: 90, severity: 'alert' }, { ruleId: 'bp_sys_high', metricType: 'bp_systolic', op: 'gte', threshold: 180, severity: 'alert' }, { ruleId: 'bp_dia_high', metricType: 'bp_diastolic', op: 'gte', threshold: 110, severity: 'alert' }, ]); function compare(value, op, threshold) { const num = Number(value); if (!Number.isFinite(num)) return false; if (op === 'lt') return num < threshold; if (op === 'lte') return num <= threshold; if (op === 'gt') return num > threshold; if (op === 'gte') return num >= threshold; return false; } function dayKey(observedAt) { return new Date(Number(observedAt)).toISOString().slice(0, 10); } export function evaluateAbsoluteThresholdRules(observations = [], { now = Date.now() } = {}) { const events = []; const latestByMetric = new Map(); for (const row of observations) { if (!isValidObservationForBaseline(row) || row.valueNum == null) continue; const prev = latestByMetric.get(row.metricType); if (!prev || Number(row.observedAt) > Number(prev.observedAt)) { latestByMetric.set(row.metricType, row); } } for (const rule of ABSOLUTE_RULES) { const row = latestByMetric.get(rule.metricType); if (!row) continue; if (!compare(row.valueNum, rule.op, rule.threshold)) continue; events.push({ eventType: 'threshold_breach', ruleId: rule.ruleId, severity: rule.severity, metricType: rule.metricType, value: row.valueNum, observedAt: row.observedAt, message: `${rule.metricType} 触发绝对阈值规则 ${rule.ruleId}`, createdAt: now, }); } return events; } export function evaluateMissedMorningBp(observations = [], { now = Date.now(), streakDays = 3 } = {}) { const morningBpDays = new Set( observations .filter((row) => row.metricType === 'bp_systolic' && row.context === 'morning') .map((row) => dayKey(row.observedAt)), ); let miss = 0; for (let i = 0; i < streakDays; i += 1) { const d = new Date(now - i * 24 * 60 * 60 * 1000).toISOString().slice(0, 10); if (!morningBpDays.has(d)) miss += 1; } if (miss < streakDays) return []; return [{ eventType: 'missed_measurement', ruleId: 'morning_bp_streak', severity: 'watch', metricType: 'bp_systolic', message: `连续 ${streakDays} 天缺少晨间血压记录`, createdAt: now, }]; } export function evaluateBaselineDrift(observations = [], baselines = [], { driftPct = 15, minDays = 3, now = Date.now(), } = {}) { const events = []; const baseline30 = baselines.filter((b) => b.windowDays === 30 && b.context === 'morning'); for (const base of baseline30) { if (resolveBaselineMaturity(base.sampleCount) === 'cold') continue; const recent = observations.filter( (row) => row.metricType === base.metricType && row.context === base.context && isValidObservationForBaseline(row) && Number(row.observedAt) >= now - 14 * 24 * 60 * 60 * 1000, ); if (recent.length < minDays) continue; const deviations = recent .map((row) => deviationFromBaseline(row.valueNum, base.mean)) .filter((v) => v != null); const above = deviations.filter((v) => Math.abs(v) >= driftPct).length; if (above < minDays) continue; const severity = base.maturity === 'stable' ? 'watch' : 'info'; if (!canEmitHealthEvent(base.maturity, severity)) continue; events.push({ eventType: 'baseline_drift', ruleId: `${base.metricType}_drift_30d`, severity, metricType: base.metricType, baselineMean: base.mean, message: `${base.metricType} 近 14 天较 30 天基线偏离超过 ${driftPct}%`, createdAt: now, }); } return events; } export function evaluateSymptomCluster(observations = [], { streakDays = 3, now = Date.now() } = {}) { const symptomRows = observations .filter((row) => row.metricType === 'symptom' && row.valueText && row.valueText !== 'none') .sort((a, b) => Number(b.observedAt) - Number(a.observedAt)); const byCode = new Map(); for (const row of symptomRows) { const code = String(row.valueText).split(':')[0]; if (!byCode.has(code)) byCode.set(code, []); byCode.get(code).push(dayKey(row.observedAt)); } const events = []; for (const [code, days] of byCode.entries()) { const uniqueDays = [...new Set(days)].slice(0, streakDays); if (uniqueDays.length < streakDays) continue; events.push({ eventType: 'symptom_cluster', ruleId: 'symptom_streak', severity: 'watch', metricType: 'symptom', symptomCode: code, message: `症状 ${code} 在最近 ${streakDays} 天重复出现`, createdAt: now, }); } return events; } export function evaluateHealthEvents(observations = [], baselines = [], options = {}) { const mode = options.mode ?? 'active'; const events = [ ...evaluateAbsoluteThresholdRules(observations, options), ...evaluateMissedMorningBp(observations, options), ...evaluateBaselineDrift(observations, baselines, options), ...evaluateSymptomCluster(observations, options), ]; if (mode === 'shadow') { return events.map((event) => ({ ...event, shadow: true })); } return events; }