Skip to main content

research_agent/application/
gap_analyzer.rs

1use 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/// Everything an agent needs to reason about one topic's coverage, collected
11/// without any LLM call. The calling agent analyzes the brief with its own
12/// model and reports back through [`record_gaps`].
13#[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
21/// Collect the brief for one topic. Store-only; safe to call from hosts with
22/// no LLM configured.
23pub 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
35/// Persist agent-identified gaps for a topic and refresh the topic's gap
36/// counter. Descriptions must be unique enough to be useful; type defaults to
37/// `missing_literature` when absent or unrecognized, priority clamps to 0..=1.
38pub 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        // Empty descriptions are skipped; priority clamps into 0..=1.
154        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}