Files
DevFlow/src-tauri/src/commands/ai/audit/record.rs
T
lxy cb23061088 新增: 跨会话召回四层架构与脉络卡
- 会话终态提炼: 删除前+停靠双挂点,快照与清水位同临界区
- 目标快照: 恢复引导按最终有效意图生成,旧目标不复活
- 知识检索项目维度软排序,WorkingContext 持久化迁移
- 脉络卡: published 知识按项目聚合纯读侧,带入上下文经输入框显式注入
- 压缩与缓存能力在主循环/子代理/提示词三面接线
- 两份专项设计文档与看板销账
2026-10-11 22:27:20 +08:00

756 lines
34 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_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 记录");
}
}