Spaces:
Sleeping
Sleeping
Fix: robust OCR parsing and UI label alignment
Browse files- api/alert_logic.py +19 -22
- api/extract_report.py +30 -10
- api/main.py +3 -3
- dashboard/app.js +8 -6
api/alert_logic.py
CHANGED
|
@@ -20,7 +20,7 @@ _alert_history: Dict[str, dict] = {}
|
|
| 20 |
|
| 21 |
# ─── Clinical rule safety net ─────────────────────────────────────────────────
|
| 22 |
|
| 23 |
-
def apply_clinical_safety_net(visits_raw: list) -> tuple[
|
| 24 |
"""
|
| 25 |
Checks raw visit values against WHO clinical thresholds.
|
| 26 |
Returns (forced_tier, reason) if a rule fires, else (None, None).
|
|
@@ -33,16 +33,16 @@ def apply_clinical_safety_net(visits_raw: list) -> tuple[str | None, str | None]
|
|
| 33 |
latest_sys = latest.get("systolic_bp") or 0
|
| 34 |
latest_dia = latest.get("diastolic_bp") or 0
|
| 35 |
|
| 36 |
-
#
|
| 37 |
|
| 38 |
-
# Rule 1: Severe hypertension
|
| 39 |
if max_systolic >= 160 or max_diastolic >= 110:
|
| 40 |
-
return
|
| 41 |
f"SystolicBP {max_systolic} mmHg meets WHO severe "
|
| 42 |
-
f"hypertension threshold (
|
| 43 |
)
|
| 44 |
|
| 45 |
-
# Rule 5: Multi-vital simultaneous escalation
|
| 46 |
if len(visits_raw) >= 3:
|
| 47 |
first = visits_raw[0]
|
| 48 |
last = visits_raw[-1]
|
|
@@ -61,37 +61,37 @@ def apply_clinical_safety_net(visits_raw: list) -> tuple[str | None, str | None]
|
|
| 61 |
if temp_rise >= 0.5: escalating_count += 1
|
| 62 |
|
| 63 |
if escalating_count >= 3:
|
| 64 |
-
return
|
| 65 |
f"{escalating_count} vitals escalating simultaneously "
|
| 66 |
f"(BP +{sys_rise:.0f} mmHg, HR +{hr_rise:.0f} bpm, "
|
| 67 |
-
f"BS +{bs_rise:.1f} mmol/L)
|
| 68 |
f"pattern, refer immediately"
|
| 69 |
)
|
| 70 |
|
| 71 |
-
#
|
| 72 |
|
| 73 |
-
# Rule 2: Hypertension in pregnancy
|
| 74 |
if latest_sys >= 140 or latest_dia >= 90:
|
| 75 |
-
return
|
| 76 |
f"SystolicBP {latest_sys} mmHg meets WHO hypertension "
|
| 77 |
-
f"in pregnancy threshold (
|
| 78 |
)
|
| 79 |
|
| 80 |
-
# Rule 3: Severe hyperglycaemia
|
| 81 |
if max_bs > 11.1:
|
| 82 |
-
return
|
| 83 |
f"Blood sugar {max_bs} mmol/L exceeds gestational "
|
| 84 |
f"diabetes threshold (>11.1)"
|
| 85 |
)
|
| 86 |
|
| 87 |
-
# Rule 4: BP escalation pattern
|
| 88 |
if len(visits_raw) >= 2:
|
| 89 |
first_sys = visits_raw[0].get("systolic_bp") or 0
|
| 90 |
bp_rise = latest_sys - first_sys
|
| 91 |
if bp_rise >= 20:
|
| 92 |
-
return
|
| 93 |
f"SystolicBP rose {bp_rise:.0f} mmHg across visits "
|
| 94 |
-
f"
|
| 95 |
)
|
| 96 |
|
| 97 |
return None, None
|
|
@@ -120,11 +120,8 @@ def compute_alert_tier(
|
|
| 120 |
|
| 121 |
# Determine tier
|
| 122 |
if high_risk_prob >= RED_THRESHOLD:
|
| 123 |
-
|
| 124 |
-
|
| 125 |
-
tier = AlertTier.RED
|
| 126 |
-
else:
|
| 127 |
-
tier = AlertTier.AMBER
|
| 128 |
elif high_risk_prob >= AMBER_THRESHOLD:
|
| 129 |
tier = AlertTier.AMBER
|
| 130 |
else:
|
|
|
|
| 20 |
|
| 21 |
# ─── Clinical rule safety net ─────────────────────────────────────────────────
|
| 22 |
|
| 23 |
+
def apply_clinical_safety_net(visits_raw: list) -> tuple[AlertTier | None, str | None]:
|
| 24 |
"""
|
| 25 |
Checks raw visit values against WHO clinical thresholds.
|
| 26 |
Returns (forced_tier, reason) if a rule fires, else (None, None).
|
|
|
|
| 33 |
latest_sys = latest.get("systolic_bp") or 0
|
| 34 |
latest_dia = latest.get("diastolic_bp") or 0
|
| 35 |
|
| 36 |
+
# --- RED RULES --------------------------------------------------------------
|
| 37 |
|
| 38 |
+
# Rule 1: Severe hypertension -> RED
|
| 39 |
if max_systolic >= 160 or max_diastolic >= 110:
|
| 40 |
+
return AlertTier.RED, (
|
| 41 |
f"SystolicBP {max_systolic} mmHg meets WHO severe "
|
| 42 |
+
f"hypertension threshold (>=160) -- emergency referral required"
|
| 43 |
)
|
| 44 |
|
| 45 |
+
# Rule 5: Multi-vital simultaneous escalation -> RED
|
| 46 |
if len(visits_raw) >= 3:
|
| 47 |
first = visits_raw[0]
|
| 48 |
last = visits_raw[-1]
|
|
|
|
| 61 |
if temp_rise >= 0.5: escalating_count += 1
|
| 62 |
|
| 63 |
if escalating_count >= 3:
|
| 64 |
+
return AlertTier.RED, (
|
| 65 |
f"{escalating_count} vitals escalating simultaneously "
|
| 66 |
f"(BP +{sys_rise:.0f} mmHg, HR +{hr_rise:.0f} bpm, "
|
| 67 |
+
f"BS +{bs_rise:.1f} mmol/L) -- combined deterioration "
|
| 68 |
f"pattern, refer immediately"
|
| 69 |
)
|
| 70 |
|
| 71 |
+
# --- AMBER RULES ------------------------------------------------------------
|
| 72 |
|
| 73 |
+
# Rule 2: Hypertension in pregnancy -> AMBER
|
| 74 |
if latest_sys >= 140 or latest_dia >= 90:
|
| 75 |
+
return AlertTier.AMBER, (
|
| 76 |
f"SystolicBP {latest_sys} mmHg meets WHO hypertension "
|
| 77 |
+
f"in pregnancy threshold (>=140)"
|
| 78 |
)
|
| 79 |
|
| 80 |
+
# Rule 3: Severe hyperglycaemia -> AMBER
|
| 81 |
if max_bs > 11.1:
|
| 82 |
+
return AlertTier.AMBER, (
|
| 83 |
f"Blood sugar {max_bs} mmol/L exceeds gestational "
|
| 84 |
f"diabetes threshold (>11.1)"
|
| 85 |
)
|
| 86 |
|
| 87 |
+
# Rule 4: BP escalation pattern -> AMBER
|
| 88 |
if len(visits_raw) >= 2:
|
| 89 |
first_sys = visits_raw[0].get("systolic_bp") or 0
|
| 90 |
bp_rise = latest_sys - first_sys
|
| 91 |
if bp_rise >= 20:
|
| 92 |
+
return AlertTier.AMBER, (
|
| 93 |
f"SystolicBP rose {bp_rise:.0f} mmHg across visits "
|
| 94 |
+
f"-- escalation pattern detected"
|
| 95 |
)
|
| 96 |
|
| 97 |
return None, None
|
|
|
|
| 120 |
|
| 121 |
# Determine tier
|
| 122 |
if high_risk_prob >= RED_THRESHOLD:
|
| 123 |
+
# If very high confidence, default to RED
|
| 124 |
+
tier = AlertTier.RED
|
|
|
|
|
|
|
|
|
|
| 125 |
elif high_risk_prob >= AMBER_THRESHOLD:
|
| 126 |
tier = AlertTier.AMBER
|
| 127 |
else:
|
api/extract_report.py
CHANGED
|
@@ -340,32 +340,52 @@ def is_skip_line(line: str) -> bool:
|
|
| 340 |
|
| 341 |
|
| 342 |
def parse_table_format(raw_text: str) -> List[dict]:
|
| 343 |
-
"""
|
|
|
|
|
|
|
|
|
|
| 344 |
lines = [l.strip() for l in raw_text.split('\n') if l.strip()]
|
| 345 |
field_vals = {}
|
| 346 |
max_visits = 0
|
| 347 |
|
| 348 |
-
for line in lines:
|
| 349 |
if is_skip_line(line):
|
| 350 |
continue
|
|
|
|
| 351 |
field = match_label(line)
|
| 352 |
if field is None:
|
| 353 |
continue
|
| 354 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 355 |
lo, hi = RANGES[field]
|
| 356 |
valid = [n for n in numbers if lo <= n <= hi]
|
|
|
|
| 357 |
if valid:
|
| 358 |
-
|
| 359 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 360 |
|
| 361 |
if not field_vals:
|
| 362 |
return []
|
| 363 |
|
| 364 |
-
|
| 365 |
-
|
| 366 |
-
|
| 367 |
-
|
| 368 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 369 |
|
| 370 |
|
| 371 |
def parse_column_format(raw_text: str) -> List[dict]:
|
|
|
|
| 340 |
|
| 341 |
|
| 342 |
def parse_table_format(raw_text: str) -> List[dict]:
|
| 343 |
+
"""
|
| 344 |
+
Parser A -- Row-per-field table layout.
|
| 345 |
+
Upgraded to look at subsequent lines if the label line is empty.
|
| 346 |
+
"""
|
| 347 |
lines = [l.strip() for l in raw_text.split('\n') if l.strip()]
|
| 348 |
field_vals = {}
|
| 349 |
max_visits = 0
|
| 350 |
|
| 351 |
+
for idx, line in enumerate(lines):
|
| 352 |
if is_skip_line(line):
|
| 353 |
continue
|
| 354 |
+
|
| 355 |
field = match_label(line)
|
| 356 |
if field is None:
|
| 357 |
continue
|
| 358 |
+
|
| 359 |
+
# Look for numbers in current line, and if none, the next 2 lines
|
| 360 |
+
search_chunk = line
|
| 361 |
+
if idx + 1 < len(lines): search_chunk += " " + lines[idx + 1]
|
| 362 |
+
if idx + 2 < len(lines): search_chunk += " " + lines[idx + 2]
|
| 363 |
+
|
| 364 |
+
numbers = find_numbers(search_chunk)
|
| 365 |
lo, hi = RANGES[field]
|
| 366 |
valid = [n for n in numbers if lo <= n <= hi]
|
| 367 |
+
|
| 368 |
if valid:
|
| 369 |
+
# If we already have values for this field, append or merge
|
| 370 |
+
if field in field_vals:
|
| 371 |
+
field_vals[field].extend(valid)
|
| 372 |
+
else:
|
| 373 |
+
field_vals[field] = valid
|
| 374 |
+
max_visits = max(max_visits, len(field_vals[field]))
|
| 375 |
|
| 376 |
if not field_vals:
|
| 377 |
return []
|
| 378 |
|
| 379 |
+
# Assemble into visits
|
| 380 |
+
results = []
|
| 381 |
+
for i in range(max_visits):
|
| 382 |
+
visit = {}
|
| 383 |
+
for field in RANGES.keys():
|
| 384 |
+
vals = field_vals.get(field, [])
|
| 385 |
+
visit[field] = vals[i] if i < len(vals) else None
|
| 386 |
+
results.append(visit)
|
| 387 |
+
|
| 388 |
+
return results
|
| 389 |
|
| 390 |
|
| 391 |
def parse_column_format(raw_text: str) -> List[dict]:
|
api/main.py
CHANGED
|
@@ -160,10 +160,10 @@ async def predict(request: PredictionRequest):
|
|
| 160 |
)
|
| 161 |
|
| 162 |
# Override with clinical rule minimum if needed
|
| 163 |
-
tier_order = {
|
| 164 |
if forced_tier is not None:
|
| 165 |
-
if tier_order[forced_tier] > tier_order[alert_tier
|
| 166 |
-
alert_tier =
|
| 167 |
suppressed = False
|
| 168 |
|
| 169 |
# Action text
|
|
|
|
| 160 |
)
|
| 161 |
|
| 162 |
# Override with clinical rule minimum if needed
|
| 163 |
+
tier_order = {AlertTier.GREEN: 0, AlertTier.AMBER: 1, AlertTier.RED: 2}
|
| 164 |
if forced_tier is not None:
|
| 165 |
+
if tier_order[forced_tier] > tier_order[alert_tier]:
|
| 166 |
+
alert_tier = forced_tier
|
| 167 |
suppressed = False
|
| 168 |
|
| 169 |
# Action text
|
dashboard/app.js
CHANGED
|
@@ -175,6 +175,7 @@ async function extractFromImage() {
|
|
| 175 |
}
|
| 176 |
|
| 177 |
const data = await resp.json();
|
|
|
|
| 178 |
|
| 179 |
if (data.patient_id) {
|
| 180 |
const pidField = document.getElementById("patient_id");
|
|
@@ -184,6 +185,7 @@ async function extractFromImage() {
|
|
| 184 |
}
|
| 185 |
}
|
| 186 |
|
|
|
|
| 187 |
document.getElementById("visits-container").innerHTML = "";
|
| 188 |
visitCount = 0;
|
| 189 |
|
|
@@ -202,12 +204,12 @@ async function extractFromImage() {
|
|
| 202 |
<span style="color:${confColor};font-weight:600;">
|
| 203 |
Extraction confidence: ${confPct}%
|
| 204 |
</span>
|
| 205 |
-
${confPct < 80 ? " --
|
| 206 |
-
${data.notes ? `<br><
|
| 207 |
`;
|
| 208 |
|
| 209 |
statusEl.className = "extract-status success";
|
| 210 |
-
statusEl.textContent = `Extracted ${visits.length} visit${visits.length > 1 ? "s" : ""}
|
| 211 |
|
| 212 |
} catch (err) {
|
| 213 |
statusEl.className = "extract-status error";
|
|
@@ -284,9 +286,9 @@ function showResult(data) {
|
|
| 284 |
const tierClass = { GREEN: "alert-green", AMBER: "alert-amber", RED: "alert-red" };
|
| 285 |
const badgeClass = { GREEN: "badge-green", AMBER: "badge-amber", RED: "badge-red" };
|
| 286 |
const tierLabel = {
|
| 287 |
-
GREEN: "Low risk",
|
| 288 |
-
AMBER: "
|
| 289 |
-
RED: "
|
| 290 |
};
|
| 291 |
|
| 292 |
const card = document.getElementById("result-card");
|
|
|
|
| 175 |
}
|
| 176 |
|
| 177 |
const data = await resp.json();
|
| 178 |
+
console.log("Extraction successful:", data);
|
| 179 |
|
| 180 |
if (data.patient_id) {
|
| 181 |
const pidField = document.getElementById("patient_id");
|
|
|
|
| 185 |
}
|
| 186 |
}
|
| 187 |
|
| 188 |
+
// Clear existing visits
|
| 189 |
document.getElementById("visits-container").innerHTML = "";
|
| 190 |
visitCount = 0;
|
| 191 |
|
|
|
|
| 204 |
<span style="color:${confColor};font-weight:600;">
|
| 205 |
Extraction confidence: ${confPct}%
|
| 206 |
</span>
|
| 207 |
+
${confPct < 80 ? " -- verify highlighted fields" : " -- extraction successful"}
|
| 208 |
+
${data.notes ? `<br><small>${data.notes}</small>` : ""}
|
| 209 |
`;
|
| 210 |
|
| 211 |
statusEl.className = "extract-status success";
|
| 212 |
+
statusEl.textContent = `Extracted ${visits.length} visit${visits.length > 1 ? "s" : ""}. Highlighted fields are auto-filled.`;
|
| 213 |
|
| 214 |
} catch (err) {
|
| 215 |
statusEl.className = "extract-status error";
|
|
|
|
| 286 |
const tierClass = { GREEN: "alert-green", AMBER: "alert-amber", RED: "alert-red" };
|
| 287 |
const badgeClass = { GREEN: "badge-green", AMBER: "badge-amber", RED: "badge-red" };
|
| 288 |
const tierLabel = {
|
| 289 |
+
GREEN: "Normal / Low risk",
|
| 290 |
+
AMBER: "Elevated risk detected",
|
| 291 |
+
RED: "Critical risk -- REFER IMMEDIATELY"
|
| 292 |
};
|
| 293 |
|
| 294 |
const card = document.getElementById("result-card");
|