gem-mapping-studio 0.2.2__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,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=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'}[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&rsquo;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&rsquo;s axis maps it to a UMLS <b>semantic type</b>; the type&rsquo;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">&times;${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 &rarr; <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 &rarr; <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>&nbsp;${esc(e.dim_sty_name||e.dim_sty_tui)}</b>&nbsp;· <span class="mono">${esc(e.dim_sty_tree||e.dim_sty_tui)}</span>&nbsp;+ 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)}&#10;B · ${esc(CRITERIA.B)}&#10;C · ${esc(CRITERIA.C)}&#10;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">&uarr;</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'?'&uarr; is_a':'&darr; 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 &darr;</button></div><div class="rollbox"></div></div>`;
1998
+ box.innerHTML=`<div class="evtop"><b>${esc(e.name)}</b> <span class="cui">${esc(cui)}</span> &middot; status ${esc(e.status||'?')} &middot; ${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=>` &middot; <a href="#" data-cui="${cui}" onclick="rollNav(this,'${esc(s)}');return false">${esc(s)}</a>`).join('')}${sab?' &mdash; <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())