feat(cli): search verb-noun subtree (chunks/kb/docs/sessions)

Roadmap 3-1. Verb-noun shape borrowed from gh search (gh search repos
/ code / commits / issues / prs verified against the gh manual).

Subcommands:
- `search chunks "<q>" --kb X` — hybrid retrieval (RAG search).
- `search kb "<q>"` — case-insensitive substring match across KB names
  and descriptions; sorted by name length (shortest hits first).
- `search docs "<q>" --kb X` — pages through ListKnowledge filtering by
  title / file_name; stops once --limit matches are found.
- `search sessions "<q>"` — pages through GetSessionsByTenant filtering
  by title / description.

kb / docs / sessions are client-side filters because the server has no
fuzzy search endpoint for any of them. ListKnowledgeBases returns the
full tenant catalog in one call; the doc/session walkers chunk at 200
per request and stop early on limit.

The parent `search` command is a pure dispatcher — there is no bare-
positional form (no `weknora search "<q>"`).

Cleanups surfaced by the post-commit reviewer round:
- UX consistency: search docs's displayDocName ordered Title →
  FileName → "-", while doc list's displayName uses FileName → Title
  → ID. Same Knowledge rendered differently across commands. Aligned
  search docs on doc list's existing FileName-first convention.
- cmdutil.ResolveKBFlag(ctx, lister, raw) — extracted the
  `IsKBID ? raw : ResolveKBNameToID` block duplicated across chunks
  and docs.
- text.ContainsFold(needle, fields...) — replaces inline
  `strings.Contains(strings.ToLower(field), needle)` patterns.

37 unit tests across chunks/kb/docs/sessions plus the parent
registration smoke-test.

