research_agent/application/
report_generator.rs1use 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#[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
24fn 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
49pub 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 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}