feat(uploads): ADR-0011 manual-upload safety gate (Now slice)
#2
by anne-voigt - opened
This view is limited to 50 files because it contains too many changes. See the raw diff here.
- .dockerignore +0 -37
- .gitattributes +0 -9
- .github/dependabot.yml +0 -36
- .github/workflows/security.yml +0 -61
- .gitignore +0 -12
- .gitleaks.toml +0 -50
- .pre-commit-config.yaml +0 -23
- CLAUDE.md +210 -0
- Makefile +0 -28
- README.md +0 -39
- TODO.md +486 -0
- app.py +7 -20
- bandit.yaml +0 -21
- deploy/scan_posture.yaml +0 -43
- docker/sandbox.Dockerfile +0 -73
- docs/adr/ADR-0003-spaces-dev-mode.md +5 -5
- docs/adr/ADR-0005-private-storage-persistence.md +5 -5
- docs/adr/ADR-0006-loveless-single-cell-serving.md +3 -3
- docs/adr/ADR-0007-phase1-local-validation.md +0 -147
- docs/adr/ADR-0007-phase2-local-hardening.md +0 -122
- docs/adr/ADR-0007-sandboxed-code-execution.md +0 -157
- docs/adr/ADR-0010-dataset-integrity-verification.md +4 -33
- docs/adr/ADR-0011-upload-safety-gate.md +10 -86
- docs/adr/ADR-0012-authentication-access-control.md +0 -164
- docs/adr/ADR-0013-audit-trace-redaction.md +0 -125
- docs/adr/ADR-0014-ci-security-scanning.md +0 -150
- gradio_ui.py +245 -901
- memory.md +0 -0
- pdac-analysis-orchestrator-dev +1 -0
- prompts.yaml +3 -3
- pytest.ini +0 -11
- requirements.in +1 -20
- requirements.txt +8 -18
- resources/mouse_ensembl_symbol_map.tsv.gz +0 -3
- ruff.toml +0 -31
- scripts/_gse205154_sears_common.py +8 -11
- scripts/_investigate_gpl6244.py +1 -1
- scripts/_precompute_common.py +51 -79
- scripts/_verify_precompute.py +1 -10
- scripts/_vst_transform.R +0 -32
- scripts/analyze_post_analysis_reads.py +0 -251
- scripts/assemble_cptac_pda.py +12 -24
- scripts/assemble_cptac_pda_counts.py +15 -48
- scripts/assemble_gse15471.py +4 -7
- scripts/assemble_gse16515_mayo.py +6 -20
- scripts/assemble_gse17891.py +5 -7
- scripts/assemble_gse205154_sears.py +0 -1
- scripts/assemble_gse205154_sears_counts.py +0 -1
- scripts/assemble_gse205154_sears_filtered_tmm.py +0 -75
- 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 |
-
|
| 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 |
-
|
| 33 |
-
|
| 34 |
-
|
| 35 |
-
|
| 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 —
|
| 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. [
|
| 105 |
-
3. [
|
| 106 |
-
4. [
|
| 107 |
-
5. [
|
| 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 —
|
| 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. [
|
| 96 |
-
3. [
|
| 97 |
-
4. [
|
| 98 |
-
5. [
|
| 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. [
|
| 162 |
-
8. [
|
| 163 |
-
9. [ ] Pair with the PURIST-vs-single-cell methodology comparison (TODO) once the subset is loadable.
|
| 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:**
|
| 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 |
-
*(
|
| 53 |
-
streamed — landed with ADR-0011 and is the exact
|
| 54 |
-
|
| 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
|
| 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` (
|
| 68 |
-
|
| 69 |
-
|
| 70 |
-
|
| 71 |
-
|
| 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
|
| 149 |
-
|
| 150 |
-
|
| 151 |
-
|
| 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 |
-
|
| 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
|
| 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 =
|
| 607 |
-
"
|
|
|
|
| 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=
|
|
|
|
|
|
|
| 616 |
)
|
| 617 |
|
| 618 |
if config is None:
|
| 619 |
-
config = AgentConfig(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 = {}
|
| 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 |
-
|
| 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 |
-
|
| 756 |
-
|
| 757 |
-
|
| 758 |
-
f"Discovery errors: {self._mcp_discovery_errors}"
|
| 759 |
-
)
|
| 760 |
except Exception as e:
|
| 761 |
-
print(
|
| 762 |
-
|
| 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
|
| 832 |
-
solution_shown = False
|
| 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)
|
|
|
|
| 843 |
)
|
| 844 |
-
last_internal_step = None
|
| 845 |
-
header_pending = False
|
| 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[
|
| 880 |
-
_t = re.sub(r
|
| 881 |
-
_t = re.sub(r
|
| 882 |
-
_t =
|
| 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[
|
| 889 |
-
_obs = re.sub(r
|
| 890 |
-
if _obs.startswith(
|
| 891 |
last_error = _obs
|
| 892 |
-
parts[
|
| 893 |
|
| 894 |
plan_changed = bool(current_plan and current_plan != previous_plan)
|
| 895 |
-
has_content = bool(
|
| 896 |
-
|
| 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",
|
|
|
|
|
|
|
| 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",
|
|
|
|
|
|
|
| 952 |
)
|
| 953 |
previous_plan = current_plan
|
| 954 |
|
| 955 |
-
if parts[
|
| 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[
|
| 962 |
```"""
|
| 963 |
yield gr.ChatMessage(
|
| 964 |
-
role="assistant",
|
|
|
|
|
|
|
| 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[
|
| 973 |
-
truncated = self.truncate_output(parts[
|
| 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",
|
|
|
|
|
|
|
| 983 |
)
|
| 984 |
|
| 985 |
-
all_artifacts = self._extract_artifacts(parts[
|
| 986 |
for desc, path in all_artifacts:
|
| 987 |
-
if path.endswith(
|
| 988 |
-
with open(path,
|
| 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[
|
| 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[
|
| 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",
|
|
|
|
|
|
|
| 1052 |
)
|
| 1053 |
|
| 1054 |
-
if parts[
|
| 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[
|
| 1062 |
|
| 1063 |
-
if parts[
|
| 1064 |
-
|
|
|
|
| 1065 |
yield gr.ChatMessage(
|
| 1066 |
-
role="assistant",
|
|
|
|
|
|
|
| 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",
|
|
|
|
|
|
|
| 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(
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1123 |
|
| 1124 |
-
def interact_with_agent(
|
| 1125 |
-
|
| 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 |
-
|
| 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 |
-
|
| 1280 |
-
|
| 1281 |
-
|
| 1282 |
-
|
| 1283 |
-
|
| 1284 |
-
|
| 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 |
-
|
| 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
|
| 1336 |
-
notice =
|
| 1337 |
-
|
| 1338 |
-
|
|
|
|
|
|
|
| 1339 |
)
|
| 1340 |
-
|
| 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 |
-
|
| 1364 |
-
log_blocks = self._log_blocks(raw_messages, current_run)
|
| 1365 |
if log_blocks:
|
| 1366 |
-
|
| 1367 |
-
|
| 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 |
-
|
| 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,
|
| 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(
|
| 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 |
-
|
| 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"),
|
|
|
|
| 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
|
| 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",
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 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 |
-
|
| 1705 |
-
)
|
| 1706 |
-
|
| 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,
|
| 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 +=
|
| 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 |
-
|
| 1754 |
-
|
| 1755 |
-
session_state["
|
| 1756 |
-
|
| 1757 |
-
|
| 1758 |
-
|
| 1759 |
-
|
| 1760 |
-
|
| 1761 |
-
role="assistant", content=notice, metadata={"status": "done"}
|
| 1762 |
-
)
|
| 1763 |
)
|
|
|
|
| 1764 |
yield chatbot
|
| 1765 |
|
| 1766 |
-
|
| 1767 |
-
|
| 1768 |
-
|
| 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,
|
| 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,
|
| 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 |
-
|
| 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(
|
| 1882 |
-
kwargs.setdefault(
|
| 1883 |
-
kwargs.setdefault(
|
| 1884 |
# Gradio 6.0 moved theme + css off the Blocks() constructor onto launch().
|
| 1885 |
-
kwargs.setdefault(
|
| 1886 |
-
kwargs.setdefault(
|
| 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###
|
| 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\
|
| 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/
|
| 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/
|
| 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==
|
| 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.
|
| 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.
|
| 296 |
# via
|
| 297 |
# -r requirements.in
|
| 298 |
# fastmcp
|
|
@@ -399,9 +394,8 @@ patsy==1.0.2
|
|
| 399 |
# via
|
| 400 |
# scanpy
|
| 401 |
# statsmodels
|
| 402 |
-
pillow==
|
| 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.
|
| 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] =
|
|
|
|
|
|
|
| 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
|
| 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,
|
|
|
|
| 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
|
| 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 |
-
|
| 140 |
-
|
| 141 |
-
|
| 142 |
-
|
| 143 |
-
|
| 144 |
-
|
| 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 |
-
|
| 154 |
-
|
| 155 |
-
|
| 156 |
-
|
| 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 |
-
|
| 174 |
-
|
| 175 |
-
|
| 176 |
-
|
| 177 |
-
|
| 178 |
-
|
| 179 |
-
|
| 180 |
-
|
| 181 |
-
|
| 182 |
-
|
| 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 |
-
|
| 217 |
-
|
| 218 |
-
|
| 219 |
-
|
| 220 |
-
|
| 221 |
-
|
| 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 |
-
|
| 231 |
-
|
| 232 |
-
|
| 233 |
-
|
| 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 |
-
|
| 264 |
-
"
|
| 265 |
-
|
| 266 |
-
|
| 267 |
-
|
| 268 |
-
|
| 269 |
-
|
| 270 |
-
|
| 271 |
-
|
| 272 |
-
|
| 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(
|
| 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=
|
| 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=
|
| 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=
|
| 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=
|
| 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 |
-
|
| 99 |
-
|
| 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=
|
| 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 |
-
|
| 111 |
-
|
| 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 |
-
|
| 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 |
-
|
| 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 |
-
|
| 364 |
-
|
| 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 |
-
|
| 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 |
-
|
| 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 |
|