File size: 6,389 Bytes
12eb9f4
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
#!/usr/bin/env python3
"""
Append papers that Turso has and the metadata sidecar does not.

Why this exists
---------------
The sidecar ships inside the Docker image, and the starter feed and keyword
search only see what it holds. build_metadata_sidecar.py rebuilds it from
scratch, which re-reads 1.8M rows; after an ingest only the new rows are
needed. New Turso rows have rowids above the last ingest's, so they are
fetched by `rowid > --after-rowid`.

Three things must stay consistent, and this does all of them in one
transaction:
  * papers            one row per paper (plain INSERT; an existing arxiv_id is
                      skipped, never replaced: INSERT OR REPLACE would give it
                      a new rowid and desynchronise the search index)
  * paper_categories  one row per (paper, arXiv code), as the builder writes
  * papers_fts        external-content FTS5 over papers(rowid); new rows must
                      be indexed explicitly, under the rowid they were given

Run it on a COPY of the sidecar, then check with --verify.

Usage
-----
    export TURSO_URL=... TURSO_DB_TOKEN=...
    python scripts/append_to_sidecar.py --sidecar publish/metadata.sqlite --after-rowid 1799348
"""
from __future__ import annotations

import argparse
import os
import sqlite3
import sys

import httpx

COLUMNS = ("arxiv_id", "title", "authors", "abstract_preview", "categories",
           "primary_topic", "update_date", "citation_count", "influential_citations")


def turso_rows_after(url: str, token: str, after: int, page: int = 2000):
    """Yield Turso paper rows with rowid > after, in rowid order."""
    url = url.replace("libsql://", "https://").rstrip("/")
    last = after
    with httpx.Client(timeout=180) as client:
        while True:
            sql = (f"SELECT rowid, {', '.join(COLUMNS)} FROM papers "
                   f"WHERE rowid > {int(last)} ORDER BY rowid LIMIT {int(page)}")
            r = client.post(f"{url}/v2/pipeline", headers={"Authorization": f"Bearer {token}"},
                            json={"requests": [{"type": "execute", "stmt": {"sql": sql}},
                                               {"type": "close"}]})
            r.raise_for_status()
            res = r.json()["results"][0]
            if res.get("type") != "ok":
                raise RuntimeError(str(res)[:200])
            rows = [[c.get("value") for c in row] for row in res["response"]["result"]["rows"]]
            if not rows:
                return
            for row in rows:
                yield dict(zip(COLUMNS, row[1:]))
            last = int(rows[-1][0])


def append_rows(conn: sqlite3.Connection, rows) -> dict:
    """Insert papers, their category rows and their FTS entries. Returns counts."""
    stats = {"inserted": 0, "skipped_existing": 0, "category_rows": 0}
    with conn:
        for r in rows:
            cit = int(r.get("citation_count") or 0)
            inf = int(r.get("influential_citations") or 0)
            cur = conn.execute(
                "INSERT OR IGNORE INTO papers (arxiv_id, title, authors, abstract_preview, "
                "categories, primary_topic, update_date, citation_count, influential_citations) "
                "VALUES (?,?,?,?,?,?,?,?,?)",
                (r["arxiv_id"], r["title"], r["authors"], r["abstract_preview"], r["categories"],
                 r["primary_topic"], r["update_date"], cit, inf))
            if cur.rowcount != 1:
                stats["skipped_existing"] += 1
                continue
            rowid = cur.lastrowid
            stats["inserted"] += 1
            codes = str(r.get("categories") or "").split()
            conn.executemany("INSERT INTO paper_categories VALUES (?,?,?,?)",
                             [(code, r["arxiv_id"], cit, r["update_date"]) for code in codes])
            stats["category_rows"] += len(codes)
            conn.execute("INSERT INTO papers_fts(rowid, title, abstract_preview) VALUES (?,?,?)",
                         (rowid, r["title"], r["abstract_preview"]))
    return stats


def verify(conn: sqlite3.Connection) -> dict:
    """Consistency checks the published file must pass."""
    out = {
        "quick_check": conn.execute("PRAGMA quick_check").fetchone()[0],
        "papers": conn.execute("SELECT COUNT(*) FROM papers").fetchone()[0],
        "fts_rows": conn.execute("SELECT COUNT(*) FROM papers_fts").fetchone()[0],
        "papers_without_categories": conn.execute(
            "SELECT COUNT(*) FROM papers p WHERE categories IS NOT NULL AND categories != '' "
            "AND NOT EXISTS (SELECT 1 FROM paper_categories c WHERE c.arxiv_id = p.arxiv_id)"
        ).fetchone()[0],
        "newest_update_date": conn.execute("SELECT MAX(update_date) FROM papers").fetchone()[0],
    }
    try:
        conn.execute("INSERT INTO papers_fts(papers_fts) VALUES('integrity-check')")
        out["fts_integrity"] = "ok"
    except sqlite3.DatabaseError as e:
        out["fts_integrity"] = f"FAILED: {e}"
    return out


def main() -> int:
    ap = argparse.ArgumentParser()
    ap.add_argument("--sidecar", required=True, help="sidecar COPY to modify")
    ap.add_argument("--after-rowid", type=int, help="append Turso rows with rowid above this")
    ap.add_argument("--verify", action="store_true", help="only run the consistency checks")
    args = ap.parse_args()
    conn = sqlite3.connect(args.sidecar)
    if not args.verify:
        if args.after_rowid is None:
            print("--after-rowid is required to append", file=sys.stderr)
            return 2
        url, token = os.environ.get("TURSO_URL", ""), os.environ.get("TURSO_DB_TOKEN", "")
        if not url or not token:
            print("TURSO_URL and TURSO_DB_TOKEN must be set", file=sys.stderr)
            return 2
        stats = append_rows(conn, turso_rows_after(url, token, args.after_rowid))
        print(f"[sidecar] appended {stats['inserted']:,} papers, {stats['category_rows']:,} "
              f"category rows; skipped {stats['skipped_existing']:,} already present")
        conn.execute("PRAGMA wal_checkpoint(TRUNCATE)")
    report = verify(conn)
    print("[sidecar] verify:", report)
    ok = (report["quick_check"] == "ok" and report["fts_integrity"] == "ok"
          and report["fts_rows"] == report["papers"] and report["papers_without_categories"] == 0)
    return 0 if ok else 1


if __name__ == "__main__":
    sys.exit(main())