use serde::{Deserialize, Serialize};
use trusty_common::chat::{ChatEvent, ChatProvider, ToolDef};
use trusty_common::ChatMessage;
use crate::core::review::ReviewReport;
use crate::types::complexity::ComplexityGrade;
pub const DEFAULT_MODEL: &str = "openai/gpt-4o-mini";
pub const ENV_API_KEY: &str = "OPENROUTER_API_KEY";
pub const ENV_MODEL: &str = "TRUSTY_LLM_MODEL";
const MAX_FILES_IN_PROMPT: usize = 3;
const MAX_SMELLS_IN_PROMPT: usize = 5;
const MAX_RECS_IN_PROMPT: usize = 5;
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct DeepAnalysisReport {
pub index_id: String,
pub narrative: String,
pub frameworks: Vec<String>,
pub recommendations: Vec<String>,
pub model_used: String,
pub based_on: ReviewReport,
}
#[derive(Debug, thiserror::Error)]
pub enum DeepAnalysisError {
#[error("OPENROUTER_API_KEY is not set; deep analysis requires an OpenRouter API key")]
MissingApiKey,
#[error(
"AWS credentials not configured for Bedrock deep analysis — \
set AWS_ACCESS_KEY_ID / AWS_SECRET_ACCESS_KEY (or AWS_PROFILE, IAM role, SSO)"
)]
BedrockAuth,
#[error("LLM provider chat failed: {0}")]
Chat(String),
}
pub async fn explain_report(
report: &ReviewReport,
frameworks: &[String],
provider: &dyn ChatProvider,
) -> anyhow::Result<String> {
let prompt = build_explain_prompt(report, frameworks);
let messages = vec![
ChatMessage {
role: "system".to_string(),
content: SYSTEM_PROMPT.to_string(),
tool_call_id: None,
tool_calls: None,
},
ChatMessage {
role: "user".to_string(),
content: prompt,
tool_call_id: None,
tool_calls: None,
},
];
let (tx, mut rx) = tokio::sync::mpsc::channel::<ChatEvent>(32);
let stream_fut = provider.chat_stream(messages, Vec::<ToolDef>::new(), tx);
let drain = async {
let mut out = String::new();
let mut stream_error: Option<String> = None;
while let Some(event) = rx.recv().await {
match event {
ChatEvent::Delta(s) => out.push_str(&s),
ChatEvent::ToolCall(_) => {
}
ChatEvent::Done => break,
ChatEvent::Error(msg) => {
stream_error = Some(msg);
break;
}
}
}
if let Some(msg) = stream_error {
anyhow::bail!("chat provider stream error: {msg}");
}
Ok::<String, anyhow::Error>(out)
};
let (stream_res, narrative) = tokio::join!(stream_fut, drain);
stream_res?;
narrative
}
const SYSTEM_PROMPT: &str = "You are a code review assistant. Given structured \
metrics from a pull-request review (complexity grade, code smells, recommendations, \
and detected frameworks), explain the findings in 2-3 short paragraphs of plain prose. \
Focus on why these issues matter and the highest-priority next steps the developer \
should take. Be specific: reference the file paths, smell categories, and frameworks \
provided. Do not invent metrics that aren't in the input.";
pub fn build_explain_prompt(report: &ReviewReport, frameworks: &[String]) -> String {
let mut out = String::new();
out.push_str(&format!(
"Overall grade: {}\nSmell count: {}\nChanged lines: {}\nFiles reviewed: {}\n",
report.overall_grade,
report.smell_count,
report.changed_lines,
report.files.len(),
));
if !frameworks.is_empty() {
out.push_str(&format!("Detected frameworks: {}\n", frameworks.join(", ")));
}
out.push('\n');
out.push_str(&format!("Summary: {}\n\n", report.summary));
let mut worst: Vec<&_> = report.files.iter().collect();
worst.sort_by_key(|b| std::cmp::Reverse(b.grade));
worst.truncate(MAX_FILES_IN_PROMPT);
if !worst.is_empty() {
out.push_str("Worst files (worst first):\n");
for f in &worst {
out.push_str(&format!(
"- {} (grade {}, cyclomatic {}, cognitive {}, {} smell(s))\n",
f.path,
f.grade,
f.complexity.cyclomatic,
f.complexity.cognitive,
f.smells.len(),
));
}
out.push('\n');
}
let mut smells: Vec<(&str, &_)> = Vec::new();
for f in &report.files {
for s in &f.smells {
smells.push((f.path.as_str(), s));
if smells.len() >= MAX_SMELLS_IN_PROMPT {
break;
}
}
if smells.len() >= MAX_SMELLS_IN_PROMPT {
break;
}
}
if !smells.is_empty() {
out.push_str("Top smells:\n");
for (path, s) in &smells {
out.push_str(&format!(
"- {} at {}:{} (severity {})\n",
s.category, path, s.line, s.severity,
));
}
out.push('\n');
}
let recs: Vec<&str> = report
.files
.iter()
.flat_map(|f| f.recommendations.iter().map(String::as_str))
.take(MAX_RECS_IN_PROMPT)
.collect();
if !recs.is_empty() {
out.push_str("Existing static recommendations:\n");
for r in &recs {
out.push_str(&format!("- {r}\n"));
}
out.push('\n');
}
out.push_str(
"Write 2-3 short paragraphs explaining why these findings matter and the prioritised \
next steps for the developer. Reference specific files, smells, and (where relevant) \
the detected frameworks.",
);
if report.files.is_empty() && report.overall_grade == ComplexityGrade::A {
out.push_str(
"\n\nNote: this diff is empty or has no measurable findings; \
explain briefly that no review-worthy issues were detected.",
);
}
out
}
fn extract_recommendations(narrative: &str) -> Vec<String> {
let mut out = Vec::new();
for raw in narrative.lines() {
let line = raw.trim();
if let Some(rest) = strip_bullet_marker(line) {
let cleaned = rest.trim();
if !cleaned.is_empty() {
out.push(cleaned.to_string());
}
}
}
out
}
fn strip_bullet_marker(line: &str) -> Option<&str> {
for marker in ["- ", "* ", "• "] {
if let Some(rest) = line.strip_prefix(marker) {
return Some(rest);
}
}
if let Some(dot) = line.find('.') {
let head = &line[..dot];
if !head.is_empty() && head.chars().all(|c| c.is_ascii_digit()) {
let rest = &line[dot + 1..];
if let Some(stripped) = rest.strip_prefix(' ') {
return Some(stripped);
}
}
}
None
}
pub fn resolve_api_key(explicit: Option<&str>) -> Result<String, DeepAnalysisError> {
if let Some(k) = explicit.filter(|s| !s.is_empty()) {
return Ok(k.to_string());
}
std::env::var(ENV_API_KEY)
.ok()
.filter(|s| !s.is_empty())
.ok_or(DeepAnalysisError::MissingApiKey)
}
pub fn resolve_model(explicit: Option<&str>) -> String {
if let Some(m) = explicit.filter(|s| !s.is_empty()) {
return m.to_string();
}
std::env::var(ENV_MODEL)
.ok()
.filter(|s| !s.is_empty())
.unwrap_or_else(|| DEFAULT_MODEL.to_string())
}
pub(crate) fn classify_bedrock_error(e: &anyhow::Error) -> DeepAnalysisError {
let msg = format!("{e:#}");
let lower = msg.to_lowercase();
if lower.contains("credential") || lower.contains("no credentials") || lower.contains("aws") {
DeepAnalysisError::BedrockAuth
} else {
DeepAnalysisError::Chat(msg)
}
}
pub(crate) async fn explain_with_bedrock_provider(
index_id: &str,
report: ReviewReport,
frameworks: Vec<String>,
model: &str,
provider: &dyn ChatProvider,
) -> Result<DeepAnalysisReport, DeepAnalysisError> {
let narrative = explain_report(&report, &frameworks, provider)
.await
.map_err(|e| classify_bedrock_error(&e))?;
let recommendations = extract_recommendations(&narrative);
Ok(DeepAnalysisReport {
index_id: index_id.to_string(),
narrative,
frameworks,
recommendations,
model_used: model.to_string(),
based_on: report,
})
}
pub const BEDROCK_MODEL_PREFIX: &str = "bedrock/";
pub const DEFAULT_BEDROCK_MODEL_ID: &str = trusty_common::chat::DEFAULT_BEDROCK_MODEL;
pub async fn deep_analysis(
index_id: &str,
report: ReviewReport,
frameworks: Vec<String>,
api_key: Option<&str>,
model: Option<&str>,
) -> Result<DeepAnalysisReport, DeepAnalysisError> {
let model = resolve_model(model);
if model.starts_with(BEDROCK_MODEL_PREFIX) {
let bedrock_model_id = model
.strip_prefix(BEDROCK_MODEL_PREFIX)
.filter(|s| !s.is_empty())
.unwrap_or(DEFAULT_BEDROCK_MODEL_ID);
let region = std::env::var("TRUSTY_AWS_REGION")
.or_else(|_| std::env::var("AWS_REGION"))
.ok();
use trusty_common::chat::BedrockProvider;
let provider = BedrockProvider::new(bedrock_model_id, region.as_deref())
.await
.map_err(|e| DeepAnalysisError::Chat(format!("Bedrock provider init: {e:#}")))?;
explain_with_bedrock_provider(index_id, report, frameworks, &model, &provider).await
} else {
use trusty_common::chat::OpenRouterProvider;
let api_key = resolve_api_key(api_key)?;
let provider = OpenRouterProvider::new(api_key, &model);
let narrative = explain_report(&report, &frameworks, &provider)
.await
.map_err(|e| DeepAnalysisError::Chat(format!("{e:#}")))?;
let recommendations = extract_recommendations(&narrative);
Ok(DeepAnalysisReport {
index_id: index_id.to_string(),
narrative,
frameworks,
recommendations,
model_used: model,
based_on: report,
})
}
}
pub fn render_text(report: &DeepAnalysisReport) -> String {
let mut out = String::new();
out.push_str("=== Deep Analysis ===\n");
out.push_str(&format!("index: {}\n", report.index_id));
out.push_str(&format!("model: {}\n", report.model_used));
if report.frameworks.is_empty() {
out.push_str("frameworks: none detected\n");
} else {
out.push_str(&format!("frameworks: {}\n", report.frameworks.join(", ")));
}
out.push_str("\n--- Narrative ---\n");
out.push_str(report.narrative.trim());
out.push('\n');
if !report.recommendations.is_empty() {
out.push_str("\n--- Recommendations ---\n");
for r in &report.recommendations {
out.push_str(&format!(" - {r}\n"));
}
}
out.push_str(&format!(
"\n--- Based On ---\n{}\n",
report.based_on.summary
));
out
}
#[cfg(test)]
#[path = "explain_tests.rs"]
mod tests;