mirror of
https://git.pumpin.online/funkpower/catena.git
synced 2026-11-14 08:05:40 +00:00
A catalogue's worst records are the ones whose file says nothing true about itself: a PDF titled `Untitled` by an author called `Oxford`, which is a publisher, from a filename that was never anything but a filename. What those books do have is pages. The title page carries the title as the publisher set it; the copyright page carries the imprint, the year, the ISBN, and in most English books since the 1970s the cataloguing block, which is a catalogue record a librarian already made, subject headings and all. So this reads the pages. Every field names the page it came from, and a book that printed nothing comes back empty with its fields in `unsettled` rather than filled with something plausible — an extraction that starts at chapter one has no front matter in it, and saying so is the answer. Two pages look like a title page and are not, and both are how a catalogue comes to name the wrong book. A series advertisement lists other titles in the shape a title page uses, over as many as four pages with only the first announcing itself. A translation's cataloguing block prints the original title in brackets above the real one. Both are found and refused. Three things the pages give that no file does: the subject headings a librarian assigned, an ISBN that exists only in print — which turns a book with no identifier into one `bibfetch` can look up — and a title in the case the publisher meant, because a half title prints unbroken what the title page breaks across two lines and sets in capitals. `--prompt` and `--merge` are citefind's: a question about exactly the fields the pages left unsettled, composed here and answered wherever the caller keeps a model, with the advertising taken out of what the model is shown. A field a model answered is marked `model`, so a record always says which of it was read and which was inferred. Measured against 600 books of the home corpus: the cataloguing block answered for 21% of them and agreed with the catalogue 89% of the time, most of the residue being the catalogue's error rather than this one's. Title pages answered for another 41%. The remaining 38% print nothing readable, and are what the prompt is for.
195 lines
7.1 KiB
Rust
195 lines
7.1 KiB
Rust
//! A question to put to a model, for what the pages did not settle.
|
|
//!
|
|
//! Nothing here sends anything. The prompt is composed and printed, and what
|
|
//! happens to it is the caller's business — their model, their machine,
|
|
//! their GPU. The tool stays what every tool here is: the same output for
|
|
//! the same input, on every machine, with no network and nothing installed.
|
|
//!
|
|
//! What the model is shown is not the front matter as it arrived. The pages
|
|
//! that advertise other books are taken out first, because handing them to a
|
|
//! model is how a book ends up catalogued as whichever of two hundred other
|
|
//! titles the model's eye fell on. It is shown the pages that describe this
|
|
//! book, told what was already read off them, and asked only for the rest.
|
|
|
|
use crate::{Printed, Source, page};
|
|
|
|
/// The version of the prompt below.
|
|
///
|
|
/// Bumped when the wording changes in a way that could change an answer, so
|
|
/// a caller keeping results can tell which prompt produced them.
|
|
pub const PROMPT_VERSION: u32 = 1;
|
|
|
|
/// How much of the front matter to show. Enough for a title page, a
|
|
/// copyright page and a contents; not enough to wander into the text.
|
|
const SHOWN: usize = 6000;
|
|
|
|
/// The fields a model may be asked for.
|
|
const ASKABLE: &[&str] = &["title", "authors", "publisher", "year", "subjects"];
|
|
|
|
fn known(record: &Printed) -> String {
|
|
let mut out = String::new();
|
|
if let Some(title) = &record.title {
|
|
out.push_str(&format!("title already read: {title}\n"));
|
|
}
|
|
if !record.authors.is_empty() {
|
|
out.push_str(&format!(
|
|
"authors already read: {}\n",
|
|
record.authors.join("; ")
|
|
));
|
|
}
|
|
if !record.isbns.is_empty() {
|
|
out.push_str(&format!(
|
|
"isbn printed in the book: {}\n",
|
|
record.isbns.join(", ")
|
|
));
|
|
}
|
|
if let Some(publisher) = &record.publisher {
|
|
out.push_str(&format!("publisher already read: {publisher}\n"));
|
|
}
|
|
out
|
|
}
|
|
|
|
/// The front matter with the advertising taken out.
|
|
pub fn readable_pages(text: &str, budget: usize) -> String {
|
|
let mut out = String::new();
|
|
for page in page::split(text, budget) {
|
|
if matches!(page.kind, page::Kind::SeriesList | page::Kind::Blank) || page.lines.is_empty()
|
|
{
|
|
continue;
|
|
}
|
|
out.push_str(&format!("--- page {}\n", page.number));
|
|
for line in &page.lines {
|
|
out.push_str(line);
|
|
out.push('\n');
|
|
}
|
|
out.push('\n');
|
|
if out.len() >= SHOWN {
|
|
break;
|
|
}
|
|
}
|
|
out.truncate(SHOWN);
|
|
out
|
|
}
|
|
|
|
/// Compose the question, or decline to.
|
|
///
|
|
/// `None` when the pages settled everything a model could help with, because
|
|
/// there is nothing to ask and a call to a model would be waste.
|
|
pub fn compose(record: &Printed, text: &str, budget: usize) -> Option<String> {
|
|
let wanted: Vec<&str> = ASKABLE
|
|
.iter()
|
|
.copied()
|
|
.filter(|field| record.unsettled.iter().any(|f| f == field))
|
|
.collect();
|
|
if wanted.is_empty() {
|
|
return None;
|
|
}
|
|
let pages = readable_pages(text, budget);
|
|
if pages.trim().is_empty() {
|
|
return None;
|
|
}
|
|
|
|
let mut out = String::from(
|
|
"Below are the opening pages of one book, as text. Pages advertising \
|
|
other books have already been removed.\n\n",
|
|
);
|
|
out.push_str(&pages);
|
|
out.push('\n');
|
|
let already = known(record);
|
|
if !already.is_empty() {
|
|
out.push_str("Read off these pages already:\n");
|
|
out.push_str(&already);
|
|
out.push('\n');
|
|
}
|
|
out.push_str(&format!("wanted: {}\n\n", wanted.join(", ")));
|
|
out.push_str(
|
|
"Reply with one JSON object and nothing else, using only the keys \
|
|
listed as wanted. `title` is the book's own title, without its \
|
|
subtitle. `authors` is a list of the people who wrote it, as the \
|
|
book writes their names. `publisher` is the press. `year` is a \
|
|
four-digit number. `subjects` is a list of at most five subject \
|
|
headings for what the book is about.\n\n\
|
|
Answer only from the pages above. Use null for anything they do not \
|
|
settle: a guess is worse than an absence, because nobody downstream \
|
|
can tell them apart. A title listed beside other titles is another \
|
|
book's, not this one's.\n",
|
|
);
|
|
Some(out)
|
|
}
|
|
|
|
/// Fill in what the pages did not settle from a model's reply.
|
|
///
|
|
/// Only the fields that were unsettled are filled, and each one is marked as
|
|
/// having come from a model — a record that cannot say which of its fields
|
|
/// were read and which were answered is a record nobody can apply a policy
|
|
/// to. Returns how many fields were filled.
|
|
pub fn merge(record: &mut Printed, reply: &str) -> Result<usize, String> {
|
|
let start = reply.find('{').ok_or("the reply holds no JSON object")?;
|
|
let end = reply.rfind('}').ok_or("the reply holds no JSON object")?;
|
|
let parsed: serde_json::Value = serde_json::from_str(&reply[start..=end])
|
|
.map_err(|e| format!("the reply is not JSON: {e}"))?;
|
|
let object = parsed.as_object().ok_or("the reply is not a JSON object")?;
|
|
|
|
let mut filled = 0;
|
|
let wanted = |record: &Printed, field: &str| record.unsettled.iter().any(|f| f == field);
|
|
|
|
if wanted(record, "title") {
|
|
if let Some(title) = object.get("title").and_then(|v| v.as_str()) {
|
|
let title = title.trim();
|
|
if !title.is_empty() {
|
|
record.title = Some(title.to_string());
|
|
record.read_from.insert("title".into(), Source::Model);
|
|
filled += 1;
|
|
}
|
|
}
|
|
}
|
|
if wanted(record, "publisher") {
|
|
if let Some(publisher) = object.get("publisher").and_then(|v| v.as_str()) {
|
|
let publisher = publisher.trim();
|
|
if !publisher.is_empty() {
|
|
record.publisher = Some(publisher.to_string());
|
|
record.read_from.insert("publisher".into(), Source::Model);
|
|
filled += 1;
|
|
}
|
|
}
|
|
}
|
|
if wanted(record, "year") {
|
|
if let Some(year) = object.get("year").and_then(serde_json::Value::as_u64) {
|
|
if (1400..=2200).contains(&year) {
|
|
record.year = Some(year as u16);
|
|
record.read_from.insert("year".into(), Source::Model);
|
|
filled += 1;
|
|
}
|
|
}
|
|
}
|
|
for (field, into) in [("authors", true), ("subjects", false)] {
|
|
if !wanted(record, field) {
|
|
continue;
|
|
}
|
|
let Some(list) = object.get(field).and_then(|v| v.as_array()) else {
|
|
continue;
|
|
};
|
|
let values: Vec<String> = list
|
|
.iter()
|
|
.filter_map(|v| v.as_str())
|
|
.map(|s| s.trim().to_string())
|
|
.filter(|s| !s.is_empty())
|
|
.collect();
|
|
if values.is_empty() {
|
|
continue;
|
|
}
|
|
if into {
|
|
record.authors = values;
|
|
} else {
|
|
record.subjects = values;
|
|
}
|
|
record.read_from.insert(field.into(), Source::Model);
|
|
filled += 1;
|
|
}
|
|
|
|
record
|
|
.unsettled
|
|
.retain(|field| !record.read_from.contains_key(field));
|
|
Ok(filled)
|
|
}
|