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,161 @@
|
||||
package kb
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/Tencent/WeKnora/cli/internal/cmdutil"
|
||||
"github.com/Tencent/WeKnora/cli/internal/iostreams"
|
||||
sdk "github.com/Tencent/WeKnora/client"
|
||||
)
|
||||
|
||||
// CheckResult is the deep-verification response for `kb check <id>`.
|
||||
// Superset of StatusResult: includes all status fields plus FailedCount
|
||||
// aggregated by paging the doc list. Verb split with `kb status`:
|
||||
// status reads existing state cheaply, check actively verifies.
|
||||
type CheckResult struct {
|
||||
ID string `json:"id"`
|
||||
Reachable bool `json:"reachable"`
|
||||
// RetrievalReady is false when no embedding model is bound — the KB can never
|
||||
// index/retrieve regardless of failed_count. Always emitted (no omitempty) so
|
||||
// an unconfigured KB is not reported as silently healthy.
|
||||
RetrievalReady bool `json:"retrieval_ready"`
|
||||
KnowledgeCount int64 `json:"knowledge_count,omitempty"`
|
||||
ChunkCount int64 `json:"chunk_count,omitempty"`
|
||||
IsProcessing bool `json:"is_processing,omitempty"`
|
||||
ProcessingCount int64 `json:"processing_count,omitempty"`
|
||||
FailedCount int64 `json:"failed_count"` // always populated (no omitempty)
|
||||
}
|
||||
|
||||
// CheckService is the narrow SDK surface needed for kb check.
|
||||
type CheckService interface {
|
||||
GetKnowledgeBase(ctx context.Context, id string) (*sdk.KnowledgeBase, error)
|
||||
ListKnowledgeWithFilter(ctx context.Context, kbID string, page, pageSize int, filter sdk.KnowledgeListFilter) ([]sdk.Knowledge, int64, error)
|
||||
}
|
||||
|
||||
var kbCheckFields = []string{
|
||||
"id", "reachable", "retrieval_ready", "knowledge_count", "chunk_count",
|
||||
"is_processing", "processing_count", "failed_count",
|
||||
}
|
||||
|
||||
// NewCmdCheck builds `weknora kb check <id>`.
|
||||
func NewCmdCheck(f *cmdutil.Factory) *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "check <kb-id>",
|
||||
Short: "Verify a knowledge base end-to-end (status + failed-doc aggregation)",
|
||||
Long: `Active verification of a knowledge base.
|
||||
|
||||
Performs 1 + N HTTP calls:
|
||||
1 GET /kb/{id} — reachable + counts + processing state
|
||||
N page-walk doc list with parse_status=failed — failed_count
|
||||
|
||||
Use 'weknora kb status <id>' for a fast read-only health snapshot
|
||||
(1 HTTP call, no failed_count). Use 'kb check' when you need
|
||||
verification including failed-doc aggregation.`,
|
||||
Example: ` weknora kb check kb_abc
|
||||
weknora kb check kb_abc --format json`,
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: func(c *cobra.Command, args []string) error {
|
||||
fopts, err := cmdutil.CheckFormatFlag(c)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
fopts.ResolveDefault(iostreams.IO.IsStdoutTTY())
|
||||
cli, err := f.Client()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
res, err := runCheck(c.Context(), cli, args[0])
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return emitCheck(res, fopts, iostreams.IO.Out)
|
||||
},
|
||||
}
|
||||
cmdutil.AddFormatFlag(cmd, kbCheckFields...)
|
||||
cmdutil.SetAgentHelp(cmd, cmdutil.AgentHelp{
|
||||
UsedFor: "verify a knowledge base end-to-end: status plus failed-doc aggregation",
|
||||
RequiredFlags: []string{"<kb-id> (positional)"},
|
||||
Examples: []string{"weknora kb check kb_abc"},
|
||||
Output: "envelope.data is {id, reachable, retrieval_ready, failed_count, ...}; retrieval_ready=false means no embedding model is bound (run `kb config set`); deeper than `kb status`",
|
||||
})
|
||||
return cmd
|
||||
}
|
||||
|
||||
// runCheck is the testable core: status fields + failed-doc aggregation.
|
||||
// Never returns an error for "kb not reachable" (Reachable=false carries
|
||||
// the signal).
|
||||
func runCheck(ctx context.Context, svc CheckService, id string) (*CheckResult, error) {
|
||||
kb, err := svc.GetKnowledgeBase(ctx, id)
|
||||
if err != nil {
|
||||
return &CheckResult{ID: id, Reachable: false}, nil
|
||||
}
|
||||
res := &CheckResult{
|
||||
ID: kb.ID,
|
||||
Reachable: true,
|
||||
RetrievalReady: kb.EmbeddingModelID != "",
|
||||
KnowledgeCount: kb.KnowledgeCount,
|
||||
ChunkCount: kb.ChunkCount,
|
||||
IsProcessing: kb.IsProcessing,
|
||||
ProcessingCount: kb.ProcessingCount,
|
||||
}
|
||||
failed, err := aggregateFailedCount(ctx, svc, id)
|
||||
if err != nil {
|
||||
return res, fmt.Errorf("aggregate failed_count: %w", err)
|
||||
}
|
||||
res.FailedCount = failed
|
||||
return res, nil
|
||||
}
|
||||
|
||||
// aggregateFailedCount pages the doc list with parse_status=failed and
|
||||
// returns the total count. Termination uses accumulated total (not
|
||||
// page*pageSize) so server-capped page sizes don't truncate.
|
||||
func aggregateFailedCount(ctx context.Context, svc CheckService, kbID string) (int64, error) {
|
||||
const pageSize = 100
|
||||
var total int64
|
||||
page := 1
|
||||
for {
|
||||
docs, totalAvailable, err := svc.ListKnowledgeWithFilter(ctx, kbID, page, pageSize, sdk.KnowledgeListFilter{ParseStatus: "failed"})
|
||||
if err != nil {
|
||||
return total, err
|
||||
}
|
||||
total += int64(len(docs))
|
||||
if total >= totalAvailable || len(docs) == 0 {
|
||||
break
|
||||
}
|
||||
page++
|
||||
}
|
||||
return total, nil
|
||||
}
|
||||
|
||||
// emitCheck renders res. Same dispatch as emitStatus.
|
||||
func emitCheck(res *CheckResult, fopts *cmdutil.FormatOptions, w io.Writer) error {
|
||||
switch fopts.Mode {
|
||||
case cmdutil.FormatJSON, cmdutil.FormatNDJSON:
|
||||
return fopts.Emit(w, res, nil)
|
||||
case cmdutil.FormatText, "":
|
||||
return writeCheckText(w, res)
|
||||
default:
|
||||
return fmt.Errorf("unsupported --format %q for kb check", fopts.Mode)
|
||||
}
|
||||
}
|
||||
|
||||
func writeCheckText(w io.Writer, res *CheckResult) error {
|
||||
fmt.Fprintf(w, "ID: %s\n", res.ID)
|
||||
fmt.Fprintf(w, "Reachable: %v\n", res.Reachable)
|
||||
if !res.Reachable {
|
||||
return nil
|
||||
}
|
||||
fmt.Fprintf(w, "Retrieval: %v%s\n", res.RetrievalReady, retrievalHint(res.RetrievalReady))
|
||||
fmt.Fprintf(w, "Knowledge: %d\n", res.KnowledgeCount)
|
||||
fmt.Fprintf(w, "Chunks: %d\n", res.ChunkCount)
|
||||
fmt.Fprintf(w, "Processing: %v (%d active)\n", res.IsProcessing, res.ProcessingCount)
|
||||
fmt.Fprintf(w, "Failed: %d\n", res.FailedCount)
|
||||
return nil
|
||||
}
|
||||
|
||||
// compile-time check: SDK client satisfies CheckService.
|
||||
var _ CheckService = (*sdk.Client)(nil)
|
||||
@@ -0,0 +1,135 @@
|
||||
package kb
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/Tencent/WeKnora/cli/internal/cmdutil"
|
||||
sdk "github.com/Tencent/WeKnora/client"
|
||||
)
|
||||
|
||||
type fakeCheckSvc struct {
|
||||
kb *sdk.KnowledgeBase
|
||||
getErr error
|
||||
failedDocs []sdk.Knowledge // returned by ListKnowledgeWithFilter when ParseStatus=failed
|
||||
listErr error
|
||||
}
|
||||
|
||||
func (f *fakeCheckSvc) GetKnowledgeBase(_ context.Context, id string) (*sdk.KnowledgeBase, error) {
|
||||
if f.getErr != nil {
|
||||
return nil, f.getErr
|
||||
}
|
||||
return f.kb, nil
|
||||
}
|
||||
|
||||
func (f *fakeCheckSvc) ListKnowledgeWithFilter(_ context.Context, _ string, page, pageSize int, _ sdk.KnowledgeListFilter) ([]sdk.Knowledge, int64, error) {
|
||||
if f.listErr != nil {
|
||||
return nil, 0, f.listErr
|
||||
}
|
||||
// Single-page fake: return all docs at once (page 1), empty thereafter.
|
||||
if page == 1 {
|
||||
return f.failedDocs, int64(len(f.failedDocs)), nil
|
||||
}
|
||||
return nil, int64(len(f.failedDocs)), nil
|
||||
}
|
||||
|
||||
func TestRunCheck_AggregatesFailed(t *testing.T) {
|
||||
svc := &fakeCheckSvc{
|
||||
kb: &sdk.KnowledgeBase{ID: "kb_x", KnowledgeCount: 5, ChunkCount: 20, EmbeddingModelID: "emb_1"},
|
||||
failedDocs: []sdk.Knowledge{
|
||||
{ID: "d1", ParseStatus: "failed"},
|
||||
{ID: "d2", ParseStatus: "failed"},
|
||||
},
|
||||
}
|
||||
res, err := runCheck(context.Background(), svc, "kb_x")
|
||||
if err != nil {
|
||||
t.Fatalf("runCheck: %v", err)
|
||||
}
|
||||
if res.FailedCount != 2 {
|
||||
t.Errorf("FailedCount=%d, want 2", res.FailedCount)
|
||||
}
|
||||
if !res.Reachable {
|
||||
t.Error("Reachable=false, want true")
|
||||
}
|
||||
if !res.RetrievalReady {
|
||||
t.Error("RetrievalReady=false, want true when an embedding model is bound")
|
||||
}
|
||||
if res.KnowledgeCount != 5 || res.ChunkCount != 20 {
|
||||
t.Errorf("got %+v", res)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunCheck_Unreachable(t *testing.T) {
|
||||
svc := &fakeCheckSvc{getErr: fmt.Errorf("404 not found")}
|
||||
res, err := runCheck(context.Background(), svc, "kb_x")
|
||||
if err != nil {
|
||||
t.Fatalf("runCheck should not return err on unreachable; got %v", err)
|
||||
}
|
||||
if res.Reachable {
|
||||
t.Error("Reachable=true, want false")
|
||||
}
|
||||
if res.ID != "kb_x" {
|
||||
t.Errorf("ID=%q, want kb_x (echoed even when unreachable)", res.ID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunCheck_NoFailedDocs(t *testing.T) {
|
||||
svc := &fakeCheckSvc{
|
||||
kb: &sdk.KnowledgeBase{ID: "kb_y", KnowledgeCount: 10},
|
||||
failedDocs: nil,
|
||||
}
|
||||
res, err := runCheck(context.Background(), svc, "kb_y")
|
||||
if err != nil {
|
||||
t.Fatalf("runCheck: %v", err)
|
||||
}
|
||||
if res.FailedCount != 0 {
|
||||
t.Errorf("FailedCount=%d, want 0", res.FailedCount)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEmitCheck_JSON(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
res := &CheckResult{ID: "kb_x", Reachable: true, KnowledgeCount: 5, FailedCount: 3}
|
||||
fopts := &cmdutil.FormatOptions{Mode: cmdutil.FormatJSON}
|
||||
if err := emitCheck(res, fopts, &buf); err != nil {
|
||||
t.Fatalf("emitCheck: %v", err)
|
||||
}
|
||||
var env struct {
|
||||
OK bool `json:"ok"`
|
||||
Data CheckResult `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(buf.Bytes(), &env); err != nil {
|
||||
t.Fatalf("not JSON: %v\n%s", err, buf.String())
|
||||
}
|
||||
got := env.Data
|
||||
if got.ID != "kb_x" || got.KnowledgeCount != 5 || got.FailedCount != 3 {
|
||||
t.Errorf("got %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEmitCheck_Text(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
res := &CheckResult{
|
||||
ID: "kb_x", Reachable: true,
|
||||
KnowledgeCount: 5, ChunkCount: 20,
|
||||
IsProcessing: true, ProcessingCount: 1,
|
||||
FailedCount: 2,
|
||||
}
|
||||
fopts := &cmdutil.FormatOptions{Mode: cmdutil.FormatText}
|
||||
if err := emitCheck(res, fopts, &buf); err != nil {
|
||||
t.Fatalf("emitCheck: %v", err)
|
||||
}
|
||||
for _, want := range []string{"kb_x", "5", "20", "true", "2"} {
|
||||
if !strings.Contains(buf.String(), want) {
|
||||
t.Errorf("output missing %q:\n%s", want, buf.String())
|
||||
}
|
||||
}
|
||||
// "Failed:" line must always appear for check
|
||||
if !strings.Contains(buf.String(), "Failed:") {
|
||||
t.Errorf("output missing 'Failed:' line:\n%s", buf.String())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
package kb
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/Tencent/WeKnora/cli/internal/cmdutil"
|
||||
"github.com/Tencent/WeKnora/cli/internal/iostreams"
|
||||
sdk "github.com/Tencent/WeKnora/client"
|
||||
)
|
||||
|
||||
// kbConfigFields enumerates the fields surfaced for `--format json` discovery on
|
||||
// `kb config`. Mirrors client.KBModelConfigView (secret-free — no api keys).
|
||||
var kbConfigFields = []string{
|
||||
"retrieval_ready", "embedding", "llm", "rerank", "multimodal",
|
||||
}
|
||||
|
||||
// ConfigService is the narrow SDK surface this command depends on.
|
||||
type ConfigService interface {
|
||||
GetInitializationConfig(ctx context.Context, kbID string) (*sdk.KBModelConfigView, error)
|
||||
}
|
||||
|
||||
// NewCmdConfig builds `weknora kb config <kb-id>` — read-only inspection of a
|
||||
// knowledge base's model configuration. Write it with `weknora kb config set`.
|
||||
func NewCmdConfig(f *cmdutil.Factory) *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "config <kb-id>",
|
||||
Short: "Show a knowledge base's model configuration (set it with `config set`)",
|
||||
Long: `Show the model configuration bound to a knowledge base: embedding, llm
|
||||
(chat), rerank, and multimodal model names + source. retrieval_ready is false
|
||||
until an embedding model is bound — configure it with 'weknora kb config set'.
|
||||
Provider API keys are never shown.`,
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: func(c *cobra.Command, args []string) error {
|
||||
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 runConfig(c.Context(), fopts, cli, args[0])
|
||||
},
|
||||
}
|
||||
// `kb config` reads (this command's RunE); `kb config set` writes. Same
|
||||
// read/write pairing as mainstream config surfaces.
|
||||
cmd.AddCommand(newKBModelWriteCmd(f, "set <kb-id>", []string{"weknora", "kb", "config", "set"}))
|
||||
cmdutil.AddFormatFlag(cmd, kbConfigFields...)
|
||||
cmdutil.SetAgentHelp(cmd, cmdutil.AgentHelp{
|
||||
UsedFor: "show a KB's model config (embedding/llm/rerank/multimodal by name, secret-free). retrieval_ready=false => run `weknora kb config set`. Write config with `weknora kb config set`.",
|
||||
RequiredFlags: []string{"<kb-id> (positional)"},
|
||||
Examples: []string{"weknora kb config kb_abc --jq .data.embedding_model_id"},
|
||||
Output: "envelope.data is {retrieval_ready, embedding{configured,model_name,source,dimension}, llm{...}, rerank{enabled,model_name}, multimodal{enabled}} — secret-free (no provider api keys)",
|
||||
})
|
||||
return cmd
|
||||
}
|
||||
|
||||
func runConfig(ctx context.Context, fopts *cmdutil.FormatOptions, svc ConfigService, kbID string) error {
|
||||
cfg, err := svc.GetInitializationConfig(ctx, kbID)
|
||||
if err != nil {
|
||||
return cmdutil.WrapHTTP(err, "get config for knowledge base %q", kbID)
|
||||
}
|
||||
if cfg == nil {
|
||||
cfg = &sdk.KBModelConfigView{}
|
||||
}
|
||||
if fopts.WantsJSON() {
|
||||
return fopts.Emit(iostreams.IO.Out, cfg, nil)
|
||||
}
|
||||
w := iostreams.IO.Out
|
||||
fmt.Fprintf(w, "%-13s %v\n", "RETRIEVAL:", readyLabel(cfg.RetrievalReady))
|
||||
fmt.Fprintf(w, "%-13s %s\n", "EMBEDDING:", slotLabel(cfg.Embedding))
|
||||
fmt.Fprintf(w, "%-13s %s\n", "CHAT (LLM):", slotLabel(cfg.LLM))
|
||||
fmt.Fprintf(w, "%-13s %s\n", "RERANK:", rerankLabel(cfg.Rerank))
|
||||
fmt.Fprintf(w, "%-13s %v\n", "MULTIMODAL:", cfg.Multimodal.Enabled)
|
||||
if !cfg.RetrievalReady {
|
||||
fmt.Fprintln(w, "\n(not retrieval-ready — no embedding model; run `weknora kb config set <kb-id> --chat-model <id> --embedding-model <id>`)")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func readyLabel(ready bool) string {
|
||||
if ready {
|
||||
return "ready"
|
||||
}
|
||||
return "NOT ready (no embedding model)"
|
||||
}
|
||||
|
||||
func slotLabel(s sdk.ModelSlotView) string {
|
||||
if !s.Configured {
|
||||
return "(unset)"
|
||||
}
|
||||
if s.Source != "" {
|
||||
return fmt.Sprintf("%s (%s)", s.ModelName, s.Source)
|
||||
}
|
||||
return s.ModelName
|
||||
}
|
||||
|
||||
func rerankLabel(r sdk.RerankSlotView) string {
|
||||
if !r.Enabled {
|
||||
return "(disabled)"
|
||||
}
|
||||
return r.ModelName
|
||||
}
|
||||
|
||||
// compile-time check: the production SDK client implements ConfigService.
|
||||
var _ ConfigService = (*sdk.Client)(nil)
|
||||
@@ -0,0 +1,166 @@
|
||||
package kb
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/Tencent/WeKnora/cli/internal/cmdutil"
|
||||
"github.com/Tencent/WeKnora/cli/internal/iostreams"
|
||||
sdk "github.com/Tencent/WeKnora/client"
|
||||
)
|
||||
|
||||
type ConfigSetOptions struct {
|
||||
ChatModel string
|
||||
EmbeddingModel string
|
||||
Yes bool
|
||||
DryRun bool
|
||||
}
|
||||
|
||||
// ConfigSetService is the narrow SDK surface this command depends on. SetKBModelConfig
|
||||
// points the KB at already-registered models; GetInitializationConfig re-reads
|
||||
// the server's resulting state so the success envelope reflects what stuck.
|
||||
type ConfigSetService interface {
|
||||
SetKBModelConfig(ctx context.Context, kbID string, cfg *sdk.KBModelConfig) error
|
||||
GetInitializationConfig(ctx context.Context, kbID string) (*sdk.KBModelConfigView, error)
|
||||
}
|
||||
|
||||
// newKBModelWriteCmd builds the `kb config set` model-binding write command.
|
||||
// head is the argv prefix used for the risk action and retry_argv (weknora kb
|
||||
// config set).
|
||||
func newKBModelWriteCmd(f *cmdutil.Factory, use string, head []string) *cobra.Command {
|
||||
opts := &ConfigSetOptions{}
|
||||
action := strings.Join(head[1:], ".") // e.g. "kb.config.set"
|
||||
cmd := &cobra.Command{
|
||||
Use: use,
|
||||
Short: "Bind embedding + chat models to a knowledge base (make it usable)",
|
||||
Long: `Bind already-registered models to a knowledge base so it can embed, retrieve,
|
||||
and generate. Both --chat-model (LLM, used for generation/summary) and
|
||||
--embedding-model (used for retrieval) are required; register models first with
|
||||
'weknora model create' and discover ids with 'weknora model list'.
|
||||
|
||||
High-risk write: changing a KB's embedding model affects how its content is
|
||||
indexed and searched (the server refuses to CHANGE it once the KB has
|
||||
documents; setting it on an unconfigured KB is allowed). Without -y/--yes in a
|
||||
non-TTY / JSON context it exits 10 (input.confirmation_required) without
|
||||
applying the change.`,
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: func(c *cobra.Command, args []string) error {
|
||||
fopts, err := cmdutil.CheckFormatFlag(c)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
fopts.ResolveDefault(iostreams.IO.IsStdoutTTY())
|
||||
opts.Yes, _ = c.Flags().GetBool("yes")
|
||||
kbID := args[0]
|
||||
// Validate required flags before the dry-run gate so --dry-run rejects
|
||||
// identically to the live path.
|
||||
if err := validateConfigSetFlags(opts); err != nil {
|
||||
return err
|
||||
}
|
||||
if handled, err := cmdutil.HandleDryRun(c, opts.DryRun, cmdutil.DryRunPlan{
|
||||
Action: action,
|
||||
Args: map[string]any{"kb": kbID, "chat_model": opts.ChatModel, "embedding_model": opts.EmbeddingModel},
|
||||
}); handled {
|
||||
return err
|
||||
}
|
||||
cli, err := f.Client()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := cmdutil.ConfirmDestructive(f.Prompter(), opts.Yes, fopts.WantsJSON(),
|
||||
"configure", "knowledge base", kbID, action,
|
||||
cmdutil.BuildRetryArgv(c, append(append([]string{}, head...), kbID), "chat-model", "embedding-model", "format")); err != nil {
|
||||
return err
|
||||
}
|
||||
// Resolve name-or-id for the model flags (a UUID passes through; a
|
||||
// name is looked up among models of the expected type). Network read
|
||||
// on the live path only — the dry-run above shows the raw refs.
|
||||
if opts.ChatModel, err = cmdutil.ResolveModelRef(c.Context(), cli, opts.ChatModel, "KnowledgeQA"); err != nil {
|
||||
return err
|
||||
}
|
||||
if opts.EmbeddingModel, err = cmdutil.ResolveModelRef(c.Context(), cli, opts.EmbeddingModel, "Embedding"); err != nil {
|
||||
return err
|
||||
}
|
||||
return runConfigSet(c.Context(), opts, fopts, cli, kbID)
|
||||
},
|
||||
}
|
||||
cmd.Flags().StringVar(&opts.ChatModel, "chat-model", "", "Chat / LLM model id or name for generation & summary (required) — see `weknora model list`")
|
||||
cmd.Flags().StringVar(&opts.EmbeddingModel, "embedding-model", "", "Embedding model id or name for retrieval (required) — see `weknora model list`")
|
||||
cmdutil.AddFormatFlag(cmd, kbConfigFields...)
|
||||
cmdutil.AddDryRunFlag(cmd, &opts.DryRun)
|
||||
cmdutil.SetRisk(cmd, action)
|
||||
cmdutil.SetAgentHelp(cmd, cmdutil.AgentHelp{
|
||||
UsedFor: "bind models to a KB so it becomes retrieval-ready. --chat-model and --embedding-model are required and accept a model id or name; discover them with `weknora model list`. Read the result back with `weknora kb config`.",
|
||||
RequiredFlags: []string{"<kb-id> (positional)", "--chat-model", "--embedding-model"},
|
||||
Examples: []string{
|
||||
"weknora kb config set kb_abc --chat-model model_llm --embedding-model model_emb -y",
|
||||
},
|
||||
Output: "envelope.data is the resulting secret-free config view {retrieval_ready, embedding, llm, rerank, multimodal}",
|
||||
Warnings: []string{
|
||||
"Requires explicit user approval (exit 10 / input.confirmation_required); never auto-add -y.",
|
||||
"The server refuses to CHANGE the embedding model of a KB that already has documents (setting it on an unconfigured KB is fine).",
|
||||
},
|
||||
})
|
||||
return cmd
|
||||
}
|
||||
|
||||
func validateConfigSetFlags(opts *ConfigSetOptions) error {
|
||||
var missing []string
|
||||
if strings.TrimSpace(opts.ChatModel) == "" {
|
||||
missing = append(missing, "--chat-model")
|
||||
}
|
||||
if strings.TrimSpace(opts.EmbeddingModel) == "" {
|
||||
missing = append(missing, "--embedding-model")
|
||||
}
|
||||
if len(missing) == 0 {
|
||||
return nil
|
||||
}
|
||||
return &cmdutil.Error{
|
||||
Code: cmdutil.CodeInputMissingFlag,
|
||||
Message: "kb config set requires " + strings.Join(missing, " and "),
|
||||
Hint: "discover model ids with `weknora model list` (or register one with `weknora model create`), then pass --chat-model <id> --embedding-model <id>",
|
||||
}
|
||||
}
|
||||
|
||||
func runConfigSet(ctx context.Context, opts *ConfigSetOptions, fopts *cmdutil.FormatOptions, svc ConfigSetService, kbID string) error {
|
||||
if err := validateConfigSetFlags(opts); err != nil {
|
||||
return err
|
||||
}
|
||||
cfg := &sdk.KBModelConfig{
|
||||
LLMModelID: opts.ChatModel,
|
||||
EmbeddingModelID: opts.EmbeddingModel,
|
||||
}
|
||||
if err := svc.SetKBModelConfig(ctx, kbID, cfg); err != nil {
|
||||
return cmdutil.WrapHTTP(err, "configure knowledge base %q", kbID)
|
||||
}
|
||||
// Re-read the server's resulting state (secret-free view) so the envelope
|
||||
// reflects what stuck — the same shape `kb config` returns.
|
||||
result, err := svc.GetInitializationConfig(ctx, kbID)
|
||||
if err != nil || result == nil {
|
||||
// The write succeeded; surface what we applied if the read-back failed.
|
||||
result = &sdk.KBModelConfigView{
|
||||
RetrievalReady: opts.EmbeddingModel != "",
|
||||
Embedding: sdk.ModelSlotView{Configured: opts.EmbeddingModel != "", ModelName: opts.EmbeddingModel},
|
||||
LLM: sdk.ModelSlotView{Configured: opts.ChatModel != "", ModelName: opts.ChatModel},
|
||||
}
|
||||
}
|
||||
if fopts.WantsJSON() {
|
||||
return fopts.Emit(iostreams.IO.Out, result, nil)
|
||||
}
|
||||
fmt.Fprintf(iostreams.IO.Out, "✓ Configured knowledge base %s (chat: %s, embedding: %s)\n",
|
||||
kbID, orUnset(result.LLM.ModelName), orUnset(result.Embedding.ModelName))
|
||||
return nil
|
||||
}
|
||||
|
||||
func orUnset(s string) string {
|
||||
if s == "" {
|
||||
return "(unset)"
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// compile-time check: the production SDK client implements ConfigSetService.
|
||||
var _ ConfigSetService = (*sdk.Client)(nil)
|
||||
@@ -0,0 +1,138 @@
|
||||
package kb
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/Tencent/WeKnora/cli/internal/cmdutil"
|
||||
"github.com/Tencent/WeKnora/cli/internal/iostreams"
|
||||
"github.com/Tencent/WeKnora/cli/internal/prompt"
|
||||
sdk "github.com/Tencent/WeKnora/client"
|
||||
)
|
||||
|
||||
type fakeInitSvc struct {
|
||||
gotKB string
|
||||
gotCfg *sdk.KBModelConfig
|
||||
result *sdk.KBModelConfigView
|
||||
setErr error
|
||||
}
|
||||
|
||||
func (f *fakeInitSvc) SetKBModelConfig(_ context.Context, kbID string, cfg *sdk.KBModelConfig) error {
|
||||
f.gotKB = kbID
|
||||
f.gotCfg = cfg
|
||||
return f.setErr
|
||||
}
|
||||
|
||||
func (f *fakeInitSvc) GetInitializationConfig(_ context.Context, _ string) (*sdk.KBModelConfigView, error) {
|
||||
if f.result != nil {
|
||||
return f.result, nil
|
||||
}
|
||||
if f.gotCfg == nil {
|
||||
return &sdk.KBModelConfigView{}, nil
|
||||
}
|
||||
return &sdk.KBModelConfigView{
|
||||
RetrievalReady: f.gotCfg.EmbeddingModelID != "",
|
||||
Embedding: sdk.ModelSlotView{Configured: f.gotCfg.EmbeddingModelID != "", ModelName: f.gotCfg.EmbeddingModelID},
|
||||
LLM: sdk.ModelSlotView{Configured: f.gotCfg.LLMModelID != "", ModelName: f.gotCfg.LLMModelID},
|
||||
}, nil
|
||||
}
|
||||
|
||||
func TestKBConfigSet_AppliesAndEmitsResult(t *testing.T) {
|
||||
out, _ := iostreams.SetForTest(t)
|
||||
svc := &fakeInitSvc{}
|
||||
opts := &ConfigSetOptions{ChatModel: "model_llm", EmbeddingModel: "model_emb"}
|
||||
require.NoError(t, runConfigSet(context.Background(), opts, &cmdutil.FormatOptions{Mode: cmdutil.FormatJSON}, svc, "kb_abc"))
|
||||
|
||||
assert.Equal(t, "kb_abc", svc.gotKB)
|
||||
require.NotNil(t, svc.gotCfg)
|
||||
assert.Equal(t, "model_llm", svc.gotCfg.LLMModelID)
|
||||
assert.Equal(t, "model_emb", svc.gotCfg.EmbeddingModelID)
|
||||
|
||||
var env struct {
|
||||
OK bool `json:"ok"`
|
||||
Data sdk.KBModelConfigView `json:"data"`
|
||||
}
|
||||
require.NoError(t, json.Unmarshal(out.Bytes(), &env))
|
||||
assert.True(t, env.OK)
|
||||
assert.Equal(t, "model_emb", env.Data.Embedding.ModelName)
|
||||
assert.Equal(t, "model_llm", env.Data.LLM.ModelName)
|
||||
assert.True(t, env.Data.RetrievalReady)
|
||||
}
|
||||
|
||||
func TestKBConfigSet_RequiresBothModels(t *testing.T) {
|
||||
_, _ = iostreams.SetForTest(t)
|
||||
svc := &fakeInitSvc{}
|
||||
// Missing both.
|
||||
err := runConfigSet(context.Background(), &ConfigSetOptions{}, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, svc, "kb_abc")
|
||||
var ce *cmdutil.Error
|
||||
require.ErrorAs(t, err, &ce)
|
||||
assert.Equal(t, cmdutil.CodeInputMissingFlag, ce.Code)
|
||||
assert.Equal(t, "", svc.gotKB, "must not call SetKBModelConfig when flags are missing")
|
||||
|
||||
// Missing just embedding.
|
||||
err = runConfigSet(context.Background(), &ConfigSetOptions{ChatModel: "m"}, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, svc, "kb_abc")
|
||||
require.ErrorAs(t, err, &ce)
|
||||
assert.Contains(t, ce.Message, "--embedding-model")
|
||||
}
|
||||
|
||||
func TestKBConfigSet_WriteSucceedsReadbackFails(t *testing.T) {
|
||||
out, _ := iostreams.SetForTest(t)
|
||||
svc2 := &readbackErrSvc{fakeInitSvc: &fakeInitSvc{}}
|
||||
opts := &ConfigSetOptions{ChatModel: "model_llm", EmbeddingModel: "model_emb"}
|
||||
require.NoError(t, runConfigSet(context.Background(), opts, &cmdutil.FormatOptions{Mode: cmdutil.FormatJSON}, svc2, "kb_abc"))
|
||||
var env struct {
|
||||
Data sdk.KBModelConfigView `json:"data"`
|
||||
}
|
||||
require.NoError(t, json.Unmarshal(out.Bytes(), &env))
|
||||
assert.Equal(t, "model_emb", env.Data.Embedding.ModelName, "falls back to applied config when read-back fails")
|
||||
}
|
||||
|
||||
type readbackErrSvc struct{ *fakeInitSvc }
|
||||
|
||||
func (s *readbackErrSvc) GetInitializationConfig(_ context.Context, _ string) (*sdk.KBModelConfigView, error) {
|
||||
return nil, errors.New("read-back boom")
|
||||
}
|
||||
|
||||
// withRootKB wraps a kb subcommand under a synthetic root with global flags,
|
||||
// keyed off the sub's own name (unlike withRootHarnessKB which is hardcoded to
|
||||
// `update`).
|
||||
func withRootKB(sub *cobra.Command, args ...string) *cobra.Command {
|
||||
root := &cobra.Command{Use: "weknora"}
|
||||
pf := root.PersistentFlags()
|
||||
pf.BoolP("yes", "y", false, "")
|
||||
pf.String("format", "", "")
|
||||
pf.StringP("jq", "q", "", "")
|
||||
kb := &cobra.Command{Use: "kb"}
|
||||
kb.AddCommand(sub)
|
||||
root.AddCommand(kb)
|
||||
root.SetArgs(append([]string{"kb", sub.Name()}, args...))
|
||||
root.SetContext(context.Background())
|
||||
root.SilenceErrors = true
|
||||
root.SilenceUsage = true
|
||||
return root
|
||||
}
|
||||
|
||||
func TestKBConfigSet_RequiresConfirmation(t *testing.T) {
|
||||
iostreams.SetForTest(t)
|
||||
f := &cmdutil.Factory{
|
||||
Client: func() (*sdk.Client, error) { return nil, nil },
|
||||
Prompter: func() prompt.Prompter { return prompt.AgentPrompter{} },
|
||||
}
|
||||
// Drive `kb config set` (the config parent routes to its `set` subcommand).
|
||||
root := withRootKB(NewCmdConfig(f), "set", "kb_abc", "--chat-model", "model_llm", "--embedding-model", "model_emb", "--format", "json")
|
||||
err := root.Execute()
|
||||
require.Error(t, err)
|
||||
var ce *cmdutil.Error
|
||||
require.ErrorAs(t, err, &ce)
|
||||
assert.Equal(t, cmdutil.CodeInputConfirmationRequired, ce.Code)
|
||||
assert.Equal(t, 10, cmdutil.ExitCode(err))
|
||||
assert.Contains(t, ce.RetryArgv, "-y")
|
||||
assert.Contains(t, ce.RetryArgv, "model_emb")
|
||||
assert.Contains(t, ce.RetryArgv, "set", "retry_argv should target `kb config set`")
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
package kb
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"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 fakeConfigSvc struct {
|
||||
cfg *sdk.KBModelConfigView
|
||||
err error
|
||||
}
|
||||
|
||||
func (f *fakeConfigSvc) GetInitializationConfig(_ context.Context, _ string) (*sdk.KBModelConfigView, error) {
|
||||
return f.cfg, f.err
|
||||
}
|
||||
|
||||
func TestKBConfig_EmitsConfig(t *testing.T) {
|
||||
out, _ := iostreams.SetForTest(t)
|
||||
svc := &fakeConfigSvc{cfg: &sdk.KBModelConfigView{
|
||||
RetrievalReady: true,
|
||||
Embedding: sdk.ModelSlotView{Configured: true, ModelName: "model_emb", Source: "remote"},
|
||||
LLM: sdk.ModelSlotView{Configured: true, ModelName: "model_chat"},
|
||||
}}
|
||||
require.NoError(t, runConfig(context.Background(), &cmdutil.FormatOptions{Mode: cmdutil.FormatJSON}, svc, "kb_abc"))
|
||||
var env struct {
|
||||
OK bool `json:"ok"`
|
||||
Data sdk.KBModelConfigView `json:"data"`
|
||||
}
|
||||
require.NoError(t, json.Unmarshal(out.Bytes(), &env))
|
||||
assert.True(t, env.OK)
|
||||
assert.Equal(t, "model_emb", env.Data.Embedding.ModelName)
|
||||
assert.Equal(t, "model_chat", env.Data.LLM.ModelName)
|
||||
assert.True(t, env.Data.RetrievalReady)
|
||||
}
|
||||
|
||||
// TestKBConfig_SecretFree: the view type has no apiKey/baseUrl field, so the
|
||||
// JSON output can never carry provider credentials — the CLI never echoes the
|
||||
// keys the server returns for the web config form.
|
||||
func TestKBConfig_SecretFree(t *testing.T) {
|
||||
out, _ := iostreams.SetForTest(t)
|
||||
svc := &fakeConfigSvc{cfg: &sdk.KBModelConfigView{
|
||||
Embedding: sdk.ModelSlotView{Configured: true, ModelName: "e", Source: "remote"},
|
||||
}}
|
||||
require.NoError(t, runConfig(context.Background(), &cmdutil.FormatOptions{Mode: cmdutil.FormatJSON}, svc, "kb_abc"))
|
||||
assert.NotContains(t, out.String(), "apiKey")
|
||||
assert.NotContains(t, out.String(), "api_key")
|
||||
assert.NotContains(t, out.String(), "baseUrl")
|
||||
}
|
||||
|
||||
// TestKBConfig_NilConfig: a nil server config (KB not yet initialized) emits
|
||||
// retrieval_ready:false, not a crash.
|
||||
func TestKBConfig_NilConfig(t *testing.T) {
|
||||
out, _ := iostreams.SetForTest(t)
|
||||
svc := &fakeConfigSvc{cfg: nil}
|
||||
require.NoError(t, runConfig(context.Background(), &cmdutil.FormatOptions{Mode: cmdutil.FormatJSON}, svc, "kb_abc"))
|
||||
var env struct {
|
||||
Data sdk.KBModelConfigView `json:"data"`
|
||||
}
|
||||
require.NoError(t, json.Unmarshal(out.Bytes(), &env))
|
||||
assert.False(t, env.Data.RetrievalReady)
|
||||
assert.Empty(t, env.Data.Embedding.ModelName)
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
package kb
|
||||
|
||||
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"
|
||||
)
|
||||
|
||||
// kbCreateFields enumerates the fields surfaced for `--format json` discovery
|
||||
// on `kb create`. The result is the full KnowledgeBase struct; these mirror
|
||||
// its top-level json tags. Nested config objects are intentionally omitted —
|
||||
// users wanting them can drop the projection or use --jq.
|
||||
var kbCreateFields = []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 CreateOptions struct {
|
||||
Name string
|
||||
Description string
|
||||
EmbeddingModel string
|
||||
ChatModel string
|
||||
StorageProvider string
|
||||
DryRun bool
|
||||
}
|
||||
|
||||
// storageProviderValues mirrors the server enum in
|
||||
// internal/types/knowledgebase.go:StorageProviderConfig.Provider.
|
||||
var storageProviderValues = []string{"local", "minio", "cos", "tos", "s3", "oss", "ks3", "obs"}
|
||||
|
||||
// CreateService is the narrow SDK surface this command depends on.
|
||||
// *sdk.Client satisfies it via duck typing.
|
||||
type CreateService interface {
|
||||
CreateKnowledgeBase(ctx context.Context, kb *sdk.KnowledgeBase) (*sdk.KnowledgeBase, error)
|
||||
}
|
||||
|
||||
// NewCmdCreate builds `weknora kb create <name>`. Positional <name> only,
|
||||
// consistent with `agent create <name>`.
|
||||
func NewCmdCreate(f *cmdutil.Factory) *cobra.Command {
|
||||
opts := &CreateOptions{}
|
||||
cmd := &cobra.Command{
|
||||
Use: "create <name>",
|
||||
Short: "Create a new knowledge base",
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: func(c *cobra.Command, args []string) error {
|
||||
fopts, err := cmdutil.CheckFormatFlag(c)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
fopts.ResolveDefault(iostreams.IO.IsStdoutTTY())
|
||||
opts.Name = args[0]
|
||||
// Validate --storage-provider enum before the dry-run gate so
|
||||
// --dry-run rejects identically to the live path. ValidateEnum
|
||||
// returns input.invalid_argument (exit 5) and normalizes to the
|
||||
// canonical lowercase form — consistent with every other enum flag
|
||||
// (model --type, agent --agent-mode, message search --mode).
|
||||
// runCreate re-validates for direct-call callers.
|
||||
canonSP, err := cmdutil.ValidateEnum("storage-provider", opts.StorageProvider, storageProviderValues)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
opts.StorageProvider = canonSP
|
||||
if handled, err := cmdutil.HandleDryRun(c, opts.DryRun, cmdutil.DryRunPlan{
|
||||
Action: "kb.create",
|
||||
Args: map[string]any{
|
||||
"name": opts.Name,
|
||||
"description": opts.Description,
|
||||
},
|
||||
}); handled {
|
||||
return err
|
||||
}
|
||||
cli, err := f.Client()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// --embedding-model accepts a model id or name (a UUID passes
|
||||
// through; a name resolves among Embedding models). Configuring a
|
||||
// KB's models fully is `weknora kb config set`; this just pre-sets the
|
||||
// embedding model at creation.
|
||||
if opts.EmbeddingModel, err = cmdutil.ResolveModelRef(c.Context(), cli, opts.EmbeddingModel, "Embedding"); err != nil {
|
||||
return err
|
||||
}
|
||||
// --chat-model (id or name) pre-sets the KB's LLM at creation, so a
|
||||
// KB can be born retrieval-ready in one step. Full model config
|
||||
// (rerank / multimodal) is still `weknora kb config set`.
|
||||
if opts.ChatModel, err = cmdutil.ResolveModelRef(c.Context(), cli, opts.ChatModel, "KnowledgeQA"); err != nil {
|
||||
return err
|
||||
}
|
||||
return runCreate(c.Context(), opts, fopts, cli)
|
||||
},
|
||||
}
|
||||
cmd.Flags().StringVar(&opts.Description, "description", "", "Knowledge base description (optional)")
|
||||
cmd.Flags().StringVar(&opts.EmbeddingModel, "embedding-model", "", "Embedding model id or name (optional; makes the KB retrieval-ready at creation)")
|
||||
cmd.Flags().StringVar(&opts.ChatModel, "chat-model", "", "Chat/LLM model id or name (optional; pre-set the KB's answer model at creation)")
|
||||
cmd.Flags().StringVar(&opts.StorageProvider, "storage-provider", "",
|
||||
"Storage provider for documents in this KB: "+strings.Join(storageProviderValues, " | ")+" (optional; server default when unset)")
|
||||
cmdutil.AddFormatFlag(cmd, kbCreateFields...)
|
||||
cmdutil.AddDryRunFlag(cmd, &opts.DryRun)
|
||||
cmdutil.SetAgentHelp(cmd, cmdutil.AgentHelp{
|
||||
UsedFor: "Create a new knowledge base with the given name. Emits the created KB object with its id.",
|
||||
RequiredFlags: []string{"<name> (positional)"},
|
||||
Examples: []string{
|
||||
`weknora kb create "Eng Docs"`,
|
||||
`weknora kb create "Eng Docs" --embedding-model text-embedding-3-small --chat-model gpt-4o-mini # retrieval-ready in one step`,
|
||||
`weknora kb create "Eng Docs" --jq .data.id # capture id to chain into doc upload --kb`,
|
||||
},
|
||||
Output: "envelope.data is the created KnowledgeBase object with id, name, type, embedding_model_id, summary_model_id",
|
||||
})
|
||||
return cmd
|
||||
}
|
||||
|
||||
func runCreate(ctx context.Context, opts *CreateOptions, fopts *cmdutil.FormatOptions, svc CreateService) error {
|
||||
// Trim defensively in case a caller invokes runCreate directly with
|
||||
// whitespace; the cobra layer enforces a non-empty positional from the CLI.
|
||||
if strings.TrimSpace(opts.Name) == "" {
|
||||
return cmdutil.NewError(cmdutil.CodeInputInvalidArgument, "knowledge base name is required")
|
||||
}
|
||||
|
||||
req := &sdk.KnowledgeBase{
|
||||
Name: opts.Name,
|
||||
Description: opts.Description,
|
||||
}
|
||||
if opts.EmbeddingModel != "" {
|
||||
req.EmbeddingModelID = opts.EmbeddingModel
|
||||
}
|
||||
if opts.ChatModel != "" {
|
||||
req.SummaryModelID = opts.ChatModel
|
||||
}
|
||||
if opts.StorageProvider != "" {
|
||||
canonSP, err := cmdutil.ValidateEnum("storage-provider", opts.StorageProvider, storageProviderValues)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
req.StorageProviderConfig = &sdk.StorageProviderConfig{Provider: canonSP}
|
||||
}
|
||||
|
||||
created, err := svc.CreateKnowledgeBase(ctx, req)
|
||||
if err != nil {
|
||||
return cmdutil.WrapHTTP(err, "create knowledge base")
|
||||
}
|
||||
|
||||
// A KB with no embedding model can hold documents but never index/retrieve
|
||||
// them — surface the next step at the point of creation instead of leaving
|
||||
// the agent to discover a silent-draft KB via a later empty search.
|
||||
var meta *output.Meta
|
||||
if created.EmbeddingModelID == "" {
|
||||
meta = &output.Meta{Hint: "retrieval_ready=false: no embedding model bound. Uploaded docs will not be searchable until you run `weknora kb config set " + created.ID + " --embedding-model <id> --chat-model <id>` (create the KB with --embedding-model/--chat-model to skip this step)."}
|
||||
}
|
||||
|
||||
if fopts.WantsJSON() {
|
||||
return fopts.Emit(iostreams.IO.Out, created, meta)
|
||||
}
|
||||
fmt.Fprintf(iostreams.IO.Out, "✓ Created knowledge base %q (id: %s)\n", created.Name, created.ID)
|
||||
if meta != nil {
|
||||
fmt.Fprintf(iostreams.IO.Out, "⚠ %s\n", meta.Hint)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
package kb
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"testing"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/Tencent/WeKnora/cli/internal/cmdutil"
|
||||
"github.com/Tencent/WeKnora/cli/internal/iostreams"
|
||||
"github.com/Tencent/WeKnora/cli/internal/prompt"
|
||||
sdk "github.com/Tencent/WeKnora/client"
|
||||
)
|
||||
|
||||
// kbDryRunFactory builds a Factory whose Client closure panics if invoked —
|
||||
// dry-run must early-exit before any SDK call. Prompter is similarly trapped:
|
||||
// dry-run is non-interactive by contract.
|
||||
func kbDryRunFactory(t *testing.T) *cmdutil.Factory {
|
||||
t.Helper()
|
||||
return &cmdutil.Factory{
|
||||
Client: func() (*sdk.Client, error) {
|
||||
t.Fatal("dry-run path must not call Factory.Client(); SDK side effect leaked")
|
||||
return nil, nil
|
||||
},
|
||||
Prompter: func() prompt.Prompter {
|
||||
t.Fatal("dry-run path must not call Factory.Prompter(); confirm-prompt side effect leaked")
|
||||
return nil
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// withRootHarness wraps a kb command under a synthetic root cmd that
|
||||
// registers the global persistent flags (mirrors addGlobalFlags in
|
||||
// cmd/root.go). Required because kb subcommands inherit --yes / --format /
|
||||
// --jq from root in production.
|
||||
func withRootHarness(sub *cobra.Command, args ...string) *cobra.Command {
|
||||
root := &cobra.Command{Use: "weknora"}
|
||||
pf := root.PersistentFlags()
|
||||
pf.BoolP("yes", "y", false, "")
|
||||
pf.String("format", "", "")
|
||||
pf.StringP("jq", "q", "", "")
|
||||
root.AddCommand(sub)
|
||||
root.SetArgs(append([]string{sub.Name()}, args...))
|
||||
root.SetContext(context.Background())
|
||||
root.SilenceErrors = true
|
||||
root.SilenceUsage = true
|
||||
return root
|
||||
}
|
||||
|
||||
// TestKBCreate_DryRun_EmitsPlan: --dry-run on `kb create` must emit the
|
||||
// standard dry-run envelope (ok:true, meta.dry_run:true, meta.plan.action) and
|
||||
// must NOT touch the SDK. Verifies that the cobra-layer early-exit runs before
|
||||
// f.Client() and that the plan shape matches the envelope contract.
|
||||
func TestKBCreate_DryRun_EmitsPlan(t *testing.T) {
|
||||
out, _ := iostreams.SetForTest(t)
|
||||
f := kbDryRunFactory(t)
|
||||
root := withRootHarness(NewCmdCreate(f),
|
||||
"foo", "--description", "bar", "--dry-run", "--format", "json")
|
||||
require.NoError(t, root.Execute(), "dry-run must succeed (exit 0) without SDK")
|
||||
|
||||
var env struct {
|
||||
OK bool `json:"ok"`
|
||||
Meta struct {
|
||||
DryRun bool `json:"dry_run"`
|
||||
Plan map[string]any `json:"plan"`
|
||||
} `json:"meta"`
|
||||
Data any `json:"data"`
|
||||
}
|
||||
require.NoError(t, json.Unmarshal(out.Bytes(), &env), "expected valid JSON envelope, got %q", out.String())
|
||||
assert.True(t, env.OK, "envelope.ok must be true on dry-run success")
|
||||
assert.True(t, env.Meta.DryRun, "meta.dry_run must be true")
|
||||
assert.Equal(t, "kb.create", env.Meta.Plan["action"], "plan.action must be kb.create")
|
||||
// plan.args contains the user-provided flags so agents can diff "what would happen".
|
||||
planArgs, ok := env.Meta.Plan["args"].(map[string]any)
|
||||
require.True(t, ok, "plan.args must be a map, got %T", env.Meta.Plan["args"])
|
||||
assert.Equal(t, "foo", planArgs["name"], "plan.args.name must echo positional <name>")
|
||||
assert.Equal(t, "bar", planArgs["description"], "plan.args.description must echo --description")
|
||||
assert.Nil(t, env.Data, "data must be omitted on dry-run (no real result)")
|
||||
}
|
||||
|
||||
// TestKBCreate_DryRun_RejectsInvalidStorageProvider: --dry-run must reject
|
||||
// the same invalid --storage-provider value the live path rejects. Before
|
||||
// the fix the enum check lived only in runCreate(), which HandleDryRun
|
||||
// short-circuited past — so --dry-run silently accepted "garbage".
|
||||
func TestKBCreate_DryRun_RejectsInvalidStorageProvider(t *testing.T) {
|
||||
iostreams.SetForTest(t)
|
||||
f := kbDryRunFactory(t)
|
||||
root := withRootHarness(NewCmdCreate(f),
|
||||
"foo", "--storage-provider", "garbage", "--dry-run", "--format", "json")
|
||||
err := root.Execute()
|
||||
require.Error(t, err, "dry-run must reject invalid --storage-provider")
|
||||
|
||||
// The enum check returns input.invalid_argument (exit 5) — make sure the
|
||||
// dry-run path preserves that exact mapping (same as the live path).
|
||||
assert.Equal(t, 5, cmdutil.ExitCode(err), "invalid --storage-provider must map to exit 5")
|
||||
}
|
||||
@@ -0,0 +1,210 @@
|
||||
package kb
|
||||
|
||||
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"
|
||||
)
|
||||
|
||||
// fakeCreateSvc captures the request and returns canned responses.
|
||||
type fakeCreateSvc struct {
|
||||
resp *sdk.KnowledgeBase
|
||||
err error
|
||||
got *sdk.KnowledgeBase
|
||||
}
|
||||
|
||||
func (f *fakeCreateSvc) CreateKnowledgeBase(_ context.Context, kb *sdk.KnowledgeBase) (*sdk.KnowledgeBase, error) {
|
||||
f.got = kb
|
||||
return f.resp, f.err
|
||||
}
|
||||
|
||||
func TestCreate_Success_Text(t *testing.T) {
|
||||
out, _ := iostreams.SetForTest(t)
|
||||
svc := &fakeCreateSvc{resp: &sdk.KnowledgeBase{
|
||||
ID: "kb_new",
|
||||
Name: "Marketing",
|
||||
Description: "team docs",
|
||||
EmbeddingModelID: "model_x",
|
||||
}}
|
||||
opts := &CreateOptions{
|
||||
Name: "Marketing",
|
||||
Description: "team docs",
|
||||
EmbeddingModel: "model_x",
|
||||
}
|
||||
require.NoError(t, runCreate(context.Background(), opts, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, svc))
|
||||
|
||||
// Body sent to SDK matches flags.
|
||||
require.NotNil(t, svc.got)
|
||||
assert.Equal(t, "Marketing", svc.got.Name)
|
||||
assert.Equal(t, "team docs", svc.got.Description)
|
||||
assert.Equal(t, "model_x", svc.got.EmbeddingModelID)
|
||||
|
||||
got := out.String()
|
||||
for _, want := range []string{"✓", "Created", "Marketing", "kb_new"} {
|
||||
if !strings.Contains(got, want) {
|
||||
t.Errorf("output missing %q in:\n%s", want, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreate_Success_OmitsEmbeddingModelWhenEmpty(t *testing.T) {
|
||||
_, _ = iostreams.SetForTest(t)
|
||||
svc := &fakeCreateSvc{resp: &sdk.KnowledgeBase{ID: "kb_x", Name: "n"}}
|
||||
opts := &CreateOptions{Name: "n"}
|
||||
require.NoError(t, runCreate(context.Background(), opts, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, svc))
|
||||
|
||||
require.NotNil(t, svc.got)
|
||||
assert.Equal(t, "", svc.got.EmbeddingModelID, "embedding-model unset ⇒ empty in request")
|
||||
}
|
||||
|
||||
// A KB created without an embedding model can never retrieve; the create result
|
||||
// must hand the agent the fix (kb config set) instead of a silent unusable KB.
|
||||
func TestCreate_HintsWhenNoEmbeddingModel(t *testing.T) {
|
||||
out, _ := iostreams.SetForTest(t)
|
||||
svc := &fakeCreateSvc{resp: &sdk.KnowledgeBase{ID: "kb_x", Name: "n"}}
|
||||
require.NoError(t, runCreate(context.Background(), &CreateOptions{Name: "n"}, &cmdutil.FormatOptions{Mode: cmdutil.FormatJSON}, svc))
|
||||
var env struct {
|
||||
Meta struct {
|
||||
Hint string `json:"hint"`
|
||||
} `json:"meta"`
|
||||
}
|
||||
require.NoError(t, json.Unmarshal(out.Bytes(), &env))
|
||||
assert.Contains(t, env.Meta.Hint, "kb config set", "unconfigured KB must hint the retrieval-readiness fix")
|
||||
}
|
||||
|
||||
// A retrieval-ready KB (embedding model bound) carries no such hint — no noise.
|
||||
func TestCreate_NoHintWhenEmbeddingModelBound(t *testing.T) {
|
||||
out, _ := iostreams.SetForTest(t)
|
||||
svc := &fakeCreateSvc{resp: &sdk.KnowledgeBase{ID: "kb_x", Name: "n", EmbeddingModelID: "emb_1"}}
|
||||
require.NoError(t, runCreate(context.Background(), &CreateOptions{Name: "n", EmbeddingModel: "emb_1"}, &cmdutil.FormatOptions{Mode: cmdutil.FormatJSON}, svc))
|
||||
var env struct {
|
||||
Meta *struct {
|
||||
Hint string `json:"hint"`
|
||||
} `json:"meta"`
|
||||
}
|
||||
require.NoError(t, json.Unmarshal(out.Bytes(), &env))
|
||||
if env.Meta != nil {
|
||||
assert.Empty(t, env.Meta.Hint, "retrieval-ready KB must not emit a readiness hint")
|
||||
}
|
||||
}
|
||||
|
||||
// TestCreate_ChatModelSetsSummaryModelID: --chat-model rides the create request
|
||||
// as summary_model_id, so a KB can be born retrieval-ready in one step.
|
||||
func TestCreate_ChatModelSetsSummaryModelID(t *testing.T) {
|
||||
_, _ = iostreams.SetForTest(t)
|
||||
svc := &fakeCreateSvc{resp: &sdk.KnowledgeBase{ID: "kb_x", Name: "n"}}
|
||||
opts := &CreateOptions{Name: "n", EmbeddingModel: "emb_x", ChatModel: "chat_x"}
|
||||
require.NoError(t, runCreate(context.Background(), opts, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, svc))
|
||||
|
||||
require.NotNil(t, svc.got)
|
||||
assert.Equal(t, "emb_x", svc.got.EmbeddingModelID)
|
||||
assert.Equal(t, "chat_x", svc.got.SummaryModelID, "--chat-model must set summary_model_id on the create request")
|
||||
}
|
||||
|
||||
func TestCreate_NameRequired(t *testing.T) {
|
||||
_, _ = iostreams.SetForTest(t)
|
||||
svc := &fakeCreateSvc{}
|
||||
err := runCreate(context.Background(), &CreateOptions{}, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, svc)
|
||||
require.Error(t, err)
|
||||
|
||||
var typed *cmdutil.Error
|
||||
require.ErrorAs(t, err, &typed)
|
||||
assert.Equal(t, cmdutil.CodeInputInvalidArgument, typed.Code)
|
||||
assert.Nil(t, svc.got, "service must not be called when name is missing")
|
||||
}
|
||||
|
||||
func TestCreate_NameWhitespaceOnly(t *testing.T) {
|
||||
_, _ = iostreams.SetForTest(t)
|
||||
svc := &fakeCreateSvc{}
|
||||
err := runCreate(context.Background(), &CreateOptions{Name: " "}, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, svc)
|
||||
require.Error(t, err)
|
||||
|
||||
var typed *cmdutil.Error
|
||||
require.ErrorAs(t, err, &typed)
|
||||
assert.Equal(t, cmdutil.CodeInputInvalidArgument, typed.Code)
|
||||
}
|
||||
|
||||
func TestCreate_HTTPError_500(t *testing.T) {
|
||||
_, _ = iostreams.SetForTest(t)
|
||||
svc := &fakeCreateSvc{err: errors.New("HTTP error 500: internal")}
|
||||
err := runCreate(context.Background(), &CreateOptions{Name: "x"}, &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)
|
||||
}
|
||||
|
||||
func TestCreate_HTTPError_409Conflict(t *testing.T) {
|
||||
_, _ = iostreams.SetForTest(t)
|
||||
svc := &fakeCreateSvc{err: errors.New("HTTP error 409: name exists")}
|
||||
err := runCreate(context.Background(), &CreateOptions{Name: "dup"}, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, svc)
|
||||
require.Error(t, err)
|
||||
|
||||
var typed *cmdutil.Error
|
||||
require.ErrorAs(t, err, &typed)
|
||||
assert.Equal(t, cmdutil.CodeResourceAlreadyExists, typed.Code)
|
||||
}
|
||||
|
||||
func TestCreate_JSONOutput(t *testing.T) {
|
||||
out, _ := iostreams.SetForTest(t)
|
||||
svc := &fakeCreateSvc{resp: &sdk.KnowledgeBase{ID: "kb_99", Name: "Eng"}}
|
||||
opts := &CreateOptions{Name: "Eng"}
|
||||
require.NoError(t, runCreate(context.Background(), opts, &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.Equal(t, "kb_99", env.Data.ID, "envelope.data.id must be kb_99")
|
||||
assert.Contains(t, got, `"name":"Eng"`)
|
||||
}
|
||||
|
||||
func TestCreate_StorageProvider_InjectsRequest(t *testing.T) {
|
||||
_, _ = iostreams.SetForTest(t)
|
||||
svc := &fakeCreateSvc{resp: &sdk.KnowledgeBase{ID: "kb_s", Name: "n"}}
|
||||
opts := &CreateOptions{Name: "n", StorageProvider: "Local"}
|
||||
require.NoError(t, runCreate(context.Background(), opts, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, svc))
|
||||
|
||||
require.NotNil(t, svc.got.StorageProviderConfig)
|
||||
assert.Equal(t, "local", svc.got.StorageProviderConfig.Provider, "value should be lowercased + trimmed before send")
|
||||
}
|
||||
|
||||
func TestCreate_StorageProvider_InvalidValueReturnsInputError(t *testing.T) {
|
||||
_, _ = iostreams.SetForTest(t)
|
||||
svc := &fakeCreateSvc{}
|
||||
opts := &CreateOptions{Name: "n", StorageProvider: "azure"}
|
||||
err := runCreate(context.Background(), opts, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, svc)
|
||||
require.Error(t, err)
|
||||
|
||||
// A bad enum *value* (cobra accepted the string; the app rejected it) is an
|
||||
// app-level input error → exit 5, consistent with every other enum flag
|
||||
// (model --type, agent --agent-mode, message search --mode).
|
||||
var typed *cmdutil.Error
|
||||
require.ErrorAs(t, err, &typed)
|
||||
assert.Equal(t, cmdutil.CodeInputInvalidArgument, typed.Code)
|
||||
assert.Equal(t, 5, cmdutil.ExitCode(err), "invalid --storage-provider must exit 5 (app-level input validation)")
|
||||
assert.Contains(t, err.Error(), "--storage-provider")
|
||||
assert.Nil(t, svc.got, "SDK must not be called when input validation fails")
|
||||
}
|
||||
|
||||
func TestCreate_StorageProvider_OmittedWhenEmpty(t *testing.T) {
|
||||
_, _ = iostreams.SetForTest(t)
|
||||
svc := &fakeCreateSvc{resp: &sdk.KnowledgeBase{ID: "kb_n", Name: "n"}}
|
||||
opts := &CreateOptions{Name: "n"} // no --storage-provider
|
||||
require.NoError(t, runCreate(context.Background(), opts, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, svc))
|
||||
|
||||
assert.Nil(t, svc.got.StorageProviderConfig, "empty flag must omit StorageProviderConfig (let server pick default)")
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
package kb
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/Tencent/WeKnora/cli/internal/cmdutil"
|
||||
"github.com/Tencent/WeKnora/cli/internal/iostreams"
|
||||
"github.com/Tencent/WeKnora/cli/internal/prompt"
|
||||
)
|
||||
|
||||
// kbDeleteFields enumerates the fields surfaced for `--format json` discovery
|
||||
// on `kb delete`. The result payload is a small {id, deleted} object.
|
||||
var kbDeleteFields = []string{"id", "deleted"}
|
||||
|
||||
type DeleteOptions struct {
|
||||
Yes bool // sourced from the global -y/--yes persistent flag (see cli/cmd/root.go addGlobalFlags)
|
||||
DryRun bool
|
||||
}
|
||||
|
||||
// DeleteService is the narrow SDK surface this command depends on.
|
||||
// *sdk.Client satisfies it.
|
||||
type DeleteService interface {
|
||||
DeleteKnowledgeBase(ctx context.Context, id string) error
|
||||
}
|
||||
|
||||
// deleteResult is the typed payload emitted under data on success.
|
||||
type deleteResult struct {
|
||||
ID string `json:"id"`
|
||||
Deleted bool `json:"deleted"`
|
||||
}
|
||||
|
||||
// NewCmdDelete builds `weknora kb delete`. The global -y/--yes persistent
|
||||
// flag is the single skip-prompt switch for the destructive-write
|
||||
// confirmation pattern.
|
||||
func NewCmdDelete(f *cmdutil.Factory) *cobra.Command {
|
||||
opts := &DeleteOptions{}
|
||||
cmd := &cobra.Command{
|
||||
Use: "delete <kb-id>",
|
||||
Short: "Delete a knowledge base",
|
||||
Long: `Permanently deletes a knowledge base and all its contents.
|
||||
|
||||
Prompts for confirmation by default when stdout is a TTY and JSON output is not set.
|
||||
Pass -y/--yes (global flag) to skip the prompt (required in agent / CI / piped contexts).
|
||||
|
||||
AI agents: This is a high-risk write. Without -y/--yes the CLI exits 10
|
||||
and writes input.confirmation_required to stderr. NEVER auto-pass -y
|
||||
without the user's explicit go-ahead — the exit-10 protocol exists
|
||||
exactly to guard against unintended deletes.`,
|
||||
Example: ` weknora kb delete kb_abc # interactive confirm
|
||||
weknora kb delete kb_abc -y # no prompt
|
||||
weknora kb delete kb_abc -y --format json # bare {id, deleted:true} JSON`,
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: func(c *cobra.Command, args []string) error {
|
||||
fopts, err := cmdutil.CheckFormatFlag(c)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
fopts.ResolveDefault(iostreams.IO.IsStdoutTTY())
|
||||
opts.Yes, _ = c.Flags().GetBool("yes")
|
||||
if handled, err := cmdutil.HandleDryRun(c, opts.DryRun, cmdutil.DryRunPlan{
|
||||
Action: "kb.delete",
|
||||
Args: map[string]any{
|
||||
"kb_ids": args,
|
||||
},
|
||||
}); handled {
|
||||
return err
|
||||
}
|
||||
cli, err := f.Client()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return runDelete(c.Context(), opts, fopts, cli, f.Prompter(), args[0])
|
||||
},
|
||||
}
|
||||
cmdutil.AddFormatFlag(cmd, kbDeleteFields...)
|
||||
cmdutil.AddDryRunFlag(cmd, &opts.DryRun)
|
||||
cmdutil.SetRisk(cmd, "kb.delete")
|
||||
cmdutil.SetAgentHelp(cmd, cmdutil.AgentHelp{
|
||||
UsedFor: "permanently delete a knowledge base and all its contents",
|
||||
RequiredFlags: []string{"<kb-id> (positional)"},
|
||||
Output: "envelope.data is {id, deleted:true}",
|
||||
Examples: []string{
|
||||
"weknora kb delete kb_abc -y",
|
||||
"weknora kb delete kb_abc -y --format json",
|
||||
},
|
||||
Warnings: []string{
|
||||
"Requires explicit user approval (exit 10 / input.confirmation_required); never auto-add -y.",
|
||||
"kb delete is irreversible; whole KB + docs + chunks gone.",
|
||||
},
|
||||
})
|
||||
return cmd
|
||||
}
|
||||
|
||||
func runDelete(ctx context.Context, opts *DeleteOptions, fopts *cmdutil.FormatOptions, svc DeleteService, p prompt.Prompter, id string) error {
|
||||
if err := cmdutil.ConfirmDestructive(p, opts.Yes, fopts.WantsJSON(), "delete", "knowledge base", id, "kb.delete", []string{"weknora", "kb", "delete", id, "-y"}); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := svc.DeleteKnowledgeBase(ctx, id); err != nil {
|
||||
return cmdutil.WrapHTTP(err, "delete knowledge base %s", id)
|
||||
}
|
||||
|
||||
if fopts.WantsJSON() {
|
||||
return fopts.Emit(iostreams.IO.Out, deleteResult{ID: id, Deleted: true}, nil)
|
||||
}
|
||||
fmt.Fprintf(iostreams.IO.Out, "✓ Deleted knowledge base %s\n", id)
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
package kb
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"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"
|
||||
)
|
||||
|
||||
// TestKBDelete_DryRun_NoExit10: --dry-run must bypass ConfirmDestructive.
|
||||
// Non-TTY without -y normally yields exit 10 (CodeInputConfirmationRequired);
|
||||
// with --dry-run it must early-exit successfully (exit 0) because no real
|
||||
// delete happens — preview implies no execution, so the confirmation gate
|
||||
// is irrelevant.
|
||||
func TestKBDelete_DryRun_NoExit10(t *testing.T) {
|
||||
out, _ := iostreams.SetForTest(t) // non-TTY
|
||||
f := kbDryRunFactory(t)
|
||||
root := withRootHarness(NewCmdDelete(f), "kb_to_preview", "--dry-run", "--format", "json")
|
||||
err := root.Execute()
|
||||
require.NoError(t, err, "dry-run must succeed (exit 0); ConfirmDestructive must be skipped")
|
||||
assert.Equal(t, 0, cmdutil.ExitCode(err), "exit code must be 0, not 10 (confirmation_required)")
|
||||
|
||||
var env struct {
|
||||
OK bool `json:"ok"`
|
||||
Meta struct {
|
||||
DryRun bool `json:"dry_run"`
|
||||
Plan map[string]any `json:"plan"`
|
||||
} `json:"meta"`
|
||||
}
|
||||
require.NoError(t, json.Unmarshal(out.Bytes(), &env), "expected valid JSON envelope, got %q", out.String())
|
||||
assert.True(t, env.OK)
|
||||
assert.True(t, env.Meta.DryRun)
|
||||
assert.Equal(t, "kb.delete", env.Meta.Plan["action"])
|
||||
}
|
||||
|
||||
// TestKBDelete_DryRunWithYes_EquivalentToDryRunAlone: -y is a no-op when
|
||||
// --dry-run is set (no real delete happens, so no confirmation gate to skip).
|
||||
// The envelope shape must match the dry-run-alone case exactly so agents can
|
||||
// rely on the plan output regardless of whether the human pre-approved.
|
||||
func TestKBDelete_DryRunWithYes_EquivalentToDryRunAlone(t *testing.T) {
|
||||
// First invocation: --dry-run alone.
|
||||
outA, _ := iostreams.SetForTest(t)
|
||||
rootA := withRootHarness(NewCmdDelete(kbDryRunFactory(t)),
|
||||
"kb_to_preview", "--dry-run", "--format", "json")
|
||||
require.NoError(t, rootA.Execute())
|
||||
gotA := outA.String()
|
||||
|
||||
// Second invocation: --dry-run + -y.
|
||||
outB, _ := iostreams.SetForTest(t)
|
||||
rootB := withRootHarness(NewCmdDelete(kbDryRunFactory(t)),
|
||||
"kb_to_preview", "--dry-run", "-y", "--format", "json")
|
||||
require.NoError(t, rootB.Execute(), "--dry-run + -y must succeed identically to --dry-run alone")
|
||||
gotB := outB.String()
|
||||
|
||||
assert.JSONEq(t, gotA, gotB,
|
||||
"--dry-run output must be identical with or without -y; -y is a no-op under dry-run")
|
||||
}
|
||||
@@ -0,0 +1,189 @@
|
||||
package kb
|
||||
|
||||
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"
|
||||
"github.com/Tencent/WeKnora/cli/internal/prompt"
|
||||
"github.com/Tencent/WeKnora/cli/internal/testutil"
|
||||
)
|
||||
|
||||
// fakeDeleteSvc records what id was deleted.
|
||||
type fakeDeleteSvc struct {
|
||||
err error
|
||||
gotID string
|
||||
called bool
|
||||
}
|
||||
|
||||
func (f *fakeDeleteSvc) DeleteKnowledgeBase(_ context.Context, id string) error {
|
||||
f.called = true
|
||||
f.gotID = id
|
||||
return f.err
|
||||
}
|
||||
|
||||
func TestDelete_Success_WithForce(t *testing.T) {
|
||||
out, _ := iostreams.SetForTest(t)
|
||||
svc := &fakeDeleteSvc{}
|
||||
p := &testutil.ConfirmPrompter{}
|
||||
opts := &DeleteOptions{Yes: true}
|
||||
require.NoError(t, runDelete(context.Background(), opts, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, svc, p, "kb_force"))
|
||||
|
||||
assert.True(t, svc.called)
|
||||
assert.Equal(t, "kb_force", svc.gotID)
|
||||
assert.False(t, p.Asked, "--force must skip the confirm prompt")
|
||||
assert.Contains(t, out.String(), "✓ Deleted")
|
||||
assert.Contains(t, out.String(), "kb_force")
|
||||
}
|
||||
|
||||
func TestDelete_NotFound(t *testing.T) {
|
||||
_, _ = iostreams.SetForTest(t)
|
||||
svc := &fakeDeleteSvc{err: errors.New("HTTP error 404: not found")}
|
||||
p := &testutil.ConfirmPrompter{}
|
||||
err := runDelete(context.Background(), &DeleteOptions{Yes: true}, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, svc, p, "kb_missing")
|
||||
require.Error(t, err)
|
||||
|
||||
var typed *cmdutil.Error
|
||||
require.ErrorAs(t, err, &typed)
|
||||
assert.Equal(t, cmdutil.CodeResourceNotFound, typed.Code)
|
||||
}
|
||||
|
||||
func TestDelete_NonTTY_NoYes_RequiresConfirmation(t *testing.T) {
|
||||
// SetForTest uses bytes.Buffer for Out - IsStdoutTTY() = false. Without
|
||||
// -y/--yes, exit-10 protocol fires (see cli/README.md): the CLI must NOT
|
||||
// silently proceed in scripted contexts.
|
||||
iostreams.SetForTest(t)
|
||||
svc := &fakeDeleteSvc{}
|
||||
p := &testutil.ConfirmPrompter{}
|
||||
err := runDelete(context.Background(), &DeleteOptions{}, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, svc, p, "kb_nontty")
|
||||
|
||||
require.Error(t, err)
|
||||
var typed *cmdutil.Error
|
||||
require.ErrorAs(t, err, &typed)
|
||||
assert.Equal(t, cmdutil.CodeInputConfirmationRequired, typed.Code)
|
||||
assert.False(t, svc.called, "non-TTY without -y must not call DeleteKnowledgeBase")
|
||||
assert.False(t, p.Asked, "non-TTY ⇒ Confirm is never invoked")
|
||||
assert.Equal(t, 10, cmdutil.ExitCode(err), "exit code 10 per destructive-write protocol")
|
||||
}
|
||||
|
||||
func TestDelete_JSONOutput(t *testing.T) {
|
||||
out, _ := iostreams.SetForTest(t)
|
||||
svc := &fakeDeleteSvc{}
|
||||
p := &testutil.ConfirmPrompter{}
|
||||
opts := &DeleteOptions{Yes: true}
|
||||
require.NoError(t, runDelete(context.Background(), opts, &cmdutil.FormatOptions{Mode: cmdutil.FormatJSON}, svc, p, "kb_json"))
|
||||
|
||||
got := out.String()
|
||||
var env struct {
|
||||
OK bool `json:"ok"`
|
||||
Data map[string]any `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.Equal(t, "kb_json", env.Data["id"], "envelope.data.id must be kb_json")
|
||||
assert.Equal(t, true, env.Data["deleted"], "envelope.data.deleted must be true")
|
||||
}
|
||||
|
||||
// The remaining tests cover the interactive confirm path which only fires
|
||||
// under IsStdoutTTY() && !JSONOut - exercised via SetForTestWithTTY.
|
||||
|
||||
func TestDelete_ConfirmYes(t *testing.T) {
|
||||
_, _ = iostreams.SetForTestWithTTY(t)
|
||||
svc := &fakeDeleteSvc{}
|
||||
p := &testutil.ConfirmPrompter{Answer: true}
|
||||
require.NoError(t, runDelete(context.Background(), &DeleteOptions{}, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, svc, p, "kb_yes"))
|
||||
|
||||
assert.True(t, p.Asked, "confirm prompt should fire on TTY without --force")
|
||||
assert.True(t, svc.called, "answer=yes ⇒ delete proceeds")
|
||||
assert.Equal(t, "kb_yes", svc.gotID)
|
||||
}
|
||||
|
||||
func TestDelete_ConfirmNo(t *testing.T) {
|
||||
_, errBuf := iostreams.SetForTestWithTTY(t)
|
||||
svc := &fakeDeleteSvc{}
|
||||
p := &testutil.ConfirmPrompter{Answer: false}
|
||||
err := runDelete(context.Background(), &DeleteOptions{}, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, svc, p, "kb_no")
|
||||
require.Error(t, err)
|
||||
|
||||
var typed *cmdutil.Error
|
||||
require.ErrorAs(t, err, &typed)
|
||||
assert.Equal(t, cmdutil.CodeUserAborted, typed.Code)
|
||||
assert.True(t, p.Asked)
|
||||
assert.False(t, svc.called, "answer=no ⇒ delete must NOT run")
|
||||
assert.Contains(t, errBuf.String(), "Aborted")
|
||||
}
|
||||
|
||||
func TestDelete_ConfirmPrompterError(t *testing.T) {
|
||||
_, _ = iostreams.SetForTestWithTTY(t)
|
||||
svc := &fakeDeleteSvc{}
|
||||
p := &testutil.ConfirmPrompter{Err: prompt.ErrAgentNoPrompt}
|
||||
err := runDelete(context.Background(), &DeleteOptions{}, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, svc, p, "kb_err")
|
||||
require.Error(t, err)
|
||||
|
||||
var typed *cmdutil.Error
|
||||
require.ErrorAs(t, err, &typed)
|
||||
assert.Equal(t, cmdutil.CodeInputMissingFlag, typed.Code,
|
||||
"prompter error should surface as missing-flag (pass --force)")
|
||||
assert.False(t, svc.called)
|
||||
}
|
||||
|
||||
func TestDelete_JSONOut_NoYes_RequiresConfirmation(t *testing.T) {
|
||||
// Even on a TTY, --format json indicates a scripted caller; cannot prompt.
|
||||
// Exit-10 protocol must fire when -y is absent.
|
||||
iostreams.SetForTestWithTTY(t)
|
||||
svc := &fakeDeleteSvc{}
|
||||
p := &testutil.ConfirmPrompter{}
|
||||
opts := &DeleteOptions{}
|
||||
err := runDelete(context.Background(), opts, &cmdutil.FormatOptions{Mode: cmdutil.FormatJSON}, svc, p, "kb_jtty")
|
||||
|
||||
require.Error(t, err)
|
||||
var typed *cmdutil.Error
|
||||
require.ErrorAs(t, err, &typed)
|
||||
assert.Equal(t, cmdutil.CodeInputConfirmationRequired, typed.Code)
|
||||
assert.False(t, p.Asked, "--format json must skip the prompt even on TTY")
|
||||
assert.False(t, svc.called, "--format json without -y must not call DeleteKnowledgeBase")
|
||||
assert.Equal(t, 10, cmdutil.ExitCode(err))
|
||||
}
|
||||
|
||||
func TestDelete_JSONOut_WithYes_Proceeds(t *testing.T) {
|
||||
// --format json + -y is the agent happy-path: scripted caller with explicit
|
||||
// approval. Must call SDK and emit the bare result object.
|
||||
out, _ := iostreams.SetForTestWithTTY(t)
|
||||
svc := &fakeDeleteSvc{}
|
||||
p := &testutil.ConfirmPrompter{}
|
||||
opts := &DeleteOptions{Yes: true}
|
||||
require.NoError(t, runDelete(context.Background(), opts, &cmdutil.FormatOptions{Mode: cmdutil.FormatJSON}, svc, p, "kb_jtty"))
|
||||
|
||||
assert.False(t, p.Asked, "-y must skip the prompt")
|
||||
assert.True(t, svc.called)
|
||||
assert.Contains(t, out.String(), `"deleted":true`)
|
||||
}
|
||||
|
||||
func TestKbDelete_NoYes_JSONMode_AttachesRiskAndRetry(t *testing.T) {
|
||||
// Non-TTY + JSON mode without -y must return CodeInputConfirmationRequired
|
||||
// with risk.action == "kb.delete" and retry_argv == [weknora kb delete kb_x -y].
|
||||
// Regression test for H1: ConfirmDestructive must attach risk + retry_argv.
|
||||
iostreams.SetForTest(t)
|
||||
svc := &fakeDeleteSvc{}
|
||||
p := &testutil.ConfirmPrompter{}
|
||||
opts := &DeleteOptions{Yes: false}
|
||||
|
||||
err := runDelete(context.Background(), opts, &cmdutil.FormatOptions{Mode: cmdutil.FormatJSON}, svc, p, "kb_x")
|
||||
|
||||
require.Error(t, err, "expected confirmation_required error")
|
||||
ce := cmdutil.AsError(err)
|
||||
require.NotNil(t, ce, "expected *cmdutil.Error; got %T %v", err, err)
|
||||
assert.Equal(t, cmdutil.CodeInputConfirmationRequired, ce.Code)
|
||||
assert.False(t, svc.called, "SDK must not be called without confirmation")
|
||||
assert.False(t, p.Asked, "--format json must not prompt even on test setup")
|
||||
require.NotNil(t, ce.Risk, "expected risk metadata on confirmation_required error")
|
||||
assert.Equal(t, "kb.delete", ce.Risk.Action, "expected risk.action == kb.delete")
|
||||
assert.Equal(t, "destructive", ce.Risk.Level, "expected risk.level == destructive")
|
||||
assert.Equal(t, []string{"weknora", "kb", "delete", "kb_x", "-y"}, ce.RetryArgv, "expected retry_argv")
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
package kb
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/Tencent/WeKnora/cli/internal/cmdutil"
|
||||
"github.com/Tencent/WeKnora/cli/internal/iostreams"
|
||||
sdk "github.com/Tencent/WeKnora/client"
|
||||
)
|
||||
|
||||
// kbEditFields enumerates the fields surfaced for `--format json` discovery on
|
||||
// `kb update`. The result is the updated KnowledgeBase; mirrors the kb
|
||||
// top-level json tags.
|
||||
var kbEditFields = []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 EditOptions struct {
|
||||
// Name/Description are *string so we can distinguish "unset" from "set to
|
||||
// empty". An unset field is omitted from the SDK request - only fields the
|
||||
// user passed are sent. Server PUT semantics are "replace everything in the
|
||||
// request"; if we always sent both, an `--name` invocation would silently
|
||||
// clear the description.
|
||||
Name *string
|
||||
Description *string
|
||||
Yes bool // sourced from global -y/--yes persistent flag
|
||||
DryRun bool
|
||||
}
|
||||
|
||||
// EditService is the narrow SDK surface this command depends on. GetKnowledgeBase
|
||||
// is needed for the fetch-then-update flow: the server's UpdateKnowledgeBase
|
||||
// endpoint requires Name on the PUT body (UpdateKnowledgeBaseRequest.Name is
|
||||
// `string`, not `*string`, and the server validates `required`), so passing
|
||||
// only --description without fetching the current Name would 400.
|
||||
type EditService interface {
|
||||
GetKnowledgeBase(ctx context.Context, id string) (*sdk.KnowledgeBase, error)
|
||||
UpdateKnowledgeBase(ctx context.Context, id string, req *sdk.UpdateKnowledgeBaseRequest) (*sdk.KnowledgeBase, error)
|
||||
}
|
||||
|
||||
// NewCmdEdit builds `weknora kb update <id>`. At least one of --name /
|
||||
// --description must be provided.
|
||||
func NewCmdEdit(f *cmdutil.Factory) *cobra.Command {
|
||||
opts := &EditOptions{}
|
||||
var name, desc string
|
||||
cmd := &cobra.Command{
|
||||
Use: "update <kb-id>",
|
||||
Short: "Update a knowledge base's name or description",
|
||||
Long: `Update a knowledge base's name and/or description. At least one of
|
||||
--name / --description must be supplied; fields you omit are preserved
|
||||
server-side.
|
||||
|
||||
AI agents: this is a high-risk write. Without -y/--yes the CLI exits 10
|
||||
with input.confirmation_required. Never auto-pass -y; surface the prompt
|
||||
to the user first.`,
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: func(c *cobra.Command, args []string) error {
|
||||
fopts, err := cmdutil.CheckFormatFlag(c)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
fopts.ResolveDefault(iostreams.IO.IsStdoutTTY())
|
||||
opts.Yes, _ = c.Flags().GetBool("yes")
|
||||
if c.Flag("name").Changed {
|
||||
opts.Name = &name
|
||||
}
|
||||
if c.Flag("description").Changed {
|
||||
opts.Description = &desc
|
||||
}
|
||||
id := args[0]
|
||||
// Validate pure-local "at least one mutation flag" before the
|
||||
// dry-run gate so --dry-run rejects identically to the live path.
|
||||
// Same typed Error as runEdit so live behavior is unchanged, just
|
||||
// earlier.
|
||||
if opts.Name == nil && opts.Description == nil {
|
||||
return &cmdutil.Error{
|
||||
Code: cmdutil.CodeInputMissingFlag,
|
||||
Message: "kb update requires at least one of --name or --description",
|
||||
Hint: "pass --name <name> and/or --description <desc>",
|
||||
}
|
||||
}
|
||||
planArgs := map[string]any{"kb": id}
|
||||
if opts.Name != nil {
|
||||
planArgs["name"] = *opts.Name
|
||||
}
|
||||
if opts.Description != nil {
|
||||
planArgs["description"] = *opts.Description
|
||||
}
|
||||
if handled, err := cmdutil.HandleDryRun(c, opts.DryRun, cmdutil.DryRunPlan{
|
||||
Action: "kb.update",
|
||||
Args: planArgs,
|
||||
}); handled {
|
||||
return err
|
||||
}
|
||||
cli, err := f.Client()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// Build a retry command from the flags the user actually passed so
|
||||
// agents can re-invoke with -y after explicit human approval.
|
||||
retryCmd := cmdutil.BuildRetryArgv(c, []string{"weknora", "kb", "update", id}, "name", "description", "format")
|
||||
if err := cmdutil.ConfirmWrite(f.Prompter(), opts.Yes, fopts.WantsJSON(), "update", "knowledge base", id, "kb.update", retryCmd); err != nil {
|
||||
return err
|
||||
}
|
||||
return runEdit(c.Context(), opts, fopts, cli, id)
|
||||
},
|
||||
}
|
||||
cmd.Flags().StringVar(&name, "name", "", "New name (omit to leave unchanged)")
|
||||
cmd.Flags().StringVar(&desc, "description", "", "New description (omit to leave unchanged)")
|
||||
cmdutil.AddFormatFlag(cmd, kbEditFields...)
|
||||
cmdutil.AddDryRunFlag(cmd, &opts.DryRun)
|
||||
cmdutil.SetWriteRisk(cmd, "kb.update")
|
||||
cmdutil.SetAgentHelp(cmd, cmdutil.AgentHelp{
|
||||
UsedFor: "update a knowledge base's name or description",
|
||||
RequiredFlags: []string{"<kb-id> (positional)", "--name or --description (at least one)"},
|
||||
Output: "envelope.data is the updated KnowledgeBase object (id, name, description)",
|
||||
Examples: []string{
|
||||
"weknora kb update kb_abc --name \"New Name\" -y",
|
||||
"weknora kb update kb_abc --description \"Updated desc\" --format json -y",
|
||||
},
|
||||
Warnings: []string{
|
||||
"Requires explicit user approval (exit 10 / input.confirmation_required); never auto-add -y.",
|
||||
"kb update overwrites fields; SDK uses fetch-then-update to avoid clobbering, but malformed input can still corrupt config.",
|
||||
},
|
||||
})
|
||||
return cmd
|
||||
}
|
||||
|
||||
func runEdit(ctx context.Context, opts *EditOptions, fopts *cmdutil.FormatOptions, svc EditService, id string) error {
|
||||
if opts.Name == nil && opts.Description == nil {
|
||||
return &cmdutil.Error{
|
||||
Code: cmdutil.CodeInputMissingFlag,
|
||||
Message: "kb update requires at least one of --name or --description",
|
||||
Hint: "pass --name <name> and/or --description <desc>",
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch current state so we can fill in fields the user didn't touch.
|
||||
// TOCTOU note: another writer could change Name/Description between
|
||||
// our Get and Put; matches the same race window kb pin / unpin have.
|
||||
current, err := svc.GetKnowledgeBase(ctx, id)
|
||||
if err != nil {
|
||||
return cmdutil.WrapHTTP(err, "fetch knowledge base %s", id)
|
||||
}
|
||||
req := &sdk.UpdateKnowledgeBaseRequest{
|
||||
Name: current.Name,
|
||||
Description: current.Description,
|
||||
}
|
||||
if opts.Name != nil {
|
||||
req.Name = *opts.Name
|
||||
}
|
||||
if opts.Description != nil {
|
||||
req.Description = *opts.Description
|
||||
}
|
||||
|
||||
updated, err := svc.UpdateKnowledgeBase(ctx, id, req)
|
||||
if err != nil {
|
||||
return cmdutil.WrapHTTP(err, "update knowledge base %s", id)
|
||||
}
|
||||
if fopts.WantsJSON() {
|
||||
return fopts.Emit(iostreams.IO.Out, updated, nil)
|
||||
}
|
||||
fmt.Fprintf(iostreams.IO.Out, "✓ Updated knowledge base %s\n", id)
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
package kb
|
||||
|
||||
import (
|
||||
"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"
|
||||
)
|
||||
|
||||
// TestKBEdit_DryRun_RejectsNoMutationFlag: --dry-run must reject the same
|
||||
// "no mutation flag supplied" invocation the live path rejects. Before the
|
||||
// fix the validation lived in runEdit() (the live-only callee) and was
|
||||
// reached only after HandleDryRun early-exited, so --dry-run silently
|
||||
// emitted a no-op plan envelope. Validation must run before previewing.
|
||||
func TestKBEdit_DryRun_RejectsNoMutationFlag(t *testing.T) {
|
||||
iostreams.SetForTest(t)
|
||||
f := kbDryRunFactory(t)
|
||||
root := withRootHarness(NewCmdEdit(f), "kb_abc", "--dry-run", "--format", "json")
|
||||
err := root.Execute()
|
||||
require.Error(t, err, "dry-run must reject identically to live path")
|
||||
|
||||
var typed *cmdutil.Error
|
||||
require.True(t, errors.As(err, &typed), "expected *cmdutil.Error, got %T %v", err, err)
|
||||
assert.Equal(t, cmdutil.CodeInputMissingFlag, typed.Code, "must use the same code runEdit emits")
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
package kb
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/Tencent/WeKnora/cli/internal/cmdutil"
|
||||
"github.com/Tencent/WeKnora/cli/internal/iostreams"
|
||||
"github.com/Tencent/WeKnora/cli/internal/prompt"
|
||||
sdk "github.com/Tencent/WeKnora/client"
|
||||
)
|
||||
|
||||
// fakeEditSvc captures the (id, request) pair handed to UpdateKnowledgeBase
|
||||
// and scripts the GetKnowledgeBase fetch used by the fetch-then-update path.
|
||||
type fakeEditSvc struct {
|
||||
current *sdk.KnowledgeBase // returned by GetKnowledgeBase
|
||||
currentErr error
|
||||
gotID string
|
||||
gotReq *sdk.UpdateKnowledgeBaseRequest
|
||||
resp *sdk.KnowledgeBase
|
||||
err error
|
||||
}
|
||||
|
||||
func (f *fakeEditSvc) GetKnowledgeBase(_ context.Context, id string) (*sdk.KnowledgeBase, error) {
|
||||
if f.currentErr != nil {
|
||||
return nil, f.currentErr
|
||||
}
|
||||
if f.current != nil {
|
||||
return f.current, nil
|
||||
}
|
||||
return &sdk.KnowledgeBase{ID: id}, nil
|
||||
}
|
||||
|
||||
func (f *fakeEditSvc) UpdateKnowledgeBase(_ context.Context, id string, req *sdk.UpdateKnowledgeBaseRequest) (*sdk.KnowledgeBase, error) {
|
||||
f.gotID = id
|
||||
f.gotReq = req
|
||||
return f.resp, f.err
|
||||
}
|
||||
|
||||
func TestEdit_RequiresAtLeastOneFlag(t *testing.T) {
|
||||
_, _ = iostreams.SetForTest(t)
|
||||
svc := &fakeEditSvc{}
|
||||
err := runEdit(context.Background(), &EditOptions{}, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, svc, "kb_abc")
|
||||
require.Error(t, err)
|
||||
var typed *cmdutil.Error
|
||||
require.ErrorAs(t, err, &typed)
|
||||
assert.Equal(t, cmdutil.CodeInputMissingFlag, typed.Code)
|
||||
assert.Contains(t, typed.Hint, "--name")
|
||||
assert.Contains(t, typed.Hint, "--description")
|
||||
}
|
||||
|
||||
// When only --name is passed, the request must carry the user's new name
|
||||
// AND the current Description (preserved via the fetch). Sending Description=""
|
||||
// would clobber the server-side value because UpdateKnowledgeBaseRequest
|
||||
// fields are `string`, not `*string`.
|
||||
func TestEdit_OnlyName_PreservesCurrentDescription(t *testing.T) {
|
||||
out, _ := iostreams.SetForTest(t)
|
||||
svc := &fakeEditSvc{
|
||||
current: &sdk.KnowledgeBase{ID: "kb_abc", Name: "old", Description: "keep me"},
|
||||
resp: &sdk.KnowledgeBase{ID: "kb_abc", Name: "new", Description: "keep me"},
|
||||
}
|
||||
opts := &EditOptions{}
|
||||
opts.Name = stringPtr("new")
|
||||
require.NoError(t, runEdit(context.Background(), opts, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, svc, "kb_abc"))
|
||||
|
||||
assert.Equal(t, "kb_abc", svc.gotID)
|
||||
require.NotNil(t, svc.gotReq)
|
||||
assert.Equal(t, "new", svc.gotReq.Name)
|
||||
assert.Equal(t, "keep me", svc.gotReq.Description, "Description must be preserved from fetch when not in --description")
|
||||
assert.Contains(t, out.String(), "kb_abc")
|
||||
}
|
||||
|
||||
// Symmetric: only --description must preserve current Name. The server's
|
||||
// `Name required` validation made this case fail without the fetch.
|
||||
func TestEdit_OnlyDescription_PreservesCurrentName(t *testing.T) {
|
||||
_, _ = iostreams.SetForTest(t)
|
||||
svc := &fakeEditSvc{
|
||||
current: &sdk.KnowledgeBase{ID: "kb_abc", Name: "keep me", Description: "old"},
|
||||
resp: &sdk.KnowledgeBase{ID: "kb_abc"},
|
||||
}
|
||||
opts := &EditOptions{}
|
||||
opts.Description = stringPtr("new desc")
|
||||
require.NoError(t, runEdit(context.Background(), opts, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, svc, "kb_abc"))
|
||||
|
||||
require.NotNil(t, svc.gotReq)
|
||||
assert.Equal(t, "new desc", svc.gotReq.Description)
|
||||
assert.Equal(t, "keep me", svc.gotReq.Name, "Name must be preserved from fetch when not in --name")
|
||||
}
|
||||
|
||||
func TestEdit_BothFlags(t *testing.T) {
|
||||
_, _ = iostreams.SetForTest(t)
|
||||
svc := &fakeEditSvc{resp: &sdk.KnowledgeBase{ID: "kb_abc"}}
|
||||
opts := &EditOptions{}
|
||||
opts.Name = stringPtr("renamed")
|
||||
opts.Description = stringPtr("new desc")
|
||||
require.NoError(t, runEdit(context.Background(), opts, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, svc, "kb_abc"))
|
||||
assert.Equal(t, "renamed", svc.gotReq.Name)
|
||||
assert.Equal(t, "new desc", svc.gotReq.Description)
|
||||
}
|
||||
|
||||
func TestEdit_NotFound(t *testing.T) {
|
||||
_, _ = iostreams.SetForTest(t)
|
||||
// 404 must come from the GetKnowledgeBase pre-fetch in the fetch-then-
|
||||
// update flow - that's the first server roundtrip when the id is bad.
|
||||
svc := &fakeEditSvc{currentErr: errors.New("HTTP error 404: not found")}
|
||||
opts := &EditOptions{}
|
||||
opts.Name = stringPtr("x")
|
||||
err := runEdit(context.Background(), opts, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, svc, "kb_missing")
|
||||
require.Error(t, err)
|
||||
var typed *cmdutil.Error
|
||||
require.ErrorAs(t, err, &typed)
|
||||
assert.Equal(t, cmdutil.CodeResourceNotFound, typed.Code)
|
||||
}
|
||||
|
||||
func stringPtr(s string) *string { return &s }
|
||||
|
||||
// withRootHarnessKB wraps `weknora kb update ...` under a synthetic root cmd
|
||||
// that registers the global persistent flags (mirrors addGlobalFlags in
|
||||
// cmd/root.go). Required because NewCmdEdit reads --yes and --format from the
|
||||
// persistent flag set.
|
||||
func withRootHarnessKB(edit *cobra.Command, args ...string) *cobra.Command {
|
||||
root := &cobra.Command{Use: "weknora"}
|
||||
pf := root.PersistentFlags()
|
||||
pf.BoolP("yes", "y", false, "")
|
||||
pf.String("format", "", "Output format: text | json | ndjson")
|
||||
pf.StringP("jq", "q", "", "")
|
||||
kb := &cobra.Command{Use: "kb"}
|
||||
kb.AddCommand(edit)
|
||||
root.AddCommand(kb)
|
||||
root.SetArgs(append([]string{"kb", "update"}, args...))
|
||||
root.SetContext(context.Background())
|
||||
root.SilenceErrors = true
|
||||
root.SilenceUsage = true
|
||||
return root
|
||||
}
|
||||
|
||||
// TestKBEdit_RequiresConfirmation asserts that without -y (non-TTY / JSON
|
||||
// mode), kb update returns input.confirmation_required (exit 10).
|
||||
func TestKBEdit_RequiresConfirmation(t *testing.T) {
|
||||
iostreams.SetForTest(t) // non-TTY
|
||||
svc := &fakeEditSvc{
|
||||
current: &sdk.KnowledgeBase{ID: "kb_abc", Name: "old"},
|
||||
resp: &sdk.KnowledgeBase{ID: "kb_abc", Name: "new"},
|
||||
}
|
||||
f := &cmdutil.Factory{
|
||||
Client: func() (*sdk.Client, error) { return nil, nil },
|
||||
Prompter: func() prompt.Prompter { return prompt.AgentPrompter{} },
|
||||
}
|
||||
_ = svc // gate fires before SDK call
|
||||
root := withRootHarnessKB(NewCmdEdit(f), "kb_abc", "--name", "new", "--format", "json")
|
||||
err := root.Execute()
|
||||
require.Error(t, err)
|
||||
var ce *cmdutil.Error
|
||||
require.ErrorAs(t, err, &ce)
|
||||
assert.Equal(t, cmdutil.CodeInputConfirmationRequired, ce.Code)
|
||||
assert.Equal(t, 10, cmdutil.ExitCode(err))
|
||||
// retry argv must include -y
|
||||
assert.Contains(t, ce.RetryArgv, "-y")
|
||||
assert.Contains(t, ce.RetryArgv, "kb_abc")
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
// Package kb holds the `weknora kb` command tree: list / view / create /
|
||||
// update / delete / pin / unpin. Verb set follows common CRUD vocabulary
|
||||
// (list/view/create/update/delete) plus pin/unpin. Bulk content deletion
|
||||
// is exposed via `weknora doc delete --all --kb=<id>`.
|
||||
package kb
|
||||
|
||||
import (
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/Tencent/WeKnora/cli/internal/cmdutil"
|
||||
)
|
||||
|
||||
// NewCmd builds the `weknora kb` parent command.
|
||||
func NewCmd(f *cmdutil.Factory) *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "kb",
|
||||
Short: "Manage knowledge bases",
|
||||
}
|
||||
cmd.AddCommand(NewCmdList(f))
|
||||
cmd.AddCommand(NewCmdView(f))
|
||||
cmd.AddCommand(NewCmdCreate(f))
|
||||
cmd.AddCommand(NewCmdEdit(f))
|
||||
cmd.AddCommand(NewCmdDelete(f))
|
||||
cmd.AddCommand(NewCmdPin(f))
|
||||
cmd.AddCommand(NewCmdUnpin(f))
|
||||
cmd.AddCommand(NewCmdStatus(f))
|
||||
cmd.AddCommand(NewCmdCheck(f))
|
||||
cmd.AddCommand(NewCmdConfig(f)) // `config` also hosts the `config set` write subcommand
|
||||
return cmd
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
package kb
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"sort"
|
||||
"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"
|
||||
)
|
||||
|
||||
// kbListFields enumerates the fields surfaced for `--format json` discovery on
|
||||
// `kb list`. Nested config structs (chunking / image / FAQ / VLM / storage
|
||||
// / extract) are intentionally omitted - users wanting those can use `--jq`
|
||||
// against the full object.
|
||||
var kbListFields = []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",
|
||||
}
|
||||
|
||||
// ListOptions captures `kb list` filter flag state.
|
||||
type ListOptions struct {
|
||||
Pinned bool // --pinned: client-side filter to KBs with IsPinned == true
|
||||
// Limit caps the returned slice client-side. 0 = no cap, 1..10000 = explicit.
|
||||
// The KB list SDK is unpaginated; --all-pages is intentionally not exposed
|
||||
// because it would be a no-op.
|
||||
Limit int
|
||||
}
|
||||
|
||||
// ListService is the narrow SDK surface this command depends on.
|
||||
type ListService interface {
|
||||
ListKnowledgeBases(ctx context.Context) ([]sdk.KnowledgeBase, error)
|
||||
}
|
||||
|
||||
// NewCmdList builds `weknora kb list`.
|
||||
func NewCmdList(f *cmdutil.Factory) *cobra.Command {
|
||||
opts := &ListOptions{}
|
||||
cmd := &cobra.Command{
|
||||
Use: "list",
|
||||
Short: "List knowledge bases visible to the active profile",
|
||||
Long: `List knowledge bases visible to the active profile, sorted by most recently updated. Pass --pinned to restrict to pinned KBs.`,
|
||||
Args: cobra.NoArgs,
|
||||
RunE: func(c *cobra.Command, _ []string) error {
|
||||
fopts, err := cmdutil.CheckFormatFlag(c)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
fopts.ResolveDefault(iostreams.IO.IsStdoutTTY())
|
||||
// Validate static input before building the client so a bad --limit
|
||||
// returns input.invalid_argument (exit 5) rather than an auth error
|
||||
// (exit 3) when no profile is configured.
|
||||
if err := validateListOpts(opts); err != nil {
|
||||
return err
|
||||
}
|
||||
cli, err := f.Client()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return runList(c.Context(), opts, fopts, cli)
|
||||
},
|
||||
}
|
||||
cmd.Flags().BoolVar(&opts.Pinned, "pinned", false, "Only show pinned knowledge bases")
|
||||
cmd.Flags().IntVarP(&opts.Limit, "limit", "L", 30, "Maximum results to return — client-side cap; meta.has_more/total_count report the full size (1..10000)")
|
||||
cmdutil.AddFormatFlag(cmd, kbListFields...)
|
||||
cmdutil.SetAgentHelp(cmd, cmdutil.AgentHelp{
|
||||
UsedFor: "List knowledge bases in the current tenant. --format json emits the standard envelope {ok, data:[...], meta:{count}, profile}.",
|
||||
Examples: []string{"weknora kb list --format json"},
|
||||
Output: "envelope.data is an array of KnowledgeBase objects with id, name, is_pinned, type, embedding_model_id; meta.total_count is the full tenant set and meta.has_more=true means --limit truncated it (raise --limit to get the rest)",
|
||||
})
|
||||
return cmd
|
||||
}
|
||||
|
||||
// validateListOpts checks --limit. Called from RunE before the client is built
|
||||
// (so a bad value surfaces as exit 5, not an auth error) and at runList's top
|
||||
// for direct callers; idempotent.
|
||||
func validateListOpts(opts *ListOptions) error {
|
||||
if opts.Limit < 1 || opts.Limit > 10000 {
|
||||
return &cmdutil.Error{
|
||||
Code: cmdutil.CodeInputInvalidArgument,
|
||||
Message: fmt.Sprintf("--limit must be in 1..10000, got %d", opts.Limit),
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func runList(ctx context.Context, opts *ListOptions, fopts *cmdutil.FormatOptions, svc ListService) error {
|
||||
if err := validateListOpts(opts); err != nil {
|
||||
return err
|
||||
}
|
||||
items, err := svc.ListKnowledgeBases(ctx)
|
||||
if err != nil {
|
||||
return cmdutil.WrapHTTP(err, "list knowledge bases")
|
||||
}
|
||||
if items == nil {
|
||||
items = []sdk.KnowledgeBase{} // ensure JSON [] not null
|
||||
}
|
||||
if opts.Pinned {
|
||||
filtered := items[:0]
|
||||
for _, kb := range items {
|
||||
if kb.IsPinned {
|
||||
filtered = append(filtered, kb)
|
||||
}
|
||||
}
|
||||
items = filtered
|
||||
}
|
||||
// Default sort by updated_at desc. Server return order is not
|
||||
// guaranteed, so client-side sort makes output deterministic regardless
|
||||
// of backend storage choices.
|
||||
sort.Slice(items, func(i, j int) bool {
|
||||
return items[i].UpdatedAt.After(items[j].UpdatedAt)
|
||||
})
|
||||
// The KB list SDK is unpaginated — it returns every KB in one call —
|
||||
// so the CLI holds the true total and can tell the caller whether the
|
||||
// client-side --limit dropped any. total_count is the full count;
|
||||
// has_more flags that --limit truncated it (raise --limit to get the
|
||||
// rest — there is no server cursor to continue with).
|
||||
total := len(items)
|
||||
truncated := false
|
||||
if opts.Limit > 0 && len(items) > opts.Limit {
|
||||
items = items[:opts.Limit]
|
||||
truncated = true
|
||||
}
|
||||
|
||||
if fopts.WantsJSON() {
|
||||
meta := &output.Meta{Count: output.IntPtr(len(items)), HasMore: truncated, TotalCount: output.IntPtr(total)}
|
||||
return fopts.Emit(iostreams.IO.Out, items, meta)
|
||||
}
|
||||
|
||||
if len(items) == 0 {
|
||||
if opts.Pinned {
|
||||
fmt.Fprintln(iostreams.IO.Out, "(no pinned knowledge bases)")
|
||||
return nil
|
||||
}
|
||||
fmt.Fprintln(iostreams.IO.Out, "(no knowledge bases)")
|
||||
return nil
|
||||
}
|
||||
|
||||
tw := tabwriter.NewWriter(iostreams.IO.Out, 0, 0, 2, ' ', 0)
|
||||
fmt.Fprintln(tw, "ID\tNAME\tDOCS\tUPDATED")
|
||||
now := time.Now()
|
||||
for _, kb := range items {
|
||||
name := text.Truncate(40, kb.Name)
|
||||
docs := text.Pluralize(int(kb.KnowledgeCount), "doc")
|
||||
updated := text.FuzzyAgo(now, kb.UpdatedAt)
|
||||
fmt.Fprintf(tw, "%s\t%s\t%s\t%s\n", kb.ID, name, docs, updated)
|
||||
}
|
||||
return tw.Flush()
|
||||
}
|
||||
@@ -0,0 +1,242 @@
|
||||
package kb
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/Tencent/WeKnora/cli/internal/cmdutil"
|
||||
"github.com/Tencent/WeKnora/cli/internal/iostreams"
|
||||
sdk "github.com/Tencent/WeKnora/client"
|
||||
)
|
||||
|
||||
type fakeListSvc struct {
|
||||
items []sdk.KnowledgeBase
|
||||
err error
|
||||
}
|
||||
|
||||
func (f *fakeListSvc) ListKnowledgeBases(ctx context.Context) ([]sdk.KnowledgeBase, error) {
|
||||
return f.items, f.err
|
||||
}
|
||||
|
||||
func TestList_Empty_Text(t *testing.T) {
|
||||
out, _ := iostreams.SetForTest(t)
|
||||
if err := runList(context.Background(), &ListOptions{Limit: 30}, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, &fakeListSvc{items: []sdk.KnowledgeBase{}}); err != nil {
|
||||
t.Fatalf("runList: %v", err)
|
||||
}
|
||||
if !strings.Contains(out.String(), "(no knowledge bases)") {
|
||||
t.Errorf("empty output expected '(no knowledge bases)', got %q", out.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestList_Empty_JSON(t *testing.T) {
|
||||
out, _ := iostreams.SetForTest(t)
|
||||
fopts := &cmdutil.FormatOptions{Mode: cmdutil.FormatJSON}
|
||||
if err := runList(context.Background(), &ListOptions{Limit: 30}, fopts, &fakeListSvc{items: []sdk.KnowledgeBase{}}); err != nil {
|
||||
t.Fatalf("runList: %v", err)
|
||||
}
|
||||
var env struct {
|
||||
OK bool `json:"ok"`
|
||||
Data []sdk.KnowledgeBase `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(out.Bytes(), &env); err != nil {
|
||||
t.Fatalf("parse: %v\n%s", err, out.String())
|
||||
}
|
||||
if !env.OK {
|
||||
t.Error("envelope.ok must be true")
|
||||
}
|
||||
if len(env.Data) != 0 {
|
||||
t.Errorf("expected empty data, got %d items", len(env.Data))
|
||||
}
|
||||
}
|
||||
|
||||
func TestList_NonEmpty_Text_RenderColumns(t *testing.T) {
|
||||
out, _ := iostreams.SetForTest(t)
|
||||
now := time.Now()
|
||||
items := []sdk.KnowledgeBase{
|
||||
{ID: "kb1", Name: "Marketing", KnowledgeCount: 5, UpdatedAt: now.Add(-3 * time.Hour)},
|
||||
{ID: "kb2", Name: "Engineering", KnowledgeCount: 1, UpdatedAt: now.Add(-2 * 24 * time.Hour)},
|
||||
}
|
||||
if err := runList(context.Background(), &ListOptions{Limit: 30}, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, &fakeListSvc{items: items}); err != nil {
|
||||
t.Fatalf("runList: %v", err)
|
||||
}
|
||||
got := out.String()
|
||||
for _, want := range []string{"ID", "NAME", "DOCS", "UPDATED", "kb1", "Marketing", "5 docs", "kb2", "Engineering", "1 doc"} {
|
||||
if !strings.Contains(got, want) {
|
||||
t.Errorf("output missing %q in:\n%s", want, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestList_JSON_JQProjection(t *testing.T) {
|
||||
out, _ := iostreams.SetForTest(t)
|
||||
now := time.Now()
|
||||
items := []sdk.KnowledgeBase{
|
||||
{ID: "kb1", Name: "Marketing", Description: "MKT desc", UpdatedAt: now},
|
||||
}
|
||||
// --jq projects from the envelope; .data[] | ... extracts from the array inside envelope.
|
||||
fopts := &cmdutil.FormatOptions{Mode: cmdutil.FormatJSON, JQ: ".data[] | {id, name}"}
|
||||
if err := runList(context.Background(), &ListOptions{Limit: 30}, fopts, &fakeListSvc{items: items}); err != nil {
|
||||
t.Fatalf("runList: %v", err)
|
||||
}
|
||||
var item map[string]any
|
||||
if err := json.Unmarshal(out.Bytes(), &item); err != nil {
|
||||
t.Fatalf("parse: %v\n%s", err, out.String())
|
||||
}
|
||||
if item["id"] != "kb1" || item["name"] != "Marketing" {
|
||||
t.Errorf("kept fields wrong: %+v", item)
|
||||
}
|
||||
if _, has := item["description"]; has {
|
||||
t.Errorf("description should be dropped, got: %+v", item)
|
||||
}
|
||||
}
|
||||
|
||||
func TestList_JSON_JQ(t *testing.T) {
|
||||
out, _ := iostreams.SetForTest(t)
|
||||
now := time.Now()
|
||||
items := []sdk.KnowledgeBase{
|
||||
{ID: "kb1", Name: "Marketing", UpdatedAt: now},
|
||||
{ID: "kb2", Name: "Engineering", UpdatedAt: now.Add(-time.Hour)},
|
||||
}
|
||||
// .data | length counts the items inside the envelope's data array.
|
||||
fopts := &cmdutil.FormatOptions{Mode: cmdutil.FormatJSON, JQ: ".data | length"}
|
||||
if err := runList(context.Background(), &ListOptions{Limit: 30}, fopts, &fakeListSvc{items: items}); err != nil {
|
||||
t.Fatalf("runList: %v", err)
|
||||
}
|
||||
if got := strings.TrimSpace(out.String()); got != "2" {
|
||||
t.Errorf("expected '2', got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestList_PinnedFilter(t *testing.T) {
|
||||
out, _ := iostreams.SetForTest(t)
|
||||
now := time.Now()
|
||||
items := []sdk.KnowledgeBase{
|
||||
{ID: "kb1", Name: "Marketing", IsPinned: true, UpdatedAt: now},
|
||||
{ID: "kb2", Name: "Engineering", IsPinned: false, UpdatedAt: now.Add(-time.Hour)},
|
||||
{ID: "kb3", Name: "Finance", IsPinned: true, UpdatedAt: now.Add(-2 * time.Hour)},
|
||||
}
|
||||
if err := runList(context.Background(), &ListOptions{Pinned: true, Limit: 30}, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, &fakeListSvc{items: items}); err != nil {
|
||||
t.Fatalf("runList: %v", err)
|
||||
}
|
||||
got := out.String()
|
||||
if !strings.Contains(got, "kb1") || !strings.Contains(got, "kb3") {
|
||||
t.Errorf("expected pinned KBs kb1 and kb3 in output, got:\n%s", got)
|
||||
}
|
||||
if strings.Contains(got, "kb2") {
|
||||
t.Errorf("unpinned kb2 should be filtered out, got:\n%s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestList_PinnedFilter_NoPinned_TextMessage(t *testing.T) {
|
||||
out, _ := iostreams.SetForTest(t)
|
||||
items := []sdk.KnowledgeBase{
|
||||
{ID: "kb1", Name: "Marketing", IsPinned: false, UpdatedAt: time.Now()},
|
||||
}
|
||||
if err := runList(context.Background(), &ListOptions{Pinned: true, Limit: 30}, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, &fakeListSvc{items: items}); err != nil {
|
||||
t.Fatalf("runList: %v", err)
|
||||
}
|
||||
if !strings.Contains(out.String(), "(no pinned knowledge bases)") {
|
||||
t.Errorf("expected pinned-specific empty message, got: %q", out.String())
|
||||
}
|
||||
}
|
||||
|
||||
// makeKBs returns N KBs with distinct IDs and descending UpdatedAt.
|
||||
func makeKBs(n int) []sdk.KnowledgeBase {
|
||||
base := time.Now()
|
||||
out := make([]sdk.KnowledgeBase, n)
|
||||
for i := 0; i < n; i++ {
|
||||
out[i] = sdk.KnowledgeBase{
|
||||
ID: fmt.Sprintf("kb_%02d", i),
|
||||
Name: fmt.Sprintf("kb-%02d", i),
|
||||
UpdatedAt: base.Add(-time.Duration(i) * time.Hour),
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func TestList_Limit_CapsResults(t *testing.T) {
|
||||
out, _ := iostreams.SetForTest(t)
|
||||
svc := &fakeListSvc{items: makeKBs(20)}
|
||||
fopts := &cmdutil.FormatOptions{Mode: cmdutil.FormatJSON}
|
||||
if err := runList(context.Background(), &ListOptions{Limit: 5}, fopts, svc); err != nil {
|
||||
t.Fatalf("runList: %v", err)
|
||||
}
|
||||
got := strings.Count(out.String(), `"id":"kb_`)
|
||||
if got != 5 {
|
||||
t.Errorf("--limit 5 should slice 20 items to 5; got %d in:\n%s", got, out.String())
|
||||
}
|
||||
}
|
||||
|
||||
// TestList_Truncation_SignalsHasMoreAndTotal pins that a client-side --limit
|
||||
// truncation tells the agent it did NOT get everything: has_more=true and
|
||||
// total_count=full set. Regression: kb list silently dropped items past
|
||||
// --limit with no completeness signal, so an agent listing to find a KB by
|
||||
// name could miss KBs beyond position 30 and never know.
|
||||
func TestList_Truncation_SignalsHasMoreAndTotal(t *testing.T) {
|
||||
out, _ := iostreams.SetForTest(t)
|
||||
svc := &fakeListSvc{items: makeKBs(20)}
|
||||
fopts := &cmdutil.FormatOptions{Mode: cmdutil.FormatJSON}
|
||||
if err := runList(context.Background(), &ListOptions{Limit: 5}, fopts, svc); err != nil {
|
||||
t.Fatalf("runList: %v", err)
|
||||
}
|
||||
if !strings.Contains(out.String(), `"has_more":true`) {
|
||||
t.Errorf("truncated list must set has_more:true; got:\n%s", out.String())
|
||||
}
|
||||
if !strings.Contains(out.String(), `"total_count":20`) {
|
||||
t.Errorf("truncated list must report total_count:20; got:\n%s", out.String())
|
||||
}
|
||||
}
|
||||
|
||||
// TestList_NoTruncation_OmitsHasMore pins that when --limit does NOT truncate,
|
||||
// has_more is absent (omitempty) so the agent reads "complete".
|
||||
func TestList_NoTruncation_OmitsHasMore(t *testing.T) {
|
||||
out, _ := iostreams.SetForTest(t)
|
||||
svc := &fakeListSvc{items: makeKBs(3)}
|
||||
fopts := &cmdutil.FormatOptions{Mode: cmdutil.FormatJSON}
|
||||
if err := runList(context.Background(), &ListOptions{Limit: 30}, fopts, svc); err != nil {
|
||||
t.Fatalf("runList: %v", err)
|
||||
}
|
||||
if strings.Contains(out.String(), `"has_more"`) {
|
||||
t.Errorf("non-truncated list must omit has_more; got:\n%s", out.String())
|
||||
}
|
||||
if !strings.Contains(out.String(), `"total_count":3`) {
|
||||
t.Errorf("list must report total_count:3; got:\n%s", out.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestList_Limit_Zero_Rejected(t *testing.T) {
|
||||
_, _ = iostreams.SetForTest(t)
|
||||
svc := &fakeListSvc{items: makeKBs(7)}
|
||||
fopts := &cmdutil.FormatOptions{Mode: cmdutil.FormatJSON}
|
||||
err := runList(context.Background(), &ListOptions{Limit: 0}, fopts, svc)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for --limit 0")
|
||||
}
|
||||
var typed *cmdutil.Error
|
||||
if !errors.As(err, &typed) {
|
||||
t.Fatalf("expected *cmdutil.Error, got %T: %v", err, err)
|
||||
}
|
||||
if typed.Code != cmdutil.CodeInputInvalidArgument {
|
||||
t.Errorf("expected CodeInputInvalidArgument, got %v", typed.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestList_Limit_Negative_Rejected(t *testing.T) {
|
||||
_, _ = iostreams.SetForTest(t)
|
||||
err := runList(context.Background(), &ListOptions{Limit: -1}, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, &fakeListSvc{items: makeKBs(3)})
|
||||
if err == nil {
|
||||
t.Fatal("expected error for negative --limit")
|
||||
}
|
||||
var typed *cmdutil.Error
|
||||
if !errors.As(err, &typed) {
|
||||
t.Fatalf("expected *cmdutil.Error, got %T: %v", err, err)
|
||||
}
|
||||
if typed.Code != cmdutil.CodeInputInvalidArgument {
|
||||
t.Errorf("expected CodeInputInvalidArgument, got %v", typed.Code)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
package kb
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/Tencent/WeKnora/cli/internal/cmdutil"
|
||||
"github.com/Tencent/WeKnora/cli/internal/iostreams"
|
||||
sdk "github.com/Tencent/WeKnora/client"
|
||||
)
|
||||
|
||||
// kbPinFields enumerates the fields surfaced for `--format json` discovery on
|
||||
// `kb pin` / `kb unpin`. The toggle result is the KnowledgeBase; the user-
|
||||
// relevant fields here are the id and the new pin state.
|
||||
var kbPinFields = []string{"id", "is_pinned"}
|
||||
|
||||
type PinOptions struct {
|
||||
DryRun bool
|
||||
}
|
||||
|
||||
// PinService is the narrow SDK surface this command depends on. The CLI
|
||||
// reads current state before toggling so `pin`/`unpin` are idempotent — the
|
||||
// server endpoint is only a toggle. Pin state is read from the LIST endpoint:
|
||||
// the single-KB GET does not carry per-user pin state (pin is a per-user
|
||||
// list-ordering preference, surfaced where the user sees it — in `kb list`),
|
||||
// so the list is the canonical source.
|
||||
type PinService interface {
|
||||
ListKnowledgeBases(ctx context.Context) ([]sdk.KnowledgeBase, error)
|
||||
TogglePinKnowledgeBase(ctx context.Context, id string) (*sdk.KnowledgeBase, error)
|
||||
}
|
||||
|
||||
// NewCmdPin builds `weknora kb pin <id>`.
|
||||
func NewCmdPin(f *cmdutil.Factory) *cobra.Command {
|
||||
return newPinCmd(f, "pin", true, "Pin a knowledge base to the top of the list")
|
||||
}
|
||||
|
||||
// NewCmdUnpin builds `weknora kb unpin <id>`.
|
||||
func NewCmdUnpin(f *cmdutil.Factory) *cobra.Command {
|
||||
return newPinCmd(f, "unpin", false, "Unpin a knowledge base")
|
||||
}
|
||||
|
||||
func newPinCmd(f *cmdutil.Factory, use string, want bool, short string) *cobra.Command {
|
||||
opts := &PinOptions{}
|
||||
cmd := &cobra.Command{
|
||||
Use: use + " <kb-id>",
|
||||
Short: short,
|
||||
Long: short + ". Idempotent: reads the current pin state and toggles only if different, so re-running on a KB already in the target state is a no-op.",
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: func(c *cobra.Command, args []string) error {
|
||||
fopts, err := cmdutil.CheckFormatFlag(c)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
fopts.ResolveDefault(iostreams.IO.IsStdoutTTY())
|
||||
action := "kb.pin"
|
||||
if !want {
|
||||
action = "kb.unpin"
|
||||
}
|
||||
if handled, err := cmdutil.HandleDryRun(c, opts.DryRun, cmdutil.DryRunPlan{
|
||||
Action: action,
|
||||
Args: map[string]any{"kb": args[0]},
|
||||
}); handled {
|
||||
return err
|
||||
}
|
||||
cli, err := f.Client()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return runPin(c.Context(), opts, fopts, cli, args[0], want)
|
||||
},
|
||||
}
|
||||
cmdutil.AddFormatFlag(cmd, kbPinFields...)
|
||||
cmdutil.AddDryRunFlag(cmd, &opts.DryRun)
|
||||
cmdutil.SetAgentHelp(cmd, cmdutil.AgentHelp{
|
||||
UsedFor: use + " a knowledge base (idempotent: a no-op if it is already in the target state)",
|
||||
RequiredFlags: []string{"<kb-id> (positional)"},
|
||||
Examples: []string{"weknora kb " + use + " kb_abc"},
|
||||
Output: "envelope.data is the KnowledgeBase with is_pinned reflecting the new state",
|
||||
})
|
||||
return cmd
|
||||
}
|
||||
|
||||
func runPin(ctx context.Context, opts *PinOptions, fopts *cmdutil.FormatOptions, svc PinService, id string, want bool) error {
|
||||
verb := "pin"
|
||||
if !want {
|
||||
verb = "unpin"
|
||||
}
|
||||
// Read the current pin state from the list (the canonical per-user pin
|
||||
// source); the single-KB GET doesn't carry it. This makes pin/unpin
|
||||
// idempotent: we toggle only when the state actually needs to change.
|
||||
kbs, err := svc.ListKnowledgeBases(ctx)
|
||||
if err != nil {
|
||||
return cmdutil.WrapHTTP(err, "list knowledge bases")
|
||||
}
|
||||
var current *sdk.KnowledgeBase
|
||||
for i := range kbs {
|
||||
if kbs[i].ID == id {
|
||||
current = &kbs[i]
|
||||
break
|
||||
}
|
||||
}
|
||||
if current == nil {
|
||||
return cmdutil.NewError(cmdutil.CodeResourceNotFound,
|
||||
fmt.Sprintf("knowledge base not found: %s", id))
|
||||
}
|
||||
if current.IsPinned == want {
|
||||
state := "pinned"
|
||||
if !want {
|
||||
state = "unpinned"
|
||||
}
|
||||
// No-op path: the resource is already in the requested state. We
|
||||
// emit the current resource so callers see the canonical shape on
|
||||
// both fresh-toggle and no-op paths. Text path prints a confirming
|
||||
// line; agents observe via the unchanged is_pinned field.
|
||||
if fopts.WantsJSON() {
|
||||
return fopts.Emit(iostreams.IO.Out, current, nil)
|
||||
}
|
||||
fmt.Fprintf(iostreams.IO.Out, "✓ %s is already %s\n", id, state)
|
||||
return nil
|
||||
}
|
||||
|
||||
updated, err := svc.TogglePinKnowledgeBase(ctx, id)
|
||||
if err != nil {
|
||||
return cmdutil.WrapHTTP(err, "%s knowledge base %s", verb, id)
|
||||
}
|
||||
if fopts.WantsJSON() {
|
||||
return fopts.Emit(iostreams.IO.Out, updated, nil)
|
||||
}
|
||||
state := "pinned"
|
||||
if !updated.IsPinned {
|
||||
state = "unpinned"
|
||||
}
|
||||
fmt.Fprintf(iostreams.IO.Out, "✓ %s %s\n", id, state)
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
package kb
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
"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"
|
||||
)
|
||||
|
||||
// fakePinSvc satisfies PinService: ListKnowledgeBases + TogglePinKnowledgeBase.
|
||||
// The fake's `current` is returned as the single KB in the list (the list is
|
||||
// the canonical pin-state source).
|
||||
type fakePinSvc struct {
|
||||
current sdk.KnowledgeBase
|
||||
getErr error
|
||||
toggleErr error
|
||||
toggleCalled bool
|
||||
}
|
||||
|
||||
func (f *fakePinSvc) ListKnowledgeBases(_ context.Context) ([]sdk.KnowledgeBase, error) {
|
||||
if f.getErr != nil {
|
||||
return nil, f.getErr
|
||||
}
|
||||
c := f.current
|
||||
if c.ID == "" {
|
||||
c.ID = "kb_abc" // tests address this id
|
||||
}
|
||||
return []sdk.KnowledgeBase{c}, nil
|
||||
}
|
||||
|
||||
func (f *fakePinSvc) TogglePinKnowledgeBase(_ context.Context, id string) (*sdk.KnowledgeBase, error) {
|
||||
f.toggleCalled = true
|
||||
if f.toggleErr != nil {
|
||||
return nil, f.toggleErr
|
||||
}
|
||||
c := f.current
|
||||
c.ID = id
|
||||
c.IsPinned = !c.IsPinned
|
||||
return &c, nil
|
||||
}
|
||||
|
||||
func TestPin_UnpinnedToPinned_CallsToggle(t *testing.T) {
|
||||
out, _ := iostreams.SetForTest(t)
|
||||
svc := &fakePinSvc{current: sdk.KnowledgeBase{IsPinned: false}}
|
||||
require.NoError(t, runPin(context.Background(), &PinOptions{}, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, svc, "kb_abc", true))
|
||||
assert.True(t, svc.toggleCalled, "must call toggle when current state differs")
|
||||
assert.Contains(t, out.String(), "kb_abc")
|
||||
}
|
||||
|
||||
func TestPin_AlreadyPinned_NoOp(t *testing.T) {
|
||||
out, _ := iostreams.SetForTest(t)
|
||||
svc := &fakePinSvc{current: sdk.KnowledgeBase{IsPinned: true}}
|
||||
require.NoError(t, runPin(context.Background(), &PinOptions{}, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, svc, "kb_abc", true))
|
||||
assert.False(t, svc.toggleCalled, "already pinned ⇒ must not call toggle")
|
||||
assert.Contains(t, out.String(), "already pinned")
|
||||
}
|
||||
|
||||
func TestUnpin_PinnedToUnpinned_CallsToggle(t *testing.T) {
|
||||
_, _ = iostreams.SetForTest(t)
|
||||
svc := &fakePinSvc{current: sdk.KnowledgeBase{IsPinned: true}}
|
||||
require.NoError(t, runPin(context.Background(), &PinOptions{}, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, svc, "kb_abc", false))
|
||||
assert.True(t, svc.toggleCalled)
|
||||
}
|
||||
|
||||
func TestUnpin_AlreadyUnpinned_NoOp(t *testing.T) {
|
||||
out, _ := iostreams.SetForTest(t)
|
||||
svc := &fakePinSvc{current: sdk.KnowledgeBase{IsPinned: false}}
|
||||
require.NoError(t, runPin(context.Background(), &PinOptions{}, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, svc, "kb_abc", false))
|
||||
assert.False(t, svc.toggleCalled, "already unpinned ⇒ must not call toggle")
|
||||
assert.Contains(t, out.String(), "already unpinned")
|
||||
}
|
||||
|
||||
func TestPin_NotFound(t *testing.T) {
|
||||
_, _ = iostreams.SetForTest(t)
|
||||
svc := &fakePinSvc{getErr: errors.New("HTTP error 404: not found")}
|
||||
err := runPin(context.Background(), &PinOptions{}, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, svc, "kb_missing", true)
|
||||
require.Error(t, err)
|
||||
var typed *cmdutil.Error
|
||||
require.ErrorAs(t, err, &typed)
|
||||
assert.Equal(t, cmdutil.CodeResourceNotFound, typed.Code)
|
||||
assert.False(t, svc.toggleCalled)
|
||||
}
|
||||
|
||||
func TestPin_ToggleError(t *testing.T) {
|
||||
_, _ = iostreams.SetForTest(t)
|
||||
svc := &fakePinSvc{
|
||||
current: sdk.KnowledgeBase{IsPinned: false},
|
||||
toggleErr: errors.New("HTTP error 500: internal"),
|
||||
}
|
||||
err := runPin(context.Background(), &PinOptions{}, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, svc, "kb_abc", true)
|
||||
require.Error(t, err)
|
||||
var typed *cmdutil.Error
|
||||
require.ErrorAs(t, err, &typed)
|
||||
assert.Equal(t, cmdutil.CodeServerError, typed.Code)
|
||||
}
|
||||
|
||||
func TestPin_JSON(t *testing.T) {
|
||||
out, _ := iostreams.SetForTest(t)
|
||||
svc := &fakePinSvc{current: sdk.KnowledgeBase{IsPinned: false}}
|
||||
require.NoError(t, runPin(context.Background(), &PinOptions{}, &cmdutil.FormatOptions{Mode: cmdutil.FormatJSON}, svc, "kb_abc", true))
|
||||
body := out.String()
|
||||
assert.Contains(t, body, `"is_pinned":true`)
|
||||
assert.Contains(t, body, `"id":"kb_abc"`)
|
||||
}
|
||||
|
||||
// TestPin_DryRun_NoServerCall: --dry-run must emit a kb.pin plan (exit 0)
|
||||
// without reaching the server, so pin/unpin honor the same mutation-preview
|
||||
// contract as create/edit/delete. Regression for pin/unpin lacking --dry-run.
|
||||
func TestPin_DryRun_NoServerCall(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name, want string
|
||||
cmd func(*cmdutil.Factory) *cobra.Command
|
||||
}{
|
||||
{"pin", "kb.pin", NewCmdPin},
|
||||
{"unpin", "kb.unpin", NewCmdUnpin},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
out, _ := iostreams.SetForTest(t)
|
||||
root := withRootHarness(tc.cmd(kbDryRunFactory(t)), "kb_x", "--dry-run", "--format", "json")
|
||||
require.NoError(t, root.Execute(), "dry-run must succeed without a client")
|
||||
var env struct {
|
||||
OK bool `json:"ok"`
|
||||
Meta struct {
|
||||
DryRun bool `json:"dry_run"`
|
||||
Plan map[string]any `json:"plan"`
|
||||
} `json:"meta"`
|
||||
}
|
||||
require.NoError(t, json.Unmarshal(out.Bytes(), &env), "got %q", out.String())
|
||||
assert.True(t, env.OK)
|
||||
assert.True(t, env.Meta.DryRun)
|
||||
assert.Equal(t, tc.want, env.Meta.Plan["action"])
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
package kb
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/Tencent/WeKnora/cli/internal/cmdutil"
|
||||
"github.com/Tencent/WeKnora/cli/internal/iostreams"
|
||||
sdk "github.com/Tencent/WeKnora/client"
|
||||
)
|
||||
|
||||
// StatusResult is the health-oriented response for `kb status <id>`.
|
||||
// Shallow read only: 1 HTTP call, no failed-doc aggregation.
|
||||
// For deep verification including failed_count, use `kb check <id>`.
|
||||
type StatusResult struct {
|
||||
ID string `json:"id"`
|
||||
Reachable bool `json:"reachable"`
|
||||
// RetrievalReady is false when the KB has no embedding model bound — it can
|
||||
// never index or retrieve until `kb config set` runs. Always emitted (no
|
||||
// omitempty) so a not-ready KB is visible, not silently green.
|
||||
RetrievalReady bool `json:"retrieval_ready"`
|
||||
KnowledgeCount int64 `json:"knowledge_count,omitempty"`
|
||||
ChunkCount int64 `json:"chunk_count,omitempty"`
|
||||
IsProcessing bool `json:"is_processing,omitempty"`
|
||||
ProcessingCount int64 `json:"processing_count,omitempty"`
|
||||
}
|
||||
|
||||
// StatusService is the narrow SDK surface needed for kb status.
|
||||
type StatusService interface {
|
||||
GetKnowledgeBase(ctx context.Context, id string) (*sdk.KnowledgeBase, error)
|
||||
}
|
||||
|
||||
var kbStatusFields = []string{
|
||||
"id", "reachable", "retrieval_ready", "knowledge_count", "chunk_count",
|
||||
"is_processing", "processing_count",
|
||||
}
|
||||
|
||||
// NewCmdStatus builds `weknora kb status <id>`.
|
||||
func NewCmdStatus(f *cmdutil.Factory) *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "status <kb-id>",
|
||||
Short: "Show health status of a knowledge base (shallow, 1 HTTP)",
|
||||
Long: `Show health-oriented fields for a KB.
|
||||
|
||||
1 HTTP call:
|
||||
reachable / knowledge_count / chunk_count / is_processing / processing_count
|
||||
|
||||
For deep verification including failed_count, use 'weknora kb check <id>'
|
||||
(1 + N HTTP, pages the doc list with parse_status=failed).
|
||||
|
||||
For full metadata (config / pinned / tenant), use 'weknora kb view <id>'.`,
|
||||
Example: ` weknora kb status kb_abc
|
||||
weknora kb status kb_abc --format json`,
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: func(c *cobra.Command, args []string) error {
|
||||
fopts, err := cmdutil.CheckFormatFlag(c)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
fopts.ResolveDefault(iostreams.IO.IsStdoutTTY())
|
||||
cli, err := f.Client()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
res, err := runStatus(c.Context(), cli, args[0])
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return emitStatus(res, fopts, iostreams.IO.Out)
|
||||
},
|
||||
}
|
||||
cmdutil.AddFormatFlag(cmd, kbStatusFields...)
|
||||
cmdutil.SetAgentHelp(cmd, cmdutil.AgentHelp{
|
||||
UsedFor: "shallow health probe of a knowledge base (one HTTP call): reachability, no failed-doc aggregation",
|
||||
RequiredFlags: []string{"<kb-id> (positional)"},
|
||||
Examples: []string{"weknora kb status kb_abc"},
|
||||
Output: "envelope.data is {id, reachable, retrieval_ready, ...}; retrieval_ready=false means no embedding model is bound (run `kb config set`), so the KB cannot index/retrieve; use `kb check` for deep failed-doc aggregation",
|
||||
})
|
||||
return cmd
|
||||
}
|
||||
|
||||
// runStatus is the testable core: fetch KB metadata and return a StatusResult.
|
||||
// Never returns an error for "kb not reachable" (Reachable=false carries
|
||||
// that signal).
|
||||
func runStatus(ctx context.Context, svc StatusService, id string) (*StatusResult, error) {
|
||||
kb, err := svc.GetKnowledgeBase(ctx, id)
|
||||
if err != nil {
|
||||
return &StatusResult{ID: id, Reachable: false}, nil
|
||||
}
|
||||
return &StatusResult{
|
||||
ID: kb.ID,
|
||||
Reachable: true,
|
||||
RetrievalReady: kb.EmbeddingModelID != "",
|
||||
KnowledgeCount: kb.KnowledgeCount,
|
||||
ChunkCount: kb.ChunkCount,
|
||||
IsProcessing: kb.IsProcessing,
|
||||
ProcessingCount: kb.ProcessingCount,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// emitStatus renders res using --format options. Mirrors emitWaitResult
|
||||
// pattern from cli/cmd/doc/wait.go for consistency.
|
||||
func emitStatus(res *StatusResult, fopts *cmdutil.FormatOptions, w io.Writer) error {
|
||||
switch fopts.Mode {
|
||||
case cmdutil.FormatJSON, cmdutil.FormatNDJSON:
|
||||
return fopts.Emit(w, res, nil)
|
||||
case cmdutil.FormatText, "":
|
||||
return writeStatusText(w, res)
|
||||
default:
|
||||
return fmt.Errorf("unsupported --format %q for kb status", fopts.Mode)
|
||||
}
|
||||
}
|
||||
|
||||
func writeStatusText(w io.Writer, res *StatusResult) error {
|
||||
fmt.Fprintf(w, "ID: %s\n", res.ID)
|
||||
fmt.Fprintf(w, "Reachable: %v\n", res.Reachable)
|
||||
if !res.Reachable {
|
||||
return nil
|
||||
}
|
||||
fmt.Fprintf(w, "Retrieval: %v%s\n", res.RetrievalReady, retrievalHint(res.RetrievalReady))
|
||||
fmt.Fprintf(w, "Knowledge: %d\n", res.KnowledgeCount)
|
||||
fmt.Fprintf(w, "Chunks: %d\n", res.ChunkCount)
|
||||
fmt.Fprintf(w, "Processing: %v (%d active)\n", res.IsProcessing, res.ProcessingCount)
|
||||
return nil
|
||||
}
|
||||
|
||||
// retrievalHint annotates a not-ready KB in text output with the fix.
|
||||
func retrievalHint(ready bool) string {
|
||||
if ready {
|
||||
return ""
|
||||
}
|
||||
return " ← no embedding model bound; run `weknora kb config set <id>`"
|
||||
}
|
||||
|
||||
// compile-time check: SDK client satisfies StatusService.
|
||||
var _ StatusService = (*sdk.Client)(nil)
|
||||
@@ -0,0 +1,110 @@
|
||||
package kb
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/Tencent/WeKnora/cli/internal/cmdutil"
|
||||
sdk "github.com/Tencent/WeKnora/client"
|
||||
)
|
||||
|
||||
type fakeStatusSvc struct {
|
||||
kb *sdk.KnowledgeBase
|
||||
getErr error
|
||||
}
|
||||
|
||||
func (f *fakeStatusSvc) GetKnowledgeBase(_ context.Context, id string) (*sdk.KnowledgeBase, error) {
|
||||
if f.getErr != nil {
|
||||
return nil, f.getErr
|
||||
}
|
||||
return f.kb, nil
|
||||
}
|
||||
|
||||
func TestRunStatus_ShallowFields(t *testing.T) {
|
||||
svc := &fakeStatusSvc{kb: &sdk.KnowledgeBase{
|
||||
ID: "kb_x",
|
||||
KnowledgeCount: 42,
|
||||
ChunkCount: 100,
|
||||
IsProcessing: true,
|
||||
ProcessingCount: 3,
|
||||
EmbeddingModelID: "emb_1",
|
||||
}}
|
||||
res, err := runStatus(context.Background(), svc, "kb_x")
|
||||
if err != nil {
|
||||
t.Fatalf("runStatus: %v", err)
|
||||
}
|
||||
if !res.Reachable {
|
||||
t.Error("Reachable=false, want true")
|
||||
}
|
||||
if res.KnowledgeCount != 42 || res.ChunkCount != 100 || res.ProcessingCount != 3 || !res.IsProcessing {
|
||||
t.Errorf("got %+v", res)
|
||||
}
|
||||
if !res.RetrievalReady {
|
||||
t.Error("RetrievalReady=false, want true when an embedding model is bound")
|
||||
}
|
||||
}
|
||||
|
||||
// A KB with no embedding model can never retrieve — the health probe must say
|
||||
// so (retrieval_ready=false), not report a silent all-green status.
|
||||
func TestRunStatus_RetrievalNotReadyWithoutEmbeddingModel(t *testing.T) {
|
||||
svc := &fakeStatusSvc{kb: &sdk.KnowledgeBase{ID: "kb_x", KnowledgeCount: 1}}
|
||||
res, err := runStatus(context.Background(), svc, "kb_x")
|
||||
if err != nil {
|
||||
t.Fatalf("runStatus: %v", err)
|
||||
}
|
||||
if res.RetrievalReady {
|
||||
t.Error("RetrievalReady=true, want false when no embedding model is bound")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunStatus_Unreachable(t *testing.T) {
|
||||
svc := &fakeStatusSvc{getErr: fmt.Errorf("404 not found")}
|
||||
res, err := runStatus(context.Background(), svc, "kb_x")
|
||||
if err != nil {
|
||||
t.Fatalf("runStatus should not return err on unreachable; got %v", err)
|
||||
}
|
||||
if res.Reachable {
|
||||
t.Error("Reachable=true, want false")
|
||||
}
|
||||
if res.ID != "kb_x" {
|
||||
t.Errorf("ID=%q, want kb_x (echoed even when unreachable)", res.ID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEmitStatus_JSON(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
res := &StatusResult{ID: "kb_x", Reachable: true, KnowledgeCount: 5}
|
||||
fopts := &cmdutil.FormatOptions{Mode: cmdutil.FormatJSON}
|
||||
if err := emitStatus(res, fopts, &buf); err != nil {
|
||||
t.Fatalf("emitStatus: %v", err)
|
||||
}
|
||||
var env struct {
|
||||
OK bool `json:"ok"`
|
||||
Data StatusResult `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(buf.Bytes(), &env); err != nil {
|
||||
t.Fatalf("not JSON: %v\n%s", err, buf.String())
|
||||
}
|
||||
got := env.Data
|
||||
if got.ID != "kb_x" || got.KnowledgeCount != 5 {
|
||||
t.Errorf("got %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEmitStatus_Text(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
res := &StatusResult{ID: "kb_x", Reachable: true, KnowledgeCount: 5, ChunkCount: 20, IsProcessing: true, ProcessingCount: 1}
|
||||
fopts := &cmdutil.FormatOptions{Mode: cmdutil.FormatText}
|
||||
if err := emitStatus(res, fopts, &buf); err != nil {
|
||||
t.Fatalf("emitStatus: %v", err)
|
||||
}
|
||||
for _, want := range []string{"kb_x", "5", "20", "true"} {
|
||||
if !strings.Contains(buf.String(), want) {
|
||||
t.Errorf("output missing %q:\n%s", want, buf.String())
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
package kb
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/Tencent/WeKnora/cli/internal/cmdutil"
|
||||
"github.com/Tencent/WeKnora/cli/internal/iostreams"
|
||||
"github.com/Tencent/WeKnora/cli/internal/text"
|
||||
sdk "github.com/Tencent/WeKnora/client"
|
||||
)
|
||||
|
||||
// kbViewFields enumerates the fields surfaced for `--format json` discovery on
|
||||
// `kb view`. Lists the KnowledgeBase top-level json tags; nested config
|
||||
// structs are omitted (use --jq for those).
|
||||
var kbViewFields = []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 ViewOptions struct{}
|
||||
|
||||
// ViewService is the narrow SDK surface this command depends on.
|
||||
// ListKnowledgeBases is used only to enrich is_pinned (the single-KB GET
|
||||
// doesn't carry per-user pin state — pin is a per-user list concept).
|
||||
type ViewService interface {
|
||||
GetKnowledgeBase(ctx context.Context, id string) (*sdk.KnowledgeBase, error)
|
||||
ListKnowledgeBases(ctx context.Context) ([]sdk.KnowledgeBase, error)
|
||||
}
|
||||
|
||||
// NewCmdView builds `weknora kb view <id>`.
|
||||
func NewCmdView(f *cmdutil.Factory) *cobra.Command {
|
||||
opts := &ViewOptions{}
|
||||
cmd := &cobra.Command{
|
||||
Use: "view <kb-id>",
|
||||
Short: "Show a knowledge base by ID",
|
||||
Long: `Fetch a knowledge base's full configuration: chunking settings, embedding / summary model IDs, knowledge_count, chunk_count.`,
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: func(c *cobra.Command, args []string) error {
|
||||
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 runView(c.Context(), opts, fopts, cli, args[0])
|
||||
},
|
||||
}
|
||||
cmdutil.AddFormatFlag(cmd, kbViewFields...)
|
||||
cmdutil.SetAgentHelp(cmd, cmdutil.AgentHelp{
|
||||
UsedFor: "fetch one knowledge base's full configuration by id",
|
||||
RequiredFlags: []string{"<kb-id> (positional)"},
|
||||
Examples: []string{
|
||||
"weknora kb view kb_abc",
|
||||
"weknora kb view kb_abc --jq .data.embedding_model_id",
|
||||
},
|
||||
Output: "envelope.data is the KnowledgeBase object (id, name, type, embedding/summary model ids, chunk_count, ...)",
|
||||
})
|
||||
return cmd
|
||||
}
|
||||
|
||||
func runView(ctx context.Context, opts *ViewOptions, fopts *cmdutil.FormatOptions, svc ViewService, id string) error {
|
||||
kb, err := svc.GetKnowledgeBase(ctx, id)
|
||||
if err != nil {
|
||||
return cmdutil.WrapHTTP(err, "get knowledge base %q", id)
|
||||
}
|
||||
// The single-KB GET doesn't stamp per-user pin state, so is_pinned would
|
||||
// always read false here. Enrich it from the list (the canonical pin
|
||||
// source) so the field is accurate. Best-effort: a list error leaves the
|
||||
// (unstamped) value rather than failing the view.
|
||||
if kbs, lerr := svc.ListKnowledgeBases(ctx); lerr == nil {
|
||||
for i := range kbs {
|
||||
if kbs[i].ID == id {
|
||||
kb.IsPinned = kbs[i].IsPinned
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
if fopts.WantsJSON() {
|
||||
return fopts.Emit(iostreams.IO.Out, kb, nil)
|
||||
}
|
||||
// Text mode: KEY: VALUE. Nested config structs (chunking_config,
|
||||
// vlm_config, etc.) are intentionally omitted from the text render —
|
||||
// those are for `--format json | jq '.chunking_config'` workflows.
|
||||
w := iostreams.IO.Out
|
||||
fmt.Fprintf(w, "ID: %s\n", kb.ID)
|
||||
fmt.Fprintf(w, "NAME: %s\n", kb.Name)
|
||||
if kb.Type != "" {
|
||||
fmt.Fprintf(w, "TYPE: %s\n", kb.Type)
|
||||
}
|
||||
if kb.Description != "" {
|
||||
fmt.Fprintf(w, "DESC: %s\n", kb.Description)
|
||||
}
|
||||
if kb.IsPinned {
|
||||
fmt.Fprintf(w, "PINNED: yes\n")
|
||||
}
|
||||
if kb.IsTemporary {
|
||||
fmt.Fprintf(w, "TEMPORARY: yes\n")
|
||||
}
|
||||
fmt.Fprintf(w, "DOCS: %s\n", text.Pluralize(int(kb.KnowledgeCount), "doc"))
|
||||
fmt.Fprintf(w, "CHUNKS: %s\n", text.Pluralize(int(kb.ChunkCount), "chunk"))
|
||||
if kb.IsProcessing {
|
||||
fmt.Fprintf(w, "PROCESSING: %s\n", text.Pluralize(int(kb.ProcessingCount), "doc"))
|
||||
}
|
||||
if kb.EmbeddingModelID != "" {
|
||||
fmt.Fprintf(w, "EMBEDDING: %s\n", kb.EmbeddingModelID)
|
||||
}
|
||||
if kb.SummaryModelID != "" {
|
||||
fmt.Fprintf(w, "SUMMARY MODEL: %s\n", kb.SummaryModelID)
|
||||
}
|
||||
if !kb.CreatedAt.IsZero() {
|
||||
fmt.Fprintf(w, "CREATED: %s\n", kb.CreatedAt.Format("2006-01-02 15:04:05"))
|
||||
}
|
||||
if !kb.UpdatedAt.IsZero() {
|
||||
// Detail page favors absolute time; FuzzyAgo is reserved for list views.
|
||||
fmt.Fprintf(w, "UPDATED: %s\n", kb.UpdatedAt.Format("2006-01-02 15:04:05"))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,184 @@
|
||||
package kb
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/Tencent/WeKnora/cli/internal/cmdutil"
|
||||
"github.com/Tencent/WeKnora/cli/internal/iostreams"
|
||||
sdk "github.com/Tencent/WeKnora/client"
|
||||
)
|
||||
|
||||
type fakeGetSvc struct {
|
||||
kb *sdk.KnowledgeBase
|
||||
err error
|
||||
}
|
||||
|
||||
func (f *fakeGetSvc) GetKnowledgeBase(ctx context.Context, id string) (*sdk.KnowledgeBase, error) {
|
||||
return f.kb, f.err
|
||||
}
|
||||
|
||||
// ListKnowledgeBases backs is_pinned enrichment in runView. Returns the same
|
||||
// KB so the enrichment finds it; nil/empty is fine (enrichment is best-effort).
|
||||
func (f *fakeGetSvc) ListKnowledgeBases(ctx context.Context) ([]sdk.KnowledgeBase, error) {
|
||||
if f.kb == nil {
|
||||
return nil, nil
|
||||
}
|
||||
return []sdk.KnowledgeBase{*f.kb}, nil
|
||||
}
|
||||
|
||||
func TestGet_OK_Text(t *testing.T) {
|
||||
out, _ := iostreams.SetForTest(t)
|
||||
svc := &fakeGetSvc{kb: &sdk.KnowledgeBase{
|
||||
ID: "kb1", Name: "Marketing", KnowledgeCount: 12, ChunkCount: 245,
|
||||
}}
|
||||
if err := runView(context.Background(), &ViewOptions{}, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, svc, "kb1"); err != nil {
|
||||
t.Fatalf("runGet: %v", err)
|
||||
}
|
||||
got := out.String()
|
||||
for _, want := range []string{"ID:", "kb1", "NAME:", "Marketing", "DOCS:", "12 docs", "CHUNKS:", "245 chunks"} {
|
||||
if !strings.Contains(got, want) {
|
||||
t.Errorf("missing %q in:\n%s", want, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestGet_OK_JSON(t *testing.T) {
|
||||
out, _ := iostreams.SetForTest(t)
|
||||
svc := &fakeGetSvc{kb: &sdk.KnowledgeBase{ID: "kb1", Name: "Marketing"}}
|
||||
if err := runView(context.Background(), &ViewOptions{}, &cmdutil.FormatOptions{Mode: cmdutil.FormatJSON}, svc, "kb1"); err != nil {
|
||||
t.Fatalf("runGet: %v", err)
|
||||
}
|
||||
got := out.String()
|
||||
var env struct {
|
||||
OK bool `json:"ok"`
|
||||
Data sdk.KnowledgeBase `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(got), &env); err != nil {
|
||||
t.Fatalf("parse: %v\n%s", err, got)
|
||||
}
|
||||
if !env.OK {
|
||||
t.Errorf("envelope.ok must be true, got %q", got)
|
||||
}
|
||||
if env.Data.ID != "kb1" {
|
||||
t.Errorf("expected id=kb1 in envelope.data, got %q", env.Data.ID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGet_NotFound(t *testing.T) {
|
||||
_, _ = iostreams.SetForTest(t)
|
||||
svc := &fakeGetSvc{err: errors.New("HTTP error 404: not found")}
|
||||
err := runView(context.Background(), &ViewOptions{}, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, svc, "missing")
|
||||
if err == nil {
|
||||
t.Fatal("expected error")
|
||||
}
|
||||
if !cmdutil.IsNotFound(err) {
|
||||
t.Errorf("expected resource.not_found, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// --- expanded text render: badges + extra KV lines ---
|
||||
|
||||
func TestView_Pinned_RendersPinnedLine(t *testing.T) {
|
||||
out, _ := iostreams.SetForTest(t)
|
||||
svc := &fakeGetSvc{kb: &sdk.KnowledgeBase{ID: "kb1", Name: "Pinned", IsPinned: true}}
|
||||
if err := runView(context.Background(), &ViewOptions{}, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, svc, "kb1"); err != nil {
|
||||
t.Fatalf("runView: %v", err)
|
||||
}
|
||||
if !strings.Contains(out.String(), "PINNED:") {
|
||||
t.Errorf("expected PINNED line for IsPinned=true:\n%s", out.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestView_NotPinned_OmitsPinnedLine(t *testing.T) {
|
||||
out, _ := iostreams.SetForTest(t)
|
||||
svc := &fakeGetSvc{kb: &sdk.KnowledgeBase{ID: "kb1", Name: "Plain", IsPinned: false}}
|
||||
if err := runView(context.Background(), &ViewOptions{}, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, svc, "kb1"); err != nil {
|
||||
t.Fatalf("runView: %v", err)
|
||||
}
|
||||
for _, l := range strings.Split(out.String(), "\n") {
|
||||
if strings.HasPrefix(l, "PINNED:") {
|
||||
t.Errorf("PINNED line should be omitted when IsPinned=false: %q", l)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestView_Temporary_RendersTempLine(t *testing.T) {
|
||||
out, _ := iostreams.SetForTest(t)
|
||||
svc := &fakeGetSvc{kb: &sdk.KnowledgeBase{ID: "kb_t", Name: "Tmp", IsTemporary: true}}
|
||||
if err := runView(context.Background(), &ViewOptions{}, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, svc, "kb_t"); err != nil {
|
||||
t.Fatalf("runView: %v", err)
|
||||
}
|
||||
if !strings.Contains(out.String(), "TEMPORARY:") {
|
||||
t.Errorf("expected TEMPORARY line:\n%s", out.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestView_SummaryModel(t *testing.T) {
|
||||
out, _ := iostreams.SetForTest(t)
|
||||
svc := &fakeGetSvc{kb: &sdk.KnowledgeBase{ID: "kb1", Name: "X", SummaryModelID: "summary-model-x"}}
|
||||
if err := runView(context.Background(), &ViewOptions{}, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, svc, "kb1"); err != nil {
|
||||
t.Fatalf("runView: %v", err)
|
||||
}
|
||||
got := out.String()
|
||||
if !strings.Contains(got, "SUMMARY MODEL:") || !strings.Contains(got, "summary-model-x") {
|
||||
t.Errorf("expected SUMMARY MODEL line:\n%s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestView_TypeAndSource(t *testing.T) {
|
||||
out, _ := iostreams.SetForTest(t)
|
||||
svc := &fakeGetSvc{kb: &sdk.KnowledgeBase{ID: "kb1", Name: "X", Type: "general", Description: "d"}}
|
||||
if err := runView(context.Background(), &ViewOptions{}, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, svc, "kb1"); err != nil {
|
||||
t.Fatalf("runView: %v", err)
|
||||
}
|
||||
got := out.String()
|
||||
if !strings.Contains(got, "TYPE:") || !strings.Contains(got, "general") {
|
||||
t.Errorf("expected TYPE line for non-empty Type:\n%s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestView_Processing(t *testing.T) {
|
||||
out, _ := iostreams.SetForTest(t)
|
||||
svc := &fakeGetSvc{kb: &sdk.KnowledgeBase{ID: "kb_p", Name: "Busy", IsProcessing: true, ProcessingCount: 3}}
|
||||
if err := runView(context.Background(), &ViewOptions{}, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, svc, "kb_p"); err != nil {
|
||||
t.Fatalf("runView: %v", err)
|
||||
}
|
||||
got := out.String()
|
||||
if !strings.Contains(got, "PROCESSING:") || !strings.Contains(got, "3") {
|
||||
t.Errorf("expected PROCESSING line with count:\n%s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestView_NotProcessing_OmitsProcessingLine(t *testing.T) {
|
||||
out, _ := iostreams.SetForTest(t)
|
||||
svc := &fakeGetSvc{kb: &sdk.KnowledgeBase{ID: "kb_idle", Name: "Idle", IsProcessing: false}}
|
||||
if err := runView(context.Background(), &ViewOptions{}, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, svc, "kb_idle"); err != nil {
|
||||
t.Fatalf("runView: %v", err)
|
||||
}
|
||||
for _, l := range strings.Split(out.String(), "\n") {
|
||||
if strings.HasPrefix(l, "PROCESSING:") {
|
||||
t.Errorf("PROCESSING line should be omitted: %q", l)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestView_CreatedAt_AlwaysRendered(t *testing.T) {
|
||||
out, _ := iostreams.SetForTest(t)
|
||||
svc := &fakeGetSvc{kb: &sdk.KnowledgeBase{
|
||||
ID: "kb1", Name: "X",
|
||||
CreatedAt: time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC),
|
||||
UpdatedAt: time.Date(2026, 1, 2, 0, 0, 0, 0, time.UTC),
|
||||
}}
|
||||
if err := runView(context.Background(), &ViewOptions{}, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, svc, "kb1"); err != nil {
|
||||
t.Fatalf("runView: %v", err)
|
||||
}
|
||||
got := out.String()
|
||||
if !strings.Contains(got, "CREATED:") || !strings.Contains(got, "2026-01-01") {
|
||||
t.Errorf("expected CREATED line:\n%s", got)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user