Spaces:
Running
Running
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 filesparse_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 +16 -1
- scripts/ingest_backends.py +42 -1
- tests/test_ingest_safety.py +52 -0
- tests/test_oai_harvest.py +21 -0
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 |
-
|
|
|
|
|
|
|
|
|
| 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"] == ""
|