Files
DevFlow/src-tauri/src/commands/ai/audit/record.rs
T

453 lines
18 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 审计记录写/查操作 + 审批历史 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<String>,
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<String>,
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<String>,
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<Vec<AiToolExecutionRecord>, 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<String>,
pub tool_call_id: String,
pub tool_name: String,
/// 参数摘要(截断 120 字符,完整原值仍留库)
pub arguments_brief: String,
/// 结果摘要(截断 160 字符,None → 空串便于前端展示)
pub result_brief: Option<String>,
/// pending/approved/rejected/executing/completed/failed
pub status: String,
/// low/medium/high
pub risk_level: String,
pub requested_at: String,
pub executed_at: Option<String>,
/// human/autoNone 表示尚未决策
pub decided_by: Option<String>,
}
/// 审批历史查询入参(前端透传,空值=不过滤)。
///
/// 复用 [`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<String>,
/// 风险等级精确匹配(low/medium/high)
pub risk_level: Option<String>,
/// 工具名关键词(tool_name LIKE %kw%)
pub tool_keyword: Option<String>,
pub limit: Option<u32>,
pub offset: Option<u32>,
}
impl From<ToolExecQuery> 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<ToolExecutionDto>,
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<ToolExecQuery>,
) -> Result<ToolExecutionPage, String> {
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<ToolExecutionDto> = 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<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 记录");
}
}