//! 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 { 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, 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 { 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()); } }