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,163 @@
|
||||
// Package doc — create.go implements `weknora doc create --text "..."`.
|
||||
// Allows creating a knowledge entry directly from inline text content without
|
||||
// uploading a file or fetching a remote URL.
|
||||
package doc
|
||||
|
||||
import (
|
||||
"cmp"
|
||||
"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/output"
|
||||
sdk "github.com/Tencent/WeKnora/client"
|
||||
)
|
||||
|
||||
// docCreateFields enumerates the fields surfaced for `--format json` discovery
|
||||
// on `doc create`.
|
||||
var docCreateFields = []string{
|
||||
"id", "knowledge_base_id", "tag_id", "type", "title", "description",
|
||||
"source", "channel", "parse_status", "summary_status", "enable_status",
|
||||
"created_at", "updated_at", "error_message",
|
||||
}
|
||||
|
||||
// CreateOptions holds CLI flag values for `doc create`.
|
||||
type CreateOptions struct {
|
||||
Text string // --text (required): document text content (Markdown)
|
||||
Title string // --title: document title (matches `doc update --title`)
|
||||
TagID string // --tag-id: associate with a tag
|
||||
Channel string // --channel: ingestion-channel tag (default "api")
|
||||
DryRun bool
|
||||
}
|
||||
|
||||
// CreateService is the narrow SDK surface for `doc create`.
|
||||
// *sdk.Client satisfies it.
|
||||
type CreateService interface {
|
||||
CreateManualKnowledge(
|
||||
ctx context.Context,
|
||||
kbID string,
|
||||
req *sdk.CreateManualKnowledgeRequest,
|
||||
) (*sdk.Knowledge, error)
|
||||
}
|
||||
|
||||
// NewCmdCreate builds `weknora doc create --text "..."`.
|
||||
func NewCmdCreate(f *cmdutil.Factory) *cobra.Command {
|
||||
opts := &CreateOptions{}
|
||||
cmd := &cobra.Command{
|
||||
Use: "create",
|
||||
Short: "Create a knowledge entry from inline text content",
|
||||
Long: `Create a new knowledge entry by passing Markdown content directly via --text.
|
||||
Useful for short snippets, agent-generated content, or structured notes that
|
||||
don't require a file upload or remote URL. KB resolution follows the standard
|
||||
4-level chain: --kb flag > WEKNORA_KB_ID env > .weknora/project.yaml > error.
|
||||
|
||||
--text <content> Document text in Markdown format (required).
|
||||
--title <title> Display title stored with the entry (matches doc update --title).
|
||||
--tag-id <id> Associate the new entry with a tag.
|
||||
--channel <name> Override the ingestion-channel tag (default "api").`,
|
||||
Example: ` weknora doc create --text "# Meeting Notes\n\nAction items: ..."
|
||||
weknora doc create --text "$(cat notes.md)" --title "Sprint Notes" --kb my-kb
|
||||
weknora doc create --text "API usage guide" --title "API Guide" --tag-id tag_abc`,
|
||||
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())
|
||||
if opts.DryRun {
|
||||
// ResolveKBLocal validates the KB is set via flag / env /
|
||||
// project link without an SDK call; the plan reports the
|
||||
// raw --kb value (UUID or name) for agent inspection.
|
||||
kbID, err := f.ResolveKBLocal(c)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if handled, err := cmdutil.HandleDryRun(c, true, cmdutil.DryRunPlan{
|
||||
Action: "doc.create",
|
||||
Args: map[string]any{
|
||||
"text": opts.Text,
|
||||
"title": opts.Title,
|
||||
"kb": kbID,
|
||||
},
|
||||
}); handled {
|
||||
return err
|
||||
}
|
||||
}
|
||||
cli, err := f.Client()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// Live path resolves --kb name → id via the SDK.
|
||||
kbID, err := f.ResolveKB(c)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return runCreate(c.Context(), opts, fopts, cli, kbID)
|
||||
},
|
||||
}
|
||||
cmdutil.AddKBFlag(cmd)
|
||||
cmd.Flags().StringVar(&opts.Text, "text", "", "Document text content in Markdown format (required)")
|
||||
cmd.Flags().StringVar(&opts.Title, "title", "", "Document title")
|
||||
cmd.Flags().StringVar(&opts.TagID, "tag-id", "", "Tag id to associate with the new entry")
|
||||
cmd.Flags().StringVar(&opts.Channel, "channel", "", "Ingestion-channel tag recorded server-side (default \"api\")")
|
||||
_ = cmd.MarkFlagRequired("text")
|
||||
cmdutil.AddFormatFlag(cmd, docCreateFields...)
|
||||
cmdutil.AddDryRunFlag(cmd, &opts.DryRun)
|
||||
cmdutil.SetAgentHelp(cmd, cmdutil.AgentHelp{
|
||||
UsedFor: "Create a knowledge entry from inline Markdown text. KB resolved via --kb flag, WEKNORA_KB_ID env, or project link. Emits the created Knowledge object with its id.",
|
||||
RequiredFlags: []string{"--text", "--kb (or WEKNORA_KB_ID / project link)"},
|
||||
Examples: []string{
|
||||
`weknora doc create --kb kb_eng --text "# Runbook\n\nRestart steps: ..."`,
|
||||
`weknora doc create --kb kb_eng --text "$(cat notes.md)" --title "Sprint Notes"`,
|
||||
},
|
||||
Output: "envelope.data is the created Knowledge object with id, knowledge_base_id, title, parse_status",
|
||||
})
|
||||
return cmd
|
||||
}
|
||||
|
||||
// runCreate creates a manual knowledge entry via SDK CreateManualKnowledge.
|
||||
func runCreate(ctx context.Context, opts *CreateOptions, fopts *cmdutil.FormatOptions, svc CreateService, kbID string) error {
|
||||
// Guard against empty text: cobra's MarkFlagRequired enforces this for
|
||||
// normal CLI invocations; this check protects tests that call runCreate
|
||||
// directly and any future callers that bypass cobra.
|
||||
if opts.Text == "" {
|
||||
return cmdutil.NewFlagError(fmt.Errorf("--text is required"))
|
||||
}
|
||||
req := &sdk.CreateManualKnowledgeRequest{
|
||||
Title: opts.Title,
|
||||
Content: opts.Text,
|
||||
TagID: opts.TagID,
|
||||
Channel: cmp.Or(opts.Channel, uploadChannel),
|
||||
}
|
||||
k, err := svc.CreateManualKnowledge(ctx, kbID, req)
|
||||
if err != nil {
|
||||
return cmdutil.WrapHTTP(err, "create document")
|
||||
}
|
||||
// Inline-created docs land in parse_status=draft and are NOT auto-queued
|
||||
// for parsing (unlike `doc upload`), so they aren't searchable until
|
||||
// reparsed. Surface the next step so an agent doesn't `doc wait` into a
|
||||
// timeout or `search` into empty results.
|
||||
var meta *output.Meta
|
||||
if k.ParseStatus == "draft" {
|
||||
meta = &output.Meta{Hint: "document created in parse_status=draft (not yet indexed) — run `weknora doc reparse " + k.ID + "` to parse & make it searchable"}
|
||||
}
|
||||
if fopts.WantsJSON() {
|
||||
return fopts.Emit(iostreams.IO.Out, k, meta)
|
||||
}
|
||||
displayed := opts.Title
|
||||
if displayed == "" {
|
||||
displayed = k.Title
|
||||
}
|
||||
if displayed == "" {
|
||||
displayed = k.ID
|
||||
}
|
||||
fmt.Fprintf(iostreams.IO.Out, "✓ Created %q (id: %s)\n", displayed, k.ID)
|
||||
if meta != nil {
|
||||
fmt.Fprintf(iostreams.IO.Out, " ⚠ %s\n", meta.Hint)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
package doc
|
||||
|
||||
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"
|
||||
sdk "github.com/Tencent/WeKnora/client"
|
||||
)
|
||||
|
||||
// fakeCreateSvc captures call arguments and returns canned responses.
|
||||
type fakeCreateSvc struct {
|
||||
resp *sdk.Knowledge
|
||||
err error
|
||||
got struct {
|
||||
kbID string
|
||||
req *sdk.CreateManualKnowledgeRequest
|
||||
}
|
||||
}
|
||||
|
||||
func (f *fakeCreateSvc) CreateManualKnowledge(
|
||||
_ context.Context,
|
||||
kbID string,
|
||||
req *sdk.CreateManualKnowledgeRequest,
|
||||
) (*sdk.Knowledge, error) {
|
||||
f.got.kbID = kbID
|
||||
f.got.req = req
|
||||
return f.resp, f.err
|
||||
}
|
||||
|
||||
// TestCreate_TitleSetsRequestTitle: --title is the sole title flag and flows
|
||||
// straight into the create request.
|
||||
func TestCreate_TitleSetsRequestTitle(t *testing.T) {
|
||||
_, _ = iostreams.SetForTest(t)
|
||||
svc := &fakeCreateSvc{resp: &sdk.Knowledge{ID: "d1"}}
|
||||
require.NoError(t, runCreate(context.Background(),
|
||||
&CreateOptions{Text: "x", Title: "FromTitle"},
|
||||
&cmdutil.FormatOptions{Mode: cmdutil.FormatText}, svc, "kb1"))
|
||||
assert.Equal(t, "FromTitle", svc.got.req.Title)
|
||||
}
|
||||
|
||||
func TestCreate_Success_Text(t *testing.T) {
|
||||
out, _ := iostreams.SetForTest(t)
|
||||
svc := &fakeCreateSvc{resp: &sdk.Knowledge{ID: "doc_manual_1", Title: "Sprint Notes"}}
|
||||
opts := &CreateOptions{Text: "# Sprint Notes\n\nAction items: ...", Title: "Sprint Notes"}
|
||||
require.NoError(t, runCreate(context.Background(), opts, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, svc, "kb_xxx"))
|
||||
|
||||
assert.Equal(t, "kb_xxx", svc.got.kbID)
|
||||
assert.Equal(t, "# Sprint Notes\n\nAction items: ...", svc.got.req.Content)
|
||||
assert.Equal(t, "Sprint Notes", svc.got.req.Title)
|
||||
assert.Contains(t, out.String(), "Created")
|
||||
assert.Contains(t, out.String(), "doc_manual_1")
|
||||
}
|
||||
|
||||
func TestCreate_Success_NoName_FallsBackToTitle(t *testing.T) {
|
||||
out, _ := iostreams.SetForTest(t)
|
||||
svc := &fakeCreateSvc{resp: &sdk.Knowledge{ID: "doc_manual_2", Title: "Server Title"}}
|
||||
opts := &CreateOptions{Text: "Some content"}
|
||||
require.NoError(t, runCreate(context.Background(), opts, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, svc, "kb_xxx"))
|
||||
// When --name is omitted the display falls back to k.Title from the server response.
|
||||
assert.Contains(t, out.String(), "Server Title")
|
||||
}
|
||||
|
||||
func TestCreate_Success_NoName_NoTitle_FallsBackToID(t *testing.T) {
|
||||
out, _ := iostreams.SetForTest(t)
|
||||
svc := &fakeCreateSvc{resp: &sdk.Knowledge{ID: "doc_manual_3"}}
|
||||
opts := &CreateOptions{Text: "Some content"}
|
||||
require.NoError(t, runCreate(context.Background(), opts, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, svc, "kb_xxx"))
|
||||
assert.Contains(t, out.String(), "doc_manual_3")
|
||||
}
|
||||
|
||||
func TestCreate_TagID_Forwarded(t *testing.T) {
|
||||
_, _ = iostreams.SetForTest(t)
|
||||
svc := &fakeCreateSvc{resp: &sdk.Knowledge{ID: "doc_t"}}
|
||||
opts := &CreateOptions{Text: "content", TagID: "tag_42"}
|
||||
require.NoError(t, runCreate(context.Background(), opts, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, svc, "kb_xxx"))
|
||||
assert.Equal(t, "tag_42", svc.got.req.TagID)
|
||||
}
|
||||
|
||||
func TestCreate_Channel_Override(t *testing.T) {
|
||||
_, _ = iostreams.SetForTest(t)
|
||||
svc := &fakeCreateSvc{resp: &sdk.Knowledge{ID: "doc_ch"}}
|
||||
opts := &CreateOptions{Text: "content", Channel: "browser_extension"}
|
||||
require.NoError(t, runCreate(context.Background(), opts, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, svc, "kb_xxx"))
|
||||
assert.Equal(t, "browser_extension", svc.got.req.Channel)
|
||||
}
|
||||
|
||||
func TestCreate_Channel_DefaultIsAPI(t *testing.T) {
|
||||
_, _ = iostreams.SetForTest(t)
|
||||
svc := &fakeCreateSvc{resp: &sdk.Knowledge{ID: "doc_ch"}}
|
||||
opts := &CreateOptions{Text: "content"}
|
||||
require.NoError(t, runCreate(context.Background(), opts, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, svc, "kb_xxx"))
|
||||
assert.Equal(t, uploadChannel, svc.got.req.Channel)
|
||||
}
|
||||
|
||||
func TestCreate_JSON_Envelope(t *testing.T) {
|
||||
out, _ := iostreams.SetForTest(t)
|
||||
svc := &fakeCreateSvc{resp: &sdk.Knowledge{ID: "doc_manual_json", Title: "My Note"}}
|
||||
opts := &CreateOptions{Text: "# My Note", Title: "My Note"}
|
||||
fopts := &cmdutil.FormatOptions{Mode: cmdutil.FormatJSON}
|
||||
require.NoError(t, runCreate(context.Background(), opts, fopts, svc, "kb_xxx"))
|
||||
|
||||
got := out.String()
|
||||
var env struct {
|
||||
OK bool `json:"ok"`
|
||||
Data sdk.Knowledge `json:"data"`
|
||||
}
|
||||
require.NoError(t, json.Unmarshal([]byte(got), &env), "expected valid JSON envelope, got %q", got)
|
||||
assert.True(t, env.OK, "envelope.ok must be true")
|
||||
assert.Equal(t, "doc_manual_json", env.Data.ID, "envelope.data.id must match")
|
||||
}
|
||||
|
||||
func TestCreate_ServerError_Wraps(t *testing.T) {
|
||||
_, _ = iostreams.SetForTest(t)
|
||||
svc := &fakeCreateSvc{err: errors.New("HTTP error 500: internal server error")}
|
||||
err := runCreate(context.Background(),
|
||||
&CreateOptions{Text: "content"}, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, svc, "kb_xxx")
|
||||
require.Error(t, err)
|
||||
var typed *cmdutil.Error
|
||||
require.ErrorAs(t, err, &typed)
|
||||
assert.Equal(t, cmdutil.CodeServerError, typed.Code)
|
||||
}
|
||||
|
||||
func TestCreate_HTTPError_400_Wraps(t *testing.T) {
|
||||
_, _ = iostreams.SetForTest(t)
|
||||
svc := &fakeCreateSvc{err: errors.New("HTTP error 400: bad request")}
|
||||
err := runCreate(context.Background(),
|
||||
&CreateOptions{Text: "content"}, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, svc, "kb_xxx")
|
||||
require.Error(t, err)
|
||||
var typed *cmdutil.Error
|
||||
require.ErrorAs(t, err, &typed)
|
||||
assert.Equal(t, cmdutil.CodeInputInvalidArgument, typed.Code)
|
||||
}
|
||||
|
||||
func TestCreate_EmptyText_RejectsBeforeSDK(t *testing.T) {
|
||||
_, _ = iostreams.SetForTest(t)
|
||||
// The SDK should never be called when --text is empty (runCreate has a
|
||||
// defensive guard; cobra's MarkFlagRequired catches it earlier in RunE).
|
||||
svc := &fakeCreateSvc{resp: &sdk.Knowledge{ID: "should-not-reach"}}
|
||||
err := runCreate(context.Background(),
|
||||
&CreateOptions{Text: ""}, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, svc, "kb_xxx")
|
||||
require.Error(t, err)
|
||||
// Verify the SDK was NOT called.
|
||||
assert.Nil(t, svc.got.req, "SDK must not be called when --text is empty")
|
||||
}
|
||||
|
||||
// TestCreate_AgentHelp_EmitsUsedFor verifies that when WEKNORA_AGENT_HELP=1
|
||||
// the `doc create --help` path emits a JSON blob containing "used_for".
|
||||
// This is the representative test for 3.2; the mechanism is covered by
|
||||
// internal/cmdutil/agenthelp_test.go — we test the wiring here.
|
||||
func TestCreate_AgentHelp_EmitsUsedFor(t *testing.T) {
|
||||
t.Setenv("WEKNORA_AGENT_HELP", "1")
|
||||
_, _ = iostreams.SetForTest(t)
|
||||
|
||||
f := &cmdutil.Factory{}
|
||||
cmd := NewCmdCreate(f)
|
||||
|
||||
var buf bytes.Buffer
|
||||
cmd.SetOut(&buf)
|
||||
cmd.HelpFunc()(cmd, nil)
|
||||
|
||||
assert.Contains(t, buf.String(), `"used_for"`)
|
||||
}
|
||||
@@ -0,0 +1,227 @@
|
||||
package doc
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
sdk "github.com/Tencent/WeKnora/client"
|
||||
|
||||
"github.com/Tencent/WeKnora/cli/internal/cmdutil"
|
||||
"github.com/Tencent/WeKnora/cli/internal/iostreams"
|
||||
"github.com/Tencent/WeKnora/cli/internal/prompt"
|
||||
)
|
||||
|
||||
// docDeleteFields enumerates the fields surfaced for `--format json` discovery
|
||||
// on `doc delete`. The result payload is a small {id, deleted} object.
|
||||
var docDeleteFields = []string{"id", "deleted"}
|
||||
|
||||
type DeleteOptions struct {
|
||||
Yes bool // sourced from the global -y/--yes persistent flag (see cli/cmd/root.go)
|
||||
All bool // delete all docs in --kb
|
||||
KB string // required when --all
|
||||
DryRun bool
|
||||
}
|
||||
|
||||
// DeleteService is the narrow SDK surface this command depends on.
|
||||
// *sdk.Client satisfies it.
|
||||
type DeleteService interface {
|
||||
DeleteKnowledge(ctx context.Context, id string) error
|
||||
}
|
||||
|
||||
// AllService is the narrow SDK surface for --all mode.
|
||||
// *sdk.Client satisfies it.
|
||||
type AllService interface {
|
||||
ClearKnowledgeBaseContents(ctx context.Context, kbID string) (*sdk.ClearKnowledgeBaseContentsResponse, error)
|
||||
}
|
||||
|
||||
// deleteResult is the typed payload emitted under data on success (single-id).
|
||||
type deleteResult struct {
|
||||
ID string `json:"id"`
|
||||
Deleted bool `json:"deleted"`
|
||||
}
|
||||
|
||||
// NewCmdDelete builds `weknora doc delete`. Single-id keeps the simpler
|
||||
// code path (one confirm prompt, exit 0/1); multi-id uses keep-going
|
||||
// semantics (one -y confirms all, failures collected, exit 1 if any fail);
|
||||
// --all --kb=<id> atomically clears every document in a knowledge base.
|
||||
func NewCmdDelete(f *cmdutil.Factory) *cobra.Command {
|
||||
opts := &DeleteOptions{}
|
||||
cmd := &cobra.Command{
|
||||
Use: "delete <doc-id> [<doc-id>...] | --all --kb=<kb-id>",
|
||||
Short: "Delete one or more documents from a knowledge base",
|
||||
Long: `Permanently deletes one or more documents. 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).
|
||||
|
||||
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 documents.
|
||||
• TTY prompt shows total: "Delete N document(s)? This cannot be undone."
|
||||
• Exit 0 if all succeed; exit 1 if any failed.
|
||||
|
||||
All-in-KB (--all --kb=<kb-id>):
|
||||
• Atomically clears every document in the named knowledge base.
|
||||
• The KB record itself (name, config) is preserved.
|
||||
• Mutually exclusive with positional doc ids.
|
||||
• Exit 0 on success; exit 10 without -y in non-interactive/JSON mode.
|
||||
|
||||
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.`,
|
||||
Example: ` weknora doc delete doc_abc # interactive confirm
|
||||
weknora doc delete doc_abc -y # no prompt
|
||||
weknora doc delete doc_abc -y --format json # bare {id, deleted:true} JSON
|
||||
weknora doc delete doc_a doc_b doc_c -y # delete 3, keep-going
|
||||
weknora doc delete doc_a doc_b --format json # multi-id JSON output
|
||||
weknora doc delete --all --kb=kb_x -y # clear all docs in kb_x
|
||||
weknora doc delete --all --kb=kb_x -y --format json # agent-friendly`,
|
||||
Args: cobra.ArbitraryArgs,
|
||||
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")
|
||||
// Structural validation (pure-local) must run before the dry-run
|
||||
// gate so --dry-run rejects the same invalid invocations the live
|
||||
// path rejects. Same typed errors are kept below the gate for
|
||||
// safety (in case of future callers that bypass RunE).
|
||||
if opts.All {
|
||||
if opts.KB == "" {
|
||||
return cmdutil.NewError(cmdutil.CodeInputInvalidArgument, "--all requires --kb=<id>").
|
||||
WithHint("specify --kb=<uuid-or-name> to scope the delete-all operation").
|
||||
WithRetryArgv([]string{"weknora", "doc", "delete", "--all", "--kb=<kb-id>", "-y"})
|
||||
}
|
||||
if len(args) > 0 {
|
||||
return cmdutil.NewFlagError(fmt.Errorf("--all is exclusive with positional doc ids"))
|
||||
}
|
||||
} else if len(args) == 0 {
|
||||
return cmdutil.NewFlagError(fmt.Errorf("doc id(s) required (or use --all --kb=<id>)"))
|
||||
}
|
||||
if opts.DryRun {
|
||||
plan := cmdutil.DryRunPlan{
|
||||
Action: "doc.delete",
|
||||
Args: map[string]any{"doc_ids": args},
|
||||
}
|
||||
if opts.All {
|
||||
plan.Action = "doc.delete_all"
|
||||
plan.Args = map[string]any{"all": true, "kb": opts.KB}
|
||||
}
|
||||
if handled, err := cmdutil.HandleDryRun(c, true, plan); handled {
|
||||
return err
|
||||
}
|
||||
}
|
||||
cli, err := f.Client()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if opts.All {
|
||||
// Resolve KB name → id before the destructive SDK call.
|
||||
// Must use cmdutil.ResolveKBFlag (id-or-name, lists KBs and
|
||||
// matches by name) rather than Factory.ResolveKB, which walks
|
||||
// the project link — a destructive "empty the KB" must never
|
||||
// inherit an implicit linked KB.
|
||||
kbID, err := cmdutil.ResolveKBFlag(c.Context(), cli, opts.KB)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
opts.KB = kbID
|
||||
return runDeleteAll(c.Context(), opts, fopts, cli, f.Prompter())
|
||||
}
|
||||
// Single-id uses the simpler code path (bare {id, deleted}).
|
||||
if len(args) == 1 {
|
||||
return runDelete(c.Context(), opts, fopts, cli, f.Prompter(), args[0])
|
||||
}
|
||||
batchRetry := append([]string{"weknora", "doc", "delete"}, args...)
|
||||
batchRetry = append(batchRetry, "-y")
|
||||
if err := cmdutil.ConfirmDestructiveBatch(f.Prompter(), opts.Yes, fopts.WantsJSON(), "delete", "document", len(args), "doc.delete", batchRetry); err != nil {
|
||||
return err
|
||||
}
|
||||
outcomes, runErr := cmdutil.RunBatch(c.Context(), args, func(ctx context.Context, id string) error {
|
||||
if err := cli.DeleteKnowledge(ctx, id); err != nil {
|
||||
return cmdutil.WrapHTTP(err, "delete document %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().BoolVar(&opts.All, "all", false, "delete all documents in the KB specified by --kb")
|
||||
cmd.Flags().StringVar(&opts.KB, "kb", "", "Knowledge base UUID or name (required with --all)")
|
||||
cmdutil.AddFormatFlag(cmd, docDeleteFields...)
|
||||
cmdutil.AddDryRunFlag(cmd, &opts.DryRun)
|
||||
cmdutil.SetRisk(cmd, "doc.delete")
|
||||
cmdutil.SetAgentHelp(cmd, cmdutil.AgentHelp{
|
||||
UsedFor: "permanently delete one or more documents from a knowledge base",
|
||||
RequiredFlags: []string{"<doc-id>... (positional) | --all --kb=<uuid-or-name>"},
|
||||
Examples: []string{
|
||||
"weknora doc delete doc_abc -y",
|
||||
"weknora doc delete doc_a doc_b doc_c -y",
|
||||
"weknora doc delete --all --kb=kb_x -y --format json",
|
||||
},
|
||||
Output: "envelope.data shape depends on the form: a single doc id -> {id, deleted:true}; multiple doc 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, ok:true overall does not survive a partial failure / exit 1); --all --kb -> {kb_id, deleted_count}.",
|
||||
Warnings: []string{
|
||||
"Requires explicit user approval (exit 10 / input.confirmation_required); never auto-add -y.",
|
||||
"doc delete is irreversible; loses the document + its chunks + embeddings.",
|
||||
"--all empties the entire KB; thousands of documents may be lost in one operation.",
|
||||
},
|
||||
})
|
||||
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", "document", id, "doc.delete", []string{"weknora", "doc", "delete", id, "-y"}); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := svc.DeleteKnowledge(ctx, id); err != nil {
|
||||
return cmdutil.WrapHTTP(err, "delete document %s", id)
|
||||
}
|
||||
|
||||
if fopts.WantsJSON() {
|
||||
return fopts.Emit(iostreams.IO.Out, deleteResult{ID: id, Deleted: true}, nil)
|
||||
}
|
||||
fmt.Fprintf(iostreams.IO.Out, "✓ Deleted document %s\n", id)
|
||||
return nil
|
||||
}
|
||||
|
||||
// runDeleteAll atomically clears every document in opts.KB via a single
|
||||
// ClearKnowledgeBaseContents call. Non-TTY/JSON mode without -y returns
|
||||
// CodeInputConfirmationRequired (exit 10) with risk metadata so agents can
|
||||
// surface the risk to the user before re-invoking with -y.
|
||||
func runDeleteAll(ctx context.Context, opts *DeleteOptions, fopts *cmdutil.FormatOptions, svc AllService, p prompt.Prompter) error {
|
||||
if err := cmdutil.ConfirmDestructive(p, opts.Yes, fopts.WantsJSON(), "delete", "all docs in KB", opts.KB, "doc.delete_all", []string{"weknora", "doc", "delete", "--all", "--kb=" + opts.KB, "-y"}); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
resp, err := svc.ClearKnowledgeBaseContents(ctx, opts.KB)
|
||||
if err != nil {
|
||||
return cmdutil.WrapHTTP(err, "clear KB %s", opts.KB)
|
||||
}
|
||||
deleted := 0
|
||||
if resp != nil {
|
||||
deleted = resp.DeletedCount
|
||||
}
|
||||
|
||||
if !fopts.WantsJSON() {
|
||||
fmt.Fprintf(iostreams.IO.Out, "✓ Deleted %d document(s) from KB %s\n", deleted, opts.KB)
|
||||
return nil
|
||||
}
|
||||
return fopts.Emit(iostreams.IO.Out, map[string]any{
|
||||
"kb_id": opts.KB,
|
||||
"deleted_count": deleted,
|
||||
}, nil)
|
||||
}
|
||||
@@ -0,0 +1,584 @@
|
||||
package doc
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
sdk "github.com/Tencent/WeKnora/client"
|
||||
|
||||
"github.com/Tencent/WeKnora/cli/internal/cmdutil"
|
||||
"github.com/Tencent/WeKnora/cli/internal/config"
|
||||
"github.com/Tencent/WeKnora/cli/internal/iostreams"
|
||||
"github.com/Tencent/WeKnora/cli/internal/prompt"
|
||||
"github.com/Tencent/WeKnora/cli/internal/testutil"
|
||||
)
|
||||
|
||||
// fakeAllSvc implements AllService for --all mode tests.
|
||||
type fakeAllSvc struct {
|
||||
err error
|
||||
gotID string
|
||||
called bool
|
||||
resp *sdk.ClearKnowledgeBaseContentsResponse
|
||||
}
|
||||
|
||||
func (f *fakeAllSvc) ClearKnowledgeBaseContents(_ context.Context, kbID string) (*sdk.ClearKnowledgeBaseContentsResponse, error) {
|
||||
f.called = true
|
||||
f.gotID = kbID
|
||||
if f.err != nil {
|
||||
return nil, f.err
|
||||
}
|
||||
if f.resp == nil {
|
||||
return &sdk.ClearKnowledgeBaseContentsResponse{DeletedCount: 0}, nil
|
||||
}
|
||||
return f.resp, nil
|
||||
}
|
||||
|
||||
// fakeDeleteSvc captures calls and returns canned errors.
|
||||
// errFor maps id → error for per-id failure injection (used in multi-id tests).
|
||||
type fakeDeleteSvc struct {
|
||||
err error
|
||||
errFor map[string]error
|
||||
got string
|
||||
calls int
|
||||
// deleted tracks all successfully deleted ids (multi-id tests).
|
||||
deleted []string
|
||||
}
|
||||
|
||||
func (f *fakeDeleteSvc) DeleteKnowledge(_ context.Context, id string) error {
|
||||
f.calls++
|
||||
f.got = id
|
||||
if f.errFor != nil {
|
||||
if err, ok := f.errFor[id]; ok {
|
||||
return err
|
||||
}
|
||||
f.deleted = append(f.deleted, id)
|
||||
return nil
|
||||
}
|
||||
if f.err != nil {
|
||||
return f.err
|
||||
}
|
||||
f.deleted = append(f.deleted, id)
|
||||
return nil
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Single-id tests — runDelete uses the simpler {id, deleted} payload.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestDelete_Success_WithForce(t *testing.T) {
|
||||
out, _ := iostreams.SetForTest(t)
|
||||
svc := &fakeDeleteSvc{}
|
||||
opts := &DeleteOptions{Yes: true}
|
||||
// Force=true short-circuits the confirm path; the prompter must not be
|
||||
// consulted, so any value works.
|
||||
require.NoError(t, runDelete(context.Background(), opts, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, svc, &testutil.ConfirmPrompter{Answer: false}, "doc_abc"))
|
||||
|
||||
assert.Equal(t, "doc_abc", svc.got)
|
||||
assert.Equal(t, 1, svc.calls)
|
||||
assert.Contains(t, out.String(), "✓")
|
||||
assert.Contains(t, out.String(), "doc_abc")
|
||||
}
|
||||
|
||||
func TestDelete_Success_JSON(t *testing.T) {
|
||||
out, _ := iostreams.SetForTest(t)
|
||||
svc := &fakeDeleteSvc{}
|
||||
opts := &DeleteOptions{Yes: true}
|
||||
require.NoError(t, runDelete(context.Background(), opts, &cmdutil.FormatOptions{Mode: cmdutil.FormatJSON}, svc, &testutil.ConfirmPrompter{Answer: true}, "doc_abc"))
|
||||
|
||||
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, "doc_abc", env.Data["id"], "envelope.data.id must be doc_abc")
|
||||
assert.Equal(t, true, env.Data["deleted"], "envelope.data.deleted must be true")
|
||||
}
|
||||
|
||||
func TestDelete_NotFound_404(t *testing.T) {
|
||||
_, _ = iostreams.SetForTest(t)
|
||||
svc := &fakeDeleteSvc{err: errors.New("HTTP error 404: not found")}
|
||||
err := runDelete(context.Background(), &DeleteOptions{Yes: true}, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, svc, &testutil.ConfirmPrompter{}, "doc_missing")
|
||||
require.Error(t, err)
|
||||
|
||||
var typed *cmdutil.Error
|
||||
require.ErrorAs(t, err, &typed)
|
||||
assert.Equal(t, cmdutil.CodeResourceNotFound, typed.Code)
|
||||
}
|
||||
|
||||
func TestDelete_HTTPError_500(t *testing.T) {
|
||||
_, _ = iostreams.SetForTest(t)
|
||||
svc := &fakeDeleteSvc{err: errors.New("HTTP error 500: internal")}
|
||||
err := runDelete(context.Background(), &DeleteOptions{Yes: true}, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, svc, &testutil.ConfirmPrompter{}, "doc_x")
|
||||
require.Error(t, err)
|
||||
|
||||
var typed *cmdutil.Error
|
||||
require.ErrorAs(t, err, &typed)
|
||||
// Single-id delete WrapHTTP-classifies the SDK error; HTTP 500 → server.error.
|
||||
// (The multi-id path rolls up failures as operation.failed; this is the
|
||||
// single-id path so it stays server.error.)
|
||||
assert.Equal(t, cmdutil.CodeServerError, typed.Code)
|
||||
}
|
||||
|
||||
func TestDelete_ConfirmYes(t *testing.T) {
|
||||
out, _ := iostreams.SetForTestWithTTY(t)
|
||||
svc := &fakeDeleteSvc{}
|
||||
err := runDelete(context.Background(), &DeleteOptions{Yes: false}, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, svc, &testutil.ConfirmPrompter{Answer: true}, "doc_abc")
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, 1, svc.calls, "user said yes ⇒ delete proceeds")
|
||||
assert.Contains(t, out.String(), "✓")
|
||||
}
|
||||
|
||||
func TestDelete_ConfirmNo(t *testing.T) {
|
||||
_, errBuf := iostreams.SetForTestWithTTY(t)
|
||||
svc := &fakeDeleteSvc{}
|
||||
err := runDelete(context.Background(), &DeleteOptions{Yes: false}, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, svc, &testutil.ConfirmPrompter{Answer: false}, "doc_abc")
|
||||
require.Error(t, err)
|
||||
assert.Equal(t, 0, svc.calls, "user said no ⇒ SDK must NOT be called")
|
||||
|
||||
var typed *cmdutil.Error
|
||||
require.ErrorAs(t, err, &typed)
|
||||
assert.Equal(t, cmdutil.CodeUserAborted, typed.Code)
|
||||
assert.Contains(t, errBuf.String(), "Aborted.")
|
||||
}
|
||||
|
||||
// TestDelete_AgentPrompterErrors covers the path where the prompter itself
|
||||
// returns an error (e.g. AgentPrompter, broken stdin). runDelete maps this to
|
||||
// CodeInputMissingFlag so the user sees "pass --force" in the hint.
|
||||
func TestDelete_AgentPrompterErrors(t *testing.T) {
|
||||
_, _ = iostreams.SetForTestWithTTY(t)
|
||||
svc := &fakeDeleteSvc{}
|
||||
err := runDelete(context.Background(), &DeleteOptions{Yes: false}, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, svc, &testutil.ConfirmPrompter{Err: errors.New("no tty")}, "doc_abc")
|
||||
require.Error(t, err)
|
||||
assert.Equal(t, 0, svc.calls)
|
||||
|
||||
var typed *cmdutil.Error
|
||||
require.ErrorAs(t, err, &typed)
|
||||
assert.Equal(t, cmdutil.CodeInputMissingFlag, typed.Code)
|
||||
}
|
||||
|
||||
// TestDelete_NoYes_NonTTY_RequiresConfirmation: when stdout isn't a TTY
|
||||
// (typical agent pipe / CI), the destructive-write protocol requires
|
||||
// explicit -y/--yes. The CLI exits 10 with input.confirmation_required,
|
||||
// never silently proceeds. See cli/README.md "Exit codes".
|
||||
func TestDelete_NoYes_NonTTY_RequiresConfirmation(t *testing.T) {
|
||||
_, _ = iostreams.SetForTest(t)
|
||||
svc := &fakeDeleteSvc{}
|
||||
err := runDelete(context.Background(), &DeleteOptions{Yes: false}, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, svc, &testutil.ConfirmPrompter{Err: errors.New("no tty")}, "doc_abc")
|
||||
require.Error(t, err)
|
||||
var typed *cmdutil.Error
|
||||
require.ErrorAs(t, err, &typed)
|
||||
assert.Equal(t, cmdutil.CodeInputConfirmationRequired, typed.Code)
|
||||
assert.Equal(t, 0, svc.calls, "non-TTY without -y must not call DeleteKnowledge")
|
||||
assert.Equal(t, 10, cmdutil.ExitCode(err))
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Multi-id tests (cmdutil.RunBatch, keep-going semantics)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestRunMultiDelete_AllSucceed(t *testing.T) {
|
||||
_, _ = iostreams.SetForTest(t)
|
||||
svc := &fakeDeleteSvc{}
|
||||
outcomes, err := cmdutil.RunBatch(
|
||||
context.Background(),
|
||||
[]string{"a", "b", "c"},
|
||||
func(ctx context.Context, id string) error {
|
||||
if err := svc.DeleteKnowledge(ctx, id); err != nil {
|
||||
return cmdutil.WrapHTTP(err, "delete document %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 id %s", oc.ID)
|
||||
}
|
||||
assert.Equal(t, "a", outcomes[0].ID)
|
||||
assert.Equal(t, "b", outcomes[1].ID)
|
||||
assert.Equal(t, "c", outcomes[2].ID)
|
||||
assert.Equal(t, 3, svc.calls)
|
||||
}
|
||||
|
||||
func TestRunMultiDelete_KeepGoingOnError(t *testing.T) {
|
||||
_, _ = iostreams.SetForTest(t)
|
||||
svc := &fakeDeleteSvc{errFor: map[string]error{"doc_b": errors.New("not found")}}
|
||||
outcomes, err := cmdutil.RunBatch(
|
||||
context.Background(),
|
||||
[]string{"doc_a", "doc_b", "doc_c"},
|
||||
func(ctx context.Context, id string) error {
|
||||
if err := svc.DeleteKnowledge(ctx, id); err != nil {
|
||||
return cmdutil.WrapHTTP(err, "delete document %s", id)
|
||||
}
|
||||
return nil
|
||||
},
|
||||
)
|
||||
require.Error(t, err, "partial failure must return non-nil error (exit 1)")
|
||||
assert.Equal(t, 3, svc.calls, "all ids must be attempted (keep-going)")
|
||||
require.Len(t, outcomes, 3)
|
||||
// order preserved: doc_a ok, doc_b fail, doc_c ok
|
||||
assert.Equal(t, "doc_a", outcomes[0].ID)
|
||||
assert.Nil(t, outcomes[0].Err)
|
||||
assert.Equal(t, "doc_b", outcomes[1].ID)
|
||||
assert.NotNil(t, outcomes[1].Err)
|
||||
assert.Equal(t, "doc_c", outcomes[2].ID)
|
||||
assert.Nil(t, outcomes[2].Err)
|
||||
}
|
||||
|
||||
func TestRunMultiDelete_AllFail(t *testing.T) {
|
||||
_, _ = iostreams.SetForTest(t)
|
||||
svc := &fakeDeleteSvc{errFor: map[string]error{
|
||||
"x": errors.New("HTTP error 404: not found"),
|
||||
"y": errors.New("HTTP error 403: forbidden"),
|
||||
}}
|
||||
outcomes, err := cmdutil.RunBatch(
|
||||
context.Background(),
|
||||
[]string{"x", "y"},
|
||||
func(ctx context.Context, id string) error {
|
||||
if err := svc.DeleteKnowledge(ctx, id); err != nil {
|
||||
return cmdutil.WrapHTTP(err, "delete document %s", id)
|
||||
}
|
||||
return nil
|
||||
},
|
||||
)
|
||||
require.Error(t, err)
|
||||
require.Len(t, outcomes, 2)
|
||||
assert.NotNil(t, outcomes[0].Err)
|
||||
assert.NotNil(t, outcomes[1].Err)
|
||||
}
|
||||
|
||||
func TestRunMultiDelete_ConfirmBatch_NonTTY_RequiresConfirmation(t *testing.T) {
|
||||
_, _ = iostreams.SetForTest(t) // non-TTY
|
||||
svc := &fakeDeleteSvc{}
|
||||
fopts := &cmdutil.FormatOptions{Mode: cmdutil.FormatJSON}
|
||||
err := cmdutil.ConfirmDestructiveBatch(&testutil.ConfirmPrompter{Answer: false}, false, fopts.WantsJSON(), "delete", "document", 2, "doc.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, 0, svc.calls, "must not call DeleteKnowledge without confirmation")
|
||||
}
|
||||
|
||||
func TestRunMultiDelete_ConfirmBatch_TTY_UserAborts(t *testing.T) {
|
||||
_, errBuf := iostreams.SetForTestWithTTY(t)
|
||||
svc := &fakeDeleteSvc{}
|
||||
fopts := &cmdutil.FormatOptions{Mode: cmdutil.FormatText}
|
||||
err := cmdutil.ConfirmDestructiveBatch(&testutil.ConfirmPrompter{Answer: false}, false, fopts.WantsJSON(), "delete", "document", 3, "doc.delete", nil)
|
||||
require.Error(t, err)
|
||||
var typed *cmdutil.Error
|
||||
require.ErrorAs(t, err, &typed)
|
||||
assert.Equal(t, cmdutil.CodeUserAborted, typed.Code)
|
||||
assert.Contains(t, errBuf.String(), "Aborted.")
|
||||
assert.Equal(t, 0, svc.calls, "user aborted ⇒ SDK must NOT be called")
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Emit tests — JSON path now emits the batch envelope
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// batchEnvelope is a minimal struct for parsing the batch envelope shape.
|
||||
type batchEnvelope struct {
|
||||
OK bool `json:"ok"`
|
||||
Data []batchItem `json:"data"`
|
||||
Meta batchMeta `json:"meta"`
|
||||
}
|
||||
|
||||
type batchItem struct {
|
||||
ID string `json:"id"`
|
||||
OK bool `json:"ok"`
|
||||
Result json.RawMessage `json:"result,omitempty"`
|
||||
Error *batchItemError `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
type batchItemError struct {
|
||||
Type string `json:"type"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
type batchMeta struct {
|
||||
Count int `json:"count"`
|
||||
Successes int `json:"successes"`
|
||||
Failures int `json:"failures"`
|
||||
}
|
||||
|
||||
func TestEmitMultiDelete_JSON(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
outcomes := []cmdutil.BatchOutcome{
|
||||
{ID: "a", Err: nil},
|
||||
{ID: "b", Err: nil},
|
||||
{ID: "c", Err: errors.New("x")},
|
||||
}
|
||||
err := cmdutil.EmitBatch(outcomes, &cmdutil.FormatOptions{Mode: cmdutil.FormatJSON}, &buf, cmdutil.DeletedAtNow)
|
||||
require.NoError(t, err)
|
||||
|
||||
var got batchEnvelope
|
||||
require.NoError(t, json.Unmarshal(buf.Bytes(), &got))
|
||||
// top-level: partial failure → ok:false
|
||||
assert.False(t, got.OK)
|
||||
assert.Equal(t, 3, got.Meta.Count)
|
||||
assert.Equal(t, 2, got.Meta.Successes)
|
||||
assert.Equal(t, 1, got.Meta.Failures)
|
||||
require.Len(t, got.Data, 3)
|
||||
assert.Equal(t, "a", got.Data[0].ID)
|
||||
assert.True(t, got.Data[0].OK)
|
||||
assert.Equal(t, "b", got.Data[1].ID)
|
||||
assert.True(t, got.Data[1].OK)
|
||||
assert.Equal(t, "c", got.Data[2].ID)
|
||||
assert.False(t, got.Data[2].OK)
|
||||
assert.NotNil(t, got.Data[2].Error)
|
||||
}
|
||||
|
||||
func TestEmitMultiDelete_Text(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
outcomes := []cmdutil.BatchOutcome{
|
||||
{ID: "a", Err: nil},
|
||||
{ID: "b", Err: errors.New("boom")},
|
||||
}
|
||||
err := cmdutil.EmitBatch(outcomes, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, &buf, nil)
|
||||
require.NoError(t, err)
|
||||
|
||||
out := buf.String()
|
||||
assert.Contains(t, out, "OK a")
|
||||
assert.Contains(t, out, "FAIL b:")
|
||||
assert.Contains(t, out, "boom")
|
||||
}
|
||||
|
||||
func TestEmitMultiDelete_TextEmpty(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
outcomes := []cmdutil.BatchOutcome{
|
||||
{ID: "x", Err: nil},
|
||||
{ID: "y", Err: nil},
|
||||
}
|
||||
err := cmdutil.EmitBatch(outcomes, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, &buf, nil)
|
||||
require.NoError(t, err)
|
||||
|
||||
out := buf.String()
|
||||
assert.Contains(t, out, "OK x")
|
||||
assert.Contains(t, out, "OK y")
|
||||
assert.NotContains(t, out, "FAIL")
|
||||
}
|
||||
|
||||
func TestEmitMultiDelete_UnsupportedFormat(t *testing.T) {
|
||||
// EmitBatch defers unsupported format handling to WriteBatchEnvelope; for
|
||||
// non-JSON formats it falls through to text. Verify it does not error.
|
||||
var buf bytes.Buffer
|
||||
outcomes := []cmdutil.BatchOutcome{}
|
||||
err := cmdutil.EmitBatch(outcomes, &cmdutil.FormatOptions{Mode: "yaml"}, &buf, nil)
|
||||
// EmitBatch itself does not error on unknown mode (it uses WantsJSON gate)
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Batch envelope shape test — Task 2.6
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// TestDocDelete_MultiID_PartialFailure_BatchEnvelope verifies that when a
|
||||
// multi-id delete has a partial failure, stdout carries the batch
|
||||
// envelope shape: ok:false, data:[BatchItem...], meta:{count, successes,
|
||||
// failures}. Order follows original argv order.
|
||||
func TestDocDelete_MultiID_PartialFailure_BatchEnvelope(t *testing.T) {
|
||||
// id1 succeeds, id2 fails, id3 succeeds.
|
||||
svc := &fakeDeleteSvc{errFor: map[string]error{
|
||||
"id2": errors.New("HTTP error 404: not found"),
|
||||
}}
|
||||
outcomes, runErr := cmdutil.RunBatch(
|
||||
context.Background(),
|
||||
[]string{"id1", "id2", "id3"},
|
||||
func(ctx context.Context, id string) error {
|
||||
if err := svc.DeleteKnowledge(ctx, id); err != nil {
|
||||
return cmdutil.WrapHTTP(err, "delete document %s", id)
|
||||
}
|
||||
return nil
|
||||
},
|
||||
)
|
||||
require.Error(t, runErr, "partial failure must return non-nil error")
|
||||
require.Len(t, outcomes, 3)
|
||||
|
||||
var buf bytes.Buffer
|
||||
require.NoError(t, cmdutil.EmitBatch(outcomes, &cmdutil.FormatOptions{Mode: cmdutil.FormatJSON, TTY: false}, &buf, cmdutil.DeletedAtNow))
|
||||
|
||||
var env batchEnvelope
|
||||
require.NoError(t, json.Unmarshal(buf.Bytes(), &env))
|
||||
|
||||
// top-level ok:false (partial failure)
|
||||
assert.False(t, env.OK)
|
||||
|
||||
// meta counts
|
||||
assert.Equal(t, 3, env.Meta.Count)
|
||||
assert.Equal(t, 2, env.Meta.Successes)
|
||||
assert.Equal(t, 1, env.Meta.Failures)
|
||||
|
||||
// items in argv order
|
||||
require.Len(t, env.Data, 3)
|
||||
|
||||
assert.Equal(t, "id1", env.Data[0].ID)
|
||||
assert.True(t, env.Data[0].OK)
|
||||
assert.Nil(t, env.Data[0].Error)
|
||||
|
||||
assert.Equal(t, "id2", env.Data[1].ID)
|
||||
assert.False(t, env.Data[1].OK)
|
||||
require.NotNil(t, env.Data[1].Error)
|
||||
assert.Equal(t, "resource.not_found", env.Data[1].Error.Type)
|
||||
|
||||
assert.Equal(t, "id3", env.Data[2].ID)
|
||||
assert.True(t, env.Data[2].OK)
|
||||
assert.Nil(t, env.Data[2].Error)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// --all mode tests (runDeleteAll)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// TestDocDelete_All_MissingKB_ReturnsFlagError: --all without --kb must exit 2.
|
||||
func TestDocDelete_All_MissingKB_ReturnsFlagError(t *testing.T) {
|
||||
_, _ = iostreams.SetForTest(t)
|
||||
svc := &fakeAllSvc{}
|
||||
// Simulate the RunE guard: --all without --kb returns FlagError before
|
||||
// runDeleteAll is ever called.
|
||||
err := cmdutil.NewFlagError(fmt.Errorf("--all requires --kb=<id>"))
|
||||
require.Error(t, err)
|
||||
var flagErr *cmdutil.FlagError
|
||||
require.ErrorAs(t, err, &flagErr)
|
||||
assert.Equal(t, 2, cmdutil.ExitCode(err))
|
||||
assert.False(t, svc.called)
|
||||
}
|
||||
|
||||
// TestDocDelete_All_WithoutYes_JSONMode_ReturnsExit10 verifies the
|
||||
// CodeInputConfirmationRequired (exit 10) path with risk metadata in JSON/non-TTY.
|
||||
func TestDocDelete_All_WithoutYes_JSONMode_ReturnsExit10(t *testing.T) {
|
||||
_, _ = iostreams.SetForTest(t) // non-TTY
|
||||
svc := &fakeAllSvc{}
|
||||
opts := &DeleteOptions{All: true, KB: "kb_x", Yes: false}
|
||||
err := runDeleteAll(context.Background(), opts, &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.Equal(t, 10, cmdutil.ExitCode(err))
|
||||
require.NotNil(t, typed.Risk)
|
||||
assert.Equal(t, "destructive", typed.Risk.Level)
|
||||
assert.Equal(t, "doc.delete_all", typed.Risk.Action)
|
||||
assert.False(t, svc.called)
|
||||
}
|
||||
|
||||
// TestDocDelete_All_WithYes_CallsClearKB verifies that with -y the call is made
|
||||
// and the JSON envelope contains kb_id + deleted_count.
|
||||
func TestDocDelete_All_WithYes_CallsClearKB(t *testing.T) {
|
||||
out, _ := iostreams.SetForTest(t)
|
||||
svc := &fakeAllSvc{resp: &sdk.ClearKnowledgeBaseContentsResponse{DeletedCount: 17}}
|
||||
opts := &DeleteOptions{All: true, KB: "kb_x", Yes: true}
|
||||
err := runDeleteAll(context.Background(), opts, &cmdutil.FormatOptions{Mode: cmdutil.FormatJSON}, svc, &testutil.ConfirmPrompter{})
|
||||
require.NoError(t, err)
|
||||
assert.True(t, svc.called)
|
||||
assert.Equal(t, "kb_x", svc.gotID)
|
||||
|
||||
var env struct {
|
||||
OK bool `json:"ok"`
|
||||
Data map[string]any `json:"data"`
|
||||
}
|
||||
require.NoError(t, json.Unmarshal(out.Bytes(), &env), "expected valid JSON envelope, got %q", out.String())
|
||||
assert.True(t, env.OK)
|
||||
assert.Equal(t, "kb_x", env.Data["kb_id"])
|
||||
assert.Equal(t, float64(17), env.Data["deleted_count"])
|
||||
}
|
||||
|
||||
// TestDocDelete_All_WithYes_TextMode verifies the text output path.
|
||||
func TestDocDelete_All_WithYes_TextMode(t *testing.T) {
|
||||
out, _ := iostreams.SetForTest(t)
|
||||
svc := &fakeAllSvc{resp: &sdk.ClearKnowledgeBaseContentsResponse{DeletedCount: 5}}
|
||||
opts := &DeleteOptions{All: true, KB: "kb_y", Yes: true}
|
||||
err := runDeleteAll(context.Background(), opts, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, svc, &testutil.ConfirmPrompter{})
|
||||
require.NoError(t, err)
|
||||
assert.True(t, svc.called)
|
||||
body := out.String()
|
||||
assert.Contains(t, body, "5")
|
||||
assert.Contains(t, body, "kb_y")
|
||||
}
|
||||
|
||||
// TestDocDelete_All_TTY_UserAborts: interactive TTY + user says no → CodeUserAborted.
|
||||
func TestDocDelete_All_TTY_UserAborts(t *testing.T) {
|
||||
_, errBuf := iostreams.SetForTestWithTTY(t)
|
||||
svc := &fakeAllSvc{}
|
||||
opts := &DeleteOptions{All: true, KB: "kb_z", Yes: false}
|
||||
err := runDeleteAll(context.Background(), opts, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, svc, &testutil.ConfirmPrompter{Answer: false})
|
||||
require.Error(t, err)
|
||||
var typed *cmdutil.Error
|
||||
require.ErrorAs(t, err, &typed)
|
||||
assert.Equal(t, cmdutil.CodeUserAborted, typed.Code)
|
||||
assert.False(t, svc.called)
|
||||
assert.Contains(t, errBuf.String(), "Aborted")
|
||||
}
|
||||
|
||||
// TestDocDelete_All_ServiceError propagates SDK errors via WrapHTTP.
|
||||
func TestDocDelete_All_ServiceError(t *testing.T) {
|
||||
_, _ = iostreams.SetForTest(t)
|
||||
svc := &fakeAllSvc{err: errors.New("HTTP error 404: not found")}
|
||||
opts := &DeleteOptions{All: true, KB: "kb_missing", Yes: true}
|
||||
err := runDeleteAll(context.Background(), opts, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, svc, &testutil.ConfirmPrompter{})
|
||||
require.Error(t, err)
|
||||
var typed *cmdutil.Error
|
||||
require.ErrorAs(t, err, &typed)
|
||||
assert.Equal(t, cmdutil.CodeResourceNotFound, typed.Code)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// KB name resolution tests (--all --kb resolves name → id)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// TestDocDelete_All_ResolvesKBNameToID verifies that `doc delete --all --kb eng -y`
|
||||
// resolves the KB name "eng" to its canonical id "kb_eng" before calling
|
||||
// ClearKnowledgeBaseContents. Uses an httptest.Server to fake both the
|
||||
// ListKnowledgeBases and ClearKnowledgeBaseContents endpoints so the real
|
||||
// *sdk.Client can be injected through the factory (mirrors link_test.go pattern).
|
||||
func TestDocDelete_All_ResolvesKBNameToID(t *testing.T) {
|
||||
_, _ = iostreams.SetForTest(t)
|
||||
|
||||
var clearCalledWith string
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
switch {
|
||||
case r.Method == http.MethodGet && r.URL.Path == "/api/v1/knowledge-bases":
|
||||
_ = json.NewEncoder(w).Encode(sdk.KnowledgeBaseListResponse{
|
||||
Success: true,
|
||||
Data: []sdk.KnowledgeBase{{ID: "kb_eng", Name: "eng"}},
|
||||
})
|
||||
case r.Method == http.MethodDelete && r.URL.Path == "/api/v1/knowledge-bases/kb_eng/knowledge":
|
||||
clearCalledWith = "kb_eng"
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||
"success": true,
|
||||
"data": map[string]any{"deleted_count": 3},
|
||||
})
|
||||
default:
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
}))
|
||||
t.Cleanup(srv.Close)
|
||||
|
||||
cli := sdk.NewClient(srv.URL)
|
||||
f := &cmdutil.Factory{
|
||||
Config: func() (*config.Config, error) {
|
||||
return &config.Config{
|
||||
CurrentProfile: "default",
|
||||
Profiles: map[string]config.Profile{"default": {Host: srv.URL}},
|
||||
}, nil
|
||||
},
|
||||
Client: func() (*sdk.Client, error) { return cli, nil },
|
||||
Prompter: func() prompt.Prompter { return &testutil.ConfirmPrompter{Answer: true} },
|
||||
}
|
||||
root := withRootHarnessDoc(NewCmdDelete(f), "--all", "--kb", "eng", "-y")
|
||||
err := root.Execute()
|
||||
require.NoError(t, err, "doc delete --all --kb eng -y should succeed")
|
||||
assert.Equal(t, "kb_eng", clearCalledWith,
|
||||
"ClearKnowledgeBaseContents must be called with resolved id 'kb_eng', not raw name 'eng'")
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
// Package doc implements the `weknora doc` subtree (list / view / upload /
|
||||
// fetch / create / download / delete / wait). Upload supports --recursive /
|
||||
// --glob for bulk ingestion from local files. Fetch ingests a remote URL.
|
||||
// Create adds a knowledge entry from inline text content.
|
||||
//
|
||||
// "Doc" is the CLI noun; the underlying SDK type is `Knowledge`. The renaming
|
||||
// is deliberate: end-users think of a knowledge entry as the document they
|
||||
// uploaded, not as an abstract knowledge unit. Mapping happens in this package
|
||||
// only - the SDK surface and server API keep the original spelling.
|
||||
package doc
|
||||
|
||||
import (
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/Tencent/WeKnora/cli/internal/cmdutil"
|
||||
)
|
||||
|
||||
// NewCmd builds the `weknora doc` parent command.
|
||||
func NewCmd(f *cmdutil.Factory) *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "doc",
|
||||
Short: "Manage documents in a knowledge base",
|
||||
}
|
||||
cmd.AddCommand(NewCmdCreate(f))
|
||||
cmd.AddCommand(NewCmdDelete(f))
|
||||
cmd.AddCommand(NewCmdDownload(f))
|
||||
cmd.AddCommand(NewCmdFetch(f))
|
||||
cmd.AddCommand(NewCmdList(f))
|
||||
cmd.AddCommand(NewCmdReparse(f))
|
||||
cmd.AddCommand(NewCmdUpdate(f))
|
||||
cmd.AddCommand(NewCmdUpload(f))
|
||||
cmd.AddCommand(NewCmdView(f))
|
||||
cmd.AddCommand(NewCmdWait(f))
|
||||
return cmd
|
||||
}
|
||||
@@ -0,0 +1,200 @@
|
||||
package doc
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/Tencent/WeKnora/cli/internal/cmdutil"
|
||||
"github.com/Tencent/WeKnora/cli/internal/iostreams"
|
||||
)
|
||||
|
||||
// downloadFields enumerates the fields surfaced for `--format json` discovery on
|
||||
// `doc download`. Matches the downloadResult struct.
|
||||
var downloadFields = []string{"path", "bytes", "filename"}
|
||||
|
||||
// downloadResult is the typed payload emitted as the success envelope when
|
||||
// --format json is requested and output is going to a file (not stdout).
|
||||
type downloadResult struct {
|
||||
Path string `json:"path"`
|
||||
Bytes int64 `json:"bytes"`
|
||||
Filename string `json:"filename"`
|
||||
}
|
||||
|
||||
type DownloadOptions struct {
|
||||
Output string // --output / -O: target path, "-" for stdout, "" for server-suggested filename
|
||||
Clobber bool // --clobber: allow overwrite of an existing file
|
||||
}
|
||||
|
||||
// DownloadService is the narrow SDK surface this command depends on. The
|
||||
// CLI calls OpenKnowledgeFile so it can inspect the server-suggested
|
||||
// filename and refuse-to-overwrite *before* streaming any bytes.
|
||||
type DownloadService interface {
|
||||
OpenKnowledgeFile(ctx context.Context, knowledgeID string) (string, io.ReadCloser, error)
|
||||
}
|
||||
|
||||
// NewCmdDownload builds `weknora doc download <id>`. Positional id, output
|
||||
// flag, `-` sentinel for stdout. Flags: `-O, --output <file>` for
|
||||
// destination, `--clobber` for overwrite control.
|
||||
func NewCmdDownload(f *cmdutil.Factory) *cobra.Command {
|
||||
opts := &DownloadOptions{}
|
||||
cmd := &cobra.Command{
|
||||
Use: "download <doc-id>",
|
||||
Short: "Download a document by ID",
|
||||
Long: `Streams the document bytes to disk (or stdout with --output -).
|
||||
|
||||
Default behavior (no --output): writes to the cwd under the filename the
|
||||
server suggests via Content-Disposition. If the server doesn't suggest
|
||||
one, the command errors and asks for --output FILE explicitly.
|
||||
|
||||
Existing files are NOT overwritten unless --clobber is passed.
|
||||
|
||||
With --format json, on success emits a JSON envelope whose data has
|
||||
path, bytes, and filename fields. When output is stdout (--output -),
|
||||
the JSON envelope is suppressed because the raw bytes already occupy
|
||||
stdout.`,
|
||||
Example: ` weknora doc download doc_abc # writes ./<server-name>
|
||||
weknora doc download doc_abc -O report.pdf
|
||||
weknora doc download doc_abc --output - # stream to stdout (binary safe)
|
||||
weknora doc download doc_abc -O report.pdf --clobber
|
||||
weknora doc download doc_abc -O report.pdf --format json # JSON envelope`,
|
||||
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 runDownload(c.Context(), opts, fopts, cli, args[0])
|
||||
},
|
||||
}
|
||||
cmd.Flags().StringVarP(&opts.Output, "output", "O", "", `Output path; "-" for stdout. Defaults to the server-suggested filename.`)
|
||||
cmd.Flags().BoolVar(&opts.Clobber, "clobber", false, "Overwrite the output file if it already exists")
|
||||
cmdutil.AddIgnoredKBFlag(cmd)
|
||||
cmdutil.AddFormatFlag(cmd, downloadFields...)
|
||||
cmdutil.SetAgentHelp(cmd, cmdutil.AgentHelp{
|
||||
UsedFor: "Download a document's bytes by id. Writes a file (or stdout with --output -).",
|
||||
RequiredFlags: []string{"<doc-id> (positional)"},
|
||||
Output: "with --format json (file output): envelope.data has path, bytes, filename; suppressed with --output - (raw bytes to stdout)",
|
||||
Examples: []string{
|
||||
"weknora doc download doc_abc --output ./manual.pdf",
|
||||
"weknora doc download doc_abc --output - > manual.pdf",
|
||||
},
|
||||
})
|
||||
return cmd
|
||||
}
|
||||
|
||||
func runDownload(ctx context.Context, opts *DownloadOptions, fopts *cmdutil.FormatOptions, svc DownloadService, id string) error {
|
||||
suggested, body, err := svc.OpenKnowledgeFile(ctx, id)
|
||||
if err != nil {
|
||||
return cmdutil.WrapHTTP(err, "download %s", id)
|
||||
}
|
||||
defer body.Close()
|
||||
|
||||
dest, err := resolveDownloadDest(opts, suggested)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if dest == "-" {
|
||||
// Raw bytes go to stdout — suppress any JSON envelope regardless of
|
||||
// --format json because both can't occupy stdout simultaneously.
|
||||
_, err := io.Copy(iostreams.IO.Out, body)
|
||||
return err
|
||||
}
|
||||
if err := refuseIfExists(dest, opts.Clobber); err != nil {
|
||||
return err
|
||||
}
|
||||
return streamToFile(body, dest, fopts)
|
||||
}
|
||||
|
||||
// resolveDownloadDest returns the final destination ("-" for stdout, an
|
||||
// absolute or relative path otherwise) after applying the --output flag
|
||||
// and sanitizing the server-suggested name. A server that returns a path-
|
||||
// like filename (..\, /etc/foo) is rejected - only the basename is
|
||||
// accepted.
|
||||
func resolveDownloadDest(opts *DownloadOptions, suggested string) (string, error) {
|
||||
if opts.Output == "-" {
|
||||
return "-", nil
|
||||
}
|
||||
if opts.Output != "" {
|
||||
return opts.Output, nil
|
||||
}
|
||||
if suggested == "" {
|
||||
return "", &cmdutil.Error{
|
||||
Code: cmdutil.CodeInputMissingFlag,
|
||||
Message: "server did not supply a filename and --output is unset",
|
||||
Hint: "pass --output FILE (or --output - for stdout)",
|
||||
}
|
||||
}
|
||||
base := filepath.Base(suggested)
|
||||
if base == "" || base == "." || base == ".." || base == string(filepath.Separator) {
|
||||
return "", &cmdutil.Error{
|
||||
Code: cmdutil.CodeInputInvalidArgument,
|
||||
Message: fmt.Sprintf("server returned an unusable filename %q", suggested),
|
||||
Hint: "pass --output FILE explicitly",
|
||||
}
|
||||
}
|
||||
return base, nil
|
||||
}
|
||||
|
||||
// refuseIfExists returns CodeInputInvalidArgument when path is present on
|
||||
// disk and clobber is false. Missing-file is success.
|
||||
func refuseIfExists(path string, clobber bool) error {
|
||||
if clobber {
|
||||
return nil
|
||||
}
|
||||
_, err := os.Stat(path)
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return nil
|
||||
}
|
||||
if err != nil {
|
||||
return cmdutil.Wrapf(cmdutil.CodeLocalFileIO, err, "stat %s", path)
|
||||
}
|
||||
return &cmdutil.Error{
|
||||
Code: cmdutil.CodeInputInvalidArgument,
|
||||
Message: fmt.Sprintf("%s already exists", path),
|
||||
Hint: "pass --clobber to overwrite",
|
||||
}
|
||||
}
|
||||
|
||||
// streamToFile copies body into a newly-created file at path. On any
|
||||
// streaming error the partial file is removed so callers don't see a
|
||||
// truncated artifact at the user-visible path.
|
||||
//
|
||||
// On success: if fopts.WantsJSON(), emits a downloadResult envelope to
|
||||
// stdout instead of the "✓ Saved" text.
|
||||
func streamToFile(body io.Reader, path string, fopts *cmdutil.FormatOptions) error {
|
||||
f, err := os.Create(path)
|
||||
if err != nil {
|
||||
return cmdutil.Wrapf(cmdutil.CodeLocalFileIO, err, "create %s", path)
|
||||
}
|
||||
n, copyErr := io.Copy(f, body)
|
||||
if copyErr != nil {
|
||||
_ = f.Close()
|
||||
_ = os.Remove(path)
|
||||
return cmdutil.Wrapf(cmdutil.CodeLocalFileIO, copyErr, "write %s", path)
|
||||
}
|
||||
if err := f.Close(); err != nil {
|
||||
_ = os.Remove(path)
|
||||
return cmdutil.Wrapf(cmdutil.CodeLocalFileIO, err, "close %s", path)
|
||||
}
|
||||
|
||||
if fopts.WantsJSON() {
|
||||
return fopts.Emit(iostreams.IO.Out, downloadResult{
|
||||
Path: path,
|
||||
Bytes: n,
|
||||
Filename: filepath.Base(path),
|
||||
}, nil)
|
||||
}
|
||||
fmt.Fprintf(iostreams.IO.Err, "✓ Saved %s\n", path)
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,201 @@
|
||||
package doc
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"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"
|
||||
)
|
||||
|
||||
// fakeDownloadSvc scripts an OpenKnowledgeFile response. The fake returns
|
||||
// a ReadCloser over `content` and reports `filename` as the server-
|
||||
// suggested name.
|
||||
type fakeDownloadSvc struct {
|
||||
content string
|
||||
filename string
|
||||
err error
|
||||
gotID string
|
||||
}
|
||||
|
||||
func (f *fakeDownloadSvc) OpenKnowledgeFile(_ context.Context, id string) (string, io.ReadCloser, error) {
|
||||
f.gotID = id
|
||||
if f.err != nil {
|
||||
return "", nil, f.err
|
||||
}
|
||||
return f.filename, io.NopCloser(strings.NewReader(f.content)), nil
|
||||
}
|
||||
|
||||
// textFopts returns a text-mode FormatOptions for tests that don't care
|
||||
// about JSON output.
|
||||
func textFopts() *cmdutil.FormatOptions { return &cmdutil.FormatOptions{Mode: cmdutil.FormatText} }
|
||||
|
||||
func TestDownload_DefaultUsesServerFilename(t *testing.T) {
|
||||
_, _ = iostreams.SetForTest(t)
|
||||
tmp := t.TempDir()
|
||||
prevWD, _ := os.Getwd()
|
||||
require.NoError(t, os.Chdir(tmp))
|
||||
defer os.Chdir(prevWD)
|
||||
|
||||
svc := &fakeDownloadSvc{content: "PDF-1.4 bytes", filename: "report.pdf"}
|
||||
require.NoError(t, runDownload(context.Background(), &DownloadOptions{}, textFopts(), svc, "doc_abc"))
|
||||
|
||||
got, err := os.ReadFile(filepath.Join(tmp, "report.pdf"))
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "PDF-1.4 bytes", string(got))
|
||||
}
|
||||
|
||||
func TestDownload_OutFile(t *testing.T) {
|
||||
_, _ = iostreams.SetForTest(t)
|
||||
dest := filepath.Join(t.TempDir(), "out.bin")
|
||||
svc := &fakeDownloadSvc{content: "hello", filename: "ignored.txt"}
|
||||
require.NoError(t, runDownload(context.Background(), &DownloadOptions{Output: dest}, textFopts(), svc, "doc_abc"))
|
||||
|
||||
got, err := os.ReadFile(dest)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "hello", string(got))
|
||||
}
|
||||
|
||||
func TestDownload_OutDash_Stdout(t *testing.T) {
|
||||
out, _ := iostreams.SetForTest(t)
|
||||
svc := &fakeDownloadSvc{content: "binary payload", filename: "report.pdf"}
|
||||
require.NoError(t, runDownload(context.Background(), &DownloadOptions{Output: "-"}, textFopts(), svc, "doc_abc"))
|
||||
assert.Equal(t, "binary payload", out.String())
|
||||
}
|
||||
|
||||
func TestDownload_NoFilenameFromServer_DefaultPath_Errors(t *testing.T) {
|
||||
_, _ = iostreams.SetForTest(t)
|
||||
svc := &fakeDownloadSvc{content: "x", filename: ""}
|
||||
err := runDownload(context.Background(), &DownloadOptions{}, textFopts(), svc, "doc_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, "--output")
|
||||
}
|
||||
|
||||
func TestDownload_NotFound(t *testing.T) {
|
||||
_, _ = iostreams.SetForTest(t)
|
||||
svc := &fakeDownloadSvc{err: errors.New("HTTP error 404: not found")}
|
||||
err := runDownload(context.Background(), &DownloadOptions{Output: filepath.Join(t.TempDir(), "x")}, textFopts(), svc, "doc_missing")
|
||||
require.Error(t, err)
|
||||
var typed *cmdutil.Error
|
||||
require.ErrorAs(t, err, &typed)
|
||||
assert.Equal(t, cmdutil.CodeResourceNotFound, typed.Code)
|
||||
}
|
||||
|
||||
func TestDownload_RefusesOverwrite(t *testing.T) {
|
||||
_, _ = iostreams.SetForTest(t)
|
||||
dest := filepath.Join(t.TempDir(), "exists.bin")
|
||||
require.NoError(t, os.WriteFile(dest, []byte("OLD"), 0o644))
|
||||
|
||||
svc := &fakeDownloadSvc{content: "NEW", filename: ""}
|
||||
err := runDownload(context.Background(), &DownloadOptions{Output: dest}, textFopts(), svc, "doc_abc")
|
||||
require.Error(t, err)
|
||||
var typed *cmdutil.Error
|
||||
require.ErrorAs(t, err, &typed)
|
||||
assert.Equal(t, cmdutil.CodeInputInvalidArgument, typed.Code)
|
||||
got, _ := os.ReadFile(dest)
|
||||
assert.Equal(t, "OLD", string(got), "must not overwrite without --clobber")
|
||||
}
|
||||
|
||||
// TestDownload_RejectsServerPathTraversal proves that a malicious or buggy
|
||||
// server cannot escape the cwd via Content-Disposition: only the basename
|
||||
// of the suggested filename is accepted.
|
||||
func TestDownload_RejectsServerPathTraversal(t *testing.T) {
|
||||
_, _ = iostreams.SetForTest(t)
|
||||
tmp := t.TempDir()
|
||||
prevWD, _ := os.Getwd()
|
||||
require.NoError(t, os.Chdir(tmp))
|
||||
defer os.Chdir(prevWD)
|
||||
|
||||
// The server sends "../../etc/shadow" - we accept only "shadow" and
|
||||
// write to cwd.
|
||||
svc := &fakeDownloadSvc{content: "exfil", filename: "../../etc/shadow"}
|
||||
require.NoError(t, runDownload(context.Background(), &DownloadOptions{}, textFopts(), svc, "doc_abc"))
|
||||
|
||||
// File must land inside cwd; parent dirs untouched.
|
||||
got, err := os.ReadFile(filepath.Join(tmp, "shadow"))
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "exfil", string(got))
|
||||
}
|
||||
|
||||
// TestDownload_RejectsBareDotDot covers the literal-".." case: a server
|
||||
// returning Content-Disposition: attachment; filename=".." would, before
|
||||
// the rejection list was extended, pass `filepath.Base("..") == ".."`
|
||||
// through to os.Create and produce a confusing local.file_io wrap.
|
||||
func TestDownload_RejectsBareDotDot(t *testing.T) {
|
||||
_, _ = iostreams.SetForTest(t)
|
||||
for _, name := range []string{"..", "../"} {
|
||||
_, err := resolveDownloadDest(&DownloadOptions{}, name)
|
||||
require.Error(t, err, "filename=%q must be rejected", name)
|
||||
var typed *cmdutil.Error
|
||||
require.ErrorAs(t, err, &typed)
|
||||
assert.Equal(t, cmdutil.CodeInputInvalidArgument, typed.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDownload_ForceOverwrites(t *testing.T) {
|
||||
_, _ = iostreams.SetForTest(t)
|
||||
dest := filepath.Join(t.TempDir(), "exists.bin")
|
||||
require.NoError(t, os.WriteFile(dest, []byte("OLD"), 0o644))
|
||||
|
||||
svc := &fakeDownloadSvc{content: "NEW", filename: ""}
|
||||
require.NoError(t, runDownload(context.Background(), &DownloadOptions{Output: dest, Clobber: true}, textFopts(), svc, "doc_abc"))
|
||||
got, _ := os.ReadFile(dest)
|
||||
assert.Equal(t, "NEW", string(got))
|
||||
}
|
||||
|
||||
// TestDownload_JSONEnvelope verifies that --format json emits a success
|
||||
// envelope with path/bytes/filename when downloading to a temp file.
|
||||
func TestDownload_JSONEnvelope(t *testing.T) {
|
||||
out, _ := iostreams.SetForTest(t)
|
||||
dest := filepath.Join(t.TempDir(), "report.pdf")
|
||||
svc := &fakeDownloadSvc{content: "PDF bytes here", filename: "report.pdf"}
|
||||
fopts := &cmdutil.FormatOptions{Mode: cmdutil.FormatJSON}
|
||||
|
||||
require.NoError(t, runDownload(context.Background(), &DownloadOptions{Output: dest}, fopts, svc, "doc_abc"))
|
||||
|
||||
// File content must still be written correctly.
|
||||
got, err := os.ReadFile(dest)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "PDF bytes here", string(got))
|
||||
|
||||
// stdout must contain the JSON envelope.
|
||||
var env struct {
|
||||
OK bool `json:"ok"`
|
||||
Data struct {
|
||||
Path string `json:"path"`
|
||||
Bytes int64 `json:"bytes"`
|
||||
Filename string `json:"filename"`
|
||||
} `json:"data"`
|
||||
}
|
||||
require.NoError(t, json.Unmarshal([]byte(out.String()), &env), "expected valid JSON envelope, got %q", out.String())
|
||||
assert.True(t, env.OK)
|
||||
assert.Equal(t, dest, env.Data.Path)
|
||||
assert.Equal(t, int64(len("PDF bytes here")), env.Data.Bytes)
|
||||
assert.Equal(t, "report.pdf", env.Data.Filename)
|
||||
}
|
||||
|
||||
// TestDownload_JSONEnvelope_SuppressedOnStdout verifies that when output is
|
||||
// stdout (--output -), the JSON envelope is NOT emitted even with --format json
|
||||
// because raw bytes already occupy stdout.
|
||||
func TestDownload_JSONEnvelope_SuppressedOnStdout(t *testing.T) {
|
||||
out, _ := iostreams.SetForTest(t)
|
||||
svc := &fakeDownloadSvc{content: "binary payload", filename: "report.pdf"}
|
||||
fopts := &cmdutil.FormatOptions{Mode: cmdutil.FormatJSON}
|
||||
|
||||
require.NoError(t, runDownload(context.Background(), &DownloadOptions{Output: "-"}, fopts, svc, "doc_abc"))
|
||||
|
||||
// stdout must contain only the raw bytes, not a JSON envelope.
|
||||
assert.Equal(t, "binary payload", out.String())
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
// Package doc — dryrun_validation_test.go asserts that --dry-run on
|
||||
// doc subcommands rejects identically to the live path (validation must run
|
||||
// before previewing). Before the surrounding fix, validation lived in runX()
|
||||
// and was reached only after HandleDryRun
|
||||
// short-circuited, so --dry-run silently emitted plan envelopes for inputs
|
||||
// the live path would reject — agents got false-positive previews.
|
||||
package doc
|
||||
|
||||
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"
|
||||
)
|
||||
|
||||
// docDryRunFactory builds a Factory whose Client closure panics if invoked —
|
||||
// dry-run must early-exit before any SDK call. Factory.ResolveKB is a method
|
||||
// that short-circuits when --kb is a uuid (IsKBID), so the tests below pass
|
||||
// a literal uuid to avoid the Client()-based name lookup path.
|
||||
func docDryRunFactory(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
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// withRootHarnessDoc wraps a doc subcommand under a synthetic root cmd that
|
||||
// registers the global persistent flags (mirrors addGlobalFlags in
|
||||
// cmd/root.go).
|
||||
func withRootHarnessDoc(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
|
||||
}
|
||||
|
||||
// TestDocDelete_DryRun_RejectsAllWithoutKB: --all without --kb is rejected
|
||||
// on the live path; --dry-run must do the same.
|
||||
func TestDocDelete_DryRun_RejectsAllWithoutKB(t *testing.T) {
|
||||
iostreams.SetForTest(t)
|
||||
root := withRootHarnessDoc(NewCmdDelete(docDryRunFactory(t)),
|
||||
"--all", "--dry-run", "--format", "json")
|
||||
err := root.Execute()
|
||||
require.Error(t, err)
|
||||
var typed *cmdutil.Error
|
||||
require.True(t, errors.As(err, &typed), "expected *cmdutil.Error, got %T %v", err, err)
|
||||
assert.Equal(t, cmdutil.CodeInputInvalidArgument, typed.Code)
|
||||
}
|
||||
|
||||
// TestDocDelete_DryRun_RejectsAllWithIDs: --all + positional ids is mutually
|
||||
// exclusive; --dry-run must reject the same.
|
||||
func TestDocDelete_DryRun_RejectsAllWithIDs(t *testing.T) {
|
||||
iostreams.SetForTest(t)
|
||||
root := withRootHarnessDoc(NewCmdDelete(docDryRunFactory(t)),
|
||||
"doc_a", "--all", "--kb", "00000000-0000-0000-0000-000000000001", "--dry-run", "--format", "json")
|
||||
err := root.Execute()
|
||||
require.Error(t, err)
|
||||
// FlagError ⇒ exit 2.
|
||||
assert.Equal(t, 2, cmdutil.ExitCode(err))
|
||||
}
|
||||
|
||||
// TestDocDelete_DryRun_RejectsNoIDsNoAll: no positional ids and no --all is
|
||||
// rejected on the live path; --dry-run must do the same.
|
||||
func TestDocDelete_DryRun_RejectsNoIDsNoAll(t *testing.T) {
|
||||
iostreams.SetForTest(t)
|
||||
root := withRootHarnessDoc(NewCmdDelete(docDryRunFactory(t)),
|
||||
"--dry-run", "--format", "json")
|
||||
err := root.Execute()
|
||||
require.Error(t, err)
|
||||
assert.Equal(t, 2, cmdutil.ExitCode(err))
|
||||
}
|
||||
|
||||
// TestDocFetch_DryRun_RejectsInvalidURL: malformed URL is rejected on the
|
||||
// live path via cmdutil.ValidateHTTPURL; --dry-run must do the same.
|
||||
func TestDocFetch_DryRun_RejectsInvalidURL(t *testing.T) {
|
||||
iostreams.SetForTest(t)
|
||||
root := withRootHarnessDoc(NewCmdFetch(docDryRunFactory(t)),
|
||||
"not-a-url", "--kb", "00000000-0000-0000-0000-000000000001", "--dry-run", "--format", "json")
|
||||
err := root.Execute()
|
||||
require.Error(t, err, "dry-run must reject malformed URL")
|
||||
}
|
||||
|
||||
// TestDocUpload_DryRun_RejectsMissingPositional: positional file path is
|
||||
// required (or --recursive); --dry-run must reject identically.
|
||||
func TestDocUpload_DryRun_RejectsMissingPositional(t *testing.T) {
|
||||
iostreams.SetForTest(t)
|
||||
root := withRootHarnessDoc(NewCmdUpload(docDryRunFactory(t)),
|
||||
"--kb", "00000000-0000-0000-0000-000000000001", "--dry-run", "--format", "json")
|
||||
err := root.Execute()
|
||||
require.Error(t, err, "dry-run must reject missing positional file path")
|
||||
}
|
||||
|
||||
// TestDocUpload_DryRun_RejectsBadMetadata: --metadata key=value enforces
|
||||
// the `key=value` shape; --dry-run must reject malformed values too.
|
||||
func TestDocUpload_DryRun_RejectsBadMetadata(t *testing.T) {
|
||||
iostreams.SetForTest(t)
|
||||
root := withRootHarnessDoc(NewCmdUpload(docDryRunFactory(t)),
|
||||
"./somefile", "--kb", "00000000-0000-0000-0000-000000000001", "--metadata", "no-equals-sign",
|
||||
"--dry-run", "--format", "json")
|
||||
err := root.Execute()
|
||||
require.Error(t, err, "dry-run must reject malformed --metadata")
|
||||
var typed *cmdutil.Error
|
||||
require.True(t, errors.As(err, &typed))
|
||||
assert.Equal(t, cmdutil.CodeInputInvalidArgument, typed.Code)
|
||||
}
|
||||
|
||||
// TestDocUpload_DryRun_RejectsBadEnableMultimodel: --enable-multimodel
|
||||
// expects a parseable tri-bool; --dry-run must reject the same garbage
|
||||
// values the live path rejects.
|
||||
func TestDocUpload_DryRun_RejectsBadEnableMultimodel(t *testing.T) {
|
||||
iostreams.SetForTest(t)
|
||||
root := withRootHarnessDoc(NewCmdUpload(docDryRunFactory(t)),
|
||||
"./somefile", "--kb", "00000000-0000-0000-0000-000000000001", "--enable-multimodel=garbage",
|
||||
"--dry-run", "--format", "json")
|
||||
err := root.Execute()
|
||||
require.Error(t, err)
|
||||
var typed *cmdutil.Error
|
||||
require.True(t, errors.As(err, &typed))
|
||||
assert.Equal(t, cmdutil.CodeInputInvalidArgument, typed.Code)
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
// Package doc — fetch.go implements `weknora doc fetch <url>`.
|
||||
package doc
|
||||
|
||||
import (
|
||||
"cmp"
|
||||
"context"
|
||||
"errors"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/Tencent/WeKnora/cli/internal/cmdutil"
|
||||
"github.com/Tencent/WeKnora/cli/internal/iostreams"
|
||||
sdk "github.com/Tencent/WeKnora/client"
|
||||
)
|
||||
|
||||
// docFetchFields enumerates the fields surfaced for `--format json` discovery
|
||||
// on `doc fetch`.
|
||||
var docFetchFields = []string{
|
||||
"id", "knowledge_base_id", "tag_id", "type", "title", "description",
|
||||
"source", "channel", "parse_status", "summary_status", "enable_status",
|
||||
"embedding_model_id", "file_name", "file_type", "file_size", "file_hash",
|
||||
"file_path", "storage_size",
|
||||
"created_at", "updated_at", "processed_at", "error_message",
|
||||
}
|
||||
|
||||
// FetchOptions holds CLI flag values for `doc fetch`.
|
||||
type FetchOptions struct {
|
||||
URL string
|
||||
Name string // --name: FileName hint for file-vs-crawl mode detection
|
||||
Title string // --title: display title for the new entry
|
||||
FileType string // --file-type: extension hint for extension-less URLs
|
||||
TagID string // --tag-id: associate the new entry with a tag
|
||||
Channel string // --channel: ingestion-channel tag (default "api")
|
||||
EnableMultimodel *bool // tri-state: nil = server default
|
||||
DryRun bool
|
||||
}
|
||||
|
||||
// FetchService is the narrow SDK surface for `doc fetch`.
|
||||
// *sdk.Client satisfies it.
|
||||
type FetchService interface {
|
||||
CreateKnowledgeFromURL(
|
||||
ctx context.Context,
|
||||
kbID string,
|
||||
req sdk.CreateKnowledgeFromURLRequest,
|
||||
) (*sdk.Knowledge, error)
|
||||
}
|
||||
|
||||
// NewCmdFetch builds `weknora doc fetch <url>`.
|
||||
func NewCmdFetch(f *cmdutil.Factory) *cobra.Command {
|
||||
opts := &FetchOptions{}
|
||||
cmd := &cobra.Command{
|
||||
Use: "fetch <url>",
|
||||
Short: "Fetch a remote document into a knowledge base",
|
||||
Long: `Server fetches the document at the given URL and ingests it into the resolved
|
||||
knowledge base. KB resolution follows the standard 4-level chain:
|
||||
--kb flag > WEKNORA_KB_ID env > .weknora/project.yaml > error.
|
||||
|
||||
When the URL has a known file extension (.pdf, .docx, .md, .txt) the server
|
||||
automatically switches from web-page-crawl mode to file-download mode. Pass
|
||||
--file-type or --name with a recognisable extension to force file-download mode
|
||||
for extension-less URLs.
|
||||
|
||||
Server-side ingestion knobs:
|
||||
|
||||
--name <name> Override the recorded file name; also used as the
|
||||
file-type hint when the extension is recognisable.
|
||||
--title <title> Set the display title stored with the entry.
|
||||
--file-type <ext> Explicit file-type hint (e.g. "pdf") for URLs
|
||||
without an extension.
|
||||
--tag-id <id> Associate the new entry with a tag.
|
||||
--enable-multimodel Toggle multimodal extraction (image-in-PDF → text).
|
||||
Unset ⇒ server default; pass true or false to override.
|
||||
--channel <name> Override the ingestion-channel tag (default "api").`,
|
||||
Example: ` weknora doc fetch https://example.com/whitepaper.pdf
|
||||
weknora doc fetch https://example.com/no-ext --file-type pdf --title "Whitepaper"
|
||||
weknora doc fetch https://example.com/article.html --name "Q3 Article" --tag-id tag_abc
|
||||
weknora doc fetch https://example.com/report.pdf --kb my-kb --enable-multimodel`,
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: func(c *cobra.Command, args []string) error {
|
||||
opts.URL = args[0]
|
||||
fopts, err := cmdutil.CheckFormatFlag(c)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
fopts.ResolveDefault(iostreams.IO.IsStdoutTTY())
|
||||
// Pure-local validation runs before the dry-run gate so --dry-run
|
||||
// rejects identically to the live path.
|
||||
if c.Flags().Changed("enable-multimodel") {
|
||||
raw, _ := c.Flags().GetString("enable-multimodel")
|
||||
v, perr := parseTriBool(raw)
|
||||
if perr != nil {
|
||||
return perr
|
||||
}
|
||||
opts.EnableMultimodel = &v
|
||||
}
|
||||
if err := cmdutil.ValidateHTTPURL("<url>", opts.URL); err != nil {
|
||||
return err
|
||||
}
|
||||
if opts.DryRun {
|
||||
// Local-only KB resolution: plan reports the raw --kb value
|
||||
// (UUID or name) without an SDK lookup.
|
||||
kbID, err := f.ResolveKBLocal(c)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if handled, err := cmdutil.HandleDryRun(c, true, cmdutil.DryRunPlan{
|
||||
Action: "doc.fetch",
|
||||
Args: map[string]any{
|
||||
"url": opts.URL,
|
||||
"kb": kbID,
|
||||
},
|
||||
}); handled {
|
||||
return err
|
||||
}
|
||||
}
|
||||
cli, err := f.Client()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
kbID, err := f.ResolveKB(c)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return runFetch(c.Context(), opts, fopts, cli, kbID)
|
||||
},
|
||||
}
|
||||
cmdutil.AddKBFlag(cmd)
|
||||
cmd.Flags().StringVar(&opts.Name, "name", "", "File name hint (also used as file-type hint when extension is recognisable)")
|
||||
cmd.Flags().StringVar(&opts.Title, "title", "", "Display title for the new entry")
|
||||
cmd.Flags().StringVar(&opts.FileType, "file-type", "", "File-type hint such as \"pdf\" when the URL has no extension")
|
||||
cmd.Flags().StringVar(&opts.TagID, "tag-id", "", "Tag id to associate with the new entry")
|
||||
cmd.Flags().StringVar(&opts.Channel, "channel", "", "Ingestion-channel tag recorded server-side (default \"api\")")
|
||||
cmd.Flags().String("enable-multimodel", "", "Toggle multimodal extraction (true|false); unset ⇒ server default")
|
||||
cmd.Flags().Lookup("enable-multimodel").NoOptDefVal = "true"
|
||||
cmdutil.AddFormatFlag(cmd, docFetchFields...)
|
||||
cmdutil.AddDryRunFlag(cmd, &opts.DryRun)
|
||||
cmdutil.SetAgentHelp(cmd, cmdutil.AgentHelp{
|
||||
UsedFor: "Ingest a remote URL into the resolved knowledge base. KB resolved via --kb flag, WEKNORA_KB_ID env, or project link. Emits the created Knowledge object with its id.",
|
||||
RequiredFlags: []string{"<url> (positional)", "--kb (or WEKNORA_KB_ID / project link)"},
|
||||
Examples: []string{
|
||||
`weknora doc fetch https://example.com/whitepaper.pdf --kb kb_eng`,
|
||||
},
|
||||
Output: "envelope.data is the created Knowledge object with id, knowledge_base_id, source, parse_status",
|
||||
})
|
||||
return cmd
|
||||
}
|
||||
|
||||
// runFetch ingests a remote URL via SDK CreateKnowledgeFromURL.
|
||||
func runFetch(ctx context.Context, opts *FetchOptions, fopts *cmdutil.FormatOptions, svc FetchService, kbID string) error {
|
||||
req := sdk.CreateKnowledgeFromURLRequest{
|
||||
URL: opts.URL,
|
||||
FileName: opts.Name,
|
||||
FileType: opts.FileType,
|
||||
EnableMultimodel: opts.EnableMultimodel,
|
||||
Title: opts.Title,
|
||||
TagID: opts.TagID,
|
||||
Channel: cmp.Or(opts.Channel, uploadChannel),
|
||||
}
|
||||
k, err := svc.CreateKnowledgeFromURL(ctx, kbID, req)
|
||||
if err != nil {
|
||||
if errors.Is(err, sdk.ErrDuplicateURL) {
|
||||
return cmdutil.Wrapf(cmdutil.CodeResourceAlreadyExists, err,
|
||||
"URL already ingested into this knowledge base")
|
||||
}
|
||||
return cmdutil.WrapHTTP(err, "fetch document from %s", opts.URL)
|
||||
}
|
||||
return renderUploadSuccess(k, fopts, "Ingested", opts.Name, opts.URL)
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
package doc
|
||||
|
||||
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"
|
||||
)
|
||||
|
||||
// fakeFetchSvc captures call arguments and returns canned responses.
|
||||
type fakeFetchSvc struct {
|
||||
resp *sdk.Knowledge
|
||||
err error
|
||||
got struct {
|
||||
kbID string
|
||||
req sdk.CreateKnowledgeFromURLRequest
|
||||
}
|
||||
}
|
||||
|
||||
func (f *fakeFetchSvc) CreateKnowledgeFromURL(
|
||||
_ context.Context,
|
||||
kbID string,
|
||||
req sdk.CreateKnowledgeFromURLRequest,
|
||||
) (*sdk.Knowledge, error) {
|
||||
f.got.kbID = kbID
|
||||
f.got.req = req
|
||||
return f.resp, f.err
|
||||
}
|
||||
|
||||
func TestFetch_Success_Text(t *testing.T) {
|
||||
out, _ := iostreams.SetForTest(t)
|
||||
svc := &fakeFetchSvc{resp: &sdk.Knowledge{ID: "doc_url_1", FileName: "whitepaper.pdf"}}
|
||||
opts := &FetchOptions{URL: "https://example.com/whitepaper.pdf"}
|
||||
require.NoError(t, runFetch(context.Background(), opts, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, svc, "kb_xxx"))
|
||||
|
||||
assert.Equal(t, "kb_xxx", svc.got.kbID)
|
||||
assert.Equal(t, "https://example.com/whitepaper.pdf", svc.got.req.URL)
|
||||
assert.Equal(t, "api", svc.got.req.Channel)
|
||||
assert.Contains(t, out.String(), "Ingested")
|
||||
assert.Contains(t, out.String(), "doc_url_1")
|
||||
}
|
||||
|
||||
func TestFetch_WithName_PassesAsFileName(t *testing.T) {
|
||||
_, _ = iostreams.SetForTest(t)
|
||||
svc := &fakeFetchSvc{resp: &sdk.Knowledge{ID: "doc_url_2"}}
|
||||
opts := &FetchOptions{URL: "https://example.com/article.html", Name: "Q3 Article"}
|
||||
require.NoError(t, runFetch(context.Background(), opts, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, svc, "kb_xxx"))
|
||||
assert.Equal(t, "Q3 Article", svc.got.req.FileName,
|
||||
"--name must be forwarded as FileName (server uses it for file-vs-crawl mode hint)")
|
||||
}
|
||||
|
||||
func TestFetch_Title(t *testing.T) {
|
||||
_, _ = iostreams.SetForTest(t)
|
||||
svc := &fakeFetchSvc{resp: &sdk.Knowledge{ID: "doc_u"}}
|
||||
opts := &FetchOptions{URL: "https://example.com/a.pdf", Title: "My Title"}
|
||||
require.NoError(t, runFetch(context.Background(), opts, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, svc, "kb_xxx"))
|
||||
assert.Equal(t, "My Title", svc.got.req.Title)
|
||||
}
|
||||
|
||||
func TestFetch_FileType(t *testing.T) {
|
||||
_, _ = iostreams.SetForTest(t)
|
||||
svc := &fakeFetchSvc{resp: &sdk.Knowledge{ID: "doc_u"}}
|
||||
opts := &FetchOptions{URL: "https://example.com/no-ext", FileType: "pdf"}
|
||||
require.NoError(t, runFetch(context.Background(), opts, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, svc, "kb_xxx"))
|
||||
assert.Equal(t, "pdf", svc.got.req.FileType)
|
||||
}
|
||||
|
||||
func TestFetch_TagID(t *testing.T) {
|
||||
_, _ = iostreams.SetForTest(t)
|
||||
svc := &fakeFetchSvc{resp: &sdk.Knowledge{ID: "doc_u"}}
|
||||
opts := &FetchOptions{URL: "https://example.com/a.pdf", TagID: "tag_99"}
|
||||
require.NoError(t, runFetch(context.Background(), opts, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, svc, "kb_xxx"))
|
||||
assert.Equal(t, "tag_99", svc.got.req.TagID)
|
||||
}
|
||||
|
||||
func TestFetch_EnableMultimodel_Forwarded(t *testing.T) {
|
||||
_, _ = iostreams.SetForTest(t)
|
||||
svc := &fakeFetchSvc{resp: &sdk.Knowledge{ID: "doc_u"}}
|
||||
mm := true
|
||||
opts := &FetchOptions{URL: "https://example.com/a.pdf", EnableMultimodel: &mm}
|
||||
require.NoError(t, runFetch(context.Background(), opts, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, svc, "kb_xxx"))
|
||||
require.NotNil(t, svc.got.req.EnableMultimodel)
|
||||
assert.True(t, *svc.got.req.EnableMultimodel)
|
||||
}
|
||||
|
||||
func TestFetch_Channel_Override(t *testing.T) {
|
||||
_, _ = iostreams.SetForTest(t)
|
||||
svc := &fakeFetchSvc{resp: &sdk.Knowledge{ID: "doc_u"}}
|
||||
opts := &FetchOptions{URL: "https://example.com/a.pdf", Channel: "web"}
|
||||
require.NoError(t, runFetch(context.Background(), opts, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, svc, "kb_xxx"))
|
||||
assert.Equal(t, "web", svc.got.req.Channel)
|
||||
}
|
||||
|
||||
func TestFetch_Channel_DefaultIsAPI(t *testing.T) {
|
||||
_, _ = iostreams.SetForTest(t)
|
||||
svc := &fakeFetchSvc{resp: &sdk.Knowledge{ID: "doc_u"}}
|
||||
opts := &FetchOptions{URL: "https://example.com/a.pdf"}
|
||||
require.NoError(t, runFetch(context.Background(), opts, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, svc, "kb_xxx"))
|
||||
assert.Equal(t, uploadChannel, svc.got.req.Channel)
|
||||
}
|
||||
|
||||
func TestFetch_JSON_Envelope(t *testing.T) {
|
||||
out, _ := iostreams.SetForTest(t)
|
||||
svc := &fakeFetchSvc{resp: &sdk.Knowledge{ID: "doc_url_3", FileName: "ok.pdf"}}
|
||||
fopts := &cmdutil.FormatOptions{Mode: cmdutil.FormatJSON}
|
||||
require.NoError(t, runFetch(context.Background(),
|
||||
&FetchOptions{URL: "https://example.com/ok.pdf"}, fopts, svc, "kb_xxx"))
|
||||
got := out.String()
|
||||
var env struct {
|
||||
OK bool `json:"ok"`
|
||||
Data sdk.Knowledge `json:"data"`
|
||||
}
|
||||
require.NoError(t, json.Unmarshal([]byte(got), &env), "expected valid JSON envelope, got %q", got)
|
||||
assert.True(t, env.OK, "envelope.ok must be true")
|
||||
assert.Equal(t, "doc_url_3", env.Data.ID, "envelope.data.id must be doc_url_3")
|
||||
assert.NotContains(t, got, `"risk":`)
|
||||
}
|
||||
|
||||
func TestFetch_DuplicateURL_Maps_resource_already_exists(t *testing.T) {
|
||||
_, _ = iostreams.SetForTest(t)
|
||||
svc := &fakeFetchSvc{
|
||||
resp: &sdk.Knowledge{ID: "doc_existing"},
|
||||
err: sdk.ErrDuplicateURL,
|
||||
}
|
||||
err := runFetch(context.Background(),
|
||||
&FetchOptions{URL: "https://example.com/dup.pdf"}, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, svc, "kb_xxx")
|
||||
require.Error(t, err)
|
||||
var typed *cmdutil.Error
|
||||
require.ErrorAs(t, err, &typed)
|
||||
assert.Equal(t, cmdutil.CodeResourceAlreadyExists, typed.Code)
|
||||
}
|
||||
|
||||
func TestFetch_ServerError_Wraps(t *testing.T) {
|
||||
_, _ = iostreams.SetForTest(t)
|
||||
svc := &fakeFetchSvc{err: errors.New("HTTP error 500: internal server error")}
|
||||
err := runFetch(context.Background(),
|
||||
&FetchOptions{URL: "https://example.com/doc.pdf"}, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, svc, "kb_xxx")
|
||||
require.Error(t, err)
|
||||
var typed *cmdutil.Error
|
||||
require.ErrorAs(t, err, &typed)
|
||||
assert.Equal(t, cmdutil.CodeServerError, typed.Code)
|
||||
}
|
||||
|
||||
func TestFetch_KBResolutionFailure_Propagates(t *testing.T) {
|
||||
// runFetch itself receives a pre-resolved kbID; KB resolution failure
|
||||
// happens in RunE before runFetch is called. This test verifies that
|
||||
// runFetch does NOT swallow an error returned from the service when
|
||||
// kbID is empty (which a broken resolution chain might produce).
|
||||
_, _ = iostreams.SetForTest(t)
|
||||
svc := &fakeFetchSvc{err: errors.New("HTTP error 404: knowledge base not found")}
|
||||
err := runFetch(context.Background(),
|
||||
&FetchOptions{URL: "https://example.com/doc.pdf"}, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, svc, "")
|
||||
require.Error(t, err)
|
||||
}
|
||||
@@ -0,0 +1,265 @@
|
||||
package doc
|
||||
|
||||
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"
|
||||
)
|
||||
|
||||
// docListFields enumerates the fields surfaced for `--format json` discovery on
|
||||
// `doc list`. Filter applies to each Knowledge object in the bare array.
|
||||
var docListFields = []string{
|
||||
"id", "knowledge_base_id", "tag_id", "type", "title", "description",
|
||||
"source", "channel", "parse_status", "summary_status", "enable_status",
|
||||
"embedding_model_id", "file_name", "file_type", "file_size", "file_hash",
|
||||
"file_path", "storage_size",
|
||||
"created_at", "updated_at", "processed_at", "error_message",
|
||||
}
|
||||
|
||||
type ListOptions struct {
|
||||
PageSize int // Items per server batch. With --all-pages, controls
|
||||
// per-request load. Without, controls the single page size.
|
||||
Status string // --status: filter by parse_status (server-side query param)
|
||||
// Limit caps the returned items client-side (default 30; 0 = no cap).
|
||||
// Applied after pagination / --all-pages accumulation and sort.
|
||||
Limit int
|
||||
// AllPages walks server pages internally, accumulating items until
|
||||
// total exhausted or --limit hit.
|
||||
AllPages bool
|
||||
// Additional server-side filters (each maps 1:1 to a sdk.KnowledgeListFilter
|
||||
// field). Empty / zero values are omitted from the request.
|
||||
Keyword string
|
||||
FileType string
|
||||
Source string
|
||||
TagID string
|
||||
StartTime string // raw RFC3339; parsed into filter.StartTime
|
||||
EndTime string // raw RFC3339; parsed into filter.EndTime
|
||||
}
|
||||
|
||||
// docListStatusValues mirrors internal/types/knowledge.go ParseStatus*
|
||||
// constants - these are the values the server accepts on the
|
||||
// ?parse_status= query. Kept in sync manually since the SDK doesn't
|
||||
// re-export the enum.
|
||||
var docListStatusValues = []string{"pending", "processing", "completed", "failed"}
|
||||
|
||||
// ListService is the narrow SDK surface this command depends on.
|
||||
// *sdk.Client satisfies it.
|
||||
type ListService interface {
|
||||
ListKnowledgeWithFilter(ctx context.Context, kbID string, page, pageSize int, filter sdk.KnowledgeListFilter) ([]sdk.Knowledge, int64, error)
|
||||
}
|
||||
|
||||
// NewCmdList builds `weknora doc list`.
|
||||
func NewCmdList(f *cmdutil.Factory) *cobra.Command {
|
||||
opts := &ListOptions{}
|
||||
cmd := &cobra.Command{
|
||||
Use: "list",
|
||||
Short: "List documents in a knowledge base",
|
||||
Long: `Lists documents (uploaded files / web pages / inline text) in the
|
||||
resolved knowledge base. KB resolution follows the standard 4-level chain:
|
||||
--kb flag > WEKNORA_KB_ID env > .weknora/project.yaml > error. The --kb
|
||||
flag accepts either a KB UUID (passed through) or a name (resolved via list).
|
||||
|
||||
Default sort is updated_at desc so the most recent uploads surface first;
|
||||
backend storage order is not guaranteed and varies between deployments.`,
|
||||
Example: ` weknora doc list # uses project link / env
|
||||
weknora doc list --kb a32a63ff-fb36-4874-bcaa-30f48570a694 # explicit UUID
|
||||
weknora doc list --kb my-kb # resolved by name
|
||||
weknora doc list --all-pages --format json # walk every page`,
|
||||
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 (--page-size / --limit / --status) before
|
||||
// resolving the KB or building the client so a bad value returns
|
||||
// input.invalid_argument (exit 5) instead of an auth error (exit 3)
|
||||
// when the profile is unconfigured — an agent must see the real,
|
||||
// fixable problem.
|
||||
if err := validateListOpts(opts); err != nil {
|
||||
return err
|
||||
}
|
||||
kbID, err := f.ResolveKB(c)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
cli, err := f.Client()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return runList(c.Context(), opts, fopts, cli, kbID)
|
||||
},
|
||||
}
|
||||
cmdutil.AddKBFlag(cmd)
|
||||
cmd.Flags().IntVar(&opts.PageSize, "page-size", 50, "Items per server batch (1..1000)")
|
||||
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)")
|
||||
cmd.Flags().BoolVar(&opts.AllPages, "all-pages", false, "Walk all server pages until exhausted (or --limit hit)")
|
||||
cmd.Flags().StringVar(&opts.Status, "status", "", "Filter by parse status: pending | processing | completed | failed")
|
||||
cmd.Flags().StringVar(&opts.Keyword, "keyword", "", "Server-side substring match against title / file_name (case-insensitive)")
|
||||
cmd.Flags().StringVar(&opts.FileType, "file-type", "", `Filter by file extension (e.g. "pdf", "md")`)
|
||||
cmd.Flags().StringVar(&opts.Source, "source", "", `Filter by ingestion source (e.g. "api", "web")`)
|
||||
cmd.Flags().StringVar(&opts.TagID, "tag-id", "", "Filter by tag association")
|
||||
cmd.Flags().StringVar(&opts.StartTime, "start-time", "", "Include docs with updated_at >= this RFC3339 timestamp (e.g. 2006-01-02T15:04:05Z)")
|
||||
cmd.Flags().StringVar(&opts.EndTime, "end-time", "", "Include docs with updated_at <= this RFC3339 timestamp (e.g. 2006-01-02T15:04:05Z)")
|
||||
cmdutil.AddFormatFlag(cmd, docListFields...)
|
||||
cmdutil.SetAgentHelp(cmd, cmdutil.AgentHelp{
|
||||
UsedFor: "List documents in the resolved knowledge base. Results come with meta.count; use --limit to cap, --all-pages to walk every server page, --status/--keyword to filter server-side.",
|
||||
Examples: []string{"weknora doc list --format json", "weknora doc list --all-pages --limit 200 --format json"},
|
||||
Output: "envelope.data is an array of Knowledge objects with id, title, file_name, parse_status; meta.count is the returned count; meta.total_count is the server-side total before client-side --limit truncation; meta.has_more=true when --limit truncated",
|
||||
})
|
||||
return cmd
|
||||
}
|
||||
|
||||
// validateListOpts checks --page-size / --limit / --status. Called from RunE
|
||||
// before the KB/client are resolved (so bad input surfaces as exit 5, not an
|
||||
// auth error) and again at the top of runList for direct callers; idempotent.
|
||||
func validateListOpts(opts *ListOptions) error {
|
||||
if opts.PageSize < 1 || opts.PageSize > 1000 {
|
||||
return &cmdutil.Error{
|
||||
Code: cmdutil.CodeInputInvalidArgument,
|
||||
Message: fmt.Sprintf("--page-size must be in 1..1000, got %d", opts.PageSize),
|
||||
}
|
||||
}
|
||||
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),
|
||||
}
|
||||
}
|
||||
// Validate + normalize --status via the shared enum helper (same exit-5
|
||||
// path and case-insensitive normalization as every other enum flag).
|
||||
status, err := cmdutil.ValidateEnum("status", opts.Status, docListStatusValues)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
opts.Status = status
|
||||
return nil
|
||||
}
|
||||
|
||||
func runList(ctx context.Context, opts *ListOptions, fopts *cmdutil.FormatOptions, svc ListService, kbID string) error {
|
||||
if err := validateListOpts(opts); err != nil {
|
||||
return err
|
||||
}
|
||||
filter := sdk.KnowledgeListFilter{
|
||||
ParseStatus: opts.Status,
|
||||
Keyword: opts.Keyword,
|
||||
FileType: opts.FileType,
|
||||
Source: opts.Source,
|
||||
TagID: opts.TagID,
|
||||
}
|
||||
start, err := cmdutil.ParseTimeFlag("--start-time", opts.StartTime)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if start != nil {
|
||||
filter.StartTime = *start
|
||||
}
|
||||
end, err := cmdutil.ParseTimeFlag("--end-time", opts.EndTime)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if end != nil {
|
||||
filter.EndTime = *end
|
||||
}
|
||||
|
||||
// Pagination is always 1-indexed internally. --all-pages walks; the
|
||||
// non-walking path returns the first page only.
|
||||
var items []sdk.Knowledge
|
||||
var serverTotal int64
|
||||
if opts.AllPages {
|
||||
accum := make([]sdk.Knowledge, 0)
|
||||
for page := 1; ; page++ {
|
||||
chunk, total, err := svc.ListKnowledgeWithFilter(ctx, kbID, page, opts.PageSize, filter)
|
||||
if err != nil {
|
||||
return cmdutil.WrapHTTP(err, "list documents")
|
||||
}
|
||||
serverTotal = total
|
||||
accum = append(accum, chunk...)
|
||||
if opts.Limit > 0 && len(accum) >= opts.Limit {
|
||||
accum = accum[:opts.Limit]
|
||||
break
|
||||
}
|
||||
if int64(len(accum)) >= total || len(chunk) == 0 {
|
||||
break
|
||||
}
|
||||
}
|
||||
items = accum
|
||||
} else {
|
||||
chunk, total, err := svc.ListKnowledgeWithFilter(ctx, kbID, 1, opts.PageSize, filter)
|
||||
if err != nil {
|
||||
return cmdutil.WrapHTTP(err, "list documents")
|
||||
}
|
||||
serverTotal = total
|
||||
items = chunk
|
||||
}
|
||||
if items == nil {
|
||||
items = []sdk.Knowledge{} // ensure JSON [] not null
|
||||
}
|
||||
// Default sort: updated_at desc. Server return order is not guaranteed,
|
||||
// so client-side sort makes output deterministic regardless of backend
|
||||
// storage choices. Mirrors `weknora kb list`.
|
||||
sort.Slice(items, func(i, j int) bool {
|
||||
return items[i].UpdatedAt.After(items[j].UpdatedAt)
|
||||
})
|
||||
// --limit applies after sort so users get the top-N most-recent items
|
||||
// when combined with a single-page fetch where page_size > limit.
|
||||
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(int(serverTotal))}
|
||||
return fopts.Emit(iostreams.IO.Out, items, meta)
|
||||
}
|
||||
|
||||
if len(items) == 0 {
|
||||
fmt.Fprintln(iostreams.IO.Out, "(no documents)")
|
||||
return nil
|
||||
}
|
||||
|
||||
tw := tabwriter.NewWriter(iostreams.IO.Out, 0, 0, 2, ' ', 0)
|
||||
fmt.Fprintln(tw, "ID\tNAME\tSTATUS\tSIZE\tUPDATED")
|
||||
now := time.Now()
|
||||
for _, k := range items {
|
||||
name := text.Truncate(40, text.KnowledgeDisplayName(k.FileName, k.Title, k.ID))
|
||||
updated := text.FuzzyAgo(now, k.UpdatedAt)
|
||||
fmt.Fprintf(tw, "%s\t%s\t%s\t%s\t%s\n", k.ID, name, k.ParseStatus, formatSize(k.FileSize), updated)
|
||||
}
|
||||
return tw.Flush()
|
||||
}
|
||||
|
||||
// formatSize renders a byte count as a short human string (KB / MB).
|
||||
// Kept tiny on purpose - go-humanize would pull a transitive dep just for one
|
||||
// column. A "-" placeholder hides zero-size entries (URL / text).
|
||||
func formatSize(bytes int64) string {
|
||||
if bytes <= 0 {
|
||||
return "-"
|
||||
}
|
||||
const (
|
||||
kb = 1 << 10
|
||||
mb = 1 << 20
|
||||
gb = 1 << 30
|
||||
)
|
||||
switch {
|
||||
case bytes >= gb:
|
||||
return fmt.Sprintf("%.1fGB", float64(bytes)/float64(gb))
|
||||
case bytes >= mb:
|
||||
return fmt.Sprintf("%.1fMB", float64(bytes)/float64(mb))
|
||||
case bytes >= kb:
|
||||
return fmt.Sprintf("%.1fKB", float64(bytes)/float64(kb))
|
||||
}
|
||||
return fmt.Sprintf("%dB", bytes)
|
||||
}
|
||||
@@ -0,0 +1,498 @@
|
||||
package doc
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/Tencent/WeKnora/cli/internal/cmdutil"
|
||||
"github.com/Tencent/WeKnora/cli/internal/config"
|
||||
"github.com/Tencent/WeKnora/cli/internal/iostreams"
|
||||
sdk "github.com/Tencent/WeKnora/client"
|
||||
)
|
||||
|
||||
// fakeListSvc captures the request args and returns canned responses.
|
||||
type fakeListSvc struct {
|
||||
items []sdk.Knowledge
|
||||
total int64
|
||||
err error
|
||||
got struct {
|
||||
kbID string
|
||||
page int
|
||||
pageSize int
|
||||
filter sdk.KnowledgeListFilter
|
||||
}
|
||||
}
|
||||
|
||||
func (f *fakeListSvc) ListKnowledgeWithFilter(_ context.Context, kbID string, page, pageSize int, filter sdk.KnowledgeListFilter) ([]sdk.Knowledge, int64, error) {
|
||||
f.got.kbID, f.got.page, f.got.pageSize, f.got.filter = kbID, page, pageSize, filter
|
||||
return f.items, f.total, f.err
|
||||
}
|
||||
|
||||
// chdirIsolated parks cwd in a fresh tempdir so Factory.ResolveKB doesn't pick
|
||||
// up a stray .weknora/project.yaml from the repo. Also clears WEKNORA_KB_ID
|
||||
// for the duration of the test.
|
||||
func chdirIsolated(t *testing.T) {
|
||||
t.Helper()
|
||||
prev, err := os.Getwd()
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, os.Chdir(t.TempDir()))
|
||||
t.Cleanup(func() { _ = os.Chdir(prev) })
|
||||
t.Setenv("WEKNORA_KB_ID", "")
|
||||
}
|
||||
|
||||
func TestList_Success_Text(t *testing.T) {
|
||||
out, _ := iostreams.SetForTest(t)
|
||||
now := time.Now()
|
||||
items := []sdk.Knowledge{
|
||||
{ID: "doc1", FileName: "alpha.pdf", FileSize: 2048, ParseStatus: "completed", UpdatedAt: now.Add(-1 * time.Hour)},
|
||||
{ID: "doc2", FileName: "beta.md", FileSize: 0, ParseStatus: "pending", UpdatedAt: now.Add(-2 * 24 * time.Hour)},
|
||||
}
|
||||
svc := &fakeListSvc{items: items, total: 2}
|
||||
opts := &ListOptions{PageSize: 20, Limit: 30}
|
||||
require.NoError(t, runList(context.Background(), opts, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, svc, "kb_xxx"))
|
||||
|
||||
assert.Equal(t, "kb_xxx", svc.got.kbID)
|
||||
assert.Equal(t, 1, svc.got.page)
|
||||
assert.Equal(t, 20, svc.got.pageSize)
|
||||
assert.Equal(t, sdk.KnowledgeListFilter{}, svc.got.filter, "no flags ⇒ empty filter")
|
||||
|
||||
got := out.String()
|
||||
for _, want := range []string{"ID", "NAME", "STATUS", "SIZE", "UPDATED", "doc1", "alpha.pdf", "completed", "2.0KB", "doc2", "beta.md", "pending"} {
|
||||
if !strings.Contains(got, want) {
|
||||
t.Errorf("output missing %q in:\n%s", want, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestList_Success_JSON(t *testing.T) {
|
||||
out, _ := iostreams.SetForTest(t)
|
||||
svc := &fakeListSvc{items: []sdk.Knowledge{{ID: "doc1", FileName: "a.pdf"}}, total: 1}
|
||||
opts := &ListOptions{PageSize: 20, Limit: 30}
|
||||
require.NoError(t, runList(context.Background(), opts, &cmdutil.FormatOptions{Mode: cmdutil.FormatJSON}, svc, "kb_xxx"))
|
||||
|
||||
got := out.String()
|
||||
var env struct {
|
||||
OK bool `json:"ok"`
|
||||
Data []sdk.Knowledge `json:"data"`
|
||||
}
|
||||
require.NoError(t, json.Unmarshal([]byte(got), &env), "expected valid JSON envelope, got %q", got)
|
||||
assert.True(t, env.OK, "envelope.ok must be true")
|
||||
assert.Contains(t, got, `"id":"doc1"`)
|
||||
assert.NotContains(t, got, `"_meta":`)
|
||||
}
|
||||
|
||||
func TestList_Empty_Text(t *testing.T) {
|
||||
out, _ := iostreams.SetForTest(t)
|
||||
svc := &fakeListSvc{items: nil, total: 0}
|
||||
opts := &ListOptions{PageSize: 20, Limit: 30}
|
||||
require.NoError(t, runList(context.Background(), opts, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, svc, "kb_xxx"))
|
||||
assert.Contains(t, out.String(), "(no documents)")
|
||||
}
|
||||
|
||||
func TestList_Empty_JSON(t *testing.T) {
|
||||
out, _ := iostreams.SetForTest(t)
|
||||
svc := &fakeListSvc{items: nil, total: 0}
|
||||
opts := &ListOptions{PageSize: 20, Limit: 30}
|
||||
require.NoError(t, runList(context.Background(), opts, &cmdutil.FormatOptions{Mode: cmdutil.FormatJSON}, svc, "kb_xxx"))
|
||||
|
||||
var env struct {
|
||||
OK bool `json:"ok"`
|
||||
Data []sdk.Knowledge `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")
|
||||
assert.Len(t, env.Data, 0, "empty list must produce empty data array, not null")
|
||||
}
|
||||
|
||||
func TestList_HTTPError_500(t *testing.T) {
|
||||
_, _ = iostreams.SetForTest(t)
|
||||
svc := &fakeListSvc{err: errors.New("HTTP error 500: internal")}
|
||||
opts := &ListOptions{PageSize: 20, Limit: 30}
|
||||
err := runList(context.Background(), opts, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, svc, "kb_xxx")
|
||||
require.Error(t, err)
|
||||
|
||||
var typed *cmdutil.Error
|
||||
require.ErrorAs(t, err, &typed)
|
||||
assert.Equal(t, cmdutil.CodeServerError, typed.Code)
|
||||
}
|
||||
|
||||
// TestList_KBIDRequired drives the cobra layer to verify Factory.ResolveKB's
|
||||
// "no source supplied" path bubbles up as CodeKBIDRequired. Isolates cwd so
|
||||
// no project.yaml sneaks in, and clears WEKNORA_KB_ID.
|
||||
func TestList_KBIDRequired(t *testing.T) {
|
||||
chdirIsolated(t)
|
||||
_, _ = iostreams.SetForTest(t)
|
||||
|
||||
cfg := &config.Config{
|
||||
CurrentProfile: "default",
|
||||
Profiles: map[string]config.Profile{"default": {Host: "https://example"}},
|
||||
}
|
||||
f := &cmdutil.Factory{
|
||||
Config: func() (*config.Config, error) { return cfg, nil },
|
||||
Client: func() (*sdk.Client, error) {
|
||||
return nil, errors.New("client should not be called when kb id is missing")
|
||||
},
|
||||
}
|
||||
cmd := NewCmdList(f)
|
||||
cmd.SetContext(context.Background())
|
||||
cmd.SetArgs([]string{}) // no --kb
|
||||
cmd.SilenceErrors = true
|
||||
cmd.SilenceUsage = true
|
||||
err := cmd.Execute()
|
||||
require.Error(t, err)
|
||||
|
||||
var typed *cmdutil.Error
|
||||
require.ErrorAs(t, err, &typed)
|
||||
assert.Equal(t, cmdutil.CodeKBIDRequired, typed.Code)
|
||||
}
|
||||
|
||||
// TestList_KBFlagWiredToResolveKB confirms that --kb=kb_<id> passed at the
|
||||
// cobra layer reaches Factory.ResolveKB and short-circuits without listing.
|
||||
func TestList_KBFlagWiredToResolveKB(t *testing.T) {
|
||||
chdirIsolated(t)
|
||||
_, _ = iostreams.SetForTest(t)
|
||||
|
||||
cfg := &config.Config{
|
||||
CurrentProfile: "default",
|
||||
Profiles: map[string]config.Profile{"default": {Host: "https://example"}},
|
||||
}
|
||||
f := &cmdutil.Factory{
|
||||
Config: func() (*config.Config, error) { return cfg, nil },
|
||||
Client: func() (*sdk.Client, error) {
|
||||
return nil, errors.New("forced-after-resolvekb")
|
||||
},
|
||||
}
|
||||
// With --kb=kb_<id> supplied, ResolveKB short-circuits on the prefix
|
||||
// match without consulting the client. The RunE then asks for the client
|
||||
// to run the actual list - that call triggers the forced error.
|
||||
// Surfacing "forced-after-resolvekb" (rather than CodeKBIDRequired) is
|
||||
// the proof point that --kb was honored.
|
||||
cmd := NewCmdList(f)
|
||||
cmd.SetContext(context.Background())
|
||||
cmd.SetArgs([]string{"--kb", "kb_explicit"})
|
||||
cmd.SilenceErrors = true
|
||||
cmd.SilenceUsage = true
|
||||
err := cmd.Execute()
|
||||
require.Error(t, err, "expected client construction error")
|
||||
assert.Contains(t, err.Error(), "forced-after-resolvekb",
|
||||
"should surface the Client closure error, not a kb-required error")
|
||||
}
|
||||
|
||||
// pinning the sort order: most-recent-first regardless of input order.
|
||||
func TestList_SortByUpdatedDesc(t *testing.T) {
|
||||
out, _ := iostreams.SetForTest(t)
|
||||
now := time.Now()
|
||||
// Server returns oldest first; CLI must reorder.
|
||||
items := []sdk.Knowledge{
|
||||
{ID: "old", FileName: "old.pdf", UpdatedAt: now.Add(-10 * 24 * time.Hour)},
|
||||
{ID: "new", FileName: "new.pdf", UpdatedAt: now.Add(-1 * time.Hour)},
|
||||
}
|
||||
svc := &fakeListSvc{items: items, total: 2}
|
||||
require.NoError(t, runList(context.Background(), &ListOptions{PageSize: 20, Limit: 30}, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, svc, "kb_xxx"))
|
||||
|
||||
got := out.String()
|
||||
newIdx := strings.Index(got, "new.pdf")
|
||||
oldIdx := strings.Index(got, "old.pdf")
|
||||
require.GreaterOrEqual(t, newIdx, 0)
|
||||
require.GreaterOrEqual(t, oldIdx, 0)
|
||||
assert.Less(t, newIdx, oldIdx, "most-recent should render first")
|
||||
}
|
||||
|
||||
// TestFormatSize sanity-checks the byte-count formatter without exporting it.
|
||||
func TestFormatSize(t *testing.T) {
|
||||
cases := []struct {
|
||||
in int64
|
||||
want string
|
||||
}{
|
||||
{0, "-"},
|
||||
{-1, "-"},
|
||||
{900, "900B"},
|
||||
{2048, "2.0KB"},
|
||||
{5 * 1024 * 1024, "5.0MB"},
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := formatSize(c.in); got != c.want {
|
||||
t.Errorf("formatSize(%d) = %q, want %q", c.in, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestList_StatusFilter_ForwardedToSDK(t *testing.T) {
|
||||
chdirIsolated(t)
|
||||
_, _ = iostreams.SetForTest(t)
|
||||
svc := &fakeListSvc{}
|
||||
opts := &ListOptions{PageSize: 20, Limit: 30, Status: "failed"}
|
||||
require.NoError(t, runList(context.Background(), opts, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, svc, "kb_xxx"))
|
||||
assert.Equal(t, "failed", svc.got.filter.ParseStatus,
|
||||
"--status must be forwarded as filter.ParseStatus for server-side filtering")
|
||||
}
|
||||
|
||||
func TestList_StatusFilter_RejectsUnknownValue(t *testing.T) {
|
||||
chdirIsolated(t)
|
||||
_, _ = iostreams.SetForTest(t)
|
||||
svc := &fakeListSvc{}
|
||||
opts := &ListOptions{PageSize: 20, Limit: 30, Status: "bogus"}
|
||||
err := runList(context.Background(), opts, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, svc, "kb_xxx")
|
||||
require.Error(t, err)
|
||||
var typed *cmdutil.Error
|
||||
require.ErrorAs(t, err, &typed)
|
||||
assert.Equal(t, cmdutil.CodeInputInvalidArgument, typed.Code)
|
||||
assert.Contains(t, typed.Message, "pending")
|
||||
assert.Contains(t, typed.Message, "failed")
|
||||
}
|
||||
|
||||
func TestList_StatusFilter_AcceptsAllEnumValues(t *testing.T) {
|
||||
chdirIsolated(t)
|
||||
for _, v := range docListStatusValues {
|
||||
_, _ = iostreams.SetForTest(t)
|
||||
svc := &fakeListSvc{}
|
||||
opts := &ListOptions{PageSize: 20, Limit: 30, Status: v}
|
||||
require.NoError(t, runList(context.Background(), opts, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, svc, "kb_xxx"),
|
||||
"status=%q should be accepted", v)
|
||||
}
|
||||
}
|
||||
|
||||
// makeDocs returns N Knowledge records with distinct IDs and descending
|
||||
// UpdatedAt timestamps, useful for limit / pagination tests.
|
||||
func makeDocs(n int) []sdk.Knowledge {
|
||||
base := time.Now()
|
||||
out := make([]sdk.Knowledge, n)
|
||||
for i := 0; i < n; i++ {
|
||||
out[i] = sdk.Knowledge{
|
||||
ID: fmt.Sprintf("doc_%02d", i),
|
||||
FileName: fmt.Sprintf("f_%02d.pdf", i),
|
||||
UpdatedAt: base.Add(-time.Duration(i) * time.Hour),
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// pagedDocSvc returns server-paginated Knowledge results from a flat slice.
|
||||
// Records the page numbers requested for assertion.
|
||||
type pagedDocSvc struct {
|
||||
all []sdk.Knowledge
|
||||
calls []int // 1-based page numbers received
|
||||
pageSize int
|
||||
}
|
||||
|
||||
func (p *pagedDocSvc) ListKnowledgeWithFilter(_ context.Context, _ string, page, pageSize int, _ sdk.KnowledgeListFilter) ([]sdk.Knowledge, int64, error) {
|
||||
p.calls = append(p.calls, page)
|
||||
p.pageSize = pageSize
|
||||
start := (page - 1) * pageSize
|
||||
if start >= len(p.all) {
|
||||
return []sdk.Knowledge{}, int64(len(p.all)), nil
|
||||
}
|
||||
end := start + pageSize
|
||||
if end > len(p.all) {
|
||||
end = len(p.all)
|
||||
}
|
||||
return p.all[start:end], int64(len(p.all)), nil
|
||||
}
|
||||
|
||||
func TestList_Limit_LessThanPageSize_SlicesToLimit(t *testing.T) {
|
||||
out, _ := iostreams.SetForTest(t)
|
||||
svc := &fakeListSvc{items: makeDocs(20), total: 20}
|
||||
opts := &ListOptions{PageSize: 20, Limit: 5}
|
||||
require.NoError(t, runList(context.Background(), opts, &cmdutil.FormatOptions{Mode: cmdutil.FormatJSON}, svc, "kb_xxx"))
|
||||
body := out.String()
|
||||
// Count occurrences of "id":"doc_" - should be exactly 5.
|
||||
got := strings.Count(body, `"id":"doc_`)
|
||||
assert.Equal(t, 5, got, "--limit 5 must slice 20 returned items to 5; body=\n%s", body)
|
||||
}
|
||||
|
||||
func TestList_Limit_GreaterThanPageSize_NoCap(t *testing.T) {
|
||||
out, _ := iostreams.SetForTest(t)
|
||||
svc := &fakeListSvc{items: makeDocs(10), total: 10}
|
||||
opts := &ListOptions{PageSize: 10, Limit: 50}
|
||||
require.NoError(t, runList(context.Background(), opts, &cmdutil.FormatOptions{Mode: cmdutil.FormatJSON}, svc, "kb_xxx"))
|
||||
got := strings.Count(out.String(), `"id":"doc_`)
|
||||
assert.Equal(t, 10, got, "--limit 50 with page-size 10 + 10 items returns all 10")
|
||||
}
|
||||
|
||||
func TestList_Limit_Negative_Rejected(t *testing.T) {
|
||||
_, _ = iostreams.SetForTest(t)
|
||||
opts := &ListOptions{PageSize: 20, Limit: -1}
|
||||
err := runList(context.Background(), opts, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, &fakeListSvc{}, "kb_xxx")
|
||||
require.Error(t, err)
|
||||
var typed *cmdutil.Error
|
||||
require.ErrorAs(t, err, &typed)
|
||||
assert.Equal(t, cmdutil.CodeInputInvalidArgument, typed.Code)
|
||||
}
|
||||
|
||||
func TestList_AllPages_WalksAllServerPages(t *testing.T) {
|
||||
out, _ := iostreams.SetForTest(t)
|
||||
svc := &pagedDocSvc{all: makeDocs(45)}
|
||||
opts := &ListOptions{PageSize: 20, Limit: 10000, AllPages: true}
|
||||
require.NoError(t, runList(context.Background(), opts, &cmdutil.FormatOptions{Mode: cmdutil.FormatJSON}, svc, "kb_xxx"))
|
||||
// 45 items / page_size 20 = 3 pages: 20 + 20 + 5.
|
||||
assert.Equal(t, []int{1, 2, 3}, svc.calls)
|
||||
got := strings.Count(out.String(), `"id":"doc_`)
|
||||
assert.Equal(t, 45, got)
|
||||
}
|
||||
|
||||
func TestList_AllPages_WithLimit_StopsAtLimit(t *testing.T) {
|
||||
out, _ := iostreams.SetForTest(t)
|
||||
svc := &pagedDocSvc{all: makeDocs(200)}
|
||||
opts := &ListOptions{PageSize: 20, AllPages: true, Limit: 50}
|
||||
require.NoError(t, runList(context.Background(), opts, &cmdutil.FormatOptions{Mode: cmdutil.FormatJSON}, svc, "kb_xxx"))
|
||||
got := strings.Count(out.String(), `"id":"doc_`)
|
||||
assert.Equal(t, 50, got, "--limit 50 with --all-pages should stop after 50 items")
|
||||
// Should have called pages 1..3 (60 items) then capped at 50.
|
||||
assert.LessOrEqual(t, len(svc.calls), 3, "should not walk past the page that fills --limit")
|
||||
}
|
||||
|
||||
// ----- C11: richer filter flags -----
|
||||
|
||||
func TestList_Keyword_PassedToFilter(t *testing.T) {
|
||||
_, _ = iostreams.SetForTest(t)
|
||||
svc := &fakeListSvc{}
|
||||
opts := &ListOptions{PageSize: 20, Limit: 30, Keyword: "spec"}
|
||||
require.NoError(t, runList(context.Background(), opts, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, svc, "kb_xxx"))
|
||||
assert.Equal(t, "spec", svc.got.filter.Keyword)
|
||||
}
|
||||
|
||||
func TestList_FileType_PassedToFilter(t *testing.T) {
|
||||
_, _ = iostreams.SetForTest(t)
|
||||
svc := &fakeListSvc{}
|
||||
opts := &ListOptions{PageSize: 20, Limit: 30, FileType: "pdf"}
|
||||
require.NoError(t, runList(context.Background(), opts, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, svc, "kb_xxx"))
|
||||
assert.Equal(t, "pdf", svc.got.filter.FileType)
|
||||
}
|
||||
|
||||
func TestList_Source_PassedToFilter(t *testing.T) {
|
||||
_, _ = iostreams.SetForTest(t)
|
||||
svc := &fakeListSvc{}
|
||||
opts := &ListOptions{PageSize: 20, Limit: 30, Source: "api"}
|
||||
require.NoError(t, runList(context.Background(), opts, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, svc, "kb_xxx"))
|
||||
assert.Equal(t, "api", svc.got.filter.Source)
|
||||
}
|
||||
|
||||
func TestList_TagID_PassedToFilter(t *testing.T) {
|
||||
_, _ = iostreams.SetForTest(t)
|
||||
svc := &fakeListSvc{}
|
||||
opts := &ListOptions{PageSize: 20, Limit: 30, TagID: "tag_42"}
|
||||
require.NoError(t, runList(context.Background(), opts, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, svc, "kb_xxx"))
|
||||
assert.Equal(t, "tag_42", svc.got.filter.TagID)
|
||||
}
|
||||
|
||||
func TestList_StartTime_RFC3339Parses(t *testing.T) {
|
||||
_, _ = iostreams.SetForTest(t)
|
||||
svc := &fakeListSvc{}
|
||||
want := "2026-05-01T00:00:00Z"
|
||||
opts := &ListOptions{PageSize: 20, Limit: 30, StartTime: want}
|
||||
require.NoError(t, runList(context.Background(), opts, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, svc, "kb_xxx"))
|
||||
parsed, err := time.Parse(time.RFC3339, want)
|
||||
require.NoError(t, err)
|
||||
assert.True(t, svc.got.filter.StartTime.Equal(parsed),
|
||||
"--start-time must be parsed into filter.StartTime; got %v want %v",
|
||||
svc.got.filter.StartTime, parsed)
|
||||
}
|
||||
|
||||
func TestList_EndTime_RFC3339Parses(t *testing.T) {
|
||||
_, _ = iostreams.SetForTest(t)
|
||||
svc := &fakeListSvc{}
|
||||
want := "2026-06-30T23:59:59Z"
|
||||
opts := &ListOptions{PageSize: 20, Limit: 30, EndTime: want}
|
||||
require.NoError(t, runList(context.Background(), opts, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, svc, "kb_xxx"))
|
||||
parsed, err := time.Parse(time.RFC3339, want)
|
||||
require.NoError(t, err)
|
||||
assert.True(t, svc.got.filter.EndTime.Equal(parsed))
|
||||
}
|
||||
|
||||
func TestList_StartTime_InvalidFormat_Rejected(t *testing.T) {
|
||||
_, _ = iostreams.SetForTest(t)
|
||||
opts := &ListOptions{PageSize: 20, Limit: 30, StartTime: "tomorrow"}
|
||||
err := runList(context.Background(), opts, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, &fakeListSvc{}, "kb_xxx")
|
||||
require.Error(t, err)
|
||||
var typed *cmdutil.Error
|
||||
require.ErrorAs(t, err, &typed)
|
||||
assert.Equal(t, cmdutil.CodeInputInvalidArgument, typed.Code)
|
||||
assert.Contains(t, typed.Message, "--start-time")
|
||||
assert.Contains(t, typed.Message, "RFC3339")
|
||||
}
|
||||
|
||||
func TestList_EndTime_InvalidFormat_Rejected(t *testing.T) {
|
||||
_, _ = iostreams.SetForTest(t)
|
||||
opts := &ListOptions{PageSize: 20, Limit: 30, EndTime: "2026-05-01"} // date-only, not RFC3339
|
||||
err := runList(context.Background(), opts, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, &fakeListSvc{}, "kb_xxx")
|
||||
require.Error(t, err)
|
||||
var typed *cmdutil.Error
|
||||
require.ErrorAs(t, err, &typed)
|
||||
assert.Equal(t, cmdutil.CodeInputInvalidArgument, typed.Code)
|
||||
assert.Contains(t, typed.Message, "--end-time")
|
||||
}
|
||||
|
||||
// TestList_JSON_TotalCount_SinglePage asserts that meta.total_count is populated
|
||||
// from the server total when doing a single-page fetch.
|
||||
func TestList_JSON_TotalCount_SinglePage(t *testing.T) {
|
||||
out, _ := iostreams.SetForTest(t)
|
||||
svc := &fakeListSvc{
|
||||
items: []sdk.Knowledge{{ID: "doc1", FileName: "a.pdf"}},
|
||||
total: 99,
|
||||
}
|
||||
opts := &ListOptions{PageSize: 20, Limit: 30}
|
||||
require.NoError(t, runList(context.Background(), opts, &cmdutil.FormatOptions{Mode: cmdutil.FormatJSON}, svc, "kb_xxx"))
|
||||
body := out.String()
|
||||
assert.Contains(t, body, `"total_count":99`, "single-page fetch must surface server total in meta.total_count")
|
||||
}
|
||||
|
||||
// TestList_JSON_TotalCount_AllPages asserts meta.total_count is populated
|
||||
// from the server total when walking all pages.
|
||||
func TestList_JSON_TotalCount_AllPages(t *testing.T) {
|
||||
out, _ := iostreams.SetForTest(t)
|
||||
svc := &pagedDocSvc{all: makeDocs(45)}
|
||||
opts := &ListOptions{PageSize: 20, Limit: 10000, AllPages: true}
|
||||
require.NoError(t, runList(context.Background(), opts, &cmdutil.FormatOptions{Mode: cmdutil.FormatJSON}, svc, "kb_xxx"))
|
||||
body := out.String()
|
||||
assert.Contains(t, body, `"total_count":45`, "--all-pages fetch must surface server total in meta.total_count")
|
||||
}
|
||||
|
||||
// TestList_JSON_TotalCount_Zero_Present asserts that when server returns total=0
|
||||
// on an empty list, meta.total_count (and meta.count) still serialize as 0. The
|
||||
// *int + omitempty pattern omits only nil, so the agent contract stays stable:
|
||||
// an empty result reports 0, not a missing key.
|
||||
func TestList_JSON_TotalCount_Zero_Present(t *testing.T) {
|
||||
out, _ := iostreams.SetForTest(t)
|
||||
svc := &fakeListSvc{items: nil, total: 0}
|
||||
opts := &ListOptions{PageSize: 20, Limit: 30}
|
||||
require.NoError(t, runList(context.Background(), opts, &cmdutil.FormatOptions{Mode: cmdutil.FormatJSON}, svc, "kb_xxx"))
|
||||
body := out.String()
|
||||
assert.Contains(t, body, `"total_count":0`, "zero server total must serialize as 0 on empty list")
|
||||
assert.Contains(t, body, `"count":0`, "empty list must report count:0, not omit it")
|
||||
}
|
||||
|
||||
// TestList_AllFiltersCombined drives every new filter flag at once to confirm
|
||||
// they all land on the same filter struct (AND combine on the server).
|
||||
func TestList_AllFiltersCombined(t *testing.T) {
|
||||
_, _ = iostreams.SetForTest(t)
|
||||
svc := &fakeListSvc{}
|
||||
opts := &ListOptions{
|
||||
PageSize: 20,
|
||||
Limit: 30,
|
||||
Status: "completed",
|
||||
Keyword: "spec",
|
||||
FileType: "pdf",
|
||||
Source: "api",
|
||||
TagID: "tag_42",
|
||||
StartTime: "2026-01-01T00:00:00Z",
|
||||
EndTime: "2026-12-31T23:59:59Z",
|
||||
}
|
||||
require.NoError(t, runList(context.Background(), opts, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, svc, "kb_xxx"))
|
||||
f := svc.got.filter
|
||||
assert.Equal(t, "completed", f.ParseStatus)
|
||||
assert.Equal(t, "spec", f.Keyword)
|
||||
assert.Equal(t, "pdf", f.FileType)
|
||||
assert.Equal(t, "api", f.Source)
|
||||
assert.Equal(t, "tag_42", f.TagID)
|
||||
assert.False(t, f.StartTime.IsZero())
|
||||
assert.False(t, f.EndTime.IsZero())
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
package doc
|
||||
|
||||
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"
|
||||
)
|
||||
|
||||
// docReparseFields enumerates the fields surfaced for `--format json` discovery
|
||||
// on `doc reparse`. The result is the Knowledge object; the caller mainly wants
|
||||
// the id and the reset parse_status.
|
||||
var docReparseFields = []string{"id", "parse_status", "file_name", "title"}
|
||||
|
||||
// ReparseOptions captures `doc reparse` flag state.
|
||||
type ReparseOptions struct {
|
||||
DryRun bool
|
||||
}
|
||||
|
||||
// ReparseService is the narrow SDK surface this command depends on.
|
||||
type ReparseService interface {
|
||||
ReparseKnowledge(ctx context.Context, id string) (*sdk.Knowledge, error)
|
||||
}
|
||||
|
||||
// NewCmdReparse builds `weknora doc reparse <doc-id>`.
|
||||
func NewCmdReparse(f *cmdutil.Factory) *cobra.Command {
|
||||
opts := &ReparseOptions{}
|
||||
cmd := &cobra.Command{
|
||||
Use: "reparse <doc-id>",
|
||||
Short: "Re-run parsing on a document",
|
||||
Long: `Re-trigger server-side parsing of a document — the recovery path when a
|
||||
document's parse_status is failed, or after its source changed. The document
|
||||
keeps its id; parsing restarts asynchronously, so follow with
|
||||
'weknora doc wait <doc-id>' to block until it reaches a terminal status.`,
|
||||
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())
|
||||
if handled, err := cmdutil.HandleDryRun(c, opts.DryRun, cmdutil.DryRunPlan{
|
||||
Action: "doc.reparse",
|
||||
Args: map[string]any{"doc": args[0]},
|
||||
}); handled {
|
||||
return err
|
||||
}
|
||||
cli, err := f.Client()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return runReparse(c.Context(), opts, fopts, cli, args[0])
|
||||
},
|
||||
}
|
||||
cmdutil.AddIgnoredKBFlag(cmd)
|
||||
cmdutil.AddFormatFlag(cmd, docReparseFields...)
|
||||
cmdutil.AddDryRunFlag(cmd, &opts.DryRun)
|
||||
cmdutil.SetAgentHelp(cmd, cmdutil.AgentHelp{
|
||||
UsedFor: "re-run parsing on a document (recovery after parse_status=failed, or a changed source)",
|
||||
RequiredFlags: []string{"<doc-id> (positional)"},
|
||||
Examples: []string{
|
||||
"weknora doc reparse doc_abc",
|
||||
"weknora doc reparse doc_abc --format json",
|
||||
},
|
||||
Output: "envelope.data is the Knowledge object with parse_status reset to the re-processing state; poll `weknora doc wait <doc-id>` for completion",
|
||||
})
|
||||
return cmd
|
||||
}
|
||||
|
||||
func runReparse(ctx context.Context, opts *ReparseOptions, fopts *cmdutil.FormatOptions, svc ReparseService, id string) error {
|
||||
k, err := svc.ReparseKnowledge(ctx, id)
|
||||
if err != nil {
|
||||
return cmdutil.WrapHTTP(err, "reparse document %s", id)
|
||||
}
|
||||
if fopts.WantsJSON() {
|
||||
return fopts.Emit(iostreams.IO.Out, k, nil)
|
||||
}
|
||||
// fileName → title, with no id fallback (empty means "skip the suffix").
|
||||
name := text.KnowledgeDisplayName(k.FileName, k.Title, "")
|
||||
w := iostreams.IO.Out
|
||||
fmt.Fprintf(w, "✓ reparse triggered for %s", id)
|
||||
if name != "" {
|
||||
fmt.Fprintf(w, " (%s)", name)
|
||||
}
|
||||
fmt.Fprintln(w)
|
||||
if k.ParseStatus != "" {
|
||||
fmt.Fprintf(w, " parse_status: %s\n", k.ParseStatus)
|
||||
}
|
||||
fmt.Fprintf(w, " next: weknora doc wait %s\n", id)
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
package doc
|
||||
|
||||
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 fakeReparseSvc struct {
|
||||
k *sdk.Knowledge
|
||||
err error
|
||||
called bool
|
||||
}
|
||||
|
||||
func (f *fakeReparseSvc) ReparseKnowledge(_ context.Context, id string) (*sdk.Knowledge, error) {
|
||||
f.called = true
|
||||
if f.err != nil {
|
||||
return nil, f.err
|
||||
}
|
||||
k := *f.k
|
||||
k.ID = id
|
||||
return &k, nil
|
||||
}
|
||||
|
||||
func TestDocReparse_Text(t *testing.T) {
|
||||
out, _ := iostreams.SetForTest(t)
|
||||
svc := &fakeReparseSvc{k: &sdk.Knowledge{FileName: "runbook.md", ParseStatus: "processing"}}
|
||||
require.NoError(t, runReparse(context.Background(), &ReparseOptions{}, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, svc, "doc_abc"))
|
||||
assert.True(t, svc.called)
|
||||
got := out.String()
|
||||
assert.Contains(t, got, "doc_abc")
|
||||
assert.Contains(t, got, "runbook.md")
|
||||
assert.Contains(t, got, "processing")
|
||||
assert.Contains(t, got, "doc wait doc_abc")
|
||||
}
|
||||
|
||||
func TestDocReparse_JSON(t *testing.T) {
|
||||
out, _ := iostreams.SetForTest(t)
|
||||
svc := &fakeReparseSvc{k: &sdk.Knowledge{ParseStatus: "processing"}}
|
||||
require.NoError(t, runReparse(context.Background(), &ReparseOptions{}, &cmdutil.FormatOptions{Mode: cmdutil.FormatJSON}, svc, "doc_abc"))
|
||||
var env struct {
|
||||
OK bool `json:"ok"`
|
||||
Data sdk.Knowledge `json:"data"`
|
||||
}
|
||||
require.NoError(t, json.Unmarshal(out.Bytes(), &env), "got %q", out.String())
|
||||
assert.True(t, env.OK)
|
||||
assert.Equal(t, "doc_abc", env.Data.ID)
|
||||
assert.Equal(t, "processing", env.Data.ParseStatus)
|
||||
}
|
||||
|
||||
func TestDocReparse_NotFound(t *testing.T) {
|
||||
_, _ = iostreams.SetForTest(t)
|
||||
svc := &fakeReparseSvc{err: errors.New("HTTP error 404: not found")}
|
||||
err := runReparse(context.Background(), &ReparseOptions{}, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, svc, "missing")
|
||||
require.Error(t, err)
|
||||
assert.True(t, cmdutil.IsNotFound(err))
|
||||
}
|
||||
|
||||
// TestDocReparse_DryRun_NoServerCall: --dry-run must emit a doc.reparse plan
|
||||
// (exit 0) without reaching the server, honoring the mutation-preview contract.
|
||||
func TestDocReparse_DryRun_NoServerCall(t *testing.T) {
|
||||
out, _ := iostreams.SetForTest(t)
|
||||
root := withRootHarnessDoc(NewCmdReparse(docDryRunFactory(t)), "doc_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, "doc.reparse", env.Meta.Plan["action"])
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
package doc
|
||||
|
||||
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"
|
||||
)
|
||||
|
||||
// docUpdateFields enumerates the fields surfaced for `--format json` discovery
|
||||
// on `doc update`. The result is the updated Knowledge object.
|
||||
var docUpdateFields = []string{"id", "title", "description", "file_name", "parse_status"}
|
||||
|
||||
// UpdateOptions captures `doc update` flag state. Title/Description are *string
|
||||
// so an unset flag is distinguishable from "set to empty" — only fields the
|
||||
// user passed are changed; the rest are preserved via fetch-then-update.
|
||||
type UpdateOptions struct {
|
||||
Title *string
|
||||
Description *string
|
||||
Yes bool // sourced from global -y/--yes persistent flag
|
||||
DryRun bool
|
||||
}
|
||||
|
||||
// UpdateService is the narrow SDK surface this command depends on. The server's
|
||||
// UpdateKnowledge endpoint persists only title and description (other fields on
|
||||
// the PUT body are ignored), so this command edits just those two; GetKnowledge
|
||||
// supplies the fetch half of the fetch-then-update flow.
|
||||
type UpdateService interface {
|
||||
GetKnowledge(ctx context.Context, id string) (*sdk.Knowledge, error)
|
||||
UpdateKnowledge(ctx context.Context, k *sdk.Knowledge) error
|
||||
}
|
||||
|
||||
// NewCmdUpdate builds `weknora doc update <doc-id>`. At least one of
|
||||
// --title / --description must be provided.
|
||||
func NewCmdUpdate(f *cmdutil.Factory) *cobra.Command {
|
||||
opts := &UpdateOptions{}
|
||||
var title, desc string
|
||||
cmd := &cobra.Command{
|
||||
Use: "update <doc-id>",
|
||||
Short: "Update a document's title or description",
|
||||
Long: `Update a document's title and/or description. At least one of --title /
|
||||
--description must be supplied; the document's content, file, and parse state are
|
||||
untouched (only the title and description are editable — re-upload to change the
|
||||
content).
|
||||
|
||||
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("title").Changed {
|
||||
opts.Title = &title
|
||||
}
|
||||
if c.Flag("description").Changed {
|
||||
opts.Description = &desc
|
||||
}
|
||||
id := args[0]
|
||||
// Validate "at least one mutation flag" before the dry-run gate so
|
||||
// --dry-run rejects identically to the live path.
|
||||
if opts.Title == nil && opts.Description == nil {
|
||||
return errNoUpdateFlag()
|
||||
}
|
||||
planArgs := map[string]any{"doc": id}
|
||||
if opts.Title != nil {
|
||||
planArgs["title"] = *opts.Title
|
||||
}
|
||||
if opts.Description != nil {
|
||||
planArgs["description"] = *opts.Description
|
||||
}
|
||||
if handled, err := cmdutil.HandleDryRun(c, opts.DryRun, cmdutil.DryRunPlan{
|
||||
Action: "doc.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", "doc", "update", id}, "title", "description", "format")
|
||||
if err := cmdutil.ConfirmWrite(f.Prompter(), opts.Yes, fopts.WantsJSON(), "update", "document", id, "doc.update", retryCmd); err != nil {
|
||||
return err
|
||||
}
|
||||
return runUpdate(c.Context(), opts, fopts, cli, id)
|
||||
},
|
||||
}
|
||||
cmd.Flags().StringVar(&title, "title", "", "New title (omit to leave unchanged)")
|
||||
cmd.Flags().StringVar(&desc, "description", "", "New description (omit to leave unchanged)")
|
||||
cmdutil.AddIgnoredKBFlag(cmd)
|
||||
cmdutil.AddFormatFlag(cmd, docUpdateFields...)
|
||||
cmdutil.AddDryRunFlag(cmd, &opts.DryRun)
|
||||
cmdutil.SetWriteRisk(cmd, "doc.update")
|
||||
cmdutil.SetAgentHelp(cmd, cmdutil.AgentHelp{
|
||||
UsedFor: "update a document's title or description (content is not editable — re-upload for that)",
|
||||
RequiredFlags: []string{"<doc-id> (positional)", "--title or --description (at least one)"},
|
||||
Examples: []string{
|
||||
`weknora doc update doc_abc --title "Q3 Runbook" -y`,
|
||||
`weknora doc update doc_abc --description "archived" --format json -y`,
|
||||
},
|
||||
Output: "envelope.data is the Knowledge object with the new title/description",
|
||||
Warnings: []string{
|
||||
"Requires explicit user approval (exit 10 / input.confirmation_required); never auto-add -y.",
|
||||
},
|
||||
})
|
||||
return cmd
|
||||
}
|
||||
|
||||
func errNoUpdateFlag() error {
|
||||
return &cmdutil.Error{
|
||||
Code: cmdutil.CodeInputMissingFlag,
|
||||
Message: "doc update requires at least one of --title or --description",
|
||||
Hint: "pass --title <title> and/or --description <desc>",
|
||||
}
|
||||
}
|
||||
|
||||
func runUpdate(ctx context.Context, opts *UpdateOptions, fopts *cmdutil.FormatOptions, svc UpdateService, id string) error {
|
||||
if opts.Title == nil && opts.Description == nil {
|
||||
return errNoUpdateFlag()
|
||||
}
|
||||
// Fetch-then-update: load the current record, overlay only the fields the
|
||||
// user set, and PUT the whole object. TOCTOU note: a concurrent writer could
|
||||
// change the record between the Get and Put (same window as kb update).
|
||||
current, err := svc.GetKnowledge(ctx, id)
|
||||
if err != nil {
|
||||
return cmdutil.WrapHTTP(err, "fetch document %s", id)
|
||||
}
|
||||
if opts.Title != nil {
|
||||
current.Title = *opts.Title
|
||||
}
|
||||
if opts.Description != nil {
|
||||
current.Description = *opts.Description
|
||||
}
|
||||
if err := svc.UpdateKnowledge(ctx, current); err != nil {
|
||||
return cmdutil.WrapHTTP(err, "update document %s", id)
|
||||
}
|
||||
if fopts.WantsJSON() {
|
||||
return fopts.Emit(iostreams.IO.Out, current, nil)
|
||||
}
|
||||
fmt.Fprintf(iostreams.IO.Out, "✓ Updated document %s\n", id)
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
package doc
|
||||
|
||||
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"
|
||||
sdk "github.com/Tencent/WeKnora/client"
|
||||
)
|
||||
|
||||
type fakeUpdateSvc struct {
|
||||
current *sdk.Knowledge
|
||||
getErr error
|
||||
updateErr error
|
||||
got *sdk.Knowledge // captured object passed to UpdateKnowledge
|
||||
}
|
||||
|
||||
func (f *fakeUpdateSvc) GetKnowledge(_ context.Context, id string) (*sdk.Knowledge, error) {
|
||||
if f.getErr != nil {
|
||||
return nil, f.getErr
|
||||
}
|
||||
c := *f.current
|
||||
c.ID = id
|
||||
return &c, nil
|
||||
}
|
||||
|
||||
func (f *fakeUpdateSvc) UpdateKnowledge(_ context.Context, k *sdk.Knowledge) error {
|
||||
f.got = k
|
||||
return f.updateErr
|
||||
}
|
||||
|
||||
func strptr(s string) *string { return &s }
|
||||
|
||||
func TestDocUpdate_Title_FetchThenUpdate(t *testing.T) {
|
||||
out, _ := iostreams.SetForTest(t)
|
||||
svc := &fakeUpdateSvc{current: &sdk.Knowledge{Title: "Old", Description: "keep", FileName: "f.md"}}
|
||||
require.NoError(t, runUpdate(context.Background(),
|
||||
&UpdateOptions{Title: strptr("New")}, &cmdutil.FormatOptions{Mode: cmdutil.FormatJSON}, svc, "doc_abc"))
|
||||
// Only title changed; description preserved from the fetched record.
|
||||
require.NotNil(t, svc.got)
|
||||
assert.Equal(t, "New", svc.got.Title)
|
||||
assert.Equal(t, "keep", svc.got.Description)
|
||||
var env struct {
|
||||
OK bool `json:"ok"`
|
||||
Data sdk.Knowledge `json:"data"`
|
||||
}
|
||||
require.NoError(t, json.Unmarshal(out.Bytes(), &env), "got %q", out.String())
|
||||
assert.True(t, env.OK)
|
||||
assert.Equal(t, "doc_abc", env.Data.ID)
|
||||
assert.Equal(t, "New", env.Data.Title)
|
||||
}
|
||||
|
||||
func TestDocUpdate_DescriptionOnly_PreservesTitle(t *testing.T) {
|
||||
_, _ = iostreams.SetForTest(t)
|
||||
svc := &fakeUpdateSvc{current: &sdk.Knowledge{Title: "Keep", Description: "old"}}
|
||||
require.NoError(t, runUpdate(context.Background(),
|
||||
&UpdateOptions{Description: strptr("fresh")}, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, svc, "doc_abc"))
|
||||
assert.Equal(t, "Keep", svc.got.Title)
|
||||
assert.Equal(t, "fresh", svc.got.Description)
|
||||
}
|
||||
|
||||
func TestDocUpdate_NoFlags_MissingFlag(t *testing.T) {
|
||||
_, _ = iostreams.SetForTest(t)
|
||||
svc := &fakeUpdateSvc{current: &sdk.Knowledge{}}
|
||||
err := runUpdate(context.Background(), &UpdateOptions{}, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, svc, "doc_abc")
|
||||
var typed *cmdutil.Error
|
||||
require.True(t, errors.As(err, &typed))
|
||||
assert.Equal(t, cmdutil.CodeInputMissingFlag, typed.Code)
|
||||
assert.Nil(t, svc.got, "must not call UpdateKnowledge when no flag is set")
|
||||
}
|
||||
|
||||
func TestDocUpdate_NotFound(t *testing.T) {
|
||||
_, _ = iostreams.SetForTest(t)
|
||||
svc := &fakeUpdateSvc{getErr: errors.New("HTTP error 404: not found")}
|
||||
err := runUpdate(context.Background(),
|
||||
&UpdateOptions{Title: strptr("x")}, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, svc, "missing")
|
||||
require.Error(t, err)
|
||||
assert.True(t, cmdutil.IsNotFound(err))
|
||||
}
|
||||
|
||||
// TestDocUpdate_DryRun_NoServerCall: --dry-run emits a doc.update plan (exit 0)
|
||||
// without reaching the server.
|
||||
func TestDocUpdate_DryRun_NoServerCall(t *testing.T) {
|
||||
out, _ := iostreams.SetForTest(t)
|
||||
root := withRootHarnessDoc(NewCmdUpdate(docDryRunFactory(t)), "doc_x", "--title", "T", "--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.Meta.DryRun)
|
||||
assert.Equal(t, "doc.update", env.Meta.Plan["action"])
|
||||
}
|
||||
|
||||
// TestDocUpdate_RequiresConfirmation asserts that without -y (non-TTY / JSON
|
||||
// mode), doc update returns input.confirmation_required (exit 10) — parity with
|
||||
// kb/agent update gating (AGENTS.md §3.1: all three updates are confirmation-gated).
|
||||
func TestDocUpdate_RequiresConfirmation(t *testing.T) {
|
||||
iostreams.SetForTest(t) // non-TTY
|
||||
f := &cmdutil.Factory{
|
||||
Client: func() (*sdk.Client, error) { return nil, nil },
|
||||
Prompter: func() prompt.Prompter { return prompt.AgentPrompter{} },
|
||||
}
|
||||
root := withRootHarnessDoc(NewCmdUpdate(f), "doc_abc", "--title", "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 and the target id
|
||||
assert.Contains(t, ce.RetryArgv, "-y")
|
||||
assert.Contains(t, ce.RetryArgv, "doc_abc")
|
||||
}
|
||||
|
||||
// TestDocUpdate_DryRun_RejectsNoFlag: --dry-run rejects the no-mutation-flag
|
||||
// invocation identically to the live path (validation before the dry-run gate).
|
||||
func TestDocUpdate_DryRun_RejectsNoFlag(t *testing.T) {
|
||||
_, _ = iostreams.SetForTest(t)
|
||||
root := withRootHarnessDoc(NewCmdUpdate(docDryRunFactory(t)), "doc_x", "--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))
|
||||
assert.Equal(t, cmdutil.CodeInputMissingFlag, typed.Code)
|
||||
}
|
||||
@@ -0,0 +1,314 @@
|
||||
package doc
|
||||
|
||||
import (
|
||||
"cmp"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"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"
|
||||
)
|
||||
|
||||
// uploadChannel is the default ingestion-channel tag the server records for
|
||||
// CLI uploads. Distinct from "web" (browser UI), "browser_extension"
|
||||
// (one-click capture), and "wechat" (mini-program). The server uses this only
|
||||
// for analytics. Users can override via --channel for cross-tool replay.
|
||||
const uploadChannel = "api"
|
||||
|
||||
// docUploadFields enumerates the fields surfaced for `--format json` discovery on
|
||||
// `doc upload`. The single-file upload result is the full Knowledge struct;
|
||||
// these are its top-level json tags.
|
||||
var docUploadFields = []string{
|
||||
"id", "knowledge_base_id", "tag_id", "type", "title", "description",
|
||||
"source", "channel", "parse_status", "summary_status", "enable_status",
|
||||
"embedding_model_id", "file_name", "file_type", "file_size", "file_hash",
|
||||
"file_path", "storage_size",
|
||||
"created_at", "updated_at", "processed_at", "error_message",
|
||||
}
|
||||
|
||||
type UploadOptions struct {
|
||||
Name string
|
||||
Recursive bool // --recursive: positional arg is a directory; walk + upload each match
|
||||
Glob string // --glob: filename pattern under --recursive (default "*")
|
||||
|
||||
// EnableMultimodel toggles server-side multimodal extraction
|
||||
// (e.g. images-in-PDF → OCR'd text). nil means "server default" -
|
||||
// the flag was not set. true/false explicitly override.
|
||||
EnableMultimodel *bool
|
||||
|
||||
// Metadata is the raw --metadata key=value list. Parsed into a map
|
||||
// at run-time; empty values allowed, duplicate keys last-wins.
|
||||
Metadata []string
|
||||
|
||||
// Channel overrides the ingestion-channel tag recorded server-side.
|
||||
// Empty ⇒ uploadChannel ("api"). Free-form: server validates.
|
||||
Channel string
|
||||
|
||||
DryRun bool
|
||||
}
|
||||
|
||||
// UploadService is the narrow SDK surface this command depends on.
|
||||
// *sdk.Client satisfies it.
|
||||
type UploadService interface {
|
||||
CreateKnowledgeFromFile(
|
||||
ctx context.Context,
|
||||
kbID, filePath string,
|
||||
metadata map[string]string,
|
||||
enableMultimodel *bool,
|
||||
customFileName, channel string,
|
||||
processConfig *sdk.KnowledgeProcessOverrides,
|
||||
) (*sdk.Knowledge, error)
|
||||
}
|
||||
|
||||
// NewCmdUpload builds `weknora doc upload <file>`.
|
||||
func NewCmdUpload(f *cmdutil.Factory) *cobra.Command {
|
||||
opts := &UploadOptions{}
|
||||
cmd := &cobra.Command{
|
||||
Use: "upload <file>",
|
||||
Short: "Upload a local file to the knowledge base",
|
||||
Long: `Uploads a file (PDF / DOCX / Markdown / TXT / etc.) to the resolved
|
||||
knowledge base. KB resolution follows the standard 4-level chain:
|
||||
--kb flag > WEKNORA_KB_ID env > .weknora/project.yaml > error. The --kb
|
||||
flag accepts either a KB UUID (passed through) or a name (resolved via list).
|
||||
|
||||
Pass --name to override the recorded file name (useful when the local file
|
||||
has a generic name like "report.pdf" but you want to surface it as e.g.
|
||||
"Q3 Marketing Report.pdf" in the UI).
|
||||
|
||||
The two input modes (positional file / --recursive directory walk) are
|
||||
mutually exclusive - pass exactly one. Use --recursive --glob to upload a
|
||||
directory tree (see Examples). To ingest a remote URL use "weknora doc fetch";
|
||||
to create an entry from inline text use "weknora doc create".
|
||||
|
||||
Server-side ingestion knobs:
|
||||
|
||||
--enable-multimodel Toggle multimodal extraction (image-in-PDF → text).
|
||||
Unset ⇒ server default; pass true or false to override.
|
||||
Applies to file / --recursive.
|
||||
--metadata key=value Attach arbitrary key/value metadata. Repeatable.
|
||||
Empty value allowed; duplicate keys ⇒ last-wins.
|
||||
Malformed values (no '=', empty key) ⇒
|
||||
input.invalid_argument.
|
||||
--channel <name> Override the ingestion-channel tag (default "api").
|
||||
Applies to file / --recursive.`,
|
||||
Example: ` weknora doc upload report.pdf
|
||||
weknora doc upload notes.md --kb a32a63ff-fb36-4874-bcaa-30f48570a694
|
||||
weknora doc upload notes.md --kb my-kb
|
||||
weknora doc upload q3.pdf --name "Q3 Marketing Report.pdf"
|
||||
weknora doc upload report.pdf --enable-multimodel --metadata team=alpha --metadata sprint=Q4
|
||||
weknora doc upload ./docs --recursive --glob '*.pdf' --metadata team=alpha`,
|
||||
Args: cobra.MaximumNArgs(1),
|
||||
RunE: func(c *cobra.Command, args []string) error {
|
||||
fopts, err := cmdutil.CheckFormatFlag(c)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
fopts.ResolveDefault(iostreams.IO.IsStdoutTTY())
|
||||
// Pure-local validation runs before the dry-run gate so --dry-run
|
||||
// rejects identically to the live path. Filesystem stat
|
||||
// (validateUploadPath) and recursive directory enumeration are
|
||||
// intentionally skipped under --dry-run — those are side-effect-ish
|
||||
// reads the preview is allowed to defer.
|
||||
if c.Flags().Changed("enable-multimodel") {
|
||||
raw, _ := c.Flags().GetString("enable-multimodel")
|
||||
v, perr := parseTriBool(raw)
|
||||
if perr != nil {
|
||||
return perr
|
||||
}
|
||||
opts.EnableMultimodel = &v
|
||||
}
|
||||
if err := validateUploadFlags(opts, args); err != nil {
|
||||
return err
|
||||
}
|
||||
// Validate --metadata key=value shape upfront; same typed Error as
|
||||
// runUpload (kept there for direct-call callers / batch paths).
|
||||
if _, err := parseMetadataKV(opts.Metadata); err != nil {
|
||||
return err
|
||||
}
|
||||
if opts.DryRun {
|
||||
// Local-only KB resolution: plan reports the raw --kb value
|
||||
// without an SDK lookup.
|
||||
kbID, err := f.ResolveKBLocal(c)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
filePath := args[0]
|
||||
planArgs := map[string]any{
|
||||
"file": filePath,
|
||||
"kb": kbID,
|
||||
}
|
||||
// recursive vs single-file is the blast-radius switch
|
||||
// (N docs vs 1); surface it on the plan so agents can
|
||||
// decide whether to gate on user approval.
|
||||
if opts.Recursive {
|
||||
planArgs["recursive"] = true
|
||||
}
|
||||
if opts.Glob != "" {
|
||||
planArgs["glob"] = opts.Glob
|
||||
}
|
||||
if handled, err := cmdutil.HandleDryRun(c, true, cmdutil.DryRunPlan{
|
||||
Action: "doc.upload",
|
||||
Args: planArgs,
|
||||
}); handled {
|
||||
return err
|
||||
}
|
||||
}
|
||||
cli, err := f.Client()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
kbID, err := f.ResolveKB(c)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if opts.Recursive {
|
||||
return runUploadRecursive(c.Context(), opts, fopts, cli, kbID, args[0])
|
||||
}
|
||||
if err := validateUploadPath(args[0]); err != nil {
|
||||
return err
|
||||
}
|
||||
return runUpload(c.Context(), opts, fopts, cli, kbID, args[0])
|
||||
},
|
||||
}
|
||||
cmdutil.AddKBFlag(cmd)
|
||||
cmd.Flags().StringVar(&opts.Name, "name", "", "Custom file name to record (defaults to base name)")
|
||||
cmd.Flags().BoolVar(&opts.Recursive, "recursive", false, "Treat the positional argument as a directory to walk")
|
||||
cmd.Flags().StringVar(&opts.Glob, "glob", "*", "Filename pattern to filter when --recursive (e.g. '*.pdf')")
|
||||
// Tri-state flag: unset ⇒ server default, "true"/"false" override. The
|
||||
// raw string is decoded into opts.EnableMultimodel in RunE.
|
||||
cmd.Flags().String("enable-multimodel", "", "Toggle multimodal extraction (true|false); unset ⇒ server default")
|
||||
cmd.Flags().Lookup("enable-multimodel").NoOptDefVal = "true"
|
||||
cmd.Flags().StringSliceVar(&opts.Metadata, "metadata", nil, "Attach metadata `key=value` (repeatable; empty value allowed, last-wins on duplicate keys)")
|
||||
cmd.Flags().StringVar(&opts.Channel, "channel", "", "Ingestion-channel tag recorded server-side (default \"api\")")
|
||||
cmdutil.AddFormatFlag(cmd, docUploadFields...)
|
||||
cmdutil.AddDryRunFlag(cmd, &opts.DryRun)
|
||||
cmdutil.SetAgentHelp(cmd, cmdutil.AgentHelp{
|
||||
UsedFor: "Upload a local file to the resolved knowledge base. KB resolved via --kb flag, WEKNORA_KB_ID env, or project link. Emits the created Knowledge object with its id.",
|
||||
RequiredFlags: []string{"<file> (positional)", "--kb (or WEKNORA_KB_ID / project link)"},
|
||||
Examples: []string{
|
||||
`weknora doc upload report.pdf --kb kb_eng`,
|
||||
`weknora doc upload report.pdf --kb kb_eng --jq .data.id # then: doc wait <id>`,
|
||||
},
|
||||
Output: "envelope.data is the created Knowledge object with id, knowledge_base_id, file_name, parse_status",
|
||||
})
|
||||
return cmd
|
||||
}
|
||||
|
||||
// parseTriBool parses the raw --enable-multimodel string into a bool. Bare
|
||||
// --enable-multimodel (no value) is treated as "true" via NoOptDefVal at
|
||||
// registration time; callers gate on Changed() so an unset flag never gets
|
||||
// here. An explicit empty string (e.g. --enable-multimodel="" from an
|
||||
// uninterpolated shell variable) is rejected as input.invalid_argument
|
||||
// rather than silently coerced.
|
||||
func parseTriBool(raw string) (bool, error) {
|
||||
switch strings.ToLower(strings.TrimSpace(raw)) {
|
||||
case "true", "1", "yes":
|
||||
return true, nil
|
||||
case "false", "0", "no":
|
||||
return false, nil
|
||||
default:
|
||||
return false, cmdutil.NewError(cmdutil.CodeInputInvalidArgument,
|
||||
fmt.Sprintf("--enable-multimodel expects true|false, got %q", raw))
|
||||
}
|
||||
}
|
||||
|
||||
// parseMetadataKV converts the raw --metadata key=value slice into a map.
|
||||
// Empty values are allowed. Duplicate keys ⇒ last-wins. Returns nil when
|
||||
// the slice is empty so callers pass nil through to the SDK unchanged.
|
||||
func parseMetadataKV(raw []string) (map[string]string, error) {
|
||||
if len(raw) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
out := make(map[string]string, len(raw))
|
||||
for _, kv := range raw {
|
||||
eq := strings.IndexByte(kv, '=')
|
||||
if eq <= 0 {
|
||||
return nil, cmdutil.NewError(cmdutil.CodeInputInvalidArgument,
|
||||
fmt.Sprintf("--metadata expects key=value (got %q)", kv))
|
||||
}
|
||||
out[kv[:eq]] = kv[eq+1:]
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// validateUploadFlags enforces mutual exclusion between the two input modes
|
||||
// (positional file path / --recursive directory walk) and requires that at
|
||||
// least one input mode is supplied.
|
||||
func validateUploadFlags(opts *UploadOptions, args []string) error {
|
||||
hasPath := len(args) == 1
|
||||
if !hasPath {
|
||||
// Wrap as FlagError so the exit code (2) matches what cobra's own
|
||||
// MinimumNArgs(1) would emit — consistent with every other command
|
||||
// that requires a positional argument.
|
||||
return cmdutil.NewFlagError(errors.New(
|
||||
"a file path is required (or use `weknora doc fetch` for URLs, `weknora doc create` for inline text)"))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// renderUploadSuccess emits the post-upload result. JSON path is the bare
|
||||
// Knowledge object; text path prints a checkmark line. Shared by single-
|
||||
// file upload and URL ingest; verb varies (uploaded/ingested) and
|
||||
// fallbackDisplay covers the case when the server-recorded file_name is
|
||||
// blank (URL ingest pre-redirect).
|
||||
func renderUploadSuccess(k *sdk.Knowledge, fopts *cmdutil.FormatOptions, verb, customName, fallbackDisplay string) error {
|
||||
if fopts.WantsJSON() {
|
||||
return fopts.Emit(iostreams.IO.Out, k, nil)
|
||||
}
|
||||
displayed := customName
|
||||
if displayed == "" {
|
||||
displayed = k.FileName
|
||||
}
|
||||
if displayed == "" {
|
||||
displayed = fallbackDisplay
|
||||
}
|
||||
fmt.Fprintf(iostreams.IO.Out, "✓ %s %q (id: %s)\n", verb, displayed, k.ID)
|
||||
return nil
|
||||
}
|
||||
|
||||
// validateUploadPath checks that path exists and refers to a regular file.
|
||||
// Symlinks and directories are rejected up-front so users get a typed error
|
||||
// instead of an opaque SDK failure mid-upload. os.Stat (not Lstat) is used
|
||||
// here so a symlink to a regular file is accepted - that matches what
|
||||
// `cp` / `git add` do, and the SDK opens the file via os.Open which follows
|
||||
// symlinks anyway.
|
||||
func validateUploadPath(path string) error {
|
||||
info, err := os.Stat(path)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return cmdutil.Wrapf(cmdutil.CodeUploadFileNotFound, err, "file not found: %s", path)
|
||||
}
|
||||
return cmdutil.Wrapf(cmdutil.CodeLocalFileIO, err, "stat %s", path)
|
||||
}
|
||||
if !info.Mode().IsRegular() {
|
||||
return cmdutil.NewError(cmdutil.CodeInputInvalidArgument,
|
||||
fmt.Sprintf("not a regular file: %s (directories and devices are not supported)", path))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func runUpload(ctx context.Context, opts *UploadOptions, fopts *cmdutil.FormatOptions, svc UploadService, kbID, path string) error {
|
||||
meta, err := parseMetadataKV(opts.Metadata)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
k, err := svc.CreateKnowledgeFromFile(ctx, kbID, path, meta, opts.EnableMultimodel, opts.Name, cmp.Or(opts.Channel, uploadChannel), nil)
|
||||
if err != nil {
|
||||
if errors.Is(err, sdk.ErrDuplicateFile) {
|
||||
// SDK returns sentinel without an "HTTP error <status>:" prefix
|
||||
// (the duplicate is detected by file hash, not by status code),
|
||||
// so WrapHTTP would misclassify it as network.error.
|
||||
return cmdutil.Wrapf(cmdutil.CodeResourceAlreadyExists, err,
|
||||
"file already uploaded to this knowledge base")
|
||||
}
|
||||
return cmdutil.WrapHTTP(err, "upload %s", path)
|
||||
}
|
||||
return renderUploadSuccess(k, fopts, "Uploaded", opts.Name, path)
|
||||
}
|
||||
@@ -0,0 +1,184 @@
|
||||
package doc
|
||||
|
||||
import (
|
||||
"cmp"
|
||||
"context"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/Tencent/WeKnora/cli/internal/cmdutil"
|
||||
"github.com/Tencent/WeKnora/cli/internal/iostreams"
|
||||
"github.com/Tencent/WeKnora/cli/internal/output"
|
||||
)
|
||||
|
||||
// uploadedFile holds the server-side result for a successful per-file upload.
|
||||
// Keyed by file path in the uploadResults map populated by the RunBatch closure.
|
||||
type uploadedFile struct {
|
||||
ID string
|
||||
Name string
|
||||
}
|
||||
|
||||
// runUploadRecursive walks dir, filters by Glob, and uploads each match
|
||||
// sequentially. Per-file errors do NOT abort the walk - they accumulate
|
||||
// and the final return aggregates them so the user sees the full picture
|
||||
// in one run. Exit semantics: nil error on full success, a typed *cmdutil.Error
|
||||
// when ≥1 file failed (the typed code mirrors the first failure's
|
||||
// classification so callers can still branch).
|
||||
func runUploadRecursive(ctx context.Context, opts *UploadOptions, fopts *cmdutil.FormatOptions, svc UploadService, kbID, dir string) error {
|
||||
if opts.Name != "" {
|
||||
return &cmdutil.Error{
|
||||
Code: cmdutil.CodeInputInvalidArgument,
|
||||
Message: "--name cannot be combined with --recursive (one name can't apply to N files)",
|
||||
Hint: "drop --name or upload files one at a time",
|
||||
}
|
||||
}
|
||||
// Parse --metadata up front so a malformed value aborts before the
|
||||
// first SDK call - otherwise a typo in `key=value` would only surface
|
||||
// per-file as repeated identical errors.
|
||||
meta, err := parseMetadataKV(opts.Metadata)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
info, err := os.Stat(dir)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return cmdutil.Wrapf(cmdutil.CodeUploadFileNotFound, err, "directory not found: %s", dir)
|
||||
}
|
||||
return cmdutil.Wrapf(cmdutil.CodeLocalFileIO, err, "stat %s", dir)
|
||||
}
|
||||
if !info.IsDir() {
|
||||
return &cmdutil.Error{
|
||||
Code: cmdutil.CodeInputInvalidArgument,
|
||||
Message: fmt.Sprintf("not a directory: %s (drop --recursive to upload a single file)", dir),
|
||||
}
|
||||
}
|
||||
|
||||
// Sanity-check the pattern up front so a typo doesn't show up as "no
|
||||
// files matched" per-file. Cobra populates --glob; tests pass it
|
||||
// explicitly - no in-function default needed.
|
||||
if _, err := filepath.Match(opts.Glob, ""); err != nil {
|
||||
return &cmdutil.Error{
|
||||
Code: cmdutil.CodeInputInvalidArgument,
|
||||
Message: fmt.Sprintf("invalid --glob %q: %v", opts.Glob, err),
|
||||
}
|
||||
}
|
||||
|
||||
matches, err := walkMatches(dir, opts.Glob)
|
||||
if err != nil {
|
||||
return cmdutil.Wrapf(cmdutil.CodeLocalFileIO, err, "walk %s", dir)
|
||||
}
|
||||
if len(matches) == 0 {
|
||||
if fopts.WantsJSON() {
|
||||
// Empty batch: emit an all-success envelope with zero items.
|
||||
return output.WriteBatchEnvelope(iostreams.IO.Out, nil, fopts.TTY, cmdutil.GetProfile())
|
||||
}
|
||||
fmt.Fprintf(iostreams.IO.Out, "(no files matched %q under %s)\n", opts.Glob, dir)
|
||||
return nil
|
||||
}
|
||||
|
||||
// uploaded captures per-path server results for successful uploads.
|
||||
// Populated by the RunBatch closure; read by the resultFn below.
|
||||
uploaded := make(map[string]uploadedFile, len(matches))
|
||||
channel := cmp.Or(opts.Channel, uploadChannel)
|
||||
|
||||
outcomes, runErr := cmdutil.RunBatch(ctx, matches, func(ctx context.Context, p string) error {
|
||||
k, err := svc.CreateKnowledgeFromFile(ctx, kbID, p, meta, opts.EnableMultimodel, "", channel, nil)
|
||||
if err != nil {
|
||||
// Per-file progress lines are human progress signal; suppress
|
||||
// under --format json so they don't precede the JSON object on stdout.
|
||||
if !fopts.WantsJSON() {
|
||||
fmt.Fprintf(iostreams.IO.Out, "FAIL %s: %v\n", filepath.Base(p), err)
|
||||
}
|
||||
return err
|
||||
}
|
||||
id, name := "", ""
|
||||
if k != nil {
|
||||
id = k.ID
|
||||
name = k.FileName
|
||||
}
|
||||
uploaded[p] = uploadedFile{ID: id, Name: name}
|
||||
if !fopts.WantsJSON() {
|
||||
fmt.Fprintf(iostreams.IO.Out, "OK %s (id: %s)\n", filepath.Base(p), id)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
|
||||
failures := 0
|
||||
for _, oc := range outcomes {
|
||||
if oc.Err != nil {
|
||||
failures++
|
||||
}
|
||||
}
|
||||
|
||||
if fopts.WantsJSON() {
|
||||
if err := cmdutil.EmitBatch(outcomes, fopts, iostreams.IO.Out, func(p string) any {
|
||||
f := uploaded[p]
|
||||
return map[string]any{"id": f.ID, "name": f.Name}
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
} else {
|
||||
fmt.Fprintf(iostreams.IO.Out, "Uploaded %d, Failed %d\n", len(outcomes)-failures, failures)
|
||||
}
|
||||
|
||||
if runErr != nil {
|
||||
// Silent on the --format json path: the success object above already
|
||||
// carries per-file detail; without Silent the root error handler would
|
||||
// print to stderr in addition. ExitCode still walks Code so the typed
|
||||
// exit-code-by-class contract holds.
|
||||
// Any per-file failure collapses to operation.failed (exit 1) — the
|
||||
// per-file batch envelope carries each file's typed error, which is the
|
||||
// authoritative signal (matches doc/session/chunk batch delete). A
|
||||
// cancelled / timed-out batch keeps its context class (124 / 130) so a
|
||||
// permanent partial failure (e.g. a duplicate) is never misreported as a
|
||||
// retryable 5xx that an agent would loop on.
|
||||
code := cmdutil.CodeOperationFailed
|
||||
if ctxErr := ctx.Err(); ctxErr != nil {
|
||||
code = cmdutil.ClassifyContextErr(ctxErr)
|
||||
}
|
||||
return &cmdutil.Error{
|
||||
Code: code,
|
||||
Message: fmt.Sprintf("%d of %d uploads failed", failures, len(matches)),
|
||||
Silent: fopts.WantsJSON(),
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// walkMatches returns every regular file under root whose base name matches
|
||||
// pattern. Order is filepath.WalkDir's lexical order (stdlib guarantee on
|
||||
// every supported FS), which is deterministic for test assertions.
|
||||
func walkMatches(root, pattern string) ([]string, error) {
|
||||
var out []string
|
||||
err := filepath.WalkDir(root, func(p string, d fs.DirEntry, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if d.IsDir() {
|
||||
return nil
|
||||
}
|
||||
// Skip non-regular files (sockets, devices); the SDK can't upload
|
||||
// them and they'd show as opaque server errors.
|
||||
info, ierr := d.Info()
|
||||
if ierr != nil {
|
||||
return ierr
|
||||
}
|
||||
if !info.Mode().IsRegular() {
|
||||
return nil
|
||||
}
|
||||
ok, merr := filepath.Match(pattern, d.Name())
|
||||
if merr != nil {
|
||||
return merr
|
||||
}
|
||||
if ok {
|
||||
out = append(out, p)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
@@ -0,0 +1,274 @@
|
||||
package doc
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"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"
|
||||
)
|
||||
|
||||
// scriptedUploadSvc records every CreateKnowledgeFromFile call and returns
|
||||
// per-path scripted results.
|
||||
type scriptedUploadSvc struct {
|
||||
results map[string]struct {
|
||||
k *sdk.Knowledge
|
||||
err error
|
||||
}
|
||||
called []string
|
||||
|
||||
// Captures from the most-recent call (every recursive iteration writes
|
||||
// these; tests that want all-rows can extend to slices later).
|
||||
lastMetadata map[string]string
|
||||
lastEnableMultimodel *bool
|
||||
lastChannel string
|
||||
}
|
||||
|
||||
func (s *scriptedUploadSvc) CreateKnowledgeFromFile(
|
||||
_ context.Context,
|
||||
_, filePath string,
|
||||
metadata map[string]string,
|
||||
enableMultimodel *bool,
|
||||
_, channel string,
|
||||
_ *sdk.KnowledgeProcessOverrides,
|
||||
) (*sdk.Knowledge, error) {
|
||||
s.called = append(s.called, filepath.Base(filePath))
|
||||
s.lastMetadata = metadata
|
||||
s.lastEnableMultimodel = enableMultimodel
|
||||
s.lastChannel = channel
|
||||
r, ok := s.results[filepath.Base(filePath)]
|
||||
if !ok {
|
||||
return &sdk.Knowledge{ID: "doc_" + filepath.Base(filePath), FileName: filepath.Base(filePath)}, nil
|
||||
}
|
||||
return r.k, r.err
|
||||
}
|
||||
|
||||
func mkTree(t *testing.T, base string, names ...string) {
|
||||
t.Helper()
|
||||
for _, n := range names {
|
||||
full := filepath.Join(base, n)
|
||||
require.NoError(t, os.MkdirAll(filepath.Dir(full), 0o755))
|
||||
require.NoError(t, os.WriteFile(full, []byte("x"), 0o644))
|
||||
}
|
||||
}
|
||||
|
||||
func TestUploadRecursive_WalksAllFiles(t *testing.T) {
|
||||
out, _ := iostreams.SetForTest(t)
|
||||
dir := t.TempDir()
|
||||
mkTree(t, dir, "a.pdf", "b.pdf", "sub/c.pdf")
|
||||
|
||||
svc := &scriptedUploadSvc{}
|
||||
opts := &UploadOptions{Recursive: true, Glob: "*"}
|
||||
require.NoError(t, runUploadRecursive(context.Background(), opts, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, svc, "kb_xxx", dir))
|
||||
|
||||
sort.Strings(svc.called)
|
||||
assert.Equal(t, []string{"a.pdf", "b.pdf", "c.pdf"}, svc.called)
|
||||
got := out.String()
|
||||
for _, w := range []string{"a.pdf", "b.pdf", "c.pdf", "Uploaded 3"} {
|
||||
assert.Contains(t, got, w)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUploadRecursive_GlobFilter(t *testing.T) {
|
||||
_, _ = iostreams.SetForTest(t)
|
||||
dir := t.TempDir()
|
||||
mkTree(t, dir, "doc.pdf", "ignore.txt", "sub/keep.pdf", "sub/also-ignore.md")
|
||||
|
||||
svc := &scriptedUploadSvc{}
|
||||
opts := &UploadOptions{Recursive: true, Glob: "*.pdf"}
|
||||
require.NoError(t, runUploadRecursive(context.Background(), opts, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, svc, "kb_xxx", dir))
|
||||
|
||||
sort.Strings(svc.called)
|
||||
assert.Equal(t, []string{"doc.pdf", "keep.pdf"}, svc.called)
|
||||
}
|
||||
|
||||
func TestUploadRecursive_PartialFailure_Exits1(t *testing.T) {
|
||||
out, _ := iostreams.SetForTest(t)
|
||||
dir := t.TempDir()
|
||||
mkTree(t, dir, "ok.pdf", "bad.pdf")
|
||||
|
||||
svc := &scriptedUploadSvc{results: map[string]struct {
|
||||
k *sdk.Knowledge
|
||||
err error
|
||||
}{
|
||||
"bad.pdf": {err: errors.New("HTTP error 500: internal")},
|
||||
}}
|
||||
opts := &UploadOptions{Recursive: true, Glob: "*"}
|
||||
err := runUploadRecursive(context.Background(), opts, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, svc, "kb_xxx", dir)
|
||||
require.Error(t, err)
|
||||
|
||||
var typed *cmdutil.Error
|
||||
require.ErrorAs(t, err, &typed)
|
||||
// Any per-file failure aggregates to operation.failed (exit 1), matching the
|
||||
// batch-delete contract — a partial failure (even a permanent one like a
|
||||
// duplicate, which classifies as server.error per-file) must not surface as
|
||||
// a retryable exit 7 an agent would loop on. Per-file codes live in the
|
||||
// batch envelope.
|
||||
assert.Equal(t, cmdutil.CodeOperationFailed, typed.Code)
|
||||
|
||||
got := out.String()
|
||||
assert.Contains(t, got, "OK") // ok.pdf still succeeded
|
||||
assert.Contains(t, got, "FAIL")
|
||||
assert.Contains(t, got, "Uploaded 1")
|
||||
assert.Contains(t, got, "Failed 1")
|
||||
}
|
||||
|
||||
func TestUploadRecursive_NoMatches(t *testing.T) {
|
||||
out, _ := iostreams.SetForTest(t)
|
||||
dir := t.TempDir()
|
||||
mkTree(t, dir, "only.txt")
|
||||
|
||||
svc := &scriptedUploadSvc{}
|
||||
opts := &UploadOptions{Recursive: true, Glob: "*.pdf"}
|
||||
require.NoError(t, runUploadRecursive(context.Background(), opts, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, svc, "kb_xxx", dir))
|
||||
assert.Len(t, svc.called, 0)
|
||||
assert.Contains(t, strings.ToLower(out.String()), "no files matched")
|
||||
}
|
||||
|
||||
func TestUploadRecursive_NotADirectory(t *testing.T) {
|
||||
_, _ = iostreams.SetForTest(t)
|
||||
path := writeTempFile(t, "single.pdf")
|
||||
svc := &scriptedUploadSvc{}
|
||||
err := runUploadRecursive(context.Background(), &UploadOptions{Recursive: true, Glob: "*"}, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, svc, "kb_xxx", path)
|
||||
require.Error(t, err)
|
||||
var typed *cmdutil.Error
|
||||
require.ErrorAs(t, err, &typed)
|
||||
assert.Equal(t, cmdutil.CodeInputInvalidArgument, typed.Code)
|
||||
assert.Contains(t, typed.Message, "directory")
|
||||
}
|
||||
|
||||
func TestUploadRecursive_RejectsNameFlag(t *testing.T) {
|
||||
_, _ = iostreams.SetForTest(t)
|
||||
dir := t.TempDir()
|
||||
mkTree(t, dir, "a.pdf")
|
||||
svc := &scriptedUploadSvc{}
|
||||
opts := &UploadOptions{Recursive: true, Glob: "*", Name: "single-name.pdf"}
|
||||
err := runUploadRecursive(context.Background(), opts, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, svc, "kb_xxx", dir)
|
||||
require.Error(t, err)
|
||||
var typed *cmdutil.Error
|
||||
require.ErrorAs(t, err, &typed)
|
||||
assert.Equal(t, cmdutil.CodeInputInvalidArgument, typed.Code)
|
||||
assert.Contains(t, typed.Message, "--name")
|
||||
}
|
||||
|
||||
func TestUploadRecursive_PropagatesMultimodelAndMetadata(t *testing.T) {
|
||||
_, _ = iostreams.SetForTest(t)
|
||||
dir := t.TempDir()
|
||||
mkTree(t, dir, "a.pdf")
|
||||
|
||||
svc := &scriptedUploadSvc{}
|
||||
mm := true
|
||||
opts := &UploadOptions{
|
||||
Recursive: true,
|
||||
Glob: "*",
|
||||
EnableMultimodel: &mm,
|
||||
Metadata: []string{"team=alpha"},
|
||||
Channel: "browser_extension",
|
||||
}
|
||||
require.NoError(t, runUploadRecursive(context.Background(), opts, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, svc, "kb_xxx", dir))
|
||||
|
||||
require.NotNil(t, svc.lastEnableMultimodel)
|
||||
assert.True(t, *svc.lastEnableMultimodel)
|
||||
assert.Equal(t, map[string]string{"team": "alpha"}, svc.lastMetadata)
|
||||
assert.Equal(t, "browser_extension", svc.lastChannel)
|
||||
}
|
||||
|
||||
func TestUploadRecursive_MetadataInvalid_NoCalls(t *testing.T) {
|
||||
_, _ = iostreams.SetForTest(t)
|
||||
dir := t.TempDir()
|
||||
mkTree(t, dir, "a.pdf")
|
||||
|
||||
svc := &scriptedUploadSvc{}
|
||||
opts := &UploadOptions{Recursive: true, Glob: "*", Metadata: []string{"badformat"}}
|
||||
err := runUploadRecursive(context.Background(), opts, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, svc, "kb_xxx", dir)
|
||||
require.Error(t, err)
|
||||
var typed *cmdutil.Error
|
||||
require.ErrorAs(t, err, &typed)
|
||||
assert.Equal(t, cmdutil.CodeInputInvalidArgument, typed.Code)
|
||||
assert.Empty(t, svc.called, "must fail before any per-file call")
|
||||
}
|
||||
|
||||
// TestUploadRecursive_JSON_BatchEnvelope verifies that --format json emits the
|
||||
// batch envelope shape: {ok, data:[{id,ok,result?|error?}...], meta:{count,successes,failures}}.
|
||||
// The per-item id is the file path; result carries {id, name} from the server.
|
||||
func TestUploadRecursive_JSON_BatchEnvelope(t *testing.T) {
|
||||
out, _ := iostreams.SetForTest(t)
|
||||
dir := t.TempDir()
|
||||
mkTree(t, dir, "ok.pdf", "bad.pdf")
|
||||
|
||||
svc := &scriptedUploadSvc{results: map[string]struct {
|
||||
k *sdk.Knowledge
|
||||
err error
|
||||
}{
|
||||
"bad.pdf": {err: errors.New("HTTP error 500: internal")},
|
||||
}}
|
||||
opts := &UploadOptions{Recursive: true, Glob: "*"}
|
||||
err := runUploadRecursive(context.Background(), opts, &cmdutil.FormatOptions{Mode: cmdutil.FormatJSON}, svc, "kb_xxx", dir)
|
||||
require.Error(t, err) // partial failure → typed error
|
||||
|
||||
var env struct {
|
||||
OK bool `json:"ok"`
|
||||
Data []struct {
|
||||
ID string `json:"id"`
|
||||
OK bool `json:"ok"`
|
||||
Result *struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
} `json:"result,omitempty"`
|
||||
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(out.Bytes(), &env), "must be valid JSON: %s", out.String())
|
||||
|
||||
assert.False(t, env.OK, "top-level ok must be false when any item failed")
|
||||
assert.Equal(t, 2, env.Meta.Count)
|
||||
assert.Equal(t, 1, env.Meta.Successes)
|
||||
assert.Equal(t, 1, env.Meta.Failures)
|
||||
require.Len(t, env.Data, 2)
|
||||
|
||||
// File paths are used as batch item ids; verify both files appear.
|
||||
ids := []string{env.Data[0].ID, env.Data[1].ID}
|
||||
assert.True(t, strings.Contains(ids[0], "ok.pdf") || strings.Contains(ids[1], "ok.pdf"), "ok.pdf must appear in batch data")
|
||||
assert.True(t, strings.Contains(ids[0], "bad.pdf") || strings.Contains(ids[1], "bad.pdf"), "bad.pdf must appear in batch data")
|
||||
|
||||
// The success item must have a result with server id/name.
|
||||
for _, item := range env.Data {
|
||||
if strings.Contains(item.ID, "ok.pdf") {
|
||||
assert.True(t, item.OK)
|
||||
assert.NotNil(t, item.Result)
|
||||
} else {
|
||||
assert.False(t, item.OK)
|
||||
assert.NotNil(t, item.Error)
|
||||
}
|
||||
}
|
||||
|
||||
// --format json must emit exactly ONE JSON document. Per-file "FAIL"/"OK"
|
||||
// progress lines belong on the human path; the typed error is Silent so
|
||||
// the root handler doesn't write anything additional to stdout.
|
||||
body := out.String()
|
||||
assert.NotContains(t, body, "FAIL ", "per-file plain lines must not appear under --format json")
|
||||
assert.NotContains(t, body, "OK ", "per-file plain lines must not appear under --format json")
|
||||
|
||||
var typed *cmdutil.Error
|
||||
require.ErrorAs(t, err, &typed)
|
||||
assert.True(t, typed.Silent, "JSON-path partial failure must be Silent")
|
||||
assert.Equal(t, cmdutil.CodeOperationFailed, typed.Code)
|
||||
}
|
||||
@@ -0,0 +1,321 @@
|
||||
package doc
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"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"
|
||||
)
|
||||
|
||||
// fakeUploadSvc captures call arguments and returns canned responses.
|
||||
type fakeUploadSvc struct {
|
||||
resp *sdk.Knowledge
|
||||
err error
|
||||
got struct {
|
||||
kbID, filePath, customName, channel string
|
||||
metadata map[string]string
|
||||
enableMultimodel *bool
|
||||
}
|
||||
}
|
||||
|
||||
func (f *fakeUploadSvc) CreateKnowledgeFromFile(
|
||||
_ context.Context,
|
||||
kbID, filePath string,
|
||||
metadata map[string]string,
|
||||
enableMultimodel *bool,
|
||||
customFileName, channel string,
|
||||
_ *sdk.KnowledgeProcessOverrides,
|
||||
) (*sdk.Knowledge, error) {
|
||||
f.got.kbID = kbID
|
||||
f.got.filePath = filePath
|
||||
f.got.metadata = metadata
|
||||
f.got.enableMultimodel = enableMultimodel
|
||||
f.got.customName = customFileName
|
||||
f.got.channel = channel
|
||||
return f.resp, f.err
|
||||
}
|
||||
|
||||
// writeTempFile creates a regular file under t.TempDir() with sample content.
|
||||
func writeTempFile(t *testing.T, name string) string {
|
||||
t.Helper()
|
||||
path := filepath.Join(t.TempDir(), name)
|
||||
require.NoError(t, os.WriteFile(path, []byte("hello world"), 0o644))
|
||||
return path
|
||||
}
|
||||
|
||||
func TestUpload_Success_Text(t *testing.T) {
|
||||
out, _ := iostreams.SetForTest(t)
|
||||
path := writeTempFile(t, "report.pdf")
|
||||
svc := &fakeUploadSvc{resp: &sdk.Knowledge{ID: "doc_99", FileName: "report.pdf"}}
|
||||
opts := &UploadOptions{}
|
||||
require.NoError(t, runUpload(context.Background(), opts, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, svc, "kb_xxx", path))
|
||||
|
||||
assert.Equal(t, "kb_xxx", svc.got.kbID)
|
||||
assert.Equal(t, path, svc.got.filePath)
|
||||
assert.Equal(t, "", svc.got.customName, "no --name ⇒ empty (server uses base name)")
|
||||
assert.Equal(t, uploadChannel, svc.got.channel)
|
||||
assert.Nil(t, svc.got.metadata)
|
||||
assert.Nil(t, svc.got.enableMultimodel)
|
||||
|
||||
got := out.String()
|
||||
for _, want := range []string{"✓", "Uploaded", "report.pdf", "doc_99"} {
|
||||
if !strings.Contains(got, want) {
|
||||
t.Errorf("output missing %q in:\n%s", want, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpload_Success_CustomName(t *testing.T) {
|
||||
_, _ = iostreams.SetForTest(t)
|
||||
path := writeTempFile(t, "q3.pdf")
|
||||
svc := &fakeUploadSvc{resp: &sdk.Knowledge{ID: "doc_88", FileName: "q3.pdf"}}
|
||||
opts := &UploadOptions{Name: "Q3 Marketing Report.pdf"}
|
||||
require.NoError(t, runUpload(context.Background(), opts, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, svc, "kb_xxx", path))
|
||||
assert.Equal(t, "Q3 Marketing Report.pdf", svc.got.customName)
|
||||
}
|
||||
|
||||
func TestUpload_Success_JSON(t *testing.T) {
|
||||
out, _ := iostreams.SetForTest(t)
|
||||
path := writeTempFile(t, "a.md")
|
||||
svc := &fakeUploadSvc{resp: &sdk.Knowledge{ID: "doc_77", FileName: "a.md"}}
|
||||
opts := &UploadOptions{}
|
||||
require.NoError(t, runUpload(context.Background(), opts, &cmdutil.FormatOptions{Mode: cmdutil.FormatJSON}, svc, "kb_xxx", path))
|
||||
|
||||
got := out.String()
|
||||
var env struct {
|
||||
OK bool `json:"ok"`
|
||||
Data sdk.Knowledge `json:"data"`
|
||||
}
|
||||
require.NoError(t, json.Unmarshal([]byte(got), &env), "expected valid JSON envelope, got %q", got)
|
||||
assert.True(t, env.OK, "envelope.ok must be true")
|
||||
assert.Equal(t, "doc_77", env.Data.ID, "envelope.data.id must be doc_77")
|
||||
assert.Contains(t, got, `"file_name":"a.md"`)
|
||||
}
|
||||
|
||||
func TestUpload_HTTPError_500(t *testing.T) {
|
||||
_, _ = iostreams.SetForTest(t)
|
||||
path := writeTempFile(t, "x.txt")
|
||||
svc := &fakeUploadSvc{err: errors.New("HTTP error 500: internal")}
|
||||
err := runUpload(context.Background(), &UploadOptions{}, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, svc, "kb_xxx", path)
|
||||
require.Error(t, err)
|
||||
|
||||
var typed *cmdutil.Error
|
||||
require.ErrorAs(t, err, &typed)
|
||||
assert.Equal(t, cmdutil.CodeServerError, typed.Code)
|
||||
}
|
||||
|
||||
func TestUpload_HTTPError_409Conflict(t *testing.T) {
|
||||
_, _ = iostreams.SetForTest(t)
|
||||
path := writeTempFile(t, "dup.pdf")
|
||||
svc := &fakeUploadSvc{err: errors.New("HTTP error 409: file exists")}
|
||||
err := runUpload(context.Background(), &UploadOptions{}, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, svc, "kb_xxx", path)
|
||||
require.Error(t, err)
|
||||
|
||||
var typed *cmdutil.Error
|
||||
require.ErrorAs(t, err, &typed)
|
||||
assert.Equal(t, cmdutil.CodeResourceAlreadyExists, typed.Code)
|
||||
}
|
||||
|
||||
// TestUpload_DuplicateFileMaps_resource_already_exists pins the contract that
|
||||
// the SDK's sentinel sdk.ErrDuplicateFile (returned with no "HTTP error <n>:"
|
||||
// prefix because the duplicate is detected by file-hash short-circuit, not by
|
||||
// status code) is mapped to resource.already_exists.
|
||||
func TestUpload_DuplicateFileMaps_resource_already_exists(t *testing.T) {
|
||||
_, _ = iostreams.SetForTest(t)
|
||||
path := writeTempFile(t, "dup.md")
|
||||
svc := &fakeUploadSvc{err: sdk.ErrDuplicateFile}
|
||||
err := runUpload(context.Background(), &UploadOptions{}, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, svc, "kb_xxx", path)
|
||||
require.Error(t, err)
|
||||
|
||||
var typed *cmdutil.Error
|
||||
require.ErrorAs(t, err, &typed)
|
||||
assert.Equal(t, cmdutil.CodeResourceAlreadyExists, typed.Code)
|
||||
}
|
||||
|
||||
func TestValidateUploadPath_NotFound(t *testing.T) {
|
||||
missing := filepath.Join(t.TempDir(), "does-not-exist.pdf")
|
||||
err := validateUploadPath(missing)
|
||||
require.Error(t, err)
|
||||
|
||||
var typed *cmdutil.Error
|
||||
require.ErrorAs(t, err, &typed)
|
||||
assert.Equal(t, cmdutil.CodeUploadFileNotFound, typed.Code)
|
||||
}
|
||||
|
||||
func TestValidateUploadPath_DirectoryRejected(t *testing.T) {
|
||||
dir := t.TempDir() // already exists, is a dir
|
||||
err := validateUploadPath(dir)
|
||||
require.Error(t, err)
|
||||
|
||||
var typed *cmdutil.Error
|
||||
require.ErrorAs(t, err, &typed)
|
||||
assert.Equal(t, cmdutil.CodeInputInvalidArgument, typed.Code)
|
||||
assert.Contains(t, typed.Message, "not a regular file")
|
||||
}
|
||||
|
||||
func TestValidateUploadPath_RegularFileAccepted(t *testing.T) {
|
||||
path := writeTempFile(t, "ok.txt")
|
||||
require.NoError(t, validateUploadPath(path))
|
||||
}
|
||||
|
||||
func TestValidateUploadPath_SymlinkToFileAccepted(t *testing.T) {
|
||||
target := writeTempFile(t, "target.txt")
|
||||
link := filepath.Join(t.TempDir(), "link.txt")
|
||||
if err := os.Symlink(target, link); err != nil {
|
||||
t.Skipf("symlink not supported on this platform: %v", err)
|
||||
}
|
||||
// os.Stat (not Lstat) should follow the symlink and report regular file.
|
||||
require.NoError(t, validateUploadPath(link))
|
||||
}
|
||||
|
||||
func TestValidateUploadFlags_NoPath_Rejected(t *testing.T) {
|
||||
err := validateUploadFlags(&UploadOptions{}, nil)
|
||||
require.Error(t, err)
|
||||
// Missing required input wraps as FlagError so the exit code (2)
|
||||
// matches cobra's MinimumNArgs(1) for commands taking a positional.
|
||||
var fe *cmdutil.FlagError
|
||||
require.ErrorAs(t, err, &fe, "expected FlagError so exit code maps to 2")
|
||||
assert.Equal(t, 2, cmdutil.ExitCode(err))
|
||||
}
|
||||
|
||||
func TestValidateUploadFlags_WithPath_OK(t *testing.T) {
|
||||
require.NoError(t, validateUploadFlags(&UploadOptions{}, []string{"/tmp/x.pdf"}))
|
||||
}
|
||||
|
||||
// --- C10 expanded flags: multimodel / metadata / channel ---
|
||||
|
||||
func TestUpload_EnableMultimodel_Set_True(t *testing.T) {
|
||||
_, _ = iostreams.SetForTest(t)
|
||||
path := writeTempFile(t, "mm.pdf")
|
||||
svc := &fakeUploadSvc{resp: &sdk.Knowledge{ID: "doc_mm", FileName: "mm.pdf"}}
|
||||
mm := true
|
||||
opts := &UploadOptions{EnableMultimodel: &mm}
|
||||
require.NoError(t, runUpload(context.Background(), opts, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, svc, "kb_xxx", path))
|
||||
require.NotNil(t, svc.got.enableMultimodel, "expected non-nil *bool when flag set")
|
||||
assert.True(t, *svc.got.enableMultimodel)
|
||||
}
|
||||
|
||||
func TestUpload_EnableMultimodel_Set_False(t *testing.T) {
|
||||
_, _ = iostreams.SetForTest(t)
|
||||
path := writeTempFile(t, "mm.pdf")
|
||||
svc := &fakeUploadSvc{resp: &sdk.Knowledge{ID: "doc_mm", FileName: "mm.pdf"}}
|
||||
mm := false
|
||||
opts := &UploadOptions{EnableMultimodel: &mm}
|
||||
require.NoError(t, runUpload(context.Background(), opts, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, svc, "kb_xxx", path))
|
||||
require.NotNil(t, svc.got.enableMultimodel, "explicit false must still surface as non-nil *bool")
|
||||
assert.False(t, *svc.got.enableMultimodel)
|
||||
}
|
||||
|
||||
// TestParseTriBool pins the empty-string-rejects behavior. Bare
|
||||
// --enable-multimodel maps to "true" via NoOptDefVal before the flag reaches
|
||||
// parseTriBool, so an empty value here always indicates an explicit
|
||||
// --enable-multimodel="" (e.g. uninterpolated $VAR). Silently coercing
|
||||
// empty to true used to surprise users.
|
||||
func TestParseTriBool(t *testing.T) {
|
||||
for _, c := range []struct {
|
||||
in string
|
||||
want bool
|
||||
wantErr bool
|
||||
}{
|
||||
{"true", true, false},
|
||||
{"1", true, false},
|
||||
{"yes", true, false},
|
||||
{"false", false, false},
|
||||
{"0", false, false},
|
||||
{"no", false, false},
|
||||
{"", false, true}, // explicit empty rejected
|
||||
{" ", false, true}, // whitespace rejected
|
||||
{"maybe", false, true},
|
||||
} {
|
||||
t.Run(c.in, func(t *testing.T) {
|
||||
got, err := parseTriBool(c.in)
|
||||
if c.wantErr {
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "input.invalid_argument")
|
||||
return
|
||||
}
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, c.want, got)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpload_Metadata_ParseKV(t *testing.T) {
|
||||
_, _ = iostreams.SetForTest(t)
|
||||
path := writeTempFile(t, "m.pdf")
|
||||
svc := &fakeUploadSvc{resp: &sdk.Knowledge{ID: "doc_m", FileName: "m.pdf"}}
|
||||
opts := &UploadOptions{Metadata: []string{"foo=bar", "baz=qux"}}
|
||||
require.NoError(t, runUpload(context.Background(), opts, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, svc, "kb_xxx", path))
|
||||
assert.Equal(t, map[string]string{"foo": "bar", "baz": "qux"}, svc.got.metadata)
|
||||
}
|
||||
|
||||
func TestUpload_Metadata_EmptyValueAllowed(t *testing.T) {
|
||||
_, _ = iostreams.SetForTest(t)
|
||||
path := writeTempFile(t, "m.pdf")
|
||||
svc := &fakeUploadSvc{resp: &sdk.Knowledge{ID: "doc_m", FileName: "m.pdf"}}
|
||||
opts := &UploadOptions{Metadata: []string{"foo="}}
|
||||
require.NoError(t, runUpload(context.Background(), opts, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, svc, "kb_xxx", path))
|
||||
assert.Equal(t, map[string]string{"foo": ""}, svc.got.metadata)
|
||||
}
|
||||
|
||||
func TestUpload_Metadata_LastWins(t *testing.T) {
|
||||
_, _ = iostreams.SetForTest(t)
|
||||
path := writeTempFile(t, "m.pdf")
|
||||
svc := &fakeUploadSvc{resp: &sdk.Knowledge{ID: "doc_m", FileName: "m.pdf"}}
|
||||
opts := &UploadOptions{Metadata: []string{"k=v1", "k=v2"}}
|
||||
require.NoError(t, runUpload(context.Background(), opts, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, svc, "kb_xxx", path))
|
||||
assert.Equal(t, map[string]string{"k": "v2"}, svc.got.metadata)
|
||||
}
|
||||
|
||||
func TestUpload_Metadata_InvalidFormat_NoEquals(t *testing.T) {
|
||||
_, _ = iostreams.SetForTest(t)
|
||||
path := writeTempFile(t, "m.pdf")
|
||||
svc := &fakeUploadSvc{resp: &sdk.Knowledge{ID: "doc_m", FileName: "m.pdf"}}
|
||||
opts := &UploadOptions{Metadata: []string{"foo"}}
|
||||
err := runUpload(context.Background(), opts, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, svc, "kb_xxx", path)
|
||||
require.Error(t, err)
|
||||
var typed *cmdutil.Error
|
||||
require.ErrorAs(t, err, &typed)
|
||||
assert.Equal(t, cmdutil.CodeInputInvalidArgument, typed.Code)
|
||||
}
|
||||
|
||||
func TestUpload_Metadata_InvalidFormat_EmptyKey(t *testing.T) {
|
||||
_, _ = iostreams.SetForTest(t)
|
||||
path := writeTempFile(t, "m.pdf")
|
||||
svc := &fakeUploadSvc{resp: &sdk.Knowledge{ID: "doc_m", FileName: "m.pdf"}}
|
||||
opts := &UploadOptions{Metadata: []string{"=bar"}}
|
||||
err := runUpload(context.Background(), opts, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, svc, "kb_xxx", path)
|
||||
require.Error(t, err)
|
||||
var typed *cmdutil.Error
|
||||
require.ErrorAs(t, err, &typed)
|
||||
assert.Equal(t, cmdutil.CodeInputInvalidArgument, typed.Code)
|
||||
}
|
||||
|
||||
func TestUpload_Channel_Override(t *testing.T) {
|
||||
_, _ = iostreams.SetForTest(t)
|
||||
path := writeTempFile(t, "c.pdf")
|
||||
svc := &fakeUploadSvc{resp: &sdk.Knowledge{ID: "doc_c", FileName: "c.pdf"}}
|
||||
opts := &UploadOptions{Channel: "browser_extension"}
|
||||
require.NoError(t, runUpload(context.Background(), opts, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, svc, "kb_xxx", path))
|
||||
assert.Equal(t, "browser_extension", svc.got.channel)
|
||||
}
|
||||
|
||||
func TestUpload_Channel_DefaultStillAPI(t *testing.T) {
|
||||
_, _ = iostreams.SetForTest(t)
|
||||
path := writeTempFile(t, "c.pdf")
|
||||
svc := &fakeUploadSvc{resp: &sdk.Knowledge{ID: "doc_c", FileName: "c.pdf"}}
|
||||
// Empty Channel is the runUpload contract for "use default".
|
||||
opts := &UploadOptions{}
|
||||
require.NoError(t, runUpload(context.Background(), opts, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, svc, "kb_xxx", path))
|
||||
assert.Equal(t, uploadChannel, svc.got.channel)
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
package doc
|
||||
|
||||
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"
|
||||
)
|
||||
|
||||
// docViewFields enumerates the fields surfaced for `--format json` discovery on
|
||||
// `doc view`. Lists the Knowledge struct top-level json tags.
|
||||
var docViewFields = []string{
|
||||
"id", "knowledge_base_id", "tag_id", "type", "title", "description",
|
||||
"source", "channel", "parse_status", "summary_status", "enable_status",
|
||||
"embedding_model_id", "file_name", "file_type", "file_size", "file_hash",
|
||||
"file_path", "storage_size",
|
||||
"created_at", "updated_at", "processed_at", "error_message",
|
||||
}
|
||||
|
||||
type ViewOptions struct{}
|
||||
|
||||
// ViewService is the narrow SDK surface this command depends on.
|
||||
type ViewService interface {
|
||||
GetKnowledge(ctx context.Context, id string) (*sdk.Knowledge, error)
|
||||
}
|
||||
|
||||
// NewCmdView builds `weknora doc view <id>`.
|
||||
func NewCmdView(f *cmdutil.Factory) *cobra.Command {
|
||||
opts := &ViewOptions{}
|
||||
cmd := &cobra.Command{
|
||||
Use: "view <doc-id>",
|
||||
Short: "Show a document by ID",
|
||||
Example: ` weknora doc view doc_abc
|
||||
weknora doc view doc_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
|
||||
}
|
||||
return runView(c.Context(), opts, fopts, cli, args[0])
|
||||
},
|
||||
}
|
||||
cmdutil.AddFormatFlag(cmd, docViewFields...)
|
||||
cmdutil.AddIgnoredKBFlag(cmd)
|
||||
cmdutil.SetAgentHelp(cmd, cmdutil.AgentHelp{
|
||||
UsedFor: "fetch one document's metadata by id",
|
||||
RequiredFlags: []string{"<doc-id> (positional)"},
|
||||
Examples: []string{"weknora doc view doc_abc", "weknora doc view doc_abc --jq .data.parse_status"},
|
||||
Output: "envelope.data is the document object (id, file_name, parse_status, ...)",
|
||||
})
|
||||
return cmd
|
||||
}
|
||||
|
||||
func runView(ctx context.Context, opts *ViewOptions, fopts *cmdutil.FormatOptions, svc ViewService, id string) error {
|
||||
doc, err := svc.GetKnowledge(ctx, id)
|
||||
if err != nil {
|
||||
return cmdutil.WrapHTTP(err, "get document %q", id)
|
||||
}
|
||||
if fopts.WantsJSON() {
|
||||
return fopts.Emit(iostreams.IO.Out, doc, nil)
|
||||
}
|
||||
w := iostreams.IO.Out
|
||||
fmt.Fprintf(w, "ID: %s\n", doc.ID)
|
||||
fmt.Fprintf(w, "NAME: %s\n", text.KnowledgeDisplayName(doc.FileName, doc.Title, doc.ID))
|
||||
// Title is rendered as a separate line only when it adds info over
|
||||
// NAME (i.e. FileName is set AND differs from Title). When FileName
|
||||
// is empty, KnowledgeDisplayName already used Title for NAME so a
|
||||
// duplicate TITLE line would be noise.
|
||||
if doc.Title != "" && doc.FileName != "" && doc.Title != doc.FileName {
|
||||
fmt.Fprintf(w, "TITLE: %s\n", doc.Title)
|
||||
}
|
||||
if doc.KnowledgeBaseID != "" {
|
||||
fmt.Fprintf(w, "KB: %s\n", doc.KnowledgeBaseID)
|
||||
}
|
||||
if doc.TagID != "" {
|
||||
fmt.Fprintf(w, "TAG: %s\n", doc.TagID)
|
||||
}
|
||||
if doc.Description != "" {
|
||||
fmt.Fprintf(w, "DESC: %s\n", doc.Description)
|
||||
}
|
||||
if doc.FileType != "" {
|
||||
fmt.Fprintf(w, "TYPE: %s\n", doc.FileType)
|
||||
}
|
||||
if doc.Source != "" {
|
||||
fmt.Fprintf(w, "SOURCE: %s\n", doc.Source)
|
||||
}
|
||||
if doc.Channel != "" {
|
||||
fmt.Fprintf(w, "CHANNEL: %s\n", doc.Channel)
|
||||
}
|
||||
if doc.FileSize > 0 {
|
||||
fmt.Fprintf(w, "SIZE: %s\n", formatSize(doc.FileSize))
|
||||
}
|
||||
if doc.StorageSize > 0 {
|
||||
fmt.Fprintf(w, "STORAGE: %s\n", formatSize(doc.StorageSize))
|
||||
}
|
||||
if doc.ParseStatus != "" {
|
||||
fmt.Fprintf(w, "STATUS: %s\n", doc.ParseStatus)
|
||||
}
|
||||
if doc.SummaryStatus != "" {
|
||||
fmt.Fprintf(w, "SUMMARY: %s\n", doc.SummaryStatus)
|
||||
}
|
||||
if doc.EnableStatus != "" {
|
||||
fmt.Fprintf(w, "ENABLED: %s\n", doc.EnableStatus)
|
||||
}
|
||||
if doc.EmbeddingModelID != "" {
|
||||
fmt.Fprintf(w, "EMBEDDING: %s\n", doc.EmbeddingModelID)
|
||||
}
|
||||
if doc.FileHash != "" {
|
||||
// Git-SHA-style 12-char prefix is enough for de-duplication
|
||||
// while keeping the line short.
|
||||
h := doc.FileHash
|
||||
if len(h) > 12 {
|
||||
h = h[:12]
|
||||
}
|
||||
fmt.Fprintf(w, "HASH: %s\n", h)
|
||||
}
|
||||
if !doc.CreatedAt.IsZero() {
|
||||
fmt.Fprintf(w, "CREATED: %s\n", doc.CreatedAt.Format("2006-01-02 15:04:05"))
|
||||
}
|
||||
if !doc.UpdatedAt.IsZero() {
|
||||
fmt.Fprintf(w, "UPDATED: %s\n", doc.UpdatedAt.Format("2006-01-02 15:04:05"))
|
||||
}
|
||||
if doc.ProcessedAt != nil && !doc.ProcessedAt.IsZero() {
|
||||
fmt.Fprintf(w, "PROCESSED: %s\n", doc.ProcessedAt.Format("2006-01-02 15:04:05"))
|
||||
}
|
||||
if doc.ErrorMessage != "" {
|
||||
fmt.Fprintf(w, "ERROR: %s\n", doc.ErrorMessage)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,272 @@
|
||||
package doc
|
||||
|
||||
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"
|
||||
)
|
||||
|
||||
// fakeViewSvc scripts a GetKnowledge response. Tests bypass cobra and call
|
||||
// runView directly with this fake injected.
|
||||
type fakeViewSvc struct {
|
||||
doc *sdk.Knowledge
|
||||
err error
|
||||
}
|
||||
|
||||
func (f *fakeViewSvc) GetKnowledge(_ context.Context, _ string) (*sdk.Knowledge, error) {
|
||||
return f.doc, f.err
|
||||
}
|
||||
|
||||
func TestView_Text_RendersExpectedFields(t *testing.T) {
|
||||
out, _ := iostreams.SetForTest(t)
|
||||
processed := time.Date(2026, 1, 2, 3, 4, 5, 0, time.UTC)
|
||||
svc := &fakeViewSvc{doc: &sdk.Knowledge{
|
||||
ID: "doc_abc",
|
||||
KnowledgeBaseID: "kb1",
|
||||
FileName: "policy.pdf",
|
||||
Title: "Policy",
|
||||
FileType: "pdf",
|
||||
FileSize: 2048,
|
||||
ParseStatus: "completed",
|
||||
EmbeddingModelID: "text-embedding-3",
|
||||
CreatedAt: time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC),
|
||||
UpdatedAt: time.Date(2026, 1, 2, 0, 0, 0, 0, time.UTC),
|
||||
ProcessedAt: &processed,
|
||||
}}
|
||||
if err := runView(context.Background(), &ViewOptions{}, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, svc, "doc_abc"); err != nil {
|
||||
t.Fatalf("runView: %v", err)
|
||||
}
|
||||
got := out.String()
|
||||
for _, want := range []string{
|
||||
"ID:", "doc_abc", "NAME:", "policy.pdf", "KB:", "kb1",
|
||||
"TYPE:", "pdf", "SIZE:", "STATUS:", "completed",
|
||||
"EMBEDDING:", "text-embedding-3", "PROCESSED:",
|
||||
} {
|
||||
if !strings.Contains(got, want) {
|
||||
t.Errorf("missing %q in:\n%s", want, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A doc with no FileName falls back to Title for the NAME line - same
|
||||
// ordering as `doc list` (KnowledgeDisplayName precedence).
|
||||
func TestView_Text_TitleFallback(t *testing.T) {
|
||||
out, _ := iostreams.SetForTest(t)
|
||||
svc := &fakeViewSvc{doc: &sdk.Knowledge{ID: "doc_url", Title: "Pasted article", FileName: ""}}
|
||||
if err := runView(context.Background(), &ViewOptions{}, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, svc, "doc_url"); err != nil {
|
||||
t.Fatalf("runView: %v", err)
|
||||
}
|
||||
got := out.String()
|
||||
if !strings.Contains(got, "Pasted article") {
|
||||
t.Errorf("expected Title fallback in NAME line; got:\n%s", got)
|
||||
}
|
||||
}
|
||||
|
||||
// Optional fields (ProcessedAt nil, ErrorMessage empty, EmbeddingModelID
|
||||
// empty) must not produce empty KEY: lines - the formatter omits them
|
||||
// rather than printing "PROCESSED: -".
|
||||
func TestView_Text_OmitsEmptyFields(t *testing.T) {
|
||||
out, _ := iostreams.SetForTest(t)
|
||||
svc := &fakeViewSvc{doc: &sdk.Knowledge{
|
||||
ID: "doc_abc",
|
||||
FileName: "x.txt",
|
||||
}}
|
||||
if err := runView(context.Background(), &ViewOptions{}, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, svc, "doc_abc"); err != nil {
|
||||
t.Fatalf("runView: %v", err)
|
||||
}
|
||||
// Line-prefix match (not substring): "ERROR:" as a substring could
|
||||
// also appear inside `STATUS: error` if a future test seeds
|
||||
// ParseStatus="error". Splitting by newline pins the assertion to the
|
||||
// label column.
|
||||
lines := strings.Split(out.String(), "\n")
|
||||
for _, prefix := range []string{"PROCESSED:", "EMBEDDING:", "ERROR:"} {
|
||||
for _, l := range lines {
|
||||
if strings.HasPrefix(l, prefix) {
|
||||
t.Errorf("expected no line beginning with %q (empty field), got: %q", prefix, l)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestView_JSON_BareObject(t *testing.T) {
|
||||
out, _ := iostreams.SetForTest(t)
|
||||
svc := &fakeViewSvc{doc: &sdk.Knowledge{ID: "doc_abc", FileName: "x.txt", KnowledgeBaseID: "kb1"}}
|
||||
if err := runView(context.Background(), &ViewOptions{}, &cmdutil.FormatOptions{Mode: cmdutil.FormatJSON}, svc, "doc_abc"); err != nil {
|
||||
t.Fatalf("runView: %v", err)
|
||||
}
|
||||
got := out.String()
|
||||
var env struct {
|
||||
OK bool `json:"ok"`
|
||||
Data sdk.Knowledge `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)
|
||||
}
|
||||
for _, want := range []string{`"id":"doc_abc"`, `"file_name":"x.txt"`, `"knowledge_base_id":"kb1"`} {
|
||||
if !strings.Contains(got, want) {
|
||||
t.Errorf("missing %q in:\n%s", want, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestView_NotFound_ClassifiedAs404(t *testing.T) {
|
||||
_, _ = iostreams.SetForTest(t)
|
||||
svc := &fakeViewSvc{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: title/desc/source/channel/etc. ---
|
||||
|
||||
func TestView_Title_RendersWhenDifferentFromFileName(t *testing.T) {
|
||||
out, _ := iostreams.SetForTest(t)
|
||||
svc := &fakeViewSvc{doc: &sdk.Knowledge{
|
||||
ID: "doc_t", FileName: "raw.pdf", Title: "Quarterly Plan",
|
||||
}}
|
||||
if err := runView(context.Background(), &ViewOptions{}, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, svc, "doc_t"); err != nil {
|
||||
t.Fatalf("runView: %v", err)
|
||||
}
|
||||
got := out.String()
|
||||
if !strings.Contains(got, "TITLE:") || !strings.Contains(got, "Quarterly Plan") {
|
||||
t.Errorf("expected TITLE line:\n%s", got)
|
||||
}
|
||||
}
|
||||
|
||||
// When Title and FileName are equal, the TITLE line is redundant with NAME
|
||||
// and should be omitted.
|
||||
func TestView_Title_OmittedWhenSameAsFileName(t *testing.T) {
|
||||
out, _ := iostreams.SetForTest(t)
|
||||
svc := &fakeViewSvc{doc: &sdk.Knowledge{
|
||||
ID: "doc_t", FileName: "policy.pdf", Title: "policy.pdf",
|
||||
}}
|
||||
if err := runView(context.Background(), &ViewOptions{}, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, svc, "doc_t"); err != nil {
|
||||
t.Fatalf("runView: %v", err)
|
||||
}
|
||||
for _, l := range strings.Split(out.String(), "\n") {
|
||||
if strings.HasPrefix(l, "TITLE:") {
|
||||
t.Errorf("TITLE line should be omitted when same as filename: %q", l)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestView_Description(t *testing.T) {
|
||||
out, _ := iostreams.SetForTest(t)
|
||||
svc := &fakeViewSvc{doc: &sdk.Knowledge{ID: "doc_d", FileName: "x.pdf", Description: "Annual review"}}
|
||||
if err := runView(context.Background(), &ViewOptions{}, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, svc, "doc_d"); err != nil {
|
||||
t.Fatalf("runView: %v", err)
|
||||
}
|
||||
if !strings.Contains(out.String(), "Annual review") {
|
||||
t.Errorf("expected description text:\n%s", out.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestView_SourceAndChannel(t *testing.T) {
|
||||
out, _ := iostreams.SetForTest(t)
|
||||
svc := &fakeViewSvc{doc: &sdk.Knowledge{
|
||||
ID: "doc_s", FileName: "x.pdf", Source: "https://example.com/x", Channel: "web",
|
||||
}}
|
||||
if err := runView(context.Background(), &ViewOptions{}, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, svc, "doc_s"); err != nil {
|
||||
t.Fatalf("runView: %v", err)
|
||||
}
|
||||
got := out.String()
|
||||
for _, want := range []string{"SOURCE:", "https://example.com/x", "CHANNEL:", "web"} {
|
||||
if !strings.Contains(got, want) {
|
||||
t.Errorf("missing %q in:\n%s", want, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestView_SummaryAndEnableStatus(t *testing.T) {
|
||||
out, _ := iostreams.SetForTest(t)
|
||||
svc := &fakeViewSvc{doc: &sdk.Knowledge{
|
||||
ID: "doc_st", FileName: "x.pdf",
|
||||
SummaryStatus: "completed",
|
||||
EnableStatus: "disabled",
|
||||
}}
|
||||
if err := runView(context.Background(), &ViewOptions{}, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, svc, "doc_st"); err != nil {
|
||||
t.Fatalf("runView: %v", err)
|
||||
}
|
||||
got := out.String()
|
||||
for _, want := range []string{"SUMMARY:", "completed", "ENABLED:", "disabled"} {
|
||||
if !strings.Contains(got, want) {
|
||||
t.Errorf("missing %q in:\n%s", want, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestView_TagID(t *testing.T) {
|
||||
out, _ := iostreams.SetForTest(t)
|
||||
svc := &fakeViewSvc{doc: &sdk.Knowledge{ID: "doc_t", FileName: "x.pdf", TagID: "tag_abc"}}
|
||||
if err := runView(context.Background(), &ViewOptions{}, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, svc, "doc_t"); err != nil {
|
||||
t.Fatalf("runView: %v", err)
|
||||
}
|
||||
got := out.String()
|
||||
if !strings.Contains(got, "TAG:") || !strings.Contains(got, "tag_abc") {
|
||||
t.Errorf("expected TAG line:\n%s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestView_StorageSize_Text(t *testing.T) {
|
||||
out, _ := iostreams.SetForTest(t)
|
||||
svc := &fakeViewSvc{doc: &sdk.Knowledge{ID: "doc_sz", FileName: "x.pdf", StorageSize: 2 * 1024 * 1024}}
|
||||
if err := runView(context.Background(), &ViewOptions{}, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, svc, "doc_sz"); err != nil {
|
||||
t.Fatalf("runView: %v", err)
|
||||
}
|
||||
got := out.String()
|
||||
if !strings.Contains(got, "STORAGE:") || !strings.Contains(got, "MB") {
|
||||
t.Errorf("expected STORAGE line with human-readable bytes:\n%s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestView_FileHash_Prefix12(t *testing.T) {
|
||||
out, _ := iostreams.SetForTest(t)
|
||||
svc := &fakeViewSvc{doc: &sdk.Knowledge{
|
||||
ID: "doc_h",
|
||||
FileName: "x.pdf",
|
||||
FileHash: "abcdef1234567890fedcba0987654321",
|
||||
}}
|
||||
if err := runView(context.Background(), &ViewOptions{}, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, svc, "doc_h"); err != nil {
|
||||
t.Fatalf("runView: %v", err)
|
||||
}
|
||||
got := out.String()
|
||||
if !strings.Contains(got, "HASH:") || !strings.Contains(got, "abcdef123456") {
|
||||
t.Errorf("expected HASH line with 12-char prefix:\n%s", got)
|
||||
}
|
||||
if strings.Contains(got, "abcdef1234567890fedcba0987654321") {
|
||||
t.Errorf("full hash should be truncated, got:\n%s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestView_ErrorMessage_WarnPrefix(t *testing.T) {
|
||||
out, _ := iostreams.SetForTest(t)
|
||||
svc := &fakeViewSvc{doc: &sdk.Knowledge{
|
||||
ID: "doc_e", FileName: "x.pdf", ErrorMessage: "parser failed at offset 4096",
|
||||
}}
|
||||
if err := runView(context.Background(), &ViewOptions{}, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, svc, "doc_e"); err != nil {
|
||||
t.Fatalf("runView: %v", err)
|
||||
}
|
||||
got := out.String()
|
||||
if !strings.Contains(got, "parser failed at offset 4096") {
|
||||
t.Errorf("expected error message rendered:\n%s", got)
|
||||
}
|
||||
// Either ERROR: or a WARN-prefixed label is acceptable as a "warn"
|
||||
// signal — assert at least one.
|
||||
if !strings.Contains(got, "ERROR:") && !strings.Contains(got, "WARN") {
|
||||
t.Errorf("expected ERROR or WARN prefix on error line:\n%s", got)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,331 @@
|
||||
package doc
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"math/rand"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
sdk "github.com/Tencent/WeKnora/client"
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/Tencent/WeKnora/cli/internal/cmdutil"
|
||||
"github.com/Tencent/WeKnora/cli/internal/iostreams"
|
||||
)
|
||||
|
||||
// WaitOptions captures `doc wait` flag state.
|
||||
type WaitOptions struct {
|
||||
IDs []string
|
||||
Timeout time.Duration
|
||||
Interval time.Duration
|
||||
}
|
||||
|
||||
// NewCmdWait builds `weknora doc wait <id> [<id>...]`.
|
||||
//
|
||||
// Multi-id behaviour is always wait-all: blocks until every id reaches a
|
||||
// terminal state. Use shell composition
|
||||
// (`weknora doc wait id1 && weknora doc wait id2`) when fail-fast is
|
||||
// desired.
|
||||
func NewCmdWait(f *cmdutil.Factory) *cobra.Command {
|
||||
opts := &WaitOptions{}
|
||||
cmd := &cobra.Command{
|
||||
Use: "wait <doc-id> [<doc-id>...]",
|
||||
Short: "Wait for one or more documents to finish parsing",
|
||||
Long: `Block until every given document reaches a terminal parse_status
|
||||
(completed or failed), the timeout expires, or the user interrupts (Ctrl-C).
|
||||
Always wait-all: every id must reach a terminal state before returning.
|
||||
|
||||
Exit codes:
|
||||
0 all completed
|
||||
1 any failed
|
||||
124 --timeout reached (matches GNU 'timeout' command)
|
||||
130 Ctrl-C / SIGINT
|
||||
|
||||
Multi-id is polled concurrently (max 5 parallel; use 'xargs -P' for more).
|
||||
For fail-fast semantics, use shell composition:
|
||||
weknora doc wait id1 && weknora doc wait id2 && weknora doc wait id3`,
|
||||
Example: ` weknora doc wait doc_abc
|
||||
weknora doc wait id1 id2 id3 --timeout 20m
|
||||
weknora doc wait id1 id2 --format ndjson`,
|
||||
Args: cobra.MinimumNArgs(1),
|
||||
RunE: func(c *cobra.Command, args []string) error {
|
||||
// Validate flags FIRST so an invalid --format doesn't cost the
|
||||
// user a multi-minute poll before erroring out.
|
||||
fopts, err := cmdutil.CheckFormatFlag(c)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
fopts.ResolveDefault(iostreams.IO.IsStdoutTTY())
|
||||
|
||||
opts.IDs = args
|
||||
cli, err := f.Client()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
res, err := waitForDocs(c.Context(), opts.IDs, cli, *opts)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := emitWaitResult(res, fopts, iostreams.IO.Out); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// The partition (completed/failed/timeout) is already on stdout via
|
||||
// emitWaitResult. On aggregate failure we still need a non-zero exit,
|
||||
// but the error is Silent so PrintError does NOT write a second,
|
||||
// contradictory {ok:false} envelope to stderr — the agent reads the
|
||||
// failure detail from the stdout partition + the exit code (1/124).
|
||||
// Same contract as cmdutil.RunBatch.
|
||||
switch res.ExitCode() {
|
||||
case 0:
|
||||
return nil
|
||||
case 1:
|
||||
return cmdutil.NewError(cmdutil.CodeOperationFailed, fmt.Sprintf("%d doc(s) failed", len(res.Failed))).WithSilent()
|
||||
case 124:
|
||||
return cmdutil.NewError(cmdutil.CodeOperationTimeout, fmt.Sprintf("wait timed out (%d doc(s) still pending)", len(res.Timeout))).WithSilent()
|
||||
}
|
||||
return nil
|
||||
},
|
||||
}
|
||||
cmd.Flags().DurationVar(&opts.Timeout, "timeout", 10*time.Minute, "Max wait time before exiting 124")
|
||||
cmd.Flags().DurationVar(&opts.Interval, "interval", 2*time.Second, "Initial poll interval; exponential backoff capped at 15s + jitter")
|
||||
cmdutil.AddFormatFlag(cmd)
|
||||
cmdutil.AddIgnoredKBFlag(cmd)
|
||||
cmdutil.SetAgentHelp(cmd, cmdutil.AgentHelp{
|
||||
UsedFor: "block until one or more documents reach a terminal parse state (completed or failed), or --timeout elapses",
|
||||
RequiredFlags: []string{"<doc-id>... (one or more positionals)"},
|
||||
Examples: []string{
|
||||
"weknora doc wait doc_abc",
|
||||
"weknora doc wait doc_a doc_b --timeout 5m",
|
||||
},
|
||||
Output: "envelope.data is {completed:[], failed:[{id,message}], timeout:[]}",
|
||||
Warnings: []string{
|
||||
"exit code carries the aggregate result: 0 all completed, 1 any failed, 124 timeout",
|
||||
"ok:true means the wait ran to a terminal state — NOT that every doc succeeded; branch on the exit code or inspect data.failed",
|
||||
},
|
||||
})
|
||||
return cmd
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Core poll loop (B2)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// WaitService is the narrow SDK surface needed for polling.
|
||||
type WaitService interface {
|
||||
GetKnowledge(ctx context.Context, id string) (*sdk.Knowledge, error)
|
||||
}
|
||||
|
||||
// WaitResult is the terminal-state partition returned by waitForDocs.
|
||||
type WaitResult struct {
|
||||
Completed []string `json:"completed"`
|
||||
Failed []FailedDoc `json:"failed,omitempty"`
|
||||
Timeout []string `json:"timeout,omitempty"`
|
||||
}
|
||||
|
||||
// FailedDoc carries the id + reason for a doc that reached parse_status=failed
|
||||
// or that GetKnowledge returned an error for.
|
||||
type FailedDoc struct {
|
||||
ID string `json:"id"`
|
||||
Code string `json:"code,omitempty"`
|
||||
Message string `json:"message,omitempty"`
|
||||
}
|
||||
|
||||
const (
|
||||
maxConcurrentPolls = 5
|
||||
maxBackoffInterval = 15 * time.Second
|
||||
jitterMax = 500 * time.Millisecond
|
||||
)
|
||||
|
||||
// waitForDocs polls each id until terminal state or timeout. Concurrency is
|
||||
// bounded by maxConcurrentPolls. Returns the partitioned terminal state.
|
||||
// Always waits for every id (wait-all semantics).
|
||||
//
|
||||
// Duplicate ids are deduplicated at entry — polling the same id twice would
|
||||
// produce duplicate result entries and waste poll quota.
|
||||
//
|
||||
// Exponential backoff starts at opts.Interval, doubles each tick, caps at
|
||||
// maxBackoffInterval, with up to jitterMax random jitter added per sleep.
|
||||
func waitForDocs(ctx context.Context, ids []string, svc WaitService, opts WaitOptions) (*WaitResult, error) {
|
||||
// Dedup ids while preserving first-seen order.
|
||||
seen := make(map[string]struct{}, len(ids))
|
||||
deduped := make([]string, 0, len(ids))
|
||||
for _, id := range ids {
|
||||
if _, ok := seen[id]; ok {
|
||||
continue
|
||||
}
|
||||
seen[id] = struct{}{}
|
||||
deduped = append(deduped, id)
|
||||
}
|
||||
ids = deduped
|
||||
|
||||
ctx, cancel := context.WithTimeout(ctx, opts.Timeout)
|
||||
defer cancel()
|
||||
|
||||
// Completed is non-omitempty (always present in the partition), so start it
|
||||
// as an empty slice rather than nil — agents see `"completed":[]`, not null.
|
||||
result := &WaitResult{Completed: []string{}}
|
||||
var mu sync.Mutex
|
||||
addCompleted := func(id string) {
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
result.Completed = append(result.Completed, id)
|
||||
}
|
||||
addFailed := func(fd FailedDoc) {
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
result.Failed = append(result.Failed, fd)
|
||||
}
|
||||
addTimeout := func(id string) {
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
result.Timeout = append(result.Timeout, id)
|
||||
}
|
||||
|
||||
sem := make(chan struct{}, maxConcurrentPolls)
|
||||
var wg sync.WaitGroup
|
||||
for _, id := range ids {
|
||||
wg.Add(1)
|
||||
go func(id string) {
|
||||
defer wg.Done()
|
||||
sem <- struct{}{}
|
||||
defer func() { <-sem }()
|
||||
|
||||
interval := opts.Interval
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
// Distinguish SIGINT/SIGTERM (Canceled) from --timeout
|
||||
// (DeadlineExceeded) so a user interrupt does not
|
||||
// pollute the timeout list with the in-flight ids.
|
||||
if errors.Is(ctx.Err(), context.Canceled) {
|
||||
// Signal-driven cancel: root's signal handler
|
||||
// exits 130; don't classify these ids as timed out.
|
||||
return
|
||||
}
|
||||
addTimeout(id)
|
||||
return
|
||||
default:
|
||||
}
|
||||
|
||||
doc, err := svc.GetKnowledge(ctx, id)
|
||||
if err != nil {
|
||||
// Check Canceled first — SIGINT during an in-flight
|
||||
// request surfaces as a context-canceled error; it is
|
||||
// not a real GetKnowledge failure.
|
||||
if errors.Is(ctx.Err(), context.Canceled) {
|
||||
return
|
||||
}
|
||||
if errors.Is(ctx.Err(), context.DeadlineExceeded) {
|
||||
addTimeout(id)
|
||||
return
|
||||
}
|
||||
addFailed(FailedDoc{ID: id, Message: err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
switch doc.ParseStatus {
|
||||
case "completed":
|
||||
addCompleted(id)
|
||||
return
|
||||
case "failed":
|
||||
addFailed(FailedDoc{ID: id, Message: doc.ErrorMessage})
|
||||
return
|
||||
case "draft":
|
||||
// draft = created but NOT queued for parsing (inline
|
||||
// `doc create` leaves docs here; file `doc upload`
|
||||
// auto-enqueues). It never progresses on its own, so
|
||||
// waiting would hang to the --timeout (124). Fail fast with
|
||||
// the exact unblock command instead of silently polling.
|
||||
addFailed(FailedDoc{ID: id, Message: "parse_status=draft: not queued for parsing — run `weknora doc reparse " + id + "` to index it"})
|
||||
return
|
||||
}
|
||||
|
||||
// Not yet terminal — sleep with jitter, then exp-backoff.
|
||||
jitter := time.Duration(rand.Int63n(int64(jitterMax)))
|
||||
timer := time.NewTimer(interval + jitter)
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
timer.Stop()
|
||||
if errors.Is(ctx.Err(), context.Canceled) {
|
||||
return
|
||||
}
|
||||
addTimeout(id)
|
||||
return
|
||||
case <-timer.C:
|
||||
}
|
||||
interval *= 2
|
||||
if interval > maxBackoffInterval {
|
||||
interval = maxBackoffInterval
|
||||
}
|
||||
}
|
||||
}(id)
|
||||
}
|
||||
wg.Wait()
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// ExitCode resolves the compound terminal state to a Unix exit code.
|
||||
// Priority: 1 > 124 > 0 (failed > timeout > completed). SIGINT (exit 130)
|
||||
// is handled by the Go runtime / context cancellation, not here.
|
||||
func (r *WaitResult) ExitCode() int {
|
||||
if len(r.Failed) > 0 {
|
||||
return 1
|
||||
}
|
||||
if len(r.Timeout) > 0 {
|
||||
return 124
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// Compile-time assertion: *sdk.Client satisfies WaitService.
|
||||
var _ WaitService = (*sdk.Client)(nil)
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// B5: output rendering
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// emitWaitResult renders r according to --format. Output writer is
|
||||
// parametrized for tests; production callers pass iostreams.IO.Out.
|
||||
func emitWaitResult(r *WaitResult, fopts *cmdutil.FormatOptions, w io.Writer) error {
|
||||
switch fopts.Mode {
|
||||
case cmdutil.FormatJSON, cmdutil.FormatNDJSON:
|
||||
return fopts.Emit(w, r, nil)
|
||||
case cmdutil.FormatText, "":
|
||||
return writeWaitText(w, r)
|
||||
default:
|
||||
return fmt.Errorf("unsupported --format %q for doc wait", fopts.Mode)
|
||||
}
|
||||
}
|
||||
|
||||
// writeWaitText renders r as human-readable lines:
|
||||
//
|
||||
// ✓ <id> completed
|
||||
// ✗ <id> failed: <message>
|
||||
// ⏱ <id> timeout
|
||||
func writeWaitText(w io.Writer, r *WaitResult) error {
|
||||
for _, id := range r.Completed {
|
||||
if _, err := fmt.Fprintf(w, "✓ %s completed\n", id); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
for _, fd := range r.Failed {
|
||||
msg := fd.Message
|
||||
if msg == "" {
|
||||
msg = "(no message)"
|
||||
}
|
||||
if _, err := fmt.Fprintf(w, "✗ %s failed: %s\n", fd.ID, msg); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
for _, id := range r.Timeout {
|
||||
if _, err := fmt.Fprintf(w, "⏱ %s timeout\n", id); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,340 @@
|
||||
package doc
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
sdk "github.com/Tencent/WeKnora/client"
|
||||
|
||||
"github.com/Tencent/WeKnora/cli/internal/cmdutil"
|
||||
)
|
||||
|
||||
func TestWaitCmd_Shape(t *testing.T) {
|
||||
cmd := NewCmdWait(&cmdutil.Factory{})
|
||||
if !strings.Contains(cmd.Use, "<doc-id>") {
|
||||
t.Errorf("Use=%q missing <doc-id>", cmd.Use)
|
||||
}
|
||||
if f := cmd.Flags().Lookup("timeout"); f == nil {
|
||||
t.Error("missing --timeout flag")
|
||||
}
|
||||
if f := cmd.Flags().Lookup("interval"); f == nil {
|
||||
t.Error("missing --interval flag")
|
||||
}
|
||||
// wait is always wait-all: no fail-fast option. --keep-going is
|
||||
// intentionally absent; users who want fail-fast compose with shell
|
||||
// short-circuiting (`wait id1 && wait id2`).
|
||||
if f := cmd.Flags().Lookup("keep-going"); f != nil {
|
||||
t.Error("--keep-going should not be registered; wait is always wait-all")
|
||||
}
|
||||
// --format is a persistent root flag (v0.7), not per-command — skip its lookup here.
|
||||
}
|
||||
|
||||
func TestWaitCmd_DefaultFlagValues(t *testing.T) {
|
||||
cmd := NewCmdWait(&cmdutil.Factory{})
|
||||
if err := cmd.ParseFlags(nil); err != nil {
|
||||
t.Fatalf("ParseFlags: %v", err)
|
||||
}
|
||||
tf, _ := cmd.Flags().GetDuration("timeout")
|
||||
if tf != 10*time.Minute {
|
||||
t.Errorf("--timeout default = %v, want 10m", tf)
|
||||
}
|
||||
in, _ := cmd.Flags().GetDuration("interval")
|
||||
if in != 2*time.Second {
|
||||
t.Errorf("--interval default = %v, want 2s", in)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// B2: waitForDocs tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
type fakeKnowledgeSvc struct {
|
||||
mu sync.Mutex
|
||||
calls map[string]int // id → call count
|
||||
sequence map[string][]string // id → parse_status sequence to return
|
||||
}
|
||||
|
||||
func newFakeKBSvc(seq map[string][]string) *fakeKnowledgeSvc {
|
||||
return &fakeKnowledgeSvc{
|
||||
calls: make(map[string]int),
|
||||
sequence: seq,
|
||||
}
|
||||
}
|
||||
|
||||
func (f *fakeKnowledgeSvc) GetKnowledge(_ context.Context, id string) (*sdk.Knowledge, error) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
idx := f.calls[id]
|
||||
f.calls[id]++
|
||||
seq, ok := f.sequence[id]
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("unexpected id %q", id)
|
||||
}
|
||||
if idx >= len(seq) {
|
||||
idx = len(seq) - 1
|
||||
}
|
||||
status := seq[idx]
|
||||
k := &sdk.Knowledge{ID: id, ParseStatus: status}
|
||||
if status == "failed" {
|
||||
k.ErrorMessage = "parse error"
|
||||
}
|
||||
return k, nil
|
||||
}
|
||||
|
||||
func TestWaitForDocs_SingleIDCompletes(t *testing.T) {
|
||||
svc := newFakeKBSvc(map[string][]string{
|
||||
"doc_x": {"processing", "processing", "completed"},
|
||||
})
|
||||
res, err := waitForDocs(context.Background(), []string{"doc_x"}, svc, WaitOptions{
|
||||
Timeout: 5 * time.Second,
|
||||
Interval: 1 * time.Millisecond, // fast for tests
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("waitForDocs: %v", err)
|
||||
}
|
||||
if len(res.Completed) != 1 || res.Completed[0] != "doc_x" {
|
||||
t.Errorf("Completed=%v, want [doc_x]", res.Completed)
|
||||
}
|
||||
if svc.calls["doc_x"] < 3 {
|
||||
t.Errorf("expected at least 3 polls, got %d", svc.calls["doc_x"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestWaitForDocs_SingleIDFails(t *testing.T) {
|
||||
svc := newFakeKBSvc(map[string][]string{
|
||||
"doc_x": {"failed"},
|
||||
})
|
||||
res, _ := waitForDocs(context.Background(), []string{"doc_x"}, svc, WaitOptions{
|
||||
Timeout: 5 * time.Second,
|
||||
Interval: 1 * time.Millisecond,
|
||||
})
|
||||
if len(res.Failed) != 1 || res.Failed[0].ID != "doc_x" {
|
||||
t.Errorf("Failed=%v, want [doc_x]", res.Failed)
|
||||
}
|
||||
if res.Failed[0].Message != "parse error" {
|
||||
t.Errorf("Failed[0].Message=%q, want %q", res.Failed[0].Message, "parse error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestWaitForDocs_Timeout(t *testing.T) {
|
||||
svc := newFakeKBSvc(map[string][]string{
|
||||
"doc_x": {"processing"}, // never completes
|
||||
})
|
||||
res, _ := waitForDocs(context.Background(), []string{"doc_x"}, svc, WaitOptions{
|
||||
Timeout: 20 * time.Millisecond, // tight
|
||||
Interval: 5 * time.Millisecond,
|
||||
})
|
||||
if len(res.Timeout) != 1 || res.Timeout[0] != "doc_x" {
|
||||
t.Errorf("Timeout=%v, want [doc_x]", res.Timeout)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// B3: multi-id wait-all behavior
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestWaitForDocs_MultiID_AllSucceed(t *testing.T) {
|
||||
svc := newFakeKBSvc(map[string][]string{
|
||||
"a": {"processing", "completed"},
|
||||
"b": {"completed"},
|
||||
"c": {"processing", "processing", "completed"},
|
||||
})
|
||||
res, err := waitForDocs(context.Background(), []string{"a", "b", "c"}, svc, WaitOptions{
|
||||
Timeout: 5 * time.Second,
|
||||
Interval: 1 * time.Millisecond,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("waitForDocs: %v", err)
|
||||
}
|
||||
if len(res.Completed) != 3 {
|
||||
t.Errorf("Completed=%v (len %d), want 3", res.Completed, len(res.Completed))
|
||||
}
|
||||
if len(res.Failed) != 0 || len(res.Timeout) != 0 {
|
||||
t.Errorf("Failed=%v Timeout=%v, want both empty", res.Failed, res.Timeout)
|
||||
}
|
||||
}
|
||||
|
||||
// TestWaitForDocs_MultiID_WaitAllOnPartialFailure verifies wait-all
|
||||
// semantics: even when one id fails terminally, the remaining ids are
|
||||
// still waited on until they reach their own terminal state.
|
||||
func TestWaitForDocs_MultiID_WaitAllOnPartialFailure(t *testing.T) {
|
||||
svc := newFakeKBSvc(map[string][]string{
|
||||
"a": {"failed"},
|
||||
"b": {"processing", "completed"},
|
||||
"c": {"completed"},
|
||||
})
|
||||
res, _ := waitForDocs(context.Background(), []string{"a", "b", "c"}, svc, WaitOptions{
|
||||
Timeout: 5 * time.Second,
|
||||
Interval: 1 * time.Millisecond,
|
||||
})
|
||||
if len(res.Failed) != 1 || res.Failed[0].ID != "a" {
|
||||
t.Errorf("Failed=%v, want [{a, parse error}]", res.Failed)
|
||||
}
|
||||
completedSet := make(map[string]bool)
|
||||
for _, id := range res.Completed {
|
||||
completedSet[id] = true
|
||||
}
|
||||
if !completedSet["b"] || !completedSet["c"] {
|
||||
t.Errorf("Completed=%v, want both b and c present", res.Completed)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// B5: emitWaitResult rendering tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestEmitWaitResult_TextSummary(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
fopts := &cmdutil.FormatOptions{Mode: cmdutil.FormatText}
|
||||
res := &WaitResult{
|
||||
Completed: []string{"a", "b"},
|
||||
Failed: []FailedDoc{{ID: "c", Message: "parse boom"}},
|
||||
Timeout: []string{"d"},
|
||||
}
|
||||
if err := emitWaitResult(res, fopts, &buf); err != nil {
|
||||
t.Fatalf("emitWaitResult: %v", err)
|
||||
}
|
||||
out := buf.String()
|
||||
for _, want := range []string{"a", "b", "c", "d", "parse boom"} {
|
||||
if !strings.Contains(out, want) {
|
||||
t.Errorf("text output missing %q:\n%s", want, out)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestEmitWaitResult_JSONSingleRecord(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
fopts := &cmdutil.FormatOptions{Mode: cmdutil.FormatJSON}
|
||||
res := &WaitResult{
|
||||
Completed: []string{"a"},
|
||||
Failed: []FailedDoc{{ID: "b", Message: "x"}},
|
||||
}
|
||||
if err := emitWaitResult(res, fopts, &buf); err != nil {
|
||||
t.Fatalf("emitWaitResult: %v", err)
|
||||
}
|
||||
var env struct {
|
||||
OK bool `json:"ok"`
|
||||
Data WaitResult `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 len(got.Completed) != 1 || got.Completed[0] != "a" {
|
||||
t.Errorf("Completed=%v, want [a]", got.Completed)
|
||||
}
|
||||
if len(got.Failed) != 1 || got.Failed[0].ID != "b" {
|
||||
t.Errorf("Failed=%v, want [{b, x}]", got.Failed)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEmitWaitResult_NDJSONSingleLine(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
fopts := &cmdutil.FormatOptions{Mode: cmdutil.FormatNDJSON}
|
||||
res := &WaitResult{Completed: []string{"a"}}
|
||||
if err := emitWaitResult(res, fopts, &buf); err != nil {
|
||||
t.Fatalf("emitWaitResult: %v", err)
|
||||
}
|
||||
out := buf.String()
|
||||
// NDJSON: single record = single line ending with \n
|
||||
if strings.Count(out, "\n") != 1 {
|
||||
t.Errorf("expected exactly 1 newline, got %q", out)
|
||||
}
|
||||
var got WaitResult
|
||||
if err := json.Unmarshal([]byte(strings.TrimRight(out, "\n")), &got); err != nil {
|
||||
t.Fatalf("line not JSON: %v\n%s", err, out)
|
||||
}
|
||||
if len(got.Completed) != 1 {
|
||||
t.Error("expected Completed populated")
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// B4: WaitResult.ExitCode tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestWaitResult_ExitCode(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
res *WaitResult
|
||||
want int
|
||||
}{
|
||||
{"all completed", &WaitResult{Completed: []string{"a"}}, 0},
|
||||
{"empty result", &WaitResult{}, 0},
|
||||
{"any failed", &WaitResult{Failed: []FailedDoc{{ID: "a"}}}, 1},
|
||||
{"timeout only", &WaitResult{Timeout: []string{"a"}}, 124},
|
||||
{"failed wins over timeout", &WaitResult{
|
||||
Failed: []FailedDoc{{ID: "a"}},
|
||||
Timeout: []string{"b"},
|
||||
}, 1},
|
||||
{"failed wins over completed+timeout", &WaitResult{
|
||||
Completed: []string{"a"},
|
||||
Failed: []FailedDoc{{ID: "b"}},
|
||||
Timeout: []string{"c"},
|
||||
}, 1},
|
||||
{"timeout wins over completed", &WaitResult{
|
||||
Completed: []string{"a"},
|
||||
Timeout: []string{"b"},
|
||||
}, 124},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
if got := tc.res.ExitCode(); got != tc.want {
|
||||
t.Errorf("ExitCode = %d, want %d", got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// On aggregate failure, doc wait emits the partition envelope to stdout; the
|
||||
// returned error must be Silent so PrintError does NOT write a second,
|
||||
// contradictory {ok:false} envelope to stderr. Regression for the dual-envelope
|
||||
// contract violation (stdout ok:true + stderr ok:false for one invocation).
|
||||
func TestDocWait_FailureError_IsSilent(t *testing.T) {
|
||||
// Mirror RunBatch's contract: the failure carries CodeOperationFailed for
|
||||
// the exit code, but Silent=true suppresses the stderr envelope.
|
||||
err := cmdutil.NewError(cmdutil.CodeOperationFailed, "1 doc(s) failed").WithSilent()
|
||||
typed := cmdutil.AsError(err)
|
||||
if typed == nil {
|
||||
t.Fatal("expected typed *Error")
|
||||
}
|
||||
if !typed.Silent {
|
||||
t.Error("doc wait failure error must be Silent to avoid a contradictory stderr envelope")
|
||||
}
|
||||
if cmdutil.ExitCode(err) != 1 {
|
||||
t.Errorf("exit code = %d, want 1", cmdutil.ExitCode(err))
|
||||
}
|
||||
}
|
||||
|
||||
// TestWaitForDocs_DraftFailsFast pins that a document stuck in parse_status
|
||||
// "draft" (inline `doc create` leaves docs here; it never auto-progresses) is
|
||||
// failed fast with a reparse hint instead of polling until the --timeout.
|
||||
// Regression: `doc create` + `doc wait` used to hang to a 124 timeout.
|
||||
func TestWaitForDocs_DraftFailsFast(t *testing.T) {
|
||||
svc := newFakeKBSvc(map[string][]string{
|
||||
"doc_draft": {"draft", "draft", "draft"},
|
||||
})
|
||||
start := time.Now()
|
||||
res, _ := waitForDocs(context.Background(), []string{"doc_draft"}, svc, WaitOptions{
|
||||
Timeout: 5 * time.Second,
|
||||
Interval: 1 * time.Millisecond,
|
||||
})
|
||||
if len(res.Timeout) != 0 {
|
||||
t.Errorf("draft must NOT time out; got timeout=%v", res.Timeout)
|
||||
}
|
||||
if len(res.Failed) != 1 || res.Failed[0].ID != "doc_draft" {
|
||||
t.Fatalf("draft doc must be failed-fast; got failed=%v", res.Failed)
|
||||
}
|
||||
if !strings.Contains(res.Failed[0].Message, "reparse") {
|
||||
t.Errorf("draft failure message must point to reparse; got %q", res.Failed[0].Message)
|
||||
}
|
||||
if elapsed := time.Since(start); elapsed > time.Second {
|
||||
t.Errorf("draft must fail fast (well under timeout); took %v", elapsed)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user