Mr-Gondal commited on
Commit
df2b7ae
·
1 Parent(s): e95bb2d

feat: add grounded Q&A and faster multi-source search

Browse files

Codex-assisted changes:
- Add grounded Q&A flow with reliable paper selection
- Replace Semantic Scholar with OpenAlex and Crossref
- Keep arXiv and PubMed for 4-source parallel search
- Reconstruct OpenAlex abstracts and strip Crossref JATS
- Add DOI-aware deduplication with title fallback
- Paginate results table with Prev/Next controls
- Improve Modal inference startup performance

README.md CHANGED
@@ -12,15 +12,16 @@ license: mit
12
 
13
  # Scholar Lens
14
 
15
- Scholar Lens is a Gradio research assistant for searching papers across Semantic Scholar, arXiv, and PubMed from one focused interface. It normalizes results into a clean table, lets users select a paper, and sends the title plus abstract directly to an AI summarizer hosted on Modal.
16
 
17
  ## Features
18
 
19
- - Search Semantic Scholar, arXiv, and PubMed with one query.
 
 
20
  - View normalized paper metadata: title, year, source, authors, citations, and abstract availability.
21
  - Select a search result and load it into the Summarize tab.
22
  - Summarize selected papers or pasted abstracts with a Modal-hosted language model.
23
- - Clear search results and reset the summarization workspace.
24
  - Professional dark Gradio interface with responsive result tables and source badges.
25
 
26
  ## Codex Track
@@ -54,8 +55,17 @@ python app.py
54
 
55
  The app launches a local Gradio server and prints the URL in the terminal.
56
 
57
- ## Modal Summarizer
58
 
59
- `modal_inference.py` defines a Modal FastAPI endpoint backed by `Qwen/Qwen2.5-7B-Instruct`. The Gradio app posts paper text to the deployed Modal endpoint configured in `MODAL_SUMMARIZE_URL`.
60
 
61
- If you deploy your own Modal endpoint, update `MODAL_SUMMARIZE_URL` in `app.py`.
 
 
 
 
 
 
 
 
 
 
12
 
13
  # Scholar Lens
14
 
15
+ Scholar Lens is a Gradio research assistant for searching papers across OpenAlex, Crossref, arXiv, and PubMed from one focused interface. It normalizes and de-duplicates results into a clean table, lets users select a paper, and sends the title plus abstract to a small open language model (Qwen2.5-7B) hosted on Modal.
16
 
17
  ## Features
18
 
19
+ - **Ask (grounded Q&A):** ask a research question; the app searches all four sources and a small open model writes a synthesized, **cited** answer grounded only in the retrieved abstracts (no invented sources).
20
+ - Search OpenAlex, Crossref, arXiv, and PubMed with one query.
21
+ - De-duplicates results across sources by DOI, then normalized title.
22
  - View normalized paper metadata: title, year, source, authors, citations, and abstract availability.
23
  - Select a search result and load it into the Summarize tab.
24
  - Summarize selected papers or pasted abstracts with a Modal-hosted language model.
 
25
  - Professional dark Gradio interface with responsive result tables and source badges.
26
 
27
  ## Codex Track
 
55
 
56
  The app launches a local Gradio server and prints the URL in the terminal.
57
 
58
+ ## Modal Inference
59
 
60
+ `modal_inference.py` defines a Modal class backed by `Qwen/Qwen2.5-7B-Instruct` (a small, ≤32B open model). The model is loaded once per container via `@modal.enter()` and kept warm, then exposed through two FastAPI endpoints:
61
 
62
+ - `summarize_paper` — condenses a single abstract or pasted text (`MODAL_SUMMARIZE_URL`).
63
+ - `synthesize` — answers a question grounded in numbered abstracts, with `[n]` citations (`MODAL_SYNTHESIZE_URL`).
64
+
65
+ Deploy with:
66
+
67
+ ```bash
68
+ modal deploy modal_inference.py
69
+ ```
70
+
71
+ If you deploy your own endpoints, update `MODAL_SUMMARIZE_URL` and `MODAL_SYNTHESIZE_URL` in `app.py` to match the URLs Modal prints.
__pycache__/app.cpython-311.pyc CHANGED
Binary files a/__pycache__/app.cpython-311.pyc and b/__pycache__/app.cpython-311.pyc differ
 
__pycache__/modal_inference.cpython-311.pyc CHANGED
Binary files a/__pycache__/modal_inference.cpython-311.pyc and b/__pycache__/modal_inference.cpython-311.pyc differ
 
app.py CHANGED
@@ -4,7 +4,7 @@ import html
4
  import re
5
  import textwrap
6
  import xml.etree.ElementTree as ET
7
- from concurrent.futures import ThreadPoolExecutor
8
  from dataclasses import dataclass
9
  from typing import Any
10
 
@@ -12,9 +12,19 @@ import gradio as gr
12
  import requests
13
 
14
  APP_TITLE = "Scholar Lens"
15
- SEARCH_LIMIT_PER_SOURCE = 8
16
- REQUEST_TIMEOUT_SECONDS = 12
 
 
 
17
  MODAL_SUMMARIZE_URL = "https://kinggondal731--scholar-lens-summarizer-summarize-paper.modal.run"
 
 
 
 
 
 
 
18
 
19
 
20
  @dataclass(frozen=True)
@@ -26,6 +36,7 @@ class PaperResult:
26
  citations: str
27
  url: str
28
  abstract: str = ""
 
29
 
30
 
31
  def _safe_text(value: Any, fallback: str = "Unknown") -> str:
@@ -62,30 +73,104 @@ def _request_json(url: str, params: dict[str, Any]) -> dict[str, Any]:
62
  return response.json()
63
 
64
 
65
- def search_semantic_scholar(query: str) -> tuple[list[PaperResult], str | None]:
66
- url = "https://api.semanticscholar.org/graph/v1/paper/search"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
67
  params = {
68
  "query": query,
69
- "limit": SEARCH_LIMIT_PER_SOURCE,
70
- "fields": "title,year,authors,citationCount,url,abstract",
 
71
  }
72
  try:
73
  payload = _request_json(url, params)
74
  except requests.RequestException:
75
- return [], "Semantic Scholar is unavailable right now."
76
 
