//! AI 工具实体参数 name→id 自动解析(机制层) //! //! 背景:DevFlow aichat 的 AI 工具要求 project_id 传 UUID,但用户/模型常用 name //! (如 "moyu"/"u-talk"/"DevFlow"),`repo.query("project_id", pid)` 硬匹配 UUID 全返空, //! 模型被迫绕 list_projects 找 UUID 浪费轮次。 //! //! 本模块在工具执行前统一做 name→id 解析: //! - 声明式映射表 [`RESOLVE_MAP`]:新工具需按 name 解析,只需在表里登记一行即生效 //! (工具名 + 参数键名 + 实体类型),无框架/无配置。 //! - UUID 形态跳过:已是 id 的值直接透传,不查库(零额外开销)。 //! - 按 name 精确查 active 项目(list_active 排软删 + 内存过滤 `p.name == value`), //! 命中唯一则替换为项目 id;0 命中 / 重名(≥2)返可行动错误,让 LLM 拿提示自修。 //! //! 调用点:audit/mod.rs process_tool_calls 单点漏斗(auto + 审批 + 目录授权全部执行路径 //! 统一拿到已解析 id)。 use std::sync::Arc; use df_storage::crud::ProjectRepo; use df_storage::db::Database; /// 实体类型:后续扩展 Task/Idea 时在此加枚举变体即可。 #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub(crate) enum EntityType { Project, } /// 声明式映射表:工具名 → [(参数键名, 实体类型)]。 /// 需按 name 解析的工具在此登记一行即生效。 pub(crate) static RESOLVE_MAP: &[(&str, &[(&str, EntityType)])] = &[ ("list_tasks", &[("project_id", EntityType::Project)]), ("create_task", &[("project_id", EntityType::Project)]), ("get_task_count", &[("project_id", EntityType::Project)]), ("get_project_timeline", &[("project_id", EntityType::Project)]), ("add_project_service", &[("project_id", EntityType::Project)]), ("list_project_services",&[("project_id", EntityType::Project)]), ("list_project_modules", &[("project_id", EntityType::Project)]), ("update_project", &[("id", EntityType::Project)]), ("bind_directory", &[("id", EntityType::Project)]), ("delete_project", &[("id", EntityType::Project)]), ("restore_project", &[("id", EntityType::Project)]), ("purge_project", &[("id", EntityType::Project)]), ]; /// 判定字符串是否为 UUID 形态(`Uuid::parse_str` 支持 simple/hyphenated/urn/braced 四格式)。 /// /// **严禁用 `contains('-')` 判定**——"u-talk"/"df-relay" 是真实项目名带连字符,会被误判为 UUID。 fn is_uuid_shape(s: &str) -> bool { uuid::Uuid::parse_str(s).is_ok() } /// 解析工具参数中的实体 name→id。 /// /// 规则(与调用点协商的契约): /// - 工具未在 [`RESOLVE_MAP`] 登记 / 参数缺失 / 值非字符串 → 原样 `Ok(args.clone())` /// (不拦截,交给工具 handler 正常处理)。 /// - 值是 UUID 形态 → 已是 id,跳过(不查库)。 /// - 非 UUID → 按 name 精确查 active 项目(list_active 排软删 + 内存过滤 `p.name == value`)。 /// 命中唯一 → 替换该键为项目 id(保持其余字段);0 命中 / 重名(≥2)→ Err(结构化错误, /// 由调用点包 failed envelope 回传 LLM,让模型拿可行动提示自修)。 pub(crate) async fn resolve_entity_ids( db: &Arc, tool_name: &str, args: &serde_json::Value, ) -> anyhow::Result { // 映射表未登记该工具 → 原样透传(零侵入) let Some((_, key_entities)) = RESOLVE_MAP.iter().find(|(name, _)| *name == tool_name) else { return Ok(args.clone()); }; let mut resolved = args.clone(); // 遍历该工具登记的所有实体参数键(当前每个工具 1 键,通用支持多键) for (key, entity_type) in *key_entities { // 参数缺失 / 值非字符串 → 跳过(工具 handler 自有缺参语义,不在此拦截) let Some(value) = resolved.get(*key).and_then(|v| v.as_str()) else { continue; }; // UUID 形态 → 已是 id,跳过(不查库,零额外开销) if is_uuid_shape(value) { continue; } // 按 name 精确解析:list_active 排软删 + 内存过滤 p.name == value let id = match *entity_type { EntityType::Project => resolve_project_name(db, tool_name, key, value).await?, }; // 替换该键为项目 id,保持其余字段 if let Some(obj) = resolved.as_object_mut() { obj.insert(key.to_string(), serde_json::Value::String(id)); } } Ok(resolved) } /// 按 name 精确解析 active 项目(list_active 排软删),返回唯一命中的项目 id。 /// 0 命中 / 重名(≥2)返可行动错误(含工具名 + 参数键 + 值),让 LLM 自修。 async fn resolve_project_name( db: &Arc, tool_name: &str, key: &str, value: &str, ) -> anyhow::Result { let projects = ProjectRepo::new(db).list_active().await?; let matches: Vec<_> = projects.iter().filter(|p| p.name == value).collect(); match matches.len() { 0 => Err(anyhow::anyhow!( "工具 {} 的参数 {}={} 解析失败:未找到名为「{}」的项目(精确匹配,含空格/大小写)。可先调用 list_projects 核对项目名", tool_name, key, value, value )), 1 => Ok(matches[0].id.clone()), _ => { // 重名:列出前 2 个(含 id+path),引导用 list_projects 区分后传具体项目 id let a = matches[0]; let b = matches[1]; Err(anyhow::anyhow!( "工具 {} 的参数 {}={} 命中多个同名项目:「{}」(id={}, path={})、「{}」(id={}, path={})。请用 list_projects 区分后传具体项目 id", tool_name, key, value, a.name, a.id, a.path.clone().unwrap_or_default(), b.name, b.id, b.path.clone().unwrap_or_default(), )) } } } #[cfg(test)] mod tests { use super::*; use df_storage::models::ProjectRecord; use df_types::types::{ProjectStatus, new_id}; /// 内存 DB + 插一条项目,返回 (db, project_id) async fn seed_project(db: &Arc, name: &str) -> String { let now = df_types::now_millis().to_string(); let rec = ProjectRecord { id: new_id(), name: name.to_owned(), description: String::new(), status: ProjectStatus::Planning, idea_id: None, path: Some(format!("C:/projects/{}", name)), stack: None, archived: false, created_at: now.clone(), updated_at: now, }; ProjectRepo::new(db).insert(rec).await.unwrap() } #[test] fn is_uuid_shape_table() { let real_uuid = df_types::types::new_id(); assert!(is_uuid_shape(&real_uuid), "真 UUID v4 应为 true"); for s in ["u-talk", "moyu", "proj-svc-1", ""] { assert!(!is_uuid_shape(s), "「{}」不应判为 UUID", s); } } #[tokio::test] async fn resolve_unknown_tool_passthrough() { let db = Arc::new(Database::open_in_memory().await.unwrap()); let args = serde_json::json!({ "path": "/tmp/x.rs" }); let out = resolve_entity_ids(&db, "read_file", &args).await.unwrap(); assert_eq!(out, args, "未登记工具应原样透传"); } #[tokio::test] async fn resolve_unique_name_to_id() { let db = Arc::new(Database::open_in_memory().await.unwrap()); let pid = seed_project(&db, "moyu").await; let args = serde_json::json!({ "project_id": "moyu", "status": "todo" }); let out = resolve_entity_ids(&db, "list_tasks", &args).await.unwrap(); assert_eq!(out["project_id"], serde_json::Value::String(pid)); assert_eq!(out["status"], "todo", "其余字段应保持不变"); } #[tokio::test] async fn resolve_uuid_passthrough() { let db = Arc::new(Database::open_in_memory().await.unwrap()); let uuid = df_types::types::new_id(); let args = serde_json::json!({ "project_id": uuid }); let out = resolve_entity_ids(&db, "list_tasks", &args).await.unwrap(); assert_eq!(out, args, "已是 UUID 应原样透传(不查库)"); } #[tokio::test] async fn resolve_missing_param_passthrough() { let db = Arc::new(Database::open_in_memory().await.unwrap()); let args = serde_json::json!({ "status": "todo" }); let out = resolve_entity_ids(&db, "list_tasks", &args).await.unwrap(); assert_eq!(out, args, "参数缺失应原样透传"); } #[tokio::test] async fn resolve_zero_hit_errors() { let db = Arc::new(Database::open_in_memory().await.unwrap()); let args = serde_json::json!({ "project_id": "不存在" }); let err = resolve_entity_ids(&db, "list_tasks", &args).await.unwrap_err(); let msg = err.to_string(); assert!(msg.contains("未找到"), "0 命中应报「未找到」,实际: {}", msg); } #[tokio::test] async fn resolve_duplicate_name_errors() { let db = Arc::new(Database::open_in_memory().await.unwrap()); let pid1 = seed_project(&db, "DevFlow").await; let pid2 = seed_project(&db, "DevFlow").await; let args = serde_json::json!({ "project_id": "DevFlow" }); let err = resolve_entity_ids(&db, "list_tasks", &args).await.unwrap_err(); let msg = err.to_string(); assert!(msg.contains("命中多个同名项目"), "重名应报,实际: {}", msg); assert!(msg.contains(&pid1), "错误应含第一个 id,实际: {}", msg); assert!(msg.contains(&pid2), "错误应含第二个 id,实际: {}", msg); } #[tokio::test] async fn resolve_soft_deleted_excluded() { let db = Arc::new(Database::open_in_memory().await.unwrap()); let pid = seed_project(&db, "回收站项目").await; ProjectRepo::new(&db).soft_delete(&pid).await.unwrap(); let args = serde_json::json!({ "project_id": "回收站项目" }); let err = resolve_entity_ids(&db, "list_tasks", &args).await.unwrap_err(); assert!( err.to_string().contains("未找到"), "软删项目不应解析到,实际: {}", err ); } }