Roadmap: 3-1.
This commit is contained in:
nullkey
2026-05-12 23:38:01 +08:00
committed by lyingbug
parent 78f3994112
commit d54a7a5834
15 changed files with 1131 additions and 331 deletions
+4 -4
View File
@@ -153,18 +153,18 @@ var envelopeCases = []envelopeCase{
wantErr: true,
},
// 11-13. search — top-level command, positional query, --kb required.
// 11-13. search chunks — verb-noun shape (gh search parity), positional query, --kb required.
// --kb accepts either kb_<id> (passed through) or a name (resolved via
// list); UUID-format detection happens client-side, mirroring gcloud
// --project's id-or-name auto-detection.
{
name: "search.success",
args: []string{"search", "query", "--kb=11111111-1111-4111-8111-111111111111", "--top-k=3", "--json"},
args: []string{"search", "chunks", "query", "--kb=11111111-1111-4111-8111-111111111111", "--limit=3", "--json"},
server: searchTwoResults,
},
{
name: "search.error_resource_not_found",
args: []string{"search", "query", "--kb=eeeeeeee-eeee-4eee-8eee-eeeeeeeeeeee", "--json"},
args: []string{"search", "chunks", "query", "--kb=eeeeeeee-eeee-4eee-8eee-eeeeeeeeeeee", "--json"},
server: always404,
wantErr: true,
},
@@ -173,7 +173,7 @@ var envelopeCases = []envelopeCase{
// is just there to satisfy MarkFlagRequired so validation runs deep
// enough to hit the mutex-channel check.
name: "search.error_input_invalid",
args: []string{"search", "query", "--kb=11111111-1111-4111-8111-111111111111", "--no-vector", "--no-keyword", "--json"},
args: []string{"search", "chunks", "query", "--kb=11111111-1111-4111-8111-111111111111", "--no-vector", "--no-keyword", "--json"},
wantErr: true,
},
}
+1 -1
View File
@@ -86,7 +86,7 @@ func TestRAGFullLoop(t *testing.T) {
waitDocReady(t, bin, env, kbID, docID, 90*time.Second)
// 4. search — verify retrieval returns chunks
searchOut := runJSON(t, bin, env, "search", "sample", "--kb", kbID, "--top-k", "5", "--json")
searchOut := runJSON(t, bin, env, "search", "chunks", "sample", "--kb", kbID, "--limit", "5", "--json")
searchData, _ := searchOut["data"].(map[string]any)
results, _ := searchData["results"].([]any)
if len(results) == 0 {
+1 -1
View File
@@ -123,7 +123,7 @@ var cobraFlagErrorPrefixes = []string{
"requires at least", // MinimumNArgs
"requires at most", // MaximumNArgs
"unknown flag",
"invalid argument", // pflag type-coercion failure (e.g. --top-k=foo)
"invalid argument", // pflag type-coercion failure (e.g. --limit=foo)
}
// NewRootCmd builds the cobra tree. Splitting it from Execute() lets tests
+162
View File
@@ -0,0 +1,162 @@
package search
import (
"context"
"fmt"
"strings"
"github.com/spf13/cobra"
"github.com/Tencent/WeKnora/cli/internal/agent"
"github.com/Tencent/WeKnora/cli/internal/cmdutil"
"github.com/Tencent/WeKnora/cli/internal/format"
"github.com/Tencent/WeKnora/cli/internal/iostreams"
sdk "github.com/Tencent/WeKnora/client"
)
// ChunksOptions is the runtime configuration of a chunks search.
type ChunksOptions struct {
Query string
KB string // raw --kb (UUID or name)
KBID string // resolved id; populated before HybridSearch
Limit int
VectorThreshold float64
KeywordThreshold float64
NoVector bool
NoKeyword bool
JSONOut bool
}
// ChunksService is the narrow SDK surface used by runChunks. *sdk.Client
// satisfies it; tests inject fakes via Factory.Client.
type ChunksService interface {
HybridSearch(ctx context.Context, kbID string, params *sdk.SearchParams) ([]*sdk.SearchResult, error)
}
// NewCmdChunks builds `weknora search chunks "<query>" --kb <id-or-name>`.
// Mirrors gh `search code`'s "subject as positional" shape (the previous
// top-level `weknora search` is its legacy alias — see search.go).
//
// The `--kb` flag accepts either a KB UUID (passed through) or a name
// (resolved via ListKnowledgeBases). Mirrors gcloud `--project`'s
// id-or-name auto-detection.
func NewCmdChunks(f *cmdutil.Factory) *cobra.Command {
opts := &ChunksOptions{}
cmd := &cobra.Command{
Use: `chunks "<query>"`,
Short: "Hybrid (vector + keyword) chunk retrieval against a knowledge base",
Args: cobra.ExactArgs(1),
RunE: func(c *cobra.Command, args []string) error {
opts.Query = strings.TrimSpace(args[0])
if err := opts.validate(); err != nil {
return err
}
cli, err := f.Client()
if err != nil {
return err
}
kbID, err := cmdutil.ResolveKBFlag(c.Context(), cli, opts.KB)
if err != nil {
return err
}
opts.KBID = kbID
return runChunks(c.Context(), opts, cli)
},
}
bindChunksFlags(cmd, opts)
_ = cmd.MarkFlagRequired("kb")
agent.SetAgentHelp(cmd, "Hybrid retrieval; returns ranked chunk list. The server may include parent/nearby/relation chunks beyond match_count; --limit caps the returned slice client-side. Pass --no-vector or --no-keyword to disable a channel (mutually exclusive both-off).")
return cmd
}
// bindChunksFlags registers the chunks flag surface in one place to keep
// the constructor readable; --kb is marked required by the caller.
func bindChunksFlags(cmd *cobra.Command, opts *ChunksOptions) {
cmd.Flags().StringVar(&opts.KB, "kb", "", "Knowledge base UUID or name")
cmd.Flags().IntVarP(&opts.Limit, "limit", "L", 8, "Maximum results to return")
cmd.Flags().Float64Var(&opts.VectorThreshold, "vector-threshold", 0, "Vector retrieval similarity floor (per-channel, pre-fusion); 0 = no filter")
cmd.Flags().Float64Var(&opts.KeywordThreshold, "keyword-threshold", 0, "Keyword retrieval score floor (per-channel, pre-fusion); 0 = no filter")
cmd.Flags().BoolVar(&opts.NoVector, "no-vector", false, "Disable the vector channel")
cmd.Flags().BoolVar(&opts.NoKeyword, "no-keyword", false, "Disable the keyword channel")
cmd.Flags().BoolVar(&opts.JSONOut, "json", false, "Output JSON envelope")
}
// validate checks the option set before any SDK call.
func (o *ChunksOptions) validate() error {
if o.Query == "" {
return cmdutil.NewError(cmdutil.CodeInputInvalidArgument, "query argument cannot be empty")
}
if o.NoVector && o.NoKeyword {
return cmdutil.NewError(cmdutil.CodeInputInvalidArgument, "--no-vector and --no-keyword cannot both be set")
}
return nil
}
func runChunks(ctx context.Context, opts *ChunksOptions, svc ChunksService) error {
if err := opts.validate(); err != nil {
return err
}
if svc == nil {
return cmdutil.NewError(cmdutil.CodeServerError, "search chunks: no SDK client available")
}
params := &sdk.SearchParams{
QueryText: opts.Query,
MatchCount: opts.Limit,
VectorThreshold: opts.VectorThreshold,
KeywordThreshold: opts.KeywordThreshold,
DisableVectorMatch: opts.NoVector,
DisableKeywordsMatch: opts.NoKeyword,
}
results, err := svc.HybridSearch(ctx, opts.KBID, params)
if err != nil {
return cmdutil.Wrapf(cmdutil.ClassifyHTTPError(err), err, "hybrid search")
}
// match_count is the server's *primary-match* cap — after that, the
// service appends parent / nearby / relation chunks as context
// enrichment, so the wire response can exceed Limit. CLIs like gh /
// kubectl / aws treat their `--limit`-style flag as a hard return-count
// cap; honor that contract by trimming on the client. Recall isn't
// affected because the server's internal retrieval pool is already
// max(MatchCount*5, 50).
if opts.Limit > 0 && len(results) > opts.Limit {
results = results[:opts.Limit]
}
if opts.JSONOut {
return format.WriteEnvelope(iostreams.IO.Out, format.Success(results, &format.Meta{KBID: opts.KBID}))
}
return renderChunkResults(results, opts.KBID)
}
// renderChunkResults prints a compact pretty list. Minimal stopgap — a
// richer tabular renderer can replace this later without breaking the
// JSON contract.
func renderChunkResults(results []*sdk.SearchResult, kbID string) error {
if len(results) == 0 {
fmt.Fprintln(iostreams.IO.Out, "(no results)")
return nil
}
fmt.Fprintf(iostreams.IO.Out, "%d result(s) from kb=%s:\n\n", len(results), kbID)
for i, r := range results {
fmt.Fprintf(iostreams.IO.Out, "[%d] score=%.3f", i+1, r.Score)
if r.KnowledgeID != "" {
fmt.Fprintf(iostreams.IO.Out, " doc=%s", r.KnowledgeID)
}
fmt.Fprintln(iostreams.IO.Out)
fmt.Fprintln(iostreams.IO.Out, indent(strings.TrimSpace(r.Content), " "))
fmt.Fprintln(iostreams.IO.Out)
}
return nil
}
func indent(s, prefix string) string {
if s == "" {
return ""
}
lines := strings.Split(s, "\n")
for i, l := range lines {
lines[i] = prefix + l
}
return strings.Join(lines, "\n")
}
+191
View File
@@ -0,0 +1,191 @@
package search
import (
"context"
"errors"
"strings"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/Tencent/WeKnora/cli/internal/cmdutil"
"github.com/Tencent/WeKnora/cli/internal/iostreams"
sdk "github.com/Tencent/WeKnora/client"
)
type fakeChunksSvc struct {
results []*sdk.SearchResult
err error
gotKB string
gotQ string
}
func (f *fakeChunksSvc) HybridSearch(_ context.Context, kbID string, p *sdk.SearchParams) ([]*sdk.SearchResult, error) {
f.gotKB = kbID
f.gotQ = p.QueryText
return f.results, f.err
}
func TestRunSearch_HumanOutput(t *testing.T) {
out, _ := iostreams.SetForTest(t)
svc := &fakeChunksSvc{results: []*sdk.SearchResult{
{Score: 0.92, Content: "first chunk", KnowledgeID: "doc-1", MatchType: sdk.MatchTypeVector},
{Score: 0.81, Content: "second chunk", KnowledgeID: "doc-2", MatchType: sdk.MatchTypeKeyword},
}}
opts := &ChunksOptions{Query: "hello", KBID: "kb_abc", Limit: 5}
require.NoError(t, runChunks(context.Background(), opts, svc))
assert.Equal(t, "kb_abc", svc.gotKB)
assert.Equal(t, "hello", svc.gotQ)
got := out.String()
assert.Contains(t, got, "2 result(s) from kb=kb_abc")
assert.Contains(t, got, "first chunk")
assert.Contains(t, got, "doc-1")
}
// JSON envelope must surface match_type so machine consumers / agents can
// reason about retrieval channels without re-implementing the wire format.
// (Human renderer keeps default minimal — diagnostic info opt-in via --json,
// matching gh / kubectl / Algolia / Vespa terse-default conventions.)
func TestRunSearch_JSONIncludesMatchType(t *testing.T) {
out, _ := iostreams.SetForTest(t)
svc := &fakeChunksSvc{results: []*sdk.SearchResult{
{Score: 0.9, Content: "x", MatchType: sdk.MatchTypeKeyword},
}}
require.NoError(t, runChunks(context.Background(), &ChunksOptions{Query: "q", KBID: "kb1", JSONOut: true}, svc))
assert.Contains(t, out.String(), `"match_type":1`)
}
func TestRunSearch_JSONOutput(t *testing.T) {
out, _ := iostreams.SetForTest(t)
svc := &fakeChunksSvc{results: []*sdk.SearchResult{{Score: 0.9, Content: "x"}}}
opts := &ChunksOptions{Query: "q", KBID: "kb1", Limit: 1, JSONOut: true}
require.NoError(t, runChunks(context.Background(), opts, svc))
assert.True(t, strings.HasPrefix(out.String(), `{"ok":true`), "got: %q", out.String())
assert.Contains(t, out.String(), `"kb_id":"kb1"`)
}
func TestRunSearch_EmptyResults(t *testing.T) {
out, _ := iostreams.SetForTest(t)
svc := &fakeChunksSvc{results: nil}
require.NoError(t, runChunks(context.Background(), &ChunksOptions{Query: "q", KBID: "kb1"}, svc))
assert.Contains(t, out.String(), "(no results)")
}
// Server returns primary matches plus parent/related/nearby enrichment chunks,
// so the wire response can exceed Limit. CLI must trim to honor the user's
// hard-limit contract (gh / kubectl / aws idiom).
func TestRunSearch_LimitHardCap(t *testing.T) {
out, _ := iostreams.SetForTest(t)
svc := &fakeChunksSvc{results: []*sdk.SearchResult{
{Score: 0.9, Content: "primary 1"},
{Score: 0.8, Content: "primary 2"},
{Score: 0.7, Content: "primary 3"},
{Score: 0, Content: "enrichment parent"}, // server-padded
{Score: 0, Content: "enrichment nearby"}, // server-padded
}}
require.NoError(t, runChunks(context.Background(), &ChunksOptions{Query: "q", KBID: "kb1", Limit: 3}, svc))
got := out.String()
assert.Contains(t, got, "3 result(s)")
assert.NotContains(t, got, "enrichment parent")
assert.NotContains(t, got, "enrichment nearby")
}
func TestRunSearch_BothChannelsDisabled(t *testing.T) {
iostreams.SetForTest(t)
err := runChunks(context.Background(), &ChunksOptions{Query: "q", KBID: "kb1", NoVector: true, NoKeyword: true}, &fakeChunksSvc{})
require.Error(t, err)
assert.Contains(t, err.Error(), "input.invalid_argument")
}
func TestRunSearch_ServiceError_Transport(t *testing.T) {
iostreams.SetForTest(t)
svc := &fakeChunksSvc{err: assert.AnError}
err := runChunks(context.Background(), &ChunksOptions{Query: "q", KBID: "kb1"}, svc)
require.Error(t, err)
var typed *cmdutil.Error
require.ErrorAs(t, err, &typed)
assert.Equal(t, cmdutil.CodeNetworkError, typed.Code,
"non-HTTP-shaped errors classify as network.error so IsTransient picks them up")
}
func TestRunSearch_ServiceError_HTTPNotFound(t *testing.T) {
iostreams.SetForTest(t)
svc := &fakeChunksSvc{err: errors.New("HTTP error 404: knowledge base not found")}
err := runChunks(context.Background(), &ChunksOptions{Query: "q", KBID: "missing"}, svc)
require.Error(t, err)
var typed *cmdutil.Error
require.ErrorAs(t, err, &typed)
assert.Equal(t, cmdutil.CodeResourceNotFound, typed.Code)
}
func TestIndent(t *testing.T) {
assert.Equal(t, " foo\n bar", indent("foo\nbar", " "))
assert.Equal(t, "", indent("", " "))
}
func TestRunSearch_NilService(t *testing.T) {
iostreams.SetForTest(t)
err := runChunks(context.Background(), &ChunksOptions{Query: "q", KBID: "kb1"}, nil)
require.Error(t, err)
assert.Contains(t, err.Error(), "server.error")
}
func TestNewCmdChunks_RequiresQuery(t *testing.T) {
iostreams.SetForTest(t)
cmd := NewCmdChunks(&cmdutil.Factory{
Client: func() (*sdk.Client, error) { return nil, nil },
})
cmd.SetArgs([]string{}) // no query
cmd.SilenceErrors = true
cmd.SilenceUsage = true
err := cmd.Execute()
require.Error(t, err)
}
func TestNewCmdChunks_RejectsEmptyQuery(t *testing.T) {
iostreams.SetForTest(t)
cmd := NewCmdChunks(&cmdutil.Factory{
Client: func() (*sdk.Client, error) { return nil, nil },
})
cmd.SetArgs([]string{" ", "--kb", "kb1"})
cmd.SilenceErrors = true
cmd.SilenceUsage = true
err := cmd.Execute()
require.Error(t, err)
assert.Contains(t, err.Error(), "input.invalid_argument")
}
func TestRunSearch_NoVectorPassedThrough(t *testing.T) {
iostreams.SetForTest(t)
var got *sdk.SearchParams
svc := &capturingChunksSvc{capture: func(p *sdk.SearchParams) { got = p }}
require.NoError(t, runChunks(context.Background(), &ChunksOptions{
Query: "q", KBID: "kb1", NoVector: true,
}, svc))
require.NotNil(t, got)
assert.True(t, got.DisableVectorMatch)
assert.False(t, got.DisableKeywordsMatch)
}
func TestRunSearch_NoKeywordPassedThrough(t *testing.T) {
iostreams.SetForTest(t)
var got *sdk.SearchParams
svc := &capturingChunksSvc{capture: func(p *sdk.SearchParams) { got = p }}
require.NoError(t, runChunks(context.Background(), &ChunksOptions{
Query: "q", KBID: "kb1", NoKeyword: true,
}, svc))
require.NotNil(t, got)
assert.True(t, got.DisableKeywordsMatch)
assert.False(t, got.DisableVectorMatch)
}
type capturingChunksSvc struct {
capture func(*sdk.SearchParams)
}
func (c *capturingChunksSvc) HybridSearch(_ context.Context, _ string, p *sdk.SearchParams) ([]*sdk.SearchResult, error) {
c.capture(p)
return nil, nil
}
+142
View File
@@ -0,0 +1,142 @@
package search
import (
"context"
"fmt"
"sort"
"strings"
"text/tabwriter"
"github.com/spf13/cobra"
"github.com/Tencent/WeKnora/cli/internal/agent"
"github.com/Tencent/WeKnora/cli/internal/cmdutil"
"github.com/Tencent/WeKnora/cli/internal/format"
"github.com/Tencent/WeKnora/cli/internal/iostreams"
"github.com/Tencent/WeKnora/cli/internal/text"
sdk "github.com/Tencent/WeKnora/client"
)
// docsPageSize is how many entries we pull per ListKnowledge round-trip
// when paging through a KB to filter client-side. Server caps page_size at
// 1000 (per the doc/list bound this branch already added).
const docsPageSize = 200
// DocsSearchOptions captures `weknora search docs` flag state.
type DocsSearchOptions struct {
Query string
KB string // raw --kb (UUID or name)
KBID string // resolved id; populated before listing
Limit int
JSONOut bool
}
// DocsSearchService is the narrow SDK surface this command depends on.
// Server has no fuzzy-document-name endpoint, so the CLI pages through
// ListKnowledge and filters by Title / FileName client-side.
type DocsSearchService interface {
ListKnowledge(ctx context.Context, kbID string, page, pageSize int, tagID string) ([]sdk.Knowledge, int64, error)
}
// NewCmdDocs builds `weknora search docs "<query>" --kb <id-or-name>`.
// Pages through the KB's documents and surfaces every entry whose title
// or filename contains the query (case-insensitive). Useful for finding
// a specific upload to download or delete.
func NewCmdDocs(f *cmdutil.Factory) *cobra.Command {
opts := &DocsSearchOptions{}
cmd := &cobra.Command{
Use: `docs "<query>"`,
Short: "Find documents in a knowledge base by name (client-side substring match)",
Args: cobra.ExactArgs(1),
RunE: func(c *cobra.Command, args []string) error {
opts.Query = strings.TrimSpace(args[0])
if opts.Query == "" {
return cmdutil.NewError(cmdutil.CodeInputInvalidArgument, "query argument cannot be empty")
}
cli, err := f.Client()
if err != nil {
return err
}
kbID, err := cmdutil.ResolveKBFlag(c.Context(), cli, opts.KB)
if err != nil {
return err
}
opts.KBID = kbID
return runDocsSearch(c.Context(), opts, cli)
},
}
cmd.Flags().StringVar(&opts.KB, "kb", "", "Knowledge base UUID or name (required)")
cmd.Flags().IntVarP(&opts.Limit, "limit", "L", 20, "Maximum results to return")
cmd.Flags().BoolVar(&opts.JSONOut, "json", false, "Output JSON envelope")
_ = cmd.MarkFlagRequired("kb")
agent.SetAgentHelp(cmd, "Lists documents in --kb whose title or file_name contains the query. Pages through the KB sequentially; stops once limit hits found. Returns the full Knowledge object so agents can derive id / file_size / processed_at without a second call.")
return cmd
}
func runDocsSearch(ctx context.Context, opts *DocsSearchOptions, svc DocsSearchService) error {
needle := strings.ToLower(opts.Query)
var matches []sdk.Knowledge
// Page through the KB until limit matches found or pagination exhausted.
// The server returns total; stop when (page-1)*pageSize >= total.
for page := 1; ; page++ {
items, total, err := svc.ListKnowledge(ctx, opts.KBID, page, docsPageSize, "")
if err != nil {
return cmdutil.Wrapf(cmdutil.ClassifyHTTPError(err), err, "list documents")
}
for _, k := range items {
if matchKnowledge(k, needle) {
matches = append(matches, k)
if opts.Limit > 0 && len(matches) >= opts.Limit {
goto done
}
}
}
if int64(page*docsPageSize) >= total || len(items) == 0 {
break
}
}
done:
sortKnowledgeByRecency(matches)
if opts.JSONOut {
return format.WriteEnvelope(iostreams.IO.Out, format.Success(matches, &format.Meta{KBID: opts.KBID}))
}
if len(matches) == 0 {
fmt.Fprintln(iostreams.IO.Out, "(no matches)")
return nil
}
tw := tabwriter.NewWriter(iostreams.IO.Out, 0, 0, 2, ' ', 0)
fmt.Fprintln(tw, "ID\tFILE\tTYPE\tUPDATED")
for _, k := range matches {
name := text.Truncate(50, displayDocName(k))
fmt.Fprintf(tw, "%s\t%s\t%s\t%s\n", k.ID, name, k.FileType, k.UpdatedAt.Format("2006-01-02"))
}
return tw.Flush()
}
// matchKnowledge reports whether title or filename contains needle (already
// lowercased by caller).
func matchKnowledge(k sdk.Knowledge, needle string) bool {
return text.ContainsFold(needle, k.Title, k.FileName)
}
// displayDocName picks a human-friendly name. Order matches `weknora doc
// list` (FileName for uploads → Title for URL/text entries → ID fallback)
// so a Knowledge renders the same in both commands.
func displayDocName(k sdk.Knowledge) string {
if k.FileName != "" {
return k.FileName
}
if k.Title != "" {
return k.Title
}
return k.ID
}
// sortKnowledgeByRecency sorts in place by UpdatedAt desc.
func sortKnowledgeByRecency(items []sdk.Knowledge) {
sort.Slice(items, func(i, j int) bool {
return items[i].UpdatedAt.After(items[j].UpdatedAt)
})
}
+121
View File
@@ -0,0 +1,121 @@
package search
import (
"context"
"encoding/json"
"errors"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/Tencent/WeKnora/cli/internal/cmdutil"
"github.com/Tencent/WeKnora/cli/internal/format"
"github.com/Tencent/WeKnora/cli/internal/iostreams"
sdk "github.com/Tencent/WeKnora/client"
)
// fakeDocsSearchSvc scripts paginated ListKnowledge responses. Pages are
// indexed 1-based; items keyed by page.
type fakeDocsSearchSvc struct {
pages map[int][]sdk.Knowledge
total int64
err error
calls []int // page numbers requested, for assertions
}
func (f *fakeDocsSearchSvc) ListKnowledge(_ context.Context, kbID string, page, pageSize int, tagID string) ([]sdk.Knowledge, int64, error) {
f.calls = append(f.calls, page)
if f.err != nil {
return nil, 0, f.err
}
return f.pages[page], f.total, nil
}
func TestDocsSearch_Substring(t *testing.T) {
out, _ := iostreams.SetForTest(t)
svc := &fakeDocsSearchSvc{
pages: map[int][]sdk.Knowledge{
1: {
{ID: "d1", Title: "Q3 Forecast", FileName: "q3.pdf", UpdatedAt: mustTime(t, "2026-05-10T00:00:00Z")},
{ID: "d2", Title: "Random Notes", FileName: "notes.md", UpdatedAt: mustTime(t, "2026-05-12T00:00:00Z")},
{ID: "d3", Title: "Q3 retro", FileName: "retro.pdf", UpdatedAt: mustTime(t, "2026-05-11T00:00:00Z")},
},
},
total: 3,
}
require.NoError(t, runDocsSearch(context.Background(), &DocsSearchOptions{Query: "q3", KBID: "kb1", Limit: 20}, svc))
got := out.String()
assert.Contains(t, got, "d1")
assert.Contains(t, got, "d3")
assert.NotContains(t, got, "d2") // "Random Notes" doesn't contain q3
}
func TestDocsSearch_MatchesFileName(t *testing.T) {
out, _ := iostreams.SetForTest(t)
svc := &fakeDocsSearchSvc{
pages: map[int][]sdk.Knowledge{1: {{ID: "d1", Title: "Untitled", FileName: "report.pdf"}}},
total: 1,
}
require.NoError(t, runDocsSearch(context.Background(), &DocsSearchOptions{Query: "report", KBID: "kb1", Limit: 20}, svc))
assert.Contains(t, out.String(), "d1")
}
func TestDocsSearch_PaginatesUntilTotal(t *testing.T) {
out, _ := iostreams.SetForTest(t)
page1 := make([]sdk.Knowledge, docsPageSize)
for i := range page1 {
page1[i] = sdk.Knowledge{ID: "p1", Title: "no match"}
}
page2 := []sdk.Knowledge{{ID: "found", Title: "needle here"}}
svc := &fakeDocsSearchSvc{
pages: map[int][]sdk.Knowledge{1: page1, 2: page2},
total: int64(docsPageSize) + 1,
}
require.NoError(t, runDocsSearch(context.Background(), &DocsSearchOptions{Query: "needle", KBID: "kb1", Limit: 20}, svc))
assert.Contains(t, out.String(), "found")
assert.Equal(t, []int{1, 2}, svc.calls, "must page past the first batch when no match on page 1")
}
func TestDocsSearch_StopsAtTopK(t *testing.T) {
_, _ = iostreams.SetForTest(t)
page1 := make([]sdk.Knowledge, 50)
for i := range page1 {
page1[i] = sdk.Knowledge{ID: "match", Title: "needle"}
}
svc := &fakeDocsSearchSvc{pages: map[int][]sdk.Knowledge{1: page1}, total: 1000}
require.NoError(t, runDocsSearch(context.Background(), &DocsSearchOptions{Query: "needle", KBID: "kb1", Limit: 3}, svc))
// Must not request page 2 because top-k was hit mid-page.
assert.Equal(t, []int{1}, svc.calls)
}
func TestDocsSearch_JSON(t *testing.T) {
out, _ := iostreams.SetForTest(t)
svc := &fakeDocsSearchSvc{
pages: map[int][]sdk.Knowledge{1: {{ID: "d1", Title: "match"}}},
total: 1,
}
require.NoError(t, runDocsSearch(context.Background(), &DocsSearchOptions{Query: "match", KBID: "kb1", Limit: 20, JSONOut: true}, svc))
var env format.Envelope
require.NoError(t, json.Unmarshal(out.Bytes(), &env))
require.True(t, env.OK)
assert.Contains(t, out.String(), `"id":"d1"`)
}
func TestDocsSearch_NetworkError(t *testing.T) {
_, _ = iostreams.SetForTest(t)
svc := &fakeDocsSearchSvc{err: errors.New("HTTP error 404: kb not found")}
err := runDocsSearch(context.Background(), &DocsSearchOptions{Query: "x", KBID: "missing", Limit: 20}, svc)
require.Error(t, err)
var typed *cmdutil.Error
require.ErrorAs(t, err, &typed)
assert.Equal(t, cmdutil.CodeResourceNotFound, typed.Code)
}
func mustTime(t *testing.T, s string) time.Time {
t.Helper()
v, err := time.Parse(time.RFC3339, s)
require.NoError(t, err)
return v
}
+106
View File
@@ -0,0 +1,106 @@
package search
import (
"context"
"fmt"
"sort"
"strings"
"text/tabwriter"
"github.com/spf13/cobra"
"github.com/Tencent/WeKnora/cli/internal/agent"
"github.com/Tencent/WeKnora/cli/internal/cmdutil"
"github.com/Tencent/WeKnora/cli/internal/format"
"github.com/Tencent/WeKnora/cli/internal/iostreams"
"github.com/Tencent/WeKnora/cli/internal/text"
sdk "github.com/Tencent/WeKnora/client"
)
// KBSearchOptions captures `weknora search kb` flag state.
type KBSearchOptions struct {
Query string
Limit int
JSONOut bool
}
// KBSearchService is the narrow SDK surface this command depends on.
// Server has no fuzzy-KB-name endpoint; the CLI filters ListKnowledgeBases
// client-side. Acceptable because tenants typically have ≪ 1000 KBs.
type KBSearchService interface {
ListKnowledgeBases(ctx context.Context) ([]sdk.KnowledgeBase, error)
}
// NewCmdKB builds `weknora search kb "<query>"` — substring + case-insensitive
// match across KB names and descriptions visible to the active context.
// Mirrors gh `search repos`. Results are sorted by name length (shortest
// first; usually the closest hit) for deterministic output.
func NewCmdKB(f *cmdutil.Factory) *cobra.Command {
opts := &KBSearchOptions{}
cmd := &cobra.Command{
Use: `kb "<query>"`,
Short: "Find knowledge bases by name or description (client-side substring match)",
Args: cobra.ExactArgs(1),
RunE: func(c *cobra.Command, args []string) error {
opts.Query = strings.TrimSpace(args[0])
if opts.Query == "" {
return cmdutil.NewError(cmdutil.CodeInputInvalidArgument, "query argument cannot be empty")
}
cli, err := f.Client()
if err != nil {
return err
}
return runKBSearch(c.Context(), opts, cli)
},
}
cmd.Flags().IntVarP(&opts.Limit, "limit", "L", 20, "Maximum results to return")
cmd.Flags().BoolVar(&opts.JSONOut, "json", false, "Output JSON envelope")
agent.SetAgentHelp(cmd, "Lists KBs whose name or description contains the query (case-insensitive). Useful to discover --kb identifiers before running search chunks / doc list.")
return cmd
}
func runKBSearch(ctx context.Context, opts *KBSearchOptions, svc KBSearchService) error {
items, err := svc.ListKnowledgeBases(ctx)
if err != nil {
return cmdutil.Wrapf(cmdutil.ClassifyHTTPError(err), err, "list knowledge bases")
}
matches := filterKBs(items, opts.Query)
if opts.Limit > 0 && len(matches) > opts.Limit {
matches = matches[:opts.Limit]
}
if opts.JSONOut {
return format.WriteEnvelope(iostreams.IO.Out, format.Success(matches, nil))
}
if len(matches) == 0 {
fmt.Fprintln(iostreams.IO.Out, "(no matches)")
return nil
}
tw := tabwriter.NewWriter(iostreams.IO.Out, 0, 0, 2, ' ', 0)
fmt.Fprintln(tw, "ID\tNAME\tDOCS")
for _, kb := range matches {
name := text.Truncate(50, kb.Name)
fmt.Fprintf(tw, "%s\t%s\t%s\n", kb.ID, name, text.Pluralize(int(kb.KnowledgeCount), "doc"))
}
return tw.Flush()
}
// filterKBs returns the KBs whose name or description contains q (case-
// insensitive), sorted by name length so the most-likely match shows
// first. Ties broken alphabetically for determinism.
func filterKBs(items []sdk.KnowledgeBase, q string) []sdk.KnowledgeBase {
needle := strings.ToLower(q)
out := make([]sdk.KnowledgeBase, 0, len(items))
for _, kb := range items {
if text.ContainsFold(needle, kb.Name, kb.Description) {
out = append(out, kb)
}
}
sort.Slice(out, func(i, j int) bool {
if len(out[i].Name) != len(out[j].Name) {
return len(out[i].Name) < len(out[j].Name)
}
return out[i].Name < out[j].Name
})
return out
}
+121
View File
@@ -0,0 +1,121 @@
package search
import (
"context"
"encoding/json"
"errors"
"strings"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/Tencent/WeKnora/cli/internal/cmdutil"
"github.com/Tencent/WeKnora/cli/internal/format"
"github.com/Tencent/WeKnora/cli/internal/iostreams"
sdk "github.com/Tencent/WeKnora/client"
)
type fakeKBSearchSvc struct {
items []sdk.KnowledgeBase
err error
}
func (f *fakeKBSearchSvc) ListKnowledgeBases(_ context.Context) ([]sdk.KnowledgeBase, error) {
return f.items, f.err
}
func TestKBSearch_Substring(t *testing.T) {
out, _ := iostreams.SetForTest(t)
svc := &fakeKBSearchSvc{items: []sdk.KnowledgeBase{
{ID: "kb1", Name: "Marketing Q3", KnowledgeCount: 10},
{ID: "kb2", Name: "Engineering Docs", KnowledgeCount: 50},
{ID: "kb3", Name: "Marketing Q4 Plan", KnowledgeCount: 5},
}}
require.NoError(t, runKBSearch(context.Background(), &KBSearchOptions{Query: "marketing", Limit: 20}, svc))
got := out.String()
assert.Contains(t, got, "kb1")
assert.Contains(t, got, "kb3")
assert.NotContains(t, got, "Engineering")
}
func TestKBSearch_CaseInsensitive(t *testing.T) {
out, _ := iostreams.SetForTest(t)
svc := &fakeKBSearchSvc{items: []sdk.KnowledgeBase{
{ID: "kb1", Name: "ENGINEERING"},
}}
require.NoError(t, runKBSearch(context.Background(), &KBSearchOptions{Query: "engineering", Limit: 20}, svc))
assert.Contains(t, out.String(), "kb1")
}
func TestKBSearch_MatchesDescription(t *testing.T) {
out, _ := iostreams.SetForTest(t)
svc := &fakeKBSearchSvc{items: []sdk.KnowledgeBase{
{ID: "kb1", Name: "Engineering", Description: "all marketing docs are here"},
}}
require.NoError(t, runKBSearch(context.Background(), &KBSearchOptions{Query: "marketing", Limit: 20}, svc))
assert.Contains(t, out.String(), "kb1")
}
func TestKBSearch_SortByNameLength(t *testing.T) {
out, _ := iostreams.SetForTest(t)
svc := &fakeKBSearchSvc{items: []sdk.KnowledgeBase{
{ID: "kb_long", Name: "very long name marketing"},
{ID: "kb_short", Name: "marketing"},
{ID: "kb_mid", Name: "marketing 2024"},
}}
require.NoError(t, runKBSearch(context.Background(), &KBSearchOptions{Query: "marketing", Limit: 20}, svc))
got := out.String()
// Order: shortest name first.
iShort := strings.Index(got,"kb_short")
iMid := strings.Index(got,"kb_mid")
iLong := strings.Index(got,"kb_long")
assert.Less(t, iShort, iMid)
assert.Less(t, iMid, iLong)
}
func TestKBSearch_TopKHardCap(t *testing.T) {
out, _ := iostreams.SetForTest(t)
svc := &fakeKBSearchSvc{items: []sdk.KnowledgeBase{
{ID: "a", Name: "match-a"}, {ID: "b", Name: "match-b"},
{ID: "c", Name: "match-c"}, {ID: "d", Name: "match-d"},
}}
require.NoError(t, runKBSearch(context.Background(), &KBSearchOptions{Query: "match", Limit: 2}, svc))
got := out.String()
count := 0
for _, id := range []string{"a", "b", "c", "d"} {
if strings.Contains(got, "match-"+id) {
count++
}
}
assert.Equal(t, 2, count)
}
func TestKBSearch_NoMatches(t *testing.T) {
out, _ := iostreams.SetForTest(t)
svc := &fakeKBSearchSvc{items: []sdk.KnowledgeBase{{Name: "foo"}}}
require.NoError(t, runKBSearch(context.Background(), &KBSearchOptions{Query: "bar", Limit: 20}, svc))
assert.Contains(t, out.String(), "(no matches)")
}
func TestKBSearch_JSON(t *testing.T) {
out, _ := iostreams.SetForTest(t)
svc := &fakeKBSearchSvc{items: []sdk.KnowledgeBase{{ID: "kb1", Name: "marketing"}}}
require.NoError(t, runKBSearch(context.Background(), &KBSearchOptions{Query: "marketing", Limit: 20, JSONOut: true}, svc))
var env format.Envelope
require.NoError(t, json.Unmarshal(out.Bytes(), &env))
require.True(t, env.OK)
assert.Contains(t, out.String(), `"id":"kb1"`)
}
func TestKBSearch_NetworkError(t *testing.T) {
_, _ = iostreams.SetForTest(t)
svc := &fakeKBSearchSvc{err: errors.New("HTTP error 401: unauthenticated")}
err := runKBSearch(context.Background(), &KBSearchOptions{Query: "x", Limit: 20}, svc)
require.Error(t, err)
var typed *cmdutil.Error
require.ErrorAs(t, err, &typed)
assert.Equal(t, cmdutil.CodeAuthUnauthenticated, typed.Code)
}
+26 -154
View File
@@ -1,167 +1,39 @@
// Package search implements the top-level `weknora search` command — the
// chunk hybrid retrieval entry point (ADR-3: only one search command).
// Package search implements the `weknora search` command tree:
// chunks / kb / docs / sessions. Verb-noun shape borrowed from
// gh search (gh search repos / code / commits / issues / prs).
package search
import (
"context"
"fmt"
"strings"
"github.com/spf13/cobra"
"github.com/Tencent/WeKnora/cli/internal/agent"
"github.com/Tencent/WeKnora/cli/internal/cmdutil"
"github.com/Tencent/WeKnora/cli/internal/format"
"github.com/Tencent/WeKnora/cli/internal/iostreams"
sdk "github.com/Tencent/WeKnora/client"
)
// Options is the runtime configuration of one search invocation.
type Options struct {
Query string
KB string // raw --kb flag value (UUID or name)
KBID string // resolved id, populated by RunE before HybridSearch
TopK int
VectorThreshold float64
KeywordThreshold float64
NoVector bool
NoKeyword bool
JSONOut bool
}
// Service is the narrow SDK surface used by runSearch. *sdk.Client satisfies
// it; tests inject fakes via Factory.Client.
type Service interface {
HybridSearch(ctx context.Context, kbID string, params *sdk.SearchParams) ([]*sdk.SearchResult, error)
}
// NewCmdSearch builds `weknora search "<query>" --kb <id-or-name>`.
//
// The single `--kb` flag accepts either a KB UUID (passed through) or a
// name (resolved via ListKnowledgeBases). Mirrors gcloud `--project`'s
// id-or-name auto-detection — the only mainstream pattern that collapses
// the two forms onto one flag.
// NewCmdSearch builds the `weknora search` parent. Pure dispatcher to the
// four subcommands; no positional/legacy form.
func NewCmdSearch(f *cmdutil.Factory) *cobra.Command {
opts := &Options{}
cmd := &cobra.Command{
Use: `search "<query>"`,
Short: "Hybrid (vector + keyword) chunk retrieval against a knowledge base",
Args: cobra.ExactArgs(1),
RunE: func(c *cobra.Command, args []string) error {
opts.Query = strings.TrimSpace(args[0])
// Validate input shape before touching the SDK so flag/arg misuse
// surfaces fast (no auth / no client construction). KB resolution
// happens *after* — name → id needs a live client.
if err := opts.validate(); err != nil {
return err
}
cli, err := f.Client()
if err != nil {
return err
}
if cmdutil.IsKBID(opts.KB) {
opts.KBID = opts.KB
} else {
resolved, rerr := cmdutil.ResolveKBNameToID(c.Context(), cli, opts.KB)
if rerr != nil {
return rerr
}
opts.KBID = resolved
}
return runSearch(c.Context(), opts, cli)
},
Use: "search",
Short: "Search across chunks, knowledge bases, documents, or sessions",
Long: `Verb-noun search tree (gh search idiom):
search chunks "<q>" --kb X hybrid retrieval (RAG search)
search kb "<q>" find KBs by name / description
search docs "<q>" --kb X find documents inside a KB
search sessions "<q>" find chat sessions by title / description`,
Example: ` weknora search chunks "what is RAG?" --kb engineering
weknora search kb "marketing"
weknora search docs "Q3 forecast" --kb finance
weknora search sessions "onboarding"`,
Args: cobra.NoArgs,
Run: func(c *cobra.Command, _ []string) { _ = c.Help() },
}
cmd.Flags().StringVar(&opts.KB, "kb", "", "Knowledge base UUID or name")
cmd.Flags().IntVar(&opts.TopK, "top-k", 8, "Maximum results to return")
cmd.Flags().Float64Var(&opts.VectorThreshold, "vector-threshold", 0, "Vector retrieval similarity floor (per-channel, pre-fusion); 0 = no filter")
cmd.Flags().Float64Var(&opts.KeywordThreshold, "keyword-threshold", 0, "Keyword retrieval score floor (per-channel, pre-fusion); 0 = no filter")
cmd.Flags().BoolVar(&opts.NoVector, "no-vector", false, "Disable the vector channel")
cmd.Flags().BoolVar(&opts.NoKeyword, "no-keyword", false, "Disable the keyword channel")
cmd.Flags().BoolVar(&opts.JSONOut, "json", false, "Output JSON envelope")
_ = cmd.MarkFlagRequired("kb")
agent.SetAgentHelp(cmd, "Search parent. Use `search chunks/kb/docs/sessions <q>` — there is no bare-positional form.")
cmd.AddCommand(NewCmdChunks(f))
cmd.AddCommand(NewCmdKB(f))
cmd.AddCommand(NewCmdDocs(f))
cmd.AddCommand(NewCmdSessions(f))
return cmd
}
// validate checks the option set before any SDK call. RunE invokes it before
// f.Client() so flag misuse fails fast; runSearch re-invokes it as a safety
// net for direct test calls that bypass RunE.
func (o *Options) validate() error {
if o.Query == "" {
return cmdutil.NewError(cmdutil.CodeInputInvalidArgument, "query argument cannot be empty")
}
if o.NoVector && o.NoKeyword {
return cmdutil.NewError(cmdutil.CodeInputInvalidArgument, "--no-vector and --no-keyword cannot both be set")
}
return nil
}
func runSearch(ctx context.Context, opts *Options, svc Service) error {
if err := opts.validate(); err != nil {
return err
}
if svc == nil {
return cmdutil.NewError(cmdutil.CodeServerError, "search: no SDK client available")
}
params := &sdk.SearchParams{
QueryText: opts.Query,
MatchCount: opts.TopK,
VectorThreshold: opts.VectorThreshold,
KeywordThreshold: opts.KeywordThreshold,
DisableVectorMatch: opts.NoVector,
DisableKeywordsMatch: opts.NoKeyword,
}
results, err := svc.HybridSearch(ctx, opts.KBID, params)
if err != nil {
return cmdutil.Wrapf(cmdutil.ClassifyHTTPError(err), err, "hybrid search")
}
// match_count is the server's *primary-match* cap — after that, the
// service appends parent / nearby / relation chunks as context
// enrichment, so the wire response can exceed TopK. CLIs like gh /
// kubectl / aws treat their `--limit`-style flag as a hard return-count
// cap; honor that contract by trimming on the client. Recall isn't
// affected because the server's internal retrieval pool is already
// max(MatchCount*5, 50).
if opts.TopK > 0 && len(results) > opts.TopK {
results = results[:opts.TopK]
}
if opts.JSONOut {
return cmdutil.NewJSONExporter().Write(iostreams.IO.Out, format.Success(results, &format.Meta{
KBID: opts.KBID,
}))
}
return renderHumanResults(results, opts.KBID)
}
// renderHumanResults prints a compact pretty list to stdout. The inline
// indent helper is a minimal stopgap so search output is usable in a plain
// terminal; a richer tabular renderer can replace this later.
func renderHumanResults(results []*sdk.SearchResult, kbID string) error {
if len(results) == 0 {
fmt.Fprintln(iostreams.IO.Out, "(no results)")
return nil
}
fmt.Fprintf(iostreams.IO.Out, "%d result(s) from kb=%s:\n\n", len(results), kbID)
for i, r := range results {
fmt.Fprintf(iostreams.IO.Out, "[%d] score=%.3f", i+1, r.Score)
if r.KnowledgeID != "" {
fmt.Fprintf(iostreams.IO.Out, " doc=%s", r.KnowledgeID)
}
fmt.Fprintln(iostreams.IO.Out)
fmt.Fprintln(iostreams.IO.Out, indent(strings.TrimSpace(r.Content), " "))
fmt.Fprintln(iostreams.IO.Out)
}
return nil
}
// indent prefixes each line of s with the given prefix.
func indent(s, prefix string) string {
if s == "" {
return ""
}
lines := strings.Split(s, "\n")
for i, l := range lines {
lines[i] = prefix + l
}
return strings.Join(lines, "\n")
}
+31 -171
View File
@@ -1,191 +1,51 @@
package search
import (
"context"
"errors"
"strings"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/Tencent/WeKnora/cli/internal/cmdutil"
"github.com/Tencent/WeKnora/cli/internal/iostreams"
sdk "github.com/Tencent/WeKnora/client"
)
type fakeSearchService struct {
results []*sdk.SearchResult
err error
gotKB string
gotQ string
// TestSearch_NoArgs_ShowsHelp: bare `weknora search` (no subcommand)
// must run cobra's Help() without erroring.
func TestSearch_NoArgs_ShowsHelp(t *testing.T) {
_, _ = iostreams.SetForTest(t)
cmd := NewCmdSearch(&cmdutil.Factory{})
cmd.SetArgs([]string{})
cmd.SilenceErrors = true
cmd.SilenceUsage = true
require.NoError(t, cmd.Execute())
}
func (f *fakeSearchService) HybridSearch(_ context.Context, kbID string, p *sdk.SearchParams) ([]*sdk.SearchResult, error) {
f.gotKB = kbID
f.gotQ = p.QueryText
return f.results, f.err
}
func TestRunSearch_HumanOutput(t *testing.T) {
out, _ := iostreams.SetForTest(t)
svc := &fakeSearchService{results: []*sdk.SearchResult{
{Score: 0.92, Content: "first chunk", KnowledgeID: "doc-1", MatchType: sdk.MatchTypeVector},
{Score: 0.81, Content: "second chunk", KnowledgeID: "doc-2", MatchType: sdk.MatchTypeKeyword},
}}
opts := &Options{Query: "hello", KBID: "kb_abc", TopK: 5}
require.NoError(t, runSearch(context.Background(), opts, svc))
assert.Equal(t, "kb_abc", svc.gotKB)
assert.Equal(t, "hello", svc.gotQ)
got := out.String()
assert.Contains(t, got, "2 result(s) from kb=kb_abc")
assert.Contains(t, got, "first chunk")
assert.Contains(t, got, "doc-1")
}
// JSON envelope must surface match_type so machine consumers / agents can
// reason about retrieval channels without re-implementing the wire format.
// (Human renderer keeps default minimal — diagnostic info opt-in via --json,
// matching gh / kubectl / Algolia / Vespa terse-default conventions.)
func TestRunSearch_JSONIncludesMatchType(t *testing.T) {
out, _ := iostreams.SetForTest(t)
svc := &fakeSearchService{results: []*sdk.SearchResult{
{Score: 0.9, Content: "x", MatchType: sdk.MatchTypeKeyword},
}}
require.NoError(t, runSearch(context.Background(), &Options{Query: "q", KBID: "kb1", JSONOut: true}, svc))
assert.Contains(t, out.String(), `"match_type":1`)
}
func TestRunSearch_JSONOutput(t *testing.T) {
out, _ := iostreams.SetForTest(t)
svc := &fakeSearchService{results: []*sdk.SearchResult{{Score: 0.9, Content: "x"}}}
opts := &Options{Query: "q", KBID: "kb1", TopK: 1, JSONOut: true}
require.NoError(t, runSearch(context.Background(), opts, svc))
assert.True(t, strings.HasPrefix(out.String(), `{"ok":true`), "got: %q", out.String())
assert.Contains(t, out.String(), `"kb_id":"kb1"`)
}
func TestRunSearch_EmptyResults(t *testing.T) {
out, _ := iostreams.SetForTest(t)
svc := &fakeSearchService{results: nil}
require.NoError(t, runSearch(context.Background(), &Options{Query: "q", KBID: "kb1"}, svc))
assert.Contains(t, out.String(), "(no results)")
}
// Server returns primary matches plus parent/related/nearby enrichment chunks,
// so the wire response can exceed TopK. CLI must trim to honor the user's
// hard-limit contract (gh / kubectl / aws idiom).
func TestRunSearch_TopKHardCap(t *testing.T) {
out, _ := iostreams.SetForTest(t)
svc := &fakeSearchService{results: []*sdk.SearchResult{
{Score: 0.9, Content: "primary 1"},
{Score: 0.8, Content: "primary 2"},
{Score: 0.7, Content: "primary 3"},
{Score: 0, Content: "enrichment parent"}, // server-padded
{Score: 0, Content: "enrichment nearby"}, // server-padded
}}
require.NoError(t, runSearch(context.Background(), &Options{Query: "q", KBID: "kb1", TopK: 3}, svc))
got := out.String()
assert.Contains(t, got, "3 result(s)")
assert.NotContains(t, got, "enrichment parent")
assert.NotContains(t, got, "enrichment nearby")
}
func TestRunSearch_BothChannelsDisabled(t *testing.T) {
iostreams.SetForTest(t)
err := runSearch(context.Background(), &Options{Query: "q", KBID: "kb1", NoVector: true, NoKeyword: true}, &fakeSearchService{})
require.Error(t, err)
assert.Contains(t, err.Error(), "input.invalid_argument")
}
func TestRunSearch_ServiceError_Transport(t *testing.T) {
iostreams.SetForTest(t)
svc := &fakeSearchService{err: assert.AnError}
err := runSearch(context.Background(), &Options{Query: "q", KBID: "kb1"}, svc)
require.Error(t, err)
var typed *cmdutil.Error
require.ErrorAs(t, err, &typed)
assert.Equal(t, cmdutil.CodeNetworkError, typed.Code,
"non-HTTP-shaped errors classify as network.error so IsTransient picks them up")
}
func TestRunSearch_ServiceError_HTTPNotFound(t *testing.T) {
iostreams.SetForTest(t)
svc := &fakeSearchService{err: errors.New("HTTP error 404: knowledge base not found")}
err := runSearch(context.Background(), &Options{Query: "q", KBID: "missing"}, svc)
require.Error(t, err)
var typed *cmdutil.Error
require.ErrorAs(t, err, &typed)
assert.Equal(t, cmdutil.CodeResourceNotFound, typed.Code)
}
func TestIndent(t *testing.T) {
assert.Equal(t, " foo\n bar", indent("foo\nbar", " "))
assert.Equal(t, "", indent("", " "))
}
func TestRunSearch_NilService(t *testing.T) {
iostreams.SetForTest(t)
err := runSearch(context.Background(), &Options{Query: "q", KBID: "kb1"}, nil)
require.Error(t, err)
assert.Contains(t, err.Error(), "server.error")
}
func TestNewCmdSearch_RequiresQuery(t *testing.T) {
iostreams.SetForTest(t)
cmd := NewCmdSearch(&cmdutil.Factory{
Client: func() (*sdk.Client, error) { return nil, nil },
})
cmd.SetArgs([]string{}) // no query
// TestSearch_RejectsPositional: bare positional (e.g. the v0.0-style
// `weknora search "<q>" --kb X`) must error — the legacy alias was
// dropped, search is now pure dispatcher.
func TestSearch_RejectsPositional(t *testing.T) {
_, _ = iostreams.SetForTest(t)
cmd := NewCmdSearch(&cmdutil.Factory{})
cmd.SetArgs([]string{"hello", "--kb", "kb_abc"})
cmd.SilenceErrors = true
cmd.SilenceUsage = true
err := cmd.Execute()
require.Error(t, err)
}
func TestNewCmdSearch_RejectsEmptyQuery(t *testing.T) {
iostreams.SetForTest(t)
cmd := NewCmdSearch(&cmdutil.Factory{
Client: func() (*sdk.Client, error) { return nil, nil },
})
cmd.SetArgs([]string{" ", "--kb", "kb1"})
cmd.SilenceErrors = true
cmd.SilenceUsage = true
err := cmd.Execute()
require.Error(t, err)
assert.Contains(t, err.Error(), "input.invalid_argument")
}
func TestRunSearch_NoVectorPassedThrough(t *testing.T) {
iostreams.SetForTest(t)
var got *sdk.SearchParams
svc := &capturingSearchService{capture: func(p *sdk.SearchParams) { got = p }}
require.NoError(t, runSearch(context.Background(), &Options{
Query: "q", KBID: "kb1", NoVector: true,
}, svc))
require.NotNil(t, got)
assert.True(t, got.DisableVectorMatch)
assert.False(t, got.DisableKeywordsMatch)
}
func TestRunSearch_NoKeywordPassedThrough(t *testing.T) {
iostreams.SetForTest(t)
var got *sdk.SearchParams
svc := &capturingSearchService{capture: func(p *sdk.SearchParams) { got = p }}
require.NoError(t, runSearch(context.Background(), &Options{
Query: "q", KBID: "kb1", NoKeyword: true,
}, svc))
require.NotNil(t, got)
assert.True(t, got.DisableKeywordsMatch)
assert.False(t, got.DisableVectorMatch)
}
type capturingSearchService struct {
capture func(*sdk.SearchParams)
}
func (c *capturingSearchService) HybridSearch(_ context.Context, _ string, p *sdk.SearchParams) ([]*sdk.SearchResult, error) {
c.capture(p)
return nil, nil
// TestSearch_SubcommandsRegistered: ensure chunks/kb/docs/sessions are
// reachable through the parent. Smoke-test only; the subcommands' own
// tests cover behavior.
func TestSearch_SubcommandsRegistered(t *testing.T) {
f := &cmdutil.Factory{}
cmd := NewCmdSearch(f)
names := map[string]bool{}
for _, c := range cmd.Commands() {
names[c.Name()] = true
}
for _, want := range []string{"chunks", "kb", "docs", "sessions"} {
if !names[want] {
t.Errorf("missing subcommand %q", want)
}
}
}
+119
View File
@@ -0,0 +1,119 @@
package search
import (
"context"
"fmt"
"sort"
"strings"
"text/tabwriter"
"github.com/spf13/cobra"
"github.com/Tencent/WeKnora/cli/internal/agent"
"github.com/Tencent/WeKnora/cli/internal/cmdutil"
"github.com/Tencent/WeKnora/cli/internal/format"
"github.com/Tencent/WeKnora/cli/internal/iostreams"
"github.com/Tencent/WeKnora/cli/internal/text"
sdk "github.com/Tencent/WeKnora/client"
)
const sessionsPageSize = 200
// SessionsSearchOptions captures `weknora search sessions` flag state.
type SessionsSearchOptions struct {
Query string
Limit int
JSONOut bool
}
// SessionsSearchService is the narrow SDK surface this command depends on.
// Server has no session-search endpoint; CLI pages through and filters by
// Title / Description client-side.
type SessionsSearchService interface {
GetSessionsByTenant(ctx context.Context, page, pageSize int) ([]sdk.Session, int, error)
}
// NewCmdSessions builds `weknora search sessions "<query>"`. Finds chat
// sessions whose title or description contains the query.
func NewCmdSessions(f *cmdutil.Factory) *cobra.Command {
opts := &SessionsSearchOptions{}
cmd := &cobra.Command{
Use: `sessions "<query>"`,
Short: "Find chat sessions by title or description (client-side substring match)",
Args: cobra.ExactArgs(1),
RunE: func(c *cobra.Command, args []string) error {
opts.Query = strings.TrimSpace(args[0])
if opts.Query == "" {
return cmdutil.NewError(cmdutil.CodeInputInvalidArgument, "query argument cannot be empty")
}
cli, err := f.Client()
if err != nil {
return err
}
return runSessionsSearch(c.Context(), opts, cli)
},
}
cmd.Flags().IntVarP(&opts.Limit, "limit", "L", 20, "Maximum results to return")
cmd.Flags().BoolVar(&opts.JSONOut, "json", false, "Output JSON envelope")
agent.SetAgentHelp(cmd, "Lists chat sessions whose title or description contains the query. Pages through the tenant sequentially; stops once limit matches found. Returns full Session objects so agents can pivot to session view/delete by id.")
return cmd
}
func runSessionsSearch(ctx context.Context, opts *SessionsSearchOptions, svc SessionsSearchService) error {
needle := strings.ToLower(opts.Query)
var matches []sdk.Session
for page := 1; ; page++ {
items, total, err := svc.GetSessionsByTenant(ctx, page, sessionsPageSize)
if err != nil {
return cmdutil.Wrapf(cmdutil.ClassifyHTTPError(err), err, "list sessions")
}
for _, s := range items {
if matchSession(s, needle) {
matches = append(matches, s)
if opts.Limit > 0 && len(matches) >= opts.Limit {
goto done
}
}
}
if page*sessionsPageSize >= total || len(items) == 0 {
break
}
}
done:
sortSessionsByRecency(matches)
if opts.JSONOut {
return format.WriteEnvelope(iostreams.IO.Out, format.Success(matches, nil))
}
if len(matches) == 0 {
fmt.Fprintln(iostreams.IO.Out, "(no matches)")
return nil
}
tw := tabwriter.NewWriter(iostreams.IO.Out, 0, 0, 2, ' ', 0)
fmt.Fprintln(tw, "ID\tTITLE\tUPDATED")
for _, s := range matches {
title := text.Truncate(50, s.Title)
if title == "" {
title = "-"
}
fmt.Fprintf(tw, "%s\t%s\t%s\n", s.ID, title, s.UpdatedAt)
}
return tw.Flush()
}
// matchSession reports whether title or description contains needle (already
// lowercased by caller).
func matchSession(s sdk.Session, needle string) bool {
return text.ContainsFold(needle, s.Title, s.Description)
}
// sortSessionsByRecency sorts in place by UpdatedAt desc. Server returns
// strings; we compare lexically — RFC3339 timestamps sort correctly that
// way, and a stable order is enough for output determinism even if a
// non-conforming string slips through.
func sortSessionsByRecency(items []sdk.Session) {
sort.SliceStable(items, func(i, j int) bool {
return items[i].UpdatedAt > items[j].UpdatedAt
})
}
+77
View File
@@ -0,0 +1,77 @@
package search
import (
"context"
"errors"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/Tencent/WeKnora/cli/internal/cmdutil"
"github.com/Tencent/WeKnora/cli/internal/iostreams"
sdk "github.com/Tencent/WeKnora/client"
)
type fakeSessionsSearchSvc struct {
pages map[int][]sdk.Session
total int
err error
calls []int
}
func (f *fakeSessionsSearchSvc) GetSessionsByTenant(_ context.Context, page, pageSize int) ([]sdk.Session, int, error) {
f.calls = append(f.calls, page)
if f.err != nil {
return nil, 0, f.err
}
return f.pages[page], f.total, nil
}
func TestSessionsSearch_TitleAndDescription(t *testing.T) {
out, _ := iostreams.SetForTest(t)
svc := &fakeSessionsSearchSvc{
pages: map[int][]sdk.Session{1: {
{ID: "s1", Title: "Design review", UpdatedAt: "2026-05-12"},
{ID: "s2", Title: "Random", Description: "with design notes", UpdatedAt: "2026-05-11"},
{ID: "s3", Title: "Marketing", UpdatedAt: "2026-05-10"},
}},
total: 3,
}
require.NoError(t, runSessionsSearch(context.Background(), &SessionsSearchOptions{Query: "design", Limit: 20}, svc))
got := out.String()
assert.Contains(t, got, "s1")
assert.Contains(t, got, "s2")
assert.NotContains(t, got, "s3")
}
func TestSessionsSearch_NoMatches(t *testing.T) {
out, _ := iostreams.SetForTest(t)
svc := &fakeSessionsSearchSvc{
pages: map[int][]sdk.Session{1: {{Title: "foo"}}},
total: 1,
}
require.NoError(t, runSessionsSearch(context.Background(), &SessionsSearchOptions{Query: "missing", Limit: 20}, svc))
assert.Contains(t, out.String(), "(no matches)")
}
func TestSessionsSearch_PaginatesAndStopsAtLimit(t *testing.T) {
_, _ = iostreams.SetForTest(t)
page1 := make([]sdk.Session, sessionsPageSize)
for i := range page1 {
page1[i] = sdk.Session{ID: "m", Title: "needle"}
}
svc := &fakeSessionsSearchSvc{pages: map[int][]sdk.Session{1: page1}, total: 1000}
require.NoError(t, runSessionsSearch(context.Background(), &SessionsSearchOptions{Query: "needle", Limit: 5}, svc))
assert.Equal(t, []int{1}, svc.calls, "stops paging when limit reached")
}
func TestSessionsSearch_NetworkError(t *testing.T) {
_, _ = iostreams.SetForTest(t)
svc := &fakeSessionsSearchSvc{err: errors.New("HTTP error 500: internal")}
err := runSessionsSearch(context.Background(), &SessionsSearchOptions{Query: "x", Limit: 20}, svc)
require.Error(t, err)
var typed *cmdutil.Error
require.ErrorAs(t, err, &typed)
assert.Equal(t, cmdutil.CodeServerError, typed.Code)
}
+12
View File
@@ -25,6 +25,18 @@ type KBLister interface {
ListKnowledgeBases(ctx context.Context) ([]sdk.KnowledgeBase, error)
}
// ResolveKBFlag interprets a raw --kb value (id or name) and returns the
// canonical id. Pass-through when raw already looks like an id; otherwise
// list and match by name. Shared by every command that takes a --kb flag
// directly (search chunks/docs, doc download, link …) so the id-or-name
// policy never drifts.
func ResolveKBFlag(ctx context.Context, lister KBLister, raw string) (string, error) {
if IsKBID(raw) {
return raw, nil
}
return ResolveKBNameToID(ctx, lister, raw)
}
// ResolveKBNameToID looks up a knowledge base by name and returns its ID.
// Used by `link` and `Factory.ResolveKB` — a single lookup so the match
// policy (currently exact case-sensitive) lives in one place.
+17
View File
@@ -0,0 +1,17 @@
package text
import "strings"
// ContainsFold reports whether any of fields contains needle, comparing
// case-insensitively. Avoids the inline `strings.Contains(strings.ToLower
// (field), needle)` triple-pattern when callers want to OR-match across
// several columns of the same record. The caller passes the needle in
// lowercase form so we don't lowercase the same string per call site.
func ContainsFold(lowerNeedle string, fields ...string) bool {
for _, f := range fields {
if strings.Contains(strings.ToLower(f), lowerNeedle) {
return true
}
}
return false
}