diff --git a/50-research-limit.py b/50-research-limit.py new file mode 100644 index 0000000..b010b38 --- /dev/null +++ b/50-research-limit.py @@ -0,0 +1,1148 @@ +""" +title: Smart PubMed Research Assistant +author: Research Assistant +version: 5.5.0 +date: 2025-01-01 +license: MIT +description: Intelligent PubMed research assistant with ABSTRACT fetching. Passes full abstracts to the AI model for synthesis and analysis. Uses PubMed's Automatic Term Mapping โ€” no dumb word splitting. +""" + +import requests +import re +import xml.etree.ElementTree as ET +from typing import List, Dict, Optional, Tuple +from pydantic import Field + + +class Tools: + def __init__(self): + self.base_url = "https://eutils.ncbi.nlm.nih.gov/entrez/eutils" + self._cache = {} + self._last_results = [] + self._last_query = "" + + # ================================================================ + # MAIN SEARCH + # ================================================================ + + def search_pubmed( + self, + query: str = Field( + ..., + description="Your research question in plain English. Examples: 'current guidelines for management of gastric reflux in children', 'low back pain treatment in elderly', 'ECMO outcomes in neonates'", + ), + max_results: int = Field( + 10, + description="How many relevant articles you want (1-50)", + ), + include_abstracts: bool = Field( + True, + description="Include full abstracts (allows AI to synthesize findings). Set False for faster search without abstracts.", + ), + ) -> str: + """ + Intelligent PubMed search with full abstract retrieval. + The AI can read and synthesize the abstracts to answer your question. + Just ask naturally. + """ + try: + max_results = self._safe_int(max_results, 10, 1, 50) + query = str(query).strip() + if not query: + return "Please ask me a research question." + + # Handle include_abstracts safely + if isinstance(include_abstracts, str): + include_abstracts = include_abstracts.lower() not in ( + "false", + "no", + "0", + ) + elif not isinstance(include_abstracts, bool): + include_abstracts = True + + # PHASE 1: Let PubMed analyze the query + analysis = self._analyze_via_pubmed(query) + + # PHASE 2: Detect query type + query_type = self._detect_query_type(query) + + # PHASE 3: Iterative search + all_articles, search_log = self._iterative_search( + query, analysis, query_type, max_results + ) + + # PHASE 4: Fetch abstracts for all collected articles + if include_abstracts and all_articles: + pmids = [a["pmid"] for a in all_articles] + abstracts = self._fetch_abstracts(pmids) + + for article in all_articles: + article["abstract"] = abstracts.get(article["pmid"], "") + + # PHASE 5: Score relevance (now using abstracts too) + scored = self._score_relevance(all_articles, query, query_type) + top = scored[:max_results] + + # PHASE 6: Store + self._last_results = top + self._last_query = query + + # PHASE 7: Format + if not top: + return self._format_no_results(query, analysis, search_log) + + return self._format_results( + query, + analysis, + query_type, + search_log, + top, + len(all_articles), + include_abstracts, + ) + + except Exception as e: + return f"Search error: {str(e)}\n\nTry rephrasing your question." + + # ================================================================ + # GET RESULTS + # ================================================================ + + def get_results( + self, + format: str = Field( + "list", + description="'list' (references), 'ris' (Zotero), 'summary' (synthesis with abstracts), 'abstracts' (just abstracts), 'detailed' (everything)", + ), + ) -> str: + """Get last search results in different formats.""" + try: + fmt = str(format).strip().lower() if format else "list" + if not self._last_results: + return "No stored results. Run `search_pubmed` first." + + if fmt == "ris": + return self._export_ris() + elif fmt == "summary": + return self._synthesize() + elif fmt == "abstracts": + return self._format_abstracts_only() + elif fmt == "detailed": + return self._format_detailed() + else: + return self._format_reference_list() + except Exception as e: + return f"Error: {str(e)}" + + # ================================================================ + # PICO SEARCH + # ================================================================ + + def pico_search( + self, + population: str = Field(..., description="Who?"), + intervention: str = Field("", description="What treatment/exposure?"), + comparison: str = Field("", description="Versus what?"), + outcome: str = Field("", description="What outcome?"), + max_results: int = Field(15, description="How many (1-50)"), + ) -> str: + """PICO framework search with abstracts for synthesis.""" + try: + max_results = self._safe_int(max_results, 15, 1, 50) + + pico = {} + for label, val in [ + ("Population", population), + ("Intervention", intervention), + ("Comparison", comparison), + ("Outcome", outcome), + ]: + val = str(val).strip() if val else "" + if val: + pico[label] = val + + if not pico: + return "Please provide at least a Population." + + pico_analysis = {} + for comp, text in pico.items(): + pico_analysis[comp] = self._analyze_via_pubmed(text) + + # Build and run strategies + all_articles, search_log = self._pico_iterative_search( + pico, pico_analysis, max_results + ) + + # Fetch abstracts + if all_articles: + pmids = [a["pmid"] for a in all_articles] + abstracts = self._fetch_abstracts(pmids) + for a in all_articles: + a["abstract"] = abstracts.get(a["pmid"], "") + + combined_query = " ".join(pico.values()) + scored = self._score_relevance( + all_articles, combined_query, self._detect_query_type(combined_query) + ) + top = scored[:max_results] + + self._last_results = top + self._last_query = f"PICO: {pico}" + + # Format + md = "# ๐Ÿ”ฌ PICO Search Results\n\n" + md += "## Framework\n\n" + md += "| Component | Your Input | PubMed Mapped To |\n" + md += "|-----------|-----------|------------------|\n" + for comp, text in pico.items(): + a = pico_analysis[comp] + mapped = ", ".join(a.get("mesh_found", [])[:3]) or a.get( + "cleaned_query", text + ) + md += f"| **{comp}** | {text} | {mapped} |\n" + md += "\n" + + for s in search_log: + icon = "โœ…" if s["found"] > 0 else "โญ•" + md += f"{icon} **{s['name']}** โ†’ {s['found']} results \n" + + md += "\n" + + if top: + md += f"## Top {len(top)} Results\n\n" + md += self._format_article_list(top, show_abstracts=True) + else: + md += "**No results.** Try broader terms.\n" + + md += self._format_next_steps() + return md + + except Exception as e: + return f"PICO error: {str(e)}" + + # ================================================================ + # MESH FINDER + # ================================================================ + + def find_mesh( + self, + topic: str = Field(..., description="Any medical topic"), + ) -> str: + """Find MeSH terms using PubMed's own term mapping.""" + try: + topic = str(topic).strip() + analysis = self._analyze_via_pubmed(topic) + + md = f"# ๐Ÿท๏ธ MeSH Terms for: {topic}\n\n" + + if analysis["mesh_found"]: + md += "| MeSH Term | Search Syntax |\n" + md += "|-----------|---------------|\n" + for t in analysis["mesh_found"]: + md += f'| {t} | `"{t}"[MeSH]` |\n' + md += "\n" + + if analysis["query_translation"]: + md += f"**PubMed Translation:**\n```\n{analysis['query_translation']}\n```\n\n" + + return md + + except Exception as e: + return f"MeSH error: {str(e)}" + + # ================================================================ + # CORE: ABSTRACT FETCHING + # ================================================================ + + def _fetch_abstracts(self, pmids: List[str]) -> Dict[str, str]: + """ + Fetch full abstracts from PubMed for a list of PMIDs. + Returns {pmid: abstract_text} + + This is the KEY function that enables AI synthesis. + """ + + abstracts = {} + if not pmids: + return abstracts + + # Process in batches of 20 to avoid API limits + batch_size = 20 + for i in range(0, len(pmids), batch_size): + batch = pmids[i : i + batch_size] + + try: + resp = requests.get( + f"{self.base_url}/efetch.fcgi", + params={ + "db": "pubmed", + "id": ",".join(batch), + "rettype": "xml", + "retmode": "xml", + }, + timeout=30, + ) + + if resp.status_code != 200: + continue + + # Parse XML to extract abstracts + root = ET.fromstring(resp.content) + + for article_elem in root.findall(".//PubmedArticle"): + # Get PMID + pmid_elem = article_elem.find(".//PMID") + if pmid_elem is None: + continue + pmid = pmid_elem.text + + # Get Abstract + abstract_parts = [] + + # Handle structured abstracts (with labels like Background, Methods, etc.) + abstract_elem = article_elem.find(".//Abstract") + if abstract_elem is not None: + for text_elem in abstract_elem.findall("AbstractText"): + label = text_elem.get("Label", "") + text = self._get_element_text(text_elem) + + if text: + if label: + abstract_parts.append(f"**{label}:** {text}") + else: + abstract_parts.append(text) + + if abstract_parts: + abstracts[pmid] = "\n\n".join(abstract_parts) + + # Also try to get keywords and MeSH headings + # (useful for the AI to understand the article) + mesh_headings = [] + for mesh_elem in article_elem.findall( + ".//MeshHeading/DescriptorName" + ): + if mesh_elem.text: + mesh_headings.append(mesh_elem.text) + + if mesh_headings and pmid in abstracts: + abstracts[ + pmid + ] += f"\n\n**MeSH Keywords:** {', '.join(mesh_headings[:10])}" + + keywords = [] + for kw_elem in article_elem.findall(".//Keyword"): + if kw_elem.text: + keywords.append(kw_elem.text) + + if keywords and pmid in abstracts: + abstracts[ + pmid + ] += f"\n**Author Keywords:** {', '.join(keywords[:10])}" + + except ET.ParseError: + # If XML parsing fails, try regex fallback + self._fetch_abstracts_fallback(batch, abstracts, resp.text) + except Exception: + continue + + return abstracts + + def _get_element_text(self, elem) -> str: + """ + Get all text from an XML element, including text in child elements. + Handles cases like: Some text italic more text + """ + parts = [] + if elem.text: + parts.append(elem.text) + for child in elem: + if child.text: + parts.append(child.text) + if child.tail: + parts.append(child.tail) + return " ".join(parts).strip() + + def _fetch_abstracts_fallback( + self, pmids: List[str], abstracts: Dict, xml_text: str + ): + """Regex fallback if XML parsing fails""" + try: + for pmid in pmids: + if pmid in abstracts: + continue + + # Find abstract text using regex + pattern = ( + rf"]*>{re.escape(pmid)}" + rf".*?" + rf"(.*?)" + ) + match = re.search(pattern, xml_text, re.DOTALL) + if match: + abstract_xml = match.group(1) + # Strip XML tags + text = re.sub(r"<[^>]+>", " ", abstract_xml) + text = re.sub(r"\s+", " ", text).strip() + if text: + abstracts[pmid] = text + except Exception: + pass + + # ================================================================ + # CORE: PUBMED QUERY ANALYSIS + # ================================================================ + + def _analyze_via_pubmed(self, query: str) -> Dict: + """Let PubMed itself parse and understand the query""" + + cache_key = query.lower().strip() + if cache_key in self._cache: + return self._cache[cache_key] + + result = { + "original": query, + "cleaned_query": query, + "query_translation": "", + "mesh_found": [], + "result_count": 0, + } + + try: + resp = requests.get( + f"{self.base_url}/esearch.fcgi", + params={ + "db": "pubmed", + "term": query, + "retmode": "json", + "retmax": "0", + "usehistory": "n", + }, + timeout=15, + ) + + if resp.status_code == 200: + data = resp.json() + esearch = data.get("esearchresult", {}) + result["result_count"] = int(esearch.get("count", 0)) + result["query_translation"] = esearch.get("querytranslation", "") + + if result["query_translation"]: + mesh = re.findall( + r'"([^"]+)"\[MeSH Terms\]', result["query_translation"] + ) + result["mesh_found"] = list(dict.fromkeys(mesh)) + + # Also check full phrase as MeSH + resp2 = requests.get( + f"{self.base_url}/esearch.fcgi", + params={ + "db": "pubmed", + "term": f'"{query}"[MeSH Terms]', + "retmode": "json", + "retmax": "0", + }, + timeout=10, + ) + + if resp2.status_code == 200: + data2 = resp2.json() + trans2 = data2.get("esearchresult", {}).get("querytranslation", "") + count2 = int(data2.get("esearchresult", {}).get("count", 0)) + if count2 > 0 and trans2: + extra = re.findall(r'"([^"]+)"\[MeSH Terms\]', trans2) + for t in extra: + if t not in result["mesh_found"]: + result["mesh_found"].append(t) + + except Exception: + pass + + self._cache[cache_key] = result + return result + + # ================================================================ + # QUERY TYPE DETECTION + # ================================================================ + + def _detect_query_type(self, query: str) -> str: + q = query.lower() + if any( + w in q + for w in [ + "guideline", + "guidelines", + "protocol", + "recommendation", + "consensus", + "management of", + ] + ): + return "guidelines" + elif any(w in q for w in ["systematic review", "meta-analysis"]): + return "systematic_review" + elif any(w in q for w in ["outcome", "outcomes", "effectiveness", "efficacy"]): + return "outcomes" + elif any(w in q for w in ["prevalence", "incidence", "epidemiology"]): + return "epidemiology" + elif any(w in q for w in ["diagnosis", "diagnostic", "screening"]): + return "diagnosis" + elif any(w in q for w in ["treatment", "therapy", "drug", "medication"]): + return "treatment" + elif any(w in q for w in ["risk factor", "cause", "etiology"]): + return "risk_factors" + return "general" + + # ================================================================ + # SEARCH STRATEGIES + # ================================================================ + + def _iterative_search(self, query, analysis, query_type, max_results): + strategies = self._build_strategies(query, analysis, query_type) + return self._run_strategies(strategies, max_results) + + def _build_strategies(self, query, analysis, query_type): + strategies = [] + mesh = analysis.get("mesh_found", []) + count = analysis.get("result_count", 0) + type_filter = self._get_type_filter(query_type) + + # Strategy 1: Original query (PubMed auto-maps it) + if count > 0: + strategies.append(("PubMed Auto-Mapping", query)) + + # Strategy 2: MeSH + type filter + if mesh and type_filter: + mq = " AND ".join([f'"{t}"[MeSH]' for t in mesh[:4]]) + strategies.append((f"MeSH + {query_type} filter", f"{mq}{type_filter}")) + + # Strategy 3: MeSH only + if mesh: + mq = " AND ".join([f'"{t}"[MeSH]' for t in mesh[:4]]) + strategies.append(("MeSH Combined", mq)) + + # Strategy 4: Core MeSH (top 2) + if len(mesh) >= 2: + strategies.append(("Core MeSH", f'"{mesh[0]}"[MeSH] AND "{mesh[1]}"[MeSH]')) + + # Strategy 5: Primary MeSH + filter + if mesh and type_filter: + strategies.append( + (f"Primary MeSH + {query_type}", f'"{mesh[0]}"[MeSH]{type_filter}') + ) + + # Strategy 6: Title/Abstract + strategies.append(("Title/Abstract", f"({query})[tiab]")) + + # Strategy 7: All fields + strategies.append(("All Fields", query)) + + return strategies + + def _pico_iterative_search(self, pico, pico_analysis, max_results): + strategies = [] + + # Build per-component queries + comp_parts = [] + for comp, analysis in pico_analysis.items(): + mesh = analysis.get("mesh_found", []) + if mesh: + if len(mesh) > 1: + group = " OR ".join([f'"{t}"[MeSH]' for t in mesh[:3]]) + comp_parts.append(f"({group})") + else: + comp_parts.append(f'"{mesh[0]}"[MeSH]') + else: + comp_parts.append(f"({pico[comp]})") + + if len(comp_parts) >= 2: + strategies.append(("Full PICO", " AND ".join(comp_parts))) + + combined = " ".join(pico.values()) + strategies.append(("Natural Language", combined)) + + # P + I + for pair_name, keys in [ + ("P+I", ["Population", "Intervention"]), + ("P+O", ["Population", "Outcome"]), + ]: + parts = [] + for k in keys: + if k in pico_analysis: + mesh = pico_analysis[k].get("mesh_found", []) + if mesh: + parts.append(f'"{mesh[0]}"[MeSH]') + elif k in pico: + parts.append(pico[k]) + if len(parts) == 2: + strategies.append((pair_name, " AND ".join(parts))) + + strategies.append(("Broad", combined)) + + return self._run_strategies(strategies, max_results) + + def _run_strategies(self, strategies, max_results): + all_articles = [] + seen = set() + log = [] + + for name, sq in strategies: + if not sq: + continue + results = self._run_search(sq, min(max_results * 2, 50)) + log.append({"name": name, "query": sq, "found": len(results)}) + + for a in results: + if a["pmid"] not in seen: + seen.add(a["pmid"]) + a["found_via"] = name + all_articles.append(a) + + if len(all_articles) >= max_results * 3 and len(log) >= 3: + break + + return all_articles, log + + def _get_type_filter(self, query_type): + filters = { + "guidelines": ' AND ("Practice Guideline"[PT] OR "Guideline"[PT] OR guideline[ti])', + "systematic_review": ' AND ("Systematic Review"[PT] OR "Meta-Analysis"[PT])', + "outcomes": ' AND ("Clinical Trial"[PT] OR "Comparative Study"[PT])', + "diagnosis": " AND (diagnosis[ti] OR diagnostic[ti])", + "epidemiology": " AND (prevalence[ti] OR incidence[ti] OR epidemiology[sh])", + "treatment": ' AND ("Clinical Trial"[PT] OR "Randomized Controlled Trial"[PT])', + } + return filters.get(query_type, "") + + # ================================================================ + # RELEVANCE SCORING (now uses abstracts) + # ================================================================ + + def _score_relevance(self, articles, query, query_type): + query_words = set(re.findall(r"[a-z]{3,}", query.lower())) + stops = { + "the", + "and", + "for", + "with", + "from", + "that", + "this", + "are", + "was", + "were", + "been", + "have", + "has", + "how", + "what", + "which", + "current", + "recent", + "new", + "using", + "based", + } + query_words -= stops + + type_bonuses = { + "guidelines": [ + "guideline", + "guidelines", + "recommendation", + "consensus", + "management", + ], + "systematic_review": ["systematic", "review", "meta-analysis"], + "outcomes": ["outcome", "outcomes", "effectiveness", "efficacy"], + "diagnosis": ["diagnosis", "diagnostic", "screening"], + "treatment": ["treatment", "therapy", "therapeutic", "intervention"], + "epidemiology": ["prevalence", "incidence", "epidemiology"], + } + + for article in articles: + score = 0 + title_lower = article.get("title", "").lower() + abstract_lower = article.get("abstract", "").lower() + + # Title word overlap + title_words = set(re.findall(r"[a-z]{3,}", title_lower)) + score += len(query_words & title_words) * 5 + + # Abstract word overlap (worth less per word, but still valuable) + if abstract_lower: + abstract_words = set(re.findall(r"[a-z]{3,}", abstract_lower)) + score += min(15, len(query_words & abstract_words) * 2) + + # Query type bonus + if query_type in type_bonuses: + for w in type_bonuses[query_type]: + if w in title_lower: + score += 10 + if w in abstract_lower: + score += 3 + + # Recency + year = self._extract_year(article.get("pubdate", "")) + if year: + if year >= 2023: + score += 8 + elif year >= 2020: + score += 5 + elif year >= 2015: + score += 2 + + # Journal quality + journal_lower = article.get("journal", "").lower() + if any( + j in journal_lower + for j in [ + "lancet", + "bmj", + "jama", + "new england", + "cochrane", + "pediatrics", + "annals", + "nature", + "plos", + ] + ): + score += 5 + + # Bonus if abstract exists (more informative article) + if abstract_lower: + score += 3 + + article["relevance_score"] = score + + articles.sort(key=lambda x: x.get("relevance_score", 0), reverse=True) + return articles + + # ================================================================ + # PUBMED SEARCH API + # ================================================================ + + def _run_search(self, query, max_results): + max_results = self._safe_int(max_results, 10, 1, 50) + try: + resp = requests.get( + f"{self.base_url}/esearch.fcgi", + params={ + "db": "pubmed", + "term": str(query), + "retmode": "json", + "retmax": str(max_results), + "sort": "relevance", + }, + timeout=20, + ) + resp.raise_for_status() + esearch = resp.json().get("esearchresult", {}) + if "ERROR" in esearch: + return [] + + ids = esearch.get("idlist", esearch.get("IdList", [])) + if not ids: + return [] + + resp = requests.get( + f"{self.base_url}/esummary.fcgi", + params={"db": "pubmed", "id": ",".join(ids), "retmode": "json"}, + timeout=20, + ) + resp.raise_for_status() + summaries = resp.json().get("result", {}) + + articles = [] + for aid in ids: + if aid not in summaries: + continue + art = summaries[aid] + if not isinstance(art, dict) or "title" not in art: + continue + articles.append( + { + "title": art.get("title", "Untitled"), + "authors": ", ".join( + a["name"] + for a in art.get("authors", []) + if isinstance(a, dict) and a.get("name") + ), + "pubdate": art.get("pubdate", ""), + "journal": art.get("fulljournalname", ""), + "volume": art.get("volume", ""), + "issue": art.get("issue", ""), + "pages": art.get("pages", ""), + "doi": next( + ( + x["value"] + for x in art.get("articleids", []) + if isinstance(x, dict) and x.get("idtype") == "doi" + ), + "", + ), + "pmid": aid, + "url": f"https://pubmed.ncbi.nlm.nih.gov/{aid}/", + "abstract": "", # Will be filled by _fetch_abstracts + } + ) + return articles + except Exception: + return [] + + # ================================================================ + # FORMATTING + # ================================================================ + + def _format_results( + self, query, analysis, query_type, search_log, articles, total, show_abstracts + ): + md = "# ๐Ÿ“š PubMed Search Results\n\n" + md += f"**Your Question:** {query}\n\n" + + # PubMed understanding + md += "## ๐Ÿง  How PubMed Understood Your Query\n\n" + md += f"**Query Type:** {query_type.replace('_', ' ').title()}\n\n" + + if analysis["mesh_found"]: + md += "**MeSH Terms:**\n" + for t in analysis["mesh_found"]: + md += f"- `{t}`\n" + md += "\n" + + if analysis["query_translation"]: + md += f"**PubMed Translation:**\n```\n{analysis['query_translation']}\n```\n\n" + + # Search process + md += "## ๐Ÿ”ง Search Process\n\n" + md += f"Ran {len(search_log)} strategies, collected {total} candidates, showing top {len(articles)}.\n\n" + for s in search_log: + icon = "โœ…" if s["found"] > 0 else "โญ•" + md += f"{icon} **{s['name']}** โ†’ {s['found']} \n" + md += "\n" + + # Results + md += f"## ๐Ÿ“„ Top {len(articles)} Results\n\n" + + # Count how many have abstracts + with_abstracts = sum(1 for a in articles if a.get("abstract")) + if with_abstracts > 0: + md += f"*๐Ÿ“‹ {with_abstracts}/{len(articles)} articles have abstracts available for AI analysis*\n\n" + + md += self._format_article_list(articles, show_abstracts=show_abstracts) + + # Synthesis hint + if with_abstracts > 0: + md += "## ๐Ÿค– AI Can Now Analyze These\n\n" + md += "The abstracts have been loaded. You can now ask me:\n\n" + md += f"> Based on these results, what are the key findings about {query}?\n\n" + md += f"> Summarize the evidence on {query}\n\n" + md += f"> What do these studies conclude about {query}?\n\n" + + md += self._format_next_steps() + return md + + def _format_article_list(self, articles, show_abstracts=True): + md = "" + for i, a in enumerate(articles): + score = a.get("relevance_score", 0) + stars = min(5, max(1, score // 5)) + + md += f"### {i+1}. {a.get('title', 'Untitled')}\n\n" + + if a.get("authors"): + auths = a["authors"].split(", ") + if len(auths) > 3: + md += f"**Authors:** {', '.join(auths[:3])}, et al.\n\n" + else: + md += f"**Authors:** {a['authors']}\n\n" + + info = [] + if a.get("journal"): + info.append(f"*{a['journal']}*") + if a.get("pubdate"): + info.append(a["pubdate"]) + v = a.get("volume", "") + if v: + if a.get("issue"): + v += f"({a['issue']})" + if a.get("pages"): + v += f":{a['pages']}" + info.append(v) + if info: + md += f"**Published:** {' | '.join(info)}\n\n" + + if a.get("doi"): + md += f"**DOI:** [{a['doi']}](https://doi.org/{a['doi']})\n\n" + + md += f"๐Ÿ”— [PubMed PMID {a['pmid']}]({a['url']}) ยท {'โญ' * stars}\n\n" + + # ABSTRACT โ€” the key addition + if show_abstracts and a.get("abstract"): + abstract = a["abstract"] + md += f"
\n๐Ÿ“‹ Abstract (click to expand)\n\n" + md += f"{abstract}\n\n" + md += f"
\n\n" + + md += "---\n\n" + + return md + + def _format_next_steps(self): + md = "\n## ๐Ÿ’ก What You Can Do Next\n\n" + md += "| Command | What You Get |\n" + md += "|---------|-------------|\n" + md += "| `get results as list` | Numbered reference list |\n" + md += "| `get results as ris` | RIS file for Zotero import |\n" + md += "| `get results as summary` | AI synthesis of findings |\n" + md += "| `get results as abstracts` | All abstracts for reading |\n" + md += "| `get results as detailed` | Full metadata for every article |\n" + md += "\n" + return md + + def _format_no_results(self, query, analysis, search_log): + md = f"# No Results Found\n\n**Query:** {query}\n\n" + if analysis["query_translation"]: + md += f"**PubMed interpreted as:**\n```\n{analysis['query_translation']}\n```\n\n" + for s in search_log: + md += f"โŒ {s['name']}: `{s['query']}`\n\n" + md += "Try simpler phrasing or use `find_mesh` to check terms.\n" + return md + + # ================================================================ + # OUTPUT FORMATS + # ================================================================ + + def _format_reference_list(self): + md = f"# ๐Ÿ“‹ References ({len(self._last_results)})\n\n" + for i, a in enumerate(self._last_results): + authors = a.get("authors", "") + auths = authors.split(", ") + if len(auths) > 3: + authors = ", ".join(auths[:3]) + ", et al." + year = self._extract_year(a.get("pubdate", "")) + year_str = f" ({year})" if year else "" + title = a.get("title", "").rstrip(".") + ref = f"{i+1}. {authors}.{year_str} {title}." + if a.get("journal"): + ref += f" *{a['journal']}*." + v = a.get("volume", "") + if v: + if a.get("issue"): + v += f"({a['issue']})" + if a.get("pages"): + v += f":{a['pages']}" + ref += f" {v}." + if a.get("doi"): + ref += f" doi:{a['doi']}" + ref += f" PMID:{a['pmid']}" + md += f"{ref}\n\n" + md += "> `get results as ris` for Zotero export\n" + return md + + def _export_ris(self): + ris = "" + for a in self._last_results: + ris += self._to_ris(a) + return ( + f"# ๐Ÿ“ฅ RIS Export ({len(self._last_results)} refs)\n\n" + "1. Copy code block โ†’ 2. Save as `.ris` โ†’ 3. Zotero Import\n\n" + f"```ris\n{ris}```\n" + ) + + def _format_abstracts_only(self): + """Output all abstracts โ€” perfect for AI to read and synthesize""" + md = f"# ๐Ÿ“‹ Abstracts ({len(self._last_results)} articles)\n\n" + md += f"**Search:** {self._last_query}\n\n---\n\n" + + for i, a in enumerate(self._last_results): + year = self._extract_year(a.get("pubdate", "")) or "n.d." + auths = a.get("authors", "").split(", ") + first = auths[0] if auths else "Unknown" + + md += f"## {i+1}. {a.get('title', 'Untitled')}\n" + md += f"*{first} et al. ({year}) โ€” {a.get('journal', '')}*\n\n" + + if a.get("abstract"): + md += f"{a['abstract']}\n\n" + else: + md += "*No abstract available.*\n\n" + + if a.get("doi"): + md += f"DOI: {a['doi']}\n" + md += f"PMID: {a['pmid']}\n\n" + md += "---\n\n" + + return md + + def _synthesize(self): + """Synthesis using abstract content""" + articles = self._last_results + md = f"# ๐Ÿ“Š Research Summary\n\n" + md += f"**Question:** {self._last_query}\n" + md += f"**Articles:** {len(articles)}\n\n" + + years = [self._extract_year(a.get("pubdate", "")) for a in articles] + years = [y for y in years if y] + if years: + md += f"**Publication Range:** {min(years)}โ€“{max(years)}\n\n" + + with_abstracts = sum(1 for a in articles if a.get("abstract")) + md += f"**Abstracts Available:** {with_abstracts}/{len(articles)}\n\n" + + # Journals + journals = {} + for a in articles: + j = a.get("journal", "Unknown") + journals[j] = journals.get(j, 0) + 1 + + md += "## Sources\n\n" + for j, c in sorted(journals.items(), key=lambda x: -x[1])[:8]: + md += f"- {j} ({c})\n" + md += "\n" + + # Key themes from titles AND abstracts + all_text = "" + for a in articles: + all_text += " " + a.get("title", "") + all_text += " " + a.get("abstract", "") + + word_freq = {} + stops = { + "the", + "and", + "for", + "with", + "from", + "that", + "this", + "was", + "were", + "been", + "have", + "has", + "study", + "review", + "analysis", + "patients", + "results", + "methods", + "conclusion", + "background", + "objective", + "purpose", + "clinical", + "using", + "based", + "among", + "between", + "however", + "conclusion", + "findings", + "group", + "compared", + "significantly", + "associated", + "included", + "data", + } + + for w in re.findall(r"[a-z]{4,}", all_text.lower()): + if w not in stops: + word_freq[w] = word_freq.get(w, 0) + 1 + + md += "## Key Themes\n\n" + for w, c in sorted(word_freq.items(), key=lambda x: -x[1])[:15]: + if c >= 3: + md += f"- **{w}** (appears {c} times)\n" + md += "\n" + + # Article summaries with abstract snippets + md += "## Article Summaries\n\n" + for i, a in enumerate(articles[:15]): + year = self._extract_year(a.get("pubdate", "")) or "n.d." + auths = a.get("authors", "").split(", ") + first = auths[0] if auths else "Unknown" + + md += f"### {i+1}. {first} ({year})\n" + md += f"**{a.get('title', '')}**\n" + md += f"*{a.get('journal', '')}*\n\n" + + if a.get("abstract"): + # Show first 300 chars of abstract + snippet = a["abstract"][:300] + if len(a["abstract"]) > 300: + snippet += "..." + md += f"{snippet}\n\n" + + md += "---\n" + md += "*Full abstracts available โ€” ask me to analyze specific findings.*\n" + return md + + def _format_detailed(self): + md = f"# ๐Ÿ“‘ Detailed ({len(self._last_results)})\n\n" + for i, a in enumerate(self._last_results): + md += f"## {i+1}. {a.get('title', 'Untitled')}\n\n" + md += f"- **Authors:** {a.get('authors', 'Unknown')}\n" + md += f"- **Journal:** {a.get('journal', 'Unknown')}\n" + md += f"- **Date:** {a.get('pubdate', 'Unknown')}\n" + if a.get("doi"): + md += f"- **DOI:** [{a['doi']}](https://doi.org/{a['doi']})\n" + md += f"- **PMID:** [{a['pmid']}]({a['url']})\n" + md += f"- **Relevance:** {a.get('relevance_score', 0)} ยท via {a.get('found_via', '?')}\n" + if a.get("abstract"): + md += f"\n**Abstract:**\n\n{a['abstract']}\n" + md += "\n---\n\n" + return md + + # ================================================================ + # UTILITIES + # ================================================================ + + def _to_ris(self, article): + ris = "TY - JOUR\n" + if article.get("authors"): + for a in article["authors"].split(", "): + if a.strip(): + ris += f"AU - {a.strip()}\n" + ris += f"T1 - {article.get('title', '').rstrip('.')}\n" + if article.get("journal"): + ris += f"JO - {article['journal']}\n" + if article.get("pubdate"): + m = re.search(r"(\d{4})", article["pubdate"]) + if m: + ris += f"PY - {m.group(1)}\n" + ris += f"DA - {article['pubdate']}\n" + if article.get("volume"): + ris += f"VL - {article['volume']}\n" + if article.get("issue"): + ris += f"IS - {article['issue']}\n" + if article.get("pages"): + if "-" in article["pages"]: + sp, ep = article["pages"].split("-", 1) + ris += f"SP - {sp.strip()}\n" + ris += f"EP - {ep.strip()}\n" + else: + ris += f"SP - {article['pages']}\n" + if article.get("doi"): + ris += f"DO - {article['doi']}\n" + if article.get("url"): + ris += f"UR - {article['url']}\n" + if article.get("abstract"): + ris += f"AB - {article['abstract'][:2000]}\n" + ris += "ER -\n\n" + return ris + + def _extract_year(self, pubdate): + if not pubdate: + return None + m = re.search(r"(\d{4})", str(pubdate)) + return int(m.group(1)) if m else None + + def _safe_int(self, value, default=10, minimum=1, maximum=50): + try: + r = int(float(str(value))) + except (TypeError, ValueError): + r = default + return max(minimum, min(maximum, r))