- risk.rs 新增 high_risk_scopes:样本 ≥5 且有非 CI 返工的 scope 按返工率降序 top 10,项目名 LEFT JOIN project_profiles(缺失回退 id) - generate_muhe_prompt 新增「跨项目高风险 scope」表格段,附分析指引 (反复返工 scope = 结构性脆弱点 → 固化环节输出预防性规范) - rule_hits 段 pool() 改 try_pool():原实现测试环境必 panic, generate_muhe_prompt 因此不可测;改后两段在无 DB 时走占位文案 - prompt 构建测试断言两个数据段存在 + 聚合排序/最小样本过滤测试,92 全绿 Rules-Applied: R06,R07
375 lines
13 KiB
Rust
375 lines
13 KiB
Rust
//! 飞轮阶段三:执行质量风险画像(预测层)。
|
||
//!
|
||
//! 从 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<ScopeRisk>,
|
||
pub file_stats: Vec<FileRisk>,
|
||
/// 结论依据的样本量(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<String>,
|
||
}
|
||
|
||
/// 返工率分级阈值:≥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<Option<ScopeRisk>, 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<Vec<FileRisk>, 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<Vec<HighRiskScope>, 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<TaskRiskPrediction, String> {
|
||
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 自修不计返工");
|
||
}
|
||
}
|