Files
dingtalk-workspace-cli/internal/cli/schema_search_command.go
T
玉澜 0df293186a feat(cli): harden tool search ranking, guardrails and observability
- default engine: fielded BM25 ensemble (action_v1 remains a shadow candidate)
- add --explain score_breakdown: per-field contributions, structured
  multiplier, query classification and avoid_when penalty
- add DWS_TOOL_SEARCH_* env overrides (algorithm, k1/b, field weights,
  explain) for release-independent tuning and rollback
- tokenizer: CJK unigram+bigram so single-character intents match; align
  isToolSearchCJK with unicode.Han used by the evaluation slices
- structured routing: extend vocabularies with English synonyms,
  word-boundary matching for ASCII values, restrict the rerank gate to
  technical identifiers only
- avoid_when demotion layer wraps every lexical algorithm (Forbidden@5
  proxy 37.55% -> 0); intent-overlap matching rejected after control
  showed it demoted gold answers (Recall@5 0.8664 -> 0.7845)
- remove the OA task_id +1.00 fixture-specific multiplier
- build the default engine once per process for long-lived hosts
- evaluation baseline: intent-proxy R@5 990/1123 (88.16%); update
  integration sentinels accordingly
2026-08-13 15:20:09 +08:00

204 lines
8.7 KiB
Go
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.
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package cli
import (
"bytes"
"encoding/json"
"fmt"
"io"
"strings"
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
"github.com/spf13/cobra"
)
// ToolSearchV1Request is the stable stdin transport used by Agent hosts.
type ToolSearchV1Request struct {
Version string `json:"version"`
Query string `json:"query"`
Subqueries []string `json:"subqueries,omitempty"`
Limit int `json:"limit,omitempty"`
CandidateLimit int `json:"candidate_limit,omitempty"`
ProductIDs []string `json:"product_ids,omitempty"`
Effects []string `json:"effects,omitempty"`
ExcludeCanonical []string `json:"exclude_canonical,omitempty"`
}
var schemaSearchNewEngine = NewDeliveryToolSearchEngineWithConfig
// schemaSearchEngineConfig resolves the search config the same way
// NewDeliveryToolSearchEngine does (defaults + DWS_TOOL_SEARCH_* env
// overrides), then lets an explicit --explain flag win over the env value.
func schemaSearchEngineConfig(cmd *cobra.Command) ToolSearchConfig {
config := ResolveToolSearchConfigFromEnv(DefaultToolSearchConfig())
if explain, _ := cmd.Flags().GetBool("explain"); explain {
config.Explain = true
}
return config
}
func newSchemaSearchCommand() *cobra.Command {
cmd := &cobra.Command{
Use: "search",
Short: "按自然语言搜索可用命令 Schema",
Long: `按自然语言或精确身份检索 ToolReference。
输出固定为完整、紧凑的 tool-search.v1 JSON envelope。为保证版本、Catalog
绑定和响应字节预算可验证,本命令不支持 --fields、--jq 或非 JSON --format。
DWS 对 Agent 是一个元工具:已知子命令直接执行,未知子命令才使用 search。
选中候选后,必须使用 response catalog 的 source/surface hash Inspect canonical_path,
再按 primary_cli_path 执行;Search 结果本身不授权、不执行。`,
Example: ` dws schema search --query "查询群消息已读状态"
dws schema chat.query_msg_read_status --compact --format json \
--expected-source-hash "<search.catalog.source_hash>" \
--expected-surface-hash "<search.catalog.surface_hash>"
printf '%s' '{"version":"tool-search.v1","query":"发群文件并确认已读","subqueries":["给群里发送文件消息","查询群消息已读状态"]}' |
dws schema search --request-json -`,
Args: cobra.NoArgs,
DisableAutoGenTag: true,
RunE: func(cmd *cobra.Command, _ []string) error {
if err := validateSchemaSearchOutputFlags(cmd); err != nil {
return err
}
request, err := schemaSearchRequest(cmd)
if err != nil {
return err
}
engine, err := schemaSearchNewEngine(schemaSearchEngineConfig(cmd))
if err != nil {
return err
}
internal := ToolSearchRequest{
Query: request.Query,
Limit: request.Limit,
CandidateLimit: request.CandidateLimit,
ProductIDs: request.ProductIDs,
Effects: request.Effects,
ExcludeCanonicalPaths: request.ExcludeCanonical,
}
var response ToolSearchResponse
if len(request.Subqueries) > 0 {
response, err = engine.SearchSubqueries(cmd.Context(), request.Subqueries, internal)
} else {
response, err = engine.Search(cmd.Context(), internal)
}
if err != nil {
return err
}
// The response budget is defined over this compact wire encoding. Do
// not run it through pretty/table formatting or post-filtering because
// Agent hosts require one complete versioned envelope.
if err := json.NewEncoder(cmd.OutOrStdout()).Encode(response); err != nil {
return fmt.Errorf("encode tool-search.v1 response: %w", err)
}
return nil
},
}
cmd.Flags().String("query", "", "自然语言意图或 canonical/CLI path")
cmd.Flags().Int("limit", defaultToolSearchLimit, "返回引用数量")
cmd.Flags().Int("candidate-limit", defaultToolSearchCandidateLimit, "每路召回候选数量")
cmd.Flags().StringSlice("product", nil, "限制产品 ID,可重复或逗号分隔")
cmd.Flags().StringSlice("effect", nil, "限制 effect,可重复或逗号分隔")
cmd.Flags().StringSlice("exclude", nil, "排除 canonical path,可重复或逗号分隔")
cmd.Flags().String("request-json", "", `从 stdin 读取 JSON;值必须为 -;必含 version="tool-search.v1",动作分解字段为 subqueries`)
cmd.Flags().Bool("explain", false, "在每个 candidate 上输出 score_breakdown 排序诊断(会增大响应体积,建议配合 --limit 3)")
return cmd
}
func validateSchemaSearchOutputFlags(cmd *cobra.Command) error {
for _, name := range []string{"fields", "jq"} {
if flag := cmd.Flag(name); flag != nil && flag.Changed {
return apperrors.NewValidation(
"schema search returns one complete tool-search.v1 envelope and does not support --"+name,
apperrors.WithReason("unsupported_output_filter"),
)
}
}
if flag := cmd.Flag("format"); flag != nil && flag.Changed && strings.TrimSpace(flag.Value.String()) != "json" {
return apperrors.NewValidation(
"schema search only supports --format json",
apperrors.WithReason("unsupported_output_format"),
)
}
return nil
}
func schemaSearchRequest(cmd *cobra.Command) (ToolSearchV1Request, error) {
requestJSON, _ := cmd.Flags().GetString("request-json")
requestJSON = strings.TrimSpace(requestJSON)
if requestJSON != "" {
if requestJSON != "-" {
return ToolSearchV1Request{}, apperrors.NewValidation("--request-json only accepts - (stdin)", apperrors.WithReason("invalid_transport"))
}
for _, name := range []string{"query", "limit", "candidate-limit", "product", "effect", "exclude"} {
if cmd.Flags().Changed(name) {
return ToolSearchV1Request{}, apperrors.NewValidation("--request-json cannot be combined with --"+name, apperrors.WithReason("ambiguous_input"))
}
}
request, err := decodeToolSearchV1Request(cmd.InOrStdin())
if err != nil {
return ToolSearchV1Request{}, err
}
return validateToolSearchV1Request(request)
}
query, _ := cmd.Flags().GetString("query")
limit, _ := cmd.Flags().GetInt("limit")
candidateLimit, _ := cmd.Flags().GetInt("candidate-limit")
products, _ := cmd.Flags().GetStringSlice("product")
effects, _ := cmd.Flags().GetStringSlice("effect")
excluded, _ := cmd.Flags().GetStringSlice("exclude")
return validateToolSearchV1Request(ToolSearchV1Request{
Version: "tool-search.v1",
Query: query,
Limit: limit,
CandidateLimit: candidateLimit,
ProductIDs: products,
Effects: effects,
ExcludeCanonical: excluded,
})
}
func decodeToolSearchV1Request(reader io.Reader) (ToolSearchV1Request, error) {
payload, err := io.ReadAll(io.LimitReader(reader, maxToolSearchRequestBytes+1))
if err != nil {
return ToolSearchV1Request{}, apperrors.NewValidation("read tool search request: "+err.Error(), apperrors.WithReason("read_request_failed"))
}
if len(payload) > maxToolSearchRequestBytes {
return ToolSearchV1Request{}, apperrors.NewValidation(fmt.Sprintf("tool search request exceeds %d bytes", maxToolSearchRequestBytes), apperrors.WithReason("request_too_large"))
}
decoder := json.NewDecoder(bytes.NewReader(payload))
decoder.DisallowUnknownFields()
var request ToolSearchV1Request
if err := decoder.Decode(&request); err != nil {
return ToolSearchV1Request{}, apperrors.NewValidation("decode tool-search.v1 request: "+err.Error(), apperrors.WithReason("invalid_request_json"))
}
var trailing any
if err := decoder.Decode(&trailing); err != io.EOF {
if err == nil {
return ToolSearchV1Request{}, apperrors.NewValidation("tool search request must contain exactly one JSON object", apperrors.WithReason("invalid_request_json"))
}
return ToolSearchV1Request{}, apperrors.NewValidation("decode trailing tool search input: "+err.Error(), apperrors.WithReason("invalid_request_json"))
}
return request, nil
}
func validateToolSearchV1Request(request ToolSearchV1Request) (ToolSearchV1Request, error) {
if request.Version != "tool-search.v1" {
return ToolSearchV1Request{}, apperrors.NewValidation(`tool search request must include "version":"tool-search.v1"; action decomposition uses the "subqueries" array`, apperrors.WithReason("unsupported_version"))
}
return request, nil
}