idea source 结构化(消息级溯源闭环 — 灵感来源 conv_msg:{id}):
- 新建 idea_source.rs(IDEA_SOURCE_AUTO_FILL_ENABLED + maybe_fill_idea_source 保守五条件:
开关/create_idea/source 空(不覆盖 AI 填)/message_id 非 None/result.id)
- audit/mod low_risk + chat ai_approve 双执行路径接补全(create_idea Medium 审批)
workspace_root 去固定根(F-260619-03 收尾):
- is_authorized 删 workspace_root 固定放行(代码硬编码),改走 persistent/session 白名单
- default_with_root 保留(默认初始 persistent,首次免授权行为不变)
- 用户从白名单删工程根后也需授权(动态白名单完整语义)
run_command 偏好修复(db 分析:591ff4a0 run_command 31/56=55% 严重偏好):
- P1 描述收紧(明确仅命令执行 + 负向引导:读取 read_file/编辑 patch_file/列目录 list_directory/搜索 search_files)
- P2 ToolDomain::Exec(run_command 独立 domain,仅 Debug 含 Exec,Code/File/Search 不含)
- P3 注册顺序(run_command 放 File 层最后)+ DEBUG_RULES 加命令执行关键词
自验: df-ai 228(intent Exec)+ devflow 169(idea source)+ workspace EXIT 0
329 lines
13 KiB
Rust
329 lines
13 KiB
Rust
//! F-260619-04 P2(方案 B): 灵感来源(create_idea)消息级溯源补全。
|
|
//!
|
|
//! ## 背景
|
|
//! P1 把知识/审计的 `source_ref` 升级到消息级(`conv_msg:{id}`),但**灵感 `create_idea`
|
|
//! 漏了**:create_idea handler 是 `Fn(Value)→result` 无 message_id 上下文,source 由 AI
|
|
//! 自由填(`args["source"]`)。AI 多数场景不填(空),致灵感丢失溯源——查不到从哪条对话长出来。
|
|
//!
|
|
//! ## 方案 B(最小,低侵入 — 不改 AiToolHandler 接口)
|
|
//! 工具执行后(process_tool_calls / 审批执行路径)拿到 handler 返回的 result + AI 入参 args,
|
|
//! 检测 `tool_name == "create_idea"`:
|
|
//! - 仅当 AI 未填 source(空 / null / 缺字段)且 message_id 可得 → 补 `source = conv_msg:{message_id}`
|
|
//! - AI 已填(非空)→ **不覆盖**(保留 AI 有意义的自由文本,如"用户口述"/"xxx 文档")
|
|
//! - message_id 缺失(None,异常路径/老数据)→ **不补**(无源可溯,保持原值,不伪造)
|
|
//!
|
|
//! ## 保守原则
|
|
//! 仅 create_idea 专用(其他工具不碰) / 仅 source 空时补(不覆盖 AI 填) / 无 message_id 不补。
|
|
//! 常量开关 `IDEA_SOURCE_AUTO_FILL_ENABLED` 可一键关闭(易迭代,出问题降级)。
|
|
|
|
use std::sync::Arc;
|
|
|
|
use df_storage::crud::IdeaRepo;
|
|
use df_storage::db::Database;
|
|
|
|
/// 一键开关:是否启用 create_idea source 自动补全。出问题改 false 即降级为 noop(不补)。
|
|
const IDEA_SOURCE_AUTO_FILL_ENABLED: bool = true;
|
|
|
|
/// 从 AI 入参 args 取 source,返回是否「AI 未填」(空 / null / 缺字段)。
|
|
///
|
|
/// 独立纯函数(便于单测):判定逻辑收敛一处,调用方与测试共享同一语义。
|
|
/// - `{"source": null}` / `{"source": ""}` / 缺 source 键 → true(未填,应补)
|
|
/// - `{"source": "用户口述"}` → false(AI 已填,不覆盖)
|
|
fn is_source_unfilled(args: &serde_json::Value) -> bool {
|
|
match args.get("source") {
|
|
None => true,
|
|
Some(serde_json::Value::Null) => true,
|
|
Some(serde_json::Value::String(s)) if s.trim().is_empty() => true,
|
|
_ => false,
|
|
}
|
|
}
|
|
|
|
/// 从 create_idea handler 返回的 result 取 idea_id(定位刚创建的灵感行)。
|
|
///
|
|
/// handler 返回 `{ "id": "...", "title": "...", "status": "draft" }`(tool_registry.rs create_idea)。
|
|
/// id 字段即 idea 表主键,用于 `IdeaRepo.update_field(id, "source", ...)`。
|
|
fn extract_idea_id(result: &serde_json::Value) -> Option<String> {
|
|
result.get("id").and_then(|v| v.as_str()).map(|s| s.to_string())
|
|
}
|
|
|
|
/// 灵感来源消息级溯源补全(方案 B 核心)。
|
|
///
|
|
/// **保守**:仅在以下条件全部满足时补 source = `conv_msg:{message_id}`:
|
|
/// 1. `IDEA_SOURCE_AUTO_FILL_ENABLED == true`(一键开关)
|
|
/// 2. `tool_name == "create_idea"`(专用,其他工具不碰)
|
|
/// 3. `args.source` 未填(空 / null / 缺字段)—— AI 已填则**不覆盖**
|
|
/// 4. `message_id` 为 `Some`(无源可溯的异常/老数据不补,不伪造)
|
|
/// 5. result 含 `id`(idea_id,定位刚创建的灵感行)
|
|
///
|
|
/// 补全失败(库写失败等)仅 `tracing::warn` 不传播——溯源补全是 best-effort 增益,
|
|
/// 不应阻断工具已成功的执行流程(灵感已创建,source 缺失只是少了溯源,非业务失败)。
|
|
///
|
|
/// 单点逻辑(本函数)+ 多调用点(process_tool_calls 各执行路径 + 审批执行 chat.rs):
|
|
/// 调用点只管传 (tool_name, args, result, message_id),判定/补全/容错全在本函数。
|
|
pub async fn maybe_fill_idea_source(
|
|
db: &Arc<Database>,
|
|
tool_name: &str,
|
|
args: &serde_json::Value,
|
|
result: &serde_json::Value,
|
|
message_id: Option<&str>,
|
|
) {
|
|
// 条件 1: 一键开关
|
|
if !IDEA_SOURCE_AUTO_FILL_ENABLED {
|
|
return;
|
|
}
|
|
// 条件 2: 仅 create_idea 专用
|
|
if tool_name != "create_idea" {
|
|
return;
|
|
}
|
|
// 条件 3: AI 未填 source 才补(AI 已填不覆盖)
|
|
if !is_source_unfilled(args) {
|
|
return;
|
|
}
|
|
// 条件 4: message_id 缺失不补(无源可溯,不伪造)
|
|
let msg_id = match message_id {
|
|
Some(id) if !id.is_empty() => id,
|
|
_ => return,
|
|
};
|
|
// 条件 5: result 含 idea_id(定位刚创建的灵感行)
|
|
let idea_id = match extract_idea_id(result) {
|
|
Some(id) => id,
|
|
None => {
|
|
tracing::warn!(
|
|
tool = tool_name,
|
|
"[idea-source] create_idea result 缺 id 字段,无法定位灵感行补 source(降级跳过)"
|
|
);
|
|
return;
|
|
}
|
|
};
|
|
|
|
let source_val = format!("conv_msg:{}", msg_id);
|
|
let repo = IdeaRepo::new(db);
|
|
match repo.update_field(&idea_id, "source", &source_val).await {
|
|
Ok(true) => {
|
|
tracing::info!(
|
|
idea_id = %idea_id,
|
|
message_id = %msg_id,
|
|
"[idea-source] 补全灵感来源消息级溯源: source = conv_msg:{}",
|
|
msg_id
|
|
);
|
|
}
|
|
Ok(false) => {
|
|
// update_field 返 false = 影响行数 0(idea_id 不存在,异常,但灵感刚创建理论上必存在)
|
|
tracing::warn!(
|
|
idea_id = %idea_id,
|
|
"[idea-source] update_field 影响 0 行(idea_id 不存在?);source 未补,灵感已创建不受影响"
|
|
);
|
|
}
|
|
Err(e) => {
|
|
// best-effort:溯源补全失败不阻断工具成功流程,仅 warn
|
|
tracing::warn!(
|
|
idea_id = %idea_id,
|
|
error = %e,
|
|
"[idea-source] 补全灵感来源失败(降级跳过,灵感已创建不受影响)"
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
// ============================================================
|
|
// 单元测试 — 判定纯函数 + 补全全链路(内存 DB)
|
|
// ============================================================
|
|
|
|
// ============================================================
|
|
// 测试辅助(仅 cfg(test))— 内存 DB + 插入 IdeaRecord fixture
|
|
// ============================================================
|
|
// 独立 #[cfg(test)] 子模块:供本文件 tests 与同 crate 其他测试复用(防 DRY)。
|
|
// 不暴露到非测试构建(避免误用内存 DB 做 prod 路径)。
|
|
|
|
#[cfg(test)]
|
|
mod idea_source_test_helpers {
|
|
use std::sync::Arc;
|
|
|
|
use df_storage::crud::IdeaRepo;
|
|
use df_storage::db::Database;
|
|
use df_storage::models::IdeaRecord;
|
|
|
|
/// 建内存 DB(已跑 migrations,含 ideas 表)+ 插一条 IdeaRecord fixture,返回 db 句柄。
|
|
///
|
|
/// `source`: None → 插 source=NULL(模拟 AI 未填);Some(s) → 插已填值。
|
|
/// 其余字段用合理默认(title/description/status=draft/priority=1)。
|
|
pub async fn idea_source_fixture_record(id: &str, source: Option<&str>) -> Arc<Database> {
|
|
let db = Database::open_in_memory().await.expect("open_in_memory");
|
|
let repo = IdeaRepo::new(&db);
|
|
let now = df_types::now_millis().to_string();
|
|
repo.insert(IdeaRecord {
|
|
id: id.to_string(),
|
|
title: format!("fixture-{}", id),
|
|
description: String::new(),
|
|
status: "draft".to_string(),
|
|
priority: 1,
|
|
score: None,
|
|
tags: None,
|
|
source: source.map(|s| s.to_string()),
|
|
promoted_to: None,
|
|
ai_analysis: None,
|
|
scores: None,
|
|
created_at: now.clone(),
|
|
updated_at: now,
|
|
})
|
|
.await
|
|
.expect("insert fixture idea");
|
|
Arc::new(db)
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::idea_source_test_helpers::idea_source_fixture_record;
|
|
use super::*;
|
|
|
|
// ---------- is_source_unfilled 纯函数 ----------
|
|
|
|
#[test]
|
|
fn source_missing_key_is_unfilled() {
|
|
let args = serde_json::json!({ "title": "t" });
|
|
assert!(is_source_unfilled(&args));
|
|
}
|
|
|
|
#[test]
|
|
fn source_null_is_unfilled() {
|
|
let args = serde_json::json!({ "title": "t", "source": null });
|
|
assert!(is_source_unfilled(&args));
|
|
}
|
|
|
|
#[test]
|
|
fn source_empty_string_is_unfilled() {
|
|
let args = serde_json::json!({ "title": "t", "source": "" });
|
|
assert!(is_source_unfilled(&args));
|
|
}
|
|
|
|
#[test]
|
|
fn source_whitespace_only_is_unfilled() {
|
|
let args = serde_json::json!({ "title": "t", "source": " " });
|
|
assert!(is_source_unfilled(&args));
|
|
}
|
|
|
|
#[test]
|
|
fn source_non_empty_is_filled() {
|
|
let args = serde_json::json!({ "title": "t", "source": "用户口述" });
|
|
assert!(!is_source_unfilled(&args));
|
|
}
|
|
|
|
// ---------- extract_idea_id ----------
|
|
|
|
#[test]
|
|
fn extract_idea_id_from_result() {
|
|
let result = serde_json::json!({ "id": "idea-1", "title": "t", "status": "draft" });
|
|
assert_eq!(extract_idea_id(&result).as_deref(), Some("idea-1"));
|
|
}
|
|
|
|
#[test]
|
|
fn extract_idea_id_missing_returns_none() {
|
|
let result = serde_json::json!({ "title": "t" });
|
|
assert!(extract_idea_id(&result).is_none());
|
|
}
|
|
|
|
// ---------- maybe_fill_idea_source 全链路(内存 DB)----------
|
|
|
|
/// create_idea + source 空 + message_id 有 → 补 conv_msg:{id}
|
|
#[tokio::test]
|
|
async fn fills_source_when_unfilled_and_message_id_present() {
|
|
let db = idea_source_fixture_record("idea-1", None).await;
|
|
let args = serde_json::json!({ "title": "t" }); // source 缺
|
|
let result = serde_json::json!({ "id": "idea-1", "title": "t", "status": "draft" });
|
|
|
|
maybe_fill_idea_source(&db, "create_idea", &args, &result, Some("msg-42")).await;
|
|
|
|
let repo = IdeaRepo::new(&db);
|
|
let rec = repo.get_by_id("idea-1").await.unwrap().expect("idea-1 存在");
|
|
assert_eq!(rec.source.as_deref(), Some("conv_msg:msg-42"));
|
|
}
|
|
|
|
/// create_idea + AI 已填 source → 不覆盖
|
|
#[tokio::test]
|
|
async fn does_not_overwrite_when_ai_filled_source() {
|
|
let db = idea_source_fixture_record("idea-2", Some("用户口述")).await;
|
|
let args = serde_json::json!({ "title": "t", "source": "用户口述" });
|
|
let result = serde_json::json!({ "id": "idea-2", "title": "t", "status": "draft" });
|
|
|
|
maybe_fill_idea_source(&db, "create_idea", &args, &result, Some("msg-42")).await;
|
|
|
|
let repo = IdeaRepo::new(&db);
|
|
let rec = repo.get_by_id("idea-2").await.unwrap().expect("idea-2 存在");
|
|
assert_eq!(rec.source.as_deref(), Some("用户口述"), "AI 已填 source 不被覆盖");
|
|
}
|
|
|
|
/// message_id 缺失(None)→ 不补
|
|
#[tokio::test]
|
|
async fn does_not_fill_when_message_id_none() {
|
|
let db = idea_source_fixture_record("idea-3", None).await;
|
|
let args = serde_json::json!({ "title": "t" });
|
|
let result = serde_json::json!({ "id": "idea-3", "title": "t", "status": "draft" });
|
|
|
|
maybe_fill_idea_source(&db, "create_idea", &args, &result, None).await;
|
|
|
|
let repo = IdeaRepo::new(&db);
|
|
let rec = repo.get_by_id("idea-3").await.unwrap().expect("idea-3 存在");
|
|
assert!(rec.source.is_none(), "无 message_id 不补 source");
|
|
}
|
|
|
|
/// message_id 空串("")→ 不补(等同 None 语义,防伪造)
|
|
#[tokio::test]
|
|
async fn does_not_fill_when_message_id_empty_string() {
|
|
let db = idea_source_fixture_record("idea-3b", None).await;
|
|
let args = serde_json::json!({ "title": "t" });
|
|
let result = serde_json::json!({ "id": "idea-3b", "title": "t", "status": "draft" });
|
|
|
|
maybe_fill_idea_source(&db, "create_idea", &args, &result, Some("")).await;
|
|
|
|
let repo = IdeaRepo::new(&db);
|
|
let rec = repo.get_by_id("idea-3b").await.unwrap().expect("idea-3b 存在");
|
|
assert!(rec.source.is_none(), "空串 message_id 不补 source");
|
|
}
|
|
|
|
/// 非 create_idea 工具 → 不碰(其他工具不受影响)
|
|
#[tokio::test]
|
|
async fn does_not_touch_other_tools() {
|
|
let db = idea_source_fixture_record("idea-4", None).await;
|
|
let args = serde_json::json!({ "title": "t" });
|
|
let result = serde_json::json!({ "id": "idea-4" });
|
|
|
|
maybe_fill_idea_source(&db, "write_file", &args, &result, Some("msg-42")).await;
|
|
|
|
let repo = IdeaRepo::new(&db);
|
|
let rec = repo.get_by_id("idea-4").await.unwrap().expect("idea-4 存在");
|
|
assert!(rec.source.is_none(), "非 create_idea 不碰 source");
|
|
}
|
|
|
|
/// result 缺 id 字段 → 不补(降级 warn,不 panic)
|
|
#[tokio::test]
|
|
async fn does_not_fill_when_result_missing_id() {
|
|
let db = idea_source_fixture_record("idea-5", None).await;
|
|
let args = serde_json::json!({ "title": "t" });
|
|
let result = serde_json::json!({ "title": "t", "status": "draft" }); // 无 id
|
|
|
|
maybe_fill_idea_source(&db, "create_idea", &args, &result, Some("msg-42")).await;
|
|
|
|
let repo = IdeaRepo::new(&db);
|
|
let rec = repo.get_by_id("idea-5").await.unwrap().expect("idea-5 存在");
|
|
assert!(rec.source.is_none(), "result 缺 id 时不补,保持原值");
|
|
}
|
|
|
|
/// idea_id 不存在于库 → 不 panic(0 行影响 warn)
|
|
#[tokio::test]
|
|
async fn does_not_panic_when_idea_id_not_in_db() {
|
|
let db = idea_source_fixture_record("idea-6", None).await;
|
|
let args = serde_json::json!({ "title": "t" });
|
|
// result 指向不存在的 idea-x
|
|
let result = serde_json::json!({ "id": "idea-nonexistent", "title": "t", "status": "draft" });
|
|
|
|
// 不应 panic,仅 warn
|
|
maybe_fill_idea_source(&db, "create_idea", &args, &result, Some("msg-42")).await;
|
|
|
|
// idea-6 原值不变
|
|
let repo = IdeaRepo::new(&db);
|
|
let rec = repo.get_by_id("idea-6").await.unwrap().expect("idea-6 存在");
|
|
assert!(rec.source.is_none());
|
|
}
|
|
}
|