feature: Explainable Alerts
This commit is contained in:
@@ -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)}`, {
|
||||
|
||||
Reference in New Issue
Block a user