77
  results: list[PaperResult] = []
78
- for paper in payload.get("data", []):
79
- authors = [author.get("name", "") for author in paper.get("authors", [])]
 
 
 
 
80
  results.append(
81
  PaperResult(
82
- title=_safe_text(paper.get("title"), "Untitled paper"),
83
- year=_safe_text(paper.get("year")),
84
- source="Semantic Scholar",
85
  authors=_shorten_authors(authors),
86
- citations=_safe_text(paper.get("citationCount"), "0"),
87
- url=_safe_text(paper.get("url"), "#"),
88
- abstract=_safe_text(paper.get("abstract"), ""),
 
89
  )
90
  )
91
  return results, None
@@ -204,7 +289,8 @@ def _fetch_pubmed_abstracts(paper_ids: list[str]) -> dict[str, str]:
204
 
205
  def _source_badge(source: str) -> str:
206
  config = {
207
- "Semantic Scholar": ("semantic", "🧠"),
 
208
  "arXiv": ("arxiv", "📐"),
209
  "PubMed": ("pubmed", "🧬"),
210
  }
@@ -216,7 +302,7 @@ def _source_badge(source: str) -> str:
216
  )
217
 
218
 
219
- def _render_results_table(results: list[PaperResult]) -> str:
220
  if not results:
221
  return """
222
  <div class="empty-state">
@@ -226,21 +312,20 @@ def _render_results_table(results: list[PaperResult]) -> str:
226
  </div>
227
  """
228
  rows = []
229
- for i, result in enumerate(results):
 
230
  safe_url = html.escape(result.url, quote=True)
231
  rows.append(
232
  textwrap.dedent(
233
  f"""
234
- <tr class="result-row" onclick="selectPaper({i})" id="row-{i}">
235
- <td class="select-cell">
236
- <input type="radio" name="paper-select" id="radio-{i}" onclick="event.stopPropagation(); selectPaper({i})">
237
- </td>
238
  <td class="title-cell">{html.escape(result.title)}</td>
239
  <td class="year-cell">{html.escape(result.year)}</td>
240
  <td>{_source_badge(result.source)}</td>
241
  <td class="authors-cell">{html.escape(result.authors)}</td>
242
  <td class="citation-cell">{html.escape(result.citations)}</td>
243
- <td><a class="paper-link" href="{safe_url}" target="_blank" rel="noopener noreferrer" onclick="event.stopPropagation()">Open ↗</a></td>
244
  </tr>
245
  """
246
  ).strip()
@@ -250,7 +335,7 @@ def _render_results_table(results: list[PaperResult]) -> str:
250
  <table class="results-table">
251
  <thead>
252
  <tr>
253
- <th style="width: 40px;"></th>
254
  <th>Title</th>
255
  <th>Year</th>
256
  <th>Source</th>
@@ -265,40 +350,50 @@ def _render_results_table(results: list[PaperResult]) -> str:
265
  """
266
 
267
 
 
 
 
 
 
 
 
 
 
 
 
268
  def _dedupe_results(results: list[PaperResult]) -> list[PaperResult]:
269
  """Drop duplicate papers that appear in more than one source.
270
 
271
  Papers are matched on a normalized title; the first occurrence wins so we
272
  keep the source order produced by the search functions.
273
  """
274
- seen: set[str] = set()
 
275
  unique: list[PaperResult] = []
276
  for result in results:
277
- key = re.sub(r"[^a-z0-9]+", "", result.title.lower())
278
- if not key or key in seen:
279
  continue
280
- seen.add(key)
 
 
 
 
 
 
281
  unique.append(result)
282
  return unique
283
 
284
 
285
- def search_all_sources(query: str) -> tuple[str, str, list[PaperResult], int | None]:
286
- clean_query = query.strip()
287
- if not clean_query:
288
- return (
289
- "Enter a research topic to search Semantic Scholar, arXiv, and PubMed.",
290
- _render_results_table([]),
291
- [],
292
- None,
293
- )
294
-
295
- search_functions = [search_semantic_scholar, search_arxiv, search_pubmed]
296
  results: list[PaperResult] = []
297
  warnings: list[str] = []
298
 
299
- with ThreadPoolExecutor(max_workers=3) as executor:
300
- future_to_search = {executor.submit(fn, clean_query): fn.__name__ for fn in search_functions}
301
- for future in future_to_search:
302
  try:
303
  source_results, warning = future.result()
304
  results.extend(source_results)
@@ -315,6 +410,27 @@ def search_all_sources(query: str) -> tuple[str, str, list[PaperResult], int | N
315
  return (1, int(item.year))
316
 
317
  results.sort(key=_sort_key, reverse=True)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
318
 
319
  if warnings and results:
320
  status = f"✓ Found **{len(results)}** papers. " + " ".join(warnings)
@@ -326,14 +442,21 @@ def search_all_sources(query: str) -> tuple[str, str, list[PaperResult], int | N
326
  if not results:
327
  status = "No papers found. Try a broader research topic or a different phrase."
328
 
 
329
  return (
330
  status,
331
- _render_results_table(results),
332
  results,
333
- None,
 
 
334
  )
335
 
336
 
 
 
 
 
337
  def summarize_with_modal(text: str) -> str:
338
  if not text or len(text.strip()) < 50:
339
  return "Please provide a longer abstract or paper text to summarize."
@@ -362,6 +485,99 @@ def summarize_with_modal(text: str) -> str:
362
  return _safe_text(payload, "No summary returned.")
363
 
364
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
365
  def select_result(event: gr.SelectData) -> int | None:
366
  if event.index is None:
367
  return None
@@ -398,10 +614,14 @@ def load_selected_paper(
398
 
399
 
400
  def summarize_selected_paper(
401
- selected_index: int | None,
402
  results: list[PaperResult],
403
  ) -> tuple[str, str, str, gr.Tabs]:
404
- paper_text, load_status, _, tab_update = load_selected_paper(selected_index, results)
 
 
 
 
405
  if not paper_text:
406
  return paper_text, load_status, "", tab_update
407
  if "No abstract is available" in paper_text:
@@ -414,12 +634,15 @@ def summarize_selected_paper(
414
  return paper_text, load_status, summarize_with_modal(paper_text), tab_update
415
 
416
 
417
- def clear_search() -> tuple[str, str, list[PaperResult], int | None, str, str]:
 
418
  return (
419
  "Enter a research topic to begin.",
420
- _render_results_table([]),
421
  [],
422
- None,
 
 
423
  "",
424
  "",
425
  )
@@ -587,17 +810,84 @@ CUSTOM_CSS = """
587
  font-weight: 600;
588
  color: var(--sl-text-bright) !important;
589
  }
 
