nutrilens / src /literature.py
vicarioush's picture
Initial NutriLens submission
8e54894
Raw
History Blame Contribute Delete
5.27 kB
"""
PubMed E-utilities client. No API key needed (3 req/sec).
Falls back gracefully when rate-limited.
"""
import time
import requests
import xml.etree.ElementTree as ET
from typing import Optional
from src.cache import get_cached, set_cached
EUTILS_BASE = "https://eutils.ncbi.nlm.nih.gov/entrez/eutils"
_last_req = 0.0
def _rate_limit():
global _last_req
wait = 0.35 - (time.time() - _last_req)
if wait > 0:
time.sleep(wait)
_last_req = time.time()
def _search(query: str, max_results: int = 3) -> Optional[list[str]]:
_rate_limit()
try:
resp = requests.get(f"{EUTILS_BASE}/esearch.fcgi", params={
"db": "pubmed", "retmode": "json", "retmax": max_results,
"sort": "relevance", "term": query,
}, timeout=10)
if resp.status_code == 429:
return None
resp.raise_for_status()
return resp.json().get("esearchresult", {}).get("idlist", [])
except Exception as e:
print(f"PubMed search error: {e}")
return None
def _fetch(pmids: list[str]) -> list[dict]:
if not pmids:
return []
_rate_limit()
try:
resp = requests.get(f"{EUTILS_BASE}/efetch.fcgi", params={
"db": "pubmed", "id": ",".join(pmids),
"retmode": "xml", "rettype": "abstract",
}, timeout=10)
resp.raise_for_status()
articles = []
root = ET.fromstring(resp.text)
for elem in root.findall(".//PubmedArticle"):
a = _parse(elem)
if a:
articles.append(a)
return articles
except Exception as e:
print(f"PubMed fetch error: {e}")
return []
def _parse(elem) -> Optional[dict]:
try:
med = elem.find(".//MedlineCitation")
art = med.find(".//Article")
pmid = med.findtext("PMID", "")
title = art.findtext("ArticleTitle", "")
authors = []
al = art.find("AuthorList")
if al is not None:
for a in al.findall("Author")[:3]:
last = a.findtext("LastName", "")
init = a.findtext("Initials", "")
if last:
authors.append(f"{last} {init}".strip())
author_str = ", ".join(authors)
if len(al.findall("Author") if al is not None else []) > 3:
author_str += " et al."
j = art.find("Journal")
journal = j.findtext("ISOAbbreviation", "") if j is not None else ""
pd = j.find(".//PubDate") if j is not None else None
year = pd.findtext("Year", "") if pd is not None else ""
abstract_parts = []
ab = art.find("Abstract")
if ab is not None:
for t in ab.findall("AbstractText"):
label = t.get("Label", "")
content = (t.text or "")[:400]
abstract_parts.append(f"{label}: {content}" if label else content)
doi = ""
for id_el in elem.findall(".//ArticleId"):
if id_el.get("IdType") == "doi":
doi = id_el.text or ""
break
return {
"pmid": pmid, "title": title, "authors": author_str,
"journal": journal, "year": year,
"abstract": " ".join(abstract_parts)[:800],
"doi": doi, "url": f"https://pubmed.ncbi.nlm.nih.gov/{pmid}/",
}
except Exception:
return None
def search_food_health(ingredient: str, health_aspect: str = "",
max_papers: int = 2) -> list[dict]:
"""Search PubMed for studies on a food's health effects. Cached."""
if health_aspect:
query = f'({ingredient}) AND ({health_aspect}) AND (health OR nutrition)'
else:
query = f'({ingredient}) AND (health effects OR nutritional)'
query += ' AND (Review[pt] OR Meta-Analysis[pt])'
cache_key = f"lit:{ingredient}:{health_aspect}"
cached = get_cached("literature_cache", cache_key)
if cached is not None:
return cached
pmids = _search(query, max_results=max_papers)
if not pmids:
# broader retry without review filter
pmids = _search(query.replace(' AND (Review[pt] OR Meta-Analysis[pt])', ''),
max_results=max_papers)
if not pmids:
return []
articles = _fetch(pmids)
set_cached("literature_cache", cache_key, articles, ttl_days=7)
return articles
def format_citation(a: dict) -> str:
parts = []
if a.get("authors"): parts.append(a["authors"])
if a.get("year"): parts.append(f"({a['year']})")
if a.get("title"): parts.append(f"{a['title'].rstrip('.')}.")
if a.get("journal"): parts.append(f"{a['journal']}.")
if a.get("doi"): parts.append(f"DOI: {a['doi']}")
return " ".join(parts)
def lookup_literature(ingredients: list[str], health_goal: str = "",
papers_per: int = 2) -> tuple[dict, int]:
"""Look up literature for ingredients. Returns (results, failure_count)."""
results = {}
failures = 0
for ing in ingredients:
articles = search_food_health(ing.strip(), health_goal, papers_per)
if articles:
results[ing] = articles
else:
failures += 1
results[ing] = []
return results, failures