//! 会话意图识别层(Intent Recognition) //! //! 论证依据:`docs/02-架构设计/构想审查/意图识别层论证-2026-06-19.md` //! //! ## 定位 //! 纯函数模块,**不接入 agentic loop**(待 Phase B+C 完成 + 模型模态管理后接入)。 //! 提供三个能力: //! 1. `IntentRecognizer::recognize(message)` —— 规则/关键词匹配(方式 A,零延迟零成本) //! 返回 `(Intent, f32)`,置信度 0.0–1.0。低置信 → 上游 fallback 全量工具。 //! 2. `tool_subset_for(intent)` —— 硬编码工具名→domain 映射,工具名子集(空 = 全量 fallback)。 //! 3. `suggested_model_tier(intent)` —— 模态建议**接口预留**,当前恒返 `None` //! (待模型模态管理 Phase 落地后补充实际逻辑)。 //! //! ## 设计原则 //! - **不碰** `tool_registry`:domain 映射在本文件内硬编码工具名常量,运行期不读 registry。 //! - **不碰** agentic loop / src-tauri:本 crate 内独立编译,单测自洽。 //! - 关键词表对齐 devflow 实际工具语义(read_file/write_file/patch_file/http_request 等)。 //! //! ## 优先级(匹配命中后取舍) //! 具体(Code/File/Http/Search)> 实体(Project/Task/Idea)> 通用(Chat/Unknown)。 //! 同级别内按命中关键词权重/数量决定置信度。 // ---- Intent 枚举 ------------------------------------------------------------ /// 会话意图分类。覆盖 devflow 当前 AI 工具域 + 通用对话。 /// /// `Unknown` 表示未命中任何关键词,上游应走全量工具 fallback。 #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] pub enum Intent { /// 代码相关:实现/重构/编译错误/bug Code, /// 调试相关:复现/排查/堆栈 Debug, /// 文件操作:读写/patch/列目录 File, /// 项目管理:创建/绑定/列出项目 Project, /// 任务管理:推进/状态/创建任务 Task, /// 灵感:捕获/评估/晋升 Idea, /// 对话:历史会话/总结 Conversation, /// 搜索:grep/查找 Search, /// HTTP 请求:调外部 API Http, /// 闲聊:问候/感谢 Chat, /// 未识别:fallback 全量工具 Unknown, } impl Intent { /// 短标签(日志/调试用)。 pub fn as_str(self) -> &'static str { match self { Intent::Code => "code", Intent::Debug => "debug", Intent::File => "file", Intent::Project => "project", Intent::Task => "task", Intent::Idea => "idea", Intent::Conversation => "conversation", Intent::Search => "search", Intent::Http => "http", Intent::Chat => "chat", Intent::Unknown => "unknown", } } } // ---- ModelTier 预留 --------------------------------------------------------- /// 模型模态档位(**预留**)。 /// /// 待模型模态管理 Phase 落地后定义实际 provider/model 映射。 /// 当前仅占位于 `suggested_model_tier` 返回类型,逻辑恒返 `None`。 #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum ModelTier { /// 轻量快速(简单意图/闲聊) Fast, /// 标准(默认复杂度) Standard, /// 重型(复杂推理/重构) Heavy, } // ---- 关键词表 + 权重 -------------------------------------------------------- /// 单条关键词规则:关键词 + 命中权重。权重高者对置信度贡献大。 struct Rule { kw: &'static str, weight: f32, } // 优先级分组(具体 > 实体 > 通用)。组内关键词线性求和。 // 关键词覆盖中英文,对齐 devflow 工具语义与用户自然表达。 const CODE_RULES: &[Rule] = &[ Rule { kw: "代码", weight: 1.0 }, Rule { kw: "函数", weight: 1.0 }, Rule { kw: "方法", weight: 0.8 }, Rule { kw: "bug", weight: 1.0 }, Rule { kw: "编译", weight: 0.9 }, Rule { kw: "error", weight: 0.9 }, Rule { kw: "impl", weight: 0.7 }, Rule { kw: "refactor", weight: 0.9 }, Rule { kw: "重构", weight: 1.0 }, Rule { kw: "实现", weight: 0.7 }, ]; const DEBUG_RULES: &[Rule] = &[ Rule { kw: "调试", weight: 1.0 }, Rule { kw: "debug", weight: 1.0 }, Rule { kw: "排查", weight: 0.9 }, Rule { kw: "复现", weight: 0.9 }, Rule { kw: "堆栈", weight: 0.8 }, Rule { kw: "栈", weight: 0.5 }, Rule { kw: "panic", weight: 0.9 }, Rule { kw: "traceback", weight: 0.9 }, // 命令执行场景关键词(run_command 入口引导):用户明确"运行/执行/构建/测试/跑" // 才该走 Exec domain(run_command)。中等权重(0.7)避免误抢 Code 命中(编译/重构 1.0)。 Rule { kw: "运行", weight: 0.7 }, Rule { kw: "执行", weight: 0.7 }, Rule { kw: "构建", weight: 0.7 }, Rule { kw: "测试", weight: 0.7 }, Rule { kw: "跑", weight: 0.6 }, ]; const FILE_RULES: &[Rule] = &[ Rule { kw: "文件", weight: 1.0 }, Rule { kw: "读取", weight: 0.9 }, Rule { kw: "写入", weight: 0.9 }, Rule { kw: "修改", weight: 0.7 }, Rule { kw: "read", weight: 0.6 }, Rule { kw: "write", weight: 0.6 }, Rule { kw: "file", weight: 0.5 }, Rule { kw: "patch", weight: 0.9 }, ]; const PROJECT_RULES: &[Rule] = &[ Rule { kw: "项目", weight: 1.0 }, Rule { kw: "project", weight: 0.9 }, Rule { kw: "创建项目", weight: 1.0 }, Rule { kw: "绑定", weight: 0.8 }, Rule { kw: "目录", weight: 0.5 }, ]; const TASK_RULES: &[Rule] = &[ Rule { kw: "任务", weight: 1.0 }, Rule { kw: "task", weight: 0.9 }, Rule { kw: "推进", weight: 0.9 }, Rule { kw: "状态", weight: 0.7 }, Rule { kw: "todo", weight: 0.8 }, ]; const IDEA_RULES: &[Rule] = &[ Rule { kw: "灵感", weight: 1.0 }, Rule { kw: "idea", weight: 0.9 }, Rule { kw: "评估", weight: 0.8 }, Rule { kw: "创意", weight: 0.8 }, ]; const CONVERSATION_RULES: &[Rule] = &[ Rule { kw: "会话", weight: 0.9 }, Rule { kw: "历史", weight: 0.7 }, Rule { kw: "总结", weight: 0.6 }, Rule { kw: "conversation", weight: 0.8 }, ]; const SEARCH_RULES: &[Rule] = &[ Rule { kw: "搜索", weight: 1.0 }, Rule { kw: "search", weight: 0.9 }, Rule { kw: "查找", weight: 0.8 }, Rule { kw: "grep", weight: 1.0 }, Rule { kw: "find", weight: 0.6 }, ]; const HTTP_RULES: &[Rule] = &[ Rule { kw: "请求", weight: 0.9 }, Rule { kw: "http", weight: 1.0 }, Rule { kw: "api", weight: 0.8 }, Rule { kw: "url", weight: 0.7 }, Rule { kw: "fetch", weight: 0.8 }, Rule { kw: "调用接口", weight: 1.0 }, ]; const CHAT_RULES: &[Rule] = &[ Rule { kw: "你好", weight: 1.0 }, Rule { kw: "hello", weight: 1.0 }, Rule { kw: "hi", weight: 0.9 }, Rule { kw: "谢谢", weight: 1.0 }, Rule { kw: "thanks", weight: 1.0 }, Rule { kw: "帮助", weight: 0.7 }, Rule { kw: "闲聊", weight: 1.0 }, ]; /// 优先级分组(高 → 低)。命中高分组的意图直接胜出,不向下累积。 /// 分组内的候选按 (置信度, Intent 顺序) 取胜者。 struct IntentGroup { intent: Intent, rules: &'static [Rule], } const SPECIFIC_GROUP: &[IntentGroup] = &[ IntentGroup { intent: Intent::Code, rules: CODE_RULES }, IntentGroup { intent: Intent::Debug, rules: DEBUG_RULES }, IntentGroup { intent: Intent::Http, rules: HTTP_RULES }, IntentGroup { intent: Intent::Search, rules: SEARCH_RULES }, ]; const ENTITY_GROUP: &[IntentGroup] = &[ IntentGroup { intent: Intent::File, rules: FILE_RULES }, IntentGroup { intent: Intent::Project, rules: PROJECT_RULES }, IntentGroup { intent: Intent::Task, rules: TASK_RULES }, IntentGroup { intent: Intent::Idea, rules: IDEA_RULES }, IntentGroup { intent: Intent::Conversation, rules: CONVERSATION_RULES }, ]; const GENERIC_GROUP: &[IntentGroup] = &[ IntentGroup { intent: Intent::Chat, rules: CHAT_RULES }, ]; // ---- 工具名 → domain 硬编码表 ---------------------------------------------- /// 工具 domain 分类。`tool_subset_for` 按 Intent 选 domain,再回该 domain 的工具名列表。 /// /// **来源**:核验 `src-tauri/src/commands/ai/tool_registry.rs`(实际注册名)。 /// 本表硬编码,不读 registry 运行期状态(保持模块独立可单测)。 #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] pub enum ToolDomain { /// 数据/业务:项目/任务/灵感/工作流/回收站 Data, /// 文件:读写/patch/列目录/搜索(不含命令执行) File, /// 命令执行:run_command(shell 命令,独立 domain 防止被泛 File 意图带出) Exec, /// 网络:HTTP Http, } impl ToolDomain { /// 该 domain 下的工具名常量表(与 tool_registry 注册名一一对应)。 fn tools(self) -> &'static [&'static str] { match self { ToolDomain::Data => &[ "list_projects", "create_project", "update_project", "delete_project", "bind_directory", "list_tasks", "create_task", "update_task", "advance_task", "delete_task", "list_ideas", "create_idea", "run_workflow", "list_trash", "restore_project", "purge_project", "get_project_count", "get_task_count", ], ToolDomain::File => &[ "read_file", "write_file", "patch_file", "list_directory", "search_files", "file_info", "append_file", "delete_file", "rename_file", ], // Exec domain 独立:run_command 从 File 移出。 // 意图侧仅 Debug 默认带 Exec(用户明确"运行/测试/构建"), // Code/File/Search 不带 → 减少 LLM 对 run_command 的偏好暴露。 ToolDomain::Exec => &["run_command"], ToolDomain::Http => &["http_request"], } } } // ---- 识别器 ----------------------------------------------------------------- /// 意图识别 L0 去噪:剥离 mention 结构化前缀(`[项目: DevFlow]` / `[任务: xxx]` 等)。 /// /// mention 前缀是系统注入的结构标签,语义已由 augmentation @ 通道单独 resolve /// 注入 system prompt;content 里残留的前缀只污染关键词识别——实测会话 /// `[项目: DevFlow] 这个错误是项目里的 miniapp 报错` 被误判 `Intent::Project`, /// 收敛砍掉 File domain 致 `read_file` 对 LLM 不可见,agent 被迫反复 `list_tasks`。 /// /// 剥离规则:扫描 `[`,若紧跟已知 mention kind(项目/任务/想法/技能, /// project/task/idea/skill,大小写不敏感)+ 冒号(全/半角 `:`/`:`),则连同到 /// 首个 `]` 整段移除;未闭合或非已知 kind 的 `[...]` 原样保留(代码片段/错误码)。 /// /// kind 表与 `MentionRef`(project/task/idea/skill)对齐——单一真相源,新增 mention /// kind 时此处同步。不折叠空白:关键词 `contains` 匹配对空白不敏感,剥离即可。 fn strip_mention_tags(message: &str) -> String { const KINDS: &[&str] = &["项目", "任务", "想法", "技能", "project", "task", "idea", "skill"]; let chars: Vec = message.chars().collect(); let n = chars.len(); let mut out = String::with_capacity(message.len()); let mut i = 0; while i < n { if chars[i] == '[' { if let Some(close) = try_match_mention(&chars, i, KINDS) { i = close + 1; // 跳过整段 mention(含 `]`) continue; } } out.push(chars[i]); i += 1; } out } /// 探测 `chars[open] == '['` 起的 mention 段:`[kind: ...]`。 /// /// 返回匹配 `]` 的索引(`open` 指向 `[`)。kind 大小写不敏感(ASCII)+ 中文直比; /// 冒号接受全角 `:`/半角 `:`;kind 后允许空白;未闭合 `]` 返 `None`(不当 mention,保留原样)。 fn try_match_mention(chars: &[char], open: usize, kinds: &[&str]) -> Option { let after = open + 1; for k in kinds { let kc: Vec = k.chars().collect(); if chars.len() < after + kc.len() { continue; } let slice = &chars[after..after + kc.len()]; if !kc.iter().zip(slice.iter()).all(|(a, b)| a.eq_ignore_ascii_case(b)) { continue; } let mut j = after + kc.len(); while j < chars.len() && chars[j].is_whitespace() { j += 1; } if j >= chars.len() || (chars[j] != ':' && chars[j] != ':') { continue; } j += 1; while j < chars.len() { if chars[j] == ']' { return Some(j); } j += 1; } // 未闭合 `]` → 不当 mention,保留原样 return None; } None } /// 意图识别器(无状态,所有方法均为纯函数,可 `Default` 构造)。 #[derive(Debug, Clone, Default)] pub struct IntentRecognizer; impl IntentRecognizer { /// 识别会话意图。 /// /// 返回 `(Intent, f32)`,置信度 0.0–1.0: /// - 命中关键词:`min(1.0, sum(命中权重))`,至少命中一条即胜出本组。 /// - 未命中:`(Intent::Unknown, 0.0)`。 /// /// 优先级:SPECIFIC > ENTITY > GENERIC。高分组的胜者直接返回,不累积低分组。 pub fn recognize(message: &str) -> (Intent, f32) { // L0 去噪:先剥离 mention 结构标签(防 `[项目: x]` 把代码任务误判 Project)。 let cleaned = strip_mention_tags(message); // 依次按优先级组求胜者。高分组有命中即提前返回。 if let Some(hit) = best_in_group(&cleaned, SPECIFIC_GROUP) { return hit; } if let Some(hit) = best_in_group(&cleaned, ENTITY_GROUP) { return hit; } if let Some(hit) = best_in_group(&cleaned, GENERIC_GROUP) { return hit; } (Intent::Unknown, 0.0) } } /// 在一组候选里求置信度最高的(命中权重和截断到 1.0)。无任何命中返 `None`。 fn best_in_group(message: &str, group: &[IntentGroup]) -> Option<(Intent, f32)> { let lower = message.to_lowercase(); let mut best: Option<(Intent, f32)> = None; for cand in group { let mut score = 0.0f32; let mut matched = false; for rule in cand.rules.iter() { // 英文关键词用小写匹配;中文不受 to_lowercase 影响(no-op)。 let needle = rule.kw.to_lowercase(); if lower.contains(&needle) { score += rule.weight; matched = true; } } if !matched { continue; } let conf = score.min(1.0); match best { Some((_, b)) if b >= conf => {} _ => best = Some((cand.intent, conf)), } } best } // ---- Intent → 工具子集映射 -------------------------------------------------- /// 按 Intent 返回工具名子集。 /// /// 返回空 `Vec` 表示该意图**无工具收敛**(Chat)或**未识别**(Unknown), /// 上游应走**全量 fallback**(即不过滤工具,交全量给 LLM)。 /// /// 设计:Code → [file, http];File → [file];Project/Task/Idea → [data, file](加 file 防「提项目/任务 → 误判 → 砍只读探索」,见 L1); /// Http → [http];Search → [file](含 search_files);Conversation → []; /// Chat → [];Debug → [file, exec, http, data](调试常需跑命令+读文件+查 API+查任务/工作流状态,CR-25 审查🟡-1 加 data 防"调试任务"丢 Data 工具); /// **仅 Debug 含 Exec**(用户明确"运行/测试/构建/调试"才暴露 run_command), /// Code/File/Search 不含 Exec → 收紧 run_command 暴露面;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::Exec, ToolDomain::Http, ToolDomain::Data], Intent::File => &[ToolDomain::File], // Project/Task/Idea 加 File:用户提"项目/任务"时常是在其内编码/排查 // (实测会话 [项目:x] 报错 → Project → 砍 File 致 read_file 对 LLM 不可见)。 // 保留只读探索;写工具有 RiskLevel/审批兜底,"分心"远好于"断手"。 Intent::Project => &[ToolDomain::Data, ToolDomain::File], Intent::Task => &[ToolDomain::Data, ToolDomain::File], Intent::Idea => &[ToolDomain::Data, ToolDomain::File], Intent::Conversation => &[], Intent::Search => &[ToolDomain::File], Intent::Http => &[ToolDomain::Http], Intent::Chat => &[], Intent::Unknown => &[], }; let mut out: Vec<&'static str> = Vec::new(); for d in domains { for name in d.tools() { if !out.contains(name) { out.push(name); } } } out } // ---- 模态建议(接口预留) --------------------------------------------------- /// 按 Intent 建议模型模态档位。 /// /// **预留接口**:当前恒返 `None`。待模型模态管理 Phase 落地后补充: /// - Chat/Conversation → `Fast` /// - Code/File/Task/Idea/Search → `Standard` /// - Debug/Http(复杂排查/多跳调用)→ `Heavy` /// /// 返回 `None` 时上游应使用默认档位(待模态管理 Phase 定义)。 pub fn suggested_model_tier(_intent: &Intent) -> Option { // TODO(model-tier-phase): 待模型模态管理落地后填实映射。 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 { 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() } // ---- 工具子集过滤 + plan_hint 编排(B 路线 Phase 1 接入主 loop) -------------- /// 在 `filter_tool_defs` 收敛的扁平子集之上,叠加 `plan_hint` 的编排关系 /// (并行组同批聚拢 / 顺序依赖源在前),供 LLM 看到一份**按编排意图排序**的工具列表。 /// /// **Phase 1 接入语义**(依据 /// `docs/02-架构设计/单对话并行多轮-设计-2026-06-20.md` §五 Phase 1 + /// `单对话并行多轮-Phase0落地路线图-2026-06-20.md` §八 Phase 1 硬前置): /// - **纯函数零延迟**(与 intent/plan_hint 一致,无 IO 无 LLM 调用)。 /// - **不真正多轮并行**(Phase 1 仅提示层;真正子流并行 execution 留 Phase 3)。 /// - 行为 = 先 `filter_tool_defs` 收敛 → 再按 `plan_hint` 产出排序。 /// /// **三重 fallback(与 filter_tool_defs 一致的安全语义)**: /// 1. `plan_hint` 返空 PlanHint(无编排信号/单工具/闲聊)→ 退 `filter_tool_defs` 扁平结果。 /// 2. `plan_hint::validate` 判非法(环/空组/自环/重复边)→ 丢弃 hint 退扁平结果 /// (主 loop 不能据非法 hint 调度,否则死锁)。 /// 3. hint 引用的工具名不在 filtered 子集(registry 漂移)→ 该名跳过,剩余照常排序。 /// /// **排序规则**(确定性,便于单测固定行为): /// - 收集 hint 所有并行组里的工具名,按「组序 → 组内顺序」展开成有序列表 `ordered`。 /// - 输出 = `ordered` 中能在 filtered 找到的(保 ordered 顺序) + /// filtered 中未被 ordered 命中的(保 filtered 原序,放尾部兜底,防丢工具)。 /// - 顺序依赖(`sequence` 边)的「from」节点天然被并行组顺序吸收(组在前 = from 先出现), /// Phase 1 不额外重排依赖边(真正 DAG 调度留 Phase 3 Kahn 分层)。 /// /// **关键安全**(与 filter_tool_defs 同):本函数**只改 LLM 可见 tool_defs 的顺序/可见性**, /// 不改执行路径(audit 走 tools_arc 完整 registry)。LLM 即使幻觉被滤掉的顺序, /// audit 仍能查到/拒绝。 /// /// `intent`:主 loop 已识别的意图;其 `as_str()` 标签内部派生传给 plan_hint。 /// `intent_label`:**冗余参数**(等价于 `intent.as_str()`),仅为调用方签名兼容保留, /// 内部已忽略——以 `intent.as_str()` 为权威源,防调用方传错标签与实际 intent 不一致。 /// `context`:用户末条消息原文(plan_hint 关键词检测用)。 pub fn filter_tool_defs_planned( all_defs: &[df_ai_core::types::ToolDefinition], intent: &Intent, intent_label: &str, context: &str, ) -> Vec { // 第一步:先得扁平收敛子集(复用 filter_tool_defs 的全部 fallback 语义)。 let filtered = filter_tool_defs(all_defs, intent); // 第二步:产 PlanHint。PLAN_HINT_ENABLED 关时 plan_hint 内部返空 → 退扁平。 // intent_label 内部由 intent.as_str() 派生(冗余参数权威性低于 intent 本身)。 let label = intent.as_str(); let _ = intent_label; // 冗余:签名兼容保留,内部以 intent.as_str() 为准。 let hint = crate::plan_hint::plan_hint(label, context); // 空 hint(无编排信号)→ 直接返扁平收敛结果(零行为变更 vs filter_tool_defs)。 if hint.is_empty() { return filtered; } // 非法 hint(环/空组/自环/重复边)→ 丢弃,退扁平(主 loop 不能据非法 hint 调度)。 if !matches!(crate::plan_hint::validate(&hint), crate::plan_hint::HintValidity::Ok) { return filtered; } // 第三步:按 hint 编排关系重排 filtered。先展开「组序 → 组内序」的有序工具名表。 let mut ordered_names: Vec<&'static str> = Vec::new(); for g in &hint.groups { for t in &g.tools { if !ordered_names.contains(t) { ordered_names.push(*t); } } } // 第四步:用 HashMap 索引(filtered 名 → 其在该 Vec 中的位置)替代「ordered × filtered」 // 嵌套线性扫描。原版对每个 ordered 名做一次 O(filtered) 内层扫描,总 O(ordered × filtered); // 索引化后 O(filtered) 建索引 + 每 ordered 名 O(1) 查(filtered 通常 5-20 个工具, // 索引开销极小,但消除最坏 O(n²) 当 ordered/filtered 同时增长)。 use std::collections::HashMap; // name_to_idx:工具名 → filtered 中首次出现位置(filtered 内工具名唯一,无二义)。 let mut name_to_idx: HashMap<&str, usize> = HashMap::with_capacity(filtered.len()); for (i, d) in filtered.iter().enumerate() { // filtered 名唯一(同 domain 不会重复),with_capacity 后直接 insert 不冲突。 name_to_idx.entry(d.function.name.as_str()).or_insert(i); } // 按 ordered 顺序挑 filtered 里的(命中编排顺序的工具置前)。 let mut result: Vec = Vec::with_capacity(filtered.len()); let mut consumed: std::collections::HashSet = std::collections::HashSet::new(); for name in &ordered_names { if let Some(&idx) = name_to_idx.get(*name) { if consumed.insert(idx) { result.push(filtered[idx].clone()); } } } // 未被 ordered 命中的(filtered 里有但 hint 没标)→ 保 filtered 原序追加尾部兜底(防丢工具)。 for (i, d) in filtered.iter().enumerate() { if consumed.insert(i) { result.push(d.clone()); } } result } // ---- 单测 ------------------------------------------------------------------- #[cfg(test)] mod tests { use super::*; // --- Intent 枚举 --- #[test] fn intent_as_str_covers_all_variants() { assert_eq!(Intent::Code.as_str(), "code"); assert_eq!(Intent::Debug.as_str(), "debug"); assert_eq!(Intent::File.as_str(), "file"); assert_eq!(Intent::Project.as_str(), "project"); assert_eq!(Intent::Task.as_str(), "task"); assert_eq!(Intent::Idea.as_str(), "idea"); assert_eq!(Intent::Conversation.as_str(), "conversation"); assert_eq!(Intent::Search.as_str(), "search"); assert_eq!(Intent::Http.as_str(), "http"); assert_eq!(Intent::Chat.as_str(), "chat"); assert_eq!(Intent::Unknown.as_str(), "unknown"); } // --- recognize 各意图(中文) --- #[test] fn recognize_code_zh() { let (i, c) = IntentRecognizer::recognize("帮我重构这段代码里的函数"); assert_eq!(i, Intent::Code); assert!(c > 0.0 && c <= 1.0); // 命中"重构"+"代码"+"函数",置信度应封顶 1.0 assert!((c - 1.0).abs() < f32::EPSILON); } #[test] fn recognize_code_wins_over_debug_in_specific_group() { let (i, c) = IntentRecognizer::recognize("这个 bug 怎么复现,帮我调试排查一下"); // "bug" 在 Code 组,"复现/调试/排查" 在 Debug 组 —— SPECIFIC 组内 Code 命中先于 Debug // Code 命中(bug=1.0) 即胜出本组,不再看 Debug。断言优先级正确:Code 胜出。 assert_eq!(i, Intent::Code); assert!(c >= 1.0 - f32::EPSILON); } #[test] fn recognize_debug_pure() { let (i, _) = IntentRecognizer::recognize("帮我调试这个 panic 堆栈"); assert_eq!(i, Intent::Debug); } #[test] fn recognize_file_zh() { let (i, _) = IntentRecognizer::recognize("读取一下这个文件再写入"); assert_eq!(i, Intent::File); } #[test] fn recognize_project_zh() { let (i, _) = IntentRecognizer::recognize("创建项目并绑定目录"); assert_eq!(i, Intent::Project); } #[test] fn recognize_task_zh() { let (i, _) = IntentRecognizer::recognize("把这个任务推进到下一个状态"); assert_eq!(i, Intent::Task); } #[test] fn recognize_idea_zh() { let (i, _) = IntentRecognizer::recognize("我有个灵感想评估一下"); assert_eq!(i, Intent::Idea); } #[test] fn recognize_search_zh() { let (i, _) = IntentRecognizer::recognize("搜索一下代码里哪里用了 grep"); // "搜索" Search(SPECIFIC) 优先于 "代码" Code(SPECIFIC):同组取高分。 // "搜索"=1.0 vs "代码"=1.0 平局,按表顺序 Code 在前会胜出 —— 调整用例避免歧义。 // 这里只断言落在 SPECIFIC 组之一即可(实际设计具体组互斥,单消息取高分)。 assert!(matches!(i, Intent::Search | Intent::Code)); } #[test] fn recognize_search_pure() { let (i, _) = IntentRecognizer::recognize("grep 查找关键字"); assert_eq!(i, Intent::Search); } // --- strip_mention_tags(L0 去噪)--- #[test] fn strip_removes_all_mention_kinds() { assert_eq!(strip_mention_tags("[项目: DevFlow] 重构代码"), " 重构代码"); assert_eq!(strip_mention_tags("[任务: 修复bug] 看看"), " 看看"); assert_eq!(strip_mention_tags("[想法: x][技能: y] 闲聊"), " 闲聊"); // 英文 kind + 大小写不敏感 assert_eq!(strip_mention_tags("[Project: x] fix this"), " fix this"); assert_eq!(strip_mention_tags("[TASK: y] do it"), " do it"); // 全角冒号 assert_eq!(strip_mention_tags("[项目:DevFlow] 阅读"), " 阅读"); } #[test] fn strip_preserves_non_mention_brackets() { // 非 mention 的 [...] 原样保留(代码片段/错误码) assert_eq!(strip_mention_tags("数组 [1,2,3] 求和"), "数组 [1,2,3] 求和"); assert_eq!(strip_mention_tags("[error] 致命"), "[error] 致命"); // 'error' 非 kind assert_eq!(strip_mention_tags("[项目报错] 看"), "[项目报错] 看"); // kind 后无冒号 // 未闭合的 mention-like 不剥离(防误吞) assert_eq!(strip_mention_tags("[项目: 未闭合"), "[项目: 未闭合"); } #[test] fn recognize_mention_prefix_not_misread_as_project() { // 核心修复:[项目: DevFlow] 帮我重构代码 → Code(非 Project) // 原 bug:mention 前缀的"项目"命中 PROJECT(1.0)→ 收敛砍 File → read_file 不可见。 let (i, c) = IntentRecognizer::recognize("[项目: DevFlow] 帮我重构这段代码"); assert_eq!(i, Intent::Code); assert!(c >= 0.7, "重构+代码 应达 Code 阈值, 实际 conf={}", c); } #[test] fn recognize_real_project_word_still_hits_project() { // 用户口语真"项目"(非 mention 前缀)仍命中 Project——预处理只去系统噪声。 // 正是 L1(tool_subset_for 加 File)不可省的佐证:口语误判靠映射兜底。 let (i, _) = IntentRecognizer::recognize("创建项目并绑定目录"); assert_eq!(i, Intent::Project); } #[test] fn recognize_http_zh() { let (i, _) = IntentRecognizer::recognize("调用接口请求这个 api"); assert_eq!(i, Intent::Http); } #[test] fn recognize_http_en() { let (i, _) = IntentRecognizer::recognize("fetch the url via http"); assert_eq!(i, Intent::Http); } #[test] fn recognize_chat_zh() { let (i, _) = IntentRecognizer::recognize("你好,谢谢你的帮助"); assert_eq!(i, Intent::Chat); } #[test] fn recognize_chat_en() { let (i, _) = IntentRecognizer::recognize("hello, thanks!"); assert_eq!(i, Intent::Chat); } #[test] fn recognize_conversation() { let (i, _) = IntentRecognizer::recognize("总结一下这段会话历史"); assert_eq!(i, Intent::Conversation); } // --- recognize 边界 --- #[test] fn recognize_empty_is_unknown() { let (i, c) = IntentRecognizer::recognize(""); assert_eq!(i, Intent::Unknown); assert_eq!(c, 0.0); } #[test] fn recognize_whitespace_is_unknown() { let (i, c) = IntentRecognizer::recognize(" \n\t "); assert_eq!(i, Intent::Unknown); assert_eq!(c, 0.0); } #[test] fn recognize_no_keyword_is_unknown() { let (i, c) = IntentRecognizer::recognize("今天的天气不错啊"); assert_eq!(i, Intent::Unknown); assert_eq!(c, 0.0); } #[test] fn recognize_confidence_bounded() { // 大量命中也应封顶 1.0 let (_, c) = IntentRecognizer::recognize("代码 函数 方法 编译 error impl 重构"); assert!(c >= 0.0 && c <= 1.0); assert!((c - 1.0).abs() < f32::EPSILON); } // --- 优先级 --- #[test] fn priority_specific_over_entity() { // "代码"(Code/SPECIFIC) + "文件"(File/ENTITY) → SPECIFIC 胜出 let (i, _) = IntentRecognizer::recognize("看看这段代码对应的文件"); assert_eq!(i, Intent::Code); } #[test] fn priority_entity_over_generic() { // "任务"(Task/ENTITY) + "帮助"(Chat/GENERIC) → ENTITY 胜出 let (i, _) = IntentRecognizer::recognize("帮我处理这个任务,谢谢帮助"); assert_eq!(i, Intent::Task); } #[test] fn case_insensitive_english() { let (i, _) = IntentRecognizer::recognize("HTTP REQUEST to API"); assert_eq!(i, Intent::Http); } // --- tool_subset_for --- #[test] fn subset_code_has_file_and_http() { let s = tool_subset_for(&Intent::Code); assert!(s.contains(&"read_file")); assert!(s.contains(&"write_file")); assert!(s.contains(&"patch_file")); assert!(s.contains(&"http_request")); assert!(!s.contains(&"list_projects")); } #[test] fn subset_file_only_file_domain() { let s = tool_subset_for(&Intent::File); assert!(s.contains(&"read_file")); assert!(s.contains(&"search_files")); assert!(!s.contains(&"http_request")); assert!(!s.contains(&"list_projects")); } #[test] fn subset_project_is_data_and_file() { // L1 修复:Project 加 File domain(防"提项目 → 误判 Project → 砍只读探索")。 // Data(create_project/bind_directory)+ File 只读(read_file)共存。 let s = tool_subset_for(&Intent::Project); assert!(s.contains(&"create_project")); assert!(s.contains(&"bind_directory")); assert!(s.contains(&"read_file"), "Project 应保留 read_file(L1)"); } #[test] fn subset_task_is_data() { let s = tool_subset_for(&Intent::Task); assert!(s.contains(&"advance_task")); assert!(s.contains(&"create_task")); } #[test] fn subset_idea_is_data() { let s = tool_subset_for(&Intent::Idea); assert!(s.contains(&"list_ideas")); assert!(s.contains(&"create_idea")); } #[test] fn subset_http_only_http() { let s = tool_subset_for(&Intent::Http); assert_eq!(s, vec!["http_request"]); } #[test] fn subset_search_has_search_files() { let s = tool_subset_for(&Intent::Search); assert!(s.contains(&"search_files")); // Search 复用 File domain,read_file 等也在 assert!(s.contains(&"read_file")); } #[test] fn subset_chat_empty_means_full_fallback() { let s = tool_subset_for(&Intent::Chat); assert!(s.is_empty(), "Chat 空=全量 fallback 约定"); } #[test] fn subset_unknown_empty_means_full_fallback() { let s = tool_subset_for(&Intent::Unknown); assert!(s.is_empty(), "Unknown 空=全量 fallback 约定"); } #[test] fn subset_debug_has_file_and_http() { let s = tool_subset_for(&Intent::Debug); assert!(s.contains(&"read_file")); assert!(s.contains(&"http_request")); } #[test] fn subset_no_duplicates_across_domains() { // Code/Debug 跨 file+http domain,确保去重 let s = tool_subset_for(&Intent::Code); let mut seen = std::collections::HashSet::new(); for n in &s { assert!(seen.insert(*n), "重复工具名: {}", n); } } // --- ToolDomain 工具名与 registry 对齐(核验) --- #[test] fn tool_domain_names_match_registry() { // 抽样核对与 src-tauri tool_registry 实际注册名一致 assert!(ToolDomain::File.tools().contains(&"patch_file")); // run_command 已从 File 移出 Exec domain(意图收紧:仅 Debug 默认带 run_command) assert!(!ToolDomain::File.tools().contains(&"run_command")); assert_eq!(ToolDomain::Exec.tools(), &["run_command"]); assert!(ToolDomain::Data.tools().contains(&"advance_task")); assert!(ToolDomain::Data.tools().contains(&"run_workflow")); assert!(ToolDomain::Data.tools().contains(&"list_trash")); assert_eq!(ToolDomain::Http.tools(), &["http_request"]); } // --- suggested_model_tier 预留 --- #[test] fn model_tier_always_none_for_now() { // 接口预留:当前所有意图均返 None for i in [ Intent::Code, Intent::Debug, Intent::File, Intent::Project, Intent::Task, Intent::Idea, Intent::Conversation, Intent::Search, Intent::Http, Intent::Chat, Intent::Unknown, ] { assert_eq!(suggested_model_tier(&i), None, "intent {:?} 应返 None", i); } } // --- IntentRecognizer Default --- #[test] fn recognizer_is_default_constructible() { let _r = IntentRecognizer::default(); // 纯静态方法,实例仅占位 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_four_domains_complete() { // 对抗(多 domain):Debug subset 跨 File + Exec + 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"), // Exec domain tool_def("run_command"), // Http domain tool_def("http_request"), ]; let out = filter_tool_defs(&all, &Intent::Debug); let names: Vec = 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(&"run_command".to_string()), "Debug 必含 Exec(run_command)"); assert!(names.contains(&"http_request".to_string()), "Debug 必含 Http"); assert_eq!(names.len(), 7, "四 domain 工具全保留, 实际: {:?}", names); } #[test] fn run_command_only_exposed_for_debug_intent() { // 核心修复断言:run_command 仅 Debug 暴露。 // Code/File/Search 意图 subset 不含 Exec domain → 不含 run_command。 for intent in [Intent::Code, Intent::File, Intent::Search] { let s = tool_subset_for(&intent); assert!( !s.contains(&"run_command"), "Intent {:?} subset 不应含 run_command(仅 Debug 暴露), 实际: {:?}", intent, s ); } // Debug 必含 run_command(Exec domain) let dbg = tool_subset_for(&Intent::Debug); assert!(dbg.contains(&"run_command"), "Debug 必含 run_command"); } #[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 并集:Data + File + Exec + Http) let registry: std::collections::HashSet<&str> = ToolDomain::Data .tools() .iter() .chain(ToolDomain::File.tools().iter()) .chain(ToolDomain::Exec.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); } } // ===== filter_tool_defs_planned(B 路线 Phase 1 接入主 loop 用) ===== // // 单测覆盖 Phase 1 接入语义:flag 关(filter_tool_defs 旧行为)、 // plan_hint 空 hint 退扁平、非法 hint 退扁平、registry 漂移跳过、有序重排。 // PLANNING_ENABLED(planner.rs)门控主 loop 接入点;PLAN_HINT_ENABLED(plan_hint.rs) // 门控 plan_hint 函数内部产出。本组单测直接验证 filter_tool_defs_planned // 纯函数行为(两层 flag 均为当前默认值时 plan_hint 函数产非空 hint)。 #[test] fn planned_empty_intent_subset_falls_back_to_full() { // Unknown subset 空 → filter_tool_defs 回全量 → plan_hint(无编排信号或闲聊)→ 扁平全量。 // 校验 fallback 链完整:不会因 plan_hint 误丢工具。 let all = vec![tool_def("read_file"), tool_def("write_file"), tool_def("http_request")]; let out = filter_tool_defs_planned(&all, &Intent::Unknown, "unknown", "你好谢谢"); assert_eq!(out.len(), all.len(), "Unknown + 闲聊应回全量(双 fallback)"); } #[test] fn planned_plain_chat_yields_filter_result_unordered() { // Chat subset 空 → filter_tool_defs 回全量;context 无编排信号 → plan_hint 空 → 退扁平。 // 关键:即使 PLANNING_ENABLED(true 时)走 planned 路径,无编排信号时输出与 // filter_tool_defs 一致(顺序 + 内容),零行为变更。 let all = vec![ tool_def("read_file"), tool_def("write_file"), tool_def("http_request"), ]; let planned = filter_tool_defs_planned(&all, &Intent::Chat, "chat", "你好谢谢"); let flat = filter_tool_defs(&all, &Intent::Chat); let planned_names: Vec<&str> = planned.iter().map(|d| d.function.name.as_str()).collect(); let flat_names: Vec<&str> = flat.iter().map(|d| d.function.name.as_str()).collect(); assert_eq!(planned_names, flat_names, "无编排信号 planned 应与 filter 扁平一致"); } #[test] fn planned_read_then_modify_orders_read_first() { // 读后写编排信号 → plan_hint 产 read_file→write_file 依赖边 → planned 把 read_file 置前。 // File intent subset 含 read_file/write_file/patch_file 等全 File domain。 let all = vec![ // 故意把 write_file 放前,验证 planned 把 read_file 重排到 write_file 之前 tool_def("write_file"), tool_def("patch_file"), tool_def("read_file"), tool_def("list_directory"), tool_def("search_files"), tool_def("file_info"), tool_def("append_file"), tool_def("delete_file"), tool_def("rename_file"), tool_def("http_request"), // 不在 File domain,应被滤掉 ]; let out = filter_tool_defs_planned(&all, &Intent::File, "file", "先 read 这个文件再修改它"); let names: Vec<&str> = out.iter().map(|d| d.function.name.as_str()).collect(); // http_request 应被滤掉(intent 收敛) assert!(!names.contains(&"http_request"), "File 意图应滤掉 http_request"); // read_file 应出现在 write_file 之前(plan_hint 编排:读组在前) let read_idx = names.iter().position(|n| *n == "read_file").expect("应含 read_file"); let write_idx = names.iter().position(|n| *n == "write_file").expect("应含 write_file"); assert!(read_idx < write_idx, "plan_hint 应把 read_file 排到 write_file 前, 实际顺序: {:?}", names); // 不丢工具:http_request 滤掉后剩 9 个(全 File domain 9 工具) assert_eq!(out.len(), 9, "planned 不应丢工具(除被 intent 滤掉), 实际: {:?}", names); } #[test] fn planned_preserves_unordered_tail_for_hint_missing_tools() { // plan_hint 标了 read_file/write_file,但 filtered 里还有 hint 没标的工具(list_directory 等) // → 未被 hint 命中的按 filtered 原序追加尾部兜底(防丢工具)。 let all = vec![ tool_def("list_directory"), tool_def("search_files"), tool_def("write_file"), tool_def("read_file"), tool_def("patch_file"), tool_def("file_info"), tool_def("append_file"), tool_def("delete_file"), tool_def("rename_file"), ]; let out = filter_tool_defs_planned(&all, &Intent::File, "file", "先 read 文件再修改 write_file"); let names: Vec<&str> = out.iter().map(|d| d.function.name.as_str()).collect(); // 全部 9 个工具都应保留(hint 未标的不丢) assert_eq!(out.len(), 9, "未命中 hint 的工具应追加尾部兜底, 不丢, 实际: {:?}", names); // read_file 仍置前(命中 hint 编排) let read_idx = names.iter().position(|n| *n == "read_file").unwrap(); let write_idx = names.iter().position(|n| *n == "write_file").unwrap(); assert!(read_idx < write_idx); } #[test] fn planned_registry_drift_skips_missing_names() { // registry 漂移:hint 标 read_file/write_file 但 all_defs 没这俩(改了名)。 // → ordered 命中 0 个,全部走「未被 ordered 命中」追加 = filtered 原序(退 filter_tool_defs 行为)。 let all = vec![ tool_def("list_directory"), tool_def("search_files"), tool_def("file_info"), tool_def("append_file"), tool_def("delete_file"), tool_def("rename_file"), ]; // 这条消息会产 read→write hint,但 all_defs 里没 read_file/write_file let out = filter_tool_defs_planned(&all, &Intent::File, "file", "先 read 再修改 write_file"); let flat = filter_tool_defs(&all, &Intent::File); // 漂移:hint 工具名都不在 → 退扁平(filtered 原序),不 panic,不丢不增。 assert_eq!(out.len(), flat.len(), "漂移应退扁平, 实际 planned={}, flat={}", out.len(), flat.len()); let out_names: Vec<&str> = out.iter().map(|d| d.function.name.as_str()).collect(); let flat_names: Vec<&str> = flat.iter().map(|d| d.function.name.as_str()).collect(); assert_eq!(out_names, flat_names, "漂移时 planned 应与 filter 扁平一致"); } #[test] fn planned_multi_read_single_parallel_group_preserves_all() { // 规则 3:读多文件(无写)→ plan_hint 产单并行组 [read_file, list_directory]。 // 全部命中 filtered → 两个工具置前(组内序),其余 filtered 工具追加尾部。 let all = vec![ tool_def("patch_file"), tool_def("read_file"), tool_def("write_file"), tool_def("list_directory"), tool_def("search_files"), tool_def("file_info"), tool_def("append_file"), tool_def("delete_file"), tool_def("rename_file"), ]; let out = filter_tool_defs_planned(&all, &Intent::File, "file", "read 文件再看 directory 目录"); let names: Vec<&str> = out.iter().map(|d| d.function.name.as_str()).collect(); assert_eq!(out.len(), 9, "planned 不应丢工具"); // read_file 和 list_directory 都在 hint 单组,应置前 let read_idx = names.iter().position(|n| *n == "read_file").unwrap(); let dir_idx = names.iter().position(|n| *n == "list_directory").unwrap(); assert!(read_idx < 4 && dir_idx < 4, "组内工具应置前, 实际 read_idx={} dir_idx={}", read_idx, dir_idx); } }