//! history_search AI 工具声明式注册(跨会话历史检索,devflow 等价 u-claw #5/#6)。 //! //! 背景:P1-A 跨会话检索(分析:temp/分析-P1A-跨会话检索-2026-10-21.md)。ai_messages //! 是全量账本(compressed 消息原文完整保留),检索面覆盖即全覆盖,零迁移零回填。 //! 查询内核与 IPC search_messages 同源(commands/search.rs::keyword_search / //! recent_conversation_briefs),本工具是 AI 通道的薄包装(DRY 无双轨)。 //! //! ## 两跳式瘦身(拍板②) //! 首跳(不带 conversation_id)= 每会话一条 120 字片段 + 会话元信息的会话级概览; //! 二跳传 conversation_id+query 深查该会话逐条命中(片段 300 字)。 //! 预算纪律:工具结果整体对齐 2K 收敛(namespace_store.rs:32 NAMESPACE_BYTE_THRESHOLD= //! 2048,超限由 namespace 引用化机制兜底转引用);首跳默认 5 会话 × 120 字天然在预算内, //! 深跳若超预算走 namespace 引用化属机制设计路径,不做人为硬截。 //! //! ## 无参降级(拍板③) //! query 空 = 列最近 10 会话概览(标题+摘要+时间,一工具两语义,u-claw #5 同行为); //! 摘要走降级链(拍板⑤):最近一条 system 压缩摘要 > 首条 user 消息,截 200 字。 //! //! 风险:Low(纯只读),免审批,knowledge_search 同级。 use std::collections::HashSet; use std::sync::Arc; use df_ai::ai_tools::{object_schema, AiToolRegistry, RiskLevel}; use df_ai::declare_tool; use df_storage::db::Database; use crate::commands::ai::audit::truncate_chars_ellipsis; use crate::commands::ai::commands::search::{keyword_search, recent_conversation_briefs}; use crate::commands::ai::knowledge_inject::extract_query_keywords; /// 首跳单会话片段长度(会话级概览,两跳式瘦身核心参数)。 const FIRST_HOP_SNIPPET_CHARS: usize = 120; /// 深跳逐条命中片段长度(单跳备选方案 limit5×300 字口径)。 const DEEP_HOP_SNIPPET_CHARS: usize = 300; /// 无参降级列最近会话条数(u-claw #5 口径)。 const RECENT_CONVS_COUNT: usize = 10; /// 工具 limit 默认与上限(与 knowledge_search 同手感)。 const TOOL_DEFAULT_LIMIT: usize = 5; const TOOL_MAX_LIMIT: usize = 10; /// 首跳为凑满 limit 个会话多取的消息数(单会话多命中会挤占名额,6 倍起步封顶 60)。 const FIRST_HOP_FETCH_CAP: usize = 60; /// 命中会话压缩原文回读指引(存档路径约定,与 archive.rs 召回句柄同款文案)。 const ARCHIVE_RECALL_HINT: &str = "被压缩的早期原文可按存档路径回读:应用数据目录 context-archive/-archive.jsonl(read_file)。"; /// 注册 history_search 工具。 pub fn register(registry: &mut AiToolRegistry, db: &Arc) { declare_tool!( registry, db: Arc, "history_search", "跨会话历史检索:在全部历史会话的消息正文(user/assistant/system,含已压缩原文)中按关键词检索。使用时机:用户提到「上次/之前/历史」的任务、结论或做法时调用,如「上次那个部署怎么弄的」「之前说过的 XX 约定」。两跳式:首跳不带 conversation_id,返回每会话一条命中片段(会话级概览,含 conversation_id);需要某会话更多上下文时二跳传 conversation_id 深查逐条命中。参数:query=关键词或自然语言(可省略,省略时降级列最近会话概览:标题+摘要+时间);conversation_id=限定会话深查(可选,须与 query 同传);limit=返回会话/消息条数(可选,默认 5,上限 10)", RiskLevel::Low, schema: object_schema(vec![ ("query", "string", false), ("conversation_id", "string", false), ("limit", "integer", false), ]), args => { let query = args["query"].as_str().unwrap_or("").trim().to_string(); let conversation_id = args["conversation_id"] .as_str() .map(|s| s.trim().to_string()) .filter(|s| !s.is_empty()); let limit = args["limit"] .as_u64() .unwrap_or(TOOL_DEFAULT_LIMIT as u64) .clamp(1, TOOL_MAX_LIMIT as u64) as usize; // 无参降级(拍板③):query 空 → 列最近会话概览。conversation_id 与空 query // 同传语义不明,显式报错防 LLM 猜。 if query.is_empty() { if conversation_id.is_some() { anyhow::bail!("conversation_id 深查须与 query 同传;列最近会话请省略 query"); } let briefs = recent_conversation_briefs(&db, RECENT_CONVS_COUNT) .await .map_err(anyhow::Error::msg)?; return Ok(serde_json::json!({ "mode": "recent_conversations", "conversations": briefs, "hint": "以上为最近会话概览(标题+摘要+时间)。按关键词检索全部历史消息请传 query;深查某会话传 conversation_id+query。", })); } match conversation_id { Some(conv_id) => { // 二跳深查:会话内逐条命中(片段 300 字,超 2K 由 namespace 引用化兜底) let hits = keyword_search(&db, &query, Some(&conv_id), limit, 0) .await .map_err(anyhow::Error::msg)?; if hits.is_empty() { return Ok(serde_json::json!({ "mode": "messages", "conversation_id": conv_id, "results": [], "hint": "该会话内无命中。可换关键词重试,或去掉 conversation_id 全局检索定位会话。", })); } let title = hits[0].title.clone(); let results: Vec = hits .into_iter() .map(|h| { serde_json::json!({ "seq": h.seq, "role": h.role, "timestamp": h.timestamp, "snippet": truncate_chars_ellipsis(&h.snippet, DEEP_HOP_SNIPPET_CHARS), }) }) .collect(); Ok(serde_json::json!({ "mode": "messages", "conversation_id": conv_id, "title": title, "results": results, "hint": ARCHIVE_RECALL_HINT, })) } None => { // 首跳:每会话一条首命中片段的会话级概览(拍板②两跳式瘦身)。 // 命中已按 timestamp DESC,保序按会话去重即「每会话最新一条」。 let fetch = (limit * 6).clamp(TOOL_DEFAULT_LIMIT * 6, FIRST_HOP_FETCH_CAP); let hits = keyword_search(&db, &query, None, fetch, 0) .await .map_err(anyhow::Error::msg)?; if hits.is_empty() { // 分词后无有效关键词与「真无命中」分开给指引(前者换词,后者换法) let hint = if extract_query_keywords(&query).is_empty() { "query 分词后无有效关键词(全是停用词/标点),请换更具体的关键词(任务名/文件名/技术词)。" } else { "历史会话中无命中。可换关键词重试;或省略 query 列最近会话概览。" }; return Ok(serde_json::json!({ "mode": "conversations", "results": [], "hint": hint, })); } let mut seen: HashSet = HashSet::new(); let results: Vec = hits .into_iter() .filter(|h| seen.insert(h.conversation_id.clone())) .take(limit) .map(|h| { serde_json::json!({ "conversation_id": h.conversation_id, "title": h.title, "updated_at": h.updated_at, "origin": h.origin, "snippet": truncate_chars_ellipsis(&h.snippet, FIRST_HOP_SNIPPET_CHARS), }) }) .collect(); Ok(serde_json::json!({ "mode": "conversations", "results": results, "hint": format!("以上为每会话一条首命中片段(会话级概览)。需某会话更多上下文时传 conversation_id+query 深查逐条命中;{ARCHIVE_RECALL_HINT}"), })) } } } ); } #[cfg(test)] mod tests { use super::*; use df_storage::crud::{AiConversationRepo, AiMessageRepo}; use df_storage::models::{AiConversationRecord, AiMessageRecord}; use serde_json::json; async fn test_db() -> Arc { Arc::new(Database::open_in_memory().await.unwrap()) } async fn ins_conv(db: &Database, id: &str, title: &str, updated_at: &str) { AiConversationRepo::new(db) .insert(AiConversationRecord { id: id.into(), title: Some(title.into()), messages: "[]".into(), 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: "100".into(), updated_at: updated_at.into(), origin: None, boundary_json: None, loop_state: None, status: "active".into(), working_context_json: None, }) .await .unwrap(); } fn msg(conv: &str, seq: i64, role: &str, content: &str, timestamp: i64) -> AiMessageRecord { AiMessageRecord { id: format!("m_{conv}_{seq}"), conversation_id: conv.into(), seq, role: role.into(), content: content.into(), parts: None, tool_call_id: None, tool_calls: None, model: None, status: "active".into(), reasoning_content: None, timestamp: Some(timestamp), created_at: "100".into(), prompt_tokens: None, completion_tokens: None, prompt_cache_hit_tokens: None, prompt_cache_miss_tokens: None, reasoning_tokens: None, is_estimated: None, source: None, } } /// 首跳瘦身(拍板②):单会话多命中只出一条(会话级去重),片段截 120 字, /// 带 conversation_id 供二跳深查;空结果给指引非裸空数组。 #[tokio::test] async fn first_hop_groups_per_conversation_with_slim_snippet() { let db = test_db().await; ins_conv(&db, "c1", "部署踩坑复盘", "200").await; ins_conv(&db, "c2", "端口约定", "300").await; AiMessageRepo::new(&db) .insert_batch(vec![ msg("c1", 0, "user", "部署第一次尝试失败", 100), msg("c1", 1, "assistant", "部署第二次改用代理成功", 200), msg("c2", 0, "user", "部署统一走 3307 端口", 300), ]) .await .unwrap(); let mut registry = AiToolRegistry::new(); register(&mut registry, &db); let out = registry .execute("history_search", json!({"query": "部署", "limit": 5})) .await .unwrap(); assert_eq!(out["mode"], "conversations"); let results = out["results"].as_array().unwrap(); assert_eq!(results.len(), 2, "两会话各一条,单会话多命中去重: {out}"); let ids: Vec<&str> = results.iter().map(|r| r["conversation_id"].as_str().unwrap()).collect(); assert_eq!(ids, vec!["c2", "c1"], "timestamp DESC,c2 最新在前"); for r in results { let snippet = r["snippet"].as_str().unwrap(); assert!( snippet.chars().count() <= FIRST_HOP_SNIPPET_CHARS + 1, "首跳片段应截 120 字(+省略号),实际 {}: {snippet}", snippet.chars().count() ); } let hint = out["hint"].as_str().unwrap(); assert!(hint.contains("conversation_id"), "首跳应提示可二跳深查: {hint}"); } /// 二跳深查:conversation_id+query 返回该会话逐条命中(mode=messages)。 #[tokio::test] async fn deep_hop_returns_message_level_hits() { let db = test_db().await; ins_conv(&db, "c1", "部署复盘", "200").await; AiMessageRepo::new(&db) .insert_batch(vec![ msg("c1", 0, "user", "部署怎么弄", 100), msg("c1", 1, "assistant", "部署分三步:构建/上传/重启", 200), msg("c1", 2, "tool", "部署工具输出", 300), ]) .await .unwrap(); let mut registry = AiToolRegistry::new(); register(&mut registry, &db); let out = registry .execute( "history_search", json!({"query": "部署", "conversation_id": "c1"}), ) .await .unwrap(); assert_eq!(out["mode"], "messages"); assert_eq!(out["title"], "部署复盘"); let results = out["results"].as_array().unwrap(); assert_eq!(results.len(), 2, "深跳逐条命中但 tool role 仍排除: {out}"); assert_eq!(results[0]["seq"], 1, "timestamp DESC,assistant 在前"); } /// 无参降级(拍板③+⑤):query 空列最近会话,摘要走「system 压缩摘要 > 首条 user」 /// 降级链;conversation_id 与空 query 同传显式报错。 #[tokio::test] async fn no_param_falls_back_to_recent_conversations_with_summary_chain() { let db = test_db().await; ins_conv(&db, "c_sum", "有摘要", "200").await; ins_conv(&db, "c_user", "无摘要", "300").await; AiMessageRepo::new(&db) .insert_batch(vec![ msg("c_sum", 0, "user", "c_sum 的用户消息不应入选", 1), msg("c_sum", 1, "system", "## 意图\n- c_sum 压缩摘要优先", 2), msg("c_user", 0, "user", "c_user 首条消息作摘要兜底", 3), ]) .await .unwrap(); let mut registry = AiToolRegistry::new(); register(&mut registry, &db); let out = registry.execute("history_search", json!({})).await.unwrap(); assert_eq!(out["mode"], "recent_conversations"); let convs = out["conversations"].as_array().unwrap(); assert_eq!(convs.len(), 2, "c_user(300)在前 c_sum(200)在后: {out}"); let c_sum = convs.iter().find(|c| c["id"] == "c_sum").unwrap(); assert!(c_sum["summary"].as_str().unwrap().contains("压缩摘要优先"), "摘要走 system 优先: {out}"); let c_user = convs.iter().find(|c| c["id"] == "c_user").unwrap(); assert!(c_user["summary"].as_str().unwrap().contains("首条消息"), "无 system 走首条 user 兜底: {out}"); // conversation_id 与空 query 同传 → 明确报错,不静默当列表用 let err = registry .execute("history_search", json!({"conversation_id": "c1"})) .await .unwrap_err(); assert!(err.to_string().contains("query"), "报错应指引带 query: {err}"); } /// 空命中给可执行指引(非裸空数组);分词无有效关键词与真无命中分开指引。 #[tokio::test] async fn empty_results_carry_actionable_hint() { let db = test_db().await; ins_conv(&db, "c1", "无关会话", "200").await; AiMessageRepo::new(&db) .insert_batch(vec![msg("c1", 0, "user", "完全无关的内容", 1)]) .await .unwrap(); let mut registry = AiToolRegistry::new(); register(&mut registry, &db); // 真无命中 let out = registry .execute("history_search", json!({"query": "量子纠缠实验"})) .await .unwrap(); assert_eq!(out["results"].as_array().unwrap().len(), 0); assert!(out["hint"].as_str().unwrap().contains("换关键词"), "真无命中指引: {out}"); // 分词后无有效关键词(纯停用词) let out2 = registry .execute("history_search", json!({"query": "帮我看看一下这个"})) .await .unwrap(); assert!(out2["hint"].as_str().unwrap().contains("关键词"), "分词空指引: {out2}"); } }