Files
dingtalk-workspace-cli/internal/helpers/aisearch.go

467 lines
20 KiB
Go
Raw Permalink 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.
package helpers
import (
"strings"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract"
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
"github.com/spf13/cobra"
)
// boolPtr returns a pointer to v, for ParamDecl.Required declarations.
func boolPtr(v bool) *bool { return &v }
// 这些是原有的宽松兜底名,不属于本次 Primary 迁移;保持原生 hidden
// 注册,不给它们新增 alias_of/origin 元数据。
var aisearchPersonGuessFlags = []string{"name", "q", "text"}
// 历史解析优先级必须独立于新旧 Primary:双传时旧 --keyword 始终获胜。
var aisearchQueryResolutionOrder = []string{"keyword", "name", "q", "query", "text"}
// flagValue 安全地读取 flag 值:先查 local,再查自身 PersistentFlags,
// 再查 parents 的 PersistentFlags。比 cmd.Flags().GetString 更鲁棒,
// 因为后者只在 cobra Execute 完成 mergePersistentFlags 后才包含继承的 flag。
func flagValue(cmd *cobra.Command, name string) string {
if f := cmd.Flag(name); f != nil {
return f.Value.String()
}
return ""
}
func changedFlagValue(cmd *cobra.Command, name string) (string, bool) {
if f := cmd.Flag(name); f != nil && f.Changed {
return f.Value.String(), true
}
return "", false
}
func aisearchFlagOrFallback(cmd *cobra.Command, primary string, aliases ...string) string {
if v := flagValue(cmd, primary); v != "" {
return v
}
for _, alias := range aliases {
if v := flagValue(cmd, alias); v != "" {
return v
}
}
return ""
}
func aisearchFlagOrDefault(cmd *cobra.Command, primary, def string, aliases ...string) string {
names := append([]string{primary}, aliases...)
for _, name := range names {
if v, ok := changedFlagValue(cmd, name); ok && v != "" {
return v
}
}
if v := flagValue(cmd, primary); v != "" {
return v
}
return def
}
// resolveAisearchKeyword 按历史顺序解析人员搜索词。虽然公开 Primary 已改为
// --query,但兼容期内显式旧 --keyword 的值仍优先。
func resolveAisearchKeyword(cmd *cobra.Command) string {
for _, name := range aisearchQueryResolutionOrder {
if v := flagValue(cmd, name); v != "" {
return v
}
}
return ""
}
func addAisearchPersonFlags(cmd *cobra.Command) {
// Alias evidence must be emitted by corecmd.FlagSpec.Aliases. Build the pair
// on a temporary declaration command so the legacy alias can retain its
// historical -w shorthand without extending the global alias mechanism or
// assigning that shorthand to the new --query Primary.
declaration := &cobra.Command{Use: "aisearch-query-flags"}
corecmd.RegisterFlags(declaration, []corecmd.FlagSpec{{
Name: "query",
Usage: "搜索关键词 (必填,如人名、技能关键词等)",
Aliases: []string{"keyword"},
}})
queryFlag := declaration.Flags().Lookup("query")
keywordFlag := declaration.Flags().Lookup("keyword")
keywordFlag.Shorthand = "w"
cmd.Flags().AddFlag(queryFlag)
cmd.Flags().AddFlag(keywordFlag)
cmd.Flags().StringP("dimension", "d", "all", "查询维度: all/name/department/position/duty/supervisor/subordinate/phone/jobNumber,多个用逗号分隔")
for _, alias := range aisearchPersonGuessFlags {
cmd.Flags().String(alias, "", "")
_ = cmd.Flags().MarkHidden(alias)
}
cmd.Flags().String("type", "", "兼容选择器;person/search 路径仅接受 person/user/people")
_ = cmd.Flags().MarkHidden("type")
}
func addAisearchKeywordCompatibilityFlag(cmd *cobra.Command) {
cmd.Flags().String("keyword", "", "--queries 的兼容别名")
_ = cmd.Flags().MarkHidden("keyword")
cmd.Flags().String("query", "", "--queries 的兼容别名")
_ = cmd.Flags().MarkHidden("query")
}
// runAisearchPerson 是 aisearch person 的实际执行体,被 personCmd 和 root
// 的智能 RunE(裸调兜底)共享调用。
func runAisearchPerson(cmd *cobra.Command, _ []string) error {
if selector := strings.ToLower(strings.TrimSpace(flagValue(cmd, "type"))); selector != "" && selector != "person" && selector != "user" && selector != "people" {
return apperrors.NewValidation("aisearch person/search 的 --type 仅接受 person、user 或 people")
}
keyword := resolveAisearchKeyword(cmd)
if keyword == "" {
return validateRequiredFlags(cmd, "query")
}
dimensions := parseDimensions(flagValue(cmd, "dimension"))
return callMCPTool("enterprise_person_search", map[string]any{
"keyword": keyword,
"dimension": dimensions,
})
}
// runAisearchEnterprise 调用企业内部知识搜索工具。它关注内容本身,
// 参数只包含内容关键词、内容类型和显式时间范围。
func runAisearchEnterprise(cmd *cobra.Command, _ []string) error {
queries := parseCSVValues(aisearchFlagOrFallback(cmd, "queries", "query", "keyword"))
searchTypes := normalizeAisearchSearchTypes(parseCSVValues(aisearchFlagOrDefault(cmd, "types", "all", "search-types", "searchTypes")))
if len(searchTypes) == 0 {
searchTypes = []string{"all"}
}
toolArgs := map[string]any{
"queries": queries,
"searchTypes": searchTypes,
}
if v := aisearchFlagOrFallback(cmd, "time-range", "timeRange"); v != "" {
toolArgs["timeRange"] = v
}
return callMCPTool("search_enterprise", toolArgs)
}
// runAisearchBehavior 调用企业内部行为记录搜索工具。该能力和 person 同属
// aisearch server,但参数空间不同,因此独立成 behavior 子命令,避免复用
// search/query 这类已经被 person 兜底占用的路径。
func runAisearchBehavior(cmd *cobra.Command, _ []string) error {
queries := parseCSVValues(aisearchFlagOrFallback(cmd, "queries", "query", "keyword"))
searchTypes := normalizeAisearchSearchTypes(parseCSVValues(aisearchFlagOrDefault(cmd, "types", "all", "search-types", "searchTypes")))
if len(searchTypes) == 0 {
searchTypes = []string{"all"}
}
toolArgs := map[string]any{
"queries": queries,
"searchTypes": searchTypes,
}
if v := aisearchFlagOrFallback(cmd, "chat-scope", "chatScope"); v != "" {
toolArgs["chatScope"] = v
}
if v := aisearchFlagOrDefault(cmd, "behavior-type", "all", "behaviorType"); v != "" {
toolArgs["behaviorType"] = v
}
if v := aisearchFlagOrFallback(cmd, "time-range", "timeRange"); v != "" {
toolArgs["timeRange"] = v
}
if v := flagValue(cmd, "direction"); v != "" {
toolArgs["direction"] = v
}
return callMCPTool("search_enterprise_behavior", toolArgs)
}
func normalizeAisearchSearchTypes(values []string) []string {
normalized := make([]string, 0, len(values))
for _, v := range values {
switch strings.ToLower(strings.TrimSpace(v)) {
case "doc", "document":
normalized = append(normalized, "document")
default:
normalized = append(normalized, v)
}
}
return normalized
}
func newAisearchCommand() *cobra.Command {
// Product-level Agent routing Decl (migrated from selection/aisearch.json
// products.aisearch). Catalog assembly stamps provenance contract_final.
contract.RegisterProductDecl(contract.ProductDecl{
ID: "aisearch",
Selection: contract.ProductSelectionDecl{
AgentSummary: "企业内智能搜人、搜知识内容与搜行为记录",
UseWhen: []string{
"语义找人、按主题搜企业知识,或追溯发送/创建/分享等行为",
},
AvoidWhen: []string{
"已有明确资源 ID 要读写时改用对应产品;普通 OAuth 登录不用 aisearch",
},
},
})
root := &cobra.Command{
Use: "aisearch",
Short: "AI 搜问",
Long: `AI 搜问:搜索企业人员信息、企业内部知识内容与企业内部行为记录。`,
// 智能 root:模型常漏 person 子命令直接 dws aisearch --query xxx,
// 检测到 query 就自动等价于 person;否则退回 group help。
RunE: func(cmd *cobra.Command, args []string) error {
if resolveAisearchKeyword(cmd) != "" {
return runAisearchPerson(cmd, args)
}
return groupRunE(cmd, args)
},
}
// root 和 person 各自定义同一组本地 flag,这样:
// - dws aisearch --query xxx ← root 自己能解析
// - dws aisearch person --query xxx ← person 本地 flag
// - dws aisearch search --query xxx ← search 是 person 的 alias
// 不能放在 product PersistentFlags:否则 enterprise/behavior 会公开接受
// 它们不拥有的 person-only dimension,破坏 Help ↔ Schema 完整性。
addAisearchPersonFlags(root)
personCmd := &cobra.Command{
Use: "person",
// alias 列表覆盖真实瞎猜模式(按图里调用频次降序):
// A 类同义瞎猜:search(196) / search-person(74) / user-search(2) /
// user(隐含) / query(5) / people(4) / ask(1) / search-user(2)
// B 类跨模块混淆:contact(2 + 路径变体共 5)
// 所有 alias 透明等价于 person,对外文档/help 仍只展示 person。
Aliases: []string{
"search", "search-person", "search-user",
"user", "user-search",
"query", "people", "ask", "find", "lookup",
"contact",
},
// 显式声明允许任意位置参数:模型可能写 dws aisearch person search --query xxx,
// 此时 "search" 会作为 positional arg 被忽略,不报错。
Args: cobra.ArbitraryArgs,
Short: "搜索企业人员",
Long: `通过关键词搜索企业内人员信息,支持按维度筛选。
可选维度 (--dimension):
all 全部维度 (默认)
name 姓名
department 部门
position 职位
duty 职责/技能
supervisor 上级
subordinate 下级
phone 手机号
jobNumber 工号
多个维度用逗号分隔。`,
Example: ` dws aisearch person --query "张三" --dimension department
dws aisearch person --query "产品部" --dimension department
dws aisearch person --query "五道" --dimension supervisor
dws aisearch person --query "AI搜问" --dimension duty
dws aisearch person --query "李四" --dimension name,department
dws aisearch person --query "13800138000" --dimension phone
dws aisearch person --query "W12345" --dimension jobNumber`,
RunE: runAisearchPerson,
}
DeclareLeafMetadata(personCmd, LeafSpec{
Safety: contract.SafetySpec{
Effect: "read", Risk: "low",
Confirmation: "not_required", Idempotency: "idempotent",
},
Contract: LeafContract{
Identity: contract.ToolIdentitySpec{
ProductID: "aisearch",
Name: "enterprise_person_search",
CanonicalPath: "aisearch.enterprise_person_search",
CLIPath: "aisearch person",
PrimaryCLIPath: "aisearch person",
},
Description: "企业内找人:按姓名/部门/职位/职责/上下级/手机号/工号筛选",
Interface: &contract.InterfaceSpec{
Mode: "mcp",
Availability: "available",
Ref: &contract.InterfaceRefSpec{ProductID: "aisearch", RPCName: "enterprise_person_search"},
},
Selection: contract.SelectionSpec{
AgentSummary: "企业内找人:按姓名/部门/职位/职责/上下级/手机号/工号筛选",
UseWhen: []string{
"找人、谁负责某事、查上级/下级、按手机号或工号定位人员",
"需要把维度词映射到 --dimension,关键词只保留目标实体",
},
AvoidWhen: []string{
"已有 userId 只需详情时用 contact user get",
"精确通讯录关键词搜同事/好友且不涉及职责语义时可用 contact user search",
"搜企业知识内容时用 aisearch enterprise;搜行为记录时用 aisearch behavior",
},
Examples: []string{
"dws aisearch person --query \"张三\" --dimension name --format json",
"dws aisearch person --query \"五道\" --dimension supervisor --format json",
},
},
Parameters: []contract.ParamDecl{
{Name: "query", Property: "keyword", Required: boolPtr(true)},
},
},
})
addAisearchPersonFlags(personCmd)
enterpriseCmd := &cobra.Command{
Use: "enterprise",
Aliases: []string{"knowledge", "content", "search-enterprise", "search_enterprise"},
Short: "搜索企业内部知识内容和相关消息",
Long: `检索企业内部知识内容,如文档、消息、日程、待办、听记、日志、图片、链接、AI 表格、企业百科、邮件等。
普通“XX 相关消息/文档/邮件/日程/待办/纪要有哪些”属于企业内容搜索,使用本命令;queries 只放内容关键词,时间放到 --time-range,所有类型词放到 --types。汇总类场景可不传 queries,使用 --types all。
不要把“最近搜索问题相关消息”截断成 --query "搜索问题",也不要把“最近 OKR 相关邮件”写成 --query "OKR 邮件";这会丢失时间和类型槽位。应显式写成 --queries + --types + --time-range。`,
Example: ` dws aisearch enterprise --queries "智能化方案" --types document
dws aisearch enterprise --queries "搜索问题" --types im --time-range "最近"
dws aisearch enterprise --queries "OKR" --types mail --time-range "最近"
dws aisearch enterprise --queries "AI搜问" --types calendar --time-range "本周"
dws aisearch enterprise --queries "项目" --types todo,minute --time-range "最近"
dws aisearch enterprise --queries "发版" --types im --time-range "本周"
dws aisearch enterprise --types all --time-range "本周"
dws aisearch enterprise --queries "OKR" --types document,im,mail`,
RunE: runAisearchEnterprise,
}
// Register flags that carry ParamDecl before DeclareLeafMetadata so
// AttachContract can emit their dws.schema.* annotations.
enterpriseCmd.Flags().String("queries", "", "内容关键词列表,多个用逗号分隔;汇总类场景可留空")
enterpriseCmd.Flags().String("time-range", "", "时间范围,仅当用户显式给出时间词时填写,如 今天/本周/9月/过去一周")
DeclareLeafMetadata(enterpriseCmd, LeafSpec{
Safety: contract.SafetySpec{
Effect: "read", Risk: "low",
Confirmation: "not_required", Idempotency: "idempotent",
},
Contract: LeafContract{
Identity: contract.ToolIdentitySpec{
ProductID: "aisearch",
Name: "search_enterprise",
CanonicalPath: "aisearch.search_enterprise",
CLIPath: "aisearch enterprise",
PrimaryCLIPath: "aisearch enterprise",
},
Description: "搜索企业内部知识与相关内容(文档/IM/日历/待办/纪要/日志/邮件等)",
Interface: &contract.InterfaceSpec{
Mode: "mcp",
Availability: "available",
Ref: &contract.InterfaceRefSpec{ProductID: "aisearch", RPCName: "search_enterprise"},
},
Selection: contract.SelectionSpec{
AgentSummary: "搜索企业内部知识与相关内容(文档/IM/日历/待办/纪要/日志/邮件等)",
UseWhen: []string{
"按主题找资料、方案、文档、消息、邮件等内容,且关注“有什么内容”",
"用户显式给出时间词或类型词时分别映射到 --time-range / --types",
},
AvoidWhen: []string{
"问“我发给谁/谁发给我/我创建过”等行为追溯时用 aisearch behavior",
"企业找人时用 aisearch person",
"已知具体资源 ID 要读写时改用对应产品命令",
},
Examples: []string{
"dws aisearch enterprise --queries \"智能化方案\" --types document --format json",
"dws aisearch enterprise --queries \"OKR\" --types mail --time-range \"最近\" --format json",
},
},
Parameters: []contract.ParamDecl{
{Name: "queries", Required: boolPtr(false)},
{Name: "time-range", Required: boolPtr(false)},
{Name: "types", Property: "searchTypes"},
},
},
})
enterpriseCmd.Flags().String("types", "all", "搜索类型: all/document/im/calendar/todo/minute/report/image/link/notable/baike/mail,多个用逗号分隔")
enterpriseCmd.Flags().String("search-types", "", "--types 的别名")
_ = enterpriseCmd.Flags().MarkHidden("search-types")
enterpriseCmd.Flags().String("searchTypes", "", "--types 的别名")
_ = enterpriseCmd.Flags().MarkHidden("searchTypes")
enterpriseCmd.Flags().String("timeRange", "", "--time-range 的别名")
_ = enterpriseCmd.Flags().MarkHidden("timeRange")
addAisearchKeywordCompatibilityFlag(enterpriseCmd)
behaviorCmd := &cobra.Command{
Use: "behavior",
Short: "搜索明确的发送/创建/接收等行为记录",
Long: `仅当用户明确询问“我/某人发过、发给、收到、创建、分享、编辑过什么”等行为动作时,检索企业内部行为记录。
普通“XX 相关消息/文档/邮件有哪些”不是行为记录,应使用 aisearch enterprise。behavior 的 queries 只放内容关键词;时间放到 --time-range,所有类型词放到 --types,行为动作放到 --behavior-type,人与人之间的流向放到 --direction。`,
Example: ` dws aisearch behavior --types mail --behavior-type send --direction "我->汐峰"
dws aisearch behavior --types im,mail --behavior-type send --direction "我->汐峰"
dws aisearch behavior --types document --behavior-type receive --direction "汐峰->我"
dws aisearch behavior --types all --behavior-type create --time-range "本周"
dws aisearch behavior --types im --chat-scope "scrum群" --behavior-type send --time-range "今天"`,
RunE: runAisearchBehavior,
}
DeclareLeafMetadata(behaviorCmd, LeafSpec{
Safety: contract.SafetySpec{
Effect: "read", Risk: "low",
Confirmation: "not_required", Idempotency: "idempotent",
},
Contract: LeafContract{
Identity: contract.ToolIdentitySpec{
ProductID: "aisearch",
Name: "search_enterprise_behavior",
CanonicalPath: "aisearch.search_enterprise_behavior",
CLIPath: "aisearch behavior",
PrimaryCLIPath: "aisearch behavior",
},
Description: "搜索发送/创建/分享/编辑/接收等明确行为记录",
Interface: &contract.InterfaceSpec{
Mode: "mcp",
Availability: "available",
Ref: &contract.InterfaceRefSpec{ProductID: "aisearch", RPCName: "search_enterprise_behavior"},
},
Selection: contract.SelectionSpec{
AgentSummary: "搜索发送/创建/分享/编辑/接收等明确行为记录",
UseWhen: []string{"用户明确问我/某人发过、发给、收到、创建、分享、编辑过什么"},
AvoidWhen: []string{
"只按主题找内容本身时用 aisearch enterprise",
"没有行为动作词时不要选用本工具",
},
Examples: []string{
"dws aisearch behavior --types mail --behavior-type send --direction \"我->汐峰\" --format json",
"dws aisearch behavior --queries \"智能化方案\" --types document --behavior-type create --format json",
},
},
Parameters: []contract.ParamDecl{
{Name: "types", Property: "searchTypes"},
},
},
})
behaviorCmd.Flags().String("queries", "", "内容关键词列表,多个用逗号分隔;汇总类场景可留空")
behaviorCmd.Flags().String("types", "all", "搜索类型: all/document/im/calendar/todo/minute/report/image/link/notable/baike/mail,多个用逗号分隔")
behaviorCmd.Flags().String("search-types", "", "--types 的别名")
_ = behaviorCmd.Flags().MarkHidden("search-types")
behaviorCmd.Flags().String("searchTypes", "", "--types 的别名")
_ = behaviorCmd.Flags().MarkHidden("searchTypes")
behaviorCmd.Flags().String("chat-scope", "", "消息所在会话/群范围,仅 IM 类型且用户明确指定群名时填写")
behaviorCmd.Flags().String("chatScope", "", "--chat-scope 的别名")
_ = behaviorCmd.Flags().MarkHidden("chatScope")
behaviorCmd.Flags().String("behavior-type", "all", "行为类型: all/send/create/share/edit/receive")
behaviorCmd.Flags().String("behaviorType", "", "--behavior-type 的别名")
_ = behaviorCmd.Flags().MarkHidden("behaviorType")
behaviorCmd.Flags().String("time-range", "", "时间范围,仅当用户显式给出时间词时填写,如 今天/本周/9月/过去一周")
behaviorCmd.Flags().String("timeRange", "", "--time-range 的别名")
_ = behaviorCmd.Flags().MarkHidden("timeRange")
behaviorCmd.Flags().String("direction", "", `交互方向,如 "我->汐峰"、"汐峰->我"、"我<->汐峰"`)
addAisearchKeywordCompatibilityFlag(behaviorCmd)
root.AddCommand(personCmd, enterpriseCmd, behaviorCmd)
return root
}
// parseDimensions 将逗号分隔的维度字符串解析为 []string。
func parseDimensions(s string) []string {
s = strings.TrimSpace(s)
if s == "" {
return []string{"all"}
}
parts := strings.Split(s, ",")
dims := make([]string, 0, len(parts))
for _, p := range parts {
if d := strings.TrimSpace(p); d != "" {
dims = append(dims, d)
}
}
if len(dims) == 0 {
return []string{"all"}
}
return dims
}