avoigt1121 commited on
Commit
af3e3ac
·
2 Parent(s): 96e5b8717acd0c

merge: feat/phase1-knowledge-specialist-s2 — the five knowledge endpoints (drugs_for_targets, trials_for_genes, annotate_genes, pathways_for_genes, targets_for_disease), gradio_ui with the machine gate, frozen descriptor; 11 cassettes recorded live (Phase 0 Q1-Q9 replay), 8 pinned schemas, loaded set 12 -> 8; D5-A pillow acceptance until 2026-11-20; 238 passed; scan clean

Browse files
Files changed (47) hide show
  1. .gitleaksignore +3 -0
  2. README.md +10 -9
  3. config/tools_allowlist.yaml +14 -12
  4. deploy/orchestrator_registration.yaml +83 -0
  5. docs/plans/PLAN-phase1-knowledge-specialist.md +21 -0
  6. gradio_ui.py +317 -0
  7. scripts/record_fixtures.py +195 -0
  8. scripts/security_scan.sh +18 -1
  9. security/ACCEPTED-FINDINGS.md +8 -1
  10. security/accepted-vulns.txt +13 -0
  11. src/endpoints/__init__.py +30 -0
  12. src/endpoints/_common.py +247 -0
  13. src/endpoints/annotate.py +179 -0
  14. src/endpoints/drugs.py +185 -0
  15. src/endpoints/pathways.py +124 -0
  16. src/endpoints/targets.py +136 -0
  17. src/endpoints/trials.py +149 -0
  18. src/identifiers.py +19 -8
  19. src/schemas/ChEMBL_get_drug_mechanisms.schema.json +30 -0
  20. src/schemas/ClinicalTrials_search_studies.schema.json +67 -0
  21. src/schemas/HGNC_fetch_gene_by_symbol.schema.json +37 -0
  22. src/schemas/OpenTargets_get_associated_drugs_by_target_ensemblID.schema.json +5 -3
  23. src/schemas/OpenTargets_get_associated_targets_by_disease_efoId.schema.json +75 -0
  24. src/schemas/OpenTargets_get_disease_id_description_by_name.schema.json +37 -0
  25. src/schemas/Reactome_map_uniprot_to_pathways.schema.json +25 -0
  26. src/schemas/UniProt_search.schema.json +40 -0
  27. src/tu_client.py +13 -6
  28. tests/conftest.py +96 -0
  29. tests/fixtures/cassettes/c1_annotate_kras_tp53_sord2p.json +1025 -0
  30. tests/fixtures/cassettes/edge_legacy_efo.json +32 -0
  31. tests/fixtures/cassettes/edge_unknown_symbol.json +37 -0
  32. tests/fixtures/cassettes/q1_q2_drugs_kras.json +605 -0
  33. tests/fixtures/cassettes/q3_annotate_elavl1.json +345 -0
  34. tests/fixtures/cassettes/q4_annotate_porg.json +0 -0
  35. tests/fixtures/cassettes/q5_trials_cd40.json +90 -0
  36. tests/fixtures/cassettes/q6_drugs_jak_stat.json +0 -0
  37. tests/fixtures/cassettes/q7_drugs_tnfrsf10b.json +865 -0
  38. tests/fixtures/cassettes/q8_targets_pdac.json +262 -0
  39. tests/fixtures/cassettes/q9_pathways_isg15_rsad2_oas1.json +578 -0
  40. tests/test_accepted_vulns.py +51 -0
  41. tests/test_access_gate.py +74 -0
  42. tests/test_allowlist.py +2 -2
  43. tests/test_deploy_surface.py +88 -0
  44. tests/test_endpoint_provenance.py +75 -0
  45. tests/test_output_schemas.py +21 -17
  46. tests/test_phase0_replay.py +135 -0
  47. tests/test_refusals_and_payloads.py +220 -0
.gitleaksignore ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ # Exact-fingerprint acceptances of verified gitleaks false positives (security/ACCEPTED-FINDINGS.md §1).
2
+ # One finding per line; never a path or rule wildcard.
3
+ 97e7d80cbcef56eaa6e8deec780a1b7b27532a30:security/ACCEPTED-FINDINGS.md:generic-api-key:13
README.md CHANGED
@@ -19,18 +19,18 @@ analysis**: it reads no dataset and computes no statistic. Built per
19
  [ADR-0001](docs/adr/ADR-0001-knowledge-specialist-contract.md) on a pinned, allow-listed subset
20
  of [ToolUniverse](https://github.com/mims-harvard/ToolUniverse) (Apache-2.0).
21
 
22
- > **Status: Phase 1, slice s1 — the governed core only.** The endpoints, the Gradio app and the
23
- > deploy descriptor arrive in s2; there is no Space yet. See
24
- > [the plan](docs/plans/PLAN-phase1-knowledge-specialist.md).
25
 
26
- ## What it will answer (s2)
27
 
28
  | Endpoint | What | Sources |
29
  |---|---|---|
30
- | `drugs_for_targets` | Drugs and clinical candidates per target, with mechanism and maximum clinical stage | HGNC → Open Targets; ChEMBL |
31
- | `trials_for_genes` | ClinicalTrials.gov studies whose intervention matches the gene | ClinicalTrials.gov |
32
- | `annotate_genes` | Approved name, UniProt accession, protein name, function, subcellular location | HGNC, UniProt |
33
- | `pathways_for_genes` | Reactome pathways each gene belongs to (membership, not enrichment) | UniProt → Reactome |
34
  | `targets_for_disease` | Top associated targets, with Open Targets' own association score | Open Targets |
35
 
36
  Which TFs or pathways are active, which genes are differentially expressed and which gene sets
@@ -62,7 +62,8 @@ are enriched in a dataset are DecoupleRpy's questions; the literature corpus is
62
 
63
  ```bash
64
  uv venv --python 3.12 .venv && uv pip install -r requirements-dev.txt
65
- python -m pytest # hermetic: outbound sockets are blocked
 
66
  make security-scan # ADR-0014: pip-audit, bandit, gitleaks (trivy N/A)
67
  make install-hooks # the pre-push security gate, once per clone
68
  ```
 
19
  [ADR-0001](docs/adr/ADR-0001-knowledge-specialist-contract.md) on a pinned, allow-listed subset
20
  of [ToolUniverse](https://github.com/mims-harvard/ToolUniverse) (Apache-2.0).
21
 
22
+ > **Status: Phase 1, slice s2 — the five endpoints, the Gradio app and the frozen deploy
23
+ > descriptor, replayed against the Phase 0 lookups recorded live.** There is no Space yet (s3).
24
+ > See [the plan](docs/plans/PLAN-phase1-knowledge-specialist.md).
25
 
26
+ ## What it answers
27
 
28
  | Endpoint | What | Sources |
29
  |---|---|---|
30
+ | `drugs_for_targets` | Drugs and clinical candidates per target, with maximum clinical stage; ChEMBL mechanisms for the 30 most advanced | HGNC → Open Targets; ChEMBL |
31
+ | `trials_for_genes` | ClinicalTrials.gov studies whose intervention name matches the gene, with the exact query run | ClinicalTrials.gov |
32
+ | `annotate_genes` | Approved name, locus type, UniProt accession, protein name, function, subcellular location | HGNC, UniProt |
33
+ | `pathways_for_genes` | Reactome lowest-level pathways each gene belongs to, and those all share (membership, not enrichment) | HGNC → Reactome |
34
  | `targets_for_disease` | Top associated targets, with Open Targets' own association score | Open Targets |
35
 
36
  Which TFs or pathways are active, which genes are differentially expressed and which gene sets
 
62
 
63
  ```bash
64
  uv venv --python 3.12 .venv && uv pip install -r requirements-dev.txt
65
+ python -m pytest # hermetic: outbound sockets are blocked; recorded cassettes
66
+ python scripts/record_fixtures.py # the ONLY live step: re-record cassettes (review the diff)
67
  make security-scan # ADR-0014: pip-audit, bandit, gitleaks (trivy N/A)
68
  make install-hooks # the pre-push security gate, once per clone
69
  ```
config/tools_allowlist.yaml CHANGED
@@ -12,8 +12,10 @@
12
  # and why. Never add an agentic/LLM-backed tool, the tool finder, a remote MCP server, a
13
  # web-search backend or a key-gated tool — tests/test_allowlist.py refuses each of those.
14
  #
15
- # The five endpoints are built in s2; their tool sets are fixed here so the loaded surface is
16
- # reviewable before any endpoint code exists.
 
 
17
 
18
  tooluniverse_version: "1.5.6"
19
 
@@ -65,20 +67,20 @@ tools:
65
  # --- UniProt (4) ---
66
  - name: UniProt_search
67
  source: uniprot
68
- endpoints: [annotate_genes, pathways_for_genes]
69
  review: "memo 2026-10-12, approved 2026-10-13 — symbol to reviewed human accession (Phase 0 Q4)"
70
  - name: UniProt_get_function_by_accession
71
  source: uniprot
72
- endpoints: [annotate_genes]
73
- review: "memo 2026-10-12, approved 2026-10-13 — function (Phase 0 Q3)"
74
  - name: UniProt_get_recommended_name_by_accession
75
  source: uniprot
76
- endpoints: [annotate_genes]
77
- review: "memo 2026-10-12, approved 2026-10-13 — recommended protein name"
78
  - name: UniProt_get_subcellular_location_by_accession
79
  source: uniprot
80
- endpoints: [annotate_genes]
81
- review: "memo 2026-10-12, approved 2026-10-13 — subcellular location"
82
 
83
  # --- ClinicalTrials.gov (3) ---
84
  - name: ClinicalTrials_search_studies
@@ -119,8 +121,8 @@ tools:
119
  review: "memo 2026-10-12, approved 2026-10-13 — one pathway"
120
  - name: Reactome_get_pathways_low_entity
121
  source: reactome
122
- endpoints: [pathways_for_genes]
123
- review: "memo 2026-10-12, approved 2026-10-13 — lowest-level pathways for an entity (ADR-0001 §4)"
124
 
125
  # --- DGIdb (3) ---
126
  - name: DGIdb_get_drug_gene_interactions
@@ -139,7 +141,7 @@ tools:
139
  # --- HGNC (2) ---
140
  - name: HGNC_fetch_gene_by_symbol
141
  source: hgnc
142
- endpoints: [drugs_for_targets, annotate_genes]
143
  review: "memo 2026-10-12, approved 2026-10-13 — approved symbol, name and Ensembl id"
144
  - name: HGNC_search_genes
145
  source: hgnc
 
12
  # and why. Never add an agentic/LLM-backed tool, the tool finder, a remote MCP server, a
13
  # web-search backend or a key-gated tool — tests/test_allowlist.py refuses each of those.
14
  #
15
+ # s2 (2026-10-20) narrowed the loaded set from 12 to 8 once the real responses were seen: HGNC
16
+ # returns each gene's Ensembl id and UniProt accession, one batched UniProt_search returns name,
17
+ # function and location for up to 100 accessions, and Reactome's UniProt mapping already returns
18
+ # the lowest-level pathways. The four tools no longer used stay approved (`endpoints: []`).
19
 
20
  tooluniverse_version: "1.5.6"
21
 
 
67
  # --- UniProt (4) ---
68
  - name: UniProt_search
69
  source: uniprot
70
+ endpoints: [annotate_genes]
71
  review: "memo 2026-10-12, approved 2026-10-13 — symbol to reviewed human accession (Phase 0 Q4)"
72
  - name: UniProt_get_function_by_accession
73
  source: uniprot
74
+ endpoints: []
75
+ review: "memo 2026-10-12, approved 2026-10-13 — function (Phase 0 Q3); s2 2026-10-20: not loaded, one batched UniProt_search returns this field for every gene"
76
  - name: UniProt_get_recommended_name_by_accession
77
  source: uniprot
78
+ endpoints: []
79
+ review: "memo 2026-10-12, approved 2026-10-13 — recommended protein name; s2 2026-10-20: not loaded, one batched UniProt_search returns this field for every gene"
80
  - name: UniProt_get_subcellular_location_by_accession
81
  source: uniprot
82
+ endpoints: []
83
+ review: "memo 2026-10-12, approved 2026-10-13 — subcellular location; s2 2026-10-20: not loaded, one batched UniProt_search returns this field for every gene"
84
 
85
  # --- ClinicalTrials.gov (3) ---
86
  - name: ClinicalTrials_search_studies
 
121
  review: "memo 2026-10-12, approved 2026-10-13 — one pathway"
122
  - name: Reactome_get_pathways_low_entity
123
  source: reactome
124
+ endpoints: []
125
+ review: "memo 2026-10-12, approved 2026-10-13 — lowest-level pathways for an entity (ADR-0001 §4); s2 2026-10-20: not loaded, Reactome_map_uniprot_to_pathways already returns the lowest-level pathways"
126
 
127
  # --- DGIdb (3) ---
128
  - name: DGIdb_get_drug_gene_interactions
 
141
  # --- HGNC (2) ---
142
  - name: HGNC_fetch_gene_by_symbol
143
  source: hgnc
144
+ endpoints: [drugs_for_targets, annotate_genes, pathways_for_genes]
145
  review: "memo 2026-10-12, approved 2026-10-13 — approved symbol, name and Ensembl id"
146
  - name: HGNC_search_genes
147
  source: hgnc
deploy/orchestrator_registration.yaml ADDED
@@ -0,0 +1,83 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Registration descriptor for pdac-analysis-orchestrator's agents.yaml.
2
+ # Endpoint names and parameter order are FROZEN (ADR-0001 §4) — tests/test_deploy_surface.py
3
+ # cross-checks them against the gr.api surface and src/endpoints.ENDPOINTS. The dev twin is NOT
4
+ # a registration target; dev routing is the orchestrator's HF_SPACE_PDAC_KNOWLEDGE_AGENT override.
5
+
6
+ id: pdac_knowledge_agent
7
+ name: "PDAC Knowledge Agent"
8
+ dispatch_kind: tool_server
9
+ space: "anne-voigt/pdac-knowledge-agent"
10
+ space_dev: "anne-voigt/pdac-knowledge-agent-dev"
11
+ # The boundary sentence, verbatim from ADR-0001 §3 (also the routing prompt's).
12
+ description: >
13
+ Looks up what public knowledge bases say about named genes, drugs, diseases and trials
14
+ (Open Targets, ChEMBL, UniProt, ClinicalTrials.gov, Reactome, HGNC). It returns annotation,
15
+ never analysis: it reads no dataset and computes no statistic. Which genes are differentially
16
+ expressed, which TFs or pathways are active, and which gene sets are enriched in a dataset are
17
+ DecoupleRpy's questions. The literature corpus belongs to lit-agent.
18
+
19
+ # Payload contract: JSON string; status ∈ {ok, refused, error, denied} + reason/message on non-ok;
20
+ # run_id on ok/refused/error (denials mint none). Every ok payload has kind: "annotation" and NO
21
+ # hierarchy or n_for_inference — a row count is never a sample size. Every row carries a
22
+ # provenance block {source, source_tool, tooluniverse_version, query, source_ref, source_url,
23
+ # retrieved_at, cache, licence, licence_url}; nested blocks (mechanisms, protein, disease) carry
24
+ # their own. Also on every ok payload: unresolved[], source_errors[] (reason ∈
25
+ # source_unavailable | rate_limited | upstream_schema_drift), coverage_notes[], attribution[],
26
+ # a deterministic narrative ("According to <source> (queried <date>) …") and a disclaimer.
27
+ # RELAY the narrative and coverage notes; never add a drug, trial or gene the rows do not hold.
28
+ # Inputs are identifiers only (ADR-0001 §6): free text, sample ids and numbers are refused as
29
+ # invalid_identifier, more than 100 identifiers as too_many_identifiers.
30
+ # Machine auth: x-orchestrator-token header.
31
+ tools:
32
+ - name: drugs_for_targets
33
+ api_name: "/drugs_for_targets"
34
+ params: [genes]
35
+ description: >
36
+ Drugs and clinical candidates per target, from Open Targets, each with its maximum clinical
37
+ stage and indications, plus ChEMBL's mechanisms of action for the 30 most advanced drugs in
38
+ the call. `genes` is a comma-separated list of HGNC symbols or Ensembl gene ids (at most
39
+ 100). Open Targets lists drugs with trial or approval evidence; an investigational compound
40
+ with no trial record (for example MRTX1133 against KRAS G12D) can be absent — the
41
+ coverage note says so, relay it rather than filling the gap from memory.
42
+ - name: trials_for_genes
43
+ api_name: "/trials_for_genes"
44
+ params: [genes, condition, status]
45
+ description: >
46
+ ClinicalTrials.gov studies whose INTERVENTION NAME matches each gene symbol, for a
47
+ condition (default 'pancreatic cancer') and recruitment status (default RECRUITING; also
48
+ NOT_YET_RECRUITING, ACTIVE_NOT_RECRUITING, ENROLLING_BY_INVITATION, COMPLETED, ANY). The
49
+ payload's query_semantics and executed_queries state exactly what was searched: a name
50
+ match is not evidence that the intervention acts on the gene's product, and studies that
51
+ mention the gene elsewhere are not listed. For the weekly digest's trials, lit-agent keeps
52
+ its own harvester.
53
+ - name: annotate_genes
54
+ api_name: "/annotate_genes"
55
+ params: [genes]
56
+ timeout_seconds: 300 # 55 genes: one HGNC call each at 3/s, then one batched UniProt call
57
+ description: >
58
+ What HGNC and UniProt say each gene and its protein are: approved name, locus type,
59
+ Ensembl id, and from the reviewed (Swiss-Prot) human UniProt entry the recommended protein
60
+ name, function text and subcellular location. `genes` is a comma-separated list of HGNC
61
+ symbols (at most 100). A gene with no reviewed entry (a pseudogene such as SORD2P) keeps
62
+ its HGNC row with protein null and is listed in unresolved — never describe it from
63
+ anything else.
64
+ - name: pathways_for_genes
65
+ api_name: "/pathways_for_genes"
66
+ params: [genes]
67
+ description: >
68
+ Reactome lowest-level pathways each gene's protein belongs to, plus shared_by_all (the
69
+ pathways every resolved gene belongs to). MEMBERSHIP, NOT ENRICHMENT: no statistic is
70
+ computed over the list, so never call a pathway enriched, overrepresented or significant
71
+ from this payload — that question is DecoupleRpy's. `genes` is a comma-separated list of
72
+ HGNC symbols or UniProt accessions (at most 100).
73
+ - name: targets_for_disease
74
+ api_name: "/targets_for_disease"
75
+ params: [disease, size]
76
+ description: >
77
+ The targets Open Targets most associates with a disease, ranked by Open Targets' own
78
+ association score (relayed as its score; this agent ranks nothing). `disease` is a name or
79
+ a MONDO/EFO/Orphanet id — Open Targets has moved most ids to MONDO (pancreatic ductal
80
+ adenocarcinoma is MONDO_0005184; the legacy EFO_0002618 no longer resolves and is refused
81
+ with Open Targets' message). A name resolves to an exact match if there is one, else
82
+ Open Targets' first hit, and the payload's disease block names the id chosen and the
83
+ alternatives. `size` is 1-50 (default 25).
docs/plans/PLAN-phase1-knowledge-specialist.md CHANGED
@@ -133,6 +133,27 @@ The memo settled the approach. The only real choice left is where the endpoints
133
  - **Blocks:** s1 (and through it every slice).
134
  - **Status:** DECIDED 2026-10-20 (project lead): A — start s1 now.
135
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
136
  **Two-way defaults** (DECIDED by default; overrule in one line):
137
  - The plan and ADR-0001 stay here until s1 moves them into the new repo.
138
  - **No orchestrator ADR number is claimed in Phase 1.** The knowledge-join ADR (annotation inside chains, the recipe
 
133
  - **Blocks:** s1 (and through it every slice).
134
  - **Status:** DECIDED 2026-10-20 (project lead): A — start s1 now.
135
 
136
+ ### Decision D5: what to do about the pillow 12.3.0 CVEs that block the security gate (raised by s2)
137
+ - **Door:** one-way. Accepting a known CVE, or moving gradio, is a security posture call, and it is
138
+ fleet-wide: gradio 6.28.0 requires `pillow<13`, every fleet repo's `dev` pins `pillow==12.3.0`, and
139
+ 8 advisories published 2026-10-20/21 are fixed only in 13.0.0. Every pre-push gate in the fleet
140
+ now fails on them.
141
+ - **Exposure here:** all eight concern parsing attacker-supplied image files (PDF append, GIF,
142
+ PPM/PGM, TIFF, EPS). This Space accepts identifiers only and has no image or file input.
143
+ - **Options:**
144
+ - A) a justified acceptance in this repo's `security/ACCEPTED-FINDINGS.md`, scoped to these eight
145
+ ids and expiring when a gradio release allows `pillow>=13` (or on 2026-11-20, whichever is
146
+ first), with the fleet-wide question triaged separately;
147
+ - B) no acceptance: s3 (the first push) waits for a gradio release that allows pillow 13 and a
148
+ fleet-wide bump;
149
+ - C) force `pillow==13.0.0` against gradio's declared constraint (unsupported).
150
+ - **Recommend:** A, for this repo.
151
+ - **Blocks:** the s2 merge gate ("security scan green") and s3's first push. Everything else in s2
152
+ is done and green.
153
+ - **Status:** DECIDED 2026-10-20 (project lead): A — accepted in this repo only, expiring 2026-11-20 or
154
+ when gradio allows pillow 13 (`security/accepted-vulns.txt`, `tests/test_accepted_vulns.py`). The
155
+ fleet-wide question stays open outside this plan.
156
+
157
  **Two-way defaults** (DECIDED by default; overrule in one line):
158
  - The plan and ADR-0001 stay here until s1 moves them into the new repo.
