mirror of
https://git.pumpin.online/funkpower/catena.git
synced 2026-10-23 03:07:13 +00:00
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.
171 lines
6.6 KiB
Rust
171 lines
6.6 KiB
Rust
//! A converted book, read the whole way through in one process.
|
|
//!
|
|
//! The commands are filters and a subprocess is usually the right answer, but
|
|
//! when the caller already holds the text there is nothing to serialise: the
|
|
//! same libraries the commands are built from take a `&str` and hand back the
|
|
//! records. This walks a file from "what even is it" to passages worth
|
|
//! embedding and the references at the back, without a pipe anywhere.
|
|
//!
|
|
//! cargo run -- book.md
|
|
//! cargo run # reads the sample below
|
|
//!
|
|
//! For a real book, convert one first — Gibbon's first volume is public
|
|
//! domain and has a few hundred pages of footnotes:
|
|
//!
|
|
//! curl -sL -o gibbon.epub https://www.gutenberg.org/cache/epub/731/pg731.epub
|
|
//! pandoc -f epub -t commonmark --wrap=none gibbon.epub -o gibbon.md
|
|
//! cargo run -- gibbon.md
|
|
|
|
use std::path::Path;
|
|
|
|
/// A page of a converted book: a heading, two passages and a short
|
|
/// bibliography, which is enough to show every stage doing something.
|
|
const SAMPLE: &str = "\
|
|
# III. The Second Estate
|
|
|
|
The obligation ran both ways, and contemporaries said so plainly whenever
|
|
they were asked.[^4] A lord who could not protect was a lord who could not
|
|
command, and the rolls record the consequences.
|
|
|
|
What the rolls do not say, and what has to be inferred, is how often the
|
|
obligation was actually tested. <!-- a converter's leftover -->
|
|
|
|
# Bibliography
|
|
|
|
Bloch, Marc. *Feudal Society*. Translated by L. A. Manyon. London: Routledge
|
|
and Kegan Paul, 1961.
|
|
|
|
Duby, Georges. *The Three Orders: Feudal Society Imagined*. Chicago:
|
|
University of Chicago Press, 1980.
|
|
|
|
Reynolds, Susan. *Fiefs and Vassals: The Medieval Evidence Reinterpreted*.
|
|
Oxford: Clarendon Press, 1994.
|
|
";
|
|
|
|
fn main() {
|
|
let (name, raw) = match std::env::args().nth(1) {
|
|
Some(path) => {
|
|
let bytes = std::fs::read(&path).unwrap_or_else(|why| {
|
|
eprintln!("catena-example: {path}: {why}");
|
|
std::process::exit(74);
|
|
});
|
|
(path, bytes)
|
|
}
|
|
None => ("sample.md".to_string(), SAMPLE.as_bytes().to_vec()),
|
|
};
|
|
|
|
// What the file actually is, whatever it is called. Worth asking first:
|
|
// a `.pdf` that is really a DjVu fails four stages later, in a way that
|
|
// looks like a bug in the converter.
|
|
let doc = booksniff::sniff(&raw.as_slice(), &name);
|
|
let stem = Path::new(&name).file_name().unwrap_or_default();
|
|
println!("{:?} is a {:?}, {} bytes", stem, doc.kind, doc.bytes);
|
|
for note in &doc.notes {
|
|
println!(" note: {note}");
|
|
}
|
|
|
|
// Converted markdown carries the converter's leftovers. Scrubbing before
|
|
// chunking means the passages are the book's words and not the tool's.
|
|
let markdown = String::from_utf8_lossy(&raw);
|
|
let clean = mdscrub::scrub(&markdown);
|
|
|
|
// The defaults are sized for a book: 512 tokens a chunk, which would put
|
|
// this whole sample in one. A page needs a page's budget.
|
|
let budgets = match std::env::args().nth(1) {
|
|
Some(_) => chunkmd::Budgets::default(),
|
|
None => chunkmd::Budgets {
|
|
target: 120,
|
|
overlap: 0,
|
|
min_tokens: 10,
|
|
..chunkmd::Budgets::default()
|
|
},
|
|
};
|
|
let chunks = chunkmd::chunk(&clean, &budgets);
|
|
println!("\n{} passage(s), the first few:", chunks.len());
|
|
for chunk in chunks.iter().take(6) {
|
|
let chapter = chunk.chapter.as_deref().unwrap_or("—");
|
|
let opening: String = chunk.text.chars().take(64).collect();
|
|
println!(
|
|
" [{}] {:?} {} tokens, under {chapter:?}\n {}…",
|
|
chunk.idx,
|
|
chunk.role,
|
|
chunk.tokens,
|
|
opening.replace('\n', " ")
|
|
);
|
|
}
|
|
|
|
// The references. A passage whose role is `bibliography` is one place
|
|
// they live; a page of notes at the foot of a chapter is the other, and
|
|
// it has no heading over it to find it by — which is why `chunkmd`
|
|
// flags those, so this does not have to guess.
|
|
let carrying: Vec<&corpus_core::Chunk> = chunks
|
|
.iter()
|
|
.filter(|chunk| {
|
|
chunk.role == corpus_core::Role::Bibliography
|
|
|| chunk
|
|
.flags
|
|
.iter()
|
|
.any(|flag| flag == corpus_core::chunk::flags::FOOTNOTE_CITATIONS)
|
|
})
|
|
.collect();
|
|
|
|
let references: Vec<citefind::Reference> = carrying
|
|
.iter()
|
|
.flat_map(|chunk| citefind::read(&chunk.text))
|
|
.filter(|found| !found.continuation)
|
|
.collect();
|
|
|
|
println!(
|
|
"\n{} passage(s) carry citation material, giving {} reference(s):",
|
|
carrying.len(),
|
|
references.len()
|
|
);
|
|
// The ones that named something, which is not all of them: a book whose
|
|
// notes cite in the classical short form — `Tacit. Annal. xiii. 5.` —
|
|
// gives mostly passages rather than works, and the record says as much
|
|
// rather than inventing a title to fill the field.
|
|
let with_work = references
|
|
.iter()
|
|
.filter(|found| found.title.is_some())
|
|
.count();
|
|
let named: Vec<&citefind::Reference> = references
|
|
.iter()
|
|
.filter(|found| !found.authors.is_empty())
|
|
.collect();
|
|
println!(
|
|
"{with_work} name a work and {} name an author; those:",
|
|
named.len()
|
|
);
|
|
|
|
for found in named.iter().take(6) {
|
|
let who = match found.authors.is_empty() {
|
|
true => "—".to_string(),
|
|
false => found.authors.join("; "),
|
|
};
|
|
println!(
|
|
" {who}\n {} ({}) — {:?}",
|
|
found.title.as_deref().unwrap_or("—"),
|
|
found
|
|
.year
|
|
.map(|year| year.to_string())
|
|
.unwrap_or_else(|| "no year".into()),
|
|
found.kind
|
|
);
|
|
if !found.unsettled.is_empty() {
|
|
println!(" could not read: {}", found.unsettled.join(", "));
|
|
}
|
|
}
|
|
|
|
// Worth saying plainly, because the numbers above look poor on a book
|
|
// like Gibbon and the reason is not the parser having failed quietly.
|
|
// His notes cite in the classical short form — `Tacit. Annal. xiii. 5.`
|
|
// — which names a passage rather than a work, so there is no title and
|
|
// no year to read and the record says which fields it could not fill
|
|
// rather than inventing them. `citation_prompt` and `merge_citation_reply`
|
|
// are there for a caller that wants a model to finish the job.
|
|
if references.len() > 20 && named.len() * 10 < references.len() {
|
|
println!("\n most of these are classical short cites — a passage");
|
|
println!(" rather than a work — there is nothing more to read in them.");
|
|
}
|
|
}
|