siddhm11 Claude Opus 5.5 commited on
Commit
da423eb
Β·
1 Parent(s): 38e1da6

feat(ingest): keep each harvested paper's licence in Turso paper_licences

Browse files

parse_oai_record reads <license> into licence_url and a short code
(app.licences.code_from_url). New papers' licence rows go in the same
Turso pipeline as their papers rows; papers already stored get theirs
when OAI lists them again. Own table, never a papers column: INSERT OR
REPLACE rewrites whole papers rows (trap T5). The upsert skips rows that
already say the same, so daily re-harvests write nothing.

Dry run over 2026-10-16: 1,833 papers kept, all with a licence
(ARXIV-NONEXCLUSIVE 865, CC-BY-4.0 773, CC-BY-NC-ND 79, CC-BY-NC-SA 67,
CC-BY-SA 36, CC0 13).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

scripts/ingest_arxiv.py CHANGED
@@ -57,7 +57,9 @@ import urllib.request
57
  import xml.etree.ElementTree as ET
58
 
59
  sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
 
60
  from net_deadline import deadline # noqa: E402
 
61
 
62
  ARXIV_API = "https://export.arxiv.org/api/query"
63
  NS = {"atom": "http://www.w3.org/2005/Atom",
@@ -328,6 +330,7 @@ def parse_oai_record(rec) -> dict | None:
328
  au.findtext("a:suffix", "", OAI_NS))
329
  authors.append(" ".join(p.strip() for p in parts if p and p.strip()))
330
  cats = canonical_categories(" ".join((m.findtext("a:categories", "", OAI_NS) or "").split()))
 
331
  return {
332
  "arxiv_id": m.findtext("a:id", "", OAI_NS).strip(),
333
  "title": title,
@@ -336,6 +339,8 @@ def parse_oai_record(rec) -> dict | None:
336
  "categories": cats,
337
  "primary_topic": cats.split()[0] if cats else "",
338
  "update_date": (m.findtext("a:updated", "", OAI_NS) or m.findtext("a:created", "", OAI_NS) or "")[:10],
 
 
339
  }
340
 
341
 
@@ -507,9 +512,14 @@ def main() -> int:
507
 
508
  seen_total = new_total = 0
509
  t0 = time.time()
 
510
 
511
  def handle_page(papers: list[dict]) -> int:
512
  ids = [p["arxiv_id"] for p in papers]
 
 
 
 
513
  if args.dry_run:
514
  return len(ids)
515
  if args.encode_only:
@@ -523,7 +533,9 @@ def main() -> int:
523
  todo = [p for p in papers if p["arxiv_id"] not in have]
524
  if todo:
525
  vecs = encoder.encode([f"{p['title'][:256]} {p['abstract'][:1024]}" for p in todo])
526
- upserter.upsert(todo, vecs)
 
 
527
  return len(todo)
528
 
529
  def save_state() -> None:
@@ -545,6 +557,9 @@ def main() -> int:
545
 
546
  print(f"\ndone: examined {seen_total:,}, new {new_total:,} "
547
  f"in {(time.time()-t0)/60:.1f} min")
 
 
 
548
  if args.limit:
549
  return 0
550
  if args.source == "oai":
 
57
  import xml.etree.ElementTree as ET
58
 
59
  sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
