# 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
import re
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(). Two words are
# the same term when the longer one is the shorter one plus no more than
# _MAX_SUFFIX_LEFT trailing characters -- the length of an English
# inflectional ending. _MIN_WORD_LEN keeps short words from colliding by
# coincidence ("renal"/"renin" share "ren" and are otherwise the same shape).
#
# There used to be a second threshold here bounding how much was trimmed off
# the SHORTER word to reach the shared prefix. It is gone because at equal
# values it is dead code, not policy: `longer - prefix <= N` and
# `prefix <= shorter <= longer` together give `prefix >= longer - N >=
# shorter - N`, so the trim bound can never reject a pair the suffix bound
# accepts. Verified as well as argued -- 298,378 pairs over the live panel
# vocabulary and 400,000 synthetic pairs, zero disagreements.
_MIN_WORD_LEN = 6
_MAX_SUFFIX_LEFT = 3
# Panel names are prose, so a term is routinely followed by a comma or closing
# parenthesis ("...cerebellar anomalies, childhood onset", "(Lynch syndrome)")
# and is hyphenated as often as spaced ("non-syndromic"). Splitting on
# whitespace alone leaves the punctuation glued to the word, where it defeats
# any exact or suffix-sensitive comparison. Known cost of splitting hyphens:
# "non-syndromic" yields "syndromic", so search="syndrome" matches the three
# panels that are explicitly NON-syndromic. Word-level matching has no notion
# of negation; the alternative is losing every hyphenated compound, which is
# worse.
_WORD_RE = re.compile(r"[a-z0-9]+")
def _words(text: str) -> list:
"""Split lowercase text into comparable words, dropping punctuation."""
return _WORD_RE.findall(text)
def _word_matches(query_word: str, haystack_word: str) -> bool:
"""True if two words are the same disease term modulo simple English
inflection ("haemoglobinopathy"/"haemoglobinopathies",
"diabetes"/"diabetic"). 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.
Fix-47-2: this used to bound only how much was trimmed off the SHORTER
word to reach the shared prefix, and never bounded what was left dangling
off the LONGER one. That made every short-ish query a prefix search with
three characters of slack, and the extra characters on the other side
could be a whole different word:
"hernia" vs "hereditary" -> shared "her", "editary" dangling
"sarcoma" vs "sarcoidosis" -> shared "sarco", "idosis" dangling
"myopia" vs "myopathy" -> shared "myop", "athy" dangling
"anaemia" vs "anaesthesia" -> shared "anae", "sthesia" dangling
"disease" vs "distal" -> shared "dis", "tal" dangling
"neuropathy" vs "neuronal"/"neural"/"neuron"
all returned True. Measured against the live 433-panel PanelApp list,
`search="hernia"` returned 15 panels, every one of them a "Hereditary ..."
panel with no hernia content, presented under a `count` of 15 and a note
describing the match as being against name/disease_group/disease_sub_group
-- wrong data, legitimised by a count. `search="myopia"` returned 3
myopathy panels (an eye disorder answered with muscle-disease panels).
Bounding what dangles off the LONGER word is the fix: a true inflection is
a short ending, while every one of these collisions is lopsided. The
inflection classes that matter are kept -- singulars/plurals
("anomaly"/"anomalies", "dystrophy"/"dystrophies"), the medical adjectival
forms ("diabetes"/"diabetic", "syndrome"/"syndromic", "tumour"/"tumoral")
and the "-osis"/"-otic" alternation ("thrombosis"/"thrombotic").
It is NOT free, and the cost is concentrated where PanelApp's own
`disease_group` vocabulary uses a derived form four or more characters
longer than the query. Measured over 214 queries against the live
433-panel list: ~198 wrong panel-hits removed, but ~51 real ones lost too,
mostly "cardiac"/"cardiology" (15 panels, including Brugada, ARVC and the
long/short-QT panels) and "immune"/"immunology" (11), plus
"muscle"/"muscular" (12), "arrhythmia"/"arrhythmogenic" (6),
"pigmentation"/"pigmentary" (3) and "retinopathy"/"retinal" (1). No
threshold separates these from the collisions above -- "cardiac"/
"cardiology" needs five dangling characters and "sarcoma"/"sarcoidosis"
has six -- so widening the bound just restores the false positives. The
trade is deliberate: a loss shows up as an honest empty result carrying
the "try PanelApp_search_genes" note, whereas the old behaviour returned
another disease's panels under a confident `count`. Callers who hit an
empty result should retry with the other word form.
"""
if not query_word or not haystack_word:
return False
if query_word == haystack_word:
return True
if len(query_word) < _MIN_WORD_LEN or len(haystack_word) < _MIN_WORD_LEN:
return False
shorter = min(len(query_word), len(haystack_word))
longer = max(len(query_word), len(haystack_word))
# Exact O(1) prefilter, not an approximation: the bound below needs
# `longer - common_prefix <= _MAX_SUFFIX_LEFT`, and `common_prefix` can
# never exceed `shorter`, so any pair further apart in length than
# _MAX_SUFFIX_LEFT is already rejected. Checking it first keeps the
# allocating character scan off the great majority of word pairs (this
# runs over every word of every one of ~433 panels, per query word).
if longer - shorter > _MAX_SUFFIX_LEFT:
return False
common_prefix = len(os.path.commonprefix([query_word, haystack_word]))
return longer - common_prefix <= _MAX_SUFFIX_LEFT