feat: implement 4-point demographic synchronization & patient validation

This commit is contained in:
2026-07-15 11:33:48 +05:30
parent ae5a928671
commit 554771abfe
6 changed files with 407 additions and 34 deletions
+29 -2
View File
@@ -133,7 +133,7 @@ def parse_contec_hl7_text(raw_text: str, client_ip: str) -> Optional[NormalizedV
found_any = False found_any = False
# Parse PID and OBX segments # Parse PID, PV1 and OBX segments
for seg in segments[1:]: for seg in segments[1:]:
fields = seg.split("|") fields = seg.split("|")
if not fields: if not fields:
@@ -142,10 +142,37 @@ def parse_contec_hl7_text(raw_text: str, client_ip: str) -> Optional[NormalizedV
seg_name = fields[0] seg_name = fields[0]
if seg_name == "PID": if seg_name == "PID":
# PID-3: MRN / Patient ID
if len(fields) > 3 and fields[3]: if len(fields) > 3 and fields[3]:
patient_id = fields[3].split("^")[0] patient_id = fields[3].split("^")[0].strip()
vitals_dict["patient_id"] = patient_id vitals_dict["patient_id"] = patient_id
# PID-5: Patient Name (format: FamilyName^GivenName^...)
if len(fields) > 5 and fields[5]:
name_parts = fields[5].split("^")
family = name_parts[0].strip() if len(name_parts) > 0 else ""
given = name_parts[1].strip() if len(name_parts) > 1 else ""
full_name = f"{given} {family}".strip() or family or given
if full_name:
vitals_dict["patient_name"] = full_name
# PID-7: Date of Birth (YYYYMMDD or YYYY-MM-DD)
if len(fields) > 7 and fields[7]:
vitals_dict["patient_dob"] = fields[7].strip()
# PID-8: Administrative Sex (M / F / U / O)
if len(fields) > 8 and fields[8]:
vitals_dict["patient_gender"] = fields[8].strip().upper()
elif seg_name == "PV1":
# PV1-3: Assigned Patient Location (Room^Bed^...)
if len(fields) > 3 and fields[3]:
location_parts = fields[3].split("^")
# Use second component (Bed) if present, else first (Room)
bed = location_parts[1].strip() if len(location_parts) > 1 else location_parts[0].strip()
if bed:
vitals_dict["bed_id"] = bed
elif seg_name == "OBX": elif seg_name == "OBX":
if len(fields) > 5: if len(fields) > 5:
obs_val = fields[5].strip() obs_val = fields[5].strip()
+20 -2
View File
@@ -261,11 +261,29 @@ async def process_vitals(vitals):
device.status = "active" device.status = "active"
device.last_seen = datetime.now(timezone.utc) device.last_seen = datetime.now(timezone.utc)
# 2. Patient tracking # 2. Patient tracking — store/update demographics from HL7
patient = db.query(Patient).filter(Patient.patient_id == vitals.patient_id).first() patient = db.query(Patient).filter(Patient.patient_id == vitals.patient_id).first()
if not patient: if not patient:
patient = Patient(patient_id=vitals.patient_id, name="Unknown") patient = Patient(
patient_id=vitals.patient_id,
mrn=vitals.patient_id,
name=vitals.patient_name or "Unknown",
gender=vitals.patient_gender,
dob=vitals.patient_dob,
bed_id=vitals.bed_id,
)
db.add(patient) db.add(patient)
else:
# Refresh demographics whenever monitor sends updated PID fields
if vitals.patient_name:
patient.name = vitals.patient_name
if vitals.patient_gender:
patient.gender = vitals.patient_gender
if vitals.patient_dob:
patient.dob = vitals.patient_dob
if vitals.bed_id:
patient.bed_id = vitals.bed_id
# 3. Create Reading Entry # 3. Create Reading Entry
reading = VitalReading( reading = VitalReading(
+6 -2
View File
@@ -13,8 +13,12 @@ class Device(Base):
class Patient(Base): class Patient(Base):
__tablename__ = "patients" __tablename__ = "patients"
id = Column(Integer, primary_key=True, index=True) id = Column(Integer, primary_key=True, index=True)
patient_id = Column(String, unique=True, index=True) patient_id = Column(String, unique=True, index=True) # MRN from monitor PID-3
name = Column(String) mrn = Column(String, index=True, nullable=True) # explicit MRN (same as patient_id)
name = Column(String, nullable=True) # full name from PID-5
gender = Column(String, nullable=True) # M/F/U from PID-8
dob = Column(String, nullable=True) # YYYYMMDD from PID-7
bed_id = Column(String, nullable=True) # bed from PV1-3
class VitalReading(Base): class VitalReading(Base):
__tablename__ = "vital_readings" __tablename__ = "vital_readings"
+167
View File
@@ -269,3 +269,170 @@ async def push_vitals(vitals: NormalizedVitals, api_key: str = None, db: Session
logger.error(f"Push-vitals error: {e}") logger.error(f"Push-vitals error: {e}")
db.rollback() db.rollback()
raise HTTPException(status_code=500, detail=f"Failed to store vitals: {str(e)}") raise HTTPException(status_code=500, detail=f"Failed to store vitals: {str(e)}")
# === Patient-to-Monitor Matching Endpoint ===
def _normalize_name(s: str) -> str:
"""Lowercase, strip punctuation and extra spaces for fuzzy name comparison."""
import re
if not s:
return ""
return re.sub(r"[^a-z0-9\s]", "", s.lower()).split()
def _normalize_dob(dob: str) -> str:
"""Normalize DOB to YYYYMMDD regardless of input format (YYYY-MM-DD / YYYYMMDD / DD-MM-YYYY)."""
if not dob:
return ""
dob = dob.strip().replace("/", "-")
if len(dob) == 8 and dob.isdigit():
return dob # already YYYYMMDD
parts = dob.split("-")
if len(parts) == 3:
if len(parts[0]) == 4: # YYYY-MM-DD
return "".join(parts)
elif len(parts[2]) == 4: # DD-MM-YYYY
return parts[2] + parts[1] + parts[0]
return dob.replace("-", "")
def _gender_code(g: str) -> str:
"""Return single-letter gender code: M / F / U."""
if not g:
return ""
g = g.strip().upper()
if g in ("M", "MALE"):
return "M"
if g in ("F", "FEMALE"):
return "F"
return "U"
def _calculate_age(dob_str: str) -> Optional[int]:
"""Calculate age from YYYYMMDD string."""
if not dob_str:
return None
normalized = _normalize_dob(dob_str)
if len(normalized) != 8 or not normalized.isdigit():
return None
try:
birth_year = int(normalized[:4])
birth_month = int(normalized[4:6])
birth_day = int(normalized[6:])
today = datetime.now()
return today.year - birth_year - ((today.month, today.day) < (birth_month, birth_day))
except Exception:
return None
@router.get("/match-patient")
def match_patient(
mrn: str = None,
name: str = None,
gender: str = None,
dob: str = None,
age: int = None,
db: Session = Depends(get_db)
):
"""
Match incoming patient demographics to an active Contec monitor.
Call this from the dashboard iframe URL:
/api/dashboard?minimal=true&mrn=MRN001&name=John+Doe&gender=M&dob=19900115&age=36
Returns:
{ matched: true/false, device_id, patient_id, bed_id, device_status, ... }
"""
if not mrn and not name:
return {"matched": False, "reason": "No patient identifiers supplied"}
# Fetch all patients
all_patients = db.query(Patient).all()
now = datetime.now(timezone.utc)
# Normalize incoming params
in_mrn = (mrn or "").strip().upper()
in_name = _normalize_name(name or "")
in_gender = _gender_code(gender or "")
in_dob = _normalize_dob(dob or "")
in_age = age
matched_patient = None
match_score = 0
for p in all_patients:
score = 0
# --- MRN check (primary) ---
p_mrn = (p.mrn or p.patient_id or "").strip().upper()
if in_mrn and p_mrn == in_mrn:
score += 10
elif in_mrn:
continue # MRN supplied but doesn't match → skip
# --- Name check ---
if in_name:
p_name = _normalize_name(p.name or "")
if p_name and any(w in p_name for w in in_name):
score += 3
# --- Gender check ---
if in_gender and p.gender:
if _gender_code(p.gender) == in_gender:
score += 2
# --- DOB check ---
if in_dob and p.dob:
if _normalize_dob(p.dob) == in_dob:
score += 3
# --- Age check ---
if in_age is not None and p.dob:
calc_p_age = _calculate_age(p.dob)
if calc_p_age is not None and calc_p_age == in_age:
score += 2
if score > match_score:
match_score = score
matched_patient = p
# To be matched, MRN must be found, and name/gender/dob must also be validated
# (Require at least a score of 12: MRN + Name, or MRN + Gender, etc.)
if not matched_patient or match_score < 12:
return {"matched": False, "reason": "No matching patient found in monitor database with 4-point verification"}
# Find the most recent vital reading for this patient
latest = (
db.query(VitalReading)
.filter(VitalReading.patient_id == matched_patient.patient_id)
.order_by(VitalReading.timestamp.desc())
.first()
)
if not latest:
return {
"matched": True,
"patient_id": matched_patient.patient_id,
"device_id": None,
"bed_id": matched_patient.bed_id,
"reason": "Patient found but no readings yet"
}
# Get device status
device = db.query(Device).filter(Device.device_id == latest.device_id).first()
device_status = "unknown"
if device:
last_seen = device.last_seen
if last_seen.tzinfo is None:
last_seen = last_seen.replace(tzinfo=timezone.utc)
device_status = "active" if (now - last_seen).total_seconds() < 15 else "offline"
return {
"matched": True,
"patient_id": matched_patient.patient_id,
"patient_name": matched_patient.name,
"patient_gender": matched_patient.gender,
"patient_dob": matched_patient.dob,
"device_id": latest.device_id,
"bed_id": matched_patient.bed_id,
"device_status": device_status,
"last_reading_at": latest.timestamp.isoformat() if latest.timestamp else None,
"match_score": match_score,
}
+5
View File
@@ -15,6 +15,11 @@ class NormalizedVitals(BaseModel):
respiratory_rate: Optional[float] = None respiratory_rate: Optional[float] = None
temperature: Optional[float] = None temperature: Optional[float] = None
present_fields: Optional[list] = None present_fields: Optional[list] = None
# Patient demographics parsed from HL7 PID/PV1 segments
patient_name: Optional[str] = None # PID-5: "Given Family"
patient_gender: Optional[str] = None # PID-8: M / F / U
patient_dob: Optional[str] = None # PID-7: YYYYMMDD
bed_id: Optional[str] = None # PV1-3: bed / room ID
class DeviceStatus(BaseModel): class DeviceStatus(BaseModel):
device_id: str device_id: str
+160 -8
View File
@@ -759,13 +759,67 @@
display: none !important; display: none !important;
} }
/* === Scrollbar === */ /* === Verification Failure Overlay === */
::-webkit-scrollbar { width: 6px; } .verification-overlay {
::-webkit-scrollbar-track { background: transparent; } position: fixed;
::-webkit-scrollbar-thumb { background: rgba(0,0,0,0.1); border-radius: 3px; } inset: 0;
background: rgba(248, 250, 252, 0.98);
z-index: 9999;
display: flex;
align-items: center;
justify-content: center;
flex-direction: column;
text-align: center;
padding: 24px;
color: var(--text-primary);
}
.verification-overlay .error-card {
background: #ffffff;
border: 1px solid rgba(220, 38, 38, 0.12);
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.05);
border-radius: 16px;
padding: 32px;
max-width: 460px;
width: 100%;
transition: all 0.3s ease;
}
.verification-overlay .error-icon {
font-size: 40px;
margin-bottom: 16px;
}
.verification-overlay h2 {
font-size: 19px;
font-weight: 700;
margin-bottom: 8px;
color: #dc2626;
}
.verification-overlay p {
font-size: 13px;
color: var(--text-secondary);
line-height: 1.6;
}
.verification-overlay.loading h2 {
color: var(--spo2-color);
}
.verification-overlay.loading .error-icon {
animation: rotateGlow 2s linear infinite;
}
@keyframes rotateGlow {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
</style> </style>
</head> </head>
<body> <body>
<!-- Patient Verification Overlay -->
<div id="verificationOverlay" class="verification-overlay" style="display: none;">
<div class="error-card" id="verificationCard">
<div class="error-icon" id="verificationIcon">🔒</div>
<h2 id="verificationTitle">Patient Verification Required</h2>
<p id="verificationMsg">Telemetry data is only displayed once the patient's MRN, Name, Gender, and DOB are fully verified against the active monitor.</p>
</div>
</div>
<div class="bg-grid"></div> <div class="bg-grid"></div>
<div class="bg-glow"></div> <div class="bg-glow"></div>
@@ -1025,6 +1079,65 @@
let activeDevices = []; let activeDevices = [];
let selectedDeviceId = localStorage.getItem('selectedDeviceId') || ''; let selectedDeviceId = localStorage.getItem('selectedDeviceId') || '';
// Verification / Matching Mode State
let isMatchingMode = false;
let matchParams = {};
async function verifyPatientMatch() {
const overlay = document.getElementById('verificationOverlay');
const icon = document.getElementById('verificationIcon');
const title = document.getElementById('verificationTitle');
const msg = document.getElementById('verificationMsg');
try {
const query = new URLSearchParams(matchParams).toString();
const response = await fetch(`${API_BASE}/match-patient?${query}`);
if (!response.ok) throw new Error('Match verification API error');
const data = await response.json();
if (data.matched) {
if (data.device_id) {
// Success - matched and connected!
overlay.style.display = 'none';
// Hide device selector element to restrict access
const selectorContainer = document.querySelector('.device-selector-container');
if (selectorContainer) selectorContainer.style.display = 'none';
if (selectedDeviceId !== data.device_id) {
selectedDeviceId = data.device_id;
clearVitalsDisplay();
}
} else {
// Matched but device not transmitting yet
overlay.style.display = 'flex';
overlay.className = 'verification-overlay loading';
icon.textContent = '⏳';
title.textContent = 'Connecting Monitor...';
msg.textContent = `Patient ${data.patient_name || 'Record'} verified. Waiting for monitor to start transmitting...`;
selectedDeviceId = '';
clearVitalsDisplay();
}
} else {
// Match failed
overlay.style.display = 'flex';
overlay.className = 'verification-overlay';
icon.textContent = '❌';
title.textContent = 'Access Restricted';
msg.textContent = data.reason || 'This patient record does not match the active monitor configuration.';
selectedDeviceId = '';
clearVitalsDisplay();
}
} catch (e) {
console.error("Match verification failed:", e);
// On API error, show connecting/retry state
overlay.style.display = 'flex';
overlay.className = 'verification-overlay loading';
icon.textContent = '🔄';
title.textContent = 'Verification Server Offline';
msg.textContent = 'Retrying verification match...';
}
}
// === Initialize === // === Initialize ===
document.addEventListener('DOMContentLoaded', () => { document.addEventListener('DOMContentLoaded', () => {
const urlParams = new URLSearchParams(window.location.search); const urlParams = new URLSearchParams(window.location.search);
@@ -1033,17 +1146,49 @@
document.body.classList.add('minimal-mode'); document.body.classList.add('minimal-mode');
} }
// Extract matching credentials from URL query params
const mrn = urlParams.get('mrn');
const name = urlParams.get('name');
const gender = urlParams.get('gender');
const dob = urlParams.get('dob');
const age = urlParams.get('age');
if (mrn || name) {
isMatchingMode = true;
matchParams = { mrn, name, gender, dob, age };
// Show verification loading overlay initially
const overlay = document.getElementById('verificationOverlay');
overlay.style.display = 'flex';
overlay.className = 'verification-overlay loading';
document.getElementById('verificationIcon').textContent = '🔄';
document.getElementById('verificationTitle').textContent = 'Verifying Patient Credentials...';
document.getElementById('verificationMsg').textContent = 'Matching MRN, Name, Gender, DOB, and Age with live patient monitors...';
}
initWaveforms(); initWaveforms();
updateClock(); updateClock();
setInterval(updateClock, 1000); setInterval(updateClock, 1000);
if (isMatchingMode) {
// In matching mode, verify credentials first, then poll/ws
verifyPatientMatch().then(() => {
tryWebSocket();
startPolling();
});
// Periodically verify the match and update device mapping
setInterval(verifyPatientMatch, 5000);
} else {
fetchDevices().then(() => { fetchDevices().then(() => {
tryWebSocket(); tryWebSocket();
startPolling(); startPolling();
}); });
animateWaveforms();
fetchNetworkInfo();
// Periodically refresh the list of active devices // Periodically refresh the list of active devices
setInterval(fetchDevices, 4000); setInterval(fetchDevices, 4000);
}
animateWaveforms();
fetchNetworkInfo();
}); });
// === Fetch Network Info === // === Fetch Network Info ===
@@ -1250,14 +1395,16 @@
ws.onmessage = (event) => { ws.onmessage = (event) => {
const data = JSON.parse(event.data); const data = JSON.parse(event.data);
// Filter messages by selected device or auto-select if nothing selected yet // Filter messages by selected device or auto-select if nothing selected yet
if (!selectedDeviceId && data.device_id) { if (!selectedDeviceId && data.device_id && !isMatchingMode) {
selectDevice(data.device_id); selectDevice(data.device_id);
} }
if (data.device_id === selectedDeviceId) { if (data.device_id && data.device_id === selectedDeviceId) {
updateVitals(data); updateVitals(data);
} }
// Refresh device list status // Refresh device list status
if (!isMatchingMode) {
fetchDevices(); fetchDevices();
}
}; };
ws.onclose = () => { ws.onclose = () => {
useWebSocket = false; useWebSocket = false;
@@ -1279,6 +1426,9 @@
} }
async function fetchLatestReadings() { async function fetchLatestReadings() {
if (isMatchingMode && !selectedDeviceId) {
return;
}
try { try {
const url = selectedDeviceId const url = selectedDeviceId
? `${API_BASE}/latest-readings?device_id=${encodeURIComponent(selectedDeviceId)}&limit=1` ? `${API_BASE}/latest-readings?device_id=${encodeURIComponent(selectedDeviceId)}&limit=1`
@@ -1304,6 +1454,7 @@
} }
// Fetch system status // Fetch system status
if (!isMatchingMode) {
const statusResp = await fetch(`${API_BASE}/status`); const statusResp = await fetch(`${API_BASE}/status`);
if (statusResp.ok) { if (statusResp.ok) {
const status = await statusResp.json(); const status = await statusResp.json();
@@ -1319,6 +1470,7 @@
portsEl.textContent = status.active_ports.join(', '); portsEl.textContent = status.active_ports.join(', ');
} }
} }
}
} catch (e) { } catch (e) {
if (!useWebSocket) { if (!useWebSocket) {
updateConnectionStatus('offline', 'Server offline'); updateConnectionStatus('offline', 'Server offline');