gem-mapping-studio 0.2.2__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.
- forome/gem/_reference/__init__.py +12 -0
- forome/gem/_reference/dimensions.md +388 -0
- forome/gem/_reference/genetic_evidence.shacl.ttl +387 -0
- forome/gem/_reference/semantic_types.yaml +949 -0
- forome/gem/extraction/__init__.py +0 -0
- forome/gem/extraction/extract_annotations.py +106 -0
- forome/gem/extraction/extract_annotations_pypdf.py +140 -0
- forome/gem/extraction/yaml_to_rdf.py +191 -0
- forome/gem/umls/__init__.py +0 -0
- forome/gem/umls/_paths.py +58 -0
- forome/gem/umls/adjudicate_ui.py +2253 -0
- forome/gem/umls/build_umls_crosswalk.py +384 -0
- forome/gem/umls/classify_credibility_sweep.py +112 -0
- forome/gem/umls/fetch_semantic_network.py +133 -0
- forome/gem/umls/local_umls.py +806 -0
- forome/gem/umls/render_crosswalk_tex.py +312 -0
- forome/gem/umls/semantic_types.py +130 -0
- forome/gem/umls/sweep.py +576 -0
- forome/gem/umls/uts_client.py +415 -0
- forome/gem/validation/__init__.py +0 -0
- forome/gem/validation/compute_coverage.py +149 -0
- forome/gem/validation/validate_annotations.py +123 -0
- gem_mapping_studio-0.2.2.dist-info/METADATA +465 -0
- gem_mapping_studio-0.2.2.dist-info/RECORD +29 -0
- gem_mapping_studio-0.2.2.dist-info/WHEEL +5 -0
- gem_mapping_studio-0.2.2.dist-info/entry_points.txt +11 -0
- gem_mapping_studio-0.2.2.dist-info/licenses/LICENSE-code.txt +202 -0
- gem_mapping_studio-0.2.2.dist-info/licenses/LICENSE.txt +395 -0
- gem_mapping_studio-0.2.2.dist-info/top_level.txt +1 -0
forome/gem/umls/sweep.py
ADDED
|
@@ -0,0 +1,576 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Pre-registered UMLS mapping sweeps: protocol loader, runner, S2-style table.
|
|
3
|
+
|
|
4
|
+
An "unmapped" verdict in the crosswalk must be *earned*: a stated, reproducible
|
|
5
|
+
search protocol was executed, every candidate it surfaced was recorded, and each
|
|
6
|
+
candidate was rejected under a named criterion. This module makes that a
|
|
7
|
+
mechanical pipeline:
|
|
8
|
+
|
|
9
|
+
1. The curator writes a protocol YAML **before** searching (pre-registration):
|
|
10
|
+
dimension, tokens, search passes (terms x match x scope), semantic types to
|
|
11
|
+
enumerate, and a note on what would confirm or refute the verdict. See
|
|
12
|
+
``data/umls/sweeps/credibility.yaml`` for the shape.
|
|
13
|
+
2. ``run(protocol, client)`` executes it through any client with the shared
|
|
14
|
+
``search(term, search_type, sabs, page_size, semantic_types, partial)``
|
|
15
|
+
interface (``UTSClient`` live with disk cache, ``NullClient`` for dry runs,
|
|
16
|
+
or a local index later) and returns a results dict: the queries, the
|
|
17
|
+
deduplicated candidates (``found_by`` merged across queries), and counts.
|
|
18
|
+
Each candidate carries ``fails: null`` / ``why: null`` for the curator to
|
|
19
|
+
fill in using criteria A-D.
|
|
20
|
+
3. ``render_tex(results)`` renders a ``longtable`` for the supplement.
|
|
21
|
+
|
|
22
|
+
Results are written to ``data/umls/sweeps/<name>.results.yaml`` -- a separate
|
|
23
|
+
file. The sweep **never** writes ``adjudications.yaml``; an acceptance is still
|
|
24
|
+
recorded through the Mapping Studio.
|
|
25
|
+
|
|
26
|
+
**Curation survives re-runs.** When the results file already exists, any
|
|
27
|
+
``fails``/``why`` a curator filled in is carried over to the same CUI in the new
|
|
28
|
+
run. A curated CUI that the new run no longer surfaces is kept under
|
|
29
|
+
``stale_curation`` (never silently dropped), so a protocol change or a UMLS
|
|
30
|
+
release change is visible.
|
|
31
|
+
|
|
32
|
+
Usage:
|
|
33
|
+
gem-umls-sweep data/umls/sweeps/credibility.yaml
|
|
34
|
+
gem-umls-sweep data/umls/sweeps/credibility.yaml --tex build/credibility_sweep.tex
|
|
35
|
+
gem-umls-sweep PROTOCOL.yaml --provider null # dry run, no key
|
|
36
|
+
gem-umls-sweep PROTOCOL.yaml --enrich --limit-per-query 50
|
|
37
|
+
"""
|
|
38
|
+
from __future__ import annotations
|
|
39
|
+
|
|
40
|
+
import argparse
|
|
41
|
+
import sys
|
|
42
|
+
from pathlib import Path
|
|
43
|
+
|
|
44
|
+
import yaml
|
|
45
|
+
|
|
46
|
+
from forome.gem.umls._paths import DATA_DIR
|
|
47
|
+
from forome.gem.umls.render_crosswalk_tex import tex
|
|
48
|
+
|
|
49
|
+
SWEEPS_DIR = DATA_DIR / "sweeps"
|
|
50
|
+
|
|
51
|
+
MATCH_TYPES = ("words", "exact", "normalizedWords", "normalizedString", "partial")
|
|
52
|
+
SCOPES = ("all", "axis")
|
|
53
|
+
CRITERIA = ("A", "B", "C", "D")
|
|
54
|
+
REQUIRED_KEYS = ("name", "dimension", "tokens", "criteria", "date_registered",
|
|
55
|
+
"passes")
|
|
56
|
+
|
|
57
|
+
RESULTS_HEADER = """\
|
|
58
|
+
# GENERATED by gem-umls-sweep from the pre-registered protocol named below.
|
|
59
|
+
# Curator: for every candidate that is NOT accepted as a mapping, set
|
|
60
|
+
# fails: one of A|B|C|D (see protocol.criteria) why: one line
|
|
61
|
+
# Leave both null only for a candidate you accept (record the acceptance in
|
|
62
|
+
# adjudications.yaml via the Mapping Studio; this file is never read by the
|
|
63
|
+
# harness). Your fails/why entries are preserved when the sweep is re-run.
|
|
64
|
+
"""
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
class ProtocolError(ValueError):
|
|
68
|
+
"""The protocol file is missing a required key or holds an invalid value."""
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
# --------------------------------------------------------------------------
|
|
72
|
+
# Protocol
|
|
73
|
+
# --------------------------------------------------------------------------
|
|
74
|
+
|
|
75
|
+
def load_protocol(path: Path | str) -> dict:
|
|
76
|
+
"""Load and validate a sweep protocol. Raises ``ProtocolError``."""
|
|
77
|
+
path = Path(path)
|
|
78
|
+
doc = yaml.safe_load(path.read_text())
|
|
79
|
+
if not isinstance(doc, dict):
|
|
80
|
+
raise ProtocolError(f"{path}: protocol must be a mapping")
|
|
81
|
+
validate_protocol(doc)
|
|
82
|
+
doc["_path"] = str(path)
|
|
83
|
+
return doc
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def validate_protocol(p: dict) -> None:
|
|
87
|
+
missing = [k for k in REQUIRED_KEYS if k not in p]
|
|
88
|
+
if missing:
|
|
89
|
+
raise ProtocolError(f"protocol missing required keys: {', '.join(missing)}")
|
|
90
|
+
if not isinstance(p["tokens"], list) or not p["tokens"]:
|
|
91
|
+
raise ProtocolError("protocol 'tokens' must be a non-empty list")
|
|
92
|
+
crit = p["criteria"]
|
|
93
|
+
if not isinstance(crit, dict) or any(c not in crit for c in CRITERIA):
|
|
94
|
+
raise ProtocolError("protocol 'criteria' must define codes A, B, C and D")
|
|
95
|
+
passes = p["passes"]
|
|
96
|
+
if not isinstance(passes, dict) or not passes:
|
|
97
|
+
raise ProtocolError("protocol 'passes' must be a non-empty mapping "
|
|
98
|
+
"(name -> pass)")
|
|
99
|
+
for name, body in passes.items():
|
|
100
|
+
if not isinstance(body, dict):
|
|
101
|
+
raise ProtocolError(f"pass '{name}' must be a mapping")
|
|
102
|
+
kind = pass_kind(body)
|
|
103
|
+
for key in ("match", "scopes"):
|
|
104
|
+
if key in body and not isinstance(body[key], list):
|
|
105
|
+
raise ProtocolError(f"pass '{name}': '{key}' must be a list")
|
|
106
|
+
if kind == "sty":
|
|
107
|
+
if not isinstance(body["tuis"], list) or not body["tuis"]:
|
|
108
|
+
raise ProtocolError(f"pass '{name}': 'tuis' must be a non-empty list")
|
|
109
|
+
mode = body.get("mode") or ("terms" if ("terms" in body or "terms_from" in body)
|
|
110
|
+
else "enumerate")
|
|
111
|
+
if mode not in ("terms", "enumerate"):
|
|
112
|
+
raise ProtocolError(f"pass '{name}': mode must be 'terms' or 'enumerate'")
|
|
113
|
+
if mode == "terms":
|
|
114
|
+
src = body.get("terms_from")
|
|
115
|
+
if src is not None and src not in passes:
|
|
116
|
+
raise ProtocolError(f"pass '{name}': terms_from '{src}' is not a pass")
|
|
117
|
+
if src is None and not body.get("terms"):
|
|
118
|
+
raise ProtocolError(f"pass '{name}': mode 'terms' needs 'terms' or 'terms_from'")
|
|
119
|
+
bad = [m for m in (body.get("match") or ["words"]) if m not in MATCH_TYPES]
|
|
120
|
+
if bad:
|
|
121
|
+
raise ProtocolError(f"pass '{name}': unknown match type(s) {bad}")
|
|
122
|
+
continue
|
|
123
|
+
if kind is None:
|
|
124
|
+
raise ProtocolError(f"pass '{name}' needs 'terms', 'phrases' or 'tuis'")
|
|
125
|
+
queries = pass_queries(body)
|
|
126
|
+
if not queries:
|
|
127
|
+
raise ProtocolError(f"pass '{name}': no query strings")
|
|
128
|
+
match = body.get("match") or ["words"]
|
|
129
|
+
bad = [m for m in match if m not in MATCH_TYPES]
|
|
130
|
+
if bad:
|
|
131
|
+
raise ProtocolError(f"pass '{name}': unknown match type(s) {bad}; "
|
|
132
|
+
f"allowed: {list(MATCH_TYPES)}")
|
|
133
|
+
scopes = body.get("scopes") or ["all"]
|
|
134
|
+
bad = [s for s in scopes if s not in SCOPES]
|
|
135
|
+
if bad:
|
|
136
|
+
raise ProtocolError(f"pass '{name}': unknown scope(s) {bad}; "
|
|
137
|
+
f"allowed: {list(SCOPES)}")
|
|
138
|
+
axis = p.get("axis_tuis")
|
|
139
|
+
if axis is not None and (not isinstance(axis, list)
|
|
140
|
+
or not all(isinstance(t, str) for t in axis)):
|
|
141
|
+
raise ProtocolError("protocol 'axis_tuis' must be a list of TUIs")
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def pass_kind(body: dict) -> str | None:
|
|
145
|
+
if "tuis" in body:
|
|
146
|
+
return "sty"
|
|
147
|
+
if "terms" in body or "phrases" in body:
|
|
148
|
+
return "search"
|
|
149
|
+
return None
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def pass_queries(body: dict) -> list[str]:
|
|
153
|
+
qs = list(body.get("terms") or []) + list(body.get("phrases") or [])
|
|
154
|
+
return [str(q) for q in qs if str(q).strip()]
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def axis_filter(protocol: dict) -> str | None:
|
|
158
|
+
"""Comma-joined TUI list for scope=axis queries: each axis TUI plus its
|
|
159
|
+
Semantic Network subtree (when the reference is available)."""
|
|
160
|
+
tuis = protocol.get("axis_tuis") or []
|
|
161
|
+
if not tuis:
|
|
162
|
+
return None
|
|
163
|
+
try:
|
|
164
|
+
from forome.gem.umls import semantic_types as stylib
|
|
165
|
+
out: set[str] = set()
|
|
166
|
+
for t in tuis:
|
|
167
|
+
out |= stylib.subtree_tuis(t)
|
|
168
|
+
except Exception: # noqa: BLE001 -- reference data unavailable
|
|
169
|
+
out = set(tuis)
|
|
170
|
+
return ",".join(sorted(out)) if out else None
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
# --------------------------------------------------------------------------
|
|
174
|
+
# Runner
|
|
175
|
+
# --------------------------------------------------------------------------
|
|
176
|
+
|
|
177
|
+
def provider_info(client, umls: str | None = None) -> dict:
|
|
178
|
+
kind = getattr(client, "kind", None) or type(client).__name__
|
|
179
|
+
kind = kind.lower().removesuffix("client") or kind.lower()
|
|
180
|
+
if umls is None:
|
|
181
|
+
rel = getattr(client, "release", None)
|
|
182
|
+
if callable(rel):
|
|
183
|
+
try:
|
|
184
|
+
row = rel()
|
|
185
|
+
umls = (row or {}).get("version")
|
|
186
|
+
except Exception: # noqa: BLE001 -- no DB / no release row yet
|
|
187
|
+
umls = None
|
|
188
|
+
umls = (umls or getattr(client, "umls_release", None)
|
|
189
|
+
or getattr(client, "version", None) or "unknown")
|
|
190
|
+
return {"kind": kind, "umls": str(umls)}
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def _search(client, q: str, match: str, stys: str | None, limit: int | None):
|
|
194
|
+
if match == "partial":
|
|
195
|
+
hits = client.search(q, search_type="words", semantic_types=stys,
|
|
196
|
+
partial=True)
|
|
197
|
+
else:
|
|
198
|
+
hits = client.search(q, search_type=match, semantic_types=stys)
|
|
199
|
+
hits = list(hits or [])
|
|
200
|
+
if limit is not None:
|
|
201
|
+
hits = hits[:limit]
|
|
202
|
+
return hits
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def run(protocol: dict, client, enrich: bool = False,
|
|
206
|
+
limit_per_query: int | None = None, umls: str | None = None) -> dict:
|
|
207
|
+
"""Execute ``protocol`` through ``client``; return the results dict.
|
|
208
|
+
|
|
209
|
+
Candidates are deduplicated by CUI in discovery order, ``found_by`` merged.
|
|
210
|
+
The ``sty`` pass runs only when the client offers ``concepts_by_tui(tui)``
|
|
211
|
+
(a local index); on a REST provider it is recorded as skipped.
|
|
212
|
+
"""
|
|
213
|
+
stys_axis = axis_filter(protocol)
|
|
214
|
+
passes_out: list[dict] = []
|
|
215
|
+
cands: dict[str, dict] = {}
|
|
216
|
+
n_queries = 0
|
|
217
|
+
by_pass: dict[str, int] = {}
|
|
218
|
+
|
|
219
|
+
def add(hit: dict, pname: str, q: str, match: str) -> None:
|
|
220
|
+
cui = hit.get("cui")
|
|
221
|
+
if not cui:
|
|
222
|
+
return
|
|
223
|
+
c = cands.get(cui)
|
|
224
|
+
if c is None:
|
|
225
|
+
c = {"cui": cui, "name": hit.get("name"),
|
|
226
|
+
"root_source": hit.get("root_source"),
|
|
227
|
+
"semantic_types": list(hit.get("semantic_types") or []),
|
|
228
|
+
"found_by": [], "sabs": [], "fails": None, "why": None}
|
|
229
|
+
cands[cui] = c
|
|
230
|
+
seen_passes = {f["pass"] for f in c["found_by"]}
|
|
231
|
+
fb = {"pass": pname, "q": q, "match": match}
|
|
232
|
+
if fb not in c["found_by"]:
|
|
233
|
+
c["found_by"].append(fb)
|
|
234
|
+
if pname not in seen_passes: # distinct candidates per pass
|
|
235
|
+
by_pass[pname] = by_pass.get(pname, 0) + 1
|
|
236
|
+
|
|
237
|
+
for pname, body in protocol["passes"].items():
|
|
238
|
+
kind = pass_kind(body)
|
|
239
|
+
rec = {"name": pname, "status": "executed", "queries": []}
|
|
240
|
+
if kind == "sty":
|
|
241
|
+
mode = body.get("mode") or ("terms" if ("terms" in body or "terms_from" in body)
|
|
242
|
+
else "enumerate")
|
|
243
|
+
rec["mode"] = mode
|
|
244
|
+
if mode == "terms":
|
|
245
|
+
# search the terms WITHIN each semantic type -- works on any
|
|
246
|
+
# provider (UTS: semanticTypes filter), never enumerates a type
|
|
247
|
+
src = body.get("terms_from")
|
|
248
|
+
terms = (pass_queries(protocol["passes"][src]) if src
|
|
249
|
+
else list(body.get("terms") or []))
|
|
250
|
+
for tui in body["tuis"]:
|
|
251
|
+
for q in terms:
|
|
252
|
+
for match in (body.get("match") or ["words"]):
|
|
253
|
+
hits = _search(client, q, match, tui, limit_per_query)
|
|
254
|
+
n_queries += 1
|
|
255
|
+
rec["queries"].append({"q": q, "match": match, "scope": tui,
|
|
256
|
+
"hits": len(hits)})
|
|
257
|
+
for h in hits:
|
|
258
|
+
add(h, pname, q, match)
|
|
259
|
+
passes_out.append(rec)
|
|
260
|
+
continue
|
|
261
|
+
fn = getattr(client, "concepts_by_tui", None)
|
|
262
|
+
if not callable(fn):
|
|
263
|
+
rec["status"] = "skipped"
|
|
264
|
+
rec["reason"] = (body.get("note")
|
|
265
|
+
or "client has no concepts_by_tui (local index)")
|
|
266
|
+
passes_out.append(rec)
|
|
267
|
+
continue
|
|
268
|
+
cap = int(body.get("limit") or 5000) # a whole type is never dumped uncapped
|
|
269
|
+
if limit_per_query is not None:
|
|
270
|
+
cap = min(cap, limit_per_query)
|
|
271
|
+
for tui in body["tuis"]:
|
|
272
|
+
try:
|
|
273
|
+
hits = list(fn(tui, limit=cap) or [])
|
|
274
|
+
except TypeError: # client without a limit kwarg
|
|
275
|
+
hits = list(fn(tui) or [])[:cap]
|
|
276
|
+
n_queries += 1
|
|
277
|
+
qrec = {"q": tui, "match": "sty", "scope": "all", "hits": len(hits)}
|
|
278
|
+
if len(hits) >= cap:
|
|
279
|
+
qrec["truncated"] = cap
|
|
280
|
+
rec["queries"].append(qrec)
|
|
281
|
+
for h in hits:
|
|
282
|
+
add(h, pname, tui, "sty")
|
|
283
|
+
passes_out.append(rec)
|
|
284
|
+
continue
|
|
285
|
+
|
|
286
|
+
match_types = body.get("match") or ["words"]
|
|
287
|
+
scopes = body.get("scopes") or ["all"]
|
|
288
|
+
for q in pass_queries(body):
|
|
289
|
+
for match in match_types:
|
|
290
|
+
for scope in scopes:
|
|
291
|
+
qrec = {"q": q, "match": match, "scope": scope, "hits": 0}
|
|
292
|
+
if scope == "axis" and not stys_axis:
|
|
293
|
+
qrec["skipped"] = "protocol has no axis_tuis"
|
|
294
|
+
rec["queries"].append(qrec)
|
|
295
|
+
continue
|
|
296
|
+
stys = stys_axis if scope == "axis" else None
|
|
297
|
+
hits = _search(client, q, match, stys, limit_per_query)
|
|
298
|
+
n_queries += 1
|
|
299
|
+
qrec["hits"] = len(hits)
|
|
300
|
+
rec["queries"].append(qrec)
|
|
301
|
+
for h in hits:
|
|
302
|
+
add(h, pname, q, match)
|
|
303
|
+
passes_out.append(rec)
|
|
304
|
+
|
|
305
|
+
candidates = list(cands.values())
|
|
306
|
+
if enrich:
|
|
307
|
+
_enrich(client, candidates, protocol.get("sabs_of_interest"))
|
|
308
|
+
|
|
309
|
+
return {
|
|
310
|
+
"protocol": {
|
|
311
|
+
"name": protocol["name"],
|
|
312
|
+
"date_registered": str(protocol["date_registered"]),
|
|
313
|
+
"dimension": protocol["dimension"],
|
|
314
|
+
"tokens": list(protocol["tokens"]),
|
|
315
|
+
"path": protocol.get("_path"),
|
|
316
|
+
"criteria": dict(protocol["criteria"]),
|
|
317
|
+
"sabs_of_interest": list(protocol.get("sabs_of_interest") or []),
|
|
318
|
+
},
|
|
319
|
+
"provider": provider_info(client, umls),
|
|
320
|
+
"passes": passes_out,
|
|
321
|
+
"candidates": candidates,
|
|
322
|
+
"summary": {"n_queries": n_queries, "n_candidates": len(candidates),
|
|
323
|
+
"by_pass": by_pass},
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
|
|
327
|
+
def _enrich(client, candidates: list[dict], sabs_of_interest=None) -> None:
|
|
328
|
+
"""Fill ``sabs`` with the English source vocabularies holding an atom. When
|
|
329
|
+
the protocol names ``sabs_of_interest``, ``sabs`` keeps only those (in
|
|
330
|
+
protocol order) and ``sabs_other`` counts the rest, so the table shows what
|
|
331
|
+
the reader was promised rather than every mirror vocabulary."""
|
|
332
|
+
atoms = getattr(client, "atoms", None)
|
|
333
|
+
if not callable(atoms):
|
|
334
|
+
return
|
|
335
|
+
interest = list(sabs_of_interest or [])
|
|
336
|
+
for c in candidates:
|
|
337
|
+
try:
|
|
338
|
+
rows = atoms(c["cui"]) or []
|
|
339
|
+
except Exception: # noqa: BLE001 -- enrichment is best-effort
|
|
340
|
+
rows = []
|
|
341
|
+
sabs = {a.get("sab") for a in rows
|
|
342
|
+
if a.get("sab") and (a.get("language") or "ENG") == "ENG"}
|
|
343
|
+
if interest:
|
|
344
|
+
c["sabs"] = [s for s in interest if s in sabs]
|
|
345
|
+
c["sabs_other"] = len(sabs - set(interest))
|
|
346
|
+
else:
|
|
347
|
+
c["sabs"] = sorted(sabs)
|
|
348
|
+
|
|
349
|
+
|
|
350
|
+
# --------------------------------------------------------------------------
|
|
351
|
+
# Curation merge
|
|
352
|
+
# --------------------------------------------------------------------------
|
|
353
|
+
|
|
354
|
+
def _curated(c: dict) -> bool:
|
|
355
|
+
return c.get("fails") is not None or c.get("why") is not None
|
|
356
|
+
|
|
357
|
+
|
|
358
|
+
def merge_curation(new: dict, previous: dict | None) -> dict:
|
|
359
|
+
"""Carry curator-filled ``fails``/``why`` from ``previous`` results into
|
|
360
|
+
``new`` by CUI. Curated CUIs no longer surfaced go to ``stale_curation``.
|
|
361
|
+
"""
|
|
362
|
+
if not previous:
|
|
363
|
+
return new
|
|
364
|
+
prior = {c["cui"]: c for c in (previous.get("candidates") or [])
|
|
365
|
+
if c.get("cui") and _curated(c)}
|
|
366
|
+
prior.update({c["cui"]: c for c in (previous.get("stale_curation") or [])
|
|
367
|
+
if c.get("cui")})
|
|
368
|
+
carried = 0
|
|
369
|
+
for c in new["candidates"]:
|
|
370
|
+
p = prior.pop(c["cui"], None)
|
|
371
|
+
if p is not None:
|
|
372
|
+
c["fails"] = p.get("fails")
|
|
373
|
+
c["why"] = p.get("why")
|
|
374
|
+
carried += 1
|
|
375
|
+
stale = [{"cui": p["cui"], "name": p.get("name"), "fails": p.get("fails"),
|
|
376
|
+
"why": p.get("why")} for p in prior.values()]
|
|
377
|
+
if stale:
|
|
378
|
+
new["stale_curation"] = stale
|
|
379
|
+
new["summary"]["n_curated"] = sum(1 for c in new["candidates"] if _curated(c))
|
|
380
|
+
new["summary"]["n_carried_over"] = carried
|
|
381
|
+
new["summary"]["n_stale_curation"] = len(stale)
|
|
382
|
+
return new
|
|
383
|
+
|
|
384
|
+
|
|
385
|
+
def load_results(path: Path | str) -> dict | None:
|
|
386
|
+
path = Path(path)
|
|
387
|
+
if not path.is_file():
|
|
388
|
+
return None
|
|
389
|
+
doc = yaml.safe_load(path.read_text())
|
|
390
|
+
return doc if isinstance(doc, dict) else None
|
|
391
|
+
|
|
392
|
+
|
|
393
|
+
def dump_results(results: dict) -> str:
|
|
394
|
+
body = yaml.safe_dump(results, sort_keys=False, allow_unicode=True,
|
|
395
|
+
width=100, default_flow_style=False)
|
|
396
|
+
return RESULTS_HEADER + body
|
|
397
|
+
|
|
398
|
+
|
|
399
|
+
# --------------------------------------------------------------------------
|
|
400
|
+
# LaTeX
|
|
401
|
+
# --------------------------------------------------------------------------
|
|
402
|
+
|
|
403
|
+
def _first_pass(c: dict) -> str:
|
|
404
|
+
fb = c.get("found_by") or []
|
|
405
|
+
return fb[0]["pass"] if fb else ""
|
|
406
|
+
|
|
407
|
+
|
|
408
|
+
def render_tex(results: dict) -> str:
|
|
409
|
+
"""Render the sweep as an S2-style ``longtable``. Candidates with
|
|
410
|
+
``fails: null`` render as not yet adjudicated."""
|
|
411
|
+
p = results["protocol"]
|
|
412
|
+
prov = results.get("provider") or {}
|
|
413
|
+
s = results.get("summary") or {}
|
|
414
|
+
order = {r["name"]: i for i, r in enumerate(results.get("passes") or [])}
|
|
415
|
+
rows = sorted(results.get("candidates") or [],
|
|
416
|
+
key=lambda c: (order.get(_first_pass(c), len(order)),
|
|
417
|
+
(c.get("name") or "").lower(), c.get("cui") or ""))
|
|
418
|
+
n_adj = sum(1 for c in rows if c.get("fails") is not None)
|
|
419
|
+
|
|
420
|
+
out = [
|
|
421
|
+
r"% =====================================================================",
|
|
422
|
+
rf"% GENERATED by gem-umls-sweep from protocol '{p['name']}' "
|
|
423
|
+
rf"(registered {p.get('date_registered')}) -- do not edit by hand.",
|
|
424
|
+
rf"% Dimension: {p.get('dimension')}; tokens: "
|
|
425
|
+
rf"{', '.join(p.get('tokens') or [])}",
|
|
426
|
+
rf"% Provider: {prov.get('kind')} / UMLS {prov.get('umls')}; "
|
|
427
|
+
rf"{s.get('n_queries', 0)} queries, {len(rows)} candidates, "
|
|
428
|
+
rf"{n_adj} adjudicated.",
|
|
429
|
+
r"% Rejection criteria:",
|
|
430
|
+
]
|
|
431
|
+
for code in CRITERIA:
|
|
432
|
+
meaning = (p.get("criteria") or {}).get(code, "")
|
|
433
|
+
out.append(f"% {code}: {meaning}")
|
|
434
|
+
out += [
|
|
435
|
+
r"% Requires: \usepackage{longtable} and \usepackage{booktabs}.",
|
|
436
|
+
r"% =====================================================================",
|
|
437
|
+
"",
|
|
438
|
+
]
|
|
439
|
+
exec_passes = [r["name"] for r in results.get("passes") or []
|
|
440
|
+
if r.get("status") == "executed"]
|
|
441
|
+
skip_passes = [r["name"] for r in results.get("passes") or []
|
|
442
|
+
if r.get("status") != "executed"]
|
|
443
|
+
caption = (rf"Mapping sweep for dimension \texttt{{{tex(p.get('dimension'))}}} "
|
|
444
|
+
rf"(protocol \texttt{{{tex(p['name'])}}}, registered "
|
|
445
|
+
rf"{tex(p.get('date_registered'))}; provider {tex(prov.get('kind'))}, "
|
|
446
|
+
rf"UMLS {tex(prov.get('umls'))}). {s.get('n_queries', 0)} queries "
|
|
447
|
+
rf"over passes {tex(', '.join(exec_passes)) or '---'}"
|
|
448
|
+
+ (rf" (skipped: {tex(', '.join(skip_passes))})" if skip_passes else "")
|
|
449
|
+
+ rf" surfaced {len(rows)} distinct concepts; {n_adj} adjudicated. "
|
|
450
|
+
r"Fails: rejection criterion (A denotation, B granularity, "
|
|
451
|
+
r"C set membership, D domain sense); ``---'' = not yet adjudicated.")
|
|
452
|
+
head = (r"Candidate & STY & Source & Found by & Fails & Why \\")
|
|
453
|
+
out += [
|
|
454
|
+
r"{\footnotesize",
|
|
455
|
+
r"\begin{longtable}{@{}p{4.0cm} p{2.3cm} p{2.6cm} l c p{4.4cm}@{}}",
|
|
456
|
+
rf"\caption{{{caption}}}\\",
|
|
457
|
+
r"\toprule", head, r"\midrule", r"\endfirsthead",
|
|
458
|
+
r"\toprule", head, r"\midrule", r"\endhead",
|
|
459
|
+
]
|
|
460
|
+
cur = None
|
|
461
|
+
for c in rows:
|
|
462
|
+
pn = _first_pass(c)
|
|
463
|
+
if pn != cur:
|
|
464
|
+
cur = pn
|
|
465
|
+
out.append(rf"\midrule \multicolumn{{6}}{{@{{}}l}}"
|
|
466
|
+
rf"{{\textbf{{pass: {tex(pn) or '---'}}}}} \\")
|
|
467
|
+
name = rf"{tex(c.get('name'))} \emph{{({tex(c.get('cui'))})}}"
|
|
468
|
+
sty = "; ".join(tex(x) for x in (c.get("semantic_types") or []))
|
|
469
|
+
src = tex(c.get("root_source"))
|
|
470
|
+
if c.get("sabs"):
|
|
471
|
+
src += rf" \emph{{({tex(', '.join(c['sabs']))})}}"
|
|
472
|
+
if c.get("sabs_other"):
|
|
473
|
+
src += rf" +{int(c['sabs_other'])}"
|
|
474
|
+
found = ", ".join(sorted({f["pass"] for f in c.get("found_by") or []}))
|
|
475
|
+
if c.get("fails") is None:
|
|
476
|
+
fails, why = "---", r"\emph{not adjudicated}"
|
|
477
|
+
else:
|
|
478
|
+
fails = tex(c.get("fails"))
|
|
479
|
+
why = tex(c.get("why")) if c.get("why") else "---"
|
|
480
|
+
out.append(rf"{name} & {sty} & {src} & {tex(found)} & {fails} & {why} \\")
|
|
481
|
+
stale = results.get("stale_curation") or []
|
|
482
|
+
if stale:
|
|
483
|
+
out.append(r"\midrule \multicolumn{6}{@{}l}{\textbf{curated in a previous "
|
|
484
|
+
r"run, no longer surfaced}} \\")
|
|
485
|
+
for c in stale:
|
|
486
|
+
out.append(rf"{tex(c.get('name'))} \emph{{({tex(c.get('cui'))})}} & & & "
|
|
487
|
+
rf"& {tex(c.get('fails')) or '---'} & {tex(c.get('why')) or '---'} \\")
|
|
488
|
+
out += [r"\bottomrule", r"\end{longtable}", r"}", ""]
|
|
489
|
+
return "\n".join(out) + "\n"
|
|
490
|
+
|
|
491
|
+
|
|
492
|
+
# --------------------------------------------------------------------------
|
|
493
|
+
# CLI
|
|
494
|
+
# --------------------------------------------------------------------------
|
|
495
|
+
|
|
496
|
+
def _build_client(provider: str, dsn: str | None = None):
|
|
497
|
+
if provider == "pg":
|
|
498
|
+
# the local PostgreSQL index (forome.gem.umls.local_umls); the exact
|
|
499
|
+
# UMLS release comes from its umls_release row
|
|
500
|
+
from forome.gem.umls.local_umls import PgUMLSClient
|
|
501
|
+
return PgUMLSClient(dsn)
|
|
502
|
+
if provider == "null":
|
|
503
|
+
from forome.gem.umls.uts_client import NullClient
|
|
504
|
+
return NullClient()
|
|
505
|
+
if provider == "uts":
|
|
506
|
+
# Lazy: adjudicate_ui.api_key() reads UMLS_API_KEY or the repo .envrc.
|
|
507
|
+
# The key is passed to the client only; it is never printed or logged.
|
|
508
|
+
from forome.gem.umls import adjudicate_ui as UI
|
|
509
|
+
from forome.gem.umls import build_umls_crosswalk as H
|
|
510
|
+
from forome.gem.umls.uts_client import UTSClient
|
|
511
|
+
key = UI.api_key()
|
|
512
|
+
if not key:
|
|
513
|
+
raise SystemExit("gem-umls-sweep: no UMLS API key found (set "
|
|
514
|
+
"UMLS_API_KEY or add it to .envrc); use "
|
|
515
|
+
"--provider null for a dry run.")
|
|
516
|
+
return UTSClient(key, cache_dir=H.CACHE_DIR)
|
|
517
|
+
raise SystemExit(f"gem-umls-sweep: unknown provider '{provider}'")
|
|
518
|
+
|
|
519
|
+
|
|
520
|
+
def main(argv=None) -> int:
|
|
521
|
+
ap = argparse.ArgumentParser(
|
|
522
|
+
prog="gem-umls-sweep", description=__doc__,
|
|
523
|
+
formatter_class=argparse.RawDescriptionHelpFormatter)
|
|
524
|
+
ap.add_argument("protocol", help="pre-registered protocol YAML")
|
|
525
|
+
ap.add_argument("--out", help="results YAML (default: "
|
|
526
|
+
"data/umls/sweeps/<name>.results.yaml)")
|
|
527
|
+
ap.add_argument("--tex", help="also write an S2-style LaTeX longtable here")
|
|
528
|
+
ap.add_argument("--dsn", default=None,
|
|
529
|
+
help="PostgreSQL DSN for --provider pg (default: $GEM_UMLS_DSN, "
|
|
530
|
+
"else postgresql:///umls)")
|
|
531
|
+
ap.add_argument("--provider", choices=["uts", "pg", "null"], default="uts",
|
|
532
|
+
help="uts = live UTS REST with disk cache (default); "
|
|
533
|
+
"null = no results, for a dry run")
|
|
534
|
+
ap.add_argument("--enrich", action="store_true",
|
|
535
|
+
help="fetch each candidate's atoms to list its English SABs")
|
|
536
|
+
ap.add_argument("--limit-per-query", type=int, default=None, metavar="N",
|
|
537
|
+
help="keep at most N hits per query")
|
|
538
|
+
ap.add_argument("--umls-release", default=None, metavar="LABEL",
|
|
539
|
+
help="label for the UMLS release queried (e.g. 2025AA); "
|
|
540
|
+
"default: the client's version string")
|
|
541
|
+
args = ap.parse_args(argv)
|
|
542
|
+
|
|
543
|
+
try:
|
|
544
|
+
protocol = load_protocol(args.protocol)
|
|
545
|
+
except ProtocolError as e:
|
|
546
|
+
print(f"gem-umls-sweep: invalid protocol: {e}", file=sys.stderr)
|
|
547
|
+
return 2
|
|
548
|
+
|
|
549
|
+
out_path = Path(args.out) if args.out else (
|
|
550
|
+
SWEEPS_DIR / f"{protocol['name']}.results.yaml")
|
|
551
|
+
client = _build_client(args.provider, args.dsn)
|
|
552
|
+
results = run(protocol, client, enrich=args.enrich,
|
|
553
|
+
limit_per_query=args.limit_per_query, umls=args.umls_release)
|
|
554
|
+
previous = load_results(out_path)
|
|
555
|
+
results = merge_curation(results, previous)
|
|
556
|
+
|
|
557
|
+
out_path.parent.mkdir(parents=True, exist_ok=True)
|
|
558
|
+
out_path.write_text(dump_results(results))
|
|
559
|
+
s = results["summary"]
|
|
560
|
+
print(f"Wrote {out_path}: {s['n_queries']} queries, {s['n_candidates']} "
|
|
561
|
+
f"candidates" + (f", {s['n_carried_over']} curated entries carried over"
|
|
562
|
+
if previous else "")
|
|
563
|
+
+ (f", {s['n_stale_curation']} stale" if s.get("n_stale_curation") else ""))
|
|
564
|
+
for p in results["passes"]:
|
|
565
|
+
if p["status"] != "executed":
|
|
566
|
+
print(f" pass '{p['name']}' skipped: {p.get('reason')}")
|
|
567
|
+
if args.tex:
|
|
568
|
+
tex_path = Path(args.tex)
|
|
569
|
+
tex_path.parent.mkdir(parents=True, exist_ok=True)
|
|
570
|
+
tex_path.write_text(render_tex(results))
|
|
571
|
+
print(f"Wrote {tex_path}")
|
|
572
|
+
return 0
|
|
573
|
+
|
|
574
|
+
|
|
575
|
+
if __name__ == "__main__":
|
|
576
|
+
raise SystemExit(main())
|