fix: No token refresh or revocation mechanism~

This commit is contained in:
voltsrage
2026-06-25 14:14:20 +08:00
parent a8964381a2
commit fdcc646fae
26 changed files with 3453 additions and 55 deletions
+346
View File
@@ -0,0 +1,346 @@
# Guide 22: Clinical Scoring Engines (NEWS2, GCS, SOFA, qSOFA)
## What Are Clinical Scoring Systems?
In medicine, a single vital sign (like heart rate = 110) doesn't tell you much on its own — it could be a patient exercising or a patient in septic shock. **Clinical scoring systems** combine multiple vital signs and lab values into a single number that predicts how sick a patient is and whether they need urgent intervention.
Think of it like a weather severity index: temperature alone doesn't tell you if a storm is dangerous, but combining temperature, wind speed, pressure, and humidity gives you a meaningful risk score.
This project implements four clinical scoring systems:
| Score | Full Name | What It Measures | Parameters | Alert Threshold |
|-------|-----------|-----------------|------------|-----------------|
| **NEWS2** | National Early Warning Score 2 | General deterioration risk | 7 vital signs | Score >= 7 → Emergency |
| **GCS** | Glasgow Coma Scale | Level of consciousness | 3 components (Eye, Verbal, Motor) | Total <= 8 → Severe (Critical) |
| **qSOFA** | Quick SOFA | Bedside sepsis screen | 3 criteria | >= 2 criteria met → Screen positive |
| **SOFA** | Sequential Organ Failure Assessment | Organ dysfunction severity | 6 organ systems | Delta >= 2 from baseline → Sepsis |
---
## How Scoring Engines Work (Architecture)
All four scoring engines follow the same pattern: they're Kafka consumers that react to observation events.
```
Observation recorded (HTTP POST)
PostgreSQL + Outbox
▼ (OutboxRelay)
Kafka topic: observation.recorded
├──► SepsisEngineService → QsofaDetector → qSOFA evaluation
├──► News2ScoringService → News2Detector → NEWS2 score
├──► GcsScoringService → GcsDetector → GCS score
│ │
│ Kafka: gcs.scored ◄──┘
│ │
├──► SofaScoringService → SofaDetector → SOFA score
├──► TrendAnalyzerService → TrendDetector → Rate-of-change
└──► WarningAlertService → WarningEvaluator → Warning alerts
```
Each engine is a separate Kafka consumer group, so they process the same observation event independently and in parallel. A single heart rate reading can simultaneously trigger NEWS2 recalculation, qSOFA re-evaluation, trend analysis, and warning threshold checking.
### The Two-Class Pattern: Calculator + Detector
Each scoring system is split into two classes:
- **Calculator** (static, pure logic): Contains the scoring rules — "heart rate 45 scores 3 points in NEWS2." No database, no Redis, no side effects. Easy to unit test.
- **Detector** (stateful, orchestration): Manages Redis state, calls the calculator, persists scores to PostgreSQL, creates alerts, publishes events. Contains all the infrastructure plumbing.
---
## NEWS2 — National Early Warning Score 2
NEWS2 combines 7 vital sign parameters into a single risk score (020+). Higher scores indicate greater deterioration risk.
### The 7 Parameters and Their Scoring
```csharp
public static class News2Calculator
{
public static readonly IReadOnlyList<string> ParameterCodes = new[]
{
"RESP_RATE", "SPO2", "SYSTOLIC_BP", "HEART_RATE",
"AVPU", "TEMP_C", "SUPPLEMENTAL_O2"
};
public static int ScoreRespRate(decimal value) => value switch
{
<= 8 => 3, // Dangerously low
<= 11 => 1,
<= 20 => 0, // Normal range
<= 24 => 2,
_ => 3 // Dangerously high
};
public static int ScoreHeartRate(decimal value) => value switch
{
<= 40 => 3,
<= 50 => 1,
<= 90 => 0, // Normal range
<= 110 => 1,
<= 130 => 2,
_ => 3
};
// ... similar for SpO2, SystolicBp, Temperature, Consciousness, SupplementalO2
}
```
Each parameter scores 03 points. The total score determines the risk level:
| Total Score | Risk Level | Alert |
|-------------|-----------|-------|
| 04 | LOW | No alert |
| 56 or any single param = 3 | MEDIUM / LOW_MEDIUM | `NEWS2_WARNING` (Warning) |
| >= 7 | HIGH | `NEWS2_EMERGENCY` (Critical) |
### How NEWS2 Aggregates Over Time
The 7 parameters rarely arrive simultaneously. A nurse records respiratory rate at 14:01, heart rate at 14:03, blood pressure at 14:05. The `News2Detector` uses Redis to accumulate parameters until all 7 are present:
```
14:01 RESP_RATE=18 → Redis: news2:{enc}:RESP_RATE = {value:18, score:0} (1/7 present)
14:03 HEART_RATE=95 → Redis: news2:{enc}:HEART_RATE = {value:95, score:1} (2/7 present)
14:05 SYSTOLIC_BP=115 → Redis: news2:{enc}:SYSTOLIC_BP = ... (3/7 present)
... (more parameters arrive)
14:12 TEMP_C=37.5 → Redis: all 7 present → compute score = 5 (MEDIUM)
→ persist News2Score to PostgreSQL
→ create NEWS2_WARNING alert
```
Each Redis key has a **4-hour TTL**. If no new respiratory rate arrives within 4 hours, that parameter expires and the next NEWS2 calculation waits for a fresh reading.
### Consciousness Resolution: GCS-First, AVPU-Fallback
The consciousness parameter prefers GCS (Glasgow Coma Scale) over AVPU (a simpler alert/voice/pain/unresponsive scale). If GCS components are cached in Redis, they're used. Otherwise, the AVPU value is used:
```csharp
private async Task<int?> ResolveConsciousnessScoreAsync(Guid encounterId)
{
// Try GCS first
var gcsValues = await cache.StringGetAsync(GcsCalculator.AllComponentKeys(encounterId));
if (gcsValues.All(v => v.HasValue))
{
var total = GcsCalculator.ComputeTotal(eye, verbal, motor)!.Value;
return News2Calculator.ScoreConsciousnessFromGcs(total);
}
// Fallback to AVPU
var avpuVal = await cache.StringGetAsync(
News2Calculator.ParameterKey(encounterId, "AVPU"));
return cached?.Score;
}
```
---
## GCS — Glasgow Coma Scale
GCS measures level of consciousness through 3 components:
| Component | Range | What It Assesses |
|-----------|-------|-----------------|
| Eye (E) | 14 | Eye opening response |
| Verbal (V) | 15 | Best verbal response |
| Motor (M) | 16 | Best motor response |
| **Total** | **315** | **Sum of all three** |
```csharp
public static string ClassifyGcs(int total) => total switch
{
<= 8 => "SEVERE", // Coma — Critical alert
<= 12 => "MODERATE", // Warning alert
_ => "MILD" // 13-15 — Normal or near-normal
};
```
### Component Assembly
Like NEWS2, GCS components may arrive separately. The `GcsDetector` caches each component in Redis and computes the total when all 3 are present:
```
14:00 GCS_EYE=3 → Redis: gcs:{enc}:GCS_EYE = 3 (1/3)
14:00 GCS_VERBAL=4 → Redis: gcs:{enc}:GCS_VERBAL = 4 (2/3)
14:01 GCS_MOTOR=5 → Redis: gcs:{enc}:GCS_MOTOR = 5 (3/3)
→ Total = 12 (MODERATE)
→ Persist GcsScore to PostgreSQL
→ Create GCS_WARNING alert
→ Publish gcs.scored to Kafka (for SOFA CNS rescoring)
→ Re-evaluate qSOFA altered mentation
```
### Downstream Effects
GCS has the most downstream effects of any scoring engine:
1. **GCS alert**: SEVERE (total <= 8) → Critical alert, MODERATE (912) → Warning alert
2. **Kafka `gcs.scored` event**: Triggers SOFA CNS organ re-scoring
3. **NEWS2 consciousness**: Replaces AVPU with GCS-derived score
4. **qSOFA altered mentation**: GCS < 15 counts as one of the 3 qSOFA criteria
---
## qSOFA — Quick SOFA (Sepsis Screen)
qSOFA is a bedside screening tool with 3 binary criteria:
| Criterion | Threshold | Meaning |
|-----------|-----------|---------|
| Respiratory rate | >= 22 /min | Breathing fast |
| Systolic blood pressure | <= 100 mmHg | Blood pressure low |
| Altered mentation | GCS < 15 or AVPU >= 1 | Not fully alert |
If **2 or more criteria** are met simultaneously, a `QSOFA_SCREEN` warning alert fires, recommending SOFA labs be ordered.
### Sliding Window with Redis TTL
Each criterion has a **30-minute TTL** in Redis. If a criterion is met, the key is set with a 30-minute expiry. If not met, the key is deleted immediately:
```csharp
if (QsofaCalculator.MeetsCriterion(observationCode, value))
await cache.StringSetAsync(key, value.ToString(), TimeSpan.FromSeconds(1800));
else
await cache.KeyDeleteAsync(key);
```
This means a patient's respiratory rate of 24 (meets criterion) at 14:00 expires automatically at 14:30 if no new high respiratory rate arrives. The qSOFA score naturally decreases as criteria expire.
### Alert Deduplication
qSOFA uses `INSERT ... WHERE NOT EXISTS` to prevent duplicate screen alerts:
```sql
INSERT INTO clinical_alerts (...)
SELECT ... WHERE NOT EXISTS (
SELECT 1 FROM clinical_alerts
WHERE encounter_id = @encounterId
AND alert_type = 'QSOFA_SCREEN'
AND status IN ('OPEN', 'ESCALATED')
)
```
If an open qSOFA screen already exists for this encounter, no duplicate is created.
---
## SOFA — Sequential Organ Failure Assessment
SOFA is the most complex scoring system. It assesses 6 organ systems, each scored 04:
| Organ System | Data Source | Score 0 | Score 4 |
|-------------|------------|---------|---------|
| Respiratory | PaO2/FiO2 ratio (or SpO2/FiO2 fallback) | >= 400 | < 100 with mechanical ventilation |
| Coagulation | Platelet count | >= 150 k/µL | < 20 k/µL |
| Liver | Bilirubin | < 1.2 mg/dL | >= 12 mg/dL |
| Cardiovascular | MAP and vasopressor dose | MAP >= 70, no vasopressors | High-dose epinephrine/norepinephrine |
| CNS | GCS total (via `gcs.scored` Kafka event) | GCS 15 | GCS < 6 |
| Renal | Creatinine and urine output | Creatinine < 1.2 | Creatinine >= 5.0 or urine < 200 mL/day |
Total SOFA score: 024 (sum of all organ scores).
### Baseline and Delta
SOFA doesn't alert on the absolute score — it alerts on the **change from baseline**:
1. **Baseline established**: When >= 4 organ systems have data, the first score becomes the baseline
2. **Delta calculated**: Every subsequent score computes `delta = current total - baseline total`
3. **Alerts**:
- Delta >= 2 → `SOFA_SEPSIS` (Critical) — indicates acute organ dysfunction, triggers a sepsis bundle
- Delta = 1 → `SOFA_WARNING` (Warning)
This is clinically important because a patient with chronic kidney disease might have a baseline SOFA of 4. A score of 4 is not alarming for them — but a sudden jump to 6 (delta = 2) indicates new organ dysfunction.
### Lab Staleness Tracking
Lab values (platelets, bilirubin, creatinine) arrive infrequently — sometimes only once per day. The `SofaLabCache` tracks how old each value is:
| Age | Status | Behavior |
|-----|--------|----------|
| < 12 hours | Current | Used as-is |
| 1224 hours | Stale | Used but flagged in `staleness_flags` JSONB |
| > 24 hours | Expired | Organ score set to 0 (assume normal) |
### SpO2/FiO2 Fallback
Many ward patients don't have arterial blood gas (PaO2) measurements. When PaO2 is unavailable but SpO2 (pulse oximetry) is available, the SOFA detector uses a validated proxy ratio (Rice et al., 2007):
```csharp
if (pao2 is not null && fio2 is not null)
respiratory = SofaCalculator.ScoreRespiratory(pao2, fio2, onMechanicalVent);
else if (_options.UseSpO2FiO2Fallback && spo2 is not null && fio2 is not null)
respiratory = SofaCalculator.ScoreRespiratoryFromSpo2(spo2, fio2, onMechanicalVent);
```
---
## How the Scores Connect
The scoring engines are not independent — they feed into each other:
```
Observation arrives
├── NEWS2: uses RESP_RATE, SPO2, SYSTOLIC_BP, HEART_RATE, TEMP_C,
│ SUPPLEMENTAL_O2, and consciousness (from GCS or AVPU)
├── qSOFA: uses RESP_RATE, SYSTOLIC_BP, and altered mentation (from GCS)
├── GCS: uses GCS_EYE, GCS_VERBAL, GCS_MOTOR
│ │
│ └── publishes gcs.scored ──► SOFA (CNS organ)
│ ──► NEWS2 (consciousness)
│ ──► qSOFA (altered mentation)
├── SOFA: uses labs + vitals + GCS + medications
│ │
│ └── SOFA_SEPSIS (delta >= 2) ──► Sepsis Bundle (Guide 23)
└── Trend: uses HEART_RATE, RESP_RATE, SYSTOLIC_BP, TEMP_C, SPO2
```
---
## Common Patterns Across All Engines
### Alert Suppression
Warning-level alerts check for suppression before firing:
```csharp
if (alertType == AlertType.News2Warning)
{
var suppression = _services.GetRequiredService<IAlertSuppressionService>();
if (await suppression.IsSuppressedAsync(encounterId, alertType, ct))
return false;
}
```
When a clinician acknowledges a WARNING alert, a Redis suppression key is set for 30 minutes. During that window, the same alert type won't fire again for that encounter. CRITICAL alerts are never suppressed.
### Duplicate Prevention
Every alert creation uses `INSERT ... WHERE NOT EXISTS` to prevent duplicates. If an open alert of the same type already exists for the encounter, no new alert is created.
### Prometheus Metrics
Every engine increments counters and records timing:
```csharp
using var timer = _metrics.SofaScoringDuration.NewTimer();
// ... compute score ...
_metrics.SofaScoresTotal.WithLabels(alertCreated ? "true" : "false").Inc();
```
---
## Key Takeaways
- **Scoring engines are Kafka consumers** — each runs independently, processing the same observation events in parallel
- **Redis aggregates parameters over time** — observations arrive individually; Redis holds partial state until all parameters are present
- **Calculator + Detector separation** — pure scoring logic is testable without infrastructure; orchestration logic handles Redis, PostgreSQL, and Kafka
- **TTL-based expiry prevents stale scores** — parameters automatically expire (30 minutes for qSOFA, 4 hours for NEWS2, 24 hours for SOFA labs)
- **Scoring engines cascade** — GCS feeds into NEWS2, qSOFA, and SOFA. SOFA delta >= 2 triggers sepsis bundles. One observation can ripple through multiple scoring pipelines.
- **Alert suppression prevents alarm fatigue** — warning alerts are silenced for 30 minutes after acknowledgment; critical alerts always fire