feat(uploads): ADR-0011 manual-upload safety gate (Now slice)

#2
This view is limited to 50 files because it contains too many changes. See the raw diff here.
Files changed (50) hide show
  1. .dockerignore +0 -37
  2. .gitattributes +0 -9
  3. .github/dependabot.yml +0 -36
  4. .github/workflows/security.yml +0 -61
  5. .gitignore +0 -12
  6. .gitleaks.toml +0 -50
  7. .pre-commit-config.yaml +0 -23
  8. CLAUDE.md +210 -0
  9. Makefile +0 -28
  10. README.md +0 -39
  11. TODO.md +486 -0
  12. app.py +7 -20
  13. bandit.yaml +0 -21
  14. deploy/scan_posture.yaml +0 -43
  15. docker/sandbox.Dockerfile +0 -73
  16. docs/adr/ADR-0003-spaces-dev-mode.md +5 -5
  17. docs/adr/ADR-0005-private-storage-persistence.md +5 -5
  18. docs/adr/ADR-0006-loveless-single-cell-serving.md +3 -3
  19. docs/adr/ADR-0007-phase1-local-validation.md +0 -147
  20. docs/adr/ADR-0007-phase2-local-hardening.md +0 -122
  21. docs/adr/ADR-0007-sandboxed-code-execution.md +0 -157
  22. docs/adr/ADR-0010-dataset-integrity-verification.md +4 -33
  23. docs/adr/ADR-0011-upload-safety-gate.md +10 -86
  24. docs/adr/ADR-0012-authentication-access-control.md +0 -164
  25. docs/adr/ADR-0013-audit-trace-redaction.md +0 -125
  26. docs/adr/ADR-0014-ci-security-scanning.md +0 -150
  27. gradio_ui.py +245 -901
  28. memory.md +0 -0
  29. pdac-analysis-orchestrator-dev +1 -0
  30. prompts.yaml +3 -3
  31. pytest.ini +0 -11
  32. requirements.in +1 -20
  33. requirements.txt +8 -18
  34. resources/mouse_ensembl_symbol_map.tsv.gz +0 -3
  35. ruff.toml +0 -31
  36. scripts/_gse205154_sears_common.py +8 -11
  37. scripts/_investigate_gpl6244.py +1 -1
  38. scripts/_precompute_common.py +51 -79
  39. scripts/_verify_precompute.py +1 -10
  40. scripts/_vst_transform.R +0 -32
  41. scripts/analyze_post_analysis_reads.py +0 -251
  42. scripts/assemble_cptac_pda.py +12 -24
  43. scripts/assemble_cptac_pda_counts.py +15 -48
  44. scripts/assemble_gse15471.py +4 -7
  45. scripts/assemble_gse16515_mayo.py +6 -20
  46. scripts/assemble_gse17891.py +5 -7
  47. scripts/assemble_gse205154_sears.py +0 -1
  48. scripts/assemble_gse205154_sears_counts.py +0 -1
  49. scripts/assemble_gse205154_sears_filtered_tmm.py +0 -75
  50. scripts/assemble_gse205154_sears_tmm.py +0 -1
.dockerignore DELETED
@@ -1,37 +0,0 @@
1
- # ADR-0007 sandbox image build context trimming.
2
- # The sandbox Dockerfile only COPYs `src/` and `requirements.txt`; everything
3
- # else in the repo (multi-GB venvs, git history, local audit tooling, caches)
4
- # would otherwise be streamed to the Docker daemon on every build. Excluded here
5
- # so `docker build -f docker/sandbox.Dockerfile .` sends a small context.
6
-
7
- # Virtualenvs / local interpreters
8
- .venv/
9
- venv/
10
- security/.audit-venv/
11
-
12
- # VCS + tooling metadata
13
- .git/
14
- .github/
15
- .claude/
16
-
17
- # Local security-audit tooling (not needed in the sandbox image)
18
- security/
19
-
20
- # Python caches / build artifacts
21
- **/__pycache__/
22
- **/*.pyc
23
- **/*.pyo
24
- .pytest_cache/
25
- *.egg-info/
26
-
27
- # Runtime scratch / caches / generated data that must not ship in the image
28
- src/tmp/
29
- **/geo_cache/
30
- *.h5ad
31
- *.log
32
-
33
- # Docs, tests, notebooks — not part of the runtime image
34
- docs/
35
- tests/
36
- scripts/
37
- *.ipynb
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
.gitattributes CHANGED
@@ -33,12 +33,3 @@ saved_model/**/* filter=lfs diff=lfs merge=lfs -text
33
  *.zip filter=lfs diff=lfs merge=lfs -text
34
  *.zst filter=lfs diff=lfs merge=lfs -text
35
  *tfevents* filter=lfs diff=lfs merge=lfs -text
36
-
37
- # --- Multi-agent concurrency -------------------------------------------------
38
- # Append-style bookkeeping files. Several agents write these in parallel from
39
- # separate worktrees; union merge keeps BOTH sides of a conflicting hunk rather
40
- # than halting, so no lane can resolve a conflict by discarding another lane.
41
- # Cost: occasional duplicate lines to tidy. Never set this on code or lockfiles.
42
- memory.md merge=union
43
- TODO.md merge=union
44
- REGISTRY_TODO_PLANS.md merge=union
 
33
  *.zip filter=lfs diff=lfs merge=lfs -text
34
  *.zst filter=lfs diff=lfs merge=lfs -text
35
  *tfevents* filter=lfs diff=lfs merge=lfs -text
 
 
 
 
 
 
 
 
 
.github/dependabot.yml DELETED
@@ -1,36 +0,0 @@
1
- # ADR-0014 — automated dependency bump PRs. Active only when this repo is
2
- # mirrored to GitHub (see .github/workflows/security.yml for the deploy-model
3
- # note); the HF-only path relies on the weekly pip-audit run to surface stale,
4
- # vulnerable pins instead.
5
- version: 2
6
- updates:
7
- - package-ecosystem: pip
8
- directory: "/"
9
- schedule:
10
- interval: weekly
11
- open-pull-requests-limit: 5
12
- # gradio and mcp are pinned to the HF Space sdk_version and must not be
13
- # bumped by an automated PR — bumping them requires a coordinated Space
14
- # rebuild. Security advisories still surface via pip-audit.
15
- ignore:
16
- - dependency-name: gradio
17
- - dependency-name: mcp
18
-
19
- - package-ecosystem: github-actions
20
- directory: "/"
21
- schedule:
22
- interval: weekly
23
-
24
- - package-ecosystem: docker
25
- directory: "/docker"
26
- schedule:
27
- interval: weekly
28
- # The sandbox base image (ADR-0007) tracks a specific Python line; a major/
29
- # minor jump (e.g. 3.11 -> 3.14, PR #3, closed 2026-07-08) can break the
30
- # rpy2/scanpy/decoupler stack and is not validated by the Dockerfile scan.
31
- # Allow patch bumps (security) but not major/minor without a manual build test.
32
- ignore:
33
- - dependency-name: python
34
- update-types:
35
- - "version-update:semver-major"
36
- - "version-update:semver-minor"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
.github/workflows/security.yml DELETED
@@ -1,61 +0,0 @@
1
- # ADR-0014 — CI security scan (dependencies, static analysis, secrets, image).
2
- #
3
- # NOTE ON THIS REPO'S DEPLOY MODEL: DecoupleRpy_Agent's `origin` is the
4
- # HuggingFace Space (git push builds the Space); HuggingFace does not run GitHub
5
- # Actions. This workflow therefore executes ONLY if the repo is also mirrored to
6
- # GitHub (or `biodata-registry`, which reuses this same file). In the HF-only
7
- # path the enforced check is the pre-push hook (`make install-hooks`) running the
8
- # identical scripts/security_scan.sh, plus the scheduled unattended run. Keeping
9
- # the workflow here means adding a GitHub mirror is zero extra work and the scan
10
- # definition never diverges between the two paths.
11
- name: security
12
-
13
- on:
14
- pull_request:
15
- push:
16
- branches: [main]
17
- schedule:
18
- - cron: "0 6 * * 1" # weekly, Monday 06:00 UTC — catches newly disclosed CVEs
19
- workflow_dispatch:
20
-
21
- permissions:
22
- contents: read
23
-
24
- jobs:
25
- scan:
26
- runs-on: ubuntu-latest
27
- steps:
28
- - uses: actions/checkout@v7
29
- with:
30
- fetch-depth: 0 # gitleaks needs full history
31
-
32
- - uses: actions/setup-python@v6
33
- with:
34
- python-version: "3.12"
35
-
36
- - name: Install scanners
37
- run: |
38
- python -m pip install --upgrade pip
39
- # uv drives the CycloneDX SBOM stage; without it that stage SKIPs, which
40
- # is a failure under SECURITY_SCAN_STRICT=1.
41
- pip install pip-audit bandit uv
42
- # gitleaks + trivy via their official installers
43
- curl -sSfL https://raw.githubusercontent.com/aquasecurity/trivy/main/contrib/install.sh | sh -s -- -b "$HOME/.local/bin"
44
- curl -sSfL https://github.com/gitleaks/gitleaks/releases/download/v8.30.1/gitleaks_8.30.1_linux_x64.tar.gz | tar -xz -C "$HOME/.local/bin" gitleaks
45
- echo "$HOME/.local/bin" >> "$GITHUB_PATH"
46
-
47
- - name: Run shared security scan
48
- env:
49
- SECURITY_SCAN_STRICT: "1" # in CI every scanner is present; a skip is a bug
50
- # The GitHub runner has no R; rpy2 in API mode refuses to build without it,
51
- # which breaks pip-audit's dependency resolve. ABI mode builds without R.
52
- RPY2_CFFI_MODE: ABI
53
- run: make security-scan
54
-
55
- - name: Upload scan artifacts
56
- if: always()
57
- uses: actions/upload-artifact@v7
58
- with:
59
- name: security-scan-${{ github.run_id }}
60
- path: security/
61
- retention-days: 90
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
.gitignore CHANGED
@@ -1,9 +1,5 @@
1
  .DS_Store
2
  .claude/
3
- # Local secrets — never commit (matches the other repos)
4
- .env
5
- .env.*
6
- !.env.example
7
  .venv/
8
  venv/
9
  __pycache__/
@@ -18,11 +14,3 @@ reports/
18
  upload_staging/
19
  upload_registered/
20
  run_logs/
21
- # ADR-0014 security scan — cached pip-audit venv (artifacts under security/ are
22
- # committed; the venv is not)
23
- security/.audit-venv/
24
- CLAUDE.md
25
- memory.md
26
- TODO.md
27
- docs/tasks/
28
- session_export.docx
 
1
  .DS_Store
2
  .claude/
 
 
 
 
3
  .venv/
4
  venv/
5
  __pycache__/
 
14
  upload_staging/
15
  upload_registered/
16
  run_logs/
 
 
 
 
 
 
 
 
.gitleaks.toml DELETED
@@ -1,50 +0,0 @@
1
- # Gitleaks config — ADR-0014 secret scan (shared across the three repos).
2
- #
3
- # Extends the upstream default ruleset (do not replace it) and adds allowlists
4
- # for this repo's known-safe matches: placeholder tokens in docs/tests, the
5
- # committed security scan artifacts, vendored virtualenvs, and two triaged false
6
- # positives. Real secrets (HF write tokens, the ADR-0012 service token) must
7
- # NEVER be committed — they live in HF Space secrets. This scan runs over the
8
- # working tree AND full git history (`gitleaks detect`), because tokens have
9
- # flowed through these repos.
10
- #
11
- # Run: gitleaks detect --config .gitleaks.toml --redact --no-banner
12
-
13
- [extend]
14
- useDefault = true
15
-
16
- # Known-safe paths and placeholder strings.
17
- [[allowlists]]
18
- description = "known-safe paths and placeholder tokens"
19
- paths = [
20
- '''\.venv/''',
21
- '''(^|/)node_modules/''',
22
- '''security/sbom\.json''',
23
- '''security/pip-audit-.*\.(json|txt)''',
24
- '''\.gitleaks\.toml''',
25
- ]
26
- # Documentation and ADRs reference token *names* (research_agent_token,
27
- # ANTHROPIC_API_KEY) as identifiers, never their values.
28
- regexes = [
29
- '''research_agent_token''',
30
- '''ANTHROPIC_API_KEY''',
31
- '''(?i)your[-_]?(hf|api|anthropic)[-_]?(token|key)[-_]?here''',
32
- '''(?i)example[-_]?(token|key|secret)''',
33
- '''xxx+|placeholder|dummy|fake[-_]?(token|key|secret)''',
34
- ]
35
- stopwords = ["example", "placeholder", "changeme"]
36
-
37
- # --- False positives triaged 2026-07-02 (ADR-0014 first run) ---------------- #
38
- # The generic-api-key entropy rule fires on long snake_case keyword arguments in
39
- # the precompute build scripts (`gene_symbol_column=...`, `assignment_column=...`).
40
- # These are column names, not secrets. Matched against the finding text so a
41
- # genuinely new secret in the same file still surfaces.
42
- [[allowlists]]
43
- description = "column-name kwargs in precompute scripts (not secrets)"
44
- regexTarget = "match"
45
- regexes = ['''(gene_symbol|assignment)_column\s*=''']
46
-
47
- # Historical gcp-api-key in app.py (commit 155d8d7) was scrubbed from git history
48
- # on 2026-08-18 (value replaced with **REMOVED-KEY** in all blobs); the key was
49
- # triaged 2026-07-02 as non-functional. No value allowlist needed anymore.
50
-
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
.pre-commit-config.yaml DELETED
@@ -1,23 +0,0 @@
1
- # Pre-commit hooks — shared lint/format baseline across the PDAC-system repos.
2
- # Install once per clone: pip install pre-commit && pre-commit install
3
- # Run on all files: pre-commit run --all-files
4
- #
5
- # NOTE: this is separate from the ADR-0014 *security* pre-push hook
6
- # (scripts/hooks/pre-push, installed via `make install-hooks`). This one runs
7
- # ruff at commit time for style/correctness; that one runs the security scan at
8
- # push time.
9
- repos:
10
- - repo: https://github.com/astral-sh/ruff-pre-commit
11
- rev: v0.15.20
12
- hooks:
13
- - id: ruff # lint (uses ruff.toml / [tool.ruff]); --fix applies safe fixes
14
- args: [--fix]
15
- - id: ruff-format # formatter (line-length + quote style from config)
16
- - repo: https://github.com/pre-commit/pre-commit-hooks
17
- rev: v5.0.0
18
- hooks:
19
- - id: end-of-file-fixer
20
- - id: trailing-whitespace
21
- - id: check-yaml
22
- - id: check-added-large-files
23
- args: [--maxkb=2048]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
CLAUDE.md ADDED
@@ -0,0 +1,210 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # DecoupleRpy Agent — Project Context
2
+
3
+ This file provides stable architectural context for Claude Code sessions.
4
+ **Do not encode current status here** — that belongs in `memory.md`.
5
+ Update this file when architectural decisions change, not when code changes.
6
+
7
+ ---
8
+
9
+ ## Memory maintenance (after every commit)
10
+
11
+ These status files drift unless updated at commit time. After any `git commit` in this
12
+ repo — bound for prod (`origin`, the HF Space) or dev (`hf-dev`) — update whatever that
13
+ commit changed, and skip what it didn't:
14
+
15
+ - `memory.md` — current status / what just changed
16
+ - `TODO.md` — move finished items to Done, add anything new
17
+ - `/Users/annivoigt/Documents/GitHub/SHOWCASE_STATUS.md` — the cross-repo rollup; update especially on a prod/dev deploy
18
+ - `CLAUDE.md` (this file) — only when the architecture itself changes (rare)
19
+
20
+ A PostToolUse hook (`~/.claude/hooks/remind-memory-sync.py`, wired in
21
+ `~/.claude/settings.json`) prints this checklist automatically after each commit. It only
22
+ *reminds* — the edits are still done by hand.
23
+
24
+ ---
25
+
26
+ ## What This Project Is
27
+
28
+ A **Paper2Agent conversion** of the [scverse/decoupler-py](https://github.com/scverse/decoupler-py) bioinformatics library. The goal is to expose decoupler's computational biology methods as MCP tools so non-coding scientists at the Brenden-Colson Center for Pancreatic Care (OHSU, Dr. Rosalie Sears lab) can run analyses via natural language.
29
+
30
+ This repo is the **specialist agent** — it does the computation. It is called by a separate orchestrator (`research-coordinator`, deployed at `anne-voigt/research_coordinator` on HuggingFace).
31
+
32
+ ---
33
+
34
+ ## Two-Tier Architecture
35
+
36
+ ```
37
+ User
38
+ └── Research Coordinator (HF Space: anne-voigt/research_coordinator)
39
+ ├── Claude API — conceptual/interpretive questions (direct)
40
+ └── gradio_client → DecoupleRpy Agent (HF Space: anne-voigt/Paper2Agent_decoupleRpy)
41
+ └── MCP tools (this repo)
42
+ ```
43
+
44
+ The coordinator classifies every user message and routes to the specialist for anything involving computation, datasets, or capability questions. The coordinator does NOT know what datasets are registered — it always asks the specialist.
45
+
46
+ ---
47
+
48
+ ## MCP Tool Layers
49
+
50
+ Tools are organized in three layers:
51
+
52
+ **Layer 1 — Tutorial tools** (`src/tools/`): Direct Paper2Agent output, one file per tutorial.
53
+ - `rna.py` — bulk RNA analysis (DE, TF enrichment, pathway scoring, GEO loading)
54
+ - `rna_sc.py` — single-cell RNA analysis
55
+ - `rna_visium.py` — spatial transcriptomics (Visium)
56
+ - `rna_pstime.py` — pseudotime analysis
57
+ - `orthologs.py` — cross-species gene symbol translation
58
+ - `dataset_tools.py` — dataset registry MCP tools (incl. `dataset_get_integration_plan`, the cross-dataset early/late/refuse planner)
59
+ - `bulk_dataset_tools.py` — bulk-specific dataset operations
60
+ - `integration_tools.py` — cross-dataset integration (`integration_mcp` sub-server): `decoupler_meta_analyze` (Mode B late integration) and `decoupler_normalization_concordance` (same-cohort sibling-variant sensitivity check — descriptive agreement, no combine; routed to by plan `mode="concordance"`)
61
+
62
+ **Layer 2 — Generic workflows** (`src/workflows/`): Reusable logic called by Layer 1.
63
+ - `geo.py` — GEO series matrix loading
64
+ - `microarray.py` — probe collapse, data type detection
65
+ - `activity_scoring.py` — TF/pathway activity inference
66
+ - `activity_stats.py` — group comparisons on activity scores
67
+ - `manifest_data_validation.py` — validates manifest semantics against loaded data
68
+ - `metadata_validation.py` — metadata column checks
69
+ - `survival.py` — survival analysis
70
+ - `meta_analysis.py` — cross-dataset meta-analysis engine (`src/core/combine.py` result-envelope contract + field-based strategy dispatch: stouffer / inverse_variance / fisher; Cochran's Q + I²)
71
+ - `concordance.py` — same-cohort normalization sensitivity engine: descriptive agreement across sibling-variant result envelopes (pairwise Pearson/Spearman on the shared effect, sign-concordance, effect spread, significant-call Jaccard). The non-combining counterpart to `meta_analysis.py`; backs `decoupler_normalization_concordance`.
72
+ - `sanity_checks.py` — result-aware (post-compute) sanity checks (ADR-0002); pure functions emitting non-fatal `sanity_warnings` (effect-size plausibility, tissue-identity contamination, network membership). See "Two safety layers" below.
73
+
74
+ **Layer 3 — Dataset manifests** (live in `biodata-registry` package): One YAML per dataset.
75
+ Auto-discovered at import time by the registry from the installed `biodata_registry` package.
76
+ The `src/datasets/manifests/` directory does **not** exist in this repo — `biodata-registry` is the
77
+ sole manifest source. Zero code changes needed to add a dataset — add a YAML to `biodata-registry`
78
+ and reinstall the package.
79
+
80
+ ---
81
+
82
+ ## Dataset Manifest System
83
+
84
+ Each manifest is a YAML file validated against `DatasetManifest` (see `src/datasets/manifest_schema.py`).
85
+
86
+ **Key controlled vocabularies:**
87
+ - `modality`: `bulk_microarray`, `bulk_rnaseq`, `sc_rnaseq`, `spatial_rnaseq`, `proteomics`
88
+ - `data_level`: `raw_counts`, `log_expression`, `log_ratio`, `normalized`, `tpm`, `fpkm`, `protein_abundance`
89
+ - `feature_id_type`: `probe_id`, `gene_symbol`, `ensembl_gene_id`, `entrez_id`, `protein_id`
90
+ - `expression_source.type`: `geo_series_matrix`, `geo_soft`, `url`, `gdc`, `cptac`, `local`, `h5ad` (hosted AnnData, single-cell/spatial — ADR-0006)
91
+
92
+ **Analysis path routing** (`analysis_path`, derived **modality-first, then `data_level`**):
93
+ - **Path P** — `sc_rnaseq` / `spatial_rnaseq` (checked **before** `data_level`, so an sc `raw_counts` h5ad is never mislabeled Path A) → load via `read_h5ad_cached` → pseudobulk → bulk DE / activity. **Never DESeq2/limma on per-cell counts.** (ADR-0006; aligns with biodata-registry 0.1.7's A/B/P.)
94
+ - **Path A** — bulk `raw_counts` → DESeq2
95
+ - **Path B** — bulk `log_expression` / `normalized` / etc. → limma or t-test
96
+
97
+ `_build_loading_plan` (`src/tools/dataset_tools/_base.py`) emits a single-cell plan for Path P (load + per-cell scoring + a pseudobulk note) instead of the bulk contrast tail.
98
+
99
+ **Probe collapse**: Required when `feature_id_type: probe_id` and `requires_collapse: true`. Uses GPL platform annotation downloaded from GEO.
100
+
101
+ **Anti-hallucination grounding**: The live dataset registry is injected into the agent's Jinja2 system prompt at every call via `get_system_prompt()`. The agent may ONLY claim access to datasets returned by `dataset_list_available()` — never from training knowledge.
102
+
103
+ ---
104
+
105
+ ## Agent Implementation
106
+
107
+ - **Framework**: LangGraph-based `CodeAgent` (`src/agent.py`)
108
+ - **System prompt**: Jinja2 template rendered with `functions`, `packages`, and `datasets` at call time
109
+ - **Prompt config**: `prompts.yaml` — coordinator system prompt, routing rules, available datasets section
110
+ - **Routing**: Research coordinator uses a two-path routing prompt; capability/dataset questions must route to specialist
111
+ - **MCP transport**: a single **persistent `server.py --transport http`** process is started once per
112
+ container by `GradioAgentUI.__init__` (`ensure_mcp_http_server()`), and tools are registered over HTTP
113
+ via `add_mcp_http(url)`. Every tool call reuses that resident process — it does NOT spawn a fresh
114
+ `python server.py` per call (the old stdio model, which re-imported rpy2/scanpy/decoupler, re-mounted
115
+ 11 sub-servers, and re-read the h5ad on every call: ~44s/step). **stdio is kept as a health-checked
116
+ fallback** (`add_mcp(mcp_config.yaml)`) if the HTTP server fails to bind. A process-lifetime in-memory
117
+ AnnData cache (`src/cache.read_h5ad_cached`, keyed by path+mtime+size, returns copies so it's safe
118
+ under the shared server) means each h5ad is parsed once per process, not once per tool call. Live on
119
+ dev + prod since 2026-06-29.
120
+
121
+ ---
122
+
123
+ ## Key Design Decisions
124
+
125
+ **Why manifests instead of hardcoding dataset logic in tools?**
126
+ Manifests are the single source of truth for dataset semantics. They encode refusal rules, prohibited inferences, metadata column meanings, and loading path — keeping analysis tools generic and dataset-specific knowledge in one place.
127
+
128
+ **Why route capability questions to the specialist?**
129
+ `dataset_list_available()` returns runtime truth — a manifest with a YAML error won't load even if the file exists. The specialist's answer is richer (capabilities, limitations, metadata) than what code inspection would produce. The coordinator never answers dataset questions from its own knowledge.
130
+
131
+ **Why DESeq2 for raw counts, limma/ttest for log-normalized?**
132
+ DESeq2 expects integer counts and models dispersion — incorrect on pre-normalized data. Limma and t-test are appropriate for log-normalized expression values. The manifest's `data_level` field enforces the correct path automatically.
133
+
134
+ **Why is the cross-dataset decision (early/late/refuse) made in the registry, not the agent?**
135
+ Combining ≥2 datasets is gated by `get_integration_plan()` in `biodata-registry` (re-exposed as the agent tool `dataset_get_integration_plan`). It is a pure function of manifest metadata — `early` (pool with `dataset_id` as a batch covariate), `late` (meta-analyze per-dataset result envelopes), or `refuse` — keyed on `data_level`/`organism`/`feature_id_type`/declared contrasts, never on hardcoded dataset pairs. Keeping the decision in the registry makes it deterministic and the single source of truth; a contrast whose factor is absent from a cohort (e.g. a Bailey-subtype contrast against a cohort with no Bailey labels) is refused as `CONFOUNDED_DESIGN` rather than silently degrading to a single-dataset result. The agent acts on `mode` and surfaces `reason`. **Mode B (late / `decoupler_meta_analyze`) shipped 2026-06-19; Mode A (early pooling, T7–T9 — the `decoupler_integrate_datasets` tool, `src/workflows/integration.py`) shipped 2026-06-24 (`a7a8caa`) and is deployed to prod, so an `early` verdict now pools the datasets (feature intersection + a `batch` obs key = `dataset_id`) and runs ONE batch-aware DE with `dataset` modelled as a covariate (DESeq2 `~batch+factor` / limma `~batch+group`) — it no longer falls back to late. For per-sample activity scoring across pooled cohorts (no DE contrast), `decoupler_pool_cohorts` (ADR-0001 item 10, built 2026-06-25) ComBat-corrects the pooled matrix (`scanpy.pp.combat` on log-normalised values, keyed on `poolable_data_level`) before `dataset_score_bulk_samples`, so pooled scores are batch-corrected rather than cohort-confounded. ComBat is the scoring-path counterpart to the DE covariate route — applied only where there is no design matrix, and run without a biological covariate (caveat surfaced on the tool; it must never feed DE testing). (Live deploy status is tracked in `memory.md`, not here.)**
136
+
137
+ **Same-cohort variants (sibling quantifications) — why they are NOT a meta-analysis.**
138
+ The GSE205154 trio (`gse205154_sears` TPM / `gse205154_sears_counts` counts / `gse205154_sears_tmm` TMM) are the *same 289 samples* quantified three ways. Each manifest's header and refusal rules already forbid pooling them ("same samples in different units"), but until biodata-registry 0.1.6 that relationship was only prose — `get_integration_plan()` did not know about it, so asking to combine two siblings (e.g. TPM + TMM) hit the `data_level` gate (`tpm` ≠ `normalized`) and returned `late`, and the agent ran `decoupler_meta_analyze`. **That is the wrong tool**: meta-analysis assumes *independent* cohorts, so combining identical samples double-counts them — Stouffer inflates the combined score by ≈√2 and Cochran's Q/I² collapse to 0 by construction (zero heterogeneity is guaranteed, not evidence of robustness). The correct framing for cross-variant work is a **normalization concordance / sensitivity check** — descriptive agreement metrics (Pearson/Spearman on scores, sign-concordance, overlap of significant calls), never an inferential combine. **Encoded in `biodata-registry` 0.1.6:** the three sibling manifests carry a structured `cohort_id: gse205154` (+ `variant`), and `get_integration_plan` has a same-cohort gate (runs before the `data_level` gate) that returns `mode="concordance"` when the whole request is one cohort's variants, or refuses with `DUPLICATE_COHORT` when siblings are mixed with independent datasets — keeping the decision in the registry per ADR-0001. The agent-side **concordance routine** the new mode routes to is **built**: `decoupler_normalization_concordance` (`integration_mcp`), backed by `src/workflows/concordance.py` — it reports descriptive agreement (pairwise Pearson/Spearman, sign-concordance, effect spread, significant-call Jaccard) instead of calling `decoupler_meta_analyze`. The agent pins 0.1.6, so `get_integration_plan` returns `mode="concordance"` for same-cohort variants and the agent routes to `decoupler_normalization_concordance` rather than `late`/`decoupler_meta_analyze`. (Live deploy status is tracked in `memory.md`, not here.)
139
+
140
+ **Two safety layers — why a result-aware sanity layer (ADR-0002) on top of the registry refusal engine?**
141
+ The refusal engine above is **Layer 1**: a pure function of manifest *metadata*, run *before* any computation — it refuses what the data inventory can't support (survival without endpoints, DESeq2-on-TPM, a confounded design). It is blind to a whole class of failure that is only visible *in the results*: normal-tissue contamination of a biopsy (e.g. hepatocyte master-regulator TFs topping a liver-met contrast), an artefactual log2FC from duplicate-collapse, a requested "TF" with no regulon. `src/workflows/sanity_checks.py` is **Layer 2**: pure post-compute checks that emit non-fatal `sanity_warnings` the agent surfaces. Layer 1 *refuses* (you lack the data); Layer 2 *cautions* (the analysis ran, but the result is confounded/partly artefactual) — it never blocks or re-runs a result, because the contrast is often legitimate-but-confounded and refusal would over-block. Kept out of `biodata-registry` deliberately: contamination is data-dependent, not a metadata fact. (NB this Layer-1/Layer-2 *safety* axis is distinct from the Layer-1/2/3 *MCP-tool-organization* axis above.) Phase 1 wires the effect-size check into the DE tool and the membership check into the CollecTRI tool; contamination auto-firing in the enrichment tools and PROGENy/Hallmark parity are the ADR-0002 S2/S3 follow-ups.
142
+
143
+ **Why keep GitHub and HF in sync?**
144
+ The DecoupleRpy Agent HF Space auto-deploys from GitHub. The research-coordinator does NOT auto-sync — push directly to the HF Space repo using a write token when changes are needed.
145
+
146
+ ---
147
+
148
+ ## Testing Philosophy
149
+
150
+ - Every new workflow should have a corresponding test in `tests/`
151
+ - Integration tests use small synthetic data (20-60 samples, 50-100 genes) — fast, no network calls
152
+ - Fixture tests (e.g., `test_moffitt_integration.py`) lock in known sample counts as regression anchors
153
+ - Manifests are tested automatically by `test_dataset_registry.py` and `test_dataset_manifest_contract.py` on every run
154
+
155
+ ---
156
+
157
+ ## HuggingFace Deployment
158
+
159
+ - **DecoupleRpy Agent Space**: `anne-voigt/Paper2Agent_decoupleRpy` — `origin` remote points directly to HF; deploy with `git push origin main`
160
+ - **Research Coordinator Space**: `anne-voigt/research_coordinator` — does NOT auto-deploy; push directly using write token (`research_agent_token`)
161
+ - **Dev Space**: `anne-voigt/Paper2Agent_decoupleRpy_dev` — tracked as `hf-dev` remote; push with `git push hf-dev main`
162
+ - **Token name**: `research_agent_token` (rotate after any session where it appears in chat logs)
163
+
164
+ ---
165
+
166
+ ## Dataset Roadmap
167
+
168
+ **Currently registered**: 19 manifests, all in `biodata-registry` (the agent pins
169
+ the `0.1.5` wheel). This list is a *stable index* — the authoritative per-dataset
170
+ validated table (platform, feature_id_type, survival, known issues, validation
171
+ dates) lives in `biodata-registry/memory.md`. Do not re-encode validation status
172
+ here; that is what drifted this list down to 9.
173
+
174
+ *Bulk array — tumor vs normal / paired:* `gse71989_chen`, `gse62165_jiang`,
175
+ `gse16515_mayo`, `gse28735_pdac`, `gse15471_badea`.
176
+
177
+ *Bulk array — subtype / survival cohorts:* `gse71729_moffitt` (classical/basal),
178
+ `gse17891_collisson` (Collisson subtypes), `paca_au_array` (Bailey 4-subtype),
179
+ `puleo_2018` (Puleo 5-subtype + survival), `gse21501_stratford` (log-ratio),
180
+ `gse57495` (survival), `gse50827_nones` (survival).
181
+
182
+ *Bulk RNA-seq counts / RSEM-TPM:* `tcga_paad` (raw_counts, Path A/DESeq2),
183
+ `paca_au_rnaseq` (Bailey RSEM, Path A), `paca_ca_rnaseq` (ICGC Canadian, ensembl),
184
+ `cptac_pda` (RSEM TPM).
185
+
186
+ *Sears GSE205154 sibling trio (same 289-sample FFPE cohort, three quantifications
187
+ — Primary 218 / Met 71):* `gse205154_sears` (TPM, Path B), `gse205154_sears_counts`
188
+ (est. counts, Path A/DESeq2), `gse205154_sears_tmm` (edgeR TMM, Path B). These three
189
+ are **the same samples in different units** — each manifest's refusal rules forbid
190
+ pooling them. The only valid cross-variant operation is a **normalization
191
+ concordance / sensitivity check**, never an integration or meta-analysis (which
192
+ would double-count the cohort). See "Same-cohort variants" below.
193
+
194
+ **h5ad files** are hosted at `anne-voigt/pdac-research-data` on HuggingFace
195
+ (migrated from `anni-voigt` 2026-06-12; the GSE205154 trio uploaded 2026-06-22).
196
+
197
+ **Priority next datasets**:
198
+ 1. TCGA-PAAD Moffitt subtypes — classical/basal classification not in GDC/Xena clinical matrix; requires inference step
199
+ 2. Bailey et al. 2016 WGS data — somatic mutation landscape (separate from expression)
200
+ 3. Structured same-cohort-variant relationship (`cohort_id` field + integration mode) — see "Same-cohort variants" below
201
+
202
+ **Before adding any new dataset**: run `dataset_validate_manifest_against_data` to confirm manifest semantics match the actual file.
203
+
204
+ ---
205
+
206
+ ## Consultant Context
207
+
208
+ - **Client**: Dr. Rosalie Sears, Brenden-Colson Center for Pancreatic Care, Knight Cancer Institute, OHSU
209
+ - **Goal**: Make computational biology methods accessible to non-coding scientists via natural language
210
+ - **Scope**: Project 1 of 3 in the consulting engagement (Paper2Agent methodology replication & deployment)
Makefile DELETED
@@ -1,28 +0,0 @@
1
- # DecoupleRpy_Agent — developer entry points. The security targets are the
2
- # shared surface ADR-0014 standardizes across the three repos.
3
- .PHONY: security-scan security-baseline install-hooks help
4
-
5
- help:
6
- @echo "make security-scan Run the ADR-0014 scan (pip-audit, bandit, gitleaks, trivy)."
7
- @echo "make security-baseline Regenerate the accepted-findings bandit baseline."
8
- @echo "make install-hooks Install the pre-push security hook into .git/hooks."
9
-
10
- # ADR-0014: the one shared scan definition. CI, the pre-push hook, and a manual
11
- # run all call this so the check never drifts between entry points.
12
- security-scan:
13
- @bash scripts/security_scan.sh
14
-
15
- # Regenerate the accepted-findings baseline after intentionally adding a new
16
- # suppressed finding. Review the diff before committing — a new entry is a new
17
- # accepted suppression and must be justified in security/ACCEPTED-FINDINGS.md.
18
- security-baseline:
19
- @if command -v bandit >/dev/null 2>&1; then B="bandit"; else B="uvx bandit"; fi; \
20
- $$B -r src/ -ll -c bandit.yaml -f json -o security/bandit-baseline.json -q; \
21
- echo "Wrote security/bandit-baseline.json — review the diff and document any new suppression."
22
-
23
- # Wire the pre-push hook. Git hooks are not committed into .git/, so this copies
24
- # the tracked script into place (idempotent).
25
- install-hooks:
26
- @cp scripts/hooks/pre-push .git/hooks/pre-push
27
- @chmod +x .git/hooks/pre-push
28
- @echo "Installed .git/hooks/pre-push — security scan now runs before every push."
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
README.md CHANGED
@@ -7,45 +7,6 @@ sdk: gradio
7
  sdk_version: 6.18.0
8
  app_file: app.py
9
  pinned: false
10
- hf_oauth: true
11
  ---
12
 
13
  Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
14
-
15
- # DecoupleRpy Agent
16
-
17
- The computation specialist for the PDAC system — a LangGraph CodeAgent exposing
18
- ~52 decoupler-py bioinformatics tools over MCP. Architecture is in `CLAUDE.md`;
19
- status in `memory.md`.
20
-
21
- ## Security-review orientation
22
-
23
- The security-relevant entry points, in priority order:
24
-
25
- - **User-upload gate (`src/uploads/`)** — the ADR-0011 pipeline for
26
- session-scoped file uploads, wired into the UI by `run_upload_gate()` in
27
- `gradio_ui.py`. Three **mandatory** gates (de-identification attestation →
28
- `stage_upload`: type-allowlist / size-cap / quarantine dir / SHA-256 →
29
- `scan_upload`: magic-byte structural check, plus an AV pass only where the
30
- deployment declares one) plus an **advisory** `validate_upload`. What a given
31
- deployment gets is declared in `deploy/scan_posture.yaml` (read by
32
- `src/uploads/posture.py`), not inferred from the host PATH: the HF Spaces run
33
- `structural_only` and are therefore **not** malware-scanned, while
34
- `av_required` fails closed and must only be set where a working AV exists.
35
- Files are
36
- only ever opened with vetted loaders (`scanpy.read_h5ad`, `pandas.read_csv`) —
37
- never pickle/eval/exec — and quarantined uploads never enter a served path.
38
- - **MCP tool dispatch (`src/managers/tools/mcp_manager.py`)** — tools are
39
- registered over HTTP (resident server) or stdio; `_parse_mcp_content` is the
40
- untrusted-output boundary (raises on `isError` rather than passing error text
41
- downstream). `_resolve_env_vars` does read-only literal `${VAR}` substitution
42
- (no shell). The tool surface itself is mounted in `server.py`.
43
- - **Secrets + redaction** — `ANTHROPIC_API_KEY` / `HF_TOKEN` /
44
- `decouplerpy_results_token` are read once at lazy init and never logged; AWS
45
- creds come from the instance role, not env. `src/core/trace_redaction.py`
46
- scrubs secret-shaped strings (and whole env dumps) from every trace before it
47
- reaches any sink (file / HF / S3).
48
- - **Access control (`src/core/access_control.py`)** — `ADMIN_IDS` / `ALLOWED_IDS`
49
- gate admin-only actions (e.g. registering an upload into the registry);
50
- fail-closed on unknown identity; the resolved principal is recorded in the
51
- audit trace (ADR-0012).
 
7
  sdk_version: 6.18.0
8
  app_file: app.py
9
  pinned: false
 
10
  ---
11
 
12
  Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
TODO.md ADDED
@@ -0,0 +1,486 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # DecoupleRpy_Agent — TODO
2
+
3
+ Canonical open-items list for this repo. Session history + detailed write-ups
4
+ stay in `memory.md`; this file is the scannable backlog. Convention: each item
5
+ is `High` / `Med` / `Low`. Move finished items to `## Done` with a date.
6
+
7
+ Routing rule for this system: manifest/dataset-semantics → biodata-registry ·
8
+ computation/tools/loaders → here · routing/coordinator/eval →
9
+ pdac-analysis-orchestrator.
10
+
11
+ ---
12
+
13
+ ## Open
14
+
15
+ ### Deploy / infra
16
+
17
+ - **Done (2026-06-29) — Salvage branch `claude/magical-banach-a2d929` ported + dropped.** The Jun-22
18
+ `wip(salvage)` commit (`8efd1be`) was reviewed file-by-file: **all 10 files already superseded** on
19
+ current `main` + biodata-registry (writable-dir `_resolve_dir`, hf_storage structured save,
20
+ `prohibited_inferences`, Moffitt prompt examples + 89/36 golden test, txt/docx/pdf export, stroma
21
+ contrasts). Nothing to port — porting would only re-add stale code on deleted paths. Branch deleted
22
+ (local + `origin` + defunct worktree); `8efd1be` recoverable via reflog. Detail in memory.md 2026-06-29.
23
+
24
+ - **Done (2026-07-01) — Always-on audit log sink (ADR-0008) MERGED + deployed to prod.** `src/logging_sink.py`
25
+ (configurable `LOG_SINK=local|hf|s3`, default `local`) + always-on `persist_trace_safe` wiring in
26
+ `agent.py`/`gradio_ui.py` merged to `main` and pushed to the prod Space. Prod posture: `LOG_SINK=hf`.
27
+
28
+ - **Med — Finish the S3 log-sink writer (AWS migration, ADR-0009).** `src/logging_sink.py` ships a
29
+ configurable trace sink (`LOG_SINK=local|hf|s3`, default `local`). ADR-0009 Phase 1 implemented the
30
+ lazy-boto3 `S3LogSink.persist_trace` (`put_object` to `${LOG_SINK_S3_PREFIX}/<run_id>/trace.json`);
31
+ `scripts/verify_s3_sink.py` is the live-verify script. **Remaining for the OHSU-managed AWS cutover:**
32
+ provision the bucket/IAM per ADR-0009 Appendix A, wire `LOG_SINK_S3_BUCKET`/`_REGION`/`_PREFIX` +
33
+ role creds as Space config, add `boto3` to requirements (optional/lazy), then set `LOG_SINK=s3`.
34
+
35
+ - **Med — Quantify the MCP HTTP perf win (DEPLOYED to dev + prod `58b961f`, 2026-06-29).** The
36
+ resident `server.py --transport http` is live on both Spaces — tool calls reuse one process instead
37
+ of spawning a subprocess per call (was the ~44s/step tax); confirmed via run logs (`Added 52 remote
38
+ MCP tools`, no Pre-warm failed). Adds a process-lifetime in-memory adata cache + `[perf]`
39
+ instrumentation; stdio kept as fallback. **Remaining:** run a real multi-step DE question (needs API
40
+ credits) to capture the actual wall-clock % cut (expected 30–60% on a ~29-min run) from the `[perf]`
41
+ lines now in the logs.
42
+ - **High — Bring PROD specialist live on gradio 6.18.** Migration is **pushed to
43
+ prod** (`origin/main` = `f098e67`, 2026-06-22); dev is RUNNING on the same code.
44
+ Prod Space is still PAUSED on the cpu-basic quota (3/3: dev specialist + dev
45
+ orchestrator + `bcc-lit-agent`). **Remaining: free one slot (e.g. pause
46
+ `bcc-lit-agent`) + unpause/restart the prod Space.** (User opted not to pause
47
+ anything yet — do this when ready.) If the prod build hits the same HF rollout
48
+ wedge dev did, a pause→unpause clears it.
49
+ - **High — Top up the dev Space `ANTHROPIC_API_KEY` credits.** "Credit balance too low"
50
+ blocks the agent from finishing any multi-step run on dev — the concordance end-to-end
51
+ output couldn't be captured. Top up, then re-run a sibling-variant concordance query
52
+ for the final numbers.
53
+ - **High — Promote the loader-auth + concordance stack to PROD.** On `main`/dev only
54
+ (`ce5168d`, `04b26ba`; the 0.1.6 concordance gate). Prod promotion = push `origin` →
55
+ **factory-rebuild prod** (a *normal* rebuild kept a stale pre-0.1.6 install on dev — a
56
+ factory rebuild was required) → set the prod `HF_TOKEN` secret (read
57
+ `pdac-research-data`) → ensure prod's Anthropic key has credits → re-validate. **2026-06-26
58
+ update: mostly DONE** — loader-auth live on prod (`5c5ca74`/`ce889e5`), prod `HF_TOKEN` CONFIRMED
59
+ set (user), Anthropic credits topped, XDI suite re-validated **4/4 end-to-end on prod**
60
+ (`20260626_131117`). Remaining: verify the 0.1.6 concordance factory-rebuild actually took on prod.
61
+ - **Done (2026-06-26) — Metadata tools loader-auth gap fixed → dev** (`ce889e5`).
62
+ `dataset_count_metadata_values` / `dataset_crosstab_metadata_values` /
63
+ `dataset_validate_manifest_against_data` were MISSED in the ce5168d/04b26ba
64
+ centralization — they did `Path(adata_path).exists()` on the raw arg, so a private
65
+ h5ad URL returned "File not found" and never authenticated. Surfaced live: the XDI-002
66
+ late eval stalled here (agent fell back to unauthenticated urllib that hung on the
67
+ private repo). All three now route through `resolve_to_local_path`. Verified locally
68
+ (loads private paca_au_rnaseq.h5ad, real Bailey counts). **Dev only**; fold into the
69
+ prod loader-auth promotion below. **Follow-up: reconcile stale early-branch prompt text**
70
+ ("no batch-aware activity-scoring tool") — `81f5db6` added `decoupler_pool_cohorts`.
71
+ - **Done (2026-06-24) — Private-data 401 fixed; loader auth centralized.** One
72
+ authenticated resolver `src/core/data_io.resolve_to_local_path`; `decoupler_differential_expression`
73
+ + Mode A `_resolve_to_local` + `bulk_dataset_tools._resolve_to_local_path` delegate
74
+ (`ce5168d` → `04b26ba`). Validated on dev (no 401/TaskGroup; resolver downloads the real
75
+ 85.4 MB private h5ad). **`HF_TOKEN` Space secret CONFIRMED set on prod (user, 2026-06-26)** —
76
+ prod loads private data end-to-end (XDI suite 4/4, `20260626_131117`).
77
+
78
+ ### Cross-dataset integration (ADR-0001)
79
+
80
+ Combine ≥2 datasets — early (pool + batch covariate) / late (meta-analysis) /
81
+ refuse, driven by registry metadata. ADR + plan + per-task spin-off prompts in
82
+ `docs/adr/ADR-0001-*.md`. All work on branch `adr-0001-phase-0` (off `main`).
83
+
84
+ - **Done (2026-06-25, later) — Step-count efficiency prompt edits → dev** (`cdfee6f`,
85
+ `prompts.yaml`; no code). Cuts wasted generate→execute round-trips in long /
86
+ cross-dataset runs (diagnosis: ~90% of ~35-40 min is the serialized step loop).
87
+ (1) Reporting-Results rule 2 ("Re-read before you report") → a SINGLE read of an
88
+ ALREADY-SAVED CSV; forbids recompute/re-derive/re-save and a repeated re-read
89
+ (killed XDI-001's ~3 redundant end steps). (2) Cross-dataset step 1 → pass
90
+ design_factor ONLY for a named two-group contrast; pooled/per-sample "across all
91
+ samples" with no contrast calls `dataset_get_integration_plan([...])` with NO
92
+ design_factor (stops XDI-001's refuse-then-retry). With-contrast refusal guidance
93
+ unchanged. YAML verified; **dev + PROD** (`hf-dev` + `origin`; on prod 2026-06-26 via `81f5db6`).
94
+ **VALIDATED on PROD 2026-06-26** (XDI suite re-run `20260626_131117`, 4/4 PASS): XDI-001 early
95
+ pooled batch-aware DE (249 samples), no redundant recompute/re-read end-steps observed. Clean
96
+ step-count A/B not possible (eval bank revised since the pre-fix run — XDI-001 is now Moffitt+Puleo).
97
+ - **Done (2026-06-25) — Late-integration prompt hardened → dev + PROD** (`0776267`,
98
+ `prompts.yaml`). Late requests must run the shared contrast per cohort then combine with
99
+ `decoupler_meta_analyze` and ALWAYS report Cochran's Q / I^2 — no hand-rolled
100
+ sign-concordance/intersection (fixes the 2026-06-25 eval miss). Early branch refreshed
101
+ to route contrasts to `decoupler_integrate_datasets`; clarifies no batch-aware
102
+ activity-scoring tool (flag pooled activity as batch-confounded). YAML+Jinja verified.
103
+ Deployed dev (`hf-dev`, `0776267`) then **PROD** (`origin`, `791bb29`→`211cc90`, clean
104
+ ff; only runtime delta is `prompts.yaml`, no re-pin so no factory-rebuild needed).
105
+ **VALIDATED 2026-06-26 (XDI-002 re-run, dev `f01f56b`, PASS):** agent calls
106
+ `decoupler_meta_analyze` (Stouffer, 91 TFs) and reports per-feature Cochran's Q / I^2,
107
+ flagging CDX2 (I^2=72.9%) + HOXD3 (51.3%) as heterogeneous — no hand-rolled concordance.
108
+ Needed the `ce889e5` metadata loader-auth fix to get there. **Promoted to PROD 2026-06-26**
109
+ (`origin` `5c5ca74`): metadata loader fix `ce889e5` + prompt point-3 reconcile `5c5ca74`
110
+ (pooled scoring via `decoupler_pool_cohorts` = ComBat-corrected, not confounded). Late
111
+ meta-analyze prompt fix + loader fix now both live on prod. **Re-validated end-to-end on PROD
112
+ 2026-06-26** (XDI-002 in suite `20260626_131117`, PASS): real `decoupler_meta_analyze` (Stouffer)
113
+ + per-TF Q/I² computed on prod, no hand-rolled concordance.
114
+ - **Done — Same-cohort concordance routine** (committed `b36d5ce`; on `main` via
115
+ merge `0ac610f`; deployed to **dev** `hf-dev`). `decoupler_normalization_concordance`
116
+ (`integration_mcp`) + `src/workflows/concordance.py` — the agent-side routine for
117
+ the registry's `mode="concordance"` (sibling variants). Descriptive agreement
118
+ (Pearson/Spearman, sign-concordance, effect spread, significant-call Jaccard); no
119
+ combine. Tests: `test_concordance.py` 9 pass; `test_concordance_tool.py` smoke-validated.
120
+ **Now live in prod:** biodata-registry 0.1.6 wheel + re-pin (`fb2091e`) **deployed**
121
+ (`origin/main` = `fb2091e`); the plan returns `concordance` live. Dev confirmed RUNNING
122
+ on 0.1.6; prod rebuilt on the push.
123
+ - **Done — Phase 0.** `src/core/combine.py` (envelope contract + strategy
124
+ registry) + decision-matrix spec, committed (`6fa0b12`, `3fd9d0a`).
125
+ - **Done — T1: `combine` descriptors on the 4 rna tools** (`src/tools/rna/analysis.py`),
126
+ committed (`42d31b1`). All four tools declare a Mode-B `combine` descriptor +
127
+ matching `@combinable` marker; `tests/test_combine_conformance.py` added (24 tests).
128
+ - **Done — T3: `src/workflows/meta_analysis.py`** (Phase 1, step 4), committed
129
+ (`bb944d5`). Four strategies register into `combine.py` (inverse_variance >
130
+ stouffer > fisher; rank_aggregation opt-in), `compare_activity_by_group`→envelope
131
+ adapter with the D4 SE-of-Cohen's-d backfill, Cochran's Q / I², and
132
+ `combine_envelopes()` (align → field-dispatch → BH). `tests/test_meta_analysis.py`
133
+ (34 tests). Also completed the `src/core/__init__` re-export surface (additive).
134
+ Engine references no tool by name. **Branch-local; not deployed.**
135
+ - **Done — T2: registry `get_integration_plan`** (biodata-registry) — decision
136
+ engine + 5th MCP tool. Merged + released as **biodata-registry 0.1.2**
137
+ (2026-06-19). Pin ready for T4:
138
+ `.../resolve/cbc083a5cd9dbe79e6740a6b64c4dc8c0639f113/biodata_registry-0.1.2-py3-none-any.whl`
139
+ (sha256 `607a14b0…`).
140
+ - **Done — T4: agent `dataset_get_integration_plan` wrapper + 0.1.2 re-pin**
141
+ (commit `a85e426`, branch-local). Thin `get_integration_plan` passthrough in
142
+ `src/datasets/registry.py` + `@dataset_mcp.tool dataset_get_integration_plan`
143
+ in `catalog.py` (early/late/refuse; `{error}` on unknown ids; forwards
144
+ contrast args to the confound gate). `requirements.txt`/`.in` → 0.1.2
145
+ (`cbc083a`). Server 50→51 tools. `tests/test_integration_plan_tool.py` (7).
146
+ - **Done — T5: `decoupler_meta_analyze` tool + A→B→refuse wiring** (commit
147
+ `c45a617`, branch-local). New `integration_mcp` sub-server +
148
+ `decoupler_meta_analyze` (envelope read → `combine_envelopes` → combined CSV +
149
+ Q/I²; refuses mismatched result_type/contrast). prompts.yaml wires
150
+ early→late-fallback / late / refuse. Server 51→52 tools.
151
+ `tests/test_meta_analyze_tool.py` (9). **Mode B (Phase 1) complete end to end.**
152
+ - **Done — T6: release + dev deploy + e2e validation** (2026-06-19). Shipped Mode B
153
+ to **prod** (`origin` @ `c54b550`, 50→52 tools). Dev validation found Case 3
154
+ returned plan `early` not `refuse`; hardened the registry confound gate
155
+ (**biodata-registry 0.1.3**, `5654e86`: a cohort that can supply neither arm of a
156
+ specified contrast → CONFOUNDED_DESIGN) + a prompt nudge to pass contrast args.
157
+ 4-case gradio_client eval on dev: refuse ✅ / early→late-fallback no-fabrication ✅
158
+ / late no-pooling ✅ / `decoupler_meta_analyze` positive path ✅ (paca_au_rnaseq +
159
+ paca_au_array). Q/I² reporting polish (`c54b550`). registry re-pinned 0.1.3.
160
+ - **Done — T7: `src/workflows/integration.py`** combined-AnnData builder (gene-symbol
161
+ intersect + `batch` obs key = dataset_id); pure `combine_anndatas` + loader
162
+ `build_combined_anndata`. Branch `adr-0001-phase-2-mode-a` (`a7a8caa`); **deployed
163
+ to dev + prod 2026-06-24** (`5cb2737`, code-only rebuild).
164
+ - **Done — T8: `batch_column` on the DE tool** — DESeq2 `~batch + factor`; new
165
+ `run_limma_covariate` (`~batch + group`) in microarray.py; ttest+batch refused (no
166
+ silent covariate drop). Back-compat default off. (`a7a8caa`.)
167
+ - **Done — T9: `decoupler_integrate_datasets`** (3rd integration_mcp tool, server
168
+ 52→53) — pools + one batch-aware DE only on plan mode=="early"; refuses/reroutes for
169
+ late/concordance/refuse; auto-picks deseq2 (raw counts) / limma. (`a7a8caa`.) v1
170
+ limits: gene-symbol axis only (no probe collapse/ortholog).
171
+ - **Done — item 10: `decoupler_pool_cohorts`** (4th integration_mcp tool, server
172
+ 54→55) — ComBat for the per-sample **scoring** path (the no-design-matrix counterpart
173
+ to T9's covariate route). Pools + `batch_correct_for_scoring` (`scanpy.pp.combat` on
174
+ the log-normalised matrix, keyed on `poolable_data_level`; standard ComBat, NOT
175
+ ComBat-seq → **no new dependency**) → one batch-corrected matrix for
176
+ `dataset_score_bulk_samples`. Same early-only plan gate. `src/workflows/integration.py`
177
+ + `tests/test_pool_cohorts_tool.py` (13 tests). Prompt early-branch rewired to call it
178
+ (was "no batch-aware scoring tool / flag confounded"). 2026-06-25 dev; **promoted to PROD 2026-06-26** (`origin` `81f5db6`).
179
+ - **Low — T10: orchestrator routing + capability + reporting rules** (pdac-analysis-orchestrator).
180
+ - **Low — T11: cross-dataset evals** (pool/fallback/refuse; needs T5/T9) — now unblocked (T9 done).
181
+
182
+ ### ADR housekeeping (reconciled 2026-06-24)
183
+
184
+ ADR-0003/0004/0005 moved **Proposed → Accepted**; ADR-0001 checkboxes ticked to shipped
185
+ reality (Mode B / concordance). Remaining open action items:
186
+
187
+ - **Low — ADR-0003:** add a "Dev Mode inner loop" note to
188
+ `pdac-analysis-orchestrator/DEPLOYMENT.md` (sibling repo); set up VS Code/SSH config.
189
+ *(Dev Mode itself is enabled on both dev Spaces.)*
190
+ - **Low — ADR-0005:** add a "promote to public on publication" step to the
191
+ dataset-onboarding checklist; record the storage posture in `biodata-registry/memory.md`.
192
+ *(`pdac-research-data` is already private.)*
193
+
194
+ ### Result-aware sanity layer (ADR-0002)
195
+
196
+ Post-compute Layer-2 cautions (never refuse/block) catching what the metadata-only
197
+ refusal engine can't: contamination, artefactual log2FC, non-TF "TFs". ADR +
198
+ spin-off prompts in `docs/adr/ADR-0002-*.md`. `sanity_warnings` is the shared
199
+ additive return contract S2/S3 extend.
200
+
201
+ - **Done — Phase 1: `src/workflows/sanity_checks.py` + tests + DE/TF wiring**
202
+ (branch `feat/adr-0002-sanity-layer`, not pushed). 3 checks + `run_sanity_checks()` aggregator;
203
+ `tests/test_sanity_checks.py` (16); additive guarded `sanity_warnings` key on
204
+ `decoupler_differential_expression` (effect-size) +
205
+ `decoupler_tf_enrichment_collectri` (membership).
206
+ - **Done — S1: CI verification of the additive key + memory sync** (this session).
207
+ Additive key breaks nothing (`tests/` 877 passed / 55 skipped); added DE + TF
208
+ `sanity_warnings` shape assertions to `tests/test_tool_response_schemas.py`
209
+ (9→11). Closes the ADR's two schema/registry caveats. **Committed on
210
+ `feat/adr-0002-sanity-layer`, not pushed.**
211
+ - **Done — S2: Phase 2 — thread cohort tissues into the enrichment tools + auto-fire
212
+ the contamination check** (2026-06-22; committed on `feat/adr-0002-sanity-layer`,
213
+ not pushed). Optional `cohort_tissues` / `home_tissue` params on all three
214
+ enrichment tools (default `None` → check skipped, back-compat).
215
+ `check_tissue_identity_contamination` runs on the ranked output and is **merged
216
+ into the same `run_sanity_checks` report** as S3's membership warnings (CollecTRI
217
+ ranks TFs by |activity|, `feature_kind="tf"`; PROGENy/Hallmark run on the input DE
218
+ genes by |stat|, `feature_kind="gene"`, since pathway/gene-set names aren't in the
219
+ marker registry). Added a `prompts.yaml` rule to foreground `critical` warnings.
220
+ New tests `tests/test_enrichment_contamination_wiring.py` (9). `test_tool_registry.py`
221
+ needed no change (tests the ToolRegistry abstraction, not real tool signatures).
222
+ - **Done — S3: parity — `sanity_warnings` (membership check) on PROGENy + Hallmark**
223
+ (2026-06-22; committed on `feat/adr-0002-sanity-layer` together with S2 as the
224
+ ADR-0002 sanity layer). Validates requested pathway/gene-set names against the
225
+ resource's source names (`known_non_tf=set()`); `run_sanity_checks` gained a
226
+ `known_non_tf` passthrough; parity tests in `tests/test_tool_response_schemas.py`
227
+ (`TestEnrichmentSanityParity`). The TF tool keeps membership **and** contamination
228
+ warnings merged (reconciled with S2, not overwritten).
229
+
230
+ ### PROGENy per-sample scoring (from GSE205154_sears eval, 2026-06-22)
231
+
232
+ Found while reviewing an agent run for "score PROGENy for every sample in
233
+ gse205154_sears and show the cohort-wide landscape." Analysis/routing were
234
+ correct (single-dataset, Path B, ULM, 98.3% coverage); these are the gaps.
235
+
236
+ - **High — Per-sample scoring path returns no retrievable figure.**
237
+ `dataset_score_bulk_samples` writes only the two CSVs to `OUTPUT_DIR`
238
+ (`tmp/outputs/`). Any "landscape" plot is agent-authored matplotlib that lands
239
+ wherever the agent chooses — observed at `/tmp/..._landscape.png`, *outside*
240
+ `OUTPUT_DIR` — so the end-of-run `tmp/outputs` inline-plot sweep never embeds
241
+ it and "show me the landscape" silently returns no image. The run's only link
242
+ was mislabeled "Saved run log" but pointed at the raw `.h5ad`. Fix: either (a)
243
+ have the tool emit a standard landscape figure (sample heatmap + mean±SD bar)
244
+ to `OUTPUT_DIR` as a declared artifact, or (b) add a prompts.yaml rule that
245
+ agent-authored plots must be written to `OUTPUT_DIR`.
246
+ - **High — Confirm/enforce log-scale before ULM on the per-sample path.**
247
+ PROGENy/ULM assume roughly symmetric, log-scale input (tool docstring says
248
+ "log2-TPM"). The run loaded "TPM" and never stated a log2 transform was
249
+ applied; if *linear* TPM reaches ULM, a few high-expression genes dominate and
250
+ scores distort. Fix: add an input-scale heuristic in
251
+ `score_bulk_samples_with_decoupler` (warn or auto-log when values look linear:
252
+ large max / right-skew / non-log range) and/or assert the manifest `data_level`
253
+ is a log level; surface the applied transform in the return dict either way.
254
+ - **Med — `DECOUPLER_DISCLAIMER` misdescribes the per-sample path.**
255
+ `src/core/constants.py` says values are "derived from differential-expression
256
+ statistics via the ULM model" — false for `dataset_score_bulk_samples`, which
257
+ scores the expression matrix directly (its own docstring: scores "without first
258
+ computing DE statistics"). Split the disclaimer into per-sample vs DE-based
259
+ wording (or parameterize) so the scoring tool emits the correct caveat.
260
+ - **Med — Pin and record the PROGENy footprint.**
261
+ `dc.op.progeny()` is called with defaults; the run reported
262
+ `n_network_genes=17,610` (full model), and decoupler's default `top` has
263
+ shifted across versions — so scores aren't reproducible across upgrades. Pin
264
+ `top` explicitly in `score_bulk_samples_with_decoupler` and echo it in the
265
+ return dict.
266
+ - **Low — `-F` (fibroblast) samples can't be excluded.** The 7 `-F` samples are
267
+ annotated `Primary` in GEO; per-sample scoring includes them and can inflate
268
+ stromal pathways (TGFb/NFkB). Consider a documented sample-filter hook
269
+ (agent-side) or a manifest note so they can be optionally excluded/segmented.
270
+ (Manifest-note half coordinates with biodata-registry.)
271
+
272
+ ### UI / output (Gradio)
273
+
274
+ - **Low — Port the UI fixes to pdac-analysis-orchestrator.** The download
275
+ truncation, inline-plot, and full-run-PDF fixes landed here 2026-06-17 (see
276
+ Done). The same class of bug likely exists in the orchestrator's UI (untested
277
+ as of 2026-06-17) — reuse the same approach there.
278
+
279
+ ### GUI / observability
280
+
281
+ - **Med — Tool/dataset call tracking panel** (was Known Gaps #9). Collapsible
282
+ "what happened" panel beneath each response showing which MCP tools ran and
283
+ which datasets were accessed. LangGraph already returns intermediate steps;
284
+ capture tool names + dataset IDs from step metadata and render an expandable
285
+ panel. Helps non-coding scientists see what the agent did.
286
+
287
+ ### Data / manifests
288
+
289
+ - **Med — `roadmap` key in manifests** (was Known Gaps #10). Add a per-manifest
290
+ `roadmap` list in biodata-registry + a `scripts/collect_roadmap.py` that
291
+ prints a consolidated cross-dataset list of open items. (Coordinate with
292
+ biodata-registry/TODO.md.)
293
+ - **Med — Daily URL health check** (was Known Gaps #11). Scheduled script that
294
+ pings each manifest's `expression_source` / `metadata_source` URLs, logs HTTP
295
+ status to `url_health.json`, and a startup banner in `gradio_ui.py` warns on
296
+ any non-2xx. Motivated by the TCGA-PAAD clinical URL 403.
297
+
298
+ ### Tool development & validation (from PI research to-do list, 2026-06-26)
299
+
300
+ - **Med — Add the PURIST subtype operation to the toolset.** Wire PURIST
301
+ (single-sample basal/classical PDAC classifier) as an agent tool, then support
302
+ the basal-vs-classical comparison that contrasts **PURIST vs single-cell**
303
+ methodology. (Pairs with the Loveless single-cell ingestion in
304
+ biodata-registry/TODO.md.)
305
+ - **Med — Hallmark Shiny app (Carl Pelz).** Build the Shiny / Hallmark-genes
306
+ tool. Review/scoping meeting with Carl Pelz is scheduled — capture his
307
+ requirements (which Hallmark gene sets, inputs, expected outputs) before building.
308
+ - **Med — Single-cell RNA-seq tool: cell annotation + UMAP + thresholds.**
309
+ Build on `rna_sc.py` to expose the cell-annotation feature, render a UMAP, and
310
+ let the user specify thresholds. Carry the existing caveats: analysis works only
311
+ within cluster groups, and pseudo-bulk single-cell is untrustworthy for certain
312
+ cell types.
313
+ - **High — Loveless single-cell serving & runtime design (agent side).**
314
+ *Started 2026-06-29 — design accepted as [`ADR-0006`](docs/adr/ADR-0006-loveless-single-cell-serving.md);
315
+ agent-side scaffold landed ahead of the biodata-registry ingestion.*
316
+ - **Done (scaffold, synthetic-tested):** custom-signature bulk fast path
317
+ `dataset_score_signature` (Role 2) + `src/workflows/signatures.py` +
318
+ `score_bulk_samples_with_decoupler` network seam (custom resource label no
319
+ longer gated when a signature net is supplied); `src/tools/rna_sc.py` now
320
+ loads via `read_h5ad_cached` (Role 1 cache contract). **ADR-0006 #6 done:**
321
+ `h5ad` `expression_source.type` + Path **P** (`analysis_path` derived
322
+ modality-first; sc/spatial → P, never mislabeled Path A) wired into
323
+ `_build_loading_plan` (single-cell plan, no bulk DESeq2/limma tail) +
324
+ `manifest_schema` (matches biodata-registry 0.1.7 A/B/P). Tests:
325
+ `tests/test_signatures.py`, `tests/test_loading_plan_h5ad.py` (all green;
326
+ `test_activity_scoring.py` updated for the relaxed resource gate).
327
+ - **Also done:** sc loaders (`rna_sc.py::_load_adata`) resolve a hosted/private
328
+ h5ad URL→local path via the shared authenticated resolver
329
+ (`resolve_to_local_path`, `HF_TOKEN`) — a `pdac-research-data` h5ad now loads
330
+ end-to-end (`tests/test_rna_sc_loader.py`).
331
+ - **Artifacts LANDED (biodata-registry 0.1.8, 2026-07-01) + integrated:** two
332
+ Loveless-atlas sc subsets — `gse155698_steele` (GSE155698) + `gse205013_werba`
333
+ (GSE205013), `modality: sc_rnaseq`/`raw_counts`, `expression_source.type: url`
334
+ → hosted `.h5ad`) + the `CROSS_RESOLUTION` gate. Agent re-pinned 0.1.8 on this
335
+ branch; both route `analysis_path=P` and produce a correct sc loading plan
336
+ (load via `decoupler_load_and_visualize_data`, NOT the bulk url loader).
337
+ Fixed `_build_loading_plan` to key Path P on **modality** (their type is `url`,
338
+ not `h5ad`); regression test in `tests/test_loading_plan_h5ad.py`.
339
+ - **Role-2 signature artifact PUBLISHED (2026-07-01):** 14 per-cell-type marker
340
+ signatures derived from the Steele subset (`rank_genes_groups` on `Clusters`)
341
+ → `loveless/signatures/gse155698_steele_celltype_signatures.csv` on
342
+ `pdac-research-data`. Script: `biodata-registry/scripts/ingest/loveless/derive_signatures.py`.
343
+ `dataset_score_signature` scores it end-to-end (66.6% coverage on a synthetic
344
+ cohort); `load_signature_net` + `resolve_to_local_path` now fetch a private
345
+ signature URL with auth (env token OR cached `huggingface-cli login`).
346
+ - **Done + DEPLOYED to PROD (2026-07-01) — `decoupler_load_and_visualize_data`
347
+ hardened for subsets lacking a precomputed UMAP/leiden** (was spun off →
348
+ `task_8b2a1bdc`). Merged via HF PR #1 into prod `main` (`6cda24b`→`abc16da`) +
349
+ **prod factory-rebooted** (folds in the pending 0.1.8 re-pin rebuild). Remaining:
350
+ confirm prod RUNNING post-reboot + e2e-verify the two Loveless sc datasets load +
351
+ produce a UMAP on prod. `_ensure_umap` reuses an existing embedding (pbmc3k unchanged) or
352
+ computes a bounded `normalize+log1p→pca→neighbors→leiden(igraph)→umap` pipeline
353
+ on the loaded copy, and NEVER hard-fails — on any failure the plot is skipped
354
+ and the loaded AnnData + metadata are still returned with a note. Grouping
355
+ detection covers R `make.names` atlas cols (`Clusters`, …), not just `leiden`;
356
+ leiden uses `flavor="igraph"` (no `leidenalg` dep on the Space). Sits on top of
357
+ the `_load_adata` seam, so the Loveless subsets now load AND visualize
358
+ end-to-end. Tests: `tests/test_rna_sc_load.py` (4 green).
359
+ - **Still open:** PROD factory rebuild on the 0.1.8 re-pin was TRIGGERED 2026-07-01
360
+ (with the UMAP-hardening merge) — confirm it settled to RUNNING and the 0.1.8
361
+ wheel actually took (both sc datasets list); `hf-dev` factory rebuild + e2e verify
362
+ still not done; confirm prod RAM holds the subset live; pseudobulk-aggregation tool
363
+ for the sample-level DE contrast; derive Werba signatures when needed. ADR-0006
364
+ items 7/8/9.
365
+ Companion to the biodata-registry Loveless ingestion (provenance/scope/gate plan
366
+ there). Design goal: keep user-facing runtime bulk-like.
367
+ - Two roles. The **Steele-subset h5ad** is an analyzable sc dataset — loads once
368
+ via the persistent MCP server + `read_h5ad_cached`, then pseudobulk → DE /
369
+ activity scoring; first load slower than a bulk series matrix, cached after,
370
+ then normal. The **full integrated atlas** is NOT live-computed — heavy sc
371
+ work (annotate / QC / derive signatures) is precomputed offline at ingestion,
372
+ and query-time ops score *bulk* cohorts against the derived Loveless
373
+ signatures / deconvolution reference on the existing fast path.
374
+ - Cache caveat: `read_h5ad_cached` returns **copies** (safe under the shared
375
+ server) — fine at MB scale, a RAM multiplier at GB scale. A large atlas on the
376
+ live path would need views/no-copy, or (preferred) stay off the live path via
377
+ the offline-signature approach.
378
+ - Space sizing: confirm the prod Space RAM tier holds whatever sc artifact is
379
+ served live — the subset should fit; the raw atlas likely won't.
380
+ - Expected user flows: "score the Loveless basal / CXCL10+ CAF signature in
381
+ TCGA-PAAD" (bulk fast path), "pseudobulk DE tumor vs normal in the Loveless
382
+ subset" (sc load + pseudobulk), "annotate cell types in Loveless" (heaviest —
383
+ prefer precomputed). Pairs with the `CROSS_RESOLUTION` gate + the PURIST
384
+ vs single-cell methodology comparison above.
385
+ - **Med — Batch-adjustment validation workflow (P53 quality check).** End-to-end
386
+ check on the batch-adjustment path (mouse → expression change → sequencing):
387
+ confirm the changes hit pathways known to associate with **P53** (or other known
388
+ processes), and quality-check that the **same pathways appear before vs. after**
389
+ batch adjustment. Tooling exists (Mode A covariate DE + `decoupler_pool_cohorts`
390
+ ComBat scoring); this is the validation/QC analysis on top.
391
+ - **Note — decoupleR "explain each step" + preprocessing = DONE.** The plain-
392
+ language Approach section + decoupleR glossary in every solution (`d8abb63`) and
393
+ the per-dataset `preprocessing` field (biodata 0.1.5, surfaced in prompts) cover
394
+ the PI's "explain how decoupleR works / add preprocessing info" items.
395
+
396
+ ### Blocked / long-term
397
+
398
+ - **Blocked — COMPASS / Chan-Seng-Yue 2020** (was Known Gaps #13). Controlled
399
+ access (EGA EGAS00001002543); needs a signed DAA + DAC approval. Not pursuable
400
+ for an open-access deployment. Would be Path A (RNA-seq raw counts → DESeq2)
401
+ if access is ever obtained.
402
+ - **Low / long-term — Additional specialist agents beyond decoupler** (was
403
+ Known Gaps #12). Architecture already supports it; add entries to `agents.yaml`
404
+ + new Space deployments when ready.
405
+
406
+ ### Minor / residual
407
+
408
+ - **Low — GPL annotation fallback residuals** (was Known Gaps #15). `GPL_GENE_SYMBOL_CANDIDATES`
409
+ is a fixed 9-variant list (other naming needs explicit `gene_symbol_column`);
410
+ in-memory cache hits cosmetically report a possibly-different `sym_col_used`
411
+ (cached mapping itself is correct).
412
+
413
+ ---
414
+
415
+ ## Done (recent)
416
+
417
+ - 2026-07-02 — **ADR-0011 upload safety gate ("Now" slice)** — branch
418
+ `feat/adr-0011-upload-gate` (off `origin/main`), **NOT pushed, no deploy**. New non-agent-facing
419
+ `src/uploads/` (`stage_upload` → `validate_upload` → `register_upload`) + shared
420
+ `src/core/integrity.py` (`compute_sha256`/`verify_sha256`, reused later by ADR-0010). Quarantine
421
+ + type/size allow-list + manifest-required + de-id attestation + validate-via-vetted-loader
422
+ (never-exec) + SHA-256 provenance into the ADR-0008 audit sink + admin-only registration
423
+ (`UPLOAD_ADMIN_IDS`, fail-closed). `tests/test_upload_gate.py` (24) green; reused suites (133)
424
+ green. ADR-0011 flipped Proposed→Accepted for this slice. **Follow-ups (open):** tabular
425
+ auto-validation (h5ad-only today); AWS staging bucket + pre-validation malware scan (ADR-0011
426
+ "At AWS"); wire `src/core/integrity.py` into `resolve_to_local_path` for ADR-0010 on-load verify.
427
+ - 2026-06-22 — **TF semantic-annotation coverage + igraph network plot**
428
+ (commit `ed98c5b` on `main`, **NOT pushed**). Fixed 70 valid HGNC TF symbols
429
+ being mis-bucketed as `unresolved_label`: bundled `resources/semantic/hgnc_cache.tsv`
430
+ (1,183 CollecTRI source TFs) loaded by `normalize_gene_symbol()`, + regen
431
+ script. Restored `igraph` to `requirements.in`/`.txt` so `dc.pl.network` (TF
432
+ network plot) stops silently failing. Semantic suite 157 passed.
433
+ **Deploy when ready** (dev `hf-dev` first, then prod).
434
+ - 2026-06-22 — **Item 3: per-dataset `preprocessing` field surfaced + re-pin
435
+ 0.1.5 → merged to `main` → DEV** (`982f65a`; `hf-dev` `64a443c..982f65a`,
436
+ rebuilding; prod NOT pushed). Re-pinned biodata-registry 0.1.4 → 0.1.5
437
+ (`b46392c`, sha256 `958f498b…`, folds in the pending Sears 0.1.4 bump); added
438
+ `preprocessing` to `list_available_datasets()` + both agent.py dataset-dict
439
+ builders; prompts.yaml renders a Preprocessing bullet + an Approach rule
440
+ (item 4). 99 tests pass. Registry side released as 0.1.5 (schema field + all
441
+ 19 manifests populated).
442
+ - 2026-06-22 — **Hide microarray from advertising + plain-language Approach in
443
+ solutions** (commit `d8abb63`, dev `hf-dev`). Item 2 agent-side:
444
+ `HIDDEN_MODALITIES`/`is_advertised()`, `advertised` field, MCP tool returns
445
+ advertised-only + `unadvertised_dataset_ids`, prompts flag microarray NOT
446
+ ADVERTISED + heuristics steer to RNA-seq. Item 1: required Approach section +
447
+ decoupleR glossary in every `<solution>`, no missteps. 99 dataset tests green.
448
+ - 2026-06-22 — **REALLY fixed HF Space stuck on "Starting": gradio 5.49 → 6.18
449
+ migration** (commits `b4e7ac0` + `5ba3350`, branch `fix/gradio-6-migration` →
450
+ merged to `main`). The 2026-06-21 SSR fix was a red herring (the working
451
+ orchestrator runs 6.18 *with SSR on*); real cause is gradio 5.49 no longer
452
+ completing HF's readiness handshake. Cascade: `mcp` 1.10.1→1.28.0,
453
+ `langchain-mcp-adapters` 0.2.2→0.3.0, `fastmcp` pinned `==3.2.3`; `gradio_ui.py`
454
+ ported to the 6.0 API; `ssr_mode=False` removed. Verified: 851 tests, 49 tools,
455
+ HTTP 200. **DEV RUNNING** on `5ba3350` (a pause→unpause cleared an HF-side
456
+ rollout wedge). Prod migration still pending (see Open → Deploy/infra).
457
+ - 2026-06-21 — [SUPERSEDED by the gradio-6 migration above] Attempted SSR fix for
458
+ the "Starting" hang: `ssr_mode=False` default in `GradioAgentUI.launch()`
459
+ (`d38496a`). Did not actually fix it; removed during the migration.
460
+ - 2026-06-17 — UI download buttons fixed: exports no longer truncate the
461
+ solution (the old path dropped everything after the first blank line); TXT
462
+ downloads again; added a **Full log (.pdf)** export of the full generated
463
+ logic. All four files are generated at run completion and armed as one-click
464
+ downloads (`_assessment_blocks` / `_log_blocks` / `_write_*` in
465
+ `ui_formatting.py`; `_arm_downloads` in `gradio_ui.py`).
466
+ - 2026-06-17 — Plots render inline: end-of-run sweep of `tmp/outputs` embeds
467
+ figures — including direct `dc.pl`/matplotlib plots that emit no artifact
468
+ JSON — deduped by basename against mid-run artifacts.
469
+ - 2026-06-17 — Full-run PDF (`full_run.pdf`) saved to the results repo
470
+ alongside `conversation.md` / `metadata.json`, with a direct link in the
471
+ saved-run notice (`HFResultsStorage.upload_run_file`).
472
+ - 2026-06-17 — Fixed spurious "Step limit reached" notice after every run: the
473
+ UI streams via `graph.stream()` so `trace_logs` was always empty; completion
474
+ is now keyed off the real solution-shown signal (`last_solution_shown`).
475
+ - 2026-06-17 — Rotated `research_agent_token` (was Known Gaps #5 / ACTION REQUIRED).
476
+ - 2026-06-14 — Bailey 2016 (`paca_au_rnaseq`/`paca_au_array`) + Puleo 2018
477
+ (`puleo_2018`) stress-tested against real hosted data; found + fixed the
478
+ `subset_query` `.query()` syntax bug and a `Sample.type` allowed_values gap.
479
+ - 2026-06-14 — TCGA-PAAD sample curation re-verified end-to-end (Known Gaps #7).
480
+ - 2026-06-12/14 — Live GPL-annotation fallback Tier 1 + Tier 2; 4 residual
481
+ limitations fixed (Known Gaps #14, #15).
482
+ - 2026-06-06 — GDC STAR-Counts loader + tcga_paad pivot to Path A.
483
+ - 2026-06-05 — biodata-registry wired in; manifest rules in system prompt; GEO
484
+ datasets loaded/validated (Known Gaps #0–4).
485
+
486
+ _Full detail for any item: see `memory.md`._
app.py CHANGED
@@ -16,28 +16,16 @@ if os.environ.get("APT_PROBE"):
16
  import subprocess
17
 
18
  def _apt_probe():
19
- pkgs = [
20
- "r-base",
21
- "r-base-dev",
22
- "r-bioc-limma",
23
- "libcurl4-openssl-dev",
24
- "libssl-dev",
25
- "libxml2-dev",
26
- "libreadline-dev",
27
- ]
28
  print("===APT_PROBE_BEGIN===", flush=True)
29
  for p in pkgs:
30
  try:
31
- inst = (
32
- subprocess.run(
33
- ["dpkg-query", "-W", "-f=${Version}", p], capture_output=True, text=True
34
- ).stdout.strip()
35
- or "MISSING"
36
- )
37
- pol = subprocess.run(
38
- ["apt-cache", "policy", p], capture_output=True, text=True
39
- ).stdout
40
- cand = next((ln.split()[1] for ln in pol.splitlines() if "Candidate:" in ln), "?")
41
  print(f"{p} installed={inst} candidate={cand}", flush=True)
42
  except Exception as exc: # never let the probe break app startup
43
  print(f"{p} probe-error={type(exc).__name__}", flush=True)
@@ -50,7 +38,6 @@ if os.environ.get("APT_PROBE"):
50
  sys.path.insert(0, os.path.join(os.path.dirname(os.path.abspath(__file__)), "src"))
51
 
52
  from dotenv import load_dotenv
53
-
54
  load_dotenv("./.env")
55
 
56
  from gradio_ui import GradioAgentUI
 
16
  import subprocess
17
 
18
  def _apt_probe():
19
+ pkgs = ["r-base", "r-base-dev", "r-bioc-limma", "libcurl4-openssl-dev",
20
+ "libssl-dev", "libxml2-dev", "libreadline-dev"]
 
 
 
 
 
 
 
21
  print("===APT_PROBE_BEGIN===", flush=True)
22
  for p in pkgs:
23
  try:
24
+ inst = subprocess.run(["dpkg-query", "-W", "-f=${Version}", p],
25
+ capture_output=True, text=True).stdout.strip() or "MISSING"
26
+ pol = subprocess.run(["apt-cache", "policy", p],
27
+ capture_output=True, text=True).stdout
28
+ cand = next((l.split()[1] for l in pol.splitlines() if "Candidate:" in l), "?")
 
 
 
 
 
29
  print(f"{p} installed={inst} candidate={cand}", flush=True)
30
  except Exception as exc: # never let the probe break app startup
31
  print(f"{p} probe-error={type(exc).__name__}", flush=True)
 
38
  sys.path.insert(0, os.path.join(os.path.dirname(os.path.abspath(__file__)), "src"))
39
 
40
  from dotenv import load_dotenv
 
41
  load_dotenv("./.env")
42
 
43
  from gradio_ui import GradioAgentUI
bandit.yaml DELETED
@@ -1,21 +0,0 @@
1
- # Bandit config — ADR-0014 static-analysis scan (shared across the three repos).
2
- #
3
- # Scope: src/ only (application code). Tests use synthetic data and assert on
4
- # error paths, so scanning them produces noise without signal.
5
- #
6
- # The intended sandboxed `exec` (ADR-0007) and the localhost-only urlopen calls
7
- # are annotated inline with `# nosec Bxxx` and a rationale comment; the remaining
8
- # accepted infra findings (0.0.0.0 bind in the dev launcher, /tmp working dirs on
9
- # ephemeral HF Spaces, GEO-download urlopen) are captured in
10
- # security/bandit-baseline.json so CI fails only on NEW findings, never on the
11
- # already-triaged set. Every accepted class is documented in
12
- # security/ACCEPTED-FINDINGS.md.
13
- #
14
- # Run: bandit -r src/ -ll -c bandit.yaml -b security/bandit-baseline.json
15
- # (-ll = report medium severity and above.)
16
-
17
- exclude_dirs:
18
- - tests
19
- - .venv
20
- - scripts
21
- - docker
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
deploy/scan_posture.yaml DELETED
@@ -1,43 +0,0 @@
1
- # Declared malware-scan posture for the ADR-0011 upload safety gate.
2
- #
3
- # This file is the SINGLE SOURCE OF TRUTH for what an upload actually gets on a
4
- # given deployment. It exists because the posture used to be implicit: the code
5
- # auto-detected ClamAV, so a dev laptop with `brew install clamav` recorded
6
- # `scan_status: clean` while the HuggingFace Space — which has no AV binary —
7
- # recorded `skipped`. Local testing therefore masked production behaviour.
8
- #
9
- # Read by src/uploads/posture.py; consumed by src/uploads/scanning.py.
10
- #
11
- # posture:
12
- # structural_only The deployment has NO anti-virus binary. Uploads get the
13
- # always-on structural magic-byte check (a renamed
14
- # ELF/Mach-O/PE/ZIP/pickle is a hard stop) and NOTHING else.
15
- # An upload is never described as malware-scanned. If an AV
16
- # happens to be present (a dev host), it still runs and a
17
- # `clean` is recorded, but a caveat notes the result is a
18
- # local-host bonus that the deployment does not guarantee.
19
- # av_required The deployment DOES ship a working, signature-updated AV.
20
- # Equivalent to UPLOAD_SCAN_REQUIRED=1 — an upload with no
21
- # clean AV result fails closed. Do NOT select this without a
22
- # verified AV binary AND a current virus database: with no
23
- # signature DB, clamscan exits 2 and EVERY upload hard-fails.
24
- #
25
- # Env override (for local experiments / CI): UPLOAD_SCAN_POSTURE, and the
26
- # pre-existing UPLOAD_SCAN_REQUIRED, both still win over this file.
27
-
28
- # --------------------------------------------------------------------------- #
29
- # Current deployed posture — anne-voigt/Paper2Agent_decoupleRpy (prod) and
30
- # Paper2Agent_decoupleRpy_dev. Verified 2026-07-29 against the running prod
31
- # Space at 7ef9d42: it is an `sdk: gradio` Space, so its only apt channel is
32
- # packages.txt, and packages.txt has no clamav entry -> no clamdscan/clamscan on
33
- # PATH -> every upload there records scan_status="skipped".
34
- #
35
- # Why not install ClamAV here: ADR-0011's remaining-at-AWS item already moves
36
- # the managed AV pass onto the encrypted staged S3 object, which supersedes a
37
- # local-AV build. On a cpu-basic Gradio Space `apt install clamav` ships no
38
- # signature database, so it would need a freshclam download (~1 GB, several
39
- # minutes) on every container start — and a stale or failed freshclam turns
40
- # av_required into a total upload outage. Structural-only, stated honestly, is
41
- # the correct interim posture until the AWS staging bucket exists.
42
- # --------------------------------------------------------------------------- #
43
- posture: structural_only
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
docker/sandbox.Dockerfile DELETED
@@ -1,73 +0,0 @@
1
- # ADR-0007 Phase 1 — sandbox exec-kernel image.
2
- #
3
- # This image runs ONLY the untrusted-code exec-kernel
4
- # (src/managers/execution/sandbox/kernel.py) inside an isolated per-session
5
- # container. The vetted decoupleR/scanpy/rpy2 tool implementations live OUTSIDE
6
- # the sandbox in the persistent MCP HTTP server — the kernel reaches them via the
7
- # MCP bridge (SANDBOX_MCP_URL). Even so, the image carries the SAME scientific
8
- # stack as the Space (scanpy/decoupler/pydeseq2/rpy2 + R) so generated code that
9
- # imports those libraries locally still runs.
10
- #
11
- # The HF Space itself is a gradio SDK Space (no custom image), so there is no
12
- # pre-built base image to inherit; this mirrors the Space's environment from
13
- # python_version (3.11), packages.txt (apt), and requirements.txt (pip).
14
- #
15
- # ---------------------------------------------------------------------------
16
- # BUILD / RUN (needs local Docker — NOT built in the dep-light dev env):
17
- #
18
- # docker build -f docker/sandbox.Dockerfile -t decouplerpy-sandbox:latest .
19
- #
20
- # # ad-hoc smoke test (kernel only, no MCP):
21
- # docker run --rm -p 127.0.0.1:8790:8790 decouplerpy-sandbox:latest \
22
- # python /app/src/managers/execution/sandbox/kernel.py --port 8790 --host 0.0.0.0
23
- # curl -s http://127.0.0.1:8790/health
24
- #
25
- # In prod the ContainerLauncher issues an equivalent `docker run` per session,
26
- # publishing the kernel port to LOCALHOST only. Phase-2 hardening flags
27
- # (--read-only, --cap-drop=ALL, --tmpfs, --pids-limit, --network) are applied by
28
- # the launcher / task definition, not baked here.
29
- # ---------------------------------------------------------------------------
30
-
31
- FROM python:3.11-slim
32
-
33
- # System packages mirror the Space's packages.txt (R + limma + build libs that
34
- # rpy2 / scientific wheels need). Pins are dropped here (slim/bookworm apt has
35
- # different candidate versions than the Space base); pin if a build needs it.
36
- RUN apt-get update && apt-get install -y --no-install-recommends \
37
- r-base \
38
- r-base-dev \
39
- r-bioc-limma \
40
- libcurl4-openssl-dev \
41
- libssl-dev \
42
- libxml2-dev \
43
- libreadline-dev \
44
- && rm -rf /var/lib/apt/lists/*
45
-
46
- WORKDIR /app
47
-
48
- # Install the Python deps first (layer-cached independently of source changes).
49
- COPY requirements.txt /app/requirements.txt
50
- RUN pip install --no-cache-dir -r /app/requirements.txt
51
-
52
- # Copy the agent source. The kernel is launched BY FILE PATH
53
- # (/app/src/managers/execution/sandbox/kernel.py), NOT `-m managers...`, so the
54
- # full `managers` package (agent stack) is never imported into the sandbox — only
55
- # the minimal kernel + its file-path-loaded siblings run here. PYTHONPATH is kept
56
- # as a harmless fallback.
57
- COPY src /app/src
58
- ENV PYTHONPATH=/app/src
59
-
60
- # --- Non-root user (Phase 1 minimum; Phase 2 adds fuller hardening). ----------
61
- RUN useradd --create-home --uid 10001 sandbox \
62
- && chown -R sandbox:sandbox /app
63
- USER sandbox
64
-
65
- # Kernel port (per-session the launcher may override with --port).
66
- ENV SANDBOX_KERNEL_PORT=8790
67
- EXPOSE 8790
68
-
69
- # Bind to 0.0.0.0 INSIDE the container; the launcher publishes only the mapped
70
- # port to the host's 127.0.0.1, so the kernel is never on a routable interface.
71
- # File-path launch (not `-m`) keeps the agent-stack package out of the sandbox.
72
- ENTRYPOINT ["python", "/app/src/managers/execution/sandbox/kernel.py"]
73
- CMD ["--port", "8790", "--host", "0.0.0.0"]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
docs/adr/ADR-0003-spaces-dev-mode.md CHANGED
@@ -1,6 +1,6 @@
1
  # ADR-0003: Spaces Dev Mode for Iterative Development
2
 
3
- **Status:** Accepted — **CLOSED 2026-07-01.** Dev Mode enabled on both dev Spaces (`Paper2Agent_decoupleRpy_dev`, `pdac-analysis-orchestrator-dev`, confirmed 2026-06-24). The remaining housekeeping is now done: the "Dev Mode inner loop" note, the SSH / VS Code Remote setup step, and the commit-before-promote convention are all documented in `pdac-analysis-orchestrator/DEPLOYMENT.md` (tier "C. Dev Mode inner loop" + the "Golden rule"); `hf-dev/main` verified current with `main` (ahead by unpromoted dev work, `main` 0 ahead — not stale). The one inherently per-developer step (attaching VS Code Remote-SSH via the command in each Space's Dev Mode panel) is documented, not a repo deliverable. Reconciled + closed 2026-07-01.
4
  **Date:** 2026-06-24
5
  **Deciders:** Annie Voigt (project lead)
6
  **Scope:** `DecoupleRpy_Agent` (specialist Space) and `pdac-analysis-orchestrator` (coordinator Space). Enabled by the HF PRO subscription (2026-06). No code change in either repo — this is a Space-settings + workflow change. The orchestrator is named here because it shares the same deploy-and-wait pain; the ADR lives in `DecoupleRpy_Agent/docs/adr/` per the existing ADR-home convention (cf. ADR-0001's cross-repo placement note).
@@ -101,10 +101,10 @@ and does not change how secrets/tokens are managed.
101
  ## Action Items
102
 
103
  1. [x] Enable Dev Mode on `Paper2Agent_decoupleRpy_dev` and `pdac-analysis-orchestrator-dev`. *(done — both enabled, confirmed 2026-06-24)*
104
- 2. [x] Confirm `hf-dev` actually tracks current `main` before relying on it. *(done 2026-07-01 `git rev-list --left-right --count main...hf-dev/main` = `0 44`: `hf-dev/main` contains everything on `main` plus unpromoted dev work; `main` is 0 ahead. Not stale. Caveat: compared against local remote-tracking refs; re-run after a `git fetch hf-dev` if in doubt.)*
105
- 3. [x] Add a short "Dev Mode inner loop" note to `pdac-analysis-orchestrator/DEPLOYMENT.md` so the workflow is documented next to the deploy rules. *(done — see DEPLOYMENT.md tier "C. Dev Mode inner loop", which links back to this ADR.)*
106
- 4. [x] Set up VS Code Remote / SSH config for both dev Spaces. *(procedure documented in DEPLOYMENT.md: one-time copy of the SSH command from each Space's Settings → Dev Mode panel, then attach with VS Code Remote-SSH. The attach itself is a per-developer/per-machine step, not a repo artifact.)*
107
- 5. [x] Convention: anything proven in a Dev Mode session must land as a commit before prod promotion. *(documented as the "Golden rule (Dev Mode)" in DEPLOYMENT.md.)*
108
 
109
  ## References
110
 
 
1
  # ADR-0003: Spaces Dev Mode for Iterative Development
2
 
3
+ **Status:** Accepted — decision adopted. **Dev Mode enabled on both dev Spaces** (`Paper2Agent_decoupleRpy_dev`, `pdac-analysis-orchestrator-dev`), confirmed 2026-06-24. Remaining action items (DEPLOYMENT.md inner-loop note; VS Code/SSH config) still open. Reconciled 2026-06-24.
4
  **Date:** 2026-06-24
5
  **Deciders:** Annie Voigt (project lead)
6
  **Scope:** `DecoupleRpy_Agent` (specialist Space) and `pdac-analysis-orchestrator` (coordinator Space). Enabled by the HF PRO subscription (2026-06). No code change in either repo — this is a Space-settings + workflow change. The orchestrator is named here because it shares the same deploy-and-wait pain; the ADR lives in `DecoupleRpy_Agent/docs/adr/` per the existing ADR-home convention (cf. ADR-0001's cross-repo placement note).
 
101
  ## Action Items
102
 
103
  1. [x] Enable Dev Mode on `Paper2Agent_decoupleRpy_dev` and `pdac-analysis-orchestrator-dev`. *(done — both enabled, confirmed 2026-06-24)*
104
+ 2. [ ] Confirm `hf-dev` actually tracks current `main` before relying on it (SHOWCASE_STATUS flags it as possibly stale).
105
+ 3. [ ] Add a short "Dev Mode inner loop" note to `pdac-analysis-orchestrator/DEPLOYMENT.md` so the workflow is documented next to the deploy rules.
106
+ 4. [ ] Set up VS Code Remote / SSH config for both dev Spaces.
107
+ 5. [ ] Convention: anything proven in a Dev Mode session must land as a commit before prod promotion.
108
 
109
  ## References
110
 
docs/adr/ADR-0005-private-storage-persistence.md CHANGED
@@ -1,6 +1,6 @@
1
  # ADR-0005: Private Storage as Durable Data Persistence
2
 
3
- **Status:** Accepted — **CLOSED 2026-07-01.** Durable, private-by-default data tier adopted; `pdac-research-data` confirmed private (2026-06-24). Remaining housekeeping now done: the "promote to public on publication" step and the private-Data-Studio validation workflow are documented in `biodata-registry/CLAUDE.md` ("Adding a New Dataset" step 5 + "Manifest validation workflow"), with a dated entry in `biodata-registry/memory.md`. One out-of-repo note (SHOWCASE_STATUS.md h5ad hosting) is tracked below. Reconciled + closed 2026-07-01.
4
  **Date:** 2026-06-24
5
  **Deciders:** Annie Voigt (project lead)
6
  **Scope:** Cross-cutting data layer — primarily the `anne-voigt/pdac-research-data` HF Dataset repo (h5ad hosting consumed by `DecoupleRpy_Agent`), with knock-on benefit to `lit-agent`'s corpus snapshots. Enabled by HF PRO (2026-06). Lives in `DecoupleRpy_Agent/docs/adr/` as the primary data consumer; the `lit-agent` corpus-persistence specifics are governed by its own ADRs.
@@ -92,10 +92,10 @@ registry — only where/how the underlying files are stored and inspected.
92
  ## Action Items
93
 
94
  1. [x] Confirm `pdac-research-data` visibility is private and audit which files are public. *(confirmed private 2026-06-24)*
95
- 2. [x] Stop any pruning driven solely by storage limits; retain corpus history in lit-agent's durable dataset. *(codified — `lit-agent/CLAUDE.md` "Retention (PRO storage): keep corpus snapshots across runs rather than minimizing them".)*
96
- 3. [x] Add a "promote to public on publication" step to the dataset-onboarding checklist. *(done 2026-07-01 — `biodata-registry/CLAUDE.md` "Adding a New Dataset" step 5: private-by-default; publish = explicit promote-to-public.)*
97
- 4. [x] Use private Data Studio as the first-pass check in the `dataset_validate_manifest_against_data` workflow. *(done 2026-07-01 — `biodata-registry/CLAUDE.md` "Manifest validation workflow".)*
98
- 5. [x] Record the new storage posture in `SHOWCASE_STATUS.md` (h5ad hosting note) and `biodata-registry/memory.md` (validation workflow). *(done 2026-07-01 — `biodata-registry/memory.md` dated entry + a "h5ad hosting posture — private-by-default (ADR-0005)" bullet in `SHOWCASE_STATUS.md`'s biodata-registry section.)*
99
 
100
  ## References
101
 
 
1
  # ADR-0005: Private Storage as Durable Data Persistence
2
 
3
+ **Status:** Accepted — durable, private-by-default data tier adopted. **`pdac-research-data` confirmed private** 2026-06-24. Remaining action items (onboarding "promote to public on publication" checklist step; private Data Studio verification workflow; `biodata-registry/memory.md` note) still open. Reconciled 2026-06-24.
4
  **Date:** 2026-06-24
5
  **Deciders:** Annie Voigt (project lead)
6
  **Scope:** Cross-cutting data layer — primarily the `anne-voigt/pdac-research-data` HF Dataset repo (h5ad hosting consumed by `DecoupleRpy_Agent`), with knock-on benefit to `lit-agent`'s corpus snapshots. Enabled by HF PRO (2026-06). Lives in `DecoupleRpy_Agent/docs/adr/` as the primary data consumer; the `lit-agent` corpus-persistence specifics are governed by its own ADRs.
 
92
  ## Action Items
93
 
94
  1. [x] Confirm `pdac-research-data` visibility is private and audit which files are public. *(confirmed private 2026-06-24)*
95
+ 2. [ ] Stop any pruning driven solely by storage limits; retain corpus history in lit-agent's durable dataset.
96
+ 3. [ ] Add a "promote to public on publication" step to the dataset-onboarding checklist.
97
+ 4. [ ] Use private Data Studio as the first-pass check in the `dataset_validate_manifest_against_data` workflow.
98
+ 5. [ ] Record the new storage posture in `SHOWCASE_STATUS.md` (h5ad hosting note) and `biodata-registry/memory.md` (validation workflow).
99
 
100
  ## References
101
 
docs/adr/ADR-0006-loveless-single-cell-serving.md CHANGED
@@ -158,9 +158,9 @@ artifacts for.
158
  4. [x] Add `src/workflows/signatures.py` (load/validate a `source/target/weight` signature net) + tests with synthetic data.
159
  5. [x] **biodata-registry (0.1.8, 2026-07-01):** two Loveless-atlas single-cell subsets landed — `gse155698_steele` and `gse205013_werba` (`modality: sc_rnaseq`, `raw_counts`, `expression_source.type: url` → hosted `.h5ad` on `pdac-research-data`) — and the `CROSS_RESOLUTION` gate. **Role 2 signature artifact published:** `scripts/ingest/loveless/derive_signatures.py` derives 14 per-cell-type marker signatures from the Steele subset (`rank_genes_groups` on `Clusters`, top-50/cluster, log2FC≥1, padj≤0.05) → `loveless/signatures/gse155698_steele_celltype_signatures.csv`. Markers are biologically sane (ACINAR→PRSS1/CLPS, FIBROBLASTS→LUM/COL11A1, B CELLS→PAX5/IGHD, …). `dataset_score_signature` / `load_signature_net` load it via the authenticated resolver and score end-to-end. Werba signatures can be derived the same way when needed.
160
  6. [x] Add an `h5ad` / single-cell `expression_source.type` to `_build_loading_plan` so a Steele-subset dataset gets a registry loading plan. Done — plus the schema alignment it required: `manifest_schema` now adds `"h5ad"` to `VALID_EXPRESSION_SOURCE_TYPES` and derives `analysis_path == "P"` for `sc_rnaseq` / `spatial_rnaseq` (modality checked **before** data_level, so an sc raw_counts h5ad is never mislabeled Path A — matches biodata-registry 0.1.7's A/B/P). The plan loads via `decoupler_load_and_visualize_data` (`read_h5ad_cached`) and Path P emits per-cell scoring + a pseudobulk note instead of the bulk DESeq2/limma contrast tail. Tests: `tests/test_loading_plan_h5ad.py`. The sc loaders (`rna_sc.py::_load_adata`) now resolve a hosted/private h5ad URL→local path through the shared authenticated resolver (`resolve_to_local_path`, `HF_TOKEN`) — the same path the bulk tools use — so a private `pdac-research-data` h5ad loads end-to-end; a stable HF-cache file is parsed once per process via `read_h5ad_cached`, a one-shot temp download is read directly and deleted (`tests/test_rna_sc_loader.py`). **Reconciled with the landed 0.1.8 manifests:** they declare `expression_source.type: url` (not `h5ad`), so Path P routing in `_build_loading_plan` now keys on **modality** (checked first) — a single-cell/spatial dataset loads via `decoupler_load_and_visualize_data`, never the bulk `decoupler_load_url_counts`, regardless of the declared source-type string. Verified against the live `gse155698_steele` / `gse205013_werba` manifests.
161
- 7. [x] Confirm the prod Space RAM tier holds the Steele-subset h5ad live (it should; the raw atlas must not be served live). **Verified 2026-08-13 by two live agent runs on prod** (runs `20260813_191535` Steele, `20260813_192108` Werba in `anne-voigt/decoupleRpy_results`): both slimmed subsets load via `dataset_load` and render a UMAP end-to-end on the cpu-basic Space (Werba, the larger at 167,366 cells, in 640 s using the atlas's precomputed `X_umap`). The raw atlas remains off the live path.
162
- 8. [x] Re-pin biodata-registry to the release carrying the Loveless artifacts — done, and the factory-rebuild worry is closed: prod was **factory-rebuilt on 0.1.16** (2026-08-13) with both sc manifests registered; the item-7 runs verify it end-to-end on prod (superseding the planned `hf-dev` check).
163
- 9. [ ] Pair with the PURIST-vs-single-cell methodology comparison (TODO) once the subset is loadable. *Unblocked as of 2026-08-13 (both subsets load on prod); waits on the Med TODO "Add the PURIST subtype operation".*
164
 
165
  ## References
166
 
 
158
  4. [x] Add `src/workflows/signatures.py` (load/validate a `source/target/weight` signature net) + tests with synthetic data.
159
  5. [x] **biodata-registry (0.1.8, 2026-07-01):** two Loveless-atlas single-cell subsets landed — `gse155698_steele` and `gse205013_werba` (`modality: sc_rnaseq`, `raw_counts`, `expression_source.type: url` → hosted `.h5ad` on `pdac-research-data`) — and the `CROSS_RESOLUTION` gate. **Role 2 signature artifact published:** `scripts/ingest/loveless/derive_signatures.py` derives 14 per-cell-type marker signatures from the Steele subset (`rank_genes_groups` on `Clusters`, top-50/cluster, log2FC≥1, padj≤0.05) → `loveless/signatures/gse155698_steele_celltype_signatures.csv`. Markers are biologically sane (ACINAR→PRSS1/CLPS, FIBROBLASTS→LUM/COL11A1, B CELLS→PAX5/IGHD, …). `dataset_score_signature` / `load_signature_net` load it via the authenticated resolver and score end-to-end. Werba signatures can be derived the same way when needed.
160
  6. [x] Add an `h5ad` / single-cell `expression_source.type` to `_build_loading_plan` so a Steele-subset dataset gets a registry loading plan. Done — plus the schema alignment it required: `manifest_schema` now adds `"h5ad"` to `VALID_EXPRESSION_SOURCE_TYPES` and derives `analysis_path == "P"` for `sc_rnaseq` / `spatial_rnaseq` (modality checked **before** data_level, so an sc raw_counts h5ad is never mislabeled Path A — matches biodata-registry 0.1.7's A/B/P). The plan loads via `decoupler_load_and_visualize_data` (`read_h5ad_cached`) and Path P emits per-cell scoring + a pseudobulk note instead of the bulk DESeq2/limma contrast tail. Tests: `tests/test_loading_plan_h5ad.py`. The sc loaders (`rna_sc.py::_load_adata`) now resolve a hosted/private h5ad URL→local path through the shared authenticated resolver (`resolve_to_local_path`, `HF_TOKEN`) — the same path the bulk tools use — so a private `pdac-research-data` h5ad loads end-to-end; a stable HF-cache file is parsed once per process via `read_h5ad_cached`, a one-shot temp download is read directly and deleted (`tests/test_rna_sc_loader.py`). **Reconciled with the landed 0.1.8 manifests:** they declare `expression_source.type: url` (not `h5ad`), so Path P routing in `_build_loading_plan` now keys on **modality** (checked first) — a single-cell/spatial dataset loads via `decoupler_load_and_visualize_data`, never the bulk `decoupler_load_url_counts`, regardless of the declared source-type string. Verified against the live `gse155698_steele` / `gse205013_werba` manifests.
161
+ 7. [ ] Confirm the prod Space RAM tier holds the Steele-subset h5ad live (it should; the raw atlas must not be served live).
162
+ 8. [~] Re-pin biodata-registry to the release carrying the Loveless artifacts (0.1.8) **done** (`requirements.in`/`.txt`; already on `origin/main` + prod, now on this branch too). **Still open:** the 0.1.8 re-pin needs a FACTORY rebuild of the consuming Space (pip/build-cache lesson from 0.1.6), then end-to-end verify on `hf-dev`.
163
+ 9. [ ] Pair with the PURIST-vs-single-cell methodology comparison (TODO) once the subset is loadable.
164
 
165
  ## References
166
 
docs/adr/ADR-0007-phase1-local-validation.md DELETED
@@ -1,147 +0,0 @@
1
- # ADR-0007 Phase 1 — Local Validation Checklist
2
-
3
- Validates the sandboxed executor on a machine with Docker + the heavy deps
4
- (rpy2/scanpy/decoupler) — the parts that couldn't run in the build environment.
5
- Branch under test: `feat/sandbox-executor` (stacked on `feat/executor-seam`).
6
-
7
- **Validation run: 2026-07-01** (macOS, Docker 28.4.0, `.venv` Python 3.12 with
8
- scanpy 1.12.1 / decoupler 2.1.6; image built on python:3.11-slim). Result:
9
- **Sections 0–8 validated** (with the noted caveats); Section 9 is the merge step,
10
- not run here. Two real container-launch bugs were found and fixed, and the MCP
11
- bridge's real streamable-http handshake was implemented (was a mock-only stub).
12
-
13
- ## 0. Setup
14
- - [x] `git checkout feat/sandbox-executor`
15
- - [x] Full deps available in `.venv` (scanpy/decoupler present; **rpy2 not in the
16
- local `.venv`** — R paths validated inside the container instead). Docker running.
17
-
18
- ## 1. Suite still green with real deps
19
- - [x] Full suite green **per-file**: all 46 `tests/test_*.py` files pass or skip
20
- (5 are network/data integration tests that `skip`), **0 failures**.
21
- - [x] `pytest tests/test_sandbox_executor.py tests/test_executor_seam.py` →
22
- **27/27** (was 22; +5 regression tests added for the fixes below).
23
- - ⚠ A single-invocation `pytest -q` **cannot collect** the whole repo: (a)
24
- `scripts/check_limma_runtime.py` is a standalone script that `sys.exit(1)`s at
25
- import when rpy2 is absent, and (b) the `tests/` files insert `sys.path`
26
- differently, so collecting them together pollutes `managers` import resolution.
27
- **Both are pre-existing and unrelated to this branch** (confirmed: not in the
28
- branch diff). Run per-file, or with `PYTHONPATH=src` and excluding `scripts/`.
29
-
30
- ## 2. Default path unchanged (regression guard)
31
- - [x] With `EXECUTOR` unset, `get_executor()` returns an in-process
32
- `PythonExecutor`; a multi-step session runs and state persists — no behavior
33
- change.
34
-
35
- ## 3. Subprocess launcher, end-to-end (no Docker)
36
- - [x] `EXECUTOR=sandbox SANDBOX_LAUNCHER=subprocess` → multi-step session.
37
- - [x] State persists across steps (a var from step 1 is visible in step 2/3),
38
- stdout returns correctly, and the `kernel.py` subprocess is reaped on
39
- `close()` (`pgrep -f sandbox/kernel.py`: 1 while open → 0 after).
40
-
41
- ## 4. Build the sandbox image
42
- - [x] `docker build -f docker/sandbox.Dockerfile -t decouplerpy-sandbox:latest .`
43
- → built (2.91 GB). **Added `.dockerignore`** so the build context is `src/`
44
- + `requirements.txt`, not the full 3.2 GB repo (`.venv`, `.git`, etc.).
45
- - [x] Kernel is the entrypoint; container starts and `/health` returns
46
- `{"status":"ok"}`.
47
- - [x] Runs as **non-root**: `uid=10001(sandbox)`.
48
- ⚠ The `docker run --rm <image> whoami` form checks nothing — the image
49
- ENTRYPOINT swallows `whoami` as a kernel arg. Use
50
- `docker run --rm --entrypoint whoami <image>`.
51
-
52
- ## 5. Container launcher, end-to-end
53
- - [x] `EXECUTOR=sandbox SANDBOX_LAUNCHER=container SANDBOX_IMAGE=decouplerpy-sandbox:latest`
54
- → session runs inside the container.
55
- - [x] scanpy + decoupler work inside the container and **state persists** across
56
- execute calls (built an AnnData in step 1, normalized it in step 2).
57
- - [x] Container is torn down on session end (`docker ps -a` — 0 leftover).
58
- - ⚠ `import rpy2.robjects` (the R/limma DE path) **fails inside the image**:
59
- `tzlocal==5.4.2` in `requirements.txt` resolves to a **metadata-only wheel**
60
- (its `RECORD` lists only `*.dist-info`; a force-reinstall still writes no
61
- `tzlocal/` package). This is a **requirements/lockfile packaging bug, not an
62
- ADR-0007 issue** — it blocks only the rpy2/limma method (ttest/DESeq2 paths and
63
- all of scanpy/decoupler are unaffected). Fix separately (re-pin/repair tzlocal).
64
- - **Two container-launch bugs found & fixed** (`launchers.py`):
65
- 1. `ContainerLauncher._kernel_command()` prepended `python <kernel-path>`, but
66
- the image ENTRYPOINT is *already* `python .../kernel.py` — so the container
67
- ran `python kernel.py python kernel.py …`, argparse rejected it, and the
68
- kernel exited before `/health`. Now returns **args only** (matching the
69
- Dockerfile `CMD`).
70
- 2. `start()` only caught `ImportError` for the docker SDK, so an `import docker`
71
- that resolves to the repo's shadowing `docker/` namespace dir (no `from_env`)
72
- raised `AttributeError` instead of falling back to the CLI. Now any
73
- unusable-SDK case (`ImportError`, missing `from_env`, daemon down) falls back
74
- to the `docker` CLI.
75
-
76
- ## 6. MCP bridge — real handshake (the marked TODO)
77
- - [x] Pointed `SANDBOX_MCP_URL` at a live `server.py --transport streamable-http`
78
- (mounted at `/mcp/`).
79
- - [x] **Implemented the real handshake** in `sandbox/mcp_bridge.py`: a
80
- `streamable_http` transport that drives the same `mcp` client path as
81
- `mcp_manager.add_mcp_http` (`streamablehttp_client` → `initialize` →
82
- `tools/call`), plus transport auto-selection (`/mcp` → streamable-http,
83
- else → the existing plain-JSON mock/adapter transport;
84
- `SANDBOX_MCP_TRANSPORT` overrides). Previously `call_mcp_tool` only spoke
85
- the mock JSON protocol.
86
- - [x] Confirmed an in-kernel tool stub invokes a **real** MCP tool
87
- (`dataset_list_available`) over streamable-http and returns real manifest
88
- data — not the mock.
89
-
90
- ## 7. Trace/logging intact through the sandbox
91
- - [x] Verified in-scope: tracing is **executor-agnostic**. The workflow engine
92
- records `code_execution` (generated code) and `observation` (captured
93
- stdout — tool results, dataset-load prints) in the *agent* process around
94
- `self.python_executor(code)` (`agent.py:261`); the executor only returns a
95
- stdout string. Same code → **byte-identical stdout** from `PythonExecutor`
96
- and the sandbox (both reuse `NamespaceKernel.exec_capture`), so the trace
97
- captures identically.
98
- - ⚠ Full log-**sink persistence** (`from logging_sink import …` in `agent.py`)
99
- lives on `feat/configurable-log-sink`, which is **not on this branch** and, per
100
- Section 9, merges *before* `feat/sandbox-executor`. End-to-end sink validation
101
- belongs to that combined-branch stage; nothing in the sandbox path affects it.
102
-
103
- ## 8. Light security spot-checks (full hardening is Phase 2)
104
- - [x] Kernel binds localhost only: subprocess launcher `host=127.0.0.1`; container
105
- publishes `127.0.0.1:<port>:<port>` (`docker port` → `… -> 127.0.0.1:<port>`,
106
- never `0.0.0.0`).
107
- - [x] Generated code in the **container** cannot read host secrets: with
108
- `ANTHROPIC_API_KEY`/`MY_HOST_SECRET` set on the host, in-sandbox
109
- `os.environ.get(...)` returns `None` for both (only `SANDBOX_MCP_URL` is
110
- passed in).
111
- - ⚠ The **subprocess** launcher inherits `os.environ` (`env = dict(os.environ)`),
112
- so in dev/subprocess mode generated code *can* read host env. Only the
113
- **container** launcher is a real isolation boundary — the subprocess launcher is
114
- a dev/test convenience, not a security boundary.
115
- - Phase 2 status: the container-side flags are now **done** — read-only rootfs,
116
- `--cap-drop=ALL`, `--security-opt=no-new-privileges`, memory/CPU/pids limits,
117
- writable tmpfs scratch, and `--network` (env-overridable), with the subprocess
118
- launcher explicitly marked "not a security boundary" in code. Egress
119
- deny-by-default + MCP allow-list and the read-only source mount remain
120
- follow-ups (deferred to Phase 3 / a local proxy spike). Full checklist +
121
- live-verification results: **`docs/adr/ADR-0007-phase2-local-hardening.md`**.
122
-
123
- ## 9. Merge order (once green)
124
- - [ ] `feat/executor-seam` → main (Phase 0, safe no-op default).
125
- - [ ] `feat/configurable-log-sink` → main.
126
- - [ ] `feat/sandbox-executor` → main (keep `EXECUTOR=in_process` default in prod
127
- until the AWS/container path is validated in a real deploy — Phase 3).
128
-
129
- ---
130
-
131
- ## Changes made during this validation
132
- - `src/managers/execution/sandbox/mcp_bridge.py` — implemented the real
133
- streamable-http tool-dispatch transport + `select_transport()` auto-detection
134
- (Section 6); kept the plain-JSON transport for the mock/adapter.
135
- - `src/managers/execution/sandbox/launchers.py` — fixed the two container-launch
136
- bugs in Section 5 (args-only kernel command; robust docker-SDK→CLI fallback).
137
- - `.dockerignore` — new; trims the build context (Section 4).
138
- - `tests/test_sandbox_executor.py` — +5 tests: container command is args-only,
139
- localhost-only publish, SDK→CLI fallback, and MCP transport selection.
140
-
141
- ## Follow-ups (out of ADR-0007 scope)
142
- - **`tzlocal==5.4.2` is a metadata-only wheel** → `import rpy2.robjects` fails in
143
- the image (and the R/limma path in the Space if it uses the same pin). Re-pin or
144
- repair. Tracked separately.
145
- - Repo-wide `pytest -q` can't collect in one shot (standalone `scripts/test_*`
146
- that `sys.exit`s + cross-file `sys.path` pollution). A `conftest.py`/`pyproject`
147
- `testpaths`+`pythonpath` config would let CI run the suite in one invocation.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
docs/adr/ADR-0007-phase2-local-hardening.md DELETED
@@ -1,122 +0,0 @@
1
- # ADR-0007 Phase 2 — Local Hardening Checklist
2
-
3
- Locks down the `ContainerLauncher` sandbox with `docker run` least-privilege
4
- flags, still on **local Docker** (AWS is Phase 3). Follows Phase 1
5
- (`feat/sandbox-executor`, commit `ffba834` / validation `d864312`), where the
6
- container ran with default Docker privileges.
7
-
8
- **Scope:** `src/managers/execution/sandbox/launchers.py` (`ContainerLauncher`) +
9
- `tests/test_sandbox_executor.py`. `SubprocessLauncher` is deliberately **not**
10
- touched — it is a dev/test convenience and **not a security boundary** (its
11
- docstring now says so explicitly). The prod default stays `EXECUTOR=in_process`;
12
- these changes only affect `SANDBOX_LAUNCHER=container`.
13
-
14
- **Validated: 2026-07-01** (macOS, Docker 28.4.0, image `decouplerpy-sandbox:latest`
15
- already built from Phase 1). All 22 tests in `tests/test_sandbox_executor.py`
16
- pass, **including the live hardened-container integration test** (see §3).
17
-
18
- ---
19
-
20
- ## 1. Container flags applied (the `_hardening()` set)
21
-
22
- All derive from ONE `_hardening()` source read at launch time, formatted for both
23
- the CLI (`_docker_run_cmd` → `_hardening_flags`) and the docker-SDK
24
- (`start` → `_hardening_kwargs`) paths so the two **cannot drift**.
25
-
26
- | Control | CLI flag | SDK kwarg | Why |
27
- |---|---|---|---|
28
- | Immutable root FS | `--read-only` | `read_only=True` | code can't tamper with the image / persist between runs |
29
- | Drop all caps | `--cap-drop=ALL` | `cap_drop=["ALL"]` | kernel needs none (binds an unprivileged high port) |
30
- | No priv-escalation | `--security-opt=no-new-privileges` | `security_opt=["no-new-privileges"]` | block setuid/setgid escalation |
31
- | RAM cap | `--memory` (`SANDBOX_MEMORY`, def `4g`) | `mem_limit` | OOM-kill runaway allocs |
32
- | CPU cap | `--cpus` (`SANDBOX_CPUS`, def `2`) | `nano_cpus=int(cpus*1e9)` | bound CPU spin |
33
- | PID cap | `--pids-limit` (`SANDBOX_PIDS_LIMIT`, def `512`) | `pids_limit` | fork/thread-bomb guard |
34
- | Network mode | `--network` (`SANDBOX_NETWORK`, def `bridge`) | `network_mode` | see §2 |
35
- | Writable scratch | `--tmpfs /tmp`, `--tmpfs /home/sandbox` (`SANDBOX_TMPFS_SIZE`, def `1g`) | `tmpfs={...}` | see below |
36
- | MCP host resolution | `--add-host=host.docker.internal:host-gateway` | `extra_hosts={...}` | MCP reachable on Linux too (dropped when `network=none`) |
37
-
38
- **Read-only rootfs + writable tmpfs.** `--read-only` freezes the whole FS, so
39
- anything that writes at runtime needs an explicit escape hatch:
40
- - **`/tmp`** (`mode=1777`) — generic scratch for generated analysis code (scanpy
41
- figure exports, pydeseq2 intermediates, temp files).
42
- - **HOME `/home/sandbox`** (owned by uid `10001`) — matplotlib / numba /
43
- fontconfig write caches under `~/.config` and `~/.cache` **at import time**;
44
- with a read-only HOME, `import scanpy` (which imports matplotlib) crashes
45
- before any user code runs. The live test §3(a) confirms the exec-kernel itself
46
- boots and runs code fine under the read-only rootfs + these two tmpfs mounts.
47
-
48
- tmpfs is RAM-backed, size-capped (so a runaway write can't exhaust host memory),
49
- and vanishes on teardown — matching the ADR's "nothing persists past the
50
- session" intent.
51
-
52
- **Env overrides** (mirroring the `get_executor` / `get_log_sink` env-var style):
53
- `SANDBOX_MEMORY`, `SANDBOX_CPUS`, `SANDBOX_PIDS_LIMIT`, `SANDBOX_NETWORK`,
54
- `SANDBOX_TMPFS_SIZE`. All optional with sane defaults; retune per deploy without
55
- a code change.
56
-
57
- ## 2. Egress — what's done, what remains (the honest bit)
58
-
59
- The ADR calls for **deny-by-default egress with an allow-list for only the MCP
60
- endpoint** ("Local egress control"). The nuance: the in-kernel MCP tool stubs
61
- must still reach the MCP HTTP server (`SANDBOX_MCP_URL`), so a blanket
62
- `--network none` is wrong — it severs the MCP bridge.
63
-
64
- **Done in Phase 2 (local):**
65
- - `SANDBOX_NETWORK` is env-overridable, default `bridge` — MCP reachable via
66
- `host.docker.internal`, with `--add-host=host.docker.internal:host-gateway`
67
- added so the name resolves on native Linux too (it's automatic on Docker
68
- Desktop/Mac/Win). Live-verified reachable in §3(c).
69
- - `SANDBOX_NETWORK=none` is available as a **full-egress-denial** escape hatch
70
- today, for operators who pre-stage all data and don't route MCP over the
71
- container network. When set, the (now-meaningless) host-gateway mapping is
72
- dropped.
73
-
74
- **Deferred — deny-by-default + single-host allow-list (FOLLOW-UP, not faked):**
75
- True "reach ONLY the MCP host, deny everything else" is **not expressible with
76
- plain `docker run` flags**. It needs one of:
77
- - an egress-filtering **proxy sidecar** (e.g. squid/envoy) the sandbox is forced
78
- through, allow-listing only the MCP host:port; or
79
- - **iptables/nftables on a custom Docker network** — but we drop `CAP_NET_ADMIN`,
80
- so the container cannot firewall *itself*; rules must live on the host/daemon.
81
-
82
- This is the right shape for **Phase 3 (AWS Fargate)**, where a VPC with egress
83
- off + security groups scoped to the MCP server give exactly this for free (ADR
84
- "Phase 3" and Open Decision #1: pre-stage into OHSU S3 → zero egress). Until
85
- then, `bridge` (default) permits general egress and `none` denies all; the
86
- in-between single-host allow-list is intentionally left for Phase 3 rather than
87
- half-built locally.
88
-
89
- ## 3. Live behavioural verification (`test_hardened_container_confines_and_still_reaches_mcp`)
90
-
91
- Guarded/skipped when Docker or the image is absent; ran for real here. A hardened
92
- container launched via `ContainerLauncher` was proven to:
93
- - **(a)** reject a write to the root FS (`open('/nope.txt','w')` → denied) while
94
- `/tmp` remains writable — the read-only rootfs + tmpfs escape hatch both work;
95
- - **(b)** NOT see a host env secret (`MY_HOST_SECRET` set on the host →
96
- `os.environ.get(...)` is `None` inside; only `SANDBOX_MCP_URL` crosses);
97
- - **(c)** still reach the MCP server — a tool stub round-tripped a call to a mock
98
- MCP endpoint over `host.docker.internal`.
99
-
100
- ## 4. Unit coverage (no Docker needed — pure arg logic)
101
-
102
- - `test_container_launcher_has_all_hardening_flags` — every flag present at its
103
- default, all preceding the image.
104
- - `test_container_launcher_resource_limits_env_overridable` — `SANDBOX_*`
105
- overrides honored on both CLI and SDK paths.
106
- - `test_container_launcher_network_none_denies_egress` — `none` drops the
107
- host-gateway mapping on both paths.
108
- - `test_container_launcher_sdk_kwargs_match_cli_flags` — the SDK kwargs and CLI
109
- flags encode the identical lockdown (anti-drift guard).
110
-
111
- ## 5. What remains / follow-ups
112
-
113
- - [ ] **Egress allow-list** (deny-by-default + MCP-only) — deferred to Phase 3
114
- (Fargate/VPC) or a local proxy-sidecar spike; see §2. Not started; not faked.
115
- - [ ] Read-only **source-data mount** — Phase 2 covers the container's own FS +
116
- network; a read-only bind of the source-data location is only meaningful once
117
- data is mounted (local: the h5ad cache dir; AWS: the S3 bucket). Wire when the
118
- mount path is decided (ties into Phase 3 / Open Decision #1).
119
- - [ ] `tzlocal==5.4.2` metadata-only wheel still blocks the rpy2/limma path in
120
- the image — unchanged from Phase 1, tracked separately (not an ADR-0007 issue).
121
- - [ ] Prod default stays `EXECUTOR=in_process`; flipping to `sandbox` +
122
- `SANDBOX_LAUNCHER=container` waits on the Phase 3 real-deploy validation.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
docs/adr/ADR-0007-sandboxed-code-execution.md DELETED
@@ -1,157 +0,0 @@
1
- # ADR-0007 — Per-Session Sandboxed Code Execution
2
-
3
- **Status:** Accepted, in progress — **Phases 0–2 complete** (local Docker,
4
- AWS-independent; validated 2026-07-01), **Phases 3–4 blocked on AWS**
5
- (landing zone + Open Decisions 1–3). Two Phase-2 line items are intentionally
6
- deferred into Phase 3 because they only become real with an AWS mount/VPC:
7
- egress deny-by-default + MCP-only allow-list, and the read-only *source-data*
8
- mount (Phase 2 hardened the container's own FS + network, not a data mount).
9
- **Prod default stays `EXECUTOR=in_process`** until the container/AWS path is
10
- validated in a real deploy (Phase 3).
11
- **Date:** 2026-07-01
12
- **Driver:** OHSU security review of the future-state AWS deployment. The agent
13
- generates and executes LLM-authored Python; isolating that execution is the
14
- single highest-priority control the review asks for.
15
-
16
- **Phase status:** 0 ✅ (`feat/executor-seam`) · 1 ✅ (`ffba834`, local-validated) ·
17
- 2 ✅ container hardening (`52e7143`; egress + source-mount → Phase 3) ·
18
- 3 ⬜ AWS Fargate · 4 ⬜ data pre-stage + OHSU sign-off. Phase-2 detail:
19
- `ADR-0007-phase2-local-hardening.md`. To close this ADR: Phases 3–4 (per-session
20
- Fargate task + least-privilege IAM + VPC egress control + the OHSU write-up
21
- naming this as the implemented top-priority control).
22
-
23
- ---
24
-
25
- ## Context
26
-
27
- The specialist agent is a CodeAct loop: the model emits `<execute>…</execute>`
28
- blocks that are run via `exec(code, self.namespace)` in
29
- `src/managers/execution/python_executor.py`. `PythonExecutor` is a persistent,
30
- Jupyter-kernel-style namespace that holds session state (loaded AnnData,
31
- intermediate variables) **across steps** — which is why the in-memory AnnData
32
- cache and persistent MCP HTTP server exist (re-loading per step is the expensive
33
- path).
34
-
35
- Today that `exec` runs **in-process, in the same container as the orchestration
36
- logic**, with whatever privileges and network access the container has. The code
37
- even documents the accepted risk: *"bounded by running in an isolated HF Space
38
- with no secrets beyond the model API key."* That bound is fine for a prototype;
39
- it is not sufficient for OHSU-managed AWS handling restricted research data.
40
-
41
- The review asks for: **network-isolated, least-privilege, ephemeral execution,
42
- with read-only access to source data.**
43
-
44
- ## Decision
45
-
46
- Run generated code in a **per-session ephemeral sandbox container** — one
47
- isolated container per user session, holding namespace state across steps, torn
48
- down at session end. "Ephemeral" at session granularity (not per-step), so the
49
- caching model is preserved and data is not re-loaded every step.
50
-
51
- Crucially, this is implemented by **swapping the executor implementation behind
52
- the existing `PythonExecutor` interface**, not by rewriting the agent loop. The
53
- agent already depends only on `send_functions` / `send_variables` /
54
- `__call__(code) -> str`. A new `SandboxedExecutor` satisfies the same contract
55
- but proxies each call to a Python kernel running inside the sandbox container.
56
-
57
- ### Architecture
58
-
59
- - **The seam.** Define an `Executor` protocol matching the current interface.
60
- `PythonExecutor` (in-process) remains the default for local/dev; the sandboxed
61
- implementation is selected by config — mirroring the `LOG_SINK` pattern just
62
- added.
63
- - **What crosses the boundary is small.** `__call__` returns captured stdout (a
64
- string) — trivially serializable over a socket/HTTP. No live Python objects
65
- need to move.
66
- - **Tools stay vetted and outside the sandbox.** Injected tool "functions"
67
- become thin MCP-client stubs; the actual computation runs in the existing
68
- persistent MCP HTTP server. So the sandbox holds only the *untrusted*
69
- free-form generated code + session namespace; the *vetted* decoupleR/scanpy
70
- tool implementations run in the MCP server, reachable over a restricted
71
- internal network. This is a natural extension of the current HTTP-MCP model.
72
- - **Data model.** Source data mounted/accessed **read-only**; results written to
73
- a separate scoped location. External dataset fetch (GEO/GDC) is resolved by
74
- either pre-staging data into an OHSU bucket (zero egress — preferred) or an
75
- egress allow-list to approved data domains (see Open Decisions).
76
-
77
- ## Phased plan
78
-
79
- Phases 0–2 are **fully AWS-independent** — buildable and testable locally with
80
- Docker. That is the "start now" portion. Phases 3–4 need the AWS account.
81
-
82
- ### Phase 0 — Extract the executor seam *(now, ~2–3 days)*
83
- - Define an `Executor` Protocol/ABC from the current `PythonExecutor` surface.
84
- - Make the agent depend on the protocol; keep `PythonExecutor` as the default.
85
- - Add an `EXECUTOR` config switch (`in_process` | `sandbox`), default
86
- `in_process`. No behavior change yet.
87
- - Test: existing suite passes unchanged with the in-process executor.
88
-
89
- ### Phase 1 — Local Docker sandbox executor *(now, ~1–2 weeks)*
90
- - Build a sandbox image (reuse the existing Space image — rpy2/scanpy/decoupler
91
- already present) running a minimal "exec kernel": accept code over a local
92
- socket/HTTP, `exec` into a persistent per-session namespace, return stdout.
93
- - Implement `SandboxedExecutor`: starts one container per session, proxies
94
- `send_functions`/`__call__`, tears down on session end.
95
- - Wire tool calls from inside the sandbox to the MCP HTTP server (client stubs).
96
- - Test: a full analysis session runs end-to-end through the sandbox with state
97
- persisting across steps; trace/logging (ADR log-sink) still captures prompts,
98
- code, tool calls, dataset loads.
99
-
100
- ### Phase 2 — Local hardening *(now, ~3–5 days)*
101
- - Non-root user, dropped capabilities, read-only root FS, `--read-only` source
102
- mount, tmpfs workdir, CPU/memory/pids limits, no host network.
103
- - Local egress control (deny-by-default; allow-list only what a session needs).
104
- - Verify the sandbox cannot read secrets or write to source data.
105
-
106
- ### Phase 3 — AWS Fargate deployment *(needs AWS, ~2–3 weeks)*
107
- - Sandbox container → per-session Fargate task (or ECS-on-gVisor).
108
- - Least-privilege task IAM role: read-only on the source-data bucket, write to a
109
- scoped results prefix, nothing else. No long-lived credentials.
110
- - VPC with egress off (data pre-staged) or allow-listed; security groups scoped
111
- to the MCP server only.
112
- - Session lifecycle: task launched per session, torn down at end; results
113
- plumbed back through the agent.
114
-
115
- ### Phase 4 — Data pre-stage + review sign-off *(needs AWS decision, ~1 week)*
116
- - If pre-staging: a sync job that mirrors approved datasets into the read-only
117
- OHSU source bucket (with checksums — also closes the Q6 tamper-detection gap).
118
- - Security validation, threat-model doc, and the OHSU-review write-up naming
119
- this as the implemented top-priority control.
120
-
121
- ### LOE summary
122
- - **Startable now (Phases 0–2):** ~2.5–3.5 engineer-weeks, no AWS.
123
- - **AWS-dependent (Phases 3–4):** ~3–4 engineer-weeks once foundations exist.
124
- - **Total:** ~4–7 engineer-weeks (consistent with the review estimate). The
125
- swing factor is Phase 3 and whether OHSU provides a ready landing zone.
126
-
127
- ## Open decisions (resolve before Phase 3; do NOT block Phases 0–2)
128
- 1. **External data: pre-stage into OHSU S3 (zero egress, preferred) vs. egress
129
- allow-list.** Pre-staging gives the cleanest review story and closes tamper
130
- detection; allow-listing is less upfront work.
131
- 2. **AWS landing zone** — does OHSU provide account/VPC/baseline IAM, or is that
132
- part of this scope?
133
- 3. **MCP server placement** — same isolation domain as the sandbox, or a
134
- separate hardened service the sandbox reaches over a restricted network.
135
-
136
- ## Consequences
137
- - **Positive:** the #1 review control is implemented cleanly via an existing
138
- seam; no agent-loop rewrite; caching/perf model preserved; the sandbox
139
- confines only the untrusted code while vetted tools stay put; work starts
140
- immediately without waiting on AWS.
141
- - **Cost:** a new container image + session lifecycle to operate; per-session
142
- container startup latency (mitigated by session-granular reuse); Phase 3 ties
143
- to AWS specifics.
144
- - **Interim posture:** until Phase 3, the in-process executor remains — so for
145
- the current prototype, keep documenting execution as "isolated Space,
146
- API-key-only secrets," and present the sandbox as the funded, in-progress
147
- target rather than a shipped control.
148
-
149
- ## Start-now checklist
150
- - [x] Phase 0: extract `Executor` protocol + `EXECUTOR` config switch (`feat/executor-seam`)
151
- - [x] Phase 1: sandbox image + `SandboxedExecutor` + MCP client stubs (`ffba834`, local-validated)
152
- - [~] Phase 2: container hardening **done** (read-only rootfs, cap-drop=ALL,
153
- no-new-privileges, mem/cpu/pids limits, tmpfs scratch, `--network` env-
154
- overridable; SDK+CLI paths share one source; live-verified) — see
155
- `ADR-0007-phase2-local-hardening.md`. Egress deny-by-default + read-only
156
- source mount remain follow-ups (deferred to Phase 3 / local proxy spike).
157
- - [ ] Decisions 1–3 raised with OHSU cloud/security contacts (parallel track)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
docs/adr/ADR-0010-dataset-integrity-verification.md CHANGED
@@ -1,7 +1,6 @@
1
  # ADR-0010 — Dataset Integrity / Tamper Verification on Load
2
 
3
- **Status:** Accepted — step 1 (AWS-independent) fully implemented 2026-07-02 (22/22 datasets
4
- baselined; GEO-series-matrix load path now covered)
5
  **Date:** 2026-07-01
6
  **Deciders:** Annie Voigt (project lead)
7
  **Driver:** OHSU security review Q7 — "detect unavailable/modified/compromised datasets." Today
@@ -49,42 +48,14 @@ Add **content-hash verification on load**, keyed off the manifest.
49
  SHA-256 verify in `resolve_to_local_path`; refusal path + test (good hash loads, altered file
50
  refused, absent hash = load with a "no integrity baseline" note); backfill hashes for the
51
  current registered datasets.
52
- *(The shared hashing helper `src/core/integrity.py` — `compute_sha256` / `verify_sha256`,
53
- streamed — landed with ADR-0011 and is the exact low-level code this step's on-load layer
54
- builds on.)*
55
  2. **At AWS (ADR-0007 Phase 4):** the pre-stage sync job writes checksums into the read-only OHSU
56
  source bucket, so integrity is anchored to an OHSU-controlled copy rather than trust-on-first-use
57
  against the public source. This ADR's on-load check is the same code; only the hash's provenance
58
  improves.
59
 
60
- ### Step 1 — as implemented (2026-07-02)
61
-
62
- - **Manifest field** (`biodata-registry` 0.1.9): optional `integrity:` block on `DatasetManifest`
63
- — `sha256` (primary/expression file) + optional `files:` map (per-file, for separate metadata)
64
- + `recorded`/`recorded_from` provenance. Validated (64-hex) in `manifest.validate()`; absent =
65
- valid + a "no integrity baseline" warning. Reverse-lookup helper `expected_sha256_for_url()`.
66
- - **Recorder** `scripts/record_integrity.py` (in `biodata-registry`) streams SHA-256 of each source
67
- and writes the block into the YAML (comment-preserving text edit). **Backfilled 22/22** registered
68
- datasets. `cptac_pda_counts` was initially skipped at 0.1.9 (its hosted `cptac_pda_counts.h5ad`
69
- was 404 on HF); the file was then assembled + hosted and its baseline recorded in
70
- `biodata-registry` **0.1.10** (`6a6a884`; sha256 `7fd71517…`, verified against the HF LFS hash of
71
- the uploaded 21.7 MB file). `DecoupleRpy_Agent` re-pins the registry to **0.1.10** so the agent
72
- enforces all 22/22 at runtime.
73
- - **On-load verify** (`DecoupleRpy_Agent` `src/core/integrity.py`): `verify_file()` is called
74
- centrally in `resolve_to_local_path` after materialize / before parse+cache. Keyed on the source
75
- URL via the registry reverse-lookup, so **no tool signature changes**. Mismatch → `IntegrityError`
76
- refusal naming the dataset; a tampered temp download is unlinked before raising; missing/absent
77
- baseline degrades to "load unverified". Verified end-to-end (real load passes, tampered copy
78
- refused).
79
- - **GEO-series-matrix loads now covered:** `src/workflows/geo.py`'s `load_geo_series_matrix_lines`
80
- bypasses `resolve_to_local_path` (it streams the matrix straight into memory), so it now runs the
81
- same check inline — `verify_bytes()` on the raw response *as served* (pre-decompression, matching
82
- how the baseline is recorded) for URL fetches, and `verify_file()` for local paths. `verify_bytes`
83
- is the in-memory counterpart of `verify_file` added to `src/core/integrity.py`. Both are no-ops
84
- unless a manifest baselines that exact URL/path, so untracked GEO fetches are unaffected; a
85
- baselined series matrix whose bytes were altered is refused before parsing. Trust-on-first-use
86
- caveat below still applies until step 2.
87
-
88
  ## Consequences
89
 
90
  - **Positive:** closes the Q7 tamper gap with a deterministic control, independent of AWS; dovetails
 
1
  # ADR-0010 — Dataset Integrity / Tamper Verification on Load
2
 
3
+ **Status:** Proposed
 
4
  **Date:** 2026-07-01
5
  **Deciders:** Annie Voigt (project lead)
6
  **Driver:** OHSU security review Q7 — "detect unavailable/modified/compromised datasets." Today
 
48
  SHA-256 verify in `resolve_to_local_path`; refusal path + test (good hash loads, altered file
49
  refused, absent hash = load with a "no integrity baseline" note); backfill hashes for the
50
  current registered datasets.
51
+ *(Available now: the shared hashing helper `src/core/integrity.py`
52
+ — `compute_sha256` / `verify_sha256`, streamed — landed with ADR-0011 and is the exact code
53
+ this step wires into `resolve_to_local_path`.)*
54
  2. **At AWS (ADR-0007 Phase 4):** the pre-stage sync job writes checksums into the read-only OHSU
55
  source bucket, so integrity is anchored to an OHSU-controlled copy rather than trust-on-first-use
56
  against the public source. This ADR's on-load check is the same code; only the hash's provenance
57
  improves.
58
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
59
  ## Consequences
60
 
61
  - **Positive:** closes the Q7 tamper gap with a deterministic control, independent of AWS; dovetails
docs/adr/ADR-0011-upload-safety-gate.md CHANGED
@@ -1,9 +1,6 @@
1
  # ADR-0011 — Safety Gate for Manual Dataset Uploads
2
 
3
- **Status:** Accepted — "Now" (AWS-independent) slice implemented 2026-07-02, extended
4
- 2026-07-02 with a local interim content scan + manifest-drafting helper; **wired into the Gradio
5
- UI 2026-07-06** (all three data-input paths — upload / URL / HF-dataset — now clear the gate before
6
- the agent sees a file); only the AWS staging-bucket/encryption pieces remain deferred (see Plan)
7
  **Date:** 2026-07-01
8
  **Deciders:** Annie Voigt (project lead)
9
  **Driver:** OHSU security review Q4 — researchers should be able to supply their own datasets.
@@ -64,91 +61,18 @@ the existing manifest/validation machinery.
64
  through the vetted `scanpy.read_h5ad` loader only — no `exec`/`eval`/`pickle`), and `register_upload`
65
  (admin-only promotion, `UPLOAD_ADMIN_IDS`, into the live registry). Every state transition persists an
66
  `UploadRecord` (with the de-id attestation + hash) through the always-on ADR-0008 audit sink.
67
- End-to-end tests: `tests/test_upload_gate.py` (27). Auto-validation covers **h5ad** (full
68
- five-check `validate_manifest_against_data`) and **flat matrices** (`.csv`/`.tsv`/`.txt` + `.gz`,
69
- samples-as-rows/genes-as-columns via the vetted `pandas.read_csv`): a bare matrix has no `obs`,
70
- so only the file-content checks that don't need metadata run (`data_level` + `feature_id_type`)
71
- and the obs-dependent checks are recorded as explicit **caveats** on the record, not silently
72
- passed — re-upload as an h5ad to validate grouping/contrasts.
73
- - **Interim content scan (local, AWS-independent). ✅ Added 2026-07-02** as `src/uploads/scanning.py`,
74
- wired as **gate 0** of `validate_upload` (runs on the staged object *before* the loaders). Two
75
- layers: (1) an always-on, zero-dependency **structural magic-byte check** — an `.h5ad` must be an
76
- HDF5 container, a `.gz` must be gzip, and a flat-text matrix must carry no executable/archive/pickle
77
- leader, shebang, or NUL bytes; this catches a renamed ELF/Mach-O/PE/ZIP/pickle that clears the
78
- suffix-only door gate, and is *always* a hard stop. (2) An **optional ClamAV pass** (`clamdscan`/
79
- `clamscan`, or any scanner named by `UPLOAD_SCAN_CMD`) — external AV *reading* the file, still no
80
- `exec`/`eval`/`pickle`. Auto-detection tries the daemon client (`clamdscan`, fast when `clamd` is
81
- warm) and **degrades to standalone `clamscan`** if the daemon is absent/misconfigured, rather than
82
- reporting the file unscanned. When no AV is present the scan is recorded honestly as
83
- `scan_status="skipped"` with a caveat (never reported as malware-scanned); set `UPLOAD_SCAN_REQUIRED=1`
84
- to fail closed instead. Outcome persists on the `UploadRecord` (`scan_status`/`scanned_at`/
85
- `scan_detail`) through the ADR-0008 audit sink. Tests: `tests/test_upload_scan.py`. **Verified with a
86
- real local ClamAV** (`brew install clamav` + `freshclam`, 3.6M sigs): a clean matrix passes, the
87
- EICAR test file is flagged `infected` and blocked before the loaders run.
88
- - **Manifest-drafting helper (local, AWS-independent). ✅ Added 2026-07-02** as `src/uploads/drafting.py`
89
- (`draft_manifest`) — the friction mitigation from Consequences below. It opens the file with the same
90
- vetted loaders and pre-fills a manifest skeleton (inferred `data_level`, `feature_id_type`, sample/
91
- feature counts, candidate `group_columns`) plus an explicit `todo` list of the fields a human must
92
- still supply. It does **not** weaken the gate — a drafted manifest still has to clear `validate_upload`
93
- and admin registration; it only removes the blank-page problem. A local Gradio panel can wrap it as
94
- a thin editable-form shell. Tests: `tests/test_upload_drafting.py`.
95
- - **UI wiring (local, AWS-independent). ✅ Added 2026-07-06** in `gradio_ui.py`
96
- (`run_upload_gate`), called by the Upload-File / URL / HF-Dataset handlers. It runs
97
- `stage_upload → scan_upload → draft_manifest → validate_upload` behind a required
98
- de-identification checkbox, and only exposes the quarantined `staged_path` to the agent after the
99
- security gates clear.
100
- **Ordering correction (2026-07-29, from live verification of the prod Space).** The wiring
101
- originally ran `draft_manifest` *first*, which meant the drafter parsed the file at its original
102
- path before the type allow-list, the size cap, quarantine, or the structural magic-byte scan had
103
- run — violating this ADR's own rule that nothing parses an upload until it is staged and scanned.
104
- The visible symptom on the live Space: an ELF renamed `.csv` was refused, but by an incidental
105
- numpy crash inside the drafter (`zero-size array to reduction operation maximum`) rather than by
106
- the content scan, and a `.pdf` was refused as "could not read" rather than as a disallowed type.
107
- `run_upload_gate` now stages with a `placeholder_manifest` (filename-derived, no file read), scans,
108
- and only then drafts from the **quarantined** copy and attaches it via `attach_manifest`. A file
109
- rejected after quarantine has its staged bytes deleted (`discard_upload`), leaving only the audit
110
- record. The local test asserted merely that the ELF was "blocked", so it passed for the wrong
111
- reason throughout; it now asserts the refusal comes from the content scan. **Session-use vs registration split (decision):** on this session-scoped,
112
- single-file, read-only path the *security* envelope (attestation / type / size / structural + AV
113
- scan / SHA-256 / never-exec) is **mandatory and blocks on failure**, but `validate_upload`
114
- (manifest-vs-data consistency) is **advisory** — an ad-hoc uploader has no hand-authored manifest,
115
- so a bare matrix (which fails the `group_columns`-non-empty schema rule) is still usable with a
116
- visible "manifest not fully validated" warning rather than being blocked. Admin **registration**
117
- into the shared registry (item 7) is unchanged and still required to make an upload a persistent,
118
- listed dataset; a session upload is read by the vetted loaders for that session only and is never
119
- added to the registry. Tests: `tests/test_upload_ui_gate.py`.
120
- - **Deployed scan posture made explicit (`deploy/scan_posture.yaml`). ✅ Added 2026-07-29.** The AV
121
- layer above is auto-detected from PATH, which left the *deployed* posture implicit — and wrong in
122
- the optimistic direction during testing: a dev Mac with `brew install clamav` recorded
123
- `scan_status="clean"`, while the prod Space records `skipped`. Verified 2026-07-29 against the
124
- RUNNING prod Space at `7ef9d42`: `anne-voigt/Paper2Agent_decoupleRpy` is an `sdk: gradio` Space,
125
- so `packages.txt` is its only apt channel and it contains no `clamav` — **no AV binary exists there,
126
- and every upload records `skipped`.** `UPLOAD_SCAN_REQUIRED` was never set in any deploy config.
127
- **Decision: keep structural-only and say so, rather than install ClamAV in the Space image.**
128
- Rationale: (a) the AWS item below already supersedes a local-AV build by moving the managed pass
129
- onto the encrypted staged object; (b) `apt install clamav` ships **no signature database**, so a
130
- Space would need a ~1 GB `freshclam` download on every cold start, and a stale/failed refresh turns
131
- `av_required` into a total upload outage on a cpu-basic box. `deploy/scan_posture.yaml` now declares
132
- `posture: structural_only`, read by `src/uploads/posture.py`; `av_required` is the alternative value
133
- and is equivalent to `UPLOAD_SCAN_REQUIRED=1` (both env knobs still override the file). Every record
134
- is stamped with `scan_posture`, the skipped caveat states plainly that the upload is **NOT
135
- malware-scanned**, a `clean` produced under a `structural_only` posture carries an extra caveat that
136
- it is a host-local result the deployment does not guarantee, and the Gradio panel says
137
- "structure-checked … this is not a malware scan" instead of the ambiguous "content-scanned".
138
- - **At AWS (remaining):** staging bucket is a separate scoped prefix with its own encryption; the
139
- ClamAV pass moves to run on the staged S3 object — at which point the posture file flips to
140
- `av_required`. The basic type/size/quarantine gate **and** the local content scan above do **not**
141
- require AWS — only the scoped/encrypted bucket does.
142
 
143
  ## Consequences
144
 
145
  - **Positive:** delivers the requested capability without opening an ingress hole — uploads inherit
146
  the same grounding gate as curated datasets, stay de-identified by attestation, are tamper-checked,
147
  and are never executable. Clean story for the review.
148
- - **Cost / caveat:** a manifest requirement adds friction for uploaders now mitigated by
149
- `draft_manifest` (`src/uploads/drafting.py`), which drafts the manifest from the file + a few
150
- prompts. Content scanning is now local: an always-on structural magic-byte check plus an optional
151
- ClamAV pass. Full managed anti-malware (signature-updated, on the encrypted staging bucket) is still
152
- AWS-dependent; until then, an upload that ran with no AV available carries an explicit `skipped`
153
- scan caveat and must be described honestly as structurally-checked-but-not-malware-scanned rather
154
- than malware-scanned — unless the deployment sets `UPLOAD_SCAN_REQUIRED` to fail closed.
 
1
  # ADR-0011 — Safety Gate for Manual Dataset Uploads
2
 
3
+ **Status:** Accepted — "Now" (AWS-independent) slice implemented 2026-07-02; AWS pieces deferred (see Plan)
 
 
 
4
  **Date:** 2026-07-01
5
  **Deciders:** Annie Voigt (project lead)
6
  **Driver:** OHSU security review Q4 — researchers should be able to supply their own datasets.
 
61
  through the vetted `scanpy.read_h5ad` loader only — no `exec`/`eval`/`pickle`), and `register_upload`
62
  (admin-only promotion, `UPLOAD_ADMIN_IDS`, into the live registry). Every state transition persists an
63
  `UploadRecord` (with the de-id attestation + hash) through the always-on ADR-0008 audit sink.
64
+ End-to-end tests: `tests/test_upload_gate.py` (24). Auto-validation currently covers `.h5ad`;
65
+ tabular uploads stage but stay quarantined pending a tabular loader.
66
+ - **At AWS:** staging bucket is a separate scoped prefix with its own encryption; the optional
67
+ malware scan runs on the staged object *before* validation. The basic type/size/quarantine gate
68
+ here does **not** require AWS; the malware scan does.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
69
 
70
  ## Consequences
71
 
72
  - **Positive:** delivers the requested capability without opening an ingress hole — uploads inherit
73
  the same grounding gate as curated datasets, stay de-identified by attestation, are tamper-checked,
74
  and are never executable. Clean story for the review.
75
+ - **Cost / caveat:** a manifest requirement adds friction for uploaders (mitigate with a UI that
76
+ drafts the manifest from the file + a few prompts). Full anti-malware coverage is AWS-dependent;
77
+ until then the gate is type/size/quarantine/validation/attestation, which should be stated
78
+ honestly rather than described as malware-scanned.
 
 
 
docs/adr/ADR-0012-authentication-access-control.md DELETED
@@ -1,164 +0,0 @@
1
- # ADR-0012 — Authentication & Access Control on the Spaces
2
-
3
- **Status:** **Accepted — IMPLEMENTED, DEPLOYED to prod (both Spaces), and validated
4
- end-to-end (2026-07-02).** Front-door HF OAuth + allow-list gate live on the
5
- orchestrator (public); specialist flipped **private** + reached only via the
6
- orchestrator's service token (anonymous access verified refused); authenticated
7
- identity flows into the ADR-0008 trace. The **only** remaining piece is the OHSU
8
- SSO/IdP provider cutover, which is **deferred by design** (an OHSU decision) and does
9
- not block this "now" slice. *(Proposed → Accepted → Done 2026-07-02.)*
10
- **Date:** 2026-07-02
11
- **Deciders:** Annie Voigt (project lead)
12
- **Driver:** OHSU security review — the "Authentication & Authorization / Users"
13
- section describes ~5 users, 1–2 admins, service accounts, and *no shared
14
- accounts*. That is the intended model; today it is **not enforced in code**.
15
- Both Spaces call Gradio `launch()` with no `auth=` and no login gate, so access
16
- control is only whatever the Space's HuggingFace visibility setting provides.
17
- **Related:** ADR-0008 (audit trace — records *what* happened but attaches no
18
- authenticated identity), ADR-0011 (admin-only upload registration — assumes an
19
- identity this ADR supplies), future OHSU SSO/IdP integration (the AWS-era target).
20
-
21
- ---
22
-
23
- ## Context
24
-
25
- The system is two Gradio Spaces:
26
-
27
- - **`pdac-analysis-orchestrator`** — the user-facing front door. `app.py` →
28
- `ui.build().launch(server_name="0.0.0.0", server_port=7860)`. No `auth`.
29
- - **`Paper2Agent_decoupleRpy`** (specialist) — called by the orchestrator via
30
- `gradio_client`. `GradioAgentUI.launch(share=False, **kwargs)` →
31
- `app.queue(...).launch(...)`. No `auth`. It is also **directly reachable** as
32
- its own Space, so it is an authentication bypass around the orchestrator.
33
-
34
- Neither app authenticates a user, and there is no per-user identity attached to
35
- a request or to the ADR-0008 audit trace. The review's answers about admin
36
- accounts, service accounts, and no-shared-accounts therefore have no technical
37
- enforcement behind them. This is the largest AWS-independent exposure remaining.
38
-
39
- Two distinct problems:
40
- 1. **Front-door auth** — who may use the orchestrator at all.
41
- 2. **Specialist bypass** — the specialist must not be usable except *through* an
42
- authenticated orchestrator (or under the same gate).
43
-
44
- ## Decision
45
-
46
- Add an authentication gate to both Spaces now, using HuggingFace-native
47
- identity (AWS-independent), structured so the later cutover to OHSU SSO/IdP is a
48
- provider swap, not a redesign.
49
-
50
- 1. **Front door — HF OAuth, allow-listed.** Put the orchestrator behind
51
- HuggingFace OAuth (`hf_oauth: true` in the Space README metadata +
52
- `gr.LoginButton` / the `gr.OAuthProfile` dependency), and authorize only an
53
- explicit allow-list of HF usernames (config, not code — mirrors
54
- `recipients.yaml` / `UPLOAD_ADMIN_IDS`). A logged-in user not on the list is
55
- denied. This ties every session to a named identity that ADR-0008 can record.
56
- - *Interim fallback if OAuth is deemed too heavy for the pilot:* Gradio native
57
- `auth=` with per-user credentials threaded through the existing
58
- `launch(**kwargs)` seam. This is weaker (credential-based, not identity-
59
- federated, easy to share) and is explicitly a stopgap, not the target.
60
-
61
- 2. **Close the specialist bypass — set the specialist Space to private + a
62
- service token.** The specialist becomes a **private** Space; the orchestrator
63
- authenticates to it as a **service account** (HF token, the "inter-service
64
- token" already named in the review) via `gradio_client(..., hf_token=...)`.
65
- Direct public access is removed; only the orchestrator (holding the token)
66
- can reach it. No shared human accounts (consistent with Auth Q6).
67
-
68
- 3. **Identity into the audit trace.** Once a request carries an authenticated
69
- principal, thread it into `get_trace()` (`config` block) so ADR-0008 traces
70
- record *who* ran each analysis — the missing link between the app-layer trace
71
- and identity events. (App trace still ≠ full IdP log; see ADR-0008's two-layer
72
- note.)
73
-
74
- 4. **Roles.** Two roles only: **user** (run analyses) and **admin** (the 1–2
75
- accounts that register uploads per ADR-0011 and deploy). Role is an
76
- allow-list attribute in config, not a separate auth system.
77
-
78
- ## Plan
79
-
80
- - **Now (AWS-independent, ~2–4 days):**
81
- 1. Orchestrator: add HF OAuth + allow-list gate; deny non-listed identities.
82
- 2. Specialist: flip Space visibility to private; orchestrator authenticates
83
- with a service token; verify direct anonymous access is refused.
84
- 3. Thread the authenticated username into the ADR-0008 trace `config`.
85
- 4. Add an `ADMIN_IDS` / allow-list config block (both Spaces) + a test that a
86
- non-listed identity is rejected and an admin-only action refuses a user.
87
- - **At OHSU SSO/IdP (deferred — OHSU decision):** replace the HF-OAuth provider
88
- with OHSU SSO (OIDC/SAML) behind the same allow-list/role seam; the app change
89
- is the provider, not the gate. This is the only piece that waits on OHSU.
90
-
91
- ## Implementation status (2026-07-02) — COMPLETE
92
-
93
- **Specialist (`DecoupleRpy_Agent`) — deployed to prod:**
94
- - **`src/core/access_control.py`** — the pure, provider-independent identity seam
95
- (plan step 4). `Principal`, `role_for` / `is_authorized` / `is_admin`,
96
- `resolve_principal`, and `principal_trace_fields`. Config via `ADMIN_IDS`
97
- (admins) + `ALLOWED_IDS` (users), with `UPLOAD_ADMIN_IDS` (ADR-0011) honored as
98
- admins for back-compat so there is one coherent admin set. **Fail-closed:**
99
- empty config denies everyone; an unknown/blank identity has no role. This is
100
- the seam OHSU SSO later reuses unchanged — only where the identity string comes
101
- from changes.
102
- - **Identity into the trace** (plan step 3) — `CodeAgent.get_trace()` `config`
103
- block carries `principal` + `role` via `principal_trace_fields`, sourced from
104
- `self.principal`. A hidden `principal` input on `/interact_with_agent` receives
105
- the identity the orchestrator forwards; no forwarded identity records
106
- `principal="anonymous"`, `role=None` (honest, never misattributed).
107
- - **Tests** — `tests/test_access_control.py` (17).
108
-
109
- **Orchestrator (`pdac-analysis-orchestrator`) — deployed to prod:**
110
- - **`access.py`** — front-door allow-list gate mirroring the specialist's env
111
- contract, with an `ACCESS_CONTROL` enforcement toggle (fail-closed when on) so
112
- the code could ship to the public Space without locking anyone out before cutover.
113
- - **`README.md`** `hf_oauth: true`; **`gradio_ui.py`** `gr.LoginButton` +
114
- `gr.OAuthProfile` gate in `_respond`; **`router.py`** forwards the authenticated
115
- username as `principal` to the specialist (only when non-empty). Service-token
116
- client side (`gradio_client(token=HF_TOKEN)`) was already wired.
117
- - **Tests** — `tests/test_access.py` (20); full suite 38 green.
118
-
119
- **Architecture note (asymmetric on purpose):** the **orchestrator stays public** and
120
- is gated at the *app layer* by HF OAuth + the allow-list (a private Space would push
121
- access back to the HF-visibility layer the ADR is moving away from, and make OAuth
122
- redundant). The **specialist is private** because it has no login of its own and must
123
- not be directly reachable — it is an internal service reached only by the
124
- orchestrator's service token.
125
-
126
- **Validated end-to-end on prod (2026-07-02):** a listed user signs in and gets a real
127
- analysis (proving gate-allow + orchestrator→private-specialist via token + identity
128
- forwarding in one shot); an anonymous request to the specialist Space is refused
129
- (Hub API 401 / app 404, calibrated against a known-public Space returning 200); the
130
- allow-list refuses a non-listed / anonymous user at the front door.
131
-
132
- **Deferred (the only open item):** the OHSU SSO/IdP provider cutover — swap HF OAuth
133
- for OHSU OIDC/SAML behind this same `access_control` / `access` seam. Provider swap,
134
- not a redesign; waits on OHSU.
135
-
136
- ## Consequences
137
-
138
- - **Positive:** turns the review's stated user/admin/service-account model into
139
- an enforced control; every session gains a named identity that flows into the
140
- audit trace; the specialist stops being an open bypass; the SSO cutover is
141
- scoped to a provider swap.
142
- - **Cost / caveat:** HF OAuth requires each user to have (or make) a HuggingFace
143
- account — acceptable for ~5 pilot users, but call it out; it is HF-account
144
- identity, **not** OHSU-managed identity, until the SSO cutover. The Gradio
145
- `auth=` fallback is credential-based and must not be presented as identity
146
- federation. Setting the specialist private means the orchestrator's service
147
- token becomes a secret to manage (rotate; never in the trace — see ADR-0013).
148
- - **Honesty note for the review:** until this ships, state plainly that access
149
- control is currently at the HF-Space-visibility layer only, and that app-level
150
- authentication is the funded next step — do not imply the user/admin model is
151
- already enforced.
152
-
153
- ## Start-now checklist — ALL DONE (2026-07-02)
154
- - [x] Orchestrator: HF OAuth + allow-list gate (deny non-listed). *(`access.py`,
155
- `README.md` `hf_oauth`, `gradio_ui.py` gate — prod)*
156
- - [x] Specialist: private visibility + orchestrator service-token auth; anonymous
157
- direct access verified refused. *(HF console + `router.py` `token=HF_TOKEN`)*
158
- - [x] Authenticated username threaded into ADR-0008 trace. *(`get_trace()` `config`)*
159
- - [x] `ADMIN_IDS`/allow-list config + rejection tests (user vs admin).
160
- *(`src/core/access_control.py`+`tests/test_access_control.py`;
161
- `access.py`+`tests/test_access.py`)*
162
-
163
- **Deferred (not part of the now-slice):** OHSU SSO/IdP provider cutover — tracked for
164
- the AWS/OHSU-managed-identity milestone.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
docs/adr/ADR-0013-audit-trace-redaction.md DELETED
@@ -1,125 +0,0 @@
1
- # ADR-0013 — Audit-Trace Redaction (Secret / Credential / PII Scrubbing)
2
-
3
- **Status:** Accepted — implemented 2026-07-02 (AWS-independent; live once deployed)
4
- **Date:** 2026-07-02
5
- **Deciders:** Annie Voigt (project lead)
6
- **Driver:** OHSU security review — the always-on audit trace (ADR-0008) captures
7
- the full prompt and the code the agent generated and executed. Anything a user
8
- pastes, or that generated code prints, is persisted **verbatim** to the trace
9
- store. Since that store *is* the audit control, a secret or identifier landing in
10
- it is a leak into the very artifact meant to be trusted.
11
- **Related:** ADR-0008 (always-on logging — defines the persist path this hooks),
12
- ADR-0009 (S3 sink — same redacted payload lands there at cutover), ADR-0011
13
- (upload de-identification attestation — dataset content; this ADR is about
14
- secrets/PII in prompts+code, a different surface), ADR-0012 (identity in trace —
15
- must itself not over-collect).
16
-
17
- ---
18
-
19
- ## Context
20
-
21
- `CodeAgent.get_trace()` returns
22
- `{execution_time, config, messages, trace_logs}`, where `messages` is the full
23
- message history (user prompts + model turns) and `trace_logs` includes the
24
- generated code and captured stdout. `agent.run()` persists this on every live
25
- run via `persist_trace_safe(get_log_sink(), run_id, self.get_trace())`. There is
26
- **no redaction** anywhere in `src/logging_sink.py` or in `get_trace()`.
27
-
28
- Realistic leak paths into the trace:
29
- - A user pastes an API key, token, or a credentialed URL into the chat prompt.
30
- - Generated code echoes the environment (`print(os.environ)`), a connection
31
- string, or a bearer token.
32
- - The ADR-0012 orchestrator→specialist **service token** appears in an error
33
- string or a debug print.
34
- - Personal identifiers (email, name) in a prompt — the data is de-identified per
35
- ADR-0011, but free-text prompts are not.
36
-
37
- The fail-open wrapper (`persist_trace_safe`) means a bad trace is written
38
- silently, so there is no natural backstop.
39
-
40
- ## Decision
41
-
42
- Add a deterministic, **no-LLM** redaction pass applied to the trace immediately
43
- before it is persisted, in both the always-on sink path and the opt-in
44
- `save_trace` file dump.
45
-
46
- - **A pure function `redact_trace(trace: dict) -> dict`** (new
47
- `src/core/trace_redaction.py`, mirroring the shared-helper pattern of
48
- `src/core/integrity.py`). It deep-copies and walks all string values in
49
- `messages` + `trace_logs` and replaces matches with a typed placeholder
50
- (`«REDACTED:anthropic_key»`, `«REDACTED:hf_token»`, etc.).
51
- - **Pattern set (deterministic regex, high-precision):**
52
- - Anthropic keys (`sk-ant-…`), HuggingFace tokens (`hf_…`), AWS access keys
53
- (`AKIA…`) + secret-key-shaped high-entropy strings, generic `Bearer <token>`,
54
- OpenAI-style `sk-…`, and credentialed URLs (`https://user:pass@…`).
55
- - Whole-value drop for obvious environment dumps (a dict/text blob containing
56
- multiple `KEY=VALUE` env lines) → `«REDACTED:env_dump»`.
57
- - Email addresses → `«REDACTED:email»` (PII; conservative, on by default).
58
- - **Applied at one seam.** Hook `redact_trace` into `agent.run()` right before
59
- `persist_trace_safe(...)` and before the file dump — so every sink (`local` /
60
- `hf` / `s3`) and every path receives the redacted payload. The sinks stay
61
- dumb; redaction is not per-sink.
62
- - **Fail-closed on redaction, fail-open on logging.** If `redact_trace` itself
63
- raises, persist a **minimal** trace (run_id + timestamp + "redaction_error")
64
- rather than the raw payload — never write an unredacted trace, but still never
65
- crash the run.
66
- - **Size cap.** Truncate any single value over a configurable limit
67
- (`TRACE_MAX_FIELD_CHARS`) so a pathological paste can't bloat the store.
68
- - **Config, allow tuning:** `TRACE_REDACTION` (`on` default | `off` for local
69
- debug only) + an extensible extra-patterns list; **off is never the prod
70
- posture** and that is documented.
71
-
72
- Redaction runs on a copy; the in-memory trace the UI/eval harness reads is
73
- unchanged, so no user-facing behavior changes — only what is *persisted*.
74
-
75
- ## Plan
76
-
77
- - **Now (AWS-independent, ~2–3 days):**
78
- 1. `src/core/trace_redaction.py` with the pattern set + `redact_trace`.
79
- 2. Wire it into `agent.run()` before both persist paths.
80
- 3. Tests (`tests/test_trace_redaction.py`): each pattern is scrubbed; a
81
- planted `sk-ant-…` / `hf_…` / env-dump never reaches a stub sink; redaction
82
- failure yields the minimal trace, not the raw one; clean traces pass through
83
- unchanged.
84
- 4. Document `TRACE_REDACTION` + the "off ≠ prod" note.
85
- - **No AWS dependency at all** — this hardens the payload *before* it reaches any
86
- sink, so it is complete independent of the S3 cutover, and the S3 sink
87
- (ADR-0009) inherits it for free.
88
-
89
- ## Consequences
90
-
91
- - **Positive:** the audit store can no longer silently capture credentials/PII;
92
- closes the leak into the trust artifact itself; a single seam covers all
93
- sinks; deterministic + testable, no model call, no latency of note.
94
- - **Cost / caveat:** regex redaction is high-precision but not exhaustive — a
95
- novel secret format can slip through, so this is defense-in-depth layered with
96
- ADR-0012 (don't put the service token where it can be printed) and secret
97
- hygiene (ADR-0014), not a guarantee. Over-eager patterns could redact
98
- legitimate content (e.g. a gene identifier that looks token-shaped); keep
99
- patterns anchored/high-entropy and cover with tests. State to the review that
100
- redaction is best-effort scrubbing, not a proof of secret-free logs.
101
- - **Honesty note:** this reduces *accidental* capture; it is not a substitute for
102
- not exposing secrets to the agent in the first place.
103
-
104
- ## Start-now checklist
105
- - [x] `src/core/trace_redaction.py` (`redact_trace`, pattern set, size cap).
106
- - [x] Hook before `persist_trace_safe` (`agent.run()`) + the `save_trace` file
107
- dump (`WorkflowEngine.save_trace_to_file`, which also covers `agent.save_trace()`).
108
- - [x] Fail-closed minimal-trace path on redaction error (`redact_trace_safe`).
109
- - [x] `tests/test_trace_redaction.py` (20 tests, network-free) + `TRACE_REDACTION`
110
- config doc (module docstring + Decision above).
111
-
112
- ## Implementation notes (2026-07-02)
113
- - Patterns landed: anthropic (`sk-ant-…`), hf (`hf_…`), AWS access key (`AKIA…`),
114
- generic OpenAI `sk-…`, `Bearer …`, GitHub `gh[pousr]_…`, credentialed URL
115
- (host preserved, `user:pass@` dropped), email, whole-value env-dump drop
116
- (≥3 `UPPER_SNAKE=value` lines), and an entropy-gated 40+char base64 run for the
117
- AWS *secret* key shape (`TRACE_REDACTION_EXTRA` adds operator regexes →
118
- `«REDACTED:custom»`).
119
- - Entropy gate (≥4.0 bits/char) on the 40+char matcher keeps repetitive/low-entropy
120
- identifiers (e.g. a long gene-id run) from tripping the secret pattern.
121
- - Size cap is `TRACE_MAX_FIELD_CHARS` (default 20 000); scrubbing runs *before*
122
- truncation so a secret straddling the boundary is removed, not half-exposed.
123
- - Both seams call `redact_trace_safe`, which fails **closed** to a minimal trace
124
- (`run_id` + timestamp + `redaction_error`, no payload) and never raises, so the
125
- fail-open sink wrapper still governs crash-safety.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
docs/adr/ADR-0014-ci-security-scanning.md DELETED
@@ -1,150 +0,0 @@
1
- # ADR-0014 — CI Security Scanning (Dependency, Static, Secret)
2
-
3
- **Status:** Accepted — in progress (specialist repo implemented 2026-07-02;
4
- biodata-registry + orchestrator pending)
5
- **Date:** 2026-07-02
6
- **Deciders:** Annie Voigt (project lead)
7
- **Driver:** OHSU security review — no automated scanning exists today. There is
8
- no `.github/workflows/` in the specialist, and no pip-audit / bandit / safety /
9
- Dependabot / gitleaks anywhere in the three repos. The `tzlocal==5.4.2`
10
- metadata-only-wheel break (found by hand during ADR-0007 validation, fixed
11
- reactively in `05fca82`) is exactly the supply-chain class automated scanning
12
- catches before it ships.
13
- **Related:** ADR-0010 (dataset integrity — data supply chain; this is the *code*
14
- supply chain), ADR-0012/0013 (secret hygiene — secret scanning here backstops
15
- them), ADR-0007 (pinned sandbox image — scan the image too).
16
-
17
- ---
18
-
19
- ## Context
20
-
21
- Three repos, three different deploy models, which changes where CI can run:
22
-
23
- - **`DecoupleRpy_Agent`** (specialist) — **has a GitHub repo** that auto-deploys
24
- to the HF Space. GitHub Actions runs natively here.
25
- - **`biodata-registry`** — **GitHub-only** pip package. GitHub Actions runs
26
- natively.
27
- - **`pdac-analysis-orchestrator`** — **no GitHub repo**; `origin` *is* the prod
28
- HF Space (git push builds the Space). GitHub Actions has nowhere to run, so it
29
- needs a different hook (pre-push + manual/scheduled scan).
30
-
31
- None of them run any dependency-vulnerability scan, static-analysis pass, or
32
- secret scan. Dependencies are pinned (`requirements.txt`, `tests/
33
- requirements-test.txt`) but never checked against advisory databases, and
34
- tokens flow through the code (HF write tokens, the ADR-0012 service token) with
35
- no automated secret-scanning of commits or history.
36
-
37
- ## Decision
38
-
39
- Add a standard three-part security scan — **dependencies, static analysis,
40
- secrets** — to every repo, using the hook appropriate to that repo's deploy
41
- model. All tooling is open-source and runs locally / in GitHub-hosted CI; **no
42
- AWS and no OHSU decision required.**
43
-
44
- **Scan set (same three everywhere):**
45
- 1. **Dependencies — `pip-audit`** against `requirements*.txt` (PyPI advisory /
46
- OSV). Fails on a known-vuln dependency; this is what would have surfaced a
47
- bad `tzlocal` pin. Add **Dependabot** (GitHub repos) for automated bump PRs.
48
- 2. **Static analysis — `bandit`** over `src/` (common Python security
49
- anti-patterns: `exec`/`eval`, `subprocess shell=True`, insecure temp files,
50
- hardcoded secrets). Scoped/tuned so the *intended* sandboxed `exec`
51
- (ADR-0007) is an acknowledged, annotated finding, not noise.
52
- 3. **Secrets — `gitleaks`** over the working tree **and full git history**
53
- (tokens have flowed through these repos; history matters). Backstops
54
- ADR-0012/0013.
55
-
56
- **Per-repo hook (deploy-model-aware):**
57
- - **`DecoupleRpy_Agent` + `biodata-registry` (GitHub):** a
58
- `.github/workflows/security.yml` running the three on every PR + a weekly
59
- schedule; Dependabot config committed.
60
- - **`pdac-analysis-orchestrator` (HF-only, no GitHub):** a `make security-scan`
61
- target + a **pre-push git hook** running the same three locally before a push
62
- builds the Space, plus optionally a **scheduled HF Job** (reusing the
63
- `scripts/hf_job.sh` pattern already in `lit-agent`) so it also runs
64
- unattended. The scan definition is shared so all repos run an identical check.
65
- - **Shared config** so thresholds/allow-lists (e.g. the annotated ADR-0007
66
- `exec`) don't drift between repos.
67
-
68
- **Also scan the sandbox image (ADR-0007):** add a container-image vuln scan
69
- (`trivy`, open-source) of `docker/sandbox.Dockerfile` to the specialist workflow
70
- so the pinned image is checked, not just the Python deps.
71
-
72
- ## Plan
73
-
74
- - **Now (AWS-independent, ~2–3 days):**
75
- 1. Author the shared three-scan definition + a documented allow-list for known
76
- accepted findings (the ADR-0007 `exec`).
77
- 2. GitHub repos: commit `security.yml` (PR + weekly) + Dependabot config.
78
- 3. Orchestrator: `make security-scan` + pre-push hook (+ optional scheduled HF
79
- Job).
80
- 4. Run once across all three, triage findings, fix or explicitly accept each,
81
- and record the accepted set (so CI is green and every suppression is
82
- justified — a clean artifact for the review).
83
- 5. Add `trivy` on the sandbox image to the specialist workflow.
84
- - **No AWS dependency.** Everything here is local tooling or GitHub-hosted
85
- runners; nothing waits on the AWS account or an OHSU decision.
86
-
87
- ## Consequences
88
-
89
- - **Positive:** turns "we pin dependencies" into "we pin *and* continuously
90
- scan"; catches the next `tzlocal`-class break before deploy; secret scanning
91
- backstops ADR-0012/0013; gives the review a concrete, always-on supply-chain
92
- control across all three repos and the sandbox image.
93
- - **Cost / caveat:** the orchestrator's HF-only model means its scan is a
94
- pre-push hook / scheduled Job rather than blocking CI — a developer *can*
95
- bypass a local hook, so pair it with the scheduled unattended run and say so
96
- honestly (it is not an enforced merge gate the way the GitHub repos are).
97
- Initial triage will surface a backlog to accept or fix; budget for that first
98
- pass. Scanners produce false positives — the annotated allow-list keeps CI
99
- meaningful rather than ignored.
100
- - **Honesty note:** scanning reduces known-vulnerability and leaked-secret risk;
101
- it does not prove the absence of either. It is one layer with ADR-0010 (data
102
- supply chain), ADR-0012/0013 (secret handling), and ADR-0007 (execution
103
- isolation).
104
-
105
- ## Start-now checklist
106
- - [x] Shared three-scan definition (pip-audit + bandit + gitleaks) + accepted-
107
- findings allow-list. — `scripts/security_scan.sh` (4 stages incl. trivy),
108
- `bandit.yaml` + `security/bandit-baseline.json`, `.gitleaks.toml`,
109
- `security/ACCEPTED-FINDINGS.md`.
110
- - [x] `security.yml` + Dependabot on `DecoupleRpy_Agent`. *(biodata-registry
111
- still pending — separate repo; it reuses the same scan definition.)*
112
- - [x] `make security-scan` + pre-push hook on the specialist. *(See deploy-model
113
- correction below — the specialist is HF-only, so this local/pre-push path
114
- is its enforced check, same as the orchestrator; the orchestrator itself
115
- still pending.)*
116
- - [x] `trivy` image scan on the sandbox Dockerfile. — clean (0 HIGH/CRITICAL).
117
- - [~] First full run triaged; every suppression justified. — **done; CI is NOT
118
- green because the first run surfaced two genuine open items** (below),
119
- which is the intended outcome of a first pass, not a failure of it.
120
-
121
- ## Implementation notes (2026-07-02, specialist repo)
122
-
123
- **Deploy-model correction.** The Context above states the specialist "has a
124
- GitHub repo" where GitHub Actions runs natively. That is **not** true of the
125
- current repo: `origin` *is* the HuggingFace Space (no GitHub remote), so
126
- `.github/workflows/security.yml` will not execute on push — HF does not run
127
- Actions. The specialist therefore has the **same** deploy model as the
128
- orchestrator, and its enforced check is the **pre-push hook** (`make
129
- install-hooks`) running the identical `scripts/security_scan.sh`, plus the
130
- scheduled unattended run. The workflow + `dependabot.yml` are still committed so
131
- that adding a GitHub mirror (or reusing them in `biodata-registry`, a real
132
- GitHub repo) is zero extra work and the scan definition never diverges.
133
-
134
- **First-run findings (see `security/ACCEPTED-FINDINGS.md` for full triage):**
135
- 1. **Exposed Google API key in git history** — `AIzaSy…` hardcoded as a Gradio
136
- textbox default in the initial commit (`155d8d7` `app.py:488`), removed from
137
- HEAD in `acf6553` but still in published history. **Requires rotation**
138
- (revoke in GCP console); left intentionally un-allowlisted so the scan keeps
139
- failing until handled. This is exactly the history-scan value the ADR argued
140
- for.
141
- 2. **Three fixable dependency CVEs** — `pillow 11.3.0` (→12.2.0), `langsmith
142
- 0.8.16` (→0.8.18), `pydantic-settings 2.14.1` (→2.14.2). None are HF
143
- `sdk_version`-locked, so all three are bump candidates (a separate,
144
- test-gated dependency change + redeploy).
145
-
146
- Two gitleaks false positives (column-name kwargs in a precompute script) were
147
- triaged and allowlisted. Bandit's 16 medium infra findings (0.0.0.0 dev bind,
148
- `/tmp` working dirs, GEO-download urlopen) are captured in the committed baseline
149
- so only *new* findings fail; the ADR-0007 sandboxed `exec` is inline-`# nosec`
150
- annotated.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
gradio_ui.py CHANGED
@@ -14,20 +14,19 @@ import sys
14
  import threading
15
  import time
16
  import traceback
17
- from collections.abc import Generator
18
  from datetime import datetime
 
19
 
20
  import gradio as gr
21
  from gradio.themes.utils import fonts
 
22
  from langchain_anthropic import ChatAnthropic
23
- from langchain_core.messages import AIMessage, HumanMessage
24
 
25
  from agent import CodeAgent
26
- from core.access_control import check_access
27
  from core.constants import DECOUPLER_DISCLAIMER
28
  from core.types import AgentConfig
29
- from logging_sink import get_log_sink, persist_trace_safe
30
  from managers.hf_storage import HFResultsStorage
 
31
  from ui_formatting import _UIFormattingMixin
32
 
33
  # ---------------------------------------------------------------------------
@@ -37,9 +36,6 @@ from ui_formatting import _UIFormattingMixin
37
  _MCP_HTTP_PORT = 8765
38
  _mcp_server_proc: subprocess.Popen | None = None
39
 
40
- # Steps granted per press of the Continue button.
41
- STEP_LIMIT_INCREMENT = 15
42
-
43
 
44
  def _port_open(port: int, host: str = "127.0.0.1") -> bool:
45
  try:
@@ -104,494 +100,18 @@ def ensure_mcp_http_server() -> str:
104
  # port before committing to HTTP-vs-stdio. Catch a fast crash early.
105
  for i in range(15):
106
  if _mcp_server_proc.poll() is not None:
107
- print(
108
- f"[MCP] Server process exited early (code {_mcp_server_proc.returncode}) "
109
- "see logs above; prewarm will fall back to stdio"
110
- )
111
  return url
112
  if _port_open(_MCP_HTTP_PORT):
113
- print(f"[MCP] HTTP server ready at {url} (took {i + 1}s)")
114
  return url
115
  time.sleep(1)
116
 
117
- print("[MCP] HTTP server not up in 15s — prewarm will keep waiting in the background")
118
  return url
119
 
120
 
121
- # ---------------------------------------------------------------------------
122
- # Upload safety gate (ADR-0011) — the UI wiring in front of src/uploads/.
123
- #
124
- # An uploaded/downloaded file is untrusted input. Before it is ever handed to
125
- # the agent it must clear the *security* envelope of ADR-0011:
126
- # 1. de-identification attestation (no PHI/PSI),
127
- # 2. type allow-list + size cap + quarantine + SHA-256 (stage_upload),
128
- # 3. structural magic-byte content scan (scan_upload) + an AV pass only where
129
- # the deployment declares one (deploy/scan_posture.yaml; the Space does not).
130
- # Those three are MANDATORY — a failure blocks the file entirely.
131
- #
132
- # validate_upload (manifest-vs-data consistency) is run too, but treated as
133
- # ADVISORY for this session-scoped, single-file, read-only use: an ad-hoc
134
- # uploader has no hand-authored manifest, so we auto-draft one and surface any
135
- # validation gaps as warnings rather than blocking exploratory analysis. The
136
- # admin-only registration step (promotion into the shared biodata-registry) is
137
- # a separate governance action and is intentionally NOT part of this path —
138
- # a session upload is read by the agent's vetted loaders for that session only,
139
- # never added to the registry or exposed to other users.
140
- # ---------------------------------------------------------------------------
141
- def run_upload_gate(src_path, filename, session_state, deidentified):
142
- """Route an upload/download through the ADR-0011 safety gate.
143
-
144
- Returns ``(session_state, status_markdown)``. On any *security* failure the
145
- file is NOT made available (``uploaded_file`` stays unset). On a
146
- manifest-validation-only gap the file is allowed for session use with a
147
- visible warning.
148
- """
149
- from src.uploads import (
150
- SCAN_INFECTED,
151
- UploadRejected,
152
- attach_manifest,
153
- discard_upload,
154
- draft_manifest,
155
- placeholder_manifest,
156
- scan_upload,
157
- stage_upload,
158
- validate_upload,
159
- )
160
- from src.uploads.drafting import _slug
161
- from src.uploads.records import STATUS_VALIDATED
162
-
163
- def _block(msg):
164
- session_state.pop("uploaded_file", None)
165
- session_state.pop("uploaded_filename", None)
166
- session_state.pop("upload_record_id", None)
167
- return session_state, msg
168
-
169
- # ── Gate 1: de-identification attestation (ADR-0011 item 6) ──────────────
170
- if not deidentified:
171
- return _block(
172
- "⚠️ **Upload blocked.** Tick *“I confirm this data is de-identified"
173
- " (no PHI/PSI)”* above before uploading — an upload without that"
174
- " attestation is refused at the door (ADR-0011)."
175
- )
176
-
177
- uploader = session_state.get("principal") or "ui-upload"
178
- dataset_id = f"upload_{_slug(filename)}"
179
-
180
- # A metadata workbook is not an analysable dataset on its own — it only has
181
- # meaning joined to a counts matrix. Say so here rather than letting it
182
- # quarantine successfully and then fail in the manifest drafter.
183
- if filename.lower().endswith(".xlsx"):
184
- return _block(
185
- "⚠️ **An Excel workbook is metadata, not a dataset.** Use the"
186
- " *Assemble from TSVs* tab, where the sheet is joined to a counts"
187
- " matrix to build the analysis file."
188
- )
189
-
190
- # ── Gate 2: quarantine + type/size + SHA-256 (stage_upload) ──────────────
191
- # Staged with a placeholder manifest, because drafting the real one means
192
- # *parsing* the file, and ADR-0011 requires that no parser touch an
193
- # unstaged, unscanned, un-type-checked file. The draft is attached below,
194
- # after the content scan clears.
195
- try:
196
- record = stage_upload(
197
- src_path,
198
- uploader=uploader,
199
- dataset_id=dataset_id,
200
- manifest=placeholder_manifest(dataset_id),
201
- deidentified=True,
202
- )
203
- except UploadRejected as rej:
204
- return _block("⚠️ **Upload rejected:** " + "; ".join(rej.record.errors))
205
-
206
- # ── Gate 3: content scan (structural magic-byte + optional ClamAV) ───────
207
- # Runs on the quarantined copy *before* anything reads its contents, so a
208
- # disguised binary (an ELF wearing a .csv suffix) is stopped here rather
209
- # than surfacing later as an incidental parser crash.
210
- record, _scan_report = scan_upload(record)
211
- if record.scan_status == SCAN_INFECTED:
212
- discard_upload(record, "blocked by content scan")
213
- return _block("🛑 **Upload blocked by content scan:** " + record.scan_detail)
214
-
215
- # ── Auto-draft a manifest so the uploader faces no blank form ────────────
216
- # Reads the *quarantined* copy through the vetted loaders, never the original.
217
- try:
218
- draft = draft_manifest(record.staged_path, dataset_id=dataset_id)
219
- except Exception as exc: # noqa: BLE001 — unreadable/unsupported file
220
- discard_upload(record, f"could not draft a manifest: {exc}")
221
- return _block(f"⚠️ **Upload blocked:** could not read `{filename}` — {exc}")
222
- record = attach_manifest(record, draft.manifest)
223
-
224
- # ── Advisory: manifest-vs-data validation ───────────────────────────────
225
- # validate_upload re-checks scan status internally (idempotent) then runs the
226
- # manifest/against-data gate. A validation gap does not block session use.
227
- record, _val_report = validate_upload(record)
228
-
229
- session_state["uploaded_file"] = record.staged_path
230
- session_state["uploaded_filename"] = filename
231
- session_state["upload_record_id"] = record.upload_id
232
-
233
- lines = [f"✅ **Ready:** `{filename}`"]
234
- if record.scan_status == "skipped":
235
- lines.append(
236
- "_Structurally checked, but **not malware-scanned** — this deployment"
237
- " ships no anti-virus binary (declared posture `structural_only`, see"
238
- " `deploy/scan_posture.yaml`). Treat this file as structurally-verified"
239
- " only._"
240
- )
241
- elif record.scan_status == "clean" and record.scan_posture == "structural_only":
242
- lines.append(
243
- "_Structurally checked; an anti-virus on this host also reported clean,"
244
- " but the deployment does not guarantee an AV pass (declared posture"
245
- " `structural_only`) — production uploads are structure-checked only._"
246
- )
247
- if record.status != STATUS_VALIDATED and record.errors:
248
- lines.append(
249
- "_Manifest not fully validated (auto-drafted): "
250
- + "; ".join(record.errors)
251
- + ". The agent will still load the file; confirm the analysis matches"
252
- " your data._"
253
- )
254
- elif record.caveats:
255
- lines.append("_Caveats:_ " + "; ".join(record.caveats))
256
- return session_state, "\n\n".join(lines)
257
-
258
-
259
- # ---------------------------------------------------------------------------
260
- # Data-Input handlers (module level so they are unit-testable — they close over
261
- # no UI state). All three funnel into run_upload_gate above.
262
- #
263
- # The URL and HF-Dataset paths must first materialise remote bytes on local disk
264
- # before the gate can hash and quarantine them. That scratch copy is *ungated*
265
- # untrusted input, so it goes to a private temporary directory that is deleted
266
- # unconditionally once the gate has taken its own quarantined copy — it is never
267
- # left behind in a persistent tmp/inputs/ dir for the agent to stumble onto.
268
- # ---------------------------------------------------------------------------
269
- def _incomplete_run_notice(reason: str | None, config) -> str:
270
- """Explain why a run ended without an answer, and what actually helps.
271
-
272
- A run can end incomplete four ways, and only ONE of them is fixed by
273
- granting more steps. The UI used to call all of them "Step limit reached",
274
- which sent users to press Continue against a wall clock or a failing tool —
275
- Continue would grant steps a timed-out run has no use for.
276
- """
277
- step_budget = getattr(config, "max_steps", 15)
278
- timeout_min = round(getattr(config, "timeout_seconds", 1200) / 60)
279
-
280
- if reason == "step_limit":
281
- body = (
282
- f"⏸ <strong>Step limit reached</strong> ({step_budget} steps). "
283
- f"Click <strong>Continue</strong> to give the agent {STEP_LIMIT_INCREMENT} more steps."
284
- )
285
- elif reason == "timeout":
286
- body = (
287
- f"⏱ <strong>Time limit reached</strong> (~{timeout_min} min). The agent was still "
288
- "working, so <strong>Continue will not help</strong> — it grants more steps, not more "
289
- "time. Re-run with a narrower question (one dataset, one contrast), or raise the "
290
- "timeout if this analysis is genuinely long-running."
291
- )
292
- elif reason == "error_limit":
293
- body = (
294
- "⚠️ <strong>Stopped after repeated errors.</strong> The agent hit "
295
- f"{getattr(config, 'retry_attempts', 3)} consecutive failures and gave up rather than "
296
- "guess. The errors are shown above — Continue is unlikely to help until the underlying "
297
- "failure is addressed."
298
- )
299
- else:
300
- body = (
301
- "⚠️ <strong>The run ended without an answer.</strong> The agent stopped without "
302
- "producing a solution or any further code to run. Try rephrasing the question."
303
- )
304
-
305
- return (
306
- '<div style="background-color:#dbeafe;border-left:4px solid #2563eb;'
307
- 'padding:10px 14px;margin:10px 0;border-radius:4px;font-size:0.9em;color:#1e3a5f;">'
308
- f"{body}</div>"
309
- )
310
-
311
-
312
- def _clear_upload(session_state, msg):
313
- session_state.pop("uploaded_file", None)
314
- session_state.pop("uploaded_filename", None)
315
- session_state.pop("upload_record_id", None)
316
- return session_state, msg
317
-
318
-
319
- def _gate_component_file(src_path, filename, uploader, dataset_id):
320
- """Quarantine + content-scan ONE input file of a multi-file assembly.
321
-
322
- Returns the staged path. The component files (counts TSV, TPM TSV, metadata
323
- workbook) are not datasets in their own right — no manifest is drafted and
324
- no validation runs for them; the assembled h5ad goes through the full gate
325
- afterwards. What matters here is that nothing reads a byte of them until
326
- :func:`stage_upload` has type/size-checked and hashed the file and
327
- :func:`scan_upload` has cleared its structure.
328
-
329
- Raises ``ValueError`` with a user-facing message on any gate failure.
330
- """
331
- from src.uploads import (
332
- SCAN_INFECTED,
333
- UploadRejected,
334
- discard_upload,
335
- placeholder_manifest,
336
- scan_upload,
337
- stage_upload,
338
- )
339
-
340
- try:
341
- record = stage_upload(
342
- src_path,
343
- uploader=uploader,
344
- dataset_id=dataset_id,
345
- manifest=placeholder_manifest(dataset_id),
346
- deidentified=True,
347
- )
348
- except UploadRejected as rej:
349
- raise ValueError(f"`{filename}` rejected: " + "; ".join(rej.record.errors)) from rej
350
-
351
- record, _report = scan_upload(record)
352
- if record.scan_status == SCAN_INFECTED:
353
- discard_upload(record, "blocked by content scan")
354
- raise ValueError(f"`{filename}` blocked by content scan: {record.scan_detail}")
355
- return record.staged_path
356
-
357
-
358
- def run_assembly_gate(
359
- counts_path,
360
- metadata_path,
361
- tpm_path,
362
- session_state,
363
- deidentified,
364
- *,
365
- sample_column=None,
366
- skip_rows=None,
367
- column_map=None,
368
- value_maps=None,
369
- group_column=None,
370
- control_label=None,
371
- treatment_label="shMyc",
372
- ):
373
- """Build the analysis h5ad from raw delivery files, all inside the gate.
374
-
375
- Replaces the hand-run `scripts/assemble_myc_kd_kmc_mouse.py` pre-step: the
376
- user drops the counts TSV (+ optional TPM TSV) and the metadata sheet, and
377
- the assembly happens here. Each input is quarantined and content-scanned
378
- *before* it is parsed; the assembled h5ad is then put through the ordinary
379
- upload gate, so the session-visible file is a normal staged upload with its
380
- own record, SHA-256, drafted manifest and advisory validation.
381
- """
382
- import shutil as _shutil
383
- import tempfile
384
-
385
- from src.uploads.assembly import AssemblyError, assemble_h5ad
386
- from src.uploads.drafting import _slug
387
-
388
- def _block(msg):
389
- session_state.pop("uploaded_file", None)
390
- session_state.pop("uploaded_filename", None)
391
- session_state.pop("upload_record_id", None)
392
- return session_state, msg
393
-
394
- if not deidentified:
395
- return _block(
396
- "⚠️ **Assembly blocked.** Tick *“I confirm this data is de-identified"
397
- " (no PHI/PSI)”* above first — an upload without that attestation is"
398
- " refused at the door (ADR-0011)."
399
- )
400
- if not counts_path or not metadata_path:
401
- return _block("Provide at least a counts matrix **and** a metadata sheet.")
402
-
403
- uploader = session_state.get("principal") or "ui-upload"
404
- base_id = f"upload_{_slug(os.path.basename(counts_path))}"
405
-
406
- try:
407
- staged_counts = _gate_component_file(
408
- counts_path, os.path.basename(counts_path), uploader, f"{base_id}_counts"
409
- )
410
- staged_meta = _gate_component_file(
411
- metadata_path, os.path.basename(metadata_path), uploader, f"{base_id}_metadata"
412
- )
413
- staged_tpm = (
414
- _gate_component_file(tpm_path, os.path.basename(tpm_path), uploader, f"{base_id}_tpm")
415
- if tpm_path
416
- else None
417
- )
418
- except ValueError as exc:
419
- return _block(f"🛑 **Assembly blocked:** {exc}")
420
-
421
- workdir = tempfile.mkdtemp(prefix="assembled_")
422
- try:
423
- out_path = os.path.join(workdir, "assembled.h5ad")
424
- try:
425
- _adata, report = assemble_h5ad(
426
- staged_counts,
427
- staged_meta,
428
- tpm_path=staged_tpm,
429
- out_path=out_path,
430
- sample_column=sample_column or None,
431
- skip_rows=int(skip_rows) if str(skip_rows or "").strip() else None,
432
- column_map=column_map or None,
433
- value_maps=value_maps or None,
434
- group_column=group_column or None,
435
- control_label=control_label or None,
436
- treatment_label=treatment_label or "shMyc",
437
- staging_script="gradio_ui.run_assembly_gate",
438
- )
439
- except AssemblyError as exc:
440
- return _block(f"⚠️ **Could not assemble the dataset:** {exc}")
441
- except Exception as exc: # noqa: BLE001 — unreadable/unsupported input
442
- return _block(f"⚠️ **Could not read the supplied files:** {exc}")
443
-
444
- session_state, status = run_upload_gate(
445
- out_path, "assembled.h5ad", session_state, deidentified
446
- )
447
- finally:
448
- _shutil.rmtree(workdir, ignore_errors=True)
449
-
450
- if "uploaded_file" not in session_state:
451
- return session_state, status
452
-
453
- detail = [
454
- status,
455
- f"_Assembled **{report['n_samples']} samples × {report['n_genes']} genes**"
456
- f" from `{os.path.basename(counts_path)}`"
457
- + (f" + `{os.path.basename(tpm_path)}` (TPM layer)" if tpm_path else "")
458
- + f" + `{os.path.basename(metadata_path)}`._",
459
- "_Groups:_ "
460
- + "; ".join(f"**{col}** {counts}" for col, counts in report["obs_counts"].items()),
461
- ]
462
- if report["unmatched_samples"]:
463
- detail.append(
464
- f"_⚠️ {len(report['unmatched_samples'])} matrix sample(s) had no metadata row and"
465
- f" were dropped: {', '.join(report['unmatched_samples'])}._"
466
- )
467
- return session_state, "\n\n".join(detail)
468
-
469
-
470
- def handle_assembly(
471
- counts_file,
472
- metadata_file,
473
- tpm_file,
474
- sample_column,
475
- column_map,
476
- value_maps,
477
- group_column,
478
- control_label,
479
- treatment_label,
480
- skip_rows,
481
- deidentified,
482
- session_state,
483
- ):
484
- """Gradio binding for the *Assemble from TSVs* tab."""
485
- return run_assembly_gate(
486
- counts_file,
487
- metadata_file,
488
- tpm_file,
489
- session_state,
490
- deidentified,
491
- sample_column=sample_column,
492
- skip_rows=skip_rows,
493
- column_map=column_map,
494
- value_maps=value_maps,
495
- group_column=group_column,
496
- control_label=control_label,
497
- treatment_label=treatment_label,
498
- )
499
-
500
-
501
- def handle_file_upload(file_path, deidentified, session_state):
502
- """Gradio's own upload widget already wrote the file to a temp path."""
503
- if file_path is None:
504
- return _clear_upload(session_state, "")
505
- return run_upload_gate(file_path, os.path.basename(file_path), session_state, deidentified)
506
-
507
-
508
- def handle_url_download(url, deidentified, session_state):
509
- import gzip
510
- import shutil as _shutil
511
- import tempfile
512
-
513
- import requests
514
-
515
- from src.uploads.staging import _max_upload_bytes
516
-
517
- if not url or not url.strip():
518
- return session_state, "No URL provided"
519
- url = url.strip()
520
- if not url.lower().startswith(("http://", "https://")):
521
- return _clear_upload(session_state, "Only http:// and https:// URLs are supported.")
522
- filename = url.rstrip("/").split("/")[-1].split("?")[0]
523
- if not filename or "." not in filename:
524
- filename = "downloaded_data.bin"
525
-
526
- scratch = tempfile.mkdtemp(prefix="ungated_url_")
527
- try:
528
- dest = os.path.join(scratch, os.path.basename(filename))
529
- limit = _max_upload_bytes()
530
- r = requests.get(url, stream=True, timeout=300)
531
- r.raise_for_status()
532
- if "text/html" in r.headers.get("Content-Type", ""):
533
- return _clear_upload(
534
- session_state,
535
- "URL returned an HTML page, not a file. Make sure the URL points"
536
- " directly to a file, not a directory.",
537
- )
538
- # Enforce the size cap *while* streaming, so an oversized (or endless)
539
- # response is abandoned rather than fully written and rejected after.
540
- written = 0
541
- with open(dest, "wb") as f:
542
- for chunk in r.iter_content(chunk_size=8192):
543
- if not chunk:
544
- continue
545
- written += len(chunk)
546
- if written > limit:
547
- return _clear_upload(
548
- session_state,
549
- f"⚠️ **Download aborted:** the file exceeds the"
550
- f" {limit}-byte upload limit (UPLOAD_MAX_BYTES).",
551
- )
552
- f.write(chunk)
553
- if filename.endswith(".gz") and not filename.endswith(".tar.gz"):
554
- decompressed = filename[:-3]
555
- decompressed_dest = os.path.join(scratch, decompressed)
556
- with gzip.open(dest, "rb") as f_in, open(decompressed_dest, "wb") as f_out:
557
- _shutil.copyfileobj(f_in, f_out)
558
- os.remove(dest)
559
- dest, filename = decompressed_dest, decompressed
560
- return run_upload_gate(dest, filename, session_state, deidentified)
561
- except Exception as e: # noqa: BLE001 — surfaced to the uploader
562
- return _clear_upload(session_state, f"Download failed: {e}")
563
- finally:
564
- _shutil.rmtree(scratch, ignore_errors=True)
565
-
566
-
567
- def handle_hf_dataset(repo_id, filepath, deidentified, session_state):
568
- import shutil as _shutil
569
- import tempfile
570
-
571
- if not repo_id or not repo_id.strip():
572
- return session_state, "No repo ID provided"
573
- if not filepath or not filepath.strip():
574
- return session_state, "No file path provided"
575
-
576
- scratch = tempfile.mkdtemp(prefix="ungated_hf_")
577
- try:
578
- from huggingface_hub import hf_hub_download
579
-
580
- local_path = hf_hub_download(
581
- repo_id=repo_id.strip(),
582
- filename=filepath.strip(),
583
- repo_type="dataset",
584
- local_dir=scratch,
585
- )
586
- return run_upload_gate(
587
- local_path, os.path.basename(local_path), session_state, deidentified
588
- )
589
- except Exception as e: # noqa: BLE001 — surfaced to the uploader
590
- return _clear_upload(session_state, f"Failed to load from HF Dataset: {e}")
591
- finally:
592
- _shutil.rmtree(scratch, ignore_errors=True)
593
-
594
-
595
  class GradioAgentUI(_UIFormattingMixin):
596
  """
597
  Gradio interface for interacting with the LangGraph ReAct Agent.
@@ -603,8 +123,9 @@ class GradioAgentUI(_UIFormattingMixin):
603
  if model is None:
604
  # Prefer the standard ANTHROPIC_API_KEY; fall back to the legacy
605
  # mixed-case name some older Spaces still use.
606
- api_key_anthropic = os.environ.get("ANTHROPIC_API_KEY") or os.environ.get(
607
- "Anthropic_API_KEY"
 
608
  )
609
  if not api_key_anthropic:
610
  raise RuntimeError(
@@ -612,11 +133,18 @@ class GradioAgentUI(_UIFormattingMixin):
612
  "or Anthropic_API_KEY in the Space secrets."
613
  )
614
  model = ChatAnthropic(
615
- model="claude-sonnet-4-6", temperature=0, api_key=api_key_anthropic
 
 
616
  )
617
 
618
  if config is None:
619
- config = AgentConfig(max_steps=15, retry_attempts=3, timeout_seconds=2700, verbose=True)
 
 
 
 
 
620
 
621
  self.model = model
622
  self.config = config
@@ -632,7 +160,6 @@ class GradioAgentUI(_UIFormattingMixin):
632
  except Exception as e:
633
  print(f"[log_sink] init failed ({e}); falling back to local sink")
634
  from logging_sink import LocalLogSink
635
-
636
  self.log_sink = LocalLogSink()
637
  print(f"[log_sink] Using sink: {self.log_sink.name}")
638
 
@@ -640,7 +167,7 @@ class GradioAgentUI(_UIFormattingMixin):
640
  # doesn't pay the 2-minute subprocess startup cost.
641
  # The seed agent's mcp_functions (stateless closures) are cached and
642
  # copied into each new session agent without re-spawning anything.
643
- self._mcp_cache: dict = {} # tool_name → mcp_functions entry
644
  self._mcp_discovery_errors: dict = {} # server_name -> error string, if discovery failed
645
  self._mcp_ready = threading.Event() # set when pre-warm finishes
646
 
@@ -727,10 +254,8 @@ class GradioAgentUI(_UIFormattingMixin):
727
  if not seed.tool_manager.mcp_manager.mcp_functions:
728
  # HTTP discovery returned nothing — fall back to stdio so the
729
  # Space is still usable (just slower) rather than tool-less.
730
- print(
731
- "[mcp-prewarm] ⚠️ HTTP discovery returned 0 tools — "
732
- "falling back to stdio add_mcp()"
733
- )
734
  self._mcp_http_url = None
735
  self._probe_stdio_server()
736
  seed.add_mcp(mcp_config_path)
@@ -751,17 +276,13 @@ class GradioAgentUI(_UIFormattingMixin):
751
  # add_mcp() didn't raise, but discovered zero tools — this is
752
  # just as broken as an exception (agent will have no analysis
753
  # tools), so log it just as loudly.
754
- print(
755
- "[mcp-prewarm] ⚠️ add_mcp() completed but discovered 0 MCP "
756
- "tools analysis requests will fail. Check server.py / "
757
- "mcp_config.yaml on this Space. "
758
- f"Discovery errors: {self._mcp_discovery_errors}"
759
- )
760
  except Exception as e:
761
- print(
762
- "[mcp-prewarm] ⚠️ Pre-warm failed — will retry discovery on "
763
- "first query. Full traceback:"
764
- )
765
  traceback.print_exc()
766
  self._mcp_discovery_errors["_prewarm"] = f"{type(e).__name__}: {e}"
767
  finally:
@@ -828,8 +349,8 @@ class GradioAgentUI(_UIFormattingMixin):
828
  previous_plan = None
829
  step_timings = {}
830
  last_state = None
831
- last_error = None # buffer transient step errors; only the last is
832
- solution_shown = False # surfaced, and only if the run yields no solution
833
 
834
  # Monotonic step numbering for the UI, decoupled from the raw graph
835
  # step_count. The raw count is emitted twice per step (once for the
@@ -839,10 +360,11 @@ class GradioAgentUI(_UIFormattingMixin):
839
  # the prior run on /handle_continue so the continuation keeps counting
840
  # up; reset to 0 for a fresh question.
841
  display_step = (
842
- getattr(agent.workflow_engine, "last_display_step", 0) if resume_messages else 0
 
843
  )
844
- last_internal_step = None # raw step_count we last opened a header for
845
- header_pending = False # a step started; flush its header before content
846
  header_drawn = bool(resume_messages) # leading <hr> before the first step?
847
 
848
  # Snapshot existing figures so we can detect ones this run produces and
@@ -876,29 +398,24 @@ class GradioAgentUI(_UIFormattingMixin):
876
  # header is gated on it, so a deduped/empty yield no longer
877
  # leaves an orphan "Step N" with no body beneath it.
878
  thinking_text = ""
879
- if parts["thinking"] and len(parts["thinking"]) > 20:
880
- _t = re.sub(r"(Thinking:|Plan:)\s*", "", parts["thinking"]).strip()
881
- _t = re.sub(r"\n\s*\n\s*\n+", "\n\n", _t).strip()
882
- _t = "\n".join(ln.strip() for ln in _t.split("\n") if ln.strip())
883
  if _t and hash(_t) not in displayed_reasoning:
884
  thinking_text = _t
885
 
886
  # Route an errored observation to the buffered-error path before
887
  # it can count as renderable content (see the long note below).
888
- if parts["observation"]:
889
- _obs = re.sub(r"^\s*Code Output:\s*", "", parts["observation"]).strip()
890
- if _obs.startswith("Error:"):
891
  last_error = _obs
892
- parts["observation"] = None
893
 
894
  plan_changed = bool(current_plan and current_plan != previous_plan)
895
- has_content = bool(
896
- thinking_text
897
- or plan_changed
898
- or parts["code"]
899
- or parts["observation"]
900
- or parts["solution"]
901
- )
902
 
903
  # One header per logical step. graph.stream emits a state after
904
  # both the generate node (step N) and the execute node (still
@@ -916,15 +433,13 @@ class GradioAgentUI(_UIFormattingMixin):
916
  display_step += 1
917
  separator = (
918
  '<hr style="border: none; border-top: 1px solid #e2e8f0; '
919
- 'margin: 20px 0;">'
920
- if header_drawn
921
- else ""
922
  )
923
  header_drawn = True
924
  yield gr.ChatMessage(
925
  role="assistant",
926
  content=f"{separator}## Step {display_step}\n",
927
- metadata={"status": "done"},
928
  )
929
 
930
  if thinking_text:
@@ -933,35 +448,37 @@ class GradioAgentUI(_UIFormattingMixin):
933
  <div style="margin: 0; white-space: pre-line; font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; color: #000000; background-color: #ffffff; padding: 10px; border-radius: 4px; font-size: 14px; line-height: 1.6;">{thinking_text}</div>
934
  </div>"""
935
  yield gr.ChatMessage(
936
- role="assistant", content=thinking_block, metadata={"status": "done"}
 
 
937
  )
938
  displayed_reasoning.add(hash(thinking_text))
939
 
940
  if current_plan and current_plan != previous_plan:
941
- formatted_plan = (
942
- current_plan.replace("[ ]", "☐")
943
- .replace("[✓]", "✅")
944
- .replace("[✗]", "❌")
945
- )
946
  plan_block = f"""<div style="background-color: #f8f9fa; border-left: 4px solid #000000; padding: 12px; margin: 10px 0; border-radius: 4px;">
947
  <div style="font-weight: bold; color: #000000; margin-bottom: 8px;">📋 Current Plan</div>
948
  <pre style="margin: 0; white-space: pre-wrap; font-family: 'Consolas', 'Monaco', 'Courier New', monospace; color: #000000; background-color: #ffffff; padding: 10px; border-radius: 4px; font-size: 14px; line-height: 1.6;">{formatted_plan}</pre>
949
  </div>"""
950
  yield gr.ChatMessage(
951
- role="assistant", content=plan_block, metadata={"status": "done"}
 
 
952
  )
953
  previous_plan = current_plan
954
 
955
- if parts["code"]:
956
  code_block = f"""<div style="background-color: #f7fafc; border-left: 4px solid #48bb78; padding: 12px; margin: 10px 0; border-radius: 4px;">
957
  <div style="font-weight: bold; color: #22543d; margin-bottom: 8px;">⚡ Executing Code</div>
958
  </div>
959
 
960
  ```python
961
- {parts["code"]}
962
  ```"""
963
  yield gr.ChatMessage(
964
- role="assistant", content=code_block, metadata={"status": "done"}
 
 
965
  )
966
 
967
  # An execution that raised is returned by the executor as
@@ -969,8 +486,8 @@ class GradioAgentUI(_UIFormattingMixin):
969
  # buffered-error path above (parts['observation'] nulled) so it
970
  # never renders an alarming "Code Output: Error" block for a
971
  # hiccup the agent usually self-corrects on the next step.
972
- if parts["observation"]:
973
- truncated = self.truncate_output(parts["observation"])
974
  result_block = f"""<div style="background-color: #fef5e7; border-left: 4px solid #f6ad55; padding: 12px; margin: 10px 0; border-radius: 4px;">
975
  <div style="font-weight: bold; color: #744210; margin-bottom: 8px;">📊 Execution Result</div>
976
  </div>
@@ -979,30 +496,32 @@ class GradioAgentUI(_UIFormattingMixin):
979
  {truncated}
980
  ```"""
981
  yield gr.ChatMessage(
982
- role="assistant", content=result_block, metadata={"status": "done"}
 
 
983
  )
984
 
985
- all_artifacts = self._extract_artifacts(parts["observation"])
986
  for desc, path in all_artifacts:
987
- if path.endswith(".png") and os.path.exists(path):
988
- with open(path, "rb") as f:
989
  img_b64 = base64.b64encode(f.read()).decode()
990
  yield gr.ChatMessage(
991
  role="assistant",
992
  content=self._figure_html(desc, img_b64),
993
- metadata={"status": "done"},
994
  )
995
  shown_fig_names.add(os.path.basename(path))
996
  self.hf_storage.upload_artifacts(all_artifacts, run_id)
997
 
998
- if parts["solution"]:
999
  solution_shown = True
1000
  # The standing decoupleR method limitations are appended
1001
  # deterministically here, after the model's text — never
1002
  # generated by the model — so they are identical on every
1003
  # run and can never be softened or dropped. The model
1004
  # writes only run-specific caveats.
1005
- solution_text = parts["solution"] + "\n\n" + DECOUPLER_DISCLAIMER
1006
  solution_block = f"""<div style="background-color: #d1fae5; border-left: 4px solid #10b981; padding: 16px; margin: 10px 0; border-radius: 6px;">
1007
  <div style="font-weight: bold; color: #065f46; margin-bottom: 12px; font-size: 16px;">✅ Final Solution</div>
1008
  <div class="solution-content" style="color: #1f2937 !important; background-color: #ffffff; padding: 16px; border-radius: 4px; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; font-size: 15px; line-height: 1.7; border: 1px solid #e5e7eb;">
@@ -1048,22 +567,27 @@ class GradioAgentUI(_UIFormattingMixin):
1048
  </style>
1049
  </div>"""
1050
  yield gr.ChatMessage(
1051
- role="assistant", content=solution_block, metadata={"status": "done"}
 
 
1052
  )
1053
 
1054
- if parts["error"]:
1055
  # Buffer, don't render. These are mostly transient
1056
  # NameError/AttributeError that the CodeAgent hits and then
1057
  # self-corrects on a later step — showing each one floods the
1058
  # user with red boxes for problems that were already resolved.
1059
  # The last error is surfaced after the loop only if the run
1060
  # never produced a solution (i.e. it genuinely failed).
1061
- last_error = parts["error"]
1062
 
1063
- if parts["observation"] and step_count in step_timings:
1064
- footnote = f'<div style="color: #718096; font-size: 0.875em; margin-top: 8px;">Step {display_step}</div>'
 
1065
  yield gr.ChatMessage(
1066
- role="assistant", content=footnote, metadata={"status": "done"}
 
 
1067
  )
1068
 
1069
  # (The inter-step <hr> divider is emitted as part of the next
@@ -1096,7 +620,7 @@ class GradioAgentUI(_UIFormattingMixin):
1096
  yield gr.ChatMessage(
1097
  role="assistant",
1098
  content=self._figure_html(desc, img_b64),
1099
- metadata={"status": "done"},
1100
  )
1101
  shown_fig_names.add(os.path.basename(path))
1102
  if new_figures:
@@ -1111,7 +635,9 @@ class GradioAgentUI(_UIFormattingMixin):
1111
  <div style="color: #742a2a;">{last_error}</div>
1112
  </div>"""
1113
  yield gr.ChatMessage(
1114
- role="assistant", content=error_block, metadata={"status": "done"}
 
 
1115
  )
1116
 
1117
  except Exception as e:
@@ -1119,57 +645,23 @@ class GradioAgentUI(_UIFormattingMixin):
1119
  <div style="font-weight: bold; color: #742a2a; margin-bottom: 8px;">💥 Critical Error</div>
1120
  <div style="color: #742a2a;">Error during agent execution: {str(e)}</div>
1121
  </div>"""
1122
- yield gr.ChatMessage(role="assistant", content=error_block, metadata={"status": "done"})
 
 
 
 
1123
 
1124
- def interact_with_agent(
1125
- self,
1126
- query: str,
1127
- chatbot_history: list,
1128
- session_state: dict,
1129
- principal: str = "",
1130
- profile: gr.OAuthProfile | None = None,
1131
- ) -> Generator:
1132
- """Handle interaction with the agent.
1133
-
1134
- ``principal`` is the authenticated end-user identity forwarded by the
1135
- orchestrator (ADR-0012), which reaches this endpoint via its service
1136
- token. ``profile`` is injected by Gradio from HF OAuth for a **direct**
1137
- human on this Space's own UI (it is not part of the API inputs, so the
1138
- orchestrator's ``/interact_with_agent`` call is unaffected).
1139
-
1140
- The effective caller is the direct human's OAuth username if present,
1141
- else the orchestrator-forwarded principal. When ``ACCESS_CONTROL`` is
1142
- enforced, a non-allow-listed or unauthenticated effective caller is
1143
- refused before any work — this closes the direct-UI side door around the
1144
- orchestrator's gate. Enforcement is OFF by default, so the existing
1145
- orchestrator route is byte-identical until an operator turns it on (and
1146
- then must list the forwarded identities here too). The effective identity
1147
- is recorded in the ADR-0008 audit trace via ``agent.set_principal``.
1148
- """
1149
  original_query = query
1150
 
1151
- # ADR-0012 app-layer gate. No-op when ACCESS_CONTROL enforcement is off.
1152
- username = getattr(profile, "username", None) if profile else None
1153
- effective_principal = username or principal
1154
- allowed, denial = check_access(effective_principal)
1155
- if not allowed:
1156
- chatbot_history = chatbot_history + [
1157
- gr.ChatMessage(role="user", content=original_query, metadata={"status": "done"}),
1158
- gr.ChatMessage(role="assistant", content=denial, metadata={"status": "done"}),
1159
- ]
1160
- yield chatbot_history
1161
- return
1162
- # A signed-in human's identity supersedes an empty forwarded principal so
1163
- # the audit trace attributes the run to whoever actually ran it.
1164
- principal = effective_principal
1165
-
1166
  uploaded_file = session_state.get("uploaded_file")
1167
  if uploaded_file and os.path.exists(uploaded_file):
1168
  filename = session_state.get("uploaded_filename", os.path.basename(uploaded_file))
1169
  is_geo = False
1170
  if filename.endswith((".txt", ".tsv")):
1171
  try:
1172
- with open(uploaded_file, encoding="utf-8", errors="replace") as _f:
1173
  is_geo = _f.readline().startswith("!")
1174
  except Exception:
1175
  pass
@@ -1212,7 +704,6 @@ class GradioAgentUI(_UIFormattingMixin):
1212
  # Re-register into the tool catalog so the agent can call them.
1213
  for tool_name, tool_data in self._mcp_cache.items():
1214
  from managers.tools.tool_manager import ToolInfo, ToolSource
1215
-
1216
  tool_info = ToolInfo(
1217
  name=tool_name,
1218
  description=tool_data.get("description", "MCP tool"),
@@ -1225,9 +716,7 @@ class GradioAgentUI(_UIFormattingMixin):
1225
  schema=None,
1226
  )
1227
  mgr._tool_catalog[tool_name] = tool_info
1228
- print(
1229
- f"✅ Loaded {len(self._mcp_cache)} MCP tools from cache (no subprocess spawn)"
1230
- )
1231
  else:
1232
  # Cache empty (pre-warm failed) — fall back to live discovery.
1233
  # Prefer the persistent HTTP server if it's reachable; only drop
@@ -1255,56 +744,37 @@ class GradioAgentUI(_UIFormattingMixin):
1255
  mcp_tool_count = session_state["agent"].get_tool_statistics()["by_source"].get("mcp", 0)
1256
  session_state["mcp_tool_count"] = mcp_tool_count
1257
  if mcp_tool_count == 0:
1258
- print(
1259
- "⚠️ Session agent has 0 MCP tools decoupleR analysis "
1260
- "tools are unavailable for this session."
1261
- )
1262
 
1263
  agent = session_state["agent"]
1264
- # ADR-0012: attach the authenticated caller (forwarded by the orchestrator)
1265
- # so it flows into the audit trace. Empty string -> None -> "anonymous".
1266
- agent.set_principal(principal or None)
1267
  run_id = datetime.now().strftime("%Y%m%d_%H%M%S")
1268
 
1269
  try:
1270
- chatbot_history.append(
1271
- gr.ChatMessage(role="user", content=original_query, metadata={"status": "done"})
1272
- )
1273
  yield chatbot_history
1274
 
1275
  if session_state.get("mcp_tool_count", 0) == 0:
1276
  error_notice = (
1277
  '<div style="background-color:#fee2e2;border-left:4px solid #dc2626;'
1278
  'padding:12px 16px;margin:10px 0;border-radius:4px;color:#7f1d1d;">'
1279
- "⚠️ <strong>Analysis tools unavailable.</strong> The decoupleR MCP "
1280
- "tools failed to load for this session, so I cannot run any real "
1281
- "analysis (TF activity, pathway/hallmark scoring, differential "
1282
- "expression, etc.) right now. Rather than guess at results, I'm "
1283
- "stopping here — please try again in a moment, or contact the "
1284
- "maintainer if this persists.</div>"
1285
- )
1286
- chatbot_history.append(
1287
- gr.ChatMessage(
1288
- role="assistant", content=error_notice, metadata={"status": "done"}
1289
- )
1290
  )
 
1291
  yield chatbot_history
1292
  return
1293
 
1294
  if uploaded_file and os.path.exists(uploaded_file):
1295
  file_notice = f'<div style="background:#f0fdf4;border-left:3px solid #22c55e;padding:8px 12px;margin:6px 0;border-radius:4px;font-size:0.85em;color:#166534;">Using uploaded file: <strong>{filename}</strong></div>'
1296
- chatbot_history.append(
1297
- gr.ChatMessage(
1298
- role="assistant", content=file_notice, metadata={"status": "done"}
1299
- )
1300
- )
1301
  yield chatbot_history
1302
 
1303
- chatbot_history.append(
1304
- gr.ChatMessage(
1305
- role="assistant", content="🤔 Processing...", metadata={"status": "pending"}
1306
- )
1307
- )
1308
  yield chatbot_history
1309
  chatbot_history.pop()
1310
 
@@ -1313,10 +783,7 @@ class GradioAgentUI(_UIFormattingMixin):
1313
  yield chatbot_history
1314
 
1315
  completed = getattr(agent.workflow_engine, "last_solution_shown", False)
1316
- reason = getattr(agent.workflow_engine, "last_end_reason", None)
1317
- # Only a genuine step exhaustion is fixed by granting more steps.
1318
- session_state["step_limit_hit"] = (not completed) and reason == "step_limit"
1319
- session_state["end_reason"] = reason
1320
  session_state["last_run_id"] = run_id
1321
 
1322
  # ALWAYS-ON audit trace persistence via the configured sink.
@@ -1332,18 +799,14 @@ class GradioAgentUI(_UIFormattingMixin):
1332
  print("⚠️ Could not persist execution trace:")
1333
  traceback.print_exc()
1334
 
1335
- if not completed:
1336
- notice = _incomplete_run_notice(reason, agent.config)
1337
- chatbot_history.append(
1338
- gr.ChatMessage(role="assistant", content=notice, metadata={"status": "done"})
 
 
1339
  )
1340
- yield chatbot_history
1341
-
1342
- # Collapsible provenance panel: which analysis tools ran, which
1343
- # registered datasets were touched (GUI/observability TODO).
1344
- panel = self._what_happened_message(agent)
1345
- if panel is not None:
1346
- chatbot_history.append(panel)
1347
  yield chatbot_history
1348
 
1349
  saved = self.hf_storage.save_conversation(chatbot_history, original_query, run_id)
@@ -1360,15 +823,10 @@ class GradioAgentUI(_UIFormattingMixin):
1360
  pdf_remote = None
1361
  try:
1362
  raw_messages = agent.workflow_engine.last_state_messages or []
1363
- current_run = self._current_question_messages(chatbot_history)
1364
- log_blocks = self._log_blocks(raw_messages, current_run)
1365
  if log_blocks:
1366
- # Scoped to the current question: figures from earlier
1367
- # questions in the session must not reach this export.
1368
- images = self._extract_images(current_run)
1369
- pdf_path = self._write_pdf(
1370
- log_blocks, f"DecoupleRpy Full Run — {run_id}", images
1371
- )
1372
  remote_name = f"full_run{os.path.splitext(pdf_path)[1] or '.pdf'}"
1373
  if self.hf_storage.upload_run_file(pdf_path, run_id, remote_name):
1374
  pdf_remote = remote_name
@@ -1380,19 +838,13 @@ class GradioAgentUI(_UIFormattingMixin):
1380
  pdf_line = ""
1381
  if pdf_remote:
1382
  pdf_url = f"https://huggingface.co/datasets/{self.hf_storage.repo_id}/resolve/main/runs/{run_id}/{pdf_remote}"
1383
- pdf_line = (
1384
- f'<br>📄 Full run PDF: <a href="{pdf_url}" target="_blank">{pdf_remote}</a>'
1385
- )
1386
  log_notice = (
1387
  f'<div style="background-color:#f0f9ff;border-left:3px solid #0ea5e9;'
1388
  f'padding:6px 12px;margin:6px 0;border-radius:4px;font-size:0.82em;color:#0c4a6e;">'
1389
  f'📁 Full run log saved: <a href="{hf_url}" target="_blank">{hf_url}</a>{pdf_line}</div>'
1390
  )
1391
- chatbot_history.append(
1392
- gr.ChatMessage(
1393
- role="assistant", content=log_notice, metadata={"status": "done"}
1394
- )
1395
- )
1396
  yield chatbot_history
1397
 
1398
  except Exception as e:
@@ -1400,34 +852,11 @@ class GradioAgentUI(_UIFormattingMixin):
1400
  gr.ChatMessage(
1401
  role="assistant",
1402
  content=f"Error: {str(e)}",
1403
- metadata={"title": "💥 Error", "status": "done"},
1404
  )
1405
  )
1406
  yield chatbot_history
1407
 
1408
- def _what_happened_message(self, agent) -> "gr.ChatMessage | None":
1409
- """Build the collapsed 'what happened' provenance panel for a run.
1410
-
1411
- Never raises — display plumbing must not take the chat down. Returns
1412
- None when there is nothing to show (no steps / no analysis tools).
1413
- """
1414
- try:
1415
- raw_messages = agent.workflow_engine.last_state_messages or []
1416
- try:
1417
- from src.datasets.registry import list_available_datasets
1418
-
1419
- dataset_ids = [d["dataset_id"] for d in list_available_datasets()]
1420
- except Exception:
1421
- dataset_ids = []
1422
- activity = self._run_activity(raw_messages, dataset_ids)
1423
- html = self._what_happened_html(activity)
1424
- if html is None:
1425
- return None
1426
- return gr.ChatMessage(role="assistant", content=html, metadata={"status": "done"})
1427
- except Exception:
1428
- traceback.print_exc()
1429
- return None
1430
-
1431
  def _arm_downloads(self, chatbot_history: list, session_state: dict):
1432
  """Build export files at run completion and arm the download buttons.
1433
 
@@ -1436,12 +865,7 @@ class GradioAgentUI(_UIFormattingMixin):
1436
  trace), then returns DownloadButton updates so each is a single
1437
  reliable click. A button stays disabled if its file can't be built.
1438
  """
1439
- # Scope every export to the most recent question's run: a session
1440
- # accumulates questions, and sweeping the whole history embedded stale
1441
- # figures from earlier questions that could contradict the current
1442
- # run's tables (TODO 2026-08-11 #13).
1443
- current_run = self._current_question_messages(chatbot_history)
1444
- assessment = self._assessment_blocks(current_run)
1445
 
1446
  raw_messages = []
1447
  agent = session_state.get("agent")
@@ -1450,7 +874,7 @@ class GradioAgentUI(_UIFormattingMixin):
1450
  raw_messages = agent.workflow_engine.last_state_messages or []
1451
  except Exception:
1452
  raw_messages = []
1453
- log_blocks = self._log_blocks(raw_messages, current_run)
1454
 
1455
  def _safe(label, fn, *args):
1456
  try:
@@ -1459,16 +883,13 @@ class GradioAgentUI(_UIFormattingMixin):
1459
  print(f"[export] {label} build failed: {exc}")
1460
  return None
1461
 
1462
- images = self._extract_images(current_run)
1463
  a_title = "DecoupleRpy Analysis"
1464
  txt = _safe("txt", self._write_txt, assessment, a_title, images) if assessment else None
1465
  docx = _safe("docx", self._write_docx, assessment, a_title, images) if assessment else None
1466
  pdf = _safe("pdf", self._write_pdf, assessment, a_title, images) if assessment else None
1467
- log = (
1468
- _safe("log", self._write_pdf, log_blocks, "DecoupleRpy — Full Generated Logic", images)
1469
- if log_blocks
1470
- else None
1471
- )
1472
 
1473
  return (
1474
  gr.DownloadButton(value=txt, interactive=txt is not None),
@@ -1493,14 +914,14 @@ class GradioAgentUI(_UIFormattingMixin):
1493
 
1494
  def _gradio_theme(self):
1495
  return gr.themes.Monochrome(
1496
- font=fonts.GoogleFont("Inter"), font_mono=fonts.GoogleFont("JetBrains Mono")
 
1497
  )
1498
 
1499
  def create_app(self):
1500
  """Create the Gradio app with sidebar layout."""
1501
  with gr.Blocks(fill_height=True, title=self.name) as demo:
1502
- demo.load(
1503
- js="""
1504
  () => {
1505
  document.body.classList.remove('dark');
1506
  document.querySelector('gradio-app').classList.remove('dark');
@@ -1514,21 +935,9 @@ class GradioAgentUI(_UIFormattingMixin):
1514
  brandingElements.forEach(el => el.style.display = 'none');
1515
  }, 100);
1516
  }
1517
- """
1518
- )
1519
  session_state = gr.State({})
1520
  stored_messages = gr.State([])
1521
- # ADR-0012: hidden channel for the authenticated caller. The UI leaves
1522
- # it blank (direct use is anonymous); the orchestrator sets it over
1523
- # gradio_client so the identity reaches the audit trace.
1524
- principal_input = gr.Textbox(value="", visible=False, label="principal")
1525
-
1526
- # ADR-0012: HuggingFace OAuth sign-in for direct use of this Space's
1527
- # UI. Requires `hf_oauth: true` in the README metadata. The injected
1528
- # gr.OAuthProfile is read in interact_with_agent, where the allow-list
1529
- # gate is enforced. Inert until OAuth is on + ACCESS_CONTROL=enforce,
1530
- # so it is safe to ship ahead of cutover.
1531
- gr.LoginButton()
1532
 
1533
  with gr.Row():
1534
  with gr.Column(scale=1):
@@ -1540,87 +949,13 @@ class GradioAgentUI(_UIFormattingMixin):
1540
 
1541
  with gr.Group():
1542
  gr.Markdown("**Data Input (optional)**")
1543
- gr.Markdown(
1544
- "<span style='font-size:0.8em;color:#475569;'>Uploads are "
1545
- "quarantined, structure-checked (file type verified against "
1546
- "its contents), and integrity-hashed before the agent sees "
1547
- "them (ADR-0011). This is not a malware scan — do not upload "
1548
- "files from an untrusted source. Data must be "
1549
- "de-identified.</span>"
1550
- )
1551
- deid_checkbox = gr.Checkbox(
1552
- label="I confirm this data is de-identified (no PHI/PSI)",
1553
- value=False,
1554
- )
1555
  with gr.Tabs():
1556
  with gr.Tab("Upload File"):
1557
  file_input = gr.File(
1558
- label="Upload .h5ad / .csv / .tsv / .txt (.gz ok)",
1559
- file_types=[
1560
- ".h5ad",
1561
- ".csv",
1562
- ".tsv",
1563
- ".txt",
1564
- ".gz",
1565
- ],
1566
- type="filepath",
1567
- )
1568
- with gr.Tab("Assemble from TSVs"):
1569
- gr.Markdown(
1570
- "<span style='font-size:0.8em;color:#475569;'>"
1571
- "Drop a sequencing delivery as-is — a gene×sample"
1572
- " counts TSV, an optional TPM TSV, and the sample"
1573
- " metadata sheet — and the analysis file is built"
1574
- " here. Metadata must yield <code>clone</code>,"
1575
- " <code>arm</code>, <code>site</code> and"
1576
- " <code>mouse_id</code>; use the mapping fields"
1577
- " below if the sheet names them differently."
1578
- "</span>"
1579
- )
1580
- asm_counts = gr.File(
1581
- label="Counts matrix (.tsv / .csv, genes × samples)",
1582
- file_types=[".tsv", ".csv", ".txt", ".gz"],
1583
- type="filepath",
1584
- )
1585
- asm_tpm = gr.File(
1586
- label="TPM / abundance matrix (optional)",
1587
- file_types=[".tsv", ".csv", ".txt", ".gz"],
1588
- type="filepath",
1589
- )
1590
- asm_meta = gr.File(
1591
- label="Sample metadata (.xlsx / .csv / .tsv)",
1592
- file_types=[".xlsx", ".csv", ".tsv", ".txt"],
1593
  type="filepath",
1594
  )
1595
- with gr.Accordion("Metadata column mapping", open=False):
1596
- asm_sample_col = gr.Textbox(
1597
- label="Sample-id column",
1598
- placeholder="SampleName (default: first column)",
1599
- )
1600
- asm_column_map = gr.Textbox(
1601
- label="Rename columns → obs",
1602
- placeholder="mouse_id=Mouse,site=Type",
1603
- )
1604
- asm_value_maps = gr.Textbox(
1605
- label="Recode values (one per line)",
1606
- placeholder="site=Tumor:tumor\nsite=Met:liver_met",
1607
- lines=2,
1608
- )
1609
- asm_group_col = gr.Textbox(
1610
- label="Group column (derives arm + clone)",
1611
- placeholder="Group",
1612
- )
1613
- asm_control = gr.Textbox(
1614
- label="Control label", placeholder="shCntrl"
1615
- )
1616
- asm_treatment = gr.Textbox(
1617
- label="Treatment label", value="shMyc"
1618
- )
1619
- asm_skip_rows = gr.Textbox(
1620
- label="Header row offset",
1621
- placeholder="blank = auto-detect",
1622
- )
1623
- asm_btn = gr.Button("Assemble dataset", size="sm")
1624
  with gr.Tab("URL"):
1625
  url_input = gr.Textbox(
1626
  label="Public URL",
@@ -1645,7 +980,7 @@ class GradioAgentUI(_UIFormattingMixin):
1645
  lines=4,
1646
  label="Query",
1647
  placeholder="Enter your query here and press Enter or click Submit",
1648
- value="""Use decoupleRpy MCP to load a built-in RNA-seq dataset, perform preprocessing and differential expression analysis, then run transcription factor enrichment using CollecTRI. Summarize the key regulators.""",
1649
  )
1650
  submit_btn = gr.Button("Submit", variant="primary", size="lg")
1651
 
@@ -1655,25 +990,23 @@ class GradioAgentUI(_UIFormattingMixin):
1655
  minimum=5,
1656
  maximum=50,
1657
  value=self.config.max_steps,
1658
- step=1,
1659
  )
1660
  temperature_input = gr.Slider(
1661
- label="Temperature", minimum=0.0, maximum=1.0, value=0, step=0.1
 
 
 
 
1662
  )
1663
  apply_config_btn = gr.Button("Apply Configuration", size="sm")
1664
 
1665
  with gr.Accordion("Example Queries", open=False):
1666
  gr.Examples(
1667
  examples=[
1668
- [
1669
- "Use decoupleRpy MCP to load a built-in RNA-seq dataset, perform preprocessing and differential expression analysis, then run transcription factor enrichment using CollecTRI. Summarize the key regulators."
1670
- ],
1671
- [
1672
- "Load the uploaded dataset, run differential expression analysis between the two conditions, then perform TF enrichment with CollecTRI and pathway enrichment with PROGENy."
1673
- ],
1674
- [
1675
- "Run hallmark gene set enrichment on the uploaded data and identify the most significantly activated and repressed pathways."
1676
- ],
1677
  ],
1678
  inputs=text_input,
1679
  )
@@ -1700,18 +1033,10 @@ class GradioAgentUI(_UIFormattingMixin):
1700
 
1701
  with gr.Row():
1702
  copy_btn = gr.Button("📋 Copy text", size="sm")
1703
- export_txt_btn = gr.DownloadButton(
1704
- "⬇️ .txt", size="sm", value=None, interactive=False
1705
- )
1706
- export_docx_btn = gr.DownloadButton(
1707
- "⬇️ .docx", size="sm", value=None, interactive=False
1708
- )
1709
- export_pdf_btn = gr.DownloadButton(
1710
- "⬇️ .pdf", size="sm", value=None, interactive=False
1711
- )
1712
- export_log_btn = gr.DownloadButton(
1713
- "⬇️ Full log (.pdf)", size="sm", value=None, interactive=False
1714
- )
1715
 
1716
  copy_box = gr.Textbox(
1717
  label="Copy-friendly text (select all → Ctrl+C / use copy button)",
@@ -1720,11 +1045,84 @@ class GradioAgentUI(_UIFormattingMixin):
1720
  interactive=False,
1721
  )
1722
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1723
  def update_config(max_steps, temperature, session_state):
1724
  if "agent" in session_state:
1725
  agent = session_state["agent"]
1726
  agent.config.max_steps = max_steps
1727
- if hasattr(agent.model, "temperature"):
1728
  agent.model.temperature = temperature
1729
  return "Configuration updated!"
1730
 
@@ -1739,7 +1137,7 @@ class GradioAgentUI(_UIFormattingMixin):
1739
  return chatbot
1740
 
1741
  agent = session_state["agent"]
1742
- agent.config.max_steps += STEP_LIMIT_INCREMENT
1743
  run_id = session_state.get("last_run_id", datetime.now().strftime("%Y%m%d_%H%M%S"))
1744
  resume_messages = agent.workflow_engine.last_state_messages
1745
 
@@ -1750,104 +1148,52 @@ class GradioAgentUI(_UIFormattingMixin):
1750
  yield chatbot
1751
 
1752
  completed = getattr(agent.workflow_engine, "last_solution_shown", False)
1753
- reason = getattr(agent.workflow_engine, "last_end_reason", None)
1754
- session_state["step_limit_hit"] = (not completed) and reason == "step_limit"
1755
- session_state["end_reason"] = reason
1756
-
1757
- if not completed:
1758
- notice = _incomplete_run_notice(reason, agent.config)
1759
- chatbot.append(
1760
- gr.ChatMessage(
1761
- role="assistant", content=notice, metadata={"status": "done"}
1762
- )
1763
  )
 
1764
  yield chatbot
1765
 
1766
- panel = self._what_happened_message(agent)
1767
- if panel is not None:
1768
- chatbot.append(panel)
1769
- yield chatbot
1770
-
1771
- file_input.change(
1772
- handle_file_upload,
1773
- [file_input, deid_checkbox, session_state],
1774
- [session_state, file_status],
1775
- )
1776
- # Re-run the gate when the attestation is toggled, so ticking the box
1777
- # after picking a file re-validates without re-selecting it.
1778
- deid_checkbox.change(
1779
- handle_file_upload,
1780
- [file_input, deid_checkbox, session_state],
1781
- [session_state, file_status],
1782
- )
1783
- asm_btn.click(
1784
- handle_assembly,
1785
- [
1786
- asm_counts,
1787
- asm_meta,
1788
- asm_tpm,
1789
- asm_sample_col,
1790
- asm_column_map,
1791
- asm_value_maps,
1792
- asm_group_col,
1793
- asm_control,
1794
- asm_treatment,
1795
- asm_skip_rows,
1796
- deid_checkbox,
1797
- session_state,
1798
- ],
1799
- [session_state, file_status],
1800
- )
1801
- url_btn.click(
1802
- handle_url_download,
1803
- [url_input, deid_checkbox, session_state],
1804
- [session_state, file_status],
1805
- )
1806
- hf_btn.click(
1807
- handle_hf_dataset,
1808
- [hf_repo_input, hf_file_input, deid_checkbox, session_state],
1809
- [session_state, file_status],
1810
- )
1811
 
1812
  download_btns = [export_txt_btn, export_docx_btn, export_pdf_btn, export_log_btn]
1813
 
1814
  type_submit = text_input.submit(
1815
  lambda x: (x, "", gr.Button(interactive=False)),
1816
- [text_input],
1817
- [stored_messages, text_input, submit_btn],
1818
  )
1819
  type_interact = type_submit.then(
1820
  self.interact_with_agent,
1821
- [stored_messages, chatbot, session_state, principal_input],
1822
- [chatbot],
1823
  )
1824
  type_interact.then(self._arm_downloads, [chatbot, session_state], download_btns)
1825
  type_interact.then(lambda: gr.Button(interactive=True), None, [submit_btn])
1826
 
1827
  btn_submit = submit_btn.click(
1828
  lambda x: (x, "", gr.Button(interactive=False)),
1829
- [text_input],
1830
- [stored_messages, text_input, submit_btn],
1831
  )
1832
  btn_interact = btn_submit.then(
1833
  self.interact_with_agent,
1834
- [stored_messages, chatbot, session_state, principal_input],
1835
- [chatbot],
1836
  )
1837
  btn_interact.then(self._arm_downloads, [chatbot, session_state], download_btns)
1838
  btn_interact.then(lambda: gr.Button(interactive=True), None, [submit_btn])
1839
 
1840
- apply_config_btn.click(
1841
- update_config, [max_steps_input, temperature_input, session_state], None
1842
- )
1843
  clear_btn.click(
1844
  clear_chat, [session_state], [chatbot, stored_messages, session_state]
1845
  ).then(
1846
- lambda: tuple(
1847
- gr.DownloadButton(value=None, interactive=False) for _ in download_btns
1848
- ),
1849
- None,
1850
- download_btns,
1851
  )
1852
 
1853
  # Export / copy buttons
@@ -1863,14 +1209,12 @@ class GradioAgentUI(_UIFormattingMixin):
1863
  # armed at run completion by _arm_downloads — a single reliable click,
1864
  # with no recompute-on-click and no truncation.
1865
 
1866
- continue_interact = continue_btn.click(
1867
- handle_continue, [chatbot, session_state], [chatbot]
1868
- )
1869
  continue_interact.then(self._arm_downloads, [chatbot, session_state], download_btns)
1870
  stop_btn.click(
1871
  lambda: gr.Button(interactive=True),
1872
  outputs=[submit_btn],
1873
- cancels=[type_submit, type_interact, btn_submit, btn_interact, continue_interact],
1874
  )
1875
 
1876
  return demo
@@ -1878,11 +1222,11 @@ class GradioAgentUI(_UIFormattingMixin):
1878
  def launch(self, share: bool = False, **kwargs):
1879
  """Launch the Gradio app."""
1880
  app = self.create_app()
1881
- kwargs.setdefault("server_name", "0.0.0.0")
1882
- kwargs.setdefault("server_port", 7860)
1883
- kwargs.setdefault("show_error", True)
1884
  # Gradio 6.0 moved theme + css off the Blocks() constructor onto launch().
1885
- kwargs.setdefault("theme", self._gradio_theme())
1886
- kwargs.setdefault("css", self._GRADIO_CSS)
1887
  # gradio 6.0 removed the show_api argument from launch().
1888
  app.queue(max_size=10).launch(share=share, favicon_path=None, **kwargs)
 
14
  import threading
15
  import time
16
  import traceback
 
17
  from datetime import datetime
18
+ from typing import Generator
19
 
20
  import gradio as gr
21
  from gradio.themes.utils import fonts
22
+ from langchain_core.messages import HumanMessage, AIMessage
23
  from langchain_anthropic import ChatAnthropic
 
24
 
25
  from agent import CodeAgent
 
26
  from core.constants import DECOUPLER_DISCLAIMER
27
  from core.types import AgentConfig
 
28
  from managers.hf_storage import HFResultsStorage
29
+ from logging_sink import get_log_sink, persist_trace_safe
30
  from ui_formatting import _UIFormattingMixin
31
 
32
  # ---------------------------------------------------------------------------
 
36
  _MCP_HTTP_PORT = 8765
37
  _mcp_server_proc: subprocess.Popen | None = None
38
 
 
 
 
39
 
40
  def _port_open(port: int, host: str = "127.0.0.1") -> bool:
41
  try:
 
100
  # port before committing to HTTP-vs-stdio. Catch a fast crash early.
101
  for i in range(15):
102
  if _mcp_server_proc.poll() is not None:
103
+ print(f"[MCP] Server process exited early (code {_mcp_server_proc.returncode}) — "
104
+ "see logs above; prewarm will fall back to stdio")
 
 
105
  return url
106
  if _port_open(_MCP_HTTP_PORT):
107
+ print(f"[MCP] HTTP server ready at {url} (took {i+1}s)")
108
  return url
109
  time.sleep(1)
110
 
111
+ print(f"[MCP] HTTP server not up in 15s — prewarm will keep waiting in the background")
112
  return url
113
 
114
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
115
  class GradioAgentUI(_UIFormattingMixin):
116
  """
117
  Gradio interface for interacting with the LangGraph ReAct Agent.
 
123
  if model is None:
124
  # Prefer the standard ANTHROPIC_API_KEY; fall back to the legacy
125
  # mixed-case name some older Spaces still use.
126
+ api_key_anthropic = (
127
+ os.environ.get("ANTHROPIC_API_KEY")
128
+ or os.environ.get("Anthropic_API_KEY")
129
  )
130
  if not api_key_anthropic:
131
  raise RuntimeError(
 
133
  "or Anthropic_API_KEY in the Space secrets."
134
  )
135
  model = ChatAnthropic(
136
+ model='claude-sonnet-4-6',
137
+ temperature=0,
138
+ api_key=api_key_anthropic
139
  )
140
 
141
  if config is None:
142
+ config = AgentConfig(
143
+ max_steps=15,
144
+ retry_attempts=3,
145
+ timeout_seconds=1200,
146
+ verbose=True
147
+ )
148
 
149
  self.model = model
150
  self.config = config
 
160
  except Exception as e:
161
  print(f"[log_sink] init failed ({e}); falling back to local sink")
162
  from logging_sink import LocalLogSink
 
163
  self.log_sink = LocalLogSink()
164
  print(f"[log_sink] Using sink: {self.log_sink.name}")
165
 
 
167
  # doesn't pay the 2-minute subprocess startup cost.
168
  # The seed agent's mcp_functions (stateless closures) are cached and
169
  # copied into each new session agent without re-spawning anything.
170
+ self._mcp_cache: dict = {} # tool_name → mcp_functions entry
171
  self._mcp_discovery_errors: dict = {} # server_name -> error string, if discovery failed
172
  self._mcp_ready = threading.Event() # set when pre-warm finishes
173
 
 
254
  if not seed.tool_manager.mcp_manager.mcp_functions:
255
  # HTTP discovery returned nothing — fall back to stdio so the
256
  # Space is still usable (just slower) rather than tool-less.
257
+ print("[mcp-prewarm] ⚠️ HTTP discovery returned 0 tools — "
258
+ "falling back to stdio add_mcp()")
 
 
259
  self._mcp_http_url = None
260
  self._probe_stdio_server()
261
  seed.add_mcp(mcp_config_path)
 
276
  # add_mcp() didn't raise, but discovered zero tools — this is
277
  # just as broken as an exception (agent will have no analysis
278
  # tools), so log it just as loudly.
279
+ print("[mcp-prewarm] ⚠️ add_mcp() completed but discovered 0 MCP "
280
+ "tools analysis requests will fail. Check server.py / "
281
+ "mcp_config.yaml on this Space. "
282
+ f"Discovery errors: {self._mcp_discovery_errors}")
 
 
283
  except Exception as e:
284
+ print("[mcp-prewarm] ⚠️ Pre-warm failed — will retry discovery on "
285
+ "first query. Full traceback:")
 
 
286
  traceback.print_exc()
287
  self._mcp_discovery_errors["_prewarm"] = f"{type(e).__name__}: {e}"
288
  finally:
 
349
  previous_plan = None
350
  step_timings = {}
351
  last_state = None
352
+ last_error = None # buffer transient step errors; only the last is
353
+ solution_shown = False # surfaced, and only if the run yields no solution
354
 
355
  # Monotonic step numbering for the UI, decoupled from the raw graph
356
  # step_count. The raw count is emitted twice per step (once for the
 
360
  # the prior run on /handle_continue so the continuation keeps counting
361
  # up; reset to 0 for a fresh question.
362
  display_step = (
363
+ getattr(agent.workflow_engine, "last_display_step", 0)
364
+ if resume_messages else 0
365
  )
366
+ last_internal_step = None # raw step_count we last opened a header for
367
+ header_pending = False # a step started; flush its header before content
368
  header_drawn = bool(resume_messages) # leading <hr> before the first step?
369
 
370
  # Snapshot existing figures so we can detect ones this run produces and
 
398
  # header is gated on it, so a deduped/empty yield no longer
399
  # leaves an orphan "Step N" with no body beneath it.
400
  thinking_text = ""
401
+ if parts['thinking'] and len(parts['thinking']) > 20:
402
+ _t = re.sub(r'(Thinking:|Plan:)\s*', '', parts['thinking']).strip()
403
+ _t = re.sub(r'\n\s*\n\s*\n+', '\n\n', _t).strip()
404
+ _t = '\n'.join(ln.strip() for ln in _t.split('\n') if ln.strip())
405
  if _t and hash(_t) not in displayed_reasoning:
406
  thinking_text = _t
407
 
408
  # Route an errored observation to the buffered-error path before
409
  # it can count as renderable content (see the long note below).
410
+ if parts['observation']:
411
+ _obs = re.sub(r'^\s*Code Output:\s*', '', parts['observation']).strip()
412
+ if _obs.startswith('Error:'):
413
  last_error = _obs
414
+ parts['observation'] = None
415
 
416
  plan_changed = bool(current_plan and current_plan != previous_plan)
417
+ has_content = bool(thinking_text or plan_changed or parts['code']
418
+ or parts['observation'] or parts['solution'])
 
 
 
 
 
419
 
420
  # One header per logical step. graph.stream emits a state after
421
  # both the generate node (step N) and the execute node (still
 
433
  display_step += 1
434
  separator = (
435
  '<hr style="border: none; border-top: 1px solid #e2e8f0; '
436
+ 'margin: 20px 0;">' if header_drawn else ''
 
 
437
  )
438
  header_drawn = True
439
  yield gr.ChatMessage(
440
  role="assistant",
441
  content=f"{separator}## Step {display_step}\n",
442
+ metadata={"status": "done"}
443
  )
444
 
445
  if thinking_text:
 
448
  <div style="margin: 0; white-space: pre-line; font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; color: #000000; background-color: #ffffff; padding: 10px; border-radius: 4px; font-size: 14px; line-height: 1.6;">{thinking_text}</div>
449
  </div>"""
450
  yield gr.ChatMessage(
451
+ role="assistant",
452
+ content=thinking_block,
453
+ metadata={"status": "done"}
454
  )
455
  displayed_reasoning.add(hash(thinking_text))
456
 
457
  if current_plan and current_plan != previous_plan:
458
+ formatted_plan = current_plan.replace('[ ]', '☐').replace('[✓]', '✅').replace('[✗]', '❌')
 
 
 
 
459
  plan_block = f"""<div style="background-color: #f8f9fa; border-left: 4px solid #000000; padding: 12px; margin: 10px 0; border-radius: 4px;">
460
  <div style="font-weight: bold; color: #000000; margin-bottom: 8px;">📋 Current Plan</div>
461
  <pre style="margin: 0; white-space: pre-wrap; font-family: 'Consolas', 'Monaco', 'Courier New', monospace; color: #000000; background-color: #ffffff; padding: 10px; border-radius: 4px; font-size: 14px; line-height: 1.6;">{formatted_plan}</pre>
462
  </div>"""
463
  yield gr.ChatMessage(
464
+ role="assistant",
465
+ content=plan_block,
466
+ metadata={"status": "done"}
467
  )
468
  previous_plan = current_plan
469
 
470
+ if parts['code']:
471
  code_block = f"""<div style="background-color: #f7fafc; border-left: 4px solid #48bb78; padding: 12px; margin: 10px 0; border-radius: 4px;">
472
  <div style="font-weight: bold; color: #22543d; margin-bottom: 8px;">⚡ Executing Code</div>
473
  </div>
474
 
475
  ```python
476
+ {parts['code']}
477
  ```"""
478
  yield gr.ChatMessage(
479
+ role="assistant",
480
+ content=code_block,
481
+ metadata={"status": "done"}
482
  )
483
 
484
  # An execution that raised is returned by the executor as
 
486
  # buffered-error path above (parts['observation'] nulled) so it
487
  # never renders an alarming "Code Output: Error" block for a
488
  # hiccup the agent usually self-corrects on the next step.
489
+ if parts['observation']:
490
+ truncated = self.truncate_output(parts['observation'])
491
  result_block = f"""<div style="background-color: #fef5e7; border-left: 4px solid #f6ad55; padding: 12px; margin: 10px 0; border-radius: 4px;">
492
  <div style="font-weight: bold; color: #744210; margin-bottom: 8px;">📊 Execution Result</div>
493
  </div>
 
496
  {truncated}
497
  ```"""
498
  yield gr.ChatMessage(
499
+ role="assistant",
500
+ content=result_block,
501
+ metadata={"status": "done"}
502
  )
503
 
504
+ all_artifacts = self._extract_artifacts(parts['observation'])
505
  for desc, path in all_artifacts:
506
+ if path.endswith('.png') and os.path.exists(path):
507
+ with open(path, 'rb') as f:
508
  img_b64 = base64.b64encode(f.read()).decode()
509
  yield gr.ChatMessage(
510
  role="assistant",
511
  content=self._figure_html(desc, img_b64),
512
+ metadata={"status": "done"}
513
  )
514
  shown_fig_names.add(os.path.basename(path))
515
  self.hf_storage.upload_artifacts(all_artifacts, run_id)
516
 
517
+ if parts['solution']:
518
  solution_shown = True
519
  # The standing decoupleR method limitations are appended
520
  # deterministically here, after the model's text — never
521
  # generated by the model — so they are identical on every
522
  # run and can never be softened or dropped. The model
523
  # writes only run-specific caveats.
524
+ solution_text = parts['solution'] + "\n\n" + DECOUPLER_DISCLAIMER
525
  solution_block = f"""<div style="background-color: #d1fae5; border-left: 4px solid #10b981; padding: 16px; margin: 10px 0; border-radius: 6px;">
526
  <div style="font-weight: bold; color: #065f46; margin-bottom: 12px; font-size: 16px;">✅ Final Solution</div>
527
  <div class="solution-content" style="color: #1f2937 !important; background-color: #ffffff; padding: 16px; border-radius: 4px; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; font-size: 15px; line-height: 1.7; border: 1px solid #e5e7eb;">
 
567
  </style>
568
  </div>"""
569
  yield gr.ChatMessage(
570
+ role="assistant",
571
+ content=solution_block,
572
+ metadata={"status": "done"}
573
  )
574
 
575
+ if parts['error']:
576
  # Buffer, don't render. These are mostly transient
577
  # NameError/AttributeError that the CodeAgent hits and then
578
  # self-corrects on a later step — showing each one floods the
579
  # user with red boxes for problems that were already resolved.
580
  # The last error is surfaced after the loop only if the run
581
  # never produced a solution (i.e. it genuinely failed).
582
+ last_error = parts['error']
583
 
584
+ if parts['observation'] and step_count in step_timings:
585
+ duration = time.time() - step_timings[step_count]
586
+ footnote = f'<div style="color: #718096; font-size: 0.875em; margin-top: 8px;">Step {display_step} | Duration: {duration:.2f}s</div>'
587
  yield gr.ChatMessage(
588
+ role="assistant",
589
+ content=footnote,
590
+ metadata={"status": "done"}
591
  )
592
 
593
  # (The inter-step <hr> divider is emitted as part of the next
 
620
  yield gr.ChatMessage(
621
  role="assistant",
622
  content=self._figure_html(desc, img_b64),
623
+ metadata={"status": "done"}
624
  )
625
  shown_fig_names.add(os.path.basename(path))
626
  if new_figures:
 
635
  <div style="color: #742a2a;">{last_error}</div>
636
  </div>"""
637
  yield gr.ChatMessage(
638
+ role="assistant",
639
+ content=error_block,
640
+ metadata={"status": "done"}
641
  )
642
 
643
  except Exception as e:
 
645
  <div style="font-weight: bold; color: #742a2a; margin-bottom: 8px;">💥 Critical Error</div>
646
  <div style="color: #742a2a;">Error during agent execution: {str(e)}</div>
647
  </div>"""
648
+ yield gr.ChatMessage(
649
+ role="assistant",
650
+ content=error_block,
651
+ metadata={"status": "done"}
652
+ )
653
 
654
+ def interact_with_agent(self, query: str, chatbot_history: list, session_state: dict) -> Generator:
655
+ """Handle interaction with the agent."""
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
656
  original_query = query
657
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
658
  uploaded_file = session_state.get("uploaded_file")
659
  if uploaded_file and os.path.exists(uploaded_file):
660
  filename = session_state.get("uploaded_filename", os.path.basename(uploaded_file))
661
  is_geo = False
662
  if filename.endswith((".txt", ".tsv")):
663
  try:
664
+ with open(uploaded_file, "r", encoding="utf-8", errors="replace") as _f:
665
  is_geo = _f.readline().startswith("!")
666
  except Exception:
667
  pass
 
704
  # Re-register into the tool catalog so the agent can call them.
705
  for tool_name, tool_data in self._mcp_cache.items():
706
  from managers.tools.tool_manager import ToolInfo, ToolSource
 
707
  tool_info = ToolInfo(
708
  name=tool_name,
709
  description=tool_data.get("description", "MCP tool"),
 
716
  schema=None,
717
  )
718
  mgr._tool_catalog[tool_name] = tool_info
719
+ print(f"✅ Loaded {len(self._mcp_cache)} MCP tools from cache (no subprocess spawn)")
 
 
720
  else:
721
  # Cache empty (pre-warm failed) — fall back to live discovery.
722
  # Prefer the persistent HTTP server if it's reachable; only drop
 
744
  mcp_tool_count = session_state["agent"].get_tool_statistics()["by_source"].get("mcp", 0)
745
  session_state["mcp_tool_count"] = mcp_tool_count
746
  if mcp_tool_count == 0:
747
+ print("⚠️ Session agent has 0 MCP tools — decoupleR analysis "
748
+ "tools are unavailable for this session.")
 
 
749
 
750
  agent = session_state["agent"]
 
 
 
751
  run_id = datetime.now().strftime("%Y%m%d_%H%M%S")
752
 
753
  try:
754
+ chatbot_history.append(gr.ChatMessage(role="user", content=original_query, metadata={"status": "done"}))
 
 
755
  yield chatbot_history
756
 
757
  if session_state.get("mcp_tool_count", 0) == 0:
758
  error_notice = (
759
  '<div style="background-color:#fee2e2;border-left:4px solid #dc2626;'
760
  'padding:12px 16px;margin:10px 0;border-radius:4px;color:#7f1d1d;">'
761
+ '⚠️ <strong>Analysis tools unavailable.</strong> The decoupleR MCP '
762
+ 'tools failed to load for this session, so I cannot run any real '
763
+ 'analysis (TF activity, pathway/hallmark scoring, differential '
764
+ 'expression, etc.) right now. Rather than guess at results, I\'m '
765
+ 'stopping here — please try again in a moment, or contact the '
766
+ 'maintainer if this persists.</div>'
 
 
 
 
 
767
  )
768
+ chatbot_history.append(gr.ChatMessage(role="assistant", content=error_notice, metadata={"status": "done"}))
769
  yield chatbot_history
770
  return
771
 
772
  if uploaded_file and os.path.exists(uploaded_file):
773
  file_notice = f'<div style="background:#f0fdf4;border-left:3px solid #22c55e;padding:8px 12px;margin:6px 0;border-radius:4px;font-size:0.85em;color:#166534;">Using uploaded file: <strong>{filename}</strong></div>'
774
+ chatbot_history.append(gr.ChatMessage(role="assistant", content=file_notice, metadata={"status": "done"}))
 
 
 
 
775
  yield chatbot_history
776
 
777
+ chatbot_history.append(gr.ChatMessage(role="assistant", content="🤔 Processing...", metadata={"status": "pending"}))
 
 
 
 
778
  yield chatbot_history
779
  chatbot_history.pop()
780
 
 
783
  yield chatbot_history
784
 
785
  completed = getattr(agent.workflow_engine, "last_solution_shown", False)
786
+ session_state["step_limit_hit"] = not completed
 
 
 
787
  session_state["last_run_id"] = run_id
788
 
789
  # ALWAYS-ON audit trace persistence via the configured sink.
 
799
  print("⚠️ Could not persist execution trace:")
800
  traceback.print_exc()
801
 
802
+ if session_state["step_limit_hit"]:
803
+ notice = (
804
+ '<div style="background-color:#dbeafe;border-left:4px solid #2563eb;'
805
+ 'padding:10px 14px;margin:10px 0;border-radius:4px;font-size:0.9em;color:#1e3a5f;">'
806
+ '⏸ <strong>Step limit reached.</strong> Click <strong>Continue</strong> '
807
+ 'to give the agent 15 more steps.</div>'
808
  )
809
+ chatbot_history.append(gr.ChatMessage(role="assistant", content=notice, metadata={"status": "done"}))
 
 
 
 
 
 
810
  yield chatbot_history
811
 
812
  saved = self.hf_storage.save_conversation(chatbot_history, original_query, run_id)
 
823
  pdf_remote = None
824
  try:
825
  raw_messages = agent.workflow_engine.last_state_messages or []
826
+ log_blocks = self._log_blocks(raw_messages, chatbot_history)
 
827
  if log_blocks:
828
+ images = self._extract_images(chatbot_history)
829
+ pdf_path = self._write_pdf(log_blocks, f"DecoupleRpy Full Run {run_id}", images)
 
 
 
 
830
  remote_name = f"full_run{os.path.splitext(pdf_path)[1] or '.pdf'}"
831
  if self.hf_storage.upload_run_file(pdf_path, run_id, remote_name):
832
  pdf_remote = remote_name
 
838
  pdf_line = ""
839
  if pdf_remote:
840
  pdf_url = f"https://huggingface.co/datasets/{self.hf_storage.repo_id}/resolve/main/runs/{run_id}/{pdf_remote}"
841
+ pdf_line = f'<br>📄 Full run PDF: <a href="{pdf_url}" target="_blank">{pdf_remote}</a>'
 
 
842
  log_notice = (
843
  f'<div style="background-color:#f0f9ff;border-left:3px solid #0ea5e9;'
844
  f'padding:6px 12px;margin:6px 0;border-radius:4px;font-size:0.82em;color:#0c4a6e;">'
845
  f'📁 Full run log saved: <a href="{hf_url}" target="_blank">{hf_url}</a>{pdf_line}</div>'
846
  )
847
+ chatbot_history.append(gr.ChatMessage(role="assistant", content=log_notice, metadata={"status": "done"}))
 
 
 
 
848
  yield chatbot_history
849
 
850
  except Exception as e:
 
852
  gr.ChatMessage(
853
  role="assistant",
854
  content=f"Error: {str(e)}",
855
+ metadata={"title": "💥 Error", "status": "done"}
856
  )
857
  )
858
  yield chatbot_history
859
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
860
  def _arm_downloads(self, chatbot_history: list, session_state: dict):
861
  """Build export files at run completion and arm the download buttons.
862
 
 
865
  trace), then returns DownloadButton updates so each is a single
866
  reliable click. A button stays disabled if its file can't be built.
867
  """
868
+ assessment = self._assessment_blocks(chatbot_history)
 
 
 
 
 
869
 
870
  raw_messages = []
871
  agent = session_state.get("agent")
 
874
  raw_messages = agent.workflow_engine.last_state_messages or []
875
  except Exception:
876
  raw_messages = []
877
+ log_blocks = self._log_blocks(raw_messages, chatbot_history)
878
 
879
  def _safe(label, fn, *args):
880
  try:
 
883
  print(f"[export] {label} build failed: {exc}")
884
  return None
885
 
886
+ images = self._extract_images(chatbot_history)
887
  a_title = "DecoupleRpy Analysis"
888
  txt = _safe("txt", self._write_txt, assessment, a_title, images) if assessment else None
889
  docx = _safe("docx", self._write_docx, assessment, a_title, images) if assessment else None
890
  pdf = _safe("pdf", self._write_pdf, assessment, a_title, images) if assessment else None
891
+ log = _safe("log", self._write_pdf, log_blocks,
892
+ "DecoupleRpy — Full Generated Logic", images) if log_blocks else None
 
 
 
893
 
894
  return (
895
  gr.DownloadButton(value=txt, interactive=txt is not None),
 
914
 
915
  def _gradio_theme(self):
916
  return gr.themes.Monochrome(
917
+ font=fonts.GoogleFont("Inter"),
918
+ font_mono=fonts.GoogleFont("JetBrains Mono")
919
  )
920
 
921
  def create_app(self):
922
  """Create the Gradio app with sidebar layout."""
923
  with gr.Blocks(fill_height=True, title=self.name) as demo:
924
+ demo.load(js="""
 
925
  () => {
926
  document.body.classList.remove('dark');
927
  document.querySelector('gradio-app').classList.remove('dark');
 
935
  brandingElements.forEach(el => el.style.display = 'none');
936
  }, 100);
937
  }
938
+ """)
 
939
  session_state = gr.State({})
940
  stored_messages = gr.State([])
 
 
 
 
 
 
 
 
 
 
 
941
 
942
  with gr.Row():
943
  with gr.Column(scale=1):
 
949
 
950
  with gr.Group():
951
  gr.Markdown("**Data Input (optional)**")
 
 
 
 
 
 
 
 
 
 
 
 
952
  with gr.Tabs():
953
  with gr.Tab("Upload File"):
954
  file_input = gr.File(
955
+ label="Upload .h5ad or .csv",
956
+ file_types=[".h5ad", ".csv"],
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
957
  type="filepath",
958
  )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
959
  with gr.Tab("URL"):
960
  url_input = gr.Textbox(
961
  label="Public URL",
 
980
  lines=4,
981
  label="Query",
982
  placeholder="Enter your query here and press Enter or click Submit",
983
+ value="""Use decoupleRpy MCP to load a built-in RNA-seq dataset, perform preprocessing and differential expression analysis, then run transcription factor enrichment using CollecTRI. Summarize the key regulators."""
984
  )
985
  submit_btn = gr.Button("Submit", variant="primary", size="lg")
986
 
 
990
  minimum=5,
991
  maximum=50,
992
  value=self.config.max_steps,
993
+ step=1
994
  )
995
  temperature_input = gr.Slider(
996
+ label="Temperature",
997
+ minimum=0.0,
998
+ maximum=1.0,
999
+ value=0,
1000
+ step=0.1
1001
  )
1002
  apply_config_btn = gr.Button("Apply Configuration", size="sm")
1003
 
1004
  with gr.Accordion("Example Queries", open=False):
1005
  gr.Examples(
1006
  examples=[
1007
+ ["Use decoupleRpy MCP to load a built-in RNA-seq dataset, perform preprocessing and differential expression analysis, then run transcription factor enrichment using CollecTRI. Summarize the key regulators."],
1008
+ ["Load the uploaded dataset, run differential expression analysis between the two conditions, then perform TF enrichment with CollecTRI and pathway enrichment with PROGENy."],
1009
+ ["Run hallmark gene set enrichment on the uploaded data and identify the most significantly activated and repressed pathways."],
 
 
 
 
 
 
1010
  ],
1011
  inputs=text_input,
1012
  )
 
1033
 
1034
  with gr.Row():
1035
  copy_btn = gr.Button("📋 Copy text", size="sm")
1036
+ export_txt_btn = gr.DownloadButton("⬇️ .txt", size="sm", value=None, interactive=False)
1037
+ export_docx_btn = gr.DownloadButton("⬇️ .docx", size="sm", value=None, interactive=False)
1038
+ export_pdf_btn = gr.DownloadButton("⬇️ .pdf", size="sm", value=None, interactive=False)
1039
+ export_log_btn = gr.DownloadButton("⬇️ Full log (.pdf)", size="sm", value=None, interactive=False)
 
 
 
 
 
 
 
 
1040
 
1041
  copy_box = gr.Textbox(
1042
  label="Copy-friendly text (select all → Ctrl+C / use copy button)",
 
1045
  interactive=False,
1046
  )
1047
 
1048
+ def _inputs_dir():
1049
+ from pathlib import Path
1050
+ d = Path(__file__).parent / "tmp" / "inputs"
1051
+ d.mkdir(parents=True, exist_ok=True)
1052
+ return d
1053
+
1054
+ def handle_file_upload(file_path, session_state):
1055
+ import shutil
1056
+ if file_path is None:
1057
+ session_state.pop("uploaded_file", None)
1058
+ session_state.pop("uploaded_filename", None)
1059
+ return session_state, ""
1060
+ filename = os.path.basename(file_path)
1061
+ dest = str(_inputs_dir() / filename)
1062
+ shutil.copy2(file_path, dest)
1063
+ session_state["uploaded_file"] = dest
1064
+ session_state["uploaded_filename"] = filename
1065
+ return session_state, f"Ready: {filename}"
1066
+
1067
+ def handle_url_download(url, session_state):
1068
+ import gzip
1069
+ import requests
1070
+ import shutil as _shutil
1071
+ if not url or not url.strip():
1072
+ return session_state, "No URL provided"
1073
+ url = url.strip()
1074
+ filename = url.rstrip("/").split("/")[-1].split("?")[0]
1075
+ if not filename or "." not in filename:
1076
+ filename = "downloaded_data.bin"
1077
+ dest = str(_inputs_dir() / filename)
1078
+ try:
1079
+ r = requests.get(url, stream=True, timeout=300)
1080
+ r.raise_for_status()
1081
+ ct = r.headers.get("Content-Type", "")
1082
+ if "text/html" in ct:
1083
+ return session_state, "URL returned an HTML page, not a file. Make sure the URL points directly to a file, not a directory."
1084
+ with open(dest, "wb") as f:
1085
+ for chunk in r.iter_content(chunk_size=8192):
1086
+ if chunk:
1087
+ f.write(chunk)
1088
+ if filename.endswith(".gz") and not filename.endswith(".tar.gz"):
1089
+ decompressed = filename[:-3]
1090
+ decompressed_dest = str(_inputs_dir() / decompressed)
1091
+ with gzip.open(dest, "rb") as f_in, open(decompressed_dest, "wb") as f_out:
1092
+ _shutil.copyfileobj(f_in, f_out)
1093
+ os.remove(dest)
1094
+ dest, filename = decompressed_dest, decompressed
1095
+ session_state["uploaded_file"] = dest
1096
+ session_state["uploaded_filename"] = filename
1097
+ return session_state, f"Ready: {filename}"
1098
+ except Exception as e:
1099
+ return session_state, f"Download failed: {e}"
1100
+
1101
+ def handle_hf_dataset(repo_id, filepath, session_state):
1102
+ if not repo_id or not repo_id.strip():
1103
+ return session_state, "No repo ID provided"
1104
+ if not filepath or not filepath.strip():
1105
+ return session_state, "No file path provided"
1106
+ try:
1107
+ from huggingface_hub import hf_hub_download
1108
+ local_path = hf_hub_download(
1109
+ repo_id=repo_id.strip(),
1110
+ filename=filepath.strip(),
1111
+ repo_type="dataset",
1112
+ local_dir=str(_inputs_dir()),
1113
+ )
1114
+ filename = os.path.basename(local_path)
1115
+ session_state["uploaded_file"] = local_path
1116
+ session_state["uploaded_filename"] = filename
1117
+ return session_state, f"Ready: {filename}"
1118
+ except Exception as e:
1119
+ return session_state, f"Failed to load from HF Dataset: {e}"
1120
+
1121
  def update_config(max_steps, temperature, session_state):
1122
  if "agent" in session_state:
1123
  agent = session_state["agent"]
1124
  agent.config.max_steps = max_steps
1125
+ if hasattr(agent.model, 'temperature'):
1126
  agent.model.temperature = temperature
1127
  return "Configuration updated!"
1128
 
 
1137
  return chatbot
1138
 
1139
  agent = session_state["agent"]
1140
+ agent.config.max_steps += 15
1141
  run_id = session_state.get("last_run_id", datetime.now().strftime("%Y%m%d_%H%M%S"))
1142
  resume_messages = agent.workflow_engine.last_state_messages
1143
 
 
1148
  yield chatbot
1149
 
1150
  completed = getattr(agent.workflow_engine, "last_solution_shown", False)
1151
+ session_state["step_limit_hit"] = not completed
1152
+
1153
+ if session_state["step_limit_hit"]:
1154
+ notice = (
1155
+ '<div style="background-color:#dbeafe;border-left:4px solid #2563eb;'
1156
+ 'padding:10px 14px;margin:10px 0;border-radius:4px;font-size:0.9em;color:#1e3a5f;">'
1157
+ '⏸ <strong>Step limit reached again.</strong> Click <strong>Continue</strong> '
1158
+ 'to add 15 more steps.</div>'
 
 
1159
  )
1160
+ chatbot.append(gr.ChatMessage(role="assistant", content=notice, metadata={"status": "done"}))
1161
  yield chatbot
1162
 
1163
+ file_input.change(handle_file_upload, [file_input, session_state], [session_state, file_status])
1164
+ url_btn.click(handle_url_download, [url_input, session_state], [session_state, file_status])
1165
+ hf_btn.click(handle_hf_dataset, [hf_repo_input, hf_file_input, session_state], [session_state, file_status])
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1166
 
1167
  download_btns = [export_txt_btn, export_docx_btn, export_pdf_btn, export_log_btn]
1168
 
1169
  type_submit = text_input.submit(
1170
  lambda x: (x, "", gr.Button(interactive=False)),
1171
+ [text_input], [stored_messages, text_input, submit_btn]
 
1172
  )
1173
  type_interact = type_submit.then(
1174
  self.interact_with_agent,
1175
+ [stored_messages, chatbot, session_state], [chatbot]
 
1176
  )
1177
  type_interact.then(self._arm_downloads, [chatbot, session_state], download_btns)
1178
  type_interact.then(lambda: gr.Button(interactive=True), None, [submit_btn])
1179
 
1180
  btn_submit = submit_btn.click(
1181
  lambda x: (x, "", gr.Button(interactive=False)),
1182
+ [text_input], [stored_messages, text_input, submit_btn]
 
1183
  )
1184
  btn_interact = btn_submit.then(
1185
  self.interact_with_agent,
1186
+ [stored_messages, chatbot, session_state], [chatbot]
 
1187
  )
1188
  btn_interact.then(self._arm_downloads, [chatbot, session_state], download_btns)
1189
  btn_interact.then(lambda: gr.Button(interactive=True), None, [submit_btn])
1190
 
1191
+ apply_config_btn.click(update_config, [max_steps_input, temperature_input, session_state], None)
 
 
1192
  clear_btn.click(
1193
  clear_chat, [session_state], [chatbot, stored_messages, session_state]
1194
  ).then(
1195
+ lambda: tuple(gr.DownloadButton(value=None, interactive=False) for _ in download_btns),
1196
+ None, download_btns
 
 
 
1197
  )
1198
 
1199
  # Export / copy buttons
 
1209
  # armed at run completion by _arm_downloads — a single reliable click,
1210
  # with no recompute-on-click and no truncation.
1211
 
1212
+ continue_interact = continue_btn.click(handle_continue, [chatbot, session_state], [chatbot])
 
 
1213
  continue_interact.then(self._arm_downloads, [chatbot, session_state], download_btns)
1214
  stop_btn.click(
1215
  lambda: gr.Button(interactive=True),
1216
  outputs=[submit_btn],
1217
+ cancels=[type_submit, type_interact, btn_submit, btn_interact, continue_interact]
1218
  )
1219
 
1220
  return demo
 
1222
  def launch(self, share: bool = False, **kwargs):
1223
  """Launch the Gradio app."""
1224
  app = self.create_app()
1225
+ kwargs.setdefault('server_name', '0.0.0.0')
1226
+ kwargs.setdefault('server_port', 7860)
1227
+ kwargs.setdefault('show_error', True)
1228
  # Gradio 6.0 moved theme + css off the Blocks() constructor onto launch().
1229
+ kwargs.setdefault('theme', self._gradio_theme())
1230
+ kwargs.setdefault('css', self._GRADIO_CSS)
1231
  # gradio 6.0 removed the show_api argument from launch().
1232
  app.queue(max_size=10).launch(share=share, favicon_path=None, **kwargs)
memory.md ADDED
The diff for this file is too large to render. See raw diff
 
pdac-analysis-orchestrator-dev ADDED
@@ -0,0 +1 @@
 
 
1
+ Subproject commit dd6588af335b3a7ee4c99b09f2dba1dd6e9ff023
prompts.yaml CHANGED
@@ -15,7 +15,7 @@ system_prompt: "You are an expert assistant who can solve any task using Python
15
  \ generated\n - An **Approach** section — the analysis plan, each computation step, which tools/methods ran, and (for a non-coding scientist) what each decoupleR method actually does (see ## Explaining Your Approach). Describe ONLY the clean final path — never the missteps, retries, or transient errors you self-corrected\n - Any files created or modified\n - Next steps or\
16
  \ recommendations (if applicable)\n\n## Code Execution Best Practices\n\n### Printing Results\n**YOU MUST print outputs**\
17
  \ - the system cannot see unpublished results:\n```python\n# ✓ CORRECT\nresult = some_function(param1, param2)\nprint(result)\n\
18
- \n# ✗ WRONG - output is invisible\nsome_function(param1, param2)\n```\n\n### Saving Plots\n**Every figure you create MUST be written into the tool output directory** — the same directory the MCP tools write their CSVs and PNGs to (`tmp/outputs/`, or `$RNA_OUTPUT_DIR` when set). Only files in that directory are collected and shown inline to the user at the end of the run; a figure saved to `/tmp/`, the working directory, or any other path is invisible — the user asks to see the landscape and silently gets no image.\n\n```python\n# CORRECT - lands where the UI can find it\nimport os\nout_dir = os.environ.get('RNA_OUTPUT_DIR', 'tmp/outputs')\nos.makedirs(out_dir, exist_ok=True)\nfig_path = os.path.join(out_dir, 'progeny_landscape.png')\nplt.savefig(fig_path, dpi=200, bbox_inches='tight')\nprint(f'Saved figure: {fig_path}')\n\n# WRONG - never surfaced to the user\nplt.savefig('/tmp/progeny_landscape.png')\n```\n\n**Prefer the tool's own figure over hand-rolled matplotlib.** Several tools already emit a standard figure and return its path (e.g. `dataset_score_bulk_samples` returns `landscape_path` — a per-sample heatmap plus mean±SD bar). When a tool returns a figure path, reference that file instead of re-plotting.\n\n### Working with Data\nWhen working with data structures,\
19
  \ always inspect them **but limit output to avoid context overflow**:\n```python\n# For DataFrames - ALWAYS use head() for\
20
  \ large tables\nprint(f\"Shape: {df.shape}\")\nprint(f\"Columns: {list(df.columns)}\")\nprint(df.head(10)) # Show first\
21
  \ 10 rows max\n\n# For large DataFrames, also show basic info\nif len(df) > 20:\n print(f\"DataFrame has {len(df)} rows.\
@@ -187,7 +187,7 @@ The ONLY datasets registered in this system are listed below with everything\
187
  \ features, or survival_columns for an external survival package), say so and offer to compute\
188
  \ those inputs instead.\n4. Name the appropriate external tool or package for the requested\
189
  \ analysis.\n5. End in <solution> explaining this. Then STOP — do not proceed further in this\
190
- \ turn.\n\n## Explaining Your Approach (write this in every analytical solution)\n\nEvery <solution> that runs an analysis MUST open with a short **Approach** section written for a non-coding scientist, placed BEFORE the numeric results. In plain language, cover:\n\n1. **Plan** — what you set out to do and which dataset/subset you used (name the cohort and the sample groups, e.g. 'PACA-AU RNA-seq, squamous n=X vs progenitor n=Y').\n2. **Steps** — each computation step in order and why, e.g. 'loaded counts → filtered low-expression genes → differential expression → transcription-factor activity inference'.\n3. **Tools/code** — name the actual tool or method that ran at each step (e.g. decoupler_differential_expression with method=deseq2; decoupler_tf_enrichment_collectri). The user is NOT familiar with decoupleR internals, so for each decoupleR method add ONE plain-language sentence on what it actually does (see glossary below).\n4. **Preprocessing** — add ONE plain-language line on how that dataset was prepared (what the raw data was, the normalization/transform and units, and any feature-ID handling), taken from the dataset's **Preprocessing** field in ## Available Datasets. This grounds the reader in what the numbers are computed from; state only what that field says — do not invent provenance.\n\n**Hard rules:**\n- Describe ONLY the clean final path. NEVER narrate missteps, retries, NameErrors, or anything you self-corrected — the user wants the method, not the debugging.\n- Do not invent steps you did not run; the Approach must match the tools you actually called this turn.\n- Keep it tight: a few sentences or a short numbered list, not a transcript.\n\n### decoupleR method glossary (explain in plain language; never paste jargon verbatim)\n- **Differential expression (DESeq2 / limma / t-test):** finds genes whose expression differs between two groups; DESeq2 is for raw integer counts (RNA-seq), limma/t-test for already-normalized data.\n- **CollecTRI + ULM (decoupler_tf_enrichment_collectri):** CollecTRI is a curated map of which transcription factors (TFs) switch which genes on or off; ULM (univariate linear model) scores, for each TF, how consistently its target genes move — an INFERRED estimate of TF activity, not a direct measurement of TF protein or expression.\n- **PROGENy (decoupler_pathway_enrichment_progeny):** estimates the activity of ~14 cancer-relevant signalling pathways from the expression of genes those pathways are known to drive — inferred, not measured.\n- **Activity-scoring methods (ulm / mlm / zscore):** alternative statistics for turning a regulon/gene-set plus an expression profile into one activity score per TF or pathway; ulm is the default.\n- **Hallmark / gene-set enrichment:** tests whether a predefined gene set (e.g. MSigDB Hallmark) is collectively shifted up or down between groups.\n- **Meta-analysis (decoupler_meta_analyze):** combines per-cohort results into one pooled estimate and reports heterogeneity (Cochran's Q / I^2) — how consistent the effect is across cohorts.\n\nThis Approach section is IN ADDITION to the standing method-limitation boilerplate appended automatically after your <solution>; do not duplicate that boilerplate here.\n\n## Reporting Results: Provenance and Anti-Fabrication\n\nThese rules are MANDATORY for every <solution> that reports any number (activity scores, effect sizes, p-values, sample counts, gene-coverage percentages, counts of significant features, etc.).\n\n1. **Every number in <solution> MUST come from a tool/code <observation> in THIS turn.** Never write a result value from memory, from general knowledge, or by analogy to the examples in this prompt. If you did not observe a value from a tool call this turn, do not state it.\n\n2. **Re-read before you report — UNLESS the tool already handed you the table.** If the final analysis tool's observation is still in view and returned a structured result table — any `top_table` field, which every analysis-terminating tool now returns (decoupler_differential_expression, decoupler_tf_enrichment_collectri, decoupler_pathway_enrichment_progeny, decoupler_hallmark_enrichment, dataset_compare_activity_by_group). It is already joined with padj, already filtered to significant rows, and already sorted in both directions, so it needs no merge, no re-sort and no second look, quote it directly and emit <solution> next. Those numbers ARE an observation from this turn, so rule 1 is already satisfied and a re-read buys nothing. Otherwise — on a long run, earlier observations scroll out of your working context. Immediately before writing <solution>, run ONE final <execute> that ONLY re-reads the ALREADY-SAVED results file(s) and prints the exact rows you will cite — the final table must already have been saved by the step that computed it. Do NOT recompute, re-derive, or re-save the table in this step, and do NOT repeat this re-read more than once. Example:\n<execute>\nimport pandas as pd\ndf = pd.read_csv(\"<out_prefix>_results.csv\", index_col=0)\nprint(df.head(20).round(3))\n</execute>\nThen quote those printed values verbatim. Do NOT reconstruct numbers you 'remember' from earlier steps — read them back from disk. **This re-read happens AT MOST ONCE per turn, and the very next thing you emit after its output is `<solution>`.** If you have already re-read a results file this turn, you have the numbers — do not read it again, do not re-print it in a different shape, and do not 'verify' it a second time. A repeated re-read is the single most common way a run runs out of steps with the answer already in hand. When you author your OWN gene table in <execute> (anything not taken verbatim from a `top_table`), rank it by the test statistic (`stat` / Wald), NOT by log2FoldChange — low-count genes carry huge noisy fold-changes, so a log2FC-ranked table headlines pseudogene/Rik junk; keep log2FC as a column, never as the sort key. And when a legitimate re-read IS needed (a custom figure, more rows than top_table carries, a cross-contrast join), do it in ONE step: the tool result already tells you the CSV's exact schema (`de_results_columns` on the DE tool, `csv_orientation` on the enrichment tools), so never spend a separate step printing `df.columns` or `df.head()` before the real work.\n\n3. **Cite provenance in <solution>.** State the exact tools/methods you ran and the output artifact path(s) they produced (e.g. the activities/results CSV paths). This lets the user verify the results.\n\n4. **If a required tool call failed or you could not actually compute a result, say so plainly and do not present substitute numbers.** A truthful 'I could not run X' always beats a fabricated table.\n\n5. **Foreground any CRITICAL sanity warning — never bury it.** Several tools return a 'sanity_warnings' report (ADR-0002 Layer-2 result-aware checks: effect-size plausibility, tissue-identity contamination, network membership). If a tool you ran THIS turn returns sanity_warnings whose max_severity is 'critical', you MUST foreground that caution at the TOP of your <solution>, before the numbers — not in a footnote — and quote the warning's message. For a 'tissue_identity_contamination' warning specifically, state plainly that the contrast may reflect tissue COMPOSITION (normal-tissue admixture carried along in the biopsy) rather than tumour biology, name the flagged identity markers, and recommend controlling for tumour purity or comparing within a single tissue of origin before drawing biological conclusions. Never present contaminated top hits as validated tumour findings. A 'warn'/'info' sanity_warning need not headline the solution but MUST be stated in your run-specific caveats.\n\n## Non-Human (Mouse) Ad-Hoc Data\n\nAlmost everything in ## Available Datasets is human PDAC. Occasionally you are pointed at an **ad-hoc mouse h5ad** that is NOT in the registry (it carries `uns['organism'] = 'mouse'` and `uns['analysis_space'] = 'mouse'`). When the data is mouse, these rules override the human defaults:\n\n1. **Check `uns` before you analyse an unregistered h5ad.** If `uns['organism']` is `'mouse'`, say so in your Approach section and flag — as you would for any non-registered cohort — that this dataset is outside the curated PDAC registry.\n\n2. **Pass `organism='mouse'` to EVERY enrichment tool.** `decoupler_tf_enrichment_collectri`, `decoupler_pathway_enrichment_progeny` and `decoupler_hallmark_enrichment` all take `organism`, and it defaults to `'human'`. Leaving the default on mouse data silently matches MGI mouse symbols (e.g. `Myc`, `Kras`) against HGNC human symbols (`MYC`, `KRAS`), so the network overlap collapses and the scores are meaningless rather than merely noisy. The var index of a mouse h5ad is MGI symbols; there is no human mapping step and none is wanted.\n\n3. **Mouse-space results are never comparable to human-space results.** Do not pool, meta-analyse, or numerically compare a mouse result with any registered human cohort's result, and do not translate scores between them. Describe them side by side at most, and label every mouse table as mouse-space.\n\n4. **Counts vs TPM routing is the same rule as everywhere else.** A mouse counts matrix (integer `X`) is Path A — `decoupler_differential_expression(method='deseq2')`. A TPM matrix (often carried as `layers['tpm']`) is Path B — limma or t-test, never DESeq2. Do not run DESeq2 on the TPM layer just because it is in the same file.\n\n5. **Two design recipes for a paired-tumour/met, knockdown-vs-control mouse experiment** (obs columns `arm`, `clone`, `site`, `mouse_id`):\n - **Tumour vs paired metastasis:** `design_factor='site'`, `batch_column='mouse_id'`. This is the standard paired design (`~mouse_id + site`) — each mouse contributes both a tumour and a met, so mouse is a legitimate blocking factor. Restrict to mice that actually have both samples, or the unpaired mice contribute nothing but degrees of freedom.\n - **Knockdown vs control:** `design_factor='arm'`, and **`batch_column` MUST NOT be `mouse_id`** — each mouse belongs to exactly one arm, so mouse is nested inside (confounded with) arm and the design matrix is singular. Use `batch_column='clone'` only when clone actually crosses the arms; if every clone sits in one arm it is nested too and must be left out. Also pass `subset_query` to analyse one site at a time (e.g. tumours only) — mixing a mouse's tumour and met into one arm-level contrast is pseudo-replication. The DE tool has a nested-batch pre-flight guard that refuses a fully nested `batch_column`; if it fires, drop the covariate rather than trying to work around it.\n\n## Efficiency Rules\n\nThese rules reduce unnecessary tool calls. Follow them to avoid latency\
191
  \ on every request.\n\n1. **Skip `dataset_list_available` when the dataset is already identified.** If the user's message\
192
  \ names a specific dataset (e.g. \"Moffitt\", \"GSE71729\", \"gse71729_moffitt\") or any GSE accession number, do not call\
193
  \ `dataset_list_available`. The dataset is already known — proceed directly to `dataset_describe` or the analysis tool sequence.\
@@ -203,7 +203,7 @@ The ONLY datasets registered in this system are listed below with everything\
203
  \ ## Available Datasets. Skip straight to DE or the relevant analysis tool.\n\n5. **Always pass `subset_query` when the default contrast specifies one.** The ## Available\
204
  \ Datasets section lists each dataset's default contrasts, including any `subset_query`. When running DE,\
205
  \ read that value and pass it directly — never pre-filter the AnnData manually. Example: if the contrast\
206
- \ entry shows `subset_query=\"tumor_subtype != ''\"`, pass that exact string to `decoupler_differential_expression`.\n\n6. **Never import from `tools`, `src.tools`, `server`, or any `_mcp` module, and never inspect `FunctionTool`/`.fn`/`.run()` internals.** All functions listed below are already pre-loaded as plain callables in your namespace and are re-verified before every step. If a call to one of them raises `NameError` or `'FunctionTool' object is not callable`, do NOT start importing or introspecting — simply retry the exact same call with the parameters documented below. If it still fails after one retry, move on to a different approach rather than reverse-engineering the tool wrapper.\n\n7. **Never guess how to load a registered dataset — ask for its loading plan.** FIRST check whether the tool you need already takes a `dataset_id` (e.g. `dataset_score_bulk_samples`, `dataset_compare_activity_by_group`, `dataset_score_signature`, `dataset_get_integration_plan`). **Those tools load the dataset themselves — call them directly. Do NOT call `dataset_plan_analysis` and do NOT load the data first; that wastes two steps.** Only when you need a tool that takes an `adata_path` (e.g. `decoupler_differential_expression`, `decoupler_pseudobulk_aggregate`, the metadata tools) do you need the data on disk — and then your first call is `dataset_load(dataset_id=...)`. It EXECUTES the whole loading plan in one step (the correct loader for the source type, the precomputed collapsed URL, the clinical join, the curated-sample filter) and returns the analysis-ready `adata_path` plus the analysis path, data_level and default contrast — pass that path straight to the analysis tool. Do NOT hand-pick loaders, do NOT call `dataset_plan_analysis`/`dataset_describe` first just to load (use those only when the user asks how a dataset WOULD be analyzed), and do NOT re-load a dataset already loaded this turn. Do NOT open a dataset by pattern-matching its URL out of ## Available Datasets and picking a loader that looks right: `decoupler_load_geo_series_matrix` does not read h5ad, a bare `urllib`/`requests` download cannot authenticate to the private data repo, and `ad.read_h5ad` on a URL is not a local path. Each wrong guess costs a step, and `dataset_load` already makes the right calls — including using a dataset's **collapsed** file variant when one exists (loading the uncollapsed one instead forces two extra steps, `decoupler_annotate_probes_with_gpl` + `decoupler_collapse_probes_to_genes`).\n\n8. **Load each dataset exactly once per turn.** The loaded AnnData is written to `output_path` and cached. If you already loaded a dataset this turn, reuse that path — never re-download or re-load it to 'check' something.\n\n## Available Functions\n\nYou have access to the following functions. These functions are already available\
207
  \ in your Python environment and can be called directly:\n\n{% for func_name, schema in functions.items() %}\n**{{ schema.function.name\
208
  \ }}({% for param_name in schema.function.parameters.properties.keys() %}{{ param_name }}{{ \", \" if not loop.last }}{%\
209
  \ endfor %})**\n- Description: {{ schema.function.description }}\n- Parameters:\n {% for param_name, param_info in schema.function.parameters.properties.items()\
 
15
  \ generated\n - An **Approach** section — the analysis plan, each computation step, which tools/methods ran, and (for a non-coding scientist) what each decoupleR method actually does (see ## Explaining Your Approach). Describe ONLY the clean final path — never the missteps, retries, or transient errors you self-corrected\n - Any files created or modified\n - Next steps or\
16
  \ recommendations (if applicable)\n\n## Code Execution Best Practices\n\n### Printing Results\n**YOU MUST print outputs**\
17
  \ - the system cannot see unpublished results:\n```python\n# ✓ CORRECT\nresult = some_function(param1, param2)\nprint(result)\n\
18
+ \n# ✗ WRONG - output is invisible\nsome_function(param1, param2)\n```\n\n### Working with Data\nWhen working with data structures,\
19
  \ always inspect them **but limit output to avoid context overflow**:\n```python\n# For DataFrames - ALWAYS use head() for\
20
  \ large tables\nprint(f\"Shape: {df.shape}\")\nprint(f\"Columns: {list(df.columns)}\")\nprint(df.head(10)) # Show first\
21
  \ 10 rows max\n\n# For large DataFrames, also show basic info\nif len(df) > 20:\n print(f\"DataFrame has {len(df)} rows.\
 
187
  \ features, or survival_columns for an external survival package), say so and offer to compute\
188
  \ those inputs instead.\n4. Name the appropriate external tool or package for the requested\
189
  \ analysis.\n5. End in <solution> explaining this. Then STOP — do not proceed further in this\
190
+ \ turn.\n\n## Explaining Your Approach (write this in every analytical solution)\n\nEvery <solution> that runs an analysis MUST open with a short **Approach** section written for a non-coding scientist, placed BEFORE the numeric results. In plain language, cover:\n\n1. **Plan** — what you set out to do and which dataset/subset you used (name the cohort and the sample groups, e.g. 'PACA-AU RNA-seq, squamous n=X vs progenitor n=Y').\n2. **Steps** — each computation step in order and why, e.g. 'loaded counts → filtered low-expression genes → differential expression → transcription-factor activity inference'.\n3. **Tools/code** — name the actual tool or method that ran at each step (e.g. decoupler_differential_expression with method=deseq2; decoupler_tf_enrichment_collectri). The user is NOT familiar with decoupleR internals, so for each decoupleR method add ONE plain-language sentence on what it actually does (see glossary below).\n4. **Preprocessing** — add ONE plain-language line on how that dataset was prepared (what the raw data was, the normalization/transform and units, and any feature-ID handling), taken from the dataset's **Preprocessing** field in ## Available Datasets. This grounds the reader in what the numbers are computed from; state only what that field says — do not invent provenance.\n\n**Hard rules:**\n- Describe ONLY the clean final path. NEVER narrate missteps, retries, NameErrors, or anything you self-corrected — the user wants the method, not the debugging.\n- Do not invent steps you did not run; the Approach must match the tools you actually called this turn.\n- Keep it tight: a few sentences or a short numbered list, not a transcript.\n\n### decoupleR method glossary (explain in plain language; never paste jargon verbatim)\n- **Differential expression (DESeq2 / limma / t-test):** finds genes whose expression differs between two groups; DESeq2 is for raw integer counts (RNA-seq), limma/t-test for already-normalized data.\n- **CollecTRI + ULM (decoupler_tf_enrichment_collectri):** CollecTRI is a curated map of which transcription factors (TFs) switch which genes on or off; ULM (univariate linear model) scores, for each TF, how consistently its target genes move — an INFERRED estimate of TF activity, not a direct measurement of TF protein or expression.\n- **PROGENy (decoupler_pathway_enrichment_progeny):** estimates the activity of ~14 cancer-relevant signalling pathways from the expression of genes those pathways are known to drive — inferred, not measured.\n- **Activity-scoring methods (ulm / mlm / zscore):** alternative statistics for turning a regulon/gene-set plus an expression profile into one activity score per TF or pathway; ulm is the default.\n- **Hallmark / gene-set enrichment:** tests whether a predefined gene set (e.g. MSigDB Hallmark) is collectively shifted up or down between groups.\n- **Meta-analysis (decoupler_meta_analyze):** combines per-cohort results into one pooled estimate and reports heterogeneity (Cochran's Q / I^2) — how consistent the effect is across cohorts.\n\nThis Approach section is IN ADDITION to the standing method-limitation boilerplate appended automatically after your <solution>; do not duplicate that boilerplate here.\n\n## Reporting Results: Provenance and Anti-Fabrication\n\nThese rules are MANDATORY for every <solution> that reports any number (activity scores, effect sizes, p-values, sample counts, gene-coverage percentages, counts of significant features, etc.).\n\n1. **Every number in <solution> MUST come from a tool/code <observation> in THIS turn.** Never write a result value from memory, from general knowledge, or by analogy to the examples in this prompt. If you did not observe a value from a tool call this turn, do not state it.\n\n2. **Re-read before you report.** On a long run, earlier observations scroll out of your working context. Immediately before writing <solution>, run ONE final <execute> that ONLY re-reads the ALREADY-SAVED results file(s) and prints the exact rows you will cite — the final table must already have been saved by the step that computed it. Do NOT recompute, re-derive, or re-save the table in this step, and do NOT repeat this re-read more than once. Example:\n<execute>\nimport pandas as pd\ndf = pd.read_csv(\"<out_prefix>_results.csv\", index_col=0)\nprint(df.head(20).round(3))\n</execute>\nThen quote those printed values verbatim. Do NOT reconstruct numbers you 'remember' from earlier steps — read them back from disk.\n\n3. **Cite provenance in <solution>.** State the exact tools/methods you ran and the output artifact path(s) they produced (e.g. the activities/results CSV paths). This lets the user verify the results.\n\n4. **If a required tool call failed or you could not actually compute a result, say so plainly and do not present substitute numbers.** A truthful 'I could not run X' always beats a fabricated table.\n\n5. **Foreground any CRITICAL sanity warning — never bury it.** Several tools return a 'sanity_warnings' report (ADR-0002 Layer-2 result-aware checks: effect-size plausibility, tissue-identity contamination, network membership). If a tool you ran THIS turn returns sanity_warnings whose max_severity is 'critical', you MUST foreground that caution at the TOP of your <solution>, before the numbers — not in a footnote — and quote the warning's message. For a 'tissue_identity_contamination' warning specifically, state plainly that the contrast may reflect tissue COMPOSITION (normal-tissue admixture carried along in the biopsy) rather than tumour biology, name the flagged identity markers, and recommend controlling for tumour purity or comparing within a single tissue of origin before drawing biological conclusions. Never present contaminated top hits as validated tumour findings. A 'warn'/'info' sanity_warning need not headline the solution but MUST be stated in your run-specific caveats.\n\n## Efficiency Rules\n\nThese rules reduce unnecessary tool calls. Follow them to avoid latency\
191
  \ on every request.\n\n1. **Skip `dataset_list_available` when the dataset is already identified.** If the user's message\
192
  \ names a specific dataset (e.g. \"Moffitt\", \"GSE71729\", \"gse71729_moffitt\") or any GSE accession number, do not call\
193
  \ `dataset_list_available`. The dataset is already known — proceed directly to `dataset_describe` or the analysis tool sequence.\
 
203
  \ ## Available Datasets. Skip straight to DE or the relevant analysis tool.\n\n5. **Always pass `subset_query` when the default contrast specifies one.** The ## Available\
204
  \ Datasets section lists each dataset's default contrasts, including any `subset_query`. When running DE,\
205
  \ read that value and pass it directly — never pre-filter the AnnData manually. Example: if the contrast\
206
+ \ entry shows `subset_query=\"tumor_subtype != ''\"`, pass that exact string to `decoupler_differential_expression`.\n\n6. **Never import from `tools`, `src.tools`, `server`, or any `_mcp` module, and never inspect `FunctionTool`/`.fn`/`.run()` internals.** All functions listed below are already pre-loaded as plain callables in your namespace and are re-verified before every step. If a call to one of them raises `NameError` or `'FunctionTool' object is not callable`, do NOT start importing or introspecting — simply retry the exact same call with the parameters documented below. If it still fails after one retry, move on to a different approach rather than reverse-engineering the tool wrapper.\n\n## Available Functions\n\nYou have access to the following functions. These functions are already available\
207
  \ in your Python environment and can be called directly:\n\n{% for func_name, schema in functions.items() %}\n**{{ schema.function.name\
208
  \ }}({% for param_name in schema.function.parameters.properties.keys() %}{{ param_name }}{{ \", \" if not loop.last }}{%\
209
  \ endfor %})**\n- Description: {{ schema.function.description }}\n- Parameters:\n {% for param_name, param_info in schema.function.parameters.properties.items()\
pytest.ini DELETED
@@ -1,11 +0,0 @@
1
- [pytest]
2
- # Belt-and-braces. The actual fix was renaming the offender:
3
- # scripts/test_limma_runtime.py -> scripts/check_limma_runtime.py. It is a
4
- # hand-run rpy2/limma diagnostic that calls sys.exit(1) at import when rpy2 is
5
- # absent, and pytest collected it on the test_* filename alone — which aborted
6
- # the whole run with an INTERNALERROR before a single test executed.
7
- #
8
- # testpaths keeps that from recurring if another test_*.py is ever dropped into
9
- # scripts/ or elsewhere outside tests/. It applies only when no path argument is
10
- # given, so `pytest` and `pytest tests/` agree, and an explicit path still works.
11
- testpaths = tests
 
 
 
 
 
 
 
 
 
 
 
 
requirements.in CHANGED
@@ -25,7 +25,7 @@
25
  # so git+https://huggingface.co/... fails the Space build. To release new
26
  # manifests: bump version in biodata-registry/pyproject.toml, run its
27
  # scripts/release.sh, then update this URL + commit.
28
- biodata-registry @ https://huggingface.co/anne-voigt/biodata-registry/resolve/7cc464295182f17bc95b8b5bee78a588a4e66440/biodata_registry-0.1.17-py3-none-any.whl
29
 
30
  # --- GEO platform (GPL) SOFT parsing (scripts/assemble_*.py precompute) ---
31
  GEOparse==2.0.4
@@ -103,22 +103,3 @@ plotnine
103
  # src/tools/rna/analysis.py). Not imported by first-party code, so it must be
104
  # pinned here explicitly or the plot is skipped at runtime.
105
  igraph
106
-
107
- # --- Security floors (ADR-0014 pip-audit remediation) ---
108
- # These are all transitive deps (not first-party imports); we constrain the
109
- # floor only to pull them past known CVEs. None are HF sdk_version-locked (only
110
- # gradio + mcp are), so they float freely to the fixed release.
111
- # pillow — CVE-2026-25990, CVE-2026-40192, CVE-2026-42309/42310/42311,
112
- # PYSEC-2026-165 (fixed in 12.2.0). via fpdf2/gradio/matplotlib.
113
- # langsmith — GHSA-f4xh-w4cj-qxq8 (fixed in 0.8.18). via langchain-core.
114
- # pydantic-settings — GHSA-4xgf-cpjx-pc3j (fixed in 2.14.2). via mcp.
115
- # cryptography — CVE-2026-69247 (fixed in 50.0.0). via authlib (which backs
116
- # the ADR-0012 OAuth sign-in) and cffi→rpy2-rinterface. This
117
- # one is NOT a free float: authlib is on the auth path, so the
118
- # bump was verified on its own before shipping — see memory.md
119
- # 2026-08-04. authlib 1.7.2 declares `cryptography>=3.2` and
120
- # imports only the stable hazmat primitives, so 50.x is in range.
121
- pillow>=12.2.0
122
- langsmith>=0.8.18
123
- pydantic-settings>=2.14.2
124
- cryptography>=50.0.0
 
25
  # so git+https://huggingface.co/... fails the Space build. To release new
26
  # manifests: bump version in biodata-registry/pyproject.toml, run its
27
  # scripts/release.sh, then update this URL + commit.
28
+ biodata-registry @ https://huggingface.co/anne-voigt/biodata-registry/resolve/0e29c5efa60a66b417f79e067dc2b0d927fcdc83/biodata_registry-0.1.8-py3-none-any.whl
29
 
30
  # --- GEO platform (GPL) SOFT parsing (scripts/assemble_*.py precompute) ---
31
  GEOparse==2.0.4
 
103
  # src/tools/rna/analysis.py). Not imported by first-party code, so it must be
104
  # pinned here explicitly or the plot is skipped at runtime.
105
  igraph
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
requirements.txt CHANGED
@@ -23,7 +23,6 @@ anyio==4.14.0
23
  # anthropic
24
  # gradio
25
  # httpx
26
- # langsmith
27
  # mcp
28
  # openai
29
  # py-key-value-aio
@@ -45,7 +44,7 @@ backports-tarfile==1.2.0
45
  # via jaraco-context
46
  beartype==0.22.9
47
  # via py-key-value-aio
48
- biodata-registry @ https://huggingface.co/anne-voigt/biodata-registry/resolve/7cc464295182f17bc95b8b5bee78a588a4e66440/biodata_registry-0.1.17-py3-none-any.whl
49
  # via -r requirements.in
50
  brotli==1.2.0
51
  # via gradio
@@ -71,9 +70,8 @@ click==8.4.1
71
  # uvicorn
72
  contourpy==1.3.3
73
  # via matplotlib
74
- cryptography==50.0.0
75
  # via
76
- # -r requirements.in
77
  # authlib
78
  # joserfc
79
  # pyjwt
@@ -89,7 +87,6 @@ defusedxml==0.7.1
89
  distro==1.9.0
90
  # via
91
  # anthropic
92
- # langsmith
93
  # openai
94
  dnspython==2.8.0
95
  # via email-validator
@@ -258,10 +255,8 @@ langgraph-prebuilt==1.1.0
258
  # via langgraph
259
  langgraph-sdk==0.4.2
260
  # via langgraph
261
- langsmith==0.9.7
262
- # via
263
- # -r requirements.in
264
- # langchain-core
265
  legacy-api-wrap==1.5
266
  # via
267
  # anndata
@@ -292,7 +287,7 @@ matplotlib==3.11.0
292
  # pydeseq2
293
  # scanpy
294
  # seaborn
295
- mcp==1.28.1
296
  # via
297
  # -r requirements.in
298
  # fastmcp
@@ -399,9 +394,8 @@ patsy==1.0.2
399
  # via
400
  # scanpy
401
  # statsmodels
402
- pillow==12.3.0
403
  # via
404
- # -r requirements.in
405
  # fpdf2
406
  # gradio
407
  # matplotlib
@@ -434,10 +428,8 @@ pydantic==2.11.10
434
  # pydantic-settings
435
  pydantic-core==2.33.2
436
  # via pydantic
437
- pydantic-settings==2.14.2
438
- # via
439
- # -r requirements.in
440
- # mcp
441
  pydeseq2==0.5.4
442
  # via -r requirements.in
443
  pydub==0.25.1
@@ -573,7 +565,6 @@ six==1.17.0
573
  sniffio==1.3.1
574
  # via
575
  # anthropic
576
- # langsmith
577
  # openai
578
  sse-starlette==3.4.4
579
  # via mcp
@@ -626,7 +617,6 @@ typing-extensions==4.15.0
626
  # langchain-core
627
  # langchain-mcp-adapters
628
  # langchain-protocol
629
- # langsmith
630
  # mcp
631
  # numcodecs
632
  # openai
 
23
  # anthropic
24
  # gradio
25
  # httpx
 
26
  # mcp
27
  # openai
28
  # py-key-value-aio
 
44
  # via jaraco-context
45
  beartype==0.22.9
46
  # via py-key-value-aio
47
+ biodata-registry @ https://huggingface.co/anne-voigt/biodata-registry/resolve/0e29c5efa60a66b417f79e067dc2b0d927fcdc83/biodata_registry-0.1.8-py3-none-any.whl
48
  # via -r requirements.in
49
  brotli==1.2.0
50
  # via gradio
 
70
  # uvicorn
71
  contourpy==1.3.3
72
  # via matplotlib
73
+ cryptography==49.0.0
74
  # via
 
75
  # authlib
76
  # joserfc
77
  # pyjwt
 
87
  distro==1.9.0
88
  # via
89
  # anthropic
 
90
  # openai
91
  dnspython==2.8.0
92
  # via email-validator
 
255
  # via langgraph
256
  langgraph-sdk==0.4.2
257
  # via langgraph
258
+ langsmith==0.8.16
259
+ # via langchain-core
 
 
260
  legacy-api-wrap==1.5
261
  # via
262
  # anndata
 
287
  # pydeseq2
288
  # scanpy
289
  # seaborn
290
+ mcp==1.28.0
291
  # via
292
  # -r requirements.in
293
  # fastmcp
 
394
  # via
395
  # scanpy
396
  # statsmodels
397
+ pillow==11.3.0
398
  # via
 
399
  # fpdf2
400
  # gradio
401
  # matplotlib
 
428
  # pydantic-settings
429
  pydantic-core==2.33.2
430
  # via pydantic
431
+ pydantic-settings==2.14.1
432
+ # via mcp
 
 
433
  pydeseq2==0.5.4
434
  # via -r requirements.in
435
  pydub==0.25.1
 
565
  sniffio==1.3.1
566
  # via
567
  # anthropic
 
568
  # openai
569
  sse-starlette==3.4.4
570
  # via mcp
 
617
  # langchain-core
618
  # langchain-mcp-adapters
619
  # langchain-protocol
 
620
  # mcp
621
  # numcodecs
622
  # openai
resources/mouse_ensembl_symbol_map.tsv.gz DELETED
@@ -1,3 +0,0 @@
1
- version https://git-lfs.github.com/spec/v1
2
- oid sha256:1bfa6ee6b405a13f19e5d334211663fc74b7235528e0c440941741cb7d1c8dc9
3
- size 470035
 
 
 
 
ruff.toml DELETED
@@ -1,31 +0,0 @@
1
- # Ruff config — shared lint/format baseline across the PDAC-system repos.
2
- # Conservative rule set aimed at readability + standards, not pedantry:
3
- # E/W pycodestyle, F pyflakes (dead code, undefined names, unused imports),
4
- # I import sorting, UP pyupgrade, B bugbear (likely bugs).
5
- line-length = 100
6
- target-version = "py311"
7
-
8
- extend-exclude = [
9
- ".claude", ".git", "security/.audit-venv", "build", "dist",
10
- "__pycache__", ".venv", "node_modules", "data", "logs", "out",
11
- ]
12
-
13
- [lint]
14
- select = ["E", "W", "F", "I", "UP", "B"]
15
- # E501 (line too long) is reported by the formatter, not worth failing lint on
16
- # during active dev; B008 (function call in default arg) is a FastAPI/Gradio idiom.
17
- ignore = ["E501", "B008"]
18
-
19
- [format]
20
- quote-style = "double"
21
-
22
- [lint.per-file-ignores]
23
- # E402: these files deliberately run setup before importing — a
24
- # sys.path.insert() so first-party modules resolve, or an env/R bootstrap
25
- # in the server/app entrypoints. The imports cannot precede that setup, so
26
- # E402 is a false positive here (kept active everywhere else).
27
- "app.py" = ["E402"]
28
- "server.py" = ["E402"]
29
- "scripts/*" = ["E402"]
30
- "tests/*" = ["E402"]
31
- "src/tools/*" = ["E402"]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
scripts/_gse205154_sears_common.py CHANGED
@@ -19,7 +19,6 @@ to the 289 GEO samples by name.
19
 
20
  Usage (per variant): .venv/bin/python scripts/assemble_gse205154_sears*.py [--dry-run]
21
  """
22
-
23
  import gzip
24
  import io
25
  import sys
@@ -150,7 +149,9 @@ def build_gse205154_anndata(matrix_url: str, *, data_level: str) -> ad.AnnData:
150
  if "hgnc_symbol" in var.columns:
151
  var["SYMBOL"] = var["hgnc_symbol"].astype(str)
152
  if TMM_FILTER_COLUMN in var.columns:
153
- var[TMM_FILTER_COLUMN] = var[TMM_FILTER_COLUMN].astype(str).str.upper().eq("TRUE")
 
 
154
 
155
  # expression — genes x samples, transpose to samples x genes (index = ST-id).
156
  expr = df[sample_cols].copy()
@@ -159,11 +160,12 @@ def build_gse205154_anndata(matrix_url: str, *, data_level: str) -> ad.AnnData:
159
 
160
  # obs — join GEO metadata to the TSV sample columns by sample_title (ST-id).
161
  geo_obs = load_geo_obs()
162
- title_to_gsm = dict(zip(geo_obs["sample_title"], geo_obs.index, strict=True))
163
  missing = [st for st in x_df.index if st not in title_to_gsm]
164
  if missing:
165
  raise RuntimeError(
166
- f"{len(missing)} TSV sample columns have no GEO sample_title match, e.g. {missing[:5]}"
 
167
  )
168
  obs = geo_obs.set_index("sample_title").loc[x_df.index].copy()
169
  obs["sample_title"] = x_df.index.values
@@ -182,16 +184,11 @@ def build_gse205154_anndata(matrix_url: str, *, data_level: str) -> ad.AnnData:
182
 
183
  adata = ad.AnnData(X=X, obs=obs, var=var)
184
  print(f" AnnData: {adata.shape[0]} samples x {adata.shape[1]} genes")
185
- print(
186
- " tumor_type:\n"
187
- + adata.obs.get("tumor_type", pd.Series(dtype=str)).value_counts().to_string()
188
- )
189
  return adata
190
 
191
 
192
- def assemble(
193
- dataset_id: str, matrix_url: str, data_level: str, script_name: str, dry_run: bool
194
- ) -> None:
195
  """End-to-end: build, stamp provenance, write + (optionally) upload to HF."""
196
  print(f"=== Assembling {dataset_id} (data_level={data_level}) ===")
197
  adata = build_gse205154_anndata(matrix_url, data_level=data_level)
 
19
 
20
  Usage (per variant): .venv/bin/python scripts/assemble_gse205154_sears*.py [--dry-run]
21
  """
 
22
  import gzip
23
  import io
24
  import sys
 
149
  if "hgnc_symbol" in var.columns:
150
  var["SYMBOL"] = var["hgnc_symbol"].astype(str)
151
  if TMM_FILTER_COLUMN in var.columns:
152
+ var[TMM_FILTER_COLUMN] = (
153
+ var[TMM_FILTER_COLUMN].astype(str).str.upper().eq("TRUE")
154
+ )
155
 
156
  # expression — genes x samples, transpose to samples x genes (index = ST-id).
157
  expr = df[sample_cols].copy()
 
160
 
161
  # obs — join GEO metadata to the TSV sample columns by sample_title (ST-id).
162
  geo_obs = load_geo_obs()
163
+ title_to_gsm = dict(zip(geo_obs["sample_title"], geo_obs.index))
164
  missing = [st for st in x_df.index if st not in title_to_gsm]
165
  if missing:
166
  raise RuntimeError(
167
+ f"{len(missing)} TSV sample columns have no GEO sample_title match, "
168
+ f"e.g. {missing[:5]}"
169
  )
170
  obs = geo_obs.set_index("sample_title").loc[x_df.index].copy()
171
  obs["sample_title"] = x_df.index.values
 
184
 
185
  adata = ad.AnnData(X=X, obs=obs, var=var)
186
  print(f" AnnData: {adata.shape[0]} samples x {adata.shape[1]} genes")
187
+ print(" tumor_type:\n" + adata.obs.get("tumor_type", pd.Series(dtype=str)).value_counts().to_string())
 
 
 
188
  return adata
189
 
190
 
191
+ def assemble(dataset_id: str, matrix_url: str, data_level: str, script_name: str, dry_run: bool) -> None:
 
 
192
  """End-to-end: build, stamp provenance, write + (optionally) upload to HF."""
193
  print(f"=== Assembling {dataset_id} (data_level={data_level}) ===")
194
  adata = build_gse205154_anndata(matrix_url, data_level=data_level)
scripts/_investigate_gpl6244.py CHANGED
@@ -7,7 +7,6 @@ Checks whether the GSE28735-embedded GPL6244 platform table has a usable
7
 
8
  Usage: .venv/bin/python scripts/_investigate_gpl6244.py
9
  """
10
-
11
  import sys
12
  from pathlib import Path
13
 
@@ -16,6 +15,7 @@ sys.path.insert(0, str(ROOT))
16
  sys.path.insert(0, str(ROOT / "scripts"))
17
 
18
  import GEOparse
 
19
  from _precompute_common import _ensure_gse_family_soft
20
 
21
  GSE_ACCESSION = "GSE28735"
 
7
 
8
  Usage: .venv/bin/python scripts/_investigate_gpl6244.py
9
  """
 
10
  import sys
11
  from pathlib import Path
12
 
 
15
  sys.path.insert(0, str(ROOT / "scripts"))
16
 
17
  import GEOparse
18
+
19
  from _precompute_common import _ensure_gse_family_soft
20
 
21
  GSE_ACCESSION = "GSE28735"
scripts/_precompute_common.py CHANGED
@@ -8,7 +8,7 @@ shape/obs/var as a live load -- just without the per-query reasoning steps.
8
  """
9
 
10
  import sys
11
- from datetime import UTC, datetime
12
  from pathlib import Path
13
 
14
  import anndata as ad
@@ -60,7 +60,7 @@ def load_series_matrix_to_anndata(url_or_path: str) -> ad.AnnData:
60
 
61
  def _gse_range_subdir(gse_accession: str) -> str:
62
  """e.g. 'GSE62165' -> 'GSE62nnn', 'GSE71989' -> 'GSE71nnn'."""
63
- digits = gse_accession[len("GSE") :]
64
  return f"GSE{digits[:-3]}nnn"
65
 
66
 
@@ -135,29 +135,21 @@ def annotate_with_gpl(adata, gse_accession, gpl_accession, gene_symbol_column="G
135
  source_used = f"GEOparse:{gse_accession}:{gpl_accession}"
136
  gpl = gse.gpls.get(gpl_accession)
137
  if gpl is None or gpl.table is None or gpl.table.empty:
138
- return (
139
- adata,
140
- None,
141
- {
142
- "source_used": source_used,
143
- "gpl_columns_available": [],
144
- "requested_column_found": False,
145
- "error": f"{gpl_accession} table not found/empty in {gse_accession} "
146
- f"(available GPLs: {list(gse.gpls.keys())})",
147
- },
148
- )
149
 
150
  gpl_columns_available = list(gpl.table.columns)
151
  if gene_symbol_column not in gpl_columns_available:
152
- return (
153
- adata,
154
- None,
155
- {
156
- "source_used": source_used,
157
- "gpl_columns_available": gpl_columns_available,
158
- "requested_column_found": False,
159
- },
160
- )
161
 
162
  probe_col = "ID" if "ID" in gpl.table.columns else gpl.table.columns[0]
163
  gpl_map = gpl.table.set_index(probe_col)[gene_symbol_column]
@@ -169,24 +161,18 @@ def annotate_with_gpl(adata, gse_accession, gpl_accession, gene_symbol_column="G
169
 
170
  n_total = len(probe_ids)
171
  n_annotated = int((mapped != "").sum())
172
- return (
173
- adata,
174
- gene_symbol_column,
175
- {
176
- "source_used": source_used,
177
- "gpl_columns_available": gpl_columns_available,
178
- "n_total": n_total,
179
- "n_annotated": n_annotated,
180
- "n_unannotated": n_total - n_annotated,
181
- "coverage_pct": round(100 * n_annotated / n_total, 1) if n_total else 0.0,
182
- "requested_column_found": True,
183
- },
184
- )
185
-
186
-
187
- def annotate_with_gpl_gene_assignment(
188
- adata, gse_accession, gpl_accession, assignment_column="gene_assignment"
189
- ):
190
  """
191
  Annotate adata.var with a "gene_symbol" column parsed from a GPL
192
  'gene_assignment'-style column (Affymetrix Gene/Exon ST arrays, e.g.
@@ -212,29 +198,21 @@ def annotate_with_gpl_gene_assignment(
212
  source_used = f"GEOparse:{gse_accession}:{gpl_accession}:{assignment_column}"
213
  gpl = gse.gpls.get(gpl_accession)
214
  if gpl is None or gpl.table is None or gpl.table.empty:
215
- return (
216
- adata,
217
- None,
218
- {
219
- "source_used": source_used,
220
- "gpl_columns_available": [],
221
- "requested_column_found": False,
222
- "error": f"{gpl_accession} table not found/empty in {gse_accession} "
223
- f"(available GPLs: {list(gse.gpls.keys())})",
224
- },
225
- )
226
 
227
  gpl_columns_available = list(gpl.table.columns)
228
  if assignment_column not in gpl_columns_available:
229
- return (
230
- adata,
231
- None,
232
- {
233
- "source_used": source_used,
234
- "gpl_columns_available": gpl_columns_available,
235
- "requested_column_found": False,
236
- },
237
- )
238
 
239
  probe_col = "ID" if "ID" in gpl.table.columns else gpl.table.columns[0]
240
 
@@ -259,27 +237,21 @@ def annotate_with_gpl_gene_assignment(
259
 
260
  n_total = len(probe_ids)
261
  n_annotated = int((mapped != "").sum())
262
- return (
263
- adata,
264
- "gene_symbol",
265
- {
266
- "source_used": source_used,
267
- "gpl_columns_available": gpl_columns_available,
268
- "n_total": n_total,
269
- "n_annotated": n_annotated,
270
- "n_unannotated": n_total - n_annotated,
271
- "coverage_pct": round(100 * n_annotated / n_total, 1) if n_total else 0.0,
272
- "requested_column_found": True,
273
- },
274
- )
275
-
276
-
277
- def stamp_provenance(
278
- adata, *, source_url, dataset_id, biodata_registry_commit, script_name, extra=None
279
- ):
280
  """Write plain-string .uns provenance keys (h5ad-safe)."""
281
  adata.uns["precompute_source_url"] = source_url
282
- adata.uns["precompute_built_at"] = datetime.now(UTC).isoformat()
283
  adata.uns["precompute_biodata_registry_commit"] = biodata_registry_commit
284
  adata.uns["precompute_dataset_id"] = dataset_id
285
  adata.uns["precompute_script"] = script_name
 
8
  """
9
 
10
  import sys
11
+ from datetime import datetime, timezone
12
  from pathlib import Path
13
 
14
  import anndata as ad
 
60
 
61
  def _gse_range_subdir(gse_accession: str) -> str:
62
  """e.g. 'GSE62165' -> 'GSE62nnn', 'GSE71989' -> 'GSE71nnn'."""
63
+ digits = gse_accession[len("GSE"):]
64
  return f"GSE{digits[:-3]}nnn"
65
 
66
 
 
135
  source_used = f"GEOparse:{gse_accession}:{gpl_accession}"
136
  gpl = gse.gpls.get(gpl_accession)
137
  if gpl is None or gpl.table is None or gpl.table.empty:
138
+ return adata, None, {
139
+ "source_used": source_used,
140
+ "gpl_columns_available": [],
141
+ "requested_column_found": False,
142
+ "error": f"{gpl_accession} table not found/empty in {gse_accession} "
143
+ f"(available GPLs: {list(gse.gpls.keys())})",
144
+ }
 
 
 
 
145
 
146
  gpl_columns_available = list(gpl.table.columns)
147
  if gene_symbol_column not in gpl_columns_available:
148
+ return adata, None, {
149
+ "source_used": source_used,
150
+ "gpl_columns_available": gpl_columns_available,
151
+ "requested_column_found": False,
152
+ }
 
 
 
 
153
 
154
  probe_col = "ID" if "ID" in gpl.table.columns else gpl.table.columns[0]
155
  gpl_map = gpl.table.set_index(probe_col)[gene_symbol_column]
 
161
 
162
  n_total = len(probe_ids)
163
  n_annotated = int((mapped != "").sum())
164
+ return adata, gene_symbol_column, {
165
+ "source_used": source_used,
166
+ "gpl_columns_available": gpl_columns_available,
167
+ "n_total": n_total,
168
+ "n_annotated": n_annotated,
169
+ "n_unannotated": n_total - n_annotated,
170
+ "coverage_pct": round(100 * n_annotated / n_total, 1) if n_total else 0.0,
171
+ "requested_column_found": True,
172
+ }
173
+
174
+
175
+ def annotate_with_gpl_gene_assignment(adata, gse_accession, gpl_accession, assignment_column="gene_assignment"):
 
 
 
 
 
 
176
  """
177
  Annotate adata.var with a "gene_symbol" column parsed from a GPL
178
  'gene_assignment'-style column (Affymetrix Gene/Exon ST arrays, e.g.
 
198
  source_used = f"GEOparse:{gse_accession}:{gpl_accession}:{assignment_column}"
199
  gpl = gse.gpls.get(gpl_accession)
200
  if gpl is None or gpl.table is None or gpl.table.empty:
201
+ return adata, None, {
202
+ "source_used": source_used,
203
+ "gpl_columns_available": [],
204
+ "requested_column_found": False,
205
+ "error": f"{gpl_accession} table not found/empty in {gse_accession} "
206
+ f"(available GPLs: {list(gse.gpls.keys())})",
207
+ }
 
 
 
 
208
 
209
  gpl_columns_available = list(gpl.table.columns)
210
  if assignment_column not in gpl_columns_available:
211
+ return adata, None, {
212
+ "source_used": source_used,
213
+ "gpl_columns_available": gpl_columns_available,
214
+ "requested_column_found": False,
215
+ }
 
 
 
 
216
 
217
  probe_col = "ID" if "ID" in gpl.table.columns else gpl.table.columns[0]
218
 
 
237
 
238
  n_total = len(probe_ids)
239
  n_annotated = int((mapped != "").sum())
240
+ return adata, "gene_symbol", {
241
+ "source_used": source_used,
242
+ "gpl_columns_available": gpl_columns_available,
243
+ "n_total": n_total,
244
+ "n_annotated": n_annotated,
245
+ "n_unannotated": n_total - n_annotated,
246
+ "coverage_pct": round(100 * n_annotated / n_total, 1) if n_total else 0.0,
247
+ "requested_column_found": True,
248
+ }
249
+
250
+
251
+ def stamp_provenance(adata, *, source_url, dataset_id, biodata_registry_commit, script_name, extra=None):
 
 
 
 
 
 
252
  """Write plain-string .uns provenance keys (h5ad-safe)."""
253
  adata.uns["precompute_source_url"] = source_url
254
+ adata.uns["precompute_built_at"] = datetime.now(timezone.utc).isoformat()
255
  adata.uns["precompute_biodata_registry_commit"] = biodata_registry_commit
256
  adata.uns["precompute_dataset_id"] = dataset_id
257
  adata.uns["precompute_script"] = script_name
scripts/_verify_precompute.py CHANGED
@@ -10,7 +10,6 @@ precompute-cache verification checklist:
10
 
11
  Usage: .venv/bin/python scripts/_verify_precompute.py <dataset_id>
12
  """
13
-
14
  import sys
15
  from pathlib import Path
16
 
@@ -57,15 +56,7 @@ def main():
57
  result = decoupler_load_url_counts(
58
  url_or_path=url, feature_id_type=feature_id_type, out_prefix=f"verify_{dataset_id}"
59
  )
60
- for k in (
61
- "message",
62
- "n_obs",
63
- "n_vars",
64
- "shape",
65
- "obs_columns",
66
- "var_index_sample",
67
- "output_path",
68
- ):
69
  print(f" {k}: {result.get(k)}")
70
 
71
  # 4. dataset_validate_manifest_against_data
 
10
 
11
  Usage: .venv/bin/python scripts/_verify_precompute.py <dataset_id>
12
  """
 
13
  import sys
14
  from pathlib import Path
15
 
 
56
  result = decoupler_load_url_counts(
57
  url_or_path=url, feature_id_type=feature_id_type, out_prefix=f"verify_{dataset_id}"
58
  )
59
+ for k in ("message", "n_obs", "n_vars", "shape", "obs_columns", "var_index_sample", "output_path"):
 
 
 
 
 
 
 
 
60
  print(f" {k}: {result.get(k)}")
61
 
62
  # 4. dataset_validate_manifest_against_data
scripts/_vst_transform.R DELETED
@@ -1,32 +0,0 @@
1
- #!/usr/bin/env Rscript
2
- # DESeq2 variance-stabilizing transform for the GSE205154 (Sears) VST sibling.
3
- #
4
- # Reads a gzipped integer counts matrix (genes x samples; first column = gene id,
5
- # header row = sample ids), applies DESeq2::vst (blind, no design — this is a QC/
6
- # scoring transform, not a DE fit), and writes the gzipped VST matrix (same
7
- # orientation). Called as an Rscript subprocess from
8
- # assemble_gse205154_sears_vst.py (the ADR-0002 Rscript-subprocess pattern; no
9
- # rpy2). VST output is log2-like and homoscedastic → biodata-registry maps it to
10
- # data_level=log_expression (already-log; Path B). See ADR-0015.
11
- #
12
- # Usage: Rscript _vst_transform.R <counts_in.tsv.gz> <vst_out.tsv.gz>
13
- suppressMessages(library(DESeq2))
14
-
15
- args <- commandArgs(trailingOnly = TRUE)
16
- if (length(args) != 2L) stop("usage: _vst_transform.R <counts_in.tsv.gz> <vst_out.tsv.gz>")
17
- inp <- args[[1]]
18
- outp <- args[[2]]
19
-
20
- cts <- as.matrix(read.delim(gzfile(inp), row.names = 1, check.names = FALSE))
21
- storage.mode(cts) <- "integer"
22
- cat(sprintf(" R: counts matrix %d genes x %d samples\n", nrow(cts), ncol(cts)))
23
-
24
- # vst() is DESeq2's fast VST (fits the dispersion trend on nsub genes, then
25
- # applies the closed-form transform to all genes). blind=TRUE => design-agnostic.
26
- vsd <- vst(cts, blind = TRUE)
27
- cat(sprintf(" R: vst done; range %.3f .. %.3f\n", min(vsd), max(vsd)))
28
-
29
- con <- gzfile(outp, "wt")
30
- write.table(vsd, con, sep = "\t", quote = FALSE, col.names = NA)
31
- close(con)
32
- cat(sprintf(" R: wrote %s\n", outp))
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
scripts/analyze_post_analysis_reads.py DELETED
@@ -1,251 +0,0 @@
1
- #!/usr/bin/env python3
2
- """Retrospective before/after analysis of post-analysis CSV re-reads.
3
-
4
- Answers the question `1ec068b` shipped without answering: **did returning
5
- `top_table` from `dataset_compare_activity_by_group` actually stop the agent
6
- re-opening `comparison_path` before writing its solution?**
7
-
8
- The intent was a retrospective control arm: historical run records carry their
9
- own `messages`, so `n_post_analysis_reads` could be recomputed with no re-running
10
- and no reverting (both Spaces run the new code, so there is no old-code
11
- deployment left to A/B against).
12
-
13
- **That control arm does not exist in the data.** As of 2026-08-06 all 20 run
14
- traces in `anne-voigt/decoupleRpy_results` persist `"messages": []`, because the
15
- Gradio path drives `graph.stream()` directly and never fills
16
- `WorkflowEngine.message_history` — the field `get_trace()` reads. Every
17
- production trace was therefore written empty. `src/agent.py` now falls back to
18
- the final graph state, so records written from here on carry the conversation;
19
- the pre-fix baseline is unrecoverable and has to be rebuilt forward. This script
20
- reports that explicitly (`no_messages`) rather than scoring an empty record as
21
- zero re-reads.
22
-
23
- The counting logic is imported from ``src/core/run_metrics.py`` — the SAME code
24
- the live instrumentation uses. Do not re-implement it here: a divergence between
25
- the two would silently invalidate the comparison.
26
-
27
- Arm assignment is by the record's own ``execution_time`` versus ``--cutoff``
28
- (the `1ec068b` prod deploy). It deliberately does NOT use which sink the run
29
- landed in: dev runs currently write to the PROD sink despite
30
- ``LOG_SINK_HF_DATASET`` pointing at ``…_dev`` (open TODO). Splitting on time
31
- sidesteps that bug rather than depending on it — harmless today because both
32
- Spaces run identical code, but it is why the sink is not the discriminator.
33
-
34
- NOTE: this file must NOT be named ``test_*.py`` — pytest collects on filename
35
- alone and a script that exits at import kills the whole run.
36
-
37
- Usage::
38
-
39
- # local run_logs
40
- python scripts/analyze_post_analysis_reads.py --source local --dir ./run_logs
41
-
42
- # the prod HF dataset (needs decouplerpy_results_token)
43
- python scripts/analyze_post_analysis_reads.py --source hf
44
-
45
- # dump the per-run table too
46
- python scripts/analyze_post_analysis_reads.py --source hf --per-run
47
- """
48
-
49
- from __future__ import annotations
50
-
51
- import argparse
52
- import json
53
- import os
54
- import sys
55
- from datetime import datetime
56
- from pathlib import Path
57
-
58
- REPO_ROOT = Path(__file__).resolve().parents[1]
59
- sys.path.insert(0, str(REPO_ROOT / "src"))
60
-
61
- from core.run_metrics import compute_run_metrics # noqa: E402
62
-
63
- # 1ec068b reached prod on 2026-08-05. Runs at or after this instant are "after".
64
- DEFAULT_CUTOFF = "2026-08-05T00:00:00"
65
-
66
- # The one tool the fix actually touched. Every other terminal tool still returns
67
- # only paths, so the re-read tail is EXPECTED to persist there — which is why
68
- # results are grouped by terminal tool and never averaged into one number.
69
- FIXED_TOOL = "dataset_compare_activity_by_group"
70
-
71
-
72
- # --------------------------------------------------------------------------- #
73
- # Record loading
74
- # --------------------------------------------------------------------------- #
75
- def load_local(directory: str) -> list[tuple[str, dict]]:
76
- out = []
77
- for path in sorted(Path(directory).glob("**/*.json")):
78
- try:
79
- with open(path, encoding="utf-8") as f:
80
- record = json.load(f)
81
- if is_run_record(record):
82
- out.append((path.stem, record))
83
- except Exception as exc: # noqa: BLE001
84
- print(f" ! skipping {path}: {exc}", file=sys.stderr)
85
- return out
86
-
87
-
88
- def is_run_record(record: dict) -> bool:
89
- """Filter out the upload/audit records that share the same sink."""
90
- return isinstance(record, dict) and "upload_id" not in record and "messages" in record
91
-
92
-
93
- def load_hf(repo_id: str) -> list[tuple[str, dict]]:
94
- from huggingface_hub import HfApi, hf_hub_download
95
-
96
- token = os.environ.get("decouplerpy_results_token")
97
- api = HfApi(token=token)
98
- files = [
99
- f
100
- for f in api.list_repo_files(repo_id=repo_id, repo_type="dataset")
101
- if f.endswith("/trace.json")
102
- ]
103
- print(f"Found {len(files)} trace records in {repo_id}")
104
-
105
- out = []
106
- for remote in files:
107
- try:
108
- local = hf_hub_download(
109
- repo_id=repo_id, filename=remote, repo_type="dataset", token=token
110
- )
111
- with open(local, encoding="utf-8") as f:
112
- record = json.load(f)
113
- if is_run_record(record):
114
- out.append((remote.split("/")[-2], record))
115
- except Exception as exc: # noqa: BLE001
116
- print(f" ! skipping {remote}: {exc}", file=sys.stderr)
117
- return out
118
-
119
-
120
- # --------------------------------------------------------------------------- #
121
- # Arm assignment
122
- # --------------------------------------------------------------------------- #
123
- def _parse_time(record: dict, run_id: str) -> datetime | None:
124
- raw = record.get("execution_time")
125
- for fmt in ("%Y-%m-%d %H:%M:%S", "%Y-%m-%dT%H:%M:%S.%f", "%Y-%m-%dT%H:%M:%S"):
126
- try:
127
- return datetime.strptime(str(raw), fmt)
128
- except (TypeError, ValueError):
129
- continue
130
- # run_ids are stamped YYYYmmdd_HHMMSS by the Gradio path.
131
- try:
132
- return datetime.strptime(run_id[:15], "%Y%m%d_%H%M%S")
133
- except ValueError:
134
- return None
135
-
136
-
137
- def summarize(records: list[tuple[str, dict]], cutoff: datetime) -> list[dict]:
138
- rows = []
139
- for run_id, record in records:
140
- metrics = compute_run_metrics(record.get("messages") or [])
141
- when = _parse_time(record, run_id)
142
- rows.append(
143
- {
144
- "run_id": run_id,
145
- "time": when,
146
- "arm": None if when is None else ("after" if when >= cutoff else "before"),
147
- **metrics,
148
- }
149
- )
150
- return rows
151
-
152
-
153
- def fisher(before_hits, before_n, after_hits, after_n):
154
- """Two-sided Fisher exact p for (≥1 re-read) before vs after."""
155
- try:
156
- from scipy.stats import fisher_exact
157
- except ImportError:
158
- return None
159
- table = [
160
- [before_hits, before_n - before_hits],
161
- [after_hits, after_n - after_hits],
162
- ]
163
- return float(fisher_exact(table)[1])
164
-
165
-
166
- def report(rows: list[dict], per_run: bool) -> None:
167
- measurable = [r for r in rows if r["n_post_analysis_reads"] is not None]
168
- unmeasurable = [r for r in rows if r["n_post_analysis_reads"] is None]
169
-
170
- print(f"\nRuns loaded: {len(rows)}")
171
- print(f" measurable: {len(measurable)} (a terminal analysis tool wrote a results CSV)")
172
- print(f" not measurable: {len(unmeasurable)} (excluded — NOT counted as zero re-reads)")
173
- reasons: dict[str, int] = {}
174
- for r in unmeasurable:
175
- key = r.get("unmeasurable_reason") or "unknown"
176
- reasons[key] = reasons.get(key, 0) + 1
177
- for reason, n in sorted(reasons.items(), key=lambda kv: -kv[1]):
178
- note = ""
179
- if reason == "no_messages":
180
- note = " ← the record persisted an empty conversation; nothing to count"
181
- print(f" {reason:<28} {n}{note}")
182
- no_arm = [r for r in measurable if r["arm"] is None]
183
- if no_arm:
184
- print(f" undated: {len(no_arm)} (excluded from the arms)")
185
-
186
- tools = sorted({r["terminal_tool"] or "(unknown)" for r in measurable})
187
- print("\nBy terminal tool — fraction of runs with ≥1 post-analysis re-read")
188
- print(f"{'terminal tool':<45} {'before':>14} {'after':>14} {'fisher p':>10}")
189
- print("-" * 87)
190
-
191
- for tool in tools:
192
- arm_counts = {}
193
- for arm in ("before", "after"):
194
- sel = [
195
- r
196
- for r in measurable
197
- if (r["terminal_tool"] or "(unknown)") == tool and r["arm"] == arm
198
- ]
199
- hits = sum(1 for r in sel if r["n_post_analysis_reads"] >= 1)
200
- arm_counts[arm] = (hits, len(sel))
201
- (bh, bn), (ah, an) = arm_counts["before"], arm_counts["after"]
202
-
203
- def fmt(h, n):
204
- return f"{h}/{n}" + (f" ({h / n:.0%})" if n else " (n=0)")
205
-
206
- p = fisher(bh, bn, ah, an) if bn and an else None
207
- p_str = f"{p:.4f}" if p is not None else "—"
208
- mark = " ← the fixed tool" if tool == FIXED_TOOL else ""
209
- print(f"{tool:<45} {fmt(bh, bn):>14} {fmt(ah, an):>14} {p_str:>10}{mark}")
210
-
211
- print(
212
- "\nOnly " + FIXED_TOOL + " was changed by 1ec068b; a persisting tail on the other\n"
213
- "tools is expected, not a refutation. Do not average across rows."
214
- )
215
-
216
- if per_run:
217
- print("\nPer-run detail")
218
- for r in sorted(measurable, key=lambda r: r["time"] or datetime.min):
219
- print(
220
- f" {r['run_id']:<24} {str(r['arm']):<7} "
221
- f"reads={r['n_post_analysis_reads']} "
222
- f"after_steps={r['n_steps_after_analysis']} "
223
- f"tool={r['terminal_tool']}"
224
- )
225
-
226
-
227
- def main() -> int:
228
- ap = argparse.ArgumentParser(description=__doc__)
229
- ap.add_argument("--source", choices=("local", "hf"), default="local")
230
- ap.add_argument("--dir", default="./run_logs", help="local source directory")
231
- ap.add_argument(
232
- "--repo",
233
- default=os.environ.get("LOG_SINK_HF_DATASET", "anne-voigt/decoupleRpy_results"),
234
- help="HF dataset repo id (hf source)",
235
- )
236
- ap.add_argument("--cutoff", default=DEFAULT_CUTOFF, help="1ec068b deploy instant (ISO)")
237
- ap.add_argument("--per-run", action="store_true")
238
- args = ap.parse_args()
239
-
240
- cutoff = datetime.fromisoformat(args.cutoff)
241
- records = load_local(args.dir) if args.source == "local" else load_hf(args.repo)
242
- if not records:
243
- print("No run records found — nothing to report.", file=sys.stderr)
244
- return 1
245
-
246
- report(summarize(records, cutoff), args.per_run)
247
- return 0
248
-
249
-
250
- if __name__ == "__main__":
251
- raise SystemExit(main())
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
scripts/assemble_cptac_pda.py CHANGED
@@ -5,19 +5,18 @@ Source: broad transcriptomics (RSEM TPM, gene-level collapsed).
5
 
6
  Usage: .venv/bin/python scripts/assemble_cptac_pda.py
7
  """
8
-
9
- import os
10
  import sys
 
11
  from pathlib import Path
12
 
13
  ROOT = Path(__file__).parent.parent
14
  sys.path.insert(0, str(ROOT))
15
  os.makedirs(ROOT / "tmp" / "datasets", exist_ok=True)
16
 
17
- import anndata as ad
18
  import cptac
19
- import numpy as np
20
  import pandas as pd
 
 
21
  from huggingface_hub import HfApi
22
 
23
  OUT_H5AD = ROOT / "tmp" / "datasets" / "cptac_pda.h5ad"
@@ -31,7 +30,7 @@ def main():
31
 
32
  # Get broad transcriptomics (TPM, transcript level)
33
  print("Getting transcriptomics (broad/TPM)...")
34
- tx = pdac.get_transcriptomics(source="broad")
35
  print(f" Transcript-level shape: {tx.shape}")
36
  print(f" MultiIndex levels: {tx.columns.names}")
37
 
@@ -52,7 +51,7 @@ def main():
52
  # Get clinical data
53
  print("\nGetting clinical data...")
54
  try:
55
- clinical = pdac.get_clinical(source="mssm")
56
  print(f" Clinical shape: {clinical.shape}")
57
  print(f" Clinical columns: {clinical.columns.tolist()[:20]}")
58
  except Exception as e:
@@ -61,7 +60,7 @@ def main():
61
 
62
  # Get follow-up (survival)
63
  try:
64
- followup = pdac.get_followup(source="mssm")
65
  print(f" Follow-up shape: {followup.shape}")
66
  print(f" Follow-up columns: {followup.columns.tolist()}")
67
  except Exception as e:
@@ -72,11 +71,7 @@ def main():
72
  common_samples = tx_gene.index.intersection(clinical.index)
73
  print(f"\nCommon clinical+expression samples: {len(common_samples)}")
74
 
75
- obs_df = (
76
- clinical.loc[common_samples].copy()
77
- if len(common_samples) > 0
78
- else pd.DataFrame(index=tx_gene.index)
79
- )
80
  tx_final = tx_gene.loc[obs_df.index]
81
 
82
  # Join follow-up (drop overlapping columns first)
@@ -88,22 +83,15 @@ def main():
88
  overlap = obs_df.columns.intersection(fu_sub.columns)
89
  fu_sub = fu_sub.drop(columns=overlap)
90
  if len(fu_sub.columns) > 0:
91
- obs_df = obs_df.join(fu_sub, how="left")
92
 
93
  print(f"\nFinal obs shape: {obs_df.shape}")
94
  print(f"Obs columns: {obs_df.columns.tolist()}")
95
 
96
  # Print key metadata
97
- key_cols = [
98
- "histologic_grade",
99
- "tumor_site",
100
- "tumor_stage_pathological",
101
- "margin_status",
102
- "Overall survival, days",
103
- "Survival status (1, dead; 0, alive)",
104
- "sex",
105
- "age",
106
- ]
107
  for col in key_cols:
108
  if col in obs_df.columns:
109
  vc = obs_df[col].value_counts().head(8)
@@ -114,7 +102,7 @@ def main():
114
  print(f"After dedup: {obs_df.shape[1]} columns")
115
 
116
  # Cast object columns to str
117
- for col in obs_df.select_dtypes(include="object").columns:
118
  obs_df[col] = obs_df[col].astype(str)
119
 
120
  # Build var
 
5
 
6
  Usage: .venv/bin/python scripts/assemble_cptac_pda.py
7
  """
 
 
8
  import sys
9
+ import os
10
  from pathlib import Path
11
 
12
  ROOT = Path(__file__).parent.parent
13
  sys.path.insert(0, str(ROOT))
14
  os.makedirs(ROOT / "tmp" / "datasets", exist_ok=True)
15
 
 
16
  import cptac
 
17
  import pandas as pd
18
+ import anndata as ad
19
+ import numpy as np
20
  from huggingface_hub import HfApi
21
 
22
  OUT_H5AD = ROOT / "tmp" / "datasets" / "cptac_pda.h5ad"
 
30
 
31
  # Get broad transcriptomics (TPM, transcript level)
32
  print("Getting transcriptomics (broad/TPM)...")
33
+ tx = pdac.get_transcriptomics(source='broad')
34
  print(f" Transcript-level shape: {tx.shape}")
35
  print(f" MultiIndex levels: {tx.columns.names}")
36
 
 
51
  # Get clinical data
52
  print("\nGetting clinical data...")
53
  try:
54
+ clinical = pdac.get_clinical(source='mssm')
55
  print(f" Clinical shape: {clinical.shape}")
56
  print(f" Clinical columns: {clinical.columns.tolist()[:20]}")
57
  except Exception as e:
 
60
 
61
  # Get follow-up (survival)
62
  try:
63
+ followup = pdac.get_followup(source='mssm')
64
  print(f" Follow-up shape: {followup.shape}")
65
  print(f" Follow-up columns: {followup.columns.tolist()}")
66
  except Exception as e:
 
71
  common_samples = tx_gene.index.intersection(clinical.index)
72
  print(f"\nCommon clinical+expression samples: {len(common_samples)}")
73
 
74
+ obs_df = clinical.loc[common_samples].copy() if len(common_samples) > 0 else pd.DataFrame(index=tx_gene.index)
 
 
 
 
75
  tx_final = tx_gene.loc[obs_df.index]
76
 
77
  # Join follow-up (drop overlapping columns first)
 
83
  overlap = obs_df.columns.intersection(fu_sub.columns)
84
  fu_sub = fu_sub.drop(columns=overlap)
85
  if len(fu_sub.columns) > 0:
86
+ obs_df = obs_df.join(fu_sub, how='left')
87
 
88
  print(f"\nFinal obs shape: {obs_df.shape}")
89
  print(f"Obs columns: {obs_df.columns.tolist()}")
90
 
91
  # Print key metadata
92
+ key_cols = ['histologic_grade', 'tumor_site', 'tumor_stage_pathological',
93
+ 'margin_status', 'Overall survival, days', 'Survival status (1, dead; 0, alive)',
94
+ 'sex', 'age']
 
 
 
 
 
 
 
95
  for col in key_cols:
96
  if col in obs_df.columns:
97
  vc = obs_df[col].value_counts().head(8)
 
102
  print(f"After dedup: {obs_df.shape[1]} columns")
103
 
104
  # Cast object columns to str
105
+ for col in obs_df.select_dtypes(include='object').columns:
106
  obs_df[col] = obs_df[col].astype(str)
107
 
108
  # Build var
scripts/assemble_cptac_pda_counts.py CHANGED
@@ -106,22 +106,10 @@ def query_gdc_pancreas_files(max_size: int = 2000) -> list[dict]:
106
  "filters": {
107
  "op": "and",
108
  "content": [
109
- {
110
- "op": "in",
111
- "content": {"field": "cases.project.project_id", "value": [GDC_PROJECT]},
112
- },
113
- {
114
- "op": "in",
115
- "content": {"field": "cases.primary_site", "value": [GDC_PRIMARY_SITE]},
116
- },
117
- {
118
- "op": "in",
119
- "content": {"field": "data_type", "value": ["Gene Expression Quantification"]},
120
- },
121
- {
122
- "op": "in",
123
- "content": {"field": "analysis.workflow_type", "value": ["STAR - Counts"]},
124
- },
125
  ],
126
  },
127
  "fields": (
@@ -146,9 +134,7 @@ def query_gdc_pancreas_files(max_size: int = 2000) -> list[dict]:
146
  total = result["data"]["pagination"]["total"]
147
  print(f"[gdc] CPTAC-3 / Pancreas STAR-Counts files: {len(hits)} (total reported {total})")
148
  if total > len(hits):
149
- print(
150
- f"[gdc] WARNING: {total} files exist but only {len(hits)} fetched — raise --max-size."
151
- )
152
  return hits
153
 
154
 
@@ -160,9 +146,7 @@ def _participant_from_hit(hit: dict) -> str:
160
  return ""
161
 
162
 
163
- def build_count_matrix(
164
- hits: list[dict], count_column: str
165
- ) -> tuple[pd.DataFrame, pd.DataFrame, dict]:
166
  """Download + parse STAR-Counts. Returns (counts_df samples×genes, var_df, barcode→meta)."""
167
  id_to_barcode = {h["file_id"]: _barcode_from_hit(h) for h in hits}
168
  filename_to_barcode = {h["file_name"]: _barcode_from_hit(h) for h in hits}
@@ -253,10 +237,8 @@ def load_clinical() -> pd.DataFrame:
253
  try:
254
  import cptac
255
  except ImportError:
256
- print(
257
- "[clinical] cptac package not installed skipping clinical join "
258
- "(os_*/grade/site will be empty; install `cptac` to populate)."
259
- )
260
  return pd.DataFrame()
261
 
262
  pdac = cptac.Pdac()
@@ -271,16 +253,12 @@ def load_clinical() -> pd.DataFrame:
271
  out.index.name = "participant_id"
272
  os_days = "Overall survival, days"
273
  os_stat = "Survival status (1, dead; 0, alive)"
274
- out["os_days"] = (
275
- pd.to_numeric(clin.get(os_days), errors="coerce") if os_days in clin else np.nan
276
- )
277
  if os_stat in clin:
278
  out["os_event"] = pd.to_numeric(clin[os_stat], errors="coerce")
279
  else:
280
  out["os_event"] = np.nan
281
- out["histologic_grade"] = (
282
- clin.get("histologic_grade", "").astype(str) if "histologic_grade" in clin else ""
283
- )
284
  out["tumor_site"] = clin.get("tumor_site", "").astype(str) if "tumor_site" in clin else ""
285
  return out
286
 
@@ -291,16 +269,8 @@ def load_clinical() -> pd.DataFrame:
291
  def main() -> int:
292
  ap = argparse.ArgumentParser()
293
  ap.add_argument("--subtype-table", type=Path, help="Cao 2021 Table S1 (.xlsx/.xls/.csv)")
294
- ap.add_argument(
295
- "--counts-only",
296
- action="store_true",
297
- help="Skip subtype/clinical join — verify GDC PDA filter only",
298
- )
299
- ap.add_argument(
300
- "--count-column",
301
- default="unstranded",
302
- choices=["unstranded", "stranded_first", "stranded_second"],
303
- )
304
  ap.add_argument("--max-size", type=int, default=2000)
305
  ap.add_argument("--dry-run", action="store_true", help="Build + report, skip HF upload")
306
  args = ap.parse_args()
@@ -359,15 +329,12 @@ def main() -> int:
359
 
360
  if args.dry_run:
361
  print("[dry-run] skipping HF upload.")
362
- print(
363
- "\nNEXT: paste the subtype counts above into "
364
- "biodata_registry/manifests/cptac_pda_counts.yaml, then run "
365
- "dataset_validate_manifest_against_data(dataset_id='cptac_pda_counts')."
366
- )
367
  return 0
368
 
369
  from huggingface_hub import HfApi
370
-
371
  HfApi().upload_file(
372
  path_or_fileobj=str(OUT_H5AD),
373
  path_in_repo=HF_FILENAME,
 
106
  "filters": {
107
  "op": "and",
108
  "content": [
109
+ {"op": "in", "content": {"field": "cases.project.project_id", "value": [GDC_PROJECT]}},
110
+ {"op": "in", "content": {"field": "cases.primary_site", "value": [GDC_PRIMARY_SITE]}},
111
+ {"op": "in", "content": {"field": "data_type", "value": ["Gene Expression Quantification"]}},
112
+ {"op": "in", "content": {"field": "analysis.workflow_type", "value": ["STAR - Counts"]}},
 
 
 
 
 
 
 
 
 
 
 
 
113
  ],
114
  },
115
  "fields": (
 
134
  total = result["data"]["pagination"]["total"]
135
  print(f"[gdc] CPTAC-3 / Pancreas STAR-Counts files: {len(hits)} (total reported {total})")
136
  if total > len(hits):
137
+ print(f"[gdc] WARNING: {total} files exist but only {len(hits)} fetched — raise --max-size.")
 
 
138
  return hits
139
 
140
 
 
146
  return ""
147
 
148
 
149
+ def build_count_matrix(hits: list[dict], count_column: str) -> tuple[pd.DataFrame, pd.DataFrame, dict]:
 
 
150
  """Download + parse STAR-Counts. Returns (counts_df samples×genes, var_df, barcode→meta)."""
151
  id_to_barcode = {h["file_id"]: _barcode_from_hit(h) for h in hits}
152
  filename_to_barcode = {h["file_name"]: _barcode_from_hit(h) for h in hits}
 
237
  try:
238
  import cptac
239
  except ImportError:
240
+ print("[clinical] cptac package not installed — skipping clinical join "
241
+ "(os_*/grade/site will be empty; install `cptac` to populate).")
 
 
242
  return pd.DataFrame()
243
 
244
  pdac = cptac.Pdac()
 
253
  out.index.name = "participant_id"
254
  os_days = "Overall survival, days"
255
  os_stat = "Survival status (1, dead; 0, alive)"
256
+ out["os_days"] = pd.to_numeric(clin.get(os_days), errors="coerce") if os_days in clin else np.nan
 
 
257
  if os_stat in clin:
258
  out["os_event"] = pd.to_numeric(clin[os_stat], errors="coerce")
259
  else:
260
  out["os_event"] = np.nan
261
+ out["histologic_grade"] = clin.get("histologic_grade", "").astype(str) if "histologic_grade" in clin else ""
 
 
262
  out["tumor_site"] = clin.get("tumor_site", "").astype(str) if "tumor_site" in clin else ""
263
  return out
264
 
 
269
  def main() -> int:
270
  ap = argparse.ArgumentParser()
271
  ap.add_argument("--subtype-table", type=Path, help="Cao 2021 Table S1 (.xlsx/.xls/.csv)")
272
+ ap.add_argument("--counts-only", action="store_true", help="Skip subtype/clinical join — verify GDC PDA filter only")
273
+ ap.add_argument("--count-column", default="unstranded", choices=["unstranded", "stranded_first", "stranded_second"])
 
 
 
 
 
 
 
 
274
  ap.add_argument("--max-size", type=int, default=2000)
275
  ap.add_argument("--dry-run", action="store_true", help="Build + report, skip HF upload")
276
  args = ap.parse_args()
 
329
 
330
  if args.dry_run:
331
  print("[dry-run] skipping HF upload.")
332
+ print("\nNEXT: paste the subtype counts above into "
333
+ "biodata_registry/manifests/cptac_pda_counts.yaml, then run "
334
+ "dataset_validate_manifest_against_data(dataset_id='cptac_pda_counts').")
 
 
335
  return 0
336
 
337
  from huggingface_hub import HfApi
 
338
  HfApi().upload_file(
339
  path_or_fileobj=str(OUT_H5AD),
340
  path_in_repo=HF_FILENAME,
scripts/assemble_gse15471.py CHANGED
@@ -5,19 +5,18 @@ Platform: Affymetrix Human Genome U133 Plus 2.0 (GPL570)
5
 
6
  Usage: .venv/bin/python scripts/assemble_gse15471.py
7
  """
8
-
9
- import os
10
  import sys
 
11
  from pathlib import Path
12
 
13
  ROOT = Path(__file__).parent.parent
14
  sys.path.insert(0, str(ROOT))
15
  os.makedirs(ROOT / "tmp" / "datasets", exist_ok=True)
16
 
17
- import anndata as ad
18
  import GEOparse
19
- import numpy as np
20
  import pandas as pd
 
 
21
  from huggingface_hub import HfApi
22
 
23
  GEO_ID = "GSE15471"
@@ -92,9 +91,7 @@ def main():
92
  var_df["gene_symbol"] = gpl_map.loc[
93
  var_df.index.intersection(gpl_map.index), "Gene Symbol"
94
  ].reindex(var_df.index)
95
- print(
96
- f" Gene symbols mapped: {var_df['gene_symbol'].notna().sum()} / {len(var_df)}"
97
- )
98
  except Exception as e:
99
  print(f" Warning: Could not map GPL570: {e}")
100
 
 
5
 
6
  Usage: .venv/bin/python scripts/assemble_gse15471.py
7
  """
 
 
8
  import sys
9
+ import os
10
  from pathlib import Path
11
 
12
  ROOT = Path(__file__).parent.parent
13
  sys.path.insert(0, str(ROOT))
14
  os.makedirs(ROOT / "tmp" / "datasets", exist_ok=True)
15
 
 
16
  import GEOparse
 
17
  import pandas as pd
18
+ import anndata as ad
19
+ import numpy as np
20
  from huggingface_hub import HfApi
21
 
22
  GEO_ID = "GSE15471"
 
91
  var_df["gene_symbol"] = gpl_map.loc[
92
  var_df.index.intersection(gpl_map.index), "Gene Symbol"
93
  ].reindex(var_df.index)
94
+ print(f" Gene symbols mapped: {var_df['gene_symbol'].notna().sum()} / {len(var_df)}")
 
 
95
  except Exception as e:
96
  print(f" Warning: Could not map GPL570: {e}")
97
 
scripts/assemble_gse16515_mayo.py CHANGED
@@ -13,23 +13,15 @@ same linear scale as the live GEO series matrix load.
13
 
14
  Usage: .venv/bin/python scripts/assemble_gse16515_mayo.py [--dry-run]
15
  """
16
-
17
  import sys
18
  from pathlib import Path
19
 
20
  sys.path.insert(0, str(Path(__file__).resolve().parent))
21
- from _precompute_common import (
22
- annotate_with_gpl,
23
- load_series_matrix_to_anndata,
24
- stamp_provenance,
25
- write_and_upload,
26
- )
27
 
28
  DATASET_ID = "gse16515_mayo"
29
  GSE_ACCESSION = "GSE16515"
30
- SOURCE_URL = (
31
- "https://ftp.ncbi.nlm.nih.gov/geo/series/GSE16nnn/GSE16515/matrix/GSE16515_series_matrix.txt.gz"
32
- )
33
  GPL_ACCESSION = "GPL570"
34
  GENE_SYMBOL_COLUMN = "Gene Symbol"
35
  BIODATA_REGISTRY_COMMIT = "b9ae20c6ebe4809e8bfea2f8e6dfef6b98170097"
@@ -42,24 +34,18 @@ def main():
42
  adata = load_series_matrix_to_anndata(SOURCE_URL)
43
  print(f"Shape: {adata.shape[0]} samples x {adata.shape[1]} probes")
44
  print(f"Obs columns: {adata.obs.columns.tolist()}")
45
- print(
46
- f"X range: {adata.X.min():.3f} - {adata.X.max():.3f} "
47
- "(expect linear scale ~2-65000, NOT log2 - manifest declares data_level: normalized)"
48
- )
49
 
50
  if "tissue" in adata.obs.columns:
51
  print("\ntissue value counts:")
52
  print(adata.obs["tissue"].value_counts(dropna=False).to_string())
53
 
54
  print(f"\nAnnotating probes with {GPL_ACCESSION} ...")
55
- adata, sym_col, stats = annotate_with_gpl(
56
- adata, GSE_ACCESSION, GPL_ACCESSION, gene_symbol_column=GENE_SYMBOL_COLUMN
57
- )
58
  if sym_col is None:
59
  raise RuntimeError(f"GPL annotation failed: {stats}")
60
- print(
61
- f" sym_col_used={sym_col!r}, n_annotated={stats['n_annotated']}/{stats['n_total']} ({stats['coverage_pct']}%)"
62
- )
63
 
64
  stamp_provenance(
65
  adata,
 
13
 
14
  Usage: .venv/bin/python scripts/assemble_gse16515_mayo.py [--dry-run]
15
  """
 
16
  import sys
17
  from pathlib import Path
18
 
19
  sys.path.insert(0, str(Path(__file__).resolve().parent))
20
+ from _precompute_common import annotate_with_gpl, load_series_matrix_to_anndata, stamp_provenance, write_and_upload
 
 
 
 
 
21
 
22
  DATASET_ID = "gse16515_mayo"
23
  GSE_ACCESSION = "GSE16515"
24
+ SOURCE_URL = "https://ftp.ncbi.nlm.nih.gov/geo/series/GSE16nnn/GSE16515/matrix/GSE16515_series_matrix.txt.gz"
 
 
25
  GPL_ACCESSION = "GPL570"
26
  GENE_SYMBOL_COLUMN = "Gene Symbol"
27
  BIODATA_REGISTRY_COMMIT = "b9ae20c6ebe4809e8bfea2f8e6dfef6b98170097"
 
34
  adata = load_series_matrix_to_anndata(SOURCE_URL)
35
  print(f"Shape: {adata.shape[0]} samples x {adata.shape[1]} probes")
36
  print(f"Obs columns: {adata.obs.columns.tolist()}")
37
+ print(f"X range: {adata.X.min():.3f} - {adata.X.max():.3f} "
38
+ "(expect linear scale ~2-65000, NOT log2 - manifest declares data_level: normalized)")
 
 
39
 
40
  if "tissue" in adata.obs.columns:
41
  print("\ntissue value counts:")
42
  print(adata.obs["tissue"].value_counts(dropna=False).to_string())
43
 
44
  print(f"\nAnnotating probes with {GPL_ACCESSION} ...")
45
+ adata, sym_col, stats = annotate_with_gpl(adata, GSE_ACCESSION, GPL_ACCESSION, gene_symbol_column=GENE_SYMBOL_COLUMN)
 
 
46
  if sym_col is None:
47
  raise RuntimeError(f"GPL annotation failed: {stats}")
48
+ print(f" sym_col_used={sym_col!r}, n_annotated={stats['n_annotated']}/{stats['n_total']} ({stats['coverage_pct']}%)")
 
 
49
 
50
  stamp_provenance(
51
  adata,
scripts/assemble_gse17891.py CHANGED
@@ -5,19 +5,18 @@ GPL8321 (mouse) present — filter to human only.
5
 
6
  Usage: .venv/bin/python scripts/assemble_gse17891.py
7
  """
8
-
9
- import os
10
  import sys
 
11
  from pathlib import Path
12
 
13
  ROOT = Path(__file__).parent.parent
14
  sys.path.insert(0, str(ROOT))
15
  os.makedirs(ROOT / "tmp" / "datasets", exist_ok=True)
16
 
17
- import anndata as ad
18
  import GEOparse
19
- import numpy as np
20
  import pandas as pd
 
 
21
  from huggingface_hub import HfApi
22
 
23
  GEO_ID = "GSE17891"
@@ -31,9 +30,8 @@ def main():
31
  gse = GEOparse.get_GEO(geo=GEO_ID, destdir=str(ROOT / "tmp" / "datasets"), silent=True)
32
 
33
  # Filter to human samples (GPL570 only)
34
- human_gsms = {
35
- k: v for k, v in gse.gsms.items() if v.metadata.get("platform_id", [""])[0] == "GPL570"
36
- }
37
  print(f" Total GSMs: {len(gse.gsms)}, Human (GPL570): {len(human_gsms)}")
38
 
39
  # Inspect first human sample
 
5
 
6
  Usage: .venv/bin/python scripts/assemble_gse17891.py
7
  """
 
 
8
  import sys
9
+ import os
10
  from pathlib import Path
11
 
12
  ROOT = Path(__file__).parent.parent
13
  sys.path.insert(0, str(ROOT))
14
  os.makedirs(ROOT / "tmp" / "datasets", exist_ok=True)
15
 
 
16
  import GEOparse
 
17
  import pandas as pd
18
+ import anndata as ad
19
+ import numpy as np
20
  from huggingface_hub import HfApi
21
 
22
  GEO_ID = "GSE17891"
 
30
  gse = GEOparse.get_GEO(geo=GEO_ID, destdir=str(ROOT / "tmp" / "datasets"), silent=True)
31
 
32
  # Filter to human samples (GPL570 only)
33
+ human_gsms = {k: v for k, v in gse.gsms.items()
34
+ if v.metadata.get("platform_id", [""])[0] == "GPL570"}
 
35
  print(f" Total GSMs: {len(gse.gsms)}, Human (GPL570): {len(human_gsms)}")
36
 
37
  # Inspect first human sample
scripts/assemble_gse205154_sears.py CHANGED
@@ -8,7 +8,6 @@ Ensembl ID; var['SYMBOL'] = HGNC symbol. data_level: tpm (float32) -> limma/ttes
8
 
9
  Usage: .venv/bin/python scripts/assemble_gse205154_sears.py [--dry-run]
10
  """
11
-
12
  import sys
13
  from pathlib import Path
14
 
 
8
 
9
  Usage: .venv/bin/python scripts/assemble_gse205154_sears.py [--dry-run]
10
  """
 
11
  import sys
12
  from pathlib import Path
13
 
scripts/assemble_gse205154_sears_counts.py CHANGED
@@ -13,7 +13,6 @@ limitations). For length-aware DE, work from the raw GEO file directly.
13
 
14
  Usage: .venv/bin/python scripts/assemble_gse205154_sears_counts.py [--dry-run]
15
  """
16
-
17
  import sys
18
  from pathlib import Path
19
 
 
13
 
14
  Usage: .venv/bin/python scripts/assemble_gse205154_sears_counts.py [--dry-run]
15
  """
 
16
  import sys
17
  from pathlib import Path
18
 
scripts/assemble_gse205154_sears_filtered_tmm.py DELETED
@@ -1,75 +0,0 @@
1
- """
2
- Assemble precomputed h5ad for gse205154_sears_filtered_tmm — Sears lab (OHSU)
3
- PDAC bulk RNA-seq, edgeR TMM restricted to expression-filter-passing genes
4
- (Path B). Subseries of GSE281129; PMID 39789181.
5
-
6
- Same 289 FFPE samples and the SAME edgeR TMM values as gse205154_sears_tmm, but
7
- the gene axis is SUBSET to the genes the authors' expression filter passed
8
- (TMM_pass_filter == TRUE in GSE205154_Gene_Level_TMM_Estimates.txt.gz). That flag
9
- is edgeR's own filterByExpr-style low-count filter, so this variant is "edgeR TMM
10
- after a standard low-count filter" — a ready-to-score matrix with low-count/noise
11
- genes already dropped, so the agent needn't subset at runtime.
12
-
13
- Values are edgeR TMM and NOT log-scaled (data_level: normalized) → log-transform
14
- before limma/ttest; never DESeq2. var.index = versioned Ensembl ID; var['SYMBOL']
15
- = HGNC symbol; obs is the identical 289-sample metadata as the other siblings.
16
-
17
- Usage: .venv/bin/python scripts/assemble_gse205154_sears_filtered_tmm.py [--dry-run]
18
- """
19
- import sys
20
- from pathlib import Path
21
-
22
- sys.path.insert(0, str(Path(__file__).resolve().parent))
23
- from _gse205154_sears_common import ( # noqa: E402
24
- BIODATA_REGISTRY_COMMIT,
25
- SERIES_MATRIX_URL,
26
- TMM_FILTER_COLUMN,
27
- build_gse205154_anndata,
28
- )
29
- from _precompute_common import stamp_provenance, write_and_upload # noqa: E402
30
-
31
- DATASET_ID = "gse205154_sears_filtered_tmm"
32
- MATRIX_URL = (
33
- "https://ftp.ncbi.nlm.nih.gov/geo/series/GSE205nnn/GSE205154/suppl/"
34
- "GSE205154_Gene_Level_TMM_Estimates.txt.gz"
35
- )
36
- DATA_LEVEL = "normalized"
37
-
38
-
39
- def main():
40
- dry_run = "--dry-run" in sys.argv
41
- print(f"=== Assembling {DATASET_ID} (data_level={DATA_LEVEL}) ===")
42
-
43
- adata = build_gse205154_anndata(MATRIX_URL, data_level=DATA_LEVEL)
44
- if TMM_FILTER_COLUMN not in adata.var.columns:
45
- raise RuntimeError(f"{TMM_FILTER_COLUMN} not present in var — cannot filter genes")
46
-
47
- n_before = adata.n_vars
48
- keep = adata.var[TMM_FILTER_COLUMN].astype(bool).to_numpy()
49
- adata = adata[:, keep].copy()
50
- n_after = adata.n_vars
51
- print(f" Filtered to {TMM_FILTER_COLUMN}==TRUE: {n_before} -> {n_after} genes "
52
- f"({n_before - n_after} low-count genes dropped)")
53
- if n_after == 0 or n_after == n_before:
54
- raise RuntimeError(f"Unexpected filter result: {n_before} -> {n_after}")
55
-
56
- stamp_provenance(
57
- adata,
58
- source_url=MATRIX_URL,
59
- dataset_id=DATASET_ID,
60
- biodata_registry_commit=BIODATA_REGISTRY_COMMIT,
61
- script_name="scripts/assemble_gse205154_sears_filtered_tmm.py",
62
- extra={
63
- "precompute_geo_accession": "GSE205154",
64
- "precompute_superseries": "GSE281129",
65
- "precompute_pmid": "39789181",
66
- "precompute_data_level": DATA_LEVEL,
67
- "precompute_gene_filter": f"{TMM_FILTER_COLUMN}==TRUE ({n_before}->{n_after} genes)",
68
- "precompute_metadata_source": SERIES_MATRIX_URL,
69
- },
70
- )
71
- write_and_upload(adata, DATASET_ID, dry_run=dry_run)
72
-
73
-
74
- if __name__ == "__main__":
75
- main()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
scripts/assemble_gse205154_sears_tmm.py CHANGED
@@ -10,7 +10,6 @@ limma/ttest.
10
 
11
  Usage: .venv/bin/python scripts/assemble_gse205154_sears_tmm.py [--dry-run]
12
  """
13
-
14
  import sys
15
  from pathlib import Path
16
 
 
10
 
11
  Usage: .venv/bin/python scripts/assemble_gse205154_sears_tmm.py [--dry-run]
12
  """
 
13
  import sys
14
  from pathlib import Path
15