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"})

# /api/antibody-instances filters, as its openapi.json (SAbDab 2.1.5) declares
# them. `binds_to_antigen` is a case-insensitive substring of the antigen's
# name as deposited in the PDB, and the API rejects it above 25 characters
# (HTTP 422), so a full UniProt name such as "Receptor tyrosine-protein kinase
# erbB-2" cannot be sent; "erbB-2" matches 31 instances. A gene symbol or
# shorthand usually matches nothing: "HER2" and "PD-1" both return 0, while
# "cell death protein 1" returns 51.
SABDAB_ANTIGEN_QUERY_MAX = 25
SABDAB_SEARCH_LIMIT_MAX = 100
SABDAB_EXPERIMENTAL_METHODS = (
    "X-ray diffraction",
    "electron microscopy",
    "electron crystallography",
    "solution NMR",
    "solid-state NMR",
    "neutron diffraction",
    "fiber diffraction",
    "powder diffraction",
    "solution scattering",
    "fluorescence transfer",
    "infrared spectroscopy",
    "EPR",
    "theoretical model",
)
SABDAB_ANTIGEN_KINDS = (
    "PROTEIN",
    "PEPTIDE",
    "DNA",
    "RNA",
    "NUCLEOTIDE",
    "PEPTIDE_NUCLEIC_ACID",
    "SUGAR",
    "HAPTEN",
    "ION",
    "CHEMICAL_ELEMENT",
    "WATER",
    "UNKNOWN",
    "OTHER",
)
SABDAB_ANTIBODY_KINDS = ("FV", "FAB", "FAB+FC", "FV*", "SD-H", "SD-L", "VNAR")


