优化: 上下文管理与提示词链路批

- 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 子件目录)
This commit is contained in:
lxy
2026-10-26 01:36:00 +08:00
parent 23e505bc40
commit a30f599fe2
59 changed files with 2651 additions and 2541 deletions
+363
View File
@@ -0,0 +1,363 @@
//! 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}");
}
}