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.
@@ -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())