Source code for tooluniverse.clingen_tool
"""
ClinGen Database REST API Tool
This tool provides access to ClinGen (Clinical Genome Resource) data including:
- Gene-Disease Validity curations
- Dosage Sensitivity curations
- Clinical Actionability curations
- Variant Pathogenicity data
ClinGen is a NIH-funded resource providing authoritative information on
gene-disease relationships for use in clinical genomics.
"""
import requests
import csv
import io
import re
from concurrent.futures import ThreadPoolExecutor
from typing import Dict, Any, List, Optional
from .base_tool import BaseTool
from .tool_registry import register_tool
# Base URLs for ClinGen APIs
CLINGEN_BASE_URL = "https://search.clinicalgenome.org"
ACTIONABILITY_ADULT_URL = "https://actionability.clinicalgenome.org/ac/Adult/api"
ACTIONABILITY_PEDIATRIC_URL = (
"https://actionability.clinicalgenome.org/ac/Pediatric/api"
)
EREPO_BASE_URL = "https://erepo.clinicalgenome.org/evrepo/api"
# Confirmed live against the Evidence Repository's `classifications` endpoint:
# * `gene`, `caid`, `hgvs` and `variationId` are real server-side filters
# * `variant` is not a parameter at all -- it is accepted and ignored, so the
# request degrades to an unfiltered listing of the whole repository. A
# deliberately bogus `variant=ZZZNOTAVARIANT` returned the same first rows
# as sending no filter whatsoever.
# * the response carries no total, and an unspecified `matchLimit` pages at 25
# Those two facts together understated PAH by 32x -- `total: 25` reported for a
# gene with 817 curated classifications -- and reported curated variants as
# absent, because the old client-side narrowing ran over an arbitrary 25-row
# page: CAID CA16020993 (PAH c.1315+1G>T) is row 401 of PAH's 817, so it was
# answered "No variant classifications found ... Not all genes have active
# VCEPs" when in fact the Phenylketonuria VCEP had classified it.
#
# The largest single gene is RUNX1 at 1,648 rows and the entire repository is
# 13,084 rows, so this cap covers any gene-scoped query outright.
_EREPO_MATCH_LIMIT = 5000
# `data` stays capped so a listing cannot return tens of MB; `total` reports the
# real count beside it and the gap is now disclosed rather than left to be
# inferred from counting rows.
_MAX_PUBLISHED = 100
def _published(
rows: List[Dict[str, Any]], narrow_hint: str, total_is_capped: bool = False
) -> Dict[str, Any]:
"""Publish a capped slice of `rows` and state the cap beside the total.
Every listing in this module caps `data` for payload size while reporting
the full `total` next to it. Undisclosed, that reads as a total which
disagrees with the rows printed beside it: ClinGen_get_gene_validity
published `total: 3659` directly above exactly 100 rows, on a tool whose
own description promises a "comprehensive list".
`truncated` / `truncation_note` follow the repo-wide disclosure convention
(see cbioportal_tool._truncation_fields and clinvar_tool.
_truncation_disclosure) so a caller reading truncation generically does not
need a spelling unique to this module. `total_is_capped` marks the case
where the upstream request itself filled `_EREPO_MATCH_LIMIT`, which makes
`total` a lower bound rather than a count.
"""
published = rows[:_MAX_PUBLISHED]
result: Dict[str, Any] = {
"data": published,
"total": len(rows),
"returned": len(published),
}
if total_is_capped:
result["truncated"] = True
result["truncation_note"] = (
f"The query filled the {_EREPO_MATCH_LIMIT}-row request cap, so "
f"`total` is a lower bound rather than the true count, and `data` "
f"carries the first {len(published)} rows. {narrow_hint}"
)
elif len(published) < len(rows):
result["truncated"] = True
result["truncation_note"] = (
f"`total` is the full count for this query; `data` carries only the "
f"first {len(published)} of them. {narrow_hint}"
)
else:
result["truncated"] = False
return result
def _clean(value: Any) -> Optional[str]:
"""Normalise a filter argument to a non-blank string, or None."""
if value is None:
return None
text = str(value).strip()
return text or None
def _variant_query_param(variant: Any) -> tuple:
"""Map a variant identifier onto the erepo parameter that filters on it.
Returns an (endpoint parameter name, value) pair. `hgvs` is the fallback
because it matches protein-change strings as well as full HGVS -- confirmed
live that `hgvs=p.Arg408Trp` resolves to a single classification.
"""
value = str(variant).strip()
caid = value.upper().removeprefix("CAR:")
if re.fullmatch(r"CA\d+", caid):
return "caid", caid
if value.isdigit():
return "variationId", value
return "hgvs", value
[docs]
@register_tool("ClinGenTool")
class ClinGenTool(BaseTool):
"""
ClinGen Database REST API tool.
Provides access to ClinGen curated data including gene-disease validity,
dosage sensitivity, and clinical actionability.
"""
[docs]
def __init__(self, tool_config):
super().__init__(tool_config)
self.parameter = tool_config.get("parameter", {})
self.required = self.parameter.get("required", [])
fields = tool_config.get("fields", {})
self.operation = fields.get("operation", "")
self.timeout = fields.get("timeout", 60)
[docs]
def run(self, arguments: Dict[str, Any]) -> Dict[str, Any]:
"""Route to operation handler based on config."""
operation = self.operation or arguments.get("operation")
if not operation:
return {"status": "error", "error": "Missing: operation"}
operation_map = {
"get_gene_validity": self._get_gene_validity,
"search_gene_validity": self._search_gene_validity,
"get_dosage_sensitivity": self._get_dosage_sensitivity,
"search_dosage_sensitivity": self._search_dosage_sensitivity,
"get_actionability_adult": self._get_actionability_adult,
"get_actionability_pediatric": self._get_actionability_pediatric,
"search_actionability": self._search_actionability,
"get_variant_classifications": self._get_variant_classifications,
}
handler = operation_map.get(operation)
if not handler:
return {"status": "error", "error": f"Unknown operation: {operation}"}
return handler(arguments)
[docs]
def _get_gene_validity(self, arguments: Dict[str, Any]) -> Dict[str, Any]:
"""Get all gene-disease validity curations from ClinGen."""
try:
url = f"{CLINGEN_BASE_URL}/kb/gene-validity/download"
response = requests.get(url, timeout=self.timeout)
response.raise_for_status()
# Parse CSV response
curations = self._parse_csv(response.text)
# Optional filtering by gene
gene = arguments.get("gene")
if gene:
gene_upper = gene.upper()
# Handle both "GENE SYMBOL" (from CSV) and "Gene Symbol" key formats
curations = [
c
for c in curations
if c.get("GENE SYMBOL", c.get("Gene Symbol", "")).upper()
== gene_upper
]
return {
"status": "success",
**_published(
curations,
"Pass `gene` to narrow to one gene's curations, or use "
"ClinGen_search_gene_validity, which returns every match.",
),
"source": "ClinGen Gene-Disease Validity",
}
except requests.exceptions.Timeout:
return {"status": "error", "error": f"Timeout after {self.timeout}s"}
except requests.exceptions.HTTPError as e:
return {
"status": "error",
"error": f"HTTP {e.response.status_code}: {e.response.text[:200]}",
}
except Exception as e:
return {"status": "error", "error": str(e)}
[docs]
def _search_gene_validity(self, arguments: Dict[str, Any]) -> Dict[str, Any]:
"""Search gene-disease validity curations by gene symbol."""
gene = arguments.get("gene")
if not gene:
return {"status": "error", "error": "Missing required parameter: gene"}
try:
url = f"{CLINGEN_BASE_URL}/kb/gene-validity/download"
response = requests.get(url, timeout=self.timeout)
response.raise_for_status()
# Parse CSV response
curations = self._parse_csv(response.text)
# Filter by gene symbol (case-insensitive, EXACT match -- this
# column holds a single precise HGNC symbol, not free text, and
# substring matching silently pulled in unrelated genes whose
# symbol happens to contain the query, e.g. "OTC" -> "NOTCH1"/
# "NOTCH2" (confirmed live). Mirrors _get_gene_validity's already-
# correct exact-match filter above.
gene_upper = gene.upper()
matches = [
c
for c in curations
if c.get("GENE SYMBOL", c.get("Gene Symbol", "")).upper() == gene_upper
]
return {
"status": "success",
"data": matches,
"total": len(matches),
"gene_searched": gene,
"source": "ClinGen Gene-Disease Validity",
}
except requests.exceptions.Timeout:
return {"status": "error", "error": f"Timeout after {self.timeout}s"}
except requests.exceptions.HTTPError as e:
return {
"status": "error",
"error": f"HTTP {e.response.status_code}: {e.response.text[:200]}",
}
except Exception as e:
return {"status": "error", "error": str(e)}
[docs]
def _get_dosage_sensitivity(self, arguments: Dict[str, Any]) -> Dict[str, Any]:
"""Get all dosage sensitivity curations from ClinGen."""
include_regions = arguments.get("include_regions", False)
try:
if include_regions:
url = f"{CLINGEN_BASE_URL}/kb/gene-dosage/downloadall"
else:
url = f"{CLINGEN_BASE_URL}/kb/gene-dosage/download"
response = requests.get(url, timeout=self.timeout)
response.raise_for_status()
# Parse CSV response
curations = self._parse_csv(response.text)
# Optional filtering by gene -- EXACT match (case-insensitive):
# this column holds a single precise HGNC symbol, not free text,
# and substring matching silently pulled in unrelated genes
# whose symbol happens to contain the query, e.g. "OTC" ->
# "NOTCH1"/"NOTCH2" (confirmed live).
gene = arguments.get("gene")
if gene:
gene_upper = gene.upper()
# Handle both "GENE SYMBOL" and "Gene Symbol" key formats
curations = [
c
for c in curations
if c.get("GENE SYMBOL", c.get("Gene Symbol", "")).upper()
== gene_upper
]
return {
"status": "success",
**_published(
curations,
"Pass `gene` to narrow to one gene's dosage curations.",
),
"include_regions": include_regions,
"source": "ClinGen Dosage Sensitivity",
}
except requests.exceptions.Timeout:
return {"status": "error", "error": f"Timeout after {self.timeout}s"}
except requests.exceptions.HTTPError as e:
return {
"status": "error",
"error": f"HTTP {e.response.status_code}: {e.response.text[:200]}",
}
except Exception as e:
return {"status": "error", "error": str(e)}
[docs]
def _search_dosage_sensitivity(self, arguments: Dict[str, Any]) -> Dict[str, Any]:
"""Search dosage sensitivity curations by gene symbol."""
gene = arguments.get("gene")
if not gene:
return {"status": "error", "error": "Missing required parameter: gene"}
try:
# Use the simpler download endpoint (genes only) for searching
url = f"{CLINGEN_BASE_URL}/kb/gene-dosage/download"
response = requests.get(url, timeout=self.timeout)
response.raise_for_status()
# Parse CSV response
curations = self._parse_csv(response.text)
# Filter by gene symbol (case-insensitive, EXACT match -- this
# column holds a single precise HGNC symbol, not free text, and
# substring matching silently pulled in unrelated genes whose
# symbol happens to contain the query, e.g. "OTC" -> "NOTCH1"/
# "NOTCH2" (confirmed live).
# Handle different column name formats from different endpoints
gene_upper = gene.upper()
matches = [
c
for c in curations
if c.get(
"GENE SYMBOL", c.get("GENE/REGION", c.get("Gene Symbol", ""))
).upper()
== gene_upper
]
return {
"status": "success",
"data": matches,
"total": len(matches),
"gene_searched": gene,
"source": "ClinGen Dosage Sensitivity",
}
except requests.exceptions.Timeout:
return {"status": "error", "error": f"Timeout after {self.timeout}s"}
except requests.exceptions.HTTPError as e:
return {
"status": "error",
"error": f"HTTP {e.response.status_code}: {e.response.text[:200]}",
}
except Exception as e:
return {"status": "error", "error": str(e)}
[docs]
def _get_actionability_adult(self, arguments: Dict[str, Any]) -> Dict[str, Any]:
"""Get clinical actionability curations for adult context."""
return self._get_actionability(arguments, "Adult")
[docs]
def _get_actionability_pediatric(self, arguments: Dict[str, Any]) -> Dict[str, Any]:
"""Get clinical actionability curations for pediatric context."""
return self._get_actionability(arguments, "Pediatric")
[docs]
@staticmethod
def _actionability_rows_to_dicts(data: Any) -> list:
"""The ?flavor=flat actionability API returns a columnar table --
{"columns": [...], "rows": [[...], ...]} -- not a list of record
dicts (confirmed live). Neither older code path recognized this
shape (isinstance(data, list) is False and there's no "data" key),
so the gene filter silently matched nothing and the raw un-parsed
table was returned as-is. Zip columns with each row into dicts.
"""
if isinstance(data, list):
return data
if isinstance(data, dict) and isinstance(data.get("columns"), list):
columns = data["columns"]
return [
dict(zip(columns, row))
for row in data.get("rows", [])
if isinstance(row, list)
]
if isinstance(data, dict) and isinstance(data.get("data"), list):
return data["data"]
return []
[docs]
def _get_actionability(
self, arguments: Dict[str, Any], context: str
) -> Dict[str, Any]:
"""Get clinical actionability curations for a specific context."""
try:
base_url = (
ACTIONABILITY_ADULT_URL
if context == "Adult"
else ACTIONABILITY_PEDIATRIC_URL
)
# Use flat format for easier parsing
url = f"{base_url}/summ?flavor=flat"
headers = {"Accept": "application/json"}
response = requests.get(url, headers=headers, timeout=self.timeout)
response.raise_for_status()
curations = self._actionability_rows_to_dicts(response.json())
# Optional filtering by gene. geneOrVariant is a comma-joined
# multi-gene field for panel curations (e.g. "BRCA1,BRCA2"), so
# substring match rather than exact-match the whole field.
gene = arguments.get("gene")
if gene:
gene_upper = gene.upper()
curations = [
c
for c in curations
if gene_upper in str(c.get("geneOrVariant", "")).upper()
or gene_upper in str(c.get("gene", "")).upper()
or gene_upper in str(c.get("Gene", "")).upper()
or gene_upper in str(c.get("hgncId", "")).upper()
]
return {
"status": "success",
**_published(
curations,
"Pass `gene` to narrow to one gene's actionability curations.",
),
"context": context,
"source": f"ClinGen Clinical Actionability ({context})",
}
except requests.exceptions.Timeout:
return {"status": "error", "error": f"Timeout after {self.timeout}s"}
except requests.exceptions.HTTPError as e:
return {
"status": "error",
"error": f"HTTP {e.response.status_code}: {e.response.text[:200]}",
}
except Exception as e:
return {"status": "error", "error": str(e)}
[docs]
def _fetch_actionability_context(
self, base_url: str, gene: str
) -> List[Dict[str, Any]]:
"""Fetch and gene-filter one actionability context (Adult/Pediatric).
Raises on failure -- the caller decides how to handle a failed context."""
url = f"{base_url}/summ?flavor=flat"
headers = {"Accept": "application/json"}
response = requests.get(url, headers=headers, timeout=self.timeout)
response.raise_for_status()
curations = self._actionability_rows_to_dicts(response.json())
# Filter by gene. geneOrVariant is a comma-joined multi-gene field
# for panel curations, so substring match rather than exact-match
# the whole field.
gene_upper = gene.upper()
return [
c
for c in curations
if gene_upper in str(c.get("geneOrVariant", "")).upper()
or gene_upper in str(c.get("gene", "")).upper()
or gene_upper in str(c.get("Gene", "")).upper()
or gene_upper in str(c.get("hgncId", "")).upper()
]
[docs]
def _search_actionability(self, arguments: Dict[str, Any]) -> Dict[str, Any]:
"""Search clinical actionability across both adult and pediatric contexts."""
gene = arguments.get("gene")
if not gene:
return {"status": "error", "error": "Missing required parameter: gene"}
try:
# Fix-R53A-1: the Adult and Pediatric actionability endpoints are
# each independently slow (confirmed live: the Adult endpoint
# alone took ~124s for a 138KB response -- expensive server-side
# computation, not a network issue) but are fetched one after
# the other despite being fully independent requests, so a
# caller paid the sum of both (up to ~4 minutes, and close to
# this tool's own 120s per-request timeout budget even for a
# single context). Fetching them concurrently bounds the wait
# to the slower of the two instead of the sum of both.
contexts = [
("Adult", ACTIONABILITY_ADULT_URL),
("Pediatric", ACTIONABILITY_PEDIATRIC_URL),
]
results = {"Adult": [], "Pediatric": []}
# Fix-R43-1: a failed context must not read as a genuine zero, so
# record which ones failed rather than swallowing the exception.
failures: Dict[str, str] = {}
with ThreadPoolExecutor(max_workers=len(contexts)) as executor:
futures = {
executor.submit(
self._fetch_actionability_context, base_url, gene
): context
for context, base_url in contexts
}
for future in futures:
context = futures[future]
try:
results[context] = future.result()
except Exception as exc:
failures[context] = f"{type(exc).__name__}: {exc}"
response: Dict[str, Any] = {
"gene_searched": gene,
"source": "ClinGen Clinical Actionability",
}
if failures:
response["failed_contexts"] = failures
if len(failures) == len(contexts):
# Both counts would be a fabricated zero. proteins_api_tool
# likewise reports error when nothing at all was retrieved.
response["status"] = "error"
response["error"] = (
f"All ClinGen actionability contexts failed to fetch for "
f"{gene}; see failed_contexts. No count can be reported."
)
return response
response.update(
status="success",
data=results,
adult_count=len(results["Adult"]),
pediatric_count=len(results["Pediatric"]),
)
if failures:
response["note"] = (
f"Partial result: {', '.join(sorted(failures))} could not be "
"fetched, so its count of 0 means 'not retrieved', not "
"'no curation exists'. Retry for a complete answer."
)
return response
except Exception as e:
return {"status": "error", "error": str(e)}
[docs]
@staticmethod
def _flatten_classification(item: Dict[str, Any]) -> Dict[str, Any]:
"""Map one `classifications` JSON record to the tool's
declared 7-field return_schema (Variation, ClinVar Variation Id,
HGNC Gene Symbol, Disease, Mondo Id, Assertion, Expert Panel)."""
hgvs = item.get("hgvs") or []
gene = item.get("gene") or {}
condition = item.get("condition") or {}
guidelines = item.get("guidelines") or []
first_guideline = guidelines[0] if guidelines else {}
agents = first_guideline.get("agents") or []
first_agent = agents[0] if agents else {}
return {
# The last hgvs entry is the gene-and-protein-notation form,
# e.g. "NM_000277.2(PAH):c.1A>G (p.Met1Val)" -- matches the old
# TSV "Variation" column's format.
"Variation": hgvs[-1] if hgvs else None,
"ClinVar Variation Id": item.get("variationId"),
"HGNC Gene Symbol": gene.get("label"),
"Disease": condition.get("label"),
"Mondo Id": condition.get("@id"),
"Assertion": (first_guideline.get("outcome") or {}).get("label"),
"Expert Panel": first_agent.get("affiliation"),
}
[docs]
def _get_variant_classifications(self, arguments: Dict[str, Any]) -> Dict[str, Any]:
"""Get variant pathogenicity classifications from ClinGen Evidence Repository.
classifications/all fetches the *entire* ~22MB, ~10k-row repository
with no server-side filter and filters client-side -- confirmed live
this routinely took 2-5+ minutes (and sometimes hung past a 300s
client timeout) even for a gene with zero curated variants. The
classifications endpoint (no /all) supports real server-side filtering.
Calling it with neither gene nor variant set is just as unbounded as
classifications/all (confirmed live: also hangs past 30s with no
params), so require at least one.
Every filter is applied by the server: `gene` directly, and `variant`
via whichever of `caid`/`variationId`/`hgvs` matches the identifier
supplied. See `_variant_query_param` and `_EREPO_MATCH_LIMIT` for why
the tool must name the parameter the endpoint actually implements and
must state its own page size.
Cost of stating the page size: asking for every row is what makes
`total` a count rather than a page size, and the endpoint reports no
total of its own, so there is no cheaper way to get it. Measured live,
gene queries go from 44 KB / 0.8s to 1.5 MB / ~5s (PAH) and 8.1 MB /
~15s at the worst case in the repository (RUNX1, 1,648 rows). That sits
inside this tool's 120s timeout with roughly 8x headroom. Identifier
lookups are unaffected -- `caid` returns ~2 KB in ~0.6s either way.
"""
# Strip before the guard, not after. A whitespace-only `variant` is
# truthy, so it satisfied "at least one filter" and then resolved to an
# empty `hgvs=`, sending exactly the unfiltered request this guard
# exists to prevent -- measured live at a full 120s timeout, with the
# cost paid upstream as well as here.
gene = _clean(arguments.get("gene"))
variant = _clean(arguments.get("variant"))
if not gene and not variant:
return {
"status": "error",
"error": (
"gene or variant parameter is required. The ClinGen "
"Evidence Repository's classifications endpoint has no "
"working unfiltered listing (an unfiltered request is "
"just as unbounded as the bulk classifications/all "
"download) -- query by gene and/or variant instead."
),
}
try:
params: Dict[str, Any] = {"matchLimit": _EREPO_MATCH_LIMIT}
if gene:
params["gene"] = gene
if variant:
variant_key, variant_value = _variant_query_param(variant)
params[variant_key] = variant_value
response = requests.get(
f"{EREPO_BASE_URL}/classifications", params=params, timeout=self.timeout
)
response.raise_for_status()
payload = response.json()
items = payload.get("variantInterpretations", [])
# Flattening every row and publishing 100 wastes ~0.6ms at the
# repository's worst case (RUNX1, 1,648 rows), against a ~15s round
# trip. Slicing first would make `total` count the slice instead of
# the query, which is the defect this whole change exists to fix,
# so the list stays whole.
data = [self._flatten_classification(item) for item in items]
result = {
"status": "success",
**_published(
data,
"Pass `variant` (a ClinGen CAID, a ClinVar VariationID or an "
"HGVS/protein-change string) to retrieve a specific row.",
# No gene can fill the cap -- the largest is RUNX1 at 1,648
# -- but `hgvs` matches on substring, so a short fragment
# can sweep the repository. Reachable, and `total` would be
# a floor rather than a count if it were reached.
total_is_capped=len(data) >= _EREPO_MATCH_LIMIT,
),
"source": "ClinGen Evidence Repository",
}
if not data:
if gene:
result["note"] = (
f"No variant classifications found for gene '{gene}'. "
"The ClinGen Evidence Repository only contains variants "
"curated by Variant Curation Expert Panels (VCEPs), and "
"not all genes have an active VCEP. Try "
"ClinGen_get_gene_validity for gene-disease validity or "
"ClinVar_search_variants for ClinVar classifications."
)
else:
result["note"] = (
f"No variant classification found for variant '{variant}', "
f"queried as `{variant_key}` server-side across the whole "
"Evidence Repository, so the variant is genuinely "
"uncurated rather than merely missing from a page of "
"results. Supply `gene` to list every curated variant "
"for its gene, or use ClinVar_search_variants for "
"ClinVar classifications."
)
return result
except requests.exceptions.Timeout:
return {"status": "error", "error": f"Timeout after {self.timeout}s"}
except requests.exceptions.HTTPError as e:
return {
"status": "error",
"error": f"HTTP {e.response.status_code}: {e.response.text[:200]}",
}
except Exception as e:
return {"status": "error", "error": str(e)}
[docs]
def _parse_csv(self, csv_text: str) -> List[Dict[str, Any]]:
"""Parse CSV text into list of dictionaries.
Handles ClinGen's special CSV format which has metadata headers
before the actual data rows.
"""
result = []
try:
lines = csv_text.strip().split("\n")
# Find the header row (contains "GENE SYMBOL" or similar)
header_idx = None
for i, line in enumerate(lines):
if "GENE SYMBOL" in line.upper():
header_idx = i
break
if header_idx is None:
# Fallback - try standard CSV parsing
reader = csv.DictReader(io.StringIO(csv_text))
for row in reader:
cleaned = {k: v for k, v in row.items() if v and k}
if cleaned:
result.append(cleaned)
return result
# Find where actual data starts (skip separator row after header)
data_start = header_idx + 1
if data_start < len(lines) and "+++++" in lines[data_start]:
data_start += 1
# Build new CSV content: header + data rows
header_line = lines[header_idx]
data_lines = [line for line in lines[data_start:] if "+++++" not in line]
csv_content = header_line + "\n" + "\n".join(data_lines)
# Use StringIO to read CSV from string
reader = csv.DictReader(io.StringIO(csv_content))
for row in reader:
# Clean up the row - remove empty values
cleaned = {}
for k, v in row.items():
if v and k:
# Strip whitespace
k = k.strip()
v = v.strip()
if v:
cleaned[k] = v
if cleaned and len(cleaned) > 2: # Must have meaningful data
result.append(cleaned)
except Exception:
# Log but don't fail
pass
return result