mirror of
https://github.com/mims-harvard/ToolUniverse.git
synced 2026-09-19 07:31:47 +08:00
Round 026: standard envelope for FDA label tools + RCSB entry_id alias
Feature-026C-1: FDALabelTool (FDA_search_drug_labels / FDA_get_drug_label /
FDA_list_drug_classes) returned bare lists/dicts on success (framework-wrapped
to {"result": [...]}), inconsistent with the project-wide
{status, data, metadata} success contract that the error paths already follow.
Added an _ok() helper; all three query types now return the standard envelope
and their return_schema oneOf success branch is updated to match.
Feature-026B-002: RCSBData_get_entry only accepted pdb_id; the RCSB-native term
is entry_id (endpoint is /core/entry/{id}), so callers reaching for it hit a
hard schema-validation failure. _query() now aliases entry_id/id -> pdb_id and
the schema accepts either (anyOf) while an empty call still returns a clear
error.
Tests: tests/unit/test_fda_label_envelope.py, test_rcsb_entry_id_alias.py (9).
This commit is contained in:
@@ -47,94 +47,119 @@
|
||||
"return_schema": {
|
||||
"oneOf": [
|
||||
{
|
||||
"type": "array",
|
||||
"description": "List of FDA drug label summaries with clinical sections",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"brand_name": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"generic_name": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"manufacturer": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"route": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"pharm_class": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"boxed_warning": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"indications_and_usage": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"dosage_and_administration": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"contraindications": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"warnings_and_precautions": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"drug_interactions": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"spl_id": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"status": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"success"
|
||||
]
|
||||
},
|
||||
"data": {
|
||||
"type": "array",
|
||||
"description": "List of FDA drug label summaries with clinical sections",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"brand_name": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"generic_name": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"manufacturer": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"route": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"pharm_class": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"boxed_warning": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"indications_and_usage": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"dosage_and_administration": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"contraindications": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"warnings_and_precautions": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"drug_interactions": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"spl_id": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"metadata": {
|
||||
"type": "object"
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"status",
|
||||
"data"
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"status": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"error"
|
||||
]
|
||||
},
|
||||
"error": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"status",
|
||||
"error"
|
||||
]
|
||||
}
|
||||
@@ -176,126 +201,151 @@
|
||||
"oneOf": [
|
||||
{
|
||||
"type": "object",
|
||||
"description": "Complete FDA drug label with all clinical sections",
|
||||
"properties": {
|
||||
"brand_name": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
"status": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"success"
|
||||
]
|
||||
},
|
||||
"generic_name": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
"data": {
|
||||
"type": "object",
|
||||
"description": "Complete FDA drug label with all clinical sections",
|
||||
"properties": {
|
||||
"brand_name": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"generic_name": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"manufacturer": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"route": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"pharm_class": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"rxcui": {
|
||||
"type": [
|
||||
"array",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"boxed_warning": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"indications_and_usage": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"dosage_and_administration": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"dosage_forms_and_strengths": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"contraindications": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"warnings_and_precautions": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"adverse_reactions": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"drug_interactions": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"use_in_specific_populations": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"clinical_pharmacology": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"mechanism_of_action": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"spl_id": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"manufacturer": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"route": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"pharm_class": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"rxcui": {
|
||||
"type": [
|
||||
"array",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"boxed_warning": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"indications_and_usage": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"dosage_and_administration": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"dosage_forms_and_strengths": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"contraindications": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"warnings_and_precautions": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"adverse_reactions": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"drug_interactions": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"use_in_specific_populations": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"clinical_pharmacology": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"mechanism_of_action": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"spl_id": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
"metadata": {
|
||||
"type": "object"
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"status",
|
||||
"data"
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"status": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"error"
|
||||
]
|
||||
},
|
||||
"error": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"status",
|
||||
"error"
|
||||
]
|
||||
}
|
||||
@@ -334,28 +384,53 @@
|
||||
"return_schema": {
|
||||
"oneOf": [
|
||||
{
|
||||
"type": "array",
|
||||
"description": "List of FDA pharmacological drug classes with product counts",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"drug_class": {
|
||||
"type": "string"
|
||||
},
|
||||
"count": {
|
||||
"type": "integer"
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"status": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"success"
|
||||
]
|
||||
},
|
||||
"data": {
|
||||
"type": "array",
|
||||
"description": "List of FDA pharmacological drug classes with product counts",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"drug_class": {
|
||||
"type": "string"
|
||||
},
|
||||
"count": {
|
||||
"type": "integer"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"metadata": {
|
||||
"type": "object"
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"status",
|
||||
"data"
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"status": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"error"
|
||||
]
|
||||
},
|
||||
"error": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"status",
|
||||
"error"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -8,10 +8,23 @@
|
||||
"pdb_id": {
|
||||
"type": "string",
|
||||
"description": "PDB entry ID (4 characters). Examples: '4HHB' (hemoglobin), '1TUP' (p53-DNA complex), '1M17' (EGFR kinase), '6LU7' (SARS-CoV-2 main protease)."
|
||||
},
|
||||
"entry_id": {
|
||||
"type": "string",
|
||||
"description": "Alias for pdb_id (the RCSB-native term; the REST endpoint is /core/entry/{id}). Provide either pdb_id or entry_id."
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"pdb_id"
|
||||
"anyOf": [
|
||||
{
|
||||
"required": [
|
||||
"pdb_id"
|
||||
]
|
||||
},
|
||||
{
|
||||
"required": [
|
||||
"entry_id"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
"fields": {
|
||||
|
||||
@@ -19,6 +19,18 @@ from .tool_registry import register_tool
|
||||
FDA_LABEL_URL = "https://api.fda.gov/drug/label.json"
|
||||
|
||||
|
||||
def _ok(data: Any, **metadata: Any) -> dict:
|
||||
"""Wrap a successful result in the standard ToolUniverse envelope.
|
||||
|
||||
Error paths already return {status: error, ...}; this keeps the success
|
||||
path consistent with the project-wide {status, data, metadata} contract.
|
||||
"""
|
||||
metadata.setdefault("source", "openFDA drug label")
|
||||
if isinstance(data, list):
|
||||
metadata.setdefault("count", len(data))
|
||||
return {"status": "success", "data": data, "metadata": metadata}
|
||||
|
||||
|
||||
def _extract_label(result: dict) -> dict:
|
||||
"""Extract key clinical sections from a raw openFDA label record."""
|
||||
openfda = result.get("openfda", {})
|
||||
@@ -121,7 +133,8 @@ class FDALabelTool(BaseTool):
|
||||
return {"status": "error", "error": "Provide drug_name or indication"}
|
||||
|
||||
if drug_name:
|
||||
return self._query_drug_fields(drug_name, limit) or []
|
||||
labels = self._query_drug_fields(drug_name, limit) or []
|
||||
return _ok(labels, query=drug_name, query_field="drug_name")
|
||||
|
||||
q = f'indications_and_usage:"{indication}"'
|
||||
resp = requests.get(
|
||||
@@ -129,11 +142,11 @@ class FDALabelTool(BaseTool):
|
||||
params={"search": q, "limit": limit},
|
||||
timeout=20,
|
||||
)
|
||||
if resp.status_code == 404:
|
||||
return []
|
||||
resp.raise_for_status()
|
||||
results = resp.json().get("results", [])
|
||||
return [_extract_label(r) for r in results]
|
||||
labels = []
|
||||
if resp.status_code != 404:
|
||||
resp.raise_for_status()
|
||||
labels = [_extract_label(r) for r in resp.json().get("results", [])]
|
||||
return _ok(labels, query=indication, query_field="indication")
|
||||
|
||||
def _get_label(self, arguments: dict) -> Any:
|
||||
drug_name = arguments.get("drug_name", "")
|
||||
@@ -154,7 +167,7 @@ class FDALabelTool(BaseTool):
|
||||
return len(brand) or 999
|
||||
|
||||
results.sort(key=_match_score)
|
||||
return results[0]
|
||||
return _ok(results[0], query=drug_name)
|
||||
|
||||
def _list_classes(self, arguments: dict) -> Any:
|
||||
limit = min(int(arguments.get("limit", 20)), 100)
|
||||
@@ -169,4 +182,5 @@ class FDALabelTool(BaseTool):
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
results = data.get("results", [])
|
||||
return [{"drug_class": r["term"], "count": r["count"]} for r in results]
|
||||
classes = [{"drug_class": r["term"], "count": r["count"]} for r in results]
|
||||
return _ok(classes)
|
||||
|
||||
@@ -62,6 +62,12 @@ class RCSBDataTool(BaseTool):
|
||||
|
||||
def _query(self, arguments: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""Route to appropriate endpoint."""
|
||||
# Accept the RCSB-native term 'entry_id' (and plain 'id') as aliases for
|
||||
# 'pdb_id' — the REST endpoint is /core/entry/{id}, so users reach for it.
|
||||
if not arguments.get("pdb_id"):
|
||||
alias = arguments.get("entry_id") or arguments.get("id")
|
||||
if alias:
|
||||
arguments = {**arguments, "pdb_id": alias}
|
||||
if self.endpoint == "entry":
|
||||
return self._get_entry(arguments)
|
||||
elif self.endpoint == "assembly":
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
"""Round 026: FDALabelTool returns the standard {status, data, metadata} envelope.
|
||||
|
||||
Regression for Feature-026C-1: FDA_search_drug_labels previously returned a bare
|
||||
list (framework-wrapped to {"result": [...]}), inconsistent with the project-wide
|
||||
success envelope. All three FDALabelTool query types now return {status, data,
|
||||
metadata} on success and {status: error, ...} on failure.
|
||||
"""
|
||||
|
||||
from unittest.mock import patch
|
||||
|
||||
from tooluniverse.fda_label_tool import FDALabelTool, _ok
|
||||
|
||||
|
||||
def _cfg(query_type):
|
||||
return {
|
||||
"name": f"FDA_{query_type}",
|
||||
"type": "FDALabelTool",
|
||||
"fields": {"endpoint": "https://api.fda.gov/drug/label.json", "query_type": query_type},
|
||||
"parameter": {"type": "object", "properties": {}},
|
||||
}
|
||||
|
||||
|
||||
class _Resp:
|
||||
status_code = 200
|
||||
|
||||
def __init__(self, payload):
|
||||
self._payload = payload
|
||||
|
||||
def raise_for_status(self):
|
||||
pass
|
||||
|
||||
def json(self):
|
||||
return self._payload
|
||||
|
||||
|
||||
def test_ok_helper_wraps_list_with_count():
|
||||
out = _ok([1, 2, 3], query="x")
|
||||
assert out["status"] == "success"
|
||||
assert out["data"] == [1, 2, 3]
|
||||
assert out["metadata"]["count"] == 3
|
||||
assert out["metadata"]["source"] == "openFDA drug label"
|
||||
assert out["metadata"]["query"] == "x"
|
||||
|
||||
|
||||
def test_ok_helper_does_not_count_dict():
|
||||
out = _ok({"brand_name": "X"})
|
||||
assert out["status"] == "success"
|
||||
assert "count" not in out["metadata"]
|
||||
|
||||
|
||||
def test_search_returns_standard_envelope():
|
||||
raw = {"results": [{"openfda": {"brand_name": ["AVYCAZ"]}, "indications_and_usage": ["use"]}]}
|
||||
tool = FDALabelTool(_cfg("search"))
|
||||
with patch("tooluniverse.fda_label_tool.requests.get", return_value=_Resp(raw)):
|
||||
out = tool.run({"drug_name": "Avycaz", "limit": 1})
|
||||
assert set(out) >= {"status", "data", "metadata"}
|
||||
assert out["status"] == "success"
|
||||
assert isinstance(out["data"], list)
|
||||
assert out["data"][0]["brand_name"] == "AVYCAZ"
|
||||
assert out["metadata"]["query"] == "Avycaz"
|
||||
|
||||
|
||||
def test_search_missing_args_is_error_envelope():
|
||||
tool = FDALabelTool(_cfg("search"))
|
||||
out = tool.run({})
|
||||
assert out["status"] == "error"
|
||||
assert "drug_name" in out["error"]
|
||||
|
||||
|
||||
def test_list_classes_returns_standard_envelope():
|
||||
raw = {"results": [{"term": "Cephalosporin", "count": 12}]}
|
||||
tool = FDALabelTool(_cfg("list_classes"))
|
||||
with patch("tooluniverse.fda_label_tool.requests.get", return_value=_Resp(raw)):
|
||||
out = tool.run({"limit": 1})
|
||||
assert out["status"] == "success"
|
||||
assert out["data"] == [{"drug_class": "Cephalosporin", "count": 12}]
|
||||
assert out["metadata"]["count"] == 1
|
||||
@@ -0,0 +1,67 @@
|
||||
"""Round 026: RCSBData_get_entry accepts the RCSB-native 'entry_id' alias.
|
||||
|
||||
Regression for Feature-026B-002: callers reaching for the RCSB-native term
|
||||
'entry_id' (the endpoint is /core/entry/{id}) previously hit a hard schema
|
||||
validation failure because only 'pdb_id' was accepted. The tool now aliases
|
||||
entry_id/id -> pdb_id in _query() and the schema accepts either.
|
||||
"""
|
||||
|
||||
from unittest.mock import patch
|
||||
|
||||
from tooluniverse.rcsb_data_tool import RCSBDataTool
|
||||
|
||||
|
||||
def _cfg():
|
||||
return {
|
||||
"name": "RCSBData_get_entry",
|
||||
"type": "RCSBDataTool",
|
||||
"fields": {"endpoint": "entry"},
|
||||
"parameter": {"type": "object", "properties": {}},
|
||||
}
|
||||
|
||||
|
||||
def test_entry_id_is_aliased_to_pdb_id():
|
||||
captured = {}
|
||||
|
||||
def fake_get_entry(args):
|
||||
captured.update(args)
|
||||
return {"status": "success", "data": {"pdb_id": args.get("pdb_id")}}
|
||||
|
||||
tool = RCSBDataTool(_cfg())
|
||||
with patch.object(tool, "_get_entry", side_effect=fake_get_entry):
|
||||
out = tool.run({"entry_id": "8fmi"})
|
||||
assert out["status"] == "success"
|
||||
assert captured["pdb_id"] == "8fmi"
|
||||
|
||||
|
||||
def test_explicit_pdb_id_not_clobbered_by_alias():
|
||||
captured = {}
|
||||
|
||||
def fake_get_entry(args):
|
||||
captured.update(args)
|
||||
return {"status": "success", "data": {}}
|
||||
|
||||
tool = RCSBDataTool(_cfg())
|
||||
with patch.object(tool, "_get_entry", side_effect=fake_get_entry):
|
||||
tool.run({"pdb_id": "4HHB", "entry_id": "9ZZZ"})
|
||||
assert captured["pdb_id"] == "4HHB"
|
||||
|
||||
|
||||
def test_plain_id_alias_also_accepted():
|
||||
captured = {}
|
||||
|
||||
def fake_get_entry(args):
|
||||
captured.update(args)
|
||||
return {"status": "success", "data": {}}
|
||||
|
||||
tool = RCSBDataTool(_cfg())
|
||||
with patch.object(tool, "_get_entry", side_effect=fake_get_entry):
|
||||
tool.run({"id": "6LU7"})
|
||||
assert captured["pdb_id"] == "6LU7"
|
||||
|
||||
|
||||
def test_no_identifier_returns_clear_error():
|
||||
tool = RCSBDataTool(_cfg())
|
||||
out = tool.run({})
|
||||
assert out["status"] == "error"
|
||||
assert "pdb_id" in out["error"]
|
||||
Reference in New Issue
Block a user