[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() allowed_arguments = { "search_structures": { "operation", "query", "antigen", "species", "method", "resolution_max", "antigen_type", "antibody_type", "pdb_id", "limit", "offset", }, "get_structure": {"operation", "pdb_id", "pdb_code"}, "get_structure_summary": {"operation", "pdb_id", "pdb_code", "pdb"}, # pdb/pdb_id were accepted here historically; keep them harmless. "get_summary": {"operation", "pdb", "pdb_id"}, } if operation in allowed_arguments: unknown = sorted(set(arguments) - allowed_arguments[operation]) if unknown: return { "status": "error", "error": f"Unknown parameter(s) for {operation}: {', '.join(unknown)}", } 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] @staticmethod def _choice(value, choices, name): """Match `value` to one of `choices` ignoring case; (choice, error).""" if value in (None, ""): return None, None by_key = {choice.lower(): choice for choice in choices} choice = by_key.get(str(value).strip().lower()) if choice is None: return None, f"{name} must be one of: {', '.join(choices)}" return choice, None
[docs] @staticmethod def _segment_row(segment: dict) -> dict: """The CDRs and germline of one chain, without the residue numbering.""" return { "chain": segment.get("sabdab_auth_asym_id"), "chain_type": segment.get("type"), "cdr1": segment.get("cdr1"), "cdr2": segment.get("cdr2"), "cdr3": segment.get("cdr3"), "v_gene": segment.get("v_gene"), "j_gene": segment.get("j_gene"), }
[docs] def _instance_row(self, instance: dict) -> dict: """One antibody instance, compact: a full record carries the residue numbering of both chains, so a 500-row page is 3.4 MB.""" entry = instance.get("pdb_entry") or {} heavy = instance.get("heavy_like_segment") light = instance.get("light_like_segment") extended = entry.get("id") or "" return { "instance_id": instance.get("id"), "pdb_id": extended[len("pdb_0000") :] if extended.startswith("pdb_0000") else extended, "structure_name": entry.get("name"), "antibody_id": instance.get("antibody_id"), "antibody_type": instance.get("type"), "resolution": entry.get("resolution"), "structure_method": entry.get("structure_method"), "release_date": entry.get("release_date"), "heavy": self._segment_row(heavy) if isinstance(heavy, dict) else None, "light": self._segment_row(light) if isinstance(light, dict) else None, "antigen_instance_ids": [ binding.get("antigen_instance_id") for binding in instance.get("antigen_bindings") or [] ], }
[docs] def _search_structures(self, arguments: Dict[str, Any]) -> Dict[str, Any]: """Search antibody instances through /api/antibody-instances. #701 found no search route because it looked on /api/pdb, where every filter is accepted and ignored. /api/antibody-instances does filter: on 2026-10-23 no filter gave `total` 22671, `binds_to_antigen=lysozyme` 123, "spike glycoprotein" 2008 and a made-up name 0. """ query = (arguments.get("query") or arguments.get("antigen") or "").strip() if len(query) > SABDAB_ANTIGEN_QUERY_MAX: return { "status": "error", "error": ( f"query is {len(query)} characters; SAbDab matches antigen " f"names on at most {SABDAB_ANTIGEN_QUERY_MAX}. Send a " "distinctive part of the name instead, e.g. 'erbB-2' for " "Receptor tyrosine-protein kinase erbB-2, or 'cell death " "protein 1' for PD-1." ), "parameter": "query", } limit = arguments.get("limit", 20) offset = arguments.get("offset", 0) if ( isinstance(limit, bool) or not isinstance(limit, int) or not 1 <= limit <= SABDAB_SEARCH_LIMIT_MAX ): return { "status": "error", "error": f"limit must be an integer from 1 to {SABDAB_SEARCH_LIMIT_MAX}", "parameter": "limit", } if isinstance(offset, bool) or not isinstance(offset, int) or offset < 0: return { "status": "error", "error": "offset must be a non-negative integer", "parameter": "offset", } resolution = arguments.get("resolution_max") if resolution is not None and ( isinstance(resolution, bool) or not isinstance(resolution, (int, float)) or resolution <= 0 ): return { "status": "error", "error": "resolution_max must be a positive number of angstroms", "parameter": "resolution_max", } filters = {} for name, key, choices in ( ("method", "experimental_method", SABDAB_EXPERIMENTAL_METHODS), ("antigen_type", "antigen_type", SABDAB_ANTIGEN_KINDS), ("antibody_type", "antibody_type", SABDAB_ANTIBODY_KINDS), ): choice, error = self._choice(arguments.get(name), choices, name) if error: return {"status": "error", "error": error, "parameter": name} if choice: filters[key] = choice if query: filters["binds_to_antigen"] = query species = (arguments.get("species") or "").strip() if species: filters["organism_scientific"] = species if resolution is not None: filters["resolution"] = resolution pdb_id = (arguments.get("pdb_id") or "").strip() if pdb_id: filters["pdb_entry"] = self._extended_pdb_id(pdb_id) payload, failure = self._api_get( "/antibody-instances", params={**filters, "limit": limit, "offset": offset} ) if failure is not None: return failure results = payload.get("results") if isinstance(payload, dict) else None total = payload.get("total") if isinstance(payload, dict) else None if ( not isinstance(results, list) or isinstance(total, bool) or not isinstance(total, int) ): return { "status": "error", "error": "SAbDab returned a search response without results and total", "url": f"{SABDAB_API_URL}/antibody-instances", } rows = [self._instance_row(item) for item in results if isinstance(item, dict)] next_offset = offset + len(rows) has_more = next_offset < total data = { "query": query, "filters_applied": filters, "structures": rows, "count": len(rows), "total_matches": total, "offset": offset, "limit": limit, "has_more": has_more, "next_offset": next_offset if has_more else None, "result_set_complete": offset == 0 and not has_more, } if total == 0 and query: data["note"] = ( f"No antibody binds an antigen whose name contains {query!r}. " "SAbDab matches the antigen's name as deposited in the PDB, so " "gene symbols and shorthand often miss: 'HER2' finds nothing " "where 'erbB-2' finds 31. Try a distinctive part of the full " "protein name." ) return { "status": "success", "data": data, "metadata": { "source": "SAbDab 2 API", "url": f"{SABDAB_API_URL}/antibody-instances", "details": "SAbDab_get_structure_summary takes a pdb_id from these rows", }, }
[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", }, }