Spaces:
Sleeping
fix(ui): make the raw-result accordion legible without touching the payload
Browse filesA full-panel `pdac_msk_2024` query rendered a ~300,000-pixel page: 21,187 lines
of JSON in an accordion that grows with its content. The payload is not the
problem and is deliberately NOT trimmed β `samples_profiled` is the ID set the
orchestrator derives wild-type from (`per_sample_contract`), and `per_sample` /
`provenance` are the per-sample answer. Dropping either is the bug that reported
every gene as "no variation in alteration status".
So the split is display-side only. The machine endpoints still call `_as_json`
and emit the payload byte for byte (verified live over `gradio_client`: 3 x 2,336
IDs, still lists, no display key). The UI gets three tiers over the same object:
- a summary table, gene x modality, each cell carrying the percentage AND the
fraction it came from. Absent cells NAME the absence ("not on DNA panel",
"not in cohort", "not curated") β never blank, never padded to 0%, and NRG1
still shows its 6 SV events while its mutation/CNV cells stay refused.
- `_ui_json`: the same object with bulk scalar collections replaced by a counted
sentinel of a different shape, so an abridged block cannot be misread as data.
21,187 -> 1,658 lines. Small payloads (denial, refusal, error) pass through
verbatim, so the denial-visibility guarantee is untouched.
- a download of the COMPLETE payload, so nothing is withheld, only deferred.
Grounded results only β a BYOD upload is never written to disk (ADR-0003).
Height is now capped in CSS, not by `gr.Code(max_lines=...)`: gradio 6.18
ignores that here β measured in the browser, the box still rendered 23,323px
tall with it set. Measured after: page 2,818px with the accordion OPEN, and it
no longer tracks cohort size at all.
The clearing frame blanks the two new slots as it does the charts β a stale
download link is the worst version of that bug, since the file leaves the page.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
- gradio_ui.py +270 -7
- tests/test_plot_and_caveat_rendering.py +14 -2
- tests/test_raw_result_legibility.py +173 -0
|
@@ -29,6 +29,7 @@ from __future__ import annotations
|
|
| 29 |
import json
|
| 30 |
import os
|
| 31 |
import sys
|
|
|
|
| 32 |
import time
|
| 33 |
from pathlib import Path
|
| 34 |
|
|
@@ -114,6 +115,125 @@ def _as_json(result) -> str:
|
|
| 114 |
return json.dumps(result, indent=2, default=str)
|
| 115 |
|
| 116 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 117 |
# --------------------------------------------------------------------------- #
|
| 118 |
# Plots
|
| 119 |
# --------------------------------------------------------------------------- #
|
|
@@ -215,6 +335,83 @@ def _frequency_plots(result):
|
|
| 215 |
return updates
|
| 216 |
|
| 217 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 218 |
def _subtype_frame(result) -> pd.DataFrame | None:
|
| 219 |
"""Tidy (gene, subtype, % altered) frame from a `variant_by_subtype` result."""
|
| 220 |
if not isinstance(result, dict) or not result.get("join_available"):
|
|
@@ -672,6 +869,11 @@ def _ui_variant_status(source, genes, profile: gr.OAuthProfile | None = None):
|
|
| 672 |
warning (found on prod, 2026-08-05). Clearing first means the worst intermediate state a
|
| 673 |
reader can catch is an EMPTY chart, which claims nothing, rather than a stale one, which
|
| 674 |
claims something false. The cost is one extra frame; the alternative is a wrong answer.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 675 |
"""
|
| 676 |
username = _username(profile)
|
| 677 |
src = registered_source(source)
|
|
@@ -679,12 +881,30 @@ def _ui_variant_status(source, genes, profile: gr.OAuthProfile | None = None):
|
|
| 679 |
if not allowed:
|
| 680 |
record_run("variant_status", username=username, source=src, status="denied")
|
| 681 |
_, js = _denied_json(denial)
|
| 682 |
-
yield (
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 683 |
return
|
| 684 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 685 |
result = _safe_result(lambda: _query_variant_status(list(genes) or list(PANEL), source))
|
| 686 |
record_run("variant_status", username=username, source=src, result=result)
|
| 687 |
-
yield (
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 688 |
|
| 689 |
|
| 690 |
def _ui_variant_by_subtype(
|
|
@@ -816,7 +1036,22 @@ def _byod_caution_md(result):
|
|
| 816 |
# --------------------------------------------------------------------------- #
|
| 817 |
# App
|
| 818 |
# --------------------------------------------------------------------------- #
|
| 819 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 820 |
gr.Markdown(
|
| 821 |
"# PDAC Genomics Agent\n"
|
| 822 |
"Somatic **mutation**, **copy-number** and **structural-variant (fusion)** status over a "
|
|
@@ -855,16 +1090,44 @@ with gr.Blocks(title="PDAC Genomics Agent") as demo:
|
|
| 855 |
)
|
| 856 |
vs_button = gr.Button("Query variant status", variant="primary")
|
| 857 |
vs_caution = gr.Markdown(visible=False)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 858 |
# One chart per modality β see `_frequency_plots`. Never a single stacked chart: the
|
| 859 |
# modalities have different denominators and overlapping membership, so a stack is not
|
| 860 |
# a quantity. Each starts hidden and is shown only when the answer contains it.
|
| 861 |
vs_plots = [gr.BarPlot(visible=False) for _ in _MODALITY_ORDER]
|
| 862 |
-
|
| 863 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 864 |
vs_button.click(
|
| 865 |
_ui_variant_status,
|
| 866 |
inputs=[vs_source, vs_genes],
|
| 867 |
-
outputs=[vs_caution, *vs_plots, vs_json],
|
| 868 |
api_name=False,
|
| 869 |
)
|
| 870 |
|
|
|
|
| 29 |
import json
|
| 30 |
import os
|
| 31 |
import sys
|
| 32 |
+
import tempfile
|
| 33 |
import time
|
| 34 |
from pathlib import Path
|
| 35 |
|
|
|
|
| 115 |
return json.dumps(result, indent=2, default=str)
|
| 116 |
|
| 117 |
|
| 118 |
+
# --------------------------------------------------------------------------- #
|
| 119 |
+
# Result presentation β summary first, abridged JSON, full payload on request
|
| 120 |
+
# --------------------------------------------------------------------------- #
|
| 121 |
+
# A full-panel `pdac_msk_2024` answer serializes to 21,187 lines / ~800 kB, which rendered the
|
| 122 |
+
# "Raw result (JSON)" accordion as a ~300,000-pixel page: opening it lost the reader the charts,
|
| 123 |
+
# the caution box and the scrollbar in one click (found in the 2026-08-05 signed-in
|
| 124 |
+
# click-through). The size is not an accident and is NOT fixable by trimming the payload:
|
| 125 |
+
#
|
| 126 |
+
# * `samples_profiled` is 3 x 2,336 IDs = 7,008 lines. It exists so a MACHINE caller can
|
| 127 |
+
# reconstruct wild-type from the sparse `per_sample` (`deploy/orchestrator_registration.yaml`,
|
| 128 |
+
# `per_sample_contract`). Dropping it is what previously made the orchestrator's join report
|
| 129 |
+
# every gene as "no variation in alteration status".
|
| 130 |
+
# * `per_sample` + `provenance` are another ~13,200 lines β KRAS alone is altered in ~90% of
|
| 131 |
+
# 2,336 samples. That is the actual answer, per sample, and is equally load-bearing.
|
| 132 |
+
#
|
| 133 |
+
# So the payload is right and the RENDERING was wrong. The split below is therefore strictly
|
| 134 |
+
# display-side: the machine endpoints keep calling `_as_json` and emit the payload byte for byte,
|
| 135 |
+
# while the UI gets three tiers over the same object β a summary table (what a non-coding
|
| 136 |
+
# scientist actually reads), an abridged JSON preview (structure, inspectable at a glance), and a
|
| 137 |
+
# download of the COMPLETE payload (nothing is withheld, only deferred).
|
| 138 |
+
_ABRIDGE_MIN_ITEMS = 8 # collections at or below this render in full β small blocks stay readable
|
| 139 |
+
_ABRIDGE_PREVIEW = 3 # how many entries a sentinel shows, so the shape stays visible
|
| 140 |
+
|
| 141 |
+
_ABRIDGE_NOTE = (
|
| 142 |
+
"This is an ABRIDGED view for on-screen reading: long sample-ID lists and per-sample maps "
|
| 143 |
+
"are replaced by a sentinel giving their exact size and first few entries. Nothing has been "
|
| 144 |
+
"removed from the answer itself β use βDownload full result (JSON)β above for the complete "
|
| 145 |
+
"payload, which is also exactly what the machine endpoint returns."
|
| 146 |
+
)
|
| 147 |
+
|
| 148 |
+
|
| 149 |
+
def _is_bulk(items) -> bool:
|
| 150 |
+
"""True for a big collection of SCALARS β the shape worth replacing with a sentinel.
|
| 151 |
+
|
| 152 |
+
Nested collections are left alone deliberately: `genes` has 36 entries but each is a block a
|
| 153 |
+
reader needs, whereas `samples_profiled["mutation"]` is 2,336 interchangeable IDs. Size alone
|
| 154 |
+
is the wrong test; size plus flatness is the one that only ever hits bulk data.
|
| 155 |
+
"""
|
| 156 |
+
items = list(items)
|
| 157 |
+
return len(items) > _ABRIDGE_MIN_ITEMS and not any(isinstance(v, (dict, list)) for v in items)
|
| 158 |
+
|
| 159 |
+
|
| 160 |
+
def _abridge(value):
|
| 161 |
+
"""Recursively replace oversized collections with a self-describing sentinel.
|
| 162 |
+
|
| 163 |
+
The sentinel is deliberately a DIFFERENT SHAPE from what it replaces (a dict where a list
|
| 164 |
+
was), so an abridged block can never be mistaken for the real one β a truncated list that
|
| 165 |
+
still looks like a list is how a reader ends up believing a cohort has 8 profiled samples.
|
| 166 |
+
Every sentinel states the true count, which is the analytically load-bearing part: the size
|
| 167 |
+
of `samples_profiled[modality]` IS the denominator, and it is also carried in `n_profiled`.
|
| 168 |
+
"""
|
| 169 |
+
if isinstance(value, dict):
|
| 170 |
+
if _is_bulk(value.values()):
|
| 171 |
+
return {
|
| 172 |
+
"__abridged__": f"{len(value)} entries β shown in full in the downloaded JSON",
|
| 173 |
+
"n_entries": len(value),
|
| 174 |
+
"first": dict(list(value.items())[:_ABRIDGE_PREVIEW]),
|
| 175 |
+
}
|
| 176 |
+
return {k: _abridge(v) for k, v in value.items()}
|
| 177 |
+
if isinstance(value, list):
|
| 178 |
+
if _is_bulk(value):
|
| 179 |
+
return {
|
| 180 |
+
"__abridged__": f"{len(value)} items β shown in full in the downloaded JSON",
|
| 181 |
+
"n_items": len(value),
|
| 182 |
+
"first": value[:_ABRIDGE_PREVIEW],
|
| 183 |
+
}
|
| 184 |
+
return [_abridge(v) for v in value]
|
| 185 |
+
return value
|
| 186 |
+
|
| 187 |
+
|
| 188 |
+
def _ui_json(result) -> str:
|
| 189 |
+
"""The accordion's JSON: the same object, abridged for display only.
|
| 190 |
+
|
| 191 |
+
Small payloads (a denial, a refusal, a BYOD summary, a `route: orchestrator` result) pass
|
| 192 |
+
through untouched β every collection in them is under the threshold β so the denial-visibility
|
| 193 |
+
and error-shape guarantees the tests pin are unaffected.
|
| 194 |
+
"""
|
| 195 |
+
if not isinstance(result, dict):
|
| 196 |
+
return _as_json(result)
|
| 197 |
+
abridged = _abridge(result)
|
| 198 |
+
if abridged == result: # nothing was large enough to touch β show it verbatim
|
| 199 |
+
return _as_json(result)
|
| 200 |
+
return _as_json({"__display__": _ABRIDGE_NOTE, **abridged})
|
| 201 |
+
|
| 202 |
+
|
| 203 |
+
# Full-payload downloads. Written under the system temp dir, pruned to the most recent few, and
|
| 204 |
+
# only ever used for GROUNDED registered-cohort results β a BYOD upload is never written to disk
|
| 205 |
+
# (ADR-0003: "never persisted"), which is why the BYOD tab has no download button.
|
| 206 |
+
_DOWNLOAD_DIR = Path(tempfile.gettempdir()) / "pdac-genomics-agent-results"
|
| 207 |
+
_DOWNLOAD_KEEP = 20
|
| 208 |
+
|
| 209 |
+
|
| 210 |
+
def _full_result_file(result, stem: str):
|
| 211 |
+
"""Write the COMPLETE payload to a temp file and return a DownloadButton update.
|
| 212 |
+
|
| 213 |
+
The abridged accordion is only honest if the unabridged thing is one click away; without
|
| 214 |
+
this, "abridged for display" would be indistinguishable from data we quietly dropped.
|
| 215 |
+
"""
|
| 216 |
+
try:
|
| 217 |
+
_DOWNLOAD_DIR.mkdir(parents=True, exist_ok=True)
|
| 218 |
+
existing = sorted(_DOWNLOAD_DIR.glob("*.json"), key=lambda p: p.stat().st_mtime)
|
| 219 |
+
for stale in existing[: max(0, len(existing) - _DOWNLOAD_KEEP + 1)]:
|
| 220 |
+
stale.unlink(missing_ok=True)
|
| 221 |
+
handle, path = tempfile.mkstemp(prefix=f"{stem}-", suffix=".json", dir=_DOWNLOAD_DIR)
|
| 222 |
+
with os.fdopen(handle, "w", encoding="utf-8") as fh:
|
| 223 |
+
fh.write(_as_json(result))
|
| 224 |
+
except OSError:
|
| 225 |
+
# A download we cannot write is not a reason to lose the answer β the summary table,
|
| 226 |
+
# the charts and the abridged JSON all still render.
|
| 227 |
+
return gr.update(visible=False)
|
| 228 |
+
return gr.update(value=path, visible=True)
|
| 229 |
+
|
| 230 |
+
|
| 231 |
+
def _safe_source_stem(result) -> str:
|
| 232 |
+
source = result.get("source") if isinstance(result, dict) else None
|
| 233 |
+
text = str(source or "result").replace(":", "-")
|
| 234 |
+
return "".join(c if c.isalnum() or c in "-_" else "-" for c in text)[:60] or "result"
|
| 235 |
+
|
| 236 |
+
|
| 237 |
# --------------------------------------------------------------------------- #
|
| 238 |
# Plots
|
| 239 |
# --------------------------------------------------------------------------- #
|
|
|
|
| 335 |
return updates
|
| 336 |
|
| 337 |
|
| 338 |
+
# --------------------------------------------------------------------------- #
|
| 339 |
+
# Summary table β the top tier of the disclosure
|
| 340 |
+
# --------------------------------------------------------------------------- #
|
| 341 |
+
# One row per requested gene, one column per modality, each cell carrying the frequency AND the
|
| 342 |
+
# fraction it came from. It is the answer a reader wants before any of the per-sample detail,
|
| 343 |
+
# and it is what makes the abridged JSON acceptable: the numbers are on the page, not inside a
|
| 344 |
+
# collapsed accordion.
|
| 345 |
+
#
|
| 346 |
+
# An absent cell is NEVER blank and never zero. Padding every gene to three modalities is the
|
| 347 |
+
# specific bug this repo has already fixed twice (it reports NRG1 as 0% mutated on a cohort that
|
| 348 |
+
# never sequenced it), so each empty cell instead SAYS which kind of absence it is β and the four
|
| 349 |
+
# kinds are genuinely different things:
|
| 350 |
+
_CELL_NOT_ON_PANEL = "not on DNA panel" # cohort never interrogated the gene for this modality
|
| 351 |
+
_CELL_NO_MODALITY = "not in cohort" # the whole cohort lacks the modality (per-study gate)
|
| 352 |
+
_CELL_NOT_CURATED = "not curated" # our snapshot's gap, fixed by re-curating
|
| 353 |
+
_SUMMARY_GENE_COL = "gene"
|
| 354 |
+
_SUMMARY_NOTE_COL = "coverage"
|
| 355 |
+
|
| 356 |
+
|
| 357 |
+
def _summary_cell(entry: dict, modality: str, modalities_available: dict) -> str:
|
| 358 |
+
block = entry.get(modality)
|
| 359 |
+
if isinstance(block, dict) and "frequency" in block:
|
| 360 |
+
pct = round(block["frequency"] * 100, 1)
|
| 361 |
+
return f"{pct}% ({block['n_altered']} / {block['n_profiled']})"
|
| 362 |
+
if entry.get("curated") is False:
|
| 363 |
+
return _CELL_NOT_CURATED
|
| 364 |
+
if not modalities_available.get(modality):
|
| 365 |
+
return _CELL_NO_MODALITY
|
| 366 |
+
if entry.get("assayed") is False:
|
| 367 |
+
return _CELL_NOT_ON_PANEL
|
| 368 |
+
return _CELL_NO_MODALITY
|
| 369 |
+
|
| 370 |
+
|
| 371 |
+
def _coverage_note(entry: dict) -> str:
|
| 372 |
+
"""A SHORT reason for a gated row β the payload keeps the full wording.
|
| 373 |
+
|
| 374 |
+
The tool's own `note` runs to three sentences, which is right in the JSON and wrong in a
|
| 375 |
+
table cell: one long note makes every row as tall as the longest, and 36 of them reproduce
|
| 376 |
+
the scrolling problem this change exists to remove. The distinctions are preserved, only
|
| 377 |
+
compressed β and the full text is one click away in the downloaded payload.
|
| 378 |
+
"""
|
| 379 |
+
if entry.get("curated") is False:
|
| 380 |
+
return "absent from our curated snapshot β re-curation fixes it"
|
| 381 |
+
if entry.get("assayed") is False:
|
| 382 |
+
if isinstance(entry.get("sv"), dict):
|
| 383 |
+
# The modality-scoped gate (ADR-0008): off the DNA panel, but the fusion assay
|
| 384 |
+
# covers it, so the SV cell legitimately carries a number while the others do not.
|
| 385 |
+
return "off the DNA panel β fusion assay covers it, so SV only"
|
| 386 |
+
return "never sequenced in this cohort β not a zero"
|
| 387 |
+
return ""
|
| 388 |
+
|
| 389 |
+
|
| 390 |
+
def _summary_frame(result) -> pd.DataFrame | None:
|
| 391 |
+
"""Gene x modality frequency table, with every absence named rather than left blank."""
|
| 392 |
+
genes = result.get("genes") if isinstance(result, dict) else None
|
| 393 |
+
if not genes:
|
| 394 |
+
return None
|
| 395 |
+
available = result.get("modalities_available") or {}
|
| 396 |
+
rows = []
|
| 397 |
+
for gene, entry in genes.items():
|
| 398 |
+
if not isinstance(entry, dict):
|
| 399 |
+
continue
|
| 400 |
+
row = {_SUMMARY_GENE_COL: gene}
|
| 401 |
+
for modality in _MODALITY_ORDER:
|
| 402 |
+
row[_MODALITY_LABELS[modality]] = _summary_cell(entry, modality, available)
|
| 403 |
+
row[_SUMMARY_NOTE_COL] = _coverage_note(entry)
|
| 404 |
+
rows.append(row)
|
| 405 |
+
return pd.DataFrame(rows) if rows else None
|
| 406 |
+
|
| 407 |
+
|
| 408 |
+
def _summary_table(result):
|
| 409 |
+
frame = _summary_frame(result)
|
| 410 |
+
if frame is None or frame.empty:
|
| 411 |
+
return gr.update(value=None, visible=False)
|
| 412 |
+
return gr.update(value=frame, visible=True)
|
| 413 |
+
|
| 414 |
+
|
| 415 |
def _subtype_frame(result) -> pd.DataFrame | None:
|
| 416 |
"""Tidy (gene, subtype, % altered) frame from a `variant_by_subtype` result."""
|
| 417 |
if not isinstance(result, dict) or not result.get("join_available"):
|
|
|
|
| 869 |
warning (found on prod, 2026-08-05). Clearing first means the worst intermediate state a
|
| 870 |
reader can catch is an EMPTY chart, which claims nothing, rather than a stale one, which
|
| 871 |
claims something false. The cost is one extra frame; the alternative is a wrong answer.
|
| 872 |
+
|
| 873 |
+
Output order is (caution, summary table, three charts, download, JSON). The JSON stays LAST:
|
| 874 |
+
the gating tests read the answer as `outputs[-1]` and every slot between the caution and it
|
| 875 |
+
as "must show nothing on a denial", which the two new slots satisfy by returning a
|
| 876 |
+
hidden-visibility update on the denied path exactly as the charts do.
|
| 877 |
"""
|
| 878 |
username = _username(profile)
|
| 879 |
src = registered_source(source)
|
|
|
|
| 881 |
if not allowed:
|
| 882 |
record_run("variant_status", username=username, source=src, status="denied")
|
| 883 |
_, js = _denied_json(denial)
|
| 884 |
+
yield (
|
| 885 |
+
_denial_caution_md(denial),
|
| 886 |
+
gr.update(value=None, visible=False),
|
| 887 |
+
*_CLEAR_PLOTS,
|
| 888 |
+
gr.update(visible=False),
|
| 889 |
+
js,
|
| 890 |
+
)
|
| 891 |
return
|
| 892 |
+
# The clearing frame blanks the summary table and the download file for the same reason it
|
| 893 |
+
# blanks the charts: leaving the previous cohort's numbers on screen under the new cohort's
|
| 894 |
+
# caution is a wrong answer, and a stale download link is the worst version of it β the file
|
| 895 |
+
# would leave the page and be read later with no indication which query produced it. Values
|
| 896 |
+
# only, never visibility (see `_CLEAR_PLOTS`).
|
| 897 |
+
yield (gr.update(), gr.update(value=None), *_CLEAR_PLOTS, gr.update(value=None), gr.update())
|
| 898 |
result = _safe_result(lambda: _query_variant_status(list(genes) or list(PANEL), source))
|
| 899 |
record_run("variant_status", username=username, source=src, result=result)
|
| 900 |
+
yield (
|
| 901 |
+
_status_caution_md(result),
|
| 902 |
+
_summary_table(result),
|
| 903 |
+
*_frequency_plots(result),
|
| 904 |
+
_full_result_file(result, _safe_source_stem(result)),
|
| 905 |
+
# Abridged for the screen only β the button above serves the payload in full.
|
| 906 |
+
_ui_json(result),
|
| 907 |
+
)
|
| 908 |
|
| 909 |
|
| 910 |
def _ui_variant_by_subtype(
|
|
|
|
| 1036 |
# --------------------------------------------------------------------------- #
|
| 1037 |
# App
|
| 1038 |
# --------------------------------------------------------------------------- #
|
| 1039 |
+
# The height cap on the raw-JSON box, in CSS rather than in `gr.Code(max_lines=...)`.
|
| 1040 |
+
#
|
| 1041 |
+
# `max_lines` is NOT honoured by gradio 6.18's CodeMirror editor β measured in the browser, a
|
| 1042 |
+
# full-panel `pdac_msk_2024` answer still rendered a 23,323-pixel-tall box with `max_lines=25`
|
| 1043 |
+
# set, i.e. the box grows with the content exactly as before. The parameter is kept below as a
|
| 1044 |
+
# declaration of intent, but THIS is what enforces it. Without a cap the page height tracks the
|
| 1045 |
+
# payload, which is the whole bug: abridgement alone took the page from ~300,000px to ~25,000px,
|
| 1046 |
+
# and only this makes it constant.
|
| 1047 |
+
_CSS = """
|
| 1048 |
+
#vs-raw-json .cm-editor, #vs-raw-json .cm-scroller {
|
| 1049 |
+
max-height: 460px;
|
| 1050 |
+
overflow: auto;
|
| 1051 |
+
}
|
| 1052 |
+
"""
|
| 1053 |
+
|
| 1054 |
+
with gr.Blocks(title="PDAC Genomics Agent", css=_CSS) as demo:
|
| 1055 |
gr.Markdown(
|
| 1056 |
"# PDAC Genomics Agent\n"
|
| 1057 |
"Somatic **mutation**, **copy-number** and **structural-variant (fusion)** status over a "
|
|
|
|
| 1090 |
)
|
| 1091 |
vs_button = gr.Button("Query variant status", variant="primary")
|
| 1092 |
vs_caution = gr.Markdown(visible=False)
|
| 1093 |
+
# Progressive disclosure, widest first: summary table -> charts -> full payload on
|
| 1094 |
+
# request -> abridged JSON. The table is deliberately ABOVE the charts β it carries the
|
| 1095 |
+
# fractions the bars are drawn from, and it is the only tier that can say "not on DNA
|
| 1096 |
+
# panel" rather than drawing nothing.
|
| 1097 |
+
vs_summary = gr.Dataframe(
|
| 1098 |
+
label="Alteration frequency by gene (cell = % altered, and the fraction it came from)",
|
| 1099 |
+
interactive=False,
|
| 1100 |
+
wrap=True,
|
| 1101 |
+
# Bounded height: 36 panel genes scroll INSIDE the table rather than lengthening the
|
| 1102 |
+
# page. The whole point of this change is that page height stops tracking data size.
|
| 1103 |
+
max_height=420,
|
| 1104 |
+
visible=False,
|
| 1105 |
+
)
|
| 1106 |
# One chart per modality β see `_frequency_plots`. Never a single stacked chart: the
|
| 1107 |
# modalities have different denominators and overlapping membership, so a stack is not
|
| 1108 |
# a quantity. Each starts hidden and is shown only when the answer contains it.
|
| 1109 |
vs_plots = [gr.BarPlot(visible=False) for _ in _MODALITY_ORDER]
|
| 1110 |
+
# The complete, unabridged payload β byte for byte what the machine endpoint returns.
|
| 1111 |
+
# It sits OUTSIDE the accordion, above it, so the abridged preview can never be mistaken
|
| 1112 |
+
# for the whole answer.
|
| 1113 |
+
vs_download = gr.DownloadButton("Download full result (JSON)", visible=False)
|
| 1114 |
+
with gr.Accordion("Raw result (JSON β abridged preview)", open=False):
|
| 1115 |
+
gr.Markdown(
|
| 1116 |
+
"Long sample-ID lists and per-sample maps are shown as a sentinel giving their "
|
| 1117 |
+
"exact size and first few entries, so this stays readable: a full-panel query on "
|
| 1118 |
+
"a 2,336-sample cohort is ~21,000 lines of JSON. **Nothing is withheld** β use "
|
| 1119 |
+
"*Download full result (JSON)* above for the complete payload."
|
| 1120 |
+
)
|
| 1121 |
+
# Height is bounded by `_CSS` via this elem_id, NOT by `max_lines` β see the note on
|
| 1122 |
+
# `_CSS`, which gradio 6.18 ignores here. Between them: the abridgement keeps what
|
| 1123 |
+
# you scroll through comprehensible, and the cap keeps the PAGE finite.
|
| 1124 |
+
vs_json = gr.Code(
|
| 1125 |
+
label="Result", language="json", lines=20, max_lines=25, elem_id="vs-raw-json"
|
| 1126 |
+
)
|
| 1127 |
vs_button.click(
|
| 1128 |
_ui_variant_status,
|
| 1129 |
inputs=[vs_source, vs_genes],
|
| 1130 |
+
outputs=[vs_caution, vs_summary, *vs_plots, vs_download, vs_json],
|
| 1131 |
api_name=False,
|
| 1132 |
)
|
| 1133 |
|
|
@@ -186,11 +186,23 @@ def test_variant_status_handler_clears_the_plot_before_answering():
|
|
| 186 |
assert len(frames) == 2, "expected a clearing frame followed by the answer"
|
| 187 |
clearing, answer = frames
|
| 188 |
|
| 189 |
-
# The clearing frame blanks each
|
| 190 |
# instead made every query a hideβshow flip, and the third chart lost that race about half
|
| 191 |
# the time β it stayed unmounted ("null does not exist" in the browser), so a cohort WITH
|
| 192 |
# structural variants rendered as mutation+CNV only, reading as "no fusions here".
|
| 193 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 194 |
assert any(getattr(u, "value", None) is not None for u in answer[1:-1])
|
| 195 |
|
| 196 |
|
|
|
|
| 186 |
assert len(frames) == 2, "expected a clearing frame followed by the answer"
|
| 187 |
clearing, answer = frames
|
| 188 |
|
| 189 |
+
# The clearing frame blanks each result slot's VALUE and leaves visibility alone. Hiding here
|
| 190 |
# instead made every query a hideβshow flip, and the third chart lost that race about half
|
| 191 |
# the time β it stayed unmounted ("null does not exist" in the browser), so a cohort WITH
|
| 192 |
# structural variants rendered as mutation+CNV only, reading as "no fusions here".
|
| 193 |
+
#
|
| 194 |
+
# Two spellings of "blanked", because the slots are no longer all charts: a chart clears to a
|
| 195 |
+
# bare `None`, while the summary table and the full-result download clear with an explicit
|
| 196 |
+
# `gr.update(value=None)`. The invariant is the same for all of them and is what this asserts
|
| 197 |
+
# β carry no value, and say nothing about visibility. The download slot matters most here: a
|
| 198 |
+
# stale file link would leave the page entirely and be opened later with nothing to say which
|
| 199 |
+
# query produced it.
|
| 200 |
+
for slot in clearing[1:-1]:
|
| 201 |
+
if slot is None:
|
| 202 |
+
continue
|
| 203 |
+
assert isinstance(slot, dict), f"unexpected clearing-frame slot: {slot!r}"
|
| 204 |
+
assert slot.get("value") is None, "clearing frame must carry no value"
|
| 205 |
+
assert "visible" not in slot, "clearing frame must blank, not hide"
|
| 206 |
assert any(getattr(u, "value", None) is not None for u in answer[1:-1])
|
| 207 |
|
| 208 |
|
|
@@ -0,0 +1,173 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""The "Raw result (JSON)" accordion must stay readable β WITHOUT trimming the payload.
|
| 2 |
+
|
| 3 |
+
A full-panel `pdac_msk_2024` answer is ~21,000 lines / ~800 kB, and rendering it verbatim
|
| 4 |
+
produced a roughly 300,000-pixel page (found in the 2026-08-05 signed-in click-through). The
|
| 5 |
+
size is load-bearing, not waste:
|
| 6 |
+
|
| 7 |
+
* `samples_profiled` is the per-modality profiled universe as an ID SET, and the ONLY way a
|
| 8 |
+
machine caller can reconstruct wild-type from the sparse `per_sample`
|
| 9 |
+
(`deploy/orchestrator_registration.yaml`, `per_sample_contract`). Dropping it is what made
|
| 10 |
+
the orchestrator's join report every gene as "no variation in alteration status".
|
| 11 |
+
* `per_sample` / `provenance` are the per-sample answer itself.
|
| 12 |
+
|
| 13 |
+
So this is a RENDERING split, and these tests pin both halves of it: the machine endpoint still
|
| 14 |
+
emits the payload in full, and the UI's view is abridged, bounded, and self-describing. A change
|
| 15 |
+
that fixes the page by shortening the payload fails here.
|
| 16 |
+
"""
|
| 17 |
+
|
| 18 |
+
from __future__ import annotations
|
| 19 |
+
|
| 20 |
+
import json
|
| 21 |
+
|
| 22 |
+
import pytest
|
| 23 |
+
|
| 24 |
+
import gradio_ui
|
| 25 |
+
from src.tools.query_variant_status import query_variant_status
|
| 26 |
+
|
| 27 |
+
# The widest answer this deployment can produce: full panel over the largest cohort.
|
| 28 |
+
BIG_SOURCE = "cbioportal:pdac_msk_2024"
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
@pytest.fixture(scope="module")
|
| 32 |
+
def big_result():
|
| 33 |
+
return query_variant_status([], BIG_SOURCE)
|
| 34 |
+
|
| 35 |
+
|
| 36 |
+
# --------------------------------------------------------------------------- #
|
| 37 |
+
# The payload contract is untouched
|
| 38 |
+
# --------------------------------------------------------------------------- #
|
| 39 |
+
def test_full_payload_keeps_every_profiled_sample_id(big_result):
|
| 40 |
+
"""`samples_profiled` stays a complete ID list β the orchestrator derives WT from it."""
|
| 41 |
+
for modality, ids in big_result["samples_profiled"].items():
|
| 42 |
+
assert isinstance(ids, list)
|
| 43 |
+
assert len(ids) == big_result["n_profiled"][modality]
|
| 44 |
+
assert all(isinstance(i, str) for i in ids)
|
| 45 |
+
|
| 46 |
+
|
| 47 |
+
def test_machine_json_is_the_unabridged_payload(big_result):
|
| 48 |
+
"""`_as_json` β what every `gr.api` endpoint returns β carries the payload verbatim."""
|
| 49 |
+
body = json.loads(gradio_ui._as_json(big_result))
|
| 50 |
+
assert body == json.loads(json.dumps(big_result, default=str))
|
| 51 |
+
assert len(body["samples_profiled"]["mutation"]) == 2336
|
| 52 |
+
|
| 53 |
+
|
| 54 |
+
# --------------------------------------------------------------------------- #
|
| 55 |
+
# The UI view is abridged, bounded, and honest about it
|
| 56 |
+
# --------------------------------------------------------------------------- #
|
| 57 |
+
def test_ui_json_is_dramatically_smaller_than_the_payload(big_result):
|
| 58 |
+
full = gradio_ui._as_json(big_result).splitlines()
|
| 59 |
+
ui = gradio_ui._ui_json(big_result).splitlines()
|
| 60 |
+
assert len(full) > 20_000, "fixture no longer reproduces the size that caused the bug"
|
| 61 |
+
# Bounded by the PANEL (36 genes x 3 modalities), not by the cohort (2,336 samples) β that
|
| 62 |
+
# decoupling is the actual fix. The page can no longer grow with cohort size.
|
| 63 |
+
assert len(ui) < 2_500
|
| 64 |
+
assert len(ui) < len(full) / 8
|
| 65 |
+
|
| 66 |
+
|
| 67 |
+
def test_ui_json_replaces_big_collections_with_a_counted_sentinel(big_result):
|
| 68 |
+
"""An abridged block states its TRUE size and is shaped so it cannot be misread as the data."""
|
| 69 |
+
body = json.loads(gradio_ui._ui_json(big_result))
|
| 70 |
+
|
| 71 |
+
profiled = body["samples_profiled"]["mutation"]
|
| 72 |
+
assert isinstance(profiled, dict), "a truncated list would still look like a list"
|
| 73 |
+
assert profiled["n_items"] == 2336 == big_result["n_profiled"]["mutation"]
|
| 74 |
+
assert len(profiled["first"]) == gradio_ui._ABRIDGE_PREVIEW
|
| 75 |
+
|
| 76 |
+
per_sample = body["genes"]["KRAS"]["mutation"]["per_sample"]
|
| 77 |
+
assert per_sample["n_entries"] == len(big_result["genes"]["KRAS"]["mutation"]["per_sample"])
|
| 78 |
+
|
| 79 |
+
|
| 80 |
+
def test_ui_json_says_it_is_abridged_and_where_the_full_thing_is(big_result):
|
| 81 |
+
body = json.loads(gradio_ui._ui_json(big_result))
|
| 82 |
+
note = body["__display__"]
|
| 83 |
+
assert "ABRIDGED" in note
|
| 84 |
+
assert "Download full result" in note
|
| 85 |
+
|
| 86 |
+
|
| 87 |
+
def test_frequencies_and_denominators_survive_abridgement(big_result):
|
| 88 |
+
"""Only collections are abridged. Every NUMBER a reader might act on is untouched."""
|
| 89 |
+
body = json.loads(gradio_ui._ui_json(big_result))
|
| 90 |
+
for gene, entry in big_result["genes"].items():
|
| 91 |
+
for modality in ("mutation", "cnv", "sv"):
|
| 92 |
+
block = entry.get(modality)
|
| 93 |
+
if not isinstance(block, dict) or "frequency" not in block:
|
| 94 |
+
continue
|
| 95 |
+
shown = body["genes"][gene][modality]
|
| 96 |
+
assert shown["frequency"] == block["frequency"]
|
| 97 |
+
assert shown["n_altered"] == block["n_altered"]
|
| 98 |
+
assert shown["n_profiled"] == block["n_profiled"]
|
| 99 |
+
|
| 100 |
+
|
| 101 |
+
@pytest.mark.parametrize(
|
| 102 |
+
"payload",
|
| 103 |
+
[
|
| 104 |
+
{"status": "denied", "reason": "Please sign in."},
|
| 105 |
+
{"status": "refused", "reason": "non_human_species", "message": "not human"},
|
| 106 |
+
{"status": "error", "reason": "RuntimeError: cBioPortal is down"},
|
| 107 |
+
{"source": "cbioportal:paad_tcga", "n_samples": 3, "genes": {}},
|
| 108 |
+
],
|
| 109 |
+
)
|
| 110 |
+
def test_small_payloads_pass_through_verbatim(payload):
|
| 111 |
+
"""A denial/refusal/error is never rewritten β the visibility guarantees rest on its shape."""
|
| 112 |
+
assert json.loads(gradio_ui._ui_json(payload)) == payload
|
| 113 |
+
|
| 114 |
+
|
| 115 |
+
# --------------------------------------------------------------------------- #
|
| 116 |
+
# The summary table β the tier a non-coding scientist actually reads
|
| 117 |
+
# --------------------------------------------------------------------------- #
|
| 118 |
+
def test_summary_table_has_one_row_per_requested_gene(big_result):
|
| 119 |
+
frame = gradio_ui._summary_frame(big_result)
|
| 120 |
+
assert len(frame) == len(big_result["genes"])
|
| 121 |
+
assert list(frame["gene"]) == list(big_result["genes"])
|
| 122 |
+
|
| 123 |
+
|
| 124 |
+
def test_summary_table_never_pads_an_unassayed_gene_to_zero(big_result):
|
| 125 |
+
"""The regression this repo has already fixed twice: a gene nobody sequenced is not 0%.
|
| 126 |
+
|
| 127 |
+
ELAVL1 is off `pdac_msk_2024`'s DNA panel and has no fusion events, so every one of its
|
| 128 |
+
cells must NAME the absence rather than show a percentage.
|
| 129 |
+
"""
|
| 130 |
+
frame = gradio_ui._summary_frame(big_result)
|
| 131 |
+
row = frame[frame["gene"] == "ELAVL1"].iloc[0]
|
| 132 |
+
for modality in gradio_ui._MODALITY_ORDER:
|
| 133 |
+
cell = row[gradio_ui._MODALITY_LABELS[modality]]
|
| 134 |
+
assert "%" not in cell
|
| 135 |
+
assert cell == gradio_ui._CELL_NOT_ON_PANEL
|
| 136 |
+
assert "not a zero" in row[gradio_ui._SUMMARY_NOTE_COL]
|
| 137 |
+
|
| 138 |
+
|
| 139 |
+
def test_summary_table_honours_the_modality_scoped_gate(big_result):
|
| 140 |
+
"""NRG1 (ADR-0008): off the DNA panel, but 6 real somatic rearrangements β SV shows,
|
| 141 |
+
mutation and CNV do not."""
|
| 142 |
+
frame = gradio_ui._summary_frame(big_result)
|
| 143 |
+
row = frame[frame["gene"] == "NRG1"].iloc[0]
|
| 144 |
+
assert row[gradio_ui._MODALITY_LABELS["mutation"]] == gradio_ui._CELL_NOT_ON_PANEL
|
| 145 |
+
assert row[gradio_ui._MODALITY_LABELS["cnv"]] == gradio_ui._CELL_NOT_ON_PANEL
|
| 146 |
+
sv = row[gradio_ui._MODALITY_LABELS["sv"]]
|
| 147 |
+
assert "%" in sv and "6 / 2336" in sv
|
| 148 |
+
assert "SV only" in row[gradio_ui._SUMMARY_NOTE_COL]
|
| 149 |
+
|
| 150 |
+
|
| 151 |
+
def test_summary_cell_states_the_fraction_not_just_the_percentage(big_result):
|
| 152 |
+
""""93.7%" alone hides the denominator, and the denominator is the thing people get wrong."""
|
| 153 |
+
frame = gradio_ui._summary_frame(big_result)
|
| 154 |
+
kras = frame[frame["gene"] == "KRAS"].iloc[0][gradio_ui._MODALITY_LABELS["mutation"]]
|
| 155 |
+
assert "93.7%" in kras
|
| 156 |
+
assert "2188 / 2336" in kras
|
| 157 |
+
|
| 158 |
+
|
| 159 |
+
def test_summary_table_hidden_when_there_is_nothing_to_show():
|
| 160 |
+
for empty in ({"genes": {}}, {"status": "denied"}, None):
|
| 161 |
+
assert gradio_ui._summary_table(empty)["visible"] is False
|
| 162 |
+
|
| 163 |
+
|
| 164 |
+
# --------------------------------------------------------------------------- #
|
| 165 |
+
# The download β the abridgement is only honest if the full thing is one click away
|
| 166 |
+
# --------------------------------------------------------------------------- #
|
| 167 |
+
def test_download_file_holds_the_complete_payload(big_result):
|
| 168 |
+
update = gradio_ui._full_result_file(big_result, gradio_ui._safe_source_stem(big_result))
|
| 169 |
+
assert update["visible"] is True
|
| 170 |
+
with open(update["value"], encoding="utf-8") as fh:
|
| 171 |
+
written = json.load(fh)
|
| 172 |
+
assert written == json.loads(json.dumps(big_result, default=str))
|
| 173 |
+
assert len(written["samples_profiled"]["cnv"]) == 2336
|