Add bulk operations and remove collections abstraction
- Add bulk delete, bulk tags, and bulk set-tags engine endpoints (POST /api/v1/bulk/delete, /bulk/tags, /bulk/set-tags) - Filter-based selection: by tags, doc_type, ID list, ID range - Safety threshold (KB_BULK_SAFETY_PERCENT, default 70%) prevents accidental mass operations unless force=true - Synchronous execution with audit trail via jobs table - Add kb_bulk_delete, kb_bulk_tags, kb_bulk_set_tags MCP tools - Add kb bulk-remove, bulk-tag, bulk-set-tags CLI commands - Remove collection abstraction from MCP server (use tags instead) - Remove kb_set_collection MCP tool - Update SKILL.md, MCP.md, README.md documentation Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,186 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"fmt"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/kb-search/kb/internal/api"
|
||||
"github.com/kb-search/kb/internal/output"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
var bulkRemoveCmd = &cobra.Command{
|
||||
Use: "bulk-remove",
|
||||
Short: "Delete multiple documents matching a filter",
|
||||
RunE: runBulkRemove,
|
||||
}
|
||||
|
||||
func init() {
|
||||
addBulkFilterFlags(bulkRemoveCmd)
|
||||
rootCmd.AddCommand(bulkRemoveCmd)
|
||||
}
|
||||
|
||||
func runBulkRemove(cmd *cobra.Command, args []string) error {
|
||||
body, err := buildBulkBody(cmd)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
yes, _ := cmd.Flags().GetBool("yes")
|
||||
if !yes {
|
||||
desc := describeBulkFilter(cmd)
|
||||
fmt.Printf("This will delete documents matching: %s\nProceed? [y/N] ", desc)
|
||||
reader := bufio.NewReader(os.Stdin)
|
||||
answer, _ := reader.ReadString('\n')
|
||||
answer = strings.TrimSpace(strings.ToLower(answer))
|
||||
if answer != "y" && answer != "yes" {
|
||||
fmt.Println("Cancelled.")
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
client := api.NewClient()
|
||||
resp, err := client.Post("/api/v1/bulk/delete", body)
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
os.Exit(1)
|
||||
}
|
||||
if err := api.CheckError(resp); err != nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
var result map[string]interface{}
|
||||
if err := api.DecodeJSON(resp, &result); err != nil {
|
||||
return fmt.Errorf("failed to decode response: %w", err)
|
||||
}
|
||||
|
||||
if output.IsJSON() {
|
||||
output.PrintJSON(result)
|
||||
} else {
|
||||
printBulkResult("Deleted", result)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Shared helpers for all bulk commands
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func addBulkFilterFlags(cmd *cobra.Command) {
|
||||
cmd.Flags().String("tags", "", "filter by tags (comma-separated)")
|
||||
cmd.Flags().String("type", "", "filter by document type")
|
||||
cmd.Flags().String("ids", "", "filter by document IDs (comma-separated)")
|
||||
cmd.Flags().Int("from-id", 0, "filter by id >= value")
|
||||
cmd.Flags().Int("to-id", 0, "filter by id <= value")
|
||||
cmd.Flags().BoolP("force", "f", false, "override safety threshold")
|
||||
cmd.Flags().BoolP("yes", "y", false, "skip confirmation prompt")
|
||||
}
|
||||
|
||||
func buildBulkBody(cmd *cobra.Command) (map[string]interface{}, error) {
|
||||
body := map[string]interface{}{}
|
||||
|
||||
tagsStr, _ := cmd.Flags().GetString("tags")
|
||||
if tagsStr != "" {
|
||||
body["tags"] = splitTags(tagsStr)
|
||||
}
|
||||
|
||||
docType, _ := cmd.Flags().GetString("type")
|
||||
if docType != "" {
|
||||
body["doc_type"] = docType
|
||||
}
|
||||
|
||||
idsStr, _ := cmd.Flags().GetString("ids")
|
||||
if idsStr != "" {
|
||||
ids, err := parseIntList(idsStr)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid --ids: %w", err)
|
||||
}
|
||||
body["document_ids"] = ids
|
||||
}
|
||||
|
||||
fromID, _ := cmd.Flags().GetInt("from-id")
|
||||
if fromID > 0 {
|
||||
body["from_id"] = fromID
|
||||
}
|
||||
|
||||
toID, _ := cmd.Flags().GetInt("to-id")
|
||||
if toID > 0 {
|
||||
body["to_id"] = toID
|
||||
}
|
||||
|
||||
force, _ := cmd.Flags().GetBool("force")
|
||||
if force {
|
||||
body["force"] = true
|
||||
}
|
||||
|
||||
// Ensure at least one filter
|
||||
hasFilter := tagsStr != "" || docType != "" || idsStr != "" || fromID > 0 || toID > 0
|
||||
if !hasFilter {
|
||||
return nil, fmt.Errorf("at least one filter is required (--tags, --type, --ids, --from-id, --to-id)")
|
||||
}
|
||||
|
||||
return body, nil
|
||||
}
|
||||
|
||||
func describeBulkFilter(cmd *cobra.Command) string {
|
||||
var parts []string
|
||||
|
||||
tagsStr, _ := cmd.Flags().GetString("tags")
|
||||
if tagsStr != "" {
|
||||
parts = append(parts, fmt.Sprintf("tags=[%s]", tagsStr))
|
||||
}
|
||||
|
||||
docType, _ := cmd.Flags().GetString("type")
|
||||
if docType != "" {
|
||||
parts = append(parts, fmt.Sprintf("type=%s", docType))
|
||||
}
|
||||
|
||||
idsStr, _ := cmd.Flags().GetString("ids")
|
||||
if idsStr != "" {
|
||||
parts = append(parts, fmt.Sprintf("ids=[%s]", idsStr))
|
||||
}
|
||||
|
||||
fromID, _ := cmd.Flags().GetInt("from-id")
|
||||
if fromID > 0 {
|
||||
parts = append(parts, fmt.Sprintf("from_id=%d", fromID))
|
||||
}
|
||||
|
||||
toID, _ := cmd.Flags().GetInt("to-id")
|
||||
if toID > 0 {
|
||||
parts = append(parts, fmt.Sprintf("to_id=%d", toID))
|
||||
}
|
||||
|
||||
return strings.Join(parts, " ")
|
||||
}
|
||||
|
||||
func printBulkResult(action string, result map[string]interface{}) {
|
||||
matched := int(result["matched"].(float64))
|
||||
succeeded := int(result["succeeded"].(float64))
|
||||
failed := int(result["failed"].(float64))
|
||||
|
||||
fmt.Printf("%s %d of %d documents", action, succeeded, matched)
|
||||
if failed > 0 {
|
||||
fmt.Printf(" (%d failed)", failed)
|
||||
}
|
||||
fmt.Println()
|
||||
}
|
||||
|
||||
func parseIntList(s string) ([]int, error) {
|
||||
var ids []int
|
||||
for _, part := range strings.Split(s, ",") {
|
||||
part = strings.TrimSpace(part)
|
||||
if part == "" {
|
||||
continue
|
||||
}
|
||||
id, err := strconv.Atoi(part)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid ID %q: %w", part, err)
|
||||
}
|
||||
ids = append(ids, id)
|
||||
}
|
||||
return ids, nil
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/kb-search/kb/internal/api"
|
||||
"github.com/kb-search/kb/internal/output"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
var bulkSetTagsCmd = &cobra.Command{
|
||||
Use: "bulk-set-tags",
|
||||
Short: "Replace all tags on multiple documents matching a filter",
|
||||
RunE: runBulkSetTags,
|
||||
}
|
||||
|
||||
func init() {
|
||||
addBulkFilterFlags(bulkSetTagsCmd)
|
||||
bulkSetTagsCmd.Flags().String("set", "", "replacement tags (comma-separated)")
|
||||
rootCmd.AddCommand(bulkSetTagsCmd)
|
||||
}
|
||||
|
||||
func runBulkSetTags(cmd *cobra.Command, args []string) error {
|
||||
body, err := buildBulkBody(cmd)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
setStr, _ := cmd.Flags().GetString("set")
|
||||
if setStr == "" {
|
||||
return fmt.Errorf("--set is required (comma-separated list of replacement tags)")
|
||||
}
|
||||
body["new_tags"] = splitTags(setStr)
|
||||
|
||||
yes, _ := cmd.Flags().GetBool("yes")
|
||||
if !yes {
|
||||
desc := describeBulkFilter(cmd)
|
||||
fmt.Printf("This will replace all tags with [%s] on documents matching: %s\nProceed? [y/N] ", setStr, desc)
|
||||
reader := bufio.NewReader(os.Stdin)
|
||||
answer, _ := reader.ReadString('\n')
|
||||
answer = strings.TrimSpace(strings.ToLower(answer))
|
||||
if answer != "y" && answer != "yes" {
|
||||
fmt.Println("Cancelled.")
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
client := api.NewClient()
|
||||
resp, err := client.Post("/api/v1/bulk/set-tags", body)
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
os.Exit(1)
|
||||
}
|
||||
if err := api.CheckError(resp); err != nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
var result map[string]interface{}
|
||||
if err := api.DecodeJSON(resp, &result); err != nil {
|
||||
return fmt.Errorf("failed to decode response: %w", err)
|
||||
}
|
||||
|
||||
if output.IsJSON() {
|
||||
output.PrintJSON(result)
|
||||
} else {
|
||||
printBulkResult("Set tags on", result)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/kb-search/kb/internal/api"
|
||||
"github.com/kb-search/kb/internal/output"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
var bulkTagCmd = &cobra.Command{
|
||||
Use: "bulk-tag",
|
||||
Short: "Add or remove tags on multiple documents matching a filter",
|
||||
RunE: runBulkTag,
|
||||
}
|
||||
|
||||
func init() {
|
||||
addBulkFilterFlags(bulkTagCmd)
|
||||
bulkTagCmd.Flags().String("add", "", "tags to add (comma-separated)")
|
||||
bulkTagCmd.Flags().String("remove", "", "tags to remove (comma-separated)")
|
||||
rootCmd.AddCommand(bulkTagCmd)
|
||||
}
|
||||
|
||||
func runBulkTag(cmd *cobra.Command, args []string) error {
|
||||
body, err := buildBulkBody(cmd)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
addStr, _ := cmd.Flags().GetString("add")
|
||||
removeStr, _ := cmd.Flags().GetString("remove")
|
||||
|
||||
if addStr == "" && removeStr == "" {
|
||||
return fmt.Errorf("specify --add and/or --remove")
|
||||
}
|
||||
|
||||
if addStr != "" {
|
||||
body["add"] = splitTags(addStr)
|
||||
}
|
||||
if removeStr != "" {
|
||||
body["remove"] = splitTags(removeStr)
|
||||
}
|
||||
|
||||
yes, _ := cmd.Flags().GetBool("yes")
|
||||
if !yes {
|
||||
desc := describeBulkFilter(cmd)
|
||||
action := ""
|
||||
if addStr != "" {
|
||||
action += fmt.Sprintf("add=[%s]", addStr)
|
||||
}
|
||||
if removeStr != "" {
|
||||
if action != "" {
|
||||
action += " "
|
||||
}
|
||||
action += fmt.Sprintf("remove=[%s]", removeStr)
|
||||
}
|
||||
fmt.Printf("This will update tags (%s) on documents matching: %s\nProceed? [y/N] ", action, desc)
|
||||
reader := bufio.NewReader(os.Stdin)
|
||||
answer, _ := reader.ReadString('\n')
|
||||
answer = strings.TrimSpace(strings.ToLower(answer))
|
||||
if answer != "y" && answer != "yes" {
|
||||
fmt.Println("Cancelled.")
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
client := api.NewClient()
|
||||
resp, err := client.Post("/api/v1/bulk/tags", body)
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
os.Exit(1)
|
||||
}
|
||||
if err := api.CheckError(resp); err != nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
var result map[string]interface{}
|
||||
if err := api.DecodeJSON(resp, &result); err != nil {
|
||||
return fmt.Errorf("failed to decode response: %w", err)
|
||||
}
|
||||
|
||||
if output.IsJSON() {
|
||||
output.PrintJSON(result)
|
||||
} else {
|
||||
printBulkResult("Tagged", result)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
Reference in New Issue
Block a user