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" name = "catena"
version = "0.1.0" version = "0.1.0"
dependencies = [ dependencies = [
"bibclean",
"booksniff", "booksniff",
"chunkmd", "chunkmd",
"citefind", "citefind",
+1
View File
@@ -29,6 +29,7 @@ crate-type = ["cdylib", "rlib"]
[dependencies] [dependencies]
booksniff.workspace = true booksniff.workspace = true
bibclean.workspace = true
chunkmd.workspace = true chunkmd.workspace = true
citefind.workspace = true citefind.workspace = true
corpus-core.workspace = true corpus-core.workspace = true
+1
View File
@@ -80,6 +80,7 @@ except ValueError as why:
| `parse_identifier` | [`idnorm`](../idnorm/README.md) | | `parse_identifier` | [`idnorm`](../idnorm/README.md) |
| `find_citations` | [`citefind`](../citefind/README.md) | | `find_citations` | [`citefind`](../citefind/README.md) |
| `looks_like_footnote_citations` | whether a body chunk carries notes worth reading | | `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 | | `citation_prompt` | the question `citefind --prompt` writes |
| `merge_citation_reply` | what `citefind --merge` does with the answer | | `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. */ * one — an answer, not an error. */
char *catena_parse_identifier(const char *text); 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. /* Whether a chunk of body text carries footnote or endnote citations.
* Returns 1 for yes, 0 for no, -1 on error. */ * Returns 1 for yes, 0 for no, -1 on error. */
int catena_looks_like_footnote_citations(const char *text); int catena_looks_like_footnote_citations(const char *text);
+5 -2
View File
@@ -4,9 +4,9 @@
import { import {
chunkMarkdown, scrub, countTokens, parseSubtitles, packCues, chunkMarkdown, scrub, countTokens, parseSubtitles, packCues,
splitCollection, fuseHits, identify, seriesOf, parseIdentifier, segmentCues, cueWindows, 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 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"; } from "./dist/web/catena.js";
const chunks: Chunk[] = chunkMarkdown("# One\n\nbody\n", 512, 64, 50, 768); 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; const volume: number | undefined = proposal?.volume;
seriesOf("Aquinas (Companion)", "Cambridge"); 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 references: Reference[] = findCitations("Smith, John. *A Title*. Oxford: Clarendon Press, 1998.");
const unsettled: string[] = references[0].unsettled; const unsettled: string[] = references[0].unsettled;
const ask: string | undefined = citationPrompt(references); const ask: string | undefined = citationPrompt(references);
@@ -28,6 +28,7 @@ from ._catena import (
segment_cues, segment_cues,
series_of, series_of,
split_collection, split_collection,
suggest_author,
) )
__all__ = [ __all__ = [
@@ -48,4 +49,5 @@ __all__ = [
"segment_cues", "segment_cues",
"series_of", "series_of",
"split_collection", "split_collection",
"suggest_author",
] ]
@@ -39,6 +39,9 @@ def fuse_hits(
def identify(name: str, data: bytes) -> dict[str, Any]: ... def identify(name: str, data: bytes) -> dict[str, Any]: ...
def find_citations(text: str) -> list[dict[str, Any]]: ... def find_citations(text: str) -> list[dict[str, Any]]: ...
def looks_like_footnote_citations(text: str) -> bool: ... 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 citation_prompt(references: Iterable[Mapping[str, Any]]) -> str | None: ...
def merge_citation_reply( def merge_citation_reply(
references: Iterable[Mapping[str, Any]], reply: str 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)) 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. /// Whether a chunk of body text carries footnote or endnote citations.
/// Returns 1 for yes, 0 for no, -1 on error. /// 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) 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. /// Whether a chunk of body text carries footnote or endnote citations.
/// ///
/// The structural signal a converter leaves on EPUB3 note definitions first; /// 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")); 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] #[test]
fn the_footnote_signal_agrees_with_the_library_underneath() { fn the_footnote_signal_agrees_with_the_library_underneath() {
const NOTES: &str = "1. John Smith, *A Title* (Oxford: Clarendon Press, 1998), 183.\n\ 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)) 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. /// Whether a chunk of body text carries footnote or endnote citations.
#[pyfunction] #[pyfunction]
fn looks_like_footnote_citations(text: &str) -> bool { 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!(identify, module)?)?;
module.add_function(wrap_pyfunction!(find_citations, module)?)?; module.add_function(wrap_pyfunction!(find_citations, module)?)?;
module.add_function(wrap_pyfunction!(looks_like_footnote_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!(citation_prompt, module)?)?;
module.add_function(wrap_pyfunction!(merge_citation_reply, module)?)?; module.add_function(wrap_pyfunction!(merge_citation_reply, module)?)?;
module.add_function(wrap_pyfunction!(series_of, module)?)?; module.add_function(wrap_pyfunction!(series_of, module)?)?;
+23
View File
@@ -97,6 +97,17 @@ export interface Identifier {
url?: string; 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. */ /** One reference out of a bibliography or a page of notes. */
export interface Reference { export interface Reference {
v: number; v: number;
@@ -305,6 +316,17 @@ pub fn identify(name: &str, data: &[u8]) -> Result<JsValue, JsValue> {
to_js(&crate::identify(name, data)) 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. /// Whether a chunk of body text carries footnote or endnote citations.
#[wasm_bindgen(js_name = looksLikeFootnoteCitations)] #[wasm_bindgen(js_name = looksLikeFootnoteCitations)]
pub fn looks_like_footnote_citations(text: &str) -> bool { 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 splitCollection(markdown: string): Section[];
export function fuseHits(hits: Hit[], k?: number, weights?: Record<string, number>, limit?: number): Hit[]; export function fuseHits(hits: Hit[], k?: number, weights?: Record<string, number>, limit?: number): Hit[];
export function identify(name: string, data: Uint8Array): Doc; 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 findCitations(text: string): Reference[];
export function citationPrompt(references: Reference[]): string | undefined; export function citationPrompt(references: Reference[]): string | undefined;
export function mergeCitationReply(references: Reference[], reply: string): Reference[]; export function mergeCitationReply(references: Reference[], reply: string): Reference[];