+ Overview:
+
+ Formatting of this note might be different from the original.
+
+Bad Chest XR
+Not responding to Antibiotics
+
+
+
+
+
Hypertension
+
17/05/2019
+
+
+
+
+
+
+
+
+
+
+
+
Resolved Problems
+
Noted Date
+
Resolved Date
+
+
+
+
+
Gallstone
+
16/02/2022
+
16/02/2022
+
+
+
+ documented as of this encounter (statuses as of 29/11/2022)
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Transition
+ Snomed
+
+
+
+
+
+
+
+
+ UCLH - University College London Hospitals - OLDTST
+
+ NW1 2BU
+ England
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Intensive Care Medicine
+
+
+
+
+
+
+
+ Terry
+ Segal
+
+
+
+
+
+
+
+
+ UCLH - University College London Hospitals - OLDTST
+
+ NW1 2BU
+ England
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Haematology
+
+
+
+
+
+
+
+ Georgina
+ Dean
+
+
+
+
+
+
+
+
+ UCLH - University College London Hospitals - OLDTST
+
+ NW1 2BU
+ England
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Haematology
+
+
+
+
+
+
+
+ Georgina
+ Dean
+
+
+
+
+
+
+
+
+ UCLH - University College London Hospitals - OLDTST
+
+ NW1 2BU
+ England
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Progress Notes
+
+ Summary: Test note
+
+
+ Annotation test
+
+This 37 year old gentleman presented with a fever, cough and sore throat. He was diagnosed with a community acquired pneumonia, and started on co-amoxiclav. Unfortunately he developed anaphylaxis and was treated with resuscitation fluids and adrenaline but not hydrocortisone. He had refractory anaphylaxis and so was transferred to intensive care for intubation and ventilation. He then developed a "ventilation associated pneumonia", requiring meropenem. Once treated for his CAP and VAP he was stepped down to the ward. He was treated with haloperidol for a presumed 'delirium'. He improved medically, but deteriorated in terms of his psychiatric health, with depression, anxiety and paranoid schizophrenia. He was self-medicating with his own supply of Librium.
+
+He has a past medical history of asthma and COPD but not cirrhosis.
+
+He regularly takes penicillin, Ventolin and tiotropium inhalers, as well as an ACE inhibitor, beta blocker and calcium channel blocker.
+
+He is allergic to all opiates, including morphine.
+
+He has previously had a cholecystectomy and appendicectomy.
+
+Plan:
+- Discharge planning
+
+I reviewed the Resident's note and agree with the documented findings and plan of care. The reason the patient is critically ill and the nature of the treatment and management provided by the teaching physician (me) to manage the critically ill patient is: as aove
+The patient was critically ill during the time that I saw the patient. The Critical Care Time excluding procedures was 6 minutes.
+
+Electronically signed by Georgina DEAN at 29/11/2022 09:50 GMT
+
+
+
+
+
+
+
diff --git a/cookbook/multi_ehr_data_aggregation.py b/cookbook/multi_ehr_data_aggregation.py
new file mode 100644
index 00000000..fc6bf272
--- /dev/null
+++ b/cookbook/multi_ehr_data_aggregation.py
@@ -0,0 +1,114 @@
+#!/usr/bin/env python3
+"""
+Multi-Source FHIR Data Aggregation
+
+Demonstrates aggregating patient data from multiple FHIR sources with
+simple pipeline processing and provenance tracking.
+
+Requirements:
+- pip install healthchain python-dotenv
+
+Run:
+- python data_aggregation.py
+"""
+
+from typing import List
+
+from dotenv import load_dotenv
+
+from fhir.resources.bundle import Bundle
+from fhir.resources.condition import Condition
+from fhir.resources.annotation import Annotation
+
+from healthchain.gateway import FHIRGateway, HealthChainAPI
+from healthchain.gateway.clients.fhir.base import FHIRAuthConfig
+from healthchain.pipeline import Pipeline
+from healthchain.io.containers import Document
+from healthchain.fhir import merge_bundles
+
+
+load_dotenv()
+
+
+# Epic FHIR Sandbox - configure via environment, then build connection string
+config = FHIRAuthConfig.from_env("EPIC")
+EPIC_URL = config.to_connection_string()
+
+# Cerner Open Sandbox
+CERNER_URL = "fhir://fhir-open.cerner.com/r4/ec2458f2-1e24-41c8-b71b-0e701af7583d"
+
+
+def create_pipeline() -> Pipeline[Document]:
+ """Build simple pipeline for demo purposes."""
+ pipeline = Pipeline[Document]()
+
+ @pipeline.add_node
+ def deduplicate(doc: Document) -> Document:
+ """Remove duplicate conditions by resource ID."""
+ conditions = doc.fhir.get_resources("Condition")
+ unique = list({c.id: c for c in conditions if c.id}.values())
+ doc.fhir.add_resources(unique, "Condition", replace=True)
+ print(f"Deduplicated {len(unique)} conditions")
+ return doc
+
+ @pipeline.add_node
+ def add_annotation(doc: Document) -> Document:
+ """Add a note to each Condition indicating pipeline processing."""
+ conditions = doc.fhir.get_resources("Condition")
+ for condition in conditions:
+ note_text = "This resource has been processed by healthchain pipeline"
+ annotation = Annotation(text=note_text)
+ condition.note = (condition.note or []) + [annotation]
+ print(f"Added annotation to {len(conditions)} conditions")
+ return doc
+
+ return pipeline
+
+
+def create_app():
+ # Initialize gateway and add sources
+ gateway = FHIRGateway()
+ gateway.add_source("epic", EPIC_URL)
+ gateway.add_source("cerner", CERNER_URL)
+
+ pipeline = create_pipeline()
+
+ @gateway.aggregate(Condition)
+ def get_unified_patient(patient_id: str, sources: List[str]) -> Bundle:
+ """Aggregate conditions for a patient from multiple sources"""
+ bundles = []
+ for source in sources:
+ try:
+ bundle = gateway.search(
+ Condition,
+ {"patient": patient_id},
+ source,
+ add_provenance=True,
+ provenance_tag="aggregated",
+ )
+ bundles.append(bundle)
+ except Exception as e:
+ print(f"Error from {source}: {e}")
+
+ # Merge bundles - OperationOutcome resources are automatically extracted
+ merged_bundle = merge_bundles(bundles, deduplicate=True)
+
+ doc = Document(data=merged_bundle)
+ doc = pipeline(doc)
+
+ # print([outcome.model_dump() for outcome in doc.fhir.operation_outcomes])
+
+ return doc.fhir.bundle.model_dump()
+
+ app = HealthChainAPI()
+ app.register_gateway(gateway)
+
+ return app
+
+
+if __name__ == "__main__":
+ import uvicorn
+
+ app = create_app()
+ uvicorn.run(app, port=8888)
+ # Runs at: http://127.0.0.1:8888/
diff --git a/cookbook/notereader_clinical_coding_fhir.py b/cookbook/notereader_clinical_coding_fhir.py
index 45af7f0a..638a55a4 100644
--- a/cookbook/notereader_clinical_coding_fhir.py
+++ b/cookbook/notereader_clinical_coding_fhir.py
@@ -13,19 +13,17 @@
- python notereader_clinical_coding_fhir.py # Demo and start server
"""
-import os
import uvicorn
-from datetime import datetime, timezone
-
import healthchain as hc
+
from fhir.resources.documentreference import DocumentReference
-from fhir.resources.meta import Meta
from spacy.tokens import Span
from dotenv import load_dotenv
-from healthchain.fhir import create_document_reference
+from healthchain.fhir import create_document_reference, add_provenance_metadata
from healthchain.gateway.api import HealthChainAPI
from healthchain.gateway.fhir import FHIRGateway
+from healthchain.gateway.clients.fhir.base import FHIRAuthConfig
from healthchain.gateway.soap import NoteReaderService
from healthchain.io import CdaAdapter, Document
from healthchain.models import CdaRequest
@@ -35,14 +33,9 @@
load_dotenv()
-
-BILLING_URL = (
- f"fhir://api.medplum.com/fhir/R4/"
- f"?client_id={os.environ.get('MEDPLUM_CLIENT_ID')}"
- f"&client_secret={os.environ.get('MEDPLUM_CLIENT_SECRET')}"
- f"&token_url={os.environ.get('MEDPLUM_TOKEN_URL', 'https://api.medplum.com/oauth2/token')}"
- f"&scope={os.environ.get('MEDPLUM_SCOPE', 'openid')}"
-)
+# Load configuration from environment variables
+config = FHIRAuthConfig.from_env("MEDPLUM")
+BILLING_URL = config.to_connection_string()
def create_pipeline():
@@ -84,7 +77,6 @@ def link_entities(doc: Document) -> Document:
def create_app():
- """Create production healthcare API."""
pipeline = create_pipeline()
cda_adapter = CdaAdapter()
@@ -102,9 +94,8 @@ def ai_coding_workflow(request: CdaRequest):
for condition in doc.fhir.problem_list:
# Add basic provenance tracking
- condition.meta = Meta(
- source="urn:healthchain:pipeline:cdi",
- lastUpdated=datetime.now(timezone.utc).isoformat(),
+ condition = add_provenance_metadata(
+ condition, source="epic-notereader", tag_code="cdi"
)
fhir_gateway.create(condition, source="billing")
@@ -127,7 +118,7 @@ class NotereaderSandbox(ClinicalDocumentation):
def __init__(self):
super().__init__()
- self.data_path = "./resources/uclh_cda.xml"
+ self.data_path = "./data/notereader_cda.xml"
@hc.ehr(workflow="sign-note-inpatient")
def load_clinical_document(self) -> DocumentReference:
diff --git a/docs/assets/images/epicsandbox1.png b/docs/assets/images/epicsandbox1.png
new file mode 100644
index 00000000..66ea25f6
Binary files /dev/null and b/docs/assets/images/epicsandbox1.png differ
diff --git a/docs/assets/images/epicsandbox2.png b/docs/assets/images/epicsandbox2.png
new file mode 100644
index 00000000..ea7443f5
Binary files /dev/null and b/docs/assets/images/epicsandbox2.png differ
diff --git a/docs/assets/images/epicsandbox3.png b/docs/assets/images/epicsandbox3.png
new file mode 100644
index 00000000..c99face9
Binary files /dev/null and b/docs/assets/images/epicsandbox3.png differ
diff --git a/docs/assets/images/epicsandbox4.png b/docs/assets/images/epicsandbox4.png
new file mode 100644
index 00000000..6f009c63
Binary files /dev/null and b/docs/assets/images/epicsandbox4.png differ
diff --git a/docs/assets/images/epicsandboxlogin.png b/docs/assets/images/epicsandboxlogin.png
new file mode 100644
index 00000000..e032a190
Binary files /dev/null and b/docs/assets/images/epicsandboxlogin.png differ
diff --git a/docs/cookbook/clinical_coding.md b/docs/cookbook/clinical_coding.md
index 36537a91..e55a8963 100644
--- a/docs/cookbook/clinical_coding.md
+++ b/docs/cookbook/clinical_coding.md
@@ -1,6 +1,6 @@
# Build a NoteReader Service with FHIR Integration
-This tutorial shows you how to build a clinical coding service that connects legacy [CDA](https://hl7.org/cda/) systems with modern [FHIR servers](https://build.fhir.org/http.html). We'll process clinical notes, extract billing codes, and handle both old and new healthcare data formats. We'll use [Epic NoteReader](https://discovery.hgdata.com/product/epic-notereader-cdi) as the legacy system and [Medplum](https://www.medplum.com/) as the FHIR server.
+This example shows you how to build a clinical coding service that connects legacy [CDA](https://hl7.org/cda/) systems with modern [FHIR servers](https://build.fhir.org/http.html). We'll process clinical notes, extract billing codes, and handle both old and new healthcare data formats. We'll use [Epic NoteReader](https://discovery.hgdata.com/product/epic-notereader-cdi) as the legacy system and [Medplum](https://www.medplum.com/) as the FHIR server.
Check out the full working example [here](https://github.com/dotimplement/HealthChain/tree/main/cookbook/notereader_clinical_coding_fhir.py)!
@@ -13,71 +13,71 @@ pip install healthchain scispacy python-dotenv
pip install https://s3-us-west-2.amazonaws.com/ai2-s2-scispacy/releases/v0.5.4/en_core_sci_sm-0.5.4.tar.gz
```
-If you'd like to test the FHIR integration with Medplum, make sure you have the following environment variables set. To setup Medplum, register an account on [Medplum](https://www.medplum.com/docs/tutorials/register) and obtain your [Client Credentials](https://www.medplum.com/docs/auth/methods/client-credentials).
+To test the FHIR integration with Medplum, you'll need to set up a Medplum account and obtain client credentials. See the [FHIR Sandbox Setup Guide](./setup_fhir_sandboxes.md#medplum) for detailed instructions.
-
+Once you have your Medplum credentials, configure them in a `.env` file:
```bash
# .env file
+MEDPLUM_BASE_URL=https://api.medplum.com/fhir/R4
MEDPLUM_CLIENT_ID=your_client_id
MEDPLUM_CLIENT_SECRET=your_client_secret
+MEDPLUM_TOKEN_URL=https://api.medplum.com/oauth2/token
+MEDPLUM_SCOPE=openid
```
-## Initialize the pipeline
+## Initialize the Pipeline
-First, we'll create a [medical coding pipeline](../reference/pipeline/pipeline.md) with a custom entity linking node for extracting conditions from clinical text.
-
-The example below just uses a dictionary lookup of medical concepts to a [SNOMED CT](https://www.snomed.org/) code for demo purposes, but you can obviously do more fancy stuff with it if you want.
+First, we'll build a [medical coding pipeline](../reference/pipeline/pipeline.md) with a custom entity linking node that maps extracted entities (e.g., "chronic kidney disease") to standard codes (e.g., SNOMED CT). For this demo, we'll use a simple dictionary for SNOMED CT mapping.
```python
from healthchain.pipeline.medicalcodingpipeline import MedicalCodingPipeline
from healthchain.io import Document
from spacy.tokens import Span
-def create_pipeline():
- """Build FHIR-native ML pipeline with automatic problem extraction."""
- pipeline = MedicalCodingPipeline.from_model_id("en_core_sci_sm", source="spacy")
-
- # Add custom entity linking
- @pipeline.add_node(position="after", reference="SpacyNLP")
- def link_entities(doc: Document) -> Document:
- """Add CUI codes to medical entities for problem extraction"""
- if not Span.has_extension("cui"):
- Span.set_extension("cui", default=None)
-
- spacy_doc = doc.nlp.get_spacy_doc()
-
- # Dummy medical concept mapping to SNOMED CT codes
- medical_concepts = {
- "pneumonia": "233604007",
- "type 2 diabetes mellitus": "44054006",
- "congestive heart failure": "42343007",
- "chronic kidney disease": "431855005",
- "hypertension": "38341003",
- # Add more mappings as needed
- }
-
- for ent in spacy_doc.ents:
- if ent.text.lower() in medical_concepts:
- ent._.cui = medical_concepts[ent.text.lower()]
-
- return doc
-
- return pipeline
+# Build FHIR-native ML pipeline with automatic problem extraction.
+pipeline = MedicalCodingPipeline.from_model_id("en_core_sci_sm", source="spacy")
+
+# Add custom entity linking
+@pipeline.add_node(position="after", reference="SpacyNLP")
+def link_entities(doc: Document) -> Document:
+ """
+ Add CUI codes to medical entities for problem extraction.
+ """
+ if not Span.has_extension("cui"):
+ Span.set_extension("cui", default=None)
+
+ spacy_doc = doc.nlp.get_spacy_doc()
+
+ # Dummy medical concept mapping to SNOMED CT codes
+ medical_concepts = {
+ "pneumonia": "233604007",
+ "type 2 diabetes mellitus": "44054006",
+ "congestive heart failure": "42343007",
+ "chronic kidney disease": "431855005",
+ "hypertension": "38341003",
+ # Add more mappings as needed
+ }
+
+ for ent in spacy_doc.ents:
+ if ent.text.lower() in medical_concepts:
+ ent._.cui = medical_concepts[ent.text.lower()]
+
+ return doc
```
The `MedicalCodingPipeline` automatically:
- Extracts medical entities using the `scispacy` model
- Converts entities to FHIR [Condition](https://www.hl7.org/fhir/condition.html) resources
-- Populates the Document's `fhir.problem_list` for downstream processing
+- Automatically populates the Document's `fhir.problem_list` for downstream processing
It is equivalent to building a pipeline with the following components:
```python
from healthchain.pipeline import Pipeline
from healthchain.pipeline.components import SpacyNLP, FHIRProblemListExtractor
-from healthchain.io.containers import Document
+from healthchain.io import Document
pipeline = Pipeline[Document]()
@@ -87,7 +87,8 @@ pipeline.add_node(FHIRProblemListExtractor())
## Add the CDA Adapter
-The [CdaAdapter](../reference/pipeline/adapters/cdaadapter.md) converts CDA documents to HealthChain's [Document](../reference/pipeline/data_container.md) format using an instance of the [InteropEngine](../reference/interop/engine.md). This lets you work with legacy clinical documents without having to leave FHIR.
+The [CdaAdapter](../reference/pipeline/adapters/cdaadapter.md) parses CDA XML into a [Document](../reference/pipeline/data_container.md), extracts both clinical text and coded data (e.g., conditions), and enables round-trip conversion between CDA, FHIR, and Document formats using the [InteropEngine](../reference/interop/engine.md) for seamless legacy-to-modern data integration.
+
```python
from healthchain.io import CdaAdapter
@@ -107,33 +108,27 @@ doc.fhir.problem_list
response = cda_adapter.format(doc)
```
-What it does:
+!!! info "What this adapter does"
-- Parses CDA XML documents
-- Extracts clinical text and coded data from the CDA document
-- Stores the text data in `doc.text`
-- Stores the CDA XML as a [DocumentReference](https://www.hl7.org/fhir/documentreference.html) resource in `doc.fhir.bundle`
-- Stores the extracted [Condition](https://www.hl7.org/fhir/condition.html) resources from the CDA document in `doc.fhir.problem_list`
+ - Parses CDA XML documents and extracts clinical text and coded data
+ - Stores text data in `doc.text`
+ - Stores CDA XML as a [DocumentReference](https://www.hl7.org/fhir/documentreference.html) resource in `doc.fhir.bundle`
+ - Stores extracted [Condition](https://www.hl7.org/fhir/condition.html) resources in `doc.fhir.problem_list`
## Set up FHIR Gateway
-[FHIR gateways](../reference/gateway/fhir_gateway.md) connect to external FHIR servers. You can add multiple FHIR sources to the gateway via connection strings with the `add_source` method. The gateway will handle authentication and connections for you.
+[FHIR gateways](../reference/gateway/fhir_gateway.md) enable your app to connect to one or more external FHIR servers (like EHRs, registries, billing systems). Use `add_source` to register each FHIR endpoint with its connection string; the gateway manages authentication, routing, and merging data across sources, allowing unified access to patient data.
```python
-import os
-from healthchain.gateway.fhir import FHIRGateway
+from healthchain.gateway import FHIRGateway
+from healthchain.gateway.clients.fhir.base import FHIRAuthConfig
from dotenv import load_dotenv
load_dotenv()
-# Configure FHIR connection with OAuth2 authentication
-BILLING_URL = (
- f"fhir://api.medplum.com/fhir/R4/"
- f"?client_id={os.environ.get('MEDPLUM_CLIENT_ID')}"
- f"&client_secret={os.environ.get('MEDPLUM_CLIENT_SECRET')}"
- f"&token_url=https://api.medplum.com/oauth2/token"
- f"&scope=openid"
-)
+# Load configuration from environment variables
+config = FHIRAuthConfig.from_env("MEDPLUM")
+BILLING_URL = config.to_connection_string()
# Initialize FHIR gateway and register external systems
fhir_gateway = FHIRGateway()
@@ -144,16 +139,16 @@ fhir_gateway.add_source("billing", BILLING_URL)
# fhir_gateway.add_source("registry", "fhir://registry.example.com/fhir/R4/")
```
-## Set up the NoteReader Service
+## Set Up the NoteReader Service
-Now let's set up the [NoteReader Service](../reference/gateway/soap_cda.md). This is an Epic specific module that allows third party services to interact with Epic's CDI service via CDA. It's somewhat niche but it be like that sometimes.
+Now let's set up the handler for [NoteReader Service](../reference/gateway/soap_cda.md).
-The good thing about NoteReader is that it's already integrated in existing EHR workflows. The bad thing is it's legacy stuff and relatively rigid.
+!!! note "About NoteReader"
-We can make it more exciting by routing the extracted conditions to a FHIR server inside the NoteReader service method, so you can do other cool modern stuff to it.
+ Epic NoteReader is a legacy SOAP/CDA interface for clinical documentation improvement (CDI) workflows. While it's rigid and dated, it's already integrated into existing EHR workflows. We can modernize it by routing extracted conditions to FHIR servers for additional processing.
```python
-from healthchain.gateway.soap import NoteReaderService
+from healthchain.gateway import NoteReaderService
# Create the NoteReader service
note_service = NoteReaderService()
@@ -169,9 +164,8 @@ def ai_coding_workflow(request: CdaRequest):
# Access the extracted FHIR resources
for condition in doc.fhir.problem_list:
# Add metadata for audit and provenance tracking
- condition.meta = Meta(
- source="urn:healthchain:pipeline:cdi",
- lastUpdated=datetime.now(timezone.utc).isoformat(),
+ condition = add_provenance_metadata(
+ condition, source="epic-notereader", tag_code="cdi"
)
# Send to external FHIR server via gateway
fhir_gateway.create(condition, source="billing")
@@ -182,12 +176,12 @@ def ai_coding_workflow(request: CdaRequest):
return cda_response
```
-## Build the service
+## Build the Service
Time to put it all together! Using [HealthChainAPI](../reference/gateway/api.md), we can create a service with both FHIR and NoteReader endpoints.
```python
-from healthchain.gateway.api import HealthChainAPI
+from healthchain.gateway import HealthChainAPI
# Register services with the API gateway
app = HealthChainAPI(title="Healthcare Integration Gateway")
@@ -196,14 +190,20 @@ app.register_gateway(fhir_gateway, path="/fhir")
app.register_service(note_service, path="/notereader")
```
-## Test with sample documents
+## Test with Sample Documents
+
+Use the [sandbox utility](../reference/utilities/sandbox.md) to test with sample clinical documents:
-You can test the service with sample clinical documents using the [sandbox utility](../reference/utilities/sandbox.md) so you don't have to go out there and source a real EHR for our neat little demo. Woohoo.
+!!! note "Download Sample Data"
+
+ Download sample CDA files from [cookbook/data](https://github.com/dotimplement/HealthChain/tree/main/cookbook/data) and place them in a `data/` folder in your project root.
```python
import healthchain as hc
+
from healthchain.sandbox.use_cases import ClinicalDocumentation
from healthchain.fhir import create_document_reference
+
from fhir.resources.documentreference import DocumentReference
def create_sandbox():
@@ -212,7 +212,7 @@ def create_sandbox():
"""Sandbox for testing clinical documentation workflows"""
def __init__(self):
super().__init__()
- self.data_path = "./resources/uclh_cda.xml"
+ self.data_path = "./data/notereader_cda.xml"
@hc.ehr(workflow="sign-note-inpatient")
def load_clinical_document(self) -> DocumentReference:
@@ -229,7 +229,7 @@ def create_sandbox():
return NotereaderSandbox()
```
-## Run the complete example
+## Run the Complete Example
Run `HealthChainAPI` with `uvicorn` and start the sandbox to test the service.
@@ -242,31 +242,30 @@ sandbox = create_sandbox()
sandbox.start_sandbox()
```
-## What happens when you run this
+## What You've Built
+
+A clinical coding service that bridges legacy CDA systems with modern FHIR infrastructure:
+
+- **Legacy system integration** - Processes CDA documents from Epic NoteReader workflows
+- **AI-powered extraction** - Uses NLP to extract medical entities and map to SNOMED CT codes
+- **FHIR interoperability** - Converts extracted conditions to FHIR resources and syncs with external servers
+- **Audit trail** - Tracks provenance metadata for compliance and debugging
+- **Dual interface** - Maintains CDA compatibility while enabling modern FHIR operations
-Here's the workflow:
+!!! info "Use Cases"
-=== "1. Server Startup"
- - **URL:** `http://localhost:8000/`
- - **Endpoints:**
- - FHIR API: `/fhir/*`
- - SOAP API: `/notereader`
- - Documentation: `/docs`
+ - **Clinical Documentation Improvement (CDI)**:
+ Automatically extract billable conditions from clinical notes and populate problem lists in real-time during clinician workflows.
-=== "2. Sandbox"
- - Loads a sample CDA document from the `resources/` directory.
- - Sends a SOAP request to the notereader service at `http://localhost:8000/notereader/ProcessDocument`.
- - Receives the processed CDA document in response.
- - Saves both the original and processed CDA documents to the `output/` directory.
+ - **Terminology Harmonization**:
+ Bridge legacy ICD-9 systems with modern SNOMED CT standards by processing historical CDA documents and creating FHIR-compliant problem lists.
-=== "3. Pipeline"
- - Converts the CDA document to FHIR resources.
- - Processes the text data from the CDA document using the pipeline.
- - Creates FHIR `Condition` resources from extracted conditions.
- - Converts the new FHIR resources back into an updated CDA document.
+ - **Research Data Extraction**:
+ Extract structured condition data from unstructured clinical notes for cohort building and retrospective studies.
-=== "4. Output"
- - Stores the `Condition` resources in the Medplum FHIR server.
- - Returns the processed CDA document to the sandbox/EHR workflow.
+!!! tip "Next Steps"
-That's it! You can now test the service with sample documents and see the FHIR resources being created in Medplum. ๐
+ - **Enhance entity linking**: Replace the dictionary lookup with terminology servers or entity linking models for comprehensive medical terminology coverage.
+ - **Add validation**: Implement FHIR resource validation before sending to external servers.
+ - **Expand to other workflows**: Adapt the pattern for lab results, medications, or radiology reports.
+ - **Build on it**: Use the extracted conditions in the [Data Aggregation example](./multi_ehr_aggregation.md) to combine with other FHIR sources.
diff --git a/docs/cookbook/discharge_summarizer.md b/docs/cookbook/discharge_summarizer.md
index 22ab33b6..94bb2d19 100644
--- a/docs/cookbook/discharge_summarizer.md
+++ b/docs/cookbook/discharge_summarizer.md
@@ -1,11 +1,17 @@
# Build a CDS Hooks Service for Discharge Summarization
-This tutorial shows you how to build a CDS service that integrates with EHR systems. We'll automatically summarize discharge notes and return actionable recommendations using the [CDS Hooks standard](https://cds-hooks.org/).
+This example shows you how to build a CDS service that integrates with EHR systems. We'll automatically summarize discharge notes and return actionable recommendations using the [CDS Hooks standard](https://cds-hooks.org/).
Check out the full working example [here](https://github.com/dotimplement/HealthChain/tree/main/cookbook/cds_discharge_summarizer_hf_chat.py)!
+ *Illustrative Architecture - actual implementation may vary.*
+
## Setup
+```bash
+pip install healthchain
+```
+
Make sure you have a [Hugging Face API token](https://huggingface.co/docs/hub/security-tokens) and set it as the `HUGGINGFACEHUB_API_TOKEN` environment variable.
```python
@@ -97,111 +103,106 @@ cds_adapter.parse(request)
cds_adapter.format(doc)
```
-What it does:
+!!! info "What this adapter does"
-- Parses FHIR resources from CDS requests
-- Extracts text from [DocumentReference](https://www.hl7.org/fhir/documentreference.html) resources
-- Formats responses as CDS cards
+ - Parses FHIR resources from CDS Hooks requests
+ - Extracts text from [DocumentReference](https://www.hl7.org/fhir/documentreference.html) resources
+ - Formats responses as CDS cards according to the CDS Hooks specification
-## Set up the CDS service
+## Set Up the CDS Hook Handler
-Now let's create the CDS service. [HealthChainAPI](../reference/gateway/api.md) gives you discovery endpoints, validation, and docs automatically.
+Create the [CDS Hooks handler](../reference/gateway/cdshooks.md) to receive discharge note requests, run the AI summarization pipeline, and return results as CDS cards.
```python
-from healthchain.gateway import HealthChainAPI, CDSHooksService
+from healthchain.gateway import CDSHooksService
from healthchain.models import CDSRequest, CDSResponse
-from healthchain.io import CdsFhirAdapter
-def create_pipeline():
- """Build the discharge summarization pipeline"""
- # Configure your pipeline (using previous examples)
- return pipeline
+# Initialize the CDS service
+cds_service = CDSHooksService()
-def create_app():
- """Create the CDS Hooks application"""
- pipeline = create_pipeline()
- adapter = CdsFhirAdapter()
+# Define the CDS service function
+@cds_service.hook("encounter-discharge", id="discharge-summary")
+def handle_discharge_summary(request: CDSRequest) -> CDSResponse:
+ """Process discharge summaries with AI"""
+ # Parse CDS request to internal Document format
+ doc = cds_adapter.parse(request)
- # Initialize the CDS service
- cds_service = CDSHooksService()
+ # Process through AI pipeline
+ processed_doc = pipeline(doc)
- # Define the CDS service function
- @cds_service.hook("encounter-discharge", id="discharge-summary")
- def handle_discharge_summary(request: CDSRequest) -> CDSResponse:
- """Process discharge summaries with AI"""
- # Parse CDS request to internal Document format
- doc = adapter.parse(request)
+ # Format response with CDS cards
+ response = cds_adapter.format(processed_doc)
+ return response
+```
- # Process through AI pipeline
- processed_doc = pipeline(doc)
+## Build the Service
- # Format response with CDS cards
- response = adapter.format(processed_doc)
- return response
+Register the CDS service with [HealthChainAPI](../reference/gateway/api.md) to create REST endpoints:
- # Register the service with the API gateway
- app = HealthChainAPI(title="Discharge Summary CDS Service")
- app.register_service(cds_service)
+```python
+from healthchain.gateway import HealthChainAPI
- return app
+app = HealthChainAPI(title="Discharge Summary CDS Service")
+app.register_service(cds_service)
```
+## Test with Sandbox
+
+Use the [sandbox utility](../reference/utilities/sandbox.md) to test the service with sample data:
-## Test with sample clinical data
+!!! note "Download Sample Data"
-Let's test the service with some sample discharge notes using the [sandbox utility](../reference/utilities/sandbox.md) and the [CdsDataGenerator](../reference/utilities/data_generator.md):
+ Download sample discharge note files from [cookbook/data](https://github.com/dotimplement/HealthChain/tree/main/cookbook/data) and place them in a `data/` folder in your project root.
```python
+import healthchain as hc
+from healthchain.sandbox.use_cases import ClinicalDecisionSupport
+from healthchain.models import Prefetch
from healthchain.data_generators import CdsDataGenerator
-data_generator = CdsDataGenerator()
-data = data_generator.generate(
- free_text_path="data/discharge_notes.csv", column_name="text"
-)
-print(data.model_dump())
-# {
-# "prefetch": {
-# "entry": [
-# {
-# "resource": {
-# "resourceType": "Bundle",
-# ...
-# }
-# }
-# ]
-# }
+@hc.sandbox(api="http://localhost:8000")
+class DischargeNoteSummarizer(ClinicalDecisionSupport):
+ def __init__(self):
+ super().__init__(path="/cds-services/discharge-summary")
+ self.data_generator = CdsDataGenerator()
+
+ @hc.ehr(workflow="encounter-discharge")
+ def load_data_in_client(self) -> Prefetch:
+ data = self.data_generator.generate(
+ free_text_path="data/discharge_notes.csv", column_name="text"
+ )
+ return data
```
-The data generator returns a `Prefetch` object, which ensures that the data is parsed correctly by the CDS service.
+## Run the Complete Example
-## Run the complete example
-
-Run the service with `uvicorn`:
+Put it all together and run both the service and sandbox:
```python
import uvicorn
+import threading
+
+# Start the API server in a separate thread
+def start_api():
+ uvicorn.run(app, port=8000)
-app = create_app()
+api_thread = threading.Thread(target=start_api, daemon=True)
+api_thread.start()
-uvicorn.run(app)
+# Start the sandbox
+summarizer = DischargeNoteSummarizer()
+summarizer.start_sandbox()
```
-## What happens when you run this
+!!! tip "Service Endpoints"
-## Workflow Overview
+ Once running, your service will be available at:
-=== "1. Service Startup"
- - **URL:** [http://localhost:8000/](http://localhost:8000/)
- - **Service discovery:** `/cds-services`
- - **CDS endpoint:** `/cds-services/discharge-summary`
- - **API docs:** `/docs`
+ - **Service discovery**: `http://localhost:8000/cds-services`
+ - **Discharge summary endpoint**: `http://localhost:8000/cds-services/discharge-summary`
-=== "2. Request Processing"
- - Receives CDS Hooks requests from EHR systems
- - Summarizes discharge notes using AI
- - Returns CDS cards with clinical recommendations
+??? example "Example CDS Response"
-=== "3. Example CDS Response"
```json
{
"cards": [
@@ -224,3 +225,31 @@ uvicorn.run(app)
]
}
```
+
+## What You've Built
+
+A CDS Hooks service for discharge workflows that integrates seamlessly with EHR systems:
+
+- **Standards-compliant** - Implements the CDS Hooks specification for EHR interoperability
+- **AI-powered summarization** - Processes discharge notes using transformer models or LLMs
+- **Actionable recommendations** - Returns structured cards with discharge planning tasks
+- **Flexible pipeline** - Supports both fine-tuned models and prompt-engineered LLMs
+- **Auto-discovery** - Provides service discovery endpoint for EHR registration
+
+!!! info "Use Cases"
+
+ - **Discharge Planning Coordination**
+ Automatically extract and highlight critical discharge tasks (appointments, medications, equipment needs) to reduce care coordination errors and readmissions.
+
+ - **Clinical Decision Support**
+ Provide real-time recommendations during discharge workflows, surfacing potential issues like medication interactions or missing follow-up appointments.
+
+ - **Documentation Efficiency**
+ Generate concise discharge summaries from lengthy clinical notes, saving clinicians time while ensuring all critical information is captured.
+
+!!! tip "Next Steps"
+
+ - **Enhance prompts**: Tune your clinical prompts to extract specific discharge criteria or care plan elements.
+ - **Add validation**: Implement checks for required discharge elements (medications, follow-ups, equipment).
+ - **Multi-card support**: Expand to generate separate cards for different discharge aspects (medication reconciliation, transportation, follow-up scheduling).
+ - **Integrate with workflows**: Deploy to Epic App Orchard or Cerner Code Console for production EHR integration.
diff --git a/docs/cookbook/index.md b/docs/cookbook/index.md
index ed71d616..e64b6d3c 100644
--- a/docs/cookbook/index.md
+++ b/docs/cookbook/index.md
@@ -1,6 +1,28 @@
-# Examples
+# ๐ณ Cookbook: Hands-On Examples
-The best way to learn is by example! Here are some to get you started:
+Dive into real-world, production-ready examples to learn how to build interoperable healthcare AI apps with **HealthChain**.
-- [Summarize Discharge Notes with CDS Hooks](./discharge_summarizer.md): Implement a CDS Hooks service that listens for `encounter-discharge` events, automatically generates concise summaries of discharge notes, and delivers clinical recommendations directly into EHR workflows.
-- [Automate Clinical Coding and FHIR Integration](./clinical_coding.md): Build a system that extracts medical conditions from clinical documentation, maps them to SNOMED CT codes, and synchronizes structured Condition resources with external FHIR servers (Medplum) for billing and analytics.
+---
+
+## ๐ฆ Getting Started
+
+- [**Working with FHIR Sandboxes**](./setup_fhir_sandboxes.md)
+ *Spin up and access free Epic, Medplum, and other FHIR sandboxes for safe experimentation. This is the recommended first step before doing the detailed tutorials below.*
+
+---
+
+## ๐ How-To Guides
+
+- ๐ฆ **[Multi-Source Patient Data Aggregation](./multi_ehr_aggregation.md)**
+ *Merge patient data from multiple FHIR sources (Epic, Cerner, etc.), deduplicate conditions, prove provenance, and robustly handle cross-vendor errors. Foundation for retrieval-augmented generation (RAG) and analytics workflows.*
+
+- ๐งพ **[Automate Clinical Coding & FHIR Integration](./clinical_coding.md)**
+ *Extract medical conditions from clinical documentation using AI, map to SNOMED CT codes, and sync as FHIR Condition resources to systems like Medplumโenabling downstream billing, analytics, and interoperability.*
+
+- ๐ **[Summarize Discharge Notes with CDS Hooks](./discharge_summarizer.md)**
+ *Deploy a CDS Hooks-compliant service that listens for discharge events, auto-generates concise plain-language summaries, and delivers actionable clinical cards directly into the EHR workflow.*
+
+---
+
+!!! info "What next?"
+ See the source code for each recipe, experiment with the sandboxes, and adapt the patterns for your projects!
diff --git a/docs/cookbook/multi_ehr_aggregation.md b/docs/cookbook/multi_ehr_aggregation.md
index 7d6c3e46..d0f3d6ce 100644
--- a/docs/cookbook/multi_ehr_aggregation.md
+++ b/docs/cookbook/multi_ehr_aggregation.md
@@ -1,55 +1,407 @@
-# Multi-EHR Data Aggregation Guide
+# Multi-Source Patient Data Aggregation
-*This example is coming soon! ๐ง*
+This example shows you how to aggregate patient data from multiple FHIR sources and track data provenance: essential for building AI applications that train on diverse data, query multiple EHR vendors in RAG systems, or construct unified patient timelines from fragmented health records.
-
-
-
+Check out the full working example [here](https://github.com/dotimplement/HealthChain/tree/main/cookbook/multi_ehr_data_aggregation.py)!
-## Overview
+ *Illustrative Architecture - actual implementation may vary.*
-This comprehensive tutorial will show you how to build a patient data aggregation system that connects to multiple EHR systems, combines patient records, and enriches them with AI-powered insights.
+## Setup
-## What You'll Learn
+```bash
+pip install healthchain python-dotenv
+```
-- **Multi-source FHIR connectivity** - Connect to Epic, Cerner, and other FHIR servers simultaneously
-- **Patient record matching** - Identify and link patient records across different systems
-- **Data deduplication** - Handle overlapping and duplicate information intelligently
-- **NLP enrichment** - Extract insights from clinical notes and add structured data
-- **Unified patient timelines** - Create comprehensive patient views across all systems
-- **Real-time synchronization** - Keep data updated across multiple sources
+We'll use Epic's public FHIR sandbox. If you haven't set up Epic sandbox access yet, see the [FHIR Sandbox Setup Guide](./setup_fhir_sandboxes.md#epic-on-fhir-sandbox) for detailed instructions.
-## Architecture
+Once you have your Epic credentials, configure them in a `.env` file:
-The example will demonstrate:
+```bash
+# .env file
+EPIC_BASE_URL=https://fhir.epic.com/interconnect-fhir-oauth/api/FHIR/R4
+EPIC_CLIENT_ID=your_non_production_client_id
+EPIC_CLIENT_SECRET_PATH=path/to/privatekey.pem
+EPIC_TOKEN_URL=https://fhir.epic.com/interconnect-fhir-oauth/oauth2/token
+EPIC_USE_JWT_ASSERTION=true
+```
-1. **FHIR Gateway Setup** - Configure connections to multiple healthcare systems
-2. **Patient Matching Algorithm** - Match patients across systems using demographics and identifiers
-3. **Data Aggregation Pipeline** - Combine and normalize patient data from different sources
-4. **NLP Processing** - Extract medical entities and conditions from clinical notes
-5. **Conflict Resolution** - Handle discrepancies between different data sources
-6. **Export & Analytics** - Generate unified datasets for research and analytics
+Load your Epic credentials from the `.env` file and create a connection string compatible with the FHIR gateway:
-## Use Cases
+```python
+from healthchain.gateway.clients import FHIRAuthConfig
-Perfect for:
-- **Healthcare Analytics** - Create comprehensive datasets for population health studies
-- **Clinical Research** - Aggregate patient cohorts from multiple institutions
-- **AI/ML Training** - Build rich, multi-source datasets for model training
-- **Patient Care Coordination** - Provide clinicians with complete patient views
+config = FHIRAuthConfig.from_env("EPIC")
+EPIC_URL = config.to_connection_string()
+```
-## Prerequisites
+## Set Up FHIR Gateway
-- Multiple FHIR server connections (we'll show how to set up test environments)
-- Basic understanding of FHIR resources (Patient, Observation, Condition)
-- Python environment with HealthChain installed
+[FHIR Gateways](../reference/gateway/fhir_gateway.md) connect to external FHIR servers and handles authentication, connection pooling, and token refresh automatically. Add the Epic sandbox as a source:
-## Coming Soon
+```python
+from healthchain.gateway import FHIRGateway
-We're actively developing this example and it will be available soon!
+gateway = FHIRGateway()
+gateway.add_source("epic", EPIC_URL)
-In the meantime, check out our [Gateway documentation](../reference/gateway/fhir_gateway.md) to understand the fundamentals of multi-source FHIR connectivity.
+# Optional: Add Cerner's public sandbox (no auth required)
+CERNER_URL = "fhir://fhir-open.cerner.com/r4/ec2458f2-1e24-41c8-b71b-0e701af7583d"
+gateway.add_source("cerner", CERNER_URL)
----
+# You can add more sources:
+# gateway.add_source("other source", fhir://url)
+```
-**Want to be notified when this example is ready?** Join our [Discord community](https://discord.gg/UQC6uAepUz) for updates!
+!!! note
+
+ Cerner's public sandbox patient cohort differs from Epic's. For demo/testing with sandboxes, expect incomplete aggregation if patient cohorts don't overlap - this is normal for the public test data.
+
+ In production, you must perform your own patient identity matching (MPI/crosswalk) before aggregation.
+
+
+## Create Aggregation Handler
+
+Define an aggregation handler that queries multiple FHIR sources for [Condition](https://www.hl7.org/fhir/condition.html) resources.
+
+```python
+from healthchain.fhir import merge_bundles
+
+@gateway.aggregate(Condition)
+def get_unified_patient(patient_id: str, sources: List[str]) -> Bundle:
+ """Aggregate conditions from multiple FHIR sources with provenance tracking."""
+ bundles = []
+ for source in sources:
+ try:
+ bundle = gateway.search(
+ Condition,
+ {"patient": patient_id},
+ source,
+ add_provenance=True, # Track which EHR the data came from
+ provenance_tag="aggregated",
+ )
+ bundles.append(bundle)
+ except Exception as e:
+ print(f"Error from {source}: {e}")
+ # Continue with partial data rather than fail completely
+
+ # Combine conditions across sources
+ merged_bundle = merge_bundles(bundles, deduplicate=True)
+ return merged_bundle
+```
+
+!!! info "What this handler does"
+
+ - Queries each configured FHIR source for patient conditions
+ - Adds [Meta](https://hl7.org/fhir/resource.html#Meta) tags to track data provenance (which source each condition came from, preserves existing metadata)
+ - Handles errors gracefully โ partial data is better than no data
+ - Deduplicates identical conditions across sources
+
+
+??? example "Example FHIR Metadata"
+
+ ```json
+ {
+ "resourceType": "Condition",
+ "id": ...,
+ "meta": {
+ "lastUpdated": "2025-10-10T15:23:50.167941Z", // Updated timestamp
+ "source": "urn:healthchain:source:epic", // Adds source
+ "tag": [
+ {
+ "system": "https://dotimplement.github.io/HealthChain/fhir/tags",
+ "code": "aggregated",
+ "display": "Aggregated"
+ } // Appends a custom HealthChain tag
+ ]
+ }
+ ...
+ }
+ ```
+
+## Build the Service
+
+Register the gateway with [HealthChainAPI](../reference/gateway/api.md) to create REST endpoints.
+
+```python
+import uvicorn
+from healthchain.gateway import HealthChainAPI
+
+app = HealthChainAPI()
+app.register_gateway(gateway, path="/fhir")
+
+uvicorn.run(app)
+```
+
+!!! tip "FHIR Endpoints Provided by the Service"
+
+ - `/fhir/*` - Standard FHIR operations (`read`, `search`, `create`, `update`)
+ - `/fhir/metadata` - [CapabilityStatement](https://hl7.org/fhir/capabilitystatement.html) describing supported resources and operations
+ - `/fhir/status` - Operational status and metadata for gateway
+
+## Add Processing Pipeline (Optional)
+
+For additional processing like terminology mapping or quality checks, create a Document [Pipeline](../reference/pipeline/pipeline.md).
+
+Document pipelines are optimized for text and structured data processing, such as FHIR resources. When you initialize a [Document](../reference/pipeline/data_container.md) with FHIR [Bundle](https://www.hl7.org/fhir/condition.html) data, it automatically extracts and separates metadata resources from the clinical resources for easier inspection and error handling:
+
+```python
+# Initialize Document with a Bundle
+doc = Document(data=merged_bundle)
+
+# OperationOutcomes are automatically extracted and available
+doc.fhir.operation_outcomes # List of OperationOutcome resources
+
+# Clinical resources remain in the bundle
+doc.fhir.bundle # Bundle with clinical resources
+doc.fhir.problem_list # List of Condition resources
+doc.fhir.medication_list # List of MedicationStatement resources
+```
+
+Add processing nodes using decorators:
+
+```python
+from healthchain.pipeline import Pipeline
+from healthchain.io.containers import Document
+
+pipeline = Pipeline[Document]()
+
+@pipeline.add_node
+def deduplicate(doc: Document) -> Document:
+ ...
+
+@pipeline.add_node
+def add_annotation(doc: Document) -> Document:
+ ...
+
+# Apply the pipeline
+doc = Document(data=merged_bundle)
+doc = pipeline(doc)
+```
+
+!!! tip "Common Pipeline Uses"
+
+ - **Terminology mapping** (ICD-10 โ SNOMED CT)
+ - **Data enrichment** (risk scores, clinical decision support)
+ - **Quality checks** (validate completeness, flag inconsistencies)
+ - **Consent filtering** (apply patient consent rules)
+
+
+## Test the Service
+
+To test aggregation, request `/fhir/aggregate/Condition/{patientId}` with the `sources` parameter (e.g., `epic,cerner`).
+
+Example uses Epic patient `eIXesllypH3M9tAA5WdJftQ3`; see [Epic sandbox](https://fhir.epic.com/Documentation?docId=testpatients) for more test patients.
+
+
+=== "cURL"
+ ```bash
+ curl -X 'GET' \
+ 'http://127.0.0.1:8888/fhir/aggregate/Condition?id=eIXesllypH3M9tAA5WdJftQ3&sources=epic&sources=cerner' \
+ -H 'accept: application/fhir+json'
+ ```
+
+=== "Python"
+ ```python
+ import requests
+
+ url = "http://127.0.0.1:8888/fhir/aggregate/Condition"
+ params = {
+ "id": "eIXesllypH3M9tAA5WdJftQ3",
+ "sources": ["epic", "cerner"]
+ }
+ headers = {
+ "accept": "application/fhir+json"
+ }
+ response = requests.get(url, headers=headers, params=params)
+ print(response.json)
+ ```
+
+
+### Expected Outputs
+
+Example output when querying Linda Ross (Epic patient `eIXesllypH3M9tAA5WdJftQ3`):
+
+```
+โ Patient: Ross, Linda Jane
+โ Conditions retrieved: 2
+
+Sample conditions:
+ โข Moderate persistent asthma
+ Codes: ICD10-CM:J45.40, SNOMED:427295004, ICD9:493.90
+ Source: urn:healthchain:source:epic
+ Severity: Medium
+ Onset: 1999-03-08
+
+ โข Bronchitis with asthma, acute
+ Codes: ICD10-CM:J20.9/J45.909, SNOMED:405944004, ICD9:466.0
+ Source: urn:healthchain:source:epic
+ Severity: High
+ Onset: 2019-05-24
+```
+
+???+ example "Aggregated Result: With provenance tags and pipeline processing"
+
+ Sample Bundle with deduplicated Conditions aggregated from Epic and Cerner. Each includes source details (`meta.source`, `meta.tag`) and a pipeline-added `note`.
+
+ ```json
+ {
+ "resourceType": "Bundle",
+ "type": "collection",
+ "entry": [
+ {
+ "resource": {
+ "resourceType": "Condition",
+ "id": "eOCME6XUbCLYmFlVf2l1G0w3",
+ "meta": {
+ "lastUpdated": "2025-10-10T15:23:50.167941Z", // Updated by HealthChain Gateway
+ "source": "urn:healthchain:source:epic", // Added by HealthChain Gateway
+ "tag": [{
+ "system": "https://dotimplement.github.io/HealthChain/fhir/tags",
+ "code": "aggregated",
+ "display": "Aggregated"
+ }] // Added by HealthChain Gateway
+ },
+ "clinicalStatus": { "text": "Active" },
+ "severity": { "text": "Medium" },
+ "code": {
+ "coding": [
+ {
+ "system": "http://hl7.org/fhir/sid/icd-10-cm",
+ "code": "J45.40",
+ "display": "Moderate persistent asthma, uncomplicated"
+ },
+ {
+ "system": "http://snomed.info/sct",
+ "code": "427295004",
+ "display": "Moderate Persistent Asthma"
+ },
+ {
+ "system": "http://hl7.org/fhir/sid/icd-9-cm",
+ "code": "493.90"
+ }
+ ],
+ "text": "Moderate persistent asthma"
+ },
+ "subject": {
+ "reference": "Patient/eIXesllypH3M9tAA5WdJftQ3",
+ "display": "Ross, Linda Jane"
+ },
+ "onsetDateTime": "1999-03-08",
+ "note": [{
+ "text": "This resource has been processed by healthchain pipeline"
+ }] // Added by HealthChain Pipeline
+ }
+ },
+ {
+ "resource": {
+ "resourceType": "Condition",
+ "id": "etZVq9vWdHQ4q0Y6INaFhig3",
+ "meta": {
+ "lastUpdated": "2025-10-10T15:23:50.168175Z", // Updated by HealthChain Gateway
+ "source": "urn:healthchain:source:epic", // Added by HealthChain Gateway
+ "tag": [{
+ "system": "https://dotimplement.github.io/HealthChain/fhir/tags",
+ "code": "aggregated"
+ }] // Added by HealthChain Gateway
+ },
+ "severity": { "text": "High" },
+ "code": {
+ "coding": [
+ {
+ "system": "http://hl7.org/fhir/sid/icd-10-cm",
+ "code": "J20.9",
+ "display": "Acute bronchitis, unspecified"
+ },
+ {
+ "system": "http://snomed.info/sct",
+ "code": "405944004",
+ "display": "Asthmatic Bronchitis"
+ }
+ ],
+ "text": "Bronchitis with asthma, acute"
+ },
+ "onsetDateTime": "2019-05-24",
+ "note": [{
+ "text": "This resource has been processed by healthchain pipeline"
+ }] // Added by HealthChain Pipeline
+ }
+ }
+ ]
+ }
+ ```
+
+??? warning "OperationOutcome: Authorization warnings"
+
+ You'll see this if you haven't authorized access to the correct FHIR resources when you set up your FHIR sandbox.
+
+ ```python
+ print([outcome.model_dump() for outcome in doc.fhir.operation_outcomes])
+ ```
+
+ ```json
+ {
+ "resourceType": "OperationOutcome",
+ "meta": {
+ "source": "urn:healthchain:source:epic"
+ },
+ "issue": [
+ {
+ "severity": "warning",
+ "code": "suppressed",
+ "details": {
+ "coding": [{
+ "system": "urn:oid:1.2.840.114350.1.13.0.1.7.2.657369",
+ "code": "59204"
+ }]
+ },
+ "diagnostics": "Client not authorized for Condition - Encounter Diagnosis"
+ },
+ {
+ "severity": "warning",
+ "code": "suppressed",
+ "diagnostics": "Client not authorized for Condition - Health Concerns"
+ },
+ {
+ "severity": "warning",
+ "code": "suppressed",
+ "diagnostics": "Client not authorized for Condition - Medical History"
+ }
+ ]
+ }
+ ```
+
+??? warning "Expected Error Handling"
+
+ You'll see this when querying a patient that doesn't exist in a source:
+
+ ```
+ Error from cerner: [FHIR request failed: 400 - Unknown error]
+ search failed:
+ Resource could not be parsed or failed basic FHIR validation rules
+ ```
+
+
+## What You've Built
+
+A production-ready data aggregation service with:
+
+- **Multi-vendor support** - Query Epic, Cerner, and other FHIR sources simultaneously
+- **Automatic provenance tracking** - `meta.source` field shows which EHR each resource came from
+- **Error resilience** - Handles missing patients, network failures, auth issues gracefully
+- **Deduplication** - Merges identical conditions across sources
+- **Pipeline extensibility** - Add custom processing for terminology mapping, NLP, or quality checks
+
+!!! info "Use Cases"
+
+ - **Data Harmonization**: Use pipelines to normalize terminology (ICD-10 โ SNOMED CT), validate completeness, and flag inconsistencies across sources. Combine with clinical NLP engines to extract and aggregate data from unstructured clinical notes alongside structured FHIR resources.
+
+ - **RAG Systems**: Build retrieval systems that search across multiple health systems. The aggregator provides the unified patient context LLMs need for clinical reasoning.
+
+ - **Training Data for AI Models**: Aggregate diverse patient data across EHR vendors for model training. Provenance tags enable stratified analysis (e.g., "how does model performance vary by data source?").
+
+!!! tip "Next Steps"
+
+ - **Try another FHIR server**: Set up a different [FHIR server](./setup_fhir_sandboxes.md) where you can upload the same test patients to multiple instances for true multi-source aggregation.
+ - **Expand resource types**: Change `Condition` to `MedicationStatement`, `Observation`, or `Procedure` to aggregate different data.
+ - **Add processing**: Extend the pipeline with terminology mapping, entity extraction, or quality checks.
+ - **Build on it**: Use aggregated data in the [Clinical Coding tutorial](./clinical_coding.md) or feed it to your LLM application.
diff --git a/docs/cookbook/setup_fhir_sandboxes.md b/docs/cookbook/setup_fhir_sandboxes.md
new file mode 100644
index 00000000..ca5b54b9
--- /dev/null
+++ b/docs/cookbook/setup_fhir_sandboxes.md
@@ -0,0 +1,219 @@
+# Working with FHIR Sandboxes
+
+This guide covers setting up access to public FHIR sandboxes for testing and development. These sandboxes provide free access to test data and realistic FHIR APIs without requiring production EHR credentials.
+
+## Epic on FHIR Sandbox
+
+Epic provides a public [testing sandbox](https://open.epic.com/MyApps/Endpoints) with [sample patients](https://fhir.epic.com/Documentation?docId=testpatients) and [resource specifications available](https://fhir.epic.com/Specifications) for developing against their FHIR Server.
+
+### Prerequisites
+
+- Free Epic on FHIR developer account: [https://fhir.epic.com/](https://fhir.epic.com/)
+- No existing Epic customer account required (it only takes a minute)
+
+### Step 1: Create an App
+
+1. Log in to [https://fhir.epic.com/](https://fhir.epic.com/)
+
+
+
+2. Navigate to "Build Apps" โ "Create"
+
+
+
+3. Fill out the application form:
+ - **Application Name**: Choose any descriptive name
+ - **Application Type**: Check "Backend Systems"
+ - **FHIR APIs**: Select the APIs you need (note the versions)
+
+
+
+### Step 2: Configure OAuth2 with JWT Authentication
+
+Epic uses [OAuth2 with JWT assertion for authentication](https://fhir.epic.com/Documentation?docId=oauth2§ion=BackendOAuth2Guide).
+
+#### Generate Key Pair
+
+Follow Epic's instructions to [create a Public Private key pair for JWT signature](https://fhir.epic.com/Documentation?docId=oauth2§ion=Creating-Key-Pair):
+
+```bash
+# Generate private key - make sure the key length is at least 2048 bits.
+openssl genrsa -out privatekey.pem 2048
+
+# Export public key as base64 encoded X.509 certificate
+openssl req -new -x509 -key privatekey.pem -out publickey509.pem -subj '/CN=myapp'
+```
+
+Where `/CN=myapp` is the subject name (e.g., your app name). The subject name doesn't have functional impact but is required for creating an X.509 certificate.
+
+#### Upload Public Key
+
+1. In your Epic app configuration, upload the `publickey509.pem` file
+2. Click **Save**
+3. Note down your **Non-Production Client ID**
+
+
+
+### Step 3: Complete App Setup
+
+1. Fill out remaining required fields (description, etc.)
+2. Check to confirm terms of use
+3. Click **Save & Ready for Sandbox**
+
+
+
+### Step 4: Configure Environment Variables
+
+Create a `.env` file with your credentials:
+
+```bash
+# .env file
+EPIC_BASE_URL=https://fhir.epic.com/interconnect-fhir-oauth/api/FHIR/R4
+EPIC_CLIENT_ID=your_non_production_client_id
+EPIC_CLIENT_SECRET_PATH=path/to/privatekey.pem
+EPIC_TOKEN_URL=https://fhir.epic.com/interconnect-fhir-oauth/oauth2/token
+EPIC_USE_JWT_ASSERTION=true
+```
+
+### Using Epic Sandbox in Code
+
+```python
+from healthchain.gateway.clients import FHIRAuthConfig
+
+# Load configuration from environment variables
+config = FHIRAuthConfig.from_env("EPIC")
+EPIC_URL = config.to_connection_string()
+
+# Add to FHIR gateway
+from healthchain.gateway import FHIRGateway
+
+gateway = FHIRGateway()
+gateway.add_source("epic", EPIC_URL)
+```
+
+### Available Test Patients
+
+Epic provides [sample test patients](https://fhir.epic.com/Documentation?docId=testpatients) including:
+
+- **Derrick Lin** - Patient ID: `eq081-VQEgP8drUUqCWzHfw3`
+- **Linda Ross** - Patient ID: `eIXesllypH3M9tAA5WdJftQ3`
+- Many others with various clinical scenarios
+
+---
+## Cerner Sandbox
+
+Cerner (now Oracle Health) provides both open and secure public sandboxes for the [FHIR R4 APIs for Oracle Health Millennium Platform](https://docs.oracle.com/en/industries/health/millennium-platform-apis/mfrap/srv_root_url.html).
+
+The Open Sandbox is read-only. It does not require authentication and is handy for quick proof of concepts:
+
+```bash
+https://fhir-open.cerner.com/r4/ec2458f2-1e24-41c8-b71b-0e701af7583d/:resource[?:parameters]
+```
+You can get an idea of patients available in the open sandbox by querying some common last names:
+
+```bash
+curl -i -H "Accept: application/json+fhir" "https://fhir-open.cerner.com/r4/ec2458f2-1e24-41c8-b71b-0e701af7583d/Patient?family=smith"
+```
+
+Documentation on Secure Sandbox coming soon.
+
+---
+## Medplum
+
+[Medplum](https://www.medplum.com/) is an open-source healthcare platform that provides a compliant FHIR server. It's useful for testing with controlled data where you can upload your own test patients. Medplum uses [standard OAuth2/OpenID authentication](https://www.medplum.com/docs/auth/client-credentials).
+
+### Prerequisites
+
+- Medplum account: [Register here](https://www.medplum.com/docs/tutorials/register)
+- Free tier available
+
+### Step 1: Create a Client Application
+
+1. Log in to your Medplum account
+2. Navigate to [ClientApplication](https://app.medplum.com/ClientApplication)
+3. Create a new Client and configure Access Policy if needed.
+
+
+### Step 2: Get Credentials
+
+After creating the client:
+
+1. Note your **Client ID**
+2. Copy your **Client Secret**
+
+
+
+
+### Step 3: Configure Environment Variables
+
+Create a `.env` file with your credentials:
+
+```bash
+# .env file
+MEDPLUM_BASE_URL=https://api.medplum.com/fhir/R4
+MEDPLUM_CLIENT_ID=your_client_id
+MEDPLUM_CLIENT_SECRET=your_client_secret
+MEDPLUM_TOKEN_URL=https://api.medplum.com/oauth2/token
+MEDPLUM_SCOPE=openid
+```
+
+### Using Medplum in Code
+
+```python
+from healthchain.gateway import FHIRGateway
+from healthchain.gateway.clients import FHIRAuthConfig
+from dotenv import load_dotenv
+
+load_dotenv()
+
+# Load configuration from environment variables
+config = FHIRAuthConfig.from_env("MEDPLUM")
+MEDPLUM_URL = config.to_connection_string()
+
+# Add to FHIR gateway
+gateway = FHIRGateway()
+gateway.add_source("medplum", MEDPLUM_URL)
+```
+
+### Benefits of Medplum
+
+- **Full control**: Upload your own test data
+- **FHIR R4 compliant**: Complete FHIR API implementation
+- **Multi-source testing**: Create multiple projects for different data sources
+- **Web interface**: Browse and manage resources via UI
+
+---
+## Tips for Multi-Source Testing
+
+### Different Test Data
+
+Public sandboxes (Epic, Cerner) contain different test patients. When testing multi-source aggregation:
+
+- **Expected behavior**: Queries for patients not in a source should fail gracefully
+- **Production use**: Map patient identifiers across systems or use sources sharing patient cohorts
+- **Controlled testing**: Use Medplum where you can upload the same test patients to multiple instances
+
+### Error Handling
+
+Your code should handle:
+
+- Network issues or downtime
+- Patient not found in specific sources
+- Rate limiting
+- Authorization failures
+
+### Authentication
+
+| Sandbox | Auth Mechanism |
+|-----------|------------------------------------|
+| **Epic** | OAuth2 with JWT assertion (backend) |
+| **Medplum** | OAuth2 client credentials (Client Credentials Flow) |
+
+
+HealthChain's [FHIRGateway](../reference/gateway/fhir_gateway.md) handles these automatically via connection strings.
+
+## Next Steps
+
+- Return to your tutorial to continue with the specific use case
+- See [FHIR Gateway documentation](../reference/gateway/fhir_gateway.md) for advanced configuration
+- Check [FHIR Resources documentation](https://www.hl7.org/fhir/) for working with different resource types
diff --git a/docs/reference/interop/experimental.md b/docs/reference/interop/experimental.md
index aa42180e..baa15677 100644
--- a/docs/reference/interop/experimental.md
+++ b/docs/reference/interop/experimental.md
@@ -25,6 +25,7 @@ This page tracks templates that are under development or have known issues. Use
**Location:** `dev-templates/allergies/`
**Usage:**
+
1. Copy experimental files to your custom config:
```bash
# After running: healthchain init-configs my_configs
@@ -70,6 +71,7 @@ We welcome contributions to improve experimental templates!
## Roadmap
**Next Priorities:**
+
1. ๐ฏ **Allergies stabilization** - Fix clinical status parsing and round-trip issues
2. ๐ฎ **Future sections** - Procedures, Vital Signs, Lab Results
3. ๐ง **Template tooling** - Better validation and testing framework
diff --git a/docs/reference/interop/generators.md b/docs/reference/interop/generators.md
index 0c2c5979..8d49d17d 100644
--- a/docs/reference/interop/generators.md
+++ b/docs/reference/interop/generators.md
@@ -71,91 +71,65 @@ fhir_generator = engine.fhir_generator
fhir_resources = fhir_generator.transform(cda_section_entries, src_format=FormatType.CDA)
```
-
-View full input data
+??? example "View full input data"
-```python
-{
- "problems": [{
- 'act': {
- '@classCode': 'ACT',
- '@moodCode': 'EVN',
- 'templateId': [
- {'@root': '2.16.840.1.113883.10.20.1.27'},
- {'@root': '1.3.6.1.4.1.19376.1.5.3.1.4.5.1'},
- {'@root': '1.3.6.1.4.1.19376.1.5.3.1.4.5.2'},
- {'@root': '2.16.840.1.113883.3.88.11.32.7'},
- {'@root': '2.16.840.1.113883.3.88.11.83.7'}
- ],
- 'id': {
- '@extension': '51854-concern',
- '@root': '1.2.840.114350.1.13.525.3.7.2.768076'
- },
- 'code': {
- '@nullFlavor': 'NA'
- },
- 'text': {
- 'reference': {'@value': '#problem12'}
- },
- 'statusCode': {
- '@code': 'active'
- },
- 'effectiveTime': {
- 'low': {'@value': '20210317'}
- },
- 'entryRelationship': {
- '@typeCode': 'SUBJ',
- '@inversionInd': False,
- 'observation': {
- '@classCode': 'OBS',
+ The FHIR generator transforms this structure into a FHIR Condition resource by:
+
+ 1. Identifying the section type ("problems") from the dictionary key
+ 2. Looking up the corresponding FHIR resource type ("Condition") from configuration
+ 3. Extracting relevant data from the nested structure (codes, dates, statuses)
+ 4. Using templates to map specific fields to FHIR attributes
+
+ ```python
+ {
+ "problems": [{
+ 'act': {
+ '@classCode': 'ACT',
'@moodCode': 'EVN',
'templateId': [
- {'@root': '1.3.6.1.4.1.19376.1.5.3.1.4.5'},
- {'@root': '2.16.840.1.113883.10.20.1.28'}
+ {'@root': '2.16.840.1.113883.10.20.1.27'},
+ {'@root': '1.3.6.1.4.1.19376.1.5.3.1.4.5.1'},
+ {'@root': '1.3.6.1.4.1.19376.1.5.3.1.4.5.2'},
+ {'@root': '2.16.840.1.113883.3.88.11.32.7'},
+ {'@root': '2.16.840.1.113883.3.88.11.83.7'}
],
'id': {
- '@extension': '51854',
+ '@extension': '51854-concern',
'@root': '1.2.840.114350.1.13.525.3.7.2.768076'
},
'code': {
- '@code': '64572001',
- '@codeSystem': '2.16.840.1.113883.6.96',
- '@codeSystemName': 'SNOMED CT'
+ '@nullFlavor': 'NA'
},
'text': {
- 'reference': {'@value': '#problem12name'}
+ 'reference': {'@value': '#problem12'}
},
'statusCode': {
- '@code': 'completed'
+ '@code': 'active'
},
'effectiveTime': {
- 'low': {'@value': '20190517'}
- },
- 'value': {
- '@code': '38341003',
- '@codeSystem': '2.16.840.1.113883.6.96',
- '@codeSystemName': 'SNOMED CT',
- '@xsi:type': 'CD',
- '@xmlns:xsi': 'http://www.w3.org/2001/XMLSchema-instance',
- 'originalText': {
- 'reference': {'@value': '#problem12name'}
- }
+ 'low': {'@value': '20210317'}
},
'entryRelationship': {
- '@typeCode': 'REFR',
+ '@typeCode': 'SUBJ',
'@inversionInd': False,
'observation': {
'@classCode': 'OBS',
'@moodCode': 'EVN',
'templateId': [
- {'@root': '2.16.840.1.113883.10.20.1.50'},
- {'@root': '2.16.840.1.113883.10.20.1.57'},
- {'@root': '1.3.6.1.4.1.19376.1.5.3.1.4.1.1'}
+ {'@root': '1.3.6.1.4.1.19376.1.5.3.1.4.5'},
+ {'@root': '2.16.840.1.113883.10.20.1.28'}
],
+ 'id': {
+ '@extension': '51854',
+ '@root': '1.2.840.114350.1.13.525.3.7.2.768076'
+ },
'code': {
- '@code': '33999-4',
- '@codeSystem': '2.16.840.1.113883.6.1',
- '@displayName': 'Status'
+ '@code': '64572001',
+ '@codeSystem': '2.16.840.1.113883.6.96',
+ '@codeSystemName': 'SNOMED CT'
+ },
+ 'text': {
+ 'reference': {'@value': '#problem12name'}
},
'statusCode': {
'@code': 'completed'
@@ -164,34 +138,54 @@ fhir_resources = fhir_generator.transform(cda_section_entries, src_format=Format
'low': {'@value': '20190517'}
},
'value': {
- '@code': '55561003',
+ '@code': '38341003',
'@codeSystem': '2.16.840.1.113883.6.96',
- '@xsi:type': 'CE',
- '@displayName': 'Active',
- '@xmlns:xsi': 'http://www.w3.org/2001/XMLSchema-instance'
+ '@codeSystemName': 'SNOMED CT',
+ '@xsi:type': 'CD',
+ '@xmlns:xsi': 'http://www.w3.org/2001/XMLSchema-instance',
+ 'originalText': {
+ 'reference': {'@value': '#problem12name'}
+ }
+ },
+ 'entryRelationship': {
+ '@typeCode': 'REFR',
+ '@inversionInd': False,
+ 'observation': {
+ '@classCode': 'OBS',
+ '@moodCode': 'EVN',
+ 'templateId': [
+ {'@root': '2.16.840.1.113883.10.20.1.50'},
+ {'@root': '2.16.840.1.113883.10.20.1.57'},
+ {'@root': '1.3.6.1.4.1.19376.1.5.3.1.4.1.1'}
+ ],
+ 'code': {
+ '@code': '33999-4',
+ '@codeSystem': '2.16.840.1.113883.6.1',
+ '@displayName': 'Status'
+ },
+ 'statusCode': {
+ '@code': 'completed'
+ },
+ 'effectiveTime': {
+ 'low': {'@value': '20190517'}
+ },
+ 'value': {
+ '@code': '55561003',
+ '@codeSystem': '2.16.840.1.113883.6.96',
+ '@xsi:type': 'CE',
+ '@displayName': 'Active',
+ '@xmlns:xsi': 'http://www.w3.org/2001/XMLSchema-instance'
+ }
+ }
}
}
}
}
- }
+ }]
}
- }]
-}
-```
+ ```
+
-The FHIR generator transforms this structure into a FHIR Condition resource by:
-
-1. Identifying the section type ("problems") from the dictionary key
-
-2. Looking up the corresponding FHIR resource type ("Condition") from configuration
-
-3. Extracting relevant data from the nested structure (codes, dates, statuses)
-
-4. Using templates to map specific fields to FHIR attributes
-
-
-The result is a properly structured FHIR Condition resource with all required fields populated.
-
-Prebuilt pipelines are end-to-end workflows optimized for specific healthcare AI tasks. They can be used with adapters for seamless integration with EHR systems via [protocols](../gateway/gateway.md).
+Prebuilt pipelines are production-ready workflows that automatically handle FHIR conversion, validation, and formatting. They integrate seamlessly with EHR systems through [adapters](./adapters/adapters.md) and [gateways](../gateway/gateway.md), supporting standards like CDS Hooks and FHIR REST APIs.
-You can load your models directly as a pipeline object, from local files or from a remote model repository such as Hugging Face.
+Load your models from Hugging Face, local files, or pipeline objects:
```python
from healthchain.pipeline import MedicalCodingPipeline
@@ -96,57 +96,94 @@ There are three types of nodes you can add to your pipeline with the method `.ad
#### Inline Functions
-Inline functions are simple functions that take in a container and return a container.
+Inline functions are simple functions that process Document containers. Use them for custom clinical logic without creating full components.
```python
+from spacy.tokens import Span
+
@pipeline.add_node
-def remove_stopwords(doc: Document) -> Document:
- stopwords = {"the", "a", "an", "in", "on", "at"}
- doc.tokens = [token for token in doc.tokens if token not in stopwords]
+def link_snomed_codes(doc: Document) -> Document:
+ """Map medical entities to SNOMED CT codes."""
+ if not Span.has_extension("cui"):
+ Span.set_extension("cui", default=None)
+
+ spacy_doc = doc.nlp.get_spacy_doc()
+
+ # Map clinical terms to SNOMED CT
+ snomed_mapping = {
+ "hypertension": "38341003",
+ "diabetes": "73211009",
+ "pneumonia": "233604007",
+ }
+
+ for ent in spacy_doc.ents:
+ if ent.text.lower() in snomed_mapping:
+ ent._.cui = snomed_mapping[ent.text.lower()]
+
return doc
# Equivalent to:
-pipeline.add_node(remove_stopwords)
+pipeline.add_node(link_snomed_codes)
```
#### Components
-Components are pre-configured building blocks that perform specific tasks. They are defined as separate classes and can be reused across multiple pipelines.
+Components are pre-configured building blocks for common clinical NLP tasks. They handle FHIR conversion, entity extraction, and CDS formatting automatically.
-You can see the full list of available components at the [Components](./components/components.md) page.
+See the full list at the [Components](./components/components.md) page.
```python
-from healthchain.pipeline import TextPreProcessor
+from healthchain.pipeline.components import SpacyNLP, FHIRProblemListExtractor
+
+# Add medical NLP processing
+nlp = SpacyNLP.from_model_id("en_core_sci_sm")
+pipeline.add_node(nlp)
-preprocessor = TextPreProcessor(tokenizer="spacy", lowercase=True)
-pipeline.add_node(preprocessor)
+# Extract FHIR Condition resources from entities
+extractor = FHIRProblemListExtractor()
+pipeline.add_node(extractor)
```
#### Custom Components
-Custom components are classes that implement the `BaseComponent` interface. You can use them to add custom processing logic to your pipeline.
+Custom components implement the `BaseComponent` interface for reusable clinical processing logic.
```python
from healthchain.pipeline import BaseComponent
+from healthchain.fhir import create_condition
-class RemoveStopwords(BaseComponent):
- def __init__(self, stopwords: List[str]):
+class ClinicalEntityLinker(BaseComponent):
+ """Links extracted entities to standard medical terminologies."""
+
+ def __init__(self, terminology_service_url: str):
super().__init__()
- self.stopwords = stopwords
+ self.terminology_url = terminology_service_url
def __call__(self, doc: Document) -> Document:
- doc.tokens = [token for token in doc.tokens if token not in self.stopwords]
+ """Convert medical entities to FHIR Conditions."""
+ spacy_doc = doc.nlp.get_spacy_doc()
+
+ for ent in spacy_doc.ents:
+ if ent._.cui: # Has SNOMED CT code
+ condition = create_condition(
+ subject=f"Patient/{doc.patient_id}",
+ code=ent._.cui,
+ display=ent.text
+ )
+ doc.fhir.problem_list.append(condition)
+
return doc
-stopwords = ["the", "a", "an", "in", "on", "at"]
-pipeline.add_node(RemoveStopwords(stopwords))
+# Add to pipeline
+linker = ClinicalEntityLinker(terminology_service_url="https://terminology.hl7.org/")
+pipeline.add_node(linker)
```
[(BaseComponent API Reference)](../../api/component.md#healthchain.pipeline.components.base.BaseComponent)
### Working with Healthcare Data Formats ๐
-Use adapters to handle conversion between healthcare formats (CDA, FHIR) and HealthChain's internal Document objects. Adapters enable clean separation between ML processing and format handling.
+Adapters convert between healthcare formats (CDA, FHIR, CDS Hooks) and HealthChain's internal Document objects, enabling clean separation between ML processing and format handling. This allows your pipeline to work with any healthcare data source while maintaining FHIR-native outputs.
```python
from healthchain.io import CdaAdapter, Document
@@ -184,20 +221,38 @@ When using `"after"` or `"before"`, you must also specify the `reference` parame
You can also specify the `stage` parameter to add the component to a specific stage group of the pipeline.
```python
-@pipeline.add_node(position="after", reference="tokenize", stage="preprocessing")
-def remove_stopwords(doc: Document) -> Document:
- stopwords = {"the", "a", "an", "in", "on", "at"}
- doc.tokens = [token for token in doc.tokens if token not in stopwords]
+@pipeline.add_node(position="after", reference="SpacyNLP", stage="entity_linking")
+def link_snomed_codes(doc: Document) -> Document:
+ """Add SNOMED CT codes to extracted medical entities."""
+ spacy_doc = doc.nlp.get_spacy_doc()
+ snomed_mapping = {
+ "hypertension": "38341003",
+ "diabetes": "73211009",
+ }
+ for ent in spacy_doc.ents:
+ if ent.text.lower() in snomed_mapping:
+ ent._.cui = snomed_mapping[ent.text.lower()]
return doc
```
You can specify dependencies between components using the `dependencies` parameter. This is useful if you want to ensure that a component is run after another component.
```python
-@pipeline.add_node(dependencies=["tokenize"])
-def remove_stopwords(doc: Document) -> Document:
- stopwords = {"the", "a", "an", "in", "on", "at"}
- doc.tokens = [token for token in doc.tokens if token not in stopwords]
+@pipeline.add_node(dependencies=["SpacyNLP"])
+def extract_medications(doc: Document) -> Document:
+ """Extract medication entities and convert to FHIR MedicationStatements."""
+ spacy_doc = doc.nlp.get_spacy_doc()
+
+ for ent in spacy_doc.ents:
+ if ent.label_ == "MEDICATION":
+ # Create FHIR MedicationStatement
+ med_statement = create_medication_statement(
+ subject=f"Patient/{doc.patient_id}",
+ code=ent._.cui if hasattr(ent._, "cui") else None,
+ display=ent.text
+ )
+ doc.fhir.medication_list.append(med_statement)
+
return doc
```
@@ -206,7 +261,7 @@ def remove_stopwords(doc: Document) -> Document:
Use `.remove()` to remove a component from the pipeline.
```python
-pipeline.remove("remove_stopwords")
+pipeline.remove("link_snomed_codes")
```
#### Replacing
@@ -214,11 +269,20 @@ pipeline.remove("remove_stopwords")
Use `.replace()` to replace a component in the pipeline.
```python
-def remove_names(doc: Document) -> Document:
- doc.entities = [token for token in doc.entities if token[0].isupper() and len(token) > 1]
+def enhanced_entity_linking(doc: Document) -> Document:
+ """Enhanced entity linking with external terminology service."""
+ spacy_doc = doc.nlp.get_spacy_doc()
+
+ for ent in spacy_doc.ents:
+ # Call external terminology service for validation
+ validated_code = terminology_service.validate(ent.text)
+ if validated_code:
+ ent._.cui = validated_code
+
return doc
-pipeline.replace("remove_stopwords", remove_names)
+# Replace basic linking with enhanced version
+pipeline.replace("link_snomed_codes", enhanced_entity_linking)
```
#### Inspecting the Pipeline
@@ -227,11 +291,11 @@ pipeline.replace("remove_stopwords", remove_names)
print(pipeline)
print(pipeline.stages)
-# ["TextPreprocessor", "Model", "TextPostProcessor"]
+# ["SpacyNLP", "ClinicalEntityLinker", "FHIRProblemListExtractor"]
# preprocessing:
-# - TextPreprocessor
-# ner+l:
-# - Model
-# postprocessing:
-# - TextPostProcessor
+# - SpacyNLP
+# entity_linking:
+# - ClinicalEntityLinker
+# fhir_conversion:
+# - FHIRProblemListExtractor
```
diff --git a/docs/reference/utilities/fhir_helpers.md b/docs/reference/utilities/fhir_helpers.md
index 839388b6..35d531ad 100644
--- a/docs/reference/utilities/fhir_helpers.md
+++ b/docs/reference/utilities/fhir_helpers.md
@@ -6,9 +6,9 @@ The `fhir` module provides a set of helper functions to make it easier for you t
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.
+Creating FHIR resources can involve a lot of boilerplate code, validation errors and manual comparison with FHIR specifications.
-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:
+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 to locate the SNOMED CT code for a medication, you may have to do something headache-inducing like:
```python
medication_statement = {
@@ -32,14 +32,20 @@ medication_statement = {
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.
+!!! tip "Sensible Defaults for Resource Creation"
+ The `fhir` `create_*` functions create FHIR resources with sensible defaults, automatically setting:
+ - A reference ID prefixed by "`hc-`"
+ - A status of "`active`" (or equivalent)
+ - 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.
+ You can modify and manipulate these resources as you would any other Pydantic object after their 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!**
+!!! important "Validation of FHIR Resources"
+ Internally, HealthChain uses [fhir.resources](https://github.com/nazrulworld/fhir.resources) to validate FHIR resources, which is powered by [Pydantic V2](https://docs.pydantic.dev/latest/).
+ These helpers create minimal valid FHIR objects to help you get started easily.
+ :octicons-alert-16: **ALWAYS check that the sensible defaults fit your needs, and validate your resource!**
### Overview
@@ -48,21 +54,20 @@ Internally, HealthChain uses [fhir.resources](https://github.com/nazrulworld/fhi
| **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 |
+| **DocumentReference** | โข `type` | โข `status`: "current" โข `date`: UTC now โข `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:**
+!!! note "Required fields"
+ - [clinicalStatus](https://www.hl7.org/fhir/condition-definitions.html#Condition.clinicalStatus)
+ - [subject](https://www.hl7.org/fhir/condition-definitions.html#Condition.subject)
-- `clinicalStatus` is set to "`active`"
+!!! tip "Sensible Defaults"
+ `clinicalStatus` is set to "`active`"
```python
from healthchain.fhir import create_condition
@@ -79,53 +84,44 @@ condition = create_condition(
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"
+??? example "Example Output JSON"
+ ```json
+ {
+ "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"
+ }
}
-}
-```
-
+ ```
+
+---
### 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)
+!!! note "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`"
+!!! tip "Sensible Defaults"
+ `status` is set to "`recorded`"
```python
from healthchain.fhir import create_medication_statement
@@ -142,47 +138,38 @@ medication = create_medication_statement(
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"
- }]
+??? example "Example Output JSON"
+ ```json
+ {
+ "resourceType": "MedicationStatement",
+ "id": "hc-86a26eba-63f9-4017-b7b2-5b36f9bad5f1",
+ "status": "recorded",
+ "medication": {
+ "concept": {
+ "coding": [{
+ "system": "http://www.nlm.nih.gov/research/umls/rxnorm",
+ "code": "1049221",
+ "display": "Acetaminophen 325 MG Oral Tablet"
+ }]
+ }
+ },
+ "subject": {
+ "reference": "Patient/123"
}
- },
-
- // 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:**
+!!! note "Required fields"
+ - [patient](https://www.hl7.org/fhir/allergyintolerance-definitions.html#AllergyIntolerance.patient)
-- None
+!!! tip "Sensible Defaults"
+ None (besides the auto-generated id)
```python
from healthchain.fhir import create_allergy_intolerance
@@ -199,46 +186,39 @@ allergy = create_allergy_intolerance(
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"
+??? example "Example Output JSON"
+ ```json
+ {
+ "resourceType": "AllergyIntolerance",
+ "id": "hc-65edab39-d90b-477b-bdb5-a173b21efd44",
+ "code": {
+ "coding": [{
+ "system": "http://snomed.info/sct",
+ "code": "418038007",
+ "display": "Propensity to adverse reactions to substance"
+ }]
+ },
+ "patient": {
+ "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:**
+!!! note "Required fields"
+ - [type](https://www.hl7.org/fhir/documentreference-definitions.html#DocumentReference.type)
-- [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`"
+!!! tip "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
@@ -254,39 +234,31 @@ doc_ref = create_document_reference(
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
- }
- }]
-}
-```
+??? example "Example Output JSON"
+ ```json
+ {
+ "resourceType": "DocumentReference",
+ "id": "hc-60fcfdad-9617-4557-88d8-8c8db9b9fe70",
+ "status": "current",
+ "date": "2025-02-28T14:55:33+00:00",
+ "description": "A simple text document",
+ "content": [{
+ "attachment": {
+ "contentType": "text/plain",
+ "data": "SGVsbG8gV29ybGQ=",
+ "title": "Attachment created by HealthChain",
+ "creation": "2025-02-28T14:55:33+00:00"
+ }
+ }]
+ }
+ ```
-
-View decoded content
+ ??? example "View Decoded Content"
+ ```text
+ Hello World
+ ```
-```text
-Hello World
-```
-
-
+---
## Utilities
@@ -310,48 +282,39 @@ set_problem_list_item_category(problem_list_item)
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"
+??? example "Example Output JSON"
+ ```json
+ {
+ "resourceType": "Condition",
+ "id": "hc-3d5f62e7-729b-4da1-936c-e8e16e5a9358",
+ "clinicalStatus": {
+ "coding": [{
+ "system": "http://terminology.hl7.org/CodeSystem/condition-clinical",
+ "code": "active",
+ "display": "Active"
+ }]
+ },
+ "category": [{
+ "coding": [{
+ "system": "http://terminology.hl7.org/CodeSystem/condition-category",
+ "code": "problem-list-item",
+ "display": "Problem List Item"
+ }]
+ }],
+ "code": {
+ "coding": [{
+ "system": "http://snomed.info/sct",
+ "code": "38341003",
+ "display": "Hypertension"
+ }]
+ },
+ "subject": {
+ "reference": "Patient/123"
+ }
}
-}
-```
-
+ ```
+
+---
### read_content_attachment()
@@ -374,6 +337,8 @@ attachments = read_content_attachment(document_reference)
# ]
```
+---
+
## 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.
@@ -385,17 +350,17 @@ The bundle operations make it easy to:
- 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)
+!!! note "Required field"
+ - [type](https://www.hl7.org/fhir/bundle-definitions.html#Bundle.type)
-**Sensible defaults:**
-
-- `type` is set to "`collection`"
+!!! tip "Sensible Defaults"
+ `type` is set to "`collection`"
```python
from healthchain.fhir import create_bundle
@@ -407,17 +372,16 @@ bundle = create_bundle(bundle_type="collection")
print(bundle.model_dump())
```
-
-View output JSON
+??? example "Example Output JSON"
+ ```json
+ {
+ "resourceType": "Bundle",
+ "type": "collection",
+ "entry": []
+ }
+ ```
-```json
-{
- "resourceType": "Bundle",
- "type": "collection", // [Required] Type of bundle
- "entry": [] // Empty list of resources
-}
-```
-
+---
### add_resource()
@@ -441,56 +405,45 @@ add_resource(bundle, condition)
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"
+??? example "Example Output 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"
+ }
}
- }
- }]
-}
-```
-
-
-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 |
+ ??? info "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()
@@ -506,11 +459,12 @@ conditions = get_resources(bundle, "Condition")
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).
@@ -539,60 +493,141 @@ set_resources(bundle, conditions, "Condition", replace=True)
set_resources(bundle, conditions, "Condition", replace=False)
```
-
-View example bundle with multiple conditions
+??? 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"}
+ }
+ }
+ ]
+ }
+ ```
+
+---
+
+### merge_bundles()
+
+Merges multiple FHIR [**Bundle**](https://www.hl7.org/fhir/bundle.html) resources into a single bundle.
+
+- Resources from each bundle are combined into a single output bundle of `type: collection`.
+- All entries from all input bundles will appear in the resulting bundle's `entry` array.
+- If bundles have the same resource (e.g. matching `id` or identical resources), they will *all* be included unless you handle duplicates before/after calling `merge_bundles`.
+
+```python
+from healthchain.fhir import merge_bundles, create_bundle, create_condition
+
+# Create two bundles with different resources
+bundle1 = create_bundle()
+add_resource(bundle1, create_condition(
+ subject="Patient/123", code="38341003", display="Hypertension"
+))
+bundle2 = create_bundle()
+add_resource(bundle2, create_condition(
+ subject="Patient/123", code="44054006", display="Diabetes"
+))
+
+# Merge the bundles together
+merged = merge_bundles(bundle1, bundle2)
+
+# Output the merged bundle
+print(merged.model_dump())
+```
-```json
-{
- "resourceType": "Bundle",
- "type": "collection",
- "entry": [
+??? example "Example Output 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-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": "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
@@ -654,88 +689,85 @@ 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": {
+??? example "Complete Bundle Example Output"
+ ```json
+ {
+ "resourceType": "Bundle",
+ "type": "collection",
+ "entry": [
+ {
+ "resource": {
+ "resourceType": "Condition",
+ "id": "hc-3117bdce-bfab-4d71-968b-1ded900882ca",
+ "clinicalStatus": {
"coding": [{
- "system": "http://www.nlm.nih.gov/research/umls/rxnorm",
- "code": "1049221",
- "display": "Acetaminophen 325 MG"
+ "system": "http://terminology.hl7.org/CodeSystem/condition-clinical",
+ "code": "active",
+ "display": "Active"
}]
- }
- },
- "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"}
+ },
+ "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/docs/cookbook/interop/basic_conversion.md b/docs/tutorials/interop/basic_conversion.md
similarity index 100%
rename from docs/cookbook/interop/basic_conversion.md
rename to docs/tutorials/interop/basic_conversion.md
diff --git a/healthchain/fhir/__init__.py b/healthchain/fhir/__init__.py
index 82e68a2e..96a85f40 100644
--- a/healthchain/fhir/__init__.py
+++ b/healthchain/fhir/__init__.py
@@ -6,11 +6,13 @@
create_allergy_intolerance,
create_single_codeable_concept,
create_single_reaction,
- set_problem_list_item_category,
+ set_condition_category,
read_content_attachment,
create_document_reference,
create_single_attachment,
create_resource_from_dict,
+ add_provenance_metadata,
+ add_coding_to_codeable_concept,
)
from healthchain.fhir.bundle_helpers import (
@@ -18,6 +20,8 @@
add_resource,
get_resources,
set_resources,
+ merge_bundles,
+ extract_resources,
)
__all__ = [
@@ -27,14 +31,19 @@
"create_allergy_intolerance",
"create_single_codeable_concept",
"create_single_reaction",
- "set_problem_list_item_category",
+ "set_condition_category",
"read_content_attachment",
"create_document_reference",
"create_single_attachment",
"create_resource_from_dict",
+ # Resource modification
+ "add_provenance_metadata",
+ "add_coding_to_codeable_concept",
# Bundle operations
"create_bundle",
"add_resource",
"get_resources",
"set_resources",
+ "merge_bundles",
+ "extract_resources",
]
diff --git a/healthchain/fhir/bundle_helpers.py b/healthchain/fhir/bundle_helpers.py
index 8e0f9e34..14ac08e4 100644
--- a/healthchain/fhir/bundle_helpers.py
+++ b/healthchain/fhir/bundle_helpers.py
@@ -1,22 +1,11 @@
"""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")
+Patterns:
+- create_*(): create a new FHIR bundle
+- add_*(): add a resource to a bundle
+- get_*(): get resources from a bundle
+- set_*(): set resources in a bundle
+- merge_*(): merge multiple bundles into a single bundle
+- extract_*(): extract resources from a bundle
"""
from typing import List, Type, TypeVar, Optional, Union
@@ -165,3 +154,105 @@ def set_resources(
f"got {type(resource).__name__}"
)
add_resource(bundle, resource)
+
+
+def merge_bundles(
+ bundles: List[Bundle],
+ bundle_type: str = "collection",
+ deduplicate: bool = False,
+ dedupe_key: str = "id",
+) -> Bundle:
+ """Merge multiple FHIR bundles into a single bundle.
+
+ Combines entries from multiple bundles while preserving resource metadata.
+ Useful for aggregating search results from multiple FHIR sources.
+
+ Args:
+ bundles: List of bundles to merge
+ bundle_type: Type for the merged bundle (default: "collection")
+ deduplicate: If True, remove duplicate resources based on dedupe_key
+ dedupe_key: Resource attribute to use for deduplication (default: "id")
+
+ Returns:
+ A new bundle containing all entries from input bundles
+
+ Example:
+ >>> # Merge search results from multiple sources
+ >>> epic_bundle = gateway.search(Condition, {"patient": "123"}, "epic")
+ >>> cerner_bundle = gateway.search(Condition, {"patient": "123"}, "cerner")
+ >>> merged = merge_bundles([epic_bundle, cerner_bundle], deduplicate=True)
+ >>>
+ >>> # Use in Document workflow
+ >>> doc = Document(data=merged)
+ >>> doc.fhir.bundle # Contains all conditions from both sources
+ """
+ merged = create_bundle(bundle_type=bundle_type)
+
+ if deduplicate:
+ # Track seen resources by dedupe_key to avoid duplicates
+ seen_keys = set()
+
+ for bundle in bundles:
+ if not bundle or not bundle.entry:
+ continue
+
+ for entry in bundle.entry:
+ if not entry.resource:
+ continue
+
+ # Get the deduplication key value
+ key_value = getattr(entry.resource, dedupe_key, None)
+
+ # Skip if we've seen this key before
+ if key_value and key_value in seen_keys:
+ continue
+
+ # Add to merged bundle and track the key
+ add_resource(merged, entry.resource, entry.fullUrl)
+ if key_value:
+ seen_keys.add(key_value)
+ else:
+ # No deduplication - just merge all entries
+ for bundle in bundles:
+ if not bundle or not bundle.entry:
+ continue
+
+ for entry in bundle.entry:
+ if entry.resource:
+ add_resource(merged, entry.resource, entry.fullUrl)
+
+ return merged
+
+
+def extract_resources(
+ bundle: Bundle, resource_type: Union[str, Type[Resource]]
+) -> List[Resource]:
+ """Remove resources of a given type from a bundle and return them.
+
+ Useful for extracting and separating specific resource types (e.g., OperationOutcome)
+ from a FHIR Bundle, modifying the bundle in place.
+
+ Args:
+ bundle: The FHIR Bundle to process (modified in place)
+ resource_type: The FHIR resource class or string name to extract (e.g., OperationOutcome or "OperationOutcome")
+
+ Returns:
+ List[Resource]: All resources of the specified type that were in the bundle
+ """
+ if not bundle or not bundle.entry:
+ return []
+
+ type_class = get_resource_type(resource_type)
+
+ extracted: List[Resource] = []
+ remaining_entries: List[BundleEntry] = []
+
+ for entry in bundle.entry:
+ resource = entry.resource
+ if isinstance(resource, type_class):
+ extracted.append(resource)
+ continue
+ remaining_entries.append(entry)
+
+ bundle.entry = remaining_entries
+ return extracted
diff --git a/healthchain/fhir/helpers.py b/healthchain/fhir/helpers.py
index 444a6ea5..20f8c106 100644
--- a/healthchain/fhir/helpers.py
+++ b/healthchain/fhir/helpers.py
@@ -1,4 +1,10 @@
-"""Convenience functions for creating minimal FHIR resources."""
+"""Convenience functions for creating minimal FHIR resources.
+Patterns:
+- create_*(): create a new FHIR resource with sensible defaults - useful for dev, use with caution
+- add_*(): add data to resources with list fields safely (e.g. coding)
+- set_*(): set the field of specific resources with soft validation (e.g. category)
+- read_*(): return a human readable format of the data in a resource (e.g. attachments)
+"""
import logging
import base64
@@ -17,6 +23,8 @@
from fhir.resources.attachment import Attachment
from fhir.resources.resource import Resource
from fhir.resources.reference import Reference
+from fhir.resources.meta import Meta
+
logger = logging.getLogger(__name__)
@@ -145,29 +153,6 @@ def create_single_attachment(
)
-def set_problem_list_item_category(condition: Condition) -> Condition:
- """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",
- display="Problem List Item",
- system="http://terminology.hl7.org/CodeSystem/condition-category",
- )
- ]
- return condition
-
-
def create_condition(
subject: str,
clinical_status: str = "active",
@@ -327,6 +312,133 @@ def create_document_reference(
return document_reference
+def set_condition_category(condition: Condition, category: str) -> Condition:
+ """
+ Set the category of a FHIR Condition to either 'problem-list-item' or 'encounter-diagnosis'.
+
+ Args:
+ condition: The FHIR Condition resource to modify
+ category: The category to set. Must be 'problem-list-item' or 'encounter-diagnosis'.
+
+ Returns:
+ Condition: The modified FHIR Condition resource with the specified category set
+
+ Raises:
+ ValueError: If the category is not one of the allowed values.
+ """
+ allowed_categories = {
+ "problem-list-item": {
+ "code": "problem-list-item",
+ "display": "Problem List Item",
+ },
+ "encounter-diagnosis": {
+ "code": "encounter-diagnosis",
+ "display": "Encounter Diagnosis",
+ },
+ }
+ if category not in allowed_categories:
+ raise ValueError(
+ f"Invalid category '{category}'. Must be one of: {list(allowed_categories.keys())}"
+ )
+
+ cat_info = allowed_categories[category]
+ condition.category = [
+ create_single_codeable_concept(
+ code=cat_info["code"],
+ display=cat_info["display"],
+ system="http://terminology.hl7.org/CodeSystem/condition-category",
+ )
+ ]
+ return condition
+
+
+def add_provenance_metadata(
+ resource: Resource,
+ source: str,
+ tag_code: Optional[str] = None,
+ tag_display: Optional[str] = None,
+) -> Resource:
+ """Add provenance metadata to a FHIR resource.
+
+ Adds source system identifier, timestamp, and optional processing tags to track
+ data lineage and transformations for audit trails.
+
+ Args:
+ resource: The FHIR resource to annotate
+ source: Name of the source system (e.g., "epic", "cerner")
+ tag_code: Optional tag code for processing operations (e.g., "aggregated", "deduplicated")
+ tag_display: Optional display text for the tag
+
+ Returns:
+ Resource: The resource with added provenance metadata
+
+ Example:
+ >>> condition = create_condition(subject="Patient/123", code="E11.9")
+ >>> condition = add_provenance_metadata(condition, "epic", "aggregated", "Aggregated from source")
+ """
+ if not resource.meta:
+ resource.meta = Meta()
+
+ # Add source system identifier
+ resource.meta.source = f"urn:healthchain:source:{source}"
+
+ # Update timestamp
+ resource.meta.lastUpdated = datetime.datetime.now(datetime.timezone.utc).isoformat()
+
+ # Add processing tag if provided
+ if tag_code:
+ if not resource.meta.tag:
+ resource.meta.tag = []
+
+ resource.meta.tag.append(
+ Coding(
+ system="https://dotimplement.github.io/HealthChain/fhir/tags",
+ code=tag_code,
+ display=tag_display or tag_code,
+ )
+ )
+
+ return resource
+
+
+def add_coding_to_codeable_concept(
+ codeable_concept: CodeableConcept,
+ code: str,
+ system: str,
+ display: Optional[str] = None,
+) -> CodeableConcept:
+ """Add a coding to an existing CodeableConcept.
+
+ Useful for adding standardized codes (e.g., SNOMED CT) to resources that already
+ have codes from other systems (e.g., ICD-10).
+
+ Args:
+ codeable_concept: The CodeableConcept to add coding to
+ code: The code value from the code system
+ system: The code system URI
+ display: Optional display text for the code
+
+ Returns:
+ CodeableConcept: The updated CodeableConcept with the new coding added
+
+ Example:
+ >>> # Add SNOMED CT code to a condition that has ICD-10
+ >>> condition_code = condition.code
+ >>> condition_code = add_coding_to_codeable_concept(
+ ... condition_code,
+ ... code="44054006",
+ ... system="http://snomed.info/sct",
+ ... display="Type 2 diabetes mellitus"
+ ... )
+ """
+ if not codeable_concept.coding:
+ codeable_concept.coding = []
+
+ codeable_concept.coding.append(Coding(system=system, code=code, display=display))
+
+ return codeable_concept
+
+
def read_content_attachment(
document_reference: DocumentReference,
include_data: bool = True,
diff --git a/healthchain/gateway/clients/fhir/base.py b/healthchain/gateway/clients/fhir/base.py
index 45bd6c2f..99fadea5 100644
--- a/healthchain/gateway/clients/fhir/base.py
+++ b/healthchain/gateway/clients/fhir/base.py
@@ -32,27 +32,48 @@ def __init__(
class FHIRAuthConfig(BaseModel):
"""Configuration for FHIR server authentication."""
- # OAuth2 settings
- client_id: str
+ # Connection settings
+ base_url: str
+ timeout: int = 30
+ verify_ssl: bool = True
+
+ # OAuth2 settings (optional - for authenticated endpoints)
+ client_id: Optional[str] = None
client_secret: Optional[str] = None # Client secret string for standard flow
client_secret_path: Optional[str] = (
None # Path to private key file for JWT assertion
)
- token_url: str
+ token_url: Optional[str] = None
scope: Optional[str] = "system/*.read system/*.write"
audience: Optional[str] = None
use_jwt_assertion: bool = False # Use JWT client assertion (Epic/SMART style)
- # Connection settings
- base_url: str
- timeout: int = 30
- verify_ssl: bool = True
+ @property
+ def requires_auth(self) -> bool:
+ """
+ Auto-detect if authentication is needed based on parameters.
+
+ Returns:
+ True if any auth parameters are present, False for public endpoints
+ """
+ return bool(self.client_id or self.token_url)
def model_post_init(self, __context) -> None:
- """Validate that exactly one of client_secret or client_secret_path is provided."""
+ """Validate auth configuration if auth parameters are present."""
+ # If no auth params, this is a public endpoint - skip validation
+ if not self.requires_auth:
+ return
+
+ # If auth is required, validate configuration
+ if not self.client_id:
+ raise ValueError("client_id is required when using authentication")
+
+ if not self.token_url:
+ raise ValueError("token_url is required when using authentication")
+
if not self.client_secret and not self.client_secret_path:
raise ValueError(
- "Either client_secret or client_secret_path must be provided"
+ "Either client_secret or client_secret_path must be provided for authentication"
)
if self.client_secret and self.client_secret_path:
@@ -328,17 +349,29 @@ def parse_fhir_auth_connection_string(connection_string: str) -> FHIRAuthConfig:
"""
Parse a FHIR connection string into authentication configuration.
- Format: fhir://hostname:port/path?client_id=xxx&client_secret=xxx&token_url=xxx&scope=xxx
- Or for JWT: fhir://hostname:port/path?client_id=xxx&client_secret_path=xxx&token_url=xxx&use_jwt_assertion=true
+ Supports both authenticated and unauthenticated (public) endpoints:
+ - Authenticated: fhir://hostname/path?client_id=xxx&client_secret=xxx&token_url=xxx
+ - Public: fhir://hostname/path (no auth parameters)
Args:
- connection_string: FHIR connection string with OAuth2 credentials
+ connection_string: FHIR connection string with optional OAuth2 credentials
Returns:
FHIRAuthConfig with parsed settings
Raises:
- ValueError: If connection string is invalid or missing required parameters
+ ValueError: If connection string is invalid
+
+ Examples:
+ # Authenticated endpoint
+ config = parse_fhir_auth_connection_string(
+ "fhir://epic.com/api/FHIR/R4?client_id=app&client_secret=secret&token_url=https://epic.com/token"
+ )
+
+ # Public endpoint (no auth)
+ config = parse_fhir_auth_connection_string(
+ "fhir://fhir-open.cerner.com/r4/ec2458f2-1e24-41c8-b71b-0e701af7583d"
+ )
"""
import urllib.parse
@@ -348,35 +381,30 @@ def parse_fhir_auth_connection_string(connection_string: str) -> FHIRAuthConfig:
parsed = urllib.parse.urlparse(connection_string)
params = dict(urllib.parse.parse_qsl(parsed.query))
- # Validate required parameters
- required_params = ["client_id", "token_url"]
- missing_params = [param for param in required_params if param not in params]
-
- if missing_params:
- raise ValueError(f"Missing required parameters: {missing_params}")
+ # Build base URL
+ base_url = f"https://{parsed.netloc}{parsed.path}"
- # Check that exactly one of client_secret or client_secret_path is provided
+ # Auto-detect if auth is needed based on parameter presence
+ has_client_id = "client_id" in params
+ has_token_url = "token_url" in params
has_secret = "client_secret" in params
has_secret_path = "client_secret_path" in params
- if not has_secret and not has_secret_path:
- raise ValueError(
- "Either 'client_secret' or 'client_secret_path' parameter must be provided"
- )
-
- if has_secret and has_secret_path:
- raise ValueError(
- "Cannot provide both 'client_secret' and 'client_secret_path' parameters"
+ # If no auth params at all, this is a public endpoint
+ if not any([has_client_id, has_token_url, has_secret, has_secret_path]):
+ return FHIRAuthConfig(
+ base_url=base_url,
+ timeout=int(params.get("timeout", 30)),
+ verify_ssl=params.get("verify_ssl", "true").lower() == "true",
)
- # Build base URL
- base_url = f"https://{parsed.netloc}{parsed.path}"
-
+ # If any auth param is present, validate complete auth config
+ # FHIRAuthConfig.model_post_init will handle validation
return FHIRAuthConfig(
- client_id=params["client_id"],
+ client_id=params.get("client_id"),
client_secret=params.get("client_secret"),
client_secret_path=params.get("client_secret_path"),
- token_url=params["token_url"],
+ token_url=params.get("token_url"),
scope=params.get("scope", "system/*.read system/*.write"),
audience=params.get("audience"),
base_url=base_url,
diff --git a/healthchain/gateway/clients/fhir/sync/client.py b/healthchain/gateway/clients/fhir/sync/client.py
index 6b0ff16d..5d92d41c 100644
--- a/healthchain/gateway/clients/fhir/sync/client.py
+++ b/healthchain/gateway/clients/fhir/sync/client.py
@@ -30,15 +30,24 @@ def __init__(
**kwargs,
):
"""
- Initialize the FHIR client with OAuth2.0 authentication.
+ Initialize the FHIR client with optional OAuth2.0 authentication.
+
+ Supports both authenticated and public FHIR endpoints.
+ Authentication is auto-detected based on auth_config.requires_auth.
Args:
- auth_config: OAuth2.0 authentication configuration
+ auth_config: Authentication configuration (auth optional for public endpoints)
limits: httpx connection limits for pooling
**kwargs: Additional parameters passed to httpx.Client
"""
super().__init__(auth_config)
- self.token_manager = OAuth2TokenManager(auth_config.to_oauth2_config())
+
+ # Only create token manager if authentication is required
+ self.token_manager = (
+ OAuth2TokenManager(auth_config.to_oauth2_config())
+ if auth_config.requires_auth
+ else None
+ )
# Create httpx client with connection pooling and additional kwargs
client_kwargs = {"timeout": self.timeout, "verify": self.verify_ssl}
@@ -63,10 +72,19 @@ def close(self):
self.client.close()
def _get_headers(self) -> Dict[str, str]:
- """Get headers with fresh OAuth2.0 token."""
+ """
+ Get headers with optional OAuth2.0 token.
+
+ For authenticated endpoints, includes Authorization header.
+ For public endpoints, returns base headers only.
+ """
headers = self.base_headers.copy()
- token = self.token_manager.get_access_token()
- headers["Authorization"] = f"Bearer {token}"
+
+ # Only add authorization header if authentication is required
+ if self.token_manager is not None:
+ token = self.token_manager.get_access_token()
+ headers["Authorization"] = f"Bearer {token}"
+
return headers
def capabilities(self) -> CapabilityStatement:
diff --git a/healthchain/gateway/fhir/aio.py b/healthchain/gateway/fhir/aio.py
index 8cebf24e..5a941832 100644
--- a/healthchain/gateway/fhir/aio.py
+++ b/healthchain/gateway/fhir/aio.py
@@ -12,6 +12,7 @@
from healthchain.gateway.fhir.errors import FHIRErrorHandler
from healthchain.gateway.fhir.base import BaseFHIRGateway
from healthchain.gateway.events.fhir import create_fhir_event
+from healthchain.fhir import add_provenance_metadata
logger = logging.getLogger(__name__)
@@ -170,6 +171,8 @@ async def search(
resource_type: Type[Resource],
params: Dict[str, Any] = None,
source: str = None,
+ add_provenance: bool = False,
+ provenance_tag: str = None,
) -> Bundle:
"""
Search for FHIR resources.
@@ -178,6 +181,8 @@ async def search(
resource_type: The FHIR resource type class
params: Search parameters (e.g., {"name": "Smith", "active": "true"})
source: Source name to search in (uses first available if None)
+ add_provenance: If True, automatically add provenance metadata to resources
+ provenance_tag: Optional tag code for provenance (e.g., "aggregated", "transformed")
Returns:
Bundle containing search results
@@ -187,11 +192,17 @@ async def search(
FHIRConnectionError: If connection fails
Example:
- # Search for patients by name
+ # Basic search
bundle = await fhir_gateway.search(Patient, {"name": "Smith"}, "epic")
- for entry in bundle.entry or []:
- patient = entry.resource
- print(f"Found patient: {patient.name[0].family}")
+
+ # Search with automatic provenance
+ bundle = await fhir_gateway.search(
+ Condition,
+ {"patient": "123"},
+ "epic",
+ add_provenance=True,
+ provenance_tag="aggregated"
+ )
"""
bundle = await self._execute_with_client(
"search",
@@ -201,15 +212,26 @@ async def search(
client_kwargs={"params": params},
)
+ if add_provenance and bundle.entry:
+ source_name = source or next(iter(self.connection_manager.sources.keys()))
+ for entry in bundle.entry:
+ if entry.resource:
+ entry.resource = add_provenance_metadata(
+ entry.resource,
+ source_name,
+ provenance_tag,
+ provenance_tag.capitalize() if provenance_tag else None,
+ )
+
# Emit search event with result count
type_name = resource_type.__resource_type__
event_data = {
- "params": params,
"result_count": len(bundle.entry) if bundle.entry else 0,
}
+ # Do not include full params.
self._emit_fhir_event("search", type_name, None, event_data)
- logger.debug(
- f"Searched {type_name} with params {params}, found {len(bundle.entry) if bundle.entry else 0} results"
+ logger.info(
+ f"FHIR operation: search on {type_name}, found {event_data['result_count']} results"
)
return bundle
diff --git a/healthchain/gateway/fhir/base.py b/healthchain/gateway/fhir/base.py
index dcb0f3eb..845e3a59 100644
--- a/healthchain/gateway/fhir/base.py
+++ b/healthchain/gateway/fhir/base.py
@@ -451,14 +451,14 @@ def add_source(self, name: str, connection_string: str) -> None:
def aggregate(self, resource_type: Type[Resource]):
"""
- Decorator for custom aggregation functions.
+ Decorator for custom aggregation functions. Can return the same resource type or a bundle of resources.
Args:
resource_type: The FHIR resource type class that this handler aggregates
Example:
@fhir_gateway.aggregate(Patient)
- def aggregate_patients(id: str = None, sources: List[str] = None) -> List[Patient]:
+ def aggregate_patients(id: str = None, sources: List[str] = None) -> Patient | Bundle:
# Handler implementation
pass
"""
@@ -471,7 +471,7 @@ def decorator(handler: Callable):
def transform(self, resource_type: Type[Resource]):
"""
- Decorator for custom transformation functions.
+ Decorator for custom transformation functions. Must return the same resource type.
Args:
resource_type: The FHIR resource type class that this handler transforms
diff --git a/healthchain/gateway/fhir/sync.py b/healthchain/gateway/fhir/sync.py
index 0c01d9d5..96a7b7d4 100644
--- a/healthchain/gateway/fhir/sync.py
+++ b/healthchain/gateway/fhir/sync.py
@@ -10,6 +10,7 @@
from healthchain.gateway.clients.fhir.sync.connection import FHIRConnectionManager
from healthchain.gateway.fhir.base import BaseFHIRGateway
from healthchain.gateway.fhir.errors import FHIRErrorHandler
+from healthchain.fhir import add_provenance_metadata
logger = logging.getLogger(__name__)
@@ -232,6 +233,8 @@ def search(
resource_type: Type[Resource],
params: Dict[str, Any] = None,
source: str = None,
+ add_provenance: bool = False,
+ provenance_tag: str = None,
) -> Bundle:
"""
Search for FHIR resources (sync version).
@@ -240,12 +243,24 @@ def search(
resource_type: The FHIR resource type class
params: Search parameters (e.g., {"name": "Smith", "active": "true"})
source: Source name to search in (uses first available if None)
+ add_provenance: If True, automatically add provenance metadata to resources
+ provenance_tag: Optional tag code for provenance (e.g., "aggregated", "transformed")
Returns:
Bundle containing search results
Example:
+ # Basic search
bundle = gateway.search(Patient, {"name": "Smith"}, "epic")
+
+ # Search with automatic provenance
+ bundle = gateway.search(
+ Condition,
+ {"patient": "123"},
+ "epic",
+ add_provenance=True,
+ provenance_tag="aggregated"
+ )
"""
bundle = self._execute_with_client(
@@ -254,10 +269,22 @@ def search(
resource_type=resource_type,
client_args=(resource_type, params),
)
+
+ # Add provenance metadata if requested
+ if add_provenance and bundle.entry:
+ source_name = source or next(iter(self.connection_manager.sources.keys()))
+ for entry in bundle.entry:
+ if entry.resource:
+ entry.resource = add_provenance_metadata(
+ entry.resource,
+ source_name,
+ provenance_tag,
+ provenance_tag.capitalize() if provenance_tag else None,
+ )
+
type_name = resource_type.__resource_type__
- result_count = len(bundle.entry) if bundle.entry else 0
logger.info(
- f"FHIR operation: search on {type_name} with params {params}, found {result_count} results"
+ f"FHIR operation: search on {type_name}, found {len(bundle.entry) if bundle.entry else 0} results"
)
return bundle
diff --git a/healthchain/io/adapters/cdaadapter.py b/healthchain/io/adapters/cdaadapter.py
index cb56e5af..8271f52e 100644
--- a/healthchain/io/adapters/cdaadapter.py
+++ b/healthchain/io/adapters/cdaadapter.py
@@ -8,7 +8,7 @@
from healthchain.models.responses.cdaresponse import CdaResponse
from healthchain.fhir import (
create_bundle,
- set_problem_list_item_category,
+ set_condition_category,
create_document_reference,
read_content_attachment,
)
@@ -111,7 +111,7 @@ def parse(self, cda_request: CdaRequest) -> Document:
for resource in fhir_resources:
if isinstance(resource, Condition):
problem_list.append(resource)
- set_problem_list_item_category(resource)
+ set_condition_category(resource, "problem-list-item")
elif isinstance(resource, MedicationStatement):
medication_list.append(resource)
elif isinstance(resource, AllergyIntolerance):
diff --git a/healthchain/io/containers/document.py b/healthchain/io/containers/document.py
index 1c8ee780..8ee1dfd5 100644
--- a/healthchain/io/containers/document.py
+++ b/healthchain/io/containers/document.py
@@ -1,4 +1,5 @@
import logging
+
from dataclasses import dataclass, field
from typing import Any, Dict, Iterator, List, Optional, Union
from uuid import uuid4
@@ -13,17 +14,22 @@
from fhir.resources.resource import Resource
from fhir.resources.reference import Reference
from fhir.resources.documentreference import DocumentReferenceRelatesTo
+from fhir.resources.operationoutcome import OperationOutcome
+from fhir.resources.provenance import Provenance
+from fhir.resources.patient import Patient
from healthchain.io.containers.base import BaseDocument
from healthchain.models.responses import Action, Card
from healthchain.fhir import (
create_bundle,
+ add_resource,
get_resources,
set_resources,
+ extract_resources,
create_single_codeable_concept,
read_content_attachment,
create_condition,
- set_problem_list_item_category,
+ set_condition_category,
)
logger = logging.getLogger(__name__)
@@ -216,6 +222,8 @@ class FhirData:
such as a problem list, medication list, and allergy list.
These collections are accessible as properties of the class instance.
+ TODO: make problem, meds, allergy lists configurable
+
Properties:
bundle: The FHIR bundle containing resources
prefetch_resources: Dictionary of CDS Hooks prefetch resources
@@ -237,6 +245,8 @@ class FhirData:
_prefetch_resources: Optional[Dict[str, Resource]] = None
_bundle: Optional[Bundle] = None
+ _operation_outcomes: List[OperationOutcome] = field(default_factory=list)
+ _provenances: List[Provenance] = field(default_factory=list)
@property
def bundle(self) -> Optional[Bundle]:
@@ -261,6 +271,44 @@ def prefetch_resources(self, resources: Dict[str, Resource]):
"""Sets the prefetch FHIR resources from CDS service requests."""
self._prefetch_resources = resources
+ @property
+ def operation_outcomes(self) -> List[OperationOutcome]:
+ """Get extracted OperationOutcome resources separated from the bundle."""
+ return self._operation_outcomes
+
+ @operation_outcomes.setter
+ def operation_outcomes(self, outcomes: List[OperationOutcome]) -> None:
+ self._operation_outcomes = outcomes or []
+
+ @property
+ def provenances(self) -> List[Provenance]:
+ """Get extracted Provenance resources separated from the bundle."""
+ return self._provenances
+
+ @provenances.setter
+ def provenances(self, provenances: List[Provenance]) -> None:
+ self._provenances = provenances or []
+
+ @property
+ def patient(self) -> Optional[Patient]:
+ """Get the first Patient resource from the bundle (convenience accessor).
+
+ Returns None if no Patient resources are present in the bundle.
+ For bundles with multiple patients, use the patients property instead.
+ """
+ patients = self.get_resources("Patient")
+ return patients[0] if patients else None
+
+ @property
+ def patients(self) -> List[Patient]:
+ """Get all Patient resources from the bundle.
+
+ Most bundles contain a single patient, but some queries (e.g., family history,
+ population queries) may return multiple patients. This property provides access
+ to all Patient resources without removing them from the bundle.
+ """
+ return self.get_resources("Patient")
+
@property
def problem_list(self) -> List[Condition]:
"""Get problem list from the bundle.
@@ -574,40 +622,40 @@ def actions(self, actions: Union[List[Action], List[Dict[str, Any]]]) -> None:
@dataclass
class Document(BaseDocument):
"""
- A document container that extends BaseDocument with rich annotation capabilities.
+ Main document container for processing textual and clinical data in HealthChain.
- This class extends BaseDocument to handle textual document data and annotations from
- various sources. It serves as the main data structure passed through processing pipelines,
- accumulating annotations and analysis results at each step.
+ The Document class is the primary structure used throughout annotation and analytics
+ pipelines, accumulating transformations, extractions, and results from each stage. It
+ seamlessly integrates raw text, NLP annotations, FHIR resources, clinical decision
+ support (CDS) results, and ML model outputs in one object.
- The Document class provides a comprehensive representation that can include:
- - Raw text and basic tokenization
- - NLP annotations (tokens, entities, embeddings, spaCy docs)
- - 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)
+ Features:
+ - Accepts text, FHIR Bundles/resources, or lists of FHIR resources as input.
+ - Provides basic tokenization and supports integration with NLP models (spaCy, transformers).
+ - Stores and manipulates clinical FHIR data via the .fhir property (access to bundles, problem lists, meds, allergies, etc.).
+ - Encapsulates CDS Hooks-style decision support cards and suggested actions via the .cds property.
+ - Stores outputs from external ML/LLM models: HuggingFace, LangChain, etc.
Attributes:
- nlp (NlpAnnotations): Container for NLP-related annotations like tokens and entities
- fhir (FhirData): Container for FHIR resources and CDS context
- cds (CdsAnnotations): Container for clinical decision support results
- models (ModelOutputs): Container for ML model outputs
-
- Example:
+ nlp (NlpAnnotations): NLP output (tokens, entities, embeddings, spaCy doc)
+ fhir (FhirData): FHIR resources and context (problem list, medication, allergy, etc.)
+ cds (CdsAnnotations): Clinical decision support (cards and actions)
+ models (ModelOutputs): Results from ML/LLM models (HuggingFace, LangChain, etc.)
+ text (str): The text content of the document (if available).
+ data: The original input supplied (raw text, Bundle, resource, or list of resources)
+
+ Usage example:
>>> doc = Document(data="Patient has hypertension")
- >>> # Add set continuity of care lists
+ >>> doc.nlp._tokens
+ ['Patient', 'has', 'hypertension']
>>> 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(...)]
+ >>> doc.models.huggingface_results = ...
+ >>> for token in doc:
+ ... print(token)
Inherits from:
- BaseDocument: Provides base document functionality and raw text storage
+ BaseDocument
"""
_nlp: NlpAnnotations = field(default_factory=NlpAnnotations)
@@ -632,15 +680,54 @@ def models(self) -> ModelOutputs:
return self._models
def __post_init__(self):
- """Initialize the document with basic tokenization if needed."""
+ """
+ Post-initialization setup to process textual or FHIR data.
+
+ - If input data is a FHIR Bundle, stores it and extracts OperationOutcome and Provenance resources.
+ - If input data is a list of FHIR resources, wraps them in a Bundle.
+ - For text input, sets .text field accordingly.
+ - Performs basic whitespace tokenization if necessary.
+ """
super().__post_init__()
- self.text = self.data
- if not self._nlp._tokens:
+
+ # Handle FHIR Bundle data
+ if isinstance(self.data, Bundle):
+ self._fhir._bundle = self.data
+
+ # Extract OperationOutcome resources (operation results/errors)
+ outcomes = extract_resources(self._fhir._bundle, "OperationOutcome")
+ if outcomes:
+ self._fhir._operation_outcomes = outcomes
+
+ # Extract Provenance resources (data lineage/origin)
+ provenances = extract_resources(self._fhir._bundle, "Provenance")
+ if provenances:
+ self._fhir._provenances = provenances
+
+ self.text = "" # No text content for bundle-only documents
+ # Handle list of FHIR resources
+ elif (
+ isinstance(self.data, list)
+ and self.data
+ and isinstance(self.data[0], Resource)
+ ):
+ self._fhir._bundle = create_bundle()
+ for resource in self.data:
+ add_resource(self._fhir._bundle, resource)
+ self.text = "" # No text content for resource-only documents
+ else:
+ # Handle text data
+ self.text = self.data if isinstance(self.data, str) else str(self.data)
+
+ if not self._nlp._tokens and self.text:
self._nlp._tokens = self.text.split() # Basic tokenization if not provided
def word_count(self) -> int:
"""
- Get the word count from the document's text.
+ Return the number of word tokens in the document.
+
+ Returns:
+ int: The count of tokenized words in the document.
"""
return len(self._nlp._tokens)
@@ -651,7 +738,7 @@ def update_problem_list_from_nlp(
code_attribute: str = "cui",
):
"""
- Updates the document's problem list by extracting medical entities from NLP annotations.
+ Populate or update the problem list using entities extracted via NLP.
This method looks for entities with associated medical codes and creates FHIR Condition
resources from them. It supports a two-step process:
@@ -663,15 +750,18 @@ def update_problem_list_from_nlp(
1. spaCy entities with extension attributes (e.g., ent._.cui)
2. Generic entities in the NLP annotations container (framework-agnostic)
+ TODO: make this more generic and support other resource types
+
Args:
patient_ref: FHIR reference to the patient (default: "Patient/123")
coding_system: Coding system URI for the conditions (default: SNOMED CT)
code_attribute: Name of the attribute containing the medical code (default: "cui")
- Note:
- - Preserves any existing conditions in the problem list
- - For non-spaCy entities, codes should be stored as keys in entity dictionaries
- - Different code attributes: "cui", "snomed_id", "icd10", etc.
+ Notes:
+ - Preserves any existing problem list Conditions.
+ - Supports framework-agnostic extraction (spaCy and dict entities).
+ - For spaCy, looks for entity extension attribute (e.g. ent._.cui).
+ - For non-spaCy, expects codes as dict keys (ent["cui"], etc.).
"""
# Start with existing conditions to preserve them
existing_conditions = self.fhir.problem_list.copy()
@@ -699,7 +789,7 @@ def update_problem_list_from_nlp(
display=ent.text,
system=coding_system,
)
- set_problem_list_item_category(condition)
+ set_condition_category(condition, "problem-list-item")
logger.debug(
f"Adding condition from spaCy: {condition.model_dump(exclude_none=True)}"
)
@@ -725,7 +815,7 @@ def update_problem_list_from_nlp(
display=entity_text,
system=coding_system,
)
- set_problem_list_item_category(condition)
+ set_condition_category(condition, "problem-list-item")
logger.debug(
f"Adding condition from entities: {condition.model_dump(exclude_none=True)}"
)
@@ -737,7 +827,19 @@ def update_problem_list_from_nlp(
self.fhir.add_resources(all_conditions, "Condition", replace=True)
def __iter__(self) -> Iterator[str]:
+ """
+ Iterate through the document's tokens.
+
+ Returns:
+ Iterator[str]: Iterator over the document tokens.
+ """
return iter(self._nlp._tokens)
def __len__(self) -> int:
+ """
+ Return the length of the document's text.
+
+ Returns:
+ int: Character length of the document text.
+ """
return len(self.text)
diff --git a/mkdocs.yml b/mkdocs.yml
index 2002192e..a200274f 100644
--- a/mkdocs.yml
+++ b/mkdocs.yml
@@ -14,8 +14,10 @@ nav:
- Licence: distribution.md
- Cookbook:
- cookbook/index.md
+ - Setup FHIR Sandbox: cookbook/setup_fhir_sandboxes.md
+ - Multi-Source Data Integration: cookbook/multi_ehr_aggregation.md
+ - Automated Clinical Coding: cookbook/clinical_coding.md
- Discharge Summarizer: cookbook/discharge_summarizer.md
- - Clinical Coding: cookbook/clinical_coding.md
- Docs:
- Welcome: reference/index.md
- Gateway:
@@ -51,6 +53,7 @@ nav:
- Parsers: reference/interop/parsers.md
- Generators: reference/interop/generators.md
- Working with xmltodict: reference/interop/xmltodict.md
+ - In Development: reference/interop/experimental.md
- Utilities:
- FHIR Helpers: reference/utilities/fhir_helpers.md
- Sandbox: reference/utilities/sandbox.md
@@ -107,7 +110,8 @@ markdown_extensions:
- tables
- def_list
- attr_list
- - md_in_html
+ - md_in_html:
+ - pymdownx.details
- pymdownx.highlight:
anchor_linenums: true
line_spans: __span
diff --git a/scripts/healthchainapi_e2e_demo.py b/scripts/healthchainapi_e2e_demo.py
new file mode 100644
index 00000000..cb16299d
--- /dev/null
+++ b/scripts/healthchainapi_e2e_demo.py
@@ -0,0 +1,1231 @@
+"""
+๐ฅ HealthChain Complete Sandbox Demo
+
+A comprehensive demonstration of HealthChain's capabilities including:
+- Medical coding and summarization pipelines
+- CDS Hooks and NoteReader services
+- FHIR Gateway with custom handlers
+- Event-driven processing
+- Sandbox environments for testing
+
+This script showcases the full HealthChain ecosystem in action.
+
+Prerequisites:
+- HUGGINGFACEHUB_API_TOKEN environment variable (will prompt if not set)
+- ./resources/uclh_cda.xml
+- ./cookbook/data/discharge_notes.csv
+"""
+
+import getpass
+import os
+import threading
+import uvicorn
+import requests
+import json
+import glob
+from datetime import datetime
+from time import sleep
+from typing import List, Optional
+from pathlib import Path
+from dataclasses import dataclass, field
+from enum import Enum
+
+import healthchain as hc
+from spacy.tokens import Span
+
+# HealthChain imports
+from healthchain.data_generators.cdsdatagenerator import CdsDataGenerator
+from healthchain.gateway import (
+ HealthChainAPI,
+ NoteReaderService,
+ EHREvent,
+ EHREventType,
+)
+from healthchain.gateway.cds import CDSHooksService
+from healthchain.gateway.fhir import FHIRGateway
+from healthchain.gateway.events.dispatcher import local_handler
+from healthchain.io import Document
+from healthchain.models.hooks.prefetch import Prefetch
+from healthchain.models.requests import CdaRequest
+from healthchain.models.requests.cdsrequest import CDSRequest
+from healthchain.models.responses import CdaResponse
+from healthchain.models.responses.cdsresponse import CDSResponse
+from healthchain.pipeline.medicalcodingpipeline import MedicalCodingPipeline
+from healthchain.pipeline.summarizationpipeline import SummarizationPipeline
+from healthchain.sandbox.use_cases import ClinicalDocumentation
+from healthchain.fhir import create_document_reference
+from healthchain.sandbox.use_cases.cds import ClinicalDecisionSupport
+
+# FHIR imports
+from fhir.resources.documentreference import DocumentReference
+from fhir.resources.patient import Patient
+from fhir.resources.meta import Meta
+
+# Configuration
+CONFIG = {
+ "server": {
+ "host": "localhost",
+ "port": 8000,
+ "startup_delay": 5,
+ },
+ "data": {
+ "cda_document_path": "./resources/uclh_cda.xml",
+ "discharge_notes_path": "./cookbook/data/discharge_notes.csv",
+ },
+ "models": {
+ "spacy_model": "en_core_sci_sm",
+ "summarization_model": "google/pegasus-xsum",
+ },
+ "workflows": {
+ "notereader": "sign-note-inpatient",
+ "cds": "encounter-discharge",
+ },
+}
+
+
+def print_section(title: str, emoji: str = "๐ง"):
+ """Print a nicely formatted section header"""
+ print(f"\n{'='*60}")
+ print(f"{emoji} {title}")
+ print("=" * 60)
+
+
+def print_step(message: str):
+ """Print a step message"""
+ print(f"๐น {message}")
+
+
+def print_success(message: str):
+ """Print success message"""
+ print(f"โ {message}")
+
+
+def print_warning(message: str):
+ """Print warning message"""
+ print(f"โ ๏ธ {message}")
+
+
+def print_error(message: str):
+ """Print error message"""
+ print(f"โ {message}")
+
+
+def print_info(message: str):
+ """Print info message"""
+ print(f"โน๏ธ {message}")
+
+
+# Validation result structures
+class ValidationLevel(Enum):
+ """Validation result levels"""
+
+ SUCCESS = "success"
+ WARNING = "warning"
+ ERROR = "error"
+ INFO = "info"
+
+
+@dataclass
+class ValidationResult:
+ """Result of a validation check"""
+
+ passed: bool
+ message: str
+ details: dict = field(default_factory=dict)
+ level: ValidationLevel = ValidationLevel.INFO
+
+ def print(self):
+ """Print validation result with appropriate formatting"""
+ if self.level == ValidationLevel.SUCCESS:
+ print_success(self.message)
+ elif self.level == ValidationLevel.WARNING:
+ print_warning(self.message)
+ elif self.level == ValidationLevel.ERROR:
+ print_error(self.message)
+ else:
+ print_info(self.message)
+
+ # Print details if present
+ for key, value in self.details.items():
+ if isinstance(value, list):
+ print(f" {key}:")
+ for item in value:
+ print(f" - {item}")
+ else:
+ print(f" {key}: {value}")
+
+
+def make_api_request(
+ url: str, method: str = "get", timeout: int = 10, **kwargs
+) -> Optional[requests.Response]:
+ """
+ Make HTTP request with consistent error handling
+
+ Args:
+ url: URL to request
+ method: HTTP method (get, post, etc.)
+ timeout: Request timeout in seconds
+ **kwargs: Additional arguments to pass to requests
+
+ Returns:
+ Response object or None if request failed
+ """
+ try:
+ response = getattr(requests, method.lower())(url, timeout=timeout, **kwargs)
+ return response
+ except Exception as e:
+ print_error(f"Request failed: {str(e)}")
+ return None
+
+
+def validate_prerequisites():
+ """Validate that all required files exist before running"""
+ print_section("Validating Prerequisites", "๐")
+
+ required_files = {
+ "CDA Document": CONFIG["data"]["cda_document_path"],
+ "Discharge Notes": CONFIG["data"]["discharge_notes_path"],
+ }
+
+ missing = []
+ for name, path in required_files.items():
+ if not os.path.exists(path):
+ missing.append(f"{name}: {path}")
+ else:
+ print_success(f"Found {name}: {path}")
+
+ if missing:
+ print_error("Missing required files:")
+ for file in missing:
+ print(f" โ {file}")
+ print("\nPlease ensure these files exist before running the demo.")
+ raise SystemExit(1)
+
+ print_success("All prerequisites validated")
+
+
+def setup_environment():
+ """Setup required environment variables"""
+ print_section("Environment Setup", "๐")
+
+ if not os.getenv("HUGGINGFACEHUB_API_TOKEN"):
+ print_step("HuggingFace API token required for summarization pipeline")
+ os.environ["HUGGINGFACEHUB_API_TOKEN"] = getpass.getpass(
+ "Enter your HuggingFace token: "
+ )
+ print_success("HuggingFace token configured")
+ else:
+ print_success("HuggingFace token already configured")
+
+
+def create_pipelines():
+ """Create and configure ML pipelines"""
+ print_section("Creating ML Pipelines", "๐ง ")
+
+ # Medical coding pipeline
+ print_step("Setting up medical coding pipeline...")
+ coding_pipeline = MedicalCodingPipeline.from_model_id(
+ CONFIG["models"]["spacy_model"], source="spacy"
+ )
+
+ # Add custom entity linking
+ @coding_pipeline.add_node(position="after", reference="SpacyNLP")
+ def link_entities(doc: Document) -> Document:
+ """Add CUI codes to medical entities"""
+ if not Span.has_extension("cui"):
+ Span.set_extension("cui", default=None)
+
+ spacy_doc = doc.nlp.get_spacy_doc()
+
+ # Simple dummy linker for demo purposes
+ 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]
+
+ return doc
+
+ print_success("Medical coding pipeline created")
+
+ # Summarization pipeline
+ print_step("Setting up summarization pipeline...")
+ summarization_pipeline = SummarizationPipeline.from_model_id(
+ CONFIG["models"]["summarization_model"],
+ source="huggingface",
+ task="summarization",
+ )
+ print_success("Summarization pipeline created")
+
+ return coding_pipeline, summarization_pipeline
+
+
+def setup_event_handlers():
+ """Configure event handlers for the system"""
+ print_section("Setting Up Event Handlers", "๐ก")
+
+ # Event collector for validation
+ captured_events = []
+
+ @local_handler.register(event_name=EHREventType.NOTEREADER_PROCESS_NOTE.value)
+ async def handle_notereader_event(event):
+ """Handler for NoteReader events"""
+ event_name, payload = event
+ print(f"๐ NoteReader Event: {payload['source_system']}")
+ print(f" Custom ID: {payload['payload'].get('custom_id', 'N/A')}")
+ captured_events.append(("notereader", event_name))
+ return True
+
+ @local_handler.register(event_name="*")
+ async def log_all_events(event):
+ """Catch-all handler for system events"""
+ event_name, payload = event
+ print(f"๐ System Event: {event_name}")
+ captured_events.append(("global", event_name))
+ return True
+
+ print_success("Event handlers registered")
+
+ # Store the captured events list for later validation
+ setup_event_handlers.captured_events = captured_events
+
+
+def create_services(coding_pipeline, summarization_pipeline):
+ """Create and configure HealthChain services"""
+ print_section("Creating HealthChain Services", "โ๏ธ")
+
+ # Create services
+ note_service = NoteReaderService()
+ cds_service = CDSHooksService()
+ fhir_gateway = FHIRGateway()
+
+ # Configure CDS Hooks
+ @cds_service.hook("encounter-discharge", id="discharge-summary")
+ def handle_discharge_summary(request: CDSRequest) -> CDSResponse:
+ """Process discharge summaries with AI"""
+ result = summarization_pipeline.process_request(request)
+ return result
+
+ print_success("CDS Hooks service configured")
+
+ # Configure NoteReader
+ @note_service.method("ProcessDocument")
+ def process_document(cda_request: CdaRequest) -> CdaResponse:
+ """Process clinical documents with NLP"""
+ result = coding_pipeline.process_request(cda_request)
+ return result
+
+ print_success("NoteReader service configured")
+
+ # Configure FHIR Gateway handlers
+ @fhir_gateway.transform(DocumentReference)
+ def enhance_document(id: str, source: str) -> DocumentReference:
+ """Transform documents with AI enhancements"""
+ print(f"๐ Enhancing document {id} from {source}")
+
+ # Create enhanced document
+ document = create_document_reference(
+ data="AI-enhanced clinical document",
+ content_type="text/xml",
+ description="Document enhanced with HealthChain AI processing",
+ )
+
+ # Add AI summary extension
+ document.extension = [
+ {
+ "url": "http://healthchain.org/extension/ai-summary",
+ "valueString": "This document has been enhanced with AI-powered analysis",
+ }
+ ]
+
+ # Add processing metadata
+ document.meta = Meta(
+ lastUpdated=datetime.now().isoformat(),
+ tag=[{"system": "http://healthchain.org/tag", "code": "ai-enhanced"}],
+ )
+
+ return document
+
+ @fhir_gateway.aggregate(Patient)
+ def aggregate_patient_data(id: str, sources: List[str]) -> Patient:
+ """Aggregate patient data from multiple sources"""
+ print(f"๐ค Aggregating patient {id} from sources: {', '.join(sources)}")
+
+ patient = Patient()
+ patient.id = id
+ patient.gender = "unknown"
+ patient.birthDate = "1990-01-01"
+
+ return patient
+
+ print_success("FHIR Gateway handlers configured")
+
+ # Setup custom event creator
+ def custom_event_creator(operation, *args):
+ """Create customized events for integration tracking"""
+ return EHREvent(
+ event_type=EHREventType.NOTEREADER_PROCESS_NOTE,
+ source_system="HealthChain-Demo",
+ timestamp=datetime.now(),
+ payload={
+ "demo_id": "sandbox-123",
+ "operation": operation,
+ },
+ metadata={
+ "environment": "sandbox",
+ "priority": "demo",
+ },
+ )
+
+ note_service.events.set_event_creator(custom_event_creator)
+ print_success("Custom event creator configured")
+
+ return note_service, cds_service, fhir_gateway
+
+
+def create_app(note_service, cds_service, fhir_gateway):
+ """Create and configure the main HealthChain application"""
+ print_section("Creating HealthChain Application", "๐ฅ")
+
+ app = HealthChainAPI()
+
+ # Register all services
+ app.register_service(note_service)
+ app.register_service(cds_service)
+ app.register_gateway(fhir_gateway)
+
+ print_success("HealthChain application created and services registered")
+ return app
+
+
+def create_sandboxes():
+ """Create sandbox environments for testing"""
+ print_section("Creating Sandbox Environments", "๐๏ธ")
+
+ base_url = f"http://{CONFIG['server']['host']}:{CONFIG['server']['port']}/"
+
+ # NoteReader Sandbox
+ @hc.sandbox(base_url)
+ class NotereaderSandbox(ClinicalDocumentation):
+ """Sandbox for testing clinical documentation workflows"""
+
+ def __init__(self):
+ super().__init__()
+ self.data_path = CONFIG["data"]["cda_document_path"]
+
+ @hc.ehr(workflow=CONFIG["workflows"]["notereader"])
+ def load_clinical_document(self) -> DocumentReference:
+ """Load a sample CDA document for processing"""
+ with open(self.data_path, "r") as file:
+ xml_content = file.read()
+
+ return create_document_reference(
+ data=xml_content,
+ content_type="text/xml",
+ description="Sample CDA document from sandbox",
+ )
+
+ # CDS Hooks Sandbox
+ @hc.sandbox(base_url)
+ class DischargeNoteSummarizer(ClinicalDecisionSupport):
+ """Sandbox for testing clinical decision support workflows"""
+
+ def __init__(self):
+ super().__init__(path="/cds/cds-services/")
+ self.data_generator = CdsDataGenerator()
+
+ @hc.ehr(workflow=CONFIG["workflows"]["cds"])
+ def load_discharge_data(self) -> Prefetch:
+ """Generate synthetic discharge data for testing"""
+ data = self.data_generator.generate_prefetch(
+ free_text_path=CONFIG["data"]["discharge_notes_path"],
+ column_name="text",
+ )
+ return data
+
+ print_success("Sandbox environments created")
+ return NotereaderSandbox(), DischargeNoteSummarizer()
+
+
+def start_server(app):
+ """Start the HealthChain server in a separate thread"""
+ print_section("Starting HealthChain Server", "๐")
+
+ def run_server():
+ uvicorn.run(
+ app,
+ host=CONFIG["server"]["host"],
+ port=CONFIG["server"]["port"],
+ log_level="info",
+ )
+
+ server_thread = threading.Thread(target=run_server, daemon=True)
+ server_thread.start()
+
+ print_step(
+ f"Server starting on {CONFIG['server']['host']}:{CONFIG['server']['port']}"
+ )
+ print_step(f"Waiting {CONFIG['server']['startup_delay']} seconds for startup...")
+ sleep(CONFIG["server"]["startup_delay"])
+ print_success("Server is ready!")
+
+ return server_thread
+
+
+def run_sandbox_demos(notereader_sandbox, cds_sandbox):
+ """Run the sandbox demonstrations"""
+ print_section("Running Sandbox Demonstrations", "๐ญ")
+
+ # Start NoteReader demo
+ print_step("Starting Clinical Documentation sandbox...")
+ try:
+ notereader_sandbox.start_sandbox()
+ print_success("Clinical Documentation sandbox completed")
+ except Exception as e:
+ print(f"โ NoteReader sandbox error: {str(e)}")
+
+ sleep(2)
+
+ # Start CDS Hooks demo
+ print_step("Starting Clinical Decision Support sandbox...")
+ try:
+ cds_sandbox.start_sandbox(service_id="discharge-summary")
+ print_success("Clinical Decision Support sandbox completed")
+ except Exception as e:
+ print(f"โ CDS sandbox error: {str(e)}")
+
+
+# FHIR Endpoint Validation Helpers
+def check_metadata_endpoint(base_url: str) -> ValidationResult:
+ """Check FHIR metadata endpoint accessibility"""
+ print_step("Checking FHIR metadata endpoint...")
+
+ response = make_api_request(f"{base_url}/fhir/metadata")
+ if not response or response.status_code != 200:
+ return ValidationResult(
+ passed=False,
+ message=f"Metadata endpoint returned status {response.status_code if response else 'no response'}",
+ level=ValidationLevel.ERROR,
+ )
+
+ return ValidationResult(
+ passed=True,
+ message="FHIR metadata endpoint accessible",
+ level=ValidationLevel.SUCCESS,
+ details={"response": response.json()},
+ )
+
+
+def validate_capability_statement(metadata: dict) -> list[ValidationResult]:
+ """Validate CapabilityStatement structure and content"""
+ results = []
+
+ # Check resource type
+ if metadata.get("resourceType") != "CapabilityStatement":
+ results.append(
+ ValidationResult(
+ passed=False,
+ message=f"Expected CapabilityStatement, got {metadata.get('resourceType')}",
+ level=ValidationLevel.WARNING,
+ )
+ )
+ return results
+
+ results.append(
+ ValidationResult(
+ passed=True,
+ message="Valid FHIR CapabilityStatement returned",
+ level=ValidationLevel.SUCCESS,
+ )
+ )
+
+ # Check resource capabilities
+ expected_resources = ["DocumentReference", "Patient"]
+ rest_resources = metadata.get("rest", [])
+
+ if rest_resources and rest_resources[0].get("resource"):
+ actual_resources = [r["type"] for r in rest_resources[0]["resource"]]
+ missing = set(expected_resources) - set(actual_resources)
+
+ if not missing:
+ results.append(
+ ValidationResult(
+ passed=True,
+ message="All expected resources found in capability statement",
+ level=ValidationLevel.SUCCESS,
+ details={"๐ Supported resources": ", ".join(actual_resources)},
+ )
+ )
+ else:
+ results.append(
+ ValidationResult(
+ passed=False,
+ message=f"Missing expected resources: {', '.join(missing)}",
+ level=ValidationLevel.WARNING,
+ )
+ )
+ else:
+ results.append(
+ ValidationResult(
+ passed=False,
+ message="No resource capabilities found in statement",
+ level=ValidationLevel.WARNING,
+ )
+ )
+
+ # Check FHIR version
+ if fhir_version := metadata.get("fhirVersion"):
+ results.append(
+ ValidationResult(
+ passed=True,
+ message=f"FHIR version: {fhir_version}",
+ level=ValidationLevel.SUCCESS,
+ )
+ )
+
+ # Check gateway information
+ if gateways := metadata.get("gateways"):
+ gateway_details = []
+ for name, info in gateways.items():
+ endpoints = info.get("endpoints", [])
+ gateway_details.append(
+ f"{name} ({info.get('type', 'Unknown')}): {len(endpoints)} endpoints"
+ )
+
+ results.append(
+ ValidationResult(
+ passed=True,
+ message=f"CapabilityStatement includes {len(gateways)} gateway(s)",
+ level=ValidationLevel.SUCCESS,
+ details={"Gateways": gateway_details},
+ )
+ )
+
+ return results
+
+
+def check_gateway_status(base_url: str) -> list[ValidationResult]:
+ """Check gateway status endpoint and validate structure"""
+ print_step("Checking gateway status endpoint...")
+ results = []
+
+ response = make_api_request(f"{base_url}/fhir/status")
+ if not response or response.status_code != 200:
+ results.append(
+ ValidationResult(
+ passed=False,
+ message=f"Status endpoint returned status {response.status_code if response else 'no response'}",
+ level=ValidationLevel.ERROR,
+ )
+ )
+ return results
+
+ status = response.json()
+ results.append(
+ ValidationResult(
+ passed=True,
+ message="Gateway status endpoint accessible",
+ level=ValidationLevel.SUCCESS,
+ )
+ )
+
+ # Validate gateway type
+ if status.get("gateway_type") != "FHIRGateway":
+ results.append(
+ ValidationResult(
+ passed=False,
+ message=f"Expected FHIRGateway, got {status.get('gateway_type')}",
+ level=ValidationLevel.ERROR,
+ )
+ )
+ return results
+
+ results.append(
+ ValidationResult(
+ passed=True,
+ message="Valid gateway status returned",
+ level=ValidationLevel.SUCCESS,
+ )
+ )
+
+ # Check operations
+ if ops := status.get("supported_operations"):
+ resource_count = len(ops.keys())
+ transform_count = sum(
+ 1
+ for operations in ops.values()
+ for op in operations
+ if op.get("type") == "transform"
+ )
+ aggregate_count = sum(
+ 1
+ for operations in ops.values()
+ for op in operations
+ if op.get("type") == "aggregate"
+ )
+ total_operations = sum(len(operations) for operations in ops.values())
+
+ operation_details = [
+ f"๐ Transform endpoints: {transform_count}",
+ f"๐ Aggregate endpoints: {aggregate_count}",
+ ]
+ for resource_name, operations in ops.items():
+ operation_types = [op.get("type") for op in operations]
+ operation_details.append(
+ f"โโโ {resource_name}: {', '.join(operation_types)}"
+ )
+
+ results.append(
+ ValidationResult(
+ passed=True,
+ message=f"Gateway supports {resource_count} resources, {total_operations} operations",
+ level=ValidationLevel.SUCCESS,
+ details={"Operations": operation_details},
+ )
+ )
+
+ # Check event system
+ if events := status.get("events"):
+ if events.get("enabled"):
+ results.append(
+ ValidationResult(
+ passed=True,
+ message="Event system enabled",
+ level=ValidationLevel.SUCCESS,
+ )
+ )
+ if events.get("dispatcher_configured"):
+ results.append(
+ ValidationResult(
+ passed=True,
+ message="Event dispatcher configured",
+ level=ValidationLevel.SUCCESS,
+ )
+ )
+ else:
+ results.append(
+ ValidationResult(
+ passed=True,
+ message="Event dispatcher not configured (normal for demo)",
+ level=ValidationLevel.INFO,
+ )
+ )
+ else:
+ results.append(
+ ValidationResult(
+ passed=True,
+ message="Event system disabled",
+ level=ValidationLevel.INFO,
+ )
+ )
+
+ # Check sources
+ if sources := status.get("sources"):
+ source_count = sources.get("count", 0)
+ if source_count > 0:
+ source_names = sources.get("names", [])
+ results.append(
+ ValidationResult(
+ passed=True,
+ message=f"๐ก Connected FHIR sources: {source_count}",
+ level=ValidationLevel.INFO,
+ details={"Sources": source_names},
+ )
+ )
+
+ # Check discovery endpoints
+ if discovery := status.get("discovery_endpoints"):
+ discovery_list = [
+ f"{endpoint}: {description}" for endpoint, description in discovery.items()
+ ]
+ results.append(
+ ValidationResult(
+ passed=True,
+ message=f"Gateway provides {len(discovery)} discovery endpoints",
+ level=ValidationLevel.SUCCESS,
+ details={"Endpoints": discovery_list},
+ )
+ )
+
+ return results
+
+
+def check_transform_endpoint(base_url: str) -> list[ValidationResult]:
+ """Check DocumentReference transform endpoint"""
+ print_step("Checking DocumentReference transform endpoint...")
+ results = []
+
+ response = make_api_request(
+ f"{base_url}/fhir/transform/DocumentReference/test-doc-123?source=demo"
+ )
+ if not response or response.status_code != 200:
+ results.append(
+ ValidationResult(
+ passed=False,
+ message=f"Transform endpoint returned status {response.status_code if response else 'no response'}",
+ level=ValidationLevel.ERROR,
+ )
+ )
+ return results
+
+ doc_data = response.json()
+ results.append(
+ ValidationResult(
+ passed=True,
+ message="Transform endpoint accessible",
+ level=ValidationLevel.SUCCESS,
+ )
+ )
+
+ # Validate response structure
+ if doc_data.get("resourceType") != "DocumentReference":
+ results.append(
+ ValidationResult(
+ passed=False,
+ message=f"Expected DocumentReference, got {doc_data.get('resourceType')}",
+ level=ValidationLevel.ERROR,
+ )
+ )
+ return results
+
+ results.append(
+ ValidationResult(
+ passed=True,
+ message="Valid DocumentReference returned from transform",
+ level=ValidationLevel.SUCCESS,
+ )
+ )
+
+ # Check for AI enhancement
+ extensions = doc_data.get("extension", [])
+ ai_enhanced = any("ai-summary" in ext.get("url", "") for ext in extensions)
+
+ if ai_enhanced:
+ results.append(
+ ValidationResult(
+ passed=True,
+ message="Document contains AI enhancement extension",
+ level=ValidationLevel.SUCCESS,
+ )
+ )
+ else:
+ results.append(
+ ValidationResult(
+ passed=False,
+ message="No AI enhancement extension found",
+ level=ValidationLevel.WARNING,
+ )
+ )
+
+ return results
+
+
+def check_aggregate_endpoint(base_url: str) -> list[ValidationResult]:
+ """Check Patient aggregate endpoint"""
+ print_step("Checking Patient aggregate endpoint...")
+ results = []
+
+ response = make_api_request(
+ f"{base_url}/fhir/aggregate/Patient?id=test-patient-123&sources=demo&sources=epic"
+ )
+ if not response or response.status_code != 200:
+ results.append(
+ ValidationResult(
+ passed=False,
+ message=f"Aggregate endpoint returned status {response.status_code if response else 'no response'}",
+ level=ValidationLevel.ERROR,
+ )
+ )
+ return results
+
+ patient_data = response.json()
+ results.append(
+ ValidationResult(
+ passed=True,
+ message="Aggregate endpoint accessible",
+ level=ValidationLevel.SUCCESS,
+ )
+ )
+
+ # Validate response structure
+ if patient_data.get("resourceType") != "Patient":
+ results.append(
+ ValidationResult(
+ passed=False,
+ message=f"Expected Patient, got {patient_data.get('resourceType')}",
+ level=ValidationLevel.ERROR,
+ )
+ )
+ return results
+
+ results.append(
+ ValidationResult(
+ passed=True,
+ message="Valid Patient returned from aggregate",
+ level=ValidationLevel.SUCCESS,
+ )
+ )
+
+ # Check patient has expected fields
+ if patient_data.get("id") and patient_data.get("gender"):
+ results.append(
+ ValidationResult(
+ passed=True,
+ message="Patient contains expected demographic data",
+ level=ValidationLevel.SUCCESS,
+ )
+ )
+ else:
+ results.append(
+ ValidationResult(
+ passed=False,
+ message="Patient missing expected demographic fields",
+ level=ValidationLevel.WARNING,
+ )
+ )
+
+ return results
+
+
+def validate_fhir_endpoints():
+ """Validate that FHIR Gateway endpoints are properly registered"""
+ print_section("Validating FHIR Gateway Endpoints", "๐")
+
+ base_url = f"http://{CONFIG['server']['host']}:{CONFIG['server']['port']}"
+
+ # Check metadata endpoint
+ result = check_metadata_endpoint(base_url)
+ result.print()
+
+ if result.passed:
+ metadata = result.details.get("response", {})
+ for validation_result in validate_capability_statement(metadata):
+ validation_result.print()
+
+ # Check gateway status
+ for result in check_gateway_status(base_url):
+ result.print()
+
+ # Check transform endpoint
+ for result in check_transform_endpoint(base_url):
+ result.print()
+
+ # Check aggregate endpoint
+ for result in check_aggregate_endpoint(base_url):
+ result.print()
+
+
+def validate_json_response(file_path: str) -> list[ValidationResult]:
+ """Validate JSON response file (CDS response)"""
+ results = []
+ print_step(f"Checking latest JSON response: {Path(file_path).name}")
+
+ try:
+ with open(file_path, "r") as f:
+ content = f.read()
+
+ try:
+ data = json.loads(content)
+
+ if "cards" not in data:
+ results.append(
+ ValidationResult(
+ passed=False,
+ message="JSON file has unknown structure",
+ level=ValidationLevel.WARNING,
+ )
+ )
+ return results
+
+ results.append(
+ ValidationResult(
+ passed=True,
+ message="File has CDS response structure",
+ level=ValidationLevel.SUCCESS,
+ )
+ )
+
+ # Check for card details
+ cards = data.get("cards", [])
+ has_details = any(
+ card.get("detail") and len(card.get("detail", "").strip()) > 0
+ for card in cards
+ )
+
+ results.append(
+ ValidationResult(
+ passed=has_details,
+ message="CDS response contains card details โ"
+ if has_details
+ else "CDS response missing card details",
+ level=ValidationLevel.SUCCESS
+ if has_details
+ else ValidationLevel.WARNING,
+ )
+ )
+
+ except json.JSONDecodeError:
+ results.append(
+ ValidationResult(
+ passed=False,
+ message="File is not valid JSON",
+ level=ValidationLevel.ERROR,
+ )
+ )
+
+ except Exception as e:
+ results.append(
+ ValidationResult(
+ passed=False,
+ message=f"Error reading file: {str(e)}",
+ level=ValidationLevel.ERROR,
+ )
+ )
+
+ return results
+
+
+def validate_xml_response(file_path: str) -> list[ValidationResult]:
+ """Validate XML response file (CDA response)"""
+ results = []
+ print_step(f"Checking latest XML response: {Path(file_path).name}")
+
+ try:
+ with open(file_path, "r") as f:
+ content = f.read()
+
+ # Check for expected code in CDA content
+ has_code = "0006477" in content.lower()
+ results.append(
+ ValidationResult(
+ passed=has_code,
+ message="CDA response contains '0006477' โ"
+ if has_code
+ else "CDA response does not contain '0006477'",
+ level=ValidationLevel.SUCCESS if has_code else ValidationLevel.WARNING,
+ )
+ )
+
+ # Check for basic CDA structure
+ has_structure = (
+ "" in content
+ )
+ results.append(
+ ValidationResult(
+ passed=has_structure,
+ message="Valid CDA document structure"
+ if has_structure
+ else "Invalid or incomplete CDA document structure",
+ level=ValidationLevel.SUCCESS
+ if has_structure
+ else ValidationLevel.WARNING,
+ )
+ )
+
+ except Exception as e:
+ results.append(
+ ValidationResult(
+ passed=False,
+ message=f"Error reading file: {str(e)}",
+ level=ValidationLevel.ERROR,
+ )
+ )
+
+ return results
+
+
+def validate_output_files():
+ """Validate that output files contain expected content"""
+ print_section("Validating Output Files", "๐")
+
+ output_dir = "output/responses"
+
+ # Check if output directory exists
+ if not os.path.exists(output_dir):
+ print_warning(f"Output directory {output_dir} does not exist")
+ return
+
+ # Find all sandbox response files
+ json_files = glob.glob(f"{output_dir}/*_sandbox_*_response_0.json")
+ xml_files = glob.glob(f"{output_dir}/*_sandbox_*_response_0.xml")
+
+ if not json_files and not xml_files:
+ print_warning(f"No sandbox response files found in {output_dir}")
+ return
+
+ # Get latest files by modification time
+ all_files = json_files + xml_files
+ all_files.sort(key=lambda x: os.path.getmtime(x), reverse=True)
+
+ print_success(f"Found {len(all_files)} sandbox response file(s)")
+
+ # Validate latest JSON file
+ latest_json = [f for f in all_files if f.endswith(".json")]
+ if latest_json:
+ for result in validate_json_response(latest_json[0]):
+ result.print()
+
+ # Validate latest XML file
+ latest_xml = [f for f in all_files if f.endswith(".xml")]
+ if latest_xml:
+ for result in validate_xml_response(latest_xml[0]):
+ result.print()
+
+ # Summary
+ print_step("Validation summary:")
+ print(f" ๐ Total files: {len(all_files)}")
+ print(f" ๐ JSON: {len(json_files)}, XML: {len(xml_files)}")
+ if all_files:
+ print(f" ๐ Latest: {Path(all_files[0]).name}")
+
+
+def check_captured_events() -> list[ValidationResult]:
+ """Check events captured during demo execution"""
+ results = []
+
+ if not hasattr(setup_event_handlers, "captured_events"):
+ results.append(
+ ValidationResult(
+ passed=False,
+ message="Event capture system not initialized",
+ level=ValidationLevel.WARNING,
+ )
+ )
+ return results
+
+ captured = setup_event_handlers.captured_events
+
+ if not captured:
+ results.append(
+ ValidationResult(
+ passed=False,
+ message="No events were captured during demo execution",
+ level=ValidationLevel.WARNING,
+ )
+ )
+ return results
+
+ # Count events by type
+ notereader_events = len([e for e in captured if e[0] == "notereader"])
+ global_events = len([e for e in captured if e[0] == "global"])
+
+ results.append(
+ ValidationResult(
+ passed=True,
+ message=f"Captured {len(captured)} events during demo execution",
+ level=ValidationLevel.SUCCESS,
+ details={
+ "๐ NoteReader events": str(notereader_events),
+ "๐ Global events": str(global_events),
+ },
+ )
+ )
+
+ if notereader_events > 0:
+ results.append(
+ ValidationResult(
+ passed=True,
+ message="Event system working - events fired during normal operations โ",
+ level=ValidationLevel.SUCCESS,
+ )
+ )
+ else:
+ results.append(
+ ValidationResult(
+ passed=False,
+ message="No NoteReader events captured during demos",
+ level=ValidationLevel.WARNING,
+ )
+ )
+
+ return results
+
+
+def test_event_system():
+ """Validate that the event system worked during normal operations"""
+ print_section("Validating Event System", "๐ก")
+
+ print_step("Checking events captured during demos...")
+
+ try:
+ for result in check_captured_events():
+ result.print()
+ except Exception as e:
+ ValidationResult(
+ passed=False,
+ message=f"Error checking captured events: {str(e)}",
+ level=ValidationLevel.ERROR,
+ ).print()
+
+
+def run_automated_validation():
+ """Run all automated validation checks"""
+ print_section("๐ Automated Validation Suite", "๐งช")
+ print("Running automated checks to verify system functionality...")
+
+ # Give the system a moment to settle after demos
+ sleep(2)
+
+ # Run all validation checks
+ validate_fhir_endpoints()
+ validate_output_files()
+ test_event_system()
+
+ print_section("Validation Complete", "โ ")
+ print("All automated checks completed. Review results above for any issues.")
+
+
+def main():
+ """Main demo orchestrator"""
+ print_section("๐ฅ HealthChain Complete Sandbox Demo", "๐ฏ")
+ print(
+ "Demonstrating the full HealthChain ecosystem with AI-powered healthcare workflows"
+ )
+
+ try:
+ # Validate prerequisites first
+ validate_prerequisites()
+
+ # Setup
+ setup_environment()
+ setup_event_handlers()
+
+ # Create components
+ coding_pipeline, summarization_pipeline = create_pipelines()
+ note_service, cds_service, fhir_gateway = create_services(
+ coding_pipeline, summarization_pipeline
+ )
+ app = create_app(note_service, cds_service, fhir_gateway)
+ notereader_sandbox, cds_sandbox = create_sandboxes()
+
+ # Run demo
+ server_thread = start_server(app)
+ run_sandbox_demos(notereader_sandbox, cds_sandbox)
+
+ # Run automated validation
+ run_automated_validation()
+
+ print_section("โจ Demo Complete", "๐")
+ print("All HealthChain components demonstrated and validated successfully!")
+ print("Press Ctrl+C to stop the server")
+
+ # Keep the server running
+ try:
+ server_thread.join()
+ except KeyboardInterrupt:
+ print("\n๐ Shutting down gracefully...")
+
+ except Exception as e:
+ print(f"โ Demo failed: {str(e)}")
+ raise
+
+
+if __name__ == "__main__":
+ main()
diff --git a/tests/containers/test_document.py b/tests/containers/test_document.py
index 1051e8c0..9faa038f 100644
--- a/tests/containers/test_document.py
+++ b/tests/containers/test_document.py
@@ -1,5 +1,9 @@
+import pytest
+
from healthchain.io.containers.document import Document
from unittest.mock import patch, MagicMock
+from fhir.resources.bundle import Bundle
+from healthchain.fhir import create_bundle, add_resource, create_condition
def test_document_initialization(sample_document):
@@ -68,6 +72,194 @@ def test_empty_document():
assert doc.word_count() == 0
+@pytest.mark.parametrize(
+ "data_builder, expect_bundle, expected_entries, expected_text",
+ [
+ # bundle with two conditions
+ (
+ lambda: (
+ lambda b: (
+ add_resource(
+ b, create_condition(subject="Patient/123", code="E11.9")
+ ),
+ add_resource(
+ b, create_condition(subject="Patient/123", code="I10")
+ ),
+ )
+ and b
+ )(create_bundle("collection")),
+ True,
+ 2,
+ "",
+ ),
+ # resource list
+ (
+ lambda: [
+ create_condition(subject="Patient/123", code="E11.9"),
+ create_condition(subject="Patient/123", code="I10"),
+ ],
+ True,
+ 2,
+ "",
+ ),
+ # text only
+ (lambda: "Patient has hypertension", False, 0, "Patient has hypertension"),
+ # empty list
+ (lambda: [], None, 0, ""),
+ ],
+)
+def test_document_handles_various_data_inputs(
+ data_builder, expect_bundle, expected_entries, expected_text
+):
+ """Document handles bundle, resource list, text-only, and empty list inputs."""
+ data = data_builder()
+ doc = Document(data=data)
+
+ if expect_bundle is True:
+ assert isinstance(doc.fhir.bundle, Bundle)
+ assert len(doc.fhir.bundle.entry) == expected_entries
+ assert doc.text == expected_text
+ elif expect_bundle is False:
+ assert doc.fhir.bundle is None
+ assert doc.text == expected_text
+ assert len(doc.nlp._tokens) > 0
+ else:
+ # empty list case: either no bundle or empty bundle
+ assert doc.fhir.bundle is None or len(doc.fhir.bundle.entry) == 0
+
+
+def test_document_bundle_accessible_via_problem_list():
+ """Document's problem_list accessor works with auto-set bundle."""
+ bundle = create_bundle("collection")
+ add_resource(bundle, create_condition(subject="Patient/123", code="E11.9"))
+
+ doc = Document(data=bundle)
+
+ conditions = doc.fhir.problem_list
+ assert len(conditions) == 1
+ assert conditions[0].code.coding[0].code == "E11.9"
+
+ assert conditions[0].code.coding[0].code == "E11.9"
+
+
+@pytest.mark.parametrize(
+ "num_outcomes, expected_outcome_count, expected_remaining_entries",
+ [
+ (0, 0, 2), # no OperationOutcomes
+ (2, 2, 2), # outcomes extracted, conditions remain
+ ],
+)
+def test_document_operation_outcome_extraction(
+ num_outcomes, expected_outcome_count, expected_remaining_entries
+):
+ from fhir.resources.operationoutcome import OperationOutcome, OperationOutcomeIssue
+
+ bundle = create_bundle("collection")
+ add_resource(bundle, create_condition(subject="Patient/123", code="E11.9"))
+ add_resource(bundle, create_condition(subject="Patient/123", code="I10"))
+
+ for i in range(num_outcomes):
+ add_resource(
+ bundle,
+ OperationOutcome(
+ issue=[
+ OperationOutcomeIssue(
+ severity="information" if i == 0 else "warning",
+ code="informational" if i == 0 else "processing",
+ diagnostics=(
+ "Data retrieved from source A"
+ if i == 0
+ else "Source B temporarily unavailable"
+ ),
+ )
+ ]
+ ),
+ )
+
+ doc = Document(data=bundle)
+
+ assert len(doc.fhir.operation_outcomes) == expected_outcome_count
+ assert len(doc.fhir.bundle.entry) == expected_remaining_entries
+
+
+@pytest.mark.parametrize(
+ "num_provenances, expected_provenance_count, expected_remaining_entries",
+ [
+ (0, 0, 2), # no Provenances
+ (2, 2, 2), # provenances extracted, conditions remain
+ ],
+)
+def test_document_provenance_extraction(
+ num_provenances, expected_provenance_count, expected_remaining_entries
+):
+ """Document automatically extracts Provenance resources during initialization."""
+ from fhir.resources.provenance import Provenance, ProvenanceAgent
+ from fhir.resources.reference import Reference
+
+ bundle = create_bundle("collection")
+ add_resource(bundle, create_condition(subject="Patient/123", code="E11.9"))
+ add_resource(bundle, create_condition(subject="Patient/123", code="I10"))
+
+ for i in range(num_provenances):
+ add_resource(
+ bundle,
+ Provenance(
+ target=[Reference(reference=f"Condition/{i}")],
+ recorded="2024-01-01T00:00:00Z",
+ agent=[
+ ProvenanceAgent(who=Reference(reference=f"Organization/source-{i}"))
+ ],
+ ),
+ )
+
+ doc = Document(data=bundle)
+
+ assert len(doc.fhir.provenances) == expected_provenance_count
+ assert len(doc.fhir.bundle.entry) == expected_remaining_entries
+
+
+@pytest.mark.parametrize("num_patients", [0, 1, 2])
+def test_document_patient_convenience_properties_param(num_patients):
+ """Patient convenience accessors behave for 0, 1, 2 patients without extraction."""
+ from fhir.resources.patient import Patient
+ from fhir.resources.humanname import HumanName
+
+ bundle = create_bundle("collection")
+
+ # Add patients
+ patients = []
+ for i in range(num_patients):
+ patient = Patient(
+ id=f"patient-{i+1}", name=[HumanName(given=["John"], family=f"Fam{i+1}")]
+ )
+ patients.append(patient)
+ add_resource(bundle, patient)
+
+ # Add a condition referencing first patient if present
+ subject_ref = f"Patient/{patients[0].id}" if patients else "Patient/123"
+ add_resource(bundle, create_condition(subject=subject_ref, code="E11.9"))
+
+ doc = Document(data=bundle)
+
+ # Singular accessor
+ if num_patients >= 1:
+ assert doc.fhir.patient is not None
+ assert doc.fhir.patient.id == "patient-1"
+ else:
+ assert doc.fhir.patient is None
+
+ # Plural accessor
+ assert len(doc.fhir.patients) == num_patients
+
+ # Patients remain in bundle (no extraction)
+ bundle_patients = [
+ e.resource
+ for e in doc.fhir.bundle.entry
+ if type(e.resource).__name__ == "Patient"
+ ]
+ assert len(bundle_patients) == num_patients
+
+
@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"""
diff --git a/tests/fhir/test_bundle_helpers.py b/tests/fhir/test_bundle_helpers.py
index 6dd8ee65..d82ba692 100644
--- a/tests/fhir/test_bundle_helpers.py
+++ b/tests/fhir/test_bundle_helpers.py
@@ -13,7 +13,9 @@
get_resources,
set_resources,
get_resource_type,
+ extract_resources,
)
+from healthchain.fhir import merge_bundles, create_condition
def test_create_bundle():
@@ -120,3 +122,96 @@ 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_merge_bundles_basic_and_type():
+ """Merging combines entries and sets bundle type to collection by default."""
+ b1 = create_bundle("searchset")
+ add_resource(b1, create_condition(subject="Patient/123", code="E11.9"))
+ add_resource(b1, create_condition(subject="Patient/123", code="I10"))
+
+ b2 = create_bundle("searchset")
+ add_resource(b2, create_condition(subject="Patient/123", code="J44.9"))
+
+ merged = merge_bundles([b1, b2])
+ assert merged.entry is not None and len(merged.entry) == 3
+ assert merged.type == "collection"
+
+
+def test_merge_bundles_deduplication_toggle():
+ """Deduplication removes dups when True, keeps when False."""
+ c1 = create_condition(subject="Patient/123", code="E11.9")
+ c1.id = "cond-1"
+ c1_dup = create_condition(subject="Patient/123", code="E11.9")
+ c1_dup.id = "cond-1"
+
+ b1 = create_bundle("searchset")
+ add_resource(b1, c1)
+ b2 = create_bundle("searchset")
+ add_resource(b2, c1_dup)
+
+ merged_dedupe = merge_bundles([b1, b2], deduplicate=True)
+ assert merged_dedupe.entry is not None and len(merged_dedupe.entry) == 1
+
+ merged_all = merge_bundles([b1, b2], deduplicate=False)
+ assert merged_all.entry is not None and len(merged_all.entry) == 2
+
+
+def test_merge_bundles_preserves_full_url_and_handles_empty_none():
+ """Preserves fullUrl and handles empty/None bundles."""
+ b1 = create_bundle("searchset")
+ cond = create_condition(subject="Patient/123", code="E11.9")
+ add_resource(b1, cond, full_url="http://example.com/Condition/123")
+
+ b2 = create_bundle("searchset") # empty
+
+ merged = merge_bundles([b1, b2, None])
+ assert merged.entry is not None and len(merged.entry) == 1
+ assert merged.entry[0].fullUrl == "http://example.com/Condition/123"
+
+
+def test_merge_bundles_customizations():
+ """Supports custom bundle_type and custom dedupe_key semantics."""
+ # custom bundle_type
+ b = create_bundle("searchset")
+ add_resource(b, create_condition(subject="Patient/123", code="E11.9"))
+ merged_txn = merge_bundles([b], bundle_type="transaction")
+ assert merged_txn.type == "transaction"
+
+ # custom dedupe_key (keep both because ids differ)
+ c1 = create_condition(subject="Patient/123", code="E11.9")
+ c1.id = "id-1"
+ c2 = create_condition(subject="Patient/123", code="E11.9")
+ c2.id = "id-2"
+ b1 = create_bundle("searchset")
+ add_resource(b1, c1)
+ b2 = create_bundle("searchset")
+ add_resource(b2, c2)
+ merged_custom_key = merge_bundles([b1, b2], deduplicate=True, dedupe_key="id")
+ assert merged_custom_key.entry is not None and len(merged_custom_key.entry) == 2
+
+
+def test_extract_resources_removes_and_returns():
+ """extract_resources removes resources of a type and returns them."""
+ b = create_bundle()
+ c1 = create_condition(subject="Patient/1", code="E11.9")
+ c2 = create_condition(subject="Patient/1", code="I10")
+ add_resource(b, c1)
+ add_resource(b, c2)
+ extracted = extract_resources(b, "Condition")
+ assert len(extracted) == 2
+ assert b.entry == []
+
+
+def test_merge_bundles_dedupe_missing_key_keeps_all():
+ """Resources missing dedupe_key should not be collapsed when deduplicate=True."""
+ b1 = create_bundle("searchset")
+ b2 = create_bundle("searchset")
+ c1 = create_condition(subject="Patient/1", code="E11.9")
+ c1.id = None
+ c2 = create_condition(subject="Patient/1", code="E11.9")
+ c2.id = None
+ add_resource(b1, c1)
+ add_resource(b2, c2)
+ merged = merge_bundles([b1, b2], deduplicate=True, dedupe_key="id")
+ assert merged.entry is not None and len(merged.entry) == 2
diff --git a/tests/fhir/test_helpers.py b/tests/fhir/test_helpers.py
index da0fb684..5da02ae1 100644
--- a/tests/fhir/test_helpers.py
+++ b/tests/fhir/test_helpers.py
@@ -9,16 +9,20 @@
from healthchain.fhir.helpers import (
+ create_resource_from_dict,
create_single_codeable_concept,
create_single_reaction,
create_condition,
create_medication_statement,
create_allergy_intolerance,
- set_problem_list_item_category,
+ set_condition_category,
create_single_attachment,
create_document_reference,
read_content_attachment,
+ add_provenance_metadata,
+ add_coding_to_codeable_concept,
)
+import pytest
def test_create_single_codeable_concept():
@@ -75,7 +79,7 @@ def test_create_condition():
assert condition.code.coding[0].display == "Test Condition"
assert condition.code.coding[0].system == "http://test.system"
- set_problem_list_item_category(condition)
+ set_condition_category(condition, "problem-list-item")
assert condition.category[0].coding[0].code == "problem-list-item"
@@ -245,3 +249,40 @@ def test_read_attachment_with_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
+
+
+def test_create_resource_from_dict_success_and_failure():
+ cond_dict = {
+ "id": "x",
+ "clinicalStatus": {"coding": [{"code": "active"}]},
+ "subject": {"reference": "Patient/1"},
+ }
+ ok = create_resource_from_dict(cond_dict, "Condition")
+ assert ok is not None
+ bad = create_resource_from_dict({}, "NotAType")
+ assert bad is None
+
+
+def test_add_provenance_metadata_sets_source_and_tag():
+ cond = create_condition(subject="Patient/1", code="E11.9")
+ updated = add_provenance_metadata(cond, "epic", "aggregated", "Aggregated")
+ assert updated.meta.source.endswith(":epic")
+ assert updated.meta.lastUpdated is not None
+ assert any(t.code == "aggregated" for t in (updated.meta.tag or []))
+
+
+def test_add_coding_to_codeable_concept_appends():
+ cc = create_single_codeable_concept("123", "X")
+ updated = add_coding_to_codeable_concept(cc, "456", "http://sys", "Y")
+ assert any(c.code == "456" and c.system == "http://sys" for c in updated.coding)
+
+
+def test_set_condition_category_invalid_raises():
+ cond = create_condition(subject="Patient/1", code="E11.9")
+ with pytest.raises(ValueError):
+ set_condition_category(cond, "not-valid")
+
+
+def test_create_condition_without_code_is_none():
+ cond = create_condition(subject="Patient/1")
+ assert cond.code is None
diff --git a/tests/gateway/test_auth.py b/tests/gateway/test_auth.py
index 280efe89..0624b454 100644
--- a/tests/gateway/test_auth.py
+++ b/tests/gateway/test_auth.py
@@ -15,6 +15,11 @@
TokenInfo,
OAuth2TokenManager,
)
+from healthchain.gateway.clients.fhir.base import (
+ FHIRAuthConfig,
+ parse_fhir_auth_connection_string,
+)
+from healthchain.gateway.clients.fhir.sync.client import FHIRClient
@pytest.fixture
@@ -231,7 +236,6 @@ def test_oauth2_token_manager_thread_safety():
# Mock the token refresh to track calls
refresh_calls = []
- token_manager._refresh_token
def tracked_refresh():
refresh_calls.append(time.time())
@@ -347,3 +351,105 @@ def test_sync_vs_async_token_manager_distinction():
# Method signatures should be different
assert not inspect.iscoroutinefunction(sync_manager.get_access_token)
assert inspect.iscoroutinefunction(async_manager.get_access_token)
+
+
+def test_public_endpoint_mode_behaviors():
+ """Public endpoint: parse config, no auth required, no token, no Authorization header."""
+ connection_string = (
+ "fhir://fhir-open.cerner.com/r4/ec2458f2-1e24-41c8-b71b-0e701af7583d"
+ )
+
+ config = parse_fhir_auth_connection_string(connection_string)
+
+ assert (
+ config.base_url
+ == "https://fhir-open.cerner.com/r4/ec2458f2-1e24-41c8-b71b-0e701af7583d"
+ )
+ assert config.requires_auth is False
+
+ client = FHIRClient(auth_config=config)
+ assert client.token_manager is None
+
+ headers = client._get_headers()
+ assert "Authorization" not in headers
+ assert headers["Accept"] == "application/fhir+json"
+ assert headers["Content-Type"] == "application/fhir+json"
+
+
+def test_parse_connection_string_with_auth():
+ """Connection string with auth params creates authenticated config."""
+ connection_string = (
+ "fhir://epic.com/api/FHIR/R4"
+ "?client_id=test_app"
+ "&client_secret=test_secret"
+ "&token_url=https://epic.com/oauth2/token"
+ )
+
+ config = parse_fhir_auth_connection_string(connection_string)
+
+ assert config.base_url == "https://epic.com/api/FHIR/R4"
+ assert config.requires_auth is True
+ assert config.client_id == "test_app"
+ assert config.client_secret == "test_secret"
+ assert config.token_url == "https://epic.com/oauth2/token"
+
+
+@pytest.mark.parametrize(
+ "config_kwargs,error",
+ [
+ (
+ {"base_url": "https://epic.com/api/FHIR/R4", "client_id": "test_app"},
+ "token_url is required",
+ ),
+ (
+ {
+ "base_url": "https://epic.com/api/FHIR/R4",
+ "client_id": "test_app",
+ "token_url": "https://epic.com/token",
+ },
+ "Either client_secret or client_secret_path",
+ ),
+ (
+ {
+ "base_url": "https://epic.com/api/FHIR/R4",
+ "client_id": "test_app",
+ "client_secret": "secret",
+ "token_url": "https://epic.com/token",
+ "use_jwt_assertion": True,
+ },
+ "requires client_secret_path",
+ ),
+ ],
+)
+def test_fhir_auth_config_validation_rules(config_kwargs, error):
+ """FHIRAuthConfig enforces validation rules for authenticated configs."""
+ with pytest.raises(ValueError, match=error):
+ FHIRAuthConfig(**config_kwargs)
+
+
+def test_to_connection_string_roundtrip():
+ """FHIRAuthConfig serializes to and parses from connection string consistently."""
+ cfg = FHIRAuthConfig(
+ base_url="https://epic.com/api/FHIR/R4",
+ client_id="app",
+ client_secret="secret",
+ token_url="https://epic.com/token",
+ scope="system/*.read",
+ timeout=45,
+ verify_ssl=False,
+ )
+ conn = cfg.to_connection_string()
+ parsed = parse_fhir_auth_connection_string(conn)
+ assert parsed.base_url == cfg.base_url
+ assert parsed.client_id == cfg.client_id
+ assert parsed.token_url == cfg.token_url
+ assert parsed.timeout == 45
+ assert parsed.verify_ssl is False
+
+
+def test_from_env_missing_vars_raises(monkeypatch):
+ """from_env raises when required env vars are missing."""
+ for key in ["EPIC_CLIENT_ID", "EPIC_TOKEN_URL", "EPIC_BASE_URL"]:
+ monkeypatch.delenv(key, raising=False)
+ with pytest.raises(ValueError, match="Missing required environment variables"):
+ FHIRAuthConfig.from_env("EPIC")
diff --git a/tests/gateway/test_base_auth.py b/tests/gateway/test_base_auth.py
index 4d883a85..62bc3a2c 100644
--- a/tests/gateway/test_base_auth.py
+++ b/tests/gateway/test_base_auth.py
@@ -223,23 +223,23 @@ def test_fhir_auth_config_to_oauth2_config_conversion():
# Missing required params
(
"fhir://example.com/fhir/R4?client_id=test_client",
- "Missing required parameters",
+ "token_url is required",
),
# Missing secrets
(
"fhir://example.com/fhir/R4?client_id=test&token_url=https://example.com/token",
- "Either 'client_secret' or 'client_secret_path' parameter must be provided",
+ "Either client_secret or client_secret_path must be provided",
),
# Both secrets
(
"fhir://example.com/fhir/R4?client_id=test&client_secret=secret&client_secret_path=/path&token_url=https://example.com/token",
- "Cannot provide both 'client_secret' and 'client_secret_path' parameters",
+ "Cannot provide both client_secret and client_secret_path",
),
],
)
def test_connection_string_parsing_validation(connection_string, expected_error):
"""Connection string parsing enforces validation rules."""
- with pytest.raises(ValueError, match=expected_error):
+ with pytest.raises(Exception, match=expected_error):
parse_fhir_auth_connection_string(connection_string)
diff --git a/tests/gateway/test_fhir_gateway.py b/tests/gateway/test_fhir_gateway.py
index cef091cf..a009b3cb 100644
--- a/tests/gateway/test_fhir_gateway.py
+++ b/tests/gateway/test_fhir_gateway.py
@@ -3,10 +3,12 @@
from typing import Dict, Any
from fhir.resources.patient import Patient
-from fhir.resources.bundle import Bundle
+from fhir.resources.bundle import Bundle, BundleEntry
+from fhir.resources.condition import Condition
from healthchain.gateway.fhir import FHIRGateway
from healthchain.gateway.fhir.errors import FHIRConnectionError
+from healthchain.fhir import create_condition
class MockConnectionManager:
@@ -199,3 +201,86 @@ def perform_read():
# Verify concurrent access was tracked
assert client_usage_count == 5
+
+
+def test_search_with_auto_provenance():
+ """Gateway.search automatically adds provenance metadata when requested."""
+ gateway = FHIRGateway()
+ gateway.add_source("test_source", "fhir://test.example.com/fhir")
+
+ condition1 = create_condition(
+ subject="Patient/123", code="E11.9", display="Type 2 diabetes"
+ )
+ condition2 = create_condition(
+ subject="Patient/123", code="I10", display="Hypertension"
+ )
+
+ mock_bundle = Bundle(
+ type="searchset",
+ entry=[
+ BundleEntry(resource=condition1),
+ BundleEntry(resource=condition2),
+ ],
+ )
+
+ mock_client = Mock()
+ mock_client.search.return_value = mock_bundle
+
+ with patch.object(gateway, "get_client", return_value=mock_client):
+ result = gateway.search(
+ Condition,
+ {"patient": "Patient/123"},
+ "test_source",
+ add_provenance=True,
+ provenance_tag="aggregated",
+ )
+
+ assert result.entry is not None
+ assert len(result.entry) == 2
+ first_condition = result.entry[0].resource
+ assert first_condition.meta is not None
+ assert first_condition.meta.source == "urn:healthchain:source:test_source"
+ assert first_condition.meta.lastUpdated is not None
+ assert first_condition.meta.tag[0].code == "aggregated"
+ assert first_condition.meta.tag[0].display == "Aggregated"
+
+
+def test_search_without_auto_provenance():
+ """Gateway.search without auto-provenance leaves resources unchanged."""
+ gateway = FHIRGateway()
+ gateway.add_source("test_source", "fhir://test.example.com/fhir")
+
+ condition = create_condition(subject="Patient/123", code="E11.9")
+ condition.meta = None
+
+ mock_bundle = Bundle(type="searchset", entry=[BundleEntry(resource=condition)])
+ mock_client = Mock()
+ mock_client.search.return_value = mock_bundle
+
+ with patch.object(gateway, "get_client", return_value=mock_client):
+ result = gateway.search(
+ Condition, {"patient": "Patient/123"}, "test_source", add_provenance=False
+ )
+ assert result.entry is not None
+ assert len(result.entry) == 1
+ assert result.entry[0].resource.meta is None
+
+
+def test_search_with_empty_bundle():
+ """Gateway.search with auto-provenance handles empty bundles gracefully."""
+ gateway = FHIRGateway()
+ gateway.add_source("test_source", "fhir://test.example.com/fhir")
+
+ mock_bundle = Bundle(type="searchset", entry=None)
+ mock_client = Mock()
+ mock_client.search.return_value = mock_bundle
+
+ with patch.object(gateway, "get_client", return_value=mock_client):
+ result = gateway.search(
+ Condition,
+ {"patient": "Patient/123"},
+ "test_source",
+ add_provenance=True,
+ provenance_tag="aggregated",
+ )
+ assert result.entry is None
diff --git a/tests/integration_tests/conftest.py b/tests/integration_tests/conftest.py
new file mode 100644
index 00000000..311da567
--- /dev/null
+++ b/tests/integration_tests/conftest.py
@@ -0,0 +1,132 @@
+"""Shared fixtures for integration tests."""
+
+import pytest
+from unittest.mock import MagicMock
+from datetime import datetime
+
+from healthchain.gateway import NoteReaderService
+from healthchain.gateway.cds import CDSHooksService
+from healthchain.gateway.fhir import FHIRGateway
+from healthchain.gateway.events.dispatcher import EventDispatcher
+from healthchain.gateway import HealthChainAPI
+from healthchain.pipeline.medicalcodingpipeline import MedicalCodingPipeline
+from healthchain.pipeline.summarizationpipeline import SummarizationPipeline
+from healthchain.fhir import create_document_reference
+from fhir.resources.documentreference import DocumentReference
+from fhir.resources.patient import Patient
+from fhir.resources.meta import Meta
+
+
+@pytest.fixture
+def mock_coding_pipeline(test_cda_response):
+ """Mock medical coding pipeline that returns test CDA response."""
+ pipeline = MagicMock(spec=MedicalCodingPipeline)
+ pipeline.process_request.return_value = test_cda_response
+ return pipeline
+
+
+@pytest.fixture
+def mock_summarization_pipeline(test_cds_response_single_card):
+ """Mock summarization pipeline that returns test CDS response."""
+ pipeline = MagicMock(spec=SummarizationPipeline)
+ pipeline.process_request.return_value = test_cds_response_single_card
+ return pipeline
+
+
+@pytest.fixture
+def note_service(mock_coding_pipeline):
+ """NoteReader service with mock pipeline."""
+ service = NoteReaderService()
+
+ @service.method("ProcessDocument")
+ def process_document(cda_request):
+ return mock_coding_pipeline.process_request(cda_request)
+
+ return service
+
+
+@pytest.fixture
+def cds_service(mock_summarization_pipeline):
+ """CDS Hooks service with mock pipeline."""
+ service = CDSHooksService()
+
+ @service.hook("encounter-discharge", id="discharge-summary")
+ def handle_discharge_summary(request):
+ return mock_summarization_pipeline.process_request(request)
+
+ return service
+
+
+@pytest.fixture
+def fhir_gateway():
+ """FHIR Gateway with transform and aggregate handlers."""
+ gateway = FHIRGateway()
+
+ @gateway.transform(DocumentReference)
+ def enhance_document(id: str, source: str) -> DocumentReference:
+ document = create_document_reference(
+ data="AI-enhanced document",
+ content_type="text/xml",
+ description="Enhanced document",
+ )
+ document.extension = [
+ {
+ "url": "http://healthchain.org/extension/ai-summary",
+ "valueString": "AI-enhanced",
+ }
+ ]
+ document.meta = Meta(
+ lastUpdated=datetime.now().isoformat(),
+ tag=[{"system": "http://healthchain.org/tag", "code": "ai-enhanced"}],
+ )
+ return document
+
+ @gateway.aggregate(Patient)
+ def aggregate_patient_data(id: str, sources: list[str]) -> Patient:
+ patient = Patient()
+ patient.id = id
+ patient.gender = "unknown"
+ patient.birthDate = "1990-01-01"
+ return patient
+
+ return gateway
+
+
+@pytest.fixture
+def configured_app(note_service, cds_service, fhir_gateway):
+ """HealthChainAPI with all services configured."""
+ app = HealthChainAPI()
+ app.register_service(note_service)
+ app.register_service(cds_service)
+ app.register_gateway(fhir_gateway)
+ return app
+
+
+@pytest.fixture
+def event_dispatcher():
+ """Event dispatcher for testing event system."""
+ return EventDispatcher()
+
+
+@pytest.fixture
+def note_service_with_events(mock_coding_pipeline, event_dispatcher):
+ """NoteReader service with event dispatching enabled."""
+ service = NoteReaderService(event_dispatcher=event_dispatcher, use_events=True)
+
+ @service.method("ProcessDocument")
+ def process_document(cda_request):
+ return mock_coding_pipeline.process_request(cda_request)
+
+ return service
+
+
+@pytest.fixture
+def cds_service_with_events(mock_summarization_pipeline, event_dispatcher):
+ """CDS Hooks service with event dispatching enabled."""
+ service = CDSHooksService(event_dispatcher=event_dispatcher, use_events=True)
+
+ @service.hook("encounter-discharge", id="discharge-summary")
+ def handle_discharge_summary(request):
+ return mock_summarization_pipeline.process_request(request)
+
+ return service
diff --git a/tests/integration_tests/test_event_system_integration.py b/tests/integration_tests/test_event_system_integration.py
new file mode 100644
index 00000000..2c0c3e1a
--- /dev/null
+++ b/tests/integration_tests/test_event_system_integration.py
@@ -0,0 +1,108 @@
+"""Integration tests for HealthChain event system."""
+
+import pytest
+from datetime import datetime
+
+from healthchain.gateway import HealthChainAPI, EHREvent, EHREventType
+from healthchain.models.requests.cdsrequest import CDSRequest
+
+pytestmark = pytest.mark.anyio
+
+
+@pytest.mark.parametrize("anyio_backend", ["asyncio"])
+async def test_event_dispatcher_emit_and_publish(event_dispatcher):
+ """EventDispatcher handles both synchronous emit and async publish operations."""
+ event = EHREvent(
+ event_type=EHREventType.EHR_GENERIC,
+ source_system="test",
+ timestamp=datetime.now(),
+ payload={"data": "test"},
+ metadata={},
+ )
+
+ # Both operations should complete without error
+ event_dispatcher.emit(event)
+ await event_dispatcher.publish(event)
+
+
+@pytest.mark.parametrize(
+ "service_fixture,operation,request_fixture",
+ [
+ ("note_service_with_events", "ProcessDocument", "test_cda_request"),
+ ("cds_service_with_events", "encounter-discharge", None),
+ ],
+)
+def test_services_propagate_event_dispatcher(
+ service_fixture, operation, request_fixture, request, event_dispatcher
+):
+ """Services with events enabled correctly propagate the event dispatcher."""
+ service = request.getfixturevalue(service_fixture)
+
+ # Verify event capability is configured
+ assert service.use_events is True
+ assert service.events.dispatcher == event_dispatcher
+
+ # Process a request to ensure event system is engaged
+ if request_fixture:
+ req = request.getfixturevalue(request_fixture)
+ service.handle(operation, request=req)
+ else:
+ cds_request = CDSRequest(
+ hook=operation,
+ hookInstance="test",
+ context={"patientId": "123", "userId": "Practitioner/1"},
+ )
+ service.handle(operation, request=cds_request)
+
+
+def test_healthchain_api_propagates_dispatcher_to_services():
+ """HealthChainAPI propagates event dispatcher when registering services with events enabled."""
+ app = HealthChainAPI(enable_events=True)
+ assert app.event_dispatcher is not None
+
+ # Create and register service with events
+ from healthchain.gateway import NoteReaderService
+
+ service = NoteReaderService()
+
+ @service.method("ProcessDocument")
+ def process_document(cda_request):
+ return cda_request
+
+ app.register_service(service, use_events=True)
+
+ # Service should receive the app's dispatcher
+ assert service.events.dispatcher == app.event_dispatcher
+
+
+def test_custom_event_creator_integration(note_service_with_events):
+ """Services support custom event creators for specialized event generation."""
+
+ def custom_creator(operation, request, response):
+ return EHREvent(
+ event_type=EHREventType.NOTEREADER_PROCESS_NOTE,
+ source_system="custom",
+ timestamp=datetime.now(),
+ payload={"operation": operation},
+ metadata={"custom": True},
+ )
+
+ note_service_with_events.events.set_event_creator(custom_creator)
+ assert note_service_with_events.events._event_creator == custom_creator
+
+
+def test_event_model_structure_and_naming():
+ """EHREvent provides consistent structure and naming convention."""
+ event = EHREvent(
+ event_type=EHREventType.EHR_GENERIC,
+ source_system="test-system",
+ timestamp=datetime.now(),
+ payload={"data": {"key": "value"}},
+ metadata={"env": "test"},
+ )
+
+ assert event.event_type == EHREventType.EHR_GENERIC
+ assert event.source_system == "test-system"
+ assert event.payload["data"]["key"] == "value"
+ assert event.metadata["env"] == "test"
+ assert event.get_name() == EHREventType.EHR_GENERIC.value
diff --git a/tests/integration_tests/test_healthchain_api_e2e.py b/tests/integration_tests/test_healthchain_api_e2e.py
new file mode 100644
index 00000000..80428a3d
--- /dev/null
+++ b/tests/integration_tests/test_healthchain_api_e2e.py
@@ -0,0 +1,73 @@
+"""Integration tests for HealthChain API end-to-end workflows."""
+
+from healthchain.models.requests.cdsrequest import CDSRequest
+
+
+def test_notereader_service_processes_through_pipeline(
+ note_service, test_cda_request, test_cda_response, mock_coding_pipeline
+):
+ """NoteReader service integrates with medical coding pipeline for document processing."""
+ response = note_service.handle("ProcessDocument", request=test_cda_request)
+
+ # Verify pipeline was invoked and response matches
+ mock_coding_pipeline.process_request.assert_called_once_with(test_cda_request)
+ assert response.document == test_cda_response.document
+
+
+def test_cds_service_processes_through_pipeline(
+ cds_service, test_cds_response_single_card, mock_summarization_pipeline
+):
+ """CDS Hooks service integrates with summarization pipeline for decision support."""
+ cds_request = CDSRequest(
+ hook="encounter-discharge",
+ hookInstance="test",
+ context={"patientId": "123", "userId": "Practitioner/1"},
+ )
+
+ response = cds_service.handle("encounter-discharge", request=cds_request)
+
+ # Verify pipeline was invoked and cards generated
+ mock_summarization_pipeline.process_request.assert_called_once_with(cds_request)
+ assert len(response.cards) == 1
+ assert response.cards[0].summary == test_cds_response_single_card.cards[0].summary
+
+
+def test_fhir_gateway_supports_multiple_resource_operations(fhir_gateway):
+ """FHIR Gateway handles both transform and aggregate operations on different resource types."""
+ from fhir.resources.documentreference import DocumentReference
+ from fhir.resources.patient import Patient
+
+ # Transform operation
+ doc = fhir_gateway._resource_handlers[DocumentReference]["transform"](
+ "id1", "source1"
+ )
+ assert isinstance(doc, DocumentReference)
+ assert doc.extension
+ assert any("ai-summary" in ext.url for ext in doc.extension)
+
+ # Aggregate operation
+ patient = fhir_gateway._resource_handlers[Patient]["aggregate"]("id2", ["s1", "s2"])
+ assert isinstance(patient, Patient)
+ assert patient.id == "id2"
+
+
+def test_healthchain_app_integrates_multiple_service_types(configured_app):
+ """HealthChainAPI successfully orchestrates NoteReader, CDS Hooks, and FHIR Gateway services."""
+ # Verify all service types are registered
+ assert len(configured_app.services) == 2
+ assert len(configured_app.gateways) == 1
+
+ # Verify service types
+ assert "NoteReaderService" in configured_app.services
+ assert "CDSHooksService" in configured_app.services
+ assert "FHIRGateway" in configured_app.gateways
+
+ # Verify services are operational by checking they have required methods
+ note_service = configured_app.services["NoteReaderService"]
+ assert hasattr(note_service, "handle")
+
+ cds_service = configured_app.services["CDSHooksService"]
+ assert hasattr(cds_service, "handle")
+
+ fhir_gateway = configured_app.gateways["FHIRGateway"]
+ assert hasattr(fhir_gateway, "_resource_handlers")
diff --git a/tests/integration_tests/test_healthchain_api_endpoints.py b/tests/integration_tests/test_healthchain_api_endpoints.py
new file mode 100644
index 00000000..2a426923
--- /dev/null
+++ b/tests/integration_tests/test_healthchain_api_endpoints.py
@@ -0,0 +1,128 @@
+"""Integration tests for HealthChain API HTTP endpoints."""
+
+import pytest
+from fastapi.testclient import TestClient
+
+pytestmark = pytest.mark.anyio
+
+
+@pytest.fixture
+def client(configured_app):
+ """FastAPI TestClient for API testing."""
+ return TestClient(configured_app)
+
+
+@pytest.mark.parametrize(
+ "endpoint,expected_fields",
+ [
+ ("/", ["name", "version", "gateways", "services"]),
+ ("/metadata", ["resourceType", "status", "gateways", "services"]),
+ ],
+)
+def test_api_metadata_endpoints(client, endpoint, expected_fields):
+ """API metadata endpoints return expected structure."""
+ response = client.get(endpoint)
+ assert response.status_code == 200
+ data = response.json()
+ for field in expected_fields:
+ assert field in data
+
+
+def test_health_check_returns_healthy_status(client):
+ """Health check endpoint returns healthy status with registered services."""
+ response = client.get("/health")
+ assert response.status_code == 200
+ assert response.json()["status"] == "healthy"
+
+
+@pytest.mark.parametrize(
+ "endpoint,expected_resource_type,expected_kind",
+ [
+ ("/metadata", "CapabilityStatement", None),
+ ("/fhir/metadata", "CapabilityStatement", "instance"),
+ ],
+)
+def test_fhir_capability_statements(
+ client, endpoint, expected_resource_type, expected_kind
+):
+ """FHIR metadata endpoints return valid CapabilityStatement resources."""
+ response = client.get(endpoint)
+ assert response.status_code == 200
+ data = response.json()
+ assert data["resourceType"] == expected_resource_type
+ if expected_kind:
+ assert data["kind"] == expected_kind
+ assert "rest" in data
+
+
+def test_fhir_gateway_status(client):
+ """FHIR status endpoint returns gateway operational details."""
+ response = client.get("/fhir/status")
+ assert response.status_code == 200
+ data = response.json()
+ assert data["gateway_type"] == "FHIRGateway"
+ assert data["status"] == "active"
+ assert "supported_operations" in data
+ # Verify operations are organized by resource type
+ assert "DocumentReference" in data["supported_operations"]
+ assert "Patient" in data["supported_operations"]
+
+
+def test_fhir_transform_applies_ai_enhancements(client):
+ """FHIR transform endpoint enhances DocumentReference with AI extensions."""
+ response = client.get("/fhir/transform/DocumentReference/test-id?source=demo")
+
+ assert response.status_code == 200
+ assert "application/fhir+json" in response.headers["content-type"]
+ data = response.json()
+ assert data["resourceType"] == "DocumentReference"
+
+ # Verify AI enhancement applied
+ assert data["extension"]
+ assert any("ai-summary" in ext["url"] for ext in data["extension"])
+ assert data["meta"]["tag"][0]["code"] == "ai-enhanced"
+
+
+def test_fhir_aggregate_combines_patient_data(client):
+ """FHIR aggregate endpoint merges Patient data from multiple sources."""
+ response = client.get(
+ "/fhir/aggregate/Patient?id=test-patient&sources=demo&sources=epic"
+ )
+
+ assert response.status_code == 200
+ assert "application/fhir+json" in response.headers["content-type"]
+ data = response.json()
+ assert data["resourceType"] == "Patient"
+ assert data["id"] == "test-patient"
+
+
+def test_cds_discovery_returns_available_services(client):
+ """CDS Hooks discovery endpoint exposes registered service definitions."""
+ response = client.get("/cds/cds-discovery")
+
+ assert response.status_code == 200
+ data = response.json()
+ assert "services" in data
+ assert len(data["services"]) == 1
+
+ # Verify service configuration
+ service = data["services"][0]
+ assert service["hook"] == "encounter-discharge"
+ assert service["id"] == "discharge-summary"
+
+
+def test_cds_service_processes_hook_request(client, test_cds_response_single_card):
+ """CDS Hooks service endpoint processes requests and returns cards."""
+ request_data = {
+ "hook": "encounter-discharge",
+ "hookInstance": "test-instance",
+ "context": {"patientId": "123", "userId": "Practitioner/1"},
+ }
+
+ response = client.post("/cds/cds-services/discharge-summary", json=request_data)
+
+ assert response.status_code == 200
+ data = response.json()
+ assert "cards" in data
+ assert len(data["cards"]) == 1
+ assert data["cards"][0]["summary"] == test_cds_response_single_card.cards[0].summary
diff --git a/tests/pipeline/test_cdaadapter.py b/tests/pipeline/test_cdaadapter.py
index d9fdaf5d..9f33b67d 100644
--- a/tests/pipeline/test_cdaadapter.py
+++ b/tests/pipeline/test_cdaadapter.py
@@ -16,11 +16,11 @@ def cda_adapter():
@patch("healthchain.io.adapters.cdaadapter.create_interop")
@patch("healthchain.io.adapters.cdaadapter.create_document_reference")
@patch("healthchain.io.adapters.cdaadapter.read_content_attachment")
-@patch("healthchain.io.adapters.cdaadapter.set_problem_list_item_category")
+@patch("healthchain.io.adapters.cdaadapter.set_condition_category")
@patch("healthchain.io.adapters.cdaadapter.Document", autospec=True)
def test_parse(
mock_document_class,
- mock_set_problem_category,
+ mock_set_condition_category,
mock_read_content,
mock_create_doc_ref,
mock_create_interop,
@@ -104,7 +104,9 @@ def test_parse(
assert mock_doc.fhir.allergy_list == [test_allergy]
# 6. Verify problem list items were categorized
- mock_set_problem_category.assert_called_once_with(test_condition)
+ mock_set_condition_category.assert_called_once_with(
+ test_condition, "problem-list-item"
+ )
# 7. Verify document reference content was read
mock_read_content.assert_called_once_with(note_doc_ref)