dev-manager-tauri/src-tauri/src/commands/risk.rs
lanrtop 66dc6209a8 feat(flywheel): 梦核 prompt 注入跨项目高风险 scope 数据段
- 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
2026-07-04 14:18:04 +09:00

375 lines
13 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//! 飞轮阶段三:执行质量风险画像(预测层)。
//!
//! 从 commit_metricsscope 级)+ 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 scope6 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 个 fixcold.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/hot4 commits 2 fixrate 0.5proj-b/warm5 commits 1 fixrate 0.2
// proj-a/tiny2 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 自修不计返工");
}
}