Skip to main content

convergio_reports/
engine.rs

1//! Report generation engine — orchestrates research, LLM, and formatting.
2//!
3//! Pipeline: load → research → synthesize → generate → format → store.
4//! Research and LLM calls are in engine_research.rs.
5
6use 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
15/// Run the full report generation pipeline for a given report ID.
16pub 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    // Phase 1: Research
32    update_status(pool, report_id, ReportStatus::Researching);
33    let research = run_research(pool, report_id, &row.topic, report_type).await;
34
35    // Phase 2: Generate via LLM
36    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    // Phase 3: PDF compilation (if requested)
49    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    // Phase 4: Store result
71    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    // Wrap with CTT branding
146    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
170// --- DB helpers ---
171
172struct 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}