新增: 消息级溯源 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:
@@ -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_files);Conversation → [];
|
||||
/// Chat → [];Debug → [file, http](调试常需读文件+查 API);Unknown → [](全量)。
|
||||
/// 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 B):agentic 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 完整 registry,LLM 即使幻觉一个被滤掉的工具名,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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user