Skip to main content

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::{Field, ProviderId, SourceRecord, Usage};
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            if !entry
48                .provider
49                .capabilities()
50                .fields
51                .contains(Field::Abstract)
52            {
53                continue;
54            }
55            let missing = works
56                .iter()
57                .copied()
58                .enumerate()
59                .filter(|(i, _)| found[*i].is_none());
60            for (i, record) in self.ask_about(entry, missing, &mut usage).await {
61                if let Some(text) = non_blank(record.work.abstract_text) {
62                    found[i] = Some(Abstract {
63                        text,
64                        source: entry.provider.id(),
65                    });
66                }
67            }
68        }
69        AbstractsReport { found, usage }
70    }
71}
72
73/// Some deposits carry an empty abstract element; treat it as absent.
74fn non_blank(text: Option<String>) -> Option<String> {
75    text.filter(|t| !t.trim().is_empty())
76}