//! 审计记录写/查操作 + 审批历史 DTO + 审计面板查询 IPC。 //! //! 第六批从 audit/mod.rs 抽离,行为零变更。包含: //! - `audit_tool_call`:写一条工具执行审计记录(insert) //! - `record_audit`:`audit_tool_call` 的别名,语义更清晰("记一条审计") //! - `query_audit_history`:按条件查询审计历史记录 //! - `ToolExecutionDto`:传给前端的精简审计视图 //! - `list_tool_executions`:审批历史面板查询 IPC //! //! 依赖 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; use df_storage::crud::{AiToolExecutionRepo, AuditQuery}; use df_storage::models::AiToolExecutionRecord; use df_types::types::new_id; use crate::commands::err_str; use crate::commands::now_millis; use crate::state::AppState; use super::risk_str; /// 构造一条工具执行审计记录(纯函数,单条 [`audit_tool_call`] / 批量插入路径共用)。 /// /// `decided_by` 有值(auto/human)= 已决策执行 → 记 executed_at; /// `None`(pending 待审批)→ executed_at 留空,待 audit_finalize 回填。 pub(crate) fn build_audit_record( conv_id: &str, tool_call_id: &str, tool_name: &str, arguments: &str, status: &str, risk_level: RiskLevel, result: Option, decided_by: Option<&str>, message_id: Option<&str>, ) -> AiToolExecutionRecord { let executed_at = if decided_by.is_some() { Some(now_millis()) } else { None }; AiToolExecutionRecord { id: new_id(), conversation_id: Some(conv_id.to_string()), // P1 消息级溯源:message_id 由调用方(process_tool_calls)从 // ContextManager 取当前 assistant 消息 id 传入(LLM 返回带 tool_calls 的 // assistant 消息已 push 到 per_conv.messages,入口取末条 assistant id)。 // None 表示无 assistant 消息(异常路径/老数据无 id),展示侧兼容。 message_id: message_id.map(|s| s.to_string()), tool_call_id: tool_call_id.to_string(), tool_name: tool_name.to_string(), arguments: arguments.to_string(), result, status: status.to_string(), risk_level: risk_str(risk_level).to_string(), requested_at: now_millis(), executed_at, decided_by: decided_by.map(|s| s.to_string()), } } /// 写一条工具执行审计记录(insert 失败不阻断主流程,故 `let _ =`) /// /// 单条写入路径。批量路径(audit/mod.rs process_tool_calls 回填循环)经 /// [`build_audit_record`] 收集记录后调 `AiToolExecutionRepo::insert_batch` /// 单事务批量插入(治 aichat 效率 AC-EFF-T1-1,N 次串行 INSERT → 一次事务)。 /// /// `decided_by` 有值(auto/human)= 已决策执行 → 记 executed_at; /// `None`(pending 待审批)→ executed_at 留空,待 audit_finalize 回填。 pub(crate) async fn audit_tool_call( repo: &AiToolExecutionRepo, conv_id: &str, tool_call_id: &str, tool_name: &str, arguments: &str, status: &str, risk_level: RiskLevel, result: Option, decided_by: Option<&str>, message_id: Option<&str>, ) { let record = build_audit_record( conv_id, tool_call_id, tool_name, arguments, status, risk_level, result, decided_by, message_id, ); if let Err(e) = repo.insert(record).await { tracing::error!( "audit_tool_call: 写审计记录失败(conv={}, tool_call_id={}, tool={}): {}", conv_id, tool_call_id, tool_name, e ); } } /// `audit_tool_call` 的语义别名,功能完全相同。 /// 命名更符合"记录一条审计"的调用意图,供新代码使用。 #[allow(dead_code)] pub(crate) async fn record_audit( repo: &AiToolExecutionRepo, conv_id: &str, tool_call_id: &str, tool_name: &str, arguments: &str, status: &str, risk_level: RiskLevel, result: Option, decided_by: Option<&str>, message_id: Option<&str>, ) { audit_tool_call(repo, conv_id, tool_call_id, tool_name, arguments, status, risk_level, result, decided_by, message_id).await; } /// 查询审计历史记录(分页,按 requested_at 倒序)。 /// /// 封装 `list_recent` 添加一层可读语义,方便未来扩展筛选条件。 #[allow(dead_code)] pub(crate) async fn query_audit_history( repo: &AiToolExecutionRepo, limit: u32, offset: u32, ) -> Result, String> { let limit = limit.min(200); repo.list_recent(limit, offset) .await .map_err(|e| format!("query_audit_history 查询失败: {}", e)) } /// 审批历史 DTO(传给前端的精简视图,敏感字段截断防泄露) /// /// arguments/result 在落库时是完整 JSON(可能含项目名/路径/长结果),审计面板只展示摘要, /// 故截断到固定长度(参数 120 / 结果 160),既保留可读性又不泄露全量数据到前端 DOM。 #[derive(Debug, Clone, Serialize)] pub struct ToolExecutionDto { pub id: String, pub conversation_id: Option, pub tool_call_id: String, pub tool_name: String, /// 参数摘要(截断 120 字符,完整原值仍留库) pub arguments_brief: String, /// 结果摘要(截断 160 字符,None → 空串便于前端展示) pub result_brief: Option, /// pending/approved/rejected/executing/completed/failed pub status: String, /// low/medium/high pub risk_level: String, pub requested_at: String, pub executed_at: Option, /// human/auto,None 表示尚未决策 pub decided_by: Option, } /// 审批历史查询入参(前端透传,空值=不过滤)。 /// /// 复用 [`AuditQuery`](`df_storage::crud::AuditQuery`) 的字段语义:status/risk_level 精确匹配, /// tool_keyword 走 tool_name LIKE。limit/offset 默认 50/0,storage 层钳制 limit ≤ 200。 /// /// `Deserialize`:Tauri IPC 从前端 JSON 反序列化。 #[derive(Debug, Clone, Default, Deserialize)] pub struct ToolExecQuery { /// 状态精确匹配(pending/approved/rejected/executing/completed/failed/interrupted) pub status: Option, /// 风险等级精确匹配(low/medium/high) pub risk_level: Option, /// 工具名关键词(tool_name LIKE %kw%) pub tool_keyword: Option, pub limit: Option, pub offset: Option, } impl From for AuditQuery { fn from(q: ToolExecQuery) -> Self { AuditQuery { status: q.status, risk_level: q.risk_level, tool_keyword: q.tool_keyword, limit: q.limit, offset: q.offset, } } } /// 审批历史分页结果(对标项目通用 `{items,total,has_more}` 结构)。 /// /// - `items`:当前页审计 DTO 列表 /// - `total`:满足筛选条件的总行数(忽略分页裁剪,前端用于"第 N 页 / 共 M 条"展示) /// - `has_more`:基于 `loaded < total` 推断,而非"本页是否满 limit"启发式 #[derive(Debug, Clone, Serialize)] pub struct ToolExecutionPage { pub items: Vec, pub total: i64, pub has_more: bool, } /// 审批历史面板查询:按 requested_at 倒序(最新在前)分页返回工具调用审计记录。 /// /// 支持 status / risk_level / 工具名关键词筛选(WHERE 在后端收口,非前端 filter 当前页)。 /// 默认 limit=50 / offset=0(第一页)。limit 在 storage 层钳制 ≤200 防滥用。 /// 敏感字段(arguments/result)截断成摘要返回,完整原值仍留库。 /// /// 返回 `{items,total,has_more}`:total 为满足筛选条件的真实总数(独立 COUNT 查询), /// has_more 基于 `offset + items.len() < total` 推断。 #[tauri::command] pub async fn list_tool_executions( state: State<'_, AppState>, query: Option, ) -> Result { let q = query.unwrap_or_default(); let _limit = q.limit.unwrap_or(50); let offset = q.offset.unwrap_or(0); let audit_q = AuditQuery::from(q); let records = state .ai_tool_executions .list_by_query(&audit_q) .await .map_err(err_str)?; let total = state .ai_tool_executions .count_by_query(&audit_q) .await .map_err(err_str)?; let items: Vec = records .into_iter() .map(|r| ToolExecutionDto { id: r.id, conversation_id: r.conversation_id, tool_call_id: r.tool_call_id, tool_name: r.tool_name, arguments_brief: super::truncate_chars(&r.arguments, 120), result_brief: r.result.map(|s| super::truncate_chars(&s, 160)), status: r.status, risk_level: r.risk_level, requested_at: r.requested_at, executed_at: r.executed_at, decided_by: r.decided_by, }) .collect(); let has_more = (offset as i64 + items.len() as i64) < total; Ok(ToolExecutionPage { items, total, 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, } /// 单工具执行统计(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, 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> = 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 = 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, ) -> Result { 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 记录"); } }