Source code for tooluniverse.sabdab_tool

"""
SAbDab (Structural Antibody Database) tool for ToolUniverse.

SAbDab is a database containing all antibody structures from the PDB,
annotated with CDR sequences, chain pairings, and other structural features.

Website: https://opig.stats.ox.ac.uk/webapps/sabdab-sabpred/sabdab
"""

import csv
import io
import requests
from typing import Dict, Any
from .base_tool import BaseTool
from .tool_registry import register_tool

# SAbDab base URL
SABDAB_BASE_URL = "https://opig.stats.ox.ac.uk/webapps/sabdab-sabpred/sabdab"
# SAbDab 2 serves JSON here. The site itself answers 200 text/html for every
# path, including ones that look like data routes, which is why this tool used
# to report "SAbDab appears to have migrated ... with no public summary-data
# API currently reachable" -- the API is reachable, just not where it looked.
# `GET {SABDAB_API_URL}/` returns {"message": "Welcome to the SAbDab API!"} and
# {SABDAB_API_URL}/openapi.json identifies it as SAbDab 2.1.4.
#
# NEVER append a trailing slash to an API path. That is the whole reason this
# API was written off as unreachable, and it is reproducible:
#   /api/pdb/pdb_00003hfm   -> 200 JSON
#   /api/pdb/pdb_00003hfm/  -> 307 http://backend:8000/pdb/pdb_00003hfm
# The redirect target is an internal hostname that does not resolve off the
# cluster, so the slash form fails DNS rather than returning data -- which is
# exactly what tests/unit/test_sabdab_spa_migration_detection.py recorded as
# "307-redirects to an internal-only hostname, not publicly resolvable".
SABDAB_API_URL = f"{SABDAB_BASE_URL}/api"

# `sabdab_chain_type` vocabulary, tallied over 286 chains in 40 structures:
# H heavy (98), N non-antibody (92), K kappa light (50), L lambda light (42),
# M single-chain construct such as scFv16 (4). Light chains are K *or* L, so
# testing for "L" alone drops every kappa chain -- and classifying antigen as
# "not H or L" then files those kappa chains, and scFvs, as antigen. 3hfm is
# the case that showed it: its light chain is K on auth_asym_id "L".
SABDAB_HEAVY_TYPES = frozenset({"H"})
SABDAB_LIGHT_TYPES = frozenset({"K", "L"})
SABDAB_SINGLE_CHAIN_TYPES = frozenset({"M"})
SABDAB_ANTIGEN_TYPES = frozenset({"N"})


