564 lines
32 KiB
Markdown
564 lines
32 KiB
Markdown
# Medication Correlation Design Decisions
|
|
|
|
**Status:** Implemented (Phase 15). Medication administration CRUD, `MedicationCorrelationHelper`, and integration with `WarningEvaluator` and `News2Detector` are in production. Verification: `./scripts/run-phase15-verification.sh` and `MedicationCorrelationTests`. The simulator scenario `VigilCare.Simulator/Scenarios/List/medication-false-alarm-01.json` exercises the end-to-end flow.
|
|
|
|
## The problem this solves
|
|
|
|
A patient with a blood pressure of 140/90 receives metoprolol (a beta-blocker that
|
|
lowers blood pressure and heart rate). Forty-five minutes later, their systolic BP reads
|
|
95 mmHg. Without medication context, the system fires a `WARNING_SYSTOLIC_BP` alert, a
|
|
NEWS2 score increase, and potentially a `RAPID_DETERIORATION` trend alert — all because
|
|
the BP dropped. But the BP dropped *because the medication is working as intended*.
|
|
|
|
Clinicians who see these false positives repeatedly stop trusting the alert system. At
|
|
that point, the system is worse than useless — it trains people to ignore alerts,
|
|
including the real ones.
|
|
|
|
Phase 15 addresses this by **annotating** alerts with medication context. The alert still
|
|
fires (the BP is genuinely low and may need monitoring), but the details say:
|
|
|
|
> SYSTOLIC_BP value 95 is below warning low of 90. — note: metoprolol 25mg (PO)
|
|
> administered 45 min ago
|
|
|
|
That single line changes the clinical interpretation from "something is wrong" to "the
|
|
medication is working — keep monitoring."
|
|
|
|
The authoritative endpoint list lives in the [API Reference](../../README.md#api-reference)
|
|
(`/api/v1` prefix, standard `{ success, statusCode, data, error }` envelope). For the
|
|
full patient journey from registration through discharge — not only medication correlation —
|
|
see [Patient Encounter API Lifecycle](../patient-encounter-api-lifecycle.md). This
|
|
document explains how medication correlation fits into that surface — which endpoints
|
|
write administrations, which paths produce annotated alerts, and which endpoints are
|
|
deliberately outside the correlation scope.
|
|
|
|
---
|
|
|
|
## Relationship to the full API
|
|
|
|
Medication correlation is not a standalone micro-API. It is a cross-cutting concern that
|
|
connects medication write endpoints, observation ingest, asynchronous alert consumers, and
|
|
alert read endpoints. The table below maps every `/api/v1` resource group to its role.
|
|
|
|
| Resource group | Endpoints | Role in medication correlation |
|
|
|---|---|---|
|
|
| **Patients** | `POST/GET /patients`, `GET /patients/{id}` | Prerequisite only — patients must exist before encounters are opened. No correlation logic. |
|
|
| **Encounters** | `GET/POST /encounters`, `PATCH /encounters/{id}/status`, `GET /encounters/{id}/timeline`, `GET /encounters/{id}/qsofa/current` | Medications and observations are scoped to an **active** encounter. `GET /encounters/{id}` and `GET /encounters/{id}/timeline` return open alerts whose `details` may already include server-side annotations; the timeline does **not** include medication administrations. |
|
|
| **Alert thresholds** | `POST/GET/PUT /alert-thresholds` | Defines the warning/critical bands that `WarningEvaluator` compares against. Threshold changes invalidate the Redis cache but do not affect drug-vital mappings (`MedicationCorrelation` config is separate). |
|
|
| **Observations** | `POST/GET /encounters/{id}/observations` | **Write path** that eventually triggers correlation. `POST` returns `alertGenerated` only for **critical** breaches created synchronously (never annotated). Warning and NEWS2 alerts are created asynchronously by Kafka consumers and include annotations when applicable. |
|
|
| **Clinical alerts** | `GET /encounters/{id}/alerts`, `GET /alerts`, `GET /alerts/{id}`, `POST /alerts/{id}/acknowledge`, `POST /alerts/{id}/resolve` | **Read path** where annotations surface. The `details` field on `ClinicalAlert` carries the `— note: …` suffix. Acknowledge/resolve lifecycle is unchanged. |
|
|
| **Orders** | `POST/GET /encounters/{id}/orders`, `GET/PATCH /orders/{id}` | Unrelated to correlation. Sepsis-bundle orders are auto-created by the sepsis engine, not by medication recording. |
|
|
| **Analytics** | `GET /analytics/patients`, `/observations/trend`, `/alerts/summary`, `/population` | Elasticsearch-backed reporting. Alert summaries index the annotated `details` text but do not run correlation themselves. |
|
|
| **NEWS2** | `GET /encounters/{id}/news2/current`, `GET /encounters/{id}/news2/history` | Scores are computed asynchronously (`news2-scoring` consumer). When a NEWS2 warning/emergency alert is created, its `details` may be annotated the same way as threshold warnings. Score history endpoints return numeric components only — annotations live on the alert, not the score row. |
|
|
| **Sepsis bundles** | `GET /encounters/{id}/sepsis-bundle/current`, `GET /sepsis-bundles/{id}` | Deliberately excluded from annotation (see [What is NOT annotated](#what-is-not-annotated-and-why)). |
|
|
| **Medications** | `POST/GET /encounters/{id}/medications`, `GET /medications/{id}` | **Write and read path** for administrations. Correlation reads via `MedicationService.GetRecentForEncounterAsync`; HTTP clients (dashboard, simulator) use the list endpoint to show recent drugs alongside alert reasoning. |
|
|
|
|
There is no dedicated "correlate" or "annotate" endpoint. Correlation runs inside background
|
|
consumers at alert-creation time and is visible only through alert `details` and
|
|
medication list responses.
|
|
|
|
### End-to-end API choreography (metoprolol example)
|
|
|
|
A typical integration or simulator run exercises these endpoints in order:
|
|
|
|
```
|
|
1. POST /api/v1/patients → register patient (MRN assigned)
|
|
2. POST /api/v1/patients/{id}/encounters → open encounter (status: active)
|
|
3. POST /api/v1/encounters/{id}/medications → record metoprolol 25mg PO
|
|
4. POST /api/v1/encounters/{id}/observations → ingest SYSTOLIC_BP = 95
|
|
│
|
|
├─ HTTP 201: observation row created; alertGenerated = false
|
|
│ (warning path is async — no annotation in this response)
|
|
│
|
|
└─ Kafka observation.recorded
|
|
→ WarningAlertService (consumer group: warning-evaluator)
|
|
→ MedicationCorrelationHelper annotates details
|
|
→ clinical_alerts row + alert.generated outbox event
|
|
5. GET /api/v1/encounters/{id}/alerts → poll until WARNING_SYSTOLIC_BP appears
|
|
details: "SYSTOLIC_BP value 95 is below warning low of 90. — note: metoprolol 25mg (PO) administered 45 min ago"
|
|
6. GET /api/v1/encounters/{id}/medications → optional; dashboard uses this for the
|
|
"Recent medications" panel in alert reasoning (client-side 90-min window)
|
|
```
|
|
|
|
Allow a few seconds between steps 4 and 5 for the Kafka consumer to process the
|
|
`observation.recorded` event. NEWS2 follows the same pattern via `news2-scoring` after
|
|
all seven parameters are present.
|
|
|
|
### Where annotations appear (and where they do not)
|
|
|
|
| Response field / endpoint | Contains annotation? | Notes |
|
|
|---|---|---|
|
|
| `POST …/observations` → `alertGenerated` / inline alert | No (warnings) | Only critical alerts return synchronously; critical path never calls `MedicationCorrelationHelper`. |
|
|
| `GET …/alerts`, `GET /alerts/{id}`, `GET /encounters/{id}` (embedded alerts) | Yes (warning + NEWS2) | Primary consumer surface. `details` is plain text with appended `— note: …`. |
|
|
| `GET …/encounters/{id}/timeline` | Yes (on alert events) | Timeline merges observations and alerts; alert events include annotated `details`. |
|
|
| `alert.generated` Kafka / outbox payload | Yes | `details` in the event matches the persisted alert row. |
|
|
| `GET …/news2/current`, `GET …/news2/history` | No | Scores only; read the corresponding NEWS2 alert for annotated context. |
|
|
| `GET …/medications` | No | Returns raw administration rows; dashboard correlates client-side for the reasoning panel. |
|
|
|
|
---
|
|
|
|
## How the pieces fit together
|
|
|
|
There are six components. Here is how a request flows through them, starting
|
|
from when a nurse records a medication and ending when an annotated alert is created.
|
|
|
|
```
|
|
Nurse records medication
|
|
│
|
|
▼
|
|
┌─────────────────────┐
|
|
│ MedicationsController│ ◄── thin HTTP layer, no business logic
|
|
│ POST /api/v1/ │
|
|
│ encounters/{id}/ │
|
|
│ medications │
|
|
└────────┬────────────┘
|
|
│
|
|
▼
|
|
┌─────────────────────┐
|
|
│ FluentValidation │ ◄── rejects bad input before it reaches the service
|
|
│ (auto-registered) │ (empty drug name, zero dose, future timestamps)
|
|
└────────┬────────────┘
|
|
│
|
|
▼
|
|
┌─────────────────────┐
|
|
│ MedicationService │ ◄── checks encounter exists and is active,
|
|
│ │ persists to PostgreSQL
|
|
└─────────────────────┘
|
|
|
|
... time passes, vital signs arrive ...
|
|
|
|
POST /api/v1/encounters/{id}/observations (e.g. SYSTOLIC_BP = 95)
|
|
│
|
|
▼
|
|
┌─────────────────────┐
|
|
│ ObservationService │ ◄── sync path: critical alerts only (never annotated)
|
|
│ COMMIT + outbox │ always emits observation.recorded → Kafka
|
|
└────────┬────────────┘
|
|
│
|
|
▼ (async, consumer group: warning-evaluator)
|
|
┌─────────────────────┐
|
|
│ WarningEvaluator │ ◄── or News2Detector (consumer group: news2-scoring)
|
|
│ │
|
|
│ 1. Load threshold │ (from Redis cache)
|
|
│ 2. Check breach │ (is 95 < warningLow of 90?)
|
|
│ 3. Build details │ ("SYSTOLIC_BP value 95 is below warning low of 90.")
|
|
│ 4. ► Annotate ◄ │ MedicationCorrelationHelper
|
|
│ 5. INSERT alert │ (idempotent — skips if one already open)
|
|
└────────┬────────────┘
|
|
│
|
|
step 4 calls
|
|
│
|
|
▼
|
|
┌────────────────────────────┐
|
|
│ MedicationCorrelationHelper│
|
|
│ │
|
|
│ "Was a relevant drug │
|
|
│ given to this patient │
|
|
│ within the last 90 │
|
|
│ minutes?" │
|
|
│ │
|
|
│ Uses MedicationService │
|
|
│ .GetRecentForEncounterAsync│
|
|
│ to query PostgreSQL │
|
|
│ │
|
|
│ Uses DrugVitalMappings │
|
|
│ from config to decide │
|
|
│ which drugs are "relevant"│
|
|
│ to which vital signs │
|
|
└────────────────────────────┘
|
|
|
|
... client reads result ...
|
|
|
|
GET /api/v1/encounters/{id}/alerts → annotated details in response
|
|
```
|
|
|
|
---
|
|
|
|
## Component-by-component: what it does and why it was built that way
|
|
|
|
### 1. MedicationAdministration entity
|
|
|
|
**What it is:** A database table (`medication_administrations`) that records when a drug
|
|
was given to a patient. Each row captures the drug name, dose, unit, route (PO = oral,
|
|
IV = intravenous, etc.), timestamp, and who gave it.
|
|
|
|
**Why it's a separate table (not a column on `observations`):**
|
|
Medications and vital sign observations are fundamentally different things. An
|
|
observation is a measurement — the patient's heart rate *is* 105 bpm right now. A
|
|
medication administration is an action — a nurse *gave* metoprolol 25mg at 10:15 AM.
|
|
They have different fields (dose, route, administered_by vs. value, unit, source),
|
|
different lifecycles, and different access patterns. Mixing them into one table would
|
|
require nullable columns everywhere and make queries harder to reason about.
|
|
|
|
**Why `decimal(10,4)` for dose:**
|
|
Medication doses can be fractional — 0.5mg of atropine, 2.5mg of metoprolol. Using an
|
|
integer would lose precision. Using `float`/`double` introduces floating-point rounding
|
|
errors (0.1 + 0.2 = 0.30000000000000004), which is unacceptable for medical dosing.
|
|
`decimal` stores exact values. The `(10,4)` precision allows doses up to 999999.9999,
|
|
which covers everything from micrograms to grams.
|
|
|
|
**Why two composite indexes:**
|
|
- `(encounter_id, administered_at)` — the correlation query asks "what drugs were given
|
|
to this encounter in the last 90 minutes?" This index makes that query fast.
|
|
- `(encounter_id, drug_name)` — supports the list endpoint when filtering by drug.
|
|
|
|
Without these indexes, every correlation check would do a full table scan on the
|
|
medication_administrations table. On a busy hospital with thousands of medication records
|
|
per day, that would add visible latency to every alert evaluation.
|
|
|
|
**Why `DeleteBehavior.Restrict` on the Encounter FK:**
|
|
If someone accidentally tries to delete an encounter that has medication records, the
|
|
database will reject the delete rather than silently cascade-deleting the medication
|
|
history. Medical records should never be silently deleted.
|
|
|
|
---
|
|
|
|
### 2. MedicationCorrelationOptions (configuration)
|
|
|
|
**What it is:** A C# class that maps drug names to the vital signs they affect. For
|
|
example, metoprolol maps to `["SYSTOLIC_BP", "HEART_RATE", "DIASTOLIC_BP"]` because
|
|
beta-blockers lower blood pressure and heart rate. It also holds the correlation window
|
|
(default 90 minutes).
|
|
|
|
**Why this is config, not a database table:**
|
|
Drug-vital mappings change rarely — new drugs don't appear every week, and the
|
|
physiological effects of existing drugs don't change. Putting them in `appsettings.json`
|
|
means:
|
|
- They're version-controlled with the code (you can see in git who changed them and why)
|
|
- They're loaded once at startup, not queried from the database on every alert
|
|
- They can be overridden per environment (test vs. production) using the standard
|
|
ASP.NET Core configuration hierarchy
|
|
- No migration needed when a mapping changes — just update the config and restart
|
|
|
|
If this were a database table, you'd need an admin UI, migration scripts, and a cache
|
|
layer to avoid querying the table on every observation. The config approach avoids all of
|
|
that complexity for data that changes maybe once a quarter.
|
|
|
|
**Why 90 minutes as the default window:**
|
|
Most oral medications reach peak effect within 60-90 minutes. IV medications act faster
|
|
(minutes), but their effects also persist. A 90-minute window catches most therapeutic
|
|
scenarios. If it were too short (30 minutes), oral medications like metoprolol (peak
|
|
effect at 60 minutes) would be missed. If it were too long (6 hours), unrelated
|
|
medication effects would pollute the annotation. The value is configurable per
|
|
environment if a clinical team wants to adjust it.
|
|
|
|
**Why drug names are lowercase keys:**
|
|
Drug names in clinical systems come in all forms — "Metoprolol", "METOPROLOL",
|
|
"metoprolol". By normalizing keys to lowercase in the config and doing case-insensitive
|
|
lookups, the correlation works regardless of how the nurse typed the drug name. This
|
|
avoids a class of bugs where correlation silently fails because the case doesn't match.
|
|
|
|
The shipped `appsettings.json` includes mappings for common cardiovascular, vasopressor,
|
|
opioid, sedative, diuretic, and antibiotic agents — not just metoprolol. Add or adjust
|
|
entries under `MedicationCorrelation:DrugVitalMappings` without a migration.
|
|
|
|
---
|
|
|
|
### 3. MedicationService
|
|
|
|
**What it is:** The business logic layer for medication administration CRUD. It handles
|
|
creating records, listing them with pagination, looking up by ID, and the critical
|
|
`GetRecentForEncounterAsync` method used by the correlation helper.
|
|
|
|
**Why the service throws exceptions instead of returning error codes:**
|
|
The project uses a pattern where services throw typed domain exceptions
|
|
(`NotFoundException`, `ConflictException`) and a global `ExceptionHandlerMiddleware`
|
|
converts them into HTTP responses (404, 409). This pattern means:
|
|
- The service doesn't know or care about HTTP — it could be called from a Kafka consumer,
|
|
a background job, or a test, and the error handling still works
|
|
- The controller stays thin (no `if (result.IsError) return NotFound(...)` chains)
|
|
- Error handling is consistent across every endpoint — one middleware, one set of rules
|
|
|
|
**Why `GetRecentForEncounterAsync` reverses the mapping:**
|
|
The config maps drugs → vital signs (metoprolol → SYSTOLIC_BP). But the correlation
|
|
query starts from the other direction: "I have a SYSTOLIC_BP alert — which drugs affect
|
|
SYSTOLIC_BP?" The method inverts the mapping at query time: it scans all entries in
|
|
`DrugVitalMappings` to find keys whose values contain the target observation code, then
|
|
queries the database for those specific drug names.
|
|
|
|
You might ask: "Why not store the mapping both ways?" Because maintaining two maps that
|
|
must stay in sync is a bug waiting to happen. The drug → vitals direction is the natural
|
|
way clinicians think about it ("metoprolol affects blood pressure"), so that's how it's
|
|
stored. The reverse lookup happens in code, where it's guaranteed to be consistent.
|
|
|
|
**Why the DB query uses `relevantDrugs.Contains(m.DrugName.ToLower())`:**
|
|
This translates to a SQL `WHERE drug_name IN (...)` clause, which PostgreSQL handles
|
|
efficiently with the composite index. The alternative — loading all medications and
|
|
filtering in memory — would transfer unnecessary data from the database. Let the database
|
|
do what it's good at.
|
|
|
|
---
|
|
|
|
### 4. MedicationsController
|
|
|
|
**What it is:** Three HTTP endpoints under `/api/v1` (see also
|
|
[Medications in the API Reference](../../README.md#medications)):
|
|
|
|
| Method | Path | Purpose |
|
|
|---|---|---|
|
|
| `POST` | `/encounters/{encounterId}/medications` | Record a new administration |
|
|
| `GET` | `/encounters/{encounterId}/medications` | List with pagination and optional `since` filter |
|
|
| `GET` | `/medications/{id}` | Look up a single record (includes nested `encounter`) |
|
|
|
|
**POST body** (`CreateMedicationAdministrationRequest`):
|
|
|
|
| Field | Type | Required | Notes |
|
|
|---|---|---|---|
|
|
| `drugName` | string | yes | Trimmed on persist; case-insensitive for `DrugVitalMappings` lookup |
|
|
| `dose` | decimal | yes | Must be > 0 |
|
|
| `doseUnit` | string | yes | e.g. `mg`, `g`, `mcg`, `units` |
|
|
| `route` | string | yes | e.g. `PO`, `IV`, `SubQ` |
|
|
| `administeredAt` | DateTimeOffset | no | Defaults to server UTC time if omitted |
|
|
| `administeredBy` | string | yes | Clinician or nurse identifier |
|
|
|
|
**POST response:** `201 Created` with `ApiResponse<MedicationAdministration>` in the
|
|
standard envelope. The `data` object includes `id`, `encounterId`, `drugName`, `dose`,
|
|
`doseUnit`, `route`, `administeredAt`, `administeredBy`.
|
|
|
|
**POST status codes:**
|
|
|
|
| Code | When |
|
|
|---|---|
|
|
| `201` | Administration recorded |
|
|
| `400` | FluentValidation failure (empty drug name, zero dose, `administeredAt` > 5 min in future, field length exceeded) |
|
|
| `404` | Encounter not found (`ENCOUNTER_NOT_FOUND`) |
|
|
| `409` | Encounter not active (`ENCOUNTER_NOT_ACTIVE`) |
|
|
|
|
**GET list response:** `200 OK` with paginated `{ items, page, pageSize, totalCount, totalPages }`.
|
|
Query params: `since` (ISO 8601 — administrations at or after this time), `page` (default 1),
|
|
`pageSize` (default 20). The dashboard's `fetchMedications` helper pages through this
|
|
endpoint (default `pageSize` 50) to populate the alert-reasoning panel.
|
|
|
|
**GET by id response:** `200 OK` with full `MedicationAdministration` (404 if not found).
|
|
|
|
**Why medication POST does not trigger correlation:**
|
|
Recording a drug does not create or modify alerts. Correlation is evaluated only when a
|
|
warning or NEWS2 alert is **created** — triggered by observation ingest (async) or NEWS2
|
|
scoring (async). This keeps the medication write path fast and idempotent with no
|
|
side effects beyond the `medication_administrations` row.
|
|
|
|
**Why the controller is almost empty:**
|
|
The controller's only job is to translate HTTP concepts (route parameters, query strings,
|
|
status codes) into service calls and back. All business logic lives in
|
|
`MedicationService`. This is the same pattern used by `OrdersController` and every other
|
|
controller in the project. The benefit: you can test business logic by calling the
|
|
service directly (no need to spin up an HTTP server), and the controller is so simple
|
|
that bugs in it are immediately obvious.
|
|
|
|
**Why `POST` returns 201, not 200:**
|
|
HTTP 201 means "a new resource was created." This follows REST conventions and tells the
|
|
client that the medication record now exists and can be fetched at the returned URL. It's
|
|
a small detail, but API consumers (other teams, integration partners) expect it.
|
|
|
|
---
|
|
|
|
### 5. FluentValidation
|
|
|
|
**What it is:** A validator that rejects invalid requests before they reach the service
|
|
layer. It checks: drug name is not empty, dose is greater than zero, future timestamps
|
|
are rejected (a medication can't be recorded as given tomorrow), and string fields don't
|
|
exceed database column limits.
|
|
|
|
**Why validation is separate from the service:**
|
|
Validation and business rules are different things. Validation asks "is this request
|
|
well-formed?" (non-empty drug name, positive dose). Business rules ask "is this operation
|
|
allowed?" (encounter must be active). Keeping them separate means:
|
|
- Validation runs automatically via FluentValidation middleware — no manual calls needed
|
|
- Validation errors return 400 (bad request), business rule violations return 404/409
|
|
- The service can trust that inputs are well-formed and focus on domain logic
|
|
- Validators are auto-registered by scanning the assembly — adding a new validator just
|
|
means creating a class
|
|
|
|
**Why `AdministeredAt` allows up to 5 minutes in the future:**
|
|
Clock skew. The nurse's tablet might be a few minutes ahead of the server. Rejecting a
|
|
timestamp that's 30 seconds in the future because of clock drift would be frustrating.
|
|
Five minutes is generous enough to handle clock differences without allowing genuinely
|
|
wrong timestamps (like recording a medication as given tomorrow).
|
|
|
|
---
|
|
|
|
### 6. MedicationCorrelationHelper
|
|
|
|
**What it is:** The bridge between the medication subsystem and the alerting subsystem. It
|
|
has one public method: `TryAnnotateDetailsAsync`. Given an encounter ID, an observation
|
|
code, and the existing alert details string, it checks if a relevant medication was given
|
|
within the correlation window. If yes, it appends a note. If no, it returns the original
|
|
string unchanged.
|
|
|
|
**Why it's a separate class (not inline in WarningEvaluator):**
|
|
Two different alert paths need medication annotation: `WarningEvaluator` (for threshold
|
|
warnings) and `News2Detector` (for composite NEWS2 scores). If the annotation logic lived
|
|
inside `WarningEvaluator`, it would need to be duplicated in `News2Detector`. The helper
|
|
class means both callers share the same logic, and if the annotation format changes, it
|
|
changes in one place.
|
|
|
|
**Why it annotates rather than suppresses:**
|
|
A patient's BP of 95 after metoprolol is *expected* but still *clinically relevant*. The
|
|
BP is genuinely low. If a nurse doubled the dose by mistake, or if the patient is
|
|
hypotensive beyond what the medication should cause, the alert needs to fire. Suppressing
|
|
it entirely would be a patient safety risk. Annotation gives the clinician the context to
|
|
make that judgment — it says "this might be the medication" without saying "ignore this."
|
|
|
|
Smart suppression (automatically silencing alerts that are clearly medication-related) is
|
|
deferred to a future phase because it requires more sophisticated logic: was the drop
|
|
proportional to the dose? Is the patient also septic? These judgment calls are hard to
|
|
encode safely in software.
|
|
|
|
**Why it only annotates the most recent matching medication:**
|
|
If a patient received metoprolol at 9:00 AM and again at 10:30 AM, and their BP drops at
|
|
11:00 AM, the 10:30 AM dose is the one the clinician cares about — it's the most likely
|
|
cause. Listing every medication in the window would clutter the alert. The most recent
|
|
match gives the most actionable context.
|
|
|
|
---
|
|
|
|
## What is NOT annotated and why
|
|
|
|
| Alert path | Annotated? | Reason |
|
|
|---|---|---|
|
|
| `WarningEvaluator` | Yes | Threshold warnings are the primary source of medication-related false positives |
|
|
| `News2Detector` | Yes | NEWS2 is a composite of 7 vital signs — any of which could be medication-affected |
|
|
| `TrendDetector` | No | A rapid trajectory change after medication is still clinically important — the *rate* of change matters even if the medication explains it |
|
|
| `SirsDetector` / `QsofaDetector` | No | Sepsis alerts should never be downplayed by medication context — missing sepsis kills patients |
|
|
| Critical alerts (sync path) | No | Critical values (e.g., SBP below 70) are emergencies regardless of medication context — patient safety takes priority |
|
|
|
|
This is a deliberate clinical safety decision. The cost of a false negative (missing a
|
|
real emergency because the system said "it's just the medication") is much higher than the
|
|
cost of a false positive (an extra annotation on an expected alert).
|
|
|
|
---
|
|
|
|
## How the annotation hooks into existing alert creation
|
|
|
|
Both `WarningEvaluator` and `News2Detector` already follow a pattern:
|
|
|
|
1. Check if a threshold is breached
|
|
2. Build a details string describing the breach
|
|
3. INSERT the alert into the database
|
|
|
|
Phase 15 inserts one step between 2 and 3:
|
|
|
|
```
|
|
2. Build details string
|
|
2.5 ► Call MedicationCorrelationHelper.TryAnnotateDetailsAsync ◄
|
|
3. INSERT the alert (with the possibly-annotated details)
|
|
```
|
|
|
|
This insertion point required no changes to the threshold logic, the idempotent
|
|
INSERT pattern, the outbox event publishing, or the alert suppression logic. Each of
|
|
those systems continues to work exactly as before.
|
|
|
|
The annotated details string is also included in the outbox event payload, so downstream
|
|
consumers (notifications, dashboards) receive the medication context without needing
|
|
their own correlation logic.
|
|
|
|
Clients that poll `GET /api/v1/encounters/{id}/alerts` or `GET /api/v1/alerts/{id}` read
|
|
the same annotated `details` string. The ward dashboard (`vigilcare-dashboard`) adds a
|
|
second layer: `AlertReasoning.vue` fetches `GET …/medications` and shows administrations
|
|
within 90 minutes before `alert.triggeredAt`, independent of whether the server appended
|
|
the `— note:` suffix (e.g. when the drug name is not in `DrugVitalMappings`).
|
|
|
|
### News2Detector annotation strategy
|
|
|
|
NEWS2 is different from single-vital warnings because it's a composite score of 7
|
|
parameters (respiratory rate, SpO2, systolic BP, heart rate, consciousness,
|
|
temperature, supplemental O2). A single NEWS2 alert doesn't correspond to one
|
|
observation code — it's a rollup.
|
|
|
|
The annotation loops through all 7 parameter codes and checks if any has a correlated
|
|
medication. It appends the first match found. This keeps the annotation concise (one
|
|
drug per alert) while still catching the most relevant context. If a patient received
|
|
both morphine (affects RESP_RATE) and metoprolol (affects SYSTOLIC_BP), the annotation
|
|
shows whichever parameter code comes first in the loop — which is fine, because the
|
|
purpose is to flag "there's medication context here" rather than to provide a complete
|
|
pharmaceutical review.
|
|
|
|
---
|
|
|
|
## Data flow summary
|
|
|
|
```
|
|
HTTP POST /api/v1/encounters/{id}/medications
|
|
│
|
|
▼
|
|
┌────────────────┐
|
|
│ Medications │
|
|
│ Controller │
|
|
└───────┬────────┘
|
|
│
|
|
▼
|
|
┌────────────────┐ ┌─────────────────┐
|
|
│ Medication │──────►│ PostgreSQL │
|
|
│ Service │ │ medication_ │
|
|
└────────────────┘ │ administrations │
|
|
└────────┬────────┘
|
|
│
|
|
HTTP POST /api/v1/encounters/{id}/observations
|
|
(observation row + observation.recorded outbox)
|
|
│ │
|
|
▼ │
|
|
┌────────────────┐ │
|
|
│ Kafka │ │
|
|
│ observation. │ │
|
|
│ recorded │ │
|
|
└───────┬────────┘ │
|
|
│ │
|
|
┌────────────┴────────────┐ │
|
|
▼ ▼ │
|
|
┌──────────────┐ ┌──────────────┐ │
|
|
│ WarningAlert │ │ News2Scoring │ │
|
|
│ Service │ │ Service │ │
|
|
└──────┬───────┘ └──────┬───────┘ │
|
|
│ │ │
|
|
▼ ▼ │
|
|
┌────────────────┐ ┌────────────────┐ │
|
|
│ Warning │ │ News2 │ │
|
|
│ Evaluator │ │ Detector │ │
|
|
│ │ │ │ │ │ │
|
|
│ ▼ │ │ ▼ │ │
|
|
│ Correlation │◄──────┴───────┘ │
|
|
│ Helper │◄────────────────────────┘
|
|
│ │ │ queries recent meds
|
|
│ ▼ │ for encounter + code
|
|
│ annotated │
|
|
│ details │
|
|
│ │ │
|
|
│ ▼ │
|
|
│ INSERT alert │──────► PostgreSQL clinical_alerts
|
|
│ + outbox event│──────► outbox_events → Kafka alert.generated
|
|
└────────────────┘
|
|
│
|
|
▼
|
|
GET /api/v1/encounters/{id}/alerts
|
|
GET /api/v1/alerts/{id}
|
|
(annotated details in JSON response)
|
|
```
|
|
|
|
---
|
|
|
|
## Testing strategy
|
|
|
|
Twelve tests across three files cover the medication subsystem:
|
|
|
|
**MedicationServiceTests (4 tests)** — tests the service layer directly. Can a medication
|
|
be created on an active encounter? Does a discharged encounter get rejected? Does
|
|
pagination work? Does the time-window filter exclude old records? These tests call
|
|
`IMedicationService` methods directly, bypassing HTTP.
|
|
|
|
**MedicationCorrelationTests (5 tests)** — tests the full integration from medication
|
|
recording through alert creation. These seed a medication into the database, then invoke
|
|
`WarningEvaluator.EvaluateAsync` (and NEWS2 paths where applicable) and check whether the
|
|
resulting alert's `details` field contains the medication annotation. This is the most
|
|
important test file because it verifies the end-to-end behavior the feature exists to provide.
|
|
|
|
**MedicationValidationTests (3 tests)** — tests the HTTP validation layer. These send
|
|
invalid requests via `HttpClient` and assert 400 responses. They don't seed encounters
|
|
because the validator rejects the request before the service layer runs.
|
|
|
|
Run with `dotnet test --filter "FullyQualifiedName~Medication"` or
|
|
`./scripts/run-phase15-verification.sh` (requires API + Docker Compose).
|
|
|
|
All tests run against a real PostgreSQL database and real Redis instance (using test
|
|
containers on different ports). No mocking. This means the tests catch real issues like
|
|
SQL translation failures, index problems, and configuration registration mistakes that
|
|
mocks would miss.
|