Source code for tooluniverse.sider_tool

"""
SIDER Tool - Drug Side Effects Resource

Provides access to the SIDER (Side Effect Resource) database for retrieving drug
side effects, side effect frequencies from drug labels, therapeutic indications,
and reverse lookups from side effects to associated drugs.

SIDER 4.1 is maintained by EMBL (European Molecular Biology Laboratory) and
contains information on marketed medicines and their recorded adverse drug
reactions. Data is extracted from public documents and package inserts.

Source: http://sideeffects.embl.de
No authentication required.

Reference: Kuhn et al., Nucleic Acids Res. 2016; 44(D1): D1075-D1079
"""

import re
import requests
from html import unescape
from typing import Dict, Any, Optional, List
from .base_tool import BaseTool
from .tool_registry import register_tool


SIDER_BASE_URL = "http://sideeffects.embl.de"

# Column layout of the side-effect table on a SIDER drug page
# (http://sideeffects.embl.de/drugs/<id>/). Verified against the live page:
# the header row is
#   <th>Side effect</th> <th>Data for drug</th> <th>Placebo</th>
#   <th colspan="N">Labels</th>
# so every data row starts with these three fixed cells, followed by one cell
# per source label. The trailing label cells carry per-label tooltips such as
# title="<b>Acitretin</b> : 65%", so percentages MUST be read from their own
# <td> and never by scanning the whole <tr>.
SE_NAME_COL = 0
DRUG_FREQUENCY_COL = 1
PLACEBO_FREQUENCY_COL = 2

_TD_RE = re.compile(r"<td\b[^>]*>(.*?)</td>", re.DOTALL | re.IGNORECASE)
_TAG_RE = re.compile(r"<[^>]+>")


def _split_row_cells(row_html: str) -> List[str]:
    """Split a table row's inner HTML into the inner HTML of its <td> cells."""
    return _TD_RE.findall(row_html)


def _cell_text(cell_html: str) -> Optional[str]:
    """
    Return the visible text of a single <td>, or None when the cell is empty.

    SIDER renders "no data" as a physically empty cell (e.g. no placebo arm in
    the label), so an empty cell must map to None rather than being back-filled
    from anywhere else in the row.
    """
    text = unescape(_TAG_RE.sub(" ", cell_html))
    text = re.sub(r"\s+", " ", text).strip()
    return text or None


