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,33 @@
|
||||
// Package chunkcmd implements the `chunk` command subtree for managing
|
||||
// document chunks in a knowledge base. The directory is named `chunk/`
|
||||
// to match the cobra subcommand; the Go package is `chunkcmd` to avoid
|
||||
// colliding with cobra's *cobra.Command identifier.
|
||||
//
|
||||
// "chunk" in this subtree refers to indexed pieces of a knowledge
|
||||
// document (server resource: GET/DELETE /chunks/...). Each document
|
||||
// has many chunks; the chunking pipeline produces them at ingest time.
|
||||
package chunkcmd
|
||||
|
||||
import (
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/Tencent/WeKnora/cli/internal/cmdutil"
|
||||
)
|
||||
|
||||
const chunkLong = `Manage and inspect document chunks.
|
||||
|
||||
Chunks are the indexed pieces of a knowledge document (1 doc → many chunks).
|
||||
Use 'chunk list' to enumerate chunks in stored order (RAG admin / debug).`
|
||||
|
||||
// NewCmdChunk builds the parent `chunk` command. Called from cli/cmd/root.go.
|
||||
func NewCmdChunk(f *cmdutil.Factory) *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "chunk <subcommand>",
|
||||
Short: "Manage document chunks (RAG retrieval debug)",
|
||||
Long: chunkLong,
|
||||
}
|
||||
cmd.AddCommand(NewCmdList(f))
|
||||
cmd.AddCommand(NewCmdView(f))
|
||||
cmd.AddCommand(NewCmdDelete(f))
|
||||
return cmd
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
package chunkcmd
|
||||
|
||||
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"
|
||||
)
|
||||
|
||||
// chunkDeleteFields enumerates the JSON discovery fields for `chunk delete`.
|
||||
// Tracks the single-id result struct; multi-id mode emits batch envelope.
|
||||
var chunkDeleteFields = []string{"id", "deleted"}
|
||||
|
||||
type DeleteOptions struct {
|
||||
ChunkID string // single-id path
|
||||
DocID string // required: SDK DeleteChunk takes both ids in the route
|
||||
Yes bool // sourced from the global -y/--yes persistent flag
|
||||
DryRun bool
|
||||
}
|
||||
|
||||
// DeleteService is the narrow SDK surface this command depends on.
|
||||
type DeleteService interface {
|
||||
DeleteChunk(ctx context.Context, docID, chunkID string) error
|
||||
}
|
||||
|
||||
// deleteResult is the typed payload emitted on single-id success in JSON mode.
|
||||
type deleteResult struct {
|
||||
ID string `json:"id"`
|
||||
Deleted bool `json:"deleted"`
|
||||
}
|
||||
|
||||
const chunkDeleteLong = `Permanently delete one or more chunks from a document.
|
||||
|
||||
Requires both the chunk id(s) (positional, repeatable) and the parent
|
||||
document id (--doc) because the server route encodes both:
|
||||
DELETE /chunks/{doc}/{id}. All chunks in a multi-id call must share the
|
||||
same --doc. The CLI does not auto-resolve doc id from chunk id because
|
||||
that would add a round-trip and open a race with the ingest pipeline
|
||||
(a chunk could move between documents between resolve and delete).
|
||||
|
||||
Single-id: one confirm prompt, exit 0/1.
|
||||
Multi-id:
|
||||
• Default keep-going: failed deletes do NOT stop the run; failures collected.
|
||||
• One -y/--yes confirms all chunks.
|
||||
• Exit 0 if all succeed; exit 1 if any failed.
|
||||
|
||||
Prompts for confirmation by default when stdout is a TTY and JSON output
|
||||
is not set. Pass -y/--yes (the global flag) to skip the prompt (required
|
||||
in agent / CI / piped contexts).
|
||||
|
||||
Typed exit codes:
|
||||
resource.not_found no chunk with the given id under that doc (exit 4)
|
||||
auth.forbidden caller lacks delete permission on the chunk (exit 3)
|
||||
input.confirmation_required destructive op without -y on a TTY (exit 10)
|
||||
|
||||
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.`
|
||||
|
||||
const chunkDeleteExample = ` weknora chunk delete chunk_abc --doc doc_xyz # interactive confirm
|
||||
weknora chunk delete chunk_abc --doc doc_xyz -y # no prompt
|
||||
weknora chunk delete chunk_abc --doc doc_xyz -y --format json # bare {id, deleted:true} JSON
|
||||
weknora chunk delete c1 c2 c3 --doc doc_xyz -y # delete 3 chunks under same doc, keep-going`
|
||||
|
||||
// NewCmdDelete builds `weknora chunk delete <chunk-id> [<chunk-id>...] --doc <doc-id>`.
|
||||
func NewCmdDelete(f *cmdutil.Factory) *cobra.Command {
|
||||
opts := &DeleteOptions{}
|
||||
cmd := &cobra.Command{
|
||||
Use: "delete <chunk-id> [<chunk-id>...] --doc <doc-id>",
|
||||
Short: "Delete one or more chunks from a document (scoped)",
|
||||
Long: chunkDeleteLong,
|
||||
Example: chunkDeleteExample,
|
||||
Args: cobra.MinimumNArgs(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: "chunk.delete",
|
||||
Args: map[string]any{
|
||||
"chunk_ids": args,
|
||||
"doc": opts.DocID,
|
||||
},
|
||||
}); handled {
|
||||
return err
|
||||
}
|
||||
cli, err := f.Client()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(args) == 1 {
|
||||
opts.ChunkID = args[0]
|
||||
return runDelete(c.Context(), opts, fopts, cli, f.Prompter())
|
||||
}
|
||||
batchRetry := append([]string{"weknora", "chunk", "delete"}, args...)
|
||||
batchRetry = append(batchRetry, "--doc", opts.DocID, "-y")
|
||||
if err := cmdutil.ConfirmDestructiveBatch(f.Prompter(), opts.Yes, fopts.WantsJSON(), "delete", "chunk", len(args), "chunk.delete", batchRetry); err != nil {
|
||||
return err
|
||||
}
|
||||
outcomes, runErr := cmdutil.RunBatch(c.Context(), args, func(ctx context.Context, id string) error {
|
||||
if err := cli.DeleteChunk(ctx, opts.DocID, id); err != nil {
|
||||
return cmdutil.WrapHTTP(err, "delete chunk %s", id)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
// Only emit when the operation actually ran. Pre-flight errors
|
||||
// (e.g. confirmation_required) must leave stdout empty per the
|
||||
// wire contract in README.md.
|
||||
if len(outcomes) > 0 {
|
||||
if emitErr := cmdutil.EmitBatch(outcomes, fopts, iostreams.IO.Out, cmdutil.DeletedAtNow); emitErr != nil {
|
||||
return emitErr
|
||||
}
|
||||
}
|
||||
return runErr
|
||||
},
|
||||
}
|
||||
cmd.Flags().StringVar(&opts.DocID, "doc", "", "Parent document id (SDK knowledge_id) the chunks live under")
|
||||
_ = cmd.MarkFlagRequired("doc")
|
||||
cmdutil.AddIgnoredKBFlag(cmd)
|
||||
cmdutil.AddFormatFlag(cmd, chunkDeleteFields...)
|
||||
cmdutil.AddDryRunFlag(cmd, &opts.DryRun)
|
||||
cmdutil.SetRisk(cmd, "chunk.delete")
|
||||
cmdutil.SetAgentHelp(cmd, cmdutil.AgentHelp{
|
||||
UsedFor: "permanently delete one or more chunks from a document",
|
||||
RequiredFlags: []string{"<chunk-id>... (positional, at least one)", "--doc <doc-id>"},
|
||||
Examples: []string{
|
||||
"weknora chunk delete chunk_abc --doc doc_xyz -y",
|
||||
"weknora chunk delete c1 c2 c3 --doc doc_xyz -y",
|
||||
"weknora chunk delete chunk_abc --doc doc_xyz -y --format json",
|
||||
},
|
||||
Output: "envelope.data shape depends on the form: a single chunk id -> {id, deleted:true}; multiple ids -> batch envelope (top-level status success|partial|error, ok=(failures==0), data per-item [{id, ok, error?}], meta.successes/failures — read data[].ok per id).",
|
||||
Warnings: []string{
|
||||
"Requires explicit user approval (exit 10 / input.confirmation_required); never auto-add -y.",
|
||||
"chunk delete is irreversible; loses the chunk + breaks RAG retrieval coherence for downstream queries.",
|
||||
},
|
||||
})
|
||||
return cmd
|
||||
}
|
||||
|
||||
func runDelete(ctx context.Context, opts *DeleteOptions, fopts *cmdutil.FormatOptions, svc DeleteService, p prompt.Prompter) error {
|
||||
if err := cmdutil.ConfirmDestructive(p, opts.Yes, fopts.WantsJSON(), "delete", "chunk", opts.ChunkID, "chunk.delete", []string{"weknora", "chunk", "delete", opts.ChunkID, "--doc", opts.DocID, "-y"}); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := svc.DeleteChunk(ctx, opts.DocID, opts.ChunkID); err != nil {
|
||||
return cmdutil.WrapHTTP(err, "delete chunk %s", opts.ChunkID)
|
||||
}
|
||||
if fopts.WantsJSON() {
|
||||
return fopts.Emit(iostreams.IO.Out, deleteResult{ID: opts.ChunkID, Deleted: true}, nil)
|
||||
}
|
||||
fmt.Fprintf(iostreams.IO.Out, "✓ Deleted chunk %s\n", opts.ChunkID)
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,243 @@
|
||||
package chunkcmd
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"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/testutil"
|
||||
)
|
||||
|
||||
type fakeChunkDeleteSvc struct {
|
||||
gotDocID, gotChunkID string
|
||||
err error
|
||||
}
|
||||
|
||||
func (f *fakeChunkDeleteSvc) DeleteChunk(_ context.Context, docID, chunkID string) error {
|
||||
f.gotDocID = docID
|
||||
f.gotChunkID = chunkID
|
||||
return f.err
|
||||
}
|
||||
|
||||
func TestDelete_NonTTY_NoYes_ExitTen(t *testing.T) {
|
||||
_, _ = iostreams.SetForTest(t)
|
||||
svc := &fakeChunkDeleteSvc{}
|
||||
err := runDelete(context.Background(),
|
||||
&DeleteOptions{ChunkID: "c1", DocID: "doc_abc", Yes: false},
|
||||
&cmdutil.FormatOptions{Mode: cmdutil.FormatJSON}, svc, &testutil.ConfirmPrompter{})
|
||||
require.Error(t, err)
|
||||
var typed *cmdutil.Error
|
||||
require.ErrorAs(t, err, &typed)
|
||||
assert.Equal(t, cmdutil.CodeInputConfirmationRequired, typed.Code)
|
||||
assert.Empty(t, svc.gotChunkID, "must not call DeleteChunk without confirm")
|
||||
assert.Equal(t, 10, cmdutil.ExitCode(err), "exit 10 per destructive-write protocol")
|
||||
}
|
||||
|
||||
func TestDelete_WithYes_PassesBothIDs(t *testing.T) {
|
||||
_, _ = iostreams.SetForTest(t)
|
||||
svc := &fakeChunkDeleteSvc{}
|
||||
require.NoError(t, runDelete(context.Background(),
|
||||
&DeleteOptions{ChunkID: "c1", DocID: "doc_abc", Yes: true},
|
||||
&cmdutil.FormatOptions{Mode: cmdutil.FormatJSON}, svc, &testutil.ConfirmPrompter{}))
|
||||
assert.Equal(t, "doc_abc", svc.gotDocID)
|
||||
assert.Equal(t, "c1", svc.gotChunkID)
|
||||
}
|
||||
|
||||
func TestDelete_MissingDoc_FlagError(t *testing.T) {
|
||||
cmd := NewCmdDelete(nil)
|
||||
cmd.SetArgs([]string{"c1"}) // no --doc
|
||||
cmd.SilenceUsage = true
|
||||
cmd.SilenceErrors = true
|
||||
require.Error(t, cmd.Execute())
|
||||
}
|
||||
|
||||
func TestDelete_404_PropagatesNotFound(t *testing.T) {
|
||||
_, _ = iostreams.SetForTest(t)
|
||||
svc := &fakeChunkDeleteSvc{err: errors.New("HTTP error 404: not found")}
|
||||
err := runDelete(context.Background(),
|
||||
&DeleteOptions{ChunkID: "missing", DocID: "doc_abc", Yes: true},
|
||||
&cmdutil.FormatOptions{Mode: cmdutil.FormatJSON}, svc, &testutil.ConfirmPrompter{})
|
||||
require.Error(t, err)
|
||||
var typed *cmdutil.Error
|
||||
require.ErrorAs(t, err, &typed)
|
||||
assert.Equal(t, cmdutil.CodeResourceNotFound, typed.Code)
|
||||
}
|
||||
|
||||
func TestDelete_TTY_ConfirmYes_Calls(t *testing.T) {
|
||||
_, _ = iostreams.SetForTestWithTTY(t)
|
||||
svc := &fakeChunkDeleteSvc{}
|
||||
p := &testutil.ConfirmPrompter{Answer: true}
|
||||
require.NoError(t, runDelete(context.Background(),
|
||||
&DeleteOptions{ChunkID: "c1", DocID: "doc_abc"},
|
||||
&cmdutil.FormatOptions{Mode: cmdutil.FormatText}, svc, p))
|
||||
assert.True(t, p.Asked)
|
||||
assert.Equal(t, "c1", svc.gotChunkID)
|
||||
}
|
||||
|
||||
func TestDelete_TTY_ConfirmNo_Aborts(t *testing.T) {
|
||||
_, errBuf := iostreams.SetForTestWithTTY(t)
|
||||
svc := &fakeChunkDeleteSvc{}
|
||||
p := &testutil.ConfirmPrompter{Answer: false}
|
||||
err := runDelete(context.Background(),
|
||||
&DeleteOptions{ChunkID: "c1", DocID: "doc_abc"},
|
||||
&cmdutil.FormatOptions{Mode: cmdutil.FormatText}, svc, p)
|
||||
require.Error(t, err)
|
||||
var typed *cmdutil.Error
|
||||
require.ErrorAs(t, err, &typed)
|
||||
assert.Equal(t, cmdutil.CodeUserAborted, typed.Code)
|
||||
assert.Empty(t, svc.gotChunkID, "answer=no must not call DeleteChunk")
|
||||
assert.Contains(t, errBuf.String(), "Aborted")
|
||||
}
|
||||
|
||||
func TestDelete_JSON_BareObject(t *testing.T) {
|
||||
out, _ := iostreams.SetForTest(t)
|
||||
svc := &fakeChunkDeleteSvc{}
|
||||
require.NoError(t, runDelete(context.Background(),
|
||||
&DeleteOptions{ChunkID: "c1", DocID: "doc_abc", Yes: true},
|
||||
&cmdutil.FormatOptions{Mode: cmdutil.FormatJSON}, svc, &testutil.ConfirmPrompter{}))
|
||||
body := out.String()
|
||||
assert.Contains(t, body, `"id":"c1"`)
|
||||
assert.Contains(t, body, `"deleted":true`)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Multi-id (all chunks share --doc, keep-going on failure)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// fakeMultiChunkDeleteSvc records (docID, chunkID) pairs and can fail-on
|
||||
// selected chunkIDs.
|
||||
type fakeMultiChunkDeleteSvc struct {
|
||||
deleted []string // chunk ids successfully deleted
|
||||
docIDs []string // doc id observed for each call
|
||||
failOn map[string]error
|
||||
}
|
||||
|
||||
func (f *fakeMultiChunkDeleteSvc) DeleteChunk(_ context.Context, docID, chunkID string) error {
|
||||
f.docIDs = append(f.docIDs, docID)
|
||||
if e, ok := f.failOn[chunkID]; ok {
|
||||
return e
|
||||
}
|
||||
f.deleted = append(f.deleted, chunkID)
|
||||
return nil
|
||||
}
|
||||
|
||||
func TestMultiDelete_AllSucceed_SharedDoc(t *testing.T) {
|
||||
_, _ = iostreams.SetForTest(t)
|
||||
svc := &fakeMultiChunkDeleteSvc{}
|
||||
docID := "doc_xyz"
|
||||
outcomes, err := cmdutil.RunBatch(context.Background(),
|
||||
[]string{"c1", "c2", "c3"},
|
||||
func(ctx context.Context, id string) error {
|
||||
if err := svc.DeleteChunk(ctx, docID, id); err != nil {
|
||||
return cmdutil.WrapHTTP(err, "delete chunk %s", id)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.Len(t, outcomes, 3)
|
||||
for _, oc := range outcomes {
|
||||
assert.Nil(t, oc.Err, "expected no error for %s", oc.ID)
|
||||
}
|
||||
// All calls observed the same --doc.
|
||||
for _, d := range svc.docIDs {
|
||||
assert.Equal(t, "doc_xyz", d)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMultiDelete_PartialFailure_KeepsGoing(t *testing.T) {
|
||||
_, _ = iostreams.SetForTest(t)
|
||||
svc := &fakeMultiChunkDeleteSvc{failOn: map[string]error{"c2": errors.New("HTTP error 404: not found")}}
|
||||
docID := "doc_xyz"
|
||||
outcomes, err := cmdutil.RunBatch(context.Background(),
|
||||
[]string{"c1", "c2", "c3"},
|
||||
func(ctx context.Context, id string) error {
|
||||
if err := svc.DeleteChunk(ctx, docID, id); err != nil {
|
||||
return cmdutil.WrapHTTP(err, "delete chunk %s", id)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
require.Error(t, err)
|
||||
var typed *cmdutil.Error
|
||||
require.ErrorAs(t, err, &typed)
|
||||
assert.Equal(t, cmdutil.CodeOperationFailed, typed.Code)
|
||||
require.Len(t, outcomes, 3, "all three ids must appear in outcomes (argv order preserved)")
|
||||
assert.Nil(t, outcomes[0].Err, "c1 should succeed")
|
||||
assert.NotNil(t, outcomes[1].Err, "c2 should fail")
|
||||
assert.Nil(t, outcomes[2].Err, "c3 should succeed (keep-going)")
|
||||
assert.Equal(t, "c2", outcomes[1].ID)
|
||||
}
|
||||
|
||||
func TestMultiDelete_NonTTY_NoYes_RequiresConfirmation(t *testing.T) {
|
||||
_, _ = iostreams.SetForTest(t)
|
||||
svc := &fakeMultiChunkDeleteSvc{}
|
||||
fopts := &cmdutil.FormatOptions{Mode: cmdutil.FormatText}
|
||||
err := cmdutil.ConfirmDestructiveBatch(&testutil.ConfirmPrompter{}, false, fopts.WantsJSON(), "delete", "chunk", 2, "chunk.delete", nil)
|
||||
require.Error(t, err)
|
||||
var typed *cmdutil.Error
|
||||
require.ErrorAs(t, err, &typed)
|
||||
assert.Equal(t, cmdutil.CodeInputConfirmationRequired, typed.Code)
|
||||
assert.Equal(t, 10, cmdutil.ExitCode(err))
|
||||
assert.Empty(t, svc.deleted)
|
||||
}
|
||||
|
||||
// TestChunkDelete_MultiID_PartialFailure_BatchEnvelope verifies that the JSON
|
||||
// output for a multi-id partial failure uses the batch envelope shape:
|
||||
// {ok:false, data:[{id,ok,result?|error?}...], meta:{count,successes,failures}}.
|
||||
func TestChunkDelete_MultiID_PartialFailure_BatchEnvelope(t *testing.T) {
|
||||
_, _ = iostreams.SetForTest(t)
|
||||
svc := &fakeMultiChunkDeleteSvc{failOn: map[string]error{
|
||||
"c2": errors.New("HTTP error 404: not found"),
|
||||
}}
|
||||
docID := "doc_xyz"
|
||||
outcomes, runErr := cmdutil.RunBatch(context.Background(),
|
||||
[]string{"c1", "c2", "c3"},
|
||||
func(ctx context.Context, id string) error {
|
||||
if err := svc.DeleteChunk(ctx, docID, id); err != nil {
|
||||
return cmdutil.WrapHTTP(err, "delete chunk %s", id)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
require.Error(t, runErr)
|
||||
require.Len(t, outcomes, 3)
|
||||
|
||||
var buf bytes.Buffer
|
||||
require.NoError(t, cmdutil.EmitBatch(outcomes, &cmdutil.FormatOptions{Mode: cmdutil.FormatJSON}, &buf, cmdutil.DeletedAtNow))
|
||||
|
||||
var env struct {
|
||||
OK bool `json:"ok"`
|
||||
Data []struct {
|
||||
ID string `json:"id"`
|
||||
OK bool `json:"ok"`
|
||||
Error *struct {
|
||||
Type string `json:"type"`
|
||||
Message string `json:"message"`
|
||||
} `json:"error,omitempty"`
|
||||
} `json:"data"`
|
||||
Meta struct {
|
||||
Count int `json:"count"`
|
||||
Successes int `json:"successes"`
|
||||
Failures int `json:"failures"`
|
||||
} `json:"meta"`
|
||||
}
|
||||
require.NoError(t, json.Unmarshal(buf.Bytes(), &env), "envelope must be valid JSON")
|
||||
|
||||
assert.False(t, env.OK, "top-level ok must be false when any item failed")
|
||||
assert.Equal(t, 3, env.Meta.Count)
|
||||
assert.Equal(t, 2, env.Meta.Successes)
|
||||
assert.Equal(t, 1, env.Meta.Failures)
|
||||
require.Len(t, env.Data, 3)
|
||||
assert.Equal(t, "c1", env.Data[0].ID)
|
||||
assert.True(t, env.Data[0].OK)
|
||||
assert.Equal(t, "c2", env.Data[1].ID)
|
||||
assert.False(t, env.Data[1].OK)
|
||||
assert.NotNil(t, env.Data[1].Error)
|
||||
assert.Equal(t, "c3", env.Data[2].ID)
|
||||
assert.True(t, env.Data[2].OK)
|
||||
}
|
||||
@@ -0,0 +1,214 @@
|
||||
package chunkcmd
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"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"
|
||||
)
|
||||
|
||||
const (
|
||||
defaultPageSize = 50
|
||||
maxPageSize = 1000
|
||||
defaultLimit = 50
|
||||
maxLimit = 10000
|
||||
previewWidth = 80
|
||||
)
|
||||
|
||||
// chunkListFields enumerates the fields surfaced for `--format json` discovery on
|
||||
// `chunk list`. Mirrors sdk.Chunk json tags — all 23 fields are projectable
|
||||
// because chunk list returns bare SDK objects.
|
||||
var chunkListFields = []string{
|
||||
"id", "seq_id", "knowledge_id", "knowledge_base_id", "tenant_id",
|
||||
"tag_id", "content", "chunk_index", "is_enabled", "status",
|
||||
"start_at", "end_at", "pre_chunk_id", "next_chunk_id", "chunk_type",
|
||||
"parent_chunk_id", "relation_chunks", "indirect_relation_chunks",
|
||||
"metadata", "content_hash", "image_info", "created_at", "updated_at",
|
||||
}
|
||||
|
||||
// ListService is the narrow SDK surface this command depends on.
|
||||
type ListService interface {
|
||||
ListKnowledgeChunks(ctx context.Context, knowledgeID string, page, pageSize int, chunkTypes ...string) ([]sdk.Chunk, int64, error)
|
||||
}
|
||||
|
||||
type ListOptions struct {
|
||||
// DocID scopes the listing to a single knowledge document (SDK
|
||||
// `knowledge_id`). The chunks SDK does not expose a KB-wide route.
|
||||
DocID string
|
||||
// PageSize is the server batch size (1..1000, default 50).
|
||||
PageSize int
|
||||
// Limit caps the client-side accumulated slice (1..10000, default 50).
|
||||
// Default 50 chosen as domain-tuned for chunk enumeration (RAG debug).
|
||||
Limit int
|
||||
// AllPages walks server pages internally until total exhausted or
|
||||
// --limit hit. Mirrors the session list / doc list pagination pattern.
|
||||
AllPages bool
|
||||
}
|
||||
|
||||
const chunkListLong = `List chunks under a document in stored order.
|
||||
|
||||
'chunk list --doc D' enumerates ALL chunks of document D in ChunkIndex
|
||||
order (the per-doc ordinal assigned at ingest time). This is the
|
||||
admin/debug surface for RAG retrieval — see what the chunking pipeline
|
||||
produced, audit content, find a chunk id to view/delete.
|
||||
|
||||
For relevance-ranked retrieval (the RAG runtime surface), use
|
||||
'search chunks "<query>" --kb K' instead. That command runs hybrid
|
||||
vector + keyword scoring across all chunks of a knowledge base.
|
||||
|
||||
Typed exit codes:
|
||||
input.invalid_argument --limit out of 1..10000 or --page-size out of 1..1000 (exit 5)
|
||||
resource.not_found no document with the given id (exit 4)
|
||||
|
||||
AI agents: prefer 'search chunks' for retrieval tasks. Use 'chunk list'
|
||||
only when you need to enumerate / verify the chunking output of a
|
||||
specific document.`
|
||||
|
||||
const chunkListExample = ` weknora chunk list --doc doc_abc
|
||||
weknora chunk list --doc doc_abc --all-pages --page-size 100
|
||||
weknora chunk list --doc doc_abc --format json | jq '.[] | {id, chunk_index}'`
|
||||
|
||||
// NewCmdList builds `weknora chunk list --doc <doc-id>`.
|
||||
func NewCmdList(f *cmdutil.Factory) *cobra.Command {
|
||||
opts := &ListOptions{}
|
||||
cmd := &cobra.Command{
|
||||
Use: "list",
|
||||
Short: "List chunks of a document (admin/debug, not retrieval)",
|
||||
Long: chunkListLong,
|
||||
Example: chunkListExample,
|
||||
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/--page-size returns input.invalid_argument (exit 5), not
|
||||
// an auth error (exit 3).
|
||||
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().StringVar(&opts.DocID, "doc", "", "Document id (SDK knowledge_id) to enumerate chunks for")
|
||||
_ = cmd.MarkFlagRequired("doc")
|
||||
cmd.Flags().IntVarP(&opts.Limit, "limit", "L", defaultLimit, "Maximum results to return — client-side cap; meta.has_more/total_count report truncation (1..10000)")
|
||||
cmd.Flags().IntVar(&opts.PageSize, "page-size", defaultPageSize, "Items per server batch (1..1000)")
|
||||
cmd.Flags().BoolVar(&opts.AllPages, "all-pages", false, "Walk all server pages until exhausted (or --limit hit)")
|
||||
cmdutil.AddFormatFlag(cmd, chunkListFields...)
|
||||
cmdutil.AddIgnoredKBFlag(cmd)
|
||||
cmdutil.SetAgentHelp(cmd, cmdutil.AgentHelp{
|
||||
UsedFor: "List chunks of a specific document in stored order (admin/debug). Results come with meta.count; use --limit (1..10000) and --all-pages to paginate. Prefer 'search chunks' for RAG retrieval.",
|
||||
RequiredFlags: []string{"--doc"},
|
||||
Examples: []string{"weknora chunk list --doc doc_abc --format json", "weknora chunk list --doc doc_abc --all-pages --format json"},
|
||||
Output: "envelope.data is an array of Chunk objects with id, chunk_index, content, is_enabled; meta.count is the returned count, meta.total_count the document's full chunk count, meta.has_more true when --limit truncated",
|
||||
})
|
||||
return cmd
|
||||
}
|
||||
|
||||
// validateListOpts checks --limit / --page-size. 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 > maxLimit {
|
||||
return &cmdutil.Error{
|
||||
Code: cmdutil.CodeInputInvalidArgument,
|
||||
Message: fmt.Sprintf("--limit must be in 1..%d, got %d", maxLimit, opts.Limit),
|
||||
}
|
||||
}
|
||||
if opts.PageSize < 1 || opts.PageSize > maxPageSize {
|
||||
return &cmdutil.Error{
|
||||
Code: cmdutil.CodeInputInvalidArgument,
|
||||
Message: fmt.Sprintf("--page-size must be in 1..%d, got %d", maxPageSize, opts.PageSize),
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func runList(ctx context.Context, opts *ListOptions, fopts *cmdutil.FormatOptions, svc ListService) error {
|
||||
if err := validateListOpts(opts); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var items []sdk.Chunk
|
||||
var serverTotal int64
|
||||
truncated := false
|
||||
if opts.AllPages {
|
||||
accum := make([]sdk.Chunk, 0)
|
||||
for page := 1; ; page++ {
|
||||
chunks, total, err := svc.ListKnowledgeChunks(ctx, opts.DocID, page, opts.PageSize)
|
||||
if err != nil {
|
||||
return cmdutil.WrapHTTP(err, "list chunks for doc %s", opts.DocID)
|
||||
}
|
||||
serverTotal = total
|
||||
accum = append(accum, chunks...)
|
||||
if len(accum) >= opts.Limit {
|
||||
accum = accum[:opts.Limit]
|
||||
truncated = true
|
||||
break
|
||||
}
|
||||
if len(chunks) == 0 || int64(len(accum)) >= total {
|
||||
break
|
||||
}
|
||||
}
|
||||
items = accum
|
||||
} else {
|
||||
chunks, total, err := svc.ListKnowledgeChunks(ctx, opts.DocID, 1, opts.PageSize)
|
||||
if err != nil {
|
||||
return cmdutil.WrapHTTP(err, "list chunks for doc %s", opts.DocID)
|
||||
}
|
||||
serverTotal = total
|
||||
items = chunks
|
||||
}
|
||||
if items == nil {
|
||||
items = []sdk.Chunk{} // JSON [] not null
|
||||
}
|
||||
if len(items) > opts.Limit {
|
||||
items = items[:opts.Limit]
|
||||
truncated = true
|
||||
}
|
||||
|
||||
if fopts.WantsJSON() {
|
||||
meta := &output.Meta{Count: output.IntPtr(len(items)), TotalCount: output.IntPtr(int(serverTotal)), HasMore: truncated}
|
||||
return fopts.Emit(iostreams.IO.Out, items, meta)
|
||||
}
|
||||
|
||||
if len(items) == 0 {
|
||||
fmt.Fprintln(iostreams.IO.Out, "(no chunks)")
|
||||
return nil
|
||||
}
|
||||
tw := tabwriter.NewWriter(iostreams.IO.Out, 0, 0, 2, ' ', 0)
|
||||
fmt.Fprintln(tw, "CHUNK_ID\tINDEX\tTYPE\tENABLED\tPREVIEW\tUPDATED")
|
||||
now := time.Now()
|
||||
for _, c := range items {
|
||||
enabled := "-"
|
||||
if c.IsEnabled {
|
||||
enabled = "yes"
|
||||
}
|
||||
preview := text.OneLine(previewWidth, c.Content)
|
||||
if preview == "" {
|
||||
preview = "-"
|
||||
}
|
||||
typ := c.ChunkType
|
||||
if typ == "" {
|
||||
typ = "-"
|
||||
}
|
||||
fmt.Fprintf(tw, "%s\t%d\t%s\t%s\t%s\t%s\n",
|
||||
c.ID, c.ChunkIndex, typ, enabled, preview, text.FuzzyAgoStr(now, c.UpdatedAt))
|
||||
}
|
||||
return tw.Flush()
|
||||
}
|
||||
@@ -0,0 +1,245 @@
|
||||
package chunkcmd
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/Tencent/WeKnora/cli/internal/cmdutil"
|
||||
"github.com/Tencent/WeKnora/cli/internal/iostreams"
|
||||
sdk "github.com/Tencent/WeKnora/client"
|
||||
)
|
||||
|
||||
type listCall struct {
|
||||
docID string
|
||||
page int
|
||||
pageSize int
|
||||
}
|
||||
|
||||
type fakeListSvc struct {
|
||||
calls []listCall
|
||||
pages [][]sdk.Chunk
|
||||
totals []int64
|
||||
errs []error
|
||||
callIdx int
|
||||
}
|
||||
|
||||
func (f *fakeListSvc) ListKnowledgeChunks(_ context.Context, docID string, page, pageSize int, _ ...string) ([]sdk.Chunk, int64, error) {
|
||||
f.calls = append(f.calls, listCall{docID, page, pageSize})
|
||||
defer func() { f.callIdx++ }()
|
||||
if f.callIdx >= len(f.pages) {
|
||||
return nil, 0, nil
|
||||
}
|
||||
return f.pages[f.callIdx], f.totals[f.callIdx], f.errs[f.callIdx]
|
||||
}
|
||||
|
||||
func TestList_Happy(t *testing.T) {
|
||||
out, _ := iostreams.SetForTest(t)
|
||||
svc := &fakeListSvc{
|
||||
pages: [][]sdk.Chunk{{
|
||||
{ID: "c1", ChunkIndex: 0, Content: "hello", ChunkType: "text", IsEnabled: true},
|
||||
{ID: "c2", ChunkIndex: 1, Content: "world", ChunkType: "text", IsEnabled: true},
|
||||
}},
|
||||
totals: []int64{2}, errs: []error{nil},
|
||||
}
|
||||
opts := &ListOptions{DocID: "doc_abc", Limit: 50, PageSize: 50}
|
||||
require.NoError(t, runList(context.Background(), opts, &cmdutil.FormatOptions{Mode: cmdutil.FormatJSON}, svc))
|
||||
require.Len(t, svc.calls, 1)
|
||||
assert.Equal(t, "doc_abc", svc.calls[0].docID)
|
||||
assert.Equal(t, 1, svc.calls[0].page)
|
||||
assert.Equal(t, 50, svc.calls[0].pageSize)
|
||||
// JSON mode emits a bare array; both ids must be present.
|
||||
body := out.String()
|
||||
assert.Contains(t, body, `"c1"`)
|
||||
assert.Contains(t, body, `"c2"`)
|
||||
}
|
||||
|
||||
// TestList_AllPages_StopsOnEmptyPage exercises the empty-page stop branch:
|
||||
// total is large enough that page*pageSize < total never trips, so the loop
|
||||
// can only terminate when the server returns an empty page.
|
||||
func TestList_AllPages_StopsOnEmptyPage(t *testing.T) {
|
||||
_, _ = iostreams.SetForTest(t)
|
||||
svc := &fakeListSvc{
|
||||
pages: [][]sdk.Chunk{
|
||||
{{ID: "c1"}, {ID: "c2"}},
|
||||
{{ID: "c3"}},
|
||||
{}, // empty page → done
|
||||
},
|
||||
// Inflated total isolates the empty-page branch from the
|
||||
// page*pageSize >= total branch.
|
||||
totals: []int64{100, 100, 100},
|
||||
errs: []error{nil, nil, nil},
|
||||
}
|
||||
opts := &ListOptions{DocID: "doc_abc", AllPages: true, PageSize: 2, Limit: 1000}
|
||||
require.NoError(t, runList(context.Background(), opts, &cmdutil.FormatOptions{Mode: cmdutil.FormatJSON}, svc))
|
||||
assert.Equal(t, 3, len(svc.calls), "must stop on empty page, not loop forever")
|
||||
}
|
||||
|
||||
// TestList_AllPages_StopsOnTotalExhausted exercises the page*pageSize >= total
|
||||
// stop branch: server never returns an empty page in the requested window, so
|
||||
// the loop must exit when accumulated coverage reaches total.
|
||||
func TestList_AllPages_StopsOnTotalExhausted(t *testing.T) {
|
||||
_, _ = iostreams.SetForTest(t)
|
||||
svc := &fakeListSvc{
|
||||
pages: [][]sdk.Chunk{
|
||||
{{ID: "c1"}, {ID: "c2"}},
|
||||
{{ID: "c3"}},
|
||||
},
|
||||
totals: []int64{3, 3},
|
||||
errs: []error{nil, nil},
|
||||
}
|
||||
opts := &ListOptions{DocID: "doc_abc", AllPages: true, PageSize: 2, Limit: 1000}
|
||||
require.NoError(t, runList(context.Background(), opts, &cmdutil.FormatOptions{Mode: cmdutil.FormatJSON}, svc))
|
||||
// After page 2: page*pageSize=4 >= total=3 → stop. No 3rd request.
|
||||
assert.Equal(t, 2, len(svc.calls), "must stop when total exhausted, no extra empty probe")
|
||||
}
|
||||
|
||||
// TestList_AllPages_LimitTruncatesAccumulated exercises the limit-cap stop
|
||||
// branch: pagination halts as soon as accumulated >= limit, and the result
|
||||
// is sliced to exactly --limit items regardless of how the last page over-ran.
|
||||
func TestList_AllPages_LimitTruncatesAccumulated(t *testing.T) {
|
||||
out, _ := iostreams.SetForTest(t)
|
||||
svc := &fakeListSvc{
|
||||
pages: [][]sdk.Chunk{
|
||||
{{ID: "c1"}, {ID: "c2"}},
|
||||
{{ID: "c3"}, {ID: "c4"}},
|
||||
{{ID: "c5"}}, // should not be requested — limit hits first
|
||||
},
|
||||
totals: []int64{5, 5, 5},
|
||||
errs: []error{nil, nil, nil},
|
||||
}
|
||||
opts := &ListOptions{DocID: "doc_abc", AllPages: true, PageSize: 2, Limit: 3}
|
||||
require.NoError(t, runList(context.Background(), opts, &cmdutil.FormatOptions{Mode: cmdutil.FormatJSON}, svc))
|
||||
// After page 2: accum=4 >= limit=3 → stop. Third page never requested.
|
||||
assert.LessOrEqual(t, len(svc.calls), 2, "must not walk past limit-hit point")
|
||||
// Result must be exactly --limit items (server returned 4, sliced to 3).
|
||||
var env struct {
|
||||
OK bool `json:"ok"`
|
||||
Data []sdk.Chunk `json:"data"`
|
||||
}
|
||||
require.NoError(t, json.Unmarshal(out.Bytes(), &env))
|
||||
got := env.Data
|
||||
assert.Len(t, got, 3, "accumulated must be sliced to exactly --limit")
|
||||
// IDs preserve order: first 3 from the first 2 pages.
|
||||
assert.Equal(t, []string{"c1", "c2", "c3"}, []string{got[0].ID, got[1].ID, got[2].ID})
|
||||
}
|
||||
|
||||
// TestList_JSON_EmitsTotalCount pins that chunk list surfaces the document's
|
||||
// full chunk count as meta.total_count (like doc/session/kb/model list), not
|
||||
// just the returned count — an agent must be able to tell truncation from
|
||||
// completeness.
|
||||
func TestList_JSON_EmitsTotalCount(t *testing.T) {
|
||||
out, _ := iostreams.SetForTest(t)
|
||||
svc := &fakeListSvc{
|
||||
pages: [][]sdk.Chunk{{{ID: "c1"}, {ID: "c2"}}},
|
||||
totals: []int64{7},
|
||||
errs: []error{nil},
|
||||
}
|
||||
opts := &ListOptions{DocID: "d1", Limit: 50, PageSize: 50}
|
||||
require.NoError(t, runList(context.Background(), opts, &cmdutil.FormatOptions{Mode: cmdutil.FormatJSON}, svc))
|
||||
var env struct {
|
||||
Meta struct {
|
||||
Count *int `json:"count"`
|
||||
TotalCount *int `json:"total_count"`
|
||||
} `json:"meta"`
|
||||
}
|
||||
require.NoError(t, json.Unmarshal(out.Bytes(), &env))
|
||||
require.NotNil(t, env.Meta.TotalCount, "chunk list must emit meta.total_count")
|
||||
assert.Equal(t, 7, *env.Meta.TotalCount)
|
||||
require.NotNil(t, env.Meta.Count)
|
||||
assert.Equal(t, 2, *env.Meta.Count)
|
||||
}
|
||||
|
||||
func TestList_LimitInvalid(t *testing.T) {
|
||||
svc := &fakeListSvc{}
|
||||
for _, lim := range []int{0, -1, 10001} {
|
||||
err := runList(context.Background(), &ListOptions{DocID: "d", Limit: lim, PageSize: 50}, &cmdutil.FormatOptions{Mode: cmdutil.FormatJSON}, svc)
|
||||
require.Error(t, err, "expect error for --limit %d", lim)
|
||||
assert.Contains(t, err.Error(), "input.invalid_argument")
|
||||
}
|
||||
}
|
||||
|
||||
func TestList_PageSizeInvalid(t *testing.T) {
|
||||
svc := &fakeListSvc{}
|
||||
for _, ps := range []int{0, -1, 1001} {
|
||||
err := runList(context.Background(), &ListOptions{DocID: "d", Limit: 50, PageSize: ps}, &cmdutil.FormatOptions{Mode: cmdutil.FormatJSON}, svc)
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "input.invalid_argument")
|
||||
}
|
||||
}
|
||||
|
||||
func TestList_MissingDoc_FlagError(t *testing.T) {
|
||||
cmd := NewCmdList(nil)
|
||||
cmd.SetArgs([]string{})
|
||||
cmd.SilenceUsage = true
|
||||
cmd.SilenceErrors = true
|
||||
require.Error(t, cmd.Execute(), "expect required-flag error for missing --doc")
|
||||
}
|
||||
|
||||
func TestList_Text_TableHeader(t *testing.T) {
|
||||
out, _ := iostreams.SetForTest(t)
|
||||
svc := &fakeListSvc{
|
||||
pages: [][]sdk.Chunk{{
|
||||
{ID: "c1", ChunkIndex: 0, Content: "the quick brown fox", ChunkType: "text", IsEnabled: true, UpdatedAt: "2026-05-15T12:00:00Z"},
|
||||
}},
|
||||
totals: []int64{1}, errs: []error{nil},
|
||||
}
|
||||
require.NoError(t, runList(context.Background(), &ListOptions{DocID: "doc_abc", Limit: 50, PageSize: 50}, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, svc))
|
||||
body := out.String()
|
||||
for _, want := range []string{"CHUNK_ID", "INDEX", "TYPE", "ENABLED", "PREVIEW", "UPDATED", "c1", "text"} {
|
||||
assert.Contains(t, body, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestList_Text_PreviewTruncatedTo80(t *testing.T) {
|
||||
out, _ := iostreams.SetForTest(t)
|
||||
long := strings.Repeat("a", 200)
|
||||
svc := &fakeListSvc{
|
||||
pages: [][]sdk.Chunk{{{ID: "c1", Content: long, ChunkType: "text", IsEnabled: true}}},
|
||||
totals: []int64{1}, errs: []error{nil},
|
||||
}
|
||||
require.NoError(t, runList(context.Background(), &ListOptions{DocID: "doc_abc", Limit: 50, PageSize: 50}, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, svc))
|
||||
body := out.String()
|
||||
// 80-col preview means we never see the 100th `a` from the content (only column truncation kicks in).
|
||||
assert.NotContains(t, body, strings.Repeat("a", 100), "preview must be truncated to ~80 chars")
|
||||
}
|
||||
|
||||
func TestList_JSON_BareArray(t *testing.T) {
|
||||
out, _ := iostreams.SetForTest(t)
|
||||
svc := &fakeListSvc{
|
||||
pages: [][]sdk.Chunk{{
|
||||
{ID: "c1", KnowledgeID: "doc_abc", KnowledgeBaseID: "kb_x"},
|
||||
}},
|
||||
totals: []int64{1}, errs: []error{nil},
|
||||
}
|
||||
require.NoError(t, runList(context.Background(), &ListOptions{DocID: "doc_abc", Limit: 50, PageSize: 50}, &cmdutil.FormatOptions{Mode: cmdutil.FormatJSON}, svc))
|
||||
var env struct {
|
||||
OK bool `json:"ok"`
|
||||
Data []sdk.Chunk `json:"data"`
|
||||
}
|
||||
require.NoError(t, json.Unmarshal(out.Bytes(), &env))
|
||||
got := env.Data
|
||||
require.Len(t, got, 1)
|
||||
assert.Equal(t, "doc_abc", got[0].KnowledgeID)
|
||||
assert.Equal(t, "kb_x", got[0].KnowledgeBaseID)
|
||||
// SDK snake_case keys must be present.
|
||||
assert.Contains(t, out.String(), `"knowledge_id":"doc_abc"`)
|
||||
assert.NotContains(t, out.String(), `"doc_id"`)
|
||||
}
|
||||
|
||||
func TestList_EmptyResultRendersBareArray(t *testing.T) {
|
||||
out, _ := iostreams.SetForTest(t)
|
||||
svc := &fakeListSvc{pages: [][]sdk.Chunk{{}}, totals: []int64{0}, errs: []error{nil}}
|
||||
require.NoError(t, runList(context.Background(), &ListOptions{DocID: "doc_abc", Limit: 50, PageSize: 50}, &cmdutil.FormatOptions{Mode: cmdutil.FormatJSON}, svc))
|
||||
var env struct {
|
||||
OK bool `json:"ok"`
|
||||
Data []sdk.Chunk `json:"data"`
|
||||
}
|
||||
require.NoError(t, json.Unmarshal(out.Bytes(), &env))
|
||||
assert.True(t, env.OK)
|
||||
assert.Len(t, env.Data, 0, "expected empty data array")
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
package chunkcmd
|
||||
|
||||
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"
|
||||
)
|
||||
|
||||
// chunkViewFields enumerates the 23 SDK Chunk fields surfaced for `--format json`
|
||||
// discovery. JSON is bare SDK pass-through, so keys are snake_case
|
||||
// (`knowledge_id`, `knowledge_base_id`) even though the human KV labels
|
||||
// them as `doc_id` / `kb_id`.
|
||||
var chunkViewFields = []string{
|
||||
"id", "seq_id", "knowledge_id", "knowledge_base_id", "tenant_id",
|
||||
"tag_id", "content", "chunk_index", "is_enabled", "status",
|
||||
"start_at", "end_at", "pre_chunk_id", "next_chunk_id", "chunk_type",
|
||||
"parent_chunk_id", "relation_chunks", "indirect_relation_chunks",
|
||||
"metadata", "content_hash", "image_info", "created_at", "updated_at",
|
||||
}
|
||||
|
||||
// ViewService is the narrow SDK surface this command depends on.
|
||||
type ViewService interface {
|
||||
GetChunkByIDOnly(ctx context.Context, chunkID string) (*sdk.Chunk, error)
|
||||
}
|
||||
|
||||
type ViewOptions struct {
|
||||
ChunkID string
|
||||
}
|
||||
|
||||
const chunkViewLong = `Show a single chunk with all SDK fields.
|
||||
|
||||
Text output is a key-value block; pass --format json for the bare 23-field SDK
|
||||
Chunk object. Content renders verbatim regardless of size — pipe to
|
||||
less or use --format json for large chunks. WeKnora chunks are typically bounded
|
||||
by the ingest pipeline (~1000 tokens / a few KB), so unconditional full
|
||||
rendering is reasonable.
|
||||
|
||||
Scope asymmetry with 'chunk delete':
|
||||
view <id> scope-less (server: GET /chunks/by-id/{id})
|
||||
delete <id> --doc D scoped (server: DELETE /chunks/{doc}/{id})
|
||||
|
||||
The asymmetry is deliberate. Auto-resolving doc id on delete would race
|
||||
the ingest pipeline; forcing --doc on view would add friction with no
|
||||
benefit. AI agents: a chunk id from any source (list, search, agent
|
||||
invoke citation) is sufficient for view.
|
||||
|
||||
Typed exit codes:
|
||||
resource.not_found no chunk with the given id (exit 4)`
|
||||
|
||||
const chunkViewExample = ` weknora chunk view chunk_abc
|
||||
weknora chunk view chunk_abc --format json | jq '.content'
|
||||
weknora chunk view chunk_abc --format json --jq '{id, chunk_index, is_enabled}'`
|
||||
|
||||
// NewCmdView builds `weknora chunk view <chunk-id>`.
|
||||
func NewCmdView(f *cmdutil.Factory) *cobra.Command {
|
||||
opts := &ViewOptions{}
|
||||
cmd := &cobra.Command{
|
||||
Use: "view <chunk-id>",
|
||||
Short: "Show a chunk's fields and content (scope-less)",
|
||||
Long: chunkViewLong,
|
||||
Example: chunkViewExample,
|
||||
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.ChunkID = args[0]
|
||||
cli, err := f.Client()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return runView(c.Context(), opts, fopts, cli)
|
||||
},
|
||||
}
|
||||
cmdutil.AddFormatFlag(cmd, chunkViewFields...)
|
||||
cmdutil.AddIgnoredKBFlag(cmd)
|
||||
cmdutil.SetAgentHelp(cmd, cmdutil.AgentHelp{
|
||||
UsedFor: "fetch one chunk's fields and content by id (scope-less; no --doc needed)",
|
||||
RequiredFlags: []string{"<chunk-id> (positional)"},
|
||||
Examples: []string{"weknora chunk view chunk_abc"},
|
||||
Output: "envelope.data is the chunk object (id, content, knowledge_id, ...)",
|
||||
})
|
||||
return cmd
|
||||
}
|
||||
|
||||
func runView(ctx context.Context, opts *ViewOptions, fopts *cmdutil.FormatOptions, svc ViewService) error {
|
||||
ch, err := svc.GetChunkByIDOnly(ctx, opts.ChunkID)
|
||||
if err != nil {
|
||||
return cmdutil.WrapHTTP(err, "fetch chunk %s", opts.ChunkID)
|
||||
}
|
||||
if fopts.WantsJSON() {
|
||||
return fopts.Emit(iostreams.IO.Out, ch, nil)
|
||||
}
|
||||
renderChunk(iostreams.IO.Out, ch)
|
||||
return nil
|
||||
}
|
||||
|
||||
// renderChunk prints a single chunk in human-readable KV form.
|
||||
// Field order: id / seq_id / chunk_index / doc_id / kb_id / type / enabled /
|
||||
// status (omit-zero) / start_at (omit-zero) / end_at (omit-zero) /
|
||||
// tag_id (omit-empty) / image_info (omit-empty) / created_at / updated_at /
|
||||
// content (full, no truncation, last entry).
|
||||
//
|
||||
// `doc_id` / `kb_id` are the human-friendly labels for the SDK fields
|
||||
// `knowledge_id` / `knowledge_base_id`; JSON output keeps the SDK names.
|
||||
func renderChunk(w io.Writer, c *sdk.Chunk) {
|
||||
fmt.Fprintf(w, "id: %s\n", c.ID)
|
||||
if c.SeqID != 0 {
|
||||
fmt.Fprintf(w, "seq_id: %d\n", c.SeqID)
|
||||
}
|
||||
fmt.Fprintf(w, "chunk_index: %d\n", c.ChunkIndex)
|
||||
if c.KnowledgeID != "" {
|
||||
fmt.Fprintf(w, "doc_id: %s\n", c.KnowledgeID)
|
||||
}
|
||||
if c.KnowledgeBaseID != "" {
|
||||
fmt.Fprintf(w, "kb_id: %s\n", c.KnowledgeBaseID)
|
||||
}
|
||||
if c.ChunkType != "" {
|
||||
fmt.Fprintf(w, "type: %s\n", c.ChunkType)
|
||||
}
|
||||
enabled := "no"
|
||||
if c.IsEnabled {
|
||||
enabled = "yes"
|
||||
}
|
||||
fmt.Fprintf(w, "enabled: %s\n", enabled)
|
||||
if c.Status != 0 {
|
||||
fmt.Fprintf(w, "status: %d\n", c.Status)
|
||||
}
|
||||
if c.StartAt != 0 {
|
||||
fmt.Fprintf(w, "start_at: %d\n", c.StartAt)
|
||||
}
|
||||
if c.EndAt != 0 {
|
||||
fmt.Fprintf(w, "end_at: %d\n", c.EndAt)
|
||||
}
|
||||
if c.TagID != "" {
|
||||
fmt.Fprintf(w, "tag_id: %s\n", c.TagID)
|
||||
}
|
||||
if c.ImageInfo != "" {
|
||||
fmt.Fprintf(w, "image_info: %s\n", c.ImageInfo)
|
||||
}
|
||||
if c.CreatedAt != "" {
|
||||
fmt.Fprintf(w, "created_at: %s\n", c.CreatedAt)
|
||||
}
|
||||
if c.UpdatedAt != "" {
|
||||
fmt.Fprintf(w, "updated_at: %s\n", c.UpdatedAt)
|
||||
}
|
||||
// Content rendered verbatim, last entry, no truncation.
|
||||
fmt.Fprintln(w)
|
||||
fmt.Fprintln(w, "content:")
|
||||
fmt.Fprintln(w, c.Content)
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
package chunkcmd
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/Tencent/WeKnora/cli/internal/cmdutil"
|
||||
"github.com/Tencent/WeKnora/cli/internal/iostreams"
|
||||
sdk "github.com/Tencent/WeKnora/client"
|
||||
)
|
||||
|
||||
type fakeViewSvc struct {
|
||||
resp *sdk.Chunk
|
||||
err error
|
||||
}
|
||||
|
||||
func (f *fakeViewSvc) GetChunkByIDOnly(_ context.Context, _ string) (*sdk.Chunk, error) {
|
||||
return f.resp, f.err
|
||||
}
|
||||
|
||||
func TestView_Happy_RendersAllFields(t *testing.T) {
|
||||
out, _ := iostreams.SetForTest(t)
|
||||
svc := &fakeViewSvc{resp: &sdk.Chunk{
|
||||
ID: "c1",
|
||||
SeqID: 42,
|
||||
ChunkIndex: 0,
|
||||
KnowledgeID: "doc_abc",
|
||||
KnowledgeBaseID: "kb_abc",
|
||||
ChunkType: "text",
|
||||
IsEnabled: true,
|
||||
Content: "the quick brown fox",
|
||||
CreatedAt: "2026-05-15T11:00:00Z",
|
||||
UpdatedAt: "2026-05-15T12:00:00Z",
|
||||
}}
|
||||
require.NoError(t, runView(context.Background(), &ViewOptions{ChunkID: "c1"}, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, svc))
|
||||
body := out.String()
|
||||
assert.Contains(t, body, "c1")
|
||||
assert.Contains(t, body, "doc_abc")
|
||||
assert.Contains(t, body, "kb_abc")
|
||||
assert.Contains(t, body, "text")
|
||||
assert.Contains(t, body, "the quick brown fox", "content must render in full")
|
||||
}
|
||||
|
||||
func TestView_HumanLabels_DocAndKB(t *testing.T) {
|
||||
out, _ := iostreams.SetForTest(t)
|
||||
svc := &fakeViewSvc{resp: &sdk.Chunk{
|
||||
ID: "c1", KnowledgeID: "doc_abc", KnowledgeBaseID: "kb_abc",
|
||||
}}
|
||||
require.NoError(t, runView(context.Background(), &ViewOptions{ChunkID: "c1"}, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, svc))
|
||||
body := out.String()
|
||||
// Text KV uses friendlier DOC_ID / KB_ID labels (the SDK's
|
||||
// knowledge_id / knowledge_base_id are kept only in --format json output).
|
||||
assert.Contains(t, body, "doc_id")
|
||||
assert.Contains(t, body, "kb_id")
|
||||
assert.NotContains(t, body, "knowledge_id")
|
||||
assert.NotContains(t, body, "knowledge_base_id")
|
||||
}
|
||||
|
||||
func TestView_OmitsZeroOrEmpty(t *testing.T) {
|
||||
out, _ := iostreams.SetForTest(t)
|
||||
svc := &fakeViewSvc{resp: &sdk.Chunk{ID: "c_min", Content: "x"}}
|
||||
require.NoError(t, runView(context.Background(), &ViewOptions{ChunkID: "c_min"}, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, svc))
|
||||
body := out.String()
|
||||
// status / start_at / end_at all zero → must be omitted from the human KV.
|
||||
assert.NotContains(t, body, "status:")
|
||||
assert.NotContains(t, body, "start_at:")
|
||||
assert.NotContains(t, body, "end_at:")
|
||||
// tag_id / image_info empty → omitted.
|
||||
assert.NotContains(t, body, "tag_id:")
|
||||
assert.NotContains(t, body, "image_info:")
|
||||
}
|
||||
|
||||
func TestView_JSON_BareSDKShape(t *testing.T) {
|
||||
out, _ := iostreams.SetForTest(t)
|
||||
svc := &fakeViewSvc{resp: &sdk.Chunk{
|
||||
ID: "c_json", KnowledgeID: "doc_abc", KnowledgeBaseID: "kb_abc",
|
||||
}}
|
||||
require.NoError(t, runView(context.Background(), &ViewOptions{ChunkID: "c_json"}, &cmdutil.FormatOptions{Mode: cmdutil.FormatJSON}, svc))
|
||||
var env struct {
|
||||
OK bool `json:"ok"`
|
||||
Data sdk.Chunk `json:"data"`
|
||||
}
|
||||
require.NoError(t, json.Unmarshal(out.Bytes(), &env))
|
||||
got := env.Data
|
||||
assert.Equal(t, "c_json", got.ID)
|
||||
assert.Equal(t, "doc_abc", got.KnowledgeID)
|
||||
// JSON uses SDK snake_case keys (knowledge_id), not text relabel doc_id.
|
||||
assert.Contains(t, out.String(), `"knowledge_id":"doc_abc"`)
|
||||
assert.Contains(t, out.String(), `"knowledge_base_id":"kb_abc"`)
|
||||
assert.NotContains(t, out.String(), `"doc_id"`)
|
||||
assert.NotContains(t, out.String(), `"kb_id"`)
|
||||
}
|
||||
|
||||
func TestView_404(t *testing.T) {
|
||||
_, _ = iostreams.SetForTest(t)
|
||||
svc := &fakeViewSvc{err: errors.New("HTTP error 404: not found")}
|
||||
err := runView(context.Background(), &ViewOptions{ChunkID: "missing"}, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, svc)
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "resource.not_found")
|
||||
}
|
||||
Reference in New Issue
Block a user