aimedicalcoding 0.3.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- aimedicalcoding/__init__.py +29 -0
- aimedicalcoding/__main__.py +313 -0
- aimedicalcoding/adapters/__init__.py +3 -0
- aimedicalcoding/adapters/base.py +25 -0
- aimedicalcoding/adapters/ccda/__init__.py +3 -0
- aimedicalcoding/adapters/ccda/adapter.py +294 -0
- aimedicalcoding/adapters/ccda/codes.py +198 -0
- aimedicalcoding/adapters/ccda/narrative.py +121 -0
- aimedicalcoding/adapters/ccda/sections.py +44 -0
- aimedicalcoding/adapters/custom_json/__init__.py +3 -0
- aimedicalcoding/adapters/custom_json/adapter.py +30 -0
- aimedicalcoding/adapters/fhir/__init__.py +3 -0
- aimedicalcoding/adapters/fhir/adapter.py +326 -0
- aimedicalcoding/adapters/fhir/codes.py +211 -0
- aimedicalcoding/adapters/fhir/mapping.py +68 -0
- aimedicalcoding/api.py +163 -0
- aimedicalcoding/core/__init__.py +34 -0
- aimedicalcoding/core/checkdigits.py +103 -0
- aimedicalcoding/core/models.py +149 -0
- aimedicalcoding/core/system_registry.py +185 -0
- aimedicalcoding/pipeline/__init__.py +3 -0
- aimedicalcoding/pipeline/orchestrator.py +33 -0
- aimedicalcoding/recovery/__init__.py +3 -0
- aimedicalcoding/recovery/engine.py +16 -0
- aimedicalcoding/serialize.py +257 -0
- aimedicalcoding/systems/__init__.py +3 -0
- aimedicalcoding/systems/base.py +43 -0
- aimedicalcoding/systems/cvx/__init__.py +15 -0
- aimedicalcoding/systems/cvx/handler.py +55 -0
- aimedicalcoding/systems/cvx/normalize.py +75 -0
- aimedicalcoding/systems/cvx/patterns.py +134 -0
- aimedicalcoding/systems/icd10cm/__init__.py +16 -0
- aimedicalcoding/systems/icd10cm/handler.py +55 -0
- aimedicalcoding/systems/icd10cm/icdformat.py +43 -0
- aimedicalcoding/systems/icd10cm/patterns.py +133 -0
- aimedicalcoding/systems/loinc/__init__.py +14 -0
- aimedicalcoding/systems/loinc/axes.py +199 -0
- aimedicalcoding/systems/loinc/handler.py +80 -0
- aimedicalcoding/systems/loinc/normalize.py +81 -0
- aimedicalcoding/systems/loinc/patterns.py +166 -0
- aimedicalcoding/systems/rxnorm/__init__.py +13 -0
- aimedicalcoding/systems/rxnorm/handler.py +55 -0
- aimedicalcoding/systems/rxnorm/normalize.py +88 -0
- aimedicalcoding/systems/rxnorm/patterns.py +143 -0
- aimedicalcoding/systems/snomed/__init__.py +15 -0
- aimedicalcoding/systems/snomed/handler.py +60 -0
- aimedicalcoding/systems/snomed/hierarchy.py +96 -0
- aimedicalcoding/systems/snomed/patterns.py +166 -0
- aimedicalcoding/systems/snomed/qualifiers.py +90 -0
- aimedicalcoding/terminology/__init__.py +3 -0
- aimedicalcoding/terminology/service.py +31 -0
- aimedicalcoding-0.3.1.dist-info/METADATA +276 -0
- aimedicalcoding-0.3.1.dist-info/RECORD +57 -0
- aimedicalcoding-0.3.1.dist-info/WHEEL +5 -0
- aimedicalcoding-0.3.1.dist-info/entry_points.txt +2 -0
- aimedicalcoding-0.3.1.dist-info/licenses/LICENSE +21 -0
- aimedicalcoding-0.3.1.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
"""Scan-and-identify code extraction for CCDA <code> elements.
|
|
2
|
+
|
|
3
|
+
Implements LOINC_pattern_analysis.md §3.4: never predict which slot holds a
|
|
4
|
+
code; enumerate the root <code> + all <translation> children, canonicalize each
|
|
5
|
+
by system, and let downstream pick. Also extracts <value>, effectiveTime,
|
|
6
|
+
interpretation and reference ranges.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from typing import Optional
|
|
12
|
+
|
|
13
|
+
from lxml import etree
|
|
14
|
+
|
|
15
|
+
from ...core.checkdigits import is_valid_loinc, is_valid_sctid
|
|
16
|
+
from ...core.models import (
|
|
17
|
+
CodingCandidate,
|
|
18
|
+
System,
|
|
19
|
+
ValueInfo,
|
|
20
|
+
QN,
|
|
21
|
+
ORD,
|
|
22
|
+
NOM,
|
|
23
|
+
NAR,
|
|
24
|
+
)
|
|
25
|
+
from ...core.system_registry import canonicalize
|
|
26
|
+
|
|
27
|
+
V3 = "urn:hl7-org:v3"
|
|
28
|
+
XSI = "http://www.w3.org/2001/XMLSchema-instance"
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def q(tag: str) -> str:
|
|
32
|
+
"""Qualify an unprefixed tag with the HL7 V3 default namespace."""
|
|
33
|
+
return f"{{{V3}}}{tag}"
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def xsi_type(el: etree._Element) -> Optional[str]:
|
|
37
|
+
t = el.get(f"{{{XSI}}}type")
|
|
38
|
+
if t and ":" in t:
|
|
39
|
+
t = t.split(":", 1)[1]
|
|
40
|
+
return t
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _coding_from_code_el(el: etree._Element, from_translation: bool) -> CodingCandidate:
|
|
44
|
+
raw_system = el.get("codeSystem") or el.get("codeSystemName")
|
|
45
|
+
system = canonicalize(raw_system)
|
|
46
|
+
code = el.get("code")
|
|
47
|
+
cand = CodingCandidate(
|
|
48
|
+
raw_system=raw_system,
|
|
49
|
+
system=system,
|
|
50
|
+
code=code,
|
|
51
|
+
display=el.get("displayName"),
|
|
52
|
+
from_translation=from_translation,
|
|
53
|
+
)
|
|
54
|
+
if code:
|
|
55
|
+
if system == System.LOINC:
|
|
56
|
+
cand.check_digit_valid = is_valid_loinc(code)
|
|
57
|
+
elif system == System.SNOMEDCT:
|
|
58
|
+
cand.check_digit_valid = is_valid_sctid(code)
|
|
59
|
+
return cand
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def scan_codings(code_el: Optional[etree._Element]) -> list[CodingCandidate]:
|
|
63
|
+
"""Return all codings on a <code> element: root + every <translation>.
|
|
64
|
+
|
|
65
|
+
A nullFlavor code with no code attribute still yields a candidate (so the
|
|
66
|
+
handler can classify it as a NULLFLAVOR gap), but only if it carries a
|
|
67
|
+
nullFlavor or displayName/originalText signal.
|
|
68
|
+
"""
|
|
69
|
+
if code_el is None:
|
|
70
|
+
return []
|
|
71
|
+
codings: list[CodingCandidate] = []
|
|
72
|
+
|
|
73
|
+
null_flavor = code_el.get("nullFlavor")
|
|
74
|
+
if code_el.get("code") or code_el.get("codeSystem") or null_flavor:
|
|
75
|
+
root = _coding_from_code_el(code_el, from_translation=False)
|
|
76
|
+
if null_flavor and not root.code:
|
|
77
|
+
root.system = System.NULLFLAVOR
|
|
78
|
+
root.raw_system = null_flavor # the flavor itself: UNK, OTH, NA, NI, …
|
|
79
|
+
codings.append(root)
|
|
80
|
+
|
|
81
|
+
for tr in code_el.findall(q("translation")):
|
|
82
|
+
codings.append(_coding_from_code_el(tr, from_translation=True))
|
|
83
|
+
|
|
84
|
+
return codings
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def best_loinc(codings: list[CodingCandidate]) -> Optional[CodingCandidate]:
|
|
88
|
+
"""Select the canonical LOINC coding (root preferred, must validate)."""
|
|
89
|
+
hits = [
|
|
90
|
+
c
|
|
91
|
+
for c in codings
|
|
92
|
+
if c.system == System.LOINC and c.code and c.check_digit_valid
|
|
93
|
+
]
|
|
94
|
+
if not hits:
|
|
95
|
+
return None
|
|
96
|
+
roots = [c for c in hits if not c.from_translation]
|
|
97
|
+
return roots[0] if roots else hits[0]
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
# --------------------------------------------------------------------------
|
|
101
|
+
# Value extraction
|
|
102
|
+
# --------------------------------------------------------------------------
|
|
103
|
+
|
|
104
|
+
def extract_value(obs_el: etree._Element) -> Optional[ValueInfo]:
|
|
105
|
+
val = obs_el.find(q("value"))
|
|
106
|
+
if val is None:
|
|
107
|
+
return None
|
|
108
|
+
t = xsi_type(val)
|
|
109
|
+
|
|
110
|
+
if t in ("PQ", "REAL", "INT"):
|
|
111
|
+
raw = val.get("value")
|
|
112
|
+
num = _to_float(raw)
|
|
113
|
+
return ValueInfo(
|
|
114
|
+
type=QN, raw=raw, num=num, unit=val.get("unit"),
|
|
115
|
+
unit_system="UCUM" if val.get("unit") else None,
|
|
116
|
+
)
|
|
117
|
+
if t in ("CD", "CE", "CO"):
|
|
118
|
+
# scan the value's root <code> + any <translation> children (e.g. a SNOMED
|
|
119
|
+
# value with an ICD-10-CM translation, or vice-versa) — same structure as
|
|
120
|
+
# a <code> element, so reuse scan_codings.
|
|
121
|
+
codings = scan_codings(val)
|
|
122
|
+
coded = next((c for c in codings if not c.from_translation),
|
|
123
|
+
codings[0] if codings else None)
|
|
124
|
+
vtype = ORD if (val.get("displayName") or "").lower() in _ORDINAL_WORDS else NOM
|
|
125
|
+
return ValueInfo(type=vtype, raw=val.get("displayName") or val.get("code"),
|
|
126
|
+
coded_value=coded, codings=codings)
|
|
127
|
+
if t in ("ST", "ED"):
|
|
128
|
+
return ValueInfo(type=NAR, raw=(val.text or "").strip() or None)
|
|
129
|
+
if t == "IVL_PQ":
|
|
130
|
+
low = val.find(q("low"))
|
|
131
|
+
raw = low.get("value") if low is not None else None
|
|
132
|
+
return ValueInfo(type=QN, raw=raw, num=_to_float(raw),
|
|
133
|
+
unit=low.get("unit") if low is not None else None,
|
|
134
|
+
unit_system="UCUM")
|
|
135
|
+
# unknown type: keep whatever value attr exists
|
|
136
|
+
raw = val.get("value") or (val.text or "").strip() or None
|
|
137
|
+
return ValueInfo(type=None, raw=raw, num=_to_float(val.get("value")),
|
|
138
|
+
unit=val.get("unit"))
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
_ORDINAL_WORDS = {"positive", "negative", "reactive", "non-reactive",
|
|
142
|
+
"detected", "not detected", "present", "absent"}
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def extract_effective_time(obs_el: etree._Element) -> Optional[str]:
|
|
146
|
+
et = obs_el.find(q("effectiveTime"))
|
|
147
|
+
if et is None:
|
|
148
|
+
return None
|
|
149
|
+
if et.get("value"):
|
|
150
|
+
return et.get("value")
|
|
151
|
+
low = et.find(q("low"))
|
|
152
|
+
return low.get("value") if low is not None else None
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def extract_specimen(el: etree._Element) -> Optional[dict]:
|
|
156
|
+
"""Explicit specimen from an observation or organizer:
|
|
157
|
+
specimen/specimenRole/specimenPlayingEntity/code (usually SNOMED).
|
|
158
|
+
Returns {code, system, display} or None.
|
|
159
|
+
"""
|
|
160
|
+
sp = el.find(
|
|
161
|
+
f"{q('specimen')}/{q('specimenRole')}/{q('specimenPlayingEntity')}/{q('code')}"
|
|
162
|
+
)
|
|
163
|
+
if sp is None:
|
|
164
|
+
return None
|
|
165
|
+
return {
|
|
166
|
+
"code": sp.get("code"),
|
|
167
|
+
"system": canonicalize(sp.get("codeSystem")),
|
|
168
|
+
"display": sp.get("displayName"),
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def extract_companion(obs_el: etree._Element) -> dict:
|
|
173
|
+
out: dict = {}
|
|
174
|
+
interp = obs_el.find(q("interpretationCode"))
|
|
175
|
+
if interp is not None and interp.get("code"):
|
|
176
|
+
out["interpretation"] = interp.get("code")
|
|
177
|
+
|
|
178
|
+
# reference range: observationRange/value (IVL_PQ low-high) or text
|
|
179
|
+
rng = obs_el.find(f"{q('referenceRange')}/{q('observationRange')}/{q('value')}")
|
|
180
|
+
if rng is not None:
|
|
181
|
+
low = rng.find(q("low"))
|
|
182
|
+
high = rng.find(q("high"))
|
|
183
|
+
if low is not None or high is not None:
|
|
184
|
+
lo = low.get("value") if low is not None else ""
|
|
185
|
+
hi = high.get("value") if high is not None else ""
|
|
186
|
+
unit = (low.get("unit") if low is not None else None) or (
|
|
187
|
+
high.get("unit") if high is not None else None)
|
|
188
|
+
out["reference_range"] = f"{lo}-{hi}" + (f" {unit}" if unit else "")
|
|
189
|
+
return out
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def _to_float(s: Optional[str]) -> Optional[float]:
|
|
193
|
+
if s is None:
|
|
194
|
+
return None
|
|
195
|
+
try:
|
|
196
|
+
return float(s)
|
|
197
|
+
except (ValueError, TypeError):
|
|
198
|
+
return None
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
"""CCDA narrative handling.
|
|
2
|
+
|
|
3
|
+
Two jobs (LOINC_pattern_analysis.md §3.5):
|
|
4
|
+
1. originalText/<reference value="#id"/> resolution against the section's
|
|
5
|
+
human-readable <text> block.
|
|
6
|
+
2. Narrative-table extraction for sections/rows that have NO structured
|
|
7
|
+
<entry> — the highest-yield "display without code" recovery path.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import re
|
|
13
|
+
from typing import Optional
|
|
14
|
+
|
|
15
|
+
from lxml import etree
|
|
16
|
+
|
|
17
|
+
from .codes import q
|
|
18
|
+
|
|
19
|
+
_NUM_UNIT = re.compile(r"^\s*([<>]?=?\s*-?\d+(?:\.\d+)?)\s*([A-Za-z%/µ°\[\]\d\.\^*-]*)\s*$")
|
|
20
|
+
_RANGE = re.compile(r"^\s*-?\d+(?:\.\d+)?\s*[-–]\s*-?\d+(?:\.\d+)?")
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def text_of(el: etree._Element) -> str:
|
|
24
|
+
"""Flattened text content of a narrative element."""
|
|
25
|
+
return re.sub(r"\s+", " ", "".join(el.itertext())).strip()
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def build_id_map(section_el: etree._Element) -> dict[str, str]:
|
|
29
|
+
"""Map every narrative element carrying an ID/id attribute to its text.
|
|
30
|
+
|
|
31
|
+
Used to resolve <reference value="#id"/> from originalText.
|
|
32
|
+
"""
|
|
33
|
+
text_el = section_el.find(q("text"))
|
|
34
|
+
if text_el is None:
|
|
35
|
+
return {}
|
|
36
|
+
id_map: dict[str, str] = {}
|
|
37
|
+
for el in text_el.iter():
|
|
38
|
+
for attr in ("ID", "id"):
|
|
39
|
+
ref_id = el.get(attr)
|
|
40
|
+
if ref_id:
|
|
41
|
+
id_map[ref_id] = text_of(el)
|
|
42
|
+
return id_map
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def resolve_reference(id_map: dict[str, str], ref: Optional[str]) -> Optional[str]:
|
|
46
|
+
if not ref:
|
|
47
|
+
return None
|
|
48
|
+
return id_map.get(ref.lstrip("#"))
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def original_text(code_el: Optional[etree._Element], id_map: dict[str, str]) -> Optional[str]:
|
|
52
|
+
"""Return originalText for a <code>: inline text or resolved #reference."""
|
|
53
|
+
if code_el is None:
|
|
54
|
+
return None
|
|
55
|
+
ot = code_el.find(q("originalText"))
|
|
56
|
+
if ot is None:
|
|
57
|
+
return None
|
|
58
|
+
ref = ot.find(q("reference"))
|
|
59
|
+
if ref is not None and ref.get("value"):
|
|
60
|
+
resolved = resolve_reference(id_map, ref.get("value"))
|
|
61
|
+
if resolved:
|
|
62
|
+
return resolved
|
|
63
|
+
inline = text_of(ot)
|
|
64
|
+
return inline or None
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def parse_narrative_table(section_el: etree._Element) -> list[dict]:
|
|
68
|
+
"""Extract rows from a section's narrative <table>(s).
|
|
69
|
+
|
|
70
|
+
Heuristic, source-tolerant: first cell is the analyte label; we then scan
|
|
71
|
+
remaining cells for a value+unit and a reference range. Returns a list of
|
|
72
|
+
{"label", "value_raw", "num", "unit", "reference_range", "row_index"}.
|
|
73
|
+
"""
|
|
74
|
+
text_el = section_el.find(q("text"))
|
|
75
|
+
if text_el is None:
|
|
76
|
+
return []
|
|
77
|
+
|
|
78
|
+
rows: list[dict] = []
|
|
79
|
+
for table in text_el.iter(q("table")):
|
|
80
|
+
headers = _header_labels(table)
|
|
81
|
+
body_rows = list(table.iter(q("tr")))
|
|
82
|
+
for ri, tr in enumerate(body_rows):
|
|
83
|
+
cells = [text_of(td) for td in tr.iter(q("td"))]
|
|
84
|
+
if not cells:
|
|
85
|
+
continue # header row (th) or empty
|
|
86
|
+
label = cells[0].strip()
|
|
87
|
+
if not label:
|
|
88
|
+
continue
|
|
89
|
+
row = {
|
|
90
|
+
"label": label,
|
|
91
|
+
"value_raw": None,
|
|
92
|
+
"num": None,
|
|
93
|
+
"unit": None,
|
|
94
|
+
"reference_range": None,
|
|
95
|
+
"row_index": ri,
|
|
96
|
+
"headers": headers,
|
|
97
|
+
}
|
|
98
|
+
for c in cells[1:]:
|
|
99
|
+
c = c.strip()
|
|
100
|
+
if not c:
|
|
101
|
+
continue
|
|
102
|
+
if row["reference_range"] is None and _RANGE.match(c):
|
|
103
|
+
row["reference_range"] = c
|
|
104
|
+
continue
|
|
105
|
+
m = _NUM_UNIT.match(c)
|
|
106
|
+
if m and row["value_raw"] is None:
|
|
107
|
+
row["value_raw"] = c
|
|
108
|
+
try:
|
|
109
|
+
row["num"] = float(m.group(1).replace(" ", "").lstrip("<>=~"))
|
|
110
|
+
except ValueError:
|
|
111
|
+
pass
|
|
112
|
+
row["unit"] = m.group(2) or None
|
|
113
|
+
rows.append(row)
|
|
114
|
+
return rows
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def _header_labels(table: etree._Element) -> list[str]:
|
|
118
|
+
thead = table.find(q("thead"))
|
|
119
|
+
if thead is None:
|
|
120
|
+
return []
|
|
121
|
+
return [text_of(th) for tr in thead.iter(q("tr")) for th in tr.iter(q("th"))]
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
"""CCDA section routing.
|
|
2
|
+
|
|
3
|
+
Maps a section's LOINC code to (context, target_system_hint) so downstream
|
|
4
|
+
handlers receive correctly-framed statements. This is *routing* knowledge, not
|
|
5
|
+
terminology logic (ARCHITECTURE.md §1 subtlety): the adapter tags context/role,
|
|
6
|
+
the handler stays format-agnostic.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from ...core.models import System
|
|
12
|
+
|
|
13
|
+
# section LOINC code -> (context, entry-level target system hint)
|
|
14
|
+
SECTION_ROUTING: dict[str, tuple[str, System | None]] = {
|
|
15
|
+
"30954-2": ("result", System.LOINC), # Results
|
|
16
|
+
"8716-3": ("vital", System.LOINC), # Vital signs
|
|
17
|
+
"11450-4": ("problem", System.SNOMEDCT), # Problem list
|
|
18
|
+
"10160-0": ("medication", System.RXNORM), # Medications
|
|
19
|
+
"11369-6": ("immunization", System.CVX), # History of immunizations
|
|
20
|
+
"48765-2": ("allergy", System.RXNORM), # Allergies
|
|
21
|
+
"47519-4": ("procedure", System.CPT), # Procedures
|
|
22
|
+
"29762-2": ("social_history", System.LOINC), # Social history (question slot)
|
|
23
|
+
"10157-6": ("family_history", System.SNOMEDCT), # Family history
|
|
24
|
+
"47420-5": ("functional_status", System.LOINC),
|
|
25
|
+
"8648-8": ("hospital_course", None),
|
|
26
|
+
"18776-5": ("plan_of_treatment", None),
|
|
27
|
+
"46240-8": ("encounters", None),
|
|
28
|
+
"51848-0": ("assessment", System.LOINC),
|
|
29
|
+
# PHQ-9 / survey panels surface as LOINC inside assessment-style sections
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
# Result/observation organizer & observation templateIds (entries-level routing)
|
|
33
|
+
TEMPLATE_RESULT_ORGANIZER = "2.16.840.1.113883.10.20.22.4.1"
|
|
34
|
+
TEMPLATE_RESULT_OBSERVATION = "2.16.840.1.113883.10.20.22.4.2"
|
|
35
|
+
TEMPLATE_VITALS_ORGANIZER = "2.16.840.1.113883.10.20.22.4.26"
|
|
36
|
+
TEMPLATE_VITALS_OBSERVATION = "2.16.840.1.113883.10.20.22.4.27"
|
|
37
|
+
TEMPLATE_ALLERGY_REACTION = "2.16.840.1.113883.10.20.22.4.9" # Reaction Observation
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def route_section(section_code: str | None) -> tuple[str, System | None]:
|
|
41
|
+
"""Return (context, target_system_hint) for a section LOINC code."""
|
|
42
|
+
if section_code and section_code in SECTION_ROUTING:
|
|
43
|
+
return SECTION_ROUTING[section_code]
|
|
44
|
+
return ("section", None)
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
"""Custom JSON adapter — PLACEHOLDER.
|
|
2
|
+
|
|
3
|
+
Planned: config-driven field mapping per source (mappings/<source>.yaml),
|
|
4
|
+
validate any 'loinc'-named key before trusting it, split combined "value unit"
|
|
5
|
+
strings. See ARCHITECTURE.md §5.1 and LOINC_pattern_analysis.md §5.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from typing import Iterable
|
|
11
|
+
|
|
12
|
+
from ...core.models import ClinicalStatement
|
|
13
|
+
from ..base import FormatAdapter
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class CustomJsonAdapter(FormatAdapter):
|
|
17
|
+
format_key = "custom"
|
|
18
|
+
|
|
19
|
+
def __init__(self, mapping: dict | None = None):
|
|
20
|
+
self.mapping = mapping or {}
|
|
21
|
+
|
|
22
|
+
def detect(self, document: str | bytes) -> bool:
|
|
23
|
+
# Custom JSON has no universal signature; the orchestrator selects it
|
|
24
|
+
# explicitly (by source config), not by detection.
|
|
25
|
+
return False
|
|
26
|
+
|
|
27
|
+
def parse(self, document: str | bytes) -> Iterable[ClinicalStatement]:
|
|
28
|
+
raise NotImplementedError(
|
|
29
|
+
"Custom JSON adapter is a placeholder; supply a source mapping later."
|
|
30
|
+
)
|