diff --git a/README.md b/README.md index c9be8f3b..26525af2 100644 --- a/README.md +++ b/README.md @@ -26,6 +26,7 @@ Came here from NHS RPySOC 2024 โœจ? ## Features - [x] ๐Ÿ”ฅ Build FHIR-native pipelines or use [pre-built ones](https://dotimplement.github.io/HealthChain/reference/pipeline/pipeline/#prebuilt) for your healthcare NLP and ML tasks - [x] ๐Ÿ”Œ Connect pipelines to any EHR system with built-in [CDA and FHIR Connectors](https://dotimplement.github.io/HealthChain/reference/pipeline/connectors/connectors/) +- [x] ๐Ÿ”„ Convert between FHIR, CDA, and HL7v2 with the [InteropEngine](https://dotimplement.github.io/HealthChain/reference/interop/interop/) - [x] ๐Ÿงช Test your pipelines in full healthcare-context aware [sandbox](https://dotimplement.github.io/HealthChain/reference/sandbox/sandbox/) environments - [x] ๐Ÿ—ƒ๏ธ Generate [synthetic healthcare data](https://dotimplement.github.io/HealthChain/reference/utilities/data_generator/) for testing and development - [x] ๐Ÿš€ Deploy sandbox servers locally with [FastAPI](https://fastapi.tiangolo.com/) @@ -117,6 +118,24 @@ cda_data = CdaRequest(document="") output = pipeline(cda_data) ``` +## Interoperability + +The InteropEngine is a template-based system that allows you to convert between FHIR, CDA, and HL7v2. + +```python +from healthchain.interop import create_engine, FormatType + +engine = create_engine() + +with open("tests/data/test_cda.xml", "r") as f: + cda_data = f.read() + +# Convert CDA to FHIR +fhir_resources = engine.to_fhir(cda_data, src_format=FormatType.CDA) + +# Convert FHIR to CDA +cda_data = engine.from_fhir(fhir_resources, dest_format=FormatType.CDA) +``` ## Sandbox @@ -219,7 +238,7 @@ healthchain run mycds.py By default, the server runs at `http://127.0.0.1:8000`, and you can interact with the exposed endpoints at `/docs`. ## Road Map -- [ ] ๐Ÿ”„ Transform and validate healthcare HL7v2, CDA to FHIR with template-based interop engine +- [x] ๐Ÿ”„ Transform and validate healthcare HL7v2, CDA to FHIR with template-based interop engine - [ ] ๐Ÿฅ Runtime connection health and EHR integration management - connect to FHIR APIs and legacy systems - [ ] ๐Ÿ“Š Track configurations, data provenance, and monitor model performance with MLFlow integration - [ ] ๐Ÿš€ Compliance monitoring, auditing at deployment as a sidecar service diff --git a/configs/defaults.yaml b/configs/defaults.yaml new file mode 100644 index 00000000..16e33509 --- /dev/null +++ b/configs/defaults.yaml @@ -0,0 +1,59 @@ +# HealthChain Interoperability Engine Default Configuration +# This file contains default values used throughout the engine + +defaults: + # Common defaults for all resources + common: + id_prefix: "hc-" + timestamp: "%Y%m%d" + reference_name: "#{uuid}name" + subject: + reference: "Patient/example" + + # Mapping directory configuration + mappings_dir: "cda_default" + + # Resource-specific defaults + resources: + Condition: + clinicalStatus: + coding: + - system: "http://terminology.hl7.org/CodeSystem/condition-clinical" + code: "unknown" + display: "Unknown" + MedicationStatement: + status: "unknown" + medication: + concept: + coding: + - system: "http://terminology.hl7.org/CodeSystem/v3-NullFlavor" + code: "UNK" + display: "Unknown" + +# TODO: More control over settings +# # Validation settings +# validation: +# strict_mode: true +# warn_on_missing: true +# ignore_unknown_fields: true + +# # Parser settings +# parser: +# max_entries: 1000 +# skip_empty_sections: true + +# # Logging settings +# logging: +# level: "INFO" +# include_timestamps: true + +# # Error handling +# errors: +# retry_count: 3 +# fail_on_critical: true + +# # Performance settings +# performance: +# cache_templates: true +# cache_mappings: true +# batch_size: 100 diff --git a/configs/environments/development.yaml b/configs/environments/development.yaml new file mode 100644 index 00000000..03ce37ec --- /dev/null +++ b/configs/environments/development.yaml @@ -0,0 +1,33 @@ +# Development Environment Configuration +# This file contains settings specific to the development environment +# TODO: Implement + +# Logging settings for development +logging: + level: "DEBUG" + include_timestamps: true + console_output: true + file_output: false + +# Error handling for development +errors: + retry_count: 1 + fail_on_critical: true + verbose_errors: true + +# Performance settings for development +performance: + cache_templates: false # Disable caching for easier template development + cache_mappings: false # Disable caching for easier mapping development + batch_size: 10 # Smaller batch size for easier debugging + +# Default resource fields for development +defaults: + common: + id_prefix: "dev-" # Development-specific ID prefix + subject: + reference: "Patient/Foo" + +# Template settings for development +templates: + reload_on_change: true # Automatically reload templates when they change diff --git a/configs/environments/production.yaml b/configs/environments/production.yaml new file mode 100644 index 00000000..846a914c --- /dev/null +++ b/configs/environments/production.yaml @@ -0,0 +1,37 @@ +# Production Environment Configuration +# This file contains settings specific to the production environment +# TODO: Implement + +# Logging settings for production +logging: + level: "WARNING" + include_timestamps: true + console_output: false + file_output: true + file_path: "/var/log/healthchain/interop.log" + rotate_logs: true + max_log_size_mb: 10 + backup_count: 5 + +# Error handling for production +errors: + retry_count: 3 + fail_on_critical: true + verbose_errors: false + +# Performance settings for production +performance: + cache_templates: true # Enable caching for better performance + cache_mappings: true # Enable caching for better performance + batch_size: 100 # Larger batch size for better throughput + +# Default resource fields for production +defaults: + common: + id_prefix: "hc-" # Production ID prefix + subject: + reference: "Patient/example" + +# Template settings for production +templates: + reload_on_change: false # Don't reload templates in production diff --git a/configs/environments/testing.yaml b/configs/environments/testing.yaml new file mode 100644 index 00000000..2c6aca95 --- /dev/null +++ b/configs/environments/testing.yaml @@ -0,0 +1,39 @@ +# Testing Environment Configuration +# This file contains settings specific to the testing environment +# TODO: Implement +# Logging settings for testing +logging: + level: "INFO" + include_timestamps: true + console_output: true + file_output: true + file_path: "./logs/test-interop.log" + +# Error handling for testing +errors: + retry_count: 2 + fail_on_critical: true + verbose_errors: true + +# Performance settings for testing +performance: + cache_templates: true # Enable caching for realistic testing + cache_mappings: true # Enable caching for realistic testing + batch_size: 50 # Medium batch size for testing + +# Default resource fields for testing +defaults: + common: + id_prefix: "test-" # Testing-specific ID prefix + subject: + reference: "Patient/test-example" + +# Template settings for testing +templates: + reload_on_change: false # Don't reload templates in testing + +# Validation settings for testing +validation: + strict_mode: true + warn_on_missing: true + ignore_unknown_fields: false # Stricter validation for testing diff --git a/configs/interop/cda/document/ccd.yaml b/configs/interop/cda/document/ccd.yaml new file mode 100644 index 00000000..2afe5eed --- /dev/null +++ b/configs/interop/cda/document/ccd.yaml @@ -0,0 +1,55 @@ +# CCD Document Configuration +# This file contains configuration for CCD documents + +# Document templates (required) +templates: + document: "fhir_cda/document" + section: "fhir_cda/section" + +# Basic document information +code: + code: "34133-9" + code_system: "2.16.840.1.113883.6.1" + code_system_name: "LOINC" + display: "Summarization of Episode Note" +confidentiality_code: + code: "N" + code_system: "2.16.840.1.113883.5.25" +language_code: "en-US" +realm_code: "GB" +type_id: + extension: "POCD_HD000040" + root: "2.16.840.1.113883.1.3" +template_id: + root: "1.2.840.114350.1.72.1.51693" + +# Document structure +structure: + # Header configuration + header: + include_patient: false + include_author: false + include_custodian: false + include_legal_authenticator: false + + # Body configuration + body: + structured_body: true + non_xml_body: false + include_sections: + - "allergies" + - "medications" + - "problems" + - "notes" + +# Rendering configuration +rendering: + # XML formatting + xml: + pretty_print: true + encoding: "UTF-8" + + # Narrative generation + narrative: + include: true + generate_if_missing: true diff --git a/configs/interop/cda/sections/allergies.yaml b/configs/interop/cda/sections/allergies.yaml new file mode 100644 index 00000000..e9d32663 --- /dev/null +++ b/configs/interop/cda/sections/allergies.yaml @@ -0,0 +1,88 @@ +# Allergies Section Configuration +# ======================== + +# Metadata for both extraction and rendering processes +resource: "AllergyIntolerance" +resource_template: "cda_fhir/allergy_intolerance" +entry_template: "fhir_cda/allergy_entry" + +# Section identifiers (used for extraction) +identifiers: + template_id: "2.16.840.1.113883.10.20.1.2" + code: "48765-2" + code_system: "2.16.840.1.113883.6.1" + code_system_name: "LOINC" + display: "Allergies" + reaction: + template_id: "1.3.6.1.4.1.19376.1.5.3.1.4.5" + severity: + template_id: "1.3.6.1.4.1.19376.1.5.3.1.4.1" + +# Template configuration (used for rendering/generation) +template: + # Act element configuration + act: + template_id: + - "1.3.6.1.4.1.19376.1.5.3.1.4.5.1" + - "1.3.6.1.4.1.19376.1.5.3.1.4.5.3" + - "2.16.840.1.113883.3.88.11.32.6" + - "2.16.840.1.113883.3.88.11.83.6" + status_code: "active" + + # Allergy observation configuration + allergy_obs: + type_code: "SUBJ" + inversion_ind: false + template_id: + - "1.3.6.1.4.1.19376.1.5.3.1.4.5" + - "1.3.6.1.4.1.19376.1.5.3.1.4.6" + - "2.16.840.1.113883.10.20.1.18" + - "1.3.6.1.4.1.19376.1.5.3.1" + - "2.16.840.1.113883.10.20.1.28" + code: "420134006" + code_system: "2.16.840.1.113883.6.96" + code_system_name: "SNOMED CT" + display_name: "Propensity to adverse reactions" + status_code: "completed" + + # Reaction observation configuration + reaction_obs: + template_id: + - "2.16.840.1.113883.10.20.1.54" + - "1.3.6.1.4.1.19376.1.5.3.1.4.5" + code: "RXNASSESS" + status_code: "completed" + + # Severity observation configuration + severity_obs: + template_id: + - "2.16.840.1.113883.10.20.1.55" + - "1.3.6.1.4.1.19376.1.5.3.1.4.1" + code: "SEV" + code_system: "2.16.840.1.113883.5.4" + code_system_name: "ActCode" + display_name: "Severity" + status_code: "completed" + value: + code_system: "2.16.840.1.113883.5.1063" + code_system_name: "SeverityObservation" + + # Clinical status observation configuration + clinical_status_obs: + template_id: "2.16.840.1.113883.10.20.1.39" + code: "33999-4" + code_system: "2.16.840.1.113883.6.1" + code_system_name: "LOINC" + display_name: "Status" + status_code: "completed" + +# Rendering configuration (not used) +rendering: + narrative: + include: true + template: "narratives/allergy_narrative" + entry: + include_status: true + include_reaction: true + include_severity: true + include_dates: true diff --git a/configs/interop/cda/sections/medications.yaml b/configs/interop/cda/sections/medications.yaml new file mode 100644 index 00000000..c37c7195 --- /dev/null +++ b/configs/interop/cda/sections/medications.yaml @@ -0,0 +1,72 @@ +# Medications Section Configuration +# ======================== + +# Metadata for both extraction and rendering processes +resource: "MedicationStatement" +resource_template: "cda_fhir/medication_statement" +entry_template: "fhir_cda/medication_entry" + +# Section identifiers (used for extraction) +identifiers: + template_id: "2.16.840.1.113883.10.20.1.8" + code: "10160-0" + code_system: "2.16.840.1.113883.6.1" + code_system_name: "LOINC" + display: "Medications" + clinical_status: + template_id: "2.16.840.1.113883.10.20.1.47" + code: "33999-4" + +# Template configuration (used for rendering/generation) +template: + # Substance administration configuration + substance_admin: + template_id: + - "2.16.840.1.113883.10.20.1.24" + - "2.16.840.1.113883.3.88.11.83.8" + - "1.3.6.1.4.1.19376.1.5.3.1.4.7" + - "1.3.6.1.4.1.19376.1.5.3.1.4.7.1" + - "2.16.840.1.113883.3.88.11.32.8" + status_code: "completed" + + # Manufactured product configuration + manufactured_product: + template_id: + - "1.3.6.1.4.1.19376.1.5.3.1.4.7.2" + - "2.16.840.1.113883.10.20.1.53" + - "2.16.840.1.113883.3.88.11.32.9" + - "2.16.840.1.113883.3.88.11.83.8.2" + + # Clinical status observation configuration + clinical_status_obs: + template_id: "2.16.840.1.113883.10.20.1.47" + status_code: "completed" + code: "33999-4" + code_system: "2.16.840.1.113883.6.1" + code_system_name: "LOINC" + display_name: "Status" + value: + code: "755561003" + code_system: "2.16.840.1.113883.6.96" + code_system_name: "SNOMED CT" + display_name: "Active" + +# Rendering configuration +rendering: + narrative: + include: true + template: "narratives/medication_narrative" + entry: + include_status: true + include_dates: true + include_dosage: true + include_route: true + include_frequency: true + +# Default values for template +defaults: + status_code: "active" + type_code: "REFR" + medication_status_code: "755561003" + medication_status_display: "Active" + medication_status_system: "2.16.840.1.113883.6.96" diff --git a/configs/interop/cda/sections/notes.yaml b/configs/interop/cda/sections/notes.yaml new file mode 100644 index 00000000..bdbae936 --- /dev/null +++ b/configs/interop/cda/sections/notes.yaml @@ -0,0 +1,34 @@ +# Notes Section Configuration +# ===================== + +# Metadata for both extraction and rendering processes +resource: "DocumentReference" +resource_template: "cda_fhir/document_reference" +entry_template: "fhir_cda/note_entry" + +# Section identifiers (used for extraction) +identifiers: + template_id: "1.2.840.114350.1.72.1.200001" + code: "51847-2" + code_system: "2.16.840.1.113883.6.1" + code_system_name: "LOINC" + display: "Progress Notes" + +# Template configuration (used for rendering/generation) +template: + # Note section configuration + note_section: + template_id: + - "1.2.840.114350.1.72.1.200001" + code: "51847-2" + code_system: "2.16.840.1.113883.6.1" + code_system_name: "LOINC" + display_name: "Progress Notes" + status_code: "completed" + +# Rendering configuration +rendering: + narrative: + include: true + template: "narratives/note_narrative" + description: "Progress Notes extracted from CDA notes section" diff --git a/configs/interop/cda/sections/problems.yaml b/configs/interop/cda/sections/problems.yaml new file mode 100644 index 00000000..92614471 --- /dev/null +++ b/configs/interop/cda/sections/problems.yaml @@ -0,0 +1,61 @@ +# Problems Section Configuration +# ======================== + +# Metadata for both extraction and rendering processes +resource: "Condition" +resource_template: "cda_fhir/condition" +entry_template: "fhir_cda/problem_entry" + +# Section identifiers (used for extraction) +identifiers: + template_id: "2.16.840.1.113883.10.20.1.11" + code: "11450-4" + code_system: "2.16.840.1.113883.6.1" + code_system_name: "LOINC" + display: "Problem List" + clinical_status: + template_id: "2.16.840.1.113883.10.20.1.47" + code: "33999-4" + +# Template configuration (used for rendering/generation) +template: + # Act element configuration + act: + template_id: + - "2.16.840.1.113883.10.20.1.27" + - "1.3.6.1.4.1.19376.1.5.3.1.4.5.1" + - "1.3.6.1.4.1.19376.1.5.3.1.4.5.2" + - "2.16.840.1.113883.3.88.11.32.7" + - "2.16.840.1.113883.3.88.11.83.7" + status_code: "completed" + + # Problem observation configuration + problem_obs: + type_code: "SUBJ" + inversion_ind: false + template_id: + - "1.3.6.1.4.1.19376.1.5.3.1.4.5" + - "2.16.840.1.113883.10.20.1.28" + code: "55607006" + code_system: "2.16.840.1.113883.6.96" + code_system_name: "SNOMED CT" + display_name: "Problem" + status_code: "completed" + + # Clinical status observation configuration + clinical_status_obs: + template_id: "2.16.840.1.113883.10.20.1.47" + code: "33999-4" + code_system: "2.16.840.1.113883.6.1" + code_system_name: "LOINC" + display_name: "Status" + status_code: "completed" + +# Rendering configuration (not used) +rendering: + narrative: + include: true + template: "narratives/problem_narrative" + entry: + include_status: true + include_dates: true diff --git a/configs/mappings/README.md b/configs/mappings/README.md new file mode 100644 index 00000000..dea5d5c6 --- /dev/null +++ b/configs/mappings/README.md @@ -0,0 +1,33 @@ +# HealthChain Mappings + +This directory contains mapping configurations used by the HealthChain interoperability module to translate between different healthcare data formats. + +## Organization + +The mappings are organized by the formats they translate between: + +- `cda_default/` - Default mappings for CDA format + - `systems.yaml` - Code system mappings (SNOMED CT, LOINC, etc.) + - `status_codes.yaml` - Status code mappings (active, inactive, resolved) + - `severity_codes.yaml` - Severity code mappings (mild, moderate, severe) + + +## Configuration + +The mapping directory to use is configurable through the `defaults.mappings_dir` configuration value. The default is `"cda_default"`. + +## Structure + +Each mapping file uses a flat structure designed for clarity and simplicity. See the README in each subdirectory for detailed examples and documentation. + +## Adding New Mappings + +To add mappings for new format combinations: + +1. Create a new subdirectory (e.g., `hl7v2/` for HL7v2 mappings, or `cda_local/` for local CDA mappings) +2. Add yaml files for each mapping type needed +3. Update the mapping directory in your configuration to use the new mappings + +## Usage + +These mapping files are loaded automatically by the `InteropConfigManager` and are used by the filters in the `healthchain.interop.filters` module to translate codes between formats. diff --git a/configs/mappings/cda_default/README.md b/configs/mappings/cda_default/README.md new file mode 100644 index 00000000..817129e4 --- /dev/null +++ b/configs/mappings/cda_default/README.md @@ -0,0 +1,53 @@ +# CDA Default Mappings + +This directory contains default mapping configurations used to translate between CDA (Clinical Document Architecture) and FHIR (Fast Healthcare Interoperability Resources) formats. + +## Files + +- `systems.yaml` - Code system mappings between FHIR URLs and CDA OIDs +- `status_codes.yaml` - Status code mappings (FHIR status codes to CDA status codes) +- `severity_codes.yaml` - Severity code mappings (FHIR severity codes to CDA severity codes) + +## Structure + +Each mapping file uses a flat structure designed for clarity and simplicity, consistently using FHIR values as keys mapping to CDA values: + +### systems.yaml +```yaml +"http://snomed.info/sct": + oid: "2.16.840.1.113883.6.96" + name: "SNOMED CT" + +"http://loinc.org": + oid: "2.16.840.1.113883.6.1" + name: "LOINC" +``` + +### status_codes.yaml +```yaml +"active": + code: "55561003" + display: "Active" + +"resolved": + code: "413322009" + display: "Resolved" +``` + +### severity_codes.yaml +```yaml +"severe": + code: "H" + display: "Severe" + +"moderate": + code: "M" + display: "Moderate" +``` + +## Usage + +These mappings are used by the filters in the interoperability module for bidirectional translation between CDA and FHIR: + +1. **FHIR to CDA**: Maps FHIR URLs to CDA OIDs, FHIR status codes to CDA status codes, etc. +2. **CDA to FHIR**: Maps CDA OIDs to FHIR URLs, CDA status codes to FHIR status codes, etc. (reverse lookup) diff --git a/configs/mappings/cda_default/severity_codes.yaml b/configs/mappings/cda_default/severity_codes.yaml new file mode 100644 index 00000000..e9d63e2b --- /dev/null +++ b/configs/mappings/cda_default/severity_codes.yaml @@ -0,0 +1,12 @@ +# Allergy and reaction severity codes (FHIR to CDA) +"severe": + code: "H" + display: "Severe" + +"moderate": + code: "M" + display: "Moderate" + +"mild": + code: "L" + display: "Mild" diff --git a/configs/mappings/cda_default/status_codes.yaml b/configs/mappings/cda_default/status_codes.yaml new file mode 100644 index 00000000..6203d8cf --- /dev/null +++ b/configs/mappings/cda_default/status_codes.yaml @@ -0,0 +1,12 @@ +# Clinical status codes (FHIR to CDA) +"active": + code: "55561003" + display: "Active" + +"resolved": + code: "413322009" + display: "Resolved" + +"inactive": + code: "73425007" + display: "Inactive" diff --git a/configs/mappings/cda_default/systems.yaml b/configs/mappings/cda_default/systems.yaml new file mode 100644 index 00000000..d7414502 --- /dev/null +++ b/configs/mappings/cda_default/systems.yaml @@ -0,0 +1,23 @@ +"http://snomed.info/sct": + oid: "2.16.840.1.113883.6.96" + name: "SNOMED CT" + +"http://loinc.org": + oid: "2.16.840.1.113883.6.1" + name: "LOINC" + +"http://www.nlm.nih.gov/research/umls/rxnorm": + oid: "2.16.840.1.113883.6.88" + name: "RxNorm" + +"http://ncicb.nci.nih.gov/xml/owl/EVS/Thesaurus.owl": + oid: "2.16.840.1.113883.3.26.1.1" + name: "NCI Thesaurus" + +"http://terminology.hl7.org/CodeSystem/condition-clinical": + oid: "2.16.840.1.113883.6.96" + name: "SNOMED CT" + +"http://terminology.hl7.org/CodeSystem/allergyintolerance-clinical": + oid: "2.16.840.1.113883.6.96" + name: "SNOMED CT" diff --git a/configs/templates/cda_fhir/allergy_intolerance.liquid b/configs/templates/cda_fhir/allergy_intolerance.liquid new file mode 100644 index 00000000..e77f4a34 --- /dev/null +++ b/configs/templates/cda_fhir/allergy_intolerance.liquid @@ -0,0 +1,79 @@ +{ + {% if entry.act.entryRelationship.size %} + {% assign obs = entry.act.entryRelationship[0].observation %} + {% else %} + {% assign obs = entry.act.entryRelationship.observation %} + {% endif %} + + {% assign clinical_status = obs | extract_clinical_status: config %} + {% assign reactions = obs | extract_reactions: config %} + + "resourceType": "AllergyIntolerance" + + {% if clinical_status != blank %} + , + "clinicalStatus": { + "coding": [{ + "system": "http://terminology.hl7.org/CodeSystem/allergyintolerance-clinical", + "code": "{{ clinical_status | map_status: 'cda_to_fhir' }}" + }] + } + {% endif %} + + {% if obs.code %} + , + "type": { + "coding": [{ + "system": "{{ obs.code['@codeSystem'] | map_system: 'cda_to_fhir' }}", + "code": "{{ obs.code['@code'] }}", + "display": "{{ obs.code['@displayName'] }}" + }] + } + {% endif %} + + {% if obs.participant.participantRole.playingEntity %} + , + {% assign playing_entity = obs.participant.participantRole.playingEntity %} + "code": { + "coding": [{ + "system": "{{ playing_entity.code['@codeSystem'] | map_system: 'cda_to_fhir' }}", + "code": "{{ playing_entity.code['@code'] }}", + "display": "{{ playing_entity.name | default: playing_entity.code['@displayName'] }}" + }] + } + {% elsif obs.value %} + , + "code": { + "coding": [{ + "system": "{{ obs.value['@codeSystem'] | map_system: 'cda_to_fhir' }}", + "code": "{{ obs.value['@code'] }}", + "display": "{{ obs.value['@displayName'] }}" + }] + } + {% endif %} + + {% if obs.effectiveTime.low['@value'] %} + , + "onsetDateTime": "{{ obs.effectiveTime.low['@value'] | format_date }}" + {% endif %} + + {% if reactions.size > 0 %} + , + "reaction": [ + {% for reaction in reactions %} + { + "manifestation": [{ + "concept": { + "coding": [{ + "system": "{{ reaction.system | map_system: 'cda_to_fhir' }}", + "code": "{{ reaction.code }}", + "display": "{{ reaction.display }}" + }] + } + }]{% if reaction.severity != blank %}, + "severity": "{{ reaction.severity | map_severity: 'cda_to_fhir' }}"{% endif %} + }{% unless forloop.last %},{% endunless %} + {% endfor %} + ] + {% endif %} +} diff --git a/configs/templates/cda_fhir/condition.liquid b/configs/templates/cda_fhir/condition.liquid new file mode 100644 index 00000000..8089a92b --- /dev/null +++ b/configs/templates/cda_fhir/condition.liquid @@ -0,0 +1,49 @@ +{ + "resourceType": "Condition", + {% if entry.act.entryRelationship.is_array %} + {% assign obs = entry.act.entryRelationship[0].observation %} + {% else %} + {% assign obs = entry.act.entryRelationship.observation %} + {% endif %} + {% if obs.entryRelationship.observation.code['@code'] == config.identifiers.clinical_status.code %} + {% if obs.entryRelationship.observation.value %} + "clinicalStatus": { + "coding": [ + { + "system": "http://terminology.hl7.org/CodeSystem/condition-clinical", + "code": "{{ obs.entryRelationship.observation.value['@code'] | map_status: 'cda_to_fhir' }}" + }, + { + "system": "{{ obs.entryRelationship.observation.value['@codeSystem'] | map_system: 'cda_to_fhir' }}", + "code": "{{ obs.entryRelationship.observation.value['@code'] }}", + "display": "{{ obs.entryRelationship.observation.value['@displayName'] }}" + } + ] + }{% if true %},{% endif %} + {% endif %} + {% endif %} + "category": [{ + "coding": [{ + "system": "http://terminology.hl7.org/CodeSystem/condition-category", + "code": "problem-list-item", + "display": "Problem List Item" + }] + }]{% if obs.value or obs.effectiveTime %},{% endif %} + {% if obs.value %} + "code": { + "coding": [{ + "system": "{{ obs.value['@codeSystem'] | map_system: 'cda_to_fhir' }}", + "code": "{{ obs.value['@code'] }}", + "display": "{{ obs.value['@displayName'] }}" + }] + }{% if obs.effectiveTime %},{% endif %} + {% endif %} + {% if obs.effectiveTime %} + {% if obs.effectiveTime.low %} + "onsetDateTime": "{{ obs.effectiveTime.low['@value'] | format_date }}"{% if obs.effectiveTime.high %},{% endif %} + {% endif %} + {% if obs.effectiveTime.high %} + "abatementDateTime": "{{ obs.effectiveTime.high['@value'] | format_date }}" + {% endif %} + {% endif %} +} diff --git a/configs/templates/cda_fhir/document_reference.liquid b/configs/templates/cda_fhir/document_reference.liquid new file mode 100644 index 00000000..657a48b5 --- /dev/null +++ b/configs/templates/cda_fhir/document_reference.liquid @@ -0,0 +1,22 @@ +{ + "resourceType": "DocumentReference", + "status": "current", + "type": { + "coding": [{ + "system": "{{ entry.code['@codeSystem'] | map_system: 'cda_to_fhir' }}", + "code": "{{ entry.code['@code'] }}", + "display": "{{ entry.code['@displayName'] }}" + }] + }, + {% if entry.effectiveTime %} + "date": "{{ entry.effectiveTime['@value'] | format_timestamp }}", + {% endif %} + "description": "{{ config.rendering.narrative.description }}", + "content": [{ + "attachment": { + "contentType": "text/plain", + "data": "{{ entry.text | xmldict_to_html | to_base64 }}", + "title": "{{ entry.title }}" + } + }] +} diff --git a/configs/templates/cda_fhir/medication_statement.liquid b/configs/templates/cda_fhir/medication_statement.liquid new file mode 100644 index 00000000..9d31f1ab --- /dev/null +++ b/configs/templates/cda_fhir/medication_statement.liquid @@ -0,0 +1,69 @@ +{ + "resourceType": "MedicationStatement", + {% assign substance_admin = entry.substanceAdministration %} + "status": "{{ substance_admin.statusCode['@code'] | map_status: 'cda_to_fhir' }}", + "medication": { + "concept": { + "coding": [{ + "system": "{{ substance_admin.consumable.manufacturedProduct.manufacturedMaterial.code['@codeSystem'] | map_system: 'cda_to_fhir' }}", + "code": "{{ substance_admin.consumable.manufacturedProduct.manufacturedMaterial.code['@code'] }}", + "display": "{{ substance_admin.consumable.manufacturedProduct.manufacturedMaterial.code['@displayName'] }}" + }] + } + } + + {% comment %}Process effectiveTime and extract period/timing information if exists{% endcomment %} + {% if substance_admin.effectiveTime %} + , + {% assign effective_period = substance_admin.effectiveTime | extract_effective_period %} + {% if effective_period %} + "effectivePeriod": { + {% if effective_period.start %}"start": "{{ effective_period.start }}"{% if effective_period.end %},{% endif %}{% endif %} + {% if effective_period.end %}"end": "{{ effective_period.end }}"{% endif %} + } + {% assign effective_timing = substance_admin.effectiveTime | extract_effective_timing %} + {% if substance_admin.doseQuantity or substance_admin.routeCode or effective_timing %},{% endif %} + {% endif %} + {% endif %} + + {% comment %}Add dosage if any dosage related fields are present{% endcomment %} + {% assign effective_timing = substance_admin.effectiveTime | extract_effective_timing %} + {% if substance_admin.doseQuantity or substance_admin.routeCode or effective_timing %} + {% if substance_admin.effectiveTime == nil %},{% endif %} + "dosage": [ + { + {% if substance_admin.doseQuantity %} + "doseAndRate": [ + { + "doseQuantity": { + "value": {{ substance_admin.doseQuantity['@value'] }}, + "unit": "{{ substance_admin.doseQuantity['@unit'] }}" + } + } + ]{% if substance_admin.routeCode or effective_timing %},{% endif %} + {% endif %} + + {% if substance_admin.routeCode %} + "route": { + "coding": [ + { + "system": "{{ substance_admin.routeCode['@codeSystem'] | map_system: 'cda_to_fhir' }}", + "code": "{{ substance_admin.routeCode['@code'] }}", + "display": "{{ substance_admin.routeCode['@displayName'] }}" + } + ] + }{% if effective_timing %},{% endif %} + {% endif %} + + {% if effective_timing %} + "timing": { + "repeat": { + "period": {{ effective_timing.period }}, + "periodUnit": "{{ effective_timing.periodUnit }}" + } + } + {% endif %} + } + ] + {% endif %} +} diff --git a/configs/templates/fhir_cda/allergy_entry.liquid b/configs/templates/fhir_cda/allergy_entry.liquid new file mode 100644 index 00000000..303edb73 --- /dev/null +++ b/configs/templates/fhir_cda/allergy_entry.liquid @@ -0,0 +1,204 @@ +{ + "act": { + "@classCode": "ACT", + "@moodCode": "EVN", + "templateId": [ + {% for template_id in config.template.act.template_id %} + {"@root": "{{template_id}}"} {% if forloop.last != true %},{% endif %} + {% endfor %} + ], + {% if resource.id %} + "id": {"@root": "{{ resource.id }}"}, + {% endif %} + "code": {"@nullFlavor": "NA"}, + "statusCode": { + "@code": "{{ config.template.act.status_code }}" + }, + "effectiveTime": { + "low": {"@value": "{{ timestamp }}"} + }, + "entryRelationship": { + "@typeCode": "{{ config.template.allergy_obs.type_code }}", + "@inversionInd": {{ config.template.allergy_obs.inversion_ind }}, + "observation": { + "@classCode": "OBS", + "@moodCode": "EVN", + "templateId": [ + {% for template_id in config.template.allergy_obs.template_id %} + {"@root": "{{template_id}}"} {% if forloop.last != true %},{% endif %} + {% endfor %} + ], + {% if resource.id %} + "id": {"@root": "{{ resource.id }}_obs"}, + {% endif %} + "text": { + "reference": {"@value": "{{ text_reference_name }}"} + }, + "statusCode": {"@code": "{{ config.template.allergy_obs.status_code }}"}, + "effectiveTime": { + "low": {"@value": "{{ timestamp }}"} + }, + {% if resource.type %} + "code": { + "@code": "{{ resource.type.coding[0].code }}", + "@codeSystem": "{{ resource.type.coding[0].system | map_system: 'fhir_to_cda' }}", + "@displayName": "{{ resource.type.coding[0].display }}" + }, + {% else %} + "code": { + "@code": "{{ config.template.allergy_obs.code }}", + "@codeSystem": "{{ config.template.allergy_obs.code_system }}", + "@codeSystemName": "{{ config.template.allergy_obs.code_system_name }}", + "@displayName": "{{ config.template.allergy_obs.display_name }}" + }, + {% endif %} + "value": { + "@xmlns:xsi": "http://www.w3.org/2001/XMLSchema-instance", + "@xsi:type": "CD", + "@code": "{{ resource.code.coding[0].code }}", + "@codeSystem": "{{ resource.code.coding[0].system | map_system: 'fhir_to_cda' }}", + "@displayName": "{{ resource.code.coding[0].display }}", + "originalText": { + "reference": {"@value": "{{ text_reference_name }}"} + } + }, + "participant": { + "@typeCode": "CSM", + "participantRole": { + "@classCode": "MANU", + "playingEntity": { + "@classCode": "MMAT", + "code": { + "originalText": { + "reference": {"@value": "{{ text_reference_name }}"} + }, + "@code": "{{ resource.code.coding[0].code }}", + "@codeSystem": "{{ resource.code.coding[0].system | map_system: 'fhir_to_cda' }}", + "@displayName": "{{ resource.code.coding[0].display }}" + }, + "name": "{{ resource.code.coding[0].display }}" + } + } + }{% if resource.clinicalStatus or resource.reaction %},{% endif %} + + {% if resource.reaction %} + "entryRelationship": [ + { + "@typeCode": "REFR", + "@inversionInd": true, + "observation": { + "@classCode": "OBS", + "@moodCode": "EVN", + "templateId": {"@root": "{{config.template.clinical_status_obs.template_id}}"}, + "code": { + "@code": "{{ config.template.clinical_status_obs.code }}", + "@codeSystem": "{{ config.template.clinical_status_obs.code_system }}", + "@displayName": "{{ config.template.clinical_status_obs.display_name }}" + }, + "statusCode": {"@code": "{{ config.template.clinical_status_obs.status_code }}"}, + "value": { + "@xmlns:xsi": "http://www.w3.org/2001/XMLSchema-instance", + "@xsi:type": "CE", + "@code": "{{ resource.clinicalStatus.coding[0].code | map_status: 'fhir_to_cda' }}", + "@codeSystem": "{{ resource.clinicalStatus.coding[0].system | map_system: 'fhir_to_cda' }}", + "@displayName": "{{ resource.clinicalStatus.coding[0].display }}" + } + } + }, + { + "@typeCode": "MFST", + "observation": { + "@classCode": "OBS", + "@moodCode": "EVN", + "templateId": [ + {% for template_id in config.template.reaction_obs.template_id %} + {"@root": "{{template_id}}"} {% if forloop.last != true %},{% endif %} + {% endfor %} + ], + "id": {"@root": "{{ resource.id }}_reaction"}, + "code": {"@code": "{{ config.template.reaction_obs.code }}"}, + "text": { + "reference": {"@value": "{{ text_reference_name }}reaction"} + }, + "statusCode": {"@code": "{{ config.template.reaction_obs.status_code }}"}, + "effectiveTime": { + "low": {"@value": "{{ timestamp }}"} + }, + "value": { + "@xmlns:xsi": "http://www.w3.org/2001/XMLSchema-instance", + "@xsi:type": "CD", + "@code": "{{ resource.reaction[0].manifestation[0].concept.coding[0].code }}", + "@codeSystem": "{{ resource.reaction[0].manifestation[0].concept.coding[0].system | map_system: 'fhir_to_cda' }}", + "@displayName": "{{ resource.reaction[0].manifestation[0].concept.coding[0].display }}", + "originalText": { + "reference": {"@value": "{{ text_reference_name }}reaction"} + } + }{% if resource.reaction[0].severity %}, + "entryRelationship": { + "@typeCode": "SUBJ", + "observation": { + "@classCode": "OBS", + "@moodCode": "EVN", + "templateId": [ + {% for template_id in config.template.severity_obs.template_id %} + {"@root": "{{template_id}}"} {% if forloop.last != true %},{% endif %} + {% endfor %} + ], + "code": { + "@code": "{{ config.template.severity_obs.code }}", + "@codeSystem": "{{ config.template.severity_obs.code_system }}", + "@codeSystemName": "{{ config.template.severity_obs.code_system_name }}", + "@displayName": "{{ config.template.severity_obs.display_name }}" + }, + "text": { + "reference": {"@value": "{{ text_reference_name }}severity"} + }, + "statusCode": {"@code": "{{ config.template.severity_obs.status_code }}"}, + "value": { + "@xmlns:xsi": "http://www.w3.org/2001/XMLSchema-instance", + "@xsi:type": "CD", + "@code": "{{ resource.reaction[0].severity | map_severity: 'fhir_to_cda'}}", + "@codeSystem": "{{ config.template.severity_obs.value.code_system }}", + "@codeSystemName": "{{ config.template.severity_obs.value.code_system_name }}", + "@displayName": "{{ resource.reaction[0].severity | map_severity: 'fhir_to_cda'}}" + } + } + } + {% endif %} + } + } + ] + {% else %} + {% if resource.clinicalStatus %} + "entryRelationship": { + "@typeCode": "REFR", + "@inversionInd": true, + "observation": { + "@classCode": "OBS", + "@moodCode": "EVN", + "templateId": [ + {% for template_id in config.template.clinical_status_obs.template_id %} + {"@root": "{{template_id}}"} {% if forloop.last != true %},{% endif %} + {% endfor %} + ], + "code": { + "@code": "{{ config.template.clinical_status_obs.code }}", + "@codeSystem": "{{ config.template.clinical_status_obs.code_system }}", + "@displayName": "{{ config.template.clinical_status_obs.display_name }}" + }, + "statusCode": {"@code": "{{ config.template.clinical_status_obs.status_code }}"}, + "value": { + "@xmlns:xsi": "http://www.w3.org/2001/XMLSchema-instance", + "@xsi:type": "CE", + "@code": "{{ resource.clinicalStatus.coding[0].code }}", + "@codeSystem": "{{ resource.clinicalStatus.coding[0].system | map_system: 'fhir_to_cda' }}", + "@displayName": "{{ resource.clinicalStatus.coding[0].display }}" + } + } + } + {% endif %} + {% endif %} + } + } + } +} diff --git a/configs/templates/fhir_cda/document.liquid b/configs/templates/fhir_cda/document.liquid new file mode 100644 index 00000000..460d7196 --- /dev/null +++ b/configs/templates/fhir_cda/document.liquid @@ -0,0 +1,41 @@ +{ + "ClinicalDocument": { + "@xmlns": "urn:hl7-org:v3", + "@xmlns:xsi": "http://www.w3.org/2001/XMLSchema-instance", + "id": { + "@root": "{{ bundle.identifier.value | generate_id }}" + }, + "realmCode": { + "@code": "{{ config.realm_code }}" + }, + "typeId": { + "@extension": "{{ config.type_id.extension}}", + "@root": "{{ config.type_id.root }}" + }, + "templateId": { + "@root": "{{ config.template_id.root }}" + }, + "code": { + "@code": "{{ config.code.code }}", + "@codeSystem": "{{ config.code.code_system }}", + "@codeSystemName": "{{ config.code.code_system_name }}", + "@displayName": "{{ config.code.display }}" + }, + "title": "Clinical Document", + "effectiveTime": { + "@value": "{{ bundle.timestamp | format_timestamp }}" + }, + "confidentialityCode": { + "@code": "{{ config.confidentiality_code.code }}", + "@codeSystem": "{{ config.confidentiality_code.code_system }}" + }, + "languageCode": { + "@code": "{{ config.language_code }}" + }, + "component": { + "structuredBody": { + "component": {{ sections | json }} + } + } + } +} diff --git a/configs/templates/fhir_cda/medication_entry.liquid b/configs/templates/fhir_cda/medication_entry.liquid new file mode 100644 index 00000000..65f00954 --- /dev/null +++ b/configs/templates/fhir_cda/medication_entry.liquid @@ -0,0 +1,111 @@ +{ + "substanceAdministration": { + "@classCode": "SBADM", + "@moodCode": "INT", + "templateId": [ + {% for template_id in config.template.substance_admin.template_id %} + {"@root": "{{template_id}}"} {% if forloop.last != true %},{% endif %} + {% endfor %} + ], + {% if resource.id %} + "id": {"@root": "{{ resource.id }}"}, + {% endif %} + "statusCode": {"@code": "{{ config.template.substance_admin.status_code }}"}, + {% if resource.dosage and resource.dosage[0].doseAndRate %} + "doseQuantity": { + "@value": "{{ resource.dosage[0].doseAndRate[0].doseQuantity.value }}", + "@unit": "{{ resource.dosage[0].doseAndRate[0].doseQuantity.unit }}" + }, + {% endif %} + {% if resource.dosage and resource.dosage[0].route %} + "routeCode": { + "@code": "{{ resource.dosage[0].route.coding[0].code }}", + "@codeSystem": "{{ resource.dosage[0].route.coding[0].system | map_system: 'fhir_to_cda' }}", + "@displayName": "{{ resource.dosage[0].route.coding[0].display }}" + }, + {% endif %} + {% if resource.dosage and resource.dosage[0].timing or resource.effectivePeriod %} + "effectiveTime": [ + {% if resource.dosage and resource.dosage[0].timing %} + { + "@xsi:type": "PIVL_TS", + "@institutionSpecified": true, + "@operator": "A", + "@xmlns:xsi": "http://www.w3.org/2001/XMLSchema-instance", + "period": { + "@unit": "{{ resource.dosage[0].timing.repeat.periodUnit }}", + "@value": "{{ resource.dosage[0].timing.repeat.period }}" + } + }{% if resource.effectivePeriod %},{% endif %} + {% endif %} + {% if resource.effectivePeriod %} + { + "@xsi:type": "IVL_TS", + "@xmlns:xsi": "http://www.w3.org/2001/XMLSchema-instance", + {% if resource.effectivePeriod.start %} + "low": { + "@value": "{{ resource.effectivePeriod.start | format_date }}" + }, + {% else %} + "low": {"@nullFlavor": "UNK"}, + {% endif %} + {% if resource.effectivePeriod.end %} + "high": { + "@value": "{{ resource.effectivePeriod.end }}" + } + {% else %} + "high": {"@nullFlavor": "UNK"} + {% endif %} + } + {% endif %} + ], + {% endif %} + "consumable": { + "@typeCode": "CSM", + "manufacturedProduct": { + "@classCode": "MANU", + "templateId": [ + {% for template_id in config.template.manufactured_product.template_id %} + {"@root": "{{template_id}}"} {% if forloop.last != true %},{% endif %} + {% endfor %} + ], + "manufacturedMaterial": { + "code": { + "@code": "{{ resource.medication.concept.coding[0].code }}", + "@codeSystem": "{{ resource.medication.concept.coding[0].system | map_system: 'fhir_to_cda' }}", + "@displayName": "{{ resource.medication.concept.coding[0].display }}", + "originalText": { + "reference": {"@value": "{{ text_reference_name }}"} + } + } + } + } + }, + "entryRelationship": { + "@typeCode": "REFR", + "observation": { + "@classCode": "OBS", + "@moodCode": "EVN", + "templateId": {"@root": "{{ config.template.clinical_status_obs.template_id }}"}, + "code": { + "@code": "{{ config.template.clinical_status_obs.code }}", + "@codeSystem": "{{ config.template.clinical_status_obs.code_system }}", + "@codeSystemName": "{{ config.template.clinical_status_obs.code_system_name }}", + "@displayName": "{{ config.template.clinical_status_obs.display_name }}" + }, + "value": { + "@xmlns:xsi": "http://www.w3.org/2001/XMLSchema-instance", + "@code": "{{ config.template.clinical_status_obs.value.code }}", + "@codeSystem": "{{ config.template.clinical_status_obs.value.code_system }}", + "@codeSystemName": "{{ config.template.clinical_status_obs.value.code_system_name }}", + "@xsi:type": "CE", + "@displayName": "{{ config.template.clinical_status_obs.value.display_name }}" + }, + "statusCode": {"@code": "{{ config.template.clinical_status_obs.status_code }}"}, + "effectiveTime": { + "low": {"@value": "{{ timestamp }}"} + } + } + } + } +} diff --git a/configs/templates/fhir_cda/note_entry.liquid b/configs/templates/fhir_cda/note_entry.liquid new file mode 100644 index 00000000..62c7b676 --- /dev/null +++ b/configs/templates/fhir_cda/note_entry.liquid @@ -0,0 +1,23 @@ +{ + "component": { + "section": { + "templateId": [ + {% for template_id in config.template.note_section.template_id %} + {"@root": "{{ template_id }}"} {% if forloop.last != true %},{% endif %} + {% endfor %} + ], + "code": { + "@code": "{{ resource.type.coding[0].code | default: config.template.note_section.code }}", + "@codeSystem": "{{ resource.type.coding[0].system | map_system: 'fhir_to_cda' | default: config.template.note_section.code_system }}", + "@displayName": "{{ resource.type.coding[0].display | default: config.template.note_section.display_name }}" + }, + "title": "{{ resource.content[0].attachment.title }}", + {% if resource.date %} + "effectiveTime": { + "@value": "{{ resource.date | format_date: 'cda' }}" + }, + {% endif %} + "text": "{{ resource.content[0].attachment.data | from_base64 }}" + } + } +} diff --git a/configs/templates/fhir_cda/problem_entry.liquid b/configs/templates/fhir_cda/problem_entry.liquid new file mode 100644 index 00000000..756a9d58 --- /dev/null +++ b/configs/templates/fhir_cda/problem_entry.liquid @@ -0,0 +1,90 @@ +{ + "act": { + "@classCode": "ACT", + "@moodCode": "EVN", + "templateId": [ + {% for template_id in config.template.act.template_id %} + {"@root": "{{template_id}}"} {% if forloop.last != true %},{% endif %} + {% endfor %} + ], + {% if resource.id %} + "id": {"@root": "{{ resource.id }}"}, + {% endif %} + "code": {"@nullFlavor": "NA"}, + "statusCode": { + "@code": "{{ config.template.act.status_code }}" + }, + "effectiveTime": { + "low": {"@value": "{{ timestamp }}"} + }, + "entryRelationship": { + "@typeCode": "{{ config.template.problem_obs.type_code }}", + "@inversionInd": {{ config.template.problem_obs.inversion_ind }}, + "observation": { + "@classCode": "OBS", + "@moodCode": "EVN", + "templateId": [ + {% for template_id in config.template.problem_obs.template_id %} + {"@root": "{{template_id}}"} {% if forloop.last != true %},{% endif %} + {% endfor %} + ], + {% if resource.id %} + "id": {"@root": "{{ resource.id }}_obs"}, + {% endif %} + "code": { + "@code": "{{ config.template.problem_obs.code }}", + "@codeSystem": "{{ config.template.problem_obs.code_system }}", + "@codeSystemName": "{{ config.template.problem_obs.code_system_name }}", + "@displayName": "{{ config.template.problem_obs.display_name }}" + }, + "text": { + "reference": {"@value": "{{ text_reference_name }}"} + }, + "statusCode": {"@code": "{{ config.template.problem_obs.status_code }}"}, + "effectiveTime": { + {% if resource.onsetDateTime %} + "low": {"@value": "{{ resource.onsetDateTime }}"} + {% endif %} + {% if resource.abatementDateTime %} + "high": {"@value": "{{ resource.abatementDateTime }}"} + {% endif %} + }, + "value": { + "@xmlns:xsi": "http://www.w3.org/2001/XMLSchema-instance", + "@xsi:type": "CD", + "@code": "{{ resource.code.coding[0].code }}", + "@codeSystem": "{{ resource.code.coding[0].system | map_system: 'fhir_to_cda' }}", + "@displayName": "{{ resource.code.coding[0].display }}", + "originalText": { + "reference": {"@value": "{{ text_reference_name }}"} + } + }, + "entryRelationship": { + "@typeCode": "REFR", + "observation": { + "@classCode": "OBS", + "@moodCode": "EVN", + "templateId": {"@root": "{{ config.template.clinical_status_obs.template_id }}"}, + "code": { + "@code": "{{ config.template.clinical_status_obs.code }}", + "@codeSystem": "{{ config.template.clinical_status_obs.code_system }}", + "@codeSystemName": "{{config.template.clinical_status_obs.code_system_name }}", + "@displayName": "{{ config.template.clinical_status_obs.display_name }}" + }, + "value": { + "@xmlns:xsi": "http://www.w3.org/2001/XMLSchema-instance", + "@code": "{{ resource.clinicalStatus.coding[0].code | map_status: 'fhir_to_cda' }}", + "@codeSystem": "{{ resource.clinicalStatus.coding[0].system | map_system: 'fhir_to_cda' }}", + "@displayName": "{{ resource.clinicalStatus.coding[0].display }}", + "@xsi:type": "CE" + }, + "statusCode": {"@code": "{{ config.template.clinical_status_obs.status_code }}"}, + "effectiveTime": { + "low": {"@value": "{{ timestamp }}"} + } + } + } + } + } + } +} diff --git a/configs/templates/fhir_cda/section.liquid b/configs/templates/fhir_cda/section.liquid new file mode 100644 index 00000000..3e35256c --- /dev/null +++ b/configs/templates/fhir_cda/section.liquid @@ -0,0 +1,15 @@ +{ + "section": { + "templateId": { + "@root": "{{ config.identifiers.template_id }}" + }, + "code": { + "@code": "{{ config.identifiers.code }}", + "@codeSystem": "{{ config.identifiers.code_system | default: '2.16.840.1.113883.6.1' }}", + "@codeSystemName": "{{ config.identifiers.code_system_name | default: 'LOINC' }}", + "@displayName": "{{ config.identifiers.display }}" + }, + "title": "{{ config.identifiers.display }}", + "entry": {{ entries | json }} + } +} diff --git a/docs/api/cda_parser.md b/docs/api/cda_parser.md deleted file mode 100644 index eccbea7c..00000000 --- a/docs/api/cda_parser.md +++ /dev/null @@ -1,3 +0,0 @@ -# CDA Parser - -::: healthchain.cda_parser.cdaannotator diff --git a/docs/api/interop.md b/docs/api/interop.md new file mode 100644 index 00000000..1df7b425 --- /dev/null +++ b/docs/api/interop.md @@ -0,0 +1,15 @@ +# Interoperability Engine + +::: healthchain.interop.engine + +::: healthchain.interop.config_manager + +::: healthchain.interop.template_registry + +::: healthchain.interop.parsers.cda + +::: healthchain.interop.generators.cda + +::: healthchain.interop.generators.fhir + +::: healthchain.config.validators diff --git a/docs/cookbook/interop/basic_conversion.md b/docs/cookbook/interop/basic_conversion.md new file mode 100644 index 00000000..bbee570c --- /dev/null +++ b/docs/cookbook/interop/basic_conversion.md @@ -0,0 +1,262 @@ +# Basic Format Conversion + +This tutorial demonstrates how to use the HealthChain interoperability module to convert between different healthcare data formats. + +## Prerequisites + +- HealthChain installed (`pip install healthchain`) +- Basic understanding of FHIR resources +- Sample CDA or HL7v2 files (or you can use the examples below) + +## Setup + +First, let's import the required modules and create an interoperability engine: + +```python +from healthchain.interop import create_engine, FormatType +from pathlib import Path +import json + +# Create an engine +engine = create_engine() +``` + +## Converting CDA to FHIR + +Let's convert a CDA document to FHIR resources: + +```python +# Sample CDA document +cda_xml = """ + + + + + Example CDA Document + + + + + + + + + John + Smith + + + + + + + + + +
+ + + Problems + Hypertension + + + + + + + + + + + +
+
+
+
+
+""" + +# Convert CDA to FHIR resources +fhir_resources = engine.to_fhir(cda_xml, src_format=FormatType.CDA) + +# Print the resulting FHIR resources +for resource in fhir_resources: + print(f"Resource Type: {resource.resource_type}") + print(json.dumps(resource.dict(), indent=2)) + print("-" * 40) +``` + +## Converting FHIR to CDA + +You can also convert FHIR resources to a CDA document: + +```python +from fhir.resources.condition import Condition +from fhir.resources.patient import Patient + +# Create some FHIR resources +patient = Patient( + resourceType="Patient", + id="patient-1", + name=[{"family": "Smith", "given": ["John"]}], + gender="male", + birthDate="1970-11-01" +) + +condition = Condition( + resourceType="Condition", + id="condition-1", + subject={"reference": "Patient/patient-1"}, + code={ + "coding": [ + { + "system": "http://snomed.info/sct", + "code": "38341003", + "display": "Hypertension" + } + ], + "text": "Hypertension" + }, + clinicalStatus={ + "coding": [ + { + "system": "http://terminology.hl7.org/CodeSystem/condition-clinical", + "code": "active" + } + ] + }, + verificationStatus={ + "coding": [ + { + "system": "http://terminology.hl7.org/CodeSystem/condition-ver-status", + "code": "confirmed" + } + ] + }, + onsetDateTime="2018-01-01" +) + +# Convert FHIR resources to CDA +resources = [patient, condition] +cda_document = engine.from_fhir(resources, dest_format=FormatType.CDA) + +# Print the resulting CDA document +print(cda_document) +``` + +## Converting HL7v2 to FHIR + +Let's convert an HL7v2 message to FHIR resources: + +```python +# Sample HL7v2 message +hl7v2_message = """ +MSH|^~\&|EPIC|EPICADT|SMS|SMSADT|199912271408|CHARRIS|ADT^A01|1817457|D|2.5| +PID|1||PATID1234^5^M11^ADT1^MR^GOOD HEALTH HOSPITAL~123456789^^^USSSA^SS||EVERYMAN^ADAM^A^III||19610615|M||C|2222 HOME STREET^^GREENSBORO^NC^27401-1020|GL|(555) 555-2004|(555)555-2004||S||PATID12345001^2^M10^ADT1^AN^A|444333333|987654^NC| +NK1|1|NUCLEAR^NELDA^W|SPO||(555)555-3333||EC||||||||||||||||||||||||| +PV1|1|I|2000^2012^01||||004777^ATTEND^AARON^A|||SUR||||ADM|A0| +""" + +# Convert HL7v2 to FHIR resources +fhir_resources = engine.to_fhir(hl7v2_message, src_format=FormatType.HL7V2) + +# Print the resulting FHIR resources +for resource in fhir_resources: + print(f"Resource Type: {resource.resource_type}") + print(json.dumps(resource.dict(), indent=2)) + print("-" * 40) +``` + +## Converting FHIR to HL7v2 + +You can also convert FHIR resources to an HL7v2 message: + +```python +from fhir.resources.patient import Patient +from fhir.resources.encounter import Encounter + +# Create some FHIR resources +patient = Patient( + resourceType="Patient", + id="patient-1", + name=[{"family": "Everyman", "given": ["Adam", "A"], "suffix": ["III"]}], + gender="male", + birthDate="1961-06-15", + identifier=[ + { + "system": "urn:oid:1.2.36.146.595.217.0.1", + "value": "PATID1234" + } + ], + address=[ + { + "line": ["2222 Home Street"], + "city": "Greensboro", + "state": "NC", + "postalCode": "27401" + } + ], + telecom=[ + { + "system": "phone", + "value": "(555) 555-2004", + "use": "home" + } + ] +) + +encounter = Encounter( + resourceType="Encounter", + id="encounter-1", + status="finished", + class_fhir={ + "system": "http://terminology.hl7.org/CodeSystem/v3-ActCode", + "code": "IMP", + "display": "inpatient encounter" + }, + subject={ + "reference": "Patient/patient-1" + } +) + +# Convert FHIR resources to HL7v2 +resources = [patient, encounter] +hl7v2_message = engine.from_fhir(resources, dest_format=FormatType.HL7V2) + +# Print the resulting HL7v2 message +print(hl7v2_message) +``` + +## Saving Conversion Results + +Let's save our conversion results to files: + +```python +# Save FHIR resources to JSON files +output_dir = Path("./output") +output_dir.mkdir(exist_ok=True) + +for resource in fhir_resources: + filename = f"{resource.resource_type.lower()}_{resource.id}.json" + with open(output_dir / filename, "w") as f: + json.dump(resource.dict(), f, indent=2) + +# Save CDA document to XML file +with open(output_dir / "document.xml", "w") as f: + f.write(cda_document) + +# Save HL7v2 message to file +with open(output_dir / "message.hl7", "w") as f: + f.write(hl7v2_message) +``` + +## Conclusion + +In this tutorial, you learned how to: + +- Convert CDA documents to FHIR resources +- Convert FHIR resources to CDA documents +- Convert HL7v2 messages to FHIR resources +- Convert FHIR resources to HL7v2 messages +- Save conversion results to files + +For more advanced interoperability features, see the [InteropEngine documentation](../../reference/interop/engine.md) and other cookbook examples. diff --git a/docs/index.md b/docs/index.md index 7d480bc9..6d97fba0 100644 --- a/docs/index.md +++ b/docs/index.md @@ -27,21 +27,21 @@ HealthChain ๐Ÿ’ซ๐Ÿฅ is an open-source Python framework designed to streamline t [:octicons-arrow-right-24: Sandbox](reference/sandbox/sandbox.md) -- :material-database:{ .lg .middle } __Generate synthetic data__ +- :material-database:{ .lg .middle } __Interoperability__ --- - Use the data generator to create synthetic healthcare data for testing and development + Configuration-driven InteropEngine to convert between FHIR, CDA, and HL7v2 - [:octicons-arrow-right-24: Data Generator](reference/utilities/data_generator.md) + [:octicons-arrow-right-24: Interoperability](reference/interop/interop.md) -- :material-lightbulb-on-outline:{ .lg .middle } __Contribute__ +- :material-fire:{ .lg .middle } __Utilities__ --- - If you have an idea or suggestions, we'd love to hear from you! + FHIR data model utilities and helpers to make development easier - [:octicons-arrow-right-24: Community](community/index.md) + [:octicons-arrow-right-24: Utilities](reference/utilities/fhir_helpers.md) diff --git a/docs/quickstart.md b/docs/quickstart.md index 99791cc4..96872914 100644 --- a/docs/quickstart.md +++ b/docs/quickstart.md @@ -114,6 +114,41 @@ cda_data = CdaRequest(document="") output = pipeline(cda_data) ``` +### Interoperability ๐Ÿ”„ + +The HealthChain Interoperability module provides tools for converting between different healthcare data formats, including HL7 FHIR, HL7 CDA, and HL7v2 messages. + +[(Full Documentation on Interoperability Engine)](./reference/interop/interop.md) + +```python +from healthchain.interop import create_engine, FormatType + +# Create an interoperability engine +engine = create_engine() + +# Load a CDA document +with open("tests/data/test_cda.xml", "r") as f: + cda_xml = f.read() + +# Convert CDA XML to FHIR resources +fhir_resources = engine.to_fhir(cda_xml, src_format=FormatType.CDA) + +# Convert FHIR resources back to CDA +cda_document = engine.from_fhir(fhir_resources, dest_format=FormatType.CDA) +``` + +The interop module provides a flexible, template-based approach to healthcare format conversion: + +| Feature | Description | +|---------|-------------| +| Format conversion | Convert legacy formats (CDA, HL7v2) to FHIR resources and back | +| Template-based generation | Customize syntactic output using [Liquid](https://shopify.github.io/liquid/) templates | +| Configuration | Configure terminology mappings, validation rules, and environments | +| Extension | Register custom parsers, generators, and validators | + +For more details, see the [conversion examples](cookbook/interop/basic_conversion.md). + + ### Sandbox ๐Ÿงช Once you've built your pipeline, you might want to experiment with how it interacts with different healthcare systems. A sandbox helps you stage and test the end-to-end workflow of your pipeline application where real-time EHR integrations are involved. @@ -168,7 +203,7 @@ if __name__ == "__main__": clindoc.start_sandbox() ``` -## Deploy sandbox locally with FastAPI ๐Ÿš€ +#### Deploy sandbox locally with FastAPI ๐Ÿš€ To run your sandbox: @@ -179,6 +214,25 @@ healthchain run my_sandbox.py This will start a server by default at `http://127.0.0.1:8000`, and you can interact with the exposed endpoints at `/docs`. Data generated from your sandbox runs is saved at `./output/` by default. ## Utilities โš™๏ธ + +### FHIR Helpers + +The `fhir` module provides a set of helper functions for working with FHIR resources. + +```python +from healthchain.fhir import create_condition + +condition = create_condition( + code="38341003", + display="Hypertension", + system="http://snomed.info/sct", + subject="Patient/Foo", + clinical_status="active" +) +``` + +[(Full Documentation on FHIR Helpers)](./reference/utilities/fhir_helpers.md) + ### Data Generator You can use the data generator to generate synthetic data for your sandbox runs. diff --git a/docs/reference/index.md b/docs/reference/index.md index aa3c4b9b..8d405beb 100644 --- a/docs/reference/index.md +++ b/docs/reference/index.md @@ -1 +1,8 @@ # Welcome! + +## Core Components + +- [Pipeline](pipeline/pipeline.md): Build and manage processing pipelines for healthcare NLP and ML tasks. +- [Sandbox](sandbox/sandbox.md): Test your pipelines in a simulated healthcare environment. +- [Interoperability](interop/interop.md): Convert between healthcare data formats like FHIR, CDA, and HL7v2. +- [Utilities](utilities/fhir_helpers.md): Additional tools for development and testing. diff --git a/docs/reference/interop/configuration.md b/docs/reference/interop/configuration.md new file mode 100644 index 00000000..60d8519a --- /dev/null +++ b/docs/reference/interop/configuration.md @@ -0,0 +1,347 @@ +# Configuration + +The interoperability module uses a configuration system to control its behavior. This includes mappings between different healthcare data formats, validation rules, and environment-specific settings. + +## Configuration Components + +| Component | Description | +|-----------|-------------| +| `InteropConfigManager` | Manages access to configuration files and values | +| `defaults.yaml` | Default configuration values for FHIR required fields | +| `environments/` | Environment-specific configuration values | +| `interop/` | Configuration for format specific settings (e.g. CDA, HL7v2) | +| `mappings/` | Mapping tables for codes, identifiers, and terminology systems | +| `templates/` | Templates for generating healthcare documents | + +## Configuration Structure + +The configuration system uses `YAML` files with a hierarchical structure. The main configuration sections include: + +- `defaults`: Global default values +- `cda`: Contains CDA specific configurations + - `sections`: Configuration for CDA document sections + - `document`: Configuration for CDA document types +- `mappings`: Mappings between different terminology systems +- `templates`: Template configuration + +## Default Configuration + +The default configuration is stored in `configs/defaults.yaml` and contains global settings and default fallback values for required fields in FHIR resources in the event that they are not provided in the source document. + +```yaml +defaults: + # Common defaults for all resources + common: + id_prefix: "hc-" + timestamp: "%Y%m%d" + reference_name: "#{uuid}name" + subject: + reference: "Patient/example" + + # Resource-specific defaults + resources: + Condition: + clinicalStatus: + coding: + - system: "http://terminology.hl7.org/CodeSystem/condition-clinical" + code: "unknown" + display: "Unknown" + MedicationStatement: + status: "unknown" + medication: + concept: + coding: + - system: "http://terminology.hl7.org/CodeSystem/v3-NullFlavor" + code: "UNK" + display: "Unknown" +``` + +## CDA Configurations + +CDA configurations primarily concern the extraction and rendering processes on the ***document*** and ***section*** level. While [Templates](./templates.md) are used to define the syntactic structure of the document, the YAML configurations are used to define the semantic content of the document structure, such as template IDs, status codes, and other metadata. + +Configs are loaded from the `configs/interop/cda/` directory and loaded in the `ConfigManager` with the folder and filenames as keys. e.g. given the following directory structure: + +``` +configs/ + interop/ + cda/ + document/ + ccd.yaml + sections/ + problems.yaml +``` +The config will be loaded into the config manager under the key `cda.document.ccd` and `cda.sections.problems`. + +### Example Document Configuration + +```yaml +# configs/interop/cda/document/ccd.yaml + +# Configured template names in templates/ directory (required) +templates: + document: "fhir_cda/document" + section: "fhir_cda/section" + +# Basic document information +code: + code: "34133-9" + code_system: "2.16.840.1.113883.6.1" + code_system_name: "LOINC" + display: "Summarization of Episode Note" +realm_code: "GB" +type_id: + extension: "POCD_HD000040" + root: "2.16.840.1.113883.1.3" +template_id: + root: "1.2.840.114350.1.72.1.51693" +# ... + +# Document structure +structure: + # Header configuration + header: + include_patient: false + include_author: false + include_custodian: false + + # Body configuration + body: + structured_body: true + non_xml_body: false + include_sections: + - "allergies" + - "medications" + - "problems" +``` + +### Example Section Configuration + +```yaml +# configs/interop/cda/sections/problems.yaml + +# Metadata for both extraction and rendering processes (required) +resource: "Condition" # The FHIR resource this section maps to +resource_template: "cda_fhir/condition" # The template to use for the FHIR resource rendering +entry_template: "fhir_cda/problem_entry" # The template to use for the CDA section entry rendering + +# Section identifiers (used for extraction) (required) +identifiers: + template_id: "2.16.840.1.113883.10.20.1.11" + code: "11450-4" + +# Template configuration (used for rendering) +template: + act: + template_id: + - "2.16.840.1.113883.10.20.1.27" + status_code: "completed" + # ... +``` + +## Mapping Tables + +Mapping tables are used to translate codes and identifiers between different terminology systems. These are stored in the `mappings/` directory. + +Example mapping table for coding systems: + +```yaml +# mappings/cda_default/systems.yaml +systems: + "http://snomed.info/sct": + oid: "2.16.840.1.113883.6.96" + name: "SNOMED CT" + + "http://loinc.org": + oid: "2.16.840.1.113883.6.1" + name: "LOINC" + + "http://www.nlm.nih.gov/research/umls/rxnorm": + oid: "2.16.840.1.113883.6.88" + name: "RxNorm" +``` +(Full Documentation on [Mappings](mappings.md)) + + +## Environment-Specific Configuration + +The configuration system supports different environments (development, testing, production) with environment-specific overrides. These are stored in the `environments/` directory. In development and subject to change. + +```yaml +# environments/development.yaml +defaults: + common: + id_prefix: "dev-" # Development-specific ID prefix + subject: + reference: "Patient/Foo" + +# environments/production.yaml +defaults: + common: + id_prefix: "hc-" # Production ID prefix + subject: + reference: "Patient/example" +``` + +## Using the Configuration Manager + +### Basic Configuration Access + +```python +from healthchain.interop import create_engine + +# Create an engine +engine = create_engine() +# OR +engine = create_engine(config_dir="custom_configs/") + +# Get all configurations +engine.config.get_configs() + +# Get a configuration value by dot notation +id_prefix = engine.config.get_config_value("defaults.common.id_prefix") + +# Set the environment (this reloads configuration from the specified environment) +engine = create_engine(environment="production") + +# Validation level is set during initialization or using set_validation_level +engine = create_engine(validation_level="warn") +# OR +engine.config.set_validation_level("strict") + +# Set a runtime configuration override +engine.config.set_config_value("cda.sections.problems.identifiers.code", "10160-0") +``` + +### Section Configuration + +```python +from healthchain.interop import create_engine + +# Create an engine +engine = create_engine() + +# Get all section configurations +sections = engine.config.get_cda_section_configs() + +# Get a specific section configuration +problems_config = engine.config.get_cda_section_configs("problems") + +# Get section identifiers +template_id = problems_config["identifiers"]["template_id"] +code = problems_config["identifiers"]["code"] +``` + +### Mapping Access + +```python +from healthchain.interop import create_engine + +# Create an engine +engine = create_engine() + +# Get all mappings +mappings = engine.config.get_mappings() + +# Get a specific mapping +systems = mappings.get("cda_fhir", {}).get("systems", {}) +snomed = systems.get("http://snomed.info/sct", {}) +snomed_oid = snomed.get("oid") # "2.16.840.1.113883.6.96" +``` + +## Configuration Loading Order + +The configuration system follows a hierarchical loading order, where each layer can override values from previous layers: + +``` +โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” +โ”‚ 1. BASE CONFIGURATION โ”‚ +โ”‚ configs/defaults.yaml โ”‚ +โ”‚ โ”‚ +โ”‚ โ€ข Default values for all envs โ”‚ +โ”‚ โ€ข Complete set of all options โ”‚ +โ”‚ โ€ข Lowest precedence โ”‚ +โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ + โ”‚ + โ–ผ +โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” +โ”‚ 2. ENVIRONMENT CONFIGURATION โ”‚ +โ”‚ configs/environments/{env}.yamlโ”‚ +โ”‚ โ”‚ +โ”‚ โ€ข Environment-specific values โ”‚ +โ”‚ โ€ข Overrides matching defaults โ”‚ +โ”‚ โ€ข Medium precedence โ”‚ +โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ + โ”‚ + โ–ผ +โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” +โ”‚ 3. RUNTIME CONFIGURATION โ”‚ +โ”‚ Programmatic overrides โ”‚ +โ”‚ โ”‚ +โ”‚ โ€ข Set via API at runtime โ”‚ +โ”‚ โ€ข For temporary changes โ”‚ +โ”‚ โ€ข Highest precedence โ”‚ +โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ +``` + +### Example of Configuration Override + +Consider the following values set across different configuration layers: + +1. In `defaults.yaml`: +```yaml +defaults: + common: + id_prefix: "hc-" + subject: + reference: "Patient/example" +``` + +2. In `environments/development.yaml`: +```yaml +defaults: + common: + id_prefix: "dev-" + subject: + reference: "Patient/Foo" +``` + +3. Runtime override in code: +```python +from healthchain.interop import create_engine +from healthchain.interop.types import FormatType + +# Create an engine +engine = create_engine() + +# Override a configuration value at runtime +engine.config.set_config_value("defaults.common.id_prefix", "test-") + +with open("tests/data/test_cda.xml", "r") as f: + cda = f.read() + +fhir_resources = engine.to_fhir(cda, FormatType.CDA) +print(fhir_resources[0].id) # Will use "test-" prefix instead of "dev-" or "hc-" +``` + +In this example, the final value of `id_prefix` would be `"test-"` because the runtime override has the highest precedence, followed by the environment configuration, and finally the base configuration. + +## Validation Levels + +The configuration system supports different validation levels: + +| Level | Description | +|-------|-------------| +| `strict` | Raise exceptions for any configuration errors | +| `warn` | Log warnings for configuration errors but continue | +| `ignore` | Ignore configuration errors | + +```python +from healthchain.interop import create_engine + +# Create an engine with a specific validation level +engine = create_engine(validation_level="warn") + +# Change the validation level +engine.config.set_validation_level("strict") +``` diff --git a/docs/reference/interop/engine.md b/docs/reference/interop/engine.md new file mode 100644 index 00000000..2a77c310 --- /dev/null +++ b/docs/reference/interop/engine.md @@ -0,0 +1,134 @@ +# InteropEngine + +The `InteropEngine` is the core component of the HealthChain interoperability module. It provides a unified interface for converting between different healthcare data formats. + +## Basic Usage + +```python +from healthchain.interop import create_engine, FormatType + +# Create an interoperability engine +engine = create_engine() + +# Convert CDA XML to FHIR resources +fhir_resources = engine.to_fhir(cda_xml, src_format=FormatType.CDA) + +# Convert FHIR resources to CDA XML +cda_xml = engine.from_fhir(fhir_resources, dest_format=FormatType.CDA) + +# Convert HL7v2 message to FHIR resources +fhir_resources = engine.to_fhir(hl7v2_message, src_format=FormatType.HL7V2) +``` + +## Creating an Engine + +The `create_engine()` function is the recommended way to create an engine instance: + +```python +from healthchain.interop import create_engine + +# Create with default configuration +engine = create_engine() + +# Create with custom configuration directory +from pathlib import Path +config_dir = Path("/path/to/configs") +engine = create_engine(config_dir=config_dir) + +# Create with custom validation level +engine = create_engine(validation_level="warn") +``` + +## Conversion Methods + +All conversions convert to and from FHIR. + +| Method | Description | +|--------|-------------| +| `to_fhir(data, src_format)` | Convert from source format to FHIR resources | +| `from_fhir(resources, dest_format)` | Convert from FHIR resources to destination format | + +### Converting to FHIR + +```python +# From CDA +fhir_resources = engine.to_fhir(cda_xml, src_format=FormatType.CDA) + +# From HL7v2 +fhir_resources = engine.to_fhir(hl7v2_message, src_format=FormatType.HL7V2) +``` + +### Converting from FHIR + +```python +# To CDA +cda_xml = engine.from_fhir(fhir_resources, dest_format=FormatType.CDA) + +# To HL7v2 +hl7v2_message = engine.from_fhir(fhir_resources, dest_format=FormatType.HL7V2) +``` + +## Accessing Configuration + +The engine provides direct access to the underlying configuration manager: + +```python +# Access configuration directly +engine.config.set_environment("production") +engine.config.set_validation_level("warn") +value = engine.config.get_config_value("cda.sections.problems.resource") +``` + +## Custom Components + +You can register custom parsers and generators to extend the engine's capabilities. Note that registering a custom parser / generator for an existing format type will replace the default. + +```python +from healthchain.interop import FormatType + +# Register a custom parser +engine.register_parser(FormatType.CDA, custom_parser) + +# Register a custom generator +engine.register_generator(FormatType.FHIR, custom_generator) +``` + + +## Advanced Configuration + +For more information on the configuration options, see the [Configuration](configuration.md) page. diff --git a/docs/reference/interop/generators.md b/docs/reference/interop/generators.md new file mode 100644 index 00000000..5477c914 --- /dev/null +++ b/docs/reference/interop/generators.md @@ -0,0 +1,226 @@ +# Generators + +Generators in the interoperability module are responsible for producing healthcare data in various formats from FHIR resources. The module includes built-in generators for common formats including CDA, HL7v2, and FHIR. + +The generators are largely configuration-driven, with rendering done using Liquid templates. We highly recommend that you read through the [Templates](templates.md) and [Configuration](configuration.md) sections before using the generators! + +## Available Generators + +| Generator | Description | +|-----------|-------------| +| `CDAGenerator` | Generates CDA XML documents from FHIR resources | +| `FHIRGenerator` | Generates FHIR JSON/XML from FHIR resources | + + + +## CDA Generator + +The CDA Generator produces Clinical Document Architecture (CDA) XML documents from FHIR resources. + +### Usage Examples + +```python +from healthchain.interop import create_engine, FormatType +from healthchain.fhir import create_condition + +# Create an engine +engine = create_engine() + +# Use the FHIR helper functions to create a condition resource +condition = create_condition( + code="38341003", + display="Hypertension", + system="http://snomed.info/sct", + subject="Patient/Foo", + clinical_status="active" +) + +# Generate CDA from FHIR resources +cda_xml = engine.from_fhir([condition], dest_format=FormatType.CDA) + +# Access the CDA generator directly (advanced use case) +cda_generator = engine.cda_generator +cda_xml = cda_generator.transform([condition]) +``` + +## FHIR Generator + +The FHIR Generator transforms data from other formats into FHIR resources. It currently only supports transforming CDA documents into FHIR resources. + +### Usage Examples + +```python +from healthchain.interop import create_engine, FormatType + +# Create an engine +engine = create_engine() + +# CDA section entries in dictionary format (@ is used to represent XML attributes) +cda_section_entries = { + "problems": { + "act": { + "@classCode": "ACT", + "@moodCode": "EVN" + ... + } + } +} + +# Generate FHIR resources +fhir_generator = engine.fhir_generator +fhir_resources = fhir_generator.transform(cda_section_entries, src_format=FormatType.CDA) +``` + +
+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', + '@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'} + ], + 'id': { + '@extension': '51854', + '@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' + }, + 'text': { + 'reference': {'@value': '#problem12name'} + }, + 'statusCode': { + '@code': 'completed' + }, + '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'} + } + }, + '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. +
+ + + + +## Creating a Custom Generator + +You can create a custom generator by implementing a class that inherits from `BaseGenerator` and registering it with the engine (this will replace the default generator for the format type): + +```python +from healthchain.interop import create_engine, FormatType +from healthchain.interop.config_manager import InteropConfigManager +from healthchain.interop.template_registry import TemplateRegistry +from healthchain.interop.generators import BaseGenerator + +from typing import List +from fhir.resources.resource import Resource + + +class CustomGenerator(BaseGenerator): + def __init__(self, config: InteropConfigManager, templates: TemplateRegistry): + super().__init__(config, templates) + + def transform(self, resources: List[Resource], **kwargs) -> str: + # Generate output from FHIR resources + return "Custom output format" + +# Register the custom generator with the engine +engine = create_engine() +engine.register_generator(FormatType.CDA, CustomGenerator(engine.config, engine.template_registry)) +``` diff --git a/docs/reference/interop/interop.md b/docs/reference/interop/interop.md new file mode 100644 index 00000000..bd0084c6 --- /dev/null +++ b/docs/reference/interop/interop.md @@ -0,0 +1,125 @@ +# Interoperability Engine + +The HealthChain Interop Engine provides a robust and customizable framework for converting between different HL7 healthcare data formats, including: + +- FHIR +- CDA +- HL7v2 (Coming soon) + +## Architecture + +The interoperability module is built around a central `InteropEngine` that coordinates format conversion through specialized parsers and generators: + +``` + โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” + โ”‚ InteropEngine โ”‚ + โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ + โ”‚ + โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ผโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” + โ”‚ โ”‚ โ”‚ +โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ–ผโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” โ”Œโ”€โ”€โ”€โ”€โ”€โ–ผโ”€โ”€โ”€โ”€โ”€โ” โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ–ผโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” +โ”‚ Parsers โ”‚ โ”‚ Templates โ”‚ โ”‚ Generators โ”‚ +โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ โ””โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”˜ โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ + โ”‚ โ”‚ โ”‚ +โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ–ผโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” โ”Œโ”€โ”€โ”€โ”€โ”€โ–ผโ”€โ”€โ”€โ”€โ”€โ” โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ–ผโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” +โ”‚ - CDA Parser โ”‚ โ”‚ Registry โ”‚ โ”‚ - CDA Generator โ”‚ +โ”‚ - HL7v2 Parser โ”‚ โ”‚ Renderer โ”‚ โ”‚ - FHIR Generator โ”‚ +โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ โ”‚ - HL7v2 Generator โ”‚ + โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ +``` + +## Key Components + +| Component | Description | +|-----------|-------------| +| [**InteropEngine**](engine.md) | Core engine that manages the conversion process | +| [**Templates**](templates.md) | Liquid-based template system for customizing output syntactic generation | +| [**Mappings**](mappings.md) | Mappings between different terminology systems | +| [**Configuration**](configuration.md) | Configuration system for controlling engine behavior and template variations| +| [**Parsers**](parsers.md) | Components for parsing different healthcare formats | +| [**Generators**](generators.md) | Components for generating output in different formats | + +## Basic Usage + +FHIR serves as the de facto data standard in HealthChain (and in the world of healthcare more broadly, ideally), therefore everything converts to and from FHIR resources. + +The main conversion methods are (hold on to your hats): + +- `.to_fhir()` - Convert a source format to FHIR resources +- `.from_fhir()` - Convert FHIR resources to a destination format + +```python +from healthchain.interop import create_engine, FormatType + +# Create an interoperability engine +engine = create_engine() + +# Convert CDA XML to FHIR resources +with open('patient_ccd.xml', 'r') as f: + cda_xml = f.read() + +fhir_resources = engine.to_fhir(cda_xml, src_format="cda") + +# Convert FHIR resources back to CDA +cda_document = engine.from_fhir(fhir_resources, dest_format="cda") +``` + +## Customization Points + +The interoperability module is designed with extensibility at its core. You can customize and extend the framework in several ways: + +### Custom Parsers + +Parsers convert source formats (CDA or HL7v2) into mapped dictionaries that can be processed by generators: + +```python +# Register a custom parser with the engine +engine.register_parser(FormatType.CDA, CustomCDAParser(engine.config)) +``` + +For detailed implementation examples, see [Creating a Custom Parser](parsers.md#creating-a-custom-parser). + +### Custom Generators + +Generators transform mapped dictionaries into target formats: + +```python +# Register a custom generator with the engine +engine.register_generator(FormatType.CDA, + CustomCDAGenerator(engine.config, engine.template_registry)) +``` + +For detailed implementation examples, see [Creating a Custom Generator](generators.md#creating-a-custom-generator). + + +### Environment Configuration + +You can customize the engine's behavior for different environments: + +```python +# Create an engine with specific environment settings +engine = create_engine( + config_dir=Path("/path/to/custom/configs"), + validation_level="warn", # Options: strict, warn, ignore + environment="production" # Options: development, testing, production +) + +# Change environment settings after creation +engine.config.set_environment("testing") +engine.config.set_validation_level("strict") + +# Access environment-specific configuration +id_prefix = engine.config.get_config_value("defaults.common.id_prefix") +``` + +Environment-specific configurations are loaded from the `environments/` directory and can override default settings for different deployment scenarios. + +### Template Customization + +The template system uses [Liquid templates](https://shopify.github.io/liquid/) to generate output formats. You can: + +1. Override existing templates by placing custom versions in the configured template directory +2. Add new templates for custom formats or content types +3. Extend the template system with custom logic via filters + +For more details on extending the system, check out the [Templates](templates.md) and [Configuration](configuration.md) pages. diff --git a/docs/reference/interop/mappings.md b/docs/reference/interop/mappings.md new file mode 100644 index 00000000..c5c756e5 --- /dev/null +++ b/docs/reference/interop/mappings.md @@ -0,0 +1,163 @@ +# Mappings + +Mapping tables are used to translate between different healthcare terminology systems, code sets, and formats. These mappings are essential for semantic interoperability between CDA, HL7v2, and FHIR formats where the same concept may be represented differently. + +## Mapping Types + +The system supports several types of mappings: + +| Mapping Type | Description | Example | +|--------------|-------------|---------| +| **Code Systems** | Maps between URI-based systems (FHIR) and OID-based systems (CDA) | SNOMED CT: `http://snomed.info/sct` โ†” `2.16.840.1.113883.6.96` | +| **Status Codes** | Maps status codes between formats | `active` โ†” `55561003` | +| **Severity Codes** | Maps severity designations between formats | `moderate` โ†” `6736007` | + +## Mapping Directory Structure + +Mappings are stored in YAML files in the `configs/mappings/` directory, organized by the formats they translate between: + +``` +configs/mappings/ +โ”œโ”€โ”€ cda_fhir/ +โ”‚ โ”œโ”€โ”€ systems.yaml +โ”‚ โ”œโ”€โ”€ status_codes.yaml +โ”‚ โ””โ”€โ”€ severity_codes.yaml +โ”œโ”€โ”€ hl7v2_fhir/ +โ”‚ โ””โ”€โ”€ ... +โ””โ”€โ”€ README.md +``` + +## Mapping File Format + +### Code Systems Mapping + +The `systems.yaml` file maps between FHIR URI-based code systems and CDA OID-based systems: + +```yaml +# mappings/cda_fhir/systems.yaml +systems: + "http://snomed.info/sct": + oid: "2.16.840.1.113883.6.96" + name: "SNOMED CT" + + "http://loinc.org": + oid: "2.16.840.1.113883.6.1" + name: "LOINC" + + "http://www.nlm.nih.gov/research/umls/rxnorm": + oid: "2.16.840.1.113883.6.88" + name: "RxNorm" +``` + +### Status Codes Mapping + +The `status_codes.yaml` file maps between different formats' status codes: + +```yaml +# mappings/cda_fhir/status_codes.yaml +# Clinical status codes (CDA to FHIR) +"55561003": + code: "active" + display: "Active" + +"73425007": + code: "inactive" + display: "Inactive" + +"413322009": + code: "resolved" + display: "Resolved" +``` + +### Severity Codes Mapping + +The `severity_codes.yaml` file maps severity designations between formats: + +```yaml +# mappings/cda_fhir/severity_codes.yaml +# Allergy and reaction severity codes (CDA to FHIR) +"255604002": # Mild (SNOMED CT) + code: "mild" + display: "Mild" + +"6736007": # Moderate (SNOMED CT) + code: "moderate" + display: "Moderate" + +"24484000": # Severe (SNOMED CT) + code: "severe" + display: "Severe" +``` + +## Using Mappings + +The `InteropEngine` automatically loads and applies mappings during the conversion process. You can also access mappings directly through the configuration manager: + +```python +from healthchain.interop import create_engine + +# Create an engine +engine = create_engine() + +# Get all mappings +mappings = engine.config.get_mappings() + +# Access specific mapping +systems = mappings.get("cda_fhir", {}).get("systems", {}) +snomed = systems.get("http://snomed.info/sct", {}) +snomed_oid = snomed.get("oid") # "2.16.840.1.113883.6.96" +``` + +## Template Filters for Mappings + +The mapping system provides several Liquid template filters to help with code translation: + +| Filter | Description | Example Usage | +|--------|-------------|---------------| +| `map_system` | Maps between CDA and FHIR code systems | `{{ "http://snomed.info/sct" | map_system: 'fhir_to_cda' }}` | +| `map_status` | Maps between CDA and FHIR status codes | `{{ "active" | map_status: 'fhir_to_cda' }}` | +| `map_severity` | Maps between CDA and FHIR severity codes | `{{ "moderate" | map_severity: 'fhir_to_cda' }}` | + +Example in a template: + +```liquid +{ + "code": { + "@codeSystem": "{{ resource.code.coding[0].system | map_system: 'fhir_to_cda' }}", + "@code": "{{ resource.code.coding[0].code }}" + } +} +``` + +## Adding Custom Mappings + +To add new mappings: + +1. Create or modify the appropriate YAML file in the `configs/mappings/` directory +2. Follow the structure of existing mapping files +3. The changes will be automatically loaded the next time the `InteropEngine` is initialized + +For more complex mapping needs, you can create custom mapping filters: + +```python +def custom_map_filter(value, mappings, direction="fhir_to_cda"): + # Custom mapping logic here + return mapped_value + +# Register with the template registry +engine.template_registry.add_filter("custom_map", custom_map_filter) +``` + + + +## Related Documentation + +- [Configuration Management](configuration.md) +- [Templates](templates.md) +- [Custom Filters](templates.md#custom-template-filters) diff --git a/docs/reference/interop/parsers.md b/docs/reference/interop/parsers.md new file mode 100644 index 00000000..cb325f6c --- /dev/null +++ b/docs/reference/interop/parsers.md @@ -0,0 +1,211 @@ +# Parsers + +Parsers are responsible for extracting structured data from various healthcare document formats. The module includes built-in parsers for common formats like CDA and HL7v2. + +## Available Parsers + +| Parser | Description | +|--------|-------------| +| `CDAParser` | Parses CDA XML documents into structured data | +| `HL7v2Parser` | Parses HL7v2 messages into structured data | + +## CDA Parser + +The CDA Parser extracts data from Clinical Document Architecture (CDA) XML documents based on configured section identifiers. + +Internally, it uses [xmltodict](https://github.com/martinblech/xmltodict) to parse the XML into a dictionary, validates the dictionary with [Pydantic](https://docs.pydantic.dev/), and then maps each entry to the section keys. See [Working with xmltodict in HealthChain](./xmltodict.md) for more details. + +Each extracted entry should be mapped to the name of the corresponding configuration file, which will be used as the `section_key`. The configuration file contains information about the section identifiers that are used to extract the correct section entries. + +The input data should be in the format `{}: {}`. + +([Full Documentation on Configuration](./configuration.md)) + +### Usage Examples + +```python +from healthchain.interop import create_engine, FormatType + +# Create an engine +engine = create_engine() + +# Parse a CDA document directly to FHIR +with open("tests/data/test_cda.xml", "r") as f: + cda_xml = f.read() + +fhir_resources = engine.to_fhir(cda_xml, src_format=FormatType.CDA) + +# Access the CDA parser directly (advanced use case) +cda_parser = engine.cda_parser +sections = cda_parser.parse_document(cda_xml) + +# Extract problems section data +problems = sections.get("problems", []) +# parsed CDA section entry in xmltodict format - note that '@' is used to access attributes +# { +# "act": { +# "@classCode": "ACT", +# "@moodCode": "EVN", +# ... +# } +# } +``` + +
+View full parsed output + +Note how the original XML structure is preserved in dictionary format with '@' used to denote attributes: + +```python +[{ + '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', + '@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'} + ], + 'id': { + '@extension': '51854', + '@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' + }, + 'text': { + 'reference': {'@value': '#problem12name'} + }, + 'statusCode': { + '@code': 'completed' + }, + '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'} + } + }, + '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' + } + } + } + } + } + } +}] +``` + +This data structure represents a problem (condition) entry from a CDA document, containing: +
+- A problem act with template IDs and status +
+- An observation with clinical details (SNOMED code 38341003 - Hypertension) +
+- Status information (Active) +
+- Dates (onset date: May 17, 2019) +
+ +This data structure is then processed by the generator to map to the configured FHIR resource. +
+ +### Section Configuration + +Sections make up the structure of a CDA document. The CDA parser uses `identifiers` in the section configuration file to determine which sections to extract and map to FHIR resources. Each section is identified by a template ID, code, or both: + +```yaml +# Example section configuration +cda: + sections: + problems: + identifiers: + template_id: "2.16.840.1.113883.10.20.1.11" + code: "11450-4" + resource: "Condition" +``` + +## Creating a Custom Parser + +You can create a custom parser by implementing a class that inherits from `BaseParser` and registering it with the engine (this will replace the default parser for the format type): + +```python +from healthchain.interop import create_engine, FormatType +from healthchain.interop.config_manager import InteropConfigManager +from healthchain.interop.parsers.base import BaseParser + +class CustomParser(BaseParser): + def __init__(self, config: InteropConfigManager): + super().__init__(config) + + def from_string(self, data: str) -> dict: + # Parse the document and return structured data + return {"structured_data": "example"} + +# Register the custom parser with the engine +engine = create_engine() +engine.register_parser(FormatType.CDA, CustomParser(engine.config)) +``` diff --git a/docs/reference/interop/templates.md b/docs/reference/interop/templates.md new file mode 100644 index 00000000..d3968f93 --- /dev/null +++ b/docs/reference/interop/templates.md @@ -0,0 +1,242 @@ +# Templates + +The HealthChain interoperability module uses a template system based on [**Liquid**](https://shopify.github.io/liquid/), an open-source templating language to generate healthcare data in various formats. This allows for flexible and customizable document generation on a syntactic level. + +## Template Directory Structure + +Templates are stored in the `configs/templates` directory by default. The directory structure follows a convention based on format and resource type: + +``` +templates/ +โ”œโ”€โ”€ cda_fhir/ +โ”‚ โ”œโ”€โ”€ document.liquid +โ”‚ โ”œโ”€โ”€ section.liquid +โ”‚ โ”œโ”€โ”€ problem_entry.liquid +โ”‚ โ”œโ”€โ”€ medication_entry.liquid +โ”‚ โ””โ”€โ”€ allergy_entry.liquid +โ”œโ”€โ”€ cda_fhir/ +โ”‚ โ”œโ”€โ”€ condition.liquid +โ”‚ โ”œโ”€โ”€ medication_statement.liquid +โ”‚ โ””โ”€โ”€ allergy_intolerance.liquid +โ”œโ”€โ”€ hl7v2_fhir/ +โ”‚ โ”œโ”€โ”€ adt_a01.liquid +โ”‚ โ”œโ”€โ”€ oru_r01.liquid +โ”‚ โ””โ”€โ”€ obx_r01.liquid +โ”œโ”€โ”€ fhir_hl7v2/ +โ”‚ โ”œโ”€โ”€ patient_adt.liquid +โ”‚ โ”œโ”€โ”€ encounter_adt.liquid +โ”‚ โ””โ”€โ”€ observation_oru.liquid +``` +Templates can be accessed through the `TemplateRegistry`: + +1. Using their full path within the template directory without extension as a key: `cda_fhir/document` +2. Using just their stem name as a key: `document` + +Using full paths is recommended for clarity and to avoid confusion when templates with the same filename exist in different directories. However, both methods will work as long as template names are unique across all directories and matches the template name in the configuration files. + +## Default Templates + +HealthChain provides default templates for the transformation of Problems, Medications, and Allergies sections in a Continuity of Care (CCD) CDA to FHIR and the reverse. They are configured to work out of the box with the default configuration. You are welcome to modify these templates at your own discretion or use them as a starting reference point for your writing your own templates. + +| CDA Section | FHIR Resource | +|-------------|---------------| +| **Problems** | [**Condition**](https://www.hl7.org/fhir/condition.html) | +| **Medications** | [**MedicationStatement**](https://www.hl7.org/fhir/medicationstatement.html) | +| **Allergies** | [**AllergyIntolerance**](https://www.hl7.org/fhir/allergyintolerance.html) | +| **Notes** | [**DocumentReference**](https://www.hl7.org/fhir/documentreference.html) | + +CDA to FHIR templates: + +| CDA Section | Default Template | +|-------------|------------------| +| **Problems** | `fhir_cda/problem_entry.liquid` | +| **Medications** | `fhir_cda/medication_entry.liquid` | +| **Allergies** | `fhir_cda/allergy_entry.liquid` | +| **Notes** | `fhir_cda/note_entry.liquid` | + +FHIR to CDA templates: + +| Resource Type | Default Template | +|---------------|------------------| +| **Condition** | `cda_fhir/condition.liquid` | +| **MedicationStatement** | `cda_fhir/medication_statement.liquid` | +| **AllergyIntolerance** | `cda_fhir/allergy_intolerance.liquid` | +| **DocumentReference** | `cda_fhir/document_reference.liquid` | + +## Template Format + +Templates use Python [**Liquid**](https://liquid.readthedocs.io/en/latest/) syntax with additional custom filters provided by the interoperability module. Note that HealthChain uses [**xmltodict**](https://github.com/martinblech/xmltodict) to parse and unparse XML documents into dictionaries and vice versa, therefore templates should be written in JSON format that is compatible with `xmltodict`. For more information, see the [Working with xmltodict in HealthChain](./xmltodict.md) guide (it's not that bad, I promise). + +Example template for a CDA to FHIR conversion: + +```liquid +{ + "act": { + "@classCode": "ACT", + "@moodCode": "EVN", + "templateId": [ + {% for template_id in config.template.act.template_id %} + {"@root": "{{template_id}}"} {% if forloop.last != true %},{% endif %} + {% endfor %} + ], + {% if resource.id %} + "id": {"@root": "{{ resource.id }}"}, + {% endif %} + "code": {"@nullFlavor": "NA"}, + "statusCode": { + "@code": "{{ config.template.act.status_code }}" + } + } +} +``` + +## Using the Template System + +### Interoperability Engine API + +The Interoperability Engine API provides a high-level interface (`.to_fhir()` and `.from_fhir()`) for transforming healthcare data between different formats using templates. + +```python +from healthchain.interop import create_engine, FormatType +from healthchain.fhir import create_condition + +# Create an engine +engine = create_engine() + +# Create a FHIR resource +condition = create_condition( + code="38341003", + system="http://snomed.info/sct", + display="Hypertension", + subject="Patient/123", + clinical_status="active" +) + +# Generate CDA from FHIR resources +cda_xml = engine.from_fhir([condition], dest_format=FormatType.CDA) + +# Generate FHIR from CDA +fhir_resources = engine.to_fhir(cda_xml, src_format=FormatType.CDA) +``` + +### Direct Template Access (Advanced) + +For advanced use cases, you can access the template system directly: + +```python +from healthchain.interop import create_engine + +# Create an engine +engine = create_engine() + +# Access the template registry +registry = engine.template_registry + +# Get a template by name +template = registry.get_template("cda_fhir/condition") +``` + +## Creating Custom Templates + +To create a custom template: + +1. Create a new file in the appropriate template directory. Use a descriptive name for the template, e.g. `cda_fhir/procedure.liquid`. +2. Create a template using Python Liquid syntax with available filters if needed. +3. Access source data properties using dot notation. (e.g. `entry.act.id`) +4. Access configuration values (see [Configuration](./configuration.md)) using the `config` object. + +For example, to create a custom template to transform a CDA section into a FHIR Procedure resource: + +```liquid + +{ + "procedure": { + "@classCode": "PROC", + "@moodCode": "EVN", + "templateId": { + "@root": "2.16.840.1.113883.10.20.1.29" + }, + "id": { + "@root": "{{ entry.act.id | generate_id }}" + }, + "code": { + "@code": "{{ entry.act.entryRelationship.observation.value['@code'] }}", + "@displayName": "{{ entry.act.entryRelationship.observation.value['@displayName'] }}", + "@codeSystem": "{{ entry.act.entryRelationship.observation.value['@codeSystem'] | map_system: 'fhir_to_cda' }}", + }, + "statusCode": { + "@code": "{{ entry.act.statusCode['@code'] | map_status: 'fhir_to_cda' }}" + }, + "effectiveTime": { + "@value": "{{ entry.act.effectiveTime['@value'] | format_date }}" + }, + {% if entry.act.performer %} + "performer": { + "assignedEntity": { + "id": { + "@root": "{{ entry.act.performer[0].actor.reference }}" + } + } + } + {% endif %} + } +} +``` +## Custom Template Filters + +The template system provides several custom filters for common healthcare document transformation tasks: + +| Filter | Description | +|--------|-------------| +| `map_system` | Maps between CDA and FHIR code systems | +| `map_status` | Maps between CDA and FHIR status codes | +| `map_severity` | Maps between CDA and FHIR severity codes | +| `format_date` | Formats a date in the correct format for the output document | +| `format_timestamp` | Formats a timestamp or uses current time | +| `generate_id` | Generates an ID or uses provided value | +| `to_json` | Converts object to JSON string | +| `extract_effective_period` | Extracts effective period data from CDA effectiveTime elements | +| `extract_effective_timing` | Extracts timing data from effectiveTime elements | +| `extract_clinical_status` | Extracts clinical status from an observation | +| `extract_reactions` | Extracts reactions from an observation | +| `clean_empty` | Recursively removes empty values from dictionaries and lists | +| `to_base64` | Encodes text to base64 | +| `from_base64` | Decodes base64 to text | +| `xmldict_to_html` | Converts xmltodict format to HTML string | + +### Using Filters + +```liquid + +{{ resource.code.coding[0].system | map_system: 'fhir_to_cda' }} + + +{{ "Hello World" | to_base64 }} + + +{{ "SGVsbG8gV29ybGQ=" | from_base64 }} + + + +{% assign xml_dict = {'div': {'p': 'Hello', '@class': 'note'}} %} +{{ xml_dict | xmldict_to_html }} + +``` +For more information on using filters, see Liquid's [official documentation](https://shopify.github.io/liquid/basics/introduction/). + +### Adding Custom Filters + +You can add custom filters to the template system: + +```python +from healthchain.interop import create_engine + +def custom_filter(value): + return f"CUSTOM:{value}" + +# Create an engine +engine = create_engine() + +# Add a custom filter +engine.template_registry.add_filter("custom", custom_filter) +``` diff --git a/docs/reference/interop/xmltodict.md b/docs/reference/interop/xmltodict.md new file mode 100644 index 00000000..ef5dff57 --- /dev/null +++ b/docs/reference/interop/xmltodict.md @@ -0,0 +1,109 @@ +# Working with xmltodict in HealthChain + +The HealthChain interoperability engine uses [xmltodict](https://github.com/martinblech/xmltodict) to convert between XML and Python dictionaries. This guide explains key conventions to be aware of when working with the parsed data. + +*Why use `xmltodict`?* You say, *Why not use the `lxml` or `xml.etree.ElementTree` or some other decent library so you can work on the XML tree directly?* + +There are two main reasons: + +- HealthChain uses [Pydantic](https://docs.pydantic.dev/) models for validation and type checking extensively, which works best with JSON-able data. We wanted to keep everything in modern Python ecosystem whilst still being able to work with XML, which is still a very common format in healthcare + +- Developer experience: it's just easier to work with JSON than XML trees in Python ๐Ÿคทโ€โ™€๏ธ + +The flow roughly looks like this: + +```bash +XML โ†” Dictionary with @ prefixes โ†” Pydantic Model +``` + +Still with me? Cool. Let's dive into the key conventions to be aware of when working with the parsed data. + +## Key Conventions + +### Attribute Prefixes +XML attributes are prefixed with `@`: +```xml + +``` +becomes: +```python +{ + "code": { + "@code": "55607006", + "@displayName": "Problem" + } +} +``` + +### Text Content +Text content of elements is represented with `#text`: +```xml +Hypertension +``` +becomes: +```python +{ + "displayName": "Hypertension" +} +``` +or for mixed content: +```xml +Some bold text +``` +becomes: +```python +{ + "text": { + "#text": "Some text", + "b": "bold" + } +} +``` + +### Lists vs Single Items +A collection of elements with the same name becomes a list: +```xml + +
...
+
+ +
...
+
+``` +becomes: +```python +{ + "component": [ + {"section": {...}}, + {"section": {...}} + ] +} +``` + +### Force List Parameter +When parsing, you can force certain elements to always be lists even when there's only one: +```python +xmltodict.parse(xml_string, force_list=('component', 'entry')) +``` + +### Namespaces +Namespaces are included in element names: +```xml +value +``` +becomes: +```python +{ + "ns1:element": { + "@xmlns:ns1": "http://example.org", + "#text": "value" + } +} +``` + +## Tips for Working with CDA Documents + +- Remember to use the `@` prefix for attributes +- Always check if an element might be a list before accessing it directly +- In Liquid, use `['string']` to access attributes with `@` prefixes. e.g. `act.entry.code['@code']` +- When generating XML, make sure to include required namespaces diff --git a/docs/reference/pipeline/connectors/cdaconnector.md b/docs/reference/pipeline/connectors/cdaconnector.md index fe902a73..4a6ddda8 100644 --- a/docs/reference/pipeline/connectors/cdaconnector.md +++ b/docs/reference/pipeline/connectors/cdaconnector.md @@ -6,7 +6,6 @@ This connector is particularly useful for clinical documentation improvement (CD [(Full Documentation on Clinical Documentation)](../../sandbox/use_cases/clindoc.md) -[(Full Documentation on CDA Parser)](../../utilities/cda_parser.md) ## Input and Output @@ -59,8 +58,9 @@ Note | [DocumentReference](https://www.hl7.org/fhir/documentreference.html) | `D ## Configuration -The `overwrite` parameter in the `CdaConnector` constructor determines whether existing data in the document should be overwritten. This can be useful for readability with very long CDA documents when the receiving system does not require the full document. +Configure the directory of the CDA templates and configuration files through the `config_dir` parameter in the `CdaConnector` constructor. ```python -cda_connector = CdaConnector(overwrite=True) +cda_connector = CdaConnector(config_dir="path/to/config/dir") ``` +([Full Documentation on InteropEngine](../../interop/interop.md)) diff --git a/docs/reference/utilities/cda_parser.md b/docs/reference/utilities/cda_parser.md deleted file mode 100644 index 2ce6b9a6..00000000 --- a/docs/reference/utilities/cda_parser.md +++ /dev/null @@ -1,99 +0,0 @@ -# CDA Parser - -The `CdaAnnotator` class is responsible for parsing and annotating CDA (Clinical Document Architecture) documents. It extracts information about problems, medications, allergies, and notes from the CDA document into FHIR resources, and allows you to add new information to the CDA document. - -The CDA parser is used in the [CDA Connector](../pipeline/connectors/cdaconnector.md) module, but can also be used independently. - -[(CdaAnnotator API Reference)](../../api/cda_parser.md) - -## Usage - -### Parsing CDA documents - -Parse a CDA document from an XML string: - -```python -from healthchain.cda_parser import CdaAnnotator - -with open("tests/data/test_cda.xml", "r") as f: - cda_xml_string = f.read() - -cda = CdaAnnotator.from_xml(cda_xml_string) - -conditions = cda.problem_list -medications = cda.medication_list -allergies = cda.allergy_list -note = cda.note - -print([condition.model_dump() for condition in conditions]) -print([medication.model_dump() for medication in medications]) -print([allergy.model_dump() for allergy in allergies]) -print(note) -``` - -You can access data parsed from the CDA document in the `problem_list`, `medication_list`, `allergy_list`, and `note` attributes of the `CdaAnnotator` instance. They return a list of FHIR `Condition`, `MedicationStatement`, and `AllergyIntolerance` resources. - -### Adding new information to the CDA document - -The methods currently available for adding new information to the CDA document are: - -| Method | Description | -|--------|-------------| -| `.add_to_problem_list()` | Adds a list of [FHIR Condition](https://www.hl7.org/fhir/condition.html) resources | -| `.add_to_medication_list()` | Adds a list of [FHIR MedicationStatement](https://www.hl7.org/fhir/medicationstatement.html) resources | -| `.add_to_allergy_list()` | Adds a list of [FHIR AllergyIntolerance](https://www.hl7.org/fhir/allergyintolerance.html) resources | - -The `overwrite` parameter in the `add_to_*_list()` methods is used to determine whether to overwrite the existing list or append to it. If `overwrite` is `True`, the existing list will be replaced with the new list. If `overwrite` is `False`, the new list will be appended to the existing list. - -Depending on the use case, you don't always need to return the original list of information in the CDA document you receive, although this is mostly useful if you are just developing and don't want the eye-strain of a lengthy CDA document. - -### Exporting the CDA document - -```python -xml_string = cda.export(pretty_print=True) -``` - -The `pretty_print` parameter is optional and defaults to `True`. If `pretty_print` is `True`, the XML string will be formatted with newlines and indentation. - -## Example - -```python -from healthchain.cda_parser import CdaAnnotator -from healthchain.fhir import ( - create_condition, - create_medication_statement, - create_allergy_intolerance, -) - -with open("tests/data/test_cda.xml", "r") as f: - cda_xml_string = f.read() - -cda = CdaAnnotator.from_xml(cda_xml_string) - -new_problems = [ - create_condition(subject="Patient/123", code="123456", display="New Problem") -] -new_medications = [ - create_medication_statement( - subject="Patient/123", code="789012", display="New Medication" - ) -] -new_allergies = [ - create_allergy_intolerance( - patient="Patient/123", code="345678", display="New Allergy" - ) -] - -# Add new problems, medications, and allergies -cda.add_to_problem_list(new_problems, overwrite=True) -cda.add_to_medication_list(new_medications, overwrite=True) -cda.add_to_allergy_list(new_allergies, overwrite=True) - -# Export the modified CDA document -modified_cda_xml = cda.export() - -print(modified_cda_xml) - -``` - -The CDA parser is a work in progress. I'm just gonna be real with you, CDAs are the bane of my existence. If you, for some reason, love working with XML-based documents, please get [in touch](https://discord.gg/UQC6uAepUz)! We have plans to implement more functionality in the future, including allowing configurable templates, more CDA section methods, and using LLMs as a fallback parsing method. diff --git a/healthchain/__init__.py b/healthchain/__init__.py index dd0417ec..307be960 100644 --- a/healthchain/__init__.py +++ b/healthchain/__init__.py @@ -3,10 +3,11 @@ from .decorators import api, sandbox from .clients import ehr +from .config.base import ConfigManager, ValidationLevel logger = logging.getLogger(__name__) add_handlers(logger) logger.setLevel(logging.INFO) # Export them at the top level -__all__ = ["ehr", "api", "sandbox"] +__all__ = ["ehr", "api", "sandbox", "ConfigManager", "ValidationLevel"] diff --git a/healthchain/cda_parser/__init__.py b/healthchain/cda_parser/__init__.py deleted file mode 100644 index 412ea6be..00000000 --- a/healthchain/cda_parser/__init__.py +++ /dev/null @@ -1,3 +0,0 @@ -from .cdaannotator import CdaAnnotator - -__all__ = ["CdaAnnotator"] diff --git a/healthchain/cda_parser/cdaannotator.py b/healthchain/cda_parser/cdaannotator.py deleted file mode 100644 index 82734e04..00000000 --- a/healthchain/cda_parser/cdaannotator.py +++ /dev/null @@ -1,1337 +0,0 @@ -import xmltodict -import uuid -import re -import logging - -from enum import Enum -from datetime import datetime -from typing import Dict, Optional, List, Tuple, Union - -from fhir.resources.condition import Condition -from fhir.resources.medicationstatement import MedicationStatement -from fhir.resources.allergyintolerance import AllergyIntolerance - -from healthchain.cda_parser.model.datatypes import CD, CE, IVL_PQ -from healthchain.cda_parser.model.cda import ClinicalDocument -from healthchain.cda_parser.model.sections import ( - Entry, - Section, - EntryRelationship, - Observation, -) -from fhir.resources.dosage import Dosage -from healthchain.cda_parser.utils import CodeMapping -from healthchain.fhir import ( - create_condition, - create_allergy_intolerance, - create_medication_statement, - create_single_codeable_concept, - set_problem_list_item_category, - create_single_reaction, -) - -log = logging.getLogger(__name__) - - -def get_value_from_entry_relationship(entry_relationship: EntryRelationship) -> List: - """ - Retrieves the values from the given entry_relationship. - - Args: - entry_relationship: The entry_relationship object to extract values from. - - Returns: - A list of values extracted from the entry_relationship. - - """ - values = [] - if isinstance(entry_relationship, list): - for item in entry_relationship: - if item.observation: - values.append(item.observation.value) - else: - if entry_relationship.observation: - values.append(entry_relationship.observation.value) - return values - - -def check_has_template_id(section: Section, template_id: str) -> bool: - """ - Check if the given section has a matching template ID. - - Args: - section: The section to check. - template_id: The template ID to match. - - Returns: - True if the section has a matching template ID, False otherwise. - """ - - if section.templateId is None: - return False - - if isinstance(section.templateId, list): - for template in section.templateId: - if template.root == template_id: - return True - elif section.templateId.root == template_id: - return True - - return False - - -def check_for_entry_observation(entry: Entry) -> bool: - """ - Checks if the given entry contains an observation. - - Args: - entry: The entry to check. - - Returns: - True if the entry contains an observation, False otherwise. - """ - if isinstance(entry, EntryRelationship): - if entry.observation: - return True - elif isinstance(entry, Observation): - if entry.entryRelationship: - return check_for_entry_observation(entry.entryRelationship) - elif isinstance(entry, list): - for item in entry: - if isinstance(item, EntryRelationship): - if item.observation: - return True - elif isinstance(item, Observation): - if item.entryRelationship: - return check_for_entry_observation(item.entryRelationship) - return False - - -class SectionId(Enum): - PROBLEM = "2.16.840.1.113883.10.20.1.11" - MEDICATION = "2.16.840.1.113883.10.20.1.8" - ALLERGY = "2.16.840.1.113883.10.20.1.2" - NOTE = "1.2.840.114350.1.72.1.200001" - - -class SectionCode(Enum): - PROBLEM = "11450-4" - MEDICATION = "10160-0" - ALLERGY = "48765-2" - NOTE = "51847-2" - - -class ProblemCodes(Enum): - CONDITION = "64572001" - SYMPTOM = "418799008" - FINDING = "404684003" - COMPLAINT = "409586006" - FUNCTIONAL_LIMITATION = "248536006" - PROBLEM = "55607006" - DIAGNOSIS = "282291009" - - -class CdaAnnotator: - """ - Annotates a Clinical Document Architecture (CDA) document. - Limited to problems, medications, allergies, and notes sections for now. - - Args: - cda_data (ClinicalDocument): The CDA document data. - - Attributes: - clinical_document (ClinicalDocument): The CDA document data. - fallback (str): The fallback value. - problem_list (List[Condition]): The list of problems extracted from the CDA document. - medication_list (List[MedicationStatement]): The list of medications extracted from the CDA document. - allergy_list (List[AllergyIntolerance]): The list of allergies extracted from the CDA document. - note (str): The note extracted from the CDA document. - - Methods: - from_dict(cls, data: Dict): Creates a CdaAnnotator instance from a dictionary. - from_xml(cls, data: str): Creates a CdaAnnotator instance from an XML string. - add_to_problem_list(problems: List[Condition], overwrite: bool = False) -> None: Adds a list of Condition resources to the problems section. - export(pretty_print: bool = True) -> str: Exports the CDA document as an XML string. - """ - - def __init__(self, cda_data: ClinicalDocument) -> None: - self.clinical_document = cda_data - self.code_mapping = CodeMapping() - self._get_ccd_sections() - self._extract_data() - - @classmethod - def from_dict(cls, data: Dict) -> "CdaAnnotator": - """ - Creates an instance of the class from a dictionary. - - Args: - data (Dict): The dictionary containing the dictionary representation of the cda xml (using xmltodict.parse). - - Returns: - CdaAnnotator: An instance of the class initialized with the data from the dictionary. - """ - clinical_document_model = ClinicalDocument(**data.get("ClinicalDocument", {})) - return cls(cda_data=clinical_document_model) - - @classmethod - def from_xml(cls, data: str) -> "CdaAnnotator": - """ - Creates an instance of the CDAAnnotator class from an XML string. - - Args: - data (str): The XML string representing the CDA document. - - Returns: - CDAAnnotator: An instance of the CDAAnnotator class initialized with the parsed CDA data. - """ - cda_dict = xmltodict.parse(data) - clinical_document_model = ClinicalDocument( - **cda_dict.get("ClinicalDocument", {}) - ) - return cls(cda_data=clinical_document_model) - - def __str__(self): - problems = "" - allergies = "" - medications = "" - - if self.problem_list: - problems = "\n".join( - [problem.model_dump_json() for problem in self.problem_list] - ) - if self.allergy_list: - allergies = "\n".join( - [allergy.model_dump_json() for allergy in self.allergy_list] - ) - if self.medication_list: - medications = "\n".join( - [medication.model_dump_json() for medication in self.medication_list] - ) - - return problems + allergies + medications - - def _get_ccd_sections(self) -> None: - """ - Retrieves the different sections of the CCD document. - - This method finds and assigns the problem section, medication section, - allergy section, and note section of the CCD document. - - Returns: - None - """ - self._problem_section = self._find_problems_section() - self._medication_section = self._find_medications_section() - self._allergy_section = self._find_allergies_section() - self._note_section = self._find_notes_section() - - def _extract_data(self) -> None: - """ - Extracts data from the CDA document and assigns it to instance variables. - - This method extracts problem list, medication list, allergy list, and note from the CDA document - and assigns them to the corresponding instance variables. - - Returns: - None - """ - self.problem_list: List[Condition] = self._extract_problems() - self.medication_list: List[MedicationStatement] = self._extract_medications() - self.allergy_list: List[AllergyIntolerance] = self._extract_allergies() - self.note: str = self._extract_note() - - def _find_section_by_code(self, section_code: str) -> Optional[Section]: - """ - Finds a section in the clinical document by its code value. - - Args: - section_code (str): The code of the section to find. - - Returns: - Optional[Section]: The section with the specified code, or None if not found. - """ - components = self.clinical_document.component.structuredBody.component - - if not isinstance(components, list): - components = [components] - - for component in components: - code = component.section.code.code - - if code is None: - continue - if code == section_code: - return component.section - log.warning(f"unable to find section with code {section_code}") - return None - - def _find_section_by_template_id(self, section_id: str) -> Optional[Section]: - """ - Finds a section in the clinical document by its template ID. - - Args: - section_id (str): The template ID of the section to find. - - Returns: - Optional[Section]: The section with the specified template ID, or None if not found. - """ - # NOTE not all CDAs have template ids in each section (don't ask me why) - # TODO: It's probably safer to parse by 'code' which is a required field - components = self.clinical_document.component.structuredBody.component - # Ensure components is a list - if not isinstance(components, list): - components = [components] - - for component in components: - template_ids = component.section.templateId - if template_ids is None: - continue - - if isinstance(template_ids, list): - for template_id in template_ids: - if template_id.root == section_id: - return component.section - elif template_ids.root == section_id: - return component.section - - log.warning(f"Unable to find section templateId {section_id}") - - return None - - def _find_problems_section(self) -> Optional[Section]: - return self._find_section_by_template_id( - SectionId.PROBLEM.value - ) or self._find_section_by_code(SectionCode.PROBLEM.value) - - def _find_medications_section(self) -> Optional[Section]: - return self._find_section_by_template_id( - SectionId.MEDICATION.value - ) or self._find_section_by_code(SectionCode.MEDICATION.value) - - def _find_allergies_section(self) -> Optional[Section]: - return self._find_section_by_template_id( - SectionId.ALLERGY.value - ) or self._find_section_by_code(SectionCode.ALLERGY.value) - - def _find_notes_section(self) -> Optional[Section]: - return self._find_section_by_template_id( - SectionId.NOTE.value - ) or self._find_section_by_code(SectionCode.NOTE.value) - - def _extract_problems(self) -> List[Condition]: - """ - Extracts problems from the CDA document's problem section and converts them to FHIR Condition resources. - - The method processes each problem entry in the CDA document and: - - Maps CDA status codes to FHIR clinical status - - Extracts onset and abatement dates - - Creates FHIR Condition resources with appropriate coding - - Sets problem list item category - - Handles both single entries and lists of entries - - Returns: - List[Condition]: A list of FHIR Condition resources representing the extracted problems. - Returns empty list if problem section is not found. - """ - if not self._problem_section: - log.warning("Empty problem section!") - return [] - - conditions = [] - - def create_fhir_condition_from_cda(value: Dict, entry) -> Condition: - # Map CDA status to FHIR clinical status - status = "unknown" - if hasattr(entry, "act") and hasattr(entry.act, "statusCode"): - status_code = entry.act.statusCode.code - status = self.code_mapping.cda_to_fhir( - status_code, "status", case_sensitive=False, default="unknown" - ) - - # Extract dates from entry - onset_date = None - abatement_date = None - if hasattr(entry, "act") and hasattr(entry.act, "effectiveTime"): - effective_time = entry.act.effectiveTime - if hasattr(effective_time, "low") and effective_time.low: - onset_date = CodeMapping.convert_date_cda_to_fhir( - effective_time.low.value - ) - - if hasattr(effective_time, "high") and effective_time.high: - abatement_date = CodeMapping.convert_date_cda_to_fhir( - effective_time.high.value - ) - - # Create condition using helper function - condition = create_condition( - subject="Patient/123", # TODO: add patient reference {self.clinical_document.recordTarget.patientRole.id} - clinical_status=status, - code=value.get("@code"), - display=value.get("@displayName"), - system=self.code_mapping.cda_to_fhir( - value.get("@codeSystem"), "system" - ), - ) - - # Add dates if present - if onset_date: - condition.onsetDateTime = onset_date - if abatement_date: - condition.abatementDateTime = abatement_date - - # Set category (problem-list-item by default for problems section) - set_problem_list_item_category(condition) - - return condition - - entries = ( - self._problem_section.entry - if isinstance(self._problem_section.entry, list) - else [self._problem_section.entry] - ) - - for entry in entries: - entry_relationship = entry.act.entryRelationship - values = get_value_from_entry_relationship(entry_relationship) - for value in values: - condition = create_fhir_condition_from_cda(value, entry) - conditions.append(condition) - - return conditions - - def _extract_medications(self) -> List[MedicationStatement]: - """ - Extracts medication concepts from the medication section of the CDA document. - - Returns: - A list of MedicationStatement resources representing the extracted medication concepts. - """ - if not self._medication_section: - log.warning("Empty medication section!") - return [] - - medications = [] - - def create_medication_statement_from_cda( - code: CD, - dose_quantity: Optional[IVL_PQ], - route_code: Optional[CE], - effective_times: Optional[Union[List[Dict], Dict]], - ) -> MedicationStatement: - # Map CDA system to FHIR system - fhir_system = self.code_mapping.cda_to_fhir( - code.codeSystem, "system", default="http://snomed.info/sct" - ) - - # Create base medication statement using helper - medication = create_medication_statement( - subject="Patient/123", # TODO: extract patient reference - status="recorded", # TODO: extract status - code=code.code, - display=code.displayName, - system=fhir_system, - ) - - # Add dosage if present - if dose_quantity: - medication.dosage = [ - { - "doseAndRate": [ - { - "doseQuantity": { - "value": dose_quantity.value, - "unit": dose_quantity.unit, - } - } - ] - } - ] - - # Add route if present - if route_code: - route_system = self.code_mapping.cda_to_fhir( - route_code.codeSystem, "system", default="http://snomed.info/sct" - ) - medication.dosage = medication.dosage or [Dosage()] - medication.dosage[0].route = create_single_codeable_concept( - code=route_code.code, - display=route_code.displayName, - system=route_system, - ) - - # Add timing if present - if effective_times: - effective_times = ( - effective_times - if isinstance(effective_times, list) - else [effective_times] - ) - # TODO: could refactor this into a pydantic validator - for effective_time in effective_times: - if effective_time.get("@xsi:type") == "IVL_TS": - # Handle duration - low_value = effective_time.get("low", {}).get("@value") - high_value = effective_time.get("high", {}).get("@value") - - if low_value or high_value: - medication.effectivePeriod = {} - if low_value: - medication.effectivePeriod.start = ( - CodeMapping.convert_date_cda_to_fhir(low_value) - ) - if high_value: - medication.effectivePeriod.end = ( - CodeMapping.convert_date_cda_to_fhir(high_value) - ) - - elif effective_time.get("@xsi:type") == "PIVL_TS": - # Handle frequency - period = effective_time.get("period") - if period: - medication.dosage = medication.dosage or [Dosage()] - medication.dosage[0].timing = { - "repeat": { - "period": float(period.get("@value")), - "periodUnit": period.get("@unit"), - } - } - - return medication - - entries = ( - self._medication_section.entry - if isinstance(self._medication_section.entry, list) - else [self._medication_section.entry] - ) - - for entry in entries: - substance_administration = entry.substanceAdministration - if not substance_administration: - log.warning("Substance administration not found in entry.") - continue - - # Get medication details - consumable = substance_administration.consumable - manufactured_product = ( - consumable.manufacturedProduct if consumable else None - ) - manufactured_material = ( - manufactured_product.manufacturedMaterial - if manufactured_product - else None - ) - code = manufactured_material.code if manufactured_material else None - - if not code: - log.warning("Code not found in the consumable") - continue - - # Create FHIR medication statement - medication = create_medication_statement_from_cda( - code=code, - dose_quantity=substance_administration.doseQuantity, - route_code=substance_administration.routeCode, - effective_times=substance_administration.effectiveTime, - ) - medications.append(medication) - - return medications - - def _extract_allergies(self) -> List[AllergyIntolerance]: - """ - Extracts allergy concepts from the allergy section of the CDA document. - - Returns: - List[AllergyIntolerance]: A list of FHIR AllergyIntolerance resources. - """ - if not self._allergy_section: - log.warning("Empty allergy section!") - return [] - - allergies = [] - - def get_allergy_details_from_entry_relationship( - entry_relationship: EntryRelationship, - ) -> Tuple[str, CD, Dict, Dict]: - allergen_name = None - allergy_type = None - reaction = None - severity = None - - # TODO: Improve this - - entry_relationships = ( - entry_relationship - if isinstance(entry_relationship, list) - else [entry_relationship] - ) - for entry_relationship in entry_relationships: - if check_for_entry_observation(entry_relationship): - allergy_type = entry_relationship.observation.code - observation = entry_relationship.observation - allergen_name = ( - observation.participant.participantRole.playingEntity.name - ) - - if check_for_entry_observation(observation): - observation_entry_relationships = ( - observation.entryRelationship - if isinstance(observation.entryRelationship, list) - else [observation.entryRelationship] - ) - for observation_entry_rel in observation_entry_relationships: - if check_has_template_id( - observation_entry_rel.observation, - "1.3.6.1.4.1.19376.1.5.3.1.4.5", - ): - reaction = observation_entry_rel.observation.value - - if check_for_entry_observation( - observation_entry_rel.observation - ): - if check_has_template_id( - observation_entry_rel.observation.entryRelationship.observation, - "1.3.6.1.4.1.19376.1.5.3.1.4.1", - ): - severity = observation_entry_rel.observation.entryRelationship.observation.value - - return allergen_name, allergy_type, reaction, severity - - entries = ( - self._allergy_section.entry - if isinstance(self._allergy_section.entry, list) - else [self._allergy_section.entry] - ) - - for entry in entries: - entry_relationship = entry.act.entryRelationship - values = get_value_from_entry_relationship(entry_relationship) - - allergen_name, allergy_type, reaction, severity = ( - get_allergy_details_from_entry_relationship(entry_relationship) - ) - - for value in values: - # Map CDA system to FHIR system - allergy_code_system = self.code_mapping.cda_to_fhir( - value.get("@codeSystem", ""), - "system", - default="http://snomed.info/sct", - ) - allergy = create_allergy_intolerance( - patient="Patient/123", # TODO: Get from patient context - code=value.get("@code"), - display=value.get("@displayName"), - system=allergy_code_system, - ) - if allergy.code and allergy.code.coding[0].display is None: - allergy.code.coding[0].display = allergen_name - - if allergy_type: - allergy_type_system = self.code_mapping.cda_to_fhir( - allergy_type.codeSystem, - "system", - default="http://snomed.info/sct", - ) - allergy.type = create_single_codeable_concept( - code=allergy_type.code, - display=allergy_type.displayName, - system=allergy_type_system, - ) - - if reaction: - reaction_system = self.code_mapping.cda_to_fhir( - reaction.get("@codeSystem"), - "system", - default="http://snomed.info/sct", - ) - allergy.reaction = create_single_reaction( - code=reaction.get("@code"), - display=reaction.get("@displayName"), - system=reaction_system, - ) - - if severity: - severity_code = self.code_mapping.cda_to_fhir( - severity.get("@code"), - "severity", - default="http://snomed.info/sct", - ) - if allergy.reaction: - allergy.reaction[0].severity = severity_code - allergies.append(allergy) - - return allergies - - def _extract_note(self) -> str: - """ - Extracts the note section from the CDA document. - - Returns: - str: The extracted note section as a string. - """ - # TODO: need to handle / escape html tags within the note section, parse with right field - if not self._note_section: - log.warning("Empty notes section!") - return [] - - return self._note_section.text - - def _add_new_problem_entry( - self, - new_problem: Condition, - timestamp: str, - act_id: str, - problem_reference_name: str, - ) -> None: - """ - Adds a new problem entry to the problem section of the CDA document. - - Args: - new_problem (Condition): The new problem concept to be added. - timestamp (str): The timestamp of the entry. - act_id (str): The ID of the act. - problem_reference_name (str): The reference name of the problem. - - Returns: - None - """ - - # Get CDA status from FHIR clinical status - fhir_status = new_problem.clinicalStatus.coding[0].code - cda_status = self.code_mapping.fhir_to_cda( - fhir_status, "status", case_sensitive=False, default="unknown" - ) - - # Get CDA system from FHIR system - if not new_problem.code: - log.warning("No code found for problem") - return - - fhir_system = new_problem.code.coding[0].system - cda_system = self.code_mapping.fhir_to_cda( - fhir_system, "system", default="2.16.840.1.113883.6.96" - ) # Default to SNOMED-CT - - template = { - "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": {"@root": act_id}, - "code": {"@nullflavor": "NA"}, - "statusCode": {"@code": cda_status}, - "effectiveTime": {"low": {"@value": timestamp}}, - "entryRelationship": { - "@typeCode": "SUBJ", - "@inversionInd": False, - "observation": { - "@classCode": "OBS", - "@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"}, - ], - "id": {"@root": act_id}, - "code": { - "@code": "55607006", - "@codeSystem": "2.16.840.1.113883.6.96", - "@codeSystemName": "SNOMED CT", - "@displayName": "Problem", - }, - "text": {"reference": {"@value": problem_reference_name}}, - "value": { - "@xmlns:xsi": "http://www.w3.org/2001/XMLSchema-instance", - "@code": new_problem.code.coding[0].code, - "@codeSystem": cda_system, - "@displayName": new_problem.code.coding[0].display, - "originalText": { - "reference": {"@value": problem_reference_name} - }, - "@xsi:type": "CD", - }, - "statusCode": {"@code": "completed"}, - "effectiveTime": {"low": {"@value": timestamp}}, - "entryRelationship": { - "@typeCode": "REFR", - "observation": { - "@classCode": "OBS", - "@moodCode": "EVN", - "code": { - "@code": "33999-4", - "@codeSystem": "2.16.840.1.113883.6.1", - "@displayName": "Status", - }, - "value": { - "@xmlns:xsi": "http://www.w3.org/2001/XMLSchema-instance", - "@code": "55561003", - "@codeSystem": "2.16.840.1.113883.6.96", - "@displayName": "Active", - "@xsi:type": "CE", - }, - "statusCode": {"@code": "completed"}, - "effectiveTime": {"low": {"@value": timestamp}}, - }, - }, - }, - }, - } - } - if not isinstance(self._problem_section.entry, list): - self._problem_section.entry = [self._problem_section.entry] - - new_entry = Entry(**template) - self._problem_section.entry.append(new_entry) - - def add_to_problem_list( - self, problems: List[Condition], overwrite: bool = False - ) -> None: - """ - Adds a list of problem lists to the problems section. - - Args: - problems (List[Condition]): A list of Condition resources to be added. - overwrite (bool, optional): If True, the existing problem list will be overwritten. - Defaults to False. - - Returns: - None - """ - if self._problem_section is None: - log.warning( - "Skipping: No problem section to add to, check your CDA configuration" - ) - return - - timestamp = datetime.now().strftime(format="%Y%m%d") - act_id = str(uuid.uuid4()) - problem_reference_name = "#p" + str(uuid.uuid4())[:8] + "name" - - if overwrite: - self._problem_section.entry = [] - - added_problems = [] - - for problem in problems: - if problem in self.problem_list: - log.debug( - f"Skipping: Problem {problem.model_dump()} already exists in the problem list." - ) - continue - log.debug(f"Adding problem: {problem}") - self._add_new_problem_entry( - new_problem=problem, - timestamp=timestamp, - act_id=act_id, - problem_reference_name=problem_reference_name, - ) - added_problems.append(problem) - - if overwrite: - self.problem_list = added_problems - else: - self.problem_list.extend(added_problems) - - def _add_new_medication_entry( - self, - new_medication: MedicationStatement, - timestamp: str, - subad_id: str, - medication_reference_name: str, - ) -> None: - """ - Adds a new medication entry to the medication section of the CDA document. - - Args: - new_medication (MedicationStatement): The FHIR MedicationStatement resource to add to the CDA - timestamp (str): The timestamp for when this entry was created, in YYYYMMDD format - subad_id (str): The unique ID for this substance administration entry - medication_reference_name (str): The reference name used to link narrative text to this medication - - The method creates a CDA substance administration entry with: - - Medication details (code, name, etc) - - Dosage information if present (amount, route, frequency) - - Effective time periods - - Status as Active - """ - - if not new_medication.medication.concept: - log.warning("No medication concept found for medication") - return - - # Get CDA system from FHIR system - fhir_system = new_medication.medication.concept.coding[0].system - cda_system = self.code_mapping.fhir_to_cda( - fhir_system, "system", default="2.16.840.1.113883.6.96" - ) - - effective_times = [] - - # Handle timing/frequency - if new_medication.dosage and new_medication.dosage[0].timing: - timing = new_medication.dosage[0].timing.repeat - effective_times.append( - { - "@xsi:type": "PIVL_TS", - "@institutionSpecified": True, - "@operator": "A", - "@xmlns:xsi": "http://www.w3.org/2001/XMLSchema-instance", - "period": { - "@unit": timing.periodUnit, - "@value": str(timing.period), - }, - } - ) - - # Handle effective period - # TODO: standardize datetime format - if new_medication.effectivePeriod: - time_range = { - "@xsi:type": "IVL_TS", - "@xmlns:xsi": "http://www.w3.org/2001/XMLSchema-instance", - "low": {"@nullFlavor": "UNK"}, - "high": {"@nullFlavor": "UNK"}, - } - if new_medication.effectivePeriod.start: - time_range["low"] = { - "@value": CodeMapping.convert_date_fhir_to_cda( - new_medication.effectivePeriod.start - ) - } - if new_medication.effectivePeriod.end: - time_range["high"] = { - "@value": CodeMapping.convert_date_fhir_to_cda( - new_medication.effectivePeriod.end - ) - } - effective_times.append(time_range) - - template = { - "substanceAdministration": { - "@classCode": "SBADM", - "@moodCode": "INT", - "templateId": [ - {"@root": "2.16.840.1.113883.10.20.1.24"}, - {"@root": "2.16.840.1.113883.3.88.11.83.8"}, - {"@root": "1.3.6.1.4.1.19376.1.5.3.1.4.7"}, - {"@root": "1.3.6.1.4.1.19376.1.5.3.1.4.7.1"}, - {"@root": "2.16.840.1.113883.3.88.11.32.8"}, - ], - "id": {"@root": subad_id}, - "statusCode": {"@code": "completed"}, - } - } - - # Add dosage if present - if new_medication.dosage and new_medication.dosage[0].doseAndRate: - dose = new_medication.dosage[0].doseAndRate[0].doseQuantity - template["substanceAdministration"]["doseQuantity"] = { - "@value": dose.value, - "@unit": dose.unit, - } - - # Add route if present - if new_medication.dosage and new_medication.dosage[0].route: - route = new_medication.dosage[0].route.coding[0] - route_system = self.code_mapping.fhir_to_cda(route.system, "system") - template["substanceAdministration"]["routeCode"] = { - "@code": route.code, - "@codeSystem": route_system, - "@displayName": route.display, - } - - # Add timing - if effective_times: - template["substanceAdministration"]["effectiveTime"] = effective_times - - # Add medication details - template["substanceAdministration"]["consumable"] = { - "@typeCode": "CSM", - "manufacturedProduct": { - "@classCode": "MANU", - "templateId": [ - {"@root": "1.3.6.1.4.1.19376.1.5.3.1.4.7.2"}, - {"@root": "2.16.840.1.113883.10.20.1.53"}, - {"@root": "2.16.840.1.113883.3.88.11.32.9"}, - {"@root": "2.16.840.1.113883.3.88.11.83.8.2"}, - ], - "manufacturedMaterial": { - "code": { - "@code": new_medication.medication.concept.coding[0].code, - "@codeSystem": cda_system, - "@displayName": new_medication.medication.concept.coding[ - 0 - ].display, - "originalText": { - "reference": {"@value": medication_reference_name} - }, - } - }, - }, - } - - # Add an Active status - template["substanceAdministration"]["entryRelationship"] = ( - { - "@typeCode": "REFR", - "observation": { - "@classCode": "OBS", - "@moodCode": "EVN", - "effectiveTime": {"low": {"@value": timestamp}}, - "templateId": {"@root": "2.16.840.1.113883.10.20.1.47"}, - "code": { - "@code": "33999-4", - "@codeSystem": "2.16.840.1.113883.6.1", - "@codeSystemName": "LOINC", - "@displayName": "Status", - }, - "value": { - "@code": "755561003", - "@codeSystem": "2.16.840.1.113883.6.96", - "@codeSystemName": "SNOMED CT", - "@xsi:type": "CE", - "@displayName": "Active", - "@xmlns:xsi": "http://www.w3.org/2001/XMLSchema-instance", - }, - "statusCode": {"@code": "completed"}, - }, - }, - ) - - if not isinstance(self._medication_section.entry, list): - self._medication_section.entry = [self._medication_section.entry] - - new_entry = Entry(**template) - self._medication_section.entry.append(new_entry) - - def add_to_medication_list( - self, medications: List[MedicationStatement], overwrite: bool = False - ) -> None: - """ - Adds medications to the medication list. - - Args: - medications (List[MedicationStatement]): A list of MedicationStatement resources to be added - overwrite (bool, optional): If True, existing medication list will be overwritten. Defaults to False. - """ - if self._medication_section is None: - log.warning( - "Skipping: No medication section to add to, check your CDA configuration" - ) - return - - timestamp = datetime.now().strftime(format="%Y%m%d") - subad_id = str(uuid.uuid4()) - medication_reference_name = "#m" + str(uuid.uuid4())[:8] + "name" - - if overwrite: - self._medication_section.entry = [] - - added_medications = [] - - for medication in medications: - if medication in self.medication_list: - log.debug( - f"Skipping: medication {medication.model_dump()} already exists in the medication list." - ) - continue - - log.debug(f"Adding medication: {medication}") - self._add_new_medication_entry( - new_medication=medication, - timestamp=timestamp, - subad_id=subad_id, - medication_reference_name=medication_reference_name, - ) - added_medications.append(medication) - - if overwrite: - self.medication_list = added_medications - else: - self.medication_list.extend(added_medications) - - def _add_new_allergy_entry( - self, - new_allergy: AllergyIntolerance, - timestamp: str, - act_id: str, - allergy_reference_name: str, - ) -> None: - """ - Adds a new allergy entry to the allergy section of the CDA document. - - Args: - new_allergy (AllergyIntolerance): The new allergy concept to be added. - timestamp (str): The timestamp of the entry. - act_id (str): The ID of the act. - allergy_reference_name (str): The reference name of the allergy. - - Returns: - None - """ - if not new_allergy.code: - log.warning("No code found for allergy") - return - - template = { - "act": { - "@classCode": "ACT", - "@moodCode": "EVN", - "templateId": [ - {"@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.3"}, - {"@root": "2.16.840.1.113883.3.88.11.32.6"}, - {"@root": "2.16.840.1.113883.3.88.11.83.6"}, - ], - "id": {"@root": act_id}, - "code": {"@nullFlavor": "NA"}, - "statusCode": {"@code": "active"}, - "effectiveTime": {"low": {"@value": timestamp}}, - "entryRelationship": { - "@typeCode": "SUBJ", - "@inversionInd": False, - "observation": { - "@classCode": "OBS", - "@moodCode": "EVN", - "templateId": [ - {"@root": "1.3.6.1.4.1.19376.1.5.3.1.4.5"}, - {"@root": "1.3.6.1.4.1.19376.1.5.3.1.4.6"}, - {"@root": "2.16.840.1.113883.10.20.1.18"}, - { - "@root": "1.3.6.1.4.1.19376.1.5.3.1", - "@extension": "allergy", - }, - {"@root": "2.16.840.1.113883.10.20.1.28"}, - ], - "id": {"@root": act_id}, - "text": {"reference": {"@value": allergy_reference_name}}, - "statusCode": {"@code": "completed"}, - "effectiveTime": {"low": {"@value": timestamp}}, - }, - }, - } - } - allergen_observation = template["act"]["entryRelationship"]["observation"] - - # Attach allergy type code - if new_allergy.type: - allergy_type_system = self.code_mapping.fhir_to_cda( - new_allergy.type.coding[0].system, - "system", - default="2.16.840.1.113883.6.96", - ) - allergen_observation["code"] = { - "@code": new_allergy.type.coding[0].code, - "@codeSystem": allergy_type_system, - # "@codeSystemName": new_allergy.type.coding[0].display, - "@displayName": new_allergy.type.coding[0].display, - } - else: - log.warning("Allergy type code is missing, using default.") - allergen_observation["code"] = { - "@code": "420134006", - "@codeSystem": "2.16.840.1.113883.6.96", - "@displayName": "Propensity to adverse reactions", - } - - # Attach allergen code to value and participant - allergen_code_system = self.code_mapping.fhir_to_cda( - new_allergy.code.coding[0].system, - "system", - default="2.16.840.1.113883.6.96", - ) - allergen_observation["value"] = { - "@xmlns:xsi": "http://www.w3.org/2001/XMLSchema-instance", - "@code": new_allergy.code.coding[0].code, - "@codeSystem": allergen_code_system, - # "@codeSystemName": new_allergy.code.coding[0].display, - "@displayName": new_allergy.code.coding[0].display, - "originalText": {"reference": {"@value": allergy_reference_name}}, - "@xsi:type": "CD", - } - - allergen_observation["participant"] = { - "@typeCode": "CSM", - "participantRole": { - "@classCode": "MANU", - "playingEntity": { - "@classCode": "MMAT", - "code": { - "originalText": { - "reference": {"@value": allergy_reference_name} - }, - "@code": new_allergy.code.coding[0].code, - "@codeSystem": allergen_code_system, - # "@codeSystemName": new_allergy.code.coding[0].display, - "@displayName": new_allergy.code.coding[0].display, - }, - "name": new_allergy.code.coding[0].display, - }, - }, - } - - # We need an entryRelationship if either reaction or severity is present - if new_allergy.reaction: - allergen_observation["entryRelationship"] = { - "@typeCode": "MFST", - "observation": { - "@classCode": "OBS", - "@moodCode": "EVN", - "templateId": [ - {"@root": "2.16.840.1.113883.10.20.1.54"}, - {"@root": "1.3.6.1.4.1.19376.1.5.3.1.4.5"}, - { - "@root": "1.3.6.1.4.1.19376.1.5.3.1.4.5", - "@extension": "reaction", - }, - ], - "id": {"@root": act_id}, - "code": {"@code": "RXNASSESS"}, - "text": { - "reference": {"@value": allergy_reference_name + "reaction"} - }, - "statusCode": {"@code": "completed"}, - "effectiveTime": {"low": {"@value": timestamp}}, - }, - } - # Attach reaction code if given otherwise attach nullFlavor - if new_allergy.reaction: - reaction_code_system = self.code_mapping.fhir_to_cda( - new_allergy.reaction[0].manifestation[0].concept.coding[0].system, - "system", - default="2.16.840.1.113883.6.96", - ) - allergen_observation["entryRelationship"]["observation"]["value"] = { - "@xmlns:xsi": "http://www.w3.org/2001/XMLSchema-instance", - "@code": new_allergy.reaction[0] - .manifestation[0] - .concept.coding[0] - .code, - "@codeSystem": reaction_code_system, - # "@codeSystemName": new_allergy.reaction[0].manifestation[0].concept.coding[0].display, - "@displayName": new_allergy.reaction[0] - .manifestation[0] - .concept.coding[0] - .display, - "@xsi:type": "CD", - "originalText": { - "reference": {"@value": allergy_reference_name + "reaction"} - }, - } - else: - allergen_observation["entryRelationship"]["observation"]["value"] = { - "@xmlns:xsi": "http://www.w3.org/2001/XMLSchema-instance", - "@nullFlavor": "OTH", - "@xsi:type": "CD", - } - # Attach severity code if given - if new_allergy.reaction[0].severity: - severity_code = self.code_mapping.fhir_to_cda( - new_allergy.reaction[0].severity, "severity" - ) - allergen_observation["entryRelationship"]["observation"][ - "entryRelationship" - ] = { - "@typeCode": "SUBJ", - "observation": { - "@classCode": "OBS", - "@moodCode": "EVN", - "templateId": [ - {"@root": "2.16.840.1.113883.10.20.1.55"}, - {"@root": "1.3.6.1.4.1.19376.1.5.3.1.4.1"}, - ], - "code": { - "@code": "SEV", - "@codeSystem": "2.16.840.1.113883.5.4", - "@codeSystemName": "ActCode", - "@displayName": "Severity", - }, - "text": { - "reference": {"@value": allergy_reference_name + "severity"} - }, - "statusCode": {"@code": "completed"}, - "value": { - "@xmlns:xsi": "http://www.w3.org/2001/XMLSchema-instance", - "@code": new_allergy.reaction[0].severity, - "@codeSystem": severity_code, - # "@codeSystemName": new_allergy.severity.code_system_name, - "@displayName": new_allergy.reaction[0].severity, - "@xsi:type": "CD", - }, - }, - } - - if not isinstance(self._allergy_section.entry, list): - self._allergy_section.entry = [self._allergy_section.entry] - - new_entry = Entry(**template) - self._allergy_section.entry.append(new_entry) - - def add_to_allergy_list( - self, allergies: List[AllergyIntolerance], overwrite: bool = False - ) -> None: - """ - Adds allergies to the allergy list. - - Args: - allergies: List of FHIR AllergyIntolerance resources to add - overwrite: If True, overwrites existing allergy list - """ - if self._allergy_section is None: - log.warning( - "Skipping: No allergy section to add to, check your CDA configuration" - ) - return - - timestamp = datetime.now().strftime(format="%Y%m%d") - act_id = str(uuid.uuid4()) - allergy_reference_name = "#a" + str(uuid.uuid4())[:8] + "name" - - if overwrite: - self._allergy_section.entry = [] - - added_allergies = [] - - for allergy in allergies: - if allergy in self.allergy_list: - log.debug(f"Allergy {allergy.model_dump()} already exists") - continue - log.debug(f"Adding allergy: {allergy}") - self._add_new_allergy_entry( - new_allergy=allergy, - timestamp=timestamp, - act_id=act_id, - allergy_reference_name=allergy_reference_name, - ) - added_allergies.append(allergy) - - if overwrite: - self.allergy_list = added_allergies - else: - self.allergy_list.extend(added_allergies) - - def export(self, pretty_print: bool = True) -> str: - """ - Exports CDA document as an XML string - """ - out_string = xmltodict.unparse( - { - "ClinicalDocument": self.clinical_document.model_dump( - exclude_none=True, exclude_unset=True, by_alias=True - ) - }, - pretty=pretty_print, - ) - # Fixes self closing tags - this is not strictly necessary, just looks more readable - pattern = r"(<(\w+)(\s+[^>]*?)?)>" - export_xml = re.sub(pattern, r"\1/>", out_string) - - return export_xml diff --git a/healthchain/cda_parser/utils.py b/healthchain/cda_parser/utils.py deleted file mode 100644 index 976b57a8..00000000 --- a/healthchain/cda_parser/utils.py +++ /dev/null @@ -1,224 +0,0 @@ -import yaml -import logging -from pathlib import Path -from typing import Any, Dict, List, Optional, Union -from enum import Enum - -log = logging.getLogger(__name__) - - -class MappingStrategy(Enum): - """Defines how to handle multiple matches when converting FHIR to CDA.""" - - FIRST = "first" # Return first match found - ALL = "all" # Return all matches as list - ERROR = "error" # Raise error if multiple matches found - - -# TODO: Dates, times, human readable names, etc. -class CodeMapping: - """Handles bidirectional mapping between CDA and FHIR codes and formats.""" - - # Default mappings as fallback - DEFAULT_MAPPINGS = { - "system": { - "cda_to_fhir": { - "2.16.840.1.113883.6.96": "http://snomed.info/sct", - "2.16.840.1.113883.3.26.1.1": "http://ncit.nci.nih.gov", - "2.16.840.1.113883.6.88": "http://www.nlm.nih.gov/research/umls/rxnorm", - } - }, - "status": { - "cda_to_fhir": { - "active": "active", - "completed": "resolved", - "aborted": "inactive", - "suspended": "inactive", - } - }, - "date_format": { - "cda_to_fhir": { - "YYYYMMDD": "YYYY-MM-DD", - } - }, - "severity": { - "cda_to_fhir": { - "H": "severe", - "M": "moderate", - "L": "mild", - } - }, - } - - def __init__(self, config_path: Optional[Union[str, Path]] = None): - """Initialize with optional config file path.""" - self.mappings = self._load_mappings(config_path) - self._validate_mappings() - - def _load_mappings(self, config_path: Optional[Union[str, Path]] = None) -> Dict: - """Load mappings from config file if provided, else use defaults.""" - if not config_path: - return self.DEFAULT_MAPPINGS - - try: - with open(config_path, "r") as f: - return yaml.safe_load(f) - except Exception as e: - log.error(f"Failed to load mappings from {config_path}: {e}") - log.warning("Falling back to default mappings") - return self.DEFAULT_MAPPINGS - - def _validate_mappings(self) -> None: - """Validate mapping structure and log warnings for potential issues.""" - for mapping_type, mapping_data in self.mappings.items(): - if "cda_to_fhir" not in mapping_data: - log.warning(f"Missing cda_to_fhir mapping for {mapping_type}") - - # Check for duplicate FHIR codes - fhir_codes = {} - for cda, fhir in mapping_data.get("cda_to_fhir", {}).items(): - if fhir in fhir_codes: - log.warning( - f"Duplicate FHIR mapping found in {mapping_type}: " - f"{fhir} maps to both {cda} and {fhir_codes[fhir]}" - ) - fhir_codes[fhir] = cda - - def cda_to_fhir( - self, - code: str, - mapping_type: str, - case_sensitive: bool = False, - default: Any = None, - ) -> Optional[str]: - """Convert CDA code to FHIR code.""" - try: - mapping = self.mappings[mapping_type]["cda_to_fhir"] - - # Add null check for code - if code is None: - log.error(f"Received None code for mapping type '{mapping_type}'") - return default - - if not case_sensitive: - code = code.lower() - mapping = {k.lower(): v for k, v in mapping.items()} - - result = mapping.get(code, default) - if result is None: - log.debug(f"No mapping found for CDA code '{code}' in {mapping_type}") - return result - - except KeyError: - log.error(f"Invalid mapping type: {mapping_type}") - return default - except AttributeError as e: - log.error(f"Invalid code type for '{code}' in {mapping_type}: {str(e)}") - return default - except Exception as e: - log.error( - f"Unexpected error converting code '{code}' in {mapping_type}: {str(e)}" - ) - return default - - def fhir_to_cda( - self, - code: str, - mapping_type: str, - strategy: MappingStrategy = MappingStrategy.FIRST, - case_sensitive: bool = False, - default: Any = None, - ) -> Union[str, List[str], None]: - """Convert FHIR code to CDA code(s).""" - try: - mapping = self.mappings[mapping_type]["cda_to_fhir"] - if not case_sensitive: - code = code.lower() - mapping = {k: v.lower() for k, v in mapping.items()} - - matches = [cda for cda, fhir in mapping.items() if fhir == code] - - if not matches: - log.debug(f"No mapping found for FHIR code '{code}' in {mapping_type}") - return default - - if len(matches) > 1: - if strategy == MappingStrategy.ERROR: - raise ValueError( - f"Multiple CDA codes found for FHIR code '{code}': {matches}" - ) - elif strategy == MappingStrategy.ALL: - return matches - - return matches[0] - - except KeyError: - log.error(f"Invalid mapping type: {mapping_type}") - return default - - def get_mapping_types(self) -> List[str]: - """Return list of available mapping types.""" - return list(self.mappings.keys()) - - def add_mapping(self, mapping_type: str, cda_code: str, fhir_code: str) -> None: - """Add a new mapping pair.""" - if mapping_type not in self.mappings: - self.mappings[mapping_type] = {"cda_to_fhir": {}} - - self.mappings[mapping_type]["cda_to_fhir"][cda_code] = fhir_code - log.info(f"Added mapping: {mapping_type} - {cda_code} -> {fhir_code}") - - # TODO: use datetime - @classmethod - def convert_date_cda_to_fhir(cls, date_str: Optional[str]) -> Optional[str]: - """Convert CDA date format (YYYYMMDD) to FHIR date format (YYYY-MM-DD). - - Args: - date_str: Date string in CDA format (YYYYMMDD) - - Returns: - Date string in FHIR format (YYYY-MM-DD) or None if input is invalid - """ - if not date_str or not isinstance(date_str, str): - return None - - # Validate input format - if not date_str.isdigit() or len(date_str) != 8: - log.warning(f"Invalid CDA date format: {date_str}") - return None - - try: - from datetime import datetime - - parsed_date = datetime.strptime(date_str, "%Y%m%d") - return parsed_date.strftime("%Y-%m-%d") - except (ValueError, TypeError): - log.warning(f"Invalid CDA date format: {date_str}") - return None - - @classmethod - def convert_date_fhir_to_cda(cls, date_str: Optional[str]) -> Optional[str]: - """Convert FHIR date format (YYYY-MM-DD) to CDA date format (YYYYMMDD). - - Args: - date_str: Date string in FHIR format (YYYY-MM-DD) - - Returns: - Date string in CDA format (YYYYMMDD) or None if input is invalid - """ - if not date_str or not isinstance(date_str, str): - return None - - # Validate input format - if not len(date_str) == 10 or date_str[4] != "-" or date_str[7] != "-": - log.warning(f"Invalid FHIR date format: {date_str}") - return None - - try: - from datetime import datetime - - parsed_date = datetime.strptime(date_str, "%Y-%m-%d") - return parsed_date.strftime("%Y%m%d") - except (ValueError, TypeError): - log.warning(f"Invalid FHIR date format: {date_str}") - return None diff --git a/healthchain/config/__init__.py b/healthchain/config/__init__.py new file mode 100644 index 00000000..11e6b739 --- /dev/null +++ b/healthchain/config/__init__.py @@ -0,0 +1,27 @@ +""" +HealthChain Configuration Module + +This module manages configuration for HealthChain components, providing +functionality for loading, validating, and accessing configuration settings +from various sources. +""" + +from healthchain.config.base import ( + ConfigManager, + ValidationLevel, +) +from healthchain.config.validators import ( + validate_cda_section_config_model, + validate_cda_document_config_model, + register_cda_section_template_config_model, + register_cda_document_template_config_model, +) + +__all__ = [ + "ConfigManager", + "ValidationLevel", + "validate_cda_section_config_model", + "validate_cda_document_config_model", + "register_cda_section_template_config_model", + "register_cda_document_template_config_model", +] diff --git a/healthchain/config/base.py b/healthchain/config/base.py new file mode 100644 index 00000000..f14d33fe --- /dev/null +++ b/healthchain/config/base.py @@ -0,0 +1,522 @@ +import yaml +import logging +import os +from pathlib import Path +from typing import Dict, Any, Optional, List + +log = logging.getLogger(__name__) + + +def _deep_merge(target: Dict, source: Dict) -> None: + """Deep merge source dictionary into target dictionary + + Args: + target: Target dictionary to merge into + source: Source dictionary to merge from + """ + for key, value in source.items(): + if key in target and isinstance(target[key], dict) and isinstance(value, dict): + # If both are dictionaries, recursively merge + _deep_merge(target[key], value) + else: + # Otherwise, overwrite the value + target[key] = value + + +def _get_nested_value(data: Dict, parts: List[str]) -> Any: + """Get a nested value from a dictionary using a list of keys + + Args: + data: Dictionary to search in + parts: List of keys representing the path + + Returns: + The value if found, None otherwise + """ + current = data + + for part in parts: + if isinstance(current, dict) and part in current: + current = current[part] + else: + return None + + return current + + +def _load_yaml_files_recursively(directory: Path, skip_files: set = None) -> Dict: + """Load YAML files recursively from a directory with nested structure + + Args: + directory: Directory to load files from + skip_files: Optional set of filenames to skip + + Returns: + Dict of loaded configurations with nested structure + """ + configs = {} + skip_files = skip_files or set() + + for config_file in directory.rglob("*.yaml"): + if config_file.name in skip_files: + continue + + try: + with open(config_file) as f: + # Get relative path from directory for hierarchical keys + rel_path = config_file.relative_to(directory) + parent_dirs = list(rel_path.parent.parts) + + # Load the YAML content + content = yaml.safe_load(f) + + # If the file is in a subdirectory, create nested structure + if parent_dirs and parent_dirs[0] != ".": + # Start with the file's stem as the deepest key + current_level = {config_file.stem: content} + + # Work backwards through parent directories to build nested dict + for parent in reversed(parent_dirs): + current_level = {parent: current_level} + + # Merge with existing configs + _deep_merge(configs, current_level) + else: + # Top-level file, just use the stem as key + configs[config_file.stem] = content + + log.debug(f"Loaded configuration file: {config_file}") + except Exception as e: + log.error(f"Failed to load configuration file {config_file}: {str(e)}") + + return configs + + +class ValidationLevel: + """Validation levels for configuration""" + + STRICT = "strict" # Raise exceptions for missing or invalid config + WARN = "warn" # Log warnings but continue + IGNORE = "ignore" # Skip validation entirely + + +class ConfigManager: + """Manages loading and accessing configuration files for the HealthChain project + + The ConfigManager handles loading configuration from multiple sources with a defined + precedence order: + + 1. Default configuration (lowest precedence) + 2. Environment-specific configuration (medium precedence) + 3. Module-specific configuration (higher precedence) + 4. Runtime overrides (highest precedence) + + Configuration can be accessed using dot notation paths, and runtime overrides + can be set programmatically. The manager supports different validation levels + to control how configuration errors are handled. + """ + + def __init__( + self, + config_dir: Path, + validation_level: str = ValidationLevel.STRICT, + module: Optional[str] = None, + ): + """Initialize the ConfigManager + + Args: + config_dir: Base directory containing configuration files + validation_level: Level of validation to perform + module: Optional module name to load specific configs for + """ + self.config_dir = config_dir + self._module = module + self._validation_level = validation_level + self._defaults = {} + self._env_configs = {} + self._module_configs = {} + self._mappings = {} + self._loaded = False + self._environment = self._detect_environment() + + def _detect_environment(self) -> str: + """Detect the current environment from environment variables + + Returns: + String representing the environment (development, testing, production) + """ + # Check for environment variable + env = os.environ.get("HEALTHCHAIN_ENV", "development").lower() + + # Validate environment + valid_envs = ["development", "testing", "production"] + if env not in valid_envs: + log.warning(f"Invalid environment '{env}', defaulting to 'development'") + env = "development" + + log.info(f"Detected environment: {env}") + return env + + def load( + self, environment: Optional[str] = None, skip_validation: bool = False + ) -> "ConfigManager": + """Load configuration files in priority order: defaults, environment, module + + This method loads configuration files in the following order: + 1. defaults.yaml - Base configuration defaults + 2. environments/{env}.yaml - Environment-specific configuration + 3. {module}/*.yaml - Module-specific configuration files (if module specified) + + After loading, validates the configuration unless validation is skipped. + + Args: + environment: Optional environment name to override detected environment + skip_validation: Skip validation (useful when subclasses handle validation) + + Returns: + Self for method chaining + + Raises: + ValidationError: If validation fails and validation_level is STRICT + """ + if environment: + self._environment = environment + + self._load_defaults() + self._load_environment_config() + + if self._module: + self._load_module_configs(self._module) + + self._loaded = True + + if not skip_validation and self._validation_level != ValidationLevel.IGNORE: + self.validate() + + return self + + def _load_defaults(self) -> None: + """Load the defaults.yaml file if it exists""" + defaults_file = self.config_dir / "defaults.yaml" + if defaults_file.exists(): + try: + with open(defaults_file) as f: + self._defaults = yaml.safe_load(f) + log.debug(f"Loaded defaults from {defaults_file}") + except Exception as e: + log.error(f"Failed to load defaults file {defaults_file}: {str(e)}") + self._defaults = {} + else: + log.warning(f"Defaults file not found: {defaults_file}") + self._defaults = {} + + def _load_environment_config(self) -> None: + """Load environment-specific configuration file""" + env_file = self.config_dir / "environments" / f"{self._environment}.yaml" + if env_file.exists(): + try: + with open(env_file) as f: + self._env_configs = yaml.safe_load(f) + log.debug(f"Loaded environment configuration from {env_file}") + except Exception as e: + log.error(f"Failed to load environment file {env_file}: {str(e)}") + self._env_configs = {} + else: + log.warning(f"Environment file not found: {env_file}") + self._env_configs = {} + + def _load_module_configs(self, module: str) -> None: + """Load module-specific configurations + + Args: + module: Module name to load configs for + """ + module_dir = self.config_dir / module + + if not module_dir.exists() or not module_dir.is_dir(): + log.warning(f"Module config directory not found: {module_dir}") + self._module_configs[module] = {} + return + + self._module_configs[module] = _load_yaml_files_recursively(module_dir) + log.debug( + f"Loaded {len(self._module_configs[module])} configurations for module {module}: {self._module_configs[module]}" + ) + + def _load_mappings(self) -> Dict: + """Load mappings from the mapping directory + + Returns: + Dict of mappings + """ + if not self._mappings: + mappings_dir = self.config_dir / "mappings" + + if not mappings_dir.exists(): + log.warning(f"Mappings directory not found: {mappings_dir}") + self._mappings = {} + return self._mappings + + # Use the existing recursive loader that already handles directory structures + self._mappings = _load_yaml_files_recursively(mappings_dir) + + # Log summary information + # Only consider directories as folders, not top-level yaml files + folders = [] + for dir_path in mappings_dir.iterdir(): + if dir_path.is_dir() and dir_path.name in self._mappings: + folders.append(dir_path.name) + + total_files = sum(1 for _ in mappings_dir.rglob("*.yaml")) + + # Log summary of loaded mappings + log.info( + f"Loaded {total_files} mapping files from {mappings_dir}: {', '.join(folders)}" + ) + log.debug(f"Mapping structure: {list(self._mappings.keys())}") + + return self._mappings + + def _find_config_section(self, module_name: str, section_path: str) -> Dict: + """Find a configuration section in the module configs. + + Args: + module_name: Name of the module to search in (e.g. "interop") + section_path: Path to the config section, with segments separated by slashes + (e.g. "sections", "document/ccd", "cda/document/ccd") + + Returns: + Configuration dict or empty dict if not found + """ + # Start with the module config + if module_name not in self._module_configs: + return {} + + config = self._module_configs[module_name] + + # Empty path returns the whole module config + if not section_path: + return config + + # Split path into segments and navigate + path_segments = section_path.split("/") + + # Navigate through the config structure + current_config = config + + for segment in path_segments: + if segment in current_config and isinstance(current_config[segment], dict): + current_config = current_config[segment] + else: + # Path segment not found or not a dict + if len(path_segments) > 1: + log.warning(f"Config section not found: {section_path}") + return {} + + return current_config + + def get_mappings(self, mapping_key: Optional[str] = None) -> Dict: + """Get all mappings, loading them first if needed + + Args: + mapping_key: Optional key to get a specific mapping subset + + Returns: + Dict of mappings or specific mapping subset if mapping_key is provided + """ + mappings = self._load_mappings() + if mapping_key and mapping_key in mappings: + return mappings[mapping_key] + return mappings + + def get_defaults(self) -> Dict: + """Get all default values + + Returns: + Dict of default values + """ + if not self._loaded: + self.load() + return self._defaults + + def get_environment_configs(self) -> Dict: + """Get environment-specific configuration + + Returns: + Dict of environment-specific configuration + """ + if not self._loaded: + self.load() + return self._env_configs + + def get_environment(self) -> str: + """Get the current environment + + Returns: + String representing the current environment + """ + return self._environment + + def set_environment(self, environment: str) -> "ConfigManager": + """Set the environment and reload environment-specific configuration + + Args: + environment: Environment to set (development, testing, production) + + Returns: + Self for method chaining + """ + self._environment = environment + self._load_environment_config() + return self + + def get_configs(self) -> Dict: + """Get all configuration values merged according to precedence order. + + This method merges configuration values from different sources in a simplified + four-layer precedence order: + + 1. Runtime overrides (highest priority, set via set_config_value) + 2. Module-specific configs (if a module is specified) + 3. Environment-specific configs + 4. Default configs (lowest priority) + + The configurations are deep merged, meaning nested dictionary values are + recursively combined rather than overwritten. + + Returns: + Dict: A merged dictionary containing all configuration values according + to the precedence order. + """ + if not self._loaded: + self.load() + + merged_configs = {} + + # Start with defaults (lowest priority) + _deep_merge(merged_configs, self._defaults) + + # Apply environment-specific configs (middle priority) + _deep_merge(merged_configs, self._env_configs) + + # Apply module-specific configs if a module is specified (high priority) + if self._module and self._module in self._module_configs: + _deep_merge(merged_configs, self._module_configs[self._module]) + + # Apply runtime overrides (highest priority) + if hasattr(self, "_runtime_overrides"): + _deep_merge(merged_configs, self._runtime_overrides) + + return merged_configs + + def set_config_value(self, path: str, value: Any) -> "ConfigManager": + """Set a configuration value using dot notation path + + This method allows setting configuration values at runtime. The value will + override any values from files when get_config_value is called. Values are + stored in a runtime_overrides dictionary that takes precedence over all + other configuration sources. + + Args: + path: Dot notation path (e.g. "defaults.common.id_prefix") + value: The value to set + + Returns: + Self for method chaining + """ + # TODO: validate path + if not hasattr(self, "_runtime_overrides"): + self._runtime_overrides = {} + + # Split the path into parts + parts = path.split(".") + + # Navigate to the correct nested dictionary + current = self._runtime_overrides + for part in parts[:-1]: + if part not in current: + current[part] = {} + current = current[part] + + # Set the value + current[parts[-1]] = value + + log.debug(f"Set runtime config override: {path} = {value}") + return self + + def get_config_value(self, path: str, default: Any = None) -> Any: + """Get a configuration value using dot notation path + + Args: + path: Dot notation path + default: Default value if path not found + + Returns: + Configuration value or default + """ + if not self._loaded: + self.load() + + # Split the path into parts + parts = path.split(".") + + # Create merged configs with proper precedence + configs = self.get_configs() + + # Get the value from merged configs + value = _get_nested_value(configs, parts) + if value is not None: + return value + + # Return the provided default if not found + return default + + def validate(self) -> bool: + """Validate that all required configurations are present""" + # TODO: Implement validation + return True + + def set_validation_level(self, level: str) -> "ConfigManager": + """Set the validation level + + Args: + level: Validation level (strict, warn, ignore) + + Returns: + Self for method chaining + """ + if level not in ( + ValidationLevel.STRICT, + ValidationLevel.WARN, + ValidationLevel.IGNORE, + ): + raise ValueError(f"Invalid validation level: {level}") + + self._validation_level = level + return self + + def get_validation_level(self) -> str: + """Get the current validation level + + Returns: + String representing the current validation level + """ + return self._validation_level + + def _handle_validation_error(self, message: str) -> bool: + """Handle validation error based on validation level + + Args: + message: Error message + + Returns: + False for WARN mode with validation errors or STRICT mode (though STRICT raises), + True only for IGNORE mode + """ + if self._validation_level == ValidationLevel.STRICT: + raise ValueError(message) + elif self._validation_level == ValidationLevel.WARN: + log.warning(f"Configuration validation: {message}") + return False # Return False for WARN mode with errors + + return True # Return True only for IGNORE mode diff --git a/healthchain/config/validators.py b/healthchain/config/validators.py new file mode 100644 index 00000000..3940614d --- /dev/null +++ b/healthchain/config/validators.py @@ -0,0 +1,322 @@ +""" +Configuration validators for HealthChain + +This module provides validation models and utilities for configuration files. +""" + +import logging +from pydantic import BaseModel, ValidationError, field_validator, ConfigDict +from typing import Dict, List, Any, Optional, Type, Union + +logger = logging.getLogger(__name__) + +# +# Base Models +# + + +class ComponentTemplateConfig(BaseModel): + """Generic template for CDA/FHIR component configuration""" + + template_id: Union[List[str], str] + code: Optional[str] = None + code_system: Optional[str] = "2.16.840.1.113883.6.1" + code_system_name: Optional[str] = "LOINC" + display_name: Optional[str] = None + status_code: Optional[str] = "active" + class_code: Optional[str] = None + mood_code: Optional[str] = None + type_code: Optional[str] = None + inversion_ind: Optional[bool] = None + value: Optional[Dict[str, Any]] = None + + model_config = ConfigDict(extra="allow") + + +class SectionIdentifiersConfig(BaseModel): + """Section identifiers validation""" + + template_id: str + code: str + code_system: Optional[str] = "2.16.840.1.113883.6.1" + code_system_name: Optional[str] = "LOINC" + display: str + clinical_status: Optional[Dict[str, str]] = None + reaction: Optional[Dict[str, str]] = None + severity: Optional[Dict[str, str]] = None + + model_config = ConfigDict(extra="allow") + + +class RenderingConfig(BaseModel): + """Configuration for section rendering""" + + narrative: Optional[Dict[str, Any]] = None + entry: Optional[Dict[str, Any]] = None + + model_config = ConfigDict(extra="allow") + + +class SectionBaseConfig(BaseModel): + """Base model for all section configurations""" + + resource: str + resource_template: str + entry_template: str + identifiers: SectionIdentifiersConfig + rendering: Optional[RenderingConfig] = None + + model_config = ConfigDict(extra="allow") + + +# +# Resource-Specific Template Models +# + + +class SectionTemplateConfigBase(BaseModel): + """Base class for section template configurations""" + + def validate_component_fields(self, component, required_fields): + """Helper method to validate required fields in a component""" + missing = required_fields - set(component.model_dump(exclude_unset=True).keys()) + if missing: + raise ValueError( + f"{component.__class__.__name__} missing required fields: {missing}" + ) + return component + + +class ProblemSectionTemplateConfig(SectionTemplateConfigBase): + """Template configuration for Problem Section""" + + act: ComponentTemplateConfig + problem_obs: ComponentTemplateConfig + clinical_status_obs: ComponentTemplateConfig + + @field_validator("problem_obs") + @classmethod + def validate_problem_obs(cls, v): + required_fields = {"code", "code_system", "status_code"} + missing = required_fields - set(v.model_dump(exclude_unset=True).keys()) + if missing: + raise ValueError(f"problem_obs missing required fields: {missing}") + return v + + @field_validator("clinical_status_obs") + @classmethod + def validate_clinical_status(cls, v): + required_fields = {"code", "code_system", "status_code"} + missing = required_fields - set(v.model_dump(exclude_unset=True).keys()) + if missing: + raise ValueError(f"clinical_status_obs missing required fields: {missing}") + return v + + +class MedicationSectionTemplateConfig(SectionTemplateConfigBase): + """Template configuration for SubstanceAdministration Section""" + + substance_admin: ComponentTemplateConfig + manufactured_product: ComponentTemplateConfig + clinical_status_obs: ComponentTemplateConfig + + @field_validator("substance_admin") + @classmethod + def validate_substance_admin(cls, v): + if not v.status_code: + raise ValueError("substance_admin requires status_code") + return v + + +class AllergySectionTemplateConfig(SectionTemplateConfigBase): + """Template configuration for Allergy Section""" + + act: ComponentTemplateConfig + allergy_obs: ComponentTemplateConfig + reaction_obs: Optional[ComponentTemplateConfig] = None + severity_obs: Optional[ComponentTemplateConfig] = None + clinical_status_obs: ComponentTemplateConfig + + @field_validator("allergy_obs") + @classmethod + def validate_allergy_obs(cls, v): + required_fields = {"code", "code_system", "status_code"} + missing = required_fields - set(v.model_dump(exclude_unset=True).keys()) + if missing: + raise ValueError(f"allergy_obs missing required fields: {missing}") + return v + + +class DocumentConfigBase(BaseModel): + """Generic document configuration model""" + + type_id: Dict[str, Any] + code: Dict[str, Any] + confidentiality_code: Dict[str, Any] + language_code: Optional[str] = "en-US" + templates: Optional[Dict[str, Any]] = None + structure: Optional[Dict[str, Any]] = None + defaults: Optional[Dict[str, Any]] = None + rendering: Optional[Dict[str, Any]] = None + + @field_validator("type_id") + @classmethod + def validate_type_id(cls, v): + if not isinstance(v, dict) or "root" not in v: + raise ValueError("type_id must contain 'root' field") + return v + + @field_validator("code") + @classmethod + def validate_code(cls, v): + if not isinstance(v, dict) or "code" not in v or "code_system" not in v: + raise ValueError("code must contain 'code' and 'code_system' fields") + return v + + @field_validator("confidentiality_code") + @classmethod + def validate_confidentiality_code(cls, v): + if not isinstance(v, dict) or "code" not in v: + raise ValueError("confidentiality_code must contain 'code' field") + return v + + @field_validator("templates") + @classmethod + def validate_templates(cls, v): + if not isinstance(v, dict) or "section" not in v or "document" not in v: + raise ValueError("templates must contain 'section' and 'document' fields") + return v + + model_config = ConfigDict(extra="allow") + + +class CcdDocumentConfig(DocumentConfigBase): + """Configuration model specific to CCD documents""" + + allowed_sections: List[str] = ["problems", "medications", "allergies", "notes"] + + +class NoteSectionTemplateConfig(SectionTemplateConfigBase): + """Template configuration for Notes Section""" + + note_section: ComponentTemplateConfig + + @field_validator("note_section") + @classmethod + def validate_note_section(cls, v): + required_fields = {"template_id", "code", "code_system", "status_code"} + missing = required_fields - set(v.model_dump(exclude_unset=True).keys()) + if missing: + raise ValueError(f"note_section missing required fields: {missing}") + return v + + +# +# Registries and Factory Functions +# + +CDA_SECTION_CONFIG_REGISTRY = { + "Condition": ProblemSectionTemplateConfig, + "MedicationStatement": MedicationSectionTemplateConfig, + "AllergyIntolerance": AllergySectionTemplateConfig, + "DocumentReference": NoteSectionTemplateConfig, +} + +CDA_DOCUMENT_CONFIG_REGISTRY = { + "ccd": CcdDocumentConfig, +} + + +def create_cda_section_validator( + resource_type: str, template_model: Type[BaseModel] +) -> Type[BaseModel]: + """Create a section validator for a specific resource type""" + + class DynamicSectionConfig(SectionBaseConfig): + template: Dict[str, Any] + + @field_validator("template") + @classmethod + def validate_template(cls, v): + try: + template_model(**v) + except ValidationError as e: + raise ValueError(f"Template validation failed: {str(e)}") + return v + + DynamicSectionConfig.__name__ = f"{resource_type}SectionConfig" + return DynamicSectionConfig + + +SECTION_VALIDATORS = { + resource_type: create_cda_section_validator(resource_type, template_model) + for resource_type, template_model in CDA_SECTION_CONFIG_REGISTRY.items() +} + +# +# Validation Functions +# + + +def validate_cda_section_config_model( + section_key: str, section_config: Dict[str, Any] +) -> bool: + """Validate a section configuration""" + resource_type = section_config.get("resource") + if not resource_type: + logger.error(f"Section '{section_key}' is missing 'resource' field") + return False + + validator = SECTION_VALIDATORS.get(resource_type) + if not validator: + # TODO: Pass validation level to this + logger.warning(f"No specific validator for resource type: {resource_type}") + return True + + try: + validator(**section_config) + return True + except ValidationError as e: + logger.error(f"Section validation failed for {resource_type}: {str(e)}") + return False + + +def validate_cda_document_config_model( + document_type: str, document_config: Dict[str, Any] +) -> bool: + """Validate a document configuration""" + validator = CDA_DOCUMENT_CONFIG_REGISTRY.get(document_type.lower()) + if not validator: + logger.warning(f"No specific validator for document type: {document_type}") + return True + + try: + validator(**document_config) + return True + except ValidationError as e: + logger.error(f"Document validation failed for {document_type}: {str(e)}") + return False + + +# +# Registration Functions +# + + +def register_cda_section_template_config_model( + resource_type: str, template_model: Type[BaseModel] +) -> None: + """Register a custom template model for a section""" + CDA_SECTION_CONFIG_REGISTRY[resource_type] = template_model + SECTION_VALIDATORS[resource_type] = create_cda_section_validator( + resource_type, template_model + ) + logger.info(f"Registered custom template model for {resource_type}") + + +def register_cda_document_template_config_model( + document_type: str, document_model: Type[BaseModel] +) -> None: + """Register a custom document model""" + CDA_DOCUMENT_CONFIG_REGISTRY[document_type.lower()] = document_model + logger.info(f"Registered custom document model for {document_type}") diff --git a/healthchain/fhir/__init__.py b/healthchain/fhir/__init__.py index fbb2dbc0..82e68a2e 100644 --- a/healthchain/fhir/__init__.py +++ b/healthchain/fhir/__init__.py @@ -10,6 +10,7 @@ read_content_attachment, create_document_reference, create_single_attachment, + create_resource_from_dict, ) from healthchain.fhir.bundle_helpers import ( @@ -30,6 +31,7 @@ "read_content_attachment", "create_document_reference", "create_single_attachment", + "create_resource_from_dict", # Bundle operations "create_bundle", "add_resource", diff --git a/healthchain/fhir/helpers.py b/healthchain/fhir/helpers.py index 70f3f0d8..087e4e67 100644 --- a/healthchain/fhir/helpers.py +++ b/healthchain/fhir/helpers.py @@ -4,6 +4,7 @@ import base64 import datetime import uuid +import importlib from typing import Optional, List, Dict, Any from fhir.resources.condition import Condition @@ -14,7 +15,7 @@ from fhir.resources.codeablereference import CodeableReference from fhir.resources.coding import Coding from fhir.resources.attachment import Attachment - +from fhir.resources.resource import Resource logger = logging.getLogger(__name__) @@ -28,6 +29,29 @@ def _generate_id() -> str: return f"hc-{str(uuid.uuid4())}" +def create_resource_from_dict( + resource_dict: Dict, resource_type: str +) -> Optional[Resource]: + """Create a FHIR resource instance from a dictionary + + Args: + resource_dict: Dictionary representation of the resource + resource_type: Type of FHIR resource to create + + Returns: + Optional[Resource]: FHIR resource instance or None if creation failed + """ + try: + resource_module = importlib.import_module( + f"fhir.resources.{resource_type.lower()}" + ) + resource_class = getattr(resource_module, resource_type) + return resource_class(**resource_dict) + except Exception as e: + logger.error(f"Failed to create FHIR resource: {str(e)}") + return None + + def create_single_codeable_concept( code: str, display: Optional[str] = None, diff --git a/healthchain/interop/__init__.py b/healthchain/interop/__init__.py new file mode 100644 index 00000000..1ac2df07 --- /dev/null +++ b/healthchain/interop/__init__.py @@ -0,0 +1,71 @@ +""" +HealthChain Interoperability Module + +This package provides modules for handling interoperability between +healthcare data formats. +""" + +from .config_manager import InteropConfigManager +from .engine import InteropEngine +from .types import FormatType, validate_format +from .template_registry import TemplateRegistry +from .parsers.cda import CDAParser +from .generators.cda import CDAGenerator +from .generators.fhir import FHIRGenerator + +import logging +from pathlib import Path +from typing import Optional + + +def create_engine( + config_dir: Optional[Path] = None, + validation_level: str = "strict", + environment: str = "development", +) -> InteropEngine: + """Create and initialize an InteropEngine instance + + Creates a configured InteropEngine for converting between healthcare data formats. + + Args: + config_dir: Base directory containing configuration files. If None, defaults to "configs" + validation_level: Level of configuration validation ("strict", "warn", "ignore") + environment: Configuration environment to use ("development", "testing", "production") + + Returns: + Initialized InteropEngine + + Raises: + ValueError: If config_dir doesn't exist or if validation_level/environment has invalid values + """ + logger = logging.getLogger(__name__) + if config_dir is None: + logger.warning("config_dir is not provided, looking for configs in /configs") + config_dir = Path("configs") + if not config_dir.exists(): + raise ValueError("config_dir does not exist") + + # TODO: Remove this once we have a proper environment system + if environment not in ["development", "testing", "production"]: + raise ValueError("environment must be one of: development, testing, production") + + engine = InteropEngine(config_dir, validation_level, environment) + + return engine + + +__all__ = [ + # Core classes + "InteropEngine", + "InteropConfigManager", + "TemplateRegistry", + # Types and utils + "FormatType", + "validate_format", + # Parsers + "CDAParser", + # Generators + "CDAGenerator", + "FHIRGenerator", + "create_engine", +] diff --git a/healthchain/interop/config_manager.py b/healthchain/interop/config_manager.py new file mode 100644 index 00000000..8f0aba0a --- /dev/null +++ b/healthchain/interop/config_manager.py @@ -0,0 +1,264 @@ +""" +InteropConfigManager for HealthChain Interoperability Engine + +This module provides specialized configuration management for interoperability. +""" + +import logging +from typing import Dict, Optional, List, Type + +from pydantic import BaseModel + +from healthchain.config.base import ConfigManager, ValidationLevel +from healthchain.config.validators import ( + register_cda_document_template_config_model, + register_cda_section_template_config_model, + validate_cda_document_config_model, + validate_cda_section_config_model, +) + +log = logging.getLogger(__name__) + + +class InteropConfigManager(ConfigManager): + """Specialized configuration manager for the interoperability module + + Extends ConfigManager to handle CDA document and section template configurations. + Provides functionality for: + + - Loading and validating interop configurations + - Managing document and section templates + - Registering custom validation models + + Configuration structure: + - Document templates (under "document") + - Section templates (under "sections") + - Default values and settings + + Validation levels: + - STRICT: Full validation (default) + - WARN: Warning-only + - IGNORE: No validation + """ + + def __init__( + self, + config_dir, + validation_level: str = ValidationLevel.STRICT, + environment: Optional[str] = None, + ): + """Initialize the InteropConfigManager. + + Initializes the configuration manager with the interop module and validates + the configuration. The interop module configuration must exist in the + specified config directory. + + Args: + config_dir: Base directory containing configuration files + validation_level: Level of validation to perform. Default is STRICT. + Can be STRICT, WARN, or IGNORE. + environment: Optional environment name to load environment-specific configs. + If provided, will load and merge environment-specific configuration. + + Raises: + ValueError: If the interop module configuration is not found in config_dir. + """ + # Initialize with "interop" as the fixed module + super().__init__(config_dir, validation_level, module="interop") + self.load(environment, skip_validation=True) + + if "interop" not in self._module_configs: + raise ValueError( + f"Interop module not found in configuration directory {config_dir}" + ) + + self.validate() + + def _find_cda_document_types(self) -> List[str]: + """Find available CDA document types in the configs + + Returns: + List of CDA document type strings + """ + # Get document types from cda/document path + doc_section = self._find_config_section( + module_name="interop", section_path="cda/document" + ) + + # If no document section exists, return empty list + if not doc_section: + return [] + + # Return the keys from the document section + return list(doc_section.keys()) + + def get_cda_section_configs(self, section_key: Optional[str] = None) -> Dict: + """Get CDA section configuration(s). + + Retrieves section configurations from the loaded configs. When section_key is provided, + retrieves configuration for a specific section; otherwise, returns all section configurations. + Section configurations define how different CDA sections should be processed and mapped to + FHIR resources. + + Args: + section_key: Optional section identifier (e.g., "problems", "medications"). + If provided, returns only that specific section's configuration. + + Returns: + Dict: Dictionary mapping section keys to their configurations if section_key is None. + Single section configuration dict if section_key is provided. + + Raises: + ValueError: If section_key is provided but not found in configurations + or if no sections are configured + """ + # Get all sections + sections = self._find_config_section( + module_name="interop", section_path="cda/sections" + ) + + if not sections: + raise ValueError("No CDA section configurations found") + + # If section_key is provided, return just that section + if section_key is not None: + if section_key not in sections: + raise ValueError(f"Section configuration not found: {section_key}") + + # Basic validation that required fields exist + section_config = sections[section_key] + if "resource" not in section_config: + raise ValueError( + f"Invalid section configuration for {section_key}: missing 'resource' field" + ) + + return section_config + + return sections + + def get_cda_document_config(self, document_type: str) -> Dict: + """Get CDA document configuration for a specific document type. + + Retrieves the configuration for a CDA document type from the loaded configs. + The configuration contains template settings and other document-specific parameters. + + Args: + document_type: Type of document (e.g., "ccd", "discharge") to get config for + + Returns: + Dict containing the document configuration + + Raises: + ValueError: If document_type is not found or the configuration is invalid + """ + document_config = self._find_config_section( + module_name="interop", section_path=f"cda/document/{document_type}" + ) + + if not document_config: + raise ValueError( + f"Document configuration not found for type: {document_type}" + ) + + # Basic validation that required sections exist + if "templates" not in document_config: + raise ValueError( + f"Invalid document configuration for {document_type}: missing 'templates' section" + ) + + # Return the validated config + return document_config + + def validate(self) -> bool: + """Validate that all required configurations are present for the interop module. + + Validates both section and document configurations according to their registered + validation models. Section configs are required and will cause validation to fail + if missing or invalid. Document configs are optional but will be validated if present. + + The validation behavior depends on the validation_level setting: + - IGNORE: Always returns True without validating + - WARN: Logs warnings for validation failures but returns True + - ERROR: Returns False if any validation fails + + Returns: + bool: True if validation passes or is ignored, False if validation fails + when validation_level is ERROR + """ + if self._validation_level == ValidationLevel.IGNORE: + return True + + is_valid = super().validate() + + # Validate section configs + try: + section_configs = self._find_config_section( + module_name="interop", section_path="cda/sections" + ) + if not section_configs: + is_valid = self._handle_validation_error("No section configs found") + else: + # Validate each section config + for section_key, section_config in section_configs.items(): + result = validate_cda_section_config_model( + section_key, section_config + ) + if not result: + is_valid = self._handle_validation_error( + f"Section config validation failed for key: {section_key}" + ) + except Exception as e: + is_valid = self._handle_validation_error( + f"Error validating section configs: {str(e)}" + ) + + # Validate document configs - but don't fail if no documents are configured + # since some use cases might not require documents + document_types = self._find_cda_document_types() + for doc_type in document_types: + try: + doc_config = self._find_config_section( + module_name="interop", section_path=f"cda/document/{doc_type}" + ) + if doc_config: + result = validate_cda_document_config_model(doc_type, doc_config) + if not result: + is_valid = self._handle_validation_error( + f"Document config validation failed for type: {doc_type}" + ) + except Exception as e: + is_valid = self._handle_validation_error( + f"Error validating document config for {doc_type}: {str(e)}" + ) + + return is_valid + + def register_cda_section_config( + self, resource_type: str, config_model: Type[BaseModel] + ) -> None: + """Register a validation model for a CDA section configuration. + + Registers a Pydantic model that will be used to validate configuration for a CDA section + that maps to a specific FHIR resource type. The model defines the required and optional + fields that should be present in the section configuration. + + Args: + resource_type: FHIR resource type that the section maps to (e.g. "Condition") + config_model: Pydantic model class that defines the validation schema for the section config + """ + register_cda_section_template_config_model(resource_type, config_model) + + def register_cda_document_config( + self, document_type: str, config_model: Type[BaseModel] + ) -> None: + """Register a validation model for a CDA document configuration. + + Registers a Pydantic model that will be used to validate configuration for a CDA document + type. The model defines the required and optional fields that should be present in the + document configuration. + + Args: + document_type: Document type identifier (e.g., "ccd", "discharge") + config_model: Pydantic model class that defines the validation schema for the document config + """ + register_cda_document_template_config_model(document_type, config_model) diff --git a/healthchain/interop/engine.py b/healthchain/interop/engine.py new file mode 100644 index 00000000..ddf71b95 --- /dev/null +++ b/healthchain/interop/engine.py @@ -0,0 +1,423 @@ +import logging + +from functools import cached_property +from typing import List, Union, Optional +from pathlib import Path + +from fhir.resources.resource import Resource +from fhir.resources.bundle import Bundle +from pydantic import BaseModel + +from healthchain.config.base import ValidationLevel +from healthchain.interop.config_manager import InteropConfigManager +from healthchain.interop.types import FormatType, validate_format + +from healthchain.interop.parsers.cda import CDAParser +from healthchain.interop.template_registry import TemplateRegistry +from healthchain.interop.generators.cda import CDAGenerator +from healthchain.interop.generators.fhir import FHIRGenerator +from healthchain.interop.filters import create_default_filters + +log = logging.getLogger(__name__) + + +def normalize_resource_list( + resources: Union[Resource, List[Resource], Bundle], +) -> List[Resource]: + """Convert input resources to a normalized list format""" + if isinstance(resources, Bundle): + return [entry.resource for entry in resources.entry if entry.resource] + elif isinstance(resources, list): + return resources + else: + return [resources] + + +class InteropEngine: + """Generic interoperability engine for converting between healthcare formats + + The InteropEngine provides capabilities for converting between different + healthcare data format standards, such as HL7 FHIR, CDA, and HL7v2. + + The engine uses a template-based approach for transformations, with templates + stored in the configured template directory. Transformations are handled by + format-specific parsers and generators that are lazily loaded as needed. + + Configuration is handled through the `config` property, which provides + direct access to the underlying ConfigManager instance. This allows + for setting validation levels, changing environments, and accessing + configuration values. + + The engine supports registering custom parsers, generators, and validators + to extend or override the default functionality. + + Example: + engine = InteropEngine() + + # Convert CDA to FHIR + fhir_resources = engine.to_fhir(cda_xml, src_format="cda") + + # Convert FHIR to CDA + cda_xml = engine.from_fhir(fhir_resources, dest_format="cda") + + # Access config directly: + engine.config.set_environment("production") + engine.config.set_validation_level("warn") + value = engine.config.get_config_value("cda.sections.problems.resource") + + # Access the template registry: + template = engine.template_registry.get_template("cda_fhir/condition") + engine.template_registry.add_filter() + + # Register custom components: + engine.register_parser(FormatType.CDA, custom_parser) + engine.register_generator(FormatType.FHIR, custom_generator) + + # Register custom configuration validators: + engine.register_cda_section_config_validator("Procedure", ProcedureSectionConfig) + engine.register_cda_document_config_validator("CCD", CCDDocumentConfig) + """ + + def __init__( + self, + config_dir: Optional[Path] = None, + validation_level: str = ValidationLevel.STRICT, + environment: Optional[str] = None, + ): + """Initialize the InteropEngine + + Args: + config_dir: Base directory containing configuration files. If None, will search standard locations. + validation_level: Level of configuration validation (strict, warn, ignore) + environment: Optional environment to use (development, testing, production) + """ + # Initialize configuration manager + self.config = InteropConfigManager(config_dir, validation_level, environment) + + # Initialize template registry + template_dir = config_dir / "templates" + self.template_registry = TemplateRegistry(template_dir) + + # Create and register default filters + # Get required configuration for filters + mappings_dir = self.config.get_config_value("defaults.mappings_dir") + if not mappings_dir: + log.warning("No mappings directory configured, using default mappings") + mappings_dir = "cda_default" + mappings = self.config.get_mappings(mappings_dir) + id_prefix = self.config.get_config_value("defaults.common.id_prefix") + + # Get default filters from the filters module + default_filters = create_default_filters(mappings, id_prefix) + self.template_registry.initialize(default_filters) + + # Component registries for lazy loading + self._parsers = {} + self._generators = {} + + # Lazy-loaded parsers + @cached_property + def cda_parser(self): + """Lazily load the CDA parser""" + return self._get_parser(FormatType.CDA) + + @cached_property + def hl7v2_parser(self): + """Lazily load the HL7v2 parser""" + return self._get_parser(FormatType.HL7V2) + + # Lazy-loaded generators + @cached_property + def cda_generator(self): + """Lazily load the CDA generator""" + return self._get_generator(FormatType.CDA) + + @cached_property + def fhir_generator(self): + """Lazily load the FHIR generator""" + return self._get_generator(FormatType.FHIR) + + @cached_property + def hl7v2_generator(self): + """Lazily load the HL7v2 generator""" + return self._get_generator(FormatType.HL7V2) + + def _get_parser(self, format_type: FormatType): + """Get or create a parser for the specified format + + Args: + format_type: The format type to get a parser for (CDA or HL7v2) + + Returns: + The parser instance for the specified format + + Raises: + ValueError: If an unsupported format type is provided + """ + if format_type not in self._parsers: + if format_type == FormatType.CDA: + parser = CDAParser(self.config) + self._parsers[format_type] = parser + elif format_type == FormatType.HL7V2: + raise NotImplementedError("HL7v2 parser not implemented") + else: + raise ValueError(f"Unsupported parser format: {format_type}") + + return self._parsers[format_type] + + def _get_generator(self, format_type: FormatType): + """Get or create a generator for the specified format + + Args: + format_type: The format type to get a generator for (CDA, HL7v2, or FHIR) + + Returns: + The generator instance for the specified format + + Raises: + ValueError: If an unsupported format type is provided + """ + if format_type not in self._generators: + if format_type == FormatType.CDA: + generator = CDAGenerator(self.config, self.template_registry) + self._generators[format_type] = generator + elif format_type == FormatType.HL7V2: + raise NotImplementedError("HL7v2 generator not implemented") + elif format_type == FormatType.FHIR: + generator = FHIRGenerator(self.config, self.template_registry) + self._generators[format_type] = generator + else: + raise ValueError(f"Unsupported generator format: {format_type}") + + return self._generators[format_type] + + def register_parser(self, format_type: FormatType, parser_instance): + """Register a custom parser for a format type. This will replace the default parser for the format type. + + Args: + format_type: The format type (CDA, HL7v2) to register the parser for + parser_instance: The parser instance that implements the parsing logic + + Returns: + InteropEngine: Returns self for method chaining + + Example: + engine.register_parser(FormatType.CDA, CustomCDAParser()) + """ + self._parsers[format_type] = parser_instance + return self + + def register_generator(self, format_type: FormatType, generator_instance): + """Register a custom generator for a format type. This will replace the default generator for the format type. + + Args: + format_type: The format type (CDA, HL7v2, FHIR) to register the generator for + generator_instance: The generator instance that implements the generation logic + + Returns: + InteropEngine: Returns self for method chaining + + Example: + engine.register_generator(FormatType.CDA, CustomCDAGenerator()) + """ + self._generators[format_type] = generator_instance + return self + + # TODO: make the config validator functions more generic + def register_cda_section_config_validator( + self, resource_type: str, template_model: BaseModel + ) -> "InteropEngine": + """Register a custom section config validator model for a resource type + + Args: + resource_type: FHIR resource type (e.g., "Condition", "MedicationStatement") which converts to the CDA section + template_model: Pydantic model for CDA section config validation + + Returns: + Self for method chaining + + Example: + # Register a config validator for the Problem section, which is converted from the Condition resource + engine.register_cda_section_config_validator( + "Condition", ProblemSectionConfig + ) + """ + self.config.register_cda_section_config(resource_type, template_model) + return self + + def register_cda_document_config_validator( + self, document_type: str, document_model: BaseModel + ) -> "InteropEngine": + """Register a custom document validator model for a document type + + Args: + document_type: Document type (e.g., "ccd", "discharge") + document_model: Pydantic model for document validation + + Returns: + Self for method chaining + + Example: + # Register a config validator for the CCD document type + engine.register_cda_document_validator( + "ccd", CCDDocumentConfig + ) + """ + self.config.register_cda_document_config(document_type, document_model) + return self + + def to_fhir( + self, src_data: str, src_format: Union[str, FormatType] + ) -> List[Resource]: + """Convert source data to FHIR resources + + Args: + src_data: Input data as string (CDA XML or HL7v2 message) + src_format: Source format type, either as string ("cda", "hl7v2") + or FormatType enum + + Returns: + List[Resource]: List of FHIR resources generated from the source data + + Raises: + ValueError: If src_format is not supported + + Example: + # Convert CDA XML to FHIR resources + fhir_resources = engine.to_fhir(cda_xml, src_format="cda") + """ + src_format = validate_format(src_format) + + if src_format == FormatType.CDA: + return self._cda_to_fhir(src_data) + elif src_format == FormatType.HL7V2: + return self._hl7v2_to_fhir(src_data) + else: + raise ValueError(f"Unsupported format: {src_format}") + + def from_fhir( + self, + resources: Union[List[Resource], Bundle], + dest_format: Union[str, FormatType], + **kwargs, + ) -> str: + """Convert FHIR resources to a target format + + Args: + resources: List of FHIR resources to convert or a FHIR Bundle + dest_format: Destination format type, either as string ("cda", "hl7v2") + or FormatType enum + **kwargs: Additional arguments to pass to generator. + For CDA: document_type (str) - Type of CDA document (e.g. "ccd", "discharge") + + Returns: + str: Converted data as string (CDA XML or HL7v2 message) + + Raises: + ValueError: If dest_format is not supported + + Example: + # Convert FHIR resources to CDA XML + cda_xml = engine.from_fhir(fhir_resources, dest_format="cda") + """ + dest_format = validate_format(dest_format) + resources = normalize_resource_list(resources) + + if dest_format == FormatType.HL7V2: + return self._fhir_to_hl7v2(resources, **kwargs) + elif dest_format == FormatType.CDA: + return self._fhir_to_cda(resources, **kwargs) + else: + raise ValueError(f"Unsupported format: {dest_format}") + + def _cda_to_fhir(self, xml: str, **kwargs) -> List[Resource]: + """Convert CDA XML to FHIR resources + + Args: + xml: CDA document as XML string + **kwargs: Additional arguments to pass to parser and generator. + + Returns: + List[Resource]: List of FHIR resources + + Raises: + ValueError: If required mappings are missing or if sections are unsupported + """ + # Get parser and generator (lazy loaded) + parser = self.cda_parser + generator = self.fhir_generator + + # Parse sections from CDA XML using the parser + section_entries = parser.from_string(xml) + + # Process each section and convert entries to FHIR resources + resources = [] + for section_key, entries in section_entries.items(): + section_resources = generator.transform( + entries, src_format=FormatType.CDA, section_key=section_key + ) + resources.extend(section_resources) + + return resources + + def _fhir_to_cda(self, resources: List[Resource], **kwargs) -> str: + """Convert FHIR resources to CDA XML + + Args: + resources: A list of FHIR resources + **kwargs: Additional arguments to pass to generator. + Supported arguments: + - document_type: Type of CDA document (e.g. "CCD", "Discharge Summary") + + Returns: + str: CDA document as XML string + + Raises: + ValueError: If required mappings are missing or if resource types are unsupported + """ + # Get generators (lazy loaded) + cda_generator = self.cda_generator + + # Check for document type + document_type = kwargs.get("document_type", "ccd") + if document_type: + log.info(f"Processing CDA document of type: {document_type}") + + # Get document configuration for this specific document type + doc_config = self.config.get_cda_document_config(document_type) + if not doc_config: + raise ValueError( + f"Invalid or missing document configuration for type: {document_type}" + ) + + return cda_generator.transform(resources, document_type=document_type) + + def _hl7v2_to_fhir(self, source_data: str) -> List[Resource]: + """Convert HL7v2 to FHIR resources""" + parser = self.hl7v2_parser + generator = self.fhir_generator + + # Parse HL7v2 message using the parser + message_entries = parser.from_string(source_data) + + # Process each message entry and convert to FHIR resources + resources = [] + for message_key, entries in message_entries.items(): + resource_entries = generator.transform( + entries, src_format=FormatType.HL7V2, message_key=message_key + ) + resources.extend(resource_entries) + + return resources + + def _fhir_to_hl7v2(self, resources: List[Resource]) -> str: + """Convert FHIR resources to HL7v2""" + generator = self.hl7v2_generator + + # Process each resource and convert to HL7v2 message + messages = [] + for resource in resources: + message = generator.transform(resource) + messages.append(message) + + return messages diff --git a/healthchain/interop/filters.py b/healthchain/interop/filters.py new file mode 100644 index 00000000..996f0a27 --- /dev/null +++ b/healthchain/interop/filters.py @@ -0,0 +1,570 @@ +import json +import uuid +import base64 +from datetime import datetime +from typing import Dict, Any, Optional, List, Union, Callable + + +def map_system( + system: str, mappings: Dict = None, direction: str = "fhir_to_cda" +) -> Optional[str]: + """Maps between CDA and FHIR code systems + + Args: + system: The code system to map + mappings: Mappings dictionary (if None, returns system unchanged) + direction: Direction of mapping ('fhir_to_cda' or 'cda_to_fhir') + + Returns: + Mapped code system or original if no mapping found + """ + if not system: + return None + + if not mappings: + return system + + # TODO: can refactor + # TODO: can get name from config + # Get systems mapping from the cda_fhir subfolder + systems_mapping = mappings.get("systems", {}) + + if direction == "fhir_to_cda": + # For FHIR to CDA, map the URL to OID + if system in systems_mapping: + return systems_mapping[system].get("oid", system) + else: + # For CDA to FHIR, map OID to URL + # We need to find a system with the given OID + for url, info in systems_mapping.items(): + if info.get("oid") == system: + return url + + return system + + +def map_status( + status: str, mappings: Dict = None, direction: str = "fhir_to_cda" +) -> Optional[str]: + """Maps between CDA and FHIR status codes + + Args: + status: The status code to map + mappings: Mappings dictionary (if None, returns status unchanged) + direction: Direction of mapping ('fhir_to_cda' or 'cda_to_fhir') + + Returns: + Mapped status code or original if no mapping found + """ + if not status: + return None + + if not mappings: + return status + + # Get the status codes mapping from the cda_fhir subfolder + status_codes = mappings.get("status_codes", {}) + + if direction == "fhir_to_cda": + # For FHIR to CDA, get the value directly + if status in status_codes: + return status_codes[status].get("code", status) + else: + # For CDA to FHIR, find FHIR code by CDA value + for fhir_code, info in status_codes.items(): + if info.get("code") == status: + return fhir_code + + return status + + +def map_severity( + severity_code: str, mappings: Dict = None, direction: str = "cda_to_fhir" +) -> Optional[str]: + """Maps between CDA and FHIR severity codes + + Args: + severity_code: The severity code to map + mappings: Mappings dictionary (if None, returns severity code unchanged) + direction: Direction of mapping ('fhir_to_cda' or 'cda_to_fhir') + + Returns: + Mapped severity code or original if no mapping found + """ + if not severity_code: + return None + + if not mappings: + return severity_code + + # Get the severity codes mapping from the cda_fhir subfolder + severity_codes = mappings.get("severity_codes", {}) + + if direction == "fhir_to_cda": + # For FHIR to CDA, get the value directly + if severity_code in severity_codes: + return severity_codes[severity_code].get("code", severity_code) + else: + # For CDA to FHIR, find FHIR code by CDA value + for fhir_code, info in severity_codes.items(): + if info.get("code") == severity_code: + return fhir_code + + return severity_code + + +# TODO: Make this date formatter more complete +def format_date( + date_str: str, input_format: str = "%Y%m%d", output_format: str = "iso" +) -> Optional[str]: + """Formats dates to the specified format + + Args: + date_str: Date string to format + input_format: Input date format (default: "%Y%m%d") + output_format: Output format - "iso" for ISO format or a strftime format string + + Returns: + Formatted date string or None if formatting fails + """ + if not date_str: + return None + + try: + dt = datetime.strptime(date_str, input_format) + if output_format == "iso": + return dt.isoformat() + "Z" # Add UTC timezone indicator + else: + return dt.strftime(output_format) + except (ValueError, TypeError): + return None + + +def format_timestamp(value=None, format_str: str = "%Y%m%d%H%M%S") -> str: + """Format timestamp or use current time + + Args: + value: Datetime object to format (if None, uses current time) + format_str: Format string for strftime + + Returns: + Formatted timestamp string + """ + if value: + return value.strftime(format_str) + return datetime.now().strftime(format_str) + + +def generate_id(value=None, prefix: str = "hc-") -> str: + """Generate UUID or use provided value + + Args: + value: Existing ID to use (if None, generates a new UUID) + prefix: Prefix to add to generated UUID + + Returns: + ID string + """ + return value if value else f"{prefix}{str(uuid.uuid4())}" + + +def to_json(obj: Any) -> str: + """Convert object to JSON string + + Args: + obj: Object to convert to JSON + + Returns: + JSON string representation + """ + if obj is None: + return "[]" + return json.dumps(obj) + + +def extract_effective_period( + effective_times: Union[Dict, List[Dict], None], +) -> Optional[Dict]: + """Extract effective period data from CDA effectiveTime elements + + Processes CDA effectiveTime elements of type IVL_TS to extract start/end dates + for a FHIR effectivePeriod. + + Args: + effective_times: Single effectiveTime element or list of effectiveTime elements + + Returns: + Dictionary with 'start' and/or 'end' fields, or None if no period found + """ + if not effective_times: + return None + + # Ensure we have a list to work with + if not isinstance(effective_times, list): + effective_times = [effective_times] + + # Look for IVL_TS type effective times + for effective_time in effective_times: + if effective_time.get("@xsi:type") == "IVL_TS": + result = {} + + # Extract low value (start date) + low_value = effective_time.get("low", {}).get("@value") + if low_value: + result["start"] = format_date(low_value) + + # Extract high value (end date) + high_value = effective_time.get("high", {}).get("@value") + if high_value: + result["end"] = format_date(high_value) + + # Return the period if we found start or end date + if result: + return result + + # No period found + return None + + +def extract_effective_timing( + effective_times: Union[Dict, List[Dict], None], +) -> Optional[Dict]: + """Extract timing data from CDA effectiveTime elements + + Processes CDA effectiveTime elements of type PIVL_TS to extract frequency/timing + for FHIR dosage.timing. + + Args: + effective_times: Single effectiveTime element or list of effectiveTime elements + + Returns: + Dictionary with 'period' and 'periodUnit' fields, or None if no timing found + """ + if not effective_times: + return None + + # Ensure we have a list to work with + if not isinstance(effective_times, list): + effective_times = [effective_times] + + # Look for PIVL_TS type effective times with period + for effective_time in effective_times: + if effective_time.get("@xsi:type") == "PIVL_TS" and effective_time.get( + "period" + ): + period = effective_time.get("period") + if period and "@value" in period and "@unit" in period: + return { + "period": float(period.get("@value")), + "periodUnit": period.get("@unit"), + } + + # No timing information found + return None + + +def clean_empty(d: Any) -> Any: + """Recursively remove empty strings, empty lists, empty dicts, and None values + + Args: + d: Data structure to clean + + Returns: + Cleaned data structure + """ + if isinstance(d, dict): + return { + k: v + for k, v in ((k, clean_empty(v)) for k, v in d.items()) + if v not in (None, "", {}, []) + } + elif isinstance(d, list): + return [v for v in (clean_empty(v) for v in d) if v not in (None, "", {}, [])] + return d + + +def _ensure_list(value: Any) -> List: + """Convert a value to a list if it isn't already one""" + if not isinstance(value, list): + return [value] + return value + + +def _get_template_ids(section: Dict) -> List[Dict]: + """Get template IDs from a section, ensuring they are in list form""" + if not section.get("templateId"): + return [] + return _ensure_list(section["templateId"]) + + +def _get_entry_relationships(observation: Dict) -> List[Dict]: + """Get entry relationships from an observation, ensuring they are in list form""" + relationships = observation.get("entryRelationship") + if not relationships: + return [] + return _ensure_list(relationships) + + +def extract_clinical_status(observation: Dict, config: Dict) -> Optional[str]: + """Extract clinical status from a CDA allergy entry. + Not sure how to do this in liquid, so doing it here for now. + + Args: + observation: CDA observation containing allergy information + config: Config dictionary + + Returns: + Clinical status code or None if not found + """ + if not observation or not isinstance(observation, dict): + return None + + # Look for clinical status in entry relationships + for rel in _get_entry_relationships(observation): + if not rel.get("observation", {}).get("templateId"): + continue + + # Check each template ID + for template in _get_template_ids(rel["observation"]): + if template.get("@root") == config.get("template", {}).get( + "clinical_status_obs", {} + ).get("template_id"): + if rel.get("observation", {}).get("value", {}).get("@code"): + return rel["observation"]["value"]["@code"] + + return None + + +def extract_reactions(observation: Dict, config: Dict) -> List[Dict]: + """Extract reaction information from a CDA allergy entry + + Args: + observation: CDA observation containing allergy information + config: Config dictionary + + Returns: + List of reaction dictionaries, each with system, code, display, and severity + """ + if not observation or not isinstance(observation, dict): + return [] + + reactions = [] + + # Process each entry relationship + for rel in _get_entry_relationships(observation): + if not rel.get("observation", {}).get("templateId"): + continue + + # Look for reaction template ID + for template in _get_template_ids(rel["observation"]): + if template.get("@root") == config.get("identifiers", {}).get( + "reaction", {} + ).get("template_id"): + # Found a reaction observation + reaction = {} + + # Extract manifestation + if rel.get("observation", {}).get("value"): + value = rel["observation"]["value"] + reaction = { + "system": value.get("@codeSystem"), + "code": value.get("@code"), + "display": value.get("@displayName"), + "severity": None, + } + + # Check for severity in nested entry relationship + for sev in _get_entry_relationships(rel["observation"]): + # Ensure observation and templateId exist + if not sev.get("observation", {}).get("templateId"): + continue + + # Look for severity template ID + for sev_template in _get_template_ids(sev["observation"]): + if sev_template.get("@root") == config.get( + "identifiers", {} + ).get("severity", {}).get("template_id"): + if ( + sev.get("observation", {}) + .get("value", {}) + .get("@code") + ): + reaction["severity"] = sev["observation"]["value"][ + "@code" + ] + break + + if "system" in reaction and "code" in reaction: + reactions.append(reaction) + break + + return reactions + + +def to_base64(text: str) -> str: + """Encodes text to base64 + + Args: + text: The text to encode + + Returns: + Base64 encoded string + """ + if not text: + return "" + text = str(text) + return base64.b64encode(text.encode("utf-8")).decode("utf-8") + + +def from_base64(encoded_text: str) -> str: + """Decodes base64 to text + + Args: + encoded_text: The base64 encoded text to decode + + Returns: + Decoded string + """ + if not encoded_text: + return "" + try: + return base64.b64decode(encoded_text).decode("utf-8") + except Exception: + return encoded_text + + +def xmldict_to_html(xml_dict: Dict) -> str: + """Converts xmltodict format to HTML string + + Args: + xml_dict: Dictionary in xmltodict format + + Returns: + HTML string representation + + Examples: + >>> xmldict_to_html({'paragraph': 'test'}) + 'test' + + >>> xmldict_to_html({'div': {'p': 'Hello', '@class': 'note'}}) + '

