Files
DevFlow/crates/df-ai/src/intent.rs
2026-06-24 00:27:09 +08:00

1328 lines
55 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 会话意图识别层Intent Recognition
//!
//! 论证依据:`docs/02-架构设计/构想审查/意图识别层论证-2026-06-19.md`
//!
//! ## 定位
//! 纯函数模块,**不接入 agentic loop**(待 Phase B+C 完成 + 模型模态管理后接入)。
//! 提供三个能力:
//! 1. `IntentRecognizer::recognize(message)` —— 规则/关键词匹配(方式 A零延迟零成本
//! 返回 `(Intent, f32)`,置信度 0.01.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_commandshell 命令,独立 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 promptcontent 里残留的前缀只污染关键词识别——实测会话
/// `[项目: 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<char> = 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<usize> {
let after = open + 1;
for k in kinds {
let kc: Vec<char> = 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.01.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_filesConversation → [];
/// 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<ModelTier> {
// TODO(model-tier-phase): 待模型模态管理落地后填实映射。
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()
}
// ---- 工具子集过滤 + 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<df_ai_core::types::ToolDefinition> {
// 第一步:先得扁平收敛子集(复用 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<df_ai_core::types::ToolDefinition> = Vec::with_capacity(filtered.len());
let mut consumed: std::collections::HashSet<usize> = 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_tagsL0 去噪)---
#[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<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(&"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);
}
}