优化: AI治理与性能(tool_failure_stats失败率统计命令 + git status仓库级缓存治cmd闪烁N²) + 销账
This commit is contained in:
@@ -43,7 +43,10 @@ pub mod record;
|
||||
#[allow(unused_imports)]
|
||||
pub(crate) use record::{audit_tool_call, query_audit_history, record_audit};
|
||||
#[allow(unused_imports)]
|
||||
pub use record::{list_tool_executions, ToolExecutionDto, ToolExecutionPage, ToolExecQuery};
|
||||
pub use record::{
|
||||
list_tool_executions, tool_failure_stats, ToolExecutionDto, ToolExecutionPage, ToolExecQuery,
|
||||
ToolFailureStat, ToolFailureStats, ToolFailureStatsQuery,
|
||||
};
|
||||
|
||||
// reason 拼装(resolve_project_label / resolve_task_label / build_approval_reason)
|
||||
// 拆至子模块 audit/reason.rs(第一批 helper 抽离,行为零变更)。
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
//! 依赖 audit/utils.rs 的 `truncate_chars` 做参数/结果截断,通过 `super::truncate_chars` 引用。
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashMap;
|
||||
use tauri::State;
|
||||
|
||||
use df_ai::ai_tools::RiskLevel;
|
||||
@@ -223,3 +224,206 @@ pub async fn list_tool_executions(
|
||||
has_more,
|
||||
})
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// AC-5 按工具失败率统计(诊断 IPC,替代 ad-hoc 查库)
|
||||
// ============================================================
|
||||
|
||||
/// 按工具失败率统计查询入参(前端透传,空值=全量)。
|
||||
///
|
||||
/// `from`:可选时间下限(millis,`requested_at >= from`),None = 全量。
|
||||
/// AC-5 诊断期 ad-hoc 查库无运行时统计机制,本命令提供聚合统计供审计面板/诊断查询。
|
||||
#[derive(Debug, Clone, Default, Deserialize)]
|
||||
pub struct ToolFailureStatsQuery {
|
||||
pub from: Option<i64>,
|
||||
}
|
||||
|
||||
/// 单工具执行统计(AC-5 失败率画像)。
|
||||
///
|
||||
/// `failed_rate` = failed / (completed + failed)——仅"真正执行过"的算成功率分母;
|
||||
/// rejected/skipped_retry 是用户/AI 决策非执行失败,不计分母,但单独计数展示。
|
||||
/// completed+failed=0(从未真正执行,如纯决策挂起)时 failed_rate=0。
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct ToolFailureStat {
|
||||
pub tool_name: String,
|
||||
pub total: i64,
|
||||
pub completed: i64,
|
||||
pub failed: i64,
|
||||
pub rejected: i64,
|
||||
pub interrupted: i64,
|
||||
pub skipped_retry: i64,
|
||||
pub failed_rate: f64,
|
||||
}
|
||||
|
||||
/// 失败率统计聚合结果(跨工具汇总)。
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct ToolFailureStats {
|
||||
/// 按 total 降序(使用最多的工具在前,面板聚焦高流量工具)
|
||||
pub stats: Vec<ToolFailureStat>,
|
||||
pub total_executions: i64,
|
||||
pub total_failed: i64,
|
||||
}
|
||||
|
||||
/// 把 `stats_by_tool` 的 (tool_name, status, count) 三元组聚合为 DTO(纯函数,命令 + 单测共用)。
|
||||
///
|
||||
/// 内存聚合(单条 GROUP BY 已按 tool,status 分组):HashMap 二次归并 → 每工具 status 分布,
|
||||
/// 计算 failed_rate(分母 completed+failed,四舍五入到 4 位小数防浮点长尾)。排序按 total 降序、
|
||||
/// tool_name 升序破平。`total_executions` = 全工具 total 之和,`total_failed` = failed 之和。
|
||||
fn aggregate_tool_stats(rows: Vec<(String, String, i64)>) -> ToolFailureStats {
|
||||
let mut by_tool: HashMap<String, HashMap<String, i64>> = HashMap::new();
|
||||
for (tool, status, cnt) in rows {
|
||||
*by_tool.entry(tool).or_default().entry(status).or_insert(0) += cnt;
|
||||
}
|
||||
let mut total_executions = 0i64;
|
||||
let mut total_failed = 0i64;
|
||||
let mut stats: Vec<ToolFailureStat> = by_tool
|
||||
.into_iter()
|
||||
.map(|(tool_name, m)| {
|
||||
let completed = m.get("completed").copied().unwrap_or(0);
|
||||
let failed = m.get("failed").copied().unwrap_or(0);
|
||||
let rejected = m.get("rejected").copied().unwrap_or(0);
|
||||
let interrupted = m.get("interrupted").copied().unwrap_or(0);
|
||||
let skipped_retry = m.get("skipped_retry").copied().unwrap_or(0);
|
||||
let total: i64 = m.values().sum();
|
||||
total_executions += total;
|
||||
total_failed += failed;
|
||||
let failed_rate = if completed + failed > 0 {
|
||||
((failed as f64 / (completed + failed) as f64) * 10_000.0).round() / 10_000.0
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
ToolFailureStat {
|
||||
tool_name,
|
||||
total,
|
||||
completed,
|
||||
failed,
|
||||
rejected,
|
||||
interrupted,
|
||||
skipped_retry,
|
||||
failed_rate,
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
stats.sort_by(|a, b| b.total.cmp(&a.total).then_with(|| a.tool_name.cmp(&b.tool_name)));
|
||||
ToolFailureStats { stats, total_executions, total_failed }
|
||||
}
|
||||
|
||||
/// AC-5 运行时失败率统计:按工具聚合 ai_tool_executions 的 status 分布与失败率。
|
||||
///
|
||||
/// 只读诊断 IPC:`query.from` 可选时间下限(millis),默认全量。数据源 ai_tool_executions 表
|
||||
/// (GUI audit 模块写,已完成记录落盘)。口径:failed_rate = failed / (completed + failed);
|
||||
/// rejected/skipped_retry/interrupted 单独计数展示(不计失败率分母,属用户/AI 决策非执行失败)。
|
||||
#[tauri::command]
|
||||
pub async fn tool_failure_stats(
|
||||
state: State<'_, AppState>,
|
||||
query: Option<ToolFailureStatsQuery>,
|
||||
) -> Result<ToolFailureStats, String> {
|
||||
let from = query.and_then(|q| q.from);
|
||||
let rows = state
|
||||
.ai_tool_executions
|
||||
.stats_by_tool(from)
|
||||
.await
|
||||
.map_err(err_str)?;
|
||||
Ok(aggregate_tool_stats(rows))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use df_storage::db::Database;
|
||||
|
||||
/// 插入一条指定 tool/status/requested_at 的审计记录(测试构造数据用)。
|
||||
/// requested_at 传毫秒(与生产 `audit_tool_call` 落 now_millis() 同口径)。
|
||||
async fn insert_record(repo: &AiToolExecutionRepo, tool: &str, status: &str, t: i64) {
|
||||
repo.insert(AiToolExecutionRecord {
|
||||
id: new_id(),
|
||||
conversation_id: None,
|
||||
message_id: None,
|
||||
tool_call_id: new_id(),
|
||||
tool_name: tool.to_string(),
|
||||
arguments: "{}".to_string(),
|
||||
result: None,
|
||||
status: status.to_string(),
|
||||
risk_level: "low".to_string(),
|
||||
requested_at: t.to_string(),
|
||||
executed_at: None,
|
||||
decided_by: None,
|
||||
})
|
||||
.await
|
||||
.expect("测试数据插入应成功");
|
||||
}
|
||||
|
||||
/// failed_rate 分母口径:rejected/skipped_retry/interrupted 不计分母,单独计数。
|
||||
///
|
||||
/// read_file: 8 completed + 2 failed + 3 rejected → rate=2/10=0.2,rejected=3
|
||||
/// patch_file: 1 completed + 4 failed → rate=4/5=0.8
|
||||
/// run_command: 2 skipped_retry + 1 rejected + 1 interrupted(无 completed/failed)→ rate=0
|
||||
#[tokio::test]
|
||||
async fn tool_failure_stats_denominator_and_counts() {
|
||||
let db = Database::open_in_memory().await.expect("in-memory db 初始化失败");
|
||||
let repo = AiToolExecutionRepo::new(&db);
|
||||
let t = 2_000_000_000_000i64;
|
||||
for _ in 0..8 { insert_record(&repo, "read_file", "completed", t).await; }
|
||||
for _ in 0..2 { insert_record(&repo, "read_file", "failed", t).await; }
|
||||
for _ in 0..3 { insert_record(&repo, "read_file", "rejected", t).await; }
|
||||
insert_record(&repo, "patch_file", "completed", t).await;
|
||||
for _ in 0..4 { insert_record(&repo, "patch_file", "failed", t).await; }
|
||||
for _ in 0..2 { insert_record(&repo, "run_command", "skipped_retry", t).await; }
|
||||
insert_record(&repo, "run_command", "rejected", t).await;
|
||||
insert_record(&repo, "run_command", "interrupted", t).await;
|
||||
|
||||
let out = aggregate_tool_stats(repo.stats_by_tool(None).await.unwrap());
|
||||
assert_eq!(out.total_executions, 8 + 2 + 3 + 1 + 4 + 2 + 1 + 1);
|
||||
assert_eq!(out.total_failed, 6);
|
||||
|
||||
let rf = out.stats.iter().find(|s| s.tool_name == "read_file").unwrap();
|
||||
assert_eq!(rf.total, 13);
|
||||
assert_eq!(rf.completed, 8);
|
||||
assert_eq!(rf.failed, 2);
|
||||
assert_eq!(rf.rejected, 3);
|
||||
assert_eq!(rf.failed_rate, 0.2, "rejected 不计分母,failed/(completed+failed)=2/10");
|
||||
|
||||
let pf = out.stats.iter().find(|s| s.tool_name == "patch_file").unwrap();
|
||||
assert_eq!(pf.failed_rate, 0.8, "4/(1+4)=0.8");
|
||||
|
||||
let rc = out.stats.iter().find(|s| s.tool_name == "run_command").unwrap();
|
||||
assert_eq!(rc.completed + rc.failed, 0, "无真正执行记录");
|
||||
assert_eq!(rc.failed_rate, 0.0, "分母为 0 应归零");
|
||||
assert_eq!(rc.skipped_retry, 2);
|
||||
assert_eq!(rc.interrupted, 1);
|
||||
|
||||
// 排序:total 降序 → read_file(13) 应在 patch_file(5) 之前
|
||||
assert_eq!(out.stats[0].tool_name, "read_file");
|
||||
}
|
||||
|
||||
/// from 时间过滤:仅统计 requested_at >= from 的记录。
|
||||
///
|
||||
/// patch_file 追加一条更早(early)的 completed → from=late 时其不计入,
|
||||
/// completed 由 2 降为 1,failed_rate 由 4/6≈0.6667 变为 4/5=0.8。
|
||||
#[tokio::test]
|
||||
async fn tool_failure_stats_from_time_filter() {
|
||||
let db = Database::open_in_memory().await.expect("in-memory db 初始化失败");
|
||||
let repo = AiToolExecutionRepo::new(&db);
|
||||
let t_late = 2_000_000_000_000i64;
|
||||
let t_early = 1_000_000_000_000i64;
|
||||
|
||||
for _ in 0..8 { insert_record(&repo, "read_file", "completed", t_late).await; }
|
||||
for _ in 0..2 { insert_record(&repo, "read_file", "failed", t_late).await; }
|
||||
insert_record(&repo, "patch_file", "completed", t_late).await;
|
||||
for _ in 0..4 { insert_record(&repo, "patch_file", "failed", t_late).await; }
|
||||
insert_record(&repo, "patch_file", "completed", t_early).await;
|
||||
|
||||
// 全量:patch_file completed=2(early+late)
|
||||
let all = aggregate_tool_stats(repo.stats_by_tool(None).await.unwrap());
|
||||
let pf_all = all.stats.iter().find(|s| s.tool_name == "patch_file").unwrap();
|
||||
assert_eq!(pf_all.completed, 2);
|
||||
assert_eq!(pf_all.failed_rate, 0.6667, "4/(2+4)≈0.6667");
|
||||
|
||||
// from=t_late:early 不计,patch_file completed=1
|
||||
let late = aggregate_tool_stats(repo.stats_by_tool(Some(t_late)).await.unwrap());
|
||||
let pf_late = late.stats.iter().find(|s| s.tool_name == "patch_file").unwrap();
|
||||
assert_eq!(pf_late.completed, 1, "early 记录应被 from 过滤");
|
||||
assert_eq!(pf_late.failed_rate, 0.8, "4/(1+4)=0.8");
|
||||
assert_eq!(late.total_executions, 8 + 2 + 1 + 4, "不含 early 记录");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,6 +15,8 @@
|
||||
use std::collections::HashMap;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::Command;
|
||||
use std::sync::{LazyLock, Mutex};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tauri::State;
|
||||
@@ -209,6 +211,10 @@ pub async fn update_project_module(
|
||||
if path.is_empty() {
|
||||
return Err("path 不能为空".to_string());
|
||||
}
|
||||
// path 变更 → 清旧路径的 git status 缓存(避免后续同路径复用脏状态)
|
||||
if existing.path != path {
|
||||
invalidate_git_status_cache(&existing.path);
|
||||
}
|
||||
existing.path = path;
|
||||
}
|
||||
existing.git_url = trim_opt(input.git_url);
|
||||
@@ -245,6 +251,10 @@ pub async fn remove_project_module(state: State<'_, AppState>, id: String) -> Re
|
||||
if id.is_empty() {
|
||||
return Err("id 不能为空".to_string());
|
||||
}
|
||||
// 删除前取 path,删除后清 git status 缓存(避免同路径复用脏状态)
|
||||
if let Ok(Some(module)) = state.project_modules.get_by_id(&id).await {
|
||||
invalidate_git_status_cache(&module.path);
|
||||
}
|
||||
state.project_modules.delete(&id).await.map_err(err_str)?;
|
||||
Ok(())
|
||||
}
|
||||
@@ -301,6 +311,61 @@ pub async fn list_project_modules(
|
||||
Ok(modules)
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 仓库级 git status 缓存(P1-f:cmd 闪烁 N² 修复)
|
||||
// ============================================================
|
||||
|
||||
/// git status 缓存 TTL(5 秒)。
|
||||
///
|
||||
/// 背景:`get_module_file_tree` 展开新目录 + `get_module_git_status` 切换变更视图都会在仓库根
|
||||
/// 跑整仓 `git status --porcelain`;前端 `loadedChildren` 只对「同目录展开」去重,跨目录展开
|
||||
/// N 次 = N 次整仓扫描(cmd 闪烁 N²)。git status 天然幂等(输出反映当前工作区),TTL 秒级失效
|
||||
/// 可接受(用户改文件后刷新触发重扫兜底),不做实时 watcher(过度设计)。
|
||||
const GIT_STATUS_CACHE_TTL: Duration = Duration::from_secs(5);
|
||||
|
||||
/// 仓库根路径 → (采集时间戳, git status --porcelain 输出 map)。
|
||||
/// 键 = module.path(仓库根);值 = (Instant, {posix_rel: status})。
|
||||
/// 进程级共享(spawn_blocking 内 std Mutex 访问),跨 IPC 请求复用。
|
||||
static GIT_STATUS_CACHE: LazyLock<Mutex<HashMap<String, (Instant, HashMap<String, String>)>>> =
|
||||
LazyLock::new(|| Mutex::new(HashMap::new()));
|
||||
|
||||
/// 判断缓存条目是否新鲜(TTL 内)。抽出纯函数便于单测。
|
||||
fn is_git_status_cache_fresh(collected_at: Instant, now: Instant) -> bool {
|
||||
now.duration_since(collected_at) < GIT_STATUS_CACHE_TTL
|
||||
}
|
||||
|
||||
/// 取仓库 git status map:TTL 内命中直接复用缓存,未命中/过期才跑 git status 并更新缓存。
|
||||
/// 非 git 仓库 → 空 map 且不入缓存(.git 判断廉价,且避免「git init 后 TTL 内误返空」)。
|
||||
fn git_status_map_cached(dir: &str) -> HashMap<String, String> {
|
||||
// 非 git 仓库快速返回(不缓存:git init 后 TTL 内重扫才拿得到真实状态)
|
||||
if !Path::new(dir).join(".git").exists() {
|
||||
return HashMap::new();
|
||||
}
|
||||
let now = Instant::now();
|
||||
// 命中 TTL 内缓存 → 直接复用(锁内只查表,不跑 git,快速返回)
|
||||
{
|
||||
let cache = GIT_STATUS_CACHE.lock().unwrap_or_else(|e| e.into_inner());
|
||||
if let Some((ts, map)) = cache.get(dir) {
|
||||
if is_git_status_cache_fresh(*ts, now) {
|
||||
return map.clone();
|
||||
}
|
||||
}
|
||||
}
|
||||
// 未命中/过期 → 跑 git(锁外执行,避免持锁阻塞其他目录的缓存命中;完成后更新缓存)
|
||||
let map = collect_git_status_map(dir);
|
||||
let mut cache = GIT_STATUS_CACHE.lock().unwrap_or_else(|e| e.into_inner());
|
||||
cache.insert(dir.to_string(), (now, map.clone()));
|
||||
map
|
||||
}
|
||||
|
||||
/// 清空指定仓库根的 git status 缓存条目(工程删除 / path 变更时防脏数据复用)。
|
||||
fn invalidate_git_status_cache(dir: &str) {
|
||||
GIT_STATUS_CACHE
|
||||
.lock()
|
||||
.unwrap_or_else(|e| e.into_inner())
|
||||
.remove(dir);
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// IPC 命令 — Git 状态查询(实时派生,不进表)
|
||||
// ============================================================
|
||||
@@ -448,8 +513,6 @@ pub async fn list_branches(
|
||||
///
|
||||
/// 超时:每个 git 子进程 10s(用户读操作,卡死不应阻塞 UI)。任一命令失败时返回已采集部分。
|
||||
fn run_git_status(dir: &str) -> GitStatus {
|
||||
use std::time::Duration;
|
||||
|
||||
let path = std::path::Path::new(dir);
|
||||
if !path.join(".git").exists() {
|
||||
return empty_status();
|
||||
@@ -463,24 +526,14 @@ fn run_git_status(dir: &str) -> GitStatus {
|
||||
.filter(|s| !s.is_empty() && s != "HEAD")
|
||||
.unwrap_or_default();
|
||||
|
||||
// 2) 改动文件:`git status --porcelain`(每行 "XY path",XY 为两字符状态码)
|
||||
let mut changed_files = Vec::new();
|
||||
if let Some(out) = run_git_cmd(path, &["status", "--porcelain"], timeout) {
|
||||
for line in out.lines() {
|
||||
if line.len() < 4 {
|
||||
continue;
|
||||
}
|
||||
// porcelain 格式:"XY path"(X/Y 各一字符 + 一空格 + path)
|
||||
let status = line[..2].to_string();
|
||||
let file_path = line[3..].trim().to_string();
|
||||
if !file_path.is_empty() {
|
||||
changed_files.push(GitChangedFile {
|
||||
status,
|
||||
path: file_path,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
// 2) 改动文件:`git status --porcelain`(每行 "XY path",XY 为两字符状态码)。
|
||||
// 走仓库级缓存(与文件树共用同一份扫描,TTL 内展开/切换不再整仓重扫)。
|
||||
let mut changed_files: Vec<GitChangedFile> = git_status_map_cached(dir)
|
||||
.into_iter()
|
||||
.map(|(path, status)| GitChangedFile { status, path })
|
||||
.collect();
|
||||
// map 无序,按路径排序保证前端展示顺序稳定(git status 输出本身即按路径有序)。
|
||||
changed_files.sort_by(|a, b| a.path.cmp(&b.path));
|
||||
|
||||
// 3) 最近提交:`git log -50 --format="%h %ct %an %s"`(hash + 时间戳 + 作者 + subject)
|
||||
let mut recent_commits = Vec::new();
|
||||
@@ -652,7 +705,7 @@ pub async fn get_module_file_tree(
|
||||
// 采集 git 改动文件映射(相对仓库根):{posix_rel: status}。
|
||||
// 10s 超时;非 git 仓库/失败 → 空映射(条目 git_status 全 None,不阻断列目录)。
|
||||
let dir_for_git = module.path.clone();
|
||||
let git_map = tokio::task::spawn_blocking(move || collect_git_status_map(&dir_for_git))
|
||||
let git_map = tokio::task::spawn_blocking(move || git_status_map_cached(&dir_for_git))
|
||||
.await
|
||||
.map_err(|e| format!("git status 采集任务失败: {e}"))?;
|
||||
// 路径前缀(工程根本身)的相对基准;target_dir 下条目相对路径要以此拼成 posix 形式查 git_map。
|
||||
@@ -1362,3 +1415,42 @@ pub async fn detect_module_cycles(
|
||||
}
|
||||
Ok(cycle_nodes.into_iter().collect())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::time::Duration;
|
||||
|
||||
/// P1-f:git status 缓存 TTL 判定 — 5s 内新鲜,超时失效。
|
||||
#[test]
|
||||
fn test_is_git_status_cache_fresh() {
|
||||
let now = Instant::now();
|
||||
// TTL 内 → 新鲜
|
||||
assert!(is_git_status_cache_fresh(now - Duration::from_millis(4000), now));
|
||||
// 恰好 TTL → 不新鲜(duration_since < TTL 严格小于)
|
||||
assert!(!is_git_status_cache_fresh(now - Duration::from_secs(5), now));
|
||||
// 超过 TTL → 失效
|
||||
assert!(!is_git_status_cache_fresh(now - Duration::from_secs(6), now));
|
||||
}
|
||||
|
||||
/// P1-f:git status 缓存同一仓库路径复用同一 map 条目、不同路径独立(纯逻辑,不经 git)。
|
||||
#[test]
|
||||
fn test_git_status_cache_shared_per_repo() {
|
||||
let mut cache = GIT_STATUS_CACHE.lock().unwrap_or_else(|e| e.into_inner());
|
||||
let now = Instant::now();
|
||||
let mut map_a = HashMap::new();
|
||||
map_a.insert("src/main.rs".to_string(), " M".to_string());
|
||||
let mut map_b = HashMap::new();
|
||||
map_b.insert("README.md".to_string(), "??".to_string());
|
||||
cache.insert("repoA".to_string(), (now, map_a.clone()));
|
||||
cache.insert("repoB".to_string(), (now, map_b.clone()));
|
||||
// 同路径命中同一份缓存,不同路径互不串扰
|
||||
let (_, cached_a) = cache.get("repoA").unwrap();
|
||||
assert_eq!(cached_a, &map_a);
|
||||
assert_ne!(cached_a, &map_b);
|
||||
// 清理后不再命中
|
||||
cache.remove("repoA");
|
||||
assert!(cache.get("repoA").is_none());
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -465,6 +465,8 @@ pub fn run() {
|
||||
commands::ai::ai_authorize_dir,
|
||||
// 审批历史面板(AE-2025-08:查 ai_tool_executions 表,敏感字段截断)
|
||||
commands::ai::audit::record::list_tool_executions,
|
||||
// AC-5 按工具失败率统计(诊断 IPC,聚合 ai_tool_executions 覆盖全量/时间范围)
|
||||
commands::ai::audit::record::tool_failure_stats,
|
||||
// 知识库
|
||||
commands::knowledge::knowledge_list,
|
||||
commands::knowledge::knowledge_get,
|
||||
|
||||
Reference in New Issue
Block a user