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,2253 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""GEM Mapping Studio: local, Metathesaurus-integrated adjudication UI + axis
|
|
3
|
+
builder for the UMLS crosswalk.
|
|
4
|
+
|
|
5
|
+
A small localhost web app with two jobs, over one mapping directory (the
|
|
6
|
+
"workspace"). Navigation follows the model's own hierarchy: core dimensions
|
|
7
|
+
first in canonical order, then conditional dimensions grouped by activation
|
|
8
|
+
rule, then candidates -- driven by the inventory's per-dimension tier / order /
|
|
9
|
+
activation metadata (missing tier defaults to core).
|
|
10
|
+
|
|
11
|
+
* Build / modify AXES. An axis maps a GEM dimension to a UMLS *semantic type*
|
|
12
|
+
(TUI); the type's subtree becomes the value-search filter for that dimension.
|
|
13
|
+
"+ New axis" constructs one from scratch (search / browse the Semantic Network,
|
|
14
|
+
pick the most-specific type, see its subtree and the filter breadth it
|
|
15
|
+
implies); clicking an existing axis modifies it. Axes are written to the
|
|
16
|
+
workspace's dimensions_inventory.yaml -- so the tool works against an EMPTY
|
|
17
|
+
directory (build from scratch) or a populated one (modify).
|
|
18
|
+
|
|
19
|
+
* Adjudicate VALUES. For each value it shows the GEM meaning (from
|
|
20
|
+
dimensions.md), the query used, the current decision, and the candidate
|
|
21
|
+
concepts. You can search the UMLS Metathesaurus live, read each concept's
|
|
22
|
+
definition, semantic type, vocabularies, is_a hierarchy and rollup, then
|
|
23
|
+
Accept a concept, mark it Unmapped, or Clear. Choices are written to the
|
|
24
|
+
workspace's adjudications.yaml; a Rebuild button reruns the harness.
|
|
25
|
+
|
|
26
|
+
Concept-level relations (is_a, part_of, gene_mapped_to_disease, ...) are shown
|
|
27
|
+
only for value candidates -- they are concept-to-concept edges and do not apply
|
|
28
|
+
to axes, whose structure is the Semantic Network, not the Metathesaurus.
|
|
29
|
+
|
|
30
|
+
Honest by construction: accepting a concept found via search (not among the
|
|
31
|
+
query's candidates) makes the harness fetch it live from UMLS on rebuild to
|
|
32
|
+
confirm it is a real concept -- the harness never records a CUI it cannot
|
|
33
|
+
resolve.
|
|
34
|
+
|
|
35
|
+
It runs locally (not a hosted artifact) because it must reach the licensed UTS
|
|
36
|
+
API with your key and write files. The key is read from UMLS_API_KEY or .envrc
|
|
37
|
+
and never sent to the browser; the browser calls local /api/* endpoints that
|
|
38
|
+
proxy UMLS.
|
|
39
|
+
|
|
40
|
+
Usage:
|
|
41
|
+
export UMLS_API_KEY=... # or have it in .envrc; optional -- axis
|
|
42
|
+
# construction works offline
|
|
43
|
+
gem-umls-adjudicate [--data-dir DIR] [--port N] [--no-browser]
|
|
44
|
+
DIR is a mapping workspace (inventory / crosswalk / adjudications); it
|
|
45
|
+
may be empty. Defaults to the repo's data/umls, or $GEM_DATA_DIR.
|
|
46
|
+
Requirements: pyyaml, requests (stdlib http.server otherwise).
|
|
47
|
+
"""
|
|
48
|
+
from __future__ import annotations
|
|
49
|
+
|
|
50
|
+
import json
|
|
51
|
+
import os
|
|
52
|
+
import re
|
|
53
|
+
import sys
|
|
54
|
+
import webbrowser
|
|
55
|
+
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
|
56
|
+
from pathlib import Path
|
|
57
|
+
from urllib.parse import urlparse, parse_qs
|
|
58
|
+
|
|
59
|
+
import yaml
|
|
60
|
+
|
|
61
|
+
from forome.gem.umls import build_umls_crosswalk as H
|
|
62
|
+
from forome.gem.umls import semantic_types as stylib
|
|
63
|
+
from forome.gem.umls.uts_client import UTSClient, NullClient
|
|
64
|
+
from forome.gem.umls._paths import DATA_DIR, SCHEMA_DIR
|
|
65
|
+
|
|
66
|
+
CROSSWALK = DATA_DIR / "umls_crosswalk.yaml"
|
|
67
|
+
DIMS_MD = SCHEMA_DIR / "dimensions.md"
|
|
68
|
+
PREF_DEF_SOURCES = ["MSH", "NCI", "NCI_NCI-GLOSS", "SNOMEDCT_US", "HPO", "CSP",
|
|
69
|
+
"MDR", "AIR", "PDQ", "GO", "MTH"]
|
|
70
|
+
NON_ENGLISH = ("CZE", "DUT", "FRE", "GER", "SPA", "ITA", "POR", "JPN", "RUS",
|
|
71
|
+
"SCR", "SWE", "FIN", "NOR", "DAN", "HUN", "POL", "GRE", "KOR")
|
|
72
|
+
|
|
73
|
+
# rela values that are lexical/synonym bookkeeping rather than a concept-to-
|
|
74
|
+
# concept relationship -- filtered out of the "other relations" view.
|
|
75
|
+
_LEXICAL_RELA = frozenset({
|
|
76
|
+
"has_permuted_term", "permuted_term_of", "translation_of", "has_translation",
|
|
77
|
+
"has_transliterated_form", "transliterated_form_of",
|
|
78
|
+
"has_expanded_form", "expanded_form_of", "has_alias", "alias_of",
|
|
79
|
+
"has_prev_name", "prev_name_of", "has_sort_version", "sort_version_of",
|
|
80
|
+
"has_entry_version", "entry_version_of", "has_common_name", "common_name_of",
|
|
81
|
+
"has_permuted_term_of", "has_multi_level_category", "mth_expanded_form_of",
|
|
82
|
+
})
|
|
83
|
+
|
|
84
|
+
# Offline fallback for the vocabulary picker (no key / no network): the
|
|
85
|
+
# sources that matter for this crosswalk. Live runs use the full UTS list.
|
|
86
|
+
BUILTIN_SABS = {
|
|
87
|
+
"MSH": "Medical Subject Headings", "MTH": "UMLS Metathesaurus names",
|
|
88
|
+
"NCI": "NCI Thesaurus", "SNOMEDCT_US": "SNOMED CT, US Edition",
|
|
89
|
+
"HPO": "Human Phenotype Ontology", "GO": "Gene Ontology",
|
|
90
|
+
"CSP": "CRISP Thesaurus", "MDR": "MedDRA", "OMIM": "Online Mendelian Inheritance in Man",
|
|
91
|
+
"LNC": "LOINC", "RXNORM": "RxNorm", "ICD10CM": "ICD-10-CM", "CHV": "Consumer Health Vocabulary",
|
|
92
|
+
"NCBI": "NCBI Taxonomy", "RCD": "Read Codes", "SNMI": "SNOMED International 1998",
|
|
93
|
+
"PDQ": "Physician Data Query", "AIR": "AI/RHEUM", "NCI_NCI-GLOSS": "NCI Dictionary of Cancer Terms",
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
ADJ_HEADER = [
|
|
97
|
+
"# Curator adjudications for the UMLS crosswalk review/unmapped entries.",
|
|
98
|
+
"# Applied by build_umls_crosswalk.py AFTER querying. 'accept' is used only if the",
|
|
99
|
+
"# CUI was among the query's candidates OR the harness can fetch it live from UMLS;",
|
|
100
|
+
"# the harness never fabricates a concept the curator merely named. 'unmapped: true'",
|
|
101
|
+
"# records a considered 'no faithful UMLS concept' (honest finding). Edited via",
|
|
102
|
+
"# mapping/adjudicate_ui.py.",
|
|
103
|
+
]
|
|
104
|
+
|
|
105
|
+
# ---- structured rationale (shared schema across the mapping workstreams) ----
|
|
106
|
+
# Stored per entry alongside accept/unmapped/note:
|
|
107
|
+
# relation: how the CUI relates to the GEM token (accepts), or none (unmapped)
|
|
108
|
+
# rejected: [{cui, name, sab, fails, why}] fails = criterion code A-D
|
|
109
|
+
# protocol: {queries, scopes, match, sabs, umls} what was actually searched
|
|
110
|
+
RELATIONS = ("exact", "close", "broader", "narrower", "related", "none")
|
|
111
|
+
CRITERIA = {
|
|
112
|
+
"A": "denotation — the concept denotes a different thing or relation than "
|
|
113
|
+
"the GEM token (e.g. a measurand degree vs an epistemic degree)",
|
|
114
|
+
"B": "granularity — only a broader/narrower concept exists and the gap is "
|
|
115
|
+
"not acceptable as a proxy",
|
|
116
|
+
"C": "set membership — cannot serve as a point of the token's "
|
|
117
|
+
"scale/enumeration (not disjoint from / ordered with its siblings)",
|
|
118
|
+
"D": "domain sense — right words, wrong domain or context (e.g. an IPSS-R "
|
|
119
|
+
"risk category named 'High')",
|
|
120
|
+
}
|
|
121
|
+
SCOPES = ("axis", "all")
|
|
122
|
+
MATCH_TYPES = ("words", "exact", "normalizedWords", "normalizedString", "partial")
|
|
123
|
+
RATIONALE_KEYS = ("relation", "rejected", "protocol")
|
|
124
|
+
RATIONALE_MAXLEN = 300 # cap on every free-text rationale string
|
|
125
|
+
RATIONALE_MAXITEMS = 50 # cap on every rationale list
|
|
126
|
+
_ID_RE = re.compile(r"[A-Za-z0-9][A-Za-z0-9_.:-]{0,63}") # CUI / SAB shape
|
|
127
|
+
|
|
128
|
+
# ---- "what still needs a curator's eyes": ONE definition (entry_needs), computed
|
|
129
|
+
# by the server and consumed as data by the whole UI -- the rail badges, the Home
|
|
130
|
+
# worklist, the values-table filter, the value view's "next" button. Fixed order.
|
|
131
|
+
# (code, short label, description, scope)
|
|
132
|
+
NEED_CODES = (
|
|
133
|
+
("review", "review",
|
|
134
|
+
"in review — the harness found candidates but no faithful match", "value"),
|
|
135
|
+
("unconfirmed", "unconfirmed",
|
|
136
|
+
"auto-mapped by the harness, not yet confirmed by a curator", "value"),
|
|
137
|
+
("unresolved", "unresolved",
|
|
138
|
+
"no candidates from the harness query — search wider (this is a gap, not a verdict)",
|
|
139
|
+
"value"),
|
|
140
|
+
("no-rationale", "no rationale",
|
|
141
|
+
"recorded as unmapped without an argument", "value"),
|
|
142
|
+
("untyped", "untyped axis",
|
|
143
|
+
"axis has no semantic type — value searches run unconstrained", "axis"),
|
|
144
|
+
)
|
|
145
|
+
NEED_LABELS = {c: {"label": l, "desc": d, "scope": s} for c, l, d, s in NEED_CODES}
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def _rs(x, what: str) -> str:
|
|
149
|
+
"""A rationale string: must be a scalar, stripped, capped at RATIONALE_MAXLEN."""
|
|
150
|
+
if x is None:
|
|
151
|
+
return ""
|
|
152
|
+
if isinstance(x, (dict, list)):
|
|
153
|
+
raise ValueError(f"{what} must be a string")
|
|
154
|
+
return str(x).strip()[:RATIONALE_MAXLEN]
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def _rlist(x, what: str) -> list:
|
|
158
|
+
if x is None:
|
|
159
|
+
return []
|
|
160
|
+
if not isinstance(x, list):
|
|
161
|
+
raise ValueError(f"{what} must be a list")
|
|
162
|
+
if len(x) > RATIONALE_MAXITEMS:
|
|
163
|
+
raise ValueError(f"{what}: at most {RATIONALE_MAXITEMS} items (got {len(x)})")
|
|
164
|
+
return list(x)
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def validate_rationale(verdict: str, data: dict) -> dict:
|
|
168
|
+
"""Validate the optional structured-rationale keys of a /api/decide body
|
|
169
|
+
and return the cleaned subset to store ({} when none were given).
|
|
170
|
+
|
|
171
|
+
* relation -- one of RELATIONS; 'none' is required for unmapped verdicts
|
|
172
|
+
and refused for accepts. Unmapped verdicts that carry a
|
|
173
|
+
rejected list or protocol default to relation: none.
|
|
174
|
+
* rejected -- list of {cui, name, sab, fails, why}: cui and fails (A-D)
|
|
175
|
+
are mandatory, the rest optional; strings are capped.
|
|
176
|
+
* protocol -- {queries, scopes, match, sabs, umls}: scopes/match values
|
|
177
|
+
must come from SCOPES/MATCH_TYPES; unknown keys are dropped.
|
|
178
|
+
Raises ValueError with a curator-readable message on a malformed body."""
|
|
179
|
+
out: dict = {}
|
|
180
|
+
rel = data.get("relation")
|
|
181
|
+
if rel is not None:
|
|
182
|
+
if not isinstance(rel, str) or rel not in RELATIONS:
|
|
183
|
+
raise ValueError(f"relation must be one of {', '.join(RELATIONS)}")
|
|
184
|
+
if verdict == "unmapped" and rel != "none":
|
|
185
|
+
raise ValueError("an unmapped entry takes relation: none")
|
|
186
|
+
if verdict != "unmapped" and rel == "none":
|
|
187
|
+
raise ValueError("relation: none is only valid for unmapped entries")
|
|
188
|
+
out["relation"] = rel
|
|
189
|
+
elif verdict == "unmapped" and (data.get("rejected") is not None
|
|
190
|
+
or data.get("protocol") is not None):
|
|
191
|
+
out["relation"] = "none"
|
|
192
|
+
|
|
193
|
+
if data.get("rejected") is not None:
|
|
194
|
+
rows = []
|
|
195
|
+
for i, item in enumerate(_rlist(data["rejected"], "rejected")):
|
|
196
|
+
if not isinstance(item, dict):
|
|
197
|
+
raise ValueError(f"rejected[{i}] must be an object")
|
|
198
|
+
cui = _rs(item.get("cui"), "rejected cui")
|
|
199
|
+
if not cui or not _ID_RE.fullmatch(cui):
|
|
200
|
+
raise ValueError(f"rejected[{i}]: a CUI is required")
|
|
201
|
+
fails = _rs(item.get("fails"), "rejected fails").upper()
|
|
202
|
+
if fails not in CRITERIA:
|
|
203
|
+
raise ValueError(f"rejected[{i}] ({cui}): fails must be one of "
|
|
204
|
+
f"{', '.join(CRITERIA)}")
|
|
205
|
+
row = {"cui": cui, "fails": fails}
|
|
206
|
+
for k in ("name", "sab", "why"):
|
|
207
|
+
v = _rs(item.get(k), f"rejected {k}")
|
|
208
|
+
if v:
|
|
209
|
+
row[k] = v
|
|
210
|
+
rows.append(row)
|
|
211
|
+
out["rejected"] = rows
|
|
212
|
+
|
|
213
|
+
if data.get("protocol") is not None:
|
|
214
|
+
proto = data["protocol"]
|
|
215
|
+
if not isinstance(proto, dict):
|
|
216
|
+
raise ValueError("protocol must be an object")
|
|
217
|
+
clean: dict = {}
|
|
218
|
+
for k, allowed in (("queries", None), ("scopes", SCOPES),
|
|
219
|
+
("match", MATCH_TYPES), ("sabs", None)):
|
|
220
|
+
vals = []
|
|
221
|
+
for v in _rlist(proto.get(k), f"protocol {k}"):
|
|
222
|
+
v = _rs(v, f"protocol {k}")
|
|
223
|
+
if not v:
|
|
224
|
+
continue
|
|
225
|
+
if k == "sabs":
|
|
226
|
+
v = v.upper()
|
|
227
|
+
if allowed is not None and v not in allowed:
|
|
228
|
+
raise ValueError(f"protocol {k}: {v!r} is not one of "
|
|
229
|
+
f"{', '.join(allowed)}")
|
|
230
|
+
if v not in vals:
|
|
231
|
+
vals.append(v)
|
|
232
|
+
if vals:
|
|
233
|
+
clean[k] = vals
|
|
234
|
+
umls = _rs(proto.get("umls"), "protocol umls")
|
|
235
|
+
if umls:
|
|
236
|
+
clean["umls"] = umls
|
|
237
|
+
out["protocol"] = clean
|
|
238
|
+
return out
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
def api_key() -> str:
|
|
242
|
+
k = os.environ.get("UMLS_API_KEY", "")
|
|
243
|
+
if k:
|
|
244
|
+
return k
|
|
245
|
+
envrc = H.BASE / ".envrc"
|
|
246
|
+
if envrc.is_file():
|
|
247
|
+
for line in envrc.read_text().splitlines():
|
|
248
|
+
m = re.match(r"\s*export\s+UMLS_API_KEY=(.+)", line)
|
|
249
|
+
if m:
|
|
250
|
+
return m.group(1).strip().strip("\"'")
|
|
251
|
+
return ""
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
def load_meanings() -> dict:
|
|
255
|
+
"""token -> meaning, parsed from the markdown tables in dimensions.md."""
|
|
256
|
+
m = {}
|
|
257
|
+
if DIMS_MD.is_file():
|
|
258
|
+
for line in DIMS_MD.read_text().splitlines():
|
|
259
|
+
mt = re.match(r"^\|\s*`([^`]+)`\s*\|\s*(.+?)\s*\|\s*$", line)
|
|
260
|
+
if mt:
|
|
261
|
+
m.setdefault(mt.group(1).strip(), mt.group(2).strip())
|
|
262
|
+
return m
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
MEANINGS = load_meanings()
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
def _inv_load() -> dict:
|
|
269
|
+
"""The workspace inventory document ({} if absent). Loaded round-trip with
|
|
270
|
+
ruamel.yaml when available so writes preserve the file's comments and
|
|
271
|
+
layout; plain PyYAML otherwise."""
|
|
272
|
+
if not H.INVENTORY.is_file():
|
|
273
|
+
return {}
|
|
274
|
+
try:
|
|
275
|
+
from ruamel.yaml import YAML
|
|
276
|
+
return YAML(typ="rt").load(H.INVENTORY.read_text()) or {}
|
|
277
|
+
except ImportError:
|
|
278
|
+
return yaml.safe_load(H.INVENTORY.read_text()) or {}
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
def _inv_save(doc: dict) -> None:
|
|
282
|
+
H.INVENTORY.parent.mkdir(parents=True, exist_ok=True)
|
|
283
|
+
try:
|
|
284
|
+
from ruamel.yaml import YAML
|
|
285
|
+
y = YAML(typ="rt")
|
|
286
|
+
y.width = 100
|
|
287
|
+
y.preserve_quotes = True
|
|
288
|
+
with H.INVENTORY.open("w") as fh:
|
|
289
|
+
y.dump(doc, fh)
|
|
290
|
+
except ImportError:
|
|
291
|
+
H.INVENTORY.write_text(
|
|
292
|
+
yaml.safe_dump(doc, sort_keys=False, allow_unicode=True, width=100))
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
def _dimensions() -> dict:
|
|
296
|
+
"""The inventory's dimensions block (empty if the workspace has no inventory)."""
|
|
297
|
+
return dict(_inv_load().get("dimensions") or {})
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
def _norm_sabs(x) -> list:
|
|
301
|
+
"""Normalise a preferred-SAB list given as a list or a comma/space string:
|
|
302
|
+
upper-cased, stripped, de-duplicated, order kept."""
|
|
303
|
+
if not x:
|
|
304
|
+
return []
|
|
305
|
+
items = x if isinstance(x, (list, tuple)) else re.split(r"[,\s]+", str(x))
|
|
306
|
+
out = []
|
|
307
|
+
for it in items:
|
|
308
|
+
it = str(it).strip().upper()
|
|
309
|
+
if it and it not in out:
|
|
310
|
+
out.append(it)
|
|
311
|
+
return out
|
|
312
|
+
|
|
313
|
+
|
|
314
|
+
def _rationale_view(a: dict) -> dict:
|
|
315
|
+
"""The recorded argument of one adjudication, in the shape the UI renders:
|
|
316
|
+
relation (or None), rejected (always a list), protocol (always a dict)."""
|
|
317
|
+
rej = a.get("rejected")
|
|
318
|
+
proto = a.get("protocol")
|
|
319
|
+
return {"relation": a.get("relation") or None,
|
|
320
|
+
"rejected": list(rej) if isinstance(rej, list) else [],
|
|
321
|
+
"protocol": dict(proto) if isinstance(proto, dict) else {}}
|
|
322
|
+
|
|
323
|
+
|
|
324
|
+
def entry_needs(e: dict, dim_has_values: bool = True) -> list:
|
|
325
|
+
"""The need codes (NEED_CODES) of one state entry -- the single place that
|
|
326
|
+
says what still needs a curator's eyes. Empty list = nothing to do.
|
|
327
|
+
|
|
328
|
+
value entries: review status review
|
|
329
|
+
unconfirmed auto-mapped by the harness, never confirmed
|
|
330
|
+
unresolved unmapped because the harness query returned
|
|
331
|
+
nothing (or it was all filtered out) -- a gap,
|
|
332
|
+
not a verdict
|
|
333
|
+
no-rationale a curator's unmapped verdict carrying neither
|
|
334
|
+
rejected candidates nor a protocol
|
|
335
|
+
axis entries: untyped no semantic type while the dimension has values
|
|
336
|
+
to search (boolean / free-text dimensions with
|
|
337
|
+
no values are not flagged)"""
|
|
338
|
+
if e.get("kind") == "axis":
|
|
339
|
+
return ["untyped"] if dim_has_values and not e.get("sty_tui") else []
|
|
340
|
+
status, curated = e.get("status"), bool(e.get("curated"))
|
|
341
|
+
# The crosswalk snapshot only changes on Rebuild; the live adjudication
|
|
342
|
+
# (entry["decision"]) wins so the flags follow decisions made in-session.
|
|
343
|
+
dec = e.get("decision")
|
|
344
|
+
if e.get("error"): # e.g. an accepted CUI the harness could not confirm
|
|
345
|
+
return ["review"]
|
|
346
|
+
if dec == "accept":
|
|
347
|
+
status, curated = "mapped", True
|
|
348
|
+
elif dec == "unmapped":
|
|
349
|
+
status, curated = "unmapped", True
|
|
350
|
+
elif "decision" in e and dec is None and curated: # cleared since the last Rebuild
|
|
351
|
+
curated = False
|
|
352
|
+
if status == "review":
|
|
353
|
+
return ["review"]
|
|
354
|
+
if status == "mapped":
|
|
355
|
+
return [] if curated else ["unconfirmed"]
|
|
356
|
+
if status == "unmapped":
|
|
357
|
+
if not curated:
|
|
358
|
+
return ["unresolved"]
|
|
359
|
+
if not e.get("rejected") and not e.get("protocol"):
|
|
360
|
+
return ["no-rationale"]
|
|
361
|
+
return []
|
|
362
|
+
|
|
363
|
+
|
|
364
|
+
def load_state() -> dict:
|
|
365
|
+
"""Assemble the UI state from whatever the workspace has. Axes come from the
|
|
366
|
+
inventory (so they exist even before any crosswalk is built), values from the
|
|
367
|
+
crosswalk when present. Every file is optional -- an empty workspace yields
|
|
368
|
+
no dimensions, ready for the axis builder to create the first one."""
|
|
369
|
+
inv_doc = _inv_load()
|
|
370
|
+
dims = dict(inv_doc.get("dimensions") or {})
|
|
371
|
+
ws_prefs = _norm_sabs((inv_doc.get("meta") or {}).get("preferred_sabs"))
|
|
372
|
+
adj = H.load_adjudications(H.ADJUDICATIONS)
|
|
373
|
+
doc = yaml.safe_load(CROSSWALK.read_text()) if CROSSWALK.is_file() else None
|
|
374
|
+
|
|
375
|
+
def dim_axis_tui(dim):
|
|
376
|
+
a = adj.get(H._adj_key(dim, None)) or {}
|
|
377
|
+
if a.get("unmapped"):
|
|
378
|
+
return None
|
|
379
|
+
return a.get("accept_sty") or (dims.get(dim, {}).get("axis") or {}).get("semantic_type")
|
|
380
|
+
|
|
381
|
+
# value entries from the crosswalk, grouped by dimension
|
|
382
|
+
values_by_dim: dict = {}
|
|
383
|
+
if doc:
|
|
384
|
+
for e in doc.get("entries", []):
|
|
385
|
+
if e.get("kind") == "axis":
|
|
386
|
+
continue
|
|
387
|
+
values_by_dim.setdefault(e["dimension"], []).append(e)
|
|
388
|
+
|
|
389
|
+
# Tiered ordering: core dimensions first in the model's canonical order,
|
|
390
|
+
# then conditional (grouped by activation via their order values), then
|
|
391
|
+
# candidate -- never the alphabet. A dimension without explicit tier
|
|
392
|
+
# metadata is treated as core (and flagged so the UI can warn).
|
|
393
|
+
TIER_RANK = {"core": 0, "conditional": 1, "candidate": 2}
|
|
394
|
+
|
|
395
|
+
def dim_sort_key(d):
|
|
396
|
+
block = dims.get(d) or {}
|
|
397
|
+
tier = block.get("tier") or "core"
|
|
398
|
+
try:
|
|
399
|
+
order = int(block.get("order", 999))
|
|
400
|
+
except (TypeError, ValueError): # hand-edited inventory: "20", blank, ...
|
|
401
|
+
order = 999
|
|
402
|
+
return (TIER_RANK.get(tier, 0), order, d)
|
|
403
|
+
|
|
404
|
+
entries = []
|
|
405
|
+
for dim in sorted(set(dims) | set(values_by_dim), key=dim_sort_key):
|
|
406
|
+
block = dims.get(dim) or {}
|
|
407
|
+
axis = block.get("axis") or {}
|
|
408
|
+
akey = H._adj_key(dim, None)
|
|
409
|
+
aadj = adj.get(akey) or {}
|
|
410
|
+
eff = dim_axis_tui(dim)
|
|
411
|
+
filt = stylib.filter_param(eff)
|
|
412
|
+
sty = stylib.get(eff) if eff else None
|
|
413
|
+
# axis entry (from the inventory)
|
|
414
|
+
ax = {
|
|
415
|
+
"key": akey, "dimension": dim, "token": None, "kind": "axis",
|
|
416
|
+
"query": axis.get("query") or "",
|
|
417
|
+
"meaning": axis.get("note") or MEANINGS.get(dim) or "",
|
|
418
|
+
"status": "mapped" if eff else "review",
|
|
419
|
+
"curated": bool(aadj.get("accept_sty") or aadj.get("unmapped")),
|
|
420
|
+
"fetched": False, "cui": None, "matched_name": None,
|
|
421
|
+
"semantic_types": [], "root_source": None, "candidates": [],
|
|
422
|
+
"sty_tui": eff, "sty_name": sty.get("name") if sty else None,
|
|
423
|
+
"sty_tree": sty.get("tree_number") if sty else None,
|
|
424
|
+
"axis_query": axis.get("query") or "", "axis_note": axis.get("note") or "",
|
|
425
|
+
"in_inventory": dim in dims,
|
|
426
|
+
"tier": block.get("tier") or "core",
|
|
427
|
+
"tier_explicit": "tier" in block,
|
|
428
|
+
"order": block.get("order", 999),
|
|
429
|
+
"activation": block.get("activation"),
|
|
430
|
+
"preferred_sabs": _norm_sabs(block.get("preferred_sabs")),
|
|
431
|
+
"dim_sty_tui": eff, "dim_sty_filter": filt,
|
|
432
|
+
"decision": ("accept_sty" if aadj.get("accept_sty")
|
|
433
|
+
else "unmapped" if aadj.get("unmapped") else None),
|
|
434
|
+
"decision_cui": None, "decision_sty": aadj.get("accept_sty"),
|
|
435
|
+
"note": aadj.get("note"), "error": None,
|
|
436
|
+
**_rationale_view(aadj),
|
|
437
|
+
}
|
|
438
|
+
ax["needs"] = entry_needs(ax, bool(values_by_dim.get(dim)))
|
|
439
|
+
entries.append(ax)
|
|
440
|
+
# value entries for this dimension (from the crosswalk)
|
|
441
|
+
for e in values_by_dim.get(dim, []):
|
|
442
|
+
key = H._adj_key(e["dimension"], e["token"])
|
|
443
|
+
a = adj.get(key) or {}
|
|
444
|
+
v = {
|
|
445
|
+
"key": key, "dimension": e["dimension"], "token": e["token"],
|
|
446
|
+
"kind": e["kind"], "query": e["query"],
|
|
447
|
+
"meaning": MEANINGS.get(str(e["token"])) or e.get("inventory_note") or "",
|
|
448
|
+
"status": e["status"], "curated": bool(e.get("curated")),
|
|
449
|
+
"fetched": bool(e.get("fetched")),
|
|
450
|
+
"cui": e.get("cui"), "matched_name": e.get("matched_name"),
|
|
451
|
+
"semantic_types": e.get("semantic_types") or [],
|
|
452
|
+
"root_source": e.get("root_source"),
|
|
453
|
+
"candidates": e.get("candidates") or [],
|
|
454
|
+
"sty_tui": e.get("sty_tui"), "sty_name": e.get("sty_name"),
|
|
455
|
+
"sty_tree": e.get("sty_tree"),
|
|
456
|
+
"dim_sty_tui": eff, "dim_sty_filter": filt,
|
|
457
|
+
"dim_sty_name": sty.get("name") if sty else None,
|
|
458
|
+
"dim_sty_tree": sty.get("tree_number") if sty else None,
|
|
459
|
+
"sab_pref": e.get("sab"),
|
|
460
|
+
# effective preference order: the value's own hint, then the
|
|
461
|
+
# dimension override, then the workspace list
|
|
462
|
+
"sab_prefs": _norm_sabs(([e.get("sab")] if e.get("sab") else [])
|
|
463
|
+
+ _norm_sabs(block.get("preferred_sabs"))
|
|
464
|
+
+ ws_prefs),
|
|
465
|
+
"decision": ("accept" if a.get("accept") else
|
|
466
|
+
"accept_sty" if a.get("accept_sty") else
|
|
467
|
+
"unmapped" if a.get("unmapped") else None),
|
|
468
|
+
"decision_cui": a.get("accept"), "decision_sty": a.get("accept_sty"),
|
|
469
|
+
"note": a.get("note"), "error": e.get("curator_error"),
|
|
470
|
+
"search_type": e.get("search_type"),
|
|
471
|
+
**_rationale_view(a),
|
|
472
|
+
}
|
|
473
|
+
v["needs"] = entry_needs(v)
|
|
474
|
+
entries.append(v)
|
|
475
|
+
|
|
476
|
+
# Always computed over VALUE entries (never axes), so the Home progress
|
|
477
|
+
# numbers mean what they say -- the crosswalk's meta.counts mixes axes in
|
|
478
|
+
# and carries no total.
|
|
479
|
+
vals = [e for e in entries if e["kind"] != "axis"]
|
|
480
|
+
counts = {"total": len(vals),
|
|
481
|
+
"mapped": sum(e["status"] == "mapped" for e in vals),
|
|
482
|
+
"unmapped": sum(e["status"] == "unmapped" for e in vals),
|
|
483
|
+
"review": sum(e["status"] == "review" for e in vals),
|
|
484
|
+
"curated": sum(bool(e["curated"]) for e in vals)}
|
|
485
|
+
# per-code totals over EVERY entry (axes included), in NEED_CODES order;
|
|
486
|
+
# counts.needs is the number of entries with anything left to do
|
|
487
|
+
needs = {c[0]: 0 for c in NEED_CODES}
|
|
488
|
+
for e in entries:
|
|
489
|
+
for c in e["needs"]:
|
|
490
|
+
needs[c] += 1
|
|
491
|
+
counts["needs"] = sum(1 for e in entries if e["needs"])
|
|
492
|
+
return {"entries": entries, "counts": counts,
|
|
493
|
+
"needs": needs, "need_codes": [c[0] for c in NEED_CODES],
|
|
494
|
+
"need_labels": NEED_LABELS,
|
|
495
|
+
"workspace": str(DATA_DIR), "dimensions": sorted(dims),
|
|
496
|
+
"prefs": {"workspace": ws_prefs},
|
|
497
|
+
"search_backend": SEARCH_BACKEND,
|
|
498
|
+
"server": {"started": SERVER_STARTED, "code": CODE_STAMP},
|
|
499
|
+
"search_backend_note": SEARCH_BACKEND_NOTE}
|
|
500
|
+
|
|
501
|
+
|
|
502
|
+
def write_adjudications(adj: dict) -> None:
|
|
503
|
+
"""Write adjudications.yaml. Entries that carry only a decision and a note
|
|
504
|
+
stay one compact flow-style line each (as the file has always been);
|
|
505
|
+
entries with a structured rationale (relation / rejected / protocol) are
|
|
506
|
+
emitted as an indented block mapping so the argument stays readable. Both
|
|
507
|
+
forms load back through H.load_adjudications() unchanged."""
|
|
508
|
+
L = list(ADJ_HEADER) + ["adjudications:"]
|
|
509
|
+
for key in sorted(adj):
|
|
510
|
+
v = adj[key]
|
|
511
|
+
if v.get("accept"):
|
|
512
|
+
head = ("accept", v["accept"])
|
|
513
|
+
elif v.get("accept_sty"):
|
|
514
|
+
head = ("accept_sty", v["accept_sty"])
|
|
515
|
+
elif v.get("unmapped"):
|
|
516
|
+
head = ("unmapped", True)
|
|
517
|
+
else:
|
|
518
|
+
continue
|
|
519
|
+
qkey = json.dumps(str(key), ensure_ascii=False) # YAML double-quoted
|
|
520
|
+
note = v.get("note") or ""
|
|
521
|
+
rationale = {k: v[k] for k in RATIONALE_KEYS if v.get(k) is not None}
|
|
522
|
+
if not rationale:
|
|
523
|
+
hv = "true" if head[1] is True else head[1]
|
|
524
|
+
L.append(f' {qkey}: {{{head[0]}: {hv}, '
|
|
525
|
+
f'note: {json.dumps(note, ensure_ascii=False)}}}')
|
|
526
|
+
continue
|
|
527
|
+
entry = {head[0]: head[1], "note": note, **rationale}
|
|
528
|
+
block = yaml.safe_dump(entry, sort_keys=False, allow_unicode=True,
|
|
529
|
+
width=100, default_flow_style=False)
|
|
530
|
+
L.append(f" {qkey}:")
|
|
531
|
+
L.extend(" " + line for line in block.rstrip("\n").splitlines())
|
|
532
|
+
H.ADJUDICATIONS.write_text("\n".join(L) + "\n")
|
|
533
|
+
|
|
534
|
+
|
|
535
|
+
def save_axis(dimension: str, semantic_type=None, query=None, note=None,
|
|
536
|
+
tier=None, activation=None, order=None, preferred_sabs=None) -> dict:
|
|
537
|
+
"""Create or update a dimension's axis block in the workspace inventory,
|
|
538
|
+
creating the inventory file (and directory) if absent. This is how a new
|
|
539
|
+
axis is constructed from scratch and how an existing one is modified.
|
|
540
|
+
``tier`` (core|conditional|candidate), ``activation`` and ``order`` are
|
|
541
|
+
block-level presentation metadata driving the tiered navigation.
|
|
542
|
+
Returns the resulting axis block."""
|
|
543
|
+
dimension = (dimension or "").strip()
|
|
544
|
+
if not dimension:
|
|
545
|
+
raise ValueError("a dimension name is required")
|
|
546
|
+
if not re.fullmatch(r"[A-Za-z0-9_][A-Za-z0-9_-]*", dimension):
|
|
547
|
+
raise ValueError("dimension name must use letters, digits, _ or -")
|
|
548
|
+
if semantic_type and not stylib.get(semantic_type):
|
|
549
|
+
raise ValueError(f"unknown semantic type {semantic_type!r}")
|
|
550
|
+
if tier and tier not in ("core", "conditional", "candidate"):
|
|
551
|
+
raise ValueError(f"unknown tier {tier!r}")
|
|
552
|
+
if order is not None:
|
|
553
|
+
try:
|
|
554
|
+
order = int(order)
|
|
555
|
+
except (TypeError, ValueError):
|
|
556
|
+
raise ValueError(f"order must be an integer, got {order!r}")
|
|
557
|
+
inv = _inv_load()
|
|
558
|
+
block = inv.setdefault("dimensions", {}).setdefault(dimension, {})
|
|
559
|
+
if tier:
|
|
560
|
+
block["tier"] = tier
|
|
561
|
+
if preferred_sabs is not None: # [] clears the override
|
|
562
|
+
lst = _norm_sabs(preferred_sabs)
|
|
563
|
+
if lst:
|
|
564
|
+
block["preferred_sabs"] = lst
|
|
565
|
+
else:
|
|
566
|
+
block.pop("preferred_sabs", None)
|
|
567
|
+
if order is not None:
|
|
568
|
+
block["order"] = order
|
|
569
|
+
if activation is not None:
|
|
570
|
+
if activation:
|
|
571
|
+
block["activation"] = activation
|
|
572
|
+
else:
|
|
573
|
+
block.pop("activation", None)
|
|
574
|
+
axis = block.setdefault("axis", {})
|
|
575
|
+
if query is not None:
|
|
576
|
+
axis["query"] = query
|
|
577
|
+
if note is not None:
|
|
578
|
+
axis["note"] = note
|
|
579
|
+
if semantic_type:
|
|
580
|
+
axis["semantic_type"] = semantic_type
|
|
581
|
+
_inv_save(inv)
|
|
582
|
+
return dict(axis)
|
|
583
|
+
|
|
584
|
+
|
|
585
|
+
def save_prefs(preferred_sabs) -> list:
|
|
586
|
+
"""Workspace-wide preferred vocabulary order (inventory meta.preferred_sabs)."""
|
|
587
|
+
inv = _inv_load()
|
|
588
|
+
lst = _norm_sabs(preferred_sabs)
|
|
589
|
+
meta = inv.setdefault("meta", {})
|
|
590
|
+
if lst:
|
|
591
|
+
meta["preferred_sabs"] = lst
|
|
592
|
+
else:
|
|
593
|
+
meta.pop("preferred_sabs", None)
|
|
594
|
+
_inv_save(inv)
|
|
595
|
+
return lst
|
|
596
|
+
|
|
597
|
+
|
|
598
|
+
def order_defs(defs: list) -> list:
|
|
599
|
+
eng = [d for d in defs if not (d.get("source") or "").endswith(NON_ENGLISH)]
|
|
600
|
+
pool = eng or defs
|
|
601
|
+
|
|
602
|
+
def pri(d):
|
|
603
|
+
s = d.get("source", "")
|
|
604
|
+
return PREF_DEF_SOURCES.index(s) if s in PREF_DEF_SOURCES else 99
|
|
605
|
+
return sorted(pool, key=pri)[:3]
|
|
606
|
+
|
|
607
|
+
|
|
608
|
+
def concept_evidence(cui: str, axis_tui: str | None = None,
|
|
609
|
+
english_only: bool = True) -> dict:
|
|
610
|
+
"""Per-CUI evidence for the adjudication UI: most-specific semantic type
|
|
611
|
+
with STN and the path up to the axis type, the source vocabularies as
|
|
612
|
+
SAB/STR atom rows (English only by default), and is_a relations one per row
|
|
613
|
+
with direction. Rollup is fetched separately (on demand) via /api/rollup."""
|
|
614
|
+
c = client.get_concept(cui)
|
|
615
|
+
if not c:
|
|
616
|
+
return {"error": "concept not found"}
|
|
617
|
+
atoms = client.atoms(cui)
|
|
618
|
+
if english_only:
|
|
619
|
+
atoms = [a for a in atoms if (a.get("language") or "ENG") == "ENG"]
|
|
620
|
+
atom_rows = sorted(({"sab": a["sab"], "str": a["name"], "tty": a["tty"],
|
|
621
|
+
"code": a["code"],
|
|
622
|
+
"obsolete": a["obsolete"] or a["suppressible"]}
|
|
623
|
+
for a in atoms if a["sab"]),
|
|
624
|
+
key=lambda x: (x["sab"], x["tty"] or "", x["str"] or ""))
|
|
625
|
+
sabs = sorted({a["sab"] for a in atoms if a["sab"]})
|
|
626
|
+
# most-specific semantic type + STN + path to the axis type
|
|
627
|
+
details = c.get("semantic_type_details") or []
|
|
628
|
+
tuis = [d["tui"] for d in details if d.get("tui")]
|
|
629
|
+
spec = stylib.most_specific(tuis) if tuis else None
|
|
630
|
+
sty_path = stylib.path_to(spec, axis_tui) if spec else []
|
|
631
|
+
under_axis = bool(axis_tui and sty_path and sty_path[-1]["tui"] == axis_tui)
|
|
632
|
+
axis_sty = stylib.get(axis_tui) if axis_tui else None
|
|
633
|
+
# relations. A source counts as English iff it contributed an English atom
|
|
634
|
+
# above -- exactly the SAB set of the English vocabularies subtable, robust
|
|
635
|
+
# to the many language mirrors (MSHCZE, SCTSPA, MDRARA, ...) a suffix list
|
|
636
|
+
# would miss.
|
|
637
|
+
rels = client.relations(cui)
|
|
638
|
+
sab_ok = set(sabs)
|
|
639
|
+
|
|
640
|
+
def _agg(pred, extra):
|
|
641
|
+
"""Collapse matching relations to one row per related concept. The id
|
|
642
|
+
is a CUI for CUI-level relations, otherwise the source code."""
|
|
643
|
+
m: dict = {}
|
|
644
|
+
for r in rels:
|
|
645
|
+
rid = r.get("related_code") or r.get("related_cui")
|
|
646
|
+
if not rid or not pred(r):
|
|
647
|
+
continue
|
|
648
|
+
sab = r["sab"]
|
|
649
|
+
if english_only and sab not in sab_ok:
|
|
650
|
+
continue
|
|
651
|
+
e = m.setdefault(rid, {"code": rid, "cui": r.get("related_cui"),
|
|
652
|
+
"name": r["related_name"], "sabs": set(), **extra(r)})
|
|
653
|
+
if sab:
|
|
654
|
+
e["sabs"].add(sab)
|
|
655
|
+
return [{**v, "sabs": sorted(v["sabs"])} for v in m.values()]
|
|
656
|
+
|
|
657
|
+
# is_a hierarchy -- keyed on rela, NOT rel: only isa/inverse_isa edges are a
|
|
658
|
+
# true taxonomy. Note UMLS orientation: rela=="isa" means this concept isa
|
|
659
|
+
# the related one, i.e. the related concept is the PARENT (up); inverse_isa
|
|
660
|
+
# is the child (down). MeSH-style PAR/CHD with empty rela is a thematic tree,
|
|
661
|
+
# not is_a, and is intentionally excluded here (and from the rollup).
|
|
662
|
+
isa_up = _agg(lambda r: r["rela"] == "isa", lambda r: {"dir": "up"})[:25]
|
|
663
|
+
isa_down = _agg(lambda r: r["rela"] == "inverse_isa", lambda r: {"dir": "down"})[:25]
|
|
664
|
+
|
|
665
|
+
# other meaningful relations, grouped by their (labeled) rela -- the
|
|
666
|
+
# semantically rich edges a curator wants (gene_mapped_to_disease,
|
|
667
|
+
# has_allelic_variant, part_of, mapped_to, ...). Skip is_a (shown above) and
|
|
668
|
+
# lexical/synonym bookkeeping (permuted/translation/alias/expanded forms).
|
|
669
|
+
other = _agg(lambda r: (r["rela"] and r["rela"] not in ("isa", "inverse_isa")
|
|
670
|
+
and r["rel"] != "SY" and r["rela"] not in _LEXICAL_RELA),
|
|
671
|
+
lambda r: {"rela": r["rela"]})
|
|
672
|
+
groups: dict = {}
|
|
673
|
+
for row in other:
|
|
674
|
+
groups.setdefault(row["rela"], []).append(row)
|
|
675
|
+
other_relations = [{"rela": k, "n": len(v), "items": v[:12]}
|
|
676
|
+
for k, v in sorted(groups.items(), key=lambda kv: -len(kv[1]))][:20]
|
|
677
|
+
|
|
678
|
+
return {"cui": cui, "name": c["name"], "status": c.get("status"),
|
|
679
|
+
"atom_count": c.get("atom_count"), "semantic_types": details,
|
|
680
|
+
"most_specific_tui": spec, "sty_path": sty_path,
|
|
681
|
+
"under_axis": under_axis,
|
|
682
|
+
"axis_sty": ({"tui": axis_sty["tui"], "name": axis_sty["name"],
|
|
683
|
+
"stn": axis_sty.get("tree_number")} if axis_sty else None),
|
|
684
|
+
"sabs": sabs, "atom_rows": atom_rows[:80],
|
|
685
|
+
"relations": isa_up + isa_down, "other_relations": other_relations}
|
|
686
|
+
|
|
687
|
+
|
|
688
|
+
def descend_search(cli, sab: str, code: str, query: str,
|
|
689
|
+
depth: int = 2, breadth: int = 60, limit: int = 20) -> list:
|
|
690
|
+
"""Ontology-guided query expansion: when a direct search finds no faithful
|
|
691
|
+
match, walk DOWN the is_a children of an anchor concept within one source
|
|
692
|
+
vocabulary and rank every descendant's name against ``query``. BFS with a
|
|
693
|
+
visited-set (source hierarchies can be diamonds), at most ``breadth`` nodes
|
|
694
|
+
expanded and ``depth`` levels (each expansion is one cached UTS call).
|
|
695
|
+
Returns [{code, name, sab, depth, score, parent}] best-first."""
|
|
696
|
+
import difflib
|
|
697
|
+
|
|
698
|
+
def score(name: str) -> float:
|
|
699
|
+
a, b = (query or "").lower(), (name or "").lower()
|
|
700
|
+
if not a or not b:
|
|
701
|
+
return 0.0
|
|
702
|
+
r = difflib.SequenceMatcher(None, a, b).ratio()
|
|
703
|
+
ta, tb = set(re.findall(r"[a-z0-9]+", a)), set(re.findall(r"[a-z0-9]+", b))
|
|
704
|
+
overlap = len(ta & tb) / len(ta) if ta else 0.0
|
|
705
|
+
return round(0.6 * r + 0.4 * overlap, 4)
|
|
706
|
+
|
|
707
|
+
seen, out, frontier, expanded = {code}, [], [(code, None, 0)], 0
|
|
708
|
+
while frontier and expanded < breadth:
|
|
709
|
+
cur, parent, d = frontier.pop(0)
|
|
710
|
+
if d >= depth:
|
|
711
|
+
continue
|
|
712
|
+
expanded += 1
|
|
713
|
+
try:
|
|
714
|
+
kids = cli.source_children(sab, cur) or []
|
|
715
|
+
except Exception: # noqa: BLE001 -- a leaf/forbidden node ends the branch
|
|
716
|
+
kids = []
|
|
717
|
+
for k in kids:
|
|
718
|
+
if k["code"] in seen:
|
|
719
|
+
continue
|
|
720
|
+
seen.add(k["code"])
|
|
721
|
+
out.append({"code": k["code"], "name": k["name"], "sab": k["sab"],
|
|
722
|
+
"depth": d + 1, "score": score(k["name"]),
|
|
723
|
+
"parent": parent if d else None})
|
|
724
|
+
frontier.append((k["code"], k["name"], d + 1))
|
|
725
|
+
# Direct children are the answer to "what are the descendants?" -- they are
|
|
726
|
+
# always kept (a query only orders them); deeper levels compete by score
|
|
727
|
+
# for the remaining slots. Sorted children-first, best-scored within depth.
|
|
728
|
+
d1 = sorted([x for x in out if x["depth"] == 1],
|
|
729
|
+
key=lambda x: (-x["score"], x["name"] or ""))
|
|
730
|
+
deeper = sorted([x for x in out if x["depth"] > 1],
|
|
731
|
+
key=lambda x: (-x["score"], x["depth"], x["name"] or ""))
|
|
732
|
+
keep = d1[:max(limit, len(d1))] + deeper[:max(0, limit - len(d1))]
|
|
733
|
+
return keep
|
|
734
|
+
|
|
735
|
+
|
|
736
|
+
EXPAND_MAJOR = ("SNOMEDCT_US", "MSH", "NCI", "GO", "HPO")
|
|
737
|
+
|
|
738
|
+
|
|
739
|
+
def expand_search(cli, query: str, stys: str | None = None,
|
|
740
|
+
per_word: int = 3, kids_per: int = 12,
|
|
741
|
+
max_variants: int = 40) -> dict:
|
|
742
|
+
"""Ontology-aware query expansion: for each content word of ``query``,
|
|
743
|
+
find the concepts literally named by that word, take their is_a
|
|
744
|
+
NEIGHBOURS (children and a few ancestors) from the source hierarchies,
|
|
745
|
+
substitute the neighbor's name for the word, and re-search every
|
|
746
|
+
variant. "model organism" -> organism's children (animal, plant, ...) ->
|
|
747
|
+
"model animal" -> Animal Model. Results carry their provenance
|
|
748
|
+
(word -> substituted term, and the variant searched)."""
|
|
749
|
+
words = [w for w in re.findall(r"[A-Za-z][A-Za-z-]+", query or "")
|
|
750
|
+
if len(w) > 2]
|
|
751
|
+
variants: list[tuple[str, str, str]] = []
|
|
752
|
+
seen_var = {(query or "").lower()}
|
|
753
|
+
# per-word budget: otherwise the first word's expansions starve the rest
|
|
754
|
+
quota = max(8, max_variants // max(1, len(words)))
|
|
755
|
+
for w in words:
|
|
756
|
+
n_word = 0
|
|
757
|
+
try:
|
|
758
|
+
named = cli.search(w, search_type="exact", page_size=10)[:per_word]
|
|
759
|
+
except Exception: # noqa: BLE001
|
|
760
|
+
named = []
|
|
761
|
+
for c in named:
|
|
762
|
+
codes: dict = {}
|
|
763
|
+
try:
|
|
764
|
+
for a in cli.atoms(c["cui"]):
|
|
765
|
+
if (a.get("sab") in EXPAND_MAJOR and a.get("code")
|
|
766
|
+
and a.get("code") != "NOCODE"
|
|
767
|
+
and (a.get("language") or "ENG") == "ENG"):
|
|
768
|
+
codes.setdefault(a["sab"], a["code"])
|
|
769
|
+
except Exception: # noqa: BLE001
|
|
770
|
+
continue
|
|
771
|
+
# hyponyms two levels down (animal is under organism via
|
|
772
|
+
# Eukaryota in NCI); ancestors are deliberately NOT substituted --
|
|
773
|
+
# they head straight for vocabulary roots and produce junk.
|
|
774
|
+
per_sab: list = []
|
|
775
|
+
for sab in [x for x in EXPAND_MAJOR if x in codes][:2]:
|
|
776
|
+
lst: list = []
|
|
777
|
+
try:
|
|
778
|
+
d1 = cli.source_children(sab, codes[sab])[:kids_per]
|
|
779
|
+
lst += d1
|
|
780
|
+
for k in d1[:6]:
|
|
781
|
+
lst += cli.source_children(sab, k["code"])[:6]
|
|
782
|
+
except Exception: # noqa: BLE001
|
|
783
|
+
pass
|
|
784
|
+
if lst:
|
|
785
|
+
per_sab.append(lst)
|
|
786
|
+
# interleave the vocabularies: one tree's verbose taxa (SNOMED's
|
|
787
|
+
# "Kingdom Animalia") must not starve another's clean names
|
|
788
|
+
# (NCI's "Animal") out of the per-word budget
|
|
789
|
+
neigh = [n for tup in __import__("itertools").zip_longest(*per_sab)
|
|
790
|
+
for n in tup if n is not None]
|
|
791
|
+
for n in neigh:
|
|
792
|
+
nm = re.sub(r"\s*\([^)]*\)$", "", (n.get("name") or "")).strip()
|
|
793
|
+
if not nm or len(nm.split()) > 3:
|
|
794
|
+
continue
|
|
795
|
+
v = re.sub(rf"\b{re.escape(w)}\b", nm, query, flags=re.I)
|
|
796
|
+
if v.lower() in seen_var or n_word >= quota:
|
|
797
|
+
continue
|
|
798
|
+
seen_var.add(v.lower())
|
|
799
|
+
variants.append((v, w, nm))
|
|
800
|
+
n_word += 1
|
|
801
|
+
variants = variants[:max_variants]
|
|
802
|
+
results, seen_cui = [], set()
|
|
803
|
+
for v, w, nm in variants:
|
|
804
|
+
try:
|
|
805
|
+
hits = cli.search(v, search_type="words", semantic_types=stys,
|
|
806
|
+
page_size=30)[:5]
|
|
807
|
+
except Exception: # noqa: BLE001
|
|
808
|
+
hits = []
|
|
809
|
+
for h in hits:
|
|
810
|
+
if h["cui"] in seen_cui:
|
|
811
|
+
continue
|
|
812
|
+
seen_cui.add(h["cui"])
|
|
813
|
+
results.append({**h, "via": f"{w} → {nm}", "variant": v})
|
|
814
|
+
return {"n_variants": len(variants), "results": results[:30]}
|
|
815
|
+
|
|
816
|
+
|
|
817
|
+
client = None # set in main()
|
|
818
|
+
SEARCH_BACKEND = "UTS"
|
|
819
|
+
SEARCH_BACKEND_NOTE = "" # why the local-index probe failed, when it did
|
|
820
|
+
import datetime as _dt
|
|
821
|
+
SERVER_STARTED = _dt.datetime.now().strftime("%Y-%m-%d %H:%M")
|
|
822
|
+
CODE_STAMP = _dt.datetime.fromtimestamp(
|
|
823
|
+
Path(__file__).stat().st_mtime).strftime("%Y-%m-%d %H:%M")
|
|
824
|
+
|
|
825
|
+
|
|
826
|
+
class HybridClient:
|
|
827
|
+
"""Local-index search + UTS for everything else. search/atoms and the
|
|
828
|
+
axis-subtree browse come from the PostgreSQL index (exact, normalized and
|
|
829
|
+
trigram matching over the full release, no REST ranking cap); concept
|
|
830
|
+
details, relations, definitions, rollups and the source registry stay on
|
|
831
|
+
UTS. Attribute fallback delegates to the UTS client."""
|
|
832
|
+
|
|
833
|
+
def __init__(self, pg, uts):
|
|
834
|
+
self._pg, self._uts = pg, uts
|
|
835
|
+
|
|
836
|
+
def search(self, *a, **k):
|
|
837
|
+
return self._pg.search(*a, **k)
|
|
838
|
+
|
|
839
|
+
def atoms(self, *a, **k):
|
|
840
|
+
return self._pg.atoms(*a, **k)
|
|
841
|
+
|
|
842
|
+
def concepts_by_tui(self, *a, **k):
|
|
843
|
+
return self._pg.concepts_by_tui(*a, **k)
|
|
844
|
+
|
|
845
|
+
def release(self):
|
|
846
|
+
return self._pg.release()
|
|
847
|
+
|
|
848
|
+
def sources(self):
|
|
849
|
+
try:
|
|
850
|
+
src = self._uts.sources()
|
|
851
|
+
except Exception: # noqa: BLE001
|
|
852
|
+
src = []
|
|
853
|
+
return src or self._pg.sources()
|
|
854
|
+
|
|
855
|
+
def __getattr__(self, name):
|
|
856
|
+
return getattr(self._uts, name)
|
|
857
|
+
|
|
858
|
+
|
|
859
|
+
class Handler(BaseHTTPRequestHandler):
|
|
860
|
+
def log_message(self, *a):
|
|
861
|
+
pass
|
|
862
|
+
|
|
863
|
+
def _send(self, code, body, ctype="application/json"):
|
|
864
|
+
b = body if isinstance(body, bytes) else body.encode()
|
|
865
|
+
self.send_response(code)
|
|
866
|
+
self.send_header("Content-Type", ctype)
|
|
867
|
+
self.send_header("Content-Length", str(len(b)))
|
|
868
|
+
self.send_header("Cache-Control", "no-store") # never serve a stale page
|
|
869
|
+
self.end_headers()
|
|
870
|
+
self.wfile.write(b)
|
|
871
|
+
|
|
872
|
+
def do_GET(self):
|
|
873
|
+
u = urlparse(self.path)
|
|
874
|
+
q = parse_qs(u.query)
|
|
875
|
+
try:
|
|
876
|
+
if u.path == "/":
|
|
877
|
+
return self._send(200, HTML, "text/html; charset=utf-8")
|
|
878
|
+
if u.path == "/api/state":
|
|
879
|
+
return self._send(200, json.dumps(load_state()))
|
|
880
|
+
if u.path == "/api/search":
|
|
881
|
+
term = (q.get("string") or [""])[0]
|
|
882
|
+
sab = (q.get("sabs") or [""])[0] or None
|
|
883
|
+
stys = (q.get("stys") or [""])[0] or None
|
|
884
|
+
stype = (q.get("stype") or [""])[0] or "words"
|
|
885
|
+
# "partial" is our alias for words + partialSearch=true; the
|
|
886
|
+
# rest pass through as UTS searchType values.
|
|
887
|
+
partial = stype == "partial"
|
|
888
|
+
res = client.search(term, search_type="words" if partial else stype,
|
|
889
|
+
sabs=sab, semantic_types=stys, partial=partial)
|
|
890
|
+
out = {"results": res}
|
|
891
|
+
if not res and SEARCH_BACKEND.startswith("OFFLINE"):
|
|
892
|
+
out["note"] = ("offline: no UMLS key and no local index — "
|
|
893
|
+
"every search is empty (see ⚙ Settings)")
|
|
894
|
+
return self._send(200, json.dumps(out))
|
|
895
|
+
if u.path == "/api/rollup":
|
|
896
|
+
cui = (q.get("cui") or [""])[0]
|
|
897
|
+
sab = (q.get("use_sab") or [""])[0] or None
|
|
898
|
+
# roll up within English is_a vocabularies only: those that gave
|
|
899
|
+
# this concept an English atom AND assert a direct is_a parent
|
|
900
|
+
# (MeSH asserts a thematic tree, no is_a, so it is never offered).
|
|
901
|
+
eng = {a["sab"] for a in client.atoms(cui)
|
|
902
|
+
if a.get("sab") and (a.get("language") or "ENG") == "ENG"}
|
|
903
|
+
isa_sabs = sorted({r["sab"] for r in client.relations(cui)
|
|
904
|
+
if r["rela"] == "isa" and r["sab"] in eng})
|
|
905
|
+
roll = client.rollup(cui, use_sab=sab, sab_allow=set(isa_sabs))
|
|
906
|
+
return self._send(200, json.dumps({"rollup": roll, "sabs": isa_sabs}))
|
|
907
|
+
if u.path == "/api/sabs":
|
|
908
|
+
# English vocabularies a concept has atoms in -- cheap (atoms
|
|
909
|
+
# are cached), used to enrich search-result cards so the curator
|
|
910
|
+
# sees membership without opening the evidence panel.
|
|
911
|
+
cui = (q.get("cui") or [""])[0]
|
|
912
|
+
sabs = sorted({a["sab"] for a in client.atoms(cui)
|
|
913
|
+
if a.get("sab") and (a.get("language") or "ENG") == "ENG"})
|
|
914
|
+
return self._send(200, json.dumps({"sabs": sabs}))
|
|
915
|
+
if u.path == "/api/sources":
|
|
916
|
+
try:
|
|
917
|
+
src = [x for x in client.sources() if (x.get("language") or "ENG") == "ENG"]
|
|
918
|
+
except Exception: # noqa: BLE001 -- offline: fall back below
|
|
919
|
+
src = []
|
|
920
|
+
if not src:
|
|
921
|
+
src = [{"sab": k, "name": v} for k, v in BUILTIN_SABS.items()]
|
|
922
|
+
src.sort(key=lambda x: x["sab"])
|
|
923
|
+
return self._send(200, json.dumps({"sources": src}))
|
|
924
|
+
if u.path == "/api/expand":
|
|
925
|
+
term = (q.get("q") or [""])[0]
|
|
926
|
+
stys = (q.get("stys") or [""])[0] or None
|
|
927
|
+
cap = 16 if SEARCH_BACKEND.startswith(("UTS", "OFFLINE")) else 40
|
|
928
|
+
out = expand_search(client, term, stys, max_variants=cap)
|
|
929
|
+
return self._send(200, json.dumps(out))
|
|
930
|
+
if u.path == "/api/descend":
|
|
931
|
+
cui = (q.get("cui") or [""])[0]
|
|
932
|
+
want = (q.get("sab") or [""])[0] or None
|
|
933
|
+
term = (q.get("q") or [""])[0]
|
|
934
|
+
depth = min(int((q.get("depth") or ["2"])[0]), 3)
|
|
935
|
+
# The hierarchy rarely lives in the vocabulary that named the
|
|
936
|
+
# concept (MTH names have no tree): walk EVERY English source
|
|
937
|
+
# the concept has an atom in, requested/major sources first.
|
|
938
|
+
atoms = [a for a in client.atoms(cui)
|
|
939
|
+
if a.get("sab") and a.get("code")
|
|
940
|
+
and a.get("code") != "NOCODE"
|
|
941
|
+
and (a.get("language") or "ENG") == "ENG"]
|
|
942
|
+
by_sab: dict = {}
|
|
943
|
+
for a in sorted(atoms, key=lambda a: (a.get("tty") != "PT",)):
|
|
944
|
+
by_sab.setdefault(a["sab"], a["code"])
|
|
945
|
+
if not by_sab:
|
|
946
|
+
return self._send(200, json.dumps({"error":
|
|
947
|
+
f"{cui} has no source atom to descend from"}))
|
|
948
|
+
MAJOR = ("MSH", "NCI", "SNOMEDCT_US", "HPO", "GO", "LNC", "CSP")
|
|
949
|
+
order = ([want] if want in by_sab else []) + [x for x in MAJOR if x in by_sab and x != want] + [x for x in sorted(by_sab) if x not in MAJOR and x != want]
|
|
950
|
+
rows, anchors = [], []
|
|
951
|
+
for sab in order[:6]:
|
|
952
|
+
got = descend_search(client, sab, by_sab[sab], term,
|
|
953
|
+
depth=depth, breadth=40, limit=12)
|
|
954
|
+
if got:
|
|
955
|
+
anchors.append({"sab": sab, "code": by_sab[sab]})
|
|
956
|
+
rows.extend(got)
|
|
957
|
+
rows.sort(key=lambda x: (x["depth"], -x["score"], x["name"] or ""))
|
|
958
|
+
return self._send(200, json.dumps(
|
|
959
|
+
{"anchors": anchors, "matches": rows[:48]}))
|
|
960
|
+
if u.path == "/api/axisbrowse":
|
|
961
|
+
stys = (q.get("stys") or [""])[0]
|
|
962
|
+
fn = getattr(client, "concepts_by_tui", None)
|
|
963
|
+
if not callable(fn) or not stys:
|
|
964
|
+
return self._send(200, json.dumps({"error":
|
|
965
|
+
"Axis browse needs the optional local UMLS index. "
|
|
966
|
+
"Build it once from your own licensed UMLS release: "
|
|
967
|
+
"see data/umls/README.md, section 'Local UMLS index "
|
|
968
|
+
"(PostgreSQL)' — then restart the Studio."}))
|
|
969
|
+
return self._send(200, json.dumps(
|
|
970
|
+
{"concepts": fn(stys, limit=3000)}))
|
|
971
|
+
if u.path == "/api/semantictypes":
|
|
972
|
+
return self._send(200, json.dumps({"types": [
|
|
973
|
+
{"tui": t["tui"], "name": t["name"], "tree": t.get("tree_number"),
|
|
974
|
+
"definition": (t.get("definition") or "")[:200]}
|
|
975
|
+
for t in stylib.all_types()]}))
|
|
976
|
+
if u.path == "/api/concept":
|
|
977
|
+
cui = (q.get("cui") or [""])[0]
|
|
978
|
+
axis = (q.get("axis") or [""])[0] or None
|
|
979
|
+
ev = concept_evidence(cui, axis)
|
|
980
|
+
defs = order_defs(client.definitions(cui))
|
|
981
|
+
return self._send(200, json.dumps({"evidence": ev, "definitions": defs}))
|
|
982
|
+
except Exception as ex: # noqa: BLE001
|
|
983
|
+
return self._send(500, json.dumps({"error": str(ex)}))
|
|
984
|
+
self._send(404, json.dumps({"error": "not found"}))
|
|
985
|
+
|
|
986
|
+
def do_POST(self):
|
|
987
|
+
ln = int(self.headers.get("Content-Length") or 0)
|
|
988
|
+
try:
|
|
989
|
+
data = json.loads(self.rfile.read(ln) or b"{}")
|
|
990
|
+
u = urlparse(self.path)
|
|
991
|
+
if u.path == "/api/decide":
|
|
992
|
+
adj = H.load_adjudications(H.ADJUDICATIONS)
|
|
993
|
+
key, verdict = data["key"], data.get("verdict")
|
|
994
|
+
if verdict not in ("accept", "accept_sty", "unmapped", "clear"):
|
|
995
|
+
return self._send(400, json.dumps({"error": "bad verdict"}))
|
|
996
|
+
try:
|
|
997
|
+
rationale = validate_rationale(verdict, data)
|
|
998
|
+
if verdict == "accept":
|
|
999
|
+
cui = _rs(data.get("cui"), "cui")
|
|
1000
|
+
if not cui or not _ID_RE.fullmatch(cui):
|
|
1001
|
+
raise ValueError("accept needs a CUI")
|
|
1002
|
+
adj[key] = {"accept": cui,
|
|
1003
|
+
"note": data.get("note") or "curator-accepted via UI",
|
|
1004
|
+
**rationale}
|
|
1005
|
+
elif verdict == "accept_sty":
|
|
1006
|
+
adj[key] = {"accept_sty": data["tui"],
|
|
1007
|
+
"note": data.get("note") or "axis semantic type set via UI",
|
|
1008
|
+
**rationale}
|
|
1009
|
+
elif verdict == "unmapped":
|
|
1010
|
+
adj[key] = {"unmapped": True,
|
|
1011
|
+
"note": data.get("note") or "no faithful UMLS concept (curator)",
|
|
1012
|
+
**rationale}
|
|
1013
|
+
else: # clear: decision AND rationale go
|
|
1014
|
+
adj.pop(key, None)
|
|
1015
|
+
except ValueError as ve:
|
|
1016
|
+
return self._send(400, json.dumps({"error": str(ve)}))
|
|
1017
|
+
write_adjudications(adj)
|
|
1018
|
+
return self._send(200, json.dumps({"ok": True}))
|
|
1019
|
+
if u.path == "/api/axis":
|
|
1020
|
+
try:
|
|
1021
|
+
axis = save_axis(data.get("dimension"),
|
|
1022
|
+
semantic_type=data.get("semantic_type") or None,
|
|
1023
|
+
query=data.get("query"), note=data.get("note"),
|
|
1024
|
+
tier=data.get("tier") or None,
|
|
1025
|
+
activation=data.get("activation"),
|
|
1026
|
+
order=data.get("order"),
|
|
1027
|
+
preferred_sabs=data.get("preferred_sabs"))
|
|
1028
|
+
except ValueError as ve:
|
|
1029
|
+
return self._send(400, json.dumps({"error": str(ve)}))
|
|
1030
|
+
# the inventory is now authoritative for this axis's type; drop a
|
|
1031
|
+
# stale accept_sty adjudication override so it takes effect.
|
|
1032
|
+
adj = H.load_adjudications(H.ADJUDICATIONS)
|
|
1033
|
+
akey = H._adj_key((data.get("dimension") or "").strip(), None)
|
|
1034
|
+
if (adj.get(akey) or {}).get("accept_sty"):
|
|
1035
|
+
adj.pop(akey, None)
|
|
1036
|
+
write_adjudications(adj)
|
|
1037
|
+
return self._send(200, json.dumps({"ok": True, "axis": axis}))
|
|
1038
|
+
if u.path == "/api/prefs":
|
|
1039
|
+
lst = save_prefs(data.get("preferred_sabs"))
|
|
1040
|
+
return self._send(200, json.dumps({"ok": True, "preferred_sabs": lst}))
|
|
1041
|
+
if u.path == "/api/rebuild":
|
|
1042
|
+
dim = (data.get("dim") or "").strip() or None
|
|
1043
|
+
if dim:
|
|
1044
|
+
# scoped rebuild: re-resolve one dimension, merge into the
|
|
1045
|
+
# existing crosswalk in place (other dimensions untouched).
|
|
1046
|
+
part = H.build(client, live=True, only_dims={dim})
|
|
1047
|
+
if not part["entries"]:
|
|
1048
|
+
# dim not in the inventory (renamed/removed by hand?):
|
|
1049
|
+
# merging an empty part would only DELETE its existing
|
|
1050
|
+
# crosswalk entries -- refuse instead of destroying data.
|
|
1051
|
+
return self._send(400, json.dumps({"error":
|
|
1052
|
+
f"dimension {dim!r} is not in the inventory; "
|
|
1053
|
+
"nothing to rebuild"}))
|
|
1054
|
+
doc = (yaml.safe_load(CROSSWALK.read_text())
|
|
1055
|
+
if CROSSWALK.is_file() else None)
|
|
1056
|
+
if doc and doc.get("entries"):
|
|
1057
|
+
merged, inserted = [], False
|
|
1058
|
+
for e in doc["entries"]:
|
|
1059
|
+
if e.get("dimension") == dim:
|
|
1060
|
+
if not inserted:
|
|
1061
|
+
merged.extend(part["entries"])
|
|
1062
|
+
inserted = True
|
|
1063
|
+
continue
|
|
1064
|
+
merged.append(e)
|
|
1065
|
+
if not inserted:
|
|
1066
|
+
merged.extend(part["entries"])
|
|
1067
|
+
doc["entries"] = merged
|
|
1068
|
+
else:
|
|
1069
|
+
doc = part
|
|
1070
|
+
counts = {s: 0 for s in ("mapped", "review", "unmapped", "pending")}
|
|
1071
|
+
for e in doc["entries"]:
|
|
1072
|
+
counts[e["status"]] += 1
|
|
1073
|
+
counts["curated"] = sum(1 for e in doc["entries"] if e.get("curated"))
|
|
1074
|
+
counts["axis_typed"] = sum(1 for e in doc["entries"] if e.get("sty_tui"))
|
|
1075
|
+
doc["meta"]["counts"] = counts
|
|
1076
|
+
doc["meta"]["total"] = len(doc["entries"])
|
|
1077
|
+
else:
|
|
1078
|
+
doc = H.build(client, live=True)
|
|
1079
|
+
CROSSWALK.write_text(yaml.safe_dump(doc, sort_keys=False,
|
|
1080
|
+
allow_unicode=True, width=100))
|
|
1081
|
+
return self._send(200, json.dumps({"ok": True, "dim": dim,
|
|
1082
|
+
"counts": doc["meta"]["counts"]}))
|
|
1083
|
+
except Exception as ex: # noqa: BLE001
|
|
1084
|
+
return self._send(500, json.dumps({"error": str(ex)}))
|
|
1085
|
+
self._send(404, json.dumps({"error": "not found"}))
|
|
1086
|
+
|
|
1087
|
+
|
|
1088
|
+
HTML = r'''<!doctype html><html><head><meta charset="utf-8">
|
|
1089
|
+
<title>GEM Mapping Studio</title>
|
|
1090
|
+
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Source+Serif+4:opsz,wght@8..60,600;8..60,700&family=IBM+Plex+Sans:wght@400;500;600&family=IBM+Plex+Mono:wght@400;500&display=swap" media="print" onload="this.media='all'">
|
|
1091
|
+
<style>
|
|
1092
|
+
:root{
|
|
1093
|
+
--paper:#f5f3ee;--rail:#f0ede4;--card:#fdfcf9;--ink:#23201a;--mut:#736d5f;--faint:#8a8474;
|
|
1094
|
+
--line:#e4e0d3;--hair:#f0ece0;--accent:#155e68;--accent-soft:#e2edee;--accent-bd:#b9d2d5;
|
|
1095
|
+
--ok:#23663f;--ok-bg:#e2efe5;--ok-bd:#c8e0cf;--ok-dot:#3f9463;
|
|
1096
|
+
--rev:#8a5f0c;--rev-bg:#f6ecd3;--rev-bd:#e8d9ab;--rev-dot:#d9a13a;
|
|
1097
|
+
--un:#6d675a;--un-bg:#eeeade;--un-bd:#ddd7c6;--un-dot:#cbc5b4;
|
|
1098
|
+
--warn:#9a5a1d;--danger:#9a4633;--link:#22557e;--link-bg:#e2ebf3;
|
|
1099
|
+
--serif:'Source Serif 4',Georgia,serif;
|
|
1100
|
+
--sans:'IBM Plex Sans','Helvetica Neue',Arial,sans-serif;
|
|
1101
|
+
--mono:'IBM Plex Mono',Menlo,monospace}
|
|
1102
|
+
*{box-sizing:border-box}
|
|
1103
|
+
body{margin:0;font:13.5px/1.45 var(--sans);color:var(--ink);background:var(--paper);display:flex;height:100vh;overflow:hidden}
|
|
1104
|
+
a{color:var(--accent);text-decoration:none}a:hover{text-decoration:underline}
|
|
1105
|
+
button{font:inherit;font-size:12.5px;font-weight:600;padding:6px 13px;border:1px solid var(--line);background:var(--card);color:var(--ink);border-radius:7px;cursor:pointer}
|
|
1106
|
+
button:hover{background:#f4f1e8}
|
|
1107
|
+
button.primary{background:var(--accent);color:#f6f3ea;border-color:var(--accent)}
|
|
1108
|
+
button.primary:hover{background:#0f454d}
|
|
1109
|
+
button.ok{background:var(--card);color:var(--ok);border-color:var(--ok-bd)}
|
|
1110
|
+
button.warn{background:var(--card);color:var(--rev);border-color:var(--rev-bd)}
|
|
1111
|
+
button.mini{font-size:11px;font-weight:500;padding:2px 8px}
|
|
1112
|
+
input,select,textarea{font:inherit;border:1px solid #d8d3c3;background:#fff;border-radius:7px;padding:6px 10px;color:var(--ink)}
|
|
1113
|
+
input:focus,textarea:focus{outline:1.5px solid var(--accent-bd)}
|
|
1114
|
+
textarea{width:100%;min-height:44px}
|
|
1115
|
+
/* ---- rail ---- */
|
|
1116
|
+
#rail{width:280px;flex-shrink:0;display:flex;flex-direction:column;background:var(--rail);border-right:1px solid var(--line)}
|
|
1117
|
+
#brand{display:flex;align-items:center;gap:10px;padding:18px 18px 12px;cursor:pointer}
|
|
1118
|
+
#brand .nm{font-family:var(--serif);font-size:16px;font-weight:700}
|
|
1119
|
+
#brand .ws{font-family:var(--mono);font-size:10.5px;color:var(--faint);word-break:break-all}
|
|
1120
|
+
#brand .txt{flex:1;min-width:0}
|
|
1121
|
+
#gear{flex-shrink:0;width:28px;height:28px;display:flex;align-items:center;justify-content:center;border-radius:7px;color:var(--faint);cursor:pointer;border:1px solid transparent}
|
|
1122
|
+
#gear:hover{color:var(--accent);background:#e9e5d8;border-color:var(--line)}
|
|
1123
|
+
#nav{flex:1;overflow:auto;padding:4px 0 8px}
|
|
1124
|
+
.tierhdr{padding:10px 18px 4px;font-size:10.5px;font-weight:600;letter-spacing:.09em;color:var(--faint)}
|
|
1125
|
+
.nitem{display:flex;align-items:center;gap:8px;padding:5px 18px;cursor:pointer}
|
|
1126
|
+
.nitem:hover{background:#e9e5d8}
|
|
1127
|
+
.nitem.active{background:#e9e5d8;border-left:3px solid var(--accent);padding-left:15px}
|
|
1128
|
+
.nitem .nm{font-size:13px;font-weight:500;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
|
1129
|
+
.nitem.active .nm{font-weight:600}
|
|
1130
|
+
.nitem .fr{margin-left:auto;font-family:var(--mono);font-size:11px;color:var(--faint);white-space:nowrap}
|
|
1131
|
+
.dot{width:8px;height:8px;border-radius:50%;flex:0 0 8px}
|
|
1132
|
+
.dot.mapped{background:var(--ok-dot)}.dot.review,.dot.part{background:var(--rev-dot)}.dot.unmapped,.dot.none{background:var(--un-dot)}
|
|
1133
|
+
.ghdr{display:flex;align-items:center;gap:7px;padding:4px 18px;color:var(--mut);cursor:pointer;font-family:var(--mono);font-size:10.5px}
|
|
1134
|
+
.ghdr:hover{background:#e9e5d8}
|
|
1135
|
+
.ghdr .cnt{margin-left:auto;font-size:10.5px}
|
|
1136
|
+
.gkid{padding-left:34px}
|
|
1137
|
+
.gkid .nm{font-size:12.5px;font-weight:400}
|
|
1138
|
+
.condchip{font-size:9.5px;font-weight:600;color:var(--faint);border:1px solid #d8d3c3;border-radius:999px;padding:0 5px;white-space:nowrap}
|
|
1139
|
+
#railfoot{padding:12px 18px;border-top:1px solid var(--line)}
|
|
1140
|
+
#railfoot button{width:100%;color:var(--accent);border-color:var(--accent);background:transparent}
|
|
1141
|
+
.navempty{padding:2px 18px;font-size:12px;color:var(--faint);font-style:italic}
|
|
1142
|
+
/* ---- main ---- */
|
|
1143
|
+
#main{flex:1;display:flex;flex-direction:column;min-width:0}
|
|
1144
|
+
#head{padding:20px 32px 0}
|
|
1145
|
+
.crumb{font-family:var(--mono);font-size:13px;font-weight:500;letter-spacing:.06em;color:#4c463a;text-transform:uppercase}
|
|
1146
|
+
.crumb a{color:#4c463a}.crumb a:hover{color:var(--accent)}
|
|
1147
|
+
.titlerow{display:flex;align-items:center;gap:12px;margin-top:4px;flex-wrap:wrap}
|
|
1148
|
+
.title-serif{font-family:var(--serif);font-size:24px;font-weight:700}
|
|
1149
|
+
.title-mono{font-family:var(--mono);font-size:21px;font-weight:500}
|
|
1150
|
+
.titlerow .spacer{flex:1}
|
|
1151
|
+
#msg{font-size:12px;color:var(--mut)}
|
|
1152
|
+
.orient{margin-top:6px;font-size:13px;color:var(--mut);max-width:780px}
|
|
1153
|
+
.orient b{color:var(--ink)}
|
|
1154
|
+
#content{flex:1;overflow:auto;padding:16px 32px 28px}
|
|
1155
|
+
/* ---- shared bits ---- */
|
|
1156
|
+
.card{background:var(--card);border:1px solid var(--line);border-radius:10px;padding:13px 17px;margin-bottom:14px}
|
|
1157
|
+
.card h3{margin:0 0 8px;font-size:10.5px;font-weight:600;letter-spacing:.09em;color:var(--faint);text-transform:uppercase}
|
|
1158
|
+
.card h3 .hint{font-weight:400;letter-spacing:0;text-transform:none;font-size:11.5px;color:#a29b89;font-style:italic;margin-left:8px}
|
|
1159
|
+
.chip{display:inline-flex;align-items:center;gap:5px;font-size:11px;font-weight:600;border-radius:999px;padding:1px 9px;border:1px solid var(--line);color:var(--mut);vertical-align:1px}
|
|
1160
|
+
.chip.tier{color:var(--accent);background:var(--accent-soft);border-color:var(--accent-bd);letter-spacing:.06em;font-size:10.5px}
|
|
1161
|
+
.chip.st-mapped{color:var(--ok);background:var(--ok-bg);border-color:var(--ok-bd)}
|
|
1162
|
+
.chip.st-review{color:var(--rev);background:var(--rev-bg);border-color:var(--rev-bd)}
|
|
1163
|
+
.chip.st-unmapped{color:var(--un);background:var(--un-bg);border-color:var(--un-bd)}
|
|
1164
|
+
.chip.tui{font-family:var(--mono);font-weight:500;color:var(--link);background:var(--link-bg);border-color:transparent}
|
|
1165
|
+
.chip.warnc{color:var(--warn);background:#f7ecdd;border-color:#eddabb}
|
|
1166
|
+
.mono{font-family:var(--mono)}.mut{color:var(--mut)}.mini{font-size:11.5px;color:var(--faint)}
|
|
1167
|
+
.cui{font-family:var(--mono);font-size:12px;color:var(--link)}
|
|
1168
|
+
.stn{font-family:var(--mono);color:var(--faint)}
|
|
1169
|
+
.sab{font-family:var(--mono);font-size:10px;color:var(--faint);border:1px solid var(--un-bd);border-radius:999px;padding:0 5px;white-space:nowrap}
|
|
1170
|
+
.sab.ok{color:var(--ok);border-color:var(--ok-bd);background:var(--ok-bg);font-weight:600}
|
|
1171
|
+
.warnsvg{color:var(--warn)}
|
|
1172
|
+
.bar{display:flex;height:10px;border-radius:5px;overflow:hidden;background:var(--un-bd)}
|
|
1173
|
+
.bar .m{background:var(--ok-dot)}.bar .r{background:var(--rev-dot)}.bar .u{background:var(--un-bd)}
|
|
1174
|
+
.minibar{display:inline-flex;width:78px;height:6px;border-radius:3px;overflow:hidden;background:var(--hair);vertical-align:1px}
|
|
1175
|
+
.legend{display:flex;align-items:center;gap:14px;margin-top:8px;font-size:12px;color:var(--mut);flex-wrap:wrap}
|
|
1176
|
+
.legend b{color:var(--ink)}
|
|
1177
|
+
.ldot{width:8px;height:8px;border-radius:50%;display:inline-block;margin-right:5px;vertical-align:-1px}
|
|
1178
|
+
/* home tables */
|
|
1179
|
+
.hrow{display:grid;grid-template-columns:200px minmax(0,1fr) 145px 104px;align-items:center;column-gap:12px;padding:8px 0;border-top:1px solid var(--hair);cursor:pointer;font-size:12.5px}
|
|
1180
|
+
.hrow:hover{background:#f4f1e8;margin:0 -17px;padding-left:17px;padding-right:17px}
|
|
1181
|
+
.hrow .dn{font-family:var(--mono);font-size:12.5px;font-weight:500;display:flex;align-items:center;gap:7px;min-width:0}
|
|
1182
|
+
.hrow .st{justify-self:end}
|
|
1183
|
+
.condline{display:flex;align-items:center;gap:8px;flex-wrap:wrap;font-size:12px;margin-top:8px}
|
|
1184
|
+
.condline .act{font-family:var(--mono);font-size:10.5px;color:var(--faint)}
|
|
1185
|
+
.condline a{color:var(--ink)}.condline a:hover{color:var(--accent)}
|
|
1186
|
+
.condline .f{font-family:var(--mono);font-size:10.5px;color:var(--mut)}
|
|
1187
|
+
/* values table */
|
|
1188
|
+
.vt{border-collapse:collapse;width:100%;font-size:12.5px}
|
|
1189
|
+
.vt th{text-align:left;color:var(--faint);font-weight:600;font-size:10.5px;letter-spacing:.07em;border-bottom:1px solid var(--line);padding:4px 10px 4px 0;white-space:nowrap}
|
|
1190
|
+
.vt td{padding:5px 10px 5px 0;border-bottom:1px solid var(--hair);vertical-align:middle}
|
|
1191
|
+
.vt tr.vrow{cursor:pointer}
|
|
1192
|
+
.vt tr.vrow:hover td{background:#f4f1e8}
|
|
1193
|
+
.vt .tk{font-family:var(--mono);font-size:11.5px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;max-width:250px}
|
|
1194
|
+
/* axis card */
|
|
1195
|
+
.axgrid{display:flex;gap:0;margin:0 -17px}
|
|
1196
|
+
.axgrid>div{flex:1;padding:0 17px}
|
|
1197
|
+
.axgrid>div:first-child{border-right:1px dashed var(--line)}
|
|
1198
|
+
.axtype .tnm{font-family:var(--serif);font-size:17px;font-weight:600}
|
|
1199
|
+
.axrow{display:flex;gap:8px;align-items:center;margin:7px 0}
|
|
1200
|
+
.axrow label{width:74px;color:var(--mut);font-size:12px;text-align:right;flex-shrink:0}
|
|
1201
|
+
.axrow input,.axrow select{flex:1;min-width:0}
|
|
1202
|
+
.axrow input[readonly]{background:var(--hair);color:var(--mut)}
|
|
1203
|
+
.stnbox{margin-top:8px;font-family:var(--mono);font-size:11.5px;line-height:1.7;color:var(--mut)}
|
|
1204
|
+
/* candidates + evidence (value view) */
|
|
1205
|
+
.cand{border:1px solid var(--line);border-radius:9px;padding:9px 12px;margin-bottom:8px;background:var(--card)}
|
|
1206
|
+
.cand.acc{border-color:var(--ok-bd);background:#f2f8f3;border-width:1.5px}
|
|
1207
|
+
.cand .row{display:flex;align-items:center;gap:8px}.cand .row .n{flex:1;font-weight:500}
|
|
1208
|
+
.sty{font-size:11px;color:var(--faint);margin-top:2px}
|
|
1209
|
+
.def{margin-top:6px;font-size:12.5px;color:#3d382e;background:var(--hair);border-radius:6px;padding:7px 10px;display:none}
|
|
1210
|
+
.def.show{display:block}
|
|
1211
|
+
.searchbar{display:flex;gap:8px;margin-bottom:8px}.searchbar input{flex:1}
|
|
1212
|
+
.curbar{display:flex;gap:8px;align-items:center;flex-wrap:wrap;margin-top:8px}
|
|
1213
|
+
.now{font-size:13px}
|
|
1214
|
+
.badge{font-size:10px;font-weight:600;padding:1px 6px;border-radius:999px;background:var(--ok-bg);color:var(--ok);border:1px solid var(--ok-bd)}
|
|
1215
|
+
.badge.bad{background:#f6e4de;color:var(--danger);border-color:#ecc8bd}
|
|
1216
|
+
.empty{color:var(--faint);padding:36px;text-align:center;font-style:italic}
|
|
1217
|
+
/* evidence tables */
|
|
1218
|
+
.evtop{font-size:12.5px;margin-bottom:4px}
|
|
1219
|
+
.evsec{margin:8px 0 2px;border-top:1px solid var(--hair);padding-top:6px}
|
|
1220
|
+
.evh{font-size:10.5px;text-transform:uppercase;letter-spacing:.07em;color:var(--faint);font-weight:600;margin-bottom:3px}
|
|
1221
|
+
.evh button.mini{float:right;text-transform:none;letter-spacing:0}
|
|
1222
|
+
.evrow{margin:2px 0;line-height:1.5;font-size:12.5px}
|
|
1223
|
+
.et{border-collapse:collapse;width:100%;font-size:12px}
|
|
1224
|
+
.et th{text-align:left;color:var(--faint);font-weight:500;border-bottom:1px solid var(--line);padding:2px 8px 2px 0;white-space:nowrap}
|
|
1225
|
+
.et td{padding:2px 8px 2px 0;vertical-align:top;border-bottom:1px solid var(--hair)}
|
|
1226
|
+
.et tr.obs td{color:var(--un-dot)}
|
|
1227
|
+
.et tr.axisrow td{border-top:1px solid var(--line);color:var(--link)}
|
|
1228
|
+
.et .orh td{background:var(--hair);font-weight:600;font-family:var(--mono);font-size:11px}
|
|
1229
|
+
.dir.up{color:var(--ok);white-space:nowrap}.dir.down{color:var(--link);white-space:nowrap}
|
|
1230
|
+
.otherbox{margin-top:4px}
|
|
1231
|
+
.subtree{margin-top:5px;font-size:12px;max-height:300px;overflow:auto;border:1px solid var(--line);border-radius:7px;padding:7px;background:#fff}
|
|
1232
|
+
.stn-node{padding:1px 2px;border-radius:3px}
|
|
1233
|
+
.stn-kids{margin-left:12px;border-left:1px solid var(--line);padding-left:8px}
|
|
1234
|
+
.stn-tree{font-family:var(--mono);color:var(--faint);font-size:10.5px;margin-right:4px}
|
|
1235
|
+
.stn-node.hl{background:var(--ok-bg);font-weight:600}
|
|
1236
|
+
.stn-node.ax{color:var(--link)}
|
|
1237
|
+
.stylist{max-height:320px;overflow:auto}
|
|
1238
|
+
.styrow{border:1px solid var(--line);border-radius:7px;padding:6px 9px;margin-bottom:6px;font-size:12.5px;background:var(--card)}
|
|
1239
|
+
.styrow.acc{border-color:var(--ok-bd);background:#f2f8f3}
|
|
1240
|
+
.styrow button{float:right;margin-left:6px}
|
|
1241
|
+
.stytree{font-family:var(--mono);color:var(--faint);margin-right:6px}
|
|
1242
|
+
.axtree{margin-top:5px;padding-top:5px;border-top:1px dashed var(--line)}
|
|
1243
|
+
.mini2{font-size:11px;color:var(--faint);margin-bottom:3px}.mini2 a{color:var(--link)}
|
|
1244
|
+
/* modal (Semantic Network popups) */
|
|
1245
|
+
#modal{position:fixed;inset:0;background:rgba(35,30,20,.45);display:flex;align-items:center;justify-content:center;z-index:50}
|
|
1246
|
+
#modal .mbox{background:var(--card);border:1px solid var(--line);border-radius:12px;width:min(760px,92vw);max-height:82vh;display:flex;flex-direction:column;box-shadow:0 12px 40px rgba(35,30,20,.28)}
|
|
1247
|
+
#modal .mhead{display:flex;align-items:center;gap:10px;padding:11px 16px;border-bottom:1px solid var(--hair);font-size:13px}
|
|
1248
|
+
#modal .mbody{padding:12px 16px;overflow:auto;font-size:12.5px}
|
|
1249
|
+
.tawrap{position:relative;flex:1;min-width:0;display:flex}
|
|
1250
|
+
.tawrap input{width:100%}
|
|
1251
|
+
.tadrop{position:absolute;left:0;right:0;top:100%;margin-top:3px;background:var(--card);border:1px solid var(--line);border-radius:8px;box-shadow:0 8px 24px rgba(35,30,20,.18);max-height:240px;overflow:auto;z-index:60}
|
|
1252
|
+
.taitem{display:flex;gap:10px;align-items:baseline;padding:6px 10px;cursor:pointer;font-size:12.5px}
|
|
1253
|
+
.taitem:hover,.taitem.sel{background:#e9e5d8}
|
|
1254
|
+
.taitem .ab{font-family:var(--mono);font-weight:500;min-width:110px}
|
|
1255
|
+
.taitem .nm{color:var(--mut);font-size:11.5px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
|
1256
|
+
.stnlink{font-family:var(--mono);font-size:11px;color:var(--link);cursor:pointer;text-decoration:none;border-bottom:1px dotted var(--link)}
|
|
1257
|
+
.stnlink:hover{text-decoration:none;border-bottom-style:solid}
|
|
1258
|
+
.vocab{display:inline-block;background:var(--link-bg);color:var(--link);border:1px solid #cfdce8;border-radius:4px;padding:0 4px;margin:1px;font-size:11px}
|
|
1259
|
+
/* needs: what still needs a curator's eyes (server-defined codes) */
|
|
1260
|
+
.chip.needc{color:var(--warn);background:#f7ecdd;border-color:#eddabb}
|
|
1261
|
+
.nbadge{display:inline-block;font-family:var(--mono);font-size:10px;font-weight:600;line-height:15px;color:var(--warn);background:#f7ecdd;border:1px solid #eddabb;border-radius:999px;padding:0 6px;margin-right:6px}
|
|
1262
|
+
.wlbar{display:flex;align-items:center;gap:6px;flex-wrap:wrap;margin-bottom:8px}
|
|
1263
|
+
.wlbar input{margin-left:auto;width:190px;font-size:12px}
|
|
1264
|
+
.fchip{display:inline-flex;align-items:center;gap:4px;font-size:11px;font-weight:500;border-radius:999px;padding:2px 10px;border:1px solid var(--line);color:var(--mut);cursor:pointer;user-select:none;background:var(--card)}
|
|
1265
|
+
.fchip:hover{background:#f4f1e8}
|
|
1266
|
+
.fchip b{font-family:var(--mono);font-weight:600}
|
|
1267
|
+
.fchip.on{color:#f6f3ea;background:var(--accent);border-color:var(--accent)}
|
|
1268
|
+
.fchip.zero{opacity:.55}
|
|
1269
|
+
.wlrow{display:grid;grid-template-columns:minmax(220px,1fr) minmax(0,1fr) auto;align-items:center;column-gap:12px;padding:7px 0;border-top:1px solid var(--hair);cursor:pointer;font-size:12.5px}
|
|
1270
|
+
.wlrow:hover{background:#f4f1e8;margin:0 -17px;padding-left:17px;padding-right:17px}
|
|
1271
|
+
.wlrow .dn{font-family:var(--mono);font-size:12px;display:flex;align-items:center;gap:7px;min-width:0}
|
|
1272
|
+
.wlrow .dn .tk{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
|
1273
|
+
.wlrow .st{display:flex;align-items:center;gap:5px;justify-self:end;white-space:nowrap}
|
|
1274
|
+
.notice{display:flex;align-items:center;gap:7px;margin-top:8px;padding:7px 10px;border-radius:7px;font-size:12.5px;color:var(--warn);background:#f7ecdd;border:1px solid #eddabb}
|
|
1275
|
+
</style></head><body>
|
|
1276
|
+
<div id="rail"></div>
|
|
1277
|
+
<div id="main"><div id="head"></div><div id="content"><div class="empty">Loading workspace…</div></div></div>
|
|
1278
|
+
<script>
|
|
1279
|
+
let STATE=null,SEMTYPES=null,SEL=null,AXB=null;
|
|
1280
|
+
let ROUTE={view:'home'};
|
|
1281
|
+
let OPEN={}; // conditional-group open/closed, keyed by activation
|
|
1282
|
+
let FILTER={dim:null,status:'all',text:''};
|
|
1283
|
+
let WL={need:'all',text:''}; // Home worklist filter (need code or 'all', text)
|
|
1284
|
+
// per-value, in-session record of the adjudication protocol: the searches run
|
|
1285
|
+
// (SEARCHLOG) and every candidate card rendered (SEEN, cui -> concept) -- the
|
|
1286
|
+
// raw material of a structured "no faithful concept" argument.
|
|
1287
|
+
const SEARCHLOG={},SEEN={};
|
|
1288
|
+
const RELATIONS=['exact','close','broader','narrower','related'];
|
|
1289
|
+
const CRITERIA={
|
|
1290
|
+
A:"denotation — the concept denotes a different thing or relation than the GEM token (e.g. a measurand degree vs an epistemic degree)",
|
|
1291
|
+
B:"granularity — only a broader/narrower concept exists and the gap is not acceptable as a proxy",
|
|
1292
|
+
C:"set membership — cannot serve as a point of the token’s scale/enumeration (not disjoint from / ordered with its siblings)",
|
|
1293
|
+
D:"domain sense — right words, wrong domain or context (e.g. an IPSS-R risk category named ‘High’)"};
|
|
1294
|
+
const $=s=>document.querySelector(s);
|
|
1295
|
+
const esc=s=>(s==null?'':String(s)).replace(/[&<>"]/g,c=>({'&':'&','<':'<','>':'>','"':'"'}[c]));
|
|
1296
|
+
// for user-controlled strings interpolated into single-quoted JS inside onclick attributes
|
|
1297
|
+
const jsq=s=>esc(String(s==null?'':s).replace(/\\/g,'\\\\').replace(/'/g,"\\'"));
|
|
1298
|
+
function msg(t){const el=$('#msg');if(el)el.textContent=t||'';}
|
|
1299
|
+
const IC={
|
|
1300
|
+
logo:'<svg width="26" height="26" viewBox="0 0 26 26" fill="none"><circle cx="13" cy="13" r="11.5" stroke="#155e68" stroke-width="1.6"/><path d="M13 5v16M6 9.5l14 7M6 16.5l14-7" stroke="#155e68" stroke-width="1.6" stroke-linecap="round"/></svg>',
|
|
1301
|
+
warn:'<svg class="warnsvg" width="13" height="13" viewBox="0 0 14 14" fill="none"><path d="M7 2 12.6 11.5H1.4L7 2Z" stroke="currentColor" stroke-width="1.4" stroke-linejoin="round"/><path d="M7 6v2.6" stroke="currentColor" stroke-width="1.4" stroke-linecap="round"/></svg>',
|
|
1302
|
+
right:'<svg width="10" height="10" viewBox="0 0 10 10" fill="none"><path d="M3 1.5 7 5 3 8.5" stroke="#8a8474" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round"/></svg>',
|
|
1303
|
+
down:'<svg width="10" height="10" viewBox="0 0 10 10" fill="none"><path d="M1.5 3 5 7 8.5 3" stroke="#8a8474" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round"/></svg>',
|
|
1304
|
+
plus:'<svg width="12" height="12" viewBox="0 0 12 12" fill="none"><path d="M6 1.5v9M1.5 6h9" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"/></svg>',
|
|
1305
|
+
gear:'<svg width="16" height="16" viewBox="0 0 16 16" fill="none"><circle cx="8" cy="8" r="2.2" stroke="currentColor" stroke-width="1.4"/><path d="M8 1.6v1.8M8 12.6v1.8M1.6 8h1.8M12.6 8h1.8M3.5 3.5l1.3 1.3M11.2 11.2l1.3 1.3M3.5 12.5l1.3-1.3M11.2 4.8l1.3-1.3" stroke="currentColor" stroke-width="1.4" stroke-linecap="round"/></svg>'};
|
|
1306
|
+
/* ---------- vocabulary helpers ---------- */
|
|
1307
|
+
const TTY={AB:'abbreviation',ACR:'acronym',BN:'brand name',DN:'display name',
|
|
1308
|
+
EP:'print entry term (MeSH)',ET:'entry term',FN:'fully specified name',
|
|
1309
|
+
HG:'high-level group term (MedDRA)',HT:'hierarchical term',IN:'ingredient name (RxNorm)',
|
|
1310
|
+
LA:'LOINC answer',LLT:'lower-level term (MedDRA)',MH:'main heading (MeSH)',
|
|
1311
|
+
NM:'supplementary concept name (MeSH)',OAF:'obsolete active fully specified name',
|
|
1312
|
+
OAP:'obsolete active preferred term',OAS:'obsolete active synonym',
|
|
1313
|
+
OF:'obsolete fully specified name',OL:'non-current lower-level term (MedDRA)',
|
|
1314
|
+
OP:'obsolete preferred term',PEP:'preferred entry term (MeSH)',
|
|
1315
|
+
PN:'Metathesaurus preferred name',PT:'preferred term',PTGB:'British preferred term',
|
|
1316
|
+
RPT:'root preferred term',SCD:'semantic clinical drug (RxNorm)',SY:'synonym',
|
|
1317
|
+
SYGB:'British synonym',TQ:'topical qualifier',XQ:'alternate qualifier name'};
|
|
1318
|
+
function ttyHTML(t){if(!t)return '';
|
|
1319
|
+
let base=t,pre='';
|
|
1320
|
+
if(t.startsWith('MTH_')){base=t.slice(4);pre='Metathesaurus ';}
|
|
1321
|
+
const d=TTY[base];
|
|
1322
|
+
return d?`<span class="mono">${esc(t)}</span> <span class="mut" style="font-size:10.5px">${esc(pre+d)}</span>`
|
|
1323
|
+
:`<span class="mono" title="term type — see the UMLS TTY reference">${esc(t)}</span>`;}
|
|
1324
|
+
// Enrich candidate cards with the full English SAB membership (root_source is
|
|
1325
|
+
// only the name's highest-precedence source -- MTH means the Metathesaurus
|
|
1326
|
+
// itself -- so membership must come from the atoms). The dimension's preferred
|
|
1327
|
+
// SAB (inventory `sab:` hint) is ticked when present, flagged when absent.
|
|
1328
|
+
const SABCACHE={};
|
|
1329
|
+
async function fetchSabs(cui){if(SABCACHE[cui])return SABCACHE[cui];
|
|
1330
|
+
try{const j=await (await fetch('/api/sabs?cui='+encodeURIComponent(cui))).json();SABCACHE[cui]=j.sabs||[];}
|
|
1331
|
+
catch(err){SABCACHE[cui]=[];}
|
|
1332
|
+
return SABCACHE[cui];}
|
|
1333
|
+
// ONE display rule for a concept's source, used everywhere a SAB is shown:
|
|
1334
|
+
// the first preferred vocabulary the concept is actually in (ticked), else all
|
|
1335
|
+
// of them; the search's root_source only until membership is known.
|
|
1336
|
+
function sabLabel(sabs,prefs,root){prefs=prefs||[];
|
|
1337
|
+
if(!sabs||!sabs.length)return root?`<span class="sab" title="root source of the preferred name; membership not loaded">${esc(root)}</span>`:'';
|
|
1338
|
+
const all=sabs.join(', ');
|
|
1339
|
+
const p=prefs.find(x=>sabs.includes(x));
|
|
1340
|
+
if(p)return `<span class="sab ok" title="preferred vocabulary · also in: ${esc(all)}">${esc(p)} ✓</span>`+
|
|
1341
|
+
(sabs.length>1?`<span class="mini"> +${sabs.length-1}</span>`:'');
|
|
1342
|
+
const shown=sabs.slice(0,5).join(', ');
|
|
1343
|
+
return `<span class="sab" title="${esc(all)}">${esc(shown)}${sabs.length>5?' +'+(sabs.length-5):''}</span>`+
|
|
1344
|
+
(prefs.length?`<span class="mini" style="color:var(--rev)"> not in preferred (${esc(prefs.join(' › '))})</span>`:'');}
|
|
1345
|
+
// fill every .sabslot[data-cui] under root with the membership-aware label
|
|
1346
|
+
async function enrichSlots(root){
|
|
1347
|
+
const slots=[...(root||document).querySelectorAll('.sabslot[data-cui]:not([data-done])')];
|
|
1348
|
+
slots.forEach(el=>el.setAttribute('data-done','1'));
|
|
1349
|
+
await Promise.all(slots.map(async el=>{
|
|
1350
|
+
const sabs=await fetchSabs(el.getAttribute('data-cui'));
|
|
1351
|
+
if(!el.isConnected||!sabs.length)return;
|
|
1352
|
+
const prefs=(el.getAttribute('data-prefs')||'').split(',').filter(Boolean);
|
|
1353
|
+
el.innerHTML=sabLabel(sabs,prefs,el.getAttribute('data-root'));}));}
|
|
1354
|
+
// candidate cards: append the full membership list, preferred ones first + ticked
|
|
1355
|
+
async function enrichSabs(root){const prefs=(SEL&&SEL.sab_prefs)||[];
|
|
1356
|
+
const cards=[...(root||document).querySelectorAll('.cand[data-cui]:not([data-sabs])')];
|
|
1357
|
+
cards.forEach(el=>el.setAttribute('data-sabs','1'));
|
|
1358
|
+
await Promise.all(cards.map(async el=>{
|
|
1359
|
+
const sabs=await fetchSabs(el.getAttribute('data-cui'));
|
|
1360
|
+
if(!sabs.length||!el.isConnected)return;
|
|
1361
|
+
const hit=prefs.filter(p=>sabs.includes(p)),rest=sabs.filter(x=>!hit.includes(x));
|
|
1362
|
+
const show=hit.map(x=>`<b style="color:var(--ok)">${esc(x)} ✓</b>`).concat(rest.slice(0,10).map(esc)).join(', ')
|
|
1363
|
+
+(rest.length>10?` +${rest.length-10} more`:'');
|
|
1364
|
+
const miss=prefs.length&&!hit.length?` <span style="color:var(--rev)">(not in preferred: ${esc(prefs.join(' › '))})</span>`:'';
|
|
1365
|
+
const box=el.querySelector('.sty');
|
|
1366
|
+
if(box)box.insertAdjacentHTML('beforeend',` <span class="mini">· in: ${show}${miss}</span>`);}));}
|
|
1367
|
+
/* ---------- vocabulary typeahead (comma-separated SAB lists) ---------- */
|
|
1368
|
+
let SOURCES=null,_srcReq=null;
|
|
1369
|
+
function loadSources(){if(SOURCES)return Promise.resolve(SOURCES);
|
|
1370
|
+
if(!_srcReq)_srcReq=fetch('/api/sources').then(r=>r.json()).then(j=>{SOURCES=j.sources||[];return SOURCES;}).catch(()=>{SOURCES=[];return SOURCES;});
|
|
1371
|
+
return _srcReq;}
|
|
1372
|
+
// Attach to an <input>: the token being typed (after the last comma) narrows a
|
|
1373
|
+
// dropdown of vocabularies (abbreviation prefix first, then any substring of
|
|
1374
|
+
// abbreviation or name). Enter/click completes the token; Enter with the
|
|
1375
|
+
// dropdown closed falls through to onEnter (e.g. save).
|
|
1376
|
+
function attachSabTypeahead(input,onEnter){if(!input||input.dataset.ta)return;input.dataset.ta='1';
|
|
1377
|
+
const wrap=document.createElement('div');wrap.className='tawrap';
|
|
1378
|
+
input.parentNode.insertBefore(wrap,input);wrap.appendChild(input);
|
|
1379
|
+
const drop=document.createElement('div');drop.className='tadrop';drop.style.display='none';wrap.appendChild(drop);
|
|
1380
|
+
let items=[],sel=-1;
|
|
1381
|
+
const token=()=>{const v=input.value,i=v.lastIndexOf(',');return v.slice(i+1).trim();};
|
|
1382
|
+
const close=()=>{drop.style.display='none';items=[];sel=-1;};
|
|
1383
|
+
const pick=i=>{const it=items[i];if(!it)return;const v=input.value,c=v.lastIndexOf(',');
|
|
1384
|
+
input.value=(c>=0?v.slice(0,c+1)+' ':'')+it.sab+', ';close();input.focus();};
|
|
1385
|
+
const render=()=>{if(!items.length){close();return;}
|
|
1386
|
+
drop.innerHTML=items.map((it,i)=>`<div class="taitem ${i===sel?'sel':''}" data-i="${i}"><span class="ab">${esc(it.sab)}</span><span class="nm">${esc(it.name||'')}</span></div>`).join('');
|
|
1387
|
+
drop.style.display='block';
|
|
1388
|
+
drop.querySelectorAll('.taitem').forEach(el=>{el.onmousedown=ev=>{ev.preventDefault();pick(+el.dataset.i);};});};
|
|
1389
|
+
const update=async()=>{const q=token().toUpperCase();if(!q){close();return;}
|
|
1390
|
+
const src=await loadSources();
|
|
1391
|
+
const chosen=new Set(input.value.toUpperCase().split(',').map(x=>x.trim()).filter(Boolean));
|
|
1392
|
+
const pre=src.filter(x=>x.sab.toUpperCase().startsWith(q)&&!chosen.has(x.sab.toUpperCase()));
|
|
1393
|
+
const sub=src.filter(x=>!pre.includes(x)&&!chosen.has(x.sab.toUpperCase())&&(x.sab+' '+(x.name||'')).toUpperCase().includes(q));
|
|
1394
|
+
items=pre.concat(sub).slice(0,9);sel=items.length?0:-1;render();};
|
|
1395
|
+
input.addEventListener('input',update);
|
|
1396
|
+
input.addEventListener('blur',()=>setTimeout(close,120));
|
|
1397
|
+
input.addEventListener('keydown',ev=>{
|
|
1398
|
+
const open=drop.style.display!=='none'&&items.length;
|
|
1399
|
+
if(ev.key==='ArrowDown'&&open){sel=(sel+1)%items.length;render();ev.preventDefault();}
|
|
1400
|
+
else if(ev.key==='ArrowUp'&&open){sel=(sel-1+items.length)%items.length;render();ev.preventDefault();}
|
|
1401
|
+
else if(ev.key==='Enter'){if(open){pick(sel<0?0:sel);ev.preventDefault();ev.stopPropagation();}else if(onEnter)onEnter();}
|
|
1402
|
+
else if(ev.key==='Escape'&&open){close();ev.preventDefault();ev.stopPropagation();}
|
|
1403
|
+
else if(ev.key==='Tab'&&open){pick(sel<0?0:sel);ev.preventDefault();}});}
|
|
1404
|
+
/* ---------- modal + Semantic Network views ---------- */
|
|
1405
|
+
function showModal(titleHTML,bodyHTML){closeModal();
|
|
1406
|
+
const d=document.createElement('div');d.id='modal';
|
|
1407
|
+
d.innerHTML=`<div class="mbox"><div class="mhead"><b>${titleHTML}</b><span style="flex:1"></span>`+
|
|
1408
|
+
`<button class="mini" onclick="closeModal()">close</button></div><div class="mbody">${bodyHTML}</div></div>`;
|
|
1409
|
+
d.addEventListener('click',ev=>{if(ev.target===d)closeModal();});
|
|
1410
|
+
document.body.appendChild(d);}
|
|
1411
|
+
function closeModal(){const m=$('#modal');if(m)m.remove();}
|
|
1412
|
+
document.addEventListener('keydown',ev=>{if(ev.key==='Escape')closeModal();});
|
|
1413
|
+
function mostSpecificTui(names){if(!SEMTYPES)return null;const norm=s=>(s||'').toLowerCase();
|
|
1414
|
+
let best=null;(names||[]).forEach(nm=>{const t=SEMTYPES.find(x=>norm(x.name)===norm(nm));
|
|
1415
|
+
if(t&&t.tree&&(!best||t.tree.split('.').length>best.tree.split('.').length))best=t;});
|
|
1416
|
+
return best;}
|
|
1417
|
+
function showAxisTree(){if(!SEL||!SEL.dim_sty_tui)return;
|
|
1418
|
+
loadSemTypes().then(()=>showModal('Axis type in the Semantic Network — '+esc(SEL.dim_sty_name||SEL.dim_sty_tui),
|
|
1419
|
+
`<div class="subtree" style="max-height:none;border:none;padding:0">${stnPlaceHTML(SEL.dim_sty_tui,true)}</div>`));}
|
|
1420
|
+
function showCompare(tui,axisTui){loadSemTypes().then(()=>{
|
|
1421
|
+
const t=(SEMTYPES||[]).find(x=>x.tui===tui);
|
|
1422
|
+
showModal('Place in the Semantic Network — '+esc(t?t.name:tui),
|
|
1423
|
+
stnCompareHTML(tui,axisTui!==undefined?axisTui:(SEL&&SEL.dim_sty_tui)));});}
|
|
1424
|
+
// The result type's position relative to the axis type: shared ancestors as one
|
|
1425
|
+
// spine, then the branches fork (axis and result each labeled).
|
|
1426
|
+
function stnCompareHTML(tui,axisTui){
|
|
1427
|
+
const find=x=>(SEMTYPES||[]).find(t=>t.tui===x);
|
|
1428
|
+
const t=find(tui);if(!t||!t.tree)return '<i>type not in the Semantic Network</i>';
|
|
1429
|
+
const nest=(nodes,mark,tail)=>{let h=tail||'';
|
|
1430
|
+
for(let i=nodes.length-1;i>=0;i--){const n=nodes[i];const last=i===nodes.length-1;
|
|
1431
|
+
const cls=last&&mark?' '+mark:'';
|
|
1432
|
+
const lab=last&&mark==='hl'?' <span class="mini">← this result</span>'
|
|
1433
|
+
:last&&mark==='ax'?' <span class="mini">← axis</span>':'';
|
|
1434
|
+
h=`<div class="stn-node${cls}"><span class="stn-tree">${esc(n.tree)}</span> ${esc(n.name)}${lab}`+
|
|
1435
|
+
(h?`<div class="stn-kids">${h}</div>`:'')+`</div>`;}
|
|
1436
|
+
return h;};
|
|
1437
|
+
const chainOf=x=>{const c=stnParents(x.tree).map(pt=>(SEMTYPES||[]).find(y=>y.tree===pt)).filter(Boolean);
|
|
1438
|
+
c.push(x);return c;};
|
|
1439
|
+
const ct=chainOf(t),a=axisTui?find(axisTui):null;
|
|
1440
|
+
if(!a||!a.tree)return `<div class="stn-tree">${nest(ct,'hl')}</div><div class="mini" style="margin-top:6px">no axis type to compare against</div>`;
|
|
1441
|
+
const ca=chainOf(a);
|
|
1442
|
+
let k=0;while(k<ct.length&&k<ca.length&&ct[k].tui===ca[k].tui)k++;
|
|
1443
|
+
if(k===ca.length&&k===ct.length)
|
|
1444
|
+
return `<div class="stn-tree">${nest(ca,'ax')}</div><div class="mini" style="margin-top:6px">this result's type IS the axis type</div>`;
|
|
1445
|
+
if(k===ca.length) // result lies inside the axis subtree
|
|
1446
|
+
return `<div class="stn-tree">${nest(ca,'ax',nest(ct.slice(k),'hl'))}</div>`+
|
|
1447
|
+
`<div class="mini" style="margin-top:6px">inside the axis subtree — an in-scope mapping</div>`;
|
|
1448
|
+
if(k===ct.length) // result is an ancestor of the axis type
|
|
1449
|
+
return `<div class="stn-tree">${nest(ct,'hl',nest(ca.slice(k),'ax'))}</div>`+
|
|
1450
|
+
`<div class="mini" style="margin-top:6px">broader than the axis — an ancestor of the axis type</div>`;
|
|
1451
|
+
const common=ct.slice(0,k);
|
|
1452
|
+
const branches=nest(ca.slice(k),'ax')+nest(ct.slice(k),'hl');
|
|
1453
|
+
const body=common.length?nest(common,null,branches):branches;
|
|
1454
|
+
const note=k===0?'no common ancestor — a different branch of the Semantic Network'
|
|
1455
|
+
:'branches diverge after <b>'+esc(common[common.length-1].name)+'</b>';
|
|
1456
|
+
return `<div class="stn-tree">${body}</div><div class="mini" style="margin-top:6px">${note}</div>`;}
|
|
1457
|
+
async function loadState(){const r=await fetch('/api/state');STATE=await r.json();
|
|
1458
|
+
if(!r.ok||STATE.error){$('#head').innerHTML='';
|
|
1459
|
+
$('#content').innerHTML='<div class="card" style="border-color:#ecc8bd"><h3 style="color:var(--danger)">Workspace error</h3>'+
|
|
1460
|
+
'<div style="font-size:13px">'+esc(STATE.error||('HTTP '+r.status))+'</div>'+
|
|
1461
|
+
'<div class="mini" style="margin-top:5px">Fix the workspace files (often dimensions_inventory.yaml) and reload.</div></div>';return;}
|
|
1462
|
+
render();}
|
|
1463
|
+
/* ---------- derived data ---------- */
|
|
1464
|
+
function dimList(){const out=[];
|
|
1465
|
+
(STATE.entries||[]).forEach(e=>{
|
|
1466
|
+
if(e.kind!=='axis')return;
|
|
1467
|
+
const vals=STATE.entries.filter(x=>x.dimension===e.dimension&&x.kind!=='axis');
|
|
1468
|
+
out.push({e,dim:e.dimension,tier:e.tier||'core',activation:e.activation||null,
|
|
1469
|
+
tui:e.sty_tui,styName:e.sty_name,styTree:e.sty_tree,
|
|
1470
|
+
total:vals.length,
|
|
1471
|
+
mapped:vals.filter(v=>v.status==='mapped').length,
|
|
1472
|
+
review:vals.filter(v=>v.status==='review').length,
|
|
1473
|
+
unmapped:vals.filter(v=>v.status==='unmapped').length,
|
|
1474
|
+
// items still needing a curator (server-defined entry.needs): values + the axis
|
|
1475
|
+
needs:vals.reduce((n,v)=>n+(v.needs||[]).length,0)+(e.needs||[]).length});});
|
|
1476
|
+
return out;} // server already ordered by tier/order
|
|
1477
|
+
function grouped(){const g={core:[],cond:[],candidate:[]},bykey={};
|
|
1478
|
+
dimList().forEach(d=>{
|
|
1479
|
+
if(d.tier==='conditional'){const k=d.activation||'(no activation recorded)';
|
|
1480
|
+
let grp=bykey[k];if(!grp){grp=bykey[k]={act:k,dims:[]};g.cond.push(grp);}grp.dims.push(d);}
|
|
1481
|
+
else if(d.tier==='candidate')g.candidate.push(d);
|
|
1482
|
+
else g.core.push(d);});
|
|
1483
|
+
return g;}
|
|
1484
|
+
function activeDim(){if(ROUTE.view==='dim')return ROUTE.dim;
|
|
1485
|
+
if(ROUTE.view==='value'&&SEL)return SEL.dimension;return null;}
|
|
1486
|
+
function dotFor(d){if(d.total===0)return d.tui?'none':'none';
|
|
1487
|
+
if(d.mapped===d.total)return 'mapped';if(d.mapped||d.review)return 'part';return 'none';}
|
|
1488
|
+
/* ---------- needs / worklist ----------
|
|
1489
|
+
What still needs a curator's eyes is decided by the server: every entry
|
|
1490
|
+
carries entry.needs (a list of codes), STATE.needs holds the per-code totals
|
|
1491
|
+
and STATE.need_labels the labels. Nothing here re-derives the definition. */
|
|
1492
|
+
function needLabel(code){return ((STATE&&STATE.need_labels)||{})[code]||{label:code,desc:'',scope:'value'};}
|
|
1493
|
+
function needChips(e){return (e.needs||[]).map(n=>{const l=needLabel(n);
|
|
1494
|
+
return `<span class="chip needc" title="${esc(l.desc)}">${esc(l.label)}</span>`;}).join(' ');}
|
|
1495
|
+
// worklist order = STATE.entries order: dimension order, each axis before its values
|
|
1496
|
+
function worklist(){return (STATE.entries||[]).filter(e=>(e.needs||[]).length);}
|
|
1497
|
+
function wlFiltered(){const t=WL.text.toLowerCase();
|
|
1498
|
+
return worklist().filter(e=>(WL.need==='all'||(e.needs||[]).includes(WL.need))&&
|
|
1499
|
+
(!t||(e.dimension+' '+(e.token==null?'axis':e.token)+' '+(e.matched_name||'')).toLowerCase().includes(t)));}
|
|
1500
|
+
function openEntry(e){if(!e)return;if(e.kind==='axis')gotoDim(e.dimension);else gotoValue(e.key);}
|
|
1501
|
+
// the next entry after fromKey (wrapping around) that still needs a curator, or null
|
|
1502
|
+
function nextNeeding(fromKey,need){need=need||WL.need||'all';const es=STATE.entries||[],n=es.length;
|
|
1503
|
+
const i=es.findIndex(x=>x.key===fromKey);
|
|
1504
|
+
const ok=e=>(e.needs||[]).length&&(need==='all'||e.needs.includes(need));
|
|
1505
|
+
for(let k=1;k<=n;k++){const e=es[(i+k+n)%n];if(e.key!==fromKey&&ok(e))return e;}
|
|
1506
|
+
return need==='all'?null:nextNeeding(fromKey,'all');} // filtered chain exhausted: fall back to any need
|
|
1507
|
+
function nextBtn(fromKey){const nx=nextNeeding(fromKey);if(!nx)return '';
|
|
1508
|
+
const what=(nx.needs||[]).map(c=>needLabel(c).label).join(', ');
|
|
1509
|
+
return `<button id="btn-next" title="${esc(nx.dimension)} › ${esc(nx.token==null?'axis':String(nx.token))} — ${esc(what)}">Next needing review →</button>`;}
|
|
1510
|
+
function wireNext(fromKey){const b=$('#btn-next');if(b)b.addEventListener('click',()=>openEntry(nextNeeding(fromKey)));}
|
|
1511
|
+
/* ---------- navigation ---------- */
|
|
1512
|
+
function gotoHome(){ROUTE={view:'home'};SEL=null;render();}
|
|
1513
|
+
function gotoDim(dim){if(FILTER.dim!==dim)FILTER={dim,status:'all',text:''};
|
|
1514
|
+
ROUTE={view:'dim',dim};SEL=null;AXB=null;render();}
|
|
1515
|
+
function gotoValue(key){const e=STATE.entries.find(x=>x.key===key);if(!e)return;
|
|
1516
|
+
SEL=e;ROUTE={view:'value',key};render();}
|
|
1517
|
+
function newAxis(){ROUTE={view:'dim',dim:null,isnew:true};SEL=null;
|
|
1518
|
+
AXB={dimension:'',query:'',note:'',tui:null,isnew:true,tier:'core',activation:''};render();}
|
|
1519
|
+
/* ---------- rail ---------- */
|
|
1520
|
+
function railItem(d,kid){const act=activeDim()===d.dim;
|
|
1521
|
+
const frac=d.total?`${d.mapped}/${d.total}`:(d.tui?'typed':'axis only');
|
|
1522
|
+
return `<div class="nitem ${act?'active':''} ${kid?'gkid':''}" onclick="gotoDim('${jsq(d.dim)}')">`+
|
|
1523
|
+
`<span class="dot ${dotFor(d)}"></span><span class="nm">${esc(d.dim)}</span>`+
|
|
1524
|
+
(d.tier==='core'&&d.activation?`<span class="condchip">cond.</span>`:'')+
|
|
1525
|
+
(!d.tui?IC.warn:'')+
|
|
1526
|
+
`<span class="fr">${d.needs?`<span class="nbadge" title="${d.needs} item${d.needs===1?'':'s'} still need${d.needs===1?'s':''} a curator">${d.needs}</span>`:''}${frac}</span></div>`;}
|
|
1527
|
+
function renderRail(){const g=grouped();const box=$('#rail');
|
|
1528
|
+
let h=`<div id="brand" onclick="gotoHome()">${IC.logo}<div class="txt"><div class="nm">GEM Mapping Studio</div>`+
|
|
1529
|
+
`<div class="ws">${esc(shortWs())} · ${(STATE.counts||{}).total||0} values</div></div>`+
|
|
1530
|
+
`<div id="gear" title="Settings — preferred vocabularies" onclick="event.stopPropagation();openSettings()">${IC.gear}</div></div><div id="nav">`;
|
|
1531
|
+
h+=`<div class="tierhdr">CORE</div>`;
|
|
1532
|
+
h+=g.core.map(d=>railItem(d)).join('')||'<div class="navempty">none yet</div>';
|
|
1533
|
+
h+=`<div class="tierhdr">CONDITIONAL</div>`;
|
|
1534
|
+
if(!g.cond.length)h+='<div class="navempty">none yet</div>';
|
|
1535
|
+
g.cond.forEach(grp=>{const open=OPEN[grp.act]!==undefined?OPEN[grp.act]:grp.dims.some(d=>d.dim===activeDim());
|
|
1536
|
+
h+=`<div class="ghdr" onclick="toggleGroup('${jsq(grp.act)}')">${open?IC.down:IC.right}`+
|
|
1537
|
+
`<span>${esc(grp.act)}</span><span class="cnt">${grp.dims.length}</span></div>`;
|
|
1538
|
+
if(open)h+=grp.dims.map(d=>railItem(d,true)).join('');});
|
|
1539
|
+
h+=`<div class="tierhdr">CANDIDATE</div>`;
|
|
1540
|
+
h+=g.candidate.map(d=>railItem(d)).join('')||'<div class="navempty">None — promoted candidates appear here.</div>';
|
|
1541
|
+
h+=`</div><div id="railfoot"><button onclick="newAxis()">${IC.plus} New dimension</button></div>`;
|
|
1542
|
+
box.innerHTML=h;}
|
|
1543
|
+
function toggleGroup(act){const g=grouped();const grp=g.cond.find(x=>x.act===act);
|
|
1544
|
+
const open=OPEN[act]!==undefined?OPEN[act]:(grp&&grp.dims.some(d=>d.dim===activeDim()));
|
|
1545
|
+
OPEN[act]=!open;renderRail();}
|
|
1546
|
+
function shortWs(){const w=STATE.workspace||'';const parts=w.split('/');return parts.slice(-2).join('/');}
|
|
1547
|
+
/* ---------- render dispatch ---------- */
|
|
1548
|
+
function render(){if(!STATE)return;renderRail();
|
|
1549
|
+
if(ROUTE.view==='home')renderHome();
|
|
1550
|
+
else if(ROUTE.view==='dim')renderDim();
|
|
1551
|
+
else if(ROUTE.view==='value')renderValue();}
|
|
1552
|
+
function head(crumbHTML,titleHTML,orient){$('#head').innerHTML=
|
|
1553
|
+
`<div class="crumb">${crumbHTML}</div><div class="titlerow">${titleHTML}</div>`+
|
|
1554
|
+
(orient?`<div class="orient">${orient}</div>`:'');}
|
|
1555
|
+
/* ---------- HOME ---------- */
|
|
1556
|
+
function renderHome(){const c=STATE.counts||{},g=grouped(),dims=dimList();
|
|
1557
|
+
head('WORKSPACE',
|
|
1558
|
+
`<span class="title-serif">Mapping workspace</span><span class="spacer"></span><span id="msg"></span>`+
|
|
1559
|
+
`<button onclick="rebuild()" title="Re-queries UMLS for every dimension and value in the workspace and rewrites umls_crosswalk.yaml">Rebuild all</button><button class="primary" onclick="newAxis()">New dimension</button>`,
|
|
1560
|
+
`Define each dimension’s <b>axis</b> as a UMLS semantic type, adjudicate its <b>values</b> to concepts, and export the crosswalk. Pick a dimension on the left to begin.`);
|
|
1561
|
+
const tot=c.total||0,m=c.mapped||0,r=c.review||0,u=c.unmapped||0;
|
|
1562
|
+
const pct=x=>tot?Math.round(100*x/tot):0;
|
|
1563
|
+
let h='';
|
|
1564
|
+
if(!dims.length){
|
|
1565
|
+
h+=`<div class="card"><h3>Empty workspace</h3><div style="font-size:13px">This mapping directory has no dimensions yet. `+
|
|
1566
|
+
`<b>New dimension</b> creates the first axis from scratch; the Semantic Network reference is built in, so no API key is needed for that.</div></div>`;
|
|
1567
|
+
$('#content').innerHTML=h;return;}
|
|
1568
|
+
h+=`<div class="card"><h3>Adjudication progress</h3>`+
|
|
1569
|
+
`<div class="bar"><div class="m" style="width:${pct(m)}%"></div><div class="r" style="width:${pct(r)}%"></div><div class="u" style="width:${100-pct(m)-pct(r)}%"></div></div>`+
|
|
1570
|
+
`<div class="legend"><span><span class="ldot" style="background:var(--ok-dot)"></span><b>${m}</b> mapped</span>`+
|
|
1571
|
+
`<span><span class="ldot" style="background:var(--rev-dot)"></span><b>${r}</b> in review</span>`+
|
|
1572
|
+
`<span><span class="ldot" style="background:var(--un-dot)"></span><b>${u}</b> unmapped</span>`+
|
|
1573
|
+
`<span><span class="ldot" style="background:var(--warn)"></span><b>${c.needs||0}</b> item${(c.needs||0)===1?'':'s'} need${(c.needs||0)===1?'s':''} review</span>`+
|
|
1574
|
+
`<span style="margin-left:auto" class="mono mini">${tot} values · ${dims.length} dimensions · ${c.curated||0} curated</span></div></div>`;
|
|
1575
|
+
h+=worklistCard();
|
|
1576
|
+
const rowHTML=d=>{const axis=d.tui?
|
|
1577
|
+
`<span class="chip tui">${esc(d.styTree||d.tui)}</span> <span>${esc(d.styName||'')}</span>`:
|
|
1578
|
+
`${IC.warn} <span style="color:var(--warn);font-weight:500">Axis type not set</span>`;
|
|
1579
|
+
const w=d.total?Math.round(100*d.mapped/d.total):0,wr=d.total?Math.round(100*d.review/d.total):0;
|
|
1580
|
+
const st=d.total===0?'<span class="chip st-unmapped">no values</span>':
|
|
1581
|
+
d.mapped===d.total?'<span class="chip st-mapped">complete</span>':
|
|
1582
|
+
(d.mapped||d.review)?'<span class="chip st-review">in progress</span>':'<span class="chip st-unmapped">not started</span>';
|
|
1583
|
+
return `<div class="hrow" onclick="gotoDim('${jsq(d.dim)}')">`+
|
|
1584
|
+
`<div class="dn"><span>${esc(d.dim)}</span>${d.activation&&d.tier==='core'?'<span class="condchip">cond. required</span>':''}</div>`+
|
|
1585
|
+
`<div style="display:flex;align-items:center;gap:6px;min-width:0;font-size:12px">${axis}</div>`+
|
|
1586
|
+
`<div><span class="minibar"><span style="width:${w}%;background:var(--ok-dot)"></span><span style="width:${wr}%;background:var(--rev-dot)"></span></span> `+
|
|
1587
|
+
`<span class="mono" style="font-size:11px;color:var(--mut)">${d.mapped}/${d.total}</span></div>`+
|
|
1588
|
+
`<div class="st">${st}</div></div>`;};
|
|
1589
|
+
h+=`<div class="card"><h3>Core dimensions<span class="hint">ordered by the model, not the alphabet</span></h3>`+
|
|
1590
|
+
g.core.map(rowHTML).join('')+`</div>`;
|
|
1591
|
+
if(g.cond.length){h+=`<div class="card"><h3>Conditional dimensions</h3>`+
|
|
1592
|
+
g.cond.map(grp=>`<div class="condline"><span class="act">${esc(grp.act)}</span>`+
|
|
1593
|
+
grp.dims.map(d=>`<a href="#" onclick="gotoDim('${jsq(d.dim)}');return false">${esc(d.dim)}</a>`+
|
|
1594
|
+
(d.total?` <span class="f">${d.mapped}/${d.total}</span>`:'')).join('<span class="mut"> · </span>')+
|
|
1595
|
+
`</div>`).join('')+`</div>`;}
|
|
1596
|
+
if(g.candidate.length){h+=`<div class="card"><h3>Candidate dimensions</h3>`+
|
|
1597
|
+
g.candidate.map(rowHTML).join('')+`</div>`;}
|
|
1598
|
+
h+=`<div class="card"><h3>Workspace</h3><div class="mono" style="font-size:11.5px;word-break:break-all">${esc(STATE.workspace||'')}</div>`+
|
|
1599
|
+
`<div class="mini" style="margin-top:5px">dimensions_inventory.yaml defines the structure (tier / order / activation live there); `+
|
|
1600
|
+
`umls_crosswalk.yaml holds the harness results; adjudications.yaml records your decisions.</div></div>`;
|
|
1601
|
+
$('#content').innerHTML=h;wireWorklist();}
|
|
1602
|
+
/* ---------- HOME: worklist card ---------- */
|
|
1603
|
+
function worklistCard(){const wl=worklist(),nc=STATE.needs||{};
|
|
1604
|
+
const chip=(code,label,n,desc)=>`<span class="fchip ${WL.need===code?'on':''} ${n?'':'zero'}" data-need="${esc(code)}" title="${esc(desc)}">${esc(label)} <b>${n}</b></span>`;
|
|
1605
|
+
return `<div class="card" id="wlcard"><h3>Worklist · ${wl.length}<span class="hint">what still needs a curator's eyes — every dimension, in dimension order</span></h3>`+
|
|
1606
|
+
`<div class="wlbar">`+chip('all','all',wl.length,'everything that still needs a curator')+
|
|
1607
|
+
(STATE.need_codes||[]).map(c=>chip(c,needLabel(c).label,nc[c]||0,needLabel(c).desc)).join('')+
|
|
1608
|
+
`<input id="wltext" placeholder="filter dimension / token…" value="${esc(WL.text)}">`+
|
|
1609
|
+
`<button class="primary" id="wlstart" title="open the first item of the current filter">Start review</button></div>`+
|
|
1610
|
+
`<div id="wllist" style="max-height:300px;overflow:auto">${wlRows()}</div></div>`;}
|
|
1611
|
+
function wlRows(){const rows=wlFiltered();
|
|
1612
|
+
if(!rows.length)return `<div class="mini" style="padding:8px 0;font-style:italic">${worklist().length?'nothing matches the filter':'nothing needs review — every value is confirmed or argued, every axis typed'}</div>`;
|
|
1613
|
+
return rows.map(e=>`<div class="wlrow" data-key="${esc(e.key)}">`+
|
|
1614
|
+
`<div class="dn"><span class="dot ${esc(e.status)}"></span><span class="mut">${esc(e.dimension)} ›</span>`+
|
|
1615
|
+
`<span class="tk">${e.kind==='axis'?'<i>axis</i>':esc(String(e.token))}</span>`+
|
|
1616
|
+
(e.kind!=='axis'&&e.kind!=='value'?`<span class="condchip">${esc(e.kind)}</span>`:'')+`</div>`+
|
|
1617
|
+
`<div class="mut" style="font-size:12px;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">`+
|
|
1618
|
+
(e.cui?esc(e.matched_name||'')+' <span class="cui">'+esc(e.cui)+'</span>':(e.kind==='axis'&&e.sty_name?esc(e.sty_name):''))+`</div>`+
|
|
1619
|
+
`<div class="st">${needChips(e)}<span class="chip st-${esc(e.status)}">${esc(e.status)}</span></div></div>`).join('');}
|
|
1620
|
+
function wireWorklist(){const card=$('#wlcard');if(!card)return;
|
|
1621
|
+
const list=$('#wllist');
|
|
1622
|
+
card.querySelectorAll('.fchip').forEach(el=>el.addEventListener('click',()=>{const n=el.dataset.need;
|
|
1623
|
+
WL.need=(n==='all'||WL.need===n)?'all':n; // single-select; clicking the active chip returns to all
|
|
1624
|
+
card.querySelectorAll('.fchip').forEach(x=>x.classList.toggle('on',x.dataset.need===WL.need));
|
|
1625
|
+
list.innerHTML=wlRows();}));
|
|
1626
|
+
const t=$('#wltext');if(t)t.addEventListener('input',()=>{WL.text=t.value;list.innerHTML=wlRows();});
|
|
1627
|
+
list.addEventListener('click',ev=>{const row=ev.target.closest('.wlrow');if(!row)return;
|
|
1628
|
+
openEntry(STATE.entries.find(x=>x.key===row.dataset.key));});
|
|
1629
|
+
$('#wlstart').addEventListener('click',()=>{const first=wlFiltered()[0];
|
|
1630
|
+
if(first)openEntry(first);else msg('nothing to review in this filter');});}
|
|
1631
|
+
/* ---------- DIMENSION view ---------- */
|
|
1632
|
+
function renderDim(){const isnew=!!ROUTE.isnew;
|
|
1633
|
+
const d=isnew?null:dimList().find(x=>x.dim===ROUTE.dim);
|
|
1634
|
+
if(!isnew&&!d){gotoHome();return;}
|
|
1635
|
+
const e=d?d.e:null;
|
|
1636
|
+
if(!AXB)AXB=e?{dimension:e.dimension,query:e.axis_query||'',note:e.axis_note||'',tui:e.sty_tui||null,
|
|
1637
|
+
isnew:!e.in_inventory,tier:e.tier||'core',activation:e.activation||'',
|
|
1638
|
+
prefs:(e.preferred_sabs||[]).join(', ')}
|
|
1639
|
+
:{dimension:'',query:'',note:'',tui:null,isnew:true,tier:'core',activation:'',prefs:''};
|
|
1640
|
+
const tierchip=`<span class="chip tier">${esc((AXB.tier||'core').toUpperCase())}</span>`;
|
|
1641
|
+
head(`<a href="#" onclick="gotoHome();return false">WORKSPACE</a> › ${esc((AXB.dimension||'NEW').toUpperCase())}`,
|
|
1642
|
+
`<span class="title-mono">${esc(AXB.dimension||'new dimension')}</span>${tierchip}`+
|
|
1643
|
+
(AXB.activation?`<span class="mini mono">${esc(AXB.activation)}</span>`:'')+
|
|
1644
|
+
(e?needChips(e):'')+
|
|
1645
|
+
`<span class="spacer"></span><span id="msg"></span>`+(e?nextBtn(e.key):'')+
|
|
1646
|
+
(AXB.isnew?'':`<button onclick="rebuild('${jsq(AXB.dimension)}')" title="Re-queries UMLS for this dimension's values only; the rest of the crosswalk is untouched">Rebuild this dimension</button>`)+
|
|
1647
|
+
`<button class="primary" onclick="axbSave()">${AXB.isnew?'Create axis':'Save axis'}</button>`,
|
|
1648
|
+
isnew?'A dimension’s axis maps it to a UMLS <b>semantic type</b>; the type’s subtree becomes the search filter for every value of the dimension.':'');
|
|
1649
|
+
const ro=AXB.isnew?'':'readonly';
|
|
1650
|
+
let h=`<div class="card"><h3>Axis</h3><div class="axgrid">`+
|
|
1651
|
+
`<div class="axtype"><div id="axsel"></div></div>`+
|
|
1652
|
+
`<div>`+
|
|
1653
|
+
(AXB.isnew?`<div class="axrow"><label>dimension</label><input id="axdim" value="${esc(AXB.dimension)}" placeholder="e.g. resolution"></div>`+
|
|
1654
|
+
`<div class="axrow"><label>tier</label><select id="axtier">`+
|
|
1655
|
+
['core','conditional','candidate'].map(t=>`<option ${AXB.tier===t?'selected':''}>${t}</option>`).join('')+`</select></div>`+
|
|
1656
|
+
`<div class="axrow"><label>activation</label><input id="axact" value="${esc(AXB.activation)}" placeholder="e.g. knowledge_domain: GENE_FUNCTION (conditional only)"></div>`:'')+
|
|
1657
|
+
`<div class="axrow"><label>query</label><input id="axq" value="${esc(AXB.query)}" placeholder="seed query, e.g. Spatial concept"><button onclick="axbRun()">Run query</button></div>`+
|
|
1658
|
+
`<div class="axrow"><label>note</label><input id="axnote" value="${esc(AXB.note)}" placeholder="what this axis means"></div>`+
|
|
1659
|
+
`<div class="axrow"><label>preferred</label><input id="axprefs" value="${esc(AXB.prefs||'')}" style="font-family:var(--mono);font-size:12px" placeholder="vocabularies for this dimension, e.g. HPO, MSH — blank inherits workspace${((STATE.prefs||{}).workspace||[]).length?' ('+esc(STATE.prefs.workspace.join(', '))+')':''}"></div>`+
|
|
1660
|
+
`<div class="mini" style="margin-left:82px">Run query surfaces the semantic <i>types</i> of matching concepts — an axis maps to a type, not a concept.</div>`+
|
|
1661
|
+
`</div></div>`+
|
|
1662
|
+
`<div id="axprev" style="margin-top:10px"></div>`+
|
|
1663
|
+
`<details style="margin-top:8px"><summary class="mini" style="cursor:pointer">Browse all 127 semantic types</summary>`+
|
|
1664
|
+
`<div class="searchbar" style="margin-top:8px"><input id="styq" placeholder="filter by name / TUI / tree…"></div>`+
|
|
1665
|
+
`<div id="styresults" class="stylist"></div></details></div>`;
|
|
1666
|
+
if(e){const vals=STATE.entries.filter(x=>x.dimension===e.dimension&&x.kind!=='axis');
|
|
1667
|
+
h+=`<div class="card"><h3>Values · ${vals.length}`+
|
|
1668
|
+
`<span class="hint">${d.mapped} mapped · ${d.review} in review · ${d.unmapped} unmapped · ${d.needs} need${d.needs===1?'s':''} review</span></h3>`+
|
|
1669
|
+
`<div style="display:flex;gap:8px;margin-bottom:6px">`+
|
|
1670
|
+
`<select id="fstatus" onchange="setFilter()" style="font-size:12px">`+
|
|
1671
|
+
`<option value="all">All</option><option value="needs">Needs attention (any)</option>`+
|
|
1672
|
+
(STATE.need_codes||[]).filter(c=>needLabel(c).scope!=='axis').map(c=>
|
|
1673
|
+
`<option value="need:${esc(c)}" title="${esc(needLabel(c).desc)}">needs: ${esc(needLabel(c).label)}</option>`).join('')+
|
|
1674
|
+
`<option value="mapped">Mapped</option>`+
|
|
1675
|
+
`<option value="unmapped">Unmapped</option><option value="curated">Curated</option><option value="auto">Auto (not curated)</option></select>`+
|
|
1676
|
+
`<input id="ftext" oninput="setFilter()" placeholder="filter token…" style="width:180px;font-size:12px">`+
|
|
1677
|
+
`</div><div id="vtbox">${valuesTable(vals)}</div></div>`;
|
|
1678
|
+
if(!vals.length)h+=`<div class="mini" style="margin:-6px 0 12px 4px">No values in the crosswalk yet — values come from the inventory/schema; <b>Rebuild</b> generates and resolves them.</div>`;}
|
|
1679
|
+
$('#content').innerHTML=h;
|
|
1680
|
+
if(e)wireNext(e.key);
|
|
1681
|
+
if(AXB.isnew){['axdim','axtier','axact'].forEach(id=>{const el=$('#'+id);if(el)el.addEventListener('input',()=>{
|
|
1682
|
+
AXB.dimension=($('#axdim')||{}).value||'';AXB.tier=($('#axtier')||{}).value||'core';AXB.activation=($('#axact')||{}).value||'';});});
|
|
1683
|
+
const t=$('#axtier');if(t)t.addEventListener('change',()=>{AXB.tier=t.value;});}
|
|
1684
|
+
['axq','axnote','axprefs'].forEach(id=>{const el=$('#'+id);if(el)el.addEventListener('input',()=>{
|
|
1685
|
+
AXB.query=($('#axq')||{}).value||'';AXB.note=($('#axnote')||{}).value||'';AXB.prefs=($('#axprefs')||{}).value||'';});});
|
|
1686
|
+
const q=$('#axq');if(q)q.addEventListener('keydown',ev=>{if(ev.key==='Enter')axbRun();});
|
|
1687
|
+
const sq=$('#styq');if(sq)sq.addEventListener('input',axbPicker);
|
|
1688
|
+
attachSabTypeahead($('#axprefs'));
|
|
1689
|
+
const fs=$('#fstatus');if(fs)fs.value=FILTER.status;const ft=$('#ftext');if(ft)ft.value=FILTER.text;
|
|
1690
|
+
enrichSlots($('#vtbox'));
|
|
1691
|
+
loadSemTypes().then(()=>{axbPicker();axbRenderSel();
|
|
1692
|
+
if(ROUTE.view==='dim'&&!ROUTE.isnew&&$('#vtbox'))setFilter();});}
|
|
1693
|
+
function setFilter(){FILTER.dim=ROUTE.dim;FILTER.status=($('#fstatus')||{}).value||'all';FILTER.text=($('#ftext')||{}).value||'';
|
|
1694
|
+
const e=dimList().find(x=>x.dim===ROUTE.dim);if(!e)return;
|
|
1695
|
+
const vals=STATE.entries.filter(x=>x.dimension===ROUTE.dim&&x.kind!=='axis');
|
|
1696
|
+
$('#vtbox').innerHTML=valuesTable(vals);enrichSlots($('#vtbox'));}
|
|
1697
|
+
function passFilter(e){const f=FILTER.status,t=FILTER.text.toLowerCase();
|
|
1698
|
+
if(t&&!(String(e.token||'')+' '+e.dimension).toLowerCase().includes(t))return false;
|
|
1699
|
+
if(f==='all')return true;if(f==='mapped')return e.status==='mapped';if(f==='unmapped')return e.status==='unmapped';
|
|
1700
|
+
if(f==='curated')return e.curated;if(f==='auto')return e.status==='mapped'&&!e.curated;
|
|
1701
|
+
if(f==='needs')return (e.needs||[]).length>0; // server-defined
|
|
1702
|
+
if(f.startsWith('need:'))return (e.needs||[]).includes(f.slice(5));return true;}
|
|
1703
|
+
function valuesTable(vals){const rows=vals.filter(passFilter).map(e=>{
|
|
1704
|
+
const map=e.cui?`<span>${esc(e.matched_name||'')}</span> <span class="cui">${esc(e.cui)}</span> `+
|
|
1705
|
+
`<span class="sabslot" data-cui="${esc(e.cui)}" data-root="${esc(e.root_source||'')}" data-prefs="${esc((e.sab_prefs||[]).join(','))}">`+
|
|
1706
|
+
(e.root_source?`<span class="sab">${esc(e.root_source)}</span>`:'')+`</span>`:'<span class="mut">—</span>';
|
|
1707
|
+
return `<tr class="vrow" onclick="gotoValue('${jsq(e.key)}')">`+
|
|
1708
|
+
`<td style="width:16px"><span class="dot ${esc(e.status)}" style="display:inline-block"></span></td>`+
|
|
1709
|
+
`<td class="tk">${esc(e.token)}${e.kind!=='value'?` <span class="condchip">${esc(e.kind)}</span>`:''}</td>`+
|
|
1710
|
+
`<td class="mut">${esc(e.query)}</td><td>${map}</td><td>${styCell(e)}</td>`+
|
|
1711
|
+
`<td style="text-align:right;white-space:nowrap">${needChips(e)}${(e.needs||[]).length?' ':''}<span class="chip st-${esc(e.status)}">${esc(e.status)}</span>`+
|
|
1712
|
+
(e.curated?' <span class="badge">curated</span>':'')+`</td></tr>`;}).join('');
|
|
1713
|
+
return rows?`<table class="vt"><tr><th></th><th>TOKEN</th><th>QUERY</th><th>MAPPING</th><th>STY</th><th style="text-align:right">STATUS</th></tr>${rows}</table>`
|
|
1714
|
+
:'<div class="mini" style="padding:8px 0">no values match the filter</div>';}
|
|
1715
|
+
// the mapped concept's most specific semantic type as STN + name (no TUI),
|
|
1716
|
+
// clickable: its place in the network relative to the dimension's axis type
|
|
1717
|
+
function styCell(e){if(!e.cui||!(e.semantic_types||[]).length)return '<span class="mut">—</span>';
|
|
1718
|
+
const st=mostSpecificTui(e.semantic_types);
|
|
1719
|
+
if(!st)return `<span class="mini">${esc(e.semantic_types.join(', '))}</span>`;
|
|
1720
|
+
return `<a href="#" class="stnlink" title="${esc(st.name)} — click to see its place relative to the axis type" `+
|
|
1721
|
+
`onclick="event.stopPropagation();showCompare('${st.tui}','${jsq(e.dim_sty_tui||'')}');return false">${esc(st.tree)}</a> `+
|
|
1722
|
+
`<span style="font-size:11.5px">${esc(st.name)}</span>`;}
|
|
1723
|
+
/* ---------- axis builder internals ---------- */
|
|
1724
|
+
async function axbRun(){const q=(($('#axq')||{}).value||'').trim();const box=$('#axprev');if(!box)return;
|
|
1725
|
+
if(!q){box.innerHTML='<span class="mini">enter a query first</span>';return;}
|
|
1726
|
+
box.innerHTML='<span class="mini">searching UMLS…</span>';
|
|
1727
|
+
const j=await (await fetch('/api/search?string='+encodeURIComponent(q))).json();
|
|
1728
|
+
if(j.error){box.innerHTML='<span style="color:var(--danger)">'+esc(j.error)+'</span>';return;}
|
|
1729
|
+
const res=(j.results||[]).slice(0,60);
|
|
1730
|
+
if(!res.length){box.innerHTML='<span class="mini">no results — offline (no key), or no match. You can still pick a type below.</span>';return;}
|
|
1731
|
+
const norm=s=>(s||'').toLowerCase(),byT={};
|
|
1732
|
+
res.forEach(c=>(c.semantic_types||[]).forEach(nm=>{
|
|
1733
|
+
const t=(SEMTYPES||[]).find(x=>norm(x.name)===norm(nm));if(!t)return;
|
|
1734
|
+
const g=byT[t.tui]||(byT[t.tui]={tui:t.tui,name:t.name,tree:t.tree,n:0,ex:[]});
|
|
1735
|
+
g.n++;if(g.ex.length<5)g.ex.push(c.name);}));
|
|
1736
|
+
const types=Object.values(byT).sort((a,b)=>b.n-a.n);
|
|
1737
|
+
if(!types.length){box.innerHTML='<span class="mini">'+res.length+' concepts matched but none carried a recognized semantic type. Pick a type below.</span>';return;}
|
|
1738
|
+
const rows=types.map(t=>`<div class="styrow ${t.tui===AXB.tui?'acc':''}"><span class="stytree">${esc(t.tree)}</span> ${esc(t.name)} <span class="mut">×${t.n}</span>`+
|
|
1739
|
+
`<button class="ok mini" onclick="axbSet('${t.tui}')">${t.tui===AXB.tui?'✓ axis type':'use as axis type'}</button>`+
|
|
1740
|
+
`<button class="mini" onclick="stnToggle(this,'${t.tui}')">tree</button>`+
|
|
1741
|
+
`<div class="mini">e.g. ${esc(t.ex.join(', '))}</div></div>`).join('');
|
|
1742
|
+
box.innerHTML=`<div class="mini" style="margin-bottom:5px">${types.length} semantic type${types.length===1?'':'s'} across ${res.length} matching concepts — click one to set the axis type:</div><div class="stylist">${rows}</div>`;}
|
|
1743
|
+
function axbRenderSel(){const box=$('#axsel');if(!box)return;const t=(SEMTYPES||[]).find(x=>x.tui===AXB.tui);
|
|
1744
|
+
if(!t){box.innerHTML=`<div style="display:flex;align-items:center;gap:7px">${IC.warn}<span style="color:var(--warn);font-weight:500">Axis type not set</span></div>`+
|
|
1745
|
+
`<div class="mini" style="margin-top:5px">Run the query or browse the types to choose one — value searches are unconstrained until then.</div>`;return;}
|
|
1746
|
+
const sub=(SEMTYPES||[]).filter(x=>stnUnder(x.tree,t.tree));
|
|
1747
|
+
box.innerHTML=`<span class="tnm">${esc(t.name)}</span> <span class="chip tui">${esc(t.tree)}</span>`+
|
|
1748
|
+
`<div class="mini" style="margin-top:4px">value searches constrained to <b>${sub.length}</b> semantic type${sub.length===1?'':'s'} (the axis subtree)</div>`+
|
|
1749
|
+
(t.definition?`<div class="def show" style="margin-top:7px">${esc(t.definition)}</div>`:'')+
|
|
1750
|
+
`<div class="subtree" style="margin-top:8px">${stnPlaceHTML(t.tui)}</div>`;}
|
|
1751
|
+
function axbPicker(){const box=$('#styresults');if(!box)return;const q=(($('#styq')||{}).value||'').toLowerCase();
|
|
1752
|
+
box.innerHTML=(SEMTYPES||[]).filter(t=>!q||(t.name+' '+t.tui+' '+t.tree).toLowerCase().includes(q)).slice(0,80).map(t=>
|
|
1753
|
+
`<div class="styrow ${t.tui===AXB.tui?'acc':''}"><span class="stytree">${esc(t.tree)}</span> ${esc(t.name)}`+
|
|
1754
|
+
`<button class="ok mini" onclick="axbSet('${t.tui}')">${t.tui===AXB.tui?'✓ chosen':'choose'}</button>`+
|
|
1755
|
+
`<button class="mini" onclick="stnToggle(this,'${t.tui}')">tree</button>`+
|
|
1756
|
+
(t.definition?`<div class="def show">${esc(t.definition)}</div>`:'')+`</div>`).join('');}
|
|
1757
|
+
function axbSet(tui){AXB.tui=tui;axbRenderSel();axbPicker();}
|
|
1758
|
+
// tree-number topology: levels below a letter root have no dot (A -> A1 -> A1.1)
|
|
1759
|
+
function stnUnder(tree,p){return !!tree&&(tree===p||(tree.startsWith(p)&&(p.length===1||tree[p.length]==='.')));}
|
|
1760
|
+
function stnParents(tree){const ps=[];if(!tree)return ps;const segs=tree.split('.');
|
|
1761
|
+
if(segs[0].length>1)ps.push(segs[0][0]);
|
|
1762
|
+
for(let i=1;i<segs.length;i++)ps.push(segs.slice(0,i).join('.'));return ps;}
|
|
1763
|
+
function stnParentOf(tree){const ps=stnParents(tree);return ps.length?ps[ps.length-1]:null;}
|
|
1764
|
+
function stnPlaceHTML(tui,noclick){const t=(SEMTYPES||[]).find(x=>x.tui===tui);if(!t)return '<i>type not found</i>';
|
|
1765
|
+
const nm=n=>noclick?esc(n.name):`<a href="#" onclick="axbSet('${n.tui}');return false">${esc(n.name)}</a>`;
|
|
1766
|
+
const node=(n,inner)=>`<div class="stn-node${n.tui===tui?' hl':''}"><span class="stn-tree">${esc(n.tree)}</span> `+
|
|
1767
|
+
`${nm(n)}`+
|
|
1768
|
+
(inner?`<div class="stn-kids">${inner}</div>`:'')+`</div>`;
|
|
1769
|
+
const kidsOf=n=>(SEMTYPES||[]).filter(x=>stnParentOf(x.tree)===n.tree)
|
|
1770
|
+
.sort((a,b)=>a.tree<b.tree?-1:1);
|
|
1771
|
+
const sub=n=>node(n,kidsOf(n).map(sub).join(''));
|
|
1772
|
+
const chain=stnParents(t.tree).map(pt=>(SEMTYPES||[]).find(x=>x.tree===pt)).filter(Boolean);
|
|
1773
|
+
const parent=chain.length?chain[chain.length-1]:null;
|
|
1774
|
+
let core;
|
|
1775
|
+
if(parent){core=node(parent,kidsOf(parent).map(s=>s.tui===t.tui?sub(t):node(s,'')).join(''));
|
|
1776
|
+
for(let i=chain.length-2;i>=0;i--)core=node(chain[i],core);}
|
|
1777
|
+
else core=sub(t);
|
|
1778
|
+
return `<div class="stn-tree">${core}</div>`;}
|
|
1779
|
+
function stnToggle(btn,tui){const row=btn.closest('.styrow')||btn.parentElement;
|
|
1780
|
+
const box=row.querySelector(':scope > .axtree');
|
|
1781
|
+
if(box){box.remove();return;}
|
|
1782
|
+
const d=document.createElement('div');d.className='axtree';d.innerHTML=stnPlaceHTML(tui);row.appendChild(d);}
|
|
1783
|
+
async function axbSave(){if(!AXB)return;
|
|
1784
|
+
AXB.query=($('#axq')||{}).value??AXB.query;AXB.note=($('#axnote')||{}).value??AXB.note;
|
|
1785
|
+
if(AXB.isnew){AXB.dimension=($('#axdim')||{}).value||AXB.dimension;
|
|
1786
|
+
AXB.tier=($('#axtier')||{}).value||AXB.tier;AXB.activation=($('#axact')||{}).value??AXB.activation;}
|
|
1787
|
+
if(!AXB.dimension.trim()){msg('dimension name required');return;}
|
|
1788
|
+
msg('saving axis…');
|
|
1789
|
+
AXB.prefs=($('#axprefs')||{}).value??AXB.prefs;
|
|
1790
|
+
const body={dimension:AXB.dimension,semantic_type:AXB.tui||'',query:AXB.query,note:AXB.note,
|
|
1791
|
+
preferred_sabs:AXB.prefs||''};
|
|
1792
|
+
if(AXB.isnew){body.tier=AXB.tier;if(AXB.activation)body.activation=AXB.activation;}
|
|
1793
|
+
const j=await (await fetch('/api/axis',{method:'POST',headers:{'Content-Type':'application/json'},
|
|
1794
|
+
body:JSON.stringify(body)})).json();
|
|
1795
|
+
if(j.error){msg('error: '+j.error);return;}
|
|
1796
|
+
const dim=AXB.dimension.trim();
|
|
1797
|
+
ROUTE={view:'dim',dim};AXB=null; // route first: loadState() renders with it
|
|
1798
|
+
await loadState();
|
|
1799
|
+
msg('saved axis '+dim+' — Rebuild to (re)generate value candidates');}
|
|
1800
|
+
/* ---------- VALUE view ---------- */
|
|
1801
|
+
function renderValue(){SEL=STATE.entries.find(x=>x.key===ROUTE.key)||SEL;
|
|
1802
|
+
if(!SEL){gotoHome();return;}const e=SEL;
|
|
1803
|
+
head(`<a href="#" onclick="gotoHome();return false">WORKSPACE</a> › `+
|
|
1804
|
+
`<a href="#" onclick="gotoDim('${jsq(e.dimension)}');return false">${esc(e.dimension.toUpperCase())}</a> › ${esc(String(e.token))}`,
|
|
1805
|
+
`<span class="title-mono">${esc(String(e.token))}</span>`+
|
|
1806
|
+
`<span class="mini">value of <span class="mono">${esc(e.dimension)}</span>${e.kind!=='value'?' · '+esc(e.kind):''}</span>`+
|
|
1807
|
+
`<span class="chip st-${esc(e.status)}">${esc(e.status)}</span>`+(e.curated?' <span class="badge">curated</span>':'')+needChips(e)+
|
|
1808
|
+
`<span class="spacer"></span><span id="msg"></span>${nextBtn(e.key)}<button onclick="rebuild('${jsq(e.dimension)}')" title="Re-queries UMLS for this dimension's values only">Rebuild this dimension</button>`,
|
|
1809
|
+
e.dim_sty_tui?`axis semantic type: <b>${esc(e.dim_sty_name||e.dim_sty_tui)}</b> <span class="mono" style="font-size:11px">${esc(e.dim_sty_tree||'')}</span> — value search is constrained to its subtree unless widened below`
|
|
1810
|
+
:`the ${esc(e.dimension)} axis is untyped — value search runs unconstrained`);
|
|
1811
|
+
const slot=e.cui?` <span class="sabslot" data-cui="${esc(e.cui)}" data-root="${esc(e.root_source||'')}" data-prefs="${esc((e.sab_prefs||[]).join(','))}">`+
|
|
1812
|
+
(e.root_source?`<span class="sab">${esc(e.root_source)}</span>`:'')+`</span>`:'';
|
|
1813
|
+
const pend=e.decision==='accept'&&(e.status!=='mapped'||e.decision_cui!==e.cui);
|
|
1814
|
+
const cur=pend?`<span class="now">accepted → <b id="decname"><span class="cui">${esc(e.decision_cui)}</span></b>`+
|
|
1815
|
+
` <span class="chip">${esc(e.relation||'exact')}</span> <span class="mini">— Rebuild folds it into the crosswalk</span></span>`
|
|
1816
|
+
:e.status==='mapped'?`<span class="now">mapped → <b>${esc(e.matched_name)}</b> <span class="cui">${esc(e.cui)}</span>${slot}`+
|
|
1817
|
+
(e.curated?` <span class="badge">${e.fetched?'curated · fetched':'curated'}</span>`
|
|
1818
|
+
:e.decision==='accept'?' <span class="badge">confirmed</span>':' <span class="mini">(auto)</span>')+`</span>`:
|
|
1819
|
+
e.status==='unmapped'?`<span class="now">unmapped${e.curated?' <span class="badge">curator</span>':''}</span>`:
|
|
1820
|
+
`<span class="now">review</span>`;
|
|
1821
|
+
const filt=e.dim_sty_tui?`<span class="chip" style="cursor:pointer" onclick="showAxisTree()" `+
|
|
1822
|
+
`title="click to see this type's place in the Semantic Network (ancestors, siblings, descendants)">`+
|
|
1823
|
+
`Semantic type: <b> ${esc(e.dim_sty_name||e.dim_sty_tui)}</b> · <span class="mono">${esc(e.dim_sty_tree||e.dim_sty_tui)}</span> + subtree ▾</span>`
|
|
1824
|
+
:`<span class="chip warnc">${IC.warn} axis untyped — search unconstrained</span>`;
|
|
1825
|
+
const relSel=RELATIONS.map(r=>`<option value="${r}"${(e.decision==='accept'?e.relation||'exact':'exact')===r?' selected':''}>${r}</option>`).join('');
|
|
1826
|
+
// a considered verdict without its argument (server code no-rationale)
|
|
1827
|
+
const norat=(e.needs||[]).includes('no-rationale')?`<div class="notice">${IC.warn}<span>${esc(needLabel('no-rationale').desc)} — open <b>“No faithful concept”</b> to add one</span></div>`:'';
|
|
1828
|
+
$('#content').innerHTML=`
|
|
1829
|
+
<div class="card"><h3>GEM meaning</h3><div style="font-size:13.5px">${esc(e.meaning)||'<span class="mini">(no gloss)</span>'}</div>
|
|
1830
|
+
<div class="mini" style="margin-top:4px">query used: <b>${esc(e.query)}</b></div></div>
|
|
1831
|
+
<div class="card"><h3>Decision</h3>${cur}${e.error?` <span style="color:var(--danger)">${esc(e.error)}</span>`:''}
|
|
1832
|
+
${e.relation?` <span class="chip" title="how the recorded decision relates the concept to the GEM token">relation: <b>${esc(e.relation)}</b></span>`:''}
|
|
1833
|
+
${norat}
|
|
1834
|
+
<div class="curbar">
|
|
1835
|
+
${e.status==='mapped'&&e.cui&&!e.decision?`<button class="ok" onclick="decide('accept','${jsq(e.cui)}',{relation:(($('#arel')||{}).value||'exact')})" title="record the harness mapping as your accepted decision (with the 'accept as' relation)">Confirm mapping ✓</button>`:''}
|
|
1836
|
+
<span class="mini" title="how an accepted concept relates to the GEM token — sent with the next accept">accept as</span>
|
|
1837
|
+
<select id="arel" class="mini" title="how the accepted concept relates to the GEM token">${relSel}</select>
|
|
1838
|
+
<button class="warn" id="btn-unmapped">No faithful concept</button>
|
|
1839
|
+
<button onclick="decide('clear')">Clear decision</button>
|
|
1840
|
+
${e.note?`<span class="mini">note: ${esc(e.note)}</span>`:''}
|
|
1841
|
+
</div>
|
|
1842
|
+
<textarea id="note" placeholder="optional note / rationale">${esc(e.note||'')}</textarea>
|
|
1843
|
+
${rationaleHTML(e)}
|
|
1844
|
+
</div>
|
|
1845
|
+
<div class="card"><h3>Query candidates · ${e.candidates.length}</h3>${e.candidates.map(c=>candHTML(c,null,'query')).join('')||'<span class="mini">none returned by the harness query</span>'}</div>
|
|
1846
|
+
<div class="card"><h3>Search the Metathesaurus <span style="margin-left:6px">${filt}</span>`+
|
|
1847
|
+
`<span class="hint">preferred: ${(e.sab_prefs||[]).length?esc(e.sab_prefs.join(' › ')):'none — set in ⚙ Settings'} · search: ${esc(STATE.search_backend||'UTS')}${(STATE.search_backend||'UTS')==='UTS'?' (local index not active — see ⚙)':''}</span></h3>
|
|
1848
|
+
<div class="searchbar"><input id="sq" placeholder="concept term…" value="${esc(e.token?String(e.token).replace(/_/g,' ').toLowerCase():e.query)}">
|
|
1849
|
+
<select id="sscope" title="widen the search beyond the axis subtree"${e.dim_sty_tui?'':' disabled'}>
|
|
1850
|
+
<option value="axis"${e.dim_sty_tui?'':' disabled'}>within axis subtree</option>
|
|
1851
|
+
<option value="all"${e.dim_sty_tui?'':' selected'}>all semantic types</option></select>
|
|
1852
|
+
<select id="smatch" title="how the term is matched">
|
|
1853
|
+
<option value="words">match: words</option><option value="exact">match: exact</option>
|
|
1854
|
+
<option value="normalizedWords">match: normalized</option><option value="partial">match: partial (any word)</option></select>
|
|
1855
|
+
<select id="ssab"><option value="">all sources</option><option>MSH</option><option>NCI</option><option>SNOMEDCT_US</option><option>GO</option><option>HPO</option></select>
|
|
1856
|
+
<button class="primary" onclick="runSearch()">Search</button>
|
|
1857
|
+
${e.dim_sty_filter?`<button class="mini" onclick="browseAxis()" title="list every concept of the axis subtree (local index); the search box narrows the list as you type">browse axis</button>`:''}
|
|
1858
|
+
<button class="mini" onclick="expandTerms()" title="ontology-aware expansion: each query word is replaced by its is_a children/parents (organism → animal, …) and the variants are re-searched">expand terms</button></div>
|
|
1859
|
+
<div id="sresults"></div></div>`;
|
|
1860
|
+
$('#sq').addEventListener('keydown',ev=>{if(ev.key==='Enter')runSearch();});
|
|
1861
|
+
$('#btn-unmapped').addEventListener('click',openUnmapped);
|
|
1862
|
+
wireNext(e.key);
|
|
1863
|
+
// changing "accept as" on an existing acceptance re-records it live
|
|
1864
|
+
const ar=$('#arel');
|
|
1865
|
+
if(ar)ar.addEventListener('change',()=>{
|
|
1866
|
+
if(SEL&&SEL.decision==='accept'&&SEL.decision_cui){
|
|
1867
|
+
decide('accept',SEL.decision_cui,{relation:ar.value});
|
|
1868
|
+
msg('relation → '+ar.value);}
|
|
1869
|
+
else if(SEL&&SEL.status==='mapped'&&SEL.cui&&!SEL.decision){
|
|
1870
|
+
// no decision yet: changing "accept as" on a harness mapping accepts it at that relation
|
|
1871
|
+
decide('accept',SEL.cui,{relation:ar.value});
|
|
1872
|
+
msg('accepted '+SEL.cui+' as '+ar.value);}});
|
|
1873
|
+
enrichSabs($('#content'));enrichSlots($('#content'));
|
|
1874
|
+
// resolve the accepted CUI's name for the pending-decision line
|
|
1875
|
+
if(pend&&e.decision_cui){(async()=>{try{
|
|
1876
|
+
const j=await (await fetch('/api/concept?cui='+encodeURIComponent(e.decision_cui))).json();
|
|
1877
|
+
const el=$('#decname');
|
|
1878
|
+
if(el&&j&&j.evidence&&j.evidence.name)el.innerHTML=esc(j.evidence.name)+' <span class="cui">'+esc(e.decision_cui)+'</span>';
|
|
1879
|
+
}catch(err){}})();}
|
|
1880
|
+
// the candidates' STN links need the Semantic Network; if it arrived after
|
|
1881
|
+
// this render, paint once more (guarded to the same route)
|
|
1882
|
+
if(!SEMTYPES)loadSemTypes().then(()=>{if(ROUTE.view==='value'&&ROUTE.key===e.key)renderValue();});}
|
|
1883
|
+
// The recorded argument for a decision: relation chip (above), the rejected
|
|
1884
|
+
// candidates with their failing criterion, and the protocol actually followed.
|
|
1885
|
+
function rationaleHTML(e){const rej=e.rejected||[],p=e.protocol||{};
|
|
1886
|
+
if(!rej.length&&!Object.keys(p).length)return '';
|
|
1887
|
+
const rows=rej.map(r=>`<tr><td>${esc(r.name||'')}</td><td class="cui">${esc(r.cui)}</td><td>${esc(r.sab||'')}</td>`+
|
|
1888
|
+
`<td><b title="${esc(CRITERIA[r.fails]||'')}">${esc(r.fails)}</b></td><td>${esc(r.why||'')}</td></tr>`).join('');
|
|
1889
|
+
const tbl=rej.length?`<table class="et" style="margin-top:6px"><tr><th>rejected candidate</th><th>CUI</th><th>SAB</th><th title="criterion: A denotation · B granularity · C set membership · D domain sense">fails</th><th>why</th></tr>${rows}</table>`:'';
|
|
1890
|
+
return `<div class="evsec"><div class="evh">Recorded rationale</div>${tbl}${protocolLine(p)}</div>`;}
|
|
1891
|
+
function protocolLine(p){if(!p||!Object.keys(p).length)return '';
|
|
1892
|
+
const part=[];
|
|
1893
|
+
if((p.queries||[]).length)part.push('queries '+p.queries.map(q=>'“'+esc(q)+'”').join(', '));
|
|
1894
|
+
if((p.scopes||[]).length)part.push('scope '+esc(p.scopes.join(', ')));
|
|
1895
|
+
if((p.match||[]).length)part.push('match '+esc(p.match.join(', ')));
|
|
1896
|
+
if((p.sabs||[]).length)part.push('sabs '+esc(p.sabs.join(', ')));
|
|
1897
|
+
if(p.umls)part.push(esc(p.umls));
|
|
1898
|
+
return `<div class="mini mono" style="margin-top:5px">protocol: ${part.join(' · ')}</div>`;}
|
|
1899
|
+
// remember every candidate card rendered for the selected value (query
|
|
1900
|
+
// candidates and search hits alike) so the unmapped argument can list them
|
|
1901
|
+
function noteSeen(c,origin){if(!SEL||!c||!c.cui)return;
|
|
1902
|
+
const m=SEEN[SEL.key]||(SEEN[SEL.key]={});
|
|
1903
|
+
if(!m[c.cui])m[c.cui]={cui:c.cui,name:c.name||'',root_source:c.src||c.root_source||'',
|
|
1904
|
+
semantic_types:(c.sty||c.semantic_types||[]).slice(),origin:origin||'search'};
|
|
1905
|
+
else if(c.name&&!m[c.cui].name)m[c.cui].name=c.name;}
|
|
1906
|
+
// auto-filled protocol for the selected value: the harness query (its scope
|
|
1907
|
+
// is the axis when the dimension is typed) plus every search run in-session
|
|
1908
|
+
function buildProtocol(){const log=SEARCHLOG[SEL.key]||[];
|
|
1909
|
+
const uniq=a=>[...new Set(a.filter(Boolean))];
|
|
1910
|
+
const hq=SEL.query?[SEL.query]:[],hs=SEL.query?[SEL.dim_sty_tui?'axis':'all']:[];
|
|
1911
|
+
const hm=['words','exact','normalizedWords','normalizedString','partial'].includes(SEL.search_type)?[SEL.search_type]:[];
|
|
1912
|
+
return {queries:uniq(hq.concat(log.map(l=>l.q))),scopes:uniq(hs.concat(log.map(l=>l.scope))),
|
|
1913
|
+
match:uniq(hm.concat(log.map(l=>l.match))),sabs:uniq((SEL.sab_pref?[SEL.sab_pref]:[]).concat(log.map(l=>l.sab))),
|
|
1914
|
+
umls:'UTS current, queried '+new Date().toISOString().slice(0,10)};}
|
|
1915
|
+
// "No faithful concept": argue it. Every candidate seen for this value gets a
|
|
1916
|
+
// failing criterion (A-D) and a one-line why; the protocol is auto-filled.
|
|
1917
|
+
function openUnmapped(){if(!SEL)return;const e=SEL;
|
|
1918
|
+
const seen=Object.values(SEEN[e.key]||{});
|
|
1919
|
+
const prev={};(e.rejected||[]).forEach(r=>{prev[r.cui]=r;
|
|
1920
|
+
if(!seen.some(s=>s.cui===r.cui))seen.push({cui:r.cui,name:r.name||'',root_source:r.sab||'',semantic_types:[],origin:'recorded'});});
|
|
1921
|
+
const rank={query:0,search:1,recorded:2};
|
|
1922
|
+
seen.sort((a,b)=>(rank[a.origin]??9)-(rank[b.origin]??9));
|
|
1923
|
+
const list=seen.slice(0,50),more=seen.length-list.length; // server cap: RATIONALE_MAXITEMS
|
|
1924
|
+
const opts=k=>`<option value="">—</option>`+Object.keys(CRITERIA).map(c=>`<option value="${c}" title="${esc(CRITERIA[c])}"${k===c?' selected':''}>${c}</option>`).join('');
|
|
1925
|
+
const rows=list.map(c=>{const pr=prev[c.cui]||{};
|
|
1926
|
+
return `<tr data-cui="${esc(c.cui)}" data-name="${esc(c.name)}" data-sab="${esc(c.root_source||'')}">`+
|
|
1927
|
+
`<td>${esc(c.name)}<div class="mini">${esc((c.semantic_types||[]).join(', '))}${c.origin==='query'?' · harness query':c.origin==='recorded'?' · recorded earlier':''}</div></td>`+
|
|
1928
|
+
`<td class="cui">${esc(c.cui)}</td><td>${esc(c.root_source||'')}</td>`+
|
|
1929
|
+
`<td><select class="rj-fails" title="A · ${esc(CRITERIA.A)} B · ${esc(CRITERIA.B)} C · ${esc(CRITERIA.C)} D · ${esc(CRITERIA.D)}">${opts(pr.fails)}</select></td>`+
|
|
1930
|
+
`<td><input class="rj-why" maxlength="300" placeholder="one line" value="${esc(pr.why||'')}" style="width:100%"></td></tr>`;}).join('');
|
|
1931
|
+
const legend=Object.keys(CRITERIA).map(k=>`<div><b>${k}</b> ${esc(CRITERIA[k])}</div>`).join('');
|
|
1932
|
+
const body=`<div class="mini" style="margin-bottom:8px">Record <b>${esc(String(e.token))}</b> as having no faithful UMLS concept. `+
|
|
1933
|
+
`Say why each candidate you saw fails — the criterion it fails and one line of why.</div>`+
|
|
1934
|
+
(list.length?`<div style="max-height:46vh;overflow:auto"><table class="et"><tr><th>candidate</th><th>CUI</th><th>SAB</th><th>fails</th><th>why</th></tr>${rows}</table></div>`+
|
|
1935
|
+
(more>0?`<div class="mini">+${more} more candidates seen, not listed</div>`:'')
|
|
1936
|
+
:`<div class="mini" style="font-style:italic">No candidates were seen for this value — the harness query returned none and no search has been run here. The finding is recorded with an empty rejected list.</div>`)+
|
|
1937
|
+
`<details style="margin-top:6px"><summary class="mini" style="cursor:pointer">criteria</summary><div class="mini" style="margin:4px 0 0 8px">${legend}</div></details>`+
|
|
1938
|
+
`<div class="evsec"><div class="evh">Protocol (auto-filled)</div>${protocolLine(buildProtocol())||'<span class="mini">nothing searched yet</span>'}</div>`+
|
|
1939
|
+
`<div class="evsec"><div class="evh">Note</div><textarea id="unote" placeholder="optional note / rationale">${esc(($('#note')||{}).value||e.note||'')}</textarea></div>`+
|
|
1940
|
+
`<div style="display:flex;align-items:center;gap:10px;margin-top:10px"><button class="warn" id="urec">Record: no faithful concept</button><span id="umsg" class="mini" style="color:var(--danger)"></span></div>`;
|
|
1941
|
+
showModal('No faithful concept — '+esc(String(e.token)),body);
|
|
1942
|
+
$('#urec').addEventListener('click',recordUnmapped);}
|
|
1943
|
+
async function recordUnmapped(){const rows=[...document.querySelectorAll('#modal tr[data-cui]')];
|
|
1944
|
+
const rejected=[];
|
|
1945
|
+
rows.forEach(tr=>{const f=tr.querySelector('.rj-fails').value;if(!f)return;
|
|
1946
|
+
rejected.push({cui:tr.dataset.cui,name:tr.dataset.name,sab:tr.dataset.sab,fails:f,why:tr.querySelector('.rj-why').value.trim()});});
|
|
1947
|
+
if(rows.length&&!rejected.length){$('#umsg').textContent='give at least one candidate a failing criterion (A–D) — or clear the list by accepting one';return;}
|
|
1948
|
+
const note=($('#unote')||{}).value||'';
|
|
1949
|
+
const ok=await decide('unmapped',null,{relation:'none',rejected,protocol:buildProtocol(),note});
|
|
1950
|
+
if(ok)closeModal();else $('#umsg').textContent=($('#msg')||{}).textContent||'not saved';}
|
|
1951
|
+
function candHTML(c,mark,origin){const acc=SEL&&SEL.decision_cui===c.cui;noteSeen(c,origin);
|
|
1952
|
+
const badge=mark==='in'?' <span class="badge">in axis branch</span>'
|
|
1953
|
+
:mark==='out'?' <span class="badge bad">outside axis</span>':'';
|
|
1954
|
+
const st=mostSpecificTui(c.sty||c.semantic_types);
|
|
1955
|
+
const stn=st?` · <a href="#" class="stnlink" onclick="showCompare('${st.tui}');return false" `+
|
|
1956
|
+
`title="${esc(st.name)} — click to see its place relative to the axis type">${esc(st.tree)}</a>`:'';
|
|
1957
|
+
return `<div class="cand ${acc?'acc':''}" data-cui="${esc(c.cui)}"><div class="row"><span class="n">${esc(c.name)} <span class="cui">${esc(c.cui)}</span></span>`+
|
|
1958
|
+
`<button class="mini" onclick="loadDef('${c.cui}',this)">evidence</button>`+
|
|
1959
|
+
`<button class="mini" onclick="descendFrom(this,'${c.cui}')" title="walk this concept's is_a descendants across its vocabularies, ranked against the search text — for when the direct search misses a more specific match">↓ desc</button>`+
|
|
1960
|
+
`<button class="ok" onclick="decide('accept','${c.cui}',{relation:(($('#arel')||{}).value||'exact')})">${acc?'✓ accepted':'accept'}</button></div>`+
|
|
1961
|
+
`<div class="sty">${esc((c.sty||c.semantic_types||[]).join(', '))}${c.src||c.root_source?` · <span title="root source of the concept's preferred name (MTH = the Metathesaurus itself) — full vocabulary membership follows">${esc(c.src||c.root_source)}</span>`:''}${stn}${badge}</div>`+
|
|
1962
|
+
`<div class="def"></div></div>`;}
|
|
1963
|
+
let _semReq=null;
|
|
1964
|
+
async function loadSemTypes(){if(SEMTYPES)return SEMTYPES;
|
|
1965
|
+
if(!_semReq)_semReq=fetch('/api/semantictypes').then(r=>r.json()).then(j=>{SEMTYPES=j.types;return SEMTYPES;});
|
|
1966
|
+
return _semReq;}
|
|
1967
|
+
async function loadDef(cui,btn){const box=btn.closest('.cand').querySelector('.def');
|
|
1968
|
+
if(box.classList.contains('show')){box.classList.remove('show');return;}
|
|
1969
|
+
box.innerHTML='loading concept evidence…';box.classList.add('show');
|
|
1970
|
+
const axis=SEL&&SEL.dim_sty_tui?('&axis='+SEL.dim_sty_tui):'';
|
|
1971
|
+
const j=await (await fetch('/api/concept?cui='+cui+axis)).json();
|
|
1972
|
+
await loadSemTypes();
|
|
1973
|
+
renderInfo(box,cui,j.evidence||{},j.definitions||[]);}
|
|
1974
|
+
function renderInfo(box,cui,e,defs){
|
|
1975
|
+
if(e.error){box.innerHTML='<i>'+esc(e.error)+'</i>';return;}
|
|
1976
|
+
const spec=(e.sty_path&&e.sty_path[0])||{},axis=e.axis_sty;
|
|
1977
|
+
const pathRows=(e.sty_path||[]).map((p,i)=>`<tr><td>${i?'<span class="mut">↑</span>':'<b>STY</b>'}</td><td>${esc(p.name)}</td><td class="stn">${esc(p.tree)}</td></tr>`).join('');
|
|
1978
|
+
const axbadge=axis?(e.under_axis?' <span class="badge">in axis branch</span>':' <span class="badge bad">outside axis branch</span>'):'';
|
|
1979
|
+
const styBlock=`<div class="evsec"><div class="evh">Semantic type${axbadge}<button class="mini" onclick="toggleSubtree(this,'${esc(spec.tui||'')}','${axis?esc(axis.tui):''}')">subtree</button></div>`+
|
|
1980
|
+
`<table class="et"><tr><th></th><th>STY</th><th>STN</th></tr>${pathRows}`+
|
|
1981
|
+
(axis?`<tr class="axisrow"><td><b>axis</b></td><td>${esc(axis.name)}</td><td class="stn">${esc(axis.stn)}</td></tr>`:'')+
|
|
1982
|
+
`</table><div class="subtree" style="display:none"></div></div>`;
|
|
1983
|
+
const vrows=(e.atom_rows||[]).map(a=>`<tr class="${a.obsolete?'obs':''}"><td>${esc(a.sab)}</td><td>${esc(a.str)}</td><td style="white-space:nowrap">${ttyHTML(a.tty)}</td><td class="cui">${esc(a.code)}</td></tr>`).join('');
|
|
1984
|
+
const vocBlock=`<div class="evsec"><div class="evh">Vocabularies (${(e.sabs||[]).length} sources, English)</div><table class="et"><tr><th>SAB</th><th>STR</th><th>TTY</th><th>Code</th></tr>${vrows||'<tr><td colspan=4><i>none</i></td></tr>'}</table></div>`;
|
|
1985
|
+
const rid=r=>esc(r.cui||r.code||'');
|
|
1986
|
+
const act=r=>r.cui?`<button class="mini" onclick="openConcept('${r.cui}','${jsq(r.name)}')" title="pull this concept into the search results to inspect or accept it">open</button>`
|
|
1987
|
+
:`<button class="mini" onclick="findByName('${jsq(r.name)}')" title="source-asserted (no CUI here) — search it by name">find</button>`;
|
|
1988
|
+
const rrows=(e.relations||[]).map(r=>`<tr><td class="dir ${r.dir}">${r.dir==='up'?'↑ is_a':'↓ is_a'}</td><td>${esc(r.name)}</td><td class="cui">${rid(r)}</td><td>${esc((r.sabs||[]).join(', '))}</td><td>${act(r)}</td></tr>`).join('');
|
|
1989
|
+
const relBlock=`<div class="evsec"><div class="evh">Hierarchy (is_a)</div><table class="et"><tr><th>dir</th><th>concept</th><th>id</th><th>sources</th><th></th></tr>${rrows||'<tr><td colspan=5><i>no concept-level is_a edges — hierarchies here are usually source-asserted: use the ↓ desc button for children, Rollup for ancestors</i></td></tr>'}</table></div>`;
|
|
1990
|
+
const org=(e.other_relations||[]);
|
|
1991
|
+
const orInner=org.map(g=>{
|
|
1992
|
+
const items=g.items.map(it=>`<tr><td>${esc(it.name)}</td><td class="cui">${rid(it)}</td><td>${esc((it.sabs||[]).join(', '))}</td><td>${act(it)}</td></tr>`).join('');
|
|
1993
|
+
const more=g.n>g.items.length?`<tr><td colspan=4 class="mut">+${g.n-g.items.length} more</td></tr>`:'';
|
|
1994
|
+
return `<tr class="orh"><td colspan=4>${esc(g.rela)} <span class="mut">(${g.n})</span></td></tr>${items}${more}`;}).join('');
|
|
1995
|
+
const otherBlock=org.length?`<div class="evsec"><div class="evh">Other relations<button class="mini" data-k="${org.length}" onclick="toggleOther(this)">show ${org.length} kinds</button></div><div class="otherbox" style="display:none"><table class="et"><tr><th>concept</th><th>id</th><th>sources</th><th></th></tr>${orInner}</table></div></div>`:'';
|
|
1996
|
+
const df=(defs||[]).map(d=>`<b>[${esc(d.source)}]</b> ${esc(d.value)}`).join('<br>')||'<i>no definition</i>';
|
|
1997
|
+
const rollBlock=`<div class="evsec"><div class="evh">Rollup<button class="mini" onclick="loadRollup(this.closest('.evsec').querySelector('.rollbox'),'${cui}')">roll up ↓</button></div><div class="rollbox"></div></div>`;
|
|
1998
|
+
box.innerHTML=`<div class="evtop"><b>${esc(e.name)}</b> <span class="cui">${esc(cui)}</span> · status ${esc(e.status||'?')} · ${e.atom_count||0} atoms</div>`+
|
|
1999
|
+
styBlock+vocBlock+relBlock+otherBlock+
|
|
2000
|
+
`<div class="evsec"><div class="evh">Definition</div><div class="evrow">${df}</div></div>`+rollBlock;}
|
|
2001
|
+
function toggleOther(btn){const box=btn.closest('.evsec').querySelector('.otherbox');const show=box.style.display==='none';box.style.display=show?'block':'none';btn.textContent=(show?'hide ':'show ')+btn.dataset.k+' kinds';}
|
|
2002
|
+
function toggleSubtree(btn,specTui,axisTui){const box=btn.closest('.evsec').querySelector('.subtree');
|
|
2003
|
+
if(box.style.display!=='none'){box.style.display='none';return;}
|
|
2004
|
+
box.style.display='block';
|
|
2005
|
+
const root=axisTui||specTui,rt=(SEMTYPES||[]).find(t=>t.tui===root);
|
|
2006
|
+
if(!rt){box.innerHTML='<i>semantic type not found</i>';return;}
|
|
2007
|
+
const inSub=(SEMTYPES||[]).filter(t=>stnUnder(t.tree,rt.tree));
|
|
2008
|
+
const render2=n=>{const kids=inSub.filter(t=>stnParentOf(t.tree)===n.tree);
|
|
2009
|
+
const hl=n.tui===specTui?' hl':(n.tui===axisTui?' ax':'');
|
|
2010
|
+
return `<div class="stn-node${hl}"><span class="stn-tree">${esc(n.tree)}</span> ${esc(n.name)}`+
|
|
2011
|
+
(kids.length?`<div class="stn-kids">${kids.map(render2).join('')}</div>`:'')+`</div>`;};
|
|
2012
|
+
box.innerHTML=render2(rt);}
|
|
2013
|
+
async function loadRollup(box,cui,sab){box.innerHTML='rolling up is_a ancestors…';
|
|
2014
|
+
const j=await (await fetch('/api/rollup?cui='+cui+(sab?'&use_sab='+encodeURIComponent(sab):''))).json();
|
|
2015
|
+
const rows=(j.rollup||[]).map(a=>`<tr><td>${esc(a.name)}</td><td class="cui">${esc(a.code)}</td><td>${esc(a.sab)}</td>`+
|
|
2016
|
+
`<td><button class="mini" onclick="findByName('${jsq(a.name)}')" title="search this ancestor by name to get its concept">find</button></td></tr>`).join('');
|
|
2017
|
+
const opts=j.sabs||[];
|
|
2018
|
+
const nav=opts.length?`<div class="mini2">vocabulary: <a href="#" data-cui="${cui}" onclick="rollNav(this,'');return false">auto</a>${opts.map(s=>` · <a href="#" data-cui="${cui}" onclick="rollNav(this,'${esc(s)}');return false">${esc(s)}</a>`).join('')}${sab?' — <b>'+esc(sab)+'</b>':''}</div>`:'';
|
|
2019
|
+
box.innerHTML=nav+`<table class="et"><tr><th>is_a ancestor</th><th>code</th><th>vocab</th><th></th></tr>${rows||'<tr><td colspan=4><i>no is_a ancestors in English vocabularies</i></td></tr>'}</table>`;}
|
|
2020
|
+
function rollNav(a,sab){loadRollup(a.closest('.rollbox'),a.dataset.cui,sab||null);}
|
|
2021
|
+
async function runSearch(){const q=$('#sq').value,sab=$('#ssab').value;
|
|
2022
|
+
// direct CUI lookup: paste a CUI to pull that exact concept as a candidate
|
|
2023
|
+
// card (then evidence / ↓ desc / relations work from it)
|
|
2024
|
+
const cuiM=(q||'').trim().match(/^C\d{7}$/i);
|
|
2025
|
+
if(cuiM){const box=$('#sresults');box.dataset.mode='search';
|
|
2026
|
+
box.innerHTML='<span class="mini">fetching '+esc(cuiM[0].toUpperCase())+'…</span>';
|
|
2027
|
+
const j=await (await fetch('/api/concept?cui='+cuiM[0].toUpperCase()+(SEL&&SEL.dim_sty_tui?'&axis='+SEL.dim_sty_tui:''))).json();
|
|
2028
|
+
const ev=(j||{}).evidence||{};
|
|
2029
|
+
if(ev.error||!ev.name){box.innerHTML='<span class="mini">'+esc(ev.error||'concept not found')+'</span>';return;}
|
|
2030
|
+
box.innerHTML='<div class="mini" style="margin-bottom:5px">1 concept by CUI · '+esc(STATE.search_backend||'UTS')+'</div>'
|
|
2031
|
+
+candHTML({cui:cuiM[0].toUpperCase(),name:ev.name,root_source:'',semantic_types:(ev.semantic_types||[]).map(x=>x.name||x)});
|
|
2032
|
+
enrichSabs(box);return;}
|
|
2033
|
+
const scope=($('#sscope')||{}).value||'axis',stype=($('#smatch')||{}).value||'words';
|
|
2034
|
+
if(SEL){const log=SEARCHLOG[SEL.key]||(SEARCHLOG[SEL.key]=[]);
|
|
2035
|
+
if(!log.some(l=>l.q===q&&l.scope===scope&&l.match===stype&&l.sab===sab))log.push({q,scope,match:stype,sab});}
|
|
2036
|
+
const box=$('#sresults');box.dataset.mode='search';box.innerHTML='<span class="mini">searching…</span>';
|
|
2037
|
+
const stys=scope==='axis'?(SEL.dim_sty_filter||''):'';
|
|
2038
|
+
const url='/api/search?string='+encodeURIComponent(q)+(sab?'&sabs='+sab:'')+
|
|
2039
|
+
(stys?'&stys='+encodeURIComponent(stys):'')+(stype!=='words'?'&stype='+encodeURIComponent(stype):'');
|
|
2040
|
+
const r=await fetch(url);const j=await r.json();
|
|
2041
|
+
if(j.error){box.innerHTML='<span style="color:var(--danger)">'+esc(j.error)+'</span>';return;}
|
|
2042
|
+
await loadSemTypes();
|
|
2043
|
+
// when searching beyond the axis, mark each hit as inside/outside the axis branch
|
|
2044
|
+
const axSet=new Set((SEL.dim_sty_filter||'').split(',').filter(Boolean));
|
|
2045
|
+
const norm=s=>(s||'').toLowerCase();
|
|
2046
|
+
const tuiOf=nm=>{const t=(SEMTYPES||[]).find(x=>norm(x.name)===norm(nm));return t?t.tui:null;};
|
|
2047
|
+
const res=(j.results||[]).slice(0,20);
|
|
2048
|
+
const rows=res.map(c=>{let mark=null;
|
|
2049
|
+
if(axSet.size&&scope!=='axis'){const tus=(c.semantic_types||c.sty||[]).map(tuiOf).filter(Boolean);
|
|
2050
|
+
mark=tus.some(t=>axSet.has(t))?'in':'out';}
|
|
2051
|
+
return candHTML(c,mark);}).join('');
|
|
2052
|
+
const count=`<div class="mini" style="margin-bottom:5px">${res.length} result${res.length===1?'':'s'} · ${esc(STATE.search_backend||'UTS')}${res.length>=20?' (top 20 shown)':''}</div>`;
|
|
2053
|
+
box.innerHTML=rows?count+rows:( j.note?'<span class="mini" style="color:var(--warn)">'+esc(j.note)+'</span>'
|
|
2054
|
+
:scope==='axis'
|
|
2055
|
+
?'<span class="mini">no results within the axis subtree.</span> <button class="mini" onclick="widen()">widen: search all semantic types</button>'
|
|
2056
|
+
:'<span class="mini">no results — try match: partial (any word), or a different term</span>');
|
|
2057
|
+
if(rows)enrichSabs(box);}
|
|
2058
|
+
function widen(){const s=$('#sscope');if(s)s.value='all';runSearch();}
|
|
2059
|
+
async function expandTerms(){if(!SEL)return;const box=$('#sresults');if(!box)return;
|
|
2060
|
+
const q=(($('#sq')||{}).value||'').trim();if(!q){box.innerHTML='<span class="mini">enter a query first</span>';return;}
|
|
2061
|
+
const scope=($('#sscope')||{}).value||'axis';
|
|
2062
|
+
const stys=scope==='axis'?(SEL.dim_sty_filter||''):'';
|
|
2063
|
+
box.dataset.mode='search';box.innerHTML='<span class="mini">expanding query terms through the hierarchy…</span>';
|
|
2064
|
+
const j=await (await fetch('/api/expand?q='+encodeURIComponent(q)+(stys?'&stys='+encodeURIComponent(stys):''))).json();
|
|
2065
|
+
if(j.error){box.innerHTML='<span style="color:var(--danger)">'+esc(j.error)+'</span>';return;}
|
|
2066
|
+
await loadSemTypes();
|
|
2067
|
+
const res=j.results||[];
|
|
2068
|
+
box.innerHTML=`<div class="mini" style="margin-bottom:5px">${res.length} concept${res.length===1?'':'s'} via ${j.n_variants} expanded variants of “${esc(q)}”${scope==='axis'&&stys?' (axis subtree — widen scope for more)':''}</div>`+
|
|
2069
|
+
res.map(r=>`<div class="mini" style="margin:6px 0 2px;color:var(--accent)">via ${esc(r.via)} — “${esc(r.variant)}”</div>`+candHTML(r)).join('')
|
|
2070
|
+
||'<span class="mini">no variants matched — the query words may have no exactly-named concepts to pivot on</span>';
|
|
2071
|
+
if(res.length)enrichSabs(box);}
|
|
2072
|
+
let AXBROWSE={};
|
|
2073
|
+
async function browseAxis(){if(!SEL||!SEL.dim_sty_filter)return;const box=$('#sresults');if(!box)return;
|
|
2074
|
+
box.innerHTML='<span class="mini">loading the axis subtree…</span>';
|
|
2075
|
+
const dim=SEL.dimension;
|
|
2076
|
+
if(!AXBROWSE[dim]){
|
|
2077
|
+
const j=await (await fetch('/api/axisbrowse?stys='+encodeURIComponent(SEL.dim_sty_filter))).json();
|
|
2078
|
+
if(j.error){box.innerHTML='<span class="mini">'+esc(j.error)+'</span>';return;}
|
|
2079
|
+
AXBROWSE[dim]=(j.concepts||[]);}
|
|
2080
|
+
box.dataset.mode='browse';renderAxisBrowse();
|
|
2081
|
+
const sq=$('#sq');
|
|
2082
|
+
if(sq&&!sq.dataset.axb){sq.dataset.axb='1';
|
|
2083
|
+
sq.addEventListener('input',()=>{const b=$('#sresults');
|
|
2084
|
+
if(b&&b.dataset.mode==='browse'&&AXBROWSE[SEL&&SEL.dimension])renderAxisBrowse();});}}
|
|
2085
|
+
function renderAxisBrowse(){const box=$('#sresults');if(!box)return;
|
|
2086
|
+
const all=AXBROWSE[SEL.dimension]||[];
|
|
2087
|
+
const q=(($('#sq')||{}).value||'').toLowerCase().trim();
|
|
2088
|
+
const hits=all.filter(c=>!q||(c.name||'').toLowerCase().includes(q));
|
|
2089
|
+
const shown=hits.slice(0,40);
|
|
2090
|
+
box.innerHTML=`<div class="mini" style="margin-bottom:6px">axis subtree: ${all.length} concepts · ${hits.length} match${q?' the filter':''} · showing ${shown.length} — type in the search box to narrow; Search returns to normal results</div>`+
|
|
2091
|
+
shown.map(c=>candHTML(c)).join('');
|
|
2092
|
+
enrichSabs(box);}
|
|
2093
|
+
function findByName(name){const q=$('#sq');if(!q)return;q.value=name;
|
|
2094
|
+
const s=$('#sscope');if(s)s.value='all';runSearch();
|
|
2095
|
+
msg('searching for “'+name+'” across all semantic types');}
|
|
2096
|
+
async function descendFrom(btn,cui){const box=btn.closest('.cand').querySelector('.def');
|
|
2097
|
+
const q=(($('#sq')||{}).value||'').trim();
|
|
2098
|
+
box.classList.add('show');box.innerHTML='<span class="mini">walking is_a descendants across vocabularies…</span>';
|
|
2099
|
+
const j=await (await fetch('/api/descend?cui='+encodeURIComponent(cui)+'&q='+encodeURIComponent(q))).json();
|
|
2100
|
+
if(j.error){box.innerHTML='<span class="mini">'+esc(j.error)+'</span>';return;}
|
|
2101
|
+
const rows=(j.matches||[]).map(m=>`<tr><td>${esc(m.name)}${m.parent?`<div class="mini">under ${esc(m.parent)}</div>`:''}</td>`+
|
|
2102
|
+
`<td class="cui">${esc(m.code)}</td><td>${esc(m.sab)}</td><td>${m.depth}</td><td>${m.score.toFixed(2)}</td>`+
|
|
2103
|
+
`<td><button class="mini" onclick="findByName('${jsq(m.name)}')" title="search this name to get its concept">find</button></td></tr>`).join('');
|
|
2104
|
+
const via=(j.anchors||[]).map(a=>a.sab).join(', ');
|
|
2105
|
+
box.innerHTML=`<div class="evh">is_a descendants${via?` via ${esc(via)}`:''}${q?` ranked vs “${esc(q)}”`:''} (depth ≤2)</div>`+
|
|
2106
|
+
(rows?`<table class="et"><tr><th>descendant</th><th>code</th><th>vocab</th><th>d</th><th>score</th><th></th></tr>${rows}</table>`
|
|
2107
|
+
:'<span class="mini">no is_a descendants in any of this concept’s vocabularies</span>');}
|
|
2108
|
+
function openConcept(cui,name){const box=$('#sresults');if(!box)return;
|
|
2109
|
+
if(box.querySelector('[data-cui="'+cui+'"]')){msg(cui+' already in results');return;}
|
|
2110
|
+
const t=document.createElement('template');
|
|
2111
|
+
t.innerHTML=candHTML({cui,name,sty:[],src:''}).trim();
|
|
2112
|
+
box.prepend(t.content.firstChild);enrichSabs(box);
|
|
2113
|
+
msg('pulled '+cui+' into search results — accept it or open its evidence');}
|
|
2114
|
+
// extra: optional rationale keys (relation / rejected / protocol / note) that
|
|
2115
|
+
// travel with the verdict; a note given here wins over the Decision textarea.
|
|
2116
|
+
async function decide(verdict,cui,extra){extra=extra||{};
|
|
2117
|
+
const note=extra.note!==undefined?extra.note:(($('#note')||{}).value||'');
|
|
2118
|
+
const body={key:SEL.key,verdict};if(cui)body.cui=cui;if(note)body.note=note;
|
|
2119
|
+
['relation','rejected','protocol'].forEach(k=>{if(extra[k]!==undefined)body[k]=extra[k];});
|
|
2120
|
+
msg('saving…');const r=await fetch('/api/decide',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(body)});
|
|
2121
|
+
const j=await r.json();if(j.error){msg('error: '+j.error);return false;}
|
|
2122
|
+
await loadState();msg('saved '+SEL.key+' ('+verdict+') — Rebuild to refresh status');return true;}
|
|
2123
|
+
function openSettings(){const wp=((STATE&&STATE.prefs||{}).workspace||[]).join(', ');
|
|
2124
|
+
showModal('Settings',
|
|
2125
|
+
`<div style="font-size:10.5px;font-weight:600;letter-spacing:.09em;color:var(--faint)">PREFERRED VOCABULARIES</div>`+
|
|
2126
|
+
`<div class="mini" style="margin:4px 0 8px">Ordered list. Decides which source is shown and ticked for every mapped concept and candidate, everywhere in the app.</div>`+
|
|
2127
|
+
`<div style="display:flex;gap:8px"><input id="prefsabs" value="${esc(wp)}" placeholder="e.g. SNOMEDCT_US, MSH, NCI, HPO" style="flex:1;font-family:var(--mono);font-size:12.5px">`+
|
|
2128
|
+
`<button class="primary" onclick="savePrefs()">Save</button></div>`+
|
|
2129
|
+
`<div class="mini" style="margin-top:8px">Workspace default (inventory <span class="mono">meta.preferred_sabs</span>). A dimension can override it in its axis card; a value's own <span class="mono">sab:</span> hint in the inventory always comes first.</div>`+
|
|
2130
|
+
`<div style="margin-top:14px;font-size:10.5px;font-weight:600;letter-spacing:.09em;color:var(--faint)">SERVER</div>`+
|
|
2131
|
+
`<div class="mini" style="margin-top:4px">started ${esc((STATE.server||{}).started||'?')} · running code saved ${esc((STATE.server||{}).code||'?')} — if the code date is older than your latest changes, restart the Studio (Ctrl-C, then <span class="mono">gem-umls-adjudicate</span>)</div>`+
|
|
2132
|
+
`<div style="margin-top:14px;font-size:10.5px;font-weight:600;letter-spacing:.09em;color:var(--faint)">SEARCH BACKEND</div>`+
|
|
2133
|
+
`<div style="font-size:12.5px;margin-top:4px">${esc(STATE.search_backend||'UTS')}${(STATE.search_backend||'UTS')==='UTS'?' — the optional local index (faster search, typo-tolerant matching, axis browse) is not loaded':''}</div>`+
|
|
2134
|
+
(STATE.search_backend_note?`<div class="mini" style="margin-top:3px;color:var(--warn)">probe: ${esc(STATE.search_backend_note)}</div>`:'')+
|
|
2135
|
+
((STATE.search_backend||'UTS')==='UTS'?`<div class="mini" style="margin-top:4px">To enable it: download a UMLS release with your license, create the database (<span class="mono">createdb umls; psql -d umls -c 'CREATE EXTENSION pg_trgm'</span>), load it (<span class="mono">gem-umls-load-local --rrf-dir ~/umls/2026AA/META --release 2026AA</span>) and restart the Studio. Full recipe: <span class="mono">data/umls/README.md</span>.</div>`:'')+
|
|
2136
|
+
`<div style="margin-top:14px;font-size:10.5px;font-weight:600;letter-spacing:.09em;color:var(--faint)">WORKSPACE</div>`+
|
|
2137
|
+
`<div class="mono" style="font-size:11.5px;word-break:break-all;margin-top:4px">${esc(STATE.workspace||'')}</div>`);
|
|
2138
|
+
const i=$('#prefsabs');if(i){attachSabTypeahead(i,savePrefs);i.focus();}}
|
|
2139
|
+
async function savePrefs(){const v=($('#prefsabs')||{}).value||'';msg('saving preferences…');
|
|
2140
|
+
const j=await (await fetch('/api/prefs',{method:'POST',headers:{'Content-Type':'application/json'},
|
|
2141
|
+
body:JSON.stringify({preferred_sabs:v})})).json();
|
|
2142
|
+
if(j.error){msg('error: '+j.error);return;}
|
|
2143
|
+
for(const k in SABCACHE)delete SABCACHE[k]; // labels depend on prefs; recompute lazily
|
|
2144
|
+
closeModal();await loadState();msg('preferred vocabularies: '+((j.preferred_sabs||[]).join(' › ')||'none'));}
|
|
2145
|
+
async function rebuild(dim){msg(dim?('rebuilding '+dim+' (querying UMLS)…'):'rebuilding ALL dimensions (querying UMLS)…');
|
|
2146
|
+
const r=await fetch('/api/rebuild',{method:'POST',headers:{'Content-Type':'application/json'},
|
|
2147
|
+
body:JSON.stringify(dim?{dim}:{})});
|
|
2148
|
+
const j=await r.json();
|
|
2149
|
+
if(j.error){msg('error: '+j.error);return;}
|
|
2150
|
+
await loadState();const c=STATE.counts||{};
|
|
2151
|
+
msg((dim?dim+' rebuilt':'all rebuilt')+' — '+(c.mapped||0)+' mapped · '+(c.review||0)+' review · '+(c.unmapped||0)+' unmapped of '+(c.total||0)+' values · '+(c.needs||0)+' need review');}
|
|
2152
|
+
loadState();loadSemTypes();
|
|
2153
|
+
</script></body></html>'''
|
|
2154
|
+
|
|
2155
|
+
|
|
2156
|
+
def main(argv=None):
|
|
2157
|
+
global client
|
|
2158
|
+
import argparse
|
|
2159
|
+
argv = list(sys.argv[1:] if argv is None else argv)
|
|
2160
|
+
ap = argparse.ArgumentParser(
|
|
2161
|
+
prog="gem-umls-adjudicate",
|
|
2162
|
+
description="Local UMLS crosswalk adjudication + axis builder. Point it "
|
|
2163
|
+
"at a mapping directory (--data-dir); an empty one lets you "
|
|
2164
|
+
"construct axes from scratch, a populated one lets you modify.")
|
|
2165
|
+
ap.add_argument("--data-dir", help="mapping workspace directory (inventory / "
|
|
2166
|
+
"crosswalk / adjudications). May be empty. Defaults to the "
|
|
2167
|
+
"repo's data/umls, or $GEM_DATA_DIR.")
|
|
2168
|
+
ap.add_argument("--search-backend", choices=["auto", "uts", "local"],
|
|
2169
|
+
default="auto",
|
|
2170
|
+
help="auto (default): use a local UMLS index when one is "
|
|
2171
|
+
"loaded, else UTS — the released tool needs only a "
|
|
2172
|
+
"UTS key; 'uts' never touches the local index; "
|
|
2173
|
+
"'local' requires one and fails fast without it.")
|
|
2174
|
+
ap.add_argument("--port", type=int, default=int(os.environ.get("PORT", "8765")))
|
|
2175
|
+
ap.add_argument("--no-browser", action="store_true", help="do not open a browser")
|
|
2176
|
+
args = ap.parse_args(argv)
|
|
2177
|
+
|
|
2178
|
+
# Point every path (this module AND the harness) at the chosen workspace by
|
|
2179
|
+
# setting GEM_DATA_DIR and re-execing once -- path constants resolve at import,
|
|
2180
|
+
# so an in-process override would not reach already-imported modules.
|
|
2181
|
+
if args.data_dir:
|
|
2182
|
+
want = str(Path(args.data_dir).expanduser().resolve())
|
|
2183
|
+
if os.environ.get("GEM_DATA_DIR") != want:
|
|
2184
|
+
os.environ["GEM_DATA_DIR"] = want
|
|
2185
|
+
os.execv(sys.executable,
|
|
2186
|
+
[sys.executable, "-m", "forome.gem.umls.adjudicate_ui", *argv])
|
|
2187
|
+
|
|
2188
|
+
global SEARCH_BACKEND
|
|
2189
|
+
key = api_key()
|
|
2190
|
+
if key:
|
|
2191
|
+
client = UTSClient(key, cache_dir=H.CACHE_DIR)
|
|
2192
|
+
else:
|
|
2193
|
+
# Axis construction needs only the Semantic Network (reference data), so
|
|
2194
|
+
# run without a key -- value search/concept info are simply inert.
|
|
2195
|
+
print("WARNING: no UMLS_API_KEY found; running offline (axis construction "
|
|
2196
|
+
"works; live value search/concept info are disabled).")
|
|
2197
|
+
client = NullClient()
|
|
2198
|
+
SEARCH_BACKEND = "OFFLINE — no UMLS key"
|
|
2199
|
+
globals()["SEARCH_BACKEND_NOTE"] = ("no UMLS_API_KEY in the environment: "
|
|
2200
|
+
"start the Studio from the repository directory (direnv exports the "
|
|
2201
|
+
"key there) or export UMLS_API_KEY yourself")
|
|
2202
|
+
# The local PostgreSQL index is an OPTIONAL accelerator each user builds
|
|
2203
|
+
# from their own licensed UMLS copy (gem-umls-load-local; see
|
|
2204
|
+
# data/umls/README.md) -- it is never distributed with the tool. Released
|
|
2205
|
+
# behavior needs only a UTS key: --search-backend auto silently falls
|
|
2206
|
+
# back to UTS when no index is reachable; 'browse axis' then explains
|
|
2207
|
+
# what it needs instead of appearing broken.
|
|
2208
|
+
if args.search_backend != "uts":
|
|
2209
|
+
try:
|
|
2210
|
+
from forome.gem.umls.local_umls import PgUMLSClient
|
|
2211
|
+
_pg = PgUMLSClient(os.environ.get("GEM_UMLS_DSN") or None)
|
|
2212
|
+
_rel = _pg.release()
|
|
2213
|
+
if _rel:
|
|
2214
|
+
client = HybridClient(_pg, client)
|
|
2215
|
+
SEARCH_BACKEND = f"local {_rel.get('version')}"
|
|
2216
|
+
print(f"Search backend: local UMLS index {_rel.get('version')}; "
|
|
2217
|
+
"concept details via UTS.")
|
|
2218
|
+
elif args.search_backend == "local":
|
|
2219
|
+
raise SystemExit("--search-backend local: no umls_release row -- "
|
|
2220
|
+
"load an index with gem-umls-load-local first.")
|
|
2221
|
+
else:
|
|
2222
|
+
globals()["SEARCH_BACKEND_NOTE"] = ("database reachable but no "
|
|
2223
|
+
"umls_release row (index not loaded)")
|
|
2224
|
+
except SystemExit:
|
|
2225
|
+
raise
|
|
2226
|
+
except Exception as ex: # noqa: BLE001 -- no driver / DB: UTS only
|
|
2227
|
+
if args.search_backend == "local":
|
|
2228
|
+
raise SystemExit(f"--search-backend local: {ex}")
|
|
2229
|
+
globals()["SEARCH_BACKEND_NOTE"] = (f"{type(ex).__name__}: {ex} "
|
|
2230
|
+
f"[python: {sys.executable}]")
|
|
2231
|
+
print(f"Local index not used ({SEARCH_BACKEND_NOTE}); search via UTS.")
|
|
2232
|
+
|
|
2233
|
+
if not H.INVENTORY.is_file() and not CROSSWALK.is_file():
|
|
2234
|
+
print(f"Workspace {DATA_DIR} has no inventory or crosswalk yet -- "
|
|
2235
|
+
f"starting empty; use “New axis” to build one from scratch.")
|
|
2236
|
+
|
|
2237
|
+
srv = ThreadingHTTPServer(("127.0.0.1", args.port), Handler)
|
|
2238
|
+
url = f"http://127.0.0.1:{args.port}/"
|
|
2239
|
+
print(f"GEM Mapping Studio running at {url} (workspace: {DATA_DIR}) (Ctrl-C to stop)")
|
|
2240
|
+
if not args.no_browser:
|
|
2241
|
+
try:
|
|
2242
|
+
webbrowser.open(url)
|
|
2243
|
+
except Exception: # noqa: BLE001
|
|
2244
|
+
pass
|
|
2245
|
+
try:
|
|
2246
|
+
srv.serve_forever()
|
|
2247
|
+
except KeyboardInterrupt:
|
|
2248
|
+
print("\nstopped.")
|
|
2249
|
+
return 0
|
|
2250
|
+
|
|
2251
|
+
|
|
2252
|
+
if __name__ == "__main__":
|
|
2253
|
+
raise SystemExit(main())
|