60
+ sys.path.insert(1, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
61
  from net_deadline import deadline # noqa: E402
62
+ from app.licences import code_from_url # noqa: E402 (standard library only)
63
 
64
  ARXIV_API = "https://export.arxiv.org/api/query"
65
  NS = {"atom": "http://www.w3.org/2005/Atom",
 
330
  au.findtext("a:suffix", "", OAI_NS))
331
  authors.append(" ".join(p.strip() for p in parts if p and p.strip()))
332
  cats = canonical_categories(" ".join((m.findtext("a:categories", "", OAI_NS) or "").split()))
333
+ licence_url = (m.findtext("a:license", "", OAI_NS) or "").strip()
334
  return {
335
  "arxiv_id": m.findtext("a:id", "", OAI_NS).strip(),
336
  "title": title,
 
339
  "categories": cats,
340
  "primary_topic": cats.split()[0] if cats else "",
341
  "update_date": (m.findtext("a:updated", "", OAI_NS) or m.findtext("a:created", "", OAI_NS) or "")[:10],
342
+ "licence_url": licence_url, # "" when the record names none
343
+ "licence": code_from_url(licence_url),
344
  }
345
 
346
 
 
512
 
513
  seen_total = new_total = 0
514
  t0 = time.time()
515
+ licence_tally: dict[str, int] = {}
516
 
517
  def handle_page(papers: list[dict]) -> int:
518
  ids = [p["arxiv_id"] for p in papers]
519
+ if args.source == "oai":
520
+ for p in papers:
521
+ code = p["licence"] or ("unrecognised" if p["licence_url"] else "none")
522
+ licence_tally[code] = licence_tally.get(code, 0) + 1
523
  if args.dry_run:
524
  return len(ids)
525
  if args.encode_only:
 
533
  todo = [p for p in papers if p["arxiv_id"] not in have]
534
  if todo:
535
  vecs = encoder.encode([f"{p['title'][:256]} {p['abstract'][:1024]}" for p in todo])
536
+ upserter.upsert(todo, vecs) # their licences ride in the same Turso batch
537
+ # Papers stored before licences were kept get theirs when OAI lists them again.
538
+ upserter.write_licences([p for p in papers if p["arxiv_id"] in have])
539
  return len(todo)
540
 
541
  def save_state() -> None:
 
557
 
558
  print(f"\ndone: examined {seen_total:,}, new {new_total:,} "
559
  f"in {(time.time()-t0)/60:.1f} min")
560
+ if licence_tally:
561
+ print("licences: " + ", ".join(f"{k}={v:,}" for k, v in
562
+ sorted(licence_tally.items(), key=lambda kv: -kv[1])))
563
  if args.limit:
564
  return 0
565
  if args.source == "oai":
scripts/ingest_backends.py CHANGED
@@ -134,9 +134,35 @@ class Encoder:
134
  return results
135
 
136
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
137
  class Upserter:
138
  """Writes to Qdrant, Zilliz and Turso."""
139
 
 
 
140
  def __init__(self):
141
  self.qurl = os.environ.get("QDRANT_RECENT_URL", "").rstrip("/")
142
  self.qkey = os.environ.get("QDRANT_RECENT_API_KEY", "")
@@ -195,7 +221,20 @@ class Upserter:
195
  if res.get("type") == "error":
196
  raise RuntimeError(str(res.get("error"))[:200])
197
 
 
 
 
 
 
 
 
 
 
198
  # ── Public ───────────────────────────────────────────────────────────
 
 
 
 
199
  def qdrant_existing(self, ids: list[str]) -> set[str]:
200
  """arxiv_ids of `ids` that already have a point in the collection.
201
 
@@ -254,4 +293,6 @@ class Upserter:
254
  {"type": "text", "value": topic_label(p["categories"])},
255
  {"type": "text", "value": p["update_date"]}],
256
  })
257
- self._t(stmts)
 
 
 
134
  return results
135
 
136
 
137
+ # Licences live in their own table: `INSERT OR REPLACE INTO papers` rewrites
138
+ # whole rows, so a licence column there would vanish on any re-ingest (trap T5).
139
+ # Written only by the OAI path; the search API gives no licence.
140
+ LICENCE_DDL = ("CREATE TABLE IF NOT EXISTS paper_licences (arxiv_id TEXT PRIMARY KEY, "
141
+ "licence TEXT NOT NULL, licence_url TEXT, source TEXT, updated_at TEXT)")
142
+
143
+
144
+ def licence_stmts(papers: list[dict]) -> list[dict]:
145
+ """Upserts for the papers whose record named a licence. A row that already
146
+ says the same is left alone, so daily re-harvests write nothing."""
147
+ return [{
148
+ "sql": ("INSERT INTO paper_licences (arxiv_id, licence, licence_url, source, updated_at) "
149
+ "VALUES (?, ?, ?, 'oai', datetime('now')) "
150
+ "ON CONFLICT(arxiv_id) DO UPDATE SET licence = excluded.licence, "
151
+ "licence_url = excluded.licence_url, source = excluded.source, "
152
+ "updated_at = excluded.updated_at "
153
+ "WHERE paper_licences.licence IS NOT excluded.licence "
154
+ "OR paper_licences.licence_url IS NOT excluded.licence_url"),
155
+ "args": [{"type": "text", "value": p["arxiv_id"]},
156
+ {"type": "text", "value": p.get("licence") or ""},
157
+ {"type": "text", "value": p["licence_url"]}],
158
+ } for p in papers if p.get("licence_url")]
159
+
160
+
161
  class Upserter:
162
  """Writes to Qdrant, Zilliz and Turso."""
163
 
164
+ _licence_table = False # created on the run's first licence write
165
+
166
  def __init__(self):
167
  self.qurl = os.environ.get("QDRANT_RECENT_URL", "").rstrip("/")
168
  self.qkey = os.environ.get("QDRANT_RECENT_API_KEY", "")
 
221
  if res.get("type") == "error":
222
  raise RuntimeError(str(res.get("error"))[:200])
223
 
224
+ def _with_licences(self, stmts: list[dict], papers: list[dict]) -> None:
225
+ """Turso `stmts` plus the licence rows for `papers`, in one pipeline."""
226
+ lic = licence_stmts(papers)
227
+ if lic and not self._licence_table:
228
+ lic.insert(0, {"sql": LICENCE_DDL})
229
+ if stmts or lic:
230
+ self._t(stmts + lic)
231
+ self._licence_table = self._licence_table or bool(lic)
232
+
233
  # ── Public ───────────────────────────────────────────────────────────
234
+ def write_licences(self, papers: list[dict]) -> None:
235
+ """Licence rows for papers already stored."""
236
+ self._with_licences([], papers)
237
+
238
  def qdrant_existing(self, ids: list[str]) -> set[str]:
239
  """arxiv_ids of `ids` that already have a point in the collection.
240
 
 
293
  {"type": "text", "value": topic_label(p["categories"])},
294
  {"type": "text", "value": p["update_date"]}],
