From c4e5ea21a44e79b0736a892d83e61c6caf3be07c Mon Sep 17 00:00:00 2001 From: Adam Kells Date: Tue, 21 Jan 2025 10:25:58 +0000 Subject: [PATCH 01/34] first pass at transition to fhir.resources --- healthchain/data_generators/basegenerators.py | 11 +++-- .../data_generators/conditiongenerators.py | 28 +++++++----- poetry.lock | 43 ++++++++++++++++++- pyproject.toml | 1 + .../test_condition_generators.py | 13 +++--- 5 files changed, 72 insertions(+), 24 deletions(-) diff --git a/healthchain/data_generators/basegenerators.py b/healthchain/data_generators/basegenerators.py index 8470f9e1..897744c2 100644 --- a/healthchain/data_generators/basegenerators.py +++ b/healthchain/data_generators/basegenerators.py @@ -23,10 +23,13 @@ urlModel, uuidModel, ) -from healthchain.fhir_resources.generalpurpose import ( - CodeableConcept, - Coding, -) + +# from healthchain.fhir_resources.generalpurpose import ( +# CodeableConcept, +# Coding, +# ) +from fhir.resources.codeableconcept import CodeableConcept +from fhir.resources.coding import Coding faker = Faker() diff --git a/healthchain/data_generators/conditiongenerators.py b/healthchain/data_generators/conditiongenerators.py index 1b4bc5ae..de67edcc 100644 --- a/healthchain/data_generators/conditiongenerators.py +++ b/healthchain/data_generators/conditiongenerators.py @@ -7,16 +7,23 @@ register_generator, CodeableConceptGenerator, ) -from healthchain.fhir_resources.generalpurpose import ( - CodeableConcept, - Coding, - Reference, -) -from healthchain.fhir_resources.condition import ( - Condition, - ConditionStage, - ConditionParticipant, -) + +# from healthchain.fhir_resources.generalpurpose import ( +# CodeableConcept, +# Coding, +# Reference, +# ) +from fhir.resources.codeableconcept import CodeableConcept +from fhir.resources.coding import Coding +from fhir.resources.reference import Reference + +# from healthchain.fhir_resources.condition import ( +# Condition, +# ConditionStage, +# ConditionParticipant, +# ) +from fhir.resources.condition import Condition, ConditionStage, ConditionParticipant + from healthchain.data_generators.value_sets.conditioncodes import ( ConditionCodeSimple, ConditionCodeComplex, @@ -156,7 +163,6 @@ def generate( constraints=constraints ) return Condition( - resourceType="Condition", id=generator_registry.get("IdGenerator").generate(), clinicalStatus=generator_registry.get("ClinicalStatusGenerator").generate(), verificationStatus=generator_registry.get( diff --git a/poetry.lock b/poetry.lock index 225de705..c4313f63 100644 --- a/poetry.lock +++ b/poetry.lock @@ -633,6 +633,45 @@ typing-extensions = ">=4.8.0" all = ["email-validator (>=2.0.0)", "fastapi-cli[standard] (>=0.0.5)", "httpx (>=0.23.0)", "itsdangerous (>=1.1.0)", "jinja2 (>=2.11.2)", "orjson (>=3.2.1)", "pydantic-extra-types (>=2.0.0)", "pydantic-settings (>=2.0.0)", "python-multipart (>=0.0.7)", "pyyaml (>=5.3.1)", "ujson (>=4.0.1,!=4.0.2,!=4.1.0,!=4.2.0,!=4.3.0,!=5.0.0,!=5.1.0)", "uvicorn[standard] (>=0.12.0)"] standard = ["email-validator (>=2.0.0)", "fastapi-cli[standard] (>=0.0.5)", "httpx (>=0.23.0)", "jinja2 (>=2.11.2)", "python-multipart (>=0.0.7)", "uvicorn[standard] (>=0.12.0)"] +[[package]] +name = "fhir-core" +version = "1.0.0" +description = "FHIR Core library" +optional = false +python-versions = ">=3.8" +files = [ + {file = "fhir_core-1.0.0-py2.py3-none-any.whl", hash = "sha256:8f58015563dd1ebc2dcc2185197ed269b1a2d68f098d0fd617e2dd4e16cb2376"}, + {file = "fhir_core-1.0.0.tar.gz", hash = "sha256:654cd30eeffcd49212097e6a2abb590f0b9d33dac36bf39b1518bbd0841c0f2c"}, +] + +[package.dependencies] +pydantic = ">=2.7.4,<3.0" + +[package.extras] +dev = ["Jinja2 (==2.11.1)", "MarkupSafe (==1.1.1)", "PyYAML (>=6.0.1)", "black", "certifi", "colorlog (==2.10.0)", "coverage", "fhirspec", "flake8 (==6.0)", "flake8-bugbear (>=22.12.6)", "flake8-isort (>=6.0.0)", "importlib-metadata (>=5.2.0)", "isort (>=5.11.4)", "lxml", "mypy", "pytest (>5.4.0)", "pytest-cov (>=2.10.0)", "requests (==2.23.0)", "setuptools (==65.6.3)", "types-PyYAML", "types-requests", "types-simplejson", "zest-releaser[recommended]"] +test = ["PyYAML (>=6.0.1)", "black", "coverage", "flake8 (==6.0)", "flake8-bugbear (>=22.12.6)", "flake8-isort (>=6.0.0)", "importlib-metadata (>=5.2.0)", "isort (>=5.11.4)", "lxml", "mypy", "pytest (>5.4.0)", "pytest-cov (>=2.10.0)", "pytest-runner", "requests (==2.23.0)", "setuptools (==65.6.3)", "types-PyYAML", "types-requests", "types-simplejson"] + +[[package]] +name = "fhir-resources" +version = "8.0.0" +description = "FHIR Resources as Model Class" +optional = false +python-versions = ">=3.8" +files = [ + {file = "fhir.resources-8.0.0-py2.py3-none-any.whl", hash = "sha256:9c46d6d79c6d6629c3bea6f244bcc6e8e0e4d15757a675f19d9d1c05c9ab2199"}, + {file = "fhir.resources-8.0.0.tar.gz", hash = "sha256:84dac3af31eaf90d5b0386cac21d26c50e6fb1526d68b88a2c42d112978e9cf9"}, +] + +[package.dependencies] +fhir-core = ">=1.0.0" + +[package.extras] +all = ["PyYAML (>=5.4.1)", "lxml"] +dev = ["Jinja2 (==2.11.1)", "MarkupSafe (==1.1.1)", "black", "certifi", "colorlog (==2.10.0)", "coverage", "fhirspec", "flake8 (==6.0)", "flake8-bugbear (>=22.12.6)", "flake8-isort (>=6.0.0)", "importlib-metadata (>=5.2.0)", "isort (>=5.11.4)", "mypy", "pytest (>5.4.0)", "pytest-cov (>=2.10.0)", "requests (==2.23.0)", "setuptools (==65.6.3)", "typed-ast (>=1.5.4)", "types-PyYAML", "types-requests", "types-simplejson", "zest-releaser[recommended]"] +test = ["PyYAML (>=5.4.1)", "black", "coverage", "flake8 (==6.0)", "flake8-bugbear (>=22.12.6)", "flake8-isort (>=6.0.0)", "importlib-metadata (>=5.2.0)", "isort (>=5.11.4)", "lxml", "mypy", "pytest (>5.4.0)", "pytest-cov (>=2.10.0)", "pytest-runner", "requests (==2.23.0)", "setuptools (==65.6.3)", "typed-ast (>=1.5.4)", "types-PyYAML", "types-requests", "types-simplejson"] +xml = ["lxml"] +yaml = ["PyYAML (>=5.4.1)"] + [[package]] name = "filelock" version = "3.16.1" @@ -1698,8 +1737,8 @@ files = [ [package.dependencies] numpy = [ {version = ">=1.20.3", markers = "python_version < \"3.10\""}, - {version = ">=1.21.0", markers = "python_version >= \"3.10\" and python_version < \"3.11\""}, {version = ">=1.23.2", markers = "python_version >= \"3.11\""}, + {version = ">=1.21.0", markers = "python_version >= \"3.10\" and python_version < \"3.11\""}, ] python-dateutil = ">=2.8.2" pytz = ">=2020.1" @@ -3353,4 +3392,4 @@ type = ["pytest-mypy"] [metadata] lock-version = "2.0" python-versions = ">=3.8,<3.12" -content-hash = "555c8932c3b2be4327a1f01ff3dbe418085a30c9ed5c0891f27c006827fd4ba0" +content-hash = "f317cb685fbe5ee37470f03beeaca699601a5bb5029bcc359e76d2939dc6668c" diff --git a/pyproject.toml b/pyproject.toml index fd5604d8..19b9c127 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -41,6 +41,7 @@ spyne = "^2.14.0" lxml = "^5.2.2" xmltodict = "^0.13.0" +fhir-resources = "^8.0.0" [tool.poetry.group.dev.dependencies] ruff = "^0.4.2" pytest = "^8.2.0" diff --git a/tests/generators_tests/test_condition_generators.py b/tests/generators_tests/test_condition_generators.py index 3c9db9f3..3794ab2f 100644 --- a/tests/generators_tests/test_condition_generators.py +++ b/tests/generators_tests/test_condition_generators.py @@ -13,10 +13,10 @@ def test_ClinicalStatusGenerator(): clinical_status = ClinicalStatusGenerator.generate() assert ( - clinical_status.coding_field[0].system_field + clinical_status.coding[0].system == "http://terminology.hl7.org/CodeSystem/condition-clinical" ) - assert clinical_status.coding_field[0].code_field in ( + assert clinical_status.coding[0].code in ( "active", "recurrence", "inactive", @@ -27,10 +27,10 @@ def test_ClinicalStatusGenerator(): def test_VerificationStatusGenerator(): verification_status = VerificationStatusGenerator.generate() assert ( - verification_status.coding_field[0].system_field + verification_status.coding[0].system == "http://terminology.hl7.org/CodeSystem/condition-ver-status" ) - assert verification_status.coding_field[0].code_field in ( + assert verification_status.coding[0].code in ( "provisional", "confirmed", ) @@ -38,15 +38,14 @@ def test_VerificationStatusGenerator(): def test_CategoryGenerator(): category = CategoryGenerator.generate() - assert category.coding_field[0].system_field == "http://snomed.info/sct" - assert category.coding_field[0].code_field in ("55607006", "404684003") + assert category.coding[0].system == "http://snomed.info/sct" + assert category.coding[0].code in ("55607006", "404684003") def test_ConditionGenerator(): condition_model = ConditionGenerator.generate("Patient/456", "Encounter/789") value_set = [x.code for x in ConditionCodeSimple().value_set] value_set.extend([x.code for x in ConditionCodeComplex().value_set]) - assert condition_model.resourceType == "Condition" assert condition_model.subject_field.reference_field == "Patient/456" assert condition_model.encounter_field.reference_field == "Encounter/789" assert condition_model.id_field is not None From cc6efa972cb455bd4b6b33a759482695f03272ed Mon Sep 17 00:00:00 2001 From: Adam Kells Date: Wed, 22 Jan 2025 19:51:42 +0000 Subject: [PATCH 02/34] remove fhir resources --- healthchain/data_generators/basegenerators.py | 64 +- .../data_generators/cdsdatagenerator.py | 6 +- .../data_generators/conditiongenerators.py | 10 - .../data_generators/encountergenerators.py | 45 +- .../medicationadministrationgenerators.py | 20 +- .../medicationrequestgenerators.py | 19 +- .../data_generators/patientgenerators.py | 95 ++- .../data_generators/practitionergenerators.py | 52 +- .../data_generators/proceduregenerators.py | 5 +- healthchain/fhir_resources/__init__.py | 21 - healthchain/fhir_resources/bundleresources.py | 78 --- healthchain/fhir_resources/condition.py | 238 ------- .../fhir_resources/documentreference.py | 293 -------- healthchain/fhir_resources/encounter.py | 367 ---------- healthchain/fhir_resources/generalpurpose.py | 651 ------------------ .../medicationadministration.py | 260 ------- .../fhir_resources/medicationrequest.py | 353 ---------- healthchain/fhir_resources/patient.py | 378 ---------- healthchain/fhir_resources/practitioner.py | 177 ----- healthchain/fhir_resources/primitives.py | 38 - healthchain/fhir_resources/procedure.py | 289 -------- .../fhir_resources/resourceregistry.py | 167 ----- healthchain/models/data/cdsfhirdata.py | 2 +- tests/conftest.py | 6 +- .../test_fhir_resources_base.py | 59 -- .../test_fhir_resources_bundle.py | 15 - .../test_fhir_resources_patient.py | 79 --- .../test_fhir_resources_practitioner.py | 45 -- .../test_condition_generators.py | 14 +- .../test_encounter_generators.py | 19 +- ...st_medication_administration_generators.py | 8 +- .../test_medication_request_generators.py | 8 +- .../test_patient_generators.py | 5 +- .../test_practitioner_generators.py | 39 +- .../test_procedure_generators.py | 7 +- 35 files changed, 164 insertions(+), 3768 deletions(-) delete mode 100644 healthchain/fhir_resources/__init__.py delete mode 100644 healthchain/fhir_resources/bundleresources.py delete mode 100644 healthchain/fhir_resources/condition.py delete mode 100644 healthchain/fhir_resources/documentreference.py delete mode 100644 healthchain/fhir_resources/encounter.py delete mode 100644 healthchain/fhir_resources/generalpurpose.py delete mode 100644 healthchain/fhir_resources/medicationadministration.py delete mode 100644 healthchain/fhir_resources/medicationrequest.py delete mode 100644 healthchain/fhir_resources/patient.py delete mode 100644 healthchain/fhir_resources/practitioner.py delete mode 100644 healthchain/fhir_resources/primitives.py delete mode 100644 healthchain/fhir_resources/procedure.py delete mode 100644 healthchain/fhir_resources/resourceregistry.py delete mode 100644 tests/fhir_resources_unit_tests/test_fhir_resources_base.py delete mode 100644 tests/fhir_resources_unit_tests/test_fhir_resources_bundle.py delete mode 100644 tests/fhir_resources_unit_tests/test_fhir_resources_patient.py delete mode 100644 tests/fhir_resources_unit_tests/test_fhir_resources_practitioner.py diff --git a/healthchain/data_generators/basegenerators.py b/healthchain/data_generators/basegenerators.py index 897744c2..5918ac9b 100644 --- a/healthchain/data_generators/basegenerators.py +++ b/healthchain/data_generators/basegenerators.py @@ -4,30 +4,8 @@ import string from faker import Faker -from healthchain.fhir_resources.primitives import ( - booleanModel, - canonicalModel, - codeModel, - dateModel, - dateTimeModel, - decimalModel, - idModel, - instantModel, - integerModel, - markdownModel, - positiveIntModel, - stringModel, - timeModel, - unsignedIntModel, - uriModel, - urlModel, - uuidModel, -) - -# from healthchain.fhir_resources.generalpurpose import ( -# CodeableConcept, -# Coding, -# ) + + from fhir.resources.codeableconcept import CodeableConcept from fhir.resources.coding import Coding @@ -68,14 +46,14 @@ def generate(): class BooleanGenerator(BaseGenerator): @staticmethod def generate(): - return booleanModel(random.choice(["true", "false"])) + return random.choice([True, False]) @register_generator class CanonicalGenerator(BaseGenerator): @staticmethod def generate(): - return canonicalModel(f"https://example/{faker.uri_path()}") + return f"https://example/{faker.uri_path()}" @register_generator @@ -83,107 +61,105 @@ class CodeGenerator(BaseGenerator): # TODO: Codes can technically have whitespace but here I've left it out for simplicity @staticmethod def generate(): - return codeModel( - "".join(random.choices(string.ascii_uppercase + string.digits, k=6)) - ) + return "".join(random.choices(string.ascii_uppercase + string.digits, k=6)) @register_generator class DateGenerator(BaseGenerator): @staticmethod def generate(): - return dateModel(faker.date()) + return faker.date() @register_generator class DateTimeGenerator(BaseGenerator): @staticmethod def generate(): - return dateTimeModel(faker.date_time().isoformat()) + return faker.date_time().isoformat() @register_generator class DecimalGenerator(BaseGenerator): @staticmethod def generate(): - return decimalModel(faker.random_number()) + return faker.random_number() @register_generator class IdGenerator(BaseGenerator): @staticmethod def generate(): - return idModel(faker.uuid4()) + return faker.uuid4() @register_generator class InstantGenerator(BaseGenerator): @staticmethod def generate(): - return instantModel(faker.date_time().isoformat()) + return faker.date_time().isoformat() @register_generator class IntegerGenerator(BaseGenerator): @staticmethod def generate(): - return integerModel(faker.random_int()) + return faker.random_int() @register_generator class MarkdownGenerator(BaseGenerator): @staticmethod def generate(): - return markdownModel(faker.text()) + return faker.text() @register_generator class PositiveIntGenerator(BaseGenerator): @staticmethod def generate(): - return positiveIntModel(faker.random_int(min=1)) + return faker.random_int(min=1) @register_generator class StringGenerator(BaseGenerator): @staticmethod def generate(): - return stringModel(faker.word()) + return faker.word() @register_generator class TimeGenerator(BaseGenerator): @staticmethod def generate(): - return timeModel(faker.time()) + return faker.time() @register_generator class UnsignedIntGenerator(BaseGenerator): @staticmethod def generate(): - return unsignedIntModel(faker.random_int(min=0)) + return faker.random_int(min=0) @register_generator class UriGenerator(BaseGenerator): @staticmethod def generate(): - return uriModel(f"https://example/{faker.uri_path()}") + return f"https://example/{faker.uri_path()}" @register_generator class UrlGenerator(BaseGenerator): @staticmethod def generate(): - return urlModel(f"https://example/{faker.uri_path()}") + return f"https://example/{faker.uri_path()}" @register_generator class UuidGenerator(BaseGenerator): @staticmethod def generate(): - return uuidModel(faker.uuid4()) + return faker.uuid4() class CodeableConceptGenerator(BaseGenerator): @@ -204,7 +180,7 @@ def generate_from_valueset(ValueSet): system=value_set_instance.system, code=code, display=display, - # extension=[ExtensionModel(value_set_instance.extension)], + # extension=[Extension(value_set_instance.extension)], ) ] ) diff --git a/healthchain/data_generators/cdsdatagenerator.py b/healthchain/data_generators/cdsdatagenerator.py index d471ef17..c4c912d5 100644 --- a/healthchain/data_generators/cdsdatagenerator.py +++ b/healthchain/data_generators/cdsdatagenerator.py @@ -7,10 +7,10 @@ from pathlib import Path from healthchain.base import Workflow -from healthchain.fhir_resources.bundleresources import Bundle, BundleEntry +from fhir.resources.bundle import Bundle, BundleEntry from healthchain.data_generators.basegenerators import generator_registry -from healthchain.fhir_resources.documentreference import DocumentReference -from healthchain.fhir_resources.generalpurpose import Narrative +from fhir.resources.documentreference import DocumentReference +from fhir.resources.narrative import Narrative from healthchain.models.data.cdsfhirdata import CdsFhirData logger = logging.getLogger(__name__) diff --git a/healthchain/data_generators/conditiongenerators.py b/healthchain/data_generators/conditiongenerators.py index de67edcc..67a5b608 100644 --- a/healthchain/data_generators/conditiongenerators.py +++ b/healthchain/data_generators/conditiongenerators.py @@ -8,20 +8,10 @@ CodeableConceptGenerator, ) -# from healthchain.fhir_resources.generalpurpose import ( -# CodeableConcept, -# Coding, -# Reference, -# ) from fhir.resources.codeableconcept import CodeableConcept from fhir.resources.coding import Coding from fhir.resources.reference import Reference -# from healthchain.fhir_resources.condition import ( -# Condition, -# ConditionStage, -# ConditionParticipant, -# ) from fhir.resources.condition import Condition, ConditionStage, ConditionParticipant from healthchain.data_generators.value_sets.conditioncodes import ( diff --git a/healthchain/data_generators/encountergenerators.py b/healthchain/data_generators/encountergenerators.py index 235ebe05..f41f53d4 100644 --- a/healthchain/data_generators/encountergenerators.py +++ b/healthchain/data_generators/encountergenerators.py @@ -1,45 +1,37 @@ from typing import Optional from faker import Faker -from healthchain.fhir_resources.encounter import ( - Encounter, - EncounterLocation, -) -from healthchain.fhir_resources.primitives import dateTimeModel -from healthchain.fhir_resources.generalpurpose import ( - Coding, - CodeableConcept, - Period, - Reference, -) +from fhir.resources.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 healthchain.data_generators.basegenerators import ( BaseGenerator, generator_registry, register_generator, ) +from datetime import datetime + faker = Faker() @register_generator class PeriodGenerator(BaseGenerator): - """ - A generator class for creating FHIR Period resources. - - Methods: - generate() -> Period: - Generates a FHIR Period resource with random start and end times. - """ - @staticmethod def generate(): - start = faker.date_time() - end = faker.date_time_between(start_date=start).isoformat() - start = start.isoformat() + # Use date_between instead of date() for more control + start = faker.date_between( + start_date="-30y", # You can adjust this range + end_date="today", + ) + end = faker.date_between_dates(date_start=start, date_end=datetime.now()) return Period( - start=dateTimeModel(start), - end=dateTimeModel(end), + start=start, + end=end, ) @@ -155,7 +147,6 @@ def generate( Faker.seed(random_seed) patient_reference = "Patient/123" return Encounter( - resourceType="Encounter", id=generator_registry.get("IdGenerator").generate(), status=faker.random_element( elements=( @@ -166,9 +157,9 @@ def generate( "cancelled", ) ), - class_field=[generator_registry.get("ClassGenerator").generate()], + class_fhir=[generator_registry.get("ClassGenerator").generate()], priority=generator_registry.get("EncounterPriorityGenerator").generate(), - type_field=[generator_registry.get("EncounterTypeGenerator").generate()], + type=[generator_registry.get("EncounterTypeGenerator").generate()], subject={"reference": patient_reference, "display": patient_reference}, actualPeriod=generator_registry.get("PeriodGenerator").generate(), location=[generator_registry.get("EncounterLocationGenerator").generate()], diff --git a/healthchain/data_generators/medicationadministrationgenerators.py b/healthchain/data_generators/medicationadministrationgenerators.py index 8e8b2b1a..dedea1cd 100644 --- a/healthchain/data_generators/medicationadministrationgenerators.py +++ b/healthchain/data_generators/medicationadministrationgenerators.py @@ -1,15 +1,10 @@ from typing import Optional from faker import Faker -from healthchain.fhir_resources.medicationadministration import ( - MedicationAdministration, - MedicationAdministrationDosage, -) -from healthchain.fhir_resources.generalpurpose import ( - Reference, - CodeableReference, -) -from healthchain.fhir_resources.medicationrequest import Medication +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 healthchain.data_generators.basegenerators import ( BaseGenerator, generator_registry, @@ -42,16 +37,9 @@ def generate( encounter_reference: str, constraints: Optional[list] = None, ): - contained_medication = Medication( - code=generator_registry.get( - "MedicationRequestContainedGenerator" - ).generate() - ) return MedicationAdministration( - resourceType="MedicationAdministration", id=generator_registry.get("IdGenerator").generate(), status=generator_registry.get("EventStatusGenerator").generate(), - contained=[contained_medication], medication=CodeableReference( reference=Reference(reference="Medication/123") ), diff --git a/healthchain/data_generators/medicationrequestgenerators.py b/healthchain/data_generators/medicationrequestgenerators.py index c0fbe5fa..8ef25e67 100644 --- a/healthchain/data_generators/medicationrequestgenerators.py +++ b/healthchain/data_generators/medicationrequestgenerators.py @@ -1,15 +1,6 @@ from typing import Optional from faker import Faker -from healthchain.fhir_resources.medicationrequest import ( - MedicationRequest, - Medication, - Dosage, -) -from healthchain.fhir_resources.generalpurpose import ( - Reference, - CodeableReference, -) from healthchain.data_generators.basegenerators import ( BaseGenerator, generator_registry, @@ -19,6 +10,10 @@ from healthchain.data_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 faker = Faker() @@ -51,16 +46,10 @@ def generate( Faker.seed(random_seed) subject_reference = "Patient/123" encounter_reference = "Encounter/123" - contained_medication = Medication( - code=generator_registry.get( - "MedicationRequestContainedGenerator" - ).generate() - ) return MedicationRequest( resourceType="MedicationRequest", id=generator_registry.get("IdGenerator").generate(), status=generator_registry.get("EventStatusGenerator").generate(), - contained=[contained_medication], medication=CodeableReference( reference=Reference(reference="Medication/123") ), diff --git a/healthchain/data_generators/patientgenerators.py b/healthchain/data_generators/patientgenerators.py index 7076ed31..61b15704 100644 --- a/healthchain/data_generators/patientgenerators.py +++ b/healthchain/data_generators/patientgenerators.py @@ -6,39 +6,35 @@ generator_registry, register_generator, ) -from healthchain.fhir_resources.primitives import ( - stringModel, - uriModel, - codeModel, - dateTimeModel, - positiveIntModel, -) -from healthchain.fhir_resources.generalpurpose import ( - Period, - CodeableConcept, - Coding, -) -from healthchain.fhir_resources.patient import ( - Patient, - HumanName, - ContactPoint, - Address, -) + +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 faker = Faker() +# TODO: Move to common gens @register_generator class PeriodGenerator(BaseGenerator): @staticmethod def generate(): - start = faker.date_time() - end = faker.date_time_between(start_date=start).isoformat() - start = start.isoformat() + # Use date_between instead of date() for more control + start = faker.date_between( + start_date="-30y", # You can adjust this range + end_date="today", + ) + end = faker.date_between_dates(date_start=start, date_end=datetime.now()) return Period( - start=dateTimeModel(start), - end=dateTimeModel(end), + start=start, + end=end, ) @@ -47,10 +43,10 @@ class ContactPointGenerator(BaseGenerator): @staticmethod def generate(): return ContactPoint( - system=codeModel(faker.random_element(elements=("phone", "fax"))), - value=stringModel(faker.phone_number()), - use=codeModel(faker.random_element(elements=("home", "work"))), - rank=positiveIntModel(1), + system=faker.random_element(elements=("phone", "fax")), + value=faker.phone_number(), + use=faker.random_element(elements=("home", "work")), + rank=1, period=generator_registry.get("PeriodGenerator").generate(), ) @@ -60,19 +56,15 @@ class AddressGenerator(BaseGenerator): @staticmethod def generate(): return Address( - use=codeModel( - faker.random_element(elements=("home", "work", "temp", "old")) - ), - type=codeModel( - faker.random_element(elements=("postal", "physical", "both")) - ), - text=stringModel(faker.address()), - line=[stringModel(faker.street_address())], - city=stringModel(faker.city()), - district=stringModel(faker.state()), - state=stringModel(faker.state_abbr()), - postalCode=stringModel(faker.postcode()), - country=stringModel(faker.country_code()), + use=faker.random_element(elements=("home", "work", "temp", "old")), + type=faker.random_element(elements=("postal", "physical", "both")), + text=faker.address(), + line=[faker.street_address()], + city=faker.city(), + district=faker.state(), + state=faker.state_abbr(), + postalCode=faker.postcode(), + country=faker.country_code(), period=generator_registry.get("PeriodGenerator").generate(), ) @@ -90,14 +82,12 @@ def generate(): return CodeableConcept( coding=[ Coding( - system=uriModel( - "http://terminology.hl7.org/CodeSystem/v3-MaritalStatus" - ), - code=codeModel(marital_code), - display=stringModel(marital_status_dict.get(marital_code)), + system="http://terminology.hl7.org/CodeSystem/v3-MaritalStatus", + code=marital_code, + display=marital_status_dict.get(marital_code), ) ], - text=stringModel(marital_status_dict.get(marital_code)), + text=marital_status_dict.get(marital_code), ) @@ -106,10 +96,10 @@ class HumanNameGenerator(BaseGenerator): @staticmethod def generate(): return HumanName( - family=stringModel(faker.last_name()), - given=[stringModel(faker.first_name())], - prefix=[stringModel(faker.prefix())], - suffix=[stringModel(faker.suffix())], + family=faker.last_name(), + given=[faker.first_name()], + prefix=[faker.prefix()], + suffix=[faker.suffix()], ) @@ -122,13 +112,12 @@ def generate( ) -> Patient: Faker.seed(random_seed) return Patient( - resourceType="Patient", id=generator_registry.get("IdGenerator").generate(), active=generator_registry.get("BooleanGenerator").generate(), name=[generator_registry.get("HumanNameGenerator").generate()], telecom=[generator_registry.get("ContactPointGenerator").generate()], - gender=codeModel( - faker.random_element(elements=("male", "female", "other", "unknown")) + gender=faker.random_element( + elements=("male", "female", "other", "unknown") ), birthDate=generator_registry.get("DateGenerator").generate(), address=[ diff --git a/healthchain/data_generators/practitionergenerators.py b/healthchain/data_generators/practitionergenerators.py index 6ea94cfc..f0950cb2 100644 --- a/healthchain/data_generators/practitionergenerators.py +++ b/healthchain/data_generators/practitionergenerators.py @@ -6,21 +6,14 @@ generator_registry, register_generator, ) -from healthchain.fhir_resources.primitives import ( - booleanModel, - stringModel, - uriModel, - codeModel, -) -from healthchain.fhir_resources.generalpurpose import ( - CodeableConcept, - Coding, -) -from healthchain.fhir_resources.practitioner import ( + +from fhir.resources.practitioner import ( Practitioner, - PractitionerQualification, PractitionerCommunication, + PractitionerQualification, ) +from fhir.resources.codeableconcept import CodeableConcept +from fhir.resources.coding import Coding faker = Faker() @@ -45,16 +38,12 @@ def generate(): return CodeableConcept( coding=[ Coding( - system=uriModel("http://example.org"), - code=codeModel(random_qual), - display=stringModel( - QualificationGenerator.qualification_dict.get(random_qual) - ), + system="http://example.org", + code=random_qual, + display=QualificationGenerator.qualification_dict.get(random_qual), ) ], - text=stringModel( - QualificationGenerator.qualification_dict.get(random_qual) - ), + text=QualificationGenerator.qualification_dict.get(random_qual), ) @@ -63,7 +52,7 @@ class Practitioner_QualificationGenerator(BaseGenerator): @staticmethod def generate(): return PractitionerQualification( - id=stringModel(faker.uuid4()), + id=faker.uuid4(), code=generator_registry.get("QualificationGenerator").generate(), # TODO: Modify period generator to have flexibility to set to present date period=generator_registry.get("PeriodGenerator").generate(), @@ -91,12 +80,12 @@ def generate(): return CodeableConcept( coding=[ Coding( - system=uriModel("http://terminology.hl7.org/CodeSystem/languages"), - code=codeModel(language), - display=stringModel(language_value_dict.get(language)), + system="http://terminology.hl7.org/CodeSystem/languages", + code=language, + display=language_value_dict.get(language), ) ], - text=stringModel(language_value_dict.get(language)), + text=language_value_dict.get(language), ) @@ -105,9 +94,9 @@ class Practitioner_CommunicationGenerator(BaseGenerator): @staticmethod def generate(): return PractitionerCommunication( - id=stringModel(faker.uuid4()), + id=faker.uuid4(), language=generator_registry.get("LanguageGenerator").generate(), - preferred=booleanModel("true"), + preferred=True, ) @@ -116,13 +105,12 @@ class PractitionerGenerator(BaseGenerator): @staticmethod def generate(constraints: Optional[list] = None): return Practitioner( - resourceType="Practitioner", - id=stringModel(faker.uuid4()), - active=booleanModel("true"), + id=faker.uuid4(), + active=True, name=[generator_registry.get("HumanNameGenerator").generate()], telecom=[generator_registry.get("ContactPointGenerator").generate()], - gender=codeModel( - faker.random_element(elements=("male", "female", "other", "unknown")) + gender=faker.random_element( + elements=("male", "female", "other", "unknown") ), address=[generator_registry.get("AddressGenerator").generate()], qualification=[ diff --git a/healthchain/data_generators/proceduregenerators.py b/healthchain/data_generators/proceduregenerators.py index e3881fd2..2c9f8156 100644 --- a/healthchain/data_generators/proceduregenerators.py +++ b/healthchain/data_generators/proceduregenerators.py @@ -7,12 +7,12 @@ register_generator, CodeableConceptGenerator, ) -from healthchain.fhir_resources.generalpurpose import Reference -from healthchain.fhir_resources.procedure import Procedure from healthchain.data_generators.value_sets.procedurecodes import ( ProcedureCodeSimple, ProcedureCodeComplex, ) +from fhir.resources.procedure import Procedure +from fhir.resources.reference import Reference faker = Faker() @@ -51,7 +51,6 @@ def generate( constraints=constraints ) return Procedure( - resourceType="Procedure", id=generator_registry.get("IdGenerator").generate(), status=generator_registry.get("EventStatusGenerator").generate(), code=code, diff --git a/healthchain/fhir_resources/__init__.py b/healthchain/fhir_resources/__init__.py deleted file mode 100644 index aa2fce59..00000000 --- a/healthchain/fhir_resources/__init__.py +++ /dev/null @@ -1,21 +0,0 @@ -from .bundleresources import Bundle -from .condition import Condition -from .patient import Patient -from .practitioner import Practitioner -from .procedure import Procedure -from .documentreference import DocumentReference -from .encounter import Encounter -from .medicationadministration import MedicationAdministration -from .medicationrequest import MedicationRequest - -__all__ = [ - "Bundle", - "Condition", - "Patient", - "Practitioner", - "Procedure", - "DocumentReference", - "Encounter", - "MedicationAdministration", - "MedicationRequest", -] diff --git a/healthchain/fhir_resources/bundleresources.py b/healthchain/fhir_resources/bundleresources.py deleted file mode 100644 index 63b94aff..00000000 --- a/healthchain/fhir_resources/bundleresources.py +++ /dev/null @@ -1,78 +0,0 @@ -from pydantic import Field, BaseModel, model_validator -from typing import List, Literal, Any - -from healthchain.fhir_resources.resourceregistry import ImplementedResourceRegistry - -implemented_resources = [f"{item.value}" for item in ImplementedResourceRegistry] - - -class BundleEntry(BaseModel): - resource_field: Any = Field( - default=None, - alias="resource", - description="The Resource for the entry. The purpose/meaning of the resource is determined by the Bundle.type. This is allowed to be a Parameters resource if and only if it is referenced by something else within the Bundle that provides context/meaning.", - ) - - @model_validator(mode="before") - @classmethod - def validate_and_convert_resource(cls, values): - """ - Validates and converts the resource field in the BundleEntry. - - This method performs the following tasks: - 1. Checks if the resource is None, in which case it returns the values unchanged. - 2. If the resource is already a Pydantic BaseModel, it verifies that it's an implemented resource type. - 3. If the resource is a dictionary, it checks for the presence of a 'resourceType' key and validates that it's an implemented resource type. - 4. Dynamically imports the appropriate resource class based on the resourceType. - 5. Recursively converts nested dictionaries to the appropriate Pydantic models. - - Args: - cls: The class on which this method is called. - values (dict): A dictionary containing the field values of the BundleEntry. - - Returns: - dict: The validated and potentially modified values dictionary. - - Raises: - ValueError: If the resource is invalid or of an unsupported type. - """ - resource = values.get("resource") - - if resource is None: - return values # Return unchanged if resource is None - - if isinstance(resource, BaseModel): - # If it's already a Pydantic model (e.g., Patient), use it directly - if resource.__class__.__name__ not in implemented_resources: - raise ValueError( - f"Invalid resource type: {resource.__class__.__name__}. Must be one of {implemented_resources}." - ) - return values - - if not isinstance(resource, dict) or "resourceType" not in resource: - raise ValueError( - "Invalid resource: must be a dictionary with a 'resourceType' key or a valid FHIR resource model" - ) - - resource_type = resource["resourceType"] - if resource_type not in implemented_resources: - raise ValueError( - f"Invalid resourceType: {resource_type}. Must be one of {implemented_resources}." - ) - - # Import the appropriate resource class dynamically - module = __import__("healthchain.fhir_resources", fromlist=[resource_type]) - resource_class = getattr(module, resource_type) - - # Convert the dictionary to the appropriate Pydantic model - values["resource"] = resource_class(**resource) - return values - - -class Bundle(BaseModel): - resourceType: Literal["Bundle"] = "Bundle" - entry_field: List[BundleEntry] = Field( - default_factory=list, - alias="entry", - description="An entry in a bundle resource - will either contain a resource or information about a resource (transactions and history only).", - ) diff --git a/healthchain/fhir_resources/condition.py b/healthchain/fhir_resources/condition.py deleted file mode 100644 index e05b366c..00000000 --- a/healthchain/fhir_resources/condition.py +++ /dev/null @@ -1,238 +0,0 @@ -from pydantic import BaseModel, Field -from typing import List, Literal - -from healthchain.fhir_resources.primitives import ( - stringModel, - idModel, - uriModel, - codeModel, - dateTimeModel, -) -from healthchain.fhir_resources.generalpurpose import ( - Extension, - Identifier, - CodeableConcept, - Reference, - Period, - CodeableReference, - Narrative, - Age, - Range, - Meta, - Annotation, -) - - -class ConditionParticipant(BaseModel): - id_field: stringModel = Field( - default=None, - alias="id", - description="Unique id for the element within a resource (for internal references). This may be any string value that does not contain spaces.", - ) - extension_field: List[Extension] = Field( - default=None, - alias="extension", - description="May be used to represent additional information that is not part of the basic definition of the element. To make the use of extensions safe and managable, there is a strict set of governance applied to the definition and use of extensions. Though any implementer can define an extension, there is a set of requirements that SHALL be met as part of the definition of the extension.", - ) - modifierExtension_field: List[Extension] = Field( - default=None, - alias="modifierExtension", - description="May be used to represent additional information that is not part of the basic definition of the element and that modifies the understanding of the element in which it is contained and/or the understanding of the containing element's descendants. Usually modifier elements provide negation or qualification. To make the use of extensions safe and managable, there is a strict set of governance applied to the definition and use of extensions. Though any implementer can define an extension, there is a set of requirements that SHALL be met as part of the definition of the extension. Applications processing a resource are required to check for modifier extensions.", - ) - function_field: CodeableConcept = Field( - default=None, - alias="function", - description="Distinguishes the type of involvement of the actor in the activities related to the condition.", - ) - actor_field: Reference = Field( - default=None, - alias="actor", - description="Indicates who or what participated in the activities related to the condition.", - ) - - -class ConditionStage(BaseModel): - id_field: stringModel = Field( - default=None, - alias="id", - description="Unique id for the element within a resource (for internal references). This may be any string value that does not contain spaces.", - ) - extension_field: List[Extension] = Field( - default=None, - alias="extension", - description="May be used to represent additional information that is not part of the basic definition of the element. To make the use of extensions safe and managable, there is a strict set of governance applied to the definition and use of extensions. Though any implementer can define an extension, there is a set of requirements that SHALL be met as part of the definition of the extension.", - ) - modifierExtension_field: List[Extension] = Field( - default=None, - alias="modifierExtension", - description="May be used to represent additional information that is not part of the basic definition of the element and that modifies the understanding of the element in which it is contained and/or the understanding of the containing element's descendants. Usually modifier elements provide negation or qualification. To make the use of extensions safe and managable, there is a strict set of governance applied to the definition and use of extensions. Though any implementer can define an extension, there is a set of requirements that SHALL be met as part of the definition of the extension. Applications processing a resource are required to check for modifier extensions.", - ) - summary_field: CodeableConcept = Field( - default=None, - alias="summary", - description="A simple summary of the stage such as Stage 3 or Early Onset. The determination of the stage is disease-specific, such as cancer, retinopathy of prematurity, kidney diseases, Alzheimer's, or Parkinson disease.", - ) - assessment_field: List[Reference] = Field( - default=None, - alias="assessment", - description="Reference to a formal record of the evidence on which the staging assessment is based.", - ) - type_field: CodeableConcept = Field( - default=None, - alias="type", - description="The kind of staging, such as pathological or clinical staging.", - ) - - -class Condition(BaseModel): - resourceType: Literal["Condition"] = "Condition" - id_field: idModel = Field( - default=None, - alias="id", - description="The logical id of the resource, as used in the URL for the resource. Once assigned, this value never changes.", - ) - meta_field: Meta = Field( - default=None, - alias="meta", - description="The metadata about the resource. This is content that is maintained by the infrastructure. Changes to the content might not always be associated with version changes to the resource.", - ) - implicitRules_field: uriModel = Field( - default=None, - alias="implicitRules", - description="A reference to a set of rules that were followed when the resource was constructed, and which must be understood when processing the content. Often, this is a reference to an implementation guide that defines the special rules along with other profiles etc.", - ) - language_field: codeModel = Field( - default=None, - alias="language", - description="The base language in which the resource is written.", - ) - text_field: Narrative = Field( - default=None, - alias="text", - description="A human-readable narrative that contains a summary of the resource and can be used to represent the content of the resource to a human. The narrative need not encode all the structured data, but is required to contain sufficient detail to make it clinically safe for a human to just read the narrative. Resource definitions may define what content should be represented in the narrative to ensure clinical safety.", - ) - # contained_field: List[ResourceListModel] = Field( - # default=None, - # alias="contained", - # description="These resources do not have an independent existence apart from the resource that contains them - they cannot be identified independently, nor can they have their own independent transaction scope. This is allowed to be a Parameters resource if and only if it is referenced by a resource that provides context/meaning.", - # ) - extension_field: List[Extension] = Field( - default=None, - alias="extension", - description="May be used to represent additional information that is not part of the basic definition of the resource. To make the use of extensions safe and managable, there is a strict set of governance applied to the definition and use of extensions. Though any implementer can define an extension, there is a set of requirements that SHALL be met as part of the definition of the extension.", - ) - modifierExtension_field: List[Extension] = Field( - default=None, - alias="modifierExtension", - description="May be used to represent additional information that is not part of the basic definition of the resource and that modifies the understanding of the element that contains it and/or the understanding of the containing element's descendants. Usually modifier elements provide negation or qualification. To make the use of extensions safe and managable, there is a strict set of governance applied to the definition and use of extensions. Though any implementer is allowed to define an extension, there is a set of requirements that SHALL be met as part of the definition of the extension. Applications processing a resource are required to check for modifier extensions.", - ) - identifier_field: List[Identifier] = Field( - default=None, - alias="identifier", - description="Business identifiers assigned to this condition by the performer or other systems which remain constant as the resource is updated and propagates from server to server.", - ) - clinicalStatus_field: CodeableConcept = Field( - default=None, - alias="clinicalStatus", - description="The clinical status of the condition.", - ) - verificationStatus_field: CodeableConcept = Field( - default=None, - alias="verificationStatus", - description="The verification status to support the clinical status of the condition. The verification status pertains to the condition, itself, not to any specific condition attribute.", - ) - category_field: List[CodeableConcept] = Field( - default=None, - alias="category", - description="A category assigned to the condition.", - ) - severity_field: CodeableConcept = Field( - default=None, - alias="severity", - description="A subjective assessment of the severity of the condition as evaluated by the clinician.", - ) - code_field: CodeableConcept = Field( - default=None, - alias="code", - description="Identification of the condition, problem or diagnosis.", - ) - bodySite_field: List[CodeableConcept] = Field( - default=None, - alias="bodySite", - description="The anatomical location where this condition manifests itself.", - ) - subject_field: Reference = Field( - default=None, - alias="subject", - description="Indicates the patient or group who the condition record is associated with.", - ) - encounter_field: Reference = Field( - default=None, - alias="encounter", - description="The Encounter during which this Condition was created or to which the creation of this record is tightly associated.", - ) - onsetDateTime_field: dateTimeModel = Field( - default=None, - alias="onsetDateTime", - description="Estimated or actual date or date-time the condition began, in the opinion of the clinician.", - ) - onsetAge_field: Age = Field( - default=None, - alias="onsetAge", - description="Estimated or actual date or date-time the condition began, in the opinion of the clinician.", - ) - onsetPeriod_field: Period = Field( - default=None, - alias="onsetPeriod", - description="Estimated or actual date or date-time the condition began, in the opinion of the clinician.", - ) - onsetRange_field: Range = Field( - default=None, - alias="onsetRange", - description="Estimated or actual date or date-time the condition began, in the opinion of the clinician.", - ) - abatementDateTime_field: dateTimeModel = Field( - default=None, - alias="abatementDateTime", - description="The date or estimated date that the condition resolved or went into remission. This is called abatement because of the many overloaded connotations associated with remission or resolution - Some conditions, such as chronic conditions, are never really resolved, but they can abate.", - ) - abatementAge_field: Age = Field( - default=None, - alias="abatementAge", - description="The date or estimated date that the condition resolved or went into remission. This is called abatement because of the many overloaded connotations associated with remission or resolution - Some conditions, such as chronic conditions, are never really resolved, but they can abate.", - ) - abatementPeriod_field: Period = Field( - default=None, - alias="abatementPeriod", - description="The date or estimated date that the condition resolved or went into remission. This is called abatement because of the many overloaded connotations associated with remission or resolution - Some conditions, such as chronic conditions, are never really resolved, but they can abate.", - ) - abatementRange_field: Range = Field( - default=None, - alias="abatementRange", - description="The date or estimated date that the condition resolved or went into remission. This is called abatement because of the many overloaded connotations associated with remission or resolution - Some conditions, such as chronic conditions, are never really resolved, but they can abate.", - ) - recordedDate_field: dateTimeModel = Field( - default=None, - alias="recordedDate", - description="The recordedDate represents when this particular Condition record was created in the system, which is often a system-generated date.", - ) - participant_field: List[ConditionParticipant] = Field( - default=None, - alias="participant", - description="Indicates who or what participated in the activities related to the condition and how they were involved.", - ) - stage_field: List[ConditionStage] = Field( - default=None, - alias="stage", - description="A simple summary of the stage such as Stage 3 or Early Onset. The determination of the stage is disease-specific, such as cancer, retinopathy of prematurity, kidney diseases, Alzheimer's, or Parkinson disease.", - ) - evidence_field: List[CodeableReference] = Field( - default=None, - alias="evidence", - description="Supporting evidence / manifestations that are the basis of the Condition's verification status, such as evidence that confirmed or refuted the condition.", - ) - note_field: List[Annotation] = Field( - default=None, - alias="note", - description="Additional information about the Condition. This is a general notes/comments entry for description of the Condition, its diagnosis and prognosis.", - ) diff --git a/healthchain/fhir_resources/documentreference.py b/healthchain/fhir_resources/documentreference.py deleted file mode 100644 index 33d505cc..00000000 --- a/healthchain/fhir_resources/documentreference.py +++ /dev/null @@ -1,293 +0,0 @@ -from pydantic import BaseModel, Field -from typing import List, Literal - -from healthchain.fhir_resources.primitives import ( - idModel, - uriModel, - codeModel, - dateTimeModel, - instantModel, - markdownModel, - stringModel, -) -from healthchain.fhir_resources.generalpurpose import ( - Extension, - CodeableConcept, - Reference, - Period, - Narrative, - CodeableReference, - Coding, - Attachment, - Identifier, - Meta, -) - - -class DocumentReferenceAttester(BaseModel): - id_field: stringModel = Field( - default=None, - alias="id", - description="Unique id for the element within a resource (for internal references). This may be any string value that does not contain spaces.", - ) - extension_field: List[Extension] = Field( - default=None, - alias="extension", - description="May be used to represent additional information that is not part of the basic definition of the element. To make the use of extensions safe and managable, there is a strict set of governance applied to the definition and use of extensions. Though any implementer can define an extension, there is a set of requirements that SHALL be met as part of the definition of the extension.", - ) - modifierExtension_field: List[Extension] = Field( - default=None, - alias="modifierExtension", - description="May be used to represent additional information that is not part of the basic definition of the element and that modifies the understanding of the element in which it is contained and/or the understanding of the containing element's descendants. Usually modifier elements provide negation or qualification. To make the use of extensions safe and managable, there is a strict set of governance applied to the definition and use of extensions. Though any implementer can define an extension, there is a set of requirements that SHALL be met as part of the definition of the extension. Applications processing a resource are required to check for modifier extensions.", - ) - mode_field: CodeableConcept = Field( - default=None, - alias="mode", - description="The type of attestation the authenticator offers.", - ) - time_field: dateTimeModel = Field( - default=None, - alias="time", - description="When the document was attested by the party.", - ) - party_field: Reference = Field( - default=None, - alias="party", - description="Who attested the document in the specified way.", - ) - - -class DocumentReferenceRelatesTo(BaseModel): - id_field: stringModel = Field( - default=None, - alias="id", - description="Unique id for the element within a resource (for internal references). This may be any string value that does not contain spaces.", - ) - extension_field: List[Extension] = Field( - default=None, - alias="extension", - description="May be used to represent additional information that is not part of the basic definition of the element. To make the use of extensions safe and managable, there is a strict set of governance applied to the definition and use of extensions. Though any implementer can define an extension, there is a set of requirements that SHALL be met as part of the definition of the extension.", - ) - modifierExtension_field: List[Extension] = Field( - default=None, - alias="modifierExtension", - description="May be used to represent additional information that is not part of the basic definition of the element and that modifies the understanding of the element in which it is contained and/or the understanding of the containing element's descendants. Usually modifier elements provide negation or qualification. To make the use of extensions safe and managable, there is a strict set of governance applied to the definition and use of extensions. Though any implementer can define an extension, there is a set of requirements that SHALL be met as part of the definition of the extension. Applications processing a resource are required to check for modifier extensions.", - ) - code_field: CodeableConcept = Field( - default=None, - alias="code", - description="The type of relationship that this document has with anther document.", - ) - target_field: Reference = Field( - default=None, - alias="target", - description="The target document of this relationship.", - ) - - -class DocumentReferenceProfile(BaseModel): - id_field: stringModel = Field( - default=None, - alias="id", - description="Unique id for the element within a resource (for internal references). This may be any string value that does not contain spaces.", - ) - extension_field: List[Extension] = Field( - default=None, - alias="extension", - description="May be used to represent additional information that is not part of the basic definition of the element. To make the use of extensions safe and managable, there is a strict set of governance applied to the definition and use of extensions. Though any implementer can define an extension, there is a set of requirements that SHALL be met as part of the definition of the extension.", - ) - modifierExtension_field: List[Extension] = Field( - default=None, - alias="modifierExtension", - description="May be used to represent additional information that is not part of the basic definition of the element and that modifies the understanding of the element in which it is contained and/or the understanding of the containing element's descendants. Usually modifier elements provide negation or qualification. To make the use of extensions safe and managable, there is a strict set of governance applied to the definition and use of extensions. Though any implementer can define an extension, there is a set of requirements that SHALL be met as part of the definition of the extension. Applications processing a resource are required to check for modifier extensions.", - ) - valueCoding_field: Coding = Field( - default=None, alias="valueCoding", description="Code|uri|canonical." - ) - - -class DocumentReferenceContent(BaseModel): - id_field: stringModel = Field( - default=None, - alias="id", - description="Unique id for the element within a resource (for internal references). This may be any string value that does not contain spaces.", - ) - extension_field: List[Extension] = Field( - default=None, - alias="extension", - description="May be used to represent additional information that is not part of the basic definition of the element. To make the use of extensions safe and managable, there is a strict set of governance applied to the definition and use of extensions. Though any implementer can define an extension, there is a set of requirements that SHALL be met as part of the definition of the extension.", - ) - modifierExtension_field: List[Extension] = Field( - default=None, - alias="modifierExtension", - description="May be used to represent additional information that is not part of the basic definition of the element and that modifies the understanding of the element in which it is contained and/or the understanding of the containing element's descendants. Usually modifier elements provide negation or qualification. To make the use of extensions safe and managable, there is a strict set of governance applied to the definition and use of extensions. Though any implementer can define an extension, there is a set of requirements that SHALL be met as part of the definition of the extension. Applications processing a resource are required to check for modifier extensions.", - ) - attachment_field: Attachment = Field( - default=None, - alias="attachment", - description="The document or URL of the document along with critical metadata to prove content has integrity.", - ) - profile_field: List[DocumentReferenceProfile] = Field( - default=None, - alias="profile", - description="An identifier of the document constraints, encoding, structure, and template that the document conforms to beyond the base format indicated in the mimeType.", - ) - - -class DocumentReference(BaseModel): - resourceType: Literal["DocumentReference"] = "DocumentReference" - id_field: idModel = Field( - default=None, - alias="id", - description="The logical id of the resource, as used in the URL for the resource. Once assigned, this value never changes.", - ) - meta_field: Meta = Field( - default=None, - alias="meta", - description="The metadata about the resource. This is content that is maintained by the infrastructure. Changes to the content might not always be associated with version changes to the resource.", - ) - implicitRules_field: uriModel = Field( - default=None, - alias="implicitRules", - description="A reference to a set of rules that were followed when the resource was constructed, and which must be understood when processing the content. Often, this is a reference to an implementation guide that defines the special rules along with other profiles etc.", - ) - language_field: codeModel = Field( - default=None, - alias="language", - description="The base language in which the resource is written.", - ) - text_field: Narrative = Field( - default=None, - alias="text", - description="A human-readable narrative that contains a summary of the resource and can be used to represent the content of the resource to a human. The narrative need not encode all the structured data, but is required to contain sufficient detail to make it clinically safe for a human to just read the narrative. Resource definitions may define what content should be represented in the narrative to ensure clinical safety.", - ) - # contained_field: List[ResourceListModel] = Field( - # default=None, - # alias="contained", - # description="These resources do not have an independent existence apart from the resource that contains them - they cannot be identified independently, nor can they have their own independent transaction scope. This is allowed to be a Parameters resource if and only if it is referenced by a resource that provides context/meaning.", - # ) - extension_field: List[Extension] = Field( - default=None, - alias="extension", - description="May be used to represent additional information that is not part of the basic definition of the resource. To make the use of extensions safe and managable, there is a strict set of governance applied to the definition and use of extensions. Though any implementer can define an extension, there is a set of requirements that SHALL be met as part of the definition of the extension.", - ) - modifierExtension_field: List[Extension] = Field( - default=None, - alias="modifierExtension", - description="May be used to represent additional information that is not part of the basic definition of the resource and that modifies the understanding of the element that contains it and/or the understanding of the containing element's descendants. Usually modifier elements provide negation or qualification. To make the use of extensions safe and managable, there is a strict set of governance applied to the definition and use of extensions. Though any implementer is allowed to define an extension, there is a set of requirements that SHALL be met as part of the definition of the extension. Applications processing a resource are required to check for modifier extensions.", - ) - identifier_field: List[Identifier] = Field( - default=None, - alias="identifier", - description="Other business identifiers associated with the document, including version independent identifiers.", - ) - version_field: stringModel = Field( - default=None, - alias="version", - description="An explicitly assigned identifer of a variation of the content in the DocumentReference.", - ) - basedOn_field: List[Reference] = Field( - default=None, - alias="basedOn", - description="A procedure that is fulfilled in whole or in part by the creation of this media.", - ) - status_field: codeModel = Field( - default=None, - alias="status", - description="The status of this document reference.", - ) - docStatus_field: codeModel = Field( - default=None, - alias="docStatus", - description="The status of the underlying document.", - ) - modality_field: List[CodeableConcept] = Field( - default=None, - alias="modality", - description="Imaging modality used. This may include both acquisition and non-acquisition modalities.", - ) - type_field: CodeableConcept = Field( - default=None, - alias="type", - description="Specifies the particular kind of document referenced (e.g. History and Physical, Discharge Summary, Progress Note). This usually equates to the purpose of making the document referenced.", - ) - category_field: List[CodeableConcept] = Field( - default=None, - alias="category", - description="A categorization for the type of document referenced - helps for indexing and searching. This may be implied by or derived from the code specified in the DocumentReference.type.", - ) - subject_field: Reference = Field( - default=None, - alias="subject", - description="Who or what the document is about. The document can be about a person, (patient or healthcare practitioner), a device (e.g. a machine) or even a group of subjects (such as a document about a herd of farm animals, or a set of patients that share a common exposure).", - ) - context_field: List[Reference] = Field( - default=None, - alias="context", - description="Describes the clinical encounter or type of care that the document content is associated with.", - ) - event_field: List[CodeableReference] = Field( - default=None, - alias="event", - description="This list of codes represents the main clinical acts, such as a colonoscopy or an appendectomy, being documented. In some cases, the event is inherent in the type Code, such as a History and Physical Report in which the procedure being documented is necessarily a History and Physical act.", - ) - bodySite_field: List[CodeableReference] = Field( - default=None, - alias="bodySite", - description="The anatomic structures included in the document.", - ) - facilityType_field: CodeableConcept = Field( - default=None, - alias="facilityType", - description="The kind of facility where the patient was seen.", - ) - practiceSetting_field: CodeableConcept = Field( - default=None, - alias="practiceSetting", - description="This property may convey specifics about the practice setting where the content was created, often reflecting the clinical specialty.", - ) - period_field: Period = Field( - default=None, - alias="period", - description="The time period over which the service that is described by the document was provided.", - ) - date_field: instantModel = Field( - default=None, - alias="date", - description="When the document reference was created.", - ) - author_field: List[Reference] = Field( - default=None, - alias="author", - description="Identifies who is responsible for adding the information to the document.", - ) - attester_field: List[DocumentReferenceAttester] = Field( - default=None, - alias="attester", - description="A participant who has authenticated the accuracy of the document.", - ) - custodian_field: Reference = Field( - default=None, - alias="custodian", - description="Identifies the organization or group who is responsible for ongoing maintenance of and access to the document.", - ) - relatesTo_field: List[DocumentReferenceRelatesTo] = Field( - default=None, - alias="relatesTo", - description="Relationships that this document has with other document references that already exist.", - ) - description_field: markdownModel = Field( - default=None, - alias="description", - description="Human-readable description of the source document.", - ) - securityLabel_field: List[CodeableConcept] = Field( - default=None, - alias="securityLabel", - description="A set of Security-Tag codes specifying the level of privacy/security of the Document found at DocumentReference.content.attachment.url. Note that DocumentReference.meta.security contains the security labels of the data elements in DocumentReference, while DocumentReference.securityLabel contains the security labels for the document the reference refers to. The distinction recognizes that the document may contain sensitive information, while the DocumentReference is metadata about the document and thus might not be as sensitive as the document. For example: a psychotherapy episode may contain highly sensitive information, while the metadata may simply indicate that some episode happened.", - ) - content_field: List[DocumentReferenceContent] = Field( - default=None, - alias="content", - description="The document and format referenced. If there are multiple content element repetitions, these must all represent the same document in different format, or attachment metadata.", - ) diff --git a/healthchain/fhir_resources/encounter.py b/healthchain/fhir_resources/encounter.py deleted file mode 100644 index 2b96580d..00000000 --- a/healthchain/fhir_resources/encounter.py +++ /dev/null @@ -1,367 +0,0 @@ -from pydantic import BaseModel, Field -from typing import List, Literal - -from healthchain.fhir_resources.primitives import ( - stringModel, - idModel, - uriModel, - codeModel, - dateTimeModel, -) -from healthchain.fhir_resources.generalpurpose import ( - Extension, - Identifier, - CodeableConcept, - Reference, - Period, - CodeableReference, - Narrative, - Meta, -) - - -class EncounterParticipant(BaseModel): - id_field: stringModel = Field( - default=None, - alias="id", - description="Unique id for the element within a resource (for internal references). This may be any string value that does not contain spaces.", - ) - extension_field: List[Extension] = Field( - default=None, - alias="extension", - description="May be used to represent additional information that is not part of the basic definition of the element. To make the use of extensions safe and managable, there is a strict set of governance applied to the definition and use of extensions. Though any implementer can define an extension, there is a set of requirements that SHALL be met as part of the definition of the extension.", - ) - modifierExtension_field: List[Extension] = Field( - default=None, - alias="modifierExtension", - description="May be used to represent additional information that is not part of the basic definition of the element and that modifies the understanding of the element in which it is contained and/or the understanding of the containing element's descendants. Usually modifier elements provide negation or qualification. To make the use of extensions safe and managable, there is a strict set of governance applied to the definition and use of extensions. Though any implementer can define an extension, there is a set of requirements that SHALL be met as part of the definition of the extension. Applications processing a resource are required to check for modifier extensions.", - ) - type_field: List[CodeableConcept] = Field( - default=None, - alias="type", - description="Role of participant in encounter.", - ) - period_field: Period = Field( - default=None, - alias="period", - description="The period of time that the specified participant participated in the encounter. These can overlap or be sub-sets of the overall encounter's period.", - ) - actor_field: Reference = Field( - default=None, - alias="actor", - description="Person involved in the encounter, the patient/group is also included here to indicate that the patient was actually participating in the encounter. Not including the patient here covers use cases such as a case meeting between practitioners about a patient - non contact times.", - ) - - -class EncounterReason(BaseModel): - id_field: stringModel = Field( - default=None, - alias="id", - description="Unique id for the element within a resource (for internal references). This may be any string value that does not contain spaces.", - ) - extension_field: List[Extension] = Field( - default=None, - alias="extension", - description="May be used to represent additional information that is not part of the basic definition of the element. To make the use of extensions safe and managable, there is a strict set of governance applied to the definition and use of extensions. Though any implementer can define an extension, there is a set of requirements that SHALL be met as part of the definition of the extension.", - ) - modifierExtension_field: List[Extension] = Field( - default=None, - alias="modifierExtension", - description="May be used to represent additional information that is not part of the basic definition of the element and that modifies the understanding of the element in which it is contained and/or the understanding of the containing element's descendants. Usually modifier elements provide negation or qualification. To make the use of extensions safe and managable, there is a strict set of governance applied to the definition and use of extensions. Though any implementer can define an extension, there is a set of requirements that SHALL be met as part of the definition of the extension. Applications processing a resource are required to check for modifier extensions.", - ) - use_field: List[CodeableConcept] = Field( - default=None, - alias="use", - description="What the reason value should be used as e.g. Chief Complaint, Health Concern, Health Maintenance (including screening).", - ) - value_field: List[CodeableReference] = Field( - default=None, - alias="value", - description="Reason the encounter takes place, expressed as a code or a reference to another resource. For admissions, this can be used for a coded admission diagnosis.", - ) - - -class EncounterDiagnosis(BaseModel): - id_field: stringModel = Field( - default=None, - alias="id", - description="Unique id for the element within a resource (for internal references). This may be any string value that does not contain spaces.", - ) - extension_field: List[Extension] = Field( - default=None, - alias="extension", - description="May be used to represent additional information that is not part of the basic definition of the element. To make the use of extensions safe and managable, there is a strict set of governance applied to the definition and use of extensions. Though any implementer can define an extension, there is a set of requirements that SHALL be met as part of the definition of the extension.", - ) - modifierExtension_field: List[Extension] = Field( - default=None, - alias="modifierExtension", - description="May be used to represent additional information that is not part of the basic definition of the element and that modifies the understanding of the element in which it is contained and/or the understanding of the containing element's descendants. Usually modifier elements provide negation or qualification. To make the use of extensions safe and managable, there is a strict set of governance applied to the definition and use of extensions. Though any implementer can define an extension, there is a set of requirements that SHALL be met as part of the definition of the extension. Applications processing a resource are required to check for modifier extensions.", - ) - condition_field: List[CodeableReference] = Field( - default=None, - alias="condition", - description="The coded diagnosis or a reference to a Condition (with other resources referenced in the evidence.detail), the use property will indicate the purpose of this specific diagnosis.", - ) - use_field: List[CodeableConcept] = Field( - default=None, - alias="use", - description="Role that this diagnosis has within the encounter (e.g. admission, billing, discharge …).", - ) - - -class EncounterAdmission(BaseModel): - id_field: stringModel = Field( - default=None, - alias="id", - description="Unique id for the element within a resource (for internal references). This may be any string value that does not contain spaces.", - ) - extension_field: List[Extension] = Field( - default=None, - alias="extension", - description="May be used to represent additional information that is not part of the basic definition of the element. To make the use of extensions safe and managable, there is a strict set of governance applied to the definition and use of extensions. Though any implementer can define an extension, there is a set of requirements that SHALL be met as part of the definition of the extension.", - ) - modifierExtension_field: List[Extension] = Field( - default=None, - alias="modifierExtension", - description="May be used to represent additional information that is not part of the basic definition of the element and that modifies the understanding of the element in which it is contained and/or the understanding of the containing element's descendants. Usually modifier elements provide negation or qualification. To make the use of extensions safe and managable, there is a strict set of governance applied to the definition and use of extensions. Though any implementer can define an extension, there is a set of requirements that SHALL be met as part of the definition of the extension. Applications processing a resource are required to check for modifier extensions.", - ) - preAdmissionIdentifier_field: Identifier = Field( - default=None, - alias="preAdmissionIdentifier", - description="Pre-admission identifier.", - ) - origin_field: Reference = Field( - default=None, - alias="origin", - description="The location/organization from which the patient came before admission.", - ) - admitSource_field: CodeableConcept = Field( - default=None, - alias="admitSource", - description="From where patient was admitted (physician referral, transfer).", - ) - reAdmission_field: CodeableConcept = Field( - default=None, - alias="reAdmission", - description="Indicates that this encounter is directly related to a prior admission, often because the conditions addressed in the prior admission were not fully addressed.", - ) - destination_field: Reference = Field( - default=None, - alias="destination", - description="Location/organization to which the patient is discharged.", - ) - dischargeDisposition_field: CodeableConcept = Field( - default=None, - alias="dischargeDisposition", - description="Category or kind of location after discharge.", - ) - - -class EncounterLocation(BaseModel): - id_field: stringModel = Field( - default=None, - alias="id", - description="Unique id for the element within a resource (for internal references). This may be any string value that does not contain spaces.", - ) - extension_field: List[Extension] = Field( - default=None, - alias="extension", - description="May be used to represent additional information that is not part of the basic definition of the element. To make the use of extensions safe and managable, there is a strict set of governance applied to the definition and use of extensions. Though any implementer can define an extension, there is a set of requirements that SHALL be met as part of the definition of the extension.", - ) - modifierExtension_field: List[Extension] = Field( - default=None, - alias="modifierExtension", - description="May be used to represent additional information that is not part of the basic definition of the element and that modifies the understanding of the element in which it is contained and/or the understanding of the containing element's descendants. Usually modifier elements provide negation or qualification. To make the use of extensions safe and managable, there is a strict set of governance applied to the definition and use of extensions. Though any implementer can define an extension, there is a set of requirements that SHALL be met as part of the definition of the extension. Applications processing a resource are required to check for modifier extensions.", - ) - location_field: Reference = Field( - default=None, - alias="location", - description="The location where the encounter takes place.", - ) - status_field: codeModel = Field( - default=None, - alias="status", - description="The status of the participants' presence at the specified location during the period specified. If the participant is no longer at the location, then the period will have an end date/time.", - ) - form_field: CodeableConcept = Field( - default=None, - alias="form", - description="This will be used to specify the required levels (bed/ward/room/etc.) desired to be recorded to simplify either messaging or query.", - ) - period_field: Period = Field( - default=None, - alias="period", - description="Time period during which the patient was present at the location.", - ) - - -class Encounter(BaseModel): - resourceType: Literal["Encounter"] = "Encounter" - id_field: idModel = Field( - default=None, - alias="id", - description="The logical id of the resource, as used in the URL for the resource. Once assigned, this value never changes.", - ) - meta_field: Meta = Field( - default=None, - alias="meta", - description="The metadata about the resource. This is content that is maintained by the infrastructure. Changes to the content might not always be associated with version changes to the resource.", - ) - implicitRules_field: uriModel = Field( - default=None, - alias="implicitRules", - description="A reference to a set of rules that were followed when the resource was constructed, and which must be understood when processing the content. Often, this is a reference to an implementation guide that defines the special rules along with other profiles etc.", - ) - language_field: codeModel = Field( - default=None, - alias="language", - description="The base language in which the resource is written.", - ) - text_field: Narrative = Field( - default=None, - alias="text", - description="A human-readable narrative that contains a summary of the resource and can be used to represent the content of the resource to a human. The narrative need not encode all the structured data, but is required to contain sufficient detail to make it clinically safe for a human to just read the narrative. Resource definitions may define what content should be represented in the narrative to ensure clinical safety.", - ) - # contained_field: List[ResourceListModel] = Field(default=None, alias="contained", description="These resources do not have an independent existence apart from the resource that contains them - they cannot be identified independently, nor can they have their own independent transaction scope. This is allowed to be a Parameters resource if and only if it is referenced by a resource that provides context/meaning.") - extension_field: List[Extension] = Field( - default=None, - alias="extension", - description="May be used to represent additional information that is not part of the basic definition of the resource. To make the use of extensions safe and managable, there is a strict set of governance applied to the definition and use of extensions. Though any implementer can define an extension, there is a set of requirements that SHALL be met as part of the definition of the extension.", - ) - modifierExtension_field: List[Extension] = Field( - default=None, - alias="modifierExtension", - description="May be used to represent additional information that is not part of the basic definition of the resource and that modifies the understanding of the element that contains it and/or the understanding of the containing element's descendants. Usually modifier elements provide negation or qualification. To make the use of extensions safe and managable, there is a strict set of governance applied to the definition and use of extensions. Though any implementer is allowed to define an extension, there is a set of requirements that SHALL be met as part of the definition of the extension. Applications processing a resource are required to check for modifier extensions.", - ) - identifier_field: List[Identifier] = Field( - default=None, - alias="identifier", - description="Identifier(s) by which this encounter is known.", - ) - status_field: codeModel = Field( - default=None, - alias="status", - description="The current state of the encounter (not the state of the patient within the encounter - that is subjectState).", - ) - class_field: List[CodeableConcept] = Field( - default=None, - alias="class", - description="Concepts representing classification of patient encounter such as ambulatory (outpatient), inpatient, emergency, home health or others due to local variations.", - ) - priority_field: CodeableConcept = Field( - default=None, - alias="priority", - description="Indicates the urgency of the encounter.", - ) - type_field: List[CodeableConcept] = Field( - default=None, - alias="type", - description="Specific type of encounter (e.g. e-mail consultation, surgical day-care, skilled nursing, rehabilitation).", - ) - serviceType_field: List[CodeableReference] = Field( - default=None, - alias="serviceType", - description="Broad categorization of the service that is to be provided (e.g. cardiology).", - ) - subject_field: Reference = Field( - default=None, - alias="subject", - description="The patient or group related to this encounter. In some use-cases the patient MAY not be present, such as a case meeting about a patient between several practitioners or a careteam.", - ) - subjectStatus_field: CodeableConcept = Field( - default=None, - alias="subjectStatus", - description="The subjectStatus value can be used to track the patient's status within the encounter. It details whether the patient has arrived or departed, has been triaged or is currently in a waiting status.", - ) - episodeOfCare_field: List[Reference] = Field( - default=None, - alias="episodeOfCare", - description="Where a specific encounter should be classified as a part of a specific episode(s) of care this field should be used. This association can facilitate grouping of related encounters together for a specific purpose, such as government reporting, issue tracking, association via a common problem. The association is recorded on the encounter as these are typically created after the episode of care and grouped on entry rather than editing the episode of care to append another encounter to it (the episode of care could span years).", - ) - basedOn_field: List[Reference] = Field( - default=None, - alias="basedOn", - description="The request this encounter satisfies (e.g. incoming referral or procedure request).", - ) - careTeam_field: List[Reference] = Field( - default=None, - alias="careTeam", - description="The group(s) of individuals, organizations that are allocated to participate in this encounter. The participants backbone will record the actuals of when these individuals participated during the encounter.", - ) - partOf_field: Reference = Field( - default=None, - alias="partOf", - description="Another Encounter of which this encounter is a part of (administratively or in time).", - ) - serviceProvider_field: Reference = Field( - default=None, - alias="serviceProvider", - description="The organization that is primarily responsible for this Encounter's services. This MAY be the same as the organization on the Patient record, however it could be different, such as if the actor performing the services was from an external organization (which may be billed seperately) for an external consultation. Refer to the colonoscopy example on the Encounter examples tab.", - ) - participant_field: List[EncounterParticipant] = Field( - default=None, - alias="participant", - description="The list of people responsible for providing the service.", - ) - appointment_field: List[Reference] = Field( - default=None, - alias="appointment", - description="The appointment that scheduled this encounter.", - ) - # virtualService_field: List[VirtualServiceDetailModel] = Field(default=None, alias="virtualService", description="Connection details of a virtual service (e.g. conference call).") - actualPeriod_field: Period = Field( - default=None, - alias="actualPeriod", - description="The actual start and end time of the encounter.", - ) - plannedStartDate_field: dateTimeModel = Field( - default=None, - alias="plannedStartDate", - description="The planned start date/time (or admission date) of the encounter.", - ) - plannedEndDate_field: dateTimeModel = Field( - default=None, - alias="plannedEndDate", - description="The planned end date/time (or discharge date) of the encounter.", - ) - # length_field: DurationModel = Field(default=None, alias="length", description="Actual quantity of time the encounter lasted. This excludes the time during leaves of absence.") - reason_field: List[EncounterReason] = Field( - default=None, - alias="reason", - description="The list of medical reasons that are expected to be addressed during the episode of care.", - ) - diagnosis_field: List[EncounterDiagnosis] = Field( - default=None, - alias="diagnosis", - description="The list of diagnosis relevant to this encounter.", - ) - account_field: List[Reference] = Field( - default=None, - alias="account", - description="The set of accounts that may be used for billing for this Encounter.", - ) - dietPreference_field: List[CodeableConcept] = Field( - default=None, - alias="dietPreference", - description="Diet preferences reported by the patient.", - ) - specialArrangement_field: List[CodeableConcept] = Field( - default=None, - alias="specialArrangement", - description="Any special requests that have been made for this encounter, such as the provision of specific equipment or other things.", - ) - specialCourtesy_field: List[CodeableConcept] = Field( - default=None, - alias="specialCourtesy", - description="Special courtesies that may be provided to the patient during the encounter (VIP, board member, professional courtesy).", - ) - admission_field: EncounterAdmission = Field( - default=None, - alias="admission", - description="Details about the stay during which a healthcare service is provided.", - ) - location_field: List[EncounterLocation] = Field( - default=None, - alias="location", - description="List of locations where the patient has been during this encounter.", - ) diff --git a/healthchain/fhir_resources/generalpurpose.py b/healthchain/fhir_resources/generalpurpose.py deleted file mode 100644 index 54f5c21e..00000000 --- a/healthchain/fhir_resources/generalpurpose.py +++ /dev/null @@ -1,651 +0,0 @@ -from __future__ import annotations - -from typing import List -from pydantic import BaseModel, Field - -from healthchain.fhir_resources.primitives import ( - stringModel, - uriModel, - dateTimeModel, - codeModel, - booleanModel, - markdownModel, - decimalModel, - comparatorModel, - positiveIntModel, - canonicalModel, - unsignedIntModel, - idModel, - instantModel, - timeModel, - integer64Model, - urlModel, -) - - -class Extension(BaseModel): - id_field: stringModel = Field( - default=None, - alias="id", - description="Unique id for the element within a resource (for internal references). This may be any string value that does not contain spaces.", - ) - extension_field: List[Extension] = Field( - default=None, - alias="extension", - description="May be used to represent additional information that is not part of the basic definition of the element. To make the use of extensions safe and managable, there is a strict set of governance applied to the definition and use of extensions. Though any implementer can define an extension, there is a set of requirements that SHALL be met as part of the definition of the extension.", - ) - url_field: uriModel = Field( - default=None, - alias="url", - description="Source of the definition for the extension code - a logical name or a URL.", - ) - - -class Period(BaseModel): - id_field: stringModel = Field( - default=None, - alias="id", - description="Unique id for the element within a resource (for internal references). This may be any string value that does not contain spaces.", - ) - extension_field: List[Extension] = Field( - default=None, - alias="extension", - description="May be used to represent additional information that is not part of the basic definition of the element. To make the use of extensions safe and managable, there is a strict set of governance applied to the definition and use of extensions. Though any implementer can define an extension, there is a set of requirements that SHALL be met as part of the definition of the extension.", - ) - start_field: dateTimeModel = Field( - default=None, - alias="start", - description="The start of the period. The boundary is inclusive.", - ) - end_field: dateTimeModel = Field( - default=None, - alias="end", - description="The end of the period. If the end of the period is missing, it means no end was known or planned at the time the instance was created. The start may be in the past, and the end date in the future, which means that period is expected/planned to end at that time.", - ) - - -class Identifier(BaseModel): - id_field: stringModel = Field( - default=None, - alias="id", - description="Unique id for the element within a resource (for internal references). This may be any string value that does not contain spaces.", - ) - extension_field: List[Extension] = Field( - default=None, - alias="extension", - description="May be used to represent additional information that is not part of the basic definition of the element. To make the use of extensions safe and managable, there is a strict set of governance applied to the definition and use of extensions. Though any implementer can define an extension, there is a set of requirements that SHALL be met as part of the definition of the extension.", - ) - # Identifier_use_field: useModel = Field(..., alias="use", description="The purpose of this identifier.") - type_field: CodeableConcept = Field( - default=None, - alias="type", - description="A coded type for the identifier that can be used to determine which identifier to use for a specific purpose.", - ) - system_field: uriModel = Field( - default=None, - alias="system", - description="Establishes the namespace for the value - that is, an absolute URL that describes a set values that are unique.", - ) - value_field: stringModel = Field( - default=None, - alias="value", - description="The portion of the identifier typically relevant to the user and which is unique within the context of the system.", - ) - period_field: Period = Field( - default=None, - alias="period", - description="Time period during which identifier is/was valid for use.", - ) - assigner_field: Reference = Field( - default=None, - alias="assigner", - description="Organization that issued/manages the identifier.", - ) - - -class Coding(BaseModel): - id_field: stringModel = Field( - default=None, - alias="id", - description="Unique id for the element within a resource (for internal references). This may be any string value that does not contain spaces.", - ) - extension_field: List[Extension] = Field( - default=None, - alias="extension", - description="May be used to represent additional information that is not part of the basic definition of the element. To make the use of extensions safe and managable, there is a strict set of governance applied to the definition and use of extensions. Though any implementer can define an extension, there is a set of requirements that SHALL be met as part of the definition of the extension.", - ) - system_field: uriModel = Field( - default=None, - alias="system", - description="The identification of the code system that defines the meaning of the symbol in the code.", - ) - version_field: stringModel = Field( - default=None, - alias="version", - description="The version of the code system which was used when choosing this code. Note that a well-maintained code system does not need the version reported, because the meaning of codes is consistent across versions. However this cannot consistently be assured, and when the meaning is not guaranteed to be consistent, the version SHOULD be exchanged.", - ) - code_field: codeModel = Field( - default=None, - alias="code", - description="A symbol in syntax defined by the system. The symbol may be a predefined code or an expression in a syntax defined by the coding system (e.g. post-coordination).", - ) - display_field: stringModel = Field( - default=None, - alias="display", - description="A representation of the meaning of the code in the system, following the rules of the system.", - ) - userSelected_field: booleanModel = Field( - default=None, - alias="userSelected", - description="Indicates that this coding was chosen by a user directly - e.g. off a pick list of available items (codes or displays).", - ) - - -class CodeableConcept(BaseModel): - id_field: stringModel = Field( - default=None, - alias="id", - description="Unique id for the element within a resource (for internal references). This may be any string value that does not contain spaces.", - ) - extension_field: List[Extension] = Field( - default=None, - alias="extension", - description="May be used to represent additional information that is not part of the basic definition of the element. To make the use of extensions safe and managable, there is a strict set of governance applied to the definition and use of extensions. Though any implementer can define an extension, there is a set of requirements that SHALL be met as part of the definition of the extension.", - ) - coding_field: List[Coding] = Field( - default=None, - alias="coding", - description="A reference to a code defined by a terminology system.", - ) - text_field: stringModel = Field( - default=None, - alias="text", - description="A human language representation of the concept as seen/selected/uttered by the user who entered the data and/or which represents the intended meaning of the user.", - ) - - -class Reference(BaseModel): - id_field: stringModel = Field( - default=None, - alias="id", - description="Unique id for the element within a resource (for internal references). This may be any string value that does not contain spaces.", - ) - extension_field: List[Extension] = Field( - default=None, - alias="extension", - description="May be used to represent additional information that is not part of the basic definition of the element. To make the use of extensions safe and managable, there is a strict set of governance applied to the definition and use of extensions. Though any implementer can define an extension, there is a set of requirements that SHALL be met as part of the definition of the extension.", - ) - reference_field: stringModel = Field( - default=None, - alias="reference", - description="A reference to a location at which the other resource is found. The reference may be a relative reference, in which case it is relative to the service base URL, or an absolute URL that resolves to the location where the resource is found. The reference may be version specific or not. If the reference is not to a FHIR RESTful server, then it should be assumed to be version specific. Internal fragment references (start with '#') refer to contained resources.", - ) - type_field: uriModel = Field( - default=None, - alias="type", - description="The expected type of the target of the reference. If both Reference.type and Reference.reference are populated and Reference.reference is a FHIR URL, both SHALL be consistent.", - ) - identifier_field: Identifier = Field( - default=None, - alias="identifier", - description="An identifier for the target resource. This is used when there is no way to reference the other resource directly, either because the entity it represents is not available through a FHIR server, or because there is no way for the author of the resource to convert a known identifier to an actual location. There is no requirement that a Reference.identifier point to something that is actually exposed as a FHIR instance, but it SHALL point to a business concept that would be expected to be exposed as a FHIR instance, and that instance would need to be of a FHIR resource type allowed by the reference.", - ) - display_field: stringModel = Field( - default=None, - alias="display", - description="Plain text narrative that identifies the resource in addition to the resource reference.", - ) - - -class CodeableReference(BaseModel): - id_field: stringModel = Field( - default=None, - alias="id", - description="Unique id for the element within a resource (for internal references). This may be any string value that does not contain spaces.", - ) - extension_field: List[Extension] = Field( - default=None, - alias="extension", - description="May be used to represent additional information that is not part of the basic definition of the element. To make the use of extensions safe and managable, there is a strict set of governance applied to the definition and use of extensions. Though any implementer can define an extension, there is a set of requirements that SHALL be met as part of the definition of the extension.", - ) - concept_field: CodeableConcept = Field( - default=None, - alias="concept", - description="A reference to a concept - e.g. the information is identified by its general class to the degree of precision found in the terminology.", - ) - reference_field: Reference = Field( - default=None, - alias="reference", - description="A reference to a resource the provides exact details about the information being referenced.", - ) - - -class Narrative(BaseModel): - id_field: stringModel = Field( - default=None, - alias="id", - description="Unique id for the element within a resource (for internal references). This may be any string value that does not contain spaces.", - ) - extension_field: List[Extension] = Field( - default=None, - alias="extension", - description="May be used to represent additional information that is not part of the basic definition of the element. To make the use of extensions safe and managable, there is a strict set of governance applied to the definition and use of extensions. Though any implementer can define an extension, there is a set of requirements that SHALL be met as part of the definition of the extension.", - ) - # Narrative_status_field: statusModel = Field(..., alias="status", description="The status of the narrative - whether it's entirely generated (from just the defined data or the extensions too), or whether a human authored it and it may contain additional data.") - div_field: stringModel = Field( - default=None, - alias="div", - description="The actual narrative content, a stripped down version of XHTML.", - ) - - -class Age(BaseModel): - id_field: stringModel = Field( - default=None, - alias="id", - description="Unique id for the element within a resource (for internal references). This may be any string value that does not contain spaces.", - ) - extension_field: List[Extension] = Field( - default=None, - alias="extension", - description="May be used to represent additional information that is not part of the basic definition of the element. To make the use of extensions safe and managable, there is a strict set of governance applied to the definition and use of extensions. Though any implementer can define an extension, there is a set of requirements that SHALL be met as part of the definition of the extension.", - ) - value_field: decimalModel = Field( - default=None, - alias="value", - description="The value of the measured amount. The value includes an implicit precision in the presentation of the value.", - ) - age_comparator_field: comparatorModel = Field( - ..., - alias="comparator", - description="How the value should be understood and represented - whether the actual value is greater or less than the stated value due to measurement issues; e.g. if the comparator is < , then the real value is < stated value.", - ) - unit_field: stringModel = Field( - default=None, alias="unit", description="A human-readable form of the unit." - ) - system_field: uriModel = Field( - default=None, - alias="system", - description="The identification of the system that provides the coded form of the unit.", - ) - code_field: codeModel = Field( - default=None, - alias="code", - description="A computer processable form of the unit in some unit representation system.", - ) - - -class Quantity(BaseModel): - id_field: stringModel = Field( - default=None, - alias="id", - description="Unique id for the element within a resource (for internal references). This may be any string value that does not contain spaces.", - ) - extension_field: List[Extension] = Field( - default=None, - alias="extension", - description="May be used to represent additional information that is not part of the basic definition of the element. To make the use of extensions safe and managable, there is a strict set of governance applied to the definition and use of extensions. Though any implementer can define an extension, there is a set of requirements that SHALL be met as part of the definition of the extension.", - ) - value_field: decimalModel = Field( - default=None, - alias="value", - description="The value of the measured amount. The value includes an implicit precision in the presentation of the value.", - ) - # Quantity_comparator_field: comparatorModel = Field(..., alias="comparator", description="How the value should be understood and represented - whether the actual value is greater or less than the stated value due to measurement issues; e.g. if the comparator is < , then the real value is < stated value.") - unit_field: stringModel = Field( - default=None, alias="unit", description="A human-readable form of the unit." - ) - system_field: uriModel = Field( - default=None, - alias="system", - description="The identification of the system that provides the coded form of the unit.", - ) - code_field: codeModel = Field( - default=None, - alias="code", - description="A computer processable form of the unit in some unit representation system.", - ) - - -class Range(BaseModel): - id_field: stringModel = Field( - default=None, - alias="id", - description="Unique id for the element within a resource (for internal references). This may be any string value that does not contain spaces.", - ) - extension_field: List[Extension] = Field( - default=None, - alias="extension", - description="May be used to represent additional information that is not part of the basic definition of the element. To make the use of extensions safe and managable, there is a strict set of governance applied to the definition and use of extensions. Though any implementer can define an extension, there is a set of requirements that SHALL be met as part of the definition of the extension.", - ) - low_field: Quantity = Field( - default=None, - alias="low", - description="The low limit. The boundary is inclusive.", - ) - high_field: Quantity = Field( - default=None, - alias="high", - description="The high limit. The boundary is inclusive.", - ) - - -class Ratio(BaseModel): - id_field: stringModel = Field( - default=None, - alias="id", - description="Unique id for the element within a resource (for internal references). This may be any string value that does not contain spaces.", - ) - extension_field: List[Extension] = Field( - default=None, - alias="extension", - description="May be used to represent additional information that is not part of the basic definition of the element. To make the use of extensions safe and managable, there is a strict set of governance applied to the definition and use of extensions. Though any implementer can define an extension, there is a set of requirements that SHALL be met as part of the definition of the extension.", - ) - numerator_field: Quantity = Field( - default=None, alias="numerator", description="The value of the numerator." - ) - denominator_field: Quantity = Field( - default=None, alias="denominator", description="The value of the denominator." - ) - - -class Timing(BaseModel): - id_field: stringModel = Field( - default=None, - alias="id", - description="Unique id for the element within a resource (for internal references). This may be any string value that does not contain spaces.", - ) - extension_field: List[Extension] = Field( - default=None, - alias="extension", - description="May be used to represent additional information that is not part of the basic definition of the element. To make the use of extensions safe and managable, there is a strict set of governance applied to the definition and use of extensions. Though any implementer can define an extension, there is a set of requirements that SHALL be met as part of the definition of the extension.", - ) - modifierExtension_field: List[Extension] = Field( - default=None, - alias="modifierExtension", - description="May be used to represent additional information that is not part of the basic definition of the element and that modifies the understanding of the element in which it is contained and/or the understanding of the containing element's descendants. Usually modifier elements provide negation or qualification. To make the use of extensions safe and managable, there is a strict set of governance applied to the definition and use of extensions. Though any implementer can define an extension, there is a set of requirements that SHALL be met as part of the definition of the extension. Applications processing a resource are required to check for modifier extensions.", - ) - event_field: List[dateTimeModel] = Field( - default=None, - alias="event", - description="Identifies specific times when the event occurs.", - ) - repeat_field: TimingRepeat = Field( - default=None, - alias="repeat", - description="A set of rules that describe when the event is scheduled.", - ) - code_field: CodeableConcept = Field( - default=None, - alias="code", - description="A code for the timing schedule (or just text in code.text). Some codes such as BID are ubiquitous, but many institutions define their own additional codes. If a code is provided, the code is understood to be a complete statement of whatever is specified in the structured timing data, and either the code or the data may be used to interpret the Timing, with the exception that .repeat.bounds still applies over the code (and is not contained in the code).", - ) - - -class TimingRepeat(BaseModel): - id_field: stringModel = Field( - default=None, - alias="id", - description="Unique id for the element within a resource (for internal references). This may be any string value that does not contain spaces.", - ) - extension_field: List[Extension] = Field( - default=None, - alias="extension", - description="May be used to represent additional information that is not part of the basic definition of the element. To make the use of extensions safe and managable, there is a strict set of governance applied to the definition and use of extensions. Though any implementer can define an extension, there is a set of requirements that SHALL be met as part of the definition of the extension.", - ) - modifierExtension_field: List[Extension] = Field( - default=None, - alias="modifierExtension", - description="May be used to represent additional information that is not part of the basic definition of the element and that modifies the understanding of the element in which it is contained and/or the understanding of the containing element's descendants. Usually modifier elements provide negation or qualification. To make the use of extensions safe and managable, there is a strict set of governance applied to the definition and use of extensions. Though any implementer can define an extension, there is a set of requirements that SHALL be met as part of the definition of the extension. Applications processing a resource are required to check for modifier extensions.", - ) - boundsDuration_field: Duration = Field( - default=None, - alias="boundsDuration", - description="Either a duration for the length of the timing schedule, a range of possible length, or outer bounds for start and/or end limits of the timing schedule.", - ) - boundsRange_field: Range = Field( - default=None, - alias="boundsRange", - description="Either a duration for the length of the timing schedule, a range of possible length, or outer bounds for start and/or end limits of the timing schedule.", - ) - boundsPeriod_field: Period = Field( - default=None, - alias="boundsPeriod", - description="Either a duration for the length of the timing schedule, a range of possible length, or outer bounds for start and/or end limits of the timing schedule.", - ) - count_field: positiveIntModel = Field( - default=None, - alias="count", - description="A total count of the desired number of repetitions across the duration of the entire timing specification. If countMax is present, this element indicates the lower bound of the allowed range of count values.", - ) - countMax_field: positiveIntModel = Field( - default=None, - alias="countMax", - description="If present, indicates that the count is a range - so to perform the action between [count] and [countMax] times.", - ) - duration_field: decimalModel = Field( - default=None, - alias="duration", - description="How long this thing happens for when it happens. If durationMax is present, this element indicates the lower bound of the allowed range of the duration.", - ) - durationMax_field: decimalModel = Field( - default=None, - alias="durationMax", - description="If present, indicates that the duration is a range - so to perform the action between [duration] and [durationMax] time length.", - ) - # Timing_Repeat_durationUnit_field: durationUnitModel = Field(..., alias="durationUnit", description="The units of time for the duration, in UCUM units") - frequency_field: positiveIntModel = Field( - default=None, - alias="frequency", - description="The number of times to repeat the action within the specified period. If frequencyMax is present, this element indicates the lower bound of the allowed range of the frequency.", - ) - frequencyMax_field: positiveIntModel = Field( - default=None, - alias="frequencyMax", - description="If present, indicates that the frequency is a range - so to repeat between [frequency] and [frequencyMax] times within the period or period range.", - ) - period_field: decimalModel = Field( - default=None, - alias="period", - description="Indicates the duration of time over which repetitions are to occur; e.g. to express 3 times per day, 3 would be the frequency and 1 day would be the period. If periodMax is present, this element indicates the lower bound of the allowed range of the period length.", - ) - periodMax_field: decimalModel = Field( - default=None, - alias="periodMax", - description="If present, indicates that the period is a range from [period] to [periodMax], allowing expressing concepts such as do this once every 3-5 days.", - ) - # Timing_Repeat_periodUnit_field: periodUnitModel = Field(..., alias="periodUnit", description="The units of time for the period in UCUM units") - dayOfWeek_field: List[codeModel] = Field( - default=None, - alias="dayOfWeek", - description="If one or more days of week is provided, then the action happens only on the specified day(s).", - ) - timeOfDay_field: List[timeModel] = Field( - default=None, - alias="timeOfDay", - description="Specified time of day for action to take place.", - ) - offset_field: unsignedIntModel = Field( - default=None, - alias="offset", - description="The number of minutes from the event. If the event code does not indicate whether the minutes is before or after the event, then the offset is assumed to be after the event.", - ) - - -class Meta(BaseModel): - id_field: stringModel = Field( - default=None, - alias="id", - description="Unique id for the element within a resource (for internal references). This may be any string value that does not contain spaces.", - ) - extension_field: List[Extension] = Field( - default=None, - alias="extension", - description="May be used to represent additional information that is not part of the basic definition of the element. To make the use of extensions safe and managable, there is a strict set of governance applied to the definition and use of extensions. Though any implementer can define an extension, there is a set of requirements that SHALL be met as part of the definition of the extension.", - ) - versionId_field: idModel = Field( - default=None, - alias="versionId", - description="The version specific identifier, as it appears in the version portion of the URL. This value changes when the resource is created, updated, or deleted.", - ) - lastUpdated_field: instantModel = Field( - default=None, - alias="lastUpdated", - description="When the resource last changed - e.g. when the version changed.", - ) - source_field: uriModel = Field( - default=None, - alias="source", - description="A uri that identifies the source system of the resource. This provides a minimal amount of [[[Provenance]]] information that can be used to track or differentiate the source of information in the resource. The source may identify another FHIR server, document, message, database, etc.", - ) - profile_field: List[canonicalModel] = Field( - default=None, - alias="profile", - description="A list of profiles (references to [[[StructureDefinition]]] resources) that this resource claims to conform to. The URL is a reference to [[[StructureDefinition.url]]].", - ) - security_field: List[Coding] = Field( - default=None, - alias="security", - description="Security labels applied to this resource. These tags connect specific resources to the overall security policy and infrastructure.", - ) - tag_field: List[Coding] = Field( - default=None, - alias="tag", - description="Tags applied to this resource. Tags are intended to be used to identify and relate resources to process and workflow, and applications are not required to consider the tags when interpreting the meaning of a resource.", - ) - - -class Duration(BaseModel): - id_field: stringModel = Field( - default=None, - alias="id", - description="Unique id for the element within a resource (for internal references). This may be any string value that does not contain spaces.", - ) - extension_field: List[Extension] = Field( - default=None, - alias="extension", - description="May be used to represent additional information that is not part of the basic definition of the element. To make the use of extensions safe and managable, there is a strict set of governance applied to the definition and use of extensions. Though any implementer can define an extension, there is a set of requirements that SHALL be met as part of the definition of the extension.", - ) - value_field: decimalModel = Field( - default=None, - alias="value", - description="The value of the measured amount. The value includes an implicit precision in the presentation of the value.", - ) - # Duration_comparator_field: comparatorModel = Field(..., alias="comparator", description="How the value should be understood and represented - whether the actual value is greater or less than the stated value due to measurement issues; e.g. if the comparator is < , then the real value is < stated value.") - unit_field: stringModel = Field( - default=None, alias="unit", description="A human-readable form of the unit." - ) - system_field: uriModel = Field( - default=None, - alias="system", - description="The identification of the system that provides the coded form of the unit.", - ) - code_field: codeModel = Field( - default=None, - alias="code", - description="A computer processable form of the unit in some unit representation system.", - ) - - -class Annotation(BaseModel): - id_field: stringModel = Field( - default=None, - alias="id", - description="Unique id for the element within a resource (for internal references). This may be any string value that does not contain spaces.", - ) - extension_field: List[Extension] = Field( - default=None, - alias="extension", - description="May be used to represent additional information that is not part of the basic definition of the element. To make the use of extensions safe and managable, there is a strict set of governance applied to the definition and use of extensions. Though any implementer can define an extension, there is a set of requirements that SHALL be met as part of the definition of the extension.", - ) - authorReference_field: Reference = Field( - default=None, - alias="authorReference", - description="The individual responsible for making the annotation.", - ) - time_field: dateTimeModel = Field( - default=None, - alias="time", - description="Indicates when this particular annotation was made.", - ) - text_field: markdownModel = Field( - default=None, - alias="text", - description="The text of the annotation in markdown format.", - ) - - -class Attachment(BaseModel): - id_field: stringModel = Field( - default=None, - alias="id", - description="Unique id for the element within a resource (for internal references). This may be any string value that does not contain spaces.", - ) - extension_field: List[Extension] = Field( - default=None, - alias="extension", - description="May be used to represent additional information that is not part of the basic definition of the element. To make the use of extensions safe and managable, there is a strict set of governance applied to the definition and use of extensions. Though any implementer can define an extension, there is a set of requirements that SHALL be met as part of the definition of the extension.", - ) - contentType_field: codeModel = Field( - default=None, - alias="contentType", - description="Identifies the type of the data in the attachment and allows a method to be chosen to interpret or render the data. Includes mime type parameters such as charset where appropriate.", - ) - language_field: codeModel = Field( - default=None, - alias="language", - description="The human language of the content. The value can be any valid value according to BCP 47.", - ) - data_field: stringModel = Field( - default=None, - alias="data", - description="The actual data of the attachment - a sequence of bytes, base64 encoded.", - ) - url_field: urlModel = Field( - default=None, - alias="url", - description="A location where the data can be accessed.", - ) - size_field: integer64Model = Field( - default=None, - alias="size", - description="The number of bytes of data that make up this attachment (before base64 encoding, if that is done).", - ) - hash_field: stringModel = Field( - default=None, - alias="hash", - description="The calculated hash of the data using SHA-1. Represented using base64.", - ) - title_field: stringModel = Field( - default=None, - alias="title", - description="A label or set of text to display in place of the data.", - ) - creation_field: dateTimeModel = Field( - default=None, - alias="creation", - description="The date that the attachment was first created.", - ) - height_field: positiveIntModel = Field( - default=None, - alias="height", - description="Height of the image in pixels (photo/video).", - ) - width_field: positiveIntModel = Field( - default=None, - alias="width", - description="Width of the image in pixels (photo/video).", - ) - frames_field: positiveIntModel = Field( - default=None, - alias="frames", - description="The number of frames in a photo. This is used with a multi-page fax, or an imaging acquisition context that takes multiple slices in a single image, or an animated gif. If there is more than one frame, this SHALL have a value in order to alert interface software that a multi-frame capable rendering widget is required.", - ) - duration_field: decimalModel = Field( - default=None, - alias="duration", - description="The duration of the recording in seconds - for audio and video.", - ) - pages_field: positiveIntModel = Field( - default=None, alias="pages", description="The number of pages when printed." - ) diff --git a/healthchain/fhir_resources/medicationadministration.py b/healthchain/fhir_resources/medicationadministration.py deleted file mode 100644 index 00b966e7..00000000 --- a/healthchain/fhir_resources/medicationadministration.py +++ /dev/null @@ -1,260 +0,0 @@ -from pydantic import BaseModel, Field -from typing import List, Literal - -from healthchain.fhir_resources.primitives import ( - stringModel, - idModel, - uriModel, - codeModel, - dateTimeModel, - booleanModel, -) -from healthchain.fhir_resources.generalpurpose import ( - Extension, - Identifier, - CodeableConcept, - Reference, - Period, - CodeableReference, - Narrative, - Quantity, - Timing, - Ratio, - Meta, - Annotation, -) - - -class MedicationAdministrationPerformer(BaseModel): - id_field: stringModel = Field( - default=None, - alias="id", - description="Unique id for the element within a resource (for internal references). This may be any string value that does not contain spaces.", - ) - extension_field: List[Extension] = Field( - default=None, - alias="extension", - description="May be used to represent additional information that is not part of the basic definition of the element. To make the use of extensions safe and managable, there is a strict set of governance applied to the definition and use of extensions. Though any implementer can define an extension, there is a set of requirements that SHALL be met as part of the definition of the extension.", - ) - modifierExtension_field: List[Extension] = Field( - default=None, - alias="modifierExtension", - description="May be used to represent additional information that is not part of the basic definition of the element and that modifies the understanding of the element in which it is contained and/or the understanding of the containing element's descendants. Usually modifier elements provide negation or qualification. To make the use of extensions safe and managable, there is a strict set of governance applied to the definition and use of extensions. Though any implementer can define an extension, there is a set of requirements that SHALL be met as part of the definition of the extension. Applications processing a resource are required to check for modifier extensions.", - ) - function_field: CodeableConcept = Field( - default=None, - alias="function", - description="Distinguishes the type of involvement of the performer in the medication administration.", - ) - actor_field: CodeableReference = Field( - default=None, - alias="actor", - description="Indicates who or what performed the medication administration.", - ) - - -class MedicationAdministrationDosage(BaseModel): - id_field: stringModel = Field( - default=None, - alias="id", - description="Unique id for the element within a resource (for internal references). This may be any string value that does not contain spaces.", - ) - extension_field: List[Extension] = Field( - default=None, - alias="extension", - description="May be used to represent additional information that is not part of the basic definition of the element. To make the use of extensions safe and managable, there is a strict set of governance applied to the definition and use of extensions. Though any implementer can define an extension, there is a set of requirements that SHALL be met as part of the definition of the extension.", - ) - modifierExtension_field: List[Extension] = Field( - default=None, - alias="modifierExtension", - description="May be used to represent additional information that is not part of the basic definition of the element and that modifies the understanding of the element in which it is contained and/or the understanding of the containing element's descendants. Usually modifier elements provide negation or qualification. To make the use of extensions safe and managable, there is a strict set of governance applied to the definition and use of extensions. Though any implementer can define an extension, there is a set of requirements that SHALL be met as part of the definition of the extension. Applications processing a resource are required to check for modifier extensions.", - ) - text_field: stringModel = Field( - default=None, - alias="text", - description="Free text dosage can be used for cases where the dosage administered is too complex to code. When coded dosage is present, the free text dosage may still be present for display to humans.", - ) - site_field: CodeableConcept = Field( - default=None, - alias="site", - description="A coded specification of the anatomic site where the medication first entered the body. For example, left arm.", - ) - route_field: CodeableConcept = Field( - default=None, - alias="route", - description="A code specifying the route or physiological path of administration of a therapeutic agent into or onto the patient. For example, topical, intravenous, etc.", - ) - method_field: CodeableConcept = Field( - default=None, - alias="method", - description="A coded value indicating the method by which the medication is intended to be or was introduced into or on the body. This attribute will most often NOT be populated. It is most commonly used for injections. For example, Slow Push, Deep IV.", - ) - dose_field: Quantity = Field( - default=None, - alias="dose", - description="The amount of the medication given at one administration event. Use this value when the administration is essentially an instantaneous event such as a swallowing a tablet or giving an injection.", - ) - rateRatio_field: Ratio = Field( - default=None, - alias="rateRatio", - description="Identifies the speed with which the medication was or will be introduced into the patient. Typically, the rate for an infusion e.g. 100 ml per 1 hour or 100 ml/hr. May also be expressed as a rate per unit of time, e.g. 500 ml per 2 hours. Other examples: 200 mcg/min or 200 mcg/1 minute; 1 liter/8 hours.", - ) - rateQuantity_field: Quantity = Field( - default=None, - alias="rateQuantity", - description="Identifies the speed with which the medication was or will be introduced into the patient. Typically, the rate for an infusion e.g. 100 ml per 1 hour or 100 ml/hr. May also be expressed as a rate per unit of time, e.g. 500 ml per 2 hours. Other examples: 200 mcg/min or 200 mcg/1 minute; 1 liter/8 hours.", - ) - - -class MedicationAdministration(BaseModel): - resourceType: Literal["MedicationAdministration"] = "MedicationAdministration" - id_field: idModel = Field( - default=None, - alias="id", - description="The logical id of the resource, as used in the URL for the resource. Once assigned, this value never changes.", - ) - meta_field: Meta = Field( - default=None, - alias="meta", - description="The metadata about the resource. This is content that is maintained by the infrastructure. Changes to the content might not always be associated with version changes to the resource.", - ) - implicitRules_field: uriModel = Field( - default=None, - alias="implicitRules", - description="A reference to a set of rules that were followed when the resource was constructed, and which must be understood when processing the content. Often, this is a reference to an implementation guide that defines the special rules along with other profiles etc.", - ) - language_field: codeModel = Field( - default=None, - alias="language", - description="The base language in which the resource is written.", - ) - text_field: Narrative = Field( - default=None, - alias="text", - description="A human-readable narrative that contains a summary of the resource and can be used to represent the content of the resource to a human. The narrative need not encode all the structured data, but is required to contain sufficient detail to make it clinically safe for a human to just read the narrative. Resource definitions may define what content should be represented in the narrative to ensure clinical safety.", - ) - # contained_field: List[ResourceListModel] = Field( - # default=None, - # alias="contained", - # description="These resources do not have an independent existence apart from the resource that contains them - they cannot be identified independently, nor can they have their own independent transaction scope. This is allowed to be a Parameters resource if and only if it is referenced by a resource that provides context/meaning.", - # ) - extension_field: List[Extension] = Field( - default=None, - alias="extension", - description="May be used to represent additional information that is not part of the basic definition of the resource. To make the use of extensions safe and managable, there is a strict set of governance applied to the definition and use of extensions. Though any implementer can define an extension, there is a set of requirements that SHALL be met as part of the definition of the extension.", - ) - modifierExtension_field: List[Extension] = Field( - default=None, - alias="modifierExtension", - description="May be used to represent additional information that is not part of the basic definition of the resource and that modifies the understanding of the element that contains it and/or the understanding of the containing element's descendants. Usually modifier elements provide negation or qualification. To make the use of extensions safe and managable, there is a strict set of governance applied to the definition and use of extensions. Though any implementer is allowed to define an extension, there is a set of requirements that SHALL be met as part of the definition of the extension. Applications processing a resource are required to check for modifier extensions.", - ) - identifier_field: List[Identifier] = Field( - default=None, - alias="identifier", - description="Identifiers associated with this Medication Administration that are defined by business processes and/or used to refer to it when a direct URL reference to the resource itself is not appropriate. They are business identifiers assigned to this resource by the performer or other systems and remain constant as the resource is updated and propagates from server to server.", - ) - basedOn_field: List[Reference] = Field( - default=None, - alias="basedOn", - description="A plan that is fulfilled in whole or in part by this MedicationAdministration.", - ) - partOf_field: List[Reference] = Field( - default=None, - alias="partOf", - description="A larger event of which this particular event is a component or step.", - ) - status_field: codeModel = Field( - default=None, - alias="status", - description="Will generally be set to show that the administration has been completed. For some long running administrations such as infusions, it is possible for an administration to be started but not completed or it may be paused while some other process is under way.", - ) - statusReason_field: List[CodeableConcept] = Field( - default=None, - alias="statusReason", - description="A code indicating why the administration was not performed.", - ) - category_field: List[CodeableConcept] = Field( - default=None, - alias="category", - description="The type of medication administration (for example, drug classification like ATC, where meds would be administered, legal category of the medication).", - ) - medication_field: CodeableReference = Field( - default=None, - alias="medication", - description="Identifies the medication that was administered. This is either a link to a resource representing the details of the medication or a simple attribute carrying a code that identifies the medication from a known list of medications.", - ) - subject_field: Reference = Field( - default=None, - alias="subject", - description="The person or animal or group receiving the medication.", - ) - encounter_field: Reference = Field( - default=None, - alias="encounter", - description="The visit, admission, or other contact between patient and health care provider during which the medication administration was performed.", - ) - supportingInformation_field: List[Reference] = Field( - default=None, - alias="supportingInformation", - description="Additional information (for example, patient height and weight) that supports the administration of the medication. This attribute can be used to provide documentation of specific characteristics of the patient present at the time of administration. For example, if the dose says give x if the heartrate exceeds y, then the heart rate can be included using this attribute.", - ) - occurencePeriod_field: Period = Field( - default=None, - alias="occurencePeriod", - description="A specific date/time or interval of time during which the administration took place (or did not take place). For many administrations, such as swallowing a tablet the use of dateTime is more appropriate.", - ) - occurenceTiming_field: Timing = Field( - default=None, - alias="occurenceTiming", - description="A specific date/time or interval of time during which the administration took place (or did not take place). For many administrations, such as swallowing a tablet the use of dateTime is more appropriate.", - ) - recorded_field: dateTimeModel = Field( - default=None, - alias="recorded", - description="The date the occurrence of the MedicationAdministration was first captured in the record - potentially significantly after the occurrence of the event.", - ) - isSubPotent_field: booleanModel = Field( - default=None, - alias="isSubPotent", - description="An indication that the full dose was not administered.", - ) - subPotentReason_field: List[CodeableConcept] = Field( - default=None, - alias="subPotentReason", - description="The reason or reasons why the full dose was not administered.", - ) - performer_field: List[MedicationAdministrationPerformer] = Field( - default=None, - alias="performer", - description="The performer of the medication treatment. For devices this is the device that performed the administration of the medication. An IV Pump would be an example of a device that is performing the administration. Both the IV Pump and the practitioner that set the rate or bolus on the pump can be listed as performers.", - ) - reason_field: List[CodeableReference] = Field( - default=None, - alias="reason", - description="A code, Condition or observation that supports why the medication was administered.", - ) - request_field: Reference = Field( - default=None, - alias="request", - description="The original request, instruction or authority to perform the administration.", - ) - device_field: List[CodeableReference] = Field( - default=None, - alias="device", - description="The device that is to be used for the administration of the medication (for example, PCA Pump).", - ) - note_field: List[Annotation] = Field( - default=None, - alias="note", - description="Extra information about the medication administration that is not conveyed by the other attributes.", - ) - dosage_field: MedicationAdministrationDosage = Field( - default=None, - alias="dosage", - description="Describes the medication dosage information details e.g. dose, rate, site, route, etc.", - ) - eventHistory_field: List[Reference] = Field( - default=None, - alias="eventHistory", - description="A summary of the events of interest that have occurred, such as when the administration was verified.", - ) diff --git a/healthchain/fhir_resources/medicationrequest.py b/healthchain/fhir_resources/medicationrequest.py deleted file mode 100644 index c2114e2f..00000000 --- a/healthchain/fhir_resources/medicationrequest.py +++ /dev/null @@ -1,353 +0,0 @@ -from pydantic import BaseModel, Field -from typing import List, Literal - -from healthchain.fhir_resources.primitives import ( - stringModel, - idModel, - uriModel, - codeModel, - dateTimeModel, - booleanModel, - integerModel, -) -from healthchain.fhir_resources.generalpurpose import ( - Extension, - Identifier, - CodeableConcept, - Reference, - Period, - CodeableReference, - Narrative, - Range, - Ratio, - Quantity, - Meta, -) - - -# TODO: Implement RatioModel and TimingModel -class DosageDoseAndRate(BaseModel): - id_field: stringModel = Field( - default=None, - alias="id", - description="Unique id for the element within a resource (for internal references). This may be any string value that does not contain spaces.", - ) - extension_field: List[Extension] = Field( - default=None, - alias="extension", - description="May be used to represent additional information that is not part of the basic definition of the element. To make the use of extensions safe and managable, there is a strict set of governance applied to the definition and use of extensions. Though any implementer can define an extension, there is a set of requirements that SHALL be met as part of the definition of the extension.", - ) - modifierExtension_field: List[Extension] = Field( - default=None, - alias="modifierExtension", - description="May be used to represent additional information that is not part of the basic definition of the element and that modifies the understanding of the element in which it is contained and/or the understanding of the containing element's descendants. Usually modifier elements provide negation or qualification. To make the use of extensions safe and managable, there is a strict set of governance applied to the definition and use of extensions. Though any implementer can define an extension, there is a set of requirements that SHALL be met as part of the definition of the extension. Applications processing a resource are required to check for modifier extensions.", - ) - type_field: CodeableConcept = Field( - default=None, - alias="type", - description="The kind of dose or rate specified, for example, ordered or calculated.", - ) - doseRange_field: Range = Field( - default=None, alias="doseRange", description="Amount of medication per dose." - ) - doseQuantity_field: Quantity = Field( - default=None, alias="doseQuantity", description="Amount of medication per dose." - ) - rateRatio_field: Ratio = Field( - default=None, - alias="rateRatio", - description="Amount of medication per unit of time.", - ) - rateRange_field: Range = Field( - default=None, - alias="rateRange", - description="Amount of medication per unit of time.", - ) - rateQuantity_field: Quantity = Field( - default=None, - alias="rateQuantity", - description="Amount of medication per unit of time.", - ) - - -class Dosage(BaseModel): - id_field: stringModel = Field( - default=None, - alias="id", - description="Unique id for the element within a resource (for internal references). This may be any string value that does not contain spaces.", - ) - extension_field: List[Extension] = Field( - default=None, - alias="extension", - description="May be used to represent additional information that is not part of the basic definition of the element. To make the use of extensions safe and managable, there is a strict set of governance applied to the definition and use of extensions. Though any implementer can define an extension, there is a set of requirements that SHALL be met as part of the definition of the extension.", - ) - modifierExtension_field: List[Extension] = Field( - default=None, - alias="modifierExtension", - description="May be used to represent additional information that is not part of the basic definition of the element and that modifies the understanding of the element in which it is contained and/or the understanding of the containing element's descendants. Usually modifier elements provide negation or qualification. To make the use of extensions safe and managable, there is a strict set of governance applied to the definition and use of extensions. Though any implementer can define an extension, there is a set of requirements that SHALL be met as part of the definition of the extension. Applications processing a resource are required to check for modifier extensions.", - ) - sequence_field: integerModel = Field( - default=None, - alias="sequence", - description="Indicates the order in which the dosage instructions should be applied or interpreted.", - ) - text_field: stringModel = Field( - default=None, - alias="text", - description="Free text dosage instructions e.g. SIG.", - ) - additionalInstruction_field: List[CodeableConcept] = Field( - default=None, - alias="additionalInstruction", - description="Supplemental instructions to the patient on how to take the medication (e.g. with meals ortake half to one hour before food) or warnings for the patient about the medication (e.g. may cause drowsiness or avoid exposure of skin to direct sunlight or sunlamps).", - ) - patientInstruction_field: stringModel = Field( - default=None, - alias="patientInstruction", - description="Instructions in terms that are understood by the patient or consumer.", - ) - # timing_field: TimingModel = Field( - # default=None, - # alias="timing", - # description="When medication should be administered.", - # ) - asNeeded_field: booleanModel = Field( - default=None, - alias="asNeeded", - description="Indicates whether the Medication is only taken when needed within a specific dosing schedule (Boolean option).", - ) - asNeededFor_field: List[CodeableConcept] = Field( - default=None, - alias="asNeededFor", - description="Indicates whether the Medication is only taken based on a precondition for taking the Medication (CodeableConcept).", - ) - site_field: CodeableConcept = Field( - default=None, alias="site", description="Body site to administer to." - ) - route_field: CodeableConcept = Field( - default=None, alias="route", description="How drug should enter body." - ) - method_field: CodeableConcept = Field( - default=None, - alias="method", - description="Technique for administering medication.", - ) - doseAndRate_field: List[DosageDoseAndRate] = Field( - default=None, - alias="doseAndRate", - description="Depending on the resource,this is the amount of medication administered, to be administered or typical amount to be administered.", - ) - maxDosePerPeriod_field: List[Ratio] = Field( - default=None, - alias="maxDosePerPeriod", - description="Upper limit on medication per unit of time.", - ) - maxDosePerAdministration_field: Quantity = Field( - default=None, - alias="maxDosePerAdministration", - description="Upper limit on medication per administration.", - ) - maxDosePerLifetime_field: Quantity = Field( - default=None, - alias="maxDosePerLifetime", - description="Upper limit on medication per lifetime of the patient.", - ) - - -class Medication(BaseModel): - code_field: CodeableConcept = Field( - default=None, alias="code", description="Identifies the item being prescribed." - ) - - -class MedicationRequest(BaseModel): - resourceType: Literal["MedicationRequest"] = "MedicationRequest" - id_field: idModel = Field( - default=None, - alias="id", - description="The logical id of the resource, as used in the URL for the resource. Once assigned, this value never changes.", - ) - meta_field: Meta = Field( - default=None, - alias="meta", - description="The metadata about the resource. This is content that is maintained by the infrastructure. Changes to the content might not always be associated with version changes to the resource.", - ) - implicitRules_field: uriModel = Field( - default=None, - alias="implicitRules", - description="A reference to a set of rules that were followed when the resource was constructed, and which must be understood when processing the content. Often, this is a reference to an implementation guide that defines the special rules along with other profiles etc.", - ) - language_field: codeModel = Field( - default=None, - alias="language", - description="The base language in which the resource is written.", - ) - text_field: Narrative = Field( - default=None, - alias="text", - description="A human-readable narrative that contains a summary of the resource and can be used to represent the content of the resource to a human. The narrative need not encode all the structured data, but is required to contain sufficient detail to make it clinically safe for a human to just read the narrative. Resource definitions may define what content should be represented in the narrative to ensure clinical safety.", - ) - contained_field: List[Medication] = Field( - default=None, - alias="contained", - description="These resources do not have an independent existence apart from the resource that contains them - they cannot be identified independently, nor can they have their own independent transaction scope. This is allowed to be a Parameters resource if and only if it is referenced by a resource that provides context/meaning.", - ) - extension_field: List[Extension] = Field( - default=None, - alias="extension", - description="May be used to represent additional information that is not part of the basic definition of the resource. To make the use of extensions safe and managable, there is a strict set of governance applied to the definition and use of extensions. Though any implementer can define an extension, there is a set of requirements that SHALL be met as part of the definition of the extension.", - ) - modifierExtension_field: List[Extension] = Field( - default=None, - alias="modifierExtension", - description="May be used to represent additional information that is not part of the basic definition of the resource and that modifies the understanding of the element that contains it and/or the understanding of the containing element's descendants. Usually modifier elements provide negation or qualification. To make the use of extensions safe and managable, there is a strict set of governance applied to the definition and use of extensions. Though any implementer is allowed to define an extension, there is a set of requirements that SHALL be met as part of the definition of the extension. Applications processing a resource are required to check for modifier extensions.", - ) - identifier_field: List[Identifier] = Field( - default=None, - alias="identifier", - description="Identifiers associated with this medication request that are defined by business processes and/or used to refer to it when a direct URL reference to the resource itself is not appropriate. They are business identifiers assigned to this resource by the performer or other systems and remain constant as the resource is updated and propagates from server to server.", - ) - basedOn_field: List[Reference] = Field( - default=None, - alias="basedOn", - description="A plan or request that is fulfilled in whole or in part by this medication request.", - ) - priorPrescription_field: Reference = Field( - default=None, - alias="priorPrescription", - description="Reference to an order/prescription that is being replaced by this MedicationRequest.", - ) - groupIdentifier_field: Identifier = Field( - default=None, - alias="groupIdentifier", - description="A shared identifier common to multiple independent Request instances that were activated/authorized more or less simultaneously by a single author. The presence of the same identifier on each request ties those requests together and may have business ramifications in terms of reporting of results, billing, etc. E.g. a requisition number shared by a set of lab tests ordered together, or a prescription number shared by all meds ordered at one time.", - ) - status_field: codeModel = Field( - default=None, - alias="status", - description="A code specifying the current state of the order. Generally, this will be active or completed state.", - ) - statusReason_field: CodeableConcept = Field( - default=None, - alias="statusReason", - description="Captures the reason for the current state of the MedicationRequest.", - ) - statusChanged_field: dateTimeModel = Field( - default=None, - alias="statusChanged", - description="The date (and perhaps time) when the status was changed.", - ) - intent_field: codeModel = Field( - default=None, - alias="intent", - description="Whether the request is a proposal, plan, or an original order.", - ) - category_field: List[CodeableConcept] = Field( - default=None, - alias="category", - description="An arbitrary categorization or grouping of the medication request. It could be used for indicating where meds are intended to be administered, eg. in an inpatient setting or in a patient's home, or a legal category of the medication.", - ) - priority_field: codeModel = Field( - default=None, - alias="priority", - description="Indicates how quickly the Medication Request should be addressed with respect to other requests.", - ) - doNotPerform_field: booleanModel = Field( - default=None, - alias="doNotPerform", - description="If true, indicates that the provider is asking for the patient to either stop taking or to not start taking the specified medication. For example, the patient is taking an existing medication and the provider is changing their medication. They want to create two seperate requests: one to stop using the current medication and another to start the new medication.", - ) - medication_field: CodeableReference = Field( - default=None, - alias="medication", - description="Identifies the medication being requested. This is a link to a resource that represents the medication which may be the details of the medication or simply an attribute carrying a code that identifies the medication from a known list of medications.", - ) - subject_field: Reference = Field( - default=None, - alias="subject", - description="The individual or group for whom the medication has been requested.", - ) - informationSource_field: List[Reference] = Field( - default=None, - alias="informationSource", - description="The person or organization who provided the information about this request, if the source is someone other than the requestor. This is often used when the MedicationRequest is reported by another person.", - ) - encounter_field: Reference = Field( - default=None, - alias="encounter", - description="The Encounter during which this [x] was created or to which the creation of this record is tightly associated.", - ) - supportingInformation_field: List[Reference] = Field( - default=None, - alias="supportingInformation", - description="Information to support fulfilling (i.e. dispensing or administering) of the medication, for example, patient height and weight, a MedicationStatement for the patient).", - ) - authoredOn_field: dateTimeModel = Field( - default=None, - alias="authoredOn", - description="The date (and perhaps time) when the prescription was initially written or authored on.", - ) - requester_field: Reference = Field( - default=None, - alias="requester", - description="The individual, organization, or device that initiated the request and has responsibility for its activation.", - ) - reported_field: booleanModel = Field( - default=None, - alias="reported", - description="Indicates if this record was captured as a secondary 'reported' record rather than as an original primary source-of-truth record. It may also indicate the source of the report.", - ) - performerType_field: CodeableConcept = Field( - default=None, - alias="performerType", - description="Indicates the type of performer of the administration of the medication.", - ) - performer_field: List[Reference] = Field( - default=None, - alias="performer", - description="The specified desired performer of the medication treatment (e.g. the performer of the medication administration). For devices, this is the device that is intended to perform the administration of the medication. An IV Pump would be an example of a device that is performing the administration. Both the IV Pump and the practitioner that set the rate or bolus on the pump can be listed as performers.", - ) - device_field: List[CodeableReference] = Field( - default=None, - alias="device", - description="The intended type of device that is to be used for the administration of the medication (for example, PCA Pump).", - ) - recorder_field: Reference = Field( - default=None, - alias="recorder", - description="The person who entered the order on behalf of another individual for example in the case of a verbal or a telephone order.", - ) - reason_field: List[CodeableReference] = Field( - default=None, - alias="reason", - description="The reason or the indication for ordering or not ordering the medication.", - ) - courseOfTherapyType_field: CodeableConcept = Field( - default=None, - alias="courseOfTherapyType", - description="The description of the overall pattern of the administration of the medication to the patient.", - ) - insurance_field: List[Reference] = Field( - default=None, - alias="insurance", - description="Insurance plans, coverage extensions, pre-authorizations and/or pre-determinations that may be required for delivering the requested service.", - ) - # note_field: List[AnnotationModel] = Field(default=None, alias="note", description="Extra information about the prescription that could not be conveyed by the other attributes.") - # renderedDosageInstruction_field: markdownModel = Field(default=None, alias="renderedDosageInstruction", description="The full representation of the dose of the medication included in all dosage instructions. To be used when multiple dosage instructions are included to represent complex dosing such as increasing or tapering doses.") - effectiveDosePeriod_field: Period = Field( - default=None, - alias="effectiveDosePeriod", - description="The period over which the medication is to be taken. Where there are multiple dosageInstruction lines (for example, tapering doses), this is the earliest date and the latest end date of the dosageInstructions.", - ) - dosageInstruction_field: List[Dosage] = Field( - default=None, - alias="dosageInstruction", - description="Specific instructions for how the medication is to be used by the patient.", - ) - # dispenseRequest_field: MedicationRequest_DispenseRequestModel = Field(default=None, alias="dispenseRequest", description="Indicates the specific details for the dispense or medication supply part of a medication request (also known as a Medication Prescription or Medication Order). Note that this information is not always sent with the order. There may be in some settings (e.g. hospitals) institutional or system support for completing the dispense details in the pharmacy department.") - # substitution_field: MedicationRequest_SubstitutionModel = Field(default=None, alias="substitution", description="Indicates whether or not substitution can or should be part of the dispense. In some cases, substitution must happen, in other cases substitution must not happen. This block explains the prescriber's intent. If nothing is specified substitution may be done.") - eventHistory_field: List[Reference] = Field( - default=None, - alias="eventHistory", - description="Links to Provenance records for past versions of this resource or fulfilling request or event resources that identify key state transitions or updates that are likely to be relevant to a user looking at the current version of the resource.", - ) diff --git a/healthchain/fhir_resources/patient.py b/healthchain/fhir_resources/patient.py deleted file mode 100644 index 5d5c73ca..00000000 --- a/healthchain/fhir_resources/patient.py +++ /dev/null @@ -1,378 +0,0 @@ -from pydantic import Field, BaseModel -from typing import List, Literal - -from healthchain.fhir_resources.primitives import ( - idModel, - uriModel, - codeModel, - booleanModel, - stringModel, - positiveIntModel, - dateModel, -) -from healthchain.fhir_resources.generalpurpose import ( - Identifier, - Reference, - Extension, - Period, - CodeableConcept, - Meta, - Narrative, -) - - -class Address(BaseModel): - id_field: stringModel = Field( - default=None, - alias="id", - description="Unique id for the element within a resource (for internal references). This may be any string value that does not contain spaces.", - ) - extension_field: List[Extension] = Field( - default=None, - alias="extension", - description="May be used to represent additional information that is not part of the basic definition of the element. To make the use of extensions safe and managable, there is a strict set of governance applied to the definition and use of extensions. Though any implementer can define an extension, there is a set of requirements that SHALL be met as part of the definition of the extension.", - ) - use_field: codeModel = Field( - default=None, alias="use", description="The purpose of this address." - ) - type_field: codeModel = Field( - default=None, - alias="type", - description="Distinguishes between physical addresses (those you can visit) and mailing addresses (e.g. PO Boxes and care-of addresses). Most addresses are both.", - ) - text_field: stringModel = Field( - default=None, - alias="text", - description="Specifies the entire address as it should be displayed e.g. on a postal label. This may be provided instead of or as well as the specific parts.", - ) - line_field: List[stringModel] = Field( - default=None, - alias="line", - description="This component contains the house number, apartment number, street name, street direction, P.O. Box number, delivery hints, and similar address information.", - ) - city_field: stringModel = Field( - default=None, - alias="city", - description="The name of the city, town, suburb, village or other community or delivery center.", - ) - district_field: stringModel = Field( - default=None, - alias="district", - description="The name of the administrative area (county).", - ) - state_field: stringModel = Field( - default=None, - alias="state", - description="Sub-unit of a country with limited sovereignty in a federally organized country. A code may be used if codes are in common use (e.g. US 2 letter state codes).", - ) - postalCode_field: stringModel = Field( - default=None, - alias="postalCode", - description="A postal code designating a region defined by the postal service.", - ) - country_field: stringModel = Field( - default=None, - alias="country", - description="Country - a nation as commonly understood or generally accepted.", - ) - period_field: Period = Field( - default=None, - alias="period", - description="Time period when address was/is in use.", - ) - - -class ContactPoint(BaseModel): - id_field: stringModel = Field( - default=None, - alias="id", - description="Unique id for the element within a resource (for internal references). This may be any string value that does not contain spaces.", - ) - extension_field: List[Extension] = Field( - default=None, - alias="extension", - description="May be used to represent additional information that is not part of the basic definition of the element. To make the use of extensions safe and managable, there is a strict set of governance applied to the definition and use of extensions. Though any implementer can define an extension, there is a set of requirements that SHALL be met as part of the definition of the extension.", - ) - system_field: codeModel = Field( - default=None, - alias="system", - description="Telecommunications form for contact point - what communications system is required to make use of the contact.", - ) - value_field: stringModel = Field( - default=None, - alias="value", - description="The actual contact point details, in a form that is meaningful to the designated communication system (i.e. phone number or email address).", - ) - use_field: codeModel = Field( - default=None, - alias="use", - description="Identifies the purpose for the contact point.", - ) - rank_field: positiveIntModel = Field( - default=None, - alias="rank", - description="Specifies a preferred order in which to use a set of contacts. ContactPoints with lower rank values are more preferred than those with higher rank values.", - ) - period_field: Period = Field( - default=None, - alias="period", - description="Time period when the contact point was/is in use.", - ) - - -class HumanName(BaseModel): - id_field: stringModel = Field( - default=None, - alias="id", - description="Unique id for the element within a resource (for internal references). This may be any string value that does not contain spaces.", - ) - extension_field: List[Extension] = Field( - default=None, - alias="extension", - description="May be used to represent additional information that is not part of the basic definition of the element. To make the use of extensions safe and managable, there is a strict set of governance applied to the definition and use of extensions. Though any implementer can define an extension, there is a set of requirements that SHALL be met as part of the definition of the extension.", - ) - use_field: codeModel = Field( - default=None, alias="use", description="Identifies the purpose for this name." - ) - text_field: stringModel = Field( - default=None, - alias="text", - description="Specifies the entire name as it should be displayed e.g. on an application UI. This may be provided instead of or as well as the specific parts.", - ) - family_field: stringModel = Field( - default=None, - alias="family", - description="The part of a name that links to the genealogy. In some cultures (e.g. Eritrea) the family name of a son is the first name of his father.", - ) - given_field: List[stringModel] = Field( - default=None, alias="given", description="Given name." - ) - prefix_field: List[stringModel] = Field( - default=None, - alias="prefix", - description="Part of the name that is acquired as a title due to academic, legal, employment or nobility status, etc. and that appears at the start of the name.", - ) - suffix_field: List[stringModel] = Field( - default=None, - alias="suffix", - description="Part of the name that is acquired as a title due to academic, legal, employment or nobility status, etc. and that appears at the end of the name.", - ) - period_field: Period = Field( - default=None, - alias="period", - description="Indicates the period of time when this name was valid for the named person.", - ) - - -class PatientLink(BaseModel): - id_field: stringModel = Field( - default=None, - alias="id", - description="Unique id for the element within a resource (for internal references). This may be any string value that does not contain spaces.", - ) - extension_field: List[Extension] = Field( - default=None, - alias="extension", - description="May be used to represent additional information that is not part of the basic definition of the element. To make the use of extensions safe and managable, there is a strict set of governance applied to the definition and use of extensions. Though any implementer can define an extension, there is a set of requirements that SHALL be met as part of the definition of the extension.", - ) - modifierExtension_field: List[Extension] = Field( - default=None, - alias="modifierExtension", - description="May be used to represent additional information that is not part of the basic definition of the element and that modifies the understanding of the element in which it is contained and/or the understanding of the containing element's descendants. Usually modifier elements provide negation or qualification. To make the use of extensions safe and managable, there is a strict set of governance applied to the definition and use of extensions. Though any implementer can define an extension, there is a set of requirements that SHALL be met as part of the definition of the extension. Applications processing a resource are required to check for modifier extensions.", - ) - other_field: Reference = Field( - default=None, - alias="other", - description="Link to a Patient or RelatedPerson resource that concerns the same actual individual.", - ) - type_field: codeModel = Field( - default=None, - alias="type", - description="The type of link between this patient resource and another patient resource.", - ) - - -class PatientContact(BaseModel): - id_field: stringModel = Field( - default=None, - alias="id", - description="Unique id for the element within a resource (for internal references). This may be any string value that does not contain spaces.", - ) - extension_field: List[Extension] = Field( - default=None, - alias="extension", - description="May be used to represent additional information that is not part of the basic definition of the element. To make the use of extensions safe and managable, there is a strict set of governance applied to the definition and use of extensions. Though any implementer can define an extension, there is a set of requirements that SHALL be met as part of the definition of the extension.", - ) - modifierExtension_field: List[Extension] = Field( - default=None, - alias="modifierExtension", - description="May be used to represent additional information that is not part of the basic definition of the element and that modifies the understanding of the element in which it is contained and/or the understanding of the containing element's descendants. Usually modifier elements provide negation or qualification. To make the use of extensions safe and managable, there is a strict set of governance applied to the definition and use of extensions. Though any implementer can define an extension, there is a set of requirements that SHALL be met as part of the definition of the extension. Applications processing a resource are required to check for modifier extensions.", - ) - relationship_field: List[CodeableConcept] = Field( - default=None, - alias="relationship", - description="The nature of the relationship between the patient and the contact person.", - ) - name_field: HumanName = Field( - default=None, - alias="name", - description="A name associated with the contact person.", - ) - telecom_field: List[ContactPoint] = Field( - default=None, - alias="telecom", - description="A contact detail for the person, e.g. a telephone number or an email address.", - ) - address_field: Address = Field( - default=None, alias="address", description="Address for the contact person." - ) - gender_field: codeModel = Field( - default=None, - alias="gender", - description="Administrative Gender - the gender that the contact person is considered to have for administration and record keeping purposes.", - ) - organization_field: Reference = Field( - default=None, - alias="organization", - description="Organization on behalf of which the contact is acting or for which the contact is working.", - ) - period_field: Period = Field( - default=None, - alias="period", - description="The period during which this contact person or organization is valid to be contacted relating to this patient.", - ) - - -class PatientCommunication(BaseModel): - id_field: stringModel = Field( - default=None, - alias="id", - description="Unique id for the element within a resource (for internal references). This may be any string value that does not contain spaces.", - ) - extension_field: List[Extension] = Field( - default=None, - alias="extension", - description="May be used to represent additional information that is not part of the basic definition of the element. To make the use of extensions safe and managable, there is a strict set of governance applied to the definition and use of extensions. Though any implementer can define an extension, there is a set of requirements that SHALL be met as part of the definition of the extension.", - ) - modifierExtension_field: List[Extension] = Field( - default=None, - alias="modifierExtension", - description="May be used to represent additional information that is not part of the basic definition of the element and that modifies the understanding of the element in which it is contained and/or the understanding of the containing element's descendants. Usually modifier elements provide negation or qualification. To make the use of extensions safe and managable, there is a strict set of governance applied to the definition and use of extensions. Though any implementer can define an extension, there is a set of requirements that SHALL be met as part of the definition of the extension. Applications processing a resource are required to check for modifier extensions.", - ) - language_field: CodeableConcept = Field( - default=None, - alias="language", - description="The ISO-639-1 alpha 2 code in lower case for the language, optionally followed by a hyphen and the ISO-3166-1 alpha 2 code for the region in upper case; e.g. en for English, or en-US for American English versus en-AU for Australian English.", - ) - preferred_field: booleanModel = Field( - default=None, - alias="preferred", - description="Indicates whether or not the patient prefers this language (over other languages he masters up a certain level).", - ) - - -class Patient(BaseModel): - resourceType: Literal["Patient"] = "Patient" - id_field: idModel = Field( - default=None, - alias="id", - description="The logical id of the resource, as used in the URL for the resource. Once assigned, this value never changes.", - ) - meta_field: Meta = Field( - default=None, - alias="meta", - description="The metadata about the resource. This is content that is maintained by the infrastructure. Changes to the content might not always be associated with version changes to the resource.", - ) - implicitRules_field: uriModel = Field( - default=None, - alias="implicitRules", - description="A reference to a set of rules that were followed when the resource was constructed, and which must be understood when processing the content. Often, this is a reference to an implementation guide that defines the special rules along with other profiles etc.", - ) - language_field: codeModel = Field( - default=None, - alias="language", - description="The base language in which the resource is written.", - ) - # NOTE: The text field has been switched to stringModel rather than NarrativeField for simplicity. - text_field: Narrative = Field( - default=None, - alias="text", - description="A human-readable narrative that contains a summary of the resource and can be used to represent the content of the resource to a human. The narrative need not encode all the structured data, but is required to contain sufficient detail to make it clinically safe for a human to just read the narrative. Resource definitions may define what content should be represented in the narrative to ensure clinical safety.", - ) - # contained_field: List[ResourceListModel] = Field(default=None, alias="contained", description="These resources do not have an independent existence apart from the resource that contains them - they cannot be identified independently, nor can they have their own independent transaction scope. This is allowed to be a Parameters resource if and only if it is referenced by a resource that provides context/meaning.") - extension_field: List[Extension] = Field( - default=None, - alias="extension", - description="May be used to represent additional information that is not part of the basic definition of the resource. To make the use of extensions safe and managable, there is a strict set of governance applied to the definition and use of extensions. Though any implementer can define an extension, there is a set of requirements that SHALL be met as part of the definition of the extension.", - ) - modifierExtension_field: List[Extension] = Field( - default=None, - alias="modifierExtension", - description="May be used to represent additional information that is not part of the basic definition of the resource and that modifies the understanding of the element that contains it and/or the understanding of the containing element's descendants. Usually modifier elements provide negation or qualification. To make the use of extensions safe and managable, there is a strict set of governance applied to the definition and use of extensions. Though any implementer is allowed to define an extension, there is a set of requirements that SHALL be met as part of the definition of the extension. Applications processing a resource are required to check for modifier extensions.", - ) - identifier_field: List[Identifier] = Field( - default=None, - alias="identifier", - description="An identifier for this patient.", - ) - active_field: booleanModel = Field( - default=None, - alias="active", - description="Whether this patient record is in active use. ", - ) - name_field: List[HumanName] = Field( - default=None, - alias="name", - description="A name associated with the individual.", - ) - telecom_field: List[ContactPoint] = Field( - default=None, - alias="telecom", - description="A contact detail (e.g. a telephone number or an email address) by which the individual may be contacted.", - ) - gender_field: codeModel = Field( - default=None, - alias="gender", - description="Administrative Gender - the gender that the patient is considered to have for administration and record keeping purposes.", - ) - birthDate_field: dateModel = Field( - default=None, - alias="birthDate", - description="The date of birth for the individual.", - ) - address_field: List[Address] = Field( - default=None, - alias="address", - description="An address for the individual.", - ) - maritalStatus_field: CodeableConcept = Field( - default=None, - alias="maritalStatus", - description="This field contains a patient's most recent marital (civil) status.", - ) - # photo_field: List[AttachmentModel] = Field(default=None, alias="photo", description="Image of the patient.") - contact_field: List[PatientContact] = Field( - default=None, - alias="contact", - description="A contact party (e.g. guardian, partner, friend) for the patient.", - ) - communication_field: List[PatientCommunication] = Field( - default=None, - alias="communication", - description="A language which may be used to communicate with the patient about his or her health.", - ) - generalPractitioner_field: List[Reference] = Field( - default=None, - alias="generalPractitioner", - description="Patient's nominated care provider.", - ) - managingOrganization_field: Reference = Field( - default=None, - alias="managingOrganization", - description="Organization that is the custodian of the patient record.", - ) - link_field: List[PatientLink] = Field( - default=None, - alias="link", - description="Link to a Patient or RelatedPerson resource that concerns the same actual individual.", - ) diff --git a/healthchain/fhir_resources/practitioner.py b/healthchain/fhir_resources/practitioner.py deleted file mode 100644 index ca7ced08..00000000 --- a/healthchain/fhir_resources/practitioner.py +++ /dev/null @@ -1,177 +0,0 @@ -from pydantic import Field, BaseModel -from typing import List, Literal - -from healthchain.fhir_resources.primitives import ( - idModel, - uriModel, - codeModel, - booleanModel, - stringModel, - dateModel, -) -from healthchain.fhir_resources.generalpurpose import ( - Identifier, - Reference, - Extension, - Period, - CodeableConcept, - Meta, - Narrative, -) -from healthchain.fhir_resources.patient import ( - HumanName, - ContactPoint, - Address, -) - - -class PractitionerQualification(BaseModel): - id_field: stringModel = Field( - default=None, - alias="id", - description="Unique id for the element within a resource (for internal references). This may be any string value that does not contain spaces.", - ) - extension_field: List[Extension] = Field( - default=None, - alias="extension", - description="May be used to represent additional information that is not part of the basic definition of the element. To make the use of extensions safe and managable, there is a strict set of governance applied to the definition and use of extensions. Though any implementer can define an extension, there is a set of requirements that SHALL be met as part of the definition of the extension.", - ) - modifierExtension_field: List[Extension] = Field( - default=None, - alias="modifierExtension", - description="May be used to represent additional information that is not part of the basic definition of the element and that modifies the understanding of the element in which it is contained and/or the understanding of the containing element's descendants. Usually modifier elements provide negation or qualification. To make the use of extensions safe and managable, there is a strict set of governance applied to the definition and use of extensions. Though any implementer can define an extension, there is a set of requirements that SHALL be met as part of the definition of the extension. Applications processing a resource are required to check for modifier extensions.", - ) - identifier_field: List[Identifier] = Field( - default=None, - alias="identifier", - description="An identifier that applies to this person's qualification.", - ) - code_field: CodeableConcept = Field( - default=None, - alias="code", - description="Coded representation of the qualification.", - ) - period_field: Period = Field( - default=None, - alias="period", - description="Period during which the qualification is valid.", - ) - issuer_field: Reference = Field( - default=None, - alias="issuer", - description="Organization that regulates and issues the qualification.", - ) - - -class PractitionerCommunication(BaseModel): - id_field: stringModel = Field( - default=None, - alias="id", - description="Unique id for the element within a resource (for internal references). This may be any string value that does not contain spaces.", - ) - extension_field: List[Extension] = Field( - default=None, - alias="extension", - description="May be used to represent additional information that is not part of the basic definition of the element. To make the use of extensions safe and managable, there is a strict set of governance applied to the definition and use of extensions. Though any implementer can define an extension, there is a set of requirements that SHALL be met as part of the definition of the extension.", - ) - modifierExtension_field: List[Extension] = Field( - default=None, - alias="modifierExtension", - description="May be used to represent additional information that is not part of the basic definition of the element and that modifies the understanding of the element in which it is contained and/or the understanding of the containing element's descendants. Usually modifier elements provide negation or qualification. To make the use of extensions safe and managable, there is a strict set of governance applied to the definition and use of extensions. Though any implementer can define an extension, there is a set of requirements that SHALL be met as part of the definition of the extension. Applications processing a resource are required to check for modifier extensions.", - ) - language_field: CodeableConcept = Field( - default=None, - alias="language", - description="The ISO-639-1 alpha 2 code in lower case for the language, optionally followed by a hyphen and the ISO-3166-1 alpha 2 code for the region in upper case; e.g. en for English, or en-US for American English versus en-AU for Australian English.", - ) - preferred_field: booleanModel = Field( - default=None, - alias="preferred", - description="Indicates whether or not the person prefers this language (over other languages he masters up a certain level).", - ) - - -class Practitioner(BaseModel): - resourceType: Literal["Practitioner"] = "Practitioner" - id_field: idModel = Field( - default=None, - alias="id", - description="The logical id of the resource, as used in the URL for the resource. Once assigned, this value never changes.", - ) - meta_field: Meta = Field( - default=None, - alias="meta", - description="The metadata about the resource. This is content that is maintained by the infrastructure. Changes to the content might not always be associated with version changes to the resource.", - ) - implicitRules_field: uriModel = Field( - default=None, - alias="implicitRules", - description="A reference to a set of rules that were followed when the resource was constructed, and which must be understood when processing the content. Often, this is a reference to an implementation guide that defines the special rules along with other profiles etc.", - ) - language_field: codeModel = Field( - default=None, - alias="language", - description="The base language in which the resource is written.", - ) - text_field: Narrative = Field( - default=None, - alias="text", - description="A human-readable narrative that contains a summary of the resource and can be used to represent the content of the resource to a human. The narrative need not encode all the structured data, but is required to contain sufficient detail to make it clinically safe for a human to just read the narrative. Resource definitions may define what content should be represented in the narrative to ensure clinical safety.", - ) - # contained_field: List[ResourceListModel] = Field(default=None, alias="contained", description="These resources do not have an independent existence apart from the resource that contains them - they cannot be identified independently, nor can they have their own independent transaction scope. This is allowed to be a Parameters resource if and only if it is referenced by a resource that provides context/meaning.") - extension_field: List[Extension] = Field( - default=None, - alias="extension", - description="May be used to represent additional information that is not part of the basic definition of the resource. To make the use of extensions safe and managable, there is a strict set of governance applied to the definition and use of extensions. Though any implementer can define an extension, there is a set of requirements that SHALL be met as part of the definition of the extension.", - ) - modifierExtension_field: List[Extension] = Field( - default=None, - alias="modifierExtension", - description="May be used to represent additional information that is not part of the basic definition of the resource and that modifies the understanding of the element that contains it and/or the understanding of the containing element's descendants. Usually modifier elements provide negation or qualification. To make the use of extensions safe and managable, there is a strict set of governance applied to the definition and use of extensions. Though any implementer is allowed to define an extension, there is a set of requirements that SHALL be met as part of the definition of the extension. Applications processing a resource are required to check for modifier extensions.", - ) - identifier_field: List[Identifier] = Field( - default=None, - alias="identifier", - description="An identifier that applies to this person in this role.", - ) - active_field: booleanModel = Field( - default=None, - alias="active", - description="Whether this practitioner's record is in active use.", - ) - name_field: List[HumanName] = Field( - default=None, - alias="name", - description="The name(s) associated with the practitioner.", - ) - telecom_field: List[ContactPoint] = Field( - default=None, - alias="telecom", - description="A contact detail for the practitioner, e.g. a telephone number or an email address.", - ) - gender_field: codeModel = Field( - default=None, - alias="gender", - description="Administrative Gender - the gender that the person is considered to have for administration and record keeping purposes.", - ) - birthDate_field: dateModel = Field( - default=None, - alias="birthDate", - description="The date of birth for the practitioner.", - ) - address_field: List[Address] = Field( - default=None, - alias="address", - description="Address(es) of the practitioner that are not role specific (typically home address). ", - ) - # photo_field: List[AttachmentModel] = Field(default=None, alias="photo", description="Image of the person.") - qualification_field: List[PractitionerQualification] = Field( - default=None, - alias="qualification", - description="The official qualifications, certifications, accreditations, training, licenses (and other types of educations/skills/capabilities) that authorize or otherwise pertain to the provision of care by the practitioner.", - ) - communication_field: List[PractitionerCommunication] = Field( - default=None, - alias="communication", - description="A language which may be used to communicate with the practitioner, often for correspondence/administrative purposes.", - ) diff --git a/healthchain/fhir_resources/primitives.py b/healthchain/fhir_resources/primitives.py deleted file mode 100644 index 22cacad5..00000000 --- a/healthchain/fhir_resources/primitives.py +++ /dev/null @@ -1,38 +0,0 @@ -from __future__ import annotations - -from pydantic import conint -from pydantic import constr - - -booleanModel = constr(pattern=r"^(true|false)$") -canonicalModel = constr(pattern=r"^\S*$") -codeModel = constr(pattern=r"^[^\s]+( [^\s]+)*$") -comparatorModel = constr(pattern="^(<|<=|>=|>)$") -dateModel = constr( - pattern=r"^([0-9]([0-9]([0-9][1-9]|[1-9]0)|[1-9]00)|[1-9]000)(-(0[1-9]|1[0-2])(-(0[1-9]|[1-2][0-9]|3[0-1]))?)?$" -) -dateTimeModel = constr( - pattern=r"^([0-9]([0-9]([0-9][1-9]|[1-9]0)|[1-9]00)|[1-9]000)(-(0[1-9]|1[0-2])(-(0[1-9]|[1-2][0-9]|3[0-1])(T([01][0-9]|2[0-3]):[0-5][0-9]:([0-5][0-9]|60)(\.[0-9]{1,9})?)?)?(Z|(\+|-)((0[0-9]|1[0-3]):[0-5][0-9]|14:00)?)?)?$" -) -decimalModel = constr( - pattern=r"^-?(0|[1-9][0-9]{0,17})(\.[0-9]{1,17})?([eE][+-]?[0-9]{1,9}})?$" -) -idModel = constr(pattern=r"^[A-Za-z0-9\-\.]{1,64}$") -instantModel = constr( - pattern=r"^([0-9]([0-9]([0-9][1-9]|[1-9]0)|[1-9]00)|[1-9]000)-(0[1-9]|1[0-2])-(0[1-9]|[1-2][0-9]|3[0-1])T([01][0-9]|2[0-3]):[0-5][0-9]:([0-5][0-9]|60)(\.[0-9]{1,9})?(Z|(\+|-)((0[0-9]|1[0-3]):[0-5][0-9]|14:00))$" -) -integerModel = constr(pattern=r"^[0]|[-+]?[1-9][0-9]*$") -integer64Model = constr(pattern=r"^[0]|[-+]?[1-9][0-9]*$") -markdownModel = constr(pattern=r"^^[\s\S]+$$") -oidModel = constr(pattern=r"^urn:oid:[0-2](\.(0|[1-9][0-9]*))+$") -positiveIntModel = conint(strict=True, gt=0) -stringModel = constr(pattern=r"^^[\s\S]+$$") -timeModel = constr( - pattern=r"^([01][0-9]|2[0-3]):[0-5][0-9]:([0-5][0-9]|60)(\.[0-9]{1,9})?$" -) -unsignedIntModel = constr(pattern=r"^[0]|([1-9][0-9]*)$") -uriModel = constr(pattern=r"^\S*$") -urlModel = constr(pattern=r"^\S*$") -uuidModel = constr( - pattern=r"^urn:uuid:[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$" -) diff --git a/healthchain/fhir_resources/procedure.py b/healthchain/fhir_resources/procedure.py deleted file mode 100644 index f05643e5..00000000 --- a/healthchain/fhir_resources/procedure.py +++ /dev/null @@ -1,289 +0,0 @@ -from pydantic import BaseModel, Field -from typing import List, Literal - -from healthchain.fhir_resources.primitives import ( - stringModel, - idModel, - uriModel, - codeModel, - dateTimeModel, - canonicalModel, -) -from healthchain.fhir_resources.generalpurpose import ( - Extension, - Identifier, - CodeableConcept, - Reference, - Period, - CodeableReference, - Narrative, - Age, - Range, - Meta, - Timing, -) - - -class ProcedurePerformer(BaseModel): - id_field: stringModel = Field( - default=None, - alias="id", - description="Unique id for the element within a resource (for internal references). This may be any string value that does not contain spaces.", - ) - extension_field: List[Extension] = Field( - default=None, - alias="extension", - description="May be used to represent additional information that is not part of the basic definition of the element. To make the use of extensions safe and managable, there is a strict set of governance applied to the definition and use of extensions. Though any implementer can define an extension, there is a set of requirements that SHALL be met as part of the definition of the extension.", - ) - modifierExtension_field: List[Extension] = Field( - default=None, - alias="modifierExtension", - description="May be used to represent additional information that is not part of the basic definition of the element and that modifies the understanding of the element in which it is contained and/or the understanding of the containing element's descendants. Usually modifier elements provide negation or qualification. To make the use of extensions safe and managable, there is a strict set of governance applied to the definition and use of extensions. Though any implementer can define an extension, there is a set of requirements that SHALL be met as part of the definition of the extension. Applications processing a resource are required to check for modifier extensions.", - ) - function_field: CodeableConcept = Field( - default=None, - alias="function", - description="Distinguishes the type of involvement of the performer in the procedure. For example, surgeon, anaesthetist, endoscopist.", - ) - actor_field: Reference = Field( - default=None, - alias="actor", - description="Indicates who or what performed the procedure.", - ) - onBehalfOf_field: Reference = Field( - default=None, - alias="onBehalfOf", - description="The Organization the Patient, RelatedPerson, Device, CareTeam, and HealthcareService was acting on behalf of.", - ) - period_field: Period = Field( - default=None, - alias="period", - description="Time period during which the performer performed the procedure.", - ) - - -class ProcedureFocalDevice(BaseModel): - id_field: stringModel = Field( - default=None, - alias="id", - description="Unique id for the element within a resource (for internal references). This may be any string value that does not contain spaces.", - ) - extension_field: List[Extension] = Field( - default=None, - alias="extension", - description="May be used to represent additional information that is not part of the basic definition of the element. To make the use of extensions safe and managable, there is a strict set of governance applied to the definition and use of extensions. Though any implementer can define an extension, there is a set of requirements that SHALL be met as part of the definition of the extension.", - ) - modifierExtension_field: List[Extension] = Field( - default=None, - alias="modifierExtension", - description="May be used to represent additional information that is not part of the basic definition of the element and that modifies the understanding of the element in which it is contained and/or the understanding of the containing element's descendants. Usually modifier elements provide negation or qualification. To make the use of extensions safe and managable, there is a strict set of governance applied to the definition and use of extensions. Though any implementer can define an extension, there is a set of requirements that SHALL be met as part of the definition of the extension. Applications processing a resource are required to check for modifier extensions.", - ) - action_field: CodeableConcept = Field( - default=None, - alias="action", - description="The kind of change that happened to the device during the procedure.", - ) - manipulated_field: Reference = Field( - default=None, - alias="manipulated", - description="The device that was manipulated (changed) during the procedure.", - ) - - -class Procedure(BaseModel): - resourceType: Literal["Procedure"] = "Procedure" - id_field: idModel = Field( - default=None, - alias="id", - description="The logical id of the resource, as used in the URL for the resource. Once assigned, this value never changes.", - ) - meta_field: Meta = Field( - default=None, - alias="meta", - description="The metadata about the resource. This is content that is maintained by the infrastructure. Changes to the content might not always be associated with version changes to the resource.", - ) - implicitRules_field: uriModel = Field( - default=None, - alias="implicitRules", - description="A reference to a set of rules that were followed when the resource was constructed, and which must be understood when processing the content. Often, this is a reference to an implementation guide that defines the special rules along with other profiles etc.", - ) - language_field: codeModel = Field( - default=None, - alias="language", - description="The base language in which the resource is written.", - ) - text_field: Narrative = Field( - default=None, - alias="text", - description="A human-readable narrative that contains a summary of the resource and can be used to represent the content of the resource to a human. The narrative need not encode all the structured data, but is required to contain sufficient detail to make it clinically safe for a human to just read the narrative. Resource definitions may define what content should be represented in the narrative to ensure clinical safety.", - ) - # contained_field: List[ResourceListModel] = Field( - # default=None, - # alias="contained", - # description="These resources do not have an independent existence apart from the resource that contains them - they cannot be identified independently, nor can they have their own independent transaction scope. This is allowed to be a Parameters resource if and only if it is referenced by a resource that provides context/meaning.", - # ) - extension_field: List[Extension] = Field( - default=None, - alias="extension", - description="May be used to represent additional information that is not part of the basic definition of the resource. To make the use of extensions safe and managable, there is a strict set of governance applied to the definition and use of extensions. Though any implementer can define an extension, there is a set of requirements that SHALL be met as part of the definition of the extension.", - ) - modifierExtension_field: List[Extension] = Field( - default=None, - alias="modifierExtension", - description="May be used to represent additional information that is not part of the basic definition of the resource and that modifies the understanding of the element that contains it and/or the understanding of the containing element's descendants. Usually modifier elements provide negation or qualification. To make the use of extensions safe and managable, there is a strict set of governance applied to the definition and use of extensions. Though any implementer is allowed to define an extension, there is a set of requirements that SHALL be met as part of the definition of the extension. Applications processing a resource are required to check for modifier extensions.", - ) - identifier_field: List[Identifier] = Field( - default=None, - alias="identifier", - description="Business identifiers assigned to this procedure by the performer or other systems which remain constant as the resource is updated and is propagated from server to server.", - ) - instantiatesCanonical_field: List[canonicalModel] = Field( - default=None, - alias="instantiatesCanonical", - description="The URL pointing to a FHIR-defined protocol, guideline, order set or other definition that is adhered to in whole or in part by this Procedure.", - ) - instantiatesUri_field: List[uriModel] = Field( - default=None, - alias="instantiatesUri", - description="The URL pointing to an externally maintained protocol, guideline, order set or other definition that is adhered to in whole or in part by this Procedure.", - ) - basedOn_field: List[Reference] = Field( - default=None, - alias="basedOn", - description="A reference to a resource that contains details of the request for this procedure.", - ) - partOf_field: List[Reference] = Field( - default=None, - alias="partOf", - description="A larger event of which this particular procedure is a component or step.", - ) - status_field: codeModel = Field( - default=None, - alias="status", - description="A code specifying the state of the procedure. Generally, this will be the in-progress or completed state.", - ) - statusReason_field: CodeableConcept = Field( - default=None, - alias="statusReason", - description="Captures the reason for the current state of the procedure.", - ) - category_field: List[CodeableConcept] = Field( - default=None, - alias="category", - description="A code that classifies the procedure for searching, sorting and display purposes (e.g. Surgical Procedure).", - ) - code_field: CodeableConcept = Field( - default=None, - alias="code", - description="The specific procedure that is performed. Use text if the exact nature of the procedure cannot be coded (e.g. Laparoscopic Appendectomy).", - ) - subject_field: Reference = Field( - default=None, - alias="subject", - description="On whom or on what the procedure was performed. This is usually an individual human, but can also be performed on animals, groups of humans or animals, organizations or practitioners (for licensing), locations or devices (for safety inspections or regulatory authorizations). If the actual focus of the procedure is different from the subject, the focus element specifies the actual focus of the procedure.", - ) - focus_field: Reference = Field( - default=None, - alias="focus", - description="Who is the target of the procedure when it is not the subject of record only. If focus is not present, then subject is the focus. If focus is present and the subject is one of the targets of the procedure, include subject as a focus as well. If focus is present and the subject is not included in focus, it implies that the procedure was only targeted on the focus. For example, when a caregiver is given education for a patient, the caregiver would be the focus and the procedure record is associated with the subject (e.g. patient). For example, use focus when recording the target of the education, training, or counseling is the parent or relative of a patient.", - ) - encounter_field: Reference = Field( - default=None, - alias="encounter", - description="The Encounter during which this Procedure was created or performed or to which the creation of this record is tightly associated.", - ) - occurrencePeriod_field: Period = Field( - default=None, - alias="occurrencePeriod", - description="Estimated or actual date, date-time, period, or age when the procedure did occur or is occurring. Allows a period to support complex procedures that span more than one date, and also allows for the length of the procedure to be captured.", - ) - occurrenceAge_field: Age = Field( - default=None, - alias="occurrenceAge", - description="Estimated or actual date, date-time, period, or age when the procedure did occur or is occurring. Allows a period to support complex procedures that span more than one date, and also allows for the length of the procedure to be captured.", - ) - occurrenceRange_field: Range = Field( - default=None, - alias="occurrenceRange", - description="Estimated or actual date, date-time, period, or age when the procedure did occur or is occurring. Allows a period to support complex procedures that span more than one date, and also allows for the length of the procedure to be captured.", - ) - occurrenceTiming_field: Timing = Field( - default=None, - alias="occurrenceTiming", - description="Estimated or actual date, date-time, period, or age when the procedure did occur or is occurring. Allows a period to support complex procedures that span more than one date, and also allows for the length of the procedure to be captured.", - ) - recorded_field: dateTimeModel = Field( - default=None, - alias="recorded", - description="The date the occurrence of the procedure was first captured in the record regardless of Procedure.status (potentially after the occurrence of the event).", - ) - recorder_field: Reference = Field( - default=None, - alias="recorder", - description="Individual who recorded the record and takes responsibility for its content.", - ) - reportedReference_field: Reference = Field( - default=None, - alias="reportedReference", - description="Indicates if this record was captured as a secondary 'reported' record rather than as an original primary source-of-truth record. It may also indicate the source of the report.", - ) - performer_field: List[ProcedurePerformer] = Field( - default=None, - alias="performer", - description="Indicates who or what performed the procedure and how they were involved.", - ) - location_field: Reference = Field( - default=None, - alias="location", - description="The location where the procedure actually happened. E.g. a newborn at home, a tracheostomy at a restaurant.", - ) - reason_field: List[CodeableReference] = Field( - default=None, - alias="reason", - description="The coded reason or reference why the procedure was performed. This may be a coded entity of some type, be present as text, or be a reference to one of several resources that justify the procedure.", - ) - bodySite_field: List[CodeableConcept] = Field( - default=None, - alias="bodySite", - description="Detailed and structured anatomical location information. Multiple locations are allowed - e.g. multiple punch biopsies of a lesion.", - ) - outcome_field: CodeableConcept = Field( - default=None, - alias="outcome", - description="The outcome of the procedure - did it resolve the reasons for the procedure being performed?", - ) - report_field: List[Reference] = Field( - default=None, - alias="report", - description="This could be a histology result, pathology report, surgical report, etc.", - ) - complication_field: List[CodeableReference] = Field( - default=None, - alias="complication", - description="Any complications that occurred during the procedure, or in the immediate post-performance period. These are generally tracked separately from the notes, which will typically describe the procedure itself rather than any 'post procedure' issues.", - ) - followUp_field: List[CodeableConcept] = Field( - default=None, - alias="followUp", - description="If the procedure required specific follow up - e.g. removal of sutures. The follow up may be represented as a simple note or could potentially be more complex, in which case the CarePlan resource can be used.", - ) - # note_field: List[AnnotationModel] = Field( - # default=None, - # alias="note", - # description="Any other notes and comments about the procedure.", - # ) - focalDevice_field: List[ProcedureFocalDevice] = Field( - default=None, - alias="focalDevice", - description="A device that is implanted, removed or otherwise manipulated (calibration, battery replacement, fitting a prosthesis, attaching a wound-vac, etc.) as a focal portion of the Procedure.", - ) - used_field: List[CodeableReference] = Field( - default=None, - alias="used", - description="Identifies medications, devices and any other substance used as part of the procedure.", - ) - supportingInfo_field: List[Reference] = Field( - default=None, - alias="supportingInfo", - description="Other resources from the patient record that may be relevant to the procedure. The information from these resources was either used to create the instance or is provided to help with its interpretation. This extension should not be used if more specific inline elements or extensions are available.", - ) diff --git a/healthchain/fhir_resources/resourceregistry.py b/healthchain/fhir_resources/resourceregistry.py deleted file mode 100644 index 87f70e38..00000000 --- a/healthchain/fhir_resources/resourceregistry.py +++ /dev/null @@ -1,167 +0,0 @@ -from enum import Enum - - -class ImplementedResourceRegistry(Enum): - Bundle: str = "Bundle" - Encounter: str = "Encounter" - MedicationRequest: str = "MedicationRequest" - NutritionOrder: str = "NutritionOrder" - Patient: str = "Patient" - Practitioner: str = "Practitioner" - Condition: str = "Condition" - Procedure: str = "Procedure" - DocumentReference: str = "DocumentReference" - - -class UnimplementedResourceRegistry(Enum): - Account: str = "Account" - ActivityDefinition: str = "ActivityDefinition" - ActorDefinition: str = "ActorDefinition" - AdministrableProductDefinition: str = "AdministrableProductDefinition" - AdverseEvent: str = "AdverseEvent" - AllergyIntolerance: str = "AllergyIntolerance" - Appointment: str = "Appointment" - AppointmentResponse: str = "AppointmentResponse" - ArtifactAssessment: str = "ArtifactAssessment" - AuditEvent: str = "AuditEvent" - Basic: str = "Basic" - Binary: str = "Binary" - BiologicallyDerivedProduct: str = "BiologicallyDerivedProduct" - BiologicallyDerivedProductDispense: str = "BiologicallyDerivedProductDispense" - BodyStructure: str = "BodyStructure" - CapabilityStatement: str = "CapabilityStatement" - CarePlan: str = "CarePlan" - CareTeam: str = "CareTeam" - ChargeItem: str = "ChargeItem" - ChargeItemDefinition: str = "ChargeItemDefinition" - Citation: str = "Citation" - Claim: str = "Claim" - ClaimResponse: str = "ClaimResponse" - ClinicalImpression: str = "ClinicalImpression" - ClinicalUseDefinition: str = "ClinicalUseDefinition" - CodeSystem: str = "CodeSystem" - Communication: str = "Communication" - CommunicationRequest: str = "CommunicationRequest" - CompartmentDefinition: str = "CompartmentDefinition" - Composition: str = "Composition" - ConceptMap: str = "ConceptMap" - ConditionDefinition: str = "ConditionDefinition" - Consent: str = "Consent" - Contract: str = "Contract" - Coverage: str = "Coverage" - CoverageEligibilityRequest: str = "CoverageEligibilityRequest" - CoverageEligibilityResponse: str = "CoverageEligibilityResponse" - DetectedIssue: str = "DetectedIssue" - Device: str = "Device" - DeviceAssociation: str = "DeviceAssociation" - DeviceDefinition: str = "DeviceDefinition" - DeviceDispense: str = "DeviceDispense" - DeviceMetric: str = "DeviceMetric" - DeviceRequest: str = "DeviceRequest" - DeviceUsage: str = "DeviceUsage" - DiagnosticReport: str = "DiagnosticReport" - Encounter: str = "Encounter" - EncounterHistory: str = "EncounterHistory" - Endpoint: str = "Endpoint" - EnrollmentRequest: str = "EnrollmentRequest" - EnrollmentResponse: str = "EnrollmentResponse" - EpisodeOfCare: str = "EpisodeOfCare" - EventDefinition: str = "EventDefinition" - Evidence: str = "Evidence" - EvidenceReport: str = "EvidenceReport" - EvidenceVariable: str = "EvidenceVariable" - ExampleScenario: str = "ExampleScenario" - ExplanationOfBenefit: str = "ExplanationOfBenefit" - FamilyMemberHistory: str = "FamilyMemberHistory" - Flag: str = "Flag" - FormularyItem: str = "FormularyItem" - GenomicStudy: str = "GenomicStudy" - Goal: str = "Goal" - GraphDefinition: str = "GraphDefinition" - Group: str = "Group" - GuidanceResponse: str = "GuidanceResponse" - HealthcareService: str = "HealthcareService" - ImagingSelection: str = "ImagingSelection" - ImagingStudy: str = "ImagingStudy" - Immunization: str = "Immunization" - ImmunizationEvaluation: str = "ImmunizationEvaluation" - ImmunizationRecommendation: str = "ImmunizationRecommendation" - ImplementationGuide: str = "ImplementationGuide" - Ingredient: str = "Ingredient" - InsurancePlan: str = "InsurancePlan" - InventoryItem: str = "InventoryItem" - InventoryReport: str = "InventoryReport" - Invoice: str = "Invoice" - Library: str = "Library" - Linkage: str = "Linkage" - List: str = "List" - Location: str = "Location" - ManufacturedItemDefinition: str = "ManufacturedItemDefinition" - Measure: str = "Measure" - MeasureReport: str = "MeasureReport" - Medication: str = "Medication" - MedicationAdministration: str = "MedicationAdministration" - MedicationDispense: str = "MedicationDispense" - MedicationKnowledge: str = "MedicationKnowledge" - MedicationStatement: str = "MedicationStatement" - MedicinalProductDefinition: str = "MedicinalProductDefinition" - MessageDefinition: str = "MessageDefinition" - MessageHeader: str = "MessageHeader" - MolecularSequence: str = "MolecularSequence" - NamingSystem: str = "NamingSystem" - NutritionIntake: str = "NutritionIntake" - NutritionProduct: str = "NutritionProduct" - Observation: str = "Observation" - ObservationDefinition: str = "ObservationDefinition" - OperationDefinition: str = "OperationDefinition" - OperationOutcome: str = "OperationOutcome" - Organization: str = "Organization" - OrganizationAffiliation: str = "OrganizationAffiliation" - PackagedProductDefinition: str = "PackagedProductDefinition" - Parameters: str = "Parameters" - PaymentNotice: str = "PaymentNotice" - PaymentReconciliation: str = "PaymentReconciliation" - Permission: str = "Permission" - Person: str = "Person" - PlanDefinition: str = "PlanDefinition" - PractitionerRole: str = "PractitionerRole" - Procedure: str = "Procedure" - Provenance: str = "Provenance" - Questionnaire: str = "Questionnaire" - QuestionnaireResponse: str = "QuestionnaireResponse" - RegulatedAuthorization: str = "RegulatedAuthorization" - RelatedPerson: str = "RelatedPerson" - RequestOrchestration: str = "RequestOrchestration" - Requirements: str = "Requirements" - ResearchStudy: str = "ResearchStudy" - ResearchSubject: str = "ResearchSubject" - RiskAssessment: str = "RiskAssessment" - Schedule: str = "Schedule" - SearchParameter: str = "SearchParameter" - ServiceRequest: str = "ServiceRequest" - Slot: str = "Slot" - Specimen: str = "Specimen" - SpecimenDefinition: str = "SpecimenDefinition" - StructureDefinition: str = "StructureDefinition" - StructureMap: str = "StructureMap" - Subscription: str = "Subscription" - SubscriptionStatus: str = "SubscriptionStatus" - SubscriptionTopic: str = "SubscriptionTopic" - Substance: str = "Substance" - SubstanceDefinition: str = "SubstanceDefinition" - SubstanceNucleicAcid: str = "SubstanceNucleicAcid" - SubstancePolymer: str = "SubstancePolymer" - SubstanceProtein: str = "SubstanceProtein" - SubstanceReferenceInformation: str = "SubstanceReferenceInformation" - SubstanceSourceMaterial: str = "SubstanceSourceMaterial" - SupplyDelivery: str = "SupplyDelivery" - SupplyRequest: str = "SupplyRequest" - Task: str = "Task" - TerminologyCapabilities: str = "TerminologyCapabilities" - TestPlan: str = "TestPlan" - TestReport: str = "TestReport" - TestScript: str = "TestScript" - Transport: str = "Transport" - ValueSet: str = "ValueSet" - VerificationResult: str = "VerificationResult" - VisionPrescription: str = "VisionPrescription" diff --git a/healthchain/models/data/cdsfhirdata.py b/healthchain/models/data/cdsfhirdata.py index e684c0de..b096e01a 100644 --- a/healthchain/models/data/cdsfhirdata.py +++ b/healthchain/models/data/cdsfhirdata.py @@ -3,7 +3,7 @@ from pydantic import BaseModel, Field from typing import Dict -from healthchain.fhir_resources.bundleresources import Bundle +from fhir.resources.bundle import Bundle class CdsFhirData(BaseModel): diff --git a/tests/conftest.py b/tests/conftest.py index fb52a09c..23565d21 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -7,7 +7,7 @@ from healthchain.base import BaseStrategy, BaseUseCase from healthchain.cda_parser.cdaannotator import CdaAnnotator -from healthchain.fhir_resources.bundleresources import Bundle, BundleEntry +from fhir.resources.bundle import Bundle, BundleEntry from healthchain.models import CDSRequest, CdsFhirData from healthchain.models.data.ccddata import CcdData from healthchain.models.data.concept import ( @@ -51,7 +51,9 @@ class synth_data: class MockDataGenerator: def __init__(self) -> None: - self.data = CdsFhirData(context={}, prefetch=Bundle(entry=[BundleEntry()])) + self.data = CdsFhirData( + context={}, prefetch=Bundle(entry=[BundleEntry()], type="document") + ) # self.data = synth_data(context={}, prefetch=MockBundle()) self.workflow = None diff --git a/tests/fhir_resources_unit_tests/test_fhir_resources_base.py b/tests/fhir_resources_unit_tests/test_fhir_resources_base.py deleted file mode 100644 index 8c3ab427..00000000 --- a/tests/fhir_resources_unit_tests/test_fhir_resources_base.py +++ /dev/null @@ -1,59 +0,0 @@ -import pytest - -from pydantic import BaseModel, ValidationError -from healthchain.fhir_resources.primitives import ( - booleanModel, - canonicalModel, - codeModel, -) - - -class booleanTestModel(BaseModel): - my_bool: booleanModel - - -def test_boolean_valid(): - data = {"my_bool": "true"} - result = booleanTestModel(**data) - assert result.my_bool == "true" - - -def test_boolean_invalid(): - data = {"my_bool": "invalid"} - with pytest.raises(ValidationError): - booleanTestModel(**data) - - -class canonicalTestModel(BaseModel): - my_canonical: canonicalModel - - -def test_canonical_valid(): - data = {"my_canonical": "https://example.com"} - result = canonicalTestModel(**data) - assert result.my_canonical == "https://example.com" - - -def test_canonical_invalid(): - data = {"my_canonical": "invalid url"} - with pytest.raises(ValidationError): - canonicalTestModel(**data) - - -class codeTestModel(BaseModel): - my_code: codeModel - - -def test_code_valid(): - data = {"my_code": "ABC123"} - result = codeTestModel(**data) - assert result.my_code == "ABC123" - - -def test_code_invalid(): - data = {"my_code": "invalid code"} - with pytest.raises(ValidationError): - codeTestModel(**data) - - -# TODO: Add tests for the remaining base resources diff --git a/tests/fhir_resources_unit_tests/test_fhir_resources_bundle.py b/tests/fhir_resources_unit_tests/test_fhir_resources_bundle.py deleted file mode 100644 index 8c46c0f0..00000000 --- a/tests/fhir_resources_unit_tests/test_fhir_resources_bundle.py +++ /dev/null @@ -1,15 +0,0 @@ -from healthchain.fhir_resources.bundleresources import BundleEntry, Bundle -from healthchain.data_generators import PatientGenerator, EncounterGenerator - - -def test_bundle_entry_model(): - patient_generator = PatientGenerator() - patient = patient_generator.generate() - encounter_generator = EncounterGenerator() - encounter = encounter_generator.generate() - - bundle_patient_entry = BundleEntry(resource=patient) - bundle_encounter_entry = BundleEntry(resource=encounter) - bundle = Bundle(entry=[bundle_patient_entry, bundle_encounter_entry]) - assert bundle.entry_field[0].resource_field == patient - assert bundle.entry_field[1].resource_field == encounter diff --git a/tests/fhir_resources_unit_tests/test_fhir_resources_patient.py b/tests/fhir_resources_unit_tests/test_fhir_resources_patient.py deleted file mode 100644 index e88ad3d5..00000000 --- a/tests/fhir_resources_unit_tests/test_fhir_resources_patient.py +++ /dev/null @@ -1,79 +0,0 @@ -import pytest -from healthchain.fhir_resources.patient import ( - Patient, - HumanName, - ContactPoint, - Address, -) - - -# TODO: Refactor pytest fixtures -def test_PatientModel(): - data = { - "resourceType": "Patient", - "name": [{"family": "Doe", "given": ["John"], "prefix": ["Mr."]}], - "birthDate": "1980-01-01", - "gender": "Male", - } - patient = Patient(**data) - patient = patient.model_dump(by_alias=True) - assert patient["resourceType"] == "Patient" - assert patient["name"][0]["given"] == ["John"] - assert patient["birthDate"] == "1980-01-01" - assert patient["gender"] == "Male" - - -def test_PatientModel_invalid(): - # Fails due to invalid date format - data = { - "resourceType": "Patient", - "name": [{"family": "Doe", "given": ["John"], "prefix": ["Mr."]}], - "birthDate": "1980-00-00", - } - with pytest.raises(ValueError): - Patient(**data) - - -def test_HumanNameModel(): - data = {"family": "Doe", "given": ["John"], "prefix": ["Mr."]} - name = HumanName(**data) - name = name.model_dump(by_alias=True) - assert name["family"] == "Doe" - assert name["given"] == ["John"] - - -def test_HumanNameModel_invalid(): - # Fails due to invalid data type (int instead of str) for given - data = {"family": "Doe", "given": [15], "prefix": ["Mr."]} - with pytest.raises(ValueError): - HumanName(**data) - - -def test_ContactPointModel(): - data = {"system": "phone", "value": "1234567890", "use": "home"} - contact = ContactPoint(**data) - contact = contact.model_dump(by_alias=True) - assert contact["system"] == "phone" - assert contact["value"] == "1234567890" - - -def test_AddressModel(): - data = { - "use": "home", - "type": "postal", - "text": "123 Main St", - "line": ["Apt 1"], - "city": "Anytown", - "district": "Any County", - "state": "NY", - "postalCode": "12345", - "country": "US", - } - address = Address(**data) - address = address.model_dump(by_alias=True) - assert address["use"] == "home" - assert address["line"] == ["Apt 1"] - assert address["city"] == "Anytown" - assert address["state"] == "NY" - assert address["postalCode"] == "12345" - assert address["country"] == "US" diff --git a/tests/fhir_resources_unit_tests/test_fhir_resources_practitioner.py b/tests/fhir_resources_unit_tests/test_fhir_resources_practitioner.py deleted file mode 100644 index 644a1d15..00000000 --- a/tests/fhir_resources_unit_tests/test_fhir_resources_practitioner.py +++ /dev/null @@ -1,45 +0,0 @@ -from healthchain.fhir_resources.practitioner import Practitioner - - -def test_PractitionerModel(): - data = { - "resourceType": "Practitioner", - "name": [{"family": "Doe", "given": ["John"], "prefix": ["Mr."]}], - "birthDate": "1980-01-01", - "qualification": [ - { - "code": { - "coding": [ - { - "system": "http://example.org", - "code": "12345", - "display": "Qualification 1", - } - ], - "text": "Qualification 1", - }, - "period": {"start": "2010-01-01", "end": "2015-01-01"}, - } - ], - "communication": [ - { - "language": { - "coding": [ - { - "system": "http://example.org", - "code": "en", - "display": "English", - } - ], - "text": "English", - } - } - ], - } - - practitioner = Practitioner(**data) - practitioner = practitioner.model_dump(by_alias=True) - assert practitioner["resourceType"] == "Practitioner" - assert practitioner["name"][0]["given"] == ["John"] - assert practitioner["birthDate"] == "1980-01-01" - assert practitioner["qualification"][0]["code"]["coding"][0]["code"] == "12345" diff --git a/tests/generators_tests/test_condition_generators.py b/tests/generators_tests/test_condition_generators.py index 3794ab2f..86185d02 100644 --- a/tests/generators_tests/test_condition_generators.py +++ b/tests/generators_tests/test_condition_generators.py @@ -46,10 +46,10 @@ def test_ConditionGenerator(): condition_model = ConditionGenerator.generate("Patient/456", "Encounter/789") value_set = [x.code for x in ConditionCodeSimple().value_set] value_set.extend([x.code for x in ConditionCodeComplex().value_set]) - assert condition_model.subject_field.reference_field == "Patient/456" - assert condition_model.encounter_field.reference_field == "Encounter/789" - assert condition_model.id_field is not None - assert condition_model.subject_field is not None - assert condition_model.encounter_field is not None - assert condition_model.code_field is not None - assert condition_model.code_field.coding_field[0].code_field in value_set + assert condition_model.subject.reference == "Patient/456" + assert condition_model.encounter.reference == "Encounter/789" + assert condition_model.id is not None + assert condition_model.subject is not None + assert condition_model.encounter is not None + assert condition_model.code is not None + assert condition_model.code.coding[0].code in value_set diff --git a/tests/generators_tests/test_encounter_generators.py b/tests/generators_tests/test_encounter_generators.py index 27a10081..18ee27f6 100644 --- a/tests/generators_tests/test_encounter_generators.py +++ b/tests/generators_tests/test_encounter_generators.py @@ -8,30 +8,29 @@ def test_ClassGenerator(): patient_class = ClassGenerator.generate() assert ( - patient_class.coding_field[0].system_field + patient_class.coding[0].system == "http://terminology.hl7.org/CodeSystem/v3-ActCode" ) - assert patient_class.coding_field[0].code_field in ("IMP", "AMB") - assert patient_class.coding_field[0].display_field in ("inpatient", "ambulatory") + assert patient_class.coding[0].code in ("IMP", "AMB") + assert patient_class.coding[0].display in ("inpatient", "ambulatory") def test_EncounterTypeGenerator(): encounter_type = EncounterTypeGenerator.generate() - assert encounter_type.coding_field[0].system_field == "http://snomed.info/sct" - assert encounter_type.coding_field[0].display_field in ("consultation", "emergency") + assert encounter_type.coding[0].system == "http://snomed.info/sct" + assert encounter_type.coding[0].display in ("consultation", "emergency") def test_EncounterModel(): encounter = EncounterGenerator.generate() - assert encounter.resourceType == "Encounter" - assert encounter.id_field is not None - assert encounter.status_field in ( + assert encounter.id is not None + assert encounter.status in ( "planned", "in-progress", "on-hold", "discharged", "cancelled", ) - assert encounter.subject_field.reference_field == "Patient/123" - assert encounter.subject_field.display_field == "Patient/123" + assert encounter.subject.reference == "Patient/123" + assert encounter.subject.display == "Patient/123" diff --git a/tests/generators_tests/test_medication_administration_generators.py b/tests/generators_tests/test_medication_administration_generators.py index e9c94d12..699bb442 100644 --- a/tests/generators_tests/test_medication_administration_generators.py +++ b/tests/generators_tests/test_medication_administration_generators.py @@ -6,11 +6,11 @@ def test_MedicationAdministrationDosageGenerator(): result = MedicationAdministrationDosageGenerator.generate() - assert result.text_field is not None + assert result.text is not None def test_MedicationAdministrationGenerator(): result = MedicationAdministrationGenerator.generate("Patient/123", "Encounter/123") - assert result.id_field is not None - assert result.status_field is not None - assert result.medication_field is not None + assert result.id is not None + assert result.status is not None + assert result.medication is not None diff --git a/tests/generators_tests/test_medication_request_generators.py b/tests/generators_tests/test_medication_request_generators.py index 4784c2cd..6fca11ae 100644 --- a/tests/generators_tests/test_medication_request_generators.py +++ b/tests/generators_tests/test_medication_request_generators.py @@ -18,9 +18,5 @@ def test_MedicationRequestGenerator(): medication_request = generator.generate() value_set = [x.code for x in MedicationRequestMedication().value_set] assert medication_request is not None - assert medication_request.resourceType == "MedicationRequest" - assert medication_request.id_field is not None - assert ( - medication_request.contained_field[0].code_field.coding_field[0].code_field - in value_set - ) + assert medication_request.id is not None + assert medication_request.contained[0].code.coding[0].code in value_set diff --git a/tests/generators_tests/test_patient_generators.py b/tests/generators_tests/test_patient_generators.py index 1b332868..7651b4f7 100644 --- a/tests/generators_tests/test_patient_generators.py +++ b/tests/generators_tests/test_patient_generators.py @@ -25,6 +25,5 @@ def test_patient_data_generator(): assert patient_data is not None # Assert that the patient data has the expected pydantic fields - assert patient_data.resourceType == "Patient" - assert patient_data.id_field is not None - assert patient_data.active_field is not None + assert patient_data.id is not None + assert patient_data.active is not None diff --git a/tests/generators_tests/test_practitioner_generators.py b/tests/generators_tests/test_practitioner_generators.py index 7dc4e72e..affaec61 100644 --- a/tests/generators_tests/test_practitioner_generators.py +++ b/tests/generators_tests/test_practitioner_generators.py @@ -16,24 +16,23 @@ def test_practitioner_data_generator(): assert practitioner_data is not None # Assert that the practitioner data has the expected pydantic fields - assert practitioner_data.resourceType == "Practitioner" - assert practitioner_data.id_field is not None - assert practitioner_data.active_field is not None - assert practitioner_data.name_field is not None - assert practitioner_data.qualification_field is not None - assert practitioner_data.communication_field is not None + assert practitioner_data.id is not None + assert practitioner_data.active is not None + assert practitioner_data.name is not None + assert practitioner_data.qualification is not None + assert practitioner_data.communication is not None # Assert that the qualification data has the expected pydantic fields - qualification_data = practitioner_data.qualification_field[0] - assert qualification_data.id_field is not None - assert qualification_data.code_field is not None - assert qualification_data.period_field is not None + qualification_data = practitioner_data.qualification[0] + assert qualification_data.id is not None + assert qualification_data.code is not None + assert qualification_data.period is not None # Assert that the communication data has the expected pydantic fields - communication_data = practitioner_data.communication_field[0] - assert communication_data.id_field is not None - assert communication_data.language_field is not None - assert communication_data.preferred_field is not None + 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 def test_practitioner_qualification_generator(): @@ -47,9 +46,9 @@ def test_practitioner_qualification_generator(): assert qualification is not None # Assert that the qualification has the expected pydantic fields - assert qualification.id_field is not None - assert qualification.code_field is not None - assert qualification.period_field is not None + assert qualification.id is not None + assert qualification.code is not None + assert qualification.period is not None def test_practitioner_communication_generator(): @@ -63,6 +62,6 @@ def test_practitioner_communication_generator(): assert communication is not None # Assert that the communication has the expected pydantic fields - assert communication.id_field is not None - assert communication.language_field is not None - assert communication.preferred_field is not None + assert communication.id is not None + assert communication.language is not None + assert communication.preferred is not None diff --git a/tests/generators_tests/test_procedure_generators.py b/tests/generators_tests/test_procedure_generators.py index e5916cad..526d18f8 100644 --- a/tests/generators_tests/test_procedure_generators.py +++ b/tests/generators_tests/test_procedure_generators.py @@ -11,7 +11,6 @@ def test_ProcedureGenerator(): procedure = ProcedureGenerator.generate( subject_reference="Patient/123", encounter_reference="Encounter/123" ) - assert procedure.resourceType == "Procedure" - assert procedure.subject_field.reference_field == "Patient/123" - assert procedure.encounter_field.reference_field == "Encounter/123" - assert procedure.code_field.coding_field[0].code_field in value_set + assert procedure.subject.reference == "Patient/123" + assert procedure.encounter.reference == "Encounter/123" + assert procedure.code.coding[0].code in value_set From ec57b8ba017a7969686ba1c1d00c61016ef20df0 Mon Sep 17 00:00:00 2001 From: jenniferjiangkells Date: Thu, 13 Feb 2025 18:20:34 +0000 Subject: [PATCH 03/34] Update ProblemConcept to Condition FHIR resource WIP --- healthchain/cda_parser/cdaannotator.py | 153 +++++++++++++----- healthchain/cda_parser/utils.py | 142 ++++++++++++++++ healthchain/io/containers/document.py | 32 ++-- .../pipeline/components/integrations.py | 24 ++- poetry.lock | 2 +- pyproject.toml | 2 +- tests/test_cdaannotator.py | 116 +++++++------ 7 files changed, 364 insertions(+), 107 deletions(-) create mode 100644 healthchain/cda_parser/utils.py diff --git a/healthchain/cda_parser/cdaannotator.py b/healthchain/cda_parser/cdaannotator.py index dc71f3df..0c35a037 100644 --- a/healthchain/cda_parser/cdaannotator.py +++ b/healthchain/cda_parser/cdaannotator.py @@ -7,9 +7,14 @@ from datetime import datetime from typing import Dict, Optional, List, Tuple, Union +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.coding import Coding + from healthchain.cda_parser.model.datatypes import CD, CE, IVL_PQ from healthchain.models import ( - ProblemConcept, MedicationConcept, AllergyConcept, ) @@ -23,6 +28,7 @@ Observation, SubstanceAdministration, ) +from healthchain.cda_parser.utils import CodeMapping log = logging.getLogger(__name__) @@ -158,6 +164,7 @@ class ProblemCodes(Enum): class CdaAnnotator: """ Annotates a Clinical Document Architecture (CDA) document. + Limited to problems, medications, allergies, and notes sections for now. Args: cda_data (ClinicalDocument): The CDA document data. @@ -166,21 +173,21 @@ class CdaAnnotator: Attributes: clinical_document (ClinicalDocument): The CDA document data. fallback (str): The fallback value. - problem_list (List[ProblemConcept]): The list of problems extracted from the CDA document. - medication_list (List[MedicationConcept]): The list of medications extracted from the CDA document. - allergy_list (List[AllergyConcept]): The list of allergies extracted from the CDA document. + problem_list (List[Condition]): The list of problems extracted from the CDA document. + medication_list (List[MedicationStatement]): The list of medications extracted from the CDA document. + allergy_list (List[AllergyIntolerance]): The list of allergies extracted from the CDA document. note (str): The note extracted from the CDA document. Methods: from_dict(cls, data: Dict): Creates a CdaAnnotator instance from a dictionary. from_xml(cls, data: str): Creates a CdaAnnotator instance from an XML string. - add_to_problem_list(problems: List[ProblemConcept], overwrite: bool = False) -> None: Adds a list of problem concepts to the problems section. + add_to_problem_list(problems: List[Condition], overwrite: bool = False) -> None: Adds a list of Condition resources to the problems section. export(pretty_print: bool = True) -> str: Exports the CDA document as an XML string. """ - def __init__(self, cda_data: ClinicalDocument, fallback="LLM") -> None: + def __init__(self, cda_data: ClinicalDocument) -> None: self.clinical_document = cda_data - self.fallback = fallback + self.code_mapping = CodeMapping() self._get_ccd_sections() self._extract_data() @@ -260,9 +267,9 @@ def _extract_data(self) -> None: Returns: None """ - self.problem_list: List[ProblemConcept] = self._extract_problems() - self.medication_list: List[MedicationConcept] = self._extract_medications() - self.allergy_list: List[AllergyConcept] = self._extract_allergies() + self.problem_list: List[Condition] = self._extract_problems() + self.medication_list: List[MedicationStatement] = self._extract_medications() + self.allergy_list: List[AllergyIntolerance] = self._extract_allergies() self.note: str = self._extract_note() def _find_section_by_code(self, section_code: str) -> Optional[Section]: @@ -343,27 +350,90 @@ def _find_notes_section(self) -> Optional[Section]: SectionId.NOTE.value ) or self._find_section_by_code(SectionCode.NOTE.value) - def _extract_problems(self) -> List[ProblemConcept]: + def _extract_problems(self) -> List[Condition]: """ - Extracts problem concepts from the problem section of the CDA document. + Extracts problems from the CDA document and converts them to FHIR Condition resources. Returns: - A list of ProblemConcept objects representing the extracted problem concepts. + A list of FHIR Condition resources representing the extracted problems. """ if not self._problem_section: log.warning("Empty problem section!") return [] - concepts = [] + conditions = [] - def get_problem_concept_from_cda_data_field(value: Dict) -> ProblemConcept: - concept = ProblemConcept(_standard="cda") - concept.code = value.get("@code") - concept.code_system = value.get("@codeSystem") - concept.code_system_name = value.get("@codeSystemName") - concept.display_name = value.get("@displayName") + def create_fhir_condition_from_cda(value: Dict, entry) -> Condition: + # Map CDA status to FHIR clinical status + if hasattr(entry, "act") and hasattr(entry.act, "statusCode"): + status_code = entry.act.statusCode.code + fhir_status = self.code_mapping.cda_to_fhir( + status_code, "status", case_sensitive=False, default="unknown" + ) + clinical_status = CodeableConcept( + coding=[ + Coding( + system="http://terminology.hl7.org/CodeSystem/condition-clinical", + code=fhir_status, + display=fhir_status.capitalize(), + ) + ] + ) - return concept + # Create base condition with mapped system + condition = Condition( + clinicalStatus=clinical_status, + subject={ + "reference": "Patient/123" # {self.clinical_document.recordTarget.patientRole.id} # TODO: add patient reference + }, + code=CodeableConcept( + coding=[ + Coding( + system=self.code_mapping.cda_to_fhir( + value.get("@codeSystem"), "system" + ), + code=value.get("@code"), + display=value.get("@displayName"), + ) + ] + ), + ) + + # Extract dates from entry + # TODO: utility function for this + if hasattr(entry, "act") and hasattr(entry.act, "effectiveTime"): + effective_time = entry.act.effectiveTime + if hasattr(effective_time, "low") and effective_time.low: + # Convert CDA date format (YYYYMMDD) to FHIR date format (YYYY-MM-DD) + date_str = effective_time.low.value + if date_str: + formatted_date = ( + f"{date_str[:4]}-{date_str[4:6]}-{date_str[6:8]}" + ) + condition.onsetDateTime = formatted_date + + if hasattr(effective_time, "high") and effective_time.high: + date_str = effective_time.high.value + if date_str: + formatted_date = ( + f"{date_str[:4]}-{date_str[4:6]}-{date_str[6:8]}" + ) + condition.abatementDateTime = formatted_date + + # Set category (problem-list-item by default for problems section) + condition.category = [ + CodeableConcept( + coding=[ + Coding( + system="http://terminology.hl7.org/CodeSystem/condition-category", + code="problem-list-item", + display="Problem List Item", + ) + ] + ) + ] + + return condition entries = ( self._problem_section.entry @@ -375,17 +445,17 @@ def get_problem_concept_from_cda_data_field(value: Dict) -> ProblemConcept: entry_relationship = entry.act.entryRelationship values = get_value_from_entry_relationship(entry_relationship) for value in values: - concept = get_problem_concept_from_cda_data_field(value) - concepts.append(concept) + condition = create_fhir_condition_from_cda(value, entry) + conditions.append(condition) - return concepts + return conditions - def _extract_medications(self) -> List[MedicationConcept]: + def _extract_medications(self) -> List[MedicationStatement]: """ Extracts medication concepts from the medication section of the CDA document. Returns: - A list of MedicationConcept objects representing the extracted medication concepts. + A list of MedicationStatement resources representing the extracted medication concepts. """ if not self._medication_section: log.warning("Empty medication section!") @@ -634,7 +704,7 @@ def _extract_note(self) -> str: def _add_new_problem_entry( self, - new_problem: ProblemConcept, + new_problem: Condition, timestamp: str, act_id: str, problem_reference_name: str, @@ -643,7 +713,7 @@ def _add_new_problem_entry( Adds a new problem entry to the problem section of the CDA document. Args: - new_problem (ProblemConcept): The new problem concept to be added. + new_problem (Condition): The new problem concept to be added. timestamp (str): The timestamp of the entry. act_id (str): The ID of the act. problem_reference_name (str): The reference name of the problem. @@ -651,7 +721,19 @@ def _add_new_problem_entry( Returns: None """ - # TODO: This will need work + + # Get CDA status from FHIR clinical status + fhir_status = new_problem.clinicalStatus.coding[0].code + cda_status = self.code_mapping.fhir_to_cda( + fhir_status, "status", case_sensitive=False, default="unknown" + ) + + # Get CDA system from FHIR system + fhir_system = new_problem.code.coding[0].system + cda_system = self.code_mapping.fhir_to_cda( + fhir_system, "system", default="2.16.840.1.113883.6.96" + ) # Default to SNOMED-CT + template = { "act": { "@classCode": "ACT", @@ -665,7 +747,7 @@ def _add_new_problem_entry( ], "id": {"@root": act_id}, "code": {"@nullflavor": "NA"}, - "statusCode": {"@code": "active"}, + "statusCode": {"@code": cda_status}, "effectiveTime": {"low": {"@value": timestamp}}, "entryRelationship": { "@typeCode": "SUBJ", @@ -687,10 +769,9 @@ def _add_new_problem_entry( "text": {"reference": {"@value": problem_reference_name}}, "value": { "@xmlns:xsi": "http://www.w3.org/2001/XMLSchema-instance", - "@code": new_problem.code, - "@codeSystem": new_problem.code_system, - "@codeSystemName": new_problem.code_system_name, - "@displayName": new_problem.display_name, + "@code": new_problem.code.coding[0].code, + "@codeSystem": cda_system, + "@displayName": new_problem.code.coding[0].display, "originalText": { "reference": {"@value": problem_reference_name} }, @@ -730,13 +811,13 @@ def _add_new_problem_entry( self._problem_section.entry.append(new_entry) def add_to_problem_list( - self, problems: List[ProblemConcept], overwrite: bool = False + self, problems: List[Condition], overwrite: bool = False ) -> None: """ Adds a list of problem lists to the problems section. Args: - problems (List[ProblemConcept]): A list of problem concepts to be added. + problems (List[Condition]): A list of Condition resources to be added. overwrite (bool, optional): If True, the existing problem list will be overwritten. Defaults to False. diff --git a/healthchain/cda_parser/utils.py b/healthchain/cda_parser/utils.py new file mode 100644 index 00000000..abb6a782 --- /dev/null +++ b/healthchain/cda_parser/utils.py @@ -0,0 +1,142 @@ +import yaml +import logging +from pathlib import Path +from typing import Any, Dict, List, Optional, Union +from enum import Enum + +log = logging.getLogger(__name__) + + +class MappingStrategy(Enum): + """Defines how to handle multiple matches when converting FHIR to CDA.""" + + FIRST = "first" # Return first match found + ALL = "all" # Return all matches as list + ERROR = "error" # Raise error if multiple matches found + + +class CodeMapping: + """Handles bidirectional mapping between CDA and FHIR codes.""" + + # Default mappings as fallback + DEFAULT_MAPPINGS = { + "system": { + "cda_to_fhir": { + "2.16.840.1.113883.6.96": "http://snomed.info/sct", + "2.16.840.1.113883.3.26.1.1": "http://ncit.nci.nih.gov", + "2.16.840.1.113883.6.88": "http://www.nlm.nih.gov/research/umls/rxnorm", + } + }, + "status": { + "cda_to_fhir": { + "active": "active", + "completed": "resolved", + "aborted": "inactive", + "suspended": "inactive", + } + }, + } + + def __init__(self, config_path: Optional[Union[str, Path]] = None): + """Initialize with optional config file path.""" + self.mappings = self._load_mappings(config_path) + self._validate_mappings() + + def _load_mappings(self, config_path: Optional[Union[str, Path]] = None) -> Dict: + """Load mappings from config file if provided, else use defaults.""" + if not config_path: + return self.DEFAULT_MAPPINGS + + try: + with open(config_path, "r") as f: + return yaml.safe_load(f) + except Exception as e: + log.error(f"Failed to load mappings from {config_path}: {e}") + log.warning("Falling back to default mappings") + return self.DEFAULT_MAPPINGS + + def _validate_mappings(self) -> None: + """Validate mapping structure and log warnings for potential issues.""" + for mapping_type, mapping_data in self.mappings.items(): + if "cda_to_fhir" not in mapping_data: + log.warning(f"Missing cda_to_fhir mapping for {mapping_type}") + + # Check for duplicate FHIR codes + fhir_codes = {} + for cda, fhir in mapping_data.get("cda_to_fhir", {}).items(): + if fhir in fhir_codes: + log.warning( + f"Duplicate FHIR mapping found in {mapping_type}: " + f"{fhir} maps to both {cda} and {fhir_codes[fhir]}" + ) + fhir_codes[fhir] = cda + + def cda_to_fhir( + self, + code: str, + mapping_type: str, + case_sensitive: bool = False, + default: Any = None, + ) -> Optional[str]: + """Convert CDA code to FHIR code.""" + try: + mapping = self.mappings[mapping_type]["cda_to_fhir"] + if not case_sensitive: + code = code.lower() + mapping = {k.lower(): v for k, v in mapping.items()} + + result = mapping.get(code, default) + if result is None: + log.debug(f"No mapping found for CDA code '{code}' in {mapping_type}") + return result + + except KeyError: + log.error(f"Invalid mapping type: {mapping_type}") + return default + + def fhir_to_cda( + self, + code: str, + mapping_type: str, + strategy: MappingStrategy = MappingStrategy.FIRST, + case_sensitive: bool = False, + default: Any = None, + ) -> Union[str, List[str], None]: + """Convert FHIR code to CDA code(s).""" + try: + mapping = self.mappings[mapping_type]["cda_to_fhir"] + if not case_sensitive: + code = code.lower() + mapping = {k: v.lower() for k, v in mapping.items()} + + matches = [cda for cda, fhir in mapping.items() if fhir == code] + + if not matches: + log.debug(f"No mapping found for FHIR code '{code}' in {mapping_type}") + return default + + if len(matches) > 1: + if strategy == MappingStrategy.ERROR: + raise ValueError( + f"Multiple CDA codes found for FHIR code '{code}': {matches}" + ) + elif strategy == MappingStrategy.ALL: + return matches + + return matches[0] + + except KeyError: + log.error(f"Invalid mapping type: {mapping_type}") + return default + + def get_mapping_types(self) -> List[str]: + """Return list of available mapping types.""" + return list(self.mappings.keys()) + + def add_mapping(self, mapping_type: str, cda_code: str, fhir_code: str) -> None: + """Add a new mapping pair.""" + if mapping_type not in self.mappings: + self.mappings[mapping_type] = {"cda_to_fhir": {}} + + self.mappings[mapping_type]["cda_to_fhir"][cda_code] = fhir_code + log.info(f"Added mapping: {mapping_type} - {cda_code} -> {fhir_code}") diff --git a/healthchain/io/containers/document.py b/healthchain/io/containers/document.py index ac576e85..c53baf69 100644 --- a/healthchain/io/containers/document.py +++ b/healthchain/io/containers/document.py @@ -3,18 +3,18 @@ from typing import Any, Dict, Iterator, List, Optional, Union from spacy.tokens import Doc as SpacyDoc +from fhir.resources.condition import Condition +from fhir.resources.medicationstatement import MedicationStatement +from fhir.resources.allergyintolerance import AllergyIntolerance from healthchain.io.containers.base import BaseDocument from healthchain.models.responses import Action, Card from healthchain.models.data import CcdData, CdsFhirData, ConceptLists -from healthchain.models.data.concept import ( - AllergyConcept, - MedicationConcept, - ProblemConcept, -) logger = logging.getLogger(__name__) +# TODO: Update usage with FHIR resources + @dataclass class NlpAnnotations: @@ -375,9 +375,9 @@ def __post_init__(self): def add_concepts( self, - problems: List[ProblemConcept] = None, - medications: List[MedicationConcept] = None, - allergies: List[AllergyConcept] = None, + problems: List[Condition] = None, + medications: List[MedicationStatement] = None, + allergies: List[AllergyIntolerance] = None, ): """ Add extracted medical concepts to the document. @@ -387,17 +387,17 @@ def add_concepts( optional and will only be added if provided. Args: - problems (List[ProblemConcept], optional): List of medical problems/conditions - to add to the document. Defaults to None. - medications (List[MedicationConcept], optional): List of medications - to add to the document. Defaults to None. - allergies (List[AllergyConcept], optional): List of allergies + problems (List[Condition], optional): List of problems (FHIR Condition resources) to add to the document. Defaults to None. + medications (List[MedicationStatement], optional): List of medications + (FHIR MedicationStatement resources) to add to the document. Defaults to None. + allergies (List[AllergyIntolerance], optional): List of allergies + (FHIR AllergyIntolerance resources) to add to the document. Defaults to None. Example: >>> doc.add_concepts( - ... problems=[ProblemConcept(display_name="Hypertension")], - ... medications=[MedicationConcept(display_name="Aspirin")] + ... problems=[Condition(display_name="Hypertension")], + ... medications=[MedicationStatement(display_name="Aspirin")] ... ) """ if problems: @@ -455,7 +455,7 @@ def generate_ccd(self, overwrite: bool = False) -> CcdData: CcdData: The generated CCD data. Example: - >>> doc.add_concepts(problems=[ProblemConcept(display_name="Hypertension")]) + >>> doc.add_concepts(problems=[Condition(display_name="Hypertension")]) >>> doc.generate_ccd() # Creates CCD with the hypertension problem """ return self._hl7.update_ccd_from_concepts(self._concepts, overwrite) diff --git a/healthchain/pipeline/components/integrations.py b/healthchain/pipeline/components/integrations.py index a592f0ce..a58a9fa7 100644 --- a/healthchain/pipeline/components/integrations.py +++ b/healthchain/pipeline/components/integrations.py @@ -5,7 +5,10 @@ from healthchain.io.containers import Document from healthchain.pipeline.components.base import BaseComponent -from healthchain.models.data import ProblemConcept + +from fhir.resources.condition import Condition +from fhir.resources.codeableconcept import CodeableConcept +from fhir.resources.coding import Coding T = TypeVar("T") @@ -108,17 +111,22 @@ def _add_concepts_to_hc_doc(self, spacy_doc: SpacyDoc, hc_doc: Document): spacy_doc (Doc): The processed spaCy Doc object containing entities hc_doc (Document): The HealthChain Document to store concepts in - Note: Defaults to ProblemConcepts and SNOMED CT concepts + Note: Defaults to Condition and SNOMED CT concepts # TODO: make configurable """ concepts = [] + # TODO: Review this, too specific to MedCAT, coding system needs to be configurable for ent in spacy_doc.ents: - # Check for CUI attribute from extensions like medcat - concept = ProblemConcept( - code=ent._.cui if hasattr(ent, "_.cui") else None, - code_system="2.16.840.1.113883.6.96", - code_system_name="SNOMED CT", - display_name=ent.text, + concept = Condition( + code=CodeableConcept( + coding=[ + Coding( + system="http://snomed.info/sct", + code=ent._.cui if hasattr(ent, "_.cui") else None, + display=ent.text, + ) + ] + ) ) concepts.append(concept) diff --git a/poetry.lock b/poetry.lock index c4313f63..4527088f 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1,4 +1,4 @@ -# This file is automatically @generated by Poetry 1.8.3 and should not be changed by hand. +# This file is automatically @generated by Poetry 1.8.2 and should not be changed by hand. [[package]] name = "annotated-types" diff --git a/pyproject.toml b/pyproject.toml index 19b9c127..2c1ec7e7 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -40,8 +40,8 @@ httpx = "^0.27.0" spyne = "^2.14.0" lxml = "^5.2.2" xmltodict = "^0.13.0" - fhir-resources = "^8.0.0" + [tool.poetry.group.dev.dependencies] ruff = "^0.4.2" pytest = "^8.2.0" diff --git a/tests/test_cdaannotator.py b/tests/test_cdaannotator.py index 2c56e406..4aa401a3 100644 --- a/tests/test_cdaannotator.py +++ b/tests/test_cdaannotator.py @@ -1,7 +1,7 @@ +import pytest from healthchain.cda_parser.cdaannotator import ( SectionId, SectionCode, - ProblemConcept, AllergyConcept, ) from healthchain.models.data.concept import ( @@ -11,6 +11,34 @@ Range, TimeInterval, ) +from fhir.resources.condition import Condition +from fhir.resources.codeableconcept import CodeableConcept +from fhir.resources.coding import Coding + + +@pytest.fixture +def test_condition(): + return Condition( + subject={"reference": "Patient/123"}, + code=CodeableConcept( + coding=[ + Coding( + system="http://snomed.info/sct", + code="123456", + display="Test Condition", + ) + ] + ), + clinicalStatus=CodeableConcept( + coding=[ + Coding( + system="http://terminology.hl7.org/CodeSystem/condition-clinical", + code="active", + display="Active", + ) + ] + ), + ) def test_find_notes_section(cda_annotator): @@ -76,21 +104,29 @@ def test_extract_note_using_code(cda_annotator_code): def test_extract_problems(cda_annotator): + """Test if problems are extracted correctly as FHIR Condition resources""" problems = cda_annotator._extract_problems() - - assert len(problems) == 1 - assert problems[0].code == "38341003" - assert problems[0].code_system == "2.16.840.1.113883.6.96" - assert problems[0].code_system_name == "SNOMED CT" + assert len(problems) > 0 + for problem in problems: + assert isinstance(problem, Condition) + assert isinstance(problem.code, CodeableConcept) + assert isinstance(problem.code.coding[0], Coding) + assert problem.code.coding[0].system == "http://snomed.info/sct" + assert problem.code.coding[0].code == "38341003" + assert problem.clinicalStatus.coding[0].code == "active" + assert problem.subject.reference == "Patient/123" + assert problem.onsetDateTime == "2021-03-17" + assert problem.category[0].coding[0].code == "problem-list-item" def test_extract_problems_using_code(cda_annotator_code): + """Test if problems are extracted correctly from code-based sections as FHIR Condition resources""" problems = cda_annotator_code._extract_problems() - - assert len(problems) == 1 - assert problems[0].code == "38341003" - assert problems[0].code_system == "2.16.840.1.113883.6.96" - assert problems[0].code_system_name == "SNOMED CT" + assert len(problems) > 0 + for problem in problems: + assert isinstance(problem, Condition) + assert isinstance(problem.code, CodeableConcept) + assert isinstance(problem.code.coding[0], Coding) def test_extract_medications(cda_annotator): @@ -246,38 +282,33 @@ def test_add_to_empty_sections(cda_annotator, test_ccd_data): assert cda_annotator.allergy_list == [] -def test_add_to_problem_list(cda_annotator, test_ccd_data): - problems = test_ccd_data.concepts.problems - cda_annotator.add_to_problem_list(problems) +def test_add_to_problem_list(cda_annotator, test_condition): + """Test adding FHIR Conditions to the problem list""" + + cda_annotator.add_to_problem_list([test_condition]) assert len(cda_annotator.problem_list) == 2 - assert len(cda_annotator._problem_section.entry) == 2 + assert test_condition in cda_annotator.problem_list -def test_add_to_problem_list_overwrite(cda_annotator, test_ccd_data): - # Test if problems are added to the problem list correctly with overwrite=True - problems = test_ccd_data.concepts.problems - cda_annotator.add_to_problem_list(problems, overwrite=True) +def test_add_to_problem_list_overwrite(cda_annotator, test_condition): + """Test overwriting problem list with new FHIR Conditions""" + cda_annotator.add_to_problem_list([test_condition], overwrite=True) assert len(cda_annotator.problem_list) == 1 - assert len(cda_annotator._problem_section.entry) == 1 + assert test_condition in cda_annotator.problem_list -def test_add_multiple_to_problem_list(cda_annotator, test_multiple_ccd_data): - problems = test_multiple_ccd_data.concepts.problems - cda_annotator.add_to_problem_list(problems) +def test_add_multiple_to_problem_list(cda_annotator, test_condition): + """Test adding multiple FHIR Conditions to the problem list""" + cda_annotator.add_to_problem_list([test_condition, test_condition]) assert len(cda_annotator.problem_list) == 3 - assert len(cda_annotator._problem_section.entry) == 3 - - # test deduplicate - cda_annotator.add_to_problem_list(problems) - assert len(cda_annotator.problem_list) == 3 - assert len(cda_annotator._problem_section.entry) == 3 + assert test_condition in cda_annotator.problem_list -def test_add_multiple_to_problem_list_overwrite(cda_annotator, test_multiple_ccd_data): - problems = test_multiple_ccd_data.concepts.problems - cda_annotator.add_to_problem_list(problems, overwrite=True) +def test_add_multiple_to_problem_list_overwrite(cda_annotator, test_condition): + """Test overwriting problem list with new FHIR Conditions""" + cda_annotator.add_to_problem_list([test_condition, test_condition], overwrite=True) assert len(cda_annotator.problem_list) == 2 - assert len(cda_annotator._problem_section.entry) == 2 + assert test_condition in cda_annotator.problem_list def test_add_to_medication_list(cda_annotator, test_ccd_data): @@ -363,19 +394,15 @@ def test_export_no_pretty_print(cda_annotator): assert isinstance(exported_data, str) -def test_add_new_problem_entry(cda_annotator): - # Test if a new problem entry is added correctly - new_problem = ProblemConcept() - new_problem.code = "12345678" - new_problem.code_system = "2.16.840.1.113883.6.96" - new_problem.code_system_name = "SNOMED CT" - new_problem.display_name = "Test Problem" +def test_add_new_problem_entry(cda_annotator, test_condition): + """Test if a new FHIR Condition entry is added correctly to CDA structure""" + timestamp = "20220101" act_id = "12345678" problem_reference_name = "#p12345678name" cda_annotator._add_new_problem_entry( - new_problem=new_problem, + new_problem=test_condition, timestamp=timestamp, act_id=act_id, problem_reference_name=problem_reference_name, @@ -395,10 +422,9 @@ def test_add_new_problem_entry(cda_annotator): 1 ].act.entryRelationship.observation.value == { "@xmlns:xsi": "http://www.w3.org/2001/XMLSchema-instance", - "@code": new_problem.code, - "@codeSystem": new_problem.code_system, - "@codeSystemName": new_problem.code_system_name, - "@displayName": new_problem.display_name, + "@code": test_condition.code.coding[0].code, + "@codeSystem": "2.16.840.1.113883.6.96", + "@displayName": test_condition.code.coding[0].display, "originalText": {"reference": {"@value": problem_reference_name}}, "@xsi:type": "CD", } From 1d3c7dc71b55dd4d9016278c3112cd5eb5c8506a Mon Sep 17 00:00:00 2001 From: jenniferjiangkells Date: Fri, 14 Feb 2025 15:54:30 +0000 Subject: [PATCH 04/34] Added FHIR utility functions --- healthchain/fhir/__init__.py | 34 ++++++ healthchain/fhir/bundle_helpers.py | 172 +++++++++++++++++++++++++++++ healthchain/fhir/helpers.py | 148 +++++++++++++++++++++++++ tests/fhir/conftest.py | 35 ++++++ tests/fhir/test_bundle_helpers.py | 136 +++++++++++++++++++++++ tests/fhir/test_helpers.py | 101 +++++++++++++++++ 6 files changed, 626 insertions(+) create mode 100644 healthchain/fhir/__init__.py create mode 100644 healthchain/fhir/bundle_helpers.py create mode 100644 healthchain/fhir/helpers.py create mode 100644 tests/fhir/conftest.py create mode 100644 tests/fhir/test_bundle_helpers.py create mode 100644 tests/fhir/test_helpers.py diff --git a/healthchain/fhir/__init__.py b/healthchain/fhir/__init__.py new file mode 100644 index 00000000..6c5ff1ae --- /dev/null +++ b/healthchain/fhir/__init__.py @@ -0,0 +1,34 @@ +"""FHIR utilities for HealthChain.""" + +from healthchain.fhir.helpers import ( + create_condition, + create_medication_statement, + create_allergy_intolerance, + create_single_codeable_concept, + create_single_reaction, + set_problem_list_item_category, +) + +from healthchain.fhir.bundle_helpers import ( + create_bundle, + add_resource, + get_resources, + set_resources, + RESOURCE_TYPES, +) + +__all__ = [ + # Resource creation + "create_condition", + "create_medication_statement", + "create_allergy_intolerance", + "create_single_codeable_concept", + "create_single_reaction", + "set_problem_list_item_category", + # Bundle operations + "create_bundle", + "add_resource", + "get_resources", + "set_resources", + "RESOURCE_TYPES", +] diff --git a/healthchain/fhir/bundle_helpers.py b/healthchain/fhir/bundle_helpers.py new file mode 100644 index 00000000..d7e8bde4 --- /dev/null +++ b/healthchain/fhir/bundle_helpers.py @@ -0,0 +1,172 @@ +"""Helper functions for working with FHIR Bundles. + +Example usage: + >>> from healthchain.fhir import create_bundle, get_resources, set_resources + >>> + >>> # Create a bundle + >>> bundle = create_bundle() + >>> + >>> # Add and retrieve conditions + >>> conditions = get_resources(bundle, "Condition") + >>> set_resources(bundle, [new_condition], "Condition") + >>> + >>> # Add and retrieve medications + >>> medications = get_resources(bundle, "MedicationStatement") + >>> set_resources(bundle, [new_medication], "MedicationStatement") + >>> + >>> # Add and retrieve allergies + >>> allergies = get_resources(bundle, "AllergyIntolerance") + >>> set_resources(bundle, [new_allergy], "AllergyIntolerance") +""" + +from typing import List, Type, TypeVar, Optional, Dict, Union +from fhir.resources.bundle import Bundle, BundleEntry +from fhir.resources.resource import Resource +from fhir.resources.condition import Condition +from fhir.resources.medicationstatement import MedicationStatement +from fhir.resources.allergyintolerance import AllergyIntolerance + +T = TypeVar("T", bound=Resource) + +# Registry of supported FHIR resource types +RESOURCE_TYPES: Dict[str, Type[Resource]] = { + "Condition": Condition, + "MedicationStatement": MedicationStatement, + "AllergyIntolerance": AllergyIntolerance, +} + + +def create_bundle(bundle_type: str = "collection") -> Bundle: + """Create an empty FHIR Bundle. + https://www.hl7.org/fhir/bundle.html + + Args: + bundle_type: The type of bundle (default: collection) + Valid types: document, message, transaction, transaction-response, + batch, batch-response, history, searchset, collection + """ + return Bundle(type=bundle_type, entry=[]) + + +def add_resource( + bundle: Bundle, resource: Resource, full_url: Optional[str] = None +) -> None: + """Add a resource to a bundle. + + Args: + bundle: The bundle to add to + resource: The resource to add, e.g. Condition, MedicationStatement, AllergyIntolerance + full_url: Optional full URL for the resource + """ + entry = BundleEntry(resource=resource) + if full_url: + entry.fullUrl = full_url + bundle.entry = (bundle.entry or []) + [entry] + + +def get_resource_type(resource_type: Union[str, Type[Resource]]) -> 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 + + Returns: + The resource type class + + Raises: + ValueError: If the resource type is not supported + """ + if isinstance(resource_type, type) and issubclass(resource_type, Resource): + return resource_type + + if not isinstance(resource_type, str): + raise ValueError( + f"Resource type must be a string or Resource class, got {type(resource_type)}" + ) + + if resource_type not in RESOURCE_TYPES: + raise ValueError( + f"Unsupported resource type: {resource_type}. " + f"Supported types are: {', '.join(RESOURCE_TYPES.keys())}" + ) + + return RESOURCE_TYPES[resource_type] + + +def get_resources( + bundle: Bundle, resource_type: Union[str, Type[Resource]] +) -> List[Resource]: + """Get all resources of a specific type from a bundle. + + Args: + bundle: The bundle to search + resource_type: String name of the resource type (e.g. "Condition") or the type itself + + Returns: + List of resources of the specified type + + Example: + >>> bundle = create_bundle() + >>> # Using string identifier + >>> conditions = get_resources(bundle, "Condition") + >>> medications = get_resources(bundle, "MedicationStatement") + >>> allergies = get_resources(bundle, "AllergyIntolerance") + >>> + >>> # Or using type directly + >>> from fhir.resources.condition import Condition + >>> conditions = get_resources(bundle, Condition) + """ + type_class = get_resource_type(resource_type) + return [ + entry.resource + for entry in (bundle.entry or []) + if isinstance(entry.resource, type_class) + ] + + +def set_resources( + bundle: Bundle, + resources: List[Resource], + resource_type: Union[str, Type[Resource]], + replace: bool = False, +) -> None: + """Set resources of a specific type in the bundle. + + Args: + bundle: The bundle to modify + resources: The new resources to add + resource_type: String name of the resource type (e.g. "Condition") or the type itself + replace: If True, remove existing resources of this type before adding new ones. + If False, append new resources to existing ones. Defaults to False. + + Example: + >>> bundle = create_bundle() + >>> # Append to existing resources (default behavior) + >>> set_resources(bundle, [condition1, condition2], "Condition") + >>> set_resources(bundle, [medication1], "MedicationStatement") + >>> + >>> # Replace existing resources + >>> set_resources(bundle, [condition3], "Condition", replace=True) + >>> + >>> # Or using type directly + >>> 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) diff --git a/healthchain/fhir/helpers.py b/healthchain/fhir/helpers.py new file mode 100644 index 00000000..70a58422 --- /dev/null +++ b/healthchain/fhir/helpers.py @@ -0,0 +1,148 @@ +"""Convenience functions for creating minimal FHIR resources.""" + +from typing import Optional, List, Dict, Any +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.coding import Coding + + +def create_single_codeable_concept( + code: str, + display: Optional[str] = None, + system: Optional[str] = "http://snomed.info/sct", +) -> CodeableConcept: + """Create a FHIR CodeableConcept with a single coding. Default system is SNOMED CT.""" + return CodeableConcept(coding=[Coding(system=system, code=code, display=display)]) + + +def create_single_reaction( + code: str, + display: Optional[str] = None, + system: Optional[str] = "http://snomed.info/sct", + severity: Optional[str] = None, +) -> List[Dict[str, Any]]: + """Create a FHIR Reaction with a single coding. Default system is SNOMED CT.""" + return [ + { + "manifestation": [ + CodeableConcept( + coding=[Coding(system=system, code=code, display=display)] + ) + ], + "severity": severity, + } + ] + + +def set_problem_list_item_category(condition: Condition) -> Condition: + """Set the category of a FHIR Condition to problem-list-item.""" + condition.category = [ + create_single_codeable_concept( + code="problem-list-item", + display="Problem List Item", + system="http://terminology.hl7.org/CodeSystem/condition-category", + ) + ] + return condition + + +def create_condition( + subject: str, + status: str = "active", + code: Optional[str] = None, + display: Optional[str] = None, + system: Optional[str] = "http://snomed.info/sct", +) -> Condition: + """ + Create a minimal active FHIR Condition. + If you need to create a more complex condition, use the FHIR Condition resource directly. + https://build.fhir.org/condition.html + + Args: + subject: REQUIRED. Reference to the patient (e.g. "Patient/123") + status: REQUIRED. Clinical status (default: active) + code: The condition code + display: The display name for the condition + system: The code system (default: SNOMED CT) + """ + if code: + condition_code = create_single_codeable_concept(code, display, system) + else: + condition_code = None + + condition = Condition( + subject={"reference": subject}, + clinicalStatus=create_single_codeable_concept( + code=status, + display=status.capitalize(), + system="http://terminology.hl7.org/CodeSystem/condition-clinical", + ), + code=condition_code, + ) + + return condition + + +def create_medication_statement( + subject: str, + status: Optional[str] = "recorded", + code: Optional[str] = None, + display: Optional[str] = None, + system: Optional[str] = "http://snomed.info/sct", +) -> MedicationStatement: + """ + Create a minimal recorded FHIR MedicationStatement. + If you need to create a more complex medication statement, use the FHIR MedicationStatement resource directly. + https://build.fhir.org/medicationstatement.html + + Args: + subject_reference: REQUIRED. Reference to the patient (e.g. "Patient/123") + status: REQUIRED. Status of the medication (default: recorded) + code: The medication code + display: The display name for the medication + system: The code system (default: SNOMED CT) + """ + if code: + medication_concept = create_single_codeable_concept(code, display, system) + else: + medication_concept = None + + medication = MedicationStatement( + subject={"reference": subject}, + status=status, + medication={"concept": 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", +) -> AllergyIntolerance: + """ + Create a minimal active FHIR AllergyIntolerance. + If you need to create a more complex allergy intolerance, use the FHIR AllergyIntolerance resource directly. + https://build.fhir.org/allergyintolerance.html + + Args: + patient: REQUIRED. Reference to the patient (e.g. "Patient/123") + code: The allergen code + display: The display name for the allergen + system: The code system (default: SNOMED CT) + """ + if code: + allergy_code = create_single_codeable_concept(code, display, system) + else: + allergy_code = None + + allergy = AllergyIntolerance( + patient={"reference": patient}, + code=allergy_code, + ) + + return allergy diff --git a/tests/fhir/conftest.py b/tests/fhir/conftest.py new file mode 100644 index 00000000..8daa6cc8 --- /dev/null +++ b/tests/fhir/conftest.py @@ -0,0 +1,35 @@ +import pytest +from healthchain.fhir.helpers import ( + create_condition, + create_medication_statement, + create_allergy_intolerance, +) +from healthchain.fhir.bundle_helpers import create_bundle + + +@pytest.fixture +def empty_bundle(): + """Create an empty bundle for testing.""" + return create_bundle() + + +@pytest.fixture +def test_condition(): + """Create a test condition.""" + return create_condition(subject="Patient/123", code="123", display="Test Condition") + + +@pytest.fixture +def test_medication(): + """Create a test medication statement.""" + return create_medication_statement( + subject="Patient/123", code="456", display="Test Medication" + ) + + +@pytest.fixture +def test_allergy(): + """Create a test allergy intolerance.""" + return create_allergy_intolerance( + patient="Patient/123", code="789", display="Test Allergy" + ) diff --git a/tests/fhir/test_bundle_helpers.py b/tests/fhir/test_bundle_helpers.py new file mode 100644 index 00000000..20391083 --- /dev/null +++ b/tests/fhir/test_bundle_helpers.py @@ -0,0 +1,136 @@ +"""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 healthchain.fhir.bundle_helpers import ( + create_bundle, + add_resource, + get_resources, + set_resources, + get_resource_type, + RESOURCE_TYPES, +) + + +def test_create_bundle(): + """Test creating an empty bundle.""" + bundle = create_bundle() + assert isinstance(bundle, Bundle) + assert bundle.type == "collection" + assert bundle.entry == [] + + # Test with different type + bundle = create_bundle(bundle_type="transaction") + assert bundle.type == "transaction" + + +def test_add_resource(empty_bundle, test_condition): + """Test adding a resource to a bundle.""" + add_resource(empty_bundle, test_condition) + assert len(empty_bundle.entry) == 1 + assert isinstance(empty_bundle.entry[0].resource, Condition) + + # Test with full URL + add_resource(empty_bundle, test_condition, full_url="http://test.com/Condition/123") + assert len(empty_bundle.entry) == 2 + assert empty_bundle.entry[1].fullUrl == "http://test.com/Condition/123" + + +def test_get_resource_type(): + """Test getting resource type from string or class.""" + # Test with string + assert get_resource_type("Condition") == Condition + assert get_resource_type("MedicationStatement") == MedicationStatement + assert get_resource_type("AllergyIntolerance") == AllergyIntolerance + + # Test with class + assert get_resource_type(Condition) == Condition + + # Test invalid type + with pytest.raises(ValueError, match="Unsupported resource type"): + get_resource_type("InvalidType") + + # Test invalid input type + with pytest.raises( + ValueError, match="Resource type must be a string or Resource class" + ): + get_resource_type(123) + + +def test_get_resources(empty_bundle, test_condition, test_medication, test_allergy): + """Test getting resources by type.""" + # Add mixed resources + add_resource(empty_bundle, test_condition) + add_resource(empty_bundle, test_medication) + add_resource(empty_bundle, test_allergy) + add_resource(empty_bundle, test_condition) # Add another condition + + # Test getting by string type + conditions = get_resources(empty_bundle, "Condition") + assert len(conditions) == 2 + assert all(isinstance(c, Condition) for c in conditions) + + # Test getting by class type + medications = get_resources(empty_bundle, MedicationStatement) + assert len(medications) == 1 + assert isinstance(medications[0], MedicationStatement) + + # Test getting non-existent type + with pytest.raises(ValueError, match="Unsupported resource type"): + get_resources(empty_bundle, "Patient") + + +def test_set_resources_append(empty_bundle, test_condition, test_medication): + """Test setting resources with append mode.""" + # Add initial condition + add_resource(empty_bundle, test_condition) + assert len(get_resources(empty_bundle, "Condition")) == 1 + + # Add more conditions without replace + set_resources(empty_bundle, [test_condition], "Condition", replace=False) + assert len(get_resources(empty_bundle, "Condition")) == 2 + + # Add medication (shouldn't affect conditions) + set_resources(empty_bundle, [test_medication], "MedicationStatement") + assert len(get_resources(empty_bundle, "Condition")) == 2 + assert len(get_resources(empty_bundle, "MedicationStatement")) == 1 + + +def test_set_resources_replace(empty_bundle, test_condition, test_medication): + """Test setting resources with replace mode.""" + # Add initial resources + add_resource(empty_bundle, test_condition) + add_resource(empty_bundle, test_condition) + assert len(get_resources(empty_bundle, "Condition")) == 2 + + # Replace conditions + set_resources(empty_bundle, [test_condition], "Condition", replace=True) + assert len(get_resources(empty_bundle, "Condition")) == 1 + + # Add medication (shouldn't affect conditions) + set_resources(empty_bundle, [test_medication], "MedicationStatement", replace=True) + assert len(get_resources(empty_bundle, "Condition")) == 1 + assert len(get_resources(empty_bundle, "MedicationStatement")) == 1 + + +def test_set_resources_type_validation(empty_bundle, test_condition): + """Test type validation in set_resources.""" + # Try to add condition as medication + with pytest.raises( + ValueError, match="Resource must be of type MedicationStatement" + ): + set_resources(empty_bundle, [test_condition], "MedicationStatement") + + +def test_resource_types_registry(): + """Test the RESOURCE_TYPES registry.""" + assert "Condition" in RESOURCE_TYPES + assert "MedicationStatement" in RESOURCE_TYPES + assert "AllergyIntolerance" in RESOURCE_TYPES + assert RESOURCE_TYPES["Condition"] == Condition + assert RESOURCE_TYPES["MedicationStatement"] == MedicationStatement + assert RESOURCE_TYPES["AllergyIntolerance"] == AllergyIntolerance diff --git a/tests/fhir/test_helpers.py b/tests/fhir/test_helpers.py new file mode 100644 index 00000000..ea422529 --- /dev/null +++ b/tests/fhir/test_helpers.py @@ -0,0 +1,101 @@ +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 healthchain.fhir.helpers import ( + create_single_codeable_concept, + create_single_reaction, + create_condition, + create_medication_statement, + create_allergy_intolerance, + set_problem_list_item_category, +) + + +def test_create_single_codeable_concept(): + """Test creating a CodeableConcept with a single coding.""" + concept = create_single_codeable_concept( + code="123", + display="Test Concept", + ) + + assert isinstance(concept, CodeableConcept) + assert len(concept.coding) == 1 + assert concept.coding[0].code == "123" + assert concept.coding[0].display == "Test Concept" + assert concept.coding[0].system == "http://snomed.info/sct" + + +def test_create_single_reaction(): + """Test creating a reaction with severity.""" + reaction = create_single_reaction( + code="123", + display="Test Reaction", + system="http://test.system", + severity="severe", + ) + + assert isinstance(reaction, list) + assert len(reaction) == 1 + assert reaction[0]["severity"] == "severe" + assert len(reaction[0]["manifestation"]) == 1 + 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(): + """Test creating a condition with all optional fields.""" + condition = create_condition( + subject="Patient/123", + status="resolved", + code="123", + display="Test Condition", + system="http://test.system", + ) + + assert isinstance(condition, Condition) + assert condition.subject.reference == "Patient/123" + assert condition.clinicalStatus.coding[0].code == "resolved" + assert condition.code.coding[0].code == "123" + assert condition.code.coding[0].display == "Test Condition" + assert condition.code.coding[0].system == "http://test.system" + + set_problem_list_item_category(condition) + assert condition.category[0].coding[0].code == "problem-list-item" + + +def test_create_medication_statement_minimal(): + """Test creating a medication statement with only required fields.""" + medication = create_medication_statement( + subject="Patient/123", + code="123", + display="Test Medication", + system="http://test.system", + ) + + assert isinstance(medication, MedicationStatement) + 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" + + +def test_create_allergy_intolerance_minimal(): + """Test creating an allergy intolerance with only required fields.""" + allergy = create_allergy_intolerance( + patient="Patient/123", + code="123", + display="Test Allergy", + system="http://test.system", + ) + + assert isinstance(allergy, AllergyIntolerance) + assert allergy.patient.reference == "Patient/123" + assert allergy.code.coding[0].code == "123" + assert allergy.code.coding[0].display == "Test Allergy" + assert allergy.code.coding[0].system == "http://test.system" From 4c3b4be6aa636cf3ef0e2516d3d9c4d061890c8d Mon Sep 17 00:00:00 2001 From: jenniferjiangkells Date: Mon, 17 Feb 2025 14:54:50 +0000 Subject: [PATCH 05/34] Added DocumentReference helpers --- healthchain/fhir/bundle_helpers.py | 4 +- healthchain/fhir/helpers.py | 164 ++++++++++++++++++++++++++++- tests/conftest.py | 132 +++++++++++++++++------ tests/fhir/conftest.py | 35 ------ tests/fhir/test_helpers.py | 123 ++++++++++++++++++++++ 5 files changed, 384 insertions(+), 74 deletions(-) delete mode 100644 tests/fhir/conftest.py diff --git a/healthchain/fhir/bundle_helpers.py b/healthchain/fhir/bundle_helpers.py index d7e8bde4..20af9c5c 100644 --- a/healthchain/fhir/bundle_helpers.py +++ b/healthchain/fhir/bundle_helpers.py @@ -128,7 +128,7 @@ def set_resources( bundle: Bundle, resources: List[Resource], resource_type: Union[str, Type[Resource]], - replace: bool = False, + replace: bool = True, ) -> None: """Set resources of a specific type in the bundle. @@ -137,7 +137,7 @@ def set_resources( resources: The new resources to add resource_type: String name of the resource type (e.g. "Condition") or the type itself replace: If True, remove existing resources of this type before adding new ones. - If False, append new resources to existing ones. Defaults to False. + If False, append new resources to existing ones. Defaults to True. Example: >>> bundle = create_bundle() diff --git a/healthchain/fhir/helpers.py b/healthchain/fhir/helpers.py index 70a58422..1e865a20 100644 --- a/healthchain/fhir/helpers.py +++ b/healthchain/fhir/helpers.py @@ -1,11 +1,19 @@ """Convenience functions for creating minimal FHIR resources.""" -from typing import Optional, List, Dict, Any +import logging +import base64 +import datetime +from typing import Optional, List, Dict, Any, Union 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.codeableconcept import CodeableConcept from fhir.resources.coding import Coding +from fhir.resources.attachment import Attachment + + +logger = logging.getLogger(__name__) def create_single_codeable_concept( @@ -13,7 +21,17 @@ def create_single_codeable_concept( display: Optional[str] = None, system: Optional[str] = "http://snomed.info/sct", ) -> CodeableConcept: - """Create a FHIR CodeableConcept with a single coding. Default system is SNOMED CT.""" + """ + Create a minimal FHIR CodeableConcept with a single coding. + + Args: + code: REQUIRED. The code value from the code system + display: The display name for the code + system: The code system (default: SNOMED CT) + + Returns: + CodeableConcept: A FHIR CodeableConcept resource with a single coding + """ return CodeableConcept(coding=[Coding(system=system, code=code, display=display)]) @@ -23,7 +41,21 @@ def create_single_reaction( system: Optional[str] = "http://snomed.info/sct", severity: Optional[str] = None, ) -> List[Dict[str, Any]]: - """Create a FHIR Reaction with a single coding. Default system is SNOMED CT.""" + """Create a minimal FHIR Reaction with a single coding. + + Creates a FHIR Reaction object with a single manifestation coding. The manifestation + describes the clinical reaction that was observed. The severity indicates how severe + the reaction was. + + Args: + code: REQUIRED. The code value from the code system representing the reaction manifestation + 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) + + Returns: + A list containing a single FHIR Reaction dictionary with manifestation and severity fields + """ return [ { "manifestation": [ @@ -36,8 +68,57 @@ def create_single_reaction( ] +def create_single_attachment( + content_type: Optional[str] = None, + data: Optional[str] = None, + url: Optional[str] = None, + title: Optional[str] = "Attachment created by HealthChain", +) -> Attachment: + """Create a minimal FHIR Attachment. + + Creates a FHIR Attachment resource with basic fields. Either data or url should be provided. + If data is provided, it will be base64 encoded. + + Args: + content_type: The MIME type of the content + 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") + + Returns: + Attachment: A FHIR Attachment resource with basic metadata and content + """ + + if not data and not url: + logger.warning("No data or url provided for attachment") + + if data: + data = base64.b64encode(data.encode("utf-8")).decode("utf-8") + + return Attachment( + contentType=content_type, + data=data, + url=url, + title=title, + creation=datetime.datetime.now(datetime.timezone.utc).strftime( + "%Y-%m-%dT%H:%M:%S%z" + ), + ) + + def set_problem_list_item_category(condition: Condition) -> Condition: - """Set the category of a FHIR Condition to problem-list-item.""" + """Set the category of a FHIR Condition to problem-list-item. + + Sets the category field of a FHIR Condition resource to indicate it is a problem list item. + This is commonly used to distinguish conditions that are part of the patient's active + problem list from other types of conditions (e.g. encounter-diagnosis). + + Args: + condition: The FHIR Condition resource to modify + + Returns: + Condition: The modified FHIR Condition resource with problem-list-item category set + """ condition.category = [ create_single_codeable_concept( code="problem-list-item", @@ -146,3 +227,78 @@ def create_allergy_intolerance( ) return allergy + + +def create_document_reference( + data: Optional[Any] = None, + url: Optional[str] = None, + content_type: Optional[str] = None, + status: str = "current", + description: Optional[str] = "DocumentReference created by HealthChain", + attachment_title: Optional[str] = "Attachment created by HealthChain", +) -> DocumentReference: + """ + Create a minimal FHIR DocumentReference. + If you need to create a more complex document reference, use the FHIR DocumentReference resource directly. + https://build.fhir.org/documentreference.html + + Args: + data: The data content of the document attachment + url: URL where the document can be accessed + content_type: MIME type of the document (e.g. "application/pdf", "text/xml", "image/png") + status: REQUIRED. Status of the document reference (default: current) + description: Description of the document reference + attachment_title: Title for the document attachment + + Returns: + DocumentReference: A FHIR DocumentReference resource with basic metadata and content + """ + document_reference = DocumentReference( + status=status, + date=datetime.datetime.now(datetime.timezone.utc).strftime( + "%Y-%m-%dT%H:%M:%S%z" + ), + description=description, + content=[ + { + "attachment": create_single_attachment( + content_type=content_type, + data=data, + url=url, + title=attachment_title, + ) + } + ], + ) + + return document_reference + + +def read_attachment( + document_reference: DocumentReference, return_metadata: bool = False +) -> Optional[Union[str, Dict[str, Any]]]: + """Read the attachment from a FHIR DocumentReference. + + Args: + document_reference: The FHIR DocumentReference resource + return_metadata: Whether to return the metadata of the attachment + Returns: + Optional[Union[str, Dict[str, Any]]]: The attachment data as a string, or a dict with data and metadata, + or None if no attachment is found + """ + if not document_reference.content: + return None + + attachment = document_reference.content[0].attachment + data = attachment.url if attachment.url else attachment.data.decode("utf-8") + + if not return_metadata: + return data + + metadata = { + "content_type": attachment.contentType, + "title": attachment.title, + "creation": attachment.creation, + } + + return {"data": data, "metadata": metadata} diff --git a/tests/conftest.py b/tests/conftest.py index 23565d21..63378845 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -9,13 +9,6 @@ from healthchain.cda_parser.cdaannotator import CdaAnnotator from fhir.resources.bundle import Bundle, BundleEntry from healthchain.models import CDSRequest, CdsFhirData -from healthchain.models.data.ccddata import CcdData -from healthchain.models.data.concept import ( - AllergyConcept, - ConceptLists, - MedicationConcept, - ProblemConcept, -) from healthchain.models.requests.cdarequest import CdaRequest from healthchain.models.responses.cdaresponse import CdaResponse from healthchain.models.responses.cdsresponse import CDSResponse, Card @@ -28,10 +21,61 @@ from healthchain.decorators import sandbox from healthchain.use_cases.clindoc import ClinicalDocumentation from healthchain.workflows import UseCaseType +from healthchain.io.containers import Document +from healthchain.fhir import ( + create_bundle, + create_condition, + create_medication_statement, + create_allergy_intolerance, +) + # TODO: Tidy up fixtures +@pytest.fixture +def empty_bundle(): + """Create an empty bundle for testing.""" + return create_bundle() + + +@pytest.fixture +def test_condition(): + """Create a test condition.""" + return create_condition(subject="Patient/123", code="123", display="Test Condition") + + +@pytest.fixture +def test_medication(): + """Create a test medication statement.""" + return create_medication_statement( + subject="Patient/123", code="456", display="Test Medication" + ) + + +@pytest.fixture +def test_allergy(): + """Create a test allergy intolerance.""" + return create_allergy_intolerance( + patient="Patient/123", code="789", display="Test Allergy" + ) + + +@pytest.fixture +def test_problem_list(): + return [test_condition] + + +@pytest.fixture +def test_medication_list(): + return [test_medication] + + +@pytest.fixture +def test_allergy_list(): + return [test_allergy] + + @pytest.fixture(autouse=True) def setup_caplog(caplog): caplog.set_level(logging.WARNING) @@ -54,13 +98,60 @@ def __init__(self) -> None: self.data = CdsFhirData( context={}, prefetch=Bundle(entry=[BundleEntry()], type="document") ) - # self.data = synth_data(context={}, prefetch=MockBundle()) self.workflow = None def set_workflow(self, workflow): self.workflow = workflow +@pytest.fixture +def test_document(test_problem_list, test_medication_list, test_allergy_list): + """Create a test document with FHIR resources.""" + doc = Document(data="Test note") + doc.fhir.set_bundle(create_bundle()) + + # Add test FHIR resources + doc.fhir.problem_list = test_problem_list + doc.fhir.medication_list = test_medication_list + doc.fhir.allergy_list = test_allergy_list + return doc + + +@pytest.fixture +def test_document_with_cda(): + """Create a test document with CDA XML.""" + doc = Document(data="Test note") + doc.cda_xml = "Test CDA" + return doc + + +@pytest.fixture +def test_document_multiple(test_problem_list, test_medication_list, test_allergy_list): + """Create a test document with multiple FHIR resources.""" + doc = Document(data="Test note with multiple resources") + doc.fhir.set_bundle(create_bundle()) + + test_problem_list.append( + create_condition(subject="Patient/123", code="987", display="Test Condition 2") + ) + test_medication_list.append( + create_medication_statement( + subject="Patient/123", code="654", display="Test Medication 2" + ) + ) + test_allergy_list.append( + create_allergy_intolerance( + patient="Patient/123", code="321", display="Test Allergy 2" + ) + ) + + # Add multiple test FHIR resources + doc.fhir.problem_list = test_problem_list + doc.fhir.medication_list = test_medication_list + doc.fhir.allergy_list = test_allergy_list + return doc + + @pytest.fixture def cds_strategy(): return ClinicalDecisionSupportStrategy() @@ -395,31 +486,6 @@ def test_soap_request(): return CdaRequest(document=test_soap) -@pytest.fixture -def test_ccd_data(): - return CcdData( - concepts=ConceptLists( - problems=[ProblemConcept(code="test")], - medications=[MedicationConcept(code="test")], - allergies=[AllergyConcept(code="test")], - ) - ) - - -@pytest.fixture -def test_multiple_ccd_data(): - return CcdData( - concepts=ConceptLists( - problems=[ProblemConcept(code="test1"), ProblemConcept(code="test2")], - medications=[ - MedicationConcept(code="test1"), - MedicationConcept(code="test2"), - ], - allergies=[AllergyConcept(code="test1"), AllergyConcept(code="tes2")], - ) - ) - - @pytest.fixture def cda_annotator(): with open("./tests/data/test_cda.xml", "r") as file: diff --git a/tests/fhir/conftest.py b/tests/fhir/conftest.py deleted file mode 100644 index 8daa6cc8..00000000 --- a/tests/fhir/conftest.py +++ /dev/null @@ -1,35 +0,0 @@ -import pytest -from healthchain.fhir.helpers import ( - create_condition, - create_medication_statement, - create_allergy_intolerance, -) -from healthchain.fhir.bundle_helpers import create_bundle - - -@pytest.fixture -def empty_bundle(): - """Create an empty bundle for testing.""" - return create_bundle() - - -@pytest.fixture -def test_condition(): - """Create a test condition.""" - return create_condition(subject="Patient/123", code="123", display="Test Condition") - - -@pytest.fixture -def test_medication(): - """Create a test medication statement.""" - return create_medication_statement( - subject="Patient/123", code="456", display="Test Medication" - ) - - -@pytest.fixture -def test_allergy(): - """Create a test allergy intolerance.""" - return create_allergy_intolerance( - patient="Patient/123", code="789", display="Test Allergy" - ) diff --git a/tests/fhir/test_helpers.py b/tests/fhir/test_helpers.py index ea422529..960c2bc4 100644 --- a/tests/fhir/test_helpers.py +++ b/tests/fhir/test_helpers.py @@ -2,6 +2,9 @@ from fhir.resources.medicationstatement import MedicationStatement from fhir.resources.allergyintolerance import AllergyIntolerance from fhir.resources.codeableconcept import CodeableConcept +from fhir.resources.documentreference import DocumentReference +from fhir.resources.attachment import Attachment +from datetime import datetime from healthchain.fhir.helpers import ( @@ -11,6 +14,9 @@ create_medication_statement, create_allergy_intolerance, set_problem_list_item_category, + create_single_attachment, + create_document_reference, + read_attachment, ) @@ -99,3 +105,120 @@ def test_create_allergy_intolerance_minimal(): assert allergy.code.coding[0].code == "123" assert allergy.code.coding[0].display == "Test Allergy" assert allergy.code.coding[0].system == "http://test.system" + + +def test_create_single_attachment(): + """Test creating an attachment with data content.""" + attachment = create_single_attachment( + content_type="text/plain", data="Test content", title="Test Attachment" + ) + + assert isinstance(attachment, Attachment) + assert attachment.contentType == "text/plain" + assert attachment.title == "Test Attachment" + # Verify data is base64 encoded + assert attachment.data is not None + assert isinstance(attachment.data, bytes) + # Verify creation date is set + assert attachment.creation is not None + assert isinstance(attachment.creation, datetime) + + +def test_create_single_attachment_with_url(): + """Test creating an attachment with URL reference.""" + attachment = create_single_attachment( + content_type="application/pdf", + url="http://example.com/test.pdf", + title="Test URL Attachment", + ) + + assert isinstance(attachment, Attachment) + assert attachment.contentType == "application/pdf" + assert attachment.url == "http://example.com/test.pdf" + assert attachment.title == "Test URL Attachment" + assert attachment.data is None + + +def test_create_document_reference(): + """Test creating a document reference with data content.""" + doc_ref = create_document_reference( + data="Test document content", + content_type="text/plain", + description="Test Description", + attachment_title="Test Doc", + ) + + assert isinstance(doc_ref, DocumentReference) + assert doc_ref.status == "current" + assert doc_ref.description == "Test Description" + assert len(doc_ref.content) == 1 + assert doc_ref.content[0].attachment.contentType == "text/plain" + assert doc_ref.content[0].attachment.title == "Test Doc" + assert doc_ref.content[0].attachment.data is not None + assert isinstance(doc_ref.content[0].attachment.data, bytes) + # Verify date is set + assert doc_ref.date is not None + assert isinstance(doc_ref.date, datetime) + + +def test_create_document_reference_with_url(): + """Test creating a document reference with URL reference.""" + doc_ref = create_document_reference( + url="http://example.com/test.pdf", + content_type="application/pdf", + status="superseded", + ) + + assert isinstance(doc_ref, DocumentReference) + assert doc_ref.status == "superseded" + assert len(doc_ref.content) == 1 + assert doc_ref.content[0].attachment.contentType == "application/pdf" + assert doc_ref.content[0].attachment.url == "http://example.com/test.pdf" + assert doc_ref.content[0].attachment.data is None + + +def test_read_attachment_with_data(): + """Test reading an attachment with embedded data content.""" + # Create a document reference with data content + test_content = "Test document content" + doc_ref = create_document_reference( + data=test_content, + content_type="text/plain", + description="Test Description", + attachment_title="Test Doc", + ) + + # Test reading content only + content = read_attachment(doc_ref) + assert isinstance(content, str) + assert content == test_content + + # Test reading with metadata + content = read_attachment(doc_ref, return_metadata=True) + assert isinstance(content, dict) + assert content["data"] == test_content + assert content["metadata"]["content_type"] == "text/plain" + assert content["metadata"]["title"] == "Test Doc" + assert content["metadata"]["creation"] is not None + assert isinstance(content["metadata"]["creation"], datetime) + + +def test_read_attachment_with_url(): + """Test reading an attachment with URL reference.""" + # Create a document reference with URL + test_url = "http://example.com/test.pdf" + doc_ref = create_document_reference( + url=test_url, content_type="application/pdf", attachment_title="Test URL Doc" + ) + + # Test reading content only + content = read_attachment(doc_ref) + assert content == test_url + + # Test reading with metadata + content = read_attachment(doc_ref, return_metadata=True) + assert content["data"] == test_url + assert content["metadata"]["content_type"] == "application/pdf" + assert content["metadata"]["title"] == "Test URL Doc" + assert content["metadata"]["creation"] is not None + assert isinstance(content["metadata"]["creation"], datetime) From 7b39a76a508c41a92e5facd61a7b60b557b29948 Mon Sep 17 00:00:00 2001 From: jenniferjiangkells Date: Mon, 17 Feb 2025 17:42:43 +0000 Subject: [PATCH 06/34] Signature adjustment to read_attachment --- healthchain/fhir/helpers.py | 41 +++++++++++++++----------- tests/fhir/test_helpers.py | 58 ++++++++++++++++++++++--------------- 2 files changed, 58 insertions(+), 41 deletions(-) diff --git a/healthchain/fhir/helpers.py b/healthchain/fhir/helpers.py index 1e865a20..df0836df 100644 --- a/healthchain/fhir/helpers.py +++ b/healthchain/fhir/helpers.py @@ -3,7 +3,7 @@ import logging import base64 import datetime -from typing import Optional, List, Dict, Any, Union +from typing import Optional, List, Dict, Any from fhir.resources.condition import Condition from fhir.resources.medicationstatement import MedicationStatement from fhir.resources.allergyintolerance import AllergyIntolerance @@ -275,30 +275,37 @@ def create_document_reference( def read_attachment( - document_reference: DocumentReference, return_metadata: bool = False -) -> Optional[Union[str, Dict[str, Any]]]: - """Read the attachment from a FHIR DocumentReference. + document_reference: DocumentReference, + include_content: bool = True, +) -> Optional[List[Dict[str, Any]]]: + """Read the attachments from a FHIR DocumentReference. Args: document_reference: The FHIR DocumentReference resource - return_metadata: Whether to return the metadata of the attachment + include_content: Whether to include the content of the attachments - useful to set false for large attachments (default: True) Returns: - Optional[Union[str, Dict[str, Any]]]: The attachment data as a string, or a dict with data and metadata, - or None if no attachment is found + Optional[List[Dict[str, Any]]]: List of dictionaries containing attachment data and metadata, + or None if no attachments are found """ if not document_reference.content: return None - attachment = document_reference.content[0].attachment - data = attachment.url if attachment.url else attachment.data.decode("utf-8") + attachments = [] + for content in document_reference.content: + attachment = content.attachment + result = {} - if not return_metadata: - return data + if include_content: + result["data"] = ( + attachment.url if attachment.url else attachment.data.decode("utf-8") + ) - metadata = { - "content_type": attachment.contentType, - "title": attachment.title, - "creation": attachment.creation, - } + result["metadata"] = { + "content_type": attachment.contentType, + "title": attachment.title, + "creation": attachment.creation, + } + + attachments.append(result) - return {"data": data, "metadata": metadata} + return attachments diff --git a/tests/fhir/test_helpers.py b/tests/fhir/test_helpers.py index 960c2bc4..696db1a3 100644 --- a/tests/fhir/test_helpers.py +++ b/tests/fhir/test_helpers.py @@ -188,19 +188,23 @@ def test_read_attachment_with_data(): attachment_title="Test Doc", ) - # Test reading content only - content = read_attachment(doc_ref) - assert isinstance(content, str) - assert content == test_content - - # Test reading with metadata - content = read_attachment(doc_ref, return_metadata=True) - assert isinstance(content, dict) - assert content["data"] == test_content - assert content["metadata"]["content_type"] == "text/plain" - assert content["metadata"]["title"] == "Test Doc" - assert content["metadata"]["creation"] is not None - assert isinstance(content["metadata"]["creation"], datetime) + # Test reading attachments + attachments = read_attachment(doc_ref) + assert isinstance(attachments, list) + assert len(attachments) == 1 + assert attachments[0]["data"] == test_content + assert attachments[0]["metadata"]["content_type"] == "text/plain" + assert attachments[0]["metadata"]["title"] == "Test Doc" + assert attachments[0]["metadata"]["creation"] is not None + + # Test reading without content + attachments = read_attachment(doc_ref, include_content=False) + assert isinstance(attachments, list) + assert len(attachments) == 1 + assert "data" not in attachments[0] + assert attachments[0]["metadata"]["content_type"] == "text/plain" + assert attachments[0]["metadata"]["title"] == "Test Doc" + assert attachments[0]["metadata"]["creation"] is not None def test_read_attachment_with_url(): @@ -211,14 +215,20 @@ def test_read_attachment_with_url(): url=test_url, content_type="application/pdf", attachment_title="Test URL Doc" ) - # Test reading content only - content = read_attachment(doc_ref) - assert content == test_url - - # Test reading with metadata - content = read_attachment(doc_ref, return_metadata=True) - assert content["data"] == test_url - assert content["metadata"]["content_type"] == "application/pdf" - assert content["metadata"]["title"] == "Test URL Doc" - assert content["metadata"]["creation"] is not None - assert isinstance(content["metadata"]["creation"], datetime) + # Test reading attachments + attachments = read_attachment(doc_ref) + assert isinstance(attachments, list) + assert len(attachments) == 1 + assert attachments[0]["data"] == test_url + assert attachments[0]["metadata"]["content_type"] == "application/pdf" + assert attachments[0]["metadata"]["title"] == "Test URL Doc" + assert attachments[0]["metadata"]["creation"] is not None + + # Test reading without content + attachments = read_attachment(doc_ref, include_content=False) + assert isinstance(attachments, list) + assert len(attachments) == 1 + assert "data" not in attachments[0] + assert attachments[0]["metadata"]["content_type"] == "application/pdf" + assert attachments[0]["metadata"]["title"] == "Test URL Doc" + assert attachments[0]["metadata"]["creation"] is not None From 7aefa6318eab6d73da234a8cf7de343a109f725f Mon Sep 17 00:00:00 2001 From: jenniferjiangkells Date: Tue, 18 Feb 2025 10:38:38 +0000 Subject: [PATCH 07/34] Dynamic resource import for bundle helpers and automatic ID generation --- healthchain/fhir/__init__.py | 6 +++-- healthchain/fhir/bundle_helpers.py | 31 ++++++++++-------------- healthchain/fhir/helpers.py | 38 +++++++++++++++++++++++++----- tests/fhir/test_bundle_helpers.py | 20 +++------------- tests/fhir/test_helpers.py | 20 ++++++++++++---- 5 files changed, 67 insertions(+), 48 deletions(-) diff --git a/healthchain/fhir/__init__.py b/healthchain/fhir/__init__.py index 6c5ff1ae..209c4305 100644 --- a/healthchain/fhir/__init__.py +++ b/healthchain/fhir/__init__.py @@ -7,6 +7,8 @@ create_single_codeable_concept, create_single_reaction, set_problem_list_item_category, + read_content_attachment, + create_document_reference, ) from healthchain.fhir.bundle_helpers import ( @@ -14,7 +16,6 @@ add_resource, get_resources, set_resources, - RESOURCE_TYPES, ) __all__ = [ @@ -25,10 +26,11 @@ "create_single_codeable_concept", "create_single_reaction", "set_problem_list_item_category", + "read_content_attachment", + "create_document_reference", # Bundle operations "create_bundle", "add_resource", "get_resources", "set_resources", - "RESOURCE_TYPES", ] diff --git a/healthchain/fhir/bundle_helpers.py b/healthchain/fhir/bundle_helpers.py index 20af9c5c..8e0f9e34 100644 --- a/healthchain/fhir/bundle_helpers.py +++ b/healthchain/fhir/bundle_helpers.py @@ -19,21 +19,12 @@ >>> set_resources(bundle, [new_allergy], "AllergyIntolerance") """ -from typing import List, Type, TypeVar, Optional, Dict, Union +from typing import List, Type, TypeVar, Optional, Union from fhir.resources.bundle import Bundle, BundleEntry from fhir.resources.resource import Resource -from fhir.resources.condition import Condition -from fhir.resources.medicationstatement import MedicationStatement -from fhir.resources.allergyintolerance import AllergyIntolerance -T = TypeVar("T", bound=Resource) -# Registry of supported FHIR resource types -RESOURCE_TYPES: Dict[str, Type[Resource]] = { - "Condition": Condition, - "MedicationStatement": MedicationStatement, - "AllergyIntolerance": AllergyIntolerance, -} +T = TypeVar("T", bound=Resource) def create_bundle(bundle_type: str = "collection") -> Bundle: @@ -74,7 +65,7 @@ def get_resource_type(resource_type: Union[str, Type[Resource]]) -> Type[Resourc The resource type class Raises: - ValueError: If the resource type is not supported + ValueError: If the resource type is not supported or cannot be imported """ if isinstance(resource_type, type) and issubclass(resource_type, Resource): return resource_type @@ -84,13 +75,17 @@ def get_resource_type(resource_type: Union[str, Type[Resource]]) -> Type[Resourc f"Resource type must be a string or Resource class, got {type(resource_type)}" ) - if resource_type not in RESOURCE_TYPES: - raise ValueError( - f"Unsupported resource type: {resource_type}. " - f"Supported types are: {', '.join(RESOURCE_TYPES.keys())}" + try: + # Try to import the resource type dynamically from fhir.resources + module = __import__( + f"fhir.resources.{resource_type.lower()}", fromlist=[resource_type] ) - - return RESOURCE_TYPES[resource_type] + return getattr(module, resource_type) + except (ImportError, AttributeError) as e: + raise ValueError( + f"Could not import resource type: {resource_type}. " + "Make sure it is a valid FHIR resource type." + ) from e def get_resources( diff --git a/healthchain/fhir/helpers.py b/healthchain/fhir/helpers.py index df0836df..c1ed0933 100644 --- a/healthchain/fhir/helpers.py +++ b/healthchain/fhir/helpers.py @@ -3,6 +3,8 @@ import logging import base64 import datetime +import uuid + from typing import Optional, List, Dict, Any from fhir.resources.condition import Condition from fhir.resources.medicationstatement import MedicationStatement @@ -16,6 +18,15 @@ logger = logging.getLogger(__name__) +def _generate_id() -> str: + """Generate a unique ID prefixed with 'hc-'. + + Returns: + str: A unique ID string prefixed with 'hc-' + """ + return f"hc-{str(uuid.uuid4())}" + + def create_single_codeable_concept( code: str, display: Optional[str] = None, @@ -147,6 +158,9 @@ def create_condition( code: The condition code display: The display name for the condition system: The code system (default: SNOMED CT) + + Returns: + Condition: A FHIR Condition resource with an auto-generated ID prefixed with 'hc-' """ if code: condition_code = create_single_codeable_concept(code, display, system) @@ -154,6 +168,7 @@ def create_condition( condition_code = None condition = Condition( + id=_generate_id(), subject={"reference": subject}, clinicalStatus=create_single_codeable_concept( code=status, @@ -184,6 +199,9 @@ def create_medication_statement( code: The medication code display: The display name for the medication system: The code system (default: SNOMED CT) + + Returns: + MedicationStatement: A FHIR MedicationStatement resource with an auto-generated ID prefixed with 'hc-' """ if code: medication_concept = create_single_codeable_concept(code, display, system) @@ -191,6 +209,7 @@ def create_medication_statement( medication_concept = None medication = MedicationStatement( + id=_generate_id(), subject={"reference": subject}, status=status, medication={"concept": medication_concept}, @@ -215,6 +234,9 @@ def create_allergy_intolerance( code: The allergen code display: The display name for the allergen system: The code system (default: SNOMED CT) + + Returns: + AllergyIntolerance: A FHIR AllergyIntolerance resource with an auto-generated ID prefixed with 'hc-' """ if code: allergy_code = create_single_codeable_concept(code, display, system) @@ -222,6 +244,7 @@ def create_allergy_intolerance( allergy_code = None allergy = AllergyIntolerance( + id=_generate_id(), patient={"reference": patient}, code=allergy_code, ) @@ -251,9 +274,10 @@ def create_document_reference( attachment_title: Title for the document attachment Returns: - DocumentReference: A FHIR DocumentReference resource with basic metadata and content + DocumentReference: A FHIR DocumentReference resource with an auto-generated ID prefixed with 'hc-' """ document_reference = DocumentReference( + id=_generate_id(), status=status, date=datetime.datetime.now(datetime.timezone.utc).strftime( "%Y-%m-%dT%H:%M:%S%z" @@ -274,15 +298,17 @@ def create_document_reference( return document_reference -def read_attachment( +def read_content_attachment( document_reference: DocumentReference, - include_content: bool = True, + include_data: bool = True, ) -> Optional[List[Dict[str, Any]]]: - """Read the attachments from a FHIR DocumentReference. + """Read the attachments in a human readable format from a FHIR DocumentReference content field. Args: document_reference: The FHIR DocumentReference resource - include_content: Whether to include the content of the attachments - useful to set false for large attachments (default: True) + include_data: Whether to include the data of the attachments. + If true, the data will be also be decoded (default: True) + Returns: Optional[List[Dict[str, Any]]]: List of dictionaries containing attachment data and metadata, or None if no attachments are found @@ -295,7 +321,7 @@ def read_attachment( attachment = content.attachment result = {} - if include_content: + if include_data: result["data"] = ( attachment.url if attachment.url else attachment.data.decode("utf-8") ) diff --git a/tests/fhir/test_bundle_helpers.py b/tests/fhir/test_bundle_helpers.py index 20391083..6dd8ee65 100644 --- a/tests/fhir/test_bundle_helpers.py +++ b/tests/fhir/test_bundle_helpers.py @@ -5,6 +5,7 @@ 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 healthchain.fhir.bundle_helpers import ( create_bundle, @@ -12,7 +13,6 @@ get_resources, set_resources, get_resource_type, - RESOURCE_TYPES, ) @@ -46,12 +46,12 @@ def test_get_resource_type(): assert get_resource_type("Condition") == Condition assert get_resource_type("MedicationStatement") == MedicationStatement assert get_resource_type("AllergyIntolerance") == AllergyIntolerance - + assert get_resource_type("DocumentReference") == DocumentReference # Test with class assert get_resource_type(Condition) == Condition # Test invalid type - with pytest.raises(ValueError, match="Unsupported resource type"): + with pytest.raises(ValueError, match="Could not import resource type"): get_resource_type("InvalidType") # Test invalid input type @@ -79,10 +79,6 @@ def test_get_resources(empty_bundle, test_condition, test_medication, test_aller assert len(medications) == 1 assert isinstance(medications[0], MedicationStatement) - # Test getting non-existent type - with pytest.raises(ValueError, match="Unsupported resource type"): - get_resources(empty_bundle, "Patient") - def test_set_resources_append(empty_bundle, test_condition, test_medication): """Test setting resources with append mode.""" @@ -124,13 +120,3 @@ def test_set_resources_type_validation(empty_bundle, test_condition): ValueError, match="Resource must be of type MedicationStatement" ): set_resources(empty_bundle, [test_condition], "MedicationStatement") - - -def test_resource_types_registry(): - """Test the RESOURCE_TYPES registry.""" - assert "Condition" in RESOURCE_TYPES - assert "MedicationStatement" in RESOURCE_TYPES - assert "AllergyIntolerance" in RESOURCE_TYPES - assert RESOURCE_TYPES["Condition"] == Condition - assert RESOURCE_TYPES["MedicationStatement"] == MedicationStatement - assert RESOURCE_TYPES["AllergyIntolerance"] == AllergyIntolerance diff --git a/tests/fhir/test_helpers.py b/tests/fhir/test_helpers.py index 696db1a3..88336258 100644 --- a/tests/fhir/test_helpers.py +++ b/tests/fhir/test_helpers.py @@ -16,7 +16,7 @@ set_problem_list_item_category, create_single_attachment, create_document_reference, - read_attachment, + read_content_attachment, ) @@ -64,6 +64,8 @@ def test_create_condition(): ) assert isinstance(condition, Condition) + assert condition.id.startswith("hc-") + assert len(condition.id) > 3 # Ensure there's content after "hc-" assert condition.subject.reference == "Patient/123" assert condition.clinicalStatus.coding[0].code == "resolved" assert condition.code.coding[0].code == "123" @@ -84,6 +86,8 @@ def test_create_medication_statement_minimal(): ) assert isinstance(medication, MedicationStatement) + assert medication.id.startswith("hc-") + 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" @@ -101,6 +105,8 @@ def test_create_allergy_intolerance_minimal(): ) assert isinstance(allergy, AllergyIntolerance) + assert allergy.id.startswith("hc-") + assert len(allergy.id) > 3 # Ensure there's content after "hc-" assert allergy.patient.reference == "Patient/123" assert allergy.code.coding[0].code == "123" assert allergy.code.coding[0].display == "Test Allergy" @@ -149,6 +155,8 @@ def test_create_document_reference(): ) assert isinstance(doc_ref, DocumentReference) + assert doc_ref.id.startswith("hc-") + assert len(doc_ref.id) > 3 # Ensure there's content after "hc-" assert doc_ref.status == "current" assert doc_ref.description == "Test Description" assert len(doc_ref.content) == 1 @@ -170,6 +178,8 @@ def test_create_document_reference_with_url(): ) assert isinstance(doc_ref, DocumentReference) + assert doc_ref.id.startswith("hc-") + assert len(doc_ref.id) > 3 # Ensure there's content after "hc-" assert doc_ref.status == "superseded" assert len(doc_ref.content) == 1 assert doc_ref.content[0].attachment.contentType == "application/pdf" @@ -189,7 +199,7 @@ def test_read_attachment_with_data(): ) # Test reading attachments - attachments = read_attachment(doc_ref) + attachments = read_content_attachment(doc_ref) assert isinstance(attachments, list) assert len(attachments) == 1 assert attachments[0]["data"] == test_content @@ -198,7 +208,7 @@ def test_read_attachment_with_data(): assert attachments[0]["metadata"]["creation"] is not None # Test reading without content - attachments = read_attachment(doc_ref, include_content=False) + attachments = read_content_attachment(doc_ref, include_data=False) assert isinstance(attachments, list) assert len(attachments) == 1 assert "data" not in attachments[0] @@ -216,7 +226,7 @@ def test_read_attachment_with_url(): ) # Test reading attachments - attachments = read_attachment(doc_ref) + attachments = read_content_attachment(doc_ref) assert isinstance(attachments, list) assert len(attachments) == 1 assert attachments[0]["data"] == test_url @@ -225,7 +235,7 @@ def test_read_attachment_with_url(): assert attachments[0]["metadata"]["creation"] is not None # Test reading without content - attachments = read_attachment(doc_ref, include_content=False) + attachments = read_content_attachment(doc_ref, include_data=False) assert isinstance(attachments, list) assert len(attachments) == 1 assert "data" not in attachments[0] From 3a9f5bf09f404c3b5ebd36b8dfa9bd4902030d31 Mon Sep 17 00:00:00 2001 From: jenniferjiangkells Date: Tue, 18 Feb 2025 12:16:10 +0000 Subject: [PATCH 08/34] Renamed HL7Data -> FhirData and added convenience FHIR functions to container --- healthchain/io/containers/document.py | 505 +++++++++++++++++--------- tests/containers/conftest.py | 48 +++ tests/containers/test_document.py | 67 ++++ tests/containers/test_fhir_data.py | 144 ++++++++ 4 files changed, 586 insertions(+), 178 deletions(-) create mode 100644 tests/containers/conftest.py create mode 100644 tests/containers/test_document.py create mode 100644 tests/containers/test_fhir_data.py diff --git a/healthchain/io/containers/document.py b/healthchain/io/containers/document.py index c53baf69..4ecf2342 100644 --- a/healthchain/io/containers/document.py +++ b/healthchain/io/containers/document.py @@ -1,20 +1,27 @@ import logging from dataclasses import dataclass, field from typing import Any, Dict, Iterator, List, Optional, Union +from uuid import uuid4 from spacy.tokens import Doc as SpacyDoc 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 healthchain.io.containers.base import BaseDocument from healthchain.models.responses import Action, Card -from healthchain.models.data import CcdData, CdsFhirData, ConceptLists +from healthchain.fhir import ( + create_bundle, + get_resources, + set_resources, + create_single_codeable_concept, + read_content_attachment, +) logger = logging.getLogger(__name__) -# TODO: Update usage with FHIR resources - @dataclass class NlpAnnotations: @@ -192,77 +199,260 @@ def get_generated_text(self, source: str, task: str) -> List[str]: @dataclass -class HL7Data: +class FhirData: """ - Container for structured clinical document formats. + Container for FHIR resources and CDS Hooks context. - This class stores and manages structured clinical data in different formats, - including CCD (Continuity of Care Document) and FHIR (Fast Healthcare - Interoperability Resources). + This class stores and manages clinical data in FHIR format, including documents and their relationships, + with additional support for CDS Hooks context when needed. Attributes: - _ccd_data (Optional[CcdData]): Clinical data in CCD format, containing - problems, medications, allergies and other clinical information. - _fhir_data (Optional[CdsFhirData]): Clinical data in FHIR format for - clinical decision support. - - Methods: - update_ccd_from_concepts(concepts: ConceptLists, overwrite: bool = False) -> CcdData: - Updates the CCD data with new clinical concepts, either by overwriting or extending - existing data. - get_fhir_data() -> Optional[CdsFhirData]: - Returns the FHIR format clinical data if it exists, otherwise None. - get_ccd_data() -> Optional[CcdData]: - Returns the CCD format clinical data if it exists, otherwise None. + _bundle (Optional[Bundle]): FHIR Bundle containing clinical resources + like problems, medications, allergies, documents and other clinical information. + _cds_context (Optional[Dict]): Additional context required for CDS Hooks + integration. + + Example: + >>> fhir_data = FhirData() + >>> # Add a document + >>> doc_id = fhir_data.add_document(document_ref) + >>> # Get document with relationships + >>> doc_family = fhir_data.get_document_family(doc_id) + >>> # Get all documents with content + >>> documents = fhir_data.get_documents(include_data=True) """ - _ccd_data: Optional[CcdData] = None - _fhir_data: Optional[CdsFhirData] = None + _bundle: Optional[Bundle] = None + _cds_context: Optional[Dict] = None + + @property + def problem_list(self) -> List[Condition]: + return self.get_resources("Condition") + + @problem_list.setter + def problem_list(self, conditions: List[Condition]) -> None: + self.add_resources(conditions, "Condition") + + @property + def medication_list(self) -> List[MedicationStatement]: + return self.get_resources("MedicationStatement") + + @medication_list.setter + def medication_list(self, medications: List[MedicationStatement]) -> None: + self.add_resources(medications, "MedicationStatement") + + @property + def allergy_list(self) -> List[AllergyIntolerance]: + """Get allergy list from the bundle.""" + return self.get_resources("AllergyIntolerance") + + @allergy_list.setter + def allergy_list(self, allergies: List[AllergyIntolerance]) -> None: + """Set allergy list in the bundle.""" + self.add_resources(allergies, "AllergyIntolerance") + + def get_bundle(self) -> Optional[Bundle]: + """Returns the FHIR Bundle if it exists.""" + return self._bundle + + def set_bundle(self, bundle: Bundle): + """Sets the FHIR Bundle.""" + self._bundle = bundle + + def get_cds_context(self) -> Optional[Dict]: + """Returns the CDS context if it exists.""" + return self._cds_context + + def set_cds_context(self, context: Dict): + """Sets the CDS context.""" + self._cds_context = context + + def get_resources(self, resource_type: str) -> List[Any]: + """ + Get resources of a specific type from the bundle. + + Args: + resource_type: The FHIR resource type (e.g., "Condition", "MedicationStatement") + + Returns: + List of resources of the specified type + """ + 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 + ): + """ + Add resources of a specific type to the bundle. + + Args: + resources: List of FHIR resources to add + resource_type: The FHIR resource type (e.g., "Condition", "MedicationStatement") + replace: If True, replace existing resources of this type. If False, append. + """ + if not self._bundle: + self._bundle = create_bundle() + set_resources(self._bundle, resources, resource_type, replace=replace) - def update_ccd_from_concepts( - self, concepts: ConceptLists, overwrite: bool = False - ) -> CcdData: + def add_document( + self, + document: DocumentReference, + parent_id: Optional[str] = None, + relationship_type: Optional[str] = "transforms", + ) -> str: """ - Updates the CCD data with new clinical concepts. + Adds a DocumentReference resource to the FHIR bundle and establishes + relationships between documents if a parent_id is provided. The relationship is + tracked using the FHIR relatesTo element with a specified relationship type. + + Args: + document: The DocumentReference to add to the bundle + parent_id: Optional ID of the parent document. If provided, establishes a + relationship between this document and its parent. + relationship_type: The type of relationship to establish with the parent + document. Defaults to "transforms". This is used in the FHIR relatesTo + element's code. See: http://hl7.org/fhir/valueset-document-relationship-type - This method takes a ConceptLists object containing problems, medications, - and allergies, and updates the internal CCD data accordingly. If no CCD - data exists, it creates a new CcdData instance. + Returns: + str: The ID of the added document. If the document had no ID, a new UUID-based + ID is generated. + """ + # Generate a consistent ID if not present + if not document.id: + document.id = f"doc-{uuid4()}" + + # Add relationship metadata if there's a parent + if parent_id: + if not hasattr(document, "relatesTo") or not document.relatesTo: + document.relatesTo = [] + document.relatesTo.append( + { + "target": {"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", + ), + } + ) + + self.add_resources([document], "DocumentReference", replace=False) + + return document.id + + def get_document_family(self, document_id: str) -> Dict[str, Any]: + """ + Get a document and all its related documents. Args: - concepts (ConceptLists): The new clinical concepts to add, containing - problems, medications, and allergies lists. - overwrite (bool, optional): If True, replaces existing concepts. - If False, extends the existing lists. Defaults to False. + document_id: ID of the document to find relationships for Returns: - CcdData: The updated CCD data. + Dict containing: + 'document': The requested document + 'parents': List of parent documents + 'children': List of child documents + 'siblings': List of documents sharing the same parent """ - if self._ccd_data is None: - self._ccd_data = CcdData() + documents = self.get_resources("DocumentReference") + family = {"document": None, "parents": [], "children": [], "siblings": []} + + # Find the requested document + target_doc = next((doc for doc in documents if doc.id == document_id), None) + if not target_doc: + return family + + family["document"] = target_doc + + # Find direct relationships + if hasattr(target_doc, "relatesTo") and target_doc.relatesTo: + # Find parents from target's relationships + for relation in target_doc.relatesTo: + parent_ref = relation.get("target", {}).get("reference") + parent_id = parent_ref.split("/")[-1] + parent = next((doc for doc in documents if doc.id == parent_id), None) + if parent: + family["parents"].append(parent) + + # Find children and siblings + for doc in documents: + if not hasattr(doc, "relatesTo") or not doc.relatesTo: + continue + + for relation in doc.relatesTo: + target_ref = relation.get("target", {}).get("reference") + related_id = target_ref.split("/")[-1] + + # Check if this doc is a child of our target + if related_id == document_id: + family["children"].append(doc) + + # For siblings, check if they share the same parent + elif family["parents"] and related_id == family["parents"][0].id: + if doc.id != document_id: # Don't include self as sibling + family["siblings"].append(doc) + + return family + + def get_documents( + self, include_data: bool = True, include_relationships: bool = True + ) -> List[Dict[str, Any]]: + """ + Get document references with their content and optional relationship data. - if overwrite: - self._ccd_data.concepts.problems = concepts.problems - self._ccd_data.concepts.medications = concepts.medications - self._ccd_data.concepts.allergies = concepts.allergies - else: - self._ccd_data.concepts.problems.extend(concepts.problems) - self._ccd_data.concepts.medications.extend(concepts.medications) - self._ccd_data.concepts.allergies.extend(concepts.allergies) + Args: + include_data: If True, decode and include the document data (default: True) + include_relationships: If True, include related document information (default: True) - return self._ccd_data + Returns: + List of documents with metadata and optionally their content and relationships + """ + documents = [] + for doc in self.get_resources("DocumentReference"): + doc_data = { + "id": doc.id, + "description": doc.description, + "status": doc.status, + } - def get_fhir_data(self) -> Optional[CdsFhirData]: - return self._fhir_data + attachments = read_content_attachment(doc, include_data=include_data) + if attachments: + doc_data["attachments"] = [] + for attachment in attachments: + if include_data: + doc_data["attachments"].append( + { + "data": attachment.get("data"), + "metadata": attachment.get("metadata"), + } + ) + else: + doc_data["attachments"].append( + {"metadata": attachment.get("metadata")} + ) - def set_fhir_data(self, fhir_data: CdsFhirData): - self._fhir_data = fhir_data + if include_relationships: + family = self.get_document_family(doc.id) + doc_data["relationships"] = { + "parents": [ + {"id": p.id, "description": p.description} + for p in family["parents"] + ], + "children": [ + {"id": c.id, "description": c.description} + for c in family["children"] + ], + "siblings": [ + {"id": s.id, "description": s.description} + for s in family["siblings"] + ], + } - def get_ccd_data(self) -> Optional[CcdData]: - return self._ccd_data + documents.append(doc_data) - def set_ccd_data(self, ccd_data: CcdData): - self._ccd_data = ccd_data + return documents @dataclass @@ -270,40 +460,87 @@ class CdsAnnotations: """ Container for Clinical Decision Support (CDS) results. - This class stores the outputs from clinical decision support systems, + This class stores and manages outputs from clinical decision support systems, including CDS Hooks cards and suggested clinical actions. The cards contain recommendations, warnings, and other decision support content that can be displayed to clinicians. Actions represent specific clinical tasks or interventions that are suggested based on the analysis. Attributes: - _cards (Optional[List[Card]]): Internal storage for CDS Hooks cards containing - clinical recommendations, warnings, or other decision support content. - _actions (Optional[List[Action]]): Internal storage for suggested clinical actions - that could be taken based on the CDS analysis. - - Methods: - set_cards(cards: List[Card]): Sets the list of CDS Hooks cards - get_cards() -> Optional[List[Card]]: Returns the current list of cards if any exist - set_actions(actions: List[Action]): Sets the list of suggested clinical actions - get_actions() -> Optional[List[Action]]: Returns the current list of actions if any exist + _cards (Optional[List[Card]]): CDS Hooks cards containing clinical + recommendations, warnings, or other decision support content. + _actions (Optional[List[Action]]): Suggested clinical actions that + could be taken based on the CDS analysis. + + Example: + >>> cds = CdsAnnotations() + >>> cds.cards = [Card(summary="Consider aspirin")] + >>> cds.actions = [Action(type="create", description="Order aspirin")] """ _cards: Optional[List[Card]] = None _actions: Optional[List[Action]] = None - def set_cards(self, cards: List[Card]): - self._cards = cards - - def get_cards(self) -> Optional[List[Card]]: + @property + def cards(self) -> Optional[List[Card]]: + """Get the current list of CDS Hooks cards.""" return self._cards - def set_actions(self, actions: List[Action]): - self._actions = actions + @cards.setter + def cards(self, cards: Union[List[Card], List[Dict[str, Any]]]) -> None: + """ + Set CDS Hooks cards, converting from dictionaries if needed. + + Args: + cards: List of Card objects or dictionaries that can be converted to Cards. + + Raises: + ValueError: If cards list is empty or has invalid format. + TypeError: If cards are neither Card objects nor dictionaries. + """ + if not cards: + raise ValueError("Cards must be provided as a list!") - def get_actions(self) -> Optional[List[Action]]: + try: + if isinstance(cards[0], dict): + self._cards = [Card(**card) for card in cards] + elif isinstance(cards[0], Card): + self._cards = cards + else: + raise TypeError("Cards must be either Card objects or dictionaries") + except (IndexError, KeyError) as e: + raise ValueError("Invalid card format") from e + + @property + def actions(self) -> Optional[List[Action]]: + """Get the current list of suggested clinical actions.""" return self._actions + @actions.setter + def actions(self, actions: Union[List[Action], List[Dict[str, Any]]]) -> None: + """ + Set suggested clinical actions, converting from dictionaries if needed. + + Args: + actions: List of Action objects or dictionaries that can be converted to Actions. + + Raises: + ValueError: If actions list is empty or has invalid format. + TypeError: If actions are neither Action objects nor dictionaries. + """ + if not actions: + raise ValueError("Actions must be provided as a list!") + + try: + if isinstance(actions[0], dict): + self._actions = [Action(**action) for action in actions] + elif isinstance(actions[0], Action): + self._actions = actions + else: + raise TypeError("Actions must be either Action objects or dictionaries") + except (IndexError, KeyError) as e: + raise ValueError("Invalid action format") from e + @dataclass class Document(BaseDocument): @@ -317,32 +554,35 @@ class Document(BaseDocument): The Document class provides a comprehensive representation that can include: - Raw text and basic tokenization - NLP annotations (tokens, entities, embeddings, spaCy docs) - - Clinical concepts (problems, medications, allergies) - - Structured clinical documents (CCD, FHIR) - - Clinical decision support results (cards, actions) + - FHIR resources through the fhir property (problem list, medication list, allergy list) + - Clinical decision support results through the cds property (cards, actions) - ML model outputs (Hugging Face, LangChain) Attributes: nlp (NlpAnnotations): Container for NLP-related annotations like tokens and entities - concepts (ConceptLists): Container for extracted medical concepts - hl7 (StructuredData): Container for structured clinical documents (CCD, FHIR) + fhir (FhirData): Container for FHIR resources and CDS context cds (CdsAnnotations): Container for clinical decision support results models (ModelOutputs): Container for ML model outputs - The class provides methods to: - - Add and access medical concepts - - Generate structured clinical documents - - Get basic text statistics - - Iterate over tokens - - Access raw text + Example: + >>> doc = Document(data="Patient has hypertension") + >>> # Add set continuity of care lists + >>> doc.fhir.problem_list = [Condition(...)] + >>> doc.fhir.medication_list = [MedicationStatement(...)] + >>> # Add FHIR resources + >>> doc.fhir.add_resources([Patient(...)], "Patient") + >>> # Add a document with a parent + >>> parent_id = doc.fhir.add_document(DocumentReference(...), parent_id="123") + >>> # Add CDS results + >>> doc.cds.cards = [Card(...)] + >>> doc.cds.actions = [Action(...)] Inherits from: BaseDocument: Provides base document functionality and raw text storage """ _nlp: NlpAnnotations = field(default_factory=NlpAnnotations) - _concepts: ConceptLists = field(default_factory=ConceptLists) - _hl7: HL7Data = field(default_factory=HL7Data) + _fhir: FhirData = field(default_factory=FhirData) _cds: CdsAnnotations = field(default_factory=CdsAnnotations) _models: ModelOutputs = field(default_factory=ModelOutputs) @@ -351,12 +591,8 @@ def nlp(self) -> NlpAnnotations: return self._nlp @property - def concepts(self) -> ConceptLists: - return self._concepts - - @property - def hl7(self) -> HL7Data: - return self._hl7 + def fhir(self) -> FhirData: + return self._fhir @property def cds(self) -> CdsAnnotations: @@ -373,93 +609,6 @@ def __post_init__(self): if not self._nlp._tokens: self._nlp._tokens = self.text.split() # Basic tokenization if not provided - def add_concepts( - self, - problems: List[Condition] = None, - medications: List[MedicationStatement] = None, - allergies: List[AllergyIntolerance] = None, - ): - """ - Add extracted medical concepts to the document. - - This method adds medical concepts (problems, medications, allergies) to their - respective lists in the document's concepts container. Each concept type is - optional and will only be added if provided. - - Args: - problems (List[Condition], optional): List of problems (FHIR Condition resources) - to add to the document. Defaults to None. - medications (List[MedicationStatement], optional): List of medications - (FHIR MedicationStatement resources) to add to the document. Defaults to None. - allergies (List[AllergyIntolerance], optional): List of allergies - (FHIR AllergyIntolerance resources) to add to the document. Defaults to None. - - Example: - >>> doc.add_concepts( - ... problems=[Condition(display_name="Hypertension")], - ... medications=[MedicationStatement(display_name="Aspirin")] - ... ) - """ - if problems: - self._concepts.problems.extend(problems) - if medications: - self._concepts.medications.extend(medications) - if allergies: - self._concepts.allergies.extend(allergies) - - def add_cds_cards( - self, cards: Union[List[Card], List[Dict[str, Any]]] - ) -> List[Card]: - if not cards: - raise ValueError("Cards must be provided as a list!") - - try: - if isinstance(cards[0], dict): - cards = [Card(**card) for card in cards] - elif not isinstance(cards[0], Card): - raise TypeError("Cards must be either Card objects or dictionaries") - except (IndexError, KeyError) as e: - raise ValueError("Invalid card format") from e - - return self._cds.set_cards(cards) - - def add_cds_actions( - self, actions: Union[List[Action], List[Dict[str, Any]]] - ) -> List[Action]: - if not actions: - raise ValueError("Actions must be provided as a list!") - - try: - if isinstance(actions[0], dict): - actions = [Action(**action) for action in actions] - elif not isinstance(actions[0], Action): - raise TypeError("Actions must be either Action objects or dictionaries") - except (IndexError, KeyError) as e: - raise ValueError("Invalid action format") from e - - return self._cds.set_actions(actions) - - def generate_ccd(self, overwrite: bool = False) -> CcdData: - """ - Generate a CCD (Continuity of Care Document) from the current medical concepts. - - This method creates or updates a CCD in the hl7 container using the - medical concepts (problems, medications, allergies) currently stored in the document. - The CCD is a standard format for exchanging clinical information. - - Args: - overwrite (bool, optional): If True, overwrites any existing CCD data. - If False, merges with existing CCD data. Defaults to False. - - Returns: - CcdData: The generated CCD data. - - Example: - >>> doc.add_concepts(problems=[Condition(display_name="Hypertension")]) - >>> doc.generate_ccd() # Creates CCD with the hypertension problem - """ - return self._hl7.update_ccd_from_concepts(self._concepts, overwrite) - def word_count(self) -> int: """ Get the word count from the document's text. diff --git a/tests/containers/conftest.py b/tests/containers/conftest.py new file mode 100644 index 00000000..6ef361a9 --- /dev/null +++ b/tests/containers/conftest.py @@ -0,0 +1,48 @@ +import pytest + +from healthchain.io.containers.document import FhirData, Document +from healthchain.fhir import create_bundle, create_document_reference + + +@pytest.fixture +def fhir_data(): + return FhirData() + + +@pytest.fixture +def sample_bundle(): + return create_bundle() + + +@pytest.fixture +def sample_document(): + return Document("This is a sample text for testing.") + + +@pytest.fixture +def sample_document_reference(): + return create_document_reference( + data="test content", + content_type="text/plain", + description="Test Document", + ) + + +@pytest.fixture +def document_family(): + """Create a family of related documents.""" + original = create_document_reference( + data="original content", + content_type="text/plain", + description="Original Report", + ) + + summary = create_document_reference( + data="summary content", content_type="text/plain", description="Summary" + ) + + translation = create_document_reference( + data="translated content", content_type="text/plain", description="Translation" + ) + + return original, summary, translation diff --git a/tests/containers/test_document.py b/tests/containers/test_document.py new file mode 100644 index 00000000..5680dcc9 --- /dev/null +++ b/tests/containers/test_document.py @@ -0,0 +1,67 @@ +from healthchain.io.containers.document import Document + + +def test_document_initialization(sample_document): + """Test basic Document initialization and properties.""" + assert sample_document.data == "This is a sample text for testing." + assert sample_document.text == "This is a sample text for testing." + assert sample_document.nlp.get_tokens() == [ + "This", + "is", + "a", + "sample", + "text", + "for", + "testing.", + ] + assert sample_document.nlp.get_entities() == [] + assert sample_document.nlp.get_embeddings() is None + + +def test_document_properties(sample_document): + """Test Document property access.""" + # Test property access + assert hasattr(sample_document, "nlp") + assert hasattr(sample_document, "fhir") + assert hasattr(sample_document, "cds") + assert hasattr(sample_document, "models") + + +def test_document_word_count(sample_document): + """Test word count functionality.""" + assert sample_document.word_count() == 7 + + +def test_document_iteration(sample_document): + """Test document iteration over tokens.""" + tokens = list(sample_document) + assert tokens == [ + "This", + "is", + "a", + "sample", + "text", + "for", + "testing.", + ] + + +def test_document_length(sample_document): + """Test document length.""" + assert len(sample_document) == 34 # Length of the text string + + +def test_document_post_init(sample_document): + """Test post-initialization behavior.""" + # Test that text is set from data + assert sample_document.text == sample_document.data + # Test that basic tokenization is performed + assert len(sample_document.nlp._tokens) > 0 + + +def test_empty_document(): + """Test Document initialization with empty text.""" + doc = Document("") + assert doc.text == "" + assert doc.nlp._tokens == [] + assert doc.word_count() == 0 diff --git a/tests/containers/test_fhir_data.py b/tests/containers/test_fhir_data.py new file mode 100644 index 00000000..8966eb56 --- /dev/null +++ b/tests/containers/test_fhir_data.py @@ -0,0 +1,144 @@ +from healthchain.fhir import create_condition, create_document_reference + + +def test_bundle_operations(fhir_data, sample_bundle): + """Test basic bundle operations.""" + assert fhir_data.get_bundle() is None + + fhir_data.set_bundle(sample_bundle) + assert fhir_data.get_bundle() == sample_bundle + + +def test_cds_context_operations(fhir_data): + """Test CDS context operations.""" + assert fhir_data.get_cds_context() is None + + test_context = {"hook": "patient-view", "hookInstance": "123"} + fhir_data.set_cds_context(test_context) + assert fhir_data.get_cds_context() == test_context + + +def test_resource_operations(fhir_data): + """Test adding and getting generic resources.""" + # Create test conditions + condition1 = create_condition( + subject="Patient/123", code="C1", display="Test Condition 1" + ) + condition2 = create_condition( + subject="Patient/123", code="C2", display="Test Condition 2" + ) + + # Test empty resource list + assert len(fhir_data.get_resources("Condition")) == 0 + + # Test adding resources + fhir_data.add_resources([condition1, condition2], "Condition") + resources = fhir_data.get_resources("Condition") + assert len(resources) == 2 + + # Test replace flag + condition3 = create_condition( + subject="Patient/123", code="C3", display="Test Condition 3" + ) + fhir_data.add_resources([condition3], "Condition", replace=True) + resources = fhir_data.get_resources("Condition") + assert len(resources) == 1 + assert resources[0].code.coding[0].code == "C3" + + +def test_document_relationships(fhir_data, document_family): + """Test document relationship tracking.""" + original, summary, translation = document_family + + # Add documents with relationships + original_id = fhir_data.add_document(original) + summary_id = fhir_data.add_document(summary, parent_id=original_id) + fhir_data.add_document(translation, parent_id=original_id) + + # Test original document family + original_family = fhir_data.get_document_family(original_id) + assert original_family["document"].description == "Original Report" + assert len(original_family["children"]) == 2 + assert len(original_family["parents"]) == 0 + assert len(original_family["siblings"]) == 0 + + # Test child document family + summary_family = fhir_data.get_document_family(summary_id) + assert len(summary_family["parents"]) == 1 + assert summary_family["parents"][0].description == "Original Report" + assert len(summary_family["siblings"]) == 1 + assert summary_family["siblings"][0].description == "Translation" + + +def test_get_documents_output(fhir_data, document_family): + """Test document retrieval with various options.""" + original, summary, translation = document_family + + # Add documents with relationships + original_id = fhir_data.add_document(original) + summary_id = fhir_data.add_document(summary, parent_id=original_id) + fhir_data.add_document(translation, parent_id=original_id) + + # Test basic document retrieval + docs = fhir_data.get_documents(include_data=False, include_relationships=False) + assert len(docs) == 3 + for doc in docs: + assert "id" in doc + assert "description" in doc + assert "status" in doc + assert "data" not in doc + assert "relationships" not in doc + + # Test with content + docs = fhir_data.get_documents(include_data=True, include_relationships=False) + assert len(docs) == 3 + for doc in docs: + assert "attachments" in doc + assert len(doc["attachments"]) > 0 + assert "data" in doc["attachments"][0] + assert "metadata" in doc["attachments"][0] + + original_doc = next(d for d in docs if d["id"] == original_id) + assert original_doc["attachments"][0]["data"] == "original content" + + # Test with relationships + docs = fhir_data.get_documents(include_data=False, include_relationships=True) + original_doc = next(d for d in docs if d["id"] == original_id) + summary_doc = next(d for d in docs if d["id"] == summary_id) + + assert len(original_doc["relationships"]["children"]) == 2 + assert len(summary_doc["relationships"]["siblings"]) == 1 + assert len(summary_doc["relationships"]["parents"]) == 1 + + +def test_document_not_found(fhir_data): + """Test behavior when requesting non-existent document.""" + family = fhir_data.get_document_family("nonexistent-id") + assert family["document"] is None + assert len(family["parents"]) == 0 + assert len(family["children"]) == 0 + assert len(family["siblings"]) == 0 + + +def test_relationship_metadata(fhir_data, sample_document_reference): + """Test the structure of relationship metadata.""" + doc_id = fhir_data.add_document(sample_document_reference) + + child_doc = create_document_reference( + data="child content", + content_type="text/plain", + description="Child Document", + ) + + fhir_data.add_document(child_doc, parent_id=doc_id) + + # Verify relationship structure + 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]["target"]["reference"] == f"DocumentReference/{doc_id}" From 4e8e14d138939640c663d1f1558df6d0b4317c8d Mon Sep 17 00:00:00 2001 From: jenniferjiangkells Date: Tue, 18 Feb 2025 12:31:25 +0000 Subject: [PATCH 09/34] More descriptive function names and docstrings --- healthchain/io/containers/document.py | 155 +++++++++++++++----------- tests/containers/test_fhir_data.py | 34 +++--- 2 files changed, 109 insertions(+), 80 deletions(-) diff --git a/healthchain/io/containers/document.py b/healthchain/io/containers/document.py index 4ecf2342..0e740955 100644 --- a/healthchain/io/containers/document.py +++ b/healthchain/io/containers/document.py @@ -206,12 +206,21 @@ class FhirData: This class stores and manages clinical data in FHIR format, including documents and their relationships, with additional support for CDS Hooks context when needed. + Also includes collections of resources for common continuity of care lists, such as a problem list, medication list, and allergy list. + These collections are accessible as properties of the class instance. + + Attributes: _bundle (Optional[Bundle]): FHIR Bundle containing clinical resources like problems, medications, allergies, documents and other clinical information. _cds_context (Optional[Dict]): Additional context required for CDS Hooks integration. + Properties: + problem_list: List[Condition] + medication_list: List[MedicationStatement] + allergy_list: List[AllergyIntolerance] + Example: >>> fhir_data = FhirData() >>> # Add a document @@ -227,18 +236,28 @@ class FhirData: @property def problem_list(self) -> List[Condition]: + """Get problem list from the bundle. + Problem list items are stored as Condition resources in the bundle. + See: https://www.hl7.org/fhir/condition.html + """ return self.get_resources("Condition") @problem_list.setter def problem_list(self, conditions: List[Condition]) -> None: + """Set problem list in the bundle.""" self.add_resources(conditions, "Condition") @property def medication_list(self) -> List[MedicationStatement]: + """Get medication list from the bundle.""" return self.get_resources("MedicationStatement") @medication_list.setter def medication_list(self, medications: List[MedicationStatement]) -> None: + """Set medication list in the bundle. + Medication statements are stored as MedicationStatement resources in the bundle. + See: https://www.hl7.org/fhir/medicationstatement.html + """ self.add_resources(medications, "MedicationStatement") @property @@ -248,7 +267,10 @@ def allergy_list(self) -> List[AllergyIntolerance]: @allergy_list.setter def allergy_list(self, allergies: List[AllergyIntolerance]) -> None: - """Set allergy list in the bundle.""" + """Set allergy list in the bundle. + Allergy intolerances are stored as AllergyIntolerance resources in the bundle. + See: https://www.hl7.org/fhir/allergyintolerance.html + """ self.add_resources(allergies, "AllergyIntolerance") def get_bundle(self) -> Optional[Bundle]: @@ -256,7 +278,10 @@ def get_bundle(self) -> Optional[Bundle]: return self._bundle def set_bundle(self, bundle: Bundle): - """Sets the FHIR Bundle.""" + """Sets the FHIR Bundle. + The bundle is a collection of FHIR resources. + See: https://www.hl7.org/fhir/bundle.html + """ self._bundle = bundle def get_cds_context(self) -> Optional[Dict]: @@ -270,12 +295,6 @@ def set_cds_context(self, context: Dict): def get_resources(self, resource_type: str) -> List[Any]: """ Get resources of a specific type from the bundle. - - Args: - resource_type: The FHIR resource type (e.g., "Condition", "MedicationStatement") - - Returns: - List of resources of the specified type """ if not self._bundle: return [] @@ -296,7 +315,7 @@ def add_resources( self._bundle = create_bundle() set_resources(self._bundle, resources, resource_type, replace=replace) - def add_document( + def add_document_reference( self, document: DocumentReference, parent_id: Optional[str] = None, @@ -306,6 +325,7 @@ def add_document( Adds a DocumentReference resource to the FHIR bundle and establishes relationships between documents if a parent_id is provided. The relationship is tracked using the FHIR relatesTo element with a specified relationship type. + See: https://build.fhir.org/documentreference-definitions.html#DocumentReference.relatesTo Args: document: The DocumentReference to add to the bundle @@ -342,65 +362,12 @@ def add_document( return document.id - def get_document_family(self, document_id: str) -> Dict[str, Any]: - """ - Get a document and all its related documents. - - Args: - document_id: ID of the document to find relationships for - - Returns: - Dict containing: - 'document': The requested document - 'parents': List of parent documents - 'children': List of child documents - 'siblings': List of documents sharing the same parent - """ - documents = self.get_resources("DocumentReference") - family = {"document": None, "parents": [], "children": [], "siblings": []} - - # Find the requested document - target_doc = next((doc for doc in documents if doc.id == document_id), None) - if not target_doc: - return family - - family["document"] = target_doc - - # Find direct relationships - if hasattr(target_doc, "relatesTo") and target_doc.relatesTo: - # Find parents from target's relationships - for relation in target_doc.relatesTo: - parent_ref = relation.get("target", {}).get("reference") - parent_id = parent_ref.split("/")[-1] - parent = next((doc for doc in documents if doc.id == parent_id), None) - if parent: - family["parents"].append(parent) - - # Find children and siblings - for doc in documents: - if not hasattr(doc, "relatesTo") or not doc.relatesTo: - continue - - for relation in doc.relatesTo: - target_ref = relation.get("target", {}).get("reference") - related_id = target_ref.split("/")[-1] - - # Check if this doc is a child of our target - if related_id == document_id: - family["children"].append(doc) - - # For siblings, check if they share the same parent - elif family["parents"] and related_id == family["parents"][0].id: - if doc.id != document_id: # Don't include self as sibling - family["siblings"].append(doc) - - return family - - def get_documents( + def get_document_references_readable( self, include_data: bool = True, include_relationships: bool = True ) -> List[Dict[str, Any]]: """ - Get document references with their content and optional relationship data. + Get DocumentReferences resources with their content and optional relationship data + in a human-readable dictionary format. Args: include_data: If True, decode and include the document data (default: True) @@ -434,7 +401,7 @@ def get_documents( ) if include_relationships: - family = self.get_document_family(doc.id) + family = self.get_document_reference_family(doc.id) doc_data["relationships"] = { "parents": [ {"id": p.id, "description": p.description} @@ -454,6 +421,62 @@ def get_documents( return documents + def get_document_reference_family(self, document_id: str) -> Dict[str, Any]: + """ + Get a DocumentReference resource and all its related resources + based on the relatesTo element in the FHIR standard. + See: https://build.fhir.org/documentreference-definitions.html#DocumentReference.relatesTo + + Args: + document_id: ID of the DocumentReference resource to find relationships for + + Returns: + Dict containing: + 'document': The requested DocumentReference resource + 'parents': List of parent DocumentReference resources + 'children': List of child DocumentReference resources + 'siblings': List of DocumentReference resources sharing the same parent + """ + documents = self.get_resources("DocumentReference") + family = {"document": None, "parents": [], "children": [], "siblings": []} + + # Find the requested document + target_doc = next((doc for doc in documents if doc.id == document_id), None) + if not target_doc: + return family + + family["document"] = target_doc + + # Find direct relationships + if hasattr(target_doc, "relatesTo") and target_doc.relatesTo: + # Find parents from target's relationships + for relation in target_doc.relatesTo: + parent_ref = relation.get("target", {}).get("reference") + parent_id = parent_ref.split("/")[-1] + parent = next((doc for doc in documents if doc.id == parent_id), None) + if parent: + family["parents"].append(parent) + + # Find children and siblings + for doc in documents: + if not hasattr(doc, "relatesTo") or not doc.relatesTo: + continue + + for relation in doc.relatesTo: + target_ref = relation.get("target", {}).get("reference") + related_id = target_ref.split("/")[-1] + + # Check if this doc is a child of our target + if related_id == document_id: + family["children"].append(doc) + + # For siblings, check if they share the same parent + elif family["parents"] and related_id == family["parents"][0].id: + if doc.id != document_id: # Don't include self as sibling + family["siblings"].append(doc) + + return family + @dataclass class CdsAnnotations: diff --git a/tests/containers/test_fhir_data.py b/tests/containers/test_fhir_data.py index 8966eb56..a9589153 100644 --- a/tests/containers/test_fhir_data.py +++ b/tests/containers/test_fhir_data.py @@ -51,19 +51,19 @@ def test_document_relationships(fhir_data, document_family): original, summary, translation = document_family # Add documents with relationships - original_id = fhir_data.add_document(original) - summary_id = fhir_data.add_document(summary, parent_id=original_id) - fhir_data.add_document(translation, parent_id=original_id) + original_id = fhir_data.add_document_reference(original) + summary_id = fhir_data.add_document_reference(summary, parent_id=original_id) + fhir_data.add_document_reference(translation, parent_id=original_id) # Test original document family - original_family = fhir_data.get_document_family(original_id) + original_family = fhir_data.get_document_reference_family(original_id) assert original_family["document"].description == "Original Report" assert len(original_family["children"]) == 2 assert len(original_family["parents"]) == 0 assert len(original_family["siblings"]) == 0 # Test child document family - summary_family = fhir_data.get_document_family(summary_id) + summary_family = fhir_data.get_document_reference_family(summary_id) assert len(summary_family["parents"]) == 1 assert summary_family["parents"][0].description == "Original Report" assert len(summary_family["siblings"]) == 1 @@ -75,12 +75,14 @@ def test_get_documents_output(fhir_data, document_family): original, summary, translation = document_family # Add documents with relationships - original_id = fhir_data.add_document(original) - summary_id = fhir_data.add_document(summary, parent_id=original_id) - fhir_data.add_document(translation, parent_id=original_id) + original_id = fhir_data.add_document_reference(original) + summary_id = fhir_data.add_document_reference(summary, parent_id=original_id) + fhir_data.add_document_reference(translation, parent_id=original_id) # Test basic document retrieval - docs = fhir_data.get_documents(include_data=False, include_relationships=False) + docs = fhir_data.get_document_references_readable( + include_data=False, include_relationships=False + ) assert len(docs) == 3 for doc in docs: assert "id" in doc @@ -90,7 +92,9 @@ def test_get_documents_output(fhir_data, document_family): assert "relationships" not in doc # Test with content - docs = fhir_data.get_documents(include_data=True, include_relationships=False) + docs = fhir_data.get_document_references_readable( + include_data=True, include_relationships=False + ) assert len(docs) == 3 for doc in docs: assert "attachments" in doc @@ -102,7 +106,9 @@ def test_get_documents_output(fhir_data, document_family): assert original_doc["attachments"][0]["data"] == "original content" # Test with relationships - docs = fhir_data.get_documents(include_data=False, include_relationships=True) + docs = fhir_data.get_document_references_readable( + include_data=False, include_relationships=True + ) original_doc = next(d for d in docs if d["id"] == original_id) summary_doc = next(d for d in docs if d["id"] == summary_id) @@ -113,7 +119,7 @@ def test_get_documents_output(fhir_data, document_family): def test_document_not_found(fhir_data): """Test behavior when requesting non-existent document.""" - family = fhir_data.get_document_family("nonexistent-id") + family = fhir_data.get_document_reference_family("nonexistent-id") assert family["document"] is None assert len(family["parents"]) == 0 assert len(family["children"]) == 0 @@ -122,7 +128,7 @@ def test_document_not_found(fhir_data): def test_relationship_metadata(fhir_data, sample_document_reference): """Test the structure of relationship metadata.""" - doc_id = fhir_data.add_document(sample_document_reference) + doc_id = fhir_data.add_document_reference(sample_document_reference) child_doc = create_document_reference( data="child content", @@ -130,7 +136,7 @@ def test_relationship_metadata(fhir_data, sample_document_reference): description="Child Document", ) - fhir_data.add_document(child_doc, parent_id=doc_id) + fhir_data.add_document_reference(child_doc, parent_id=doc_id) # Verify relationship structure child = fhir_data.get_resources("DocumentReference")[1] From d8de5311fbcc4e138ab9fd7186e811ddff615f0d Mon Sep 17 00:00:00 2001 From: jenniferjiangkells Date: Tue, 18 Feb 2025 12:34:24 +0000 Subject: [PATCH 10/34] Updated CdaConnector with new Document methods --- healthchain/io/cdaconnector.py | 111 +++++++++++++++++++--------- tests/pipeline/test_cdaconnector.py | 99 +++++++++++++++---------- 2 files changed, 137 insertions(+), 73 deletions(-) diff --git a/healthchain/io/cdaconnector.py b/healthchain/io/cdaconnector.py index 1774e3be..761cc67a 100644 --- a/healthchain/io/cdaconnector.py +++ b/healthchain/io/cdaconnector.py @@ -3,9 +3,13 @@ from healthchain.io.containers import Document from healthchain.io.base import BaseConnector from healthchain.cda_parser import CdaAnnotator -from healthchain.models.data import CcdData, ConceptLists from healthchain.models.requests.cdarequest import CdaRequest from healthchain.models.responses.cdaresponse import CdaResponse +from healthchain.fhir import ( + create_bundle, + set_problem_list_item_category, + create_document_reference, +) log = logging.getLogger(__name__) @@ -32,24 +36,45 @@ def __init__(self, overwrite: bool = False): self.overwrite = overwrite self.cda_doc = None - def input(self, in_data: CdaRequest) -> Document: + def input(self, cda_request: CdaRequest) -> Document: """ - Parse the input CDA document and extract clinical data. + Parse the input CDA document and extract clinical data into a HealthChain Document object. - This method takes a CdaRequest object containing the CDA document as input, - parses it using the CdaAnnotator, and extracts relevant clinical data. - The extracted data is then used to create a CcdData object and a healthchain - Document object, which is returned. + This method takes a CdaRequest object as input, parses it, and extracts clinical data into a + FHIR Bundle. It creates two DocumentReference resources: + 1. The original CDA XML document + 2. The extracted note text from the CDA document + + The note text document is linked to the original CDA document through a relationship. + Continuity of Care Document data (problems, medications, allergies) are also extracted into FHIR resources + and added to the bundle. Args: - in_data (CdaRequest): The input request containing the CDA document. + cda_request (CdaRequest): Request object containing the CDA XML document to process. Returns: - Document: A Document object containing the extracted clinical data - and the original note text. + Document: A Document object containing: + - The extracted note text as the document data + - A FHIR Bundle with: + - DocumentReference for the original CDA XML + - DocumentReference for the extracted note text + - Extracted clinical data as FHIR resources (Condition, + MedicationStatement, AllergyIntolerance) + Note: + The note text is extracted from the CDA document's note section. If the note + is a dictionary of sections, they are joined with spaces. If no valid note + is found, an empty string is used. """ - self.cda_doc = CdaAnnotator.from_xml(in_data.document) + self.cda_doc = CdaAnnotator.from_xml(cda_request.document) + + # Create a FHIR DocumentReference for the original CDA document + cda_document_reference = create_document_reference( + data=cda_request.document, + content_type="text/xml", + description="Original CDA Document processed by HealthChain", + attachment_title="Original CDA document in XML format", + ) # TODO: Temporary fix for the note section, this might be more of a concern for the Annotator class if isinstance(self.cda_doc.note, dict): @@ -60,21 +85,35 @@ def input(self, in_data: CdaRequest) -> Document: log.warning("Note section is not a string or dictionary") note_text = "" - ccd_data = CcdData( - concepts=ConceptLists( - problems=self.cda_doc.problem_list, - medications=self.cda_doc.medication_list, - allergies=self.cda_doc.allergy_list, - ), - note=note_text, + # Create a FHIR DocumentReference for the note text + note_document_reference = create_document_reference( + data=note_text, + content_type="text/plain", + description="Text from note section of related CDA document extracted by HealthChain", + attachment_title="Note text from the related CDA document", + ) + + doc = Document(data=note_text) + + # Create FHIR Bundle and add documents + doc.fhir.set_bundle(create_bundle()) + doc.fhir.add_document_reference(cda_document_reference) + doc.fhir.add_document_reference( + note_document_reference, parent_id=cda_document_reference.id ) - doc = Document(data=ccd_data.note) - doc.hl7.set_ccd_data(ccd_data) + # Set lists with the correct FHIR resources + doc.fhir.problem_list = self.cda_doc.problem_list + doc.fhir.medication_list = self.cda_doc.medication_list + doc.fhir.allergy_list = self.cda_doc.allergy_list + + # Set the category for each problem in the problem list + for condition in doc.fhir.problem_list: + set_problem_list_item_category(condition) return doc - def output(self, out_data: Document) -> CdaResponse: + def output(self, document: Document) -> CdaResponse: """ Update the CDA document with new data and return the response. @@ -83,7 +122,7 @@ def output(self, out_data: Document) -> CdaResponse: CdaResponse object with the updated CDA document. Args: - out_data (Document): A Document object containing the updated + document (Document): A Document object containing the updated clinical data (problems, allergies, medications). Returns: @@ -95,31 +134,31 @@ def output(self, out_data: Document) -> CdaResponse: The update behavior (overwrite or append) is determined by the `overwrite` attribute of the CdaConnector instance. """ - # TODO: check what to do with overwrite - updated_ccd_data = out_data.generate_ccd(overwrite=self.overwrite) - - # Update the CDA document with the results - - if updated_ccd_data.concepts.problems: + # Update the CDA document with the results from FHIR Bundle + if document.fhir.problem_list: log.debug( - f"Updating CDA document with {len(updated_ccd_data.concepts.problems)} problem(s)." + f"Updating CDA document with {len(document.fhir.problem_list)} problem(s)." ) self.cda_doc.add_to_problem_list( - updated_ccd_data.concepts.problems, overwrite=self.overwrite + document.fhir.problem_list, overwrite=self.overwrite ) - if updated_ccd_data.concepts.allergies: + + # Update allergies + if document.fhir.allergy_list: log.debug( - f"Updating CDA document with {len(updated_ccd_data.concepts.allergies)} allergy(ies)." + f"Updating CDA document with {len(document.fhir.allergy_list)} allergy(ies)." ) self.cda_doc.add_to_allergy_list( - updated_ccd_data.concepts.allergies, overwrite=self.overwrite + document.fhir.allergy_list, overwrite=self.overwrite ) - if updated_ccd_data.concepts.medications: + + # Update medications + if document.fhir.medication_list: log.debug( - f"Updating CDA document with {len(updated_ccd_data.concepts.medications)} medication(s)." + f"Updating CDA document with {len(document.fhir.medication_list)} medication(s)." ) self.cda_doc.add_to_medication_list( - updated_ccd_data.concepts.medications, overwrite=self.overwrite + document.fhir.medication_list, overwrite=self.overwrite ) # Export the updated CDA document diff --git a/tests/pipeline/test_cdaconnector.py b/tests/pipeline/test_cdaconnector.py index cf390841..b9d22dc8 100644 --- a/tests/pipeline/test_cdaconnector.py +++ b/tests/pipeline/test_cdaconnector.py @@ -1,24 +1,22 @@ from unittest.mock import Mock, patch -from healthchain.io.containers.document import HL7Data -from healthchain.models.data.concept import ( - AllergyConcept, - ConceptLists, - MedicationConcept, - ProblemConcept, -) from healthchain.models.requests.cdarequest import CdaRequest from healthchain.models.responses.cdaresponse import CdaResponse -from healthchain.models.data.ccddata import CcdData from healthchain.io.containers import Document @patch("healthchain.io.cdaconnector.CdaAnnotator") -def test_input(mock_annotator_class, cda_connector): - # Create mock CDA document +def test_input( + mock_annotator_class, + cda_connector, + test_condition, + test_medication, + test_allergy, +): + # Create mock CDA document with FHIR resources mock_cda_doc = Mock() - mock_cda_doc.problem_list = [ProblemConcept(code="test")] - mock_cda_doc.medication_list = [MedicationConcept(code="test")] - mock_cda_doc.allergy_list = [AllergyConcept(code="test")] + mock_cda_doc.problem_list = [test_condition] + mock_cda_doc.medication_list = [test_medication] + mock_cda_doc.allergy_list = [test_allergy] mock_cda_doc.note = "Test note" # Set up the mock annotator @@ -30,43 +28,70 @@ def test_input(mock_annotator_class, cda_connector): assert isinstance(result, Document) assert result.data == "Test note" - assert isinstance(result._hl7._ccd_data, CcdData) - assert result._hl7._ccd_data.concepts.problems == [ProblemConcept(code="test")] - assert result._hl7._ccd_data.concepts.medications == [ - MedicationConcept(code="test") - ] - assert result._hl7._ccd_data.concepts.allergies == [AllergyConcept(code="test")] - assert result._hl7._ccd_data.note == "Test note" + # Verify documents in FHIR bundle + documents = result.fhir.get_document_references_readable() + assert len(documents) == 2 + # Verify original CDA document + assert ( + documents[0]["description"] == "Original CDA Document processed by HealthChain" + ) + assert documents[0]["attachments"][0]["data"] == "Test CDA" + assert documents[0]["attachments"][0]["metadata"]["content_type"] == "text/xml" + assert ( + documents[0]["attachments"][0]["metadata"]["title"] + == "Original CDA document in XML format" + ) + + # Verify extracted note document + assert ( + documents[1]["description"] + == "Text from note section of related CDA document extracted by HealthChain" + ) + assert documents[1]["attachments"][0]["data"] == "Test note" + assert documents[1]["attachments"][0]["metadata"]["content_type"] == "text/plain" + assert ( + documents[1]["attachments"][0]["metadata"]["title"] + == "Note text from the related CDA document" + ) + + # Verify document relationship + assert len(documents[1]["relationships"]["parents"]) == 1 + assert documents[1]["relationships"]["parents"][0]["id"] == documents[0]["id"] -def test_output(cda_connector): + # Verify FHIR resources + assert len(result.fhir.problem_list) == 1 + assert result.fhir.problem_list[0].code.coding[0].code == "123" + + assert len(result.fhir.medication_list) == 1 + assert result.fhir.medication_list[0].medication.concept.coding[0].code == "456" + + assert len(result.fhir.allergy_list) == 1 + assert result.fhir.allergy_list[0].code.coding[0].code == "789" + + +def test_output(cda_connector, test_condition, test_medication, test_allergy): cda_connector.cda_doc = Mock() cda_connector.cda_doc.export.return_value = "Updated CDA" - out_data = Document( - data="Updated note", - _hl7=HL7Data( - _ccd_data=CcdData( - concepts=ConceptLists( - problems=[ProblemConcept(code="New Problem")], - medications=[MedicationConcept(code="New Medication")], - allergies=[AllergyConcept(code="New Allergy")], - ), - note="Updated note", - ), - ), - ) + # Create a document with FHIR resources + out_data = Document(data="Updated note") + out_data.fhir.problem_list = [test_condition] + out_data.fhir.medication_list = [test_medication] + out_data.fhir.allergy_list = [test_allergy] result = cda_connector.output(out_data) assert isinstance(result, CdaResponse) assert result.document == "Updated CDA" + + # Verify that the CDA document was updated with FHIR resources cda_connector.cda_doc.add_to_problem_list.assert_called_once_with( - [ProblemConcept(code="New Problem")], overwrite=False + out_data.fhir.problem_list, overwrite=False ) cda_connector.cda_doc.add_to_allergy_list.assert_called_once_with( - [AllergyConcept(code="New Allergy")], overwrite=False + out_data.fhir.allergy_list, overwrite=False ) cda_connector.cda_doc.add_to_medication_list.assert_called_once_with( - [MedicationConcept(code="New Medication")], overwrite=False + out_data.fhir.medication_list, overwrite=False ) From d81420fe91e46a807e5c37dfb1f076d22add7c25 Mon Sep 17 00:00:00 2001 From: jenniferjiangkells Date: Wed, 19 Feb 2025 19:48:45 +0000 Subject: [PATCH 11/34] Migrate CdsFhirConnector to use FHIR natively --- healthchain/io/cdsfhirconnector.py | 104 ++++---- healthchain/io/containers/document.py | 46 ++-- .../pipeline/components/cdscardcreator.py | 2 +- tests/components/test_cardcreator.py | 6 +- tests/conftest.py | 226 +++++++++++------- tests/containers/test_fhir_data.py | 22 +- tests/pipeline/conftest.py | 131 +++++----- tests/pipeline/test_cdsfhirconnector.py | 92 ++++++- tests/pipeline/test_containers.py | 152 ------------ 9 files changed, 376 insertions(+), 405 deletions(-) delete mode 100644 tests/pipeline/test_containers.py diff --git a/healthchain/io/cdsfhirconnector.py b/healthchain/io/cdsfhirconnector.py index 6e799691..a2908746 100644 --- a/healthchain/io/cdsfhirconnector.py +++ b/healthchain/io/cdsfhirconnector.py @@ -1,10 +1,13 @@ import logging +from typing import Optional + +from fhir.resources.documentreference import DocumentReference from healthchain.io.containers import Document from healthchain.io.base import BaseConnector -from healthchain.models.data.cdsfhirdata import CdsFhirData from healthchain.models.requests.cdsrequest import CDSRequest from healthchain.models.responses.cdsresponse import CDSResponse +from healthchain.fhir import read_content_attachment log = logging.getLogger(__name__) @@ -25,83 +28,86 @@ class CdsFhirConnector(BaseConnector): def __init__(self, hook_name: str): self.hook_name = hook_name - def input(self, in_data: CDSRequest) -> Document: + def input( + self, cds_request: CDSRequest, prefetch_document_key: Optional[str] = "document" + ) -> Document: """ - Converts a CDSRequest object into a Document object containing FHIR resources. + Converts a CDSRequest object into a Document object. - This method takes a CDSRequest object as input, extracts the context and prefetch data, - and creates a CdsFhirData object. It then returns a Document object containing the FHIR data - and any extracted text content from DocumentReference resources. + Takes a CDSRequest containing FHIR resources and extracts them into a Document object. + The Document will contain all prefetched FHIR resources in its fhir.prefetch_resources. + If a DocumentReference resource is provided via prefetch_document_key, its text content + will be extracted into Document.data. Args: - in_data (CDSRequest): The input CDSRequest object containing context and prefetch data. + cds_request (CDSRequest): The CDSRequest containing FHIR resources in its prefetch + and/or a FHIR server URL. + prefetch_document_key (str, optional): Key in the prefetch data containing a + DocumentReference resource whose text content should be extracted. + Defaults to "document". Returns: - Document: A Document object with the following attributes: - - data: Either a string representation of the prefetch data, or if a DocumentReference - is present, the text content from that resource - - hl7: Contains the CdsFhirData object with context and prefetch data + Document: A Document object containing: + - All prefetched FHIR resources in fhir.prefetch_resources + - Any text content from the DocumentReference in data (empty string if none found) Raises: - ValueError: If neither prefetch nor fhirServer is provided in the input data - NotImplementedError: If fhirServer is provided (not yet implemented) - ValueError: If the provided prefetch data is invalid - - Note: - The method currently only supports prefetch data and does not handle FHIR server interactions. - When a DocumentReference resource is present in the prefetch data, its text content will be - extracted and stored as the Document's data field. + ValueError: If neither prefetch nor fhirServer is provided in cds_request + ValueError: If the prefetch data is invalid or cannot be processed + NotImplementedError: If fhirServer is provided (FHIR server support not implemented) """ - if in_data.prefetch is None and in_data.fhirServer is None: + if cds_request.prefetch is None and cds_request.fhirServer is None: raise ValueError( "Either prefetch or fhirServer must be provided to extract FHIR data!" ) - if in_data.fhirServer is not None: + if cds_request.fhirServer is not None: raise NotImplementedError("FHIR server is not implemented yet!") - try: - cds_fhir_data = CdsFhirData.create( - context=in_data.context.model_dump(), prefetch=in_data.prefetch - ) - except Exception as e: - raise ValueError("Invalid prefetch data provided: {e}!") from e - - doc = Document(data=str(cds_fhir_data.model_dump_prefetch())) + # Create an empty Document object + doc = Document(data="") - # Extract text from DocumentReference resources if present - for entry in cds_fhir_data.prefetch.entry_field: - if entry.resource_field.resourceType == "DocumentReference": - doc.data = entry.resource_field.text_field.div_field + # Set the prefetch resources + doc.fhir.set_prefetch_resources(cds_request.prefetch) - doc.hl7.set_fhir_data(cds_fhir_data) + # Extract text content from DocumentReference resource if provided + document_resource = cds_request.prefetch.get(prefetch_document_key) + if not document_resource: + log.warning( + f"No DocumentReference resource found in prefetch data with key {prefetch_document_key}" + ) + elif isinstance(document_resource, DocumentReference): + try: + attachments = read_content_attachment( + document_resource, include_data=True + ) + for attachment in attachments: + if len(attachments) > 1: + doc.data += attachment.get("data", "") + "\n" + else: + doc.data += attachment.get("data", "") + except Exception as e: + log.warning(f"Error extracting text from DocumentReference: {e}") return doc - def output(self, out_data: Document) -> CDSResponse: + def output(self, document: Document) -> CDSResponse: """ - Generates a CDSResponse object from a processed Document object. + Convert Document to CDSResponse. - This method takes a Document object that has been processed and potentially - contains CDS cards and system actions. It creates and returns a CDSResponse - object based on the contents of the Document. + This method takes a Document object containing CDS cards and actions, + and converts them into a CDSResponse object that follows the CDS Hooks + specification. Args: - out_data (Document): A Document object potentially containing CDS cards - and system actions. + document (Document): The Document object containing CDS results. Returns: CDSResponse: A response object containing CDS cards and optional system actions. If no cards are found in the Document, an empty list of cards is returned. - - Note: - - If out_data.cds_cards is None, a warning is logged and an empty list of cards is returned. - - System actions (out_data.cds_actions) are included in the response if present. """ - if out_data.cds.get_cards() is None: + if document.cds.cards is None: log.warning("No CDS cards found in Document, returning empty list of cards") return CDSResponse(cards=[]) - return CDSResponse( - cards=out_data.cds.get_cards(), systemActions=out_data.cds.get_actions() - ) + return CDSResponse(cards=document.cds.cards, systemActions=document.cds.actions) diff --git a/healthchain/io/containers/document.py b/healthchain/io/containers/document.py index 0e740955..34df6ad1 100644 --- a/healthchain/io/containers/document.py +++ b/healthchain/io/containers/document.py @@ -9,6 +9,7 @@ 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 healthchain.io.containers.base import BaseDocument from healthchain.models.responses import Action, Card @@ -201,20 +202,18 @@ def get_generated_text(self, source: str, task: str) -> List[str]: @dataclass class FhirData: """ - Container for FHIR resources and CDS Hooks context. + Container for FHIR resource data and its context. - This class stores and manages clinical data in FHIR format, including documents and their relationships, - with additional support for CDS Hooks context when needed. + Stores and manages clinical data in FHIR format. + Access document references within resources easily through convenience functions. - Also includes collections of resources for common continuity of care lists, such as a problem list, medication list, and allergy list. + Also allows you to set common continuity of care lists, + such as a problem list, medication list, and allergy list. These collections are accessible as properties of the class instance. - Attributes: - _bundle (Optional[Bundle]): FHIR Bundle containing clinical resources - like problems, medications, allergies, documents and other clinical information. - _cds_context (Optional[Dict]): Additional context required for CDS Hooks - integration. + _prefetch_resources (Optional[Dict[str, Resource]]): Resources specifically requested by CDS services + _bundle (Optional[Bundle]): Working bundle containing all other clinical resources Properties: problem_list: List[Condition] @@ -231,8 +230,8 @@ class FhirData: >>> documents = fhir_data.get_documents(include_data=True) """ + _prefetch_resources: Optional[Dict[str, Resource]] = None _bundle: Optional[Bundle] = None - _cds_context: Optional[Dict] = None @property def problem_list(self) -> List[Condition]: @@ -284,18 +283,18 @@ def set_bundle(self, bundle: Bundle): """ self._bundle = bundle - def get_cds_context(self) -> Optional[Dict]: - """Returns the CDS context if it exists.""" - return self._cds_context + def get_prefetch_resources(self, key: str) -> List[Any]: + """Get resources of a specific type from the prefetch bundle.""" + if not self._prefetch_resources: + return [] + return self._prefetch_resources.get(key, []) - def set_cds_context(self, context: Dict): - """Sets the CDS context.""" - self._cds_context = context + def set_prefetch_resources(self, prefetch_resources: Dict[str, Resource]): + """Sets the prefetch FHIR resources from CDS service requests.""" + self._prefetch_resources = prefetch_resources def get_resources(self, resource_type: str) -> List[Any]: - """ - Get resources of a specific type from the bundle. - """ + """Get resources of a specific type from the working bundle.""" if not self._bundle: return [] return get_resources(self._bundle, resource_type) @@ -303,14 +302,7 @@ def get_resources(self, resource_type: str) -> List[Any]: def add_resources( self, resources: List[Any], resource_type: str, replace: bool = False ): - """ - Add resources of a specific type to the bundle. - - Args: - resources: List of FHIR resources to add - resource_type: The FHIR resource type (e.g., "Condition", "MedicationStatement") - replace: If True, replace existing resources of this type. If False, append. - """ + """Add resources to the working bundle.""" if not self._bundle: self._bundle = create_bundle() set_resources(self._bundle, resources, resource_type, replace=replace) diff --git a/healthchain/pipeline/components/cdscardcreator.py b/healthchain/pipeline/components/cdscardcreator.py index 0dfeb422..64f33e05 100644 --- a/healthchain/pipeline/components/cdscardcreator.py +++ b/healthchain/pipeline/components/cdscardcreator.py @@ -186,6 +186,6 @@ def __call__(self, doc: Document) -> Document: logger.warning(f"Error creating card: {str(e)}") if cards: - doc.add_cds_cards(cards) + doc.cds.cards = cards return doc diff --git a/tests/components/test_cardcreator.py b/tests/components/test_cardcreator.py index 7932b7c8..c4b1624a 100644 --- a/tests/components/test_cardcreator.py +++ b/tests/components/test_cardcreator.py @@ -52,7 +52,7 @@ def test_document_processing_with_model_output(): ) processed_doc = creator(doc) - cards = processed_doc.cds.get_cards() + cards = processed_doc.cds.cards assert len(cards) == 1 assert cards[0].summary == "Model summary" @@ -64,7 +64,7 @@ def test_document_processing_with_static_content(): doc = Document(data="test") processed_doc = creator(doc) - cards = processed_doc.cds.get_cards() + cards = processed_doc.cds.cards assert len(cards) == 1 assert cards[0].summary == "Static content" @@ -77,7 +77,7 @@ def test_missing_model_output_warning(caplog): processed_doc = creator(doc) assert "No generated text for huggingface/missing_task found" in caplog.text - assert not processed_doc.cds.get_cards() + assert not processed_doc.cds.cards def test_invalid_input_configuration(): diff --git a/tests/conftest.py b/tests/conftest.py index 63378845..de366c3c 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -8,8 +8,9 @@ from healthchain.base import BaseStrategy, BaseUseCase from healthchain.cda_parser.cdaannotator import CdaAnnotator from fhir.resources.bundle import Bundle, BundleEntry -from healthchain.models import CDSRequest, CdsFhirData +from healthchain.models.data.cdsfhirdata import CdsFhirData from healthchain.models.requests.cdarequest import CdaRequest +from healthchain.models.requests.cdsrequest import CDSRequest from healthchain.models.responses.cdaresponse import CdaResponse from healthchain.models.responses.cdsresponse import CDSResponse, Card from healthchain.service.soap.epiccdsservice import CDSServices @@ -27,12 +28,19 @@ create_condition, create_medication_statement, create_allergy_intolerance, + create_single_attachment, + create_document_reference, ) +from fhir.resources.documentreference import DocumentReference, DocumentReferenceContent + # TODO: Tidy up fixtures +# FHIR resource fixtures + + @pytest.fixture def empty_bundle(): """Create an empty bundle for testing.""" @@ -76,32 +84,43 @@ def test_allergy_list(): return [test_allergy] -@pytest.fixture(autouse=True) -def setup_caplog(caplog): - caplog.set_level(logging.WARNING) - return caplog - - -class MockBundle(BaseModel): - condition: str = "test" - - -# TEMP -@dataclasses.dataclass -class synth_data: - context: dict - prefetch: MockBundle +@pytest.fixture +def doc_ref_with_content(): + """Create a DocumentReference with single text content.""" + return create_document_reference( + data="Test document content", + content_type="text/plain", + description="Test Description", + ) -class MockDataGenerator: - def __init__(self) -> None: - self.data = CdsFhirData( - context={}, prefetch=Bundle(entry=[BundleEntry()], type="document") +@pytest.fixture +def doc_ref_with_multiple_content(): + """Create a DocumentReference with multiple text content.""" + doc_ref = create_document_reference( + data="First content", + content_type="text/plain", + description="Test Description", + ) + doc_ref.content.append( + DocumentReferenceContent( + attachment=create_single_attachment( + data="Second content", content_type="text/plain" + ) ) - self.workflow = None + ) + return doc_ref - def set_workflow(self, workflow): - self.workflow = workflow + +@pytest.fixture +def doc_ref_without_content(): + """Create a DocumentReference without content for error testing.""" + return DocumentReference( + status="current", + content=[ + {"attachment": {"contentType": "text/plain"}} + ], # Missing required data + ) @pytest.fixture @@ -117,14 +136,6 @@ def test_document(test_problem_list, test_medication_list, test_allergy_list): return doc -@pytest.fixture -def test_document_with_cda(): - """Create a test document with CDA XML.""" - doc = Document(data="Test note") - doc.cda_xml = "Test CDA" - return doc - - @pytest.fixture def test_document_multiple(test_problem_list, test_medication_list, test_allergy_list): """Create a test document with multiple FHIR resources.""" @@ -152,6 +163,39 @@ def test_document_multiple(test_problem_list, test_medication_list, test_allergy return doc +@pytest.fixture(autouse=True) +def setup_caplog(caplog): + caplog.set_level(logging.WARNING) + return caplog + + +class MockBundle(BaseModel): + condition: str = "test" + + +# TEMP +@dataclasses.dataclass +class synth_data: + context: dict + prefetch: MockBundle + + +class MockDataGenerator: + def __init__(self) -> None: + self.data = CdsFhirData( + context={}, prefetch=Bundle(entry=[BundleEntry()], type="document") + ) + self.workflow = None + + def set_workflow(self, workflow): + self.workflow = workflow + + +@pytest.fixture +def cdsservices(): + return CDSServices() + + @pytest.fixture def cds_strategy(): return ClinicalDecisionSupportStrategy() @@ -228,63 +272,7 @@ class MockClinicalDecisionSupport(BaseUseCase): return MockClinicalDecisionSupport -@pytest.fixture -def test_cds_request(): - cds_dict = { - "hook": "patient-view", - "hookInstance": "29e93987-c345-4cb7-9a92-b5136289c2a4", - "context": {"userId": "Practitioner/123", "patientId": "123"}, - "prefetch": { - "resourceType": "Bundle", - "entry": [ - { - "resource": { - "resourceType": "Patient", - "id": "123", - "name": [{"family": "Doe", "given": ["John"]}], - "gender": "male", - "birthDate": "1970-01-01", - } - }, - ], - }, - } - return CDSRequest(**cds_dict) - - -@pytest.fixture -def test_cds_response_single_card(): - return CDSResponse( - cards=[ - Card( - summary="Test Card", - indicator="info", - source={"label": "Test Source"}, - detail="This is a test card for CDS response", - ) - ] - ) - - -@pytest.fixture -def test_cds_response_empty(): - return CDSResponse(cards=[]) - - -@pytest.fixture -def test_cds_response_multiple_cards(): - return CDSResponse( - cards=[ - Card( - summary="Test Card 1", indicator="info", source={"label": "Test Source"} - ), - Card( - summary="Test Card 2", - indicator="warning", - source={"label": "Test Source"}, - ), - ] - ) +# Sandbox fixtures @pytest.fixture @@ -455,6 +443,63 @@ def clindoc(): ) +# Test request and response fixtures + + +@pytest.fixture +def test_cds_request(): + cds_dict = { + "hook": "patient-view", + "hookInstance": "29e93987-c345-4cb7-9a92-b5136289c2a4", + "context": {"userId": "Practitioner/123", "patientId": "123"}, + "prefetch": { + "patient": { + "resourceType": "Patient", + "id": "123", + "name": [{"family": "Doe", "given": ["John"]}], + "gender": "male", + "birthDate": "1970-01-01", + }, + }, + } + return CDSRequest(**cds_dict) + + +@pytest.fixture +def test_cds_response_single_card(): + return CDSResponse( + cards=[ + Card( + summary="Test Card", + indicator="info", + source={"label": "Test Source"}, + detail="This is a test card for CDS response", + ) + ] + ) + + +@pytest.fixture +def test_cds_response_empty(): + return CDSResponse(cards=[]) + + +@pytest.fixture +def test_cds_response_multiple_cards(): + return CDSResponse( + cards=[ + Card( + summary="Test Card 1", indicator="info", source={"label": "Test Source"} + ), + Card( + summary="Test Card 2", + indicator="warning", + source={"label": "Test Source"}, + ), + ] + ) + + @pytest.fixture def test_cda_request(): with open("./tests/data/test_cda.xml", "r") as file: @@ -499,8 +544,3 @@ def cda_annotator_code(): with open("./tests/data/test_cda_without_template_id.xml", "r") as file: test_cda_without_template_id = file.read() return CdaAnnotator.from_xml(test_cda_without_template_id) - - -@pytest.fixture -def cdsservices(): - return CDSServices() diff --git a/tests/containers/test_fhir_data.py b/tests/containers/test_fhir_data.py index a9589153..948845a5 100644 --- a/tests/containers/test_fhir_data.py +++ b/tests/containers/test_fhir_data.py @@ -9,15 +9,6 @@ def test_bundle_operations(fhir_data, sample_bundle): assert fhir_data.get_bundle() == sample_bundle -def test_cds_context_operations(fhir_data): - """Test CDS context operations.""" - assert fhir_data.get_cds_context() is None - - test_context = {"hook": "patient-view", "hookInstance": "123"} - fhir_data.set_cds_context(test_context) - assert fhir_data.get_cds_context() == test_context - - def test_resource_operations(fhir_data): """Test adding and getting generic resources.""" # Create test conditions @@ -148,3 +139,16 @@ def test_relationship_metadata(fhir_data, sample_document_reference): == "http://hl7.org/fhir/ValueSet/document-relationship-type" ) assert child.relatesTo[0]["target"]["reference"] == f"DocumentReference/{doc_id}" + + +def test_multiple_document_attachments(fhir_data, doc_ref_with_multiple_content): + """Test handling documents with multiple attachments.""" + # Add and retrieve document + doc_id = fhir_data.add_document_reference(doc_ref_with_multiple_content) + docs = fhir_data.get_document_references_readable(include_data=True) + retrieved_doc = next(d for d in docs if d["id"] == doc_id) + + # Verify attachments + assert len(retrieved_doc["attachments"]) == 2 + assert retrieved_doc["attachments"][0]["data"] == "First content" + assert retrieved_doc["attachments"][1]["data"] == "Second content" diff --git a/tests/pipeline/conftest.py b/tests/pipeline/conftest.py index 12d326b4..b9120fa6 100644 --- a/tests/pipeline/conftest.py +++ b/tests/pipeline/conftest.py @@ -5,7 +5,7 @@ from healthchain.io.containers import Document from healthchain.io.containers.document import ( CdsAnnotations, - HL7Data, + FhirData, ModelOutputs, NlpAnnotations, ) @@ -19,10 +19,12 @@ from healthchain.models.responses.cdaresponse import CdaResponse from healthchain.pipeline.base import BasePipeline, ModelConfig, ModelSource from healthchain.models.responses.cdsresponse import CDSResponse, Card -from healthchain.models.data.cdsfhirdata import CdsFhirData from healthchain.pipeline.modelrouter import ModelRouter +# Basic object fixtures + + @pytest.fixture def cda_connector(): return CdaConnector() @@ -55,6 +57,9 @@ def mock_chain(): return chain +# Config fixtures + + @pytest.fixture def spacy_config(): return ModelConfig( @@ -78,6 +83,62 @@ def hf_config(): ) +# CDS component fixtures + + +@pytest.fixture +def mock_cds_card_creator(): + with patch("healthchain.pipeline.modelrouter.ModelRouter.get_component") as mock: + llm_instance = mock.return_value + llm_instance.return_value = Document( + data="Summarized discharge information", + _cds=CdsAnnotations( + _cards=[ + Card( + summary="Summarized discharge information", + detail="Patient John Doe was discharged. Encounter details...", + indicator="info", + source={"label": "Summarization LLM"}, + ) + ], + ), + ) + yield mock + + +@pytest.fixture +def mock_cds_fhir_connector(test_condition): + with patch("healthchain.io.cdsfhirconnector.CdsFhirConnector") as mock: + connector_instance = mock.return_value + + # Mock the input method + fhir_data = FhirData() + fhir_data.set_prefetch_resources({"problem": test_condition}) + + connector_instance.input.return_value = Document( + data="Original FHIR data", + _fhir=fhir_data, + ) + + # Mock the output method + connector_instance.output.return_value = CDSResponse( + cards=[ + Card( + summary="Summarized discharge information", + detail="Patient John Doe was discharged. Encounter details...", + indicator="info", + source={"label": "Summarization LLM"}, + ) + ] + ) + + yield mock + + +# CDA component fixtures + + +# TODO: UPDATE THESE @pytest.fixture def mock_cda_annotator(): with patch("healthchain.io.cdaconnector.CdaAnnotator") as mock: @@ -111,26 +172,6 @@ def mock_cda_annotator(): yield mock -@pytest.fixture -def mock_cds_card_creator(): - with patch("healthchain.pipeline.modelrouter.ModelRouter.get_component") as mock: - llm_instance = mock.return_value - llm_instance.return_value = Document( - data="Summarized discharge information", - _cds=CdsAnnotations( - _cards=[ - Card( - summary="Summarized discharge information", - detail="Patient John Doe was discharged. Encounter details...", - indicator="info", - source={"label": "Summarization LLM"}, - ) - ], - ), - ) - yield mock - - @pytest.fixture def mock_cda_connector(): with patch("healthchain.io.cdaconnector.CdaConnector") as mock: @@ -139,7 +180,7 @@ def mock_cda_connector(): # Mock the input method connector_instance.input.return_value = Document( data="Original note", - _hl7=HL7Data( + _fhir=FhirData( _ccd_data=CcdData( concepts=ConceptLists( problems=[ @@ -179,50 +220,10 @@ def mock_cda_connector(): yield mock -@pytest.fixture -def mock_cds_fhir_connector(): - with patch("healthchain.io.cdsfhirconnector.CdsFhirConnector") as mock: - connector_instance = mock.return_value - - # Mock the input method - connector_instance.input.return_value = Document( - data="Original FHIR data", - _hl7=HL7Data( - _fhir_data=CdsFhirData( - context={"patientId": "123", "encounterId": "456"}, - prefetch={ - "resourceType": "Bundle", - "entry": [ - { - "resource": { - "resourceType": "Patient", - "id": "123", - "name": [{"family": "Doe", "given": ["John"]}], - "gender": "male", - "birthDate": "1970-01-01", - } - }, - ], - }, - ), - ), - ) - - # Mock the output method - connector_instance.output.return_value = CDSResponse( - cards=[ - Card( - summary="Summarized discharge information", - detail="Patient John Doe was discharged. Encounter details...", - indicator="info", - source={"label": "Summarization LLM"}, - ) - ] - ) - - yield mock +# NLP component fixtures +# TODO: UPDATE THIS @pytest.fixture def mock_spacy_nlp(): with patch("healthchain.pipeline.components.integrations.SpacyNLP") as mock: diff --git a/tests/pipeline/test_cdsfhirconnector.py b/tests/pipeline/test_cdsfhirconnector.py index 195b6ef2..030fc818 100644 --- a/tests/pipeline/test_cdsfhirconnector.py +++ b/tests/pipeline/test_cdsfhirconnector.py @@ -3,10 +3,9 @@ from healthchain.io.containers import Document from healthchain.io.containers.document import CdsAnnotations from healthchain.models.responses.cdsresponse import Action, CDSResponse, Card -from healthchain.models.data.cdsfhirdata import CdsFhirData -def test_input_with_valid_prefetch(cds_fhir_connector, test_cds_request): +def test_input_with_no_document_reference(cds_fhir_connector, test_cds_request): # Use the valid prefetch data from test_cds_request input_data = test_cds_request @@ -15,10 +14,91 @@ def test_input_with_valid_prefetch(cds_fhir_connector, test_cds_request): # Assert the result assert isinstance(result, Document) - assert result.data == str(input_data.prefetch) - assert isinstance(result._hl7._fhir_data, CdsFhirData) - assert result._hl7._fhir_data.context == input_data.context.model_dump() - assert result._hl7._fhir_data.model_dump_prefetch() == input_data.prefetch + assert ( + result.data == "" + ) # Data should be empty since no DocumentReference is provided + assert result._fhir._prefetch_resources == input_data.prefetch + + +def test_input_with_document_reference( + cds_fhir_connector, test_cds_request, doc_ref_with_content +): + # Add DocumentReference to prefetch data + test_cds_request.prefetch["document"] = doc_ref_with_content + + # Call the input method + result = cds_fhir_connector.input(test_cds_request) + + # Assert the result + assert isinstance(result, Document) + assert result.data == "Test document content" + assert result._fhir._prefetch_resources == test_cds_request.prefetch + + +def test_input_with_multiple_attachments( + cds_fhir_connector, test_cds_request, doc_ref_with_multiple_content +): + # Add DocumentReference to prefetch data + test_cds_request.prefetch["document"] = doc_ref_with_multiple_content + + # Call the input method + result = cds_fhir_connector.input(test_cds_request) + + # Assert the result + assert isinstance(result, Document) + assert ( + result.data == "First content\nSecond content\n" + ) # Attachments should be concatenated + assert result._fhir._prefetch_resources == test_cds_request.prefetch + + +def test_input_with_custom_document_key( + cds_fhir_connector, test_cds_request, doc_ref_with_content +): + # Add DocumentReference to prefetch data with custom key + test_cds_request.prefetch["custom_key"] = doc_ref_with_content + + # Call the input method with custom key + result = cds_fhir_connector.input( + test_cds_request, prefetch_document_key="custom_key" + ) + + # Assert the result + assert isinstance(result, Document) + assert result.data == "Test document content" + assert result._fhir._prefetch_resources == test_cds_request.prefetch + + +def test_input_with_document_reference_error( + cds_fhir_connector, test_cds_request, doc_ref_without_content, caplog +): + # Add invalid DocumentReference to prefetch data + test_cds_request.prefetch["document"] = doc_ref_without_content + + # Call the input method + result = cds_fhir_connector.input(test_cds_request) + + # Assert the result + assert isinstance(result, Document) + assert result.data == "" # Should be empty due to error + assert "Error extracting text from DocumentReference" in caplog.text + + +def test_input_with_missing_document_reference( + cds_fhir_connector, test_cds_request, caplog +): + # Call the input method (document key doesn't exist in prefetch) + result = cds_fhir_connector.input( + test_cds_request, prefetch_document_key="nonexistent" + ) + + # Assert the result + assert isinstance(result, Document) + assert result.data == "" + assert ( + "No DocumentReference resource found in prefetch data with key nonexistent" + in caplog.text + ) def test_output_with_cards(cds_fhir_connector): diff --git a/tests/pipeline/test_containers.py b/tests/pipeline/test_containers.py deleted file mode 100644 index f149e61e..00000000 --- a/tests/pipeline/test_containers.py +++ /dev/null @@ -1,152 +0,0 @@ -import pytest -from healthchain.io.containers import Document -from healthchain.models.responses import Card, Action -from healthchain.models.data import CcdData -from healthchain.models.data.concept import ( - ProblemConcept, - MedicationConcept, - AllergyConcept, -) - - -@pytest.fixture -def sample_document(): - return Document(data="This is a sample text for testing.") - - -def test_document_initialization(sample_document): - assert sample_document.data == "This is a sample text for testing." - assert sample_document.text == "This is a sample text for testing." - assert sample_document.nlp.get_tokens() == [ - "This", - "is", - "a", - "sample", - "text", - "for", - "testing.", - ] - assert sample_document.nlp.get_entities() == [] - assert sample_document.nlp.get_embeddings() is None - - -def test_document_add_concepts(sample_document): - problems = [ProblemConcept(display_name="Hypertension")] - medications = [MedicationConcept(display_name="Aspirin")] - allergies = [AllergyConcept(display_name="Penicillin")] - - sample_document.add_concepts( - problems=problems, medications=medications, allergies=allergies - ) - - assert sample_document.concepts.problems == problems - assert sample_document.concepts.medications == medications - assert sample_document.concepts.allergies == allergies - - -def test_document_generate_ccd(sample_document): - problems = [ProblemConcept(display_name="Hypertension")] - sample_document.add_concepts(problems=problems) - - ccd_data = sample_document.generate_ccd() - assert isinstance(ccd_data, CcdData) - assert ccd_data.concepts.problems == problems - - -def test_document_add_cds_cards(sample_document): - cards = [ - { - "summary": "Test Card", - "detail": "Test Detail", - "indicator": "info", - "source": {"label": "Test Source"}, - } - ] - sample_document.add_cds_cards(cards) - - assert isinstance(sample_document.cds.get_cards()[0], Card) - assert sample_document.cds.get_cards()[0].summary == "Test Card" - - -def test_document_add_cds_actions(sample_document): - actions = [ - {"type": "create", "description": "Test Action", "resource": {"test": "test"}} - ] - sample_document.add_cds_actions(actions) - - assert isinstance(sample_document.cds.get_actions()[0], Action) - assert sample_document.cds.get_actions()[0].description == "Test Action" - - -def test_document_add_huggingface_output(sample_document): - mock_output = [ - {"label": "POSITIVE", "score": 0.9, "generated_text": "Generated response"} - ] - - sample_document.models.add_output("huggingface", "sentiment", mock_output) - - assert sample_document.models.get_output("huggingface", "sentiment") == mock_output - assert sample_document.models.get_generated_text("huggingface", "sentiment") == [ - "Generated response" - ] - - mock_output_chat = [ - { - "generated_text": [ - { - "role": "user", - "content": "What is the capital of France? Answer in one word.", - }, - {"role": "assistant", "content": "Paris"}, - ] - } - ] - sample_document.models.add_output("huggingface", "chat", mock_output_chat) - assert sample_document.models.get_output("huggingface", "chat") == mock_output_chat - assert sample_document.models.get_generated_text("huggingface", "chat") == [ - "Paris", - ] - - -def test_document_add_langchain_output(sample_document): - mock_output = "Summarized text" - sample_document.models.add_output("langchain", "summarization", mock_output) - - assert ( - sample_document.models.get_output("langchain", "summarization") == mock_output - ) - assert sample_document.models.get_generated_text("langchain", "summarization") == [ - mock_output - ] - - mock_output_json = {"test": "test"} - sample_document.models.add_output("langchain", "json", mock_output_json) - assert sample_document.models.get_generated_text("langchain", "json") == [ - '{"test": "test"}' - ] - - -def test_document_embeddings(sample_document): - embeddings = [0.1, 0.2, 0.3] - sample_document.nlp.set_embeddings(embeddings) - assert sample_document.nlp.get_embeddings() == embeddings - - -def test_document_iteration(sample_document): - assert list(sample_document) == [ - "This", - "is", - "a", - "sample", - "text", - "for", - "testing.", - ] - - -def test_document_length(sample_document): - assert len(sample_document) == 34 - - -def test_document_word_count(sample_document): - assert sample_document.word_count() == 7 From caf1e8352cb71a35407ed31708b46d6ab75c891e Mon Sep 17 00:00:00 2001 From: jenniferjiangkells Date: Wed, 19 Feb 2025 19:50:01 +0000 Subject: [PATCH 12/34] Add encounterId to context model --- healthchain/models/hooks/basehookcontext.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/healthchain/models/hooks/basehookcontext.py b/healthchain/models/hooks/basehookcontext.py index 72995a4b..9f732c2a 100644 --- a/healthchain/models/hooks/basehookcontext.py +++ b/healthchain/models/hooks/basehookcontext.py @@ -1,7 +1,9 @@ from pydantic import BaseModel +from typing import Optional from abc import ABC class BaseHookContext(BaseModel, ABC): userId: str patientId: str + encounterId: Optional[str] = None From 30b8390f2f675ef90314c4e2ec2d29d4282e1020 Mon Sep 17 00:00:00 2001 From: jenniferjiangkells Date: Thu, 20 Feb 2025 13:16:02 +0000 Subject: [PATCH 13/34] Updated sandbox usage --- healthchain/use_cases/cds.py | 36 ++++--- healthchain/use_cases/clindoc.py | 35 ++++--- tests/conftest.py | 28 ++--- tests/test_strategy.py | 173 ++++++++++++++++++++++--------- 4 files changed, 181 insertions(+), 91 deletions(-) diff --git a/healthchain/use_cases/cds.py b/healthchain/use_cases/cds.py index 07cff1d7..b8801f7a 100644 --- a/healthchain/use_cases/cds.py +++ b/healthchain/use_cases/cds.py @@ -3,8 +3,9 @@ from typing import Dict, Optional +from fhir.resources.resource import Resource + from healthchain.service import Service -from healthchain.models import CdsFhirData from healthchain.service.endpoints import Endpoint, ApiProtocol from healthchain.base import BaseUseCase, BaseStrategy, BaseClient from healthchain.apimethod import APIMethod @@ -46,37 +47,48 @@ def __init__(self) -> None: } @validate_workflow(UseCaseMapping.ClinicalDecisionSupport) - def construct_request(self, data: CdsFhirData, workflow: Workflow) -> CDSRequest: + def construct_request( + self, + prefetch_data: Dict[str, Resource], + workflow: Workflow, + context: Optional[Dict[str, str]] = {}, + ) -> CDSRequest: """ Constructs a HL7-compliant CDS request based on workflow. Parameters: - data: FHIR data to be injected in request. - workflow (Workflow): The CDS hook name, e.g. patient-view. + prefetch_data (Dict[str, Resource]): Dictionary mapping prefetch keys to FHIR resources + workflow (Workflow): The CDS hook name, e.g. patient-view + context (Optional[Dict[str, str]]): Optional context data for the CDS hook Returns: - CDSRequest: A Pydantic model that wraps a CDS request for REST + CDSRequest: A Pydantic model that wraps a CDS request for REST API Raises: - ValueError: If the workflow is invalid or the data does not validate properly. + ValueError: If the workflow is invalid or not implemented + TypeError: If any prefetch value is not a valid FHIR resource """ - log.debug(f"Constructing CDS request for {workflow.value} from {data}") + log.debug(f"Constructing CDS request for {workflow.value} from {prefetch_data}") context_model = self.context_mapping.get(workflow, None) if context_model is None: raise ValueError( f"Invalid workflow {workflow.value} or workflow model not implemented." ) - if not isinstance(data, CdsFhirData): + if not isinstance(prefetch_data, dict): raise TypeError( - f"CDS clients must return data of type CdsFhirData, not {type(data)}" + f"Prefetch data must be a dictionary, but got {type(prefetch_data)}" ) + for key, value in prefetch_data.items(): + if not isinstance(value, Resource): + raise TypeError( + f"Prefetch value {key} is not a valid FHIR resource, but {type(value)}" + ) - # i feel like theres a better way to do this request = CDSRequest( hook=workflow.value, - context=context_model(**data.context), - prefetch=data.model_dump_prefetch(), + context=context_model(**context), + prefetch=prefetch_data, ) return request diff --git a/healthchain/use_cases/clindoc.py b/healthchain/use_cases/clindoc.py index dda2c60c..26a44473 100644 --- a/healthchain/use_cases/clindoc.py +++ b/healthchain/use_cases/clindoc.py @@ -2,10 +2,11 @@ import logging import pkgutil import xmltodict -import base64 from typing import Dict, Optional +from fhir.resources.documentreference import DocumentReference + from healthchain.base import BaseClient, BaseUseCase, BaseStrategy from healthchain.service import Service from healthchain.service.endpoints import Endpoint, ApiProtocol @@ -16,7 +17,7 @@ Workflow, validate_workflow, ) -from healthchain.models import CdaRequest, CdaResponse, CcdData +from healthchain.models import CdaRequest, CdaResponse from healthchain.apimethod import APIMethod @@ -38,37 +39,41 @@ def _load_soap_envelope(self): def construct_cda_xml_document(self): """ - This function should wrap FHIR data from CcdFhirData into a template CDA file (dep. vendor + This function should wrap FHIR resources from Document into a template CDA file TODO: implement this function """ - pass + raise NotImplementedError("This function is not implemented yet.") @validate_workflow(UseCaseMapping.ClinicalDocumentation) - def construct_request(self, data: CcdData, workflow: Workflow) -> CdaRequest: + def construct_request( + self, document_reference: DocumentReference, workflow: Workflow + ) -> CdaRequest: """ Constructs a CDA request for clinical documentation use cases (NoteReader) Parameters: - data: CDA data to be injected in the request + document_reference (DocumentReference): FHIR DocumentReference containing CDA XML data workflow (Workflow): The NoteReader workflow type, e.g. notereader-sign-inpatient Returns: - CdaRequest: A Pydantic model that wraps CDA data for SOAP request + CdaRequest: A Pydantic model containing the CDA XML wrapped in a SOAP envelope Raises: - ValueError: If the workflow is invalid or the data does not validate properly. + ValueError: If the SOAP envelope template is invalid or missing required keys """ - # TODO: handle converting fhir data from data generator to cda # TODO: handle different workflows - if data.cda_xml is not None: - # Encode the cda xml in base64 - encoded_xml = base64.b64encode(data.cda_xml.encode("utf-8")).decode("utf-8") + cda_xml = None + for content in document_reference.content: + if content.attachment.contentType == "text/xml": + cda_xml = content.attachment.data + break + if cda_xml is not None: # Make a copy of the SOAP envelope template soap_envelope = self.soap_envelope.copy() # Insert encoded cda in the Document section - if not insert_at_key(soap_envelope, "urn:Document", encoded_xml): + if not insert_at_key(soap_envelope, "urn:Document", cda_xml): raise ValueError( "Key 'urn:Document' missing from SOAP envelope template!" ) @@ -76,9 +81,7 @@ def construct_request(self, data: CcdData, workflow: Workflow) -> CdaRequest: return request else: - log.warning( - "Data generation methods for CDA documents not implemented yet!" - ) + log.warning("No CDA document found in the DocumentReference!") class ClinicalDocumentation(BaseUseCase): diff --git a/tests/conftest.py b/tests/conftest.py index de366c3c..b3248b8d 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -112,6 +112,15 @@ def doc_ref_with_multiple_content(): return doc_ref +@pytest.fixture +def doc_ref_with_cda_xml(): + """Create a DocumentReference with CDA XML content.""" + return create_document_reference( + data="", + content_type="text/xml", + ) + + @pytest.fixture def doc_ref_without_content(): """Create a DocumentReference without content for error testing.""" @@ -202,19 +211,12 @@ def cds_strategy(): @pytest.fixture -def valid_data(): - return CdsFhirData( - context={"userId": "Practitioner/123", "patientId": "123"}, - prefetch=Bundle(entry=[BundleEntry()]), - ) - - -@pytest.fixture -def invalid_data(): - return CdsFhirData( - context={"invalidId": "Practitioner", "patientId": "123"}, - prefetch=Bundle(entry=[BundleEntry()]), - ) +def valid_prefetch_data(): + return { + "document": create_document_reference( + content_type="text/plain", data="Test document content" + ) + } @pytest.fixture diff --git a/tests/test_strategy.py b/tests/test_strategy.py index 61946b17..c6cc663b 100644 --- a/tests/test_strategy.py +++ b/tests/test_strategy.py @@ -3,33 +3,73 @@ from unittest.mock import patch, MagicMock from healthchain.workflows import Workflow from healthchain.models import CDSRequest -from healthchain.models.hooks import PatientViewContext -from healthchain.models import CcdData, CdaRequest +from healthchain.models.hooks import ( + PatientViewContext, + OrderSelectContext, + OrderSignContext, + EncounterDischargeContext, +) +from healthchain.models import CdaRequest from healthchain.use_cases.clindoc import ClinicalDocumentationStrategy +from healthchain.service.endpoints import ApiProtocol + + +def test_strategy_configuration(cds_strategy): + """Test basic strategy configuration.""" + # Test API protocol + assert cds_strategy.api_protocol == ApiProtocol.rest + + # Test context mapping completeness + expected_mappings = { + Workflow.order_select: OrderSelectContext, + Workflow.order_sign: OrderSignContext, + Workflow.patient_view: PatientViewContext, + Workflow.encounter_discharge: EncounterDischargeContext, + } + assert cds_strategy.context_mapping == expected_mappings + assert all( + workflow in cds_strategy.context_mapping for workflow in expected_mappings + ) -def test_valid_data_request_construction(cds_strategy, valid_data): +def test_valid_request_construction(cds_strategy, valid_prefetch_data): + """Test construction of valid requests with different context types.""" + # Test PatientViewContext with patch.object(CDSRequest, "__init__", return_value=None) as mock_init: - cds_strategy.construct_request(valid_data, Workflow.patient_view) + cds_strategy.construct_request( + prefetch_data=valid_prefetch_data, + workflow=Workflow.patient_view, + context={"userId": "Practitioner/123", "patientId": "123"}, + ) mock_init.assert_called_once_with( hook=Workflow.patient_view.value, context=PatientViewContext(userId="Practitioner/123", patientId="123"), - prefetch={"entry": [{}]}, + prefetch=valid_prefetch_data, ) - -def test_invalid_data_raises_error(cds_strategy, invalid_data): - with pytest.raises(ValueError): - # incorrect keys passed in - cds_strategy.construct_request(invalid_data, Workflow.patient_view) - - with pytest.raises(ValueError): - # correct keys but invalid data - invalid_data.context = {"userId": "Practitioner"} - cds_strategy.construct_request(invalid_data, Workflow.patient_view) + # # Test OrderSelectContext + # order_select_result = cds_strategy.construct_request( + # prefetch_data=valid_prefetch_data, + # workflow=Workflow.order_select, + # context={"userId": "Practitioner/123", "patientId": "123", "selections": []}, + # ) + # assert isinstance(order_select_result.context, OrderSelectContext) + + # Test EncounterDischargeContext + discharge_result = cds_strategy.construct_request( + prefetch_data=valid_prefetch_data, + workflow=Workflow.encounter_discharge, + context={ + "userId": "Practitioner/123", + "patientId": "123", + "encounterId": "456", + }, + ) + assert isinstance(discharge_result.context, EncounterDischargeContext) -def test_context_mapping(cds_strategy, valid_data): +def test_context_mapping_behavior(cds_strategy, valid_prefetch_data): + """Test context mapping functionality.""" with patch.dict( cds_strategy.context_mapping, { @@ -41,57 +81,90 @@ def test_context_mapping(cds_strategy, valid_data): ) }, ): - cds_strategy.construct_request(data=valid_data, workflow=Workflow.patient_view) + cds_strategy.construct_request( + prefetch_data=valid_prefetch_data, + workflow=Workflow.patient_view, + context={"userId": "Practitioner/123", "patientId": "123"}, + ) cds_strategy.context_mapping[Workflow.patient_view].assert_called_once_with( - **valid_data.context + userId="Practitioner/123", patientId="123" ) -def test_workflow_validation_decorator(cds_strategy, valid_data): - with pytest.raises(ValueError) as excinfo: - cds_strategy.construct_request(Workflow.sign_note_inpatient, valid_data) - assert "Invalid workflow" in str(excinfo.value) - - with pytest.raises(ValueError) as excinfo: +def test_error_handling(cds_strategy, valid_prefetch_data): + """Test various error conditions in request construction.""" + # Test invalid context keys + with pytest.raises(ValueError): cds_strategy.construct_request( - data=valid_data, workflow=Workflow.sign_note_inpatient + prefetch_data={}, + workflow=Workflow.patient_view, + context={"invalidId": "Practitioner", "patientId": "123"}, ) - assert "Invalid workflow" in str(excinfo.value) - - assert cds_strategy.construct_request(valid_data, Workflow.patient_view) - - -def test_construct_request_with_cda_xml(): - strategy = ClinicalDocumentationStrategy() - data = CcdData(cda_xml="") - workflow = Workflow.sign_note_inpatient - request = strategy.construct_request(data, workflow) + # Test missing required context data + with pytest.raises(ValueError): + cds_strategy.construct_request( + prefetch_data={}, + workflow=Workflow.patient_view, + context={"userId": "Practitioner"}, + ) - assert isinstance(request, CdaRequest) - assert ( - request.document - == '\n0000-0000-0000-00002UCLHPENEQSBYTUw+' - ) + # Test invalid prefetch data type + invalid_prefetch = {"patient": {"id": "123"}} # Not a FHIR Resource + with pytest.raises(TypeError) as excinfo: + cds_strategy.construct_request( + prefetch_data=invalid_prefetch, + workflow=Workflow.patient_view, + context={"userId": "Practitioner/123", "patientId": "123"}, + ) + assert "not a valid FHIR resource" in str(excinfo.value) + # Test unsupported workflow + mock_workflow = MagicMock() + mock_workflow.value = "unsupported-workflow" + with pytest.raises(ValueError) as excinfo: + cds_strategy.construct_request( + prefetch_data=valid_prefetch_data, + workflow=mock_workflow, + context={"userId": "Practitioner/123", "patientId": "123"}, + ) + assert "Invalid workflow" in str(excinfo.value) -def test_construct_request_without_cda_xml(caplog): - strategy = ClinicalDocumentationStrategy() - data = CcdData() - workflow = Workflow.sign_note_inpatient - strategy.construct_request(data, workflow) +def test_workflow_validation(cds_strategy, valid_prefetch_data): + """Test workflow validation decorator behavior.""" + # Test invalid workflow + with pytest.raises(ValueError) as excinfo: + cds_strategy.construct_request( + prefetch_data=valid_prefetch_data, + workflow=Workflow.sign_note_inpatient, + context={"userId": "Practitioner/123", "patientId": "123"}, + ) + assert "Invalid workflow" in str(excinfo.value) - assert ( - "Data generation methods for CDA documents not implemented yet!" in caplog.text + # Test valid workflow + result = cds_strategy.construct_request( + prefetch_data={}, + workflow=Workflow.patient_view, + context={"userId": "Practitioner/123", "patientId": "123"}, ) + assert isinstance(result, CDSRequest) + assert result.prefetch == {} -def test_construct_request(test_ccd_data): +def test_cda_request_construction( + doc_ref_with_cda_xml, doc_ref_with_multiple_content, caplog +): + """Test CDA-specific request construction.""" strategy = ClinicalDocumentationStrategy() - data = CcdData(cda_xml="") workflow = Workflow.sign_note_inpatient - request = strategy.construct_request(data, workflow) + # Test with valid CDA XML + request = strategy.construct_request(doc_ref_with_cda_xml, workflow) assert isinstance(request, CdaRequest) + assert request.document is not None assert "urn:Document" in request.document + + # Test with non-CDA content + strategy.construct_request(doc_ref_with_multiple_content, workflow) + assert "No CDA document found in the DocumentReference!" in caplog.text From 881a2ad694ab9090f78009be2fbbb21973d11817 Mon Sep 17 00:00:00 2001 From: jenniferjiangkells Date: Thu, 20 Feb 2025 18:40:09 +0000 Subject: [PATCH 14/34] Finish CdaAnnotator FHIR migration --- healthchain/cda_parser/cdaannotator.py | 655 ++++++++++++++----------- healthchain/cda_parser/utils.py | 70 ++- healthchain/fhir/__init__.py | 2 + healthchain/fhir/helpers.py | 16 +- tests/conftest.py | 38 +- tests/test_cdaannotator.py | 585 +++++++++------------- 6 files changed, 706 insertions(+), 660 deletions(-) diff --git a/healthchain/cda_parser/cdaannotator.py b/healthchain/cda_parser/cdaannotator.py index 0c35a037..1411be13 100644 --- a/healthchain/cda_parser/cdaannotator.py +++ b/healthchain/cda_parser/cdaannotator.py @@ -10,57 +10,56 @@ 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.coding import Coding from healthchain.cda_parser.model.datatypes import CD, CE, IVL_PQ -from healthchain.models import ( - MedicationConcept, - AllergyConcept, -) -from healthchain.models.data.concept import Concept, Quantity, Range, TimeInterval - from healthchain.cda_parser.model.cda import ClinicalDocument from healthchain.cda_parser.model.sections import ( Entry, Section, EntryRelationship, Observation, - SubstanceAdministration, ) +from fhir.resources.dosage import Dosage from healthchain.cda_parser.utils import CodeMapping - +from healthchain.fhir import ( + create_condition, + create_allergy_intolerance, + create_medication_statement, + create_single_codeable_concept, + set_problem_list_item_category, + create_single_reaction, +) log = logging.getLogger(__name__) -def get_time_range_from_cda_value(value: Dict) -> Range: - """ - Converts a dictionary representing a time range from a CDA value into a Range object. +# def get_time_range_from_cda_value(value: Dict) -> Range: +# """ +# Converts a dictionary representing a time range from a CDA value into a Range object. - Args: - value (Dict): A dictionary representing the CDA value. +# Args: +# value (Dict): A dictionary representing the CDA value. - Returns: - Range: A Range object representing the time range. +# Returns: +# Range: A Range object representing the time range. - """ - range_model = Range( - low=Quantity( - value=value.get("low", {}).get("@value"), - unit=value.get("low", {}).get("@unit"), - ), - high=Quantity( - value=value.get("high", {}).get("@value"), - unit=value.get("high", {}).get("@unit"), - ), - ) - if range_model.low.value is None: - range_model.low = None - if range_model.high.value is None: - range_model.high = None - - return range_model +# """ +# range_model = Range( +# low=Quantity( +# value=value.get("low", {}).get("@value"), +# unit=value.get("low", {}).get("@unit"), +# ), +# high=Quantity( +# value=value.get("high", {}).get("@value"), +# unit=value.get("high", {}).get("@unit"), +# ), +# ) +# if range_model.low.value is None: +# range_model.low = None +# if range_model.high.value is None: +# range_model.high = None + +# return range_model def get_value_from_entry_relationship(entry_relationship: EntryRelationship) -> List: @@ -352,10 +351,18 @@ def _find_notes_section(self) -> Optional[Section]: def _extract_problems(self) -> List[Condition]: """ - Extracts problems from the CDA document and converts them to FHIR Condition resources. + Extracts problems from the CDA document's problem section and converts them to FHIR Condition resources. + + The method processes each problem entry in the CDA document and: + - Maps CDA status codes to FHIR clinical status + - Extracts onset and abatement dates + - Creates FHIR Condition resources with appropriate coding + - Sets problem list item category + - Handles both single entries and lists of entries Returns: - A list of FHIR Condition resources representing the extracted problems. + List[Condition]: A list of FHIR Condition resources representing the extracted problems. + Returns empty list if problem section is not found. """ if not self._problem_section: log.warning("Empty problem section!") @@ -365,73 +372,47 @@ def _extract_problems(self) -> List[Condition]: def create_fhir_condition_from_cda(value: Dict, entry) -> Condition: # Map CDA status to FHIR clinical status + status = "unknown" if hasattr(entry, "act") and hasattr(entry.act, "statusCode"): status_code = entry.act.statusCode.code - fhir_status = self.code_mapping.cda_to_fhir( + status = self.code_mapping.cda_to_fhir( status_code, "status", case_sensitive=False, default="unknown" ) - clinical_status = CodeableConcept( - coding=[ - Coding( - system="http://terminology.hl7.org/CodeSystem/condition-clinical", - code=fhir_status, - display=fhir_status.capitalize(), - ) - ] - ) - - # Create base condition with mapped system - condition = Condition( - clinicalStatus=clinical_status, - subject={ - "reference": "Patient/123" # {self.clinical_document.recordTarget.patientRole.id} # TODO: add patient reference - }, - code=CodeableConcept( - coding=[ - Coding( - system=self.code_mapping.cda_to_fhir( - value.get("@codeSystem"), "system" - ), - code=value.get("@code"), - display=value.get("@displayName"), - ) - ] - ), - ) # Extract dates from entry - # TODO: utility function for this + onset_date = None + abatement_date = None if hasattr(entry, "act") and hasattr(entry.act, "effectiveTime"): effective_time = entry.act.effectiveTime if hasattr(effective_time, "low") and effective_time.low: - # Convert CDA date format (YYYYMMDD) to FHIR date format (YYYY-MM-DD) - date_str = effective_time.low.value - if date_str: - formatted_date = ( - f"{date_str[:4]}-{date_str[4:6]}-{date_str[6:8]}" - ) - condition.onsetDateTime = formatted_date + onset_date = CodeMapping.convert_date_cda_to_fhir( + effective_time.low.value + ) if hasattr(effective_time, "high") and effective_time.high: - date_str = effective_time.high.value - if date_str: - formatted_date = ( - f"{date_str[:4]}-{date_str[4:6]}-{date_str[6:8]}" - ) - condition.abatementDateTime = formatted_date + abatement_date = CodeMapping.convert_date_cda_to_fhir( + effective_time.high.value + ) + + # Create condition using helper function + condition = create_condition( + subject="Patient/123", # TODO: add patient reference {self.clinical_document.recordTarget.patientRole.id} + status=status, + code=value.get("@code"), + display=value.get("@displayName"), + system=self.code_mapping.cda_to_fhir( + value.get("@codeSystem"), "system" + ), + ) + + # Add dates if present + if onset_date: + condition.onsetDateTime = onset_date + if abatement_date: + condition.abatementDateTime = abatement_date # Set category (problem-list-item by default for problems section) - condition.category = [ - CodeableConcept( - coding=[ - Coding( - system="http://terminology.hl7.org/CodeSystem/condition-category", - code="problem-list-item", - display="Problem List Item", - ) - ] - ) - ] + set_problem_list_item_category(condition) return condition @@ -461,32 +442,56 @@ def _extract_medications(self) -> List[MedicationStatement]: log.warning("Empty medication section!") return [] - def get_medication_concept_from_cda_data_field( + medications = [] + + def create_medication_statement_from_cda( code: CD, dose_quantity: Optional[IVL_PQ], route_code: Optional[CE], effective_times: Optional[Union[List[Dict], Dict]], - precondition: Optional[Dict], - ) -> MedicationConcept: - concept = MedicationConcept(_standard="cda") - concept.code = code.code - concept.code_system = code.codeSystem - concept.code_system_name = code.codeSystemName - concept.display_name = code.displayName + ) -> MedicationStatement: + # Map CDA system to FHIR system + fhir_system = self.code_mapping.cda_to_fhir( + code.codeSystem, "system", default="http://snomed.info/sct" + ) + # Create base medication statement using helper + medication = create_medication_statement( + subject="Patient/123", # TODO: extract patient reference + status="recorded", # TODO: extract status + code=code.code, + display=code.displayName, + system=fhir_system, + ) + + # Add dosage if present if dose_quantity: - concept.dosage = Quantity( - _source=dose_quantity.model_dump(), - value=dose_quantity.value, - unit=dose_quantity.unit, - ) + medication.dosage = [ + { + "doseAndRate": [ + { + "doseQuantity": { + "value": dose_quantity.value, + "unit": dose_quantity.unit, + } + } + ] + } + ] + + # Add route if present if route_code: - concept.route = Concept( + route_system = self.code_mapping.cda_to_fhir( + route_code.codeSystem, "system", default="http://snomed.info/sct" + ) + medication.dosage = medication.dosage or [Dosage()] + medication.dosage[0].route = create_single_codeable_concept( code=route_code.code, - code_system=route_code.codeSystem, - code_system_name=route_code.codeSystemName, - display_name=route_code.displayName, + display=route_code.displayName, + system=route_system, ) + + # Add timing if present if effective_times: effective_times = ( effective_times @@ -496,129 +501,86 @@ def get_medication_concept_from_cda_data_field( # TODO: could refactor this into a pydantic validator for effective_time in effective_times: if effective_time.get("@xsi:type") == "IVL_TS": - concept.duration = get_time_range_from_cda_value(effective_time) - concept.duration._source = effective_time + # Handle duration + low_value = effective_time.get("low", {}).get("@value") + high_value = effective_time.get("high", {}).get("@value") + + if low_value or high_value: + medication.effectivePeriod = {} + if low_value: + medication.effectivePeriod.start = ( + CodeMapping.convert_date_cda_to_fhir(low_value) + ) + if high_value: + medication.effectivePeriod.end = ( + CodeMapping.convert_date_cda_to_fhir(high_value) + ) + elif effective_time.get("@xsi:type") == "PIVL_TS": + # Handle frequency period = effective_time.get("period") if period: - concept.frequency = TimeInterval( - period=Quantity( - value=period.get("@value"), unit=period.get("@unit") - ), - institution_specified=effective_time.get( - "@institutionSpecified" - ), - ) - concept.frequency._source = effective_time - - # TODO: this is read-only for now! can also extract status, translations, supply in entryRelationships - if precondition: - concept.precondition = precondition.model_dump( - exclude_none=True, by_alias=True - ) + medication.dosage = medication.dosage or [Dosage()] + medication.dosage[0].timing = { + "repeat": { + "period": float(period.get("@value")), + "periodUnit": period.get("@unit"), + } + } - return concept - - def get_medication_details_from_substance_administration( - substance_administration: SubstanceAdministration, - ) -> Tuple[ - Optional[CD], - Optional[CE], - Optional[IVL_PQ], - Optional[Union[List[Dict], Dict]], - Optional[Dict], - ]: - # Get the medication code from the consumable - consumable = substance_administration.consumable - manufactured_product = ( - consumable.manufacturedProduct if consumable else None - ) - manufactured_material = ( - manufactured_product.manufacturedMaterial - if manufactured_product - else None - ) - code = manufactured_material.code if manufactured_material else None - - return ( - code, - substance_administration.doseQuantity, - substance_administration.routeCode, - substance_administration.effectiveTime, - substance_administration.precondition, - ) - - concepts = [] + return medication entries = ( self._medication_section.entry if isinstance(self._medication_section.entry, list) else [self._medication_section.entry] ) + for entry in entries: substance_administration = entry.substanceAdministration if not substance_administration: log.warning("Substance administration not found in entry.") continue - code, dose_quantity, route_code, effective_times, precondition = ( - get_medication_details_from_substance_administration( - substance_administration - ) + + # Get medication details + consumable = substance_administration.consumable + manufactured_product = ( + consumable.manufacturedProduct if consumable else None ) + manufactured_material = ( + manufactured_product.manufacturedMaterial + if manufactured_product + else None + ) + code = manufactured_material.code if manufactured_material else None + if not code: log.warning("Code not found in the consumable") continue - concept = get_medication_concept_from_cda_data_field( - code, dose_quantity, route_code, effective_times, precondition + + # Create FHIR medication statement + medication = create_medication_statement_from_cda( + code=code, + dose_quantity=substance_administration.doseQuantity, + route_code=substance_administration.routeCode, + effective_times=substance_administration.effectiveTime, ) - concepts.append(concept) + medications.append(medication) - return concepts + return medications - def _extract_allergies(self) -> List[AllergyConcept]: + def _extract_allergies(self) -> List[AllergyIntolerance]: + """ + Extracts allergy concepts from the allergy section of the CDA document. + + Returns: + List[AllergyIntolerance]: A list of FHIR AllergyIntolerance resources. + """ if not self._allergy_section: log.warning("Empty allergy section!") return [] - concepts = [] - - def get_allergy_concept_from_cda_data_fields( - value: Dict, - allergen_name: str, - allergy_type: CD, - reaction: Dict, - severity: Dict, - ) -> AllergyConcept: - concept = AllergyConcept(_standard="cda") - concept.code = value.get("@code") - concept.code_system = value.get("@codeSystem") - concept.code_system_name = value.get("@codeSystemName") - concept.display_name = value.get("@displayName") - if concept.display_name is None: - concept.display_name = allergen_name - - if allergy_type: - concept.allergy_type = Concept() - concept.allergy_type.code = allergy_type.code - concept.allergy_type.code_system = allergy_type.codeSystem - concept.allergy_type.code_system_name = allergy_type.codeSystemName - concept.allergy_type.display_name = allergy_type.displayName - - if reaction: - concept.reaction = Concept() - concept.reaction.code = reaction.get("@code") - concept.reaction.code_system = reaction.get("@codeSystem") - concept.reaction.code_system_name = reaction.get("@codeSystemName") - concept.reaction.display_name = reaction.get("@displayName") - - if severity: - concept.severity = Concept() - concept.severity.code = severity.get("@code") - concept.severity.code_system = severity.get("@codeSystem") - concept.severity.code_system_name = severity.get("@codeSystemName") - concept.severity.display_name = severity.get("@displayName") - - return concept + allergies = [] def get_allergy_details_from_entry_relationship( entry_relationship: EntryRelationship, @@ -672,6 +634,7 @@ def get_allergy_details_from_entry_relationship( if isinstance(self._allergy_section.entry, list) else [self._allergy_section.entry] ) + for entry in entries: entry_relationship = entry.act.entryRelationship values = get_value_from_entry_relationship(entry_relationship) @@ -681,12 +644,54 @@ def get_allergy_details_from_entry_relationship( ) for value in values: - concept = get_allergy_concept_from_cda_data_fields( - value, allergen_name, allergy_type, reaction, severity + # Map CDA system to FHIR system + allergy_code_system = self.code_mapping.cda_to_fhir( + value.get("@codeSystem"), "system", default="http://snomed.info/sct" ) - concepts.append(concept) + allergy = create_allergy_intolerance( + patient="Patient/123", # TODO: Get from patient context + code=value.get("@code"), + display=value.get("@displayName"), + system=allergy_code_system, + ) + if allergy.code.coding[0].display is None: + allergy.code.coding[0].display = allergen_name + + if allergy_type: + allergy_type_system = self.code_mapping.cda_to_fhir( + allergy_type.codeSystem, + "system", + default="http://snomed.info/sct", + ) + allergy.type = create_single_codeable_concept( + code=allergy_type.code, + display=allergy_type.displayName, + system=allergy_type_system, + ) - return concepts + if reaction: + reaction_system = self.code_mapping.cda_to_fhir( + reaction.get("@codeSystem"), + "system", + default="http://snomed.info/sct", + ) + allergy.reaction = create_single_reaction( + code=reaction.get("@code"), + display=reaction.get("@displayName"), + system=reaction_system, + ) + + if severity: + severity_code = self.code_mapping.cda_to_fhir( + severity.get("@code"), + "severity", + default="http://snomed.info/sct", + ) + if allergy.reaction: + allergy.reaction[0].severity = severity_code + allergies.append(allergy) + + return allergies def _extract_note(self) -> str: """ @@ -861,43 +866,73 @@ def add_to_problem_list( def _add_new_medication_entry( self, - new_medication: MedicationConcept, + new_medication: MedicationStatement, timestamp: str, subad_id: str, medication_reference_name: str, - ): + ) -> None: + """ + Adds a new medication entry to the medication section of the CDA document. + + Args: + new_medication (MedicationStatement): The FHIR MedicationStatement resource to add to the CDA + timestamp (str): The timestamp for when this entry was created, in YYYYMMDD format + subad_id (str): The unique ID for this substance administration entry + medication_reference_name (str): The reference name used to link narrative text to this medication + + The method creates a CDA substance administration entry with: + - Medication details (code, name, etc) + - Dosage information if present (amount, route, frequency) + - Effective time periods + - Status as Active + """ + + # Get CDA system from FHIR system + fhir_system = new_medication.medication.concept.coding[0].system + cda_system = self.code_mapping.fhir_to_cda( + fhir_system, "system", default="2.16.840.1.113883.6.96" + ) + effective_times = [] - if new_medication.frequency: + + # Handle timing/frequency + if new_medication.dosage and new_medication.dosage[0].timing: + timing = new_medication.dosage[0].timing.repeat effective_times.append( { "@xsi:type": "PIVL_TS", - "@institutionSpecified": new_medication.frequency.institution_specified, + "@institutionSpecified": True, "@operator": "A", "@xmlns:xsi": "http://www.w3.org/2001/XMLSchema-instance", "period": { - "@unit": new_medication.frequency.period.unit, - "@value": new_medication.frequency.period.value, + "@unit": timing.periodUnit, + "@value": str(timing.period), }, } ) - if new_medication.duration: - low = {"@nullFlavor": "UNK"} - high = {"@nullFlavor": "UNK"} - if new_medication.duration.low: - low = {"@value": new_medication.duration.low.value} - if new_medication.duration.high: - high = {"@value": new_medication.duration.high.value} - effective_times.append( - { - "@xsi:type": "IVL_TS", - "@xmlns:xsi": "http://www.w3.org/2001/XMLSchema-instance", - "low": low, - "high": high, - } - ) - if len(effective_times) == 1: - effective_times = effective_times[0] + # Handle effective period + # TODO: standardize datetime format + if new_medication.effectivePeriod: + time_range = { + "@xsi:type": "IVL_TS", + "@xmlns:xsi": "http://www.w3.org/2001/XMLSchema-instance", + "low": {"@nullFlavor": "UNK"}, + "high": {"@nullFlavor": "UNK"}, + } + if new_medication.effectivePeriod.start: + time_range["low"] = { + "@value": CodeMapping.convert_date_fhir_to_cda( + new_medication.effectivePeriod.start + ) + } + if new_medication.effectivePeriod.end: + time_range["high"] = { + "@value": CodeMapping.convert_date_fhir_to_cda( + new_medication.effectivePeriod.end + ) + } + effective_times.append(time_range) template = { "substanceAdministration": { @@ -914,23 +949,30 @@ def _add_new_medication_entry( "statusCode": {"@code": "completed"}, } } - # Add dosage, route, duration, frequency - if effective_times: - template["substanceAdministration"]["effectiveTime"] = effective_times - if new_medication.route: - template["substanceAdministration"]["routeCode"] = { - "@code": new_medication.route.code, - "@codeSystem": new_medication.route.code_system, - "@codeSystemDisplayName": new_medication.route.code_system_name, - "@displayName": new_medication.route.display_name, - } - if new_medication.dosage: + + # Add dosage if present + if new_medication.dosage and new_medication.dosage[0].doseAndRate: + dose = new_medication.dosage[0].doseAndRate[0].doseQuantity template["substanceAdministration"]["doseQuantity"] = { - "@value": new_medication.dosage.value, - "@unit": new_medication.dosage.unit, + "@value": dose.value, + "@unit": dose.unit, + } + + # Add route if present + if new_medication.dosage and new_medication.dosage[0].route: + route = new_medication.dosage[0].route.coding[0] + route_system = self.code_mapping.fhir_to_cda(route.system, "system") + template["substanceAdministration"]["routeCode"] = { + "@code": route.code, + "@codeSystem": route_system, + "@displayName": route.display, } - # Add medication entry + # Add timing + if effective_times: + template["substanceAdministration"]["effectiveTime"] = effective_times + + # Add medication details template["substanceAdministration"]["consumable"] = { "@typeCode": "CSM", "manufacturedProduct": { @@ -943,10 +985,11 @@ def _add_new_medication_entry( ], "manufacturedMaterial": { "code": { - "@code": new_medication.code, - "@codeSystem": new_medication.code_system, - "@codeSystemName": new_medication.code_system_name, - "@displayName": new_medication.display_name, + "@code": new_medication.medication.concept.coding[0].code, + "@codeSystem": cda_system, + "@displayName": new_medication.medication.concept.coding[ + 0 + ].display, "originalText": { "reference": {"@value": medication_reference_name} }, @@ -982,9 +1025,6 @@ def _add_new_medication_entry( }, }, ) - template["substanceAdministration"]["precondition"] = ( - new_medication.precondition - ) if not isinstance(self._medication_section.entry, list): self._medication_section.entry = [self._medication_section.entry] @@ -993,17 +1033,14 @@ def _add_new_medication_entry( self._medication_section.entry.append(new_entry) def add_to_medication_list( - self, medications: List[MedicationConcept], overwrite: bool = False + self, medications: List[MedicationStatement], overwrite: bool = False ) -> None: """ Adds medications to the medication list. Args: - medications (List[MedicationConcept]): A list of MedicationConcept objects representing the medications to be added. - overwrite (bool, optional): If True, the existing medication list will be overwritten. Defaults to False. - - Returns: - None + medications (List[MedicationStatement]): A list of MedicationStatement resources to be added + overwrite (bool, optional): If True, existing medication list will be overwritten. Defaults to False. """ if self._medication_section is None: log.warning( @@ -1023,10 +1060,11 @@ def add_to_medication_list( for medication in medications: if medication in self.medication_list: log.debug( - f"Skipping: medication {medication.display_name} already exists in the medication list." + f"Skipping: medication {medication.medication.concept.coding[0].display} already exists in the medication list." ) continue - log.debug(f"Adding medication {medication}") + + log.debug(f"Adding medication: {medication}") self._add_new_medication_entry( new_medication=medication, timestamp=timestamp, @@ -1042,7 +1080,7 @@ def add_to_medication_list( def _add_new_allergy_entry( self, - new_allergy: AllergyConcept, + new_allergy: AllergyIntolerance, timestamp: str, act_id: str, allergy_reference_name: str, @@ -1051,7 +1089,7 @@ def _add_new_allergy_entry( Adds a new allergy entry to the allergy section of the CDA document. Args: - new_allergy (AllergyConcept): The new allergy concept to be added. + new_allergy (AllergyIntolerance): The new allergy concept to be added. timestamp (str): The timestamp of the entry. act_id (str): The ID of the act. allergy_reference_name (str): The reference name of the allergy. @@ -1060,6 +1098,12 @@ def _add_new_allergy_entry( None """ + # Get CDA system from FHIR system + fhir_system = new_allergy.code.coding[0].system + allergy_type__system = self.code_mapping.fhir_to_cda( + fhir_system, "system", default="2.16.840.1.113883.6.96" + ) + template = { "act": { "@classCode": "ACT", @@ -1101,23 +1145,33 @@ def _add_new_allergy_entry( allergen_observation = template["act"]["entryRelationship"]["observation"] # Attach allergy type code - if new_allergy.allergy_type: + if new_allergy.type: + allergy_type__system = self.code_mapping.fhir_to_cda( + new_allergy.type.coding[0].system, + "system", + default="2.16.840.1.113883.6.96", + ) allergen_observation["code"] = { - "@code": new_allergy.allergy_type.code, - "@codeSystem": new_allergy.allergy_type.code_system, - "@codeSystemName": new_allergy.allergy_type.code_system_name, - "@displayName": new_allergy.allergy_type.display_name, + "@code": new_allergy.type.coding[0].code, + "@codeSystem": allergy_type__system, + # "@codeSystemName": new_allergy.type.coding[0].display, + "@displayName": new_allergy.type.coding[0].display, } else: - raise ValueError("Allergy_type code cannot be missing when adding allergy.") + raise ValueError("Allergy type code cannot be missing when adding allergy.") # Attach allergen code to value and participant + allergen_code_system = self.code_mapping.fhir_to_cda( + new_allergy.code.coding[0].system, + "system", + default="2.16.840.1.113883.6.96", + ) allergen_observation["value"] = { "@xmlns:xsi": "http://www.w3.org/2001/XMLSchema-instance", - "@code": new_allergy.code, - "@codeSystem": new_allergy.code_system, - "@codeSystemName": new_allergy.code_system_name, - "@displayName": new_allergy.display_name, + "@code": new_allergy.code.coding[0].code, + "@codeSystem": allergen_code_system, + # "@codeSystemName": new_allergy.code.coding[0].display, + "@displayName": new_allergy.code.coding[0].display, "originalText": {"reference": {"@value": allergy_reference_name}}, "@xsi:type": "CD", } @@ -1132,18 +1186,18 @@ def _add_new_allergy_entry( "originalText": { "reference": {"@value": allergy_reference_name} }, - "@code": new_allergy.code, - "@codeSystem": new_allergy.code_system, - "@codeSystemName": new_allergy.code_system_name, - "@displayName": new_allergy.display_name, + "@code": new_allergy.code.coding[0].code, + "@codeSystem": allergen_code_system, + # "@codeSystemName": new_allergy.code.coding[0].display, + "@displayName": new_allergy.code.coding[0].display, }, - "name": new_allergy.display_name, + "name": new_allergy.code.coding[0].display, }, }, } # We need an entryRelationship if either reaction or severity is present - if new_allergy.reaction or new_allergy.severity: + if new_allergy.reaction: allergen_observation["entryRelationship"] = { "@typeCode": "MFST", "observation": { @@ -1168,12 +1222,23 @@ def _add_new_allergy_entry( } # Attach reaction code if given otherwise attach nullFlavor if new_allergy.reaction: + reaction_code_system = self.code_mapping.fhir_to_cda( + new_allergy.reaction[0].manifestation[0].concept.coding[0].system, + "system", + default="2.16.840.1.113883.6.96", + ) allergen_observation["entryRelationship"]["observation"]["value"] = { "@xmlns:xsi": "http://www.w3.org/2001/XMLSchema-instance", - "@code": new_allergy.reaction.code, - "@codeSystem": new_allergy.reaction.code_system, - "@codeSystemName": new_allergy.reaction.code_system_name, - "@displayName": new_allergy.reaction.display_name, + "@code": new_allergy.reaction[0] + .manifestation[0] + .concept.coding[0] + .code, + "@codeSystem": reaction_code_system, + # "@codeSystemName": new_allergy.reaction[0].manifestation[0].concept.coding[0].display, + "@displayName": new_allergy.reaction[0] + .manifestation[0] + .concept.coding[0] + .display, "@xsi:type": "CD", "originalText": { "reference": {"@value": allergy_reference_name + "reaction"} @@ -1186,7 +1251,10 @@ def _add_new_allergy_entry( "@xsi:type": "CD", } # Attach severity code if given - if new_allergy.severity: + if new_allergy.reaction[0].severity: + severity_code = self.code_mapping.fhir_to_cda( + new_allergy.reaction[0].severity, "severity" + ) allergen_observation["entryRelationship"]["observation"][ "entryRelationship" ] = { @@ -1210,10 +1278,10 @@ def _add_new_allergy_entry( "statusCode": {"@code": "completed"}, "value": { "@xmlns:xsi": "http://www.w3.org/2001/XMLSchema-instance", - "@code": new_allergy.severity.code, - "@codeSystem": new_allergy.severity.code_system, - "@codeSystemName": new_allergy.severity.code_system_name, - "@displayName": new_allergy.severity.display_name, + "@code": new_allergy.reaction[0].severity, + "@codeSystem": severity_code, + # "@codeSystemName": new_allergy.severity.code_system_name, + "@displayName": new_allergy.reaction[0].severity, "@xsi:type": "CD", }, }, @@ -1226,8 +1294,15 @@ def _add_new_allergy_entry( self._allergy_section.entry.append(new_entry) def add_to_allergy_list( - self, allergies: List[AllergyConcept], overwrite: bool = False + self, allergies: List[AllergyIntolerance], overwrite: bool = False ) -> None: + """ + Adds allergies to the allergy list. + + Args: + allergies: List of FHIR AllergyIntolerance resources to add + overwrite: If True, overwrites existing allergy list + """ if self._allergy_section is None: log.warning( "Skipping: No allergy section to add to, check your CDA configuration" @@ -1245,9 +1320,7 @@ def add_to_allergy_list( for allergy in allergies: if allergy in self.allergy_list: - log.debug( - f"Skipping: Allergy {allergy.display_name} already exists in the allergy list." - ) + log.debug(f"Allergy {allergy.code.coding[0].display} already exists") continue log.debug(f"Adding allergy: {allergy}") self._add_new_allergy_entry( diff --git a/healthchain/cda_parser/utils.py b/healthchain/cda_parser/utils.py index abb6a782..16dcadb7 100644 --- a/healthchain/cda_parser/utils.py +++ b/healthchain/cda_parser/utils.py @@ -15,8 +15,9 @@ class MappingStrategy(Enum): ERROR = "error" # Raise error if multiple matches found +# TODO: Dates, times, human readable names, etc. class CodeMapping: - """Handles bidirectional mapping between CDA and FHIR codes.""" + """Handles bidirectional mapping between CDA and FHIR codes and formats.""" # Default mappings as fallback DEFAULT_MAPPINGS = { @@ -35,6 +36,18 @@ class CodeMapping: "suspended": "inactive", } }, + "date_format": { + "cda_to_fhir": { + "YYYYMMDD": "YYYY-MM-DD", + } + }, + "severity": { + "cda_to_fhir": { + "H": "severe", + "M": "moderate", + "L": "mild", + } + }, } def __init__(self, config_path: Optional[Union[str, Path]] = None): @@ -140,3 +153,58 @@ def add_mapping(self, mapping_type: str, cda_code: str, fhir_code: str) -> None: self.mappings[mapping_type]["cda_to_fhir"][cda_code] = fhir_code log.info(f"Added mapping: {mapping_type} - {cda_code} -> {fhir_code}") + + # TODO: use datetime + @classmethod + def convert_date_cda_to_fhir(cls, date_str: Optional[str]) -> Optional[str]: + """Convert CDA date format (YYYYMMDD) to FHIR date format (YYYY-MM-DD). + + Args: + date_str: Date string in CDA format (YYYYMMDD) + + Returns: + Date string in FHIR format (YYYY-MM-DD) or None if input is invalid + """ + if not date_str or not isinstance(date_str, str): + return None + + # Validate input format + if not date_str.isdigit() or len(date_str) != 8: + log.warning(f"Invalid CDA date format: {date_str}") + return None + + try: + from datetime import datetime + + parsed_date = datetime.strptime(date_str, "%Y%m%d") + return parsed_date.strftime("%Y-%m-%d") + except (ValueError, TypeError): + log.warning(f"Invalid CDA date format: {date_str}") + return None + + @classmethod + def convert_date_fhir_to_cda(cls, date_str: Optional[str]) -> Optional[str]: + """Convert FHIR date format (YYYY-MM-DD) to CDA date format (YYYYMMDD). + + Args: + date_str: Date string in FHIR format (YYYY-MM-DD) + + Returns: + Date string in CDA format (YYYYMMDD) or None if input is invalid + """ + if not date_str or not isinstance(date_str, str): + return None + + # Validate input format + if not len(date_str) == 10 or date_str[4] != "-" or date_str[7] != "-": + log.warning(f"Invalid FHIR date format: {date_str}") + return None + + try: + from datetime import datetime + + parsed_date = datetime.strptime(date_str, "%Y-%m-%d") + return parsed_date.strftime("%Y%m%d") + except (ValueError, TypeError): + log.warning(f"Invalid FHIR date format: {date_str}") + return None diff --git a/healthchain/fhir/__init__.py b/healthchain/fhir/__init__.py index 209c4305..fbb2dbc0 100644 --- a/healthchain/fhir/__init__.py +++ b/healthchain/fhir/__init__.py @@ -9,6 +9,7 @@ set_problem_list_item_category, read_content_attachment, create_document_reference, + create_single_attachment, ) from healthchain.fhir.bundle_helpers import ( @@ -28,6 +29,7 @@ "set_problem_list_item_category", "read_content_attachment", "create_document_reference", + "create_single_attachment", # Bundle operations "create_bundle", "add_resource", diff --git a/healthchain/fhir/helpers.py b/healthchain/fhir/helpers.py index c1ed0933..b6d5e128 100644 --- a/healthchain/fhir/helpers.py +++ b/healthchain/fhir/helpers.py @@ -11,6 +11,7 @@ from fhir.resources.allergyintolerance import AllergyIntolerance from fhir.resources.documentreference import DocumentReference from fhir.resources.codeableconcept import CodeableConcept +from fhir.resources.codeablereference import CodeableReference from fhir.resources.coding import Coding from fhir.resources.attachment import Attachment @@ -70,8 +71,10 @@ def create_single_reaction( return [ { "manifestation": [ - CodeableConcept( - coding=[Coding(system=system, code=code, display=display)] + CodeableReference( + concept=CodeableConcept( + coding=[Coding(system=system, code=code, display=display)] + ) ) ], "severity": severity, @@ -252,6 +255,7 @@ def create_allergy_intolerance( return allergy +# TODO: create a function that creates a DocumentReferenceContent to add to the DocumentReference def create_document_reference( data: Optional[Any] = None, url: Optional[str] = None, @@ -311,7 +315,13 @@ def read_content_attachment( Returns: Optional[List[Dict[str, Any]]]: List of dictionaries containing attachment data and metadata, - or None if no attachments are found + or None if no attachments are found: + [ + { + "data": str, + "metadata": Dict[str, Any] + } + ] """ if not document_reference.content: return None diff --git a/tests/conftest.py b/tests/conftest.py index b3248b8d..fd944148 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -30,6 +30,8 @@ create_allergy_intolerance, create_single_attachment, create_document_reference, + create_single_codeable_concept, + create_single_reaction, ) from fhir.resources.documentreference import DocumentReference, DocumentReferenceContent @@ -69,6 +71,38 @@ def test_allergy(): ) +@pytest.fixture +def test_allergy_with_reaction(test_allergy): + test_allergy.type = create_single_codeable_concept( + code="ABC", display="Test Allergy", system="http://snomed.info/sct" + ) + + test_allergy.reaction = create_single_reaction( + code="DEF", + display="Test Allergy", + system="http://snomed.info/sct", + severity="GHI", + ) + return test_allergy + + +@pytest.fixture +def test_medication_with_dosage(test_medication): + test_medication.dosage = [ + { + "doseAndRate": [{"doseQuantity": {"value": 500, "unit": "mg"}}], + "route": create_single_codeable_concept( + code="test", display="test", system="http://snomed.info/sct" + ), + "timing": {"repeat": {"period": 1, "periodUnit": "d"}}, + } + ] + + # Add effective period + test_medication.effectivePeriod = {"end": "2022-10-20"} + return test_medication + + @pytest.fixture def test_problem_list(): return [test_condition] @@ -534,7 +568,7 @@ def test_soap_request(): @pytest.fixture -def cda_annotator(): +def cda_annotator_with_data(): with open("./tests/data/test_cda.xml", "r") as file: test_cda = file.read() @@ -542,7 +576,7 @@ def cda_annotator(): @pytest.fixture -def cda_annotator_code(): +def cda_annotator_without_template_id(): with open("./tests/data/test_cda_without_template_id.xml", "r") as file: test_cda_without_template_id = file.read() return CdaAnnotator.from_xml(test_cda_without_template_id) diff --git a/tests/test_cdaannotator.py b/tests/test_cdaannotator.py index 4aa401a3..cc20d4c0 100644 --- a/tests/test_cdaannotator.py +++ b/tests/test_cdaannotator.py @@ -1,111 +1,78 @@ -import pytest from healthchain.cda_parser.cdaannotator import ( SectionId, SectionCode, - AllergyConcept, -) -from healthchain.models.data.concept import ( - Concept, - MedicationConcept, - Quantity, - Range, - TimeInterval, ) from fhir.resources.condition import Condition from fhir.resources.codeableconcept import CodeableConcept from fhir.resources.coding import Coding +from fhir.resources.medicationstatement import MedicationStatement -@pytest.fixture -def test_condition(): - return Condition( - subject={"reference": "Patient/123"}, - code=CodeableConcept( - coding=[ - Coding( - system="http://snomed.info/sct", - code="123456", - display="Test Condition", - ) - ] - ), - clinicalStatus=CodeableConcept( - coding=[ - Coding( - system="http://terminology.hl7.org/CodeSystem/condition-clinical", - code="active", - display="Active", - ) - ] - ), - ) - - -def test_find_notes_section(cda_annotator): +def test_find_notes_section(cda_annotator_with_data): # Test if the notes section is found correctly - section = cda_annotator._find_notes_section() + section = cda_annotator_with_data._find_notes_section() assert section is not None assert section.templateId.root == SectionId.NOTE.value -def test_find_notes_section_using_code(cda_annotator_code): +def test_find_notes_section_using_code(cda_annotator_without_template_id): # Test if the notes section is found correctly when no template_id is available in the document. - section = cda_annotator_code._find_notes_section() + section = cda_annotator_without_template_id._find_notes_section() assert section is not None assert section.code.code == SectionCode.NOTE.value -def test_find_problems_section(cda_annotator): - section = cda_annotator._find_problems_section() +def test_find_problems_section(cda_annotator_with_data): + section = cda_annotator_with_data._find_problems_section() assert section is not None assert section.templateId.root == SectionId.PROBLEM.value -def test_find_problems_section_using_code(cda_annotator_code): - section = cda_annotator_code._find_problems_section() +def test_find_problems_section_using_code(cda_annotator_without_template_id): + section = cda_annotator_without_template_id._find_problems_section() assert section is not None assert section.code.code == SectionCode.PROBLEM.value -def test_find_medications_section(cda_annotator): - section = cda_annotator._find_medications_section() +def test_find_medications_section(cda_annotator_with_data): + section = cda_annotator_with_data._find_medications_section() assert section is not None assert section.templateId[0].root == SectionId.MEDICATION.value -def test_find_medications_section_using_code(cda_annotator_code): - section = cda_annotator_code._find_medications_section() +def test_find_medications_section_using_code(cda_annotator_without_template_id): + section = cda_annotator_without_template_id._find_medications_section() assert section is not None assert section.code.code == SectionCode.MEDICATION.value -def test_find_allergies_section(cda_annotator): - section = cda_annotator._find_allergies_section() +def test_find_allergies_section(cda_annotator_with_data): + section = cda_annotator_with_data._find_allergies_section() assert section is not None assert section.templateId[0].root == SectionId.ALLERGY.value -def test_find_allergies_section_using_code(cda_annotator_code): - section = cda_annotator_code._find_allergies_section() +def test_find_allergies_section_using_code(cda_annotator_without_template_id): + section = cda_annotator_without_template_id._find_allergies_section() assert section is not None assert section.code.code == SectionCode.ALLERGY.value -def test_extract_note(cda_annotator): +def test_extract_note(cda_annotator_with_data): # Test if the note is extracted correctly - note = cda_annotator._extract_note() + note = cda_annotator_with_data._extract_note() assert note == {"paragraph": "test"} -def test_extract_note_using_code(cda_annotator_code): +def test_extract_note_using_code(cda_annotator_without_template_id): # Test if the note is extracted correctly - note = cda_annotator_code._extract_note() + note = cda_annotator_without_template_id._extract_note() assert note == {"paragraph": "test"} -def test_extract_problems(cda_annotator): +def test_extract_problems_to_fhir(cda_annotator_with_data): """Test if problems are extracted correctly as FHIR Condition resources""" - problems = cda_annotator._extract_problems() + problems = cda_annotator_with_data._extract_problems() assert len(problems) > 0 for problem in problems: assert isinstance(problem, Condition) @@ -119,9 +86,9 @@ def test_extract_problems(cda_annotator): assert problem.category[0].coding[0].code == "problem-list-item" -def test_extract_problems_using_code(cda_annotator_code): +def test_extract_problems_using_code(cda_annotator_without_template_id): """Test if problems are extracted correctly from code-based sections as FHIR Condition resources""" - problems = cda_annotator_code._extract_problems() + problems = cda_annotator_without_template_id._extract_problems() assert len(problems) > 0 for problem in problems: assert isinstance(problem, Condition) @@ -129,296 +96,229 @@ def test_extract_problems_using_code(cda_annotator_code): assert isinstance(problem.code.coding[0], Coding) -def test_extract_medications(cda_annotator): - medications = cda_annotator._extract_medications() - +def test_extract_medications_to_fhir(cda_annotator_with_data): + """Test if medications are extracted correctly as FHIR MedicationStatement resources""" + medications = cda_annotator_with_data._extract_medications() assert len(medications) == 1 - assert medications[0].code == "314076" - assert medications[0].code_system == "2.16.840.1.113883.6.88" - assert medications[0].display_name == "lisinopril 10 MG Oral Tablet" - - assert medications[0].dosage.value == 30.0 - assert medications[0].dosage.unit == "mg" - - assert medications[0].route.code == "C38288" - assert medications[0].route.code_system == "2.16.840.1.113883.3.26.1.1" - assert medications[0].route.code_system_name == "NCI Thesaurus" - assert medications[0].route.display_name == "Oral" - - assert medications[0].frequency.period.value == 0.5 - assert medications[0].frequency.period.unit == "d" - assert medications[0].frequency.institution_specified - - assert medications[0].duration.low is None - assert medications[0].duration.high.value == 20221020 - - assert medications[0].precondition == { - "@typeCode": "PRCN", - "criterion": { - "templateId": [ - {"@root": "2.16.840.1.113883.10.20.22.4.25"}, - { - "@extension": "2014-06-09", - "@root": "2.16.840.1.113883.10.20.22.4.25", - }, - ], - "code": {"@code": "ASSERTION", "@codeSystem": "2.16.840.1.113883.5.4"}, - "value": { - "@nullFlavor": "NI", - "@xsi:type": "CD", - "@xmlns:xsi": "http://www.w3.org/2001/XMLSchema-instance", - }, - }, - } + med = medications[0] + assert isinstance(med, MedicationStatement) -def test_extract_medications_using_code(cda_annotator_code): - medications = cda_annotator_code._extract_medications() + # Check medication code + assert med.medication.concept.coding[0].code == "314076" + assert ( + med.medication.concept.coding[0].system + == "http://www.nlm.nih.gov/research/umls/rxnorm" + ) + assert med.medication.concept.coding[0].display == "lisinopril 10 MG Oral Tablet" - assert len(medications) == 1 - assert medications[0].code == "314076" - assert medications[0].code_system == "2.16.840.1.113883.6.88" - assert medications[0].display_name == "lisinopril 10 MG Oral Tablet" - - assert medications[0].dosage.value == 30.0 - assert medications[0].dosage.unit == "mg" - - assert medications[0].route.code == "C38288" - assert medications[0].route.code_system == "2.16.840.1.113883.3.26.1.1" - assert medications[0].route.code_system_name == "NCI Thesaurus" - assert medications[0].route.display_name == "Oral" - - assert medications[0].frequency.period.value == 0.5 - assert medications[0].frequency.period.unit == "d" - assert medications[0].frequency.institution_specified - - assert medications[0].duration.low is None - assert medications[0].duration.high.value == 20221020 - - assert medications[0].precondition == { - "@typeCode": "PRCN", - "criterion": { - "templateId": [ - {"@root": "2.16.840.1.113883.10.20.22.4.25"}, - { - "@extension": "2014-06-09", - "@root": "2.16.840.1.113883.10.20.22.4.25", - }, - ], - "code": {"@code": "ASSERTION", "@codeSystem": "2.16.840.1.113883.5.4"}, - "value": { - "@nullFlavor": "NI", - "@xsi:type": "CD", - "@xmlns:xsi": "http://www.w3.org/2001/XMLSchema-instance", - }, - }, - } + # Check dosage + assert med.dosage[0].doseAndRate[0].doseQuantity.value == 30.0 + assert med.dosage[0].doseAndRate[0].doseQuantity.unit == "mg" + # Check route + assert med.dosage[0].route.coding[0].code == "C38288" + assert med.dosage[0].route.coding[0].system == "http://ncit.nci.nih.gov" + assert med.dosage[0].route.coding[0].display == "Oral" -def test_extract_allergies(cda_annotator): - allergies = cda_annotator._extract_allergies() + # Check timing + assert med.dosage[0].timing.repeat.period == 0.5 + assert med.dosage[0].timing.repeat.periodUnit == "d" - assert len(allergies) == 1 - assert allergies[0].code == "102263004" - assert allergies[0].code_system == "2.16.840.1.113883.6.96" - assert allergies[0].code_system_name == "SNOMED-CT" - assert allergies[0].display_name == "EGGS" - assert allergies[0].allergy_type.code == "418471000" - assert allergies[0].allergy_type.code_system == "2.16.840.1.113883.6.96" - assert allergies[0].allergy_type.code_system_name == "SNOMED CT" + # Check period + assert med.effectivePeriod.end == "2022-10-20" + + +def test_extract_medications_using_code(cda_annotator_without_template_id): + """Test extracting medications from code-based sections""" + medications = cda_annotator_without_template_id._extract_medications() + assert len(medications) == 1 + + med = medications[0] + assert isinstance(med, MedicationStatement) + + # Check basic medication details + assert med.medication.concept.coding[0].code == "314076" assert ( - allergies[0].allergy_type.display_name - == "Propensity to adverse reactions to food" + med.medication.concept.coding[0].system + == "http://www.nlm.nih.gov/research/umls/rxnorm" ) - assert allergies[0].reaction.code == "65124004" - assert allergies[0].reaction.code_system == "2.16.840.1.113883.6.96" - assert allergies[0].reaction.code_system_name == "SNOMED CT" - assert allergies[0].reaction.display_name == "Swelling" - assert allergies[0].severity.code == "H" - assert allergies[0].severity.code_system == "2.16.840.1.113883.5.1063" - assert allergies[0].severity.code_system_name == "SeverityObservation" - assert allergies[0].severity.display_name == "High" + assert med.medication.concept.coding[0].display == "lisinopril 10 MG Oral Tablet" -def test_extract_allergies_using_code(cda_annotator_code): - allergies = cda_annotator_code._extract_allergies() +def test_extract_allergies_to_fhir(cda_annotator_with_data): + allergies = cda_annotator_with_data._extract_allergies() assert len(allergies) == 1 - assert allergies[0].code == "102263004" - assert allergies[0].code_system == "2.16.840.1.113883.6.96" - assert allergies[0].code_system_name == "SNOMED-CT" - assert allergies[0].display_name == "EGGS" - assert allergies[0].allergy_type.code == "418471000" - assert allergies[0].allergy_type.code_system == "2.16.840.1.113883.6.96" - assert allergies[0].allergy_type.code_system_name == "SNOMED CT" + assert allergies[0].code.coding[0].code == "102263004" + assert allergies[0].code.coding[0].system == "http://snomed.info/sct" + assert allergies[0].code.coding[0].display == "EGGS" + + assert allergies[0].type.coding[0].code == "418471000" + assert allergies[0].type.coding[0].system == "http://snomed.info/sct" assert ( - allergies[0].allergy_type.display_name - == "Propensity to adverse reactions to food" + allergies[0].type.coding[0].display == "Propensity to adverse reactions to food" ) - assert allergies[0].reaction.code == "65124004" - assert allergies[0].reaction.code_system == "2.16.840.1.113883.6.96" - assert allergies[0].reaction.code_system_name == "SNOMED CT" - assert allergies[0].reaction.display_name == "Swelling" - assert allergies[0].severity.code == "H" - assert allergies[0].severity.code_system == "2.16.840.1.113883.5.1063" - assert allergies[0].severity.code_system_name == "SeverityObservation" - assert allergies[0].severity.display_name == "High" - - -def test_add_to_empty_sections(cda_annotator, test_ccd_data): - cda_annotator._problem_section = None - cda_annotator.problem_list = [] - cda_annotator.add_to_problem_list(test_ccd_data.concepts.problems) - assert cda_annotator.problem_list == [] - - cda_annotator._medication_section = None - cda_annotator.medication_list = [] - cda_annotator.add_to_medication_list(test_ccd_data.concepts.medications) - assert cda_annotator.medication_list == [] - - cda_annotator._allergy_section = None - cda_annotator.allergy_list = [] - cda_annotator.add_to_allergy_list(test_ccd_data.concepts.allergies) - assert cda_annotator.allergy_list == [] - - -def test_add_to_problem_list(cda_annotator, test_condition): - """Test adding FHIR Conditions to the problem list""" - cda_annotator.add_to_problem_list([test_condition]) - assert len(cda_annotator.problem_list) == 2 - assert test_condition in cda_annotator.problem_list + assert ( + allergies[0].reaction[0].manifestation[0].concept.coding[0].code == "65124004" + ) + assert ( + allergies[0].reaction[0].manifestation[0].concept.coding[0].system + == "http://snomed.info/sct" + ) + assert ( + allergies[0].reaction[0].manifestation[0].concept.coding[0].display + == "Swelling" + ) + assert allergies[0].reaction[0].severity == "severe" -def test_add_to_problem_list_overwrite(cda_annotator, test_condition): - """Test overwriting problem list with new FHIR Conditions""" - cda_annotator.add_to_problem_list([test_condition], overwrite=True) - assert len(cda_annotator.problem_list) == 1 - assert test_condition in cda_annotator.problem_list +def test_extract_allergies_using_code(cda_annotator_without_template_id): + allergies = cda_annotator_without_template_id._extract_allergies() + assert len(allergies) == 1 + assert allergies[0].code.coding[0].code == "102263004" + assert allergies[0].code.coding[0].system == "http://snomed.info/sct" + assert allergies[0].code.coding[0].display == "EGGS" -def test_add_multiple_to_problem_list(cda_annotator, test_condition): - """Test adding multiple FHIR Conditions to the problem list""" - cda_annotator.add_to_problem_list([test_condition, test_condition]) - assert len(cda_annotator.problem_list) == 3 - assert test_condition in cda_annotator.problem_list +def test_add_to_empty_sections( + cda_annotator_with_data, test_condition, test_medication, test_allergy +): + cda_annotator_with_data._problem_section = None + cda_annotator_with_data.problem_list = [] + cda_annotator_with_data.add_to_problem_list(test_condition) + assert cda_annotator_with_data.problem_list == [] -def test_add_multiple_to_problem_list_overwrite(cda_annotator, test_condition): - """Test overwriting problem list with new FHIR Conditions""" - cda_annotator.add_to_problem_list([test_condition, test_condition], overwrite=True) - assert len(cda_annotator.problem_list) == 2 - assert test_condition in cda_annotator.problem_list + cda_annotator_with_data._medication_section = None + cda_annotator_with_data.medication_list = [] + cda_annotator_with_data.add_to_medication_list(test_medication) + assert cda_annotator_with_data.medication_list == [] + cda_annotator_with_data._allergy_section = None + cda_annotator_with_data.allergy_list = [] + cda_annotator_with_data.add_to_allergy_list(test_allergy) + assert cda_annotator_with_data.allergy_list == [] -def test_add_to_medication_list(cda_annotator, test_ccd_data): - medications = test_ccd_data.concepts.medications - cda_annotator.add_to_medication_list(medications) - assert len(cda_annotator.medication_list) == 2 - assert len(cda_annotator._medication_section.entry) == 2 +def test_add_to_problem_list(cda_annotator_with_data, test_condition): + """Test adding FHIR Conditions to the problem list""" -def test_add_to_medication_list_overwrite(cda_annotator, test_ccd_data): - # Test if medications are added to the medication list correctly with overwrite=True - medications = test_ccd_data.concepts.medications - cda_annotator.add_to_medication_list(medications, overwrite=True) - assert len(cda_annotator.medication_list) == 1 - assert len(cda_annotator._medication_section.entry) == 1 + cda_annotator_with_data.add_to_problem_list([test_condition]) + assert len(cda_annotator_with_data.problem_list) == 2 + assert test_condition in cda_annotator_with_data.problem_list -def test_add_multiple_to_medication_list(cda_annotator, test_multiple_ccd_data): - medications = test_multiple_ccd_data.concepts.medications - cda_annotator.add_to_medication_list(medications) - assert len(cda_annotator.medication_list) == 3 - assert len(cda_annotator._medication_section.entry) == 3 +def test_add_to_problem_list_overwrite(cda_annotator_with_data, test_condition): + """Test overwriting problem list with new FHIR Conditions""" + cda_annotator_with_data.add_to_problem_list([test_condition], overwrite=True) + assert len(cda_annotator_with_data.problem_list) == 1 + assert test_condition in cda_annotator_with_data.problem_list - # Test deduplicate - cda_annotator.add_to_medication_list(medications) - assert len(cda_annotator.medication_list) == 3 - assert len(cda_annotator._medication_section.entry) == 3 + +def test_add_multiple_to_problem_list(cda_annotator_with_data, test_condition): + """Test adding multiple FHIR Conditions to the problem list""" + cda_annotator_with_data.add_to_problem_list([test_condition, test_condition]) + assert len(cda_annotator_with_data.problem_list) == 3 + assert test_condition in cda_annotator_with_data.problem_list -def test_add_multiple_to_medication_list_overwrite( - cda_annotator, test_multiple_ccd_data +def test_add_multiple_to_problem_list_overwrite( + cda_annotator_with_data, test_condition ): - medications = test_multiple_ccd_data.concepts.medications - cda_annotator.add_to_medication_list(medications, overwrite=True) - assert len(cda_annotator.medication_list) == 2 - assert len(cda_annotator._medication_section.entry) == 2 + """Test overwriting problem list with new FHIR Conditions""" + cda_annotator_with_data.add_to_problem_list( + [test_condition, test_condition], overwrite=True + ) + assert len(cda_annotator_with_data.problem_list) == 2 + assert test_condition in cda_annotator_with_data.problem_list -def test_add_to_allergy_list(cda_annotator, test_ccd_data): - # Test if allergies are added to the allergy list correctly with overwrite=True - allergies = test_ccd_data.concepts.allergies - cda_annotator.add_to_allergy_list(allergies) - assert len(cda_annotator.allergy_list) == 2 - assert len(cda_annotator._allergy_section.entry) == 2 +def test_add_to_medication_list(cda_annotator_with_data, test_medication): + """Test adding medications to the medication list""" + initial_count = len(cda_annotator_with_data.medication_list) + # Add medication + cda_annotator_with_data.add_to_medication_list([test_medication]) -def test_add_to_allergy_list_overwrite(cda_annotator, test_ccd_data): - allergies = test_ccd_data.concepts.allergies - cda_annotator.add_to_allergy_list(allergies, overwrite=True) - assert len(cda_annotator.allergy_list) == 1 - assert len(cda_annotator._allergy_section.entry) == 1 + # Check medication was added + assert len(cda_annotator_with_data.medication_list) == initial_count + 1 + assert test_medication in cda_annotator_with_data.medication_list + # Try adding same medication again + cda_annotator_with_data.add_to_medication_list([test_medication]) -def test_add_multiple_to_allergy_list(cda_annotator, test_multiple_ccd_data): - # Test if allergies are added to the allergy list correctly with overwrite=True - allergies = test_multiple_ccd_data.concepts.allergies - cda_annotator.add_to_allergy_list(allergies) - assert len(cda_annotator.allergy_list) == 3 - assert len(cda_annotator._allergy_section.entry) == 3 + # Check duplicate was not added + assert len(cda_annotator_with_data.medication_list) == initial_count + 1 + + +def test_add_to_medication_list_overwrite(cda_annotator_with_data, test_medication): + """Test overwriting the medication list""" + # Add medication with overwrite + cda_annotator_with_data.add_to_medication_list([test_medication], overwrite=True) - cda_annotator.add_to_allergy_list(allergies) - assert len(cda_annotator.allergy_list) == 3 - assert len(cda_annotator._allergy_section.entry) == 3 + # Check only new medication exists + assert len(cda_annotator_with_data.medication_list) == 1 + assert test_medication in cda_annotator_with_data.medication_list -def test_add_multiple_to_allergy_list_overwrite(cda_annotator, test_multiple_ccd_data): - allergies = test_multiple_ccd_data.concepts.allergies - cda_annotator.add_to_allergy_list(allergies, overwrite=True) - assert len(cda_annotator.allergy_list) == 2 - assert len(cda_annotator._allergy_section.entry) == 2 +def test_add_to_allergy_list(cda_annotator_with_data, test_allergy_with_reaction): + # Test if allergies are added to the allergy list correctly with overwrite=True + cda_annotator_with_data.add_to_allergy_list([test_allergy_with_reaction]) + assert len(cda_annotator_with_data.allergy_list) == 2 + assert len(cda_annotator_with_data._allergy_section.entry) == 2 + + +def test_add_to_allergy_list_overwrite( + cda_annotator_with_data, test_allergy_with_reaction +): + cda_annotator_with_data.add_to_allergy_list( + [test_allergy_with_reaction], overwrite=True + ) + assert len(cda_annotator_with_data.allergy_list) == 1 + assert len(cda_annotator_with_data._allergy_section.entry) == 1 -def test_export_pretty_print(cda_annotator): +def test_export_pretty_print(cda_annotator_with_data): # Test if the export function returns a valid string with pretty_print=True - exported_data = cda_annotator.export(pretty_print=True) + exported_data = cda_annotator_with_data.export(pretty_print=True) assert isinstance(exported_data, str) assert "\t" in exported_data -def test_export_no_pretty_print(cda_annotator): +def test_export_no_pretty_print(cda_annotator_with_data): # Test if the export function returns a valid string with pretty_print=False - exported_data = cda_annotator.export(pretty_print=False) + exported_data = cda_annotator_with_data.export(pretty_print=False) assert isinstance(exported_data, str) -def test_add_new_problem_entry(cda_annotator, test_condition): +def test_add_new_problem_entry(cda_annotator_with_data, test_condition): """Test if a new FHIR Condition entry is added correctly to CDA structure""" timestamp = "20220101" act_id = "12345678" problem_reference_name = "#p12345678name" - cda_annotator._add_new_problem_entry( + cda_annotator_with_data._add_new_problem_entry( new_problem=test_condition, timestamp=timestamp, act_id=act_id, problem_reference_name=problem_reference_name, ) - assert len(cda_annotator._problem_section.entry) == 2 - assert cda_annotator._problem_section.entry[1].act.id.root == act_id + assert len(cda_annotator_with_data._problem_section.entry) == 2 + assert cda_annotator_with_data._problem_section.entry[1].act.id.root == act_id assert ( - cda_annotator._problem_section.entry[1].act.effectiveTime.low.value == timestamp + cda_annotator_with_data._problem_section.entry[1].act.effectiveTime.low.value + == timestamp ) - assert cda_annotator._problem_section.entry[ + assert cda_annotator_with_data._problem_section.entry[ 1 ].act.entryRelationship.observation.text == { "reference": {"@value": problem_reference_name} } - assert cda_annotator._problem_section.entry[ + assert cda_annotator_with_data._problem_section.entry[ 1 ].act.entryRelationship.observation.value == { "@xmlns:xsi": "http://www.w3.org/2001/XMLSchema-instance", @@ -429,115 +329,95 @@ def test_add_new_problem_entry(cda_annotator, test_condition): "@xsi:type": "CD", } assert ( - cda_annotator._problem_section.entry[ + cda_annotator_with_data._problem_section.entry[ 1 ].act.entryRelationship.observation.effectiveTime.low.value == timestamp ) assert ( - cda_annotator._problem_section.entry[ + cda_annotator_with_data._problem_section.entry[ 1 ].act.entryRelationship.observation.entryRelationship.observation.effectiveTime.low.value == timestamp ) -def test_add_new_medication_entry(cda_annotator): +def test_add_new_medication_entry(cda_annotator_with_data, test_medication_with_dosage): # Test if a new medication entry is added correctly - new_med = MedicationConcept() - new_med.code = "12345678" - new_med.code_system = "2.16.840.1.113883.6.96" - new_med.code_system_name = "SNOMED CT" - new_med.display_name = "Test Medication" - new_med.dosage = Quantity(**{"value": 500, "unit": "mg"}) - new_med.route = Concept( - **{"code": "test", "code_system": "2.16.840.1.113883", "display_name": "test"} - ) - new_med.frequency = TimeInterval( - **{ - "period": Quantity(**{"value": 1, "unit": "d"}), - "institution_specified": True, - } - ) - new_med.duration = Range(**{"high": Quantity(**{"value": "20221020"})}) + timestamp = "20240701" subad_id = "12345678" med_reference_name = "#m12345678name" - cda_annotator._add_new_medication_entry( - new_medication=new_med, + cda_annotator_with_data._add_new_medication_entry( + new_medication=test_medication_with_dosage, timestamp=timestamp, subad_id=subad_id, medication_reference_name=med_reference_name, ) - assert len(cda_annotator._medication_section.entry) == 2 - subad = cda_annotator._medication_section.entry[1].substanceAdministration + assert len(cda_annotator_with_data._medication_section.entry) == 2 + subad = cda_annotator_with_data._medication_section.entry[1].substanceAdministration assert subad.id.root == subad_id - assert subad.doseQuantity.value == new_med.dosage.value - assert subad.doseQuantity.unit == new_med.dosage.unit + # Check dosage + assert subad.doseQuantity.value == 500 + assert subad.doseQuantity.unit == "mg" - assert subad.routeCode.code == new_med.route.code - assert subad.routeCode.codeSystem == new_med.route.code_system - assert subad.routeCode.displayName == new_med.route.display_name + # Check route + assert subad.routeCode.code == "test" + assert subad.routeCode.displayName == "test" + # Check timing assert subad.effectiveTime[0] == { "@xsi:type": "PIVL_TS", - "@institutionSpecified": new_med.frequency.institution_specified, + "@institutionSpecified": True, "@operator": "A", "@xmlns:xsi": "http://www.w3.org/2001/XMLSchema-instance", "period": { - "@unit": new_med.frequency.period.unit, - "@value": new_med.frequency.period.value, + "@unit": "d", + "@value": "1", }, } + + # Check period assert subad.effectiveTime[1] == { "@xsi:type": "IVL_TS", "@xmlns:xsi": "http://www.w3.org/2001/XMLSchema-instance", "low": {"@nullFlavor": "UNK"}, - "high": {"@value": new_med.duration.high.value}, + "high": {"@value": "20221020"}, } + # Check medication details consumable_code = subad.consumable.manufacturedProduct.manufacturedMaterial.code - - assert consumable_code.code == new_med.code - assert consumable_code.codeSystem == new_med.code_system - assert consumable_code.codeSystemName == new_med.code_system_name - assert consumable_code.displayName == new_med.display_name + assert consumable_code.code == "456" + assert consumable_code.displayName == "Test Medication" + assert consumable_code.codeSystem == "2.16.840.1.113883.6.96" assert consumable_code.originalText == {"reference": {"@value": med_reference_name}} assert subad.entryRelationship[0].observation.effectiveTime.low.value == timestamp -def test_add_new_allergy_entry(cda_annotator): - # Test if a new problem entry is added correctly - new_allergy = AllergyConcept() - new_allergy.code = "12345678" - new_allergy.code_system = "2.16.840.1.113883.6.96" - new_allergy.code_system_name = "SNOMED CT" - new_allergy.display_name = "Test Allergy" - new_allergy.allergy_type = Concept(**{"code": "ABC", "code_system": "snomed"}) - new_allergy.reaction = Concept(**{"code": "DEF"}) - new_allergy.severity = Concept(**{"code": "GHI"}) +def test_add_new_allergy_entry(cda_annotator_with_data, test_allergy_with_reaction): timestamp = "20220101" act_id = "12345678" allergy_reference_name = "#a12345678name" - cda_annotator._add_new_allergy_entry( - new_allergy=new_allergy, + cda_annotator_with_data._add_new_allergy_entry( + new_allergy=test_allergy_with_reaction, timestamp=timestamp, act_id=act_id, allergy_reference_name=allergy_reference_name, ) - assert len(cda_annotator._allergy_section.entry) == 2 - assert cda_annotator._allergy_section.entry[1].act.id.root == act_id + assert len(cda_annotator_with_data._allergy_section.entry) == 2 + assert cda_annotator_with_data._allergy_section.entry[1].act.id.root == act_id assert ( - cda_annotator._allergy_section.entry[1].act.effectiveTime.low.value == timestamp + cda_annotator_with_data._allergy_section.entry[1].act.effectiveTime.low.value + == timestamp ) - allergen_observation = cda_annotator._allergy_section.entry[ + allergen_observation = cda_annotator_with_data._allergy_section.entry[ 1 ].act.entryRelationship.observation assert allergen_observation.id.root == act_id @@ -546,35 +426,30 @@ def test_add_new_allergy_entry(cda_annotator): } assert allergen_observation.effectiveTime.low.value == timestamp assert allergen_observation.code.code == "ABC" - assert allergen_observation.code.codeSystem == "snomed" + assert allergen_observation.code.codeSystem == "2.16.840.1.113883.6.96" assert allergen_observation.value == { "@xmlns:xsi": "http://www.w3.org/2001/XMLSchema-instance", - "@code": new_allergy.code, - "@codeSystem": new_allergy.code_system, - "@codeSystemName": new_allergy.code_system_name, - "@displayName": new_allergy.display_name, + "@code": test_allergy_with_reaction.code.coding[0].code, + "@codeSystem": "2.16.840.1.113883.6.96", + "@displayName": test_allergy_with_reaction.code.coding[0].display, "originalText": {"reference": {"@value": allergy_reference_name}}, "@xsi:type": "CD", } assert ( allergen_observation.participant.participantRole.playingEntity.code.code - == new_allergy.code + == test_allergy_with_reaction.code.coding[0].code ) assert ( allergen_observation.participant.participantRole.playingEntity.code.codeSystem - == new_allergy.code_system - ) - assert ( - allergen_observation.participant.participantRole.playingEntity.code.codeSystemName - == new_allergy.code_system_name + == "2.16.840.1.113883.6.96" ) assert ( allergen_observation.participant.participantRole.playingEntity.code.displayName - == new_allergy.display_name + == test_allergy_with_reaction.code.coding[0].display ) assert ( - allergen_observation.participant.participantRole.playingEntity.name - == new_allergy.display_name + allergen_observation.participant.participantRole.playingEntity.code.displayName + == test_allergy_with_reaction.code.coding[0].display ) assert allergen_observation.entryRelationship.observation.value["@code"] == "DEF" @@ -585,25 +460,9 @@ def test_add_new_allergy_entry(cda_annotator): == "GHI" ) - # Test adding withhout a reaction - new_allergy.reaction = None - cda_annotator._add_new_allergy_entry( - new_allergy=new_allergy, - timestamp=timestamp, - act_id=act_id, - allergy_reference_name=allergy_reference_name, - ) assert ( - cda_annotator._allergy_section.entry[ - 2 - ].act.entryRelationship.observation.entryRelationship.observation.value[ - "@nullFlavor" - ] - == "OTH" - ) - assert ( - cda_annotator._allergy_section.entry[ - 2 + cda_annotator_with_data._allergy_section.entry[ + 1 ].act.entryRelationship.observation.entryRelationship.observation.entryRelationship.observation.value[ "@code" ] From e6dac6f6ba12cc1116d9942af968db644b80d3ee Mon Sep 17 00:00:00 2001 From: jenniferjiangkells Date: Fri, 21 Feb 2025 11:45:24 +0000 Subject: [PATCH 15/34] Add default required allergen type field in CDA --- healthchain/cda_parser/cdaannotator.py | 17 ++++++++--------- 1 file changed, 8 insertions(+), 9 deletions(-) diff --git a/healthchain/cda_parser/cdaannotator.py b/healthchain/cda_parser/cdaannotator.py index 1411be13..7ae7b27d 100644 --- a/healthchain/cda_parser/cdaannotator.py +++ b/healthchain/cda_parser/cdaannotator.py @@ -1098,12 +1098,6 @@ def _add_new_allergy_entry( None """ - # Get CDA system from FHIR system - fhir_system = new_allergy.code.coding[0].system - allergy_type__system = self.code_mapping.fhir_to_cda( - fhir_system, "system", default="2.16.840.1.113883.6.96" - ) - template = { "act": { "@classCode": "ACT", @@ -1146,19 +1140,24 @@ def _add_new_allergy_entry( # Attach allergy type code if new_allergy.type: - allergy_type__system = self.code_mapping.fhir_to_cda( + allergy_type_system = self.code_mapping.fhir_to_cda( new_allergy.type.coding[0].system, "system", default="2.16.840.1.113883.6.96", ) allergen_observation["code"] = { "@code": new_allergy.type.coding[0].code, - "@codeSystem": allergy_type__system, + "@codeSystem": allergy_type_system, # "@codeSystemName": new_allergy.type.coding[0].display, "@displayName": new_allergy.type.coding[0].display, } else: - raise ValueError("Allergy type code cannot be missing when adding allergy.") + log.warning("Allergy type code is missing, using default.") + allergen_observation["code"] = { + "@code": "420134006", + "@codeSystem": "2.16.840.1.113883.6.96", + "@displayName": "Propensity to adverse reactions", + } # Attach allergen code to value and participant allergen_code_system = self.code_mapping.fhir_to_cda( From 8bded79008cea7052c5f6b275c003d789b9c21be Mon Sep 17 00:00:00 2001 From: jenniferjiangkells Date: Fri, 21 Feb 2025 11:46:22 +0000 Subject: [PATCH 16/34] Fix failing tests --- .../pipeline/components/integrations.py | 22 +-- tests/components/conftest.py | 13 +- tests/components/test_cardcreator.py | 14 +- tests/components/test_integrations.py | 31 ++-- tests/components/test_postprocessor.py | 8 +- tests/conftest.py | 75 ++------- tests/fhir/test_helpers.py | 11 +- tests/pipeline/conftest.py | 146 +++--------------- tests/pipeline/prebuilt/test_medicalcoding.py | 11 +- tests/pipeline/prebuilt/test_summarization.py | 21 +-- 10 files changed, 87 insertions(+), 265 deletions(-) diff --git a/healthchain/pipeline/components/integrations.py b/healthchain/pipeline/components/integrations.py index a58a9fa7..5c06b9ed 100644 --- a/healthchain/pipeline/components/integrations.py +++ b/healthchain/pipeline/components/integrations.py @@ -6,9 +6,7 @@ from healthchain.io.containers import Document from healthchain.pipeline.components.base import BaseComponent -from fhir.resources.condition import Condition -from fhir.resources.codeableconcept import CodeableConcept -from fhir.resources.coding import Coding +from healthchain.fhir import create_condition T = TypeVar("T") @@ -117,21 +115,17 @@ def _add_concepts_to_hc_doc(self, spacy_doc: SpacyDoc, hc_doc: Document): concepts = [] # TODO: Review this, too specific to MedCAT, coding system needs to be configurable for ent in spacy_doc.ents: - concept = Condition( - code=CodeableConcept( - coding=[ - Coding( - system="http://snomed.info/sct", - code=ent._.cui if hasattr(ent, "_.cui") else None, - display=ent.text, - ) - ] - ) + concept = create_condition( + subject="Patient/123", + code=ent._.cui if hasattr(ent, "_.cui") else None, + display=ent.text, + system="http://snomed.info/sct", ) + concepts.append(concept) # Add to document concepts - hc_doc.add_concepts(problems=concepts) + hc_doc.fhir.problem_list = concepts def __call__(self, doc: Document) -> Document: """Process the document using the spaCy pipeline. Adds outputs to nlp.spacy_docs.""" diff --git a/tests/components/conftest.py b/tests/components/conftest.py index 28c7d0fa..febdf8b2 100644 --- a/tests/components/conftest.py +++ b/tests/components/conftest.py @@ -1,11 +1,11 @@ import pytest -from healthchain.io.containers.document import Document + from healthchain.pipeline.components import CdsCardCreator from tests.pipeline.conftest import mock_spacy_nlp # noqa: F401 @pytest.fixture -def sample_lookup(): +def test_lookup(): return { "high blood pressure": "hypertension", "heart attack": "myocardial infarction", @@ -13,17 +13,12 @@ def sample_lookup(): @pytest.fixture -def sample_document(): - return Document(data="This is a sample text for testing.") - - -@pytest.fixture -def basic_creator(): +def test_card_creator(): return CdsCardCreator() @pytest.fixture -def custom_template_creator(): +def test_custom_template_creator(): template = """ { "summary": "Custom: {{ model_output }}", diff --git a/tests/components/test_cardcreator.py b/tests/components/test_cardcreator.py index c4b1624a..e34f0f42 100644 --- a/tests/components/test_cardcreator.py +++ b/tests/components/test_cardcreator.py @@ -4,9 +4,9 @@ from healthchain.models.responses.cdsresponse import Card, Source, IndicatorEnum -def test_default_template_rendering(basic_creator): +def test_default_template_rendering(test_card_creator): content = "Test message" - card = basic_creator.create_card(content) + card = test_card_creator.create_card(content) assert isinstance(card, Card) assert card.summary == "Test message"[:140] @@ -15,9 +15,9 @@ def test_default_template_rendering(basic_creator): assert card.detail == "Test message" -def test_custom_template_rendering(custom_template_creator): +def test_custom_template_rendering(test_custom_template_creator): content = "Test message" - card = custom_template_creator.create_card(content) + card = test_custom_template_creator.create_card(content) assert card.summary == "Custom: Test message" assert card.indicator == IndicatorEnum.warning @@ -25,15 +25,15 @@ def test_custom_template_rendering(custom_template_creator): assert card.detail == "Test message" -def test_long_summary_truncation(basic_creator): +def test_long_summary_truncation(test_card_creator): long_content = "x" * 200 - card = basic_creator.create_card(long_content) + card = test_card_creator.create_card(long_content) assert len(card.summary) == 140 assert card.summary == "x" * 140 -def test_invalid_template_json(basic_creator): +def test_invalid_template_json(test_card_creator): invalid_template = """ { "summary": {{ invalid_json }}, diff --git a/tests/components/test_integrations.py b/tests/components/test_integrations.py index 1e96d1bb..2dc312c6 100644 --- a/tests/components/test_integrations.py +++ b/tests/components/test_integrations.py @@ -13,7 +13,7 @@ langchain_installed = importlib.util.find_spec("langchain_core") is not None -def test_spacy_component(sample_document): +def test_spacy_component(test_empty_document): with patch("spacy.load") as mock_load: mock_instance = MagicMock(items=[]) mock_instance.__iter__.return_value = [] @@ -27,7 +27,7 @@ def test_spacy_component(sample_document): for kwargs, case in test_cases: component = SpacyNLP.from_model_id("en_core_web_sm", **kwargs) - result = component(sample_document) + result = component(test_empty_document) # Verify kwargs were passed correctly expected_args = {"disable": ["ner", "parser"]} if kwargs else {} @@ -40,7 +40,7 @@ def test_spacy_component(sample_document): @pytest.mark.skipif( not transformers_installed, reason="transformers package not installed" ) -def test_huggingface_component(sample_document): +def test_huggingface_component(test_empty_document): from transformers.pipelines.base import Pipeline with patch("transformers.pipeline", autospec=True) as mock_pipeline: @@ -62,7 +62,7 @@ def test_huggingface_component(sample_document): task="sentiment-analysis", **kwargs, ) - result = component(sample_document) + result = component(test_empty_document) mock_pipeline.assert_called_once_with( task="sentiment-analysis", @@ -79,7 +79,7 @@ def test_huggingface_component(sample_document): @pytest.mark.skipif( not langchain_installed, reason="langchain-core package not installed" ) -def test_langchain_component(sample_document): +def test_langchain_component(test_empty_document): from langchain_core.runnables import Runnable mock_chain = Mock(spec=Runnable) @@ -97,10 +97,10 @@ def test_langchain_component(sample_document): for kwargs, case in test_cases: component = LangChainLLM(chain=mock_chain, task="dummy_task", **kwargs) - result = component(sample_document) + result = component(test_empty_document) # Verify kwargs were passed correctly - mock_chain.invoke.assert_called_once_with(sample_document.data, **kwargs) + mock_chain.invoke.assert_called_once_with(test_empty_document.data, **kwargs) assert ( result.models.get_output("langchain", "dummy_task") == "mocked chain output" ), f"LangChainLLM failed {case}" @@ -184,7 +184,7 @@ def test_component_invalid_kwargs( assert expected_message in str(exc_info.value) -def test_spacy_add_concepts(mock_spacy_nlp, sample_document): +def test_spacy_add_concepts(mock_spacy_nlp, test_empty_document): """Test adding concepts from spaCy entities to HealthChain document""" # Get the mock spaCy doc from the fixture mock_instance = mock_spacy_nlp.return_value @@ -198,13 +198,11 @@ def test_spacy_add_concepts(mock_spacy_nlp, sample_document): component._nlp = mock_nlp # Process document using the mock entities - component._add_concepts_to_hc_doc(mock_doc, sample_document) + component._add_concepts_to_hc_doc(mock_doc, test_empty_document) # Verify concepts were added correctly - concepts = sample_document.concepts - assert ( - len(concepts.problems) == 3 - ) # All entities are treated as problems by default + conditions = test_empty_document.fhir.problem_list + assert len(conditions) == 3 # All entities are treated as problems by default # Check each concept was added with correct attributes expected_concepts = [ @@ -214,10 +212,9 @@ def test_spacy_add_concepts(mock_spacy_nlp, sample_document): ] for i, (text, cui) in enumerate(expected_concepts): - assert concepts.problems[i].display_name == text - assert concepts.problems[i].code == cui - assert concepts.problems[i].code_system == "2.16.840.1.113883.6.96" - assert concepts.problems[i].code_system_name == "SNOMED CT" + assert conditions[i].code.coding[0].display == text + assert conditions[i].code.coding[0].code == cui + assert conditions[i].code.coding[0].system == "http://snomed.info/sct" def test_requires_package_decorator(): diff --git a/tests/components/test_postprocessor.py b/tests/components/test_postprocessor.py index 5d7b873c..3242eaa3 100644 --- a/tests/components/test_postprocessor.py +++ b/tests/components/test_postprocessor.py @@ -28,8 +28,8 @@ def test_text_postprocessor_initialization_and_processing(): ] -def test_text_postprocessor_with_entities(sample_lookup): - processor = TextPostProcessor(postcoordination_lookup=sample_lookup) +def test_text_postprocessor_with_entities(test_lookup): + processor = TextPostProcessor(postcoordination_lookup=test_lookup) # Test with matching entities doc = Document(data="") @@ -64,8 +64,8 @@ def test_text_postprocessor_with_entities(sample_lookup): ] -def test_text_postprocessor_edge_cases(sample_lookup): - processor = TextPostProcessor(postcoordination_lookup=sample_lookup) +def test_text_postprocessor_edge_cases(test_lookup): + processor = TextPostProcessor(postcoordination_lookup=test_lookup) # Test with document without entities doc = Document(data="This is a test document") diff --git a/tests/conftest.py b/tests/conftest.py index fd944148..04e42a27 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,9 +1,6 @@ -import dataclasses -import logging import pytest from unittest.mock import Mock -from pydantic import BaseModel from healthchain.base import BaseStrategy, BaseUseCase from healthchain.cda_parser.cdaannotator import CdaAnnotator @@ -103,21 +100,6 @@ def test_medication_with_dosage(test_medication): return test_medication -@pytest.fixture -def test_problem_list(): - return [test_condition] - - -@pytest.fixture -def test_medication_list(): - return [test_medication] - - -@pytest.fixture -def test_allergy_list(): - return [test_allergy] - - @pytest.fixture def doc_ref_with_content(): """Create a DocumentReference with single text content.""" @@ -167,60 +149,33 @@ def doc_ref_without_content(): @pytest.fixture -def test_document(test_problem_list, test_medication_list, test_allergy_list): +def test_document(): """Create a test document with FHIR resources.""" doc = Document(data="Test note") doc.fhir.set_bundle(create_bundle()) # Add test FHIR resources - doc.fhir.problem_list = test_problem_list - doc.fhir.medication_list = test_medication_list - doc.fhir.allergy_list = test_allergy_list - return doc - - -@pytest.fixture -def test_document_multiple(test_problem_list, test_medication_list, test_allergy_list): - """Create a test document with multiple FHIR resources.""" - doc = Document(data="Test note with multiple resources") - doc.fhir.set_bundle(create_bundle()) - - test_problem_list.append( - create_condition(subject="Patient/123", code="987", display="Test Condition 2") + problem_list = create_condition( + subject="Patient/123", code="38341003", display="Hypertension" ) - test_medication_list.append( - create_medication_statement( - subject="Patient/123", code="654", display="Test Medication 2" - ) + doc.fhir.problem_list = [problem_list] + + medication_list = create_medication_statement( + subject="Patient/123", code="123454", display="Aspirin" ) - test_allergy_list.append( - create_allergy_intolerance( - patient="Patient/123", code="321", display="Test Allergy 2" - ) + doc.fhir.medication_list = [medication_list] + + allergy_list = create_allergy_intolerance( + patient="Patient/123", code="70618", display="Allergy to peanuts" ) + doc.fhir.allergy_list = [allergy_list] - # Add multiple test FHIR resources - doc.fhir.problem_list = test_problem_list - doc.fhir.medication_list = test_medication_list - doc.fhir.allergy_list = test_allergy_list return doc -@pytest.fixture(autouse=True) -def setup_caplog(caplog): - caplog.set_level(logging.WARNING) - return caplog - - -class MockBundle(BaseModel): - condition: str = "test" - - -# TEMP -@dataclasses.dataclass -class synth_data: - context: dict - prefetch: MockBundle +@pytest.fixture +def test_empty_document(): + return Document(data="This is a sample text for testing.") class MockDataGenerator: diff --git a/tests/fhir/test_helpers.py b/tests/fhir/test_helpers.py index 88336258..64bc00b3 100644 --- a/tests/fhir/test_helpers.py +++ b/tests/fhir/test_helpers.py @@ -2,6 +2,7 @@ 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 datetime import datetime @@ -47,10 +48,12 @@ 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], 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" + 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" + ) def test_create_condition(): diff --git a/tests/pipeline/conftest.py b/tests/pipeline/conftest.py index b9120fa6..b532316f 100644 --- a/tests/pipeline/conftest.py +++ b/tests/pipeline/conftest.py @@ -4,18 +4,10 @@ from healthchain.io.cdsfhirconnector import CdsFhirConnector from healthchain.io.containers import Document from healthchain.io.containers.document import ( - CdsAnnotations, FhirData, ModelOutputs, - NlpAnnotations, -) -from healthchain.models.data.ccddata import CcdData -from healthchain.models.data.concept import ( - AllergyConcept, - ConceptLists, - MedicationConcept, - ProblemConcept, ) + from healthchain.models.responses.cdaresponse import CdaResponse from healthchain.pipeline.base import BasePipeline, ModelConfig, ModelSource from healthchain.models.responses.cdsresponse import CDSResponse, Card @@ -83,26 +75,23 @@ def hf_config(): ) -# CDS component fixtures +# CDS connector fixtures @pytest.fixture def mock_cds_card_creator(): with patch("healthchain.pipeline.modelrouter.ModelRouter.get_component") as mock: llm_instance = mock.return_value - llm_instance.return_value = Document( - data="Summarized discharge information", - _cds=CdsAnnotations( - _cards=[ - Card( - summary="Summarized discharge information", - detail="Patient John Doe was discharged. Encounter details...", - indicator="info", - source={"label": "Summarization LLM"}, - ) - ], - ), - ) + document = Document(data="Summarized discharge information") + document.cds.cards = [ + Card( + summary="Summarized discharge information", + detail="Patient John Doe was discharged. Encounter details...", + indicator="info", + source={"label": "Summarization LLM"}, + ) + ] + llm_instance.return_value = document yield mock @@ -135,82 +124,16 @@ def mock_cds_fhir_connector(test_condition): yield mock -# CDA component fixtures +# CDA connector fixtures -# TODO: UPDATE THESE @pytest.fixture -def mock_cda_annotator(): - with patch("healthchain.io.cdaconnector.CdaAnnotator") as mock: - mock_instance = mock.return_value - mock_instance.from_xml.return_value = mock_instance - mock_instance.problem_list = [ - ProblemConcept( - code="38341003", - code_system="2.16.840.1.113883.6.96", - code_system_name="SNOMED CT", - display_name="Hypertension", - ) - ] - mock_instance.medication_list = [ - MedicationConcept( - code="123454", - code_system="2.16.840.1.113883.6.96", - code_system_name="SNOMED CT", - display_name="Aspirin", - ) - ] - mock_instance.allergy_list = [ - AllergyConcept( - code="70618", - code_system="2.16.840.1.113883.6.96", - code_system_name="SNOMED CT", - display_name="Allergy to peanuts", - ) - ] - mock_instance.note = "Sample Note" - yield mock - - -@pytest.fixture -def mock_cda_connector(): +def mock_cda_connector(test_document): with patch("healthchain.io.cdaconnector.CdaConnector") as mock: connector_instance = mock.return_value # Mock the input method - connector_instance.input.return_value = Document( - data="Original note", - _fhir=FhirData( - _ccd_data=CcdData( - concepts=ConceptLists( - problems=[ - ProblemConcept( - code="38341003", - code_system="2.16.840.1.113883.6.96", - code_system_name="SNOMED CT", - display_name="Hypertension", - ) - ], - medications=[ - MedicationConcept( - code="123454", - code_system="2.16.840.1.113883.6.96", - code_system_name="SNOMED CT", - display_name="Aspirin", - ) - ], - allergies=[ - AllergyConcept( - code="70618", - code_system="2.16.840.1.113883.6.96", - code_system_name="SNOMED CT", - display_name="Allergy to peanuts", - ) - ], - ), - ), - ), - ) + connector_instance.input.return_value = test_document # Mock the output method connector_instance.output.return_value = CdaResponse( @@ -223,9 +146,8 @@ def mock_cda_connector(): # NLP component fixtures -# TODO: UPDATE THIS @pytest.fixture -def mock_spacy_nlp(): +def mock_spacy_nlp(test_document): with patch("healthchain.pipeline.components.integrations.SpacyNLP") as mock: # Create mock spaCy entities mock_ent = MagicMock() @@ -244,40 +166,12 @@ def mock_spacy_nlp(): mock_spacy_doc = MagicMock() mock_spacy_doc.ents = [mock_ent, mock_ent2, mock_ent3] + test_document._nlp._spacy_doc = mock_spacy_doc + # Setup the component instance component_instance = mock.return_value - component_instance.return_value = Document( - data="Processed note", - _nlp=NlpAnnotations( - _spacy_doc=mock_spacy_doc, - ), - _concepts=ConceptLists( - problems=[ - ProblemConcept( - code="38341003", - code_system="2.16.840.1.113883.6.96", - code_system_name="SNOMED CT", - display_name="Hypertension", - ) - ], - medications=[ - MedicationConcept( - code="123454", - code_system="2.16.840.1.113883.6.96", - code_system_name="SNOMED CT", - display_name="Aspirin", - ) - ], - allergies=[ - AllergyConcept( - code="70618", - code_system="2.16.840.1.113883.6.96", - code_system_name="SNOMED CT", - display_name="Allergy to peanuts", - ) - ], - ), - ) + component_instance.return_value = test_document + yield mock diff --git a/tests/pipeline/prebuilt/test_medicalcoding.py b/tests/pipeline/prebuilt/test_medicalcoding.py index 11f9cab3..05f5f205 100644 --- a/tests/pipeline/prebuilt/test_medicalcoding.py +++ b/tests/pipeline/prebuilt/test_medicalcoding.py @@ -49,15 +49,14 @@ def test_coding_pipeline(mock_cda_connector, mock_spacy_nlp): # Verify the pipeline used the mocked input and output input_doc = mock_cda_connector.return_value.input.return_value - assert input_doc.data == "Original note" + assert input_doc.data == "Test note" + assert input_doc.fhir.problem_list[0].code.coding[0].display == "Hypertension" assert ( - input_doc._hl7._ccd_data.concepts.problems[0].display_name == "Hypertension" + input_doc.fhir.medication_list[0].medication.concept.coding[0].display + == "Aspirin" ) assert ( - input_doc._hl7._ccd_data.concepts.medications[0].display_name == "Aspirin" - ) - assert ( - input_doc._hl7._ccd_data.concepts.allergies[0].display_name + input_doc.fhir.allergy_list[0].code.coding[0].display == "Allergy to peanuts" ) diff --git a/tests/pipeline/prebuilt/test_summarization.py b/tests/pipeline/prebuilt/test_summarization.py index 8f5b45f0..392cb984 100644 --- a/tests/pipeline/prebuilt/test_summarization.py +++ b/tests/pipeline/prebuilt/test_summarization.py @@ -9,6 +9,7 @@ def test_summarization_pipeline( mock_hf_transformer, mock_cds_card_creator, test_cds_request, + test_condition, ): with patch( "healthchain.pipeline.summarizationpipeline.CdsFhirConnector", @@ -66,24 +67,8 @@ def test_summarization_pipeline( # Verify the pipeline used the mocked input and output input_data = mock_cds_fhir_connector.return_value.input.return_value - assert input_data.hl7._fhir_data.context == { - "patientId": "123", - "encounterId": "456", - } - assert input_data.hl7._fhir_data.model_dump_prefetch() == { - "resourceType": "Bundle", - "entry": [ - { - "resource": { - "resourceType": "Patient", - "id": "123", - "name": [{"family": "Doe", "given": ["John"]}], - "gender": "male", - "birthDate": "1970-01-01", - } - }, - ], - } + + assert input_data.fhir.get_prefetch_resources("problem") == test_condition # Verify stages are set correctly assert len(pipeline._stages) == 2 From b4bfb9ce2d24129797c5538ee7b92b2da28252b4 Mon Sep 17 00:00:00 2001 From: jenniferjiangkells Date: Fri, 21 Feb 2025 14:17:08 +0000 Subject: [PATCH 17/34] Fixed tests in data generator --- healthchain/data_generators/basegenerators.py | 21 ++- .../data_generators/cdsdatagenerator.py | 130 +++++++++++------- .../data_generators/conditiongenerators.py | 111 ++++++--------- .../medicationadministrationgenerators.py | 6 +- .../medicationrequestgenerators.py | 5 +- .../test_cds_data_generator.py | 34 ++++- ...st_medication_administration_generators.py | 5 + .../test_medication_request_generators.py | 5 +- tests/integration_tests/test_full_workflow.py | 2 +- 9 files changed, 191 insertions(+), 128 deletions(-) diff --git a/healthchain/data_generators/basegenerators.py b/healthchain/data_generators/basegenerators.py index 5918ac9b..e8a15ee5 100644 --- a/healthchain/data_generators/basegenerators.py +++ b/healthchain/data_generators/basegenerators.py @@ -1,5 +1,6 @@ # generators.py +import datetime import random import string @@ -75,7 +76,25 @@ def generate(): class DateTimeGenerator(BaseGenerator): @staticmethod def generate(): - return faker.date_time().isoformat() + return faker.date_time(tzinfo=datetime.timezone.utc).isoformat() + + +@register_generator +class IntentGenerator(BaseGenerator): + @staticmethod + def generate(): + return faker.random_element( + [ + "proposal", + "plan", + "order", + "original-order", + "reflex-order", + "instance-order", + "filler-order", + "option", + ] + ) @register_generator diff --git a/healthchain/data_generators/cdsdatagenerator.py b/healthchain/data_generators/cdsdatagenerator.py index c4c912d5..c07ccca1 100644 --- a/healthchain/data_generators/cdsdatagenerator.py +++ b/healthchain/data_generators/cdsdatagenerator.py @@ -2,28 +2,38 @@ import csv import logging -from pydantic import BaseModel -from typing import Callable, Dict, Optional +from typing import Callable, Dict, Optional, List from pathlib import Path from healthchain.base import Workflow -from fhir.resources.bundle import Bundle, BundleEntry +from fhir.resources.resource import Resource from healthchain.data_generators.basegenerators import generator_registry -from fhir.resources.documentreference import DocumentReference -from fhir.resources.narrative import Narrative -from healthchain.models.data.cdsfhirdata import CdsFhirData +from healthchain.fhir import create_document_reference logger = logging.getLogger(__name__) +# TODO: generate test context - move from hook models class CdsDataGenerator: """ A class to generate CDS (Clinical Decision Support) data based on specified workflows and constraints. + This class provides functionality to generate synthetic FHIR resources for testing CDS systems. + It uses registered data generators to create resources like Patients, Encounters, Conditions etc. + based on configured workflows. It can also incorporate free text data from CSV files. + Attributes: - registry (dict): A registry of data generators. - mappings (dict): A mapping of workflows to their respective data generators. - data (CdsFhirData): The generated CDS FHIR data. + registry (dict): A registry mapping generator names to generator classes. + mappings (dict): A mapping of workflow names to lists of required generators. + generated_data (Dict[str, Resource]): The most recently generated FHIR resources. + workflow (str): The currently active workflow. + + Example: + >>> generator = CdsDataGenerator() + >>> generator.set_workflow("encounter_discharge") + >>> data = generator.generate_prefetch( + ... random_seed=42 + ... ) """ # TODO: Add ordering and logic so that patient/encounter IDs are passed to subsequent generators @@ -46,17 +56,22 @@ class CdsDataGenerator: def __init__(self): self.registry = generator_registry self.mappings = self.default_workflow_mappings - self.data: CdsFhirData = None + self.generated_data: Dict[str, Resource] = {} def fetch_generator(self, generator_name: str) -> Callable: """ - Fetches a data generator function by its name from the registry. + Fetches a data generator class by its name from the registry. - Parameters: - generator_name (str): The name of the data generator to fetch. + Args: + generator_name (str): The name of the data generator to fetch (e.g. "PatientGenerator", "EncounterGenerator") Returns: - Callable: The data generator function. + Callable: The data generator class that can be used to generate FHIR resources. Returns None if generator not found. + + Example: + >>> generator = CdsDataGenerator() + >>> patient_gen = generator.fetch_generator("PatientGenerator") + >>> patient = patient_gen.generate() """ return self.registry.get(generator_name) @@ -69,26 +84,42 @@ def set_workflow(self, workflow: str) -> None: """ self.workflow = workflow - def generate( + def generate_prefetch( self, constraints: Optional[list] = None, free_text_path: Optional[str] = None, column_name: Optional[str] = None, random_seed: Optional[int] = None, - ) -> BaseModel: + ) -> Dict[str, Resource]: """ Generates CDS data based on the current workflow, constraints, and optional free text data. - Parameters: + This method generates FHIR resources according to the configured workflow mapping. For each + resource type in the workflow, it uses the corresponding generator to create a FHIR resource. + If free text data is provided via CSV, it will also generate a DocumentReference containing + randomly selected text from the CSV. + + Args: constraints (Optional[list]): A list of constraints to apply to the data generation. - free_text_path (Optional[str]): The path to a CSV file containing free text data. - column_name (Optional[str]): The column name in the CSV file to use for free text data. - random_seed (Optional[int]): The random seed to use for reproducible data generation. + Each constraint should match the format expected by the individual generators. + free_text_path (Optional[str]): Path to a CSV file containing free text data to be + included as DocumentReferences. If provided, column_name must also be specified. + column_name (Optional[str]): The name of the column in the CSV file containing the + free text data to use. Required if free_text_path is provided. + random_seed (Optional[int]): Seed value for random number generation to ensure + reproducible results. If not provided, generation will be truly random. Returns: - BaseModel: The generated CDS FHIR data. + Dict[str, Resource]: A dictionary mapping resource types to generated FHIR resources. + The keys are lowercase resource type names (e.g. "patient", "encounter"). + If free text is provided, includes a "document" key with a DocumentReference. + + Raises: + ValueError: If the configured workflow is not found in the mappings + FileNotFoundError: If the free_text_path is provided but file not found + ValueError: If free_text_path provided without column_name """ - results = [] + prefetch = {} if self.workflow not in self.mappings.keys(): raise ValueError(f"Workflow {self.workflow} not found in mappings") @@ -96,11 +127,11 @@ def generate( for resource in self.mappings[self.workflow]: generator_name = resource["generator"] generator = self.fetch_generator(generator_name) - result = generator.generate( + resource = generator.generate( constraints=constraints, random_seed=random_seed ) - results.append(BundleEntry(resource=result)) + prefetch[resource.__resource_type__.lower()] = resource parsed_free_text = ( self.free_text_parser(free_text_path, column_name) @@ -108,24 +139,38 @@ def generate( else None ) if parsed_free_text: - results.append(BundleEntry(resource=random.choice(parsed_free_text))) + prefetch["document"] = create_document_reference( + data=random.choice(parsed_free_text), + content_type="text/plain", + status="current", + description="Free text created by HealthChain CdsDataGenerator", + attachment_title="Free text created by HealthChain CdsDataGenerator", + ) + + self.generated_data = prefetch - output = CdsFhirData(prefetch=Bundle(resourceType="Bundle", entry=results)) - self.data = output - return output + return self.generated_data - def free_text_parser(self, path_to_csv: str, column_name: str) -> Dict: + def free_text_parser(self, path_to_csv: str, column_name: str) -> List[str]: """ - Parses free text data from a CSV file and converts it into a list of DocumentReference models. + Parse free text data from a CSV file. - Parameters: - path_to_csv (str): The path to the CSV file containing free text data. - column_name (str): The column name in the CSV file to use for free text data. + This method reads a CSV file and extracts text data from a specified column. The text data + can later be used to create DocumentReference resources. + + Args: + path_to_csv (str): Path to the CSV file containing the free text data. + column_name (str): Name of the column in the CSV file to extract text from. Returns: - dict: A dictionary of parsed free text data converted into DocumentReference models. + List[str]: List of text strings extracted from the specified column. + + Raises: + FileNotFoundError: If the specified CSV file does not exist or is not a file. + ValueError: If column_name is not provided. + Exception: If any other error occurs while reading/parsing the CSV file. """ - column_data = [] + text_data = [] # Check that path_to_csv is a valid path with pathlib path = Path(path_to_csv) @@ -139,7 +184,7 @@ def free_text_parser(self, path_to_csv: str, column_name: str) -> Dict: reader = csv.DictReader(file) if column_name is not None: for row in reader: - column_data.append(row[column_name]) + text_data.append(row[column_name]) else: raise ValueError( "Column name must be provided when header is True." @@ -147,15 +192,4 @@ def free_text_parser(self, path_to_csv: str, column_name: str) -> Dict: except Exception as ex: logger.error(f"An error occurred: {ex}") - document_list = [] - - for x in column_data: - # First parse x in to documentreferencemodel format - text = Narrative( - status="generated", - div=f'
{x}
', - ) - doc = DocumentReference(resourceType="DocumentReference", text=text) - document_list.append(doc) - - return document_list + return text_data diff --git a/healthchain/data_generators/conditiongenerators.py b/healthchain/data_generators/conditiongenerators.py index 67a5b608..8a3a3c77 100644 --- a/healthchain/data_generators/conditiongenerators.py +++ b/healthchain/data_generators/conditiongenerators.py @@ -1,19 +1,16 @@ from typing import Optional from faker import Faker +from fhir.resources.reference import Reference +from fhir.resources.condition import ConditionStage, ConditionParticipant + +from healthchain.fhir.helpers import create_single_codeable_concept, create_condition from healthchain.data_generators.basegenerators import ( BaseGenerator, generator_registry, register_generator, CodeableConceptGenerator, ) - -from fhir.resources.codeableconcept import CodeableConcept -from fhir.resources.coding import Coding -from fhir.resources.reference import Reference - -from fhir.resources.condition import Condition, ConditionStage, ConditionParticipant - from healthchain.data_generators.value_sets.conditioncodes import ( ConditionCodeSimple, ConditionCodeComplex, @@ -27,15 +24,11 @@ class ClinicalStatusGenerator(BaseGenerator): @staticmethod def generate(): - return CodeableConcept( - coding=[ - Coding( - system="http://terminology.hl7.org/CodeSystem/condition-clinical", - code=faker.random_element( - elements=("active", "recurrence", "inactive", "resolved") - ), - ) - ] + return create_single_codeable_concept( + code=faker.random_element( + elements=("active", "recurrence", "inactive", "resolved") + ), + system="http://terminology.hl7.org/CodeSystem/condition-clinical", ) @@ -43,13 +36,9 @@ def generate(): class VerificationStatusGenerator(BaseGenerator): @staticmethod def generate(): - return CodeableConcept( - coding=[ - Coding( - system="http://terminology.hl7.org/CodeSystem/condition-ver-status", - code=faker.random_element(elements=("provisional", "confirmed")), - ) - ] + return create_single_codeable_concept( + code=faker.random_element(elements=("provisional", "confirmed")), + system="http://terminology.hl7.org/CodeSystem/condition-ver-status", ) @@ -57,15 +46,11 @@ def generate(): class CategoryGenerator(BaseGenerator): @staticmethod def generate(): - return CodeableConcept( - coding=[ - Coding( - system="http://snomed.info/sct", - code=faker.random_element( - elements=("55607006", "404684003") - ), # Snomed Codes -> probably want to overwrite with template - ) - ] + return create_single_codeable_concept( + code=faker.random_element( + elements=("55607006", "404684003") + ), # Snomed Codes -> probably want to overwrite with template + system="http://snomed.info/sct", ) @@ -84,16 +69,9 @@ def generate(): class SeverityGenerator(BaseGenerator): @staticmethod def generate(): - return CodeableConcept( - coding=[ - Coding( - system="http://snomed.info/sct", - code=faker.random_element( - elements=("24484000", "6736007", "255604002") - ), - # TODO: Add display values for the codes - ) - ] + return create_single_codeable_concept( + code=faker.random_element(elements=("24484000", "6736007", "255604002")), + system="http://snomed.info/sct", ) @@ -116,14 +94,10 @@ def generate(self, constraints: Optional[list] = None): class BodySiteGenerator(BaseGenerator): @staticmethod def generate(): - return CodeableConcept( - coding=[ - Coding( - system="http://snomed.info/sct", - code=faker.random_element(elements=("38266002")), - display=faker.random_element(elements=("Entire body as a whole")), - ) - ] + return create_single_codeable_concept( + code=faker.random_element(elements=("38266002")), + display=faker.random_element(elements=("Entire body as a whole")), + system="http://snomed.info/sct", ) @@ -152,21 +126,22 @@ def generate( code = generator_registry.get("SnomedCodeGenerator").generate( constraints=constraints ) - return Condition( - id=generator_registry.get("IdGenerator").generate(), - clinicalStatus=generator_registry.get("ClinicalStatusGenerator").generate(), - verificationStatus=generator_registry.get( - "VerificationStatusGenerator" - ).generate(), - category=[generator_registry.get("CategoryGenerator").generate()], - severity=generator_registry.get("SeverityGenerator").generate(), - code=code, - bodySite=[generator_registry.get("BodySiteGenerator").generate()], - subject=Reference(reference=subject_reference), - encounter=Reference(reference=encounter_reference), - onsetDateTime=generator_registry.get("DateGenerator").generate(), - abatementDateTime=generator_registry.get( - "DateGenerator" - ).generate(), ## TODO: Constraint abatementDateTime to be after onsetDateTime - recordedDate=generator_registry.get("DateGenerator").generate(), - ) + condition = create_condition(subject=subject_reference) + condition.clinicalStatus = generator_registry.get( + "ClinicalStatusGenerator" + ).generate() + condition.verificationStatus = generator_registry.get( + "VerificationStatusGenerator" + ).generate() + condition.category = [generator_registry.get("CategoryGenerator").generate()] + condition.severity = generator_registry.get("SeverityGenerator").generate() + condition.code = code + condition.bodySite = [generator_registry.get("BodySiteGenerator").generate()] + condition.encounter = Reference(reference=encounter_reference) + condition.onsetDateTime = generator_registry.get("DateGenerator").generate() + condition.abatementDateTime = generator_registry.get( + "DateGenerator" + ).generate() ## TODO: Constraint abatementDateTime to be after onsetDateTime + condition.recordedDate = generator_registry.get("DateGenerator").generate() + + return condition diff --git a/healthchain/data_generators/medicationadministrationgenerators.py b/healthchain/data_generators/medicationadministrationgenerators.py index dedea1cd..90392308 100644 --- a/healthchain/data_generators/medicationadministrationgenerators.py +++ b/healthchain/data_generators/medicationadministrationgenerators.py @@ -40,12 +40,14 @@ def generate( return MedicationAdministration( id=generator_registry.get("IdGenerator").generate(), status=generator_registry.get("EventStatusGenerator").generate(), + occurenceDateTime=generator_registry.get("DateGenerator").generate(), medication=CodeableReference( - reference=Reference(reference="Medication/123") + concept=generator_registry.get( + "MedicationRequestContainedGenerator" + ).generate() ), subject=Reference(reference=subject_reference), encounter=Reference(reference=encounter_reference), - authoredOn=generator_registry.get("DateGenerator").generate(), dosage=generator_registry.get( "MedicationAdministrationDosageGenerator" ).generate(), diff --git a/healthchain/data_generators/medicationrequestgenerators.py b/healthchain/data_generators/medicationrequestgenerators.py index 8ef25e67..03dbec37 100644 --- a/healthchain/data_generators/medicationrequestgenerators.py +++ b/healthchain/data_generators/medicationrequestgenerators.py @@ -50,8 +50,11 @@ def generate( resourceType="MedicationRequest", id=generator_registry.get("IdGenerator").generate(), status=generator_registry.get("EventStatusGenerator").generate(), + intent=generator_registry.get("IntentGenerator").generate(), medication=CodeableReference( - reference=Reference(reference="Medication/123") + concept=generator_registry.get( + "MedicationRequestContainedGenerator" + ).generate() ), subject=Reference(reference=subject_reference), encounter=Reference(reference=encounter_reference), diff --git a/tests/generators_tests/test_cds_data_generator.py b/tests/generators_tests/test_cds_data_generator.py index f22deb52..61e078eb 100644 --- a/tests/generators_tests/test_cds_data_generator.py +++ b/tests/generators_tests/test_cds_data_generator.py @@ -1,5 +1,10 @@ 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 healthchain.data_generators import CdsDataGenerator from healthchain.workflows import Workflow @@ -9,9 +14,16 @@ def test_generator_orchestrator_encounter_discharge(): workflow = Workflow.encounter_discharge generator.set_workflow(workflow=workflow) - generator.generate() + generator.generate_prefetch() - assert len(generator.data.model_dump(by_alias=True)["prefetch"]["entry"]) == 4 + assert len(generator.generated_data) == 4 + assert generator.generated_data["encounter"] is not None + assert isinstance(generator.generated_data["encounter"], Encounter) + assert generator.generated_data["condition"] is not None + assert isinstance(generator.generated_data["condition"], Condition) + assert generator.generated_data["procedure"] is not None + assert isinstance(generator.generated_data["procedure"], Procedure) + assert generator.generated_data["medicationrequest"] is not None def test_generator_orchestrator_patient_view(): @@ -19,9 +31,15 @@ def test_generator_orchestrator_patient_view(): workflow = Workflow.patient_view generator.set_workflow(workflow=workflow) - generator.generate() + generator.generate_prefetch() - assert len(generator.data.model_dump(by_alias=True)["prefetch"]["entry"]) == 3 + assert len(generator.generated_data) == 3 + assert generator.generated_data["patient"] is not None + assert isinstance(generator.generated_data["patient"], Patient) + assert generator.generated_data["encounter"] is not None + assert isinstance(generator.generated_data["encounter"], Encounter) + assert generator.generated_data["condition"] is not None + assert isinstance(generator.generated_data["condition"], Condition) @pytest.mark.skip() @@ -30,8 +48,12 @@ def test_generator_with_json(): workflow = Workflow.patient_view generator.set_workflow(workflow=workflow) - generator.generate( + generator.generate_prefetch( free_text_path="use_cases/my_encounter_data.csv", column_name="free_text" ) - assert len(generator.data.model_dump(by_alias=True)["prefetch"]["entry"]) == 4 + assert len(generator.generated_data) == 4 + assert generator.generated_data["patient"] is not None + assert generator.generated_data["encounter"] is not None + assert generator.generated_data["condition"] is not None + assert generator.generated_data["document"] is not None diff --git a/tests/generators_tests/test_medication_administration_generators.py b/tests/generators_tests/test_medication_administration_generators.py index 699bb442..673ccb6a 100644 --- a/tests/generators_tests/test_medication_administration_generators.py +++ b/tests/generators_tests/test_medication_administration_generators.py @@ -2,6 +2,9 @@ MedicationAdministrationDosageGenerator, MedicationAdministrationGenerator, ) +from healthchain.data_generators.value_sets.medicationcodes import ( + MedicationRequestMedication, +) def test_MedicationAdministrationDosageGenerator(): @@ -10,7 +13,9 @@ def test_MedicationAdministrationDosageGenerator(): def test_MedicationAdministrationGenerator(): + value_set = [x.code for x in MedicationRequestMedication().value_set] 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 diff --git a/tests/generators_tests/test_medication_request_generators.py b/tests/generators_tests/test_medication_request_generators.py index 6fca11ae..88d24a7f 100644 --- a/tests/generators_tests/test_medication_request_generators.py +++ b/tests/generators_tests/test_medication_request_generators.py @@ -10,7 +10,9 @@ def test_MedicationGenerator(): generator = MedicationRequestContainedGenerator() medication = generator.generate() + value_set = [x.code for x in MedicationRequestMedication().value_set] assert medication is not None + assert medication.coding[0].code in value_set def test_MedicationRequestGenerator(): @@ -19,4 +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.contained[0].code.coding[0].code in value_set + assert medication_request.medication.concept.coding[0].code in value_set + assert medication_request.intent is not None diff --git a/tests/integration_tests/test_full_workflow.py b/tests/integration_tests/test_full_workflow.py index 573b4e3f..f87ec93d 100644 --- a/tests/integration_tests/test_full_workflow.py +++ b/tests/integration_tests/test_full_workflow.py @@ -22,7 +22,7 @@ def define_chain(self): # decorator sets up an instance of ehr configured with use case CDS @ehr(workflow="encounter-discharge", num=3) def load_data(self): - data = self.data_generator.generate(constraints=["long_duration"]) + data = self.data_generator.generate_prefetch(constraints=["long_duration"]) return data @api From 3a4745f6521978b2b6c6be24842cea6a0649c20f Mon Sep 17 00:00:00 2001 From: jenniferjiangkells Date: Fri, 21 Feb 2025 14:32:26 +0000 Subject: [PATCH 18/34] Deletion of CcdData and CdsFhirData, which are now fully migrated to FHIR resources --- cookbook/cds_discharge_summarizer_hf_chat.py | 2 +- cookbook/cds_discharge_summarizer_hf_trf.py | 2 +- healthchain/models/__init__.py | 20 --- healthchain/models/data/__init__.py | 26 ---- healthchain/models/data/ccddata.py | 22 ---- healthchain/models/data/cdsfhirdata.py | 56 --------- healthchain/models/data/concept.py | 121 ------------------- tests/conftest.py | 6 +- 8 files changed, 3 insertions(+), 252 deletions(-) delete mode 100644 healthchain/models/data/__init__.py delete mode 100644 healthchain/models/data/ccddata.py delete mode 100644 healthchain/models/data/cdsfhirdata.py delete mode 100644 healthchain/models/data/concept.py diff --git a/cookbook/cds_discharge_summarizer_hf_chat.py b/cookbook/cds_discharge_summarizer_hf_chat.py index c87512e6..c8a219ae 100644 --- a/cookbook/cds_discharge_summarizer_hf_chat.py +++ b/cookbook/cds_discharge_summarizer_hf_chat.py @@ -49,7 +49,7 @@ def __init__(self): @hc.ehr(workflow="encounter-discharge") def load_data_in_client(self) -> CdsFhirData: # Generate synthetic FHIR data for testing - data = self.data_generator.generate( + data = self.data_generator.generate_prefetch( free_text_path="data/discharge_notes.csv", column_name="text" ) return data diff --git a/cookbook/cds_discharge_summarizer_hf_trf.py b/cookbook/cds_discharge_summarizer_hf_trf.py index cec581d5..23ca7a79 100644 --- a/cookbook/cds_discharge_summarizer_hf_trf.py +++ b/cookbook/cds_discharge_summarizer_hf_trf.py @@ -23,7 +23,7 @@ def __init__(self): @hc.ehr(workflow="encounter-discharge") def load_data_in_client(self) -> CdsFhirData: - data = self.data_generator.generate( + data = self.data_generator.generate_prefetch( free_text_path="data/discharge_notes.csv", column_name="text" ) return data diff --git a/healthchain/models/__init__.py b/healthchain/models/__init__.py index 58d202ca..13de5201 100644 --- a/healthchain/models/__init__.py +++ b/healthchain/models/__init__.py @@ -13,17 +13,6 @@ CDSServiceInformation, CdaResponse, ) -from .data import ( - Concept, - ProblemConcept, - AllergyConcept, - MedicationConcept, - CdsFhirData, - CcdData, - Quantity, - Range, - TimeInterval, -) __all__ = [ "CDSRequest", @@ -39,15 +28,6 @@ "Source", "Card", "CDSResponse", - "CdsFhirData", "CdaRequest", "CdaResponse", - "CcdData", - "Concept", - "ProblemConcept", - "AllergyConcept", - "MedicationConcept", - "Quantity", - "Range", - "TimeInterval", ] diff --git a/healthchain/models/data/__init__.py b/healthchain/models/data/__init__.py deleted file mode 100644 index 353c11c2..00000000 --- a/healthchain/models/data/__init__.py +++ /dev/null @@ -1,26 +0,0 @@ -from .concept import ( - Concept, - ProblemConcept, - MedicationConcept, - AllergyConcept, - ConceptLists, - Quantity, - Range, - TimeInterval, -) -from .cdsfhirdata import CdsFhirData -from .ccddata import CcdData - - -__all__ = [ - "CdsFhirData", - "CcdData", - "Concept", - "ProblemConcept", - "MedicationConcept", - "AllergyConcept", - "ConceptLists", - "Quantity", - "Range", - "TimeInterval", -] diff --git a/healthchain/models/data/ccddata.py b/healthchain/models/data/ccddata.py deleted file mode 100644 index 2906c7ab..00000000 --- a/healthchain/models/data/ccddata.py +++ /dev/null @@ -1,22 +0,0 @@ -from pydantic import BaseModel -from typing import Optional, Union, Dict - -from healthchain.models.data.concept import ConceptLists - - -class CcdData(BaseModel): - """ - Data model for CCD (Continuity of Care Document) that can be converted to CDA. - - Attributes: - concepts (ConceptLists): Container for medical concepts (problems, allergies, medications) - extracted from the document. Defaults to empty ConceptLists. - note (Optional[Union[Dict, str]]): The clinical note text, either as a string or - dictionary of sections. Defaults to None. - cda_xml (Optional[str]): The raw CDA XML string representation of the document. - Defaults to None. - """ - - concepts: ConceptLists = ConceptLists() - note: Optional[Union[Dict, str]] = None - cda_xml: Optional[str] = None diff --git a/healthchain/models/data/cdsfhirdata.py b/healthchain/models/data/cdsfhirdata.py deleted file mode 100644 index b096e01a..00000000 --- a/healthchain/models/data/cdsfhirdata.py +++ /dev/null @@ -1,56 +0,0 @@ -import copy - -from pydantic import BaseModel, Field -from typing import Dict - -from fhir.resources.bundle import Bundle - - -class CdsFhirData(BaseModel): - """ - Data model for CDS FHIR data, matching the expected fields in CDSRequests. - - Attributes: - context (Dict): A dictionary containing contextual information for the CDS request. - prefetch (Bundle): A Bundle object containing prefetched FHIR resources. - - Methods: - create(cls, context: Dict, prefetch: Dict): Class method to create a CdsFhirData instance. - model_dump(*args, **kwargs): Returns a dictionary representation of the model. - model_dump_json(*args, **kwargs): Returns a JSON string representation of the model. - model_dump_prefetch(*args, **kwargs): Returns a dictionary representation of the prefetch Bundle. - """ - - context: Dict = Field(default={}) - prefetch: Bundle - - @classmethod - def create(cls, context: Dict, prefetch: Dict): - # deep copy to avoid modifying the original prefetch data - prefetch_copy = copy.deepcopy(prefetch) - bundle = Bundle(**prefetch_copy) - return cls(context=context, prefetch=bundle) - - def model_dump(self, *args, **kwargs): - kwargs.setdefault("exclude_unset", True) - kwargs.setdefault("exclude_defaults", False) - kwargs.setdefault("exclude_none", True) - kwargs.setdefault("by_alias", True) - - return super().model_dump(*args, **kwargs) - - def model_dump_json(self, *args, **kwargs): - kwargs.setdefault("exclude_unset", True) - kwargs.setdefault("exclude_defaults", False) - kwargs.setdefault("exclude_none", True) - kwargs.setdefault("by_alias", True) - - return super().model_dump_json(*args, **kwargs) - - def model_dump_prefetch(self, *args, **kwargs): - kwargs.setdefault("exclude_unset", True) - kwargs.setdefault("exclude_defaults", False) - kwargs.setdefault("exclude_none", True) - kwargs.setdefault("by_alias", True) - - return self.prefetch.model_dump(*args, **kwargs) diff --git a/healthchain/models/data/concept.py b/healthchain/models/data/concept.py deleted file mode 100644 index 5d0279b9..00000000 --- a/healthchain/models/data/concept.py +++ /dev/null @@ -1,121 +0,0 @@ -from enum import Enum -from pydantic import BaseModel, Field, field_validator -from typing import List, Optional, Dict, Union - - -class Standard(Enum): - cda = "cda" - fhir = "fhir" - - -class DataType(BaseModel): - """ - Base class for all data types - """ - - _source: Optional[Dict] = None - - -class Quantity(DataType): - # TODO: validate conversions str <-> float - value: Optional[Union[str, float]] = None - unit: Optional[str] = None - - @field_validator("value") - @classmethod - def validate_value(cls, value: Union[str, float]): - if value is None: - return None - - if not isinstance(value, (str, float)): - raise TypeError( - f"Value CANNOT be a {type(value)} object. Must be float or string in float format." - ) - - try: - return float(value) - - except ValueError: - raise ValueError(f"Invalid value '{value}' . Must be a float Number.") - - except OverflowError: - raise OverflowError( - "Invalid value . Value is too large resulting in overflow." - ) - - -class Range(DataType): - low: Optional[Quantity] = None - high: Optional[Quantity] = None - - -class TimeInterval(DataType): - period: Optional[Quantity] = None - phase: Optional[Range] = None - institution_specified: Optional[bool] = None - - -class Concept(BaseModel): - """ - A more lenient, system agnostic representation of a concept e.g. problems, medications, allergies - that can be converted to CDA or FHIR - """ - - _standard: Optional[Standard] = None - code: Optional[str] = None - code_system: Optional[str] = None - code_system_name: Optional[str] = None - display_name: Optional[str] = None - - -class ProblemConcept(Concept): - """ - Contains problem/condition specific fields - """ - - onset_date: Optional[str] = None - abatement_date: Optional[str] = None - status: Optional[str] = None - recorded_date: Optional[str] = None - - -class MedicationConcept(Concept): - """ - Contains medication specific fields - """ - - dosage: Optional[Quantity] = None - route: Optional[Concept] = None - frequency: Optional[TimeInterval] = None - duration: Optional[Range] = None - precondition: Optional[Dict] = None - - -class AllergyConcept(Concept): - """ - Contains allergy specific fields - - Defaults allergy type to propensity to adverse reactions in SNOMED CT - """ - - allergy_type: Optional[Concept] = Field( - default=Concept( - code="420134006", - code_system="2.16.840.1.113883.6.96", - code_system_name="SNOMED CT", - display_name="Propensity to adverse reactions", - ) - ) - severity: Optional[Concept] = None - reaction: Optional[Concept] = None - - -class ConceptLists(BaseModel): - """Container for lists of medical concepts extracted from text.""" - - problems: List[ProblemConcept] = [] - allergies: List[AllergyConcept] = [] - medications: List[MedicationConcept] = [] - - class Config: - arbitrary_types_allowed = True diff --git a/tests/conftest.py b/tests/conftest.py index 04e42a27..b6047de0 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -4,8 +4,6 @@ from healthchain.base import BaseStrategy, BaseUseCase from healthchain.cda_parser.cdaannotator import CdaAnnotator -from fhir.resources.bundle import Bundle, BundleEntry -from healthchain.models.data.cdsfhirdata import CdsFhirData from healthchain.models.requests.cdarequest import CdaRequest from healthchain.models.requests.cdsrequest import CDSRequest from healthchain.models.responses.cdaresponse import CdaResponse @@ -180,9 +178,7 @@ def test_empty_document(): class MockDataGenerator: def __init__(self) -> None: - self.data = CdsFhirData( - context={}, prefetch=Bundle(entry=[BundleEntry()], type="document") - ) + self.generated_data = {"document": create_bundle()} self.workflow = None def set_workflow(self, workflow): From 59d928f1937061e16803601708389be45ec5c341 Mon Sep 17 00:00:00 2001 From: jenniferjiangkells Date: Fri, 21 Feb 2025 14:42:09 +0000 Subject: [PATCH 19/34] Add eval backport to fix CI error --- poetry.lock | 16 +++++++++++++++- pyproject.toml | 1 + 2 files changed, 16 insertions(+), 1 deletion(-) diff --git a/poetry.lock b/poetry.lock index 4527088f..9265c188 100644 --- a/poetry.lock +++ b/poetry.lock @@ -571,6 +571,20 @@ files = [ {file = "distlib-0.3.9.tar.gz", hash = "sha256:a60f20dea646b8a33f3e7772f74dc0b2d0772d2837ee1342a00645c81edf9403"}, ] +[[package]] +name = "eval-type-backport" +version = "0.1.3" +description = "Like `typing._eval_type`, but lets older Python versions use newer typing features." +optional = false +python-versions = ">=3.7" +files = [ + {file = "eval_type_backport-0.1.3-py3-none-any.whl", hash = "sha256:519d2a993b3da286df9f90e17f503f66435106ad870cf26620c5720e2158ddf2"}, + {file = "eval_type_backport-0.1.3.tar.gz", hash = "sha256:d83ee225331dfa009493cec1f3608a71550b515ee4749abe78da14e3c5e314f5"}, +] + +[package.extras] +tests = ["pytest"] + [[package]] name = "exceptiongroup" version = "1.2.2" @@ -3392,4 +3406,4 @@ type = ["pytest-mypy"] [metadata] lock-version = "2.0" python-versions = ">=3.8,<3.12" -content-hash = "f317cb685fbe5ee37470f03beeaca699601a5bb5029bcc359e76d2939dc6668c" +content-hash = "26e0e1c27f5b77fda60153a68515bbb3767e097e7277834df08e11237513d0dd" diff --git a/pyproject.toml b/pyproject.toml index 2c1ec7e7..ccc0ba72 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -25,6 +25,7 @@ include = ["healthchain/templates/*"] [tool.poetry.dependencies] python = ">=3.8,<3.12" pydantic = "^2.7.1" +eval_type_backport = "^0.1.0" pandas = ">=1.0.0,<3.0.0" spacy = ">=3.0.0,<4.0.0" numpy = "<2.0.0" From 16dd7611a370ab9f7b2cfa6c72f24ed2867ff5eb Mon Sep 17 00:00:00 2001 From: jenniferjiangkells Date: Fri, 21 Feb 2025 14:45:52 +0000 Subject: [PATCH 20/34] Fix tests --- docs/api/data_models.md | 5 ----- tests/test_service_with_func.py | 2 +- 2 files changed, 1 insertion(+), 6 deletions(-) delete mode 100644 docs/api/data_models.md diff --git a/docs/api/data_models.md b/docs/api/data_models.md deleted file mode 100644 index 46b2e29f..00000000 --- a/docs/api/data_models.md +++ /dev/null @@ -1,5 +0,0 @@ -# Data Models - -::: healthchain.models.data.ccddata -::: healthchain.models.data.cdsfhirdata -::: healthchain.models.data.concept diff --git a/tests/test_service_with_func.py b/tests/test_service_with_func.py index 01612dc8..46f2ab4e 100644 --- a/tests/test_service_with_func.py +++ b/tests/test_service_with_func.py @@ -19,7 +19,7 @@ def __init__(self) -> None: # decorator sets up an instance of ehr configured with use case CDS @ehr(workflow="encounter-discharge", num=3) def load_data(self): - return self.data_generator.data + return self.data_generator.generated_data @api def test_service(self, request: CDSRequest): From 7946ed836b8de33aa293506fff9bea3413d4cb9f Mon Sep 17 00:00:00 2001 From: jenniferjiangkells Date: Fri, 21 Feb 2025 14:48:00 +0000 Subject: [PATCH 21/34] Remove test not needed --- tests/test_quantity_class.py | 40 ------------------------------------ 1 file changed, 40 deletions(-) delete mode 100644 tests/test_quantity_class.py diff --git a/tests/test_quantity_class.py b/tests/test_quantity_class.py deleted file mode 100644 index 1413fd5c..00000000 --- a/tests/test_quantity_class.py +++ /dev/null @@ -1,40 +0,0 @@ -import pytest -from healthchain.models.data.concept import Quantity -from pydantic import ValidationError - - -# Valid Cases -def test_valid(): - valid_floats = [1.0, 0.1, 4.5, 5.99999, 12455.321, 33, 1234, None] - for num in valid_floats: - q = Quantity(value=num, unit="mg") - assert q.value == num - - -def test_valid_string(): - valid_strings = ["100", "100.000001", ".1", "1.", ".123", "1234.", "123989"] - for string in valid_strings: - q = Quantity(value=string, unit="mg") - assert q.value == float(string) - - -# Invalid Cases -def test_invalid_strings(): - invalid_strings = [ - "1.0.0", - "1..123", - "..123", - "12..", - "12a.56", - "1e4.6", - "12#.45", - "12.12@3", - "12@3", - "abc", - "None", - "", - ] - for string in invalid_strings: - with pytest.raises(ValidationError) as exception_info: - Quantity(value=string, unit="mg") - assert "Invalid value" in str(exception_info.value) From a5a23fcd24aabc077eb3e47f9152d67975e5064e Mon Sep 17 00:00:00 2001 From: jenniferjiangkells Date: Wed, 26 Feb 2025 14:33:34 +0000 Subject: [PATCH 22/34] Added more stack trace and more detailed error in client --- healthchain/clients/ehrclient.py | 3 +-- healthchain/decorators.py | 2 +- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/healthchain/clients/ehrclient.py b/healthchain/clients/ehrclient.py index f4fc30dd..49f77b93 100644 --- a/healthchain/clients/ehrclient.py +++ b/healthchain/clients/ehrclient.py @@ -155,7 +155,6 @@ async def send_request(self, url: str) -> List[Dict]: """ async with httpx.AsyncClient() as client: responses: List[Dict] = [] - # TODO: pass timeout as config timeout = httpx.Timeout(self.timeout, read=None) for request in self.request_data: try: @@ -180,7 +179,7 @@ async def send_request(self, url: str) -> List[Dict]: responses.append(response.json()) except httpx.HTTPStatusError as exc: log.error( - f"Error response {exc.response.status_code} while requesting {exc.request.url!r}." + f"Error response {exc.response.status_code} while requesting {exc.request.url!r}: {exc.response.json()}" ) responses.append({}) except httpx.TimeoutException as exc: diff --git a/healthchain/decorators.py b/healthchain/decorators.py index 46cfb58d..d5e5c108 100644 --- a/healthchain/decorators.py +++ b/healthchain/decorators.py @@ -269,7 +269,7 @@ def start_sandbox( self._client.send_request(url=self.url.service) ) except Exception as e: - log.error(f"Couldn't start client: {e}") + log.error(f"Couldn't start client: {e}", exc_info=True) if save_data: save_dir = Path(save_dir) From 94b7a851ba8395df3baa06def740a1f3903e9985 Mon Sep 17 00:00:00 2001 From: jenniferjiangkells Date: Wed, 26 Feb 2025 14:35:00 +0000 Subject: [PATCH 23/34] Added prefetch base model and model_dump method to CdsRequest and updated cookedbook usage --- cookbook/cds_discharge_summarizer_hf_chat.py | 4 +- .../data_generators/cdsdatagenerator.py | 11 ++--- healthchain/models/__init__.py | 2 + healthchain/models/hooks/__init__.py | 3 ++ healthchain/models/hooks/basehookcontext.py | 2 - healthchain/models/hooks/prefetch.py | 7 ++++ healthchain/models/requests/cdsrequest.py | 21 ++++++++++ healthchain/pipeline/components/llm.py | 20 ---------- healthchain/use_cases/cds.py | 12 ++---- tests/conftest.py | 13 +++--- .../test_cds_data_generator.py | 40 +++++++++---------- tests/test_strategy.py | 20 +++------- 12 files changed, 78 insertions(+), 77 deletions(-) create mode 100644 healthchain/models/hooks/prefetch.py delete mode 100644 healthchain/pipeline/components/llm.py diff --git a/cookbook/cds_discharge_summarizer_hf_chat.py b/cookbook/cds_discharge_summarizer_hf_chat.py index c8a219ae..d4ef69e1 100644 --- a/cookbook/cds_discharge_summarizer_hf_chat.py +++ b/cookbook/cds_discharge_summarizer_hf_chat.py @@ -2,7 +2,7 @@ from healthchain.pipeline import SummarizationPipeline from healthchain.use_cases import ClinicalDecisionSupport -from healthchain.models import CdsFhirData, CDSRequest, CDSResponse +from healthchain.models import CDSRequest, CDSResponse, Prefetch from healthchain.data_generators import CdsDataGenerator from langchain_huggingface.llms import HuggingFaceEndpoint @@ -47,7 +47,7 @@ def __init__(self): self.data_generator = CdsDataGenerator() @hc.ehr(workflow="encounter-discharge") - def load_data_in_client(self) -> CdsFhirData: + def load_data_in_client(self) -> Prefetch: # Generate synthetic FHIR data for testing data = self.data_generator.generate_prefetch( free_text_path="data/discharge_notes.csv", column_name="text" diff --git a/healthchain/data_generators/cdsdatagenerator.py b/healthchain/data_generators/cdsdatagenerator.py index c07ccca1..473e16f9 100644 --- a/healthchain/data_generators/cdsdatagenerator.py +++ b/healthchain/data_generators/cdsdatagenerator.py @@ -8,6 +8,7 @@ from healthchain.base import Workflow from fhir.resources.resource import Resource from healthchain.data_generators.basegenerators import generator_registry +from healthchain.models import Prefetch from healthchain.fhir import create_document_reference logger = logging.getLogger(__name__) @@ -90,7 +91,7 @@ def generate_prefetch( free_text_path: Optional[str] = None, column_name: Optional[str] = None, random_seed: Optional[int] = None, - ) -> Dict[str, Resource]: + ) -> Prefetch: """ Generates CDS data based on the current workflow, constraints, and optional free text data. @@ -110,7 +111,7 @@ def generate_prefetch( reproducible results. If not provided, generation will be truly random. Returns: - Dict[str, Resource]: A dictionary mapping resource types to generated FHIR resources. + Prefetch: A dictionary mapping resource types to generated FHIR resources. The keys are lowercase resource type names (e.g. "patient", "encounter"). If free text is provided, includes a "document" key with a DocumentReference. @@ -119,7 +120,7 @@ def generate_prefetch( FileNotFoundError: If the free_text_path is provided but file not found ValueError: If free_text_path provided without column_name """ - prefetch = {} + prefetch = Prefetch(prefetch={}) if self.workflow not in self.mappings.keys(): raise ValueError(f"Workflow {self.workflow} not found in mappings") @@ -131,7 +132,7 @@ def generate_prefetch( constraints=constraints, random_seed=random_seed ) - prefetch[resource.__resource_type__.lower()] = resource + prefetch.prefetch[resource.__resource_type__.lower()] = resource parsed_free_text = ( self.free_text_parser(free_text_path, column_name) @@ -139,7 +140,7 @@ def generate_prefetch( else None ) if parsed_free_text: - prefetch["document"] = create_document_reference( + prefetch.prefetch["document"] = create_document_reference( data=random.choice(parsed_free_text), content_type="text/plain", status="current", diff --git a/healthchain/models/__init__.py b/healthchain/models/__init__.py index 13de5201..8b8caba2 100644 --- a/healthchain/models/__init__.py +++ b/healthchain/models/__init__.py @@ -13,6 +13,7 @@ CDSServiceInformation, CdaResponse, ) +from .hooks import Prefetch __all__ = [ "CDSRequest", @@ -30,4 +31,5 @@ "CDSResponse", "CdaRequest", "CdaResponse", + "Prefetch", ] diff --git a/healthchain/models/hooks/__init__.py b/healthchain/models/hooks/__init__.py index 5657f3cc..e19b9e2b 100644 --- a/healthchain/models/hooks/__init__.py +++ b/healthchain/models/hooks/__init__.py @@ -2,10 +2,13 @@ from .encounterdischarge import EncounterDischargeContext from .orderselect import OrderSelectContext from .ordersign import OrderSignContext +from .prefetch import Prefetch + __all__ = [ "PatientViewContext", "EncounterDischargeContext", "OrderSelectContext", "OrderSignContext", + "Prefetch", ] diff --git a/healthchain/models/hooks/basehookcontext.py b/healthchain/models/hooks/basehookcontext.py index 9f732c2a..72995a4b 100644 --- a/healthchain/models/hooks/basehookcontext.py +++ b/healthchain/models/hooks/basehookcontext.py @@ -1,9 +1,7 @@ from pydantic import BaseModel -from typing import Optional from abc import ABC class BaseHookContext(BaseModel, ABC): userId: str patientId: str - encounterId: Optional[str] = None diff --git a/healthchain/models/hooks/prefetch.py b/healthchain/models/hooks/prefetch.py new file mode 100644 index 00000000..56e0c503 --- /dev/null +++ b/healthchain/models/hooks/prefetch.py @@ -0,0 +1,7 @@ +from typing import Dict +from pydantic import BaseModel +from fhir.resources.resource import Resource + + +class Prefetch(BaseModel): + prefetch: Dict[str, Resource] diff --git a/healthchain/models/requests/cdsrequest.py b/healthchain/models/requests/cdsrequest.py index d862f36d..c88a642f 100644 --- a/healthchain/models/requests/cdsrequest.py +++ b/healthchain/models/requests/cdsrequest.py @@ -1,4 +1,5 @@ from pydantic import BaseModel, HttpUrl, Field +from datetime import datetime from typing import Optional, List, Dict, Any from healthchain.models.hooks.basehookcontext import BaseHookContext @@ -43,3 +44,23 @@ class CDSRequest(BaseModel): None # fhir resource is passed either thru prefetched template of fhir server ) extension: Optional[List[Dict[str, Any]]] = None + + def model_dump(self, **kwargs): + """ + Convert the model to a dictionary, converting any nested datetime objects to strings + and byte objects to strings. + """ + + def convert_objects(obj): + if isinstance(obj, dict): + return {k: convert_objects(v) for k, v in obj.items()} + elif isinstance(obj, list): + return [convert_objects(i) for i in obj] + elif isinstance(obj, datetime): + return obj.isoformat() + elif isinstance(obj, bytes): + return obj.decode("utf-8") + return obj + + dump = super().model_dump(**kwargs) + return convert_objects(dump) diff --git a/healthchain/pipeline/components/llm.py b/healthchain/pipeline/components/llm.py deleted file mode 100644 index 7331c5fc..00000000 --- a/healthchain/pipeline/components/llm.py +++ /dev/null @@ -1,20 +0,0 @@ -from healthchain.pipeline.components.base import Component -from healthchain.io.containers import Document -from typing import TypeVar, Generic - -T = TypeVar("T") - - -# TODO: implement this class -class LLM(Component[T], Generic[T]): - def __init__(self, model_name: str): - self.model = model_name - - def load_model(self): - pass - - def load_chain(self): - pass - - def __call__(self, doc: Document) -> Document: - return doc diff --git a/healthchain/use_cases/cds.py b/healthchain/use_cases/cds.py index b8801f7a..f75a67b7 100644 --- a/healthchain/use_cases/cds.py +++ b/healthchain/use_cases/cds.py @@ -26,6 +26,7 @@ OrderSignContext, PatientViewContext, EncounterDischargeContext, + Prefetch, ) @@ -75,20 +76,15 @@ def construct_request( raise ValueError( f"Invalid workflow {workflow.value} or workflow model not implemented." ) - if not isinstance(prefetch_data, dict): + if not isinstance(prefetch_data, Prefetch): raise TypeError( - f"Prefetch data must be a dictionary, but got {type(prefetch_data)}" + f"Prefetch data must be a Prefetch object, but got {type(prefetch_data)}" ) - for key, value in prefetch_data.items(): - if not isinstance(value, Resource): - raise TypeError( - f"Prefetch value {key} is not a valid FHIR resource, but {type(value)}" - ) request = CDSRequest( hook=workflow.value, context=context_model(**context), - prefetch=prefetch_data, + prefetch=prefetch_data.prefetch, ) return request diff --git a/tests/conftest.py b/tests/conftest.py index b6047de0..29c0f057 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -4,6 +4,7 @@ from healthchain.base import BaseStrategy, BaseUseCase from healthchain.cda_parser.cdaannotator import CdaAnnotator +from healthchain.models.hooks.prefetch import Prefetch from healthchain.models.requests.cdarequest import CdaRequest from healthchain.models.requests.cdsrequest import CDSRequest from healthchain.models.responses.cdaresponse import CdaResponse @@ -197,11 +198,13 @@ def cds_strategy(): @pytest.fixture def valid_prefetch_data(): - return { - "document": create_document_reference( - content_type="text/plain", data="Test document content" - ) - } + return Prefetch( + prefetch={ + "document": create_document_reference( + content_type="text/plain", data="Test document content" + ) + } + ) @pytest.fixture diff --git a/tests/generators_tests/test_cds_data_generator.py b/tests/generators_tests/test_cds_data_generator.py index 61e078eb..e336f32a 100644 --- a/tests/generators_tests/test_cds_data_generator.py +++ b/tests/generators_tests/test_cds_data_generator.py @@ -16,14 +16,14 @@ def test_generator_orchestrator_encounter_discharge(): generator.set_workflow(workflow=workflow) generator.generate_prefetch() - assert len(generator.generated_data) == 4 - assert generator.generated_data["encounter"] is not None - assert isinstance(generator.generated_data["encounter"], Encounter) - assert generator.generated_data["condition"] is not None - assert isinstance(generator.generated_data["condition"], Condition) - assert generator.generated_data["procedure"] is not None - assert isinstance(generator.generated_data["procedure"], Procedure) - assert generator.generated_data["medicationrequest"] is not None + assert len(generator.generated_data.prefetch) == 4 + assert generator.generated_data.prefetch["encounter"] is not None + assert isinstance(generator.generated_data.prefetch["encounter"], Encounter) + assert generator.generated_data.prefetch["condition"] is not None + assert isinstance(generator.generated_data.prefetch["condition"], Condition) + assert generator.generated_data.prefetch["procedure"] is not None + assert isinstance(generator.generated_data.prefetch["procedure"], Procedure) + assert generator.generated_data.prefetch["medicationrequest"] is not None def test_generator_orchestrator_patient_view(): @@ -33,13 +33,13 @@ def test_generator_orchestrator_patient_view(): generator.set_workflow(workflow=workflow) generator.generate_prefetch() - assert len(generator.generated_data) == 3 - assert generator.generated_data["patient"] is not None - assert isinstance(generator.generated_data["patient"], Patient) - assert generator.generated_data["encounter"] is not None - assert isinstance(generator.generated_data["encounter"], Encounter) - assert generator.generated_data["condition"] is not None - assert isinstance(generator.generated_data["condition"], Condition) + assert len(generator.generated_data.prefetch) == 3 + assert generator.generated_data.prefetch["patient"] is not None + assert isinstance(generator.generated_data.prefetch["patient"], Patient) + assert generator.generated_data.prefetch["encounter"] is not None + assert isinstance(generator.generated_data.prefetch["encounter"], Encounter) + assert generator.generated_data.prefetch["condition"] is not None + assert isinstance(generator.generated_data.prefetch["condition"], Condition) @pytest.mark.skip() @@ -52,8 +52,8 @@ def test_generator_with_json(): free_text_path="use_cases/my_encounter_data.csv", column_name="free_text" ) - assert len(generator.generated_data) == 4 - assert generator.generated_data["patient"] is not None - assert generator.generated_data["encounter"] is not None - assert generator.generated_data["condition"] is not None - assert generator.generated_data["document"] is not None + assert len(generator.generated_data.prefetch) == 4 + assert generator.generated_data.prefetch["patient"] is not None + assert generator.generated_data.prefetch["encounter"] is not None + assert generator.generated_data.prefetch["condition"] is not None + assert generator.generated_data.prefetch["document"] is not None diff --git a/tests/test_strategy.py b/tests/test_strategy.py index c6cc663b..c9eb657b 100644 --- a/tests/test_strategy.py +++ b/tests/test_strategy.py @@ -44,7 +44,7 @@ def test_valid_request_construction(cds_strategy, valid_prefetch_data): mock_init.assert_called_once_with( hook=Workflow.patient_view.value, context=PatientViewContext(userId="Practitioner/123", patientId="123"), - prefetch=valid_prefetch_data, + prefetch=valid_prefetch_data.prefetch, ) # # Test OrderSelectContext @@ -96,7 +96,7 @@ def test_error_handling(cds_strategy, valid_prefetch_data): # Test invalid context keys with pytest.raises(ValueError): cds_strategy.construct_request( - prefetch_data={}, + prefetch_data=valid_prefetch_data, workflow=Workflow.patient_view, context={"invalidId": "Practitioner", "patientId": "123"}, ) @@ -104,21 +104,11 @@ def test_error_handling(cds_strategy, valid_prefetch_data): # Test missing required context data with pytest.raises(ValueError): cds_strategy.construct_request( - prefetch_data={}, + prefetch_data=valid_prefetch_data, workflow=Workflow.patient_view, context={"userId": "Practitioner"}, ) - # Test invalid prefetch data type - invalid_prefetch = {"patient": {"id": "123"}} # Not a FHIR Resource - with pytest.raises(TypeError) as excinfo: - cds_strategy.construct_request( - prefetch_data=invalid_prefetch, - workflow=Workflow.patient_view, - context={"userId": "Practitioner/123", "patientId": "123"}, - ) - assert "not a valid FHIR resource" in str(excinfo.value) - # Test unsupported workflow mock_workflow = MagicMock() mock_workflow.value = "unsupported-workflow" @@ -144,12 +134,12 @@ def test_workflow_validation(cds_strategy, valid_prefetch_data): # Test valid workflow result = cds_strategy.construct_request( - prefetch_data={}, + prefetch_data=valid_prefetch_data, workflow=Workflow.patient_view, context={"userId": "Practitioner/123", "patientId": "123"}, ) assert isinstance(result, CDSRequest) - assert result.prefetch == {} + assert result.prefetch == valid_prefetch_data.prefetch def test_cda_request_construction( From 2b832963692487fd6c0aef1e4ed7a0c33709aedb Mon Sep 17 00:00:00 2001 From: jenniferjiangkells Date: Wed, 26 Feb 2025 17:44:17 +0000 Subject: [PATCH 24/34] Add timezone and note to cdsrequest model_dump() --- healthchain/clients/ehrclient.py | 1 + healthchain/models/requests/cdsrequest.py | 7 ++++--- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/healthchain/clients/ehrclient.py b/healthchain/clients/ehrclient.py index 49f77b93..5b93ccb9 100644 --- a/healthchain/clients/ehrclient.py +++ b/healthchain/clients/ehrclient.py @@ -170,6 +170,7 @@ async def send_request(self, url: str) -> List[Dict]: response_model = CdaResponse(document=response.text) responses.append(response_model.model_dump_xml()) else: + # TODO: use model_dump_json() once Pydantic V2 timezone serialization issue is resolved response = await client.post( url=url, json=request.model_dump(exclude_none=True), diff --git a/healthchain/models/requests/cdsrequest.py b/healthchain/models/requests/cdsrequest.py index c88a642f..99e08004 100644 --- a/healthchain/models/requests/cdsrequest.py +++ b/healthchain/models/requests/cdsrequest.py @@ -47,8 +47,9 @@ class CDSRequest(BaseModel): def model_dump(self, **kwargs): """ - Convert the model to a dictionary, converting any nested datetime objects to strings - and byte objects to strings. + Model dump method to convert any nested datetime and byte objects to strings for readability. + This is also a workaround to this Pydantic V2 issue https://github.com/pydantic/pydantic/issues/9571 + For proper JSON serialization, should use model_dump_json() instead when issue is resolved. """ def convert_objects(obj): @@ -57,7 +58,7 @@ def convert_objects(obj): elif isinstance(obj, list): return [convert_objects(i) for i in obj] elif isinstance(obj, datetime): - return obj.isoformat() + return obj.astimezone().isoformat() elif isinstance(obj, bytes): return obj.decode("utf-8") return obj From 725d1483745272a59fc457510da6b698029abac2 Mon Sep 17 00:00:00 2001 From: jenniferjiangkells Date: Wed, 26 Feb 2025 17:45:00 +0000 Subject: [PATCH 25/34] Add prefetch validation to cdsconnector --- cookbook/cds_discharge_summarizer_hf_trf.py | 4 +-- healthchain/io/cdsfhirconnector.py | 14 ++++++--- healthchain/models/hooks/prefetch.py | 32 ++++++++++++++++++-- tests/pipeline/test_cdsfhirconnector.py | 33 ++++++++++++++++----- 4 files changed, 66 insertions(+), 17 deletions(-) diff --git a/cookbook/cds_discharge_summarizer_hf_trf.py b/cookbook/cds_discharge_summarizer_hf_trf.py index 23ca7a79..400a4b00 100644 --- a/cookbook/cds_discharge_summarizer_hf_trf.py +++ b/cookbook/cds_discharge_summarizer_hf_trf.py @@ -2,7 +2,7 @@ from healthchain.pipeline import SummarizationPipeline from healthchain.use_cases import ClinicalDecisionSupport -from healthchain.models import CdsFhirData, CDSRequest, CDSResponse +from healthchain.models import Prefetch, CDSRequest, CDSResponse from healthchain.data_generators import CdsDataGenerator import getpass @@ -22,7 +22,7 @@ def __init__(self): self.data_generator = CdsDataGenerator() @hc.ehr(workflow="encounter-discharge") - def load_data_in_client(self) -> CdsFhirData: + def load_data_in_client(self) -> Prefetch: data = self.data_generator.generate_prefetch( free_text_path="data/discharge_notes.csv", column_name="text" ) diff --git a/healthchain/io/cdsfhirconnector.py b/healthchain/io/cdsfhirconnector.py index a2908746..7195137d 100644 --- a/healthchain/io/cdsfhirconnector.py +++ b/healthchain/io/cdsfhirconnector.py @@ -8,7 +8,7 @@ from healthchain.models.requests.cdsrequest import CDSRequest from healthchain.models.responses.cdsresponse import CDSResponse from healthchain.fhir import read_content_attachment - +from healthchain.models.hooks.prefetch import Prefetch log = logging.getLogger(__name__) @@ -37,7 +37,8 @@ def input( Takes a CDSRequest containing FHIR resources and extracts them into a Document object. The Document will contain all prefetched FHIR resources in its fhir.prefetch_resources. If a DocumentReference resource is provided via prefetch_document_key, its text content - will be extracted into Document.data. + will be extracted into Document.data. For multiple attachments, the text content will be + concatenated with newlines. Args: cds_request (CDSRequest): The CDSRequest containing FHIR resources in its prefetch @@ -50,6 +51,7 @@ def input( Document: A Document object containing: - All prefetched FHIR resources in fhir.prefetch_resources - Any text content from the DocumentReference in data (empty string if none found) + - For multiple attachments, text content is concatenated with newlines Raises: ValueError: If neither prefetch nor fhirServer is provided in cds_request @@ -67,11 +69,15 @@ def input( # Create an empty Document object doc = Document(data="") + # Validate the prefetch data + validated_prefetch = Prefetch(prefetch=cds_request.prefetch) + # Set the prefetch resources - doc.fhir.set_prefetch_resources(cds_request.prefetch) + doc.fhir.set_prefetch_resources(validated_prefetch.prefetch) # Extract text content from DocumentReference resource if provided - document_resource = cds_request.prefetch.get(prefetch_document_key) + document_resource = validated_prefetch.prefetch.get(prefetch_document_key) + if not document_resource: log.warning( f"No DocumentReference resource found in prefetch data with key {prefetch_document_key}" diff --git a/healthchain/models/hooks/prefetch.py b/healthchain/models/hooks/prefetch.py index 56e0c503..085c1678 100644 --- a/healthchain/models/hooks/prefetch.py +++ b/healthchain/models/hooks/prefetch.py @@ -1,7 +1,33 @@ -from typing import Dict -from pydantic import BaseModel +from typing import Dict, Any +from pydantic import BaseModel, field_validator from fhir.resources.resource import Resource +from fhir.resources import get_fhir_model_class class Prefetch(BaseModel): - prefetch: Dict[str, Resource] + prefetch: Dict[str, Any] + + @field_validator("prefetch") + @classmethod + def validate_fhir_resources(cls, v: Dict[str, Any]) -> Dict[str, Resource]: + if not v: + return v + + validated = {} + for key, resource_dict in v.items(): + if not isinstance(resource_dict, dict): + continue + + resource_type = resource_dict.get("resourceType") + if not resource_type: + continue + + try: + # Get the appropriate FHIR resource class + resource_class = get_fhir_model_class(resource_type) + # Convert the dict to a FHIR resource + validated[key] = resource_class.model_validate(resource_dict) + except Exception as e: + raise ValueError(f"Failed to validate FHIR resource {key}: {str(e)}") + + return validated diff --git a/tests/pipeline/test_cdsfhirconnector.py b/tests/pipeline/test_cdsfhirconnector.py index 030fc818..94aa53e3 100644 --- a/tests/pipeline/test_cdsfhirconnector.py +++ b/tests/pipeline/test_cdsfhirconnector.py @@ -3,6 +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 def test_input_with_no_document_reference(cds_fhir_connector, test_cds_request): @@ -17,29 +19,32 @@ def test_input_with_no_document_reference(cds_fhir_connector, test_cds_request): assert ( result.data == "" ) # Data should be empty since no DocumentReference is provided - assert result._fhir._prefetch_resources == input_data.prefetch + assert all( + isinstance(resource, Resource) + for resource in result.fhir._prefetch_resources.values() + ) def test_input_with_document_reference( cds_fhir_connector, test_cds_request, doc_ref_with_content ): # Add DocumentReference to prefetch data - test_cds_request.prefetch["document"] = doc_ref_with_content + test_cds_request.prefetch["document"] = doc_ref_with_content.model_dump() # Call the input method result = cds_fhir_connector.input(test_cds_request) # Assert the result assert isinstance(result, Document) + assert isinstance(result.fhir._prefetch_resources["document"], DocumentReference) assert result.data == "Test document content" - assert result._fhir._prefetch_resources == test_cds_request.prefetch def test_input_with_multiple_attachments( cds_fhir_connector, test_cds_request, doc_ref_with_multiple_content ): # Add DocumentReference to prefetch data - test_cds_request.prefetch["document"] = doc_ref_with_multiple_content + test_cds_request.prefetch["document"] = doc_ref_with_multiple_content.model_dump() # Call the input method result = cds_fhir_connector.input(test_cds_request) @@ -49,14 +54,26 @@ def test_input_with_multiple_attachments( assert ( result.data == "First content\nSecond content\n" ) # Attachments should be concatenated - assert result._fhir._prefetch_resources == test_cds_request.prefetch + assert isinstance(result.fhir._prefetch_resources["document"], DocumentReference) + assert ( + result.fhir._prefetch_resources["document"] + .content[0] + .attachment.data.decode("utf-8") + == "First content" + ) + assert ( + result.fhir._prefetch_resources["document"] + .content[1] + .attachment.data.decode("utf-8") + == "Second content" + ) def test_input_with_custom_document_key( cds_fhir_connector, test_cds_request, doc_ref_with_content ): # Add DocumentReference to prefetch data with custom key - test_cds_request.prefetch["custom_key"] = doc_ref_with_content + test_cds_request.prefetch["custom_key"] = doc_ref_with_content.model_dump() # Call the input method with custom key result = cds_fhir_connector.input( @@ -66,14 +83,14 @@ def test_input_with_custom_document_key( # Assert the result assert isinstance(result, Document) assert result.data == "Test document content" - assert result._fhir._prefetch_resources == test_cds_request.prefetch + assert isinstance(result.fhir._prefetch_resources["custom_key"], DocumentReference) def test_input_with_document_reference_error( cds_fhir_connector, test_cds_request, doc_ref_without_content, caplog ): # Add invalid DocumentReference to prefetch data - test_cds_request.prefetch["document"] = doc_ref_without_content + test_cds_request.prefetch["document"] = doc_ref_without_content.model_dump() # Call the input method result = cds_fhir_connector.input(test_cds_request) From 3e177744a589a8c5778ae7d3b917dde7568d34db Mon Sep 17 00:00:00 2001 From: jenniferjiangkells Date: Wed, 26 Feb 2025 18:59:12 +0000 Subject: [PATCH 26/34] Fix tests --- tests/conftest.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/conftest.py b/tests/conftest.py index 29c0f057..e00cf0eb 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -179,7 +179,7 @@ def test_empty_document(): class MockDataGenerator: def __init__(self) -> None: - self.generated_data = {"document": create_bundle()} + self.generated_data = Prefetch(prefetch={"document": create_bundle()}) self.workflow = None def set_workflow(self, workflow): From 9ab14921479badc496ad1974cc0d8b61edf1ad82 Mon Sep 17 00:00:00 2001 From: jenniferjiangkells Date: Thu, 27 Feb 2025 21:40:22 +0000 Subject: [PATCH 27/34] bugfixes --- healthchain/cda_parser/cdaannotator.py | 52 +++++++++----------------- healthchain/cda_parser/utils.py | 14 +++++++ 2 files changed, 32 insertions(+), 34 deletions(-) diff --git a/healthchain/cda_parser/cdaannotator.py b/healthchain/cda_parser/cdaannotator.py index 7ae7b27d..fd7c63e2 100644 --- a/healthchain/cda_parser/cdaannotator.py +++ b/healthchain/cda_parser/cdaannotator.py @@ -33,35 +33,6 @@ log = logging.getLogger(__name__) -# def get_time_range_from_cda_value(value: Dict) -> Range: -# """ -# Converts a dictionary representing a time range from a CDA value into a Range object. - -# Args: -# value (Dict): A dictionary representing the CDA value. - -# Returns: -# Range: A Range object representing the time range. - -# """ -# range_model = Range( -# low=Quantity( -# value=value.get("low", {}).get("@value"), -# unit=value.get("low", {}).get("@unit"), -# ), -# high=Quantity( -# value=value.get("high", {}).get("@value"), -# unit=value.get("high", {}).get("@unit"), -# ), -# ) -# if range_model.low.value is None: -# range_model.low = None -# if range_model.high.value is None: -# range_model.high = None - -# return range_model - - def get_value_from_entry_relationship(entry_relationship: EntryRelationship) -> List: """ Retrieves the values from the given entry_relationship. @@ -646,7 +617,9 @@ def get_allergy_details_from_entry_relationship( for value in values: # Map CDA system to FHIR system allergy_code_system = self.code_mapping.cda_to_fhir( - value.get("@codeSystem"), "system", default="http://snomed.info/sct" + value.get("@codeSystem", ""), + "system", + default="http://snomed.info/sct", ) allergy = create_allergy_intolerance( patient="Patient/123", # TODO: Get from patient context @@ -654,7 +627,7 @@ def get_allergy_details_from_entry_relationship( display=value.get("@displayName"), system=allergy_code_system, ) - if allergy.code.coding[0].display is None: + if allergy.code and allergy.code.coding[0].display is None: allergy.code.coding[0].display = allergen_name if allergy_type: @@ -734,6 +707,10 @@ def _add_new_problem_entry( ) # Get CDA system from FHIR system + if not new_problem.code: + log.warning("No code found for problem") + return + fhir_system = new_problem.code.coding[0].system cda_system = self.code_mapping.fhir_to_cda( fhir_system, "system", default="2.16.840.1.113883.6.96" @@ -847,7 +824,7 @@ def add_to_problem_list( for problem in problems: if problem in self.problem_list: log.debug( - f"Skipping: Problem {problem.display_name} already exists in the problem list." + f"Skipping: Problem {problem.model_dump()} already exists in the problem list." ) continue log.debug(f"Adding problem: {problem}") @@ -887,6 +864,10 @@ def _add_new_medication_entry( - Status as Active """ + if not new_medication.medication.concept: + log.warning("No medication concept found for medication") + return + # Get CDA system from FHIR system fhir_system = new_medication.medication.concept.coding[0].system cda_system = self.code_mapping.fhir_to_cda( @@ -1060,7 +1041,7 @@ def add_to_medication_list( for medication in medications: if medication in self.medication_list: log.debug( - f"Skipping: medication {medication.medication.concept.coding[0].display} already exists in the medication list." + f"Skipping: medication {medication.model_dump()} already exists in the medication list." ) continue @@ -1097,6 +1078,9 @@ def _add_new_allergy_entry( Returns: None """ + if not new_allergy.code: + log.warning("No code found for allergy") + return template = { "act": { @@ -1319,7 +1303,7 @@ def add_to_allergy_list( for allergy in allergies: if allergy in self.allergy_list: - log.debug(f"Allergy {allergy.code.coding[0].display} already exists") + log.debug(f"Allergy {allergy.model_dump()} already exists") continue log.debug(f"Adding allergy: {allergy}") self._add_new_allergy_entry( diff --git a/healthchain/cda_parser/utils.py b/healthchain/cda_parser/utils.py index 16dcadb7..976b57a8 100644 --- a/healthchain/cda_parser/utils.py +++ b/healthchain/cda_parser/utils.py @@ -94,6 +94,12 @@ def cda_to_fhir( """Convert CDA code to FHIR code.""" try: mapping = self.mappings[mapping_type]["cda_to_fhir"] + + # Add null check for code + if code is None: + log.error(f"Received None code for mapping type '{mapping_type}'") + return default + if not case_sensitive: code = code.lower() mapping = {k.lower(): v for k, v in mapping.items()} @@ -106,6 +112,14 @@ def cda_to_fhir( except KeyError: log.error(f"Invalid mapping type: {mapping_type}") return default + except AttributeError as e: + log.error(f"Invalid code type for '{code}' in {mapping_type}: {str(e)}") + return default + except Exception as e: + log.error( + f"Unexpected error converting code '{code}' in {mapping_type}: {str(e)}" + ) + return default def fhir_to_cda( self, From 011dcfccc4a2d9b6fe075c95c709709a17a902fd Mon Sep 17 00:00:00 2001 From: jenniferjiangkells Date: Thu, 27 Feb 2025 21:41:54 +0000 Subject: [PATCH 28/34] Refactor add_concept_to_hc_doc function --- healthchain/io/containers/document.py | 37 +++++++++++ .../pipeline/components/integrations.py | 63 ++++++++++--------- 2 files changed, 72 insertions(+), 28 deletions(-) diff --git a/healthchain/io/containers/document.py b/healthchain/io/containers/document.py index 34df6ad1..117169b9 100644 --- a/healthchain/io/containers/document.py +++ b/healthchain/io/containers/document.py @@ -4,6 +4,7 @@ from uuid import uuid4 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 @@ -19,6 +20,7 @@ set_resources, create_single_codeable_concept, read_content_attachment, + create_condition, ) logger = logging.getLogger(__name__) @@ -630,6 +632,41 @@ def word_count(self) -> int: """ return len(self._nlp._tokens) + def update_problem_list_from_nlp(self): + """ + Updates the document's problem list by extracting medical entities from the spaCy annotations. + + This method looks for entities in the document's spaCy annotations that have associated + SNOMED CT concept IDs (CUIs). For each valid entity found, it creates a new FHIR Condition + resource and adds it to the document's problem list. + + The method requires that: + 1. A spaCy doc has been added to the document's NLP annotations + 2. The entities in the spaCy doc have the 'cui' extension attribute set + + Note: + - Currently defaults to using SNOMED CT coding system + - Uses a hardcoded patient reference "Patient/123" + - Preserves any existing conditions in the problem list + """ + conditions = self.fhir.problem_list + # TODO: Make this configurable + for ent in self.nlp._spacy_doc.ents: + if not Span.has_extension("cui") or ent._.cui is None: + logger.debug(f"No CUI found for entity {ent.text}") + continue + condition = create_condition( + subject="Patient/123", + code=ent._.cui, + display=ent.text, + system="http://snomed.info/sct", + ) + logger.debug(f"Adding condition {condition.model_dump()}") + conditions.append(condition) + + # Add to document concepts + self.fhir.problem_list = conditions + def __iter__(self) -> Iterator[str]: return iter(self._nlp._tokens) diff --git a/healthchain/pipeline/components/integrations.py b/healthchain/pipeline/components/integrations.py index 5c06b9ed..a598cbc0 100644 --- a/healthchain/pipeline/components/integrations.py +++ b/healthchain/pipeline/components/integrations.py @@ -1,16 +1,18 @@ +import logging from typing import Any, Callable, TypeVar -from spacy.tokens import Doc as SpacyDoc from spacy.language import Language from functools import wraps from healthchain.io.containers import Document from healthchain.pipeline.components.base import BaseComponent -from healthchain.fhir import create_condition T = TypeVar("T") +log = logging.getLogger(__name__) + + def requires_package(package_name: str, import_path: str) -> Callable: """Decorator to check if an optional package is available. @@ -101,36 +103,41 @@ def from_model_id(cls, model: str, **kwargs: Any) -> "SpacyNLP": return cls(nlp) - def _add_concepts_to_hc_doc(self, spacy_doc: SpacyDoc, hc_doc: Document): - """ - Extract entities from spaCy Doc and add them to the HealthChain Document concepts. - - Args: - spacy_doc (Doc): The processed spaCy Doc object containing entities - hc_doc (Document): The HealthChain Document to store concepts in - - Note: Defaults to Condition and SNOMED CT concepts - # TODO: make configurable - """ - concepts = [] - # TODO: Review this, too specific to MedCAT, coding system needs to be configurable - for ent in spacy_doc.ents: - concept = create_condition( - subject="Patient/123", - code=ent._.cui if hasattr(ent, "_.cui") else None, - display=ent.text, - system="http://snomed.info/sct", - ) - - concepts.append(concept) - - # Add to document concepts - hc_doc.fhir.problem_list = concepts + # def _add_concepts_to_hc_doc(self, spacy_doc: SpacyDoc, hc_doc: Document): + # """ + # Extract entities from spaCy Doc and add them to the HealthChain Document concepts. + + # Args: + # spacy_doc (Doc): The processed spaCy Doc object containing entities + # hc_doc (Document): The HealthChain Document to store concepts in + + # Note: Defaults to Condition and SNOMED CT concepts + # # TODO: make configurable + # """ + # concepts = [] + # # TODO: Review this, too specific to MedCAT, coding system needs to be configurable + # for ent in spacy_doc.ents: + # print(ent._.cui) + # if not ent._.cui: + # log.warning(f"No CUI found for entity {ent.text}") + # continue + # condition = create_condition( + # subject="Patient/123", + # code=ent._.cui, + # display=ent.text, + # system="http://snomed.info/sct", + # ) + + # print("adding condition", condition.model_dump()) + # concepts.append(condition) + + # # Add to document concepts + # hc_doc.fhir.problem_list = concepts def __call__(self, doc: Document) -> Document: """Process the document using the spaCy pipeline. Adds outputs to nlp.spacy_docs.""" spacy_doc = self._nlp(doc.data) - self._add_concepts_to_hc_doc(spacy_doc, doc) + # self._add_concepts_to_hc_doc(spacy_doc, doc) doc.nlp.add_spacy_doc(spacy_doc) return doc From a5ba578664e324daedc7f6a6c53432b6280b9834 Mon Sep 17 00:00:00 2001 From: jenniferjiangkells Date: Thu, 27 Feb 2025 21:42:10 +0000 Subject: [PATCH 29/34] Typo --- healthchain/models/requests/cdarequest.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/healthchain/models/requests/cdarequest.py b/healthchain/models/requests/cdarequest.py index 1ca0d87c..ad86dfc8 100644 --- a/healthchain/models/requests/cdarequest.py +++ b/healthchain/models/requests/cdarequest.py @@ -33,8 +33,9 @@ def model_dump_xml(self, *args, **kwargs) -> str: xml_dict = xmltodict.parse(self.document) document = search_key(xml_dict, "urn:Document") if document is None: - log.warning("Coudln't find document under namespace 'urn:Document") + log.warning("Couldn't find document under namespace 'urn:Document") return "" + cda = base64.b64decode(document).decode("UTF-8") return cda From 89baad3b2071b7fbccdb14f0fa15e7733d1e36b7 Mon Sep 17 00:00:00 2001 From: jenniferjiangkells Date: Thu, 27 Feb 2025 21:42:30 +0000 Subject: [PATCH 30/34] Fix b64 encoding in clindoc --- healthchain/use_cases/clindoc.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/healthchain/use_cases/clindoc.py b/healthchain/use_cases/clindoc.py index 26a44473..faf67f0e 100644 --- a/healthchain/use_cases/clindoc.py +++ b/healthchain/use_cases/clindoc.py @@ -1,3 +1,4 @@ +import base64 import inspect import logging import pkgutil @@ -72,6 +73,8 @@ def construct_request( # Make a copy of the SOAP envelope template soap_envelope = self.soap_envelope.copy() + cda_xml = base64.b64encode(cda_xml).decode("utf-8") + # Insert encoded cda in the Document section if not insert_at_key(soap_envelope, "urn:Document", cda_xml): raise ValueError( @@ -96,7 +99,6 @@ class ClinicalDocumentation(BaseUseCase): service_config (Optional[Dict]): The configuration for the service. service (Optional[Service]): The service to be used for processing the documents. client (Optional[BaseClient]): The client to be used for communication with the service. - overwrite (bool): Whether to overwrite existing data in the CDA document. """ @@ -123,7 +125,6 @@ def __init__( api_protocol="SOAP", ) } - self.overwrite: bool = False @property def description(self) -> str: From 3f42d9c8c653a1f7f96580b30137f9bf0d700174 Mon Sep 17 00:00:00 2001 From: jenniferjiangkells Date: Thu, 27 Feb 2025 21:43:03 +0000 Subject: [PATCH 31/34] Update tests --- tests/components/conftest.py | 1 - tests/components/test_integrations.py | 33 -------------------- tests/containers/conftest.py | 1 - tests/containers/test_document.py | 45 +++++++++++++++++++++++++++ 4 files changed, 45 insertions(+), 35 deletions(-) diff --git a/tests/components/conftest.py b/tests/components/conftest.py index febdf8b2..abc910a3 100644 --- a/tests/components/conftest.py +++ b/tests/components/conftest.py @@ -1,7 +1,6 @@ import pytest from healthchain.pipeline.components import CdsCardCreator -from tests.pipeline.conftest import mock_spacy_nlp # noqa: F401 @pytest.fixture diff --git a/tests/components/test_integrations.py b/tests/components/test_integrations.py index 2dc312c6..5f37e924 100644 --- a/tests/components/test_integrations.py +++ b/tests/components/test_integrations.py @@ -184,39 +184,6 @@ def test_component_invalid_kwargs( assert expected_message in str(exc_info.value) -def test_spacy_add_concepts(mock_spacy_nlp, test_empty_document): - """Test adding concepts from spaCy entities to HealthChain document""" - # Get the mock spaCy doc from the fixture - mock_instance = mock_spacy_nlp.return_value - mock_doc = mock_instance.return_value._nlp._spacy_doc - - # Create a fresh SpacyNLP component instance - with patch("spacy.load") as mock_load: - mock_nlp = Mock() - mock_load.return_value = mock_nlp - component = SpacyNLP("en_core_web_sm") - component._nlp = mock_nlp - - # Process document using the mock entities - component._add_concepts_to_hc_doc(mock_doc, test_empty_document) - - # Verify concepts were added correctly - conditions = test_empty_document.fhir.problem_list - assert len(conditions) == 3 # All entities are treated as problems by default - - # Check each concept was added with correct attributes - expected_concepts = [ - ("Hypertension", "38341003"), - ("Aspirin", "123454"), - ("Allergy to peanuts", "70618"), - ] - - for i, (text, cui) in enumerate(expected_concepts): - assert conditions[i].code.coding[0].display == text - assert conditions[i].code.coding[0].code == cui - assert conditions[i].code.coding[0].system == "http://snomed.info/sct" - - def test_requires_package_decorator(): """Test the requires_package decorator handles missing packages correctly""" diff --git a/tests/containers/conftest.py b/tests/containers/conftest.py index 6ef361a9..bc77ee38 100644 --- a/tests/containers/conftest.py +++ b/tests/containers/conftest.py @@ -1,5 +1,4 @@ import pytest - from healthchain.io.containers.document import FhirData, Document from healthchain.fhir import create_bundle, create_document_reference diff --git a/tests/containers/test_document.py b/tests/containers/test_document.py index 5680dcc9..1051e8c0 100644 --- a/tests/containers/test_document.py +++ b/tests/containers/test_document.py @@ -1,4 +1,5 @@ from healthchain.io.containers.document import Document +from unittest.mock import patch, MagicMock def test_document_initialization(sample_document): @@ -65,3 +66,47 @@ def test_empty_document(): assert doc.text == "" assert doc.nlp._tokens == [] assert doc.word_count() == 0 + + +@patch("healthchain.io.containers.document.Span") +def test_update_problem_list_from_nlp(mock_span_class, test_empty_document): + """Test updating problem list from NLP entities""" + + # Create mock spaCy entities + mock_ent1 = MagicMock() + mock_ent1.text = "Hypertension" + mock_ent1._.cui = "38341003" + + mock_ent2 = MagicMock() + mock_ent2.text = "Aspirin" + mock_ent2._.cui = "123454" + + mock_ent3 = MagicMock() + mock_ent3.text = "Allergy to peanuts" + mock_ent3._.cui = "70618" + + # Create mock spaCy doc + mock_spacy_doc = MagicMock() + mock_spacy_doc.ents = [mock_ent1, mock_ent2, mock_ent3] + + # Add the spaCy doc to the test document + test_empty_document.nlp.add_spacy_doc(mock_spacy_doc) + + # Update problem list from NLP entities + test_empty_document.update_problem_list_from_nlp() + + # Verify problems were added correctly + conditions = test_empty_document.fhir.problem_list + assert len(conditions) == 3 # All entities are treated as problems by default + + # Check each problem was added with correct attributes + expected_problems = [ + ("Hypertension", "38341003"), + ("Aspirin", "123454"), + ("Allergy to peanuts", "70618"), + ] + + for i, (text, cui) in enumerate(expected_problems): + assert conditions[i].code.coding[0].display == text + assert conditions[i].code.coding[0].code == cui + assert conditions[i].code.coding[0].system == "http://snomed.info/sct" From 4f553ea49d4801e74a013e72667fc8eb3936cdda Mon Sep 17 00:00:00 2001 From: jenniferjiangkells Date: Fri, 28 Feb 2025 17:46:04 +0000 Subject: [PATCH 32/34] Update docs --- README.md | 18 +- docs/cookbook/cds_sandbox.md | 8 +- docs/cookbook/notereader_sandbox.md | 25 +- docs/quickstart.md | 25 +- .../pipeline/connectors/cdaconnector.md | 6 +- .../pipeline/connectors/cdsfhirconnector.md | 6 +- .../pipeline/connectors/connectors.md | 8 +- docs/reference/pipeline/data_container.md | 189 ++++- .../pipeline/integrations/integrations.md | 8 +- docs/reference/sandbox/client.md | 18 +- docs/reference/sandbox/sandbox.md | 8 +- docs/reference/sandbox/service.md | 17 +- docs/reference/sandbox/use_cases/cds.md | 15 +- docs/reference/sandbox/use_cases/clindoc.md | 18 +- docs/reference/utilities/cda_parser.md | 55 +- docs/reference/utilities/data_generator.md | 25 +- docs/reference/utilities/fhir_helpers.md | 741 ++++++++++++++++++ mkdocs.yml | 1 + 18 files changed, 1034 insertions(+), 157 deletions(-) create mode 100644 docs/reference/utilities/fhir_helpers.md diff --git a/README.md b/README.md index d716113a..112836b0 100644 --- a/README.md +++ b/README.md @@ -130,7 +130,7 @@ import healthchain as hc from healthchain.pipeline import SummarizationPipeline from healthchain.use_cases import ClinicalDecisionSupport -from healthchain.models import Card, CdsFhirData, CDSRequest +from healthchain.models import Card, Prefetch, CDSRequest from healthchain.data_generator import CdsDataGenerator from typing import List @@ -144,8 +144,8 @@ class MyCDS(ClinicalDecisionSupport): # Sets up an instance of a mock EHR client of the specified workflow @hc.ehr(workflow="encounter-discharge") - def ehr_database_client(self) -> CdsFhirData: - return self.data_generator.generate() + def ehr_database_client(self) -> Prefetch: + return self.data_generator.generate_prefetch() # Define your application logic here @hc.api @@ -167,7 +167,8 @@ import healthchain as hc from healthchain.pipeline import MedicalCodingPipeline from healthchain.use_cases import ClinicalDocumentation -from healthchain.models import CcdData, CdaRequest, CdaResponse +from healthchain.models import CdaRequest, CdaResponse +from fhir.resources.documentreference import DocumentReference @hc.sandbox class NotereaderSandbox(ClinicalDocumentation): @@ -178,11 +179,16 @@ class NotereaderSandbox(ClinicalDocumentation): # Load an existing CDA file @hc.ehr(workflow="sign-note-inpatient") - def load_data_in_client(self) -> CcdData: + def load_data_in_client(self) -> DocumentReference: with open("/path/to/cda/data.xml", "r") as file: xml_string = file.read() - return CcdData(cda_xml=xml_string) + cda_document_reference = create_document_reference( + data=xml_string, + content_type="text/xml", + description="Original CDA Document loaded from my sandbox", + ) + return cda_document_reference @hc.api def my_service(self, data: CdaRequest) -> CdaResponse: diff --git a/docs/cookbook/cds_sandbox.md b/docs/cookbook/cds_sandbox.md index 64174d07..71923904 100644 --- a/docs/cookbook/cds_sandbox.md +++ b/docs/cookbook/cds_sandbox.md @@ -127,7 +127,7 @@ print(data.model_dump()) # } ``` -The data generator returns a `CdsFhirData` object, which ensures that the data is parsed correctly inside the sandbox. +The data generator returns a `Prefetch` object, which ensures that the data is parsed correctly inside the sandbox. ## Define client workflow @@ -137,7 +137,7 @@ To finish our sandbox, we'll define a client function that loads the data genera import healthchain as hc from healthchain.use_cases import ClinicalDecisionSupport -from healthchain.models import CDSRequest, CDSResponse, CdsFhirData +from healthchain.models import CDSRequest, CDSResponse, Prefetch @hc.sandbox class DischargeNoteSummarizer(ClinicalDecisionSupport): @@ -151,8 +151,8 @@ class DischargeNoteSummarizer(ClinicalDecisionSupport): return result @hc.ehr(workflow="encounter-discharge") - def load_data_in_client(self) -> CdsFhirData: - data = self.data_generator.generate() + def load_data_in_client(self) -> Prefetch: + data = self.data_generator.generate_prefetch() return data ``` diff --git a/docs/cookbook/notereader_sandbox.md b/docs/cookbook/notereader_sandbox.md index 200ae2a2..3cfd2053 100644 --- a/docs/cookbook/notereader_sandbox.md +++ b/docs/cookbook/notereader_sandbox.md @@ -7,14 +7,10 @@ Full example coming soon! ```python import healthchain as hc from healthchain.use_cases import ClinicalDocumentation -from healthchain.models import ( - CcdData, - AllergyConcept, - Concept, - MedicationConcept, - ProblemConcept, - Quantity, -) +from healthchain.fhir import create_document_reference + +from fhir.resources.documentreference import DocumentReference + @hc.sandbox class NotereaderSandbox(ClinicalDocumentation): @@ -25,11 +21,16 @@ class NotereaderSandbox(ClinicalDocumentation): ) @hc.ehr(workflow="sign-note-inpatient") - def load_data_in_client(self) -> CcdData: - with open(self.cda_path, "r") as file: - xml_string = file.read() + def load_data_in_client(self) -> DocumentReference: + with open(self.cda_path, "r") as file: + xml_string = file.read() - return CcdData(cda_xml=xml_string) + cda_document_reference = create_document_reference( + data=xml_string, + content_type="text/xml", + description="Original CDA Document loaded from my sandbox", + ) + return cda_document_reference @hc.api def my_service(self, request: CdaRequest) -> CdaResponse: diff --git a/docs/quickstart.md b/docs/quickstart.md index 560a5878..ce518d4b 100644 --- a/docs/quickstart.md +++ b/docs/quickstart.md @@ -130,7 +130,10 @@ import healthchain as hc from healthchain.use_cases import ClinicalDocumentation from healthchain.pipeline import MedicalCodingPipeline -from healthchain.models import CdaRequest, CdaResponse, CcdData +from healthchain.models import CdaRequest, CdaResponse +from healthchain.fhir import create_document_reference + +from fhir.resources.documentreference import DocumentReference @hc.sandbox class MyCoolSandbox(ClinicalDocumentation): @@ -141,12 +144,18 @@ class MyCoolSandbox(ClinicalDocumentation): ) @hc.ehr(workflow="sign-note-inpatient") - def load_data_in_client(self) -> CcdData: + def load_data_in_client(self) -> DocumentReference: # Load your data with open('/path/to/data.xml', "r") as file: xml_string = file.read() - return CcdData(cda_xml=xml_string) + cda_document_reference = create_document_reference( + data=xml_string, + content_type="text/xml", + description="Original CDA Document loaded from my sandbox", + ) + + return cda_document_reference @hc.api def my_service(self, request: CdaRequest) -> CdaResponse: @@ -174,7 +183,7 @@ This will start a server by default at `http://127.0.0.1:8000`, and you can inte You can use the data generator to generate synthetic data for your sandbox runs. -The `.generate()` is dependent on use case and workflow. For example, `CdsDataGenerator` will generate synthetic [FHIR](https://hl7.org/fhir/) data suitable for the workflow specified by the use case. +The `.generate_prefetch()` method is dependent on use case and workflow. For example, `CdsDataGenerator` will generate synthetic [FHIR](https://hl7.org/fhir/) data suitable for the workflow specified by the use case. We're working on generating synthetic [CDA](https://www.hl7.org.uk/standards/hl7-standards/cda-clinical-document-architecture/) data. If you're interested in contributing, please [reach out](https://discord.gg/UQC6uAepUz)! @@ -185,7 +194,7 @@ We're working on generating synthetic [CDA](https://www.hl7.org.uk/standards/hl7 import healthchain as hc from healthchain.use_cases import ClinicalDecisionSupport - from healthchain.models import CdsFhirData + from healthchain.models import Prefetch from healthchain.data_generators import CdsDataGenerator @hc.sandbox @@ -194,8 +203,8 @@ We're working on generating synthetic [CDA](https://www.hl7.org.uk/standards/hl7 self.data_generator = CdsDataGenerator() @hc.ehr(workflow="patient-view") - def load_data_in_client(self) -> CdsFhirData: - data = self.data_generator.generate() + def load_data_in_client(self) -> Prefetch: + data = self.data_generator.generate_prefetch() return data @hc.api @@ -214,7 +223,7 @@ We're working on generating synthetic [CDA](https://www.hl7.org.uk/standards/hl7 # Generate FHIR resources for use case workflow data_generator.set_workflow(Workflow.encounter_discharge) - data = data_generator.generate() + data = data_generator.generate_prefetch() print(data.model_dump()) diff --git a/docs/reference/pipeline/connectors/cdaconnector.md b/docs/reference/pipeline/connectors/cdaconnector.md index aaa51fa8..455b828b 100644 --- a/docs/reference/pipeline/connectors/cdaconnector.md +++ b/docs/reference/pipeline/connectors/cdaconnector.md @@ -37,14 +37,12 @@ def example_pipeline_node(document: Document) -> Document: pipe = pipeline.build() cda_response = pipe(cda_request) -# Output: CcdData object... +# Output: CdaResponse object... ``` ## Accessing data inside your pipeline -Data parsed from the CDA document is stored in the `Document.ccd_data` attribute as a `CcdData` object, as shown in the example above. - -[(CcdData Reference)](../../../api/data_models.md#healthchain.models.data.ccddata.CcdData) +Data parsed from the CDA document is stored in the `Document.fhir` attribute as a `DocumentReference` FHIR resource, as shown in the example above. ## Configuration diff --git a/docs/reference/pipeline/connectors/cdsfhirconnector.md b/docs/reference/pipeline/connectors/cdsfhirconnector.md index 2d86c073..b0870c05 100644 --- a/docs/reference/pipeline/connectors/cdsfhirconnector.md +++ b/docs/reference/pipeline/connectors/cdsfhirconnector.md @@ -52,12 +52,12 @@ def example_pipeline_node(document: Document) -> Document: pipe = pipeline.build() cds_response = pipe(cds_request) -# Output: CdsFhirData object... +# Output: CdsResponse object... ``` ## Accessing data inside your pipeline -Data parsed from the FHIR resources is stored in the `Document.fhir_resources` attribute as a `CdsFhirData` object, as shown in the example above. +Data parsed from the FHIR resources is stored in the `Document.fhir_resources` attribute as a dictionary of FHIR resources corresponding to the keys in the `prefetch` field of the `CDSRequest`, as shown in the example above. -[(CdsFhirData Reference)](../../../api/data_models.md#healthchain.models.data.cdsfhirdata) +[(Prefetch Reference)](../../../api/data_models.md#healthchain.models.data.prefetch) diff --git a/docs/reference/pipeline/connectors/connectors.md b/docs/reference/pipeline/connectors/connectors.md index a1aa6440..323949e8 100644 --- a/docs/reference/pipeline/connectors/connectors.md +++ b/docs/reference/pipeline/connectors/connectors.md @@ -10,15 +10,15 @@ Connectors make certain assumptions about the data they receive depending on the Some connectors require the same instance to be used for both input and output, while others may be input or output only. -| Connector | Input | Output | Internal Data Representation | Access it by... | Same instance I/O? | +| Connector | Input | Output | FHIR Resources | Access it by... | Same instance I/O? | |-----------|-------|--------|-------------------------|----------------|--------------------------| -| [**CdaConnector**](cdaconnector.md) | `CdaRequest` :material-arrow-right: `Document` | `Document` :material-arrow-right: `CdaRequest` | [**CcdData**](../../../api/data_models.md#healthchain.models.data.ccddata.CcdData) | `.ccd_data` | ✅ | -| [**CdsFhirConnector**](cdsfhirconnector.md) | `CDSRequest` :material-arrow-right: `Document` | `Document` :material-arrow-right: `CdsResponse` | [**CdsFhirData**](../../../api/data_models.md#healthchain.models.data.cdsfhirdata.CdsFhirData) | `.fhir_resources` | ✅ | +| [**CdaConnector**](cdaconnector.md) | `CdaRequest` :material-arrow-right: `Document` | `Document` :material-arrow-right: `CdaResponse` | [**DocumentReference**] | `` | ✅ | +| [**CdsFhirConnector**](cdsfhirconnector.md) | `CDSRequest` :material-arrow-right: `Document` | `Document` :material-arrow-right: `CdsResponse` | **Any FHIR Resource** | `.fhir_resources` | ✅ | !!! example "CdaConnector Example" The `CdaConnector` expects a `CdaRequest` object as input and outputs a `CdaResponse` object. The connector converts the input data into a `Document` object because CDAs are usually represented as a document object. - This `Document` object contains a `.ccd_data` attribute, which stores the structured data from the CDA document in a `CcdData` object. Any free-text notes are stored in the `Document.text` attribute. + This `Document` object contains a `.fhir` attribute, which stores the structured data from the CDA document in a `DocumentReference` FHIR resource. Any free-text notes are stored in the `Document.text` attribute. Because CDAs are annotated documents, the same `CdaConnector` instance must be used for both input and output operations in the pipeline. diff --git a/docs/reference/pipeline/data_container.md b/docs/reference/pipeline/data_container.md index 4ad4593e..8f8313e0 100644 --- a/docs/reference/pipeline/data_container.md +++ b/docs/reference/pipeline/data_container.md @@ -2,7 +2,7 @@ The `healthchain.io.containers` module provides classes for storing and manipulating data throughout the pipeline. The main classes are `DataContainer`, `Document`, and `Tabular`. -## DataContainer +## DataContainer 📦 `DataContainer` is a generic base class for storing data of any type. @@ -21,62 +21,171 @@ container_from_dict = DataContainer.from_dict(data_dict) container_from_json = DataContainer.from_json(data_json) ``` -## Document +## Document 📄 -The `Document` class is used to store and manipulate text data along with various annotations. It extends `BaseDocument` and provides comprehensive functionality for working with text, NLP annotations, clinical concepts, and more. +The `Document` class provides comprehensive functionality for working with text, NLP annotations, FHIR resources, and more. + +| Attribute | Access | Primary Purpose | Key Features | Common Use Cases | +|-----------|--------|----------------|--------------|------------------| +| [**FHIR Data**](#fhir-data-docfhir) | `doc.fhir` | Manage clinical data in FHIR format | • Resource bundles
• Clinical lists (problems, meds, allergies)
• Document references
• CDS prefetch | • Store patient records
• Track medical history
• Manage clinical documents | +| [**NLP**](#nlp-component-docnlp) | `doc.nlp` | Process and analyze text | • Tokenization
• Entity recognition
• Embeddings
• spaCy integration | • Extract medical terms
• Analyze clinical text
• Generate features | +| [**CDS**](#clinical-decision-support-doccds) | `doc.cds` | Clinical decision support | • Recommendation cards
• Suggested actions
• Clinical alerts | • Generate alerts
• Suggest interventions
• Guide clinical decisions | +| [**Model Outputs**](#model-outputs-docmodels) | `doc.models` | Store ML model results | • Multi-framework support
• Task-specific outputs
• Text generation | • Store classifications
• Keep predictions
• Track generations | + +### FHIR Data (`doc.fhir`) + +The FHIR component serves as a comprehensive manager for FHIR resources, providing: + +**Storage and Management:** + + - Automatic `Bundle` creation and management + - Resource type validation + - Convenient access to common clinical data lists + +**Clinical Data Lists:** + + - `problem_list`: List of `Condition` resources (diagnoses, problems) + - `medication_list`: List of `MedicationStatement` resources + - `allergy_list`: List of `AllergyIntolerance` resources + +**Document Reference Management:** + + - Document relationship tracking (parent/child/sibling) + - Attachment handling with `base64` encoding + - Document family retrieval + +**CDS Support:** + + - Support for CDS Hooks prefetch resources + - Resource indexing by type + +Example usage: ```python -from healthchain.io.containers import Document +from healthchain.io import Document +from healthchain.fhir import ( + create_condition, + create_medication_statement, + create_document_reference, +) -# Create a basic document -doc = Document("Patient presents with hypertension and diabetes.") +# Basic usage with clinical lists +doc = Document("Patient presents with hypertension") -# Access NLP annotations -print(f"Word count: {doc.word_count()}") -print(f"Tokens: {doc.nlp.get_tokens()}") -print(f"Entities: {doc.nlp.get_entities()}") - -# Add clinical concepts -from healthchain.models.data.concept import ProblemConcept, MedicationConcept -doc.add_concepts( - problems=[ProblemConcept(display_name="Hypertension")], - medications=[MedicationConcept(display_name="Aspirin")] +# Add problems to the problem list +doc.fhir.problem_list = [ + create_condition(subject="Patient/123", code="38341003", display="Hypertension") +] + +# Add medications +doc.fhir.medication_list = [ + create_medication_statement( + subject="Patient/123", code="1049221", display="Acetaminophen" + ) +] + +# Working with document references +parent_doc = create_document_reference( + data="Original clinical note", content_type="text/plain" +) +parent_id = doc.fhir.add_document_reference(parent_doc) + +# Add a child document with relationship +child_doc = create_document_reference( + data="Updated clinical note", content_type="text/plain" ) +child_id = doc.fhir.add_document_reference( + child_doc, parent_id=parent_id, relationship_type="replaces" +) + +# Get document family (parents/children/siblings) +family = doc.fhir.get_document_reference_family(child_id) +print(f"Parent doc: {family['parents'][0].description}") -# Generate CCD data -ccd_data = doc.generate_ccd() +# Using CDS prefetch resources +prefetch = { + "Condition": doc.fhir.problem_list, + "MedicationStatement": doc.fhir.medication_list, +} +doc.fhir.set_prefetch_resources(prefetch) +conditions = doc.fhir.get_prefetch_resources("Condition") -# Add CDS cards and actions -from healthchain.models.responses import Card, Action -doc.add_cds_cards([Card(summary="Recommended follow-up")]) -doc.add_cds_actions([Action(type="order", description="Schedule follow-up")]) +``` + +**Technical Notes:** + +- All FHIR resources are validated using [fhir.resources](https://github.com/nazrulworld/fhir.resources) +- Document relationships follow the FHIR [DocumentReference.relatesTo](https://www.hl7.org/fhir/documentreference-definitions.html#DocumentReference.relatesTo) standard + +**Resource Documentation:** + +- [FHIR Bundle](https://www.hl7.org/fhir/bundle.html) +- [FHIR DocumentReference](https://www.hl7.org/fhir/documentreference.html) +- [FHIR Condition](https://www.hl7.org/fhir/condition.html) +- [FHIR MedicationStatement](https://www.hl7.org/fhir/medicationstatement.html) +- [FHIR AllergyIntolerance](https://www.hl7.org/fhir/allergyintolerance.html) + +### NLP Component (`doc.nlp`) + +- `get_tokens()`: Returns list of tokens from the text +- `get_entities()`: Returns named entities with optional CUI (SNOMED CT concept IDs) +- `get_embeddings()`: Returns vector representations of text +- `get_spacy_doc()`: Returns the underlying spaCy document for advanced NLP features +- `word_count()`: Returns total word count based on tokenization + + +### Clinical Decision Support (`doc.cds`) + +- `cards`: List of Card objects with clinical recommendations +- `actions`: List of Action objects for suggested interventions -# Access model outputs -huggingface_output = doc.models.get_output("huggingface", "classification") -generated_text = doc.models.get_generated_text("langchain", "summarization") -# Access spacy doc +### Model Outputs (`doc.models`) + +- `get_output(model_name, task)`: Get specific model output +- `get_generated_text(model_name, task)`: Get generated text +- Supports outputs from various models (e.g., Hugging Face, LangChain) + +Example using all components: +```python +from healthchain.io import Document +from healthchain.fhir import create_condition +from healthchain.models import Card, Action + +# Create document with text +doc = Document("Patient reports chest pain and shortness of breath.") + +# Access and update NLP features +print(f"Word count: {doc.word_count()}") +tokens = doc.nlp.get_tokens() spacy_doc = doc.nlp.get_spacy_doc() -# Iterate over tokens -for token in doc: - print(token) +# Add FHIR resources +doc.fhir.problem_list.append( + create_condition(subject="Patient/123", code="29857009", display="Chest pain") +) -# Get document length (character count) -print(f"Document length: {len(doc)}") -``` +# Update problem list from NLP entities (requires spaCy doc with CUI extension) +doc.update_problem_list_from_nlp() -The Document class includes several key components: +# Add CDS results +doc.cds.cards = [ + Card( + summary="Consider cardiac evaluation", + indicator="warning", + source={"label": "AHA Guidelines"}, + ) +] +doc.cds.actions = [Action(...)] -- `nlp`: NLP annotations (tokens, entities, embeddings, spaCy docs) -- `concepts`: Clinical concepts (problems, medications, allergies) -- `hl7`: Structured clinical documents (CCD, FHIR) -- `cds`: Clinical decision support results (cards, actions) -- `models`: ML model outputs (Hugging Face, LangChain) +# Get model outputs +classification = doc.models.get_output("huggingface", "classification") + +``` -Document API Reference: [healthchain.io.containers.document](../../api/containers.md#healthchain.io.containers.document) +[Document API Reference](../../api/containers.md#healthchain.io.containers.document) -## Tabular +## Tabular 📊 The `Tabular` class is used for storing and manipulating tabular data, wrapping a pandas DataFrame. diff --git a/docs/reference/pipeline/integrations/integrations.md b/docs/reference/pipeline/integrations/integrations.md index f1b45f3c..390fe7d7 100644 --- a/docs/reference/pipeline/integrations/integrations.md +++ b/docs/reference/pipeline/integrations/integrations.md @@ -60,13 +60,7 @@ Choose the appropriate model based on your specific needs - standard models for spacy_component = SpacyNLP.from_model_id("en_core_sci_sm") ``` -The component will process documents using spaCy and: - -- Store the spaCy Doc object in the document's `nlp` annotations - -- Extract entities if the spacy pipeline has an `ner` component and add them as `ProblemConcepts` (defaulting to SNOMED CT coding) - -The spacy Doc object can be accessed using the `Document.nlp.get_spacy_doc()` method. +The component will process documents using spaCy and store the spaCy Doc object in the document's `nlp` annotations. It can be accessed using the `Document.nlp.get_spacy_doc()` method. ### Example diff --git a/docs/reference/sandbox/client.md b/docs/reference/sandbox/client.md index 641e7fa1..8412697c 100644 --- a/docs/reference/sandbox/client.md +++ b/docs/reference/sandbox/client.md @@ -4,7 +4,7 @@ A client is a healthcare system object that requests information and processing We can mark a client by using the decorator `@hc.ehr`. You must declare a particular **workflow** for the EHR client, which informs the sandbox how your data will be formatted. You can find more information on the [Use Cases](./use_cases/use_cases.md) documentation page. -Data returned from the client should be wrapped in a [Pydantic](https://docs.pydantic.dev/latest/) model depending on use case, e.g. `CdsFhirData`. +Data returned from the client should be wrapped in a [Prefetch](../../../api/data_models.md#healthchain.models.data.prefetch) object, where prefetch is a dictionary of FHIR resources with keys corresponding to the CDS service. You can optionally specify the number of requests to generate with the `num` parameter. @@ -13,7 +13,9 @@ You can optionally specify the number of requests to generate with the `num` par import healthchain as hc from healthchain.use_cases import ClinicalDocumentation - from healthchain.models import CcdData + from healthchain.fhir import create_document_reference + + from fhir.resources.documentreference import DocumentReference @hc.sandbox class MyCoolSandbox(ClinicalDocumentation): @@ -21,9 +23,9 @@ You can optionally specify the number of requests to generate with the `num` par pass @hc.ehr(workflow="sign-note-inpatient", num=10) - def load_data_in_client(self) -> CcdData: + def load_data_in_client(self) -> DocumentReference: # Do things here to load in your data - return CcdData(cda_xml="") + return create_document_reference(data="", content_type="text/xml") ``` === "CDS" @@ -31,7 +33,9 @@ You can optionally specify the number of requests to generate with the `num` par import healthchain as hc from healthchain.use_cases import ClinicalDecisionSupport - from healthchain.models import CdsFhirData + from healthchain.models import Prefetch + + from fhir.resources.patient import Patient @hc.sandbox class MyCoolSandbox(ClinicalDecisionSupport): @@ -39,8 +43,8 @@ You can optionally specify the number of requests to generate with the `num` par pass @hc.ehr(workflow="patient-view", num=10) - def load_data_in_client(self) -> CdsFhirData: + def load_data_in_client(self) -> Prefetch: # Do things here to load in your data - return CdsFhirData(context={}, prefetch={}) + return Prefetch(prefetch={"patient": Patient(id="123")}) ``` diff --git a/docs/reference/sandbox/sandbox.md b/docs/reference/sandbox/sandbox.md index f23b11b7..f55f93a0 100644 --- a/docs/reference/sandbox/sandbox.md +++ b/docs/reference/sandbox/sandbox.md @@ -35,7 +35,7 @@ import healthchain as hc from healthchain.pipeline import SummarizationPipeline from healthchain.use_cases import ClinicalDecisionSupport from healthchain.data_generators import CdsDataGenerator -from healthchain.models import CDSRequest, CdsFhirData, CDSResponse +from healthchain.models import CDSRequest, Prefetch, CDSResponse @hc.sandbox @@ -45,9 +45,9 @@ class MyCoolSandbox(ClinicalDecisionSupport): self.pipeline = SummarizationPipeline('gpt-4o') @hc.ehr(workflow="encounter-discharge") - def load_data_in_client(self) -> CdsFhirData: - cds_fhir_data = self.data_generator.generate() - return cds_fhir_data + def load_data_in_client(self) -> Prefetch: + prefetch = self.data_generator.generate_prefetch() + return prefetch @hc.api def my_service(self, request: CDSRequest) -> CDSResponse: diff --git a/docs/reference/sandbox/service.md b/docs/reference/sandbox/service.md index 5bcf7c69..be214b00 100644 --- a/docs/reference/sandbox/service.md +++ b/docs/reference/sandbox/service.md @@ -16,7 +16,9 @@ Here are minimal examples for each use case: from healthchain.use_cases import ClinicalDocumentation from healthchain.pipeline import MedicalCodingPipeline - from healthchain.models import CcdData, CdaRequest, CdaResponse + from healthchain.models import CdaRequest, CdaResponse + from healthchain.fhir import create_document_reference + from fhir.resources.documentreference import DocumentReference @hc.sandbox class MyCoolSandbox(ClinicalDocumentation): @@ -24,11 +26,11 @@ Here are minimal examples for each use case: self.pipeline = MedicalCodingPipeline.load("./path/to/model") @hc.ehr(workflow="sign-note-inpatient") - def load_data_in_client(self) -> CcdData: + def load_data_in_client(self) -> DocumentReference: with open('/path/to/data.xml', "r") as file: xml_string = file.read() - return CcdData(cda_xml=xml_string) + return create_document_reference(data=xml_string, content_type="text/xml") @hc.api def my_service(self, request: CdaRequest) -> CdaResponse: @@ -42,19 +44,20 @@ Here are minimal examples for each use case: from healthchain.use_cases import ClinicalDecisionSupport from healthchain.pipeline import SummarizationPipeline - from healthchain.models import CDSRequest, CDSResponse, CdsFhirData + from healthchain.models import CDSRequest, CDSResponse, Prefetch + from fhir.resources.patient import Patient @hc.sandbox class MyCoolSandbox(ClinicalDecisionSupport): def __init__(self): - self.pipeline = SummarizationPipeline.load("mode-name") + self.pipeline = SummarizationPipeline.load("model-name") @hc.ehr(workflow="patient-view") - def load_data_in_client(self) -> CdsFhirData: + def load_data_in_client(self) -> Prefetch: with open('/path/to/data.json', "r") as file: fhir_json = file.read() - return CdsFhirData(**fhir_json) + return Prefetch(prefetch={"patient": Patient(**fhir_json)}) @hc.api def my_service(self, request: CDSRequest) -> CDSResponse: diff --git a/docs/reference/sandbox/use_cases/cds.md b/docs/reference/sandbox/use_cases/cds.md index 85750cc7..f4eba10a 100644 --- a/docs/reference/sandbox/use_cases/cds.md +++ b/docs/reference/sandbox/use_cases/cds.md @@ -10,20 +10,21 @@ CDS workflows are based on [CDS Hooks](https://cds-hooks.org/). CDS Hooks is an ## Data Flow -| Stage | Input | Internal Data Representation | Output | -|-------|-------|------------------------------|--------| -| Client | N/A | N/A | `CdsFhirData` | -| Service | `CdsRequest` | `CdsFhirData` | `CdsResponse` | +| Stage | Input | Output | +|-------|-------|--------| +| Client | N/A | `Prefetch` | +| Service | `CDSRequest` | `CDSResponse` | -[CdsFhirConnector](../../pipeline/connectors/cdsfhirconnector.md) handles the conversion of `CDSRequests` :material-swap-horizontal: `CdsFhirData` :material-swap-horizontal: `CdsResponse` in a HealthChain pipeline. +[CdsFhirConnector](../../pipeline/connectors/cdsfhirconnector.md) handles the conversion of `CDSRequests` :material-swap-horizontal: `Document` :material-swap-horizontal: `CDSResponse` in a HealthChain pipeline. -Attributes of `CdsFhirData` are: +Attributes of `Document` are: +- `fhir_resources` - `context` - `prefetch` -[(CdsFhirData API Reference)](../../../api/data_models.md#healthchain.models.data.cdsfhirdata) +[(Document API Reference)](../../../api/data_models.md#healthchain.models.data.document) ## Supported Workflows diff --git a/docs/reference/sandbox/use_cases/clindoc.md b/docs/reference/sandbox/use_cases/clindoc.md index bb18f10c..e6e60c9b 100644 --- a/docs/reference/sandbox/use_cases/clindoc.md +++ b/docs/reference/sandbox/use_cases/clindoc.md @@ -10,22 +10,14 @@ The `ClinicalDocumentation` use case implements a real-time Clinical Documentati ## Data Flow -| Stage | Input | Internal Data Representation | Output | -|-------|-------|------------------------------|--------| -| Client | N/A | N/A | `CcdData` | -| Service | `CdaRequest` | `CcdData` | `CdaResponse` | +| Stage | Input | Output | +|-------|-------|--------| +| Client | N/A | `DocumentReference` | +| Service | `CdaRequest` | `CdaResponse` | -[CdaConnector](../../pipeline/connectors/cdaconnector.md) handles the conversion of `CdaRequests` :material-swap-horizontal: `CcdData` :material-swap-horizontal: `CdaResponse` in a HealthChain pipeline. +[CdaConnector](../../pipeline/connectors/cdaconnector.md) handles the conversion of `CdaRequests` :material-swap-horizontal: `DocumentReference` :material-swap-horizontal: `CdaResponse` in a HealthChain pipeline. -Attributes of `CcdData` are: - -- `problems` -- `allergies` -- `medications` -- `note` - -[(CcdData API Reference)](../../../api/data_models.md/#healthchain.models.data.ccddata.CcdData) ## Supported Workflows diff --git a/docs/reference/utilities/cda_parser.md b/docs/reference/utilities/cda_parser.md index fe1327d8..2ce6b9a6 100644 --- a/docs/reference/utilities/cda_parser.md +++ b/docs/reference/utilities/cda_parser.md @@ -1,14 +1,10 @@ # CDA Parser -The `CdaAnnotator` class is responsible for parsing and annotating CDA (Clinical Document Architecture) documents. It extracts information about problems, medications, allergies, and notes from the CDA document, and allows you to add new information to the CDA document. +The `CdaAnnotator` class is responsible for parsing and annotating CDA (Clinical Document Architecture) documents. It extracts information about problems, medications, allergies, and notes from the CDA document into FHIR resources, and allows you to add new information to the CDA document. The CDA parser is used in the [CDA Connector](../pipeline/connectors/cdaconnector.md) module, but can also be used independently. -Internally, `CdaAnnotator` parses CDA documents from XML strings to a dictionary-based representation using `xmltodict` and uses Pydantic for data validation. New problems are added to the CDA document using a template-based approach. It's currently not super configurable, but we're working on it. - -Data interacts with the `CdaAnnotator` through `Concept` data models, which are designed to be an system-agnostic intermediary between FHIR and CDA data representations. - -[(CdaAnnotator API Reference](../../api/cda_parser.md) [| Concept API Reference)](../../api/data_models.md#healthchain.models.data.concept) +[(CdaAnnotator API Reference)](../../api/cda_parser.md) ## Usage @@ -19,20 +15,23 @@ Parse a CDA document from an XML string: ```python from healthchain.cda_parser import CdaAnnotator +with open("tests/data/test_cda.xml", "r") as f: + cda_xml_string = f.read() + cda = CdaAnnotator.from_xml(cda_xml_string) -problems = cda.problem_list +conditions = cda.problem_list medications = cda.medication_list allergies = cda.allergy_list note = cda.note -print([problem.name for problem in problems]) -print([medication.name for medication in medications]) -print([allergy.name for allergy in allergies]) +print([condition.model_dump() for condition in conditions]) +print([medication.model_dump() for medication in medications]) +print([allergy.model_dump() for allergy in allergies]) print(note) ``` -You can access data parsed from the CDA document in the `problem_list`, `medication_list`, `allergy_list`, and `note` attributes of the `CdaAnnotator` instance. They return a list of `Concept` data models. +You can access data parsed from the CDA document in the `problem_list`, `medication_list`, `allergy_list`, and `note` attributes of the `CdaAnnotator` instance. They return a list of FHIR `Condition`, `MedicationStatement`, and `AllergyIntolerance` resources. ### Adding new information to the CDA document @@ -40,9 +39,9 @@ The methods currently available for adding new information to the CDA document a | Method | Description | |--------|-------------| -| `.add_to_problem_list()` | Adds a list of [ProblemConcept](../../api/data_models.md#healthchain.models.data.concept.ProblemConcept) | -| `.add_to_medication_list()` | Adds a list of [MedicationConcept](../../api/data_models.md#healthchain.models.data.concept.MedicationConcept) | -| `.add_to_allergy_list()` | Adds a list of [AllergyConcept](../../api/data_models.md#healthchain.models.data.concept.AllergyConcept) | +| `.add_to_problem_list()` | Adds a list of [FHIR Condition](https://www.hl7.org/fhir/condition.html) resources | +| `.add_to_medication_list()` | Adds a list of [FHIR MedicationStatement](https://www.hl7.org/fhir/medicationstatement.html) resources | +| `.add_to_allergy_list()` | Adds a list of [FHIR AllergyIntolerance](https://www.hl7.org/fhir/allergyintolerance.html) resources | The `overwrite` parameter in the `add_to_*_list()` methods is used to determine whether to overwrite the existing list or append to it. If `overwrite` is `True`, the existing list will be replaced with the new list. If `overwrite` is `False`, the new list will be appended to the existing list. @@ -60,13 +59,30 @@ The `pretty_print` parameter is optional and defaults to `True`. If `pretty_prin ```python from healthchain.cda_parser import CdaAnnotator -from healthchain.models import ProblemConcept, MedicationConcept, AllergyConcept +from healthchain.fhir import ( + create_condition, + create_medication_statement, + create_allergy_intolerance, +) + +with open("tests/data/test_cda.xml", "r") as f: + cda_xml_string = f.read() cda = CdaAnnotator.from_xml(cda_xml_string) -new_problems = [ProblemConcept(name="New Problem", code="123456")] -new_medications = [MedicationConcept(name="New Medication", code="789012")] -new_allergies = [AllergyConcept(name="New Allergy", code="345678")] +new_problems = [ + create_condition(subject="Patient/123", code="123456", display="New Problem") +] +new_medications = [ + create_medication_statement( + subject="Patient/123", code="789012", display="New Medication" + ) +] +new_allergies = [ + create_allergy_intolerance( + patient="Patient/123", code="345678", display="New Allergy" + ) +] # Add new problems, medications, and allergies cda.add_to_problem_list(new_problems, overwrite=True) @@ -75,6 +91,9 @@ cda.add_to_allergy_list(new_allergies, overwrite=True) # Export the modified CDA document modified_cda_xml = cda.export() + +print(modified_cda_xml) + ``` The CDA parser is a work in progress. I'm just gonna be real with you, CDAs are the bane of my existence. If you, for some reason, love working with XML-based documents, please get [in touch](https://discord.gg/UQC6uAepUz)! We have plans to implement more functionality in the future, including allowing configurable templates, more CDA section methods, and using LLMs as a fallback parsing method. diff --git a/docs/reference/utilities/data_generator.md b/docs/reference/utilities/data_generator.md index ac1fc8cd..1e05da15 100644 --- a/docs/reference/utilities/data_generator.md +++ b/docs/reference/utilities/data_generator.md @@ -15,7 +15,7 @@ On the synthetic data spectrum defined by [this UK ONS methodology working paper ## CDS Data Generator -The `.generate()` method will return a `CdsFhirData` model with the `prefetch` field populated with a [Bundle](https://build.fhir.org/bundle.html) of generated structural synthetic FHIR data. +The `.generate_prefetch()` method will return a `Prefetch` model with the `prefetch` field populated with a dictionary of FHIR resources. Each key in the dictionary corresponds to a FHIR resource type, and the value is a list of FHIR resources of that type. For more information, check out the [CDS Hooks documentation](https://cds-hooks.org/specification/current/#providing-fhir-resources-to-a-cds-service). For each workflow, a pre-configured list of FHIR resources is randomly generated and placed in the `prefetch` field of a `CDSRequest`. @@ -36,7 +36,7 @@ You can use the data generator within a client function or on its own. ```python import healthchain as hc from healthchain.use_cases import ClinicalDecisionSupport - from healthchain.models import CdsFhirData + from healthchain.models import Prefetch from healthchain.data_generators import CdsDataGenerator @hc.sandbox @@ -45,9 +45,9 @@ You can use the data generator within a client function or on its own. self.data_generator = CdsDataGenerator() @hc.ehr(workflow="patient-view") - def load_data_in_client(self) -> CdsFhirData: - data = self.data_generator.generate() - return data + def load_data_in_client(self) -> Prefetch: + prefetch = self.data_generator.generate_prefetch() + return prefetch @hc.api def my_server(self, request) -> None: @@ -58,24 +58,23 @@ You can use the data generator within a client function or on its own. === "On its own" ```python from healthchain.data_generators import CdsDataGenerator - from healthchain.workflow import Workflow + from healthchain.workflows import Workflow # Initialise data generator data_generator = CdsDataGenerator() # Generate FHIR resources for use case workflow data_generator.set_workflow(Workflow.encounter_discharge) - data = data_generator.generate() + prefetch = data_generator.generate_prefetch() - print(data.model_dump()) + print(prefetch.model_dump()) # { # "prefetch": { - # "entry": [ + # "encounter": # { - # "resource": ... + # "resourceType": ... # } - # ] # } #} ``` @@ -97,11 +96,11 @@ If you are looking for realistic datasets, you are also free to load your own da ## Loading free-text -You can specify the `free_text_csv` field of the `.generate()` method to load in free-text sources into the data generator, e.g. discharge summaries. This will wrap the text into a FHIR [DocumentReference](https://build.fhir.org/documentreference.html) resource (N.B. currently we place the text directly in the resource attachment, although it is technically supposed to be base64 encoded). +You can specify the `free_text_csv` field of the `.generate_prefetch()` method to load in free-text sources into the data generator, e.g. discharge summaries. This will wrap the text into a FHIR [DocumentReference](https://build.fhir.org/documentreference.html) resource (N.B. currently we place the text directly in the resource attachment, although it is technically supposed to be base64 encoded). A random text document from the `csv` file will be picked for each generation. ```python # Load free text into a DocumentResource FHIR resource -data = data_generator.generate(free_text_csv="./dir/to/csv/file") +data = data_generator.generate_prefetch(free_text_csv="./dir/to/csv/file") ``` diff --git a/docs/reference/utilities/fhir_helpers.md b/docs/reference/utilities/fhir_helpers.md new file mode 100644 index 00000000..839388b6 --- /dev/null +++ b/docs/reference/utilities/fhir_helpers.md @@ -0,0 +1,741 @@ +# FHIR Utilities + +The `fhir` module provides a set of helper functions to make it easier for you to work with FHIR resources. + +## Resource Creation + +FHIR is the modern de facto standard for storing and exchanging healthcare data, but working with [FHIR resources](https://www.hl7.org/fhir/resourcelist.html) can often involve complex and nested JSON structures with required and optional fields that vary between contexts. + +Creating FHIR resources can involve a lot of boilerplate code, validation errors and manual comparison of FHIR specifications with the resource you're trying to create. + +For example, as an ML practitioner, you may only care about extracting and inserting certain codes and texts within a FHIR resource. If you want locate the SNOMED CT code for a medication, you may have to do something headache-inducing like this: + +```python +medication_statement = { + "resourceType": "MedicationStatement", + "status": "active", # required + "medication": { # required + "concept": { + "coding": [ + { + "system": "http://www.nlm.nih.gov/research/umls/rxnorm", + "code": "1049221", + "display": "Acetaminophen 325 MG Oral Tablet", + } + ] + } + }, + "subject": { # required + "reference": "Patient/example" + }, +} + +medication_statement["medication"]["concept"]["coding"][0]["code"] +medication_statement["medication"]["concept"]["coding"][0]["display"] + +``` + +The `fhir` `create_*` functions create FHIR resources with sensible defaults, automatically creating a reference ID prefixed by "`hc-`", a status of "`active`" (or equivalent) and adding a creation date where necessary. + +Internally, HealthChain uses [fhir.resources](https://github.com/nazrulworld/fhir.resources) to validate FHIR resources, which is in turn powered by [Pydantic V2](https://docs.pydantic.dev/latest/). You can modify and manipulate the FHIR resources as you would any other Pydantic object after its creation. + +**Please exercise caution when using these functions, as they are only meant to create minimal valid FHIR resources to make it easier to get started. Always check the sensible defaults serve your needs, and validate the resource to ensure it is correct!** + +### Overview + +| Resource Type | Required Fields | Sensible Defaults | Common Use Cases | +|--------------|-----------------|-------------------|------------------| +| **Condition** | • `clinicalStatus`
• `subject` | • `clinicalStatus`: "active"
• `id`: auto-generated with "hc-" prefix | • Recording diagnoses
• Problem list items
• Active conditions | +| **MedicationStatement** | • `subject`
• `status`
• `medication` | • `status`: "recorded"
• `id`: auto-generated with "hc-" prefix | • Current medications
• Medication history
• Prescribed medications | +| **AllergyIntolerance** | • `patient` | • `id`: auto-generated with "hc-" prefix | • Allergies
• Intolerances
• Adverse reactions | +| **DocumentReference** | • `type` | • `status`: "current"
• `date`: current UTC time
• `description`: default text
• `content.attachment.title`: default text | • Clinical notes
• Lab reports
• Imaging reports | + + +### create_condition() + +Creates a new [**Condition**](https://www.hl7.org/fhir/condition.html) resource. + +**Required fields:** + +- [clinicalStatus](https://www.hl7.org/fhir/condition-definitions.html#Condition.clinicalStatus) +- [subject](https://www.hl7.org/fhir/condition-definitions.html#Condition.subject) + +**Sensible defaults:** + +- `clinicalStatus` is set to "`active`" + +```python +from healthchain.fhir import create_condition + +# Create a condition representing hypertension +condition = create_condition( + subject="Patient/123", + code="38341003", + display="Hypertension", + system="http://snomed.info/sct", +) + +# Output the created resource +print(condition.model_dump()) +``` + +
+View output JSON + +```json +{ + "resourceType": "Condition", + "id": "hc-3117bdce-bfab-4d71-968b-1ded900882ca", + + // Clinical status indicating this is an active condition + "clinicalStatus": { + "coding": [{ + "system": "http://terminology.hl7.org/CodeSystem/condition-clinical", + "code": "active", + "display": "Active" + }] + }, + + // SNOMED CT code for Hypertension + "code": { + "coding": [{ + "system": "http://snomed.info/sct", + "code": "38341003", + "display": "Hypertension" + }] + }, + + // Reference to the patient this condition belongs to + "subject": { + "reference": "Patient/123" + } +} +``` +
+ +### create_medication_statement() + +Creates a new [**MedicationStatement**](https://www.hl7.org/fhir/medicationstatement.html) resource. + +**Required fields:** + +- [subject](https://www.hl7.org/fhir/medicationstatement-definitions.html#MedicationStatement.subject) +- [status](https://www.hl7.org/fhir/medicationstatement-definitions.html#MedicationStatement.status) +- [medication](https://www.hl7.org/fhir/medicationstatement-definitions.html#MedicationStatement.medication) + +**Sensible defaults:** + +- `status` is set to "`recorded`" + +```python +from healthchain.fhir import create_medication_statement + +# Create a medication statement for Acetaminophen +medication = create_medication_statement( + subject="Patient/123", + code="1049221", + display="Acetaminophen 325 MG Oral Tablet", + system="http://www.nlm.nih.gov/research/umls/rxnorm", +) + +# Output the created resource +print(medication.model_dump()) +``` + +
+View output JSON + +```json +{ + "resourceType": "MedicationStatement", + "id": "hc-86a26eba-63f9-4017-b7b2-5b36f9bad5f1", + + // Required fields are highlighted + "status": "recorded", // [Required] Status of the medication statement + + // Required medication details using RxNorm coding + "medication": { // [Required] Details about the medication + "concept": { + "coding": [{ + "system": "http://www.nlm.nih.gov/research/umls/rxnorm", + "code": "1049221", + "display": "Acetaminophen 325 MG Oral Tablet" + }] + } + }, + + // Required reference to the patient + "subject": { // [Required] Reference to the patient this medication belongs to + "reference": "Patient/123" + } +} +``` +
+ +### create_allergy_intolerance() + +Creates a new [**AllergyIntolerance**](https://www.hl7.org/fhir/allergyintolerance.html) resource. + +**Required fields:** + +- [patient](https://www.hl7.org/fhir/allergyintolerance-definitions.html#AllergyIntolerance.patient) + +**Sensible defaults:** + +- None + +```python +from healthchain.fhir import create_allergy_intolerance + +# Create an allergy intolerance record +allergy = create_allergy_intolerance( + patient="Patient/123", + code="418038007", + display="Propensity to adverse reactions to substance", + system="http://snomed.info/sct" +) + +# Output the created resource +print(allergy.model_dump()) +``` + +
+View output JSON + +```json +{ + "resourceType": "AllergyIntolerance", + "id": "hc-65edab39-d90b-477b-bdb5-a173b21efd44", + + // SNOMED CT code for the allergy + "code": { + "coding": [{ + "system": "http://snomed.info/sct", + "code": "418038007", + "display": "Propensity to adverse reactions to substance" + }] + }, + + // Required reference to the patient + "patient": { // [Required] Reference to the patient this allergy belongs to + "reference": "Patient/123" + } +} +``` +
+ +### create_document_reference() + +Creates a new [**DocumentReference**](https://www.hl7.org/fhir/documentreference.html) resource. Handles base64 encoding of the attachment data. + +**Required fields:** + +- [type](https://www.hl7.org/fhir/documentreference-definitions.html#DocumentReference.type) + +**Sensible defaults:** + +- `type` is set to "`collection`" +- `status` is set to "`current`" +- `date` is set to the current UTC timestamp +- `description` is set to "`DocumentReference created by HealthChain`" +- `content[0].attachment.title` is set to "`Attachment created by HealthChain`" + +```python +from healthchain.fhir import create_document_reference + +# Create a document reference with a simple text attachment +doc_ref = create_document_reference( + data="Hello World", + content_type="text/plain", + description="A simple text document" +) + +# Output the created resource +print(doc_ref.model_dump()) +``` + +
+View output JSON + +```json +{ + "resourceType": "DocumentReference", + "id": "hc-60fcfdad-9617-4557-88d8-8c8db9b9fe70", + + // Document metadata + "status": "current", + "date": "2025-02-28T14:55:33+00:00", // UTC timestamp + "description": "A simple text document", + + // Document content with base64 encoded data + "content": [{ + "attachment": { + "contentType": "text/plain", + "data": "SGVsbG8gV29ybGQ=", // "Hello World" in base64 + "title": "Attachment created by HealthChain", + "creation": "2025-02-28T14:55:33+00:00" // UTC timestamp + } + }] +} +``` + +
+View decoded content + +```text +Hello World +``` +
+
+ +## Utilities + +### set_problem_list_item_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 + +# Create a condition and set it as a problem list item +problem_list_item = create_condition( + subject="Patient/123", + code="38341003", + display="Hypertension" +) + +set_problem_list_item_category(problem_list_item) + +# Output the modified resource +print(problem_list_item.model_dump()) +``` + +
+View output JSON + +```json +{ + "resourceType": "Condition", + "id": "hc-3d5f62e7-729b-4da1-936c-e8e16e5a9358", + + // Required fields are highlighted + "clinicalStatus": { // [Required] Clinical status of the condition + "coding": [{ + "system": "http://terminology.hl7.org/CodeSystem/condition-clinical", + "code": "active", + "display": "Active" + }] + }, + + // Category added by set_problem_list_item_category + "category": [{ + "coding": [{ + "system": "http://terminology.hl7.org/CodeSystem/condition-category", + "code": "problem-list-item", + "display": "Problem List Item" + }] + }], + + // SNOMED CT code for the condition + "code": { + "coding": [{ + "system": "http://snomed.info/sct", + "code": "38341003", + "display": "Hypertension" + }] + }, + + // Required reference to the patient + "subject": { // [Required] Reference to the patient this condition belongs to + "reference": "Patient/123" + } +} +``` +
+ +### read_content_attachment() + +Reads attachments from a [**DocumentReference**](https://www.hl7.org/fhir/documentreference.html) in a human-readable format. + +```python +from healthchain.fhir import read_content_attachment + +attachments = read_content_attachment(document_reference) +# Returns a list of dictionaries containing: +# [ +# { +# "data": "Hello World", +# "metadata": { +# "content_type": "text/plain", +# "title": "My Document", +# "creation": datetime.datetime(2025, 2, 28, 15, 27, 55, tzinfo=TzInfo(UTC)), +# }, +# } +# ] +``` + +## Bundle Operations + +FHIR Bundles are containers that can hold multiple FHIR resources together. They are commonly used to group related resources or to send/receive multiple resources in a single request. + +The bundle operations make it easy to: + +- Create and manage bundles +- Add or update resources within bundles +- Retrieve specific resource types from bundles +- Work with multiple resource types in a single bundle + +### create_bundle() + +Creates a new [**Bundle**](https://www.hl7.org/fhir/bundle.html) resource. + +**Required fields:** + +- [type](https://www.hl7.org/fhir/bundle-definitions.html#Bundle.type) + +**Sensible defaults:** + +- `type` is set to "`collection`" + +```python +from healthchain.fhir import create_bundle + +# Create an empty bundle +bundle = create_bundle(bundle_type="collection") + +# Output the created resource +print(bundle.model_dump()) +``` + +
+View output JSON + +```json +{ + "resourceType": "Bundle", + "type": "collection", // [Required] Type of bundle + "entry": [] // Empty list of resources +} +``` +
+ +### add_resource() + +Adds a single resource to a [**Bundle**](https://www.hl7.org/fhir/bundle.html). + +```python +from healthchain.fhir import add_resource, create_condition, create_bundle + +# Create a condition to add to the bundle +condition = create_condition( + subject="Patient/123", + code="38341003", + display="Hypertension" +) + +# Create a bundle and add the condition +bundle = create_bundle() +add_resource(bundle, condition) + +# Output the modified bundle +print(bundle.model_dump()) +``` + +
+View output JSON + +```json +{ + "resourceType": "Bundle", + "type": "collection", + + // List of resources in the bundle + "entry": [{ + "resource": { + "resourceType": "Condition", + "id": "hc-3117bdce-bfab-4d71-968b-1ded900882ca", + + // Required fields from the condition + "clinicalStatus": { // [Required] + "coding": [{ + "system": "http://terminology.hl7.org/CodeSystem/condition-clinical", + "code": "active", + "display": "Active" + }] + }, + + "code": { + "coding": [{ + "system": "http://snomed.info/sct", + "code": "38341003", + "display": "Hypertension" + }] + }, + + "subject": { // [Required] + "reference": "Patient/123" + } + } + }] +} +``` + +
+View field descriptions + +| Field | Required | Description | +|-------|:--------:|-------------| +| `entry` | - | Array of resources in the bundle | +| `entry[].resource` | ✓ | The FHIR resource being added | +| `entry[].fullUrl` | - | Optional full URL for the resource | + +
+
+ +### get_resources() + +Retrieves all resources of a specific type from a [**Bundle**](https://www.hl7.org/fhir/bundle.html). + +```python +from healthchain.fhir import get_resources + +# Get all conditions in the bundle +conditions = get_resources(bundle, "Condition") + +# Or using the resource type directly +from fhir.resources.condition import Condition +conditions = get_resources(bundle, Condition) + +# Each resource in the returned list will be a full FHIR resource +for condition in conditions: + print(f"Found condition: {condition.code.coding[0].display}") +``` + +### set_resources() + +Sets or updates resources of a specific type in a [**Bundle**](https://www.hl7.org/fhir/bundle.html). + +```python +from healthchain.fhir import set_resources, create_condition + +# Create some conditions +conditions = [ + create_condition( + subject="Patient/123", + code="38341003", + display="Hypertension" + ), + create_condition( + subject="Patient/123", + code="44054006", + display="Diabetes" + ) +] + +# Replace all existing conditions with new ones +set_resources(bundle, conditions, "Condition", replace=True) + +# Or append new conditions to existing ones +set_resources(bundle, conditions, "Condition", replace=False) +``` + +
+View example bundle with multiple conditions + +```json +{ + "resourceType": "Bundle", + "type": "collection", + "entry": [ + { + "resource": { + "resourceType": "Condition", + "id": "hc-3117bdce-bfab-4d71-968b-1ded900882ca", + "clinicalStatus": { + "coding": [{ + "system": "http://terminology.hl7.org/CodeSystem/condition-clinical", + "code": "active", + "display": "Active" + }] + }, + "code": { + "coding": [{ + "system": "http://snomed.info/sct", + "code": "38341003", + "display": "Hypertension" + }] + }, + "subject": {"reference": "Patient/123"} + } + }, + { + "resource": { + "resourceType": "Condition", + "id": "hc-9876fedc-ba98-7654-3210-fedcba987654", + "clinicalStatus": { + "coding": [{ + "system": "http://terminology.hl7.org/CodeSystem/condition-clinical", + "code": "active", + "display": "Active" + }] + }, + "code": { + "coding": [{ + "system": "http://snomed.info/sct", + "code": "44054006", + "display": "Diabetes" + }] + }, + "subject": {"reference": "Patient/123"} + } + } + ] +} +``` +
+ +## Common Patterns + +### Working with Multiple Resource Types + +This example shows how to work with multiple types of FHIR resources in a single bundle. + +```python +from healthchain.fhir import ( + create_bundle, + create_condition, + create_medication_statement, + create_allergy_intolerance, + get_resources, + set_resources, +) + +# Create a bundle to hold patient data +bundle = create_bundle() + +# Add conditions (diagnoses) +conditions = [ + create_condition( + subject="Patient/123", + code="38341003", + display="Hypertension" + ), + create_condition( + subject="Patient/123", + code="44054006", + display="Diabetes" + ) +] +set_resources(bundle, conditions, "Condition") + +# Add medications +medications = [ + create_medication_statement( + subject="Patient/123", + code="1049221", + display="Acetaminophen 325 MG" + ) +] +set_resources(bundle, medications, "MedicationStatement") + +# Add allergies +allergies = [ + create_allergy_intolerance( + patient="Patient/123", + code="418038007", + display="Penicillin allergy" + ) +] +set_resources(bundle, allergies, "AllergyIntolerance") + +# Later, retrieve resources by type +conditions = get_resources(bundle, "Condition") +medications = get_resources(bundle, "MedicationStatement") +allergies = get_resources(bundle, "AllergyIntolerance") +``` + +
+View complete bundle JSON + +```json +{ + "resourceType": "Bundle", + "type": "collection", + "entry": [ + { + "resource": { + "resourceType": "Condition", + "id": "hc-3117bdce-bfab-4d71-968b-1ded900882ca", + "clinicalStatus": { + "coding": [{ + "system": "http://terminology.hl7.org/CodeSystem/condition-clinical", + "code": "active", + "display": "Active" + }] + }, + "code": { + "coding": [{ + "system": "http://snomed.info/sct", + "code": "38341003", + "display": "Hypertension" + }] + }, + "subject": {"reference": "Patient/123"} + } + }, + { + "resource": { + "resourceType": "Condition", + "id": "hc-9876fedc-ba98-7654-3210-fedcba987654", + "clinicalStatus": { + "coding": [{ + "system": "http://terminology.hl7.org/CodeSystem/condition-clinical", + "code": "active", + "display": "Active" + }] + }, + "code": { + "coding": [{ + "system": "http://snomed.info/sct", + "code": "44054006", + "display": "Diabetes" + }] + }, + "subject": {"reference": "Patient/123"} + } + }, + { + "resource": { + "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" + }] + } + }, + "subject": {"reference": "Patient/123"} + } + }, + { + "resource": { + "resourceType": "AllergyIntolerance", + "id": "hc-65edab39-d90b-477b-bdb5-a173b21efd44", + "code": { + "coding": [{ + "system": "http://snomed.info/sct", + "code": "418038007", + "display": "Penicillin allergy" + }] + }, + "patient": {"reference": "Patient/123"} + } + } + ] +} +``` +
diff --git a/mkdocs.yml b/mkdocs.yml index f8fa3a91..e10a5498 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -42,6 +42,7 @@ nav: - Clinical Decision Support: reference/sandbox/use_cases/cds.md - Clinical Documentation: reference/sandbox/use_cases/clindoc.md - Utilities: + - FHIR Helpers: reference/utilities/fhir_helpers.md - Data Generator: reference/utilities/data_generator.md - CDA Parser: reference/utilities/cda_parser.md - API Reference: From 8430229728f54a9c81bb0c4a5f0b7d34407eeff4 Mon Sep 17 00:00:00 2001 From: jenniferjiangkells Date: Fri, 28 Feb 2025 17:46:29 +0000 Subject: [PATCH 33/34] create_condition parameter should match fhir spec --- healthchain/cda_parser/cdaannotator.py | 2 +- healthchain/fhir/helpers.py | 6 +++--- tests/fhir/test_helpers.py | 2 +- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/healthchain/cda_parser/cdaannotator.py b/healthchain/cda_parser/cdaannotator.py index fd7c63e2..4dabfc7d 100644 --- a/healthchain/cda_parser/cdaannotator.py +++ b/healthchain/cda_parser/cdaannotator.py @@ -368,7 +368,7 @@ def create_fhir_condition_from_cda(value: Dict, entry) -> Condition: # Create condition using helper function condition = create_condition( subject="Patient/123", # TODO: add patient reference {self.clinical_document.recordTarget.patientRole.id} - status=status, + clinical_status=status, code=value.get("@code"), display=value.get("@displayName"), system=self.code_mapping.cda_to_fhir( diff --git a/healthchain/fhir/helpers.py b/healthchain/fhir/helpers.py index b6d5e128..53bb8bd1 100644 --- a/healthchain/fhir/helpers.py +++ b/healthchain/fhir/helpers.py @@ -145,7 +145,7 @@ def set_problem_list_item_category(condition: Condition) -> Condition: def create_condition( subject: str, - status: str = "active", + clinical_status: str = "active", code: Optional[str] = None, display: Optional[str] = None, system: Optional[str] = "http://snomed.info/sct", @@ -174,8 +174,8 @@ def create_condition( id=_generate_id(), subject={"reference": subject}, clinicalStatus=create_single_codeable_concept( - code=status, - display=status.capitalize(), + code=clinical_status, + display=clinical_status.capitalize(), system="http://terminology.hl7.org/CodeSystem/condition-clinical", ), code=condition_code, diff --git a/tests/fhir/test_helpers.py b/tests/fhir/test_helpers.py index 64bc00b3..da0fb684 100644 --- a/tests/fhir/test_helpers.py +++ b/tests/fhir/test_helpers.py @@ -60,7 +60,7 @@ def test_create_condition(): """Test creating a condition with all optional fields.""" condition = create_condition( subject="Patient/123", - status="resolved", + clinical_status="resolved", code="123", display="Test Condition", system="http://test.system", From 4f22ec4fb67f4e74a894c87f1982350add379632 Mon Sep 17 00:00:00 2001 From: jenniferjiangkells Date: Tue, 4 Mar 2025 12:12:13 +0000 Subject: [PATCH 34/34] Update docs --- README.md | 39 +++++---- docs/api/fhir_helpers.md | 3 + docs/cookbook/notereader_sandbox.md | 83 +++++++++++++------ docs/quickstart.md | 21 ++--- .../pipeline/connectors/cdaconnector.md | 39 ++++++--- .../pipeline/connectors/cdsfhirconnector.md | 44 ++++++---- .../pipeline/connectors/connectors.md | 20 ++--- docs/reference/pipeline/data_container.md | 2 +- docs/reference/sandbox/use_cases/cds.md | 7 -- docs/reference/sandbox/use_cases/clindoc.md | 2 +- docs/reference/utilities/data_generator.md | 2 +- healthchain/cda_parser/cdaannotator.py | 1 - healthchain/fhir/helpers.py | 7 +- healthchain/io/cdaconnector.py | 2 +- healthchain/io/cdsfhirconnector.py | 2 +- healthchain/io/containers/document.py | 64 +++++++------- healthchain/pipeline/components/base.py | 3 - .../pipeline/components/integrations.py | 32 ------- mkdocs.yml | 3 +- tests/conftest.py | 2 +- tests/containers/test_fhir_data.py | 6 +- tests/pipeline/conftest.py | 2 +- 22 files changed, 206 insertions(+), 180 deletions(-) create mode 100644 docs/api/fhir_helpers.md diff --git a/README.md b/README.md index 112836b0..c9be8f3b 100644 --- a/README.md +++ b/README.md @@ -10,20 +10,22 @@ -Simplify developing, testing and validating AI and NLP applications in a healthcare context 💫 🏥. +Build simple, portable, and scalable AI and NLP applications in a healthcare context 💫 🏥. -Building applications that integrate with electronic health record systems (EHRs) is complex, and so is designing reliable, reactive algorithms involving unstructured data. Let's try to change that. +Integrating electronic health record systems (EHRs) data is complex, and so is designing reliable, reactive algorithms involving unstructured healthcare data. Let's try to change that. ```bash pip install healthchain ``` First time here? Check out our [Docs](https://dotimplement.github.io/HealthChain/) page! -Came here from NHS RPySOC 2024 ✨? [CDS sandbox walkthrough](https://dotimplement.github.io/HealthChain/cookbook/cds_sandbox/) +Came here from NHS RPySOC 2024 ✨? +[CDS sandbox walkthrough](https://dotimplement.github.io/HealthChain/cookbook/cds_sandbox/) +[Slides](https://speakerdeck.com/jenniferjiangkells/building-healthcare-context-aware-applications-with-healthchain) ## Features -- [x] 🛠️ Build custom pipelines or use [pre-built ones](https://dotimplement.github.io/HealthChain/reference/pipeline/pipeline/#prebuilt) for your healthcare NLP and ML tasks -- [x] 🏗️ Add built-in [CDA and FHIR parsers](https://dotimplement.github.io/HealthChain/reference/utilities/cda_parser/) to connect your pipeline to interoperability standards +- [x] 🔥 Build FHIR-native pipelines or use [pre-built ones](https://dotimplement.github.io/HealthChain/reference/pipeline/pipeline/#prebuilt) for your healthcare NLP and ML tasks +- [x] 🔌 Connect pipelines to any EHR system with built-in [CDA and FHIR Connectors](https://dotimplement.github.io/HealthChain/reference/pipeline/connectors/connectors/) - [x] 🧪 Test your pipelines in full healthcare-context aware [sandbox](https://dotimplement.github.io/HealthChain/reference/sandbox/sandbox/) environments - [x] 🗃️ Generate [synthetic healthcare data](https://dotimplement.github.io/HealthChain/reference/utilities/data_generator/) for testing and development - [x] 🚀 Deploy sandbox servers locally with [FastAPI](https://fastapi.tiangolo.com/) @@ -42,24 +44,28 @@ Pipelines provide a flexible way to build and manage processing pipelines for NL ```python from healthchain.io.containers import Document from healthchain.pipeline import Pipeline -from healthchain.pipeline.components import TextPreProcessor, SpacyNLP, TextPostProcessor +from healthchain.pipeline.components import ( + TextPreProcessor, + SpacyNLP, + TextPostProcessor, +) # Initialize the pipeline nlp_pipeline = Pipeline[Document]() # Add TextPreProcessor component -preprocessor = TextPreProcessor(tokenizer="spacy") +preprocessor = TextPreProcessor() nlp_pipeline.add_node(preprocessor) # Add Model component (assuming we have a pre-trained model) -spacy_nlp = SpacyNLP.from_model_id("en_core_sci_md", source="spacy") +spacy_nlp = SpacyNLP.from_model_id("en_core_sci_sm") nlp_pipeline.add_node(spacy_nlp) # Add TextPostProcessor component postprocessor = TextPostProcessor( postcoordination_lookup={ "heart attack": "myocardial infarction", - "high blood pressure": "hypertension" + "high blood pressure": "hypertension", } ) nlp_pipeline.add_node(postprocessor) @@ -70,7 +76,7 @@ nlp = nlp_pipeline.build() # Use the pipeline result = nlp(Document("Patient has a history of heart attack and high blood pressure.")) -print(f"Entities: {result.nlp.spacy_doc.ents}") +print(f"Entities: {result.nlp.get_entities()}") ``` #### Adding connectors @@ -89,6 +95,7 @@ pipe = pipeline.build() cda_data = CdaRequest(document="") output = pipe(cda_data) +# output: CdsResponse model ``` ### Using pre-built pipelines @@ -212,12 +219,12 @@ healthchain run mycds.py By default, the server runs at `http://127.0.0.1:8000`, and you can interact with the exposed endpoints at `/docs`. ## Road Map -- [ ] 🎛️ Versioning and artifact management for pipelines sandbox EHR configurations -- [ ] ❓ Testing and evaluation framework for pipelines and use cases +- [ ] 🔄 Transform and validate healthcare HL7v2, CDA to FHIR with template-based interop engine +- [ ] 🏥 Runtime connection health and EHR integration management - connect to FHIR APIs and legacy systems +- [ ] 📊 Track configurations, data provenance, and monitor model performance with MLFlow integration +- [ ] 🚀 Compliance monitoring, auditing at deployment as a sidecar service +- [ ] 🔒 Built-in HIPAA compliance validation and PHI detection - [ ] 🧠 Multi-modal pipelines that that have built-in NLP to utilize unstructured data -- [ ] ✨ Improvements to synthetic data generator methods -- [ ] 👾 Frontend UI for EHR client and visualization features -- [ ] 🚀 Production deployment options ## Contribute We are always eager to hear feedback and suggestions, especially if you are a developer or researcher working with healthcare systems! @@ -225,4 +232,4 @@ We are always eager to hear feedback and suggestions, especially if you are a de - 🛠️ [Contribution Guidelines](CONTRIBUTING.md) ## Acknowledgement -This repository makes use of CDS Hooks developed by Boston Children’s Hospital. +This repository makes use of [fhir.resources](https://github.com/nazrulworld/fhir.resources), and [CDS Hooks](https://cds-hooks.org/) developed by [HL7](https://www.hl7.org/) and [Boston Children’s Hospital](https://www.childrenshospital.org/). diff --git a/docs/api/fhir_helpers.md b/docs/api/fhir_helpers.md new file mode 100644 index 00000000..9f1f15aa --- /dev/null +++ b/docs/api/fhir_helpers.md @@ -0,0 +1,3 @@ +# FHIR Helpers + +::: healthchain.fhir diff --git a/docs/cookbook/notereader_sandbox.md b/docs/cookbook/notereader_sandbox.md index 3cfd2053..8fdc3fa0 100644 --- a/docs/cookbook/notereader_sandbox.md +++ b/docs/cookbook/notereader_sandbox.md @@ -1,39 +1,74 @@ # NoteReader Sandbox -A sandbox example of NoteReader clinical documentation improvement which extracts problems, medications, and allergies entries from the progress note section of a pre-configured CDA document. +A sandbox example of NoteReader clinical documentation improvement which extracts problems, medications, and allergies entries from the progress note section of a pre-configured CDA document using [scispacy](https://github.com/allenai/scispacy) with a custom entity linker component. Full example coming soon! ```python import healthchain as hc -from healthchain.use_cases import ClinicalDocumentation + +from healthchain.io import Document +from healthchain.models.requests.cda import CdaRequest, CdaResponse +from healthchain.pipeline.medicalcodingpipeline import MedicalCodingPipeline +from healthchain.use_cases.clindoc import ClinicalDocumentation from healthchain.fhir import create_document_reference +from spacy.tokens import Span + from fhir.resources.documentreference import DocumentReference +pipeline = MedicalCodingPipeline.from_model_id("en_core_sci_sm", source="spacy") + +@pipeline.add_node(position="after", reference="SpacyNLP") +def link_entities(doc: Document) -> Document: + # Register the extension if it doesn't exist already + if not Span.has_extension("cui"): + Span.set_extension("cui", default=None) + spacy_doc = doc.nlp.get_spacy_doc() + + dummy_linker = {"fever": "C0006477", + "cough": "C0006477", + "cold": "C0006477", + "flu": "C0006477", + "headache": "C0006477", + "sore throat": "C0006477", + } + + for ent in spacy_doc.ents: + if ent.text in dummy_linker: + ent._.cui = dummy_linker[ent.text] + + doc.update_problem_list_from_nlp() + + return doc + @hc.sandbox class NotereaderSandbox(ClinicalDocumentation): - def __init__(self): - self.cda_path = "./resources/uclh_cda.xml" - self.pipeline = MedicalCodingPipeline.from_local_model( - "./resources/models/medcat_model.zip", source="spacy" - ) - - @hc.ehr(workflow="sign-note-inpatient") - def load_data_in_client(self) -> DocumentReference: - with open(self.cda_path, "r") as file: - xml_string = file.read() - - cda_document_reference = create_document_reference( - data=xml_string, - content_type="text/xml", - description="Original CDA Document loaded from my sandbox", - ) - return cda_document_reference - - @hc.api - def my_service(self, request: CdaRequest) -> CdaResponse: - response = self.pipeline(request) - return response + def __init__(self): + self.pipeline = pipeline + + @hc.ehr(workflow="sign-note-inpatient") + def load_data_in_client(self) -> DocumentReference: + with open("./resources/uclh_cda.xml", "r") as file: + xml_string = file.read() + + cda_document_reference = create_document_reference( + data=xml_string, + content_type="text/xml", + description="Original CDA Document loaded from my sandbox", + ) + + return cda_document_reference + + @hc.api + def my_service(self, request: CdaRequest) -> CdaResponse: + result = self.pipeline(request) + + return result + + +if __name__ == "__main__": + clindoc = NotereaderSandbox() + clindoc.start_sandbox() ``` diff --git a/docs/quickstart.md b/docs/quickstart.md index ce518d4b..99791cc4 100644 --- a/docs/quickstart.md +++ b/docs/quickstart.md @@ -54,13 +54,13 @@ Add components to your pipeline with the `.add_node()` method and compile with ` ```python from healthchain.pipeline import Pipeline -from healthchain.pipeline.components import TextPreProcessor, Model, TextPostProcessor +from healthchain.pipeline.components import TextPreProcessor, SpacyNLP, TextPostProcessor from healthchain.io import Document pipeline = Pipeline[Document]() pipeline.add_node(TextPreProcessor()) -pipeline.add_node(Model(model_path="path/to/model")) +pipeline.add_node(SpacyNLP.from_model_id("en_core_sci_sm")) pipeline.add_node(TextPostProcessor()) pipe = pipeline.build() @@ -73,7 +73,7 @@ Let's go one step further! You can use [Connectors](./reference/pipeline/connect ```python from healthchain.pipeline import Pipeline -from healthchain.pipeline.components import Model +from healthchain.pipeline.components import SpacyNLP from healthchain.io import CdaConnector from healthchain.models import CdaRequest @@ -81,7 +81,7 @@ pipeline = Pipeline() cda_connector = CdaConnector() pipeline.add_input(cda_connector) -pipeline.add_node(Model(model_path="path/to/model")) +pipeline.add_node(SpacyNLP.from_model_id("en_core_sci_sm")) pipeline.add_output(cda_connector) pipe = pipeline.build() @@ -183,9 +183,7 @@ This will start a server by default at `http://127.0.0.1:8000`, and you can inte You can use the data generator to generate synthetic data for your sandbox runs. -The `.generate_prefetch()` method is dependent on use case and workflow. For example, `CdsDataGenerator` will generate synthetic [FHIR](https://hl7.org/fhir/) data suitable for the workflow specified by the use case. - -We're working on generating synthetic [CDA](https://www.hl7.org.uk/standards/hl7-standards/cda-clinical-document-architecture/) data. If you're interested in contributing, please [reach out](https://discord.gg/UQC6uAepUz)! +The `.generate_prefetch()` method is dependent on use case and workflow. For example, `CdsDataGenerator` will generate synthetic [FHIR](https://hl7.org/fhir/) data as [Pydantic](https://docs.pydantic.dev/) models suitable for the workflow specified by the use case. [(Full Documentation on Data Generators)](./reference/utilities/data_generator.md) @@ -216,9 +214,9 @@ We're working on generating synthetic [CDA](https://www.hl7.org.uk/standards/hl7 === "On its own" ```python from healthchain.data_generators import CdsDataGenerator - from healthchain.workflow import Workflow + from healthchain.workflows import Workflow - # Initialise data generator + # Initialize data generator data_generator = CdsDataGenerator() # Generate FHIR resources for use case workflow @@ -229,11 +227,10 @@ We're working on generating synthetic [CDA](https://www.hl7.org.uk/standards/hl7 # { # "prefetch": { - # "entry": [ + # "encounter": # { - # "resource": ... + # "resourceType": ... # } - # ] # } #} ``` diff --git a/docs/reference/pipeline/connectors/cdaconnector.md b/docs/reference/pipeline/connectors/cdaconnector.md index 455b828b..fe902a73 100644 --- a/docs/reference/pipeline/connectors/cdaconnector.md +++ b/docs/reference/pipeline/connectors/cdaconnector.md @@ -1,11 +1,19 @@ # CDA Connector -The `CdaConnector` handles Clinical Document Architecture (CDA) documents, serving as both an input and output connector in the pipeline. It parses CDA documents, extracting free-text notes and relevant structured clinical data into a `Document` object, and can return an annotated CDA document as output. +The `CdaConnector` parses CDA documents, extracting free-text notes and relevant structured clinical data into FHIR resources in the `Document` container, and returns an annotated CDA document as output. It will also extract the text from the note section of the document and store it in the `Document.text` attribute. -This connector is particularly useful for clinical documentation improvement (CDI) workflows where CDA documents need to be processed and updated with additional structured data. +This connector is particularly useful for clinical documentation improvement (CDI) workflows where a document needs to be processed and updated with additional structured data. [(Full Documentation on Clinical Documentation)](../../sandbox/use_cases/clindoc.md) +[(Full Documentation on CDA Parser)](../../utilities/cda_parser.md) + +## Input and Output + +| Input | Output | Access | +|-------|--------|-----------| +| [**CdaRequest**](../../../api/use_cases.md#healthchain.models.requests.cdarequest.CdaRequest) | [**CdaResponse**](../../../api/use_cases.md#healthchain.models.responses.cdaresponse.CdaResponse) | `Document.fhir.problem_list`, `Document.fhir.medication_list`, `Document.fhir.allergy_list`, `Document.text` | + ## Usage ```python @@ -21,28 +29,33 @@ pipeline.add_input(cda_connector) pipeline.add_output(cda_connector) # Example CDA request -cda_request = CdaRequest(document="") - -# Example 1: Simple pipeline execution -pipe = pipeline.build() -cda_response = pipe(cda_request) -print(cda_response) -# Output: CdaResponse(document='') +cda_request = CdaRequest(document="test") -# Example 2: Accessing CDA data inside a pipeline node +# Accessing CDA data inside a pipeline node @pipeline.add_node def example_pipeline_node(document: Document) -> Document: - print(document.ccd_data) + print(document.fhir.problem_list) + print(document.text) return document +# Pipeline execution pipe = pipeline.build() cda_response = pipe(cda_request) -# Output: CdaResponse object... +print(cda_response) +# Output: CdaResponse(document='') ``` ## Accessing data inside your pipeline -Data parsed from the CDA document is stored in the `Document.fhir` attribute as a `DocumentReference` FHIR resource, as shown in the example above. +Data parsed from the CDA document is converted into FHIR resources and stored in the `Document.fhir.bundle` attribute. The connector currently supports the following CDA section to FHIR resource mappings: + +CDA section | FHIR resource | Document.fhir attribute +--- | --- | --- +Problem List | [Condition](https://www.hl7.org/fhir/condition.html) | `Document.fhir.problem_list` +Medication List | [MedicationStatement](https://www.hl7.org/fhir/medicationstatement.html) | `Document.fhir.medication_list` +Allergy List | [AllergyIntolerance](https://www.hl7.org/fhir/allergyintolerance.html) | `Document.fhir.allergy_list` +Note | [DocumentReference](https://www.hl7.org/fhir/documentreference.html) | `Document.fhir.bundle` (use `get_resources("DocumentReference")` to access) + ## Configuration diff --git a/docs/reference/pipeline/connectors/cdsfhirconnector.md b/docs/reference/pipeline/connectors/cdsfhirconnector.md index b0870c05..2b49a86e 100644 --- a/docs/reference/pipeline/connectors/cdsfhirconnector.md +++ b/docs/reference/pipeline/connectors/cdsfhirconnector.md @@ -1,11 +1,16 @@ # CDS FHIR Connector -The `CdsFhirConnector` handles FHIR data in the context of Clinical Decision Support (CDS) services, serving as both an input and output connector in the pipeline. - -Note that this is not meant to be used as a generic FHIR connector, but specifically designed for use with the [CDS Hooks specification](https://cds-hooks.org/). +The `CdsFhirConnector` handles FHIR data in the context of Clinical Decision Support (CDS) services, specifically using the [CDS Hooks specification](https://cds-hooks.org/). [(Full Documentation on Clinical Decision Support)](../../sandbox/use_cases/cds.md) +## Input and Output + +| Input | Output | Access | +|-------|--------|-----------| +| [**CDSRequest**](../../../api/use_cases.md#healthchain.models.requests.cdsrequest.CDSRequest) | [**CDSResponse**](../../../api/use_cases.md#healthchain.models.responses.cdsresponse.CDSResponse) | `Document.fhir.prefetch_resources` | + + ## Usage ```python @@ -16,7 +21,7 @@ from healthchain.pipeline import Pipeline # Create a pipeline with CdsFhirConnector pipeline = Pipeline() -cds_fhir_connector = CdsFhirConnector() +cds_fhir_connector = CdsFhirConnector(hook_name="patient-view") pipeline.add_input(cds_fhir_connector) pipeline.add_output(cds_fhir_connector) @@ -38,26 +43,33 @@ cds_request = CDSRequest( } ) -# Example 1: Simple pipeline execution -pipe = pipeline.build() -cds_response = pipe(cds_request) -print(cds_response) -# Output: CDSResponse with cards... - -# Example 2: Accessing FHIR data inside a pipeline node +# Accessing FHIR data inside a pipeline node @pipeline.add_node def example_pipeline_node(document: Document) -> Document: - print(document.fhir_resources) + print(document.fhir.get_prefetch_resources("patient")) return document +# Execute the pipeline pipe = pipeline.build() cds_response = pipe(cds_request) -# Output: CdsResponse object... - +# Output: CdsResponse with cards... ``` ## Accessing data inside your pipeline -Data parsed from the FHIR resources is stored in the `Document.fhir_resources` attribute as a dictionary of FHIR resources corresponding to the keys in the `prefetch` field of the `CDSRequest`, as shown in the example above. +Data parsed from the CDS request is stored in the `Document.fhir.prefetch_resources` attribute as a dictionary of FHIR resources corresponding to the keys in the `prefetch` field of the `CDSRequest`. For more information on the `prefetch` field, check out the [CDS Hooks specification on providing FHIR resources to a CDS service](https://cds-hooks.org/specification/current/#providing-fhir-resources-to-a-cds-service). + +### Example Prefetch -[(Prefetch Reference)](../../../api/data_models.md#healthchain.models.data.prefetch) +```json +{ + "patient": { + "resourceType": "Patient", + "id": "123", + "name": [{"family": "Doe", "given": ["John"]}], + "birthDate": "1970-01-01" + }, + "condition": // Condition FHIR resource... + "document": // DocumentReference FHIR resource... +} +``` diff --git a/docs/reference/pipeline/connectors/connectors.md b/docs/reference/pipeline/connectors/connectors.md index 323949e8..0f58651e 100644 --- a/docs/reference/pipeline/connectors/connectors.md +++ b/docs/reference/pipeline/connectors/connectors.md @@ -6,21 +6,17 @@ Connectors are what give you the power to build *end-to-end* pipelines that inte ## Available connectors -Connectors make certain assumptions about the data they receive depending on the use case to convert it to an appropriate internal data format and container. +Connectors parse data from a specific format into FHIR resources and store them in a `Document` container. -Some connectors require the same instance to be used for both input and output, while others may be input or output only. +([Document API Reference](../../../api/containers.md#healthchain.io.containers.document.Document)) -| Connector | Input | Output | FHIR Resources | Access it by... | Same instance I/O? | -|-----------|-------|--------|-------------------------|----------------|--------------------------| -| [**CdaConnector**](cdaconnector.md) | `CdaRequest` :material-arrow-right: `Document` | `Document` :material-arrow-right: `CdaResponse` | [**DocumentReference**] | `` | ✅ | -| [**CdsFhirConnector**](cdsfhirconnector.md) | `CDSRequest` :material-arrow-right: `Document` | `Document` :material-arrow-right: `CdsResponse` | **Any FHIR Resource** | `.fhir_resources` | ✅ | +Some connectors require the same instance to be used for both input and output as they respond to a synchronous call, while others may be input or output only. -!!! example "CdaConnector Example" - The `CdaConnector` expects a `CdaRequest` object as input and outputs a `CdaResponse` object. The connector converts the input data into a `Document` object because CDAs are usually represented as a document object. +| Connector | FHIR Resources | Access | Same instance I/O? | +|-----------|-------------------------|----------------|--------------------------| +| [**CdaConnector**](cdaconnector.md) | [**DocumentReference**](https://www.hl7.org/fhir/documentreference.html) | `Document.text`, `Document.fhir.problem_list`, `Document.fhir.medication_list`, `Document.fhir.allergy_list` | ✅ | +| [**CdsFhirConnector**](cdsfhirconnector.md) | [**Any FHIR Resource**](https://www.hl7.org/fhir/resourcelist.html) | `Document.fhir.get_prefetch_resources()` | ✅ | - This `Document` object contains a `.fhir` attribute, which stores the structured data from the CDA document in a `DocumentReference` FHIR resource. Any free-text notes are stored in the `Document.text` attribute. - - Because CDAs are annotated documents, the same `CdaConnector` instance must be used for both input and output operations in the pipeline. ## Use Cases Each connector can be mapped to a specific use case in the sandbox module. @@ -46,4 +42,4 @@ pipeline.add_input(cda_connector) pipeline.add_output(cda_connector) ``` -Connectors are currently intended for development and testing purposes only. They are not production-ready, although this is something we want to work towards on our long-term roadmap. If there is a specific connector you would like to see, please feel free to [open an issue](https://github.com/dotimplement/healthchain/issues) or [contact us](https://discord.gg/UQC6uAepUz)! +Connectors are currently intended for development and testing purposes only. They are not production-ready, although this is something we are working towards on our long-term roadmap. If there is a specific connector you would like to see, please feel free to [open an issue](https://github.com/dotimplement/healthchain/issues) or [contact us](https://discord.gg/UQC6uAepUz)! diff --git a/docs/reference/pipeline/data_container.md b/docs/reference/pipeline/data_container.md index 8f8313e0..be4c2f77 100644 --- a/docs/reference/pipeline/data_container.md +++ b/docs/reference/pipeline/data_container.md @@ -107,7 +107,7 @@ prefetch = { "Condition": doc.fhir.problem_list, "MedicationStatement": doc.fhir.medication_list, } -doc.fhir.set_prefetch_resources(prefetch) +doc.fhir.prefetch_resources = prefetch conditions = doc.fhir.get_prefetch_resources("Condition") ``` diff --git a/docs/reference/sandbox/use_cases/cds.md b/docs/reference/sandbox/use_cases/cds.md index f4eba10a..87b6ab0f 100644 --- a/docs/reference/sandbox/use_cases/cds.md +++ b/docs/reference/sandbox/use_cases/cds.md @@ -18,13 +18,6 @@ CDS workflows are based on [CDS Hooks](https://cds-hooks.org/). CDS Hooks is an [CdsFhirConnector](../../pipeline/connectors/cdsfhirconnector.md) handles the conversion of `CDSRequests` :material-swap-horizontal: `Document` :material-swap-horizontal: `CDSResponse` in a HealthChain pipeline. -Attributes of `Document` are: - -- `fhir_resources` -- `context` -- `prefetch` - -[(Document API Reference)](../../../api/data_models.md#healthchain.models.data.document) ## Supported Workflows diff --git a/docs/reference/sandbox/use_cases/clindoc.md b/docs/reference/sandbox/use_cases/clindoc.md index e6e60c9b..b2d2d59a 100644 --- a/docs/reference/sandbox/use_cases/clindoc.md +++ b/docs/reference/sandbox/use_cases/clindoc.md @@ -1,5 +1,5 @@ # Clinical Documentation -The `ClinicalDocumentation` use case implements a real-time Clinical Documentation Improvement (CDI) service. It currently implements the Epic-integrated NoteReader CDI specification, which communicates with a third-party NLP engine to analyse clinical notes and extract structured data. It helps convert free-text medical documentation into coded information that can be used for billing, quality reporting, and clinical decision support. +The `ClinicalDocumentation` use case implements a real-time Clinical Documentation Improvement (CDI) service. It currently implements the Epic-integrated NoteReader CDI specification, which communicates with a third-party NLP engine to analyse clinical notes and extract structured data. It helps convert free-text medical documentation into coded information that can be used for billing, quality reporting, continuity of care, and clinical decision support ([case study](https://www.researchsquare.com/article/rs-4925228/v1)). `ClinicalDocumentation` communicates using [CDA (Clinical Document Architecture)](https://www.hl7.org.uk/standards/hl7-standards/cda-clinical-document-architecture/). CDAs are standardized electronic documents for exchanging clinical information. They provide a common structure for capturing and sharing patient data like medical history, medications, and care plans between different healthcare systems and providers. Think of it as a collaborative Google Doc that you can add, amend, and remove entries from. diff --git a/docs/reference/utilities/data_generator.md b/docs/reference/utilities/data_generator.md index 1e05da15..b6e492df 100644 --- a/docs/reference/utilities/data_generator.md +++ b/docs/reference/utilities/data_generator.md @@ -60,7 +60,7 @@ You can use the data generator within a client function or on its own. from healthchain.data_generators import CdsDataGenerator from healthchain.workflows import Workflow - # Initialise data generator + # Initialize data generator data_generator = CdsDataGenerator() # Generate FHIR resources for use case workflow diff --git a/healthchain/cda_parser/cdaannotator.py b/healthchain/cda_parser/cdaannotator.py index 4dabfc7d..82734e04 100644 --- a/healthchain/cda_parser/cdaannotator.py +++ b/healthchain/cda_parser/cdaannotator.py @@ -138,7 +138,6 @@ class CdaAnnotator: Args: cda_data (ClinicalDocument): The CDA document data. - fallback (str, optional): The fallback value. Defaults to "LLM". Attributes: clinical_document (ClinicalDocument): The CDA document data. diff --git a/healthchain/fhir/helpers.py b/healthchain/fhir/helpers.py index 53bb8bd1..70f3f0d8 100644 --- a/healthchain/fhir/helpers.py +++ b/healthchain/fhir/helpers.py @@ -157,7 +157,7 @@ def create_condition( Args: subject: REQUIRED. Reference to the patient (e.g. "Patient/123") - status: REQUIRED. Clinical status (default: active) + clinical_status: REQUIRED. Clinical status (default: active) code: The condition code display: The display name for the condition system: The code system (default: SNOMED CT) @@ -197,7 +197,7 @@ def create_medication_statement( https://build.fhir.org/medicationstatement.html Args: - subject_reference: REQUIRED. Reference to the patient (e.g. "Patient/123") + subject: REQUIRED. Reference to the patient (e.g. "Patient/123") status: REQUIRED. Status of the medication (default: recorded) code: The medication code display: The display name for the medication @@ -310,8 +310,7 @@ def read_content_attachment( Args: document_reference: The FHIR DocumentReference resource - include_data: Whether to include the data of the attachments. - If true, the data will be also be decoded (default: True) + include_data: Whether to include the data of the attachments. If true, the data will be also be decoded (default: True) Returns: Optional[List[Dict[str, Any]]]: List of dictionaries containing attachment data and metadata, diff --git a/healthchain/io/cdaconnector.py b/healthchain/io/cdaconnector.py index 761cc67a..60f60e3d 100644 --- a/healthchain/io/cdaconnector.py +++ b/healthchain/io/cdaconnector.py @@ -96,7 +96,7 @@ def input(self, cda_request: CdaRequest) -> Document: doc = Document(data=note_text) # Create FHIR Bundle and add documents - doc.fhir.set_bundle(create_bundle()) + doc.fhir.bundle = create_bundle() doc.fhir.add_document_reference(cda_document_reference) doc.fhir.add_document_reference( note_document_reference, parent_id=cda_document_reference.id diff --git a/healthchain/io/cdsfhirconnector.py b/healthchain/io/cdsfhirconnector.py index 7195137d..62384d0e 100644 --- a/healthchain/io/cdsfhirconnector.py +++ b/healthchain/io/cdsfhirconnector.py @@ -73,7 +73,7 @@ def input( validated_prefetch = Prefetch(prefetch=cds_request.prefetch) # Set the prefetch resources - doc.fhir.set_prefetch_resources(validated_prefetch.prefetch) + doc.fhir.prefetch_resources = validated_prefetch.prefetch # Extract text content from DocumentReference resource if provided document_resource = validated_prefetch.prefetch.get(prefetch_document_key) diff --git a/healthchain/io/containers/document.py b/healthchain/io/containers/document.py index 117169b9..2161b5b9 100644 --- a/healthchain/io/containers/document.py +++ b/healthchain/io/containers/document.py @@ -213,28 +213,51 @@ class FhirData: such as a problem list, medication list, and allergy list. These collections are accessible as properties of the class instance. - Attributes: - _prefetch_resources (Optional[Dict[str, Resource]]): Resources specifically requested by CDS services - _bundle (Optional[Bundle]): Working bundle containing all other clinical resources - Properties: - problem_list: List[Condition] - medication_list: List[MedicationStatement] - allergy_list: List[AllergyIntolerance] + bundle: The FHIR bundle containing resources + prefetch_resources: Dictionary of CDS Hooks prefetch resources + problem_list: List of Condition resources + medication_list: List of MedicationStatement resources + allergy_list: List of AllergyIntolerance resources Example: - >>> fhir_data = FhirData() - >>> # Add a document - >>> doc_id = fhir_data.add_document(document_ref) + >>> fhir = FhirData() + >>> # Add prefetch resources from CDS request + >>> fhir.prefetch_resources = {"patient": patient_resource} + >>> # Add document to bundle + >>> doc_id = fhir.add_document_reference(document) >>> # Get document with relationships - >>> doc_family = fhir_data.get_document_family(doc_id) - >>> # Get all documents with content - >>> documents = fhir_data.get_documents(include_data=True) + >>> doc_family = fhir.get_document_reference_family(doc_id) + >>> # Access clinical lists + >>> conditions = fhir.problem_list """ _prefetch_resources: Optional[Dict[str, Resource]] = None _bundle: Optional[Bundle] = None + @property + def bundle(self) -> Optional[Bundle]: + """Returns the FHIR Bundle if it exists.""" + return self._bundle + + @bundle.setter + def bundle(self, bundle: Bundle): + """Sets the FHIR Bundle. + The bundle is a collection of FHIR resources. + See: https://www.hl7.org/fhir/bundle.html + """ + self._bundle = bundle + + @property + def prefetch_resources(self) -> Optional[Dict[str, Resource]]: + """Returns the prefetch FHIR resources.""" + return self._prefetch_resources + + @prefetch_resources.setter + def prefetch_resources(self, resources: Dict[str, Resource]): + """Sets the prefetch FHIR resources from CDS service requests.""" + self._prefetch_resources = resources + @property def problem_list(self) -> List[Condition]: """Get problem list from the bundle. @@ -274,27 +297,12 @@ def allergy_list(self, allergies: List[AllergyIntolerance]) -> None: """ self.add_resources(allergies, "AllergyIntolerance") - def get_bundle(self) -> Optional[Bundle]: - """Returns the FHIR Bundle if it exists.""" - return self._bundle - - def set_bundle(self, bundle: Bundle): - """Sets the FHIR Bundle. - The bundle is a collection of FHIR resources. - See: https://www.hl7.org/fhir/bundle.html - """ - self._bundle = bundle - def get_prefetch_resources(self, key: str) -> List[Any]: """Get resources of a specific type from the prefetch bundle.""" if not self._prefetch_resources: return [] return self._prefetch_resources.get(key, []) - def set_prefetch_resources(self, prefetch_resources: Dict[str, Resource]): - """Sets the prefetch FHIR resources from CDS service requests.""" - self._prefetch_resources = prefetch_resources - def get_resources(self, resource_type: str) -> List[Any]: """Get resources of a specific type from the working bundle.""" if not self._bundle: diff --git a/healthchain/pipeline/components/base.py b/healthchain/pipeline/components/base.py index 3cb44d98..b7f3948f 100644 --- a/healthchain/pipeline/components/base.py +++ b/healthchain/pipeline/components/base.py @@ -12,9 +12,6 @@ class BaseComponent(Generic[T], ABC): This class should be subclassed to create specific components. Subclasses must implement the __call__ method. - - Attributes: - None """ @abstractmethod diff --git a/healthchain/pipeline/components/integrations.py b/healthchain/pipeline/components/integrations.py index a598cbc0..9e0c8c3d 100644 --- a/healthchain/pipeline/components/integrations.py +++ b/healthchain/pipeline/components/integrations.py @@ -103,41 +103,9 @@ def from_model_id(cls, model: str, **kwargs: Any) -> "SpacyNLP": return cls(nlp) - # def _add_concepts_to_hc_doc(self, spacy_doc: SpacyDoc, hc_doc: Document): - # """ - # Extract entities from spaCy Doc and add them to the HealthChain Document concepts. - - # Args: - # spacy_doc (Doc): The processed spaCy Doc object containing entities - # hc_doc (Document): The HealthChain Document to store concepts in - - # Note: Defaults to Condition and SNOMED CT concepts - # # TODO: make configurable - # """ - # concepts = [] - # # TODO: Review this, too specific to MedCAT, coding system needs to be configurable - # for ent in spacy_doc.ents: - # print(ent._.cui) - # if not ent._.cui: - # log.warning(f"No CUI found for entity {ent.text}") - # continue - # condition = create_condition( - # subject="Patient/123", - # code=ent._.cui, - # display=ent.text, - # system="http://snomed.info/sct", - # ) - - # print("adding condition", condition.model_dump()) - # concepts.append(condition) - - # # Add to document concepts - # hc_doc.fhir.problem_list = concepts - def __call__(self, doc: Document) -> Document: """Process the document using the spaCy pipeline. Adds outputs to nlp.spacy_docs.""" spacy_doc = self._nlp(doc.data) - # self._add_concepts_to_hc_doc(spacy_doc, doc) doc.nlp.add_spacy_doc(spacy_doc) return doc diff --git a/mkdocs.yml b/mkdocs.yml index e10a5498..7ff95e48 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -57,8 +57,7 @@ nav: - api/clients.md - api/cda_parser.md - api/data_generators.md - - api/data_models.md - + - api/fhir_helpers.md - Community: - community/index.md - Contribution Guide: community/contribution_guide.md diff --git a/tests/conftest.py b/tests/conftest.py index e00cf0eb..82066370 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -151,7 +151,7 @@ def doc_ref_without_content(): def test_document(): """Create a test document with FHIR resources.""" doc = Document(data="Test note") - doc.fhir.set_bundle(create_bundle()) + doc.fhir.bundle = create_bundle() # Add test FHIR resources problem_list = create_condition( diff --git a/tests/containers/test_fhir_data.py b/tests/containers/test_fhir_data.py index 948845a5..fe991dde 100644 --- a/tests/containers/test_fhir_data.py +++ b/tests/containers/test_fhir_data.py @@ -3,10 +3,10 @@ def test_bundle_operations(fhir_data, sample_bundle): """Test basic bundle operations.""" - assert fhir_data.get_bundle() is None + assert fhir_data.bundle is None - fhir_data.set_bundle(sample_bundle) - assert fhir_data.get_bundle() == sample_bundle + fhir_data.bundle = sample_bundle + assert fhir_data.bundle == sample_bundle def test_resource_operations(fhir_data): diff --git a/tests/pipeline/conftest.py b/tests/pipeline/conftest.py index b532316f..bbe72cbb 100644 --- a/tests/pipeline/conftest.py +++ b/tests/pipeline/conftest.py @@ -102,7 +102,7 @@ def mock_cds_fhir_connector(test_condition): # Mock the input method fhir_data = FhirData() - fhir_data.set_prefetch_resources({"problem": test_condition}) + fhir_data.prefetch_resources = {"problem": test_condition} connector_instance.input.return_value = Document( data="Original FHIR data",