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,806 @@
1
+ #!/usr/bin/env python3
2
+ """Local UMLS Metathesaurus index on PostgreSQL, behind the UTS client interface.
3
+
4
+ Two things live here:
5
+
6
+ * **A loader** (``gem-umls-load-local`` / ``main``) that streams the RRF files of
7
+ a UMLS release (``MRCONSO.RRF``, ``MRSTY.RRF``) into two PostgreSQL tables via
8
+ ``COPY``, builds the search indexes (btree, full-text GIN, trigram GIN) and
9
+ records the release in ``umls_release``. Designed for ~9M English rows: the
10
+ files are never read into memory.
11
+
12
+ * **``PgUMLSClient``** -- a drop-in for ``uts_client.UTSClient`` for the
13
+ operations that only need atoms + semantic types (``search``, ``get_concept``,
14
+ ``atoms``, ``sources``) plus local-only extras (``concepts_by_tui``,
15
+ ``strings_like``, ``release``). Relations, definitions and hierarchy rollups
16
+ are *not* indexed locally and stay on UTS (they return ``[]`` here).
17
+
18
+ All SQL is produced by small pure functions (``build_schema_sql``,
19
+ ``build_index_sql``, ``build_search_sql`` ...) returning ``(sql, params)`` so
20
+ the query logic is unit-testable without a database. ``psycopg`` (v3) is
21
+ imported lazily, only on the code paths that actually touch PostgreSQL.
22
+
23
+ The RRF files are licensed content: keep them **outside** the repository and
24
+ never commit them or a database dump (see data/umls/README.md).
25
+ """
26
+ from __future__ import annotations
27
+
28
+ import argparse
29
+ import gzip
30
+ import logging
31
+ import os
32
+ import sys
33
+ from pathlib import Path
34
+ from typing import Callable, Iterable, Iterator, Optional
35
+
36
+ log = logging.getLogger(__name__)
37
+
38
+ # --------------------------------------------------------------------------
39
+ # RRF layout
40
+ # --------------------------------------------------------------------------
41
+
42
+ MRCONSO_COLUMNS = ["CUI", "LAT", "TS", "LUI", "STT", "SUI", "ISPREF", "AUI",
43
+ "SAUI", "SCUI", "SDUI", "SAB", "TTY", "CODE", "STR", "SRL",
44
+ "SUPPRESS", "CVF"]
45
+ MRSTY_COLUMNS = ["CUI", "TUI", "STN", "STY", "ATUI", "CVF"]
46
+
47
+ DEFAULT_DSN = "postgresql:///umls"
48
+ DSN_ENV = "GEM_UMLS_DSN"
49
+ MAX_PAGE_SIZE = 1000
50
+ DEFAULT_MIN_SIMILARITY = 0.3
51
+ COPY_BATCH = 50_000
52
+
53
+ # The distinct search types the harness / UI use, mapped onto SQL below.
54
+ SEARCH_TYPES = ("exact", "words", "normalizedWords", "normalizedString")
55
+
56
+
57
+ def iter_rrf(path: str | os.PathLike, ncols: int,
58
+ stats: Optional[dict] = None,
59
+ max_warnings: int = 10) -> Iterator[list[str]]:
60
+ """Stream the rows of an RRF file as lists of ``ncols`` strings.
61
+
62
+ RRF is pipe-delimited with **no quoting** and a **trailing pipe** on every
63
+ line, UTF-8 encoded. Lines with the wrong number of fields (short or long)
64
+ are skipped with a warning; ``stats`` (if given) is updated in place with
65
+ ``rows`` (yielded) and ``skipped`` counts, so the caller of a generator can
66
+ still report them. ``.gz`` files are read transparently.
67
+ """
68
+ path = Path(path)
69
+ if stats is None:
70
+ stats = {}
71
+ stats.setdefault("rows", 0)
72
+ stats.setdefault("skipped", 0)
73
+ opener = gzip.open if path.suffix == ".gz" else open
74
+ warned = 0
75
+ with opener(path, "rt", encoding="utf-8", newline="\n") as fh:
76
+ for lineno, line in enumerate(fh, 1):
77
+ line = line.rstrip("\r\n")
78
+ if not line:
79
+ continue
80
+ parts = line.split("|")
81
+ # A well-formed line splits into ncols fields plus an empty tail.
82
+ if len(parts) == ncols + 1 and parts[-1] == "":
83
+ stats["rows"] += 1
84
+ yield parts[:-1]
85
+ continue
86
+ stats["skipped"] += 1
87
+ if warned < max_warnings:
88
+ warned += 1
89
+ log.warning("%s:%d: expected %d fields, got %d -- skipped",
90
+ path.name, lineno, ncols,
91
+ len(parts) - (1 if parts and parts[-1] == "" else 0))
92
+ if stats["skipped"] > max_warnings:
93
+ log.warning("%s: %d malformed lines skipped in total",
94
+ path.name, stats["skipped"])
95
+
96
+
97
+ # --------------------------------------------------------------------------
98
+ # SQL: schema and indexes (pure functions)
99
+ # --------------------------------------------------------------------------
100
+
101
+ MRCONSO_TABLE = "mrconso"
102
+ MRSTY_TABLE = "mrsty"
103
+ RELEASE_TABLE = "umls_release"
104
+
105
+ # Index names are fixed so a reload can drop and rebuild them.
106
+ INDEXES: list[tuple[str, str]] = [
107
+ ("mrconso_cui_idx", f"CREATE INDEX IF NOT EXISTS mrconso_cui_idx ON {MRCONSO_TABLE} USING btree (cui)"),
108
+ ("mrconso_sab_idx", f"CREATE INDEX IF NOT EXISTS mrconso_sab_idx ON {MRCONSO_TABLE} USING btree (sab)"),
109
+ # Equality on lower(str). A *hash* index rather than btree on purpose: a few
110
+ # Metathesaurus strings exceed the btree row-size cap (~2.7 kB), which would
111
+ # abort the build; hash has no such limit and serves `lower(str) = ...`.
112
+ ("mrconso_lower_str_idx", f"CREATE INDEX IF NOT EXISTS mrconso_lower_str_idx ON {MRCONSO_TABLE} USING hash (lower(str))"),
113
+ ("mrconso_str_english_idx", f"CREATE INDEX IF NOT EXISTS mrconso_str_english_idx ON {MRCONSO_TABLE} USING gin (to_tsvector('english', str))"),
114
+ ("mrconso_str_simple_idx", f"CREATE INDEX IF NOT EXISTS mrconso_str_simple_idx ON {MRCONSO_TABLE} USING gin (to_tsvector('simple', str))"),
115
+ ("mrconso_str_trgm_idx", f"CREATE INDEX IF NOT EXISTS mrconso_str_trgm_idx ON {MRCONSO_TABLE} USING gin (lower(str) gin_trgm_ops)"),
116
+ ("mrsty_cui_idx", f"CREATE INDEX IF NOT EXISTS mrsty_cui_idx ON {MRSTY_TABLE} USING btree (cui)"),
117
+ ("mrsty_tui_idx", f"CREATE INDEX IF NOT EXISTS mrsty_tui_idx ON {MRSTY_TABLE} USING btree (tui)"),
118
+ ]
119
+
120
+
121
+ def build_schema_sql() -> list[str]:
122
+ """DDL for the three tables (idempotent: ``CREATE TABLE IF NOT EXISTS``)."""
123
+ conso_cols = ",\n".join(f" {c.lower()} text" for c in MRCONSO_COLUMNS)
124
+ sty_cols = ",\n".join(f" {c.lower()} text" for c in MRSTY_COLUMNS)
125
+ return [
126
+ f"CREATE TABLE IF NOT EXISTS {MRCONSO_TABLE} (\n{conso_cols}\n)",
127
+ f"CREATE TABLE IF NOT EXISTS {MRSTY_TABLE} (\n{sty_cols}\n)",
128
+ f"CREATE TABLE IF NOT EXISTS {RELEASE_TABLE} (\n"
129
+ " version text NOT NULL,\n"
130
+ " loaded_at timestamptz NOT NULL DEFAULT now(),\n"
131
+ " source_dir text,\n"
132
+ " rows_mrconso bigint,\n"
133
+ " rows_mrsty bigint,\n"
134
+ " lang text,\n"
135
+ " sabs text\n"
136
+ ")",
137
+ ]
138
+
139
+
140
+ def build_index_sql() -> list[str]:
141
+ """Index DDL, preceded by the ``pg_trgm`` extension the trigram index needs."""
142
+ return ["CREATE EXTENSION IF NOT EXISTS pg_trgm"] + [ddl for _, ddl in INDEXES]
143
+
144
+
145
+ def build_drop_index_sql() -> list[str]:
146
+ """Drop the indexes before a bulk reload (COPY is much faster without them)."""
147
+ return [f"DROP INDEX IF EXISTS {name}" for name, _ in INDEXES]
148
+
149
+
150
+ def build_copy_sql(table: str, columns: list[str]) -> str:
151
+ cols = ", ".join(c.lower() for c in columns)
152
+ return f"COPY {table} ({cols}) FROM STDIN"
153
+
154
+
155
+ def build_release_insert_sql() -> str:
156
+ return (f"INSERT INTO {RELEASE_TABLE} "
157
+ "(version, source_dir, rows_mrconso, rows_mrsty, lang, sabs) "
158
+ "VALUES (%s, %s, %s, %s, %s, %s)")
159
+
160
+
161
+ def build_prune_sty_sql() -> str:
162
+ """Drop semantic-type rows for CUIs that have no loaded atom (e.g. no
163
+ English string, or outside the ``--sabs`` subset)."""
164
+ return (f"DELETE FROM {MRSTY_TABLE} s WHERE NOT EXISTS "
165
+ f"(SELECT 1 FROM {MRCONSO_TABLE} m WHERE m.cui = s.cui)")
166
+
167
+
168
+ # --------------------------------------------------------------------------
169
+ # SQL: queries (pure functions returning (sql, params))
170
+ # --------------------------------------------------------------------------
171
+
172
+ def _split_list(value) -> Optional[list[str]]:
173
+ """A comma-joined string (the UTS convention), a list, or None -> list|None."""
174
+ if value is None:
175
+ return None
176
+ if isinstance(value, str):
177
+ items = [x.strip() for x in value.split(",")]
178
+ else:
179
+ items = [str(x).strip() for x in value]
180
+ items = [x for x in items if x]
181
+ return items or None
182
+
183
+
184
+ def normalize_string(term: str) -> str:
185
+ """The client-side twin of the SQL ``normalizedString`` expression:
186
+ lower-case, every run of non-alphanumerics collapsed to one space."""
187
+ out, prev_space = [], True
188
+ for ch in term.lower():
189
+ if ch.isalnum():
190
+ out.append(ch)
191
+ prev_space = False
192
+ elif not prev_space:
193
+ out.append(" ")
194
+ prev_space = True
195
+ return "".join(out).strip()
196
+
197
+
198
+ # Preferred name of a CUI: the MTH-preferred English atom when available.
199
+ # Ordering is by the same flags UTS uses for the concept name (TS=P preferred
200
+ # LUI, STT=PF preferred form, ISPREF=Y preferred AUI), with non-suppressed
201
+ # atoms first and a deterministic tie-break.
202
+ _PREFERRED_ATOM_ORDER = (
203
+ "(c.lat = 'ENG') DESC, (c.ts = 'P') DESC, (c.stt = 'PF') DESC, "
204
+ "(c.ispref = 'Y') DESC, (c.suppress = 'N') DESC, c.sab, c.aui")
205
+
206
+ _PREFERRED_JOIN = (
207
+ "LEFT JOIN LATERAL (\n"
208
+ f" SELECT c.str, c.sab FROM {MRCONSO_TABLE} c WHERE c.cui = h.cui\n"
209
+ f" ORDER BY {_PREFERRED_ATOM_ORDER} LIMIT 1\n"
210
+ ") p ON true")
211
+
212
+ _STY_SUBSELECT = (
213
+ "(SELECT coalesce(array_agg(s.sty ORDER BY s.sty), ARRAY[]::text[])\n"
214
+ f" FROM {MRSTY_TABLE} s WHERE s.cui = h.cui) AS semantic_types")
215
+
216
+
217
+ def build_set_similarity_sql(min_similarity: float = DEFAULT_MIN_SIMILARITY) -> tuple[str, list]:
218
+ """Transaction-local trigram threshold used by the ``%`` operator (so the
219
+ trigram GIN index is used, unlike ``similarity(...) > x``)."""
220
+ return ("SELECT set_config('pg_trgm.similarity_threshold', %s, true)",
221
+ [str(float(min_similarity))])
222
+
223
+
224
+ def build_search_sql(term: str, search_type: str = "words",
225
+ sabs=None, page_size: int = 200,
226
+ semantic_types=None, partial: bool = False,
227
+ lang: Optional[str] = "ENG") -> tuple[str, list]:
228
+ """SQL + params for ``PgUMLSClient.search``.
229
+
230
+ Match clause by ``search_type`` (``partial`` overrides it):
231
+
232
+ * ``exact`` ``lower(str) = lower(term)``
233
+ * ``words`` ``to_tsvector('english', str) @@ plainto_tsquery('english', term)``
234
+ * ``normalizedWords`` same with the ``simple`` configuration (no stemming)
235
+ * ``normalizedString`` the ``simple`` tsquery (index) **and** the whole
236
+ punctuation-folded string equal to the folded term
237
+ * ``partial`` ``websearch_to_tsquery('english', w1 or w2 ...)`` --
238
+ any word may match -- OR trigram-similar
239
+ (``lower(str) % lower(term)``), ranked by similarity
240
+
241
+ ``sabs`` / ``semantic_types`` are comma-joined lists (UTS convention) or
242
+ sequences and become ``= ANY(%s)`` filters; ``semantic_types`` are TUIs
243
+ resolved through ``mrsty``. Results are one row per CUI with the preferred
244
+ name, its SAB and the semantic-type names, capped at ``page_size``.
245
+ """
246
+ if partial:
247
+ mode = "partial"
248
+ elif search_type in SEARCH_TYPES:
249
+ mode = search_type
250
+ else:
251
+ raise ValueError(f"unsupported search_type {search_type!r}; "
252
+ f"expected one of {SEARCH_TYPES} or partial=True")
253
+
254
+ where: list[str] = []
255
+ params: list = []
256
+ if lang:
257
+ where.append("m.lat = %s")
258
+ params.append(lang)
259
+
260
+ if mode == "exact":
261
+ where.append("lower(m.str) = lower(%s)")
262
+ params.append(term)
263
+ score = "1.0"
264
+ elif mode == "words":
265
+ where.append("to_tsvector('english', m.str) @@ plainto_tsquery('english', %s)")
266
+ params.append(term)
267
+ # exact-name bonus + length-normalized rank (flag 1: /(1+log(len))) --
268
+ # otherwise long clinical strings repeating the term outrank the
269
+ # concept actually named by it.
270
+ score = ("(CASE WHEN lower(m.str) = lower(%s) THEN 1000.0 ELSE 0.0 END)"
271
+ " + ts_rank(to_tsvector('english', m.str),"
272
+ " plainto_tsquery('english', %s), 1)")
273
+ elif mode == "normalizedWords":
274
+ where.append("to_tsvector('simple', m.str) @@ plainto_tsquery('simple', %s)")
275
+ params.append(term)
276
+ score = ("(CASE WHEN lower(m.str) = lower(%s) THEN 1000.0 ELSE 0.0 END)"
277
+ " + ts_rank(to_tsvector('simple', m.str),"
278
+ " plainto_tsquery('simple', %s), 1)")
279
+ elif mode == "normalizedString":
280
+ where.append("to_tsvector('simple', m.str) @@ plainto_tsquery('simple', %s)")
281
+ params.append(term)
282
+ where.append("regexp_replace(lower(m.str), '[^[:alnum:]]+', ' ', 'g') = %s")
283
+ params.append(normalize_string(term))
284
+ score = "1.0"
285
+ else: # partial
286
+ any_word = " or ".join(term.split()) or term
287
+ where.append("(to_tsvector('english', m.str) @@ websearch_to_tsquery('english', %s)"
288
+ " OR lower(m.str) %% lower(%s))")
289
+ params.extend([any_word, term])
290
+ score = "similarity(lower(m.str), lower(%s))"
291
+
292
+ score_params: list = [term] * score.count("%s")
293
+
294
+ sab_list = _split_list(sabs)
295
+ if sab_list:
296
+ where.append("m.sab = ANY(%s)")
297
+ params.append(sab_list)
298
+ tui_list = _split_list(semantic_types)
299
+ if tui_list:
300
+ where.append(f"EXISTS (SELECT 1 FROM {MRSTY_TABLE} t WHERE t.cui = m.cui AND t.tui = ANY(%s))")
301
+ params.append(tui_list)
302
+
303
+ limit = max(1, min(int(page_size or 200), MAX_PAGE_SIZE))
304
+ sql = (
305
+ "WITH hits AS (\n"
306
+ f" SELECT m.cui, max({score}) AS score\n"
307
+ f" FROM {MRCONSO_TABLE} m\n"
308
+ f" WHERE {' AND '.join(where)}\n"
309
+ " GROUP BY m.cui\n"
310
+ " ORDER BY score DESC, m.cui\n"
311
+ " LIMIT %s\n"
312
+ ")\n"
313
+ "SELECT h.cui, p.str AS name, p.sab AS root_source, h.score,\n"
314
+ f" {_STY_SUBSELECT}\n"
315
+ "FROM hits h\n"
316
+ f"{_PREFERRED_JOIN}\n"
317
+ "ORDER BY h.score DESC, h.cui"
318
+ )
319
+ return sql, score_params + params + [limit]
320
+
321
+
322
+ def build_concepts_by_tui_sql(tuis, limit: Optional[int] = None) -> tuple[str, list]:
323
+ tui_list = _split_list(tuis) or []
324
+ sql = (
325
+ "WITH hits AS (\n"
326
+ f" SELECT DISTINCT s.cui FROM {MRSTY_TABLE} s WHERE s.tui = ANY(%s)\n"
327
+ " ORDER BY s.cui" + ("\n LIMIT %s" if limit else "") + "\n"
328
+ ")\n"
329
+ "SELECT h.cui, p.str AS name, p.sab AS root_source,\n"
330
+ f" {_STY_SUBSELECT}\n"
331
+ "FROM hits h\n"
332
+ f"{_PREFERRED_JOIN}\n"
333
+ "ORDER BY h.cui"
334
+ )
335
+ params: list = [tui_list]
336
+ if limit:
337
+ params.append(int(limit))
338
+ return sql, params
339
+
340
+
341
+ def build_sty_backfill_sql(types: list[dict]) -> tuple[str, list]:
342
+ """Fill MRSTY's ``sty``/``stn`` from the Semantic Network reference where the
343
+ release left them blank (the raw full-release MRSTY.RRF carries only
344
+ CUI|TUI). ``types`` = [{tui, name, tree_number}] (semantic_types.py)."""
345
+ rows = [(t["tui"], t["name"], t.get("tree_number") or "") for t in types if t.get("tui")]
346
+ if not rows:
347
+ return ("SELECT 0", [])
348
+ values = ", ".join(["(%s, %s, %s)"] * len(rows))
349
+ sql = (f"UPDATE {MRSTY_TABLE} AS s SET sty = v.name, stn = v.stn\n"
350
+ f"FROM (VALUES {values}) AS v(tui, name, stn)\n"
351
+ "WHERE s.tui = v.tui AND (s.sty IS NULL OR s.sty = '')")
352
+ params: list = [x for r in rows for x in r]
353
+ return sql, params
354
+
355
+
356
+ def build_strings_like_sql(term: str, limit: int = 200,
357
+ lang: Optional[str] = "ENG") -> tuple[str, list]:
358
+ """Trigram-similar strings (the threshold comes from
359
+ ``build_set_similarity_sql``, executed first in the same transaction)."""
360
+ where = ["lower(str) %% lower(%s)"]
361
+ params: list = [term]
362
+ if lang:
363
+ where.append("lat = %s")
364
+ params.append(lang)
365
+ sql = (
366
+ "SELECT cui, str, sab, tty, similarity(lower(str), lower(%s)) AS similarity\n"
367
+ f"FROM {MRCONSO_TABLE}\n"
368
+ f"WHERE {' AND '.join(where)}\n"
369
+ "ORDER BY similarity DESC, cui, aui\n"
370
+ "LIMIT %s"
371
+ )
372
+ return sql, [term] + params + [max(1, int(limit))]
373
+
374
+
375
+ def build_sources_sql() -> tuple[str, list]:
376
+ return (
377
+ "SELECT sab, mode() WITHIN GROUP (ORDER BY lat) AS language, count(*) AS n\n"
378
+ f"FROM {MRCONSO_TABLE} GROUP BY sab ORDER BY sab", [])
379
+
380
+
381
+ def build_concept_sql(cui: str) -> tuple[str, list]:
382
+ sql = (
383
+ "WITH hits AS (SELECT %s::text AS cui)\n"
384
+ "SELECT h.cui, p.str AS name, p.sab AS root_source,\n"
385
+ f" {_STY_SUBSELECT},\n"
386
+ f" (SELECT coalesce(array_agg(s.tui ORDER BY s.sty), ARRAY[]::text[])\n"
387
+ f" FROM {MRSTY_TABLE} s WHERE s.cui = h.cui) AS tuis,\n"
388
+ f" (SELECT count(*) FROM {MRCONSO_TABLE} a WHERE a.cui = h.cui) AS atom_count\n"
389
+ "FROM hits h\n"
390
+ f"{_PREFERRED_JOIN}"
391
+ )
392
+ return sql, [cui]
393
+
394
+
395
+ def build_atoms_sql(cui: str, limit: int = 300) -> tuple[str, list]:
396
+ sql = (
397
+ "SELECT c.sab, c.tty, c.str AS name, c.lat AS language, c.code, c.suppress\n"
398
+ f"FROM {MRCONSO_TABLE} c WHERE c.cui = %s\n"
399
+ f"ORDER BY {_PREFERRED_ATOM_ORDER}\n"
400
+ "LIMIT %s"
401
+ )
402
+ return sql, [cui, max(1, int(limit))]
403
+
404
+
405
+ def build_release_sql() -> tuple[str, list]:
406
+ return (
407
+ "SELECT version, loaded_at, source_dir, rows_mrconso, rows_mrsty, lang, sabs\n"
408
+ f"FROM {RELEASE_TABLE} ORDER BY loaded_at DESC LIMIT 1", [])
409
+
410
+
411
+ # --------------------------------------------------------------------------
412
+ # psycopg (lazy)
413
+ # --------------------------------------------------------------------------
414
+
415
+ def _psycopg():
416
+ """Import psycopg 3 on demand with an actionable error if it is missing."""
417
+ try:
418
+ import psycopg # type: ignore
419
+ except ImportError as e: # pragma: no cover - exercised only without psycopg
420
+ raise RuntimeError(
421
+ "The local UMLS index needs psycopg 3 (PostgreSQL driver). Install it "
422
+ "with: pip install 'psycopg[binary]' or pip install -e '.[local]'"
423
+ ) from e
424
+ return psycopg
425
+
426
+
427
+ def _connect(dsn: Optional[str]):
428
+ dsn = dsn or os.environ.get(DSN_ENV) or DEFAULT_DSN
429
+ return _psycopg().connect(dsn, autocommit=True)
430
+
431
+
432
+ def _rows_as_dicts(cur) -> list[dict]:
433
+ """Fetch every row of the executed cursor as a dict (driver-agnostic)."""
434
+ desc = cur.description or []
435
+ cols = [getattr(d, "name", None) or d[0] for d in desc]
436
+ return [dict(zip(cols, r)) for r in cur.fetchall()]
437
+
438
+
439
+ # --------------------------------------------------------------------------
440
+ # Loader
441
+ # --------------------------------------------------------------------------
442
+
443
+ def _rrf_path(rrf_dir: Path, name: str) -> Path:
444
+ for cand in (rrf_dir / name, rrf_dir / f"{name}.gz"):
445
+ if cand.is_file():
446
+ return cand
447
+ raise FileNotFoundError(f"{name} not found in {rrf_dir}")
448
+
449
+
450
+ def filtered_mrconso(rrf_dir: Path, lang: Optional[str], sabs: Optional[set],
451
+ stats: dict) -> Iterator[list[str]]:
452
+ """Stream MRCONSO rows, applying the LAT and SAB filters; ``stats`` gets
453
+ ``read``, ``kept``, ``dropped_lang``, ``dropped_sab``, ``skipped``."""
454
+ parse = {}
455
+ stats.update({"read": 0, "kept": 0, "dropped_lang": 0, "dropped_sab": 0})
456
+ i_lat, i_sab = MRCONSO_COLUMNS.index("LAT"), MRCONSO_COLUMNS.index("SAB")
457
+ for row in iter_rrf(_rrf_path(rrf_dir, "MRCONSO.RRF"), len(MRCONSO_COLUMNS), parse):
458
+ stats["read"] += 1
459
+ if lang and row[i_lat] != lang:
460
+ stats["dropped_lang"] += 1
461
+ continue
462
+ if sabs and row[i_sab] not in sabs:
463
+ stats["dropped_sab"] += 1
464
+ continue
465
+ stats["kept"] += 1
466
+ yield row
467
+ stats["skipped"] = parse.get("skipped", 0)
468
+
469
+
470
+ def filtered_mrsty(rrf_dir: Path, stats: dict) -> Iterator[list[str]]:
471
+ parse = {}
472
+ stats.update({"read": 0, "kept": 0})
473
+ for row in iter_rrf(_rrf_path(rrf_dir, "MRSTY.RRF"), len(MRSTY_COLUMNS), parse):
474
+ stats["read"] += 1
475
+ stats["kept"] += 1
476
+ yield row
477
+ stats["skipped"] = parse.get("skipped", 0)
478
+
479
+
480
+ def _copy_rows(cur, table: str, columns: list[str], rows: Iterable[list[str]],
481
+ batch: int = COPY_BATCH, progress: Optional[Callable[[int], None]] = None) -> int:
482
+ """Stream ``rows`` into ``table`` with COPY, one COPY statement per
483
+ ``batch`` rows (bounded memory, periodic progress). Empty fields -> NULL."""
484
+ sql = build_copy_sql(table, columns)
485
+ total = 0
486
+ chunk: list = []
487
+
488
+ def flush():
489
+ nonlocal total
490
+ if not chunk:
491
+ return
492
+ with cur.copy(sql) as copy:
493
+ for r in chunk:
494
+ copy.write_row([v if v != "" else None for v in r])
495
+ total += len(chunk)
496
+ chunk.clear()
497
+ if progress:
498
+ progress(total)
499
+
500
+ for row in rows:
501
+ chunk.append(row)
502
+ if len(chunk) >= batch:
503
+ flush()
504
+ flush()
505
+ return total
506
+
507
+
508
+ def dry_run(rrf_dir: Path, lang: Optional[str], sabs: Optional[set],
509
+ skip_indexes: bool = False, out=None) -> dict:
510
+ """Parse the files and report counts per filter plus the SQL; no database."""
511
+ out = out or sys.stdout
512
+ conso, sty = {}, {}
513
+ for _ in filtered_mrconso(rrf_dir, lang, sabs, conso):
514
+ pass
515
+ for _ in filtered_mrsty(rrf_dir, sty):
516
+ pass
517
+ print(f"MRCONSO: read {conso['read']}, kept {conso['kept']} "
518
+ f"(dropped: language {conso['dropped_lang']}, sab {conso['dropped_sab']}; "
519
+ f"malformed skipped {conso['skipped']})", file=out)
520
+ print(f"MRSTY: read {sty['read']}, kept {sty['kept']} "
521
+ f"(malformed skipped {sty['skipped']})", file=out)
522
+ print("\n-- schema", file=out)
523
+ for s in build_schema_sql():
524
+ print(s + ";", file=out)
525
+ print("\n-- copy", file=out)
526
+ print(build_copy_sql(MRCONSO_TABLE, MRCONSO_COLUMNS) + ";", file=out)
527
+ print(build_copy_sql(MRSTY_TABLE, MRSTY_COLUMNS) + ";", file=out)
528
+ if not skip_indexes:
529
+ print("\n-- indexes", file=out)
530
+ for s in build_index_sql():
531
+ print(s + ";", file=out)
532
+ print("\n-- release", file=out)
533
+ print(build_release_insert_sql() + ";", file=out)
534
+ return {"mrconso": conso, "mrsty": sty}
535
+
536
+
537
+ def load(rrf_dir: Path, dsn: Optional[str], release: str,
538
+ lang: Optional[str], sabs: Optional[set], skip_indexes: bool = False,
539
+ replace: bool = False, batch: int = COPY_BATCH, out=None,
540
+ conn=None) -> dict:
541
+ """Load the release into PostgreSQL. ``conn`` may be injected (tests)."""
542
+ out = out or sys.stdout
543
+ own = conn is None
544
+ if own:
545
+ conn = _connect(dsn)
546
+ try:
547
+ with conn.cursor() as cur:
548
+ for s in build_schema_sql():
549
+ cur.execute(s)
550
+ cur.execute(f"SELECT count(*) FROM {MRCONSO_TABLE}")
551
+ existing = cur.fetchone()[0]
552
+ if existing:
553
+ if not replace:
554
+ raise SystemExit(
555
+ f"{MRCONSO_TABLE} already holds {existing} rows; "
556
+ "rerun with --replace to truncate and reload.")
557
+ print(f"replacing {existing} existing rows", file=out)
558
+ cur.execute(f"TRUNCATE {MRCONSO_TABLE}, {MRSTY_TABLE}")
559
+ for s in build_drop_index_sql():
560
+ cur.execute(s)
561
+
562
+ conso, sty = {}, {}
563
+ n_conso = _copy_rows(
564
+ cur, MRCONSO_TABLE, MRCONSO_COLUMNS,
565
+ filtered_mrconso(rrf_dir, lang, sabs, conso), batch,
566
+ progress=lambda n: print(f" mrconso: {n} rows", file=out))
567
+ n_sty = _copy_rows(
568
+ cur, MRSTY_TABLE, MRSTY_COLUMNS, filtered_mrsty(rrf_dir, sty), batch,
569
+ progress=lambda n: print(f" mrsty: {n} rows", file=out))
570
+ if not skip_indexes:
571
+ print("building indexes (this takes a while) ...", file=out)
572
+ for s in build_index_sql():
573
+ print(f" {s.split(' ON ')[0]}", file=out)
574
+ cur.execute(s)
575
+ cur.execute(build_prune_sty_sql())
576
+ pruned = cur.rowcount if cur.rowcount and cur.rowcount > 0 else 0
577
+ # the raw full-release MRSTY.RRF carries only CUI|TUI (STN/STY blank):
578
+ # name the types from the Semantic Network reference we ship
579
+ from forome.gem.umls import semantic_types as stylib
580
+ bsql, bparams = build_sty_backfill_sql(list(stylib._load().values()))
581
+ cur.execute(bsql, bparams)
582
+ named = cur.rowcount if cur.rowcount and cur.rowcount > 0 else 0
583
+ if named:
584
+ print(f" mrsty: named {named} rows from the Semantic Network reference",
585
+ file=out)
586
+ cur.execute(build_release_insert_sql(),
587
+ [release, str(rrf_dir), n_conso, n_sty - pruned, lang or "ALL",
588
+ ",".join(sorted(sabs)) if sabs else None])
589
+ cur.execute("ANALYZE " + MRCONSO_TABLE)
590
+ cur.execute("ANALYZE " + MRSTY_TABLE)
591
+ print(f"loaded {release}: mrconso {n_conso} rows "
592
+ f"(read {conso['read']}, dropped language {conso['dropped_lang']}, "
593
+ f"sab {conso['dropped_sab']}, malformed {conso['skipped']}); "
594
+ f"mrsty {n_sty - pruned} rows (pruned {pruned} without atoms)", file=out)
595
+ return {"mrconso": conso, "mrsty": sty, "rows_mrconso": n_conso,
596
+ "rows_mrsty": n_sty - pruned}
597
+ finally:
598
+ if own:
599
+ conn.close()
600
+
601
+
602
+ def _parse_args(argv=None) -> argparse.Namespace:
603
+ ap = argparse.ArgumentParser(
604
+ prog="gem-umls-load-local",
605
+ description="Load a UMLS release (MRCONSO.RRF + MRSTY.RRF) into a local "
606
+ "PostgreSQL index used by forome.gem.umls.local_umls.PgUMLSClient.")
607
+ ap.add_argument("--rrf-dir", required=True,
608
+ help="directory holding MRCONSO.RRF and MRSTY.RRF (e.g. ~/umls/2026AA/META)")
609
+ ap.add_argument("--dsn", default=None,
610
+ help=f"PostgreSQL DSN (default: ${DSN_ENV}, else {DEFAULT_DSN})")
611
+ ap.add_argument("--release", required=True, help="release label, e.g. 2026AA")
612
+ ap.add_argument("--lang", default="ENG",
613
+ help="LAT to keep (default ENG); ALL keeps every language")
614
+ ap.add_argument("--sabs", default=None,
615
+ help="optional comma list of source vocabularies to keep")
616
+ ap.add_argument("--dry-run", action="store_true",
617
+ help="parse the files, print counts per filter and the SQL; no database")
618
+ ap.add_argument("--skip-indexes", action="store_true",
619
+ help="load the tables only (indexes can be built later with build_index_sql)")
620
+ ap.add_argument("--replace", action="store_true",
621
+ help="truncate an existing load before loading")
622
+ ap.add_argument("--batch", type=int, default=COPY_BATCH,
623
+ help=f"rows per COPY statement (default {COPY_BATCH})")
624
+ return ap.parse_args(argv)
625
+
626
+
627
+ def main(argv=None) -> int:
628
+ logging.basicConfig(level=logging.INFO, format="%(levelname)s %(message)s")
629
+ a = _parse_args(argv)
630
+ rrf_dir = Path(a.rrf_dir).expanduser()
631
+ lang = None if a.lang.upper() == "ALL" else a.lang.upper()
632
+ sabs = set(_split_list(a.sabs) or []) or None
633
+ if a.dry_run:
634
+ dry_run(rrf_dir, lang, sabs, a.skip_indexes)
635
+ return 0
636
+ load(rrf_dir, a.dsn, a.release, lang, sabs, skip_indexes=a.skip_indexes,
637
+ replace=a.replace, batch=a.batch)
638
+ return 0
639
+
640
+
641
+ # --------------------------------------------------------------------------
642
+ # Client
643
+ # --------------------------------------------------------------------------
644
+
645
+ class PgUMLSClient:
646
+ """UTS-compatible client over the local PostgreSQL index.
647
+
648
+ ``search``, ``get_concept``, ``atoms`` and ``sources`` return the same dict
649
+ shapes as ``uts_client.UTSClient``; ``concepts_by_tui``, ``strings_like``
650
+ and ``release`` are local extras. The connection is opened lazily on first
651
+ use (``conn`` may be injected, e.g. a fake in tests). ``lang`` restricts
652
+ searches to one language (``None`` = every loaded language).
653
+ """
654
+
655
+ def __init__(self, dsn: Optional[str] = None, conn=None,
656
+ lang: Optional[str] = "ENG",
657
+ min_similarity: float = DEFAULT_MIN_SIMILARITY):
658
+ self.dsn = dsn
659
+ self._conn = conn
660
+ self.lang = lang
661
+ self.min_similarity = min_similarity
662
+
663
+ # -- plumbing ----------------------------------------------------------
664
+ @property
665
+ def conn(self):
666
+ if self._conn is None:
667
+ self._conn = _connect(self.dsn)
668
+ return self._conn
669
+
670
+ def close(self):
671
+ if self._conn is not None:
672
+ self._conn.close()
673
+ self._conn = None
674
+
675
+ def _query(self, sql: str, params=None, pre: Optional[list[tuple[str, list]]] = None) -> list[dict]:
676
+ """Run ``pre`` statements then ``sql`` inside ONE transaction. The
677
+ connection is autocommit, so without an explicit block each statement
678
+ would commit on its own and a transaction-local ``set_config`` (the
679
+ trigram threshold) would be gone before the SELECT ran."""
680
+ import contextlib
681
+ tx = getattr(self.conn, "transaction", None)
682
+ block = tx() if (pre and callable(tx)) else contextlib.nullcontext()
683
+ with block, self.conn.cursor() as cur:
684
+ for psql, pparams in pre or []:
685
+ cur.execute(psql, pparams)
686
+ cur.execute(sql, params or [])
687
+ return _rows_as_dicts(cur)
688
+
689
+ def _similarity_pre(self) -> list[tuple[str, list]]:
690
+ return [build_set_similarity_sql(self.min_similarity)]
691
+
692
+ # -- UTS-compatible ----------------------------------------------------
693
+ def search(self, term: str, search_type: str = "words",
694
+ sabs: Optional[str] = None, page_size: int = 200,
695
+ semantic_types: Optional[str] = None,
696
+ partial: bool = False) -> list[dict]:
697
+ """Same contract as ``UTSClient.search``: ``[{cui, name, root_source,
698
+ semantic_types: [names]}]``, one row per concept."""
699
+ if not (term or "").strip():
700
+ return []
701
+ sql, params = build_search_sql(term, search_type, sabs, page_size,
702
+ semantic_types, partial, lang=self.lang)
703
+ pre = self._similarity_pre() if partial else None
704
+ out = []
705
+ for r in self._query(sql, params, pre):
706
+ if not r.get("name"):
707
+ continue
708
+ out.append({"cui": r["cui"], "name": r["name"],
709
+ "root_source": r.get("root_source"),
710
+ "semantic_types": list(r.get("semantic_types") or [])})
711
+ return out
712
+
713
+ def sources(self) -> list[dict]:
714
+ """Distinct SABs -> [{sab, name, language, count}] (``name`` is the SAB
715
+ itself: MRSAB is not loaded)."""
716
+ sql, params = build_sources_sql()
717
+ return [{"sab": r["sab"], "name": r["sab"], "language": r.get("language") or "",
718
+ "count": int(r.get("n") or 0)} for r in self._query(sql, params)]
719
+
720
+ def get_concept(self, cui: str) -> Optional[dict]:
721
+ sql, params = build_concept_sql(cui)
722
+ rows = self._query(sql, params)
723
+ if not rows or not rows[0].get("name"):
724
+ return None
725
+ r = rows[0]
726
+ names = list(r.get("semantic_types") or [])
727
+ tuis = list(r.get("tuis") or [])
728
+ return {"cui": r["cui"], "name": r["name"],
729
+ "root_source": r.get("root_source"),
730
+ "semantic_types": names,
731
+ "semantic_type_details": [{"name": n, "tui": t} for n, t in zip(names, tuis)],
732
+ "status": None, "atom_count": int(r.get("atom_count") or 0)}
733
+
734
+ def atoms(self, cui: str, page_size: int = 300) -> list[dict]:
735
+ """Atoms of a CUI from ``mrconso`` -> [{sab, tty, name, language, code,
736
+ obsolete, suppressible}] (``obsolete`` = SUPPRESS in O/E, ``suppressible``
737
+ = SUPPRESS other than N)."""
738
+ sql, params = build_atoms_sql(cui, page_size)
739
+ out = []
740
+ for r in self._query(sql, params):
741
+ sup = (r.get("suppress") or "N").upper()
742
+ out.append({"sab": r.get("sab"), "tty": r.get("tty"), "name": r.get("name"),
743
+ "language": r.get("language"), "code": r.get("code") or "",
744
+ "obsolete": sup in ("O", "E"), "suppressible": sup != "N"})
745
+ return out
746
+
747
+ def get_semantic_type(self, tui: str) -> Optional[dict]:
748
+ """From the bundled Semantic Network reference (no database needed)."""
749
+ from forome.gem.umls import semantic_types as ST
750
+ t = ST.get(tui)
751
+ if not t:
752
+ return None
753
+ return {"tui": t.get("tui"), "name": t.get("name"),
754
+ "tree_number": t.get("tree_number"),
755
+ "abbreviation": t.get("abbreviation"),
756
+ "definition": (t.get("definition") or "").strip()}
757
+
758
+ # -- stay on UTS -------------------------------------------------------
759
+ def relations(self, cui: str, page_size: int = 400) -> list[dict]:
760
+ """Not indexed locally (MRREL is not loaded); use ``UTSClient``."""
761
+ return []
762
+
763
+ def definitions(self, cui: str, page_size: int = 5) -> list[dict]:
764
+ """Not indexed locally (MRDEF is not loaded); use ``UTSClient``."""
765
+ return []
766
+
767
+ def source_ancestors(self, sab: str, code: str, page_size: int = 120) -> list[dict]:
768
+ """Not indexed locally (hierarchies need MRHIER); use ``UTSClient``."""
769
+ return []
770
+
771
+ def rollup(self, cui: str, use_sab=None, sab_allow=None,
772
+ node_cap: int = 80) -> list[dict]:
773
+ """Not indexed locally (hierarchies need MRHIER); use ``UTSClient``."""
774
+ return []
775
+
776
+ # -- local extras ------------------------------------------------------
777
+ def concepts_by_tui(self, tuis, limit: Optional[int] = None) -> list[dict]:
778
+ """Every concept carrying one of the TUIs -> [{cui, name, semantic_types}]."""
779
+ if not _split_list(tuis):
780
+ return []
781
+ sql, params = build_concepts_by_tui_sql(tuis, limit)
782
+ return [{"cui": r["cui"], "name": r.get("name"),
783
+ "root_source": r.get("root_source"),
784
+ "semantic_types": list(r.get("semantic_types") or [])}
785
+ for r in self._query(sql, params)]
786
+
787
+ def strings_like(self, term: str, min_similarity: float = DEFAULT_MIN_SIMILARITY,
788
+ limit: int = 200) -> list[dict]:
789
+ """Trigram-similar atoms -> [{cui, str, sab, tty, similarity}]."""
790
+ if not (term or "").strip():
791
+ return []
792
+ sql, params = build_strings_like_sql(term, limit, lang=self.lang)
793
+ pre = [build_set_similarity_sql(min_similarity)]
794
+ return [{"cui": r["cui"], "str": r["str"], "sab": r.get("sab"),
795
+ "tty": r.get("tty"), "similarity": float(r.get("similarity") or 0)}
796
+ for r in self._query(sql, params, pre)]
797
+
798
+ def release(self) -> Optional[dict]:
799
+ """The most recent ``umls_release`` row (None before any load)."""
800
+ sql, params = build_release_sql()
801
+ rows = self._query(sql, params)
802
+ return rows[0] if rows else None
803
+
804
+
805
+ if __name__ == "__main__":
806
+ sys.exit(main())