Files
mims-harvard__tooluniverse/scripts/audit_empty_success.py
Shanghua Gao 3038dcbee0 Fix #264: adapt OpenTargets tools to EFO→MONDO migration + harden GraphQL not-found handling (#266)
* 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.
2026-06-30 13:16:56 -04:00

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())