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