graziul commited on
Commit
5f58cd5
·
verified ·
1 Parent(s): 598644b

feat: collapsible expansion, canonical formalism pages, citation enrichment

Browse files
Files changed (1) hide show
  1. ingest.py +435 -0
ingest.py ADDED
@@ -0,0 +1,435 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ arXiv ingestion module.
3
+
4
+ Polls the arXiv API for new preprints in ML/AI categories, deduplicates by
5
+ arXiv ID, and applies a triage gate: only papers whose abstracts contain
6
+ novelty-claim language proceed to extraction.
7
+
8
+ Categories polled:
9
+ cs.LG — Machine Learning
10
+ cs.AI — Artificial Intelligence
11
+ stat.ML — Machine Learning (Statistics)
12
+ cs.CL — Computation and Language (NLP)
13
+
14
+ Rate limit: arXiv asks for polite delays (one call per 3 seconds).
15
+ We use 5s between calls and limit to 50 results per call by default.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import re
21
+ import time
22
+ import urllib.parse
23
+ import urllib.request
24
+ import xml.etree.ElementTree as ET
25
+ from dataclasses import dataclass, field
26
+ from datetime import datetime, timezone
27
+ from typing import Any
28
+
29
+ from .db import Database, get_db
30
+
31
+ # ---------------------------------------------------------------------------
32
+ # arXiv API constants
33
+ # ---------------------------------------------------------------------------
34
+
35
+ ARXIV_API_BASE = "https://export.arxiv.org/api/query"
36
+
37
+ DEFAULT_CATEGORIES = ["cs.LG", "cs.AI", "stat.ML", "cs.CL"]
38
+
39
+ # arXiv API namespaces
40
+ _NS = {
41
+ "atom": "http://www.w3.org/2005/Atom",
42
+ "arxiv": "http://arxiv.org/schemas/atom",
43
+ }
44
+
45
+ # ---------------------------------------------------------------------------
46
+ # Triage gate
47
+ # ---------------------------------------------------------------------------
48
+
49
+ # Phrases that suggest the paper claims novelty (pass triage)
50
+ _NOVELTY_PATTERNS: list[re.Pattern] = [
51
+ re.compile(p, re.IGNORECASE)
52
+ for p in [
53
+ r"\bnovel\b",
54
+ r"\bwe\s+(propose|introduce|present)\b",
55
+ r"\bnew\s+(method|architecture|framework|approach|technique|algorithm|model|paradigm)\b",
56
+ r"\bstate.of.the.art\b",
57
+ r"\boutperforms?\b",
58
+ r"\b(unlike|differs?\s+from|in\s+contrast\s+to)\s+(prior|previous|existing|traditional)\b",
59
+ r"\badvances?\s+(the\s+)?(state|field)\b",
60
+ r"\bfirst\s+(method|approach|architecture|time)\b",
61
+ r"\b(breakthrough|groundbreaking|pioneering)\b",
62
+ r"\bcontribution\b",
63
+ r"\bwe\s+(achieve|obtain|demonstrate)\b",
64
+ ]
65
+ ]
66
+
67
+ # Phrases that suggest the paper claims NO novelty (skip or flag)
68
+ _SKIP_PATTERNS: list[re.Pattern] = [
69
+ re.compile(p, re.IGNORECASE)
70
+ for p in [
71
+ r"\b(survey|review|tutorial)\s+(of|on)\b",
72
+ r"\bcomprehensive\s+(survey|review)\b",
73
+ r"\b(literature\s+review|related\s+work\b)",
74
+ r"\b(benchmark|benchmarking)\b",
75
+ r"\b(reproduce|replicate|reproduction)\b",
76
+ r"\b(dataset|corpus|collection)\s+(release|introduction|description)\b",
77
+ r"\b(position\s+paper|opinion|commentary)\b",
78
+ r"\b(workshop|competition|challenge)\s+(report|summary|overview)\b",
79
+ r"\b(extended\s+abstract|demo|poster)\b",
80
+ ]
81
+ ]
82
+
83
+
84
+ def triage(abstract: str) -> tuple[bool, str]:
85
+ """Determine whether an abstract passes the triage gate.
86
+
87
+ Returns (passed, reason).
88
+ """
89
+ # Check skip patterns first (hard no)
90
+ for pat in _SKIP_PATTERNS:
91
+ if pat.search(abstract):
92
+ return False, f"skip_pattern_match: {pat.pattern[:60]}"
93
+
94
+ # Check novelty patterns (soft yes)
95
+ matches: list[str] = []
96
+ for pat in _NOVELTY_PATTERNS:
97
+ m = pat.search(abstract)
98
+ if m:
99
+ matches.append(m.group(0))
100
+
101
+ if matches:
102
+ return True, f"novelty_signals: {', '.join(matches[:3])}"
103
+
104
+ return False, "no_novelty_signals_detected"
105
+
106
+
107
+ # ---------------------------------------------------------------------------
108
+ # arXiv API client
109
+ # ---------------------------------------------------------------------------
110
+
111
+ def _fetch_arxiv(
112
+ categories: list[str] | None = None,
113
+ max_results: int = 50,
114
+ start: int = 0,
115
+ sort_by: str = "submittedDate",
116
+ sort_order: str = "descending",
117
+ ) -> str:
118
+ """Fetch raw XML from arXiv API. Returns the XML as a string.
119
+
120
+ Tries urllib first, falls back to requests if urllib fails (some
121
+ container environments have DNS/config issues with urllib).
122
+ """
123
+ if categories is None:
124
+ categories = DEFAULT_CATEGORIES
125
+
126
+ cat_query = "+OR+".join(f"cat:{c}" for c in categories)
127
+ params = {
128
+ "search_query": cat_query,
129
+ "start": str(start),
130
+ "max_results": str(max_results),
131
+ "sortBy": sort_by,
132
+ "sortOrder": sort_order,
133
+ }
134
+ url = f"{ARXIV_API_BASE}?{urllib.parse.urlencode(params)}"
135
+ headers = {"User-Agent": "DifferanceEngine/0.1 (mailto:chris@graziul.io)"}
136
+
137
+ # Strategy 1: urllib
138
+ try:
139
+ req = urllib.request.Request(url, headers=headers)
140
+ with urllib.request.urlopen(req, timeout=30) as resp:
141
+ return resp.read().decode("utf-8")
142
+ except Exception as e:
143
+ print(f" [ingest] urllib fetch failed ({type(e).__name__}: {str(e)[:100]}), trying requests...")
144
+
145
+ # Strategy 2: requests (more robust in containerized environments)
146
+ import requests as _requests
147
+ resp = _requests.get(url, headers=headers, timeout=30)
148
+ resp.raise_for_status()
149
+ return resp.text
150
+
151
+
152
+ def parse_arxiv_xml(xml_str: str) -> list[dict]:
153
+ """Parse arXiv API Atom XML into a list of paper dicts."""
154
+ root = ET.fromstring(xml_str)
155
+ papers: list[dict] = []
156
+
157
+ for entry in root.findall("atom:entry", _NS):
158
+ arxiv_id_full = entry.find("atom:id", _NS).text or ""
159
+ # Strip the "http://arxiv.org/abs/" prefix to get the canonical ID
160
+ arxiv_id = arxiv_id_full.split("/abs/")[-1] if "/abs/" in arxiv_id_full else arxiv_id_full
161
+
162
+ title = " ".join((entry.find("atom:title", _NS).text or "").split())
163
+ abstract = " ".join((entry.find("atom:summary", _NS).text or "").split())
164
+
165
+ # Authors
166
+ authors: list[str] = []
167
+ for author_elem in entry.findall("atom:author", _NS):
168
+ name_elem = author_elem.find("atom:name", _NS)
169
+ if name_elem is not None and name_elem.text:
170
+ authors.append(name_elem.text.strip())
171
+
172
+ # Categories
173
+ categories: list[str] = []
174
+ for cat_elem in entry.findall("atom:category", _NS):
175
+ term = cat_elem.get("term", "")
176
+ if term:
177
+ categories.append(term)
178
+
179
+ # Dates
180
+ published = entry.find("atom:published", _NS)
181
+ published_str = published.text if published is not None else ""
182
+ updated = entry.find("atom:updated", _NS)
183
+ updated_str = updated.text if updated is not None else ""
184
+
185
+ # PDF link
186
+ pdf_url = ""
187
+ for link in entry.findall("atom:link", _NS):
188
+ if link.get("title") == "pdf":
189
+ pdf_url = link.get("href", "")
190
+ break
191
+
192
+ papers.append({
193
+ "arxiv_id": arxiv_id,
194
+ "title": title,
195
+ "abstract": abstract,
196
+ "authors": authors,
197
+ "categories": categories,
198
+ "published": published_str,
199
+ "updated": updated_str,
200
+ "pdf_url": pdf_url,
201
+ })
202
+
203
+ return papers
204
+
205
+
206
+ # ---------------------------------------------------------------------------
207
+ # Ingestion runner
208
+ # ---------------------------------------------------------------------------
209
+
210
+ @dataclass
211
+ class IngestResult:
212
+ ingested: int = 0
213
+ triaged_in: int = 0
214
+ triaged_out: int = 0
215
+ skipped_existing: int = 0
216
+
217
+
218
+ def ingest_daily(
219
+ db: Database | None = None,
220
+ categories: list[str] | None = None,
221
+ max_results: int = 50,
222
+ max_pages: int = 2,
223
+ ) -> IngestResult:
224
+ """Run daily ingestion: fetch new papers, deduplicate, triage, store.
225
+
226
+ Pages through results up to max_pages * max_results papers.
227
+ """
228
+ if db is None:
229
+ db = get_db()
230
+ db.connect()
231
+
232
+ if categories is None:
233
+ categories = DEFAULT_CATEGORIES
234
+
235
+ result = IngestResult()
236
+
237
+ for page in range(max_pages):
238
+ start = page * max_results
239
+
240
+ try:
241
+ xml_str = _fetch_arxiv(
242
+ categories=categories,
243
+ max_results=max_results,
244
+ start=start,
245
+ )
246
+ except Exception as e:
247
+ print(f" [ingest] arXiv API error (page {page}, start={start}): {e}")
248
+ if page == 0:
249
+ raise # Fail hard on first page error; tolerate subsequent pages
250
+ break
251
+
252
+ papers = parse_arxiv_xml(xml_str)
253
+ if not papers:
254
+ break # No more results
255
+
256
+ for paper in papers:
257
+ # Deduplicate
258
+ if db.paper_exists(paper["arxiv_id"]):
259
+ result.skipped_existing += 1
260
+ continue
261
+
262
+ # Triage
263
+ passed, reason = triage(paper["abstract"])
264
+
265
+ # Store
266
+ db.insert_paper(paper)
267
+ db.update_triage(paper["arxiv_id"], passed, reason)
268
+ result.ingested += 1
269
+
270
+ if passed:
271
+ result.triaged_in += 1
272
+ else:
273
+ result.triaged_out += 1
274
+
275
+ # Respect arXiv rate limit
276
+ if page < max_pages - 1:
277
+ time.sleep(5)
278
+
279
+ return result
280
+
281
+
282
+ def _fetch_paper_via_hf_hub(arxiv_id: str) -> dict | None:
283
+ """Fallback: try to fetch paper metadata via huggingface_hub papers API.
284
+
285
+ HF Hub mirrors arXiv metadata and may be reachable when arXiv is not.
286
+ Returns a paper dict matching the arXiv parse format, or None.
287
+
288
+ Uses the HF_TOKEN from the environment (automatically available in Spaces).
289
+ The list_papers API was added in huggingface_hub 0.26+; if unavailable,
290
+ falls back to searching daily papers.
291
+ """
292
+ try:
293
+ from huggingface_hub import HfApi
294
+ api = HfApi()
295
+ # Try the dedicated list_papers API (huggingface_hub >= 0.26)
296
+ if hasattr(api, "list_papers"):
297
+ papers = api.list_papers(query=arxiv_id, limit=1)
298
+ paper_list = list(papers)
299
+ if paper_list:
300
+ p = paper_list[0]
301
+ return {
302
+ "arxiv_id": p.id or arxiv_id,
303
+ "title": p.title or "",
304
+ "abstract": p.summary or "",
305
+ "authors": p.authors or [],
306
+ "categories": p.tags or [],
307
+ "published": p.published_at.isoformat() if p.published_at else "",
308
+ "updated": p.updated_at.isoformat() if getattr(p, "updated_at", None) else "",
309
+ "pdf_url": p.url_pdf or "",
310
+ }
311
+
312
+ # Fallback: search daily papers
313
+ if hasattr(api, "search_papers"):
314
+ papers = api.search_papers(query=arxiv_id, limit=1)
315
+ paper_list = list(papers)
316
+ if paper_list:
317
+ p = paper_list[0]
318
+ return {
319
+ "arxiv_id": p.id or arxiv_id,
320
+ "title": p.title or "",
321
+ "abstract": p.summary or "",
322
+ "authors": p.authors or [],
323
+ "categories": p.tags or [],
324
+ "published": p.published_at.isoformat() if p.published_at else "",
325
+ "updated": p.updated_at.isoformat() if getattr(p, "updated_at", None) else "",
326
+ "pdf_url": p.url_pdf or "",
327
+ }
328
+
329
+ print(f" [ingest] HF Hub paper API not available in this huggingface_hub version")
330
+ return None
331
+ except Exception as e:
332
+ print(f" [ingest] HF Hub paper fallback also failed: {type(e).__name__}: {str(e)[:120]}")
333
+ return None
334
+
335
+
336
+ def _try_fetch_citations(arxiv_id: str, db: Database | None = None):
337
+ """Fetch citation count from Semantic Scholar and update the DB.
338
+
339
+ Runs synchronously but catches all errors — this is best-effort enrichment,
340
+ not mission-critical. If it fails, the paper is still ingested.
341
+ """
342
+ import json as _json
343
+ try:
344
+ url = f"https://api.semanticscholar.org/graph/v1/paper/ArXiv:{arxiv_id}?fields=citationCount"
345
+ req = urllib.request.Request(url, headers={"User-Agent": "DifferanceEngine/1.0"})
346
+ with urllib.request.urlopen(req, timeout=10) as resp:
347
+ data = _json.loads(resp.read())
348
+ count = data.get("citationCount", 0)
349
+ if count and db:
350
+ db.update_citation(arxiv_id, count)
351
+ return count
352
+ except Exception:
353
+ return 0
354
+
355
+
356
+ def ingest_single(
357
+ arxiv_id: str,
358
+ db: Database | None = None,
359
+ ) -> dict | None:
360
+ """Ingest a single paper by arXiv ID.
361
+
362
+ Returns the paper dict if found and ingested, None if not found.
363
+ Tries arXiv API first, falls back to huggingface_hub papers API.
364
+ """
365
+ if db is None:
366
+ db = get_db()
367
+ db.connect()
368
+
369
+ # Check if already ingested (version-aware) — if so, return it
370
+ existing = db.find_paper(arxiv_id)
371
+ if existing:
372
+ return existing
373
+
374
+ paper = None
375
+ errors: list[str] = []
376
+
377
+ # Strategy 1: arXiv API (primary)
378
+ try:
379
+ params = {
380
+ "id_list": arxiv_id,
381
+ "max_results": "1",
382
+ }
383
+ url = f"{ARXIV_API_BASE}?{urllib.parse.urlencode(params)}"
384
+ req = urllib.request.Request(url)
385
+ req.add_header("User-Agent", "DifferanceEngine/0.1 (mailto:chris@graziul.io)")
386
+
387
+ with urllib.request.urlopen(req, timeout=30) as resp:
388
+ xml_str = resp.read().decode("utf-8")
389
+ papers = parse_arxiv_xml(xml_str)
390
+ if papers:
391
+ paper = papers[0]
392
+ except Exception as e:
393
+ err_msg = f"arXiv API: {type(e).__name__}: {str(e)[:120]}"
394
+ errors.append(err_msg)
395
+ print(f" [ingest] {err_msg}")
396
+
397
+ # Strategy 2: Also try with requests (sometimes urllib fails on weird network configs)
398
+ if paper is None:
399
+ try:
400
+ import requests as _requests
401
+ params = {
402
+ "id_list": arxiv_id,
403
+ "max_results": "1",
404
+ }
405
+ url = f"{ARXIV_API_BASE}?{urllib.parse.urlencode(params)}"
406
+ resp = _requests.get(
407
+ url,
408
+ headers={"User-Agent": "DifferanceEngine/0.1 (mailto:chris@graziul.io)"},
409
+ timeout=30,
410
+ )
411
+ resp.raise_for_status()
412
+ papers = parse_arxiv_xml(resp.text)
413
+ if papers:
414
+ paper = papers[0]
415
+ except Exception as e:
416
+ err_msg = f"arXiv via requests: {type(e).__name__}: {str(e)[:120]}"
417
+ errors.append(err_msg)
418
+ print(f" [ingest] {err_msg}")
419
+
420
+ # Strategy 3: huggingface_hub papers API (last resort)
421
+ if paper is None:
422
+ print(f" [ingest] arXiv fetch failed, trying HF Hub papers API...")
423
+ paper = _fetch_paper_via_hf_hub(arxiv_id)
424
+
425
+ if paper is None:
426
+ print(f" [ingest] All strategies failed for {arxiv_id}: {'; '.join(errors)}")
427
+ return None
428
+
429
+ passed, reason = triage(paper["abstract"])
430
+ db.insert_paper(paper)
431
+ db.update_triage(paper["arxiv_id"], passed, reason)
432
+ # Fetch citation count from Semantic Scholar in background
433
+ _try_fetch_citations(paper["arxiv_id"], db)
434
+
435
+ return paper