WeKnora deployment source with the reviewed wiki identity, summary and recovery fixes
Anydoc / anydoc parser engine (push) Canceled after 0s
App / Format, vet, test, and build (push) Canceled after 0s
CLI / test (macos-latest / go 1.26) (push) Canceled after 0s
CLI / test (ubuntu-latest / go 1.26) (push) Canceled after 0s
CLI / test (windows-latest / go 1.26) (push) Canceled after 0s
Build and Push Docker Image / build-ui (push) Canceled after 0s
Build and Push Docker Image / build-docreader (push) Canceled after 0s
Build and Push Docker Image / build-sandbox (push) Canceled after 0s
Build and Push Docker Image / build-app (amd64, linux/amd64, ubuntu-latest) (push) Canceled after 0s
Build and Push Docker Image / build-app (arm64, linux/arm64, ubuntu-24.04-arm) (push) Canceled after 0s
DocReader / Python tests and Go client tests (push) Canceled after 0s
Build & Publish (dsh-weknora) / Typecheck, test, pack (push) Canceled after 0s
Build & Publish (dsh-weknora) / End-to-end inside dsh (pinned) (push) Canceled after 0s
Build & Publish (dsh-weknora) / End-to-end inside dsh (latest, advisory) (push) Canceled after 0s
Frontend / Test, type-check, and build (push) Canceled after 0s
Go Lint Cache / warm lint cache (.) (push) Canceled after 0s
Go Lint Cache / warm lint cache (cli) (push) Canceled after 0s
Go Lint Cache / warm lint cache (client) (push) Canceled after 0s
Build & Publish (mcp-server) / Test (Python 3.10) (push) Canceled after 0s
Build & Publish (mcp-server) / Test (Python 3.11) (push) Canceled after 0s
Build & Publish (mcp-server) / Test (Python 3.12) (push) Canceled after 0s
Build & Publish (mcp-server) / Test (Python 3.13) (push) Canceled after 0s
Build and Push Docker Image / merge (push) Canceled after 0s
Build & Publish (dsh-weknora) / Publish to npm (push) Canceled after 0s
Build & Publish (mcp-server) / Build sdist + wheel (push) Canceled after 0s
Build & Publish (mcp-server) / Publish to PyPI (push) Canceled after 0s
Anydoc / anydoc parser engine (push) Canceled after 0s
App / Format, vet, test, and build (push) Canceled after 0s
CLI / test (macos-latest / go 1.26) (push) Canceled after 0s
CLI / test (ubuntu-latest / go 1.26) (push) Canceled after 0s
CLI / test (windows-latest / go 1.26) (push) Canceled after 0s
Build and Push Docker Image / build-ui (push) Canceled after 0s
Build and Push Docker Image / build-docreader (push) Canceled after 0s
Build and Push Docker Image / build-sandbox (push) Canceled after 0s
Build and Push Docker Image / build-app (amd64, linux/amd64, ubuntu-latest) (push) Canceled after 0s
Build and Push Docker Image / build-app (arm64, linux/arm64, ubuntu-24.04-arm) (push) Canceled after 0s
DocReader / Python tests and Go client tests (push) Canceled after 0s
Build & Publish (dsh-weknora) / Typecheck, test, pack (push) Canceled after 0s
Build & Publish (dsh-weknora) / End-to-end inside dsh (pinned) (push) Canceled after 0s
Build & Publish (dsh-weknora) / End-to-end inside dsh (latest, advisory) (push) Canceled after 0s
Frontend / Test, type-check, and build (push) Canceled after 0s
Go Lint Cache / warm lint cache (.) (push) Canceled after 0s
Go Lint Cache / warm lint cache (cli) (push) Canceled after 0s
Go Lint Cache / warm lint cache (client) (push) Canceled after 0s
Build & Publish (mcp-server) / Test (Python 3.10) (push) Canceled after 0s
Build & Publish (mcp-server) / Test (Python 3.11) (push) Canceled after 0s
Build & Publish (mcp-server) / Test (Python 3.12) (push) Canceled after 0s
Build & Publish (mcp-server) / Test (Python 3.13) (push) Canceled after 0s
Build and Push Docker Image / merge (push) Canceled after 0s
Build & Publish (dsh-weknora) / Publish to npm (push) Canceled after 0s
Build & Publish (mcp-server) / Build sdist + wheel (push) Canceled after 0s
Build & Publish (mcp-server) / Publish to PyPI (push) Canceled after 0s
This commit is contained in:
@@ -0,0 +1,204 @@
|
||||
package search
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/Tencent/WeKnora/cli/internal/cmdutil"
|
||||
"github.com/Tencent/WeKnora/cli/internal/iostreams"
|
||||
"github.com/Tencent/WeKnora/cli/internal/output"
|
||||
sdk "github.com/Tencent/WeKnora/client"
|
||||
)
|
||||
|
||||
// chunksFields enumerates the fields surfaced for `--format json` discovery on
|
||||
// `search chunks`. Filter applies to each SearchResult object in the bare
|
||||
// array.
|
||||
var chunksFields = []string{
|
||||
"id", "content", "knowledge_id", "chunk_index", "knowledge_title",
|
||||
"start_at", "end_at", "seq", "score", "match_type", "chunk_type",
|
||||
"image_info", "metadata", "knowledge_filename", "knowledge_source",
|
||||
"knowledge_channel", "matched_content",
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
// 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,
|
||||
opts ...sdk.ResourceURLOptions,
|
||||
) ([]*sdk.SearchResult, error)
|
||||
}
|
||||
|
||||
var _ ChunksService = (*sdk.Client)(nil)
|
||||
|
||||
// NewCmdChunks builds `weknora search chunks "<query>" --kb <id-or-name>`.
|
||||
// Uses a positional query argument with the KB selected via flag.
|
||||
//
|
||||
// The `--kb` flag accepts either a KB UUID (passed through unchanged) or a
|
||||
// name (resolved via ListKnowledgeBases - see cmdutil.ResolveKBFlag).
|
||||
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",
|
||||
Example: ` weknora search chunks "what is RAG?" --kb engineering
|
||||
weknora search chunks "embedding model" --kb kb_abc --limit 20
|
||||
weknora search chunks "retry policy" --kb engineering --no-keyword # vector-only`,
|
||||
Long: `Hybrid (vector + keyword) retrieval against the knowledge base. Pass
|
||||
--no-vector or --no-keyword to disable one channel; you cannot disable both.
|
||||
--limit caps the returned slice client-side.`,
|
||||
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
|
||||
}
|
||||
if opts.Limit < 1 || opts.Limit > 1000 {
|
||||
return cmdutil.NewError(cmdutil.CodeInputInvalidArgument, "--limit must be between 1 and 1000")
|
||||
}
|
||||
fopts, err := cmdutil.CheckFormatFlag(c)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
fopts.ResolveDefault(iostreams.IO.IsStdoutTTY())
|
||||
// Resolve KB via the shared flag→env→project-link chain (same as
|
||||
// `doc list` / `chat`), so a linked directory or WEKNORA_KB_ID
|
||||
// works without an explicit --kb. Resolve before building the
|
||||
// client so an unresolved KB short-circuits to local.kb_id_required
|
||||
// without a client round-trip.
|
||||
kbID, err := f.ResolveKB(c)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
opts.KBID = kbID
|
||||
cli, err := f.Client()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return runChunks(c.Context(), opts, fopts, cli)
|
||||
},
|
||||
}
|
||||
bindChunksFlags(cmd, opts)
|
||||
cmdutil.SetAgentHelp(cmd, cmdutil.AgentHelp{
|
||||
UsedFor: "Hybrid (vector + keyword) chunk retrieval against a knowledge base. The KB comes from --kb (id or name), else WEKNORA_KB_ID, else the linked directory. Results come with meta.count; use --limit to cap (default 8, tuned for RAG context). Pass --no-vector or --no-keyword to disable one channel.",
|
||||
RequiredFlags: []string{"<query> (positional)", "--kb (or WEKNORA_KB_ID / linked directory)"},
|
||||
Examples: []string{`weknora search chunks "what is RAG?" --kb engineering --format json`},
|
||||
Output: "envelope.data is an array of SearchResult objects with id, content, score, knowledge_id; meta.count is the returned count; meta.has_more=true if more matched than --limit",
|
||||
})
|
||||
return cmd
|
||||
}
|
||||
|
||||
// bindChunksFlags registers the chunks flag surface in one place to keep
|
||||
// the constructor readable. --kb is optional: when omitted it falls back to
|
||||
// WEKNORA_KB_ID / the linked directory via Factory.ResolveKB.
|
||||
func bindChunksFlags(cmd *cobra.Command, opts *ChunksOptions) {
|
||||
cmd.Flags().StringVar(&opts.KB, "kb", "", "Knowledge base UUID or name (overrides env / project link)")
|
||||
cmd.Flags().IntVarP(&opts.Limit, "limit", "L", 8, "Maximum results to return (default 8 - tuned for RAG context window; list commands default to 30)")
|
||||
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")
|
||||
cmdutil.AddFormatFlag(cmd, chunksFields...)
|
||||
}
|
||||
|
||||
// validate checks the option set before any SDK call. Limit bounds are
|
||||
// enforced separately in RunE (user-input boundary) so internal callers
|
||||
// can pass Limit==0 for the "no client-side cap" path.
|
||||
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, fopts *cmdutil.FormatOptions, 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.WrapHTTP(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. Treat --limit as
|
||||
// a hard return-count cap by trimming on the client. Recall isn't
|
||||
// affected because the server's internal retrieval pool is already
|
||||
// max(MatchCount*5, 50).
|
||||
truncated := opts.Limit > 0 && len(results) > opts.Limit
|
||||
if truncated {
|
||||
results = results[:opts.Limit]
|
||||
}
|
||||
|
||||
if fopts.WantsJSON() {
|
||||
if results == nil {
|
||||
results = []*sdk.SearchResult{}
|
||||
}
|
||||
meta := &output.Meta{Count: output.IntPtr(len(results)), HasMore: truncated, Hint: emptyContentSearchHint(len(results))}
|
||||
return fopts.Emit(iostreams.IO.Out, results, meta)
|
||||
}
|
||||
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,249 @@
|
||||
package search
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"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 fakeChunksSvc struct {
|
||||
results []*sdk.SearchResult
|
||||
err error
|
||||
gotKB string
|
||||
gotQ string
|
||||
}
|
||||
|
||||
func (f *fakeChunksSvc) HybridSearch(
|
||||
_ context.Context, kbID string, p *sdk.SearchParams, _ ...sdk.ResourceURLOptions,
|
||||
) ([]*sdk.SearchResult, error) {
|
||||
f.gotKB = kbID
|
||||
f.gotQ = p.QueryText
|
||||
return f.results, f.err
|
||||
}
|
||||
|
||||
func TestRunSearch_TextOutput(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, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, 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 output must surface match_type so machine consumers / agents can
|
||||
// reason about retrieval channels without re-implementing the wire format.
|
||||
// (Text renderer keeps default minimal - diagnostic info opt-in via --format json.)
|
||||
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"}, &cmdutil.FormatOptions{Mode: cmdutil.FormatJSON}, 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}
|
||||
require.NoError(t, runChunks(context.Background(), opts, &cmdutil.FormatOptions{Mode: cmdutil.FormatJSON}, svc))
|
||||
got := out.String()
|
||||
var env struct {
|
||||
OK bool `json:"ok"`
|
||||
Data []*sdk.SearchResult `json:"data"`
|
||||
}
|
||||
require.NoError(t, json.Unmarshal([]byte(got), &env), "expected valid JSON envelope, got: %q", got)
|
||||
assert.True(t, env.OK, "envelope.ok must be true")
|
||||
assert.Contains(t, got, `"score":0.9`)
|
||||
}
|
||||
|
||||
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"}, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, 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.
|
||||
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}, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, 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}, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, &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"}, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, 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"}, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, 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"}, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, nil)
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "server.error")
|
||||
}
|
||||
|
||||
// TestNewCmdChunks_NoKBUsesResolver asserts that `search chunks "<query>"`
|
||||
// without --kb no longer fails with cobra's `required flag(s) "kb"`. The
|
||||
// command now resolves the KB through the shared flag→env→project-link chain
|
||||
// (cmdutil.Factory.ResolveKB), matching `doc list` / `chat`; when nothing
|
||||
// resolves it reports the typed local.kb_id_required (exit 1), not a cobra
|
||||
// usage error. This is the inverse of the old lock test, which deliberately
|
||||
// blocked the link fallback for this read path — the asymmetry with `doc
|
||||
// list` was not worth keeping (a non-destructive search is the same risk
|
||||
// profile as a list). The destructive `doc delete --all` keeps its explicit
|
||||
// --kb rule; that safety guard is unrelated to this path.
|
||||
func TestNewCmdChunks_NoKBUsesResolver(t *testing.T) {
|
||||
iostreams.SetForTest(t)
|
||||
t.Setenv("WEKNORA_KB_ID", "") // no ambient KB from env
|
||||
t.Chdir(t.TempDir()) // no .weknora project link discoverable
|
||||
cmd := NewCmdChunks(&cmdutil.Factory{
|
||||
// Resolution reaches local.kb_id_required before any client is built,
|
||||
// so this must never be invoked; make it loud if it is.
|
||||
Client: func() (*sdk.Client, error) { return nil, errors.New("client should not be built") },
|
||||
})
|
||||
cmd.SetArgs([]string{"some query"}) // query but no --kb
|
||||
cmd.SilenceErrors = true
|
||||
cmd.SilenceUsage = true
|
||||
err := cmd.Execute()
|
||||
require.Error(t, err)
|
||||
assert.NotContains(t, err.Error(), `required flag(s) "kb"`)
|
||||
typed := cmdutil.AsError(err)
|
||||
require.NotNil(t, typed)
|
||||
assert.Equal(t, cmdutil.CodeKBIDRequired, typed.Code)
|
||||
}
|
||||
|
||||
// TestNewCmdChunks_HonorsKBEnv proves the env fallback is wired: with
|
||||
// WEKNORA_KB_ID set and no --kb, KB resolution succeeds (no kb-required
|
||||
// error) and the command proceeds to the client step — which here errors,
|
||||
// confirming we got past resolution using the env value alone.
|
||||
func TestNewCmdChunks_HonorsKBEnv(t *testing.T) {
|
||||
iostreams.SetForTest(t)
|
||||
t.Setenv("WEKNORA_KB_ID", "kb_from_env")
|
||||
cmd := NewCmdChunks(&cmdutil.Factory{
|
||||
Client: func() (*sdk.Client, error) { return nil, errors.New("client boom") },
|
||||
})
|
||||
cmd.SetArgs([]string{"some query"}) // no --kb; env supplies it
|
||||
cmd.SilenceErrors = true
|
||||
cmd.SilenceUsage = true
|
||||
err := cmd.Execute()
|
||||
require.Error(t, err)
|
||||
assert.NotContains(t, err.Error(), "kb is required")
|
||||
assert.Contains(t, err.Error(), "client boom")
|
||||
}
|
||||
|
||||
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,
|
||||
}, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, 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,
|
||||
}, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, 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.ResourceURLOptions,
|
||||
) ([]*sdk.SearchResult, error) {
|
||||
c.capture(p)
|
||||
return nil, nil
|
||||
}
|
||||
@@ -0,0 +1,196 @@
|
||||
package search
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
"text/tabwriter"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/Tencent/WeKnora/cli/internal/cmdutil"
|
||||
"github.com/Tencent/WeKnora/cli/internal/iostreams"
|
||||
"github.com/Tencent/WeKnora/cli/internal/output"
|
||||
"github.com/Tencent/WeKnora/cli/internal/text"
|
||||
sdk "github.com/Tencent/WeKnora/client"
|
||||
)
|
||||
|
||||
// docsPageSize is the default --page-size on `search docs`: how many
|
||||
// entries to pull per ListKnowledgeWithFilter round-trip. The server
|
||||
// applies the keyword filter pre-pagination, so most KBs return in a
|
||||
// single page even at conservative sizes. Server caps page_size at 1000.
|
||||
const docsPageSize = 200
|
||||
|
||||
// docsMaxPageSize bounds the --page-size flag, matching the session/doc list cap.
|
||||
const docsMaxPageSize = 1000
|
||||
|
||||
// docsFields enumerates the fields surfaced for `--format json` discovery on
|
||||
// `search docs`. Mirrors sdk.Knowledge json tags.
|
||||
var docsFields = []string{
|
||||
"id", "tenant_id", "knowledge_base_id", "tag_id", "type", "title",
|
||||
"description", "source", "channel", "parse_status", "summary_status",
|
||||
"enable_status", "embedding_model_id", "file_name", "file_type",
|
||||
"file_size", "file_hash", "file_path", "storage_size", "metadata",
|
||||
"created_at", "updated_at", "processed_at", "error_message",
|
||||
}
|
||||
|
||||
type DocsSearchOptions struct {
|
||||
Query string
|
||||
KB string // raw --kb (UUID or name)
|
||||
KBID string // resolved id; populated before listing
|
||||
Limit int
|
||||
// PageSize is the server batch size per ListKnowledgeWithFilter call
|
||||
// (1..1000, default 200). Tunable so a caller searching a small KB
|
||||
// can fetch everything in one round-trip, or a caller on flaky
|
||||
// network can shorten the batch.
|
||||
PageSize int
|
||||
// AllPages walks server pages internally until total exhausted or
|
||||
// --limit accumulated. Default true; setting false stops after the
|
||||
// first page (useful for cheap previews).
|
||||
AllPages bool
|
||||
}
|
||||
|
||||
// DocsSearchService is the narrow SDK surface this command depends on.
|
||||
// The server applies the keyword filter pre-pagination via the
|
||||
// ?keyword= query param, so the CLI just forwards opts.Query as
|
||||
// filter.Keyword and accumulates the (already-filtered) pages.
|
||||
type DocsSearchService interface {
|
||||
ListKnowledgeWithFilter(ctx context.Context, kbID string, page, pageSize int, filter sdk.KnowledgeListFilter) ([]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 file_name contains the query as a server-side case-insensitive LIKE
|
||||
// match. 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 keyword (server-side filter)",
|
||||
Long: `Pages through the KB's documents, forwarding the query as the server-side
|
||||
keyword filter (matched against title / file_name). Useful for finding a
|
||||
specific upload to download or delete by id.
|
||||
|
||||
The query is a case-insensitive server-side LIKE filter (the server runs
|
||||
` + "`LOWER(...) LIKE LOWER('%keyword%')`" + ` against title and file_name), so
|
||||
` + "`FALCON`" + ` and ` + "`falcon`" + ` match the same documents.
|
||||
|
||||
By default, --all-pages=true walks every server page until --limit is
|
||||
reached or the KB is exhausted. Pass --all-pages=false to stop after one page.`,
|
||||
Example: ` weknora search docs "Q3 forecast" --kb finance
|
||||
weknora search docs "spec" --kb engineering --limit 5
|
||||
weknora search docs "spec" --kb engineering --all-pages=false`,
|
||||
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")
|
||||
}
|
||||
if opts.Limit < 1 || opts.Limit > 1000 {
|
||||
return cmdutil.NewError(cmdutil.CodeInputInvalidArgument, "--limit must be between 1 and 1000")
|
||||
}
|
||||
fopts, err := cmdutil.CheckFormatFlag(c)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
fopts.ResolveDefault(iostreams.IO.IsStdoutTTY())
|
||||
// Resolve KB via the shared flag→env→project-link chain (same as
|
||||
// `doc list` / `chat`), so a linked directory or WEKNORA_KB_ID
|
||||
// works without an explicit --kb. Resolve before building the
|
||||
// client so an unresolved KB short-circuits to local.kb_id_required
|
||||
// without a client round-trip.
|
||||
kbID, err := f.ResolveKB(c)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
opts.KBID = kbID
|
||||
cli, err := f.Client()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return runDocsSearch(c.Context(), opts, fopts, cli)
|
||||
},
|
||||
}
|
||||
cmd.Flags().StringVar(&opts.KB, "kb", "", "Knowledge base UUID or name (overrides env / project link)")
|
||||
cmd.Flags().IntVarP(&opts.Limit, "limit", "L", 30, "Maximum results to return")
|
||||
cmd.Flags().IntVar(&opts.PageSize, "page-size", docsPageSize, "Items per server batch (1..1000)")
|
||||
cmd.Flags().BoolVar(&opts.AllPages, "all-pages", true, "Walk every server page until exhausted or --limit hit")
|
||||
cmdutil.AddFormatFlag(cmd, docsFields...)
|
||||
cmdutil.SetAgentHelp(cmd, cmdutil.AgentHelp{
|
||||
UsedFor: "Find documents in a knowledge base by keyword (server-side LIKE filter on title/file_name). The KB comes from --kb (id or name), else WEKNORA_KB_ID, else the linked directory. Results come with meta.count; use --limit to cap and --all-pages=false to stop after one page.",
|
||||
RequiredFlags: []string{"<query> (positional)", "--kb (or WEKNORA_KB_ID / linked directory)"},
|
||||
Examples: []string{`weknora search docs "spec" --kb engineering --format json`},
|
||||
Output: "envelope.data is an array of Knowledge objects with id, title, file_name, parse_status; meta.count is the returned count, meta.total_count the server's full match count, meta.has_more=true if more matched than --limit",
|
||||
})
|
||||
return cmd
|
||||
}
|
||||
|
||||
func runDocsSearch(ctx context.Context, opts *DocsSearchOptions, fopts *cmdutil.FormatOptions, svc DocsSearchService) error {
|
||||
if opts.PageSize < 1 || opts.PageSize > docsMaxPageSize {
|
||||
return cmdutil.NewError(cmdutil.CodeInputInvalidArgument,
|
||||
fmt.Sprintf("--page-size must be in 1..%d, got %d", docsMaxPageSize, opts.PageSize))
|
||||
}
|
||||
filter := sdk.KnowledgeListFilter{Keyword: opts.Query}
|
||||
var matches []sdk.Knowledge
|
||||
|
||||
// Page through the KB until limit matches found or pagination exhausted.
|
||||
// The server applies the keyword filter pre-pagination, so every item
|
||||
// returned is already a match - no client-side filter needed.
|
||||
// --all-pages=true (default) walks every server page; --all-pages=false
|
||||
// stops after the first page. Termination counts records actually
|
||||
// received so server-capped page_size doesn't truncate.
|
||||
var serverTotal int64
|
||||
for page := 1; ; page++ {
|
||||
items, total, err := svc.ListKnowledgeWithFilter(ctx, opts.KBID, page, opts.PageSize, filter)
|
||||
if err != nil {
|
||||
return cmdutil.WrapHTTP(err, "list documents")
|
||||
}
|
||||
serverTotal = total
|
||||
for _, k := range items {
|
||||
matches = append(matches, k)
|
||||
// Collect one past --limit so has_more is accurate; trimmed below.
|
||||
if opts.Limit > 0 && len(matches) > opts.Limit {
|
||||
goto done
|
||||
}
|
||||
}
|
||||
if !opts.AllPages {
|
||||
break
|
||||
}
|
||||
if int64(len(matches)) >= total || len(items) == 0 {
|
||||
break
|
||||
}
|
||||
}
|
||||
done:
|
||||
sortKnowledgeByRecency(matches)
|
||||
truncated := opts.Limit > 0 && len(matches) > opts.Limit
|
||||
if truncated {
|
||||
matches = matches[:opts.Limit]
|
||||
}
|
||||
|
||||
if fopts.WantsJSON() {
|
||||
if matches == nil {
|
||||
matches = []sdk.Knowledge{}
|
||||
}
|
||||
meta := &output.Meta{Count: output.IntPtr(len(matches)), TotalCount: output.IntPtr(int(serverTotal)), HasMore: truncated, Hint: emptyContentSearchHint(len(matches))}
|
||||
return fopts.Emit(iostreams.IO.Out, matches, meta)
|
||||
}
|
||||
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, text.KnowledgeDisplayName(k.FileName, k.Title, k.ID))
|
||||
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()
|
||||
}
|
||||
|
||||
// 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,300 @@
|
||||
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/iostreams"
|
||||
sdk "github.com/Tencent/WeKnora/client"
|
||||
)
|
||||
|
||||
// fakeDocsSearchSvc scripts paginated ListKnowledgeWithFilter responses.
|
||||
// Pages are indexed 1-based; items keyed by page. The last-received filter
|
||||
// is captured so tests can assert opts.Query was threaded as filter.Keyword.
|
||||
type fakeDocsSearchSvc struct {
|
||||
pages map[int][]sdk.Knowledge
|
||||
total int64
|
||||
err error
|
||||
calls []int // page numbers requested, for assertions
|
||||
lastFilter sdk.KnowledgeListFilter
|
||||
}
|
||||
|
||||
func (f *fakeDocsSearchSvc) ListKnowledgeWithFilter(_ context.Context, kbID string, page, pageSize int, filter sdk.KnowledgeListFilter) ([]sdk.Knowledge, int64, error) {
|
||||
f.calls = append(f.calls, page)
|
||||
f.lastFilter = filter
|
||||
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)
|
||||
// Server applies the keyword filter pre-pagination; the fake simulates
|
||||
// that by only returning the matching items (d1/d3, not d2).
|
||||
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: "d3", Title: "Q3 retro", FileName: "retro.pdf", UpdatedAt: mustTime(t, "2026-05-11T00:00:00Z")},
|
||||
},
|
||||
},
|
||||
total: 2,
|
||||
}
|
||||
require.NoError(t, runDocsSearch(context.Background(), &DocsSearchOptions{Query: "q3", KBID: "kb1", Limit: 20, PageSize: docsPageSize, AllPages: true}, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, svc))
|
||||
assert.Equal(t, "q3", svc.lastFilter.Keyword, "query must be threaded as filter.Keyword")
|
||||
got := out.String()
|
||||
assert.Contains(t, got, "d1")
|
||||
assert.Contains(t, got, "d3")
|
||||
assert.NotContains(t, got, "d2")
|
||||
}
|
||||
|
||||
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, PageSize: docsPageSize, AllPages: true}, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, svc))
|
||||
assert.Contains(t, out.String(), "d1")
|
||||
}
|
||||
|
||||
// TestDocsSearch_PaginatesUntilTotal walks server-paginated results.
|
||||
// Server-side filter has already been applied, so every returned item
|
||||
// is in the result set; the runner just walks pages until total exhausted
|
||||
// or --limit hit. With limit > total matches, we expect 2 pages.
|
||||
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: "needle"}
|
||||
}
|
||||
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: docsPageSize + 1, PageSize: docsPageSize, AllPages: true}, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, svc))
|
||||
assert.Contains(t, out.String(), "found")
|
||||
assert.Equal(t, []int{1, 2}, svc.calls, "must page past the first batch when more items reported")
|
||||
}
|
||||
|
||||
func TestDocsSearch_StopsAtLimit(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, PageSize: docsPageSize, AllPages: true}, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, svc))
|
||||
// Must not request page 2 because limit 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, PageSize: docsPageSize, AllPages: true}, &cmdutil.FormatOptions{Mode: cmdutil.FormatJSON}, svc))
|
||||
got := out.String()
|
||||
var env struct {
|
||||
OK bool `json:"ok"`
|
||||
Data []sdk.Knowledge `json:"data"`
|
||||
}
|
||||
require.NoError(t, json.Unmarshal([]byte(got), &env), "expected valid JSON envelope, got: %q", got)
|
||||
assert.True(t, env.OK, "envelope.ok must be true")
|
||||
assert.Contains(t, got, `"id":"d1"`)
|
||||
}
|
||||
|
||||
// TestDocsSearch_JSON_EmitsTotalCount pins that search docs surfaces the
|
||||
// server's full match total as meta.total_count (server-side keyword filter, so
|
||||
// total is the real match count) — parity with doc/session/chunk list.
|
||||
func TestDocsSearch_JSON_EmitsTotalCount(t *testing.T) {
|
||||
out, _ := iostreams.SetForTest(t)
|
||||
svc := &fakeDocsSearchSvc{
|
||||
pages: map[int][]sdk.Knowledge{1: {{ID: "d1", Title: "match"}, {ID: "d2", Title: "match2"}}},
|
||||
total: 9, // server reports 9 total matches; we display the first page
|
||||
}
|
||||
require.NoError(t, runDocsSearch(context.Background(),
|
||||
&DocsSearchOptions{Query: "match", KBID: "kb1", Limit: 20, PageSize: docsPageSize, AllPages: false},
|
||||
&cmdutil.FormatOptions{Mode: cmdutil.FormatJSON}, svc))
|
||||
var env struct {
|
||||
Meta struct {
|
||||
Count *int `json:"count"`
|
||||
TotalCount *int `json:"total_count"`
|
||||
} `json:"meta"`
|
||||
}
|
||||
require.NoError(t, json.Unmarshal(out.Bytes(), &env))
|
||||
require.NotNil(t, env.Meta.TotalCount, "search docs must emit meta.total_count")
|
||||
assert.Equal(t, 9, *env.Meta.TotalCount)
|
||||
}
|
||||
|
||||
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, PageSize: docsPageSize, AllPages: true}, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, svc)
|
||||
require.Error(t, err)
|
||||
var typed *cmdutil.Error
|
||||
require.ErrorAs(t, err, &typed)
|
||||
assert.Equal(t, cmdutil.CodeResourceNotFound, typed.Code)
|
||||
}
|
||||
|
||||
// TestSearchDocs_AllPagesFlag_DefaultsTrue_WalksAllPages locks in that the
|
||||
// historic walk-all-pages behavior is preserved when the new --all-pages flag
|
||||
// is left at its default (true). Three pages of fake data, all match the
|
||||
// substring; the run must request every page.
|
||||
func TestSearchDocs_AllPagesFlag_DefaultsTrue_WalksAllPages(t *testing.T) {
|
||||
_, _ = iostreams.SetForTest(t)
|
||||
svc := &fakeDocsSearchSvc{
|
||||
pages: map[int][]sdk.Knowledge{
|
||||
1: {{ID: "d1", Title: "needle"}, {ID: "d2", Title: "needle"}},
|
||||
2: {{ID: "d3", Title: "needle"}},
|
||||
3: {},
|
||||
},
|
||||
total: 3,
|
||||
}
|
||||
opts := &DocsSearchOptions{Query: "needle", KBID: "kb_abc", Limit: 100, PageSize: 2, AllPages: true}
|
||||
require.NoError(t, runDocsSearch(context.Background(), opts, &cmdutil.FormatOptions{Mode: cmdutil.FormatJSON}, svc))
|
||||
assert.GreaterOrEqual(t, len(svc.calls), 2, "must walk multi pages by default")
|
||||
}
|
||||
|
||||
// TestSearchDocs_AllPagesFalse_StopsAtFirstPage asserts that --all-pages=false
|
||||
// caps server round-trips at one, even when the server reports far more
|
||||
// items available. New v0.5 opt-out for the walk-all default.
|
||||
func TestSearchDocs_AllPagesFalse_StopsAtFirstPage(t *testing.T) {
|
||||
_, _ = iostreams.SetForTest(t)
|
||||
svc := &fakeDocsSearchSvc{
|
||||
pages: map[int][]sdk.Knowledge{1: {{ID: "d1", Title: "needle"}, {ID: "d2", Title: "needle"}}},
|
||||
total: 100,
|
||||
}
|
||||
opts := &DocsSearchOptions{Query: "needle", KBID: "kb_abc", Limit: 100, PageSize: 2, AllPages: false}
|
||||
require.NoError(t, runDocsSearch(context.Background(), opts, &cmdutil.FormatOptions{Mode: cmdutil.FormatJSON}, svc))
|
||||
assert.Len(t, svc.calls, 1, "must stop at first page when --all-pages=false")
|
||||
}
|
||||
|
||||
// TestSearchDocs_KeywordPassedToFilter pins the v0.5 switch from client-side
|
||||
// substring filtering to server-side ?keyword= via ListKnowledgeWithFilter.
|
||||
// The query argument must arrive on the filter struct (not a discarded
|
||||
// client-side variable).
|
||||
func TestSearchDocs_KeywordPassedToFilter(t *testing.T) {
|
||||
_, _ = iostreams.SetForTest(t)
|
||||
svc := &fakeDocsSearchSvc{pages: map[int][]sdk.Knowledge{1: {{ID: "d1"}}}, total: 1}
|
||||
require.NoError(t, runDocsSearch(context.Background(), &DocsSearchOptions{Query: "my-query", KBID: "kb1", Limit: 20, PageSize: docsPageSize, AllPages: true}, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, svc))
|
||||
assert.Equal(t, "my-query", svc.lastFilter.Keyword, "Query must be threaded as filter.Keyword on ListKnowledgeWithFilter")
|
||||
// Other filter fields must be empty - search docs only forwards the keyword.
|
||||
assert.Empty(t, svc.lastFilter.ParseStatus)
|
||||
assert.Empty(t, svc.lastFilter.FileType)
|
||||
assert.Empty(t, svc.lastFilter.Source)
|
||||
assert.Empty(t, svc.lastFilter.TagID)
|
||||
}
|
||||
|
||||
// TestSearchDocs_PageSizeBound asserts the 1..1000 range guard mirrors the
|
||||
// session/doc list cap. Out-of-range values must produce
|
||||
// input.invalid_argument and never reach the SDK.
|
||||
func TestSearchDocs_PageSizeBound(t *testing.T) {
|
||||
for _, ps := range []int{0, -1, 1001} {
|
||||
err := runDocsSearch(context.Background(), &DocsSearchOptions{Query: "t", KBID: "k", Limit: 50, PageSize: ps}, &cmdutil.FormatOptions{Mode: cmdutil.FormatJSON}, &fakeDocsSearchSvc{})
|
||||
require.Error(t, err)
|
||||
var typed *cmdutil.Error
|
||||
require.ErrorAs(t, err, &typed)
|
||||
assert.Equal(t, cmdutil.CodeInputInvalidArgument, typed.Code, "page_size=%d", ps)
|
||||
}
|
||||
}
|
||||
|
||||
func mustTime(t *testing.T, s string) time.Time {
|
||||
t.Helper()
|
||||
v, err := time.Parse(time.RFC3339, s)
|
||||
require.NoError(t, err)
|
||||
return v
|
||||
}
|
||||
|
||||
// TestDocsSearch_HasMore asserts the meta.has_more truncation signal: true when
|
||||
// more matches than --limit exist (over-fetch detects it, data trimmed to
|
||||
// --limit), absent/false when the full result set fits. Mirrors the list
|
||||
// commands' contract so an agent can tell its search was capped.
|
||||
func TestDocsSearch_HasMore(t *testing.T) {
|
||||
page := make([]sdk.Knowledge, 10)
|
||||
for i := range page {
|
||||
page[i] = sdk.Knowledge{ID: "match", Title: "needle"}
|
||||
}
|
||||
type meta struct {
|
||||
Count int `json:"count"`
|
||||
HasMore bool `json:"has_more"`
|
||||
}
|
||||
parse := func(t *testing.T, s string) meta {
|
||||
var env struct {
|
||||
Meta meta `json:"meta"`
|
||||
}
|
||||
require.NoError(t, json.Unmarshal([]byte(s), &env), "got %q", s)
|
||||
return env.Meta
|
||||
}
|
||||
|
||||
t.Run("truncated -> has_more true, data trimmed", func(t *testing.T) {
|
||||
out, _ := iostreams.SetForTest(t)
|
||||
svc := &fakeDocsSearchSvc{pages: map[int][]sdk.Knowledge{1: page}, total: 10}
|
||||
require.NoError(t, runDocsSearch(context.Background(),
|
||||
&DocsSearchOptions{Query: "needle", KBID: "kb1", Limit: 3, PageSize: docsPageSize, AllPages: true},
|
||||
&cmdutil.FormatOptions{Mode: cmdutil.FormatJSON}, svc))
|
||||
m := parse(t, out.String())
|
||||
assert.Equal(t, 3, m.Count, "data must be trimmed to --limit")
|
||||
assert.True(t, m.HasMore, "has_more must be true when results exceed --limit")
|
||||
})
|
||||
|
||||
t.Run("fits -> has_more false", func(t *testing.T) {
|
||||
out, _ := iostreams.SetForTest(t)
|
||||
svc := &fakeDocsSearchSvc{pages: map[int][]sdk.Knowledge{1: page[:2]}, total: 2}
|
||||
require.NoError(t, runDocsSearch(context.Background(),
|
||||
&DocsSearchOptions{Query: "needle", KBID: "kb1", Limit: 20, PageSize: docsPageSize, AllPages: true},
|
||||
&cmdutil.FormatOptions{Mode: cmdutil.FormatJSON}, svc))
|
||||
m := parse(t, out.String())
|
||||
assert.Equal(t, 2, m.Count)
|
||||
assert.False(t, m.HasMore, "has_more must be false/absent when results fit under --limit")
|
||||
})
|
||||
}
|
||||
|
||||
// TestNewCmdDocs_NoKBUsesResolver mirrors the chunks guard: `search docs`
|
||||
// without --kb resolves the KB through the shared flag→env→project-link
|
||||
// chain (Factory.ResolveKB), not cobra's required-flag check. With nothing
|
||||
// to resolve it reports the typed local.kb_id_required, not a usage error.
|
||||
func TestNewCmdDocs_NoKBUsesResolver(t *testing.T) {
|
||||
iostreams.SetForTest(t)
|
||||
t.Setenv("WEKNORA_KB_ID", "")
|
||||
t.Chdir(t.TempDir())
|
||||
cmd := NewCmdDocs(&cmdutil.Factory{
|
||||
Client: func() (*sdk.Client, error) { return nil, errors.New("client should not be built") },
|
||||
})
|
||||
cmd.SetArgs([]string{"some query"}) // query but no --kb
|
||||
cmd.SilenceErrors = true
|
||||
cmd.SilenceUsage = true
|
||||
err := cmd.Execute()
|
||||
require.Error(t, err)
|
||||
assert.NotContains(t, err.Error(), `required flag(s) "kb"`)
|
||||
typed := cmdutil.AsError(err)
|
||||
require.NotNil(t, typed)
|
||||
assert.Equal(t, cmdutil.CodeKBIDRequired, typed.Code)
|
||||
}
|
||||
|
||||
// TestNewCmdDocs_HonorsKBEnv proves the env fallback is wired for search docs.
|
||||
func TestNewCmdDocs_HonorsKBEnv(t *testing.T) {
|
||||
iostreams.SetForTest(t)
|
||||
t.Setenv("WEKNORA_KB_ID", "kb_from_env")
|
||||
cmd := NewCmdDocs(&cmdutil.Factory{
|
||||
Client: func() (*sdk.Client, error) { return nil, errors.New("client boom") },
|
||||
})
|
||||
cmd.SetArgs([]string{"some query"}) // no --kb; env supplies it
|
||||
cmd.SilenceErrors = true
|
||||
cmd.SilenceUsage = true
|
||||
err := cmd.Execute()
|
||||
require.Error(t, err)
|
||||
assert.NotContains(t, err.Error(), "kb is required")
|
||||
assert.Contains(t, err.Error(), "client boom")
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
package search
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
"text/tabwriter"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/Tencent/WeKnora/cli/internal/cmdutil"
|
||||
"github.com/Tencent/WeKnora/cli/internal/iostreams"
|
||||
"github.com/Tencent/WeKnora/cli/internal/output"
|
||||
"github.com/Tencent/WeKnora/cli/internal/text"
|
||||
sdk "github.com/Tencent/WeKnora/client"
|
||||
)
|
||||
|
||||
// kbSearchFields enumerates the fields surfaced for `--format json` discovery on
|
||||
// `search kb`. Subset of KnowledgeBase suitable for list/filter results.
|
||||
var kbSearchFields = []string{
|
||||
"id", "name", "type", "description",
|
||||
"is_temporary", "is_pinned",
|
||||
"embedding_model_id", "summary_model_id",
|
||||
"knowledge_count", "chunk_count",
|
||||
"is_processing", "processing_count",
|
||||
"created_at", "updated_at",
|
||||
}
|
||||
|
||||
type KBSearchOptions struct {
|
||||
Query string
|
||||
Limit int
|
||||
}
|
||||
|
||||
// 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 profile.
|
||||
// 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)",
|
||||
Long: `Substring + case-insensitive match across KB names and descriptions visible
|
||||
to the active profile. Results are sorted by name length (shortest first;
|
||||
usually the closest hit) for deterministic output.
|
||||
|
||||
This is name-discovery only - for searching *inside* a knowledge base's
|
||||
content, use ` + "`weknora search chunks`" + `.`,
|
||||
Example: ` weknora search kb "marketing"
|
||||
weknora search kb "team" --limit 5 --format json`,
|
||||
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")
|
||||
}
|
||||
if opts.Limit < 1 || opts.Limit > 1000 {
|
||||
return cmdutil.NewError(cmdutil.CodeInputInvalidArgument, "--limit must be between 1 and 1000")
|
||||
}
|
||||
fopts, err := cmdutil.CheckFormatFlag(c)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
fopts.ResolveDefault(iostreams.IO.IsStdoutTTY())
|
||||
cli, err := f.Client()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return runKBSearch(c.Context(), opts, fopts, cli)
|
||||
},
|
||||
}
|
||||
cmd.Flags().IntVarP(&opts.Limit, "limit", "L", 30, "Maximum results to return")
|
||||
cmdutil.AddFormatFlag(cmd, kbSearchFields...)
|
||||
cmdutil.SetAgentHelp(cmd, cmdutil.AgentHelp{
|
||||
UsedFor: "Find knowledge bases by name or description (client-side case-insensitive substring match). Results come with meta.count; use --limit to cap. For searching content inside a KB, use 'search chunks' instead.",
|
||||
RequiredFlags: []string{"<query> (positional)"},
|
||||
Examples: []string{`weknora search kb "engineering" --format json`},
|
||||
Output: "envelope.data is an array of KnowledgeBase objects with id, name, knowledge_count; meta.count is the returned count; meta.has_more=true if more matched than --limit",
|
||||
})
|
||||
return cmd
|
||||
}
|
||||
|
||||
func runKBSearch(ctx context.Context, opts *KBSearchOptions, fopts *cmdutil.FormatOptions, svc KBSearchService) error {
|
||||
items, err := svc.ListKnowledgeBases(ctx)
|
||||
if err != nil {
|
||||
return cmdutil.WrapHTTP(err, "list knowledge bases")
|
||||
}
|
||||
matches := filterKBs(items, opts.Query)
|
||||
truncated := opts.Limit > 0 && len(matches) > opts.Limit
|
||||
if truncated {
|
||||
matches = matches[:opts.Limit]
|
||||
}
|
||||
|
||||
if fopts.WantsJSON() {
|
||||
if matches == nil {
|
||||
matches = []sdk.KnowledgeBase{}
|
||||
}
|
||||
meta := &output.Meta{Count: output.IntPtr(len(matches)), HasMore: truncated}
|
||||
return fopts.Emit(iostreams.IO.Out, matches, meta)
|
||||
}
|
||||
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,123 @@
|
||||
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/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}, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, 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}, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, 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}, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, 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}, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, 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_LimitHardCap(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}, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, 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}, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, 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}, &cmdutil.FormatOptions{Mode: cmdutil.FormatJSON}, svc))
|
||||
|
||||
got := out.String()
|
||||
var env struct {
|
||||
OK bool `json:"ok"`
|
||||
Data []sdk.KnowledgeBase `json:"data"`
|
||||
}
|
||||
require.NoError(t, json.Unmarshal([]byte(got), &env), "expected valid JSON envelope, got: %q", got)
|
||||
assert.True(t, env.OK, "envelope.ok must be true")
|
||||
assert.Contains(t, got, `"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}, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, svc)
|
||||
require.Error(t, err)
|
||||
var typed *cmdutil.Error
|
||||
require.ErrorAs(t, err, &typed)
|
||||
assert.Equal(t, cmdutil.CodeAuthUnauthenticated, typed.Code)
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
// Package search implements the `weknora search` command tree:
|
||||
// chunks / kb / docs / sessions.
|
||||
package search
|
||||
|
||||
import (
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/Tencent/WeKnora/cli/internal/cmdutil"
|
||||
)
|
||||
|
||||
// NewCmdSearch builds the `weknora search` parent. Pure dispatcher to the
|
||||
// four subcommands - users must pick a verb (chunks / kb / docs / sessions).
|
||||
func NewCmdSearch(f *cmdutil.Factory) *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "search",
|
||||
Short: "Search across chunks, knowledge bases, documents, or sessions",
|
||||
Long: `Verb-noun search tree:
|
||||
|
||||
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"`,
|
||||
}
|
||||
|
||||
cmd.AddCommand(NewCmdChunks(f))
|
||||
cmd.AddCommand(NewCmdKB(f))
|
||||
cmd.AddCommand(NewCmdDocs(f))
|
||||
cmd.AddCommand(NewCmdSessions(f))
|
||||
return cmd
|
||||
}
|
||||
|
||||
// emptyContentSearchHint returns an actionable note when a KB-scoped content
|
||||
// search (chunks / docs) yields zero results, so an agent can distinguish
|
||||
// "no match" from "the KB has no indexed content". Empty when n > 0 so it
|
||||
// never adds noise to real results.
|
||||
func emptyContentSearchHint(n int) string {
|
||||
if n > 0 {
|
||||
return ""
|
||||
}
|
||||
return "0 results: this may be no match, OR the KB has no indexed chunks. " +
|
||||
"Check `weknora kb status <kb>` (chunk_count) and `weknora doc list --kb <kb>` (parse_status); " +
|
||||
"documents in parse_status=draft are not indexed — run `weknora doc reparse <doc-id>`."
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
package search
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/Tencent/WeKnora/cli/internal/cmdutil"
|
||||
"github.com/Tencent/WeKnora/cli/internal/iostreams"
|
||||
)
|
||||
|
||||
// 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())
|
||||
}
|
||||
|
||||
// TestSearch_RejectsPositional: bare positional `weknora search "<q>" --kb X`
|
||||
// must error - search is a pure dispatcher with no shortcut form.
|
||||
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)
|
||||
}
|
||||
|
||||
// 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,184 @@
|
||||
package search
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
"text/tabwriter"
|
||||
"time"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/Tencent/WeKnora/cli/internal/cmdutil"
|
||||
"github.com/Tencent/WeKnora/cli/internal/iostreams"
|
||||
"github.com/Tencent/WeKnora/cli/internal/output"
|
||||
"github.com/Tencent/WeKnora/cli/internal/text"
|
||||
sdk "github.com/Tencent/WeKnora/client"
|
||||
)
|
||||
|
||||
// sessionsPageSize is the default --page-size on `search sessions`: how many
|
||||
// entries to pull per GetSessionsByTenant round-trip when paging through to
|
||||
// filter client-side. Tunable via --page-size in 1..1000.
|
||||
const sessionsPageSize = 200
|
||||
|
||||
// sessionsMaxPageSize bounds the --page-size flag, matching the session/doc list cap.
|
||||
const sessionsMaxPageSize = 1000
|
||||
|
||||
// sessionsSearchFields enumerates the fields surfaced for `--format json` discovery
|
||||
// on `search sessions`. Mirrors sdk.Session json tags.
|
||||
var sessionsSearchFields = []string{
|
||||
"id", "tenant_id", "title", "description", "created_at", "updated_at",
|
||||
}
|
||||
|
||||
type SessionsSearchOptions struct {
|
||||
Query string
|
||||
Limit int
|
||||
// PageSize is the server batch size per GetSessionsByTenant call
|
||||
// (1..1000, default 200). Tunable so a caller searching a small set
|
||||
// can fetch everything in one round-trip.
|
||||
PageSize int
|
||||
// AllPages walks server pages internally until total exhausted or
|
||||
// --limit accumulated. Default true; setting false stops after the
|
||||
// first page (useful for cheap previews).
|
||||
AllPages 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)",
|
||||
Long: `Pages through the tenant's chat sessions and surfaces every entry whose
|
||||
title or description contains the query (case-insensitive).
|
||||
|
||||
By default, --all-pages=true walks every server page until --limit is
|
||||
reached or the tenant's sessions are exhausted. Pass --all-pages=false
|
||||
to stop after one page.`,
|
||||
Example: ` weknora search sessions "onboarding"
|
||||
weknora search sessions "Q3 review" --limit 3 --format json
|
||||
weknora search sessions "Q3 review" --all-pages=false`,
|
||||
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")
|
||||
}
|
||||
if opts.Limit < 1 || opts.Limit > 1000 {
|
||||
return cmdutil.NewError(cmdutil.CodeInputInvalidArgument, "--limit must be between 1 and 1000")
|
||||
}
|
||||
fopts, err := cmdutil.CheckFormatFlag(c)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
fopts.ResolveDefault(iostreams.IO.IsStdoutTTY())
|
||||
cli, err := f.Client()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return runSessionsSearch(c.Context(), opts, fopts, cli)
|
||||
},
|
||||
}
|
||||
cmd.Flags().IntVarP(&opts.Limit, "limit", "L", 30, "Maximum results to return")
|
||||
cmd.Flags().IntVar(&opts.PageSize, "page-size", sessionsPageSize, "Items per server batch (1..1000)")
|
||||
cmd.Flags().BoolVar(&opts.AllPages, "all-pages", true, "Walk every server page until exhausted or --limit hit")
|
||||
cmdutil.AddFormatFlag(cmd, sessionsSearchFields...)
|
||||
cmdutil.SetAgentHelp(cmd, cmdutil.AgentHelp{
|
||||
UsedFor: "Find chat sessions by title or description (client-side case-insensitive substring match). Results come with meta.count; use --limit to cap and --all-pages=false to stop after one page.",
|
||||
RequiredFlags: []string{"<query> (positional)"},
|
||||
Examples: []string{`weknora search sessions "onboarding" --format json`},
|
||||
Output: "envelope.data is an array of Session objects with id, title, updated_at; meta.count is the returned count; meta.has_more=true if more matched than --limit",
|
||||
})
|
||||
return cmd
|
||||
}
|
||||
|
||||
func runSessionsSearch(ctx context.Context, opts *SessionsSearchOptions, fopts *cmdutil.FormatOptions, svc SessionsSearchService) error {
|
||||
if opts.PageSize < 1 || opts.PageSize > sessionsMaxPageSize {
|
||||
return cmdutil.NewError(cmdutil.CodeInputInvalidArgument,
|
||||
fmt.Sprintf("--page-size must be in 1..%d, got %d", sessionsMaxPageSize, opts.PageSize))
|
||||
}
|
||||
needle := strings.ToLower(opts.Query)
|
||||
var matches []sdk.Session
|
||||
var received int // count of server-returned items so far (separate from
|
||||
// matches, which is client-filtered). Used for termination so a
|
||||
// server-capped page_size doesn't cause early break.
|
||||
|
||||
// --all-pages=true (default) walks every server page; --all-pages=false
|
||||
// stops after the first page.
|
||||
for page := 1; ; page++ {
|
||||
items, total, err := svc.GetSessionsByTenant(ctx, page, opts.PageSize)
|
||||
if err != nil {
|
||||
return cmdutil.WrapHTTP(err, "list sessions")
|
||||
}
|
||||
received += len(items)
|
||||
for _, s := range items {
|
||||
if matchSession(s, needle) {
|
||||
matches = append(matches, s)
|
||||
// Collect one past --limit so has_more is accurate; trimmed below.
|
||||
if opts.Limit > 0 && len(matches) > opts.Limit {
|
||||
goto done
|
||||
}
|
||||
}
|
||||
}
|
||||
if !opts.AllPages {
|
||||
break
|
||||
}
|
||||
if received >= total || len(items) == 0 {
|
||||
break
|
||||
}
|
||||
}
|
||||
done:
|
||||
sortSessionsByRecency(matches)
|
||||
truncated := opts.Limit > 0 && len(matches) > opts.Limit
|
||||
if truncated {
|
||||
matches = matches[:opts.Limit]
|
||||
}
|
||||
|
||||
if fopts.WantsJSON() {
|
||||
if matches == nil {
|
||||
matches = []sdk.Session{}
|
||||
}
|
||||
meta := &output.Meta{Count: output.IntPtr(len(matches)), HasMore: truncated}
|
||||
return fopts.Emit(iostreams.IO.Out, matches, meta)
|
||||
}
|
||||
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")
|
||||
now := time.Now()
|
||||
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, text.FuzzyAgoStr(now, 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,143 @@
|
||||
package search
|
||||
|
||||
import (
|
||||
"context"
|
||||
"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/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, PageSize: sessionsPageSize, AllPages: true}, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, 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, PageSize: sessionsPageSize, AllPages: true}, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, 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, PageSize: sessionsPageSize, AllPages: true}, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, 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, PageSize: sessionsPageSize, AllPages: true}, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, svc)
|
||||
require.Error(t, err)
|
||||
var typed *cmdutil.Error
|
||||
require.ErrorAs(t, err, &typed)
|
||||
assert.Equal(t, cmdutil.CodeServerError, typed.Code)
|
||||
}
|
||||
|
||||
// TestSessionsSearch_RendersFuzzyTime is a regression guard for the v0.5
|
||||
// audit bug: `search sessions` printed UpdatedAt as the raw RFC3339 string
|
||||
// while `session list` ran it through text.FuzzyAgoStr — same SDK field,
|
||||
// two human renderings. Asserts the human output now renders relative time
|
||||
// (and does NOT contain the RFC3339 "T" date/time separator).
|
||||
func TestSessionsSearch_RendersFuzzyTime(t *testing.T) {
|
||||
out, _ := iostreams.SetForTest(t)
|
||||
svc := &fakeSessionsSearchSvc{
|
||||
pages: map[int][]sdk.Session{1: {
|
||||
{ID: "s1", Title: "needle", UpdatedAt: time.Now().Add(-2 * time.Hour).Format(time.RFC3339)},
|
||||
}},
|
||||
total: 1,
|
||||
}
|
||||
require.NoError(t, runSessionsSearch(context.Background(), &SessionsSearchOptions{Query: "needle", Limit: 10, PageSize: sessionsPageSize, AllPages: true}, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, svc))
|
||||
body := out.String()
|
||||
assert.Contains(t, body, "hour", "must render relative time (e.g. 'about 2 hours ago'), not raw RFC3339")
|
||||
assert.NotContains(t, body, "T0", "raw RFC3339 has 'T' between date and time; fuzzyTime output should not")
|
||||
}
|
||||
|
||||
// TestSearchSessions_AllPagesFlag_DefaultsTrue_WalksAllPages locks in that
|
||||
// the historic walk-all-pages behavior is preserved when --all-pages is left
|
||||
// at its default (true). Three pages of fake data with matches on each;
|
||||
// the run must request more than one page.
|
||||
func TestSearchSessions_AllPagesFlag_DefaultsTrue_WalksAllPages(t *testing.T) {
|
||||
_, _ = iostreams.SetForTest(t)
|
||||
svc := &fakeSessionsSearchSvc{
|
||||
pages: map[int][]sdk.Session{
|
||||
1: {{ID: "s1", Title: "needle"}, {ID: "s2", Title: "needle"}},
|
||||
2: {{ID: "s3", Title: "needle"}},
|
||||
3: {},
|
||||
},
|
||||
total: 3,
|
||||
}
|
||||
opts := &SessionsSearchOptions{Query: "needle", Limit: 100, PageSize: 2, AllPages: true}
|
||||
require.NoError(t, runSessionsSearch(context.Background(), opts, &cmdutil.FormatOptions{Mode: cmdutil.FormatJSON}, svc))
|
||||
assert.GreaterOrEqual(t, len(svc.calls), 2, "must walk multi pages by default")
|
||||
}
|
||||
|
||||
// TestSearchSessions_AllPagesFalse_StopsAtFirstPage asserts that
|
||||
// --all-pages=false caps server round-trips at one even when the server
|
||||
// reports far more items available. New v0.5 opt-out for the walk-all default.
|
||||
func TestSearchSessions_AllPagesFalse_StopsAtFirstPage(t *testing.T) {
|
||||
_, _ = iostreams.SetForTest(t)
|
||||
svc := &fakeSessionsSearchSvc{
|
||||
pages: map[int][]sdk.Session{1: {{ID: "s1", Title: "needle"}, {ID: "s2", Title: "needle"}}},
|
||||
total: 100,
|
||||
}
|
||||
opts := &SessionsSearchOptions{Query: "needle", Limit: 100, PageSize: 2, AllPages: false}
|
||||
require.NoError(t, runSessionsSearch(context.Background(), opts, &cmdutil.FormatOptions{Mode: cmdutil.FormatJSON}, svc))
|
||||
assert.Len(t, svc.calls, 1, "must stop at first page when --all-pages=false")
|
||||
}
|
||||
|
||||
// TestSearchSessions_PageSizeBound asserts the 1..1000 range guard mirrors
|
||||
// the session/doc list cap. Out-of-range values must produce
|
||||
// input.invalid_argument and never reach the SDK.
|
||||
func TestSearchSessions_PageSizeBound(t *testing.T) {
|
||||
for _, ps := range []int{0, -1, 1001} {
|
||||
err := runSessionsSearch(context.Background(), &SessionsSearchOptions{Query: "t", Limit: 50, PageSize: ps}, &cmdutil.FormatOptions{Mode: cmdutil.FormatJSON}, &fakeSessionsSearchSvc{})
|
||||
require.Error(t, err)
|
||||
var typed *cmdutil.Error
|
||||
require.ErrorAs(t, err, &typed)
|
||||
assert.Equal(t, cmdutil.CodeInputInvalidArgument, typed.Code, "page_size=%d", ps)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user