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