use std::sync::Arc;
use std::time::Instant;
use serde::Deserialize;
use tracing::{debug, warn};
use crate::llm::{
ChatMessage, LlmProvider, LlmRequest, LlmResponse, ResponseSchema, strip_provider_prefix,
};
use crate::models::{Effort, Finding};
use crate::profile::types::{LongitudinalFinding, PeriodBatch, TokenCostSummary};
const PERIOD_REVIEWER_TEMPERATURE: f32 = 0.2;
const PERIOD_REVIEWER_MAX_TOKENS: u32 = 2048;
pub struct BatchReviewer {
llm: Arc<dyn LlmProvider>,
model: String,
}
impl BatchReviewer {
pub fn new(llm: Arc<dyn LlmProvider>, model: impl Into<String>) -> Self {
Self {
llm,
model: model.into(),
}
}
pub async fn review_period(
&self,
batch: &PeriodBatch,
cost_out: &mut TokenCostSummary,
) -> Vec<LongitudinalFinding> {
let req = build_period_prompt(batch, &self.model);
let start = Instant::now();
let resp = match self.llm.complete(req).await {
Ok(r) => r,
Err(e) => {
warn!(
period = %batch.stats.period_label,
error = %e,
"batch_reviewer: LLM call failed — returning empty findings (fail-safe)"
);
return Vec::new();
}
};
let latency = start.elapsed().as_millis() as u64;
cost_out.accumulate(
resp.input_tokens as u64,
resp.output_tokens as u64,
resp.cost_usd,
latency,
);
debug!(
period = %batch.stats.period_label,
input_tokens = resp.input_tokens,
output_tokens = resp.output_tokens,
latency_ms = latency,
"batch_reviewer: LLM call complete"
);
parse_period_findings(&resp, &batch.stats.period_label)
}
}
fn period_findings_schema() -> ResponseSchema {
ResponseSchema {
name: "period_findings".to_string(),
schema: serde_json::json!({
"type": "object",
"properties": {
"findings": {
"type": "array",
"items": {
"type": "object",
"properties": {
"kind": {"type": "string"},
"description": {"type": "string"},
"suggestion": {"type": "string"},
"confidence": {"type": "number", "minimum": 0.0, "maximum": 1.0},
"file": {"type": "string"},
"severity": {
"type": "string",
"enum": ["low", "medium", "high", "critical"]
}
},
"required": ["kind", "description"]
}
}
},
"required": ["findings"]
}),
}
}
pub fn build_period_prompt(batch: &PeriodBatch, model: &str) -> LlmRequest {
let system = period_reviewer_system_prompt();
let user = build_period_user_message(batch);
LlmRequest {
model: strip_provider_prefix(model).to_string(),
system: system.to_string(),
messages: vec![ChatMessage {
role: "user".to_string(),
content: user,
}],
temperature: PERIOD_REVIEWER_TEMPERATURE,
max_tokens: PERIOD_REVIEWER_MAX_TOKENS,
response_schema: Some(period_findings_schema()),
}
}
pub(super) fn period_reviewer_system_prompt() -> &'static str {
r#"You are a senior software engineer reviewing a sample of one engineer's commits
over a specific time window as part of a longitudinal quality analysis.
## Task
Identify code-quality findings present in the sampled diffs. Focus on:
- Correctness bugs, error-handling gaps, resource leaks
- Security weaknesses (injection, auth, secrets in code)
- Logic errors, off-by-one issues, data-loss risks
- Missing tests or test-quality issues
- Recurring anti-patterns visible across multiple commits in this window
## Output (REQUIRED)
Populate the structured response with a `findings` array.
Each finding must include:
- `kind`: short category label (e.g. error_handling, security, logic)
- `description`: concise description of the issue observed
- `suggestion`: concrete improvement suggestion
- `confidence`: float in [0.0, 1.0]
- `file`: most relevant file path; use "multiple" if the issue spans files
- `severity`: one of low, medium, high, critical
`findings` may be an empty array if the sample looks clean."#
}
fn build_period_user_message(batch: &PeriodBatch) -> String {
const MAX_DIFFS_IN_PROMPT: usize = 10;
let s = &batch.stats;
let mut msg = String::with_capacity(4096);
msg.push_str(&format!(
"## Period: {}\nFrom {} to {}\n\n",
s.period_label, s.since, s.until
));
msg.push_str("### Statistics\n");
msg.push_str(&format!("- Commits: {}\n", s.commit_count));
msg.push_str(&format!("- Quality score: {:.2}\n", s.quality_score));
msg.push_str(&format!("- Ticketed %: {:.0}%\n", s.ticketed_pct * 100.0));
if !s.categories.is_empty() {
let mut cats: Vec<(&String, &u64)> = s.categories.iter().collect();
cats.sort_by_key(|(k, _)| k.as_str());
let cat_str: Vec<String> = cats.iter().map(|(k, v)| format!("{k}={v}")).collect();
msg.push_str(&format!("- Categories: {}\n", cat_str.join(", ")));
}
if !s.repositories.is_empty() {
msg.push_str(&format!("- Repositories: {}\n", s.repositories.join(", ")));
}
msg.push('\n');
if batch.sampled_diffs.is_empty() {
msg.push_str("### Sampled diffs\n*(no diffs available for this period)*\n\n");
} else {
msg.push_str("### Sampled diffs\n\n");
for (i, diff) in batch
.sampled_diffs
.iter()
.enumerate()
.take(MAX_DIFFS_IN_PROMPT)
{
let cat = diff.category.as_deref().unwrap_or("unknown");
let effort = diff.effort.as_deref().unwrap_or("?");
msg.push_str(&format!(
"#### Diff {} — {} ({repo}) [category={cat}, effort={effort}]\n",
i + 1,
&diff.sha[..8.min(diff.sha.len())],
repo = diff.repository,
));
msg.push_str(&format!("Commit: {}\n\n", diff.message));
msg.push_str("```diff\n");
msg.push_str(&diff.diff_text);
if !diff.diff_text.ends_with('\n') {
msg.push('\n');
}
msg.push_str("```\n\n");
}
}
msg.push_str(
"Please review the diffs above and populate the structured `findings` \
array as specified in the system prompt.\n",
);
msg
}
#[derive(Debug, Deserialize)]
struct PeriodFindingsBlock {
#[serde(default)]
findings: Vec<PeriodFindingWire>,
}
#[derive(Debug, Deserialize)]
struct PeriodFindingWire {
#[serde(default)]
kind: String,
#[serde(default)]
description: String,
#[serde(default)]
suggestion: String,
#[serde(default)]
confidence: f32,
#[serde(default)]
file: String,
#[serde(default)]
severity: String,
}
fn parse_period_findings(resp: &LlmResponse, period_label: &str) -> Vec<LongitudinalFinding> {
let body = resp.text.trim();
if body.is_empty() {
warn!(period = %period_label, "batch_reviewer: empty LLM response — returning empty findings");
return Vec::new();
}
if body.starts_with('{')
&& let Ok(block) = serde_json::from_str::<PeriodFindingsBlock>(body)
{
debug!(
period = %period_label,
findings = block.findings.len(),
"batch_reviewer: parsed via direct JSON (structured output)"
);
return convert_period_block(block, period_label);
}
let Some(fence_start) = body.rfind("```json") else {
warn!(
period = %period_label,
"batch_reviewer: no JSON block found in LLM response — returning empty findings"
);
return Vec::new();
};
let after = &body[fence_start + 7..];
let Some(fence_end) = after.find("```") else {
warn!(period = %period_label, "batch_reviewer: unclosed JSON block — returning empty findings");
return Vec::new();
};
let json_text = after[..fence_end].trim();
let block: PeriodFindingsBlock = match serde_json::from_str(json_text) {
Ok(b) => b,
Err(e) => {
warn!(
period = %period_label,
error = %e,
"batch_reviewer: JSON parse error — returning empty findings"
);
return Vec::new();
}
};
convert_period_block(block, period_label)
}
fn convert_period_block(
block: PeriodFindingsBlock,
period_label: &str,
) -> Vec<LongitudinalFinding> {
block
.findings
.into_iter()
.map(|f| {
let effort = severity_to_effort(&f.severity);
let file = if f.file.is_empty() {
"unknown".to_string()
} else {
f.file
};
let kind = if f.kind.is_empty() {
"general".to_string()
} else {
f.kind
};
LongitudinalFinding {
period_label: period_label.to_string(),
finding: Finding::new(
file,
kind,
f.description,
f.suggestion,
f.confidence,
effort,
),
trend_tag: None,
}
})
.collect()
}
fn severity_to_effort(severity: &str) -> Effort {
match severity.to_lowercase().as_str() {
"high" | "critical" => Effort::High,
"medium" => Effort::Medium,
_ => Effort::Low,
}
}
#[cfg(test)]
#[path = "batch_reviewer_tests.rs"]
mod tests;