-
Notifications
You must be signed in to change notification settings - Fork 0
🦄 new(pipeline): sefaria source acquisition (1.1) #25
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
6 commits
Select commit
Hold shift + click to select a range
7731ee0
🦄 new(pipeline): sefaria source fetch
UniquePixels 6665402
🦄 new(data): sefaria jastrow source snapshot
UniquePixels 804d66a
🦠 fix(ci): add @types/bun for type check
UniquePixels d4bb03a
🧺 chore: move pipeline under admin/
UniquePixels 8da3a36
📖 doc: record hebrew font in project guidance
UniquePixels 572d134
🦠 fix(pipeline): harden fetch per review
UniquePixels File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -10,3 +10,6 @@ node_modules/ | |
| !.dev.vars.example | ||
| .env* | ||
| !.env.example | ||
|
|
||
| # sefaria dump cache (pipeline/fetch.ts) | ||
| .cache/ | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,51 @@ | ||
| # Pipeline v2 | ||
|
|
||
| Scripted, re-runnable data pipeline from the true Sefaria source | ||
| ([design spec](../docs/specs/2026-07-03-v2-overhaul-design.md), Phase 1+). | ||
|
|
||
| ## Stage 1 — Source acquisition (`fetch.ts`) | ||
|
|
||
| ```bash | ||
| bun admin/pipeline/fetch.ts # download dump + decode + emit | ||
| bun admin/pipeline/fetch.ts --cached # re-decode from .cache/sefaria (no download) | ||
| ``` | ||
|
|
||
| ### Channel decision (spec task 1.1) | ||
|
|
||
| The canonical channel is **Sefaria's public MongoDB dump**: | ||
|
|
||
| `https://storage.googleapis.com/sefaria-mongo-backup/dump_small.tar.gz` | ||
|
|
||
| | Channel | Verdict | | ||
| |---------|---------| | ||
| | MongoDB dump (`lexicon_entry` collection) | **Chosen.** The database Sefaria actually serves, refreshed roughly daily, publicly documented in their [local-install docs](https://developers.sefaria.org/docs/local-installation-instructions) | | ||
| | [Sefaria-Export](https://github.com/Sefaria/Sefaria-Export) | Rejected: texts and links only, no lexicon collections | | ||
| | [Sefaria-Data](https://github.com/Sefaria/Sefaria-Data) | Rejected: import sources for texts; no Jastrow lexicon source | | ||
| | [Words API](https://developers.sefaria.org/reference/get-words) | Rejected: per-word lookup only; a full crawl would need ~30k requests and still reflect the same database the dump snapshots | | ||
|
|
||
| The dump is ~2.4 GB compressed (~10+ GB unpacked), so `fetch.ts` | ||
| streams it: gunzip + tar parsing happen in memory and only the three | ||
| lexicon collections are written to disk (`.cache/sefaria/`, gitignored). | ||
| The download is cancelled as soon as all targets are captured. | ||
|
|
||
| ### One Jastrow lexicon (not two) | ||
|
|
||
| Sefaria's code maps a second parent lexicon, `Jastrow Unabbreviated` | ||
| (see `LexiconEntrySubClassMapping` in Sefaria-Project | ||
| `sefaria/model/lexicon.py`), but the deployed database does not carry | ||
| it: the 2026-07-04 dump has no `lexicon` record and zero | ||
| `lexicon_entry` docs under that name. Only `Jastrow Dictionary` | ||
| (32,512 entries) exists and is emitted. | ||
|
|
||
| ### Outputs (`data/source/`, committed) | ||
|
|
||
| | File | Contents | | ||
| |------|----------| | ||
| | `jastrow-dictionary.jsonl` | `lexicon_entry` docs with `parent_lexicon: "Jastrow Dictionary"`, verbatim, dump order, relaxed extended JSON | | ||
| | `lexicons.json` | The Jastrow lexicon registry record | | ||
| | `manifest.json` | Provenance: dump URL, ETag, Last-Modified, fetch time, sha256 + entry count per output | | ||
|
|
||
| Documents are emitted **unmodified** — no transformation happens in | ||
| this stage, so `data/source/` is a faithful snapshot for the | ||
| divergence audit (task 1.2). `word_form.bson` is cached for later use | ||
| (search word forms) but not yet emitted. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,198 @@ | ||
| #!/usr/bin/env bun | ||
| /** | ||
| * Pipeline v2, stage 1: source acquisition (spec task 1.1). | ||
| * | ||
| * Streams Sefaria's public MongoDB dump, extracts only the lexicon | ||
| * collections from the tar (the full dump is ~2.4 GB compressed and is | ||
| * never written to disk), then decodes the BSON and emits the Jastrow | ||
| * entries as JSONL plus a provenance manifest. | ||
| * | ||
| * Usage: | ||
| * bun admin/pipeline/fetch.ts # full fetch (download + decode) | ||
| * bun admin/pipeline/fetch.ts --cached # decode from .cache/sefaria without downloading | ||
| * | ||
| * See admin/pipeline/README.md for the channel decision and output contract. | ||
| */ | ||
| import { mkdir } from 'node:fs/promises'; | ||
| import { type Document, EJSON } from 'bson'; | ||
| import { bsonDocuments, ChunkReader, extractTargets, sha256 } from './lib.ts'; | ||
|
|
||
| const DUMP_URL = | ||
| 'https://storage.googleapis.com/sefaria-mongo-backup/dump_small.tar.gz'; | ||
| const CACHE_DIR = '.cache/sefaria'; | ||
| const OUT_DIR = 'data/source'; | ||
|
|
||
| /** Tar members to capture, and where each is cached. */ | ||
| const TARGETS = new Map([ | ||
| ['dump/sefaria/lexicon.bson', `${CACHE_DIR}/lexicon.bson`], | ||
| ['dump/sefaria/lexicon_entry.bson', `${CACHE_DIR}/lexicon_entry.bson`], | ||
| ['dump/sefaria/word_form.bson', `${CACHE_DIR}/word_form.bson`], | ||
| ]); | ||
|
|
||
| /** | ||
| * Sefaria's code also maps a 'Jastrow Unabbreviated' lexicon | ||
| * (LexiconEntrySubClassMapping in Sefaria-Project | ||
| * sefaria/model/lexicon.py), but the 2026-07-04 dump contains no such | ||
| * lexicon record and zero entries for it, so only the printed | ||
| * dictionary is emitted. | ||
| */ | ||
| const JASTROW_LEXICONS = new Map([ | ||
| ['Jastrow Dictionary', `${OUT_DIR}/jastrow-dictionary.jsonl`], | ||
| ]); | ||
|
|
||
| interface DumpProvenance { | ||
| etag: string; | ||
| lastModified: string; | ||
| } | ||
|
|
||
| async function download( | ||
| progress: (msg: string) => void, | ||
| ): Promise<DumpProvenance> { | ||
| progress(`downloading ${DUMP_URL}`); | ||
| const res = await fetch(DUMP_URL); | ||
| if (!res.ok || res.body === null) { | ||
| throw new Error(`dump download failed: HTTP ${res.status}`); | ||
| } | ||
| const provenance: DumpProvenance = { | ||
| etag: res.headers.get('etag') ?? '', | ||
| lastModified: res.headers.get('last-modified') ?? '', | ||
| }; | ||
| const tar = res.body.pipeThrough(new DecompressionStream('gzip')); | ||
| const reader = new ChunkReader(tar[Symbol.asyncIterator]()); | ||
| const missing = await extractTargets(reader, TARGETS, progress); | ||
| if (missing.size > 0) { | ||
| throw new Error( | ||
| `archive ended before extracting: ${[...missing].join(', ')}`, | ||
| ); | ||
| } | ||
| // All targets captured; stop pulling the remainder of the ~2.4 GB body. | ||
| await tar.cancel().catch(() => progress('download stream already closed')); | ||
| await Bun.write( | ||
| `${CACHE_DIR}/provenance.json`, | ||
| `${JSON.stringify(provenance, undefined, '\t')}\n`, | ||
| ); | ||
| return provenance; | ||
| } | ||
|
|
||
| async function loadProvenance( | ||
| cached: boolean, | ||
| progress: (msg: string) => void, | ||
| ): Promise<DumpProvenance> { | ||
| if (!cached) { | ||
| return await download(progress); | ||
| } | ||
| for (const path of TARGETS.values()) { | ||
| if (!(await Bun.file(path).exists())) { | ||
| throw new Error( | ||
| `--cached given but ${path} is missing; run without --cached first`, | ||
| ); | ||
| } | ||
| } | ||
| progress('using cached collections'); | ||
| const stored = Bun.file(`${CACHE_DIR}/provenance.json`); | ||
| if (await stored.exists()) { | ||
| return (await stored.json()) as DumpProvenance; | ||
| } | ||
| return { etag: '', lastModified: '' }; | ||
| } | ||
|
|
||
| async function emitRegistry(progress: (msg: string) => void): Promise<string> { | ||
| const registry: Document[] = []; | ||
| for await (const doc of bsonDocuments( | ||
| TARGETS.get('dump/sefaria/lexicon.bson') as string, | ||
| )) { | ||
| if (JASTROW_LEXICONS.has(doc['name'] as string)) { | ||
| registry.push(doc); | ||
| } | ||
| } | ||
| if (registry.length !== JASTROW_LEXICONS.size) { | ||
| // Fail fast on lexicon-collection schema drift (renamed name | ||
| // field, renamed lexicon) instead of emitting empty outputs. | ||
| throw new Error( | ||
| `expected ${JASTROW_LEXICONS.size} lexicon record(s), found ${registry.length}`, | ||
| ); | ||
| } | ||
| const registryPath = `${OUT_DIR}/lexicons.json`; | ||
| await Bun.write( | ||
| registryPath, | ||
| `${EJSON.stringify(registry, undefined, '\t', { relaxed: true })}\n`, | ||
| ); | ||
| progress(`wrote ${registryPath} (${registry.length} lexicon records)`); | ||
| return registryPath; | ||
| } | ||
|
coderabbitai[bot] marked this conversation as resolved.
|
||
|
|
||
| /** | ||
| * Emit entries verbatim as relaxed extended JSON, one file per lexicon, | ||
| * preserving dump order. | ||
| */ | ||
| async function emitEntries( | ||
| progress: (msg: string) => void, | ||
| ): Promise<Map<string, number>> { | ||
| const counts = new Map<string, number>(); | ||
| const writers = new Map( | ||
| [...JASTROW_LEXICONS].map(([lexicon, path]) => { | ||
| counts.set(lexicon, 0); | ||
| return [lexicon, Bun.file(path).writer()]; | ||
| }), | ||
| ); | ||
| for await (const doc of bsonDocuments( | ||
| TARGETS.get('dump/sefaria/lexicon_entry.bson') as string, | ||
| )) { | ||
| const parentLexicon = doc['parent_lexicon'] as string; | ||
| const writer = writers.get(parentLexicon); | ||
| if (writer === undefined) { | ||
| continue; | ||
| } | ||
| writer.write(`${EJSON.stringify(doc, { relaxed: true })}\n`); | ||
| counts.set(parentLexicon, (counts.get(parentLexicon) ?? 0) + 1); | ||
| } | ||
| for (const [lexicon, writer] of writers) { | ||
| await writer.end(); | ||
| if ((counts.get(lexicon) ?? 0) === 0) { | ||
| // Same drift guard as the registry: a renamed parent_lexicon | ||
| // must not produce an empty snapshot that looks like success. | ||
| throw new Error(`no entries found for lexicon "${lexicon}"`); | ||
| } | ||
| progress( | ||
| `wrote ${JASTROW_LEXICONS.get(lexicon)} (${counts.get(lexicon)} entries)`, | ||
| ); | ||
| } | ||
| return counts; | ||
| } | ||
|
|
||
| async function main(): Promise<void> { | ||
| const cached = Bun.argv.includes('--cached'); | ||
| const progress = (msg: string): void => { | ||
| console.log(`[fetch] ${msg}`); | ||
| }; | ||
| await mkdir(CACHE_DIR, { recursive: true }); | ||
| await mkdir(OUT_DIR, { recursive: true }); | ||
|
|
||
| const dumpProvenance = await loadProvenance(cached, progress); | ||
| const registryPath = await emitRegistry(progress); | ||
| const counts = await emitEntries(progress); | ||
|
|
||
| const manifest = { | ||
| entryCounts: Object.fromEntries(counts), | ||
| fetchedAt: new Date().toISOString(), | ||
| outputs: await Promise.all( | ||
| [registryPath, ...JASTROW_LEXICONS.values()].map(async (path) => ({ | ||
| path, | ||
| sha256: await sha256(path), | ||
| })), | ||
| ), | ||
| source: { | ||
| url: DUMP_URL, | ||
| etag: dumpProvenance.etag, | ||
| lastModified: dumpProvenance.lastModified, | ||
| }, | ||
| }; | ||
| const manifestPath = `${OUT_DIR}/manifest.json`; | ||
| await Bun.write( | ||
| manifestPath, | ||
| `${JSON.stringify(manifest, undefined, '\t')}\n`, | ||
| ); | ||
| progress(`wrote ${manifestPath}`); | ||
| } | ||
|
|
||
| await main(); | ||
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.