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>
152 lines
5.4 KiB
JavaScript
152 lines
5.4 KiB
JavaScript
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;
|
|
}
|