159
  - **No orchestrator ADR number is claimed in Phase 1.** The knowledge-join ADR (annotation inside chains, the recipe
gradio_ui.py ADDED
@@ -0,0 +1,317 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """PDAC Knowledge Agent — Gradio app: UI + machine endpoints (tool_server contract).
2
+
3
+ Three surfaces over the five endpoint functions in ``src.endpoints`` (the pdac-spatial-agent shape):
4
+
5
+ - **Machine endpoints** — ``gr.api(fn, api_name=...)``, plain-string in / JSON-string out.
6
+ Endpoint FUNCTION names equal the contract names (the gradio MCP bridge names tools after the
7
+ Python function while ``gradio_client`` dispatches on ``api_name`` — keep them identical).
8
+ Names and parameter order are frozen by ``deploy/orchestrator_registration.yaml``.
9
+ - **MCP** — ``demo.launch(mcp_server=True)``.
10
+ - **UI handlers** — ``_ui_*`` with ``api_name=False``. NOTE: ``api_name=False`` is NOT a
11
+ security boundary; every handler, UI and machine alike, runs the access gate itself.
12
+
13
+ Every payload has ``status`` in {ok, refused, error, denied} + ``reason`` on non-ok. Gate first,
14
+ then argument validation: an unauthenticated caller learns nothing about our inputs.
15
+
16
+ The allow-listed ToolUniverse is built when this module is imported (ADR-0001 §7), so a Space
17
+ with a model-provider key, a drifted version or a widened tool set does not start at all.
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ import json
23
+ import time
24
+
25
+ import gradio as gr
26
+
27
+ from access import check_access
28
+ from audit import record_run
29
+ from src.endpoints import ENDPOINTS, _common
30
+ from src.tu_client import ToolClient, build_tooluniverse
31
+
32
+ _common.set_client(ToolClient(tooluniverse=build_tooluniverse()))
33
+
34
+ # --------------------------------------------------------------------------- #
35
+ # Machine-caller identity (x-orchestrator-token → whoami), copied from pdac-spatial-agent.
36
+ # --------------------------------------------------------------------------- #
37
+ # HF edge infra strips the reserved "x-hf-*" namespace, so only the custom header survives in
38
+ # a deployed Space; both are accepted for local/dev callers.
39
+ _MACHINE_TOKEN_HEADERS = ("x-orchestrator-token", "x-hf-authorization")
40
+
41
+ _WHOAMI_TTL_S = 15 * 60
42
+ _WHOAMI_NEG_TTL_S = 60
43
+ _WHOAMI_STALE_OK_S = 24 * 60 * 60
44
+ _WHOAMI_CACHE: dict[str, tuple[str | None, float]] = {}
45
+
46
+ _MACHINE_DENIAL_REASONS = {
47
+ "no_token_header": (
48
+ "🔒 No machine credential presented: neither 'x-orchestrator-token' nor "
49
+ "'x-hf-authorization' reached this Space. The caller is misconfigured — the "
50
+ "orchestrator attaches that header only when its own HF_TOKEN is set, so "
51
+ "check that secret on the CALLING Space."
52
+ ),
53
+ "token_unresolved": (
54
+ "🔒 A machine credential was presented but could not be resolved to a "
55
+ "HuggingFace identity — invalid/expired token, or an upstream lookup "
56
+ "failure. Fail-closed by design; retry once before treating as a rejection."
57
+ ),
58
+ "not_allowlisted": None, # check_access already says who was refused and why
59
+ }
60
+
61
+
62
+ def _header_value(headers, name: str) -> str | None:
63
+ if not headers:
64
+ return None
65
+ getter = getattr(headers, "get", None)
66
+ return getter(name) if getter else None
67
+
68
+
69
+ def _resolve_token_identity(token: str) -> str | None:
70
+ """HF token → username via whoami; TTL-cached, stale-if-error."""
71
+ now = time.monotonic()
72
+ entry = _WHOAMI_CACHE.get(token)
73
+ if entry is not None:
74
+ identity, stamp = entry
75
+ ttl = _WHOAMI_TTL_S if identity is not None else _WHOAMI_NEG_TTL_S
76
+ if now - stamp < ttl:
77
+ return identity
78
+ try:
79
+ from huggingface_hub import HfApi
80
+ from huggingface_hub.utils import HfHubHTTPError
81
+
82
+ try:
83
+ identity = HfApi().whoami(token=token).get("name")
84
+ except HfHubHTTPError:
85
+ _WHOAMI_CACHE[token] = (None, now) # definitive: the token is bad
86
+ return None
87
+ except Exception: # noqa: BLE001 — transient failure: stale-if-error, else deny
88
+ if entry is not None:
89
+ identity, stamp = entry
90
+ if identity is not None and now - stamp < _WHOAMI_STALE_OK_S:
91
+ return identity
92
+ return None
93
+ _WHOAMI_CACHE[token] = (identity, now)
94
+ return identity
95
+
96
+
97
+ def _machine_caller_identity(request) -> tuple[str | None, str]:
98
+ headers = getattr(request, "headers", None) if request else None
99
+ presented = False
100
+ for header in _MACHINE_TOKEN_HEADERS:
101
+ raw = _header_value(headers, header)
102
+ if not raw:
103
+ continue
104
+ token = raw[7:].strip() if raw.lower().startswith("bearer ") else raw.strip()
105
+ if token:
106
+ presented = True
107
+ identity = _resolve_token_identity(token)
108
+ if identity:
109
+ return identity, "resolved"
110
+ return None, ("token_unresolved" if presented else "no_token_header")
111
+
112
+
113
+ def _machine_gate(tool: str, request) -> tuple[str | None, str | None]:
114
+ """(identity, denial_json). A non-None denial is returned to the caller verbatim."""
115
+ username, diagnosis = _machine_caller_identity(request)
116
+ allowed, denial = check_access(username)
117
+ if not allowed:
118
+ if username:
119
+ diagnosis = "not_allowlisted"
120
+ record_run(tool, username=username, n_identifiers=None, status="denied")
121
+ return username, json.dumps(
122
+ {
123
+ "status": "denied",
124
+ "reason": _MACHINE_DENIAL_REASONS.get(diagnosis) or denial,
125
+ "machine_auth": diagnosis,
126
+ },
127
+ indent=2,
128
+ )
129
+ return username, None
130
+
131
+
132
+ def _machine_input_errors(**fields) -> dict | None:
133
+ """Typed error for a non-string machine argument. Deliberately no coercion — the published
134
+ schema declares `str`, and quietly accepting a second shape means the published contract and
135
+ the real one disagree."""
136
+ for field, value in fields.items():
137
+ if value is None or isinstance(value, str):
138
+ continue
139
+ got = type(value).__name__
140
+ return {
141
+ "status": "error",
142
+ "machine_input": f"{field}_not_a_string",
143
+ "reason": (
144
+ f"Machine endpoints take plain strings; `{field}` arrived as a {got}. "
145
+ "Pass a comma-separated string instead. This is a caller-side argument "
146
+ "error — the call was not run."
147
+ ),
148
+ }
149
+ return None
150
+
151
+
152
+ def _safe_result(fn):
153
+ """Any escaped exception becomes an error dict — the contract never raises."""
154
+ try:
155
+ return fn()
156
+ except Exception as exc: # noqa: BLE001
157
+ return {"status": "error", "reason": f"{type(exc).__name__}: {exc}"}
158
+
159
+
160
+ def _as_json(result) -> str:
161
+ return json.dumps(result, indent=2, default=str, ensure_ascii=False)
162
+
163
+
164
+ def _n_identifiers(result: dict) -> int | None:
165
+ query = result.get("query") or {}
166
+ genes = query.get("genes")
167
+ if isinstance(genes, list):
168
+ return len(genes)
169
+ return 1 if query.get("disease") else None
170
+
171
+
172
+ def _run(tool_name: str, request, **string_fields) -> str:
173
+ """The shared machine-endpoint shape: gate → typed inputs → endpoint → audit → JSON."""
174
+ username, denial = _machine_gate(tool_name, request)
175
+ if denial is not None:
176
+ return denial
177
+ bad = _machine_input_errors(**string_fields)
178
+ if bad is not None:
179
+ record_run(tool_name, username=username, n_identifiers=None, status="error")
180
+ return _as_json(bad)
181
+ fn, params = ENDPOINTS[tool_name]
182
+ result = _safe_result(lambda: fn(*[string_fields[p] or "" for p in params]))
183
+ record_run(
184
+ tool_name,
185
+ username=username,
186
+ n_identifiers=_n_identifiers(result),
187
+ status=str(result.get("status", "error")),
188
+ run_id=result.get("run_id"),
189
+ )
190
+ return _as_json(result)
191
+
192
+
193
+ # --------------------------------------------------------------------------- #
194
+ # Machine endpoints (string in / JSON string out). Names are FROZEN (ADR-0001 §4).
195
+ # --------------------------------------------------------------------------- #
196
+ def drugs_for_targets(genes: str = "", request: gr.Request | None = None) -> str:
197
+ """Drugs and clinical candidates per target (Open Targets), with ChEMBL mechanisms."""
198
+ return _run("drugs_for_targets", request, genes=genes)
199
+
200
+
201
+ def trials_for_genes(
202
+ genes: str = "", condition: str = "", status: str = "", request: gr.Request | None = None
203
+ ) -> str:
204
+ """ClinicalTrials.gov studies whose intervention name matches each gene."""
205
+ return _run("trials_for_genes", request, genes=genes, condition=condition, status=status)
206
+
207
+
208
+ def annotate_genes(genes: str = "", request: gr.Request | None = None) -> str:
209
+ """HGNC and reviewed UniProt annotation per gene: name, function, location."""
210
+ return _run("annotate_genes", request, genes=genes)
211
+
212
+
213
+ def pathways_for_genes(genes: str = "", request: gr.Request | None = None) -> str:
214
+ """Reactome lowest-level pathway membership per gene (membership, not enrichment)."""
215
+ return _run("pathways_for_genes", request, genes=genes)
216
+
217
+
218
+ def targets_for_disease(
219
+ disease: str = "", size: str = "", request: gr.Request | None = None
220
+ ) -> str:
221
+ """Top targets Open Targets associates with a disease, by its own association score."""
222
+ return _run("targets_for_disease", request, disease=disease, size=size)
223
+
224
+
225
+ # --------------------------------------------------------------------------- #
226
+ # UI — modest by design; the orchestrator is the primary caller.
227
+ # --------------------------------------------------------------------------- #
228
+ def _username(profile) -> str | None:
229
+ return getattr(profile, "username", None) if profile else None
230
+
231
+
232
+ def _ui_call(tool_name: str):
233
+ def handler(*args, profile: gr.OAuthProfile | None = None):
234
+ allowed, denial = check_access(_username(profile))
235
+ if not allowed:
236
+ return f"⛔ **Access denied.** {denial}", _as_json(
237
+ {"status": "denied", "reason": denial}
238
+ )
239
+ fn, params = ENDPOINTS[tool_name]
240
+ values = [a if isinstance(a, str) else "" for a in args][: len(params)]
241
+ result = _safe_result(lambda: fn(*values))
242
+ record_run(
243
+ f"{tool_name}:ui",
244
+ username=_username(profile),
245
+ n_identifiers=_n_identifiers(result),
246
+ status=str(result.get("status", "error")),
247
+ run_id=result.get("run_id"),
248
+ )
249
+ status = result.get("status", "error")
250
+ summary = (
251
+ result.get("narrative", "")
252
+ if status == "ok"
253
+ else f"⚠️ **{status}.** {result.get('message') or result.get('reason', '')}"
254
+ )
255
+ return summary, _as_json(result)
256
+
257
+ return handler
258
+
259
+
260
+ with gr.Blocks(title="PDAC Knowledge Agent") as demo:
261
+ gr.Markdown(
262
+ "# PDAC Knowledge Agent\n"
263
+ "What public knowledge bases say about named genes, drugs, diseases and trials: "
264
+ "Open Targets, ChEMBL, UniProt, ClinicalTrials.gov, Reactome and HGNC. **Annotation, "
265
+ "never analysis**: nothing here reads a dataset or computes a statistic, and every row "
266
+ "names its source, query date and licence."
267
+ )
268
+ gr.LoginButton()
269
+
270
+ gr.api(drugs_for_targets, api_name="drugs_for_targets")
271
+ gr.api(trials_for_genes, api_name="trials_for_genes")
272
+ gr.api(annotate_genes, api_name="annotate_genes")
273
+ gr.api(pathways_for_genes, api_name="pathways_for_genes")
274
+ gr.api(targets_for_disease, api_name="targets_for_disease")
275
+
276
+ def _tab(label: str, tool_name: str, inputs: list):
277
+ button = gr.Button(f"Look up: {label}", variant="primary")
278
+ summary = gr.Markdown()
279
+ raw = gr.Code(language="json", label="Result (JSON, with provenance on every row)")
280
+ button.click(_ui_call(tool_name), inputs=inputs, outputs=[summary, raw], api_name=False)
281
+
282
+ with gr.Tab("Drugs for targets"):
283
+ _tab(
284
+ "drugs", "drugs_for_targets", [gr.Textbox(label="Genes (HGNC symbols or Ensembl ids)")]
285
+ )
286
+ with gr.Tab("Trials for genes"):
287
+ _tab(
288
+ "trials",
289
+ "trials_for_genes",
290
+ [
291
+ gr.Textbox(label="Genes (HGNC symbols)"),
292
+ gr.Textbox(label="Condition", value="pancreatic cancer"),
293
+ gr.Textbox(label="Status (RECRUITING, COMPLETED, ANY, …)", value="RECRUITING"),
294
+ ],
295
+ )
296
+ with gr.Tab("Annotate genes"):
297
+ _tab("annotation", "annotate_genes", [gr.Textbox(label="Genes (HGNC symbols)")])
298
+ with gr.Tab("Pathways for genes"):
299
+ _tab(
300
+ "pathways",
301
+ "pathways_for_genes",
302
+ [gr.Textbox(label="Genes (HGNC symbols or UniProt accessions)")],
303
+ )
304
+ with gr.Tab("Targets for a disease"):
305
+ _tab(
306
+ "targets",
307
+ "targets_for_disease",
308
+ [
309
+ gr.Textbox(
310
+ label="Disease (name or MONDO/EFO id)", value="pancreatic ductal adenocarcinoma"
311
+ ),
312
+ gr.Textbox(label="How many targets (1-50)", value="25"),
313
+ ],
314
+ )
315
+
316
+ if __name__ == "__main__":
317
+ demo.launch(mcp_server=True)
scripts/record_fixtures.py ADDED
@@ -0,0 +1,195 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Record the Phase 0 lookups as replay cassettes — the ONLY live-network step in this repo.
2
+
3
+ Run by hand from the repo root, keyless, when fixtures are first made or after a reviewed
4
+ ToolUniverse bump (ADR-0001 §7). It runs each scenario through the real endpoint code and the
5
+ real, allow-listed ToolUniverse, and stores every raw response the endpoint received, keyed by
6
+ tool and arguments, in ``tests/fixtures/cassettes/<scenario>.json``. The test suite replays
7
+ those responses with sockets blocked, so what the tests check is exactly what the sources said
8
+ on the recording day. Re-recording is a reviewed change: diff the cassettes before committing.
9
+
10
+ Schema validation is switched off while recording (the schemas are written FROM these
11
+ responses); replay validates every response against the pinned schemas.
12
+
13
+ python scripts/record_fixtures.py # every scenario
14
+ python scripts/record_fixtures.py q5_trials_cd40
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import datetime
20
+ import json
21
+ import sys
22
+ import time
23
+ from pathlib import Path
24
+
25
+ ROOT = Path(__file__).resolve().parent.parent
26
+ sys.path.insert(0, str(ROOT))
27
+
28
+ from src import config, schemas
29
+ from src.cache import ResponseCache
30
+ from src.endpoints import ENDPOINTS, _common
31
+ from src.tu_client import (
32
+ ToolClient,
33
+ build_tooluniverse,
34
+ installed_tooluniverse_version,
35
+ )
36
+
37
+ CASSETTES = ROOT / "tests" / "fixtures" / "cassettes"
38
+ # The 55 pORG genes, as pdac-subtype-agent ships them (src/resources/signatures/pORG.tsv,
39
+ # commit 2451c18) — the same list Phase 0's Q4 annotated.
40
+ PORG_SOURCE = "pdac-subtype-agent src/resources/signatures/pORG.tsv @ 2451c18"
41
+ PORG = [
42
+ "TNFSF18",
43
+ "CST2",
44
+ "RPRD1B",
45
+ "BARD1",
46
+ "SORD2P",
47
+ "ZNF217",
48
+ "CHMP4B",
49
+ "SLFN13",
50
+ "MMEL1",
51
+ "SPATA24",
52
+ "OSER1",
53
+ "PHACTR3",
54
+ "PKIB",
55
+ "ARHGAP12",
56
+ "RBM41",
57
+ "MPZL3",
58
+ "GDE1",
59
+ "FGD6",
60
+ "PLAAT2",
61
+ "STAU1",
62
+ "ZG16B",
63
+ "RIOK3",
64
+ "ALOX5",
65
+ "CDS1",
66
+ "PIK3CB",
67
+ "HPGD",
68
+ "HIST1H1T",
69
+ "GID8",
70
+ "NUDT21",
71
+ "PAK1",
72
+ "CYP4F3",
73
+ "ARL17A",
74
+ "PLAAT3",
75
+ "SRP9",
76
+ "ANKRD18B",
77
+ "CCL20",
78
+ "CFB",
79
+ "SPOPL",
80
+ "CTNNBL1",
81
+ "NFE2L3",
82
+ "ACSL5",
83
+ "CIAO2A",
84
+ "SCOC",
85
+ "SAA4",
86
+ "PPP1R3C",
87
+ "WFDC13",
88
+ "FUNDC1",
89
+ "ADNP",
90
+ "DLEU7",
91
+ "NME7",
92
+ "EFNB2",
93
+ "LRRIQ1",
94
+ "MAP3K2",
95
+ "LNPK",
96
+ "ZDHHC13",
97
+ ]
98
+
99
+ SCENARIOS = {
100
+ "q1_q2_drugs_kras": ("drugs_for_targets", ["KRAS"], "Phase 0 Q1 (KRAS G12D) and Q2 (G12C)"),
101
+ "q3_annotate_elavl1": ("annotate_genes", ["ELAVL1"], "Phase 0 Q3 (HuR function)"),
102
+ "q4_annotate_porg": ("annotate_genes", [",".join(PORG)], "Phase 0 Q4 (the 55 pORG genes)"),
103
+ "q5_trials_cd40": ("trials_for_genes", ["CD40", "", ""], "Phase 0 Q5 (recruiting CD40 trials)"),
104
+ "q6_drugs_jak_stat": ("drugs_for_targets", ["JAK1,JAK2,STAT1"], "Phase 0 Q6 (JAK1/JAK2/STAT1)"),
105
+ "q7_drugs_tnfrsf10b": ("drugs_for_targets", ["TNFRSF10B"], "Phase 0 Q7 (DR5)"),
106
+ "q8_targets_pdac": (
107
+ "targets_for_disease",
108
+ ["pancreatic ductal adenocarcinoma", ""],
109
+ "Phase 0 Q8 (top PDAC targets)",
110
+ ),
111
+ "q9_pathways_isg15_rsad2_oas1": (
112
+ "pathways_for_genes",
113
+ ["ISG15,RSAD2,OAS1"],
114
+ "Phase 0 Q9 (Interferon alpha/beta signaling)",
115
+ ),
116
+ "c1_annotate_kras_tp53_sord2p": (
117
+ "annotate_genes",
118
+ ["KRAS,TP53,SORD2P"],
119
+ "plan C1(c): SORD2P listed as unresolved, not guessed",
120
+ ),
121
+ "edge_unknown_symbol": ("drugs_for_targets", ["NOTAGENE1"], "a symbol HGNC does not know"),
122
+ "edge_legacy_efo": (
123
+ "targets_for_disease",
124
+ ["EFO_0002618", "5"],
125
+ "a legacy EFO id Open Targets dropped",
126
+ ),
127
+ }
128
+
129
+
130
+ class Recorder:
131
+ def __init__(self):
132
+ self.tu = build_tooluniverse()
133
+ self.calls: list[dict] = []
134
+
135
+ def __call__(self, tool: str, args: dict):
136
+ response = self.tu.run_one_function({"name": tool, "arguments": args}, use_cache=False)
137
+ self.calls.append({"tool": tool, "args": dict(args), "response": response})
138
+ return response
139
+
140
+
141
+ def record(name: str) -> Path:
142
+ endpoint, arguments, why = SCENARIOS[name]
143
+ recorder = Recorder()
144
+ client = ToolClient(runner=recorder, cache=ResponseCache(":memory:"))
145
+ _common.set_client(client)
146
+ started = time.time()
147
+ payload = ENDPOINTS[endpoint][0](*arguments)
148
+ used = sorted({config.source_of(c["tool"]) for c in recorder.calls})
149
+ cassette = {
150
+ "_recorded": {
151
+ "scenario": name,
152
+ "why": why,
153
+ "endpoint": endpoint,
154
+ "arguments": arguments,
155
+ "tooluniverse_version": installed_tooluniverse_version(),
156
+ "recorded_on": datetime.datetime.now(datetime.UTC).strftime("%Y-%m-%dT%H:%M:%SZ"),
157
+ "recorded_at_epoch": round(started, 3),
158
+ "recorded_with": "scripts/record_fixtures.py",
159
+ "payload_status_when_recorded": payload.get("status"),
160
+ "licences": {s: config.source(s)["licence"] for s in used},
161
+ **({"porg_source": PORG_SOURCE} if name == "q4_annotate_porg" else {}),
162
+ },
163
+ "calls": recorder.calls,
164
+ }
165
+ path = CASSETTES / f"{name}.json"
166
+ path.write_text(json.dumps(cassette, indent=1, ensure_ascii=False) + "\n", encoding="utf-8")
167
+ print(
168
+ f"{name}: {len(recorder.calls)} calls, payload {payload.get('status')}, "
169
+ f"{len(payload.get('rows') or [])} rows, {time.time() - started:.1f} s -> {path.name}"
170
+ )
171
+ return path
172
+
173
+
174
+ def _check_porg_against_the_subtype_agent() -> None:
175
+ """The list above was once typed from memory and was wrong; compare it to the source file."""
176
+ relative = Path("pdac-subtype-agent/src/resources/signatures/pORG.tsv")
177
+ for candidate in (ROOT.parent / relative, Path.home() / "Documents/GitHub" / relative):
178
+ if candidate.is_file():
179
+ listed = candidate.read_text().split()[1:]
180
+ assert listed == PORG, "PORG differs from pdac-subtype-agent's pORG.tsv"
181
+ return
182
+ raise SystemExit("pdac-subtype-agent is not checked out beside this repo; cannot check PORG")
183
+
184
+
185
+ def main(names: list[str]) -> None:
186
+ assert len(PORG) == 55, len(PORG)
187
+ _check_porg_against_the_subtype_agent()
188
+ CASSETTES.mkdir(parents=True, exist_ok=True)
189
+ schemas.validate = lambda tool, data: None # recording only: schemas are written from this
190
+ for name in names or list(SCENARIOS):
191
+ record(name)
192
+
193
+
194
+ if __name__ == "__main__":
195
+ main(sys.argv[1:])
scripts/security_scan.sh CHANGED
@@ -115,8 +115,25 @@ else
115
  echo " setting up audit venv ($PYBIN)"
116
  "$PYBIN" -m venv "$VENV" && "$VENV/bin/python" -m pip install -q --upgrade pip pip-audit
117
  fi
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
118
  "$VENV/bin/python" -m pip_audit --requirement "$TMP/audit.txt" --no-deps --disable-pip \
119
- --format columns 2>/dev/null | tee "$OUT/pip-audit-$STAMP.txt"
120
  AUDIT_RC=${PIPESTATUS[0]}
121
  if [[ "$AUDIT_RC" -eq 0 ]]; then
122
  ok "pip-audit — no known vulnerabilities in the resolved tree"
 
115
  echo " setting up audit venv ($PYBIN)"
116
  "$PYBIN" -m venv "$VENV" && "$VENV/bin/python" -m pip install -q --upgrade pip pip-audit
117
  fi
118
+ # Recorded, expiring acceptances (security/accepted-vulns.txt; justified in
119
+ # security/ACCEPTED-FINDINGS.md). An EXPIRED entry fails the scan: re-triage, never extend quietly.
120
+ IGNORES=()
121
+ if [[ -f security/accepted-vulns.txt ]]; then
122
+ TODAY="$(date -u +%Y-%m-%d)"
123
+ while read -r vid expires _rest; do
124
+ [[ -z "${vid:-}" || "$vid" == \#* ]] && continue
125
+ exp="${expires#expires=}"
126
+ if [[ "$exp" < "$TODAY" ]]; then
127
+ fail "accepted finding $vid expired on $exp — re-triage it (security/ACCEPTED-FINDINGS.md)"
128
+ continue
129
+ fi
130
+ IGNORES+=(--ignore-vuln "$vid")
131
+ done < security/accepted-vulns.txt
132
+ [[ ${#IGNORES[@]} -gt 0 ]] && echo " accepting $(( ${#IGNORES[@]} / 2 )) recorded finding(s) until their expiry (security/accepted-vulns.txt)"
133
+ fi
134
+ # `${IGNORES[@]+…}`: macOS ships bash 3.2, where an empty array under `set -u` is unbound.
135
  "$VENV/bin/python" -m pip_audit --requirement "$TMP/audit.txt" --no-deps --disable-pip \
136
+ ${IGNORES[@]+"${IGNORES[@]}"} --format columns 2>/dev/null | tee "$OUT/pip-audit-$STAMP.txt"
137
  AUDIT_RC=${PIPESTATUS[0]}
138
  if [[ "$AUDIT_RC" -eq 0 ]]; then
139
  ok "pip-audit — no known vulnerabilities in the resolved tree"
security/ACCEPTED-FINDINGS.md CHANGED
@@ -10,7 +10,7 @@ There are none.
10
  |-------|--------|
11
  | **pip-audit** (compiled lock vs OSV/PyPI advisories) | **clean** — `requirements.txt` is a uv lock of `requirements.in` for python 3.12 / linux: 119 pinned packages, including gradio 6.28.0 from `sdk_version` and ToolUniverse 1.5.6 with every transitive dependency. The memo accepted that footprint under this scan (2026-10-13). |
12
  | **bandit** (static, medium+, `src/` + `access.py`, `audit.py`, `logging_sink.py`) | **clean** — 0 findings, so there is no baseline file. |
13
- | **gitleaks** (working tree + full git history) | **clean**. The first run flagged `src/identifiers.py:4` under `generic-api-key`: the docstring phrase "UniProt accessions, EFO/MONDO/Orphanet" matched the rule's keyword-plus-token shape. Not a secret; fixed by rewording the docstring and amending the unpushed commit, **not** by an allowlist entry. |
14
  | **trivy** (sandbox image) | **N/A** — no sandboxed-exec Dockerfile in this repo; the stage skips. |
15
  | **SBOM** (CycloneDX, of the lock) | written to `security/sbom.json` (not tracked — regenerable, as in pdac-spatial-agent). |
16
 
@@ -20,3 +20,10 @@ A version bump is its own lane (ADR-0001 §7): `tooluniverse-doctor`, re-recorde
20
  the schema tests, and this scan on the recompiled lock. `tests/test_no_llm_keys.py` also fails if the lock ever
21
  gains an LLM client library (openai, anthropic, google-genai, smolagents, litellm, langchain, vllm), so a new
22
  transitive of that kind is a reviewed change, not a silent one.
 
 
 
 
 
 
 
 
10
  |-------|--------|
11
  | **pip-audit** (compiled lock vs OSV/PyPI advisories) | **clean** — `requirements.txt` is a uv lock of `requirements.in` for python 3.12 / linux: 119 pinned packages, including gradio 6.28.0 from `sdk_version` and ToolUniverse 1.5.6 with every transitive dependency. The memo accepted that footprint under this scan (2026-10-13). |
12
  | **bandit** (static, medium+, `src/` + `access.py`, `audit.py`, `logging_sink.py`) | **clean** — 0 findings, so there is no baseline file. |
13
+ | **gitleaks** (working tree + full git history) | **clean**. The first run flagged `src/identifiers.py:4` under `generic-api-key`: a docstring listing identifier kinds matched the rule's keyword-plus-token shape. Not a secret; fixed by rewording the docstring and amending the unpushed commit, **not** by an allowlist entry. (See §1: this record then quoted the phrase and tripped the same rule.) |
14
  | **trivy** (sandbox image) | **N/A** — no sandboxed-exec Dockerfile in this repo; the stage skips. |
15
  | **SBOM** (CycloneDX, of the lock) | written to `security/sbom.json` (not tracked — regenerable, as in pdac-spatial-agent). |
16
 
 
20
  the schema tests, and this scan on the recompiled lock. `tests/test_no_llm_keys.py` also fails if the lock ever
21
  gains an LLM client library (openai, anthropic, google-genai, smolagents, litellm, langchain, vllm), so a new
22
  transitive of that kind is a reviewed change, not a silent one.
23
+
24
+ ## §1 — s2 run (2026-10-21 UTC)
25
+
26
+ | Stage | Result |
27
+ |-------|--------|
28
+ | **gitleaks** | One finding, **accepted as a false positive by fingerprint** (`.gitleaksignore`): commit `97e7d80`, `security/ACCEPTED-FINDINGS.md:13`, `generic-api-key`. That line of §0 quoted, verbatim, the docstring phrase §0 describes as a false positive, so it tripped the same rule. The text is reworded here; the old commit is local history on `dev` that has never been pushed, and is ignored by its exact fingerprint rather than rewritten. Not a secret: it is a list of identifier kinds. |
29
+ | **pip-audit** | **8 findings, ACCEPTED until 2026-11-20 (plan D5-A, the project lead, 2026-10-20)** — `pillow 12.3.0` (via gradio 6.28.0, which requires `pillow<13`): CVE-2026-112895 / -112400 / -112397 / -112896 / -112897 / -112395 / -112404 / -112899, all fixed in 13.0.0. **Why acceptable here:** all eight are in parsing attacker-supplied image files (PDF append, GIF, PPM/PGM, TIFF, EPS); this Space's inputs are identifiers only (ADR-0001 §6), it has no image or file input, and it never opens an image a caller supplies. **Why not fixed:** an anchored pin bump cannot move pillow without breaking gradio's constraint; every fleet repo's `dev` pins the same version, so the fix is a fleet-wide gradio move, tracked separately. **Ends** when a gradio release allowing `pillow>=13` is pinned (tests/test_accepted_vulns.py then fails until the entries are removed) or on 2026-11-20, when the scan fails on the expired entries. Mechanism: `security/accepted-vulns.txt` → `pip-audit --ignore-vuln`. |
security/accepted-vulns.txt ADDED
@@ -0,0 +1,13 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Accepted pip-audit findings — each line: <advisory id> expires=<YYYY-MM-DD> <package> <affected version>
2
+ # scripts/security_scan.sh passes every unexpired id to `pip-audit --ignore-vuln` and FAILS on an
3
+ # expired one, so an acceptance cannot outlive its date. tests/test_accepted_vulns.py fails once the
4
+ # lock moves the package off the affected version, so a fixed finding cannot stay accepted.
5
+ # Justification: security/ACCEPTED-FINDINGS.md §1 (plan D5-A, decided 2026-10-20 by the project lead).
6
+ CVE-2026-112895 expires=2026-11-20 pillow 12.3.0
7
+ CVE-2026-112400 expires=2026-11-20 pillow 12.3.0
8
+ CVE-2026-112397 expires=2026-11-20 pillow 12.3.0
9
+ CVE-2026-112896 expires=2026-11-20 pillow 12.3.0
10
+ CVE-2026-112897 expires=2026-11-20 pillow 12.3.0
11
+ CVE-2026-112395 expires=2026-11-20 pillow 12.3.0
12
+ CVE-2026-112404 expires=2026-11-20 pillow 12.3.0
13
+ CVE-2026-112899 expires=2026-11-20 pillow 12.3.0
src/endpoints/__init__.py ADDED
@@ -0,0 +1,30 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """The five BCC endpoints (ADR-0001 §4). Names and parameter order are frozen by the descriptor.
2
+
3
+ Each is ``fn(*strings) -> payload dict``; ``ENDPOINTS`` maps the contract name to the function
4
+ and its parameter names, in the order ``gradio_ui`` exposes them and the descriptor records.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from src.endpoints.annotate import annotate_genes
10
+ from src.endpoints.drugs import drugs_for_targets
11
+ from src.endpoints.pathways import pathways_for_genes
12
+ from src.endpoints.targets import targets_for_disease
13
+ from src.endpoints.trials import trials_for_genes
14
+
15
+ ENDPOINTS = {
16
+ "drugs_for_targets": (drugs_for_targets, ("genes",)),
17
+ "trials_for_genes": (trials_for_genes, ("genes", "condition", "status")),
18
+ "annotate_genes": (annotate_genes, ("genes",)),
19
+ "pathways_for_genes": (pathways_for_genes, ("genes",)),
20
+ "targets_for_disease": (targets_for_disease, ("disease", "size")),
21
+ }
22
+
23
+ __all__ = [
24
+ "ENDPOINTS",
25
+ "annotate_genes",
26
+ "drugs_for_targets",
27
+ "pathways_for_genes",
28
+ "targets_for_disease",
29
+ "trials_for_genes",
30
+ ]
src/endpoints/_common.py ADDED
@@ -0,0 +1,247 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """What every endpoint shares: the client, the call budget, dates, stages and HGNC resolution.
2
+
3
+ Endpoints are plain functions — strings in, payload dicts out — and no exception escapes one
4
+ (the family tool_server contract). A failure inside one source call is a ``source_errors`` entry,
5
+ an input that a source does not know is an ``unresolved`` entry, and only an endpoint where
6
+ nothing could be looked up at all is an ``error`` payload.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import threading
12
+ import time
13
+ from collections.abc import Iterable
14
+ from dataclasses import dataclass, field
15
+ from typing import Any
16
+
17
+ from src import config
18
+ from src.provenance import stamp
19
+ from src.tu_client import SourceError, SourceResult, ToolClient
20
+
21
+ # One endpoint call may spend at most this long across all its source calls; the orchestrator's
22
+ # own call limit is above it (300 s for annotate_genes, its default for the others).
23
+ ENDPOINT_BUDGET_SECONDS = 240
24
+
25
+ _client: ToolClient | None = None
26
+ _client_lock = threading.Lock()
27
+
28
+
29
+ def get_client() -> ToolClient:
30
+ """The process-wide client (one cache, one set of rate limits for every endpoint)."""
31
+ global _client
32
+ with _client_lock:
33
+ if _client is None:
34
+ _client = ToolClient()
35
+ return _client
36
+
37
+
38
+ def set_client(client: ToolClient | None) -> None:
39
+ """Tests replay recorded responses through a client of their own."""
40
+ global _client
41
+ with _client_lock:
42
+ _client = client
43
+
44
+
45
+ class Budget:
46
+ """The endpoint's wall-clock budget. Past it, remaining inputs are reported, not looked up."""
47
+
48
+ def __init__(self, seconds: float = ENDPOINT_BUDGET_SECONDS, clock=time.monotonic):
49
+ self._clock = clock
50
+ self._deadline = clock() + seconds
51
+
52
+ @property
53
+ def remaining(self) -> float:
54
+ return max(0.0, self._deadline - self._clock())
55
+
56
+ @property
57
+ def spent(self) -> bool:
58
+ return self.remaining <= 0
59
+
60
+
61
+ @dataclass
62
+ class Collector:
63
+ """Accumulates what an endpoint found, failed on and could not resolve."""
64
+
65
+ rows: list[dict[str, Any]] = field(default_factory=list)
66
+ unresolved: list[dict[str, str]] = field(default_factory=list)
67
+ source_errors: list[dict[str, str]] = field(default_factory=list)
68
+ sources_used: set[str] = field(default_factory=set)
69
+ retrieved: list[float] = field(default_factory=list)
70
+ calls: int = 0
71
+
72
+ def call(
73
+ self,
74
+ client: ToolClient,
75
+ budget: Budget,
76
+ tool: str,
77
+ args: dict[str, Any],
78
+ *,
79
+ unknown: tuple[str, str] | None = None,
80
+ ) -> SourceResult | None:
81
+ """One source call. None (with the reason recorded) if it failed or the budget ran out.
82
+
83
+ ``unknown=(marker, input)``: a source error whose detail contains ``marker`` means the
84
+ source does not know ``input`` (Reactome's 404, Open Targets' "no disease for ID"). That
85
+ is an unresolved input, not a source failure.
86
+ """
87
+ if budget.spent:
88
+ return None
89
+ self.calls += 1
90
+ try:
91
+ result = client.call(tool, args, max_wait=min(10.0, budget.remaining))
92
+ except SourceError as err:
93
+ if (
94
+ unknown is not None
95
+ and err.reason == "source_unavailable"
96
+ and unknown[0] in err.detail
97
+ ):
98
+ self.unresolved_input(unknown[1], f"unknown to {err.source}: {err.detail}")
99
+ else:
100
+ self.source_errors.append(err.as_payload())
101
+ return None
102
+ self.sources_used.add(result.source)
103
+ self.retrieved.append(result.retrieved_at)
104
+ return result
105
+
106
+ def unresolved_input(self, value: str, reason: str) -> None:
107
+ self.unresolved.append({"input": value, "reason": reason})
108
+
109
+ def out_of_budget(self, values: Iterable[str]) -> None:
110
+ for value in values:
111
+ self.unresolved_input(
112
+ value, f"not looked up: the {ENDPOINT_BUDGET_SECONDS} s call budget ran out"
113
+ )
114
+
115
+
116
+ def stamped(row: dict[str, Any], result: SourceResult, *, ref: str, url: str) -> dict[str, Any]:
117
+ return stamp(
118
+ row,
119
+ source=result.source,
120
+ source_tool=result.tool,
121
+ tooluniverse_version=result.tooluniverse_version,
122
+ query=result.args,
123
+ source_ref=ref,
124
+ source_url=url,
125
+ retrieved_at=result.retrieved_at,
126
+ cache=result.cache,
127
+ )
128
+
129
+
130
+ def query_dates(retrieved: Iterable[float]) -> str:
131
+ """'2026-10-20', or '2026-10-14 to 2026-10-20' when cached answers span days."""
132
+ from src.provenance import iso_utc
133
+
134
+ days = sorted({iso_utc(t)[:10] for t in retrieved})
135
+ if not days:
136
+ return "today"
137
+ return days[0] if len(days) == 1 else f"{days[0]} to {days[-1]}"
138
+
139
+
140
+ def according_to(source_ids: Iterable[str], retrieved: Iterable[float]) -> str:
141
+ names = [config.source(s)["name"] for s in sorted(set(source_ids))]
142
+ joined = names[0] if len(names) == 1 else ", ".join(names[:-1]) + " and " + names[-1]
143
+ return f"According to {joined} (queried {query_dates(retrieved)})"
144
+
145
+
146
+ def humanise_list(items: list[str], limit: int = 5) -> str:
147
+ shown = items[:limit]
148
+ more = len(items) - len(shown)
149
+ text = ", ".join(shown)
150
+ return f"{text} and {more} more" if more > 0 else text
151
+
152
+
153
+ # Open Targets' clinical-stage vocabulary, highest first. Anything unknown sorts last and is
154
+ # shown as Open Targets wrote it.
155
+ _STAGE_ORDER = (
156
+ "APPROVAL",
157
+ "APPROVED",
158
+ "PHASE_4",
159
+ "PHASE_3",
160
+ "PHASE_2_3",
161
+ "PHASE_2",
162
+ "PHASE_1_2",
163
+ "PHASE_1",
164
+ "EARLY_PHASE_1",
165
+ "PHASE_0",
166
+ "PRECLINICAL",
167
+ )
168
+
169
+
170
+ def stage_rank(stage: str | None) -> int:
171
+ try:
172
+ return _STAGE_ORDER.index((stage or "").upper())
173
+ except ValueError:
174
+ return len(_STAGE_ORDER)
175
+
176
+
177
+ def stage_label(stage: str | None) -> str:
178
+ if not stage:
179
+ return "stage not stated"
180
+ text = stage.upper()
181
+ if text in ("APPROVAL", "APPROVED"):
182
+ return "approved"
183
+ return (
184
+ text.replace("_", " ")
185
+ .lower()
186
+ .replace("phase 1 2", "phase 1/2")
187
+ .replace("phase 2 3", "phase 2/3")
188
+ )
189
+
190
+
191
+ @dataclass(frozen=True)
192
+ class Gene:
193
+ """An input resolved through HGNC."""
194
+
195
+ input: str
196
+ symbol: str
197
+ hgnc_id: str
198
+ name: str
199
+ locus_type: str
200
+ ensembl_gene_id: str | None
201
+ uniprot_ids: tuple[str, ...]
202
+ provenance_row: dict[str, Any]
203
+
204
+
205
+ HGNC = "HGNC_fetch_gene_by_symbol"
206
+
207
+
208
+ def resolve_symbols(
209
+ symbols: list[str], client: ToolClient, budget: Budget, out: Collector
210
+ ) -> list[Gene]:
211
+ """HGNC symbol → approved record. Unknown symbols go to ``unresolved``."""
212
+ genes: list[Gene] = []
213
+ for i, symbol in enumerate(symbols):
214
+ if budget.spent:
215
+ out.out_of_budget(symbols[i:])
216
+ break
217
+ result = out.call(client, budget, HGNC, {"symbol": symbol})
218
+ if result is None:
219
+ continue
220
+ data = result.data or {}
221
+ if not data.get("symbol"):
222
+ out.unresolved_input(symbol, "not a current, previous or alias symbol in HGNC")
223
+ continue
224
+ row = stamped(
225
+ {"input": symbol, "symbol": data["symbol"], "hgnc_id": data["hgnc_id"]},
226
+ result,
227
+ ref=data["hgnc_id"],
228
+ url=f"https://www.genenames.org/data/gene-symbol-report/#!/hgnc_id/{data['hgnc_id']}",
229
+ )
230
+ genes.append(
231
+ Gene(
232
+ input=symbol,
233
+ symbol=data["symbol"],
234
+ hgnc_id=data["hgnc_id"],
235
+ name=data.get("name") or "",
236
+ locus_type=data.get("locus_type") or "",
237
+ ensembl_gene_id=data.get("ensembl_gene_id"),
238
+ uniprot_ids=tuple(data.get("uniprot_ids") or ()),
239
+ provenance_row=row,
240
+ )
241
+ )
242
+ return genes
243
+
244
+
245
+ def resolution_rows(genes: list[Gene]) -> list[dict[str, Any]]:
246
+ """The identifier mapping each answer rests on, with HGNC's provenance."""
247
+ return [g.provenance_row for g in genes]
src/endpoints/annotate.py ADDED
@@ -0,0 +1,179 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """annotate_genes — what HGNC and UniProt say each gene and its protein are.
2
+
3
+ HGNC is the authority for the symbol (approved name, locus type, Ensembl id) and for the
4
+ gene → UniProt accession link. UniProt is then asked once per batch of up to 100 accessions,
5
+ reviewed (Swiss-Prot) human entries only, for the recommended name, function and subcellular
6
+ location. A gene with no reviewed entry — a pseudogene such as SORD2P — keeps its HGNC row with
7
+ ``protein: null`` and is listed in ``unresolved``; it is never filled in from anything else.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from typing import Any
13
+
14
+ from src import payloads
15
+ from src.endpoints._common import (
16
+ Budget,
17
+ Collector,
18
+ according_to,
19
+ get_client,
20
+ humanise_list,
21
+ resolve_symbols,
22
+ stamped,
23
+ )
24
+ from src.identifiers import parse_gene_list
25
+
26
+ ENDPOINT = "annotate_genes"
27
+ UNIPROT_SEARCH = "UniProt_search"
28
+ UNIPROT_BATCH = 100
29
+ UNIPROT_FIELDS = [
30
+ "accession",
31
+ "gene_primary",
32
+ "protein_name",
33
+ "cc_function",
34
+ "cc_subcellular_location",
35
+ ]
36
+ MAX_FUNCTION_CHARS = 600
37
+
38
+
39
+ def annotate_genes(genes: str = "") -> dict[str, Any]:
40
+ parsed = parse_gene_list(genes, allow_ensembl=False)
41
+ if not parsed.ok:
42
+ return payloads.refused(ENDPOINT, parsed.reason, parsed.message)
43
+ client, budget, out = get_client(), Budget(), Collector()
44
+ symbols = [i.value for i in parsed.identifiers]
45
+ resolved = resolve_symbols(symbols, client, budget, out)
46
+
47
+ accessions = sorted({acc for g in resolved for acc in g.uniprot_ids})
48
+ entries = fetch_reviewed_entries(accessions, client, budget, out)
49
+
50
+ with_protein = 0
51
+ for gene in resolved:
52
+ protein = None
53
+ for acc in gene.uniprot_ids:
54
+ if acc in entries:
55
+ entry, result = entries[acc]
56
+ protein = stamped(entry_fields(entry), result, ref=acc, url=uniprot_url(acc))
57
+ break
58
+ if protein is not None:
59
+ with_protein += 1
60
+ status = "reviewed UniProt entry"
61
+ elif not gene.uniprot_ids:
62
+ status = f"HGNC links no UniProt entry (locus type: {gene.locus_type or 'not stated'})"
63
+ out.unresolved_input(gene.input, f"no reviewed UniProt entry: {status}")
64
+ else:
65
+ status = "no reviewed (Swiss-Prot) human entry for " + ", ".join(gene.uniprot_ids)
66
+ out.unresolved_input(gene.input, status)
67
+ out.rows.append(
68
+ {
69
+ "input": gene.input,
70
+ "symbol": gene.symbol,
71
+ "hgnc_id": gene.hgnc_id,
72
+ "name": gene.name,
73
+ "locus_type": gene.locus_type,
74
+ "ensembl_gene_id": gene.ensembl_gene_id,
75
+ "protein": protein,
76
+ "protein_status": status,
77
+ "provenance": gene.provenance_row["provenance"],
78
+ }
79
+ )
80
+
81
+ if not out.rows and out.source_errors:
82
+ return payloads.error(
83
+ ENDPOINT,
84
+ "all_sources_failed",
85
+ "HGNC did not answer; nothing was looked up.",
86
+ source_errors=out.source_errors,
87
+ )
88
+ lead = according_to(out.sources_used or {"hgnc"}, out.retrieved)
89
+ approved = "an approved HGNC gene" if len(symbols) == 1 else "approved HGNC genes"
90
+ sentences = [
91
+ (
92
+ f"{lead}, {len(resolved)} of {len(symbols)} input{'s' if len(symbols) != 1 else ''} "
93
+ f"{'is' if len(symbols) == 1 else 'are'} {approved}, and {with_protein} "
94
+ f"{'has' if with_protein == 1 else 'have'} a reviewed human UniProt entry."
95
+ )
96
+ ]
97
+ if out.unresolved:
98
+ sentences.append(
99
+ "Not resolved: "
100
+ + humanise_list([f"{u['input']} ({u['reason']})" for u in out.unresolved], limit=8)
101
+ + "."
102
+ )
103
+ if out.source_errors:
104
+ sentences.append(
105
+ "Source problems: "
106
+ + "; ".join(f"{e['source']} {e['reason']}" for e in out.source_errors)
107
+ + "."
108
+ )
109
+ return payloads.ok(
110
+ ENDPOINT,
111
+ query={"genes": symbols},
112
+ rows=out.rows,
113
+ sources_used=out.sources_used or {"hgnc"},
114
+ narrative=" ".join(sentences),
115
+ unresolved=out.unresolved,
116
+ source_errors=out.source_errors,
117
+ )
118
+
119
+
120
+ def uniprot_url(accession: str) -> str:
121
+ return f"https://www.uniprot.org/uniprotkb/{accession}/entry"
122
+
123
+
124
+ def fetch_reviewed_entries(
125
+ accessions: list[str], client, budget: Budget, out: Collector
126
+ ) -> dict[str, tuple[dict[str, Any], Any]]:
127
+ """Reviewed human UniProt entries by accession, in batches of ``UNIPROT_BATCH``."""
128
+ found: dict[str, tuple[dict[str, Any], Any]] = {}
129
+ for start in range(0, len(accessions), UNIPROT_BATCH):
130
+ batch = accessions[start : start + UNIPROT_BATCH]
131
+ query = (
132
+ "("
133
+ + " OR ".join(f"accession:{a}" for a in batch)
134
+ + ") AND reviewed:true AND organism_id:9606"
135
+ )
136
+ result = out.call(
137
+ client,
138
+ budget,
139
+ UNIPROT_SEARCH,
140
+ {"query": query, "limit": len(batch), "fields": UNIPROT_FIELDS},
141
+ )
142
+ if result is None:
143
+ continue
144
+ for entry in result.data.get("results") or []:
145
+ acc = entry.get("primaryAccession")
146
+ if acc in batch:
147
+ found[acc] = (entry, result)
148
+ return found
149
+
150
+
151
+ def entry_fields(entry: dict[str, Any]) -> dict[str, Any]:
152
+ comments = entry.get("comments") or []
153
+ function = " ".join(
154
+ t.get("value", "")
155
+ for c in comments
156
+ if c.get("commentType") == "FUNCTION"
157
+ for t in c.get("texts") or []
158
+ ).strip()
159
+ locations = []
160
+ for c in comments:
161
+ if c.get("commentType") != "SUBCELLULAR LOCATION":
162
+ continue
163
+ for loc in c.get("subcellularLocations") or []:
164
+ value = (loc.get("location") or {}).get("value")
165
+ if value and value not in locations:
166
+ locations.append(value)
167
+ recommended = ((entry.get("proteinDescription") or {}).get("recommendedName") or {}).get(
168
+ "fullName"
169
+ ) or {}
170
+ genes = entry.get("genes") or []
171
+ return {
172
+ "accession": entry.get("primaryAccession"),
173
+ "gene": ((genes[0].get("geneName") or {}).get("value") if genes else None),
174
+ "protein_name": recommended.get("value"),
175
+ "function": function[:MAX_FUNCTION_CHARS]
176
+ + ("…" if len(function) > MAX_FUNCTION_CHARS else ""),
177
+ "function_truncated": len(function) > MAX_FUNCTION_CHARS,
178
+ "subcellular_location": locations,
179
+ }
src/endpoints/drugs.py ADDED
@@ -0,0 +1,185 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """drugs_for_targets — drugs and clinical candidates per target (ADR-0001 §4).
2
+
3
+ HGNC resolves each symbol to its Ensembl id, Open Targets lists the drugs and clinical candidates
4
+ for that target with their maximum clinical stage, and ChEMBL gives each drug's mechanisms. The
5
+ mechanism lookup is one call per drug, so it is capped at the ``MAX_MECHANISM_LOOKUPS`` most
6
+ advanced drugs in the call (stage first, then name); the payload says how many were looked up.
7
+ Open Targets' coverage note travels with every payload: an investigational compound with no
8
+ trial record can be absent (Phase 0: MRTX1133 against KRAS G12D).
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ from typing import Any
14
+
15
+ from src import payloads
16
+ from src.endpoints._common import (
17
+ Budget,
18
+ Collector,
19
+ according_to,
20
+ get_client,
21
+ humanise_list,
22
+ resolution_rows,
23
+ resolve_symbols,
24
+ stage_label,
25
+ stage_rank,
26
+ stamped,
27
+ )
28
+ from src.identifiers import parse_gene_list
29
+
30
+ ENDPOINT = "drugs_for_targets"
31
+ OT_DRUGS = "OpenTargets_get_associated_drugs_by_target_ensemblID"
32
+ CHEMBL_MECHANISMS = "ChEMBL_get_drug_mechanisms"
33
+ MAX_MECHANISM_LOOKUPS = 30
34
+ MAX_INDICATIONS = 10
35
+
36
+
37
+ def drugs_for_targets(genes: str = "") -> dict[str, Any]:
38
+ parsed = parse_gene_list(genes)
39
+ if not parsed.ok:
40
+ return payloads.refused(ENDPOINT, parsed.reason, parsed.message)
41
+ client, budget, out = get_client(), Budget(), Collector()
42
+
43
+ symbols = [i.value for i in parsed.identifiers if i.kind == "symbol"]
44
+ resolved = resolve_symbols(symbols, client, budget, out)
45
+ targets: list[tuple[str, str]] = [] # (label for messages, Ensembl id)
46
+ for gene in resolved:
47
+ if gene.ensembl_gene_id:
48
+ targets.append((gene.symbol, gene.ensembl_gene_id))
49
+ else:
50
+ out.unresolved_input(gene.input, "HGNC records no Ensembl gene id for it")
51
+ targets += [(i.value, i.value) for i in parsed.identifiers if i.kind == "ensembl"]
52
+
53
+ per_target: list[tuple[str, int]] = []
54
+ for label, ensembl_id in targets:
55
+ result = out.call(client, budget, OT_DRUGS, {"ensemblId": ensembl_id})
56
+ if result is None:
57
+ continue
58
+ target = result.data.get("target")
59
+ if target is None:
60
+ out.unresolved_input(label, "unknown to Open Targets")
61
+ continue
62
+ block = target["drugAndClinicalCandidates"]
63
+ # ToolUniverse omits `rows` when the count is 0 (STAT1, recorded 2026-10-20) and sets
64
+ # `truncated` when it returned fewer rows than Open Targets holds.
65
+ candidates = block.get("rows") or []
66
+ if block.get("truncated"):
67
+ out.unresolved_input(
68
+ label,
69
+ f"Open Targets holds {block.get('count')} records; ToolUniverse returned "
70
+ f"{len(candidates)} (listed)",
71
+ )
72
+ rows = []
73
+ for cand in candidates:
74
+ drug = cand["drug"]
75
+ indications = [
76
+ d["disease"]["name"]
77
+ for d in cand.get("diseases") or []
78
+ if (d.get("disease") or {}).get("name")
79
+ ]
80
+ rows.append(
81
+ stamped(
82
+ {
83
+ "target": target["approvedSymbol"],
84
+ "target_ensembl_id": target["id"],
85
+ "drug": drug["name"],
86
+ "drug_chembl_id": drug["id"],
87
+ "max_clinical_stage": cand["maxClinicalStage"],
88
+ "stage": stage_label(cand["maxClinicalStage"]),
89
+ "indications": indications[:MAX_INDICATIONS],
90
+ "indications_total": len(indications),
91
+ },
92
+ result,
93
+ ref=drug["id"],
94
+ url=f"https://platform.opentargets.org/drug/{drug['id']}",
95
+ )
96
+ )
97
+ rows.sort(key=lambda r: (stage_rank(r["max_clinical_stage"]), r["drug"]))
98
+ out.rows.extend(rows)
99
+ per_target.append((target["approvedSymbol"], len(rows)))
100
+
101
+ looked_up = _attach_mechanisms(out, client, budget)
102
+
103
+ if not out.rows and out.source_errors and not per_target:
104
+ return payloads.error(
105
+ ENDPOINT,
106
+ "all_sources_failed",
107
+ "Open Targets did not answer for any target; no drugs could be looked up.",
108
+ source_errors=out.source_errors,
109
+ )
110
+ return payloads.ok(
111
+ ENDPOINT,
112
+ query={"genes": [i.value for i in parsed.identifiers]},
113
+ rows=out.rows,
114
+ sources_used=out.sources_used,
115
+ narrative=_narrative(out, per_target, looked_up),
116
+ unresolved=out.unresolved,
117
+ source_errors=out.source_errors,
118
+ resolved_identifiers=resolution_rows(resolved),
119
+ mechanism_lookups={"looked_up": looked_up, "cap": MAX_MECHANISM_LOOKUPS},
120
+ )
121
+
122
+
123
+ def _attach_mechanisms(out: Collector, client, budget: Budget) -> int:
124
+ """ChEMBL mechanisms for the most advanced drugs; every row says whether it was looked up."""
125
+ best: dict[str, tuple[int, str]] = {}
126
+ for row in out.rows:
127
+ key = (stage_rank(row["max_clinical_stage"]), row["drug"])
128
+ best[row["drug_chembl_id"]] = min(best.get(row["drug_chembl_id"], key), key)
129
+ order = sorted(best, key=lambda chembl_id: (best[chembl_id], chembl_id))
130
+ mechanisms: dict[str, dict[str, Any]] = {}
131
+ for chembl_id in order[:MAX_MECHANISM_LOOKUPS]:
132
+ result = out.call(client, budget, CHEMBL_MECHANISMS, {"drug_chembl_id": chembl_id})
133
+ if result is None:
134
+ continue
135
+ records = result.data.get("mechanisms") or []
136
+ moa = sorted({m["mechanism_of_action"] for m in records if m.get("mechanism_of_action")})
137
+ actions = sorted({m["action_type"] for m in records if m.get("action_type")})
138
+ mechanisms[chembl_id] = stamped(
139
+ {"mechanism_of_action": moa, "action_types": actions},
140
+ result,
141
+ ref=chembl_id,
142
+ url=f"https://www.ebi.ac.uk/chembl/compound_report_card/{chembl_id}/",
143
+ )
144
+ for row in out.rows:
145
+ found = mechanisms.get(row["drug_chembl_id"])
146
+ row["mechanisms"] = found if found is not None else {"looked_up": False}
147
+ return len(mechanisms)
148
+
149
+
150
+ def _narrative(out: Collector, per_target: list[tuple[str, int]], looked_up: int) -> str:
151
+ sentences = []
152
+ clauses = []
153
+ for symbol, n in per_target:
154
+ rows = [r for r in out.rows if r["target"] == symbol]
155
+ if not rows:
156
+ clauses.append(f"{symbol} has no listed drugs or clinical candidates")
157
+ continue
158
+ top = rows[0]["stage"]
159
+ names = [r["drug"] for r in rows if r["stage"] == top]
160
+ clauses.append(
161
+ f"{symbol} has {n} drug or clinical-candidate record{'s' if n != 1 else ''}, "
162
+ f"the most advanced ({top}): {humanise_list(names)}"
163
+ )
164
+ if clauses:
165
+ sentences.append(
166
+ f"{according_to(['opentargets'], out.retrieved)}: " + "; ".join(clauses) + "."
167
+ )
168
+ if looked_up:
169
+ sentences.append(
170
+ f"Mechanisms are ChEMBL's for the {looked_up} most advanced drug"
171
+ f"{'s' if looked_up != 1 else ''} (at most {MAX_MECHANISM_LOOKUPS} per call)."
172
+ )
173
+ if out.unresolved:
174
+ sentences.append(
175
+ "Not resolved: "
176
+ + "; ".join(f"{u['input']} ({u['reason']})" for u in out.unresolved)
177
+ + "."
178
+ )
179
+ if out.source_errors:
180
+ sentences.append(
181
+ "Source problems: "
182
+ + "; ".join(f"{e['source']} {e['reason']}" for e in out.source_errors)
183
+ + "."
184
+ )
185
+ return " ".join(sentences) or "Nothing was looked up."
src/endpoints/pathways.py ADDED
@@ -0,0 +1,124 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """pathways_for_genes — which Reactome pathways each gene's protein belongs to.
2
+
3
+ Membership, never enrichment: a row says "Reactome annotates this protein to this pathway", and
4
+ nothing is computed over the gene list. ``shared_by_all`` lists the pathways every resolved gene
5
+ belongs to — a set intersection over Reactome's own annotation, with no statistic attached.
6
+ Which pathways are *enriched* in a gene list is DecoupleRpy's question.
7
+
8
+ Symbols resolve through HGNC to UniProt accessions; accessions may also be given directly.
9
+ Reactome's UniProt mapping returns the lowest-level pathways (Phase 0 Q9: ISG15, RSAD2 and OAS1
10
+ in "Interferon alpha/beta signaling").
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ from typing import Any
16
+
17
+ from src import payloads
18
+ from src.endpoints._common import (
19
+ Budget,
20
+ Collector,
21
+ according_to,
22
+ get_client,
23
+ resolution_rows,
24
+ resolve_symbols,
25
+ stamped,
26
+ )
27
+ from src.identifiers import parse_gene_list
28
+
29
+ ENDPOINT = "pathways_for_genes"
30
+ REACTOME_MAP = "Reactome_map_uniprot_to_pathways"
31
+
32
+
33
+ def pathways_for_genes(genes: str = "") -> dict[str, Any]:
34
+ parsed = parse_gene_list(genes, allow_uniprot=True, allow_ensembl=False)
35
+ if not parsed.ok:
36
+ return payloads.refused(ENDPOINT, parsed.reason, parsed.message)
37
+ client, budget, out = get_client(), Budget(), Collector()
38
+
39
+ symbols = [i.value for i in parsed.identifiers if i.kind == "symbol"]
40
+ resolved = resolve_symbols(symbols, client, budget, out)
41
+ proteins: list[tuple[str, str]] = [] # (gene label, UniProt accession)
42
+ for gene in resolved:
43
+ if not gene.uniprot_ids:
44
+ out.unresolved_input(
45
+ gene.input,
46
+ f"HGNC links no UniProt entry (locus type: {gene.locus_type or 'not stated'})",
47
+ )
48
+ proteins += [(gene.symbol, acc) for acc in gene.uniprot_ids]
49
+ proteins += [(i.value, i.value) for i in parsed.identifiers if i.kind == "uniprot"]
50
+
51
+ by_gene: dict[str, set[str]] = {}
52
+ names: dict[str, str] = {}
53
+ for label, acc in proteins:
54
+ result = out.call(client, budget, REACTOME_MAP, {"uniprot_id": acc}, unknown=("404", label))
55
+ if result is None:
56
+ continue
57
+ pathways = result.data or []
58
+ by_gene.setdefault(label, set())
59
+ for pw in pathways:
60
+ st_id = pw["stId"]
61
+ names[st_id] = pw["displayName"]
62
+ if st_id in by_gene[label]:
63
+ continue
64
+ by_gene[label].add(st_id)
65
+ out.rows.append(
66
+ stamped(
67
+ {
68
+ "gene": label,
69
+ "uniprot_accession": acc,
70
+ "pathway_id": st_id,
71
+ "pathway_name": pw["displayName"],
72
+ "is_in_disease": bool(pw.get("isInDisease")),
73
+ "species": pw.get("speciesName"),
74
+ },
75
+ result,
76
+ ref=st_id,
77
+ url=f"https://reactome.org/content/detail/{st_id}",
78
+ )
79
+ )
80
+
81
+ if not out.rows and out.source_errors and not by_gene:
82
+ return payloads.error(
83
+ ENDPOINT,
84
+ "all_sources_failed",
85
+ "No source answered; nothing was looked up.",
86
+ source_errors=out.source_errors,
87
+ )
88
+ shared = sorted(set.intersection(*by_gene.values())) if len(by_gene) > 1 else []
89
+ lead = according_to(["reactome"], out.retrieved)
90
+ sentences = [
91
+ f"{lead}, {label} is annotated to {len(ids)} lowest-level pathway"
92
+ f"{'s' if len(ids) != 1 else ''}."
93
+ for label, ids in by_gene.items()
94
+ ]
95
+ if len(by_gene) > 1:
96
+ sentences.append(
97
+ f"Pathways all {len(by_gene)} genes belong to: "
98
+ + (", ".join(f"{names[s]} ({s})" for s in shared) if shared else "none")
99
+ + "."
100
+ )
101
+ sentences.append("This is membership, not enrichment: no statistic was computed.")
102
+ if out.unresolved:
103
+ sentences.append(
104
+ "Not resolved: "
105
+ + "; ".join(f"{u['input']} ({u['reason']})" for u in out.unresolved)
106
+ + "."
107
+ )
108
+ if out.source_errors:
109
+ sentences.append(
110
+ "Source problems: "
111
+ + "; ".join(f"{e['source']} {e['reason']}" for e in out.source_errors)
112
+ + "."
113
+ )
114
+ return payloads.ok(
115
+ ENDPOINT,
116
+ query={"genes": [i.value for i in parsed.identifiers]},
117
+ rows=out.rows,
118
+ sources_used=out.sources_used or {"reactome"},
119
+ narrative=" ".join(sentences),
120
+ unresolved=out.unresolved,
121
+ source_errors=out.source_errors,
122
+ resolved_identifiers=resolution_rows(resolved),
123
+ shared_by_all=[{"pathway_id": s, "pathway_name": names[s]} for s in shared],
124
+ )
src/endpoints/targets.py ADDED
@@ -0,0 +1,136 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """targets_for_disease — the targets Open Targets most associates with a disease.
2
+
3
+ A disease name is resolved by Open Targets' own search: an exact (case-insensitive) name match
4
+ wins, else the first hit, and the payload names the id chosen and the alternatives it passed
5
+ over. The ranking and the score are Open Targets' association score, relayed as its score; this
6
+ endpoint ranks nothing itself. Open Targets moved most disease ids from EFO to MONDO
7
+ (pancreatic ductal adenocarcinoma is ``MONDO_0005184``; the legacy ``EFO_0002618`` no longer
8
+ resolves), so an unknown id is reported as unresolved with Open Targets' own message.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ from typing import Any
14
+
15
+ from src import payloads
16
+ from src.endpoints._common import (
17
+ Budget,
18
+ Collector,
19
+ according_to,
20
+ get_client,
21
+ humanise_list,
22
+ stamped,
23
+ )
24
+ from src.identifiers import parse_disease
25
+
26
+ ENDPOINT = "targets_for_disease"
27
+ OT_SEARCH = "OpenTargets_get_disease_id_description_by_name"
28
+ OT_TARGETS = "OpenTargets_get_associated_targets_by_disease_efoId"
29
+ DEFAULT_SIZE = 25
30
+ MAX_SIZE = 50
31
+ MAX_ALTERNATIVES = 4
32
+
33
+
34
+ def targets_for_disease(disease: str = "", size: str = "") -> dict[str, Any]:
35
+ parsed = parse_disease(disease)
36
+ if not parsed.ok:
37
+ return payloads.refused(ENDPOINT, parsed.reason, parsed.message)
38
+ try:
39
+ n = int((size or str(DEFAULT_SIZE)).strip())
40
+ except ValueError:
41
+ n = 0
42
+ if not 1 <= n <= MAX_SIZE:
43
+ return payloads.refused(
44
+ ENDPOINT, "invalid_argument", f"size must be a whole number from 1 to {MAX_SIZE}."
45
+ )
46
+ client, budget, out = get_client(), Budget(), Collector()
47
+ given = parsed.identifiers[0]
48
+ resolution: dict[str, Any] = {"input": given.value}
49
+
50
+ if given.kind == "disease_id":
51
+ disease_id, disease_name = given.value, None
52
+ else:
53
+ result = out.call(client, budget, OT_SEARCH, {"diseaseName": given.value})
54
+ if result is None:
55
+ return payloads.error(
56
+ ENDPOINT,
57
+ "all_sources_failed",
58
+ "Open Targets did not answer.",
59
+ source_errors=out.source_errors,
60
+ )
61
+ hits = ((result.data.get("search") or {}).get("hits")) or []
62
+ if not hits:
63
+ return payloads.refused(
64
+ ENDPOINT, "unknown_disease", f"Open Targets finds no disease named {given.value!r}."
65
+ )
66
+ exact = [h for h in hits if (h.get("name") or "").casefold() == given.value.casefold()]
67
+ chosen = (exact or hits)[0]
68
+ disease_id, disease_name = chosen["id"], chosen.get("name")
69
+ resolution = stamped(
70
+ {
71
+ "input": given.value,
72
+ "id": disease_id,
73
+ "name": disease_name,
74
+ "matched": "exact name" if exact else "first search hit",
75
+ "alternatives": [
76
+ {"id": h["id"], "name": h.get("name")} for h in hits if h["id"] != disease_id
77
+ ][:MAX_ALTERNATIVES],
78
+ },
79
+ result,
80
+ ref=disease_id,
81
+ url=f"https://platform.opentargets.org/disease/{disease_id}",
82
+ )
83
+
84
+ result = out.call(
85
+ client,
86
+ budget,
87
+ OT_TARGETS,
88
+ {"efoId": disease_id, "size": n},
89
+ unknown=("no disease for ID", disease_id),
90
+ )
91
+ if result is None:
92
+ if out.unresolved:
93
+ return payloads.refused(ENDPOINT, "unknown_disease", out.unresolved[0]["reason"])
94
+ return payloads.error(
95
+ ENDPOINT,
96
+ "all_sources_failed",
97
+ "Open Targets did not answer.",
98
+ source_errors=out.source_errors,
99
+ )
100
+ data = result.data.get("disease") or {}
101
+ disease_name = disease_name or data.get("name")
102
+ assoc = data.get("associatedTargets") or {}
103
+ for rank, row in enumerate(assoc.get("rows") or [], start=1):
104
+ target = row["target"]
105
+ out.rows.append(
106
+ stamped(
107
+ {
108
+ "rank": rank,
109
+ "target": target["approvedSymbol"],
110
+ "target_ensembl_id": target["id"],
111
+ "association_score": round(float(row["score"]), 4),
112
+ },
113
+ result,
114
+ ref=target["id"],
115
+ url=f"https://platform.opentargets.org/evidence/{target['id']}/{disease_id}",
116
+ )
117
+ )
118
+ lead = according_to(["opentargets"], out.retrieved)
119
+ symbols = [r["target"] for r in out.rows]
120
+ narrative = (
121
+ f"{lead}, the top {len(symbols)} of {assoc.get('count', len(symbols))} targets associated "
122
+ f"with {disease_name or disease_id} ({disease_id}), ranked by Open Targets' own "
123
+ f"association score: {humanise_list(symbols, limit=10)}."
124
+ )
125
+ if resolution.get("matched") == "first search hit":
126
+ narrative += f" No exact name match for {given.value!r}; Open Targets' first hit was used."
127
+ return payloads.ok(
128
+ ENDPOINT,
129
+ query={"disease": given.value, "size": n},
130
+ rows=out.rows,
131
+ sources_used=out.sources_used,
132
+ narrative=narrative,
133
+ unresolved=out.unresolved,
134
+ source_errors=out.source_errors,
135
+ disease=resolution,
136
+ )
src/endpoints/trials.py ADDED
@@ -0,0 +1,149 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """trials_for_genes — ClinicalTrials.gov studies whose intervention matches the gene.
2
+
3
+ Memo decision 3: ToolUniverse for interactive lookups; lit-agent's own harvester keeps the
4
+ weekly digest. ToolUniverse narrows an intervention search to the intervention's name and other
5
+ name (Phase 0 finding 5: "CD40" gives the one pancreas-specific mitazalimab trial, where a broad
6
+ API query also returns two solid-tumour basket trials). That rewrite is helpful but silent, so
7
+ every payload states it: ``query_semantics`` in words, and ``executed_queries`` with the exact
8
+ query ClinicalTrials.gov ran, taken from the response itself and pinned by a recorded fixture.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ from typing import Any
14
+
15
+ from src import payloads
16
+ from src.endpoints._common import Budget, Collector, according_to, get_client, stamped
17
+ from src.identifiers import parse_disease, parse_gene_list
18
+
19
+ ENDPOINT = "trials_for_genes"
20
+ CT_SEARCH = "ClinicalTrials_search_studies"
21
+ DEFAULT_CONDITION = "pancreatic cancer"
22
+ DEFAULT_STATUS = "RECRUITING"
23
+ STATUSES = (
24
+ "RECRUITING",
25
+ "NOT_YET_RECRUITING",
26
+ "ACTIVE_NOT_RECRUITING",
27
+ "ENROLLING_BY_INVITATION",
28
+ "COMPLETED",
29
+ "ANY",
30
+ )
31
+ PAGE_SIZE = 100
32
+ QUERY_SEMANTICS = (
33
+ "A study is listed when its intervention name or intervention other-name matches the gene "
34
+ "symbol (ToolUniverse's rewrite of the ClinicalTrials.gov query). Studies that mention the "
35
+ "gene only in their title, outcomes or eligibility are not listed, and a match on the name is "
36
+ "not evidence that the intervention acts on the gene's product."
37
+ )
38
+
39
+
40
+ def trials_for_genes(genes: str = "", condition: str = "", status: str = "") -> dict[str, Any]:
41
+ parsed = parse_gene_list(genes, allow_ensembl=False)
42
+ if not parsed.ok:
43
+ return payloads.refused(ENDPOINT, parsed.reason, parsed.message)
44
+ cond = parse_disease(condition or DEFAULT_CONDITION)
45
+ if not cond.ok:
46
+ return payloads.refused(ENDPOINT, cond.reason, cond.message)
47
+ if cond.identifiers[0].kind != "disease_name":
48
+ return payloads.refused(
49
+ ENDPOINT,
50
+ "invalid_identifier",
51
+ "Give the condition as a name, e.g. 'pancreatic cancer'.",
52
+ )
53
+ wanted = (status or DEFAULT_STATUS).strip().upper()
54
+ if wanted not in STATUSES:
55
+ return payloads.refused(
56
+ ENDPOINT,
57
+ "invalid_argument",
58
+ f"status must be one of {', '.join(STATUSES)}; got {wanted[:24]!r}.",
59
+ )
60
+ condition_name = cond.identifiers[0].value
61
+ client, budget, out = get_client(), Budget(), Collector()
62
+ executed: list[dict[str, Any]] = []
63
+ per_gene: list[tuple[str, int, int]] = []
64
+ symbols = [i.value for i in parsed.identifiers]
65
+ for idx, gene in enumerate(symbols):
66
+ if budget.spent:
67
+ out.out_of_budget(symbols[idx:])
68
+ break
69
+ args: dict[str, Any] = {
70
+ "query_cond": condition_name,
71
+ "query_intr": gene,
72
+ "page_size": PAGE_SIZE,
73
+ }
74
+ if wanted != "ANY":
75
+ args["filter_status"] = wanted
76
+ result = out.call(client, budget, CT_SEARCH, args)
77
+ if result is None:
78
+ continue
79
+ data = result.data
80
+ executed.append({"gene": gene, "executed_query": data.get("executed_query")})
81
+ studies = data.get("studies") or []
82
+ for study in studies:
83
+ nct = study["nct_id"]
84
+ out.rows.append(
85
+ stamped(
86
+ {
87
+ "gene": gene,
88
+ "nct_id": nct,
89
+ "title": study.get("brief_title"),
90
+ "status": study.get("status"),
91
+ "phases": study.get("phases") or [],
92
+ "study_type": study.get("study_type"),
93
+ "conditions": study.get("conditions") or [],
94
+ "interventions": study.get("interventions") or [],
95
+ "sponsor": study.get("sponsor"),
96
+ "start_date": study.get("start_date"),
97
+ "completion_date": study.get("completion_date"),
98
+ },
99
+ result,
100
+ ref=nct,
101
+ url=f"https://clinicaltrials.gov/study/{nct}",
102
+ )
103
+ )
104
+ per_gene.append((gene, len(studies), int(data.get("total_count") or len(studies))))
105
+
106
+ if not out.rows and out.source_errors and not per_gene:
107
+ return payloads.error(
108
+ ENDPOINT,
109
+ "all_sources_failed",
110
+ "ClinicalTrials.gov did not answer; nothing was looked up.",
111
+ source_errors=out.source_errors,
112
+ )
113
+ lead = according_to(["clinicaltrials"], out.retrieved)
114
+ kind = "of any status" if wanted == "ANY" else wanted
115
+ clauses = []
116
+ for gene, shown, total in per_gene:
117
+ ids = [r["nct_id"] for r in out.rows if r["gene"] == gene]
118
+ listed = f" ({', '.join(ids[:10])}" + (
119
+ f" and {len(ids) - 10} more" if len(ids) > 10 else ""
120
+ )
121
+ listed += ")" if ids else ""
122
+ more = f", showing the first {shown}" if total > shown else ""
123
+ noun = "study" if total == 1 else "studies"
124
+ clauses.append(f"{gene}: {total} {kind} {noun}{listed}{more}")
125
+ sentences = []
126
+ if clauses:
127
+ sentences.append(
128
+ f"{lead}, studies for '{condition_name}' with an intervention matching each gene — "
129
+ + "; ".join(clauses)
130
+ + "."
131
+ )
132
+ sentences.append("Matching is by intervention name only; see query_semantics.")
133
+ if out.source_errors:
134
+ sentences.append(
135
+ "Source problems: "
136
+ + "; ".join(f"{e['source']} {e['reason']}" for e in out.source_errors)
137
+ + "."
138
+ )
139
+ return payloads.ok(
140
+ ENDPOINT,
141
+ query={"genes": symbols, "condition": condition_name, "status": wanted},
142
+ rows=out.rows,
143
+ sources_used=out.sources_used or {"clinicaltrials"},
144
+ narrative=" ".join(sentences),
145
+ unresolved=out.unresolved,
146
+ source_errors=out.source_errors,
147
+ query_semantics=QUERY_SEMANTICS,
148
+ executed_queries=executed,
149
+ )
src/identifiers.py CHANGED
@@ -75,12 +75,14 @@ def _dedupe_key(token: str) -> str:
75
  return token if token.startswith("ENSG") else token.upper()
76
 
77
 
78
- def classify_gene_token(token: str, *, allow_uniprot: bool = False) -> str | None:
 
 
79
  """The identifier kind for one gene/protein token, or None if it is not one."""
80
  if _SAMPLE_ID.match(token):
81
  return None
82
  if ENSEMBL_GENE.match(token):
83
- return "ensembl"
84
  if allow_uniprot and UNIPROT.match(token):
85
  return "uniprot"
86
  if len(token) <= 25 and SYMBOL.match(token):
@@ -88,8 +90,14 @@ def classify_gene_token(token: str, *, allow_uniprot: bool = False) -> str | Non
88
  return None
89
 
90
 
91
- def parse_gene_list(raw: str | list[str] | None, *, allow_uniprot: bool = False) -> Parsed:
92
- """Split, de-duplicate (order kept) and validate a gene list."""
 
 
 
 
 
 
93
  if isinstance(raw, list):
94
  tokens = [str(t).strip() for t in raw]
95
  else:
@@ -103,12 +111,15 @@ def parse_gene_list(raw: str | list[str] | None, *, allow_uniprot: bool = False)
103
  "too_many_identifiers",
104
  f"{len(unique)} identifiers given; at most {MAX_IDENTIFIERS} per call.",
105
  )
106
- bad = [t for t in tokens if classify_gene_token(t, allow_uniprot=allow_uniprot) is None]
 
107
  if bad:
108
  shown = ", ".join(_echo(t) for t in bad[:5])
109
  more = f" and {len(bad) - 5} more" if len(bad) > 5 else ""
110
- accepted = "HGNC symbols or Ensembl gene ids" + (
111
- " or UniProt accessions" if allow_uniprot else ""
 
 
112
  )
113
  return _refuse(
114
  "invalid_identifier",
@@ -122,7 +133,7 @@ def parse_gene_list(raw: str | list[str] | None, *, allow_uniprot: bool = False)
122
  if key in emitted:
123
  continue
124
  emitted.add(key)
125
- out.append(Identifier(t, classify_gene_token(t, allow_uniprot=allow_uniprot) or "symbol"))
126
  return Parsed(identifiers=tuple(out), duplicates_dropped=len(tokens) - len(out))
127
 
128
 
 
75
  return token if token.startswith("ENSG") else token.upper()
76
 
77
 
78
+ def classify_gene_token(
79
+ token: str, *, allow_uniprot: bool = False, allow_ensembl: bool = True
80
+ ) -> str | None:
81
  """The identifier kind for one gene/protein token, or None if it is not one."""
82
  if _SAMPLE_ID.match(token):
83
  return None
84
  if ENSEMBL_GENE.match(token):
85
+ return "ensembl" if allow_ensembl else None
86
  if allow_uniprot and UNIPROT.match(token):
87
  return "uniprot"
88
  if len(token) <= 25 and SYMBOL.match(token):
 
90
  return None
91
 
92
 
93
+ def parse_gene_list(
94
+ raw: str | list[str] | None, *, allow_uniprot: bool = False, allow_ensembl: bool = True
95
+ ) -> Parsed:
96
+ """Split, de-duplicate (order kept) and validate a gene list.
97
+
98
+ ``allow_ensembl=False`` is for the endpoints that resolve through HGNC symbols
99
+ (annotate_genes, pathways_for_genes); drugs_for_targets accepts Ensembl ids directly.
100
+ """
101
  if isinstance(raw, list):
102
  tokens = [str(t).strip() for t in raw]
103
  else:
 
111
  "too_many_identifiers",
112
  f"{len(unique)} identifiers given; at most {MAX_IDENTIFIERS} per call.",
113
  )
114
+ kinds = {"allow_uniprot": allow_uniprot, "allow_ensembl": allow_ensembl}
115
+ bad = [t for t in tokens if classify_gene_token(t, **kinds) is None]
116
  if bad:
117
  shown = ", ".join(_echo(t) for t in bad[:5])
118
  more = f" and {len(bad) - 5} more" if len(bad) > 5 else ""
119
+ accepted = (
120
+ "HGNC symbols"
121
+ + (" or Ensembl gene ids" if allow_ensembl else "")
122
+ + (" or UniProt accessions" if allow_uniprot else "")
123
  )
124
  return _refuse(
125
  "invalid_identifier",
 
133
  if key in emitted:
134
  continue
135
  emitted.add(key)
136
+ out.append(Identifier(t, classify_gene_token(t, **kinds) or "symbol"))
137
  return Parsed(identifiers=tuple(out), duplicates_dropped=len(tokens) - len(out))
138
 
139
 
src/schemas/ChEMBL_get_drug_mechanisms.schema.json ADDED
@@ -0,0 +1,30 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
3
+ "$comment": "Pinned 2026-10-20 (s2) from the responses recorded in tests/fixtures/cassettes/ through ToolUniverse 1.5.6. Only the fields the row builder reads are required; added upstream fields pass.",
4
+ "type": "object",
5
+ "required": [
6
+ "mechanisms"
7
+ ],
8
+ "properties": {
9
+ "mechanisms": {
10
+ "type": "array",
11
+ "items": {
12
+ "type": "object",
13
+ "properties": {
14
+ "mechanism_of_action": {
15
+ "type": [
16
+ "string",
17
+ "null"
18
+ ]
19
+ },
20
+ "action_type": {
21
+ "type": [
22
+ "string",
23
+ "null"
24
+ ]
25
+ }
26
+ }
27
+ }
28
+ }
29
+ }
30
+ }
src/schemas/ClinicalTrials_search_studies.schema.json ADDED
@@ -0,0 +1,67 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
3
+ "$comment": "Pinned 2026-10-20 (s2) from the responses recorded in tests/fixtures/cassettes/ through ToolUniverse 1.5.6. Only the fields the row builder reads are required; added upstream fields pass. executed_query is required: it is how a payload states the query ClinicalTrials.gov actually ran (memo finding 5).",
4
+ "type": "object",
5
+ "required": [
6
+ "studies",
7
+ "total_count",
8
+ "executed_query"
9
+ ],
10
+ "properties": {
11
+ "total_count": {
12
+ "type": "integer",
13
+ "minimum": 0
14
+ },
15
+ "executed_query": {
16
+ "type": "object",
17
+ "required": [
18
+ "query.intr"
19
+ ]
20
+ },
21
+ "studies": {
22
+ "type": "array",
23
+ "items": {
24
+ "type": "object",
25
+ "required": [
26
+ "nct_id"
27
+ ],
28
+ "properties": {
29
+ "nct_id": {
30
+ "type": "string",
31
+ "pattern": "^NCT\\d{8}$"
32
+ },
33
+ "brief_title": {
34
+ "type": [
35
+ "string",
36
+ "null"
37
+ ]
38
+ },
39
+ "status": {
40
+ "type": [
41
+ "string",
42
+ "null"
43
+ ]
44
+ },
45
+ "phases": {
46
+ "type": [
47
+ "array",
48
+ "null"
49
+ ],
50
+ "items": {
51
+ "type": "string"
52
+ }
53
+ },
54
+ "interventions": {
55
+ "type": [
56
+ "array",
57
+ "null"
58
+ ],
59
+ "items": {
60
+ "type": "string"
61
+ }
62
+ }
63
+ }
64
+ }
65
+ }
66
+ }
67
+ }
src/schemas/HGNC_fetch_gene_by_symbol.schema.json ADDED
@@ -0,0 +1,37 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
3
+ "$comment": "Pinned 2026-10-20 (s2) from the responses recorded in tests/fixtures/cassettes/ through ToolUniverse 1.5.6. Only the fields the row builder reads are required; added upstream fields pass. An unknown symbol comes back as an empty object, which is an unresolved input, not drift.",
4
+ "type": "object",
5
+ "dependentRequired": {
6
+ "symbol": [
7
+ "hgnc_id"
8
+ ]
9
+ },
10
+ "properties": {
11
+ "symbol": {
12
+ "type": "string",
13
+ "minLength": 1
14
+ },
15
+ "hgnc_id": {
16
+ "type": "string",
17
+ "pattern": "^HGNC:\\d+$"
18
+ },
19
+ "name": {
20
+ "type": "string"
21
+ },
22
+ "locus_type": {
23
+ "type": "string"
24
+ },
25
+ "ensembl_gene_id": {
26
+ "type": "string",
27
+ "pattern": "^ENSG\\d{11}$"
28
+ },
29
+ "uniprot_ids": {
30
+ "type": "array",
31
+ "items": {
32
+ "type": "string",
33
+ "minLength": 6
34
+ }
35
+ }
36
+ }
37
+ }
src/schemas/OpenTargets_get_associated_drugs_by_target_ensemblID.schema.json CHANGED
@@ -1,6 +1,6 @@
1
  {
2
  "$schema": "https://json-schema.org/draft/2020-12/schema",
3
- "$comment": "Pinned 2026-10-20 (s1) from the Phase 0 response recorded through ToolUniverse 1.5.6 on 2026-10-12 (TNFRSF10B). Only the fields drugs_for_targets reads are required. A null target means the id is unknown to Open Targets: an unresolved input, not drift. `target` is object-or-null in one schema rather than a oneOf, so a drift error names the field that moved instead of 'not valid under any of the given schemas' (JSON Schema's required/properties only apply to objects).",
4
  "type": "object",
5
  "required": [
6
  "target"
@@ -28,8 +28,7 @@
28
  "drugAndClinicalCandidates": {
29
  "type": "object",
30
  "required": [
31
- "count",
32
- "rows"
33
  ],
34
  "properties": {
35
  "count": {
@@ -70,6 +69,9 @@
70
  }
71
  }
72
  }
 
 
 
73
  }
74
  }
75
  }
 
1
  {
2
  "$schema": "https://json-schema.org/draft/2020-12/schema",
3
+ "$comment": "Pinned 2026-10-20 (s1) from the Phase 0 response recorded through ToolUniverse 1.5.6 on 2026-10-12 (TNFRSF10B). Only the fields drugs_for_targets reads are required. A null target means the id is unknown to Open Targets: an unresolved input, not drift. `target` is object-or-null in one schema rather than a oneOf, so a drift error names the field that moved instead of 'not valid under any of the given schemas' (JSON Schema's required/properties only apply to objects). s2: `rows` is optional because ToolUniverse omits it when the count is 0 (STAT1, recorded 2026-10-20); `truncated` says it returned fewer rows than Open Targets holds.",
4
  "type": "object",
5
  "required": [
6
  "target"
 
28
  "drugAndClinicalCandidates": {
29
  "type": "object",
30
  "required": [
31
+ "count"
 
32
  ],
33
  "properties": {
34
  "count": {
 
69
  }
70
  }
71
  }
72
+ },
73
+ "truncated": {
74
+ "type": "boolean"
75
  }
76
  }
77
  }
src/schemas/OpenTargets_get_associated_targets_by_disease_efoId.schema.json ADDED
@@ -0,0 +1,75 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
3
+ "$comment": "Pinned 2026-10-20 (s2) from the responses recorded in tests/fixtures/cassettes/ through ToolUniverse 1.5.6. Only the fields the row builder reads are required; added upstream fields pass. Scores are Open Targets' association scores, relayed as such.",
4
+ "type": "object",
5
+ "required": [
6
+ "disease"
7
+ ],
8
+ "properties": {
9
+ "disease": {
10
+ "type": [
11
+ "object",
12
+ "null"
13
+ ],
14
+ "required": [
15
+ "id",
16
+ "associatedTargets"
17
+ ],
18
+ "properties": {
19
+ "id": {
20
+ "type": "string"
21
+ },
22
+ "name": {
23
+ "type": "string"
24
+ },
25
+ "associatedTargets": {
26
+ "type": "object",
27
+ "required": [
28
+ "count",
29
+ "rows"
30
+ ],
31
+ "properties": {
32
+ "count": {
33
+ "type": "integer",
34
+ "minimum": 0
35
+ },
36
+ "rows": {
37
+ "type": "array",
38
+ "items": {
39
+ "type": "object",
40
+ "required": [
41
+ "target",
42
+ "score"
43
+ ],
44
+ "properties": {
45
+ "target": {
46
+ "type": "object",
47
+ "required": [
48
+ "id",
49
+ "approvedSymbol"
50
+ ],
51
+ "properties": {
52
+ "id": {
53
+ "type": "string",
54
+ "pattern": "^ENSG\\d{11}$"
55
+ },
56
+ "approvedSymbol": {
57
+ "type": "string",
58
+ "minLength": 1
59
+ }
60
+ }
61
+ },
62
+ "score": {
63
+ "type": "number",
64
+ "minimum": 0,
65
+ "maximum": 1
66
+ }
67
+ }
68
+ }
69
+ }
70
+ }
71
+ }
72
+ }
73
+ }
74
+ }
75
+ }
src/schemas/OpenTargets_get_disease_id_description_by_name.schema.json ADDED
@@ -0,0 +1,37 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
3
+ "$comment": "Pinned 2026-10-20 (s2) from the responses recorded in tests/fixtures/cassettes/ through ToolUniverse 1.5.6. Only the fields the row builder reads are required; added upstream fields pass.",
4
+ "type": "object",
5
+ "required": [
6
+ "search"
7
+ ],
8
+ "properties": {
9
+ "search": {
10
+ "type": "object",
11
+ "required": [
12
+ "hits"
13
+ ],
14
+ "properties": {
15
+ "hits": {
16
+ "type": "array",
17
+ "items": {
18
+ "type": "object",
19
+ "required": [
20
+ "id",
21
+ "name"
22
+ ],
23
+ "properties": {
24
+ "id": {
25
+ "type": "string",
26
+ "minLength": 1
27
+ },
28
+ "name": {
29
+ "type": "string"
30
+ }
31
+ }
32
+ }
33
+ }
34
+ }
35
+ }
36
+ }
37
+ }
src/schemas/Reactome_map_uniprot_to_pathways.schema.json ADDED
@@ -0,0 +1,25 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
3
+ "$comment": "Pinned 2026-10-20 (s2) from the responses recorded in tests/fixtures/cassettes/ through ToolUniverse 1.5.6. Only the fields the row builder reads are required; added upstream fields pass. Reactome's UniProt mapping returns the lowest-level pathways for the protein.",
4
+ "type": "array",
5
+ "items": {
6
+ "type": "object",
7
+ "required": [
8
+ "stId",
9
+ "displayName"
10
+ ],
11
+ "properties": {
12
+ "stId": {
13
+ "type": "string",
14
+ "pattern": "^R-[A-Z]{3}-\\d+$"
15
+ },
16
+ "displayName": {
17
+ "type": "string",
18
+ "minLength": 1
19
+ },
20
+ "isInDisease": {
21
+ "type": "boolean"
22
+ }
23
+ }
24
+ }
25
+ }
src/schemas/UniProt_search.schema.json ADDED
@@ -0,0 +1,40 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
3
+ "$comment": "Pinned 2026-10-20 (s2) from the responses recorded in tests/fixtures/cassettes/ through ToolUniverse 1.5.6. Only the fields the row builder reads are required; added upstream fields pass.",
4
+ "type": "object",
5
+ "required": [
6
+ "results"
7
+ ],
8
+ "properties": {
9
+ "results": {
10
+ "type": "array",
11
+ "items": {
12
+ "type": "object",
13
+ "required": [
14
+ "primaryAccession"
15
+ ],
16
+ "properties": {
17
+ "primaryAccession": {
18
+ "type": "string",
19
+ "minLength": 6
20
+ },
21
+ "proteinDescription": {
22
+ "type": "object"
23
+ },
24
+ "genes": {
25
+ "type": "array"
26
+ },
27
+ "comments": {
28
+ "type": "array",
29
+ "items": {
30
+ "type": "object",
31
+ "required": [
32
+ "commentType"
33
+ ]
34
+ }
35
+ }
36
+ }
37
+ }
38
+ }
39
+ }
40
+ }
src/tu_client.py CHANGED
@@ -264,9 +264,12 @@ class ToolClient:
264
  limiters: RateLimiters | None = None,
265
  clock: Callable[[], float] = time.time,
266
  tooluniverse_version: str | None = None,
 
267
  ):
268
  self._runner = runner
269
- self._tu = None
 
 
270
  self.cache = cache if cache is not None else ResponseCache()
271
  self.limiters = limiters if limiters is not None else RateLimiters()
272
  self._clock = clock
@@ -308,18 +311,22 @@ class ToolClient:
308
 
309
  runner = self._runner or self._default_runner
310
  future = self._pool.submit(runner, tool, dict(args))
311
- try:
312
- raw = future.result(timeout=cfg["timeout_seconds"])
313
- except concurrent.futures.TimeoutError:
 
 
314
  future.cancel()
315
  raise SourceError(
316
  source,
317
  "source_unavailable",
318
  f"{cfg['name']} did not answer within {cfg['timeout_seconds']} s",
319
- ) from None
 
 
320
  except SourceError:
321
  raise
322
- except Exception as e:
323
  raise SourceError(source, "source_unavailable", f"{tool}: {type(e).__name__}") from e
324
  retrieved_at = self._clock()
325
 
 
264
  limiters: RateLimiters | None = None,
265
  clock: Callable[[], float] = time.time,
266
  tooluniverse_version: str | None = None,
267
+ tooluniverse: Any = None,
268
  ):
269
  self._runner = runner
270
+ # gradio_ui passes an instance built at import, so a Space that must not start (a model
271
+ # key, a version drift, a widened tool set) fails at startup, not on its first call.
272
+ self._tu = tooluniverse
273
  self.cache = cache if cache is not None else ResponseCache()
274
  self.limiters = limiters if limiters is not None else RateLimiters()
275
  self._clock = clock
 
311
 
312
  runner = self._runner or self._default_runner
313
  future = self._pool.submit(runner, tool, dict(args))
314
+ # `wait`, not `result(timeout=…)`: since Python 3.11 concurrent.futures.TimeoutError IS the
315
+ # builtin TimeoutError, so a source's own read timeout raised inside the call would be
316
+ # indistinguishable from our deadline passing (caught by a test on 2026-10-20).
317
+ done, _ = concurrent.futures.wait([future], timeout=cfg["timeout_seconds"])
318
+ if not done:
319
  future.cancel()
320
  raise SourceError(
321
  source,
322
  "source_unavailable",
323
  f"{cfg['name']} did not answer within {cfg['timeout_seconds']} s",
324
+ )
325
+ try:
326
+ raw = future.result()
327
  except SourceError:
328
  raise
329
+ except Exception as e: # any failure inside a source call is structured, never raised
330
  raise SourceError(source, "source_unavailable", f"{tool}: {type(e).__name__}") from e
331
  retrieved_at = self._clock()
332
 
tests/conftest.py CHANGED
@@ -43,6 +43,34 @@ def _blocked_connect(self, address):
43
 
44
  socket.socket.connect = _blocked_connect # type: ignore[method-assign]
45
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
46
  FIXTURES = Path(__file__).resolve().parent / "fixtures"
47
 
48
 
@@ -101,3 +129,71 @@ def memory_cache(clock):
101
  def tnfrsf10b_response() -> dict:
102
  """The real Open Targets response for DR5, recorded through ToolUniverse 1.5.6 in Phase 0."""
103
  return load_fixture("phase0/opentargets_drugs_TNFRSF10B.json")["response"]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
43
 
44
  socket.socket.connect = _blocked_connect # type: ignore[method-assign]
45
 
46
+ # gradio's local LoginButton mock reaches for a HuggingFace identity while the app graph builds at
47
+ # `import gradio_ui`. Stub `_get_mocked_oauth_info` (NOT `whoami`, which gradio calls through its
48
+ # own reference) — the pdac-spatial-agent lesson of 2026-09-28. A gradio upgrade can rename this
49
+ # private, and `setattr` on a missing name succeeds silently, so fail here if it is gone.
50
+ import gradio.oauth as _gr_oauth
51
+
52
+ _GRADIO_OAUTH_MOCK_HOOK = "_get_mocked_oauth_info"
53
+ if not hasattr(_gr_oauth, _GRADIO_OAUTH_MOCK_HOOK):
54
+ raise RuntimeError(
55
+ f"gradio.oauth.{_GRADIO_OAUTH_MOCK_HOOK} no longer exists; re-point the offline OAuth "
56
+ "stub in tests/conftest.py (a stale stub, not a network problem)."
57
+ )
58
+ _gr_oauth._get_mocked_oauth_info = lambda: { # type: ignore[assignment]
59
+ "access_token": "test-token",
60
+ "token_type": "bearer",
61
+ "expires_at": 0,
62
+ "expires_in": 28800,
63
+ "scope": "openid profile",
64
+ "userinfo": {
65
+ "sub": "test-user-id",
66
+ "name": "Test User",
67
+ "preferred_username": "test-user",
68
+ "profile": "https://huggingface.co/test-user",
69
+ "picture": None,
70
+ "email": None,
71
+ },
72
+ }
73
+
74
  FIXTURES = Path(__file__).resolve().parent / "fixtures"
75
 
76
 
 
129
  def tnfrsf10b_response() -> dict:
130
  """The real Open Targets response for DR5, recorded through ToolUniverse 1.5.6 in Phase 0."""
131
  return load_fixture("phase0/opentargets_drugs_TNFRSF10B.json")["response"]
132
+
133
+
134
+ # --------------------------------------------------------------------------- #
135
+ # Cassette replay: what the sources said on the recording day, with sockets blocked.
136
+ # --------------------------------------------------------------------------- #
137
+ CASSETTES = FIXTURES / "cassettes"
138
+ SCENARIOS = sorted(p.stem for p in CASSETTES.glob("*.json"))
139
+
140
+
141
+ class CassetteRunner:
142
+ """Answers each (tool, arguments) with the response recorded for it, or fails loudly."""
143
+
144
+ def __init__(self, name: str):
145
+ from src.cache import cache_key
146
+
147
+ self.name = name
148
+ self.cassette = json.loads((CASSETTES / f"{name}.json").read_text(encoding="utf-8"))
149
+ self._key = cache_key
150
+ self.responses = {
151
+ cache_key(c["tool"], c["args"]): c["response"] for c in self.cassette["calls"]
152
+ }
153
+ self.calls: list[tuple[str, dict]] = []
154
+
155
+ def __call__(self, tool: str, args: dict):
156
+ self.calls.append((tool, dict(args)))
157
+ try:
158
+ return self.responses[self._key(tool, args)]
159
+ except KeyError:
160
+ raise AssertionError(
161
+ f"{tool} {args} is not in cassette {self.name!r}: the endpoint now makes a call "
162
+ "that was not recorded. Re-record with scripts/record_fixtures.py and review the diff."
163
+ ) from None
164
+
165
+
166
+ def replay_client(name: str):
167
+ """(client, runner) replaying cassette ``name`` at its recording time, rate limits on a fake clock."""
168
+ from src.cache import ResponseCache
169
+ from src.ratelimit import RateLimiters
170
+ from src.tu_client import ToolClient
171
+
172
+ runner = CassetteRunner(name)
173
+ clock = FakeClock(start=runner.cassette["_recorded"]["recorded_at_epoch"])
174
+ client = ToolClient(
175
+ runner=runner,
176
+ cache=ResponseCache(":memory:", clock=clock),
177
+ limiters=RateLimiters(clock=clock, sleep=clock.sleep),
178
+ clock=clock,
179
+ tooluniverse_version=runner.cassette["_recorded"]["tooluniverse_version"],
180
+ )
181
+ return client, runner
182
+
183
+
184
+ @pytest.fixture()
185
+ def replay():
186
+ """``replay(name)`` → the endpoint's payload for that recorded scenario."""
187
+ from src.endpoints import ENDPOINTS, _common
188
+
189
+ runners = []
190
+
191
+ def run(name: str) -> dict:
192
+ client, runner = replay_client(name)
193
+ runners.append(runner)
194
+ _common.set_client(client)
195
+ meta = runner.cassette["_recorded"]
196
+ return ENDPOINTS[meta["endpoint"]][0](*meta["arguments"])
197
+
198
+ yield run
199
+ _common.set_client(None)
tests/fixtures/cassettes/c1_annotate_kras_tp53_sord2p.json ADDED
@@ -0,0 +1,1025 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "_recorded": {
3
+ "scenario": "c1_annotate_kras_tp53_sord2p",
4
+ "why": "plan C1(c): SORD2P listed as unresolved, not guessed",
5
+ "endpoint": "annotate_genes",
6
+ "arguments": [
7
+ "KRAS,TP53,SORD2P"
8
+ ],
9
+ "tooluniverse_version": "1.5.6",
10
+ "recorded_on": "2026-10-21T04:43:45Z",
11
+ "recorded_at_epoch": 1792557823.37,
12
+ "recorded_with": "scripts/record_fixtures.py",
13
+ "payload_status_when_recorded": "ok",
14
+ "licences": {
15
+ "hgnc": "No restrictions on access or use (HGNC data release policy); citation requested",
16
+ "uniprot": "CC BY 4.0"
17
+ }
18
+ },
19
+ "calls": [
20
+ {
21
+ "tool": "HGNC_fetch_gene_by_symbol",
22
+ "args": {
23
+ "symbol": "KRAS"
24
+ },
25
+ "response": {
26
+ "status": "success",
27
+ "data": {
28
+ "hgnc_id": "HGNC:6407",
29
+ "gencc": "HGNC:6407",
30
+ "alias_symbol": [
31
+ "KRAS1",
32
+ "K-Ras4B"
33
+ ],
34
+ "rgd_id": [
35
+ "RGD:2981"
36
+ ],
37
+ "ucsc_id": "uc001rgp.3",
38
+ "cosmic": "KRAS",
39
+ "location": "12p12.1",
40
+ "uniprot_ids": [
41
+ "P01116"
42
+ ],
43
+ "prev_name": [
44
+ "KRAS proto-oncogene, GTPase",
45
+ "v-Ki-ras2 Kirsten rat sarcoma 2 viral oncogene homolog",
46
+ "v-Ki-ras2 Kirsten rat sarcoma viral oncogene homolog",
47
+ "Kirsten rat sarcoma viral oncogene homolog"
48
+ ],
49
+ "omim_id": [
50
+ "190070"
51
+ ],
52
+ "uuid": "4e5a8988-4945-4b5b-9182-a394193ae050",
53
+ "lsdb": [
54
+ "LRG_344|http://ftp.ebi.ac.uk/pub/databases/lrgex/LRG_344.xml"
55
+ ],
56
+ "name": "KRas proto-oncogene, GTPase",
57
+ "status": "Approved",
58
+ "iuphar": "HGNC:6407",
59
+ "mane_select": [
60
+ "ENST00000311936.8",
61
+ "NM_004985.5"
62
+ ],
63
+ "refseq_accession": [
64
+ "NM_033360"
65
+ ],
66
+ "agr": "HGNC:6407",
67
+ "date_modified": "2026-06-10T00:00:00Z",
68
+ "locus_type": "gene with protein product",
69
+ "date_approved_reserved": "1988-06-02T00:00:00Z",
70
+ "date_name_changed": "2026-06-10T00:00:00Z",
71
+ "entrez_id": "3845",
72
+ "prev_symbol": [
73
+ "KRAS2"
74
+ ],
75
+ "gene_group": [
76
+ "RAS type GTPase family"
77
+ ],
78
+ "ena": [
79
+ "BC010502"
80
+ ],
81
+ "mgd_id": [
82
+ "MGI:96680"
83
+ ],
84
+ "ccds_id": [
85
+ "CCDS8702",
86
+ "CCDS8703"
87
+ ],
88
+ "ensembl_gene_id": "ENSG00000133703",
89
+ "vega_id": "OTTHUMG00000171193",
90
+ "locus_group": "protein-coding gene",
91
+ "orphanet": 122879,
92
+ "date_symbol_changed": "2005-01-24T00:00:00Z",
93
+ "gene_group_id": [
94
+ 389
95
+ ],
96
+ "symbol": "KRAS"
97
+ },
98
+ "metadata": {
99
+ "source": "HGNC",
100
+ "query_field": "symbol",
101
+ "query_value": "KRAS",
102
+ "num_found": 1
103
+ }
104
+ }
105
+ },
106
+ {
107
+ "tool": "HGNC_fetch_gene_by_symbol",
108
+ "args": {
109
+ "symbol": "TP53"
110
+ },
111
+ "response": {
112
+ "status": "success",
113
+ "data": {
114
+ "symbol": "TP53",
115
+ "locus_group": "protein-coding gene",
116
+ "orphanet": 120204,
117
+ "gene_group_id": [
118
+ 4068
119
+ ],
120
+ "vega_id": "OTTHUMG00000162125",
121
+ "ensembl_gene_id": "ENSG00000141510",
122
+ "ccds_id": [
123
+ "CCDS11118",
124
+ "CCDS45605",
125
+ "CCDS45606",
126
+ "CCDS73963",
127
+ "CCDS73964",
128
+ "CCDS73965",
129
+ "CCDS73966",
130
+ "CCDS73967",
131
+ "CCDS73968",
132
+ "CCDS73969",
133
+ "CCDS73970",
134
+ "CCDS73971"
135
+ ],
136
+ "gene_group": [
137
+ "p53 transcription factor family"
138
+ ],
139
+ "ena": [
140
+ "AF307851"
141
+ ],
142
+ "mgd_id": [
143
+ "MGI:98834"
144
+ ],
145
+ "date_approved_reserved": "1986-01-01T00:00:00Z",
146
+ "date_name_changed": "2008-01-16T00:00:00Z",
147
+ "entrez_id": "7157",
148
+ "date_modified": "2023-01-20T00:00:00Z",
149
+ "alias_name": [
150
+ "Li-Fraumeni syndrome"
151
+ ],
152
+ "locus_type": "gene with protein product",
153
+ "pubmed_id": [
154
+ 6396087,
155
+ 3456488,
156
+ 2047879
157
+ ],
158
+ "refseq_accession": [
159
+ "NM_000546"
160
+ ],
161
+ "mane_select": [
162
+ "ENST00000269305.9",
163
+ "NM_000546.6"
164
+ ],
165
+ "agr": "HGNC:11998",
166
+ "status": "Approved",
167
+ "omim_id": [
168
+ "191170"
169
+ ],
170
+ "uuid": "22e519f0-ad02-4326-b78b-d972b5873187",
171
+ "lsdb": [
172
+ "IARC TP53 Mutation Database|http://www-p53.iarc.fr/",
173
+ "p53 UMD TP53 mutation database|http://p53.fr/",
174
+ "Database of Germline p53 Mutations|http://www.lf2.cuni.cz/projects/germline_mut_p53.htm",
175
+ "MUTP53LOAD, Mutant p53 Loss Of Activity Database|http://www.umd.be:2072/",
176
+ "LRG_321|http://ftp.ebi.ac.uk/pub/databases/lrgex/LRG_321.xml"
177
+ ],
178
+ "name": "tumor protein p53",
179
+ "rgd_id": [
180
+ "RGD:3889"
181
+ ],
182
+ "ucsc_id": "uc060aur.1",
183
+ "location": "17p13.1",
184
+ "cosmic": "TP53",
185
+ "uniprot_ids": [
186
+ "P04637"
187
+ ],
188
+ "hgnc_id": "HGNC:11998",
189
+ "gencc": "HGNC:11998",
190
+ "alias_symbol": [
191
+ "p53",
192
+ "LFS1"
193
+ ]
194
+ },
195
+ "metadata": {
196
+ "source": "HGNC",
197
+ "query_field": "symbol",
198
+ "query_value": "TP53",
199
+ "num_found": 1
200
+ }
201
+ }
202
+ },
203
+ {
204
+ "tool": "HGNC_fetch_gene_by_symbol",
205
+ "args": {
206
+ "symbol": "SORD2P"
207
+ },
208
+ "response": {
209
+ "status": "success",
210
+ "data": {
211
+ "date_approved_reserved": "2014-03-21T00:00:00Z",
212
+ "hgnc_id": "HGNC:49919",
213
+ "entrez_id": "653381",
214
+ "location": "15q21.1",
215
+ "uuid": "002482ab-9cb8-42cd-b314-14153e1dfe38",
216
+ "name": "sorbitol dehydrogenase 2, pseudogene",
217
+ "status": "Approved",
218
+ "ensembl_gene_id": "ENSG00000259479",
219
+ "pubmed_id": [
220
+ 9880675
221
+ ],
222
+ "agr": "HGNC:49919",
223
+ "refseq_accession": [
224
+ "NR_146393"
225
+ ],
226
+ "vega_id": "OTTHUMG00000171966",
227
+ "pseudogene.org": "PGOHUM00000246773",
228
+ "locus_group": "pseudogene",
229
+ "date_modified": "2014-03-21T00:00:00Z",
230
+ "symbol": "SORD2P",
231
+ "locus_type": "pseudogene"
232
+ },
233
+ "metadata": {
234
+ "source": "HGNC",
235
+ "query_field": "symbol",
236
+ "query_value": "SORD2P",
237
+ "num_found": 1
238
+ }
239
+ }
240
+ },
241
+ {
242
+ "tool": "UniProt_search",
243
+ "args": {
244
+ "query": "(accession:P01116 OR accession:P04637) AND reviewed:true AND organism_id:9606",
245
+ "limit": 2,
246
+ "fields": [
247
+ "accession",
248
+ "gene_primary",
249
+ "protein_name",
250
+ "cc_function",
251
+ "cc_subcellular_location"
252
+ ]
253
+ },
254
+ "response": {
255
+ "status": "success",
256
+ "data": {
257
+ "total_results": 2,
258
+ "returned": 2,
259
+ "results": [
260
+ {
261
+ "entryType": "UniProtKB reviewed (Swiss-Prot)",
262
+ "primaryAccession": "P01116",
263
+ "proteinDescription": {
264
+ "recommendedName": {
265
+ "fullName": {
266
+ "value": "GTPase KRas"
267
+ },
268
+ "ecNumbers": [
269
+ {
270
+ "evidences": [
271
+ {
272
+ "evidenceCode": "ECO:0000269",
273
+ "source": "PubMed",
274
+ "id": "20949621"
275
+ },
276
+ {
277
+ "evidenceCode": "ECO:0000269",
278
+ "source": "PubMed",
279
+ "id": "39809765"
280
+ }
281
+ ],
282
+ "value": "3.6.5.2"
283
+ }
284
+ ]
285
+ },
286
+ "alternativeNames": [
287
+ {
288
+ "fullName": {
289
+ "value": "K-Ras 2"
290
+ }
291
+ },
292
+ {
293
+ "fullName": {
294
+ "value": "Ki-Ras"
295
+ }
296
+ },
297
+ {
298
+ "fullName": {
299
+ "value": "c-K-ras"
300
+ }
301
+ },
302
+ {
303
+ "fullName": {
304
+ "value": "c-Ki-ras"
305
+ }
306
+ }
307
+ ],
308
+ "contains": [
309
+ {
310
+ "recommendedName": {
311
+ "fullName": {
312
+ "value": "GTPase KRas, N-terminally processed"
313
+ }
314
+ }
315
+ }
316
+ ],
317
+ "flag": "Precursor"
318
+ },
319
+ "genes": [
320
+ {
321
+ "geneName": {
322
+ "value": "KRAS"
323
+ }
324
+ }
325
+ ],
326
+ "comments": [
327
+ {
328
+ "texts": [
329
+ {
330
+ "evidences": [
331
+ {
332
+ "evidenceCode": "ECO:0000250",
333
+ "source": "UniProtKB",
334
+ "id": "P08644"
335
+ },
336
+ {
337
+ "evidenceCode": "ECO:0000269",
338
+ "source": "PubMed",
339
+ "id": "20949621"
340
+ },
341
+ {
342
+ "evidenceCode": "ECO:0000269",
343
+ "source": "PubMed",
344
+ "id": "22711838"
345
+ },
346
+ {
347
+ "evidenceCode": "ECO:0000269",
348
+ "source": "PubMed",
349
+ "id": "23698361"
350
+ },
351
+ {
352
+ "evidenceCode": "ECO:0000269",
353
+ "source": "PubMed",
354
+ "id": "24623306"
355
+ },
356
+ {
357
+ "evidenceCode": "ECO:0000269",
358
+ "source": "PubMed",
359
+ "id": "39809765"
360
+ },
361
+ {
362
+ "evidenceCode": "ECO:0000269",
363
+ "source": "PubMed",
364
+ "id": "40934300"
365
+ }
366
+ ],
367
+ "value": "Signal transducer in the Ras-MAPK signaling pathway that regulates cell proliferation and survival (PubMed:22711838, PubMed:23698361). Ras proteins bind GDP/GTP and possess intrinsic GTPase activity (PubMed:20949621, PubMed:39809765). Activates MAPK1/MAPK3 resulting in phosphorylation and ultimately degradation of GJA1 (By similarity). Plays a role in promoting oncogenic events by inducing transcriptional silencing of tumor suppressor genes (TSGs) in colorectal cancer (CRC) cells in a ZNF304-dependent manner (PubMed:24623306). Recognized by LZTR1 that mediates its ubiquitination by a BCR (BTB-CUL3-RBX1) E3 ubiquitin-protein ligase complex (PubMed:40934300)"
368
+ }
369
+ ],
370
+ "commentType": "FUNCTION"
371
+ },
372
+ {
373
+ "commentType": "SUBCELLULAR LOCATION",
374
+ "subcellularLocations": [
375
+ {
376
+ "location": {
377
+ "evidences": [
378
+ {
379
+ "evidenceCode": "ECO:0000269",
380
+ "source": "PubMed",
381
+ "id": "22431598"
382
+ },
383
+ {
384
+ "evidenceCode": "ECO:0000269",
385
+ "source": "PubMed",
386
+ "id": "23524970"
387
+ },
388
+ {
389
+ "evidenceCode": "ECO:0000269",
390
+ "source": "PubMed",
391
+ "id": "23698361"
392
+ },
393
+ {
394
+ "evidenceCode": "ECO:0000269",
395
+ "source": "PubMed",
396
+ "id": "29239724"
397
+ }
398
+ ],
399
+ "value": "Cell membrane",
400
+ "id": "SL-0039"
401
+ },
402
+ "topology": {
403
+ "evidences": [
404
+ {
405
+ "evidenceCode": "ECO:0000269",
406
+ "source": "PubMed",
407
+ "id": "29239724"
408
+ },
409
+ {
410
+ "evidenceCode": "ECO:0000305",
411
+ "source": "PubMed",
412
+ "id": "23698361"
413
+ }
414
+ ],
415
+ "value": "Lipid-anchor",
416
+ "id": "SL-9901"
417
+ },
418
+ "orientation": {
419
+ "evidences": [
420
+ {
421
+ "evidenceCode": "ECO:0000305",
422
+ "source": "PubMed",
423
+ "id": "23698361"
424
+ }
425
+ ],
426
+ "value": "Cytoplasmic side",
427
+ "id": "SL-9910"
428
+ }
429
+ },
430
+ {
431
+ "location": {
432
+ "evidences": [
433
+ {
434
+ "evidenceCode": "ECO:0000269",
435
+ "source": "PubMed",
436
+ "id": "29239724"
437
+ }
438
+ ],
439
+ "value": "Endomembrane system",
440
+ "id": "SL-0147"
441
+ }
442
+ },
443
+ {
444
+ "location": {
445
+ "evidences": [
446
+ {
447
+ "evidenceCode": "ECO:0000269",
448
+ "source": "PubMed",
449
+ "id": "23524970"
450
+ },
451
+ {
452
+ "evidenceCode": "ECO:0000269",
453
+ "source": "PubMed",
454
+ "id": "23698361"
455
+ }
456
+ ],
457
+ "value": "Cytoplasm",
458
+ "id": "SL-0086"
459
+ }
460
+ }
461
+ ]
462
+ },
463
+ {
464
+ "commentType": "SUBCELLULAR LOCATION",
465
+ "molecule": "Isoform 2B",
466
+ "subcellularLocations": [
467
+ {
468
+ "location": {
469
+ "evidences": [
470
+ {
471
+ "evidenceCode": "ECO:0000269",
472
+ "source": "PubMed",
473
+ "id": "28619714"
474
+ }
475
+ ],
476
+ "value": "Cell membrane",
477
+ "id": "SL-0039"
478
+ },
479
+ "topology": {
480
+ "evidences": [
481
+ {
482
+ "evidenceCode": "ECO:0000305",
483
+ "source": "PubMed",
484
+ "id": "28619714"
485
+ }
486
+ ],
487
+ "value": "Lipid-anchor",
488
+ "id": "SL-9901"
489
+ }
490
+ }
491
+ ]
492
+ }
493
+ ],
494
+ "extraAttributes": {
495
+ "uniParcId": "UPI0000133132"
496
+ }
497
+ },
498
+ {
499
+ "entryType": "UniProtKB reviewed (Swiss-Prot)",
500
+ "primaryAccession": "P04637",
501
+ "proteinDescription": {
502
+ "recommendedName": {
503
+ "fullName": {
504
+ "value": "Cellular tumor antigen p53"
505
+ }
506
+ },
507
+ "alternativeNames": [
508
+ {
509
+ "fullName": {
510
+ "value": "Antigen NY-CO-13"
511
+ }
512
+ },
513
+ {
514
+ "fullName": {
515
+ "value": "Phosphoprotein p53"
516
+ }
517
+ },
518
+ {
519
+ "fullName": {
520
+ "value": "Tumor suppressor p53"
521
+ }
522
+ }
523
+ ]
524
+ },
525
+ "genes": [
526
+ {
527
+ "geneName": {
528
+ "value": "TP53"
529
+ }
530
+ }
531
+ ],
532
+ "comments": [
533
+ {
534
+ "texts": [
535
+ {
536
+ "evidences": [
537
+ {
538
+ "evidenceCode": "ECO:0000269",
539
+ "source": "PubMed",
540
+ "id": "11025664"
541
+ },
542
+ {
543
+ "evidenceCode": "ECO:0000269",
544
+ "source": "PubMed",
545
+ "id": "12524540"
546
+ },
547
+ {
548
+ "evidenceCode": "ECO:0000269",
549
+ "source": "PubMed",
550
+ "id": "12810724"
551
+ },
552
+ {
553
+ "evidenceCode": "ECO:0000269",
554
+ "source": "PubMed",
555
+ "id": "15186775"
556
+ },
557
+ {
558
+ "evidenceCode": "ECO:0000269",
559
+ "source": "PubMed",
560
+ "id": "15340061"
561
+ },
562
+ {
563
+ "evidenceCode": "ECO:0000269",
564
+ "source": "PubMed",
565
+ "id": "17189187"
566
+ },
567
+ {
568
+ "evidenceCode": "ECO:0000269",
569
+ "source": "PubMed",
570
+ "id": "17317671"
571
+ },
572
+ {
573
+ "evidenceCode": "ECO:0000269",
574
+ "source": "PubMed",
575
+ "id": "17349958"
576
+ },
577
+ {
578
+ "evidenceCode": "ECO:0000269",
579
+ "source": "PubMed",
580
+ "id": "19556538"
581
+ },
582
+ {
583
+ "evidenceCode": "ECO:0000269",
584
+ "source": "PubMed",
585
+ "id": "20673990"
586
+ },
587
+ {
588
+ "evidenceCode": "ECO:0000269",
589
+ "source": "PubMed",
590
+ "id": "20959462"
591
+ },
592
+ {
593
+ "evidenceCode": "ECO:0000269",
594
+ "source": "PubMed",
595
+ "id": "22726440"
596
+ },
597
+ {
598
+ "evidenceCode": "ECO:0000269",
599
+ "source": "PubMed",
600
+ "id": "24051492"
601
+ },
602
+ {
603
+ "evidenceCode": "ECO:0000269",
604
+ "source": "PubMed",
605
+ "id": "24652652"
606
+ },
607
+ {
608
+ "evidenceCode": "ECO:0000269",
609
+ "source": "PubMed",
610
+ "id": "35618207"
611
+ },
612
+ {
613
+ "evidenceCode": "ECO:0000269",
614
+ "source": "PubMed",
615
+ "id": "36634798"
616
+ },
617
+ {
618
+ "evidenceCode": "ECO:0000269",
619
+ "source": "PubMed",
620
+ "id": "38653238"
621
+ },
622
+ {
623
+ "evidenceCode": "ECO:0000269",
624
+ "source": "PubMed",
625
+ "id": "9840937"
626
+ }
627
+ ],
628
+ "value": "Multifunctional transcription factor that induces cell cycle arrest, DNA repair or apoptosis upon binding to its target DNA sequence (PubMed:11025664, PubMed:12524540, PubMed:12810724, PubMed:15186775, PubMed:15340061, PubMed:17317671, PubMed:17349958, PubMed:19556538, PubMed:20673990, PubMed:20959462, PubMed:22726440, PubMed:24051492, PubMed:24652652, PubMed:35618207, PubMed:36634798, PubMed:38653238, PubMed:9840937). Acts as a tumor suppressor in many tumor types; induces growth arrest or apoptosis depending on the physiological circumstances and cell type (PubMed:11025664, PubMed:12524540, PubMed:12810724, PubMed:15186775, PubMed:15340061, PubMed:17189187, PubMed:17317671, PubMed:17349958, PubMed:19556538, PubMed:20673990, PubMed:20959462, PubMed:22726440, PubMed:24051492, PubMed:24652652, PubMed:38653238, PubMed:9840937). Negatively regulates cell division by controlling expression of a set of genes required for this process (PubMed:11025664, PubMed:12524540, PubMed:12810724, PubMed:15186775, PubMed:15340061, PubMed:17317671, PubMed:17349958, PubMed:19556538, PubMed:20673990, PubMed:20959462, PubMed:22726440, PubMed:24051492, PubMed:24652652, PubMed:9840937). One of the activated genes is an inhibitor of cyclin-dependent kinases. Apoptosis induction seems to be mediated either by stimulation of BAX and FAS antigen expression, or by repression of Bcl-2 expression (PubMed:12524540, PubMed:17189187). Its pro-apoptotic activity is activated via its interaction with PPP1R13B/ASPP1 or TP53BP2/ASPP2 (PubMed:12524540). However, this activity is inhibited when the interaction with PPP1R13B/ASPP1 or TP53BP2/ASPP2 is displaced by PPP1R13L/iASPP (PubMed:12524540). In cooperation with mitochondrial PPIF is involved in activating oxidative stress-induced necrosis; the function is largely independent of transcription. Induces the transcription of long intergenic non-coding RNA p21 (lincRNA-p21) and lincRNA-Mkln1. LincRNA-p21 participates in TP53-dependent transcriptional repression leading to apoptosis and seems to have an effect on cell-cycle regulation. Implicated in Notch signaling cross-over. Prevents CDK7 kinase activity when associated to CAK complex in response to DNA damage, thus stopping cell cycle progression. Isoform 2 enhances the transactivation activity of isoform 1 from some but not all TP53-inducible promoters. Isoform 4 suppresses transactivation activity and impairs growth suppression mediated by isoform 1. Isoform 7 inhibits isoform 1-mediated apoptosis. Regulates the circadian clock by repressing CLOCK-BMAL1-mediated transcriptional activation of PER2 (PubMed:24051492)"
629
+ }
630
+ ],
631
+ "commentType": "FUNCTION"
632
+ },
633
+ {
634
+ "commentType": "SUBCELLULAR LOCATION",
635
+ "note": {
636
+ "texts": [
637
+ {
638
+ "evidences": [
639
+ {
640
+ "evidenceCode": "ECO:0000269",
641
+ "source": "PubMed",
642
+ "id": "12810724"
643
+ },
644
+ {
645
+ "evidenceCode": "ECO:0000269",
646
+ "source": "PubMed",
647
+ "id": "22726440"
648
+ },
649
+ {
650
+ "evidenceCode": "ECO:0000269",
651
+ "source": "PubMed",
652
+ "id": "24625977"
653
+ },
654
+ {
655
+ "evidenceCode": "ECO:0000269",
656
+ "source": "PubMed",
657
+ "id": "27323408"
658
+ }
659
+ ],
660
+ "value": "Recruited into PML bodies together with CHEK2 (PubMed:12810724). Translocates to mitochondria upon oxidative stress (PubMed:22726440). Translocates to mitochondria in response to mitomycin C treatment (PubMed:27323408). Competitive inhibition of TP53 interaction with HSPA9/MOT-2 by UBXN2A results in increased protein abundance and subsequent translocation of TP53 to the nucleus (PubMed:24625977)"
661
+ }
662
+ ]
663
+ },
664
+ "subcellularLocations": [
665
+ {
666
+ "location": {
667
+ "evidences": [
668
+ {
669
+ "evidenceCode": "ECO:0000269",
670
+ "source": "PubMed",
671
+ "id": "15340061"
672
+ },
673
+ {
674
+ "evidenceCode": "ECO:0000269",
675
+ "source": "PubMed",
676
+ "id": "17170702"
677
+ },
678
+ {
679
+ "evidenceCode": "ECO:0000269",
680
+ "source": "PubMed",
681
+ "id": "19011621"
682
+ },
683
+ {
684
+ "evidenceCode": "ECO:0000269",
685
+ "source": "PubMed",
686
+ "id": "19033443"
687
+ },
688
+ {
689
+ "evidenceCode": "ECO:0000269",
690
+ "source": "PubMed",
691
+ "id": "21597459"
692
+ },
693
+ {
694
+ "evidenceCode": "ECO:0000269",
695
+ "source": "PubMed",
696
+ "id": "22726440"
697
+ },
698
+ {
699
+ "evidenceCode": "ECO:0000269",
700
+ "source": "PubMed",
701
+ "id": "22914926"
702
+ },
703
+ {
704
+ "evidenceCode": "ECO:0000269",
705
+ "source": "PubMed",
706
+ "id": "24625977"
707
+ },
708
+ {
709
+ "evidenceCode": "ECO:0000269",
710
+ "source": "PubMed",
711
+ "id": "26634371"
712
+ }
713
+ ],
714
+ "value": "Cytoplasm",
715
+ "id": "SL-0086"
716
+ }
717
+ },
718
+ {
719
+ "location": {
720
+ "evidences": [
721
+ {
722
+ "evidenceCode": "ECO:0000269",
723
+ "source": "PubMed",
724
+ "id": "15340061"
725
+ },
726
+ {
727
+ "evidenceCode": "ECO:0000269",
728
+ "source": "PubMed",
729
+ "id": "17170702"
730
+ },
731
+ {
732
+ "evidenceCode": "ECO:0000269",
733
+ "source": "PubMed",
734
+ "id": "17591690"
735
+ },
736
+ {
737
+ "evidenceCode": "ECO:0000269",
738
+ "source": "PubMed",
739
+ "id": "18206965"
740
+ },
741
+ {
742
+ "evidenceCode": "ECO:0000269",
743
+ "source": "PubMed",
744
+ "id": "19011621"
745
+ },
746
+ {
747
+ "evidenceCode": "ECO:0000269",
748
+ "source": "PubMed",
749
+ "id": "21597459"
750
+ },
751
+ {
752
+ "evidenceCode": "ECO:0000269",
753
+ "source": "PubMed",
754
+ "id": "22914926"
755
+ },
756
+ {
757
+ "evidenceCode": "ECO:0000269",
758
+ "source": "PubMed",
759
+ "id": "24625977"
760
+ },
761
+ {
762
+ "evidenceCode": "ECO:0000269",
763
+ "source": "PubMed",
764
+ "id": "26634371"
765
+ }
766
+ ],
767
+ "value": "Nucleus",
768
+ "id": "SL-0191"
769
+ }
770
+ },
771
+ {
772
+ "location": {
773
+ "evidences": [
774
+ {
775
+ "evidenceCode": "ECO:0000269",
776
+ "source": "PubMed",
777
+ "id": "11025664"
778
+ },
779
+ {
780
+ "evidenceCode": "ECO:0000269",
781
+ "source": "PubMed",
782
+ "id": "12810724"
783
+ },
784
+ {
785
+ "evidenceCode": "ECO:0000269",
786
+ "source": "PubMed",
787
+ "id": "36108750"
788
+ }
789
+ ],
790
+ "value": "Nucleus, PML body",
791
+ "id": "SL-0465"
792
+ }
793
+ },
794
+ {
795
+ "location": {
796
+ "evidences": [
797
+ {
798
+ "evidenceCode": "ECO:0000269",
799
+ "source": "PubMed",
800
+ "id": "17170702"
801
+ }
802
+ ],
803
+ "value": "Endoplasmic reticulum",
804
+ "id": "SL-0095"
805
+ }
806
+ },
807
+ {
808
+ "location": {
809
+ "evidences": [
810
+ {
811
+ "evidenceCode": "ECO:0000269",
812
+ "source": "PubMed",
813
+ "id": "22726440"
814
+ },
815
+ {
816
+ "evidenceCode": "ECO:0000269",
817
+ "source": "PubMed",
818
+ "id": "25168243"
819
+ },
820
+ {
821
+ "evidenceCode": "ECO:0000269",
822
+ "source": "PubMed",
823
+ "id": "27323408"
824
+ }
825
+ ],
826
+ "value": "Mitochondrion matrix",
827
+ "id": "SL-0170"
828
+ }
829
+ },
830
+ {
831
+ "location": {
832
+ "evidences": [
833
+ {
834
+ "evidenceCode": "ECO:0000269",
835
+ "source": "PubMed",
836
+ "id": "28842590"
837
+ }
838
+ ],
839
+ "value": "Cytoplasm, cytoskeleton, microtubule organizing center, centrosome",
840
+ "id": "SL-0048"
841
+ }
842
+ }
843
+ ]
844
+ },
845
+ {
846
+ "commentType": "SUBCELLULAR LOCATION",
847
+ "molecule": "Isoform 1",
848
+ "note": {
849
+ "texts": [
850
+ {
851
+ "value": "Predominantly nuclear but localizes to the cytoplasm when expressed with isoform 4"
852
+ }
853
+ ]
854
+ },
855
+ "subcellularLocations": [
856
+ {
857
+ "location": {
858
+ "evidences": [
859
+ {
860
+ "evidenceCode": "ECO:0000269",
861
+ "source": "PubMed",
862
+ "id": "23752197"
863
+ }
864
+ ],
865
+ "value": "Nucleus",
866
+ "id": "SL-0191"
867
+ }
868
+ },
869
+ {
870
+ "location": {
871
+ "value": "Cytoplasm",
872
+ "id": "SL-0086"
873
+ }
874
+ }
875
+ ]
876
+ },
877
+ {
878
+ "commentType": "SUBCELLULAR LOCATION",
879
+ "molecule": "Isoform 2",
880
+ "note": {
881
+ "texts": [
882
+ {
883
+ "value": "Localized mainly in the nucleus with minor staining in the cytoplasm"
884
+ }
885
+ ]
886
+ },
887
+ "subcellularLocations": [
888
+ {
889
+ "location": {
890
+ "value": "Nucleus",
891
+ "id": "SL-0191"
892
+ }
893
+ },
894
+ {
895
+ "location": {
896
+ "value": "Cytoplasm",
897
+ "id": "SL-0086"
898
+ }
899
+ }
900
+ ]
901
+ },
902
+ {
903
+ "commentType": "SUBCELLULAR LOCATION",
904
+ "molecule": "Isoform 3",
905
+ "note": {
906
+ "texts": [
907
+ {
908
+ "value": "Localized in the nucleus in most cells but found in the cytoplasm in some cells"
909
+ }
910
+ ]
911
+ },
912
+ "subcellularLocations": [
913
+ {
914
+ "location": {
915
+ "value": "Nucleus",
916
+ "id": "SL-0191"
917
+ }
918
+ },
919
+ {
920
+ "location": {
921
+ "value": "Cytoplasm",
922
+ "id": "SL-0086"
923
+ }
924
+ }
925
+ ]
926
+ },
927
+ {
928
+ "commentType": "SUBCELLULAR LOCATION",
929
+ "molecule": "Isoform 4",
930
+ "note": {
931
+ "texts": [
932
+ {
933
+ "value": "Predominantly nuclear but translocates to the cytoplasm following cell stress"
934
+ }
935
+ ]
936
+ },
937
+ "subcellularLocations": [
938
+ {
939
+ "location": {
940
+ "value": "Nucleus",
941
+ "id": "SL-0191"
942
+ }
943
+ },
944
+ {
945
+ "location": {
946
+ "value": "Cytoplasm",
947
+ "id": "SL-0086"
948
+ }
949
+ }
950
+ ]
951
+ },
952
+ {
953
+ "commentType": "SUBCELLULAR LOCATION",
954
+ "molecule": "Isoform 7",
955
+ "note": {
956
+ "texts": [
957
+ {
958
+ "value": "Localized mainly in the nucleus with minor staining in the cytoplasm"
959
+ }
960
+ ]
961
+ },
962
+ "subcellularLocations": [
963
+ {
964
+ "location": {
965
+ "value": "Nucleus",
966
+ "id": "SL-0191"
967
+ }
968
+ },
969
+ {
970
+ "location": {
971
+ "value": "Cytoplasm",
972
+ "id": "SL-0086"
973
+ }
974
+ }
975
+ ]
976
+ },
977
+ {
978
+ "commentType": "SUBCELLULAR LOCATION",
979
+ "molecule": "Isoform 8",
980
+ "note": {
981
+ "texts": [
982
+ {
983
+ "value": "Localized in both nucleus and cytoplasm in most cells. In some cells, forms foci in the nucleus that are different from nucleoli"
984
+ }
985
+ ]
986
+ },
987
+ "subcellularLocations": [
988
+ {
989
+ "location": {
990
+ "value": "Nucleus",
991
+ "id": "SL-0191"
992
+ }
993
+ },
994
+ {
995
+ "location": {
996
+ "value": "Cytoplasm",
997
+ "id": "SL-0086"
998
+ }
999
+ }
1000
+ ]
1001
+ },
1002
+ {
1003
+ "commentType": "SUBCELLULAR LOCATION",
1004
+ "molecule": "Isoform 9",
1005
+ "subcellularLocations": [
1006
+ {
1007
+ "location": {
1008
+ "value": "Cytoplasm",
1009
+ "id": "SL-0086"
1010
+ }
1011
+ }
1012
+ ]
1013
+ }
1014
+ ],
1015
+ "extraAttributes": {
1016
+ "uniParcId": "UPI000002ED67"
1017
+ }
1018
+ }
1019
+ ],
1020
+ "normalization_note": "Query scope widened: 'organism_id:<taxid>' was rewritten to 'taxonomy_id:<taxid>'. UniProt's organism_id field (labelled 'Organism [OS]') matches only an entry's own organism node, while taxonomy_id ('Taxonomy [OC]') matches anywhere in the entry's lineage. Most bacterial entries are annotated at strain level, so a species-level organism_id silently returns 0 or an undercount (e.g. 'gene:gyrA AND organism_id:1423 AND reviewed:true' -> 0 hits vs 1 for taxonomy_id; gene:recA + E. coli 562 -> 9 vs 30). Results therefore include descendant strains of the taxid you supplied. To match the exact organism node only, write 'organism_id:<taxid>' yourself against the UniProt REST API, or use organism_name with the exact strain name."
1021
+ }
1022
+ }
1023
+ }
1024
+ ]
1025
+ }
tests/fixtures/cassettes/edge_legacy_efo.json ADDED
@@ -0,0 +1,32 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "_recorded": {
3
+ "scenario": "edge_legacy_efo",
4
+ "why": "a legacy EFO id Open Targets dropped",
5
+ "endpoint": "targets_for_disease",
6
+ "arguments": [
7
+ "EFO_0002618",
8
+ "5"
9
+ ],
10
+ "tooluniverse_version": "1.5.6",
11
+ "recorded_on": "2026-10-21T04:43:47Z",
12
+ "recorded_at_epoch": 1792557827.393,
13
+ "recorded_with": "scripts/record_fixtures.py",
14
+ "payload_status_when_recorded": "refused",
15
+ "licences": {
16
+ "opentargets": "CC0 1.0"
17
+ }
18
+ },
19
+ "calls": [
20
+ {
21
+ "tool": "OpenTargets_get_associated_targets_by_disease_efoId",
22
+ "args": {
23
+ "efoId": "EFO_0002618",
24
+ "size": 5
25
+ },
26
+ "response": {
27
+ "status": "error",
28
+ "error": "OpenTargets returned no disease for ID 'EFO_0002618'. OpenTargets migrated most disease IDs from EFO to MONDO, so many legacy EFO disease IDs now resolve to null. Pass a current MONDO ID (e.g. MONDO_0005011 for Crohn disease); look up a disease's current ID by name with OpenTargets_multi_entity_search_by_query_string."
29
+ }
30
+ }
31
+ ]
32
+ }
tests/fixtures/cassettes/edge_unknown_symbol.json ADDED
@@ -0,0 +1,37 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "_recorded": {
3
+ "scenario": "edge_unknown_symbol",
4
+ "why": "a symbol HGNC does not know",
5
+ "endpoint": "drugs_for_targets",
6
+ "arguments": [
7
+ "NOTAGENE1"
8
+ ],
9
+ "tooluniverse_version": "1.5.6",
10
+ "recorded_on": "2026-10-21T04:43:47Z",
11
+ "recorded_at_epoch": 1792557825.747,
12
+ "recorded_with": "scripts/record_fixtures.py",
13
+ "payload_status_when_recorded": "ok",
14
+ "licences": {
15
+ "hgnc": "No restrictions on access or use (HGNC data release policy); citation requested"
16
+ }
17
+ },
18
+ "calls": [
19
+ {
20
+ "tool": "HGNC_fetch_gene_by_symbol",
21
+ "args": {
22
+ "symbol": "NOTAGENE1"
23
+ },
24
+ "response": {
25
+ "status": "success",
26
+ "data": {},
27
+ "metadata": {
28
+ "source": "HGNC",
29
+ "query_field": "symbol",
30
+ "query_value": "NOTAGENE1",
31
+ "num_found": 0,
32
+ "no_results_note": "'NOTAGENE1' is not known to HGNC as a current symbol, a previous symbol, or an alias symbol. Check the spelling, or use HGNC_search_genes (which supports wildcards and free-text search) to find the gene."
33
+ }
34
+ }
35
+ }
36
+ ]
37
+ }
tests/fixtures/cassettes/q1_q2_drugs_kras.json ADDED
@@ -0,0 +1,605 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "_recorded": {
3
+ "scenario": "q1_q2_drugs_kras",
4
+ "why": "Phase 0 Q1 (KRAS G12D) and Q2 (G12C)",
5
+ "endpoint": "drugs_for_targets",
6
+ "arguments": [
7
+ "KRAS"
8
+ ],
9
+ "tooluniverse_version": "1.5.6",
10
+ "recorded_on": "2026-10-21T04:42:22Z",
11
+ "recorded_at_epoch": 1792557740.022,
12
+ "recorded_with": "scripts/record_fixtures.py",
13
+ "payload_status_when_recorded": "ok",
14
+ "licences": {
15
+ "chembl": "CC BY-SA 3.0",
16
+ "hgnc": "No restrictions on access or use (HGNC data release policy); citation requested",
17
+ "opentargets": "CC0 1.0"
18
+ }
19
+ },
20
+ "calls": [
21
+ {
22
+ "tool": "HGNC_fetch_gene_by_symbol",
23
+ "args": {
24
+ "symbol": "KRAS"
25
+ },
26
+ "response": {
27
+ "status": "success",
28
+ "data": {
29
+ "hgnc_id": "HGNC:6407",
30
+ "gencc": "HGNC:6407",
31
+ "alias_symbol": [
32
+ "KRAS1",
33
+ "K-Ras4B"
34
+ ],
35
+ "rgd_id": [
36
+ "RGD:2981"
37
+ ],
38
+ "ucsc_id": "uc001rgp.3",
39
+ "cosmic": "KRAS",
40
+ "location": "12p12.1",
41
+ "uniprot_ids": [
42
+ "P01116"
43
+ ],
44
+ "prev_name": [
45
+ "KRAS proto-oncogene, GTPase",
46
+ "v-Ki-ras2 Kirsten rat sarcoma 2 viral oncogene homolog",
47
+ "v-Ki-ras2 Kirsten rat sarcoma viral oncogene homolog",
48
+ "Kirsten rat sarcoma viral oncogene homolog"
49
+ ],
50
+ "omim_id": [
51
+ "190070"
52
+ ],
53
+ "uuid": "4e5a8988-4945-4b5b-9182-a394193ae050",
54
+ "lsdb": [
55
+ "LRG_344|http://ftp.ebi.ac.uk/pub/databases/lrgex/LRG_344.xml"
56
+ ],
57
+ "name": "KRas proto-oncogene, GTPase",
58
+ "status": "Approved",
59
+ "iuphar": "HGNC:6407",
60
+ "mane_select": [
61
+ "ENST00000311936.8",
62
+ "NM_004985.5"
63
+ ],
64
+ "refseq_accession": [
65
+ "NM_033360"
66
+ ],
67
+ "agr": "HGNC:6407",
68
+ "date_modified": "2026-06-10T00:00:00Z",
69
+ "locus_type": "gene with protein product",
70
+ "date_approved_reserved": "1988-06-02T00:00:00Z",
71
+ "date_name_changed": "2026-06-10T00:00:00Z",
72
+ "entrez_id": "3845",
73
+ "prev_symbol": [
74
+ "KRAS2"
75
+ ],
76
+ "gene_group": [
77
+ "RAS type GTPase family"
78
+ ],
79
+ "ena": [
80
+ "BC010502"
81
+ ],
82
+ "mgd_id": [
83
+ "MGI:96680"
84
+ ],
85
+ "ccds_id": [
86
+ "CCDS8702",
87
+ "CCDS8703"
88
+ ],
89
+ "ensembl_gene_id": "ENSG00000133703",
90
+ "vega_id": "OTTHUMG00000171193",
91
+ "locus_group": "protein-coding gene",
92
+ "orphanet": 122879,
93
+ "date_symbol_changed": "2005-01-24T00:00:00Z",
94
+ "gene_group_id": [
95
+ 389
96
+ ],
97
+ "symbol": "KRAS"
98
+ },
99
+ "metadata": {
100
+ "source": "HGNC",
101
+ "query_field": "symbol",
102
+ "query_value": "KRAS",
103
+ "num_found": 1
104
+ }
105
+ }
106
+ },
107
+ {
108
+ "tool": "OpenTargets_get_associated_drugs_by_target_ensemblID",
109
+ "args": {
110
+ "ensemblId": "ENSG00000133703"
111
+ },
112
+ "response": {
113
+ "status": "success",
114
+ "data": {
115
+ "target": {
116
+ "id": "ENSG00000133703",
117
+ "approvedSymbol": "KRAS",
118
+ "drugAndClinicalCandidates": {
119
+ "count": 3,
120
+ "rows": [
121
+ {
122
+ "drug": {
123
+ "id": "CHEMBL4535757",
124
+ "name": "SOTORASIB",
125
+ "maximumClinicalStage": "APPROVAL"
126
+ },
127
+ "maxClinicalStage": "APPROVAL",
128
+ "diseases": [
129
+ {
130
+ "disease": {
131
+ "id": "MONDO_0005233",
132
+ "name": "non-small cell lung carcinoma"
133
+ }
134
+ },
135
+ {
136
+ "disease": {
137
+ "id": "MONDO_0005233",
138
+ "name": "non-small cell lung carcinoma"
139
+ }
140
+ },
141
+ {
142
+ "disease": {
143
+ "id": "MONDO_0005070",
144
+ "name": "neoplasm"
145
+ }
146
+ },
147
+ {
148
+ "disease": {
149
+ "id": "MONDO_0005233",
150
+ "name": "non-small cell lung carcinoma"
151
+ }
152
+ },
153
+ {
154
+ "disease": {
155
+ "id": "MONDO_0005575",
156
+ "name": "colorectal cancer"
157
+ }
158
+ },
159
+ {
160
+ "disease": {
161
+ "id": "MONDO_0005233",
162
+ "name": "non-small cell lung carcinoma"
163
+ }
164
+ },
165
+ {
166
+ "disease": {
167
+ "id": "MONDO_0008903",
168
+ "name": "lung cancer"
169
+ }
170
+ },
171
+ {
172
+ "disease": {
173
+ "id": "MONDO_0056806",
174
+ "name": "non-small cell squamous lung carcinoma"
175
+ }
176
+ },
177
+ {
178
+ "disease": {
179
+ "id": "MONDO_0007254",
180
+ "name": "breast cancer"
181
+ }
182
+ },
183
+ {
184
+ "disease": {
185
+ "id": "MONDO_0021040",
186
+ "name": "pancreatic neoplasm"
187
+ }
188
+ },
189
+ {
190
+ "disease": {
191
+ "id": "MONDO_0011962",
192
+ "name": "endometrial cancer"
193
+ }
194
+ },
195
+ {
196
+ "disease": {
197
+ "id": "MONDO_0009831",
198
+ "name": "malignant pancreatic neoplasm"
199
+ }
200
+ },
201
+ {
202
+ "disease": {
203
+ "id": "MONDO_0008170",
204
+ "name": "ovarian cancer"
205
+ }
206
+ },
207
+ {
208
+ "disease": {
209
+ "id": "MONDO_0003060",
210
+ "name": "biliary tract cancer"
211
+ }
212
+ },
213
+ {
214
+ "disease": {
215
+ "id": "MONDO_0005008",
216
+ "name": "colorectal adenocarcinoma"
217
+ }
218
+ },
219
+ {
220
+ "disease": {
221
+ "id": "MONDO_0004992",
222
+ "name": "cancer"
223
+ }
224
+ },
225
+ {
226
+ "disease": {
227
+ "id": "MONDO_0005233",
228
+ "name": "non-small cell lung carcinoma"
229
+ }
230
+ },
231
+ {
232
+ "disease": {
233
+ "id": "MONDO_0005070",
234
+ "name": "neoplasm"
235
+ }
236
+ },
237
+ {
238
+ "disease": {
239
+ "id": "MONDO_0005192",
240
+ "name": "exocrine pancreatic carcinoma"
241
+ }
242
+ },
243
+ {
244
+ "disease": {
245
+ "id": "MONDO_0021350",
246
+ "name": "neoplasm of thorax"
247
+ }
248
+ },
249
+ {
250
+ "disease": {
251
+ "id": "MONDO_0021211",
252
+ "name": "brain neoplasm"
253
+ }
254
+ },
255
+ {
256
+ "disease": {
257
+ "id": "MONDO_0006047",
258
+ "name": "pancreatic adenocarcinoma"
259
+ }
260
+ },
261
+ {
262
+ "disease": {
263
+ "id": "MONDO_0005233",
264
+ "name": "non-small cell lung carcinoma"
265
+ }
266
+ }
267
+ ]
268
+ },
269
+ {
270
+ "drug": {
271
+ "id": "CHEMBL4594350",
272
+ "name": "ADAGRASIB",
273
+ "maximumClinicalStage": "APPROVAL"
274
+ },
275
+ "maxClinicalStage": "APPROVAL",
276
+ "diseases": [
277
+ {
278
+ "disease": {
279
+ "id": "MONDO_0005233",
280
+ "name": "non-small cell lung carcinoma"
281
+ }
282
+ },
283
+ {
284
+ "disease": {
285
+ "id": "MONDO_0005233",
286
+ "name": "non-small cell lung carcinoma"
287
+ }
288
+ },
289
+ {
290
+ "disease": {
291
+ "id": "MONDO_0005070",
292
+ "name": "neoplasm"
293
+ }
294
+ },
295
+ {
296
+ "disease": {
297
+ "id": "MONDO_0005233",
298
+ "name": "non-small cell lung carcinoma"
299
+ }
300
+ },
301
+ {
302
+ "disease": {
303
+ "id": "MONDO_0005233",
304
+ "name": "non-small cell lung carcinoma"
305
+ }
306
+ },
307
+ {
308
+ "disease": {
309
+ "id": "MONDO_0005575",
310
+ "name": "colorectal cancer"
311
+ }
312
+ },
313
+ {
314
+ "disease": {
315
+ "id": "MONDO_0007254",
316
+ "name": "breast cancer"
317
+ }
318
+ },
319
+ {
320
+ "disease": {
321
+ "id": "MONDO_0008903",
322
+ "name": "lung cancer"
323
+ }
324
+ },
325
+ {
326
+ "disease": {
327
+ "id": "MONDO_0005233",
328
+ "name": "non-small cell lung carcinoma"
329
+ }
330
+ },
331
+ {
332
+ "disease": {
333
+ "id": "MONDO_0005184",
334
+ "name": "pancreatic ductal adenocarcinoma"
335
+ }
336
+ },
337
+ {
338
+ "disease": {
339
+ "id": "MONDO_0003060",
340
+ "name": "biliary tract cancer"
341
+ }
342
+ },
343
+ {
344
+ "disease": {
345
+ "id": "MONDO_0021040",
346
+ "name": "pancreatic neoplasm"
347
+ }
348
+ },
349
+ {
350
+ "disease": {
351
+ "id": "MONDO_0009831",
352
+ "name": "malignant pancreatic neoplasm"
353
+ }
354
+ },
355
+ {
356
+ "disease": {
357
+ "id": "MONDO_0005070",
358
+ "name": "neoplasm"
359
+ }
360
+ },
361
+ {
362
+ "disease": {
363
+ "id": "MONDO_0004992",
364
+ "name": "cancer"
365
+ }
366
+ }
367
+ ]
368
+ },
369
+ {
370
+ "drug": {
371
+ "id": "CHEMBL23293",
372
+ "name": "SALIRASIB",
373
+ "maximumClinicalStage": "PHASE_2"
374
+ },
375
+ "maxClinicalStage": "PHASE_2",
376
+ "diseases": [
377
+ {
378
+ "disease": {
379
+ "id": "MONDO_0008903",
380
+ "name": "lung cancer"
381
+ }
382
+ },
383
+ {
384
+ "disease": {
385
+ "id": "MONDO_0005233",
386
+ "name": "non-small cell lung carcinoma"
387
+ }
388
+ },
389
+ {
390
+ "disease": {
391
+ "id": "MONDO_0021040",
392
+ "name": "pancreatic neoplasm"
393
+ }
394
+ },
395
+ {
396
+ "disease": {
397
+ "id": "MONDO_0009831",
398
+ "name": "malignant pancreatic neoplasm"
399
+ }
400
+ }
401
+ ]
402
+ }
403
+ ],
404
+ "returned": 3,
405
+ "truncated": false
406
+ }
407
+ }
408
+ }
409
+ }
410
+ },
411
+ {
412
+ "tool": "ChEMBL_get_drug_mechanisms",
413
+ "args": {
414
+ "drug_chembl_id": "CHEMBL4594350"
415
+ },
416
+ "response": {
417
+ "status": "success",
418
+ "data": {
419
+ "mechanisms": [
420
+ {
421
+ "action_type": "INHIBITOR",
422
+ "binding_site_comment": null,
423
+ "direct_interaction": 1,
424
+ "disease_efficacy": 1,
425
+ "max_phase": 4,
426
+ "mec_id": 8840,
427
+ "mechanism_comment": null,
428
+ "mechanism_of_action": "GTPase KRas inhibitor",
429
+ "mechanism_refs": [
430
+ {
431
+ "ref_id": "31658955",
432
+ "ref_type": "PubMed",
433
+ "ref_url": "https://pubmed.ncbi.nlm.nih.gov/31658955/"
434
+ }
435
+ ],
436
+ "molecular_mechanism": 1,
437
+ "molecule_chembl_id": "CHEMBL4594350",
438
+ "parent_molecule_chembl_id": "CHEMBL4594350",
439
+ "record_id": 3461020,
440
+ "selectivity_comment": null,
441
+ "site_id": null,
442
+ "target_chembl_id": "CHEMBL2189121",
443
+ "variant_sequence": {
444
+ "accession": "P01116",
445
+ "isoform": 1,
446
+ "mutation": "G12C",
447
+ "organism": "Homo sapiens",
448
+ "sequence": "MTEYKLVVVGACGVGKSALTIQLIQNHFVDEYDPTIEDSYRKQVVIDGETCLLDILDTAGQEEYSAMRDQYMRTGEGFLCVFAINNTKSFEDIHHYREQIKRVKDSEDVPMVLVGNKCDLPSRTVDTKQAQDLARSYGIPFIETSAKTRQRVEDAFYTLVREIRQYRLKKISKEEKTPGCVKIKKCIIM",
449
+ "tax_id": 9606,
450
+ "version": 1
451
+ }
452
+ }
453
+ ],
454
+ "page_meta": {
455
+ "limit": 20,
456
+ "next": null,
457
+ "offset": 0,
458
+ "previous": null,
459
+ "total_count": 1
460
+ }
461
+ },
462
+ "url": "https://www.ebi.ac.uk/chembl/api/data/mechanism.json?format=json&parent_molecule_chembl_id=CHEMBL4594350",
463
+ "page_meta": {
464
+ "limit": 20,
465
+ "next": null,
466
+ "offset": 0,
467
+ "previous": null,
468
+ "total_count": 1
469
+ },
470
+ "count": 1
471
+ }
472
+ },
473
+ {
474
+ "tool": "ChEMBL_get_drug_mechanisms",
475
+ "args": {
476
+ "drug_chembl_id": "CHEMBL4535757"
477
+ },
478
+ "response": {
479
+ "status": "success",
480
+ "data": {
481
+ "mechanisms": [
482
+ {
483
+ "action_type": "INHIBITOR",
484
+ "binding_site_comment": null,
485
+ "direct_interaction": 1,
486
+ "disease_efficacy": 1,
487
+ "max_phase": 4,
488
+ "mec_id": 8492,
489
+ "mechanism_comment": "Covalent inhibitor",
490
+ "mechanism_of_action": "GTPase KRas inhibitor",
491
+ "mechanism_refs": [
492
+ {
493
+ "ref_id": "31189530",
494
+ "ref_type": "PubMed",
495
+ "ref_url": "http://europepmc.org/abstract/MED/31189530"
496
+ },
497
+ {
498
+ "ref_id": "31666701",
499
+ "ref_type": "PubMed",
500
+ "ref_url": "http://europepmc.org/abstract/MED/31666701"
501
+ },
502
+ {
503
+ "ref_id": "31820981",
504
+ "ref_type": "PubMed",
505
+ "ref_url": "http://europepmc.org/abstract/MED/31820981"
506
+ },
507
+ {
508
+ "ref_id": "32568546",
509
+ "ref_type": "PubMed",
510
+ "ref_url": "http://europepmc.org/abstract/MED/32568546"
511
+ }
512
+ ],
513
+ "molecular_mechanism": 1,
514
+ "molecule_chembl_id": "CHEMBL4535757",
515
+ "parent_molecule_chembl_id": "CHEMBL4535757",
516
+ "record_id": 3489912,
517
+ "selectivity_comment": null,
518
+ "site_id": null,
519
+ "target_chembl_id": "CHEMBL2189121",
520
+ "variant_sequence": {
521
+ "accession": "P01116",
522
+ "isoform": 1,
523
+ "mutation": "G12C",
524
+ "organism": "Homo sapiens",
525
+ "sequence": "MTEYKLVVVGACGVGKSALTIQLIQNHFVDEYDPTIEDSYRKQVVIDGETCLLDILDTAGQEEYSAMRDQYMRTGEGFLCVFAINNTKSFEDIHHYREQIKRVKDSEDVPMVLVGNKCDLPSRTVDTKQAQDLARSYGIPFIETSAKTRQRVEDAFYTLVREIRQYRLKKISKEEKTPGCVKIKKCIIM",
526
+ "tax_id": 9606,
527
+ "version": 1
528
+ }
529
+ }
530
+ ],
531
+ "page_meta": {
532
+ "limit": 20,
533
+ "next": null,
534
+ "offset": 0,
535
+ "previous": null,
536
+ "total_count": 1
537
+ }
538
+ },
539
+ "url": "https://www.ebi.ac.uk/chembl/api/data/mechanism.json?format=json&parent_molecule_chembl_id=CHEMBL4535757",
540
+ "page_meta": {
541
+ "limit": 20,
542
+ "next": null,
543
+ "offset": 0,
544
+ "previous": null,
545
+ "total_count": 1
546
+ },
547
+ "count": 1
548
+ }
549
+ },
550
+ {
551
+ "tool": "ChEMBL_get_drug_mechanisms",
552
+ "args": {
553
+ "drug_chembl_id": "CHEMBL23293"
554
+ },
555
+ "response": {
556
+ "status": "success",
557
+ "data": {
558
+ "mechanisms": [
559
+ {
560
+ "action_type": "INHIBITOR",
561
+ "binding_site_comment": null,
562
+ "direct_interaction": 1,
563
+ "disease_efficacy": 1,
564
+ "max_phase": 2,
565
+ "mec_id": 8727,
566
+ "mechanism_comment": null,
567
+ "mechanism_of_action": "RAS inhibitor",
568
+ "mechanism_refs": [
569
+ {
570
+ "ref_id": "22547163",
571
+ "ref_type": "PubMed",
572
+ "ref_url": "https://pubmed.ncbi.nlm.nih.gov/22547163/"
573
+ }
574
+ ],
575
+ "molecular_mechanism": 1,
576
+ "molecule_chembl_id": "CHEMBL23293",
577
+ "parent_molecule_chembl_id": "CHEMBL23293",
578
+ "record_id": 1705113,
579
+ "selectivity_comment": null,
580
+ "site_id": null,
581
+ "target_chembl_id": "CHEMBL4524006",
582
+ "variant_sequence": null
583
+ }
584
+ ],
585
+ "page_meta": {
586
+ "limit": 20,
587
+ "next": null,
588
+ "offset": 0,
589
+ "previous": null,
590
+ "total_count": 1
591
+ }
592
+ },
593
+ "url": "https://www.ebi.ac.uk/chembl/api/data/mechanism.json?format=json&parent_molecule_chembl_id=CHEMBL23293",
594
+ "page_meta": {
595
+ "limit": 20,
596
+ "next": null,
597
+ "offset": 0,
598
+ "previous": null,
599
+ "total_count": 1
600
+ },
601
+ "count": 1
602
+ }
603
+ }
604
+ ]
605
+ }
tests/fixtures/cassettes/q3_annotate_elavl1.json ADDED
@@ -0,0 +1,345 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "_recorded": {
3
+ "scenario": "q3_annotate_elavl1",
4
+ "why": "Phase 0 Q3 (HuR function)",
5
+ "endpoint": "annotate_genes",
6
+ "arguments": [
7
+ "ELAVL1"
8
+ ],
9
+ "tooluniverse_version": "1.5.6",
10
+ "recorded_on": "2026-10-21T04:42:24Z",
11
+ "recorded_at_epoch": 1792557742.938,
12
+ "recorded_with": "scripts/record_fixtures.py",
13
+ "payload_status_when_recorded": "ok",
14
+ "licences": {
15
+ "hgnc": "No restrictions on access or use (HGNC data release policy); citation requested",
16
+ "uniprot": "CC BY 4.0"
17
+ }
18
+ },
19
+ "calls": [
20
+ {
21
+ "tool": "HGNC_fetch_gene_by_symbol",
22
+ "args": {
23
+ "symbol": "ELAVL1"
24
+ },
25
+ "response": {
26
+ "status": "success",
27
+ "data": {
28
+ "vega_id": "OTTHUMG00000182470",
29
+ "symbol": "ELAVL1",
30
+ "gene_group_id": [
31
+ 725,
32
+ 2291
33
+ ],
34
+ "locus_group": "protein-coding gene",
35
+ "prev_symbol": [
36
+ "HUR"
37
+ ],
38
+ "date_name_changed": "2013-10-03T00:00:00Z",
39
+ "entrez_id": "1994",
40
+ "date_approved_reserved": "1996-08-12T00:00:00Z",
41
+ "ensembl_gene_id": "ENSG00000066044",
42
+ "ccds_id": [
43
+ "CCDS12193"
44
+ ],
45
+ "mgd_id": [
46
+ "MGI:1100851"
47
+ ],
48
+ "gene_group": [
49
+ "RNA binding motif containing",
50
+ "ELAV like RNA binding protein family"
51
+ ],
52
+ "ena": [
53
+ "U38175"
54
+ ],
55
+ "refseq_accession": [
56
+ "NM_001419"
57
+ ],
58
+ "mane_select": [
59
+ "ENST00000407627.7",
60
+ "NM_001419.3"
61
+ ],
62
+ "agr": "HGNC:3312",
63
+ "pubmed_id": [
64
+ 8626503,
65
+ 9003489,
66
+ 35973378
67
+ ],
68
+ "locus_type": "gene with protein product",
69
+ "date_modified": "2023-11-02T00:00:00Z",
70
+ "alias_name": [
71
+ "embryonic lethal, abnormal vision, drosophila, homolog-like 1",
72
+ "Hu antigen R",
73
+ "Human antigen R"
74
+ ],
75
+ "location": "19p13.2",
76
+ "uniprot_ids": [
77
+ "Q15717"
78
+ ],
79
+ "prev_name": [
80
+ "ELAV (embryonic lethal, abnormal vision, Drosophila)-like 1 (Hu antigen R)"
81
+ ],
82
+ "rgd_id": [
83
+ "RGD:1308649"
84
+ ],
85
+ "ucsc_id": "uc060ssp.1",
86
+ "alias_symbol": [
87
+ "HuR",
88
+ "Hua",
89
+ "MelG"
90
+ ],
91
+ "hgnc_id": "HGNC:3312",
92
+ "status": "Approved",
93
+ "name": "ELAV like RNA binding protein 1",
94
+ "omim_id": [
95
+ "603466"
96
+ ],
97
+ "uuid": "4139f3ba-b203-4497-b00a-0952766090b5"
98
+ },
99
+ "metadata": {
100
+ "source": "HGNC",
101
+ "query_field": "symbol",
102
+ "query_value": "ELAVL1",
103
+ "num_found": 1
104
+ }
105
+ }
106
+ },
107
+ {
108
+ "tool": "UniProt_search",
109
+ "args": {
110
+ "query": "(accession:Q15717) AND reviewed:true AND organism_id:9606",
111
+ "limit": 1,
112
+ "fields": [
113
+ "accession",
114
+ "gene_primary",
115
+ "protein_name",
116
+ "cc_function",
117
+ "cc_subcellular_location"
118
+ ]
119
+ },
120
+ "response": {
121
+ "status": "success",
122
+ "data": {
123
+ "total_results": 1,
124
+ "returned": 1,
125
+ "results": [
126
+ {
127
+ "entryType": "UniProtKB reviewed (Swiss-Prot)",
128
+ "primaryAccession": "Q15717",
129
+ "proteinDescription": {
130
+ "recommendedName": {
131
+ "fullName": {
132
+ "value": "ELAV-like protein 1"
133
+ }
134
+ },
135
+ "alternativeNames": [
136
+ {
137
+ "fullName": {
138
+ "value": "Hu-antigen R"
139
+ },
140
+ "shortNames": [
141
+ {
142
+ "evidences": [
143
+ {
144
+ "evidenceCode": "ECO:0000303",
145
+ "source": "PubMed",
146
+ "id": "8626503"
147
+ }
148
+ ],
149
+ "value": "HuR"
150
+ }
151
+ ]
152
+ }
153
+ ]
154
+ },
155
+ "genes": [
156
+ {
157
+ "geneName": {
158
+ "value": "ELAVL1"
159
+ }
160
+ }
161
+ ],
162
+ "comments": [
163
+ {
164
+ "texts": [
165
+ {
166
+ "evidences": [
167
+ {
168
+ "evidenceCode": "ECO:0000250",
169
+ "source": "UniProtKB",
170
+ "id": "P70372"
171
+ },
172
+ {
173
+ "evidenceCode": "ECO:0000269",
174
+ "source": "PubMed",
175
+ "id": "14517288"
176
+ },
177
+ {
178
+ "evidenceCode": "ECO:0000269",
179
+ "source": "PubMed",
180
+ "id": "14731398"
181
+ },
182
+ {
183
+ "evidenceCode": "ECO:0000269",
184
+ "source": "PubMed",
185
+ "id": "17632515"
186
+ },
187
+ {
188
+ "evidenceCode": "ECO:0000269",
189
+ "source": "PubMed",
190
+ "id": "18285462"
191
+ },
192
+ {
193
+ "evidenceCode": "ECO:0000269",
194
+ "source": "PubMed",
195
+ "id": "19029303"
196
+ },
197
+ {
198
+ "evidenceCode": "ECO:0000269",
199
+ "source": "PubMed",
200
+ "id": "23519412"
201
+ },
202
+ {
203
+ "evidenceCode": "ECO:0000269",
204
+ "source": "PubMed",
205
+ "id": "29180010"
206
+ },
207
+ {
208
+ "evidenceCode": "ECO:0000269",
209
+ "source": "PubMed",
210
+ "id": "31358969"
211
+ },
212
+ {
213
+ "evidenceCode": "ECO:0000269",
214
+ "source": "PubMed",
215
+ "id": "32245947"
216
+ },
217
+ {
218
+ "evidenceCode": "ECO:0000269",
219
+ "source": "PubMed",
220
+ "id": "8626503"
221
+ }
222
+ ],
223
+ "value": "RNA-binding protein that binds to the 3'-UTR region of mRNAs and increases their stability (PubMed:14517288, PubMed:18285462, PubMed:31358969). Involved in embryonic stem cell (ESC) differentiation: preferentially binds mRNAs that are not methylated by N6-methyladenosine (m6A), stabilizing them, promoting ESC differentiation (By similarity). Has also been shown to be capable of binding to m6A-containing mRNAs and contributes to MYC stability by binding to m6A-containing MYC mRNAs (PubMed:32245947). Binds to poly-U elements and AU-rich elements (AREs) in the 3'-UTR of target mRNAs (PubMed:14731398, PubMed:17632515, PubMed:18285462, PubMed:23519412, PubMed:8626503). Binds avidly to the AU-rich element in FOS and IL3/interleukin-3 mRNAs. In the case of the FOS AU-rich element, binds to a core element of 27 nucleotides that contain AUUUA, AUUUUA, and AUUUUUA motifs. Binds preferentially to the 5'-UUUU[AG]UUU-3' motif in vitro (PubMed:8626503). With ZNF385A, binds the 3'-UTR of p53/TP53 mRNA to control their nuclear export induced by CDKN2A. Hence, may regulate p53/TP53 expression and mediate in part the CDKN2A anti-proliferative activity. May also bind with ZNF385A the CCNB1 mRNA (By similarity). Increases the stability of the leptin mRNA harboring an AU-rich element (ARE) in its 3' UTR (PubMed:29180010)"
224
+ }
225
+ ],
226
+ "commentType": "FUNCTION"
227
+ },
228
+ {
229
+ "commentType": "SUBCELLULAR LOCATION",
230
+ "note": {
231
+ "texts": [
232
+ {
233
+ "evidences": [
234
+ {
235
+ "evidenceCode": "ECO:0000250",
236
+ "source": "UniProtKB",
237
+ "id": "P70372"
238
+ },
239
+ {
240
+ "evidenceCode": "ECO:0000269",
241
+ "source": "PubMed",
242
+ "id": "14517288"
243
+ },
244
+ {
245
+ "evidenceCode": "ECO:0000269",
246
+ "source": "PubMed",
247
+ "id": "18285462"
248
+ }
249
+ ],
250
+ "value": "Translocates into the cytoplasm following phosphorylation by MAPKAPK2 (PubMed:14517288). Likewise, phosphorylation by PRKCD promotes translocation from the nucleus into the cytoplasm, where it is associated with free and cytoskeleton-bound polysomes (PubMed:18285462). Localizes to the stress granules in the presence of PLEKHN1 (By similarity)"
251
+ }
252
+ ]
253
+ },
254
+ "subcellularLocations": [
255
+ {
256
+ "location": {
257
+ "evidences": [
258
+ {
259
+ "evidenceCode": "ECO:0000269",
260
+ "source": "PubMed",
261
+ "id": "14517288"
262
+ },
263
+ {
264
+ "evidenceCode": "ECO:0000269",
265
+ "source": "PubMed",
266
+ "id": "17632515"
267
+ },
268
+ {
269
+ "evidenceCode": "ECO:0000269",
270
+ "source": "PubMed",
271
+ "id": "18285462"
272
+ },
273
+ {
274
+ "evidenceCode": "ECO:0000269",
275
+ "source": "PubMed",
276
+ "id": "19029303"
277
+ }
278
+ ],
279
+ "value": "Cytoplasm",
280
+ "id": "SL-0086"
281
+ }
282
+ },
283
+ {
284
+ "location": {
285
+ "evidences": [
286
+ {
287
+ "evidenceCode": "ECO:0000269",
288
+ "source": "PubMed",
289
+ "id": "14517288"
290
+ },
291
+ {
292
+ "evidenceCode": "ECO:0000269",
293
+ "source": "PubMed",
294
+ "id": "17632515"
295
+ },
296
+ {
297
+ "evidenceCode": "ECO:0000269",
298
+ "source": "PubMed",
299
+ "id": "18285462"
300
+ }
301
+ ],
302
+ "value": "Nucleus",
303
+ "id": "SL-0191"
304
+ }
305
+ },
306
+ {
307
+ "location": {
308
+ "evidences": [
309
+ {
310
+ "evidenceCode": "ECO:0000250",
311
+ "source": "UniProtKB",
312
+ "id": "P70372"
313
+ }
314
+ ],
315
+ "value": "Cytoplasm, Stress granule",
316
+ "id": "SL-0496"
317
+ }
318
+ },
319
+ {
320
+ "location": {
321
+ "evidences": [
322
+ {
323
+ "evidenceCode": "ECO:0000269",
324
+ "source": "PubMed",
325
+ "id": "29476152"
326
+ }
327
+ ],
328
+ "value": "Cytoplasm, P-body",
329
+ "id": "SL-0230"
330
+ }
331
+ }
332
+ ]
333
+ }
334
+ ],
335
+ "extraAttributes": {
336
+ "uniParcId": "UPI0000129E74"
337
+ }
338
+ }
339
+ ],
340
+ "normalization_note": "Query scope widened: 'organism_id:<taxid>' was rewritten to 'taxonomy_id:<taxid>'. UniProt's organism_id field (labelled 'Organism [OS]') matches only an entry's own organism node, while taxonomy_id ('Taxonomy [OC]') matches anywhere in the entry's lineage. Most bacterial entries are annotated at strain level, so a species-level organism_id silently returns 0 or an undercount (e.g. 'gene:gyrA AND organism_id:1423 AND reviewed:true' -> 0 hits vs 1 for taxonomy_id; gene:recA + E. coli 562 -> 9 vs 30). Results therefore include descendant strains of the taxid you supplied. To match the exact organism node only, write 'organism_id:<taxid>' yourself against the UniProt REST API, or use organism_name with the exact strain name."
341
+ }
342
+ }
343
+ }
344
+ ]
345
+ }
tests/fixtures/cassettes/q4_annotate_porg.json ADDED
The diff for this file is too large to render. See raw diff
 
