use std::sync::Arc;
use std::time::Instant;
use serde::Deserialize;
use tracing::{debug, warn};
use crate::llm::{LlmProvider, LlmResponse};
use crate::profile::types::{
ContributorProfile, LongitudinalFinding, PeriodBatch, Trajectory, TrendTag,
};
pub use synthesizer_prompt::build_synthesizer_prompt;
#[path = "synthesizer_prompt.rs"]
mod synthesizer_prompt;
const SYNTHESIZER_TEMPERATURE: f32 = 0.3;
const SYNTHESIZER_MAX_TOKENS: u32 = 2048;
const JACCARD_THRESHOLD: f64 = 0.7;
pub struct Synthesizer {
llm: Arc<dyn LlmProvider>,
model: String,
}
impl Synthesizer {
pub fn new(llm: Arc<dyn LlmProvider>, model: impl Into<String>) -> Self {
Self {
llm,
model: model.into(),
}
}
pub async fn synthesize(
&self,
mut profile: ContributorProfile,
all_period_findings: Vec<Vec<LongitudinalFinding>>,
periods: &[PeriodBatch],
) -> ContributorProfile {
profile.quality_trend = periods
.iter()
.map(|b| (b.stats.period_label.clone(), b.stats.quality_score))
.collect();
let flat: Vec<LongitudinalFinding> = all_period_findings.into_iter().flatten().collect();
let tagged = assign_trend_tags(flat);
profile.all_findings = tagged;
profile.improvement_trajectory = derive_trajectory(&profile.quality_trend);
let start = Instant::now();
let req = build_synthesizer_prompt(&profile, &self.model);
match self.llm.complete(req).await {
Ok(resp) => {
let latency = start.elapsed().as_millis() as u64;
profile.token_cost.accumulate(
resp.input_tokens as u64,
resp.output_tokens as u64,
resp.cost_usd,
latency,
);
debug!(
input_tokens = resp.input_tokens,
output_tokens = resp.output_tokens,
"synthesizer: LLM call complete"
);
apply_llm_synthesis(&mut profile, &resp);
}
Err(e) => {
warn!(
error = %e,
"synthesizer: LLM call failed — using deterministic fallback (fail-safe)"
);
apply_fallback_narrative(&mut profile);
}
}
profile
}
}
pub fn assign_trend_tags(findings: Vec<LongitudinalFinding>) -> Vec<LongitudinalFinding> {
if findings.is_empty() {
return findings;
}
let mut period_order: Vec<String> = Vec::new();
for f in &findings {
if !period_order.contains(&f.period_label) {
period_order.push(f.period_label.clone());
}
}
let latest_period = period_order.last().cloned().unwrap_or_default();
let mut clusters: Vec<Vec<usize>> = Vec::new(); let mut assigned = vec![false; findings.len()];
for i in 0..findings.len() {
if assigned[i] {
continue;
}
let mut cluster = vec![i];
assigned[i] = true;
for j in (i + 1)..findings.len() {
if assigned[j] {
continue;
}
if jaccard_similarity(
&findings[i].finding.description,
&findings[j].finding.description,
) >= JACCARD_THRESHOLD
{
cluster.push(j);
assigned[j] = true;
}
}
clusters.push(cluster);
}
let mut tagged = findings;
for cluster in &clusters {
let periods_in_cluster: Vec<&str> = cluster
.iter()
.map(|&idx| tagged[idx].period_label.as_str())
.collect();
let in_latest = periods_in_cluster.contains(&latest_period.as_str());
let in_earlier = periods_in_cluster
.iter()
.any(|&p| p != latest_period.as_str());
let worsening = if in_latest && in_earlier && cluster.len() >= 2 {
let first_conf = tagged[cluster[0]].finding.confidence;
let last_idx = cluster[cluster.len() - 1];
let last_conf = tagged[last_idx].finding.confidence;
last_conf > first_conf + 0.1
} else {
false
};
let tag = if worsening {
TrendTag::Worsening
} else if in_latest && in_earlier {
TrendTag::Recurring
} else if in_latest && !in_earlier {
TrendTag::New
} else {
TrendTag::Resolved
};
for &idx in cluster {
tagged[idx].trend_tag = Some(tag.clone());
}
}
tagged
}
pub fn jaccard_similarity(a: &str, b: &str) -> f64 {
let tokens_a = tokenize(a);
let tokens_b = tokenize(b);
if tokens_a.is_empty() && tokens_b.is_empty() {
return 1.0;
}
if tokens_a.is_empty() || tokens_b.is_empty() {
return 0.0;
}
let mut intersection = 0usize;
for t in &tokens_a {
if tokens_b.contains(t) {
intersection += 1;
}
}
let union = tokens_a.len() + tokens_b.len() - intersection;
intersection as f64 / union as f64
}
fn tokenize(s: &str) -> Vec<String> {
s.split(|c: char| !c.is_alphanumeric())
.filter(|t| !t.is_empty())
.map(|t| t.to_lowercase())
.collect()
}
pub fn derive_trajectory(quality_trend: &[(String, f64)]) -> Trajectory {
if quality_trend.len() < 2 {
return Trajectory::Stable;
}
let n = quality_trend.len() as f64;
let sum_x: f64 = (0..quality_trend.len()).map(|i| i as f64).sum();
let sum_y: f64 = quality_trend.iter().map(|(_, s)| s).sum();
let sum_xy: f64 = quality_trend
.iter()
.enumerate()
.map(|(i, (_, s))| i as f64 * s)
.sum();
let sum_xx: f64 = (0..quality_trend.len())
.map(|i| (i as f64) * (i as f64))
.sum();
let denom = n * sum_xx - sum_x * sum_x;
if denom.abs() < f64::EPSILON {
return Trajectory::Stable;
}
let slope = (n * sum_xy - sum_x * sum_y) / denom;
if slope > 0.1 {
Trajectory::Improving
} else if slope < -0.1 {
Trajectory::Declining
} else {
Trajectory::Stable
}
}
fn apply_llm_synthesis(profile: &mut ContributorProfile, resp: &LlmResponse) {
#[derive(Deserialize)]
struct SynthesisBlock {
#[serde(default)]
strengths: Vec<String>,
#[serde(default)]
recurring_weaknesses: Vec<String>,
#[serde(default)]
improvement_trajectory: String,
#[serde(default)]
narrative: String,
}
let body = resp.text.trim();
let block_opt: Option<SynthesisBlock> = if body.starts_with('{') {
serde_json::from_str(body).ok()
} else {
None
};
let block_opt = block_opt.or_else(|| {
let fence_start = body.rfind("```json")?;
let after = &body[fence_start + 7..];
let fence_end = after.find("```")?;
let json_text = after[..fence_end].trim();
match serde_json::from_str::<SynthesisBlock>(json_text) {
Ok(b) => Some(b),
Err(e) => {
warn!(error = %e, "synthesizer: JSON parse error (fence path)");
None
}
}
});
let block = match block_opt {
Some(b) => b,
None => {
warn!("synthesizer: no parseable JSON in LLM response — applying fallback narrative");
apply_fallback_narrative(profile);
return;
}
};
profile.strengths = block.strengths;
profile.recurring_weaknesses = block.recurring_weaknesses;
if !block.narrative.is_empty() {
profile.narrative = block.narrative;
} else {
apply_fallback_narrative(profile);
}
let llm_traj = match block.improvement_trajectory.to_lowercase().as_str() {
"improving" => Some(Trajectory::Improving),
"declining" => Some(Trajectory::Declining),
"stable" => Some(Trajectory::Stable),
_ => None,
};
if let Some(t) = llm_traj {
profile.improvement_trajectory = t;
}
}
fn apply_fallback_narrative(profile: &mut ContributorProfile) {
let traj_str = match profile.improvement_trajectory {
Trajectory::Improving => "improving",
Trajectory::Stable => "stable",
Trajectory::Declining => "declining",
};
let n_recurring = profile
.all_findings
.iter()
.filter(|f| f.trend_tag == Some(TrendTag::Recurring))
.count();
profile.narrative = format!(
"Longitudinal profile for {} ({} to {}). \
Quality trajectory: {}. \
{} recurring issue(s) identified across periods. \
(Narrative generation unavailable — LLM call failed or returned invalid output.)",
profile.canonical_name,
profile.profiled_since,
profile.profiled_until,
traj_str,
n_recurring,
);
}
#[cfg(test)]
#[path = "synthesizer_tests.rs"]
mod tests;