research_agent/application/
references.rs1use crate::adapters::openalex_source::ReferencesSource;
2use crate::adapters::semantic_scholar_source::SemanticScholarSource;
3use crate::domain::citation::Citation;
4use crate::domain::paper::Paper;
5use crate::error::Result;
6use crate::ports::index_store::IndexStore;
7
8pub async fn sync_references(
14 store: &dyn IndexStore,
15 paper: &Paper,
16) -> Result<(Vec<Paper>, usize, usize)> {
17 let source = ReferencesSource::new();
18 let work_ids = source.reference_ids(paper).await?;
19 let hydrated = source.hydrate(&work_ids).await?;
20
21 let (citations, new_papers) = link_related(store, &paper.id, &hydrated, false)?;
22 let new_edges = store.insert_citations(&citations)?;
23 Ok((hydrated, new_edges, new_papers))
24}
25
26pub async fn sync_cited_by(
30 store: &dyn IndexStore,
31 paper: &Paper,
32) -> Result<(Vec<Paper>, usize, usize)> {
33 let source = ReferencesSource::new();
34 let citers = source.citing_papers(paper).await?;
35
36 let (citations, new_papers) = link_related(store, &paper.id, &citers, true)?;
37 let new_edges = store.insert_citations(&citations)?;
38 Ok((citers, new_edges, new_papers))
39}
40
41pub async fn sync_citation_intents(
54 store: &dyn IndexStore,
55 paper: &Paper,
56 reverse: bool,
57) -> Result<(usize, usize)> {
58 let source = SemanticScholarSource::new();
59 let direction = if reverse { "citations" } else { "references" };
60 let intents = source.citation_intents(paper, direction).await?;
61
62 let stored = if reverse {
63 store.citations_citing_paper(&paper.id)?
64 } else {
65 store.citations_for_paper(&paper.id)?
66 };
67
68 let mut labeled = Vec::new();
69 for intent in &intents {
70 let label = intent.label();
71 if label.is_empty() {
72 continue;
73 }
74 let other = match &intent.doi {
78 Some(doi) => store.find_paper_by_doi(doi)?,
79 None => None,
80 };
81 let Some(other) = other else { continue };
82 let pair_exists = stored.iter().any(|e| {
83 if reverse {
84 e.citing_paper_id == other.id
85 } else {
86 e.cited_paper_id == other.id
87 }
88 });
89 if !pair_exists {
90 continue;
91 }
92 let mut citation = if reverse {
93 Citation::new(other.id, paper.id.clone())
94 } else {
95 Citation::new(paper.id.clone(), other.id)
96 };
97 citation.context = label;
98 labeled.push(citation);
99 }
100
101 let updated = store.set_citation_contexts(&labeled)?;
102 Ok((updated, stored.len().saturating_sub(updated)))
103}
104
105fn link_related(
110 store: &dyn IndexStore,
111 paper_id: &str,
112 related: &[Paper],
113 reverse: bool,
114) -> Result<(Vec<Citation>, usize)> {
115 let mut citations = Vec::with_capacity(related.len());
116 let mut new_papers = 0usize;
117 for other in related {
118 let existing = match &other.openalex_id {
119 Some(id) => store.find_paper_by_openalex_id(id)?,
120 None => None,
121 };
122 let existing = match (&existing, &other.doi) {
123 (None, Some(doi)) => store.find_paper_by_doi(doi)?,
124 _ => existing,
125 };
126 let other_id = match existing {
127 Some(p) => p.id,
128 None => {
129 store.insert_paper(other)?;
130 new_papers += 1;
131 other.id.clone()
132 }
133 };
134 if other_id == paper_id {
137 continue;
138 }
139 citations.push(if reverse {
140 Citation::new(other_id, paper_id.to_string())
141 } else {
142 Citation::new(paper_id.to_string(), other_id)
143 });
144 }
145 Ok((citations, new_papers))
146}
147
148#[cfg(test)]
149mod tests {
150 use super::*;
151 use crate::adapters::sqlite_store::SqliteStore;
152 use crate::domain::paper::Paper;
153
154 #[tokio::test]
155 async fn unknown_identity_errors_before_any_write() {
156 let store = SqliteStore::open_in_memory().unwrap();
157 let paper = Paper::new("no ids".into());
158 let err = sync_references(&store, &paper).await.unwrap_err();
159 assert!(err.to_string().contains("no openalex_id or DOI"));
160 assert!(store.list_papers(None).unwrap().is_empty());
161 }
162
163 #[test]
166 fn dedupes_referenced_paper_by_openalex_id() {
167 let store = SqliteStore::open_in_memory().unwrap();
168 let mut existing = Paper::new("already here".into());
169 existing.openalex_id = Some("W1".into());
170 store.insert_paper(&existing).unwrap();
171
172 assert!(store.find_paper_by_openalex_id("W1").unwrap().is_some());
173 assert!(store.find_paper_by_openalex_id("W2").unwrap().is_none());
174 }
175
176 #[test]
180 fn link_related_builds_directional_edges() {
181 let store = SqliteStore::open_in_memory().unwrap();
182 let mut paper = Paper::new("anchor".into());
183 paper.openalex_id = Some("Wanchor".into());
184 store.insert_paper(&paper).unwrap();
185
186 let mut forward = Paper::new("forward ref".into());
187 forward.openalex_id = Some("W1".into());
188 let mut self_pair = Paper::new("same work, fresh record".into());
189 self_pair.openalex_id = Some("Wanchor".into());
190 let related = vec![forward.clone(), self_pair];
191
192 let (citations, new_papers) = link_related(&store, &paper.id, &related, false).unwrap();
193 assert_eq!(new_papers, 1, "only the forward ref is new");
194 assert_eq!(citations.len(), 1, "self-pair skipped");
195 assert_eq!(citations[0].citing_paper_id, paper.id);
196 assert_eq!(citations[0].cited_paper_id, forward.id);
197
198 let mut backward = Paper::new("backward citer".into());
199 backward.openalex_id = Some("W2".into());
200 let (citations, _) = link_related(&store, &paper.id, &[backward.clone()], true).unwrap();
201 assert_eq!(citations.len(), 1);
202 assert_eq!(citations[0].citing_paper_id, backward.id);
203 assert_eq!(citations[0].cited_paper_id, paper.id);
204 }
205
206 #[test]
209 fn set_citation_contexts_scoped_to_existing_edges() {
210 let store = SqliteStore::open_in_memory().unwrap();
211 let anchor = Paper::new("anchor".into());
212 let cited = Paper::new("cited".into());
213 store.insert_paper(&anchor).unwrap();
214 store.insert_paper(&cited).unwrap();
215 store
216 .insert_citations(&[Citation::new(anchor.id.clone(), cited.id.clone())])
217 .unwrap();
218
219 let mut labeled = Citation::new(anchor.id.clone(), cited.id.clone());
220 labeled.context = "methodology+influential".into();
221 let mut orphan = Citation::new(anchor.id.clone(), "ghost".into());
223 orphan.context = "background".into();
224
225 let updated = store.set_citation_contexts(&[labeled, orphan]).unwrap();
226 assert_eq!(updated, 1);
227 let edges = store.citations_for_paper(&anchor.id).unwrap();
228 assert_eq!(edges.len(), 1, "orphan pair did not insert an edge");
229 assert_eq!(edges[0].context, "methodology+influential");
230 }
231
232 #[tokio::test]
235 #[ignore]
236 async fn live_sync_citation_intents() {
237 let store = SqliteStore::open_in_memory().unwrap();
238 let mut paper = Paper::new("Nanometre-scale thermometry in a living cell".into());
239 paper.openalex_id = Some("W2741809807".into());
240 paper.doi = Some("10.1038/nature12373".into());
241 store.insert_paper(&paper).unwrap();
242
243 sync_references(&store, &paper).await.unwrap();
244 let (labeled, unlabeled) = sync_citation_intents(&store, &paper, false).await.unwrap();
245 let edges = store.citations_for_paper(&paper.id).unwrap();
248 assert_eq!(labeled + unlabeled, edges.len());
249 assert_eq!(
250 edges.iter().filter(|e| !e.context.is_empty()).count(),
251 labeled
252 );
253 }
254
255 #[tokio::test]
258 #[ignore]
259 async fn live_sync_references() {
260 let store = SqliteStore::open_in_memory().unwrap();
261 let mut paper = Paper::new("Nanometre-scale thermometry in a living cell".into());
262 paper.openalex_id = Some("W2741809807".into());
263 store.insert_paper(&paper).unwrap();
264
265 let (papers, new_edges, new_papers) = sync_references(&store, &paper).await.unwrap();
266 assert!(!papers.is_empty());
267 assert!(new_edges > 0);
268 assert!(new_papers > 0);
269 let edges = store.citations_for_paper(&paper.id).unwrap();
270 assert!(edges.len() <= papers.len());
273 for edge in &edges {
275 assert!(store.get_paper(&edge.cited_paper_id).unwrap().is_some());
276 }
277 }
278
279 #[tokio::test]
282 #[ignore]
283 async fn live_sync_cited_by() {
284 let store = SqliteStore::open_in_memory().unwrap();
285 let mut paper = Paper::new("Nanometre-scale thermometry in a living cell".into());
286 paper.openalex_id = Some("W2741809807".into());
287 store.insert_paper(&paper).unwrap();
288
289 let (papers, new_edges, _new_papers) = sync_cited_by(&store, &paper).await.unwrap();
290 assert!(!papers.is_empty());
291 assert!(new_edges > 0);
292 let edges = store.citations_citing_paper(&paper.id).unwrap();
293 assert!(!edges.is_empty());
294 for edge in &edges {
296 assert_eq!(edge.cited_paper_id, paper.id);
297 assert!(store.get_paper(&edge.citing_paper_id).unwrap().is_some());
298 }
299 }
300}