1use convergio_db::pool::ConnPool;
7use serde_json::json;
8
9use crate::engine_research::{build_generation_prompt, build_search_queries, call_inference};
10use crate::template;
11use crate::types::{ReportStatus, ReportType};
12
13const AGENT_ID: &str = "ctt-report-engine";
14
15pub async fn generate(pool: &ConnPool, report_id: &str) {
17 tracing::info!(report_id, "CTT report generation starting");
18
19 let row = match load_report(pool, report_id) {
20 Some(r) => r,
21 None => {
22 tracing::error!(report_id, "report not found");
23 return;
24 }
25 };
26
27 let report_type: ReportType =
28 serde_json::from_value(json!(row.report_type_str)).unwrap_or(ReportType::General);
29 let date = template::report_date();
30
31 update_status(pool, report_id, ReportStatus::Researching);
33 let research = run_research(pool, report_id, &row.topic, report_type).await;
34
35 update_status(pool, report_id, ReportStatus::Generating);
37 let content = run_generation(
38 &row.topic,
39 report_type,
40 &date,
41 &row.depth,
42 row.audience.as_deref(),
43 row.extra_context.as_deref(),
44 &research,
45 )
46 .await;
47
48 let pdf_path = if row.format_str == "pdf" {
50 update_status(pool, report_id, ReportStatus::Compiling);
51 let latex_content =
52 crate::latex::markdown_to_latex(&content, &row.topic, report_type, &date);
53 let slug = crate::latex::topic_slug(&row.topic);
54 let filename = format!("ctt-{slug}-{}", chrono::Utc::now().format("%Y%m%d"));
55 let output_dir = std::path::PathBuf::from("/tmp/ctt-reports");
56 match crate::pdf_compiler::compile_pdf(&latex_content, &output_dir, &filename) {
57 Ok(path) => {
58 tracing::info!(report_id, ?path, "PDF compiled");
59 Some(path.to_string_lossy().to_string())
60 }
61 Err(e) => {
62 tracing::warn!(report_id, error = %e, "PDF compilation failed, markdown still saved");
63 None
64 }
65 }
66 } else {
67 None
68 };
69
70 let word_count = content.split_whitespace().count();
72 let section_count = content.matches("\n## ").count();
73 let metadata = json!({
74 "word_count": word_count,
75 "section_count": section_count,
76 "source_count": 0,
77 "format": row.format_str,
78 "depth": row.depth,
79 "pdf_compiled": pdf_path.is_some(),
80 });
81
82 save_content(pool, report_id, &content, &metadata.to_string());
83 if let Some(ref path) = pdf_path {
84 save_pdf_path(pool, report_id, path);
85 }
86 update_status(pool, report_id, ReportStatus::Completed);
87 tracing::info!(report_id, word_count, "CTT report generation complete");
88}
89
90async fn run_research(
91 _pool: &ConnPool,
92 report_id: &str,
93 topic: &str,
94 report_type: ReportType,
95) -> String {
96 let queries = build_search_queries(topic, report_type);
97 let mut research_parts = Vec::new();
98
99 for query in &queries {
100 let prompt = format!(
101 "Search the web for: {query}\n\
102 Return factual, well-sourced information. Include URLs where available."
103 );
104 match call_inference(&prompt, AGENT_ID).await {
105 Ok(result) if !result.is_empty() => {
106 research_parts.push(result);
107 }
108 Ok(_) => {
109 tracing::debug!(query, "empty research result");
110 }
111 Err(e) => {
112 tracing::warn!(query, error = %e, "research query failed");
113 }
114 }
115 }
116
117 if research_parts.is_empty() {
118 tracing::warn!(report_id, "no research data gathered — using topic only");
119 format!("Topic: {topic}. No additional research data available.")
120 } else {
121 research_parts.join("\n\n---\n\n")
122 }
123}
124
125async fn run_generation(
126 topic: &str,
127 report_type: ReportType,
128 date: &str,
129 depth: &str,
130 audience: Option<&str>,
131 extra_context: Option<&str>,
132 research: &str,
133) -> String {
134 let prompt =
135 build_generation_prompt(topic, report_type, depth, audience, extra_context, research);
136
137 let llm_content = match call_inference(&prompt, AGENT_ID).await {
138 Ok(content) if !content.is_empty() => content,
139 Ok(_) | Err(_) => {
140 tracing::warn!("LLM generation failed, using fallback template");
141 fallback_content(topic, report_type, date)
142 }
143 };
144
145 let mut md = template::format_header(topic, report_type, date);
147 md.push_str(&llm_content);
148 md.push_str(&template::format_sources(&[]));
149 md.push_str(&template::format_disclaimer());
150 md.push_str(&template::format_footer(topic, date));
151 md
152}
153
154fn fallback_content(topic: &str, report_type: ReportType, _date: &str) -> String {
155 format!(
156 "## Executive Summary\n\n\
157 This {label} report on **{topic}** was generated by {brand}. \
158 The inference service was unavailable during generation; \
159 this is a structural template that will be populated when \
160 the service is restored.\n\n\
161 ## Key Takeaways\n\n\
162 1. Report infrastructure is operational.\n\
163 2. CTT branding and disclaimer applied.\n\
164 3. Full research-backed content pending inference availability.\n\n",
165 label = report_type.label(),
166 brand = template::CTT_BRAND,
167 )
168}
169
170struct ReportRow {
173 topic: String,
174 report_type_str: String,
175 format_str: String,
176 depth: String,
177 audience: Option<String>,
178 extra_context: Option<String>,
179}
180
181fn load_report(pool: &ConnPool, report_id: &str) -> Option<ReportRow> {
182 let conn = pool.get().ok()?;
183 conn.query_row(
184 "SELECT topic, report_type, format, depth, audience, extra_context \
185 FROM reports WHERE id = ?1",
186 rusqlite::params![report_id],
187 |r| {
188 Ok(ReportRow {
189 topic: r.get(0)?,
190 report_type_str: r.get(1)?,
191 format_str: r.get(2)?,
192 depth: r.get(3)?,
193 audience: r.get(4)?,
194 extra_context: r.get(5)?,
195 })
196 },
197 )
198 .ok()
199}
200
201fn update_status(pool: &ConnPool, report_id: &str, status: ReportStatus) {
202 if let Ok(conn) = pool.get() {
203 let status_str = status.to_string();
204 let _ = conn.execute(
205 "UPDATE reports SET status = ?1 WHERE id = ?2",
206 rusqlite::params![status_str, report_id],
207 );
208 }
209}
210
211fn save_content(pool: &ConnPool, report_id: &str, content: &str, metadata: &str) {
212 if let Ok(conn) = pool.get() {
213 let _ = conn.execute(
214 "UPDATE reports SET content_md = ?1, metadata_json = ?2, \
215 completed_at = datetime('now') WHERE id = ?3",
216 rusqlite::params![content, metadata, report_id],
217 );
218 }
219}
220
221fn save_pdf_path(pool: &ConnPool, report_id: &str, pdf_path: &str) {
222 if let Ok(conn) = pool.get() {
223 let _ = conn.execute(
224 "UPDATE reports SET pdf_path = ?1 WHERE id = ?2",
225 rusqlite::params![pdf_path, report_id],
226 );
227 }
228}