research_agent/adapters/
europepmc_source.rs1use 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
9pub 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
33pub 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
53async 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 #[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}