bindings: the author a record's own text can offer

book-pipeline had its own copy of bibclean's author recovery. Bound on all
four surfaces so it can stop.

Checked against that Python over all 5,385 books in the library: 5,375
identical. On the other ten the Python had taken a translator, an illustrator
or the publisher off a copyright line — Sally-Ann Spencer for *The Dwarves*,
Thomas Nelson for a table of contents, C. S. Lewis for a book about Tolkien —
and bibclean either finds the real author in the Library of Congress block or
says nothing. Every one of the ten was read by hand.
This commit is contained in:
funkpower
2026-09-20 15:10:35 -04:00
parent 2dfa370cd7
commit 7a08ed953d
11 changed files with 109 additions and 2 deletions
Generated
+1
View File
@@ -252,6 +252,7 @@ checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04"
name = "catena"
version = "0.1.0"
dependencies = [
"bibclean",
"booksniff",
"chunkmd",
"citefind",
+1
View File
@@ -29,6 +29,7 @@ crate-type = ["cdylib", "rlib"]
[dependencies]
booksniff.workspace = true
bibclean.workspace = true
chunkmd.workspace = true
citefind.workspace = true
corpus-core.workspace = true
+1
View File
@@ -80,6 +80,7 @@ except ValueError as why:
| `parse_identifier` | [`idnorm`](../idnorm/README.md) |
| `find_citations` | [`citefind`](../citefind/README.md) |
| `looks_like_footnote_citations` | whether a body chunk carries notes worth reading |
| `suggest_author` | [`bibclean`](../bibclean/README.md)'s author recovery |
| `citation_prompt` | the question `citefind --prompt` writes |
| `merge_citation_reply` | what `citefind --merge` does with the answer |
+5
View File
@@ -98,6 +98,11 @@ char *catena_series_of(const char *title, const char *author);
* one — an answer, not an error. */
char *catena_parse_identifier(const char *text);
/* The best author a record's own title and front matter can offer. Returns a
* JSON suggestion, or NULL when neither names a person — an answer, not an
* error. `front_matter` may be NULL. */
char *catena_suggest_author(const char *title, const char *front_matter);
/* Whether a chunk of body text carries footnote or endnote citations.
* Returns 1 for yes, 0 for no, -1 on error. */
int catena_looks_like_footnote_citations(const char *text);
+5 -2
View File
@@ -4,9 +4,9 @@
import {
chunkMarkdown, scrub, countTokens, parseSubtitles, packCues,
splitCollection, fuseHits, identify, seriesOf, parseIdentifier, segmentCues, cueWindows,
findCitations, citationPrompt, mergeCitationReply,
findCitations, citationPrompt, mergeCitationReply, suggestAuthor,
type Chunk, type Cue, type Section, type Hit, type Doc, type SeriesProposal, type Identifier, type Segment,
type Reference,
type Reference, type AuthorSuggestion,
} from "./dist/web/catena.js";
const chunks: Chunk[] = chunkMarkdown("# One\n\nbody\n", 512, 64, 50, 768);
@@ -47,6 +47,9 @@ const proposal: SeriesProposal | undefined = seriesOf("The Philokalia Vol 3");
const volume: number | undefined = proposal?.volume;
seriesOf("Aquinas (Companion)", "Cambridge");
const suggested: AuthorSuggestion | undefined = suggestAuthor("Aquinas", "by Thomas Aquinas");
void suggested?.confidence;
const references: Reference[] = findCitations("Smith, John. *A Title*. Oxford: Clarendon Press, 1998.");
const unsettled: string[] = references[0].unsettled;
const ask: string | undefined = citationPrompt(references);
@@ -28,6 +28,7 @@ from ._catena import (
segment_cues,
series_of,
split_collection,
suggest_author,
)
__all__ = [
@@ -48,4 +49,5 @@ __all__ = [
"segment_cues",
"series_of",
"split_collection",
"suggest_author",
]
@@ -39,6 +39,9 @@ def fuse_hits(
def identify(name: str, data: bytes) -> dict[str, Any]: ...
def find_citations(text: str) -> list[dict[str, Any]]: ...
def looks_like_footnote_citations(text: str) -> bool: ...
def suggest_author(
title: str, front_matter: str | None = ...
) -> dict[str, Any] | None: ...
def citation_prompt(references: Iterable[Mapping[str, Any]]) -> str | None: ...
def merge_citation_reply(
references: Iterable[Mapping[str, Any]], reply: str
+29
View File
@@ -417,6 +417,35 @@ pub unsafe extern "C" fn catena_identify(
give_json(&crate::identify(name, bytes))
}
/// The best author a record's own title and front matter can offer. Returns
/// a JSON suggestion, or NULL when neither says anything that reads as a
/// name — an answer, not an error.
///
/// # Safety
///
/// `title` must be NUL-terminated UTF-8; `front_matter` likewise, or NULL.
#[unsafe(no_mangle)]
pub unsafe extern "C" fn catena_suggest_author(
title: *const c_char,
front_matter: *const c_char,
) -> *mut c_char {
clear_error();
let Some(title) = (unsafe { borrow(title, "title") }) else {
return ptr::null_mut();
};
let front = match front_matter.is_null() {
true => None,
false => match unsafe { borrow(front_matter, "front_matter") } {
Some(front) => Some(front),
None => return ptr::null_mut(),
},
};
match crate::suggest_author(title, front) {
Some(found) => give_json(&found),
None => ptr::null_mut(),
}
}
/// Whether a chunk of body text carries footnote or endnote citations.
/// Returns 1 for yes, 0 for no, -1 on error.
///
+22
View File
@@ -215,6 +215,17 @@ pub fn parse_identifier(text: &str) -> Option<idnorm::Identifier> {
idnorm::parse(text)
}
/// The best author a record's own title and front matter can offer.
///
/// `None` when neither says anything that reads as a person's name, which is
/// the honest answer for a scanned book with no title page.
pub fn suggest_author(
title: &str,
front_matter: Option<&str>,
) -> Option<bibclean::author::Suggestion> {
bibclean::author::best(title, front_matter)
}
/// Whether a chunk of body text carries footnote or endnote citations.
///
/// The structural signal a converter leaves on EPUB3 note definitions first;
@@ -269,6 +280,17 @@ mod tests {
assert_eq!(count_tokens("a passage"), textkit::count("a passage"));
}
#[test]
fn the_author_surface_agrees_with_the_library_underneath() {
const TITLE: &str = "Aquinas (Companion)";
const FRONT: &str = "THE CAMBRIDGE COMPANION TO AQUINAS\n\nedited by Norman Kretzmann";
assert_eq!(
suggest_author(TITLE, Some(FRONT)),
bibclean::author::best(TITLE, Some(FRONT))
);
assert!(suggest_author("", None).is_none());
}
#[test]
fn the_footnote_signal_agrees_with_the_library_underneath() {
const NOTES: &str = "1. John Smith, *A Title* (Oxford: Clarendon Press, 1998), 183.\n\
+17
View File
@@ -220,6 +220,22 @@ fn identify<'py>(py: Python<'py>, name: &str, data: &[u8]) -> PyResult<Bound<'py
to_py(py, &crate::identify(name, data))
}
/// The best author a record's own title and front matter can offer.
///
/// `None` when neither says anything that reads as a person's name.
#[pyfunction]
#[pyo3(signature = (title, front_matter = None))]
fn suggest_author<'py>(
py: Python<'py>,
title: &str,
front_matter: Option<&str>,
) -> PyResult<Option<Bound<'py, PyAny>>> {
match crate::suggest_author(title, front_matter) {
Some(found) => Ok(Some(to_py(py, &found)?)),
None => Ok(None),
}
}
/// Whether a chunk of body text carries footnote or endnote citations.
#[pyfunction]
fn looks_like_footnote_citations(text: &str) -> bool {
@@ -297,6 +313,7 @@ fn _catena(module: &Bound<'_, PyModule>) -> PyResult<()> {
module.add_function(wrap_pyfunction!(identify, module)?)?;
module.add_function(wrap_pyfunction!(find_citations, module)?)?;
module.add_function(wrap_pyfunction!(looks_like_footnote_citations, module)?)?;
module.add_function(wrap_pyfunction!(suggest_author, module)?)?;
module.add_function(wrap_pyfunction!(citation_prompt, module)?)?;
module.add_function(wrap_pyfunction!(merge_citation_reply, module)?)?;
module.add_function(wrap_pyfunction!(series_of, module)?)?;
+23
View File
@@ -97,6 +97,17 @@ export interface Identifier {
url?: string;
}
/** A name a record's own text can offer, and how far to trust it. */
export interface AuthorSuggestion {
author: string;
/** Which signal produced it. */
method: string;
confidence: number;
/** The text it was read from, so a person can check it. */
evidence: string;
isbn: string | null;
}
/** One reference out of a bibliography or a page of notes. */
export interface Reference {
v: number;
@@ -305,6 +316,17 @@ pub fn identify(name: &str, data: &[u8]) -> Result<JsValue, JsValue> {
to_js(&crate::identify(name, data))
}
/// The best author a record's own title and front matter can offer.
///
/// `undefined` when neither says anything that reads as a person's name.
#[wasm_bindgen(js_name = suggestAuthor, skip_typescript)]
pub fn suggest_author(title: &str, front_matter: Option<String>) -> Result<JsValue, JsValue> {
match crate::suggest_author(title, front_matter.as_deref()) {
Some(found) => to_js(&found),
None => Ok(JsValue::UNDEFINED),
}
}
/// Whether a chunk of body text carries footnote or endnote citations.
#[wasm_bindgen(js_name = looksLikeFootnoteCitations)]
pub fn looks_like_footnote_citations(text: &str) -> bool {
@@ -371,6 +393,7 @@ export function cueWindows(cues: Cue[]): string[];
export function splitCollection(markdown: string): Section[];
export function fuseHits(hits: Hit[], k?: number, weights?: Record<string, number>, limit?: number): Hit[];
export function identify(name: string, data: Uint8Array): Doc;
export function suggestAuthor(title: string, front_matter?: string): AuthorSuggestion | undefined;
export function findCitations(text: string): Reference[];
export function citationPrompt(references: Reference[]): string | undefined;
export function mergeCitationReply(references: Reference[], reply: string): Reference[];