1use aspero_core::id::{Mention, scan};
15use aspero_core::matching::{self, Agreement};
16use aspero_core::{Claim, FieldSet, SourceRecord, WorkId};
17
18use crate::{Engine, Verdict, Verification};
19
20pub const MAX_IDENTIFIERS: usize = 6;
22const EDGE: f64 = 0.2;
25const TOP_CHARS: usize = 800;
28
29#[derive(Debug, Clone, Copy, PartialEq, Eq)]
31#[non_exhaustive]
32pub enum Decision {
33 IdentifierAndTitle,
36 IdentifierAndPage,
39 OnlyIdentifier,
41 TitleSearch,
44}
45
46impl Decision {
47 pub fn as_str(self) -> &'static str {
48 match self {
49 Decision::IdentifierAndTitle => "identifier_and_title",
50 Decision::IdentifierAndPage => "identifier_and_page",
51 Decision::OnlyIdentifier => "only_identifier",
52 Decision::TitleSearch => "title_search",
53 }
54 }
55}
56
57#[derive(Debug, Clone)]
59#[non_exhaustive]
60pub struct Identified {
61 pub decided_by: Decision,
62 pub claim: Claim,
65 pub verification: Verification,
67}
68
69impl Identified {
70 pub fn input(&self) -> String {
73 if !self.claim.ids.is_empty() {
74 let ids: Vec<String> = self.claim.ids.iter().map(ToString::to_string).collect();
75 return ids.join(" ");
76 }
77 let text = self.claim.reference().unwrap_or_default();
78 let line = text.split_whitespace().collect::<Vec<_>>().join(" ");
79 match line.char_indices().nth(100) {
80 Some((at, _)) => format!("{}…", &line[..at]),
81 None => line,
82 }
83 }
84}
85
86impl Engine {
87 pub async fn identify(
92 &self,
93 first_page: &str,
94 title: Option<&str>,
95 title_hint: Option<&str>,
96 ) -> Option<Identified> {
97 let titles: Vec<&str> = [(title_hint, 1), (title, 3)]
100 .into_iter()
101 .filter_map(|(t, min_words)| t.map(str::trim).filter(|t| !junk_title(t, min_words)))
102 .collect();
103 let ids = candidates(first_page);
104 let found = self.lookup(&ids, FieldSet::DEFAULT).await;
105 let records: Vec<(&WorkId, &SourceRecord)> = ids
106 .iter()
107 .zip(&found.results)
108 .filter_map(|(id, r)| Some((id, r.record.as_ref()?)))
109 .collect();
110
111 let by_title = records
112 .iter()
113 .filter_map(|&(id, r)| {
114 let best = titles.iter().map(|t| title_score(t, r)).fold(0.0, f64::max);
115 (best >= TITLE_MATCH).then_some((best, id))
116 })
117 .rev()
120 .max_by(|a, b| a.0.total_cmp(&b.0))
121 .map(|(_, id)| (id, Decision::IdentifierAndTitle));
122 let on_page = || {
125 let page = Claim::from_text(first_page);
126 let norm = matching::normalise(first_page);
127 records
128 .iter()
129 .filter(|(_, r)| {
130 let a = matching::assess(&page, r);
131 a.title.agreement == Agreement::Match && a.authors.agreement == Agreement::Match
132 })
133 .min_by_key(|(_, r)| {
134 let title = r.work.title.as_deref().map(matching::normalise);
135 title.and_then(|t| norm.find(&t)).unwrap_or(usize::MAX)
136 })
137 .map(|&(id, _)| (id, Decision::IdentifierAndPage))
138 };
139 let only = || match (&ids[..], titles.is_empty(), &records[..]) {
140 ([_], true, [(id, _)]) => Some((*id, Decision::OnlyIdentifier)),
141 _ => None,
142 };
143 if let Some((id, decided_by)) = by_title.or_else(on_page).or_else(only) {
144 let mut claim = Claim::default();
145 claim.ids = vec![id.clone()];
146 let verification = self.verify(&claim, 5).await;
147 return Some(Identified {
148 decided_by,
149 claim,
150 verification,
151 });
152 }
153
154 let mut top: String = first_page.chars().take(TOP_CHARS).collect();
160 let norm = matching::normalise(&top);
161 for t in &titles {
162 if !norm.contains(&matching::normalise(t)) {
163 top = format!("{t}. {top}");
164 }
165 }
166 let mut claim = Claim::default();
167 claim.text = Some(top);
168 let verification = self.verify(&claim, 5).await;
169 matches!(verification.verdict, Verdict::Verified | Verdict::Plausible).then_some(
170 Identified {
171 decided_by: Decision::TitleSearch,
172 claim,
173 verification,
174 },
175 )
176 }
177}
178
179const TITLE_MATCH: f64 = 0.9;
181
182fn title_score(title: &str, record: &SourceRecord) -> f64 {
184 record
185 .work
186 .title
187 .as_deref()
188 .map_or(0.0, |actual| matching::title_similarity(title, actual))
189}
190
191fn candidates(page: &str) -> Vec<WorkId> {
194 let lines = page.lines().count().max(1);
195 let edge = |m: &Mention| {
196 let at = (m.line - 1) as f64 / lines as f64;
197 at < EDGE || at >= 1.0 - EDGE
198 };
199 let mentions = scan(page);
200 let (mut ordered, body): (Vec<&Mention>, Vec<&Mention>) =
201 mentions.iter().partition(|m| edge(m));
202 ordered.extend(body);
203 let mut ids: Vec<WorkId> = Vec::new();
204 for m in ordered {
205 if !ids.iter().any(|id| matching::same_work_id(id, &m.id)) {
206 ids.push(m.id.clone());
207 }
208 }
209 ids.truncate(MAX_IDENTIFIERS);
210 ids
211}
212
213fn junk_title(title: &str, min_words: usize) -> bool {
217 let lower = title.trim().to_lowercase();
218 let placeholder = [
219 "untitled",
220 "title",
221 "document",
222 "manuscript",
223 "article",
224 "pdf",
225 ]
226 .iter()
227 .any(|p| lower == *p || lower.starts_with(&format!("{p} ")) && lower.len() < 16);
228 let tool = [
229 "microsoft word",
230 "microsoft powerpoint",
231 "untitled document",
232 "doi:",
233 "http",
234 ]
235 .iter()
236 .any(|p| lower.starts_with(p));
237 let file = [
238 ".doc", ".docx", ".pdf", ".tex", ".dvi", ".indd", ".rtf", ".odt",
239 ]
240 .iter()
241 .any(|ext| lower.ends_with(ext))
242 || (!lower.contains(' ') && lower.contains(['_', '-', '.']));
243 let identifier = !scan(title).is_empty();
245 let words = lower
246 .split_whitespace()
247 .filter(|w| w.chars().any(char::is_alphabetic))
248 .count();
249 placeholder || tool || file || identifier || words < min_words.max(1)
250}
251
252#[cfg(test)]
253mod tests {
254 use super::*;
255
256 #[test]
257 fn metadata_titles_that_are_not_titles() {
258 for junk in [
259 "",
260 "untitled",
261 "Microsoft Word - draft3.docx",
262 "paper_final_v2",
263 "manuscript.pdf",
264 "Title",
265 "arXiv:1706.03762v7 [cs.CL] 2 Aug 2023",
266 ] {
267 assert!(junk_title(junk, 1), "{junk:?}");
268 }
269 assert!(junk_title("Deep learning", 3));
270 assert!(!junk_title("Deep learning", 1));
271 assert!(!junk_title(
272 "Chest wall thickness and decompression failure",
273 3
274 ));
275 }
276
277 #[test]
278 fn header_and_footer_identifiers_come_first() {
279 let mut page =
280 String::from("Journal of Things 12 (2020) 1-9 https://doi.org/10.1234/jt.2020.1\n");
281 for i in 0..20 {
282 page.push_str(&format!("Body line {i} citing doi:10.5555/ref.{i}\n"));
283 }
284 page.push_str("© 2020 Elsevier. doi:10.1234/jt.2020.1\n");
285 let ids = candidates(&page);
286 assert_eq!(ids.len(), MAX_IDENTIFIERS);
287 assert_eq!(ids[0].to_string(), "doi:10.1234/jt.2020.1");
288 assert_eq!(
290 ids.iter()
291 .filter(|id| id.to_string() == "doi:10.1234/jt.2020.1")
292 .count(),
293 1
294 );
295 }
296}