Fix Round 40: tools that silently dropped the clinical content they were asked for (#514)

* Fix Round 40: DailyMed deleted the division from antidote dose equations

SPL has no fraction element, so a dosing equation is drawn as a stacked
fraction: an underlined <content> numerator on its own line, a <br/>, then
the denominator, with the underline acting as the division bar. Every text
path in dailymed_tool flattened <br/> to a space or to nothing, which
deleted the division outright.

DigiFab (digoxin immune fab, setid c05ee6a5-c98b-45f4-83fd-40781639d653)
encodes its dosing equations this way, so DailyMed_parse_dosing returned

    Dose (in vials) = (Serum digoxin ng/mL)(weight in kg) 100

which reads as a multiplication by 100 rather than a division by it -- a
10,000-fold error in an antidote dose, at the bedside, with nothing in the
response indicating anything had been dropped.

The <br/>-aware walker previously served table cells only. Fixing just that
path left the same label rendering the same equation two different ways in
one response, since section 2.1 repeats the equations in <paragraph>s. <br/>
is a property of SPL markup, not of tables, so _cell_text becomes _flow_text
and every flatten site (table cell, paragraph, list item, contraindications)
goes through it.

The bar is recognised only on the exact stacked-fraction shape: an underlined
<content> that follows an "=" and is followed by a <br/>. Requiring the "="
keeps an underlined heading before a line break -- this same label has two,
"Risk Summary" in Pregnancy and Lactation -- from becoming a division. That
false positive is pinned by its own test.

* Fix Round 40: FDA_get_drug_label returned no warnings for pre-PLR labels

_extract_label mapped its warnings_and_precautions response key from the PLR
section name only. openFDA carries a label in whichever format it was
submitted in and the two formats share no section names, so for every pre-PLR
label the key came back null -- next to truncated: false, an explicit
"nothing was cut".

Partitioning the live drug/label corpus of 261,646 records:

    has warnings_and_cautions (PLR)                      46,930
    has warnings only, no warnings_and_cautions         204,636
    has neither                                          10,080

So 204,636 of the 251,566 labels that carry a warnings section at all -- 81%
of them -- could not return it. This follows the label's vintage, not the
drug: flumazenil is pre-PLR while naloxone and pralidoxime are PLR.

Flumazenil is the benzodiazepine antidote and its WARNINGS section ("Risk of
Seizures ... not recommended in cases of serious cyclic antidepressant
poisoning") is the reason the drug is dangerous. It was retrievable by field
query the whole time; this extraction just never asked for it.

warnings_and_precautions falls back to the pre-PLR warnings section, and the
legacy PRECAUTIONS section is returned under its own name rather than folded
into the PLR keys, so provenance stays exact. general_precautions is the same
section under an alternate name on 25,729 labels, 725 of which have no
precautions at all.

search=_exists_:warnings_and_precautions returns NOT_FOUND -- that name is the
printed heading, never an openFDA field -- so the original first lookup was
dead code and is gone.

The sibling openfda_tool family deliberately keeps the opposite policy
(annotate, never substitute) because there the response keys ARE openFDA field
names; here the key is a format-neutral printed heading, so mapping either
source section onto it is correct. Both are noted where they differ.

* Fix Round 40: DrugSafetyAnalyzer required the filter it documented as optional

patient_sex was listed in `required` while its own description ended
"(optional)" and the composition function already handled its absence
(`arguments.get("patient_sex")`, then `if patient_sex:` before forwarding it
to FAERS). So the schema rejected the call the description invited:

    $ python -m tooluniverse.cli run DrugSafetyAnalyzer \
        '{"drug_name":"dexmedetomidine"}'
    Error: Parameter validation failed for 'root':
           'patient_sex' is a required property

There is no sex-unstratified drug safety review available while that holds,
and filtering by sex roughly halves the FAERS denominator.

An earlier sweep took serious_events_only out of `required` here, and
sample_type out of BiomarkerDiscoveryWorkflow's, on exactly this reasoning --
patient_sex was read the same way but left in. The guard that pinned the old
value is updated with the evidence rather than deleted.

The description also now states the denominator effect and that the enum is
Male/Female rather than the FAERS-native numeric encoding, which was the
first thing callers tried.

* Fix Round 40: europepmc_disease_target_score documented pageSize as a result limit

The parameter was described as "Number of results per page (default: 100,
max: 100)", but it is the upstream fetch batch size: the tool pages through
every one of the disease's associated targets regardless of it, bounded only
by a 25-second scan budget. Measured live, pageSize: 10 on MONDO_0005011
(Crohn disease) still returns 390 scored targets.

Behaviour is unchanged; the description now says what the tool actually does
and what the parameter actually controls, and the tool description states
that the result is exhaustive-and-time-bounded rather than paged.

* Fix Round 40: docs and examples called tools that do not exist

An earlier round removed BioRxiv_search_preprints and MedRxiv_search_preprints
(the bioRxiv/medRxiv APIs have no keyword-search endpoint at all) and cleaned
up the tool configs, but the runnable examples that told users to call them
were left behind:

    $ python -m tooluniverse.cli run BioRxiv_search_preprints '{"query":"x"}'
    Error: Tool 'BioRxiv_search_preprints' not found even after loading tools

    $ python -c "from tooluniverse.tools import BioRxiv_search_preprints"
    ImportError: cannot import name 'BioRxiv_search_preprints'

The second is not a stale doc, it is a shipped example that cannot be
imported. A sweep for the whole class found it was not two names but many:
docs/guide/tools.rst alone carried 95 non-existent tool names against 14 real
ones, and examples/oncogenomics/icgc called ICGCARGO_query, a tool that has
never existed in any config.

Every replacement name was checked with `cli info`, and most were executed
with the snippet's own arguments so the arguments are valid too, not just the
name. Where a snippet was obsolete with no modern equivalent it is deleted
rather than patched; where a name was a deliberate placeholder it is now
written as one (`<tool_name>`) so it cannot be mistaken for a real call.

test_documented_tool_names_exist guards the class, as the companion to
test_compose_tool_dependencies: that module checks names a config declares as
a dependency, this one checks names a human-facing runnable example tells a
user to call. Both failures are a name that resolves to nothing; the config
sweep cannot see this one because docs and examples are not configs.

Generated trees (docs/tools/, docs/locale/, src/tooluniverse/tools/) are
excluded as sources to fix -- a stale name there means the generator has not
been re-run, and a hand-edit would be reverted -- though tools/__init__'s
__all__ is used as ground truth for the import check.

* Fix Round 40: match the generated wrapper's docstring to the generator

The hand-edited DrugSafetyAnalyzer wrapper labelled the two now-optional
parameters `Optional[str]` / `Optional[bool]` in its numpydoc block. The
generator wraps `Optional[...]` around the SIGNATURE type only; the docstring
label is the raw JSON type (generate_tools.py:263 emits `py_type` unchanged,
and neither property is a nullable `["x","null"]` type). DrugSafetyAnalyzer.py
was the only one of 2738 files in src/tooluniverse/tools/ with that pattern,
which is what gave it away. Left as-is it would have silently reverted on the
next SDK regeneration.

The signature itself was already correct and is unchanged.

Also applies ruff format to the new sweep test, which the pre-commit
ruff-format hook would otherwise rewrite.
This commit is contained in:
Shanghua Gao
2026-08-17 17:15:06 -07:00
committed by GitHub
parent 9c6b9062e1
commit e2d9673fdd
23 changed files with 907 additions and 356 deletions
@@ -52,7 +52,7 @@ The algorithm intelligently truncates tool names by:
- Short words (≤3 chars) kept intact: ``by``, ``get``, ``on``, ``or``, ``for``
- Medium words (4-6 chars): first 4 chars: ``drug````drug``
- Long words (>6 chars): first 4 chars: ``consultation````cons``
- Long words (>6 chars): first 4 chars: ``conditions````cond``
4. **Handling collisions**: Appends numeric suffix if shortened names clash
@@ -67,14 +67,14 @@ Examples
- Length
- Shortened Name
- Length
* - ``FDA_get_info_on_conditions_for_doctor_consultation_by_drug_name``
- 63
- ``FDA_get_info_on_cond_for_doct_cons_by_drug_name``
- 47
* - ``euhealthinfo_search_diabetes_mellitus_epidemiology_registry``
- 59
- ``euhealthinfo_sear_diab_mell_epid_regi``
- 38
* - ``FDA_get_conditions_info_for_doctor_consult_by_drug_name``
- 55
- ``FDA_get_cond_info_for_doct_cons_by_drug_name``
- 44
* - ``euhealthinfo_search_alcohol_tobacco_psychoactive_use``
- 52
- ``euhealthinfo_sear_alco_toba_psyc_use``
- 36
* - ``UniProt_get_function_by_accession``
- 34
- ``UniProt_get_function_by_accession``
@@ -172,8 +172,8 @@ Direct Python API Usage (Default)
# DON'T enable shortening unnecessarily
tu = ToolUniverse() # Default: enable_name_shortening=False
tu.run_one_function({
"name": "FDA_get_drug_info_by_name",
"arguments": {...}
"name": "FDA_get_drug_label",
"arguments": {"drug_name": "warfarin"}
})
Internal Tool Development
@@ -245,10 +245,10 @@ If you're using ToolUniverse directly in Python:
# Name shortening is applied automatically during MCP server exposure
# When enable_name_shortening=True, tool names are shortened to fit MCP limits
long_name = "FDA_get_info_on_conditions_for_doctor_consultation_by_drug_name"
long_name = "FDA_get_conditions_info_for_doctor_consult_by_drug_name"
# Execute tools using their full original name — ToolUniverse handles resolution
tu.run_one_function({"name": long_name, "arguments": {}})
tu.run_one_function({"name": long_name, "arguments": {"drug_name": "aspirin"}})
API Reference
=============
@@ -293,17 +293,25 @@ Execute a tool function. Automatically accepts both shortened and original names
.. code-block:: python
from tooluniverse.tool_name_utils import shorten_tool_name
tu = ToolUniverse(enable_name_shortening=True)
tu.load_tools()
# The original name is the one declared in the tool config; the shortened
# name is derived from it and exists only as an alias.
original_name = "FDA_get_conditions_info_for_doctor_consult_by_drug_name"
shortened_name = shorten_tool_name(original_name)
# -> "FDA_get_cond_info_for_doct_cons_by_drug_name"
# Both names work identically (transparent resolution):
result1 = tu.run_one_function({
"name": "FDA_get_info_on_conditions_for_doctor_consultation_by_drug_name",
"name": original_name,
"arguments": {"drug_name": "aspirin"}
})
result2 = tu.run_one_function({
"name": "FDA_get_info_on_cond_for_doct_cons_by_drug_name",
"name": shortened_name,
"arguments": {"drug_name": "aspirin"}
})
@@ -320,7 +328,7 @@ When you use ToolUniverse with MCP:
1. **User Configuration**: Set server key as ``"tu"`` in MCP config
2. **SMCP Startup**: Automatically enables shortening
3. **Tool Exposure**: Each tool name shortened and cached
4. **MCP Registration**: Tools registered with shortened names (e.g., ``mcp__tu__FDA_get_info_on_cond_for_doct_cons_by_drug_name``)
4. **MCP Registration**: Tools registered with shortened names (e.g., ``mcp__tu__FDA_get_cond_info_for_doct_cons_by_drug_name``)
5. **User Calls Tool**: MCP client sends full MCP name
6. **FastMCP Processing**: Strips prefix, passes shortened name
7. **Transparent Resolution**: ToolUniverse resolves shortened → original
+2 -2
View File
@@ -183,8 +183,8 @@ Examples
tu.load_tools(['europepmc'])
result = tu.run({
"name": "EuropePMC_search_publications",
"arguments": {"query": "machine learning drug discovery"}
"name": "EuropePMC_search_articles",
"arguments": {"query": "machine learning drug discovery", "limit": 25}
})
# Process with external analysis tool
+5 -5
View File
@@ -134,10 +134,10 @@ Examples
tu.load_tools(['europepmc'])
result = tu.run({
"name": "EuropePMC_search_publications",
"name": "EuropePMC_search_articles",
"arguments": {
"query": "CRISPR gene editing therapeutic applications",
"resultType": "core"
"limit": 25
}
})
@@ -190,7 +190,7 @@ Examples
# Configure for compound analysis
compound_config = {
'tool_specific_hooks': {
'ChEMBL_search_compounds': {
'ChEMBL_search_molecules': {
'enabled': True,
'hooks': [{
'name': 'compound_summarization',
@@ -215,9 +215,9 @@ Examples
# Execute compound search
result = tu.run({
"name": "ChEMBL_search_compounds",
"name": "ChEMBL_search_molecules",
"arguments": {
"compound_name": "aspirin",
"query": "aspirin",
"limit": 100
}
})
+10 -10
View File
@@ -78,10 +78,10 @@ All tool interactions follow a uniform request format:
.. code-block:: python
{
"name": "Tool_identifier",
"name": "<tool_name>",
"arguments": {
"parameter1": "value1",
"parameter2": "value2"
"<parameter1>": "<value1>",
"<parameter2>": "<value2>"
}
}
@@ -164,7 +164,7 @@ Find Tool Operation
# Protocol returns relevant tools:
tools_found = [
"boltz2_docking",
"ADMETAI_predict_properties",
"ADMETAI_predict_physicochemical_properties",
"ChEMBL_search_similar_molecules"
]
@@ -225,8 +225,8 @@ Machine Learning Models
# ADMET property prediction
{
"name": "ADMETAI_predict_admet_properties",
"arguments": {"smiles": "CCO", "properties": ["BBB_penetrance"]}
"name": "ADMETAI_predict_BBB_penetrance",
"arguments": {"smiles": ["CCO"]}
}
Database APIs
@@ -259,8 +259,8 @@ Scientific Software Packages
# Analysis packages
{
"name": "Enrichr_analyze_gene_list",
"arguments": {"genes": ["BRCA1", "BRCA2"], "library": "KEGG_2021_Human"}
"name": "Enrichr_enrich",
"arguments": {"gene_list": ["BRCA1", "BRCA2"], "library": "KEGG_2021_Human"}
}
AI Agents & Tools
@@ -270,8 +270,8 @@ AI Agents & Tools
# Literature review agent
{
"name": "conduct_literature_review_and_summarize",
"arguments": {"topic": "HMG-CoA reductase inhibitors"}
"name": "LiteratureSearchTool",
"arguments": {"research_topic": "HMG-CoA reductase inhibitors"}
}
# Hypothesis generation
+29 -29
View File
@@ -72,14 +72,14 @@ Tool Overview Table
- DOAJ
- Open Access
- Articles & Journals, HTML cleaning
* - ``BioRxiv_search_preprints``
* - ``BioRxiv_get_preprint``
- BioRxiv
- Biology Preprints
- Biology preprints, Abstracts
* - ``MedRxiv_search_preprints``
- Retrieval by DOI, Full metadata
* - ``MedRxiv_get_preprint``
- MedRxiv
- Medical Preprints
- Medical preprints, Abstracts
- Retrieval by DOI, Full metadata
* - ``HAL_search_archive``
- HAL
- French Research Archive
@@ -143,8 +143,8 @@ First, let's initialize ToolUniverse and load the literature search tools:
"PubMed_search_articles",
"DOAJ_search_articles",
"Unpaywall_check_oa_status",
"BioRxiv_search_preprints",
"MedRxiv_search_preprints",
"BioRxiv_get_preprint",
"MedRxiv_get_preprint",
"HAL_search_archive",
"SemanticScholar_search_papers",
"openalex_literature_search",
@@ -331,22 +331,28 @@ Search for preprints in specific fields:
.. code-block:: python
# Biology preprints
biorxiv_results = tu.run({
"name": "BioRxiv_search_preprints",
# The bioRxiv/medRxiv APIs offer DOI and date-based retrieval only -- they
# have no keyword search endpoint. Search for preprints through Europe PMC,
# which indexes both, then fetch full metadata by DOI.
preprints = tu.run({
"name": "EuropePMC_search_articles",
"arguments": {
"query": "CRISPR gene editing",
"max_results": 2
"source": "PPR",
"pageSize": 2
}
})
# Medical preprints
medrxiv_results = tu.run({
"name": "MedRxiv_search_preprints",
"arguments": {
"query": "COVID-19 treatment",
"max_results": 2
}
# Biology preprint metadata, by DOI
biorxiv_result = tu.run({
"name": "BioRxiv_get_preprint",
"arguments": {"doi": "10.1101/2020.09.09.289769"}
})
# Medical preprint metadata, by DOI
medrxiv_result = tu.run({
"name": "MedRxiv_get_preprint",
"arguments": {"doi": "10.1101/2020.03.24.20042937"}
})
# French research archive
@@ -796,22 +802,16 @@ Preprint Archives:
.. code-block:: python
# BioRxiv (Biology)
# BioRxiv (Biology) -- retrieval is by DOI; search via Europe PMC
result = tu.run({
"name": "BioRxiv_search_preprints",
"arguments": {
"query": "CRISPR",
"max_results": 5
}
"name": "BioRxiv_get_preprint",
"arguments": {"doi": "10.1101/2020.09.09.289769"}
})
# MedRxiv (Medical)
# MedRxiv (Medical) -- retrieval is by DOI; search via Europe PMC
result = tu.run({
"name": "MedRxiv_search_preprints",
"arguments": {
"query": "COVID-19",
"max_results": 5
}
"name": "MedRxiv_get_preprint",
"arguments": {"doi": "10.1101/2020.03.24.20042937"}
})
# HAL (French Archive)
+9 -9
View File
@@ -145,7 +145,7 @@ ERROR Level
.. code-block:: text
❌ ERROR: Failed to execute tool 'PubChem_search': Invalid API key
❌ ERROR: Failed to execute tool 'PubChem_get_CID_by_compound_name': Invalid API key
❌ ERROR: OpenTargets query timeout after 30 seconds
CRITICAL Level
@@ -244,7 +244,7 @@ Change log level during execution:
# Enable verbose logging for debugging
set_log_level('DEBUG')
result = tu.run({"name": "PubChem_search", "arguments": {"query": "aspirin"}})
result = tu.run({"name": "PubChem_get_CID_by_compound_name", "arguments": {"name": "aspirin"}})
# Return to normal logging
set_log_level('INFO')
@@ -314,8 +314,8 @@ Research Workflow Logging
setup_logging('INFO')
logger = get_logger('drug_discovery')
def drug_discovery_workflow(target_disease):
logger.info(f"🎯 Starting drug discovery for: {target_disease}")
def drug_discovery_workflow(disease_efo_id):
logger.info(f"🎯 Starting drug discovery for: {disease_efo_id}")
tu = ToolUniverse()
tu.load_tools()
@@ -323,8 +323,8 @@ Research Workflow Logging
# Step 1: Find disease targets
logger.progress("Step 1: Identifying disease targets")
targets_query = {
"name": "OpenTargets_get_associated_targets_by_disease_name",
"arguments": {"diseaseName": target_disease, "limit": 10}
"name": "OpenTargets_get_associated_targets_by_disease_efoId",
"arguments": {"efoId": disease_efo_id, "size": 10}
}
try:
@@ -357,7 +357,7 @@ Debugging Failed Tools
# Debug a failing query
problematic_query = {
"name": "PubChem_get_compound_info",
"name": "PubChem_get_CID_by_compound_name",
"arguments": {"compound_name": "invalid_compound_name"}
}
@@ -389,7 +389,7 @@ Batch Processing with Progress Tracking
logger.progress(f"Processing {i+1}/{len(compounds)}: {compound}")
query = {
"name": "PubChem_get_compound_info",
"name": "PubChem_get_CID_by_compound_name",
"arguments": {"compound_name": compound}
}
@@ -526,7 +526,7 @@ Here's what different log levels look like in practice:
.. code-block:: text
🔍 DEBUG: Tool files loaded from: /path/to/tools/
🔍 DEBUG: Validating parameters for PubChem_search
🔍 DEBUG: Validating parameters for PubChem_get_CID_by_compound_name
INFO: Loading 245 tools from 12 categories
📈 PROGRESS: Processing compound 15/100: caffeine
⚠️ WARNING: API rate limit reached, waiting 2 seconds
+133 -176
View File
@@ -112,8 +112,8 @@ Access comprehensive protein and gene information.
**Key Functions:**
* ``UniProt_get_function_by_accession`` - Get functional annotations by UniProt accession
* ``UniProt_search_proteins`` - Search proteins by keywords
* ``UniProt_get_protein_sequence`` - Retrieve protein sequences
* ``UniProt_search`` - Search UniProtKB with field queries
* ``UniProt_get_sequence_by_accession`` - Retrieve protein sequences
**Example:**
@@ -131,17 +131,17 @@ Gene Ontology - Functional Annotation
Gene Ontology annotations and functional analysis.
**Key Functions:**
* ``GeneOntology_get_annotations`` - Get GO annotations for genes
* ``GeneOntology_search_terms`` - Search GO terms
* ``GeneOntology_get_enrichment`` - Functional enrichment analysis
* ``GO_get_annotations_for_gene`` - Get GO annotations for a gene
* ``GO_search_terms`` - Search GO terms
* ``GO_get_genes_for_term`` - Get genes annotated to a GO term
**Example:**
.. code-block:: python
query = {
"name": "GeneOntology_get_annotations",
"arguments": {"gene_symbols": ["BRCA1", "BRCA2", "TP53"]}
"name": "GO_get_annotations_for_gene",
"arguments": {"gene_id": "TP53"}
}
Enrichr - Gene Set Analysis
@@ -150,18 +150,18 @@ Enrichr - Gene Set Analysis
Comprehensive gene set enrichment analysis.
**Key Functions:**
* ``Enrichr_analyze_gene_list`` - Enrichment analysis for gene lists
* ``Enrichr_get_libraries`` - List available gene set libraries
* ``Enrichr_download_results`` - Download enrichment results
* ``Enrichr_enrich`` - Enrichment analysis for a gene list against one library
* ``Enrichr_list_libraries`` - List available gene set libraries
* ``Enrichr_get_top_enriched`` - Top enriched terms across several libraries
**Example:**
.. code-block:: python
query = {
"name": "Enrichr_analyze_gene_list",
"name": "Enrichr_enrich",
"arguments": {
"genes": ["BRCA1", "BRCA2", "TP53", "ATM", "CHEK2"],
"gene_list": ["BRCA1", "BRCA2", "TP53", "ATM", "CHEK2"],
"library": "KEGG_2021_Human"
}
}
@@ -176,10 +176,10 @@ Comprehensive disease-target association data.
**Key Functions:**
* ``OpenTargets_get_associated_targets_by_disease_efoId`` - Disease-associated targets
* ``OpenTargets_get_associated_diseases_by_target`` - Target-associated diseases
* ``OpenTargets_get_associated_diseases_by_drug_chemblId`` - Drug-associated diseases
* ``OpenTargets_get_disease_id_description_by_name`` - Disease lookup
* ``OpenTargets_get_evidence`` - Evidence for associations
* ``OpenTargets_get_drug_info`` - Drug information and mechanisms
* ``OpenTargets_get_evidence_by_datasource`` - Evidence for associations
* ``OpenTargets_get_drug_mechanisms_of_action_by_chemblId`` - Drug mechanisms of action
**Example:**
@@ -197,17 +197,17 @@ EFO - Experimental Factor Ontology
Disease and experimental factor ontology.
**Key Functions:**
* ``EFO_search_diseases`` - Search diseases by name
* ``EFO_get_disease_hierarchy`` - Get disease relationships
* ``EFO_get_synonyms`` - Get disease synonyms
* ``ols_search_efo_terms`` - Search EFO terms by name
* ``OSL_get_efo_id_by_disease_name`` - Resolve a disease name to an EFO ID
* ``ols_get_efo_term_children`` - Get child terms of an EFO term
**Example:**
.. code-block:: python
query = {
"name": "EFO_search_diseases",
"arguments": {"query": "diabetes"}
"name": "ols_search_efo_terms",
"arguments": {"query": "diabetes mellitus", "rows": 5}
}
Drug & Chemical Data
@@ -219,18 +219,18 @@ PubChem - Chemical Information
Comprehensive chemical compound database.
**Key Functions:**
* ``PubChem_get_compound_info`` - Get compound information by name/ID
* ``PubChem_search_compounds`` - Search compounds by structure/properties
* ``PubChem_get_compound_properties`` - Molecular properties
* ``PubChem_similarity_search`` - Chemical similarity search
* ``PubChem_get_CID_by_compound_name`` - Look up compound IDs by name
* ``PubChem_search_compounds_by_substructure`` - Search compounds by substructure
* ``PubChem_get_compound_properties_by_CID`` - Molecular properties
* ``PubChem_search_compounds_by_similarity`` - Chemical similarity search
**Example:**
.. code-block:: python
query = {
"name": "PubChem_get_compound_info",
"arguments": {"compound_name": "aspirin"}
"name": "PubChem_get_CID_by_compound_name",
"arguments": {"name": "aspirin"}
}
ChEMBL - Bioactivity Data
@@ -239,18 +239,19 @@ ChEMBL - Bioactivity Data
Chemical bioactivity and drug discovery data.
**Key Functions:**
* ``ChEMBL_get_compound_targets`` - Get targets for compounds
* ``ChEMBL_get_compounds_by_target`` - Get compounds targeting proteins
* ``ChEMBL_get_bioactivity_data`` - Bioactivity measurements
* ``ChEMBL_search_similar_compounds`` - Chemical similarity search
* ``ChEMBL_get_molecule_targets`` - Get targets for a molecule
* ``ChEMBL_search_targets`` - Find target ChEMBL IDs by name or gene symbol
* ``ChEMBL_get_target_activities`` - Bioactivity measurements for a target
* ``ChEMBL_search_similar_molecules`` - Chemical similarity search
**Example:**
.. code-block:: python
# EGFR is CHEMBL203; use ChEMBL_search_targets to look an ID up by name
query = {
"name": "ChEMBL_get_compounds_by_target",
"arguments": {"target_symbol": "EGFR"}
"name": "ChEMBL_get_target_activities",
"arguments": {"target_chembl_id": "CHEMBL203", "limit": 20}
}
Drug Safety & Regulatory
@@ -263,9 +264,9 @@ FDA drug labeling and adverse event data.
**Key Functions:**
* ``FAERS_count_reactions_by_drug_event`` - Count adverse reactions by drug
* ``openfda_get_warnings_by_drug_name`` - Get FDA warnings
* ``OpenFDA_get_drug_labels`` - Drug labeling information
* ``OpenFDA_search_recalls`` - Drug recall information
* ``FDA_get_warnings_by_drug_name`` - Get FDA warnings
* ``OpenFDA_search_drug_labels`` - Drug labeling information
* ``OpenFDA_search_drug_enforcement`` - Drug recall and enforcement information
**Example:**
@@ -279,8 +280,8 @@ FDA drug labeling and adverse event data.
# Get FDA warnings
query = {
"name": "openfda_get_warnings_by_drug_name",
"arguments": {"medicinalproduct": "warfarin"}
"name": "FDA_get_warnings_by_drug_name",
"arguments": {"drug_name": "warfarin"}
}
DailyMed - Drug Labeling
@@ -289,17 +290,17 @@ DailyMed - Drug Labeling
Official FDA drug labeling information.
**Key Functions:**
* ``DailyMed_get_drug_label`` - Get official drug labels
* ``DailyMed_search_drugs`` - Search drugs by name
* ``DailyMed_get_NDC_info`` - NDC (drug code) information
* ``DailyMed_search_spls`` - Search structured product labels by drug name, NDC or RxCUI
* ``DailyMed_get_spl_by_setid`` - Get a full label by its set ID
* ``DailyMed_parse_dosing`` - Extract the dosing section from a label
**Example:**
.. code-block:: python
query = {
"name": "DailyMed_get_drug_label",
"arguments": {"medicinalproduct": "metformin"}
"name": "DailyMed_search_spls",
"arguments": {"drug_name": "metformin"}
}
Clinical Research
@@ -312,9 +313,9 @@ Clinical trial registry and results database.
**Key Functions:**
* ``ClinicalTrials_search_studies`` - Search clinical trials
* ``ClinicalTrials_get_study_details`` - Get detailed study information
* ``ClinicalTrials_get_trial_results`` - Get trial results
* ``ClinicalTrials_search_by_condition`` - Find trials by medical condition
* ``ClinicalTrials_get_study`` - Get detailed study information by NCT ID
* ``ClinicalTrials_search_by_intervention`` - Find trials by intervention
* ``ClinicalTrials_search_by_sponsor`` - Find trials by lead sponsor
**Example:**
@@ -323,8 +324,8 @@ Clinical trial registry and results database.
query = {
"name": "ClinicalTrials_search_studies",
"arguments": {
"condition": "breast cancer",
"intervention": "immunotherapy"
"query_cond": "breast cancer",
"query_intr": "immunotherapy"
}
}
@@ -337,18 +338,18 @@ PubTator - Biomedical Literature
PubMed literature with named entity recognition.
**Key Functions:**
* ``PubTator_search_publications`` - Search literature with entities
* ``PubTator_get_annotations`` - Get entity annotations
* ``PubTator_search_by_entity`` - Search by specific entities
* ``PubTator3_LiteratureSearch`` - Search literature with entities
* ``PubTator3_get_annotations`` - Get entity annotations for PMIDs
* ``PubTator3_EntityAutocomplete`` - Resolve a free-text name to a PubTator entity ID
**Example:**
.. code-block:: python
query = {
"name": "PubTator_search_publications",
"name": "PubTator3_LiteratureSearch",
"arguments": {
"query": "@GENE_BRCA1 @DISEASE_cancer"
"query": "@GENE_BRCA1 AND @DISEASE_Neoplasms"
}
}
@@ -378,8 +379,8 @@ AI-powered academic search engine.
**Key Functions:**
* ``SemanticScholar_search_papers`` - Search academic papers
* ``SemanticScholar_get_paper_details`` - Get detailed paper information
* ``SemanticScholar_get_citations`` - Citation network analysis
* ``SemanticScholar_get_paper`` - Get detailed paper information
* ``SemanticScholar_get_paper_citations`` - Citation network analysis
**Example:**
@@ -396,9 +397,9 @@ OpenAlex
Open academic publication database.
**Key Functions:**
* ``OpenAlex_search_works`` - Search academic works
* ``OpenAlex_get_author_info`` - Author information and metrics
* ``OpenAlex_get_institution_data`` - Institution research data
* ``openalex_search_works`` - Search academic works
* ``openalex_get_author`` - Author information and metrics
* ``openalex_get_institution`` - Institution research data
Specialized Databases
------------------------
@@ -409,17 +410,20 @@ Human Protein Atlas
Tissue and cell expression data.
**Key Functions:**
* ``HPA_get_tissue_expression`` - Tissue expression patterns
* ``HPA_get_cell_expression`` - Single-cell expression data
* ``HPA_get_protein_localization`` - Subcellular localization
* ``HPA_get_rna_expression_in_specific_tissues`` - Tissue expression patterns
* ``HPA_get_comparative_expression_by_gene_and_cellline`` - Cell-line expression data
* ``HPA_get_subcellular_location`` - Subcellular localization
**Example:**
.. code-block:: python
query = {
"name": "HPA_get_tissue_expression",
"arguments": {"gene_symbol": "BRCA1"}
"name": "HPA_get_rna_expression_in_specific_tissues",
"arguments": {
"ensembl_id": "ENSG00000012048", # BRCA1
"tissue_names": ["breast", "ovary"]
}
}
Reactome Pathways
@@ -428,17 +432,17 @@ Reactome Pathways
Biological pathway database.
**Key Functions:**
* ``Reactome_get_pathways_by_gene`` - Pathways for genes
* ``Reactome_search_pathways`` - Search pathway database
* ``Reactome_get_pathway_details`` - Detailed pathway information
* ``Reactome_map_uniprot_to_pathways`` - Pathways for a protein
* ``ReactomeContent_search`` - Search pathway database
* ``Reactome_get_pathway`` - Detailed pathway information
**Example:**
.. code-block:: python
query = {
"name": "Reactome_get_pathways_by_gene",
"arguments": {"gene_symbol": "TP53"}
"name": "Reactome_map_uniprot_to_pathways",
"arguments": {"uniprot_id": "P04637"} # TP53
}
HumanBase
@@ -447,9 +451,7 @@ HumanBase
Tissue-specific gene networks.
**Key Functions:**
* ``HumanBase_get_gene_networks`` - Tissue-specific networks
* ``HumanBase_predict_gene_function`` - Gene function prediction
* ``HumanBase_get_tissue_expression`` - Tissue expression patterns
* ``humanbase_ppi_analysis`` - Tissue-specific protein-protein interaction networks
MedlinePlus
~~~~~~~~~~~
@@ -457,9 +459,9 @@ MedlinePlus
Consumer health information.
**Key Functions:**
* ``MedlinePlus_get_health_topics`` - Health topic information
* ``MedlinePlus_search_conditions`` - Search medical conditions
* ``MedlinePlus_get_drug_info`` - Consumer drug information
* ``MedlinePlus_search_topics_by_keyword`` - Health topic information
* ``MedlinePlus_get_genetics_condition_by_name`` - Genetic condition information
* ``MedlinePlus_connect_lookup_by_code`` - Look up consumer information by drug or test code
AI-Powered Tools
--------------------
@@ -478,37 +480,29 @@ Apply machine learning algorithms for prediction, classification, and generation
{
"name": "boltz2_docking",
"arguments": {
"protein_structure": "1ABC",
"ligand_smiles": "CCO"
"sequence": "MVLSPADKTNVKAAW",
"ligands": ["CCO"],
"recycling_steps": 3,
"sampling_steps": 200,
"diffusion_samples": 1,
"step_scale": 1.638,
"use_potentials": False,
"return_structure": True
}
}
# Returns: binding_affinity, binding_probability, confidence_score
# Returns: binding affinity and probability, plus the predicted structure
**ADMET_predict_CYP_interactions** - Drug metabolism prediction
**ADMETAI_predict_CYP_interactions** - Drug metabolism prediction
.. code-block:: python
{
"name": "ADMET_predict_CYP_interactions",
"name": "ADMETAI_predict_CYP_interactions",
"arguments": {
"smiles": "CC(=O)OC1=CC=CC=C1C(=O)O", # Aspirin
"cyp_enzymes": ["CYP3A4", "CYP2D6"]
"smiles": ["CC(=O)OC1=CC=CC=C1C(=O)O"] # Aspirin
}
}
# Returns: interaction_probabilities, metabolic_stability
**run_TxAgent_biomedical_reasoning** - Therapeutic reasoning
.. code-block:: python
{
"name": "run_TxAgent_biomedical_reasoning",
"arguments": {
"query": "What are the therapeutic targets for Alzheimer's disease?",
"context": "precision_medicine"
}
}
# Returns: therapeutic_insights, target_recommendations
# Returns: per-CYP-isoform inhibition probabilities
AI Agents (33 tools)
~~~~~~~~~~~~~~~~~~~~
@@ -524,12 +518,12 @@ Autonomous tools that perceive environments, make decisions, and take actions to
{
"name": "HypothesisGenerator",
"arguments": {
"research_area": "cancer immunotherapy",
"constraints": ["FDA-approved targets", "known biomarkers"],
"num_hypotheses": 5
"context": "Checkpoint inhibitors work in only a subset of solid tumours.",
"domain": "cancer immunotherapy",
"number_of_hypotheses": 5
}
}
# Returns: ranked_hypotheses, supporting_evidence, testable_predictions
# Returns: ranked hypotheses with supporting rationale
**ExperimentalDesignScorer** - Evaluate experimental designs
@@ -538,11 +532,11 @@ Autonomous tools that perceive environments, make decisions, and take actions to
{
"name": "ExperimentalDesignScorer",
"arguments": {
"experiment_description": "Phase II trial for EGFR inhibitor",
"evaluation_criteria": ["feasibility", "statistical_power", "ethics"]
"hypothesis": "EGFR inhibition slows progression in EGFR-mutant NSCLC.",
"design_description": "Phase II single-arm trial, 80 patients, 12-month PFS endpoint"
}
}
# Returns: design_score, improvement_suggestions, risk_assessment
# Returns: design score with improvement suggestions
**MedicalLiteratureReviewer** - Comprehensive literature analysis
@@ -551,33 +545,15 @@ Autonomous tools that perceive environments, make decisions, and take actions to
{
"name": "MedicalLiteratureReviewer",
"arguments": {
"topic": "CAR-T cell therapy safety profile",
"databases": ["PubMed", "ClinicalTrials.gov"],
"time_range": "2020-2024"
}
}
# Returns: comprehensive_review, key_findings, research_gaps
Tool Discovery & Composition
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
AI tools for discovering and combining other tools.
**Key Functions:**
* ``discover_tools_by_description`` - Find tools by natural language
* ``compose_tools_for_workflow`` - Create tool workflows
* ``optimize_tool_descriptions`` - Improve tool descriptions
**Example:**
.. code-block:: python
query = {
"name": "discover_tools_by_description",
"arguments": {
"description": "I need to find genes associated with heart disease"
"research_topic": "CAR-T cell therapy safety profile",
"literature_content": "<abstracts or full text to synthesise>",
"focus_area": "safety profile",
"study_types": "randomized controlled trials",
"quality_level": "moderate and above",
"review_scope": "rapid review"
}
}
# Returns: synthesised review with key findings and research gaps
Search & Integration Tools
-----------------------------
@@ -588,39 +564,34 @@ Tool Finder
Find appropriate tools for your research needs.
**Key Functions:**
* ``find_tools_by_keyword`` - Keyword-based tool search
* ``find_tools_by_category`` - Browse tools by category
* ``get_tool_recommendations`` - Get tool recommendations
* ``Tool_Finder_Keyword`` - Keyword-based tool search
* ``Tool_Finder_LLM`` - LLM-reasoned tool search
* ``Tool_RAG`` - Embedding-based semantic tool search
**Example:**
.. code-block:: python
query = {
"name": "find_tools_by_keyword",
"arguments": {"keywords": ["drug", "safety", "adverse"]}
"name": "Tool_Finder_Keyword",
"arguments": {"description": "drug safety and adverse events", "limit": 10}
}
Embedding Stores (4 tools)
~~~~~~~~~~~~~~~~~~~~~~~~~~
Embedding Stores
~~~~~~~~~~~~~~~~
Store and retrieve vectorized representations of scientific data for semantic search.
**Core Embedding Tools:**
**embedding_tool_finder** - Semantic tool discovery
**embedding_database_create** - Create a collection to embed into
.. code-block:: python
{
"name": "embedding_tool_finder",
"arguments": {
"query": "predict protein folding dynamics",
"top_k": 10,
"similarity_threshold": 0.7
}
"name": "embedding_database_create",
"arguments": {"database_name": "pubmed_abstracts"}
}
# Returns: relevant_tools, similarity_scores, tool_descriptions
**embedding_database_search** - Vector similarity search
@@ -629,22 +600,12 @@ Store and retrieve vectorized representations of scientific data for semantic se
{
"name": "embedding_database_search",
"arguments": {
"query_vector": embedding_vector,
"database": "pubmed_abstracts",
"database_name": "pubmed_abstracts",
"query": "protein folding dynamics",
"top_k": 50
}
}
# Returns: similar_documents, relevance_scores, metadata
Data Integration
~~~~~~~~~~~~~~~~
Tools for combining data from multiple sources.
**Key Functions:**
* ``integrate_gene_data`` - Combine gene data from multiple sources
* ``cross_reference_identifiers`` - Map between different ID systems
* ``validate_data_consistency`` - Check data consistency
# Returns: similar documents with relevance scores and metadata
Tool Usage Patterns
-----------------------
@@ -689,9 +650,9 @@ Combine multiple tools for comprehensive analysis:
# Step 3: Analyze target pathways
pathway_query = {
"name": "Enrichr_analyze_gene_list",
"name": "Enrichr_enrich",
"arguments": {
"genes": target_list,
"gene_list": target_list,
"library": "KEGG_2021_Human"
}
}
@@ -734,7 +695,7 @@ Combine multiple tools for comprehensive analysis:
# 1. Find disease ID
disease_query = {
"name": "OpenTargets_get_disease_id_description_by_name",
"arguments": {"disease_name": disease_name}
"arguments": {"diseaseName": disease_name}
}
disease_info = tooluni.run(disease_query)
@@ -752,11 +713,7 @@ Combine multiple tools for comprehensive analysis:
target = row['target']
drugs_query = {
"name": "OpenTargets_get_associated_drugs_by_target_ensemblID",
"arguments": {
"target_ensembl_id": target['id'],
"size": 10,
"cursor": ""
}
"arguments": {"ensemblId": target['id']}
}
target_drugs = tooluni.run(drugs_query)
drugs.extend(target_drugs)
@@ -764,7 +721,7 @@ Combine multiple tools for comprehensive analysis:
# 4. Check safety profiles
for drug in drugs[:10]: # Top 10 drugs
safety_query = {
"name": "openfda_get_warnings_by_drug_name",
"name": "FDA_get_warnings_by_drug_name",
"arguments": {"drug_name": drug['name']}
}
safety = tooluni.run(safety_query)
@@ -782,9 +739,9 @@ Tool Composition Patterns
# Disease → Targets → Compounds → Prediction
workflow = [
("OpenTargets_get_associated_targets_by_disease_efoId", {"efoId": disease_id}),
("ChEMBL_search_compounds_by_target", {"target_id": target_result}),
("boltz2_docking", {"protein_id": target, "ligand_smiles": compound}),
("ADMETAI_predict_admet_properties", {"smiles": compound})
("ChEMBL_get_target_activities", {"target_chembl_id": target_chembl_id}),
("ADMETAI_predict_physicochemical_properties", {"smiles": [compound]}),
("ADMETAI_predict_toxicity", {"smiles": [compound]})
]
**Parallel Data Gathering:**
@@ -793,7 +750,7 @@ Tool Composition Patterns
# Multi-database literature search
parallel_searches = [
("PubTator_search_publications", {"query": research_topic}),
("PubTator3_LiteratureSearch", {"query": research_topic}),
("EuropePMC_search_articles", {"query": research_topic}),
("SemanticScholar_search_papers", {"query": research_topic})
]
@@ -901,26 +858,26 @@ Finding the Right Tools
# List tools by type (use get_tool_types() to see available types)
print(tu.get_tool_types()) # e.g. ['opentarget', 'ChEMBL', 'uniprot', ...]
ml_tools = tu.filter_tools(include_tool_types=["ML_tools"])
ml_tools = tu.filter_tools(include_tool_types=["admetai"])
database_tools = tu.filter_tools(include_tool_types=["uniprot", "ChEMBL"])
api_tools = tu.filter_tools(include_tool_types=["EuropePMC", "PubMed"])
api_tools = tu.filter_tools(include_tool_types=["EuropePMC", "pubtator"])
**By Functionality:**
.. code-block:: python
# Semantic search across all categories
# Keyword search across all categories
protein_tools = tu.run({
"name": "find_tools",
"arguments": {"query": "protein structure prediction", "limit": 10}
"name": "Tool_Finder_Keyword",
"arguments": {"description": "protein structure prediction", "limit": 10}
})
drug_tools = tu.run({
"name": "find_tools",
"arguments": {"query": "drug safety analysis", "limit": 10}
"name": "Tool_Finder_Keyword",
"arguments": {"description": "drug safety analysis", "limit": 10}
})
literature_tools = tu.run({
"name": "find_tools",
"arguments": {"query": "literature review automation", "limit": 10}
"name": "Tool_Finder_Keyword",
"arguments": {"description": "literature review automation", "limit": 10}
})
**By Domain:**
+38 -54
View File
@@ -12,72 +12,56 @@ sys.path.append(os.path.join(os.path.dirname(__file__), '..'))
from src.tooluniverse import ToolUniverse
def show_targets(result, limit):
if result and result.get("status") == "success":
data = result.get("data", [])
print(f"Found {len(data)} drug targets")
for i, target in enumerate(data[:limit], 1):
name = target.get("name", "Unknown")
target_id = target.get("targetId", "Unknown")
target_type = target.get("type", "Unknown")
print(f" {i}. {name} ({target_type}) - ID {target_id}")
else:
print(f"Error: {result.get('error')}")
def main():
# Initialize ToolUniverse
tu = ToolUniverse()
# Load tools first
tu.load_tools()
print("💊 GtoPdb Pharmacology Database Examples")
print("GtoPdb Pharmacology Database Examples")
print("=" * 40)
# Example 1: Query drug targets
print("\n1. Querying drug targets")
# Example 1: Search drug targets by name
print("\n1. Searching drug targets by name")
print("-" * 25)
result = tu.run({"name": "GtoPdb_get_targets", "arguments": {
"limit": 5
result = tu.run({"name": "GtoPdb_search_targets", "arguments": {
"name": "dopamine"
}})
if result and result.get("status") == "success":
data = result.get("data", [])
print(f"✅ Found {len(data)} drug targets")
for i, target in enumerate(data[:3], 1):
name = target.get("name", "Unknown")
target_id = target.get("targetId", "Unknown")
print(f" {i}. {name} (ID: {target_id})")
else:
print(f"❌ Error: {result.get('error')}")
# Example 2: Query specific target types
print("\n2. Querying specific target types")
show_targets(result, 3)
# Example 2: Restrict the search to one target type
print("\n2. Restricting the search to one target type")
print("-" * 30)
result = tu.run({"name": "GtoPdb_get_targets", "arguments": {
"limit": 8
result = tu.run({"name": "GtoPdb_search_targets", "arguments": {
"name": "serotonin",
"type": "GPCR"
}})
if result and result.get("status") == "success":
data = result.get("data", [])
print(f"✅ Found {len(data)} drug targets")
# Show different target types
for i, target in enumerate(data[:4], 1):
name = target.get("name", "Unknown")
target_id = target.get("targetId", "Unknown")
target_type = target.get("targetType", "Unknown")
print(f" {i}. {name} ({target_type}) - {target_id}")
else:
print(f"❌ Error: {result.get('error')}")
# Example 3: Query with different limits
print("\n3. Querying with different limits")
show_targets(result, 4)
# Example 3: Exact lookup by HGNC gene symbol
print("\n3. Exact lookup by gene symbol")
print("-" * 30)
result = tu.run({"name": "GtoPdb_get_targets", "arguments": {
"limit": 3
result = tu.run({"name": "GtoPdb_search_targets", "arguments": {
"gene_symbol": "HTR2A"
}})
if result and result.get("status") == "success":
data = result.get("data", [])
print(f"✅ Found {len(data)} drug targets")
for i, target in enumerate(data, 1):
name = target.get("name", "Unknown")
target_id = target.get("targetId", "Unknown")
print(f" {i}. {name} - {target_id}")
show_targets(result, 3)
if __name__ == "__main__":
main()
-1
View File
@@ -1 +0,0 @@
-19
View File
@@ -1,19 +0,0 @@
from tooluniverse import ToolUniverse
def main():
tu = ToolUniverse()
tu.load_tools()
query = "query { _info { apiVersion } }"
res = tu.run_one_function({
"name": "ICGCARGO_query",
"arguments": {"graphql": query, "variables": {}},
})
print("ICGCARGO_query:", res if isinstance(res, dict) else str(res)[:500])
if __name__ == "__main__":
main()
+3 -2
View File
@@ -38,8 +38,9 @@ def main():
("EuropePMC_search_articles", {"query": query, "limit": 2}, "Europe PMC"),
("openalex_literature_search", {"search_keywords": query, "max_results": 2}, "OpenAlex"),
("Crossref_search_works", {"query": query, "limit": 2}, "Crossref"),
("BioRxiv_search_preprints", {"query": query, "max_results": 2}, "BioRxiv"),
("MedRxiv_search_preprints", {"query": query, "max_results": 2}, "MedRxiv"),
# bioRxiv/medRxiv have no keyword-search endpoint -- their APIs are
# DOI/date retrieval only. Europe PMC (above) indexes both; fetch full
# preprint metadata by DOI with BioRxiv_get_preprint / MedRxiv_get_preprint.
]
# Test each tool
+5 -7
View File
@@ -113,8 +113,8 @@ from tooluniverse.tools import (
OpenTargets_get_associated_targets_by_disease_efoId,
ChEMBL_search_similar_molecules,
drugbank_get_drug_name_and_description_by_target_name,
drugbank_get_drug_name_description_pharmacology_by_mechanism_of_action,
drugbank_get_drug_interactions_by_drug_name_or_drugbank_id,
drugbank_get_drug_desc_pharmacology_by_moa,
drugbank_get_drug_interactions_by_drug_name_or_id,
drugbank_get_pharmacology_by_drug_name_or_drugbank_id,
ADMETAI_predict_toxicity,
ADMETAI_predict_bioavailability,
@@ -133,8 +133,6 @@ from tooluniverse.tools import (
SemanticScholar_search_papers,
openalex_literature_search,
ArXiv_search_papers,
BioRxiv_search_preprints,
MedRxiv_search_preprints,
Crossref_search_works,
PubChem_get_CID_by_compound_name,
PubChem_get_compound_properties_by_CID,
@@ -388,7 +386,7 @@ def unified_drug_discovery_workflow(
mechanisms = []
for mechanism in mechanisms:
try:
mech_drugs = drugbank_get_drug_name_description_pharmacology_by_mechanism_of_action(
mech_drugs = drugbank_get_drug_desc_pharmacology_by_moa(
mechanism, case_sensitive=False, exact_match=False, limit=5
)
if mech_drugs and mech_drugs.get("success"):
@@ -679,7 +677,7 @@ def unified_drug_discovery_workflow(
# DrugBank DDI
try:
drugbank_ddi = drugbank_get_drug_interactions_by_drug_name_or_drugbank_id(
drugbank_ddi = drugbank_get_drug_interactions_by_drug_name_or_id(
compound_name,
case_sensitive=False,
exact_match=False,
@@ -961,7 +959,7 @@ def neurodegenrative_drug_discovery(
for mechanism in neuro_mechanisms[:6]: # Use top 6 mechanisms
try:
print(f" Searching for {mechanism} compounds...")
compounds = drugbank_get_drug_name_description_pharmacology_by_mechanism_of_action(
compounds = drugbank_get_drug_desc_pharmacology_by_moa(
query=mechanism,
case_sensitive=False,
exact_match=False,
+79 -13
View File
@@ -473,7 +473,7 @@ class DailyMedSPLParserTool(BaseTool):
# Extract lists
list_items = text_el.xpath(".//hl7:item", namespaces=self.ns)
for item in list_items:
text_content = "".join(item.itertext()).strip()
text_content = self._flow_text(item)
if text_content and len(text_content) > 5:
contraindications.append(
{
@@ -488,7 +488,7 @@ class DailyMedSPLParserTool(BaseTool):
".//hl7:paragraph", namespaces=self.ns
)
for para in paragraphs:
text_content = "".join(para.itertext()).strip()
text_content = self._flow_text(para)
if text_content and len(text_content) > 2:
contraindications.append(
{
@@ -609,22 +609,54 @@ class DailyMedSPLParserTool(BaseTool):
# handles the empty case without a separate guard.
items.extend(self._extract_table_data(child))
elif tag == "paragraph":
text_content = "".join(child.itertext()).strip()
text_content = self._flow_text(child)
if text_content and len(text_content) > min_len:
items.append({"type": text_type, "content": text_content})
elif tag == "list":
for item_el in child.xpath(".//hl7:item", namespaces=self.ns):
text_content = "".join(item_el.itertext()).strip()
text_content = self._flow_text(item_el)
if text_content and len(text_content) > 5:
items.append({"type": text_type, "content": text_content})
return items
def _cell_text(self, element) -> str:
"""Fix-R6E-2: SPL table cells use <br/> as an in-cell line break
(e.g. "TRIKAFTA" on one line, "N=202" on the next, "n (%)" on a
third), but joining itertext() with no separator collapsed these
into a single run like "TRIKAFTAN=202n (%)". Walk the cell's mixed
content and insert a space at each <br/> boundary instead."""
def _flow_text(self, element) -> str:
"""Render one SPL flow element (cell, paragraph or list item) to text.
Every text-flattening path in this file goes through here. That is
the point: the two things below are properties of SPL markup, not of
tables, and when only the table path knew about them one response
could contain the same equation rendered both correctly and
incorrectly.
Fix-R6E-2: SPL uses <br/> as an in-line break (e.g. "TRIKAFTA" on one
line, "N=202" on the next, "n (%)" on a third), but joining
itertext() with no separator collapsed these into a single run like
"TRIKAFTAN=202n (%)". Walk the mixed content and insert a space at
each <br/> boundary instead.
A <br/> is not always a line break, though. SPL has no fraction
element, so a dosing equation is drawn as a stacked fraction: the
numerator is an underlined <content> on its own line and the
denominator is the text run after the following <br/>, with the
underline serving as the division bar. Rendering that break as a
space deletes the division. DigiFab (digoxin immune fab, setid
c05ee6a5-c98b-45f4-83fd-40781639d653) encodes its dosing equations
this way -- twice in a table and three more times in <paragraph>s --
and flattening turned
Dose (in vials) = (Serum digoxin ng/mL)(weight in kg) / 100
into "... (weight in kg) 100", which reads as a multiplication by
100 rather than a division -- a 10,000-fold error in an antidote
dose, at the bedside, with no indication anything was lost.
The bar is only recognised on the exact stacked-fraction shape:
an underlined <content> that directly follows an "=" and is
directly followed by a <br/>. Requiring the "=" is what keeps an
underlined heading that happens to precede a line break (a common
and unrelated use of underline -- this same label has two, "Risk
Summary" in Pregnancy and Lactation) from becoming a division.
"""
parts: List[str] = []
def walk(el) -> None:
@@ -632,7 +664,7 @@ class DailyMedSPLParserTool(BaseTool):
parts.append(el.text)
for child in el:
if etree.QName(child).localname == "br":
parts.append(" ")
parts.append(" / " if self._is_division_bar(el, child) else " ")
else:
walk(child)
if child.tail:
@@ -641,6 +673,40 @@ class DailyMedSPLParserTool(BaseTool):
walk(element)
return " ".join("".join(parts).split())
@staticmethod
def _is_division_bar(parent, br) -> bool:
"""Does this <br/> close a stacked fraction rather than a line?
Decided from sibling structure alone: the element before the <br/>
must be an underlined <content> (the numerator, drawn with the
underline as the division bar), nothing but whitespace may separate
the two, and the text before the numerator must end at the "=" the
fraction is the right-hand side of.
"""
numerator = br.getprevious()
if numerator is None or etree.QName(numerator).localname != "content":
return False
if "underline" not in (numerator.get("styleCode") or ""):
return False
# Text between numerator and bar means this was never a fraction;
# whitespace-only indentation (how SPL pretty-prints these) is fine.
if (numerator.tail or "").strip():
return False
# Walk back to the last real text before the numerator. The "=" is
# normally in the cell's own text with a <br/> after it -- the label
# puts the numerator on its own line -- so intervening <br/>s and
# their whitespace tails are stepped over, but any other element
# means this is not the simple "X = num/den" shape.
node = numerator.getprevious()
while node is not None:
tail = node.tail or ""
if tail.strip():
return tail.rstrip().endswith("=")
if etree.QName(node).localname != "br":
return False
node = node.getprevious()
return (parent.text or "").rstrip().endswith("=")
def _extract_table_data(self, table_element) -> List[Dict[str, Any]]:
"""Extract structured data from table element."""
try:
@@ -651,7 +717,7 @@ class DailyMedSPLParserTool(BaseTool):
thead = table_element.xpath(".//hl7:thead", namespaces=self.ns)
if thead:
header_cells = thead[0].xpath(".//hl7:th", namespaces=self.ns)
headers = [self._cell_text(cell) for cell in header_cells]
headers = [self._flow_text(cell) for cell in header_cells]
# Get table rows
tbody = table_element.xpath(".//hl7:tbody", namespaces=self.ns)
@@ -659,7 +725,7 @@ class DailyMedSPLParserTool(BaseTool):
rows = tbody[0].xpath(".//hl7:tr", namespaces=self.ns)
for row in rows:
cells = row.xpath(".//hl7:td", namespaces=self.ns)
cell_data = [self._cell_text(cell) for cell in cells]
cell_data = [self._flow_text(cell) for cell in cells]
if cell_data:
# Create dict if we have headers
+2 -3
View File
@@ -16,7 +16,7 @@
"Male",
"Female"
],
"description": "Filter by patient sex (optional)"
"description": "Optional. Restricts the FAERS adverse-event counts to one sex. Omit it to analyse all reports; supplying it roughly halves the denominator. Must be 'Male' or 'Female', not the FAERS-native numeric encoding."
},
"serious_events_only": {
"type": "boolean",
@@ -25,8 +25,7 @@
}
},
"required": [
"drug_name",
"patient_sex"
"drug_name"
]
},
"auto_load_dependencies": true,
@@ -472,7 +472,7 @@
},
{
"name": "europepmc_disease_target_score",
"description": "Extract disease-target association scores from Europe PMC literature. This includes literature-based evidence.",
"description": "Extract disease-target association scores from Europe PMC literature. This includes literature-based evidence. Returns EVERY target with a Europe PMC score for the disease, sorted strongest-first -- it is not a paged or top-N view. Scanning is capped at 25 seconds; when the cap is hit the response sets 'truncated' and a 'note' saying how many of the disease's associated targets were scanned.",
"datasource_id": "europepmc",
"parameter": {
"type": "object",
@@ -483,7 +483,7 @@
},
"pageSize": {
"type": "integer",
"description": "Number of results per page (default: 100, max: 100)",
"description": "Upstream fetch batch size (max 100), NOT a limit on results returned. The tool pages through all of the disease's associated targets regardless of this value -- 'pageSize': 10 on MONDO_0005011 (Crohn disease) still returns 390 scored targets. Lower it only to make each upstream request smaller.",
"default": 100
}
},
@@ -113,6 +113,7 @@
]
},
"warnings_and_precautions": {
"description": "The label's warnings section, taken from whichever section name the label actually uses: the PLR 'warnings_and_cautions' when present, otherwise the pre-PLR 'warnings'.",
"type": [
"string",
"null"
@@ -259,6 +260,7 @@
]
},
"warnings_and_precautions": {
"description": "The label's warnings section, taken from whichever section name the label actually uses: the PLR 'warnings_and_cautions' when present, otherwise the pre-PLR 'warnings'. Only about 18% of openFDA labels are PLR-format, so for most drugs this is the pre-PLR WARNINGS text.",
"type": [
"string",
"null"
@@ -282,6 +284,13 @@
"null"
]
},
"precautions": {
"description": "Pre-PLR PRECAUTIONS section. Older labels have no separate drug_interactions or use_in_specific_populations section -- that content is subheaded inside PRECAUTIONS instead. Null on PLR-format labels.",
"type": [
"string",
"null"
]
},
"clinical_pharmacology": {
"type": [
"string",
+41 -2
View File
@@ -62,6 +62,31 @@ _FALLBACK_SECTION_CHARS = 2000
# Extracted record keys that hold free-text clinical prose and are therefore
# subject to the per-section budget. Identifier/metadata keys are not.
#
# openFDA carries a label in whichever format it was submitted in, and the two
# formats do not share section names. Partitioning the full drug/label corpus of
# 261,646 records by which warnings section a label has, measured against the
# live API (the same split `test_fda_label_plr_section_split` measures for the
# openfda_tool family; its counts are 7 records older, the corpus grows):
#
# has warnings_and_cautions (PLR) 46,930
# has warnings only, no warnings_and_cautions 204,636
# has neither 10,080
#
# So the PLR name alone reaches under a fifth of the corpus. Reading only it
# meant `warnings_and_precautions` came back null -- alongside
# `truncated: false`, i.e. an explicit "nothing was cut" -- for 204,636 of the
# 251,566 labels that do carry a warnings section, or 81% of them.
#
# This follows the label's vintage, not the drug: flumazenil is pre-PLR while
# naloxone and pralidoxime are PLR, so it is not specific to any therapeutic
# class. Flumazenil's WARNINGS ("Risk of Seizures ... not recommended in cases
# of serious cyclic antidepressant poisoning") was retrievable by field query
# the whole time; this extraction just never asked for it.
#
# `warnings_and_precautions` is NOT an openFDA field name -- it is the printed
# heading. It is used here only as the response key, mapped from whichever
# source section the label actually has.
_SECTION_FIELDS = (
"boxed_warning",
"indications_and_usage",
@@ -72,6 +97,7 @@ _SECTION_FIELDS = (
"adverse_reactions",
"drug_interactions",
"use_in_specific_populations",
"precautions",
"clinical_pharmacology",
"mechanism_of_action",
)
@@ -220,11 +246,24 @@ def _extract_label(
"dosage_and_administration": section("dosage_and_administration"),
"dosage_forms_and_strengths": section("dosage_forms_and_strengths"),
"contraindications": section("contraindications"),
"warnings_and_precautions": section("warnings_and_precautions")
or section("warnings_and_cautions"),
# PLR heading first, then the pre-PLR WARNINGS section; see the note on
# _SECTION_FIELDS for why the fallback is needed and how much it covers.
"warnings_and_precautions": section("warnings_and_cautions")
or section("warnings"),
"adverse_reactions": section("adverse_reactions"),
"drug_interactions": section("drug_interactions"),
"use_in_specific_populations": section("use_in_specific_populations"),
# On a pre-PLR label the PRECAUTIONS section is where drug-interaction,
# pediatric- and geriatric-use guidance is subheaded when the label has
# no separate top-level section for it -- 18,821 labels have
# `precautions` and no `drug_interactions` at all. (Many pre-PLR labels
# do expose `drug_interactions` separately, and that is already read
# above; PRECAUTIONS is not a replacement for it, it is the fallback
# home for the same material.) Returned under its own name rather than
# folded into the PLR keys, so provenance stays exact.
# `general_precautions` is the same section under an alternate name on
# 25,729 labels, 725 of which have no `precautions` at all.
"precautions": section("precautions") or section("general_precautions"),
"clinical_pharmacology": section("clinical_pharmacology"),
"mechanism_of_action": section("mechanism_of_action"),
"spl_id": result.get("id"),
+3 -3
View File
@@ -10,8 +10,8 @@ from ._shared_client import get_shared_client
def DrugSafetyAnalyzer(
drug_name: str,
patient_sex: str,
serious_events_only: bool,
patient_sex: Optional[str] = None,
serious_events_only: Optional[bool] = False,
*,
stream_callback: Optional[Callable[[str], None]] = None,
use_cache: bool = False,
@@ -25,7 +25,7 @@ def DrugSafetyAnalyzer(
drug_name : str
Name of the drug to analyze
patient_sex : str
Filter by patient sex (optional)
Optional. Restricts the FAERS adverse-event counts to one sex. Omit it to ana...
serious_events_only : bool
Focus only on serious adverse events
stream_callback : Callable, optional
@@ -24,7 +24,7 @@ def europepmc_disease_target_score(
efoId : str
The EFO (Experimental Factor Ontology) ID of the disease, e.g., 'MONDO_0011996'...
pageSize : int
Number of results per page (default: 100, max: 100)
Upstream fetch batch size (max 100), NOT a limit on results returned. The too...
stream_callback : Callable, optional
Callback for streaming output
use_cache : bool, default False
@@ -32,9 +32,28 @@ def _tool_config(name):
raise AssertionError(f"{name} not found in compose_tools.json")
def test_drug_safety_analyzer_requires_only_drug_name_and_patient_sex():
def test_drug_safety_analyzer_requires_only_drug_name():
"""`patient_sex` was required while being described and implemented as optional.
The original sweep took `serious_events_only` out of `required` here and
`sample_type` out of BiomarkerDiscoveryWorkflow's, on the grounds that the
composition function already handles the omitted case -- but left
`patient_sex` in, even though it is read the same way
(`arguments.get("patient_sex")`, then `if patient_sex:` before it is
forwarded to FAERS) and its own description ends "(optional)".
So the schema rejected the call the description invited::
$ python -m tooluniverse.cli run DrugSafetyAnalyzer \\
'{"drug_name":"dexmedetomidine"}'
Error: Parameter validation failed for 'root':
'patient_sex' is a required property
There is no sex-unstratified drug safety review available while that holds,
and filtering by sex roughly halves the FAERS denominator.
"""
cfg = _tool_config("DrugSafetyAnalyzer")
assert cfg["parameter"]["required"] == ["drug_name", "patient_sex"]
assert cfg["parameter"]["required"] == ["drug_name"]
def test_biomarker_discovery_workflow_requires_only_disease_condition():
@@ -0,0 +1,167 @@
"""Regression guard: SPL stacked-fraction equations must keep their division.
SPL has no fraction element, so a dosing equation is drawn as a stacked
fraction -- an underlined <content> numerator on its own line, a <br/>, then
the denominator, with the underline acting as the division bar. _cell_text()
renders <br/> as a space, which deleted the division entirely.
Measured on the live DigiFab label (digoxin immune fab, setid
c05ee6a5-c98b-45f4-83fd-40781639d653), whose two dosing equations both use
this encoding. Before the fix DailyMed_parse_dosing returned
Dose (in vials) = (Serum digoxin ng/mL)(weight in kg) 100
which a bedside reader parses as a multiplication by 100 rather than a
division by it -- a 10,000-fold error in an antidote dose, with nothing in
the response indicating anything had been dropped.
The bar is recognised only on the exact stacked-fraction shape: an underlined
<content> directly following an "=" and directly followed by a <br/>. The
"=" requirement is what keeps an underlined *heading* before a line break --
a common, unrelated use of underline -- from being turned into a division,
which is the false positive this guard's second test pins down.
"""
from unittest.mock import MagicMock, patch
import pytest
from tooluniverse.dailymed_tool import DailyMedSPLParserTool
pytestmark = pytest.mark.unit
# Structure copied from the live DigiFab SPL, including the non-breaking-space
# indentation DailyMed uses to centre the denominator under the bar.
_DOSING_XML = """<?xml version="1.0"?>
<document xmlns="urn:hl7-org:v3">
<component><structuredBody>
<component><section>
<code code="34068-7"/>
<text>
<table>
<tbody>
<tr>
<td><content styleCode="bold">Acute ingestion of known amounts</content></td>
<td>Dose (in vials) =
<br/>
<content styleCode="underline">Amount of digoxin ingested (in mg)</content>
<br/>    0.5 mg/vial</td>
</tr>
<tr>
<td><content styleCode="bold">Chronic toxicity, known concentration</content></td>
<td>Dose (in vials) =
<br/>
<content styleCode="underline">(Serum digoxin ng/mL)(weight in kg)</content>
<br/>    100
</td>
</tr>
</tbody>
</table>
</text>
</section></component>
</structuredBody></component>
</document>
"""
# An underlined heading on its own line, followed by a <br/> and body text.
# Same element shape as a fraction, no "=" -- must stay a line break.
_UNDERLINED_HEADING_XML = """<?xml version="1.0"?>
<document xmlns="urn:hl7-org:v3">
<component><structuredBody>
<component><section>
<code code="34068-7"/>
<text>
<table>
<tbody>
<tr>
<td>Renal impairment</td>
<td>
<content styleCode="underline">Recommended Dosage</content>
<br/>Reduce the dose to 5 mg once daily.</td>
</tr>
</tbody>
</table>
</text>
</section></component>
</structuredBody></component>
</document>
"""
# The same fraction as above, encoded in a <paragraph> instead of a <td> --
# the shape DigiFab section 2.1 actually uses.
_PARAGRAPH_FRACTION_XML = """<?xml version="1.0"?>
<document xmlns="urn:hl7-org:v3">
<component><structuredBody>
<component><section>
<code code="34068-7"/>
<text>
<paragraph>Dose = <content styleCode="underline">(Serum digoxin concentration in ng/mL)(weight in kg)</content>
<br/>100</paragraph>
</text>
</section></component>
</structuredBody></component>
</document>
"""
def _parse_dosing(xml):
tool = DailyMedSPLParserTool(
{"name": "DailyMed_parse_dosing", "parameter": {"properties": {}}}
)
resp = MagicMock()
resp.status_code = 200
resp.text = xml
with patch("tooluniverse.dailymed_tool.requests.get", return_value=resp):
result = tool.run({"operation": "parse_dosing", "setid": "fake-setid"})
assert result["status"] == "success"
return [
item["data"]
for item in result["data"]["dosing_info"]
if item["type"] == "table_row"
]
def test_stacked_fraction_keeps_its_division_operator():
rows = _parse_dosing(_DOSING_XML)
assert rows[0][1] == (
"Dose (in vials) = Amount of digoxin ingested (in mg) / 0.5 mg/vial"
)
assert rows[1][1] == "Dose (in vials) = (Serum digoxin ng/mL)(weight in kg) / 100"
def test_underlined_heading_before_a_break_is_not_a_division():
rows = _parse_dosing(_UNDERLINED_HEADING_XML)
assert rows[0][1] == "Recommended Dosage Reduce the dose to 5 mg once daily."
assert "/" not in rows[0][1]
def test_paragraph_fractions_are_handled_like_table_ones():
"""The same equation must not render two different ways in one response.
DigiFab states its dosing equations twice: once in a table and again in
<paragraph>s in section 2.1. Fixing only the table path left the response
self-contradicting -- the table row said "/ 100" while the paragraph still
said " 100" for the same equation on the same drug. <br/> is a property of
SPL markup, not of tables, so every flow element goes through _flow_text.
"""
tool = DailyMedSPLParserTool(
{"name": "DailyMed_parse_dosing", "parameter": {"properties": {}}}
)
resp = MagicMock()
resp.status_code = 200
resp.text = _PARAGRAPH_FRACTION_XML
with patch("tooluniverse.dailymed_tool.requests.get", return_value=resp):
result = tool.run({"operation": "parse_dosing", "setid": "fake-setid"})
texts = [
item["content"]
for item in result["data"]["dosing_info"]
if item["type"] == "dosing_text"
]
assert any("(weight in kg) / 100" in t for t in texts), texts
@@ -0,0 +1,193 @@
"""Guards against docs and examples naming tools that do not exist.
Companion to ``test_compose_tool_dependencies``, which pins the same class of
defect one layer down: that module checks tool names a *config* declares as a
dependency, this one checks tool names a *human-facing runnable example* tells
a user to call. The failure is identical in kind -- a name that resolves to
nothing -- but the config sweep cannot see it, because docs and examples are not
configs.
Defect this covers
------------------
``BioRxiv_search_preprints`` and ``MedRxiv_search_preprints`` were removed
because the bioRxiv/medRxiv APIs have no keyword-search endpoint at all (see
``docs/dev_docs/SEARCHING_BIORXIV.md``). The tool configs were cleaned up, but
eight ``tu.run(...)`` examples across the literature-search tutorial kept
telling users to call them, and two files under ``examples/`` still did::
$ python -m tooluniverse.cli run BioRxiv_search_preprints \\
'{"query":"burn resuscitation","max_results":2}'
Error: Tool 'BioRxiv_search_preprints' not found even after loading tools
Did you mean: BioRxiv_get_preprint, BioRxiv_list_recent_preprints, ...
$ python -c "from tooluniverse.tools import BioRxiv_search_preprints"
ImportError: cannot import name 'BioRxiv_search_preprints' from
'tooluniverse.tools'
The second one is not a stale doc, it is a shipped example that cannot be
imported at all.
Scope
-----
Hand-written sources only. ``docs/tools/``, ``docs/locale/`` and
``src/tooluniverse/tools/`` are generated artifacts -- a stale name there means
the generator has not been re-run, which is a build step, not a content defect,
and hand-editing them would be reverted on the next generation.
"""
import ast
import json
import re
from pathlib import Path
import pytest
ROOT = Path(__file__).resolve().parents[2]
DATA_DIR = ROOT / "src" / "tooluniverse" / "data"
# Hand-written trees only; see the module docstring on generated artifacts.
SEARCH_ROOTS = (ROOT / "docs" / "guide", ROOT / "examples")
# `tu.run({"name": "X"` / `"name": "X"` inside a runnable snippet, and
# `from tooluniverse.tools import (X, Y)`.
_NAME_KEY = re.compile(r"""["']name["']\s*:\s*["']([A-Za-z_][A-Za-z0-9_]*)["']""")
def _declared_tool_names():
"""Every tool name declared in any config, mirroring the compose sweep."""
names = set()
for path in sorted(DATA_DIR.rglob("*.json")):
try:
content = json.loads(path.read_text())
except (json.JSONDecodeError, UnicodeDecodeError):
continue
if not isinstance(content, list):
continue
for item in content:
if isinstance(item, dict) and isinstance(item.get("name"), str):
names.add(item["name"])
return names
DECLARED = _declared_tool_names()
def _tools_package_exports():
"""Names importable from ``tooluniverse.tools``, read without importing it.
Parsed from the package's ``__all__`` so the sweep stays offline and does
not pay the import cost of the whole tool registry.
"""
init = ROOT / "src" / "tooluniverse" / "tools" / "__init__.py"
tree = ast.parse(init.read_text())
for node in ast.walk(tree):
if isinstance(node, ast.Assign) and any(
isinstance(t, ast.Name) and t.id == "__all__" for t in node.targets
):
return {
elt.value
for elt in node.value.elts
if isinstance(elt, ast.Constant) and isinstance(elt.value, str)
}
return set()
EXPORTS = _tools_package_exports()
def _collect_name_key_references():
"""(file, tool_name) for every `"name": "X"` in a hand-written source."""
refs = []
for root in SEARCH_ROOTS:
for path in sorted(root.rglob("*")):
if path.suffix not in {".rst", ".md", ".py"} or not path.is_file():
continue
try:
text = path.read_text()
except UnicodeDecodeError:
continue
for match in _NAME_KEY.finditer(text):
refs.append((str(path.relative_to(ROOT)), match.group(1)))
return refs
def _collect_tools_imports():
"""(file, imported_name) for every `from tooluniverse.tools import ...`."""
refs = []
for root in SEARCH_ROOTS:
for path in sorted(root.rglob("*.py")):
try:
text = path.read_text()
except UnicodeDecodeError:
continue
# Only 9 of the ~190 example scripts import from the tools package;
# parsing the rest costs 125ms of collection time for nothing.
if "tooluniverse.tools" not in text:
continue
try:
tree = ast.parse(text)
except SyntaxError:
continue
for node in ast.walk(tree):
if (
isinstance(node, ast.ImportFrom)
and node.module == "tooluniverse.tools"
):
for alias in node.names:
if alias.name != "*":
refs.append((str(path.relative_to(ROOT)), alias.name))
return refs
NAME_REFS = _collect_name_key_references()
IMPORT_REFS = _collect_tools_imports()
def test_sweep_found_sources_to_check():
"""Positive control: the sweeps below are vacuous if nothing was collected."""
assert len(DECLARED) > 1000, f"only {len(DECLARED)} tool names found in {DATA_DIR}"
assert EXPORTS, "tooluniverse.tools exports no names -- __all__ parse failed"
assert NAME_REFS, f"no '\"name\": ...' references found under {SEARCH_ROOTS}"
assert IMPORT_REFS, "no 'from tooluniverse.tools import' found in examples"
def test_documented_tool_names_resolve():
"""A `"name": "X"` in a runnable example must name a real tool.
Only names that look like tool references are checked: a name that matches
no tool AND is not capitalised like one is far more likely to be an
unrelated `"name"` key in some sample payload, so it is skipped rather than
guessed at.
The capitalisation rule is deliberately conservative and does not yet cover
lower-case tool families (`drugbank_*`, `openalex_*`), so a stale reference
like `drugbank_get_safety` still passes. Widening it by requiring the
name's prefix to be a real family instead --
``{n.split("_", 1)[0] for n in DECLARED if "_" in n}`` -- subsumes this rule
and catches those too; it is left for a follow-up because it flags further
pre-existing stale references beyond the ones fixed here.
"""
unknown = sorted(
{
(path, name)
for path, name in NAME_REFS
if name not in DECLARED and re.match(r"^[A-Z][A-Za-z0-9]*_[a-z]", name)
}
)
assert not unknown, (
"docs/examples call tools that are not declared in any config: "
f"{unknown}. A reader who copies the snippet gets "
"\"Tool 'X' not found even after loading tools\"."
)
def test_example_imports_from_tools_package_resolve():
"""`from tooluniverse.tools import X` in an example must actually import."""
unknown = sorted(
{(path, name) for path, name in IMPORT_REFS if name not in EXPORTS}
)
assert not unknown, (
f"examples import names that tooluniverse.tools does not export: "
f"{unknown}. These raise ImportError at module load, so the example "
"cannot run at all."
)
@@ -0,0 +1,131 @@
"""Offline regression tests: FDA_get_drug_label must read pre-PLR label sections.
Defect this covers
------------------
``_extract_label`` mapped its ``warnings_and_precautions`` response key from the
PLR section name only. openFDA carries a label in whichever format it was
submitted in, and the two formats share no section names, so for every pre-PLR
label the key came back null -- next to ``truncated: false``, an explicit
"nothing was cut". Live evidence, CLI::
$ python -m tooluniverse.cli run FDA_get_drug_label '{"drug_name":"flumazenil"}'
warnings_and_precautions: None
truncated: false
(no "warnings" key anywhere in the returned record)
Flumazenil is the benzodiazepine antidote and its WARNINGS section is the whole
reason the drug is dangerous::
$ curl -G 'https://api.fda.gov/drug/label.json' \\
--data-urlencode 'search=openfda.generic_name:"flumazenil"' --data 'limit=1'
'warnings_and_cautions' in record: False
'warnings' in record: True
"WARNINGS Risk of Seizures The reversal of benzodiazepine effects may be
associated with the onset of seizures in certain high-risk populations ...
Flumazenil is not recommended in cases of serious cyclic antidepressant
poisoning ..."
The corpus-wide scale of this, and the fact that it follows the label's vintage
rather than the drug, is measured once in the note on ``_SECTION_FIELDS`` in
``src/tooluniverse/fda_label_tool.py``; it is not restated here.
``search=_exists_:warnings_and_precautions`` returns NOT_FOUND -- that name is
the printed heading, never an openFDA field -- so the original first lookup was
dead code and is gone.
Relationship to test_fda_label_plr_section_split
------------------------------------------------
That module pins the same upstream split for the ``openfda_tool`` family, which
takes the opposite approach on purpose: there the response keys ARE the openFDA
field names, so sibling content is attached under its own key and never
substituted, or provenance would be misrepresented. Here the response key
``warnings_and_precautions`` is format-neutral prose, not a field name, so
mapping either source section onto it is the correct behaviour. The returned
text keeps its own heading ("WARNINGS" vs "5 WARNINGS AND PRECAUTIONS"), so the
source stays visible in the content either way.
Hermeticity
-----------
``_extract_label`` is a pure function over an already-fetched openFDA record, so
these tests do no I/O at all rather than relying on a patch holding.
"""
import pytest
from tooluniverse.fda_label_tool import _SECTION_FIELDS, _extract_label
pytestmark = pytest.mark.unit
# Shape of a real pre-PLR record: WARNINGS + PRECAUTIONS, and none of the PLR
# section names. Trimmed from the live flumazenil label.
_PRE_PLR = {
"id": "bd300433",
"openfda": {"generic_name": ["FLUMAZENIL"]},
"warnings": ["WARNINGS Risk of Seizures The reversal of benzodiazepine effects"],
"precautions": ["PRECAUTIONS Return of Sedation Flumazenil may be expected"],
"adverse_reactions": ["ADVERSE REACTIONS Deaths have occurred"],
}
# Shape of a real PLR record: the numbered PLR sections, no legacy sections.
_PLR = {
"id": "aa11",
"openfda": {"generic_name": ["APIXABAN"]},
"warnings_and_cautions": ["5 WARNINGS AND PRECAUTIONS Apixaban can cause bleeding"],
"drug_interactions": ["7 DRUG INTERACTIONS Apixaban is a substrate of CYP3A4"],
}
# 3,439 labels upstream carry both. The PLR section is the current one.
_BOTH = {
"id": "bb22",
"openfda": {"generic_name": ["MIXED"]},
"warnings_and_cautions": ["PLR TEXT"],
"warnings": ["LEGACY TEXT"],
}
def test_pre_plr_label_returns_its_warnings_section():
record, truncated = _extract_label(_PRE_PLR)
assert record["warnings_and_precautions"].startswith("WARNINGS Risk of Seizures")
assert truncated == []
def test_pre_plr_label_returns_its_precautions_section():
record, _ = _extract_label(_PRE_PLR)
# Pre-PLR labels have no drug_interactions/use_in_specific_populations
# section; that content is subheaded inside PRECAUTIONS.
assert record["precautions"].startswith("PRECAUTIONS Return of Sedation")
assert record["drug_interactions"] is None
assert record["use_in_specific_populations"] is None
def test_plr_label_is_unchanged():
record, _ = _extract_label(_PLR)
assert record["warnings_and_precautions"].startswith("5 WARNINGS AND PRECAUTIONS")
assert record["drug_interactions"].startswith("7 DRUG INTERACTIONS")
# A PLR label has no legacy PRECAUTIONS section, and none is invented.
assert record["precautions"] is None
def test_plr_section_wins_when_a_label_carries_both():
record, _ = _extract_label(_BOTH)
assert record["warnings_and_precautions"] == "PLR TEXT"
def test_recovered_sections_obey_the_truncation_budget():
# A section that is only reachable via the new mapping must still be
# disclosed when cut, or the fix would reintroduce silent loss by a
# different route.
record, truncated = _extract_label(_PRE_PLR, max_chars=10)
# Order follows _SECTION_FIELDS here; _disclose_truncation sorts it later.
assert set(truncated) == {
"warnings_and_precautions",
"precautions",
"adverse_reactions",
}
assert len(record["warnings_and_precautions"]) < len(_PRE_PLR["warnings"][0])
assert "precautions" in _SECTION_FIELDS