tests/fixtures/cassettes/q5_trials_cd40.json ADDED
@@ -0,0 +1,90 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "_recorded": {
3
+ "scenario": "q5_trials_cd40",
4
+ "why": "Phase 0 Q5 (recruiting CD40 trials)",
5
+ "endpoint": "trials_for_genes",
6
+ "arguments": [
7
+ "CD40",
8
+ "",
9
+ ""
10
+ ],
11
+ "tooluniverse_version": "1.5.6",
12
+ "recorded_on": "2026-10-21T04:42:55Z",
13
+ "recorded_at_epoch": 1792557775.678,
14
+ "recorded_with": "scripts/record_fixtures.py",
15
+ "payload_status_when_recorded": "ok",
16
+ "licences": {
17
+ "clinicaltrials": "Public (U.S. National Library of Medicine); NLM terms and conditions apply"
18
+ }
19
+ },
20
+ "calls": [
21
+ {
22
+ "tool": "ClinicalTrials_search_studies",
23
+ "args": {
24
+ "query_cond": "pancreatic cancer",
25
+ "query_intr": "CD40",
26
+ "page_size": 100,
27
+ "filter_status": "RECRUITING"
28
+ },
29
+ "response": {
30
+ "status": "success",
31
+ "data": {
32
+ "studies": [
33
+ {
34
+ "nct_id": "NCT06205849",
35
+ "brief_title": "Intra-tumoral Mitazalimab (CD40 Antibody) With Irreversible Electroporation (IRE) in Locally Advanced Pancreas Cancer",
36
+ "status": "RECRUITING",
37
+ "study_type": "INTERVENTIONAL",
38
+ "phases": [
39
+ "PHASE1"
40
+ ],
41
+ "enrollment": 18,
42
+ "conditions": [
43
+ "Pancreatic Cancer"
44
+ ],
45
+ "interventions": [
46
+ "IRE + intratumoral mitazalimab (CD40 antibody) injection",
47
+ "NanoKnife"
48
+ ],
49
+ "intervention_count": 2,
50
+ "interventions_truncated": false,
51
+ "sponsor": "University of California, San Diego",
52
+ "start_date": "2024-07-17",
53
+ "completion_date": "2030-08"
54
+ }
55
+ ],
56
+ "total_count": 1,
57
+ "next_page_token": null,
58
+ "executed_query": {
59
+ "query.cond": "\"pancreatic cancer\"",
60
+ "query.intr": "(AREA[InterventionName]CD40 OR AREA[InterventionOtherName]CD40)",
61
+ "filter.overallStatus": "RECRUITING"
62
+ },
63
+ "query_rewrites": [
64
+ {
65
+ "parameter": "query_cond",
66
+ "api_field": "query.cond",
67
+ "submitted": "pancreatic cancer",
68
+ "executed": "\"pancreatic cancer\"",
69
+ "restricted_to_fields": []
70
+ },
71
+ {
72
+ "parameter": "query_intr",
73
+ "api_field": "query.intr",
74
+ "submitted": "CD40",
75
+ "executed": "(AREA[InterventionName]CD40 OR AREA[InterventionOtherName]CD40)",
76
+ "restricted_to_fields": [
77
+ "InterventionName",
78
+ "InterventionOtherName"
79
+ ]
80
+ }
81
+ ]
82
+ },
83
+ "metadata": {
84
+ "source": "ClinicalTrials.gov API v2",
85
+ "operation": "search"
86
+ }
87
+ }
88
+ }
89
+ ]
90
+ }
tests/fixtures/cassettes/q6_drugs_jak_stat.json ADDED
The diff for this file is too large to render. See raw diff
 
