use aspero_core::id::{Mention, scan};
use aspero_core::matching::{self, Agreement};
use aspero_core::{Claim, FieldSet, SourceRecord, WorkId};
use crate::{Engine, Verdict, Verification};
pub const MAX_IDENTIFIERS: usize = 6;
const EDGE: f64 = 0.2;
const TOP_CHARS: usize = 800;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[non_exhaustive]
pub enum Decision {
IdentifierAndTitle,
IdentifierAndPage,
OnlyIdentifier,
TitleSearch,
}
impl Decision {
pub fn as_str(self) -> &'static str {
match self {
Decision::IdentifierAndTitle => "identifier_and_title",
Decision::IdentifierAndPage => "identifier_and_page",
Decision::OnlyIdentifier => "only_identifier",
Decision::TitleSearch => "title_search",
}
}
}
#[derive(Debug, Clone)]
#[non_exhaustive]
pub struct Identified {
pub decided_by: Decision,
pub claim: Claim,
pub verification: Verification,
}
impl Identified {
pub fn input(&self) -> String {
if !self.claim.ids.is_empty() {
let ids: Vec<String> = self.claim.ids.iter().map(ToString::to_string).collect();
return ids.join(" ");
}
let text = self.claim.reference().unwrap_or_default();
let line = text.split_whitespace().collect::<Vec<_>>().join(" ");
match line.char_indices().nth(100) {
Some((at, _)) => format!("{}…", &line[..at]),
None => line,
}
}
}
impl Engine {
pub async fn identify(
&self,
first_page: &str,
title: Option<&str>,
title_hint: Option<&str>,
) -> Option<Identified> {
let titles: Vec<&str> = [(title_hint, 1), (title, 3)]
.into_iter()
.filter_map(|(t, min_words)| t.map(str::trim).filter(|t| !junk_title(t, min_words)))
.collect();
let ids = candidates(first_page);
let found = self.lookup(&ids, FieldSet::DEFAULT).await;
let records: Vec<(&WorkId, &SourceRecord)> = ids
.iter()
.zip(&found.results)
.filter_map(|(id, r)| Some((id, r.record.as_ref()?)))
.collect();
let by_title = records
.iter()
.filter_map(|&(id, r)| {
let best = titles.iter().map(|t| title_score(t, r)).fold(0.0, f64::max);
(best >= TITLE_MATCH).then_some((best, id))
})
.max_by(|a, b| a.0.total_cmp(&b.0))
.map(|(_, id)| (id, Decision::IdentifierAndTitle));
let on_page = || {
let page = Claim::from_text(first_page);
let norm = matching::normalise(first_page);
records
.iter()
.filter(|(_, r)| {
let a = matching::assess(&page, r);
a.title.agreement == Agreement::Match && a.authors.agreement == Agreement::Match
})
.min_by_key(|(_, r)| {
let title = r.work.title.as_deref().map(matching::normalise);
title.and_then(|t| norm.find(&t)).unwrap_or(usize::MAX)
})
.map(|&(id, _)| (id, Decision::IdentifierAndPage))
};
let only = || match (&ids[..], titles.is_empty(), &records[..]) {
([_], true, [(id, _)]) => Some((*id, Decision::OnlyIdentifier)),
_ => None,
};
if let Some((id, decided_by)) = by_title.or_else(on_page).or_else(only) {
let mut claim = Claim::default();
claim.ids = vec![id.clone()];
let verification = self.verify(&claim, 5).await;
return Some(Identified {
decided_by,
claim,
verification,
});
}
let mut top: String = first_page.chars().take(TOP_CHARS).collect();
let norm = matching::normalise(&top);
for t in &titles {
if !norm.contains(&matching::normalise(t)) {
top = format!("{t}. {top}");
}
}
let mut claim = Claim::default();
claim.text = Some(top);
let verification = self.verify(&claim, 5).await;
matches!(verification.verdict, Verdict::Verified | Verdict::Plausible).then_some(
Identified {
decided_by: Decision::TitleSearch,
claim,
verification,
},
)
}
}
const TITLE_MATCH: f64 = 0.9;
fn title_score(title: &str, record: &SourceRecord) -> f64 {
record
.work
.title
.as_deref()
.map_or(0.0, |actual| matching::title_similarity(title, actual))
}
fn candidates(page: &str) -> Vec<WorkId> {
let lines = page.lines().count().max(1);
let edge = |m: &Mention| {
let at = (m.line - 1) as f64 / lines as f64;
at < EDGE || at >= 1.0 - EDGE
};
let mentions = scan(page);
let (mut ordered, body): (Vec<&Mention>, Vec<&Mention>) =
mentions.iter().partition(|m| edge(m));
ordered.extend(body);
let mut ids: Vec<WorkId> = Vec::new();
for m in ordered {
if !ids.iter().any(|id| matching::same_work_id(id, &m.id)) {
ids.push(m.id.clone());
}
}
ids.truncate(MAX_IDENTIFIERS);
ids
}
fn junk_title(title: &str, min_words: usize) -> bool {
let lower = title.trim().to_lowercase();
let placeholder = [
"untitled",
"title",
"document",
"manuscript",
"article",
"pdf",
]
.iter()
.any(|p| lower == *p || lower.starts_with(&format!("{p} ")) && lower.len() < 16);
let tool = [
"microsoft word",
"microsoft powerpoint",
"untitled document",
"doi:",
"http",
]
.iter()
.any(|p| lower.starts_with(p));
let file = [
".doc", ".docx", ".pdf", ".tex", ".dvi", ".indd", ".rtf", ".odt",
]
.iter()
.any(|ext| lower.ends_with(ext))
|| (!lower.contains(' ') && lower.contains(['_', '-', '.']));
let identifier = !scan(title).is_empty();
let words = lower
.split_whitespace()
.filter(|w| w.chars().any(char::is_alphabetic))
.count();
placeholder || tool || file || identifier || words < min_words.max(1)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn metadata_titles_that_are_not_titles() {
for junk in [
"",
"untitled",
"Microsoft Word - draft3.docx",
"paper_final_v2",
"manuscript.pdf",
"Title",
"arXiv:1706.03762v7 [cs.CL] 2 Aug 2023",
] {
assert!(junk_title(junk, 1), "{junk:?}");
}
assert!(junk_title("Deep learning", 3));
assert!(!junk_title("Deep learning", 1));
assert!(!junk_title(
"Chest wall thickness and decompression failure",
3
));
}
#[test]
fn header_and_footer_identifiers_come_first() {
let mut page =
String::from("Journal of Things 12 (2020) 1-9 https://doi.org/10.1234/jt.2020.1\n");
for i in 0..20 {
page.push_str(&format!("Body line {i} citing doi:10.5555/ref.{i}\n"));
}
page.push_str("© 2020 Elsevier. doi:10.1234/jt.2020.1\n");
let ids = candidates(&page);
assert_eq!(ids.len(), MAX_IDENTIFIERS);
assert_eq!(ids[0].to_string(), "doi:10.1234/jt.2020.1");
assert_eq!(
ids.iter()
.filter(|id| id.to_string() == "doi:10.1234/jt.2020.1")
.count(),
1
);
}
}