- 会话终态提炼: 删除前+停靠双挂点,快照与清水位同临界区 - 目标快照: 恢复引导按最终有效意图生成,旧目标不复活 - 知识检索项目维度软排序,WorkingContext 持久化迁移 - 脉络卡: published 知识按项目聚合纯读侧,带入上下文经输入框显式注入 - 压缩与缓存能力在主循环/子代理/提示词三面接线 - 两份专项设计文档与看板销账
756 lines
34 KiB
Rust
756 lines
34 KiB
Rust
//! 审计记录写/查操作 + 审批历史 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_ellipsis` 做参数/结果截断,通过 `super::truncate_chars_ellipsis` 引用。
|
||
|
||
use serde::{Deserialize, Serialize};
|
||
use std::collections::{HashMap, HashSet};
|
||
use tauri::State;
|
||
|
||
use df_ai::ai_tools::RiskLevel;
|
||
use df_storage::crud::{AiConversationRepo, AiToolExecutionRepo, AuditQuery};
|
||
use df_storage::db::Database;
|
||
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;
|
||
use crate::commands::ai::tool_result_offload;
|
||
|
||
/// 构造一条工具执行审计记录(纯函数,单条 [`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>,
|
||
duration_ms: Option<i64>,
|
||
) -> AiToolExecutionRecord {
|
||
let executed_at = if decided_by.is_some() { Some(now_millis()) } else { None };
|
||
// D2a 结果外置:超 8KB 的审计 result 落盘留句柄,治 DB 膨胀(devflow.db 193.7MB 实证)。
|
||
// 失败/未注入 base 时原样返回,降级安全。仅 completed/failed 有 result 的记录受益。
|
||
let result = match result {
|
||
Some(r) if !r.is_empty() => Some(tool_result_offload::offload_result(tool_call_id, tool_name, &r)),
|
||
other => other,
|
||
};
|
||
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()),
|
||
// &str → ToolCallId(From<&str>),branded newtype 防外部 String 误传
|
||
tool_call_id: tool_call_id.into(),
|
||
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()),
|
||
// V50 纯执行耗时:由调用方在真执行路径测量后传入(执行前→执行后墙钟);
|
||
// 未真执行(deny/pending/缓存命中)传 None。与 requested_at→executed_at 解耦,
|
||
// 后者在审批路径混入等待人工批准时长。
|
||
duration_ms,
|
||
}
|
||
}
|
||
|
||
/// 写一条工具执行审计记录(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>,
|
||
duration_ms: Option<i64>,
|
||
) {
|
||
let record = build_audit_record(
|
||
conv_id, tool_call_id, tool_name, arguments,
|
||
status, risk_level, result, decided_by, message_id, duration_ms,
|
||
);
|
||
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>,
|
||
) {
|
||
// 事件总线旁路(无真执行计时),duration_ms 传 None。
|
||
audit_tool_call(repo, conv_id, tool_call_id, tool_name, arguments, status, risk_level, result, decided_by, message_id, None).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/auto,None 表示尚未决策
|
||
pub decided_by: Option<String>,
|
||
/// 纯执行耗时(毫秒,V50):真执行墙钟,与审批等待解耦;None=未执行/老数据。
|
||
/// 前端审计面板可用此替代 executed_at-requested_at(后者含审批等待)。
|
||
#[serde(skip_serializing_if = "Option::is_none")]
|
||
pub duration_ms: Option<i64>,
|
||
}
|
||
|
||
/// 审批历史查询入参(前端透传,空值=不过滤)。
|
||
///
|
||
/// 复用 [`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,
|
||
// ToolCallId → String(DTO 边界,前端按字符串消费)
|
||
tool_call_id: r.tool_call_id.into(),
|
||
tool_name: r.tool_name,
|
||
arguments_brief: super::truncate_chars_ellipsis(&r.arguments, 120),
|
||
result_brief: r.result.map(|s| super::truncate_chars_ellipsis(&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,
|
||
duration_ms: r.duration_ms,
|
||
})
|
||
.collect();
|
||
let has_more = (offset as i64 + items.len() as i64) < total;
|
||
|
||
Ok(ToolExecutionPage {
|
||
items,
|
||
total,
|
||
has_more,
|
||
})
|
||
}
|
||
|
||
// ============================================================
|
||
// S4 TaskDrawer 关联会话反查(按 task_id 推导发起会话,只读)
|
||
// ============================================================
|
||
|
||
/// 任务工具精确名单(与 tools/task.rs 声明的三个任务工具一一对应)。
|
||
///
|
||
/// 审计表无"task_id → conversation"映射列,主链路靠本名单行 + result JSON 反推;
|
||
/// SQL 与本名单必须同步维护(见下方 SELECT 注释)。
|
||
const TASK_CONV_TOOLS: [&str; 3] = ["create_task", "update_task", "advance_task"];
|
||
|
||
/// 关联会话跳转条目 DTO(传给前端 TaskDrawer「关联会话」info-item)。
|
||
#[derive(Debug, Clone, Serialize)]
|
||
pub struct TaskConversationRef {
|
||
pub conversation_id: String,
|
||
/// 会话标题(ai_conversations.title,懒创建未命名时 None,前端回退显 id)
|
||
pub title: Option<String>,
|
||
/// 最近一次命中命中的工具名(create_task/update_task/advance_task)
|
||
pub tool_name: String,
|
||
/// 最近一次命中时间(毫秒字符串,同库 requested_at 口径)
|
||
pub requested_at: String,
|
||
}
|
||
|
||
/// 单行审计记录命中判定(纯函数):工具在名单内 且 result JSON 顶层 id 与 task_id 精确判等。
|
||
///
|
||
/// 禁 LIKE:任务 id 做子串匹配会误命中(如 "t1" 命中 "t12"),必须解析 JSON 取 id 字段判等;
|
||
/// result 非 JSON / 缺 id 字段 / id 不等 / 工具不在名单 → 一律不命中(failed 行的
|
||
/// `{"error":...}` 与 rejected 行的人话文本由此自然被排除)。
|
||
fn match_task_audit_row(tool_name: &str, result: Option<&str>, task_id: &str) -> bool {
|
||
if !TASK_CONV_TOOLS.contains(&tool_name) {
|
||
return false;
|
||
}
|
||
result
|
||
.and_then(|r| serde_json::from_str::<serde_json::Value>(r).ok())
|
||
.and_then(|v| v.get("id").and_then(|id| id.as_str()).map(|s| s.to_string()))
|
||
.map(|id| id == task_id)
|
||
.unwrap_or(false)
|
||
}
|
||
|
||
/// 按 task_id 反查发起过任务工具的会话(供 IPC 与单测共用,不依赖 AppState)。
|
||
///
|
||
/// 数据源 ai_tool_executions 审计表(V9):每笔任务工具执行都记 conversation_id + result。
|
||
/// repo 现有查询只支持 status/risk/tool_keyword(LIKE)维度,表达不了 `tool_name IN 精确名单`,
|
||
/// 故走专用 SELECT(与 state.rs 清理孤儿 pending 消息同款 app 层直查先例);SQL 里的工具名单
|
||
/// 是 TASK_CONV_TOOLS 的硬拷贝,单测覆盖三个名字防漂移。
|
||
///
|
||
/// P2-3 task_id 预筛下沉 SQL:`result LIKE '%"id":"<task_id>"%'` 只作预筛(缩小扫描窗口,
|
||
/// 参数化绑定防注入),精确判等仍在 Rust 侧 match_task_audit_row(解析 JSON 取顶层 id 字段
|
||
/// 判等,「禁裸 LIKE 判等」不破)。预筛只会多放进相关行(嵌套 id/巧合同形文本),由 Rust
|
||
/// 侧剔除;不会漏放——audit result 均由 serde_json 紧凑序列化落库(无空格),顶层 id 字段
|
||
/// 必然以 `"id":"<id>"` 形态出现。若无预筛,LIMIT 窗口取的是全局最近 1000 行,高流量下
|
||
/// 目标任务的关联行会被无关行挤出窗外(关联静默消失);预筛后窗口=含该任务 id 形文本的
|
||
/// 最近 1000 行,早期关联仍可命中(有单测锁定)。
|
||
///
|
||
/// 过滤:① 命中行按会话去重(行按 requested_at 倒序,首见=最近一次);② 子代理会话
|
||
/// (origin='sub_agent',V44)剔除,v1 只列主会话;③ 会话已不在库的 id 一并剔除
|
||
/// (switchConversation 对不存在 id 会走"新建兜底",跳过去是空会话,不如不列)。
|
||
/// LIMIT 1000 扫描上界:抽屉每次打开都查,封顶防超大审计表全表拖慢。
|
||
pub(crate) async fn find_task_conversations(
|
||
db: &Database,
|
||
task_id: &str,
|
||
) -> Result<Vec<TaskConversationRef>, String> {
|
||
let pattern = format!("%\"id\":\"{}\"%", task_id);
|
||
let conn = db.conn();
|
||
let rows: Vec<(String, String, Option<String>, String)> =
|
||
tokio::task::spawn_blocking(move || -> Result<Vec<(String, String, Option<String>, String)>, String> {
|
||
let guard = conn.blocking_lock();
|
||
let mut stmt = guard
|
||
.prepare(
|
||
// 工具名单硬拷贝自 TASK_CONV_TOOLS(三个名字),改动须两处同步
|
||
"SELECT conversation_id, tool_name, result, requested_at FROM ai_tool_executions \
|
||
WHERE tool_name IN ('create_task','update_task','advance_task') \
|
||
AND conversation_id IS NOT NULL \
|
||
AND result LIKE ?1 \
|
||
ORDER BY CAST(requested_at AS INTEGER) DESC LIMIT 1000",
|
||
)
|
||
.map_err(|e| e.to_string())?;
|
||
let mapped = stmt
|
||
.query_map([&pattern], |row| {
|
||
Ok((
|
||
row.get::<_, String>(0)?,
|
||
row.get::<_, String>(1)?,
|
||
row.get::<_, Option<String>>(2)?,
|
||
row.get::<_, String>(3)?,
|
||
))
|
||
})
|
||
.map_err(|e| e.to_string())?;
|
||
let mut out = Vec::new();
|
||
for r in mapped {
|
||
out.push(r.map_err(|e| e.to_string())?);
|
||
}
|
||
Ok(out)
|
||
})
|
||
.await
|
||
.map_err(|e| format!("find_task_conversations join 失败: {}", e))??;
|
||
|
||
let conv_repo = AiConversationRepo::new(db);
|
||
let mut visited: HashSet<String> = HashSet::new();
|
||
let mut out = Vec::new();
|
||
for (conv_id, tool_name, result, requested_at) in rows {
|
||
if !match_task_audit_row(&tool_name, result.as_deref(), task_id) {
|
||
continue;
|
||
}
|
||
// 去重 + 防同会话重复查 origin(首个可见行即该会话最近一次命中)
|
||
if !visited.insert(conv_id.clone()) {
|
||
continue;
|
||
}
|
||
if let Ok(Some(rec)) = conv_repo.get_by_id(&conv_id).await {
|
||
// origin=None(主会话)才列;查无记录的会话同样不列(见函数注释③)
|
||
if rec.origin.is_none() {
|
||
out.push(TaskConversationRef {
|
||
conversation_id: conv_id,
|
||
title: rec.title,
|
||
tool_name,
|
||
requested_at,
|
||
});
|
||
}
|
||
}
|
||
}
|
||
Ok(out)
|
||
}
|
||
|
||
/// TaskDrawer「关联会话」查询 IPC:按 task_id 反查发起过 create/update/advance_task 的主会话,
|
||
/// 按 最近命中 倒序去重返回。只读,无副作用。
|
||
#[tauri::command]
|
||
pub async fn list_task_conversations(
|
||
state: State<'_, AppState>,
|
||
task_id: String,
|
||
) -> Result<Vec<TaskConversationRef>, String> {
|
||
find_task_conversations(&state.db, &task_id).await
|
||
}
|
||
|
||
// ============================================================
|
||
// 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))
|
||
}
|
||
|
||
/// 读回外置工具结果原文(D2a 大结果外置的读取侧入口)。
|
||
///
|
||
/// 审计记录中 result 超 8KB 时被外置为句柄 JSON(含 path 字段),DB 只留句柄。
|
||
/// 前端审计面板展示历史时,遇句柄需按 path 读回原文全文——本 IPC 即该入口。
|
||
/// 过期(>90 天归档清理)后文件不在,返回带语义的错误提示(降级不 panic)。
|
||
#[tauri::command]
|
||
pub async fn ai_read_offloaded_result(path: String) -> Result<String, String> {
|
||
tool_result_offload::read_offloaded(&path)
|
||
}
|
||
|
||
#[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().into(),
|
||
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,
|
||
duration_ms: 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");
|
||
}
|
||
|
||
/// S4 关联会话反查:插入一条指定 conv/tool/result/requested_at 的审计行(测试构造数据用)。
|
||
async fn insert_conv_audit(
|
||
repo: &AiToolExecutionRepo,
|
||
conv: &str,
|
||
tool: &str,
|
||
result: Option<String>,
|
||
t: i64,
|
||
) {
|
||
repo.insert(AiToolExecutionRecord {
|
||
id: new_id(),
|
||
conversation_id: Some(conv.to_string()),
|
||
message_id: None,
|
||
tool_call_id: new_id().into(),
|
||
tool_name: tool.to_string(),
|
||
arguments: "{}".to_string(),
|
||
result,
|
||
status: "completed".to_string(),
|
||
risk_level: "low".to_string(),
|
||
requested_at: t.to_string(),
|
||
executed_at: Some(t.to_string()),
|
||
decided_by: Some("auto".to_string()),
|
||
duration_ms: None,
|
||
})
|
||
.await
|
||
.expect("测试数据插入应成功");
|
||
}
|
||
|
||
/// S4 关联会话反查:插入一条指定 origin 的会话记录(V44 origin 列,测试过滤子会话用)。
|
||
async fn insert_conversation(db: &Database, id: &str, title: Option<&str>, origin: Option<&str>) {
|
||
use df_storage::models::AiConversationRecord;
|
||
AiConversationRepo::new(db)
|
||
.insert(AiConversationRecord {
|
||
id: id.to_string(),
|
||
title: title.map(|s| s.to_string()),
|
||
messages: "[]".to_string(),
|
||
provider_id: None,
|
||
model: None,
|
||
models: None,
|
||
archived: false,
|
||
pinned: false,
|
||
prompt_tokens: None,
|
||
completion_tokens: None,
|
||
pinned_goals: None,
|
||
pending_approvals: None,
|
||
created_at: "1".to_string(),
|
||
updated_at: "1".to_string(),
|
||
origin: origin.map(|s| s.to_string()),
|
||
boundary_json: None,
|
||
loop_state: None,
|
||
status: "active".to_string(),
|
||
working_context_json: None,
|
||
})
|
||
.await
|
||
.expect("测试会话插入应成功");
|
||
}
|
||
|
||
/// S4 关联会话反查三件事:① result JSON 顶层 id 精确判等(禁 LIKE,前缀 id 不误命中);
|
||
/// ② 同会话多行命中按最近一次去重 + 行按 requested_at 倒序;③ origin='sub_agent' 子会话
|
||
/// 与已不在库的会话 id 剔除,只列主会话。SQL 三个工具名与 TASK_CONV_TOOLS 同步覆盖。
|
||
#[tokio::test]
|
||
async fn find_task_conversations_exact_id_dedup_origin_filter() {
|
||
let db = Database::open_in_memory().await.expect("in-memory db 初始化失败");
|
||
let repo = AiToolExecutionRepo::new(&db);
|
||
let t = 2_000_000_000_000i64;
|
||
|
||
insert_conversation(&db, "conv-main", Some("主会话"), None).await;
|
||
insert_conversation(&db, "conv-sub", Some("子代理"), Some("sub_agent")).await;
|
||
|
||
// conv-main:create(t-30) + update(t-10) 两次命中 → 去重后取 update(最近)
|
||
insert_conv_audit(&repo, "conv-main", "create_task",
|
||
Some(r#"{"id":"t1","title":"A","status":"todo"}"#.into()), t - 30).await;
|
||
insert_conv_audit(&repo, "conv-main", "update_task",
|
||
Some(r#"{"id":"t1","field":"title","updated":true}"#.into()), t - 10).await;
|
||
// conv-sub:advance 命中但 origin='sub_agent' → 过滤
|
||
insert_conv_audit(&repo, "conv-sub", "advance_task",
|
||
Some(r#"{"id":"t1","status":"in_progress"}"#.into()), t - 20).await;
|
||
// 干扰项:非名单工具 / id 前缀(t11 ≠ t1)/ 缺 id / 非法 JSON / 其他任务
|
||
insert_conv_audit(&repo, "conv-main", "list_tasks",
|
||
Some(r#"{"id":"t1"}"#.into()), t - 5).await;
|
||
insert_conv_audit(&repo, "conv-main", "update_task",
|
||
Some(r#"{"id":"t12","field":"title","updated":true}"#.into()), t - 6).await;
|
||
insert_conv_audit(&repo, "conv-main", "advance_task",
|
||
Some(r#"{"error":"boom"}"#.into()), t - 7).await;
|
||
insert_conv_audit(&repo, "conv-main", "create_task",
|
||
Some("审批超时,自动取消".into()), t - 8).await;
|
||
insert_conv_audit(&repo, "conv-main", "create_task",
|
||
Some(r#"{"id":"t2","title":"B"}"#.into()), t - 9).await;
|
||
insert_conv_audit(&repo, "conv-ghost", "create_task",
|
||
Some(r#"{"id":"t1","title":"C"}"#.into()), t - 4).await; // 会话已不在库
|
||
|
||
let out = find_task_conversations(&db, "t1").await.unwrap();
|
||
assert_eq!(out.len(), 1, "只应命中主会话 conv-main(去重后单条)");
|
||
assert_eq!(out[0].conversation_id, "conv-main");
|
||
assert_eq!(out[0].title.as_deref(), Some("主会话"));
|
||
assert_eq!(out[0].tool_name, "update_task", "同会话两行命中取最近一次(update)");
|
||
assert_eq!(out[0].requested_at, (t - 10).to_string());
|
||
}
|
||
|
||
/// P2-3 窗口外早期关联仍可命中:目标任务行之后灌 1000+ 条更新的无关任务工具行,
|
||
/// 旧的「全局最近 1000 条」窗口会把早期关联挤出(静默消失);task_id 预筛下沉后,
|
||
/// 窗口按预筛结果截取,早期关联仍须命中(精确判等仍在 Rust 侧,误命中不发生)。
|
||
#[tokio::test]
|
||
async fn find_task_conversations_early_hit_survives_window_flood() {
|
||
let db = Database::open_in_memory().await.expect("in-memory db 初始化失败");
|
||
let repo = AiToolExecutionRepo::new(&db);
|
||
let t_early = 1_000_000_000_000i64;
|
||
|
||
insert_conversation(&db, "conv-early", Some("早期会话"), None).await;
|
||
insert_conv_audit(&repo, "conv-early", "create_task",
|
||
Some(r#"{"id":"t-early","title":"E"}"#.into()), t_early).await;
|
||
// 灌 1100 条更新的无关任务工具行(其他任务 id),把全表推过 1000 行窗口
|
||
let t_flood = t_early + 1_000_000;
|
||
for i in 0..1100i64 {
|
||
insert_conv_audit(&repo, "conv-noise", "update_task",
|
||
Some(format!(r#"{{"id":"noise-{}"}}"#, i)), t_flood + i).await;
|
||
}
|
||
|
||
let out = find_task_conversations(&db, "t-early").await.unwrap();
|
||
assert_eq!(out.len(), 1, "早期关联不得被 1000 行窗口挤出");
|
||
assert_eq!(out[0].conversation_id, "conv-early");
|
||
assert_eq!(out[0].title.as_deref(), Some("早期会话"));
|
||
// 预筛不放无关行进来:noise 任务查不到任何关联
|
||
assert!(
|
||
find_task_conversations(&db, "noise-1").await.unwrap().is_empty(),
|
||
"noise 会话不在库,即使 id 前缀巧合也不得列出"
|
||
);
|
||
}
|
||
|
||
/// S4 纯函数判定表:名单外工具 / 非 JSON / 缺 id / id 不等 均不命中,精确判等才命中。
|
||
#[test]
|
||
fn match_task_audit_row_truth_table() {
|
||
assert!(match_task_audit_row("create_task", Some(r#"{"id":"t1"}"#), "t1"));
|
||
assert!(!match_task_audit_row("create_task", Some(r#"{"id":"t12"}"#), "t1"), "禁 LIKE:前缀不误命中");
|
||
assert!(!match_task_audit_row("list_tasks", Some(r#"{"id":"t1"}"#), "t1"), "名单外工具不命中");
|
||
assert!(!match_task_audit_row("create_task", Some("人话文本"), "t1"), "非 JSON 不命中");
|
||
assert!(!match_task_audit_row("create_task", Some(r#"{"error":"x"}"#), "t1"), "缺 id 不命中");
|
||
assert!(!match_task_audit_row("create_task", None, "t1"), "result 空不命中");
|
||
}
|
||
|
||
/// 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 记录");
|
||
}
|
||
}
|