Source code for tooluniverse.panelapp_tool
# panelapp_tool.py
"""PanelApp panel search tool for ToolUniverse.
PanelApp's `/panels/` endpoint silently ignores substring search params --
confirmed live: `search=`, `q=`, and `name__icontains=` all return the
unfiltered, unranked list of all 434 panels regardless of value; only an
exact full-string `name=` match filters anything (its OpenAPI schema
documents no search param at all, only `type` and `page`). Since the API
can't filter server-side, this fetches every panel (paginating the
API's fixed page_size=100) and filters client-side by substring match
against name/disease_group/disease_sub_group.
"""
import os
from typing import Any, Dict
from .base_rest_tool import BaseRESTTool
from .tool_registry import register_tool
PANELS_URL = "https://panelapp.genomicsengland.co.uk/api/v1/panels/"
_MAX_PAGES = 10 # safety cap; ~434 panels / 100 per page = 5 pages today
# Thresholds for the inflection heuristic in _word_matches(). English
# plural/adjectival suffixes (e.g. "y"->"ies", "-opathy"->"-opathies") are
# usually 1-3 characters, so a shared prefix within 3 characters of the
# shorter word's length is treated as the same term. _MIN_WORD_LEN and
# _MAX_LEN_DIFF guard against short/dissimilar-length words matching by
# coincidence (e.g. "cardiac" vs "cardiomyopathy" must NOT match).
_MIN_WORD_LEN = 6
_MAX_LEN_DIFF = 4
_MAX_SUFFIX_DROP = 3
def _word_matches(query_word: str, haystack_word: str) -> bool:
"""True if two words are the same disease term modulo simple English
inflection (singular/plural, e.g. "haemoglobinopathy" vs
"haemoglobinopathies"). Deliberately NOT a general substring match --
e.g. "myopathy" is a literal substring of "cardiomyopathy" but they are
different, unrelated panel topics, so containment alone is too loose.
Only an exact match, or a shared prefix between two words of similar
length (inflectional suffixes differ by a couple of characters, not by
a whole extra word), counts as the same term.
"""
if not query_word or not haystack_word:
return False
if query_word == haystack_word:
return True
if (
len(query_word) >= _MIN_WORD_LEN
and len(haystack_word) >= _MIN_WORD_LEN
and abs(len(query_word) - len(haystack_word)) <= _MAX_LEN_DIFF
):
shorter = min(len(query_word), len(haystack_word))
common_prefix = len(os.path.commonprefix([query_word, haystack_word]))
return common_prefix >= shorter - _MAX_SUFFIX_DROP
return False
[docs]
@register_tool("PanelAppSearchTool")
class PanelAppSearchTool(BaseRESTTool):
[docs]
def run(self, arguments: Dict[str, Any]) -> Dict[str, Any]:
search = (arguments.get("search") or "").strip().lower()
if not search:
return {"status": "error", "error": "'search' is required"}
panels = []
url = PANELS_URL
params = {"format": "json"}
for _ in range(_MAX_PAGES):
try:
resp = self.session.get(url, params=params, timeout=self.timeout)
resp.raise_for_status()
page = resp.json()
except Exception as e:
return {"status": "error", "error": f"PanelApp API error: {e}"}
panels.extend(page.get("results", []))
url = page.get("next")
params = None # `next` already includes all query params
if not url:
break
# Query-derived, so computed once outside the per-panel loop below.
query_words = search.split()
def matches(p: Dict[str, Any]) -> bool:
haystack = " ".join(
str(p.get(k) or "")
for k in ("name", "disease_group", "disease_sub_group")
).lower()
if search in haystack:
return True
# Plain substring matching misses simple English inflection --
# e.g. query "haemoglobinopathy" (singular) against panel name
# "...haemoglobinopathies" (plural) never matches even though a
# clinician typing the singular disease name expects a hit.
# Fall back to a per-word fuzzy-prefix match: every query word
# must share a long common prefix with some haystack word.
haystack_words = haystack.split()
return all(
any(_word_matches(qw, hw) for hw in haystack_words)
for qw in query_words
)
results = [p for p in panels if matches(p)]
note = (
"PanelApp's API has no server-side search filter, so this "
"matches client-side against name/disease_group/"
"disease_sub_group across all panels."
)
if not results:
# This only searches panel-level metadata, which doesn't
# include every gene-level phenotype term (e.g. "haemophilia"
# doesn't appear in any panel name/disease_group text -- it's
# only reachable through the genes it curates, F8/F9). Point
# the caller at the gene-level fallback instead of a dead end.
note += (
" No panel matched this term in its name/disease_group/"
"disease_sub_group metadata. If you're looking for a "
"condition by its causal gene(s) instead, try "
"PanelApp_search_genes with the gene symbol."
)
return {
"status": "success",
"data": {
"count": len(results),
"next": None,
"previous": None,
"results": results,
},
"metadata": {
"query": arguments.get("search"),
"total_panels_searched": len(panels),
"note": note,
},
}