mirror of
https://git.pumpin.online/funkpower/catena.git
synced 2026-10-23 11:17:13 +00:00
bibclean is "catalogue records audited and repaired, with a reason for every change", and book-pipeline's metadata module was a second implementation of the same thing — ported from it in the first place. Twelve functions bound: the audit, its severity, the two commonest faults, the three repairs that need no lookup, ISBNs checked and found, and whether two titles name the same work. Checked against that Python across all 5,385 books in the library — every title, every author, every ISBN, every filename, and four thousand title pairs. Identical on all of it but one: `The Soul After Death: Contemporary 'After-Death' Experiences`, where the Python lowercased a word inside the quotation marks and bibclean leaves it alone.
436 lines
15 KiB
Rust
436 lines
15 KiB
Rust
//! Python bindings.
|
|
//!
|
|
//! These hand back the record shapes as dictionaries — the same JSON every
|
|
//! tool writes to stdout, spelled the way Python spells things. A stage that
|
|
//! was reading NDJSON from a subprocess can call the function instead and get
|
|
//! the dictionaries it was already parsing.
|
|
//!
|
|
//! Built only under the `python` feature, which maturin turns on. See
|
|
//! Cargo.toml for why it cannot be on by default.
|
|
|
|
use pyo3::exceptions::PyValueError;
|
|
use pyo3::prelude::*;
|
|
use pyo3::types::{PyDict, PyList};
|
|
|
|
use crate::{ChunkBudgets, CueBudgets};
|
|
|
|
/// A record to a Python dictionary, by way of its JSON.
|
|
///
|
|
/// Through JSON rather than field by field so that the two forms cannot drift
|
|
/// apart: if a record gains a field, it appears here without anybody
|
|
/// remembering to add it.
|
|
fn to_py<'py, T: serde::Serialize>(py: Python<'py>, value: &T) -> PyResult<Bound<'py, PyAny>> {
|
|
let json = serde_json::to_value(value)
|
|
.map_err(|e| PyValueError::new_err(format!("record does not serialise: {e}")))?;
|
|
json_to_py(py, &json)
|
|
}
|
|
|
|
/// Every record in a list, likewise.
|
|
fn list_to_py<'py, T: serde::Serialize>(
|
|
py: Python<'py>,
|
|
values: &[T],
|
|
) -> PyResult<Bound<'py, PyList>> {
|
|
let mut out = Vec::with_capacity(values.len());
|
|
for value in values {
|
|
out.push(to_py(py, value)?);
|
|
}
|
|
PyList::new(py, out)
|
|
}
|
|
|
|
fn json_to_py<'py>(py: Python<'py>, value: &serde_json::Value) -> PyResult<Bound<'py, PyAny>> {
|
|
Ok(match value {
|
|
serde_json::Value::Null => py.None().into_bound(py),
|
|
serde_json::Value::Bool(b) => b.into_pyobject(py)?.to_owned().into_any(),
|
|
serde_json::Value::Number(n) => match (n.as_u64(), n.as_i64(), n.as_f64()) {
|
|
(Some(u), _, _) => u.into_pyobject(py)?.into_any(),
|
|
(_, Some(i), _) => i.into_pyobject(py)?.into_any(),
|
|
(_, _, Some(f)) => f.into_pyobject(py)?.into_any(),
|
|
// JSON cannot hold a number this is not one of, so this is
|
|
// unreachable rather than a case worth inventing a value for.
|
|
_ => return Err(PyValueError::new_err(format!("{n} is not a number"))),
|
|
},
|
|
serde_json::Value::String(s) => s.into_pyobject(py)?.into_any(),
|
|
serde_json::Value::Array(items) => {
|
|
let mut out = Vec::with_capacity(items.len());
|
|
for item in items {
|
|
out.push(json_to_py(py, item)?);
|
|
}
|
|
PyList::new(py, out)?.into_any()
|
|
}
|
|
serde_json::Value::Object(fields) => {
|
|
let dict = PyDict::new(py);
|
|
for (key, value) in fields {
|
|
dict.set_item(key, json_to_py(py, value)?)?;
|
|
}
|
|
dict.into_any()
|
|
}
|
|
})
|
|
}
|
|
|
|
/// A Python dictionary back to a record, the same way round.
|
|
fn from_py<T: serde::de::DeserializeOwned>(value: &Bound<'_, PyAny>) -> PyResult<T> {
|
|
let json: String = value
|
|
.py()
|
|
.import("json")?
|
|
.call_method1("dumps", (value,))?
|
|
.extract()?;
|
|
serde_json::from_str(&json)
|
|
.map_err(|e| PyValueError::new_err(format!("not a record of the expected shape: {e}")))
|
|
}
|
|
|
|
/// Every dictionary in a sequence.
|
|
fn from_py_list<T: serde::de::DeserializeOwned>(values: &Bound<'_, PyAny>) -> PyResult<Vec<T>> {
|
|
let mut out = Vec::new();
|
|
for value in values.try_iter()? {
|
|
out.push(from_py(&value?)?);
|
|
}
|
|
Ok(out)
|
|
}
|
|
|
|
/// Cut markdown into retrievable passages, one dictionary each.
|
|
#[pyfunction]
|
|
#[pyo3(signature = (markdown, *, target = None, overlap = None, min_tokens = None, hard_max = None))]
|
|
fn chunk_markdown<'py>(
|
|
py: Python<'py>,
|
|
markdown: &str,
|
|
target: Option<usize>,
|
|
overlap: Option<usize>,
|
|
min_tokens: Option<usize>,
|
|
hard_max: Option<usize>,
|
|
) -> PyResult<Bound<'py, PyList>> {
|
|
let default = ChunkBudgets::default();
|
|
let budgets = ChunkBudgets {
|
|
target: target.unwrap_or(default.target),
|
|
overlap: overlap.unwrap_or(default.overlap),
|
|
min_tokens: min_tokens.unwrap_or(default.min_tokens),
|
|
hard_max: hard_max.unwrap_or(default.hard_max),
|
|
};
|
|
list_to_py(py, &crate::chunk_markdown(markdown, budgets))
|
|
}
|
|
|
|
/// Clean converted markdown.
|
|
#[pyfunction]
|
|
#[pyo3(signature = (markdown, *, pages = false))]
|
|
fn scrub(markdown: &str, pages: bool) -> String {
|
|
match pages {
|
|
true => crate::scrub_markdown_pages(markdown),
|
|
false => crate::scrub_markdown(markdown),
|
|
}
|
|
}
|
|
|
|
/// How many tokens a string is.
|
|
#[pyfunction]
|
|
fn count_tokens(text: &str) -> usize {
|
|
crate::count_tokens(text)
|
|
}
|
|
|
|
/// Read any of the subtitle formats into cues.
|
|
#[pyfunction]
|
|
#[pyo3(signature = (content, *, keep_repeats = false))]
|
|
fn parse_subtitles<'py>(
|
|
py: Python<'py>,
|
|
content: &str,
|
|
keep_repeats: bool,
|
|
) -> PyResult<Bound<'py, PyList>> {
|
|
let cues = crate::parse_subtitles(content);
|
|
let cues = match keep_repeats {
|
|
true => cues,
|
|
false => crate::merge_repeats(&cues),
|
|
};
|
|
list_to_py(py, &cues)
|
|
}
|
|
|
|
/// Pack cues into passages worth embedding.
|
|
#[pyfunction]
|
|
#[pyo3(signature = (cues, *, seconds = None, max_tokens = None))]
|
|
fn pack_cues<'py>(
|
|
py: Python<'py>,
|
|
cues: &Bound<'py, PyAny>,
|
|
seconds: Option<f64>,
|
|
max_tokens: Option<usize>,
|
|
) -> PyResult<Bound<'py, PyList>> {
|
|
let default = CueBudgets::default();
|
|
let budgets = CueBudgets {
|
|
seconds: seconds.unwrap_or(default.seconds),
|
|
max_tokens: max_tokens.unwrap_or(default.max_tokens),
|
|
};
|
|
let cues: Vec<cuekit::Cue> = from_py_list(cues)?;
|
|
list_to_py(py, &crate::pack_cues(&cues, budgets))
|
|
}
|
|
|
|
/// Cut a timed transcript into topic chapters.
|
|
///
|
|
/// `duration` is the recording's length in seconds; omit it and the last cue
|
|
/// decides.
|
|
/// `embeddings` is one vector per window, in the order `cue_windows` gives
|
|
/// them; without it the windows are compared by the words they repeat.
|
|
#[pyfunction]
|
|
#[pyo3(signature = (cues, *, duration = None, embeddings = None))]
|
|
fn segment_cues<'py>(
|
|
py: Python<'py>,
|
|
cues: &Bound<'py, PyAny>,
|
|
duration: Option<f64>,
|
|
embeddings: Option<Vec<Vec<f64>>>,
|
|
) -> PyResult<Bound<'py, PyList>> {
|
|
let cues: Vec<cuekit::Cue> = from_py_list(cues)?;
|
|
list_to_py(
|
|
py,
|
|
&crate::segment_cues_with(&cues, duration.unwrap_or(0.0), embeddings.as_deref()),
|
|
)
|
|
}
|
|
|
|
/// The text of each window a transcript is measured in, to embed.
|
|
#[pyfunction]
|
|
fn cue_windows(cues: &Bound<'_, PyAny>) -> PyResult<Vec<String>> {
|
|
let cues: Vec<cuekit::Cue> = from_py_list(cues)?;
|
|
Ok(crate::cue_windows(&cues))
|
|
}
|
|
|
|
/// Split a collection into the works inside it.
|
|
///
|
|
/// Raises `ValueError` when the document is one work rather than a
|
|
/// collection, with the reason each candidate was rejected.
|
|
#[pyfunction]
|
|
fn split_collection<'py>(py: Python<'py>, markdown: &str) -> PyResult<Bound<'py, PyList>> {
|
|
match crate::split_collection(markdown) {
|
|
Ok(sections) => list_to_py(py, §ions),
|
|
Err(why) => Err(PyValueError::new_err(why)),
|
|
}
|
|
}
|
|
|
|
/// Fuse ranked lists into one.
|
|
#[pyfunction]
|
|
#[pyo3(signature = (hits, *, k = None, weights = None, limit = None))]
|
|
fn fuse_hits<'py>(
|
|
py: Python<'py>,
|
|
hits: &Bound<'py, PyAny>,
|
|
k: Option<f64>,
|
|
weights: Option<std::collections::HashMap<String, f64>>,
|
|
limit: Option<usize>,
|
|
) -> PyResult<Bound<'py, PyList>> {
|
|
let hits: Vec<corpus_core::Hit> = from_py_list(hits)?;
|
|
let weights: Vec<(String, f64)> = weights.unwrap_or_default().into_iter().collect();
|
|
let fused = crate::fuse_hits(&hits, k.unwrap_or(rrf::K), &weights, limit);
|
|
list_to_py(py, &fused)
|
|
}
|
|
|
|
/// Say what a file is from its bytes and its name.
|
|
#[pyfunction]
|
|
fn identify<'py>(py: Python<'py>, name: &str, data: &[u8]) -> PyResult<Bound<'py, PyAny>> {
|
|
to_py(py, &crate::identify(name, data))
|
|
}
|
|
|
|
/// A catalogue record audited: what is wrong with it, and the repairs that
|
|
/// need no lookup.
|
|
#[pyfunction]
|
|
fn clean_record<'py>(py: Python<'py>, book: &Bound<'py, PyAny>) -> PyResult<Bound<'py, PyAny>> {
|
|
to_py(py, &crate::clean_record(&from_py(book)?))
|
|
}
|
|
|
|
/// What is wrong with a record, named.
|
|
#[pyfunction]
|
|
#[pyo3(signature = (title = None, author = None, isbn = None))]
|
|
fn record_problems(title: Option<&str>, author: Option<&str>, isbn: Option<&str>) -> Vec<String> {
|
|
crate::record_problems(title, author, isbn)
|
|
}
|
|
|
|
/// How much attention a set of reasons deserves, worst first.
|
|
#[pyfunction]
|
|
fn record_severity(reasons: Vec<String>) -> u32 {
|
|
crate::record_severity(&reasons)
|
|
}
|
|
|
|
/// Whether the title has been copied into the author field.
|
|
#[pyfunction]
|
|
#[pyo3(signature = (title = None, author = None))]
|
|
fn title_in_author(title: Option<&str>, author: Option<&str>) -> bool {
|
|
crate::title_in_author(title, author)
|
|
}
|
|
|
|
/// Whether an author field names a person rather than a publisher.
|
|
#[pyfunction]
|
|
fn looks_like_person(author: &str) -> bool {
|
|
crate::looks_like_person(author)
|
|
}
|
|
|
|
/// Whether a title is set in capitals throughout.
|
|
#[pyfunction]
|
|
fn is_shouting(title: &str) -> bool {
|
|
crate::is_shouting(title)
|
|
}
|
|
|
|
/// A title in title case, leaving the small words small.
|
|
#[pyfunction]
|
|
fn title_case(title: &str) -> String {
|
|
crate::title_case(title)
|
|
}
|
|
|
|
/// A title with the underscores a filename left in it taken out.
|
|
#[pyfunction]
|
|
fn clean_underscores(title: &str) -> String {
|
|
crate::clean_underscores(title)
|
|
}
|
|
|
|
/// Whether a string is a well-formed ISBN, check digit and all.
|
|
#[pyfunction]
|
|
fn valid_isbn(text: &str) -> bool {
|
|
crate::valid_isbn(text)
|
|
}
|
|
|
|
/// An ISBN found in a run of text.
|
|
#[pyfunction]
|
|
fn isbn_from_text(text: &str) -> Option<String> {
|
|
crate::isbn_from_text(text)
|
|
}
|
|
|
|
/// An ISBN found in a filename.
|
|
#[pyfunction]
|
|
fn isbn_from_filename(name: &str) -> Option<String> {
|
|
crate::isbn_from_filename(name)
|
|
}
|
|
|
|
/// Whether two titles name the same work.
|
|
#[pyfunction]
|
|
fn same_work(ours: &str, theirs: &str) -> bool {
|
|
crate::same_work(ours, theirs)
|
|
}
|
|
|
|
/// The subject a heading means under a scheme, or `None`.
|
|
#[pyfunction]
|
|
fn canonical_subject(scheme: &Bound<'_, PyAny>, name: &str) -> PyResult<Option<String>> {
|
|
Ok(crate::canonical_subject(&from_py(scheme)?, name))
|
|
}
|
|
|
|
/// The subjects a book should carry under a scheme.
|
|
#[pyfunction]
|
|
#[pyo3(signature = (scheme, subjects, title = None))]
|
|
fn compose_subjects(
|
|
scheme: &Bound<'_, PyAny>,
|
|
subjects: Vec<String>,
|
|
title: Option<&str>,
|
|
) -> PyResult<Vec<String>> {
|
|
Ok(crate::compose_subjects(&from_py(scheme)?, &subjects, title))
|
|
}
|
|
|
|
/// Whether a book carries no heading the scheme recognises.
|
|
#[pyfunction]
|
|
fn needs_subject(scheme: &Bound<'_, PyAny>, existing: Vec<String>) -> PyResult<bool> {
|
|
Ok(crate::needs_subject(&from_py(scheme)?, &existing))
|
|
}
|
|
|
|
/// 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 {
|
|
crate::looks_like_footnote_citations(text)
|
|
}
|
|
|
|
/// Read the references out of a bibliography or a page of notes.
|
|
#[pyfunction]
|
|
fn find_citations<'py>(py: Python<'py>, text: &str) -> PyResult<Bound<'py, PyList>> {
|
|
list_to_py(py, &crate::find_citations(text))
|
|
}
|
|
|
|
/// The question to put to a model about the references that did not parse.
|
|
///
|
|
/// `None` when every one of them settled, because there is nothing to ask.
|
|
#[pyfunction]
|
|
fn citation_prompt(references: &Bound<'_, PyAny>) -> PyResult<Option<String>> {
|
|
let references: Vec<citefind::Reference> = from_py_list(references)?;
|
|
Ok(crate::citation_prompt(&references))
|
|
}
|
|
|
|
/// Fill in what did not parse from a model's reply.
|
|
#[pyfunction]
|
|
fn merge_citation_reply<'py>(
|
|
py: Python<'py>,
|
|
references: &Bound<'py, PyAny>,
|
|
reply: &str,
|
|
) -> PyResult<Bound<'py, PyList>> {
|
|
let references: Vec<citefind::Reference> = from_py_list(references)?;
|
|
let merged = crate::merge_citation_reply(&references, reply)
|
|
.map_err(pyo3::exceptions::PyValueError::new_err)?;
|
|
list_to_py(py, &merged)
|
|
}
|
|
|
|
/// The series a title belongs to, and which volume of it this is.
|
|
///
|
|
/// `None` when the title says nothing about a series, which is most titles.
|
|
#[pyfunction]
|
|
#[pyo3(signature = (title, author = None))]
|
|
fn series_of<'py>(
|
|
py: Python<'py>,
|
|
title: &str,
|
|
author: Option<&str>,
|
|
) -> PyResult<Option<Bound<'py, PyAny>>> {
|
|
match crate::series_of(title, author) {
|
|
Some(found) => Ok(Some(to_py(py, &found)?)),
|
|
None => Ok(None),
|
|
}
|
|
}
|
|
|
|
/// Read an identifier out of whatever it arrived wrapped in.
|
|
///
|
|
/// `None` when the string is not one.
|
|
#[pyfunction]
|
|
fn parse_identifier<'py>(py: Python<'py>, text: &str) -> PyResult<Option<Bound<'py, PyAny>>> {
|
|
match crate::parse_identifier(text) {
|
|
Some(found) => Ok(Some(to_py(py, &found)?)),
|
|
None => Ok(None),
|
|
}
|
|
}
|
|
|
|
/// The module Python imports.
|
|
#[pymodule]
|
|
fn _catena(module: &Bound<'_, PyModule>) -> PyResult<()> {
|
|
module.add("__version__", env!("CARGO_PKG_VERSION"))?;
|
|
module.add_function(wrap_pyfunction!(chunk_markdown, module)?)?;
|
|
module.add_function(wrap_pyfunction!(scrub, module)?)?;
|
|
module.add_function(wrap_pyfunction!(count_tokens, module)?)?;
|
|
module.add_function(wrap_pyfunction!(parse_subtitles, module)?)?;
|
|
module.add_function(wrap_pyfunction!(pack_cues, module)?)?;
|
|
module.add_function(wrap_pyfunction!(segment_cues, module)?)?;
|
|
module.add_function(wrap_pyfunction!(cue_windows, module)?)?;
|
|
module.add_function(wrap_pyfunction!(split_collection, module)?)?;
|
|
module.add_function(wrap_pyfunction!(fuse_hits, module)?)?;
|
|
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!(needs_subject, module)?)?;
|
|
module.add_function(wrap_pyfunction!(clean_record, module)?)?;
|
|
module.add_function(wrap_pyfunction!(record_problems, module)?)?;
|
|
module.add_function(wrap_pyfunction!(record_severity, module)?)?;
|
|
module.add_function(wrap_pyfunction!(title_in_author, module)?)?;
|
|
module.add_function(wrap_pyfunction!(looks_like_person, module)?)?;
|
|
module.add_function(wrap_pyfunction!(is_shouting, module)?)?;
|
|
module.add_function(wrap_pyfunction!(title_case, module)?)?;
|
|
module.add_function(wrap_pyfunction!(clean_underscores, module)?)?;
|
|
module.add_function(wrap_pyfunction!(valid_isbn, module)?)?;
|
|
module.add_function(wrap_pyfunction!(isbn_from_text, module)?)?;
|
|
module.add_function(wrap_pyfunction!(isbn_from_filename, module)?)?;
|
|
module.add_function(wrap_pyfunction!(same_work, module)?)?;
|
|
module.add_function(wrap_pyfunction!(compose_subjects, module)?)?;
|
|
module.add_function(wrap_pyfunction!(canonical_subject, 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)?)?;
|
|
module.add_function(wrap_pyfunction!(parse_identifier, module)?)?;
|
|
Ok(())
|
|
}
|