- T1 usage 口径: anthropic usage 两层容错+cache 三口径归一(prompt=input+read+creation) - T2 压缩水位判据: compress_should_trigger 双模式+ratio 钳位,开关关逐字节等价 - T3 跨会话检索: history_search 工具(两跳式)+message_repo 关键词检索+prompt 指引 - T4 代语义闭环: status 终局补写+飞书停靠/换代推卡+交付引导+watcher 兜底补漏 - 段序缓存重排: build_runtime_prompt 恒静段上移/工作上下文压尾(前缀缓存失效面收窄) - 智谱缓存解析: anthropic 臂补 prompt_tokens_details.cached_tokens 嵌套回退 - 工时禁令: 提示词双语加「禁止预估工时,AI Coding 工时无意义」 - 拖拽文件落盘: 非图片附件存引用行,防整段文本灌入输入框 - 事实修正: 提示词「40+ 工具」→「80+」(基线 81);tool_failure_memory 测试串行锁除竞态 - UI 拆分: TopBar/ToolResultBody 壳层化(top-bar/tool-result-body 子件目录)
364 lines
17 KiB
Rust
364 lines
17 KiB
Rust
//! 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/<conversation_id>-archive.jsonl(read_file)。";
|
|
|
|
/// 注册 history_search 工具。
|
|
pub fn register(registry: &mut AiToolRegistry, db: &Arc<Database>) {
|
|
declare_tool!(
|
|
registry,
|
|
db: Arc<Database>,
|
|
"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<serde_json::Value> = 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<String> = HashSet::new();
|
|
let results: Vec<serde_json::Value> = 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<Database> {
|
|
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}");
|
|
}
|
|
}
|