Hello

' + """ + if not xml_dict: + return "" # Return empty string for empty dictionary + + if not isinstance(xml_dict, dict): + return str(xml_dict) + + result = [] + + # Process each element in the dictionary + for tag_name, content in xml_dict.items(): + # Skip XML namespace attributes + if tag_name.startswith("@xmlns"): + continue + + # Skip attribute keys as they're handled separately + if tag_name.startswith("@"): + continue + + # Handle text content directly + if tag_name == "#text": + return str(content) + + # Start building the tag + opening_tag = f"<{tag_name}" + + # Add any attributes for this tag + attrs = { + k[1:]: v for k, v in xml_dict.items() if k.startswith("@") and k != "@xmlns" + } + for attr_name, attr_value in attrs.items(): + opening_tag += f' {attr_name}="{attr_value}"' + + opening_tag += ">" + result.append(opening_tag) + + # Process the content based on its type + if isinstance(content, dict): + result.append(xmldict_to_html(content)) + elif isinstance(content, list): + for item in content: + if isinstance(item, dict): + result.append(xmldict_to_html(item)) + else: + result.append(str(item)) + else: + result.append(str(content)) + + # Close the tag + result.append(f"") + + return "".join(result) + + +def create_default_filters(mappings, id_prefix) -> Dict[str, Callable]: + """Create and return default filter functions for templates + + Args: + mappings: Mapping configurations for various transformations + id_prefix: Prefix to use for ID generation + + Returns: + Dict of filter names to filter functions + """ + + # Create filter functions with access to mappings + def map_system_filter(system, direction="fhir_to_cda"): + return map_system(system, mappings, direction) + + def map_status_filter(status, direction="fhir_to_cda"): + return map_status(status, mappings, direction) + + def format_date_filter(date_str, input_format="%Y%m%d", output_format="iso"): + return format_date(date_str, input_format, output_format) + + def format_timestamp_filter(value=None, format_str="%Y%m%d%H%M%S"): + return format_timestamp(value, format_str) + + def generate_id_filter(value=None): + return generate_id(value, id_prefix) + + def json_filter(obj): + return to_json(obj) + + def clean_empty_filter(d): + return clean_empty(d) + + def extract_effective_period_filter(effective_times): + return extract_effective_period(effective_times) + + def extract_effective_timing_filter(effective_times): + return extract_effective_timing(effective_times) + + def extract_clinical_status_filter(entry, config): + return extract_clinical_status(entry, config) + + def extract_reactions_filter(observation, config): + return extract_reactions(observation, config) + + def map_severity_filter(severity_code, direction="cda_to_fhir"): + return map_severity(severity_code, mappings, direction) + + # Return dictionary of filters + return { + "map_system": map_system_filter, + "map_status": map_status_filter, + "format_date": format_date_filter, + "format_timestamp": format_timestamp_filter, + "generate_id": generate_id_filter, + "json": json_filter, + "clean_empty": clean_empty_filter, + "extract_effective_period": extract_effective_period_filter, + "extract_effective_timing": extract_effective_timing_filter, + "extract_clinical_status": extract_clinical_status_filter, + "extract_reactions": extract_reactions_filter, + "map_severity": map_severity_filter, + "to_base64": to_base64, + "from_base64": from_base64, + "xmldict_to_html": xmldict_to_html, + } diff --git a/healthchain/interop/generators/__init__.py b/healthchain/interop/generators/__init__.py new file mode 100644 index 00000000..aa3f18ac --- /dev/null +++ b/healthchain/interop/generators/__init__.py @@ -0,0 +1,15 @@ +""" +Generators for HealthChain Interoperability Engine + +This module provides generators for converting between healthcare data formats. +""" + +from healthchain.interop.generators.base import BaseGenerator +from healthchain.interop.generators.cda import CDAGenerator +from healthchain.interop.generators.fhir import FHIRGenerator + +__all__ = [ + "BaseGenerator", + "CDAGenerator", + "FHIRGenerator", +] diff --git a/healthchain/interop/generators/base.py b/healthchain/interop/generators/base.py new file mode 100644 index 00000000..0a8ae393 --- /dev/null +++ b/healthchain/interop/generators/base.py @@ -0,0 +1,141 @@ +""" +Base Generator for HealthChain Interoperability Engine + +This module provides the abstract base class for all generators. +""" + +import logging +import json +from abc import ABC, abstractmethod +from typing import Dict, Any, Optional + +from liquid import Template + +from healthchain.config.base import ConfigManager +from healthchain.interop.template_registry import TemplateRegistry +from healthchain.interop.filters import clean_empty + +log = logging.getLogger(__name__) + + +class BaseGenerator(ABC): + """Abstract base class for healthcare data format generators. + + This class provides core template rendering capabilities using Liquid templates. + It works with a ConfigManager to look up template configurations and a + TemplateRegistry to store and retrieve templates. + + All generators must implement the transform method to convert + input data to their specific format. + + Attributes: + config (ConfigManager): Configuration manager instance for looking up template paths + template_registry (TemplateRegistry): Registry storing available templates + """ + + def __init__(self, config: ConfigManager, template_registry: TemplateRegistry): + """Initialize the generator + + Args: + config: Configuration manager instance + template_registry: Template registry instance + """ + self.config = config + self.template_registry = template_registry + + def get_template(self, template_name: str) -> Optional[Template]: + """Get a template by name + + Args: + template_name: Name of the template to retrieve. Can be a full path + (e.g., 'cda_fhir/document') or just a filename (e.g., 'document'). + + Returns: + The template instance or None if not found + """ + try: + return self.template_registry.get_template(template_name) + except KeyError: + log.warning(f"Template '{template_name}' not found") + return None + + def get_template_from_section_config( + self, section_key: str, template_type: str + ) -> Optional[Template]: + """Get the template for a given section and template type from configuration. + + Looks up the template path from configuration using section_key and template_type, + and retrieves the template from the registry if it exists. The template path in + configuration should include the directory structure (e.g., 'cda_fhir/document'). + + Args: + section_key: Key identifying the section (e.g. 'problems', 'medications') + template_type: Type of template to look up (e.g. 'resource', 'entry', 'section') + + Returns: + Template instance if found and valid, None if template name not in config + or template not found in registry + + Example: + >>> generator.get_template_from_section_config('problems', 'entry') +