From 4008604aed3d89498f84306fbc4d639fe54d7b09 Mon Sep 17 00:00:00 2001 From: jenniferjiangkells Date: Thu, 6 Mar 2025 18:36:20 +0000 Subject: [PATCH 01/25] Interop engine WIP --- healthchain/interop/__init__.py | 3 + .../interop/config/mappings/cda_fhir.yaml | 40 +++ .../config/templates/cda/allergy.liquid | 27 ++ .../config/templates/cda/medication.liquid | 32 +++ .../config/templates/cda/problem.liquid | 52 ++++ healthchain/interop/engine.py | 204 +++++++++++++ healthchain/interop/filters.py | 42 +++ healthchain/interop/migration.py | 19 ++ healthchain/interop/models/__init__.py | 0 healthchain/interop/models/cda.py | 50 ++++ healthchain/interop/models/datatypes.py | 268 ++++++++++++++++++ healthchain/interop/models/sections.py | 127 +++++++++ healthchain/interop/parsers/cda.py | 98 +++++++ poetry.lock | 43 ++- pyproject.toml | 1 + 15 files changed, 1005 insertions(+), 1 deletion(-) create mode 100644 healthchain/interop/__init__.py create mode 100644 healthchain/interop/config/mappings/cda_fhir.yaml create mode 100644 healthchain/interop/config/templates/cda/allergy.liquid create mode 100644 healthchain/interop/config/templates/cda/medication.liquid create mode 100644 healthchain/interop/config/templates/cda/problem.liquid create mode 100644 healthchain/interop/engine.py create mode 100644 healthchain/interop/filters.py create mode 100644 healthchain/interop/migration.py create mode 100644 healthchain/interop/models/__init__.py create mode 100644 healthchain/interop/models/cda.py create mode 100644 healthchain/interop/models/datatypes.py create mode 100644 healthchain/interop/models/sections.py create mode 100644 healthchain/interop/parsers/cda.py diff --git a/healthchain/interop/__init__.py b/healthchain/interop/__init__.py new file mode 100644 index 00000000..3f3dd70a --- /dev/null +++ b/healthchain/interop/__init__.py @@ -0,0 +1,3 @@ +from .engine import InteropEngine + +__all__ = ["InteropEngine"] diff --git a/healthchain/interop/config/mappings/cda_fhir.yaml b/healthchain/interop/config/mappings/cda_fhir.yaml new file mode 100644 index 00000000..b18ef376 --- /dev/null +++ b/healthchain/interop/config/mappings/cda_fhir.yaml @@ -0,0 +1,40 @@ +# Code system mappings +code_systems: + # Maps CDA code systems to FHIR systems + "2.16.840.1.113883.6.96": "http://snomed.info/sct" + "2.16.840.1.113883.6.1": "http://loinc.org" + "2.16.840.1.113883.6.88": "http://www.nlm.nih.gov/research/umls/rxnorm" + +# Status code mappings +status: + # Maps CDA status codes to FHIR status + "55561003": "active" + "413322009": "resolved" + "73425007": "inactive" + +# Section definitions +sections: + problems: + template: cda/problem.liquid + template_id: "2.16.840.1.113883.10.20.1.11" + code: "11450-4" + resource: "Condition" + status_loinc_code: "33999-4" + + medications: + template: cda/medication.liquid + template_id: "2.16.840.1.113883.10.20.1.8" + code: "10160-0" + resource: "MedicationStatement" + + allergies: + template: cda/allergy.liquid + template_id: "2.16.840.1.113883.10.20.1.2" + code: "48765-2" + resource: "AllergyIntolerance" + + notes: + template: cda/note.liquid + template_id: "1.2.840.114350.1.72.1.200001" + code: "51847-2" + resource: "DocumentReference" diff --git a/healthchain/interop/config/templates/cda/allergy.liquid b/healthchain/interop/config/templates/cda/allergy.liquid new file mode 100644 index 00000000..3b29a8b3 --- /dev/null +++ b/healthchain/interop/config/templates/cda/allergy.liquid @@ -0,0 +1,27 @@ +{ + "resourceType": "AllergyIntolerance", + "id": "{{ entry.id }}", + "code": { + "coding": [{ + "system": "{{ entry.observation.value.codeSystem | map_system }}", + "code": "{{ entry.observation.value.code }}", + "display": "{{ entry.observation.value.displayName }}" + }] + }, + {% if entry.observation.entryRelationship %} + "reaction": [{ + {% if entry.observation.entryRelationship.observation.value %} + "manifestation": [{ + "coding": [{ + "system": "{{ entry.observation.entryRelationship.observation.value.codeSystem | map_system }}", + "code": "{{ entry.observation.entryRelationship.observation.value.code }}", + "display": "{{ entry.observation.entryRelationship.observation.value.displayName }}" + }] + }], + {% endif %} + {% if entry.observation.entryRelationship.observation.entryRelationship %} + "severity": "{{ entry.observation.entryRelationship.observation.entryRelationship.observation.value.code | map_severity }}" + {% endif %} + }] + {% endif %} +} diff --git a/healthchain/interop/config/templates/cda/medication.liquid b/healthchain/interop/config/templates/cda/medication.liquid new file mode 100644 index 00000000..d2d511d1 --- /dev/null +++ b/healthchain/interop/config/templates/cda/medication.liquid @@ -0,0 +1,32 @@ +{ + "resourceType": "MedicationStatement", + "id": "{{ entry.id }}", + "status": "{{ entry.statusCode | map_status }}", + "medicationCodeableConcept": { + "coding": [{ + "system": "{{ entry.consumable.manufacturedProduct.manufacturedMaterial.code.codeSystem | map_system }}", + "code": "{{ entry.consumable.manufacturedProduct.manufacturedMaterial.code.code }}", + "display": "{{ entry.consumable.manufacturedProduct.manufacturedMaterial.code.displayName }}" + }] + }, + {% if entry.effectiveTime %} + "effectivePeriod": { + {% if entry.effectiveTime.low %} + "start": "{{ entry.effectiveTime.low.value | format_date }}", + {% endif %} + {% if entry.effectiveTime.high %} + "end": "{{ entry.effectiveTime.high.value | format_date }}" + {% endif %} + }, + {% endif %} + {% if entry.doseQuantity %} + "dosage": [{ + "doseAndRate": [{ + "doseQuantity": { + "value": {{ entry.doseQuantity.value }}, + "unit": "{{ entry.doseQuantity.unit }}" + } + }] + }] + {% endif %} +} diff --git a/healthchain/interop/config/templates/cda/problem.liquid b/healthchain/interop/config/templates/cda/problem.liquid new file mode 100644 index 00000000..e75cb7f1 --- /dev/null +++ b/healthchain/interop/config/templates/cda/problem.liquid @@ -0,0 +1,52 @@ +{ + "resourceType": "Condition", + {% if entry.act.entryRelationship.is_array %} + {% assign actEntryRelationship = entry.act.entryRelationship[0] %} + {% else %} + {% assign actEntryRelationship = entry.act.entryRelationship %} + {% endif %} + {% if actEntryRelationship.observation.entryRelationship.observation.code['@code'] == section_config.status_loinc_code %} + {% if actEntryRelationship.observation.entryRelationship.observation.value %} + "clinicalStatus": { + "coding": [ + { + "system": "http://terminology.hl7.org/CodeSystem/condition-clinical", + "code": "{{ actEntryRelationship.observation.entryRelationship.observation.value['@code'] | map_status }}" + }, + { + "system": "{{ actEntryRelationship.observation.entryRelationship.observation.value['@codeSystem'] | map_system }}", + "code": "{{ actEntryRelationship.observation.entryRelationship.observation.value['@code'] }}", + "display": "{{ actEntryRelationship.observation.entryRelationship.observation.value['@displayName'] }}" + } + ] + }, + {% endif %} + {% endif %} + "category": [{ + "coding": [{ + "system": "http://terminology.hl7.org/CodeSystem/condition-category", + "code": "problem-list-item", + "display": "Problem List Item" + }] + }], + {% if actEntryRelationship.observation.value %} + "code": { + "coding": [{ + "system": "{{ actEntryRelationship.observation.value['@codeSystem'] | map_system }}", + "code": "{{ actEntryRelationship.observation.value['@code'] }}", + "display": "{{ actEntryRelationship.observation.value['@displayName'] }}" + }] + }, + {% endif %} + {% if actEntryRelationship.observation.effectiveTime %} + {% if actEntryRelationship.observation.effectiveTime.low %} + "onsetDateTime": "{{ actEntryRelationship.observation.effectiveTime.low['@value'] | format_date }}", + {% endif %} + {% if actEntryRelationship.observation.effectiveTime.high %} + "abatementDateTime": "{{ actEntryRelationship.observation.effectiveTime.high['@value'] | format_date }}", + {% endif %} + {% endif %} + "subject": { + "reference": "Patient/foo" + } +} diff --git a/healthchain/interop/engine.py b/healthchain/interop/engine.py new file mode 100644 index 00000000..5d94b82d --- /dev/null +++ b/healthchain/interop/engine.py @@ -0,0 +1,204 @@ +import yaml +import json +import uuid +import logging +import importlib + +from enum import Enum +from typing import Dict, List, Union +from pathlib import Path + +from liquid import Environment, FileSystemLoader +from fhir.resources.resource import Resource + +from .parsers.cda import CDAParser +from .filters import map_system, map_status, format_date, clean_empty + +log = logging.getLogger(__name__) + + +class FormatType(Enum): + HL7V2 = "hl7v2" + CDA = "cda" + FHIR = "fhir" + + +def validate_format(format_type: Union[str, FormatType]) -> FormatType: + if isinstance(format_type, str): + try: + return FormatType[format_type.upper()] + except KeyError: + raise ValueError(f"Unsupported format: {format_type}") + else: + return format_type + + +class InteropEngine: + """Generic interoperability engine for converting between healthcare formats""" + + def __init__(self, config_dir: Path): + self.config_dir = config_dir + self.mappings = self._load_mappings() + + # Create Liquid environment with loader and custom filters + template_dir = self.config_dir / "templates" + if not template_dir.exists(): + raise ValueError(f"Template directory not found: {template_dir}") + + self.env = Environment(loader=FileSystemLoader(str(template_dir))) + self._register_filters() + + self.templates = self._load_templates() + self.parser = CDAParser(self.mappings) + + def _register_filters(self): + # TODO: Can be more configurable + """Register custom filters with Liquid environment""" + + # Create filter functions with access to mappings + def map_system_filter(system): + return map_system(system, self.mappings) + + def map_status_filter(status): + return map_status(status, self.mappings) + + # Register filters with descriptive names + self.env.filters["map_system"] = map_system_filter + self.env.filters["map_status"] = map_status_filter + self.env.filters["format_date"] = format_date + + def _load_mappings(self) -> Dict: + """Load all mapping configurations""" + mappings = {} + mapping_dir = self.config_dir / "mappings" + for mapping_file in mapping_dir.glob("*.yaml"): + with open(mapping_file) as f: + mappings[mapping_file.stem] = yaml.safe_load(f) + + return mappings + + def _load_templates(self) -> Dict: + """Load all liquid templates""" + templates = {} + + # Walk through all subdirectories to find template files + for template_file in (self.config_dir / "templates").rglob("*.liquid"): + rel_path = template_file.relative_to(self.config_dir / "templates") + template_key = rel_path.stem + + try: + template = self.env.get_template(str(rel_path)) + templates[template_key] = template + + except Exception as e: + log.error(f"Failed to load template {template_file}: {str(e)}") + continue + + if not templates: + raise ValueError(f"No templates found in {self.config_dir / 'templates'}") + + log.debug(f"Loaded {len(templates)} templates: {list(templates.keys())}") + + return templates + + def _cda_to_fhir(self, source_data: str) -> List[Resource]: + """Convert CDA XML to FHIR resources""" + resources = [] + + # Get problems section config + # TODO: read sections from config + section_config = self.mappings["cda_fhir"]["sections"]["problems"] + template_key = section_config["template"].replace(".liquid", "").split("/")[-1] + + log.debug(f"Using template key: {template_key}") + template = self.templates[template_key] + + # TODO: maybe parse patient reference from source data and preserve header info + entries = self.parser.parse_section(source_data, section_config) + + # Convert each entry using template + for entry in entries: + try: + # Render template with entry data + rendered = template.render( + {"entry": entry, "section_config": section_config} + ) + + # Parse rendered JSON to dict and clean empty values + resource_dict = clean_empty(json.loads(rendered)) + + # Add required fields + if "id" not in resource_dict: + resource_dict["id"] = "hc-" + str(uuid.uuid4()) + if "subject" not in resource_dict: + resource_dict["subject"] = {"reference": "Patient/foo"} + if "clinicalStatus" not in resource_dict: + resource_dict["clinicalStatus"] = { + "coding": [ + { + "system": "http://terminology.hl7.org/CodeSystem/condition-clinical", + "code": "unknown", + } + ] + } + + # Get the FHIR resource class dynamically + try: + resource_type = resource_dict["resourceType"] + resource_module = importlib.import_module( + f"fhir.resources.{resource_type.lower()}" + ) + resource_class = getattr(resource_module, resource_type) + + # Create resource instance + resource = resource_class(**resource_dict) + resources.append(resource) + except Exception as e: + log.error(f"Failed to create FHIR resource: {str(e)}") + continue + + except Exception as e: + log.error(f"Failed to convert entry: {str(e)}") + continue + + return resources + + def _hl7v2_to_fhir(self, source_data: str) -> List[Resource]: + """Convert HL7v2 to FHIR resources""" + raise NotImplementedError("HL7v2 to FHIR conversion not implemented") + + def _fhir_to_cda(self, resources: List[Resource]) -> str: + """Convert FHIR resources to CDA""" + raise NotImplementedError("FHIR to CDA conversion not implemented") + + def _fhir_to_hl7v2(self, resources: List[Resource]) -> str: + """Convert FHIR resources to HL7v2""" + raise NotImplementedError("FHIR to HL7v2 conversion not implemented") + + def to_fhir( + self, source_data: str, source_format: Union[str, FormatType] + ) -> List[Resource]: + """Convert source format to FHIR resources""" + format_type = validate_format(source_format) + + if format_type == FormatType.CDA: + return self._cda_to_fhir(source_data) + elif format_type == FormatType.HL7V2: + return self._hl7v2_to_fhir(source_data) + else: + raise ValueError(f"Unsupported format: {format_type}") + + def from_fhir( + self, + resources: List[Resource], + format_type: Union[str, FormatType], + ) -> str: + """Convert FHIR resources to HL7v2 or CDA""" + format_type = validate_format(format_type) + + if format_type == FormatType.HL7V2: + return self._fhir_to_hl7v2(resources) + elif format_type == FormatType.CDA: + return self._fhir_to_cda(resources) + else: + raise ValueError(f"Unsupported format: {format_type}") diff --git a/healthchain/interop/filters.py b/healthchain/interop/filters.py new file mode 100644 index 00000000..787a2582 --- /dev/null +++ b/healthchain/interop/filters.py @@ -0,0 +1,42 @@ +from datetime import datetime +from typing import Dict, Any + + +def map_system(system: str, mappings: Dict) -> str: + """Maps source code system to FHIR system""" + if not system: + return None + # Access the code systems mapping directly + return mappings.get("cda_fhir", {}).get("code_systems", {}).get(system, system) + + +def map_status(status: str, mappings: Dict) -> str: + """Maps source status to FHIR status""" + if not status: + return None + # Access the status mapping directly + return mappings.get("cda_fhir", {}).get("status", {}).get(status, "unknown") + + +def format_date(date_str: str) -> str: + """Formats dates to FHIR format""" + if not date_str: + return None + try: + dt = datetime.strptime(date_str, "%Y%m%d") + return dt.isoformat() + "Z" # Add UTC timezone indicator + except (ValueError, TypeError): + return None + + +def clean_empty(d: Any) -> Any: + """Recursively remove empty strings, empty lists, empty dicts, and None values""" + 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 diff --git a/healthchain/interop/migration.py b/healthchain/interop/migration.py new file mode 100644 index 00000000..68fbeec9 --- /dev/null +++ b/healthchain/interop/migration.py @@ -0,0 +1,19 @@ +from typing import Dict, List +from pathlib import Path +from healthchain.cda_parser.cdaannotator import CdaAnnotator +from healthchain.interop.engine import InteropEngine + + +class LegacyMigrator: + """Helper class to migrate from legacy CdaAnnotator to new InteropEngine""" + + def __init__(self, config_dir: Path): + self.engine = InteropEngine(config_dir) + + def migrate_document(self, cda_annotator: CdaAnnotator) -> List[Dict]: + """Convert CdaAnnotator document to FHIR resources using new engine""" + # Export CDA XML from annotator + cda_xml = cda_annotator.export() + + # Use new engine to convert + return self.engine.to_fhir(cda_xml, "CDA") diff --git a/healthchain/interop/models/__init__.py b/healthchain/interop/models/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/healthchain/interop/models/cda.py b/healthchain/interop/models/cda.py new file mode 100644 index 00000000..cf8e2d23 --- /dev/null +++ b/healthchain/interop/models/cda.py @@ -0,0 +1,50 @@ +from __future__ import annotations + +from pydantic import BaseModel, Field +from typing import List, Dict, Optional, Union + +from healthchain.cda_parser.model.datatypes import CE, CS, II, TS + +from .sections import Section + + +class Component2(BaseModel): + """ + https://gazelle.ihe.net/CDAGenerator/cda/POCDMT000040Component2.html + """ + + structuredBody: Optional[StructuredBody] = None + + +class Component3(BaseModel): + """ + https://gazelle.ihe.net/CDAGenerator/cda/POCDMT000040Component3.html + """ + + section: Section + + +class StructuredBody(BaseModel): + """ + https://gazelle.ihe.net/CDAGenerator/cda/POCDMT000040StructuredBody.html + """ + + component: List[Component3] + + +class ClinicalDocument(BaseModel): + """ + https://gazelle.ihe.net/CDAGenerator/cda/POCDMT000040ClinicalDocument.html + """ + + xmlns: str = Field("urn:hl7-org:v3", alias="@xmlns") + realmCode: Optional[CS] = None + typeId: Dict + templateId: Optional[Union[II, List[II]]] = None + id: II + code: CE + title: Optional[str] = None + effectiveTime: TS + confidentialityCode: CE + languageCode: Optional[CS] + component: Component2 diff --git a/healthchain/interop/models/datatypes.py b/healthchain/interop/models/datatypes.py new file mode 100644 index 00000000..3cd0bede --- /dev/null +++ b/healthchain/interop/models/datatypes.py @@ -0,0 +1,268 @@ +""" +Contains CDA datatype objects with pydantic validation +https://gazelle.ihe.net/CDAGenerator/datatypes/datatypes.html +""" + +from __future__ import annotations +from pydantic import BaseModel, Field +from typing import Optional, List, Dict, Union + + +class ANY(BaseModel): + nullFlavor: Optional[str] = Field(default=None, alias="@nullFlavor") + + +class BIN(ANY): + """ + Binary data. + """ + + mixed: Optional[Dict] = None + representation: Optional[str] = Field( + default=None, alias="@representation" + ) # enumeration B64 or TXT + + +class URL(ANY): + """ + URL data. + """ + + value: Optional[str] = Field(default=None, alias="@value") + + +class TEL(URL): + """ + A telephone number, e-mail address, or other locator for a resource mediated + by telecommunication equipment. The address is specified as a URL qualified + by time specification and use codes that help in deciding which address to + use for a given time and purpose. + """ + + usablePeriod: Optional[Union[SXCM_TS, List[SXCM_TS]]] = None + use: Optional[Union[str, List[str]]] = Field(default=None, alias="@use") + + +class ED(BIN): + """ + Data that is primarily intended for human interpretation or for + further machine processing is outside the scope of HL7. + """ + + reference: Optional[TEL] = None + thumbnail: Optional[ED] = None # thumbnail + compression: Optional[str] = Field( + default=None, + alias="@compression", + description="Indicates whether the raw byte data is compressed, and what compression algorithm was used.", + ) # enum + integrityCheck: Optional[str] = Field(default=None, alias="@integrityCheck") + integrityCheckAlgorithm: Optional[str] = Field( + default=None, alias="@integrityCheckAlgorithm" + ) # enum SHA1 or SHA256 + language: Optional[str] = Field(default=None, alias="@language") + mediaType: Optional[str] = Field(default=None, alias="@mediaType") + + +class QTY(ANY): + """ + The quantity data type is an abstract generalization for all data + types (1) whose value set has an order relation (less-or-equal) + and (2) where difference is defined in all of the data type's + totally ordered value subsets. The quantity type abstraction is + needed in defining certain other types, such as the interval and + the probability distribution. + """ + + pass + + +class II(ANY): + """ + An identifier that uniquely identifies a thing or object. + """ + + assigningAuthorityName: Optional[str] = Field( + default=None, alias="@assigningAuthorityName" + ) + displayable: Optional[bool] = Field(default=None, alias="@displayable") + extension: Optional[str] = Field(default=None, alias="@extension") + root: Optional[str] = Field(default=None, alias="@root") + + +class CD(ANY): + """ + A concept descriptor represents any kind of concept usually by giving a + code defined in a code system. A concept descriptor can contain the + original text or phrase that served as the basis of the coding and one + or more translations into different coding systems. + """ + + originalText: Optional[Union[str, Dict]] = ( + None # parse as dict or str for more flexibility + ) + qualifier: Optional[Union[str, List[str]]] = None # CR + translation: Optional[Union[CD, List[CD]]] = Field(default=None) + code: Optional[str] = Field(default=None, alias="@code") + codeSystem: Optional[str] = Field(default=None, alias="@codeSystem") + codeSystemName: Optional[str] = Field(default=None, alias="@codeSystemName") + codeSystemVersion: Optional[str] = Field(default=None, alias="@codeSystemVersion") + displayName: Optional[str] = Field(default=None, alias="@displayName") + + +class CE(CD): + """ + Coded data, consists of a coded value (CV) and, optionally, + coded value(s) from other coding systems that identify the same + concept. Used when alternative codes may exist. + """ + + pass + + +class CV(CE): + """ + Coded data, consists of a code, display name, code system, + and original text. Used when a single code value must be sent. + """ + + pass + + +class CS(CV): + """ + Coded data, consists of a code, display name, code system, + and original text. Used when a single code value must be sent. + """ + + pass + + +class PQR(CV): + """ + A representation of a physical quantity in a unit from any code + system. Used to show alternative representation for a physical + quantity. + """ + + value: Optional[float] = Field(default=None, alias="@value") + + +class TS(QTY): + """ + A quantity specifying a point on the axis of natural time. A point + in time is most often represented as a calendar expression. + """ + + value: Optional[str] = Field(default=None, alias="@value") + + +class PQ(QTY): + """ + A dimensioned quantity expressing the result of a measurement act. + """ + + translation: Optional[Union[PQR, List[PQR]]] = Field( + default=None, + description="An alternative representation of the same physical quantity expressed in a different unit, of a different unit code system and possibly with a different value.", + ) + unit: Optional[str] = Field( + default=None, + alias="@unit", + description="The unit of measure specified in the Unified Code for Units of Measure (UCUM) [http://aurora.rg.iupui.edu/UCUM].", + ) + value: Optional[float] = Field( + default=None, + alias="@value", + description="The magnitude of the quantity measured in terms of the unit.", + ) + + +class SXCM_TS(TS): + operator: Optional[str] = Field(default=None, alias="@operator") # enumeration + + +class SXCM_PQ(PQ): + operator: Optional[str] = Field(default=None, alias="@operator") # enumeration + + +class IVXB_TS(TS): + inclusive: Optional[bool] = Field( + None, + alias="@inclusive", + description="Specifies whether the limit is included in the interval.", + ) + + +class IVXB_PQ(PQ): + inclusive: Optional[bool] = Field( + None, + alias="@inclusive", + description="Specifies whether the limit is included in the interval.", + ) + + +class IVL_PQ(SXCM_PQ): + low: Optional[IVXB_PQ] = Field( + default=None, description="The low limit of the interval." + ) + center: Optional[PQ] = Field( + default=None, + description="The arithmetic mean of the interval (low plus high divided by 2). The purpose of distinguishing the center as a semantic property is for conversions of intervals from and to point values.", + ) + width: Optional[PQ] = Field( + default=None, + description="The difference between high and low boundary. The purpose of distinguishing a width property is to handle all cases of incomplete information symmetrically. In any interval representation only two of the three properties high, low, and width need to be stated and the third can be derived.", + ) + high: Optional[IVXB_PQ] = Field( + default=None, description="The high limit of the interval." + ) + + +class IVL_TS(SXCM_TS): + """ + Time interval + """ + + low: Optional[IVXB_TS] = Field( + default=None, description="The low limit of the interval." + ) + center: Optional[TS] = Field( + default=None, + description="The arithmetic mean of the interval (low plus high divided by 2). The purpose of distinguishing the center as a semantic property is for conversions of intervals from and to point values.", + ) + width: Optional[PQ] = Field( + default=None, + description="The difference between high and low boundary. The purpose of distinguishing a width property is to handle all cases of incomplete information symmetrically. In any interval representation only two of the three properties high, low, and width need to be stated and the third can be derived.", + ) + high: Optional[IVXB_TS] = Field( + default=None, description="The high limit of the interval." + ) + + +class PIVL_TS(SXCM_TS): + phase: Optional[IVL_TS] = Field( + default=None, + description="A prototype of the repeating interval specifying the duration of each occurrence and anchors the periodic interval sequence at a certain point in time.", + ) + period: Optional[PQ] = Field( + default=None, + description="A time duration specifying a reciprocal measure of the frequency at which the periodic interval repeats.", + ) + alignment: Optional[CalendarCycle] = Field( + default=None, + alias="@alignment", + description="Specifies if and how the repetitions are aligned to the cycles of the underlying calendar (e.g., to distinguish every 30 days from 'the 5th of every month'.) A non-aligned periodic interval recurs independently from the calendar. An aligned periodic interval is synchronized with the calendar.", + ) + institutionSpecified: Optional[bool] = Field( + default=None, + alias="@institutionSpecified", + description="Indicates whether the exact timing is up to the party executing the schedule (e.g., to distinguish 'every 8 hours' from '3 times a day'.)", + ) + + +class CalendarCycle(ANY): + name: Optional[str] = None # enum + + +CD.model_rebuild() diff --git a/healthchain/interop/models/sections.py b/healthchain/interop/models/sections.py new file mode 100644 index 00000000..618c11de --- /dev/null +++ b/healthchain/interop/models/sections.py @@ -0,0 +1,127 @@ +""" +https://wiki.ihe.net/index.php/1.3.6.1.4.1.19376.1.5.3.1.4.5 +""" + +from __future__ import annotations +from pydantic import BaseModel, Field +from typing import List, Dict, Optional, Union + +from .datatypes import CD, CS, CE, II, IVL_PQ, IVL_TS + + +class PlayingEntity(BaseModel): + classCode: str = Field("MMAT", alias="@classCode") + code: Optional[CE] = None + name: Optional[str] = None + + +class ParticipantRole(BaseModel): + classCode: str = Field("MANU", alias="@classCode") + playingEntity: Optional[PlayingEntity] = None + + +class Participant(BaseModel): + typeCode: str = Field("CSM", alias="@typeCode") + participantRole: Optional[ParticipantRole] = None + + +class Observation(BaseModel): + classCode: str = Field("OBS", alias="@classCode") + moodCode: str = Field("EVN", alias="@moodCode") + templateId: Optional[Union[II, List[II]]] = None + id: Optional[Union[II, List[II]]] = None + code: CD + text: Optional[Dict] = None + statusCode: Optional[CS] = None + effectiveTime: Optional[IVL_TS] = None + value: Optional[Union[Dict, List[Dict]]] = None + participant: Optional[Participant] = None + entryRelationship: Optional[Union[EntryRelationship, List[EntryRelationship]]] = ( + None + ) + precondition: Optional[str] = None + + +class EntryRelationship(BaseModel): + typeCode: str = Field("SUBJ", alias="@typeCode") + inversionInd: bool = Field(False, alias="@inversionInd") + act: Optional[Act] = None + observation: Optional[Observation] = None + substanceAdministration: Optional[SubstanceAdministration] = None + supply: Optional[Dict] = None + + +class Act(BaseModel): + classCode: str = Field("ACT", alias="@classCode") + moodCode: str = Field("EVN", alias="@moodCode") + templateId: Optional[Union[II, List[II]]] = None + id: Optional[Union[II, List[II]]] = None + code: CD + text: Optional[Dict] = None + statusCode: Optional[CS] = None + effectiveTime: Optional[IVL_TS] = None + entryRelationship: Optional[Union[EntryRelationship, List[EntryRelationship]]] = ( + None + ) + + +class Entry(BaseModel): + act: Optional[Act] = None + substanceAdministration: Optional[SubstanceAdministration] = None + + +class Section(BaseModel): + id: Optional[II] = None + templateId: Optional[Union[II, List[II]]] = None + code: Optional[CE] = None + title: Optional[str] = None + text: Optional[Dict] = None + entry: Optional[Union[Entry, List[Entry]]] = None + + +class ManufacturedMaterial(BaseModel): + code: Optional[CE] = None + + +class ManufacturedProduct(BaseModel): + classCode: str = Field("MANU", alias="@classCode") + templateId: Optional[Union[II, List[II]]] = None + manufacturedMaterial: Optional[ManufacturedMaterial] = None + + +class Consumable(BaseModel): + typeCode: str = Field("CSM", alias="@typeCode") + manufacturedProduct: ManufacturedProduct + + +class Criterion(BaseModel): + templateId: Optional[Union[II, List[II]]] = None + code: Optional[CD] = None + value: Optional[Dict] = None + + +class Precondition(BaseModel): + typeCode: str = Field("PRCN", alias="@typeCode") + criterion: Criterion + + +class SubstanceAdministration(BaseModel): + """ + https://gazelle.ihe.net/CDAGenerator/cda/POCDMT000040SubstanceAdministration.html + """ + + classCode: str = Field("SBADM", alias="@classCode") + moodCode: str = Field("INT", alias="@moodCode") + templateId: Optional[Union[II, List[II]]] = None + id: Optional[Union[II, List[II]]] = None + code: Optional[CD] = None + text: Optional[Dict] = None + statusCode: Optional[CS] = None + effectiveTime: Optional[Union[Dict, List[Dict]]] = None # parse as dict + routeCode: Optional[CE] = None + doseQuantity: Optional[IVL_PQ] = None + consumable: Consumable + entryRelationship: Optional[Union[EntryRelationship, List[EntryRelationship]]] = ( + None + ) + precondition: Optional[Union[Precondition, List[Precondition]]] = None diff --git a/healthchain/interop/parsers/cda.py b/healthchain/interop/parsers/cda.py new file mode 100644 index 00000000..63536ad3 --- /dev/null +++ b/healthchain/interop/parsers/cda.py @@ -0,0 +1,98 @@ +import xmltodict +import logging + +from typing import Dict, List + +from healthchain.interop.models.cda import ClinicalDocument +from healthchain.interop.models.sections import Section, Entry + +log = logging.getLogger(__name__) + + +class CDAParser: + """Parser for CDA XML documents""" + + def __init__(self, mappings: Dict): + self.mappings = mappings + self.clinical_document = None + + def parse_section(self, xml: str, section_config: Dict) -> List[Dict]: + """ + Extract entries from a CDA section using template ID or code. + + Args: + xml: The CDA XML document + section_config: Configuration for the section containing template ID/code + + Returns: + List of entry dictionaries from the section + """ + try: + # Parse XML into ClinicalDocument model + doc_dict = xmltodict.parse(xml) + self.clinical_document = ClinicalDocument(**doc_dict["ClinicalDocument"]) + + # Get all components + components = self.clinical_document.component.structuredBody.component + if not isinstance(components, list): + components = [components] + + # Find matching section + section = None + for component in components: + curr_section = component.section + + if section_config.get( + "template_id" + ) and self._find_section_by_template_id( + curr_section, section_config["template_id"] + ): + section = curr_section + break + + if section_config.get("code") and self._find_section_by_code( + curr_section, section_config["code"] + ): + section = curr_section + break + + if not section: + log.warning(f"Section not found for config: {section_config}") + return [] + + # Get entries and convert to dicts + entries = self._get_section_entries(section) + entry_dicts = [ + entry.model_dump(exclude_none=True, by_alias=True) + for entry in entries + if entry + ] + + log.debug(f"Found {len(entry_dicts)} entries in section") + return entry_dicts + + except Exception as e: + log.error(f"Error parsing CDA document: {str(e)}") + return [] + + def _find_section_by_template_id(self, section: Section, template_id: str) -> bool: + """Check if section matches template ID""" + if not section.templateId: + return False + + template_ids = ( + section.templateId + if isinstance(section.templateId, list) + else [section.templateId] + ) + return any(tid.root == template_id for tid in template_ids) + + def _find_section_by_code(self, section: Section, code: str) -> bool: + """Check if section matches code""" + return section.code and section.code.code == code + + def _get_section_entries(self, section: Section) -> List[Entry]: + """Get list of entries from section""" + if not section.entry: + return [] + return section.entry if isinstance(section.entry, list) else [section.entry] diff --git a/poetry.lock b/poetry.lock index 9265c188..b20d1a92 100644 --- a/poetry.lock +++ b/poetry.lock @@ -842,6 +842,28 @@ perf = ["ipython"] test = ["flufl.flake8", "importlib-resources (>=1.3)", "jaraco.test (>=5.4)", "packaging", "pyfakefs", "pytest (>=6,!=8.1.*)", "pytest-perf (>=0.9.2)"] type = ["pytest-mypy"] +[[package]] +name = "importlib-resources" +version = "6.4.5" +description = "Read resources from Python packages" +optional = false +python-versions = ">=3.8" +files = [ + {file = "importlib_resources-6.4.5-py3-none-any.whl", hash = "sha256:ac29d5f956f01d5e4bb63102a5a19957f1b9175e45649977264a1416783bb717"}, + {file = "importlib_resources-6.4.5.tar.gz", hash = "sha256:980862a1d16c9e147a59603677fa2aa5fd82b87f223b6cb870695bcfce830065"}, +] + +[package.dependencies] +zipp = {version = ">=3.1.0", markers = "python_version < \"3.10\""} + +[package.extras] +check = ["pytest-checkdocs (>=2.4)", "pytest-ruff (>=0.2.1)"] +cover = ["pytest-cov"] +doc = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-lint"] +enabler = ["pytest-enabler (>=2.2)"] +test = ["jaraco.test (>=5.4)", "pytest (>=6,!=8.1.*)", "zipp (>=3.17)"] +type = ["pytest-mypy"] + [[package]] name = "iniconfig" version = "2.0.0" @@ -2222,6 +2244,25 @@ files = [ [package.dependencies] six = ">=1.5" +[[package]] +name = "python-liquid" +version = "1.13.0" +description = "A Python engine for the Liquid template language." +optional = false +python-versions = ">=3.7" +files = [ + {file = "python_liquid-1.13.0-py3-none-any.whl", hash = "sha256:843ed7b8af00c1480d1bf402553ed07bdddce130ebbf4b1fefc84bb2e076f5d4"}, + {file = "python_liquid-1.13.0.tar.gz", hash = "sha256:c158fbaad6dd41c49de7cff34e3611bff7211326fb3049322d27673e1d66e166"}, +] + +[package.dependencies] +importlib-resources = ">=5.10.0" +python-dateutil = ">=2.8.1" +typing-extensions = ">=4.2.0" + +[package.extras] +autoescape = ["markupsafe (>=2,<3)"] + [[package]] name = "pytz" version = "2024.2" @@ -3406,4 +3447,4 @@ type = ["pytest-mypy"] [metadata] lock-version = "2.0" python-versions = ">=3.8,<3.12" -content-hash = "26e0e1c27f5b77fda60153a68515bbb3767e097e7277834df08e11237513d0dd" +content-hash = "c69d71067d1a8adedc4c989ca2c3951bd1d10b7cb3cc2dcdcc52ae42eb70f862" diff --git a/pyproject.toml b/pyproject.toml index ccc0ba72..b012545c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -42,6 +42,7 @@ spyne = "^2.14.0" lxml = "^5.2.2" xmltodict = "^0.13.0" fhir-resources = "^8.0.0" +python-liquid = "^1.13.0" [tool.poetry.group.dev.dependencies] ruff = "^0.4.2" From 34d5476531fd835848d813397b7364b3202e1993 Mon Sep 17 00:00:00 2001 From: jenniferjiangkells Date: Fri, 7 Mar 2025 19:28:20 +0000 Subject: [PATCH 02/25] FHIR -> CDA wip --- .../interop/config/mappings/cda_fhir.yaml | 16 +- .../interop/config/mappings/fhir_cda.yaml | 54 ++++ .../config/mappings/shared_mappings.yaml | 22 ++ .../config/templates/cda/allergy.liquid | 16 +- .../config/templates/cda/cda_document.liquid | 31 ++ .../templates/cda/cda_problem_entry.liquid | 85 +++++ .../config/templates/cda/cda_section.liquid | 15 + .../config/templates/cda/medication.liquid | 8 +- .../config/templates/cda/problem.liquid | 8 +- healthchain/interop/engine.py | 305 ++++++++++++++---- 10 files changed, 478 insertions(+), 82 deletions(-) create mode 100644 healthchain/interop/config/mappings/fhir_cda.yaml create mode 100644 healthchain/interop/config/mappings/shared_mappings.yaml create mode 100644 healthchain/interop/config/templates/cda/cda_document.liquid create mode 100644 healthchain/interop/config/templates/cda/cda_problem_entry.liquid create mode 100644 healthchain/interop/config/templates/cda/cda_section.liquid diff --git a/healthchain/interop/config/mappings/cda_fhir.yaml b/healthchain/interop/config/mappings/cda_fhir.yaml index b18ef376..e09bdacc 100644 --- a/healthchain/interop/config/mappings/cda_fhir.yaml +++ b/healthchain/interop/config/mappings/cda_fhir.yaml @@ -1,18 +1,4 @@ -# Code system mappings -code_systems: - # Maps CDA code systems to FHIR systems - "2.16.840.1.113883.6.96": "http://snomed.info/sct" - "2.16.840.1.113883.6.1": "http://loinc.org" - "2.16.840.1.113883.6.88": "http://www.nlm.nih.gov/research/umls/rxnorm" - -# Status code mappings -status: - # Maps CDA status codes to FHIR status - "55561003": "active" - "413322009": "resolved" - "73425007": "inactive" - -# Section definitions +# CDA to FHIR section definitions sections: problems: template: cda/problem.liquid diff --git a/healthchain/interop/config/mappings/fhir_cda.yaml b/healthchain/interop/config/mappings/fhir_cda.yaml new file mode 100644 index 00000000..a326b964 --- /dev/null +++ b/healthchain/interop/config/mappings/fhir_cda.yaml @@ -0,0 +1,54 @@ +# Document level configuration +document: + type: + code: "34133-9" + system: "2.16.840.1.113883.6.1" + display: "Summarization of Episode Note" + confidentiality: + code: "N" + system: "2.16.840.1.113883.5.25" + language: "en-US" + +# Section definitions +sections: + problems: + resource: "Condition" + template: "cda/cda_problem_entry" + template_id: "2.16.840.1.113883.10.20.1.11" + code: "11450-4" + display: "Problem List" + status_loinc_code: "33999-4" + entry_template_ids: + - "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" + observation_template_ids: + - "1.3.6.1.4.1.19376.1.5.3.1.4.5" + - "2.16.840.1.113883.10.20.1.28" + + medications: + resource: "MedicationStatement" + template: "cda/cda_medication_entry" + template_id: "2.16.840.1.113883.10.20.1.8" + code: "10160-0" + display: "Medications" + entry_template_ids: + - "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" + + allergies: + resource: "AllergyIntolerance" + template: "cda/cda_allergy_entry" + template_id: "2.16.840.1.113883.10.20.1.2" + code: "48765-2" + display: "Allergies" + entry_template_ids: + - "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" diff --git a/healthchain/interop/config/mappings/shared_mappings.yaml b/healthchain/interop/config/mappings/shared_mappings.yaml new file mode 100644 index 00000000..98bf386b --- /dev/null +++ b/healthchain/interop/config/mappings/shared_mappings.yaml @@ -0,0 +1,22 @@ +# Shared mappings between CDA and FHIR formats + +code_systems: + cda_to_fhir: + "2.16.840.1.113883.6.96": "http://snomed.info/sct" + "2.16.840.1.113883.6.1": "http://loinc.org" + "2.16.840.1.113883.6.88": "http://www.nlm.nih.gov/research/umls/rxnorm" + fhir_to_cda: + "http://snomed.info/sct": "2.16.840.1.113883.6.96" + "http://loinc.org": "2.16.840.1.113883.6.1" + "http://www.nlm.nih.gov/research/umls/rxnorm": "2.16.840.1.113883.6.88" + "http://terminology.hl7.org/CodeSystem/condition-clinical": "2.16.840.1.113883.6.96" + +status_codes: + cda_to_fhir: + "55561003": "active" + "413322009": "resolved" + "73425007": "inactive" + fhir_to_cda: + "active": "55561003" + "resolved": "413322009" + "inactive": "73425007" diff --git a/healthchain/interop/config/templates/cda/allergy.liquid b/healthchain/interop/config/templates/cda/allergy.liquid index 3b29a8b3..3dbd0c35 100644 --- a/healthchain/interop/config/templates/cda/allergy.liquid +++ b/healthchain/interop/config/templates/cda/allergy.liquid @@ -1,13 +1,25 @@ { "resourceType": "AllergyIntolerance", - "id": "{{ entry.id }}", + "id": "{{ entry.id | generate_id }}", + "clinicalStatus": { + "coding": [{ + "system": "http://terminology.hl7.org/CodeSystem/allergyintolerance-clinical", + "code": "{{ entry.status | map_status: 'cda_to_fhir' }}" + }] + }, "code": { "coding": [{ - "system": "{{ entry.observation.value.codeSystem | map_system }}", + "system": "{{ entry.observation.value.codeSystem | map_system: 'cda_to_fhir' }}", "code": "{{ entry.observation.value.code }}", "display": "{{ entry.observation.value.displayName }}" }] }, + {% if entry.effectiveTime %} + "onsetDateTime": "{{ entry.effectiveTime.value | format_date }}", + {% endif %} + "subject": { + "reference": "Patient/{{ entry.subject_id | default: 'example' }}" + }, {% if entry.observation.entryRelationship %} "reaction": [{ {% if entry.observation.entryRelationship.observation.value %} diff --git a/healthchain/interop/config/templates/cda/cda_document.liquid b/healthchain/interop/config/templates/cda/cda_document.liquid new file mode 100644 index 00000000..80eb391e --- /dev/null +++ b/healthchain/interop/config/templates/cda/cda_document.liquid @@ -0,0 +1,31 @@ +{ + "ClinicalDocument": { + "@xmlns": "urn:hl7-org:v3", + "@xmlns:xsi": "http://www.w3.org/2001/XMLSchema-instance", + "id": { + "@root": "{{ bundle.identifier.value | generate_id }}" + }, + "code": { + "@code": "{{ document.type.code }}", + "@codeSystem": "{{ document.type.system }}", + "@codeSystemName": "LOINC", + "@displayName": "{{ document.type.display }}" + }, + "title": "Clinical Document", + "effectiveTime": { + "@value": "{{ bundle.timestamp | format_timestamp }}" + }, + "confidentialityCode": { + "@code": "{{ document.confidentiality.code }}", + "@codeSystem": "{{ document.confidentiality.system }}" + }, + "languageCode": { + "@code": "{{ document.language }}" + }, + "component": { + "structuredBody": { + "component": {{ formatted_sections | json }} + } + } + } +} diff --git a/healthchain/interop/config/templates/cda/cda_problem_entry.liquid b/healthchain/interop/config/templates/cda/cda_problem_entry.liquid new file mode 100644 index 00000000..40b17183 --- /dev/null +++ b/healthchain/interop/config/templates/cda/cda_problem_entry.liquid @@ -0,0 +1,85 @@ +{ + "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": "{{ resource.id | generate_id }}"}, + "code": {"@nullFlavor": "NA"}, + "statusCode": { + "@code": "active" + }, + "effectiveTime": { + "low": {"@value": "{{ context.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": "{{ resource.id }}_obs"}, + "code": { + "@code": "55607006", + "@codeSystem": "2.16.840.1.113883.6.96", + "@codeSystemName": "SNOMED CT", + "@displayName": "Problem" + }, + "text": { + "reference": {"@value": "#{{ context.text_reference_name }}"} + }, + "statusCode": {"@code": "completed"}, + "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": "#{{ context.text_reference_name }}"} + } + }, + "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": "{{ 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": "completed"}, + "effectiveTime": { + "low": {"@value": "{{ context.timestamp }}"} + } + } + } + } + } + } +} diff --git a/healthchain/interop/config/templates/cda/cda_section.liquid b/healthchain/interop/config/templates/cda/cda_section.liquid new file mode 100644 index 00000000..982c49b0 --- /dev/null +++ b/healthchain/interop/config/templates/cda/cda_section.liquid @@ -0,0 +1,15 @@ +{ + "section": { + "templateId": { + "@root": "{{ config.template_id }}" + }, + "code": { + "@code": "{{ config.code }}", + "@codeSystem": "2.16.840.1.113883.6.1", + "@codeSystemName": "LOINC", + "@displayName": "{{ config.display }}" + }, + "title": "{{ config.display }}", + "entry": {{ entries | json }} + } +} diff --git a/healthchain/interop/config/templates/cda/medication.liquid b/healthchain/interop/config/templates/cda/medication.liquid index d2d511d1..14c08627 100644 --- a/healthchain/interop/config/templates/cda/medication.liquid +++ b/healthchain/interop/config/templates/cda/medication.liquid @@ -1,10 +1,10 @@ { "resourceType": "MedicationStatement", - "id": "{{ entry.id }}", - "status": "{{ entry.statusCode | map_status }}", + "id": "{{ entry.id | generate_id }}", + "status": "{{ entry.statusCode | map_status: 'cda_to_fhir' }}", "medicationCodeableConcept": { "coding": [{ - "system": "{{ entry.consumable.manufacturedProduct.manufacturedMaterial.code.codeSystem | map_system }}", + "system": "{{ entry.consumable.manufacturedProduct.manufacturedMaterial.code.codeSystem | map_system: 'cda_to_fhir' }}", "code": "{{ entry.consumable.manufacturedProduct.manufacturedMaterial.code.code }}", "display": "{{ entry.consumable.manufacturedProduct.manufacturedMaterial.code.displayName }}" }] @@ -12,7 +12,7 @@ {% if entry.effectiveTime %} "effectivePeriod": { {% if entry.effectiveTime.low %} - "start": "{{ entry.effectiveTime.low.value | format_date }}", + "start": "{{ entry.effectiveTime.low.value | format_date }}" {% endif %} {% if entry.effectiveTime.high %} "end": "{{ entry.effectiveTime.high.value | format_date }}" diff --git a/healthchain/interop/config/templates/cda/problem.liquid b/healthchain/interop/config/templates/cda/problem.liquid index e75cb7f1..f7b41d1d 100644 --- a/healthchain/interop/config/templates/cda/problem.liquid +++ b/healthchain/interop/config/templates/cda/problem.liquid @@ -11,10 +11,10 @@ "coding": [ { "system": "http://terminology.hl7.org/CodeSystem/condition-clinical", - "code": "{{ actEntryRelationship.observation.entryRelationship.observation.value['@code'] | map_status }}" + "code": "{{ actEntryRelationship.observation.entryRelationship.observation.value['@code'] | map_status: 'cda_to_fhir' }}" }, { - "system": "{{ actEntryRelationship.observation.entryRelationship.observation.value['@codeSystem'] | map_system }}", + "system": "{{ actEntryRelationship.observation.entryRelationship.observation.value['@codeSystem'] | map_system: 'cda_to_fhir' }}", "code": "{{ actEntryRelationship.observation.entryRelationship.observation.value['@code'] }}", "display": "{{ actEntryRelationship.observation.entryRelationship.observation.value['@displayName'] }}" } @@ -32,7 +32,7 @@ {% if actEntryRelationship.observation.value %} "code": { "coding": [{ - "system": "{{ actEntryRelationship.observation.value['@codeSystem'] | map_system }}", + "system": "{{ actEntryRelationship.observation.value['@codeSystem'] | map_system: 'cda_to_fhir' }}", "code": "{{ actEntryRelationship.observation.value['@code'] }}", "display": "{{ actEntryRelationship.observation.value['@displayName'] }}" }] @@ -47,6 +47,6 @@ {% endif %} {% endif %} "subject": { - "reference": "Patient/foo" + "reference": "Patient/{{ entry.subject_id | default: 'example' }}" } } diff --git a/healthchain/interop/engine.py b/healthchain/interop/engine.py index 5d94b82d..ce59204b 100644 --- a/healthchain/interop/engine.py +++ b/healthchain/interop/engine.py @@ -3,16 +3,20 @@ import uuid import logging import importlib +import re +import xmltodict from enum import Enum from typing import Dict, List, Union from pathlib import Path +from datetime import datetime from liquid import Environment, FileSystemLoader from fhir.resources.resource import Resource +from fhir.resources.bundle import Bundle from .parsers.cda import CDAParser -from .filters import map_system, map_status, format_date, clean_empty +from .filters import format_date, clean_empty log = logging.getLogger(__name__) @@ -52,20 +56,59 @@ def __init__(self, config_dir: Path): self.parser = CDAParser(self.mappings) def _register_filters(self): - # TODO: Can be more configurable """Register custom filters with Liquid environment""" # Create filter functions with access to mappings - def map_system_filter(system): - return map_system(system, self.mappings) - - def map_status_filter(status): - return map_status(status, self.mappings) + def map_system_filter(system, direction="fhir_to_cda"): + """Map between CDA and FHIR code systems + + Args: + system: The system URI/OID to map + direction: Either 'fhir_to_cda' or 'cda_to_fhir' + """ + if not system: + return None + + shared_mappings = self.mappings.get("shared_mappings", {}) + system_mappings = shared_mappings.get("code_systems", {}).get(direction, {}) + return system_mappings.get(system, system) + + def map_status_filter(status, direction="fhir_to_cda"): + """Map between CDA and FHIR status codes + + Args: + status: The status code to map + direction: Either 'fhir_to_cda' or 'cda_to_fhir' + """ + if not status: + return None + + shared_mappings = self.mappings.get("shared_mappings", {}) + status_mappings = shared_mappings.get("status_codes", {}).get(direction, {}) + return status_mappings.get(status, status) + + def json_filter(obj): + if obj is None: + return "[]" + return json.dumps(obj) + + def generate_id_filter(value=None): + """Generate UUID or use provided value""" + return value if value else f"hc-{str(uuid.uuid4())}" + + def format_timestamp_filter(value=None): + """Format timestamp or use current time""" + if value: + return value.strftime("%Y%m%d%H%M%S") + return datetime.now().strftime("%Y%m%d%H%M%S") # Register filters with descriptive names self.env.filters["map_system"] = map_system_filter self.env.filters["map_status"] = map_status_filter self.env.filters["format_date"] = format_date + self.env.filters["json"] = json_filter + self.env.filters["generate_id"] = generate_id_filter + self.env.filters["format_timestamp"] = format_timestamp_filter def _load_mappings(self) -> Dict: """Load all mapping configurations""" @@ -85,7 +128,6 @@ def _load_templates(self) -> Dict: for template_file in (self.config_dir / "templates").rglob("*.liquid"): rel_path = template_file.relative_to(self.config_dir / "templates") template_key = rel_path.stem - try: template = self.env.get_template(str(rel_path)) templates[template_key] = template @@ -102,74 +144,223 @@ def _load_templates(self) -> Dict: return templates def _cda_to_fhir(self, source_data: str) -> List[Resource]: - """Convert CDA XML to FHIR resources""" - resources = [] + """Convert CDA XML to FHIR resources - # Get problems section config - # TODO: read sections from config - section_config = self.mappings["cda_fhir"]["sections"]["problems"] - template_key = section_config["template"].replace(".liquid", "").split("/")[-1] + Args: + source_data: CDA document as XML string - log.debug(f"Using template key: {template_key}") - template = self.templates[template_key] + Returns: + List[Resource]: List of FHIR resources + + Raises: + ValueError: If required mappings are missing or if sections are unsupported + """ + resources = [] - # TODO: maybe parse patient reference from source data and preserve header info - entries = self.parser.parse_section(source_data, section_config) + # Get required configurations + cda_fhir_config = self.mappings.get("cda_fhir", {}) + section_mappings = cda_fhir_config.get("sections", {}) + if not section_mappings: + raise ValueError("No section mappings found in cda_fhir.yaml") - # Convert each entry using template - for entry in entries: + # Parse sections from CDA XML + section_entries = {} + for section_key, section_config in section_mappings.items(): try: - # Render template with entry data - rendered = template.render( - {"entry": entry, "section_config": section_config} + entries = self.parser.parse_section(source_data, section_config) + if entries: + section_entries[section_key] = entries + except Exception as e: + log.error(f"Failed to parse section {section_key}: {str(e)}") + continue + + # Convert entries to FHIR resources + for section_key, entries in section_entries.items(): + section_config = section_mappings[section_key] + template_key = Path(section_config["template"]).stem + + if template_key not in self.templates: + log.warning( + f"Template {template_key} not found, skipping section {section_key}" ) + continue + + template = self.templates[template_key] - # Parse rendered JSON to dict and clean empty values - resource_dict = clean_empty(json.loads(rendered)) - - # Add required fields - if "id" not in resource_dict: - resource_dict["id"] = "hc-" + str(uuid.uuid4()) - if "subject" not in resource_dict: - resource_dict["subject"] = {"reference": "Patient/foo"} - if "clinicalStatus" not in resource_dict: - resource_dict["clinicalStatus"] = { - "coding": [ - { - "system": "http://terminology.hl7.org/CodeSystem/condition-clinical", - "code": "unknown", - } - ] - } - - # Get the FHIR resource class dynamically + # Convert each entry using template + for entry in entries: try: - resource_type = resource_dict["resourceType"] - resource_module = importlib.import_module( - f"fhir.resources.{resource_type.lower()}" + # Render template with entry data + rendered = template.render( + {"entry": entry, "section_config": section_config} ) - resource_class = getattr(resource_module, resource_type) - # Create resource instance - resource = resource_class(**resource_dict) - resources.append(resource) + # Parse rendered JSON and clean empty values + resource_dict = clean_empty(json.loads(rendered)) + + # Add required fields based on resource type + resource_type = section_config["resource"] + self._add_required_fields(resource_dict, resource_type) + + # Create FHIR resource instance + try: + resource_module = importlib.import_module( + f"fhir.resources.{resource_type.lower()}" + ) + resource_class = getattr(resource_module, resource_type) + resource = resource_class(**resource_dict) + resources.append(resource) + except Exception as e: + log.error(f"Failed to create FHIR resource: {str(e)}") + continue + except Exception as e: - log.error(f"Failed to create FHIR resource: {str(e)}") + log.error( + f"Failed to convert entry in section {section_key}: {str(e)}" + ) continue - except Exception as e: - log.error(f"Failed to convert entry: {str(e)}") - continue - return resources + def _add_required_fields(self, resource_dict: Dict, resource_type: str): + """Add required fields to resource dictionary based on type""" + # Add common fields + if "id" not in resource_dict: + resource_dict["id"] = f"hc-{str(uuid.uuid4())}" + if "subject" not in resource_dict: + resource_dict["subject"] = {"reference": "Patient/example"} + + # Add resource-specific required fields + if resource_type == "Condition": + if "clinicalStatus" not in resource_dict: + resource_dict["clinicalStatus"] = { + "coding": [ + { + "system": "http://terminology.hl7.org/CodeSystem/condition-clinical", + "code": "unknown", + } + ] + } + elif resource_type == "MedicationStatement": + if "status" not in resource_dict: + resource_dict["status"] = "unknown" + elif resource_type == "AllergyIntolerance": + if "clinicalStatus" not in resource_dict: + resource_dict["clinicalStatus"] = { + "coding": [ + { + "system": "http://terminology.hl7.org/CodeSystem/allergyintolerance-clinical", + "code": "unknown", + } + ] + } + def _hl7v2_to_fhir(self, source_data: str) -> List[Resource]: """Convert HL7v2 to FHIR resources""" raise NotImplementedError("HL7v2 to FHIR conversion not implemented") - def _fhir_to_cda(self, resources: List[Resource]) -> str: - """Convert FHIR resources to CDA""" - raise NotImplementedError("FHIR to CDA conversion not implemented") + def _fhir_to_cda(self, resources: Union[Resource, List[Resource]]) -> str: + """Convert FHIR resources to CDA XML + + Args: + resources: A FHIR Bundle, list of resources, or single resource + + Returns: + str: CDA document as XML string + + Raises: + ValueError: If required mappings are missing or if resource types are unsupported + """ + # Normalize input to list of resources + resource_list = [] + if isinstance(resources, Bundle): + resource_list.extend( + entry.resource for entry in resources.entry if entry.resource + ) + elif isinstance(resources, list): + resource_list = resources + else: + resource_list = [resources] + + # Get required configurations + sections_config = self.mappings.get("fhir_cda", {}).get("sections", {}) + document_config = self.mappings.get("fhir_cda", {}).get("document", {}) + + if not sections_config or not document_config: + raise ValueError("No section or document mappings found in fhir_cda.yaml") + + # Group resources by section + section_entries = {} + for resource in resource_list: + resource_type = resource.__class__.__name__ + + # Find matching section for resource type + section_key = next( + ( + key + for key, config in sections_config.items() + if config["resource"] == resource_type + ), + None, + ) + + if not section_key: + log.warning(f"Unsupported resource type: {resource_type}") + continue + + # Render Entries + template_name = Path(sections_config[section_key]["template"]).stem + if template_name not in self.templates: + log.warning( + f"Template {template_name} not found, skipping section {section_key}" + ) + continue + + try: + timestamp = datetime.now().strftime(format="%Y%m%d") + reference_name = "#" + str(uuid.uuid4())[:8] + "name" + context = { + "timestamp": timestamp, + "text_reference_name": reference_name, + } + entry_json = self.templates[template_name].render( + resource=resource.model_dump(), context=context + ) + entry = clean_empty(json.loads(entry_json)) + + section_entries.setdefault(section_key, []).append(entry) + except Exception as e: + log.error(f"Failed to render {section_key} entry: {str(e)}") + continue + + # Render sections + formatted_sections = [] + section_template = self.templates["cda_section"] + for section_key, section_config in sections_config.items(): + if section_entries.get(section_key): + try: + section_json = section_template.render( + config=section_config, entries=section_entries[section_key] + ) + formatted_sections.append(json.loads(section_json)) + except Exception as e: + log.error(f"Failed to render section {section_key}: {str(e)}") + continue + + # Create document context + context = { + "bundle": resources if isinstance(resources, Bundle) else None, + "document": document_config, + "formatted_sections": formatted_sections, + } + + # Render document + document_json = self.templates["cda_document"].render(**context) + document_dict = json.loads(document_json) + xml_string = xmltodict.unparse(document_dict, pretty=True) + + # Fix self-closing tags + return re.sub(r"(<(\w+)(\s+[^>]*?)?)>", r"\1/>", xml_string) def _fhir_to_hl7v2(self, resources: List[Resource]) -> str: """Convert FHIR resources to HL7v2""" From 61c8a0a79a9f1f94cbd563974708b39e29e6fc4b Mon Sep 17 00:00:00 2001 From: jenniferjiangkells Date: Tue, 11 Mar 2025 18:20:25 +0000 Subject: [PATCH 03/25] Organize configs and refactor --- .../interop/config/configs/cda/document.yaml | 9 + .../interop/config/configs/cda/section.yaml | 80 +++++ .../interop/config/mappings/cda_fhir.yaml | 26 -- .../interop/config/mappings/fhir_cda.yaml | 54 --- .../config/mappings/shared_mappings.yaml | 1 - ...ergy.liquid => allergy_intolerance.liquid} | 0 .../config/templates/cda/cda_document.liquid | 14 +- .../templates/cda/cda_problem_entry.liquid | 37 +- .../config/templates/cda/cda_section.liquid | 6 +- .../cda/{problem.liquid => condition.liquid} | 2 +- ...ion.liquid => medication_statement.liquid} | 0 healthchain/interop/engine.py | 340 +++++++++++++----- 12 files changed, 360 insertions(+), 209 deletions(-) create mode 100644 healthchain/interop/config/configs/cda/document.yaml create mode 100644 healthchain/interop/config/configs/cda/section.yaml delete mode 100644 healthchain/interop/config/mappings/cda_fhir.yaml delete mode 100644 healthchain/interop/config/mappings/fhir_cda.yaml rename healthchain/interop/config/templates/cda/{allergy.liquid => allergy_intolerance.liquid} (100%) rename healthchain/interop/config/templates/cda/{problem.liquid => condition.liquid} (97%) rename healthchain/interop/config/templates/cda/{medication.liquid => medication_statement.liquid} (100%) diff --git a/healthchain/interop/config/configs/cda/document.yaml b/healthchain/interop/config/configs/cda/document.yaml new file mode 100644 index 00000000..73c760c3 --- /dev/null +++ b/healthchain/interop/config/configs/cda/document.yaml @@ -0,0 +1,9 @@ +# Document level configuration +type: + code: "34133-9" + system: "2.16.840.1.113883.6.1" + display: "Summarization of Episode Note" +confidentiality: + code: "N" + system: "2.16.840.1.113883.5.25" +language: "en-US" diff --git a/healthchain/interop/config/configs/cda/section.yaml b/healthchain/interop/config/configs/cda/section.yaml new file mode 100644 index 00000000..b8ec71e7 --- /dev/null +++ b/healthchain/interop/config/configs/cda/section.yaml @@ -0,0 +1,80 @@ +problems: + resource: "Condition" + resource_template: "cda/condition" + entry_template: "cda/cda_problem_entry" + section_template_id: "2.16.840.1.113883.10.20.1.11" + code: "11450-4" + display: "Problem List" + entry_act_template_ids: + - "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" + entry_act_entryRelationship_observation_template_ids: + - "1.3.6.1.4.1.19376.1.5.3.1.4.5" + - "2.16.840.1.113883.10.20.1.28" + # New configurable options + act_status_code: "active" + entry_relationship_type_code: "SUBJ" + entry_relationship_inversion_ind: false + observation_code: "55607006" + observation_code_system: "2.16.840.1.113883.6.96" + observation_code_system_name: "SNOMED CT" + observation_display_name: "Problem" + observation_status_code: "completed" + status_loinc_code: "33999-4" + status_code_system: "2.16.840.1.113883.6.1" + status_display_name: "Status" + status_observation_status_code: "completed" + +medications: + resource: "MedicationStatement" + resource_template: "cda/medication_statement" + entry_template: "cda/cda_medication_entry" + section_template_id: "2.16.840.1.113883.10.20.1.8" + code: "10160-0" + display: "Medications" + entry_act_template_ids: + - "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" + # New configurable options + act_status_code: "active" + entry_relationship_type_code: "SUBJ" + entry_relationship_inversion_ind: false + observation_code: "10160-0" + observation_code_system: "2.16.840.1.113883.6.1" + observation_code_system_name: "LOINC" + observation_display_name: "Medication" + observation_status_code: "completed" + status_code_system: "2.16.840.1.113883.6.1" + status_display_name: "Status" + status_observation_status_code: "completed" + +allergies: + resource: "AllergyIntolerance" + resource_template: "cda/allergy_intolerance" + entry_template: "cda/cda_allergy_entry" + section_template_id: "2.16.840.1.113883.10.20.1.2" + code: "48765-2" + display: "Allergies" + entry_act_template_ids: + - "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" + # New configurable options + act_status_code: "active" + entry_relationship_type_code: "SUBJ" + entry_relationship_inversion_ind: false + observation_code: "48765-2" + observation_code_system: "2.16.840.1.113883.6.1" + observation_code_system_name: "LOINC" + observation_display_name: "Allergy" + observation_status_code: "completed" + status_code_system: "2.16.840.1.113883.6.1" + status_display_name: "Status" + status_observation_status_code: "completed" diff --git a/healthchain/interop/config/mappings/cda_fhir.yaml b/healthchain/interop/config/mappings/cda_fhir.yaml deleted file mode 100644 index e09bdacc..00000000 --- a/healthchain/interop/config/mappings/cda_fhir.yaml +++ /dev/null @@ -1,26 +0,0 @@ -# CDA to FHIR section definitions -sections: - problems: - template: cda/problem.liquid - template_id: "2.16.840.1.113883.10.20.1.11" - code: "11450-4" - resource: "Condition" - status_loinc_code: "33999-4" - - medications: - template: cda/medication.liquid - template_id: "2.16.840.1.113883.10.20.1.8" - code: "10160-0" - resource: "MedicationStatement" - - allergies: - template: cda/allergy.liquid - template_id: "2.16.840.1.113883.10.20.1.2" - code: "48765-2" - resource: "AllergyIntolerance" - - notes: - template: cda/note.liquid - template_id: "1.2.840.114350.1.72.1.200001" - code: "51847-2" - resource: "DocumentReference" diff --git a/healthchain/interop/config/mappings/fhir_cda.yaml b/healthchain/interop/config/mappings/fhir_cda.yaml deleted file mode 100644 index a326b964..00000000 --- a/healthchain/interop/config/mappings/fhir_cda.yaml +++ /dev/null @@ -1,54 +0,0 @@ -# Document level configuration -document: - type: - code: "34133-9" - system: "2.16.840.1.113883.6.1" - display: "Summarization of Episode Note" - confidentiality: - code: "N" - system: "2.16.840.1.113883.5.25" - language: "en-US" - -# Section definitions -sections: - problems: - resource: "Condition" - template: "cda/cda_problem_entry" - template_id: "2.16.840.1.113883.10.20.1.11" - code: "11450-4" - display: "Problem List" - status_loinc_code: "33999-4" - entry_template_ids: - - "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" - observation_template_ids: - - "1.3.6.1.4.1.19376.1.5.3.1.4.5" - - "2.16.840.1.113883.10.20.1.28" - - medications: - resource: "MedicationStatement" - template: "cda/cda_medication_entry" - template_id: "2.16.840.1.113883.10.20.1.8" - code: "10160-0" - display: "Medications" - entry_template_ids: - - "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" - - allergies: - resource: "AllergyIntolerance" - template: "cda/cda_allergy_entry" - template_id: "2.16.840.1.113883.10.20.1.2" - code: "48765-2" - display: "Allergies" - entry_template_ids: - - "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" diff --git a/healthchain/interop/config/mappings/shared_mappings.yaml b/healthchain/interop/config/mappings/shared_mappings.yaml index 98bf386b..f922feae 100644 --- a/healthchain/interop/config/mappings/shared_mappings.yaml +++ b/healthchain/interop/config/mappings/shared_mappings.yaml @@ -1,5 +1,4 @@ # Shared mappings between CDA and FHIR formats - code_systems: cda_to_fhir: "2.16.840.1.113883.6.96": "http://snomed.info/sct" diff --git a/healthchain/interop/config/templates/cda/allergy.liquid b/healthchain/interop/config/templates/cda/allergy_intolerance.liquid similarity index 100% rename from healthchain/interop/config/templates/cda/allergy.liquid rename to healthchain/interop/config/templates/cda/allergy_intolerance.liquid diff --git a/healthchain/interop/config/templates/cda/cda_document.liquid b/healthchain/interop/config/templates/cda/cda_document.liquid index 80eb391e..8c333a42 100644 --- a/healthchain/interop/config/templates/cda/cda_document.liquid +++ b/healthchain/interop/config/templates/cda/cda_document.liquid @@ -6,25 +6,25 @@ "@root": "{{ bundle.identifier.value | generate_id }}" }, "code": { - "@code": "{{ document.type.code }}", - "@codeSystem": "{{ document.type.system }}", + "@code": "{{ config.type.code }}", + "@codeSystem": "{{ config.type.system }}", "@codeSystemName": "LOINC", - "@displayName": "{{ document.type.display }}" + "@displayName": "{{ config.type.display }}" }, "title": "Clinical Document", "effectiveTime": { "@value": "{{ bundle.timestamp | format_timestamp }}" }, "confidentialityCode": { - "@code": "{{ document.confidentiality.code }}", - "@codeSystem": "{{ document.confidentiality.system }}" + "@code": "{{ config.confidentiality.code }}", + "@codeSystem": "{{ config.confidentiality.system }}" }, "languageCode": { - "@code": "{{ document.language }}" + "@code": "{{ config.language }}" }, "component": { "structuredBody": { - "component": {{ formatted_sections | json }} + "component": {{ sections | json }} } } } diff --git a/healthchain/interop/config/templates/cda/cda_problem_entry.liquid b/healthchain/interop/config/templates/cda/cda_problem_entry.liquid index 40b17183..97dab8c3 100644 --- a/healthchain/interop/config/templates/cda/cda_problem_entry.liquid +++ b/healthchain/interop/config/templates/cda/cda_problem_entry.liquid @@ -3,41 +3,40 @@ "@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"} + {% for template_id in config.entry_act_template_ids %} + {"@root": "{{template_id}}"} {% if forloop.last != true %},{% endif %} + {% endfor %} ], "id": {"@root": "{{ resource.id | generate_id }}"}, "code": {"@nullFlavor": "NA"}, "statusCode": { - "@code": "active" + "@code": "{{ config.act_status_code | default: 'active' }}" }, "effectiveTime": { "low": {"@value": "{{ context.timestamp }}"} }, "entryRelationship": { - "@typeCode": "SUBJ", - "@inversionInd": false, + "@typeCode": "{{ config.entry_relationship_type_code | default: 'SUBJ' }}", + "@inversionInd": {{ config.entry_relationship_inversion_ind | default: 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"} + {% for template_id in config.entry_act_entryRelationship_observation_template_ids %} + {"@root": "{{template_id}}"} {% if forloop.last != true %},{% endif %} + {% endfor %} ], "id": {"@root": "{{ resource.id }}_obs"}, "code": { - "@code": "55607006", - "@codeSystem": "2.16.840.1.113883.6.96", - "@codeSystemName": "SNOMED CT", - "@displayName": "Problem" + "@code": "{{ config.observation_code | default: '55607006' }}", + "@codeSystem": "{{ config.observation_code_system | default: '2.16.840.1.113883.6.96' }}", + "@codeSystemName": "{{ config.observation_code_system_name | default: 'SNOMED CT' }}", + "@displayName": "{{ config.observation_display_name | default: 'Problem' }}" }, "text": { "reference": {"@value": "#{{ context.text_reference_name }}"} }, - "statusCode": {"@code": "completed"}, + "statusCode": {"@code": "{{ config.observation_status_code | default: 'completed' }}"}, "effectiveTime": { {% if resource.onsetDateTime %} "low": {"@value": "{{ resource.onsetDateTime }}"} @@ -62,9 +61,9 @@ "@classCode": "OBS", "@moodCode": "EVN", "code": { - "@code": "33999-4", - "@codeSystem": "2.16.840.1.113883.6.1", - "@displayName": "Status" + "@code": "{{ config.status_loinc_code | default: '33999-4' }}", + "@codeSystem": "{{ config.status_code_system | default: '2.16.840.1.113883.6.1' }}", + "@displayName": "{{ config.status_display_name | default: 'Status' }}" }, "value": { "@xmlns:xsi": "http://www.w3.org/2001/XMLSchema-instance", @@ -73,7 +72,7 @@ "@displayName": "{{ resource.clinicalStatus.coding[0].display }}", "@xsi:type": "CE" }, - "statusCode": {"@code": "completed"}, + "statusCode": {"@code": "{{ config.status_observation_status_code | default: 'completed' }}"}, "effectiveTime": { "low": {"@value": "{{ context.timestamp }}"} } diff --git a/healthchain/interop/config/templates/cda/cda_section.liquid b/healthchain/interop/config/templates/cda/cda_section.liquid index 982c49b0..726a014d 100644 --- a/healthchain/interop/config/templates/cda/cda_section.liquid +++ b/healthchain/interop/config/templates/cda/cda_section.liquid @@ -1,12 +1,12 @@ { "section": { "templateId": { - "@root": "{{ config.template_id }}" + "@root": "{{ config.section_template_id }}" }, "code": { "@code": "{{ config.code }}", - "@codeSystem": "2.16.840.1.113883.6.1", - "@codeSystemName": "LOINC", + "@codeSystem": "{{ config.code_system | default: '2.16.840.1.113883.6.1' }}", + "@codeSystemName": "{{config.code_system_name | default: 'LOINC' }}", "@displayName": "{{ config.display }}" }, "title": "{{ config.display }}", diff --git a/healthchain/interop/config/templates/cda/problem.liquid b/healthchain/interop/config/templates/cda/condition.liquid similarity index 97% rename from healthchain/interop/config/templates/cda/problem.liquid rename to healthchain/interop/config/templates/cda/condition.liquid index f7b41d1d..f265be29 100644 --- a/healthchain/interop/config/templates/cda/problem.liquid +++ b/healthchain/interop/config/templates/cda/condition.liquid @@ -5,7 +5,7 @@ {% else %} {% assign actEntryRelationship = entry.act.entryRelationship %} {% endif %} - {% if actEntryRelationship.observation.entryRelationship.observation.code['@code'] == section_config.status_loinc_code %} + {% if actEntryRelationship.observation.entryRelationship.observation.code['@code'] == config.status_loinc_code %} {% if actEntryRelationship.observation.entryRelationship.observation.value %} "clinicalStatus": { "coding": [ diff --git a/healthchain/interop/config/templates/cda/medication.liquid b/healthchain/interop/config/templates/cda/medication_statement.liquid similarity index 100% rename from healthchain/interop/config/templates/cda/medication.liquid rename to healthchain/interop/config/templates/cda/medication_statement.liquid diff --git a/healthchain/interop/engine.py b/healthchain/interop/engine.py index ce59204b..b3a16197 100644 --- a/healthchain/interop/engine.py +++ b/healthchain/interop/engine.py @@ -7,7 +7,7 @@ import xmltodict from enum import Enum -from typing import Dict, List, Union +from typing import Dict, List, Union, Optional from pathlib import Path from datetime import datetime @@ -42,7 +42,8 @@ class InteropEngine: def __init__(self, config_dir: Path): self.config_dir = config_dir - self.mappings = self._load_mappings() + self.mappings = self._load_configs("mappings") + self.configs = self._load_configs("configs") # Create Liquid environment with loader and custom filters template_dir = self.config_dir / "templates" @@ -110,15 +111,15 @@ def format_timestamp_filter(value=None): self.env.filters["generate_id"] = generate_id_filter self.env.filters["format_timestamp"] = format_timestamp_filter - def _load_mappings(self) -> Dict: - """Load all mapping configurations""" - mappings = {} - mapping_dir = self.config_dir / "mappings" - for mapping_file in mapping_dir.glob("*.yaml"): - with open(mapping_file) as f: - mappings[mapping_file.stem] = yaml.safe_load(f) + def _load_configs(self, directory: str) -> Dict: + """Load all configuration files""" + configs = {} + config_dir = self.config_dir / directory + for config_file in config_dir.rglob("*.yaml"): + with open(config_file) as f: + configs[config_file.stem] = yaml.safe_load(f) - return mappings + return configs def _load_templates(self) -> Dict: """Load all liquid templates""" @@ -155,17 +156,31 @@ def _cda_to_fhir(self, source_data: str) -> List[Resource]: Raises: ValueError: If required mappings are missing or if sections are unsupported """ - resources = [] - # Get required configurations - cda_fhir_config = self.mappings.get("cda_fhir", {}) - section_mappings = cda_fhir_config.get("sections", {}) - if not section_mappings: - raise ValueError("No section mappings found in cda_fhir.yaml") + section_configs = self.configs.get("section", {}) + + if not section_configs: + raise ValueError("No section configs found in configs/cda/section.yaml") # Parse sections from CDA XML + section_entries = self._parse_cda_sections(source_data, section_configs) + + # Convert entries to FHIR resources + return self._convert_entries_to_fhir(section_entries, section_configs) + + def _parse_cda_sections(self, source_data: str, section_configs: Dict) -> Dict: + """Parse sections from CDA XML document + + Args: + source_data: CDA document as XML string + section_configs: Configuration for each section + + Returns: + Dict: Dictionary mapping section keys to their entries + """ section_entries = {} - for section_key, section_config in section_mappings.items(): + + for section_key, section_config in section_configs.items(): try: entries = self.parser.parse_section(source_data, section_config) if entries: @@ -174,10 +189,25 @@ def _cda_to_fhir(self, source_data: str) -> List[Resource]: log.error(f"Failed to parse section {section_key}: {str(e)}") continue - # Convert entries to FHIR resources + return section_entries + + def _convert_entries_to_fhir( + self, section_entries: Dict, section_configs: Dict + ) -> List[Resource]: + """Convert parsed CDA entries to FHIR resources + + Args: + section_entries: Dictionary mapping section keys to their entries + section_configs: Configuration for each section + + Returns: + List[Resource]: List of FHIR resources + """ + resources = [] + for section_key, entries in section_entries.items(): - section_config = section_mappings[section_key] - template_key = Path(section_config["template"]).stem + section_config = section_configs[section_key] + template_key = Path(section_config["resource_template"]).stem if template_key not in self.templates: log.warning( @@ -187,41 +217,96 @@ def _cda_to_fhir(self, source_data: str) -> List[Resource]: template = self.templates[template_key] - # Convert each entry using template - for entry in entries: - try: - # Render template with entry data - rendered = template.render( - {"entry": entry, "section_config": section_config} - ) - - # Parse rendered JSON and clean empty values - resource_dict = clean_empty(json.loads(rendered)) - - # Add required fields based on resource type - resource_type = section_config["resource"] - self._add_required_fields(resource_dict, resource_type) - - # Create FHIR resource instance - try: - resource_module = importlib.import_module( - f"fhir.resources.{resource_type.lower()}" - ) - resource_class = getattr(resource_module, resource_type) - resource = resource_class(**resource_dict) - resources.append(resource) - except Exception as e: - log.error(f"Failed to create FHIR resource: {str(e)}") - continue - - except Exception as e: - log.error( - f"Failed to convert entry in section {section_key}: {str(e)}" - ) - continue + # Process each entry in the section + section_resources = self._process_section_entries( + entries, template, section_config, section_key + ) + resources.extend(section_resources) return resources + def _process_section_entries( + self, entries: List[Dict], template, section_config: Dict, section_key: str + ) -> List[Resource]: + """Process entries from a single section and convert to FHIR resources + + Args: + entries: List of entries from a section + template: The template to use for rendering + section_config: Configuration for the section + section_key: Key identifying the section + + Returns: + List[Resource]: List of FHIR resources from this section + """ + resources = [] + resource_type = section_config["resource"] + + for entry in entries: + try: + # Convert entry to FHIR resource dictionary + resource_dict = self._render_and_process_entry( + entry, template, section_config + ) + + # Create FHIR resource instance + resource = self._create_fhir_resource(resource_dict, resource_type) + if resource: + resources.append(resource) + + except Exception as e: + log.error(f"Failed to convert entry in section {section_key}: {str(e)}") + continue + + return resources + + def _render_and_process_entry( + self, entry: Dict, template, section_config: Dict + ) -> Dict: + """Render an entry using a template and process the result + + Args: + entry: The entry data + template: The template to use for rendering + section_config: Configuration for the section + + Returns: + Dict: Processed resource dictionary + """ + # Render template with entry data and config + rendered = template.render({"entry": entry, "config": section_config}) + + # Parse rendered JSON and clean empty values + resource_dict = clean_empty(json.loads(rendered)) + + # Add required fields based on resource type + resource_type = section_config["resource"] + self._add_required_fields(resource_dict, resource_type) + + return resource_dict + + def _create_fhir_resource( + self, 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: + log.error(f"Failed to create FHIR resource: {str(e)}") + return None + def _add_required_fields(self, resource_dict: Dict, resource_type: str): """Add required fields to resource dictionary based on type""" # Add common fields @@ -272,33 +357,53 @@ def _fhir_to_cda(self, resources: Union[Resource, List[Resource]]) -> str: ValueError: If required mappings are missing or if resource types are unsupported """ # Normalize input to list of resources - resource_list = [] - if isinstance(resources, Bundle): - resource_list.extend( - entry.resource for entry in resources.entry if entry.resource - ) - elif isinstance(resources, list): - resource_list = resources - else: - resource_list = [resources] + resource_list = self._normalize_resources(resources) # Get required configurations - sections_config = self.mappings.get("fhir_cda", {}).get("sections", {}) - document_config = self.mappings.get("fhir_cda", {}).get("document", {}) + section_configs = self.configs.get("section", {}) + document_config = self.configs.get("document", {}) + + if not section_configs or not document_config: + raise ValueError("No section or document configs found in configs/cda") + + # Process resources and generate section entries + section_entries = self._process_resources_to_section_entries( + resource_list, section_configs + ) + + # Render sections + formatted_sections = self._render_sections(section_entries, section_configs) - if not sections_config or not document_config: - raise ValueError("No section or document mappings found in fhir_cda.yaml") + # Generate final CDA document + return self._generate_cda_document( + resources, document_config, formatted_sections + ) - # Group resources by section + def _normalize_resources( + self, resources: Union[Resource, List[Resource]] + ) -> 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] + + def _process_resources_to_section_entries( + self, resources: List[Resource], section_configs: Dict + ) -> Dict: + """Process resources and group them by section with rendered entries""" section_entries = {} - for resource in resource_list: + + for resource in resources: resource_type = resource.__class__.__name__ # Find matching section for resource type section_key = next( ( key - for key, config in sections_config.items() + for key, config in section_configs.items() if config["resource"] == resource_type ), None, @@ -308,50 +413,89 @@ def _fhir_to_cda(self, resources: Union[Resource, List[Resource]]) -> str: log.warning(f"Unsupported resource type: {resource_type}") continue - # Render Entries - template_name = Path(sections_config[section_key]["template"]).stem + # Get template for this section + template_name = Path(section_configs[section_key]["entry_template"]).stem if template_name not in self.templates: log.warning( f"Template {template_name} not found, skipping section {section_key}" ) continue - try: - timestamp = datetime.now().strftime(format="%Y%m%d") - reference_name = "#" + str(uuid.uuid4())[:8] + "name" - context = { - "timestamp": timestamp, - "text_reference_name": reference_name, - } - entry_json = self.templates[template_name].render( - resource=resource.model_dump(), context=context - ) - entry = clean_empty(json.loads(entry_json)) - + # Render entry using template + entry = self._render_entry( + resource, section_key, template_name, section_configs[section_key] + ) + if entry: section_entries.setdefault(section_key, []).append(entry) - except Exception as e: - log.error(f"Failed to render {section_key} entry: {str(e)}") - continue - # Render sections + return section_entries + + def _render_entry( + self, + resource: Resource, + section_key: str, + template_name: str, + section_config: Dict, + ) -> Dict: + """Render a single entry for a resource""" + try: + # Create context with common values + timestamp = datetime.now().strftime(format="%Y%m%d") + reference_name = "#" + str(uuid.uuid4())[:8] + "name" + context = { + "timestamp": timestamp, + "text_reference_name": reference_name, + } + + # Render template + entry_json = self.templates[template_name].render( + resource=resource.model_dump(), + config=section_config, + context=context, + ) + + # Parse and clean the rendered JSON + return clean_empty(json.loads(entry_json)) + + except Exception as e: + log.error(f"Failed to render {section_key} entry: {str(e)}") + return None + + def _render_sections( + self, section_entries: Dict, section_configs: Dict + ) -> List[Dict]: + """Render all sections with their entries""" formatted_sections = [] section_template = self.templates["cda_section"] - for section_key, section_config in sections_config.items(): - if section_entries.get(section_key): - try: - section_json = section_template.render( - config=section_config, entries=section_entries[section_key] - ) - formatted_sections.append(json.loads(section_json)) - except Exception as e: - log.error(f"Failed to render section {section_key}: {str(e)}") - continue + for section_key, section_config in section_configs.items(): + entries = section_entries.get(section_key, []) + if not entries: + continue + + try: + section_json = section_template.render( + entries=entries, + config=section_config, + ) + formatted_sections.append(json.loads(section_json)) + except Exception as e: + log.error(f"Failed to render section {section_key}: {str(e)}") + + return formatted_sections + + def _generate_cda_document( + self, + resources: Union[Resource, List[Resource]], + document_config: Dict, + formatted_sections: List[Dict], + ) -> str: + """Generate the final CDA document""" # Create document context context = { "bundle": resources if isinstance(resources, Bundle) else None, - "document": document_config, - "formatted_sections": formatted_sections, + "config": document_config, + "sections": formatted_sections, } # Render document From 15e4f3afea2fbda83ecbc2fc1ad305cb1d8842e9 Mon Sep 17 00:00:00 2001 From: jenniferjiangkells Date: Wed, 12 Mar 2025 20:19:28 +0000 Subject: [PATCH 04/25] stuff happened and i think it's more modular now or something --- healthchain/interop/__init__.py | 28 +- .../interop/config/configs/cda/document.yaml | 9 - .../interop/config/configs/cda/section.yaml | 80 -- .../interop/config/configs/defaults.yaml | 89 +++ .../interop/config/configs/development.yaml | 33 + .../interop/config/configs/document/cda.yaml | 76 ++ .../interop/config/configs/production.yaml | 37 + .../config/configs/sections/allergies.yaml | 42 + .../config/configs/sections/medications.yaml | 44 + .../config/configs/sections/problems.yaml | 49 ++ .../interop/config/configs/testing.yaml | 39 + .../config/templates/cda/cda_document.liquid | 24 +- .../config/templates/cda/condition.liquid | 2 +- healthchain/interop/config_manager.py | 460 +++++++++++ healthchain/interop/converters/__init__.py | 9 + healthchain/interop/converters/fhir.py | 315 ++++++++ healthchain/interop/engine.py | 750 ++++++++---------- healthchain/interop/filters.py | 126 ++- healthchain/interop/generators/__init__.py | 10 + healthchain/interop/generators/cda.py | 198 +++++ healthchain/interop/generators/hl7v2.py | 100 +++ healthchain/interop/models/cda.py | 1 + healthchain/interop/parsers/__init__.py | 10 + healthchain/interop/parsers/cda.py | 75 +- healthchain/interop/parsers/hl7v2.py | 72 ++ healthchain/interop/template_registry.py | 161 ++++ 26 files changed, 2313 insertions(+), 526 deletions(-) delete mode 100644 healthchain/interop/config/configs/cda/document.yaml delete mode 100644 healthchain/interop/config/configs/cda/section.yaml create mode 100644 healthchain/interop/config/configs/defaults.yaml create mode 100644 healthchain/interop/config/configs/development.yaml create mode 100644 healthchain/interop/config/configs/document/cda.yaml create mode 100644 healthchain/interop/config/configs/production.yaml create mode 100644 healthchain/interop/config/configs/sections/allergies.yaml create mode 100644 healthchain/interop/config/configs/sections/medications.yaml create mode 100644 healthchain/interop/config/configs/sections/problems.yaml create mode 100644 healthchain/interop/config/configs/testing.yaml create mode 100644 healthchain/interop/config_manager.py create mode 100644 healthchain/interop/converters/__init__.py create mode 100644 healthchain/interop/converters/fhir.py create mode 100644 healthchain/interop/generators/__init__.py create mode 100644 healthchain/interop/generators/cda.py create mode 100644 healthchain/interop/generators/hl7v2.py create mode 100644 healthchain/interop/parsers/__init__.py create mode 100644 healthchain/interop/parsers/hl7v2.py create mode 100644 healthchain/interop/template_registry.py diff --git a/healthchain/interop/__init__.py b/healthchain/interop/__init__.py index 3f3dd70a..5551b330 100644 --- a/healthchain/interop/__init__.py +++ b/healthchain/interop/__init__.py @@ -1,3 +1,27 @@ -from .engine import InteropEngine +""" +HealthChain Interoperability Module -__all__ = ["InteropEngine"] +This module provides functionality for interoperability between different healthcare data formats. +""" + +from .engine import InteropEngine, FormatType +from .config_manager import ConfigManager, ValidationLevel +from .template_registry import TemplateRegistry +from .parsers.cda import CDAParser +from .parsers.hl7v2 import HL7v2Parser +from .converters.fhir import FHIRConverter +from .generators.cda import CDAGenerator +from .generators.hl7v2 import HL7v2Generator + +__all__ = [ + "InteropEngine", + "FormatType", + "ConfigManager", + "ValidationLevel", + "TemplateRegistry", + "CDAParser", + "HL7v2Parser", + "FHIRConverter", + "CDAGenerator", + "HL7v2Generator", +] diff --git a/healthchain/interop/config/configs/cda/document.yaml b/healthchain/interop/config/configs/cda/document.yaml deleted file mode 100644 index 73c760c3..00000000 --- a/healthchain/interop/config/configs/cda/document.yaml +++ /dev/null @@ -1,9 +0,0 @@ -# Document level configuration -type: - code: "34133-9" - system: "2.16.840.1.113883.6.1" - display: "Summarization of Episode Note" -confidentiality: - code: "N" - system: "2.16.840.1.113883.5.25" -language: "en-US" diff --git a/healthchain/interop/config/configs/cda/section.yaml b/healthchain/interop/config/configs/cda/section.yaml deleted file mode 100644 index b8ec71e7..00000000 --- a/healthchain/interop/config/configs/cda/section.yaml +++ /dev/null @@ -1,80 +0,0 @@ -problems: - resource: "Condition" - resource_template: "cda/condition" - entry_template: "cda/cda_problem_entry" - section_template_id: "2.16.840.1.113883.10.20.1.11" - code: "11450-4" - display: "Problem List" - entry_act_template_ids: - - "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" - entry_act_entryRelationship_observation_template_ids: - - "1.3.6.1.4.1.19376.1.5.3.1.4.5" - - "2.16.840.1.113883.10.20.1.28" - # New configurable options - act_status_code: "active" - entry_relationship_type_code: "SUBJ" - entry_relationship_inversion_ind: false - observation_code: "55607006" - observation_code_system: "2.16.840.1.113883.6.96" - observation_code_system_name: "SNOMED CT" - observation_display_name: "Problem" - observation_status_code: "completed" - status_loinc_code: "33999-4" - status_code_system: "2.16.840.1.113883.6.1" - status_display_name: "Status" - status_observation_status_code: "completed" - -medications: - resource: "MedicationStatement" - resource_template: "cda/medication_statement" - entry_template: "cda/cda_medication_entry" - section_template_id: "2.16.840.1.113883.10.20.1.8" - code: "10160-0" - display: "Medications" - entry_act_template_ids: - - "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" - # New configurable options - act_status_code: "active" - entry_relationship_type_code: "SUBJ" - entry_relationship_inversion_ind: false - observation_code: "10160-0" - observation_code_system: "2.16.840.1.113883.6.1" - observation_code_system_name: "LOINC" - observation_display_name: "Medication" - observation_status_code: "completed" - status_code_system: "2.16.840.1.113883.6.1" - status_display_name: "Status" - status_observation_status_code: "completed" - -allergies: - resource: "AllergyIntolerance" - resource_template: "cda/allergy_intolerance" - entry_template: "cda/cda_allergy_entry" - section_template_id: "2.16.840.1.113883.10.20.1.2" - code: "48765-2" - display: "Allergies" - entry_act_template_ids: - - "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" - # New configurable options - act_status_code: "active" - entry_relationship_type_code: "SUBJ" - entry_relationship_inversion_ind: false - observation_code: "48765-2" - observation_code_system: "2.16.840.1.113883.6.1" - observation_code_system_name: "LOINC" - observation_display_name: "Allergy" - observation_status_code: "completed" - status_code_system: "2.16.840.1.113883.6.1" - status_display_name: "Status" - status_observation_status_code: "completed" diff --git a/healthchain/interop/config/configs/defaults.yaml b/healthchain/interop/config/configs/defaults.yaml new file mode 100644 index 00000000..16bbb0b3 --- /dev/null +++ b/healthchain/interop/config/configs/defaults.yaml @@ -0,0 +1,89 @@ +# HealthChain Interoperability Engine Default Configuration +# This file contains default values used throughout the engine + +# Default resource fields +defaults: + # Common defaults for all resources + common: + id_prefix: "hc-" + subject: + reference: "Patient/example" + + # Resource-specific defaults + resources: + Condition: + clinicalStatus: + coding: + - system: "http://terminology.hl7.org/CodeSystem/condition-clinical" + code: "unknown" + display: "Unknown" + verificationStatus: + coding: + - system: "http://terminology.hl7.org/CodeSystem/condition-ver-status" + code: "unconfirmed" + display: "Unconfirmed" + + MedicationStatement: + status: "unknown" + effectiveDateTime: "{{ now | date: '%Y-%m-%d' }}" + medicationCodeableConcept: + coding: + - system: "http://terminology.hl7.org/CodeSystem/v3-NullFlavor" + code: "UNK" + display: "Unknown" + + AllergyIntolerance: + clinicalStatus: + coding: + - system: "http://terminology.hl7.org/CodeSystem/allergyintolerance-clinical" + code: "unknown" + display: "Unknown" + verificationStatus: + coding: + - system: "http://terminology.hl7.org/CodeSystem/allergyintolerance-verification" + code: "unconfirmed" + display: "Unconfirmed" + type: "allergy" + criticality: "low" + +# TODO: Implement +formats: + # Date and time formats + date: + timestamp: "%Y%m%d" + datetime: "%Y-%m-%dT%H:%M:%SZ" + date: "%Y-%m-%d" + time: "%H:%M:%S" + + # ID and reference formats + ids: + resource: "hc-{uuid}" + reference_name: "#{uuid}name" + section: "section-{key}" + +# 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/healthchain/interop/config/configs/development.yaml b/healthchain/interop/config/configs/development.yaml new file mode 100644 index 00000000..a143a927 --- /dev/null +++ b/healthchain/interop/config/configs/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/dev-example" + +# Template settings for development +templates: + reload_on_change: true # Automatically reload templates when they change diff --git a/healthchain/interop/config/configs/document/cda.yaml b/healthchain/interop/config/configs/document/cda.yaml new file mode 100644 index 00000000..048af139 --- /dev/null +++ b/healthchain/interop/config/configs/document/cda.yaml @@ -0,0 +1,76 @@ +# CDA Document Configuration +# This file contains configuration for CDA documents + +# 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: true + include_author: true + include_custodian: true + include_legal_authenticator: false + + # Body configuration + body: + structured_body: true + non_xml_body: false + +# Default values +defaults: + # Default patient information + patient: + id: "example" + id_root: "2.16.840.1.113883.19.5" + name: + given: "John" + family: "Doe" + gender: "M" + birth_date: "19700101" + + # Default author information + author: + id: "author1" + id_root: "2.16.840.1.113883.19.5" + name: + given: "Jane" + family: "Smith" + organization: + id: "org1" + name: "HealthChain Organization" + + # Default custodian information + custodian: + id: "custodian1" + id_root: "2.16.840.1.113883.19.5" + organization: + id: "org1" + name: "HealthChain Organization" + +# Rendering configuration +rendering: + # XML formatting + xml: + pretty_print: true + encoding: "UTF-8" + + # Narrative generation + narrative: + include: true + generate_if_missing: true diff --git a/healthchain/interop/config/configs/production.yaml b/healthchain/interop/config/configs/production.yaml new file mode 100644 index 00000000..846a914c --- /dev/null +++ b/healthchain/interop/config/configs/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/healthchain/interop/config/configs/sections/allergies.yaml b/healthchain/interop/config/configs/sections/allergies.yaml new file mode 100644 index 00000000..1d681c09 --- /dev/null +++ b/healthchain/interop/config/configs/sections/allergies.yaml @@ -0,0 +1,42 @@ +# Allergies Section Configuration +# This file contains configuration for the allergies section +# TODO: Implement + +# Basic section information +resource: "AllergyIntolerance" +resource_template: "cda/allergy_intolerance" +entry_template: "cda/cda_allergy_entry" +section_template_id: "2.16.840.1.113883.10.20.1.2" +code: "48765-2" +display: "Allergies" + +# Entry act template IDs +entry_act_template_ids: + - "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 codes and other configurable options +act_status_code: "active" +entry_relationship_type_code: "SUBJ" +entry_relationship_inversion_ind: false +observation_code: "48765-2" +observation_code_system: "2.16.840.1.113883.6.1" +observation_code_system_name: "LOINC" +observation_display_name: "Allergy" +observation_status_code: "completed" +status_code_system: "2.16.840.1.113883.6.1" +status_display_name: "Status" +status_observation_status_code: "completed" + +# Rendering configuration +rendering: + narrative: + include: true + template: "narratives/allergy_narrative" + entry: + include_status: true + include_reaction: true + include_severity: true + include_dates: true diff --git a/healthchain/interop/config/configs/sections/medications.yaml b/healthchain/interop/config/configs/sections/medications.yaml new file mode 100644 index 00000000..74d6f8f7 --- /dev/null +++ b/healthchain/interop/config/configs/sections/medications.yaml @@ -0,0 +1,44 @@ +# Medications Section Configuration +# This file contains configuration for the medications section +# TODO: Implement + +# Basic section information +resource: "MedicationStatement" +resource_template: "cda/medication_statement" +entry_template: "cda/cda_medication_entry" +section_template_id: "2.16.840.1.113883.10.20.1.8" +code: "10160-0" +display: "Medications" + +# Entry act template IDs +entry_act_template_ids: + - "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 codes and other configurable options +act_status_code: "active" +entry_relationship_type_code: "SUBJ" +entry_relationship_inversion_ind: false +observation_code: "10160-0" +observation_code_system: "2.16.840.1.113883.6.1" +observation_code_system_name: "LOINC" +observation_display_name: "Medication" +observation_status_code: "completed" +status_code_system: "2.16.840.1.113883.6.1" +status_display_name: "Status" +status_observation_status_code: "completed" + +# 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 diff --git a/healthchain/interop/config/configs/sections/problems.yaml b/healthchain/interop/config/configs/sections/problems.yaml new file mode 100644 index 00000000..11ff580d --- /dev/null +++ b/healthchain/interop/config/configs/sections/problems.yaml @@ -0,0 +1,49 @@ +# Problems Section Configuration +# This file contains configuration for the problems section + +# TODO: Make code hierarchical + +# Basic section information +resource: "Condition" +resource_template: "cda/condition" +entry_template: "cda/cda_problem_entry" + +section_template_id: "2.16.840.1.113883.10.20.1.11" +code: "11450-4" +display: "Problem List" + +# Entry act template IDs +entry_act_template_ids: + - "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" + +# Entry act entryRelationship observation template IDs +entry_act_entryRelationship_observation_template_ids: + - "1.3.6.1.4.1.19376.1.5.3.1.4.5" + - "2.16.840.1.113883.10.20.1.28" + +# Status codes and other configurable options +act_status_code: "active" +entry_relationship_type_code: "SUBJ" +entry_relationship_inversion_ind: false +observation_code: "55607006" +observation_code_system: "2.16.840.1.113883.6.96" +observation_code_system_name: "SNOMED CT" +observation_display_name: "Problem" +observation_status_code: "completed" +status_loinc_code: "33999-4" +status_code_system: "2.16.840.1.113883.6.1" +status_display_name: "Status" +status_observation_status_code: "completed" + +# Rendering configuration +rendering: + narrative: + include: true + template: "narratives/problem_narrative" + entry: + include_status: true + include_dates: true diff --git a/healthchain/interop/config/configs/testing.yaml b/healthchain/interop/config/configs/testing.yaml new file mode 100644 index 00000000..2c6aca95 --- /dev/null +++ b/healthchain/interop/config/configs/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/healthchain/interop/config/templates/cda/cda_document.liquid b/healthchain/interop/config/templates/cda/cda_document.liquid index 8c333a42..460d7196 100644 --- a/healthchain/interop/config/templates/cda/cda_document.liquid +++ b/healthchain/interop/config/templates/cda/cda_document.liquid @@ -5,22 +5,32 @@ "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.type.code }}", - "@codeSystem": "{{ config.type.system }}", - "@codeSystemName": "LOINC", - "@displayName": "{{ config.type.display }}" + "@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 }}", - "@codeSystem": "{{ config.confidentiality.system }}" + "@code": "{{ config.confidentiality_code.code }}", + "@codeSystem": "{{ config.confidentiality_code.code_system }}" }, "languageCode": { - "@code": "{{ config.language }}" + "@code": "{{ config.language_code }}" }, "component": { "structuredBody": { diff --git a/healthchain/interop/config/templates/cda/condition.liquid b/healthchain/interop/config/templates/cda/condition.liquid index f265be29..0444c623 100644 --- a/healthchain/interop/config/templates/cda/condition.liquid +++ b/healthchain/interop/config/templates/cda/condition.liquid @@ -47,6 +47,6 @@ {% endif %} {% endif %} "subject": { - "reference": "Patient/{{ entry.subject_id | default: 'example' }}" + "reference": "Patient/{{ entry.subject_id | default: 'Foo' }}" } } diff --git a/healthchain/interop/config_manager.py b/healthchain/interop/config_manager.py new file mode 100644 index 00000000..0357e76d --- /dev/null +++ b/healthchain/interop/config_manager.py @@ -0,0 +1,460 @@ +import yaml +import logging +import os +from pathlib import Path +from typing import Dict, Any, Set, Optional, List + +log = logging.getLogger(__name__) + + +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 InteropEngine""" + + # TODO: Use Pydantic to validate config files + + # Define required configuration schemas + REQUIRED_SECTION_KEYS = { + "resource", + "resource_template", + "entry_template", + "section_template_id", + "code", + "display", + } + + REQUIRED_DOCUMENT_KEYS = { + "type_id", + "code", + "confidentiality_code", + } + + def __init__( + self, config_dir: Path, validation_level: str = ValidationLevel.STRICT + ): + """Initialize the ConfigManager + + Args: + config_dir: Base directory containing configuration files + validation_level: Level of validation to perform (strict, warn, ignore) + """ + self.config_dir = config_dir + self._configs = {} + self._mappings = {} + self._defaults = {} + self._env_configs = {} + self._loaded = False + self._validation_level = validation_level + self._custom_schemas = {} + 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) -> "ConfigManager": + """Load all configuration files + + Args: + environment: Optional environment to load (overrides auto-detection) + + Returns: + Self for method chaining + """ + # Set environment if provided + if environment: + self._environment = environment + + # Load defaults first + self._load_defaults() + + # Load environment-specific configuration + self._load_environment_config() + + # Load other configurations + self._mappings = self._load_directory("mappings") + self._configs = self._load_directory("configs") + self._loaded = True + + # Validate configurations if not in IGNORE mode + if 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 / "configs" / "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 / "configs" / 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_directory(self, directory: str) -> Dict: + """Load all YAML files from a directory + + Args: + directory: Directory name relative to config_dir + + Returns: + Dict of loaded configurations + """ + configs = {} + config_dir = self.config_dir / directory + + if not config_dir.exists(): + log.warning(f"Configuration directory not found: {config_dir}") + return configs + + for config_file in config_dir.rglob("*.yaml"): + # Skip defaults.yaml and environment files as they're loaded separately + if directory == "configs": + if config_file.name == "defaults.yaml": + continue + if config_file.name in [ + "development.yaml", + "testing.yaml", + "production.yaml", + ]: + continue + + try: + with open(config_file) as f: + # Get relative path from configs directory for hierarchical keys + if directory == "configs": + rel_path = config_file.relative_to(config_dir) + 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 + self._deep_merge(configs, current_level) + else: + # Top-level file, just use the stem as key + configs[config_file.stem] = content + else: + # For non-configs directories, use the old behavior + configs[config_file.stem] = yaml.safe_load(f) + + 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 + + def _deep_merge(self, 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 + self._deep_merge(target[key], value) + else: + # Otherwise, overwrite the value + target[key] = value + + def get_mappings(self) -> Dict: + """Get all mappings + + Returns: + Dict of mappings + """ + if not self._loaded: + self.load() + return self._mappings + + def get_configs(self) -> Dict: + """Get all configs + + Returns: + Dict of configs + """ + if not self._loaded: + self.load() + + # Create a merged configuration with the correct precedence: + # 1. Regular configs (highest priority) + # 2. Environment-specific configs + # 3. Default configs (lowest priority) + merged_configs = {} + + # Start with defaults + self._deep_merge(merged_configs, self._defaults) + + # Apply environment-specific configs + self._deep_merge(merged_configs, self._env_configs) + + # Apply regular configs + self._deep_merge(merged_configs, self._configs) + + return merged_configs + + 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_section_configs(self) -> Dict: + """Get section configurations + + Returns: + Dict of section configurations + """ + return self.get_configs().get("sections", {}) + + def get_document_config(self) -> Dict: + """Get document configuration + + Returns: + Document configuration dict + """ + return self.get_configs().get("document", {}).get("cda", {}) + + def get_config_value(self, path: str, default: Any = None) -> Any: + """Get a configuration value using dot notation path + + Args: + path: Dot notation path (e.g., "section.problems.resource") + 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(".") + + # Get merged configs + configs = self.get_configs() + + # Get the value from merged configs + value = self._get_nested_value(configs, parts) + if value is not None: + return value + + # Return the provided default if not found + return default + + def _get_nested_value(self, 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 register_schema(self, config_type: str, required_keys: Set[str]) -> None: + """Register a custom validation schema + + Args: + config_type: Type of configuration (e.g., "section", "document") + required_keys: Set of required keys for this configuration type + """ + self._custom_schemas[config_type] = required_keys + + def validate(self) -> bool: + """Validate that all required configurations are present + + Returns: + True if valid, False otherwise + """ + is_valid = True + + # Validate section configs + section_configs = self.get_section_configs() + if not section_configs: + is_valid = self._handle_validation_error("No section configs found") + else: + # Validate each section + for section_key, section_config in section_configs.items(): + missing_keys = self.REQUIRED_SECTION_KEYS - set(section_config.keys()) + if missing_keys: + is_valid = self._handle_validation_error( + f"Section '{section_key}' is missing required keys: {missing_keys}" + ) + + # Validate document config + document_config = self.get_document_config() + + if not document_config: + is_valid = self._handle_validation_error("No document config found") + else: + # Validate document config + missing_keys = self.REQUIRED_DOCUMENT_KEYS - set(document_config.keys()) + if missing_keys: + is_valid = self._handle_validation_error( + f"Document config is missing required keys: {missing_keys}" + ) + + # Validate custom schemas + for config_type, required_keys in self._custom_schemas.items(): + config = self.get_configs().get(config_type, {}) + if not config: + is_valid = self._handle_validation_error( + f"No {config_type} config found" + ) + continue + + # If config is a dict of dicts (like sections), validate each sub-config + if all(isinstance(v, dict) for v in config.values()): + for key, sub_config in config.items(): + missing_keys = required_keys - set(sub_config.keys()) + if missing_keys: + is_valid = self._handle_validation_error( + f"{config_type.capitalize()} '{key}' is missing required keys: {missing_keys}" + ) + else: + # Validate the config directly + missing_keys = required_keys - set(config.keys()) + if missing_keys: + is_valid = self._handle_validation_error( + f"{config_type.capitalize()} config is missing required keys: {missing_keys}" + ) + + return is_valid + + def _handle_validation_error(self, message: str) -> bool: + """Handle validation error based on validation level + + Args: + message: Error message + + Returns: + False if in STRICT mode, True otherwise + """ + if self._validation_level == ValidationLevel.STRICT: + raise ValueError(message) + elif self._validation_level == ValidationLevel.WARN: + log.warning(f"Configuration validation: {message}") + + return self._validation_level != ValidationLevel.STRICT + + 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 diff --git a/healthchain/interop/converters/__init__.py b/healthchain/interop/converters/__init__.py new file mode 100644 index 00000000..1f9d90f7 --- /dev/null +++ b/healthchain/interop/converters/__init__.py @@ -0,0 +1,9 @@ +""" +HealthChain Interoperability Converters + +This package contains converters for various healthcare data formats. +""" + +from healthchain.interop.converters.fhir import FHIRConverter + +__all__ = ["FHIRConverter"] diff --git a/healthchain/interop/converters/fhir.py b/healthchain/interop/converters/fhir.py new file mode 100644 index 00000000..65ce6a89 --- /dev/null +++ b/healthchain/interop/converters/fhir.py @@ -0,0 +1,315 @@ +""" +FHIR Converter for HealthChain Interoperability Engine + +This module provides functionality for converting to and from FHIR resources. +""" + +import logging +import importlib +import uuid +from typing import Dict, List, Optional, Union +from pathlib import Path + +from fhir.resources.resource import Resource +from fhir.resources.bundle import Bundle + +log = logging.getLogger(__name__) + + +class FHIRConverter: + """Handles conversion to and from FHIR resources""" + + def __init__(self, config_manager): + """Initialize the FHIR converter + + Args: + config_manager: Configuration manager instance + """ + self.config_manager = config_manager + self.template_registry = None + self.parser = None + + def set_template_registry(self, template_registry): + """Set the template registry + + Args: + template_registry: Template registry instance + """ + self.template_registry = template_registry + return self + + def set_parser(self, parser): + """Set the parser + + Args: + parser: Parser instance + """ + self.parser = parser + return self + + def _create_fhir_resource_from_dict( + self, 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: + log.error(f"Failed to create FHIR resource: {str(e)}") + return None + + def add_required_fields(self, resource_dict: Dict, resource_type: str): + """Add required fields to resource dictionary based on type + + Args: + resource_dict: Dictionary representation of the resource + resource_type: Type of FHIR resource + """ + # Add common fields + id_prefix = self.config_manager.get_config_value( + "defaults.common.id_prefix", "hc-" + ) + if "id" not in resource_dict: + resource_dict["id"] = f"{id_prefix}{str(uuid.uuid4())}" + + # Get default values from configuration if available + default_subject = self.config_manager.get_config_value( + "defaults.common.subject", {"reference": "Patient/example"} + ) + if "subject" not in resource_dict: + resource_dict["subject"] = default_subject + + # Add resource-specific required fields + if resource_type == "Condition": + if "clinicalStatus" not in resource_dict: + default_status = self.config_manager.get_config_value( + "defaults.resources.Condition.clinicalStatus", + { + "coding": [ + { + "system": "http://terminology.hl7.org/CodeSystem/condition-clinical", + "code": "unknown", + } + ] + }, + ) + resource_dict["clinicalStatus"] = default_status + elif resource_type == "MedicationStatement": + if "status" not in resource_dict: + default_status = self.config_manager.get_config_value( + "defaults.resources.MedicationStatement.status", "unknown" + ) + resource_dict["status"] = default_status + elif resource_type == "AllergyIntolerance": + if "clinicalStatus" not in resource_dict: + default_status = self.config_manager.get_config_value( + "defaults.resources.AllergyIntolerance.clinicalStatus", + { + "coding": [ + { + "system": "http://terminology.hl7.org/CodeSystem/allergyintolerance-clinical", + "code": "unknown", + } + ] + }, + ) + resource_dict["clinicalStatus"] = default_status + + def normalize_resources( + self, resources: Union[Resource, List[Resource], Bundle] + ) -> List[Resource]: + """Convert input resources to a normalized list format + + Args: + resources: A FHIR Bundle, list of resources, or single resource + + Returns: + List of FHIR resources + """ + if isinstance(resources, Bundle): + return [entry.resource for entry in resources.entry if entry.resource] + elif isinstance(resources, list): + return resources + else: + return [resources] + + def convert_cda_entries_to_fhir_resources( + self, section_entries: Dict, section_configs: Dict + ) -> List[Resource]: + """Convert CDA section entries to FHIR resources + + Args: + section_entries: Dictionary mapping section keys to lists of entry dictionaries + section_configs: Configuration for all sections + + Returns: + List of FHIR resources + """ + if not self.template_registry or not self.parser: + raise ValueError( + "Template registry and parser must be set before conversion" + ) + + resources = [] + + for section_key, entries in section_entries.items(): + if section_key not in section_configs: + log.warning(f"No configuration found for section: {section_key}") + continue + + section_config = section_configs[section_key] + template_key = Path(section_config.get("resource_template", "")).stem + + if not template_key: + log.warning( + f"No resource template specified for section: {section_key}" + ) + continue + + if not self.template_registry.has_template(template_key): + log.warning( + f"Template {template_key} not found, skipping section {section_key}" + ) + continue + + template = self.template_registry.get_template(template_key) + + # Process each entry in the section + section_resources = self._convert_cda_entries_to_fhir( + entries, template, section_config, section_key + ) + resources.extend(section_resources) + + return resources + + def convert_fhir_to_cda_entries( + self, resources: List[Resource], section_configs: Dict, cda_generator + ) -> Dict: + """Process resources and group them by section with rendered entries + + Args: + resources: List of FHIR resources + section_configs: Configuration for all sections + cda_generator: CDA generator instance for rendering entries + + Returns: + Dictionary mapping section keys to lists of entry dictionaries + """ + if not self.template_registry: + raise ValueError( + "Template registry must be set before processing resources" + ) + + section_entries = {} + + for resource in resources: + # Find matching section for resource type + section_key = self._find_section_for_fhir_resource( + resource, section_configs + ) + + if not section_key: + continue + + # Get template for this section + template_name = Path( + section_configs[section_key].get("entry_template", "") + ).stem + if not self.template_registry.has_template(template_name): + log.warning( + f"Template {template_name} not found, skipping section {section_key}" + ) + continue + + # Render entry using template + entry = cda_generator.render_entry( + resource, section_key, template_name, section_configs[section_key] + ) + if entry: + section_entries.setdefault(section_key, []).append(entry) + + return section_entries + + def _find_section_for_fhir_resource( + self, resource: Resource, section_configs: Dict + ) -> Optional[str]: + """Find the appropriate section key for a given resource + + Args: + resource: FHIR resource + section_configs: Configuration for all sections + + Returns: + Section key or None if no matching section found + """ + resource_type = resource.__class__.__name__ + + # Find matching section for resource type + section_key = next( + ( + key + for key, config in section_configs.items() + if config.get("resource") == resource_type + ), + None, + ) + + if not section_key: + log.warning(f"Unsupported resource type: {resource_type}") + + return section_key + + def _convert_cda_entries_to_fhir( + self, entries: List[Dict], template, section_config: Dict, section_key: str + ) -> List[Resource]: + """Process entries from a single section and convert to FHIR resources + + Args: + entries: List of entries from a section + template: The template to use for rendering + section_config: Configuration for the section + section_key: Key identifying the section + + Returns: + List[Resource]: List of FHIR resources from this section + """ + resources = [] + resource_type = section_config.get("resource") + + if not resource_type: + log.error(f"Missing resource type in section config for {section_key}") + return resources + + for entry in entries: + try: + # Convert entry to FHIR resource dictionary using the parser + resource_dict = self.parser.render_fhir_resource_from_cda_entry( + entry, template, section_config + ) + + # Add required fields based on resource type + self.add_required_fields(resource_dict, resource_type) + + # Create FHIR resource instance + resource = self._create_fhir_resource_from_dict( + resource_dict, resource_type + ) + if resource: + resources.append(resource) + + except Exception as e: + log.error(f"Failed to convert entry in section {section_key}: {str(e)}") + continue + + return resources diff --git a/healthchain/interop/engine.py b/healthchain/interop/engine.py index b3a16197..eb1f2510 100644 --- a/healthchain/interop/engine.py +++ b/healthchain/interop/engine.py @@ -1,22 +1,28 @@ -import yaml -import json -import uuid import logging -import importlib -import re -import xmltodict +from functools import cached_property from enum import Enum -from typing import Dict, List, Union, Optional +from typing import Dict, List, Union, Optional, Callable, Any, Set from pathlib import Path -from datetime import datetime -from liquid import Environment, FileSystemLoader from fhir.resources.resource import Resource -from fhir.resources.bundle import Bundle from .parsers.cda import CDAParser -from .filters import format_date, clean_empty +from .parsers.hl7v2 import HL7v2Parser +from .filters import ( + format_date, + map_system, + map_status, + clean_empty, + format_timestamp, + generate_id, + to_json, +) +from .config_manager import ConfigManager, ValidationLevel +from .template_registry import TemplateRegistry +from .converters.fhir import FHIRConverter +from .generators.cda import CDAGenerator +from .generators.hl7v2 import HL7v2Generator log = logging.getLogger(__name__) @@ -40,475 +46,327 @@ def validate_format(format_type: Union[str, FormatType]) -> FormatType: class InteropEngine: """Generic interoperability engine for converting between healthcare formats""" - def __init__(self, config_dir: Path): + def __init__( + self, + config_dir: Path, + validation_level: str = ValidationLevel.STRICT, + environment: Optional[str] = None, + ): + """Initialize the InteropEngine + + Args: + config_dir: Base directory containing configuration files + validation_level: Level of configuration validation (strict, warn, ignore) + environment: Optional environment to use (development, testing, production) + """ + # Initialize configuration manager self.config_dir = config_dir - self.mappings = self._load_configs("mappings") - self.configs = self._load_configs("configs") + self.config_manager = ConfigManager(config_dir, validation_level) + self.config_manager.load(environment) + + # Initialize template registry + template_dir = config_dir / "templates" + self.template_registry = TemplateRegistry(template_dir) + + # Create and register default filters + default_filters = self._create_default_filters() + self.template_registry.initialize(default_filters) + + # Component registries for lazy loading + self._parsers = {} + self._converters = {} + 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 converters + @cached_property + def fhir_converter(self): + """Lazily load the FHIR converter""" + return self._get_converter(FormatType.FHIR) + + # Lazy-loaded generators + @cached_property + def cda_generator(self): + """Lazily load the CDA generator""" + return self._get_generator(FormatType.CDA) + + @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 - # Create Liquid environment with loader and custom filters - template_dir = self.config_dir / "templates" - if not template_dir.exists(): - raise ValueError(f"Template directory not found: {template_dir}") + Args: + format_type: The format type to get a parser for - self.env = Environment(loader=FileSystemLoader(str(template_dir))) - self._register_filters() + Returns: + The parser instance + """ + if format_type not in self._parsers: + if format_type == FormatType.CDA: + parser = CDAParser(self.config_manager.get_mappings()) + self._parsers[format_type] = parser + elif format_type == FormatType.HL7V2: + parser = HL7v2Parser(self.config_manager.get_mappings()) + self._parsers[format_type] = parser + else: + raise ValueError(f"Unsupported parser format: {format_type}") - self.templates = self._load_templates() - self.parser = CDAParser(self.mappings) + return self._parsers[format_type] - def _register_filters(self): - """Register custom filters with Liquid environment""" + def _get_converter(self, format_type: FormatType): + """Get or create a converter for the specified format - # Create filter functions with access to mappings - def map_system_filter(system, direction="fhir_to_cda"): - """Map between CDA and FHIR code systems + Args: + format_type: The format type to get a converter for - Args: - system: The system URI/OID to map - direction: Either 'fhir_to_cda' or 'cda_to_fhir' - """ - if not system: - return None + Returns: + The converter instance + """ + if format_type not in self._converters: + if format_type == FormatType.FHIR: + converter = FHIRConverter(self.config_manager) + converter.set_template_registry(self.template_registry) + # Parsers will be set when needed + self._converters[format_type] = converter + else: + raise ValueError(f"Unsupported converter format: {format_type}") - shared_mappings = self.mappings.get("shared_mappings", {}) - system_mappings = shared_mappings.get("code_systems", {}).get(direction, {}) - return system_mappings.get(system, system) + return self._converters[format_type] - def map_status_filter(status, direction="fhir_to_cda"): - """Map between CDA and FHIR status codes + def _get_generator(self, format_type: FormatType): + """Get or create a generator for the specified format - Args: - status: The status code to map - direction: Either 'fhir_to_cda' or 'cda_to_fhir' - """ - if not status: - return None + Args: + format_type: The format type to get a generator for - shared_mappings = self.mappings.get("shared_mappings", {}) - status_mappings = shared_mappings.get("status_codes", {}).get(direction, {}) - return status_mappings.get(status, status) + Returns: + The generator instance + """ + if format_type not in self._generators: + if format_type == FormatType.CDA: + generator = CDAGenerator(self.config_manager, self.template_registry) + self._generators[format_type] = generator + elif format_type == FormatType.HL7V2: + generator = HL7v2Generator(self.config_manager, self.template_registry) + self._generators[format_type] = generator + else: + raise ValueError(f"Unsupported generator format: {format_type}") - def json_filter(obj): - if obj is None: - return "[]" - return json.dumps(obj) - - def generate_id_filter(value=None): - """Generate UUID or use provided value""" - return value if value else f"hc-{str(uuid.uuid4())}" - - def format_timestamp_filter(value=None): - """Format timestamp or use current time""" - if value: - return value.strftime("%Y%m%d%H%M%S") - return datetime.now().strftime("%Y%m%d%H%M%S") - - # Register filters with descriptive names - self.env.filters["map_system"] = map_system_filter - self.env.filters["map_status"] = map_status_filter - self.env.filters["format_date"] = format_date - self.env.filters["json"] = json_filter - self.env.filters["generate_id"] = generate_id_filter - self.env.filters["format_timestamp"] = format_timestamp_filter - - def _load_configs(self, directory: str) -> Dict: - """Load all configuration files""" - configs = {} - config_dir = self.config_dir / directory - for config_file in config_dir.rglob("*.yaml"): - with open(config_file) as f: - configs[config_file.stem] = yaml.safe_load(f) - - return configs - - def _load_templates(self) -> Dict: - """Load all liquid templates""" - templates = {} - - # Walk through all subdirectories to find template files - for template_file in (self.config_dir / "templates").rglob("*.liquid"): - rel_path = template_file.relative_to(self.config_dir / "templates") - template_key = rel_path.stem - try: - template = self.env.get_template(str(rel_path)) - templates[template_key] = template - - except Exception as e: - log.error(f"Failed to load template {template_file}: {str(e)}") - continue - - if not templates: - raise ValueError(f"No templates found in {self.config_dir / 'templates'}") - - log.debug(f"Loaded {len(templates)} templates: {list(templates.keys())}") - - return templates + return self._generators[format_type] - def _cda_to_fhir(self, source_data: str) -> List[Resource]: - """Convert CDA XML to FHIR resources + def register_parser(self, format_type: FormatType, parser_instance): + """Register a custom parser for a format Args: - source_data: CDA document as XML string + format_type: The format type to register the parser for + parser_instance: The parser instance Returns: - List[Resource]: List of FHIR resources - - Raises: - ValueError: If required mappings are missing or if sections are unsupported + Self for method chaining """ - # Get required configurations - section_configs = self.configs.get("section", {}) + self._parsers[format_type] = parser_instance + return self - if not section_configs: - raise ValueError("No section configs found in configs/cda/section.yaml") + def register_converter(self, format_type: FormatType, converter_instance): + """Register a custom converter for a format - # Parse sections from CDA XML - section_entries = self._parse_cda_sections(source_data, section_configs) + Args: + format_type: The format type to register the converter for + converter_instance: The converter instance - # Convert entries to FHIR resources - return self._convert_entries_to_fhir(section_entries, section_configs) + Returns: + Self for method chaining + """ + self._converters[format_type] = converter_instance + return self - def _parse_cda_sections(self, source_data: str, section_configs: Dict) -> Dict: - """Parse sections from CDA XML document + def register_generator(self, format_type: FormatType, generator_instance): + """Register a custom generator for a format Args: - source_data: CDA document as XML string - section_configs: Configuration for each section + format_type: The format type to register the generator for + generator_instance: The generator instance Returns: - Dict: Dictionary mapping section keys to their entries + Self for method chaining """ - section_entries = {} - - for section_key, section_config in section_configs.items(): - try: - entries = self.parser.parse_section(source_data, section_config) - if entries: - section_entries[section_key] = entries - except Exception as e: - log.error(f"Failed to parse section {section_key}: {str(e)}") - continue + self._generators[format_type] = generator_instance + return self - return section_entries - - def _convert_entries_to_fhir( - self, section_entries: Dict, section_configs: Dict - ) -> List[Resource]: - """Convert parsed CDA entries to FHIR resources + def register_config_schema( + self, config_type: str, required_keys: Set[str] + ) -> "InteropEngine": + """Register a custom configuration schema for validation Args: - section_entries: Dictionary mapping section keys to their entries - section_configs: Configuration for each section + config_type: Type of configuration (e.g., "section", "document") + required_keys: Set of required keys for this configuration type Returns: - List[Resource]: List of FHIR resources + Self for method chaining """ - resources = [] + self.config_manager.register_schema(config_type, required_keys) + return self - for section_key, entries in section_entries.items(): - section_config = section_configs[section_key] - template_key = Path(section_config["resource_template"]).stem + def set_validation_level(self, level: str) -> "InteropEngine": + """Set the configuration validation level - if template_key not in self.templates: - log.warning( - f"Template {template_key} not found, skipping section {section_key}" - ) - continue + Args: + level: Validation level (strict, warn, ignore) - template = self.templates[template_key] + Returns: + Self for method chaining + """ + self.config_manager.set_validation_level(level) + return self - # Process each entry in the section - section_resources = self._process_section_entries( - entries, template, section_config, section_key - ) - resources.extend(section_resources) + def get_environment(self) -> str: + """Get the current environment - return resources + Returns: + String representing the current environment + """ + return self.config_manager.get_environment() - def _process_section_entries( - self, entries: List[Dict], template, section_config: Dict, section_key: str - ) -> List[Resource]: - """Process entries from a single section and convert to FHIR resources + def set_environment(self, environment: str) -> "InteropEngine": + """Set the environment and reload environment-specific configuration Args: - entries: List of entries from a section - template: The template to use for rendering - section_config: Configuration for the section - section_key: Key identifying the section + environment: Environment to set (development, testing, production) Returns: - List[Resource]: List of FHIR resources from this section + Self for method chaining """ - resources = [] - resource_type = section_config["resource"] + self.config_manager.set_environment(environment) + return self - for entry in entries: - try: - # Convert entry to FHIR resource dictionary - resource_dict = self._render_and_process_entry( - entry, template, section_config - ) + def get_config_value(self, path: str, default: Any = None) -> Any: + """Get a configuration value using dot notation path - # Create FHIR resource instance - resource = self._create_fhir_resource(resource_dict, resource_type) - if resource: - resources.append(resource) + Args: + path: Dot notation path (e.g., "section.problems.resource") + default: Default value if path not found - except Exception as e: - log.error(f"Failed to convert entry in section {section_key}: {str(e)}") - continue + Returns: + Configuration value or default + """ + return self.config_manager.get_config_value(path, default) - return resources + def get_loaded_defaults(self) -> Dict: + """Get all loaded default values - def _render_and_process_entry( - self, entry: Dict, template, section_config: Dict - ) -> Dict: - """Render an entry using a template and process the result + Returns: + Dictionary of default values loaded from defaults.yaml + """ + return self.config_manager.get_defaults() - Args: - entry: The entry data - template: The template to use for rendering - section_config: Configuration for the section + def is_defaults_loaded(self) -> bool: + """Check if the defaults.yaml file is loaded Returns: - Dict: Processed resource dictionary + True if defaults.yaml is loaded, False otherwise """ - # Render template with entry data and config - rendered = template.render({"entry": entry, "config": section_config}) + defaults = self.get_loaded_defaults() + return bool(defaults) - # Parse rendered JSON and clean empty values - resource_dict = clean_empty(json.loads(rendered)) + def _create_default_filters(self) -> Dict[str, Callable]: + """Create and return default filter functions for templates - # Add required fields based on resource type - resource_type = section_config["resource"] - self._add_required_fields(resource_dict, resource_type) + Returns: + Dict of filter names to filter functions + """ + # Get mappings for filter functions + mappings = self.config_manager.get_mappings() - return resource_dict + # Create filter functions with access to mappings + def map_system_filter(system, direction="fhir_to_cda"): + return map_system(system, mappings, direction) - def _create_fhir_resource( - self, resource_dict: Dict, resource_type: str - ) -> Optional[Resource]: - """Create a FHIR resource instance from a dictionary + def map_status_filter(status, direction="fhir_to_cda"): + return map_status(status, mappings, direction) - Args: - resource_dict: Dictionary representation of the resource - resource_type: Type of FHIR resource to create + def format_date_filter(date_str, input_format="%Y%m%d", output_format="iso"): + return format_date(date_str, input_format, output_format) - 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: - log.error(f"Failed to create FHIR resource: {str(e)}") - return None - - def _add_required_fields(self, resource_dict: Dict, resource_type: str): - """Add required fields to resource dictionary based on type""" - # Add common fields - if "id" not in resource_dict: - resource_dict["id"] = f"hc-{str(uuid.uuid4())}" - if "subject" not in resource_dict: - resource_dict["subject"] = {"reference": "Patient/example"} - - # Add resource-specific required fields - if resource_type == "Condition": - if "clinicalStatus" not in resource_dict: - resource_dict["clinicalStatus"] = { - "coding": [ - { - "system": "http://terminology.hl7.org/CodeSystem/condition-clinical", - "code": "unknown", - } - ] - } - elif resource_type == "MedicationStatement": - if "status" not in resource_dict: - resource_dict["status"] = "unknown" - elif resource_type == "AllergyIntolerance": - if "clinicalStatus" not in resource_dict: - resource_dict["clinicalStatus"] = { - "coding": [ - { - "system": "http://terminology.hl7.org/CodeSystem/allergyintolerance-clinical", - "code": "unknown", - } - ] - } + def format_timestamp_filter(value=None, format_str="%Y%m%d%H%M%S"): + return format_timestamp(value, format_str) - def _hl7v2_to_fhir(self, source_data: str) -> List[Resource]: - """Convert HL7v2 to FHIR resources""" - raise NotImplementedError("HL7v2 to FHIR conversion not implemented") + def generate_id_filter(value=None, prefix="hc-"): + return generate_id(value, prefix) - def _fhir_to_cda(self, resources: Union[Resource, List[Resource]]) -> str: - """Convert FHIR resources to CDA XML + def json_filter(obj): + return to_json(obj) + + def clean_empty_filter(d): + return clean_empty(d) + + # 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, + } + + def add_filter(self, name: str, filter_func: Callable) -> "InteropEngine": + """Add a custom filter function to the template engine Args: - resources: A FHIR Bundle, list of resources, or single resource + name: Name of the filter to use in templates + filter_func: Filter function to register Returns: - str: CDA document as XML string - - Raises: - ValueError: If required mappings are missing or if resource types are unsupported + Self for method chaining """ - # Normalize input to list of resources - resource_list = self._normalize_resources(resources) + self.template_registry.add_filter(name, filter_func) + return self - # Get required configurations - section_configs = self.configs.get("section", {}) - document_config = self.configs.get("document", {}) + def add_filters(self, filters: Dict[str, Callable]) -> "InteropEngine": + """Add multiple custom filter functions to the template engine - if not section_configs or not document_config: - raise ValueError("No section or document configs found in configs/cda") - - # Process resources and generate section entries - section_entries = self._process_resources_to_section_entries( - resource_list, section_configs - ) + Args: + filters: Dictionary of filter names to filter functions - # Render sections - formatted_sections = self._render_sections(section_entries, section_configs) + Returns: + Self for method chaining + """ + self.template_registry.add_filters(filters) + return self - # Generate final CDA document - return self._generate_cda_document( - resources, document_config, formatted_sections - ) + def get_filter(self, name: str) -> Optional[Callable]: + """Get a registered filter function by name - def _normalize_resources( - self, resources: Union[Resource, List[Resource]] - ) -> 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] - - def _process_resources_to_section_entries( - self, resources: List[Resource], section_configs: Dict - ) -> Dict: - """Process resources and group them by section with rendered entries""" - section_entries = {} - - for resource in resources: - resource_type = resource.__class__.__name__ - - # Find matching section for resource type - section_key = next( - ( - key - for key, config in section_configs.items() - if config["resource"] == resource_type - ), - None, - ) - - if not section_key: - log.warning(f"Unsupported resource type: {resource_type}") - continue - - # Get template for this section - template_name = Path(section_configs[section_key]["entry_template"]).stem - if template_name not in self.templates: - log.warning( - f"Template {template_name} not found, skipping section {section_key}" - ) - continue - - # Render entry using template - entry = self._render_entry( - resource, section_key, template_name, section_configs[section_key] - ) - if entry: - section_entries.setdefault(section_key, []).append(entry) - - return section_entries - - def _render_entry( - self, - resource: Resource, - section_key: str, - template_name: str, - section_config: Dict, - ) -> Dict: - """Render a single entry for a resource""" - try: - # Create context with common values - timestamp = datetime.now().strftime(format="%Y%m%d") - reference_name = "#" + str(uuid.uuid4())[:8] + "name" - context = { - "timestamp": timestamp, - "text_reference_name": reference_name, - } - - # Render template - entry_json = self.templates[template_name].render( - resource=resource.model_dump(), - config=section_config, - context=context, - ) - - # Parse and clean the rendered JSON - return clean_empty(json.loads(entry_json)) - - except Exception as e: - log.error(f"Failed to render {section_key} entry: {str(e)}") - return None - - def _render_sections( - self, section_entries: Dict, section_configs: Dict - ) -> List[Dict]: - """Render all sections with their entries""" - formatted_sections = [] - section_template = self.templates["cda_section"] - - for section_key, section_config in section_configs.items(): - entries = section_entries.get(section_key, []) - if not entries: - continue - - try: - section_json = section_template.render( - entries=entries, - config=section_config, - ) - formatted_sections.append(json.loads(section_json)) - except Exception as e: - log.error(f"Failed to render section {section_key}: {str(e)}") - - return formatted_sections - - def _generate_cda_document( - self, - resources: Union[Resource, List[Resource]], - document_config: Dict, - formatted_sections: List[Dict], - ) -> str: - """Generate the final CDA document""" - # Create document context - context = { - "bundle": resources if isinstance(resources, Bundle) else None, - "config": document_config, - "sections": formatted_sections, - } + Args: + name: Name of the filter - # Render document - document_json = self.templates["cda_document"].render(**context) - document_dict = json.loads(document_json) - xml_string = xmltodict.unparse(document_dict, pretty=True) + Returns: + The filter function or None if not found + """ + return self.template_registry.get_filter(name) - # Fix self-closing tags - return re.sub(r"(<(\w+)(\s+[^>]*?)?)>", r"\1/>", xml_string) + def get_filters(self) -> Dict[str, Callable]: + """Get all registered filter functions - def _fhir_to_hl7v2(self, resources: List[Resource]) -> str: - """Convert FHIR resources to HL7v2""" - raise NotImplementedError("FHIR to HL7v2 conversion not implemented") + Returns: + Dictionary of filter names to filter functions + """ + return self.template_registry.get_filters() def to_fhir( self, source_data: str, source_format: Union[str, FormatType] @@ -537,3 +395,83 @@ def from_fhir( return self._fhir_to_cda(resources) else: raise ValueError(f"Unsupported format: {format_type}") + + def _cda_to_fhir(self, source_data: str) -> List[Resource]: + """Convert CDA XML to FHIR resources + + Args: + source_data: CDA document as XML string + + Returns: + List[Resource]: List of FHIR resources + + Raises: + ValueError: If required mappings are missing or if sections are unsupported + """ + # Get required configurations + section_configs = self.config_manager.get_section_configs() + + if not section_configs: + raise ValueError("No section configs found in configs/cda/section.yaml") + + # Get parser and converter (lazy loaded) + parser = self.cda_parser + converter = self.fhir_converter + + # Ensure converter has access to parser + converter.set_parser(parser) + + # Parse sections from CDA XML using the parser + section_entries = parser.parse_document(source_data, section_configs) + + # Convert entries to FHIR resources using the FHIR converter + return converter.convert_cda_entries_to_fhir_resources( + section_entries, section_configs + ) + + def _fhir_to_cda(self, resources: Union[Resource, List[Resource]]) -> str: + """Convert FHIR resources to CDA XML + + Args: + resources: A FHIR Bundle, list of resources, or single resource + + Returns: + str: CDA document as XML string + + Raises: + ValueError: If required mappings are missing or if resource types are unsupported + """ + # Get required configurations + section_configs = self.config_manager.get_section_configs() + document_config = self.config_manager.get_document_config() + + if not section_configs or not document_config: + raise ValueError("No section or document configs found in configs/cda") + + # Get converter and generator (lazy loaded) + converter = self.fhir_converter + generator = self.cda_generator + + # Normalize input to list of resources + resource_list = converter.normalize_resources(resources) + + # Process resources and generate section entries + section_entries = converter.convert_fhir_to_cda_entries( + resource_list, section_configs, generator + ) + + # Render sections + formatted_sections = generator.render_sections(section_entries, section_configs) + + # Generate final CDA document + return generator.generate_document( + resources, document_config, formatted_sections + ) + + def _hl7v2_to_fhir(self, source_data: str) -> List[Resource]: + """Convert HL7v2 to FHIR resources""" + raise NotImplementedError("HL7v2 to FHIR conversion not implemented") + + def _fhir_to_hl7v2(self, resources: List[Resource]) -> str: + """Convert FHIR resources to HL7v2""" + raise NotImplementedError("FHIR to HL7v2 conversion not implemented") diff --git a/healthchain/interop/filters.py b/healthchain/interop/filters.py index 787a2582..bd2f167c 100644 --- a/healthchain/interop/filters.py +++ b/healthchain/interop/filters.py @@ -1,36 +1,134 @@ +import json +import uuid from datetime import datetime -from typing import Dict, Any +from typing import Dict, Any, Optional -def map_system(system: str, mappings: Dict) -> str: - """Maps source code system to FHIR system""" +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 - # Access the code systems mapping directly - return mappings.get("cda_fhir", {}).get("code_systems", {}).get(system, system) + + if not mappings: + return system + + shared_mappings = mappings.get("shared_mappings", {}) + system_mappings = shared_mappings.get("code_systems", {}).get(direction, {}) + return system_mappings.get(system, system) -def map_status(status: str, mappings: Dict) -> str: - """Maps source status to FHIR status""" +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 - # Access the status mapping directly - return mappings.get("cda_fhir", {}).get("status", {}).get(status, "unknown") + if not mappings: + return status + + shared_mappings = mappings.get("shared_mappings", {}) + status_mappings = shared_mappings.get("status_codes", {}).get(direction, {}) + return status_mappings.get(status, status) -def format_date(date_str: str) -> str: - """Formats dates to FHIR format""" + +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, "%Y%m%d") - return dt.isoformat() + "Z" # Add UTC timezone indicator + 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 clean_empty(d: Any) -> Any: - """Recursively remove empty strings, empty lists, empty dicts, and None values""" + """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 diff --git a/healthchain/interop/generators/__init__.py b/healthchain/interop/generators/__init__.py new file mode 100644 index 00000000..7b42b61e --- /dev/null +++ b/healthchain/interop/generators/__init__.py @@ -0,0 +1,10 @@ +""" +HealthChain Interoperability Generators + +This package contains generators for various healthcare data formats. +""" + +from healthchain.interop.generators.cda import CDAGenerator +from healthchain.interop.generators.hl7v2 import HL7v2Generator + +__all__ = ["CDAGenerator", "HL7v2Generator"] diff --git a/healthchain/interop/generators/cda.py b/healthchain/interop/generators/cda.py new file mode 100644 index 00000000..97e1644a --- /dev/null +++ b/healthchain/interop/generators/cda.py @@ -0,0 +1,198 @@ +""" +CDA Generator for HealthChain Interoperability Engine + +This module provides functionality for generating CDA documents. +""" + +import logging +import json +import re +import xmltodict +import uuid +from datetime import datetime +from typing import Dict, List, Union + +from fhir.resources.resource import Resource +from fhir.resources.bundle import Bundle + +from healthchain.interop.filters import clean_empty + +log = logging.getLogger(__name__) + + +class CDAGenerator: + """Handles generation of CDA documents""" + + def __init__(self, config_manager, template_registry): + """Initialize the CDA generator + + Args: + config_manager: Configuration manager instance + template_registry: Template registry instance + """ + self.config_manager = config_manager + self.template_registry = template_registry + + def render_entry( + self, + resource: Resource, + section_key: str, + template_name: str, + section_config: Dict, + ) -> Dict: + """Render a single entry for a resource + + Args: + resource: FHIR resource + section_key: Key identifying the section + template_name: Name of the template to use + section_config: Configuration for the section + + Returns: + Dictionary representation of the rendered entry + """ + try: + # Create context with common values + timestamp_format = self.config_manager.get_config_value( + "formats.date.timestamp", "%Y%m%d" + ) + timestamp = datetime.now().strftime(format=timestamp_format) + + # Generate reference name using configured format + id_format = self.config_manager.get_config_value( + "formats.ids.reference_name", "#{uuid}name" + ) + reference_name = id_format.replace("{uuid}", str(uuid.uuid4())[:8]) + + # Create context with additional rendering options + context = { + "timestamp": timestamp, + "text_reference_name": reference_name, + "rendering": self.config_manager.get_config_value( + f"sections.{section_key}.rendering", {} + ), + "formats": self.config_manager.get_config_value("formats", {}), + } + + # Get template and render + template = self.template_registry.get_template(template_name) + entry_json = template.render( + resource=resource.model_dump(), + config=section_config, + context=context, + ) + + # Parse and clean the rendered JSON + return clean_empty(json.loads(entry_json)) + + except Exception as e: + log.error(f"Failed to render {section_key} entry: {str(e)}") + return None + + def render_sections( + self, section_entries: Dict, section_configs: Dict + ) -> List[Dict]: + """Render all sections with their entries + + Args: + section_entries: Dictionary mapping section keys to their entries + section_configs: Configuration for each section + + Returns: + List of formatted section dictionaries + """ + formatted_sections = [] + + # Get section template name from config or use default + section_template_name = self.config_manager.get_config_value( + "templates.core.section", "cda_section" + ) + + try: + section_template = self.template_registry.get_template( + section_template_name + ) + except KeyError: + raise ValueError(f"Required template '{section_template_name}' not found") + + for section_key, section_config in section_configs.items(): + entries = section_entries.get(section_key, []) + if entries: + try: + section_json = section_template.render( + entries=entries, + config=section_config, + ) + formatted_sections.append(json.loads(section_json)) + except Exception as e: + log.error(f"Failed to render section {section_key}: {str(e)}") + + return formatted_sections + + def generate_document( + self, + resources: Union[Resource, List[Resource], Bundle], + document_config: Dict, + formatted_sections: List[Dict], + ) -> str: + """Generate the final CDA document + + Args: + resources: FHIR resources + document_config: Configuration for the document + formatted_sections: List of formatted section dictionaries + + Returns: + CDA document as XML string + """ + # Get document template name from config or use default + document_template_name = self.config_manager.get_config_value( + "templates.core.document", "cda_document" + ) + + try: + document_template = self.template_registry.get_template( + document_template_name + ) + except KeyError: + raise ValueError(f"Required template '{document_template_name}' not found") + + # Create document context with additional configuration + context = { + "bundle": resources if isinstance(resources, Bundle) else None, + "config": document_config, + "sections": formatted_sections, + "defaults": { + "patient": self.config_manager.get_config_value( + "document.defaults.patient", {} + ), + "author": self.config_manager.get_config_value( + "document.defaults.author", {} + ), + "custodian": self.config_manager.get_config_value( + "document.defaults.custodian", {} + ), + }, + "structure": self.config_manager.get_config_value("document.structure", {}), + "rendering": self.config_manager.get_config_value("document.rendering", {}), + } + + # Render document + document_json = document_template.render(**context) + document_dict = json.loads(document_json) + + # Get XML formatting options + pretty_print = self.config_manager.get_config_value( + "document.rendering.xml.pretty_print", True + ) + encoding = self.config_manager.get_config_value( + "document.rendering.xml.encoding", "UTF-8" + ) + + # Generate XML + xml_string = xmltodict.unparse( + document_dict, pretty=pretty_print, encoding=encoding + ) + + # Fix self-closing tags + return re.sub(r"(<(\w+)(\s+[^>]*?)?)>", r"\1/>", xml_string) diff --git a/healthchain/interop/generators/hl7v2.py b/healthchain/interop/generators/hl7v2.py new file mode 100644 index 00000000..543fdbaa --- /dev/null +++ b/healthchain/interop/generators/hl7v2.py @@ -0,0 +1,100 @@ +""" +HL7v2 Generator for HealthChain Interoperability Engine + +This module provides functionality for generating HL7v2 messages. +""" + +import logging +from typing import Dict, List, Union + +from fhir.resources.resource import Resource +from fhir.resources.bundle import Bundle + +log = logging.getLogger(__name__) + + +class HL7v2Generator: + """Handles generation of HL7v2 messages""" + + def __init__(self, config_manager, template_registry): + """Initialize the HL7v2 generator + + Args: + config_manager: Configuration manager instance + template_registry: Template registry instance + """ + self.config_manager = config_manager + self.template_registry = template_registry + + def render_segment( + self, + resource: Resource, + segment_key: str, + template_name: str, + segment_config: Dict, + ) -> Dict: + """Render a single segment for a resource + + Args: + resource: FHIR resource + segment_key: Key identifying the segment + template_name: Name of the template to use + segment_config: Configuration for the segment + + Returns: + Dictionary representation of the rendered segment + """ + # TODO: Implement + + # In a real implementation, this would: + # 1. Get the template from the registry + # 2. Render the template with the resource data + # 3. Return the rendered segment + + return {} + + def render_segments( + self, segment_entries: Dict, segment_configs: Dict + ) -> List[Dict]: + """Render all segments with their entries + + Args: + segment_entries: Dictionary mapping segment keys to their entries + segment_configs: Configuration for each segment + + Returns: + List of formatted segment dictionaries + """ + # TODO: Implement + + # In a real implementation, this would: + # 1. Iterate through segment entries + # 2. Apply templates to render each segment + # 3. Return a list of formatted segments + + return [] + + def generate_message( + self, + resources: Union[Resource, List[Resource], Bundle], + message_config: Dict, + formatted_segments: List[Dict], + ) -> str: + """Generate the final HL7v2 message + + Args: + resources: FHIR resources + message_config: Configuration for the message + formatted_segments: List of formatted segment dictionaries + + Returns: + HL7v2 message as string + """ + # TODO: Implement + + # In a real implementation, this would: + # 1. Create message header (MSH segment) + # 2. Add all formatted segments + # 3. Return the complete HL7v2 message + + return "" diff --git a/healthchain/interop/models/cda.py b/healthchain/interop/models/cda.py index cf8e2d23..1de4a7c1 100644 --- a/healthchain/interop/models/cda.py +++ b/healthchain/interop/models/cda.py @@ -35,6 +35,7 @@ class StructuredBody(BaseModel): class ClinicalDocument(BaseModel): """ https://gazelle.ihe.net/CDAGenerator/cda/POCDMT000040ClinicalDocument.html + # TODO: Should be fully implemented """ xmlns: str = Field("urn:hl7-org:v3", alias="@xmlns") diff --git a/healthchain/interop/parsers/__init__.py b/healthchain/interop/parsers/__init__.py new file mode 100644 index 00000000..14d4fdd3 --- /dev/null +++ b/healthchain/interop/parsers/__init__.py @@ -0,0 +1,10 @@ +""" +HealthChain Interoperability Parsers + +This package contains parsers for various healthcare data formats. +""" + +from healthchain.interop.parsers.cda import CDAParser +from healthchain.interop.parsers.hl7v2 import HL7v2Parser + +__all__ = ["CDAParser", "HL7v2Parser"] diff --git a/healthchain/interop/parsers/cda.py b/healthchain/interop/parsers/cda.py index 63536ad3..80c40f33 100644 --- a/healthchain/interop/parsers/cda.py +++ b/healthchain/interop/parsers/cda.py @@ -1,11 +1,19 @@ +""" +CDA Parser for HealthChain Interoperability Engine + +This module provides functionality for parsing CDA XML documents. +""" + +import json import xmltodict import logging - from typing import Dict, List +from healthchain.interop.filters import clean_empty from healthchain.interop.models.cda import ClinicalDocument from healthchain.interop.models.sections import Section, Entry + log = logging.getLogger(__name__) @@ -16,22 +24,54 @@ def __init__(self, mappings: Dict): self.mappings = mappings self.clinical_document = None - def parse_section(self, xml: str, section_config: Dict) -> List[Dict]: + def parse_document(self, xml: str, section_configs: Dict) -> Dict[str, List[Dict]]: """ - Extract entries from a CDA section using template ID or code. + Parse a complete CDA document and extract entries from all configured sections. Args: xml: The CDA XML document - section_config: Configuration for the section containing template ID/code + section_configs: Configuration for all sections Returns: - List of entry dictionaries from the section + Dictionary mapping section keys to lists of entry dictionaries """ + section_entries = {} + + # Parse the document once try: - # Parse XML into ClinicalDocument model doc_dict = xmltodict.parse(xml) self.clinical_document = ClinicalDocument(**doc_dict["ClinicalDocument"]) + except Exception as e: + log.error(f"Error parsing CDA document: {str(e)}") + return section_entries + + # Process each section + for section_key, section_config in section_configs.items(): + try: + entries = self._parse_section_entries_from_document(section_config) + if entries: + section_entries[section_key] = entries + except Exception as e: + log.error(f"Failed to parse section {section_key}: {str(e)}") + continue + + return section_entries + + def _parse_section_entries_from_document(self, section_config: Dict) -> List[Dict]: + """ + Extract entries from a CDA section using an already parsed document. + + Args: + section_config: Configuration for the section containing template ID/code + Returns: + List of entry dictionaries from the section + """ + if not self.clinical_document: + log.error("No document loaded. Call parse_document or parse_section first.") + return [] + + try: # Get all components components = self.clinical_document.component.structuredBody.component if not isinstance(components, list): @@ -69,12 +109,33 @@ def parse_section(self, xml: str, section_config: Dict) -> List[Dict]: ] log.debug(f"Found {len(entry_dicts)} entries in section") + return entry_dicts except Exception as e: - log.error(f"Error parsing CDA document: {str(e)}") + log.error(f"Error parsing section: {str(e)}") return [] + def render_fhir_resource_from_cda_entry( + self, entry: Dict, template, section_config: Dict + ) -> Dict: + """ + Process a CDA entry using a template and prepare it for FHIR conversion + + Args: + entry: The entry data dictionary + template: The template to use for rendering + section_config: Configuration for the section + + Returns: + Dict: Processed resource dictionary ready for FHIR conversion + """ + # Render template with entry data and config + rendered = template.render({"entry": entry, "config": section_config}) + + # Parse rendered JSON and clean empty values + return clean_empty(json.loads(rendered)) + def _find_section_by_template_id(self, section: Section, template_id: str) -> bool: """Check if section matches template ID""" if not section.templateId: diff --git a/healthchain/interop/parsers/hl7v2.py b/healthchain/interop/parsers/hl7v2.py new file mode 100644 index 00000000..75ce95e6 --- /dev/null +++ b/healthchain/interop/parsers/hl7v2.py @@ -0,0 +1,72 @@ +""" +HL7v2 Parser for HealthChain Interoperability Engine + +This module provides functionality for parsing HL7v2 messages. +""" + +import logging +from typing import Dict, List + +log = logging.getLogger(__name__) + + +class HL7v2Parser: + """Parser for HL7v2 messages""" + + def __init__(self, mappings: Dict): + """Initialize the HL7v2 parser + + Args: + mappings: Mappings for code systems and other conversions + """ + self.mappings = mappings + + def parse_message( + self, message: str, message_configs: Dict + ) -> Dict[str, List[Dict]]: + """ + Parse a complete HL7v2 message and extract segments based on configuration. + + Args: + message: The HL7v2 message + message_configs: Configuration for message segments + + Returns: + Dictionary mapping segment keys to lists of segment dictionaries + """ + # This is a placeholder implementation + log.info("Parsing HL7v2 message") + + # In a real implementation, this would: + # 1. Parse the HL7v2 message into segments + # 2. Extract data from segments based on configuration + # 3. Return structured data for conversion to FHIR + + segment_entries = {} + + # Example implementation would parse segments like PID, OBX, etc. + # and organize them into a structure similar to CDA sections + + return segment_entries + + def process_segment(self, segment: Dict, template, segment_config: Dict) -> Dict: + """ + Process an HL7v2 segment using a template and prepare it for FHIR conversion + + Args: + segment: The segment data dictionary + template: The template to use for rendering + segment_config: Configuration for the segment + + Returns: + Dict: Processed resource dictionary ready for FHIR conversion + """ + # This is a placeholder implementation + log.info(f"Processing HL7v2 segment with template {template}") + + # In a real implementation, this would: + # 1. Apply the template to the segment data + # 2. Transform the data into a format suitable for FHIR conversion + + # Placeholder return + return segment diff --git a/healthchain/interop/template_registry.py b/healthchain/interop/template_registry.py new file mode 100644 index 00000000..e87233cb --- /dev/null +++ b/healthchain/interop/template_registry.py @@ -0,0 +1,161 @@ +import logging +from pathlib import Path +from typing import Dict, Callable, Optional + +from liquid import Environment, FileSystemLoader + +log = logging.getLogger(__name__) + + +class TemplateRegistry: + """Manages loading and accessing Liquid templates for the InteropEngine""" + + def __init__(self, template_dir: Path): + """Initialize the TemplateRegistry + + Args: + template_dir: Directory containing template files + """ + self.template_dir = template_dir + self._templates = {} + self._env = None + self._filters = {} + + if not template_dir.exists(): + raise ValueError(f"Template directory not found: {template_dir}") + + def initialize(self, filters: Dict[str, Callable] = None) -> "TemplateRegistry": + """Initialize the Liquid environment and load templates + + Args: + filters: Dictionary of filter names to filter functions + + Returns: + Self for method chaining + """ + # Store initial filters + if filters: + self._filters.update(filters) + + self._create_environment() + self._load_templates() + return self + + def _create_environment(self) -> None: + """Create and configure the Liquid environment with registered filters""" + self._env = Environment(loader=FileSystemLoader(str(self.template_dir))) + + # Register all filters + for name, func in self._filters.items(): + self._env.filters[name] = func + + def add_filter(self, name: str, filter_func: Callable) -> "TemplateRegistry": + """Add a custom filter function + + Args: + name: Name of the filter to use in templates + filter_func: Filter function to register + + Returns: + Self for method chaining + """ + # Add to internal filter registry + self._filters[name] = filter_func + + # If environment is already initialized, register the filter + if self._env: + self._env.filters[name] = filter_func + + return self + + def add_filters(self, filters: Dict[str, Callable]) -> "TemplateRegistry": + """Add multiple custom filter functions + + Args: + filters: Dictionary of filter names to filter functions + + Returns: + Self for method chaining + """ + for name, func in filters.items(): + self.add_filter(name, func) + + return self + + def get_filter(self, name: str) -> Optional[Callable]: + """Get a registered filter function by name + + Args: + name: Name of the filter + + Returns: + The filter function or None if not found + """ + return self._filters.get(name) + + def get_filters(self) -> Dict[str, Callable]: + """Get all registered filter functions + + Returns: + Dictionary of filter names to filter functions + """ + return self._filters.copy() + + def _load_templates(self) -> None: + """Load all template files""" + if not self._env: + raise ValueError("Environment not initialized. Call initialize() first.") + + # Walk through all subdirectories to find template files + for template_file in self.template_dir.rglob("*.liquid"): + rel_path = template_file.relative_to(self.template_dir) + template_key = rel_path.stem + + try: + template = self._env.get_template(str(rel_path)) + self._templates[template_key] = template + log.debug(f"Loaded template: {template_key}") + except Exception as e: + log.error(f"Failed to load template {template_file}: {str(e)}") + continue + + if not self._templates: + raise ValueError(f"No templates found in {self.template_dir}") + + log.info(f"Loaded {len(self._templates)} templates") + + def get_template(self, template_key: str): + """Get a template by key + + Args: + template_key: Template identifier + + Returns: + The template object + + Raises: + KeyError: If template not found + """ + if template_key not in self._templates: + raise KeyError(f"Template not found: {template_key}") + + return self._templates[template_key] + + def has_template(self, template_key: str) -> bool: + """Check if a template exists + + Args: + template_key: Template identifier + + Returns: + True if template exists, False otherwise + """ + return template_key in self._templates + + def get_all_templates(self) -> Dict: + """Get all templates + + Returns: + Dict of template keys to template objects + """ + return self._templates From cfcad89678c9d59d72ea29a7dadd512db1273807 Mon Sep 17 00:00:00 2001 From: jenniferjiangkells Date: Fri, 14 Mar 2025 11:00:56 +0000 Subject: [PATCH 05/25] Tidy --- healthchain/interop/__init__.py | 8 +- .../interop/config/configs/defaults.yaml | 25 +- .../config/configs/sections/allergies.yaml | 2 +- .../config/configs/sections/medications.yaml | 2 +- .../config/configs/sections/problems.yaml | 4 +- .../templates/cda/cda_problem_entry.liquid | 8 +- .../config/templates/cda/cda_section.liquid | 2 +- healthchain/interop/config_manager.py | 3 +- healthchain/interop/converters/__init__.py | 5 +- healthchain/interop/converters/fhir.py | 432 ++++++------------ healthchain/interop/engine.py | 147 +++--- healthchain/interop/generators/cda.py | 112 +++-- healthchain/interop/generators/fhir.py | 95 ++++ healthchain/interop/parsers/cda.py | 78 ++-- healthchain/interop/parsers/hl7v2.py | 68 ++- healthchain/interop/template_renderer.py | 126 +++++ 16 files changed, 639 insertions(+), 478 deletions(-) create mode 100644 healthchain/interop/generators/fhir.py create mode 100644 healthchain/interop/template_renderer.py diff --git a/healthchain/interop/__init__.py b/healthchain/interop/__init__.py index 5551b330..584be433 100644 --- a/healthchain/interop/__init__.py +++ b/healthchain/interop/__init__.py @@ -7,10 +7,12 @@ from .engine import InteropEngine, FormatType from .config_manager import ConfigManager, ValidationLevel from .template_registry import TemplateRegistry +from .template_renderer import TemplateRenderer from .parsers.cda import CDAParser from .parsers.hl7v2 import HL7v2Parser -from .converters.fhir import FHIRConverter +from .converters import fhir as fhir_utils from .generators.cda import CDAGenerator +from .generators.fhir import FHIRGenerator from .generators.hl7v2 import HL7v2Generator __all__ = [ @@ -19,9 +21,11 @@ "ConfigManager", "ValidationLevel", "TemplateRegistry", + "TemplateRenderer", "CDAParser", "HL7v2Parser", - "FHIRConverter", + "fhir_utils", "CDAGenerator", + "FHIRGenerator", "HL7v2Generator", ] diff --git a/healthchain/interop/config/configs/defaults.yaml b/healthchain/interop/config/configs/defaults.yaml index 16bbb0b3..8f28c879 100644 --- a/healthchain/interop/config/configs/defaults.yaml +++ b/healthchain/interop/config/configs/defaults.yaml @@ -46,7 +46,30 @@ defaults: type: "allergy" criticality: "low" -# TODO: Implement +# Template names and paths +templates: + # Core templates + core: + section: "cda_section" + document: "cda_document" + + # Resource templates + resources: + condition: "cda/condition" + medication_statement: "cda/medication_statement" + allergy_intolerance: "cda/allergy_intolerance" + observation: "cda/observation" + procedure: "cda/procedure" + + # Entry templates + entries: + problem: "cda/cda_problem_entry" + medication: "cda/cda_medication_entry" + allergy: "cda/cda_allergy_entry" + observation: "cda/cda_observation_entry" + procedure: "cda/cda_procedure_entry" + +# Format settings formats: # Date and time formats date: diff --git a/healthchain/interop/config/configs/sections/allergies.yaml b/healthchain/interop/config/configs/sections/allergies.yaml index 1d681c09..202579b2 100644 --- a/healthchain/interop/config/configs/sections/allergies.yaml +++ b/healthchain/interop/config/configs/sections/allergies.yaml @@ -6,7 +6,7 @@ resource: "AllergyIntolerance" resource_template: "cda/allergy_intolerance" entry_template: "cda/cda_allergy_entry" -section_template_id: "2.16.840.1.113883.10.20.1.2" +template_id: "2.16.840.1.113883.10.20.1.2" code: "48765-2" display: "Allergies" diff --git a/healthchain/interop/config/configs/sections/medications.yaml b/healthchain/interop/config/configs/sections/medications.yaml index 74d6f8f7..6d3ba318 100644 --- a/healthchain/interop/config/configs/sections/medications.yaml +++ b/healthchain/interop/config/configs/sections/medications.yaml @@ -6,7 +6,7 @@ resource: "MedicationStatement" resource_template: "cda/medication_statement" entry_template: "cda/cda_medication_entry" -section_template_id: "2.16.840.1.113883.10.20.1.8" +template_id: "2.16.840.1.113883.10.20.1.8" code: "10160-0" display: "Medications" diff --git a/healthchain/interop/config/configs/sections/problems.yaml b/healthchain/interop/config/configs/sections/problems.yaml index 11ff580d..b9565b06 100644 --- a/healthchain/interop/config/configs/sections/problems.yaml +++ b/healthchain/interop/config/configs/sections/problems.yaml @@ -8,9 +8,9 @@ resource: "Condition" resource_template: "cda/condition" entry_template: "cda/cda_problem_entry" -section_template_id: "2.16.840.1.113883.10.20.1.11" +template_id: "2.16.840.1.113883.10.20.1.11" code: "11450-4" -display: "Problem List" +display: "Processed Problem List" # Entry act template IDs entry_act_template_ids: diff --git a/healthchain/interop/config/templates/cda/cda_problem_entry.liquid b/healthchain/interop/config/templates/cda/cda_problem_entry.liquid index 97dab8c3..a2e4163d 100644 --- a/healthchain/interop/config/templates/cda/cda_problem_entry.liquid +++ b/healthchain/interop/config/templates/cda/cda_problem_entry.liquid @@ -13,7 +13,7 @@ "@code": "{{ config.act_status_code | default: 'active' }}" }, "effectiveTime": { - "low": {"@value": "{{ context.timestamp }}"} + "low": {"@value": "{{ timestamp }}"} }, "entryRelationship": { "@typeCode": "{{ config.entry_relationship_type_code | default: 'SUBJ' }}", @@ -34,7 +34,7 @@ "@displayName": "{{ config.observation_display_name | default: 'Problem' }}" }, "text": { - "reference": {"@value": "#{{ context.text_reference_name }}"} + "reference": {"@value": "#{{ text_reference_name }}"} }, "statusCode": {"@code": "{{ config.observation_status_code | default: 'completed' }}"}, "effectiveTime": { @@ -52,7 +52,7 @@ "@codeSystem": "{{ resource.code.coding[0].system | map_system: 'fhir_to_cda' }}", "@displayName": "{{ resource.code.coding[0].display }}", "originalText": { - "reference": {"@value": "#{{ context.text_reference_name }}"} + "reference": {"@value": "#{{ text_reference_name }}"} } }, "entryRelationship": { @@ -74,7 +74,7 @@ }, "statusCode": {"@code": "{{ config.status_observation_status_code | default: 'completed' }}"}, "effectiveTime": { - "low": {"@value": "{{ context.timestamp }}"} + "low": {"@value": "{{ timestamp }}"} } } } diff --git a/healthchain/interop/config/templates/cda/cda_section.liquid b/healthchain/interop/config/templates/cda/cda_section.liquid index 726a014d..f9b2be55 100644 --- a/healthchain/interop/config/templates/cda/cda_section.liquid +++ b/healthchain/interop/config/templates/cda/cda_section.liquid @@ -1,7 +1,7 @@ { "section": { "templateId": { - "@root": "{{ config.section_template_id }}" + "@root": "{{ config.template_id }}" }, "code": { "@code": "{{ config.code }}", diff --git a/healthchain/interop/config_manager.py b/healthchain/interop/config_manager.py index 0357e76d..de0d032d 100644 --- a/healthchain/interop/config_manager.py +++ b/healthchain/interop/config_manager.py @@ -25,9 +25,8 @@ class ConfigManager: "resource", "resource_template", "entry_template", - "section_template_id", + "template_id", "code", - "display", } REQUIRED_DOCUMENT_KEYS = { diff --git a/healthchain/interop/converters/__init__.py b/healthchain/interop/converters/__init__.py index 1f9d90f7..b7d9c65a 100644 --- a/healthchain/interop/converters/__init__.py +++ b/healthchain/interop/converters/__init__.py @@ -4,6 +4,7 @@ This package contains converters for various healthcare data formats. """ -from healthchain.interop.converters.fhir import FHIRConverter +# Import utility functions from fhir module +from healthchain.interop.converters import fhir -__all__ = ["FHIRConverter"] +__all__ = ["fhir"] diff --git a/healthchain/interop/converters/fhir.py b/healthchain/interop/converters/fhir.py index 65ce6a89..35467558 100644 --- a/healthchain/interop/converters/fhir.py +++ b/healthchain/interop/converters/fhir.py @@ -1,14 +1,13 @@ """ -FHIR Converter for HealthChain Interoperability Engine +FHIR Converter Utilities for HealthChain Interoperability Engine -This module provides functionality for converting to and from FHIR resources. +This module provides utility functions for converting to and from FHIR resources. """ import logging import importlib import uuid -from typing import Dict, List, Optional, Union -from pathlib import Path +from typing import Dict, List, Optional, Union, Any from fhir.resources.resource import Resource from fhir.resources.bundle import Bundle @@ -16,300 +15,165 @@ log = logging.getLogger(__name__) -class FHIRConverter: - """Handles conversion to and from FHIR resources""" - - def __init__(self, config_manager): - """Initialize the FHIR converter - - Args: - config_manager: Configuration manager instance - """ - self.config_manager = config_manager - self.template_registry = None - self.parser = None - - def set_template_registry(self, template_registry): - """Set the template registry - - Args: - template_registry: Template registry instance - """ - self.template_registry = template_registry - return self - - def set_parser(self, parser): - """Set the parser - - Args: - parser: Parser instance - """ - self.parser = parser - return self - - def _create_fhir_resource_from_dict( - self, 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: - log.error(f"Failed to create FHIR resource: {str(e)}") - return None - - def add_required_fields(self, resource_dict: Dict, resource_type: str): - """Add required fields to resource dictionary based on type - - Args: - resource_dict: Dictionary representation of the resource - resource_type: Type of FHIR resource - """ - # Add common fields - id_prefix = self.config_manager.get_config_value( - "defaults.common.id_prefix", "hc-" - ) - if "id" not in resource_dict: - resource_dict["id"] = f"{id_prefix}{str(uuid.uuid4())}" +def create_resource(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 - # Get default values from configuration if available - default_subject = self.config_manager.get_config_value( - "defaults.common.subject", {"reference": "Patient/example"} + Returns: + Optional[Resource]: FHIR resource instance or None if creation failed + """ + try: + resource_module = importlib.import_module( + f"fhir.resources.{resource_type.lower()}" ) - if "subject" not in resource_dict: - resource_dict["subject"] = default_subject - - # Add resource-specific required fields - if resource_type == "Condition": - if "clinicalStatus" not in resource_dict: - default_status = self.config_manager.get_config_value( - "defaults.resources.Condition.clinicalStatus", - { - "coding": [ - { - "system": "http://terminology.hl7.org/CodeSystem/condition-clinical", - "code": "unknown", - } - ] - }, - ) - resource_dict["clinicalStatus"] = default_status - elif resource_type == "MedicationStatement": - if "status" not in resource_dict: - default_status = self.config_manager.get_config_value( - "defaults.resources.MedicationStatement.status", "unknown" - ) - resource_dict["status"] = default_status - elif resource_type == "AllergyIntolerance": - if "clinicalStatus" not in resource_dict: - default_status = self.config_manager.get_config_value( - "defaults.resources.AllergyIntolerance.clinicalStatus", - { - "coding": [ - { - "system": "http://terminology.hl7.org/CodeSystem/allergyintolerance-clinical", - "code": "unknown", - } - ] - }, - ) - resource_dict["clinicalStatus"] = default_status - - def normalize_resources( - self, resources: Union[Resource, List[Resource], Bundle] - ) -> List[Resource]: - """Convert input resources to a normalized list format - - Args: - resources: A FHIR Bundle, list of resources, or single resource - - Returns: - List of FHIR resources - """ - if isinstance(resources, Bundle): - return [entry.resource for entry in resources.entry if entry.resource] - elif isinstance(resources, list): - return resources - else: - return [resources] - - def convert_cda_entries_to_fhir_resources( - self, section_entries: Dict, section_configs: Dict - ) -> List[Resource]: - """Convert CDA section entries to FHIR resources - - Args: - section_entries: Dictionary mapping section keys to lists of entry dictionaries - section_configs: Configuration for all sections - - Returns: - List of FHIR resources - """ - if not self.template_registry or not self.parser: - raise ValueError( - "Template registry and parser must be set before conversion" + resource_class = getattr(resource_module, resource_type) + return resource_class(**resource_dict) + except Exception as e: + log.error(f"Failed to create FHIR resource: {str(e)}") + return None + + +def add_required_fields( + resource_dict: Dict, resource_type: str, config_manager: Any +) -> Dict: + """Add required fields to resource dictionary based on type + + Args: + resource_dict: Dictionary representation of the resource + resource_type: Type of FHIR resource + config_manager: Configuration manager instance + + Returns: + Dict: The resource dictionary with required fields added + """ + # Add common fields + id_prefix = config_manager.get_config_value("defaults.common.id_prefix", "hc-") + if "id" not in resource_dict: + resource_dict["id"] = f"{id_prefix}{str(uuid.uuid4())}" + + # Get default values from configuration if available + default_subject = config_manager.get_config_value( + "defaults.common.subject", {"reference": "Patient/example"} + ) + if "subject" not in resource_dict: + resource_dict["subject"] = default_subject + + # Add resource-specific required fields + if resource_type == "Condition": + if "clinicalStatus" not in resource_dict: + default_status = config_manager.get_config_value( + "defaults.resources.Condition.clinicalStatus", + { + "coding": [ + { + "system": "http://terminology.hl7.org/CodeSystem/condition-clinical", + "code": "unknown", + } + ] + }, + ) + resource_dict["clinicalStatus"] = default_status + elif resource_type == "MedicationStatement": + if "status" not in resource_dict: + default_status = config_manager.get_config_value( + "defaults.resources.MedicationStatement.status", "unknown" + ) + resource_dict["status"] = default_status + elif resource_type == "AllergyIntolerance": + if "clinicalStatus" not in resource_dict: + default_status = config_manager.get_config_value( + "defaults.resources.AllergyIntolerance.clinicalStatus", + { + "coding": [ + { + "system": "http://terminology.hl7.org/CodeSystem/allergyintolerance-clinical", + "code": "unknown", + } + ] + }, ) + resource_dict["clinicalStatus"] = default_status - resources = [] + return resource_dict - for section_key, entries in section_entries.items(): - if section_key not in section_configs: - log.warning(f"No configuration found for section: {section_key}") - continue - section_config = section_configs[section_key] - template_key = Path(section_config.get("resource_template", "")).stem +def normalize_resources( + resources: Union[Resource, List[Resource], Bundle], +) -> List[Resource]: + """Convert input resources to a normalized list format - if not template_key: - log.warning( - f"No resource template specified for section: {section_key}" - ) - continue + Args: + resources: A FHIR Bundle, list of resources, or single resource - if not self.template_registry.has_template(template_key): - log.warning( - f"Template {template_key} not found, skipping section {section_key}" - ) - continue + Returns: + List of FHIR resources + """ + if isinstance(resources, Bundle): + return [entry.resource for entry in resources.entry if entry.resource] + elif isinstance(resources, list): + return resources + else: + return [resources] - template = self.template_registry.get_template(template_key) - # Process each entry in the section - section_resources = self._convert_cda_entries_to_fhir( - entries, template, section_config, section_key - ) - resources.extend(section_resources) +def convert_resource_dicts_to_resources( + resource_dicts: List[Dict], resource_type: str, config_manager: Any +) -> List[Resource]: + """Convert a list of resource dictionaries to FHIR resources - return resources + Args: + resource_dicts: List of resource dictionaries + resource_type: Type of FHIR resource to create + config_manager: Configuration manager instance - def convert_fhir_to_cda_entries( - self, resources: List[Resource], section_configs: Dict, cda_generator - ) -> Dict: - """Process resources and group them by section with rendered entries - - Args: - resources: List of FHIR resources - section_configs: Configuration for all sections - cda_generator: CDA generator instance for rendering entries - - Returns: - Dictionary mapping section keys to lists of entry dictionaries - """ - if not self.template_registry: - raise ValueError( - "Template registry must be set before processing resources" - ) + Returns: + List of FHIR resources + """ + resources = [] - section_entries = {} + for resource_dict in resource_dicts: + # Add required fields based on resource type + resource_dict = add_required_fields( + resource_dict, resource_type, config_manager + ) - for resource in resources: - # Find matching section for resource type - section_key = self._find_section_for_fhir_resource( - resource, section_configs - ) + # Create FHIR resource instance + resource = create_resource(resource_dict, resource_type) + if resource: + resources.append(resource) - if not section_key: - continue - - # Get template for this section - template_name = Path( - section_configs[section_key].get("entry_template", "") - ).stem - if not self.template_registry.has_template(template_name): - log.warning( - f"Template {template_name} not found, skipping section {section_key}" - ) - continue - - # Render entry using template - entry = cda_generator.render_entry( - resource, section_key, template_name, section_configs[section_key] - ) - if entry: - section_entries.setdefault(section_key, []).append(entry) - - return section_entries - - def _find_section_for_fhir_resource( - self, resource: Resource, section_configs: Dict - ) -> Optional[str]: - """Find the appropriate section key for a given resource - - Args: - resource: FHIR resource - section_configs: Configuration for all sections - - Returns: - Section key or None if no matching section found - """ - resource_type = resource.__class__.__name__ - - # Find matching section for resource type - section_key = next( - ( - key - for key, config in section_configs.items() - if config.get("resource") == resource_type - ), - None, - ) + return resources - if not section_key: - log.warning(f"Unsupported resource type: {resource_type}") - - return section_key - - def _convert_cda_entries_to_fhir( - self, entries: List[Dict], template, section_config: Dict, section_key: str - ) -> List[Resource]: - """Process entries from a single section and convert to FHIR resources - - Args: - entries: List of entries from a section - template: The template to use for rendering - section_config: Configuration for the section - section_key: Key identifying the section - - Returns: - List[Resource]: List of FHIR resources from this section - """ - resources = [] - resource_type = section_config.get("resource") - - if not resource_type: - log.error(f"Missing resource type in section config for {section_key}") - return resources - - for entry in entries: - try: - # Convert entry to FHIR resource dictionary using the parser - resource_dict = self.parser.render_fhir_resource_from_cda_entry( - entry, template, section_config - ) - - # Add required fields based on resource type - self.add_required_fields(resource_dict, resource_type) - - # Create FHIR resource instance - resource = self._create_fhir_resource_from_dict( - resource_dict, resource_type - ) - if resource: - resources.append(resource) - - except Exception as e: - log.error(f"Failed to convert entry in section {section_key}: {str(e)}") - continue - return resources +def find_section_for_resource_type( + resource_type: str, config_manager: Any +) -> Optional[str]: + """Find the appropriate section key for a given resource type + + Args: + resource_type: FHIR resource type + config_manager: Configuration manager instance + + Returns: + Section key or None if no matching section found + """ + # Get section configurations + section_configs = config_manager.get_section_configs() + + # Find matching section for resource type + section_key = next( + ( + key + for key, config in section_configs.items() + if config.get("resource") == resource_type + ), + None, + ) + + if not section_key: + log.warning(f"Unsupported resource type: {resource_type}") + + return section_key diff --git a/healthchain/interop/engine.py b/healthchain/interop/engine.py index eb1f2510..2366607d 100644 --- a/healthchain/interop/engine.py +++ b/healthchain/interop/engine.py @@ -20,8 +20,9 @@ ) from .config_manager import ConfigManager, ValidationLevel from .template_registry import TemplateRegistry -from .converters.fhir import FHIRConverter +from .converters import fhir as fhir_utils from .generators.cda import CDAGenerator +from .generators.fhir import FHIRGenerator from .generators.hl7v2 import HL7v2Generator log = logging.getLogger(__name__) @@ -74,7 +75,6 @@ def __init__( # Component registries for lazy loading self._parsers = {} - self._converters = {} self._generators = {} # Lazy-loaded parsers @@ -88,18 +88,17 @@ def hl7v2_parser(self): """Lazily load the HL7v2 parser""" return self._get_parser(FormatType.HL7V2) - # Lazy-loaded converters - @cached_property - def fhir_converter(self): - """Lazily load the FHIR converter""" - return self._get_converter(FormatType.FHIR) - # 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""" @@ -116,36 +115,16 @@ def _get_parser(self, format_type: FormatType): """ if format_type not in self._parsers: if format_type == FormatType.CDA: - parser = CDAParser(self.config_manager.get_mappings()) + parser = CDAParser(self.config_manager) self._parsers[format_type] = parser elif format_type == FormatType.HL7V2: - parser = HL7v2Parser(self.config_manager.get_mappings()) + parser = HL7v2Parser(self.config_manager) self._parsers[format_type] = parser else: raise ValueError(f"Unsupported parser format: {format_type}") return self._parsers[format_type] - def _get_converter(self, format_type: FormatType): - """Get or create a converter for the specified format - - Args: - format_type: The format type to get a converter for - - Returns: - The converter instance - """ - if format_type not in self._converters: - if format_type == FormatType.FHIR: - converter = FHIRConverter(self.config_manager) - converter.set_template_registry(self.template_registry) - # Parsers will be set when needed - self._converters[format_type] = converter - else: - raise ValueError(f"Unsupported converter format: {format_type}") - - return self._converters[format_type] - def _get_generator(self, format_type: FormatType): """Get or create a generator for the specified format @@ -162,6 +141,9 @@ def _get_generator(self, format_type: FormatType): elif format_type == FormatType.HL7V2: generator = HL7v2Generator(self.config_manager, self.template_registry) self._generators[format_type] = generator + elif format_type == FormatType.FHIR: + generator = FHIRGenerator(self.config_manager, self.template_registry) + self._generators[format_type] = generator else: raise ValueError(f"Unsupported generator format: {format_type}") @@ -180,19 +162,6 @@ def register_parser(self, format_type: FormatType, parser_instance): self._parsers[format_type] = parser_instance return self - def register_converter(self, format_type: FormatType, converter_instance): - """Register a custom converter for a format - - Args: - format_type: The format type to register the converter for - converter_instance: The converter instance - - Returns: - Self for method chaining - """ - self._converters[format_type] = converter_instance - return self - def register_generator(self, format_type: FormatType, generator_instance): """Register a custom generator for a format @@ -414,20 +383,36 @@ def _cda_to_fhir(self, source_data: str) -> List[Resource]: if not section_configs: raise ValueError("No section configs found in configs/cda/section.yaml") - # Get parser and converter (lazy loaded) + # Get parser and generator (lazy loaded) parser = self.cda_parser - converter = self.fhir_converter - - # Ensure converter has access to parser - converter.set_parser(parser) + generator = self.fhir_generator # Parse sections from CDA XML using the parser - section_entries = parser.parse_document(source_data, section_configs) - - # Convert entries to FHIR resources using the FHIR converter - return converter.convert_cda_entries_to_fhir_resources( - section_entries, section_configs - ) + section_entries = parser.parse_document(source_data) + + # Process each section and convert entries to FHIR resources + resources = [] + for section_key, entries in section_entries.items(): + # Get resource type from section config + resource_type = self.config_manager.get_config_value( + f"sections.{section_key}.resource", None + ) + if not resource_type: + log.warning(f"No resource type specified for section {section_key}") + continue + + # Convert entries to resource dictionaries using the generator + resource_dicts = generator.convert_entries_to_resources( + entries, section_key, resource_type + ) + + # Convert resource dictionaries to FHIR resources using the utility functions + section_resources = fhir_utils.convert_resource_dicts_to_resources( + resource_dicts, resource_type, self.config_manager + ) + resources.extend(section_resources) + + return resources def _fhir_to_cda(self, resources: Union[Resource, List[Resource]]) -> str: """Convert FHIR resources to CDA XML @@ -441,31 +426,39 @@ def _fhir_to_cda(self, resources: Union[Resource, List[Resource]]) -> str: Raises: ValueError: If required mappings are missing or if resource types are unsupported """ - # Get required configurations - section_configs = self.config_manager.get_section_configs() - document_config = self.config_manager.get_document_config() - - if not section_configs or not document_config: - raise ValueError("No section or document configs found in configs/cda") - - # Get converter and generator (lazy loaded) - converter = self.fhir_converter - generator = self.cda_generator + # Get generators (lazy loaded) + cda_generator = self.cda_generator # Normalize input to list of resources - resource_list = converter.normalize_resources(resources) - - # Process resources and generate section entries - section_entries = converter.convert_fhir_to_cda_entries( - resource_list, section_configs, generator - ) - - # Render sections - formatted_sections = generator.render_sections(section_entries, section_configs) - - # Generate final CDA document - return generator.generate_document( - resources, document_config, formatted_sections + resource_list = fhir_utils.normalize_resources(resources) + + # Process resources and group by section + section_entries = {} + for resource in resource_list: + resource_type = resource.__class__.__name__ + + # Find matching section for resource type using utility function + section_key = fhir_utils.find_section_for_resource_type( + resource_type, self.config_manager + ) + if not section_key: + continue + + # Get template name for this section + template_name = cda_generator.get_section_template_name( + section_key, "entry" + ) + if not template_name: + continue + + # Render entry using template + entry = cda_generator.render_entry(resource, section_key, template_name) + if entry: + section_entries.setdefault(section_key, []).append(entry) + + # Generate the complete CDA document using the simplified method + return cda_generator.generate_document_from_resources( + resources, section_entries ) def _hl7v2_to_fhir(self, source_data: str) -> List[Resource]: diff --git a/healthchain/interop/generators/cda.py b/healthchain/interop/generators/cda.py index 97e1644a..3d972a9b 100644 --- a/healthchain/interop/generators/cda.py +++ b/healthchain/interop/generators/cda.py @@ -10,48 +10,39 @@ import xmltodict import uuid from datetime import datetime -from typing import Dict, List, Union +from typing import Dict, List, Union, Optional from fhir.resources.resource import Resource from fhir.resources.bundle import Bundle -from healthchain.interop.filters import clean_empty +from healthchain.interop.template_renderer import TemplateRenderer log = logging.getLogger(__name__) -class CDAGenerator: +class CDAGenerator(TemplateRenderer): """Handles generation of CDA documents""" - def __init__(self, config_manager, template_registry): - """Initialize the CDA generator - - Args: - config_manager: Configuration manager instance - template_registry: Template registry instance - """ - self.config_manager = config_manager - self.template_registry = template_registry - def render_entry( self, resource: Resource, section_key: str, template_name: str, - section_config: Dict, - ) -> Dict: + ) -> Optional[Dict]: """Render a single entry for a resource Args: resource: FHIR resource section_key: Key identifying the section template_name: Name of the template to use - section_config: Configuration for the section Returns: Dictionary representation of the rendered entry """ try: + # Get section configuration + section_config = self.get_section_config(section_key) + # Create context with common values timestamp_format = self.config_manager.get_config_value( "formats.date.timestamp", "%Y%m%d" @@ -71,59 +62,56 @@ def render_entry( "rendering": self.config_manager.get_config_value( f"sections.{section_key}.rendering", {} ), - "formats": self.config_manager.get_config_value("formats", {}), + "resource": resource.model_dump(), + "config": section_config, } # Get template and render - template = self.template_registry.get_template(template_name) - entry_json = template.render( - resource=resource.model_dump(), - config=section_config, - context=context, - ) + template = self.get_template(template_name) + if not template: + return None - # Parse and clean the rendered JSON - return clean_empty(json.loads(entry_json)) + return self.render_template(template, context) except Exception as e: log.error(f"Failed to render {section_key} entry: {str(e)}") return None - def render_sections( - self, section_entries: Dict, section_configs: Dict - ) -> List[Dict]: + def render_sections(self, section_entries: Dict) -> List[Dict]: """Render all sections with their entries Args: section_entries: Dictionary mapping section keys to their entries - section_configs: Configuration for each section Returns: List of formatted section dictionaries """ formatted_sections = [] + # Get section configurations + section_configs = self.get_section_configs() + # Get section template name from config or use default section_template_name = self.config_manager.get_config_value( "templates.core.section", "cda_section" ) - try: - section_template = self.template_registry.get_template( - section_template_name - ) - except KeyError: + # Get the section template + section_template = self.get_template(section_template_name) + if not section_template: raise ValueError(f"Required template '{section_template_name}' not found") for section_key, section_config in section_configs.items(): entries = section_entries.get(section_key, []) if entries: try: - section_json = section_template.render( - entries=entries, - config=section_config, - ) - formatted_sections.append(json.loads(section_json)) + context = { + "entries": entries, + "config": section_config, + } + rendered = self.render_template(section_template, context) + if rendered: + formatted_sections.append(rendered) except Exception as e: log.error(f"Failed to render section {section_key}: {str(e)}") @@ -150,11 +138,9 @@ def generate_document( "templates.core.document", "cda_document" ) - try: - document_template = self.template_registry.get_template( - document_template_name - ) - except KeyError: + # Get the document template + document_template = self.get_template(document_template_name) + if not document_template: raise ValueError(f"Required template '{document_template_name}' not found") # Create document context with additional configuration @@ -178,8 +164,8 @@ def generate_document( } # Render document - document_json = document_template.render(**context) - document_dict = json.loads(document_json) + rendered = document_template.render(**context) + document_dict = json.loads(rendered) # Get XML formatting options pretty_print = self.config_manager.get_config_value( @@ -196,3 +182,37 @@ def generate_document( # Fix self-closing tags return re.sub(r"(<(\w+)(\s+[^>]*?)?)>", r"\1/>", xml_string) + + def generate_document_from_resources( + self, + resources: Union[Resource, List[Resource], Bundle], + section_entries_map: Optional[Dict] = None, + ) -> str: + """Generate a complete CDA document from FHIR resources + + This method handles the entire process of generating a CDA document: + 1. Creating section entries from resources (if not provided) + 2. Rendering sections + 3. Generating the final document + + Args: + resources: FHIR resources to include in the document + section_entries_map: Optional pre-populated section entries map + + Returns: + CDA document as XML string + """ + # Get document configuration + document_config = self.config_manager.get_document_config() + if not document_config: + raise ValueError("No document configuration found") + + # If section entries weren't provided, we'll use an empty map + if section_entries_map is None: + section_entries_map = {} + + # Render sections + formatted_sections = self.render_sections(section_entries_map) + + # Generate final CDA document + return self.generate_document(resources, document_config, formatted_sections) diff --git a/healthchain/interop/generators/fhir.py b/healthchain/interop/generators/fhir.py new file mode 100644 index 00000000..a38008ec --- /dev/null +++ b/healthchain/interop/generators/fhir.py @@ -0,0 +1,95 @@ +""" +FHIR Generator for HealthChain Interoperability Engine + +This module provides functionality for generating FHIR resources from templates. +""" + +import logging +from typing import Dict, List, Optional + +from healthchain.interop.template_renderer import TemplateRenderer + +log = logging.getLogger(__name__) + + +class FHIRGenerator(TemplateRenderer): + """Handles generation of FHIR resources from templates""" + + def render_resource_from_entry( + self, entry: Dict, section_key: str, template=None + ) -> Optional[Dict]: + """ + Process an entry using a template and prepare it for FHIR conversion + + Args: + entry: The entry data dictionary + section_key: Key identifying the section + template: Optional template to use (if not provided, will be retrieved from section config) + + Returns: + Dict: Processed resource dictionary ready for FHIR conversion + """ + try: + # Get template if not provided + if template is None: + template = self.get_section_template(section_key, "resource") + if not template: + log.error(f"No resource template found for section {section_key}") + return None + + # Get section configuration + section_config = self.get_section_config(section_key) + + # Create context with entry data and config + context = {"entry": entry, "config": section_config} + + # Add rendering options to context if available + rendering = self.config_manager.get_config_value( + f"sections.{section_key}.rendering", {} + ) + if rendering: + context["rendering"] = rendering + + # Render template with context + return self.render_template(template, context) + + except Exception as e: + log.error(f"Failed to render resource for section {section_key}: {str(e)}") + return None + + def convert_entries_to_resources( + self, entries: List[Dict], section_key: str, resource_type: str + ) -> List[Dict]: + """ + Convert entries from a section to FHIR resource dictionaries + + Args: + entries: List of entries from a section + section_key: Key identifying the section + resource_type: Type of FHIR resource to create + + Returns: + List of FHIR resource dictionaries + """ + resource_dicts = [] + template = self.get_section_template(section_key, "resource") + + if not template: + log.error(f"No resource template found for section {section_key}") + return resource_dicts + + for entry in entries: + try: + # Convert entry to FHIR resource dictionary + resource_dict = self.render_resource_from_entry( + entry, section_key, template + ) + + if resource_dict: + resource_dicts.append(resource_dict) + + except Exception as e: + log.error(f"Failed to convert entry in section {section_key}: {str(e)}") + continue + + return resource_dicts diff --git a/healthchain/interop/parsers/cda.py b/healthchain/interop/parsers/cda.py index 80c40f33..664acae1 100644 --- a/healthchain/interop/parsers/cda.py +++ b/healthchain/interop/parsers/cda.py @@ -4,15 +4,13 @@ This module provides functionality for parsing CDA XML documents. """ -import json import xmltodict import logging from typing import Dict, List -from healthchain.interop.filters import clean_empty from healthchain.interop.models.cda import ClinicalDocument from healthchain.interop.models.sections import Section, Entry - +from healthchain.interop.config_manager import ConfigManager log = logging.getLogger(__name__) @@ -20,17 +18,21 @@ class CDAParser: """Parser for CDA XML documents""" - def __init__(self, mappings: Dict): - self.mappings = mappings + def __init__(self, config_manager: ConfigManager): + """Initialize the CDA parser + + Args: + config_manager: ConfigManager instance for accessing configuration + """ + self.config_manager = config_manager self.clinical_document = None - def parse_document(self, xml: str, section_configs: Dict) -> Dict[str, List[Dict]]: + def parse_document(self, xml: str) -> Dict[str, List[Dict]]: """ Parse a complete CDA document and extract entries from all configured sections. Args: xml: The CDA XML document - section_configs: Configuration for all sections Returns: Dictionary mapping section keys to lists of entry dictionaries @@ -45,10 +47,16 @@ def parse_document(self, xml: str, section_configs: Dict) -> Dict[str, List[Dict log.error(f"Error parsing CDA document: {str(e)}") return section_entries - # Process each section - for section_key, section_config in section_configs.items(): + # Get section configurations + sections = self.config_manager.get_config_value("sections") + if not sections: + log.warning("No sections found in configuration") + return section_entries + + # Process each section from the configuration + for section_key in sections.keys(): try: - entries = self._parse_section_entries_from_document(section_config) + entries = self._parse_section_entries_from_document(section_key) if entries: section_entries[section_key] = entries except Exception as e: @@ -57,18 +65,18 @@ def parse_document(self, xml: str, section_configs: Dict) -> Dict[str, List[Dict return section_entries - def _parse_section_entries_from_document(self, section_config: Dict) -> List[Dict]: + def _parse_section_entries_from_document(self, section_key: str) -> List[Dict]: """ Extract entries from a CDA section using an already parsed document. Args: - section_config: Configuration for the section containing template ID/code + section_key: Key identifying the section in the configuration Returns: List of entry dictionaries from the section """ if not self.clinical_document: - log.error("No document loaded. Call parse_document or parse_section first.") + log.error("No document loaded. Call parse_document first.") return [] try: @@ -82,22 +90,26 @@ def _parse_section_entries_from_document(self, section_config: Dict) -> List[Dic for component in components: curr_section = component.section - if section_config.get( - "template_id" - ) and self._find_section_by_template_id( - curr_section, section_config["template_id"] + # Get template_id and code from config_manager + template_id = self.config_manager.get_config_value( + f"sections.{section_key}.template_id", None + ) + code = self.config_manager.get_config_value( + f"sections.{section_key}.code", None + ) + + if template_id and self._find_section_by_template_id( + curr_section, template_id ): section = curr_section break - if section_config.get("code") and self._find_section_by_code( - curr_section, section_config["code"] - ): + if code and self._find_section_by_code(curr_section, code): section = curr_section break if not section: - log.warning(f"Section not found for config: {section_config}") + log.warning(f"Section not found for key: {section_key}") return [] # Get entries and convert to dicts @@ -108,34 +120,14 @@ def _parse_section_entries_from_document(self, section_config: Dict) -> List[Dic if entry ] - log.debug(f"Found {len(entry_dicts)} entries in section") + log.debug(f"Found {len(entry_dicts)} entries in section {section_key}") return entry_dicts except Exception as e: - log.error(f"Error parsing section: {str(e)}") + log.error(f"Error parsing section {section_key}: {str(e)}") return [] - def render_fhir_resource_from_cda_entry( - self, entry: Dict, template, section_config: Dict - ) -> Dict: - """ - Process a CDA entry using a template and prepare it for FHIR conversion - - Args: - entry: The entry data dictionary - template: The template to use for rendering - section_config: Configuration for the section - - Returns: - Dict: Processed resource dictionary ready for FHIR conversion - """ - # Render template with entry data and config - rendered = template.render({"entry": entry, "config": section_config}) - - # Parse rendered JSON and clean empty values - return clean_empty(json.loads(rendered)) - def _find_section_by_template_id(self, section: Section, template_id: str) -> bool: """Check if section matches template ID""" if not section.templateId: diff --git a/healthchain/interop/parsers/hl7v2.py b/healthchain/interop/parsers/hl7v2.py index 75ce95e6..3f11c62f 100644 --- a/healthchain/interop/parsers/hl7v2.py +++ b/healthchain/interop/parsers/hl7v2.py @@ -5,7 +5,8 @@ """ import logging -from typing import Dict, List +from typing import Dict, List, Any +from healthchain.interop.config_manager import ConfigManager log = logging.getLogger(__name__) @@ -13,23 +14,20 @@ class HL7v2Parser: """Parser for HL7v2 messages""" - def __init__(self, mappings: Dict): + def __init__(self, config_manager: ConfigManager): """Initialize the HL7v2 parser Args: - mappings: Mappings for code systems and other conversions + config_manager: ConfigManager instance for accessing configuration """ - self.mappings = mappings + self.config_manager = config_manager - def parse_message( - self, message: str, message_configs: Dict - ) -> Dict[str, List[Dict]]: + def parse_message(self, message: str) -> Dict[str, List[Dict]]: """ Parse a complete HL7v2 message and extract segments based on configuration. Args: message: The HL7v2 message - message_configs: Configuration for message segments Returns: Dictionary mapping segment keys to lists of segment dictionaries @@ -44,19 +42,25 @@ def parse_message( segment_entries = {} - # Example implementation would parse segments like PID, OBX, etc. - # and organize them into a structure similar to CDA sections + # Get segment configurations + segments = self.config_manager.get_config_value("segments", {}) + + # Process each segment from the configuration + for segment_key in segments.keys(): + # Implementation would parse segments like PID, OBX, etc. + # and organize them into a structure similar to CDA sections + pass return segment_entries - def process_segment(self, segment: Dict, template, segment_config: Dict) -> Dict: + def process_segment(self, segment: Dict, template, segment_key: str) -> Dict: """ Process an HL7v2 segment using a template and prepare it for FHIR conversion Args: segment: The segment data dictionary template: The template to use for rendering - segment_config: Configuration for the segment + segment_key: Key identifying the segment in the configuration Returns: Dict: Processed resource dictionary ready for FHIR conversion @@ -68,5 +72,45 @@ def process_segment(self, segment: Dict, template, segment_config: Dict) -> Dict # 1. Apply the template to the segment data # 2. Transform the data into a format suitable for FHIR conversion + # Get segment configuration + segment_config = self.config_manager.get_config_value( + f"segments.{segment_key}", {} + ) + + # Create context with segment data and config + context = {"segment": segment, "config": segment_config} + + # Add rendering options to context if available + rendering = self.config_manager.get_config_value( + f"segments.{segment_key}.rendering", {} + ) + if rendering: + context["rendering"] = rendering + # Placeholder return return segment + + def get_segment_config(self, segment_key: str) -> Dict: + """ + Get configuration for a specific segment + + Args: + segment_key: Key identifying the segment + + Returns: + Segment configuration dictionary + """ + return self.config_manager.get_config_value(f"segments.{segment_key}", {}) + + def get_config_value(self, path: str, default: Any = None) -> Any: + """ + Get a configuration value using dot notation path + + Args: + path: Dot notation path (e.g., "segment.pid.resource") + default: Default value if path not found + + Returns: + Configuration value or default + """ + return self.config_manager.get_config_value(path, default) diff --git a/healthchain/interop/template_renderer.py b/healthchain/interop/template_renderer.py new file mode 100644 index 00000000..607d6393 --- /dev/null +++ b/healthchain/interop/template_renderer.py @@ -0,0 +1,126 @@ +""" +Template Renderer for HealthChain Interoperability Engine + +This module provides a base class for template rendering functionality. +""" + +import logging +import json +from typing import Dict, Any, Optional +from pathlib import Path + +from healthchain.interop.filters import clean_empty + +log = logging.getLogger(__name__) + + +class TemplateRenderer: + """Base class for template rendering functionality""" + + def __init__(self, config_manager, template_registry): + """Initialize the template renderer + + Args: + config_manager: Configuration manager instance + template_registry: Template registry instance + """ + self.config_manager = config_manager + self.template_registry = template_registry + + def get_template(self, template_name: str): + """Get a template by name + + Args: + template_name: Name of the template to retrieve + + 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_section_template_name( + self, section_key: str, template_type: str + ) -> Optional[str]: + """Get the template name for a given section and template type + + Args: + section_key: Key identifying the section + template_type: Type of template (e.g., 'resource', 'entry') + + Returns: + Template name or None if not found + """ + template_path = self.config_manager.get_config_value( + f"sections.{section_key}.{template_type}_template", "" + ) + + if not template_path: + log.warning( + f"No {template_type} template specified for section: {section_key}" + ) + return None + + return Path(template_path).stem + + def get_section_template(self, section_key: str, template_type: str): + """Get the template for a given section and template type + + Args: + section_key: Key identifying the section + template_type: Type of template (e.g., 'resource', 'entry') + + Returns: + Template instance or None if not found + """ + template_name = self.get_section_template_name(section_key, template_type) + + if not template_name: + return None + + if not self.template_registry.has_template(template_name): + log.warning( + f"Template '{template_name}' not found for section {section_key}" + ) + return None + + return self.template_registry.get_template(template_name) + + def render_template(self, template, context: Dict[str, Any]) -> Optional[Dict]: + """Render a template with the given context + + Args: + template: The template to render + context: Context dictionary for rendering + + Returns: + Rendered dictionary or None if rendering failed + """ + try: + rendered = template.render(context) + return clean_empty(json.loads(rendered)) + except Exception as e: + log.error(f"Failed to render template: {str(e)}") + return None + + def get_section_config(self, section_key: str) -> Dict: + """Get configuration for a specific section + + Args: + section_key: Key identifying the section + + Returns: + Section configuration dictionary + """ + return self.config_manager.get_config_value(f"sections.{section_key}", {}) + + def get_section_configs(self) -> Dict: + """Get configurations for all sections + + Returns: + Dictionary of section configurations + """ + return self.config_manager.get_section_configs() From cbfbb439ea7371a80a50212e1e31e6851aa912c8 Mon Sep 17 00:00:00 2001 From: jenniferjiangkells Date: Mon, 17 Mar 2025 12:33:51 +0000 Subject: [PATCH 06/25] Refactoring to make core interop logic clearer --- healthchain/interop/__init__.py | 2 - .../interop/config/configs/defaults.yaml | 4 - .../interop/config/configs/document/cda.yaml | 4 + .../config/configs/sections/problems.yaml | 2 +- healthchain/interop/converters/__init__.py | 10 - healthchain/interop/converters/fhir.py | 179 ------------------ healthchain/interop/engine.py | 80 ++------ healthchain/interop/generators/cda.py | 142 +++++++------- healthchain/interop/generators/fhir.py | 117 +++++++++++- healthchain/interop/parsers/cda.py | 4 +- healthchain/interop/template_renderer.py | 6 +- healthchain/interop/utils.py | 76 ++++++++ 12 files changed, 281 insertions(+), 345 deletions(-) delete mode 100644 healthchain/interop/converters/__init__.py delete mode 100644 healthchain/interop/converters/fhir.py create mode 100644 healthchain/interop/utils.py diff --git a/healthchain/interop/__init__.py b/healthchain/interop/__init__.py index 584be433..d5092b30 100644 --- a/healthchain/interop/__init__.py +++ b/healthchain/interop/__init__.py @@ -10,7 +10,6 @@ from .template_renderer import TemplateRenderer from .parsers.cda import CDAParser from .parsers.hl7v2 import HL7v2Parser -from .converters import fhir as fhir_utils from .generators.cda import CDAGenerator from .generators.fhir import FHIRGenerator from .generators.hl7v2 import HL7v2Generator @@ -24,7 +23,6 @@ "TemplateRenderer", "CDAParser", "HL7v2Parser", - "fhir_utils", "CDAGenerator", "FHIRGenerator", "HL7v2Generator", diff --git a/healthchain/interop/config/configs/defaults.yaml b/healthchain/interop/config/configs/defaults.yaml index 8f28c879..2c6af275 100644 --- a/healthchain/interop/config/configs/defaults.yaml +++ b/healthchain/interop/config/configs/defaults.yaml @@ -58,16 +58,12 @@ templates: condition: "cda/condition" medication_statement: "cda/medication_statement" allergy_intolerance: "cda/allergy_intolerance" - observation: "cda/observation" - procedure: "cda/procedure" # Entry templates entries: problem: "cda/cda_problem_entry" medication: "cda/cda_medication_entry" allergy: "cda/cda_allergy_entry" - observation: "cda/cda_observation_entry" - procedure: "cda/cda_procedure_entry" # Format settings formats: diff --git a/healthchain/interop/config/configs/document/cda.yaml b/healthchain/interop/config/configs/document/cda.yaml index 048af139..03bd745b 100644 --- a/healthchain/interop/config/configs/document/cda.yaml +++ b/healthchain/interop/config/configs/document/cda.yaml @@ -18,6 +18,10 @@ type_id: template_id: root: "1.2.840.114350.1.72.1.51693" +templates: + section: "cda_section" + entry: "cda_entry" + # Document structure structure: # Header configuration diff --git a/healthchain/interop/config/configs/sections/problems.yaml b/healthchain/interop/config/configs/sections/problems.yaml index b9565b06..21334e82 100644 --- a/healthchain/interop/config/configs/sections/problems.yaml +++ b/healthchain/interop/config/configs/sections/problems.yaml @@ -3,7 +3,7 @@ # TODO: Make code hierarchical -# Basic section information +# Basic required section information resource: "Condition" resource_template: "cda/condition" entry_template: "cda/cda_problem_entry" diff --git a/healthchain/interop/converters/__init__.py b/healthchain/interop/converters/__init__.py deleted file mode 100644 index b7d9c65a..00000000 --- a/healthchain/interop/converters/__init__.py +++ /dev/null @@ -1,10 +0,0 @@ -""" -HealthChain Interoperability Converters - -This package contains converters for various healthcare data formats. -""" - -# Import utility functions from fhir module -from healthchain.interop.converters import fhir - -__all__ = ["fhir"] diff --git a/healthchain/interop/converters/fhir.py b/healthchain/interop/converters/fhir.py deleted file mode 100644 index 35467558..00000000 --- a/healthchain/interop/converters/fhir.py +++ /dev/null @@ -1,179 +0,0 @@ -""" -FHIR Converter Utilities for HealthChain Interoperability Engine - -This module provides utility functions for converting to and from FHIR resources. -""" - -import logging -import importlib -import uuid -from typing import Dict, List, Optional, Union, Any - -from fhir.resources.resource import Resource -from fhir.resources.bundle import Bundle - -log = logging.getLogger(__name__) - - -def create_resource(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: - log.error(f"Failed to create FHIR resource: {str(e)}") - return None - - -def add_required_fields( - resource_dict: Dict, resource_type: str, config_manager: Any -) -> Dict: - """Add required fields to resource dictionary based on type - - Args: - resource_dict: Dictionary representation of the resource - resource_type: Type of FHIR resource - config_manager: Configuration manager instance - - Returns: - Dict: The resource dictionary with required fields added - """ - # Add common fields - id_prefix = config_manager.get_config_value("defaults.common.id_prefix", "hc-") - if "id" not in resource_dict: - resource_dict["id"] = f"{id_prefix}{str(uuid.uuid4())}" - - # Get default values from configuration if available - default_subject = config_manager.get_config_value( - "defaults.common.subject", {"reference": "Patient/example"} - ) - if "subject" not in resource_dict: - resource_dict["subject"] = default_subject - - # Add resource-specific required fields - if resource_type == "Condition": - if "clinicalStatus" not in resource_dict: - default_status = config_manager.get_config_value( - "defaults.resources.Condition.clinicalStatus", - { - "coding": [ - { - "system": "http://terminology.hl7.org/CodeSystem/condition-clinical", - "code": "unknown", - } - ] - }, - ) - resource_dict["clinicalStatus"] = default_status - elif resource_type == "MedicationStatement": - if "status" not in resource_dict: - default_status = config_manager.get_config_value( - "defaults.resources.MedicationStatement.status", "unknown" - ) - resource_dict["status"] = default_status - elif resource_type == "AllergyIntolerance": - if "clinicalStatus" not in resource_dict: - default_status = config_manager.get_config_value( - "defaults.resources.AllergyIntolerance.clinicalStatus", - { - "coding": [ - { - "system": "http://terminology.hl7.org/CodeSystem/allergyintolerance-clinical", - "code": "unknown", - } - ] - }, - ) - resource_dict["clinicalStatus"] = default_status - - return resource_dict - - -def normalize_resources( - resources: Union[Resource, List[Resource], Bundle], -) -> List[Resource]: - """Convert input resources to a normalized list format - - Args: - resources: A FHIR Bundle, list of resources, or single resource - - Returns: - List of FHIR resources - """ - if isinstance(resources, Bundle): - return [entry.resource for entry in resources.entry if entry.resource] - elif isinstance(resources, list): - return resources - else: - return [resources] - - -def convert_resource_dicts_to_resources( - resource_dicts: List[Dict], resource_type: str, config_manager: Any -) -> List[Resource]: - """Convert a list of resource dictionaries to FHIR resources - - Args: - resource_dicts: List of resource dictionaries - resource_type: Type of FHIR resource to create - config_manager: Configuration manager instance - - Returns: - List of FHIR resources - """ - resources = [] - - for resource_dict in resource_dicts: - # Add required fields based on resource type - resource_dict = add_required_fields( - resource_dict, resource_type, config_manager - ) - - # Create FHIR resource instance - resource = create_resource(resource_dict, resource_type) - if resource: - resources.append(resource) - - return resources - - -def find_section_for_resource_type( - resource_type: str, config_manager: Any -) -> Optional[str]: - """Find the appropriate section key for a given resource type - - Args: - resource_type: FHIR resource type - config_manager: Configuration manager instance - - Returns: - Section key or None if no matching section found - """ - # Get section configurations - section_configs = config_manager.get_section_configs() - - # Find matching section for resource type - section_key = next( - ( - key - for key, config in section_configs.items() - if config.get("resource") == resource_type - ), - None, - ) - - if not section_key: - log.warning(f"Unsupported resource type: {resource_type}") - - return section_key diff --git a/healthchain/interop/engine.py b/healthchain/interop/engine.py index 2366607d..ec282455 100644 --- a/healthchain/interop/engine.py +++ b/healthchain/interop/engine.py @@ -1,14 +1,22 @@ import logging -from functools import cached_property +from functools import cached_property from enum import Enum from typing import Dict, List, Union, Optional, Callable, Any, Set from pathlib import Path from fhir.resources.resource import Resource +from fhir.resources.bundle import Bundle from .parsers.cda import CDAParser from .parsers.hl7v2 import HL7v2Parser + +from .config_manager import ConfigManager, ValidationLevel +from .template_registry import TemplateRegistry + +from .generators.cda import CDAGenerator +from .generators.fhir import FHIRGenerator +from .generators.hl7v2 import HL7v2Generator from .filters import ( format_date, map_system, @@ -18,12 +26,7 @@ generate_id, to_json, ) -from .config_manager import ConfigManager, ValidationLevel -from .template_registry import TemplateRegistry -from .converters import fhir as fhir_utils -from .generators.cda import CDAGenerator -from .generators.fhir import FHIRGenerator -from .generators.hl7v2 import HL7v2Generator +from .utils import normalize_resource_list log = logging.getLogger(__name__) @@ -365,11 +368,11 @@ def from_fhir( else: raise ValueError(f"Unsupported format: {format_type}") - def _cda_to_fhir(self, source_data: str) -> List[Resource]: + def _cda_to_fhir(self, xml: str) -> List[Resource]: """Convert CDA XML to FHIR resources Args: - source_data: CDA document as XML string + xml: CDA document as XML string Returns: List[Resource]: List of FHIR resources @@ -377,44 +380,24 @@ def _cda_to_fhir(self, source_data: str) -> List[Resource]: Raises: ValueError: If required mappings are missing or if sections are unsupported """ - # Get required configurations - section_configs = self.config_manager.get_section_configs() - - if not section_configs: - raise ValueError("No section configs found in configs/cda/section.yaml") - # 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.parse_document(source_data) + section_entries = parser.parse_document_sections(xml) # Process each section and convert entries to FHIR resources resources = [] for section_key, entries in section_entries.items(): - # Get resource type from section config - resource_type = self.config_manager.get_config_value( - f"sections.{section_key}.resource", None - ) - if not resource_type: - log.warning(f"No resource type specified for section {section_key}") - continue - - # Convert entries to resource dictionaries using the generator - resource_dicts = generator.convert_entries_to_resources( - entries, section_key, resource_type - ) - - # Convert resource dictionaries to FHIR resources using the utility functions - section_resources = fhir_utils.convert_resource_dicts_to_resources( - resource_dicts, resource_type, self.config_manager + section_resources = generator.convert_cda_entries_to_resources( + entries, section_key ) resources.extend(section_resources) return resources - def _fhir_to_cda(self, resources: Union[Resource, List[Resource]]) -> str: + def _fhir_to_cda(self, resources: Union[Resource, List[Resource], Bundle]) -> str: """Convert FHIR resources to CDA XML Args: @@ -430,36 +413,9 @@ def _fhir_to_cda(self, resources: Union[Resource, List[Resource]]) -> str: cda_generator = self.cda_generator # Normalize input to list of resources - resource_list = fhir_utils.normalize_resources(resources) - - # Process resources and group by section - section_entries = {} - for resource in resource_list: - resource_type = resource.__class__.__name__ + resource_list = normalize_resource_list(resources) - # Find matching section for resource type using utility function - section_key = fhir_utils.find_section_for_resource_type( - resource_type, self.config_manager - ) - if not section_key: - continue - - # Get template name for this section - template_name = cda_generator.get_section_template_name( - section_key, "entry" - ) - if not template_name: - continue - - # Render entry using template - entry = cda_generator.render_entry(resource, section_key, template_name) - if entry: - section_entries.setdefault(section_key, []).append(entry) - - # Generate the complete CDA document using the simplified method - return cda_generator.generate_document_from_resources( - resources, section_entries - ) + return cda_generator.generate_document_from_fhir_resources(resource_list) def _hl7v2_to_fhir(self, source_data: str) -> List[Resource]: """Convert HL7v2 to FHIR resources""" diff --git a/healthchain/interop/generators/cda.py b/healthchain/interop/generators/cda.py index 3d972a9b..47ee6d27 100644 --- a/healthchain/interop/generators/cda.py +++ b/healthchain/interop/generators/cda.py @@ -5,17 +5,16 @@ """ import logging -import json import re import xmltodict import uuid from datetime import datetime -from typing import Dict, List, Union, Optional +from typing import Dict, List, Optional from fhir.resources.resource import Resource -from fhir.resources.bundle import Bundle from healthchain.interop.template_renderer import TemplateRenderer +from healthchain.interop.utils import find_section_key_for_resource_type log = logging.getLogger(__name__) @@ -23,61 +22,82 @@ class CDAGenerator(TemplateRenderer): """Handles generation of CDA documents""" - def render_entry( + def _render_entry( self, resource: Resource, - section_key: str, - template_name: str, + config_key: str, ) -> Optional[Dict]: """Render a single entry for a resource Args: resource: FHIR resource - section_key: Key identifying the section - template_name: Name of the template to use + config_key: Key identifying the section Returns: Dictionary representation of the rendered entry """ try: - # Get section configuration - section_config = self.get_section_config(section_key) + # Get section configuration and create context + section_config = self.get_section_config(config_key) + if not section_config: + raise ValueError(f"No section configuration found for {config_key}") - # Create context with common values timestamp_format = self.config_manager.get_config_value( "formats.date.timestamp", "%Y%m%d" ) timestamp = datetime.now().strftime(format=timestamp_format) - # Generate reference name using configured format id_format = self.config_manager.get_config_value( "formats.ids.reference_name", "#{uuid}name" ) reference_name = id_format.replace("{uuid}", str(uuid.uuid4())[:8]) - # Create context with additional rendering options + # Create context context = { "timestamp": timestamp, "text_reference_name": reference_name, - "rendering": self.config_manager.get_config_value( - f"sections.{section_key}.rendering", {} - ), "resource": resource.model_dump(), "config": section_config, } # Get template and render + template_name = self.get_section_template_name(config_key, "entry") template = self.get_template(template_name) if not template: - return None + raise ValueError(f"Required template '{template_name}' not found") return self.render_template(template, context) except Exception as e: - log.error(f"Failed to render {section_key} entry: {str(e)}") + log.error(f"Failed to render {config_key} entry: {str(e)}") return None - def render_sections(self, section_entries: Dict) -> List[Dict]: + def _get_mapped_entries(self, resources: List[Resource]) -> Dict: + """Get mapped entries for resources + + Args: + resources: List of FHIR resources + + Returns: + Dictionary mapping section keys to their entries + """ + section_entries = {} + for resource in resources: + # Find matching section for resource type + resource_type = resource.__class__.__name__ + all_configs = self.get_section_configs() + section_key = find_section_key_for_resource_type(resource_type, all_configs) + + if not section_key: + continue + + entry = self._render_entry(resource, section_key) + if entry: + section_entries.setdefault(section_key, []).append(entry) + + return section_entries + + def _render_sections(self, mapped_entries: Dict) -> List[Dict]: """Render all sections with their entries Args: @@ -86,23 +106,24 @@ def render_sections(self, section_entries: Dict) -> List[Dict]: Returns: List of formatted section dictionaries """ - formatted_sections = [] + sections = [] # Get section configurations section_configs = self.get_section_configs() + if not section_configs: + raise ValueError("No configurations found in /sections") # Get section template name from config or use default section_template_name = self.config_manager.get_config_value( - "templates.core.section", "cda_section" + "document.cda.templates.section", "cda_section" ) - # Get the section template section_template = self.get_template(section_template_name) if not section_template: raise ValueError(f"Required template '{section_template_name}' not found") for section_key, section_config in section_configs.items(): - entries = section_entries.get(section_key, []) + entries = mapped_entries.get(section_key, []) if entries: try: context = { @@ -111,82 +132,61 @@ def render_sections(self, section_entries: Dict) -> List[Dict]: } rendered = self.render_template(section_template, context) if rendered: - formatted_sections.append(rendered) + sections.append(rendered) except Exception as e: log.error(f"Failed to render section {section_key}: {str(e)}") - return formatted_sections + return sections - def generate_document( + def _render_document( self, - resources: Union[Resource, List[Resource], Bundle], - document_config: Dict, - formatted_sections: List[Dict], + sections: List[Dict], ) -> str: """Generate the final CDA document Args: - resources: FHIR resources - document_config: Configuration for the document - formatted_sections: List of formatted section dictionaries + sections: List of formatted section dictionaries Returns: CDA document as XML string """ + config = self.config_manager.get_document_config() + if not config: + raise ValueError("No document configuration found in /document") + # Get document template name from config or use default document_template_name = self.config_manager.get_config_value( - "templates.core.document", "cda_document" + "document.cda.templates.document", "cda_document" ) - # Get the document template document_template = self.get_template(document_template_name) if not document_template: raise ValueError(f"Required template '{document_template_name}' not found") - # Create document context with additional configuration + # Create document context context = { - "bundle": resources if isinstance(resources, Bundle) else None, - "config": document_config, - "sections": formatted_sections, - "defaults": { - "patient": self.config_manager.get_config_value( - "document.defaults.patient", {} - ), - "author": self.config_manager.get_config_value( - "document.defaults.author", {} - ), - "custodian": self.config_manager.get_config_value( - "document.defaults.custodian", {} - ), - }, - "structure": self.config_manager.get_config_value("document.structure", {}), - "rendering": self.config_manager.get_config_value("document.rendering", {}), + "config": config, + "sections": sections, } - # Render document - rendered = document_template.render(**context) - document_dict = json.loads(rendered) + rendered = self.render_template(document_template, context) # Get XML formatting options pretty_print = self.config_manager.get_config_value( - "document.rendering.xml.pretty_print", True + "document.cda.rendering.xml.pretty_print", True ) encoding = self.config_manager.get_config_value( - "document.rendering.xml.encoding", "UTF-8" + "document.cda.rendering.xml.encoding", "UTF-8" ) - # Generate XML - xml_string = xmltodict.unparse( - document_dict, pretty=pretty_print, encoding=encoding - ) + xml_string = xmltodict.unparse(rendered, pretty=pretty_print, encoding=encoding) # Fix self-closing tags return re.sub(r"(<(\w+)(\s+[^>]*?)?)>", r"\1/>", xml_string) - def generate_document_from_resources( + def generate_document_from_fhir_resources( self, - resources: Union[Resource, List[Resource], Bundle], - section_entries_map: Optional[Dict] = None, + resources: List[Resource], ) -> str: """Generate a complete CDA document from FHIR resources @@ -197,22 +197,12 @@ def generate_document_from_resources( Args: resources: FHIR resources to include in the document - section_entries_map: Optional pre-populated section entries map Returns: CDA document as XML string """ - # Get document configuration - document_config = self.config_manager.get_document_config() - if not document_config: - raise ValueError("No document configuration found") - - # If section entries weren't provided, we'll use an empty map - if section_entries_map is None: - section_entries_map = {} - - # Render sections - formatted_sections = self.render_sections(section_entries_map) + mapped_entries = self._get_mapped_entries(resources) + sections = self._render_sections(mapped_entries) # Generate final CDA document - return self.generate_document(resources, document_config, formatted_sections) + return self._render_document(sections) diff --git a/healthchain/interop/generators/fhir.py b/healthchain/interop/generators/fhir.py index a38008ec..95a79435 100644 --- a/healthchain/interop/generators/fhir.py +++ b/healthchain/interop/generators/fhir.py @@ -4,10 +4,14 @@ This module provides functionality for generating FHIR resources from templates. """ +import uuid import logging from typing import Dict, List, Optional +from fhir.resources.resource import Resource + from healthchain.interop.template_renderer import TemplateRenderer +from healthchain.interop.utils import create_resource log = logging.getLogger(__name__) @@ -57,8 +61,8 @@ def render_resource_from_entry( log.error(f"Failed to render resource for section {section_key}: {str(e)}") return None - def convert_entries_to_resources( - self, entries: List[Dict], section_key: str, resource_type: str + def convert_cda_entries_to_resources( + self, entries: List[Dict], section_key: str ) -> List[Dict]: """ Convert entries from a section to FHIR resource dictionaries @@ -66,17 +70,23 @@ def convert_entries_to_resources( Args: entries: List of entries from a section section_key: Key identifying the section - resource_type: Type of FHIR resource to create Returns: List of FHIR resource dictionaries """ - resource_dicts = [] + resources = [] template = self.get_section_template(section_key, "resource") if not template: log.error(f"No resource template found for section {section_key}") - return resource_dicts + return resources + + resource_type = self.config_manager.get_config_value( + f"sections.{section_key}.resource", None + ) + if not resource_type: + log.warning(f"No resource type specified for section {section_key}") + return resources for entry in entries: try: @@ -84,12 +94,103 @@ def convert_entries_to_resources( resource_dict = self.render_resource_from_entry( entry, section_key, template ) + if not resource_dict: + continue + + resource = self._validate_fhir_resource(resource_dict, resource_type) - if resource_dict: - resource_dicts.append(resource_dict) + if resource: + resources.append(resource) except Exception as e: log.error(f"Failed to convert entry in section {section_key}: {str(e)}") continue - return resource_dicts + return resources + + def _validate_fhir_resource( + self, resource_dict: Dict, resource_type: str + ) -> Optional[Resource]: + """Validate a FHIR resource dictionary + + Args: + resource_dict: Dictionary representation of the resource + resource_type: Type of FHIR resource to create + config_manager: Configuration manager instance + + Returns: + Optional[Resource]: FHIR resource instance or None if validation failed + """ + + try: + resource_dict = self._add_required_fields(resource_dict, resource_type) + resource = create_resource(resource_dict, resource_type) + if resource: + return resource + except Exception as e: + log.error(f"Failed to validate FHIR resource: {str(e)}") + return None + + def _add_required_fields(self, resource_dict: Dict, resource_type: str) -> Dict: + """Add required fields to resource dictionary based on type + + Args: + resource_dict: Dictionary representation of the resource + resource_type: Type of FHIR resource + config_manager: Configuration manager instance + + Returns: + Dict: The resource dictionary with required fields added + """ + # Add common fields + id_prefix = self.config_manager.get_config_value( + "defaults.common.id_prefix", "hc-" + ) + if "id" not in resource_dict: + resource_dict["id"] = f"{id_prefix}{str(uuid.uuid4())}" + + # Get default values from configuration if available + default_subject = self.config_manager.get_config_value( + "defaults.common.subject", {"reference": "Patient/example"} + ) + + if "subject" not in resource_dict: + resource_dict["subject"] = default_subject + + # Add resource-specific required fields + if resource_type == "Condition": + if "clinicalStatus" not in resource_dict: + default_status = self.config_manager.get_config_value( + "defaults.resources.Condition.clinicalStatus", + { + "coding": [ + { + "system": "http://terminology.hl7.org/CodeSystem/condition-clinical", + "code": "unknown", + } + ] + }, + ) + resource_dict["clinicalStatus"] = default_status + elif resource_type == "MedicationStatement": + if "status" not in resource_dict: + default_status = self.config_manager.get_config_value( + "defaults.resources.MedicationStatement.status", "unknown" + ) + resource_dict["status"] = default_status + elif resource_type == "AllergyIntolerance": + if "clinicalStatus" not in resource_dict: + default_status = self.config_manager.get_config_value( + "defaults.resources.AllergyIntolerance.clinicalStatus", + { + "coding": [ + { + "system": "http://terminology.hl7.org/CodeSystem/allergyintolerance-clinical", + "code": "unknown", + } + ] + }, + ) + resource_dict["clinicalStatus"] = default_status + + return resource_dict diff --git a/healthchain/interop/parsers/cda.py b/healthchain/interop/parsers/cda.py index 664acae1..99e403b0 100644 --- a/healthchain/interop/parsers/cda.py +++ b/healthchain/interop/parsers/cda.py @@ -27,7 +27,7 @@ def __init__(self, config_manager: ConfigManager): self.config_manager = config_manager self.clinical_document = None - def parse_document(self, xml: str) -> Dict[str, List[Dict]]: + def parse_document_sections(self, xml: str) -> Dict[str, List[Dict]]: """ Parse a complete CDA document and extract entries from all configured sections. @@ -48,7 +48,7 @@ def parse_document(self, xml: str) -> Dict[str, List[Dict]]: return section_entries # Get section configurations - sections = self.config_manager.get_config_value("sections") + sections = self.config_manager.get_section_configs() if not sections: log.warning("No sections found in configuration") return section_entries diff --git a/healthchain/interop/template_renderer.py b/healthchain/interop/template_renderer.py index 607d6393..d5d984e7 100644 --- a/healthchain/interop/template_renderer.py +++ b/healthchain/interop/template_renderer.py @@ -9,6 +9,8 @@ from typing import Dict, Any, Optional from pathlib import Path +from healthchain.interop.config_manager import ConfigManager +from healthchain.interop.template_registry import TemplateRegistry from healthchain.interop.filters import clean_empty log = logging.getLogger(__name__) @@ -17,7 +19,9 @@ class TemplateRenderer: """Base class for template rendering functionality""" - def __init__(self, config_manager, template_registry): + def __init__( + self, config_manager: ConfigManager, template_registry: TemplateRegistry + ): """Initialize the template renderer Args: diff --git a/healthchain/interop/utils.py b/healthchain/interop/utils.py new file mode 100644 index 00000000..edc9f8c1 --- /dev/null +++ b/healthchain/interop/utils.py @@ -0,0 +1,76 @@ +import logging +import importlib +from typing import Dict, List, Optional, Union + +from fhir.resources.resource import Resource +from fhir.resources.bundle import Bundle + +log = logging.getLogger(__name__) + + +def create_resource(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: + log.error(f"Failed to create FHIR resource: {str(e)}") + return None + + +def normalize_resource_list( + resources: Union[Resource, List[Resource], Bundle], +) -> List[Resource]: + """Convert input resources to a normalized list format + + Args: + resources: A FHIR Bundle, list of resources, or single resource + + Returns: + List of FHIR resources + """ + if isinstance(resources, Bundle): + return [entry.resource for entry in resources.entry if entry.resource] + elif isinstance(resources, list): + return resources + else: + return [resources] + + +def find_section_key_for_resource_type( + resource_type: str, section_configs: Dict +) -> Optional[str]: + """Find the appropriate section key for a given resource type + + Args: + resource_type: FHIR resource type + config_manager: Configuration manager instance + + Returns: + Section key or None if no matching section found + """ + # Find matching section for resource type + section_key = next( + ( + key + for key, config in section_configs.items() + if config.get("resource") == resource_type + ), + None, + ) + + if not section_key: + log.warning(f"Unsupported resource type: {resource_type}") + + return section_key From 3a1a0d785d7cc53642d1bf7c5dda9cefa21ac544 Mon Sep 17 00:00:00 2001 From: jenniferjiangkells Date: Mon, 17 Mar 2025 19:09:19 +0000 Subject: [PATCH 07/25] config tidy --- .../interop/config/configs/defaults.yaml | 80 ++++++------------- .../config/configs/sections/problems.yaml | 57 ++++++------- .../templates/cda/cda_problem_entry.liquid | 33 ++++---- healthchain/interop/generators/cda.py | 4 +- 4 files changed, 72 insertions(+), 102 deletions(-) diff --git a/healthchain/interop/config/configs/defaults.yaml b/healthchain/interop/config/configs/defaults.yaml index 2c6af275..723be4f2 100644 --- a/healthchain/interop/config/configs/defaults.yaml +++ b/healthchain/interop/config/configs/defaults.yaml @@ -6,6 +6,8 @@ defaults: # Common defaults for all resources common: id_prefix: "hc-" + timestamp: "%Y%m%d" + reference_name: "#{uuid}name" subject: reference: "Patient/example" @@ -46,63 +48,29 @@ defaults: type: "allergy" criticality: "low" -# Template names and paths -templates: - # Core templates - core: - section: "cda_section" - document: "cda_document" - - # Resource templates - resources: - condition: "cda/condition" - medication_statement: "cda/medication_statement" - allergy_intolerance: "cda/allergy_intolerance" - - # Entry templates - entries: - problem: "cda/cda_problem_entry" - medication: "cda/cda_medication_entry" - allergy: "cda/cda_allergy_entry" - -# Format settings -formats: - # Date and time formats - date: - timestamp: "%Y%m%d" - datetime: "%Y-%m-%dT%H:%M:%SZ" - date: "%Y-%m-%d" - time: "%H:%M:%S" - - # ID and reference formats - ids: - resource: "hc-{uuid}" - reference_name: "#{uuid}name" - section: "section-{key}" - -# Validation settings -validation: - strict_mode: true - warn_on_missing: true - ignore_unknown_fields: true +# # Validation settings +# validation: +# strict_mode: true +# warn_on_missing: true +# ignore_unknown_fields: true -# Parser settings -parser: - max_entries: 1000 - skip_empty_sections: true +# # Parser settings +# parser: +# max_entries: 1000 +# skip_empty_sections: true -# Logging settings -logging: - level: "INFO" - include_timestamps: true +# # Logging settings +# logging: +# level: "INFO" +# include_timestamps: true -# Error handling -errors: - retry_count: 3 - fail_on_critical: true +# # Error handling +# errors: +# retry_count: 3 +# fail_on_critical: true -# Performance settings -performance: - cache_templates: true - cache_mappings: true - batch_size: 100 +# # Performance settings +# performance: +# cache_templates: true +# cache_mappings: true +# batch_size: 100 diff --git a/healthchain/interop/config/configs/sections/problems.yaml b/healthchain/interop/config/configs/sections/problems.yaml index 21334e82..9945f463 100644 --- a/healthchain/interop/config/configs/sections/problems.yaml +++ b/healthchain/interop/config/configs/sections/problems.yaml @@ -1,8 +1,6 @@ # Problems Section Configuration # This file contains configuration for the problems section -# TODO: Make code hierarchical - # Basic required section information resource: "Condition" resource_template: "cda/condition" @@ -10,34 +8,37 @@ entry_template: "cda/cda_problem_entry" template_id: "2.16.840.1.113883.10.20.1.11" code: "11450-4" -display: "Processed Problem List" +display: "Problem List" # Entry act template IDs -entry_act_template_ids: - - "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" - -# Entry act entryRelationship observation template IDs -entry_act_entryRelationship_observation_template_ids: - - "1.3.6.1.4.1.19376.1.5.3.1.4.5" - - "2.16.840.1.113883.10.20.1.28" - -# Status codes and other configurable options -act_status_code: "active" -entry_relationship_type_code: "SUBJ" -entry_relationship_inversion_ind: false -observation_code: "55607006" -observation_code_system: "2.16.840.1.113883.6.96" -observation_code_system_name: "SNOMED CT" -observation_display_name: "Problem" -observation_status_code: "completed" -status_loinc_code: "33999-4" -status_code_system: "2.16.840.1.113883.6.1" -status_display_name: "Status" -status_observation_status_code: "completed" +entry: + act: + template_ids: + - "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" + entry_relationship: + type_code: "SUBJ" + inversion_ind: false + observation: + code: "55607006" + code_system: "2.16.840.1.113883.6.96" + code_system_name: "SNOMED CT" + display_name: "Problem" + status_code: "completed" + template_ids: + - "1.3.6.1.4.1.19376.1.5.3.1.4.5" + - "2.16.840.1.113883.10.20.1.28" + entry_relationship: + observation: + code: "33999-4" + code_system: "2.16.840.1.113883.6.1" + code_system_name: "LOINC" + display_name: "Status" + status_code: "completed" # Rendering configuration rendering: diff --git a/healthchain/interop/config/templates/cda/cda_problem_entry.liquid b/healthchain/interop/config/templates/cda/cda_problem_entry.liquid index a2e4163d..3c54f8b9 100644 --- a/healthchain/interop/config/templates/cda/cda_problem_entry.liquid +++ b/healthchain/interop/config/templates/cda/cda_problem_entry.liquid @@ -3,40 +3,40 @@ "@classCode": "ACT", "@moodCode": "EVN", "templateId": [ - {% for template_id in config.entry_act_template_ids %} + {% for template_id in config.entry.act.template_ids %} {"@root": "{{template_id}}"} {% if forloop.last != true %},{% endif %} {% endfor %} ], "id": {"@root": "{{ resource.id | generate_id }}"}, "code": {"@nullFlavor": "NA"}, "statusCode": { - "@code": "{{ config.act_status_code | default: 'active' }}" + "@code": "{{ config.entry.act.status_code | default: 'active' }}" }, "effectiveTime": { "low": {"@value": "{{ timestamp }}"} }, "entryRelationship": { - "@typeCode": "{{ config.entry_relationship_type_code | default: 'SUBJ' }}", - "@inversionInd": {{ config.entry_relationship_inversion_ind | default: false }}, + "@typeCode": "{{ config.entry.act.entry_relationship.type_code | default: 'SUBJ' }}", + "@inversionInd": {{ config.entry.act.entry_relationship.inversion_ind | default: false }}, "observation": { "@classCode": "OBS", "@moodCode": "EVN", "templateId": [ - {% for template_id in config.entry_act_entryRelationship_observation_template_ids %} + {% for template_id in config.entry.act.entry_relationship.observation.template_ids %} {"@root": "{{template_id}}"} {% if forloop.last != true %},{% endif %} {% endfor %} ], "id": {"@root": "{{ resource.id }}_obs"}, "code": { - "@code": "{{ config.observation_code | default: '55607006' }}", - "@codeSystem": "{{ config.observation_code_system | default: '2.16.840.1.113883.6.96' }}", - "@codeSystemName": "{{ config.observation_code_system_name | default: 'SNOMED CT' }}", - "@displayName": "{{ config.observation_display_name | default: 'Problem' }}" + "@code": "{{ config.entry.act.entry_relationship.observation.code | default: '55607006' }}", + "@codeSystem": "{{ config.entry.act.entry_relationship.observation.code_system | default: '2.16.840.1.113883.6.96' }}", + "@codeSystemName": "{{ config.entry.act.entry_relationship.observation.code_system_name | default: 'SNOMED CT' }}", + "@displayName": "{{ config.entry.act.entry_relationship.observation.display_name | default: 'Problem' }}" }, "text": { - "reference": {"@value": "#{{ text_reference_name }}"} + "reference": {"@value": "{{ text_reference_name }}"} }, - "statusCode": {"@code": "{{ config.observation_status_code | default: 'completed' }}"}, + "statusCode": {"@code": "{{ config.entry.act.entry_relationship.observation.status_code | default: 'completed' }}"}, "effectiveTime": { {% if resource.onsetDateTime %} "low": {"@value": "{{ resource.onsetDateTime }}"} @@ -52,7 +52,7 @@ "@codeSystem": "{{ resource.code.coding[0].system | map_system: 'fhir_to_cda' }}", "@displayName": "{{ resource.code.coding[0].display }}", "originalText": { - "reference": {"@value": "#{{ text_reference_name }}"} + "reference": {"@value": "{{ text_reference_name }}"} } }, "entryRelationship": { @@ -61,9 +61,10 @@ "@classCode": "OBS", "@moodCode": "EVN", "code": { - "@code": "{{ config.status_loinc_code | default: '33999-4' }}", - "@codeSystem": "{{ config.status_code_system | default: '2.16.840.1.113883.6.1' }}", - "@displayName": "{{ config.status_display_name | default: 'Status' }}" + "@code": "{{ config.entry.act.entry_relationship.observation.entry_relationship.observation.code | default: '33999-4' }}", + "@codeSystem": "{{ config.entry.act.entry_relationship.observation.entry_relationship.observation.code_system | default: '2.16.840.1.113883.6.1' }}", + "@codeSystemName": "{{config.entry.act.entry_relationship.observation.entry_relationship.observation.code_system_name | default: 'LOINC'}}", + "@displayName": "{{ config.entry.act.entry_relationship.observation.entry_relationship.observation.display_name | default: 'Status' }}" }, "value": { "@xmlns:xsi": "http://www.w3.org/2001/XMLSchema-instance", @@ -72,7 +73,7 @@ "@displayName": "{{ resource.clinicalStatus.coding[0].display }}", "@xsi:type": "CE" }, - "statusCode": {"@code": "{{ config.status_observation_status_code | default: 'completed' }}"}, + "statusCode": {"@code": "{{ config.entry.act.entry_relationship.observation.entry_relationship.observation.status_code | default: 'completed' }}"}, "effectiveTime": { "low": {"@value": "{{ timestamp }}"} } diff --git a/healthchain/interop/generators/cda.py b/healthchain/interop/generators/cda.py index 47ee6d27..20938949 100644 --- a/healthchain/interop/generators/cda.py +++ b/healthchain/interop/generators/cda.py @@ -43,12 +43,12 @@ def _render_entry( raise ValueError(f"No section configuration found for {config_key}") timestamp_format = self.config_manager.get_config_value( - "formats.date.timestamp", "%Y%m%d" + "defaults.common.timestamp", "%Y%m%d" ) timestamp = datetime.now().strftime(format=timestamp_format) id_format = self.config_manager.get_config_value( - "formats.ids.reference_name", "#{uuid}name" + "defaults.common.reference_name", "#{uuid}name" ) reference_name = id_format.replace("{uuid}", str(uuid.uuid4())[:8]) From 7ffba04b0196a9304d82b7282748f77925d18020 Mon Sep 17 00:00:00 2001 From: jenniferjiangkells Date: Tue, 18 Mar 2025 16:18:07 +0000 Subject: [PATCH 08/25] Move configs to project-level --- .../config/configs => config}/defaults.yaml | 0 .../environments}/development.yaml | 0 .../environments}/production.yaml | 0 .../environments}/testing.yaml | 0 .../interop}/document/cda.yaml | 0 .../interop}/sections/allergies.yaml | 0 .../interop}/sections/medications.yaml | 0 .../interop}/sections/problems.yaml | 41 ++-- .../mappings/shared_mappings.yaml | 0 .../templates/cda/allergy_intolerance.liquid | 0 .../templates/cda/cda_document.liquid | 0 .../templates/cda/cda_problem_entry.liquid | 26 +-- .../templates/cda/cda_section.liquid | 0 .../templates/cda/condition.liquid | 2 +- .../templates/cda/medication_statement.liquid | 0 healthchain/__init__.py | 3 +- healthchain/{interop => }/config_manager.py | 191 ++++++++++++++++-- healthchain/interop/__init__.py | 3 - healthchain/interop/engine.py | 31 +-- healthchain/interop/generators/fhir.py | 79 ++++---- healthchain/interop/parsers/cda.py | 2 +- healthchain/interop/parsers/hl7v2.py | 2 +- healthchain/interop/template_renderer.py | 2 +- 23 files changed, 265 insertions(+), 117 deletions(-) rename {healthchain/interop/config/configs => config}/defaults.yaml (100%) rename {healthchain/interop/config/configs => config/environments}/development.yaml (100%) rename {healthchain/interop/config/configs => config/environments}/production.yaml (100%) rename {healthchain/interop/config/configs => config/environments}/testing.yaml (100%) rename {healthchain/interop/config/configs => config/interop}/document/cda.yaml (100%) rename {healthchain/interop/config/configs => config/interop}/sections/allergies.yaml (100%) rename {healthchain/interop/config/configs => config/interop}/sections/medications.yaml (100%) rename {healthchain/interop/config/configs => config/interop}/sections/problems.yaml (53%) rename {healthchain/interop/config => config}/mappings/shared_mappings.yaml (100%) rename {healthchain/interop/config => config}/templates/cda/allergy_intolerance.liquid (100%) rename {healthchain/interop/config => config}/templates/cda/cda_document.liquid (100%) rename {healthchain/interop/config => config}/templates/cda/cda_problem_entry.liquid (59%) rename {healthchain/interop/config => config}/templates/cda/cda_section.liquid (100%) rename {healthchain/interop/config => config}/templates/cda/condition.liquid (97%) rename {healthchain/interop/config => config}/templates/cda/medication_statement.liquid (100%) rename healthchain/{interop => }/config_manager.py (66%) diff --git a/healthchain/interop/config/configs/defaults.yaml b/config/defaults.yaml similarity index 100% rename from healthchain/interop/config/configs/defaults.yaml rename to config/defaults.yaml diff --git a/healthchain/interop/config/configs/development.yaml b/config/environments/development.yaml similarity index 100% rename from healthchain/interop/config/configs/development.yaml rename to config/environments/development.yaml diff --git a/healthchain/interop/config/configs/production.yaml b/config/environments/production.yaml similarity index 100% rename from healthchain/interop/config/configs/production.yaml rename to config/environments/production.yaml diff --git a/healthchain/interop/config/configs/testing.yaml b/config/environments/testing.yaml similarity index 100% rename from healthchain/interop/config/configs/testing.yaml rename to config/environments/testing.yaml diff --git a/healthchain/interop/config/configs/document/cda.yaml b/config/interop/document/cda.yaml similarity index 100% rename from healthchain/interop/config/configs/document/cda.yaml rename to config/interop/document/cda.yaml diff --git a/healthchain/interop/config/configs/sections/allergies.yaml b/config/interop/sections/allergies.yaml similarity index 100% rename from healthchain/interop/config/configs/sections/allergies.yaml rename to config/interop/sections/allergies.yaml diff --git a/healthchain/interop/config/configs/sections/medications.yaml b/config/interop/sections/medications.yaml similarity index 100% rename from healthchain/interop/config/configs/sections/medications.yaml rename to config/interop/sections/medications.yaml diff --git a/healthchain/interop/config/configs/sections/problems.yaml b/config/interop/sections/problems.yaml similarity index 53% rename from healthchain/interop/config/configs/sections/problems.yaml rename to config/interop/sections/problems.yaml index 9945f463..f6f2d052 100644 --- a/healthchain/interop/config/configs/sections/problems.yaml +++ b/config/interop/sections/problems.yaml @@ -20,27 +20,28 @@ entry: - "2.16.840.1.113883.3.88.11.32.7" - "2.16.840.1.113883.3.88.11.83.7" status_code: "completed" - entry_relationship: - type_code: "SUBJ" - inversion_ind: false - observation: - code: "55607006" - code_system: "2.16.840.1.113883.6.96" - code_system_name: "SNOMED CT" - display_name: "Problem" - status_code: "completed" - template_ids: - - "1.3.6.1.4.1.19376.1.5.3.1.4.5" - - "2.16.840.1.113883.10.20.1.28" - entry_relationship: - observation: - code: "33999-4" - code_system: "2.16.840.1.113883.6.1" - code_system_name: "LOINC" - display_name: "Status" - status_code: "completed" -# Rendering configuration +main_entry_relationship: + type_code: "SUBJ" + inversion_ind: false + observation: + code: "55607006" + code_system: "2.16.840.1.113883.6.96" + code_system_name: "SNOMED CT" + display_name: "Problem" + status_code: "completed" + template_ids: + - "1.3.6.1.4.1.19376.1.5.3.1.4.5" + - "2.16.840.1.113883.10.20.1.28" + +clinical_status_observation: + 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 yet) rendering: narrative: include: true diff --git a/healthchain/interop/config/mappings/shared_mappings.yaml b/config/mappings/shared_mappings.yaml similarity index 100% rename from healthchain/interop/config/mappings/shared_mappings.yaml rename to config/mappings/shared_mappings.yaml diff --git a/healthchain/interop/config/templates/cda/allergy_intolerance.liquid b/config/templates/cda/allergy_intolerance.liquid similarity index 100% rename from healthchain/interop/config/templates/cda/allergy_intolerance.liquid rename to config/templates/cda/allergy_intolerance.liquid diff --git a/healthchain/interop/config/templates/cda/cda_document.liquid b/config/templates/cda/cda_document.liquid similarity index 100% rename from healthchain/interop/config/templates/cda/cda_document.liquid rename to config/templates/cda/cda_document.liquid diff --git a/healthchain/interop/config/templates/cda/cda_problem_entry.liquid b/config/templates/cda/cda_problem_entry.liquid similarity index 59% rename from healthchain/interop/config/templates/cda/cda_problem_entry.liquid rename to config/templates/cda/cda_problem_entry.liquid index 3c54f8b9..1b734623 100644 --- a/healthchain/interop/config/templates/cda/cda_problem_entry.liquid +++ b/config/templates/cda/cda_problem_entry.liquid @@ -16,27 +16,27 @@ "low": {"@value": "{{ timestamp }}"} }, "entryRelationship": { - "@typeCode": "{{ config.entry.act.entry_relationship.type_code | default: 'SUBJ' }}", - "@inversionInd": {{ config.entry.act.entry_relationship.inversion_ind | default: false }}, + "@typeCode": "{{ config.main_entry_relationship.type_code | default: 'SUBJ' }}", + "@inversionInd": {{ config.main_entry_relationship.inversion_ind | default: false }}, "observation": { "@classCode": "OBS", "@moodCode": "EVN", "templateId": [ - {% for template_id in config.entry.act.entry_relationship.observation.template_ids %} + {% for template_id in config.main_entry_relationship.observation.template_ids %} {"@root": "{{template_id}}"} {% if forloop.last != true %},{% endif %} {% endfor %} ], "id": {"@root": "{{ resource.id }}_obs"}, "code": { - "@code": "{{ config.entry.act.entry_relationship.observation.code | default: '55607006' }}", - "@codeSystem": "{{ config.entry.act.entry_relationship.observation.code_system | default: '2.16.840.1.113883.6.96' }}", - "@codeSystemName": "{{ config.entry.act.entry_relationship.observation.code_system_name | default: 'SNOMED CT' }}", - "@displayName": "{{ config.entry.act.entry_relationship.observation.display_name | default: 'Problem' }}" + "@code": "{{ config.main_entry_relationship.observation.code | default: '55607006' }}", + "@codeSystem": "{{ config.main_entry.act.entry_relationship.observation.code_system | default: '2.16.840.1.113883.6.96' }}", + "@codeSystemName": "{{ config.main_entry_relationship.observation.code_system_name | default: 'SNOMED CT' }}", + "@displayName": "{{ config.main_entry_relationship.observation.display_name | default: 'Problem' }}" }, "text": { "reference": {"@value": "{{ text_reference_name }}"} }, - "statusCode": {"@code": "{{ config.entry.act.entry_relationship.observation.status_code | default: 'completed' }}"}, + "statusCode": {"@code": "{{ config.main_entry_relationship.observation.status_code | default: 'completed' }}"}, "effectiveTime": { {% if resource.onsetDateTime %} "low": {"@value": "{{ resource.onsetDateTime }}"} @@ -61,10 +61,10 @@ "@classCode": "OBS", "@moodCode": "EVN", "code": { - "@code": "{{ config.entry.act.entry_relationship.observation.entry_relationship.observation.code | default: '33999-4' }}", - "@codeSystem": "{{ config.entry.act.entry_relationship.observation.entry_relationship.observation.code_system | default: '2.16.840.1.113883.6.1' }}", - "@codeSystemName": "{{config.entry.act.entry_relationship.observation.entry_relationship.observation.code_system_name | default: 'LOINC'}}", - "@displayName": "{{ config.entry.act.entry_relationship.observation.entry_relationship.observation.display_name | default: 'Status' }}" + "@code": "{{ config.clinical_status_observation.code | default: '33999-4' }}", + "@codeSystem": "{{ config.clinical_status_observation.code_system | default: '2.16.840.1.113883.6.1' }}", + "@codeSystemName": "{{config.clinical_status_observation.code_system_name | default: 'LOINC'}}", + "@displayName": "{{ config.clinical_status_observation.display_name | default: 'Status' }}" }, "value": { "@xmlns:xsi": "http://www.w3.org/2001/XMLSchema-instance", @@ -73,7 +73,7 @@ "@displayName": "{{ resource.clinicalStatus.coding[0].display }}", "@xsi:type": "CE" }, - "statusCode": {"@code": "{{ config.entry.act.entry_relationship.observation.entry_relationship.observation.status_code | default: 'completed' }}"}, + "statusCode": {"@code": "{{ config.main_entry_relationship.observation.entry_relationship.observation.status_code | default: 'completed' }}"}, "effectiveTime": { "low": {"@value": "{{ timestamp }}"} } diff --git a/healthchain/interop/config/templates/cda/cda_section.liquid b/config/templates/cda/cda_section.liquid similarity index 100% rename from healthchain/interop/config/templates/cda/cda_section.liquid rename to config/templates/cda/cda_section.liquid diff --git a/healthchain/interop/config/templates/cda/condition.liquid b/config/templates/cda/condition.liquid similarity index 97% rename from healthchain/interop/config/templates/cda/condition.liquid rename to config/templates/cda/condition.liquid index 0444c623..61ba7cae 100644 --- a/healthchain/interop/config/templates/cda/condition.liquid +++ b/config/templates/cda/condition.liquid @@ -5,7 +5,7 @@ {% else %} {% assign actEntryRelationship = entry.act.entryRelationship %} {% endif %} - {% if actEntryRelationship.observation.entryRelationship.observation.code['@code'] == config.status_loinc_code %} + {% if actEntryRelationship.observation.entryRelationship.observation.code['@code'] == config.clinical_status_observation.code %} {% if actEntryRelationship.observation.entryRelationship.observation.value %} "clinicalStatus": { "coding": [ diff --git a/healthchain/interop/config/templates/cda/medication_statement.liquid b/config/templates/cda/medication_statement.liquid similarity index 100% rename from healthchain/interop/config/templates/cda/medication_statement.liquid rename to config/templates/cda/medication_statement.liquid diff --git a/healthchain/__init__.py b/healthchain/__init__.py index dd0417ec..46e1586d 100644 --- a/healthchain/__init__.py +++ b/healthchain/__init__.py @@ -3,10 +3,11 @@ from .decorators import api, sandbox from .clients import ehr +from .config_manager 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/interop/config_manager.py b/healthchain/config_manager.py similarity index 66% rename from healthchain/interop/config_manager.py rename to healthchain/config_manager.py index de0d032d..a3615ddd 100644 --- a/healthchain/interop/config_manager.py +++ b/healthchain/config_manager.py @@ -16,7 +16,7 @@ class ValidationLevel: class ConfigManager: - """Manages loading and accessing configuration files for the InteropEngine""" + """Manages loading and accessing configuration files for the HealthChain project""" # TODO: Use Pydantic to validate config files @@ -36,23 +36,30 @@ class ConfigManager: } def __init__( - self, config_dir: Path, validation_level: str = ValidationLevel.STRICT + 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 (strict, warn, ignore) + validation_level: Level of validation to perform + module: Optional module name to load specific configs for """ self.config_dir = config_dir self._configs = {} self._mappings = {} self._defaults = {} self._env_configs = {} + self._module_configs = {} + self._module_env_configs = {} self._loaded = False self._validation_level = validation_level self._custom_schemas = {} self._environment = self._detect_environment() + self._module = module def _detect_environment(self) -> str: """Detect the current environment from environment variables @@ -85,15 +92,19 @@ def load(self, environment: Optional[str] = None) -> "ConfigManager": if environment: self._environment = environment - # Load defaults first + # Load project-wide defaults self._load_defaults() # Load environment-specific configuration self._load_environment_config() - # Load other configurations + # Load module-specific configs if module is specified + if self._module: + self._load_module_configs(self._module) + self._load_module_environment_config(self._module) + + # Load mappings from the central mappings directory self._mappings = self._load_directory("mappings") - self._configs = self._load_directory("configs") self._loaded = True # Validate configurations if not in IGNORE mode @@ -104,7 +115,7 @@ def load(self, environment: Optional[str] = None) -> "ConfigManager": def _load_defaults(self) -> None: """Load the defaults.yaml file if it exists""" - defaults_file = self.config_dir / "configs" / "defaults.yaml" + defaults_file = self.config_dir / "defaults.yaml" if defaults_file.exists(): try: with open(defaults_file) as f: @@ -119,7 +130,7 @@ def _load_defaults(self) -> None: def _load_environment_config(self) -> None: """Load environment-specific configuration file""" - env_file = self.config_dir / "configs" / f"{self._environment}.yaml" + env_file = self.config_dir / "environments" / f"{self._environment}.yaml" if env_file.exists(): try: with open(env_file) as f: @@ -132,6 +143,84 @@ def _load_environment_config(self) -> None: 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 + + module_configs = {} + + # Load all YAML files in the module directory + for config_file in module_dir.rglob("*.yaml"): + # Skip environment-specific files (they're loaded separately) + if config_file.name.startswith("env_"): + continue + + try: + with open(config_file) as f: + # Get relative path from module directory for hierarchical keys + rel_path = config_file.relative_to(module_dir) + 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 + self._deep_merge(module_configs, current_level) + else: + # Top-level file, just use the stem as key + module_configs[config_file.stem] = content + + log.debug(f"Loaded module configuration file: {config_file}") + except Exception as e: + log.error( + f"Failed to load module configuration file {config_file}: {str(e)}" + ) + + self._module_configs[module] = module_configs + log.debug(f"Loaded {len(module_configs)} configurations for module {module}") + + def _load_module_environment_config(self, module: str) -> None: + """Load module+environment-specific configuration + + Args: + module: Module name to load configs for + """ + env_file = self.config_dir / module / f"env_{self._environment}.yaml" + + if env_file.exists(): + try: + with open(env_file) as f: + env_config = yaml.safe_load(f) + self._module_env_configs.setdefault(module, {}) + self._deep_merge(self._module_env_configs[module], env_config) + log.debug(f"Loaded environment configuration for module {module}") + except Exception as e: + log.error( + f"Failed to load environment file for module {module}: {str(e)}" + ) + self._module_env_configs.setdefault(module, {}) + else: + log.warning(f"Environment file for module {module} not found: {env_file}") + self._module_env_configs.setdefault(module, {}) + def _load_directory(self, directory: str) -> Dict: """Load all YAML files from a directory @@ -224,7 +313,7 @@ def get_mappings(self) -> Dict: return self._mappings def get_configs(self) -> Dict: - """Get all configs + """Get all configs with the correct precedence order Returns: Dict of configs @@ -233,22 +322,51 @@ def get_configs(self) -> Dict: self.load() # Create a merged configuration with the correct precedence: - # 1. Regular configs (highest priority) - # 2. Environment-specific configs - # 3. Default configs (lowest priority) + # 1. Module-specific environment configs (highest priority) + # 2. Module-specific configs + # 3. Environment-specific configs + # 4. Regular configs + # 5. Default configs (lowest priority) merged_configs = {} # Start with defaults self._deep_merge(merged_configs, self._defaults) + # Apply regular configs + self._deep_merge(merged_configs, self._configs) + # Apply environment-specific configs self._deep_merge(merged_configs, self._env_configs) - # Apply regular configs - self._deep_merge(merged_configs, self._configs) + # Apply module-specific configs if a module is specified + if self._module and self._module in self._module_configs: + self._deep_merge(merged_configs, self._module_configs[self._module]) + + # Apply module+environment-specific configs + if self._module in self._module_env_configs: + self._deep_merge(merged_configs, self._module_env_configs[self._module]) return merged_configs + def get_module_configs(self, module: Optional[str] = None) -> Dict: + """Get module-specific configurations + + Args: + module: Module name to get configs for (defaults to initialized module) + + Returns: + Dict of module-specific configurations + """ + if not self._loaded: + self.load() + + target_module = module or self._module + if not target_module: + return {} + + # Return empty dict if module configs don't exist + return self._module_configs.get(target_module, {}) + def get_defaults(self) -> Dict: """Get all default values @@ -288,6 +406,11 @@ def set_environment(self, environment: str) -> "ConfigManager": """ self._environment = environment self._load_environment_config() + + # Reload module-specific environment configuration if module is set + if self._module: + self._load_module_environment_config(self._module) + return self def get_section_configs(self) -> Dict: @@ -296,7 +419,20 @@ def get_section_configs(self) -> Dict: Returns: Dict of section configurations """ - return self.get_configs().get("sections", {}) + # If we're using a module (like "interop"), look for sections in that module's config + if self._module and self._module in self._module_configs: + # First try to find sections directly in the module configs + module_sections = self._module_configs[self._module].get("sections", {}) + if module_sections: + return module_sections + + # If no sections found directly, try to find it in a child directory + for value in self._module_configs[self._module].values(): + if isinstance(value, dict) and "sections" in value: + return value["sections"] + + log.warning("No section configs found") + return {} def get_document_config(self) -> Dict: """Get document configuration @@ -304,13 +440,32 @@ def get_document_config(self) -> Dict: Returns: Document configuration dict """ - return self.get_configs().get("document", {}).get("cda", {}) + # If we're using a module (like "interop"), look for document config in that module's config + if self._module and self._module in self._module_configs: + # First try to find document configs directly in the module configs + module_document = ( + self._module_configs[self._module].get("document", {}).get("cda", {}) + ) + if module_document: + return module_document + + # If no document config found directly, try to find it in a child directory + for value in self._module_configs[self._module].values(): + if isinstance(value, dict) and "document" in value: + if ( + isinstance(value["document"], dict) + and "cda" in value["document"] + ): + return value["document"]["cda"] + + log.warning("No document config found") + return {} def get_config_value(self, path: str, default: Any = None) -> Any: """Get a configuration value using dot notation path Args: - path: Dot notation path (e.g., "section.problems.resource") + path: Dot notation path default: Default value if path not found Returns: @@ -322,7 +477,7 @@ def get_config_value(self, path: str, default: Any = None) -> Any: # Split the path into parts parts = path.split(".") - # Get merged configs + # Create merged configs with proper precedence configs = self.get_configs() # Get the value from merged configs diff --git a/healthchain/interop/__init__.py b/healthchain/interop/__init__.py index d5092b30..33fa1760 100644 --- a/healthchain/interop/__init__.py +++ b/healthchain/interop/__init__.py @@ -5,7 +5,6 @@ """ from .engine import InteropEngine, FormatType -from .config_manager import ConfigManager, ValidationLevel from .template_registry import TemplateRegistry from .template_renderer import TemplateRenderer from .parsers.cda import CDAParser @@ -17,8 +16,6 @@ __all__ = [ "InteropEngine", "FormatType", - "ConfigManager", - "ValidationLevel", "TemplateRegistry", "TemplateRenderer", "CDAParser", diff --git a/healthchain/interop/engine.py b/healthchain/interop/engine.py index ec282455..b4185fff 100644 --- a/healthchain/interop/engine.py +++ b/healthchain/interop/engine.py @@ -8,16 +8,15 @@ from fhir.resources.resource import Resource from fhir.resources.bundle import Bundle -from .parsers.cda import CDAParser -from .parsers.hl7v2 import HL7v2Parser - -from .config_manager import ConfigManager, ValidationLevel -from .template_registry import TemplateRegistry - -from .generators.cda import CDAGenerator -from .generators.fhir import FHIRGenerator -from .generators.hl7v2 import HL7v2Generator -from .filters import ( +from healthchain.config_manager import ConfigManager, ValidationLevel + +from healthchain.interop.parsers.cda import CDAParser +from healthchain.interop.parsers.hl7v2 import HL7v2Parser +from healthchain.interop.template_registry import TemplateRegistry +from healthchain.interop.generators.cda import CDAGenerator +from healthchain.interop.generators.fhir import FHIRGenerator +from healthchain.interop.generators.hl7v2 import HL7v2Generator +from healthchain.interop.filters import ( format_date, map_system, map_status, @@ -26,7 +25,7 @@ generate_id, to_json, ) -from .utils import normalize_resource_list +from healthchain.interop.utils import normalize_resource_list log = logging.getLogger(__name__) @@ -52,24 +51,26 @@ class InteropEngine: def __init__( self, - config_dir: Path, + 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 + 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_dir = config_dir - self.config_manager = ConfigManager(config_dir, validation_level) + self.config_manager = ConfigManager( + self.config_dir, validation_level, module="interop" + ) self.config_manager.load(environment) # Initialize template registry - template_dir = config_dir / "templates" + template_dir = self.config_dir / "templates" self.template_registry = TemplateRegistry(template_dir) # Create and register default filters diff --git a/healthchain/interop/generators/fhir.py b/healthchain/interop/generators/fhir.py index 95a79435..01a15786 100644 --- a/healthchain/interop/generators/fhir.py +++ b/healthchain/interop/generators/fhir.py @@ -19,48 +19,6 @@ class FHIRGenerator(TemplateRenderer): """Handles generation of FHIR resources from templates""" - def render_resource_from_entry( - self, entry: Dict, section_key: str, template=None - ) -> Optional[Dict]: - """ - Process an entry using a template and prepare it for FHIR conversion - - Args: - entry: The entry data dictionary - section_key: Key identifying the section - template: Optional template to use (if not provided, will be retrieved from section config) - - Returns: - Dict: Processed resource dictionary ready for FHIR conversion - """ - try: - # Get template if not provided - if template is None: - template = self.get_section_template(section_key, "resource") - if not template: - log.error(f"No resource template found for section {section_key}") - return None - - # Get section configuration - section_config = self.get_section_config(section_key) - - # Create context with entry data and config - context = {"entry": entry, "config": section_config} - - # Add rendering options to context if available - rendering = self.config_manager.get_config_value( - f"sections.{section_key}.rendering", {} - ) - if rendering: - context["rendering"] = rendering - - # Render template with context - return self.render_template(template, context) - - except Exception as e: - log.error(f"Failed to render resource for section {section_key}: {str(e)}") - return None - def convert_cda_entries_to_resources( self, entries: List[Dict], section_key: str ) -> List[Dict]: @@ -91,7 +49,7 @@ def convert_cda_entries_to_resources( for entry in entries: try: # Convert entry to FHIR resource dictionary - resource_dict = self.render_resource_from_entry( + resource_dict = self._render_resource_from_entry( entry, section_key, template ) if not resource_dict: @@ -108,6 +66,41 @@ def convert_cda_entries_to_resources( return resources + def _render_resource_from_entry( + self, entry: Dict, section_key: str, template=None + ) -> Optional[Dict]: + """ + Process an entry using a template and prepare it for FHIR conversion + + Args: + entry: The entry data dictionary + section_key: Key identifying the section + template: Optional template to use (if not provided, will be retrieved from section config) + + Returns: + Dict: Processed resource dictionary ready for FHIR conversion + """ + try: + # Get template if not provided + if template is None: + template = self.get_section_template(section_key, "resource") + if not template: + log.error(f"No resource template found for section {section_key}") + return None + + # Get section configuration + section_config = self.get_section_config(section_key) + + # Create context with entry data and config + context = {"entry": entry, "config": section_config} + + # Render template with context + return self.render_template(template, context) + + except Exception as e: + log.error(f"Failed to render resource for section {section_key}: {str(e)}") + return None + def _validate_fhir_resource( self, resource_dict: Dict, resource_type: str ) -> Optional[Resource]: diff --git a/healthchain/interop/parsers/cda.py b/healthchain/interop/parsers/cda.py index 99e403b0..2860a00a 100644 --- a/healthchain/interop/parsers/cda.py +++ b/healthchain/interop/parsers/cda.py @@ -10,7 +10,7 @@ from healthchain.interop.models.cda import ClinicalDocument from healthchain.interop.models.sections import Section, Entry -from healthchain.interop.config_manager import ConfigManager +from healthchain.config_manager import ConfigManager log = logging.getLogger(__name__) diff --git a/healthchain/interop/parsers/hl7v2.py b/healthchain/interop/parsers/hl7v2.py index 3f11c62f..c47e4cbf 100644 --- a/healthchain/interop/parsers/hl7v2.py +++ b/healthchain/interop/parsers/hl7v2.py @@ -6,7 +6,7 @@ import logging from typing import Dict, List, Any -from healthchain.interop.config_manager import ConfigManager +from healthchain import ConfigManager log = logging.getLogger(__name__) diff --git a/healthchain/interop/template_renderer.py b/healthchain/interop/template_renderer.py index d5d984e7..18b97f5c 100644 --- a/healthchain/interop/template_renderer.py +++ b/healthchain/interop/template_renderer.py @@ -9,7 +9,7 @@ from typing import Dict, Any, Optional from pathlib import Path -from healthchain.interop.config_manager import ConfigManager +from healthchain.config_manager import ConfigManager from healthchain.interop.template_registry import TemplateRegistry from healthchain.interop.filters import clean_empty From 3e2e866545eeb34e63b592992385bddf8e09199c Mon Sep 17 00:00:00 2001 From: jenniferjiangkells Date: Tue, 18 Mar 2025 17:02:19 +0000 Subject: [PATCH 09/25] configure document type --- .../interop/document/{cda.yaml => ccd.yaml} | 0 .../allergy_intolerance.liquid | 0 .../{cda => cda_fhir}/cda_document.liquid | 0 .../cda_problem_entry.liquid | 0 .../{cda => cda_fhir}/cda_section.liquid | 0 .../{cda => cda_fhir}/condition.liquid | 0 .../medication_statement.liquid | 0 healthchain/config_manager.py | 45 ++++++++++------ healthchain/interop/engine.py | 27 ++++++++-- healthchain/interop/generators/cda.py | 54 ++++++++++--------- 10 files changed, 79 insertions(+), 47 deletions(-) rename config/interop/document/{cda.yaml => ccd.yaml} (100%) rename config/templates/{cda => cda_fhir}/allergy_intolerance.liquid (100%) rename config/templates/{cda => cda_fhir}/cda_document.liquid (100%) rename config/templates/{cda => cda_fhir}/cda_problem_entry.liquid (100%) rename config/templates/{cda => cda_fhir}/cda_section.liquid (100%) rename config/templates/{cda => cda_fhir}/condition.liquid (100%) rename config/templates/{cda => cda_fhir}/medication_statement.liquid (100%) diff --git a/config/interop/document/cda.yaml b/config/interop/document/ccd.yaml similarity index 100% rename from config/interop/document/cda.yaml rename to config/interop/document/ccd.yaml diff --git a/config/templates/cda/allergy_intolerance.liquid b/config/templates/cda_fhir/allergy_intolerance.liquid similarity index 100% rename from config/templates/cda/allergy_intolerance.liquid rename to config/templates/cda_fhir/allergy_intolerance.liquid diff --git a/config/templates/cda/cda_document.liquid b/config/templates/cda_fhir/cda_document.liquid similarity index 100% rename from config/templates/cda/cda_document.liquid rename to config/templates/cda_fhir/cda_document.liquid diff --git a/config/templates/cda/cda_problem_entry.liquid b/config/templates/cda_fhir/cda_problem_entry.liquid similarity index 100% rename from config/templates/cda/cda_problem_entry.liquid rename to config/templates/cda_fhir/cda_problem_entry.liquid diff --git a/config/templates/cda/cda_section.liquid b/config/templates/cda_fhir/cda_section.liquid similarity index 100% rename from config/templates/cda/cda_section.liquid rename to config/templates/cda_fhir/cda_section.liquid diff --git a/config/templates/cda/condition.liquid b/config/templates/cda_fhir/condition.liquid similarity index 100% rename from config/templates/cda/condition.liquid rename to config/templates/cda_fhir/condition.liquid diff --git a/config/templates/cda/medication_statement.liquid b/config/templates/cda_fhir/medication_statement.liquid similarity index 100% rename from config/templates/cda/medication_statement.liquid rename to config/templates/cda_fhir/medication_statement.liquid diff --git a/healthchain/config_manager.py b/healthchain/config_manager.py index a3615ddd..605709e0 100644 --- a/healthchain/config_manager.py +++ b/healthchain/config_manager.py @@ -434,7 +434,7 @@ def get_section_configs(self) -> Dict: log.warning("No section configs found") return {} - def get_document_config(self) -> Dict: + def get_document_config(self, document_type: str) -> Dict: """Get document configuration Returns: @@ -444,7 +444,9 @@ def get_document_config(self) -> Dict: if self._module and self._module in self._module_configs: # First try to find document configs directly in the module configs module_document = ( - self._module_configs[self._module].get("document", {}).get("cda", {}) + self._module_configs[self._module] + .get("document", {}) + .get(document_type, {}) ) if module_document: return module_document @@ -517,12 +519,34 @@ def register_schema(self, config_type: str, required_keys: Set[str]) -> None: """ self._custom_schemas[config_type] = required_keys - def validate(self) -> bool: - """Validate that all required configurations are present + def validate_document_config(self, document_type: str) -> bool: + """Validate document configuration + + Args: + document_type: Type of document to validate Returns: True if valid, False otherwise """ + document_config = self.get_document_config(document_type) + if not document_config: + self._handle_validation_error( + f"No document config found for document type: {document_type}" + ) + return False + + # Validate document config + missing_keys = self.REQUIRED_DOCUMENT_KEYS - set(document_config.keys()) + if missing_keys: + self._handle_validation_error( + f"Document config for document type '{document_type}' is missing required keys: {missing_keys}" + ) + return False + + return True + + def validate(self) -> bool: + """Validate that all required configurations are present""" is_valid = True # Validate section configs @@ -538,19 +562,6 @@ def validate(self) -> bool: f"Section '{section_key}' is missing required keys: {missing_keys}" ) - # Validate document config - document_config = self.get_document_config() - - if not document_config: - is_valid = self._handle_validation_error("No document config found") - else: - # Validate document config - missing_keys = self.REQUIRED_DOCUMENT_KEYS - set(document_config.keys()) - if missing_keys: - is_valid = self._handle_validation_error( - f"Document config is missing required keys: {missing_keys}" - ) - # Validate custom schemas for config_type, required_keys in self._custom_schemas.items(): config = self.get_configs().get(config_type, {}) diff --git a/healthchain/interop/engine.py b/healthchain/interop/engine.py index b4185fff..ca8a7e3c 100644 --- a/healthchain/interop/engine.py +++ b/healthchain/interop/engine.py @@ -358,22 +358,24 @@ def from_fhir( self, resources: List[Resource], format_type: Union[str, FormatType], + **kwargs, ) -> str: """Convert FHIR resources to HL7v2 or CDA""" format_type = validate_format(format_type) if format_type == FormatType.HL7V2: - return self._fhir_to_hl7v2(resources) + return self._fhir_to_hl7v2(resources, **kwargs) elif format_type == FormatType.CDA: - return self._fhir_to_cda(resources) + return self._fhir_to_cda(resources, **kwargs) else: raise ValueError(f"Unsupported format: {format_type}") - def _cda_to_fhir(self, xml: str) -> List[Resource]: + 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 @@ -398,11 +400,16 @@ def _cda_to_fhir(self, xml: str) -> List[Resource]: return resources - def _fhir_to_cda(self, resources: Union[Resource, List[Resource], Bundle]) -> str: + def _fhir_to_cda( + self, resources: Union[Resource, List[Resource], Bundle], **kwargs + ) -> str: """Convert FHIR resources to CDA XML Args: resources: A FHIR Bundle, list of resources, or single resource + **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 @@ -413,10 +420,20 @@ def _fhir_to_cda(self, resources: Union[Resource, List[Resource], Bundle]) -> st # 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}") + + # Validate document configuration for this specific document type + self.config_manager.validate_document_config(document_type) + # Normalize input to list of resources resource_list = normalize_resource_list(resources) - return cda_generator.generate_document_from_fhir_resources(resource_list) + return cda_generator.generate_document_from_fhir_resources( + resource_list, document_type + ) def _hl7v2_to_fhir(self, source_data: str) -> List[Resource]: """Convert HL7v2 to FHIR resources""" diff --git a/healthchain/interop/generators/cda.py b/healthchain/interop/generators/cda.py index 20938949..36f89625 100644 --- a/healthchain/interop/generators/cda.py +++ b/healthchain/interop/generators/cda.py @@ -22,6 +22,30 @@ class CDAGenerator(TemplateRenderer): """Handles generation of CDA documents""" + def generate_document_from_fhir_resources( + self, + resources: List[Resource], + document_type: str, + ) -> str: + """Generate a complete CDA document from FHIR resources + + This method handles the entire process of generating a CDA document: + 1. Creating section entries from resources (if not provided) + 2. Rendering sections + 3. Generating the final document + + Args: + resources: FHIR resources to include in the document + + Returns: + CDA document as XML string + """ + mapped_entries = self._get_mapped_entries(resources) + sections = self._render_sections(mapped_entries) + + # Generate final CDA document + return self._render_document(sections, document_type) + def _render_entry( self, resource: Resource, @@ -141,6 +165,7 @@ def _render_sections(self, mapped_entries: Dict) -> List[Dict]: def _render_document( self, sections: List[Dict], + document_type: str, ) -> str: """Generate the final CDA document @@ -150,9 +175,11 @@ def _render_document( Returns: CDA document as XML string """ - config = self.config_manager.get_document_config() + config = self.config_manager.get_document_config(document_type) if not config: - raise ValueError("No document configuration found in /document") + raise ValueError( + f"No document configuration found in /document/{document_type}" + ) # Get document template name from config or use default document_template_name = self.config_manager.get_config_value( @@ -183,26 +210,3 @@ def _render_document( # Fix self-closing tags return re.sub(r"(<(\w+)(\s+[^>]*?)?)>", r"\1/>", xml_string) - - def generate_document_from_fhir_resources( - self, - resources: List[Resource], - ) -> str: - """Generate a complete CDA document from FHIR resources - - This method handles the entire process of generating a CDA document: - 1. Creating section entries from resources (if not provided) - 2. Rendering sections - 3. Generating the final document - - Args: - resources: FHIR resources to include in the document - - Returns: - CDA document as XML string - """ - mapped_entries = self._get_mapped_entries(resources) - sections = self._render_sections(mapped_entries) - - # Generate final CDA document - return self._render_document(sections) From b7d4bdc71b80563693337e2a0d448c8010d5346b Mon Sep 17 00:00:00 2001 From: jenniferjiangkells Date: Wed, 19 Mar 2025 13:47:09 +0000 Subject: [PATCH 10/25] Add Cda validation and medication entry templates --- config/interop/sections/medications.yaml | 50 +++++--- .../cda_fhir/cda_medication_entry.liquid | 109 ++++++++++++++++++ .../cda_fhir/medication_statement.liquid | 90 +++++++++++---- healthchain/interop/engine.py | 10 ++ healthchain/interop/filters.py | 84 +++++++++++++- healthchain/interop/generators/cda.py | 29 ++++- healthchain/interop/generators/fhir.py | 2 + healthchain/interop/template_renderer.py | 2 +- 8 files changed, 328 insertions(+), 48 deletions(-) create mode 100644 config/templates/cda_fhir/cda_medication_entry.liquid diff --git a/config/interop/sections/medications.yaml b/config/interop/sections/medications.yaml index 6d3ba318..61b5c430 100644 --- a/config/interop/sections/medications.yaml +++ b/config/interop/sections/medications.yaml @@ -11,25 +11,39 @@ code: "10160-0" display: "Medications" # Entry act template IDs -entry_act_template_ids: - - "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" +entry: + substance_administration: + status_code: "completed" + template_ids: + - "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" + +consumable: + manufactured_product: + template_ids: + - "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: + template_id: + - "2.16.840.1.113883.10.20.1.47" + status_code: "completed" + code: + 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" -# Status codes and other configurable options -act_status_code: "active" -entry_relationship_type_code: "SUBJ" -entry_relationship_inversion_ind: false -observation_code: "10160-0" -observation_code_system: "2.16.840.1.113883.6.1" -observation_code_system_name: "LOINC" -observation_display_name: "Medication" -observation_status_code: "completed" -status_code_system: "2.16.840.1.113883.6.1" -status_display_name: "Status" -status_observation_status_code: "completed" # Rendering configuration rendering: diff --git a/config/templates/cda_fhir/cda_medication_entry.liquid b/config/templates/cda_fhir/cda_medication_entry.liquid new file mode 100644 index 00000000..0d5869e9 --- /dev/null +++ b/config/templates/cda_fhir/cda_medication_entry.liquid @@ -0,0 +1,109 @@ +{ + "substanceAdministration": { + "@classCode": "SBADM", + "@moodCode": "INT", + "templateId": [ + {% for template_id in config.entry.substance_administration.template_ids %} + {"@root": "{{template_id}}"} {% if forloop.last != true %},{% endif %} + {% endfor %} + ], + "id": {"@root": "{{ resource.id | generate_id }}"}, + "statusCode": {"@code": "{{ config.entry.substance_administration.status_code | default: 'completed' }}"}, + {% 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.consumable.manufactured_product.template_ids %} + {"@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.clinical_status_observation.template_id | default: '2.16.840.1.113883.10.20.1.47' }}"}, + "code": { + "@code": "{{ config.clinical_status_observation.code.code | default: '33999-4' }}", + "@codeSystem": "{{ config.clinical_status_observation.code.code_system | default: '2.16.840.1.113883.6.1' }}", + "@codeSystemName": "{{ config.clinical_status_observation.code.code_system_name | default: 'LOINC' }}", + "@displayName": "{{ config.clinical_status_observation.code.display_name | default: 'Status' }}" + }, + "value": { + "@xmlns:xsi": "http://www.w3.org/2001/XMLSchema-instance", + "@code": "{{ config.clinical_status_observation.value.code | default: '755561003' }}", + "@codeSystem": "{{ config.clinical_status_observation.value.code_system | default: '2.16.840.1.113883.6.96' }}", + "@codeSystemName": "{{ config.clinical_status_observation.value.code_system_name | default: 'SNOMED CT' }}", + "@xsi:type": "CE", + "@displayName": "{{ config.clinical_status_observation.value.display_name | default: 'Active' }}" + }, + "statusCode": {"@code": "{{ config.clinical_status_observation.status_code | default: 'completed' }}"}, + "effectiveTime": { + "low": {"@value": "{{ timestamp }}"} + } + } + } + } +} diff --git a/config/templates/cda_fhir/medication_statement.liquid b/config/templates/cda_fhir/medication_statement.liquid index 14c08627..1495540e 100644 --- a/config/templates/cda_fhir/medication_statement.liquid +++ b/config/templates/cda_fhir/medication_statement.liquid @@ -1,32 +1,74 @@ { "resourceType": "MedicationStatement", "id": "{{ entry.id | generate_id }}", - "status": "{{ entry.statusCode | map_status: 'cda_to_fhir' }}", - "medicationCodeableConcept": { - "coding": [{ - "system": "{{ entry.consumable.manufacturedProduct.manufacturedMaterial.code.codeSystem | map_system: 'cda_to_fhir' }}", - "code": "{{ entry.consumable.manufacturedProduct.manufacturedMaterial.code.code }}", - "display": "{{ entry.consumable.manufacturedProduct.manufacturedMaterial.code.displayName }}" - }] - }, - {% if entry.effectiveTime %} - "effectivePeriod": { - {% if entry.effectiveTime.low %} - "start": "{{ entry.effectiveTime.low.value | format_date }}" - {% endif %} - {% if entry.effectiveTime.high %} - "end": "{{ entry.effectiveTime.high.value | format_date }}" + "status": "{{ entry.statusCode | map_status: 'cda_to_fhir' | default: 'recorded' }}", + "medication": { + "concept": { + "coding": [{ + "system": "{{ entry.substanceAdministration.consumable.manufacturedProduct.manufacturedMaterial.code['@codeSystem'] | map_system: 'cda_to_fhir' }}", + "code": "{{ entry.substanceAdministration.consumable.manufacturedProduct.manufacturedMaterial.code['@code'] }}", + "display": "{{ entry.substanceAdministration.consumable.manufacturedProduct.manufacturedMaterial.code['@displayName'] }}" + }] + } + } + + {% comment %}Process effectiveTime and extract period/timing information if exists{% endcomment %} + {% if entry.substanceAdministration.effectiveTime %} + , + {% assign effective_period = entry.substanceAdministration.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 = entry.substanceAdministration.effectiveTime | extract_effective_timing %} + {% if entry.substanceAdministration.doseQuantity or entry.substanceAdministration.routeCode or effective_timing %},{% endif %} {% endif %} - }, {% endif %} - {% if entry.doseQuantity %} - "dosage": [{ - "doseAndRate": [{ - "doseQuantity": { - "value": {{ entry.doseQuantity.value }}, - "unit": "{{ entry.doseQuantity.unit }}" + + {% comment %}Add dosage if any dosage related fields are present{% endcomment %} + {% assign effective_timing = entry.substanceAdministration.effectiveTime | extract_effective_timing %} + {% if entry.substanceAdministration.doseQuantity or entry.substanceAdministration.routeCode or effective_timing %} + {% if entry.substanceAdministration.effectiveTime == nil %},{% endif %} + "dosage": [ + { + {% if entry.substanceAdministration.doseQuantity %} + "doseAndRate": [ + { + "doseQuantity": { + "value": {{ entry.substanceAdministration.doseQuantity['@value'] }}, + "unit": "{{ entry.substanceAdministration.doseQuantity['@unit'] }}" + } + } + ]{% if entry.substanceAdministration.routeCode or effective_timing %},{% endif %} + {% endif %} + + {% if entry.substanceAdministration.routeCode %} + "route": { + "coding": [ + { + "system": "{{ entry.substanceAdministration.routeCode['@codeSystem'] | map_system: 'cda_to_fhir' }}", + "code": "{{ entry.substanceAdministration.routeCode['@code'] }}", + "display": "{{ entry.substanceAdministration.routeCode['@displayName'] }}" + } + ] + }{% if effective_timing %},{% endif %} + {% endif %} + + {% if effective_timing %} + "timing": { + "repeat": { + "period": {{ effective_timing.period }}, + "periodUnit": "{{ effective_timing.periodUnit }}" + } + } + {% endif %} } - }] - }] + ] {% endif %} + + , + "subject": { + "reference": "Patient/{{ entry.subject_id | default: '123' }}" + } } diff --git a/healthchain/interop/engine.py b/healthchain/interop/engine.py index ca8a7e3c..15de235e 100644 --- a/healthchain/interop/engine.py +++ b/healthchain/interop/engine.py @@ -24,6 +24,8 @@ format_timestamp, generate_id, to_json, + extract_effective_period, + extract_effective_timing, ) from healthchain.interop.utils import normalize_resource_list @@ -286,6 +288,12 @@ def json_filter(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) + # Return dictionary of filters return { "map_system": map_system_filter, @@ -295,6 +303,8 @@ def clean_empty_filter(d): "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, } def add_filter(self, name: str, filter_func: Callable) -> "InteropEngine": diff --git a/healthchain/interop/filters.py b/healthchain/interop/filters.py index bd2f167c..aa800d62 100644 --- a/healthchain/interop/filters.py +++ b/healthchain/interop/filters.py @@ -1,7 +1,7 @@ import json import uuid from datetime import datetime -from typing import Dict, Any, Optional +from typing import Dict, Any, Optional, List, Union def map_system( @@ -52,6 +52,7 @@ def map_status( return status_mappings.get(status, status) +# 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]: @@ -120,6 +121,87 @@ def to_json(obj: Any) -> str: 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 diff --git a/healthchain/interop/generators/cda.py b/healthchain/interop/generators/cda.py index 36f89625..7bd6db9d 100644 --- a/healthchain/interop/generators/cda.py +++ b/healthchain/interop/generators/cda.py @@ -12,7 +12,7 @@ from typing import Dict, List, Optional from fhir.resources.resource import Resource - +from healthchain.interop.models.cda import ClinicalDocument from healthchain.interop.template_renderer import TemplateRenderer from healthchain.interop.utils import find_section_key_for_resource_type @@ -26,6 +26,7 @@ def generate_document_from_fhir_resources( self, resources: List[Resource], document_type: str, + validate: bool = True, ) -> str: """Generate a complete CDA document from FHIR resources @@ -44,7 +45,7 @@ def generate_document_from_fhir_resources( sections = self._render_sections(mapped_entries) # Generate final CDA document - return self._render_document(sections, document_type) + return self._render_document(sections, document_type, validate=validate) def _render_entry( self, @@ -114,7 +115,8 @@ def _get_mapped_entries(self, resources: List[Resource]) -> Dict: if not section_key: continue - + if resource_type == "MedicationStatement": + print("effectivePeriod", resource.effectivePeriod.end) entry = self._render_entry(resource, section_key) if entry: section_entries.setdefault(section_key, []).append(entry) @@ -166,11 +168,14 @@ def _render_document( self, sections: List[Dict], document_type: str, + validate: bool = True, ) -> str: """Generate the final CDA document Args: sections: List of formatted section dictionaries + document_type: Type of document to generate + validate: Whether to validate the CDA document Returns: CDA document as XML string @@ -198,6 +203,9 @@ def _render_document( rendered = self.render_template(document_template, context) + if validate: + validated = ClinicalDocument(**rendered["ClinicalDocument"]) + # Get XML formatting options pretty_print = self.config_manager.get_config_value( "document.cda.rendering.xml.pretty_print", True @@ -205,8 +213,21 @@ def _render_document( encoding = self.config_manager.get_config_value( "document.cda.rendering.xml.encoding", "UTF-8" ) + if validate: + out_dict = { + "ClinicalDocument": validated.model_dump( + exclude_none=True, exclude_unset=True, by_alias=True + ) + } + else: + out_dict = rendered + # Generate XML - xml_string = xmltodict.unparse(rendered, pretty=pretty_print, encoding=encoding) + xml_string = xmltodict.unparse( + out_dict, + pretty=pretty_print, + encoding=encoding, + ) # Fix self-closing tags return re.sub(r"(<(\w+)(\s+[^>]*?)?)>", r"\1/>", xml_string) diff --git a/healthchain/interop/generators/fhir.py b/healthchain/interop/generators/fhir.py index 01a15786..11975547 100644 --- a/healthchain/interop/generators/fhir.py +++ b/healthchain/interop/generators/fhir.py @@ -55,6 +55,8 @@ def convert_cda_entries_to_resources( if not resource_dict: continue + log.debug(f"Generated FHIR resource: {resource_dict}") + resource = self._validate_fhir_resource(resource_dict, resource_type) if resource: diff --git a/healthchain/interop/template_renderer.py b/healthchain/interop/template_renderer.py index 18b97f5c..b5d3a7e9 100644 --- a/healthchain/interop/template_renderer.py +++ b/healthchain/interop/template_renderer.py @@ -107,7 +107,7 @@ def render_template(self, template, context: Dict[str, Any]) -> Optional[Dict]: rendered = template.render(context) return clean_empty(json.loads(rendered)) except Exception as e: - log.error(f"Failed to render template: {str(e)}") + log.error(f"Failed to render template {template.name}: {str(e)}") return None def get_section_config(self, section_key: str) -> Dict: From f67b308bd473af9f01d0ec2e658246fb39d47c26 Mon Sep 17 00:00:00 2001 From: jenniferjiangkells Date: Mon, 24 Mar 2025 14:44:33 +0000 Subject: [PATCH 11/25] AllergyIntolerance template --- config/interop/sections/allergies.yaml | 8 + .../cda_fhir/allergy_intolerance.liquid | 89 ++++++++--- healthchain/interop/engine.py | 15 ++ healthchain/interop/filters.py | 141 ++++++++++++++++++ healthchain/interop/generators/cda.py | 3 +- healthchain/interop/generators/fhir.py | 10 +- 6 files changed, 238 insertions(+), 28 deletions(-) diff --git a/config/interop/sections/allergies.yaml b/config/interop/sections/allergies.yaml index 202579b2..5a65d5ce 100644 --- a/config/interop/sections/allergies.yaml +++ b/config/interop/sections/allergies.yaml @@ -17,6 +17,14 @@ entry_act_template_ids: - "2.16.840.1.113883.3.88.11.32.6" - "2.16.840.1.113883.3.88.11.83.6" +clinical_status: + template_id: "2.16.840.1.113883.10.20.1.39" + code: "33999-4" +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" + # Status codes and other configurable options act_status_code: "active" entry_relationship_type_code: "SUBJ" diff --git a/config/templates/cda_fhir/allergy_intolerance.liquid b/config/templates/cda_fhir/allergy_intolerance.liquid index 3dbd0c35..24ece3ab 100644 --- a/config/templates/cda_fhir/allergy_intolerance.liquid +++ b/config/templates/cda_fhir/allergy_intolerance.liquid @@ -1,39 +1,82 @@ { "resourceType": "AllergyIntolerance", "id": "{{ entry.id | generate_id }}", + + {% comment %}Extract clinical status using the filter{% endcomment %} + {% 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 %} + {% if clinical_status %} "clinicalStatus": { "coding": [{ "system": "http://terminology.hl7.org/CodeSystem/allergyintolerance-clinical", - "code": "{{ entry.status | map_status: 'cda_to_fhir' }}" + "code": "{{ clinical_status | map_status: 'cda_to_fhir' }}" }] }, - "code": { + {% endif %} + + {% comment %}Extract allergy type directly from observation code{% endcomment %} + {% if obs.code %} + "type": { "coding": [{ - "system": "{{ entry.observation.value.codeSystem | map_system: 'cda_to_fhir' }}", - "code": "{{ entry.observation.value.code }}", - "display": "{{ entry.observation.value.displayName }}" + "system": "{{ obs.code['@codeSystem'] | map_system: 'cda_to_fhir' }}", + "code": "{{ obs.code['@code'] }}", + "display": "{{ obs.code['@displayName'] }}" }] }, - {% if entry.effectiveTime %} - "onsetDateTime": "{{ entry.effectiveTime.value | format_date }}", {% endif %} - "subject": { - "reference": "Patient/{{ entry.subject_id | default: 'example' }}" - }, - {% if entry.observation.entryRelationship %} - "reaction": [{ - {% if entry.observation.entryRelationship.observation.value %} - "manifestation": [{ + + {% comment %}Extract allergy code/substance information directly{% endcomment %} + {% 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": "{{ entry.observation.entryRelationship.observation.value.codeSystem | map_system }}", - "code": "{{ entry.observation.entryRelationship.observation.value.code }}", - "display": "{{ entry.observation.entryRelationship.observation.value.displayName }}" + "system": "{{ obs.value['@codeSystem'] | map_system: 'cda_to_fhir' }}", + "code": "{{ obs.value['@code'] }}", + "display": "{{ obs.value['@displayName'] }}" }] - }], - {% endif %} - {% if entry.observation.entryRelationship.observation.entryRelationship %} - "severity": "{{ entry.observation.entryRelationship.observation.entryRelationship.observation.value.code | map_severity }}" - {% endif %} - }] + }, + {% endif %} + + "patient": { + "reference": "Patient/{{ entry.subject_id | default: 'Foo' }}" + }, + + {% comment %}Extract onset date directly{% endcomment %} + {% if obs.effectiveTime.low['@value'] %} + "onsetDateTime": "{{ obs.effectiveTime.low['@value'] | format_date }}", + {% endif %} + + {% comment %}Extract reactions using the reactions filter{% endcomment %} + {% assign reactions = obs | extract_reactions: config %} + + {% 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 }}"{% endif %} + }{% unless forloop.last %},{% endunless %} + {% endfor %} + ] {% endif %} } diff --git a/healthchain/interop/engine.py b/healthchain/interop/engine.py index 15de235e..8f6cc495 100644 --- a/healthchain/interop/engine.py +++ b/healthchain/interop/engine.py @@ -26,6 +26,9 @@ to_json, extract_effective_period, extract_effective_timing, + extract_clinical_status, + extract_reactions, + map_severity, ) from healthchain.interop.utils import normalize_resource_list @@ -294,6 +297,15 @@ def extract_effective_period_filter(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, @@ -305,6 +317,9 @@ def extract_effective_timing_filter(effective_times): "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, } def add_filter(self, name: str, filter_func: Callable) -> "InteropEngine": diff --git a/healthchain/interop/filters.py b/healthchain/interop/filters.py index aa800d62..8fde0db1 100644 --- a/healthchain/interop/filters.py +++ b/healthchain/interop/filters.py @@ -52,6 +52,30 @@ def map_status( return status_mappings.get(status, 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 ('cda_to_fhir' or 'fhir_to_cda') + + Returns: + Mapped severity code or original if no mapping found + """ + if not severity_code: + return None + + if not mappings: + return severity_code + + shared_mappings = mappings.get("shared_mappings", {}) + severity_mappings = shared_mappings.get("severity_codes", {}).get(direction, {}) + return severity_mappings.get(severity_code, 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" @@ -220,3 +244,120 @@ def clean_empty(d: Any) -> Any: 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("clinical_status", {}).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("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 + # This should match config.severity_observation.template_id in liquid template + for sev_template in _get_template_ids(sev["observation"]): + if sev_template.get("@root") == config.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 diff --git a/healthchain/interop/generators/cda.py b/healthchain/interop/generators/cda.py index 7bd6db9d..fa8c590f 100644 --- a/healthchain/interop/generators/cda.py +++ b/healthchain/interop/generators/cda.py @@ -115,8 +115,7 @@ def _get_mapped_entries(self, resources: List[Resource]) -> Dict: if not section_key: continue - if resource_type == "MedicationStatement": - print("effectivePeriod", resource.effectivePeriod.end) + entry = self._render_entry(resource, section_key) if entry: section_entries.setdefault(section_key, []).append(entry) diff --git a/healthchain/interop/generators/fhir.py b/healthchain/interop/generators/fhir.py index 11975547..242e76db 100644 --- a/healthchain/interop/generators/fhir.py +++ b/healthchain/interop/generators/fhir.py @@ -149,11 +149,11 @@ def _add_required_fields(self, resource_dict: Dict, resource_type: str) -> Dict: "defaults.common.subject", {"reference": "Patient/example"} ) - if "subject" not in resource_dict: - resource_dict["subject"] = default_subject - # Add resource-specific required fields if resource_type == "Condition": + if "subject" not in resource_dict: + resource_dict["subject"] = default_subject + if "clinicalStatus" not in resource_dict: default_status = self.config_manager.get_config_value( "defaults.resources.Condition.clinicalStatus", @@ -168,12 +168,16 @@ def _add_required_fields(self, resource_dict: Dict, resource_type: str) -> Dict: ) resource_dict["clinicalStatus"] = default_status elif resource_type == "MedicationStatement": + if "subject" not in resource_dict: + resource_dict["subject"] = default_subject if "status" not in resource_dict: default_status = self.config_manager.get_config_value( "defaults.resources.MedicationStatement.status", "unknown" ) resource_dict["status"] = default_status elif resource_type == "AllergyIntolerance": + if "patient" not in resource_dict: + resource_dict["patient"] = default_subject if "clinicalStatus" not in resource_dict: default_status = self.config_manager.get_config_value( "defaults.resources.AllergyIntolerance.clinicalStatus", From 5c38c76552cc3ea4c165bca7dc4b56a00537dbc3 Mon Sep 17 00:00:00 2001 From: jenniferjiangkells Date: Mon, 24 Mar 2025 19:57:40 +0000 Subject: [PATCH 12/25] allergy entry template --- config/interop/sections/allergies.yaml | 77 +++++++--- config/mappings/shared_mappings.yaml | 10 ++ .../cda_fhir/allergy_intolerance.liquid | 12 +- .../cda_fhir/cda_allergy_entry.liquid | 144 ++++++++++++++++++ 4 files changed, 210 insertions(+), 33 deletions(-) create mode 100644 config/templates/cda_fhir/cda_allergy_entry.liquid diff --git a/config/interop/sections/allergies.yaml b/config/interop/sections/allergies.yaml index 5a65d5ce..22ac6383 100644 --- a/config/interop/sections/allergies.yaml +++ b/config/interop/sections/allergies.yaml @@ -1,43 +1,76 @@ # Allergies Section Configuration # This file contains configuration for the allergies section -# TODO: Implement # Basic section information resource: "AllergyIntolerance" -resource_template: "cda/allergy_intolerance" -entry_template: "cda/cda_allergy_entry" +resource_template: "cda_fhir/allergy_intolerance" +entry_template: "cda_fhir/cda_allergy_entry" template_id: "2.16.840.1.113883.10.20.1.2" code: "48765-2" display: "Allergies" -# Entry act template IDs -entry_act_template_ids: - - "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" +# Entry configuration +entry: + act: + template_ids: + - "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" +# Main entry relationship configuration +main_entry_relationship: + type_code: "SUBJ" + inversion_ind: false + observation: + template_ids: + - "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_observation: + template_ids: + - "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_observation: + template_ids: + - "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 configuration clinical_status: 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" + 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" -# Status codes and other configurable options -act_status_code: "active" -entry_relationship_type_code: "SUBJ" -entry_relationship_inversion_ind: false -observation_code: "48765-2" -observation_code_system: "2.16.840.1.113883.6.1" -observation_code_system_name: "LOINC" -observation_display_name: "Allergy" -observation_status_code: "completed" -status_code_system: "2.16.840.1.113883.6.1" -status_display_name: "Status" -status_observation_status_code: "completed" - # Rendering configuration rendering: narrative: diff --git a/config/mappings/shared_mappings.yaml b/config/mappings/shared_mappings.yaml index f922feae..6eef2001 100644 --- a/config/mappings/shared_mappings.yaml +++ b/config/mappings/shared_mappings.yaml @@ -19,3 +19,13 @@ status_codes: "active": "55561003" "resolved": "413322009" "inactive": "73425007" + +severity_codes: + cda_to_fhir: + "H": "severe" + "M": "moderate" + "L": "mild" + fhir_to_cda: + "severe": "H" + "moderate": "M" + "mild": "L" diff --git a/config/templates/cda_fhir/allergy_intolerance.liquid b/config/templates/cda_fhir/allergy_intolerance.liquid index 24ece3ab..543934d6 100644 --- a/config/templates/cda_fhir/allergy_intolerance.liquid +++ b/config/templates/cda_fhir/allergy_intolerance.liquid @@ -1,8 +1,6 @@ { "resourceType": "AllergyIntolerance", "id": "{{ entry.id | generate_id }}", - - {% comment %}Extract clinical status using the filter{% endcomment %} {% if entry.act.entryRelationship.size %} {% assign obs = entry.act.entryRelationship[0].observation %} {% else %} @@ -17,8 +15,6 @@ }] }, {% endif %} - - {% comment %}Extract allergy type directly from observation code{% endcomment %} {% if obs.code %} "type": { "coding": [{ @@ -28,8 +24,6 @@ }] }, {% endif %} - - {% comment %}Extract allergy code/substance information directly{% endcomment %} {% if obs.participant.participantRole.playingEntity %} {% assign playing_entity = obs.participant.participantRole.playingEntity %} "code": { @@ -53,14 +47,10 @@ "reference": "Patient/{{ entry.subject_id | default: 'Foo' }}" }, - {% comment %}Extract onset date directly{% endcomment %} {% if obs.effectiveTime.low['@value'] %} "onsetDateTime": "{{ obs.effectiveTime.low['@value'] | format_date }}", {% endif %} - - {% comment %}Extract reactions using the reactions filter{% endcomment %} {% assign reactions = obs | extract_reactions: config %} - {% if reactions.size > 0 %} "reaction": [ {% for reaction in reactions %} @@ -74,7 +64,7 @@ }] } }]{% if reaction.severity != blank %}, - "severity": "{{ reaction.severity | map_severity }}"{% endif %} + "severity": "{{ reaction.severity | map_severity: 'cda_to_fhir' }}"{% endif %} }{% unless forloop.last %},{% endunless %} {% endfor %} ] diff --git a/config/templates/cda_fhir/cda_allergy_entry.liquid b/config/templates/cda_fhir/cda_allergy_entry.liquid new file mode 100644 index 00000000..0044cf25 --- /dev/null +++ b/config/templates/cda_fhir/cda_allergy_entry.liquid @@ -0,0 +1,144 @@ +{ + "act": { + "@classCode": "ACT", + "@moodCode": "EVN", + "templateId": [ + {% for template_id in config.entry.act.template_ids %} + {"@root": "{{template_id}}"} {% if forloop.last != true %},{% endif %} + {% endfor %} + ], + "id": {"@root": "{{ resource.id | generate_id }}"}, + "code": {"@nullFlavor": "NA"}, + "statusCode": { + "@code": "{{ config.entry.act.status_code | default: 'active' }}" + }, + "effectiveTime": { + "low": {"@value": "{{ timestamp }}"} + }, + "entryRelationship": { + "@typeCode": "{{ config.main_entry_relationship.type_code | default: 'SUBJ' }}", + "@inversionInd": {{ config.main_entry_relationship.inversion_ind | default: false }}, + "observation": { + "@classCode": "OBS", + "@moodCode": "EVN", + "templateId": [ + {% for template_id in config.main_entry_relationship.observation.template_ids %} + {"@root": "{{template_id}}"} {% if forloop.last != true %},{% endif %} + {% endfor %} + ], + "id": {"@root": "{{ resource.id }}_obs"}, + "text": { + "reference": {"@value": "{{ text_reference_name }}"} + }, + "statusCode": {"@code": "{{ config.main_entry_relationship.observation.status_code | default: 'completed' }}"}, + "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.main_entry_relationship.observation.code | default: '420134006' }}", + "@codeSystem": "{{ config.main_entry_relationship.observation.code_system | default: '2.16.840.1.113883.6.96' }}", + "@displayName": "{{ config.main_entry_relationship.observation.display_name | default: 'Propensity to adverse reactions' }}" + }, + {% 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.reaction %}, + "entryRelationship": { + "@typeCode": "MFST", + "observation": { + "@classCode": "OBS", + "@moodCode": "EVN", + "templateId": [ + {% for template_id in config.reaction_observation.template_ids %} + {"@root": "{{template_id}}"} {% if forloop.last != true %},{% endif %} + {% endfor %} + ], + "id": {"@root": "{{ resource.id }}_reaction"}, + "code": {"@code": "{{ config.reaction_observation.code | default: 'RXNASSESS' }}"}, + "text": { + "reference": {"@value": "{{ text_reference_name }}reaction"} + }, + "statusCode": {"@code": "{{ config.reaction_observation.status_code | default: 'completed' }}"}, + "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.severity_observation.template_ids %} + {"@root": "{{template_id}}"} {% if forloop.last != true %},{% endif %} + {% endfor %} + ], + "code": { + "@code": "{{ config.severity_observation.code | default: 'SEV' }}", + "@codeSystem": "{{ config.severity_observation.code_system | default: '2.16.840.1.113883.5.4' }}", + "@codeSystemName": "{{ config.severity_observation.code_system_name | default: 'ActCode' }}", + "@displayName": "{{ config.severity_observation.display_name | default: 'Severity' }}" + }, + "text": { + "reference": {"@value": "{{ text_reference_name }}severity"} + }, + "statusCode": {"@code": "{{ config.severity_observation.status_code | default: 'completed' }}"}, + "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.severity_observation.value.code_system }}", + "@codeSystemName": "{{ config.severity_observation.value.code_system_name }}", + "@displayName": "{{ resource.reaction[0].severity | map_severity: 'fhir_to_cda'}}" + } + } + } + {% endif %} + } + } + {% endif %} + } + } + } +} From f49b3fe8f03c8987dd4230a55222347ecda42091 Mon Sep 17 00:00:00 2001 From: jenniferjiangkells Date: Tue, 25 Mar 2025 15:33:41 +0000 Subject: [PATCH 13/25] Clean up configs --- config/interop/sections/allergies.yaml | 103 ++++++++++-------- config/interop/sections/medications.yaml | 71 +++++++----- config/interop/sections/problems.yaml | 65 +++++++---- .../cda_fhir/cda_allergy_entry.liquid | 41 +++---- .../cda_fhir/cda_medication_entry.liquid | 26 ++--- .../cda_fhir/cda_problem_entry.liquid | 30 ++--- config/templates/cda_fhir/cda_section.liquid | 12 +- config/templates/cda_fhir/condition.liquid | 2 +- healthchain/config_manager.py | 2 - healthchain/interop/filters.py | 15 +-- healthchain/interop/migration.py | 19 ---- healthchain/interop/parsers/cda.py | 4 +- 12 files changed, 210 insertions(+), 180 deletions(-) delete mode 100644 healthchain/interop/migration.py diff --git a/config/interop/sections/allergies.yaml b/config/interop/sections/allergies.yaml index 22ac6383..83ca67f3 100644 --- a/config/interop/sections/allergies.yaml +++ b/config/interop/sections/allergies.yaml @@ -1,16 +1,26 @@ # Allergies Section Configuration -# This file contains configuration for the allergies section +# ======================== -# Basic section information +# Metadata for both extraction and rendering processes resource: "AllergyIntolerance" resource_template: "cda_fhir/allergy_intolerance" entry_template: "cda_fhir/cda_allergy_entry" -template_id: "2.16.840.1.113883.10.20.1.2" -code: "48765-2" -display: "Allergies" -# Entry configuration -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_ids: - "1.3.6.1.4.1.19376.1.5.3.1.4.5.1" @@ -19,11 +29,10 @@ entry: - "2.16.840.1.113883.3.88.11.83.6" status_code: "active" -# Main entry relationship configuration -main_entry_relationship: - type_code: "SUBJ" - inversion_ind: false - observation: + # Allergy observation configuration + allergy_obs: + type_code: "SUBJ" + inversion_ind: false template_ids: - "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" @@ -36,40 +45,36 @@ main_entry_relationship: display_name: "Propensity to adverse reactions" status_code: "completed" -# Reaction observation configuration -reaction_observation: - template_ids: - - "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_observation: - template_ids: - - "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" + # Reaction observation configuration + reaction_obs: + template_ids: + - "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" -# Clinical status configuration -clinical_status: - 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" + # Severity observation configuration + severity_obs: + template_ids: + - "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" -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" + # 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 rendering: @@ -81,3 +86,13 @@ rendering: include_reaction: true include_severity: true include_dates: true + +# Default values for template +defaults: + status_code: "active" + type_code: "SUBJ" + inversion_ind: false + allergy_code: "420134006" + allergy_code_system: "2.16.840.1.113883.6.96" + allergy_code_system_name: "SNOMED CT" + allergy_display_name: "Propensity to adverse reactions" diff --git a/config/interop/sections/medications.yaml b/config/interop/sections/medications.yaml index 61b5c430..ed9021e3 100644 --- a/config/interop/sections/medications.yaml +++ b/config/interop/sections/medications.yaml @@ -1,27 +1,35 @@ # Medications Section Configuration -# This file contains configuration for the medications section -# TODO: Implement +# ======================== -# Basic section information +# Metadata for both extraction and rendering processes resource: "MedicationStatement" -resource_template: "cda/medication_statement" -entry_template: "cda/cda_medication_entry" -template_id: "2.16.840.1.113883.10.20.1.8" -code: "10160-0" -display: "Medications" +resource_template: "cda_fhir/medication_statement" +entry_template: "cda_fhir/cda_medication_entry" -# Entry act template IDs -entry: - substance_administration: - status_code: "completed" +# 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_ids: - "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" -consumable: + # Manufactured product configuration manufactured_product: template_ids: - "1.3.6.1.4.1.19376.1.5.3.1.4.7.2" @@ -29,21 +37,20 @@ consumable: - "2.16.840.1.113883.3.88.11.32.9" - "2.16.840.1.113883.3.88.11.83.8.2" -clinical_status_observation: - template_id: - - "2.16.840.1.113883.10.20.1.47" - status_code: "completed" - code: - 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" - + # Clinical status observation configuration + clinical_status_obs: + template_id: "2.16.840.1.113883.10.20.1.47" + status_code: "completed" + code: + 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: @@ -56,3 +63,11 @@ rendering: 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/config/interop/sections/problems.yaml b/config/interop/sections/problems.yaml index f6f2d052..12782fdf 100644 --- a/config/interop/sections/problems.yaml +++ b/config/interop/sections/problems.yaml @@ -1,17 +1,25 @@ # Problems Section Configuration -# This file contains configuration for the problems section +# ======================== -# Basic required section information +# Metadata for both extraction and rendering processes resource: "Condition" -resource_template: "cda/condition" -entry_template: "cda/cda_problem_entry" +resource_template: "cda_fhir/condition" +entry_template: "cda_fhir/cda_problem_entry" -template_id: "2.16.840.1.113883.10.20.1.11" -code: "11450-4" -display: "Problem List" +# 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" -# Entry act template IDs -entry: +# Template configuration (used for rendering/generation) +template: + # Act element configuration act: template_ids: - "2.16.840.1.113883.10.20.1.27" @@ -21,27 +29,28 @@ entry: - "2.16.840.1.113883.3.88.11.83.7" status_code: "completed" -main_entry_relationship: - type_code: "SUBJ" - inversion_ind: false - observation: + # Problem observation configuration + problem_obs: + type_code: "SUBJ" + inversion_ind: false + template_ids: + - "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" - template_ids: - - "1.3.6.1.4.1.19376.1.5.3.1.4.5" - - "2.16.840.1.113883.10.20.1.28" -clinical_status_observation: - code: "33999-4" - code_system: "2.16.840.1.113883.6.1" - code_system_name: "LOINC" - display_name: "Status" - status_code: "completed" + # Clinical status observation configuration + clinical_status_obs: + 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 yet) +# Rendering configuration rendering: narrative: include: true @@ -49,3 +58,13 @@ rendering: entry: include_status: true include_dates: true + +# Default values for template +defaults: + status_code: "active" + type_code: "SUBJ" + inversion_ind: false + problem_code: "55607006" + problem_code_system: "2.16.840.1.113883.6.96" + problem_code_system_name: "SNOMED CT" + problem_display_name: "Problem" diff --git a/config/templates/cda_fhir/cda_allergy_entry.liquid b/config/templates/cda_fhir/cda_allergy_entry.liquid index 0044cf25..e4b9fd8b 100644 --- a/config/templates/cda_fhir/cda_allergy_entry.liquid +++ b/config/templates/cda_fhir/cda_allergy_entry.liquid @@ -3,26 +3,26 @@ "@classCode": "ACT", "@moodCode": "EVN", "templateId": [ - {% for template_id in config.entry.act.template_ids %} + {% for template_id in config.template.act.template_ids %} {"@root": "{{template_id}}"} {% if forloop.last != true %},{% endif %} {% endfor %} ], "id": {"@root": "{{ resource.id | generate_id }}"}, "code": {"@nullFlavor": "NA"}, "statusCode": { - "@code": "{{ config.entry.act.status_code | default: 'active' }}" + "@code": "{{ config.template.act.status_code | default: config.defaults.status_code }}" }, "effectiveTime": { "low": {"@value": "{{ timestamp }}"} }, "entryRelationship": { - "@typeCode": "{{ config.main_entry_relationship.type_code | default: 'SUBJ' }}", - "@inversionInd": {{ config.main_entry_relationship.inversion_ind | default: false }}, + "@typeCode": "{{ config.template.allergy_obs.type_code | default: config.defaults.type_code }}", + "@inversionInd": {{ config.template.allergy_obs.inversion_ind | default: config.defaults.inversion_ind }}, "observation": { "@classCode": "OBS", "@moodCode": "EVN", "templateId": [ - {% for template_id in config.main_entry_relationship.observation.template_ids %} + {% for template_id in config.template.allergy_obs.template_ids %} {"@root": "{{template_id}}"} {% if forloop.last != true %},{% endif %} {% endfor %} ], @@ -30,7 +30,7 @@ "text": { "reference": {"@value": "{{ text_reference_name }}"} }, - "statusCode": {"@code": "{{ config.main_entry_relationship.observation.status_code | default: 'completed' }}"}, + "statusCode": {"@code": "{{ config.template.allergy_obs.status_code | default: 'completed' }}"}, "effectiveTime": { "low": {"@value": "{{ timestamp }}"} }, @@ -42,9 +42,10 @@ }, {% else %} "code": { - "@code": "{{ config.main_entry_relationship.observation.code | default: '420134006' }}", - "@codeSystem": "{{ config.main_entry_relationship.observation.code_system | default: '2.16.840.1.113883.6.96' }}", - "@displayName": "{{ config.main_entry_relationship.observation.display_name | default: 'Propensity to adverse reactions' }}" + "@code": "{{ config.template.allergy_obs.code | default: config.defaults.allergy_code }}", + "@codeSystem": "{{ config.template.allergy_obs.code_system | default: config.defaults.allergy_code_system }}", + "@codeSystemName": "{{ config.template.allergy_obs.code_system_name | default: config.defaults.allergy_code_system_name }}", + "@displayName": "{{ config.template.allergy_obs.display_name | default: config.defaults.allergy_display_name }}" }, {% endif %} "value": { @@ -81,16 +82,16 @@ "@classCode": "OBS", "@moodCode": "EVN", "templateId": [ - {% for template_id in config.reaction_observation.template_ids %} + {% for template_id in config.template.reaction_obs.template_ids %} {"@root": "{{template_id}}"} {% if forloop.last != true %},{% endif %} {% endfor %} ], "id": {"@root": "{{ resource.id }}_reaction"}, - "code": {"@code": "{{ config.reaction_observation.code | default: 'RXNASSESS' }}"}, + "code": {"@code": "{{ config.template.reaction_obs.code | default: 'RXNASSESS' }}"}, "text": { "reference": {"@value": "{{ text_reference_name }}reaction"} }, - "statusCode": {"@code": "{{ config.reaction_observation.status_code | default: 'completed' }}"}, + "statusCode": {"@code": "{{ config.template.reaction_obs.status_code | default: 'completed' }}"}, "effectiveTime": { "low": {"@value": "{{ timestamp }}"} }, @@ -110,26 +111,26 @@ "@classCode": "OBS", "@moodCode": "EVN", "templateId": [ - {% for template_id in config.severity_observation.template_ids %} + {% for template_id in config.template.severity_obs.template_ids %} {"@root": "{{template_id}}"} {% if forloop.last != true %},{% endif %} {% endfor %} ], "code": { - "@code": "{{ config.severity_observation.code | default: 'SEV' }}", - "@codeSystem": "{{ config.severity_observation.code_system | default: '2.16.840.1.113883.5.4' }}", - "@codeSystemName": "{{ config.severity_observation.code_system_name | default: 'ActCode' }}", - "@displayName": "{{ config.severity_observation.display_name | default: 'Severity' }}" + "@code": "{{ config.template.severity_obs.code | default: 'SEV' }}", + "@codeSystem": "{{ config.template.severity_obs.code_system | default: '2.16.840.1.113883.5.4' }}", + "@codeSystemName": "{{ config.template.severity_obs.code_system_name | default: 'ActCode' }}", + "@displayName": "{{ config.template.severity_obs.display_name | default: 'Severity' }}" }, "text": { "reference": {"@value": "{{ text_reference_name }}severity"} }, - "statusCode": {"@code": "{{ config.severity_observation.status_code | default: 'completed' }}"}, + "statusCode": {"@code": "{{ config.template.severity_obs.status_code | default: 'completed' }}"}, "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.severity_observation.value.code_system }}", - "@codeSystemName": "{{ config.severity_observation.value.code_system_name }}", + "@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'}}" } } diff --git a/config/templates/cda_fhir/cda_medication_entry.liquid b/config/templates/cda_fhir/cda_medication_entry.liquid index 0d5869e9..c2c10642 100644 --- a/config/templates/cda_fhir/cda_medication_entry.liquid +++ b/config/templates/cda_fhir/cda_medication_entry.liquid @@ -3,12 +3,12 @@ "@classCode": "SBADM", "@moodCode": "INT", "templateId": [ - {% for template_id in config.entry.substance_administration.template_ids %} + {% for template_id in config.template.substance_admin.template_ids %} {"@root": "{{template_id}}"} {% if forloop.last != true %},{% endif %} {% endfor %} ], "id": {"@root": "{{ resource.id | generate_id }}"}, - "statusCode": {"@code": "{{ config.entry.substance_administration.status_code | default: 'completed' }}"}, + "statusCode": {"@code": "{{ config.template.substance_admin.status_code | default: config.defaults.status_code }}"}, {% if resource.dosage and resource.dosage[0].doseAndRate %} "doseQuantity": { "@value": "{{ resource.dosage[0].doseAndRate[0].doseQuantity.value }}", @@ -63,7 +63,7 @@ "manufacturedProduct": { "@classCode": "MANU", "templateId": [ - {% for template_id in config.consumable.manufactured_product.template_ids %} + {% for template_id in config.template.manufactured_product.template_ids %} {"@root": "{{template_id}}"} {% if forloop.last != true %},{% endif %} {% endfor %} ], @@ -84,22 +84,22 @@ "observation": { "@classCode": "OBS", "@moodCode": "EVN", - "templateId": {"@root": "{{ config.clinical_status_observation.template_id | default: '2.16.840.1.113883.10.20.1.47' }}"}, + "templateId": {"@root": "{{ config.template.clinical_status_obs.template_id | default: '2.16.840.1.113883.10.20.1.47' }}"}, "code": { - "@code": "{{ config.clinical_status_observation.code.code | default: '33999-4' }}", - "@codeSystem": "{{ config.clinical_status_observation.code.code_system | default: '2.16.840.1.113883.6.1' }}", - "@codeSystemName": "{{ config.clinical_status_observation.code.code_system_name | default: 'LOINC' }}", - "@displayName": "{{ config.clinical_status_observation.code.display_name | default: 'Status' }}" + "@code": "{{ config.template.clinical_status_obs.code.code | default: '33999-4' }}", + "@codeSystem": "{{ config.template.clinical_status_obs.code.code_system | default: '2.16.840.1.113883.6.1' }}", + "@codeSystemName": "{{ config.template.clinical_status_obs.code.code_system_name | default: 'LOINC' }}", + "@displayName": "{{ config.template.clinical_status_obs.code.display_name | default: 'Status' }}" }, "value": { "@xmlns:xsi": "http://www.w3.org/2001/XMLSchema-instance", - "@code": "{{ config.clinical_status_observation.value.code | default: '755561003' }}", - "@codeSystem": "{{ config.clinical_status_observation.value.code_system | default: '2.16.840.1.113883.6.96' }}", - "@codeSystemName": "{{ config.clinical_status_observation.value.code_system_name | default: 'SNOMED CT' }}", + "@code": "{{ config.template.clinical_status_obs.value.code | default: config.defaults.medication_status_code }}", + "@codeSystem": "{{ config.template.clinical_status_obs.value.code_system | default: config.defaults.medication_status_system }}", + "@codeSystemName": "{{ config.template.clinical_status_obs.value.code_system_name | default: 'SNOMED CT' }}", "@xsi:type": "CE", - "@displayName": "{{ config.clinical_status_observation.value.display_name | default: 'Active' }}" + "@displayName": "{{ config.template.clinical_status_obs.value.display_name | default: config.defaults.medication_status_display }}" }, - "statusCode": {"@code": "{{ config.clinical_status_observation.status_code | default: 'completed' }}"}, + "statusCode": {"@code": "{{ config.template.clinical_status_obs.status_code | default: 'completed' }}"}, "effectiveTime": { "low": {"@value": "{{ timestamp }}"} } diff --git a/config/templates/cda_fhir/cda_problem_entry.liquid b/config/templates/cda_fhir/cda_problem_entry.liquid index 1b734623..4a1e7921 100644 --- a/config/templates/cda_fhir/cda_problem_entry.liquid +++ b/config/templates/cda_fhir/cda_problem_entry.liquid @@ -3,40 +3,40 @@ "@classCode": "ACT", "@moodCode": "EVN", "templateId": [ - {% for template_id in config.entry.act.template_ids %} + {% for template_id in config.template.act.template_ids %} {"@root": "{{template_id}}"} {% if forloop.last != true %},{% endif %} {% endfor %} ], "id": {"@root": "{{ resource.id | generate_id }}"}, "code": {"@nullFlavor": "NA"}, "statusCode": { - "@code": "{{ config.entry.act.status_code | default: 'active' }}" + "@code": "{{ config.template.act.status_code | default: config.defaults.status_code }}" }, "effectiveTime": { "low": {"@value": "{{ timestamp }}"} }, "entryRelationship": { - "@typeCode": "{{ config.main_entry_relationship.type_code | default: 'SUBJ' }}", - "@inversionInd": {{ config.main_entry_relationship.inversion_ind | default: false }}, + "@typeCode": "{{ config.template.problem_obs.type_code | default: config.defaults.type_code }}", + "@inversionInd": {{ config.template.problem_obs.inversion_ind | default: config.defaults.inversion_ind }}, "observation": { "@classCode": "OBS", "@moodCode": "EVN", "templateId": [ - {% for template_id in config.main_entry_relationship.observation.template_ids %} + {% for template_id in config.template.problem_obs.template_ids %} {"@root": "{{template_id}}"} {% if forloop.last != true %},{% endif %} {% endfor %} ], "id": {"@root": "{{ resource.id }}_obs"}, "code": { - "@code": "{{ config.main_entry_relationship.observation.code | default: '55607006' }}", - "@codeSystem": "{{ config.main_entry.act.entry_relationship.observation.code_system | default: '2.16.840.1.113883.6.96' }}", - "@codeSystemName": "{{ config.main_entry_relationship.observation.code_system_name | default: 'SNOMED CT' }}", - "@displayName": "{{ config.main_entry_relationship.observation.display_name | default: 'Problem' }}" + "@code": "{{ config.template.problem_obs.code | default: config.defaults.problem_code }}", + "@codeSystem": "{{ config.template.problem_obs.code_system | default: config.defaults.problem_code_system }}", + "@codeSystemName": "{{ config.template.problem_obs.code_system_name | default: config.defaults.problem_code_system_name }}", + "@displayName": "{{ config.template.problem_obs.display_name | default: config.defaults.problem_display_name }}" }, "text": { "reference": {"@value": "{{ text_reference_name }}"} }, - "statusCode": {"@code": "{{ config.main_entry_relationship.observation.status_code | default: 'completed' }}"}, + "statusCode": {"@code": "{{ config.template.problem_obs.status_code | default: 'completed' }}"}, "effectiveTime": { {% if resource.onsetDateTime %} "low": {"@value": "{{ resource.onsetDateTime }}"} @@ -61,10 +61,10 @@ "@classCode": "OBS", "@moodCode": "EVN", "code": { - "@code": "{{ config.clinical_status_observation.code | default: '33999-4' }}", - "@codeSystem": "{{ config.clinical_status_observation.code_system | default: '2.16.840.1.113883.6.1' }}", - "@codeSystemName": "{{config.clinical_status_observation.code_system_name | default: 'LOINC'}}", - "@displayName": "{{ config.clinical_status_observation.display_name | default: 'Status' }}" + "@code": "{{ config.template.clinical_status_obs.code | default: '33999-4' }}", + "@codeSystem": "{{ config.template.clinical_status_obs.code_system | default: '2.16.840.1.113883.6.1' }}", + "@codeSystemName": "{{config.template.clinical_status_obs.code_system_name | default: 'LOINC'}}", + "@displayName": "{{ config.template.clinical_status_obs.display_name | default: 'Status' }}" }, "value": { "@xmlns:xsi": "http://www.w3.org/2001/XMLSchema-instance", @@ -73,7 +73,7 @@ "@displayName": "{{ resource.clinicalStatus.coding[0].display }}", "@xsi:type": "CE" }, - "statusCode": {"@code": "{{ config.main_entry_relationship.observation.entry_relationship.observation.status_code | default: 'completed' }}"}, + "statusCode": {"@code": "{{ config.template.clinical_status_obs.status_code | default: 'completed' }}"}, "effectiveTime": { "low": {"@value": "{{ timestamp }}"} } diff --git a/config/templates/cda_fhir/cda_section.liquid b/config/templates/cda_fhir/cda_section.liquid index f9b2be55..3e35256c 100644 --- a/config/templates/cda_fhir/cda_section.liquid +++ b/config/templates/cda_fhir/cda_section.liquid @@ -1,15 +1,15 @@ { "section": { "templateId": { - "@root": "{{ config.template_id }}" + "@root": "{{ config.identifiers.template_id }}" }, "code": { - "@code": "{{ config.code }}", - "@codeSystem": "{{ config.code_system | default: '2.16.840.1.113883.6.1' }}", - "@codeSystemName": "{{config.code_system_name | default: 'LOINC' }}", - "@displayName": "{{ config.display }}" + "@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.display }}", + "title": "{{ config.identifiers.display }}", "entry": {{ entries | json }} } } diff --git a/config/templates/cda_fhir/condition.liquid b/config/templates/cda_fhir/condition.liquid index 61ba7cae..f00d475a 100644 --- a/config/templates/cda_fhir/condition.liquid +++ b/config/templates/cda_fhir/condition.liquid @@ -5,7 +5,7 @@ {% else %} {% assign actEntryRelationship = entry.act.entryRelationship %} {% endif %} - {% if actEntryRelationship.observation.entryRelationship.observation.code['@code'] == config.clinical_status_observation.code %} + {% if actEntryRelationship.observation.entryRelationship.observation.code['@code'] == config.identifiers.clinical_status.code %} {% if actEntryRelationship.observation.entryRelationship.observation.value %} "clinicalStatus": { "coding": [ diff --git a/healthchain/config_manager.py b/healthchain/config_manager.py index 605709e0..64ef3c79 100644 --- a/healthchain/config_manager.py +++ b/healthchain/config_manager.py @@ -25,8 +25,6 @@ class ConfigManager: "resource", "resource_template", "entry_template", - "template_id", - "code", } REQUIRED_DOCUMENT_KEYS = { diff --git a/healthchain/interop/filters.py b/healthchain/interop/filters.py index 8fde0db1..ff480fe1 100644 --- a/healthchain/interop/filters.py +++ b/healthchain/interop/filters.py @@ -289,9 +289,9 @@ def extract_clinical_status(observation: Dict, config: Dict) -> Optional[str]: # Check each template ID for template in _get_template_ids(rel["observation"]): - if template.get("@root") == config.get("clinical_status", {}).get( - "template_id" - ): + 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"] @@ -320,7 +320,9 @@ def extract_reactions(observation: Dict, config: Dict) -> List[Dict]: # Look for reaction template ID for template in _get_template_ids(rel["observation"]): - if template.get("@root") == config.get("reaction", {}).get("template_id"): + if template.get("@root") == config.get("identifiers", {}).get( + "reaction", {} + ).get("template_id"): # Found a reaction observation reaction = {} @@ -341,11 +343,10 @@ def extract_reactions(observation: Dict, config: Dict) -> List[Dict]: continue # Look for severity template ID - # This should match config.severity_observation.template_id in liquid template for sev_template in _get_template_ids(sev["observation"]): if sev_template.get("@root") == config.get( - "severity", {} - ).get("template_id"): + "identifiers", {} + ).get("severity", {}).get("template_id"): if ( sev.get("observation", {}) .get("value", {}) diff --git a/healthchain/interop/migration.py b/healthchain/interop/migration.py deleted file mode 100644 index 68fbeec9..00000000 --- a/healthchain/interop/migration.py +++ /dev/null @@ -1,19 +0,0 @@ -from typing import Dict, List -from pathlib import Path -from healthchain.cda_parser.cdaannotator import CdaAnnotator -from healthchain.interop.engine import InteropEngine - - -class LegacyMigrator: - """Helper class to migrate from legacy CdaAnnotator to new InteropEngine""" - - def __init__(self, config_dir: Path): - self.engine = InteropEngine(config_dir) - - def migrate_document(self, cda_annotator: CdaAnnotator) -> List[Dict]: - """Convert CdaAnnotator document to FHIR resources using new engine""" - # Export CDA XML from annotator - cda_xml = cda_annotator.export() - - # Use new engine to convert - return self.engine.to_fhir(cda_xml, "CDA") diff --git a/healthchain/interop/parsers/cda.py b/healthchain/interop/parsers/cda.py index 2860a00a..99fbd33b 100644 --- a/healthchain/interop/parsers/cda.py +++ b/healthchain/interop/parsers/cda.py @@ -92,10 +92,10 @@ def _parse_section_entries_from_document(self, section_key: str) -> List[Dict]: # Get template_id and code from config_manager template_id = self.config_manager.get_config_value( - f"sections.{section_key}.template_id", None + f"sections.{section_key}.identifiers.template_id", None ) code = self.config_manager.get_config_value( - f"sections.{section_key}.code", None + f"sections.{section_key}.identifiers.code", None ) if template_id and self._find_section_by_template_id( From 05b9fe7eb788796159e9611397a799e09e57baee Mon Sep 17 00:00:00 2001 From: jenniferjiangkells Date: Tue, 25 Mar 2025 18:28:06 +0000 Subject: [PATCH 14/25] Added config pydantic validation --- config/interop/sections/allergies.yaml | 8 +- config/interop/sections/medications.yaml | 13 +- config/interop/sections/problems.yaml | 5 +- .../cda_fhir/cda_allergy_entry.liquid | 38 +-- .../cda_fhir/cda_medication_entry.liquid | 4 +- .../cda_fhir/cda_problem_entry.liquid | 31 +- healthchain/config/__init__.py | 14 + healthchain/config/validators.py | 298 ++++++++++++++++++ healthchain/config_manager.py | 225 ++++++------- healthchain/interop/__init__.py | 27 ++ healthchain/interop/engine.py | 162 +++------- healthchain/interop/generators/cda.py | 28 +- healthchain/interop/generators/fhir.py | 34 +- healthchain/interop/template_registry.py | 29 +- healthchain/interop/template_renderer.py | 36 ++- 15 files changed, 588 insertions(+), 364 deletions(-) create mode 100644 healthchain/config/__init__.py create mode 100644 healthchain/config/validators.py diff --git a/config/interop/sections/allergies.yaml b/config/interop/sections/allergies.yaml index 83ca67f3..0747d638 100644 --- a/config/interop/sections/allergies.yaml +++ b/config/interop/sections/allergies.yaml @@ -22,7 +22,7 @@ identifiers: template: # Act element configuration act: - template_ids: + 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" @@ -33,7 +33,7 @@ template: allergy_obs: type_code: "SUBJ" inversion_ind: false - template_ids: + 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" @@ -47,7 +47,7 @@ template: # Reaction observation configuration reaction_obs: - template_ids: + 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" @@ -55,7 +55,7 @@ template: # Severity observation configuration severity_obs: - template_ids: + 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" diff --git a/config/interop/sections/medications.yaml b/config/interop/sections/medications.yaml index ed9021e3..2b5df936 100644 --- a/config/interop/sections/medications.yaml +++ b/config/interop/sections/medications.yaml @@ -21,7 +21,7 @@ identifiers: template: # Substance administration configuration substance_admin: - template_ids: + 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" @@ -31,7 +31,7 @@ template: # Manufactured product configuration manufactured_product: - template_ids: + 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" @@ -41,11 +41,10 @@ template: clinical_status_obs: template_id: "2.16.840.1.113883.10.20.1.47" status_code: "completed" - code: - code: "33999-4" - code_system: "2.16.840.1.113883.6.1" - code_system_name: "LOINC" - display_name: "Status" + 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" diff --git a/config/interop/sections/problems.yaml b/config/interop/sections/problems.yaml index 12782fdf..0ad4b5c0 100644 --- a/config/interop/sections/problems.yaml +++ b/config/interop/sections/problems.yaml @@ -21,7 +21,7 @@ identifiers: template: # Act element configuration act: - template_ids: + 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" @@ -33,7 +33,7 @@ template: problem_obs: type_code: "SUBJ" inversion_ind: false - template_ids: + 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" @@ -44,6 +44,7 @@ template: # 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" diff --git a/config/templates/cda_fhir/cda_allergy_entry.liquid b/config/templates/cda_fhir/cda_allergy_entry.liquid index e4b9fd8b..776182c7 100644 --- a/config/templates/cda_fhir/cda_allergy_entry.liquid +++ b/config/templates/cda_fhir/cda_allergy_entry.liquid @@ -3,26 +3,26 @@ "@classCode": "ACT", "@moodCode": "EVN", "templateId": [ - {% for template_id in config.template.act.template_ids %} + {% for template_id in config.template.act.template_id %} {"@root": "{{template_id}}"} {% if forloop.last != true %},{% endif %} {% endfor %} ], "id": {"@root": "{{ resource.id | generate_id }}"}, "code": {"@nullFlavor": "NA"}, "statusCode": { - "@code": "{{ config.template.act.status_code | default: config.defaults.status_code }}" + "@code": "{{ config.template.act.status_code }}" }, "effectiveTime": { "low": {"@value": "{{ timestamp }}"} }, "entryRelationship": { - "@typeCode": "{{ config.template.allergy_obs.type_code | default: config.defaults.type_code }}", - "@inversionInd": {{ config.template.allergy_obs.inversion_ind | default: config.defaults.inversion_ind }}, + "@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_ids %} + {% for template_id in config.template.allergy_obs.template_id %} {"@root": "{{template_id}}"} {% if forloop.last != true %},{% endif %} {% endfor %} ], @@ -30,7 +30,7 @@ "text": { "reference": {"@value": "{{ text_reference_name }}"} }, - "statusCode": {"@code": "{{ config.template.allergy_obs.status_code | default: 'completed' }}"}, + "statusCode": {"@code": "{{ config.template.allergy_obs.status_code }}"}, "effectiveTime": { "low": {"@value": "{{ timestamp }}"} }, @@ -42,10 +42,10 @@ }, {% else %} "code": { - "@code": "{{ config.template.allergy_obs.code | default: config.defaults.allergy_code }}", - "@codeSystem": "{{ config.template.allergy_obs.code_system | default: config.defaults.allergy_code_system }}", - "@codeSystemName": "{{ config.template.allergy_obs.code_system_name | default: config.defaults.allergy_code_system_name }}", - "@displayName": "{{ config.template.allergy_obs.display_name | default: config.defaults.allergy_display_name }}" + "@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": { @@ -82,16 +82,16 @@ "@classCode": "OBS", "@moodCode": "EVN", "templateId": [ - {% for template_id in config.template.reaction_obs.template_ids %} + {% 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 | default: 'RXNASSESS' }}"}, + "code": {"@code": "{{ config.template.reaction_obs.code }}"}, "text": { "reference": {"@value": "{{ text_reference_name }}reaction"} }, - "statusCode": {"@code": "{{ config.template.reaction_obs.status_code | default: 'completed' }}"}, + "statusCode": {"@code": "{{ config.template.reaction_obs.status_code }}"}, "effectiveTime": { "low": {"@value": "{{ timestamp }}"} }, @@ -111,20 +111,20 @@ "@classCode": "OBS", "@moodCode": "EVN", "templateId": [ - {% for template_id in config.template.severity_obs.template_ids %} + {% 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 | default: 'SEV' }}", - "@codeSystem": "{{ config.template.severity_obs.code_system | default: '2.16.840.1.113883.5.4' }}", - "@codeSystemName": "{{ config.template.severity_obs.code_system_name | default: 'ActCode' }}", - "@displayName": "{{ config.template.severity_obs.display_name | default: 'Severity' }}" + "@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 | default: 'completed' }}"}, + "statusCode": {"@code": "{{ config.template.severity_obs.status_code }}"}, "value": { "@xmlns:xsi": "http://www.w3.org/2001/XMLSchema-instance", "@xsi:type": "CD", diff --git a/config/templates/cda_fhir/cda_medication_entry.liquid b/config/templates/cda_fhir/cda_medication_entry.liquid index c2c10642..bb611306 100644 --- a/config/templates/cda_fhir/cda_medication_entry.liquid +++ b/config/templates/cda_fhir/cda_medication_entry.liquid @@ -3,7 +3,7 @@ "@classCode": "SBADM", "@moodCode": "INT", "templateId": [ - {% for template_id in config.template.substance_admin.template_ids %} + {% for template_id in config.template.substance_admin.template_id %} {"@root": "{{template_id}}"} {% if forloop.last != true %},{% endif %} {% endfor %} ], @@ -63,7 +63,7 @@ "manufacturedProduct": { "@classCode": "MANU", "templateId": [ - {% for template_id in config.template.manufactured_product.template_ids %} + {% for template_id in config.template.manufactured_product.template_id %} {"@root": "{{template_id}}"} {% if forloop.last != true %},{% endif %} {% endfor %} ], diff --git a/config/templates/cda_fhir/cda_problem_entry.liquid b/config/templates/cda_fhir/cda_problem_entry.liquid index 4a1e7921..a14b6139 100644 --- a/config/templates/cda_fhir/cda_problem_entry.liquid +++ b/config/templates/cda_fhir/cda_problem_entry.liquid @@ -3,40 +3,40 @@ "@classCode": "ACT", "@moodCode": "EVN", "templateId": [ - {% for template_id in config.template.act.template_ids %} + {% for template_id in config.template.act.template_id %} {"@root": "{{template_id}}"} {% if forloop.last != true %},{% endif %} {% endfor %} ], "id": {"@root": "{{ resource.id | generate_id }}"}, "code": {"@nullFlavor": "NA"}, "statusCode": { - "@code": "{{ config.template.act.status_code | default: config.defaults.status_code }}" + "@code": "{{ config.template.act.status_code }}" }, "effectiveTime": { "low": {"@value": "{{ timestamp }}"} }, "entryRelationship": { - "@typeCode": "{{ config.template.problem_obs.type_code | default: config.defaults.type_code }}", - "@inversionInd": {{ config.template.problem_obs.inversion_ind | default: config.defaults.inversion_ind }}, + "@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_ids %} + {% for template_id in config.template.problem_obs.template_id %} {"@root": "{{template_id}}"} {% if forloop.last != true %},{% endif %} {% endfor %} ], "id": {"@root": "{{ resource.id }}_obs"}, "code": { - "@code": "{{ config.template.problem_obs.code | default: config.defaults.problem_code }}", - "@codeSystem": "{{ config.template.problem_obs.code_system | default: config.defaults.problem_code_system }}", - "@codeSystemName": "{{ config.template.problem_obs.code_system_name | default: config.defaults.problem_code_system_name }}", - "@displayName": "{{ config.template.problem_obs.display_name | default: config.defaults.problem_display_name }}" + "@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 | default: 'completed' }}"}, + "statusCode": {"@code": "{{ config.template.problem_obs.status_code }}"}, "effectiveTime": { {% if resource.onsetDateTime %} "low": {"@value": "{{ resource.onsetDateTime }}"} @@ -60,11 +60,12 @@ "observation": { "@classCode": "OBS", "@moodCode": "EVN", + "templateId": {"@root": "{{ config.template.clinical_status_obs.template_id }}"}, "code": { - "@code": "{{ config.template.clinical_status_obs.code | default: '33999-4' }}", - "@codeSystem": "{{ config.template.clinical_status_obs.code_system | default: '2.16.840.1.113883.6.1' }}", - "@codeSystemName": "{{config.template.clinical_status_obs.code_system_name | default: 'LOINC'}}", - "@displayName": "{{ config.template.clinical_status_obs.display_name | default: 'Status' }}" + "@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", @@ -73,7 +74,7 @@ "@displayName": "{{ resource.clinicalStatus.coding[0].display }}", "@xsi:type": "CE" }, - "statusCode": {"@code": "{{ config.template.clinical_status_obs.status_code | default: 'completed' }}"}, + "statusCode": {"@code": "{{ config.template.clinical_status_obs.status_code }}"}, "effectiveTime": { "low": {"@value": "{{ timestamp }}"} } diff --git a/healthchain/config/__init__.py b/healthchain/config/__init__.py new file mode 100644 index 00000000..e0bca173 --- /dev/null +++ b/healthchain/config/__init__.py @@ -0,0 +1,14 @@ +from healthchain.config.validators import ( + validate_section_config, + register_template_model, + SECTION_VALIDATORS, +) +from pydantic import ValidationError + + +__all__ = [ + "validate_section_config", + "register_template_model", + "SECTION_VALIDATORS", + "ValidationError", +] diff --git a/healthchain/config/validators.py b/healthchain/config/validators.py new file mode 100644 index 00000000..2069c6d1 --- /dev/null +++ b/healthchain/config/validators.py @@ -0,0 +1,298 @@ +""" +Configuration validators for HealthChain + +This module provides validation models and utilities for configuration files. +""" + +import logging +from pydantic import BaseModel, ValidationError, field_validator +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 + + class Config: + 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 + + class Config: + extra = "allow" + + +class RenderingConfig(BaseModel): + """Configuration for section rendering""" + + narrative: Optional[Dict[str, Any]] = None + entry: Optional[Dict[str, Any]] = None + + class Config: + 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 + + class Config: + extra = "allow" + + +# +# Resource-Specific Template Models +# + + +class ConditionTemplateConfig(BaseModel): + """Template configuration for Condition resource""" + + 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 MedicationTemplateConfig(BaseModel): + """Template configuration for MedicationStatement resource""" + + 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 AllergyTemplateConfig(BaseModel): + """Template configuration for AllergyIntolerance resource""" + + 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 DocumentConfig(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 + + class Config: + extra = "allow" + + +# +# Registries and Factory Functions +# + +TEMPLATE_REGISTRY = { + "Condition": ConditionTemplateConfig, + "MedicationStatement": MedicationTemplateConfig, + "AllergyIntolerance": AllergyTemplateConfig, +} + +DOCUMENT_REGISTRY = { + "ccd": DocumentConfig, +} + + +def create_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_section_validator(resource_type, template_model) + for resource_type, template_model in TEMPLATE_REGISTRY.items() +} + +# +# Validation Functions +# + + +def validate_section_config(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: + logger.warning(f"No specific validator for resource type: {resource_type}") + return True + + try: + validator(**section_config) + return True + except ValidationError as e: + error_messages = [] + for error in e.errors(): + location = ".".join(str(loc) for loc in error["loc"]) + message = error["msg"] + error_messages.append(f" - {location}: {message}") + + error_str = f"Validation failed for section '{section_key}':\n" + "\n".join( + error_messages + ) + logger.error(error_str) + return False + + +def validate_document_config( + document_type: str, document_config: Dict[str, Any] +) -> bool: + """Validate a document configuration""" + validator = DOCUMENT_REGISTRY.get(document_type.lower(), DocumentConfig) + + try: + validator(**document_config) + return True + except ValidationError as e: + error_messages = [] + for error in e.errors(): + location = ".".join(str(loc) for loc in error["loc"]) + message = error["msg"] + error_messages.append(f" - {location}: {message}") + + error_str = ( + f"Validation failed for document type '{document_type}':\n" + + "\n".join(error_messages) + ) + logger.error(error_str) + return False + + +# +# Registration Functions +# + + +def register_template_model( + resource_type: str, template_model: Type[BaseModel] +) -> None: + """Register a custom template model for a resource type""" + TEMPLATE_REGISTRY[resource_type] = template_model + SECTION_VALIDATORS[resource_type] = create_section_validator( + resource_type, template_model + ) + logger.info(f"Registered custom template model for {resource_type}") + + +def register_document_model( + document_type: str, document_model: Type[BaseModel] +) -> None: + """Register a custom document model""" + DOCUMENT_REGISTRY[document_type.lower()] = document_model + logger.info(f"Registered custom document model for {document_type}") diff --git a/healthchain/config_manager.py b/healthchain/config_manager.py index 64ef3c79..92d072f6 100644 --- a/healthchain/config_manager.py +++ b/healthchain/config_manager.py @@ -2,7 +2,13 @@ import logging import os from pathlib import Path -from typing import Dict, Any, Set, Optional, List +from typing import Dict, Any, Optional, List +from healthchain.config.validators import ( + validate_section_config, + register_template_model, + validate_document_config, + register_document_model, +) log = logging.getLogger(__name__) @@ -18,21 +24,6 @@ class ValidationLevel: class ConfigManager: """Manages loading and accessing configuration files for the HealthChain project""" - # TODO: Use Pydantic to validate config files - - # Define required configuration schemas - REQUIRED_SECTION_KEYS = { - "resource", - "resource_template", - "entry_template", - } - - REQUIRED_DOCUMENT_KEYS = { - "type_id", - "code", - "confidentiality_code", - } - def __init__( self, config_dir: Path, @@ -55,7 +46,6 @@ def __init__( self._module_env_configs = {} self._loaded = False self._validation_level = validation_level - self._custom_schemas = {} self._environment = self._detect_environment() self._module = module @@ -346,25 +336,6 @@ def get_configs(self) -> Dict: return merged_configs - def get_module_configs(self, module: Optional[str] = None) -> Dict: - """Get module-specific configurations - - Args: - module: Module name to get configs for (defaults to initialized module) - - Returns: - Dict of module-specific configurations - """ - if not self._loaded: - self.load() - - target_module = module or self._module - if not target_module: - return {} - - # Return empty dict if module configs don't exist - return self._module_configs.get(target_module, {}) - def get_defaults(self) -> Dict: """Get all default values @@ -411,26 +382,54 @@ def set_environment(self, environment: str) -> "ConfigManager": return self - def get_section_configs(self) -> Dict: + def _find_module_sections(self) -> Dict: + """Find section configs in the module configs + + Returns: + Dict of sections, or empty dict if none found + """ + if not self._module or self._module not in self._module_configs: + return {} + + # Look for sections directly in module configs + if "sections" in self._module_configs[self._module]: + return self._module_configs[self._module]["sections"] + + # Look in subdirectories + for value in self._module_configs[self._module].values(): + if isinstance(value, dict) and "sections" in value: + return value["sections"] + + return {} + + def get_section_configs(self, validate: bool = False) -> Dict: """Get section configurations + Args: + validate: Whether to validate the configurations + Returns: Dict of section configurations """ - # If we're using a module (like "interop"), look for sections in that module's config - if self._module and self._module in self._module_configs: - # First try to find sections directly in the module configs - module_sections = self._module_configs[self._module].get("sections", {}) - if module_sections: - return module_sections + sections = self._find_module_sections() - # If no sections found directly, try to find it in a child directory - for value in self._module_configs[self._module].values(): - if isinstance(value, dict) and "sections" in value: - return value["sections"] + if not sections: + log.warning("No section configs found") + return {} - log.warning("No section configs found") - return {} + if not validate: + return sections + + # Validate each section if requested + validated_sections = {} + for section_key, section_config in sections.items(): + if self.validate_section_config(section_key, section_config): + validated_sections[section_key] = section_config + elif self._validation_level != ValidationLevel.STRICT: + # Include the section with warnings if not in STRICT mode + validated_sections[section_key] = section_config + + return validated_sections def get_document_config(self, document_type: str) -> Dict: """Get document configuration @@ -438,27 +437,26 @@ def get_document_config(self, document_type: str) -> Dict: Returns: Document configuration dict """ - # If we're using a module (like "interop"), look for document config in that module's config - if self._module and self._module in self._module_configs: - # First try to find document configs directly in the module configs - module_document = ( - self._module_configs[self._module] - .get("document", {}) - .get(document_type, {}) - ) - if module_document: - return module_document - - # If no document config found directly, try to find it in a child directory - for value in self._module_configs[self._module].values(): - if isinstance(value, dict) and "document" in value: - if ( - isinstance(value["document"], dict) - and "cda" in value["document"] - ): - return value["document"]["cda"] - - log.warning("No document config found") + if not self._module or self._module not in self._module_configs: + log.warning("No document config found") + return {} + + # Look for document config directly + if "document" in self._module_configs[self._module]: + doc_section = self._module_configs[self._module]["document"] + if isinstance(doc_section, dict) and document_type in doc_section: + return doc_section[document_type] + + # Look in subdirectories + for value in self._module_configs[self._module].values(): + if isinstance(value, dict) and "document" in value: + if ( + isinstance(value["document"], dict) + and document_type in value["document"] + ): + return value["document"][document_type] + + log.warning(f"No document config found for type: {document_type}") return {} def get_config_value(self, path: str, default: Any = None) -> Any: @@ -508,17 +506,8 @@ def _get_nested_value(self, data: Dict, parts: List[str]) -> Any: return current - def register_schema(self, config_type: str, required_keys: Set[str]) -> None: - """Register a custom validation schema - - Args: - config_type: Type of configuration (e.g., "section", "document") - required_keys: Set of required keys for this configuration type - """ - self._custom_schemas[config_type] = required_keys - def validate_document_config(self, document_type: str) -> bool: - """Validate document configuration + """Validate document configuration using Pydantic models Args: document_type: Type of document to validate @@ -533,14 +522,25 @@ def validate_document_config(self, document_type: str) -> bool: ) return False - # Validate document config - missing_keys = self.REQUIRED_DOCUMENT_KEYS - set(document_config.keys()) - if missing_keys: - self._handle_validation_error( - f"Document config for document type '{document_type}' is missing required keys: {missing_keys}" - ) + # Validate using Pydantic models + result = validate_document_config(document_type, document_config) + if not result and self._validation_level == ValidationLevel.STRICT: return False + return True + def validate_section_config(self, section_key: str, section_config: Dict) -> bool: + """Validate a section configuration using Pydantic models + + Args: + section_key: Name of the section + section_config: Section configuration dict + + Returns: + True if valid, False otherwise + """ + result = validate_section_config(section_key, section_config) + if not result and self._validation_level == ValidationLevel.STRICT: + return False return True def validate(self) -> bool: @@ -548,42 +548,9 @@ def validate(self) -> bool: is_valid = True # Validate section configs - section_configs = self.get_section_configs() + section_configs = self.get_section_configs(validate=True) if not section_configs: is_valid = self._handle_validation_error("No section configs found") - else: - # Validate each section - for section_key, section_config in section_configs.items(): - missing_keys = self.REQUIRED_SECTION_KEYS - set(section_config.keys()) - if missing_keys: - is_valid = self._handle_validation_error( - f"Section '{section_key}' is missing required keys: {missing_keys}" - ) - - # Validate custom schemas - for config_type, required_keys in self._custom_schemas.items(): - config = self.get_configs().get(config_type, {}) - if not config: - is_valid = self._handle_validation_error( - f"No {config_type} config found" - ) - continue - - # If config is a dict of dicts (like sections), validate each sub-config - if all(isinstance(v, dict) for v in config.values()): - for key, sub_config in config.items(): - missing_keys = required_keys - set(sub_config.keys()) - if missing_keys: - is_valid = self._handle_validation_error( - f"{config_type.capitalize()} '{key}' is missing required keys: {missing_keys}" - ) - else: - # Validate the config directly - missing_keys = required_keys - set(config.keys()) - if missing_keys: - is_valid = self._handle_validation_error( - f"{config_type.capitalize()} config is missing required keys: {missing_keys}" - ) return is_valid @@ -621,3 +588,21 @@ def set_validation_level(self, level: str) -> "ConfigManager": self._validation_level = level return self + + def register_template_model(self, resource_type: str, template_model) -> None: + """Register a custom template model + + Args: + resource_type: FHIR resource type + template_model: Pydantic model for template validation + """ + register_template_model(resource_type, template_model) + + def register_document_model(self, document_type: str, document_model) -> None: + """Register a custom document model + + Args: + document_type: Document type (e.g., "ccd", "discharge") + document_model: Pydantic model for document validation + """ + register_document_model(document_type, document_model) diff --git a/healthchain/interop/__init__.py b/healthchain/interop/__init__.py index 33fa1760..37dddf7d 100644 --- a/healthchain/interop/__init__.py +++ b/healthchain/interop/__init__.py @@ -13,6 +13,32 @@ from .generators.fhir import FHIRGenerator from .generators.hl7v2 import HL7v2Generator +import logging +from pathlib import Path +from typing import Optional + + +def create_engine( + config_dir: Optional[Path] = None, validation_level: str = "strict" +) -> InteropEngine: + """Create and initialize an InteropEngine instance + + Args: + config_dir: Base directory containing configuration files + validation_level: Level of configuration validation (strict, warn, ignore) + + Returns: + Initialized InteropEngine + """ + engine = InteropEngine(config_dir, validation_level) + + # Add a debug message to verify document validation is available + logger = logging.getLogger(__name__) + logger.debug("InteropEngine created with document validation support") + + return engine + + __all__ = [ "InteropEngine", "FormatType", @@ -23,4 +49,5 @@ "CDAGenerator", "FHIRGenerator", "HL7v2Generator", + "create_engine", ] diff --git a/healthchain/interop/engine.py b/healthchain/interop/engine.py index 8f6cc495..d7d663b9 100644 --- a/healthchain/interop/engine.py +++ b/healthchain/interop/engine.py @@ -2,13 +2,14 @@ from functools import cached_property from enum import Enum -from typing import Dict, List, Union, Optional, Callable, Any, Set +from typing import Dict, List, Union, Optional, Callable from pathlib import Path from fhir.resources.resource import Resource from fhir.resources.bundle import Bundle from healthchain.config_manager import ConfigManager, ValidationLevel +from healthchain.config.validators import register_template_model from healthchain.interop.parsers.cda import CDAParser from healthchain.interop.parsers.hl7v2 import HL7v2Parser @@ -52,7 +53,23 @@ def validate_format(format_type: Union[str, FormatType]) -> FormatType: class InteropEngine: - """Generic interoperability engine for converting between healthcare formats""" + """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. + + 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. + + Example: + engine = InteropEngine() + # Access config directly: + engine.config.set_environment("production") + engine.config.set_validation_level("warn") + value = engine.config.get_config_value("section.problems.resource") + """ def __init__( self, @@ -68,14 +85,11 @@ def __init__( environment: Optional environment to use (development, testing, production) """ # Initialize configuration manager - self.config_dir = config_dir - self.config_manager = ConfigManager( - self.config_dir, validation_level, module="interop" - ) - self.config_manager.load(environment) + self.config = ConfigManager(config_dir, validation_level, module="interop") + self.config.load(environment) # Initialize template registry - template_dir = self.config_dir / "templates" + template_dir = config_dir / "templates" self.template_registry = TemplateRegistry(template_dir) # Create and register default filters @@ -124,10 +138,10 @@ def _get_parser(self, format_type: FormatType): """ if format_type not in self._parsers: if format_type == FormatType.CDA: - parser = CDAParser(self.config_manager) + parser = CDAParser(self.config) self._parsers[format_type] = parser elif format_type == FormatType.HL7V2: - parser = HL7v2Parser(self.config_manager) + parser = HL7v2Parser(self.config) self._parsers[format_type] = parser else: raise ValueError(f"Unsupported parser format: {format_type}") @@ -145,13 +159,13 @@ def _get_generator(self, format_type: FormatType): """ if format_type not in self._generators: if format_type == FormatType.CDA: - generator = CDAGenerator(self.config_manager, self.template_registry) + generator = CDAGenerator(self.config, self.template_registry) self._generators[format_type] = generator elif format_type == FormatType.HL7V2: - generator = HL7v2Generator(self.config_manager, self.template_registry) + generator = HL7v2Generator(self.config, self.template_registry) self._generators[format_type] = generator elif format_type == FormatType.FHIR: - generator = FHIRGenerator(self.config_manager, self.template_registry) + generator = FHIRGenerator(self.config, self.template_registry) self._generators[format_type] = generator else: raise ValueError(f"Unsupported generator format: {format_type}") @@ -184,82 +198,6 @@ def register_generator(self, format_type: FormatType, generator_instance): self._generators[format_type] = generator_instance return self - def register_config_schema( - self, config_type: str, required_keys: Set[str] - ) -> "InteropEngine": - """Register a custom configuration schema for validation - - Args: - config_type: Type of configuration (e.g., "section", "document") - required_keys: Set of required keys for this configuration type - - Returns: - Self for method chaining - """ - self.config_manager.register_schema(config_type, required_keys) - return self - - def set_validation_level(self, level: str) -> "InteropEngine": - """Set the configuration validation level - - Args: - level: Validation level (strict, warn, ignore) - - Returns: - Self for method chaining - """ - self.config_manager.set_validation_level(level) - return self - - def get_environment(self) -> str: - """Get the current environment - - Returns: - String representing the current environment - """ - return self.config_manager.get_environment() - - def set_environment(self, environment: str) -> "InteropEngine": - """Set the environment and reload environment-specific configuration - - Args: - environment: Environment to set (development, testing, production) - - Returns: - Self for method chaining - """ - self.config_manager.set_environment(environment) - 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 (e.g., "section.problems.resource") - default: Default value if path not found - - Returns: - Configuration value or default - """ - return self.config_manager.get_config_value(path, default) - - def get_loaded_defaults(self) -> Dict: - """Get all loaded default values - - Returns: - Dictionary of default values loaded from defaults.yaml - """ - return self.config_manager.get_defaults() - - def is_defaults_loaded(self) -> bool: - """Check if the defaults.yaml file is loaded - - Returns: - True if defaults.yaml is loaded, False otherwise - """ - defaults = self.get_loaded_defaults() - return bool(defaults) - def _create_default_filters(self) -> Dict[str, Callable]: """Create and return default filter functions for templates @@ -267,7 +205,7 @@ def _create_default_filters(self) -> Dict[str, Callable]: Dict of filter names to filter functions """ # Get mappings for filter functions - mappings = self.config_manager.get_mappings() + mappings = self.config.get_mappings() # Create filter functions with access to mappings def map_system_filter(system, direction="fhir_to_cda"): @@ -322,50 +260,36 @@ def map_severity_filter(severity_code, direction="cda_to_fhir"): "map_severity": map_severity_filter, } - def add_filter(self, name: str, filter_func: Callable) -> "InteropEngine": - """Add a custom filter function to the template engine + def register_template_validator( + self, resource_type: str, template_model + ) -> "InteropEngine": + """Register a custom template validator model for a resource type Args: - name: Name of the filter to use in templates - filter_func: Filter function to register + resource_type: FHIR resource type (e.g., "Condition", "MedicationStatement") + template_model: Pydantic model for template validation Returns: Self for method chaining """ - self.template_registry.add_filter(name, filter_func) + register_template_model(resource_type, template_model) return self - def add_filters(self, filters: Dict[str, Callable]) -> "InteropEngine": - """Add multiple custom filter functions to the template engine + def register_document_validator( + self, document_type: str, document_model + ) -> "InteropEngine": + """Register a custom document validator model for a document type Args: - filters: Dictionary of filter names to filter functions + document_type: Document type (e.g., "ccd", "discharge") + document_model: Pydantic model for document validation Returns: Self for method chaining """ - self.template_registry.add_filters(filters) + self.config.register_document_model(document_type, document_model) return self - def get_filter(self, name: str) -> Optional[Callable]: - """Get a registered filter function by name - - Args: - name: Name of the filter - - Returns: - The filter function or None if not found - """ - return self.template_registry.get_filter(name) - - def get_filters(self) -> Dict[str, Callable]: - """Get all registered filter functions - - Returns: - Dictionary of filter names to filter functions - """ - return self.template_registry.get_filters() - def to_fhir( self, source_data: str, source_format: Union[str, FormatType] ) -> List[Resource]: @@ -451,7 +375,7 @@ def _fhir_to_cda( log.info(f"Processing CDA document of type: {document_type}") # Validate document configuration for this specific document type - self.config_manager.validate_document_config(document_type) + self.config.validate_document_config(document_type) # Normalize input to list of resources resource_list = normalize_resource_list(resources) diff --git a/healthchain/interop/generators/cda.py b/healthchain/interop/generators/cda.py index fa8c590f..c96c2782 100644 --- a/healthchain/interop/generators/cda.py +++ b/healthchain/interop/generators/cda.py @@ -62,17 +62,15 @@ def _render_entry( Dictionary representation of the rendered entry """ try: - # Get section configuration and create context - section_config = self.get_section_config(config_key) - if not section_config: - raise ValueError(f"No section configuration found for {config_key}") + # Get validated section configuration + section_config = self.get_validated_section_config(config_key) - timestamp_format = self.config_manager.get_config_value( + timestamp_format = self.config.get_config_value( "defaults.common.timestamp", "%Y%m%d" ) timestamp = datetime.now().strftime(format=timestamp_format) - id_format = self.config_manager.get_config_value( + id_format = self.config.get_config_value( "defaults.common.reference_name", "#{uuid}name" ) reference_name = id_format.replace("{uuid}", str(uuid.uuid4())[:8]) @@ -110,7 +108,7 @@ def _get_mapped_entries(self, resources: List[Resource]) -> Dict: for resource in resources: # Find matching section for resource type resource_type = resource.__class__.__name__ - all_configs = self.get_section_configs() + all_configs = self.config.get_section_configs(validate=True) section_key = find_section_key_for_resource_type(resource_type, all_configs) if not section_key: @@ -133,13 +131,13 @@ def _render_sections(self, mapped_entries: Dict) -> List[Dict]: """ sections = [] - # Get section configurations - section_configs = self.get_section_configs() + # Get validated section configurations + section_configs = self.config.get_section_configs(validate=True) if not section_configs: - raise ValueError("No configurations found in /sections") + raise ValueError("No valid configurations found in /sections") # Get section template name from config or use default - section_template_name = self.config_manager.get_config_value( + section_template_name = self.config.get_config_value( "document.cda.templates.section", "cda_section" ) # Get the section template @@ -179,14 +177,14 @@ def _render_document( Returns: CDA document as XML string """ - config = self.config_manager.get_document_config(document_type) + config = self.config.get_document_config(document_type) if not config: raise ValueError( f"No document configuration found in /document/{document_type}" ) # Get document template name from config or use default - document_template_name = self.config_manager.get_config_value( + document_template_name = self.config.get_config_value( "document.cda.templates.document", "cda_document" ) # Get the document template @@ -206,10 +204,10 @@ def _render_document( validated = ClinicalDocument(**rendered["ClinicalDocument"]) # Get XML formatting options - pretty_print = self.config_manager.get_config_value( + pretty_print = self.config.get_config_value( "document.cda.rendering.xml.pretty_print", True ) - encoding = self.config_manager.get_config_value( + encoding = self.config.get_config_value( "document.cda.rendering.xml.encoding", "UTF-8" ) if validate: diff --git a/healthchain/interop/generators/fhir.py b/healthchain/interop/generators/fhir.py index 242e76db..aa840270 100644 --- a/healthchain/interop/generators/fhir.py +++ b/healthchain/interop/generators/fhir.py @@ -39,7 +39,7 @@ def convert_cda_entries_to_resources( log.error(f"No resource template found for section {section_key}") return resources - resource_type = self.config_manager.get_config_value( + resource_type = self.config.get_config_value( f"sections.{section_key}.resource", None ) if not resource_type: @@ -71,16 +71,16 @@ def convert_cda_entries_to_resources( def _render_resource_from_entry( self, entry: Dict, section_key: str, template=None ) -> Optional[Dict]: - """ - Process an entry using a template and prepare it for FHIR conversion + """Render a FHIR resource from a CDA entry Args: - entry: The entry data dictionary - section_key: Key identifying the section - template: Optional template to use (if not provided, will be retrieved from section config) + entry: The CDA entry to convert + section_key: The section key (e.g., "problems") + template: Optional template to use for rendering + (if None, the template will be determined from section_key) Returns: - Dict: Processed resource dictionary ready for FHIR conversion + Optional[Dict]: FHIR resource dictionary or None if rendering failed """ try: # Get template if not provided @@ -90,8 +90,12 @@ def _render_resource_from_entry( log.error(f"No resource template found for section {section_key}") return None - # Get section configuration - section_config = self.get_section_config(section_key) + # Get validated section configuration + try: + section_config = self.get_validated_section_config(section_key) + except ValueError as e: + log.error(f"Failed to get section config: {str(e)}") + return None # Create context with entry data and config context = {"entry": entry, "config": section_config} @@ -138,14 +142,12 @@ def _add_required_fields(self, resource_dict: Dict, resource_type: str) -> Dict: Dict: The resource dictionary with required fields added """ # Add common fields - id_prefix = self.config_manager.get_config_value( - "defaults.common.id_prefix", "hc-" - ) + id_prefix = self.config.get_config_value("defaults.common.id_prefix", "hc-") if "id" not in resource_dict: resource_dict["id"] = f"{id_prefix}{str(uuid.uuid4())}" # Get default values from configuration if available - default_subject = self.config_manager.get_config_value( + default_subject = self.config.get_config_value( "defaults.common.subject", {"reference": "Patient/example"} ) @@ -155,7 +157,7 @@ def _add_required_fields(self, resource_dict: Dict, resource_type: str) -> Dict: resource_dict["subject"] = default_subject if "clinicalStatus" not in resource_dict: - default_status = self.config_manager.get_config_value( + default_status = self.config.get_config_value( "defaults.resources.Condition.clinicalStatus", { "coding": [ @@ -171,7 +173,7 @@ def _add_required_fields(self, resource_dict: Dict, resource_type: str) -> Dict: if "subject" not in resource_dict: resource_dict["subject"] = default_subject if "status" not in resource_dict: - default_status = self.config_manager.get_config_value( + default_status = self.config.get_config_value( "defaults.resources.MedicationStatement.status", "unknown" ) resource_dict["status"] = default_status @@ -179,7 +181,7 @@ def _add_required_fields(self, resource_dict: Dict, resource_type: str) -> Dict: if "patient" not in resource_dict: resource_dict["patient"] = default_subject if "clinicalStatus" not in resource_dict: - default_status = self.config_manager.get_config_value( + default_status = self.config.get_config_value( "defaults.resources.AllergyIntolerance.clinicalStatus", { "coding": [ diff --git a/healthchain/interop/template_registry.py b/healthchain/interop/template_registry.py index e87233cb..02dfd218 100644 --- a/healthchain/interop/template_registry.py +++ b/healthchain/interop/template_registry.py @@ -1,6 +1,6 @@ import logging from pathlib import Path -from typing import Dict, Callable, Optional +from typing import Dict, Callable from liquid import Environment, FileSystemLoader @@ -82,25 +82,6 @@ def add_filters(self, filters: Dict[str, Callable]) -> "TemplateRegistry": return self - def get_filter(self, name: str) -> Optional[Callable]: - """Get a registered filter function by name - - Args: - name: Name of the filter - - Returns: - The filter function or None if not found - """ - return self._filters.get(name) - - def get_filters(self) -> Dict[str, Callable]: - """Get all registered filter functions - - Returns: - Dictionary of filter names to filter functions - """ - return self._filters.copy() - def _load_templates(self) -> None: """Load all template files""" if not self._env: @@ -151,11 +132,3 @@ def has_template(self, template_key: str) -> bool: True if template exists, False otherwise """ return template_key in self._templates - - def get_all_templates(self) -> Dict: - """Get all templates - - Returns: - Dict of template keys to template objects - """ - return self._templates diff --git a/healthchain/interop/template_renderer.py b/healthchain/interop/template_renderer.py index b5d3a7e9..5bc26de4 100644 --- a/healthchain/interop/template_renderer.py +++ b/healthchain/interop/template_renderer.py @@ -19,16 +19,14 @@ class TemplateRenderer: """Base class for template rendering functionality""" - def __init__( - self, config_manager: ConfigManager, template_registry: TemplateRegistry - ): + def __init__(self, config: ConfigManager, template_registry: TemplateRegistry): """Initialize the template renderer Args: - config_manager: Configuration manager instance + config: Configuration manager instance template_registry: Template registry instance """ - self.config_manager = config_manager + self.config = config self.template_registry = template_registry def get_template(self, template_name: str): @@ -58,7 +56,7 @@ def get_section_template_name( Returns: Template name or None if not found """ - template_path = self.config_manager.get_config_value( + template_path = self.config.get_config_value( f"sections.{section_key}.{template_type}_template", "" ) @@ -110,21 +108,25 @@ def render_template(self, template, context: Dict[str, Any]) -> Optional[Dict]: log.error(f"Failed to render template {template.name}: {str(e)}") return None - def get_section_config(self, section_key: str) -> Dict: - """Get configuration for a specific section + def get_validated_section_config(self, section_key: str) -> Dict: + """Get a validated section configuration Args: section_key: Key identifying the section Returns: - Section configuration dictionary - """ - return self.config_manager.get_config_value(f"sections.{section_key}", {}) - - def get_section_configs(self) -> Dict: - """Get configurations for all sections + A validated section configuration - Returns: - Dictionary of section configurations + Raises: + ValueError: If the section configuration doesn't exist or is invalid """ - return self.config_manager.get_section_configs() + validated_sections = self.config.get_section_configs(validate=True) + if section_key not in validated_sections: + # Check if the section exists at all (might have failed validation) + all_sections = self.config.get_section_configs(validate=False) + if section_key not in all_sections: + raise ValueError(f"Section configuration not found: {section_key}") + else: + raise ValueError(f"Section configuration is invalid: {section_key}") + + return validated_sections[section_key] From 723f24c6f837f26f93e44af5f31f7a16524365e8 Mon Sep 17 00:00:00 2001 From: jenniferjiangkells Date: Fri, 28 Mar 2025 14:50:10 +0000 Subject: [PATCH 15/25] Refactor ConfigManager --- configs/defaults.yaml | 76 +++ configs/environments/development.yaml | 33 + configs/environments/production.yaml | 37 ++ configs/environments/testing.yaml | 39 ++ configs/interop/document/ccd.yaml | 80 +++ configs/interop/sections/allergies.yaml | 88 +++ configs/interop/sections/medications.yaml | 72 +++ configs/interop/sections/problems.yaml | 61 ++ configs/mappings/shared_mappings.yaml | 31 + .../cda_fhir/allergy_intolerance.liquid | 72 +++ .../cda_fhir/cda_allergy_entry.liquid | 145 +++++ .../templates/cda_fhir/cda_document.liquid | 41 ++ .../cda_fhir/cda_medication_entry.liquid | 109 ++++ .../cda_fhir/cda_problem_entry.liquid | 86 +++ configs/templates/cda_fhir/cda_section.liquid | 15 + configs/templates/cda_fhir/condition.liquid | 52 ++ .../cda_fhir/medication_statement.liquid | 74 +++ healthchain/__init__.py | 2 +- healthchain/config/__init__.py | 31 +- healthchain/config/base.py | 439 +++++++++++++ healthchain/config/validators.py | 69 +- healthchain/config_manager.py | 608 ------------------ healthchain/interop/__init__.py | 2 + healthchain/interop/config_manager.py | 185 ++++++ healthchain/interop/engine.py | 19 +- healthchain/interop/generators/cda.py | 4 +- healthchain/interop/generators/fhir.py | 28 +- healthchain/interop/parsers/cda.py | 2 +- healthchain/interop/template_renderer.py | 2 +- 29 files changed, 1808 insertions(+), 694 deletions(-) create mode 100644 configs/defaults.yaml create mode 100644 configs/environments/development.yaml create mode 100644 configs/environments/production.yaml create mode 100644 configs/environments/testing.yaml create mode 100644 configs/interop/document/ccd.yaml create mode 100644 configs/interop/sections/allergies.yaml create mode 100644 configs/interop/sections/medications.yaml create mode 100644 configs/interop/sections/problems.yaml create mode 100644 configs/mappings/shared_mappings.yaml create mode 100644 configs/templates/cda_fhir/allergy_intolerance.liquid create mode 100644 configs/templates/cda_fhir/cda_allergy_entry.liquid create mode 100644 configs/templates/cda_fhir/cda_document.liquid create mode 100644 configs/templates/cda_fhir/cda_medication_entry.liquid create mode 100644 configs/templates/cda_fhir/cda_problem_entry.liquid create mode 100644 configs/templates/cda_fhir/cda_section.liquid create mode 100644 configs/templates/cda_fhir/condition.liquid create mode 100644 configs/templates/cda_fhir/medication_statement.liquid create mode 100644 healthchain/config/base.py delete mode 100644 healthchain/config_manager.py create mode 100644 healthchain/interop/config_manager.py diff --git a/configs/defaults.yaml b/configs/defaults.yaml new file mode 100644 index 00000000..723be4f2 --- /dev/null +++ b/configs/defaults.yaml @@ -0,0 +1,76 @@ +# HealthChain Interoperability Engine Default Configuration +# This file contains default values used throughout the engine + +# Default resource fields +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" + verificationStatus: + coding: + - system: "http://terminology.hl7.org/CodeSystem/condition-ver-status" + code: "unconfirmed" + display: "Unconfirmed" + + MedicationStatement: + status: "unknown" + effectiveDateTime: "{{ now | date: '%Y-%m-%d' }}" + medicationCodeableConcept: + coding: + - system: "http://terminology.hl7.org/CodeSystem/v3-NullFlavor" + code: "UNK" + display: "Unknown" + + AllergyIntolerance: + clinicalStatus: + coding: + - system: "http://terminology.hl7.org/CodeSystem/allergyintolerance-clinical" + code: "unknown" + display: "Unknown" + verificationStatus: + coding: + - system: "http://terminology.hl7.org/CodeSystem/allergyintolerance-verification" + code: "unconfirmed" + display: "Unconfirmed" + type: "allergy" + criticality: "low" + +# # 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..a143a927 --- /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/dev-example" + +# 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/document/ccd.yaml b/configs/interop/document/ccd.yaml new file mode 100644 index 00000000..03bd745b --- /dev/null +++ b/configs/interop/document/ccd.yaml @@ -0,0 +1,80 @@ +# CDA Document Configuration +# This file contains configuration for CDA documents + +# 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" + +templates: + section: "cda_section" + entry: "cda_entry" + +# Document structure +structure: + # Header configuration + header: + include_patient: true + include_author: true + include_custodian: true + include_legal_authenticator: false + + # Body configuration + body: + structured_body: true + non_xml_body: false + +# Default values +defaults: + # Default patient information + patient: + id: "example" + id_root: "2.16.840.1.113883.19.5" + name: + given: "John" + family: "Doe" + gender: "M" + birth_date: "19700101" + + # Default author information + author: + id: "author1" + id_root: "2.16.840.1.113883.19.5" + name: + given: "Jane" + family: "Smith" + organization: + id: "org1" + name: "HealthChain Organization" + + # Default custodian information + custodian: + id: "custodian1" + id_root: "2.16.840.1.113883.19.5" + organization: + id: "org1" + name: "HealthChain Organization" + +# 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/sections/allergies.yaml b/configs/interop/sections/allergies.yaml new file mode 100644 index 00000000..6a09903a --- /dev/null +++ b/configs/interop/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: "cda_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/sections/medications.yaml b/configs/interop/sections/medications.yaml new file mode 100644 index 00000000..2b5df936 --- /dev/null +++ b/configs/interop/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: "cda_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/sections/problems.yaml b/configs/interop/sections/problems.yaml new file mode 100644 index 00000000..f3c8cafb --- /dev/null +++ b/configs/interop/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: "cda_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/shared_mappings.yaml b/configs/mappings/shared_mappings.yaml new file mode 100644 index 00000000..6eef2001 --- /dev/null +++ b/configs/mappings/shared_mappings.yaml @@ -0,0 +1,31 @@ +# Shared mappings between CDA and FHIR formats +code_systems: + cda_to_fhir: + "2.16.840.1.113883.6.96": "http://snomed.info/sct" + "2.16.840.1.113883.6.1": "http://loinc.org" + "2.16.840.1.113883.6.88": "http://www.nlm.nih.gov/research/umls/rxnorm" + fhir_to_cda: + "http://snomed.info/sct": "2.16.840.1.113883.6.96" + "http://loinc.org": "2.16.840.1.113883.6.1" + "http://www.nlm.nih.gov/research/umls/rxnorm": "2.16.840.1.113883.6.88" + "http://terminology.hl7.org/CodeSystem/condition-clinical": "2.16.840.1.113883.6.96" + +status_codes: + cda_to_fhir: + "55561003": "active" + "413322009": "resolved" + "73425007": "inactive" + fhir_to_cda: + "active": "55561003" + "resolved": "413322009" + "inactive": "73425007" + +severity_codes: + cda_to_fhir: + "H": "severe" + "M": "moderate" + "L": "mild" + fhir_to_cda: + "severe": "H" + "moderate": "M" + "mild": "L" diff --git a/configs/templates/cda_fhir/allergy_intolerance.liquid b/configs/templates/cda_fhir/allergy_intolerance.liquid new file mode 100644 index 00000000..543934d6 --- /dev/null +++ b/configs/templates/cda_fhir/allergy_intolerance.liquid @@ -0,0 +1,72 @@ +{ + "resourceType": "AllergyIntolerance", + "id": "{{ entry.id | generate_id }}", + {% 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 %} + {% if clinical_status %} + "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 %} + + "patient": { + "reference": "Patient/{{ entry.subject_id | default: 'Foo' }}" + }, + + {% if obs.effectiveTime.low['@value'] %} + "onsetDateTime": "{{ obs.effectiveTime.low['@value'] | format_date }}", + {% endif %} + {% assign reactions = obs | extract_reactions: config %} + {% 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/cda_allergy_entry.liquid b/configs/templates/cda_fhir/cda_allergy_entry.liquid new file mode 100644 index 00000000..776182c7 --- /dev/null +++ b/configs/templates/cda_fhir/cda_allergy_entry.liquid @@ -0,0 +1,145 @@ +{ + "act": { + "@classCode": "ACT", + "@moodCode": "EVN", + "templateId": [ + {% for template_id in config.template.act.template_id %} + {"@root": "{{template_id}}"} {% if forloop.last != true %},{% endif %} + {% endfor %} + ], + "id": {"@root": "{{ resource.id | generate_id }}"}, + "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 %} + ], + "id": {"@root": "{{ resource.id }}_obs"}, + "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.reaction %}, + "entryRelationship": { + "@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 %} + } + } + {% endif %} + } + } + } +} diff --git a/configs/templates/cda_fhir/cda_document.liquid b/configs/templates/cda_fhir/cda_document.liquid new file mode 100644 index 00000000..460d7196 --- /dev/null +++ b/configs/templates/cda_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/cda_fhir/cda_medication_entry.liquid b/configs/templates/cda_fhir/cda_medication_entry.liquid new file mode 100644 index 00000000..5d5e2bd8 --- /dev/null +++ b/configs/templates/cda_fhir/cda_medication_entry.liquid @@ -0,0 +1,109 @@ +{ + "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 %} + ], + "id": {"@root": "{{ resource.id | generate_id }}"}, + "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/cda_fhir/cda_problem_entry.liquid b/configs/templates/cda_fhir/cda_problem_entry.liquid new file mode 100644 index 00000000..a14b6139 --- /dev/null +++ b/configs/templates/cda_fhir/cda_problem_entry.liquid @@ -0,0 +1,86 @@ +{ + "act": { + "@classCode": "ACT", + "@moodCode": "EVN", + "templateId": [ + {% for template_id in config.template.act.template_id %} + {"@root": "{{template_id}}"} {% if forloop.last != true %},{% endif %} + {% endfor %} + ], + "id": {"@root": "{{ resource.id | generate_id }}"}, + "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 %} + ], + "id": {"@root": "{{ resource.id }}_obs"}, + "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/cda_fhir/cda_section.liquid b/configs/templates/cda_fhir/cda_section.liquid new file mode 100644 index 00000000..3e35256c --- /dev/null +++ b/configs/templates/cda_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/configs/templates/cda_fhir/condition.liquid b/configs/templates/cda_fhir/condition.liquid new file mode 100644 index 00000000..f00d475a --- /dev/null +++ b/configs/templates/cda_fhir/condition.liquid @@ -0,0 +1,52 @@ +{ + "resourceType": "Condition", + {% if entry.act.entryRelationship.is_array %} + {% assign actEntryRelationship = entry.act.entryRelationship[0] %} + {% else %} + {% assign actEntryRelationship = entry.act.entryRelationship %} + {% endif %} + {% if actEntryRelationship.observation.entryRelationship.observation.code['@code'] == config.identifiers.clinical_status.code %} + {% if actEntryRelationship.observation.entryRelationship.observation.value %} + "clinicalStatus": { + "coding": [ + { + "system": "http://terminology.hl7.org/CodeSystem/condition-clinical", + "code": "{{ actEntryRelationship.observation.entryRelationship.observation.value['@code'] | map_status: 'cda_to_fhir' }}" + }, + { + "system": "{{ actEntryRelationship.observation.entryRelationship.observation.value['@codeSystem'] | map_system: 'cda_to_fhir' }}", + "code": "{{ actEntryRelationship.observation.entryRelationship.observation.value['@code'] }}", + "display": "{{ actEntryRelationship.observation.entryRelationship.observation.value['@displayName'] }}" + } + ] + }, + {% endif %} + {% endif %} + "category": [{ + "coding": [{ + "system": "http://terminology.hl7.org/CodeSystem/condition-category", + "code": "problem-list-item", + "display": "Problem List Item" + }] + }], + {% if actEntryRelationship.observation.value %} + "code": { + "coding": [{ + "system": "{{ actEntryRelationship.observation.value['@codeSystem'] | map_system: 'cda_to_fhir' }}", + "code": "{{ actEntryRelationship.observation.value['@code'] }}", + "display": "{{ actEntryRelationship.observation.value['@displayName'] }}" + }] + }, + {% endif %} + {% if actEntryRelationship.observation.effectiveTime %} + {% if actEntryRelationship.observation.effectiveTime.low %} + "onsetDateTime": "{{ actEntryRelationship.observation.effectiveTime.low['@value'] | format_date }}", + {% endif %} + {% if actEntryRelationship.observation.effectiveTime.high %} + "abatementDateTime": "{{ actEntryRelationship.observation.effectiveTime.high['@value'] | format_date }}", + {% endif %} + {% endif %} + "subject": { + "reference": "Patient/{{ entry.subject_id | default: 'Foo' }}" + } +} diff --git a/configs/templates/cda_fhir/medication_statement.liquid b/configs/templates/cda_fhir/medication_statement.liquid new file mode 100644 index 00000000..1495540e --- /dev/null +++ b/configs/templates/cda_fhir/medication_statement.liquid @@ -0,0 +1,74 @@ +{ + "resourceType": "MedicationStatement", + "id": "{{ entry.id | generate_id }}", + "status": "{{ entry.statusCode | map_status: 'cda_to_fhir' | default: 'recorded' }}", + "medication": { + "concept": { + "coding": [{ + "system": "{{ entry.substanceAdministration.consumable.manufacturedProduct.manufacturedMaterial.code['@codeSystem'] | map_system: 'cda_to_fhir' }}", + "code": "{{ entry.substanceAdministration.consumable.manufacturedProduct.manufacturedMaterial.code['@code'] }}", + "display": "{{ entry.substanceAdministration.consumable.manufacturedProduct.manufacturedMaterial.code['@displayName'] }}" + }] + } + } + + {% comment %}Process effectiveTime and extract period/timing information if exists{% endcomment %} + {% if entry.substanceAdministration.effectiveTime %} + , + {% assign effective_period = entry.substanceAdministration.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 = entry.substanceAdministration.effectiveTime | extract_effective_timing %} + {% if entry.substanceAdministration.doseQuantity or entry.substanceAdministration.routeCode or effective_timing %},{% endif %} + {% endif %} + {% endif %} + + {% comment %}Add dosage if any dosage related fields are present{% endcomment %} + {% assign effective_timing = entry.substanceAdministration.effectiveTime | extract_effective_timing %} + {% if entry.substanceAdministration.doseQuantity or entry.substanceAdministration.routeCode or effective_timing %} + {% if entry.substanceAdministration.effectiveTime == nil %},{% endif %} + "dosage": [ + { + {% if entry.substanceAdministration.doseQuantity %} + "doseAndRate": [ + { + "doseQuantity": { + "value": {{ entry.substanceAdministration.doseQuantity['@value'] }}, + "unit": "{{ entry.substanceAdministration.doseQuantity['@unit'] }}" + } + } + ]{% if entry.substanceAdministration.routeCode or effective_timing %},{% endif %} + {% endif %} + + {% if entry.substanceAdministration.routeCode %} + "route": { + "coding": [ + { + "system": "{{ entry.substanceAdministration.routeCode['@codeSystem'] | map_system: 'cda_to_fhir' }}", + "code": "{{ entry.substanceAdministration.routeCode['@code'] }}", + "display": "{{ entry.substanceAdministration.routeCode['@displayName'] }}" + } + ] + }{% if effective_timing %},{% endif %} + {% endif %} + + {% if effective_timing %} + "timing": { + "repeat": { + "period": {{ effective_timing.period }}, + "periodUnit": "{{ effective_timing.periodUnit }}" + } + } + {% endif %} + } + ] + {% endif %} + + , + "subject": { + "reference": "Patient/{{ entry.subject_id | default: '123' }}" + } +} diff --git a/healthchain/__init__.py b/healthchain/__init__.py index 46e1586d..307be960 100644 --- a/healthchain/__init__.py +++ b/healthchain/__init__.py @@ -3,7 +3,7 @@ from .decorators import api, sandbox from .clients import ehr -from .config_manager import ConfigManager, ValidationLevel +from .config.base import ConfigManager, ValidationLevel logger = logging.getLogger(__name__) add_handlers(logger) diff --git a/healthchain/config/__init__.py b/healthchain/config/__init__.py index e0bca173..de5886f7 100644 --- a/healthchain/config/__init__.py +++ b/healthchain/config/__init__.py @@ -1,14 +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_section_config, - register_template_model, - SECTION_VALIDATORS, + validate_section_config_model, + validate_document_config_model, + register_template_config_model, + register_document_config_model, ) -from pydantic import ValidationError - __all__ = [ - "validate_section_config", - "register_template_model", - "SECTION_VALIDATORS", - "ValidationError", + "ConfigManager", + "ValidationLevel", + "validate_section_config_model", + "validate_document_config_model", + "register_template_config_model", + "register_document_config_model", ] diff --git a/healthchain/config/base.py b/healthchain/config/base.py new file mode 100644 index 00000000..ea12e2a5 --- /dev/null +++ b/healthchain/config/base.py @@ -0,0 +1,439 @@ +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""" + + 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) -> "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 ignored. + + Args: + environment: Optional environment name to override detected environment + + 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 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 + + # Load each YAML file in the mappings directory + for config_file in mappings_dir.rglob("*.yaml"): + try: + with open(config_file) as f: + self._mappings[config_file.stem] = yaml.safe_load(f) + log.debug(f"Loaded mapping file: {config_file}") + except Exception as e: + log.error(f"Failed to load mapping file {config_file}: {str(e)}") + + return self._mappings + + def _find_config_section( + self, module_name: str, section_name: str, subsection: Optional[str] = None + ) -> Dict: + """Find a configuration section in the module configs, + searching first at top-level then in subdirectories. + + Args: + module_name: Name of the module to search in (e.g. "interop") + section_name: Name of the section to find (e.g. "sections", "document") + subsection: Optional subsection key to look for within the section + + Returns: + Configuration dict or empty dict if not found. If subsection is specified, + returns that subsection's config or empty dict if not found. + """ + # Look directly in module configs + if section_name in self._module_configs[module_name]: + section = self._module_configs[module_name][section_name] + if isinstance(section, dict): + if subsection: + return section.get(subsection, {}) + return section + + # Look in subdirectories + for value in self._module_configs[module_name].values(): + if isinstance(value, dict) and section_name in value: + if isinstance(value[section_name], dict): + if subsection: + return value[section_name].get(subsection, {}) + return value[section_name] + + if subsection: + log.warning(f"No {section_name} config found for: {subsection}") + + return {} + + def get_mappings(self) -> Dict: + """Get all mappings, loading them first if needed + + Returns: + Dict of mappings + """ + return self._load_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 + three-layer precedence order: + + 1. Module-specific configs (highest priority, if a module is specified) + 2. Environment-specific configs (middle priority) + 3. 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 (highest priority) + if self._module and self._module in self._module_configs: + _deep_merge(merged_configs, self._module_configs[self._module]) + + return merged_configs + + 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 _handle_validation_error(self, message: str) -> bool: + """Handle validation error based on validation level + + Args: + message: Error message + + Returns: + False if in STRICT mode, True otherwise + """ + if self._validation_level == ValidationLevel.STRICT: + raise ValueError(message) + elif self._validation_level == ValidationLevel.WARN: + log.warning(f"Configuration validation: {message}") + + return self._validation_level != ValidationLevel.STRICT diff --git a/healthchain/config/validators.py b/healthchain/config/validators.py index 2069c6d1..c4ee4b6c 100644 --- a/healthchain/config/validators.py +++ b/healthchain/config/validators.py @@ -78,8 +78,8 @@ class Config: # -class ConditionTemplateConfig(BaseModel): - """Template configuration for Condition resource""" +class ProblemSectionTemplateConfig(BaseModel): + """Template configuration for Problem Section""" act: ComponentTemplateConfig problem_obs: ComponentTemplateConfig @@ -104,8 +104,8 @@ def validate_clinical_status(cls, v): return v -class MedicationTemplateConfig(BaseModel): - """Template configuration for MedicationStatement resource""" +class MedicationSectionTemplateConfig(BaseModel): + """Template configuration for SubstanceAdministration Section""" substance_admin: ComponentTemplateConfig manufactured_product: ComponentTemplateConfig @@ -119,8 +119,8 @@ def validate_substance_admin(cls, v): return v -class AllergyTemplateConfig(BaseModel): - """Template configuration for AllergyIntolerance resource""" +class AllergySectionTemplateConfig(BaseModel): + """Template configuration for Allergy Section""" act: ComponentTemplateConfig allergy_obs: ComponentTemplateConfig @@ -179,13 +179,13 @@ class Config: # Registries and Factory Functions # -TEMPLATE_REGISTRY = { - "Condition": ConditionTemplateConfig, - "MedicationStatement": MedicationTemplateConfig, - "AllergyIntolerance": AllergyTemplateConfig, +TEMPLATE_CONFIG_REGISTRY = { + "Condition": ProblemSectionTemplateConfig, + "MedicationStatement": MedicationSectionTemplateConfig, + "AllergyIntolerance": AllergySectionTemplateConfig, } -DOCUMENT_REGISTRY = { +DOCUMENT_CONFIG_REGISTRY = { "ccd": DocumentConfig, } @@ -213,7 +213,7 @@ def validate_template(cls, v): SECTION_VALIDATORS = { resource_type: create_section_validator(resource_type, template_model) - for resource_type, template_model in TEMPLATE_REGISTRY.items() + for resource_type, template_model in TEMPLATE_CONFIG_REGISTRY.items() } # @@ -221,7 +221,9 @@ def validate_template(cls, v): # -def validate_section_config(section_key: str, section_config: Dict[str, Any]) -> bool: +def validate_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: @@ -230,6 +232,7 @@ def validate_section_config(section_key: str, section_config: Dict[str, Any]) -> 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 @@ -237,40 +240,24 @@ def validate_section_config(section_key: str, section_config: Dict[str, Any]) -> validator(**section_config) return True except ValidationError as e: - error_messages = [] - for error in e.errors(): - location = ".".join(str(loc) for loc in error["loc"]) - message = error["msg"] - error_messages.append(f" - {location}: {message}") - - error_str = f"Validation failed for section '{section_key}':\n" + "\n".join( - error_messages - ) - logger.error(error_str) + logger.error(f"Section validation failed for {resource_type}: {str(e)}") return False -def validate_document_config( +def validate_document_config_model( document_type: str, document_config: Dict[str, Any] ) -> bool: """Validate a document configuration""" - validator = DOCUMENT_REGISTRY.get(document_type.lower(), DocumentConfig) + validator = 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: - error_messages = [] - for error in e.errors(): - location = ".".join(str(loc) for loc in error["loc"]) - message = error["msg"] - error_messages.append(f" - {location}: {message}") - - error_str = ( - f"Validation failed for document type '{document_type}':\n" - + "\n".join(error_messages) - ) - logger.error(error_str) + logger.error(f"Document validation failed for {document_type}: {str(e)}") return False @@ -279,20 +266,20 @@ def validate_document_config( # -def register_template_model( +def register_template_config_model( resource_type: str, template_model: Type[BaseModel] ) -> None: - """Register a custom template model for a resource type""" - TEMPLATE_REGISTRY[resource_type] = template_model + """Register a custom template model for a section""" + TEMPLATE_CONFIG_REGISTRY[resource_type] = template_model SECTION_VALIDATORS[resource_type] = create_section_validator( resource_type, template_model ) logger.info(f"Registered custom template model for {resource_type}") -def register_document_model( +def register_document_config_model( document_type: str, document_model: Type[BaseModel] ) -> None: """Register a custom document model""" - DOCUMENT_REGISTRY[document_type.lower()] = document_model + DOCUMENT_CONFIG_REGISTRY[document_type.lower()] = document_model logger.info(f"Registered custom document model for {document_type}") diff --git a/healthchain/config_manager.py b/healthchain/config_manager.py deleted file mode 100644 index 92d072f6..00000000 --- a/healthchain/config_manager.py +++ /dev/null @@ -1,608 +0,0 @@ -import yaml -import logging -import os -from pathlib import Path -from typing import Dict, Any, Optional, List -from healthchain.config.validators import ( - validate_section_config, - register_template_model, - validate_document_config, - register_document_model, -) - -log = logging.getLogger(__name__) - - -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""" - - 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._configs = {} - self._mappings = {} - self._defaults = {} - self._env_configs = {} - self._module_configs = {} - self._module_env_configs = {} - self._loaded = False - self._validation_level = validation_level - self._environment = self._detect_environment() - self._module = module - - 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) -> "ConfigManager": - """Load all configuration files - - Args: - environment: Optional environment to load (overrides auto-detection) - - Returns: - Self for method chaining - """ - # Set environment if provided - if environment: - self._environment = environment - - # Load project-wide defaults - self._load_defaults() - - # Load environment-specific configuration - self._load_environment_config() - - # Load module-specific configs if module is specified - if self._module: - self._load_module_configs(self._module) - self._load_module_environment_config(self._module) - - # Load mappings from the central mappings directory - self._mappings = self._load_directory("mappings") - self._loaded = True - - # Validate configurations if not in IGNORE mode - if 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 - - module_configs = {} - - # Load all YAML files in the module directory - for config_file in module_dir.rglob("*.yaml"): - # Skip environment-specific files (they're loaded separately) - if config_file.name.startswith("env_"): - continue - - try: - with open(config_file) as f: - # Get relative path from module directory for hierarchical keys - rel_path = config_file.relative_to(module_dir) - 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 - self._deep_merge(module_configs, current_level) - else: - # Top-level file, just use the stem as key - module_configs[config_file.stem] = content - - log.debug(f"Loaded module configuration file: {config_file}") - except Exception as e: - log.error( - f"Failed to load module configuration file {config_file}: {str(e)}" - ) - - self._module_configs[module] = module_configs - log.debug(f"Loaded {len(module_configs)} configurations for module {module}") - - def _load_module_environment_config(self, module: str) -> None: - """Load module+environment-specific configuration - - Args: - module: Module name to load configs for - """ - env_file = self.config_dir / module / f"env_{self._environment}.yaml" - - if env_file.exists(): - try: - with open(env_file) as f: - env_config = yaml.safe_load(f) - self._module_env_configs.setdefault(module, {}) - self._deep_merge(self._module_env_configs[module], env_config) - log.debug(f"Loaded environment configuration for module {module}") - except Exception as e: - log.error( - f"Failed to load environment file for module {module}: {str(e)}" - ) - self._module_env_configs.setdefault(module, {}) - else: - log.warning(f"Environment file for module {module} not found: {env_file}") - self._module_env_configs.setdefault(module, {}) - - def _load_directory(self, directory: str) -> Dict: - """Load all YAML files from a directory - - Args: - directory: Directory name relative to config_dir - - Returns: - Dict of loaded configurations - """ - configs = {} - config_dir = self.config_dir / directory - - if not config_dir.exists(): - log.warning(f"Configuration directory not found: {config_dir}") - return configs - - for config_file in config_dir.rglob("*.yaml"): - # Skip defaults.yaml and environment files as they're loaded separately - if directory == "configs": - if config_file.name == "defaults.yaml": - continue - if config_file.name in [ - "development.yaml", - "testing.yaml", - "production.yaml", - ]: - continue - - try: - with open(config_file) as f: - # Get relative path from configs directory for hierarchical keys - if directory == "configs": - rel_path = config_file.relative_to(config_dir) - 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 - self._deep_merge(configs, current_level) - else: - # Top-level file, just use the stem as key - configs[config_file.stem] = content - else: - # For non-configs directories, use the old behavior - configs[config_file.stem] = yaml.safe_load(f) - - 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 - - def _deep_merge(self, 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 - self._deep_merge(target[key], value) - else: - # Otherwise, overwrite the value - target[key] = value - - def get_mappings(self) -> Dict: - """Get all mappings - - Returns: - Dict of mappings - """ - if not self._loaded: - self.load() - return self._mappings - - def get_configs(self) -> Dict: - """Get all configs with the correct precedence order - - Returns: - Dict of configs - """ - if not self._loaded: - self.load() - - # Create a merged configuration with the correct precedence: - # 1. Module-specific environment configs (highest priority) - # 2. Module-specific configs - # 3. Environment-specific configs - # 4. Regular configs - # 5. Default configs (lowest priority) - merged_configs = {} - - # Start with defaults - self._deep_merge(merged_configs, self._defaults) - - # Apply regular configs - self._deep_merge(merged_configs, self._configs) - - # Apply environment-specific configs - self._deep_merge(merged_configs, self._env_configs) - - # Apply module-specific configs if a module is specified - if self._module and self._module in self._module_configs: - self._deep_merge(merged_configs, self._module_configs[self._module]) - - # Apply module+environment-specific configs - if self._module in self._module_env_configs: - self._deep_merge(merged_configs, self._module_env_configs[self._module]) - - return merged_configs - - 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() - - # Reload module-specific environment configuration if module is set - if self._module: - self._load_module_environment_config(self._module) - - return self - - def _find_module_sections(self) -> Dict: - """Find section configs in the module configs - - Returns: - Dict of sections, or empty dict if none found - """ - if not self._module or self._module not in self._module_configs: - return {} - - # Look for sections directly in module configs - if "sections" in self._module_configs[self._module]: - return self._module_configs[self._module]["sections"] - - # Look in subdirectories - for value in self._module_configs[self._module].values(): - if isinstance(value, dict) and "sections" in value: - return value["sections"] - - return {} - - def get_section_configs(self, validate: bool = False) -> Dict: - """Get section configurations - - Args: - validate: Whether to validate the configurations - - Returns: - Dict of section configurations - """ - sections = self._find_module_sections() - - if not sections: - log.warning("No section configs found") - return {} - - if not validate: - return sections - - # Validate each section if requested - validated_sections = {} - for section_key, section_config in sections.items(): - if self.validate_section_config(section_key, section_config): - validated_sections[section_key] = section_config - elif self._validation_level != ValidationLevel.STRICT: - # Include the section with warnings if not in STRICT mode - validated_sections[section_key] = section_config - - return validated_sections - - def get_document_config(self, document_type: str) -> Dict: - """Get document configuration - - Returns: - Document configuration dict - """ - if not self._module or self._module not in self._module_configs: - log.warning("No document config found") - return {} - - # Look for document config directly - if "document" in self._module_configs[self._module]: - doc_section = self._module_configs[self._module]["document"] - if isinstance(doc_section, dict) and document_type in doc_section: - return doc_section[document_type] - - # Look in subdirectories - for value in self._module_configs[self._module].values(): - if isinstance(value, dict) and "document" in value: - if ( - isinstance(value["document"], dict) - and document_type in value["document"] - ): - return value["document"][document_type] - - log.warning(f"No document config found for type: {document_type}") - return {} - - 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 = self._get_nested_value(configs, parts) - if value is not None: - return value - - # Return the provided default if not found - return default - - def _get_nested_value(self, 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 validate_document_config(self, document_type: str) -> bool: - """Validate document configuration using Pydantic models - - Args: - document_type: Type of document to validate - - Returns: - True if valid, False otherwise - """ - document_config = self.get_document_config(document_type) - if not document_config: - self._handle_validation_error( - f"No document config found for document type: {document_type}" - ) - return False - - # Validate using Pydantic models - result = validate_document_config(document_type, document_config) - if not result and self._validation_level == ValidationLevel.STRICT: - return False - return True - - def validate_section_config(self, section_key: str, section_config: Dict) -> bool: - """Validate a section configuration using Pydantic models - - Args: - section_key: Name of the section - section_config: Section configuration dict - - Returns: - True if valid, False otherwise - """ - result = validate_section_config(section_key, section_config) - if not result and self._validation_level == ValidationLevel.STRICT: - return False - return True - - def validate(self) -> bool: - """Validate that all required configurations are present""" - is_valid = True - - # Validate section configs - section_configs = self.get_section_configs(validate=True) - if not section_configs: - is_valid = self._handle_validation_error("No section configs found") - - return is_valid - - def _handle_validation_error(self, message: str) -> bool: - """Handle validation error based on validation level - - Args: - message: Error message - - Returns: - False if in STRICT mode, True otherwise - """ - if self._validation_level == ValidationLevel.STRICT: - raise ValueError(message) - elif self._validation_level == ValidationLevel.WARN: - log.warning(f"Configuration validation: {message}") - - return self._validation_level != ValidationLevel.STRICT - - 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 register_template_model(self, resource_type: str, template_model) -> None: - """Register a custom template model - - Args: - resource_type: FHIR resource type - template_model: Pydantic model for template validation - """ - register_template_model(resource_type, template_model) - - def register_document_model(self, document_type: str, document_model) -> None: - """Register a custom document model - - Args: - document_type: Document type (e.g., "ccd", "discharge") - document_model: Pydantic model for document validation - """ - register_document_model(document_type, document_model) diff --git a/healthchain/interop/__init__.py b/healthchain/interop/__init__.py index 37dddf7d..6872981a 100644 --- a/healthchain/interop/__init__.py +++ b/healthchain/interop/__init__.py @@ -4,6 +4,7 @@ This module provides functionality for interoperability between different healthcare data formats. """ +from .config_manager import InteropConfigManager from .engine import InteropEngine, FormatType from .template_registry import TemplateRegistry from .template_renderer import TemplateRenderer @@ -41,6 +42,7 @@ def create_engine( __all__ = [ "InteropEngine", + "InteropConfigManager", "FormatType", "TemplateRegistry", "TemplateRenderer", diff --git a/healthchain/interop/config_manager.py b/healthchain/interop/config_manager.py new file mode 100644 index 00000000..d53e58d6 --- /dev/null +++ b/healthchain/interop/config_manager.py @@ -0,0 +1,185 @@ +""" +InteropConfigManager for HealthChain Interoperability Engine + +This module provides specialized configuration management for interoperability. +""" + +import logging +from typing import Dict, Optional, List + +from healthchain.config.base import ConfigManager, ValidationLevel +from healthchain.config.validators import ( + register_document_config_model, + register_template_config_model, + validate_document_config_model, + validate_section_config_model, +) + +log = logging.getLogger(__name__) + + +class InteropConfigManager(ConfigManager): + """Specialized configuration manager for the interoperability module""" + + def __init__( + self, + config_dir, + validation_level: str = ValidationLevel.STRICT, + environment: Optional[str] = None, + ): + """Initialize the InteropConfigManager + + Args: + config_dir: Base directory containing configuration files + validation_level: Level of validation to perform + environment: Optional environment to use + """ + # Initialize with "interop" as the fixed module + super().__init__(config_dir, validation_level, module="interop") + self.load(environment) + + if "interop" not in self._module_configs: + raise ValueError( + f"Interop module not found in configuration directory {config_dir}" + ) + + def _find_sections_config(self) -> Dict: + """Find section configs in the module configs + + Returns: + Dict of sections, or empty dict if none found + """ + return self._find_config_section(module_name="interop", section_name="sections") + + def _find_document_config(self, document_type: str) -> Dict: + """Find document configuration for a specific document type + + Args: + document_type: Type of document (e.g., "ccd", "discharge") + + Returns: + Document configuration dict or empty dict if not found + """ + return self._find_config_section( + module_name="interop", section_name="document", subsection=document_type + ) + + def _find_document_types(self) -> List[str]: + """Find available document types in the configs + + Returns: + List of document type strings + """ + document_types = [] + + # Get top level document section using _find_config_section + doc_section = self._find_config_section( + module_name="interop", section_name="document" + ) + if doc_section: + document_types.extend(doc_section.keys()) + + # Check in subdirectories for additional document types + # We still need this part as _find_config_section only returns one section/subsection + for value in self._module_configs["interop"].values(): + if isinstance(value, dict) and "document" in value: + if ( + isinstance(value["document"], dict) + and value["document"] != doc_section + ): + document_types.extend(value["document"].keys()) + + return document_types + + def get_section_configs(self, validate: bool = False) -> Dict: + """Get section configurations, optionally validating them + + Args: + validate: Whether to validate the configurations + + Returns: + Dict of section configurations + """ + sections = self._find_sections_config() + + if not sections: + log.warning("No section configs found") + return {} + + if not validate: + return sections + + # Validate if requested + validated_sections = {} + for section_key, section_config in sections.items(): + result = validate_section_config_model(section_key, section_config) + if result or self._validation_level != ValidationLevel.STRICT: + validated_sections[section_key] = section_config + + return validated_sections + + def get_document_config(self, document_type: str, validate: bool = False) -> Dict: + """Get document configuration for a specific document type + + Args: + document_type: Type of document (e.g., "ccd", "discharge") + validate: Whether to validate the configuration + + Returns: + Document configuration dict or empty dict if not found or validation failed + """ + document_config = self._find_document_config(document_type) + + if not document_config: + return {} + + if not validate: + return document_config + + # Validate if requested + result = validate_document_config_model(document_type, document_config) + if not result and self._validation_level == ValidationLevel.STRICT: + self._handle_validation_error( + f"Document config validation failed for type: {document_type}" + ) + return {} + + return document_config + + def validate(self) -> bool: + """Validate that all required configurations are present for the interop module""" + is_valid = super().validate() + + # Validate section configs + section_configs = self.get_section_configs(validate=True) + if not section_configs: + is_valid = self._handle_validation_error("No section configs found") + + # Validate document configs - but don't fail if no documents are configured + # since some use cases might not require documents + document_types = self._find_document_types() + for doc_type in document_types: + if not self.get_document_config(doc_type, validate=True): + is_valid = False + + return is_valid + + def register_section_template_config( + self, resource_type: str, template_model + ) -> None: + """Register a custom template model + + Args: + resource_type: FHIR resource type + template_model: Pydantic model for corresponding section template validation + """ + register_template_config_model(resource_type, template_model) + + def register_document_config(self, document_type: str, document_model) -> None: + """Register a custom document model + + Args: + document_type: Document type (e.g., "ccd", "discharge") + document_model: Pydantic model for document validation + """ + register_document_config_model(document_type, document_model) diff --git a/healthchain/interop/engine.py b/healthchain/interop/engine.py index d7d663b9..111ed870 100644 --- a/healthchain/interop/engine.py +++ b/healthchain/interop/engine.py @@ -8,8 +8,8 @@ from fhir.resources.resource import Resource from fhir.resources.bundle import Bundle -from healthchain.config_manager import ConfigManager, ValidationLevel -from healthchain.config.validators import register_template_model +from healthchain.config.base import ValidationLevel +from healthchain.interop.config_manager import InteropConfigManager from healthchain.interop.parsers.cda import CDAParser from healthchain.interop.parsers.hl7v2 import HL7v2Parser @@ -85,8 +85,7 @@ def __init__( environment: Optional environment to use (development, testing, production) """ # Initialize configuration manager - self.config = ConfigManager(config_dir, validation_level, module="interop") - self.config.load(environment) + self.config = InteropConfigManager(config_dir, validation_level, environment) # Initialize template registry template_dir = config_dir / "templates" @@ -272,7 +271,7 @@ def register_template_validator( Returns: Self for method chaining """ - register_template_model(resource_type, template_model) + self.config.register_section_template_config(resource_type, template_model) return self def register_document_validator( @@ -287,7 +286,7 @@ def register_document_validator( Returns: Self for method chaining """ - self.config.register_document_model(document_type, document_model) + self.config.register_document_config(document_type, document_model) return self def to_fhir( @@ -374,8 +373,12 @@ def _fhir_to_cda( if document_type: log.info(f"Processing CDA document of type: {document_type}") - # Validate document configuration for this specific document type - self.config.validate_document_config(document_type) + # Get and validate document configuration for this specific document type + doc_config = self.config.get_document_config(document_type, validate=True) + if not doc_config: + raise ValueError( + f"Invalid or missing document configuration for type: {document_type}" + ) # Normalize input to list of resources resource_list = normalize_resource_list(resources) diff --git a/healthchain/interop/generators/cda.py b/healthchain/interop/generators/cda.py index c96c2782..e89779c2 100644 --- a/healthchain/interop/generators/cda.py +++ b/healthchain/interop/generators/cda.py @@ -177,10 +177,10 @@ def _render_document( Returns: CDA document as XML string """ - config = self.config.get_document_config(document_type) + config = self.config.get_document_config(document_type, validate=True) if not config: raise ValueError( - f"No document configuration found in /document/{document_type}" + f"No document configuration found or validation failed for document type: {document_type}" ) # Get document template name from config or use default diff --git a/healthchain/interop/generators/fhir.py b/healthchain/interop/generators/fhir.py index aa840270..0738868a 100644 --- a/healthchain/interop/generators/fhir.py +++ b/healthchain/interop/generators/fhir.py @@ -142,14 +142,12 @@ def _add_required_fields(self, resource_dict: Dict, resource_type: str) -> Dict: Dict: The resource dictionary with required fields added """ # Add common fields - id_prefix = self.config.get_config_value("defaults.common.id_prefix", "hc-") + id_prefix = self.config.get_config_value("defaults.common.id_prefix") if "id" not in resource_dict: resource_dict["id"] = f"{id_prefix}{str(uuid.uuid4())}" # Get default values from configuration if available - default_subject = self.config.get_config_value( - "defaults.common.subject", {"reference": "Patient/example"} - ) + default_subject = self.config.get_config_value("defaults.common.subject") # Add resource-specific required fields if resource_type == "Condition": @@ -158,15 +156,7 @@ def _add_required_fields(self, resource_dict: Dict, resource_type: str) -> Dict: if "clinicalStatus" not in resource_dict: default_status = self.config.get_config_value( - "defaults.resources.Condition.clinicalStatus", - { - "coding": [ - { - "system": "http://terminology.hl7.org/CodeSystem/condition-clinical", - "code": "unknown", - } - ] - }, + "defaults.resources.Condition.clinicalStatus" ) resource_dict["clinicalStatus"] = default_status elif resource_type == "MedicationStatement": @@ -174,7 +164,7 @@ def _add_required_fields(self, resource_dict: Dict, resource_type: str) -> Dict: resource_dict["subject"] = default_subject if "status" not in resource_dict: default_status = self.config.get_config_value( - "defaults.resources.MedicationStatement.status", "unknown" + "defaults.resources.MedicationStatement.status" ) resource_dict["status"] = default_status elif resource_type == "AllergyIntolerance": @@ -182,15 +172,7 @@ def _add_required_fields(self, resource_dict: Dict, resource_type: str) -> Dict: resource_dict["patient"] = default_subject if "clinicalStatus" not in resource_dict: default_status = self.config.get_config_value( - "defaults.resources.AllergyIntolerance.clinicalStatus", - { - "coding": [ - { - "system": "http://terminology.hl7.org/CodeSystem/allergyintolerance-clinical", - "code": "unknown", - } - ] - }, + "defaults.resources.AllergyIntolerance.clinicalStatus" ) resource_dict["clinicalStatus"] = default_status diff --git a/healthchain/interop/parsers/cda.py b/healthchain/interop/parsers/cda.py index 99fbd33b..3e1e441d 100644 --- a/healthchain/interop/parsers/cda.py +++ b/healthchain/interop/parsers/cda.py @@ -10,7 +10,7 @@ from healthchain.interop.models.cda import ClinicalDocument from healthchain.interop.models.sections import Section, Entry -from healthchain.config_manager import ConfigManager +from healthchain.config.base import ConfigManager log = logging.getLogger(__name__) diff --git a/healthchain/interop/template_renderer.py b/healthchain/interop/template_renderer.py index 5bc26de4..59ab87e9 100644 --- a/healthchain/interop/template_renderer.py +++ b/healthchain/interop/template_renderer.py @@ -9,7 +9,7 @@ from typing import Dict, Any, Optional from pathlib import Path -from healthchain.config_manager import ConfigManager +from healthchain.config.base import ConfigManager from healthchain.interop.template_registry import TemplateRegistry from healthchain.interop.filters import clean_empty From ec37607ec852f550dcae384e97c4a5425ad09b1b Mon Sep 17 00:00:00 2001 From: jenniferjiangkells Date: Sat, 29 Mar 2025 01:33:17 +0000 Subject: [PATCH 16/25] Unify validation behaviour and added tests --- config/defaults.yaml | 76 ----- config/environments/development.yaml | 33 --- config/environments/production.yaml | 37 --- config/environments/testing.yaml | 39 --- config/interop/document/ccd.yaml | 80 ------ config/interop/sections/allergies.yaml | 98 ------- config/interop/sections/medications.yaml | 72 ----- config/interop/sections/problems.yaml | 71 ----- config/mappings/shared_mappings.yaml | 31 -- .../cda_fhir/allergy_intolerance.liquid | 72 ----- .../cda_fhir/cda_allergy_entry.liquid | 145 ---------- config/templates/cda_fhir/cda_document.liquid | 41 --- .../cda_fhir/cda_medication_entry.liquid | 109 -------- .../cda_fhir/cda_problem_entry.liquid | 86 ------ config/templates/cda_fhir/cda_section.liquid | 15 - config/templates/cda_fhir/condition.liquid | 52 ---- .../cda_fhir/medication_statement.liquid | 74 ----- healthchain/config/base.py | 15 +- healthchain/config/validators.py | 17 +- healthchain/interop/config_manager.py | 66 ++--- healthchain/interop/engine.py | 4 +- healthchain/interop/generators/cda.py | 12 +- healthchain/interop/generators/fhir.py | 2 +- healthchain/interop/template_renderer.py | 19 +- tests/conftest.py | 264 ++++++++++++++++++ tests/test_config_base.py | 177 ++++++++++++ tests/test_interop_config_manager.py | 183 ++++++++++++ tests/test_validators.py | 91 ++++++ 28 files changed, 778 insertions(+), 1203 deletions(-) delete mode 100644 config/defaults.yaml delete mode 100644 config/environments/development.yaml delete mode 100644 config/environments/production.yaml delete mode 100644 config/environments/testing.yaml delete mode 100644 config/interop/document/ccd.yaml delete mode 100644 config/interop/sections/allergies.yaml delete mode 100644 config/interop/sections/medications.yaml delete mode 100644 config/interop/sections/problems.yaml delete mode 100644 config/mappings/shared_mappings.yaml delete mode 100644 config/templates/cda_fhir/allergy_intolerance.liquid delete mode 100644 config/templates/cda_fhir/cda_allergy_entry.liquid delete mode 100644 config/templates/cda_fhir/cda_document.liquid delete mode 100644 config/templates/cda_fhir/cda_medication_entry.liquid delete mode 100644 config/templates/cda_fhir/cda_problem_entry.liquid delete mode 100644 config/templates/cda_fhir/cda_section.liquid delete mode 100644 config/templates/cda_fhir/condition.liquid delete mode 100644 config/templates/cda_fhir/medication_statement.liquid create mode 100644 tests/test_config_base.py create mode 100644 tests/test_interop_config_manager.py create mode 100644 tests/test_validators.py diff --git a/config/defaults.yaml b/config/defaults.yaml deleted file mode 100644 index 723be4f2..00000000 --- a/config/defaults.yaml +++ /dev/null @@ -1,76 +0,0 @@ -# HealthChain Interoperability Engine Default Configuration -# This file contains default values used throughout the engine - -# Default resource fields -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" - verificationStatus: - coding: - - system: "http://terminology.hl7.org/CodeSystem/condition-ver-status" - code: "unconfirmed" - display: "Unconfirmed" - - MedicationStatement: - status: "unknown" - effectiveDateTime: "{{ now | date: '%Y-%m-%d' }}" - medicationCodeableConcept: - coding: - - system: "http://terminology.hl7.org/CodeSystem/v3-NullFlavor" - code: "UNK" - display: "Unknown" - - AllergyIntolerance: - clinicalStatus: - coding: - - system: "http://terminology.hl7.org/CodeSystem/allergyintolerance-clinical" - code: "unknown" - display: "Unknown" - verificationStatus: - coding: - - system: "http://terminology.hl7.org/CodeSystem/allergyintolerance-verification" - code: "unconfirmed" - display: "Unconfirmed" - type: "allergy" - criticality: "low" - -# # 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/config/environments/development.yaml b/config/environments/development.yaml deleted file mode 100644 index a143a927..00000000 --- a/config/environments/development.yaml +++ /dev/null @@ -1,33 +0,0 @@ -# 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/dev-example" - -# Template settings for development -templates: - reload_on_change: true # Automatically reload templates when they change diff --git a/config/environments/production.yaml b/config/environments/production.yaml deleted file mode 100644 index 846a914c..00000000 --- a/config/environments/production.yaml +++ /dev/null @@ -1,37 +0,0 @@ -# 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/config/environments/testing.yaml b/config/environments/testing.yaml deleted file mode 100644 index 2c6aca95..00000000 --- a/config/environments/testing.yaml +++ /dev/null @@ -1,39 +0,0 @@ -# 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/config/interop/document/ccd.yaml b/config/interop/document/ccd.yaml deleted file mode 100644 index 03bd745b..00000000 --- a/config/interop/document/ccd.yaml +++ /dev/null @@ -1,80 +0,0 @@ -# CDA Document Configuration -# This file contains configuration for CDA documents - -# 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" - -templates: - section: "cda_section" - entry: "cda_entry" - -# Document structure -structure: - # Header configuration - header: - include_patient: true - include_author: true - include_custodian: true - include_legal_authenticator: false - - # Body configuration - body: - structured_body: true - non_xml_body: false - -# Default values -defaults: - # Default patient information - patient: - id: "example" - id_root: "2.16.840.1.113883.19.5" - name: - given: "John" - family: "Doe" - gender: "M" - birth_date: "19700101" - - # Default author information - author: - id: "author1" - id_root: "2.16.840.1.113883.19.5" - name: - given: "Jane" - family: "Smith" - organization: - id: "org1" - name: "HealthChain Organization" - - # Default custodian information - custodian: - id: "custodian1" - id_root: "2.16.840.1.113883.19.5" - organization: - id: "org1" - name: "HealthChain Organization" - -# Rendering configuration -rendering: - # XML formatting - xml: - pretty_print: true - encoding: "UTF-8" - - # Narrative generation - narrative: - include: true - generate_if_missing: true diff --git a/config/interop/sections/allergies.yaml b/config/interop/sections/allergies.yaml deleted file mode 100644 index 0747d638..00000000 --- a/config/interop/sections/allergies.yaml +++ /dev/null @@ -1,98 +0,0 @@ -# Allergies Section Configuration -# ======================== - -# Metadata for both extraction and rendering processes -resource: "AllergyIntolerance" -resource_template: "cda_fhir/allergy_intolerance" -entry_template: "cda_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 -rendering: - narrative: - include: true - template: "narratives/allergy_narrative" - entry: - include_status: true - include_reaction: true - include_severity: true - include_dates: true - -# Default values for template -defaults: - status_code: "active" - type_code: "SUBJ" - inversion_ind: false - allergy_code: "420134006" - allergy_code_system: "2.16.840.1.113883.6.96" - allergy_code_system_name: "SNOMED CT" - allergy_display_name: "Propensity to adverse reactions" diff --git a/config/interop/sections/medications.yaml b/config/interop/sections/medications.yaml deleted file mode 100644 index 2b5df936..00000000 --- a/config/interop/sections/medications.yaml +++ /dev/null @@ -1,72 +0,0 @@ -# Medications Section Configuration -# ======================== - -# Metadata for both extraction and rendering processes -resource: "MedicationStatement" -resource_template: "cda_fhir/medication_statement" -entry_template: "cda_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/config/interop/sections/problems.yaml b/config/interop/sections/problems.yaml deleted file mode 100644 index 0ad4b5c0..00000000 --- a/config/interop/sections/problems.yaml +++ /dev/null @@ -1,71 +0,0 @@ -# Problems Section Configuration -# ======================== - -# Metadata for both extraction and rendering processes -resource: "Condition" -resource_template: "cda_fhir/condition" -entry_template: "cda_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 -rendering: - narrative: - include: true - template: "narratives/problem_narrative" - entry: - include_status: true - include_dates: true - -# Default values for template -defaults: - status_code: "active" - type_code: "SUBJ" - inversion_ind: false - problem_code: "55607006" - problem_code_system: "2.16.840.1.113883.6.96" - problem_code_system_name: "SNOMED CT" - problem_display_name: "Problem" diff --git a/config/mappings/shared_mappings.yaml b/config/mappings/shared_mappings.yaml deleted file mode 100644 index 6eef2001..00000000 --- a/config/mappings/shared_mappings.yaml +++ /dev/null @@ -1,31 +0,0 @@ -# Shared mappings between CDA and FHIR formats -code_systems: - cda_to_fhir: - "2.16.840.1.113883.6.96": "http://snomed.info/sct" - "2.16.840.1.113883.6.1": "http://loinc.org" - "2.16.840.1.113883.6.88": "http://www.nlm.nih.gov/research/umls/rxnorm" - fhir_to_cda: - "http://snomed.info/sct": "2.16.840.1.113883.6.96" - "http://loinc.org": "2.16.840.1.113883.6.1" - "http://www.nlm.nih.gov/research/umls/rxnorm": "2.16.840.1.113883.6.88" - "http://terminology.hl7.org/CodeSystem/condition-clinical": "2.16.840.1.113883.6.96" - -status_codes: - cda_to_fhir: - "55561003": "active" - "413322009": "resolved" - "73425007": "inactive" - fhir_to_cda: - "active": "55561003" - "resolved": "413322009" - "inactive": "73425007" - -severity_codes: - cda_to_fhir: - "H": "severe" - "M": "moderate" - "L": "mild" - fhir_to_cda: - "severe": "H" - "moderate": "M" - "mild": "L" diff --git a/config/templates/cda_fhir/allergy_intolerance.liquid b/config/templates/cda_fhir/allergy_intolerance.liquid deleted file mode 100644 index 543934d6..00000000 --- a/config/templates/cda_fhir/allergy_intolerance.liquid +++ /dev/null @@ -1,72 +0,0 @@ -{ - "resourceType": "AllergyIntolerance", - "id": "{{ entry.id | generate_id }}", - {% 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 %} - {% if clinical_status %} - "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 %} - - "patient": { - "reference": "Patient/{{ entry.subject_id | default: 'Foo' }}" - }, - - {% if obs.effectiveTime.low['@value'] %} - "onsetDateTime": "{{ obs.effectiveTime.low['@value'] | format_date }}", - {% endif %} - {% assign reactions = obs | extract_reactions: config %} - {% 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/config/templates/cda_fhir/cda_allergy_entry.liquid b/config/templates/cda_fhir/cda_allergy_entry.liquid deleted file mode 100644 index 776182c7..00000000 --- a/config/templates/cda_fhir/cda_allergy_entry.liquid +++ /dev/null @@ -1,145 +0,0 @@ -{ - "act": { - "@classCode": "ACT", - "@moodCode": "EVN", - "templateId": [ - {% for template_id in config.template.act.template_id %} - {"@root": "{{template_id}}"} {% if forloop.last != true %},{% endif %} - {% endfor %} - ], - "id": {"@root": "{{ resource.id | generate_id }}"}, - "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 %} - ], - "id": {"@root": "{{ resource.id }}_obs"}, - "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.reaction %}, - "entryRelationship": { - "@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 %} - } - } - {% endif %} - } - } - } -} diff --git a/config/templates/cda_fhir/cda_document.liquid b/config/templates/cda_fhir/cda_document.liquid deleted file mode 100644 index 460d7196..00000000 --- a/config/templates/cda_fhir/cda_document.liquid +++ /dev/null @@ -1,41 +0,0 @@ -{ - "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/config/templates/cda_fhir/cda_medication_entry.liquid b/config/templates/cda_fhir/cda_medication_entry.liquid deleted file mode 100644 index bb611306..00000000 --- a/config/templates/cda_fhir/cda_medication_entry.liquid +++ /dev/null @@ -1,109 +0,0 @@ -{ - "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 %} - ], - "id": {"@root": "{{ resource.id | generate_id }}"}, - "statusCode": {"@code": "{{ config.template.substance_admin.status_code | default: config.defaults.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 | default: '2.16.840.1.113883.10.20.1.47' }}"}, - "code": { - "@code": "{{ config.template.clinical_status_obs.code.code | default: '33999-4' }}", - "@codeSystem": "{{ config.template.clinical_status_obs.code.code_system | default: '2.16.840.1.113883.6.1' }}", - "@codeSystemName": "{{ config.template.clinical_status_obs.code.code_system_name | default: 'LOINC' }}", - "@displayName": "{{ config.template.clinical_status_obs.code.display_name | default: 'Status' }}" - }, - "value": { - "@xmlns:xsi": "http://www.w3.org/2001/XMLSchema-instance", - "@code": "{{ config.template.clinical_status_obs.value.code | default: config.defaults.medication_status_code }}", - "@codeSystem": "{{ config.template.clinical_status_obs.value.code_system | default: config.defaults.medication_status_system }}", - "@codeSystemName": "{{ config.template.clinical_status_obs.value.code_system_name | default: 'SNOMED CT' }}", - "@xsi:type": "CE", - "@displayName": "{{ config.template.clinical_status_obs.value.display_name | default: config.defaults.medication_status_display }}" - }, - "statusCode": {"@code": "{{ config.template.clinical_status_obs.status_code | default: 'completed' }}"}, - "effectiveTime": { - "low": {"@value": "{{ timestamp }}"} - } - } - } - } -} diff --git a/config/templates/cda_fhir/cda_problem_entry.liquid b/config/templates/cda_fhir/cda_problem_entry.liquid deleted file mode 100644 index a14b6139..00000000 --- a/config/templates/cda_fhir/cda_problem_entry.liquid +++ /dev/null @@ -1,86 +0,0 @@ -{ - "act": { - "@classCode": "ACT", - "@moodCode": "EVN", - "templateId": [ - {% for template_id in config.template.act.template_id %} - {"@root": "{{template_id}}"} {% if forloop.last != true %},{% endif %} - {% endfor %} - ], - "id": {"@root": "{{ resource.id | generate_id }}"}, - "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 %} - ], - "id": {"@root": "{{ resource.id }}_obs"}, - "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/config/templates/cda_fhir/cda_section.liquid b/config/templates/cda_fhir/cda_section.liquid deleted file mode 100644 index 3e35256c..00000000 --- a/config/templates/cda_fhir/cda_section.liquid +++ /dev/null @@ -1,15 +0,0 @@ -{ - "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/config/templates/cda_fhir/condition.liquid b/config/templates/cda_fhir/condition.liquid deleted file mode 100644 index f00d475a..00000000 --- a/config/templates/cda_fhir/condition.liquid +++ /dev/null @@ -1,52 +0,0 @@ -{ - "resourceType": "Condition", - {% if entry.act.entryRelationship.is_array %} - {% assign actEntryRelationship = entry.act.entryRelationship[0] %} - {% else %} - {% assign actEntryRelationship = entry.act.entryRelationship %} - {% endif %} - {% if actEntryRelationship.observation.entryRelationship.observation.code['@code'] == config.identifiers.clinical_status.code %} - {% if actEntryRelationship.observation.entryRelationship.observation.value %} - "clinicalStatus": { - "coding": [ - { - "system": "http://terminology.hl7.org/CodeSystem/condition-clinical", - "code": "{{ actEntryRelationship.observation.entryRelationship.observation.value['@code'] | map_status: 'cda_to_fhir' }}" - }, - { - "system": "{{ actEntryRelationship.observation.entryRelationship.observation.value['@codeSystem'] | map_system: 'cda_to_fhir' }}", - "code": "{{ actEntryRelationship.observation.entryRelationship.observation.value['@code'] }}", - "display": "{{ actEntryRelationship.observation.entryRelationship.observation.value['@displayName'] }}" - } - ] - }, - {% endif %} - {% endif %} - "category": [{ - "coding": [{ - "system": "http://terminology.hl7.org/CodeSystem/condition-category", - "code": "problem-list-item", - "display": "Problem List Item" - }] - }], - {% if actEntryRelationship.observation.value %} - "code": { - "coding": [{ - "system": "{{ actEntryRelationship.observation.value['@codeSystem'] | map_system: 'cda_to_fhir' }}", - "code": "{{ actEntryRelationship.observation.value['@code'] }}", - "display": "{{ actEntryRelationship.observation.value['@displayName'] }}" - }] - }, - {% endif %} - {% if actEntryRelationship.observation.effectiveTime %} - {% if actEntryRelationship.observation.effectiveTime.low %} - "onsetDateTime": "{{ actEntryRelationship.observation.effectiveTime.low['@value'] | format_date }}", - {% endif %} - {% if actEntryRelationship.observation.effectiveTime.high %} - "abatementDateTime": "{{ actEntryRelationship.observation.effectiveTime.high['@value'] | format_date }}", - {% endif %} - {% endif %} - "subject": { - "reference": "Patient/{{ entry.subject_id | default: 'Foo' }}" - } -} diff --git a/config/templates/cda_fhir/medication_statement.liquid b/config/templates/cda_fhir/medication_statement.liquid deleted file mode 100644 index 1495540e..00000000 --- a/config/templates/cda_fhir/medication_statement.liquid +++ /dev/null @@ -1,74 +0,0 @@ -{ - "resourceType": "MedicationStatement", - "id": "{{ entry.id | generate_id }}", - "status": "{{ entry.statusCode | map_status: 'cda_to_fhir' | default: 'recorded' }}", - "medication": { - "concept": { - "coding": [{ - "system": "{{ entry.substanceAdministration.consumable.manufacturedProduct.manufacturedMaterial.code['@codeSystem'] | map_system: 'cda_to_fhir' }}", - "code": "{{ entry.substanceAdministration.consumable.manufacturedProduct.manufacturedMaterial.code['@code'] }}", - "display": "{{ entry.substanceAdministration.consumable.manufacturedProduct.manufacturedMaterial.code['@displayName'] }}" - }] - } - } - - {% comment %}Process effectiveTime and extract period/timing information if exists{% endcomment %} - {% if entry.substanceAdministration.effectiveTime %} - , - {% assign effective_period = entry.substanceAdministration.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 = entry.substanceAdministration.effectiveTime | extract_effective_timing %} - {% if entry.substanceAdministration.doseQuantity or entry.substanceAdministration.routeCode or effective_timing %},{% endif %} - {% endif %} - {% endif %} - - {% comment %}Add dosage if any dosage related fields are present{% endcomment %} - {% assign effective_timing = entry.substanceAdministration.effectiveTime | extract_effective_timing %} - {% if entry.substanceAdministration.doseQuantity or entry.substanceAdministration.routeCode or effective_timing %} - {% if entry.substanceAdministration.effectiveTime == nil %},{% endif %} - "dosage": [ - { - {% if entry.substanceAdministration.doseQuantity %} - "doseAndRate": [ - { - "doseQuantity": { - "value": {{ entry.substanceAdministration.doseQuantity['@value'] }}, - "unit": "{{ entry.substanceAdministration.doseQuantity['@unit'] }}" - } - } - ]{% if entry.substanceAdministration.routeCode or effective_timing %},{% endif %} - {% endif %} - - {% if entry.substanceAdministration.routeCode %} - "route": { - "coding": [ - { - "system": "{{ entry.substanceAdministration.routeCode['@codeSystem'] | map_system: 'cda_to_fhir' }}", - "code": "{{ entry.substanceAdministration.routeCode['@code'] }}", - "display": "{{ entry.substanceAdministration.routeCode['@displayName'] }}" - } - ] - }{% if effective_timing %},{% endif %} - {% endif %} - - {% if effective_timing %} - "timing": { - "repeat": { - "period": {{ effective_timing.period }}, - "periodUnit": "{{ effective_timing.periodUnit }}" - } - } - {% endif %} - } - ] - {% endif %} - - , - "subject": { - "reference": "Patient/{{ entry.subject_id | default: '123' }}" - } -} diff --git a/healthchain/config/base.py b/healthchain/config/base.py index ea12e2a5..5fe7027d 100644 --- a/healthchain/config/base.py +++ b/healthchain/config/base.py @@ -144,7 +144,9 @@ def _detect_environment(self) -> str: log.info(f"Detected environment: {env}") return env - def load(self, environment: Optional[str] = None) -> "ConfigManager": + 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: @@ -152,10 +154,11 @@ def load(self, environment: Optional[str] = None) -> "ConfigManager": 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 ignored. + 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 @@ -174,7 +177,7 @@ def load(self, environment: Optional[str] = None) -> "ConfigManager": self._loaded = True - if self._validation_level != ValidationLevel.IGNORE: + if not skip_validation and self._validation_level != ValidationLevel.IGNORE: self.validate() return self @@ -429,11 +432,13 @@ def _handle_validation_error(self, message: str) -> bool: message: Error message Returns: - False if in STRICT mode, True otherwise + 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 self._validation_level != ValidationLevel.STRICT + return True # Return True only for IGNORE mode diff --git a/healthchain/config/validators.py b/healthchain/config/validators.py index c4ee4b6c..93593f53 100644 --- a/healthchain/config/validators.py +++ b/healthchain/config/validators.py @@ -5,7 +5,7 @@ """ import logging -from pydantic import BaseModel, ValidationError, field_validator +from pydantic import BaseModel, ValidationError, field_validator, ConfigDict from typing import Dict, List, Any, Optional, Type, Union logger = logging.getLogger(__name__) @@ -30,8 +30,7 @@ class ComponentTemplateConfig(BaseModel): inversion_ind: Optional[bool] = None value: Optional[Dict[str, Any]] = None - class Config: - extra = "allow" + model_config = ConfigDict(extra="allow") class SectionIdentifiersConfig(BaseModel): @@ -46,8 +45,7 @@ class SectionIdentifiersConfig(BaseModel): reaction: Optional[Dict[str, str]] = None severity: Optional[Dict[str, str]] = None - class Config: - extra = "allow" + model_config = ConfigDict(extra="allow") class RenderingConfig(BaseModel): @@ -56,8 +54,7 @@ class RenderingConfig(BaseModel): narrative: Optional[Dict[str, Any]] = None entry: Optional[Dict[str, Any]] = None - class Config: - extra = "allow" + model_config = ConfigDict(extra="allow") class SectionBaseConfig(BaseModel): @@ -69,8 +66,7 @@ class SectionBaseConfig(BaseModel): identifiers: SectionIdentifiersConfig rendering: Optional[RenderingConfig] = None - class Config: - extra = "allow" + model_config = ConfigDict(extra="allow") # @@ -171,8 +167,7 @@ def validate_confidentiality_code(cls, v): raise ValueError("confidentiality_code must contain 'code' field") return v - class Config: - extra = "allow" + model_config = ConfigDict(extra="allow") # diff --git a/healthchain/interop/config_manager.py b/healthchain/interop/config_manager.py index d53e58d6..04f67768 100644 --- a/healthchain/interop/config_manager.py +++ b/healthchain/interop/config_manager.py @@ -36,13 +36,15 @@ def __init__( """ # Initialize with "interop" as the fixed module super().__init__(config_dir, validation_level, module="interop") - self.load(environment) + 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_sections_config(self) -> Dict: """Find section configs in the module configs @@ -91,11 +93,8 @@ def _find_document_types(self) -> List[str]: return document_types - def get_section_configs(self, validate: bool = False) -> Dict: - """Get section configurations, optionally validating them - - Args: - validate: Whether to validate the configurations + def get_section_configs(self) -> Dict: + """Get section configurations Returns: Dict of section configurations @@ -106,61 +105,58 @@ def get_section_configs(self, validate: bool = False) -> Dict: log.warning("No section configs found") return {} - if not validate: - return sections - - # Validate if requested - validated_sections = {} - for section_key, section_config in sections.items(): - result = validate_section_config_model(section_key, section_config) - if result or self._validation_level != ValidationLevel.STRICT: - validated_sections[section_key] = section_config + return sections - return validated_sections - - def get_document_config(self, document_type: str, validate: bool = False) -> Dict: + def get_document_config(self, document_type: str) -> Dict: """Get document configuration for a specific document type Args: document_type: Type of document (e.g., "ccd", "discharge") - validate: Whether to validate the configuration Returns: - Document configuration dict or empty dict if not found or validation failed + Document configuration dict or empty dict if not found """ document_config = self._find_document_config(document_type) if not document_config: return {} - if not validate: - return document_config - - # Validate if requested - result = validate_document_config_model(document_type, document_config) - if not result and self._validation_level == ValidationLevel.STRICT: - self._handle_validation_error( - f"Document config validation failed for type: {document_type}" - ) - return {} - return document_config def validate(self) -> bool: - """Validate that all required configurations are present for the interop module""" + """Validate that all required configurations are present for the interop module + + Behavior depends on the validation_level setting. + """ + if self._validation_level == ValidationLevel.IGNORE: + return True + is_valid = super().validate() # Validate section configs - section_configs = self.get_section_configs(validate=True) + section_configs = self._find_sections_config() 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_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}" + ) # Validate document configs - but don't fail if no documents are configured # since some use cases might not require documents document_types = self._find_document_types() for doc_type in document_types: - if not self.get_document_config(doc_type, validate=True): - is_valid = False + doc_config = self._find_document_config(doc_type) + if doc_config: + result = validate_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}" + ) return is_valid diff --git a/healthchain/interop/engine.py b/healthchain/interop/engine.py index 111ed870..f438e34e 100644 --- a/healthchain/interop/engine.py +++ b/healthchain/interop/engine.py @@ -373,8 +373,8 @@ def _fhir_to_cda( if document_type: log.info(f"Processing CDA document of type: {document_type}") - # Get and validate document configuration for this specific document type - doc_config = self.config.get_document_config(document_type, validate=True) + # Get document configuration for this specific document type + doc_config = self.config.get_document_config(document_type) if not doc_config: raise ValueError( f"Invalid or missing document configuration for type: {document_type}" diff --git a/healthchain/interop/generators/cda.py b/healthchain/interop/generators/cda.py index e89779c2..cac143d2 100644 --- a/healthchain/interop/generators/cda.py +++ b/healthchain/interop/generators/cda.py @@ -63,7 +63,7 @@ def _render_entry( """ try: # Get validated section configuration - section_config = self.get_validated_section_config(config_key) + section_config = self.get_cda_section_config(config_key) timestamp_format = self.config.get_config_value( "defaults.common.timestamp", "%Y%m%d" @@ -96,7 +96,7 @@ def _render_entry( return None def _get_mapped_entries(self, resources: List[Resource]) -> Dict: - """Get mapped entries for resources + """Map FHIR resources to CDA section entries Args: resources: List of FHIR resources @@ -108,7 +108,7 @@ def _get_mapped_entries(self, resources: List[Resource]) -> Dict: for resource in resources: # Find matching section for resource type resource_type = resource.__class__.__name__ - all_configs = self.config.get_section_configs(validate=True) + all_configs = self.config.get_section_configs() section_key = find_section_key_for_resource_type(resource_type, all_configs) if not section_key: @@ -132,7 +132,7 @@ def _render_sections(self, mapped_entries: Dict) -> List[Dict]: sections = [] # Get validated section configurations - section_configs = self.config.get_section_configs(validate=True) + section_configs = self.config.get_section_configs() if not section_configs: raise ValueError("No valid configurations found in /sections") @@ -177,10 +177,10 @@ def _render_document( Returns: CDA document as XML string """ - config = self.config.get_document_config(document_type, validate=True) + config = self.config.get_document_config(document_type) if not config: raise ValueError( - f"No document configuration found or validation failed for document type: {document_type}" + f"No document configuration found for document type: {document_type}" ) # Get document template name from config or use default diff --git a/healthchain/interop/generators/fhir.py b/healthchain/interop/generators/fhir.py index 0738868a..7e61ca0e 100644 --- a/healthchain/interop/generators/fhir.py +++ b/healthchain/interop/generators/fhir.py @@ -92,7 +92,7 @@ def _render_resource_from_entry( # Get validated section configuration try: - section_config = self.get_validated_section_config(section_key) + section_config = self.get_cda_section_config(section_key) except ValueError as e: log.error(f"Failed to get section config: {str(e)}") return None diff --git a/healthchain/interop/template_renderer.py b/healthchain/interop/template_renderer.py index 59ab87e9..0624d9cd 100644 --- a/healthchain/interop/template_renderer.py +++ b/healthchain/interop/template_renderer.py @@ -108,25 +108,20 @@ def render_template(self, template, context: Dict[str, Any]) -> Optional[Dict]: log.error(f"Failed to render template {template.name}: {str(e)}") return None - def get_validated_section_config(self, section_key: str) -> Dict: - """Get a validated section configuration + def get_cda_section_config(self, section_key: str) -> Dict: + """Get validated configuration for a CDA section. Args: - section_key: Key identifying the section + section_key: Section identifier Returns: - A validated section configuration + Dict: Validated section configuration Raises: - ValueError: If the section configuration doesn't exist or is invalid + ValueError: If section configuration not found """ - validated_sections = self.config.get_section_configs(validate=True) + validated_sections = self.config.get_section_configs() if section_key not in validated_sections: - # Check if the section exists at all (might have failed validation) - all_sections = self.config.get_section_configs(validate=False) - if section_key not in all_sections: - raise ValueError(f"Section configuration not found: {section_key}") - else: - raise ValueError(f"Section configuration is invalid: {section_key}") + raise ValueError(f"Section configuration not found: {section_key}") return validated_sections[section_key] diff --git a/tests/conftest.py b/tests/conftest.py index 82066370..6661de7b 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,4 +1,7 @@ +from pathlib import Path import pytest +import yaml +import tempfile from unittest.mock import Mock @@ -534,3 +537,264 @@ def cda_annotator_without_template_id(): with open("./tests/data/test_cda_without_template_id.xml", "r") as file: test_cda_without_template_id = file.read() return CdaAnnotator.from_xml(test_cda_without_template_id) + + +@pytest.fixture +def real_config_dir(): + """Use the actual config directory for testing""" + project_root = Path(__file__).parent.parent + config_dir = project_root / "configs" + + if not config_dir.exists(): + pytest.skip("Actual config directory not found. Skipping ConfigManager tests.") + + return config_dir + + +@pytest.fixture +def config_fixtures(): + """Create temporary directory with config files for testing both ConfigManager and InteropConfigManager.""" + with tempfile.TemporaryDirectory() as temp_dir: + config_dir = Path(temp_dir) + + # Create defaults.yaml + defaults_file = config_dir / "defaults.yaml" + defaults_content = { + # Based on the actual defaults.yaml structure + "defaults": { + "common": { + "id_prefix": "hc-", + "timestamp": "%Y%m%d", + "reference_name": "#{uuid}name", + "subject": {"reference": "Patient/example"}, + }, + "resources": { + "Condition": { + "clinicalStatus": { + "coding": [ + { + "system": "http://terminology.hl7.org/CodeSystem/condition-clinical", + "code": "unknown", + "display": "Unknown", + } + ] + } + }, + "MedicationStatement": { + "status": "unknown", + "effectiveDateTime": "{{ now | date: '%Y-%m-%d' }}", + }, + }, + }, + # Add interop-specific configs for InteropConfigManager tests + "interop": {"base_url": "https://api.example.com", "timeout": 30}, + } + + # Create environments directory and files + env_dir = config_dir / "environments" + env_dir.mkdir() + + dev_file = env_dir / "development.yaml" + dev_content = { + "database": {"name": "healthchain_dev"}, + "debug": True, + "interop": {"base_url": "https://dev-api.example.com"}, + } + + test_file = env_dir / "testing.yaml" + test_content = {"database": {"name": "healthchain_test"}, "debug": True} + + prod_file = env_dir / "production.yaml" + prod_content = { + "database": {"host": "db.example.com", "name": "healthchain_prod"}, + "debug": False, + } + + # Create module directory with config files + interop_dir = config_dir / "interop" + interop_dir.mkdir() + + # Create sections directory and files + sections_dir = interop_dir / "sections" + sections_dir.mkdir() + + # Problems section - needs to comply with ProblemSectionTemplateConfig + problems_file = sections_dir / "problems.yaml" + problems_content = { + "resource": "Condition", + "resource_template": "cda_fhir/condition", + "entry_template": "cda_fhir/problem_entry", + "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", + }, + "template": { + "act": { + "template_id": ["2.16.840.1.113883.10.20.1.27"], + "status_code": "completed", + }, + "problem_obs": { + "type_code": "SUBJ", + "inversion_ind": False, + "template_id": ["1.3.6.1.4.1.19376.1.5.3.1.4.5"], + "code": "55607006", + "code_system": "2.16.840.1.113883.6.96", + "status_code": "completed", + }, + "clinical_status_obs": { + "template_id": "2.16.840.1.113883.10.20.1.50", + "code": "33999-4", + "code_system": "2.16.840.1.113883.6.1", + "status_code": "completed", + }, + }, + } + + # Medications section - needs to comply with MedicationSectionTemplateConfig + medications_file = sections_dir / "medications.yaml" + medications_content = { + "resource": "MedicationStatement", + "resource_template": "cda_fhir/medication", + "entry_template": "cda_fhir/medication_entry", + "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", + }, + "template": { + "substance_admin": { + "template_id": ["2.16.840.1.113883.10.20.1.24"], + "status_code": "completed", + "class_code": "SBADM", + "mood_code": "EVN", + }, + "manufactured_product": { + "template_id": ["2.16.840.1.113883.10.20.1.53"], + "code": "200000", + "code_system": "2.16.840.1.113883.6.88", + }, + "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", + "status_code": "completed", + }, + }, + } + + # Allergies section - needs to comply with AllergySectionTemplateConfig + allergies_file = sections_dir / "allergies.yaml" + allergies_content = { + "resource": "AllergyIntolerance", + "resource_template": "cda_fhir/allergy", + "entry_template": "cda_fhir/allergy_entry", + "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", + }, + "template": { + "act": { + "template_id": ["2.16.840.1.113883.10.20.1.27"], + "status_code": "completed", + }, + "allergy_obs": { + "template_id": ["2.16.840.1.113883.10.20.1.18"], + "code": "416098002", + "code_system": "2.16.840.1.113883.6.96", + "status_code": "completed", + }, + "reaction_obs": { + "template_id": ["2.16.840.1.113883.10.20.1.54"], + "code": "59037007", + "code_system": "2.16.840.1.113883.6.96", + }, + "severity_obs": { + "template_id": ["2.16.840.1.113883.10.20.1.55"], + "code": "39579001", + "code_system": "2.16.840.1.113883.6.96", + }, + "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", + "status_code": "completed", + }, + }, + } + + # Create document directory and file + document_dir = interop_dir / "document" + document_dir.mkdir() + + # Document config - needs to comply with DocumentConfig + ccd_file = document_dir / "ccd.yaml" + ccd_content = { + "type_id": {"root": "2.16.840.1.113883.1.3", "extension": "POCD_HD000040"}, + "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", + "templates": {"section": "cda_section", "entry": "cda_entry"}, + "structure": { + "header": {"include_patient": True, "include_author": True}, + "body": {"structured_body": True}, + }, + } + + # Create mappings directory + mappings_dir = config_dir / "mappings" + mappings_dir.mkdir() + + mapping_file = mappings_dir / "snomed_loinc.yaml" + mapping_content = { + "snomed_to_loinc": {"55607006": "11450-4", "73211009": "10160-0"} + } + + # Create templates directory + templates_dir = config_dir / "templates" + templates_dir.mkdir() + + # Write all the files + with open(defaults_file, "w") as f: + yaml.dump(defaults_content, f) + + with open(dev_file, "w") as f: + yaml.dump(dev_content, f) + + with open(test_file, "w") as f: + yaml.dump(test_content, f) + + with open(prod_file, "w") as f: + yaml.dump(prod_content, f) + + with open(problems_file, "w") as f: + yaml.dump(problems_content, f) + + with open(medications_file, "w") as f: + yaml.dump(medications_content, f) + + with open(allergies_file, "w") as f: + yaml.dump(allergies_content, f) + + with open(ccd_file, "w") as f: + yaml.dump(ccd_content, f) + + with open(mapping_file, "w") as f: + yaml.dump(mapping_content, f) + + yield config_dir diff --git a/tests/test_config_base.py b/tests/test_config_base.py new file mode 100644 index 00000000..2b205a28 --- /dev/null +++ b/tests/test_config_base.py @@ -0,0 +1,177 @@ +import os +import pytest +from pathlib import Path +from unittest.mock import patch + +from healthchain.config.base import ( + ConfigManager, + ValidationLevel, + _deep_merge, + _get_nested_value, +) + + +# Test utility functions - essential foundation +def test_utility_functions(): + """Test utility functions for deep merging and nested access.""" + # Test deep_merge + target = {"a": 1, "b": {"c": 2, "d": 3}} + source = {"b": {"e": 4}, "f": 5} + _deep_merge(target, source) + assert target == {"a": 1, "b": {"c": 2, "d": 3, "e": 4}, "f": 5} + + # Test _get_nested_value + data = {"a": 1, "b": {"c": 2, "d": {"e": 3}}} + assert _get_nested_value(data, ["a"]) == 1 + assert _get_nested_value(data, ["b", "c"]) == 2 + assert _get_nested_value(data, ["b", "d", "e"]) == 3 + assert _get_nested_value(data, ["x"]) is None # Missing key + + +# Core functionality tests +def test_config_manager_initialization(config_fixtures): + """Test initialization and environment detection.""" + config_dir = config_fixtures + + # Test with default params + manager = ConfigManager(config_dir) + assert manager._validation_level == ValidationLevel.STRICT + assert manager._module is None + assert manager._environment == "development" # Default + + # Test with custom params + manager = ConfigManager( + config_dir, validation_level=ValidationLevel.WARN, module="interop" + ) + assert manager._validation_level == ValidationLevel.WARN + assert manager._module == "interop" + + # Test environment detection + with patch.dict(os.environ, {"HEALTHCHAIN_ENV": "production"}): + manager = ConfigManager(Path("/fake/path")) + assert manager._environment == "production" + + # Test invalid environment falls back to default + with patch.dict(os.environ, {"HEALTHCHAIN_ENV": "invalid_env"}): + manager = ConfigManager(Path("/fake/path")) + assert manager._environment == "development" + + +def test_config_loading_and_access(config_fixtures): + """Test loading configurations and accessing values.""" + config_dir = config_fixtures + + # Test loading defaults and environment configs + manager = ConfigManager(config_dir) + manager.load() + + # Test defaults are loaded + assert manager._defaults["defaults"]["common"]["id_prefix"] == "hc-" + + # Test environment config is loaded + assert manager._env_configs["debug"] is True + + # Test loading with explicit environment + manager = ConfigManager(config_dir) + manager.load(environment="production") + assert manager._environment == "production" + assert manager._env_configs["database"]["host"] == "db.example.com" + + # Test getting values with precedence + assert ( + manager.get_config_value("defaults.common.id_prefix") == "hc-" + ) # From defaults + assert ( + manager.get_config_value("database.name") == "healthchain_prod" + ) # From environment + assert ( + manager.get_config_value("nonexistent.key", "default") == "default" + ) # Default value + + # Test module configs - this was test_module_specific_configs + manager = ConfigManager(config_dir, module="interop") + manager.load() + configs = manager.get_configs() + + # Check sections are available + assert "sections" in configs + assert configs["sections"]["problems"]["resource"] == "Condition" + + # Check document configs are nested properly + assert "document" in configs + assert configs["document"]["ccd"]["code"]["code"] == "34133-9" + + # Test mappings access - was test_load_mappings + mappings = manager.get_mappings() + assert "snomed_loinc" in mappings + assert mappings["snomed_loinc"]["snomed_to_loinc"]["55607006"] == "11450-4" + + +def test_validation_and_error_handling(config_fixtures): + """Test validation levels and error handling.""" + config_dir = config_fixtures + + # Test validation levels + manager = ConfigManager(config_dir, validation_level=ValidationLevel.STRICT) + assert manager._validation_level == ValidationLevel.STRICT + + manager = ConfigManager(config_dir, validation_level=ValidationLevel.WARN) + assert manager._validation_level == ValidationLevel.WARN + + manager = ConfigManager(config_dir, validation_level=ValidationLevel.IGNORE) + assert manager._validation_level == ValidationLevel.IGNORE + + # Test setting validation level + manager.set_validation_level(ValidationLevel.WARN) + assert manager._validation_level == ValidationLevel.WARN + + # Test invalid validation level + with pytest.raises(ValueError): + manager.set_validation_level("invalid_level") + + # Test error handling with missing files (was test_missing_files) + config_dir = config_fixtures + + # Remove defaults file if it exists + defaults_file = config_dir / "defaults.yaml" + if defaults_file.exists(): + temp_content = defaults_file.read_bytes() # Save content to restore later + defaults_file.unlink() + try: + manager = ConfigManager(config_dir) + manager.load() + # Should work but with empty defaults + assert manager._defaults == {} + finally: + # Restore the file + defaults_file.write_bytes(temp_content) + + # Test nonexistent environment + manager = ConfigManager(config_dir) + manager.load(environment="nonexistent") + # Should have empty env configs but not fail + assert manager._env_configs == {} + + +# Simplified real-world test +def test_with_real_configs(real_config_dir): + """Test with real configuration files.""" + # Create manager with IGNORE validation to focus on structure + manager = ConfigManager(real_config_dir, validation_level=ValidationLevel.IGNORE) + manager.load() + + # Test 1: Check basic configs + assert manager._defaults + assert "defaults" in manager._defaults + assert manager._env_configs + + # Test 2: Check module configs if available + if real_config_dir.joinpath("interop").exists(): + manager = ConfigManager( + real_config_dir, module="interop", validation_level=ValidationLevel.IGNORE + ) + manager.load() + configs = manager.get_configs() + + # Verify expected structure exists + assert "sections" in configs or "document" in configs diff --git a/tests/test_interop_config_manager.py b/tests/test_interop_config_manager.py new file mode 100644 index 00000000..35a511fe --- /dev/null +++ b/tests/test_interop_config_manager.py @@ -0,0 +1,183 @@ +import pytest +import tempfile + +from pathlib import Path +from unittest.mock import patch, MagicMock + +from healthchain.config.base import ValidationLevel +from healthchain.interop.config_manager import InteropConfigManager + + +# Mocks for validation functions +@pytest.fixture +def mock_validators(): + """Mock the validation functions.""" + with patch( + "healthchain.interop.config_manager.validate_section_config_model" + ) as mock_section_validator, patch( + "healthchain.interop.config_manager.validate_document_config_model" + ) as mock_doc_validator: + # Configure mocks to return True by default + mock_section_validator.return_value = True + mock_doc_validator.return_value = True + + yield {"section": mock_section_validator, "document": mock_doc_validator} + + +def test_interop_config_manager_initialization(config_fixtures): + """Test initialization of InteropConfigManager.""" + config_dir = config_fixtures + + # Test with default params + manager = InteropConfigManager(config_dir) + assert manager._validation_level == ValidationLevel.STRICT + assert manager._module == "interop" + assert manager._environment == "development" + + # Test with custom environment + manager = InteropConfigManager( + config_dir, validation_level=ValidationLevel.WARN, environment="production" + ) + assert manager._validation_level == ValidationLevel.WARN + assert manager._environment == "production" + + +def test_get_section_configs(config_fixtures): + """Test getting section configurations.""" + config_dir = config_fixtures + + # Create manager - validation happens at initialization + manager = InteropConfigManager(config_dir) + + # Get section configs + sections = manager.get_section_configs() + + # Check if sections were loaded correctly + assert "problems" in sections + assert sections["problems"]["resource"] == "Condition" + assert sections["problems"]["identifiers"]["display"] == "Problem List" + + assert "medications" in sections + assert sections["medications"]["resource"] == "MedicationStatement" + + +def test_get_document_config(config_fixtures, mock_validators): + """Test getting document configurations.""" + config_dir = config_fixtures + + # Reset and configure validators to succeed + mock_validators["section"].return_value = True + mock_validators["document"].return_value = True + + # Create manager - validation happens at initialization + manager = InteropConfigManager(config_dir) + + # Get document config + ccd_config = manager.get_document_config("ccd") + + # Check if document config was loaded correctly + assert ccd_config["code"]["code"] == "34133-9" + assert ccd_config["code"]["display"] == "Summarization of Episode Note" + assert ccd_config["templates"]["section"] == "cda_section" + + # Also verify document types can be found (replaces test_find_document_types) + document_types = manager._find_document_types() + assert "ccd" in document_types + + +def test_validation_behavior(config_fixtures, mock_validators): + """Test validation behavior with different levels and explicit validation.""" + config_dir = config_fixtures + + # Test 1: STRICT validation failure during initialization + mock_validators["section"].return_value = False + with pytest.raises(ValueError): + InteropConfigManager(config_dir, validation_level=ValidationLevel.STRICT) + + # Test 2: WARN validation - should not raise exception despite validation failure + manager = InteropConfigManager(config_dir, validation_level=ValidationLevel.WARN) + assert manager._loaded # Should still load despite validation failure + + # Test 3: IGNORE validation - shouldn't call validators at all + mock_validators["section"].reset_mock() + mock_validators["document"].reset_mock() + + manager = InteropConfigManager(config_dir, validation_level=ValidationLevel.IGNORE) + assert manager._loaded + assert mock_validators["section"].call_count == 0 + + # Test 4: Explicit validation with different levels + mock_validators["section"].reset_mock() + mock_validators["section"].return_value = False + + # With STRICT validation, should raise exception on explicit validate() + manager._validation_level = ValidationLevel.STRICT + with pytest.raises(ValueError): + manager.validate() + + # With WARN validation, should return False to indicate validation failed but execution continues + manager._validation_level = ValidationLevel.WARN + assert manager.validate() is False + + # With IGNORE validation, should always return True regardless of validation result + manager._validation_level = ValidationLevel.IGNORE + assert manager.validate() is True + + +def test_registration_methods(): + """Test registration methods for custom models.""" + with tempfile.TemporaryDirectory() as temp_dir: + config_dir = Path(temp_dir) + + # Create minimal interop directory structure + interop_dir = config_dir / "interop" + interop_dir.mkdir(parents=True) + + # Create manager with IGNORE validation to simplify test + with patch( + "healthchain.interop.config_manager.register_template_config_model" + ) as mock_register_template, patch( + "healthchain.interop.config_manager.register_document_config_model" + ) as mock_register_document: + manager = InteropConfigManager( + config_dir, validation_level=ValidationLevel.IGNORE + ) + + # Register models and verify registration calls + model = MagicMock() + manager.register_section_template_config("Condition", model) + manager.register_document_config("ccd", model) + + mock_register_template.assert_called_once_with("Condition", model) + mock_register_document.assert_called_once_with("ccd", model) + + +def test_with_real_configs(real_config_dir, mock_validators): + """Test with real configuration files.""" + # Configure validators to succeed + mock_validators["section"].return_value = True + mock_validators["document"].return_value = True + + # Create manager with IGNORE validation to focus on structure tests + manager = InteropConfigManager( + real_config_dir, validation_level=ValidationLevel.IGNORE + ) + + # Test 1: Check section configs + sections = manager.get_section_configs() + assert len(sections) > 0 + + # Check at least one section has expected structure + for section_name, section in sections.items(): + assert "resource" in section + assert "identifiers" in section + break + + # Test 2: Check document configs + document_types = manager._find_document_types() + assert len(document_types) > 0 + + if "ccd" in document_types: + ccd_config = manager.get_document_config("ccd") + assert "code" in ccd_config + assert "code" in ccd_config["code"] diff --git a/tests/test_validators.py b/tests/test_validators.py new file mode 100644 index 00000000..d20f36b4 --- /dev/null +++ b/tests/test_validators.py @@ -0,0 +1,91 @@ +import pytest +from pydantic import ValidationError +from healthchain.config.validators import ( + ProblemSectionTemplateConfig, + validate_document_config_model, + register_template_config_model, + TEMPLATE_CONFIG_REGISTRY, + SECTION_VALIDATORS, + AllergySectionTemplateConfig, +) + + +class TestCustomValidations: + """Test domain-specific validation logic""" + + def test_custom_field_validators(self): + """Test custom field validators in template models""" + # Test problem_obs validator in ConditionTemplateConfig + invalid_condition = { + "act": {"template_id": "2.16.840.1.113883.10.20.22.4.3"}, + "problem_obs": { + "template_id": "2.16.840.1.113883.10.20.22.4.4", + # Missing required fields + }, + "clinical_status_obs": { + "template_id": "2.16.840.1.113883.10.20.22.4.5", + "code": "789012", + "code_system": "2.16.840.1.113883.6.1", + "status_code": "active", + }, + } + with pytest.raises(ValidationError) as excinfo: + ProblemSectionTemplateConfig(**invalid_condition) + + # Verify the specific validation error relates to our custom validator + assert "problem_obs" in str(excinfo.value) + + # Test allergy_obs validator in AllergyTemplateConfig + invalid_allergy = { + "act": {"template_id": "2.16.840.1.113883.10.20.22.4.30"}, + "allergy_obs": { + "template_id": "2.16.840.1.113883.10.20.22.4.7", + # Missing required fields + }, + "clinical_status_obs": { + "template_id": "2.16.840.1.113883.10.20.22.4.28", + "code": "789012", + "code_system": "2.16.840.1.113883.6.1", + "status_code": "active", + }, + } + with pytest.raises(ValidationError) as excinfo: + AllergySectionTemplateConfig(**invalid_allergy) + + assert "allergy_obs" in str(excinfo.value) + + +class TestPublicAPI: + """Minimal tests for public API functions""" + + def test_document_validation(self): + """Test document validation function with simple example""" + # Missing required field in document config + invalid_document = { + "type_id": {"root": "2.16.840.1.113883.10.20.22.1.2"}, + "confidentiality_code": {"code": "N"}, + # Missing code field + } + assert validate_document_config_model("ccd", invalid_document) is False + + def test_registration_functions(self): + """Verify registration functions add models to registries""" + from pydantic import BaseModel + + class TestModel(BaseModel): + test_field: str + + # Record original state + orig_count = len(TEMPLATE_CONFIG_REGISTRY) + + # Register model + register_template_config_model("TestResource", TestModel) + + # Verify registration + assert len(TEMPLATE_CONFIG_REGISTRY) == orig_count + 1 + assert "TestResource" in TEMPLATE_CONFIG_REGISTRY + assert "TestResource" in SECTION_VALIDATORS + + # Clean up + TEMPLATE_CONFIG_REGISTRY.pop("TestResource") + SECTION_VALIDATORS.pop("TestResource") From fa479ce549bfe9e4a4c5dce01a8f028635ffaf72 Mon Sep 17 00:00:00 2001 From: jenniferjiangkells Date: Mon, 31 Mar 2025 16:56:23 +0100 Subject: [PATCH 17/25] Clean up default config behaviour --- configs/environments/development.yaml | 2 +- configs/interop/document/ccd.yaml | 37 +------- configs/mappings/shared_mappings.yaml | 3 +- .../cda_fhir/allergy_intolerance.liquid | 6 -- .../cda_fhir/cda_allergy_entry.liquid | 6 +- .../cda_fhir/cda_medication_entry.liquid | 4 +- .../cda_fhir/cda_problem_entry.liquid | 6 +- configs/templates/cda_fhir/condition.liquid | 43 ++++----- .../cda_fhir/medication_statement.liquid | 45 ++++----- healthchain/config/validators.py | 7 ++ healthchain/interop/engine.py | 6 +- healthchain/interop/generators/cda.py | 27 ++++-- healthchain/interop/generators/fhir.py | 6 +- healthchain/interop/parsers/cda.py | 4 +- tests/{ => configs}/test_config_base.py | 4 +- .../test_interop_config_manager.py | 2 +- tests/configs/test_validators.py | 60 ++++++++++++ tests/conftest.py | 2 +- tests/test_models.py | 20 ---- tests/test_validators.py | 91 ------------------- 20 files changed, 158 insertions(+), 223 deletions(-) rename tests/{ => configs}/test_config_base.py (97%) rename tests/{ => configs}/test_interop_config_manager.py (98%) create mode 100644 tests/configs/test_validators.py delete mode 100644 tests/test_models.py delete mode 100644 tests/test_validators.py diff --git a/configs/environments/development.yaml b/configs/environments/development.yaml index a143a927..03ce37ec 100644 --- a/configs/environments/development.yaml +++ b/configs/environments/development.yaml @@ -26,7 +26,7 @@ defaults: common: id_prefix: "dev-" # Development-specific ID prefix subject: - reference: "Patient/dev-example" + reference: "Patient/Foo" # Template settings for development templates: diff --git a/configs/interop/document/ccd.yaml b/configs/interop/document/ccd.yaml index 03bd745b..da0815b0 100644 --- a/configs/interop/document/ccd.yaml +++ b/configs/interop/document/ccd.yaml @@ -1,5 +1,5 @@ -# CDA Document Configuration -# This file contains configuration for CDA documents +# CCD Document Configuration +# This file contains configuration for CCD documents # Basic document information code: @@ -19,8 +19,8 @@ template_id: root: "1.2.840.114350.1.72.1.51693" templates: + document: "cda_document" section: "cda_section" - entry: "cda_entry" # Document structure structure: @@ -36,37 +36,6 @@ structure: structured_body: true non_xml_body: false -# Default values -defaults: - # Default patient information - patient: - id: "example" - id_root: "2.16.840.1.113883.19.5" - name: - given: "John" - family: "Doe" - gender: "M" - birth_date: "19700101" - - # Default author information - author: - id: "author1" - id_root: "2.16.840.1.113883.19.5" - name: - given: "Jane" - family: "Smith" - organization: - id: "org1" - name: "HealthChain Organization" - - # Default custodian information - custodian: - id: "custodian1" - id_root: "2.16.840.1.113883.19.5" - organization: - id: "org1" - name: "HealthChain Organization" - # Rendering configuration rendering: # XML formatting diff --git a/configs/mappings/shared_mappings.yaml b/configs/mappings/shared_mappings.yaml index 6eef2001..343e42f3 100644 --- a/configs/mappings/shared_mappings.yaml +++ b/configs/mappings/shared_mappings.yaml @@ -4,11 +4,12 @@ code_systems: "2.16.840.1.113883.6.96": "http://snomed.info/sct" "2.16.840.1.113883.6.1": "http://loinc.org" "2.16.840.1.113883.6.88": "http://www.nlm.nih.gov/research/umls/rxnorm" + "2.16.840.1.113883.3.26.1.1": "http://ncicb.nci.nih.gov/xml/owl/EVS/Thesaurus.owl" fhir_to_cda: "http://snomed.info/sct": "2.16.840.1.113883.6.96" "http://loinc.org": "2.16.840.1.113883.6.1" "http://www.nlm.nih.gov/research/umls/rxnorm": "2.16.840.1.113883.6.88" - "http://terminology.hl7.org/CodeSystem/condition-clinical": "2.16.840.1.113883.6.96" + "http://ncicb.nci.nih.gov/xml/owl/EVS/Thesaurus.owl": "2.16.840.1.113883.3.26.1.1" status_codes: cda_to_fhir: diff --git a/configs/templates/cda_fhir/allergy_intolerance.liquid b/configs/templates/cda_fhir/allergy_intolerance.liquid index 543934d6..c8fd6424 100644 --- a/configs/templates/cda_fhir/allergy_intolerance.liquid +++ b/configs/templates/cda_fhir/allergy_intolerance.liquid @@ -1,6 +1,5 @@ { "resourceType": "AllergyIntolerance", - "id": "{{ entry.id | generate_id }}", {% if entry.act.entryRelationship.size %} {% assign obs = entry.act.entryRelationship[0].observation %} {% else %} @@ -42,11 +41,6 @@ }] }, {% endif %} - - "patient": { - "reference": "Patient/{{ entry.subject_id | default: 'Foo' }}" - }, - {% if obs.effectiveTime.low['@value'] %} "onsetDateTime": "{{ obs.effectiveTime.low['@value'] | format_date }}", {% endif %} diff --git a/configs/templates/cda_fhir/cda_allergy_entry.liquid b/configs/templates/cda_fhir/cda_allergy_entry.liquid index 776182c7..e1eea8d0 100644 --- a/configs/templates/cda_fhir/cda_allergy_entry.liquid +++ b/configs/templates/cda_fhir/cda_allergy_entry.liquid @@ -7,7 +7,9 @@ {"@root": "{{template_id}}"} {% if forloop.last != true %},{% endif %} {% endfor %} ], - "id": {"@root": "{{ resource.id | generate_id }}"}, + {% if resource.id %} + "id": {"@root": "{{ resource.id }}"}, + {% endif %} "code": {"@nullFlavor": "NA"}, "statusCode": { "@code": "{{ config.template.act.status_code }}" @@ -26,7 +28,9 @@ {"@root": "{{template_id}}"} {% if forloop.last != true %},{% endif %} {% endfor %} ], + {% if resource.id %} "id": {"@root": "{{ resource.id }}_obs"}, + {% endif %} "text": { "reference": {"@value": "{{ text_reference_name }}"} }, diff --git a/configs/templates/cda_fhir/cda_medication_entry.liquid b/configs/templates/cda_fhir/cda_medication_entry.liquid index 5d5e2bd8..65f00954 100644 --- a/configs/templates/cda_fhir/cda_medication_entry.liquid +++ b/configs/templates/cda_fhir/cda_medication_entry.liquid @@ -7,7 +7,9 @@ {"@root": "{{template_id}}"} {% if forloop.last != true %},{% endif %} {% endfor %} ], - "id": {"@root": "{{ resource.id | generate_id }}"}, + {% 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": { diff --git a/configs/templates/cda_fhir/cda_problem_entry.liquid b/configs/templates/cda_fhir/cda_problem_entry.liquid index a14b6139..756a9d58 100644 --- a/configs/templates/cda_fhir/cda_problem_entry.liquid +++ b/configs/templates/cda_fhir/cda_problem_entry.liquid @@ -7,7 +7,9 @@ {"@root": "{{template_id}}"} {% if forloop.last != true %},{% endif %} {% endfor %} ], - "id": {"@root": "{{ resource.id | generate_id }}"}, + {% if resource.id %} + "id": {"@root": "{{ resource.id }}"}, + {% endif %} "code": {"@nullFlavor": "NA"}, "statusCode": { "@code": "{{ config.template.act.status_code }}" @@ -26,7 +28,9 @@ {"@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 }}", diff --git a/configs/templates/cda_fhir/condition.liquid b/configs/templates/cda_fhir/condition.liquid index f00d475a..8089a92b 100644 --- a/configs/templates/cda_fhir/condition.liquid +++ b/configs/templates/cda_fhir/condition.liquid @@ -1,25 +1,25 @@ { "resourceType": "Condition", {% if entry.act.entryRelationship.is_array %} - {% assign actEntryRelationship = entry.act.entryRelationship[0] %} + {% assign obs = entry.act.entryRelationship[0].observation %} {% else %} - {% assign actEntryRelationship = entry.act.entryRelationship %} + {% assign obs = entry.act.entryRelationship.observation %} {% endif %} - {% if actEntryRelationship.observation.entryRelationship.observation.code['@code'] == config.identifiers.clinical_status.code %} - {% if actEntryRelationship.observation.entryRelationship.observation.value %} + {% 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": "{{ actEntryRelationship.observation.entryRelationship.observation.value['@code'] | map_status: 'cda_to_fhir' }}" + "code": "{{ obs.entryRelationship.observation.value['@code'] | map_status: 'cda_to_fhir' }}" }, { - "system": "{{ actEntryRelationship.observation.entryRelationship.observation.value['@codeSystem'] | map_system: 'cda_to_fhir' }}", - "code": "{{ actEntryRelationship.observation.entryRelationship.observation.value['@code'] }}", - "display": "{{ actEntryRelationship.observation.entryRelationship.observation.value['@displayName'] }}" + "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": [{ @@ -28,25 +28,22 @@ "code": "problem-list-item", "display": "Problem List Item" }] - }], - {% if actEntryRelationship.observation.value %} + }]{% if obs.value or obs.effectiveTime %},{% endif %} + {% if obs.value %} "code": { "coding": [{ - "system": "{{ actEntryRelationship.observation.value['@codeSystem'] | map_system: 'cda_to_fhir' }}", - "code": "{{ actEntryRelationship.observation.value['@code'] }}", - "display": "{{ actEntryRelationship.observation.value['@displayName'] }}" + "system": "{{ obs.value['@codeSystem'] | map_system: 'cda_to_fhir' }}", + "code": "{{ obs.value['@code'] }}", + "display": "{{ obs.value['@displayName'] }}" }] - }, + }{% if obs.effectiveTime %},{% endif %} {% endif %} - {% if actEntryRelationship.observation.effectiveTime %} - {% if actEntryRelationship.observation.effectiveTime.low %} - "onsetDateTime": "{{ actEntryRelationship.observation.effectiveTime.low['@value'] | format_date }}", + {% if obs.effectiveTime %} + {% if obs.effectiveTime.low %} + "onsetDateTime": "{{ obs.effectiveTime.low['@value'] | format_date }}"{% if obs.effectiveTime.high %},{% endif %} {% endif %} - {% if actEntryRelationship.observation.effectiveTime.high %} - "abatementDateTime": "{{ actEntryRelationship.observation.effectiveTime.high['@value'] | format_date }}", + {% if obs.effectiveTime.high %} + "abatementDateTime": "{{ obs.effectiveTime.high['@value'] | format_date }}" {% endif %} {% endif %} - "subject": { - "reference": "Patient/{{ entry.subject_id | default: 'Foo' }}" - } } diff --git a/configs/templates/cda_fhir/medication_statement.liquid b/configs/templates/cda_fhir/medication_statement.liquid index 1495540e..9d31f1ab 100644 --- a/configs/templates/cda_fhir/medication_statement.liquid +++ b/configs/templates/cda_fhir/medication_statement.liquid @@ -1,55 +1,55 @@ { "resourceType": "MedicationStatement", - "id": "{{ entry.id | generate_id }}", - "status": "{{ entry.statusCode | map_status: 'cda_to_fhir' | default: 'recorded' }}", + {% assign substance_admin = entry.substanceAdministration %} + "status": "{{ substance_admin.statusCode['@code'] | map_status: 'cda_to_fhir' }}", "medication": { "concept": { "coding": [{ - "system": "{{ entry.substanceAdministration.consumable.manufacturedProduct.manufacturedMaterial.code['@codeSystem'] | map_system: 'cda_to_fhir' }}", - "code": "{{ entry.substanceAdministration.consumable.manufacturedProduct.manufacturedMaterial.code['@code'] }}", - "display": "{{ entry.substanceAdministration.consumable.manufacturedProduct.manufacturedMaterial.code['@displayName'] }}" + "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 entry.substanceAdministration.effectiveTime %} + {% if substance_admin.effectiveTime %} , - {% assign effective_period = entry.substanceAdministration.effectiveTime | extract_effective_period %} + {% 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 = entry.substanceAdministration.effectiveTime | extract_effective_timing %} - {% if entry.substanceAdministration.doseQuantity or entry.substanceAdministration.routeCode or effective_timing %},{% 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 = entry.substanceAdministration.effectiveTime | extract_effective_timing %} - {% if entry.substanceAdministration.doseQuantity or entry.substanceAdministration.routeCode or effective_timing %} - {% if entry.substanceAdministration.effectiveTime == nil %},{% endif %} + {% 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 entry.substanceAdministration.doseQuantity %} + {% if substance_admin.doseQuantity %} "doseAndRate": [ { "doseQuantity": { - "value": {{ entry.substanceAdministration.doseQuantity['@value'] }}, - "unit": "{{ entry.substanceAdministration.doseQuantity['@unit'] }}" + "value": {{ substance_admin.doseQuantity['@value'] }}, + "unit": "{{ substance_admin.doseQuantity['@unit'] }}" } } - ]{% if entry.substanceAdministration.routeCode or effective_timing %},{% endif %} + ]{% if substance_admin.routeCode or effective_timing %},{% endif %} {% endif %} - {% if entry.substanceAdministration.routeCode %} + {% if substance_admin.routeCode %} "route": { "coding": [ { - "system": "{{ entry.substanceAdministration.routeCode['@codeSystem'] | map_system: 'cda_to_fhir' }}", - "code": "{{ entry.substanceAdministration.routeCode['@code'] }}", - "display": "{{ entry.substanceAdministration.routeCode['@displayName'] }}" + "system": "{{ substance_admin.routeCode['@codeSystem'] | map_system: 'cda_to_fhir' }}", + "code": "{{ substance_admin.routeCode['@code'] }}", + "display": "{{ substance_admin.routeCode['@displayName'] }}" } ] }{% if effective_timing %},{% endif %} @@ -66,9 +66,4 @@ } ] {% endif %} - - , - "subject": { - "reference": "Patient/{{ entry.subject_id | default: '123' }}" - } } diff --git a/healthchain/config/validators.py b/healthchain/config/validators.py index 93593f53..94116e4d 100644 --- a/healthchain/config/validators.py +++ b/healthchain/config/validators.py @@ -167,6 +167,13 @@ def validate_confidentiality_code(cls, 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") diff --git a/healthchain/interop/engine.py b/healthchain/interop/engine.py index f438e34e..e7ccea3f 100644 --- a/healthchain/interop/engine.py +++ b/healthchain/interop/engine.py @@ -203,8 +203,10 @@ def _create_default_filters(self) -> Dict[str, Callable]: Returns: Dict of filter names to filter functions """ + # TODO: consider moving mappings to the generator # Get mappings for filter functions mappings = self.config.get_mappings() + id_prefix = self.config.get_config_value("defaults.common.id_prefix") # Create filter functions with access to mappings def map_system_filter(system, direction="fhir_to_cda"): @@ -219,8 +221,8 @@ def format_date_filter(date_str, input_format="%Y%m%d", output_format="iso"): 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, prefix="hc-"): - return generate_id(value, prefix) + def generate_id_filter(value=None): + return generate_id(value, id_prefix) def json_filter(obj): return to_json(obj) diff --git a/healthchain/interop/generators/cda.py b/healthchain/interop/generators/cda.py index cac143d2..5aa3cf0b 100644 --- a/healthchain/interop/generators/cda.py +++ b/healthchain/interop/generators/cda.py @@ -37,12 +37,14 @@ def generate_document_from_fhir_resources( Args: resources: FHIR resources to include in the document + document_type: Type of document to generate + validate: Whether to validate the CDA document Returns: CDA document as XML string """ mapped_entries = self._get_mapped_entries(resources) - sections = self._render_sections(mapped_entries) + sections = self._render_sections(mapped_entries, document_type) # Generate final CDA document return self._render_document(sections, document_type, validate=validate) @@ -120,7 +122,7 @@ def _get_mapped_entries(self, resources: List[Resource]) -> Dict: return section_entries - def _render_sections(self, mapped_entries: Dict) -> List[Dict]: + def _render_sections(self, mapped_entries: Dict, document_type: str) -> List[Dict]: """Render all sections with their entries Args: @@ -138,8 +140,13 @@ def _render_sections(self, mapped_entries: Dict) -> List[Dict]: # Get section template name from config or use default section_template_name = self.config.get_config_value( - "document.cda.templates.section", "cda_section" + f"document.{document_type}.templates.section" ) + if not section_template_name: + raise ValueError( + f"No section template found for document type: {document_type}" + ) + # Get the section template section_template = self.get_template(section_template_name) if not section_template: @@ -183,16 +190,22 @@ def _render_document( f"No document configuration found for document type: {document_type}" ) - # Get document template name from config or use default + # Get document template name from config document_template_name = self.config.get_config_value( - "document.cda.templates.document", "cda_document" + f"document.{document_type}.templates.document" ) + if not document_template_name: + raise ValueError( + f"No document template found for document type: {document_type}" + ) + # Get the document template document_template = self.get_template(document_template_name) if not document_template: raise ValueError(f"Required template '{document_template_name}' not found") # Create document context + # TODO: modify this as bundle metadata is not extracted context = { "config": config, "sections": sections, @@ -205,10 +218,10 @@ def _render_document( # Get XML formatting options pretty_print = self.config.get_config_value( - "document.cda.rendering.xml.pretty_print", True + f"document.{document_type}.rendering.xml.pretty_print", True ) encoding = self.config.get_config_value( - "document.cda.rendering.xml.encoding", "UTF-8" + f"document.{document_type}.rendering.xml.encoding", "UTF-8" ) if validate: out_dict = { diff --git a/healthchain/interop/generators/fhir.py b/healthchain/interop/generators/fhir.py index 7e61ca0e..b04107b7 100644 --- a/healthchain/interop/generators/fhir.py +++ b/healthchain/interop/generators/fhir.py @@ -39,9 +39,7 @@ def convert_cda_entries_to_resources( log.error(f"No resource template found for section {section_key}") return resources - resource_type = self.config.get_config_value( - f"sections.{section_key}.resource", None - ) + resource_type = self.config.get_config_value(f"sections.{section_key}.resource") if not resource_type: log.warning(f"No resource type specified for section {section_key}") return resources @@ -142,7 +140,7 @@ def _add_required_fields(self, resource_dict: Dict, resource_type: str) -> Dict: Dict: The resource dictionary with required fields added """ # Add common fields - id_prefix = self.config.get_config_value("defaults.common.id_prefix") + id_prefix = self.config.get_config_value("defaults.common.id_prefix", "hc-") if "id" not in resource_dict: resource_dict["id"] = f"{id_prefix}{str(uuid.uuid4())}" diff --git a/healthchain/interop/parsers/cda.py b/healthchain/interop/parsers/cda.py index 3e1e441d..945addd8 100644 --- a/healthchain/interop/parsers/cda.py +++ b/healthchain/interop/parsers/cda.py @@ -92,10 +92,10 @@ def _parse_section_entries_from_document(self, section_key: str) -> List[Dict]: # Get template_id and code from config_manager template_id = self.config_manager.get_config_value( - f"sections.{section_key}.identifiers.template_id", None + f"sections.{section_key}.identifiers.template_id" ) code = self.config_manager.get_config_value( - f"sections.{section_key}.identifiers.code", None + f"sections.{section_key}.identifiers.code" ) if template_id and self._find_section_by_template_id( diff --git a/tests/test_config_base.py b/tests/configs/test_config_base.py similarity index 97% rename from tests/test_config_base.py rename to tests/configs/test_config_base.py index 2b205a28..77602253 100644 --- a/tests/test_config_base.py +++ b/tests/configs/test_config_base.py @@ -88,7 +88,7 @@ def test_config_loading_and_access(config_fixtures): manager.get_config_value("nonexistent.key", "default") == "default" ) # Default value - # Test module configs - this was test_module_specific_configs + # Test module configs manager = ConfigManager(config_dir, module="interop") manager.load() configs = manager.get_configs() @@ -129,7 +129,7 @@ def test_validation_and_error_handling(config_fixtures): with pytest.raises(ValueError): manager.set_validation_level("invalid_level") - # Test error handling with missing files (was test_missing_files) + # Test error handling with missing files config_dir = config_fixtures # Remove defaults file if it exists diff --git a/tests/test_interop_config_manager.py b/tests/configs/test_interop_config_manager.py similarity index 98% rename from tests/test_interop_config_manager.py rename to tests/configs/test_interop_config_manager.py index 35a511fe..0d1d7343 100644 --- a/tests/test_interop_config_manager.py +++ b/tests/configs/test_interop_config_manager.py @@ -80,7 +80,7 @@ def test_get_document_config(config_fixtures, mock_validators): assert ccd_config["code"]["display"] == "Summarization of Episode Note" assert ccd_config["templates"]["section"] == "cda_section" - # Also verify document types can be found (replaces test_find_document_types) + # Also verify document types can be found document_types = manager._find_document_types() assert "ccd" in document_types diff --git a/tests/configs/test_validators.py b/tests/configs/test_validators.py new file mode 100644 index 00000000..1c61aaee --- /dev/null +++ b/tests/configs/test_validators.py @@ -0,0 +1,60 @@ +import pytest +from pydantic import ValidationError +from healthchain.config.validators import ( + ProblemSectionTemplateConfig, + validate_document_config_model, + register_template_config_model, + TEMPLATE_CONFIG_REGISTRY, + SECTION_VALIDATORS, +) + + +def test_custom_field_validation(): + """Test critical domain-specific validation logic""" + # Test problem_obs validator in ProblemSectionTemplateConfig + invalid_condition = { + "act": {"template_id": "2.16.840.1.113883.10.20.22.4.3"}, + "problem_obs": { + "template_id": "2.16.840.1.113883.10.20.22.4.4", + # Missing required fields + }, + "clinical_status_obs": { + "template_id": "2.16.840.1.113883.10.20.22.4.5", + "code": "789012", + "code_system": "2.16.840.1.113883.6.1", + "status_code": "active", + }, + } + with pytest.raises(ValidationError) as excinfo: + ProblemSectionTemplateConfig(**invalid_condition) + + # Verify the specific validation error relates to our custom validator + assert "problem_obs" in str(excinfo.value) + + +def test_core_functionality(): + """Test essential public API functions""" + # Test document validation + invalid_document = { + "type_id": {"root": "2.16.840.1.113883.10.20.22.1.2"}, + "confidentiality_code": {"code": "N"}, + # Missing code field + } + assert validate_document_config_model("ccd", invalid_document) is False + + # Test template registration + from pydantic import BaseModel + + class TestModel(BaseModel): + test_field: str + + # Record original state and register model + register_template_config_model("TestResource", TestModel) + + # Verify registration + assert "TestResource" in TEMPLATE_CONFIG_REGISTRY + assert "TestResource" in SECTION_VALIDATORS + + # Clean up + TEMPLATE_CONFIG_REGISTRY.pop("TestResource") + SECTION_VALIDATORS.pop("TestResource") diff --git a/tests/conftest.py b/tests/conftest.py index 6661de7b..6658bd07 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -749,7 +749,7 @@ def config_fixtures(): "code_system": "2.16.840.1.113883.5.25", }, "language_code": "en-US", - "templates": {"section": "cda_section", "entry": "cda_entry"}, + "templates": {"section": "cda_section", "document": "cda_document"}, "structure": { "header": {"include_patient": True, "include_author": True}, "body": {"structured_body": True}, diff --git a/tests/test_models.py b/tests/test_models.py deleted file mode 100644 index ec3953ab..00000000 --- a/tests/test_models.py +++ /dev/null @@ -1,20 +0,0 @@ -from healthchain.models.hooks import ( - EncounterDischargeContext, - PatientViewContext, - OrderSelectContext, - OrderSignContext, -) - - -def test_default_id_generator(): - encounter_discharge = EncounterDischargeContext() - patient_view = PatientViewContext() - order_select = OrderSelectContext( - selections=["Example/123"], draftOrders={"name": "example", "id": "123"} - ) - order_sign = OrderSignContext(draftOrders={"name": "example", "id": "123"}) - - assert encounter_discharge - assert patient_view - assert order_select - assert order_sign diff --git a/tests/test_validators.py b/tests/test_validators.py deleted file mode 100644 index d20f36b4..00000000 --- a/tests/test_validators.py +++ /dev/null @@ -1,91 +0,0 @@ -import pytest -from pydantic import ValidationError -from healthchain.config.validators import ( - ProblemSectionTemplateConfig, - validate_document_config_model, - register_template_config_model, - TEMPLATE_CONFIG_REGISTRY, - SECTION_VALIDATORS, - AllergySectionTemplateConfig, -) - - -class TestCustomValidations: - """Test domain-specific validation logic""" - - def test_custom_field_validators(self): - """Test custom field validators in template models""" - # Test problem_obs validator in ConditionTemplateConfig - invalid_condition = { - "act": {"template_id": "2.16.840.1.113883.10.20.22.4.3"}, - "problem_obs": { - "template_id": "2.16.840.1.113883.10.20.22.4.4", - # Missing required fields - }, - "clinical_status_obs": { - "template_id": "2.16.840.1.113883.10.20.22.4.5", - "code": "789012", - "code_system": "2.16.840.1.113883.6.1", - "status_code": "active", - }, - } - with pytest.raises(ValidationError) as excinfo: - ProblemSectionTemplateConfig(**invalid_condition) - - # Verify the specific validation error relates to our custom validator - assert "problem_obs" in str(excinfo.value) - - # Test allergy_obs validator in AllergyTemplateConfig - invalid_allergy = { - "act": {"template_id": "2.16.840.1.113883.10.20.22.4.30"}, - "allergy_obs": { - "template_id": "2.16.840.1.113883.10.20.22.4.7", - # Missing required fields - }, - "clinical_status_obs": { - "template_id": "2.16.840.1.113883.10.20.22.4.28", - "code": "789012", - "code_system": "2.16.840.1.113883.6.1", - "status_code": "active", - }, - } - with pytest.raises(ValidationError) as excinfo: - AllergySectionTemplateConfig(**invalid_allergy) - - assert "allergy_obs" in str(excinfo.value) - - -class TestPublicAPI: - """Minimal tests for public API functions""" - - def test_document_validation(self): - """Test document validation function with simple example""" - # Missing required field in document config - invalid_document = { - "type_id": {"root": "2.16.840.1.113883.10.20.22.1.2"}, - "confidentiality_code": {"code": "N"}, - # Missing code field - } - assert validate_document_config_model("ccd", invalid_document) is False - - def test_registration_functions(self): - """Verify registration functions add models to registries""" - from pydantic import BaseModel - - class TestModel(BaseModel): - test_field: str - - # Record original state - orig_count = len(TEMPLATE_CONFIG_REGISTRY) - - # Register model - register_template_config_model("TestResource", TestModel) - - # Verify registration - assert len(TEMPLATE_CONFIG_REGISTRY) == orig_count + 1 - assert "TestResource" in TEMPLATE_CONFIG_REGISTRY - assert "TestResource" in SECTION_VALIDATORS - - # Clean up - TEMPLATE_CONFIG_REGISTRY.pop("TestResource") - SECTION_VALIDATORS.pop("TestResource") From 3c680f92063e777ec5928347081df9eb57113ce5 Mon Sep 17 00:00:00 2001 From: jenniferjiangkells Date: Thu, 3 Apr 2025 19:48:38 +0100 Subject: [PATCH 18/25] Fixed some templates --- configs/mappings/shared_mappings.yaml | 3 + .../cda_fhir/allergy_intolerance.liquid | 53 +++--- .../cda_fhir/cda_allergy_entry.liquid | 151 ++++++++++++------ 3 files changed, 138 insertions(+), 69 deletions(-) diff --git a/configs/mappings/shared_mappings.yaml b/configs/mappings/shared_mappings.yaml index 343e42f3..6d0ebe4e 100644 --- a/configs/mappings/shared_mappings.yaml +++ b/configs/mappings/shared_mappings.yaml @@ -10,6 +10,9 @@ code_systems: "http://loinc.org": "2.16.840.1.113883.6.1" "http://www.nlm.nih.gov/research/umls/rxnorm": "2.16.840.1.113883.6.88" "http://ncicb.nci.nih.gov/xml/owl/EVS/Thesaurus.owl": "2.16.840.1.113883.3.26.1.1" + "http://terminology.hl7.org/CodeSystem/condition-clinical": "2.16.840.1.113883.6.96" + "http://terminology.hl7.org/CodeSystem/allergyintolerance-clinical": "2.16.840.1.113883.6.96" + status_codes: cda_to_fhir: diff --git a/configs/templates/cda_fhir/allergy_intolerance.liquid b/configs/templates/cda_fhir/allergy_intolerance.liquid index c8fd6424..e77f4a34 100644 --- a/configs/templates/cda_fhir/allergy_intolerance.liquid +++ b/configs/templates/cda_fhir/allergy_intolerance.liquid @@ -1,51 +1,64 @@ { - "resourceType": "AllergyIntolerance", {% 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 %} - {% if clinical_status %} + {% 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'] }}" - }] - }, + "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'] }}" - }] - }, + , + "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 }}", + , + "onsetDateTime": "{{ obs.effectiveTime.low['@value'] | format_date }}" {% endif %} - {% assign reactions = obs | extract_reactions: config %} + {% if reactions.size > 0 %} + , "reaction": [ {% for reaction in reactions %} { diff --git a/configs/templates/cda_fhir/cda_allergy_entry.liquid b/configs/templates/cda_fhir/cda_allergy_entry.liquid index e1eea8d0..71352dfb 100644 --- a/configs/templates/cda_fhir/cda_allergy_entry.liquid +++ b/configs/templates/cda_fhir/cda_allergy_entry.liquid @@ -79,67 +79,120 @@ "name": "{{ resource.code.coding[0].display }}" } } - }{% if resource.reaction %}, + }{% 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 %} "entryRelationship": { - "@typeCode": "MFST", + "@typeCode": "REFR", + "@inversionInd": true, "observation": { "@classCode": "OBS", "@moodCode": "EVN", "templateId": [ - {% for template_id in config.template.reaction_obs.template_id %} + {% for template_id in config.template.clinical_status_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 }}"} + "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": "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'}}" - } - } + "@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 %} From b4004be74a3dc9c9ac221e4d7cac84574dd24aca Mon Sep 17 00:00:00 2001 From: jenniferjiangkells Date: Thu, 3 Apr 2025 19:51:36 +0100 Subject: [PATCH 19/25] Tidied, refactored, and added tests --- healthchain/config/__init__.py | 16 +- healthchain/config/validators.py | 26 +- healthchain/fhir/__init__.py | 2 + healthchain/fhir/helpers.py | 26 +- healthchain/interop/config_manager.py | 160 +++++-- healthchain/interop/engine.py | 262 ++++++----- healthchain/interop/filters.py | 67 ++- healthchain/interop/generators/cda.py | 91 ++-- healthchain/interop/generators/fhir.py | 96 ++-- healthchain/interop/parsers/cda.py | 124 +++-- healthchain/interop/template_registry.py | 44 +- healthchain/interop/template_renderer.py | 103 +++-- healthchain/interop/utils.py | 76 ---- .../test_config_base.py | 0 .../test_validators.py | 14 +- tests/conftest.py | 28 ++ tests/integration_tests/test_full_workflow.py | 56 --- .../test_interop_engine_integration.py | 226 ++++++++++ tests/interop/__init__.py | 5 + tests/interop/test_cda_generator.py | 422 ++++++++++++++++++ tests/interop/test_cda_parser.py | 192 ++++++++ tests/interop/test_engine.py | 247 ++++++++++ tests/interop/test_fhir_generator.py | 303 +++++++++++++ tests/interop/test_filters.py | 189 ++++++++ .../test_interop_config_manager.py | 36 +- tests/interop/test_template_registry.py | 147 ++++++ tests/interop/test_template_renderer.py | 132 ++++++ 27 files changed, 2592 insertions(+), 498 deletions(-) delete mode 100644 healthchain/interop/utils.py rename tests/{configs => config_manager}/test_config_base.py (100%) rename tests/{configs => config_manager}/test_validators.py (80%) delete mode 100644 tests/integration_tests/test_full_workflow.py create mode 100644 tests/integration_tests/test_interop_engine_integration.py create mode 100644 tests/interop/__init__.py create mode 100644 tests/interop/test_cda_generator.py create mode 100644 tests/interop/test_cda_parser.py create mode 100644 tests/interop/test_engine.py create mode 100644 tests/interop/test_fhir_generator.py create mode 100644 tests/interop/test_filters.py rename tests/{configs => interop}/test_interop_config_manager.py (83%) create mode 100644 tests/interop/test_template_registry.py create mode 100644 tests/interop/test_template_renderer.py diff --git a/healthchain/config/__init__.py b/healthchain/config/__init__.py index de5886f7..11e6b739 100644 --- a/healthchain/config/__init__.py +++ b/healthchain/config/__init__.py @@ -11,17 +11,17 @@ ValidationLevel, ) from healthchain.config.validators import ( - validate_section_config_model, - validate_document_config_model, - register_template_config_model, - register_document_config_model, + 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_section_config_model", - "validate_document_config_model", - "register_template_config_model", - "register_document_config_model", + "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/validators.py b/healthchain/config/validators.py index 94116e4d..73abacab 100644 --- a/healthchain/config/validators.py +++ b/healthchain/config/validators.py @@ -181,18 +181,18 @@ def validate_templates(cls, v): # Registries and Factory Functions # -TEMPLATE_CONFIG_REGISTRY = { +CDA_SECTION_CONFIG_REGISTRY = { "Condition": ProblemSectionTemplateConfig, "MedicationStatement": MedicationSectionTemplateConfig, "AllergyIntolerance": AllergySectionTemplateConfig, } -DOCUMENT_CONFIG_REGISTRY = { +CDA_DOCUMENT_CONFIG_REGISTRY = { "ccd": DocumentConfig, } -def create_section_validator( +def create_cda_section_validator( resource_type: str, template_model: Type[BaseModel] ) -> Type[BaseModel]: """Create a section validator for a specific resource type""" @@ -214,8 +214,8 @@ def validate_template(cls, v): SECTION_VALIDATORS = { - resource_type: create_section_validator(resource_type, template_model) - for resource_type, template_model in TEMPLATE_CONFIG_REGISTRY.items() + resource_type: create_cda_section_validator(resource_type, template_model) + for resource_type, template_model in CDA_SECTION_CONFIG_REGISTRY.items() } # @@ -223,7 +223,7 @@ def validate_template(cls, v): # -def validate_section_config_model( +def validate_cda_section_config_model( section_key: str, section_config: Dict[str, Any] ) -> bool: """Validate a section configuration""" @@ -246,11 +246,11 @@ def validate_section_config_model( return False -def validate_document_config_model( +def validate_cda_document_config_model( document_type: str, document_config: Dict[str, Any] ) -> bool: """Validate a document configuration""" - validator = DOCUMENT_CONFIG_REGISTRY.get(document_type.lower()) + 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 @@ -268,20 +268,20 @@ def validate_document_config_model( # -def register_template_config_model( +def register_cda_section_template_config_model( resource_type: str, template_model: Type[BaseModel] ) -> None: """Register a custom template model for a section""" - TEMPLATE_CONFIG_REGISTRY[resource_type] = template_model - SECTION_VALIDATORS[resource_type] = create_section_validator( + 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_document_config_model( +def register_cda_document_template_config_model( document_type: str, document_model: Type[BaseModel] ) -> None: """Register a custom document model""" - DOCUMENT_CONFIG_REGISTRY[document_type.lower()] = 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/config_manager.py b/healthchain/interop/config_manager.py index 04f67768..68ca8280 100644 --- a/healthchain/interop/config_manager.py +++ b/healthchain/interop/config_manager.py @@ -5,21 +5,41 @@ """ import logging -from typing import Dict, Optional, List +from typing import Dict, Optional, List, Type + +from pydantic import BaseModel from healthchain.config.base import ConfigManager, ValidationLevel from healthchain.config.validators import ( - register_document_config_model, - register_template_config_model, - validate_document_config_model, - validate_section_config_model, + 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""" + """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, @@ -27,12 +47,21 @@ def __init__( validation_level: str = ValidationLevel.STRICT, environment: Optional[str] = None, ): - """Initialize the InteropConfigManager + """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 - environment: Optional environment to use + 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") @@ -45,32 +74,32 @@ def __init__( self.validate() - def _find_sections_config(self) -> Dict: - """Find section configs in the module configs + def _find_cda_sections_config(self) -> Dict: + """Find CDA section configs in the module configs Returns: Dict of sections, or empty dict if none found """ return self._find_config_section(module_name="interop", section_name="sections") - def _find_document_config(self, document_type: str) -> Dict: - """Find document configuration for a specific document type + def _find_cda_document_config(self, document_type: str) -> Dict: + """Find CDA document configuration for a specific document type Args: document_type: Type of document (e.g., "ccd", "discharge") Returns: - Document configuration dict or empty dict if not found + CDA document configuration dict or empty dict if not found """ return self._find_config_section( module_name="interop", section_name="document", subsection=document_type ) - def _find_document_types(self) -> List[str]: - """Find available document types in the configs + def _find_cda_document_types(self) -> List[str]: + """Find available CDA document types in the configs Returns: - List of document type strings + List of CDA document type strings """ document_types = [] @@ -93,30 +122,52 @@ def _find_document_types(self) -> List[str]: return document_types - def get_section_configs(self) -> Dict: - """Get section configurations + 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 of section configurations + 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 """ - sections = self._find_sections_config() + sections = self._find_cda_sections_config() if not sections: log.warning("No section configs found") return {} + if section_key is not None: + if section_key not in sections: + raise ValueError(f"Section configuration not found: {section_key}") + return sections[section_key] + return sections - def get_document_config(self, document_type: str) -> Dict: - """Get document configuration for a specific document type + 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") + document_type: Type of document (e.g., "ccd", "discharge") to get config for Returns: - Document configuration dict or empty dict if not found + Dict containing the document configuration if found, empty dict if not found + or if document_type is invalid """ - document_config = self._find_document_config(document_type) + document_config = self._find_cda_document_config(document_type) if not document_config: return {} @@ -124,9 +175,20 @@ def get_document_config(self, document_type: str) -> Dict: return document_config def validate(self) -> bool: - """Validate that all required configurations are present for the interop module + """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. - Behavior depends on the validation_level setting. + 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 @@ -134,13 +196,13 @@ def validate(self) -> bool: is_valid = super().validate() # Validate section configs - section_configs = self._find_sections_config() + section_configs = self._find_cda_sections_config() 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_section_config_model(section_key, section_config) + 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}" @@ -148,11 +210,11 @@ def validate(self) -> bool: # Validate document configs - but don't fail if no documents are configured # since some use cases might not require documents - document_types = self._find_document_types() + document_types = self._find_cda_document_types() for doc_type in document_types: - doc_config = self._find_document_config(doc_type) + doc_config = self._find_cda_document_config(doc_type) if doc_config: - result = validate_document_config_model(doc_type, 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}" @@ -160,22 +222,32 @@ def validate(self) -> bool: return is_valid - def register_section_template_config( - self, resource_type: str, template_model + def register_cda_section_config( + self, resource_type: str, config_model: Type[BaseModel] ) -> None: - """Register a custom template model + """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 - template_model: Pydantic model for corresponding section template validation + 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_template_config_model(resource_type, template_model) + 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. - def register_document_config(self, document_type: str, document_model) -> None: - """Register a custom document model + 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 (e.g., "ccd", "discharge") - document_model: Pydantic model for document validation + document_type: Document type identifier (e.g., "ccd", "discharge") + config_model: Pydantic model class that defines the validation schema for the document config """ - register_document_config_model(document_type, document_model) + register_cda_document_template_config_model(document_type, config_model) diff --git a/healthchain/interop/engine.py b/healthchain/interop/engine.py index e7ccea3f..6372655a 100644 --- a/healthchain/interop/engine.py +++ b/healthchain/interop/engine.py @@ -2,11 +2,12 @@ from functools import cached_property from enum import Enum -from typing import Dict, List, Union, Optional, Callable +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 @@ -17,21 +18,7 @@ from healthchain.interop.generators.cda import CDAGenerator from healthchain.interop.generators.fhir import FHIRGenerator from healthchain.interop.generators.hl7v2 import HL7v2Generator -from healthchain.interop.filters import ( - format_date, - map_system, - map_status, - clean_empty, - format_timestamp, - generate_id, - to_json, - extract_effective_period, - extract_effective_timing, - extract_clinical_status, - extract_reactions, - map_severity, -) -from healthchain.interop.utils import normalize_resource_list +from healthchain.interop.filters import create_default_filters log = logging.getLogger(__name__) @@ -43,6 +30,7 @@ class FormatType(Enum): def validate_format(format_type: Union[str, FormatType]) -> FormatType: + """Validate and convert format type to enum""" if isinstance(format_type, str): try: return FormatType[format_type.upper()] @@ -52,23 +40,54 @@ def validate_format(format_type: Union[str, FormatType]) -> FormatType: return format_type +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, source_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("section.problems.resource") + + # Register custom components: + engine.register_parser(FormatType.CDA, custom_parser) + engine.register_generator(FormatType.FHIR, custom_generator) + engine.register_template_validator("Condition", condition_model) """ def __init__( @@ -92,7 +111,12 @@ def __init__( self.template_registry = TemplateRegistry(template_dir) # Create and register default filters - default_filters = self._create_default_filters() + # Get required configuration for filters + mappings = self.config.get_mappings() + 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 @@ -130,10 +154,13 @@ 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 + format_type: The format type to get a parser for (CDA or HL7v2) Returns: - The parser instance + 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: @@ -151,10 +178,13 @@ 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 + format_type: The format type to get a generator for (CDA, HL7v2, or FHIR) Returns: - The generator instance + 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: @@ -172,112 +202,60 @@ def _get_generator(self, format_type: FormatType): return self._generators[format_type] def register_parser(self, format_type: FormatType, parser_instance): - """Register a custom parser for a format + """Register a custom parser for a format type. Args: - format_type: The format type to register the parser for - parser_instance: The parser instance + format_type: The format type (CDA, HL7v2) to register the parser for + parser_instance: The parser instance that implements the parsing logic Returns: - Self for method chaining + 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 + """Register a custom generator for a format type. Args: - format_type: The format type to register the generator for - generator_instance: The generator instance + format_type: The format type (CDA, HL7v2, FHIR) to register the generator for + generator_instance: The generator instance that implements the generation logic Returns: - Self for method chaining + InteropEngine: Returns self for method chaining + + Example: + engine.register_generator(FormatType.CDA, CustomCDAGenerator()) """ self._generators[format_type] = generator_instance return self - def _create_default_filters(self) -> Dict[str, Callable]: - """Create and return default filter functions for templates - - Returns: - Dict of filter names to filter functions - """ - # TODO: consider moving mappings to the generator - # Get mappings for filter functions - mappings = self.config.get_mappings() - id_prefix = self.config.get_config_value("defaults.common.id_prefix") - - # 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, - } - - def register_template_validator( - self, resource_type: str, template_model + def register_cda_section_config_validator( + self, resource_type: str, template_model: BaseModel ) -> "InteropEngine": - """Register a custom template validator model for a resource type + """Register a custom section config validator model for a resource type Args: - resource_type: FHIR resource type (e.g., "Condition", "MedicationStatement") - template_model: Pydantic model for template validation + 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_section_template_config(resource_type, template_model) + self.config.register_cda_section_config(resource_type, template_model) return self - def register_document_validator( - self, document_type: str, document_model + def register_cda_document_config_validator( + self, document_type: str, document_model: BaseModel ) -> "InteropEngine": """Register a custom document validator model for a document type @@ -287,38 +265,79 @@ def register_document_validator( 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_document_config(document_type, document_model) + self.config.register_cda_document_config(document_type, document_model) return self def to_fhir( self, source_data: str, source_format: Union[str, FormatType] ) -> List[Resource]: - """Convert source format to FHIR resources""" - format_type = validate_format(source_format) + """Convert source data to FHIR resources + + Args: + source_data: Input data as string (CDA XML or HL7v2 message) + source_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 source_format is not supported + + Example: + # Convert CDA XML to FHIR resources + fhir_resources = engine.to_fhir(cda_xml, source_format="cda") + """ + source_format = validate_format(source_format) - if format_type == FormatType.CDA: + if source_format == FormatType.CDA: return self._cda_to_fhir(source_data) - elif format_type == FormatType.HL7V2: + elif source_format == FormatType.HL7V2: return self._hl7v2_to_fhir(source_data) else: - raise ValueError(f"Unsupported format: {format_type}") + raise ValueError(f"Unsupported format: {source_format}") def from_fhir( self, - resources: List[Resource], - format_type: Union[str, FormatType], + resources: Union[List[Resource], Bundle], + dest_format: Union[str, FormatType], **kwargs, ) -> str: - """Convert FHIR resources to HL7v2 or CDA""" - format_type = validate_format(format_type) + """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 format_type == FormatType.HL7V2: + if dest_format == FormatType.HL7V2: return self._fhir_to_hl7v2(resources, **kwargs) - elif format_type == FormatType.CDA: + elif dest_format == FormatType.CDA: return self._fhir_to_cda(resources, **kwargs) else: - raise ValueError(f"Unsupported format: {format_type}") + raise ValueError(f"Unsupported format: {dest_format}") def _cda_to_fhir(self, xml: str, **kwargs) -> List[Resource]: """Convert CDA XML to FHIR resources @@ -343,20 +362,18 @@ def _cda_to_fhir(self, xml: str, **kwargs) -> List[Resource]: # Process each section and convert entries to FHIR resources resources = [] for section_key, entries in section_entries.items(): - section_resources = generator.convert_cda_entries_to_resources( + section_resources = generator.generate_resources_from_cda_section_entries( entries, section_key ) resources.extend(section_resources) return resources - def _fhir_to_cda( - self, resources: Union[Resource, List[Resource], Bundle], **kwargs - ) -> str: + def _fhir_to_cda(self, resources: List[Resource], **kwargs) -> str: """Convert FHIR resources to CDA XML Args: - resources: A FHIR Bundle, list of resources, or single resource + 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") @@ -376,17 +393,14 @@ def _fhir_to_cda( log.info(f"Processing CDA document of type: {document_type}") # Get document configuration for this specific document type - doc_config = self.config.get_document_config(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}" ) - # Normalize input to list of resources - resource_list = normalize_resource_list(resources) - return cda_generator.generate_document_from_fhir_resources( - resource_list, document_type + resources, document_type ) def _hl7v2_to_fhir(self, source_data: str) -> List[Resource]: diff --git a/healthchain/interop/filters.py b/healthchain/interop/filters.py index ff480fe1..a47876fc 100644 --- a/healthchain/interop/filters.py +++ b/healthchain/interop/filters.py @@ -1,7 +1,7 @@ import json import uuid from datetime import datetime -from typing import Dict, Any, Optional, List, Union +from typing import Dict, Any, Optional, List, Union, Callable def map_system( @@ -362,3 +362,68 @@ def extract_reactions(observation: Dict, config: Dict) -> List[Dict]: break return reactions + + +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, + } diff --git a/healthchain/interop/generators/cda.py b/healthchain/interop/generators/cda.py index 5aa3cf0b..5fe2624b 100644 --- a/healthchain/interop/generators/cda.py +++ b/healthchain/interop/generators/cda.py @@ -14,11 +14,34 @@ from fhir.resources.resource import Resource from healthchain.interop.models.cda import ClinicalDocument from healthchain.interop.template_renderer import TemplateRenderer -from healthchain.interop.utils import find_section_key_for_resource_type log = logging.getLogger(__name__) +def _find_section_key_for_resource_type( + resource_type: str, section_configs: Dict +) -> Optional[str]: + """Find the appropriate section key for a given resource type + + Args: + resource_type: FHIR resource type + section_configs: Dictionary of section configurations + + Returns: + Section key or None if no matching section found + """ + # Find matching section for resource type + section_key = next( + ( + key + for key, config in section_configs.items() + if config.get("resource") == resource_type + ), + None, + ) + return section_key + + class CDAGenerator(TemplateRenderer): """Handles generation of CDA documents""" @@ -31,14 +54,14 @@ def generate_document_from_fhir_resources( """Generate a complete CDA document from FHIR resources This method handles the entire process of generating a CDA document: - 1. Creating section entries from resources (if not provided) - 2. Rendering sections - 3. Generating the final document + 1. Mapping FHIR resources to CDA sections (config) + 2. Rendering sections (template) + 3. Generating the final document (template) Args: resources: FHIR resources to include in the document document_type: Type of document to generate - validate: Whether to validate the CDA document + validate: Whether to validate the CDA document (default: True) Returns: CDA document as XML string @@ -61,11 +84,11 @@ def _render_entry( config_key: Key identifying the section Returns: - Dictionary representation of the rendered entry + Dictionary representation of the rendered entry (xmltodict) """ try: # Get validated section configuration - section_config = self.get_cda_section_config(config_key) + section_config = self.config.get_cda_section_configs(config_key) timestamp_format = self.config.get_config_value( "defaults.common.timestamp", "%Y%m%d" @@ -86,10 +109,10 @@ def _render_entry( } # Get template and render - template_name = self.get_section_template_name(config_key, "entry") - template = self.get_template(template_name) - if not template: - raise ValueError(f"Required template '{template_name}' not found") + template = self.get_template_from_section_config(config_key, "entry") + if template is None: + log.error(f"Required entry template for '{config_key}' not found") + return None return self.render_template(template, context) @@ -98,22 +121,29 @@ def _render_entry( return None def _get_mapped_entries(self, resources: List[Resource]) -> Dict: - """Map FHIR resources to CDA section entries + """Map FHIR resources to CDA section entries by resource type. Args: - resources: List of FHIR resources + resources: List of FHIR resources to map to CDA entries Returns: - Dictionary mapping section keys to their entries + Dictionary mapping section keys (e.g. 'problems', 'medications') to lists of + their rendered CDA entries. For example: + { + 'problems': [, ...], + 'medications': [, ...] + } """ section_entries = {} for resource in resources: # Find matching section for resource type resource_type = resource.__class__.__name__ - all_configs = self.config.get_section_configs() - section_key = find_section_key_for_resource_type(resource_type, all_configs) - + all_configs = self.config.get_cda_section_configs() + section_key = _find_section_key_for_resource_type( + resource_type, all_configs + ) if not section_key: + log.error(f"No section config found for resource type: {resource_type}") continue entry = self._render_entry(resource, section_key) @@ -134,7 +164,7 @@ def _render_sections(self, mapped_entries: Dict, document_type: str) -> List[Dic sections = [] # Get validated section configurations - section_configs = self.config.get_section_configs() + section_configs = self.config.get_cda_section_configs() if not section_configs: raise ValueError("No valid configurations found in /sections") @@ -184,7 +214,7 @@ def _render_document( Returns: CDA document as XML string """ - config = self.config.get_document_config(document_type) + config = self.config.get_cda_document_config(document_type) if not config: raise ValueError( f"No document configuration found for document type: {document_type}" @@ -214,7 +244,20 @@ def _render_document( rendered = self.render_template(document_template, context) if validate: - validated = ClinicalDocument(**rendered["ClinicalDocument"]) + if "ClinicalDocument" not in rendered: + log.error( + "Unable to validate document structure: missing ClinicalDocument" + ) + out_dict = rendered + else: + validated = ClinicalDocument(**rendered["ClinicalDocument"]) + out_dict = { + "ClinicalDocument": validated.model_dump( + exclude_none=True, exclude_unset=True, by_alias=True + ) + } + else: + out_dict = rendered # Get XML formatting options pretty_print = self.config.get_config_value( @@ -223,14 +266,6 @@ def _render_document( encoding = self.config.get_config_value( f"document.{document_type}.rendering.xml.encoding", "UTF-8" ) - if validate: - out_dict = { - "ClinicalDocument": validated.model_dump( - exclude_none=True, exclude_unset=True, by_alias=True - ) - } - else: - out_dict = rendered # Generate XML xml_string = xmltodict.unparse( diff --git a/healthchain/interop/generators/fhir.py b/healthchain/interop/generators/fhir.py index b04107b7..32d75c99 100644 --- a/healthchain/interop/generators/fhir.py +++ b/healthchain/interop/generators/fhir.py @@ -11,29 +11,61 @@ from fhir.resources.resource import Resource from healthchain.interop.template_renderer import TemplateRenderer -from healthchain.interop.utils import create_resource +from healthchain.fhir import create_resource_from_dict +from healthchain.interop.template_renderer import Template log = logging.getLogger(__name__) class FHIRGenerator(TemplateRenderer): - """Handles generation of FHIR resources from templates""" + """Handles generation of FHIR resources from templates. - def convert_cda_entries_to_resources( + This class provides functionality to convert CDA section entries into FHIR resources + using configurable templates. It handles validation, required field population, and + error handling during the conversion process. + + Key features: + - Template-based conversion of CDA entries (xmltodict format) to FHIR resources + - Automatic population of required FHIR fields based on configuration for + common resource types like Condition, MedicationStatement, AllergyIntolerance + - Validation of generated FHIR resources + + Example: + generator = FHIRGenerator(config_manager, template_registry) + + # Convert CDA problem entries to FHIR Condition resources + problems = generator.generate_resources_from_cda_section_entries( + entries=problem_entries, + section_key="problems" # from configs + ) + """ + + def generate_resources_from_cda_section_entries( self, entries: List[Dict], section_key: str ) -> List[Dict]: """ - Convert entries from a section to FHIR resource dictionaries + Convert CDA section entries into FHIR resources using configured templates. + + This method processes entries from a CDA section and generates corresponding FHIR + resources based on templates and configuration. It handles validation and error + checking during the conversion process. Args: - entries: List of entries from a section - section_key: Key identifying the section + entries: List of CDA section entries in xmltodict format to convert + section_key: Configuration key identifying the section (e.g. "problems", "medications") + Used to look up templates and resource type mappings Returns: - List of FHIR resource dictionaries + List of validated FHIR resource dictionaries. Empty list if conversion fails. + + Example: + # Convert problem list entries to FHIR Condition resources + conditions = generator.generate_resources_from_cda_section_entries( + problem_entries, "problems" + ) """ resources = [] - template = self.get_section_template(section_key, "resource") + template = self.get_template_from_section_config(section_key, "resource") if not template: log.error(f"No resource template found for section {section_key}") @@ -41,7 +73,7 @@ def convert_cda_entries_to_resources( resource_type = self.config.get_config_value(f"sections.{section_key}.resource") if not resource_type: - log.warning(f"No resource type specified for section {section_key}") + log.error(f"No resource type specified for section {section_key}") return resources for entry in entries: @@ -53,7 +85,7 @@ def convert_cda_entries_to_resources( if not resource_dict: continue - log.debug(f"Generated FHIR resource: {resource_dict}") + log.debug(f"Rendered FHIR resource: {resource_dict}") resource = self._validate_fhir_resource(resource_dict, resource_type) @@ -67,32 +99,26 @@ def convert_cda_entries_to_resources( return resources def _render_resource_from_entry( - self, entry: Dict, section_key: str, template=None + self, entry: Dict, section_key: str, template: Template ) -> Optional[Dict]: - """Render a FHIR resource from a CDA entry + """Renders a FHIR resource dictionary from a CDA entry using templates. Args: - entry: The CDA entry to convert - section_key: The section key (e.g., "problems") - template: Optional template to use for rendering - (if None, the template will be determined from section_key) + entry: CDA entry dictionary + section_key: Section identifier (e.g. "problems") + template: Template to use for rendering Returns: - Optional[Dict]: FHIR resource dictionary or None if rendering failed + FHIR resource dictionary or None if rendering fails """ try: - # Get template if not provided - if template is None: - template = self.get_section_template(section_key, "resource") - if not template: - log.error(f"No resource template found for section {section_key}") - return None - # Get validated section configuration try: - section_config = self.get_cda_section_config(section_key) + section_config = self.config.get_cda_section_configs(section_key) except ValueError as e: - log.error(f"Failed to get section config: {str(e)}") + log.error( + f"Failed to get CDA section config for {section_key}: {str(e)}" + ) return None # Create context with entry data and config @@ -108,20 +134,20 @@ def _render_resource_from_entry( def _validate_fhir_resource( self, resource_dict: Dict, resource_type: str ) -> Optional[Resource]: - """Validate a FHIR resource dictionary + """Validates and creates a FHIR resource from a dictionary. + Adds required fields. Args: - resource_dict: Dictionary representation of the resource - resource_type: Type of FHIR resource to create - config_manager: Configuration manager instance + resource_dict: FHIR resource dictionary + resource_type: FHIR resource type Returns: - Optional[Resource]: FHIR resource instance or None if validation failed + FHIR resource or None if validation fails """ try: resource_dict = self._add_required_fields(resource_dict, resource_type) - resource = create_resource(resource_dict, resource_type) + resource = create_resource_from_dict(resource_dict, resource_type) if resource: return resource except Exception as e: @@ -129,15 +155,15 @@ def _validate_fhir_resource( return None def _add_required_fields(self, resource_dict: Dict, resource_type: str) -> Dict: - """Add required fields to resource dictionary based on type + """Add required fields to FHIR resource dictionary based on resource type. + Currently only supports Condition, MedicationStatement, and AllergyIntolerance. Args: resource_dict: Dictionary representation of the resource resource_type: Type of FHIR resource - config_manager: Configuration manager instance Returns: - Dict: The resource dictionary with required fields added + Dict: Resource dictionary with required fields added """ # Add common fields id_prefix = self.config.get_config_value("defaults.common.id_prefix", "hc-") diff --git a/healthchain/interop/parsers/cda.py b/healthchain/interop/parsers/cda.py index 945addd8..3efdfc69 100644 --- a/healthchain/interop/parsers/cda.py +++ b/healthchain/interop/parsers/cda.py @@ -9,33 +9,67 @@ from typing import Dict, List from healthchain.interop.models.cda import ClinicalDocument -from healthchain.interop.models.sections import Section, Entry -from healthchain.config.base import ConfigManager +from healthchain.interop.models.sections import Section +from healthchain.interop.config_manager import InteropConfigManager log = logging.getLogger(__name__) class CDAParser: - """Parser for CDA XML documents""" + """Parser for CDA XML documents. - def __init__(self, config_manager: ConfigManager): - """Initialize the CDA parser + The CDAParser class provides functionality to parse Clinical Document Architecture (CDA) + XML documents and extract structured data from their sections. It works in conjunction with + the InteropConfigManager to identify and process sections based on configuration. + + Key capabilities: + - Parse complete CDA XML documents + - Extract entries from configured sections based on template IDs or codes + - Convert and validate section entries into structured dictionaries (xmltodict) + + The parser uses configuration from InteropConfigManager to: + - Identify sections by template ID or code + - Map section contents to the appropriate data structures + - Apply any configured transformations + + Attributes: + config (InteropConfigManager): Configuration manager instance + clinical_document (ClinicalDocument): Currently loaded CDA document + """ + + def __init__(self, config: InteropConfigManager): + """Initialize the CDA parser. Args: - config_manager: ConfigManager instance for accessing configuration + config: InteropConfigManager instance containing section configurations, + templates, and mapping rules for CDA document parsing """ - self.config_manager = config_manager + self.config = config self.clinical_document = None def parse_document_sections(self, xml: str) -> Dict[str, List[Dict]]: - """ - Parse a complete CDA document and extract entries from all configured sections. + """Parse a complete CDA document and extract entries from all configured sections. + + This method parses a CDA XML document and extracts entries from each section that is + defined in the configuration. It uses xmltodict to parse the XML into a dictionary + and then processes each configured section to extract its entries. Args: - xml: The CDA XML document + xml: The CDA XML document string to parse Returns: - Dictionary mapping section keys to lists of entry dictionaries + Dict[str, List[Dict]]: Dictionary mapping section keys (e.g. "problems", + "medications") to lists of entry dictionaries containing the parsed data + from that section + + Raises: + ValueError: If the XML string is empty or invalid + Exception: If there is an error parsing the document or any section + + Example: + >>> parser = CDAParser(config) + >>> sections = parser.parse_document_sections(cda_xml) + >>> problems = sections.get("problems", []) """ section_entries = {} @@ -48,7 +82,7 @@ def parse_document_sections(self, xml: str) -> Dict[str, List[Dict]]: return section_entries # Get section configurations - sections = self.config_manager.get_section_configs() + sections = self.config.get_cda_section_configs() if not sections: log.warning("No sections found in configuration") return section_entries @@ -66,18 +100,26 @@ def parse_document_sections(self, xml: str) -> Dict[str, List[Dict]]: return section_entries def _parse_section_entries_from_document(self, section_key: str) -> List[Dict]: - """ - Extract entries from a CDA section using an already parsed document. + """Extract entries from a CDA section using an already parsed document. Args: - section_key: Key identifying the section in the configuration + section_key: Key identifying the section in the configuration (e.g. "problems", + "medications"). Must match a section defined in the configuration. Returns: - List of entry dictionaries from the section + List[Dict]: List of entry dictionaries from the matched section. Each dictionary + contains the parsed data from a single entry in the section. Returns an empty + list if no entries are found or if an error occurs. + + Raises: + ValueError: If no template_id or code is configured for the section_key, or if + no matching section is found in the document. + Exception: If there is an error parsing the section or its entries. """ + entries_dicts = [] if not self.clinical_document: log.error("No document loaded. Call parse_document first.") - return [] + return entries_dicts try: # Get all components @@ -91,13 +133,21 @@ def _parse_section_entries_from_document(self, section_key: str) -> List[Dict]: curr_section = component.section # Get template_id and code from config_manager - template_id = self.config_manager.get_config_value( + template_id = self.config.get_config_value( f"sections.{section_key}.identifiers.template_id" ) - code = self.config_manager.get_config_value( + code = self.config.get_config_value( f"sections.{section_key}.identifiers.code" ) + if not template_id and not code: + raise ValueError( + f"No template_id or code found for section {section_key}: \ + configure one of the following: \ + sections.{section_key}.identifiers.template_id \ + or sections.{section_key}.identifiers.code" + ) + if template_id and self._find_section_by_template_id( curr_section, template_id ): @@ -109,14 +159,26 @@ def _parse_section_entries_from_document(self, section_key: str) -> List[Dict]: break if not section: - log.warning(f"Section not found for key: {section_key}") - return [] + log.warning( + f"Section with template_id: {template_id} or code: {code} not found in CDA document for key: {section_key}" + ) + return entries_dicts + + # Get entries from section + if section.entry: + entries_dicts = ( + section.entry + if isinstance(section.entry, list) + else [section.entry] + ) + else: + log.warning(f"No entries found for section {section_key}") + return entries_dicts - # Get entries and convert to dicts - entries = self._get_section_entries(section) + # Convert entries to dictionaries entry_dicts = [ entry.model_dump(exclude_none=True, by_alias=True) - for entry in entries + for entry in entries_dicts if entry ] @@ -126,10 +188,10 @@ def _parse_section_entries_from_document(self, section_key: str) -> List[Dict]: except Exception as e: log.error(f"Error parsing section {section_key}: {str(e)}") - return [] + return entries_dicts def _find_section_by_template_id(self, section: Section, template_id: str) -> bool: - """Check if section matches template ID""" + """Returns True if section has matching template ID""" if not section.templateId: return False @@ -141,11 +203,5 @@ def _find_section_by_template_id(self, section: Section, template_id: str) -> bo return any(tid.root == template_id for tid in template_ids) def _find_section_by_code(self, section: Section, code: str) -> bool: - """Check if section matches code""" - return section.code and section.code.code == code - - def _get_section_entries(self, section: Section) -> List[Entry]: - """Get list of entries from section""" - if not section.entry: - return [] - return section.entry if isinstance(section.entry, list) else [section.entry] + """Returns True if section has matching code""" + return bool(section.code and section.code.code == code) diff --git a/healthchain/interop/template_registry.py b/healthchain/interop/template_registry.py index 02dfd218..c3865016 100644 --- a/healthchain/interop/template_registry.py +++ b/healthchain/interop/template_registry.py @@ -8,7 +8,25 @@ class TemplateRegistry: - """Manages loading and accessing Liquid templates for the InteropEngine""" + """Manages loading and accessing Liquid templates for the InteropEngine. + + The TemplateRegistry handles loading Liquid template files from a directory and making them + available for rendering. It supports custom filter functions that can be used within templates. + + Key features: + - Loads .liquid template files recursively from a directory + - Supports adding custom filter functions + - Provides template lookup by name + - Validates template existence + + Example: + registry = TemplateRegistry(Path("templates")) + registry.initialize({ + "uppercase": str.upper, + "lowercase": str.lower + }) + template = registry.get_template("my_template") + """ def __init__(self, template_dir: Path): """Initialize the TemplateRegistry @@ -25,13 +43,24 @@ def __init__(self, template_dir: Path): raise ValueError(f"Template directory not found: {template_dir}") def initialize(self, filters: Dict[str, Callable] = None) -> "TemplateRegistry": - """Initialize the Liquid environment and load templates + """Initialize the Liquid environment and load templates. + + This method sets up the Liquid template environment by: + 1. Storing any provided filter functions + 2. Creating the Liquid environment with the template directory + 3. Loading all template files from the directory + + The environment must be initialized before templates can be loaded or rendered. Args: - filters: Dictionary of filter names to filter functions + filters: Optional dictionary mapping filter names to filter functions that can be used + in templates. For example: {"uppercase": str.upper} Returns: - Self for method chaining + TemplateRegistry: Returns self for method chaining + + Raises: + ValueError: If template directory does not exist or environment initialization fails """ # Store initial filters if filters: @@ -83,7 +112,12 @@ def add_filters(self, filters: Dict[str, Callable]) -> "TemplateRegistry": return self def _load_templates(self) -> None: - """Load all template files""" + """Load all Liquid template files from the template directory. + + This method recursively walks through the template directory and its subdirectories + to find all .liquid template files. Each template is loaded into the environment + and stored in the internal template registry with its stem name as the key. + """ if not self._env: raise ValueError("Environment not initialized. Call initialize() first.") diff --git a/healthchain/interop/template_renderer.py b/healthchain/interop/template_renderer.py index 0624d9cd..600beba5 100644 --- a/healthchain/interop/template_renderer.py +++ b/healthchain/interop/template_renderer.py @@ -9,6 +9,8 @@ from typing import Dict, Any, Optional from pathlib import Path +from liquid import Template + from healthchain.config.base import ConfigManager from healthchain.interop.template_registry import TemplateRegistry from healthchain.interop.filters import clean_empty @@ -17,7 +19,23 @@ class TemplateRenderer: - """Base class for template rendering functionality""" + """Base class for template rendering functionality. + + 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. + + The renderer supports: + - Loading templates by name from the registry + - Looking up templates based on section configurations + - Rendering templates with context data + - Cleaning empty values from rendered output + - JSON parsing of rendered templates + + 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 template renderer @@ -29,7 +47,7 @@ def __init__(self, config: ConfigManager, template_registry: TemplateRegistry): self.config = config self.template_registry = template_registry - def get_template(self, template_name: str): + def get_template(self, template_name: str) -> Optional[Template]: """Get a template by name Args: @@ -44,62 +62,69 @@ def get_template(self, template_name: str): log.warning(f"Template '{template_name}' not found") return None - def get_section_template_name( + def get_template_from_section_config( self, section_key: str, template_type: str - ) -> Optional[str]: - """Get the template name for a given section and template type + ) -> 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, + extracts the template name (stem) from the path, and retrieves the template from + the registry if it exists. Args: - section_key: Key identifying the section - template_type: Type of template (e.g., 'resource', 'entry') + 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 name or None if not found + Template instance if found and valid, None if template name not in config + or template not found in registry + + Example: + >>> renderer.get_template_from_section_config('problems', 'entry') +