feature: Explainable Alerts

This commit is contained in:
voltsrage
2026-06-25 00:25:31 +08:00
parent 279add1e45
commit 666d683d67
61 changed files with 9553 additions and 125 deletions
@@ -29,6 +29,20 @@ describe('AlertCard', () => {
expect(wrapper.text()).toContain('Sepsis Warning (SIRS — Legacy)')
})
it('showsNarrativeSummaryWhenExplanationPresent', () => {
const wrapper = mount(AlertCard, {
props: {
alert: {
...openAlert,
explanation: {
narrativeSummary: 'NEWS2 8 — respiratory rate (+3), SpO2 (+2).',
},
},
},
})
expect(wrapper.text()).toContain('NEWS2 8 — respiratory rate (+3), SpO2 (+2).')
})
it('acknowledgeButtonEmitsEvent', async () => {
const wrapper = mount(AlertCard, { props: { alert: openAlert } })
const ackButton = wrapper.findAll('button').find(b => b.text() === 'Acknowledge')
@@ -50,4 +50,41 @@ describe('AlertReasoning', () => {
expect(wrapper.text()).toContain('CustomUnknown')
expect(wrapper.text()).toContain('Something unusual happened')
})
it('showsStructuredExplanationWhenPresent', () => {
const news2Alert = {
id: 'alert-4',
alertType: 'News2Emergency',
severity: 'Critical',
details: 'NEWS2 score 8 (HIGH): RESP_RATE=3, SPO2=2',
triggeredAt: '2026-06-19T14:00:00Z',
explanation: {
narrativeSummary: 'NEWS2 8 — respiratory rate (+3), SpO2 (+2).',
scoreContributors: [
{ parameter: 'Respiratory Rate', points: 3, rawValue: '28', normalRange: '12-20' },
{ parameter: 'SpO2', points: 2, rawValue: '92%', normalRange: '96-100%' },
],
trend: {
parameter: 'Respiratory Rate',
percentChange: 37,
duration: '02:00:00',
direction: 'Increasing',
},
medicationContext: {
drugName: 'acetaminophen',
dose: '1000mg',
route: 'PO',
administeredAt: '2026-06-19T13:30:00Z',
relevanceNote: 'Antipyretic — may affect temperature trend',
},
},
}
const wrapper = mount(AlertReasoning, { props: { alert: news2Alert } })
expect(wrapper.text()).toContain('NEWS2 8 — respiratory rate (+3), SpO2 (+2).')
expect(wrapper.text()).toContain('Respiratory Rate')
expect(wrapper.text()).toContain('Correlated medication')
expect(wrapper.text()).toContain('acetaminophen')
expect(wrapper.text()).toContain('increasing 37% over 2 hours')
})
})
@@ -22,11 +22,14 @@ describe('CriticalAlertBanner', () => {
severity: 'Critical',
status: 'Open',
details: 'SOFA delta >= 2',
explanation: {
narrativeSummary: 'SOFA 6 — respiratory (+2), renal (+2).',
},
}]
const wrapper = mount(CriticalAlertBanner)
expect(wrapper.text()).toContain('Sepsis Alert (SOFA)')
expect(wrapper.text()).toContain('SOFA delta >= 2')
expect(wrapper.text()).toContain('SOFA 6 — respiratory (+2), renal (+2).')
const dismissButton = wrapper.findAll('button').find(button => button.text() === 'Dismiss')
await dismissButton.trigger('click')
@@ -0,0 +1,59 @@
import { describe, it, expect } from 'vitest'
import {
alertSummaryText,
elevatedContributors,
formatDuration,
formatMedicationContext,
formatTrendLine,
hasExplanation,
} from '@/composables/alertExplanation'
describe('alertExplanation', () => {
it('prefersNarrativeSummaryOverDetails', () => {
const alert = {
details: 'NEWS2 score 8 (HIGH): ...',
explanation: {
narrativeSummary: 'NEWS2 8 — respiratory rate (+3), SpO2 (+2).',
},
}
expect(alertSummaryText(alert)).toBe('NEWS2 8 — respiratory rate (+3), SpO2 (+2).')
})
it('fallsBackToDetailsWhenExplanationMissing', () => {
expect(alertSummaryText({ details: 'Legacy details' })).toBe('Legacy details')
expect(alertSummaryText({})).toBe('')
})
it('filtersElevatedContributors', () => {
const explanation = {
scoreContributors: [
{ parameter: 'Respiratory Rate', points: 3, rawValue: '28', normalRange: '12-20' },
{ parameter: 'Heart Rate', points: 0, rawValue: '72', normalRange: '51-90' },
],
}
expect(elevatedContributors(explanation)).toHaveLength(1)
expect(elevatedContributors(explanation)[0].parameter).toBe('Respiratory Rate')
})
it('formatsTrendAndMedicationContext', () => {
expect(formatDuration('02:00:00')).toBe('2 hours')
expect(formatTrendLine({
parameter: 'Respiratory Rate',
percentChange: 37,
duration: '02:00:00',
direction: 'Increasing',
})).toContain('Respiratory Rate')
expect(formatMedicationContext({
drugName: 'acetaminophen',
dose: '1000mg',
route: 'PO',
administeredAt: '2026-06-22T10:15:00Z',
relevanceNote: 'Antipyretic — may affect temperature trend',
})).toContain('acetaminophen')
})
it('detectsStructuredExplanation', () => {
expect(hasExplanation({ explanation: { narrativeSummary: 'x' } })).toBe(true)
expect(hasExplanation({ details: 'only details' })).toBe(false)
})
})
@@ -6,6 +6,7 @@ import Button from '@/components/ui/Button.vue'
import Badge from '@/components/ui/Badge.vue'
import { useAuthStore } from '@/stores/auth'
import { alertTypeLabel } from '@/api/normalize'
import { alertSummaryText } from '@/composables/alertExplanation'
import {
formatRoleLabel,
previewAcknowledgmentNote,
@@ -51,8 +52,8 @@ function onConfirm() {
{{ alertTypeLabel(alert.alertType) }}
</span>
</div>
<p v-if="alert.details" class="text-sm text-gray-600 dark:text-gray-400">
{{ alert.details }}
<p v-if="alertSummaryText(alert)" class="text-sm text-gray-600 dark:text-gray-400">
{{ alertSummaryText(alert) }}
</p>
<div class="rounded-lg border border-gray-200 bg-gray-50 p-4 dark:border-gray-700 dark:bg-gray-800/50">
@@ -7,6 +7,7 @@ import Button from '@/components/ui/Button.vue'
import FeedbackButtons from '@/components/feedback/FeedbackButtons.vue'
import { alertTypeLabel } from '@/api/normalize'
import { formatAcknowledgedByDisplay } from '@/composables/alertAcknowledge'
import { alertSummaryText } from '@/composables/alertExplanation'
const props = defineProps({
alert: { type: Object, required: true },
@@ -54,8 +55,8 @@ function formatTime(iso) {
<h3 class="mt-2 text-sm font-semibold text-gray-900 dark:text-white">
{{ alertTypeLabel(alert.alertType) }}
</h3>
<p v-if="alert.details" class="mt-2 text-sm text-gray-600 dark:text-gray-400">
{{ alert.details }}
<p v-if="alertSummaryText(alert)" class="mt-2 text-sm text-gray-600 dark:text-gray-400">
{{ alertSummaryText(alert) }}
</p>
<p class="mt-2 text-xs text-gray-500 dark:text-gray-500">
{{ formatTime(alert.triggeredAt) }}
@@ -3,6 +3,13 @@ import { computed } from 'vue'
import Badge from '@/components/ui/Badge.vue'
import Card from '@/components/ui/Card.vue'
import FeedbackButtons from '@/components/feedback/FeedbackButtons.vue'
import {
alertSummaryText,
elevatedContributors,
formatMedicationContext,
formatTrendLine,
hasExplanation,
} from '@/composables/alertExplanation'
const props = defineProps({
alert: { type: Object, required: true },
@@ -68,8 +75,25 @@ const reasoningMap = {
},
}
const structuredExplanation = computed(() => props.alert.explanation ?? null)
const usesStructuredExplanation = computed(() => hasExplanation(props.alert))
const contributors = computed(() => elevatedContributors(structuredExplanation.value))
const trendLine = computed(() => formatTrendLine(structuredExplanation.value?.trend))
const medicationLine = computed(() =>
formatMedicationContext(structuredExplanation.value?.medicationContext),
)
const summaryText = computed(() => {
if (structuredExplanation.value?.narrativeSummary) {
return structuredExplanation.value.narrativeSummary
}
return reasoningMap[props.alert.alertType]?.explain(props.alert)
?? alertSummaryText(props.alert)
?? ''
})
const recentMedications = computed(() => {
if (!props.medications.length || !props.alert.triggeredAt) return []
if (medicationLine.value || !props.medications.length || !props.alert.triggeredAt) return []
const alertTime = new Date(props.alert.triggeredAt).getTime()
return props.medications.filter(m => {
const administeredAt = new Date(m.administeredAt).getTime()
@@ -88,6 +112,11 @@ const actionHint = computed(() => {
return hints[props.alert.alertType] ?? null
})
const showTechnicalDetails = computed(() =>
Boolean(props.alert.details)
&& (!usesStructuredExplanation.value || props.alert.details !== summaryText.value),
)
function extractValue(alert) {
const match = alert.details?.match(/(\d+\.?\d*)/)
return match ? match[1] : '—'
@@ -135,7 +164,38 @@ function formatMed(med) {
</div>
<p class="text-sm text-gray-600 dark:text-gray-400">
{{ reasoningMap[alert.alertType]?.explain(alert) ?? alert.details }}
{{ summaryText }}
</p>
<div
v-if="contributors.length"
class="overflow-x-auto rounded-lg border border-gray-200 dark:border-gray-700"
>
<table class="min-w-full divide-y divide-gray-200 text-sm dark:divide-gray-700">
<thead class="bg-gray-50 dark:bg-gray-800/60">
<tr>
<th class="px-4 py-2 text-left font-medium text-gray-600 dark:text-gray-300">Parameter</th>
<th class="px-4 py-2 text-left font-medium text-gray-600 dark:text-gray-300">Points</th>
<th class="px-4 py-2 text-left font-medium text-gray-600 dark:text-gray-300">Value</th>
<th class="px-4 py-2 text-left font-medium text-gray-600 dark:text-gray-300">Normal</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-100 dark:divide-gray-800">
<tr v-for="(row, index) in contributors" :key="`${row.parameter}-${index}`">
<td class="px-4 py-2 text-gray-900 dark:text-gray-100">{{ row.parameter }}</td>
<td class="px-4 py-2 text-gray-700 dark:text-gray-300">{{ row.points }}</td>
<td class="px-4 py-2 text-gray-700 dark:text-gray-300">{{ row.rawValue ?? '—' }}</td>
<td class="px-4 py-2 text-gray-500 dark:text-gray-400">{{ row.normalRange ?? '—' }}</td>
</tr>
</tbody>
</table>
</div>
<p
v-if="trendLine"
class="rounded bg-sky-50 px-4 py-2 text-sm text-sky-900 dark:bg-sky-950/40 dark:text-sky-100"
>
{{ trendLine }}
</p>
<p
@@ -146,7 +206,15 @@ function formatMed(med) {
</p>
<div
v-if="recentMedications.length"
v-if="medicationLine"
class="rounded bg-amber-50 p-4 text-sm text-amber-900 dark:bg-amber-950/40 dark:text-amber-200"
>
<p class="mb-1 font-medium">Correlated medication</p>
<p>{{ medicationLine }}</p>
</div>
<div
v-else-if="recentMedications.length"
class="rounded bg-amber-50 p-4 text-sm text-amber-900 dark:bg-amber-950/40 dark:text-amber-200"
>
<p class="mb-2 font-medium">Recent medications (within 90 min)</p>
@@ -158,7 +226,7 @@ function formatMed(med) {
</div>
<div
v-if="alert.details"
v-if="showTechnicalDetails"
class="rounded bg-gray-50 p-4 text-xs font-mono text-gray-700 dark:bg-gray-800 dark:text-gray-300"
>
{{ alert.details }}
@@ -177,4 +245,4 @@ function formatMed(med) {
</div>
</div>
</Card>
</template>
</template>
@@ -5,6 +5,7 @@ import { storeToRefs } from 'pinia'
import { useAlertStore } from '@/stores/alerts'
import { useSettingsStore } from '@/stores/settings'
import { alertTypeLabel } from '@/api/normalize'
import { alertSummaryText } from '@/composables/alertExplanation'
import {
formatAlertPatientLabel,
requestNotificationPermission,
@@ -90,7 +91,9 @@ async function enableNotifications() {
<div class="min-w-0">
<p class="font-medium">{{ alertTypeLabel(alert.alertType) }}</p>
<p class="text-sm text-red-100">{{ formatAlertPatientLabel(alert) }}</p>
<p v-if="alert.details" class="mt-1 text-sm text-red-50">{{ alert.details }}</p>
<p v-if="alertSummaryText(alert)" class="mt-1 text-sm text-red-50">
{{ alertSummaryText(alert) }}
</p>
</div>
<Button size="sm" variant="ghost" class="!text-white hover:!bg-red-800" @click="dismissOne(alert.id)">
Dismiss
@@ -10,6 +10,7 @@ import EmptyState from '@/components/ui/EmptyState.vue'
import AcknowledgeModal from '@/components/alerts/AcknowledgeModal.vue'
import { alertTypeLabel } from '@/api/normalize'
import { formatAcknowledgedByDisplay } from '@/composables/alertAcknowledge'
import { alertSummaryText } from '@/composables/alertExplanation'
const props = defineProps({
encounterId: { type: String, required: true },
@@ -76,8 +77,8 @@ async function resolve(alertId) {
{{ alertTypeLabel(alert.alertType) }}
</span>
</div>
<p v-if="alert.details" class="mt-2 truncate text-xs text-gray-500 dark:text-gray-400">
{{ alert.details }}
<p v-if="alertSummaryText(alert)" class="mt-2 truncate text-xs text-gray-500 dark:text-gray-400">
{{ alertSummaryText(alert) }}
</p>
<p
v-if="alert.acknowledgedBy"
@@ -0,0 +1,47 @@
export function hasExplanation(alert) {
return Boolean(alert?.explanation)
}
export function alertSummaryText(alert) {
const narrative = alert?.explanation?.narrativeSummary
if (narrative) return narrative
return alert?.details ?? ''
}
export function elevatedContributors(explanation) {
if (!explanation?.scoreContributors?.length) return []
const elevated = explanation.scoreContributors.filter(c => c.points > 0)
return elevated.length > 0 ? elevated : explanation.scoreContributors
}
export function formatDuration(duration) {
if (!duration) return ''
if (typeof duration !== 'string') return String(duration)
const match = duration.match(/^(\d+):(\d{2}):(\d{2})$/)
if (!match) return duration
const hours = Number(match[1])
const minutes = Number(match[2])
if (hours > 0) return `${hours} hour${hours === 1 ? '' : 's'}`
if (minutes > 0) return `${minutes} minute${minutes === 1 ? '' : 's'}`
return 'under 1 minute'
}
export function formatTrendLine(trend) {
if (!trend) return ''
const direction = trend.direction?.toLowerCase() ?? 'changed'
const duration = formatDuration(trend.duration)
return `${trend.parameter} ${direction} ${Math.round(trend.percentChange)}% over ${duration}.`
}
export function formatMedicationContext(medication) {
if (!medication) return ''
const administered = medication.administeredAt
? new Date(medication.administeredAt).toLocaleString()
: null
let line = `${medication.drugName} ${medication.dose} ${medication.route}`
if (administered) line += `${administered}`
if (medication.relevanceNote) line += ` (${medication.relevanceNote})`
return line
}
@@ -2,6 +2,7 @@ import { onBeforeUnmount } from 'vue'
import { useWardStore } from '@/stores/ward'
import { useSettingsStore } from '@/stores/settings'
import { alertTypeLabel } from '@/api/normalize'
import { alertSummaryText } from '@/composables/alertExplanation'
const DEFAULT_TITLE = 'VigilCare'
let titleFlashTimer = null
@@ -80,8 +81,8 @@ export function showBrowserNotification(alert) {
if (!('Notification' in window) || Notification.permission !== 'granted') return
const patientLabel = formatAlertPatientLabel(alert)
const body = alert.details
? `${patientLabel}${alert.details}`
const body = alertSummaryText(alert)
? `${patientLabel}${alertSummaryText(alert)}`
: patientLabel
new Notification(`Critical: ${alertTypeLabel(alert.alertType)}`, {