Spaces:
Running
Running
| """Core tools: arXiv search, PDF download/parse, LLM structured extraction.""" | |
| from __future__ import annotations | |
| import json | |
| import os | |
| import re | |
| import time | |
| import urllib.error | |
| import urllib.parse | |
| import urllib.request | |
| from dataclasses import dataclass, field | |
| from . import utils | |
| USER_AGENT = ( | |
| "research-agent/0.1 (+https://github.com/abhid1234/research-agent) " | |
| "literature-review bot" | |
| ) | |
| class Paper: | |
| """A single arXiv paper plus anything we extract from it.""" | |
| id: str | |
| title: str | |
| authors: list[str] | |
| year: int | |
| abstract: str | |
| citations: list[str] = field(default_factory=list) | |
| full_text: str = "" | |
| claims: list[str] = field(default_factory=list) | |
| methods: list[str] = field(default_factory=list) | |
| results: list[str] = field(default_factory=list) | |
| score: float = 0.0 | |
| source: str = "arxiv" # "arxiv" | "semantic_scholar" | |
| arxiv_id: str = "" # set when an arXiv PDF is available | |
| pdf_url: str = "" # direct PDF URL for non-arXiv sources | |
| citation_count: int = 0 | |
| read_from: str = "" # "pdf" | "abstract" — what was actually read | |
| # --------------------------------------------------------------------------- # | |
| # Task 1.1: SearchTool | |
| # --------------------------------------------------------------------------- # | |
| class SearchTool: | |
| """Search arXiv and return :class:`Paper` records, sorted by relevance. | |
| A single rate-limited :class:`arxiv.Client` is shared across all queries so | |
| the library enforces its minimum delay between requests — issuing fresh | |
| clients per query trips arXiv's HTTP 429 throttle (notably from cloud IPs). | |
| """ | |
| def __init__( | |
| self, max_results: int = 20, delay_seconds: float = 3.0, num_retries: int = 2 | |
| ): | |
| self.max_results = max_results | |
| self._delay = delay_seconds | |
| self._num_retries = num_retries # low → fail fast instead of long backoff | |
| self._client = None | |
| def _get_client(self): | |
| if self._client is None: | |
| import arxiv | |
| self._client = arxiv.Client( | |
| page_size=min(max(self.max_results, 1), 100), | |
| delay_seconds=self._delay, | |
| num_retries=self._num_retries, | |
| ) | |
| # arXiv deprioritizes the default library User-Agent; a descriptive | |
| # one with a contact URL is the documented way to avoid throttling. | |
| try: | |
| self._client._session.headers.update({"User-Agent": USER_AGENT}) | |
| except Exception: | |
| pass | |
| return self._client | |
| def search(self, query: str) -> list[Paper]: | |
| import arxiv | |
| client = self._get_client() | |
| search = arxiv.Search( | |
| query=query, | |
| max_results=self.max_results, | |
| sort_by=arxiv.SortCriterion.Relevance, | |
| ) | |
| papers: list[Paper] = [] | |
| seen_titles: set[str] = set() | |
| for result in client.results(search): | |
| title = result.title.strip().replace("\n", " ") | |
| key = title.lower() | |
| if key in seen_titles: | |
| continue | |
| seen_titles.add(key) | |
| papers.append( | |
| Paper( | |
| id=result.get_short_id(), | |
| title=title, | |
| authors=[a.name for a in result.authors], | |
| year=result.published.year if result.published else 0, | |
| abstract=result.summary.strip().replace("\n", " "), | |
| ) | |
| ) | |
| return papers | |
| # --------------------------------------------------------------------------- # | |
| # Task 1.2: DownloadTool | |
| # --------------------------------------------------------------------------- # | |
| class DownloadTool: | |
| """Fetch a paper's PDF (cached on disk) and extract its body text.""" | |
| def __init__(self, pdf_dir: str = "papers/"): | |
| self.pdf_dir = pdf_dir | |
| os.makedirs(self.pdf_dir, exist_ok=True) | |
| def _local_path(self, paper: Paper) -> str: | |
| safe_id = (paper.arxiv_id or paper.id).replace("/", "_") | |
| return os.path.join(self.pdf_dir, f"{safe_id}.pdf") | |
| def _pdf_url(paper: Paper) -> str | None: | |
| """Best PDF URL for a paper, or None if only the abstract is available.""" | |
| if paper.arxiv_id: | |
| return f"https://arxiv.org/pdf/{paper.arxiv_id}.pdf" | |
| if paper.source == "arxiv": | |
| return f"https://arxiv.org/pdf/{paper.id}.pdf" | |
| if paper.pdf_url: | |
| return paper.pdf_url | |
| return None | |
| def _download(self, url: str, path: str) -> None: | |
| req = urllib.request.Request(url, headers={"User-Agent": USER_AGENT}) | |
| last_err: Exception | None = None | |
| for attempt in range(4): | |
| try: | |
| with urllib.request.urlopen(req, timeout=30) as resp, open( | |
| path, "wb" | |
| ) as fh: | |
| fh.write(resp.read()) | |
| return | |
| except urllib.error.HTTPError as err: | |
| last_err = err | |
| if err.code in (429, 503): # throttled — back off and retry | |
| time.sleep(2 * (attempt + 1)) | |
| continue | |
| raise | |
| if last_err: | |
| raise last_err | |
| def get_text(self, paper: Paper) -> str: | |
| """Return body text (skipping the first 2 pages); fall back to abstract. | |
| Sets ``paper.read_from`` to "pdf" or "abstract" so callers can surface | |
| when a paper was only read from its abstract. | |
| """ | |
| url = self._pdf_url(paper) | |
| if not url: | |
| paper.read_from = "abstract" | |
| return paper.abstract | |
| path = self._local_path(paper) | |
| try: | |
| if not os.path.exists(path) or os.path.getsize(path) == 0: | |
| self._download(url, path) | |
| import pdfplumber | |
| with pdfplumber.open(path) as pdf: | |
| pages = pdf.pages | |
| body = pages[2:] if len(pages) > 3 else pages # keep short papers whole | |
| pages_text = [p.extract_text() or "" for p in body] | |
| text = "\n\n".join(t for t in pages_text if t).strip() | |
| if text: | |
| paper.read_from = "pdf" | |
| return text | |
| paper.read_from = "abstract" | |
| return paper.abstract | |
| except Exception: | |
| # Any failure (network, parse, corrupt PDF) -> graceful fallback. | |
| paper.read_from = "abstract" | |
| return paper.abstract | |
| # --------------------------------------------------------------------------- # | |
| # Task 1.3: ExtractionTool | |
| # --------------------------------------------------------------------------- # | |
| _EXTRACTION_SYSTEM = ( | |
| "You are a meticulous research assistant. Extract concrete, specific " | |
| "statements from a paper. Respond with JSON only." | |
| ) | |
| class ExtractionTool: | |
| """Use the LLM to pull structured claims/methods/results from a paper.""" | |
| MAX_CHUNKS = 3 | |
| CAP = 5 | |
| def __init__( | |
| self, | |
| provider: str | None = None, | |
| model: str | None = None, | |
| meter: dict | None = None, | |
| ): | |
| # provider/model are honored via src.config; args kept for the plan's | |
| # signature and possible future overrides. | |
| self.provider = provider | |
| self.model = model | |
| self.meter = meter | |
| def _prompt(self, title: str, chunk: str, hint: str = "") -> str: | |
| focus = f"\nFocus especially on the {hint} section.\n" if hint else "" | |
| return ( | |
| f'Paper title: "{title}"\n{focus}' | |
| "From the text below, extract:\n" | |
| ' - "claims": key assertions or contributions the authors make\n' | |
| ' - "methods": techniques, models, datasets, or procedures used\n' | |
| ' - "results": quantitative or qualitative findings\n\n' | |
| "Return ONLY a JSON object with those three keys, each a list of " | |
| "short strings (one sentence each). Omit anything not present.\n\n" | |
| f"TEXT:\n{chunk}" | |
| ) | |
| def _parse(raw: str) -> dict: | |
| # Strip code fences and grab the first JSON object. | |
| raw = re.sub(r"^```(?:json)?|```$", "", raw.strip(), flags=re.MULTILINE).strip() | |
| match = re.search(r"\{.*\}", raw, flags=re.DOTALL) | |
| if not match: | |
| return {} | |
| try: | |
| return json.loads(match.group(0)) | |
| except json.JSONDecodeError: | |
| return {} | |
| def extract(self, paper: Paper) -> Paper: | |
| text = paper.full_text or paper.abstract | |
| chunks = utils.chunk_text(text, size=3000)[: self.MAX_CHUNKS] | |
| claims: list[str] = [] | |
| methods: list[str] = [] | |
| results: list[str] = [] | |
| for chunk in chunks: | |
| raw = utils.complete( | |
| self._prompt(paper.title, chunk), | |
| system=_EXTRACTION_SYSTEM, | |
| meter=self.meter, | |
| model=self.model, | |
| ) | |
| data = self._parse(raw) | |
| claims += [str(x).strip() for x in data.get("claims", []) if str(x).strip()] | |
| methods += [str(x).strip() for x in data.get("methods", []) if str(x).strip()] | |
| results += [str(x).strip() for x in data.get("results", []) if str(x).strip()] | |
| # One targeted retry if a category came back empty. | |
| if chunks and not (claims and methods and results): | |
| for missing, bucket in (("results", results), ("methods", methods), ("claims", claims)): | |
| if not bucket: | |
| raw = utils.complete( | |
| self._prompt(paper.title, chunks[0], hint=missing), | |
| system=_EXTRACTION_SYSTEM, | |
| meter=self.meter, | |
| model=self.model, | |
| ) | |
| data = self._parse(raw) | |
| bucket += [ | |
| str(x).strip() for x in data.get(missing, []) if str(x).strip() | |
| ] | |
| paper.claims = _dedupe_cap(claims, self.CAP) | |
| paper.methods = _dedupe_cap(methods, self.CAP) | |
| paper.results = _dedupe_cap(results, self.CAP) | |
| return paper | |
| ## --------------------------------------------------------------------------- # | |
| # Second source: Semantic Scholar (resilience + non-arXiv venues + citations) | |
| # --------------------------------------------------------------------------- # | |
| class SemanticScholarTool: | |
| """Search the Semantic Scholar Graph API as a second source / arXiv fallback.""" | |
| ENDPOINT = "https://api.semanticscholar.org/graph/v1/paper/search" | |
| FIELDS = "title,abstract,year,authors,externalIds,openAccessPdf,citationCount" | |
| def __init__(self, max_results: int = 8, api_key: str | None = None, year_min: int = 0): | |
| self.max_results = max_results | |
| self.year_min = year_min | |
| # An API key lifts the shared-pool 429s; without one S2 is heavily throttled. | |
| self.api_key = api_key if api_key is not None else os.getenv("S2_API_KEY", "") | |
| def search(self, query: str) -> list[Paper]: | |
| q = {"query": query, "limit": self.max_results, "fields": self.FIELDS} | |
| if self.year_min: | |
| q["year"] = f"{self.year_min}-" | |
| params = urllib.parse.urlencode(q) | |
| headers = {"User-Agent": USER_AGENT} | |
| if self.api_key: | |
| headers["x-api-key"] = self.api_key | |
| req = urllib.request.Request(f"{self.ENDPOINT}?{params}", headers=headers) | |
| last_err: Exception | None = None | |
| for attempt in range(3): | |
| try: | |
| with urllib.request.urlopen(req, timeout=20) as resp: | |
| data = json.loads(resp.read().decode("utf-8")) | |
| break | |
| except urllib.error.HTTPError as err: | |
| last_err = err | |
| if err.code == 429: # throttled — back off and retry | |
| time.sleep(2 * (attempt + 1)) | |
| continue | |
| raise | |
| except Exception as err: | |
| last_err = err | |
| raise | |
| else: | |
| # Exhausted retries on 429 — surface it so the caller can log it. | |
| raise RuntimeError(f"Semantic Scholar throttled (429): {last_err}") | |
| papers: list[Paper] = [] | |
| for item in data.get("data") or []: | |
| title = (item.get("title") or "").strip() | |
| abstract = (item.get("abstract") or "").strip() | |
| oa = item.get("openAccessPdf") or {} | |
| pdf_url = (oa.get("url") or "").strip() | |
| arxiv_id = (item.get("externalIds") or {}).get("ArXiv", "") or "" | |
| if not title or (not abstract and not pdf_url and not arxiv_id): | |
| continue # nothing to read | |
| papers.append( | |
| Paper( | |
| id=arxiv_id or item.get("paperId", title[:40]), | |
| title=title.replace("\n", " "), | |
| authors=[a.get("name", "") for a in item.get("authors") or []], | |
| year=item.get("year") or 0, | |
| abstract=abstract, | |
| source="semantic_scholar", | |
| arxiv_id=arxiv_id, | |
| pdf_url=pdf_url, | |
| citation_count=item.get("citationCount") or 0, | |
| ) | |
| ) | |
| return papers | |
| def _dedupe_cap(items: list[str], cap: int) -> list[str]: | |
| seen: set[str] = set() | |
| out: list[str] = [] | |
| for item in items: | |
| key = item.lower() | |
| if key not in seen: | |
| seen.add(key) | |
| out.append(item) | |
| if len(out) >= cap: | |
| break | |
| return out | |