Spaces:
Sleeping
Sleeping
juan commited on
Commit ·
83541b0
1
Parent(s): caaeb82
pubmed search
Browse files- app.py +47 -6
- requirements.txt +1 -1
- scripts/literature.py +144 -5
app.py
CHANGED
|
@@ -22,8 +22,10 @@ from langchain_anthropic import ChatAnthropic
|
|
| 22 |
from langgraph.checkpoint.sqlite import SqliteSaver
|
| 23 |
from langchain_community.agent_toolkits import SQLDatabaseToolkit
|
| 24 |
from langchain.agents import create_agent
|
|
|
|
| 25 |
|
| 26 |
from scripts.db import create_database
|
|
|
|
| 27 |
|
| 28 |
# --- Paths -----------------------------------------------------------
|
| 29 |
_DATA_DIR = "/data" if os.path.isdir("/data") else "."
|
|
@@ -51,9 +53,32 @@ def _save_to_registry(name, db_file):
|
|
| 51 |
with open(_REGISTRY_PATH, "w") as f:
|
| 52 |
json.dump(registry, f)
|
| 53 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 54 |
# --- Agent -----------------------------------------------------------
|
| 55 |
_SYSTEM_PROMPT = """
|
| 56 |
-
You are
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 57 |
Given an input question, create a syntactically correct {dialect} query to run,
|
| 58 |
then look at the results of the query and return the answer. Unless the user
|
| 59 |
specifies a specific number of examples they wish to obtain, always limit your
|
|
@@ -69,10 +94,26 @@ executing a query, rewrite the query and try again.
|
|
| 69 |
DO NOT make any DML statements (INSERT, UPDATE, DELETE, DROP etc.) to the
|
| 70 |
database.
|
| 71 |
|
| 72 |
-
|
| 73 |
-
can query. Do NOT skip this step.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 74 |
|
| 75 |
-
|
|
|
|
|
|
|
|
|
|
| 76 |
"""
|
| 77 |
|
| 78 |
def _init_agent(db):
|
|
@@ -80,14 +121,14 @@ def _init_agent(db):
|
|
| 80 |
toolkit = SQLDatabaseToolkit(db=db, llm=model)
|
| 81 |
agent = create_agent(
|
| 82 |
model,
|
| 83 |
-
toolkit.get_tools(),
|
| 84 |
system_prompt=_SYSTEM_PROMPT.format(dialect=db.dialect, top_k=5),
|
| 85 |
checkpointer=memory,
|
| 86 |
)
|
| 87 |
|
| 88 |
# --- UI helpers ------------------------------------------------------
|
| 89 |
_msg_locked = dict(interactive=False, placeholder="Select or load a dataset to start chatting")
|
| 90 |
-
_msg_open = dict(interactive=True, placeholder="Ask about your proteomics data")
|
| 91 |
|
| 92 |
def _read_tabular(path, nrows=None):
|
| 93 |
if path.endswith((".xlsx", ".xls")):
|
|
|
|
| 22 |
from langgraph.checkpoint.sqlite import SqliteSaver
|
| 23 |
from langchain_community.agent_toolkits import SQLDatabaseToolkit
|
| 24 |
from langchain.agents import create_agent
|
| 25 |
+
from langchain_core.tools import tool
|
| 26 |
|
| 27 |
from scripts.db import create_database
|
| 28 |
+
from scripts.literature import literature_search
|
| 29 |
|
| 30 |
# --- Paths -----------------------------------------------------------
|
| 31 |
_DATA_DIR = "/data" if os.path.isdir("/data") else "."
|
|
|
|
| 53 |
with open(_REGISTRY_PATH, "w") as f:
|
| 54 |
json.dump(registry, f)
|
| 55 |
|
| 56 |
+
# --- Tools -----------------------------------------------------------
|
| 57 |
+
@tool
|
| 58 |
+
def pubmed_search(query: str, max_results: int = 5) -> str:
|
| 59 |
+
"""Search PubMed for published biomedical literature and return titles,
|
| 60 |
+
authors, journal, year, PMID, URL and abstract for the top matches.
|
| 61 |
+
|
| 62 |
+
Use this when the user asks about published research, what is known about a
|
| 63 |
+
protein or gene, or for evidence supporting a finding in the dataset.
|
| 64 |
+
|
| 65 |
+
`query` accepts PubMed search syntax. Prefer gene or protein names over
|
| 66 |
+
UniProt accessions, and combine concepts with AND, e.g.
|
| 67 |
+
'Sox2 AND neural stem cell' or 'Tp53[Title] AND apoptosis'.
|
| 68 |
+
`max_results` is capped at 20.
|
| 69 |
+
"""
|
| 70 |
+
return literature_search(query, max_results)
|
| 71 |
+
|
| 72 |
+
|
| 73 |
# --- Agent -----------------------------------------------------------
|
| 74 |
_SYSTEM_PROMPT = """
|
| 75 |
+
You are a proteomics research assistant. You can query a SQL database holding
|
| 76 |
+
the user's proteomics dataset, and you can search PubMed for published
|
| 77 |
+
literature. Decide which tools a question needs: dataset questions need SQL,
|
| 78 |
+
questions about published research need PubMed, and some questions need both.
|
| 79 |
+
|
| 80 |
+
## Querying the dataset
|
| 81 |
+
|
| 82 |
Given an input question, create a syntactically correct {dialect} query to run,
|
| 83 |
then look at the results of the query and return the answer. Unless the user
|
| 84 |
specifies a specific number of examples they wish to obtain, always limit your
|
|
|
|
| 94 |
DO NOT make any DML statements (INSERT, UPDATE, DELETE, DROP etc.) to the
|
| 95 |
database.
|
| 96 |
|
| 97 |
+
Before running any query you should ALWAYS look at the tables in the database
|
| 98 |
+
to see what you can query. Do NOT skip this step. Then you should query the
|
| 99 |
+
schema of the most relevant tables.
|
| 100 |
+
|
| 101 |
+
## Searching the literature
|
| 102 |
+
|
| 103 |
+
Use `pubmed_search` when the user asks what is published or known about a
|
| 104 |
+
protein, gene or biological process, or asks for evidence supporting something
|
| 105 |
+
in the dataset.
|
| 106 |
+
|
| 107 |
+
Search by gene or protein name rather than UniProt accession, since accessions
|
| 108 |
+
rarely appear in abstracts. If the dataset gives you an accession, look up its
|
| 109 |
+
gene name or description in the database first, then search on that. If a
|
| 110 |
+
search returns nothing, retry with broader or alternative terms before
|
| 111 |
+
concluding there is no literature.
|
| 112 |
|
| 113 |
+
Ground your answer in the abstracts returned and cite each claim with its PMID
|
| 114 |
+
and PubMed URL. Never invent a citation, PMID or finding that is not in the
|
| 115 |
+
search results. If a question combines the dataset and the literature, report
|
| 116 |
+
the dataset result first, then what the literature says about it.
|
| 117 |
"""
|
| 118 |
|
| 119 |
def _init_agent(db):
|
|
|
|
| 121 |
toolkit = SQLDatabaseToolkit(db=db, llm=model)
|
| 122 |
agent = create_agent(
|
| 123 |
model,
|
| 124 |
+
toolkit.get_tools() + [pubmed_search],
|
| 125 |
system_prompt=_SYSTEM_PROMPT.format(dialect=db.dialect, top_k=5),
|
| 126 |
checkpointer=memory,
|
| 127 |
)
|
| 128 |
|
| 129 |
# --- UI helpers ------------------------------------------------------
|
| 130 |
_msg_locked = dict(interactive=False, placeholder="Select or load a dataset to start chatting")
|
| 131 |
+
_msg_open = dict(interactive=True, placeholder="Ask about your proteomics data or the PubMed literature")
|
| 132 |
|
| 133 |
def _read_tabular(path, nrows=None):
|
| 134 |
if path.endswith((".xlsx", ".xls")):
|
requirements.txt
CHANGED
|
@@ -2,7 +2,7 @@ langchain
|
|
| 2 |
langchain-community
|
| 3 |
gradio
|
| 4 |
nltk==3.9.1
|
| 5 |
-
|
| 6 |
langchain-experimental
|
| 7 |
langgraph
|
| 8 |
langchain-anthropic
|
|
|
|
| 2 |
langchain-community
|
| 3 |
gradio
|
| 4 |
nltk==3.9.1
|
| 5 |
+
requests
|
| 6 |
langchain-experimental
|
| 7 |
langgraph
|
| 8 |
langchain-anthropic
|
scripts/literature.py
CHANGED
|
@@ -1,7 +1,146 @@
|
|
| 1 |
-
import
|
|
|
|
|
|
|
| 2 |
|
| 3 |
-
|
| 4 |
-
fetch = mpub.PubMedFetcher()
|
| 5 |
-
ids = fetch.pmids_for_query(query)
|
| 6 |
|
| 7 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import os
|
| 2 |
+
import time
|
| 3 |
+
import xml.etree.ElementTree as ET
|
| 4 |
|
| 5 |
+
import requests
|
|
|
|
|
|
|
| 6 |
|
| 7 |
+
_EUTILS = 'https://eutils.ncbi.nlm.nih.gov/entrez/eutils'
|
| 8 |
+
_TIMEOUT = 30
|
| 9 |
+
|
| 10 |
+
# NCBI allows 3 req/s without an API key, 10 req/s with one. Each search costs
|
| 11 |
+
# two requests (esearch + efetch), so consecutive agent calls trip the limit.
|
| 12 |
+
_MIN_INTERVAL = 0.12 if os.environ.get('NCBI_API_KEY') else 0.4
|
| 13 |
+
_RETRIES = 3
|
| 14 |
+
_last_request = 0.0
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
def _params(**kwargs):
|
| 18 |
+
"""Common E-utilities params. NCBI_API_KEY raises the rate limit 3/s -> 10/s."""
|
| 19 |
+
params = {'db': 'pubmed', 'tool': 'BOTeome', **kwargs}
|
| 20 |
+
api_key = os.environ.get('NCBI_API_KEY')
|
| 21 |
+
if api_key:
|
| 22 |
+
params['api_key'] = api_key
|
| 23 |
+
email = os.environ.get('NCBI_EMAIL')
|
| 24 |
+
if email:
|
| 25 |
+
params['email'] = email
|
| 26 |
+
return params
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
def _get(endpoint, **params):
|
| 30 |
+
"""Throttled GET against E-utilities, retrying on rate limits."""
|
| 31 |
+
global _last_request
|
| 32 |
+
|
| 33 |
+
for attempt in range(_RETRIES):
|
| 34 |
+
wait = _MIN_INTERVAL - (time.monotonic() - _last_request)
|
| 35 |
+
if wait > 0:
|
| 36 |
+
time.sleep(wait)
|
| 37 |
+
|
| 38 |
+
r = requests.get(f'{_EUTILS}/{endpoint}', params=_params(**params), timeout=_TIMEOUT)
|
| 39 |
+
_last_request = time.monotonic()
|
| 40 |
+
|
| 41 |
+
if r.status_code == 429 and attempt < _RETRIES - 1:
|
| 42 |
+
time.sleep(2 ** attempt) # 1s, 2s
|
| 43 |
+
continue
|
| 44 |
+
r.raise_for_status()
|
| 45 |
+
return r
|
| 46 |
+
|
| 47 |
+
r.raise_for_status()
|
| 48 |
+
return r
|
| 49 |
+
|
| 50 |
+
|
| 51 |
+
def _text(node, path):
|
| 52 |
+
found = node.find(path)
|
| 53 |
+
return found.text.strip() if found is not None and found.text else ''
|
| 54 |
+
|
| 55 |
+
|
| 56 |
+
def _parse_article(article):
|
| 57 |
+
medline = article.find('MedlineCitation')
|
| 58 |
+
if medline is None:
|
| 59 |
+
return None
|
| 60 |
+
|
| 61 |
+
pmid = _text(medline, 'PMID')
|
| 62 |
+
art = medline.find('Article')
|
| 63 |
+
if art is None:
|
| 64 |
+
return None
|
| 65 |
+
|
| 66 |
+
# Abstracts are split into labelled sections (BACKGROUND, METHODS, ...).
|
| 67 |
+
sections = []
|
| 68 |
+
for chunk in art.findall('Abstract/AbstractText'):
|
| 69 |
+
body = ''.join(chunk.itertext()).strip()
|
| 70 |
+
if not body:
|
| 71 |
+
continue
|
| 72 |
+
label = chunk.get('Label')
|
| 73 |
+
sections.append(f'{label}: {body}' if label else body)
|
| 74 |
+
abstract = ' '.join(sections) or 'no abstract available'
|
| 75 |
+
|
| 76 |
+
authors = []
|
| 77 |
+
for author in art.findall('AuthorList/Author'):
|
| 78 |
+
last = _text(author, 'LastName')
|
| 79 |
+
initials = _text(author, 'Initials')
|
| 80 |
+
if last:
|
| 81 |
+
authors.append(f'{last} {initials}'.strip())
|
| 82 |
+
if len(authors) > 3:
|
| 83 |
+
authors = authors[:3] + ['et al.']
|
| 84 |
+
|
| 85 |
+
year = (
|
| 86 |
+
_text(art, 'Journal/JournalIssue/PubDate/Year')
|
| 87 |
+
or _text(art, 'Journal/JournalIssue/PubDate/MedlineDate')[:4]
|
| 88 |
+
or 'n.d.'
|
| 89 |
+
)
|
| 90 |
+
|
| 91 |
+
return {
|
| 92 |
+
'pmid': pmid,
|
| 93 |
+
'title': ''.join(art.find('ArticleTitle').itertext()).strip()
|
| 94 |
+
if art.find('ArticleTitle') is not None else 'untitled',
|
| 95 |
+
'journal': _text(art, 'Journal/ISOAbbreviation') or _text(art, 'Journal/Title'),
|
| 96 |
+
'year': year,
|
| 97 |
+
'authors': ', '.join(authors) or 'unknown authors',
|
| 98 |
+
'abstract': abstract,
|
| 99 |
+
'url': f'https://pubmed.ncbi.nlm.nih.gov/{pmid}/',
|
| 100 |
+
}
|
| 101 |
+
|
| 102 |
+
|
| 103 |
+
def search_pubmed(query, max_results=5):
|
| 104 |
+
"""Search PubMed and return a list of article dicts, most relevant first.
|
| 105 |
+
|
| 106 |
+
`query` accepts PubMed syntax, e.g. 'Tp53[Title] AND apoptosis'.
|
| 107 |
+
Raises requests.HTTPError if NCBI is unreachable or rejects the request.
|
| 108 |
+
"""
|
| 109 |
+
max_results = max(1, min(int(max_results), 20))
|
| 110 |
+
|
| 111 |
+
r = _get('esearch.fcgi', term=query, retmax=max_results, retmode='json', sort='relevance')
|
| 112 |
+
pmids = r.json().get('esearchresult', {}).get('idlist', [])
|
| 113 |
+
if not pmids:
|
| 114 |
+
return []
|
| 115 |
+
|
| 116 |
+
r = _get('efetch.fcgi', id=','.join(pmids), retmode='xml')
|
| 117 |
+
|
| 118 |
+
root = ET.fromstring(r.content)
|
| 119 |
+
articles = [_parse_article(a) for a in root.findall('PubmedArticle')]
|
| 120 |
+
return [a for a in articles if a]
|
| 121 |
+
|
| 122 |
+
|
| 123 |
+
def format_results(articles, abstract_chars=1200):
|
| 124 |
+
"""Render articles as plain text for the agent to read."""
|
| 125 |
+
if not articles:
|
| 126 |
+
return 'No PubMed articles found for that query.'
|
| 127 |
+
|
| 128 |
+
blocks = []
|
| 129 |
+
for i, a in enumerate(articles, 1):
|
| 130 |
+
abstract = a['abstract']
|
| 131 |
+
if len(abstract) > abstract_chars:
|
| 132 |
+
abstract = abstract[:abstract_chars].rstrip() + '...'
|
| 133 |
+
blocks.append(
|
| 134 |
+
f"[{i}] {a['title']}\n"
|
| 135 |
+
f"{a['authors']}. {a['journal']} ({a['year']}). PMID {a['pmid']} — {a['url']}\n"
|
| 136 |
+
f"{abstract}"
|
| 137 |
+
)
|
| 138 |
+
return '\n\n'.join(blocks)
|
| 139 |
+
|
| 140 |
+
|
| 141 |
+
def literature_search(query, max_results=5):
|
| 142 |
+
"""Search PubMed and return formatted text. Errors are returned, not raised."""
|
| 143 |
+
try:
|
| 144 |
+
return format_results(search_pubmed(query, max_results))
|
| 145 |
+
except Exception as e:
|
| 146 |
+
return f'PubMed search failed: {e}'
|