feat(flywheel): predict_task_risk 风险画像 MCP 工具——预测层落地
- 新增 commands/risk.rs:scope 级(前缀匹配子 scope)+ 文件级 (commit_files JOIN commit_metrics)历史返工率聚合 - classify_risk 分级:返工率 ≥0.35 high、≥0.15 medium、其余 low; 有效返工率取 scope 与文件(样本 ≥3)的较高者 - 可信度防线:样本 <5 或 conventional 率 <0.5(脏历史)→ confidence=low 并附原因 notes,不让脏数据装可信 - CI 自修 commit(is_ci_auto)不计返工,与 L0 口径一致 - MCP 注册 predict_task_risk:agent 开工前按任务卡 scope/files 查询风险,工具清单测试断言驻军 - 7 个单测(in-memory DB + conn 注入),90 全绿 Rules-Applied: R01,R06,R07,R13
This commit is contained in:
parent
0ac809970f
commit
330ddc24dd
@ -167,8 +167,9 @@
|
||||
- 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 到位(R05),git log --name-only 解析纯函数单测覆盖多 commit 多文件;[Tauri command] ingest_full_git_history 对炼境自身重跑 → commit_files 行数 >0 且重跑幂等(行数不变)"
|
||||
|
||||
### 📋 P3-C. risk.rs 风险画像 + predict_task_risk MCP 工具
|
||||
- status: todo
|
||||
### ✅ P3-C. risk.rs 风险画像 + predict_task_risk MCP 工具
|
||||
- status: done
|
||||
- done_note: 2026-07-04 完成。commands/risk.rs:scope 级(前缀匹配子 scope)+ 文件级(commit_files JOIN)返工率聚合,classify_risk 阈值 ≥0.35 high / ≥0.15 medium;样本 <5 或 conventional 率 <0.5(脏历史)→ confidence=low 并附 notes;CI 自修剔除。MCP 工具 schema+dispatch+清单断言。7 个单测(高返工 scope/小样本/脏历史/热点文件/CI 剔除/入参校验/阈值),90 全绿。MCP 实调待新 build 重启后终验
|
||||
- complexity: M
|
||||
- depends: flywheel-intelligence(卡 P3-B)
|
||||
- files: src-tauri/src/commands/risk.rs, src-tauri/src/commands/mod.rs, src-tauri/src/mcp_tools.rs
|
||||
|
||||
@ -18,6 +18,7 @@ pub mod cicd;
|
||||
pub mod blueprint;
|
||||
pub mod buff;
|
||||
pub mod commit_metrics;
|
||||
pub mod risk;
|
||||
pub mod onboarding;
|
||||
pub mod canvas;
|
||||
pub mod claude_config;
|
||||
|
||||
297
src-tauri/src/commands/risk.rs
Normal file
297
src-tauri/src/commands/risk.rs
Normal file
@ -0,0 +1,297 @@
|
||||
//! 飞轮阶段三:执行质量风险画像(预测层)。
|
||||
//!
|
||||
//! 从 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 和/或文件清单预测任务风险。
|
||||
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 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 自修不计返工");
|
||||
}
|
||||
}
|
||||
@ -245,6 +245,19 @@ pub fn tools_list_result() -> Value {
|
||||
"name": "scan_unregistered_projects",
|
||||
"description": "扫描已登记项目的父目录,找出存在于文件系统但尚未在炼境注册的项目目录。返回每个未注册目录的路径、是否有 .git、技术栈特征文件(package.json / Cargo.toml / go.mod)、是否有 AGENTS.md。用于日常发现漏网项目并决定是否接入炼境。",
|
||||
"inputSchema": { "type": "object", "properties": {}, "required": [] }
|
||||
},
|
||||
{
|
||||
"name": "predict_task_risk",
|
||||
"description": "任务风险预测(飞轮阶段三):按 scope 和/或文件清单查询该项目历史返工率,返回 risk_level(low/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(),
|
||||
"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" => {
|
||||
let pid = args.get("project_id").and_then(|v| v.as_str()).unwrap_or("");
|
||||
resolve_project_root(&gid, pid).and_then(|root| {
|
||||
@ -1039,6 +1068,7 @@ mod tests {
|
||||
assert!(names.contains(&"inject_mcp"), "缺少 inject_mcp");
|
||||
assert!(names.contains(&"create_pull_request"), "缺少 create_pull_request");
|
||||
assert!(names.contains(&"poll_now"), "缺少 poll_now");
|
||||
assert!(names.contains(&"predict_task_risk"), "缺少 predict_task_risk");
|
||||
// 每个工具都有 description 和 inputSchema
|
||||
for tool in tools {
|
||||
let name = tool["name"].as_str().unwrap_or("?");
|
||||
|
||||
Loading…
Reference in New Issue
Block a user