Source code for tooluniverse.medlineplus_tool

# medlineplus_tool.py

import re
from typing import Any, Dict, Optional

import requests
import xmltodict

from .base_tool import BaseTool
from .logging_config import get_logger
from .tool_registry import register_tool

logger = get_logger("MedlinePlusRESTTool")

_HTML_TAG_RE = re.compile(r"<[^>]+>")

# The wsearch service wraps every term match in <span class="qt0">...</span>
# highlight markup in the flat `brief`/`all` content nodes. The same text in
# the `topic` <health-topic> record carries no such markup, so stripping the
# spans (keeping their inner text) makes the three rettypes agree.
_HIGHLIGHT_SPAN_RE = re.compile(r"</?span[^>]*>")

# NLM's wsearch endpoint accepts any `db` value but only actually serves the
# two health-topic databases; every query against the other historically
# documented databases returns <count>0</count>. Verified live (2026-08) for
# drugs / drugsSpanish / genetics / medicalTests / medicalEncyclopedia across
# the terms aspirin, cancer, heart, BRCA1, diabetes and vitamin -- all zero,
# while healthTopics/healthTopicsSpanish return hundreds of hits for the same
# terms. The values stay accepted so existing callers keep working; they just
# now get an explanation instead of a generic "no results".
_SERVED_SEARCH_DBS = ("healthTopics", "healthTopicsSpanish")


