v2 restructure: Go client, Docker engine, release tooling
- Remove v1 Python CLI (src/kb_search/, tests/, root pyproject.toml, uv.lock, .venv) - Add Go client with cross-platform build (client/) - Add FastAPI engine with NVIDIA and multi-stage ROCm Dockerfiles (engine/) - Add VERSION files for client and engine, wired into builds - Add release.sh for automated build, tag, release, and Docker push - Update README with build/release docs and ROCm migration note - Clean up .gitignore for v2 project structure Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,192 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/kb-search/kb/internal/api"
|
||||
"github.com/kb-search/kb/internal/output"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
var supportedExts = map[string]bool{
|
||||
".pdf": true,
|
||||
".docx": true,
|
||||
".html": true,
|
||||
".md": true,
|
||||
".txt": true,
|
||||
".py": true,
|
||||
".sh": true,
|
||||
".go": true,
|
||||
}
|
||||
|
||||
var addCmd = &cobra.Command{
|
||||
Use: "add <path>",
|
||||
Short: "Add a document or directory to the knowledge base",
|
||||
Args: cobra.MaximumNArgs(1),
|
||||
RunE: runAdd,
|
||||
}
|
||||
|
||||
func init() {
|
||||
addCmd.Flags().String("tags", "", "tags (comma-separated)")
|
||||
addCmd.Flags().String("type", "", "document type")
|
||||
addCmd.Flags().BoolP("recursive", "r", false, "recursively add directory contents")
|
||||
addCmd.Flags().String("note", "", "add a text note instead of a file")
|
||||
addCmd.Flags().String("title", "", "title for the note")
|
||||
rootCmd.AddCommand(addCmd)
|
||||
}
|
||||
|
||||
func runAdd(cmd *cobra.Command, args []string) error {
|
||||
tags, _ := cmd.Flags().GetString("tags")
|
||||
docType, _ := cmd.Flags().GetString("type")
|
||||
recursive, _ := cmd.Flags().GetBool("recursive")
|
||||
note, _ := cmd.Flags().GetString("note")
|
||||
title, _ := cmd.Flags().GetString("title")
|
||||
|
||||
client := api.NewClient()
|
||||
|
||||
// Note mode
|
||||
if note != "" {
|
||||
fields := map[string]string{
|
||||
"note": note,
|
||||
}
|
||||
if title != "" {
|
||||
fields["title"] = title
|
||||
}
|
||||
if tags != "" {
|
||||
fields["tags"] = tags
|
||||
}
|
||||
if docType != "" {
|
||||
fields["type"] = docType
|
||||
}
|
||||
|
||||
resp, err := client.PostMultipart("/api/v1/jobs", fields, nil)
|
||||
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 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 {
|
||||
fmt.Println("Queued: note")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
if len(args) == 0 {
|
||||
return fmt.Errorf("path argument is required (or use --note)")
|
||||
}
|
||||
|
||||
path := args[0]
|
||||
info, err := os.Stat(path)
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot access %s: %w", path, err)
|
||||
}
|
||||
|
||||
if !info.IsDir() {
|
||||
// Single file upload
|
||||
result, err := uploadFile(client, path, tags, docType)
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
if output.IsJSON() {
|
||||
output.PrintJSON([]interface{}{result})
|
||||
} else {
|
||||
fmt.Printf("Queued: %s\n", filepath.Base(path))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Directory upload
|
||||
var files []string
|
||||
walkFn := func(p string, d os.DirEntry, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if d.IsDir() {
|
||||
if !recursive && p != path {
|
||||
return filepath.SkipDir
|
||||
}
|
||||
return nil
|
||||
}
|
||||
ext := strings.ToLower(filepath.Ext(p))
|
||||
if supportedExts[ext] {
|
||||
files = append(files, p)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
if err := filepath.WalkDir(path, walkFn); err != nil {
|
||||
return fmt.Errorf("failed to walk directory: %w", err)
|
||||
}
|
||||
|
||||
var results []interface{}
|
||||
for _, f := range files {
|
||||
result, err := uploadFile(client, f, tags, docType)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error uploading %s: %v\n", f, err)
|
||||
continue
|
||||
}
|
||||
results = append(results, result)
|
||||
if !output.IsJSON() {
|
||||
fmt.Printf("Queued: %s\n", filepath.Base(f))
|
||||
}
|
||||
}
|
||||
|
||||
if output.IsJSON() {
|
||||
output.PrintJSON(results)
|
||||
} else {
|
||||
fmt.Printf("Queued: %d files\n", len(results))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func uploadFile(client *api.Client, path, tags, docType string) (interface{}, error) {
|
||||
f, err := os.Open(path)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("cannot open %s: %w", path, err)
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
fields := make(map[string]string)
|
||||
if tags != "" {
|
||||
fields["tags"] = tags
|
||||
}
|
||||
if docType != "" {
|
||||
fields["type"] = docType
|
||||
}
|
||||
|
||||
upload := &api.FileUpload{
|
||||
FieldName: "file",
|
||||
FileName: filepath.Base(path),
|
||||
Reader: f,
|
||||
}
|
||||
|
||||
resp, err := client.PostMultipart("/api/v1/jobs", fields, upload)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := api.CheckError(resp); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var result interface{}
|
||||
if err := api.DecodeJSON(resp, &result); err != nil {
|
||||
return nil, fmt.Errorf("failed to decode response: %w", err)
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"github.com/kb-search/kb/internal/api"
|
||||
"github.com/kb-search/kb/internal/output"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
var infoCmd = &cobra.Command{
|
||||
Use: "info <id>",
|
||||
Short: "Show document details",
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: runInfo,
|
||||
}
|
||||
|
||||
func init() {
|
||||
rootCmd.AddCommand(infoCmd)
|
||||
}
|
||||
|
||||
func runInfo(cmd *cobra.Command, args []string) error {
|
||||
client := api.NewClient()
|
||||
resp, err := client.Get("/api/v1/documents/" + args[0])
|
||||
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)
|
||||
}
|
||||
|
||||
if output.IsJSON() {
|
||||
var raw interface{}
|
||||
if err := api.DecodeJSON(resp, &raw); err != nil {
|
||||
return fmt.Errorf("failed to decode response: %w", err)
|
||||
}
|
||||
output.PrintJSON(raw)
|
||||
return nil
|
||||
}
|
||||
|
||||
var doc struct {
|
||||
ID int `json:"id"`
|
||||
Title string `json:"title"`
|
||||
Type string `json:"doc_type"`
|
||||
Tags []string `json:"tags"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
UpdatedAt string `json:"updated_at"`
|
||||
Chunks []struct {
|
||||
ID int `json:"id"`
|
||||
Page interface{} `json:"page"`
|
||||
Section string `json:"section"`
|
||||
} `json:"chunks"`
|
||||
}
|
||||
if err := api.DecodeJSON(resp, &doc); err != nil {
|
||||
return fmt.Errorf("failed to decode response: %w", err)
|
||||
}
|
||||
|
||||
pairs := [][]string{
|
||||
{"ID", fmt.Sprintf("%d", doc.ID)},
|
||||
{"Title", doc.Title},
|
||||
{"Type", doc.Type},
|
||||
{"Tags", joinStrings(doc.Tags)},
|
||||
{"Created", doc.CreatedAt},
|
||||
{"Updated", doc.UpdatedAt},
|
||||
{"Chunks", fmt.Sprintf("%d", len(doc.Chunks))},
|
||||
}
|
||||
output.PrintKeyValue(pairs)
|
||||
|
||||
if len(doc.Chunks) > 0 {
|
||||
fmt.Println()
|
||||
headers := []string{"CHUNK ID", "PAGE", "SECTION"}
|
||||
var rows [][]string
|
||||
for _, c := range doc.Chunks {
|
||||
page := ""
|
||||
if c.Page != nil {
|
||||
page = fmt.Sprintf("%v", c.Page)
|
||||
}
|
||||
rows = append(rows, []string{fmt.Sprintf("%d", c.ID), page, c.Section})
|
||||
}
|
||||
output.PrintTable(headers, rows)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"github.com/kb-search/kb/internal/api"
|
||||
"github.com/kb-search/kb/internal/output"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
var jobsCmd = &cobra.Command{
|
||||
Use: "jobs [id]",
|
||||
Short: "List jobs or show job details",
|
||||
Args: cobra.MaximumNArgs(1),
|
||||
RunE: runJobs,
|
||||
}
|
||||
|
||||
func init() {
|
||||
jobsCmd.Flags().String("status", "", "filter by status")
|
||||
rootCmd.AddCommand(jobsCmd)
|
||||
}
|
||||
|
||||
func runJobs(cmd *cobra.Command, args []string) error {
|
||||
client := api.NewClient()
|
||||
|
||||
if len(args) == 1 {
|
||||
// Show single job
|
||||
resp, err := client.Get("/api/v1/jobs/" + args[0])
|
||||
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)
|
||||
}
|
||||
|
||||
if output.IsJSON() {
|
||||
var raw interface{}
|
||||
if err := api.DecodeJSON(resp, &raw); err != nil {
|
||||
return fmt.Errorf("failed to decode response: %w", err)
|
||||
}
|
||||
output.PrintJSON(raw)
|
||||
return nil
|
||||
}
|
||||
|
||||
var job struct {
|
||||
ID int `json:"id"`
|
||||
Status string `json:"status"`
|
||||
Filename string `json:"filename"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
UpdatedAt string `json:"updated_at"`
|
||||
Error string `json:"error"`
|
||||
}
|
||||
if err := api.DecodeJSON(resp, &job); err != nil {
|
||||
return fmt.Errorf("failed to decode response: %w", err)
|
||||
}
|
||||
|
||||
pairs := [][]string{
|
||||
{"ID", fmt.Sprintf("%d", job.ID)},
|
||||
{"Status", job.Status},
|
||||
{"Filename", job.Filename},
|
||||
{"Created", job.CreatedAt},
|
||||
{"Updated", job.UpdatedAt},
|
||||
}
|
||||
if job.Error != "" {
|
||||
pairs = append(pairs, []string{"Error", job.Error})
|
||||
}
|
||||
output.PrintKeyValue(pairs)
|
||||
return nil
|
||||
}
|
||||
|
||||
// List jobs
|
||||
path := "/api/v1/jobs"
|
||||
status, _ := cmd.Flags().GetString("status")
|
||||
if status != "" {
|
||||
path += "?status=" + status
|
||||
}
|
||||
|
||||
resp, err := client.Get(path)
|
||||
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)
|
||||
}
|
||||
|
||||
if output.IsJSON() {
|
||||
var raw interface{}
|
||||
if err := api.DecodeJSON(resp, &raw); err != nil {
|
||||
return fmt.Errorf("failed to decode response: %w", err)
|
||||
}
|
||||
output.PrintJSON(raw)
|
||||
return nil
|
||||
}
|
||||
|
||||
var jobs []struct {
|
||||
ID int `json:"id"`
|
||||
Status string `json:"status"`
|
||||
Filename string `json:"filename"`
|
||||
}
|
||||
if err := api.DecodeJSON(resp, &jobs); err != nil {
|
||||
return fmt.Errorf("failed to decode response: %w", err)
|
||||
}
|
||||
|
||||
if len(jobs) == 0 {
|
||||
fmt.Println("No jobs found.")
|
||||
return nil
|
||||
}
|
||||
|
||||
headers := []string{"ID", "STATUS", "FILENAME"}
|
||||
var rows [][]string
|
||||
for _, j := range jobs {
|
||||
rows = append(rows, []string{fmt.Sprintf("%d", j.ID), j.Status, j.Filename})
|
||||
}
|
||||
output.PrintTable(headers, rows)
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/url"
|
||||
"os"
|
||||
|
||||
"github.com/kb-search/kb/internal/api"
|
||||
"github.com/kb-search/kb/internal/output"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
var listCmd = &cobra.Command{
|
||||
Use: "list",
|
||||
Short: "List documents in the knowledge base",
|
||||
RunE: runList,
|
||||
}
|
||||
|
||||
func init() {
|
||||
listCmd.Flags().String("type", "", "filter by document type")
|
||||
listCmd.Flags().String("tags", "", "filter by tags (comma-separated)")
|
||||
rootCmd.AddCommand(listCmd)
|
||||
}
|
||||
|
||||
func runList(cmd *cobra.Command, args []string) error {
|
||||
docType, _ := cmd.Flags().GetString("type")
|
||||
tags, _ := cmd.Flags().GetString("tags")
|
||||
|
||||
params := url.Values{}
|
||||
if docType != "" {
|
||||
params.Set("type", docType)
|
||||
}
|
||||
if tags != "" {
|
||||
params.Set("tags", tags)
|
||||
}
|
||||
|
||||
path := "/api/v1/documents"
|
||||
if len(params) > 0 {
|
||||
path += "?" + params.Encode()
|
||||
}
|
||||
|
||||
client := api.NewClient()
|
||||
resp, err := client.Get(path)
|
||||
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)
|
||||
}
|
||||
|
||||
if output.IsJSON() {
|
||||
var raw interface{}
|
||||
if err := api.DecodeJSON(resp, &raw); err != nil {
|
||||
return fmt.Errorf("failed to decode response: %w", err)
|
||||
}
|
||||
output.PrintJSON(raw)
|
||||
return nil
|
||||
}
|
||||
|
||||
var docs []struct {
|
||||
ID int `json:"id"`
|
||||
Title string `json:"title"`
|
||||
Type string `json:"doc_type"`
|
||||
Tags []string `json:"tags"`
|
||||
}
|
||||
if err := api.DecodeJSON(resp, &docs); err != nil {
|
||||
return fmt.Errorf("failed to decode response: %w", err)
|
||||
}
|
||||
|
||||
if len(docs) == 0 {
|
||||
fmt.Println("No documents found.")
|
||||
return nil
|
||||
}
|
||||
|
||||
headers := []string{"ID", "TITLE", "TYPE", "TAGS"}
|
||||
var rows [][]string
|
||||
for _, d := range docs {
|
||||
rows = append(rows, []string{fmt.Sprintf("%d", d.ID), d.Title, d.Type, joinStrings(d.Tags)})
|
||||
}
|
||||
output.PrintTable(headers, rows)
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
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 removeCmd = &cobra.Command{
|
||||
Use: "remove <id>",
|
||||
Short: "Remove a document from the knowledge base",
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: runRemove,
|
||||
}
|
||||
|
||||
func init() {
|
||||
removeCmd.Flags().BoolP("yes", "y", false, "skip confirmation prompt")
|
||||
rootCmd.AddCommand(removeCmd)
|
||||
}
|
||||
|
||||
func runRemove(cmd *cobra.Command, args []string) error {
|
||||
yes, _ := cmd.Flags().GetBool("yes")
|
||||
|
||||
if !yes {
|
||||
fmt.Printf("Remove document %s? [y/N] ", args[0])
|
||||
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.Delete("/api/v1/documents/" + args[0])
|
||||
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)
|
||||
}
|
||||
|
||||
if output.IsJSON() {
|
||||
var raw interface{}
|
||||
if err := api.DecodeJSON(resp, &raw); err != nil {
|
||||
// Some DELETE endpoints return no body
|
||||
output.PrintJSON(map[string]string{"status": "removed", "id": args[0]})
|
||||
return nil
|
||||
}
|
||||
output.PrintJSON(raw)
|
||||
} else {
|
||||
fmt.Printf("Removed: %s\n", args[0])
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"github.com/kb-search/kb/internal/config"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
// Version is set at build time via -ldflags.
|
||||
var Version = "dev"
|
||||
|
||||
var (
|
||||
flagEngine string
|
||||
flagFormat string
|
||||
flagAPIKey string
|
||||
)
|
||||
|
||||
var rootCmd = &cobra.Command{
|
||||
Use: "kb",
|
||||
Short: "kb-search CLI client",
|
||||
Long: "A CLI client for the kb-search v2 engine API.",
|
||||
PersistentPreRunE: func(cmd *cobra.Command, args []string) error {
|
||||
if err := config.Load(); err != nil {
|
||||
return err
|
||||
}
|
||||
config.ApplyFlags(flagEngine, flagFormat, flagAPIKey)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
func init() {
|
||||
rootCmd.Version = Version
|
||||
rootCmd.PersistentFlags().StringVar(&flagEngine, "engine", "", "engine API URL")
|
||||
rootCmd.PersistentFlags().StringVar(&flagFormat, "format", "", "output format (human|json)")
|
||||
rootCmd.PersistentFlags().StringVar(&flagAPIKey, "api-key", "", "API key for authentication")
|
||||
}
|
||||
|
||||
// Execute runs the root command.
|
||||
func Execute() {
|
||||
if err := rootCmd.Execute(); err != nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"github.com/kb-search/kb/internal/api"
|
||||
"github.com/kb-search/kb/internal/output"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
var searchCmd = &cobra.Command{
|
||||
Use: "search <query>",
|
||||
Short: "Search the knowledge base",
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: runSearch,
|
||||
}
|
||||
|
||||
func init() {
|
||||
searchCmd.Flags().IntP("top", "n", 10, "number of results to return")
|
||||
searchCmd.Flags().String("tags", "", "filter by tags (comma-separated)")
|
||||
searchCmd.Flags().String("type", "", "filter by document type")
|
||||
searchCmd.Flags().Bool("fts-only", false, "use full-text search only")
|
||||
searchCmd.Flags().Bool("vec-only", false, "use vector search only")
|
||||
searchCmd.Flags().Float64("threshold", 0, "minimum score threshold")
|
||||
rootCmd.AddCommand(searchCmd)
|
||||
}
|
||||
|
||||
func runSearch(cmd *cobra.Command, args []string) error {
|
||||
top, _ := cmd.Flags().GetInt("top")
|
||||
tags, _ := cmd.Flags().GetString("tags")
|
||||
docType, _ := cmd.Flags().GetString("type")
|
||||
ftsOnly, _ := cmd.Flags().GetBool("fts-only")
|
||||
vecOnly, _ := cmd.Flags().GetBool("vec-only")
|
||||
threshold, _ := cmd.Flags().GetFloat64("threshold")
|
||||
|
||||
body := map[string]interface{}{
|
||||
"query": args[0],
|
||||
"top": top,
|
||||
}
|
||||
if tags != "" {
|
||||
body["tags"] = tags
|
||||
}
|
||||
if docType != "" {
|
||||
body["type"] = docType
|
||||
}
|
||||
if ftsOnly {
|
||||
body["fts_only"] = true
|
||||
}
|
||||
if vecOnly {
|
||||
body["vec_only"] = true
|
||||
}
|
||||
if threshold > 0 {
|
||||
body["threshold"] = threshold
|
||||
}
|
||||
|
||||
client := api.NewClient()
|
||||
resp, err := client.Post("/api/v1/search", 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 struct {
|
||||
Results []struct {
|
||||
Score float64 `json:"score"`
|
||||
Document struct {
|
||||
Title string `json:"title"`
|
||||
Type string `json:"doc_type"`
|
||||
Tags []string `json:"tags"`
|
||||
} `json:"document"`
|
||||
Page interface{} `json:"page"`
|
||||
Section string `json:"section"`
|
||||
Text string `json:"text"`
|
||||
} `json:"results"`
|
||||
}
|
||||
|
||||
if output.IsJSON() {
|
||||
var raw interface{}
|
||||
if err := api.DecodeJSON(resp, &raw); err != nil {
|
||||
return fmt.Errorf("failed to decode response: %w", err)
|
||||
}
|
||||
output.PrintJSON(raw)
|
||||
return nil
|
||||
}
|
||||
|
||||
if err := api.DecodeJSON(resp, &result); err != nil {
|
||||
return fmt.Errorf("failed to decode response: %w", err)
|
||||
}
|
||||
|
||||
if len(result.Results) == 0 {
|
||||
fmt.Println("No results found.")
|
||||
return nil
|
||||
}
|
||||
|
||||
for i, r := range result.Results {
|
||||
snippet := r.Text
|
||||
if len(snippet) > 200 {
|
||||
snippet = snippet[:200] + "..."
|
||||
}
|
||||
|
||||
fmt.Printf("\n%d. [%.4f] %s\n", i+1, r.Score, r.Document.Title)
|
||||
|
||||
location := ""
|
||||
if r.Page != nil {
|
||||
location = fmt.Sprintf("Page %v", r.Page)
|
||||
}
|
||||
if r.Section != "" {
|
||||
if location != "" {
|
||||
location += " / "
|
||||
}
|
||||
location += r.Section
|
||||
}
|
||||
if location != "" {
|
||||
fmt.Printf(" Location: %s\n", location)
|
||||
}
|
||||
if r.Document.Type != "" {
|
||||
fmt.Printf(" Type: %s\n", r.Document.Type)
|
||||
}
|
||||
if len(r.Document.Tags) > 0 {
|
||||
fmt.Printf(" Tags: %s\n", joinStrings(r.Document.Tags))
|
||||
}
|
||||
fmt.Printf(" %s\n", snippet)
|
||||
}
|
||||
fmt.Println()
|
||||
return nil
|
||||
}
|
||||
|
||||
func joinStrings(ss []string) string {
|
||||
result := ""
|
||||
for i, s := range ss {
|
||||
if i > 0 {
|
||||
result += ", "
|
||||
}
|
||||
result += s
|
||||
}
|
||||
return result
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"github.com/kb-search/kb/internal/api"
|
||||
"github.com/kb-search/kb/internal/output"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
var statusCmd = &cobra.Command{
|
||||
Use: "status",
|
||||
Short: "Show engine status",
|
||||
RunE: runStatus,
|
||||
}
|
||||
|
||||
func init() {
|
||||
rootCmd.AddCommand(statusCmd)
|
||||
}
|
||||
|
||||
func runStatus(cmd *cobra.Command, args []string) error {
|
||||
client := api.NewClient()
|
||||
resp, err := client.Get("/api/v1/status")
|
||||
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)
|
||||
}
|
||||
|
||||
if output.IsJSON() {
|
||||
var raw interface{}
|
||||
if err := api.DecodeJSON(resp, &raw); err != nil {
|
||||
return fmt.Errorf("failed to decode response: %w", err)
|
||||
}
|
||||
output.PrintJSON(raw)
|
||||
return nil
|
||||
}
|
||||
|
||||
var status struct {
|
||||
ModelName string `json:"model_name"`
|
||||
EmbeddingDim int `json:"embedding_dim"`
|
||||
Device string `json:"device"`
|
||||
DB struct {
|
||||
DocumentsByType map[string]int `json:"documents_by_type"`
|
||||
TotalChunks int `json:"total_chunks"`
|
||||
DBSizeBytes int64 `json:"db_size_bytes"`
|
||||
} `json:"db"`
|
||||
Queue struct {
|
||||
Queued int `json:"queued"`
|
||||
Processing int `json:"processing"`
|
||||
} `json:"queue"`
|
||||
}
|
||||
if err := api.DecodeJSON(resp, &status); err != nil {
|
||||
return fmt.Errorf("failed to decode response: %w", err)
|
||||
}
|
||||
|
||||
pairs := [][]string{
|
||||
{"Model", status.ModelName},
|
||||
{"Embedding Dim", fmt.Sprintf("%d", status.EmbeddingDim)},
|
||||
{"Device", status.Device},
|
||||
{"Total Chunks", fmt.Sprintf("%d", status.DB.TotalChunks)},
|
||||
{"DB Size", fmt.Sprintf("%d bytes", status.DB.DBSizeBytes)},
|
||||
{"Queued Jobs", fmt.Sprintf("%d", status.Queue.Queued)},
|
||||
{"Processing Jobs", fmt.Sprintf("%d", status.Queue.Processing)},
|
||||
}
|
||||
output.PrintKeyValue(pairs)
|
||||
|
||||
if len(status.DB.DocumentsByType) > 0 {
|
||||
fmt.Println("\nDocuments by Type:")
|
||||
var bPairs [][]string
|
||||
for k, v := range status.DB.DocumentsByType {
|
||||
bPairs = append(bPairs, []string{" " + k, fmt.Sprintf("%d", v)})
|
||||
}
|
||||
output.PrintKeyValue(bPairs)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/kb-search/kb/internal/api"
|
||||
"github.com/kb-search/kb/internal/output"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
var tagCmd = &cobra.Command{
|
||||
Use: "tag <id>",
|
||||
Short: "Add or remove tags on a document",
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: runTag,
|
||||
}
|
||||
|
||||
func init() {
|
||||
tagCmd.Flags().String("add", "", "tags to add (comma-separated)")
|
||||
tagCmd.Flags().String("remove", "", "tags to remove (comma-separated)")
|
||||
rootCmd.AddCommand(tagCmd)
|
||||
}
|
||||
|
||||
func runTag(cmd *cobra.Command, args []string) error {
|
||||
addStr, _ := cmd.Flags().GetString("add")
|
||||
removeStr, _ := cmd.Flags().GetString("remove")
|
||||
|
||||
body := map[string]interface{}{}
|
||||
|
||||
if addStr != "" {
|
||||
body["add"] = splitTags(addStr)
|
||||
}
|
||||
if removeStr != "" {
|
||||
body["remove"] = splitTags(removeStr)
|
||||
}
|
||||
|
||||
if len(body) == 0 {
|
||||
return fmt.Errorf("specify --add and/or --remove")
|
||||
}
|
||||
|
||||
client := api.NewClient()
|
||||
resp, err := client.Put("/api/v1/documents/"+args[0]+"/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)
|
||||
}
|
||||
|
||||
if output.IsJSON() {
|
||||
var raw interface{}
|
||||
if err := api.DecodeJSON(resp, &raw); err != nil {
|
||||
return fmt.Errorf("failed to decode response: %w", err)
|
||||
}
|
||||
output.PrintJSON(raw)
|
||||
} else {
|
||||
fmt.Printf("Tags updated for document %s\n", args[0])
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func splitTags(s string) []string {
|
||||
var tags []string
|
||||
for _, t := range strings.Split(s, ",") {
|
||||
t = strings.TrimSpace(t)
|
||||
if t != "" {
|
||||
tags = append(tags, t)
|
||||
}
|
||||
}
|
||||
return tags
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"github.com/kb-search/kb/internal/api"
|
||||
"github.com/kb-search/kb/internal/output"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
var tagsCmd = &cobra.Command{
|
||||
Use: "tags",
|
||||
Short: "List all tags",
|
||||
RunE: runTags,
|
||||
}
|
||||
|
||||
func init() {
|
||||
rootCmd.AddCommand(tagsCmd)
|
||||
}
|
||||
|
||||
func runTags(cmd *cobra.Command, args []string) error {
|
||||
client := api.NewClient()
|
||||
resp, err := client.Get("/api/v1/tags")
|
||||
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)
|
||||
}
|
||||
|
||||
if output.IsJSON() {
|
||||
var raw interface{}
|
||||
if err := api.DecodeJSON(resp, &raw); err != nil {
|
||||
return fmt.Errorf("failed to decode response: %w", err)
|
||||
}
|
||||
output.PrintJSON(raw)
|
||||
return nil
|
||||
}
|
||||
|
||||
var tags []struct {
|
||||
Name string `json:"name"`
|
||||
Count int `json:"count"`
|
||||
}
|
||||
if err := api.DecodeJSON(resp, &tags); err != nil {
|
||||
return fmt.Errorf("failed to decode response: %w", err)
|
||||
}
|
||||
|
||||
if len(tags) == 0 {
|
||||
fmt.Println("No tags found.")
|
||||
return nil
|
||||
}
|
||||
|
||||
headers := []string{"TAG", "COUNT"}
|
||||
var rows [][]string
|
||||
for _, t := range tags {
|
||||
rows = append(rows, []string{t.Name, fmt.Sprintf("%d", t.Count)})
|
||||
}
|
||||
output.PrintTable(headers, rows)
|
||||
return nil
|
||||
}
|
||||
Reference in New Issue
Block a user