Files
dingtalk-workspace-cli/internal/cli/schema_search_command_test.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

96 lines
3.7 KiB
Go

// 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.
package cli
import (
"bytes"
"encoding/json"
"errors"
"strings"
"testing"
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam"
"github.com/spf13/cobra"
)
func TestSchemaSearchCommandExactAndVersioned(t *testing.T) {
installSchemaSearchTestEngine(t)
root := &cobra.Command{Use: "dws"}
root.AddCommand(NewSchemaCommand())
var stdout bytes.Buffer
root.SetOut(&stdout)
root.SetArgs([]string{"schema", "search", "--query", "chat.read_status"})
if err := root.Execute(); err != nil {
t.Fatalf("schema search execute: %v", err)
}
var response ToolSearchResponse
if err := json.Unmarshal(stdout.Bytes(), &response); err != nil {
t.Fatalf("decode response: %v\n%s", err, stdout.String())
}
if response.Version != "tool-search.v1" || response.Strategy != "exact_guard" {
t.Fatalf("response = %#v", response)
}
if len(response.Candidates) != 1 || response.Candidates[0].CanonicalPath != "chat.read_status" {
t.Fatalf("candidates = %#v", response.Candidates)
}
wantCatalog := CatalogVersionRef{SourceHash: "source-test", SurfaceHash: "surface-test"}
if response.Catalog != wantCatalog {
t.Fatalf("Catalog = %#v, want %#v", response.Catalog, wantCatalog)
}
for _, removed := range []string{"external_ranking", "degraded", "degraded_reason_code", "warning_codes", "_fallback"} {
if strings.Contains(stdout.String(), removed) {
t.Fatalf("removed fallback field %q leaked into response: %s", removed, stdout.String())
}
}
}
func TestSchemaSearchRequestRejectsUnboundedOrAmbiguousInput(t *testing.T) {
if _, err := decodeToolSearchV1Request(strings.NewReader(`{"version":"tool-search.v1","query":"x","unknown":true}`)); validationReason(err) != "invalid_request_json" {
t.Fatal("unknown field was accepted")
}
if _, err := decodeToolSearchV1Request(strings.NewReader(`{"version":"tool-search.v1","query":"x","external_ranking":{}}`)); validationReason(err) != "invalid_request_json" {
t.Fatal("removed external_ranking field was accepted")
}
oversized := strings.NewReader(strings.Repeat("x", maxToolSearchRequestBytes+1))
if _, err := decodeToolSearchV1Request(oversized); validationReason(err) != "request_too_large" {
t.Fatal("oversized request was accepted")
}
_, err := validateToolSearchV1Request(ToolSearchV1Request{Query: "x", Subqueries: []string{"x"}})
if validationReason(err) != "unsupported_version" || !strings.Contains(err.Error(), `"version":"tool-search.v1"`) || !strings.Contains(err.Error(), "subqueries") {
t.Fatalf("missing-version guidance = %v", err)
}
}
func TestSchemaSearchHelpDocumentsStructuredRequest(t *testing.T) {
command := newSchemaSearchCommand()
var output bytes.Buffer
command.SetOut(&output)
command.SetArgs([]string{"--help"})
if err := command.Execute(); err != nil {
t.Fatalf("schema search help: %v", err)
}
for _, required := range []string{`"version":"tool-search.v1"`, `"subqueries"`, "--request-json -", "不支持 --fields、--jq", "非 JSON --format"} {
if !strings.Contains(output.String(), required) {
t.Fatalf("schema search help omits %q:\n%s", required, output.String())
}
}
}
func installSchemaSearchTestEngine(t *testing.T) {
t.Helper()
testseam.Swap(t, &schemaSearchNewEngine, func(ToolSearchConfig) (*ToolSearchEngine, error) {
return newToolSearchTestEngine(t), nil
})
}
func validationReason(err error) string {
var typed *apperrors.Error
if errors.As(err, &typed) && typed.Category == apperrors.CategoryValidation {
return typed.Reason
}
return ""
}