File size: 19,349 Bytes
af61b34
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
"""
FHIR Context Loader (Phase 3A).

Builds a PatientContext from FHIR R4 resources for context-aware risk rules.

Key features:
  - Minimum necessary principle: only fetch resources needed for the active wedge
  - Lookback periods: filter by date relevance
  - Freshness detection: warn when clinical data is stale
  - ICD-10 condition matching for risk rule evaluation
  - Medication count for polypharmacy detection
  - Pre-populated slot extraction for journey agendas

This module provides:
  1. PatientContext dataclass — normalized patient data for risk evaluation
  2. PatientContextBuilder — builds context from raw FHIR bundles
  3. FHIRClient (abstract) — interface for FHIR data fetching (Phase 3 full impl)

Safety invariants:
  - Missing context = fail closed (context-dependent rules don't fire)
  - Stale data is flagged but still used (better than no data)
  - PHI is never logged — only ICD codes and counts
"""

from __future__ import annotations

import logging
from dataclasses import dataclass, field
from datetime import datetime, timedelta, timezone
from typing import Any, Dict, List, Optional, Set

from decision.engine.config_loader import DecisionConfigLoader

logger = logging.getLogger("decision.fhir_context")


# ---------------------------------------------------------------------------
# Patient Context
# ---------------------------------------------------------------------------

@dataclass
class PatientContext:
    """
    Normalized patient context for clinical decision-making.

    Built from FHIR resources. Used by RiskClassifier for context-aware rules.
    """
    patient_id: str = ""

    # Conditions (ICD-10 codes of active problems)
    active_conditions: List[str] = field(default_factory=list)
    # Convenience sets for common condition groups
    has_chf: bool = False             # I50.*
    has_copd: bool = False            # J44.*
    has_diabetes: bool = False        # E10.*, E11.*
    has_ckd: bool = False             # N18.*
    has_cancer: bool = False          # C00-C97
    has_afib: bool = False            # I48.*
    has_depression: bool = False      # F32.*, F33.*
    has_anxiety: bool = False         # F41.*

    # Medications
    active_medication_count: int = 0
    active_medication_names: List[str] = field(default_factory=list)
    is_on_anticoagulant: bool = False
    is_on_insulin: bool = False
    is_on_opioid: bool = False

    # Demographics
    age: Optional[int] = None
    is_elderly: bool = False          # 65+
    is_very_elderly: bool = False     # 75+

    # Recent vitals
    last_bp_systolic: Optional[float] = None
    last_bp_diastolic: Optional[float] = None
    last_heart_rate: Optional[float] = None
    last_weight_kg: Optional[float] = None
    last_glucose: Optional[float] = None
    last_temperature: Optional[float] = None

    # Recent encounter
    days_since_discharge: Optional[int] = None
    discharge_diagnosis_codes: List[str] = field(default_factory=list)

    # Allergies
    allergy_count: int = 0
    drug_allergies: List[str] = field(default_factory=list)

    # Appointments
    has_upcoming_appointment: bool = False
    next_appointment_days: Optional[int] = None

    # Freshness / staleness warnings
    stale_resources: List[str] = field(default_factory=list)
    context_timestamp: Optional[datetime] = None

    def to_risk_context(self) -> Dict[str, Any]:
        """
        Convert to the dict format expected by RiskClassifier.assess(patient_context=...).
        """
        return {
            "patient_id": self.patient_id,
            "active_conditions": self.active_conditions,
            "active_medication_count": self.active_medication_count,
            "has_chf": self.has_chf,
            "has_copd": self.has_copd,
            "has_diabetes": self.has_diabetes,
            "has_ckd": self.has_ckd,
            "has_cancer": self.has_cancer,
            "has_afib": self.has_afib,
            "has_depression": self.has_depression,
            "has_anxiety": self.has_anxiety,
            "is_on_anticoagulant": self.is_on_anticoagulant,
            "is_on_insulin": self.is_on_insulin,
            "is_on_opioid": self.is_on_opioid,
            "age": self.age,
            "is_elderly": self.is_elderly,
            "is_very_elderly": self.is_very_elderly,
            "days_since_discharge": self.days_since_discharge,
            "stale_resources": self.stale_resources,
        }


# ---------------------------------------------------------------------------
# Patient Context Builder
# ---------------------------------------------------------------------------

