From f60b1b309e669ac14f54b3c0c6bd883bd6f28fb7 Mon Sep 17 00:00:00 2001 From: jenniferjiangkells Date: Sat, 28 Mar 2026 02:00:25 +0000 Subject: [PATCH 01/11] R4B migration --- .../cda_fhir/medication_statement.liquid | 14 ++- .../fhir_cda/medication_entry.liquid | 6 +- healthchain/fhir/__init__.py | 4 + healthchain/fhir/bundlehelpers.py | 91 +++++++++++++------ healthchain/fhir/dataframe.py | 16 ++-- healthchain/fhir/elementhelpers.py | 26 ++++-- healthchain/fhir/r4b.py | 47 ++++++++++ healthchain/fhir/r4b.pyi | 29 ++++++ healthchain/fhir/readers.py | 11 ++- healthchain/fhir/resourcehelpers.py | 14 ++- healthchain/fhir/version.py | 4 +- .../gateway/clients/fhir/aio/client.py | 4 +- healthchain/gateway/clients/fhir/base.py | 6 +- .../gateway/clients/fhir/sync/client.py | 4 +- healthchain/gateway/fhir/aio.py | 4 +- healthchain/gateway/fhir/base.py | 39 +++++++- healthchain/gateway/fhir/sync.py | 4 +- healthchain/interop/engine.py | 8 +- healthchain/io/adapters/cdaadapter.py | 13 +-- healthchain/io/adapters/cdsfhiradapter.py | 2 +- healthchain/io/containers/dataset.py | 4 +- healthchain/io/containers/document.py | 47 +++++----- healthchain/io/mappers/fhirfeaturemapper.py | 2 +- .../sandbox/generators/basegenerators.py | 4 +- .../sandbox/generators/conditiongenerators.py | 21 ++--- .../sandbox/generators/encountergenerators.py | 14 +-- .../medicationadministrationgenerators.py | 19 ++-- .../generators/medicationrequestgenerators.py | 15 ++- .../sandbox/generators/patientgenerators.py | 14 +-- .../generators/practitionergenerators.py | 18 ++-- .../sandbox/generators/proceduregenerators.py | 6 +- tests/conftest.py | 7 +- tests/fhir/test_bundle_helpers.py | 10 +- tests/fhir/test_helpers.py | 31 +++---- tests/fhir/test_version.py | 78 +++++++++------- tests/gateway/test_base_fhir_client.py | 4 +- tests/gateway/test_fhir_client.py | 6 +- tests/gateway/test_fhir_client_async.py | 6 +- tests/integration_tests/conftest.py | 6 +- .../test_healthchain_api_e2e.py | 4 +- tests/interop/test_engine.py | 4 +- tests/io/test_document.py | 15 +-- tests/io/test_fhir_data.py | 9 +- tests/pipeline/test_cdaadapter.py | 2 +- tests/pipeline/test_cdsfhiradapter.py | 4 +- .../generators/test_cds_data_generator.py | 8 +- ...st_medication_administration_generators.py | 4 +- .../test_medication_request_generators.py | 2 +- .../test_practitioner_generators.py | 14 ++- tests/sandbox/test_mimic_loader.py | 64 ++++++------- tests/sandbox/test_synthea_loader.py | 16 ++-- 51 files changed, 479 insertions(+), 325 deletions(-) create mode 100644 healthchain/fhir/r4b.py create mode 100644 healthchain/fhir/r4b.pyi diff --git a/healthchain/configs/templates/cda_fhir/medication_statement.liquid b/healthchain/configs/templates/cda_fhir/medication_statement.liquid index 9d31f1ab..dda04ca1 100644 --- a/healthchain/configs/templates/cda_fhir/medication_statement.liquid +++ b/healthchain/configs/templates/cda_fhir/medication_statement.liquid @@ -2,14 +2,12 @@ "resourceType": "MedicationStatement", {% assign substance_admin = entry.substanceAdministration %} "status": "{{ substance_admin.statusCode['@code'] | map_status: 'cda_to_fhir' }}", - "medication": { - "concept": { - "coding": [{ - "system": "{{ substance_admin.consumable.manufacturedProduct.manufacturedMaterial.code['@codeSystem'] | map_system: 'cda_to_fhir' }}", - "code": "{{ substance_admin.consumable.manufacturedProduct.manufacturedMaterial.code['@code'] }}", - "display": "{{ substance_admin.consumable.manufacturedProduct.manufacturedMaterial.code['@displayName'] }}" - }] - } + "medicationCodeableConcept": { + "coding": [{ + "system": "{{ substance_admin.consumable.manufacturedProduct.manufacturedMaterial.code['@codeSystem'] | map_system: 'cda_to_fhir' }}", + "code": "{{ substance_admin.consumable.manufacturedProduct.manufacturedMaterial.code['@code'] }}", + "display": "{{ substance_admin.consumable.manufacturedProduct.manufacturedMaterial.code['@displayName'] }}" + }] } {% comment %}Process effectiveTime and extract period/timing information if exists{% endcomment %} diff --git a/healthchain/configs/templates/fhir_cda/medication_entry.liquid b/healthchain/configs/templates/fhir_cda/medication_entry.liquid index 65f00954..29051495 100644 --- a/healthchain/configs/templates/fhir_cda/medication_entry.liquid +++ b/healthchain/configs/templates/fhir_cda/medication_entry.liquid @@ -71,9 +71,9 @@ ], "manufacturedMaterial": { "code": { - "@code": "{{ resource.medication.concept.coding[0].code }}", - "@codeSystem": "{{ resource.medication.concept.coding[0].system | map_system: 'fhir_to_cda' }}", - "@displayName": "{{ resource.medication.concept.coding[0].display }}", + "@code": "{{ resource.medicationCodeableConcept.coding[0].code }}", + "@codeSystem": "{{ resource.medicationCodeableConcept.coding[0].system | map_system: 'fhir_to_cda' }}", + "@displayName": "{{ resource.medicationCodeableConcept.coding[0].display }}", "originalText": { "reference": {"@value": "{{ text_reference_name }}"} } diff --git a/healthchain/fhir/__init__.py b/healthchain/fhir/__init__.py index da59f240..ee748818 100644 --- a/healthchain/fhir/__init__.py +++ b/healthchain/fhir/__init__.py @@ -1,5 +1,7 @@ """FHIR utilities for HealthChain.""" +from healthchain.fhir import r4b + from healthchain.fhir.version import ( FHIRVersion, get_fhir_resource, @@ -64,6 +66,8 @@ ) __all__ = [ + # R4B re-export module + "r4b", # Version management "FHIRVersion", "get_fhir_resource", diff --git a/healthchain/fhir/bundlehelpers.py b/healthchain/fhir/bundlehelpers.py index 2bfbda7b..cfcff9a3 100644 --- a/healthchain/fhir/bundlehelpers.py +++ b/healthchain/fhir/bundlehelpers.py @@ -9,7 +9,7 @@ """ from typing import List, Type, TypeVar, Optional, Union, TYPE_CHECKING -from fhir.resources.bundle import Bundle, BundleEntry +from fhir.resources.R4B.bundle import Bundle, BundleEntry from fhir.resources.resource import Resource if TYPE_CHECKING: @@ -63,7 +63,7 @@ def get_resource_type( Raises: ValueError: If the resource type is not supported or cannot be imported """ - if isinstance(resource_type, type) and issubclass(resource_type, Resource): + if isinstance(resource_type, type): return resource_type if not isinstance(resource_type, str): @@ -100,11 +100,19 @@ def get_resources( >>> from fhir.resources.condition import Condition >>> conditions = get_resources(bundle, Condition) """ + if isinstance(resource_type, str): + type_name = resource_type + return [ + entry.resource + for entry in (bundle.entry or []) + if entry.resource is not None and type(entry.resource).__name__ == type_name + ] type_class = get_resource_type(resource_type) + type_name = type_class.__name__ return [ entry.resource for entry in (bundle.entry or []) - if isinstance(entry.resource, type_class) + if entry.resource is not None and entry.resource.__class__.__name__ == type_name ] @@ -136,24 +144,44 @@ def set_resources( >>> from fhir.resources.condition import Condition >>> set_resources(bundle, [condition1, condition2], Condition) """ - type_class = get_resource_type(resource_type) - # Remove existing resources of this type if replace=True if replace: - bundle.entry = [ - entry - for entry in (bundle.entry or []) - if not isinstance(entry.resource, type_class) - ] - - # Add new resources - for resource in resources: - if not isinstance(resource, type_class): - raise ValueError( - f"Resource must be of type {type_class.__name__}, " - f"got {type(resource).__name__}" - ) - add_resource(bundle, resource) + if isinstance(resource_type, str): + type_name = resource_type + bundle.entry = [ + entry + for entry in (bundle.entry or []) + if entry.resource is None or type(entry.resource).__name__ != type_name + ] + else: + type_class = get_resource_type(resource_type) + type_name_cls = type_class.__name__ + bundle.entry = [ + entry + for entry in (bundle.entry or []) + if entry.resource is None + or entry.resource.__class__.__name__ != type_name_cls + ] + + # Add new resources, validating type + if isinstance(resource_type, str): + type_name = resource_type + for resource in resources: + if type(resource).__name__ != type_name: + raise ValueError( + f"Resource must be of type {type_name}, " + f"got {type(resource).__name__}" + ) + add_resource(bundle, resource) + else: + type_class = get_resource_type(resource_type) + for resource in resources: + if resource.__class__.__name__ != type_class.__name__: + raise ValueError( + f"Resource must be of type {type_class.__name__}, " + f"got {type(resource).__name__}" + ) + add_resource(bundle, resource) def merge_bundles( @@ -242,17 +270,26 @@ def extract_resources( if not bundle or not bundle.entry: return [] - type_class = get_resource_type(resource_type) - extracted: List[Resource] = [] remaining_entries: List[BundleEntry] = [] - for entry in bundle.entry: - resource = entry.resource - if isinstance(resource, type_class): - extracted.append(resource) - continue - remaining_entries.append(entry) + if isinstance(resource_type, str): + type_name = resource_type + for entry in bundle.entry: + resource = entry.resource + if resource is not None and type(resource).__name__ == type_name: + extracted.append(resource) + continue + remaining_entries.append(entry) + else: + type_class = get_resource_type(resource_type) + type_name_cls = type_class.__name__ + for entry in bundle.entry: + resource = entry.resource + if resource is not None and resource.__class__.__name__ == type_name_cls: + extracted.append(resource) + continue + remaining_entries.append(entry) bundle.entry = remaining_entries return extracted diff --git a/healthchain/fhir/dataframe.py b/healthchain/fhir/dataframe.py index 0c9bfdb2..88d481ae 100644 --- a/healthchain/fhir/dataframe.py +++ b/healthchain/fhir/dataframe.py @@ -11,7 +11,7 @@ from typing import Any, Dict, List, Union, Optional, Literal from collections import defaultdict -from fhir.resources.bundle import Bundle +from fhir.resources.R4B.bundle import Bundle from pydantic import BaseModel, field_validator, ConfigDict from healthchain.fhir.utilities import ( @@ -576,13 +576,15 @@ def _flatten_medications( features = {} for med in medications: - medication = _get_field(med, "medication") - if not medication: - continue - - med_concept = _get_field(medication, "concept") + # R4B: medicationCodeableConcept; R5: medication.concept + med_concept = _get_field(med, "medicationCodeableConcept") if not med_concept: - continue + medication = _get_field(med, "medication") + if not medication: + continue + med_concept = _get_field(medication, "concept") + if not med_concept: + continue coding_array = _get_field(med_concept, "coding") if not coding_array or len(coding_array) == 0: diff --git a/healthchain/fhir/elementhelpers.py b/healthchain/fhir/elementhelpers.py index 9f86811f..8eed01a3 100644 --- a/healthchain/fhir/elementhelpers.py +++ b/healthchain/fhir/elementhelpers.py @@ -65,21 +65,29 @@ def create_single_reaction( Returns: A list containing a single FHIR Reaction dictionary with manifestation and severity fields """ - from healthchain.fhir.version import get_fhir_resource + from healthchain.fhir.version import ( + get_fhir_resource, + FHIRVersion, + _resolve_version, + ) + resolved = _resolve_version(version) CodeableConcept = get_fhir_resource("CodeableConcept", version) - CodeableReference = get_fhir_resource("CodeableReference", version) Coding = get_fhir_resource("Coding", version) + concept = CodeableConcept( + coding=[Coding(system=system, code=code, display=display)] + ) + + if resolved == FHIRVersion.R5: + CodeableReference = get_fhir_resource("CodeableReference", version) + manifestation = [CodeableReference(concept=concept)] + else: + manifestation = [concept] + return [ { - "manifestation": [ - CodeableReference( - concept=CodeableConcept( - coding=[Coding(system=system, code=code, display=display)] - ) - ) - ], + "manifestation": manifestation, "severity": severity, } ] diff --git a/healthchain/fhir/r4b.py b/healthchain/fhir/r4b.py new file mode 100644 index 00000000..f7c1b9db --- /dev/null +++ b/healthchain/fhir/r4b.py @@ -0,0 +1,47 @@ +"""Version-aware re-exports for FHIR R4B resources. + +Import FHIR R4B resource classes from here instead of fhir.resources directly: + + from healthchain.fhir.r4b import Condition, Patient, Appointment + +This ensures version consistency with HealthChain's FHIR version management. +""" + +from healthchain.fhir.version import get_fhir_resource as _get + + +def __getattr__(name: str): + return _get(name, "R4B") + + +__all__ = [ # noqa: F822 + "AllergyIntolerance", + "Annotation", + "Appointment", + "Attachment", + "Bundle", + "BundleEntry", + "CapabilityStatement", + "CarePlan", + "CodeableConcept", + "Coding", + "Condition", + "ContactPoint", + "DocumentReference", + "Dosage", + "Encounter", + "HumanName", + "Identifier", + "MedicationRequest", + "MedicationStatement", + "Meta", + "Observation", + "OperationOutcome", + "Patient", + "Period", + "Procedure", + "Provenance", + "ProvenanceAgent", + "Reference", + "RiskAssessment", +] diff --git a/healthchain/fhir/r4b.pyi b/healthchain/fhir/r4b.pyi new file mode 100644 index 00000000..28d34cf8 --- /dev/null +++ b/healthchain/fhir/r4b.pyi @@ -0,0 +1,29 @@ +from fhir.resources.R4B.allergyintolerance import ( + AllergyIntolerance as AllergyIntolerance, +) +from fhir.resources.R4B.appointment import Appointment as Appointment +from fhir.resources.R4B.bundle import Bundle as Bundle, BundleEntry as BundleEntry +from fhir.resources.R4B.capabilitystatement import ( + CapabilityStatement as CapabilityStatement, +) +from fhir.resources.R4B.careplan import CarePlan as CarePlan +from fhir.resources.R4B.codeableconcept import CodeableConcept as CodeableConcept +from fhir.resources.R4B.coding import Coding as Coding +from fhir.resources.R4B.condition import Condition as Condition +from fhir.resources.R4B.documentreference import DocumentReference as DocumentReference +from fhir.resources.R4B.encounter import Encounter as Encounter +from fhir.resources.R4B.humanname import HumanName as HumanName +from fhir.resources.R4B.identifier import Identifier as Identifier +from fhir.resources.R4B.medicationrequest import MedicationRequest as MedicationRequest +from fhir.resources.R4B.medicationstatement import ( + MedicationStatement as MedicationStatement, +) +from fhir.resources.R4B.meta import Meta as Meta +from fhir.resources.R4B.observation import Observation as Observation +from fhir.resources.R4B.operationoutcome import OperationOutcome as OperationOutcome +from fhir.resources.R4B.patient import Patient as Patient +from fhir.resources.R4B.period import Period as Period +from fhir.resources.R4B.procedure import Procedure as Procedure +from fhir.resources.R4B.provenance import Provenance as Provenance +from fhir.resources.R4B.reference import Reference as Reference +from fhir.resources.R4B.riskassessment import RiskAssessment as RiskAssessment diff --git a/healthchain/fhir/readers.py b/healthchain/fhir/readers.py index 00c2ad4f..0dac97a4 100644 --- a/healthchain/fhir/readers.py +++ b/healthchain/fhir/readers.py @@ -10,7 +10,7 @@ from typing import Optional, Dict, Any, List from fhir.resources.resource import Resource -from fhir.resources.documentreference import DocumentReference +from fhir.resources.R4B.documentreference import DocumentReference logger = logging.getLogger(__name__) @@ -65,7 +65,7 @@ def create_resource_from_dict( """ try: resource_module = importlib.import_module( - f"fhir.resources.{resource_type.lower()}" + f"fhir.resources.R4B.{resource_type.lower()}" ) resource_class = getattr(resource_module, resource_type) return resource_class(**resource_dict) @@ -126,8 +126,6 @@ def convert_prefetch_to_fhir_objects( >>> isinstance(fhir_objects["patient"], Patient) # True >>> isinstance(fhir_objects["condition"], Condition) # True """ - from fhir.resources import get_fhir_model_class - result: Dict[str, Resource] = {} for key, resource_data in prefetch_dict.items(): @@ -138,7 +136,10 @@ def convert_prefetch_to_fhir_objects( try: # Fix timezone-naive datetimes before validation fixed_data = _fix_timezone_naive_datetimes(resource_data) - resource_class = get_fhir_model_class(resource_type) + resource_module = importlib.import_module( + f"fhir.resources.R4B.{resource_type.lower()}" + ) + resource_class = getattr(resource_module, resource_type) result[key] = resource_class(**fixed_data) except Exception as e: logger.warning( diff --git a/healthchain/fhir/resourcehelpers.py b/healthchain/fhir/resourcehelpers.py index dcac457e..84317d2c 100644 --- a/healthchain/fhir/resourcehelpers.py +++ b/healthchain/fhir/resourcehelpers.py @@ -105,8 +105,13 @@ def create_medication_statement( Returns: MedicationStatement: A FHIR MedicationStatement resource with an auto-generated ID prefixed with 'hc-' """ - from healthchain.fhir.version import get_fhir_resource + from healthchain.fhir.version import ( + get_fhir_resource, + FHIRVersion, + _resolve_version, + ) + resolved = _resolve_version(version) MedicationStatement = get_fhir_resource("MedicationStatement", version) ReferenceClass = get_fhir_resource("Reference", version) @@ -117,11 +122,16 @@ def create_medication_statement( else: medication_concept = None + if resolved == FHIRVersion.R5: + med_kwargs = {"medication": {"concept": medication_concept}} + else: + med_kwargs = {"medicationCodeableConcept": medication_concept} + medication = MedicationStatement( id=_generate_id(), subject=ReferenceClass(reference=subject), status=status, - medication={"concept": medication_concept}, + **med_kwargs, ) return medication diff --git a/healthchain/fhir/version.py b/healthchain/fhir/version.py index 19dd314e..2fec7c18 100644 --- a/healthchain/fhir/version.py +++ b/healthchain/fhir/version.py @@ -123,9 +123,9 @@ def get_default_version() -> FHIRVersion: """Get the current default FHIR version. Returns: - The current default FHIRVersion (R5 if not explicitly set) + The current default FHIRVersion (R4B if not explicitly set) """ - return _default_version or FHIRVersion.R5 + return _default_version or FHIRVersion.R4B def set_default_version(version: Union[FHIRVersion, str]) -> None: diff --git a/healthchain/gateway/clients/fhir/aio/client.py b/healthchain/gateway/clients/fhir/aio/client.py index f190c370..a3c93da7 100644 --- a/healthchain/gateway/clients/fhir/aio/client.py +++ b/healthchain/gateway/clients/fhir/aio/client.py @@ -3,8 +3,8 @@ from typing import Any, Dict, Type, Union -from fhir.resources.bundle import Bundle -from fhir.resources.capabilitystatement import CapabilityStatement +from fhir.resources.R4B.bundle import Bundle +from fhir.resources.R4B.capabilitystatement import CapabilityStatement from fhir.resources.resource import Resource from healthchain.gateway.clients.auth import AsyncOAuth2TokenManager diff --git a/healthchain/gateway/clients/fhir/base.py b/healthchain/gateway/clients/fhir/base.py index b733476f..e4f641bc 100644 --- a/healthchain/gateway/clients/fhir/base.py +++ b/healthchain/gateway/clients/fhir/base.py @@ -7,8 +7,8 @@ from typing import Any, Dict, Optional, Type, Union from urllib.parse import urlencode, urljoin -from fhir.resources.bundle import Bundle -from fhir.resources.capabilitystatement import CapabilityStatement +from fhir.resources.R4B.bundle import Bundle +from fhir.resources.R4B.capabilitystatement import CapabilityStatement from fhir.resources.resource import Resource from healthchain.gateway.clients.auth import OAuth2Config @@ -302,7 +302,7 @@ def _resolve_resource_type( else: # It's a string, need to dynamically import type_name = str(resource_type) - module_name = f"fhir.resources.{type_name.lower()}" + module_name = f"fhir.resources.R4B.{type_name.lower()}" module = __import__(module_name, fromlist=[type_name]) resource_class = getattr(module, type_name) diff --git a/healthchain/gateway/clients/fhir/sync/client.py b/healthchain/gateway/clients/fhir/sync/client.py index 5d92d41c..99c5fd02 100644 --- a/healthchain/gateway/clients/fhir/sync/client.py +++ b/healthchain/gateway/clients/fhir/sync/client.py @@ -3,8 +3,8 @@ from typing import Any, Dict, Type, Union -from fhir.resources.bundle import Bundle -from fhir.resources.capabilitystatement import CapabilityStatement +from fhir.resources.R4B.bundle import Bundle +from fhir.resources.R4B.capabilitystatement import CapabilityStatement from fhir.resources.resource import Resource from healthchain.gateway.clients.auth import OAuth2TokenManager diff --git a/healthchain/gateway/fhir/aio.py b/healthchain/gateway/fhir/aio.py index 44c849bb..ba9e900a 100644 --- a/healthchain/gateway/fhir/aio.py +++ b/healthchain/gateway/fhir/aio.py @@ -3,8 +3,8 @@ from contextlib import asynccontextmanager from typing import Any, Dict, Optional, Type -from fhir.resources.bundle import Bundle -from fhir.resources.capabilitystatement import CapabilityStatement +from fhir.resources.R4B.bundle import Bundle +from fhir.resources.R4B.capabilitystatement import CapabilityStatement from fhir.resources.resource import Resource from healthchain.gateway.clients.fhir.base import FHIRServerInterface diff --git a/healthchain/gateway/fhir/base.py b/healthchain/gateway/fhir/base.py index 07705c7a..260761f1 100644 --- a/healthchain/gateway/fhir/base.py +++ b/healthchain/gateway/fhir/base.py @@ -4,10 +4,13 @@ from fastapi import Depends, HTTPException, Path, Query from datetime import datetime -from typing import Any, Callable, Dict, List, Type, TypeVar, Optional +from typing import TYPE_CHECKING, Any, Callable, Dict, List, Type, TypeVar, Optional from fastapi.responses import JSONResponse -from fhir.resources.capabilitystatement import CapabilityStatement +if TYPE_CHECKING: + from healthchain.config.appconfig import AppConfig + +from fhir.resources.R4B.capabilitystatement import CapabilityStatement from fhir.resources.resource import Resource from healthchain.gateway.clients.fhir.base import FHIRServerInterface @@ -440,6 +443,38 @@ async def handler( return handler + @classmethod + def from_config(cls, config: "AppConfig") -> "BaseFHIRGateway": + """ + Create a gateway with sources pre-registered from healthchain.yaml. + + Credentials stay in environment variables (via env_prefix); only + source names and prefixes are declared in config. + + Args: + config: AppConfig loaded from healthchain.yaml + + Returns: + A gateway instance with all configured sources added + + Example: + # healthchain.yaml: + # sources: + # medplum: + # env_prefix: MEDPLUM + gateway = FHIRGateway.from_config(AppConfig.load()) + """ + from healthchain.fhir.version import set_default_version + + gateway = cls() + for name, source in config.sources.items(): + set_default_version(source.fhir_version) + auth_config = source.to_fhir_auth_config() + gateway.add_source(name, auth_config.to_connection_string()) + # Note: if sources declare different fhir_version values, the last one wins. + # Per-source version isolation requires a gateway refactor — see docs/rfcs/001-per-source-fhir-version.md + return gateway + def add_source(self, name: str, connection_string: str) -> None: """ Add a FHIR data source using connection string with OAuth2.0 flow. diff --git a/healthchain/gateway/fhir/sync.py b/healthchain/gateway/fhir/sync.py index 6a2bf464..47beff9f 100644 --- a/healthchain/gateway/fhir/sync.py +++ b/healthchain/gateway/fhir/sync.py @@ -2,8 +2,8 @@ from typing import Any, Dict, Type, Optional -from fhir.resources.bundle import Bundle -from fhir.resources.capabilitystatement import CapabilityStatement +from fhir.resources.R4B.bundle import Bundle +from fhir.resources.R4B.capabilitystatement import CapabilityStatement from fhir.resources.resource import Resource from healthchain.gateway.clients.fhir.base import FHIRServerInterface diff --git a/healthchain/interop/engine.py b/healthchain/interop/engine.py index db7258d5..972778af 100644 --- a/healthchain/interop/engine.py +++ b/healthchain/interop/engine.py @@ -5,7 +5,7 @@ from pathlib import Path from fhir.resources.resource import Resource -from fhir.resources.bundle import Bundle +from fhir.resources.R4B.bundle import Bundle from pydantic import BaseModel from healthchain.config.base import ValidationLevel @@ -27,7 +27,11 @@ def normalize_resource_list( resources: Union[Resource, List[Resource], Bundle], ) -> List[Resource]: """Convert input resources to a normalized list format""" - if isinstance(resources, Bundle): + # Check for Bundle using duck typing to support both R4B and R5 bundles + if isinstance(resources, Bundle) or ( + hasattr(resources, "__resource_type__") + and resources.__resource_type__ == "Bundle" + ): return [entry.resource for entry in resources.entry if entry.resource] elif isinstance(resources, list): return resources diff --git a/healthchain/io/adapters/cdaadapter.py b/healthchain/io/adapters/cdaadapter.py index 91d5d456..10da6920 100644 --- a/healthchain/io/adapters/cdaadapter.py +++ b/healthchain/io/adapters/cdaadapter.py @@ -12,10 +12,6 @@ create_document_reference, read_content_attachment, ) -from fhir.resources.condition import Condition -from fhir.resources.medicationstatement import MedicationStatement -from fhir.resources.allergyintolerance import AllergyIntolerance -from fhir.resources.documentreference import DocumentReference log = logging.getLogger(__name__) @@ -109,14 +105,15 @@ def parse(self, cda_request: CdaRequest) -> Document: allergy_list = [] for resource in fhir_resources: - if isinstance(resource, Condition): + resource_type_name = resource.__class__.__name__ + if resource_type_name == "Condition": problem_list.append(resource) set_condition_category(resource, "problem-list-item") - elif isinstance(resource, MedicationStatement): + elif resource_type_name == "MedicationStatement": medication_list.append(resource) - elif isinstance(resource, AllergyIntolerance): + elif resource_type_name == "AllergyIntolerance": allergy_list.append(resource) - elif isinstance(resource, DocumentReference): + elif resource_type_name == "DocumentReference": if ( resource.content and resource.content[0].attachment diff --git a/healthchain/io/adapters/cdsfhiradapter.py b/healthchain/io/adapters/cdsfhiradapter.py index 42d36572..cf51ebfb 100644 --- a/healthchain/io/adapters/cdsfhiradapter.py +++ b/healthchain/io/adapters/cdsfhiradapter.py @@ -1,7 +1,7 @@ import logging from typing import Optional, Any -from fhir.resources.documentreference import DocumentReference +from fhir.resources.R4B.documentreference import DocumentReference from healthchain.io.containers import Document from healthchain.io.adapters.base import BaseAdapter diff --git a/healthchain/io/containers/dataset.py b/healthchain/io/containers/dataset.py index 20a4e36c..806023b9 100644 --- a/healthchain/io/containers/dataset.py +++ b/healthchain/io/containers/dataset.py @@ -5,8 +5,8 @@ from pathlib import Path from typing import Any, Dict, Iterator, List, Union, Optional -from fhir.resources.bundle import Bundle -from fhir.resources.riskassessment import RiskAssessment +from fhir.resources.R4B.bundle import Bundle +from fhir.resources.R4B.riskassessment import RiskAssessment from healthchain.io.containers.base import DataContainer from healthchain.io.containers.featureschema import FeatureSchema diff --git a/healthchain/io/containers/document.py b/healthchain/io/containers/document.py index 81f09d2b..ab398198 100644 --- a/healthchain/io/containers/document.py +++ b/healthchain/io/containers/document.py @@ -6,17 +6,17 @@ from spacy.tokens import Doc as SpacyDoc from spacy.tokens import Span -from fhir.resources.condition import Condition -from fhir.resources.medicationstatement import MedicationStatement -from fhir.resources.allergyintolerance import AllergyIntolerance -from fhir.resources.bundle import Bundle -from fhir.resources.documentreference import DocumentReference -from fhir.resources.resource import Resource -from fhir.resources.reference import Reference -from fhir.resources.documentreference import DocumentReferenceRelatesTo -from fhir.resources.operationoutcome import OperationOutcome -from fhir.resources.provenance import Provenance -from fhir.resources.patient import Patient +from fhir.resources.R4B.condition import Condition +from fhir.resources.R4B.medicationstatement import MedicationStatement +from fhir.resources.R4B.allergyintolerance import AllergyIntolerance +from fhir.resources.R4B.bundle import Bundle +from fhir.resources.R4B.documentreference import DocumentReference +from fhir_core.fhirabstractmodel import FHIRAbstractModel as Resource +from fhir.resources.R4B.reference import Reference +from fhir.resources.R4B.documentreference import DocumentReferenceRelatesTo +from fhir.resources.R4B.operationoutcome import OperationOutcome +from fhir.resources.R4B.provenance import Provenance +from fhir.resources.R4B.patient import Patient from healthchain.io.containers.base import BaseDocument from healthchain.models.responses import Action, Card @@ -26,7 +26,6 @@ get_resources, set_resources, extract_resources, - create_single_codeable_concept, read_content_attachment, create_condition, set_condition_category, @@ -421,14 +420,17 @@ def get_prefetch_resources(self, key: str) -> List[Any]: return [] return self._prefetch_resources.get(key, []) - def get_resources(self, resource_type: str) -> List[Any]: + def get_resources(self, resource_type: Union[str, type]) -> List[Any]: """Get resources of a specific type from the working bundle.""" if not self._bundle: return [] return get_resources(self._bundle, resource_type) def add_resources( - self, resources: List[Any], resource_type: str, replace: bool = False + self, + resources: List[Any], + resource_type: Union[str, type], + replace: bool = False, ): """Add resources to the working bundle.""" if not self._bundle: @@ -470,11 +472,7 @@ def add_document_reference( document.relatesTo.append( DocumentReferenceRelatesTo( target=Reference(reference=f"DocumentReference/{parent_id}"), - code=create_single_codeable_concept( - code=relationship_type, - display=relationship_type.capitalize(), - system="http://hl7.org/fhir/ValueSet/document-relationship-type", - ), + code=relationship_type, ) ) @@ -756,17 +754,20 @@ def __post_init__(self): """ super().__post_init__() - # Handle FHIR Bundle data - if isinstance(self.data, Bundle): + # Handle FHIR Bundle data (check both R4B and R5 via resource type) + if isinstance(self.data, Bundle) or ( + hasattr(self.data, "__resource_type__") + and self.data.__resource_type__ == "Bundle" + ): self._fhir._bundle = self.data # Extract OperationOutcome resources (operation results/errors) - outcomes = extract_resources(self._fhir._bundle, "OperationOutcome") + outcomes = extract_resources(self._fhir._bundle, OperationOutcome) if outcomes: self._fhir._operation_outcomes = outcomes # Extract Provenance resources (data lineage/origin) - provenances = extract_resources(self._fhir._bundle, "Provenance") + provenances = extract_resources(self._fhir._bundle, Provenance) if provenances: self._fhir._provenances = provenances diff --git a/healthchain/io/mappers/fhirfeaturemapper.py b/healthchain/io/mappers/fhirfeaturemapper.py index 24eba60f..ad640dec 100644 --- a/healthchain/io/mappers/fhirfeaturemapper.py +++ b/healthchain/io/mappers/fhirfeaturemapper.py @@ -8,7 +8,7 @@ import pandas as pd import numpy as np -from fhir.resources.bundle import Bundle +from fhir.resources.R4B.bundle import Bundle from healthchain.io.containers.featureschema import FeatureSchema from healthchain.io.mappers.base import BaseMapper diff --git a/healthchain/sandbox/generators/basegenerators.py b/healthchain/sandbox/generators/basegenerators.py index e8a15ee5..556fed21 100644 --- a/healthchain/sandbox/generators/basegenerators.py +++ b/healthchain/sandbox/generators/basegenerators.py @@ -7,8 +7,8 @@ from faker import Faker -from fhir.resources.codeableconcept import CodeableConcept -from fhir.resources.coding import Coding +from fhir.resources.R4B.codeableconcept import CodeableConcept +from fhir.resources.R4B.coding import Coding faker = Faker() diff --git a/healthchain/sandbox/generators/conditiongenerators.py b/healthchain/sandbox/generators/conditiongenerators.py index 09dd6354..41b0726d 100644 --- a/healthchain/sandbox/generators/conditiongenerators.py +++ b/healthchain/sandbox/generators/conditiongenerators.py @@ -1,8 +1,8 @@ from typing import Optional from faker import Faker -from fhir.resources.reference import Reference -from fhir.resources.condition import ConditionStage, ConditionParticipant +from fhir.resources.R4B.reference import Reference +from fhir.resources.R4B.condition import ConditionStage from healthchain.fhir import create_single_codeable_concept, create_condition from healthchain.sandbox.generators.basegenerators import ( @@ -29,6 +29,7 @@ def generate(): elements=("active", "recurrence", "inactive", "resolved") ), system="http://terminology.hl7.org/CodeSystem/condition-clinical", + version="R4B", ) @@ -39,6 +40,7 @@ def generate(): return create_single_codeable_concept( code=faker.random_element(elements=("provisional", "confirmed")), system="http://terminology.hl7.org/CodeSystem/condition-ver-status", + version="R4B", ) @@ -51,6 +53,7 @@ def generate(): elements=("55607006", "404684003") ), # Snomed Codes -> probably want to overwrite with template system="http://snomed.info/sct", + version="R4B", ) @@ -72,6 +75,7 @@ def generate(): return create_single_codeable_concept( code=faker.random_element(elements=("24484000", "6736007", "255604002")), system="http://snomed.info/sct", + version="R4B", ) @@ -98,16 +102,7 @@ def generate(): code=faker.random_element(elements=("38266002",)), display=faker.random_element(elements=("Entire body as a whole",)), system="http://snomed.info/sct", - ) - - -@register_generator -class ConditionParticipantGenerator(BaseGenerator): - @staticmethod - def generate(): - return ConditionParticipant( - type=generator_registry.get("CodeableConceptGenerator").generate(), - individual=generator_registry.get("ReferenceGenerator").generate(), + version="R4B", ) @@ -126,7 +121,7 @@ def generate( code = generator_registry.get("SnomedCodeGenerator").generate( constraints=constraints ) - condition = create_condition(subject=subject_reference) + condition = create_condition(subject=subject_reference, version="R4B") condition.clinicalStatus = generator_registry.get( "ClinicalStatusGenerator" ).generate() diff --git a/healthchain/sandbox/generators/encountergenerators.py b/healthchain/sandbox/generators/encountergenerators.py index 133364f4..1d166a9f 100644 --- a/healthchain/sandbox/generators/encountergenerators.py +++ b/healthchain/sandbox/generators/encountergenerators.py @@ -1,12 +1,12 @@ from typing import Optional from faker import Faker -from fhir.resources.encounter import Encounter, EncounterLocation +from fhir.resources.R4B.encounter import Encounter, EncounterLocation -from fhir.resources.coding import Coding -from fhir.resources.codeableconcept import CodeableConcept -from fhir.resources.period import Period -from fhir.resources.reference import Reference +from fhir.resources.R4B.coding import Coding +from fhir.resources.R4B.codeableconcept import CodeableConcept +from fhir.resources.R4B.period import Period +from fhir.resources.R4B.reference import Reference from healthchain.sandbox.generators.basegenerators import ( BaseGenerator, generator_registry, @@ -157,10 +157,10 @@ def generate( "cancelled", ) ), - class_fhir=[generator_registry.get("ClassGenerator").generate()], + class_fhir=generator_registry.get("ClassGenerator").generate().coding[0], priority=generator_registry.get("EncounterPriorityGenerator").generate(), type=[generator_registry.get("EncounterTypeGenerator").generate()], subject={"reference": patient_reference, "display": patient_reference}, - actualPeriod=generator_registry.get("PeriodGenerator").generate(), + period=generator_registry.get("PeriodGenerator").generate(), location=[generator_registry.get("EncounterLocationGenerator").generate()], ) diff --git a/healthchain/sandbox/generators/medicationadministrationgenerators.py b/healthchain/sandbox/generators/medicationadministrationgenerators.py index ef42355a..056ac0eb 100644 --- a/healthchain/sandbox/generators/medicationadministrationgenerators.py +++ b/healthchain/sandbox/generators/medicationadministrationgenerators.py @@ -1,10 +1,9 @@ from typing import Optional from faker import Faker -from fhir.resources.medicationadministration import MedicationAdministration -from fhir.resources.medicationadministration import MedicationAdministrationDosage -from fhir.resources.reference import Reference -from fhir.resources.codeablereference import CodeableReference +from fhir.resources.R4B.medicationadministration import MedicationAdministration +from fhir.resources.R4B.medicationadministration import MedicationAdministrationDosage +from fhir.resources.R4B.reference import Reference from healthchain.sandbox.generators.basegenerators import ( BaseGenerator, generator_registry, @@ -40,14 +39,12 @@ def generate( return MedicationAdministration( id=generator_registry.get("IdGenerator").generate(), status=generator_registry.get("EventStatusGenerator").generate(), - occurenceDateTime=generator_registry.get("DateGenerator").generate(), - medication=CodeableReference( - concept=generator_registry.get( - "MedicationRequestContainedGenerator" - ).generate() - ), + effectiveDateTime=generator_registry.get("DateGenerator").generate(), + medicationCodeableConcept=generator_registry.get( + "MedicationRequestContainedGenerator" + ).generate(), subject=Reference(reference=subject_reference), - encounter=Reference(reference=encounter_reference), + context=Reference(reference=encounter_reference), dosage=generator_registry.get( "MedicationAdministrationDosageGenerator" ).generate(), diff --git a/healthchain/sandbox/generators/medicationrequestgenerators.py b/healthchain/sandbox/generators/medicationrequestgenerators.py index a1c48cfb..4df2bc37 100644 --- a/healthchain/sandbox/generators/medicationrequestgenerators.py +++ b/healthchain/sandbox/generators/medicationrequestgenerators.py @@ -10,10 +10,9 @@ from healthchain.sandbox.generators.value_sets.medicationcodes import ( MedicationRequestMedication, ) -from fhir.resources.medicationrequest import MedicationRequest -from fhir.resources.dosage import Dosage -from fhir.resources.reference import Reference -from fhir.resources.codeablereference import CodeableReference +from fhir.resources.R4B.medicationrequest import MedicationRequest +from fhir.resources.R4B.dosage import Dosage +from fhir.resources.R4B.reference import Reference faker = Faker() @@ -51,11 +50,9 @@ def generate( id=generator_registry.get("IdGenerator").generate(), status=generator_registry.get("EventStatusGenerator").generate(), intent=generator_registry.get("IntentGenerator").generate(), - medication=CodeableReference( - concept=generator_registry.get( - "MedicationRequestContainedGenerator" - ).generate() - ), + medicationCodeableConcept=generator_registry.get( + "MedicationRequestContainedGenerator" + ).generate(), subject=Reference(reference=subject_reference), encounter=Reference(reference=encounter_reference), authoredOn=generator_registry.get("DateTimeGenerator").generate(), diff --git a/healthchain/sandbox/generators/patientgenerators.py b/healthchain/sandbox/generators/patientgenerators.py index f14c9ca6..5a90091b 100644 --- a/healthchain/sandbox/generators/patientgenerators.py +++ b/healthchain/sandbox/generators/patientgenerators.py @@ -9,13 +9,13 @@ from datetime import datetime -from fhir.resources.humanname import HumanName -from fhir.resources.contactpoint import ContactPoint -from fhir.resources.address import Address -from fhir.resources.period import Period -from fhir.resources.codeableconcept import CodeableConcept -from fhir.resources.coding import Coding -from fhir.resources.patient import Patient +from fhir.resources.R4B.humanname import HumanName +from fhir.resources.R4B.contactpoint import ContactPoint +from fhir.resources.R4B.address import Address +from fhir.resources.R4B.period import Period +from fhir.resources.R4B.codeableconcept import CodeableConcept +from fhir.resources.R4B.coding import Coding +from fhir.resources.R4B.patient import Patient faker = Faker() diff --git a/healthchain/sandbox/generators/practitionergenerators.py b/healthchain/sandbox/generators/practitionergenerators.py index 284184a5..bba1712c 100644 --- a/healthchain/sandbox/generators/practitionergenerators.py +++ b/healthchain/sandbox/generators/practitionergenerators.py @@ -7,13 +7,12 @@ register_generator, ) -from fhir.resources.practitioner import ( +from fhir.resources.R4B.practitioner import ( Practitioner, - PractitionerCommunication, PractitionerQualification, ) -from fhir.resources.codeableconcept import CodeableConcept -from fhir.resources.coding import Coding +from fhir.resources.R4B.codeableconcept import CodeableConcept +from fhir.resources.R4B.coding import Coding faker = Faker() @@ -93,11 +92,8 @@ def generate(): class Practitioner_CommunicationGenerator(BaseGenerator): @staticmethod def generate(): - return PractitionerCommunication( - id=faker.uuid4(), - language=generator_registry.get("LanguageGenerator").generate(), - preferred=True, - ) + # R4B Practitioner.communication is List[CodeableConcept] directly + return generator_registry.get("LanguageGenerator").generate() @register_generator @@ -116,7 +112,5 @@ def generate(constraints: Optional[list] = None): qualification=[ generator_registry.get("Practitioner_QualificationGenerator").generate() ], - communication=[ - generator_registry.get("Practitioner_CommunicationGenerator").generate() - ], + communication=[generator_registry.get("LanguageGenerator").generate()], ) diff --git a/healthchain/sandbox/generators/proceduregenerators.py b/healthchain/sandbox/generators/proceduregenerators.py index a16f0ba6..2785e7d2 100644 --- a/healthchain/sandbox/generators/proceduregenerators.py +++ b/healthchain/sandbox/generators/proceduregenerators.py @@ -11,8 +11,8 @@ ProcedureCodeSimple, ProcedureCodeComplex, ) -from fhir.resources.procedure import Procedure -from fhir.resources.reference import Reference +from fhir.resources.R4B.procedure import Procedure +from fhir.resources.R4B.reference import Reference faker = Faker() @@ -56,5 +56,5 @@ def generate( code=code, subject=Reference(reference=subject_reference), encounter=Reference(reference=encounter_reference), - occurrencePeriod=generator_registry.get("PeriodGenerator").generate(), + performedPeriod=generator_registry.get("PeriodGenerator").generate(), ) diff --git a/tests/conftest.py b/tests/conftest.py index ee9e4b47..b1fcfb8d 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -21,7 +21,10 @@ create_single_reaction, ) -from fhir.resources.documentreference import DocumentReference, DocumentReferenceContent +from fhir.resources.R4B.documentreference import ( + DocumentReference, + DocumentReferenceContent, +) @pytest.fixture @@ -258,7 +261,7 @@ def doc_ref_without_content(): Returns: fhir.resources.documentreference.DocumentReference: An incomplete DocumentReference resource. """ - from fhir.resources.attachment import Attachment + from fhir.resources.R4B.attachment import Attachment return DocumentReference( status="current", diff --git a/tests/fhir/test_bundle_helpers.py b/tests/fhir/test_bundle_helpers.py index cfc89c4e..2585c6a3 100644 --- a/tests/fhir/test_bundle_helpers.py +++ b/tests/fhir/test_bundle_helpers.py @@ -1,11 +1,11 @@ """Tests for FHIR Bundle helper functions.""" import pytest -from fhir.resources.bundle import Bundle -from fhir.resources.condition import Condition -from fhir.resources.medicationstatement import MedicationStatement -from fhir.resources.allergyintolerance import AllergyIntolerance -from fhir.resources.documentreference import DocumentReference +from fhir.resources.R4B.bundle import Bundle +from fhir.resources.R4B.condition import Condition +from fhir.resources.R4B.medicationstatement import MedicationStatement +from fhir.resources.R4B.allergyintolerance import AllergyIntolerance +from fhir.resources.R4B.documentreference import DocumentReference from healthchain.fhir.bundlehelpers import ( create_bundle, diff --git a/tests/fhir/test_helpers.py b/tests/fhir/test_helpers.py index 35f7ce24..89ea008d 100644 --- a/tests/fhir/test_helpers.py +++ b/tests/fhir/test_helpers.py @@ -1,11 +1,10 @@ -from fhir.resources.condition import Condition -from fhir.resources.medicationstatement import MedicationStatement -from fhir.resources.allergyintolerance import AllergyIntolerance -from fhir.resources.codeableconcept import CodeableConcept -from fhir.resources.codeablereference import CodeableReference -from fhir.resources.documentreference import DocumentReference -from fhir.resources.attachment import Attachment -from fhir.resources.coding import Coding +from fhir.resources.R4B.condition import Condition +from fhir.resources.R4B.medicationstatement import MedicationStatement +from fhir.resources.R4B.allergyintolerance import AllergyIntolerance +from fhir.resources.R4B.codeableconcept import CodeableConcept +from fhir.resources.R4B.documentreference import DocumentReference +from fhir.resources.R4B.attachment import Attachment +from fhir.resources.R4B.coding import Coding from datetime import datetime @@ -56,12 +55,10 @@ def test_create_single_reaction(): assert len(reaction) == 1 assert reaction[0]["severity"] == "severe" assert len(reaction[0]["manifestation"]) == 1 - assert isinstance(reaction[0]["manifestation"][0], CodeableReference) - assert reaction[0]["manifestation"][0].concept.coding[0].code == "123" - assert reaction[0]["manifestation"][0].concept.coding[0].display == "Test Reaction" - assert ( - reaction[0]["manifestation"][0].concept.coding[0].system == "http://test.system" - ) + assert isinstance(reaction[0]["manifestation"][0], CodeableConcept) + assert reaction[0]["manifestation"][0].coding[0].code == "123" + assert reaction[0]["manifestation"][0].coding[0].display == "Test Reaction" + assert reaction[0]["manifestation"][0].coding[0].system == "http://test.system" def test_create_condition(): @@ -101,9 +98,9 @@ def test_create_medication_statement_minimal(): assert len(medication.id) > 3 # Ensure there's content after "hc-" assert medication.subject.reference == "Patient/123" assert medication.status == "recorded" - assert medication.medication.concept.coding[0].code == "123" - assert medication.medication.concept.coding[0].display == "Test Medication" - assert medication.medication.concept.coding[0].system == "http://test.system" + assert medication.medicationCodeableConcept.coding[0].code == "123" + assert medication.medicationCodeableConcept.coding[0].display == "Test Medication" + assert medication.medicationCodeableConcept.coding[0].system == "http://test.system" def test_create_allergy_intolerance_minimal(): diff --git a/tests/fhir/test_version.py b/tests/fhir/test_version.py index 352378e7..c3553ff1 100644 --- a/tests/fhir/test_version.py +++ b/tests/fhir/test_version.py @@ -32,7 +32,7 @@ def test_fhir_version_enum_from_string(): def test_resolve_version_with_none(): """Test _resolve_version returns default when None.""" reset_default_version() - assert _resolve_version(None) == FHIRVersion.R5 + assert _resolve_version(None) == FHIRVersion.R4B def test_resolve_version_with_enum(): @@ -53,9 +53,9 @@ def test_resolve_version_invalid_string(): def test_get_default_version_initial(): - """Test initial default version is R5.""" + """Test initial default version is R4B.""" reset_default_version() - assert get_default_version() == FHIRVersion.R5 + assert get_default_version() == FHIRVersion.R4B def test_set_default_version_with_enum(): @@ -75,17 +75,17 @@ def test_set_default_version_with_string(): def test_reset_default_version(): - """Test resetting default version to R5.""" - set_default_version(FHIRVersion.R4B) + """Test resetting default version to R4B.""" + set_default_version(FHIRVersion.R5) reset_default_version() - assert get_default_version() == FHIRVersion.R5 + assert get_default_version() == FHIRVersion.R4B -def test_get_fhir_resource_r5_default(): - """Test loading resource with default R5 version.""" +def test_get_fhir_resource_r4b_default(): + """Test loading resource with default R4B version.""" reset_default_version() Patient = get_fhir_resource("Patient") - assert Patient.__module__ == "fhir.resources.patient" + assert Patient.__module__ == "fhir.resources.R4B.patient" def test_get_fhir_resource_r4b(): @@ -137,43 +137,43 @@ def test_get_fhir_resource_respects_default_version(): def test_fhir_version_context_basic(): """Test fhir_version_context changes version temporarily.""" reset_default_version() - assert get_default_version() == FHIRVersion.R5 + assert get_default_version() == FHIRVersion.R4B - with fhir_version_context("R4B") as v: - assert v == FHIRVersion.R4B - assert get_default_version() == FHIRVersion.R4B + with fhir_version_context("R5") as v: + assert v == FHIRVersion.R5 + assert get_default_version() == FHIRVersion.R5 Patient = get_fhir_resource("Patient") - assert Patient.__module__ == "fhir.resources.R4B.patient" + assert Patient.__module__ == "fhir.resources.patient" - assert get_default_version() == FHIRVersion.R5 + assert get_default_version() == FHIRVersion.R4B def test_fhir_version_context_restores_on_exception(): """Test fhir_version_context restores version even on exception.""" reset_default_version() - assert get_default_version() == FHIRVersion.R5 + assert get_default_version() == FHIRVersion.R4B with pytest.raises(RuntimeError): - with fhir_version_context("R4B"): - assert get_default_version() == FHIRVersion.R4B + with fhir_version_context("R5"): + assert get_default_version() == FHIRVersion.R5 raise RuntimeError("Test exception") - assert get_default_version() == FHIRVersion.R5 + assert get_default_version() == FHIRVersion.R4B def test_fhir_version_context_nested(): """Test nested fhir_version_context restores correctly.""" reset_default_version() - with fhir_version_context("R4B"): - assert get_default_version() == FHIRVersion.R4B + with fhir_version_context("R5"): + assert get_default_version() == FHIRVersion.R5 with fhir_version_context("STU3"): assert get_default_version() == FHIRVersion.STU3 - assert get_default_version() == FHIRVersion.R4B + assert get_default_version() == FHIRVersion.R5 - assert get_default_version() == FHIRVersion.R5 + assert get_default_version() == FHIRVersion.R4B def test_convert_resource_r5_to_r4b(): @@ -254,54 +254,66 @@ def test_create_condition_with_version(): """Test create_condition with version parameter.""" from healthchain.fhir import create_condition - cond_r5 = create_condition("Patient/1", code="123", display="Test") + cond_default = create_condition("Patient/1", code="123", display="Test") cond_r4b = create_condition("Patient/1", code="123", display="Test", version="R4B") + cond_r5 = create_condition("Patient/1", code="123", display="Test", version="R5") - assert cond_r5.__class__.__module__ == "fhir.resources.condition" + assert cond_default.__class__.__module__ == "fhir.resources.R4B.condition" assert cond_r4b.__class__.__module__ == "fhir.resources.R4B.condition" + assert cond_r5.__class__.__module__ == "fhir.resources.condition" def test_create_patient_with_version(): """Test create_patient with version parameter.""" from healthchain.fhir import create_patient - patient_r5 = create_patient(gender="male") + patient_default = create_patient(gender="male") patient_r4b = create_patient(gender="female", version="R4B") + patient_r5 = create_patient(gender="other", version="R5") - assert patient_r5.__class__.__module__ == "fhir.resources.patient" + assert patient_default.__class__.__module__ == "fhir.resources.R4B.patient" assert patient_r4b.__class__.__module__ == "fhir.resources.R4B.patient" + assert patient_r5.__class__.__module__ == "fhir.resources.patient" def test_create_observation_with_version(): """Test create_value_quantity_observation with version parameter.""" from healthchain.fhir import create_value_quantity_observation - obs_r5 = create_value_quantity_observation(code="12345", value=98.6, unit="F") + obs_default = create_value_quantity_observation(code="12345", value=98.6, unit="F") obs_r4b = create_value_quantity_observation( code="12345", value=98.6, unit="F", version="R4B" ) + obs_r5 = create_value_quantity_observation( + code="12345", value=98.6, unit="F", version="R5" + ) - assert obs_r5.__class__.__module__ == "fhir.resources.observation" + assert obs_default.__class__.__module__ == "fhir.resources.R4B.observation" assert obs_r4b.__class__.__module__ == "fhir.resources.R4B.observation" + assert obs_r5.__class__.__module__ == "fhir.resources.observation" def test_get_resource_type_with_version(): """Test get_resource_type with version parameter.""" from healthchain.fhir import get_resource_type - Condition_R5 = get_resource_type("Condition") + Condition_default = get_resource_type("Condition") Condition_R4B = get_resource_type("Condition", version="R4B") + Condition_R5 = get_resource_type("Condition", version="R5") - assert Condition_R5.__module__ == "fhir.resources.condition" + assert Condition_default.__module__ == "fhir.resources.R4B.condition" assert Condition_R4B.__module__ == "fhir.resources.R4B.condition" + assert Condition_R5.__module__ == "fhir.resources.condition" def test_create_single_codeable_concept_with_version(): """Test create_single_codeable_concept with version parameter.""" from healthchain.fhir.elementhelpers import create_single_codeable_concept - cc_r5 = create_single_codeable_concept("123", "Test") + cc_default = create_single_codeable_concept("123", "Test") cc_r4b = create_single_codeable_concept("123", "Test", version="R4B") + cc_r5 = create_single_codeable_concept("123", "Test", version="R5") - assert cc_r5.__class__.__module__ == "fhir.resources.codeableconcept" + assert cc_default.__class__.__module__ == "fhir.resources.R4B.codeableconcept" assert cc_r4b.__class__.__module__ == "fhir.resources.R4B.codeableconcept" + assert cc_r5.__class__.__module__ == "fhir.resources.codeableconcept" diff --git a/tests/gateway/test_base_fhir_client.py b/tests/gateway/test_base_fhir_client.py index 16360200..f9591126 100644 --- a/tests/gateway/test_base_fhir_client.py +++ b/tests/gateway/test_base_fhir_client.py @@ -9,7 +9,7 @@ import json import httpx from unittest.mock import Mock, patch -from fhir.resources.patient import Patient +from fhir.resources.R4B.patient import Patient from healthchain.gateway.clients.fhir.sync import FHIRClient from healthchain.gateway.clients.fhir.aio import AsyncFHIRClient @@ -128,7 +128,7 @@ def test_fhir_client_resource_type_resolution(fhir_client): assert type_name == "Patient" assert resource_class == Patient mock_import.assert_called_once_with( - "fhir.resources.patient", fromlist=["Patient"] + "fhir.resources.R4B.patient", fromlist=["Patient"] ) # Test invalid resource type diff --git a/tests/gateway/test_fhir_client.py b/tests/gateway/test_fhir_client.py index c705765a..471ecaa7 100644 --- a/tests/gateway/test_fhir_client.py +++ b/tests/gateway/test_fhir_client.py @@ -7,9 +7,9 @@ import pytest import httpx from unittest.mock import Mock, patch -from fhir.resources.patient import Patient -from fhir.resources.bundle import Bundle -from fhir.resources.capabilitystatement import CapabilityStatement +from fhir.resources.R4B.patient import Patient +from fhir.resources.R4B.bundle import Bundle +from fhir.resources.R4B.capabilitystatement import CapabilityStatement from healthchain.gateway.clients.fhir.sync import FHIRClient from healthchain.gateway.clients.fhir.base import FHIRAuthConfig diff --git a/tests/gateway/test_fhir_client_async.py b/tests/gateway/test_fhir_client_async.py index 1edf41dc..93bc0527 100644 --- a/tests/gateway/test_fhir_client_async.py +++ b/tests/gateway/test_fhir_client_async.py @@ -7,9 +7,9 @@ import pytest import httpx from unittest.mock import Mock, AsyncMock, patch -from fhir.resources.patient import Patient -from fhir.resources.bundle import Bundle -from fhir.resources.capabilitystatement import CapabilityStatement +from fhir.resources.R4B.patient import Patient +from fhir.resources.R4B.bundle import Bundle +from fhir.resources.R4B.capabilitystatement import CapabilityStatement from healthchain.gateway.clients.fhir.aio import AsyncFHIRClient from healthchain.gateway.clients.fhir.base import FHIRAuthConfig diff --git a/tests/integration_tests/conftest.py b/tests/integration_tests/conftest.py index 311da567..b71791a3 100644 --- a/tests/integration_tests/conftest.py +++ b/tests/integration_tests/conftest.py @@ -12,9 +12,9 @@ from healthchain.pipeline.medicalcodingpipeline import MedicalCodingPipeline from healthchain.pipeline.summarizationpipeline import SummarizationPipeline from healthchain.fhir import create_document_reference -from fhir.resources.documentreference import DocumentReference -from fhir.resources.patient import Patient -from fhir.resources.meta import Meta +from fhir.resources.R4B.documentreference import DocumentReference +from fhir.resources.R4B.patient import Patient +from fhir.resources.R4B.meta import Meta @pytest.fixture diff --git a/tests/integration_tests/test_healthchain_api_e2e.py b/tests/integration_tests/test_healthchain_api_e2e.py index 80428a3d..26892625 100644 --- a/tests/integration_tests/test_healthchain_api_e2e.py +++ b/tests/integration_tests/test_healthchain_api_e2e.py @@ -34,8 +34,8 @@ def test_cds_service_processes_through_pipeline( def test_fhir_gateway_supports_multiple_resource_operations(fhir_gateway): """FHIR Gateway handles both transform and aggregate operations on different resource types.""" - from fhir.resources.documentreference import DocumentReference - from fhir.resources.patient import Patient + from fhir.resources.R4B.documentreference import DocumentReference + from fhir.resources.R4B.patient import Patient # Transform operation doc = fhir_gateway._resource_handlers[DocumentReference]["transform"]( diff --git a/tests/interop/test_engine.py b/tests/interop/test_engine.py index a2f97366..55c2187c 100644 --- a/tests/interop/test_engine.py +++ b/tests/interop/test_engine.py @@ -9,8 +9,8 @@ from healthchain.interop.types import FormatType, validate_format from healthchain.config.base import ValidationLevel -from fhir.resources.condition import Condition -from fhir.resources.medicationstatement import MedicationStatement +from fhir.resources.R4B.condition import Condition +from fhir.resources.R4B.medicationstatement import MedicationStatement @pytest.fixture diff --git a/tests/io/test_document.py b/tests/io/test_document.py index ab86a839..0a6472bf 100644 --- a/tests/io/test_document.py +++ b/tests/io/test_document.py @@ -2,7 +2,7 @@ from healthchain.io.containers.document import Document from unittest.mock import patch, MagicMock -from fhir.resources.bundle import Bundle +from fhir.resources.R4B.bundle import Bundle from healthchain.fhir import create_bundle, add_resource, create_condition @@ -108,7 +108,10 @@ def test_document_bundle_accessible_via_problem_list(): def test_document_operation_outcome_extraction( num_outcomes, expected_outcome_count, expected_remaining_entries ): - from fhir.resources.operationoutcome import OperationOutcome, OperationOutcomeIssue + from fhir.resources.R4B.operationoutcome import ( + OperationOutcome, + OperationOutcomeIssue, + ) bundle = create_bundle("collection") add_resource(bundle, create_condition(subject="Patient/123", code="E11.9")) @@ -149,8 +152,8 @@ def test_document_provenance_extraction( num_provenances, expected_provenance_count, expected_remaining_entries ): """Document automatically extracts Provenance resources during initialization.""" - from fhir.resources.provenance import Provenance, ProvenanceAgent - from fhir.resources.reference import Reference + from fhir.resources.R4B.provenance import Provenance, ProvenanceAgent + from fhir.resources.R4B.reference import Reference bundle = create_bundle("collection") add_resource(bundle, create_condition(subject="Patient/123", code="E11.9")) @@ -177,8 +180,8 @@ def test_document_provenance_extraction( @pytest.mark.parametrize("num_patients", [0, 1, 2]) def test_document_patient_convenience_properties_param(num_patients): """Patient convenience accessors behave for 0, 1, 2 patients without extraction.""" - from fhir.resources.patient import Patient - from fhir.resources.humanname import HumanName + from fhir.resources.R4B.patient import Patient + from fhir.resources.R4B.humanname import HumanName bundle = create_bundle("collection") diff --git a/tests/io/test_fhir_data.py b/tests/io/test_fhir_data.py index 0c28dc13..bfe774f4 100644 --- a/tests/io/test_fhir_data.py +++ b/tests/io/test_fhir_data.py @@ -158,15 +158,10 @@ def test_relationship_metadata(fhir_data, sample_document_reference): fhir_data.add_document_reference(child_doc, parent_id=doc_id) - # Verify relationship structure + # Verify relationship structure (R4B: code is a string, not CodeableConcept) child = fhir_data.get_resources("DocumentReference")[1] assert hasattr(child, "relatesTo") - assert child.relatesTo[0].code.coding[0].code == "transforms" - assert child.relatesTo[0].code.coding[0].display == "Transforms" - assert ( - child.relatesTo[0].code.coding[0].system - == "http://hl7.org/fhir/ValueSet/document-relationship-type" - ) + assert child.relatesTo[0].code == "transforms" assert child.relatesTo[0].target.reference == f"DocumentReference/{doc_id}" diff --git a/tests/pipeline/test_cdaadapter.py b/tests/pipeline/test_cdaadapter.py index 9f33b67d..157ff979 100644 --- a/tests/pipeline/test_cdaadapter.py +++ b/tests/pipeline/test_cdaadapter.py @@ -5,7 +5,7 @@ from healthchain.io.containers import Document from healthchain.io.adapters import CdaAdapter from healthchain.interop import FormatType -from fhir.resources.documentreference import DocumentReference +from fhir.resources.R4B.documentreference import DocumentReference @pytest.fixture diff --git a/tests/pipeline/test_cdsfhiradapter.py b/tests/pipeline/test_cdsfhiradapter.py index a8691aaf..a9eda957 100644 --- a/tests/pipeline/test_cdsfhiradapter.py +++ b/tests/pipeline/test_cdsfhiradapter.py @@ -3,8 +3,8 @@ from healthchain.io.containers import Document from healthchain.io.containers.document import CdsAnnotations from healthchain.models.responses.cdsresponse import Action, CDSResponse, Card -from fhir.resources.resource import Resource -from fhir.resources.documentreference import DocumentReference +from fhir.resources.R4B.resource import Resource +from fhir.resources.R4B.documentreference import DocumentReference def test_parse_with_no_document_reference(cds_fhir_adapter, test_cds_request): diff --git a/tests/sandbox/generators/test_cds_data_generator.py b/tests/sandbox/generators/test_cds_data_generator.py index 1597ff67..df5b94b6 100644 --- a/tests/sandbox/generators/test_cds_data_generator.py +++ b/tests/sandbox/generators/test_cds_data_generator.py @@ -1,9 +1,9 @@ import pytest -from fhir.resources.encounter import Encounter -from fhir.resources.condition import Condition -from fhir.resources.procedure import Procedure -from fhir.resources.patient import Patient +from fhir.resources.R4B.encounter import Encounter +from fhir.resources.R4B.condition import Condition +from fhir.resources.R4B.procedure import Procedure +from fhir.resources.R4B.patient import Patient from healthchain.sandbox.generators import CdsDataGenerator from healthchain.sandbox.workflows import Workflow diff --git a/tests/sandbox/generators/test_medication_administration_generators.py b/tests/sandbox/generators/test_medication_administration_generators.py index 24062c8d..49e40082 100644 --- a/tests/sandbox/generators/test_medication_administration_generators.py +++ b/tests/sandbox/generators/test_medication_administration_generators.py @@ -17,5 +17,5 @@ def test_MedicationAdministrationGenerator(): result = MedicationAdministrationGenerator.generate("Patient/123", "Encounter/123") assert result.id is not None assert result.status is not None - assert result.medication is not None - assert result.medication.concept.coding[0].code in value_set + assert result.medicationCodeableConcept is not None + assert result.medicationCodeableConcept.coding[0].code in value_set diff --git a/tests/sandbox/generators/test_medication_request_generators.py b/tests/sandbox/generators/test_medication_request_generators.py index 4d7437cf..aa325a5a 100644 --- a/tests/sandbox/generators/test_medication_request_generators.py +++ b/tests/sandbox/generators/test_medication_request_generators.py @@ -21,5 +21,5 @@ def test_MedicationRequestGenerator(): value_set = [x.code for x in MedicationRequestMedication().value_set] assert medication_request is not None assert medication_request.id is not None - assert medication_request.medication.concept.coding[0].code in value_set + assert medication_request.medicationCodeableConcept.coding[0].code in value_set assert medication_request.intent is not None diff --git a/tests/sandbox/generators/test_practitioner_generators.py b/tests/sandbox/generators/test_practitioner_generators.py index 75e8c9f1..b5354326 100644 --- a/tests/sandbox/generators/test_practitioner_generators.py +++ b/tests/sandbox/generators/test_practitioner_generators.py @@ -28,11 +28,10 @@ def test_practitioner_data_generator(): assert qualification_data.code is not None assert qualification_data.period is not None - # Assert that the communication data has the expected pydantic fields + # Assert that the communication data has the expected pydantic fields (R4B: List[CodeableConcept]) communication_data = practitioner_data.communication[0] - assert communication_data.id is not None - assert communication_data.language is not None - assert communication_data.preferred is not None + assert communication_data is not None + assert communication_data.coding is not None def test_practitioner_qualification_generator(): @@ -61,7 +60,6 @@ def test_practitioner_communication_generator(): # Assert that the communication is not empty assert communication is not None - # Assert that the communication has the expected pydantic fields - assert communication.id is not None - assert communication.language is not None - assert communication.preferred is not None + # Assert that the communication has the expected pydantic fields (R4B: CodeableConcept) + assert communication is not None + assert communication.coding is not None diff --git a/tests/sandbox/test_mimic_loader.py b/tests/sandbox/test_mimic_loader.py index bf4e10dd..83a88735 100644 --- a/tests/sandbox/test_mimic_loader.py +++ b/tests/sandbox/test_mimic_loader.py @@ -28,15 +28,13 @@ def mock_medication_resources(): "resourceType": "MedicationStatement", "id": "med-1", "status": "recorded", - "medication": { - "concept": { - "coding": [ - { - "system": "http://www.nlm.nih.gov/research/umls/rxnorm", - "code": "313782", - } - ] - } + "medicationCodeableConcept": { + "coding": [ + { + "system": "http://www.nlm.nih.gov/research/umls/rxnorm", + "code": "313782", + } + ] }, "subject": {"reference": "Patient/123"}, }, @@ -44,15 +42,13 @@ def mock_medication_resources(): "resourceType": "MedicationStatement", "id": "med-2", "status": "recorded", - "medication": { - "concept": { - "coding": [ - { - "system": "http://www.nlm.nih.gov/research/umls/rxnorm", - "code": "197361", - } - ] - } + "medicationCodeableConcept": { + "coding": [ + { + "system": "http://www.nlm.nih.gov/research/umls/rxnorm", + "code": "197361", + } + ] }, "subject": {"reference": "Patient/456"}, }, @@ -229,15 +225,13 @@ def test_mimic_loader_handles_malformed_json(temp_mimic_data_dir): "resourceType": "MedicationStatement", "id": "med-1", "status": "recorded", - "medication": { - "concept": { - "coding": [ - { - "system": "http://www.nlm.nih.gov/research/umls/rxnorm", - "code": "313782", - } - ] - } + "medicationCodeableConcept": { + "coding": [ + { + "system": "http://www.nlm.nih.gov/research/umls/rxnorm", + "code": "313782", + } + ] }, "subject": {"reference": "Patient/123"}, } @@ -292,15 +286,13 @@ def test_mimic_loader_skips_resources_without_resource_type(temp_mimic_data_dir) "resourceType": "MedicationStatement", "id": "med-2", "status": "recorded", - "medication": { - "concept": { - "coding": [ - { - "system": "http://www.nlm.nih.gov/research/umls/rxnorm", - "code": "313782", - } - ] - } + "medicationCodeableConcept": { + "coding": [ + { + "system": "http://www.nlm.nih.gov/research/umls/rxnorm", + "code": "313782", + } + ] }, "subject": {"reference": "Patient/123"}, }, diff --git a/tests/sandbox/test_synthea_loader.py b/tests/sandbox/test_synthea_loader.py index 0910b91b..0dd4fcb3 100644 --- a/tests/sandbox/test_synthea_loader.py +++ b/tests/sandbox/test_synthea_loader.py @@ -81,15 +81,13 @@ def mock_patient_bundle(): "resourceType": "MedicationStatement", "id": "med-1", "status": "recorded", - "medication": { - "concept": { - "coding": [ - { - "system": "http://www.nlm.nih.gov/research/umls/rxnorm", - "code": "313782", - } - ] - } + "medicationCodeableConcept": { + "coding": [ + { + "system": "http://www.nlm.nih.gov/research/umls/rxnorm", + "code": "313782", + } + ] }, "subject": { "reference": "Patient/a969c177-a995-7b89-7b6d-885214dfa253" From 674a045a3b8fd75f156f778406292c0658c71ecf Mon Sep 17 00:00:00 2001 From: jenniferjiangkells Date: Sat, 28 Mar 2026 02:31:44 +0000 Subject: [PATCH 02/11] Update docs --- README.md | 2 +- cookbook/multi_ehr_data_aggregation.py | 4 +- cookbook/sepsis_fhir_batch.py | 4 +- docs/cookbook/format_conversion.md | 6 +- docs/cookbook/ml_model_deployment.md | 3 +- docs/quickstart.md | 2 +- docs/reference/concepts.md | 2 +- docs/reference/gateway/fhir_gateway.md | 32 ++++----- docs/reference/gateway/gateway.md | 4 +- docs/reference/interop/generators.md | 2 +- docs/reference/utilities/fhir_helpers.md | 84 +++++----------------- docs/tutorials/clinicalflow/fhir-basics.md | 6 +- 12 files changed, 45 insertions(+), 106 deletions(-) diff --git a/README.md b/README.md index 120e462f..48cbbf7a 100644 --- a/README.md +++ b/README.md @@ -191,7 +191,7 @@ print(f"FHIR conditions: {result.fhir.problem_list}") # Auto-converted to FHIR ```python from healthchain.gateway import HealthChainAPI, FHIRGateway -from fhir.resources.patient import Patient +from healthchain.fhir.r4b import Patient # Create healthcare application app = HealthChainAPI(title="Multi-EHR Patient Data") diff --git a/cookbook/multi_ehr_data_aggregation.py b/cookbook/multi_ehr_data_aggregation.py index 16fb55f6..85050829 100644 --- a/cookbook/multi_ehr_data_aggregation.py +++ b/cookbook/multi_ehr_data_aggregation.py @@ -20,9 +20,7 @@ from dotenv import load_dotenv -from fhir.resources.bundle import Bundle -from fhir.resources.condition import Condition -from fhir.resources.annotation import Annotation +from healthchain.fhir.r4b import Bundle, Condition, Annotation from healthchain.gateway import FHIRGateway, HealthChainAPI from healthchain.gateway.clients.fhir.base import FHIRAuthConfig diff --git a/cookbook/sepsis_fhir_batch.py b/cookbook/sepsis_fhir_batch.py index 3c41dcb7..231406c7 100644 --- a/cookbook/sepsis_fhir_batch.py +++ b/cookbook/sepsis_fhir_batch.py @@ -21,9 +21,7 @@ import joblib import logging from dotenv import load_dotenv -from fhir.resources.patient import Patient -from fhir.resources.observation import Observation -from fhir.resources.riskassessment import RiskAssessment +from healthchain.fhir.r4b import Patient, Observation, RiskAssessment from healthchain.gateway import HealthChainAPI, FHIRGateway from healthchain.gateway.clients.fhir.base import FHIRAuthConfig diff --git a/docs/cookbook/format_conversion.md b/docs/cookbook/format_conversion.md index 5dd3b364..1848db3b 100644 --- a/docs/cookbook/format_conversion.md +++ b/docs/cookbook/format_conversion.md @@ -94,8 +94,7 @@ for resource in fhir_resources: Generate a CDA document from FHIR resources: ```python -from fhir.resources.condition import Condition -from fhir.resources.patient import Patient +from healthchain.fhir.r4b import Condition, Patient # Create FHIR resources patient = Patient( @@ -171,8 +170,7 @@ for resource in fhir_resources: Generate an HL7v2 message from FHIR resources: ```python -from fhir.resources.patient import Patient -from fhir.resources.encounter import Encounter +from healthchain.fhir.r4b import Patient, Encounter patient = Patient( resourceType="Patient", diff --git a/docs/cookbook/ml_model_deployment.md b/docs/cookbook/ml_model_deployment.md index 8d3fde0d..b9b01d25 100644 --- a/docs/cookbook/ml_model_deployment.md +++ b/docs/cookbook/ml_model_deployment.md @@ -355,8 +355,7 @@ Query patients from FHIR server → Run predictions → Write RiskAssessment bac Configure the [FHIRGateway](../reference/gateway/fhir_gateway.md) with your FHIR source: ```python -from fhir.resources.patient import Patient -from fhir.resources.observation import Observation +from healthchain.fhir.r4b import Patient, Observation from healthchain.gateway import FHIRGateway from healthchain.gateway.clients.fhir.base import FHIRAuthConfig from healthchain.fhir import merge_bundles diff --git a/docs/quickstart.md b/docs/quickstart.md index 30b3f2bd..492da870 100644 --- a/docs/quickstart.md +++ b/docs/quickstart.md @@ -18,7 +18,7 @@ The [**HealthChainAPI**](./reference/gateway/api.md) provides a unified interfac ```python from healthchain.gateway import HealthChainAPI, FHIRGateway -from fhir.resources.patient import Patient +from healthchain.fhir.r4b import Patient # Create your healthcare application app = HealthChainAPI(title="My Healthcare AI App") diff --git a/docs/reference/concepts.md b/docs/reference/concepts.md index 0dea45cb..58c6dd87 100644 --- a/docs/reference/concepts.md +++ b/docs/reference/concepts.md @@ -15,7 +15,7 @@ The [**HealthChainAPI**](./gateway/api.md) provides a unified interface for conn ```python from healthchain.gateway import HealthChainAPI, FHIRGateway -from fhir.resources.patient import Patient +from healthchain.fhir.r4b import Patient # Create your healthcare application app = HealthChainAPI(title="My Healthcare AI App") diff --git a/docs/reference/gateway/fhir_gateway.md b/docs/reference/gateway/fhir_gateway.md index 32f81e10..0de30f3d 100644 --- a/docs/reference/gateway/fhir_gateway.md +++ b/docs/reference/gateway/fhir_gateway.md @@ -22,7 +22,7 @@ Both handle the complexity of managing multiple FHIR clients and provide a consi ```python from healthchain.gateway import FHIRGateway -from fhir.resources.patient import Patient +from healthchain.fhir.r4b import Patient gateway = FHIRGateway() @@ -44,7 +44,7 @@ with gateway: import asyncio from healthchain.gateway import AsyncFHIRGateway -from fhir.resources.patient import Patient +from healthchain.fhir.r4b import Patient gateway = AsyncFHIRGateway() @@ -148,8 +148,7 @@ fhir://hostname:port/path?param1=value1¶m2=value2 ### Create Resources ```python -from fhir.resources.patient import Patient -from fhir.resources.humanname import HumanName +from healthchain.fhir.r4b import Patient, HumanName # Create a new patient patient = Patient( @@ -165,7 +164,7 @@ print(f"Created patient with ID: {created_patient.id}") ### Read Resources ```python -from fhir.resources.patient import Patient +from healthchain.fhir.r4b import Patient # Read a specific patient (Derrick Lin, Epic Sandbox) patient = gateway.read( @@ -179,7 +178,7 @@ patient = gateway.read( === "Sync" ```python - from fhir.resources.patient import Patient + from healthchain.fhir.r4b import Patient # Read, modify, and update (sync) patient = gateway.read(Patient, "123", "medplum") @@ -189,7 +188,7 @@ patient = gateway.read( === "Async" ```python - from fhir.resources.patient import Patient + from healthchain.fhir.r4b import Patient # Read, modify, and update (async) patient = await gateway.read(Patient, "123", "medplum") @@ -206,7 +205,7 @@ patient = gateway.read( ### Delete Resources ```python -from fhir.resources.patient import Patient +from healthchain.fhir.r4b import Patient # Delete a patient success = gateway.delete(Patient, "123", "medplum") @@ -219,8 +218,7 @@ if success: ### Basic Search ```python -from fhir.resources.patient import Patient -from fhir.resources.bundle import Bundle +from healthchain.fhir.r4b import Patient, Bundle # Search by name search_params = {"family": "Smith", "given": "John"} @@ -234,7 +232,7 @@ for entry in results.entry: ### Advanced Search ```python -from fhir.resources.patient import Patient +from healthchain.fhir.r4b import Patient # Complex search with multiple parameters search_params = { @@ -255,8 +253,7 @@ Transform handlers allow you to create custom API endpoints that process and enh === "Sync" ```python - from fhir.resources.patient import Patient - from fhir.resources.observation import Observation + from healthchain.fhir.r4b import Patient, Observation @fhir_gateway.transform(Patient) def get_enhanced_patient_summary(id: str, source: str = None) -> Patient: @@ -291,8 +288,7 @@ Transform handlers allow you to create custom API endpoints that process and enh === "Async" ```python - from fhir.resources.patient import Patient - from fhir.resources.observation import Observation + from healthchain.fhir.r4b import Patient, Observation @fhir_gateway.transform(Patient) async def get_enhanced_patient_summary(id: str, source: str = None) -> Patient: @@ -328,8 +324,7 @@ Aggregate handlers allow you to combine data from multiple FHIR sources into a s === "Sync" ```python - from fhir.resources.observation import Observation - from fhir.resources.bundle import Bundle + from healthchain.fhir.r4b import Observation, Bundle @gateway.aggregate(Observation) def aggregate_vitals(patient_id: str, sources: list = None) -> Bundle: @@ -357,8 +352,7 @@ Aggregate handlers allow you to combine data from multiple FHIR sources into a s === "Async" ```python - from fhir.resources.observation import Observation - from fhir.resources.bundle import Bundle + from healthchain.fhir.r4b import Observation, Bundle @gateway.aggregate(Observation) async def aggregate_vitals(patient_id: str, sources: list = None) -> Bundle: diff --git a/docs/reference/gateway/gateway.md b/docs/reference/gateway/gateway.md index 388efe92..8693363f 100644 --- a/docs/reference/gateway/gateway.md +++ b/docs/reference/gateway/gateway.md @@ -33,7 +33,7 @@ The Gateway handles the complex parts of healthcare integration: === "Sync" ```python from healthchain.gateway import HealthChainAPI, FHIRGateway - from fhir.resources.patient import Patient + from healthchain.fhir.r4b import Patient # Create the application app = HealthChainAPI() @@ -59,7 +59,7 @@ The Gateway handles the complex parts of healthcare integration: === "Async" ```python from healthchain.gateway import HealthChainAPI, AsyncFHIRGateway - from fhir.resources.patient import Patient + from healthchain.fhir.r4b import Patient # Create the application app = HealthChainAPI() diff --git a/docs/reference/interop/generators.md b/docs/reference/interop/generators.md index 8d49d17d..ee2a36b0 100644 --- a/docs/reference/interop/generators.md +++ b/docs/reference/interop/generators.md @@ -203,7 +203,7 @@ from healthchain.interop.template_registry import TemplateRegistry from healthchain.interop.generators import BaseGenerator from typing import List -from fhir.resources.resource import Resource +from fhir.resources.R4B.resource import Resource class CustomGenerator(BaseGenerator): diff --git a/docs/reference/utilities/fhir_helpers.md b/docs/reference/utilities/fhir_helpers.md index 40cedbca..06a304e6 100644 --- a/docs/reference/utilities/fhir_helpers.md +++ b/docs/reference/utilities/fhir_helpers.md @@ -4,80 +4,33 @@ The `fhir` module provides a set of helper functions to make it easier for you t ## FHIR Version Support -HealthChain supports multiple FHIR versions: **R5** (default), **R4B**, and **STU3**. All resource creation and helper functions accept an optional `version` parameter. +HealthChain uses **R4B by default** — this matches most production EHRs. For most use cases, import from `healthchain.fhir.r4b` and don't think about versions: -### Supported Versions +```python +from healthchain.fhir.r4b import Patient, Condition, Bundle +``` -| Version | Description | Package Path | -|---------|-------------|--------------| -| **R5** | FHIR Release 5 (default) | `fhir.resources.*` | -| **R4B** | FHIR R4B (Ballot) | `fhir.resources.R4B.*` | -| **STU3** | FHIR STU3 | `fhir.resources.STU3.*` | +FHIR resource classes are provided by the [`fhir.resources`](https://github.com/nazrulworld/fhir.resources) library. R4B is a minor ballot update to R4 with no breaking changes to core resources — it is compatible with R4 FHIR servers in practice. -### Basic Usage +!!! note "Scope of version utilities" + The version utilities below (`set_default_version`, `get_fhir_resource`, etc.) only affect resource creation helpers in your own code. They do **not** change how the FHIR gateway client deserializes responses from EHR servers — that is always R4B. -```python -from healthchain.fhir import ( - FHIRVersion, - get_fhir_resource, - set_default_version, - fhir_version_context, - convert_resource, - create_condition, -) +### Explicit version control -# Get a resource class for a specific version -Patient_R4B = get_fhir_resource("Patient", "R4B") -Patient_R5 = get_fhir_resource("Patient", FHIRVersion.R5) +For cases where you need to work with R5 or STU3 resources explicitly: -# Create resources with a specific version -condition_r4b = create_condition( - subject="Patient/123", - code="38341003", - display="Hypertension", - version="R4B" # Creates R4B Condition -) +```python +from healthchain.fhir import get_fhir_resource, fhir_version_context, convert_resource -# Set the default version for the session -set_default_version("R4B") +# Get a resource class for a specific version +Patient_R5 = get_fhir_resource("Patient", "R5") -# Use context manager for temporary version changes +# Temporarily create resources in a different version with fhir_version_context("STU3"): - # All resources created here use STU3 condition = create_condition(subject="Patient/123", code="123") -``` - -### Version Conversion -Convert resources between FHIR versions using `convert_resource()`: - -```python -from healthchain.fhir import get_fhir_resource, convert_resource - -# Create an R5 Patient -Patient_R5 = get_fhir_resource("Patient") -patient_r5 = Patient_R5(id="test-123", gender="male") - -# Convert to R4B +# Convert between versions (serialize/deserialize — not lossless across major field renames) patient_r4b = convert_resource(patient_r5, "R4B") -print(patient_r4b.__class__.__module__) # fhir.resources.R4B.patient -``` - -!!! warning "Version Conversion Limitations" - The `convert_resource()` function uses a serialize/deserialize approach. Field mappings between FHIR versions may not be 1:1 - some fields may be added, removed, or renamed between versions. Complex resources with version-specific fields may require manual handling. - -### Version Detection - -Detect the FHIR version of an existing resource: - -```python -from healthchain.fhir import get_resource_version, get_fhir_resource - -Patient_R4B = get_fhir_resource("Patient", "R4B") -patient = Patient_R4B(id="123") - -version = get_resource_version(patient) -print(version) # FHIRVersion.R4B ``` ### API Reference @@ -85,9 +38,8 @@ print(version) # FHIRVersion.R4B | Function | Description | |----------|-------------| | `get_fhir_resource(name, version)` | Get a resource class for a specific version | -| `get_default_version()` | Get the current default FHIR version | -| `set_default_version(version)` | Set the global default FHIR version | -| `reset_default_version()` | Reset to library default (R5) | +| `set_default_version(version)` | Override the default for subsequent helper calls | +| `reset_default_version()` | Reset to R4B | | `fhir_version_context(version)` | Context manager for temporary version changes | | `convert_resource(resource, version)` | Convert a resource to a different version | | `get_resource_version(resource)` | Detect the version of an existing resource | @@ -556,7 +508,7 @@ from healthchain.fhir import get_resources conditions = get_resources(bundle, "Condition") # Or using the resource type directly -from fhir.resources.condition import Condition +from healthchain.fhir.r4b import Condition conditions = get_resources(bundle, Condition) for condition in conditions: diff --git a/docs/tutorials/clinicalflow/fhir-basics.md b/docs/tutorials/clinicalflow/fhir-basics.md index 8d25c052..db4fd4e6 100644 --- a/docs/tutorials/clinicalflow/fhir-basics.md +++ b/docs/tutorials/clinicalflow/fhir-basics.md @@ -72,7 +72,7 @@ HealthChain provides utilities to work with FHIR resources easily: ```python from healthchain.fhir import create_condition, create_patient -from fhir.resources.patient import Patient +from healthchain.fhir.r4b import Patient # Create a patient with basic demographics # Note: create_patient generates an auto-prefixed ID (e.g., "hc-abc123") @@ -97,7 +97,7 @@ print(f"With condition: {condition.code.coding[0].display}") ??? info "FHIR Versions in HealthChain" - HealthChain uses **FHIR R5** as the default version. However, **STU3** and **R4B** are also supported for compatibility with different EHR systems. + HealthChain uses **FHIR R4B** as the default version, matching most production EHRs (Epic, Cerner, etc.). **R5** and **STU3** are also supported. You can specify the version when working with FHIR resources, and HealthChain provides utilities for converting between versions when needed. @@ -108,7 +108,7 @@ print(f"With condition: {condition.code.coding[0].display}") When an EHR sends patient context, it often comes as a **Bundle** - a collection of related resources: ```python -from fhir.resources.bundle import Bundle +from healthchain.fhir.r4b import Bundle # A bundle might contain a patient, their conditions, and medications bundle_data = { From b0ec3c4377cdf34e22e03949393a4baeb56bc743 Mon Sep 17 00:00:00 2001 From: jenniferjiangkells Date: Sat, 28 Mar 2026 02:36:30 +0000 Subject: [PATCH 03/11] Fix __getattr__ --- healthchain/fhir/r4b.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/healthchain/fhir/r4b.py b/healthchain/fhir/r4b.py index f7c1b9db..f288f0b9 100644 --- a/healthchain/fhir/r4b.py +++ b/healthchain/fhir/r4b.py @@ -11,7 +11,12 @@ def __getattr__(name: str): - return _get(name, "R4B") + if name.startswith("__"): + raise AttributeError(name) + try: + return _get(name, "R4B") + except ValueError: + raise AttributeError(f"module 'healthchain.fhir.r4b' has no attribute {name!r}") __all__ = [ # noqa: F822 From e4986875948715af0fa471067c283f1d9ac39ab4 Mon Sep 17 00:00:00 2001 From: jenniferjiangkells Date: Sat, 28 Mar 2026 02:56:36 +0000 Subject: [PATCH 04/11] Expanded fhir resources --- healthchain/fhir/r4b.py | 53 +++++++++++++++++++++++++++++------------ 1 file changed, 38 insertions(+), 15 deletions(-) diff --git a/healthchain/fhir/r4b.py b/healthchain/fhir/r4b.py index f288f0b9..b5ee1c49 100644 --- a/healthchain/fhir/r4b.py +++ b/healthchain/fhir/r4b.py @@ -20,33 +20,56 @@ def __getattr__(name: str): __all__ = [ # noqa: F822 + # Clinical resources "AllergyIntolerance", - "Annotation", "Appointment", - "Attachment", + "CarePlan", + "CareTeam", + "Condition", + "DiagnosticReport", + "DocumentReference", + "Encounter", + "FamilyMemberHistory", + "Goal", + "Immunization", + "MedicationRequest", + "MedicationStatement", + "Observation", + "Procedure", + "Questionnaire", + "QuestionnaireResponse", + "RiskAssessment", + "ServiceRequest", + "Specimen", + # Administrative resources + "Device", + "Location", + "Organization", + "Patient", + "Practitioner", + "Task", + # Infrastructure resources "Bundle", "BundleEntry", "CapabilityStatement", - "CarePlan", + "OperationOutcome", + "Provenance", + "ProvenanceAgent", + # Element types + "Address", + "Annotation", + "Attachment", "CodeableConcept", "Coding", - "Condition", "ContactPoint", - "DocumentReference", "Dosage", - "Encounter", + "Extension", "HumanName", "Identifier", - "MedicationRequest", - "MedicationStatement", "Meta", - "Observation", - "OperationOutcome", - "Patient", "Period", - "Procedure", - "Provenance", - "ProvenanceAgent", + "Quantity", + "Range", "Reference", - "RiskAssessment", + "Timing", ] From 1a615963477b568e33ab85071f8d9b4e6b91f56b Mon Sep 17 00:00:00 2001 From: jenniferjiangkells Date: Mon, 30 Mar 2026 12:14:24 +0100 Subject: [PATCH 05/11] Remove explicit R5 fallbacks on internal methods --- healthchain/fhir/bundlehelpers.py | 13 +- healthchain/fhir/dataframe.py | 8 +- healthchain/fhir/elementhelpers.py | 40 +-- healthchain/fhir/resourcehelpers.py | 245 +++++------------- .../sandbox/generators/conditiongenerators.py | 7 +- tests/fhir/test_version.py | 72 ----- 6 files changed, 81 insertions(+), 304 deletions(-) diff --git a/healthchain/fhir/bundlehelpers.py b/healthchain/fhir/bundlehelpers.py index cfcff9a3..35cc7dcf 100644 --- a/healthchain/fhir/bundlehelpers.py +++ b/healthchain/fhir/bundlehelpers.py @@ -8,13 +8,10 @@ - extract_*(): extract resources from a bundle """ -from typing import List, Type, TypeVar, Optional, Union, TYPE_CHECKING +from typing import List, Type, TypeVar, Optional, Union from fhir.resources.R4B.bundle import Bundle, BundleEntry from fhir.resources.resource import Resource -if TYPE_CHECKING: - from healthchain.fhir.version import FHIRVersion - T = TypeVar("T", bound=Resource) @@ -48,17 +45,14 @@ def add_resource( def get_resource_type( resource_type: Union[str, Type[Resource]], - version: Optional[Union["FHIRVersion", str]] = None, ) -> Type[Resource]: """Get the resource type class from string or type. Args: resource_type: String name of the resource type (e.g. "Condition") or the type itself - version: Optional FHIR version (e.g., "R4B", "STU3", or FHIRVersion enum). - If None, uses the current default version. Returns: - The resource type class for the specified version + The R4B resource type class Raises: ValueError: If the resource type is not supported or cannot be imported @@ -71,10 +65,9 @@ def get_resource_type( f"Resource type must be a string or Resource class, got {type(resource_type)}" ) - # Use version manager for dynamic import with version support from healthchain.fhir.version import get_fhir_resource - return get_fhir_resource(resource_type, version) + return get_fhir_resource(resource_type) def get_resources( diff --git a/healthchain/fhir/dataframe.py b/healthchain/fhir/dataframe.py index 88d481ae..77673b2b 100644 --- a/healthchain/fhir/dataframe.py +++ b/healthchain/fhir/dataframe.py @@ -576,15 +576,9 @@ def _flatten_medications( features = {} for med in medications: - # R4B: medicationCodeableConcept; R5: medication.concept med_concept = _get_field(med, "medicationCodeableConcept") if not med_concept: - medication = _get_field(med, "medication") - if not medication: - continue - med_concept = _get_field(medication, "concept") - if not med_concept: - continue + continue coding_array = _get_field(med_concept, "coding") if not coding_array or len(coding_array) == 0: diff --git a/healthchain/fhir/elementhelpers.py b/healthchain/fhir/elementhelpers.py index 8eed01a3..b431d269 100644 --- a/healthchain/fhir/elementhelpers.py +++ b/healthchain/fhir/elementhelpers.py @@ -8,10 +8,11 @@ import base64 import datetime -from typing import Optional, List, Dict, Any, Union, TYPE_CHECKING +from typing import Optional, List, Dict, Any -if TYPE_CHECKING: - from healthchain.fhir.version import FHIRVersion +from fhir.resources.R4B.codeableconcept import CodeableConcept +from fhir.resources.R4B.coding import Coding +from fhir.resources.R4B.attachment import Attachment logger = logging.getLogger(__name__) @@ -20,7 +21,6 @@ def create_single_codeable_concept( code: str, display: Optional[str] = None, system: Optional[str] = "http://snomed.info/sct", - version: Optional[Union["FHIRVersion", str]] = None, ) -> Any: """ Create a minimal FHIR CodeableConcept with a single coding. @@ -29,16 +29,10 @@ def create_single_codeable_concept( code: REQUIRED. The code value from the code system display: The display name for the code system: The code system (default: SNOMED CT) - version: FHIR version to use (e.g., "R4B", "STU3"). Defaults to current default. Returns: CodeableConcept: A FHIR CodeableConcept resource with a single coding """ - from healthchain.fhir.version import get_fhir_resource - - CodeableConcept = get_fhir_resource("CodeableConcept", version) - Coding = get_fhir_resource("Coding", version) - return CodeableConcept(coding=[Coding(system=system, code=code, display=display)]) @@ -47,7 +41,6 @@ def create_single_reaction( display: Optional[str] = None, system: Optional[str] = "http://snomed.info/sct", severity: Optional[str] = None, - version: Optional[Union["FHIRVersion", str]] = None, ) -> List[Dict[str, Any]]: """Create a minimal FHIR Reaction with a single coding. @@ -60,34 +53,17 @@ def create_single_reaction( display: The display name for the manifestation code system: The code system for the manifestation code (default: SNOMED CT) severity: The severity of the reaction (mild, moderate, severe) - version: FHIR version to use (e.g., "R4B", "STU3"). Defaults to current default. Returns: A list containing a single FHIR Reaction dictionary with manifestation and severity fields """ - from healthchain.fhir.version import ( - get_fhir_resource, - FHIRVersion, - _resolve_version, - ) - - resolved = _resolve_version(version) - CodeableConcept = get_fhir_resource("CodeableConcept", version) - Coding = get_fhir_resource("Coding", version) - concept = CodeableConcept( coding=[Coding(system=system, code=code, display=display)] ) - if resolved == FHIRVersion.R5: - CodeableReference = get_fhir_resource("CodeableReference", version) - manifestation = [CodeableReference(concept=concept)] - else: - manifestation = [concept] - return [ { - "manifestation": manifestation, + "manifestation": [concept], "severity": severity, } ] @@ -98,7 +74,6 @@ def create_single_attachment( data: Optional[str] = None, url: Optional[str] = None, title: Optional[str] = "Attachment created by HealthChain", - version: Optional[Union["FHIRVersion", str]] = None, ) -> Any: """Create a minimal FHIR Attachment. @@ -110,15 +85,10 @@ def create_single_attachment( data: The actual data content to be base64 encoded url: The URL where the data can be found title: A title for the attachment (default: "Attachment created by HealthChain") - version: FHIR version to use (e.g., "R4B", "STU3"). Defaults to current default. Returns: Attachment: A FHIR Attachment resource with basic metadata and content """ - from healthchain.fhir.version import get_fhir_resource - - Attachment = get_fhir_resource("Attachment", version) - if not data and not url: logger.warning("No data or url provided for attachment") diff --git a/healthchain/fhir/resourcehelpers.py b/healthchain/fhir/resourcehelpers.py index 84317d2c..3407f704 100644 --- a/healthchain/fhir/resourcehelpers.py +++ b/healthchain/fhir/resourcehelpers.py @@ -13,12 +13,19 @@ import logging import datetime -from typing import List, Optional, Dict, Any, Union, TYPE_CHECKING - -# Keep static imports only for types that are always version-compatible -# and used in signatures/type hints -from fhir.resources.codeableconcept import CodeableConcept -from fhir.resources.reference import Reference +from typing import List, Optional, Dict, Any + +from fhir.resources.R4B.allergyintolerance import AllergyIntolerance +from fhir.resources.R4B.condition import Condition +from fhir.resources.R4B.documentreference import DocumentReference +from fhir.resources.R4B.identifier import Identifier +from fhir.resources.R4B.medicationstatement import MedicationStatement +from fhir.resources.R4B.observation import Observation +from fhir.resources.R4B.patient import Patient +from fhir.resources.R4B.quantity import Quantity +from fhir.resources.R4B.reference import Reference +from fhir.resources.R4B.riskassessment import RiskAssessment +from fhir.resources.R4B.codeableconcept import CodeableConcept from healthchain.fhir.elementhelpers import ( create_single_codeable_concept, @@ -26,9 +33,6 @@ ) from healthchain.fhir.utilities import _generate_id -if TYPE_CHECKING: - from healthchain.fhir.version import FHIRVersion - logger = logging.getLogger(__name__) @@ -38,8 +42,7 @@ def create_condition( code: Optional[str] = None, display: Optional[str] = None, system: Optional[str] = "http://snomed.info/sct", - version: Optional[Union["FHIRVersion", str]] = None, -) -> Any: +) -> Condition: """ Create a minimal active FHIR Condition. If you need to create a more complex condition, use the FHIR Condition resource directly. @@ -51,35 +54,25 @@ def create_condition( code: The condition code display: The display name for the condition system: The code system (default: SNOMED CT) - version: FHIR version to use (e.g., "R4B", "STU3"). Defaults to current default. Returns: Condition: A FHIR Condition resource with an auto-generated ID prefixed with 'hc-' """ - from healthchain.fhir.version import get_fhir_resource - - Condition = get_fhir_resource("Condition", version) - ReferenceClass = get_fhir_resource("Reference", version) - - if code: - condition_code = create_single_codeable_concept(code, display, system, version) - else: - condition_code = None + condition_code = ( + create_single_codeable_concept(code, display, system) if code else None + ) - condition = Condition( + return Condition( id=_generate_id(), - subject=ReferenceClass(reference=subject), + subject=Reference(reference=subject), clinicalStatus=create_single_codeable_concept( code=clinical_status, display=clinical_status.capitalize(), system="http://terminology.hl7.org/CodeSystem/condition-clinical", - version=version, ), code=condition_code, ) - return condition - def create_medication_statement( subject: str, @@ -87,8 +80,7 @@ def create_medication_statement( code: Optional[str] = None, display: Optional[str] = None, system: Optional[str] = "http://snomed.info/sct", - version: Optional[Union["FHIRVersion", str]] = None, -) -> Any: +) -> MedicationStatement: """ Create a minimal recorded FHIR MedicationStatement. If you need to create a more complex medication statement, use the FHIR MedicationStatement resource directly. @@ -100,50 +92,28 @@ def create_medication_statement( code: The medication code display: The display name for the medication system: The code system (default: SNOMED CT) - version: FHIR version to use (e.g., "R4B", "STU3"). Defaults to current default. Returns: MedicationStatement: A FHIR MedicationStatement resource with an auto-generated ID prefixed with 'hc-' """ - from healthchain.fhir.version import ( - get_fhir_resource, - FHIRVersion, - _resolve_version, + medication_concept = ( + create_single_codeable_concept(code, display, system) if code else None ) - resolved = _resolve_version(version) - MedicationStatement = get_fhir_resource("MedicationStatement", version) - ReferenceClass = get_fhir_resource("Reference", version) - - if code: - medication_concept = create_single_codeable_concept( - code, display, system, version - ) - else: - medication_concept = None - - if resolved == FHIRVersion.R5: - med_kwargs = {"medication": {"concept": medication_concept}} - else: - med_kwargs = {"medicationCodeableConcept": medication_concept} - - medication = MedicationStatement( + return MedicationStatement( id=_generate_id(), - subject=ReferenceClass(reference=subject), + subject=Reference(reference=subject), status=status, - **med_kwargs, + medicationCodeableConcept=medication_concept, ) - return medication - def create_allergy_intolerance( patient: str, code: Optional[str] = None, display: Optional[str] = None, system: Optional[str] = "http://snomed.info/sct", - version: Optional[Union["FHIRVersion", str]] = None, -) -> Any: +) -> AllergyIntolerance: """ Create a minimal active FHIR AllergyIntolerance. If you need to create a more complex allergy intolerance, use the FHIR AllergyIntolerance resource directly. @@ -154,29 +124,20 @@ def create_allergy_intolerance( code: The allergen code display: The display name for the allergen system: The code system (default: SNOMED CT) - version: FHIR version to use (e.g., "R4B", "STU3"). Defaults to current default. Returns: AllergyIntolerance: A FHIR AllergyIntolerance resource with an auto-generated ID prefixed with 'hc-' """ - from healthchain.fhir.version import get_fhir_resource - - AllergyIntolerance = get_fhir_resource("AllergyIntolerance", version) - ReferenceClass = get_fhir_resource("Reference", version) - - if code: - allergy_code = create_single_codeable_concept(code, display, system, version) - else: - allergy_code = None + allergy_code = ( + create_single_codeable_concept(code, display, system) if code else None + ) - allergy = AllergyIntolerance( + return AllergyIntolerance( id=_generate_id(), - patient=ReferenceClass(reference=patient), + patient=Reference(reference=patient), code=allergy_code, ) - return allergy - def create_value_quantity_observation( code: str, @@ -187,8 +148,7 @@ def create_value_quantity_observation( system: str = "http://loinc.org", display: Optional[str] = None, effective_datetime: Optional[str] = None, - version: Optional[Union["FHIRVersion", str]] = None, -) -> Any: +) -> Observation: """ Create a minimal FHIR Observation for vital signs or laboratory values. If you need to create a more complex observation, use the FHIR Observation resource directly. @@ -203,29 +163,21 @@ def create_value_quantity_observation( display: The display name for the observation code effective_datetime: When the observation was made (ISO format). Uses current time if not provided. subject: Reference to the patient (e.g. "Patient/123") - version: FHIR version to use (e.g., "R4B", "STU3"). Defaults to current default. Returns: Observation: A FHIR Observation resource with an auto-generated ID prefixed with 'hc-' """ - from healthchain.fhir.version import get_fhir_resource - - Observation = get_fhir_resource("Observation", version) - ReferenceClass = get_fhir_resource("Reference", version) - Quantity = get_fhir_resource("Quantity", version) - if not effective_datetime: effective_datetime = datetime.datetime.now(datetime.timezone.utc).strftime( "%Y-%m-%dT%H:%M:%S%z" ) - subject_ref = None - if subject is not None: - subject_ref = ReferenceClass(reference=subject) - observation = Observation( + subject_ref = Reference(reference=subject) if subject is not None else None + + return Observation( id=_generate_id(), status=status, - code=create_single_codeable_concept(code, display, system, version), + code=create_single_codeable_concept(code, display, system), subject=subject_ref, effectiveDateTime=effective_datetime, valueQuantity=Quantity( @@ -233,16 +185,13 @@ def create_value_quantity_observation( ), ) - return observation - def create_patient( gender: Optional[str] = None, birth_date: Optional[str] = None, identifier: Optional[str] = None, identifier_system: Optional[str] = "http://hospital.example.org", - version: Optional[Union["FHIRVersion", str]] = None, -) -> Any: +) -> Patient: """ Create a minimal FHIR Patient resource with basic gender and birthdate If you need to create a more complex patient, use the FHIR Patient resource directly @@ -253,19 +202,11 @@ def create_patient( birth_date: Birth date in YYYY-MM-DD format identifier: Optional identifier value for the patient (e.g., MRN) identifier_system: The system for the identifier (default: "http://hospital.example.org") - version: FHIR version to use (e.g., "R4B", "STU3"). Defaults to current default. Returns: Patient: A FHIR Patient resource with an auto-generated ID prefixed with 'hc-' """ - from healthchain.fhir.version import get_fhir_resource - - Patient = get_fhir_resource("Patient", version) - Identifier = get_fhir_resource("Identifier", version) - - patient_id = _generate_id() - - patient_data: Dict[str, Any] = {"id": patient_id} + patient_data: Dict[str, Any] = {"id": _generate_id()} if birth_date: patient_data["birthDate"] = birth_date @@ -275,14 +216,10 @@ def create_patient( if identifier: patient_data["identifier"] = [ - Identifier( - system=identifier_system, - value=identifier, - ) + Identifier(system=identifier_system, value=identifier) ] - patient = Patient(**patient_data) - return patient + return Patient(**patient_data) def create_risk_assessment_from_prediction( @@ -293,8 +230,7 @@ def create_risk_assessment_from_prediction( basis: Optional[List[Reference]] = None, comment: Optional[str] = None, occurrence_datetime: Optional[str] = None, - version: Optional[Union["FHIRVersion", str]] = None, -) -> Any: +) -> RiskAssessment: """ Create a FHIR RiskAssessment from ML model prediction output. If you need to create a more complex risk assessment, use the FHIR RiskAssessment resource directly. @@ -311,7 +247,6 @@ def create_risk_assessment_from_prediction( basis: Optional list of References to observations or other resources used as input comment: Optional text comment about the assessment occurrence_datetime: When the assessment was made (ISO format). Uses current time if not provided. - version: FHIR version to use (e.g., "R4B", "STU3"). Defaults to current default. Returns: RiskAssessment: A FHIR RiskAssessment resource with an auto-generated ID prefixed with 'hc-' @@ -324,11 +259,6 @@ def create_risk_assessment_from_prediction( ... } >>> risk = create_risk_assessment("Patient/123", prediction) """ - from healthchain.fhir.version import get_fhir_resource - - RiskAssessment = get_fhir_resource("RiskAssessment", version) - ReferenceClass = get_fhir_resource("Reference", version) - if not occurrence_datetime: occurrence_datetime = datetime.datetime.now(datetime.timezone.utc).strftime( "%Y-%m-%dT%H:%M:%S%z" @@ -340,14 +270,11 @@ def create_risk_assessment_from_prediction( code=outcome["code"], display=outcome.get("display"), system=outcome.get("system", "http://snomed.info/sct"), - version=version, ) else: outcome_concept = outcome - prediction_data: Dict[str, Any] = { - "outcome": outcome_concept, - } + prediction_data: Dict[str, Any] = {"outcome": outcome_concept} if "probability" in prediction: prediction_data["probabilityDecimal"] = prediction["probability"] @@ -357,13 +284,12 @@ def create_risk_assessment_from_prediction( code=prediction["qualitative_risk"], display=prediction["qualitative_risk"].capitalize(), system="http://terminology.hl7.org/CodeSystem/risk-probability", - version=version, ) risk_assessment_data: Dict[str, Any] = { "id": _generate_id(), "status": status, - "subject": ReferenceClass(reference=subject), + "subject": Reference(reference=subject), "occurrenceDateTime": occurrence_datetime, "prediction": [prediction_data], } @@ -377,9 +303,7 @@ def create_risk_assessment_from_prediction( if comment: risk_assessment_data["note"] = [{"text": comment}] - risk_assessment = RiskAssessment(**risk_assessment_data) - - return risk_assessment + return RiskAssessment(**risk_assessment_data) def create_document_reference( @@ -389,8 +313,7 @@ def create_document_reference( status: str = "current", description: Optional[str] = "DocumentReference created by HealthChain", attachment_title: Optional[str] = "Attachment created by HealthChain", - version: Optional[Union["FHIRVersion", str]] = None, -) -> Any: +) -> DocumentReference: """ Create a minimal FHIR DocumentReference. If you need to create a more complex document reference, use the FHIR DocumentReference resource directly. @@ -403,16 +326,11 @@ def create_document_reference( status: REQUIRED. Status of the document reference (default: current) description: Description of the document reference attachment_title: Title for the document attachment - version: FHIR version to use (e.g., "R4B", "STU3"). Defaults to current default. Returns: DocumentReference: A FHIR DocumentReference resource with an auto-generated ID prefixed with 'hc-' """ - from healthchain.fhir.version import get_fhir_resource - - DocumentReference = get_fhir_resource("DocumentReference", version) - - document_reference = DocumentReference( + return DocumentReference( id=_generate_id(), status=status, date=datetime.datetime.now(datetime.timezone.utc).strftime( @@ -426,14 +344,11 @@ def create_document_reference( data=data, url=url, title=attachment_title, - version=version, ) } ], ) - return document_reference - def create_document_reference_content( attachment_data: Optional[str] = None, @@ -441,7 +356,6 @@ def create_document_reference_content( content_type: str = "text/plain", language: Optional[str] = "en-US", title: Optional[str] = None, - version: Optional[Union["FHIRVersion", str]] = None, **kwargs, ) -> Dict[str, Any]: """Create a FHIR DocumentReferenceContent object. @@ -456,7 +370,6 @@ def create_document_reference_content( content_type: MIME type (e.g., 'text/plain', 'text/html', 'application/pdf') (default: text/plain) language: Language code (default: en-US) title: Optional title for the content (default: "Attachment created by HealthChain") - version: FHIR version to use (e.g., "R4B", "STU3"). Defaults to current default. **kwargs: Additional DocumentReferenceContent fields (e.g., format, profile) Returns: @@ -497,12 +410,9 @@ def create_document_reference_content( data=attachment_data, url=url, title=title, - version=version, ) - content: Dict[str, Any] = { - "attachment": attachment, - } + content: Dict[str, Any] = {"attachment": attachment} if language: content["language"] = language @@ -513,17 +423,15 @@ def create_document_reference_content( def set_condition_category( - condition: Any, + condition: Condition, category: str, - version: Optional[Union["FHIRVersion", str]] = None, -) -> Any: +) -> Condition: """ Set the category of a FHIR Condition to either 'problem-list-item' or 'encounter-diagnosis'. Args: condition: The FHIR Condition resource to modify category: The category to set. Must be 'problem-list-item' or 'encounter-diagnosis'. - version: FHIR version to use. If None, attempts to detect from the condition resource. Returns: Condition: The modified FHIR Condition resource with the specified category set @@ -531,11 +439,11 @@ def set_condition_category( Raises: ValueError: If the category is not one of the allowed values. """ - from healthchain.fhir.version import get_resource_version + from healthchain.fhir.version import get_fhir_resource, get_resource_version - # Detect version from resource if not provided - if version is None: - version = get_resource_version(condition) + version = get_resource_version(condition) + CodeableConceptCls = get_fhir_resource("CodeableConcept", version) + CodingCls = get_fhir_resource("Coding", version) allowed_categories = { "problem-list-item": { @@ -554,11 +462,14 @@ def set_condition_category( cat_info = allowed_categories[category] condition.category = [ - create_single_codeable_concept( - code=cat_info["code"], - display=cat_info["display"], - system="http://terminology.hl7.org/CodeSystem/condition-category", - version=version, + CodeableConceptCls( + coding=[ + CodingCls( + system="http://terminology.hl7.org/CodeSystem/condition-category", + code=cat_info["code"], + display=cat_info["display"], + ) + ] ) ] return condition @@ -569,7 +480,6 @@ def add_provenance_metadata( source: str, tag_code: Optional[str] = None, tag_display: Optional[str] = None, - version: Optional[Union["FHIRVersion", str]] = None, ) -> Any: """Add provenance metadata to a FHIR resource. @@ -581,7 +491,6 @@ def add_provenance_metadata( source: Name of the source system (e.g., "epic", "cerner") tag_code: Optional tag code for processing operations (e.g., "aggregated", "deduplicated") tag_display: Optional display text for the tag - version: FHIR version to use. If None, attempts to detect from the resource. Returns: Resource: The resource with added provenance metadata @@ -592,29 +501,22 @@ def add_provenance_metadata( """ from healthchain.fhir.version import get_fhir_resource, get_resource_version - # Detect version from resource if not provided - if version is None: - version = get_resource_version(resource) - - Meta = get_fhir_resource("Meta", version) - Coding = get_fhir_resource("Coding", version) + version = get_resource_version(resource) + MetaCls = get_fhir_resource("Meta", version) + CodingCls = get_fhir_resource("Coding", version) if not resource.meta: - resource.meta = Meta() + resource.meta = MetaCls() - # Add source system identifier resource.meta.source = f"urn:healthchain:source:{source}" - - # Update timestamp resource.meta.lastUpdated = datetime.datetime.now(datetime.timezone.utc).isoformat() - # Add processing tag if provided if tag_code: if not resource.meta.tag: resource.meta.tag = [] resource.meta.tag.append( - Coding( + CodingCls( system="https://dotimplement.github.io/HealthChain/fhir/tags", code=tag_code, display=tag_display or tag_code, @@ -625,12 +527,11 @@ def add_provenance_metadata( def add_coding_to_codeable_concept( - codeable_concept: Any, + codeable_concept: CodeableConcept, code: str, system: str, display: Optional[str] = None, - version: Optional[Union["FHIRVersion", str]] = None, -) -> Any: +) -> CodeableConcept: """Add a coding to an existing CodeableConcept. Useful for adding standardized codes (e.g., SNOMED CT) to resources that already @@ -641,7 +542,6 @@ def add_coding_to_codeable_concept( code: The code value from the code system system: The code system URI display: Optional display text for the code - version: FHIR version to use. If None, attempts to detect from the CodeableConcept. Returns: CodeableConcept: The updated CodeableConcept with the new coding added @@ -658,15 +558,12 @@ def add_coding_to_codeable_concept( """ from healthchain.fhir.version import get_fhir_resource, get_resource_version - # Detect version from CodeableConcept if not provided - if version is None: - version = get_resource_version(codeable_concept) - - Coding = get_fhir_resource("Coding", version) + version = get_resource_version(codeable_concept) + CodingCls = get_fhir_resource("Coding", version) if not codeable_concept.coding: codeable_concept.coding = [] - codeable_concept.coding.append(Coding(system=system, code=code, display=display)) + codeable_concept.coding.append(CodingCls(system=system, code=code, display=display)) return codeable_concept diff --git a/healthchain/sandbox/generators/conditiongenerators.py b/healthchain/sandbox/generators/conditiongenerators.py index 41b0726d..96c89b58 100644 --- a/healthchain/sandbox/generators/conditiongenerators.py +++ b/healthchain/sandbox/generators/conditiongenerators.py @@ -29,7 +29,6 @@ def generate(): elements=("active", "recurrence", "inactive", "resolved") ), system="http://terminology.hl7.org/CodeSystem/condition-clinical", - version="R4B", ) @@ -40,7 +39,6 @@ def generate(): return create_single_codeable_concept( code=faker.random_element(elements=("provisional", "confirmed")), system="http://terminology.hl7.org/CodeSystem/condition-ver-status", - version="R4B", ) @@ -53,7 +51,6 @@ def generate(): elements=("55607006", "404684003") ), # Snomed Codes -> probably want to overwrite with template system="http://snomed.info/sct", - version="R4B", ) @@ -75,7 +72,6 @@ def generate(): return create_single_codeable_concept( code=faker.random_element(elements=("24484000", "6736007", "255604002")), system="http://snomed.info/sct", - version="R4B", ) @@ -102,7 +98,6 @@ def generate(): code=faker.random_element(elements=("38266002",)), display=faker.random_element(elements=("Entire body as a whole",)), system="http://snomed.info/sct", - version="R4B", ) @@ -121,7 +116,7 @@ def generate( code = generator_registry.get("SnomedCodeGenerator").generate( constraints=constraints ) - condition = create_condition(subject=subject_reference, version="R4B") + condition = create_condition(subject=subject_reference) condition.clinicalStatus = generator_registry.get( "ClinicalStatusGenerator" ).generate() diff --git a/tests/fhir/test_version.py b/tests/fhir/test_version.py index c3553ff1..a44aaf47 100644 --- a/tests/fhir/test_version.py +++ b/tests/fhir/test_version.py @@ -245,75 +245,3 @@ class FakeResource: fake = FakeResource() version = get_resource_version(fake) assert version is None - - -# Integration tests for versioned resource helpers - - -def test_create_condition_with_version(): - """Test create_condition with version parameter.""" - from healthchain.fhir import create_condition - - cond_default = create_condition("Patient/1", code="123", display="Test") - cond_r4b = create_condition("Patient/1", code="123", display="Test", version="R4B") - cond_r5 = create_condition("Patient/1", code="123", display="Test", version="R5") - - assert cond_default.__class__.__module__ == "fhir.resources.R4B.condition" - assert cond_r4b.__class__.__module__ == "fhir.resources.R4B.condition" - assert cond_r5.__class__.__module__ == "fhir.resources.condition" - - -def test_create_patient_with_version(): - """Test create_patient with version parameter.""" - from healthchain.fhir import create_patient - - patient_default = create_patient(gender="male") - patient_r4b = create_patient(gender="female", version="R4B") - patient_r5 = create_patient(gender="other", version="R5") - - assert patient_default.__class__.__module__ == "fhir.resources.R4B.patient" - assert patient_r4b.__class__.__module__ == "fhir.resources.R4B.patient" - assert patient_r5.__class__.__module__ == "fhir.resources.patient" - - -def test_create_observation_with_version(): - """Test create_value_quantity_observation with version parameter.""" - from healthchain.fhir import create_value_quantity_observation - - obs_default = create_value_quantity_observation(code="12345", value=98.6, unit="F") - obs_r4b = create_value_quantity_observation( - code="12345", value=98.6, unit="F", version="R4B" - ) - obs_r5 = create_value_quantity_observation( - code="12345", value=98.6, unit="F", version="R5" - ) - - assert obs_default.__class__.__module__ == "fhir.resources.R4B.observation" - assert obs_r4b.__class__.__module__ == "fhir.resources.R4B.observation" - assert obs_r5.__class__.__module__ == "fhir.resources.observation" - - -def test_get_resource_type_with_version(): - """Test get_resource_type with version parameter.""" - from healthchain.fhir import get_resource_type - - Condition_default = get_resource_type("Condition") - Condition_R4B = get_resource_type("Condition", version="R4B") - Condition_R5 = get_resource_type("Condition", version="R5") - - assert Condition_default.__module__ == "fhir.resources.R4B.condition" - assert Condition_R4B.__module__ == "fhir.resources.R4B.condition" - assert Condition_R5.__module__ == "fhir.resources.condition" - - -def test_create_single_codeable_concept_with_version(): - """Test create_single_codeable_concept with version parameter.""" - from healthchain.fhir.elementhelpers import create_single_codeable_concept - - cc_default = create_single_codeable_concept("123", "Test") - cc_r4b = create_single_codeable_concept("123", "Test", version="R4B") - cc_r5 = create_single_codeable_concept("123", "Test", version="R5") - - assert cc_default.__class__.__module__ == "fhir.resources.R4B.codeableconcept" - assert cc_r4b.__class__.__module__ == "fhir.resources.R4B.codeableconcept" - assert cc_r5.__class__.__module__ == "fhir.resources.codeableconcept" From 7abf3b1ebcaa9424749c6f8fa6cca5062dcd161c Mon Sep 17 00:00:00 2001 From: jenniferjiangkells Date: Mon, 30 Mar 2026 16:15:49 +0100 Subject: [PATCH 06/11] Cleanup --- healthchain/fhir/__init__.py | 4 --- healthchain/fhir/dataframe.py | 17 ++++++++++--- healthchain/fhir/r4b.pyi | 28 +++++++++++++++++++++ healthchain/fhir/version.py | 4 +-- healthchain/gateway/fhir/base.py | 5 ---- healthchain/io/mappers/fhirfeaturemapper.py | 4 +-- tests/fhir/test_converters.py | 16 +++++++----- 7 files changed, 55 insertions(+), 23 deletions(-) diff --git a/healthchain/fhir/__init__.py b/healthchain/fhir/__init__.py index ee748818..565d6431 100644 --- a/healthchain/fhir/__init__.py +++ b/healthchain/fhir/__init__.py @@ -6,8 +6,6 @@ FHIRVersion, get_fhir_resource, get_default_version, - set_default_version, - reset_default_version, fhir_version_context, convert_resource, get_resource_version, @@ -72,8 +70,6 @@ "FHIRVersion", "get_fhir_resource", "get_default_version", - "set_default_version", - "reset_default_version", "fhir_version_context", "convert_resource", "get_resource_version", diff --git a/healthchain/fhir/dataframe.py b/healthchain/fhir/dataframe.py index 77673b2b..29c1551c 100644 --- a/healthchain/fhir/dataframe.py +++ b/healthchain/fhir/dataframe.py @@ -33,7 +33,7 @@ "Observation": { "handler": "_flatten_observations", "description": "Clinical observations (vitals, labs)", - "output_columns": "Dynamic based on observation codes", + "output_columns": "Dynamic: obs_{code}_{display}", "options": ["aggregation"], }, "Condition": { @@ -408,7 +408,7 @@ def bundle_to_dataframe( # Get handler function by name handler_name = handler_info["handler"] - handler = globals()[handler_name] + handler = _HANDLERS[handler_name] # Call handler with standardized signature features = handler(resources, config) @@ -504,8 +504,8 @@ def _flatten_observations( values = [item["value"] for item in obs_list] display = obs_list[0]["display"] - # Create column name: code_display - col_name = f"{code}_{display.replace(' ', '_')}" + # Create column name: obs_code_display + col_name = f"obs_{code}_{display.replace(' ', '_')}" # Aggregate values if aggregation == "mean": @@ -594,3 +594,12 @@ def _flatten_medications( features[col_name] = 1 return features + + +# Populated after all handler functions are defined. +_HANDLERS = { + "_flatten_patient": _flatten_patient, + "_flatten_observations": _flatten_observations, + "_flatten_conditions": _flatten_conditions, + "_flatten_medications": _flatten_medications, +} diff --git a/healthchain/fhir/r4b.pyi b/healthchain/fhir/r4b.pyi index 28d34cf8..3310b6ca 100644 --- a/healthchain/fhir/r4b.pyi +++ b/healthchain/fhir/r4b.pyi @@ -1,19 +1,34 @@ +from fhir.resources.R4B.address import Address as Address from fhir.resources.R4B.allergyintolerance import ( AllergyIntolerance as AllergyIntolerance, ) +from fhir.resources.R4B.annotation import Annotation as Annotation from fhir.resources.R4B.appointment import Appointment as Appointment +from fhir.resources.R4B.attachment import Attachment as Attachment from fhir.resources.R4B.bundle import Bundle as Bundle, BundleEntry as BundleEntry from fhir.resources.R4B.capabilitystatement import ( CapabilityStatement as CapabilityStatement, ) from fhir.resources.R4B.careplan import CarePlan as CarePlan +from fhir.resources.R4B.careteam import CareTeam as CareTeam from fhir.resources.R4B.codeableconcept import CodeableConcept as CodeableConcept from fhir.resources.R4B.coding import Coding as Coding from fhir.resources.R4B.condition import Condition as Condition +from fhir.resources.R4B.contactpoint import ContactPoint as ContactPoint +from fhir.resources.R4B.device import Device as Device +from fhir.resources.R4B.diagnosticreport import DiagnosticReport as DiagnosticReport from fhir.resources.R4B.documentreference import DocumentReference as DocumentReference +from fhir.resources.R4B.dosage import Dosage as Dosage from fhir.resources.R4B.encounter import Encounter as Encounter +from fhir.resources.R4B.extension import Extension as Extension +from fhir.resources.R4B.familymemberhistory import ( + FamilyMemberHistory as FamilyMemberHistory, +) +from fhir.resources.R4B.goal import Goal as Goal from fhir.resources.R4B.humanname import HumanName as HumanName from fhir.resources.R4B.identifier import Identifier as Identifier +from fhir.resources.R4B.immunization import Immunization as Immunization +from fhir.resources.R4B.location import Location as Location from fhir.resources.R4B.medicationrequest import MedicationRequest as MedicationRequest from fhir.resources.R4B.medicationstatement import ( MedicationStatement as MedicationStatement, @@ -21,9 +36,22 @@ from fhir.resources.R4B.medicationstatement import ( from fhir.resources.R4B.meta import Meta as Meta from fhir.resources.R4B.observation import Observation as Observation from fhir.resources.R4B.operationoutcome import OperationOutcome as OperationOutcome +from fhir.resources.R4B.organization import Organization as Organization from fhir.resources.R4B.patient import Patient as Patient from fhir.resources.R4B.period import Period as Period +from fhir.resources.R4B.practitioner import Practitioner as Practitioner from fhir.resources.R4B.procedure import Procedure as Procedure from fhir.resources.R4B.provenance import Provenance as Provenance +from fhir.resources.R4B.provenance import ProvenanceAgent as ProvenanceAgent +from fhir.resources.R4B.quantity import Quantity as Quantity +from fhir.resources.R4B.questionnaire import Questionnaire as Questionnaire +from fhir.resources.R4B.questionnaireresponse import ( + QuestionnaireResponse as QuestionnaireResponse, +) +from fhir.resources.R4B.range import Range as Range from fhir.resources.R4B.reference import Reference as Reference from fhir.resources.R4B.riskassessment import RiskAssessment as RiskAssessment +from fhir.resources.R4B.servicerequest import ServiceRequest as ServiceRequest +from fhir.resources.R4B.specimen import Specimen as Specimen +from fhir.resources.R4B.task import Task as Task +from fhir.resources.R4B.timing import Timing as Timing diff --git a/healthchain/fhir/version.py b/healthchain/fhir/version.py index 2fec7c18..1c399c4c 100644 --- a/healthchain/fhir/version.py +++ b/healthchain/fhir/version.py @@ -144,10 +144,10 @@ def set_default_version(version: Union[FHIRVersion, str]) -> None: def reset_default_version() -> None: - """Reset the default FHIR version to library default (R5).""" + """Reset the default FHIR version to R4B.""" global _default_version _default_version = None - logger.debug("Default FHIR version reset to R5") + logger.debug("Default FHIR version reset to R4B") @contextmanager diff --git a/healthchain/gateway/fhir/base.py b/healthchain/gateway/fhir/base.py index 260761f1..e3e9a2d0 100644 --- a/healthchain/gateway/fhir/base.py +++ b/healthchain/gateway/fhir/base.py @@ -464,15 +464,10 @@ def from_config(cls, config: "AppConfig") -> "BaseFHIRGateway": # env_prefix: MEDPLUM gateway = FHIRGateway.from_config(AppConfig.load()) """ - from healthchain.fhir.version import set_default_version - gateway = cls() for name, source in config.sources.items(): - set_default_version(source.fhir_version) auth_config = source.to_fhir_auth_config() gateway.add_source(name, auth_config.to_connection_string()) - # Note: if sources declare different fhir_version values, the last one wins. - # Per-source version isolation requires a gateway refactor — see docs/rfcs/001-per-source-fhir-version.md return gateway def add_source(self, name: str, connection_string: str) -> None: diff --git a/healthchain/io/mappers/fhirfeaturemapper.py b/healthchain/io/mappers/fhirfeaturemapper.py index ad640dec..509e9959 100644 --- a/healthchain/io/mappers/fhirfeaturemapper.py +++ b/healthchain/io/mappers/fhirfeaturemapper.py @@ -124,10 +124,10 @@ def _map_columns_to_schema(self, df: pd.DataFrame) -> pd.DataFrame: # Map observation columns obs_features = self.schema.get_features_by_resource("Observation") for feature_name, mapping in obs_features.items(): - # Generic converter creates columns like: "8867-4_Heart_rate" + # Generic converter creates columns like: "obs_8867-4_Heart_rate" # Find matching column in df for col in df.columns: - if col.startswith(mapping.code): + if col.startswith(f"obs_{mapping.code}"): rename_map[col] = feature_name break diff --git a/tests/fhir/test_converters.py b/tests/fhir/test_converters.py index aa16c20a..7cfd8ac3 100644 --- a/tests/fhir/test_converters.py +++ b/tests/fhir/test_converters.py @@ -170,8 +170,8 @@ def test_bundle_to_dataframe_basic_conversion(): assert isinstance(df, pd.DataFrame) assert len(df) == 1 assert "age" in df.columns and "gender" in df.columns - assert "8867-4_Heart_rate" in df.columns - assert df["8867-4_Heart_rate"].iloc[0] == 85.0 + assert "obs_8867-4_Heart_rate" in df.columns + assert df["obs_8867-4_Heart_rate"].iloc[0] == 85.0 # Test with dict Bundle dict_bundle = { @@ -201,7 +201,7 @@ def test_bundle_to_dataframe_basic_conversion(): df = bundle_to_dataframe(dict_bundle) assert len(df) == 1 - assert "8310-5_Body_temperature" in df.columns + assert "obs_8310-5_Body_temperature" in df.columns @pytest.mark.parametrize( @@ -302,7 +302,7 @@ def test_bundle_to_dataframe_observation_aggregation_strategies( ) df = bundle_to_dataframe(bundle, config=config) - assert df["8867-4_Heart_rate"].iloc[0] == expected + assert df["obs_8867-4_Heart_rate"].iloc[0] == expected def test_bundle_to_dataframe_age_calculation_modes(): @@ -447,8 +447,12 @@ def test_bundle_to_dataframe_handles_multiple_patients(): assert len(df) == 2 assert set(df["patient_ref"]) == {"Patient/123", "Patient/456"} - assert df[df["patient_ref"] == "Patient/123"]["8867-4_Heart_rate"].iloc[0] == 85.0 - assert df[df["patient_ref"] == "Patient/456"]["8867-4_Heart_rate"].iloc[0] == 72.0 + assert ( + df[df["patient_ref"] == "Patient/123"]["obs_8867-4_Heart_rate"].iloc[0] == 85.0 + ) + assert ( + df[df["patient_ref"] == "Patient/456"]["obs_8867-4_Heart_rate"].iloc[0] == 72.0 + ) def test_bundle_converter_config_defaults(): From d6e956cb90c94d5fbd6d5f7bd1c7993a7936ef36 Mon Sep 17 00:00:00 2001 From: jenniferjiangkells Date: Mon, 30 Mar 2026 16:17:46 +0100 Subject: [PATCH 07/11] Doc updates --- docs/cookbook/clinical_coding.md | 20 +++---- docs/cookbook/discharge_summarizer.md | 12 ++--- docs/cookbook/index.md | 64 +++++++++++++++++++++- docs/cookbook/ml_model_deployment.md | 4 +- docs/cookbook/multi_ehr_aggregation.md | 11 ++-- docs/reference/config.md | 67 ++++++++++++++++++++++++ docs/reference/utilities/fhir_helpers.md | 48 +++++++---------- 7 files changed, 169 insertions(+), 57 deletions(-) diff --git a/docs/cookbook/clinical_coding.md b/docs/cookbook/clinical_coding.md index 129afad8..aa58598a 100644 --- a/docs/cookbook/clinical_coding.md +++ b/docs/cookbook/clinical_coding.md @@ -55,11 +55,9 @@ First we'll need to convert the incoming CDA XML to FHIR. The [CdaAdapter](../re ```python from healthchain.io import CdaAdapter -from healthchain.engine import create_interop -# Create an interop engine with default configuration -interop_engine = create_interop() -cda_adapter = CdaAdapter(engine=interop_engine) +# Create CDA adapter +cda_adapter = CdaAdapter() # Parse the CDA document to a Document object doc = cda_adapter.parse(request) @@ -150,7 +148,7 @@ Use `.add_source` to register a FHIR endpoint you want to connect to with its co ```python from healthchain.gateway import FHIRGateway -from healthchain.gateway.clients.fhir.base import FHIRAuthConfig +from healthchain.gateway.clients import FHIRAuthConfig from dotenv import load_dotenv load_dotenv() @@ -193,7 +191,7 @@ def ai_coding_workflow(request: CdaRequest): condition, source="epic-notereader", tag_code="cdi" ) # Send to external FHIR server via gateway - fhir_gateway.create(condition, source="billing") + fhir_gateway.create(condition, source="medplum") # Return processed CDA response to the legacy system cda_response = cda_adapter.format(doc) @@ -224,7 +222,7 @@ from healthchain.sandbox import SandboxClient # Create sandbox client for SOAP/CDA testing client = SandboxClient( - url="http://localhost:8000/notereader/ProcessDocument", + url="http://localhost:8000/notereader/?wsdl", workflow="sign-note-inpatient", protocol="soap" ) @@ -242,14 +240,9 @@ client.load_from_path("./data/notereader_cda.xml") Now for the moment of truth! Start your service and run the sandbox to see the complete workflow in action. ```python -import uvicorn import threading -# Start the API server in a separate thread -def start_api(): - uvicorn.run(app, port=8000) - -api_thread = threading.Thread(target=start_api, daemon=True) +api_thread = threading.Thread(target=app.run, daemon=True) api_thread.start() # Send requests and save responses with sandbox client @@ -472,3 +465,4 @@ A clinical coding service that bridges legacy CDA systems with modern FHIR infra - **Add validation**: Implement FHIR resource validation before sending to external servers. - **Expand to other workflows**: Adapt the pattern for lab results, medications, or radiology reports. - **Build on it**: Use the extracted conditions in the [Data Aggregation example](./multi_ehr_aggregation.md) to combine with other FHIR sources. + - **Go to production**: Scaffold a project with `healthchain new` and run with `healthchain serve` — see [From cookbook to service](./index.md#from-cookbook-to-service). diff --git a/docs/cookbook/discharge_summarizer.md b/docs/cookbook/discharge_summarizer.md index 8fd510d2..02f5ae58 100644 --- a/docs/cookbook/discharge_summarizer.md +++ b/docs/cookbook/discharge_summarizer.md @@ -124,7 +124,7 @@ from healthchain.models import CDSRequest, CDSResponse cds_service = CDSHooksService() # Define the CDS service function -@cds_service.hook("encounter-discharge", id="discharge-summary") +@cds_service.hook("encounter-discharge", id="discharge-summarizer") def handle_discharge_summary(request: CDSRequest) -> CDSResponse: """Process discharge summaries with AI""" # Parse CDS request to internal Document format @@ -183,14 +183,9 @@ client.load_free_text( Put it all together and run both the service and sandbox client: ```python -import uvicorn import threading -# Start the API server in a separate thread -def start_api(): - uvicorn.run(app, port=8000) - -api_thread = threading.Thread(target=start_api, daemon=True) +api_thread = threading.Thread(target=app.run, daemon=True) api_thread.start() # Send requests and save responses with sandbox client @@ -203,7 +198,7 @@ client.save_results("./output/") Once running, your service will be available at: - **Service discovery**: `http://localhost:8000/cds-services` - - **Discharge summary endpoint**: `http://localhost:8000/cds-services/discharge-summary` + - **Discharge summary endpoint**: `http://localhost:8000/cds/cds-services/discharge-summarizer` ??? example "Example CDS Response" @@ -257,3 +252,4 @@ A CDS Hooks service for discharge workflows that integrates seamlessly with EHR - **Add validation**: Implement checks for required discharge elements (medications, follow-ups, equipment). - **Multi-card support**: Expand to generate separate cards for different discharge aspects (medication reconciliation, transportation, follow-up scheduling). - **Integrate with workflows**: Deploy to Epic App Orchard or Cerner Code Console for production EHR integration. + - **Go to production**: Scaffold a project with `healthchain new` and run with `healthchain serve` — see [From cookbook to service](./index.md#from-cookbook-to-service). diff --git a/docs/cookbook/index.md b/docs/cookbook/index.md index 0791585f..da7eb9cb 100644 --- a/docs/cookbook/index.md +++ b/docs/cookbook/index.md @@ -105,5 +105,65 @@ Hands-on, production-ready examples for building healthcare AI applications with --- -!!! tip "What next?" - See the source code for each recipe, experiment with the sandboxes, and adapt the patterns for your projects! +## From cookbook to service + +Cookbooks are standalone scripts — run them directly to explore and experiment. When you're ready to build a proper service, scaffold a project and move your logic in: + +```bash +# 1. Run a cookbook locally +python cookbook/sepsis_cds_hooks.py + +# 2. Scaffold a project +healthchain new my-sepsis-service -t cds-hooks +cd my-sepsis-service + +# 3. Move your hook logic into app.py, then run with config +healthchain serve +``` + +`app.run()` (used in cookbooks) is a convenience wrapper — equivalent to running uvicorn directly. `healthchain serve` reads `healthchain.yaml` for port, TLS, and deployment settings, and prints a startup banner so you can see what's active at a glance. + +**What moves from your script into `healthchain.yaml`:** + +```python +# cookbook — everything hardcoded in Python +gateway = FHIRGateway() +gateway.add_source("medplum", FHIRAuthConfig.from_env("MEDPLUM").to_connection_string()) + +llm = ChatAnthropic(model="claude-opus-4-6", max_tokens=512) + +app = HealthChainAPI(title="My App", port=8000, service_type="fhir-gateway") +``` + +```yaml +# healthchain.yaml — port, sources, and LLM provider declared here +service: + type: fhir-gateway + port: 8000 + +sources: + medplum: + env_prefix: MEDPLUM # credentials stay in .env + +llm: + provider: anthropic + model: claude-opus-4-6 + max_tokens: 512 +``` + +```python +# app.py — load from config instead +from healthchain.config.appconfig import AppConfig +from healthchain.gateway import FHIRGateway, HealthChainAPI + +config = AppConfig.load() +gateway = FHIRGateway.from_config(config) +llm = config.llm.to_langchain() + +app = HealthChainAPI(title="My App") +``` + +Credentials (API keys, client secrets) always stay in `.env` — never in `healthchain.yaml`. + +!!! tip "Configuration reference" + See the [configuration reference](../reference/config.md) for all available settings — security, compliance, eval, and more. diff --git a/docs/cookbook/ml_model_deployment.md b/docs/cookbook/ml_model_deployment.md index b9b01d25..91f7efdd 100644 --- a/docs/cookbook/ml_model_deployment.md +++ b/docs/cookbook/ml_model_deployment.md @@ -512,5 +512,5 @@ Both patterns: - **Add more features**: Extend `sepsis_vitals.yaml` with lab values, medications, or other Observations - **Add more FHIR sources**: The gateway supports multiple sources—see the cookbook script for Epic sandbox configuration, or the [FHIR Sandbox Setup guide](./setup_fhir_sandboxes.md) - **Automate batch runs**: Schedule screening jobs with cron, Airflow, or cloud schedulers; or use [FHIR Subscriptions](https://www.hl7.org/fhir/subscription.html) to trigger on new ICU admissions ([PRs welcome!](https://github.com/dotimplement/HealthChain/pulls)) - - **Combine patterns**: Use batch screening to identify high-risk patients, then enable CDS - alerts for those patients + - **Combine patterns**: Use batch screening to identify high-risk patients, then enable CDS alerts for those patients + - **Go to production**: Scaffold a project with `healthchain new` and run with `healthchain serve` — see [From cookbook to service](./index.md#from-cookbook-to-service). diff --git a/docs/cookbook/multi_ehr_aggregation.md b/docs/cookbook/multi_ehr_aggregation.md index 2e03800d..0603b08c 100644 --- a/docs/cookbook/multi_ehr_aggregation.md +++ b/docs/cookbook/multi_ehr_aggregation.md @@ -123,13 +123,17 @@ def get_unified_patient(patient_id: str, sources: List[str]) -> Bundle: Register the gateway with [HealthChainAPI](../reference/gateway/api.md) to create REST endpoints. ```python -import uvicorn from healthchain.gateway import HealthChainAPI -app = HealthChainAPI() +app = HealthChainAPI( + title="Multi-EHR Data Aggregation", + description="Aggregate patient data from multiple FHIR sources", + port=8888, + service_type="fhir-gateway", +) app.register_gateway(gateway, path="/fhir") -uvicorn.run(app) +app.run() ``` !!! tip "FHIR Endpoints Provided by the Service" @@ -405,3 +409,4 @@ A production-ready data aggregation service with: - **Expand resource types**: Change `Condition` to `MedicationStatement`, `Observation`, or `Procedure` to aggregate different data. - **Add processing**: Extend the pipeline with terminology mapping, entity extraction, or quality checks. - **Build on it**: Use aggregated data in the [Clinical Coding tutorial](./clinical_coding.md) or feed it to your LLM application. + - **Go to production**: Scaffold a project with `healthchain new` and run with `healthchain serve` — see [From cookbook to service](./index.md#from-cookbook-to-service). diff --git a/docs/reference/config.md b/docs/reference/config.md index 0fef4eba..6e6ebf8e 100644 --- a/docs/reference/config.md +++ b/docs/reference/config.md @@ -39,6 +39,17 @@ eval: site: name: "" environment: development + +# FHIR data sources — credentials stay in .env +# sources: +# medplum: +# env_prefix: MEDPLUM + +# LLM provider for LangChain-based pipelines +# llm: +# provider: anthropic +# model: claude-opus-4-6 +# max_tokens: 512 ``` --- @@ -116,3 +127,59 @@ The `card_feedback` event closes the evaluation loop — it provides implicit gr |-------|------|---------|-------------| | `name` | string | `""` | Hospital or organisation name — displayed in `healthchain status` | | `environment` | string | `development` | Deployment environment — `development`, `staging`, or `production` | + +--- + +## `sources` + +Declare FHIR data sources here. Credentials stay in environment variables — only source names and env prefixes are stored in config. + +```yaml +sources: + medplum: + env_prefix: MEDPLUM # reads MEDPLUM_CLIENT_ID, MEDPLUM_BASE_URL, etc. + epic: + env_prefix: EPIC +``` + +| Field | Type | Default | Description | +|-------|------|---------|-------------| +| `` | object | — | Arbitrary source name used in `gateway.search(..., source="")` | +| `.env_prefix` | string | — | Prefix for env vars: `{PREFIX}_CLIENT_ID`, `{PREFIX}_CLIENT_SECRET`, `{PREFIX}_BASE_URL`, `{PREFIX}_TOKEN_URL` | + +With sources declared, use `FHIRGateway.from_config()` instead of `gateway.add_source()`: + +```python +from healthchain.gateway import FHIRGateway +from healthchain.config.appconfig import AppConfig + +gateway = FHIRGateway.from_config(AppConfig.load()) +``` + +--- + +## `llm` + +LLM provider settings for LangChain-based pipelines. API key is read from the standard environment variable for each provider (`ANTHROPIC_API_KEY`, `OPENAI_API_KEY`, etc.). + +```yaml +llm: + provider: anthropic + model: claude-opus-4-6 + max_tokens: 512 +``` + +| Field | Type | Default | Description | +|-------|------|---------|-------------| +| `provider` | string | `anthropic` | LLM provider — `anthropic`, `openai`, or `google` | +| `model` | string | `claude-opus-4-6` | Model ID passed to the LangChain chat model | +| `max_tokens` | int | `512` | Maximum tokens for model response | + +Use `llm.to_langchain()` to instantiate the configured model: + +```python +from healthchain.config.appconfig import AppConfig + +config = AppConfig.load() +llm = config.llm.to_langchain() # returns ChatAnthropic / ChatOpenAI / ChatGoogleGenerativeAI +``` diff --git a/docs/reference/utilities/fhir_helpers.md b/docs/reference/utilities/fhir_helpers.md index 06a304e6..5a7b90fe 100644 --- a/docs/reference/utilities/fhir_helpers.md +++ b/docs/reference/utilities/fhir_helpers.md @@ -13,24 +13,25 @@ from healthchain.fhir.r4b import Patient, Condition, Bundle FHIR resource classes are provided by the [`fhir.resources`](https://github.com/nazrulworld/fhir.resources) library. R4B is a minor ballot update to R4 with no breaking changes to core resources — it is compatible with R4 FHIR servers in practice. !!! note "Scope of version utilities" - The version utilities below (`set_default_version`, `get_fhir_resource`, etc.) only affect resource creation helpers in your own code. They do **not** change how the FHIR gateway client deserializes responses from EHR servers — that is always R4B. + The version utilities below apply to dynamic resource loading and cross-version conversion. The `create_*` helpers always produce R4B resources. The FHIR gateway client always deserializes server responses as R4B. ### Explicit version control -For cases where you need to work with R5 or STU3 resources explicitly: +For cases where you need to load or convert resources in a specific version: ```python from healthchain.fhir import get_fhir_resource, fhir_version_context, convert_resource # Get a resource class for a specific version +Patient_R4B = get_fhir_resource("Patient", "R4B") Patient_R5 = get_fhir_resource("Patient", "R5") -# Temporarily create resources in a different version +# Temporarily switch the default version for get_fhir_resource calls with fhir_version_context("STU3"): - condition = create_condition(subject="Patient/123", code="123") + PatientSTU3 = get_fhir_resource("Patient") # Convert between versions (serialize/deserialize — not lossless across major field renames) -patient_r4b = convert_resource(patient_r5, "R4B") +patient_r5 = convert_resource(patient_r4b, "R5") ``` ### API Reference @@ -38,11 +39,10 @@ patient_r4b = convert_resource(patient_r5, "R4B") | Function | Description | |----------|-------------| | `get_fhir_resource(name, version)` | Get a resource class for a specific version | -| `set_default_version(version)` | Override the default for subsequent helper calls | -| `reset_default_version()` | Reset to R4B | -| `fhir_version_context(version)` | Context manager for temporary version changes | -| `convert_resource(resource, version)` | Convert a resource to a different version | -| `get_resource_version(resource)` | Detect the version of an existing resource | +| `get_default_version()` | Returns the current default version (R4B) | +| `fhir_version_context(version)` | Context manager for temporarily switching the default version | +| `convert_resource(resource, version)` | Convert a resource to a different version (best-effort, not lossless) | +| `get_resource_version(resource)` | Detect the FHIR version of an existing resource | --- @@ -124,14 +124,6 @@ condition = create_condition( system="http://snomed.info/sct", ) -# Create an R4B condition -condition_r4b = create_condition( - subject="Patient/123", - code="38341003", - display="Hypertension", - version="R4B", # Optional: specify FHIR version -) - # Output the created resource print(condition.model_dump()) ``` @@ -196,14 +188,12 @@ print(medication.model_dump()) "resourceType": "MedicationStatement", "id": "hc-86a26eba-63f9-4017-b7b2-5b36f9bad5f1", "status": "recorded", - "medication": { - "concept": { - "coding": [{ - "system": "http://www.nlm.nih.gov/research/umls/rxnorm", - "code": "1049221", - "display": "Acetaminophen 325 MG Oral Tablet" - }] - } + "medicationCodeableConcept": { + "coding": [{ + "system": "http://www.nlm.nih.gov/research/umls/rxnorm", + "code": "1049221", + "display": "Acetaminophen 325 MG Oral Tablet" + }] }, "subject": { "reference": "Patient/123" @@ -314,12 +304,12 @@ print(doc_ref.model_dump()) ## Utilities -### set_problem_list_item_category() +### set_condition_category() Sets the category of a [**Condition**](https://www.hl7.org/fhir/condition.html) resource to "`problem-list-item`". ```python -from healthchain.fhir import set_problem_list_item_category, create_condition +from healthchain.fhir import set_condition_category, create_condition # Create a condition and set it as a problem list item problem_list_item = create_condition( @@ -328,7 +318,7 @@ problem_list_item = create_condition( display="Hypertension" ) -set_problem_list_item_category(problem_list_item) +set_condition_category(problem_list_item) # Output the modified resource print(problem_list_item.model_dump()) From cb997c4bda820d07308e0e105e58e2e02699b92f Mon Sep 17 00:00:00 2001 From: jenniferjiangkells Date: Mon, 30 Mar 2026 16:19:53 +0100 Subject: [PATCH 08/11] CLI enhancements --- healthchain/cli.py | 42 ++++++++++++++++++++----- healthchain/config/appconfig.py | 49 +++++++++++++++++++++++++++++- healthchain/gateway/api/app.py | 54 ++++++++++++++++++++++++++++----- 3 files changed, 129 insertions(+), 16 deletions(-) diff --git a/healthchain/cli.py b/healthchain/cli.py index 3846a78c..a5ff86d6 100644 --- a/healthchain/cli.py +++ b/healthchain/cli.py @@ -168,8 +168,8 @@ def patient_view(request: CDSRequest) -> CDSResponse: import os from typing import List -from fhir.resources.bundle import Bundle -from fhir.resources.condition import Condition +from fhir.resources.R4B.bundle import Bundle +from fhir.resources.R4B.condition import Condition from healthchain.gateway import FHIRGateway, HealthChainAPI from healthchain.fhir import merge_bundles @@ -272,6 +272,19 @@ def _make_healthchain_yaml(name: str, service_type: str) -> str: site: name: "" environment: development # development | staging | production + +# FHIR data sources — declare sources here, credentials stay in .env +# sources: +# medplum: +# env_prefix: MEDPLUM # reads MEDPLUM_CLIENT_ID, MEDPLUM_BASE_URL etc. +# epic: +# env_prefix: EPIC # reads EPIC_CLIENT_ID, EPIC_BASE_URL etc. + +# LLM provider (used by app.py or cookbooks via config.llm.to_langchain()) +# llm: +# provider: anthropic # anthropic | openai | google +# model: claude-opus-4-6 +# max_tokens: 512 """ @@ -413,6 +426,7 @@ def sandbox_run( config = AppConfig.load() resolved_output = output or (config.data.output_dir if config else "./output") + resolved_from_path = from_path or (config.data.patients_dir if config else None) print(f"\n{_BOLD}{_CYAN}◆ Sandbox{_RST} {_DIM}{url}{_RST}") print(f" {_CYAN}workflow {_RST}{workflow}") @@ -423,10 +437,10 @@ def sandbox_run( print(f"\n{_RED}Error:{_RST} {e}") return - if from_path: - print(f"\n{_DIM}Loading from {from_path}...{_RST}") + if resolved_from_path: + print(f"\n{_DIM}Loading from {resolved_from_path}...{_RST}") try: - client.load_from_path(from_path) + client.load_from_path(resolved_from_path) except (FileNotFoundError, ValueError) as e: print(f"{_RED}Error loading data:{_RST} {e}") return @@ -526,14 +540,16 @@ def _section(s: str) -> str: auth_col = _GREEN if config.security.auth != "none" else _AMBER print(f"{_key('auth ')}{auth_col}{config.security.auth}{_RST}") tls_val = ( - _val_on("enabled") if config.security.tls.enabled else _val_off("disabled") + _val_on("enabled") if config.security.tls.enabled else f"{_DIM}disabled{_RST}" ) print(f"{_key('TLS ')}{tls_val}") origins = ", ".join(config.security.allowed_origins) print(f"{_key('origins ')}{_DIM}{origins}{_RST}") print(_section("Compliance")) - hipaa_val = _val_on("enabled") if config.compliance.hipaa else _val_off("disabled") + hipaa_val = ( + _val_on("enabled") if config.compliance.hipaa else f"{_DIM}disabled{_RST}" + ) print(f"{_key('HIPAA ')}{hipaa_val}") if config.compliance.hipaa: print(f"{_key('audit log ')}{_BOLD}{config.compliance.audit_log}{_RST}") @@ -546,6 +562,18 @@ def _section(s: str) -> str: else: print(f" {_DIM}disabled{_RST}") + if config.sources: + print(_section("Sources")) + for source_name, source in config.sources.items(): + print( + f"{_key(f'{source_name:<12}')}{_DIM}env_prefix={source.env_prefix}{_RST}" + ) + + if config.llm: + print(_section("LLM")) + print(f"{_key('provider ')}{config.llm.provider}") + print(f"{_key('model ')}{_BOLD}{config.llm.model}{_RST}") + print() diff --git a/healthchain/config/appconfig.py b/healthchain/config/appconfig.py index 9fa7bdde..48f79558 100644 --- a/healthchain/config/appconfig.py +++ b/healthchain/config/appconfig.py @@ -8,7 +8,7 @@ import logging from pathlib import Path -from typing import List, Optional +from typing import Dict, List, Optional import yaml from pydantic import BaseModel, field_validator @@ -18,6 +18,51 @@ _CONFIG_FILENAME = "healthchain.yaml" +class SourceConfig(BaseModel): + """A FHIR data source. Credentials are loaded from environment variables.""" + + env_prefix: str # e.g. "MEDPLUM" reads MEDPLUM_CLIENT_ID, MEDPLUM_BASE_URL etc. + + def to_fhir_auth_config(self): + """Instantiate FHIRAuthConfig by reading env vars for this source's prefix.""" + from healthchain.gateway.clients.fhir.base import FHIRAuthConfig + + return FHIRAuthConfig.from_env(self.env_prefix) + + +class LLMConfig(BaseModel): + """LLM provider settings. API key is read from the standard env var for each provider.""" + + provider: str = "anthropic" # anthropic | openai | google + model: str = "claude-opus-4-6" + max_tokens: int = 512 + + @field_validator("provider") + @classmethod + def validate_provider(cls, v: str) -> str: + allowed = {"anthropic", "openai", "google"} + if v not in allowed: + raise ValueError(f"provider must be one of: {', '.join(sorted(allowed))}") + return v + + def to_langchain(self): + """Instantiate the configured LangChain chat model.""" + if self.provider == "anthropic": + from langchain_anthropic import ChatAnthropic + + return ChatAnthropic(model=self.model, max_tokens=self.max_tokens) + elif self.provider == "openai": + from langchain_openai import ChatOpenAI + + return ChatOpenAI(model=self.model, max_tokens=self.max_tokens) + elif self.provider == "google": + from langchain_google_genai import ChatGoogleGenerativeAI + + return ChatGoogleGenerativeAI( + model=self.model, max_output_tokens=self.max_tokens + ) + + class ServiceConfig(BaseModel): type: str = "cds-hooks" port: int = 8000 @@ -84,6 +129,8 @@ class AppConfig(BaseModel): compliance: ComplianceConfig = ComplianceConfig() eval: EvalConfig = EvalConfig() site: SiteConfig = SiteConfig() + sources: Dict[str, SourceConfig] = {} + llm: Optional[LLMConfig] = None @classmethod def from_yaml(cls, path: Path) -> "AppConfig": diff --git a/healthchain/gateway/api/app.py b/healthchain/gateway/api/app.py index bfd74573..4d4d437c 100644 --- a/healthchain/gateway/api/app.py +++ b/healthchain/gateway/api/app.py @@ -6,6 +6,7 @@ """ import logging +import os import re from contextlib import asynccontextmanager @@ -104,6 +105,8 @@ def _print_startup_banner( gateways: dict, services: dict, docs_url: str, + port: int = 8000, + service_type: Optional[str] = None, config=None, config_path: Optional[str] = None, ) -> None: @@ -116,23 +119,30 @@ def _print_startup_banner( LOGO_COL = 38 # ── resolve status values from config or sensible defaults ── - svc_type = (config.service.type if config else None) or ( - list({**gateways, **services}.keys())[0] - if {**gateways, **services} - else "unknown" + svc_type = ( + service_type + or (config.service.type if config else None) + or ( + list({**gateways, **services}.keys())[0] + if {**gateways, **services} + else "unknown" + ) ) env = config.site.environment if config else "development" - port = str(config.service.port if config else 8000) + port = str(port) site = config.site.name if config else None auth = config.security.auth if config else "none" tls = config.security.tls.enabled if config else False hipaa = config.compliance.hipaa if config else False eval_enabled = config.eval.enabled if config else False eval_provider = config.eval.provider if config else "mlflow" + # Check registered gateways first, then fall back to env var presence fhir_configured = any( - hasattr(gw, "sources") and getattr(gw, "sources", None) + hasattr(gw, "connection_manager") + and gw.connection_manager + and getattr(gw.connection_manager, "sources", None) for gw in gateways.values() - ) + ) or any(k.endswith("_CLIENT_ID") for k in os.environ) status: list[str] = [ f"\033[1m\033[38;2;255;121;198m{title}\033[0m \033[2mv{version}\033[0m", @@ -237,6 +247,8 @@ def __init__( title: str = "HealthChain API", description: str = "Healthcare Integration API", version: str = "1.0.0", + port: Optional[int] = None, + service_type: Optional[str] = None, enable_cors: bool = True, enable_events: bool = True, event_dispatcher: Optional[EventDispatcher] = None, @@ -262,6 +274,10 @@ def __init__( **kwargs, ) + # Display metadata for banner (when running outside healthchain serve) + self._port = port + self._service_type = service_type + # Gateway and service registries self.gateways = {} self.services = {} @@ -597,12 +613,15 @@ async def _startup(self) -> None: from healthchain.config.appconfig import AppConfig config = AppConfig.load() + port = self._port or (config.service.port if config else 8000) _print_startup_banner( title=config.name if config else self.title, version=config.version if config else self.version, gateways=self.gateways, services=self.services, - docs_url=self.docs_url or "http://localhost:8000/docs", + docs_url=self.docs_url or "/docs", + port=port, + service_type=self._service_type, config=config, config_path="./healthchain.yaml" if config else None, ) @@ -616,6 +635,25 @@ async def _startup(self) -> None: except Exception as e: logger.warning(f"Failed to initialize {name}: {e}") + def run(self, host: str = "0.0.0.0", **kwargs) -> None: + """Run the application with uvicorn. + + Convenience wrapper for local development and cookbooks. For production, + use `healthchain serve` which reads healthchain.yaml for TLS, port, etc. + + Args: + host: Host to bind to (default: 0.0.0.0) + **kwargs: Passed through to uvicorn.run (e.g. reload=True, workers=4) + + Example: + app = HealthChainAPI(title="My App", port=8888) + app.run() + app.run(reload=True) # with hot reload + """ + import uvicorn + + uvicorn.run(self, host=host, port=self._port or 8000, **kwargs) + async def _shutdown(self) -> None: """Handle graceful shutdown.""" for name, component in {**self.services, **self.gateways}.items(): From 1a42a6864a64806ba295a28edf3e50e2b9cdcffe Mon Sep 17 00:00:00 2001 From: jenniferjiangkells Date: Mon, 30 Mar 2026 16:22:19 +0100 Subject: [PATCH 09/11] Update cookbook code --- cookbook/cds_discharge_summarizer_hf_chat.py | 100 ++++++++++--------- cookbook/cds_discharge_summarizer_hf_trf.py | 78 ++++++++------- cookbook/multi_ehr_data_aggregation.py | 19 ++-- cookbook/notereader_clinical_coding_fhir.py | 27 +++-- cookbook/sepsis_cds_hooks.py | 14 +-- cookbook/sepsis_fhir_batch.py | 11 +- 6 files changed, 132 insertions(+), 117 deletions(-) diff --git a/cookbook/cds_discharge_summarizer_hf_chat.py b/cookbook/cds_discharge_summarizer_hf_chat.py index e1b6b528..3bb67805 100644 --- a/cookbook/cds_discharge_summarizer_hf_chat.py +++ b/cookbook/cds_discharge_summarizer_hf_chat.py @@ -1,25 +1,43 @@ +#!/usr/bin/env python3 +""" +Discharge Note Summarizer (LangChain + HuggingFace Chat) + +CDS Hooks service that summarises discharge notes using a HuggingFace +chat model via LangChain (DeepSeek R1 by default). + +Requirements: + pip install healthchain langchain-core langchain-huggingface python-dotenv + # HUGGINGFACEHUB_API_TOKEN env var required + +Run: + python cookbook/cds_discharge_summarizer_hf_chat.py + # POST /cds/cds-services/discharge-summarizer + # Docs at: http://localhost:8000/docs +""" + import os import getpass -from healthchain.gateway import HealthChainAPI, CDSHooksService -from healthchain.pipeline import SummarizationPipeline -from healthchain.models import CDSRequest, CDSResponse +from dotenv import load_dotenv from langchain_huggingface.llms import HuggingFaceEndpoint from langchain_huggingface import ChatHuggingFace from langchain_core.prompts import PromptTemplate from langchain_core.output_parsers import StrOutputParser -from dotenv import load_dotenv +from healthchain.gateway import HealthChainAPI, CDSHooksService +from healthchain.pipeline import SummarizationPipeline +from healthchain.models import CDSRequest, CDSResponse load_dotenv() -if not os.getenv("HUGGINGFACEHUB_API_TOKEN"): - os.environ["HUGGINGFACEHUB_API_TOKEN"] = getpass.getpass("Enter your token: ") +def create_chain(): + if not os.getenv("HUGGINGFACEHUB_API_TOKEN"): + os.environ["HUGGINGFACEHUB_API_TOKEN"] = getpass.getpass( + "Enter your HuggingFace token: " + ) - -def create_summarization_chain(): hf = HuggingFaceEndpoint( repo_id="deepseek-ai/DeepSeek-R1-0528", task="text-generation", @@ -27,72 +45,56 @@ def create_summarization_chain(): do_sample=False, repetition_penalty=1.03, ) - model = ChatHuggingFace(llm=hf) - - template = """ - You are a discharge planning assistant for hospital operations. - Provide a concise, objective summary focusing on actionable items - for care coordination, including appointments, medications, and - follow-up instructions. Format as bullet points with no preamble.\n'''{text}''' - """ - prompt = PromptTemplate.from_template(template) - + prompt = PromptTemplate.from_template( + "You are a discharge planning assistant for hospital operations. " + "Provide a concise, objective summary focusing on actionable items " + "for care coordination, including appointments, medications, and " + "follow-up instructions. Format as bullet points with no preamble.\n'''{text}'''" + ) return prompt | model | StrOutputParser() -# Create the healthcare application -app = HealthChainAPI( - title="Discharge Note Summarizer", - description="AI-powered discharge note summarization service", -) - -chain = create_summarization_chain() -pipeline = SummarizationPipeline.load( - chain, source="langchain", template_path="templates/cds_card_template.json" -) - -# Create CDS Hooks service -cds = CDSHooksService() +def create_app() -> HealthChainAPI: + chain = create_chain() + pipeline = SummarizationPipeline.load( + chain, source="langchain", template_path="templates/cds_card_template.json" + ) + cds = CDSHooksService() + @cds.hook("encounter-discharge", id="discharge-summarizer") + def discharge_summarizer(request: CDSRequest) -> CDSResponse: + return pipeline.process_request(request) -@cds.hook("encounter-discharge", id="discharge-summarizer") -def discharge_summarizer(request: CDSRequest) -> CDSResponse: - result = pipeline.process_request(request) - return result + app = HealthChainAPI( + title="Discharge Note Summarizer", + description="AI-powered discharge note summarization service", + port=8000, + service_type="cds-hooks", + ) + app.register_service(cds, path="/cds") + return app -# Register the CDS service -app.register_service(cds, path="/cds") +app = create_app() if __name__ == "__main__": - import uvicorn import threading - from healthchain.sandbox import SandboxClient - # Start the API server in a separate thread - def start_api(): - uvicorn.run(app, port=8000) - - api_thread = threading.Thread(target=start_api, daemon=True) + api_thread = threading.Thread(target=app.run, daemon=True) api_thread.start() - # Create sandbox client and load test data client = SandboxClient( url="http://localhost:8000/cds/cds-services/discharge-summarizer", workflow="encounter-discharge", ) - # Load discharge notes from CSV client.load_free_text( csv_path="data/discharge_notes.csv", column_name="text", ) - # Send requests and get responses responses = client.send_requests() - - # Save results client.save_results("./output/") try: diff --git a/cookbook/cds_discharge_summarizer_hf_trf.py b/cookbook/cds_discharge_summarizer_hf_trf.py index 6d08332c..0e438105 100644 --- a/cookbook/cds_discharge_summarizer_hf_trf.py +++ b/cookbook/cds_discharge_summarizer_hf_trf.py @@ -1,71 +1,79 @@ +#!/usr/bin/env python3 +""" +Discharge Note Summarizer (Transformer) + +CDS Hooks service that summarises discharge notes using a fine-tuned +HuggingFace transformer model (PEGASUS). + +Requirements: + pip install healthchain transformers torch python-dotenv + # HUGGINGFACEHUB_API_TOKEN env var required + +Run: + python cookbook/cds_discharge_summarizer_hf_trf.py + # POST /cds/cds-services/discharge-summarizer + # Docs at: http://localhost:8000/docs +""" + import os import getpass +from dotenv import load_dotenv + from healthchain.gateway import HealthChainAPI, CDSHooksService from healthchain.pipeline import SummarizationPipeline from healthchain.models import CDSRequest, CDSResponse -from dotenv import load_dotenv - load_dotenv() -if not os.getenv("HUGGINGFACEHUB_API_TOKEN"): - os.environ["HUGGINGFACEHUB_API_TOKEN"] = getpass.getpass("Enter your token: ") - - -# Create the healthcare application -app = HealthChainAPI( - title="Discharge Note Summarizer", - description="AI-powered discharge note summarization service", -) +def create_pipeline() -> SummarizationPipeline: + if not os.getenv("HUGGINGFACEHUB_API_TOKEN"): + os.environ["HUGGINGFACEHUB_API_TOKEN"] = getpass.getpass( + "Enter your HuggingFace token: " + ) + return SummarizationPipeline.from_model_id( + "google/pegasus-xsum", source="huggingface", task="summarization" + ) -# Initialize pipeline -pipeline = SummarizationPipeline.from_model_id( - "google/pegasus-xsum", source="huggingface", task="summarization" -) -# Create CDS Hooks service -cds = CDSHooksService() +def create_app() -> HealthChainAPI: + pipeline = create_pipeline() + cds = CDSHooksService() + @cds.hook("encounter-discharge", id="discharge-summarizer") + def discharge_summarizer(request: CDSRequest) -> CDSResponse: + return pipeline.process_request(request) -@cds.hook("encounter-discharge", id="discharge-summarizer") -def discharge_summarizer(request: CDSRequest) -> CDSResponse: - result = pipeline.process_request(request) - return result + app = HealthChainAPI( + title="Discharge Note Summarizer", + description="AI-powered discharge note summarization service", + port=8000, + service_type="cds-hooks", + ) + app.register_service(cds, path="/cds") + return app -# Register the CDS service -app.register_service(cds, path="/cds") +app = create_app() if __name__ == "__main__": - import uvicorn import threading - from healthchain.sandbox import SandboxClient - # Start the API server in a separate thread - def start_api(): - uvicorn.run(app, port=8000) - - api_thread = threading.Thread(target=start_api, daemon=True) + api_thread = threading.Thread(target=app.run, daemon=True) api_thread.start() - # Create sandbox client and load test data client = SandboxClient( url="http://localhost:8000/cds/cds-services/discharge-summarizer", workflow="encounter-discharge", ) - # Load discharge notes from CSV client.load_free_text( csv_path="data/discharge_notes.csv", column_name="text", ) - # Send requests and get responses responses = client.send_requests() - - # Save results client.save_results("./output/") try: diff --git a/cookbook/multi_ehr_data_aggregation.py b/cookbook/multi_ehr_data_aggregation.py index 85050829..5068b7bb 100644 --- a/cookbook/multi_ehr_data_aggregation.py +++ b/cookbook/multi_ehr_data_aggregation.py @@ -13,7 +13,7 @@ - Cerner Open Sandbox: No auth needed Run: -- python data_aggregation.py + python cookbook/multi_ehr_data_aggregation.py """ from typing import List @@ -23,7 +23,7 @@ from healthchain.fhir.r4b import Bundle, Condition, Annotation from healthchain.gateway import FHIRGateway, HealthChainAPI -from healthchain.gateway.clients.fhir.base import FHIRAuthConfig +from healthchain.gateway.clients import FHIRAuthConfig from healthchain.pipeline import Pipeline from healthchain.io.containers import Document from healthchain.fhir import merge_bundles @@ -100,15 +100,16 @@ def get_unified_patient(patient_id: str, sources: List[str]) -> Bundle: return doc.fhir.bundle - app = HealthChainAPI() - app.register_gateway(gateway) + app = HealthChainAPI( + title="Multi-EHR Data Aggregation", + description="Aggregate patient data from multiple FHIR sources", + port=8888, + service_type="fhir-gateway", + ) + app.register_gateway(gateway, path="/fhir") return app if __name__ == "__main__": - import uvicorn - - app = create_app() - uvicorn.run(app, port=8888) - # Runs at: http://127.0.0.1:8888/ + create_app().run() diff --git a/cookbook/notereader_clinical_coding_fhir.py b/cookbook/notereader_clinical_coding_fhir.py index dd677e25..0c0c5595 100644 --- a/cookbook/notereader_clinical_coding_fhir.py +++ b/cookbook/notereader_clinical_coding_fhir.py @@ -4,13 +4,11 @@ Demonstrates FHIR-native pipelines, legacy system integration, and multi-source data handling. Requirements: -- pip install healthchain -- pip install scispacy -- pip install https://s3-us-west-2.amazonaws.com/ai2-s2-scispacy/releases/v0.5.4/en_core_sci_sm-0.5.4.tar.gz -- pip install python-dotenv + pip install healthchain scispacy python-dotenv + pip install https://s3-us-west-2.amazonaws.com/ai2-s2-scispacy/releases/v0.5.4/en_core_sci_sm-0.5.4.tar.gz Run: -- python notereader_clinical_coding_fhir.py # Demo and start server + python cookbook/notereader_clinical_coding_fhir.py """ import logging @@ -21,7 +19,7 @@ from healthchain.fhir import add_provenance_metadata from healthchain.gateway.api import HealthChainAPI from healthchain.gateway.fhir import FHIRGateway -from healthchain.gateway.clients.fhir.base import FHIRAuthConfig +from healthchain.gateway.clients import FHIRAuthConfig from healthchain.gateway.soap import NoteReaderService from healthchain.io import CdaAdapter, Document from healthchain.models import CdaRequest @@ -104,7 +102,12 @@ def ai_coding_workflow(request: CdaRequest): return cda_response # Register services - app = HealthChainAPI(title="Epic CDI Service with FHIR integration") + app = HealthChainAPI( + title="Epic CDI Service", + description="Clinical document intelligence with FHIR and NoteReader integration", + port=8000, + service_type="fhir-gateway", + ) app.register_gateway(fhir_gateway, path="/fhir") app.register_service(note_service, path="/notereader") @@ -117,18 +120,12 @@ def ai_coding_workflow(request: CdaRequest): if __name__ == "__main__": import threading - import uvicorn - from time import sleep from healthchain.sandbox import SandboxClient - # Start server - def run_server(): - uvicorn.run(app, port=8000, log_level="warning") - - server_thread = threading.Thread(target=run_server, daemon=True) + server_thread = threading.Thread(target=app.run, daemon=True) server_thread.start() - sleep(2) # Wait for startup + sleep(2) # Create sandbox client for testing client = SandboxClient( diff --git a/cookbook/sepsis_cds_hooks.py b/cookbook/sepsis_cds_hooks.py index ff70be67..cf1ea7a2 100644 --- a/cookbook/sepsis_cds_hooks.py +++ b/cookbook/sepsis_cds_hooks.py @@ -115,7 +115,12 @@ def sepsis_alert(request: CDSRequest) -> CDSResponse: return CDSResponse(cards=[]) - app = HealthChainAPI(title="Sepsis CDS Hooks") + app = HealthChainAPI( + title="Sepsis CDS Hooks", + description="Real-time sepsis risk alerts via CDS Hooks", + port=8000, + service_type="cds-hooks", + ) app.register_service(cds, path="/cds") return app @@ -126,15 +131,10 @@ def sepsis_alert(request: CDSRequest) -> CDSResponse: if __name__ == "__main__": import threading - import uvicorn from time import sleep from healthchain.sandbox import SandboxClient - # Start server - def run_server(): - uvicorn.run(app, port=8000, log_level="warning") - - server = threading.Thread(target=run_server, daemon=True) + server = threading.Thread(target=app.run, daemon=True) server.start() sleep(2) diff --git a/cookbook/sepsis_fhir_batch.py b/cookbook/sepsis_fhir_batch.py index 231406c7..69232500 100644 --- a/cookbook/sepsis_fhir_batch.py +++ b/cookbook/sepsis_fhir_batch.py @@ -5,6 +5,9 @@ Query patients from a FHIR server, batch run sepsis predictions, and write RiskAssessment resources back. Demonstrates real FHIR server integration. +Requirements: + pip install healthchain joblib xgboost python-dotenv + Setup: 1. Extract and upload demo patients: python scripts/extract_mimic_demo_patients.py --minimal --upload @@ -24,7 +27,7 @@ from healthchain.fhir.r4b import Patient, Observation, RiskAssessment from healthchain.gateway import HealthChainAPI, FHIRGateway -from healthchain.gateway.clients.fhir.base import FHIRAuthConfig +from healthchain.gateway.clients import FHIRAuthConfig from healthchain.fhir import merge_bundles from healthchain.io import Dataset from healthchain.pipeline import Pipeline @@ -159,7 +162,11 @@ def create_app(): gateway.add_source("epic", EPIC_URL) logger.info("✓ Epic configured") - app = HealthChainAPI(title="Sepsis Batch Screening") + app = HealthChainAPI( + title="Sepsis Batch Screening", + description="Batch sepsis risk screening against a live FHIR server", + service_type="fhir-gateway", + ) app.register_gateway(gateway, path="/fhir") return app, gateway From 23ece22ddc91350cbc9972cf61af24c66bc71ccf Mon Sep 17 00:00:00 2001 From: jenniferjiangkells Date: Mon, 30 Mar 2026 16:40:47 +0100 Subject: [PATCH 10/11] Update scaffold fhir imports --- healthchain/cli.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/healthchain/cli.py b/healthchain/cli.py index a5ff86d6..7d871e03 100644 --- a/healthchain/cli.py +++ b/healthchain/cli.py @@ -168,8 +168,8 @@ def patient_view(request: CDSRequest) -> CDSResponse: import os from typing import List -from fhir.resources.R4B.bundle import Bundle -from fhir.resources.R4B.condition import Condition +from healthchain.fhir.r4b import Bundle +from healthchain.fhir.r4b import Condition from healthchain.gateway import FHIRGateway, HealthChainAPI from healthchain.fhir import merge_bundles From 1dbf39da6847855f676b49b6dcde729d2d1c3b36 Mon Sep 17 00:00:00 2001 From: jenniferjiangkells Date: Mon, 30 Mar 2026 18:06:56 +0100 Subject: [PATCH 11/11] Add HF, tests --- healthchain/cli.py | 2 +- healthchain/config/appconfig.py | 9 +++- tests/config_manager/test_appconfig.py | 75 +++++++++++++++++++++++++- 3 files changed, 82 insertions(+), 4 deletions(-) diff --git a/healthchain/cli.py b/healthchain/cli.py index 7d871e03..aad282b9 100644 --- a/healthchain/cli.py +++ b/healthchain/cli.py @@ -282,7 +282,7 @@ def _make_healthchain_yaml(name: str, service_type: str) -> str: # LLM provider (used by app.py or cookbooks via config.llm.to_langchain()) # llm: -# provider: anthropic # anthropic | openai | google +# provider: anthropic # anthropic | openai | google | huggingface # model: claude-opus-4-6 # max_tokens: 512 """ diff --git a/healthchain/config/appconfig.py b/healthchain/config/appconfig.py index 48f79558..d3da5eb7 100644 --- a/healthchain/config/appconfig.py +++ b/healthchain/config/appconfig.py @@ -33,14 +33,14 @@ def to_fhir_auth_config(self): class LLMConfig(BaseModel): """LLM provider settings. API key is read from the standard env var for each provider.""" - provider: str = "anthropic" # anthropic | openai | google + provider: str = "anthropic" # anthropic | openai | google | huggingface model: str = "claude-opus-4-6" max_tokens: int = 512 @field_validator("provider") @classmethod def validate_provider(cls, v: str) -> str: - allowed = {"anthropic", "openai", "google"} + allowed = {"anthropic", "openai", "google", "huggingface"} if v not in allowed: raise ValueError(f"provider must be one of: {', '.join(sorted(allowed))}") return v @@ -61,6 +61,11 @@ def to_langchain(self): return ChatGoogleGenerativeAI( model=self.model, max_output_tokens=self.max_tokens ) + elif self.provider == "huggingface": + from langchain_huggingface import ChatHuggingFace, HuggingFaceEndpoint + + llm = HuggingFaceEndpoint(repo_id=self.model, max_new_tokens=self.max_tokens) + return ChatHuggingFace(llm=llm) class ServiceConfig(BaseModel): diff --git a/tests/config_manager/test_appconfig.py b/tests/config_manager/test_appconfig.py index 5ef01c52..dd13e084 100644 --- a/tests/config_manager/test_appconfig.py +++ b/tests/config_manager/test_appconfig.py @@ -2,7 +2,7 @@ import pytest -from healthchain.config.appconfig import AppConfig +from healthchain.config.appconfig import AppConfig, LLMConfig def test_appconfig_loads_valid_yaml(tmp_path): @@ -111,6 +111,79 @@ def test_appconfig_tls_config_parsed(tmp_path): assert config.security.tls.key_path == "./certs/key.pem" +def test_llmconfig_valid_providers(): + """LLMConfig accepts all supported providers.""" + for provider in ("anthropic", "openai", "google", "huggingface"): + config = LLMConfig(provider=provider) + assert config.provider == provider + + +def test_llmconfig_invalid_provider_raises(): + """LLMConfig raises ValidationError for unsupported providers.""" + with pytest.raises(Exception): + LLMConfig(provider="cohere") + + +def test_llmconfig_defaults(): + """LLMConfig has sensible defaults.""" + config = LLMConfig() + assert config.provider == "anthropic" + assert config.model == "claude-opus-4-6" + assert config.max_tokens == 512 + + +def test_appconfig_llm_parsed(tmp_path): + """AppConfig parses llm section into LLMConfig correctly.""" + (tmp_path / "healthchain.yaml").write_text( + """ +llm: + provider: openai + model: gpt-4o + max_tokens: 1024 +""" + ) + config = AppConfig.from_yaml(tmp_path / "healthchain.yaml") + + assert config.llm.provider == "openai" + assert config.llm.model == "gpt-4o" + assert config.llm.max_tokens == 1024 + + +def test_appconfig_llm_defaults_to_none(tmp_path): + """AppConfig.llm is None when not specified in healthchain.yaml.""" + (tmp_path / "healthchain.yaml").write_text("name: minimal-app\n") + config = AppConfig.from_yaml(tmp_path / "healthchain.yaml") + + assert config.llm is None + + +def test_appconfig_sources_parsed(tmp_path): + """AppConfig parses sources section into SourceConfig correctly.""" + (tmp_path / "healthchain.yaml").write_text( + """ +sources: + medplum: + env_prefix: MEDPLUM + epic: + env_prefix: EPIC +""" + ) + config = AppConfig.from_yaml(tmp_path / "healthchain.yaml") + + assert "medplum" in config.sources + assert config.sources["medplum"].env_prefix == "MEDPLUM" + assert "epic" in config.sources + assert config.sources["epic"].env_prefix == "EPIC" + + +def test_appconfig_sources_defaults_to_empty(tmp_path): + """AppConfig.sources is empty dict when not specified.""" + (tmp_path / "healthchain.yaml").write_text("name: minimal-app\n") + config = AppConfig.from_yaml(tmp_path / "healthchain.yaml") + + assert config.sources == {} + + def test_appconfig_eval_track_events_parsed(tmp_path): """AppConfig parses eval.track list correctly.""" config_file = tmp_path / "healthchain.yaml"