590
 
591
- .source-badge.semantic { color: #60a5fa !important; background: rgba(59, 130, 246, 0.1); border: 1px solid rgba(59, 130, 246, 0.3); }
 
 
 
 
 
592
  .source-badge.arxiv { color: #f87171 !important; background: rgba(239, 68, 68, 0.1); border: 1px solid rgba(239, 68, 68, 0.3); }
593
  .source-badge.pubmed { color: #34d399 !important; background: rgba(16, 185, 129, 0.1); border: 1px solid rgba(16, 185, 129, 0.3); }
594
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
595
  /* Remove system buttons and footers */
596
  .settings, footer, .show-api { display: none !important; }
597
 
598
  .hidden-component { display: none !important; }
599
  """
600
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
601
  def build_app() -> tuple[gr.Blocks, gr.themes.Base]:
602
  theme = gr.themes.Base(
603
  primary_hue="blue",
@@ -612,33 +902,7 @@ def build_app() -> tuple[gr.Blocks, gr.themes.Base]:
612
  button_primary_text_color="#ffffff",
613
  )
614
 
615
- with gr.Blocks(title=APP_TITLE, head="""
616
- <script>
617
- function selectPaper(index) {
618
- // Update the hidden input
619
- const input = document.querySelector('#hidden_index_input textarea');
620
- if (input) {
621
- input.value = index;
622
- input.dispatchEvent(new Event('input', { bubbles: true }));
623
- }
624
-
625
- // Highlight the row
626
- document.querySelectorAll('.result-row').forEach(r => r.classList.remove('selected'));
627
- const row = document.getElementById('row-' + index);
628
- if (row) row.classList.add('selected');
629
-
630
- // Check the radio button
631
- const radio = document.getElementById('radio-' + index);
632
- if (radio) radio.checked = true;
633
-
634
- // Trigger the hidden button
635
- setTimeout(() => {
636
- const btn = document.querySelector('#hidden_select_btn button');
637
- if (btn) btn.click();
638
- }, 50);
639
- }
640
- </script>
641
- """) as app:
642
  with gr.Column(elem_classes=["main-shell"]):
643
  gr.HTML(
644
  """
@@ -649,7 +913,8 @@ def build_app() -> tuple[gr.Blocks, gr.themes.Base]:
649
  <h1>Scholar <span class="accent">Lens</span></h1>
650
  <p>Professional Research Discovery & Synthesis Engine</p>
651
  <div class="header-chips">
652
- <span class="header-chip">Semantic Scholar</span>
 
653
  <span class="header-chip">arXiv</span>
654
  <span class="header-chip">PubMed</span>
655
  </div>
@@ -660,13 +925,46 @@ def build_app() -> tuple[gr.Blocks, gr.themes.Base]:
660
  )
661
 
662
  papers_state = gr.State([])
663
- selected_index_state = gr.State(None)
664
-
665
- # Hidden components for JS communication
666
- hidden_index_input = gr.Textbox(visible=False, elem_id="hidden_index_input")
667
- hidden_select_btn = gr.Button(visible=False, elem_id="hidden_select_btn")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
668
 
669
- with gr.Tabs(selected="search") as app_tabs:
670
  with gr.Tab("🔍 Search", id="search"):
671
  with gr.Row():
672
  query_input = gr.Textbox(
@@ -682,28 +980,52 @@ def build_app() -> tuple[gr.Blocks, gr.themes.Base]:
682
  status_output = gr.Markdown("Ready for search.", elem_classes=["status-line"])
683
  results_output = gr.HTML(_render_results_table([]))
684
 
 
 
 
 
 
685
  with gr.Row(elem_classes=["action-row"]):
 
 
 
 
 
 
 
686
  with gr.Column(scale=1):
687
  summarize_selected_button = gr.Button("✨ Summarize Now", variant="primary")
688
 
 
 
 
 
 
 
 
 
689
  search_button.click(
690
  fn=search_all_sources,
691
  inputs=query_input,
692
- outputs=[status_output, results_output, papers_state, selected_index_state],
693
  show_progress="full",
694
  )
695
  query_input.submit(
696
  fn=search_all_sources,
697
  inputs=query_input,
698
- outputs=[status_output, results_output, papers_state, selected_index_state],
699
  show_progress="full",
700
  )
701
-
702
- # JS row selection logic
703
- hidden_select_btn.click(
704
- fn=lambda x: int(x) if x else None,
705
- inputs=hidden_index_input,
706
- outputs=selected_index_state
 
 
 
 
707
  )
708
 
709
  with gr.Tab("✨ Summarize", id="summarize"):
@@ -720,9 +1042,11 @@ def build_app() -> tuple[gr.Blocks, gr.themes.Base]:
720
  show_progress="full",
721
  )
722
 
 
 
723
  summarize_selected_button.click(
724
  fn=summarize_selected_paper,
725
- inputs=[selected_index_state, papers_state],
726
  outputs=[source_text, load_status_output, summary_output, app_tabs],
727
  show_progress="full",
728
  )
@@ -737,12 +1061,21 @@ def build_app() -> tuple[gr.Blocks, gr.themes.Base]:
737
  built to accelerate literature reviews and research synthesis.
738
  </p>
739
  <p>
740
- It performs real-time searches across <strong>Semantic Scholar</strong>,
741
- <strong>arXiv</strong>, and <strong>PubMed</strong> using parallel processing,
742
- then distills findings using a high-performance LLM hosted on Modal.
 
 
 
 
 
 
 
 
743
  </p>
744
- <p style="margin-top: 20px; font-size: 13px; color: var(--sl-accent-bright);">
745
- Background: #0f172a | Accent: #3b82f6 | V2.0
 
746
  </p>
747
  </div>
748
  """
@@ -750,7 +1083,16 @@ def build_app() -> tuple[gr.Blocks, gr.themes.Base]:
750
 
751
  clear_button.click(
752
  fn=clear_search,
753
- outputs=[status_output, results_output, papers_state, selected_index_state, source_text, summary_output],
 
 
 
 
 
 
 
 
 
754
  )
755
 
756
  return app, theme
@@ -765,4 +1107,5 @@ if __name__ == "__main__":
765
  server_port=7860,
766
  theme=theme,
767
  css=CUSTOM_CSS,
 
768
  )
 
4
  import re
5
  import textwrap
6
  import xml.etree.ElementTree as ET
7
+ from concurrent.futures import ThreadPoolExecutor, as_completed
8
  from dataclasses import dataclass
9
  from typing import Any
10
 
 
12
  import requests
13
 
14
  APP_TITLE = "Scholar Lens"
15
+ SEARCH_LIMIT_PER_SOURCE = 10
16
+ REQUEST_TIMEOUT_SECONDS = 15
17
+ # Identifies us to the OpenAlex / Crossref "polite pool" for faster, more
18
+ # reliable responses. Replace with your own email if you like.
19
+ CONTACT_EMAIL = "hussainharis946@gmail.com"
20
  MODAL_SUMMARIZE_URL = "https://kinggondal731--scholar-lens-summarizer-summarize-paper.modal.run"
21
+ MODAL_SYNTHESIZE_URL = "https://kinggondal731--scholar-lens-summarizer-synthesize.modal.run"
22
+ # How many retrieved papers (that actually have an abstract) to feed the model.
23
+ SYNTHESIS_PAPER_COUNT = 6
24
+ # Results shown per page in the Search results table.
25
+ RESULTS_PER_PAGE = 10
26
+ # Trim very long abstracts so the grounded prompt stays a reasonable size.
27
+ MAX_ABSTRACT_CHARS = 1400
28
 
29
 
30
  @dataclass(frozen=True)
 
36
  citations: str
37
  url: str
38
  abstract: str = ""
39
+ doi: str = ""
40
 
41
 
42
  def _safe_text(value: Any, fallback: str = "Unknown") -> str:
 
73
  return response.json()
74
 
75
 
76
+ def _reconstruct_abstract(inverted_index: dict[str, list[int]] | None) -> str:
77
+ """Rebuild plain text from OpenAlex's abstract inverted index.
78
+
79
+ OpenAlex never returns a plain ``abstract`` field; it returns a
80
+ ``{word: [positions]}`` map. We sort words back into reading order.
81
+ """
82
+ if not inverted_index:
83
+ return ""
84
+ positioned: list[tuple[int, str]] = []
85
+ for word, positions in inverted_index.items():
86
+ for position in positions:
87
+ positioned.append((position, word))
88
+ positioned.sort(key=lambda pair: pair[0])
89
+ return " ".join(word for _, word in positioned)
90
+
91
+
92
+ def _strip_markup(text: str) -> str:
93
+ """Crossref abstracts are JATS XML; reduce them to plain text."""
94
+ return " ".join(re.sub(r"<[^>]+>", " ", text or "").split())
95
+
96
+
97
+ def _normalize_doi(value: str) -> str:
98
+ doi = (value or "").lower().strip()
99
+ return re.sub(r"^https?://(dx\.)?doi\.org/", "", doi)
100
+
101
+
102
+ def search_openalex(query: str) -> tuple[list[PaperResult], str | None]:
103
+ url = "https://api.openalex.org/works"
104
+ params = {
105
+ "search": query,
106
+ "per_page": SEARCH_LIMIT_PER_SOURCE,
107
+ "filter": "has_abstract:true",
108
+ "mailto": CONTACT_EMAIL,
109
+ }
110
+ try:
111
+ payload = _request_json(url, params)
112
+ except requests.RequestException:
113
+ return [], "OpenAlex is unavailable right now."
114
+
115
+ results: list[PaperResult] = []
116
+ for work in payload.get("results", []):
117
+ authors = [
118
+ authorship.get("author", {}).get("display_name", "")
119
+ for authorship in work.get("authorships", [])
120
+ ]
121
+ results.append(
122
+ PaperResult(
123
+ title=_safe_text(work.get("title"), "Untitled paper"),
124
+ year=_safe_text(work.get("publication_year")),
125
+ source="OpenAlex",
126
+ authors=_shorten_authors(authors),
127
+ citations=_safe_text(work.get("cited_by_count"), "0"),
128
+ url=_safe_text(work.get("doi") or work.get("id"), "#"),
129
+ abstract=_reconstruct_abstract(work.get("abstract_inverted_index")),
130
+ doi=_normalize_doi(work.get("doi", "")),
131
+ )
132
+ )
133
+ return results, None
134
+
135
+
136
+ def _crossref_year(item: dict[str, Any]) -> str:
137
+ for key in ("published", "published-print", "published-online", "issued"):
138
+ parts = item.get(key, {}).get("date-parts", [])
139
+ if parts and parts[0] and parts[0][0]:
140
+ return str(parts[0][0])
141
+ return "Unknown"
142
+
143
+
144
+ def search_crossref(query: str) -> tuple[list[PaperResult], str | None]:
145
+ url = "https://api.crossref.org/works"
146
  params = {
147
  "query": query,
148
+ "rows": SEARCH_LIMIT_PER_SOURCE,
149
+ "filter": "has-abstract:true",
150
+ "mailto": CONTACT_EMAIL,
151
  }
152
  try:
153
  payload = _request_json(url, params)
154
  except requests.RequestException:
155
+ return [], "Crossref is unavailable right now."
156
 
157
  results: list[PaperResult] = []
158
+ for item in payload.get("message", {}).get("items", []):
159
+ title_list = item.get("title") or ["Untitled paper"]
160
+ authors = [
161
+ f"{author.get('given', '')} {author.get('family', '')}".strip()
162
+ for author in item.get("author", [])
163
+ ]
164
  results.append(
165
  PaperResult(
166
+ title=_safe_text(title_list[0], "Untitled paper"),
167
+ year=_crossref_year(item),
168
+ source="Crossref",
169
  authors=_shorten_authors(authors),
170
+ citations=_safe_text(item.get("is-referenced-by-count"), "0"),
171
+ url=_safe_text(item.get("URL"), "#"),
172
+ abstract=_strip_markup(item.get("abstract", "")),
173
+ doi=_normalize_doi(item.get("DOI", "")),
174
  )
175
  )
176
  return results, None
 
289
 
290
  def _source_badge(source: str) -> str:
291
  config = {
292
+ "OpenAlex": ("openalex", "🌐"),
293
+ "Crossref": ("crossref", "🔗"),
294
  "arXiv": ("arxiv", "📐"),
295
  "PubMed": ("pubmed", "🧬"),
296
  }
 
302
  )
303
 
304
 
305
+ def _render_results_table(results: list[PaperResult], start_index: int = 0) -> str:
306
  if not results:
307
  return """
308
  <div class="empty-state">
 
312
  </div>
313
  """
314
  rows = []
315
+ for offset, result in enumerate(results):
316
+ number = start_index + offset + 1
317
  safe_url = html.escape(result.url, quote=True)
318
  rows.append(
319
  textwrap.dedent(
320
  f"""
321
+ <tr class="result-row">
322
+ <td class="num-cell">{number}</td>
 
 
323
  <td class="title-cell">{html.escape(result.title)}</td>
324
  <td class="year-cell">{html.escape(result.year)}</td>
325
  <td>{_source_badge(result.source)}</td>
326
  <td class="authors-cell">{html.escape(result.authors)}</td>
327
  <td class="citation-cell">{html.escape(result.citations)}</td>
328
+ <td><a class="paper-link" href="{safe_url}" target="_blank" rel="noopener noreferrer">Open ↗</a></td>
329
  </tr>
330
  """
331
  ).strip()
 
335
  <table class="results-table">
336
  <thead>
337
  <tr>
338
+ <th style="width: 40px;">#</th>
339
  <th>Title</th>
340
  <th>Year</th>
341
  <th>Source</th>
 
350
  """
351
 
352
 
353
+ def _page_view(results: list[PaperResult], page: int) -> tuple[str, str, int]:
354
+ """Return (table_html, page_label, clamped_page) for one page of results."""
355
+ total_pages = max(1, (len(results) + RESULTS_PER_PAGE - 1) // RESULTS_PER_PAGE)
356
+ page = max(0, min(page, total_pages - 1))
357
+ start = page * RESULTS_PER_PAGE
358
+ chunk = results[start:start + RESULTS_PER_PAGE]
359
+ table = _render_results_table(chunk, start_index=start)
360
+ label = f"Page {page + 1} of {total_pages} · {len(results)} papers" if results else ""
361
+ return table, label, page
362
+
363
+
364
  def _dedupe_results(results: list[PaperResult]) -> list[PaperResult]:
365
  """Drop duplicate papers that appear in more than one source.
366
 
367
  Papers are matched on a normalized title; the first occurrence wins so we
368
  keep the source order produced by the search functions.
369
  """
370
+ seen_dois: set[str] = set()
371
+ seen_titles: set[str] = set()
372
  unique: list[PaperResult] = []
373
  for result in results:
374
+ doi = _normalize_doi(result.doi)
375
+ if doi and doi in seen_dois:
376
  continue
377
+ title_key = re.sub(r"[^a-z0-9]+", "", result.title.lower())
378
+ if title_key and title_key in seen_titles:
379
+ continue
380
+ if doi:
381
+ seen_dois.add(doi)
382
+ if title_key:
383
+ seen_titles.add(title_key)
384
  unique.append(result)
385
  return unique
386
 
387
 
388
+ def _collect_results(query: str) -> tuple[list[PaperResult], list[str]]:
389
+ """Query every source in parallel, then de-duplicate and sort by year."""
390
+ search_functions = [search_openalex, search_crossref, search_arxiv, search_pubmed]
 
 
 
 
 
 
 
 
391
  results: list[PaperResult] = []
392
  warnings: list[str] = []
393
 
394
+ with ThreadPoolExecutor(max_workers=len(search_functions)) as executor:
395
+ future_to_search = {executor.submit(fn, query): fn.__name__ for fn in search_functions}
396
+ for future in as_completed(future_to_search):
397
  try:
398
  source_results, warning = future.result()
399
  results.extend(source_results)
 
410
  return (1, int(item.year))
411
 
412
  results.sort(key=_sort_key, reverse=True)
413
+ return results, warnings
414
+
415
+
416
+ def _selector_choices(results: list[PaperResult]) -> list[str]:
417
+ return [f"{index + 1}. {result.title}" for index, result in enumerate(results)]
418
+
419
+
420
+ def search_all_sources(query: str):
421
+ clean_query = query.strip()
422
+ if not clean_query:
423
+ table, label, page = _page_view([], 0)
424
+ return (
425
+ "Enter a research topic to search OpenAlex, Crossref, arXiv, and PubMed.",
426
+ table,
427
+ [],
428
+ gr.update(choices=[], value=None),
429
+ label,
430
+ page,
431
+ )
432
+
433
+ results, warnings = _collect_results(clean_query)
434
 
435
  if warnings and results:
436
  status = f"✓ Found **{len(results)}** papers. " + " ".join(warnings)
 
442
  if not results:
443
  status = "No papers found. Try a broader research topic or a different phrase."
444
 
445
+ table, label, page = _page_view(results, 0)
446
  return (
447
  status,
448
+ table,
449
  results,
450
+ gr.update(choices=_selector_choices(results), value=None),
451
+ label,
452
+ page,
453
  )
454
 
455
 
456
+ def change_page(results: list[PaperResult], page: int, delta: int) -> tuple[str, str, int]:
457
+ return _page_view(results, (page or 0) + delta)
458
+
459
+
460
  def summarize_with_modal(text: str) -> str:
461
  if not text or len(text.strip()) < 50:
462
  return "Please provide a longer abstract or paper text to summarize."
 
485
  return _safe_text(payload, "No summary returned.")
486
 
487
 
488
+ def _papers_for_synthesis(results: list[PaperResult]) -> list[PaperResult]:
489
+ """Pick the top results that actually carry an abstract to ground on."""
490
+ with_abstract = [paper for paper in results if paper.abstract.strip()]
491
+ return with_abstract[:SYNTHESIS_PAPER_COUNT]
492
+
493
+
494
+ def _build_synthesis_context(papers: list[PaperResult]) -> str:
495
+ blocks = []
496
+ for index, paper in enumerate(papers, start=1):
497
+ abstract = paper.abstract.strip()
498
+ if len(abstract) > MAX_ABSTRACT_CHARS:
499
+ abstract = abstract[:MAX_ABSTRACT_CHARS].rstrip() + "..."
500
+ blocks.append(
501
+ f"[{index}] Title: {paper.title}\n"
502
+ f"Source: {paper.source} ({paper.year})\n"
503
+ f"Abstract: {abstract}"
504
+ )
505
+ return "\n\n".join(blocks)
506
+
507
+
508
+ def _render_references(papers: list[PaperResult]) -> str:
509
+ if not papers:
510
+ return ""
511
+ items = []
512
+ for index, paper in enumerate(papers, start=1):
513
+ safe_url = html.escape(paper.url, quote=True)
514
+ items.append(
515
+ f'<li class="ref-item">'
516
+ f'<span class="ref-num">[{index}]</span>'
517
+ f'<span class="ref-body">'
518
+ f'<a class="ref-link" href="{safe_url}" target="_blank" rel="noopener noreferrer">{html.escape(paper.title)}</a>'
519
+ f'<span class="ref-meta">{_source_badge(paper.source)} · {html.escape(paper.year)} · {html.escape(paper.authors)}</span>'
520
+ f'</span></li>'
521
+ )
522
+ return (
523
+ '<div class="refs-shell"><h3>📚 Sources the answer is grounded in</h3>'
524
+ f'<ul class="refs-list">{"".join(items)}</ul></div>'
525
+ )
526
+
527
+
528
+ def synthesize_with_modal(question: str, context: str) -> str:
529
+ try:
530
+ response = requests.post(
531
+ MODAL_SYNTHESIZE_URL,
532
+ json={"question": question, "context": context},
533
+ timeout=180,
534
+ )
535
+ response.raise_for_status()
536
+ except requests.Timeout:
537
+ return (
538
+ "The AI synthesizer timed out (the model may be cold-starting). "
539
+ "Please try again in a few seconds."
540
+ )
541
+ except requests.RequestException:
542
+ return "The AI synthesizer is unavailable right now. Please try again shortly."
543
+
544
+ try:
545
+ payload = response.json()
546
+ except ValueError:
547
+ return "The AI synthesizer returned an unexpected response. Please try again shortly."
548
+
549
+ if isinstance(payload, dict):
550
+ return _safe_text(payload.get("answer") or payload.get("error"), "No answer returned.")
551
+ return _safe_text(payload, "No answer returned.")
552
+
553
+
554
+ def ask_scholar_lens(question: str) -> tuple[str, str]:
555
+ """Search every source, then have the small model answer with citations."""
556
+ clean_question = question.strip()
557
+ if not clean_question:
558
+ return "Enter a research question to begin.", ""
559
+
560
+ results, warnings = _collect_results(clean_question)
561
+ if not results:
562
+ note = " ".join(warnings) if warnings else ""
563
+ return (
564
+ f"No papers were found for that question. Try rephrasing it.\n\n{note}".strip(),
565
+ "",
566
+ )
567
+
568
+ papers = _papers_for_synthesis(results)
569
+ if not papers:
570
+ return (
571
+ "Papers were found, but none included an abstract to reason over. "
572
+ "Try a broader or differently worded question.",
573
+ "",
574
+ )
575
+
576
+ context = _build_synthesis_context(papers)
577
+ answer = synthesize_with_modal(clean_question, context)
578
+ return answer, _render_references(papers)
579
+
580
+
581
  def select_result(event: gr.SelectData) -> int | None:
582
  if event.index is None:
583
  return None
 
614
 
615
 
616
  def summarize_selected_paper(
617
+ selected_index: Any,
618
  results: list[PaperResult],
619
  ) -> tuple[str, str, str, gr.Tabs]:
620
+ try:
621
+ index = int(selected_index)
622
+ except (TypeError, ValueError):
623
+ index = None
624
+ paper_text, load_status, _, tab_update = load_selected_paper(index, results)
625
  if not paper_text:
626
  return paper_text, load_status, "", tab_update
627
  if "No abstract is available" in paper_text:
 
634
  return paper_text, load_status, summarize_with_modal(paper_text), tab_update
635
 
636
 
637
+ def clear_search():
638
+ table, label, page = _page_view([], 0)
639
  return (
640
  "Enter a research topic to begin.",
641
+ table,
642
  [],
643
+ gr.update(choices=[], value=None),
644
+ label,
645
+ page,
646
  "",
647
  "",
648
  )
 
810
  font-weight: 600;
811
  color: var(--sl-text-bright) !important;
812
  }
813
+ .num-cell { color: var(--sl-muted); text-align: center; font-variant-numeric: tabular-nums; }
814
 
815
+ /* ===== PAGINATION ===== */
816
+ .page-row { align-items: center; justify-content: center; gap: 14px; margin-top: 12px; }
817
+ .page-label { text-align: center; color: var(--sl-muted) !important; min-width: 180px; }
818
+
819
+ .source-badge.openalex { color: #22d3ee !important; background: rgba(34, 211, 238, 0.1); border: 1px solid rgba(34, 211, 238, 0.3); }
820
+ .source-badge.crossref { color: #fbbf24 !important; background: rgba(251, 191, 36, 0.1); border: 1px solid rgba(251, 191, 36, 0.3); }
821
  .source-badge.arxiv { color: #f87171 !important; background: rgba(239, 68, 68, 0.1); border: 1px solid rgba(239, 68, 68, 0.3); }
822
  .source-badge.pubmed { color: #34d399 !important; background: rgba(16, 185, 129, 0.1); border: 1px solid rgba(16, 185, 129, 0.3); }
823
 
824
+ /* ===== ASK / SYNTHESIS ===== */
825
+ .ask-intro { color: var(--sl-muted) !important; margin-bottom: 6px; }
826
+ .answer-card {
827
+ background: var(--sl-panel) !important;
828
+ border: 1px solid var(--sl-border);
829
+ border-left: 4px solid var(--sl-accent);
830
+ border-radius: 12px;
831
+ padding: 20px 24px !important;
832
+ margin-top: 16px;
833
+ line-height: 1.65;
834
+ font-size: 15px;
835
+ }
836
+ .answer-card p { color: var(--sl-text) !important; }
837
+
838
+ .refs-shell {
839
+ margin-top: 18px;
840
+ border: 1px solid var(--sl-border);
841
+ border-radius: 12px;
842
+ background: var(--sl-panel);
843
+ padding: 16px 20px;
844
+ }
845
+ .refs-shell h3 {
846
+ margin: 0 0 12px;
847
+ font-size: 13px;
848
+ text-transform: uppercase;
849
+ letter-spacing: 0.5px;
850
+ color: var(--sl-accent-bright);
851
+ }
852
+ .refs-list { list-style: none; margin: 0; padding: 0; }
853
+ .ref-item { display: flex; gap: 10px; padding: 10px 0; border-bottom: 1px solid var(--sl-border-soft); }
854
+ .ref-item:last-child { border-bottom: none; }
855
+ .ref-num { color: var(--sl-accent-bright); font-weight: 700; flex-shrink: 0; }
856
+ .ref-body { display: flex; flex-direction: column; gap: 4px; }
857
+ .ref-link { color: var(--sl-text-bright) !important; font-weight: 600; text-decoration: none; }
858
+ .ref-link:hover { color: var(--sl-accent-bright) !important; text-decoration: underline; }
859
+ .ref-meta { color: var(--sl-muted); font-size: 12px; display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
860
+
861
  /* Remove system buttons and footers */
862
  .settings, footer, .show-api { display: none !important; }
863
 
864
  .hidden-component { display: none !important; }
865
  """
866
 
867
+ # Injected into the page <head> at launch time (Gradio 6 takes `head` on
868
+ # launch(), not on Blocks()). Powers click-to-select on the results table.
869
+ HEAD_SCRIPT = """
870
+ <script>
871
+ function selectPaper(index) {
872
+ const input = document.querySelector('#hidden_index_input textarea');
873
+ if (input) {
874
+ input.value = index;
875
+ input.dispatchEvent(new Event('input', { bubbles: true }));
876
+ }
877
+ document.querySelectorAll('.result-row').forEach(r => r.classList.remove('selected'));
878
+ const row = document.getElementById('row-' + index);
879
+ if (row) row.classList.add('selected');
880
+ const radio = document.getElementById('radio-' + index);
881
+ if (radio) radio.checked = true;
882
+ setTimeout(() => {
883
+ const btn = document.querySelector('#hidden_select_btn button');
884
+ if (btn) btn.click();
885
+ }, 50);
886
+ }
887
+ </script>
888
+ """
889
+
890
+
891
  def build_app() -> tuple[gr.Blocks, gr.themes.Base]:
892
  theme = gr.themes.Base(
893
  primary_hue="blue",
 
902
  button_primary_text_color="#ffffff",
903
  )
904
 
905
+ with gr.Blocks(title=APP_TITLE) as app:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
906
  with gr.Column(elem_classes=["main-shell"]):
907
  gr.HTML(
908
  """
 
913
  <h1>Scholar <span class="accent">Lens</span></h1>
914
  <p>Professional Research Discovery & Synthesis Engine</p>
915
  <div class="header-chips">
916
+ <span class="header-chip">OpenAlex</span>
917
+ <span class="header-chip">Crossref</span>
918
  <span class="header-chip">arXiv</span>
919
  <span class="header-chip">PubMed</span>
920
  </div>
 
925
  )
926
 
927
  papers_state = gr.State([])
928
+ page_state = gr.State(0)
929
+
930
+ with gr.Tabs(selected="ask") as app_tabs:
931
+ with gr.Tab("💬 Ask", id="ask"):
932
+ gr.Markdown(
933
+ "Ask a research question. Scholar Lens searches **OpenAlex, "
934
+ "Crossref, arXiv, and PubMed**, then a small open model "
935
+ "(Qwen2.5-7B) writes a synthesized, **cited** answer grounded "
936
+ "only in the retrieved abstracts.",
937
+ elem_classes=["ask-intro"],
938
+ )
939
+ with gr.Row():
940
+ question_input = gr.Textbox(
941
+ label="",
942
+ placeholder="e.g. What are the main approaches to early Alzheimer's detection from MRI, and where do they disagree?",
943
+ scale=5,
944
+ container=False,
945
+ lines=2,
946
+ )
947
+ ask_button = gr.Button("💬 Ask", variant="primary", scale=1)
948
+
949
+ answer_output = gr.Markdown(
950
+ "Your grounded, cited answer will appear here.",
951
+ elem_classes=["answer-card"],
952
+ )
953
+ references_output = gr.HTML()
954
+
955
+ ask_button.click(
956
+ fn=ask_scholar_lens,
957
+ inputs=question_input,
958
+ outputs=[answer_output, references_output],
959
+ show_progress="full",
960
+ )
961
+ question_input.submit(
962
+ fn=ask_scholar_lens,
963
+ inputs=question_input,
964
+ outputs=[answer_output, references_output],
965
+ show_progress="full",
966
+ )
967
 
 
968
  with gr.Tab("🔍 Search", id="search"):
969
  with gr.Row():
970
  query_input = gr.Textbox(
 
980
  status_output = gr.Markdown("Ready for search.", elem_classes=["status-line"])
981
  results_output = gr.HTML(_render_results_table([]))
982
 
983
+ with gr.Row(elem_classes=["page-row"]):
984
+ prev_button = gr.Button("← Prev", scale=0, min_width=100)
985
+ page_label = gr.Markdown("", elem_classes=["page-label"])
986
+ next_button = gr.Button("Next →", scale=0, min_width=100)
987
+
988
  with gr.Row(elem_classes=["action-row"]):
989
+ paper_selector = gr.Dropdown(
990
+ label="Select a paper to summarize",
991
+ choices=[],
992
+ type="index",
993
+ interactive=True,
994
+ scale=4,
995
+ )
996
  with gr.Column(scale=1):
997
  summarize_selected_button = gr.Button("✨ Summarize Now", variant="primary")
998
 
999
+ search_outputs = [
1000
+ status_output,
1001
+ results_output,
1002
+ papers_state,
1003
+ paper_selector,
1004
+ page_label,
1005
+ page_state,
1006
+ ]
1007
  search_button.click(
1008
  fn=search_all_sources,
1009
  inputs=query_input,
1010
+ outputs=search_outputs,
1011
  show_progress="full",
1012
  )
1013
  query_input.submit(
1014
  fn=search_all_sources,
1015
  inputs=query_input,
1016
+ outputs=search_outputs,
1017
  show_progress="full",
1018
  )
1019
+
1020
+ prev_button.click(
1021
+ fn=lambda results, page: change_page(results, page, -1),
1022
+ inputs=[papers_state, page_state],
1023
+ outputs=[results_output, page_label, page_state],
1024
+ )
1025
+ next_button.click(
1026
+ fn=lambda results, page: change_page(results, page, 1),
1027
+ inputs=[papers_state, page_state],
1028
+ outputs=[results_output, page_label, page_state],
1029
  )
1030
 
1031
  with gr.Tab("✨ Summarize", id="summarize"):
 
1042
  show_progress="full",
1043
  )
1044
 
1045
+ # Native dropdown selection (type="index" gives the chosen
1046
+ # paper's index directly) — no JS, no race conditions.
1047
  summarize_selected_button.click(
1048
  fn=summarize_selected_paper,
1049
+ inputs=[paper_selector, papers_state],
1050
  outputs=[source_text, load_status_output, summary_output, app_tabs],
1051
  show_progress="full",
1052
  )
 
1061
  built to accelerate literature reviews and research synthesis.
1062
  </p>
1063
  <p>
1064
+ It performs real-time searches across <strong>OpenAlex</strong>,
1065
+ <strong>Crossref</strong>, <strong>arXiv</strong>, and <strong>PubMed</strong>
1066
+ using parallel processing, then uses a small open language model
1067
+ (<strong>Qwen2.5-7B</strong>, hosted on Modal) to do the heavy lifting.
1068
+ </p>
1069
+ <p>
1070
+ In the <strong>Ask</strong> tab the model answers your research
1071
+ question with a synthesized, <strong>cited</strong> response grounded
1072
+ only in the retrieved abstracts &mdash; so it compares findings across
1073
+ papers without inventing sources. The <strong>Summarize</strong> tab
1074
+ condenses any single paper or pasted text.
1075
  </p>
1076
+ <p style="margin-top: 16px; font-size: 13px; color: var(--sl-muted);">
1077
+ Built for the Hugging Face <em>Build Small</em> hackathon &middot;
1078
+ model &le; 32B parameters.
1079
  </p>
1080
  </div>
1081
  """
 
1083
 
1084
  clear_button.click(
1085
  fn=clear_search,
1086
+ outputs=[
1087
+ status_output,
1088
+ results_output,
1089
+ papers_state,
1090
+ paper_selector,
1091
+ page_label,
1092
+ page_state,
1093
+ source_text,
1094
+ summary_output,
1095
+ ],
1096
  )
1097
 
1098
  return app, theme
 
1107
  server_port=7860,
1108
  theme=theme,
1109
  css=CUSTOM_CSS,
1110
+ head=HEAD_SCRIPT,
1111
  )
modal_inference.py CHANGED
@@ -2,7 +2,7 @@ import modal
2
 
3
  app = modal.App("scholar-lens-summarizer")
4
 
5
- MODEL_NAME = "Qwen/Qwen2.5-7B-Instruct"
6
 
7
  image = (
8
  modal.Image.debian_slim(python_version="3.11")
@@ -18,7 +18,7 @@ image = (
18
 
19
  @app.cls(
20
  image=image,
21
- gpu="T4",
22
  timeout=300,
23
  # Keep the container (and the loaded model) warm for 5 minutes after the
24
  # last request so repeat calls don't pay the cold-start cost again.
@@ -86,3 +86,34 @@ class Summarizer:
86
  except Exception as exc: # surface errors to the client instead of 500s
87
  return {"error": f"Generation failed: {exc}"}
88
  return {"summary": summary}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2
 
3
  app = modal.App("scholar-lens-summarizer")
4
 
5
+ MODEL_NAME = "Qwen/Qwen2.5-3B-Instruct"
6
 
7
  image = (
8
  modal.Image.debian_slim(python_version="3.11")
 
18
 
19
  @app.cls(
20
  image=image,
21
+ gpu="A100",
22
  timeout=300,
23
  # Keep the container (and the loaded model) warm for 5 minutes after the
24
  # last request so repeat calls don't pay the cold-start cost again.
 
86
  except Exception as exc: # surface errors to the client instead of 500s
87
  return {"error": f"Generation failed: {exc}"}
88
  return {"summary": summary}
89
+
90
+ @modal.fastapi_endpoint(method="POST", label="scholar-lens-summarizer-synthesize")
91
+ def synthesize(self, data: dict) -> dict:
92
+ """Answer a research question grounded ONLY in the supplied abstracts.
93
+
94
+ Expects ``{"question": str, "context": str}`` where ``context`` is a
95
+ block of numbered papers ([1], [2], ...). The model must cite those
96
+ numbers, which keeps it from inventing sources.
97
+ """
98
+ question = (data or {}).get("question", "")
99
+ context = (data or {}).get("context", "")
100
+ if not question or not context:
101
+ return {"error": "Both 'question' and 'context' are required."}
102
+
103
+ prompt = (
104
+ "You are a meticulous research assistant. Using ONLY the numbered "
105
+ "paper abstracts below, write a clear, synthesized answer to the "
106
+ "question. Compare and contrast the findings where relevant. Cite "
107
+ "every claim with the matching source number in square brackets, "
108
+ "e.g. [1] or [2][3]. If the abstracts do not contain enough "
109
+ "information to answer, say so plainly. Never invent sources or "
110
+ "facts that are not in the abstracts.\n\n"
111
+ f"{context}\n\n"
112
+ f"Question: {question}\n\n"
113
+ "Synthesized answer (with [n] citations):"
114
+ )
115
+ try:
116
+ answer = self._generate(prompt, max_new_tokens=450)
117
+ except Exception as exc:
118
+ return {"error": f"Generation failed: {exc}"}
119
+ return {"answer": answer}