mirror of
https://github.com/mims-harvard/ToolUniverse.git
synced 2026-09-19 07:31:47 +08:00
3038dcbee0
* Fix #264: handle OpenTargets EFO->MONDO disease ID migration OpenTargets migrated most disease IDs from EFO to MONDO. Legacy EFO disease IDs (e.g. EFO_0000537, EFO_0000384, EFO_0000339) now resolve to {"data": {"disease": null}}, which remove_none_and_empty_values() strips to {"data": {}}. The tools then reported a misleading {"status": "success", "data": {}} (OpentargetTool) or raised KeyError: 'disease' (DiseaseTargetScoreTool). - graphql_tool.py: both OpenTargets tool classes now return an explicit error that names the unresolved ID and points at the current-ID lookup, instead of an empty success or a KeyError. DiseaseTargetScoreTool uses .get("disease") since the null key is stripped before it is read. - Update every EFO disease test_example and documentation example to the current MONDO ID (MONDO_0005011 Crohn, MONDO_0011996 CML, MONDO_0005044 hypertension, MONDO_0004975 Alzheimer, MONDO_0008383 rheumatoid arthritis, MONDO_0005012 melanoma). - The ChEMBL evidence datasource was also renamed chembl -> clinical_precedence (in both evidences() and associatedTargets datasourceScores); update the affected datasource examples and chembl_disease_target_score so it returns data again instead of silently zero. - Add unit tests covering the unresolved-ID and success paths. GWAS Catalog, OLS and EFO-ontology tools still use EFO IDs natively and are left unchanged. Reported in #264. * Harden GraphQL tools against not-found / API drift Generalises the per-tool fix from the previous commit into the base class so every GraphQL-backed tool — not just the two OpenTargets classes — reports an unresolved entity as an explicit error instead of a misleading empty success. - GraphQLTool.run: when a response strips to {"data": {}} (every top-level field null/empty -> entity not found), return an explicit error via the new overridable _empty_result_error() hook. A genuine empty result set (e.g. a 0-hit search keeps {"search": {}}) is non-empty and still succeeds. - OpentargetTool / DiseaseTargetScoreTool: drop the duplicated inline checks and override _empty_result_error() to keep the EFO->MONDO guidance message. - OpentargetTool: only run the hyphen-rescue retry when an argument actually contains '-', so a genuine not-found no longer issues a redundant query. Add scripts/audit_empty_success.py — runs each tool's test_examples and reports two failure modes: success-with-empty-data (the issue #264 pattern) and error-on-its-own-example (drift). It is the seed of a scheduled drift detector so external-API changes surface in CI instead of in a user's workflow. Running it across the OpenTarget family surfaced more of the same OpenTargets migration, now fixed: - OpenTargets_get_disease_ids_by_efoId: stale EFO_0000685 example -> MONDO_0008383. - OpenTargets_get_drug_{synonyms,trade_names,names}_by_chemblId: the synonyms and tradeNames fields changed from [String] to [DrugLabelAndSource] and now require a { label source } sub-selection; query_schema and return_schema updated. Audit result after the fixes: 0 empty-success, 0 broken-example across all 72 OpenTarget tools. New unit tests cover the base-class guard, the 0-hit-search exception, the subclass message override, and the auditor's empty-detection.
139 lines
5.3 KiB
Python
139 lines
5.3 KiB
Python
#!/usr/bin/env python3
|
|
"""
|
|
Audit tools for "silent empty success" — a tool that returns
|
|
``{"status": "success", ...}`` while carrying no actual content.
|
|
|
|
This is the failure mode behind issue #264: OpenTargets changed its disease IDs,
|
|
the stale ``test_examples`` started resolving to nothing, and the tools reported
|
|
an empty success instead of an error. Nobody noticed until a user filed a bug.
|
|
|
|
The audit runs each tool's ``test_examples`` and flags any that succeed with
|
|
effectively-empty data, so stale examples and silently-failing tools surface in
|
|
CI / a scheduled job instead of in a user's workflow.
|
|
|
|
It is intentionally STRICTER than the runtime guard in ``GraphQLTool.run``: the
|
|
runtime guard only errors when the top-level entity is null (so a legitimate
|
|
0-hit search still succeeds), whereas this audit flags *any* empty-content
|
|
success for a human to review.
|
|
|
|
Usage:
|
|
python3 scripts/audit_empty_success.py --type OpenTarget
|
|
python3 scripts/audit_empty_success.py --pattern disease_target_score
|
|
python3 scripts/audit_empty_success.py # every tool (slow)
|
|
|
|
Exit code is non-zero if any tool is flagged, so it can gate a CI job.
|
|
"""
|
|
|
|
import argparse
|
|
import json
|
|
import sys
|
|
from pathlib import Path
|
|
|
|
# src/ layout import
|
|
sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "src"))
|
|
from tooluniverse import ToolUniverse # noqa: E402
|
|
|
|
DATA_DIR = Path(__file__).resolve().parent.parent / "src" / "tooluniverse" / "data"
|
|
|
|
|
|
def is_effectively_empty(value) -> bool:
|
|
"""True when value carries no real content (recursively).
|
|
|
|
None, "", [], {} are empty; a dict/list is empty when every member is;
|
|
any non-empty scalar (number, non-empty string, bool) is content.
|
|
"""
|
|
if value is None or value == "" or value == [] or value == {}:
|
|
return True
|
|
if isinstance(value, dict):
|
|
return all(is_effectively_empty(v) for v in value.values())
|
|
if isinstance(value, list):
|
|
return all(is_effectively_empty(v) for v in value)
|
|
return False # a real scalar
|
|
|
|
|
|
def load_configs(pattern=None, type_filter=None):
|
|
"""Yield (tool_name, config) for tools matching the filters."""
|
|
for file_path in sorted(DATA_DIR.glob("**/*.json")):
|
|
if "broken_apis" in file_path.parts:
|
|
continue
|
|
try:
|
|
content = json.load(open(file_path))
|
|
except Exception:
|
|
continue
|
|
tools = content if isinstance(content, list) else [content]
|
|
for t in tools:
|
|
if not isinstance(t, dict) or "name" not in t:
|
|
continue
|
|
if type_filter and t.get("type") != type_filter:
|
|
continue
|
|
if pattern and pattern not in t["name"]:
|
|
continue
|
|
yield t["name"], t
|
|
|
|
|
|
def main():
|
|
ap = argparse.ArgumentParser(description=__doc__)
|
|
ap.add_argument("--type", dest="type_filter", help="filter by tool config type")
|
|
ap.add_argument("--pattern", help="substring filter on tool name")
|
|
ap.add_argument(
|
|
"--limit", type=int, default=0, help="max tools to test (0 = no limit)"
|
|
)
|
|
args = ap.parse_args()
|
|
|
|
configs = list(load_configs(args.pattern, args.type_filter))
|
|
if args.limit:
|
|
configs = configs[: args.limit]
|
|
if not configs:
|
|
print("No tools matched the filters.")
|
|
return 0
|
|
|
|
tu = ToolUniverse()
|
|
tu.load_tools()
|
|
|
|
empty, broken, ok, no_example = [], [], 0, 0
|
|
print(f"Auditing {len(configs)} tool(s) — do their test_examples return content?\n")
|
|
|
|
for name, cfg in configs:
|
|
examples = cfg.get("test_examples", [])
|
|
if not examples:
|
|
no_example += 1
|
|
continue
|
|
for i, example in enumerate(examples, 1):
|
|
try:
|
|
result = tu.run_one_function({"name": name, "arguments": example})
|
|
except Exception as e:
|
|
broken.append((name, i, f"{type(e).__name__}: {e}"))
|
|
print(f" BROKEN {name} ex{i}: raised {type(e).__name__}: {e}")
|
|
continue
|
|
status = result.get("status") if isinstance(result, dict) else None
|
|
data = result.get("data") if isinstance(result, dict) else result
|
|
if status == "success" and is_effectively_empty(data):
|
|
# The dangerous one: looks fine, carries nothing. Invisible without
|
|
# this audit -- exactly the issue #264 failure mode.
|
|
empty.append((name, i, example))
|
|
print(f" EMPTY {name} ex{i}: success but empty data args={example}")
|
|
elif status == "error":
|
|
broken.append((name, i, result.get("error", "")))
|
|
err = str(result.get("error", ""))[:120]
|
|
print(f" BROKEN {name} ex{i}: error on its own example -- {err}")
|
|
else:
|
|
ok += 1
|
|
|
|
print(
|
|
f"\nSummary: {len(empty)} empty-success, {len(broken)} broken-example, "
|
|
f"{ok} ok, {no_example} without examples."
|
|
)
|
|
if empty:
|
|
print("\nSilent empty success (stale example or silent failure):")
|
|
for name, i, _ in empty:
|
|
print(f" - {name} (example {i})")
|
|
if broken:
|
|
print("\nBroken examples (tool errors on its own documented example):")
|
|
for name, i, _ in broken:
|
|
print(f" - {name} (example {i})")
|
|
return 1 if (empty or broken) else 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
raise SystemExit(main())
|