Skip to content

Commit 0d9844c

Browse files
authored
Fhir resolver (#8)
1 parent eafb8c7 commit 0d9844c

13 files changed

Lines changed: 1199 additions & 216 deletions

File tree

CHANGELOG.md

Lines changed: 27 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,11 +5,33 @@ All notable changes to this project will be documented in this file.
55
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
66
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
77

8+
## [1.6.0] - 2026-04-10
9+
10+
### Added
11+
12+
- **FHIR-to-OMOP Concept Resolver** (`client.fhir`): Translate FHIR coded values into OMOP standard concepts, CDM target tables, and optional Phoebe recommendations in a single API call.
13+
- `resolve()`: Resolve a single FHIR `Coding` (system URI + code) or text-only input via semantic search fallback. Returns the standard concept, target CDM table, domain alignment check, and optional mapping quality signal.
14+
- `resolve_batch()`: Batch-resolve up to 100 FHIR codings per request with inline per-item error reporting. Failed items do not fail the batch.
15+
- `resolve_codeable_concept()`: Resolve a FHIR `CodeableConcept` with multiple codings. Automatically picks the best match per OHDSI vocabulary preference (SNOMED > RxNorm > LOINC > CVX > ICD-10). Falls back to the `text` field via semantic search when no coding resolves.
16+
- New TypedDict types for FHIR resolver: `FhirResolveResult`, `FhirResolution`, `FhirBatchResult`, `FhirBatchSummary`, `FhirCodeableConceptResult`, `ResolvedConcept`, `RecommendedConceptOutput`.
17+
- Both sync (`OMOPHub`) and async (`AsyncOMOPHub`) clients support FHIR resolver methods via `client.fhir.*`.
18+
19+
### Changed
20+
21+
- **Extracted shared response parsing** (`_request.py`): The duplicated JSON decode / error-handling / rate-limit-retry logic across `Request._parse_response`, `Request._parse_response_raw`, `AsyncRequest._parse_response`, and `AsyncRequest._parse_response_raw` (4 copies of ~50 lines each) is now a single `_parse_and_raise()` module-level function. All four methods delegate to it, eliminating the risk of divergence bugs.
22+
- **Fixed `paginate_async` signature** (`_pagination.py`): The type hint now correctly declares `Callable[[int, int], Awaitable[tuple[...]]]` instead of `Callable[[int, int], tuple[...]]`, and the runtime `hasattr(__await__)` duck-typing hack has been replaced with a clean `await`.
23+
- **`AsyncSearch.semantic_iter`** now delegates to `paginate_async` instead of manually reimplementing the pagination loop, matching the sync `semantic_iter` which already uses `paginate_sync`.
24+
25+
### Fixed
26+
27+
- Python prerequisite in CONTRIBUTING.md corrected from `3.9+` to `3.10+` (matching `pyproject.toml`).
28+
- `__all__` in `types/__init__.py` sorted per RUF022.
29+
830
## [1.5.1] - 2026-04-08
931

1032
### Fixed
1133

12-
- **Rate-limit handling**: HTTP client now respects the `Retry-After` header on `429 Too Many Requests` responses and applies exponential backoff with jitter on retries. Previous versions retried only on `502/503/504` with a fixed `2^attempt * 0.5s` schedule and did not back off on `429` at all, so a client that hit the server's rate limit at high volume could burn through thousands of failed requests in a tight loop. The new behavior:
34+
- **Rate-limit handling**: HTTP client now respects the `Retry-After` header on `429 Too Many Requests` responses and applies exponential backoff with jitter on retries. Previous versions retried only on `502/503/504` with a fixed `2^attempt * 0.5s` schedule and did not back off on `429` at all, so a client that hit the server's rate limit at high volume could burn through thousands of failed requests in a tight loop. The client now honors `Retry-After`, uses exponential backoff with jitter, respects the configured `max_retries`, and caps backoff at 30 seconds.
1335
- Updated `examples/search_concepts.py` to reflect current API.
1436

1537
## [1.5.0] - 2026-03-26
@@ -117,7 +139,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
117139
- Full type hints and PEP 561 compliance
118140
- HTTP/2 support via httpx
119141

120-
[Unreleased]: https://github.com/omopHub/omophub-python/compare/v1.4.1...HEAD
142+
[Unreleased]: https://github.com/omopHub/omophub-python/compare/v1.6.0...HEAD
143+
[1.6.0]: https://github.com/omopHub/omophub-python/compare/v1.5.1...v1.6.0
144+
[1.5.1]: https://github.com/omopHub/omophub-python/compare/v1.5.0...v1.5.1
145+
[1.5.0]: https://github.com/omopHub/omophub-python/compare/v1.4.1...v1.5.0
121146
[1.4.1]: https://github.com/omopHub/omophub-python/compare/v1.4.0...v1.4.1
122147
[1.4.0]: https://github.com/omopHub/omophub-python/compare/v1.3.1...v1.4.0
123148
[1.3.1]: https://github.com/omopHub/omophub-python/compare/v1.3.0...v1.3.1

CONTRIBUTING.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -53,7 +53,7 @@ Feature requests are welcome! Please open an issue with:
5353

5454
### Prerequisites
5555

56-
- Python 3.9+
56+
- Python 3.10+
5757
- pip
5858

5959
### Installation

README.md

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,46 @@ mappings = client.mappings.get_by_code("ICD10CM", "E11.9", target_vocabulary="SN
5858
ancestors = client.hierarchy.ancestors(201826, max_levels=3)
5959
```
6060

61+
## FHIR-to-OMOP Resolution
62+
63+
Resolve FHIR coded values to OMOP standard concepts in one call:
64+
65+
```python
66+
# Single FHIR Coding → OMOP concept + CDM target table
67+
result = client.fhir.resolve(
68+
system="http://snomed.info/sct",
69+
code="44054006",
70+
resource_type="Condition",
71+
)
72+
print(result["resolution"]["target_table"]) # "condition_occurrence"
73+
print(result["resolution"]["mapping_type"]) # "direct"
74+
75+
# ICD-10-CM → traverses "Maps to" automatically
76+
result = client.fhir.resolve(
77+
system="http://hl7.org/fhir/sid/icd-10-cm",
78+
code="E11.9",
79+
)
80+
print(result["resolution"]["standard_concept"]["vocabulary_id"]) # "SNOMED"
81+
82+
# Batch resolve up to 100 codings
83+
batch = client.fhir.resolve_batch([
84+
{"system": "http://snomed.info/sct", "code": "44054006"},
85+
{"system": "http://loinc.org", "code": "2339-0"},
86+
{"system": "http://www.nlm.nih.gov/research/umls/rxnorm", "code": "197696"},
87+
])
88+
print(f"Resolved {batch['summary']['resolved']}/{batch['summary']['total']}")
89+
90+
# CodeableConcept with vocabulary preference (SNOMED wins over ICD-10)
91+
result = client.fhir.resolve_codeable_concept(
92+
coding=[
93+
{"system": "http://snomed.info/sct", "code": "44054006"},
94+
{"system": "http://hl7.org/fhir/sid/icd-10-cm", "code": "E11.9"},
95+
],
96+
resource_type="Condition",
97+
)
98+
print(result["best_match"]["resolution"]["source_concept"]["vocabulary_id"]) # "SNOMED"
99+
```
100+
61101
## Semantic Search
62102

63103
Use natural language queries to find concepts using neural embeddings:
@@ -200,6 +240,7 @@ suggestions = client.concepts.suggest("diab", vocabulary_ids=["SNOMED"], page_si
200240
| `mappings` | Cross-vocabulary mappings | `get()`, `map()` |
201241
| `vocabularies` | Vocabulary metadata | `list()`, `get()`, `stats()` |
202242
| `domains` | Domain information | `list()`, `get()`, `concepts()` |
243+
| `fhir` | FHIR-to-OMOP resolution | `resolve()`, `resolve_batch()`, `resolve_codeable_concept()` |
203244

204245
## Configuration
205246

src/omophub/_client.py

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
from ._request import AsyncRequest, Request
1818
from .resources.concepts import AsyncConcepts, Concepts
1919
from .resources.domains import AsyncDomains, Domains
20+
from .resources.fhir import AsyncFhir, Fhir
2021
from .resources.hierarchy import AsyncHierarchy, Hierarchy
2122
from .resources.mappings import AsyncMappings, Mappings
2223
from .resources.relationships import AsyncRelationships, Relationships
@@ -97,6 +98,14 @@ def __init__(
9798
self._mappings: Mappings | None = None
9899
self._vocabularies: Vocabularies | None = None
99100
self._domains: Domains | None = None
101+
self._fhir: Fhir | None = None
102+
103+
@property
104+
def fhir(self) -> Fhir:
105+
"""Access the FHIR resolver resource."""
106+
if self._fhir is None:
107+
self._fhir = Fhir(self._request)
108+
return self._fhir
100109

101110
@property
102111
def concepts(self) -> Concepts:
@@ -228,6 +237,14 @@ def __init__(
228237
self._mappings: AsyncMappings | None = None
229238
self._vocabularies: AsyncVocabularies | None = None
230239
self._domains: AsyncDomains | None = None
240+
self._fhir: AsyncFhir | None = None
241+
242+
@property
243+
def fhir(self) -> AsyncFhir:
244+
"""Access the FHIR resolver resource."""
245+
if self._fhir is None:
246+
self._fhir = AsyncFhir(self._request)
247+
return self._fhir
231248

232249
@property
233250
def concepts(self) -> AsyncConcepts:

src/omophub/_pagination.py

Lines changed: 3 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
from urllib.parse import urlencode
77

88
if TYPE_CHECKING:
9-
from collections.abc import AsyncIterator, Callable, Iterator
9+
from collections.abc import AsyncIterator, Awaitable, Callable, Iterator
1010

1111
from ._types import PaginationMeta
1212

@@ -106,7 +106,7 @@ def paginate_sync(
106106

107107

108108
async def paginate_async(
109-
fetch_page: Callable[[int, int], tuple[list[T], PaginationMeta | None]],
109+
fetch_page: Callable[[int, int], Awaitable[tuple[list[T], PaginationMeta | None]]],
110110
page_size: int = DEFAULT_PAGE_SIZE,
111111
) -> AsyncIterator[T]:
112112
"""Create an async iterator that auto-paginates through results.
@@ -121,12 +121,7 @@ async def paginate_async(
121121
page = 1
122122

123123
while True:
124-
# Note: fetch_page should be an async function
125-
result = fetch_page(page, page_size)
126-
if hasattr(result, "__await__"):
127-
items, meta = await result # type: ignore
128-
else:
129-
items, meta = result
124+
items, meta = await fetch_page(page, page_size)
130125

131126
for item in items:
132127
yield item

0 commit comments

Comments
 (0)