1328 lines
55 KiB
Rust
1328 lines
55 KiB
Rust
//! 会话意图识别层(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<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.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<ModelTier> {
|
||
// 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<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_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<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);
|
||
}
|
||
}
|