Skip to main content

research_agent/adapters/
europepmc_source.rs

1use async_trait::async_trait;
2use serde::Deserialize;
3
4use crate::adapters::semantic_scholar_source::percent_encode;
5use crate::domain::paper::Paper;
6use crate::error::{ResearchError, Result};
7use crate::ports::paper_source::PaperSource;
8
9/// Europe PMC (ebi.ac.uk) REST search — one keyless JSON endpoint covering
10/// PubMed/MEDLINE records and, via the `SRC:PPR` filter, preprint servers
11/// (bioRxiv, medRxiv, …). Both `EuropePmcSource` (biomedical literature) and
12/// `PreprintSource` (bioRxiv-style preprints only) share the same fetch path,
13/// modeled on `OpenAlexSource`: reqwest + serde DTOs, explicit non-2xx
14/// checks, and offline tests over a canned JSON fixture.
15pub struct EuropePmcSource {
16    client: reqwest::Client,
17}
18
19impl EuropePmcSource {
20    pub fn new() -> Self {
21        Self {
22            client: reqwest::Client::new(),
23        }
24    }
25}
26
27impl Default for EuropePmcSource {
28    fn default() -> Self {
29        Self::new()
30    }
31}
32
33/// Same endpoint, restricted to the preprint source (`SRC:PPR`) so ingest can
34/// target bioRxiv-style servers directly.
35pub struct PreprintSource {
36    inner: EuropePmcSource,
37}
38
39impl PreprintSource {
40    pub fn new() -> Self {
41        Self {
42            inner: EuropePmcSource::new(),
43        }
44    }
45}
46
47impl Default for PreprintSource {
48    fn default() -> Self {
49        Self::new()
50    }
51}
52
53/// Run the search and map `core` results to `Paper`s. `resultType=core` is
54/// what carries `abstractText`; the default `lite` would omit it.
55async fn fetch(client: &reqwest::Client, query: &str, limit: usize) -> Result<Vec<Paper>> {
56    let url = format!(
57        "https://www.ebi.ac.uk/europepmc/webservices/rest/search?query={}&format=json\
58         &resultType=core&pageSize={limit}",
59        percent_encode(query),
60    );
61    let resp = client
62        .get(&url)
63        .header("User-Agent", "research-agent/0.1")
64        .send()
65        .await
66        .map_err(|e| ResearchError::Source(format!("Europe PMC request failed: {e}")))?;
67
68    let status = resp.status();
69    if !status.is_success() {
70        return Err(ResearchError::Source(format!(
71            "Europe PMC API returned HTTP {status}"
72        )));
73    }
74
75    let parsed: EpmcResponse = resp
76        .json()
77        .await
78        .map_err(|e| ResearchError::Source(format!("Europe PMC parse failed: {e}")))?;
79
80    Ok(parsed
81        .result_list
82        .results
83        .unwrap_or_default()
84        .into_iter()
85        .filter_map(hit_to_paper)
86        .collect())
87}
88
89fn hit_to_paper(hit: EpmcHit) -> Option<Paper> {
90    let title = hit.title.filter(|t| !t.is_empty())?;
91    let mut paper = Paper::new(title);
92    paper.year = hit.pub_year.and_then(|y| y.parse().ok());
93    paper.doi = hit.doi;
94    paper.venue = hit
95        .journal_info
96        .and_then(|ji| ji.journal)
97        .and_then(|j| j.title);
98    paper.abstract_text = hit.abstract_text.unwrap_or_default();
99    paper.authors = hit
100        .author_list
101        .map(|al| al.author)
102        .unwrap_or_default()
103        .into_iter()
104        .filter_map(|a| a.full_name)
105        .collect();
106    paper.url = Some(format!(
107        "https://europepmc.org/article/{}/{}",
108        hit.source.unwrap_or_else(|| "MED".into()),
109        hit.id,
110    ));
111    Some(paper)
112}
113
114#[derive(Deserialize)]
115struct EpmcResponse {
116    #[serde(rename = "resultList")]
117    result_list: EpmcResultList,
118}
119
120#[derive(Deserialize)]
121struct EpmcResultList {
122    #[serde(default, rename = "result")]
123    results: Option<Vec<EpmcHit>>,
124}
125
126#[derive(Deserialize)]
127struct EpmcHit {
128    id: String,
129    #[serde(default)]
130    source: Option<String>,
131    #[serde(default)]
132    title: Option<String>,
133    #[serde(default, rename = "pubYear")]
134    pub_year: Option<String>,
135    #[serde(default)]
136    doi: Option<String>,
137    #[serde(default, rename = "abstractText")]
138    abstract_text: Option<String>,
139    #[serde(default, rename = "journalInfo")]
140    journal_info: Option<EpmcJournalInfo>,
141    #[serde(default, rename = "authorList")]
142    author_list: Option<EpmcAuthorList>,
143}
144
145#[derive(Deserialize)]
146struct EpmcAuthorList {
147    #[serde(default)]
148    author: Vec<EpmcAuthor>,
149}
150
151#[derive(Deserialize)]
152struct EpmcJournalInfo {
153    #[serde(default)]
154    journal: Option<EpmcJournal>,
155}
156
157#[derive(Deserialize)]
158struct EpmcJournal {
159    #[serde(default)]
160    title: Option<String>,
161}
162
163#[derive(Deserialize)]
164struct EpmcAuthor {
165    #[serde(default, rename = "fullName")]
166    full_name: Option<String>,
167}
168
169#[async_trait]
170impl PaperSource for EuropePmcSource {
171    async fn fetch_papers(&self, query: &str, limit: usize) -> Result<Vec<Paper>> {
172        fetch(&self.client, query, limit).await
173    }
174
175    fn name(&self) -> &str {
176        "europepmc"
177    }
178}
179
180#[async_trait]
181impl PaperSource for PreprintSource {
182    async fn fetch_papers(&self, query: &str, limit: usize) -> Result<Vec<Paper>> {
183        fetch(&self.inner.client, &format!("({query}) AND SRC:PPR"), limit).await
184    }
185
186    fn name(&self) -> &str {
187        "preprints"
188    }
189}
190
191#[cfg(test)]
192mod tests {
193    use super::*;
194
195    const FIXTURE: &str = r#"{
196        "hitCount": 1,
197        "resultList": {"result": [{
198            "id": "37654512",
199            "source": "MED",
200            "pmid": "37654512",
201            "doi": "10.1038/s41586-023-06600-8",
202            "title": "Biomedical superintelligence",
203            "pubYear": "2023",
204            "abstractText": "Large language models in medicine.",
205            "journalInfo": {"journal": {"title": "Nature"}},
206            "authorList": {"author": [{"fullName": "J. Zou"}, {"fullName": "R. B. Altman"}]}
207        }]}
208    }"#;
209
210    #[test]
211    fn parses_epmc_hit() {
212        let parsed: EpmcResponse = serde_json::from_str(FIXTURE).unwrap();
213        let mut hits = parsed.result_list.results.unwrap().into_iter();
214        let paper = hit_to_paper(hits.next().unwrap()).unwrap();
215        assert_eq!(paper.title, "Biomedical superintelligence");
216        assert_eq!(paper.year, Some(2023));
217        assert_eq!(paper.doi.as_deref(), Some("10.1038/s41586-023-06600-8"));
218        assert_eq!(paper.venue.as_deref(), Some("Nature"));
219        assert_eq!(paper.authors, vec!["J. Zou", "R. B. Altman"]);
220        assert!(!paper.abstract_text.is_empty());
221        assert_eq!(
222            paper.url.as_deref(),
223            Some("https://europepmc.org/article/MED/37654512")
224        );
225    }
226
227    #[test]
228    fn hit_without_title_is_skipped() {
229        let parsed: EpmcResponse =
230            serde_json::from_str(r#"{"resultList": {"result": [{"id": "1", "source": "PPR"}]}}"#)
231                .unwrap();
232        let hit = parsed
233            .result_list
234            .results
235            .unwrap()
236            .into_iter()
237            .next()
238            .unwrap();
239        assert!(hit_to_paper(hit).is_none());
240    }
241
242    #[test]
243    fn empty_result_list_ok() {
244        let parsed: EpmcResponse =
245            serde_json::from_str(r#"{"resultList": {"result": []}}"#).unwrap();
246        assert!(parsed.result_list.results.unwrap().is_empty());
247    }
248
249    #[test]
250    fn source_names() {
251        assert_eq!(EuropePmcSource::new().name(), "europepmc");
252        assert_eq!(PreprintSource::new().name(), "preprints");
253    }
254}
255
256#[cfg(test)]
257mod live_tests {
258    use super::*;
259
260    /// Live round-trip against the real Europe PMC API. Ignored by default:
261    /// it needs network access. Run explicitly with `cargo test -- --ignored`.
262    #[tokio::test]
263    #[ignore]
264    async fn live_search_returns_papers() {
265        let src = EuropePmcSource::new();
266        let papers = src.fetch_papers("CRISPR base editing", 3).await.unwrap();
267        println!("got {} papers", papers.len());
268        for p in &papers {
269            println!("- {} ({:?})", p.title, p.year);
270        }
271        assert!(!papers.is_empty());
272    }
273}