mirror of
https://github.com/Tencent/WeKnora.git
synced 2026-08-29 02:04:30 +08:00
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:
@@ -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,
|
||||
},
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user