Skip to main content

aspero_engine/
enrich.rs

1//! Filling in what a record's provider didn't say, from the providers
2//! that know: notices (both directions) and open access.
3
4use aspero_core::{Field, FieldSet, IdKind, ProviderId, SourceRecord, Update, Usage, WorkId};
5
6use crate::{Engine, Entry, LookupReport, LookupResult, resolve};
7
8impl Engine {
9    /// Adds editorial notices, in both directions, to records that another
10    /// provider supplied, from the providers that report them in detail
11    /// (Crossref): an original then lists its corrections and retractions,
12    /// a correction the work it corrects. Notices are typed as such and
13    /// dated by when they appeared. Batched and cached, like lookups.
14    pub async fn add_notices(&self, report: &mut LookupReport) {
15        if let Some(entry) = self.registry() {
16            let works = found_records(&report.results, |_| true);
17            for (i, found) in self.ask_about(entry, works, &mut report.usage).await {
18                let Some(record) = report.results[i].record.as_mut() else {
19                    continue;
20                };
21                let work = &mut record.work;
22                resolve::merge_updates(&mut work.updates, &found.work.updates);
23                for target in found.work.update_to {
24                    if !work.update_to.contains(&target) {
25                        work.update_to.push(target);
26                    }
27                }
28                work.type_as_notice();
29                // OpenAlex flags a retraction or withdrawal notice itself as
30                // retracted, without details: name the record as its own
31                // notice, so every row about the one event shares `notice`.
32                let own_doi = record.ids.iter().find(|id| id.kind() == IdKind::Doi);
33                if work.update_to.iter().any(|t| t.kind.is_retraction())
34                    && let Some(own) = own_doi
35                {
36                    let bare = |u: &&mut Update| u.kind.is_retraction() && u.notice.is_none();
37                    for update in work.updates.iter_mut().filter(bare) {
38                        update.notice = Some(own.clone());
39                    }
40                }
41            }
42        }
43        self.date_notices(report).await;
44    }
45
46    /// Fills in where works can be read free, for records whose provider
47    /// didn't say, from the first provider (by priority) that does.
48    pub async fn add_open_access(&self, report: &mut LookupReport) {
49        for entry in &self.entries {
50            if !entry
51                .provider
52                .capabilities()
53                .fields
54                .contains(Field::OpenAccess)
55            {
56                continue;
57            }
58            let works = found_records(&report.results, |r| r.work.open_access.is_none());
59            for (i, found) in self.ask_about(entry, works, &mut report.usage).await {
60                if let (Some(oa), Some(record)) =
61                    (found.work.open_access, report.results[i].record.as_mut())
62                {
63                    record.work.open_access = Some(oa);
64                }
65            }
66        }
67    }
68
69    /// One provider's records of works it didn't supply itself, each asked
70    /// for by an identifier it accepts: `(work index, record)` per work it
71    /// knows.
72    pub(crate) async fn ask_about<'a>(
73        &self,
74        entry: &Entry,
75        works: impl IntoIterator<Item = (usize, &'a SourceRecord)>,
76        usage: &mut Vec<(ProviderId, Usage)>,
77    ) -> Vec<(usize, SourceRecord)> {
78        let provider = entry.provider.id();
79        let kinds = &entry.provider.capabilities().lookup_kinds;
80        let wanted: Vec<(usize, WorkId)> = works
81            .into_iter()
82            .filter(|(_, r)| r.source != provider)
83            .filter_map(|(i, r)| {
84                let id = r.ids.iter().find(|id| kinds.contains(&id.kind()))?;
85                Some((i, id.clone()))
86            })
87            .collect();
88        if wanted.is_empty() {
89            return Vec::new();
90        }
91        let ids: Vec<WorkId> = wanted.iter().map(|(_, id)| id.clone()).collect();
92        let answers = self.lookup_at(entry, &ids, FieldSet::ALL, usage).await;
93        wanted
94            .into_iter()
95            .zip(answers)
96            .filter_map(|((i, _), (_, record))| Some((i, record?)))
97            .collect()
98    }
99}
100
101/// The records found among `results`, by index, that `keep` selects.
102pub(crate) fn found_records(
103    results: &[LookupResult],
104    keep: impl Fn(&SourceRecord) -> bool,
105) -> impl Iterator<Item = (usize, &SourceRecord)> {
106    results
107        .iter()
108        .enumerate()
109        .filter_map(move |(i, r)| Some((i, r.record.as_ref().filter(|r| keep(r))?)))
110}