Skip to main content

research_agent/application/
report_generator.rs

1use crate::application::gap_analyzer::TopicBrief;
2use crate::domain::research_report::{ReportSection, ResearchReport};
3use crate::error::Result;
4use crate::ports::index_store::IndexStore;
5use crate::ports::research_engine::ResearchEngine;
6use serde::Serialize;
7
8/// Source material for an agent-authored report: one brief per topic. The
9/// agent drafts the markdown with its own model and stores it via
10/// [`save_report`].
11#[derive(Debug, Serialize)]
12pub struct ReportMaterial {
13    pub topics: Vec<TopicBrief>,
14}
15
16pub fn collect_material(store: &dyn IndexStore, topic_ids: &[String]) -> Result<ReportMaterial> {
17    let topics = topic_ids
18        .iter()
19        .map(|id| crate::application::gap_analyzer::collect_brief(store, id))
20        .collect::<Result<Vec<_>>>()?;
21    Ok(ReportMaterial { topics })
22}
23
24/// Split markdown into sections on `## ` headings (content before the first
25/// heading becomes the single intro section).
26fn sections_from_markdown(markdown: &str) -> Vec<ReportSection> {
27    let mut sections: Vec<ReportSection> = Vec::new();
28    for line in markdown.lines() {
29        if let Some(heading) = line.strip_prefix("## ").map(str::trim) {
30            sections.push(ReportSection {
31                heading: heading.to_string(),
32                content: String::new(),
33            });
34        } else if let Some(last) = sections.last_mut() {
35            if !last.content.is_empty() {
36                last.content.push('\n');
37            }
38            last.content.push_str(line);
39        } else if !line.trim().is_empty() {
40            sections.push(ReportSection {
41                heading: String::new(),
42                content: line.to_string(),
43            });
44        }
45    }
46    sections
47}
48
49/// Store an agent-authored markdown report over the given topics.
50pub fn save_report(
51    store: &dyn IndexStore,
52    title: &str,
53    topic_ids: &[String],
54    markdown: &str,
55) -> Result<ResearchReport> {
56    let mut report = ResearchReport::new(title.to_string(), topic_ids.to_vec());
57    report.sections = sections_from_markdown(markdown);
58    store.insert_report(&report)?;
59    Ok(report)
60}
61
62pub struct ReportGenerator<'a> {
63    engine: &'a dyn ResearchEngine,
64    store: &'a dyn IndexStore,
65}
66
67impl<'a> ReportGenerator<'a> {
68    pub fn new(engine: &'a dyn ResearchEngine, store: &'a dyn IndexStore) -> Self {
69        Self { engine, store }
70    }
71
72    pub async fn generate(&self, title: &str, topic_ids: &[String]) -> Result<ResearchReport> {
73        let report = self.engine.generate_report(title, topic_ids).await?;
74        self.store.insert_report(&report)?;
75        Ok(report)
76    }
77}
78
79#[cfg(test)]
80mod tests {
81    use super::*;
82    use crate::adapters::llm_research_engine::LlmResearchEngine;
83    use crate::adapters::sqlite_store::SqliteStore;
84    use crate::domain::research_topic::ResearchTopic;
85
86    #[test]
87    fn save_report_splits_markdown_sections() {
88        let store = SqliteStore::open_in_memory().unwrap();
89        let topic = ResearchTopic::new("T".into());
90        let topic_id = topic.id.clone();
91        store.insert_topic(&topic).unwrap();
92
93        let report = save_report(
94            &store,
95            "Survey",
96            &[topic_id],
97            "# Survey\n\nIntro line.\n\n## Methods\n\nWe reviewed.\n\n## Findings\n\nThree gaps.",
98        )
99        .unwrap();
100        assert_eq!(report.title, "Survey");
101        // Intro paragraph becomes its own section, then one per '## ' heading.
102        assert_eq!(report.sections.len(), 3);
103        assert_eq!(report.sections[1].heading, "Methods");
104        assert!(report.sections[2].content.contains("Three gaps"));
105
106        assert_eq!(store.list_reports(None).unwrap().len(), 1);
107    }
108
109    #[tokio::test]
110    async fn generate_stores_report() {
111        let store = SqliteStore::open_in_memory().unwrap();
112        let topic = ResearchTopic::new("Test".into());
113        let topic_id = topic.id.clone();
114        store.insert_topic(&topic).unwrap();
115
116        let engine = LlmResearchEngine::new(Box::new(SqliteStore::open_in_memory().unwrap()));
117        let generator = ReportGenerator::new(&engine, &store);
118
119        let report = generator.generate("Report", &[topic_id]).await.unwrap();
120        assert_eq!(report.title, "Report");
121
122        let stored = store.list_reports(None).unwrap();
123        assert_eq!(stored.len(), 1);
124    }
125}