feat: implement 4-point demographic synchronization & patient validation
This commit is contained in:
@@ -133,7 +133,7 @@ def parse_contec_hl7_text(raw_text: str, client_ip: str) -> Optional[NormalizedV
|
||||
|
||||
found_any = False
|
||||
|
||||
# Parse PID and OBX segments
|
||||
# Parse PID, PV1 and OBX segments
|
||||
for seg in segments[1:]:
|
||||
fields = seg.split("|")
|
||||
if not fields:
|
||||
@@ -142,10 +142,37 @@ def parse_contec_hl7_text(raw_text: str, client_ip: str) -> Optional[NormalizedV
|
||||
seg_name = fields[0]
|
||||
|
||||
if seg_name == "PID":
|
||||
# PID-3: MRN / Patient ID
|
||||
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
|
||||
|
||||
# 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":
|
||||
if len(fields) > 5:
|
||||
obs_val = fields[5].strip()
|
||||
|
||||
@@ -261,11 +261,29 @@ async def process_vitals(vitals):
|
||||
device.status = "active"
|
||||
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()
|
||||
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)
|
||||
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
|
||||
reading = VitalReading(
|
||||
|
||||
@@ -13,8 +13,12 @@ class Device(Base):
|
||||
class Patient(Base):
|
||||
__tablename__ = "patients"
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
patient_id = Column(String, unique=True, index=True)
|
||||
name = Column(String)
|
||||
patient_id = Column(String, unique=True, index=True) # MRN from monitor PID-3
|
||||
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):
|
||||
__tablename__ = "vital_readings"
|
||||
|
||||
@@ -269,3 +269,170 @@ async def push_vitals(vitals: NormalizedVitals, api_key: str = None, db: Session
|
||||
logger.error(f"Push-vitals error: {e}")
|
||||
db.rollback()
|
||||
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,
|
||||
}
|
||||
|
||||
|
||||
@@ -15,6 +15,11 @@ class NormalizedVitals(BaseModel):
|
||||
respiratory_rate: Optional[float] = None
|
||||
temperature: Optional[float] = 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):
|
||||
device_id: str
|
||||
|
||||
@@ -759,13 +759,67 @@
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
/* === Scrollbar === */
|
||||
::-webkit-scrollbar { width: 6px; }
|
||||
::-webkit-scrollbar-track { background: transparent; }
|
||||
::-webkit-scrollbar-thumb { background: rgba(0,0,0,0.1); border-radius: 3px; }
|
||||
/* === Verification Failure Overlay === */
|
||||
.verification-overlay {
|
||||
position: fixed;
|
||||
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>
|
||||
</head>
|
||||
<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-glow"></div>
|
||||
|
||||
@@ -1025,6 +1079,65 @@
|
||||
let activeDevices = [];
|
||||
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 ===
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
@@ -1033,17 +1146,49 @@
|
||||
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();
|
||||
updateClock();
|
||||
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(() => {
|
||||
tryWebSocket();
|
||||
startPolling();
|
||||
});
|
||||
animateWaveforms();
|
||||
fetchNetworkInfo();
|
||||
// Periodically refresh the list of active devices
|
||||
setInterval(fetchDevices, 4000);
|
||||
}
|
||||
|
||||
animateWaveforms();
|
||||
fetchNetworkInfo();
|
||||
});
|
||||
|
||||
// === Fetch Network Info ===
|
||||
@@ -1250,14 +1395,16 @@
|
||||
ws.onmessage = (event) => {
|
||||
const data = JSON.parse(event.data);
|
||||
// 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);
|
||||
}
|
||||
if (data.device_id === selectedDeviceId) {
|
||||
if (data.device_id && data.device_id === selectedDeviceId) {
|
||||
updateVitals(data);
|
||||
}
|
||||
// Refresh device list status
|
||||
if (!isMatchingMode) {
|
||||
fetchDevices();
|
||||
}
|
||||
};
|
||||
ws.onclose = () => {
|
||||
useWebSocket = false;
|
||||
@@ -1279,6 +1426,9 @@
|
||||
}
|
||||
|
||||
async function fetchLatestReadings() {
|
||||
if (isMatchingMode && !selectedDeviceId) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const url = selectedDeviceId
|
||||
? `${API_BASE}/latest-readings?device_id=${encodeURIComponent(selectedDeviceId)}&limit=1`
|
||||
@@ -1304,6 +1454,7 @@
|
||||
}
|
||||
|
||||
// Fetch system status
|
||||
if (!isMatchingMode) {
|
||||
const statusResp = await fetch(`${API_BASE}/status`);
|
||||
if (statusResp.ok) {
|
||||
const status = await statusResp.json();
|
||||
@@ -1319,6 +1470,7 @@
|
||||
portsEl.textContent = status.active_ports.join(', ');
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
if (!useWebSocket) {
|
||||
updateConnectionStatus('offline', 'Server offline');
|
||||
|
||||
Reference in New Issue
Block a user