research_agent/application/
gap_analyzer.rs1use crate::domain::knowledge_gap::{GapType, KnowledgeGap};
2use crate::domain::paper::Paper;
3use crate::domain::research_state::ResearchState;
4use crate::domain::research_topic::ResearchTopic;
5use crate::error::Result;
6use crate::ports::index_store::IndexStore;
7use crate::ports::research_engine::ResearchEngine;
8use serde::Serialize;
9
10#[derive(Debug, Serialize)]
14pub struct TopicBrief {
15 pub topic: ResearchTopic,
16 pub state: Option<ResearchState>,
17 pub papers: Vec<Paper>,
18 pub recorded_gaps: Vec<KnowledgeGap>,
19}
20
21pub fn collect_brief(store: &dyn IndexStore, topic_id: &str) -> Result<TopicBrief> {
24 let topic = store.get_topic(topic_id)?.ok_or_else(|| {
25 crate::error::ResearchError::Validation(format!("topic '{topic_id}' not found"))
26 })?;
27 Ok(TopicBrief {
28 state: store.get_research_state(topic_id)?,
29 papers: store.list_papers_by_topic(topic_id, None)?,
30 recorded_gaps: store.list_gaps(Some(topic_id))?,
31 topic,
32 })
33}
34
35pub fn record_gaps(
39 store: &dyn IndexStore,
40 topic_id: &str,
41 gaps: &[(String, Option<String>, Option<f32>)],
42) -> Result<Vec<KnowledgeGap>> {
43 let mut saved = Vec::with_capacity(gaps.len());
44 for (description, gap_type, priority) in gaps {
45 if description.trim().is_empty() {
46 continue;
47 }
48 let gap_type = gap_type
49 .as_deref()
50 .map(GapType::from_str_lossy)
51 .unwrap_or(GapType::MissingLiterature);
52 let mut gap = KnowledgeGap::new(
53 description.trim().to_string(),
54 topic_id.to_string(),
55 gap_type,
56 );
57 if let Some(p) = priority {
58 gap.priority = p.clamp(0.0, 1.0);
59 }
60 store.insert_gap(&gap)?;
61 saved.push(gap);
62 }
63
64 if !saved.is_empty() {
65 let mut state = store
66 .get_research_state(topic_id)?
67 .unwrap_or_else(|| ResearchState::new(topic_id.to_string()));
68 state.gaps_identified = store.list_gaps(Some(topic_id))?.len() as i64;
69 state.last_updated = chrono::Utc::now().to_rfc3339();
70 store.update_research_state(&state)?;
71 }
72 Ok(saved)
73}
74
75pub struct GapAnalyzer<'a> {
76 engine: &'a dyn ResearchEngine,
77 store: &'a dyn IndexStore,
78}
79
80impl<'a> GapAnalyzer<'a> {
81 pub fn new(engine: &'a dyn ResearchEngine, store: &'a dyn IndexStore) -> Self {
82 Self { engine, store }
83 }
84
85 pub async fn analyze(&self, topic_id: &str) -> Result<Vec<KnowledgeGap>> {
86 let gaps = self.engine.analyze_gaps(topic_id).await?;
87
88 for gap in &gaps {
89 self.store.insert_gap(gap)?;
90 }
91
92 let state = ResearchState {
93 topic_id: topic_id.to_string(),
94 gaps_identified: gaps.len() as i64,
95 ..ResearchState::new(topic_id.to_string())
96 };
97 self.store.update_research_state(&state)?;
98
99 Ok(gaps)
100 }
101}
102
103#[cfg(test)]
104mod tests {
105 use super::*;
106 use crate::adapters::llm_research_engine::LlmResearchEngine;
107 use crate::adapters::sqlite_store::SqliteStore;
108 use crate::domain::research_topic::ResearchTopic;
109
110 #[test]
111 fn collect_brief_gathers_topic_data() {
112 let store = SqliteStore::open_in_memory().unwrap();
113 let topic = ResearchTopic::new("Test".into());
114 let topic_id = topic.id.clone();
115 store.insert_topic(&topic).unwrap();
116 let mut paper = crate::domain::paper::Paper::new("P".into());
117 store.insert_paper(&paper).unwrap();
118 paper.reading_status = crate::domain::paper::ReadingStatus::Queued;
119 let _ = paper;
120 store
121 .link_paper_to_topic(&store.list_papers(None).unwrap()[0].id, &topic_id, 0.9)
122 .unwrap();
123
124 let brief = collect_brief(&store, &topic_id).unwrap();
125 assert_eq!(brief.topic.id, topic_id);
126 assert_eq!(brief.papers.len(), 1);
127 assert_eq!(brief.recorded_gaps.len(), 0);
128
129 assert!(collect_brief(&store, "missing").is_err());
130 }
131
132 #[test]
133 fn record_gaps_persists_and_updates_state() {
134 let store = SqliteStore::open_in_memory().unwrap();
135 let topic = ResearchTopic::new("T".into());
136 let topic_id = topic.id.clone();
137 store.insert_topic(&topic).unwrap();
138
139 let saved = record_gaps(
140 &store,
141 &topic_id,
142 &[
143 ("missing optimization literature".into(), None, Some(1.2)),
144 ("".into(), None, None),
145 (
146 "open question".into(),
147 Some("unanswered_question".into()),
148 None,
149 ),
150 ],
151 )
152 .unwrap();
153 assert_eq!(saved.len(), 2);
155 assert_eq!(saved[0].priority, 1.0);
156 assert_eq!(
157 saved[1].gap_type,
158 crate::domain::knowledge_gap::GapType::UnansweredQuestion
159 );
160
161 let stored = store.list_gaps(Some(&topic_id)).unwrap();
162 assert_eq!(stored.len(), 2);
163 let state = store.get_research_state(&topic_id).unwrap().unwrap();
164 assert_eq!(state.gaps_identified, 2);
165 }
166
167 #[tokio::test]
168 async fn analyze_stores_gaps() {
169 let store = SqliteStore::open_in_memory().unwrap();
170 let topic = ResearchTopic::new("Test".into());
171 let topic_id = topic.id.clone();
172 store.insert_topic(&topic).unwrap();
173
174 let engine = LlmResearchEngine::new(Box::new(SqliteStore::open_in_memory().unwrap()));
175 let analyzer = GapAnalyzer::new(&engine, &store);
176
177 let gaps = analyzer.analyze(&topic_id).await.unwrap();
178 assert_eq!(gaps.len(), 2);
179
180 let stored = store.list_gaps(Some(&topic_id)).unwrap();
181 assert_eq!(stored.len(), 2);
182 }
183}