295
  })
296
+ # Licence rows after the paper rows: Turso is the resume marker, and a
297
+ # failed licence write is redone when the re-run finds the paper stored.
298
+ self._with_licences(stmts, papers)
tests/test_ingest_safety.py CHANGED
@@ -183,3 +183,55 @@ def test_fetch_page_retries_a_rate_limit_then_succeeds(monkeypatch):
183
  monkeypatch.setattr(ingest_arxiv.time, "sleep", waits.append)
184
  assert ingest_arxiv.fetch_page("cs.RO", "2026-09-28", "2026-10-02", 0) == ([], 0)
185
  assert waits == [30, 60] and len(calls) == 3
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
183
  monkeypatch.setattr(ingest_arxiv.time, "sleep", waits.append)
184
  assert ingest_arxiv.fetch_page("cs.RO", "2026-09-28", "2026-10-02", 0) == ([], 0)
185
  assert waits == [30, 60] and len(calls) == 3
186
+
187
+
188
+ # ── licences ─────────────────────────────────────────────────────────────────
189
+
190
+ def _licensed(aid, url):
191
+ p = _papers(aid)[0]
192
+ return {**p, "licence_url": url, "licence": "CC-BY-4.0" if "creativecommons" in url else ""}
193
+
194
+
195
+ def test_licence_rows_ride_in_the_same_batch_as_the_paper_rows(monkeypatch):
196
+ up, calls = _upserter(monkeypatch, present=set())
197
+ papers = [_licensed("2610.00001", "http://creativecommons.org/licenses/by/4.0/"), _papers("2610.00002")[0]]
198
+ up.upsert(papers, [([0.1] * 4, {})] * 2)
199
+ (batch,) = calls["t"]
200
+ sqls = [s["sql"] for s in batch]
201
+ assert [s.split()[0:3] for s in sqls[:2]] == [["INSERT", "OR", "REPLACE"]] * 2 # papers first
202
+ assert sqls[2] == ingest_backends.LICENCE_DDL # then the table, once
203
+ assert sqls[3].startswith("INSERT INTO paper_licences") and "ON CONFLICT(arxiv_id) DO UPDATE" in sqls[3]
204
+ assert [a["value"] for a in batch[3]["args"]] == ["2610.00001", "CC-BY-4.0",
205
+ "http://creativecommons.org/licenses/by/4.0/"]
206
+ assert len(batch) == 4 # the paper without a licence gets no row
207
+
208
+ up.upsert([_licensed("2610.00003", "http://creativecommons.org/licenses/by/4.0/")], [([0.1] * 4, {})])
209
+ assert ingest_backends.LICENCE_DDL not in [s["sql"] for s in calls["t"][1]] # created once per run
210
+
211
+
212
+ def test_stored_papers_get_licence_rows_and_nothing_else(monkeypatch):
213
+ up, calls = _upserter(monkeypatch, present=set())
214
+ up._licence_table = True
215
+ up.write_licences(_papers("2610.00004")) # no licence in the record: no call at all
216
+ assert calls["t"] == []
217
+ up.write_licences([_licensed("2610.00005", "http://arxiv.org/licenses/nonexclusive-distrib/1.0/")])
218
+ (batch,) = calls["t"]
219
+ assert len(batch) == 1 and batch[0]["sql"].startswith("INSERT INTO paper_licences")
220
+ # an unchanged row is left alone, so daily re-harvests write nothing
221
+ assert "WHERE paper_licences.licence IS NOT excluded.licence" in batch[0]["sql"]
222
+
223
+
224
+ def test_the_licence_upsert_updates_only_rows_that_changed(tmp_path):
225
+ import sqlite3
226
+ db = sqlite3.connect(tmp_path / "t.db")
227
+ db.execute(ingest_backends.LICENCE_DDL)
228
+
229
+ def write(url, code):
230
+ (s,) = ingest_backends.licence_stmts([{"arxiv_id": "2610.00006", "licence_url": url, "licence": code}])
231
+ db.execute(s["sql"], [a["value"] for a in s["args"]])
232
+ return db.total_changes
233
+
234
+ first = write("http://arxiv.org/licenses/nonexclusive-distrib/1.0/", "ARXIV-NONEXCLUSIVE-1.0")
235
+ assert write("http://arxiv.org/licenses/nonexclusive-distrib/1.0/", "ARXIV-NONEXCLUSIVE-1.0") == first
236
+ write("http://creativecommons.org/licenses/by/4.0/", "CC-BY-4.0")
237
+ assert db.execute("SELECT licence, source FROM paper_licences").fetchall() == [("CC-BY-4.0", "oai")]
tests/test_oai_harvest.py CHANGED
@@ -115,3 +115,24 @@ def test_requests_identify_themselves_and_bad_requests_are_not_retried(monkeypat
115
  assert not ia._permanent(urllib.error.HTTPError("u", 406, "x", {}, None)) # was a temporary block
116
  assert ia.retry_wait(urllib.error.HTTPError("u", 406, "x", {}, None), 0) == 30
117
  assert not ia._permanent(TimeoutError("x"))
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
115
  assert not ia._permanent(urllib.error.HTTPError("u", 406, "x", {}, None)) # was a temporary block
116
  assert ia.retry_wait(urllib.error.HTTPError("u", 406, "x", {}, None), 0) == 30
117
  assert not ia._permanent(TimeoutError("x"))
118
+
119
+
120
+ def _record_with_licence(licence: str | None) -> ET.Element:
121
+ tag = f"<license>{licence}</license>" if licence is not None else ""
122
+ return ET.fromstring(
123
+ '<record xmlns="http://www.openarchives.org/OAI/2.0/"><header><identifier>oai:arXiv.org:2610.00042'
124
+ '</identifier></header><metadata><arXiv xmlns="http://arxiv.org/OAI/arXiv/"><id>2610.00042</id>'
125
+ '<created>2026-10-19</created><authors><author><keyname>Lee</keyname></author></authors>'
126
+ '<title>Figures with licences</title><categories>cs.CV</categories>'
127
+ f'<abstract>An abstract long enough to keep the record.</abstract>{tag}</arXiv></metadata></record>')
128
+
129
+
130
+ def test_the_licence_is_read_from_the_record():
131
+ cc = ia.parse_oai_record(_record_with_licence("http://creativecommons.org/licenses/by/4.0/"))
132
+ assert cc["licence"] == "CC-BY-4.0" and cc["licence_url"] == "http://creativecommons.org/licenses/by/4.0/"
133
+ default = ia.parse_oai_record(_record_with_licence(" http://arxiv.org/licenses/nonexclusive-distrib/1.0/ "))
134
+ assert default["licence"] == "ARXIV-NONEXCLUSIVE-1.0"
135
+ odd = ia.parse_oai_record(_record_with_licence("https://example.org/my-licence"))
136
+ assert odd["licence"] == "" and odd["licence_url"] == "https://example.org/my-licence" # kept to remap later
137
+ none = ia.parse_oai_record(list(ET.fromstring(PAGE).iter("{http://www.openarchives.org/OAI/2.0/}record"))[0])
138
+ assert none["licence"] == "" and none["licence_url"] == ""