feat(flywheel): 阶段三执行质量预测层(trailer 死链修复 + 风险画像) #2

Merged
lanrtop merged 14 commits from feat/flywheel-intelligence into master 2026-07-04 14:45:28 +08:00
4 changed files with 331 additions and 2 deletions
Showing only changes of commit 330ddc24dd - Show all commits

View File

@ -167,8 +167,9 @@
- files: src-tauri/src/commands/commit_metrics.rs, src-tauri/src/commands/buff.rs, src-tauri/src/db.rs - files: src-tauri/src/commands/commit_metrics.rs, src-tauri/src/commands/buff.rs, src-tauri/src/db.rs
- acceptance: "[cargo test] 新表 commit_files(sha,path 联合主键) migration 到位R05git log --name-only 解析纯函数单测覆盖多 commit 多文件;[Tauri command] ingest_full_git_history 对炼境自身重跑 → commit_files 行数 >0 且重跑幂等(行数不变)" - acceptance: "[cargo test] 新表 commit_files(sha,path 联合主键) migration 到位R05git log --name-only 解析纯函数单测覆盖多 commit 多文件;[Tauri command] ingest_full_git_history 对炼境自身重跑 → commit_files 行数 >0 且重跑幂等(行数不变)"
### 📋 P3-C. risk.rs 风险画像 + predict_task_risk MCP 工具 ### ✅ P3-C. risk.rs 风险画像 + predict_task_risk MCP 工具
- status: todo - status: done
- done_note: 2026-07-04 完成。commands/risk.rsscope 级(前缀匹配子 scope+ 文件级commit_files JOIN返工率聚合classify_risk 阈值 ≥0.35 high / ≥0.15 medium样本 <5 conventional <0.5脏历史 confidence=low 并附 notesCI 自修剔除MCP 工具 schema+dispatch+清单断言7 个单测高返工 scope/小样本/脏历史/热点文件/CI 剔除/入参校验/阈值90 全绿MCP 实调待新 build 重启后终验
- complexity: M - complexity: M
- depends: flywheel-intelligence卡 P3-B - depends: flywheel-intelligence卡 P3-B
- files: src-tauri/src/commands/risk.rs, src-tauri/src/commands/mod.rs, src-tauri/src/mcp_tools.rs - files: src-tauri/src/commands/risk.rs, src-tauri/src/commands/mod.rs, src-tauri/src/mcp_tools.rs

View File

@ -18,6 +18,7 @@ pub mod cicd;
pub mod blueprint; pub mod blueprint;
pub mod buff; pub mod buff;
pub mod commit_metrics; pub mod commit_metrics;
pub mod risk;
pub mod onboarding; pub mod onboarding;
pub mod canvas; pub mod canvas;
pub mod claude_config; pub mod claude_config;

View File

@ -0,0 +1,297 @@
//! 飞轮阶段三:执行质量风险画像(预测层)。
//!
//! 从 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 和/或文件清单预测任务风险。
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 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 自修不计返工");
}
}

View File

@ -245,6 +245,19 @@ pub fn tools_list_result() -> Value {
"name": "scan_unregistered_projects", "name": "scan_unregistered_projects",
"description": "扫描已登记项目的父目录,找出存在于文件系统但尚未在炼境注册的项目目录。返回每个未注册目录的路径、是否有 .git、技术栈特征文件package.json / Cargo.toml / go.mod、是否有 AGENTS.md。用于日常发现漏网项目并决定是否接入炼境。", "description": "扫描已登记项目的父目录,找出存在于文件系统但尚未在炼境注册的项目目录。返回每个未注册目录的路径、是否有 .git、技术栈特征文件package.json / Cargo.toml / go.mod、是否有 AGENTS.md。用于日常发现漏网项目并决定是否接入炼境。",
"inputSchema": { "type": "object", "properties": {}, "required": [] } "inputSchema": { "type": "object", "properties": {}, "required": [] }
},
{
"name": "predict_task_risk",
"description": "任务风险预测(飞轮阶段三):按 scope 和/或文件清单查询该项目历史返工率,返回 risk_levellow/medium/high、样本量与置信度、热点文件排行。agent 开工前调用——任务卡涉及高返工 scope/文件时提高警惕(多写测试、小步提交)。样本 <5 或 commit 历史不规范时 confidence=low结论仅供参考。",
"inputSchema": {
"type": "object",
"properties": {
"project_id": { "type": "string", "description": "项目 workspace ID来自 list_group_projects 的返回结果)" },
"scope": { "type": "string", "description": "conventional commit scope如 onboarding、auth/login前缀匹配子 scope" },
"files": { "type": "array", "items": { "type": "string" }, "description": "任务卡涉及的文件相对路径清单(与 git log --name-only 路径格式一致)" }
},
"required": ["project_id"]
}
} }
] ]
}) })
@ -462,6 +475,22 @@ pub async fn tools_call(group_id: &str, params: Option<&Value>) -> Value {
} }
"list_all_projects" => list_all_projects(), "list_all_projects" => list_all_projects(),
"scan_unregistered_projects" => scan_unregistered_projects(), "scan_unregistered_projects" => scan_unregistered_projects(),
"predict_task_risk" => {
let pid = args.get("project_id").and_then(|v| v.as_str()).unwrap_or("");
let scope = args.get("scope").and_then(|v| v.as_str());
let files: Vec<String> = args
.get("files")
.and_then(|v| v.as_array())
.map(|arr| {
arr.iter()
.filter_map(|f| f.as_str().map(str::to_string))
.collect()
})
.unwrap_or_default();
let conn = db::pool().get().map_err(|e| e.to_string())?;
crate::commands::risk::predict_task_risk(&conn, pid, scope, &files)
.and_then(|r| serde_json::to_string_pretty(&r).map_err(|e| e.to_string()))
}
"apply_onboarding_pack" => { "apply_onboarding_pack" => {
let pid = args.get("project_id").and_then(|v| v.as_str()).unwrap_or(""); let pid = args.get("project_id").and_then(|v| v.as_str()).unwrap_or("");
resolve_project_root(&gid, pid).and_then(|root| { resolve_project_root(&gid, pid).and_then(|root| {
@ -1039,6 +1068,7 @@ mod tests {
assert!(names.contains(&"inject_mcp"), "缺少 inject_mcp"); assert!(names.contains(&"inject_mcp"), "缺少 inject_mcp");
assert!(names.contains(&"create_pull_request"), "缺少 create_pull_request"); assert!(names.contains(&"create_pull_request"), "缺少 create_pull_request");
assert!(names.contains(&"poll_now"), "缺少 poll_now"); assert!(names.contains(&"poll_now"), "缺少 poll_now");
assert!(names.contains(&"predict_task_risk"), "缺少 predict_task_risk");
// 每个工具都有 description 和 inputSchema // 每个工具都有 description 和 inputSchema
for tool in tools { for tool in tools {
let name = tool["name"].as_str().unwrap_or("?"); let name = tool["name"].as_str().unwrap_or("?");