aspero_engine/abstracts.rs
1//! Finding abstracts across providers.
2//!
3//! Abstract coverage differs by provider and publisher: OpenAlex lacks them
4//! for some publishers, Crossref has them only when the publisher deposited
5//! one. So a work found at one provider without an abstract is looked up at
6//! the others (by any identifier they accept) until one has it. Lookups are
7//! batched per provider, cached, and free at OpenAlex (single works) and
8//! Crossref. The abstract keeps its provenance: it is never blended into
9//! another provider's record.
10
11use aspero_core::{FieldSet, ProviderId, SourceRecord, Usage, WorkId};
12
13use crate::Engine;
14
15/// An abstract and the provider it came from.
16#[derive(Debug, Clone, PartialEq, Eq)]
17pub struct Abstract {
18 pub text: String,
19 pub source: ProviderId,
20}
21
22#[derive(Debug, Clone, Default)]
23pub struct AbstractsReport {
24 /// One per work, in order; `None` where no provider has an abstract.
25 pub found: Vec<Option<Abstract>>,
26 pub usage: Vec<(ProviderId, Usage)>,
27}
28
29impl Engine {
30 /// The abstract for each work: its own record's if present, otherwise
31 /// the first other provider's (by priority) that has one. Works are
32 /// identified by their records' identifiers; pass a record with merged
33 /// identifiers to widen the search.
34 pub async fn abstracts(&self, works: &[&SourceRecord]) -> AbstractsReport {
35 let mut found: Vec<Option<Abstract>> = works
36 .iter()
37 .map(|r| {
38 non_blank(r.work.abstract_text.clone()).map(|text| Abstract {
39 text,
40 source: r.source.clone(),
41 })
42 })
43 .collect();
44 let mut usage = Vec::new();
45
46 for entry in &self.entries {
47 let provider = entry.provider.id();
48 let kinds = &entry.provider.capabilities().lookup_kinds;
49 // One identifier per still-missing work that this provider
50 // accepts (and that it didn't already answer without one).
51 let wanted: Vec<(usize, WorkId)> = works
52 .iter()
53 .enumerate()
54 .filter(|(i, r)| found[*i].is_none() && r.source != provider)
55 .filter_map(|(i, r)| {
56 let id = r.ids.iter().find(|id| kinds.contains(&id.kind()))?;
57 Some((i, id.clone()))
58 })
59 .collect();
60 if wanted.is_empty() {
61 continue;
62 }
63 let ids: Vec<WorkId> = wanted.iter().map(|(_, id)| id.clone()).collect();
64 let answers = self.lookup_at(entry, &ids, FieldSet::ALL, &mut usage).await;
65 for ((i, _), (_, record)) in wanted.into_iter().zip(answers) {
66 if let Some(text) = record.and_then(|r| non_blank(r.work.abstract_text)) {
67 found[i] = Some(Abstract {
68 text,
69 source: provider.clone(),
70 });
71 }
72 }
73 }
74 AbstractsReport { found, usage }
75 }
76}
77
78/// Some deposits carry an empty abstract element; treat it as absent.
79fn non_blank(text: Option<String>) -> Option<String> {
80 text.filter(|t| !t.trim().is_empty())
81}