//! 飞轮阶段三:执行质量风险画像(预测层)。 //! //! 从 commit_metrics(scope 级)+ commit_files(文件级)聚合历史返工率, //! 供 agent 开工前通过 MCP 工具 `predict_task_risk` 评估任务风险。 //! 全部查询走 conn 注入(R06),聚合与分级逻辑可用 in-memory DB 独立单测。 use rusqlite::Connection; use serde::Serialize; /// scope 级返工画像。 #[derive(Debug, Serialize)] pub struct ScopeRisk { pub scope: String, pub commits: u32, pub rework: u32, pub rework_rate: f64, } /// 单文件返工画像。 #[derive(Debug, Serialize)] pub struct FileRisk { pub path: String, pub commits: u32, pub rework: u32, pub rework_rate: f64, } /// predict_task_risk 的完整返回。 #[derive(Debug, Serialize)] pub struct TaskRiskPrediction { pub project_id: String, pub scope_stats: Option, pub file_stats: Vec, /// 结论依据的样本量(scope 命中 commit 数;无 scope 时取文件最大命中数) pub sample_size: u32, /// low = 样本 <5 或历史数据脏(conventional 率 <0.5),结论仅供参考 pub confidence: String, /// low / medium / high,取 scope 与文件返工率的较高者分级 pub risk_level: String, pub notes: Vec, } /// 返工率分级阈值:≥0.35 high、≥0.15 medium、其余 low。纯函数。 pub fn classify_risk(rate: f64) -> &'static str { if rate >= 0.35 { "high" } else if rate >= 0.15 { "medium" } else { "low" } } fn rate(rework: u32, commits: u32) -> f64 { if commits == 0 { 0.0 } else { (rework as f64 / commits as f64 * 1000.0).round() / 1000.0 } } /// scope 级聚合:精确匹配或前缀匹配("auth" 命中 "auth" 与 "auth/login")。 fn scope_risk(conn: &Connection, project_id: &str, scope: &str) -> Result, String> { let (commits, rework): (u32, u32) = conn .query_row( "SELECT COUNT(*), COALESCE(SUM(CASE WHEN is_rework = 1 AND is_ci_auto = 0 THEN 1 ELSE 0 END), 0) FROM commit_metrics WHERE project_id = ?1 AND (scope = ?2 OR scope LIKE ?2 || '/%')", rusqlite::params![project_id, scope], |r| Ok((r.get(0)?, r.get(1)?)), ) .map_err(|e| e.to_string())?; if commits == 0 { return Ok(None); } Ok(Some(ScopeRisk { scope: scope.to_string(), rework_rate: rate(rework, commits), commits, rework, })) } /// 文件级聚合:每个文件在该项目历史中被多少 commit 触碰、其中多少是返工。 fn file_risks(conn: &Connection, project_id: &str, files: &[String]) -> Result, String> { let mut result = Vec::new(); for path in files { let (commits, rework): (u32, u32) = conn .query_row( "SELECT COUNT(*), COALESCE(SUM(CASE WHEN cm.is_rework = 1 AND cm.is_ci_auto = 0 THEN 1 ELSE 0 END), 0) FROM commit_files cf JOIN commit_metrics cm ON cm.sha = cf.sha WHERE cm.project_id = ?1 AND cf.path = ?2", rusqlite::params![project_id, path], |r| Ok((r.get(0)?, r.get(1)?)), ) .map_err(|e| e.to_string())?; if commits > 0 { result.push(FileRisk { path: path.clone(), rework_rate: rate(rework, commits), commits, rework, }); } } // 返工次数降序,agent 一眼看到最烫的文件 result.sort_by(|a, b| b.rework.cmp(&a.rework)); Ok(result) } /// 跨项目高风险 scope(梦核数据段)。 #[derive(Debug, Serialize)] pub struct HighRiskScope { pub project_name: String, pub scope: String, pub commits: u32, pub rework: u32, pub rework_rate: f64, } /// 跨项目聚合:样本 ≥ min_commits 且至少 1 次返工的 scope,按返工率降序取 top limit。 /// 项目名从 project_profiles 解析,登记缺失时回退 project_id。 pub fn high_risk_scopes( conn: &Connection, min_commits: u32, limit: usize, ) -> Result, String> { let mut stmt = conn .prepare( "SELECT COALESCE(pp.name, cm.project_id) AS pname, cm.scope, COUNT(*) AS c, COALESCE(SUM(CASE WHEN cm.is_rework = 1 AND cm.is_ci_auto = 0 THEN 1 ELSE 0 END), 0) AS rw FROM commit_metrics cm LEFT JOIN project_workspaces pw ON pw.id = cm.project_id LEFT JOIN project_profiles pp ON pp.id = pw.profile_id WHERE cm.scope IS NOT NULL GROUP BY cm.project_id, cm.scope HAVING c >= ?1 AND rw > 0 ORDER BY CAST(rw AS REAL) / c DESC, c DESC LIMIT ?2", ) .map_err(|e| e.to_string())?; let rows = stmt .query_map(rusqlite::params![min_commits, limit as i64], |r| { Ok(( r.get::<_, String>(0)?, r.get::<_, String>(1)?, r.get::<_, u32>(2)?, r.get::<_, u32>(3)?, )) }) .map_err(|e| e.to_string())?; let mut result = Vec::new(); for row in rows { let (project_name, scope, commits, rework) = row.map_err(|e| e.to_string())?; result.push(HighRiskScope { project_name, scope, rework_rate: rate(rework, commits), commits, rework, }); } Ok(result) } /// 核心入口:按 scope 和/或文件清单预测任务风险。 pub fn predict_task_risk( conn: &Connection, project_id: &str, scope: Option<&str>, files: &[String], ) -> Result { if scope.is_none() && files.is_empty() { return Err("scope 与 files 至少提供一个".to_string()); } let scope_stats = match scope { Some(s) if !s.trim().is_empty() => scope_risk(conn, project_id, s.trim())?, _ => None, }; let file_stats = file_risks(conn, project_id, files)?; let mut notes = Vec::new(); // 脏历史检测:conventional 率 <0.5 时结论不可信(数据可信度提示,与 onboarding 卡 D 同源) let (total, conv): (u32, u32) = conn .query_row( "SELECT COUNT(*), COALESCE(SUM(CASE WHEN commit_type IS NOT NULL THEN 1 ELSE 0 END), 0) FROM commit_metrics WHERE project_id = ?1", [project_id], |r| Ok((r.get(0)?, r.get(1)?)), ) .map_err(|e| e.to_string())?; let dirty_history = total > 0 && (conv as f64 / total as f64) < 0.5; if dirty_history { notes.push(format!( "历史数据脏:conventional 率 {:.0}%({}/{}),建议种 lefthook 后以新数据为准", conv as f64 / total as f64 * 100.0, conv, total )); } let sample_size = scope_stats .as_ref() .map(|s| s.commits) .unwrap_or_else(|| file_stats.iter().map(|f| f.commits).max().unwrap_or(0)); // 有效返工率取 scope 与文件中的较高者(文件样本 ≥3 才参与,避免单次 fix 拉爆) let scope_rate = scope_stats.as_ref().map(|s| s.rework_rate).unwrap_or(0.0); let file_rate = file_stats .iter() .filter(|f| f.commits >= 3) .map(|f| f.rework_rate) .fold(0.0_f64, f64::max); let effective_rate = scope_rate.max(file_rate); let confidence = if sample_size < 5 || dirty_history { if sample_size < 5 { notes.push(format!("样本量 {sample_size} <5,结论仅供参考")); } "low" } else { "normal" }; Ok(TaskRiskPrediction { project_id: project_id.to_string(), scope_stats, file_stats, sample_size, confidence: confidence.to_string(), risk_level: classify_risk(effective_rate).to_string(), notes, }) } #[cfg(test)] mod tests { use super::*; use crate::commands::commit_metrics::{record_commit_entry, GitLogEntry}; fn seed(conn: &Connection, pid: &str, sha: &str, subject: &str, files: &[&str]) { let entry = GitLogEntry { sha: sha.into(), subject: subject.into(), committed_at: "2026-07-04T00:00:00+09:00".into(), rules_trailer: String::new(), files: files.iter().map(|s| s.to_string()).collect(), }; record_commit_entry(conn, pid, &entry).unwrap(); } #[test] fn classify_thresholds() { assert_eq!(classify_risk(0.4), "high"); assert_eq!(classify_risk(0.35), "high"); assert_eq!(classify_risk(0.2), "medium"); assert_eq!(classify_risk(0.1), "low"); } #[test] fn high_rework_scope_flagged_high() { let conn = crate::db::conn_with_schema(); // onboarding scope:6 commits 里 3 条 fix(含一个子 scope),返工率 0.5 for (i, subj) in [ "feat(onboarding): a", "fix(onboarding): b", "fix(onboarding/pack): c", "feat(onboarding): d", "fix(onboarding): e", "chore(onboarding): f", ] .iter() .enumerate() { seed(&conn, "p1", &format!("s{i}"), subj, &[]); } let r = predict_task_risk(&conn, "p1", Some("onboarding"), &[]).unwrap(); let s = r.scope_stats.unwrap(); assert_eq!(s.commits, 6, "前缀匹配应包含子 scope"); assert_eq!(s.rework, 3); assert_eq!(r.risk_level, "high"); assert_eq!(r.confidence, "normal"); } #[test] fn small_sample_low_confidence() { let conn = crate::db::conn_with_schema(); seed(&conn, "p1", "s1", "fix(auth): x", &[]); seed(&conn, "p1", "s2", "feat(auth): y", &[]); let r = predict_task_risk(&conn, "p1", Some("auth"), &[]).unwrap(); assert_eq!(r.confidence, "low"); assert!(r.notes.iter().any(|n| n.contains("样本量")), "应有小样本提示"); } #[test] fn dirty_history_noted_and_low_confidence() { let conn = crate::db::conn_with_schema(); // 10 条里仅 3 条 conventional → 脏历史 for i in 0..7 { seed(&conn, "p1", &format!("junk{i}"), "update stuff", &[]); } seed(&conn, "p1", "c1", "feat(api): a", &[]); seed(&conn, "p1", "c2", "feat(api): b", &[]); seed(&conn, "p1", "c3", "fix(api): c", &[]); // api scope 样本 5 条以下也行,这里重点断言脏历史标记 let r = predict_task_risk(&conn, "p1", Some("api"), &[]).unwrap(); assert_eq!(r.confidence, "low"); assert!(r.notes.iter().any(|n| n.contains("历史数据脏"))); } #[test] fn file_level_rework_surfaces_hot_file() { let conn = crate::db::conn_with_schema(); // hot.rs 被 4 个 commit 触碰,其中 2 个 fix;cold.rs 只有 feat seed(&conn, "p1", "s1", "feat(a): x", &["src/hot.rs", "src/cold.rs"]); seed(&conn, "p1", "s2", "fix(a): y", &["src/hot.rs"]); seed(&conn, "p1", "s3", "fix(a): z", &["src/hot.rs"]); seed(&conn, "p1", "s4", "feat(a): w", &["src/hot.rs"]); let r = predict_task_risk( &conn, "p1", None, &["src/hot.rs".into(), "src/cold.rs".into(), "src/ghost.rs".into()], ) .unwrap(); assert_eq!(r.file_stats.len(), 2, "无历史的文件不进结果"); assert_eq!(r.file_stats[0].path, "src/hot.rs", "返工多的文件排前"); assert_eq!(r.file_stats[0].rework, 2); assert_eq!(r.risk_level, "high", "hot.rs 返工率 0.5 应分级 high"); } #[test] fn high_risk_scopes_orders_by_rate_with_min_sample() { let conn = crate::db::conn_with_schema(); // proj-a/hot:4 commits 2 fix(rate 0.5);proj-b/warm:5 commits 1 fix(rate 0.2) // proj-a/tiny:2 commits 1 fix——低于 min_commits=3 应被过滤 for (sha, subj) in [ ("a1", "fix(hot): x"), ("a2", "fix(hot): y"), ("a3", "feat(hot): z"), ("a4", "chore(hot): w"), ("b1", "fix(warm): x"), ("b2", "feat(warm): a"), ("b3", "feat(warm): b"), ("b4", "feat(warm): c"), ("b5", "feat(warm): d"), ("t1", "fix(tiny): x"), ("t2", "feat(tiny): y"), ] { let pid = if sha.starts_with('a') || sha.starts_with('t') { "proj-a" } else { "proj-b" }; seed(&conn, pid, sha, subj, &[]); } let top = high_risk_scopes(&conn, 3, 10).unwrap(); assert_eq!(top.len(), 2, "tiny 样本不足应被过滤"); assert_eq!(top[0].scope, "hot", "返工率高的排前"); assert_eq!(top[0].project_name, "proj-a", "无登记项目回退 project_id"); assert_eq!(top[1].scope, "warm"); } #[test] fn requires_scope_or_files() { let conn = crate::db::conn_with_schema(); assert!(predict_task_risk(&conn, "p1", None, &[]).is_err()); } #[test] fn ci_auto_fix_excluded_from_rework() { let conn = crate::db::conn_with_schema(); seed(&conn, "p1", "s1", "fix(ci): auto-fix [DeepSeek-V3]", &[]); seed(&conn, "p1", "s2", "feat(ci): x", &[]); let r = predict_task_risk(&conn, "p1", Some("ci"), &[]).unwrap(); assert_eq!(r.scope_stats.unwrap().rework, 0, "CI 自修不计返工"); } }