juan commited on
Commit
83541b0
·
1 Parent(s): caaeb82

pubmed search

Browse files
Files changed (3) hide show
  1. app.py +47 -6
  2. requirements.txt +1 -1
  3. 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 an agent designed to interact with a SQL database.
 
 
 
 
 
 
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
- To start you should ALWAYS look at the tables in the database to see what you
73
- can query. Do NOT skip this step.
 
 
 
 
 
 
 
 
 
 
 
 
 
74
 
75
- Then you should query the schema of the most relevant tables.
 
 
 
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
- metapub
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 metapub as mpub
 
 
2
 
3
- def literature_search(query):
4
- fetch = mpub.PubMedFetcher()
5
- ids = fetch.pmids_for_query(query)
6
 
7
- return(len(ids))
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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}'