Skip to content
Merged
Show file tree
Hide file tree
Changes from 17 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: actions/setup-python@v5
- uses: actions/setup-python@v6
with:
python-version: "3.12"
- name: Install dependencies
Expand All @@ -34,7 +34,7 @@ jobs:
python-version: ["3.10", "3.11", "3.12", "3.13"]
steps:
- uses: actions/checkout@v6
- uses: actions/setup-python@v5
- uses: actions/setup-python@v6
with:
python-version: ${{ matrix.python-version }}
- name: Install dependencies
Expand All @@ -58,7 +58,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: actions/setup-python@v5
- uses: actions/setup-python@v6
with:
python-version: "3.12"
- name: Install dependencies
Expand Down
6 changes: 3 additions & 3 deletions .github/workflows/publish.yml
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ jobs:
- uses: actions/checkout@v6
with:
fetch-depth: 0 # Required for hatch-vcs to get version from tags
- uses: actions/setup-python@v5
- uses: actions/setup-python@v6
with:
python-version: "3.12"
- name: Install build tools
Expand All @@ -24,7 +24,7 @@ jobs:
- name: Build package
run: python -m build
- name: Upload distributions
uses: actions/upload-artifact@v5
uses: actions/upload-artifact@v7
with:
name: release-dists
path: dist/
Expand All @@ -39,7 +39,7 @@ jobs:
id-token: write # Required for trusted publishing
steps:
- name: Download distributions
uses: actions/download-artifact@v5
uses: actions/download-artifact@v8
with:
name: release-dists
path: dist/
Expand Down
31 changes: 31 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,37 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: The [Unreleased] link still points to v1.7.1...HEAD. Update it to v1.8.1...HEAD now that v1.8.1 is the latest release, consistent with the pattern used by all prior version links.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At CHANGELOG.md, line 8:

<comment>The `[Unreleased]` link still points to `v1.7.1...HEAD`. Update it to `v1.8.1...HEAD` now that v1.8.1 is the latest release, consistent with the pattern used by all prior version links.</comment>

<file context>
@@ -5,6 +5,37 @@ All notable changes to this project will be documented in this file.
 The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
 and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
 
+## [1.8.1] - 2026-06-01
+
+### Changed
</file context>

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: Missing link definitions for [1.8.1] and [1.8.0]. Add reference-style link definitions at the bottom of the file so the version headings render as clickable links.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At CHANGELOG.md, line 8:

<comment>Missing link definitions for `[1.8.1]` and `[1.8.0]`. Add reference-style link definitions at the bottom of the file so the version headings render as clickable links.</comment>

<file context>
@@ -5,6 +5,37 @@ All notable changes to this project will be documented in this file.
 The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
 and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
 
+## [1.8.1] - 2026-06-01
+
+### Changed
</file context>

and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [1.8.1] - 2026-06-01

### Changed

- `Search.semantic()`, `Search.semantic_iter()`, `AsyncSearch.semantic()`, and
`AsyncSearch.semantic_iter()` now call the canonical path
`GET /v1/search/semantic` instead of `GET /v1/concepts/semantic-search`. The
legacy path remains a permanent server-side alias (emits `Deprecation: true`
+ `Link: …rel="successor-version"` headers), so older installations of this
SDK continue to work - no breaking change for callers.

## [1.8.0] - 2026-05-25

### Added

- `FhirResolution` now types the `value_as_concept` and `value_target_field`
fields the resolver returns when a composite concept is decomposed via the
`Maps to value` relationship (HL7 FHIR-to-OMOP IG Value-as-Concept pattern —
e.g. "Allergy to penicillin" → standard "Allergy to drug" + value
"Penicillin G"), plus `concept_map_id` / `mapping_note` for FHIR
administrative-code resolutions. These were already passed through; they are
now part of the typed response shape.
- `resolve()`, `resolve_batch()`, and `resolve_codeable_concept()` accept an
`on_unmapped` argument (`"error"` default / `"sentinel"`). With `"sentinel"`
the resolver returns a `concept_id` 0 record instead of a 404 when nothing
resolves, so ETL callers always get a writable row.
- Coding inputs now carry `user_selected` through to the resolver, and FHIR's
camelCase `userSelected` is mapped to it. A user-selected coding wins over
vocabulary preference in `resolve_codeable_concept()` (FHIR-to-OMOP IG
CodeableConcept pattern).

## [1.7.1] - 2026-05-20

