Files
catena/examples/shell/01-book-to-passages.sh
funkpower 58be244478 examples: programs that run, in four languages
Eight of them, each doing something a pipeline actually has to do, and each
starting from something anyone can fetch — a public domain book from Project
Gutenberg, a NASA broadcast, or a sample carried in the file.

Shell, because the tools are filters first: a book to passages and what its
notes cite; a recording to chapter marks, citable passages and the one
ffmpeg call that makes it play on a phone; passages into pgvector and a
search that asks twice and reconciles.

Rust, for the case where the caller already holds the text and a subprocess
would be serialising for nothing. Python, for the shape a pipeline stage
takes once the reading is a library call. TypeScript, for the app side —
chapter marks and deep links, and fusing two rankings in process.

Three of them end with what re-ranking means, because it is two different
things under one name: fusing ranked lists, which is what `rrf` does and
needs no model, and a cross-encoder scoring query and passage together,
which is accurate, slow, and runs over what retrieval already found.
2026-09-21 08:12:13 -04:00

94 lines
3.6 KiB
Bash
Executable File

#!/bin/sh
# A book file to passages worth embedding, and what its notes cite.
#
# The starting point is Gibbon's first volume from Project Gutenberg: public
# domain, a real table of contents, and a few hundred pages of footnotes.
#
# examples/shell/01-book-to-passages.sh [work-dir]
#
# Needs pandoc and jq, and the catena commands on PATH.
set -eu
work="${1:-./work}"
mkdir -p "$work"
book="$work/gibbon.epub"
if [ ! -s "$book" ]; then
echo "fetching Gibbon vol. 1 from Project Gutenberg"
curl -sSL --max-time 120 -o "$book" \
"https://www.gutenberg.org/cache/epub/731/pg731.epub"
fi
# What the file actually is. Extensions lie, and a `.epub` that is really a
# zip of page scans fails four stages later, in a way that looks like a bug
# in the converter.
echo
echo "== what it is"
booksniff "$book" | jq -r '" \(.kind), \(.bytes) bytes"'
# A PDF gets one more question first: exit 0 from pdftriage means the text
# layer is not worth reading and the file has to be OCRed before any of this.
if [ "$(booksniff "$book" | jq -r .kind)" = "pdf" ] && pdftriage --quiet "$book"; then
echo " the text layer is garbled; OCR it before going on"
exit 0
fi
# What the file already says about itself — the publisher's own metadata,
# which beats anything guessed from a filename, and costs no lookup.
echo
echo "== what it says about itself"
bookmeta "$book" | jq -r '" \(.title)\n \(.authors | join("; "))\n \(.language // "?") · \(.date // "no date")"'
# The chapters the book declares, rather than any inferred from the text.
echo
echo "== the contents it declares"
tocfind "$book" --outline | sed -n '6,10p' | sed 's/^/ /'
echo " … $(tocfind "$book" | wc -l | tr -d ' ') entries"
# Convert, scrub, chunk. `commonmark` rather than `commonmark_x`, because the
# extended flavour writes anchors as bracketed spans and they end up in the
# passages. The scrub is what takes out the rest of the converter's leavings.
echo
echo "== passages"
pandoc -f epub -t commonmark --wrap=none "$book" -o "$work/book.md"
mdscrub < "$work/book.md" > "$work/clean.md"
chunkmd < "$work/clean.md" > "$work/chunks.ndjson"
jq -s -r '
" \(length) passages over \([.[].chapter] | unique | length) chapters",
" roles: \([.[].role] | group_by(.) | map("\(length) \(.[0])") | join(", "))",
" \([.[] | select(.flags | index("footnote_citations"))] | length) carry citation material"
' "$work/chunks.ndjson"
echo
echo " a passage, with where it came from:"
jq -s -r '[.[] | select(.role == "body" and .chapter != null)][40]
| " [\(.idx)] \(.tokens) tokens · \(.chapter)\n \(.text[0:150])…"' \
"$work/chunks.ndjson"
# The notes. A passage flagged `footnote_citations` is a page of them, and
# each is read on its own — the flag says which passages are worth the ask,
# which is the whole reason chunkmd sets it.
echo
echo "== what the notes cite"
: > "$work/references.ndjson"
jq -c 'select(.flags | index("footnote_citations")) | .text' "$work/chunks.ndjson" \
| while IFS= read -r line; do
printf '%s' "$line" | jq -r . | citefind >> "$work/references.ndjson" || true
done
jq -s -r '
" \(length) entries read",
" \([.[] | select(.title)] | length) name a work, \([.[] | select(.authors | length > 0)] | length) name an author",
" \([.[] | select(.unsettled | length > 0)] | length) came back with something unread"
' "$work/references.ndjson"
echo
echo " Gibbon cites in the classical short form — \"Tacit. Annal. xiii. 5.\" —"
echo " so most entries name the passage rather than a book, and say so:"
jq -r 'select(.unsettled | length > 0) | " \(.text[0:58]) → unread: \(.unsettled | join(", "))"' \
"$work/references.ndjson" | head -4
echo
echo "written to $work/"