tests/fixtures/cassettes/q7_drugs_tnfrsf10b.json ADDED
@@ -0,0 +1,865 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "_recorded": {
3
+ "scenario": "q7_drugs_tnfrsf10b",
4
+ "why": "Phase 0 Q7 (DR5)",
5
+ "endpoint": "drugs_for_targets",
6
+ "arguments": [
7
+ "TNFRSF10B"
8
+ ],
9
+ "tooluniverse_version": "1.5.6",
10
+ "recorded_on": "2026-10-21T04:43:37Z",
11
+ "recorded_at_epoch": 1792557814.828,
12
+ "recorded_with": "scripts/record_fixtures.py",
13
+ "payload_status_when_recorded": "ok",
14
+ "licences": {
15
+ "chembl": "CC BY-SA 3.0",
16
+ "hgnc": "No restrictions on access or use (HGNC data release policy); citation requested",
17
+ "opentargets": "CC0 1.0"
18
+ }
19
+ },
20
+ "calls": [
21
+ {
22
+ "tool": "HGNC_fetch_gene_by_symbol",
23
+ "args": {
24
+ "symbol": "TNFRSF10B"
25
+ },
26
+ "response": {
27
+ "status": "success",
28
+ "data": {
29
+ "entrez_id": "8795",
30
+ "date_name_changed": "2016-06-28T00:00:00Z",
31
+ "date_approved_reserved": "1998-12-04T00:00:00Z",
32
+ "ensembl_gene_id": "ENSG00000120889",
33
+ "ccds_id": [
34
+ "CCDS6035",
35
+ "CCDS6036"
36
+ ],
37
+ "mgd_id": [
38
+ "MGI:1341090"
39
+ ],
40
+ "cd": "CD262",
41
+ "ena": [
42
+ "AF012628"
43
+ ],
44
+ "gene_group": [
45
+ "CD molecules",
46
+ "Tumor necrosis factor receptor superfamily"
47
+ ],
48
+ "vega_id": "OTTHUMG00000097826",
49
+ "symbol": "TNFRSF10B",
50
+ "gene_group_id": [
51
+ 471,
52
+ 782
53
+ ],
54
+ "locus_group": "protein-coding gene",
55
+ "prev_name": [
56
+ "tumor necrosis factor receptor superfamily, member 10b"
57
+ ],
58
+ "uniprot_ids": [
59
+ "O14763"
60
+ ],
61
+ "location": "8p21.3",
62
+ "ucsc_id": "uc003xcu.4",
63
+ "alias_symbol": [
64
+ "DR5",
65
+ "KILLER",
66
+ "TRICK2A",
67
+ "TRAIL-R2",
68
+ "TRICKB",
69
+ "CD262",
70
+ "TRAILR2"
71
+ ],
72
+ "gencc": "HGNC:11905",
73
+ "hgnc_id": "HGNC:11905",
74
+ "iuphar": "HGNC:11905",
75
+ "status": "Approved",
76
+ "name": "TNF receptor superfamily member 10b",
77
+ "uuid": "4b2390b4-2ff2-4ba8-9fcf-b6e65ad656f9",
78
+ "omim_id": [
79
+ "603612"
80
+ ],
81
+ "agr": "HGNC:11905",
82
+ "refseq_accession": [
83
+ "NM_147187"
84
+ ],
85
+ "mane_select": [
86
+ "ENST00000276431.9",
87
+ "NM_003842.5"
88
+ ],
89
+ "pubmed_id": [
90
+ 9285725,
91
+ 9311998
92
+ ],
93
+ "locus_type": "gene with protein product",
94
+ "date_modified": "2023-01-20T00:00:00Z"
95
+ },
96
+ "metadata": {
97
+ "source": "HGNC",
98
+ "query_field": "symbol",
99
+ "query_value": "TNFRSF10B",
100
+ "num_found": 1
101
+ }
102
+ }
103
+ },
104
+ {
105
+ "tool": "OpenTargets_get_associated_drugs_by_target_ensemblID",
106
+ "args": {
107
+ "ensemblId": "ENSG00000120889"
108
+ },
109
+ "response": {
110
+ "status": "success",
111
+ "data": {
112
+ "target": {
113
+ "id": "ENSG00000120889",
114
+ "approvedSymbol": "TNFRSF10B",
115
+ "drugAndClinicalCandidates": {
116
+ "count": 7,
117
+ "rows": [
118
+ {
119
+ "drug": {
120
+ "id": "CHEMBL2109247",
121
+ "name": "LBY-135",
122
+ "maximumClinicalStage": "PHASE_1"
123
+ },
124
+ "maxClinicalStage": "PHASE_1",
125
+ "diseases": []
126
+ },
127
+ {
128
+ "drug": {
129
+ "id": "CHEMBL2107846",
130
+ "name": "DULANERMIN",
131
+ "maximumClinicalStage": "PHASE_2"
132
+ },
133
+ "maxClinicalStage": "PHASE_2",
134
+ "diseases": [
135
+ {
136
+ "disease": {
137
+ "id": "MONDO_0056806",
138
+ "name": "non-small cell squamous lung carcinoma"
139
+ }
140
+ },
141
+ {
142
+ "disease": {
143
+ "id": "MONDO_0005233",
144
+ "name": "non-small cell lung carcinoma"
145
+ }
146
+ },
147
+ {
148
+ "disease": {
149
+ "id": "MONDO_0018908",
150
+ "name": "non-Hodgkin lymphoma"
151
+ }
152
+ },
153
+ {
154
+ "disease": {
155
+ "id": "MONDO_0018906",
156
+ "name": "follicular lymphoma"
157
+ }
158
+ },
159
+ {
160
+ "disease": {
161
+ "id": "EFO_1001480",
162
+ "name": "metastatic colorectal cancer"
163
+ }
164
+ },
165
+ {
166
+ "disease": {
167
+ "id": "MONDO_0005575",
168
+ "name": "colorectal cancer"
169
+ }
170
+ }
171
+ ]
172
+ },
173
+ {
174
+ "drug": {
175
+ "id": "CHEMBL1743008",
176
+ "name": "DROZITUMAB",
177
+ "maximumClinicalStage": "PHASE_2"
178
+ },
179
+ "maxClinicalStage": "PHASE_2",
180
+ "diseases": [
181
+ {
182
+ "disease": {
183
+ "id": "MONDO_0005233",
184
+ "name": "non-small cell lung carcinoma"
185
+ }
186
+ },
187
+ {
188
+ "disease": {
189
+ "id": "MONDO_0018906",
190
+ "name": "follicular lymphoma"
191
+ }
192
+ },
193
+ {
194
+ "disease": {
195
+ "id": "MONDO_0008977",
196
+ "name": "chondrosarcoma"
197
+ }
198
+ },
199
+ {
200
+ "disease": {
201
+ "id": "MONDO_0005575",
202
+ "name": "colorectal cancer"
203
+ }
204
+ }
205
+ ]
206
+ },
207
+ {
208
+ "drug": {
209
+ "id": "CHEMBL1743080",
210
+ "name": "TIGATUZUMAB",
211
+ "maximumClinicalStage": "PHASE_2"
212
+ },
213
+ "maxClinicalStage": "PHASE_2",
214
+ "diseases": [
215
+ {
216
+ "disease": {
217
+ "id": "MONDO_0007254",
218
+ "name": "breast cancer"
219
+ }
220
+ },
221
+ {
222
+ "disease": {
223
+ "id": "MONDO_0005575",
224
+ "name": "colorectal cancer"
225
+ }
226
+ },
227
+ {
228
+ "disease": {
229
+ "id": "MONDO_0002691",
230
+ "name": "liver cancer"
231
+ }
232
+ },
233
+ {
234
+ "disease": {
235
+ "id": "MONDO_0008170",
236
+ "name": "ovarian cancer"
237
+ }
238
+ },
239
+ {
240
+ "disease": {
241
+ "id": "MONDO_0021040",
242
+ "name": "pancreatic neoplasm"
243
+ }
244
+ },
245
+ {
246
+ "disease": {
247
+ "id": "MONDO_0009831",
248
+ "name": "malignant pancreatic neoplasm"
249
+ }
250
+ },
251
+ {
252
+ "disease": {
253
+ "id": "MONDO_0005192",
254
+ "name": "exocrine pancreatic carcinoma"
255
+ }
256
+ },
257
+ {
258
+ "disease": {
259
+ "id": "MONDO_0005233",
260
+ "name": "non-small cell lung carcinoma"
261
+ }
262
+ },
263
+ {
264
+ "disease": {
265
+ "id": "MONDO_0007256",
266
+ "name": "hepatocellular carcinoma"
267
+ }
268
+ },
269
+ {
270
+ "disease": {
271
+ "id": "MONDO_0007254",
272
+ "name": "breast cancer"
273
+ }
274
+ },
275
+ {
276
+ "disease": {
277
+ "id": "MONDO_0005062",
278
+ "name": "lymphoma"
279
+ }
280
+ },
281
+ {
282
+ "disease": {
283
+ "id": "MONDO_0024331",
284
+ "name": "colorectal carcinoma"
285
+ }
286
+ }
287
+ ]
288
+ },
289
+ {
290
+ "drug": {
291
+ "id": "CHEMBL2109248",
292
+ "name": "APOMAB",
293
+ "maximumClinicalStage": "PHASE_1"
294
+ },
295
+ "maxClinicalStage": "PHASE_1",
296
+ "diseases": [
297
+ {
298
+ "disease": {
299
+ "id": "MONDO_0005575",
300
+ "name": "colorectal cancer"
301
+ }
302
+ }
303
+ ]
304
+ },
305
+ {
306
+ "drug": {
307
+ "id": "CHEMBL1743036",
308
+ "name": "LEXATUMUMAB",
309
+ "maximumClinicalStage": "PHASE_2"
310
+ },
311
+ "maxClinicalStage": "PHASE_2",
312
+ "diseases": [
313
+ {
314
+ "disease": {
315
+ "id": "MONDO_0005072",
316
+ "name": "neuroblastoma"
317
+ }
318
+ },
319
+ {
320
+ "disease": {
321
+ "id": "MONDO_0012817",
322
+ "name": "Ewing sarcoma"
323
+ }
324
+ },
325
+ {
326
+ "disease": {
327
+ "id": "MONDO_0009807",
328
+ "name": "osteosarcoma"
329
+ }
330
+ },
331
+ {
332
+ "disease": {
333
+ "id": "MONDO_0005212",
334
+ "name": "rhabdomyosarcoma"
335
+ }
336
+ }
337
+ ]
338
+ },
339
+ {
340
+ "drug": {
341
+ "id": "CHEMBL1743003",
342
+ "name": "CONATUMUMAB",
343
+ "maximumClinicalStage": "PHASE_2"
344
+ },
345
+ "maxClinicalStage": "PHASE_2",
346
+ "diseases": [
347
+ {
348
+ "disease": {
349
+ "id": "MONDO_0005575",
350
+ "name": "colorectal cancer"
351
+ }
352
+ },
353
+ {
354
+ "disease": {
355
+ "id": "HP_0012531",
356
+ "name": "Pain"
357
+ }
358
+ },
359
+ {
360
+ "disease": {
361
+ "id": "MONDO_0024331",
362
+ "name": "colorectal carcinoma"
363
+ }
364
+ },
365
+ {
366
+ "disease": {
367
+ "id": "MONDO_0005233",
368
+ "name": "non-small cell lung carcinoma"
369
+ }
370
+ },
371
+ {
372
+ "disease": {
373
+ "id": "MONDO_0018078",
374
+ "name": "soft tissue sarcoma"
375
+ }
376
+ },
377
+ {
378
+ "disease": {
379
+ "id": "MONDO_0005192",
380
+ "name": "exocrine pancreatic carcinoma"
381
+ }
382
+ },
383
+ {
384
+ "disease": {
385
+ "id": "MONDO_0008170",
386
+ "name": "ovarian cancer"
387
+ }
388
+ },
389
+ {
390
+ "disease": {
391
+ "id": "MONDO_0005089",
392
+ "name": "sarcoma"
393
+ }
394
+ },
395
+ {
396
+ "disease": {
397
+ "id": "MONDO_0004952",
398
+ "name": "Hodgkins lymphoma"
399
+ }
400
+ },
401
+ {
402
+ "disease": {
403
+ "id": "MONDO_0018876",
404
+ "name": "mantle cell lymphoma"
405
+ }
406
+ },
407
+ {
408
+ "disease": {
409
+ "id": "MONDO_0005062",
410
+ "name": "lymphoma"
411
+ }
412
+ },
413
+ {
414
+ "disease": {
415
+ "id": "MONDO_0018905",
416
+ "name": "diffuse large B-cell lymphoma"
417
+ }
418
+ },
419
+ {
420
+ "disease": {
421
+ "id": "MONDO_0002158",
422
+ "name": "fallopian tube cancer"
423
+ }
424
+ },
425
+ {
426
+ "disease": {
427
+ "id": "MONDO_0015686",
428
+ "name": "primary peritoneal carcinoma"
429
+ }
430
+ }
431
+ ]
432
+ }
433
+ ],
434
+ "returned": 7,
435
+ "truncated": false
436
+ }
437
+ }
438
+ }
439
+ }
440
+ },
441
+ {
442
+ "tool": "ChEMBL_get_drug_mechanisms",
443
+ "args": {
444
+ "drug_chembl_id": "CHEMBL1743003"
445
+ },
446
+ "response": {
447
+ "status": "success",
448
+ "data": {
449
+ "mechanisms": [
450
+ {
451
+ "action_type": "AGONIST",
452
+ "binding_site_comment": null,
453
+ "direct_interaction": 1,
454
+ "disease_efficacy": 1,
455
+ "max_phase": 2,
456
+ "mec_id": 5991,
457
+ "mechanism_comment": "It binds to TNFRSF10B, activating intracellular caspases in vitro in the presence of a cross-linker.",
458
+ "mechanism_of_action": "Tumor necrosis factor receptor superfamily member 10B agonist",
459
+ "mechanism_refs": [
460
+ {
461
+ "ref_id": "20150762",
462
+ "ref_type": "PubMed",
463
+ "ref_url": "http://europepmc.org/abstract/MED/20150762"
464
+ }
465
+ ],
466
+ "molecular_mechanism": 1,
467
+ "molecule_chembl_id": "CHEMBL1743003",
468
+ "parent_molecule_chembl_id": "CHEMBL1743003",
469
+ "record_id": 1392887,
470
+ "selectivity_comment": null,
471
+ "site_id": null,
472
+ "target_chembl_id": "CHEMBL1075153",
473
+ "variant_sequence": null
474
+ }
475
+ ],
476
+ "page_meta": {
477
+ "limit": 20,
478
+ "next": null,
479
+ "offset": 0,
480
+ "previous": null,
481
+ "total_count": 1
482
+ }
483
+ },
484
+ "url": "https://www.ebi.ac.uk/chembl/api/data/mechanism.json?format=json&parent_molecule_chembl_id=CHEMBL1743003",
485
+ "page_meta": {
486
+ "limit": 20,
487
+ "next": null,
488
+ "offset": 0,
489
+ "previous": null,
490
+ "total_count": 1
491
+ },
492
+ "count": 1
493
+ }
494
+ },
495
+ {
496
+ "tool": "ChEMBL_get_drug_mechanisms",
497
+ "args": {
498
+ "drug_chembl_id": "CHEMBL1743008"
499
+ },
500
+ "response": {
501
+ "status": "success",
502
+ "data": {
503
+ "mechanisms": [
504
+ {
505
+ "action_type": "AGONIST",
506
+ "binding_site_comment": "Binds at the junction between CRD2 and CRD3, interacting with residues in both CRD2 and CRD3.",
507
+ "direct_interaction": 1,
508
+ "disease_efficacy": 1,
509
+ "max_phase": 2,
510
+ "mec_id": 5995,
511
+ "mechanism_comment": "Induces TNFRSF10B clustering at the cell surface and stimulates a death-inducing signaling complex containing the adaptor molecule Fas-associated death domain and the apoptosis-initiating protease caspase-8. Fc crosslinking further augmented Drozitumab's proapoptotic activity.",
512
+ "mechanism_of_action": "Tumor necrosis factor receptor superfamily member 10B agonist",
513
+ "mechanism_refs": [
514
+ {
515
+ "ref_id": "18219321",
516
+ "ref_type": "PubMed",
517
+ "ref_url": "http://europepmc.org/abstract/MED/18219321"
518
+ }
519
+ ],
520
+ "molecular_mechanism": 1,
521
+ "molecule_chembl_id": "CHEMBL1743008",
522
+ "parent_molecule_chembl_id": "CHEMBL1743008",
523
+ "record_id": 1392892,
524
+ "selectivity_comment": "Binds to TNFRSF10B, but shows no detectable binding to any of the other Apo2L/TRAIL receptors.",
525
+ "site_id": null,
526
+ "target_chembl_id": "CHEMBL1075153",
527
+ "variant_sequence": null
528
+ }
529
+ ],
530
+ "page_meta": {
531
+ "limit": 20,
532
+ "next": null,
533
+ "offset": 0,
534
+ "previous": null,
535
+ "total_count": 1
536
+ }
537
+ },
538
+ "url": "https://www.ebi.ac.uk/chembl/api/data/mechanism.json?format=json&parent_molecule_chembl_id=CHEMBL1743008",
539
+ "page_meta": {
540
+ "limit": 20,
541
+ "next": null,
542
+ "offset": 0,
543
+ "previous": null,
544
+ "total_count": 1
545
+ },
546
+ "count": 1
547
+ }
548
+ },
549
+ {
550
+ "tool": "ChEMBL_get_drug_mechanisms",
551
+ "args": {
552
+ "drug_chembl_id": "CHEMBL2107846"
553
+ },
554
+ "response": {
555
+ "status": "success",
556
+ "data": {
557
+ "mechanisms": [
558
+ {
559
+ "action_type": "AGONIST",
560
+ "binding_site_comment": null,
561
+ "direct_interaction": 1,
562
+ "disease_efficacy": 1,
563
+ "max_phase": 2,
564
+ "mec_id": 5540,
565
+ "mechanism_comment": "Binds to and activates TRAIL receptors 1 and 2 (TRAIL-R1/R2), which may activate caspases and induce p53-independent apoptosis.",
566
+ "mechanism_of_action": "TRAIL receptor-1 agonist",
567
+ "mechanism_refs": [
568
+ {
569
+ "ref_id": "10411544",
570
+ "ref_type": "PubMed",
571
+ "ref_url": "http://europepmc.org/abstract/MED/10411544"
572
+ },
573
+ {
574
+ "ref_id": "22914439",
575
+ "ref_type": "PubMed",
576
+ "ref_url": "http://europepmc.org/abstract/MED/22914439"
577
+ }
578
+ ],
579
+ "molecular_mechanism": 1,
580
+ "molecule_chembl_id": "CHEMBL2107846",
581
+ "parent_molecule_chembl_id": "CHEMBL2107846",
582
+ "record_id": 1695906,
583
+ "selectivity_comment": null,
584
+ "site_id": null,
585
+ "target_chembl_id": "CHEMBL3551",
586
+ "variant_sequence": null
587
+ },
588
+ {
589
+ "action_type": "AGONIST",
590
+ "binding_site_comment": null,
591
+ "direct_interaction": 1,
592
+ "disease_efficacy": 1,
593
+ "max_phase": 2,
594
+ "mec_id": 5868,
595
+ "mechanism_comment": "Binds to and activates TRAIL receptors 1 and 2 (TRAIL-R1/R2), which may activate caspases and induce p53-independent apoptosis.",
596
+ "mechanism_of_action": "Tumor necrosis factor receptor superfamily member 10B agonist",
597
+ "mechanism_refs": [
598
+ {
599
+ "ref_id": "10411544",
600
+ "ref_type": "PubMed",
601
+ "ref_url": "http://europepmc.org/abstract/MED/10411544"
602
+ },
603
+ {
604
+ "ref_id": "22914439",
605
+ "ref_type": "PubMed",
606
+ "ref_url": "http://europepmc.org/abstract/MED/22914439"
607
+ }
608
+ ],
609
+ "molecular_mechanism": 1,
610
+ "molecule_chembl_id": "CHEMBL2107846",
611
+ "parent_molecule_chembl_id": "CHEMBL2107846",
612
+ "record_id": 1695906,
613
+ "selectivity_comment": null,
614
+ "site_id": null,
615
+ "target_chembl_id": "CHEMBL1075153",
616
+ "variant_sequence": null
617
+ }
618
+ ],
619
+ "page_meta": {
620
+ "limit": 20,
621
+ "next": null,
622
+ "offset": 0,
623
+ "previous": null,
624
+ "total_count": 2
625
+ }
626
+ },
627
+ "url": "https://www.ebi.ac.uk/chembl/api/data/mechanism.json?format=json&parent_molecule_chembl_id=CHEMBL2107846",
628
+ "page_meta": {
629
+ "limit": 20,
630
+ "next": null,
631
+ "offset": 0,
632
+ "previous": null,
633
+ "total_count": 2
634
+ },
635
+ "count": 2
636
+ }
637
+ },
638
+ {
639
+ "tool": "ChEMBL_get_drug_mechanisms",
640
+ "args": {
641
+ "drug_chembl_id": "CHEMBL1743036"
642
+ },
643
+ "response": {
644
+ "status": "success",
645
+ "data": {
646
+ "mechanisms": [
647
+ {
648
+ "action_type": "AGONIST",
649
+ "binding_site_comment": null,
650
+ "direct_interaction": 1,
651
+ "disease_efficacy": 1,
652
+ "max_phase": 2,
653
+ "mec_id": 7082,
654
+ "mechanism_comment": null,
655
+ "mechanism_of_action": "Tumor necrosis factor receptor superfamily member 10B agonist",
656
+ "mechanism_refs": [
657
+ {
658
+ "ref_id": "https://www.cancer.gov/publications/dictionaries/cancer-drug?cdrid=528015",
659
+ "ref_type": "Other",
660
+ "ref_url": "https://www.cancer.gov/publications/dictionaries/cancer-drug?cdrid=528015"
661
+ },
662
+ {
663
+ "ref_id": "PMC3494837",
664
+ "ref_type": "PMC",
665
+ "ref_url": "http://europepmc.org/articles/PMC3494837"
666
+ }
667
+ ],
668
+ "molecular_mechanism": 1,
669
+ "molecule_chembl_id": "CHEMBL1743036",
670
+ "parent_molecule_chembl_id": "CHEMBL1743036",
671
+ "record_id": 1392920,
672
+ "selectivity_comment": null,
673
+ "site_id": null,
674
+ "target_chembl_id": "CHEMBL1075153",
675
+ "variant_sequence": null
676
+ }
677
+ ],
678
+ "page_meta": {
679
+ "limit": 20,
680
+ "next": null,
681
+ "offset": 0,
682
+ "previous": null,
683
+ "total_count": 1
684
+ }
685
+ },
686
+ "url": "https://www.ebi.ac.uk/chembl/api/data/mechanism.json?format=json&parent_molecule_chembl_id=CHEMBL1743036",
687
+ "page_meta": {
688
+ "limit": 20,
689
+ "next": null,
690
+ "offset": 0,
691
+ "previous": null,
692
+ "total_count": 1
693
+ },
694
+ "count": 1
695
+ }
696
+ },
697
+ {
698
+ "tool": "ChEMBL_get_drug_mechanisms",
699
+ "args": {
700
+ "drug_chembl_id": "CHEMBL1743080"
701
+ },
702
+ "response": {
703
+ "status": "success",
704
+ "data": {
705
+ "mechanisms": [
706
+ {
707
+ "action_type": "AGONIST",
708
+ "binding_site_comment": null,
709
+ "direct_interaction": 1,
710
+ "disease_efficacy": 1,
711
+ "max_phase": 3,
712
+ "mec_id": 5468,
713
+ "mechanism_comment": "Mimics the natural receptor ligand TRAIL and binds to TRAIL-R2 (without crosslinking), activating signal transduction pathways that may result in tumor cell apoptosis and a reduction in tumor growth.",
714
+ "mechanism_of_action": "Tumor necrosis factor receptor superfamily member 10B agonist",
715
+ "mechanism_refs": [
716
+ {
717
+ "ref_id": "18304965",
718
+ "ref_type": "PubMed",
719
+ "ref_url": "http://europepmc.org/abstract/MED/18304965"
720
+ },
721
+ {
722
+ "ref_id": "20187792",
723
+ "ref_type": "PubMed",
724
+ "ref_url": "http://europepmc.org/abstract/MED/20187792"
725
+ }
726
+ ],
727
+ "molecular_mechanism": 1,
728
+ "molecule_chembl_id": "CHEMBL1743080",
729
+ "parent_molecule_chembl_id": "CHEMBL1743080",
730
+ "record_id": 1392964,
731
+ "selectivity_comment": null,
732
+ "site_id": null,
733
+ "target_chembl_id": "CHEMBL1075153",
734
+ "variant_sequence": null
735
+ }
736
+ ],
737
+ "page_meta": {
738
+ "limit": 20,
739
+ "next": null,
740
+ "offset": 0,
741
+ "previous": null,
742
+ "total_count": 1
743
+ }
744
+ },
745
+ "url": "https://www.ebi.ac.uk/chembl/api/data/mechanism.json?format=json&parent_molecule_chembl_id=CHEMBL1743080",
746
+ "page_meta": {
747
+ "limit": 20,
748
+ "next": null,
749
+ "offset": 0,
750
+ "previous": null,
751
+ "total_count": 1
752
+ },
753
+ "count": 1
754
+ }
755
+ },
756
+ {
757
+ "tool": "ChEMBL_get_drug_mechanisms",
758
+ "args": {
759
+ "drug_chembl_id": "CHEMBL2109248"
760
+ },
761
+ "response": {
762
+ "status": "success",
763
+ "data": {
764
+ "mechanisms": [
765
+ {
766
+ "action_type": "AGONIST",
767
+ "binding_site_comment": null,
768
+ "direct_interaction": 1,
769
+ "disease_efficacy": 1,
770
+ "max_phase": 2,
771
+ "mec_id": 6327,
772
+ "mechanism_comment": null,
773
+ "mechanism_of_action": "Tumor necrosis factor receptor superfamily member 10B agonist",
774
+ "mechanism_refs": [
775
+ {
776
+ "ref_id": "18219321",
777
+ "ref_type": "PubMed",
778
+ "ref_url": "http://europepmc.org/abstract/MED/18219321"
779
+ }
780
+ ],
781
+ "molecular_mechanism": 1,
782
+ "molecule_chembl_id": "CHEMBL2109248",
783
+ "parent_molecule_chembl_id": "CHEMBL2109248",
784
+ "record_id": 1706662,
785
+ "selectivity_comment": null,
786
+ "site_id": null,
787
+ "target_chembl_id": "CHEMBL1075153",
788
+ "variant_sequence": null
789
+ }
790
+ ],
791
+ "page_meta": {
792
+ "limit": 20,
793
+ "next": null,
794
+ "offset": 0,
795
+ "previous": null,
796
+ "total_count": 1
797
+ }
798
+ },
799
+ "url": "https://www.ebi.ac.uk/chembl/api/data/mechanism.json?format=json&parent_molecule_chembl_id=CHEMBL2109248",
800
+ "page_meta": {
801
+ "limit": 20,
802
+ "next": null,
803
+ "offset": 0,
804
+ "previous": null,
805
+ "total_count": 1
806
+ },
807
+ "count": 1
808
+ }
809
+ },
810
+ {
811
+ "tool": "ChEMBL_get_drug_mechanisms",
812
+ "args": {
813
+ "drug_chembl_id": "CHEMBL2109247"
814
+ },
815
+ "response": {
816
+ "status": "success",
817
+ "data": {
818
+ "mechanisms": [
819
+ {
820
+ "action_type": "AGONIST",
821
+ "binding_site_comment": null,
822
+ "direct_interaction": 1,
823
+ "disease_efficacy": 1,
824
+ "max_phase": 1,
825
+ "mec_id": 7083,
826
+ "mechanism_comment": null,
827
+ "mechanism_of_action": "Tumor necrosis factor receptor superfamily member 10B agonist",
828
+ "mechanism_refs": [
829
+ {
830
+ "ref_id": "10.1002/ddr.20229/abstract",
831
+ "ref_type": "DOI",
832
+ "ref_url": "http://onlinelibrary.wiley.com/doi/10.1002/ddr.20229/abstract"
833
+ }
834
+ ],
835
+ "molecular_mechanism": 1,
836
+ "molecule_chembl_id": "CHEMBL2109247",
837
+ "parent_molecule_chembl_id": "CHEMBL2109247",
838
+ "record_id": 1706661,
839
+ "selectivity_comment": null,
840
+ "site_id": null,
841
+ "target_chembl_id": "CHEMBL1075153",
842
+ "variant_sequence": null
843
+ }
844
+ ],
845
+ "page_meta": {
846
+ "limit": 20,
847
+ "next": null,
848
+ "offset": 0,
849
+ "previous": null,
850
+ "total_count": 1
851
+ }
852
+ },
853
+ "url": "https://www.ebi.ac.uk/chembl/api/data/mechanism.json?format=json&parent_molecule_chembl_id=CHEMBL2109247",
854
+ "page_meta": {
855
+ "limit": 20,
856
+ "next": null,
857
+ "offset": 0,
858
+ "previous": null,
859
+ "total_count": 1
860
+ },
861
+ "count": 1
862
+ }
863
+ }
864
+ ]
865
+ }
tests/fixtures/cassettes/q8_targets_pdac.json ADDED
@@ -0,0 +1,262 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "_recorded": {
3
+ "scenario": "q8_targets_pdac",
4
+ "why": "Phase 0 Q8 (top PDAC targets)",
5
+ "endpoint": "targets_for_disease",
6
+ "arguments": [
7
+ "pancreatic ductal adenocarcinoma",
8
+ ""
9
+ ],
10
+ "tooluniverse_version": "1.5.6",
11
+ "recorded_on": "2026-10-21T04:43:38Z",
12
+ "recorded_at_epoch": 1792557817.888,
13
+ "recorded_with": "scripts/record_fixtures.py",
14
+ "payload_status_when_recorded": "ok",
15
+ "licences": {
16
+ "opentargets": "CC0 1.0"
17
+ }
18
+ },
19
+ "calls": [
20
+ {
21
+ "tool": "OpenTargets_get_disease_id_description_by_name",
22
+ "args": {
23
+ "diseaseName": "pancreatic ductal adenocarcinoma"
24
+ },
25
+ "response": {
26
+ "status": "success",
27
+ "data": {
28
+ "search": {
29
+ "hits": [
30
+ {
31
+ "id": "MONDO_0005184",
32
+ "name": "pancreatic ductal adenocarcinoma",
33
+ "description": "An infiltrating adenocarcinoma that arises from the epithelial cells of the pancreas. It affects males more often than females and the patients are usually over 50 years of age. Microscopically it is characterized by the presence of glandular (ductal) differentiation and desmoplastic stroma formation. Signs and symptoms include pain, loss of weight, and jaundice. It grows rapidly and is usually detected after it has metastasized to other anatomic sites. The prognosis is usually poor."
34
+ },
35
+ {
36
+ "id": "MONDO_0002666",
37
+ "name": "pancreatic signet ring cell adenocarcinoma",
38
+ "description": "A rare pancreatic ductal adenocarcinoma with poor prognosis. It is characterized by the presence of malignant signet ring cells infiltrating the pancreatic parenchyma in an individual cell pattern."
39
+ },
40
+ {
41
+ "id": "MONDO_0005614",
42
+ "name": "pancreatic adenosquamous carcinoma",
43
+ "description": "A carcinoma that arises from the pancreas showing both ductal and squamous differentiation. The squamous component should represent at least 30% of the malignant cellular infiltrate. The prognosis is usually worse than that of ductal adenocarcinoma."
44
+ },
45
+ {
46
+ "id": "MONDO_0006270",
47
+ "name": "lobular breast carcinoma in situ",
48
+ "description": "A non-invasive adenocarcinoma of the breast characterized by a proliferation of monomorphic cells completely filling the lumina. The overall lobular architecture is preserved. It is frequently multifocal (90% in some series) and bilateral. It seldom becomes invasive; however there is an increased risk of infiltrating ductal adenocarcinoma."
49
+ },
50
+ {
51
+ "id": "OBA_2050353",
52
+ "name": "age of onset of pancreatic ductal adenocarcinoma",
53
+ "description": "The age at which pancreatic ductal adenocarcinoma manifestations first appear."
54
+ }
55
+ ]
56
+ }
57
+ }
58
+ }
59
+ },
60
+ {
61
+ "tool": "OpenTargets_get_associated_targets_by_disease_efoId",
62
+ "args": {
63
+ "efoId": "MONDO_0005184",
64
+ "size": 25
65
+ },
66
+ "response": {
67
+ "status": "success",
68
+ "data": {
69
+ "disease": {
70
+ "id": "MONDO_0005184",
71
+ "name": "pancreatic ductal adenocarcinoma",
72
+ "associatedTargets": {
73
+ "count": 7656,
74
+ "rows": [
75
+ {
76
+ "target": {
77
+ "id": "ENSG00000133703",
78
+ "approvedSymbol": "KRAS"
79
+ },
80
+ "score": 0.5291977040592586
81
+ },
82
+ {
83
+ "target": {
84
+ "id": "ENSG00000141510",
85
+ "approvedSymbol": "TP53"
86
+ },
87
+ "score": 0.49944194194971064
88
+ },
89
+ {
90
+ "target": {
91
+ "id": "ENSG00000147889",
92
+ "approvedSymbol": "CDKN2A"
93
+ },
94
+ "score": 0.49599448524186573
95
+ },
96
+ {
97
+ "target": {
98
+ "id": "ENSG00000177084",
99
+ "approvedSymbol": "POLE"
100
+ },
101
+ "score": 0.4868680592918723
102
+ },
103
+ {
104
+ "target": {
105
+ "id": "ENSG00000062822",
106
+ "approvedSymbol": "POLD1"
107
+ },
108
+ "score": 0.48593032024807303
109
+ },
110
+ {
111
+ "target": {
112
+ "id": "ENSG00000198900",
113
+ "approvedSymbol": "TOP1"
114
+ },
115
+ "score": 0.4618463053159949
116
+ },
117
+ {
118
+ "target": {
119
+ "id": "ENSG00000141646",
120
+ "approvedSymbol": "SMAD4"
121
+ },
122
+ "score": 0.44565744688506587
123
+ },
124
+ {
125
+ "target": {
126
+ "id": "ENSG00000115170",
127
+ "approvedSymbol": "ACVR1"
128
+ },
129
+ "score": 0.44390531781927567
130
+ },
131
+ {
132
+ "target": {
133
+ "id": "ENSG00000140937",
134
+ "approvedSymbol": "CDH11"
135
+ },
136
+ "score": 0.43912921188656784
137
+ },
138
+ {
139
+ "target": {
140
+ "id": "ENSG00000167553",
141
+ "approvedSymbol": "TUBA1C"
142
+ },
143
+ "score": 0.4335269722906423
144
+ },
145
+ {
146
+ "target": {
147
+ "id": "ENSG00000178568",
148
+ "approvedSymbol": "ERBB4"
149
+ },
150
+ "score": 0.43245181660076104
151
+ },
152
+ {
153
+ "target": {
154
+ "id": "ENSG00000120217",
155
+ "approvedSymbol": "CD274"
156
+ },
157
+ "score": 0.4311901321308853
158
+ },
159
+ {
160
+ "target": {
161
+ "id": "ENSG00000167325",
162
+ "approvedSymbol": "RRM1"
163
+ },
164
+ "score": 0.4269595666656111
165
+ },
166
+ {
167
+ "target": {
168
+ "id": "ENSG00000171848",
169
+ "approvedSymbol": "RRM2"
170
+ },
171
+ "score": 0.4254344546577324
172
+ },
173
+ {
174
+ "target": {
175
+ "id": "ENSG00000146143",
176
+ "approvedSymbol": "PRIM2"
177
+ },
178
+ "score": 0.42386371823232
179
+ },
180
+ {
181
+ "target": {
182
+ "id": "ENSG00000101162",
183
+ "approvedSymbol": "TUBB1"
184
+ },
185
+ "score": 0.422448089960708
186
+ },
187
+ {
188
+ "target": {
189
+ "id": "ENSG00000176890",
190
+ "approvedSymbol": "TYMS"
191
+ },
192
+ "score": 0.4197400685336987
193
+ },
194
+ {
195
+ "target": {
196
+ "id": "ENSG00000100479",
197
+ "approvedSymbol": "POLE2"
198
+ },
199
+ "score": 0.41912077676386633
200
+ },
201
+ {
202
+ "target": {
203
+ "id": "ENSG00000101868",
204
+ "approvedSymbol": "POLA1"
205
+ },
206
+ "score": 0.4183816170544969
207
+ },
208
+ {
209
+ "target": {
210
+ "id": "ENSG00000258947",
211
+ "approvedSymbol": "TUBB3"
212
+ },
213
+ "score": 0.41803837591047344
214
+ },
215
+ {
216
+ "target": {
217
+ "id": "ENSG00000014138",
218
+ "approvedSymbol": "POLA2"
219
+ },
220
+ "score": 0.41801203719981217
221
+ },
222
+ {
223
+ "target": {
224
+ "id": "ENSG00000175482",
225
+ "approvedSymbol": "POLD4"
226
+ },
227
+ "score": 0.41708808756310045
228
+ },
229
+ {
230
+ "target": {
231
+ "id": "ENSG00000048392",
232
+ "approvedSymbol": "RRM2B"
233
+ },
234
+ "score": 0.41708808756310045
235
+ },
236
+ {
237
+ "target": {
238
+ "id": "ENSG00000106628",
239
+ "approvedSymbol": "POLD2"
240
+ },
241
+ "score": 0.4161641379263887
242
+ },
243
+ {
244
+ "target": {
245
+ "id": "ENSG00000198056",
246
+ "approvedSymbol": "PRIM1"
247
+ },
248
+ "score": 0.4161641379263887
249
+ }
250
+ ],
251
+ "returned": 25,
252
+ "truncated": true
253
+ }
254
+ }
255
+ },
256
+ "metadata": {
257
+ "truncation_note": "PARTIAL RESULT: 'associatedTargets' returned 25 of 7656 rows -- 'rows' is one page of the collection, not all of it. Open Targets defaults to a page size of 25 when no page size is given. The 7631 rows not shown are missing from this response only; their absence here is NOT evidence they are absent from Open Targets. Re-query with size: 3000 and index: 0 to get the first 3000 rows (Open Targets rejects a page size above 3000), then step the page index to reach the rest."
258
+ }
259
+ }
260
+ }
261
+ ]
262
+ }
tests/fixtures/cassettes/q9_pathways_isg15_rsad2_oas1.json ADDED
@@ -0,0 +1,578 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "_recorded": {
3
+ "scenario": "q9_pathways_isg15_rsad2_oas1",
4
+ "why": "Phase 0 Q9 (Interferon alpha/beta signaling)",
5
+ "endpoint": "pathways_for_genes",
6
+ "arguments": [
7
+ "ISG15,RSAD2,OAS1"
8
+ ],
9
+ "tooluniverse_version": "1.5.6",
10
+ "recorded_on": "2026-10-21T04:43:43Z",
11
+ "recorded_at_epoch": 1792557818.642,
12
+ "recorded_with": "scripts/record_fixtures.py",
13
+ "payload_status_when_recorded": "ok",
14
+ "licences": {
15
+ "hgnc": "No restrictions on access or use (HGNC data release policy); citation requested",
16
+ "reactome": "CC0 1.0"
17
+ }
18
+ },
19
+ "calls": [
20
+ {
21
+ "tool": "HGNC_fetch_gene_by_symbol",
22
+ "args": {
23
+ "symbol": "ISG15"
24
+ },
25
+ "response": {
26
+ "status": "success",
27
+ "data": {
28
+ "vega_id": "OTTHUMG00000040777",
29
+ "symbol": "ISG15",
30
+ "orphanet": 315470,
31
+ "date_symbol_changed": "2006-04-28T00:00:00Z",
32
+ "locus_group": "protein-coding gene",
33
+ "prev_symbol": [
34
+ "G1P2"
35
+ ],
36
+ "date_name_changed": "2019-02-18T00:00:00Z",
37
+ "entrez_id": "9636",
38
+ "date_approved_reserved": "1990-10-16T00:00:00Z",
39
+ "ccds_id": [
40
+ "CCDS6"
41
+ ],
42
+ "ensembl_gene_id": "ENSG00000187608",
43
+ "mgd_id": [
44
+ "MGI:1855694"
45
+ ],
46
+ "ena": [
47
+ "BC009507"
48
+ ],
49
+ "refseq_accession": [
50
+ "NM_005101"
51
+ ],
52
+ "mane_select": [
53
+ "ENST00000649529.1",
54
+ "NM_005101.4"
55
+ ],
56
+ "agr": "HGNC:4053",
57
+ "pubmed_id": [
58
+ 3087979
59
+ ],
60
+ "locus_type": "gene with protein product",
61
+ "date_modified": "2023-01-20T00:00:00Z",
62
+ "location": "1p36.33",
63
+ "prev_name": [
64
+ "interferon, alpha-inducible protein (clone IFI-15K)"
65
+ ],
66
+ "uniprot_ids": [
67
+ "P05161"
68
+ ],
69
+ "ucsc_id": "uc001acj.5",
70
+ "rgd_id": [
71
+ "RGD:1310312"
72
+ ],
73
+ "alias_symbol": [
74
+ "IFI15",
75
+ "UCRP"
76
+ ],
77
+ "hgnc_id": "HGNC:4053",
78
+ "gencc": "HGNC:4053",
79
+ "status": "Approved",
80
+ "name": "ISG15 ubiquitin like modifier",
81
+ "omim_id": [
82
+ "147571"
83
+ ],
84
+ "uuid": "3cc085a6-ff20-4251-a3c2-68a68d18210f",
85
+ "lsdb": [
86
+ "LRG_1231|http://ftp.ebi.ac.uk/pub/databases/lrgex/LRG_1231.xml"
87
+ ]
88
+ },
89
+ "metadata": {
90
+ "source": "HGNC",
91
+ "query_field": "symbol",
92
+ "query_value": "ISG15",
93
+ "num_found": 1
94
+ }
95
+ }
96
+ },
97
+ {
98
+ "tool": "HGNC_fetch_gene_by_symbol",
99
+ "args": {
100
+ "symbol": "RSAD2"
101
+ },
102
+ "response": {
103
+ "status": "success",
104
+ "data": {
105
+ "symbol": "RSAD2",
106
+ "gene_group_id": [
107
+ 2036,
108
+ 3925
109
+ ],
110
+ "locus_group": "protein-coding gene",
111
+ "vega_id": "OTTHUMG00000090352",
112
+ "ensembl_gene_id": "ENSG00000134321",
113
+ "ccds_id": [
114
+ "CCDS1656",
115
+ "CCDS92710"
116
+ ],
117
+ "mgd_id": [
118
+ "MGI:1929628"
119
+ ],
120
+ "gene_group": [
121
+ "Radical S-adenosylmethionine domain containing",
122
+ "Carbon-oxygen lyases"
123
+ ],
124
+ "ena": [
125
+ "AF442151"
126
+ ],
127
+ "entrez_id": "91543",
128
+ "date_approved_reserved": "2004-11-08T00:00:00Z",
129
+ "locus_type": "gene with protein product",
130
+ "date_modified": "2023-01-20T00:00:00Z",
131
+ "alias_name": [
132
+ "virus-inhibitory protein, endoplasmic reticulum–associated, interferon-inducible"
133
+ ],
134
+ "refseq_accession": [
135
+ "NM_080657"
136
+ ],
137
+ "mane_select": [
138
+ "ENST00000382040.4",
139
+ "NM_080657.5"
140
+ ],
141
+ "agr": "HGNC:30908",
142
+ "pubmed_id": [
143
+ 11752458
144
+ ],
145
+ "status": "Approved",
146
+ "name": "radical S-adenosyl methionine domain containing 2",
147
+ "omim_id": [
148
+ "607810"
149
+ ],
150
+ "uuid": "d9641307-6faf-41ec-a6de-7f4bc9f68905",
151
+ "location": "2p25.2",
152
+ "uniprot_ids": [
153
+ "Q8WXG1"
154
+ ],
155
+ "rgd_id": [
156
+ "RGD:620495"
157
+ ],
158
+ "ucsc_id": "uc002qyp.2",
159
+ "alias_symbol": [
160
+ "cig5",
161
+ "vig1",
162
+ "Viperin"
163
+ ],
164
+ "hgnc_id": "HGNC:30908"
165
+ },
166
+ "metadata": {
167
+ "source": "HGNC",
168
+ "query_field": "symbol",
169
+ "query_value": "RSAD2",
170
+ "num_found": 1
171
+ }
172
+ }
173
+ },
174
+ {
175
+ "tool": "HGNC_fetch_gene_by_symbol",
176
+ "args": {
177
+ "symbol": "OAS1"
178
+ },
179
+ "response": {
180
+ "status": "success",
181
+ "data": {
182
+ "symbol": "OAS1",
183
+ "locus_group": "protein-coding gene",
184
+ "gene_group_id": [
185
+ 2093
186
+ ],
187
+ "vega_id": "OTTHUMG00000169792",
188
+ "enzyme_id": [
189
+ "2.7.7.-"
190
+ ],
191
+ "ensembl_gene_id": "ENSG00000089127",
192
+ "ccds_id": [
193
+ "CCDS31905",
194
+ "CCDS41838",
195
+ "CCDS44980",
196
+ "CCDS81742",
197
+ "CCDS91752",
198
+ "CCDS91755"
199
+ ],
200
+ "ena": [
201
+ "X04371"
202
+ ],
203
+ "gene_group": [
204
+ "2'-5'-oligoadenylate synthetase family"
205
+ ],
206
+ "mgd_id": [
207
+ "MGI:2180860",
208
+ "MGI:97430",
209
+ "MGI:2149633",
210
+ "MGI:2140770",
211
+ "MGI:2180856",
212
+ "MGI:2180855",
213
+ "MGI:97429",
214
+ "MGI:2180853"
215
+ ],
216
+ "prev_symbol": [
217
+ "OIAS"
218
+ ],
219
+ "date_approved_reserved": "2001-06-22T00:00:00Z",
220
+ "entrez_id": "4938",
221
+ "date_name_changed": "2025-10-10T00:00:00Z",
222
+ "alias_name": [
223
+ "2'-5' oligoadenylate synthase 1"
224
+ ],
225
+ "date_modified": "2025-10-10T00:00:00Z",
226
+ "locus_type": "gene with protein product",
227
+ "pubmed_id": [
228
+ 9344649,
229
+ 9325053
230
+ ],
231
+ "agr": "HGNC:8086",
232
+ "refseq_accession": [
233
+ "NM_001032409"
234
+ ],
235
+ "mane_select": [
236
+ "ENST00000202917.10",
237
+ "NM_016816.4"
238
+ ],
239
+ "status": "Approved",
240
+ "uuid": "99e53375-4117-4b2d-bc59-4996fc779ad6",
241
+ "omim_id": [
242
+ "164350"
243
+ ],
244
+ "name": "2'-5'-oligoadenylate synthetase 1",
245
+ "rgd_id": [
246
+ "RGD:621760"
247
+ ],
248
+ "ucsc_id": "uc001tuc.4",
249
+ "uniprot_ids": [
250
+ "P00973"
251
+ ],
252
+ "prev_name": [
253
+ "2',5'-oligoadenylate synthetase 1 (40-46 kD)",
254
+ "2'-5'-oligoadenylate synthetase 1, 40/46kDa"
255
+ ],
256
+ "location": "12q24.13",
257
+ "gencc": "HGNC:8086",
258
+ "hgnc_id": "HGNC:8086",
259
+ "alias_symbol": [
260
+ "OIASI",
261
+ "IFI-4"
262
+ ]
263
+ },
264
+ "metadata": {
265
+ "source": "HGNC",
266
+ "query_field": "symbol",
267
+ "query_value": "OAS1",
268
+ "num_found": 1
269
+ }
270
+ }
271
+ },
272
+ {
273
+ "tool": "Reactome_map_uniprot_to_pathways",
274
+ "args": {
275
+ "uniprot_id": "P05161"
276
+ },
277
+ "response": {
278
+ "status": "success",
279
+ "data": [
280
+ {
281
+ "dbId": 1169408,
282
+ "displayName": "ISG15 antiviral mechanism",
283
+ "stId": "R-HSA-1169408",
284
+ "stIdVersion": "R-HSA-1169408.4",
285
+ "isInDisease": false,
286
+ "isInferred": false,
287
+ "maxDepth": 2,
288
+ "name": [
289
+ "ISG15 antiviral mechanism"
290
+ ],
291
+ "releaseDate": "2011-12-06",
292
+ "speciesName": "Homo sapiens",
293
+ "hasDiagram": true,
294
+ "hasEHLD": false,
295
+ "lastUpdatedDate": "2021-09-15",
296
+ "schemaClass": "Pathway",
297
+ "className": "Pathway"
298
+ },
299
+ {
300
+ "dbId": 168276,
301
+ "displayName": "NS1 Mediated Effects on Host Pathways",
302
+ "stId": "R-HSA-168276",
303
+ "stIdVersion": "R-HSA-168276.11",
304
+ "isInDisease": true,
305
+ "isInferred": false,
306
+ "maxDepth": 4,
307
+ "name": [
308
+ "NS1 Mediated Effects on Host Pathways"
309
+ ],
310
+ "releaseDate": "2005-06-13",
311
+ "speciesName": "Homo sapiens",
312
+ "doi": "10.3180/R-HSA-168276.9",
313
+ "hasDiagram": false,
314
+ "hasEHLD": false,
315
+ "lastUpdatedDate": "2023-09-13",
316
+ "schemaClass": "Pathway",
317
+ "className": "Pathway"
318
+ },
319
+ {
320
+ "dbId": 168928,
321
+ "displayName": "DDX58/IFIH1-mediated induction of interferon-alpha/beta",
322
+ "stId": "R-HSA-168928",
323
+ "stIdVersion": "R-HSA-168928.12",
324
+ "isInDisease": false,
325
+ "isInferred": false,
326
+ "maxDepth": 3,
327
+ "name": [
328
+ "DDX58/IFIH1-mediated induction of interferon-alpha/beta",
329
+ "RIG-I/MDA5 mediated induction of IFN-alpha/beta pathways"
330
+ ],
331
+ "releaseDate": "2010-12-14",
332
+ "speciesName": "Homo sapiens",
333
+ "doi": "10.3180/R-HSA-168928.9",
334
+ "hasDiagram": true,
335
+ "hasEHLD": false,
336
+ "lastUpdatedDate": "2023-09-13",
337
+ "schemaClass": "Pathway",
338
+ "className": "Pathway"
339
+ },
340
+ {
341
+ "dbId": 5656169,
342
+ "displayName": "Termination of translesion DNA synthesis",
343
+ "stId": "R-HSA-5656169",
344
+ "stIdVersion": "R-HSA-5656169.2",
345
+ "isInDisease": false,
346
+ "isInferred": false,
347
+ "maxDepth": 2,
348
+ "name": [
349
+ "Termination of translesion DNA synthesis"
350
+ ],
351
+ "releaseDate": "2015-03-19",
352
+ "speciesName": "Homo sapiens",
353
+ "hasDiagram": false,
354
+ "hasEHLD": false,
355
+ "schemaClass": "Pathway",
356
+ "className": "Pathway"
357
+ },
358
+ {
359
+ "dbId": 909733,
360
+ "displayName": "Interferon alpha/beta signaling",
361
+ "stId": "R-HSA-909733",
362
+ "stIdVersion": "R-HSA-909733.9",
363
+ "isInDisease": false,
364
+ "isInferred": false,
365
+ "maxDepth": 3,
366
+ "name": [
367
+ "Interferon alpha/beta signaling"
368
+ ],
369
+ "releaseDate": "2010-12-14",
370
+ "speciesName": "Homo sapiens",
371
+ "doi": "10.3180/R-HSA-909733.6",
372
+ "hasDiagram": true,
373
+ "hasEHLD": false,
374
+ "lastUpdatedDate": "2023-06-07",
375
+ "schemaClass": "Pathway",
376
+ "className": "Pathway"
377
+ },
378
+ {
379
+ "dbId": 936440,
380
+ "displayName": "Negative regulators of DDX58/IFIH1 signaling",
381
+ "stId": "R-HSA-936440",
382
+ "stIdVersion": "R-HSA-936440.6",
383
+ "isInDisease": false,
384
+ "isInferred": false,
385
+ "maxDepth": 2,
386
+ "name": [
387
+ "Negative regulators of DDX58/IFIH1 signaling",
388
+ "Negative regulators of RIG-I/MDA5 signaling"
389
+ ],
390
+ "releaseDate": "2010-12-14",
391
+ "speciesName": "Homo sapiens",
392
+ "hasDiagram": false,
393
+ "hasEHLD": false,
394
+ "lastUpdatedDate": "2022-04-07",
395
+ "schemaClass": "Pathway",
396
+ "className": "Pathway"
397
+ },
398
+ {
399
+ "dbId": 9705671,
400
+ "displayName": "SARS-CoV-2 activates/modulates innate and adaptive immune responses",
401
+ "stId": "R-HSA-9705671",
402
+ "stIdVersion": "R-HSA-9705671.5",
403
+ "isInDisease": true,
404
+ "isInferred": false,
405
+ "maxDepth": 2,
406
+ "name": [
407
+ "SARS-CoV-2 activates/modulates innate and adaptive immune responses"
408
+ ],
409
+ "releaseDate": "2022-03-23",
410
+ "speciesName": "Homo sapiens",
411
+ "hasDiagram": false,
412
+ "hasEHLD": false,
413
+ "lastUpdatedDate": "2023-03-29",
414
+ "schemaClass": "Pathway",
415
+ "className": "Pathway"
416
+ },
417
+ {
418
+ "dbId": 9833110,
419
+ "displayName": "RSV-host interactions",
420
+ "stId": "R-HSA-9833110",
421
+ "stIdVersion": "R-HSA-9833110.2",
422
+ "isInDisease": true,
423
+ "isInferred": false,
424
+ "maxDepth": 3,
425
+ "name": [
426
+ "RSV-host interactions"
427
+ ],
428
+ "releaseDate": "2023-12-06",
429
+ "speciesName": "Homo sapiens",
430
+ "hasDiagram": true,
431
+ "hasEHLD": false,
432
+ "schemaClass": "Pathway",
433
+ "className": "Pathway"
434
+ },
435
+ {
436
+ "dbId": 9833482,
437
+ "displayName": "PKR-mediated signaling",
438
+ "stId": "R-HSA-9833482",
439
+ "stIdVersion": "R-HSA-9833482.3",
440
+ "isInDisease": false,
441
+ "isInferred": false,
442
+ "maxDepth": 2,
443
+ "name": [
444
+ "PKR-mediated signaling"
445
+ ],
446
+ "releaseDate": "2023-09-13",
447
+ "speciesName": "Homo sapiens",
448
+ "doi": "10.3180/R-HSA-9833482.1",
449
+ "hasDiagram": true,
450
+ "hasEHLD": false,
451
+ "schemaClass": "Pathway",
452
+ "className": "Pathway"
453
+ },
454
+ {
455
+ "dbId": 9909505,
456
+ "displayName": "Modulation of host responses by IFN-stimulated genes",
457
+ "stId": "R-HSA-9909505",
458
+ "stIdVersion": "R-HSA-9909505.2",
459
+ "isInDisease": false,
460
+ "isInferred": false,
461
+ "maxDepth": 2,
462
+ "name": [
463
+ "Modulation of host responses by IFN-stimulated genes"
464
+ ],
465
+ "releaseDate": "2024-12-04",
466
+ "speciesName": "Homo sapiens",
467
+ "doi": "10.3180/R-HSA-9909505.1",
468
+ "hasDiagram": true,
469
+ "hasEHLD": false,
470
+ "schemaClass": "Pathway",
471
+ "className": "Pathway"
472
+ }
473
+ ]
474
+ }
475
+ },
476
+ {
477
+ "tool": "Reactome_map_uniprot_to_pathways",
478
+ "args": {
479
+ "uniprot_id": "Q8WXG1"
480
+ },
481
+ "response": {
482
+ "status": "success",
483
+ "data": [
484
+ {
485
+ "dbId": 909733,
486
+ "displayName": "Interferon alpha/beta signaling",
487
+ "stId": "R-HSA-909733",
488
+ "stIdVersion": "R-HSA-909733.9",
489
+ "isInDisease": false,
490
+ "isInferred": false,
491
+ "maxDepth": 3,
492
+ "name": [
493
+ "Interferon alpha/beta signaling"
494
+ ],
495
+ "releaseDate": "2010-12-14",
496
+ "speciesName": "Homo sapiens",
497
+ "doi": "10.3180/R-HSA-909733.6",
498
+ "hasDiagram": true,
499
+ "hasEHLD": false,
500
+ "lastUpdatedDate": "2023-06-07",
501
+ "schemaClass": "Pathway",
502
+ "className": "Pathway"
503
+ }
504
+ ]
505
+ }
506
+ },
507
+ {
508
+ "tool": "Reactome_map_uniprot_to_pathways",
509
+ "args": {
510
+ "uniprot_id": "P00973"
511
+ },
512
+ "response": {
513
+ "status": "success",
514
+ "data": [
515
+ {
516
+ "dbId": 877300,
517
+ "displayName": "Interferon gamma signaling",
518
+ "stId": "R-HSA-877300",
519
+ "stIdVersion": "R-HSA-877300.9",
520
+ "isInDisease": false,
521
+ "isInferred": false,
522
+ "maxDepth": 3,
523
+ "name": [
524
+ "Interferon gamma signaling"
525
+ ],
526
+ "releaseDate": "2010-12-14",
527
+ "speciesName": "Homo sapiens",
528
+ "hasDiagram": true,
529
+ "hasEHLD": false,
530
+ "lastUpdatedDate": "2023-06-07",
531
+ "schemaClass": "Pathway",
532
+ "className": "Pathway"
533
+ },
534
+ {
535
+ "dbId": 8983711,
536
+ "displayName": "OAS antiviral response",
537
+ "stId": "R-HSA-8983711",
538
+ "stIdVersion": "R-HSA-8983711.5",
539
+ "isInDisease": false,
540
+ "isInferred": false,
541
+ "maxDepth": 2,
542
+ "name": [
543
+ "OAS antiviral response"
544
+ ],
545
+ "releaseDate": "2018-09-12",
546
+ "speciesName": "Homo sapiens",
547
+ "doi": "10.3180/R-HSA-8983711.1",
548
+ "hasDiagram": true,
549
+ "hasEHLD": false,
550
+ "lastUpdatedDate": "2020-06-17",
551
+ "schemaClass": "Pathway",
552
+ "className": "Pathway"
553
+ },
554
+ {
555
+ "dbId": 909733,
556
+ "displayName": "Interferon alpha/beta signaling",
557
+ "stId": "R-HSA-909733",
558
+ "stIdVersion": "R-HSA-909733.9",
559
+ "isInDisease": false,
560
+ "isInferred": false,
561
+ "maxDepth": 3,
562
+ "name": [
563
+ "Interferon alpha/beta signaling"
564
+ ],
565
+ "releaseDate": "2010-12-14",
566
+ "speciesName": "Homo sapiens",
567
+ "doi": "10.3180/R-HSA-909733.6",
568
+ "hasDiagram": true,
569
+ "hasEHLD": false,
570
+ "lastUpdatedDate": "2023-06-07",
571
+ "schemaClass": "Pathway",
572
+ "className": "Pathway"
573
+ }
574
+ ]
575
+ }
576
+ }
577
+ ]
578
+ }
tests/test_accepted_vulns.py ADDED
@@ -0,0 +1,51 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Accepted pip-audit findings stay narrow, documented, dated, and die when they are fixed."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import datetime
6
+ import re
7
+ from pathlib import Path
8
+
9
+ ROOT = Path(__file__).resolve().parent.parent
10
+ LINE = re.compile(r"^(?P<id>\S+) expires=(?P<date>\d{4}-\d{2}-\d{2}) (?P<pkg>\S+) (?P<ver>\S+)$")
11
+
12
+
13
+ def _entries():
14
+ out = []
15
+ for raw in (ROOT / "security/accepted-vulns.txt").read_text().splitlines():
16
+ line = raw.strip()
17
+ if not line or line.startswith("#"):
18
+ continue
19
+ m = LINE.match(line)
20
+ assert m, f"malformed acceptance line: {line!r}"
21
+ out.append(m.groupdict())
22
+ return out
23
+
24
+
25
+ def _locked(package: str) -> str | None:
26
+ lock = (ROOT / "requirements.txt").read_text()
27
+ m = re.search(rf"^{re.escape(package)}==(\S+)$", lock, re.MULTILINE | re.IGNORECASE)
28
+ return m.group(1) if m else None
29
+
30
+
31
+ def test_every_acceptance_is_well_formed_dated_and_short_lived():
32
+ for e in _entries():
33
+ expires = datetime.date.fromisoformat(e["date"])
34
+ # D5-A: no acceptance runs past 2026-11-20 without a new decision.
35
+ assert expires <= datetime.date(2026, 11, 20), e
36
+
37
+
38
+ def test_every_acceptance_is_justified_in_the_findings_record():
39
+ record = (ROOT / "security/ACCEPTED-FINDINGS.md").read_text()
40
+ for e in _entries():
41
+ suffix = e["id"].split("-")[-1]
42
+ assert e["id"] in record or f"-{suffix}" in record, e["id"]
43
+
44
+
45
+ def test_an_acceptance_dies_when_its_package_moves_off_the_affected_version():
46
+ """When the lock moves pillow to a fixed version, remove the entries; this fails until then."""
47
+ for e in _entries():
48
+ assert _locked(e["pkg"]) == e["ver"], (
49
+ f"{e['pkg']} is now {_locked(e['pkg'])} in requirements.txt; {e['id']} was accepted "
50
+ f"for {e['ver']} only — remove it from security/accepted-vulns.txt"
51
+ )
tests/test_access_gate.py ADDED
@@ -0,0 +1,74 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """C7 — the gate runs first, in every handler, machine and UI alike (family contract)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import types
7
+
8
+ import pytest
9
+
10
+ import gradio_ui
11
+ from src.endpoints import ENDPOINTS
12
+
13
+
14
+ @pytest.fixture()
15
+ def enforced(monkeypatch):
16
+ monkeypatch.setenv("ACCESS_CONTROL", "enforce")
17
+ monkeypatch.setenv("ALLOWED_IDS", "someone-else")
18
+ monkeypatch.setenv("ADMIN_IDS", "an-admin")
19
+
20
+
21
+ @pytest.mark.parametrize("name", list(ENDPOINTS))
22
+ def test_no_token_is_denied_with_no_run_id(enforced, name):
23
+ payload = json.loads(getattr(gradio_ui, name)(request=None))
24
+ assert payload["status"] == "denied"
25
+ assert payload["machine_auth"] == "no_token_header"
26
+ assert "run_id" not in payload
27
+
28
+
29
+ @pytest.mark.parametrize("name", list(ENDPOINTS))
30
+ def test_the_gate_runs_before_validation(enforced, name):
31
+ """Bad input from an unauthenticated caller is denied, never refused: it learns nothing."""
32
+ payload = json.loads(getattr(gradio_ui, name)("what drugs target KRAS?", request=None))
33
+ assert payload["status"] == "denied"
34
+
35
+
36
+ def test_an_unresolvable_token_is_denied(enforced, monkeypatch):
37
+ monkeypatch.setattr(gradio_ui, "_resolve_token_identity", lambda token: None)
38
+ request = types.SimpleNamespace(headers={"x-orchestrator-token": "Bearer not-a-real-token"})
39
+ payload = json.loads(gradio_ui.annotate_genes("KRAS", request=request))
40
+ assert payload["status"] == "denied" and payload["machine_auth"] == "token_unresolved"
41
+
42
+
43
+ def test_a_resolved_but_unlisted_caller_is_denied(enforced, monkeypatch):
44
+ monkeypatch.setattr(gradio_ui, "_resolve_token_identity", lambda token: "a-stranger")
45
+ request = types.SimpleNamespace(headers={"x-orchestrator-token": "hf_test"})
46
+ payload = json.loads(gradio_ui.annotate_genes("KRAS", request=request))
47
+ assert payload["status"] == "denied" and payload["machine_auth"] == "not_allowlisted"
48
+
49
+
50
+ def test_an_allowlisted_caller_gets_through_to_validation(enforced, monkeypatch):
51
+ monkeypatch.setattr(gradio_ui, "_resolve_token_identity", lambda token: "someone-else")
52
+ request = types.SimpleNamespace(headers={"x-orchestrator-token": "hf_test"})
53
+ payload = json.loads(gradio_ui.annotate_genes("what drugs target KRAS?", request=request))
54
+ assert payload["status"] == "refused" and payload["reason"] == "invalid_identifier"
55
+
56
+
57
+ def test_a_non_string_argument_is_a_typed_error(monkeypatch):
58
+ monkeypatch.delenv("ACCESS_CONTROL", raising=False)
59
+ payload = json.loads(gradio_ui.drugs_for_targets(["KRAS"], request=None))
60
+ assert payload["status"] == "error"
61
+ assert payload["machine_input"] == "genes_not_a_string"
62
+
63
+
64
+ def test_the_ui_handler_runs_the_gate_too(enforced):
65
+ summary, raw = gradio_ui._ui_call("annotate_genes")("KRAS", profile=None)
66
+ assert "Access denied" in summary
67
+ assert json.loads(raw)["status"] == "denied"
68
+
69
+
70
+ def test_gate_off_lets_a_call_through(monkeypatch, replay):
71
+ monkeypatch.delenv("ACCESS_CONTROL", raising=False)
72
+ replay("q3_annotate_elavl1") # installs the replay client; the machine call below reuses it
73
+ payload = json.loads(gradio_ui.annotate_genes("ELAVL1", request=None))
74
+ assert payload["status"] == "ok" and payload["rows"][0]["symbol"] == "ELAVL1"
tests/test_allowlist.py CHANGED
@@ -4,7 +4,7 @@ What these tests pin, against the INSTALLED ToolUniverse (offline: loading reads
4
  own JSON configs and opens no socket, which the conftest block would turn into a failure):
5
 
6
  - the config is the memo's 28 tools across 8 sources, and every name exists in 1.5.6;
7
- - the Space loads exactly the five endpoints' set (12 tools), nothing more;
8
  - no agentic, tool-finder, remote-MCP, web-search or key-gated tool is or can be loaded;
9
  - the installed version is the pinned one, and a mismatch refuses startup;
10
  - ToolUniverse's own result cache is off.
@@ -51,7 +51,7 @@ def test_every_allowlisted_name_exists_in_the_installed_version():
51
  def test_the_space_loads_exactly_the_endpoints_set(loaded_tu):
52
  loaded = frozenset(loaded_tu.all_tool_dict)
53
  assert loaded == config.endpoint_tool_names()
54
- assert len(loaded) == 12
55
  assert loaded <= config.allowed_tool_names()
56
 
57
 
 
4
  own JSON configs and opens no socket, which the conftest block would turn into a failure):
5
 
6
  - the config is the memo's 28 tools across 8 sources, and every name exists in 1.5.6;
7
+ - the Space loads exactly the five endpoints' set (8 tools since s2), nothing more;
8
  - no agentic, tool-finder, remote-MCP, web-search or key-gated tool is or can be loaded;
9
  - the installed version is the pinned one, and a mismatch refuses startup;
10
  - ToolUniverse's own result cache is off.
 
51
  def test_the_space_loads_exactly_the_endpoints_set(loaded_tu):
52
  loaded = frozenset(loaded_tu.all_tool_dict)
53
  assert loaded == config.endpoint_tool_names()
54
+ assert len(loaded) == 8
55
  assert loaded <= config.allowed_tool_names()
56
 
57
 
tests/test_deploy_surface.py ADDED
@@ -0,0 +1,88 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """The deploy descriptor, the endpoint table and the gr.api surface must agree (names are frozen)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import inspect
6
+ import re
7
+ from pathlib import Path
8
+
9
+ import yaml
10
+
11
+ _EXPECTED = [
12
+ "drugs_for_targets",
13
+ "trials_for_genes",
14
+ "annotate_genes",
15
+ "pathways_for_genes",
16
+ "targets_for_disease",
17
+ ]
18
+ _REPO = Path(__file__).resolve().parent.parent
19
+
20
+
21
+ def _descriptor() -> dict:
22
+ return yaml.safe_load((_REPO / "deploy" / "orchestrator_registration.yaml").read_text())
23
+
24
+
25
+ def test_descriptor_names_and_params_match_the_endpoint_table():
26
+ from src import config
27
+ from src.endpoints import ENDPOINTS
28
+
29
+ descriptor = _descriptor()
30
+ assert descriptor["id"] == "pdac_knowledge_agent"
31
+ assert descriptor["dispatch_kind"] == "tool_server"
32
+ assert descriptor["space"] == "anne-voigt/pdac-knowledge-agent"
33
+ assert descriptor["space_dev"] == "anne-voigt/pdac-knowledge-agent-dev"
34
+ names = [t["name"] for t in descriptor["tools"]]
35
+ assert names == _EXPECTED == list(ENDPOINTS) == list(config.ENDPOINTS)
36
+ for tool in descriptor["tools"]:
37
+ assert tool["api_name"] == f"/{tool['name']}"
38
+ assert tuple(tool["params"]) == ENDPOINTS[tool["name"]][1]
39
+
40
+
41
+ def test_gradio_functions_exist_with_string_defaults_and_trailing_request():
42
+ import gradio_ui
43
+ from src.endpoints import ENDPOINTS
44
+
45
+ for name in _EXPECTED:
46
+ params = list(inspect.signature(getattr(gradio_ui, name)).parameters.values())
47
+ assert params[-1].name == "request"
48
+ assert [p.name for p in params[:-1]] == list(ENDPOINTS[name][1])
49
+ assert all(p.default == "" for p in params[:-1])
50
+
51
+
52
+ def test_every_endpoint_is_registered_with_gr_api():
53
+ import gradio_ui
54
+
55
+ registered = {getattr(fn, "api_name", None) for fn in gradio_ui.demo.fns.values()}
56
+ assert set(_EXPECTED) <= registered
57
+
58
+
59
+ def test_the_boundary_sentence_is_the_adrs_verbatim():
60
+ adr = (_REPO / "docs/adr/ADR-0001-knowledge-specialist-contract.md").read_text()
61
+ quoted = re.search(
62
+ r'reused verbatim in the descriptor and the routing prompt: \*"(.+?)"\*', adr, re.DOTALL
63
+ )
64
+ sentence = " ".join(quoted.group(1).split())
65
+ assert " ".join(_descriptor()["description"].split()) == sentence
66
+
67
+
68
+ def test_descriptor_states_the_caps_the_code_enforces():
69
+ from src.endpoints import drugs, targets
70
+ from src.identifiers import MAX_IDENTIFIERS
71
+
72
+ described = {t["name"]: " ".join(t["description"].split()) for t in _descriptor()["tools"]}
73
+ assert (
74
+ f"for the {drugs.MAX_MECHANISM_LOOKUPS} most advanced drugs"
75
+ in described["drugs_for_targets"]
76
+ )
77
+ assert (
78
+ f"`size` is 1-{targets.MAX_SIZE} (default {targets.DEFAULT_SIZE})"
79
+ in described["targets_for_disease"]
80
+ )
81
+ for name in ("drugs_for_targets", "annotate_genes", "pathways_for_genes"):
82
+ assert f"at most {MAX_IDENTIFIERS}" in described[name], name
83
+ assert "MEMBERSHIP, NOT ENRICHMENT" in described["pathways_for_genes"]
84
+
85
+
86
+ def test_annotate_genes_carries_its_longer_timeout():
87
+ [annotate] = [t for t in _descriptor()["tools"] if t["name"] == "annotate_genes"]
88
+ assert annotate["timeout_seconds"] == 300
tests/test_endpoint_provenance.py ADDED
@@ -0,0 +1,75 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """C3 — provenance on every row of every endpoint, over every recorded scenario.
2
+
3
+ Rows carry a ``provenance`` block; so does each nested block that comes from a second source
4
+ (drugs' ChEMBL ``mechanisms``, annotation's UniProt ``protein``, the disease resolution and the
5
+ HGNC identifier mapping). Every ok payload is ``kind: "annotation"`` with no sample size.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import pytest
11
+
12
+ from src.provenance import missing_fields
13
+ from tests.conftest import SCENARIOS
14
+
15
+
16
+ def _nested_blocks(payload):
17
+ for row in payload["rows"]:
18
+ mech = row.get("mechanisms")
19
+ if isinstance(mech, dict) and mech.get("looked_up") is not False:
20
+ yield "mechanisms", mech
21
+ if row.get("protein"):
22
+ yield "protein", row["protein"]
23
+ for res in payload.get("resolved_identifiers") or []:
24
+ yield "resolved_identifiers", res
25
+ disease = payload.get("disease")
26
+ if isinstance(disease, dict) and "provenance" in disease:
27
+ yield "disease", disease
28
+
29
+
30
+ @pytest.mark.parametrize("scenario", SCENARIOS)
31
+ def test_every_row_and_nested_block_is_fully_stamped(replay, scenario):
32
+ payload = replay(scenario)
33
+ if payload["status"] != "ok":
34
+ assert payload["status"] == "refused" and payload["run_id"]
35
+ return
36
+ assert payload["kind"] == "annotation"
37
+ assert "n_for_inference" not in payload and "hierarchy" not in payload
38
+ for i, row in enumerate(payload["rows"]):
39
+ assert missing_fields(row) == [], (scenario, i)
40
+ for where, block in _nested_blocks(payload):
41
+ assert missing_fields(block) == [], (scenario, where)
42
+ used = {a["source"] for a in payload["attribution"]}
43
+ stamped_sources = {r["provenance"]["source"] for r in payload["rows"]} | {
44
+ b["provenance"]["source"] for _, b in _nested_blocks(payload)
45
+ }
46
+ assert stamped_sources <= used, scenario # every licence a row carries is attributed
47
+
48
+
49
+ @pytest.mark.parametrize("scenario", SCENARIOS)
50
+ def test_the_narrative_names_its_source_and_query_date(replay, scenario):
51
+ payload = replay(scenario)
52
+ if payload["status"] != "ok" or not payload["rows"]:
53
+ return
54
+ assert payload["narrative"].startswith("According to ")
55
+ assert "(queried 20" in payload["narrative"]
56
+
57
+
58
+ def test_licences_on_rows_come_from_config(replay):
59
+ payload = replay("q6_drugs_jak_stat")
60
+ rows = payload["rows"]
61
+ assert {r["provenance"]["licence"] for r in rows} == {"CC0 1.0"}
62
+ mechs = [r["mechanisms"] for r in rows if r["mechanisms"].get("looked_up") is not False]
63
+ assert mechs and {m["provenance"]["licence"] for m in mechs} == {"CC BY-SA 3.0"}
64
+ not_looked_up = [r for r in rows if r["mechanisms"].get("looked_up") is False]
65
+ assert len(not_looked_up) == len(rows) - sum(
66
+ 1 for r in rows if r["mechanisms"].get("looked_up") is not False
67
+ )
68
+
69
+
70
+ def test_a_cached_replay_keeps_the_recorded_retrieved_at(replay):
71
+ first = replay("q7_drugs_tnfrsf10b")
72
+ again = replay("q7_drugs_tnfrsf10b")
73
+ assert [r["provenance"]["retrieved_at"] for r in first["rows"]] == [
74
+ r["provenance"]["retrieved_at"] for r in again["rows"]
75
+ ]
tests/test_output_schemas.py CHANGED
@@ -14,7 +14,7 @@ import pytest
14
 
15
  from src import config, schemas
16
  from src.tu_client import SourceError, ToolClient
17
- from tests.conftest import FakeRunner
18
 
19
  OT_DRUGS = "OpenTargets_get_associated_drugs_by_target_ensemblID"
20
  ARGS = {"ensemblId": "ENSG00000120889", "size": 100}
@@ -88,27 +88,31 @@ def test_an_added_field_upstream_is_not_drift(memory_cache, clock, tnfrsf10b_res
88
  assert result.cache == "miss"
89
 
90
 
91
- def test_a_tool_with_no_pinned_schema_builds_no_rows(memory_cache, clock):
92
- client = ToolClient(
93
- runner=FakeRunner({"UniProt_search": {"status": "success", "data": {"results": []}}}),
94
- cache=memory_cache,
95
- clock=clock,
96
- tooluniverse_version="1.5.6",
97
- )
98
- with pytest.raises(SourceError) as excinfo:
99
- client.call("UniProt_search", {"query": "gene_exact:KRAS"})
100
- assert excinfo.value.reason == "upstream_schema_drift"
101
- assert "no pinned schema" in excinfo.value.detail
 
 
 
 
 
 
 
 
 
102
 
103
 
104
  def test_every_pinned_schema_belongs_to_an_endpoint_tool():
105
  assert schemas.pinned_tools() <= config.endpoint_tool_names()
106
 
107
 
108
- @pytest.mark.xfail(
109
- strict=True,
110
- reason="s2 records the Phase 0 fixtures and pins the remaining 11 schemas; when it does, "
111
- "this XPASSes and strict mode fails the suite until the marker is removed",
112
- )
113
  def test_every_endpoint_tool_has_a_pinned_schema():
114
  assert schemas.pinned_tools() == config.endpoint_tool_names()
 
14
 
15
  from src import config, schemas
16
  from src.tu_client import SourceError, ToolClient
17
+ from tests.conftest import SCENARIOS, FakeRunner
18
 
19
  OT_DRUGS = "OpenTargets_get_associated_drugs_by_target_ensemblID"
20
  ARGS = {"ensemblId": "ENSG00000120889", "size": 100}
 
88
  assert result.cache == "miss"
89
 
90
 
91
+ def test_a_tool_with_no_pinned_schema_builds_no_rows():
92
+ """Fail-closed: an approved tool that gains an endpoint without a schema cannot answer."""
93
+ with pytest.raises(schemas.SchemaDrift, match="no pinned schema"):
94
+ schemas.validate("PubChem_get_CID_by_compound_name", {"IdentifierList": {"CID": [60750]}})
95
+
96
+
97
+ @pytest.mark.parametrize("scenario", SCENARIOS)
98
+ def test_every_recorded_response_validates(scenario):
99
+ """C4: what the sources said on the recording day matches every pinned schema."""
100
+ import json
101
+
102
+ from src.tu_client import _unwrap
103
+ from tests.conftest import CASSETTES
104
+
105
+ for call in json.loads((CASSETTES / f"{scenario}.json").read_text())["calls"]:
106
+ try:
107
+ data = _unwrap(call["tool"], config.source_of(call["tool"]), call["response"])
108
+ except SourceError:
109
+ continue # a recorded error envelope (the legacy EFO id): no data to validate
110
+ schemas.validate(call["tool"], data)
111
 
112
 
113
  def test_every_pinned_schema_belongs_to_an_endpoint_tool():
114
  assert schemas.pinned_tools() <= config.endpoint_tool_names()
115
 
116
 
 
 
 
 
 
117
  def test_every_endpoint_tool_has_a_pinned_schema():
118
  assert schemas.pinned_tools() == config.endpoint_tool_names()
tests/test_phase0_replay.py ADDED
@@ -0,0 +1,135 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """C6 — the ten Phase 0 lookups, recorded once, replay to their expected entities.
2
+
3
+ Each scenario is a cassette in tests/fixtures/cassettes/ recorded live through the real
4
+ endpoints and ToolUniverse 1.5.6 (scripts/record_fixtures.py). Q10 (gemcitabine identifiers,
5
+ PubChem) has no Phase 1 endpoint and is not replayed.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from src.identifiers import NCT
11
+
12
+
13
+ def _drugs(payload, target=None):
14
+ return {r["drug"] for r in payload["rows"] if target is None or r["target"] == target}
15
+
16
+
17
+ def test_q1_kras_g12d_mrtx1133_absent_from_open_targets_and_the_note_says_so(replay):
18
+ payload = replay("q1_q2_drugs_kras")
19
+ assert payload["status"] == "ok"
20
+ assert not any("MRTX" in d for d in _drugs(payload))
21
+ notes = " ".join(n["note"] for n in payload["coverage_notes"])
22
+ assert "MRTX1133" in notes
23
+
24
+
25
+ def test_q2_kras_g12c_sotorasib_and_adagrasib_approved(replay):
26
+ payload = replay("q1_q2_drugs_kras")
27
+ approved = {r["drug"] for r in payload["rows"] if r["stage"] == "approved"}
28
+ assert {"SOTORASIB", "ADAGRASIB"} <= approved
29
+ assert "SOTORASIB" in payload["narrative"] and "ADAGRASIB" in payload["narrative"]
30
+
31
+
32
+ def test_q3_elavl1_is_an_rna_binding_stabiliser(replay):
33
+ payload = replay("q3_annotate_elavl1")
34
+ [row] = payload["rows"]
35
+ assert row["symbol"] == "ELAVL1"
36
+ assert row["protein"]["accession"] == "Q15717"
37
+ function = row["protein"]["function"].lower()
38
+ assert "rna" in function and "stabil" in function
39
+
40
+
41
+ def test_q4_porg_54_of_55_with_sord2p_unresolved(replay):
42
+ payload = replay("q4_annotate_porg")
43
+ assert len(payload["rows"]) == 55
44
+ with_protein = [r for r in payload["rows"] if r["protein"]]
45
+ assert len(with_protein) == 54
46
+ assert [u["input"] for u in payload["unresolved"]] == ["SORD2P"]
47
+ sord2p = next(r for r in payload["rows"] if r["symbol"] == "SORD2P")
48
+ assert sord2p["protein"] is None and sord2p["locus_type"] == "pseudogene"
49
+ assert "54 have a reviewed human UniProt entry" in payload["narrative"]
50
+
51
+
52
+ def test_q5_cd40_trial_semantics_are_pinned(replay):
53
+ payload = replay("q5_trials_cd40")
54
+ assert [r["nct_id"] for r in payload["rows"]] == ["NCT06205849"]
55
+ assert all(NCT.match(r["nct_id"]) for r in payload["rows"])
56
+ [executed] = payload["executed_queries"]
57
+ assert executed["executed_query"]["query.intr"] == (
58
+ "(AREA[InterventionName]CD40 OR AREA[InterventionOtherName]CD40)"
59
+ )
60
+ assert executed["executed_query"]["filter.overallStatus"] == "RECRUITING"
61
+ assert "intervention name" in payload["query_semantics"]
62
+
63
+
64
+ def test_q6_ruxolitinib_targets_jak1_and_jak2_and_stat1_has_none(replay):
65
+ payload = replay("q6_drugs_jak_stat")
66
+ for target in ("JAK1", "JAK2"):
67
+ assert any(d.startswith("RUXOLITINIB") for d in _drugs(payload, target)), target
68
+ assert _drugs(payload, "STAT1") == set()
69
+ assert "STAT1 has no listed drugs or clinical candidates" in payload["narrative"]
70
+ assert payload["mechanism_lookups"] == {"looked_up": 30, "cap": 30}
71
+
72
+
73
+ def test_q7_dr5_all_seven_agents(replay):
74
+ payload = replay("q7_drugs_tnfrsf10b")
75
+ assert _drugs(payload) == {
76
+ "CONATUMUMAB",
77
+ "LEXATUMUMAB",
78
+ "TIGATUZUMAB",
79
+ "DROZITUMAB",
80
+ "DULANERMIN",
81
+ "APOMAB",
82
+ "LBY-135",
83
+ }
84
+
85
+
86
+ def test_q8_pdac_top_targets(replay):
87
+ payload = replay("q8_targets_pdac")
88
+ assert payload["disease"]["id"] == "MONDO_0005184"
89
+ assert payload["disease"]["matched"] == "exact name"
90
+ top = [r["target"] for r in payload["rows"]]
91
+ assert {"KRAS", "TP53", "CDKN2A", "SMAD4"} <= set(top)
92
+ assert top[0] == "KRAS"
93
+ assert [r["rank"] for r in payload["rows"]] == list(range(1, len(top) + 1))
94
+ assert "Open Targets' own association score" in payload["narrative"]
95
+
96
+
97
+ def test_q9_isg15_rsad2_oas1_share_interferon_alpha_beta_signaling(replay):
98
+ payload = replay("q9_pathways_isg15_rsad2_oas1")
99
+ assert {"pathway_id": "R-HSA-909733", "pathway_name": "Interferon alpha/beta signaling"} in (
100
+ payload["shared_by_all"]
101
+ )
102
+ assert "membership, not enrichment" in payload["narrative"]
103
+ assert "enriched" not in payload["narrative"].lower()
104
+
105
+
106
+ def test_c1_sord2p_listed_as_unresolved_not_guessed(replay):
107
+ payload = replay("c1_annotate_kras_tp53_sord2p")
108
+ assert [r["symbol"] for r in payload["rows"]] == ["KRAS", "TP53", "SORD2P"]
109
+ assert [u["input"] for u in payload["unresolved"]] == ["SORD2P"]
110
+ assert "SORD2P" in payload["narrative"]
111
+
112
+
113
+ def test_an_unknown_symbol_is_unresolved_and_no_target_is_queried(replay):
114
+ payload = replay("edge_unknown_symbol")
115
+ assert payload["status"] == "ok" and payload["rows"] == []
116
+ assert payload["unresolved"][0]["input"] == "NOTAGENE1"
117
+
118
+
119
+ def test_a_legacy_efo_id_is_refused_with_open_targets_message(replay):
120
+ payload = replay("edge_legacy_efo")
121
+ assert payload["status"] == "refused"
122
+ assert payload["reason"] == "unknown_disease"
123
+ assert "MONDO" in payload["message"]
124
+
125
+
126
+ def test_every_scenario_was_recorded_through_the_pinned_version():
127
+ import json
128
+
129
+ from tests.conftest import CASSETTES, SCENARIOS
130
+
131
+ assert len(SCENARIOS) == 11
132
+ for name in SCENARIOS:
133
+ meta = json.loads((CASSETTES / f"{name}.json").read_text())["_recorded"]
134
+ assert meta["tooluniverse_version"] == "1.5.6", name
135
+ assert meta["recorded_with"] == "scripts/record_fixtures.py", name
tests/test_refusals_and_payloads.py ADDED
@@ -0,0 +1,220 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """C7 — refusals are data, and they happen before any outbound call (ADR-0001 §5, §6).
2
+
3
+ Every endpoint here runs against a client whose runner fails the test if it is ever called, so
4
+ "refused before any call" is enforced, not assumed.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import pytest
10
+
11
+ from src.endpoints import ENDPOINTS, _common
12
+ from src.tu_client import ToolClient
13
+ from tests.conftest import FakeRunner
14
+
15
+
16
+ class NoCalls:
17
+ def __call__(self, tool, args):
18
+ raise AssertionError(f"an outbound call was attempted: {tool} {args}")
19
+
20
+
21
+ @pytest.fixture()
22
+ def no_calls(memory_cache, clock):
23
+ _common.set_client(
24
+ ToolClient(runner=NoCalls(), cache=memory_cache, clock=clock, tooluniverse_version="1.5.6")
25
+ )
26
+ yield
27
+ _common.set_client(None)
28
+
29
+
30
+ GENE_ENDPOINTS = ["drugs_for_targets", "trials_for_genes", "annotate_genes", "pathways_for_genes"]
31
+
32
+
33
+ @pytest.mark.parametrize("endpoint", GENE_ENDPOINTS)
34
+ @pytest.mark.parametrize(
35
+ "genes",
36
+ [
37
+ "what drugs target KRAS?",
38
+ "TCGA-2J-AAB1-01A",
39
+ "GSM1234567",
40
+ "KRAS 2.5",
41
+ "",
42
+ "SELECT * FROM genes",
43
+ ],
44
+ )
45
+ def test_bad_gene_input_is_refused_before_any_call(no_calls, endpoint, genes):
46
+ payload = ENDPOINTS[endpoint][0](genes)
47
+ assert payload["status"] == "refused"
48
+ assert payload["reason"] == "invalid_identifier"
49
+ assert payload["run_id"] and payload["message"]
50
+
51
+
52
+ @pytest.mark.parametrize("endpoint", GENE_ENDPOINTS)
53
+ def test_more_than_100_identifiers_is_refused(no_calls, endpoint):
54
+ genes = ",".join(f"GENE{i}" for i in range(101))
55
+ payload = ENDPOINTS[endpoint][0](genes)
56
+ assert payload["reason"] == "too_many_identifiers"
57
+
58
+
59
+ @pytest.mark.parametrize("endpoint", ["trials_for_genes", "annotate_genes", "pathways_for_genes"])
60
+ def test_ensembl_ids_are_refused_where_hgnc_symbols_are_needed(no_calls, endpoint):
61
+ payload = ENDPOINTS[endpoint][0]("ENSG00000133703")
62
+ assert payload["reason"] == "invalid_identifier"
63
+
64
+
65
+ @pytest.mark.parametrize(
66
+ "args,reason",
67
+ [
68
+ (("CD40", "", "SOMETIMES"), "invalid_argument"),
69
+ (("CD40", "MONDO_0005184", ""), "invalid_identifier"),
70
+ (("CD40", "x" * 90, ""), "invalid_identifier"),
71
+ ],
72
+ )
73
+ def test_trials_arguments_are_checked(no_calls, args, reason):
74
+ from src.endpoints import trials_for_genes
75
+
76
+ assert trials_for_genes(*args)["reason"] == reason
77
+
78
+
79
+ @pytest.mark.parametrize("size", ["0", "51", "ten", "-3"])
80
+ def test_targets_size_is_bounded(no_calls, size):
81
+ from src.endpoints import targets_for_disease
82
+
83
+ payload = targets_for_disease("pancreatic ductal adenocarcinoma", size)
84
+ assert payload["reason"] == "invalid_argument"
85
+
86
+
87
+ @pytest.mark.parametrize("disease", ["", "patient 12; DOB 1950-01-01?", "TCGA-2J-AAB1", "1234"])
88
+ def test_bad_disease_is_refused(no_calls, disease):
89
+ from src.endpoints import targets_for_disease
90
+
91
+ assert targets_for_disease(disease)["reason"] == "invalid_identifier"
92
+
93
+
94
+ HGNC_KRAS = {
95
+ "status": "success",
96
+ "data": {
97
+ "symbol": "KRAS",
98
+ "hgnc_id": "HGNC:6407",
99
+ "name": "KRAS proto-oncogene, GTPase",
100
+ "locus_type": "gene with protein product",
101
+ "ensembl_gene_id": "ENSG00000133703",
102
+ "uniprot_ids": ["P01116"],
103
+ },
104
+ }
105
+
106
+
107
+ def _with(responses, memory_cache, clock):
108
+ _common.set_client(
109
+ ToolClient(
110
+ runner=FakeRunner(responses),
111
+ cache=memory_cache,
112
+ clock=clock,
113
+ tooluniverse_version="1.5.6",
114
+ )
115
+ )
116
+
117
+
118
+ def _hgnc(args):
119
+ symbol = args["symbol"]
120
+ ensembl = {"KRAS": "ENSG00000133703", "TNFRSF10B": "ENSG00000120889"}[symbol]
121
+ data = {**HGNC_KRAS["data"], "symbol": symbol, "ensembl_gene_id": ensembl}
122
+ return {"status": "success", "data": data}
123
+
124
+
125
+ def test_a_failing_source_is_a_source_error_not_a_guess(memory_cache, clock, tnfrsf10b_response):
126
+ """One target's Open Targets call fails: the other's rows stand, the failure is named."""
127
+ from src.endpoints import drugs_for_targets
128
+
129
+ def ot(args):
130
+ if args["ensemblId"] == "ENSG00000133703":
131
+ raise TimeoutError("read timed out")
132
+ return tnfrsf10b_response
133
+
134
+ _with(
135
+ {
136
+ "HGNC_fetch_gene_by_symbol": _hgnc,
137
+ "OpenTargets_get_associated_drugs_by_target_ensemblID": ot,
138
+ "ChEMBL_get_drug_mechanisms": {"status": "success", "data": {"mechanisms": []}},
139
+ },
140
+ memory_cache,
141
+ clock,
142
+ )
143
+ try:
144
+ payload = drugs_for_targets("KRAS,TNFRSF10B")
145
+ finally:
146
+ _common.set_client(None)
147
+ assert payload["status"] == "ok"
148
+ assert {r["target"] for r in payload["rows"]} == {"TNFRSF10B"}
149
+ assert payload["source_errors"] == [
150
+ {
151
+ "source": "opentargets",
152
+ "reason": "source_unavailable",
153
+ "detail": "OpenTargets_get_associated_drugs_by_target_ensemblID: TimeoutError",
154
+ }
155
+ ]
156
+ assert "Source problems: opentargets source_unavailable" in payload["narrative"]
157
+
158
+
159
+ def test_when_the_drug_source_fails_for_every_target_it_is_an_error(memory_cache, clock):
160
+ from src.endpoints import drugs_for_targets
161
+
162
+ _with(
163
+ {
164
+ "HGNC_fetch_gene_by_symbol": HGNC_KRAS,
165
+ "OpenTargets_get_associated_drugs_by_target_ensemblID": TimeoutError("read timed out"),
166
+ },
167
+ memory_cache,
168
+ clock,
169
+ )
170
+ try:
171
+ payload = drugs_for_targets("KRAS")
172
+ finally:
173
+ _common.set_client(None)
174
+ assert payload["status"] == "error" and payload["reason"] == "all_sources_failed"
175
+ assert payload["source_errors"][0]["source"] == "opentargets"
176
+
177
+
178
+ def test_when_nothing_answers_the_payload_is_an_error(memory_cache, clock):
179
+ from src.endpoints import annotate_genes
180
+
181
+ _with({"HGNC_fetch_gene_by_symbol": ConnectionError("reset")}, memory_cache, clock)
182
+ try:
183
+ payload = annotate_genes("KRAS,TP53")
184
+ finally:
185
+ _common.set_client(None)
186
+ assert payload["status"] == "error"
187
+ assert payload["reason"] == "all_sources_failed"
188
+ assert {e["source"] for e in payload["source_errors"]} == {"hgnc"}
189
+
190
+
191
+ def test_a_drifted_response_builds_no_rows(memory_cache, clock):
192
+ from src.endpoints import drugs_for_targets
193
+
194
+ drifted = {
195
+ "status": "success",
196
+ "data": {"target": {"id": "ENSG00000133703", "approvedSymbol": "KRAS"}},
197
+ }
198
+ _with(
199
+ {
200
+ "HGNC_fetch_gene_by_symbol": HGNC_KRAS,
201
+ "OpenTargets_get_associated_drugs_by_target_ensemblID": drifted,
202
+ },
203
+ memory_cache,
204
+ clock,
205
+ )
206
+ try:
207
+ payload = drugs_for_targets("KRAS")
208
+ finally:
209
+ _common.set_client(None)
210
+ assert payload["status"] == "error"
211
+ assert "rows" not in payload
212
+ assert payload["source_errors"][0]["reason"] == "upstream_schema_drift"
213
+
214
+
215
+ def test_source_errors_carry_only_the_three_reasons():
216
+ from src.payloads import SOURCE_ERROR_REASONS, source_error
217
+
218
+ assert SOURCE_ERROR_REASONS == ("source_unavailable", "rate_limited", "upstream_schema_drift")
219
+ with pytest.raises(ValueError):
220
+ source_error("opentargets", "made_up", "z")