[docs] @register_tool("MedlinePlusRESTTool") class MedlinePlusRESTTool(BaseTool): """ MedlinePlus REST API tool class. Supports health topic search, code lookup, genetics information retrieval, etc. """
[docs] def __init__(self, tool_config): super().__init__(tool_config) self.timeout = 10 self.endpoint_template = tool_config["fields"]["endpoint"] self.param_schema = tool_config["parameter"]["properties"]
# MedlinePlus's genetics download endpoints are case-sensitive on the # identifier segment: an uppercase gene symbol (e.g. "FBN1.json") 200s # but silently serves XML instead of the requested JSON, forcing a much # buggier XML-fallback parse path (confirmed live). Lowercasing matches # the convention already used for condition names (e.g. # "marfan-syndrome") and routes both endpoints through the same # reliably-correct real-JSON response shape. _LOWERCASE_URL_PARAMS = {"gene", "condition"}
[docs] def _build_url(self, arguments: dict) -> str: """Build complete URL""" url_path = self.endpoint_template placeholders = re.findall(r"\{([^{}]+)\}", url_path) for ph in placeholders: if ph not in arguments: return { "status": "error", "error": f"Missing required parameter '{ph}'", } value = str(arguments[ph]) if ph in self._LOWERCASE_URL_PARAMS: value = value.lower() url_path = url_path.replace(f"{{{ph}}}", value) return url_path
[docs] @staticmethod def _paragraph_text(p) -> str: """A <html:p> paragraph from xmltodict is a bare string when it has no nested inline tag, or a dict when it does (e.g. <html:i>FMR1</html:i> splits into {"html:i": "FMR1", "#text": "The gene provides..."} -- xmltodict keeps the surrounding text but drops the inline tag's own text from "#text", leaving a double-space gap where it belongs). Reinsert the inline text into that gap instead of losing the word.""" if isinstance(p, str): return p if not isinstance(p, dict): return "" text = p.get("#text", "") inline = next( (v for k, v in p.items() if k != "#text" and isinstance(v, str)), None ) if inline and " " in text: text = text.replace(" ", f" {inline} ", 1) return text
[docs] def _extract_text_content(self, text_item: dict) -> str: """Extract content from text item""" if not isinstance(text_item, dict): return "" text = text_item.get("text", {}) if not isinstance(text, dict): return "" html = text.get("html", "") if isinstance(html, dict) and "html:p" in html: paragraphs = html["html:p"] if not isinstance(paragraphs, list): paragraphs = [paragraphs] # Confirmed live: paragraphs without a nested inline tag parse as # bare strings, not dicts -- the previous `isinstance(p, dict)` # filter silently dropped every such paragraph (e.g. lost the # entire middle paragraph of FMR1's "function" description). return "\n".join(self._paragraph_text(p) for p in paragraphs) if isinstance(html, str): return _HTML_TAG_RE.sub("", html.replace("</p>", "\n")).strip() return ""
[docs] @staticmethod def _strip_highlight(value: Any) -> str: """Drop the wsearch term-highlight <span> wrappers, keeping their text.""" if not isinstance(value, str): return "" return _HIGHLIGHT_SPAN_RE.sub("", value).strip()
[docs] @staticmethod def _truncate_summary(summary: Any) -> Any: text = summary if isinstance(summary, str) else str(summary) return text[:500] + "..." if len(text) > 500 else summary
[docs] @staticmethod def _split_document_content(doc: dict): """Split a <document>'s <content> nodes into the structured <health-topic> record (rettype=topic / all) and a name -> [text] map of the flat content nodes (rettype=brief / all). rettype=topic yields exactly one <content name="healthTopic"> node, which xmltodict collapses to a bare dict; brief yields several flat <content name="..."> nodes (a list); all yields both in one list. The old code only ever looked at the dict shape, so every brief/all document was silently discarded. """ content = doc.get("content", {}) if isinstance(content, dict): entries = [content] elif isinstance(content, list): entries = [c for c in content if isinstance(c, dict)] else: entries = [] health_topic = {} flat: dict[str, list] = {} for entry in entries: nested = entry.get("health-topic") if isinstance(nested, dict): health_topic = nested continue text = entry.get("#text") if isinstance(text, str): flat.setdefault(entry.get("@name", ""), []).append(text) return health_topic, flat
[docs] def _format_health_topic(self, health_topic: dict, doc_url: str, doc_rank: str): """Format the structured <health-topic> record (rettype=topic/all).""" title = health_topic.get("@title", "") meta_desc = health_topic.get("@meta-desc", "") topic_url = health_topic.get("@url", doc_url) language = health_topic.get("@language", "") # Extract aliases also_called = health_topic.get("also-called", []) if isinstance(also_called, str): also_called = [also_called] elif isinstance(also_called, dict): also_called = [also_called.get("#text", str(also_called))] elif not isinstance(also_called, list): also_called = [] # Extract summary full_summary = health_topic.get("full-summary", "") if isinstance(full_summary, dict): full_summary = str(full_summary) # Extract group information groups = health_topic.get("group", []) if isinstance(groups, str): groups = [groups] elif isinstance(groups, dict): groups = [groups.get("#text", str(groups))] elif not isinstance(groups, list): groups = [] return { "title": title, "meta_desc": meta_desc, "url": topic_url, "language": language, "rank": doc_rank, "also_called": also_called, "summary": self._truncate_summary(full_summary), "groups": groups, }
[docs] def _format_brief_document( self, flat: dict[str, list], doc_url: str, doc_rank: str, db: str ): """Format a rettype=brief document, whose fields arrive as flat <content name="title|altTitle|FullSummary|groupName|snippet"> nodes instead of a nested <health-topic> record.""" def first(name: str) -> str: values = flat.get(name) or [""] return self._strip_highlight(values[0]) # brief carries no meta-desc; the search snippet is the equivalent # short description the service offers for this rettype. snippet = first("snippet") # brief carries no language attribute either; it is fixed by the db. if db.lower().endswith("spanish"): language = "Spanish" elif db: language = "English" else: language = "" return { "title": first("title"), "meta_desc": snippet, "url": doc_url, "language": language, "rank": doc_rank, "also_called": [ self._strip_highlight(v) for v in flat.get("altTitle", []) if self._strip_highlight(v) ], "summary": self._truncate_summary(first("FullSummary")), "groups": [ self._strip_highlight(v) for v in flat.get("groupName", []) if self._strip_highlight(v) ], }
[docs] @staticmethod def _no_documents_error(arguments: dict[str, Any]) -> str: """Explain an empty result set, naming the real cause when the caller targeted a `db` that wsearch accepts but never serves.""" db = str(arguments.get("db", "") or "") term = str(arguments.get("term", "") or "") if db and db not in _SERVED_SEARCH_DBS: return ( f"MedlinePlus wsearch does not serve `db={db}`; only " f"{' and '.join(_SERVED_SEARCH_DBS)} return results. The service " f"accepts the request but always answers with count=0, so this is " f"not a 'no match for your term' result. Retry with " f"db=healthTopics (or db=healthTopicsSpanish)." ) where = f" in db={db}" if db else "" for_term = f" for term '{term}'" if term else "" return f"MedlinePlus returned no matching documents{for_term}{where} (count=0)."
[docs] def _format_response( self, response: Any, tool_name: str, arguments: dict[str, Any] | None = None, ) -> Dict[str, Any]: """Format response content""" if not isinstance(response, dict): return {"raw_response": response} # Extract text content def get_text_content(data, role): text_list = data.get("text-list", []) if isinstance(text_list, dict): text_list = [text_list] for item in text_list: if isinstance(item, dict) and "text" in item: text = item["text"] if text.get("text-role") == role: return self._extract_text_content(item) return "" # MedlinePlus genetics list fields arrive in two shapes depending on # the parse path (confirmed live): real JSON gives a top-level *list* # of single-key wrapper dicts (e.g. [{"related-gene": {...}}, ...]), # while the XML fallback (xmltodict) collapses the same data to a # *dict* ({"related-gene": [...] or {...}}). Normalize both to a flat # list of entries. def unwrap_entries(data, list_key, item_key): raw = data.get(list_key, []) if isinstance(raw, dict): entries = raw.get(item_key, []) return entries if isinstance(entries, list) else [entries] if isinstance(raw, list): return [w.get(item_key) if isinstance(w, dict) else w for w in raw] return [] def get_list_items( data, list_key, item_key, name_key="name", url_key="ghr-page" ): formatted = [] for item in unwrap_entries(data, list_key, item_key): if isinstance(item, dict): name = item.get(name_key, "") url = item.get(url_key, "") formatted.append(f"{name} ({url})" if url else name) return formatted def get_synonyms(data): return [ s for s in unwrap_entries(data, "synonym-list", "synonym") if isinstance(s, str) ] # Format response based on tool type if tool_name == "MedlinePlus_search_topics_by_keyword": arguments = arguments or {} logger.debug("MedlinePlus raw search response: %s", response) # Extract topic information from XML structure nlm_result = response.get("nlmSearchResult", {}) if not nlm_result: return {"status": "error", "error": "nlmSearchResult node not found"} # Get document list document_list = nlm_result.get("list", {}).get("document", []) if not document_list: return { "status": "error", "error": self._no_documents_error(arguments), } # Ensure document_list is a list if isinstance(document_list, dict): document_list = [document_list] db = str(arguments.get("db", "") or "") formatted_topics = [] for doc in document_list: if not isinstance(doc, dict): continue # Get document basic info doc_url = doc.get("@url", "") doc_rank = doc.get("@rank", "") health_topic, flat = self._split_document_content(doc) if health_topic: # rettype=topic, and rettype=all (which ships the same # structured record alongside the flat fields). formatted_topics.append( self._format_health_topic(health_topic, doc_url, doc_rank) ) elif flat: # rettype=brief: only the flat content nodes are present. formatted_topics.append( self._format_brief_document(flat, doc_url, doc_rank, db) ) return ( {"topics": formatted_topics} if formatted_topics else {"error": "Failed to parse health topic information"} ) elif tool_name == "MedlinePlus_get_genetics_condition_by_name": inheritance = [ p.get("memo", "") for p in unwrap_entries( response, "inheritance-pattern-list", "inheritance-pattern" ) if isinstance(p, dict) ] return { "name": response.get("name", ""), "description": get_text_content(response, "description"), "genes": get_list_items( response, "related-gene-list", "related-gene", "gene-symbol" ), "inheritance": inheritance, "synonyms": get_synonyms(response), "ghr_page": response.get("ghr_page", ""), } elif tool_name == "MedlinePlus_get_genetics_gene_by_name": # Real JSON responses have the gene's fields at the top level # (no "gene-summary" wrapper) -- that wrapper only exists in the # XML-parsed shape. Fall back to `response` itself so both # shapes work. gene_summary = response.get("gene-summary", response) return { "name": gene_summary.get("name", ""), "function": get_text_content(gene_summary, "function"), "health_conditions": get_list_items( gene_summary, "related-health-condition-list", "related-health-condition", ), "synonyms": get_synonyms(gene_summary), "ghr_page": gene_summary.get("ghr-page", ""), } elif tool_name == "MedlinePlus_connect_lookup_by_code": # Handle both JSON and XML response from Connect API feed = response.get("feed", {}) entries = feed.get("entry", []) # Ensure entries is a list if isinstance(entries, dict): entries = [entries] if not entries: return { "status": "error", "error": "No matching code information found", } formatted_responses = [] for entry in entries: # Extract title - handle both JSON and XML formats title = entry.get("title", "") if isinstance(title, dict): # JSON format: {"_value": "...", "type": "text"} # XML format: {"#text": "..."} title = title.get("_value", title.get("#text", str(title))) # Extract link - handle both JSON and XML formats link = entry.get("link", {}) url = "" if isinstance(link, dict): # JSON format: {"href": "..."} # XML format: {"@href": "..."} url = link.get("href", link.get("@href", "")) elif isinstance(link, list): # Multiple links, get the first one if link: url = link[0].get("href", link[0].get("@href", "")) # Extract summary - handle both JSON and XML formats summary_data = entry.get("summary", {}) summary = "" if isinstance(summary_data, dict): # JSON format: {"_value": "...", "type": "html"} # XML format: {"#text": "..."} summary = summary_data.get("_value", summary_data.get("#text", "")) elif isinstance(summary_data, str): summary = summary_data formatted_responses.append( { "title": title, "summary": summary[:500] + "..." if len(summary) > 500 else summary, "url": url, } ) return {"responses": formatted_responses} elif tool_name == "MedlinePlus_get_genetics_index": topics = response.get("genetics_home_reference_topic_list", {}).get( "topic", [] ) return ( { "topics": [ {"name": t.get("name", ""), "url": t.get("url", "")} for t in topics ] } if topics else {"error": "No genetics topics found"} ) return {"raw_response": response}
[docs] def run(self, arguments: dict): """Execute tool call""" # Apply default values for optional parameters for key, prop in self.param_schema.items(): if key not in arguments and "default" in prop: arguments[key] = prop["default"] # Build URL url = self._build_url(arguments) if isinstance(url, dict) and "error" in url: return url # Diagnostics go to the shared ToolUniverse logger at DEBUG level # (enable with TOOLUNIVERSE_LOG_LEVEL=DEBUG). They used to be bare # print() calls, which polluted stdout on every single call and # corrupted any consumer parsing the tool's JSON payload from there. logger.debug("Request URL: %s", url) # Make request try: resp = requests.get(url, timeout=self.timeout) if resp.status_code != 200: return { "status": "error", "error": f"MedlinePlus returned non-200 status code: {resp.status_code}", "detail": resp.text, } logger.debug( "Response status: %s, length: %s characters", resp.status_code, len(resp.text), ) logger.debug("First 500 characters of response: %s", resp.text[:500]) # Improved parsing logic tool_name = self.tool_config["name"] response_text = resp.text.strip() # Decide parsing method based on tool type and content format format_arg = arguments.get("format", "") if url.endswith(".json") or (format_arg in ["json", "application/json"]): # JSON format try: response = resp.json() logger.debug("Parsed as: JSON") except Exception: # If JSON parsing fails, fall back to XML response = xmltodict.parse(resp.text) logger.debug("Parsed as: XML -> Dictionary (fallback)") elif ( url.endswith(".xml") or response_text.startswith("<?xml") or (format_arg in ["xml", "text/xml"]) ): # XML format response = xmltodict.parse(resp.text) logger.debug("Parsed as: XML -> Dictionary") elif tool_name == "MedlinePlus_search_topics_by_keyword": # Search tool defaults to XML response = xmltodict.parse(resp.text) logger.debug("Parsed as: XML -> Dictionary (Search tool)") elif tool_name == "MedlinePlus_get_genetics_index": # Genetics index defaults to XML response = xmltodict.parse(resp.text) logger.debug("Parsed as: XML -> Dictionary (Genetics index)") else: # Other cases keep original text response = resp.text logger.debug("Parsed as: Plain text") if isinstance(response, dict): logger.debug("Top-level dictionary keys: %s", list(response.keys())) return self._format_response(response, tool_name, arguments) except requests.RequestException as e: return { "status": "error", "error": f"Failed to request MedlinePlus: {str(e)}", }
# Tool methods
[docs] def search_topics_by_keyword( self, term: str, db: str, rettype: str = "topic" ) -> Dict[str, Any]: return self.run({"term": term, "db": db, "rettype": rettype})
[docs] def connect_lookup_by_code( self, cs: str, c: str, dn: Optional[str] = None, language: str = "en", format: str = "json", ) -> Any: args = {"cs": cs, "c": c, "language": language, "format": format} if dn: args["dn"] = dn return self.run(args)
[docs] def get_genetics_condition_by_name( self, condition: str, format: str = "json" ) -> Any: return self.run({"condition": condition, "format": format})
[docs] def get_genetics_gene_by_name(self, gene: str, format: str = "json") -> Any: return self.run({"gene": gene, "format": "json"})
[docs] def get_genetics_index(self) -> Any: return self.run({})