From 330ddc24dd7f2e01f6f8e1b33fc87cff3ed903c4 Mon Sep 17 00:00:00 2001 From: lanrtop Date: Sat, 4 Jul 2026 14:12:35 +0900 Subject: [PATCH] =?UTF-8?q?feat(flywheel):=20predict=5Ftask=5Frisk=20?= =?UTF-8?q?=E9=A3=8E=E9=99=A9=E7=94=BB=E5=83=8F=20MCP=20=E5=B7=A5=E5=85=B7?= =?UTF-8?q?=E2=80=94=E2=80=94=E9=A2=84=E6=B5=8B=E5=B1=82=E8=90=BD=E5=9C=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 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 --- .blueprint/modules/flywheel-intelligence.md | 5 +- src-tauri/src/commands/mod.rs | 1 + src-tauri/src/commands/risk.rs | 297 ++++++++++++++++++++ src-tauri/src/mcp_tools.rs | 30 ++ 4 files changed, 331 insertions(+), 2 deletions(-) create mode 100644 src-tauri/src/commands/risk.rs diff --git a/.blueprint/modules/flywheel-intelligence.md b/.blueprint/modules/flywheel-intelligence.md index 5489d5b..f08bc3a 100644 --- a/.blueprint/modules/flywheel-intelligence.md +++ b/.blueprint/modules/flywheel-intelligence.md @@ -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 diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs index 2466c08..86f05d9 100644 --- a/src-tauri/src/commands/mod.rs +++ b/src-tauri/src/commands/mod.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; diff --git a/src-tauri/src/commands/risk.rs b/src-tauri/src/commands/risk.rs new file mode 100644 index 0000000..5859970 --- /dev/null +++ b/src-tauri/src/commands/risk.rs @@ -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, + 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 和/或文件清单预测任务风险。 +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 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 自修不计返工"); + } +} diff --git a/src-tauri/src/mcp_tools.rs b/src-tauri/src/mcp_tools.rs index 1c5c1a0..04345b2 100644 --- a/src-tauri/src/mcp_tools.rs +++ b/src-tauri/src/mcp_tools.rs @@ -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 = 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("?");