[docs] class SiderUpstreamError(Exception): """ SIDER answered with an unexpected HTTP status that is not a genuine 404. Kept distinct from "the record does not exist" so that a temporary outage of the SIDER website (which answers 5xx with a "Page unavailable" body) is not reported to callers as a stable absence of the drug. """
[docs] def __init__(self, url: str, status_code: int, message: str): self.url = url self.status_code = status_code self.retryable = status_code >= 500 super().__init__(message)
[docs] @register_tool("SiderTool") class SiderTool(BaseTool): """ Tool for querying the SIDER drug side effects database. SIDER contains information on marketed medicines and their recorded adverse drug reactions extracted from drug labels. It includes side effect frequencies, therapeutic indications, and MedDRA concept codes. Supported operations: - search_drug: Search for a drug by name and get its SIDER ID - get_side_effects: Get side effects for a drug (with frequencies if available) - get_indications: Get therapeutic indications for a drug - get_drugs_for_side_effect: Find drugs associated with a specific side effect - search_side_effect: Search for a side effect by name """
[docs] def __init__(self, tool_config: Dict[str, Any]): super().__init__(tool_config) self.parameter = tool_config.get("parameter", {}) self.required = self.parameter.get("required", []) self.session = requests.Session() self.session.headers.update( {"User-Agent": "Mozilla/5.0 (compatible; ToolUniverse/1.0)"} ) self.timeout = 30
[docs] def run(self, arguments: Dict[str, Any]) -> Dict[str, Any]: """Execute the SIDER tool with given arguments.""" operation = arguments.get("operation") if not operation: return {"status": "error", "error": "Missing required parameter: operation"} operation_handlers = { "search_drug": self._search_drug, "get_side_effects": self._get_side_effects, "get_indications": self._get_indications, "get_drugs_for_side_effect": self._get_drugs_for_side_effect, "search_side_effect": self._search_side_effect, } handler = operation_handlers.get(operation) if not handler: return { "status": "error", "error": "Unknown operation: {}".format(operation), "available_operations": list(operation_handlers.keys()), } try: return handler(arguments) except SiderUpstreamError as e: return { "status": "error", "error": str(e), "http_status": e.status_code, "retryable": e.retryable, } except requests.exceptions.Timeout: return {"status": "error", "error": "SIDER request timed out"} except requests.exceptions.ConnectionError: return {"status": "error", "error": "Failed to connect to SIDER"} except Exception as e: return { "status": "error", "error": "SIDER operation failed: {}".format(str(e)), }
[docs] def _fetch_page(self, path: str) -> Optional[str]: """ Fetch an HTML page from SIDER. Returns the page body on HTTP 200 and None on a genuine HTTP 404 (the record really is absent). Any other status raises SiderUpstreamError so that a server-side failure is never mistaken for a missing record. """ url = "{}/{}".format(SIDER_BASE_URL, path.lstrip("/")) response = self.session.get(url, timeout=self.timeout) status = response.status_code if status == 200: return response.text if status == 404: return None if status >= 500: raise SiderUpstreamError( url, status, ( "SIDER server error (HTTP {}) from {}. The SIDER website is " "temporarily unavailable; this is a transient upstream " "failure and the request should be retried later." ).format(status, url), ) raise SiderUpstreamError( url, status, "SIDER returned unexpected HTTP {} from {}".format(status, url), )
[docs] def _search_drug(self, arguments: Dict[str, Any]) -> Dict[str, Any]: """Search for a drug by name using SIDER's search endpoint.""" query = arguments.get("drug_name") if not query: return {"status": "error", "error": "drug_name parameter is required"} html = self._fetch_page("/searchBox/?q={}".format(requests.utils.quote(query))) if not html: return {"status": "error", "error": "SIDER search endpoint unavailable"} # Extract drug results drug_matches = re.findall(r'href="/drugs/(\d+)/"[^>]*>([^<]+)</a>', html) # Extract side effect results se_matches = re.findall(r'href="/se/(C\d+)/"[^>]*>([^<]+)</a>', html) drugs = [] for drug_id, name in drug_matches: drugs.append( { "sider_drug_id": drug_id, "drug_name": name.strip(), "url": "{}/drugs/{}/".format(SIDER_BASE_URL, drug_id), } ) related_side_effects = [] for se_code, name in se_matches: related_side_effects.append( { "meddra_code": se_code, "side_effect_name": name.strip(), } ) if not drugs and not related_side_effects: return { "status": "success", "data": { "query": query, "drugs": [], "related_side_effects": [], "message": "No results found for '{}'".format(query), }, } return { "status": "success", "data": { "query": query, "drugs": drugs, "related_side_effects": related_side_effects, }, }
[docs] def _get_side_effects(self, arguments: Dict[str, Any]) -> Dict[str, Any]: """Get side effects for a drug by SIDER drug ID or drug name.""" drug_id = arguments.get("sider_drug_id") drug_name = arguments.get("drug_name") limit = arguments.get("limit", 50) if not drug_id and not drug_name: return { "status": "error", "error": "Either sider_drug_id or drug_name is required", } # If drug_name given, search first if not drug_id and drug_name: search_result = self._search_drug({"drug_name": drug_name}) if search_result["status"] == "error": return search_result drugs = search_result["data"].get("drugs", []) if not drugs: return { "status": "error", "error": "Drug '{}' not found in SIDER".format(drug_name), } drug_id = drugs[0]["sider_drug_id"] html = self._fetch_page("/drugs/{}/".format(drug_id)) if not html: return { "status": "error", "error": "Drug page not found for ID {}".format(drug_id), } # Extract drug name from <h1> name_match = re.search(r"<h1[^>]*>([^<]+)</h1>", html) resolved_name = name_match.group(1).strip() if name_match else "Unknown" # Find the boundary between side effects and indications sections ind_idx = html.lower().find("<h3 class='top'>indications") se_section = html[:ind_idx] if ind_idx > 0 else html # Extract side effect rows se_rows = re.findall(r'<tr\s+class="bg\d">(.*?)</tr>', se_section, re.DOTALL) side_effects = [] for row in se_rows: # Parse the row into its cells. Every value below is read from the # cell that actually holds it: a row-wide regex would also match the # per-label tooltips ("<b>Acitretin</b> : 65%") and percentages that # occur inside the MedDRA concept description prose, and would # attribute those numbers to the drug or to placebo. cells = _split_row_cells(row) name_cell = cells[SE_NAME_COL] if cells else row # Extract MedDRA code and name from the "Side effect" cell se_match = re.search( r'href="/se/(C\d+)/"[^>]*title="([^"]*)"[^>]*>([^<]+)', name_cell ) if not se_match: se_match_simple = re.search( r'href="/se/(C\d+)/"[^>]*>([^<]+)', name_cell ) if not se_match_simple: continue code = se_match_simple.group(1) name = se_match_simple.group(2).strip() description = None else: code = se_match.group(1) description = se_match.group(2) name = se_match.group(3).strip() # Read each frequency from its own column. SIDER leaves the placebo # cell physically empty whenever the label reports no placebo arm, # which is the common case; that must surface as null and never as a # copy of the drug's own rate. frequency = ( _cell_text(cells[DRUG_FREQUENCY_COL]) if len(cells) > DRUG_FREQUENCY_COL else None ) placebo_frequency = ( _cell_text(cells[PLACEBO_FREQUENCY_COL]) if len(cells) > PLACEBO_FREQUENCY_COL else None ) entry = { "meddra_code": code, "side_effect_name": name, "frequency": frequency, "placebo_frequency": placebo_frequency, } if description: entry["description"] = description side_effects.append(entry) if len(side_effects) >= limit: break return { "status": "success", "data": { "drug_name": resolved_name, "sider_drug_id": str(drug_id), "total_side_effects": len(se_rows), "side_effects": side_effects, }, }
[docs] def _get_indications(self, arguments: Dict[str, Any]) -> Dict[str, Any]: """Get therapeutic indications for a drug.""" drug_id = arguments.get("sider_drug_id") drug_name = arguments.get("drug_name") if not drug_id and not drug_name: return { "status": "error", "error": "Either sider_drug_id or drug_name is required", } # If drug_name given, search first if not drug_id and drug_name: search_result = self._search_drug({"drug_name": drug_name}) if search_result["status"] == "error": return search_result drugs = search_result["data"].get("drugs", []) if not drugs: return { "status": "error", "error": "Drug '{}' not found in SIDER".format(drug_name), } drug_id = drugs[0]["sider_drug_id"] html = self._fetch_page("/drugs/{}/".format(drug_id)) if not html: return { "status": "error", "error": "Drug page not found for ID {}".format(drug_id), } # Extract drug name from <h1> name_match = re.search(r"<h1[^>]*>([^<]+)</h1>", html) resolved_name = name_match.group(1).strip() if name_match else "Unknown" # Find indications section ind_idx = html.lower().find("<h3 class='top'>indications") if ind_idx < 0: return { "status": "success", "data": { "drug_name": resolved_name, "sider_drug_id": str(drug_id), "indications": [], "message": "No indications section found", }, } ind_section = html[ind_idx:] # Extract indication entries (linked to /se/CXXXXXX/ with title descriptions) ind_matches = re.findall( r'<a href="/se/(C\d+)/"[^>]*title="([^"]*)"[^>]*>([^<]+)', ind_section, ) # Deduplicate by MedDRA code seen = set() indications = [] for code, description, name in ind_matches: if code not in seen: seen.add(code) indications.append( { "meddra_code": code, "indication_name": name.strip(), "description": description if description else None, } ) return { "status": "success", "data": { "drug_name": resolved_name, "sider_drug_id": str(drug_id), "total_indications": len(indications), "indications": indications, }, }
[docs] def _search_side_effect(self, arguments: Dict[str, Any]) -> Dict[str, Any]: """Search for a side effect by name.""" query = arguments.get("side_effect_name") if not query: return { "status": "error", "error": "side_effect_name parameter is required", } html = self._fetch_page("/searchBox/?q={}".format(requests.utils.quote(query))) if not html: return {"status": "error", "error": "SIDER search endpoint unavailable"} # Extract side effect results se_matches = re.findall( r'href="/se/(C\d+)/"[^>]*>([^<]+)</a>\s*\((\d+)\s+drugs?\)', html, ) if not se_matches: # Try without drug count se_matches_simple = re.findall(r'href="/se/(C\d+)/"[^>]*>([^<]+)</a>', html) side_effects = [] for code, name in se_matches_simple: side_effects.append( { "meddra_code": code, "side_effect_name": name.strip(), } ) else: side_effects = [] for code, name, drug_count in se_matches: side_effects.append( { "meddra_code": code, "side_effect_name": name.strip(), "drug_count": int(drug_count), } ) if not side_effects: return { "status": "success", "data": { "query": query, "side_effects": [], "message": "No side effects found matching '{}'".format(query), }, } return { "status": "success", "data": { "query": query, "side_effects": side_effects, }, }
[docs] def _get_drugs_for_side_effect(self, arguments: Dict[str, Any]) -> Dict[str, Any]: """Find drugs associated with a specific side effect by MedDRA code.""" meddra_code = arguments.get("meddra_code") side_effect_name = arguments.get("side_effect_name") limit = arguments.get("limit", 50) if not meddra_code and not side_effect_name: return { "status": "error", "error": "Either meddra_code or side_effect_name is required", } # If side_effect_name given, search first if not meddra_code and side_effect_name: search_result = self._search_side_effect( {"side_effect_name": side_effect_name} ) if search_result["status"] == "error": return search_result se_list = search_result["data"].get("side_effects", []) if not se_list: return { "status": "error", "error": "Side effect '{}' not found".format(side_effect_name), } meddra_code = se_list[0]["meddra_code"] html = self._fetch_page("/se/{}/".format(meddra_code)) if not html: return { "status": "error", "error": "Side effect page not found for {}".format(meddra_code), } # Extract SE name from <h1> name_match = re.search(r"<h1[^>]*>([^<]+)</h1>", html) se_name = name_match.group(1).strip() if name_match else "Unknown" # Extract drug list entries with frequency info # Pattern: <li><a href="/drugs/NNN/">drugname</a>: freq info </li> drug_entries = re.findall( r'<li><a href="/drugs/(\d+)/">([^<]+)</a>\s*(?::\s*([^<]*?))?</li>', html, re.DOTALL, ) drugs = [] for drug_id, drug_name, freq_info in drug_entries: freq_info = freq_info.strip() if freq_info else None entry = { "sider_drug_id": drug_id, "drug_name": drug_name.strip(), "frequency_info": freq_info if freq_info else None, } drugs.append(entry) if len(drugs) >= limit: break return { "status": "success", "data": { "meddra_code": meddra_code, "side_effect_name": se_name, "total_drugs": len(drug_entries), "drugs": drugs, }, }