# ICD-10 pattern groups for condition detection
_CONDITION_GROUPS = {
    "has_chf": ["I50"],
    "has_copd": ["J44"],
    "has_diabetes": ["E10", "E11"],
    "has_ckd": ["N18"],
    "has_cancer": [f"C{i:02d}" for i in range(98)],  # C00-C97
    "has_afib": ["I48"],
    "has_depression": ["F32", "F33"],
    "has_anxiety": ["F41"],
}

# Medication class detection (by name substring)
_ANTICOAGULANTS = {"warfarin", "coumadin", "heparin", "enoxaparin", "lovenox",
                   "apixaban", "eliquis", "rivaroxaban", "xarelto", "dabigatran", "pradaxa"}
_INSULINS = {"insulin", "humalog", "novolog", "lantus", "levemir", "tresiba",
             "basaglar", "admelog", "fiasp", "toujeo"}
_OPIOIDS = {"oxycodone", "hydrocodone", "morphine", "fentanyl", "codeine",
            "tramadol", "methadone", "hydromorphone", "oxymorphone", "dilaudid",
            "percocet", "vicodin", "norco"}


class PatientContextBuilder:
    """
    Builds a PatientContext from FHIR resource bundles.

    Usage:
        builder = PatientContextBuilder(config)
        context = builder.build_from_fhir(
            patient_id="P-123",
            fhir_bundle=bundle_dict,
            wedge_type="pde",
        )
    """

    def __init__(self, config: DecisionConfigLoader):
        self._config = config
        self._fhir_mappings = config.fhir_mappings
        self._freshness_thresholds = self._fhir_mappings.get("freshness_thresholds", {})

    def build_from_fhir(
        self,
        patient_id: str,
        fhir_bundle: Dict[str, Any],
        wedge_type: Optional[str] = None,
    ) -> PatientContext:
        """
        Build PatientContext from a FHIR bundle response.

        Args:
            patient_id: Patient identifier
            fhir_bundle: FHIR Bundle resource (or dict of resource lists)
            wedge_type: Active wedge for minimum necessary filtering

        Returns:
            PatientContext ready for risk rule evaluation
        """
        ctx = PatientContext(
            patient_id=patient_id,
            context_timestamp=datetime.now(timezone.utc),
        )

        # Extract resources from bundle
        resources = self._extract_resources(fhir_bundle)

        # Build context from each resource type
        self._process_patient(ctx, resources.get("Patient", []))
        self._process_conditions(ctx, resources.get("Condition", []))
        self._process_medications(ctx, resources.get("MedicationRequest", []))
        self._process_observations(ctx, resources.get("Observation", []))
        self._process_allergies(ctx, resources.get("AllergyIntolerance", []))
        self._process_encounters(ctx, resources.get("Encounter", []))
        self._process_appointments(ctx, resources.get("Appointment", []))

        # Check freshness
        self._check_freshness(ctx, resources)

        logger.info(
            "PatientContext built: patient=%s conditions=%d meds=%d age=%s chf=%s copd=%s dm=%s",
            patient_id,
            len(ctx.active_conditions),
            ctx.active_medication_count,
            ctx.age,
            ctx.has_chf,
            ctx.has_copd,
            ctx.has_diabetes,
        )

        return ctx

    def build_from_dict(
        self,
        patient_id: str,
        data: Dict[str, Any],
    ) -> PatientContext:
        """
        Build PatientContext from a pre-processed dict (e.g., from API request).

        This allows callers to pass patient context directly without FHIR.
        """
        ctx = PatientContext(
            patient_id=patient_id,
            context_timestamp=datetime.now(timezone.utc),
        )

        ctx.active_conditions = data.get("active_conditions", [])
        ctx.active_medication_count = data.get("active_medication_count", 0)
        ctx.active_medication_names = data.get("active_medication_names", [])
        ctx.age = data.get("age")
        ctx.days_since_discharge = data.get("days_since_discharge")

        # Derive flags from conditions
        self._set_condition_flags(ctx)

        # Derive flags from medications
        self._set_medication_flags(ctx)

        # Age flags
        if ctx.age:
            ctx.is_elderly = ctx.age >= 65
            ctx.is_very_elderly = ctx.age >= 75

        return ctx

    # ------------------------------------------------------------------
    # Resource processing
    # ------------------------------------------------------------------

    def _extract_resources(
        self, bundle: Dict[str, Any]
    ) -> Dict[str, List[Dict[str, Any]]]:
        """Extract resources from a FHIR Bundle, grouped by resourceType."""
        resources: Dict[str, List[Dict[str, Any]]] = {}

        # Handle standard FHIR Bundle format
        entries = bundle.get("entry", [])
        for entry in entries:
            resource = entry.get("resource", {})
            rtype = resource.get("resourceType", "Unknown")
            resources.setdefault(rtype, []).append(resource)

        # Also handle flat dict format {resourceType: [resources]}
        for key, value in bundle.items():
            if key != "entry" and isinstance(value, list):
                resources.setdefault(key, []).extend(value)

        return resources

    def _process_patient(
        self, ctx: PatientContext, patients: List[Dict[str, Any]]
    ) -> None:
        if not patients:
            return
        patient = patients[0]
        birth_date = patient.get("birthDate")
        if birth_date:
            try:
                dob = datetime.strptime(birth_date, "%Y-%m-%d")
                today = datetime.now()
                ctx.age = today.year - dob.year - ((today.month, today.day) < (dob.month, dob.day))
                ctx.is_elderly = ctx.age >= 65
                ctx.is_very_elderly = ctx.age >= 75
            except (ValueError, TypeError):
                pass

    def _process_conditions(
        self, ctx: PatientContext, conditions: List[Dict[str, Any]]
    ) -> None:
        for condition in conditions:
            # Only active conditions
            clinical_status = condition.get("clinicalStatus", {})
            if isinstance(clinical_status, dict):
                codings = clinical_status.get("coding", [])
                status_code = codings[0].get("code", "") if codings else ""
            else:
                status_code = str(clinical_status)

            if status_code and status_code not in ("active", "recurrence", "relapse"):
                continue

            # Extract ICD-10 codes
            code_concept = condition.get("code", {})
            for coding in code_concept.get("coding", []):
                system = coding.get("system", "")
                code = coding.get("code", "")
                if "icd" in system.lower() or code:
                    ctx.active_conditions.append(code)

        self._set_condition_flags(ctx)

    def _set_condition_flags(self, ctx: PatientContext) -> None:
        """Set boolean condition flags from ICD-10 codes."""
        for flag_name, prefixes in _CONDITION_GROUPS.items():
            has_condition = any(
                any(code.startswith(prefix) for prefix in prefixes)
                for code in ctx.active_conditions
            )
            setattr(ctx, flag_name, has_condition)

    def _process_medications(
        self, ctx: PatientContext, medications: List[Dict[str, Any]]
    ) -> None:
        active_meds = []
        for med in medications:
            status = med.get("status", "")
            if status not in ("active", "completed"):
                continue
            med_name = ""
            med_concept = med.get("medicationCodeableConcept", {})
            if med_concept:
                med_name = med_concept.get("text", "")
                if not med_name:
                    codings = med_concept.get("coding", [])
                    if codings:
                        med_name = codings[0].get("display", "")
            if med_name:
                active_meds.append(med_name)

        ctx.active_medication_names = active_meds
        ctx.active_medication_count = len(active_meds)
        self._set_medication_flags(ctx)

    def _set_medication_flags(self, ctx: PatientContext) -> None:
        """Set medication class flags from med names."""
        names_lower = {n.lower() for n in ctx.active_medication_names}
        ctx.is_on_anticoagulant = bool(names_lower & _ANTICOAGULANTS)
        ctx.is_on_insulin = bool(names_lower & _INSULINS)
        ctx.is_on_opioid = bool(names_lower & _OPIOIDS)

    def _process_observations(
        self, ctx: PatientContext, observations: List[Dict[str, Any]]
    ) -> None:
        # Sort by date descending to get most recent first
        observations.sort(
            key=lambda o: o.get("effectiveDateTime", ""),
            reverse=True,
        )

        for obs in observations:
            code_concept = obs.get("code", {})
            codings = code_concept.get("coding", [])
            loinc_code = ""
            for coding in codings:
                if "loinc" in coding.get("system", "").lower():
                    loinc_code = coding.get("code", "")
                    break

            value = obs.get("valueQuantity", {}).get("value")
            if value is None:
                continue

            # Map LOINC codes to context fields (most recent only)
            if loinc_code == "8480-6" and ctx.last_bp_systolic is None:  # Systolic BP
                ctx.last_bp_systolic = float(value)
            elif loinc_code == "8462-4" and ctx.last_bp_diastolic is None:  # Diastolic BP
                ctx.last_bp_diastolic = float(value)
            elif loinc_code == "8867-4" and ctx.last_heart_rate is None:  # Heart rate
                ctx.last_heart_rate = float(value)
            elif loinc_code == "29463-7" and ctx.last_weight_kg is None:  # Weight
                ctx.last_weight_kg = float(value)
            elif loinc_code in ("2339-0", "2345-7") and ctx.last_glucose is None:  # Glucose
                ctx.last_glucose = float(value)
            elif loinc_code == "8310-5" and ctx.last_temperature is None:  # Temperature
                ctx.last_temperature = float(value)

    def _process_allergies(
        self, ctx: PatientContext, allergies: List[Dict[str, Any]]
    ) -> None:
        active_allergies = [
            a for a in allergies
            if a.get("clinicalStatus", {}).get("coding", [{}])[0].get("code") == "active"
            or not a.get("clinicalStatus")
        ]
        ctx.allergy_count = len(active_allergies)

        for allergy in active_allergies:
            category = allergy.get("category", [])
            if "medication" in category:
                substance = allergy.get("code", {}).get("text", "")
                if substance:
                    ctx.drug_allergies.append(substance)

    def _process_encounters(
        self, ctx: PatientContext, encounters: List[Dict[str, Any]]
    ) -> None:
        # Find most recent discharge
        encounters.sort(
            key=lambda e: e.get("period", {}).get("end", ""),
            reverse=True,
        )

        for enc in encounters:
            period = enc.get("period", {})
            end_date = period.get("end")
            if not end_date:
                continue
            try:
                discharge_dt = datetime.fromisoformat(end_date.replace("Z", "+00:00"))
                now = datetime.now(timezone.utc)
                delta = now - discharge_dt
                ctx.days_since_discharge = delta.days

                # Extract discharge diagnosis codes
                diagnoses = enc.get("diagnosis", [])
                for diag in diagnoses:
                    code = diag.get("condition", {}).get("reference", "")
                    if code:
                        ctx.discharge_diagnosis_codes.append(code)
                break  # Most recent only
            except (ValueError, TypeError):
                continue

    def _process_appointments(
        self, ctx: PatientContext, appointments: List[Dict[str, Any]]
    ) -> None:
        now = datetime.now(timezone.utc)
        for appt in appointments:
            status = appt.get("status", "")
            if status in ("cancelled", "noshow", "entered-in-error"):
                continue
            start = appt.get("start")
            if not start:
                continue
            try:
                appt_dt = datetime.fromisoformat(start.replace("Z", "+00:00"))
                if appt_dt > now:
                    ctx.has_upcoming_appointment = True
                    delta = appt_dt - now
                    if ctx.next_appointment_days is None or delta.days < ctx.next_appointment_days:
                        ctx.next_appointment_days = delta.days
            except (ValueError, TypeError):
                continue

    def _check_freshness(
        self, ctx: PatientContext, resources: Dict[str, List[Dict[str, Any]]]
    ) -> None:
        """Check resource freshness against configured thresholds."""
        now = datetime.now(timezone.utc)

        for resource_type, threshold_hours in self._freshness_thresholds.items():
            if threshold_hours is None:
                continue

            # Map compound types (e.g., "Observation_laboratory") to base type
            base_type = resource_type.split("_")[0]
            entries = resources.get(base_type, [])
            if not entries:
                continue

            # Find most recent entry date
            latest_date = None
            for entry in entries:
                date_str = (
                    entry.get("effectiveDateTime")
                    or entry.get("authoredOn")
                    or entry.get("recordedDate")
                    or entry.get("meta", {}).get("lastUpdated")
                )
                if date_str:
                    try:
                        dt = datetime.fromisoformat(date_str.replace("Z", "+00:00"))
                        if latest_date is None or dt > latest_date:
                            latest_date = dt
                    except (ValueError, TypeError):
                        continue

            if latest_date:
                hours_old = (now - latest_date).total_seconds() / 3600
                if hours_old > threshold_hours:
                    ctx.stale_resources.append(
                        f"{resource_type} (last updated {hours_old:.0f}h ago, threshold {threshold_hours}h)"
                    )
                    logger.warning(
                        "Stale FHIR data: %s is %.0fh old (threshold: %dh)",
                        resource_type,
                        hours_old,
                        threshold_hours,
                    )