Maintenance release. Dependency and lock-file updates only — there are no
Expand Down
18 changes: 18 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,24 @@ result = client.fhir.resolve_codeable_concept(
print(result["best_match"]["resolution"]["source_concept"]["vocabulary_id"]) # "SNOMED"
```

The resolver also follows the [HL7 FHIR-to-OMOP IG](https://hl7.org/fhir/uv/omop/INFORMATIVE1/en/): it resolves FHIR administrative codes via the IG ConceptMaps, decomposes composite concepts (`Maps to value`), honors `Coding.userSelected`, and can return a `concept_id` 0 sentinel instead of a 404.

```python
# Administrative gender → person.gender_concept_id (via IG ConceptMap)
client.fhir.resolve(system="http://hl7.org/fhir/administrative-gender", code="male")

# A user-selected coding wins over vocabulary preference
client.fhir.resolve_codeable_concept(coding=[
{"system": "http://snomed.info/sct", "code": "44054006"},
{"system": "http://hl7.org/fhir/sid/icd-10-cm", "code": "E11.9", "user_selected": True},
])

# on_unmapped="sentinel" → a concept_id 0 record instead of a 404 (one row per input for ETL)
client.fhir.resolve(system="http://snomed.info/sct", code="00000000", on_unmapped="sentinel")
```

Composite concepts (e.g. "Allergy to penicillin") additionally surface `resolution["value_as_concept"]` (the IG Value-as-Concept pattern). `on_unmapped` is accepted by `resolve()`, `resolve_batch()`, and `resolve_codeable_concept()` on both the sync and async clients.

### Type Interoperability

The resolver accepts any Coding-like input via duck typing - a plain dict, omophub's lightweight `Coding` TypedDict, or any object with `.system` / `.code` attributes (e.g. `fhir.resources.Coding`, `fhirpy` codings).
Expand Down
100 changes: 100 additions & 0 deletions examples/fhir_resolver.py
Original file line number Diff line number Diff line change
Expand Up @@ -489,6 +489,103 @@ def error_handling_examples() -> None:
client.close()


# ---------------------------------------------------------------------------
# 13. Administrative code via the HL7 FHIR-to-OMOP IG ConceptMap layer
# ---------------------------------------------------------------------------


def resolve_administrative_gender() -> None:
"""Resolve a FHIR administrative code (gender) via the IG ConceptMap layer.

Administrative/structural systems (administrative-gender, v3-ActCode
encounter class, condition-clinical, ...) have no OMOP vocabulary-table
representation and resolve through the IG's ConceptMaps.
"""
print("\n=== 13. Administrative gender (IG ConceptMap) ===")

client = omophub.OMOPHub()
try:
result = client.fhir.resolve(
system="http://hl7.org/fhir/administrative-gender",
code="male",
resource_type="Patient",
)
res = result["resolution"]
std = res["standard_concept"]
print(f" male → {std['concept_name']} ({std['concept_id']})")
print(f" target_table: {res['target_table']}")
print(f" via IG ConceptMap: {res.get('concept_map_id')}")
# Composite source concepts also surface a `value_as_concept`
# (the IG 'Maps to value' / Value-as-Concept pattern) when present.
if res.get("value_as_concept"):
val = res["value_as_concept"]
print(
f" value_as_concept: {val['concept_name']}"
f" → {res.get('value_target_field')}"
)
finally:
client.close()


# ---------------------------------------------------------------------------
# 14. CodeableConcept with user_selected (overrides vocabulary preference)
# ---------------------------------------------------------------------------


def resolve_user_selected() -> None:
"""A coding marked user_selected wins best_match over vocabulary preference."""
print("\n=== 14. CodeableConcept with user_selected ===")

client = omophub.OMOPHub()
try:
result = client.fhir.resolve_codeable_concept(
coding=[
# SNOMED would normally win on OHDSI preference, but the
# user-selected ICD-10-CM coding takes precedence.
{"system": "http://snomed.info/sct", "code": "44054006"},
{
"system": "http://hl7.org/fhir/sid/icd-10-cm",
"code": "E11.9",
"user_selected": True,
},
],
resource_type="Condition",
)
best = result["best_match"]
if best:
src = best["resolution"]["source_concept"]
print(f" best_match source vocabulary: {src['vocabulary_id']}")
finally:
client.close()


# ---------------------------------------------------------------------------
# 15. on_unmapped="sentinel" — a concept_id 0 record instead of a 404
# ---------------------------------------------------------------------------


def resolve_unmapped_sentinel() -> None:
"""With on_unmapped='sentinel', an unresolvable code yields a row, not a 404.

Handy for ETL pipelines that need one output row per input. Works on
resolve(), resolve_batch(), and resolve_codeable_concept().
"""
print("\n=== 15. on_unmapped='sentinel' ===")

client = omophub.OMOPHub()
try:
result = client.fhir.resolve(
system="http://snomed.info/sct",
code="00000000",
on_unmapped="sentinel",
)
res = result["resolution"]
print(f" mapping_type: {res['mapping_type']}")
print(f" standard concept_id: {res['standard_concept']['concept_id']}")
finally:
client.close()


# ---------------------------------------------------------------------------
# Main
# ---------------------------------------------------------------------------
Expand All @@ -505,5 +602,8 @@ def error_handling_examples() -> None:
resolve_batch()
resolve_codeable_concept()
resolve_codeable_concept_text_fallback()
resolve_administrative_gender()
resolve_user_selected()
resolve_unmapped_sentinel()
asyncio.run(async_resolve())
error_handling_examples()
Loading
Loading