[docs] @register_tool("SAbDabTool") class SAbDabTool(BaseTool): """ Tool for querying SAbDab structural antibody database. SAbDab provides: - Antibody structures from PDB - CDR (complementarity-determining region) annotations - Heavy/light chain pairing information - Antigen binding information No authentication required. """
[docs] def __init__(self, tool_config: Dict[str, Any]): super().__init__(tool_config) self.timeout: int = tool_config.get("timeout", 60) self.parameter = tool_config.get("parameter", {})
[docs] @staticmethod def _extended_pdb_id(pdb_id: str) -> str: """SAbDab 2 keys entries by the extended PDB ID, not the 4-char code. `/api/pdb/3hfm` answers 404 "No PDB entry found with pdb_id='3hfm'" -- a real route rejecting a real structure, which is what made the old code look like the API was gone. `/api/pdb/pdb_00003hfm` returns it. An id that is already extended is passed through unchanged. """ code = (pdb_id or "").strip().lower() if code.startswith("pdb_"): return code return f"pdb_0000{code}"
[docs] def _api_get(self, path: str, params=None): """GET one API path. Returns (payload, error_dict); exactly one is None.""" url = f"{SABDAB_API_URL}{path}" try: response = requests.get( url, params=params or {}, timeout=self.timeout, headers={ "Accept": "application/json", "User-Agent": "ToolUniverse/SAbDab", }, ) except requests.exceptions.RequestException as exc: return None, { "status": "error", "error": f"SAbDab request failed: {exc}", "url": url, } if response.status_code == 404: return None, {"status": "not_found", "url": url} if response.status_code != 200: return None, { "status": "error", "error": f"SAbDab API returned HTTP {response.status_code}", "url": url, } try: return response.json(), None except ValueError: # The surrounding site answers 200 text/html for unknown paths, so # a non-JSON 200 means the path is not an API route. return None, { "status": "error", "error": ( "SAbDab returned a 200 that is not JSON, so this path is " "not an API route" ), "url": url, }
[docs] def _chain_rows(self, entry: dict) -> list: """Chain inventory: type, organism and sequence, per polymer instance.""" rows = [] for chain in entry.get("polymer_instances") or []: rows.append( { "chain": chain.get("sabdab_auth_asym_id") or chain.get("pdb_auth_asym_id"), "chain_type": chain.get("sabdab_chain_type"), "name": chain.get("name"), "entity_type": chain.get("entity_type"), "organism_scientific": chain.get("organism_scientific"), "organism_common": chain.get("organism_common"), "sequence": chain.get("sequence"), "resolved_sequence": chain.get("resolved_sequence"), } ) return rows
[docs] @staticmethod def _antibody_rows(entry: dict) -> list: """One row per paired antibody found in the structure.""" rows = [] for instance in entry.get("antibody_instances") or []: rows.append( { "id": instance.get("id"), "antibody_id": instance.get("antibody_id"), "type": instance.get("type"), "pairing_distance": instance.get("pairing_distance"), "any_cdr_coords_resolved": instance.get( "any_cdr_coords_resolved" ), "all_cdr_coords_resolved": instance.get( "all_cdr_coords_resolved" ), } ) return rows
[docs] def run(self, arguments: Dict[str, Any]) -> Dict[str, Any]: """Execute SAbDab query based on operation type.""" operation = arguments.get("operation", "") # Auto-fill operation from tool config const if not provided by user if not operation: operation = self.get_schema_const_operation() if operation == "search_structures": return self._search_structures(arguments) elif operation == "get_structure": return self._get_structure(arguments) elif operation == "get_structure_summary": return self._get_structure_summary(arguments) elif operation == "get_summary": return self._get_summary(arguments) else: return { "status": "error", "error": f"Unknown operation: {operation}. Supported: search_structures, get_structure, get_structure_summary, get_summary", }
[docs] def _search_structures(self, arguments: Dict[str, Any]) -> Dict[str, Any]: """SAbDab 2 has no search route, so say so with the facts attached. Checked exhaustively against /api/pdb: name, head, keywords, name__contains, filter, text, search, q, resolution__lte and structure_method are all accepted and all ignored -- `total` comes back as the full catalogue size every time. Filtering locally is not viable either: `limit` is capped (1000 is rejected 422) and a 200-row page is 2.0 MB over 1.7 s, so the 11,667 entries would be 59 requests and ~117 MB for one tool call. The previous version returned a browse URL with the claim that "SAbDab search does not expose a JSON API", which is no longer true and left the caller nowhere to go. This says nothing was searched -- so an empty answer is not evidence of absence -- and names the routes that work. """ query = (arguments.get("query") or arguments.get("antigen") or "").strip() totals, failure = self._api_get("/pdb", params={"limit": 1}) catalogue_size = None if failure is not None else (totals or {}).get("total") browse = f"{SABDAB_BASE_URL}/search/" return { "status": "success", "data": { "query": query, "searched": False, "reason": ( "The SAbDab 2 API exposes entry and collection routes but " "no search route: every filter parameter is accepted and " "ignored. Nothing was searched, so an empty result here is " "not evidence that the structure is absent." ), "catalogue_size": catalogue_size, "browse_url": f"{browse}?q={query}" if query else browse, "next_steps": [ "With a PDB ID, use SAbDab_get_structure_summary or " "SAbDab_get_structure for the curated annotations.", "To find candidate PDB IDs by text, search RCSB (the " "rcsb_pdb tools) and look each one up here; SAbDab holds " "antibody and nanobody structures only.", ], }, "metadata": { "source": "SAbDab 2 API", "url": f"{SABDAB_API_URL}/pdb", }, }
[docs] def _get_structure(self, arguments: Dict[str, Any]) -> Dict[str, Any]: """Structure-level antibody record: chains, pairings, organisms. The description has always promised "CDR annotations, chain information, and antigen binding data", but the implementation fetched {SABDAB_BASE_URL}/pdb/{id}/ and expected a coordinate file, so it reported "got non-PDB content, likely an HTML page". SAbDab 2 has no coordinate route at all -- /structure, /file, /download and /{id}.pdb all 404 under /api -- and coordinates were never what this tool said it returned. RCSB serves those, and ToolUniverse covers it through the rcsb_pdb tools. """ pdb_id = arguments.get("pdb_id") or arguments.get("pdb_code") or "" if not pdb_id: return {"status": "error", "error": "Missing required parameter: pdb_id"} extended = self._extended_pdb_id(pdb_id) entry, failure = self._api_get(f"/pdb/{extended}") if failure is not None: if failure.get("status") == "not_found": return { "status": "error", "error": ( f"SAbDab has no entry for '{pdb_id}'. It holds antibody " "and nanobody structures only, so a PDB entry without " "one is absent rather than missing." ), "url": failure["url"], } return failure return { "status": "success", "data": { "pdb_id": entry.get("id"), "query_pdb_id": pdb_id, "name": entry.get("name"), "head": entry.get("head"), "resolution": entry.get("resolution"), "structure_method": entry.get("structure_method"), "chains": self._chain_rows(entry), "antibodies": self._antibody_rows(entry), "coordinates": ( "SAbDab serves no coordinate files; use the rcsb_pdb tools " f"or https://files.rcsb.org/download/{pdb_id.upper()}.pdb" ), }, "metadata": { "source": "SAbDab 2 API", "url": f"{SABDAB_API_URL}/pdb/{extended}", }, }
[docs] def _get_structure_summary(self, arguments: Dict[str, Any]) -> Dict[str, Any]: """The curated per-structure fields, the way the summary TSV read. Same API entry as _get_structure, projected differently: this is the one-row view (method, resolution, dates, antibody type, chain species), while _get_structure returns the full chain and pairing inventory. """ pdb_id = ( arguments.get("pdb_id") or arguments.get("pdb_code") or arguments.get("pdb") or "" ) if not pdb_id: return {"status": "error", "error": "Missing required parameter: pdb_id"} extended = self._extended_pdb_id(pdb_id) entry, failure = self._api_get(f"/pdb/{extended}") if failure is not None: if failure.get("status") == "not_found": return { "status": "error", "error": ( f"SAbDab has no entry for '{pdb_id}'. It holds antibody " "and nanobody structures only." ), "url": failure["url"], } return failure chains = self._chain_rows(entry) heavy = [c for c in chains if c.get("chain_type") in SABDAB_HEAVY_TYPES] light = [c for c in chains if c.get("chain_type") in SABDAB_LIGHT_TYPES] single = [ c for c in chains if c.get("chain_type") in SABDAB_SINGLE_CHAIN_TYPES ] # Only N is the antigen side. Treating "not heavy or light" as antigen # files kappa chains and scFv constructs as antigen, and a chain type # SAbDab adds later would silently join them. antigen = [c for c in chains if c.get("chain_type") in SABDAB_ANTIGEN_TYPES] unclassified = [ c for c in chains if c.get("chain_type") not in SABDAB_HEAVY_TYPES | SABDAB_LIGHT_TYPES | SABDAB_SINGLE_CHAIN_TYPES | SABDAB_ANTIGEN_TYPES ] antibodies = self._antibody_rows(entry) return { "status": "success", "data": { "pdb_id": entry.get("id"), "query_pdb_id": pdb_id, "name": entry.get("name"), "resolution": entry.get("resolution"), "structure_method": entry.get("structure_method"), "r_work": entry.get("r_work"), "r_free": entry.get("r_free"), "deposition_date": entry.get("deposition_date"), "release_date": entry.get("release_date"), "journal_references": entry.get("journal_references"), "antibody_type": sorted( {a["type"] for a in antibodies if a.get("type")} ), "antibody_count": len(antibodies), "heavy_chains": [c["chain"] for c in heavy], "light_chains": [c["chain"] for c in light], "single_chain_constructs": [c["chain"] for c in single], "heavy_chain_species": sorted( {c["organism_scientific"] for c in heavy if c.get("organism_scientific")} ), "light_chain_species": sorted( {c["organism_scientific"] for c in light if c.get("organism_scientific")} ), "antigen_chains": [ { "chain": c["chain"], "name": c["name"], "organism_scientific": c["organism_scientific"], } for c in antigen ], # Reported rather than folded into antigen, so a chain type # added upstream is visible instead of mislabelled. "unclassified_chains": [ {"chain": c["chain"], "chain_type": c["chain_type"]} for c in unclassified ], "all_cdr_coords_resolved": all( a.get("all_cdr_coords_resolved") for a in antibodies ) if antibodies else None, }, "metadata": { "source": "SAbDab 2 API", "url": f"{SABDAB_API_URL}/pdb/{extended}", "note": ( "Derived from the SAbDab 2 JSON entry. The summary TSV this " "used to parse is no longer served, and four of its columns " "have no counterpart anywhere in the JSON entry: IMGT " "heavy/light V-gene subclass, affinity, the engineered " "flag, and the PMID. They are absent rather than guessed. " "`journal_references` is the only citation the API carries, " "as free text. Whether an antibody is a single-chain " "construct is readable from `antibody_type` and " "`single_chain_constructs` instead of the old scfv flag." ), }, }
[docs] def _get_summary(self, arguments: Dict[str, Any]) -> Dict[str, Any]: """Database-level counts, read from the API rather than hardcoded. This used to return a fixed blurb -- a description string and a list of features -- with status "success" and no request made at all. It could not go out of date because it was never live, and it could not tell a caller anything they did not already know. """ structures, failure = self._api_get("/pdb", params={"limit": 1}) if failure is not None: return failure antibodies, failure = self._api_get("/antibodies", params={"limit": 1}) if failure is not None: return failure return { "status": "success", "data": { "description": "SAbDab 2 - the Structural Antibody Database", "structures": structures.get("total"), "unique_antibodies": antibodies.get("total"), "web_url": SABDAB_BASE_URL, "api_url": SABDAB_API_URL, "note": ( "`structures` counts PDB entries SAbDab holds; " "`unique_antibodies` counts distinct heavy/light pairings " "across them, so it is the smaller number." ), }, "metadata": { "source": "SAbDab 2 API", "url": f"{SABDAB_API_URL}/pdb", }, }