research_agent/adapters/
openalex_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 OpenAlexSource {
13 client: reqwest::Client,
14}
15
16impl OpenAlexSource {
17 pub fn new() -> Self {
18 Self {
19 client: reqwest::Client::new(),
20 }
21 }
22}
23
24impl Default for OpenAlexSource {
25 fn default() -> Self {
26 Self::new()
27 }
28}
29
30fn reconstruct_abstract(inverted: &std::collections::HashMap<String, Vec<usize>>) -> String {
33 let mut words: Vec<(usize, &str)> = inverted
34 .iter()
35 .flat_map(|(word, positions)| positions.iter().map(move |&pos| (pos, word.as_str())))
36 .collect();
37 words.sort_unstable_by_key(|(pos, _)| *pos);
38 words
39 .into_iter()
40 .map(|(_, word)| word)
41 .collect::<Vec<_>>()
42 .join(" ")
43}
44
45fn normalize_doi(doi: &str) -> String {
47 doi.strip_prefix("https://doi.org/")
48 .unwrap_or(doi)
49 .to_string()
50}
51
52fn openalex_work_id(id: &str) -> Option<String> {
54 id.rsplit('/')
55 .next()
56 .filter(|s| !s.is_empty())
57 .map(String::from)
58}
59
60#[derive(Deserialize)]
61struct OaResponse {
62 results: Option<Vec<OaWork>>,
63}
64
65#[derive(Deserialize)]
66struct OaWork {
67 id: Option<String>,
68 #[serde(default)]
69 display_name: Option<String>,
70 #[serde(default)]
71 publication_year: Option<u32>,
72 #[serde(default)]
73 doi: Option<String>,
74 #[serde(default)]
75 primary_location: Option<OaLocation>,
76 #[serde(default)]
77 authorships: Vec<OaAuthorship>,
78 #[serde(default)]
79 abstract_inverted_index: Option<std::collections::HashMap<String, Vec<usize>>>,
80 #[serde(default)]
81 referenced_works: Vec<String>,
82}
83
84fn work_to_paper(work: OaWork) -> Option<Paper> {
86 let title = work.display_name.filter(|t| !t.is_empty())?;
87 let mut paper = Paper::new(title);
88 paper.year = work.publication_year;
89 paper.doi = work.doi.as_deref().map(normalize_doi);
90 paper.venue = work
91 .primary_location
92 .as_ref()
93 .and_then(|loc| loc.source.as_ref())
94 .and_then(|src| src.display_name.clone());
95 paper.openalex_id = work.id.as_deref().and_then(openalex_work_id);
96 paper.abstract_text = work
97 .abstract_inverted_index
98 .as_ref()
99 .map(reconstruct_abstract)
100 .unwrap_or_default();
101 paper.authors = work
102 .authorships
103 .iter()
104 .filter_map(|a| a.author.as_ref().and_then(|au| au.display_name.clone()))
105 .collect();
106 paper.url = work.id.clone();
107 Some(paper)
108}
109
110#[derive(Deserialize)]
111struct OaLocation {
112 #[serde(default)]
113 source: Option<OaSource>,
114}
115
116#[derive(Deserialize)]
117struct OaSource {
118 #[serde(default)]
119 display_name: Option<String>,
120}
121
122#[derive(Deserialize)]
123struct OaAuthorship {
124 #[serde(default)]
125 author: Option<OaAuthor>,
126}
127
128#[derive(Deserialize)]
129struct OaAuthor {
130 #[serde(default)]
131 display_name: Option<String>,
132}
133
134#[async_trait]
135impl PaperSource for OpenAlexSource {
136 async fn fetch_papers(&self, query: &str, limit: usize) -> Result<Vec<Paper>> {
137 let url = format!(
138 "https://api.openalex.org/works?search={}&per-page={limit}",
139 percent_encode(query),
140 );
141 let resp = self
142 .client
143 .get(&url)
144 .header("User-Agent", "research-agent/0.1")
145 .send()
146 .await
147 .map_err(|e| ResearchError::Source(format!("OpenAlex request failed: {e}")))?;
148
149 let status = resp.status();
150 if !status.is_success() {
151 return Err(ResearchError::Source(format!(
152 "OpenAlex API returned HTTP {status}"
153 )));
154 }
155
156 let parsed: OaResponse = resp
157 .json()
158 .await
159 .map_err(|e| ResearchError::Source(format!("OpenAlex parse failed: {e}")))?;
160
161 Ok(parsed
162 .results
163 .unwrap_or_default()
164 .into_iter()
165 .filter_map(work_to_paper)
166 .collect())
167 }
168
169 fn name(&self) -> &str {
170 "openalex"
171 }
172}
173
174pub struct ReferencesSource {
178 client: reqwest::Client,
179}
180
181impl ReferencesSource {
182 pub fn new() -> Self {
183 Self {
184 client: reqwest::Client::new(),
185 }
186 }
187
188 pub async fn reference_ids(&self, paper: &Paper) -> Result<Vec<String>> {
191 let key = match (&paper.openalex_id, &paper.doi) {
192 (Some(id), _) => id.clone(),
193 (None, Some(doi)) => format!("doi:{doi}"),
194 (None, None) => {
195 return Err(ResearchError::Source(
196 "paper has no openalex_id or DOI; cannot resolve references".into(),
197 ));
198 }
199 };
200 let work = self.fetch_work(&key).await?;
201 Ok(work
202 .referenced_works
203 .iter()
204 .filter_map(|u| openalex_work_id(u))
205 .collect())
206 }
207
208 pub async fn citing_papers(&self, paper: &Paper) -> Result<Vec<Paper>> {
213 let work_id = match &paper.openalex_id {
214 Some(id) => id.clone(),
215 None => {
216 let doi = paper.doi.as_ref().ok_or_else(|| {
217 ResearchError::Source(
218 "paper has no openalex_id or DOI; cannot resolve citers".into(),
219 )
220 })?;
221 let work = self.fetch_work(&format!("doi:{doi}")).await?;
222 openalex_work_id(work.id.as_deref().unwrap_or_default()).ok_or_else(|| {
223 ResearchError::Source("OpenAlex returned a work without an id".into())
224 })?
225 }
226 };
227 let works = self
228 .fetch_results(&format!(
229 "https://api.openalex.org/works?filter=cites:{work_id}&per-page=200"
230 ))
231 .await?;
232 Ok(works.into_iter().filter_map(work_to_paper).collect())
233 }
234
235 pub async fn hydrate(&self, work_ids: &[String]) -> Result<Vec<Paper>> {
238 let mut papers = Vec::new();
239 for chunk in work_ids.chunks(50) {
240 let url = format!(
241 "https://api.openalex.org/works?filter=openalex_id:{}&per-page={}",
242 chunk.join("|"),
243 chunk.len(),
244 );
245 let works = self.fetch_results(&url).await?;
246 papers.extend(works.into_iter().filter_map(work_to_paper));
247 }
248 Ok(papers)
249 }
250
251 async fn fetch_results(&self, url: &str) -> Result<Vec<OaWork>> {
253 let resp = self
254 .client
255 .get(url)
256 .header("User-Agent", "research-agent/0.1")
257 .send()
258 .await
259 .map_err(|e| ResearchError::Source(format!("OpenAlex request failed: {e}")))?;
260 let status = resp.status();
261 if !status.is_success() {
262 return Err(ResearchError::Source(format!(
263 "OpenAlex API returned HTTP {status}"
264 )));
265 }
266 let parsed: OaResponse = resp
267 .json()
268 .await
269 .map_err(|e| ResearchError::Source(format!("OpenAlex parse failed: {e}")))?;
270 Ok(parsed.results.unwrap_or_default())
271 }
272
273 async fn fetch_work(&self, key: &str) -> Result<OaWork> {
274 let url = format!("https://api.openalex.org/works/{}", percent_encode(key));
275 let resp = self
276 .client
277 .get(&url)
278 .header("User-Agent", "research-agent/0.1")
279 .send()
280 .await
281 .map_err(|e| ResearchError::Source(format!("OpenAlex request failed: {e}")))?;
282 let status = resp.status();
283 if !status.is_success() {
284 return Err(ResearchError::Source(format!(
285 "OpenAlex API returned HTTP {status}"
286 )));
287 }
288 resp.json()
289 .await
290 .map_err(|e| ResearchError::Source(format!("OpenAlex parse failed: {e}")))
291 }
292}
293
294impl Default for ReferencesSource {
295 fn default() -> Self {
296 Self::new()
297 }
298}
299
300#[cfg(test)]
301mod tests {
302 use super::*;
303
304 const FIXTURE: &str = r#"{
305 "meta": {"count": 1},
306 "results": [{
307 "id": "https://openalex.org/W2741809807",
308 "doi": "https://doi.org/10.1038/nature12373",
309 "display_name": "Nanometre-scale thermometry in a living cell",
310 "publication_year": 2013,
311 "primary_location": {
312 "source": {"display_name": "Nature"}
313 },
314 "authorships": [
315 {"author": {"display_name": "G. Kucsko"}},
316 {"author": {"display_name": "P. C. Maurer"}}
317 ],
318 "abstract_inverted_index": {
319 "Heat": [0], "is": [1], "generated": [2], "by": [3], "the": [4, 9],
320 "cell": [5, 10]
321 }
322 }]
323 }"#;
324
325 #[test]
326 fn parses_openalex_work() {
327 let parsed: OaResponse = serde_json::from_str(FIXTURE).unwrap();
328 let works = parsed.results.unwrap();
329 assert_eq!(works.len(), 1);
330 let work = works.into_iter().next().unwrap();
331 assert_eq!(
332 openalex_work_id(work.id.as_deref().unwrap()).unwrap(),
333 "W2741809807"
334 );
335 assert_eq!(
336 work.doi.as_deref().map(normalize_doi).unwrap(),
337 "10.1038/nature12373"
338 );
339 assert_eq!(work.publication_year, Some(2013));
340 }
341
342 #[test]
343 fn reconstructs_abstract_from_inverted_index() {
344 let parsed: OaResponse = serde_json::from_str(FIXTURE).unwrap();
345 let work = &parsed.results.unwrap()[0];
346 let inverted = work.abstract_inverted_index.as_ref().unwrap();
347 let abstract_text = reconstruct_abstract(inverted);
348 assert_eq!(abstract_text, "Heat is generated by the cell the cell");
349 }
350
351 #[test]
352 fn missing_fields_tolerated() {
353 let parsed: OaResponse =
354 serde_json::from_str(r#"{"results": [{"id": "https://openalex.org/W1"}]}"#).unwrap();
355 let work = &parsed.results.unwrap()[0];
356 assert!(work.display_name.is_none());
357 assert!(work.abstract_inverted_index.is_none());
358 assert!(work.authorships.is_empty());
359 }
360
361 #[test]
362 fn source_name() {
363 assert_eq!(OpenAlexSource::new().name(), "openalex");
364 }
365
366 const REFS_FIXTURE: &str = r#"{
367 "id": "https://openalex.org/W2741809807",
368 "display_name": "Nanometre-scale thermometry in a living cell",
369 "referenced_works": [
370 "https://openalex.org/W1560783210",
371 "https://openalex.org/W1724212071"
372 ]
373 }"#;
374
375 #[test]
376 fn extracts_reference_ids_from_urls() {
377 let work: OaWork = serde_json::from_str(REFS_FIXTURE).unwrap();
378 let ids: Vec<String> = work
379 .referenced_works
380 .iter()
381 .filter_map(|u| openalex_work_id(u))
382 .collect();
383 assert_eq!(ids, vec!["W1560783210", "W1724212071"]);
384 }
385
386 #[test]
387 fn hydrates_referenced_works() {
388 let parsed: OaResponse = serde_json::from_str(
389 r#"{"results": [{"id": "https://openalex.org/W1560783210", "display_name": "Anatomy of green open access"}]}"#,
390 )
391 .unwrap();
392 let papers: Vec<Paper> = parsed
393 .results
394 .unwrap()
395 .into_iter()
396 .filter_map(work_to_paper)
397 .collect();
398 assert_eq!(papers.len(), 1);
399 assert_eq!(papers[0].openalex_id.as_deref(), Some("W1560783210"));
400 assert_eq!(papers[0].title, "Anatomy of green open access");
401 }
402}