新增: 消息级溯源 P2 切 ai_messages + AI Chat 跑题改进 P0-P2 + 标题诊断 + 苛刻测

消息级溯源 P2(一次性切读 b + 备份保留):
- 批次A 读路径切 ai_messages(state.rs AppState.ai_messages + 映射 ChatMessage↔AiMessageRecord +
  switch/export load 切 + fallback 兜底)
- 批次B 写路径切 ai_messages(replace_conversation 单事务全量重写 + save/clear 切 + 旧 messages 备份)
- 摘要/压缩不改 updated_at(save_conversation touch_updated_at,compress false 防时间分组跳变)

AI Chat 跑题改进 P0-P2(5 改进,治跑题 5 根因,三原则优雅/可靠/易迭代):
- P0 系统提示聚焦(## 聚焦准则独立段)+ 意图接入 loop(filter_tool_defs 收敛工具 29→5-10,三重 fallback)
- P1 压缩增强(compress prompt 主题锚点 + 失败关键词兜底 extract_keyword_summary)+
  工具结果压缩(should_summarize + extract_key_info view-only 不改持久化)
- P2 主题检测(TrackedMessage.topic + 双高置信保守标记 + tokenize 2-gram 修复中文锚点)
- title LLM complete 失败诊断日志(助定位返空根因)
- 跑题 P0 审查🟡修复(Debug 加 Data 域 + threshold 注释)

苛刻测 38 条(边界/对抗:全漂移/全停用词/全错误行/2KB 边界/连续主题切换/中文 2-gram/emoji)
df-ai 227 passed + workspace EXIT 0
This commit is contained in:
2026-06-20 03:54:58 +08:00
parent 4e3a11f925
commit de049704c6
11 changed files with 2434 additions and 44 deletions

View File

@@ -337,11 +337,11 @@ fn best_in_group(message: &str, group: &[IntentGroup]) -> Option<(Intent, f32)>
///
/// 设计Code → [file, http]File → [file]Project/Task/Idea → [data]
/// Http → [http]Search → [file](含 search_filesConversation → [];
/// Chat → []Debug → [file, http](调试常需读文件+查 APIUnknown → [](全量)。
/// Chat → []Debug → [file, http, data](调试常需读文件+查 API+查任务/工作流状态,CR-25 审查🟡-1 加 data 防"调试任务"丢 Data 工具Unknown → [](全量)。
pub fn tool_subset_for(intent: &Intent) -> Vec<&'static str> {
let domains: &[ToolDomain] = match intent {
Intent::Code => &[ToolDomain::File, ToolDomain::Http],
Intent::Debug => &[ToolDomain::File, ToolDomain::Http],
Intent::Debug => &[ToolDomain::File, ToolDomain::Http, ToolDomain::Data],
Intent::File => &[ToolDomain::File],
Intent::Project => &[ToolDomain::Data],
Intent::Task => &[ToolDomain::Data],
@@ -378,6 +378,34 @@ pub fn suggested_model_tier(_intent: &Intent) -> Option<ModelTier> {
None
}
// ---- 工具子集过滤agentic loop 接入用改进2 A -----------------------------
/// 按意图过滤 `tool_defs`subset 非空只留命中,空返回全量(低置信/Unknown/Chat fallback
///
/// **接入语义**改进2 Bagentic loop 在调用 LLM 前用本函数收敛 LLM 可见工具集,
/// 减少跑题(如纯闲聊不暴露文件操作工具)。三重 fallback 保证安全:
/// 1. `tool_subset_for(intent)` 空Chat/Unknown/Conversation→ 返回 `all_defs` 全量。
/// 2. subset 工具名在 `all_defs` 找不到 → 跳过该名(防 registry 漂移导致过滤后为空)。
/// 3. 调用方再做「过滤后 < 3 条 → 回全量」的兜底(见 agentic loop
///
/// **关键安全**filter 仅影响 LLM 可见 tool_defs**不影响执行**audit 走 tools_arc
/// get/execute 完整 registryLLM 即使幻觉一个被滤掉的工具名audit 也能查到/拒绝)。
pub fn filter_tool_defs(
all_defs: &[df_ai_core::types::ToolDefinition],
intent: &Intent,
) -> Vec<df_ai_core::types::ToolDefinition> {
let subset = tool_subset_for(intent);
if subset.is_empty() {
return all_defs.to_vec();
}
let allowed: std::collections::HashSet<&str> = subset.iter().copied().collect();
all_defs
.iter()
.filter(|d| allowed.contains(d.function.name.as_str()))
.cloned()
.collect()
}
// ---- 单测 -------------------------------------------------------------------
#[cfg(test)]
@@ -679,4 +707,234 @@ mod tests {
let (i, _) = IntentRecognizer::recognize("hi");
assert_eq!(i, Intent::Chat);
}
// --- filter_tool_defs改进2 A ---
/// 构造测试用 ToolDefinition仅 name 有意义description/parameters 填占位)。
fn tool_def(name: &str) -> df_ai_core::types::ToolDefinition {
df_ai_core::types::ToolDefinition {
tool_type: "function".to_string(),
function: df_ai_core::types::ToolFunction {
name: name.to_string(),
description: String::new(),
parameters: serde_json::json!({}),
},
}
}
#[test]
fn filter_subset_empty_returns_all_for_unknown() {
// Unknown → subset 空 → fallback 全量
let all = vec![tool_def("read_file"), tool_def("create_project")];
let out = filter_tool_defs(&all, &Intent::Unknown);
assert_eq!(out.len(), all.len(), "Unknown subset 空应回全量");
}
#[test]
fn filter_subset_empty_returns_all_for_chat() {
// Chat → subset 空 → fallback 全量(低意图/闲聊不收敛)
let all = vec![tool_def("read_file"), tool_def("write_file")];
let out = filter_tool_defs(&all, &Intent::Chat);
assert_eq!(out.len(), all.len(), "Chat subset 空应回全量");
}
#[test]
fn filter_subset_empty_returns_all_for_conversation() {
let all = vec![tool_def("read_file")];
let out = filter_tool_defs(&all, &Intent::Conversation);
assert_eq!(out.len(), 1, "Conversation subset 空应回全量");
}
#[test]
fn filter_subset_hit_filters_to_matching() {
// File 意图 subset = File domain(read_file/write_file/...);Http 工具应被滤掉
let all = vec![
tool_def("read_file"),
tool_def("write_file"),
tool_def("http_request"), // 不在 File domain
];
let out = filter_tool_defs(&all, &Intent::File);
assert!(out.iter().any(|d| d.function.name == "read_file"));
assert!(out.iter().any(|d| d.function.name == "write_file"));
assert!(
!out.iter().any(|d| d.function.name == "http_request"),
"File 意图不应含 http_request"
);
assert_eq!(out.len(), 2);
}
#[test]
fn filter_subset_drift_skips_missing_names() {
// subset 命中但 all_defs 里没有对应工具(registry 漂移)→ 跳过,不 panic
// Project 意图 subset = Data domain(create_project/...),all 里只放了一个 Data 工具 + 一个无关工具
let all = vec![
tool_def("create_project"), // 命中
tool_def("http_request"), // 不在 Data domain,滤掉
// 其余 Data domain 工具名在 subset 里但 all 没有 → 跳过
];
let out = filter_tool_defs(&all, &Intent::Project);
assert_eq!(out.len(), 1);
assert_eq!(out[0].function.name, "create_project");
}
#[test]
fn filter_all_drift_returns_empty_not_panic() {
// 极端漂移:subset 命中但 all_defs 完全不交集 → 返回空(调用方做 <3 回全量兜底)
let all = vec![tool_def("totally_unknown_tool")];
let out = filter_tool_defs(&all, &Intent::File);
assert!(out.is_empty(), "全部漂移应返空(交调用方兜底)");
}
#[test]
fn filter_preserves_input_order_for_matching() {
// 过滤后顺序应跟 all_defs 一致(filter 保留原序)
let all = vec![
tool_def("write_file"),
tool_def("read_file"),
tool_def("patch_file"),
tool_def("http_request"),
];
let out = filter_tool_defs(&all, &Intent::File);
let names: Vec<&str> = out.iter().map(|d| d.function.name.as_str()).collect();
assert_eq!(names, vec!["write_file", "read_file", "patch_file"]);
}
// ===== 苛刻测:对抗 + 边界 + 极端(filter_tool_defs / tool_subset_for) =====
#[test]
fn filter_total_drift_subset_names_none_in_registry_returns_empty_no_panic() {
// 对抗:registry 全改名(File subset 工具名一个不在 all_defs)
// → filter 返空 Vec(调用方 <3 回全量兜底)。证不 panic。
let all = vec![
tool_def("renamed_read_file_v2"),
tool_def("totally_other_tool"),
tool_def("weird_name_xyz"),
];
let out = filter_tool_defs(&all, &Intent::File);
assert!(out.is_empty(), "全漂移应返空(交调用方兜底), 实际: {}", out.len());
}
#[test]
fn filter_partial_drift_keeps_only_intersecting() {
// 对抗:subset 含 N 个工具名,all_defs 只命中其中 2 个,其余跳过(不 panic,不补全)
let all = vec![
tool_def("read_file"),
tool_def("patch_file"),
tool_def("unrelated_thing"),
];
let out = filter_tool_defs(&all, &Intent::File);
let names: Vec<&str> = out.iter().map(|d| d.function.name.as_str()).collect();
assert_eq!(names, vec!["read_file", "patch_file"], "部分漂移只留交集, 顺序跟 all");
}
#[test]
fn filter_empty_all_defs_no_panic() {
// 极端:all_defs 空(无工具注册)。无论 intent 怎样都不 panic,返空 Vec。
let empty: Vec<_> = vec![];
let out_unknown = filter_tool_defs(&empty, &Intent::Unknown);
let out_file = filter_tool_defs(&empty, &Intent::File);
assert!(out_unknown.is_empty());
assert!(out_file.is_empty(), "空 registry + File 意图应返空不 panic");
}
#[test]
fn filter_debug_subset_spans_three_domains_complete() {
// 对抗(多 domain):Debug subset 跨 File + Http + Data 三 domain,
// 必须同条消息里能同时命中三个 domain 的工具(防 domain 漏挂)
let all = vec![
// Data domain
tool_def("list_tasks"),
tool_def("create_project"),
tool_def("run_workflow"),
// File domain
tool_def("read_file"),
tool_def("patch_file"),
// Http domain
tool_def("http_request"),
];
let out = filter_tool_defs(&all, &Intent::Debug);
let names: Vec<String> = out.iter().map(|d| d.function.name.clone()).collect();
assert!(names.contains(&"list_tasks".to_string()), "Debug 必含 Data(list_tasks), 防 CR-25 🟡-1 丢 Data");
assert!(names.contains(&"read_file".to_string()), "Debug 必含 File");
assert!(names.contains(&"http_request".to_string()), "Debug 必含 Http");
assert_eq!(names.len(), 6, "三 domain 工具全保留, 实际: {:?}", names);
}
#[test]
fn filter_low_confidence_unknown_subset_empty_falls_back_full() {
// 对抗(低置信):"帮我看看这个"无任何关键词 → recognize 返 Unknown/0.0
// → tool_subset_for(Unknown) 空 → filter 返全量(fallback 链完整)
let (intent, conf) = IntentRecognizer::recognize("帮我看看这个");
assert_eq!(intent, Intent::Unknown, "无关键词应 Unknown");
assert_eq!(conf, 0.0, "置信度应 0.0");
assert!(tool_subset_for(&intent).is_empty(), "Unknown subset 应空");
let all = vec![tool_def("read_file"), tool_def("write_file"), tool_def("http_request")];
let out = filter_tool_defs(&all, &intent);
assert_eq!(out.len(), all.len(), "Unknown/低置信应回全量 fallback");
}
#[test]
fn subset_all_intents_covered_names_in_registry() {
// 对抗(完整性):遍历所有 Intent 的 subset,每个工具名都能在对应 ToolDomain.tools() 找到。
// 防 subset 表写错工具名(如 read_filez 笔误)。subset 工具名必须真存在于其声明 domain。
let all_intents = [
Intent::Code,
Intent::Debug,
Intent::File,
Intent::Project,
Intent::Task,
Intent::Idea,
Intent::Conversation,
Intent::Search,
Intent::Http,
Intent::Chat,
Intent::Unknown,
];
for intent in all_intents {
let subset = tool_subset_for(&intent);
// 全 registry 工具名(三 domain 并集)
let registry: std::collections::HashSet<&str> = ToolDomain::Data
.tools()
.iter()
.chain(ToolDomain::File.tools().iter())
.chain(ToolDomain::Http.tools().iter())
.copied()
.collect();
for name in &subset {
assert!(
registry.contains(*name),
"Intent {:?} subset 含工具名 {} 不在任何 domain 注册表",
intent,
name
);
}
}
}
#[test]
fn filter_subset_correct_intent_for_multi_domain_message() {
// 对抗(跨 domain 完整):"调试任务并读文件"意图多 domain,
// 但 recognize 按优先级定单一 intent,filter 据此收敛。
// 关键:无论 recognize 落哪个 intent,该 intent 的 subset 必须覆盖任务+文件相关工具。
let (intent, conf) = IntentRecognizer::recognize("调试这个任务");
// "调试"(Debug/SPECIFIC, 1.0) 优先于 "任务"(Task/ENTITY)
assert_eq!(intent, Intent::Debug);
assert!(conf >= 0.7);
let subset = tool_subset_for(&intent);
// Debug subset 必含 list_tasks(Data) + read_file(File) 防"调试任务"丢工具
assert!(subset.contains(&"list_tasks"), "Debug 应含 list_tasks(Data domain)");
assert!(subset.contains(&"read_file"), "Debug 应含 read_file(File domain)");
}
#[test]
fn subset_code_and_debug_dedup_http_across_domains() {
// 边界:Code = [File, Http], Debug = [File, Http, Data]
// File + Http 都含 http_request,必须去重(subset 内不重复)
for intent in [Intent::Code, Intent::Debug] {
let s = tool_subset_for(&intent);
let http_count = s.iter().filter(|n| **n == "http_request").count();
assert_eq!(http_count, 1, "Intent {:?} subset http_request 应去重为 1", intent);
}
}
}