Add reranking, RRF fusion, bench harness, tag contexts, and data ingestion

Implements five of the six enhancements from docs/kb-enhancements-proposal.htm,
closing the retrieval-quality gap identified in the qmd review.

- Cross-encoder reranking: new kb/reranker.py loads an optional reranking
  model at startup (KB_RERANK_ENABLED, KB_RERANKER_MODEL,
  KB_RERANK_CANDIDATES). Search degrades gracefully to plain hybrid
  retrieval when the model is absent. Exposed via a "rerank" block in
  /status, a rerank flag on search, and --no-rerank in the CLI.
- RRF rank fusion: FTS and vector lists now merge by reciprocal rank
  fusion with a top-rank bonus, replacing the old score blend. Scores are
  comparable across queries.
- Bench harness and explain traces: kb bench runs a query fixture against
  each backend and reports precision@k, recall and MRR. --explain returns a
  per-result score breakdown.
- Tag context descriptions: tags carry an optional one-line description
  (kb tag-describe), returned as tag_contexts with search results. Adds a
  tags.description column migration.
- Structured data ingestion: .json/.yaml/.toml files ingest as text via the
  new "data" doc type, pretty-printing minified JSON before chunking.

Query expansion (proposal item 5) is deliberately left out pending bench
results. Requires engine v3.3.0.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-21 09:51:33 +01:00
parent 75e4a0cf73
commit 6dfc13be1d
33 changed files with 1809 additions and 57 deletions
+4
View File
@@ -38,6 +38,10 @@ var supportedExts = map[string]bool{
".py": true,
".sh": true,
".go": true,
".json": true,
".yaml": true,
".yml": true,
".toml": true,
}
var addfileCmd = &cobra.Command{
+376
View File
@@ -0,0 +1,376 @@
package cmd
import (
"encoding/json"
"fmt"
"os"
"strings"
"time"
"github.com/kb-search/kb/internal/api"
"github.com/kb-search/kb/internal/output"
"github.com/spf13/cobra"
)
var benchCmd = &cobra.Command{
Use: "bench <fixture.json>",
Short: "Benchmark search quality against a query fixture",
Long: `Run a fixture of queries with known-relevant documents against each
search backend (fts, vec, hybrid, rerank) and report precision@k, recall
and MRR per backend. Use this to baseline search quality before ranking
changes and to measure their effect. See docs/bench-example.json for the
fixture format.`,
Args: cobra.ExactArgs(1),
RunE: runBench,
}
func init() {
benchCmd.Flags().Int("top", 0, "override result count per query (k)")
benchCmd.Flags().String("backends", "fts,vec,hybrid,rerank", "comma-separated backends to run")
rootCmd.AddCommand(benchCmd)
}
type benchSelector struct {
DocumentID int64 `json:"document_id"`
SourcePath string `json:"source_path"`
TitleContains string `json:"title_contains"`
}
type benchQuery struct {
ID string `json:"id"`
Query string `json:"query"`
Tags []string `json:"tags"`
DocType string `json:"doc_type"`
Top int `json:"top"`
Relevant []benchSelector `json:"relevant"`
Notes string `json:"notes"`
}
type benchFixture struct {
Description string `json:"description"`
Top int `json:"top"`
Queries []benchQuery `json:"queries"`
}
type benchDoc struct {
DocumentID int64
Title string
SourcePath string
}
type queryResult struct {
ID string `json:"id"`
Precision float64 `json:"precision"`
Recall float64 `json:"recall"`
MRR float64 `json:"mrr"`
LatencyMS float64 `json:"latency_ms"`
Returned int `json:"returned"`
}
type backendResult struct {
Precision float64 `json:"precision"`
Recall float64 `json:"recall"`
MRR float64 `json:"mrr"`
AvgLatencyMS float64 `json:"avg_latency_ms"`
Queries []queryResult `json:"queries"`
}
func loadFixture(path string) (*benchFixture, error) {
data, err := os.ReadFile(path)
if err != nil {
return nil, fmt.Errorf("cannot read fixture: %w", err)
}
var fx benchFixture
if err := json.Unmarshal(data, &fx); err != nil {
return nil, fmt.Errorf("invalid fixture JSON: %w", err)
}
if len(fx.Queries) == 0 {
return nil, fmt.Errorf("fixture has no queries")
}
for i, q := range fx.Queries {
if q.Query == "" {
return nil, fmt.Errorf("query %d: missing query text", i)
}
if len(q.Relevant) == 0 {
return nil, fmt.Errorf("query %q: no relevant selectors", q.Query)
}
for j, sel := range q.Relevant {
set := 0
if sel.DocumentID != 0 {
set++
}
if sel.SourcePath != "" {
set++
}
if sel.TitleContains != "" {
set++
}
if set != 1 {
return nil, fmt.Errorf("query %q selector %d: exactly one of document_id, source_path, title_contains must be set", q.Query, j)
}
}
}
return &fx, nil
}
func (s benchSelector) matches(doc benchDoc) bool {
switch {
case s.DocumentID != 0:
return doc.DocumentID == s.DocumentID
case s.SourcePath != "":
return doc.SourcePath == s.SourcePath
case s.TitleContains != "":
return strings.Contains(strings.ToLower(doc.Title), strings.ToLower(s.TitleContains))
}
return false
}
// dedupeByDocument collapses ranked chunks into ranked documents, keeping the
// best (first) position for each document.
func dedupeByDocument(docs []benchDoc) []benchDoc {
seen := map[int64]bool{}
var out []benchDoc
for _, d := range docs {
if seen[d.DocumentID] {
continue
}
seen[d.DocumentID] = true
out = append(out, d)
}
return out
}
// scoreQuery computes document-level precision, recall and MRR for one
// query's ranked document list against the relevant-document selectors.
func scoreQuery(ranked []benchDoc, relevant []benchSelector) (precision, recall, mrr float64) {
if len(ranked) == 0 {
return 0, 0, 0
}
matchedDocs := 0
firstMatch := 0
selectorHit := make([]bool, len(relevant))
for i, doc := range ranked {
docMatched := false
for j, sel := range relevant {
if sel.matches(doc) {
docMatched = true
selectorHit[j] = true
}
}
if docMatched {
matchedDocs++
if firstMatch == 0 {
firstMatch = i + 1
}
}
}
selectorsMatched := 0
for _, hit := range selectorHit {
if hit {
selectorsMatched++
}
}
precision = float64(matchedDocs) / float64(len(ranked))
recall = float64(selectorsMatched) / float64(len(relevant))
if firstMatch > 0 {
mrr = 1.0 / float64(firstMatch)
}
return precision, recall, mrr
}
// rerankAvailable probes engine status for a loaded reranker.
func rerankAvailable(client *api.Client) bool {
resp, err := client.Get("/api/v1/status")
if err != nil {
return false
}
var status struct {
Rerank struct {
Enabled bool `json:"enabled"`
Loaded bool `json:"loaded"`
} `json:"rerank"`
}
if err := api.DecodeJSON(resp, &status); err != nil {
return false
}
return status.Rerank.Enabled && status.Rerank.Loaded
}
func benchSearch(client *api.Client, q benchQuery, backend string, top int) ([]benchDoc, float64, error) {
body := map[string]interface{}{
"query": q.Query,
"top": top,
}
if len(q.Tags) > 0 {
body["tags"] = q.Tags
}
if q.DocType != "" {
body["doc_type"] = q.DocType
}
switch backend {
case "fts":
body["fts_only"] = true
case "vec":
body["vec_only"] = true
case "hybrid":
body["rerank"] = false
case "rerank":
body["rerank"] = true
}
start := time.Now()
resp, err := client.Post("/api/v1/search", body)
if err != nil {
return nil, 0, err
}
if err := api.CheckError(resp); err != nil {
return nil, 0, err
}
var result struct {
Results []struct {
DocumentID int64 `json:"document_id"`
Title string `json:"title"`
SourcePath string `json:"source_path"`
} `json:"results"`
}
if err := api.DecodeJSON(resp, &result); err != nil {
return nil, 0, err
}
latency := float64(time.Since(start).Microseconds()) / 1000.0
var docs []benchDoc
for _, r := range result.Results {
docs = append(docs, benchDoc{DocumentID: r.DocumentID, Title: r.Title, SourcePath: r.SourcePath})
}
return dedupeByDocument(docs), latency, nil
}
func runBench(cmd *cobra.Command, args []string) error {
topFlag, _ := cmd.Flags().GetInt("top")
backendsFlag, _ := cmd.Flags().GetString("backends")
fx, err := loadFixture(args[0])
if err != nil {
return err
}
var backends []string
for _, b := range strings.Split(backendsFlag, ",") {
b = strings.TrimSpace(b)
if b == "" {
continue
}
switch b {
case "fts", "vec", "hybrid", "rerank":
backends = append(backends, b)
default:
return fmt.Errorf("unknown backend %q (valid: fts, vec, hybrid, rerank)", b)
}
}
if len(backends) == 0 {
return fmt.Errorf("no backends selected")
}
client := api.NewClient()
rerankSkipped := false
if contains(backends, "rerank") && !rerankAvailable(client) {
backends = remove(backends, "rerank")
rerankSkipped = true
if len(backends) == 0 {
return fmt.Errorf("reranking is not available on this engine (requires engine with reranker enabled)")
}
}
results := map[string]*backendResult{}
for _, backend := range backends {
br := &backendResult{}
for _, q := range fx.Queries {
top := 10
if fx.Top > 0 {
top = fx.Top
}
if q.Top > 0 {
top = q.Top
}
if topFlag > 0 {
top = topFlag
}
ranked, latency, err := benchSearch(client, q, backend, top)
if err != nil {
return fmt.Errorf("backend %s, query %q: %w", backend, q.Query, err)
}
p, r, m := scoreQuery(ranked, q.Relevant)
id := q.ID
if id == "" {
id = q.Query
}
br.Queries = append(br.Queries, queryResult{
ID: id, Precision: p, Recall: r, MRR: m,
LatencyMS: latency, Returned: len(ranked),
})
}
n := float64(len(br.Queries))
for _, qr := range br.Queries {
br.Precision += qr.Precision / n
br.Recall += qr.Recall / n
br.MRR += qr.MRR / n
br.AvgLatencyMS += qr.LatencyMS / n
}
results[backend] = br
}
if output.IsJSON() {
output.PrintJSON(map[string]interface{}{
"description": fx.Description,
"query_count": len(fx.Queries),
"backends": results,
"rerank_skipped": rerankSkipped,
})
return nil
}
if fx.Description != "" {
fmt.Printf("%s (%d queries)\n\n", fx.Description, len(fx.Queries))
}
headers := []string{"Backend", "Precision", "Recall", "MRR", "Avg ms"}
var rows [][]string
for _, backend := range backends {
br := results[backend]
rows = append(rows, []string{
backend,
fmt.Sprintf("%.3f", br.Precision),
fmt.Sprintf("%.3f", br.Recall),
fmt.Sprintf("%.3f", br.MRR),
fmt.Sprintf("%.0f", br.AvgLatencyMS),
})
}
output.PrintTable(headers, rows)
if rerankSkipped {
fmt.Println("\nrerank: n/a (reranker not enabled on this engine)")
}
return nil
}
func contains(ss []string, s string) bool {
for _, v := range ss {
if v == s {
return true
}
}
return false
}
func remove(ss []string, s string) []string {
var out []string
for _, v := range ss {
if v != s {
out = append(out, v)
}
}
return out
}
+143
View File
@@ -0,0 +1,143 @@
package cmd
import (
"math"
"os"
"path/filepath"
"testing"
)
func writeFixture(t *testing.T, content string) string {
t.Helper()
path := filepath.Join(t.TempDir(), "fixture.json")
if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
t.Fatal(err)
}
return path
}
func TestLoadFixture_Valid(t *testing.T) {
path := writeFixture(t, `{
"description": "test",
"top": 5,
"queries": [
{"id": "q1", "query": "hello", "relevant": [{"document_id": 7}]}
]
}`)
fx, err := loadFixture(path)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if fx.Top != 5 || len(fx.Queries) != 1 || fx.Queries[0].Relevant[0].DocumentID != 7 {
t.Errorf("fixture parsed incorrectly: %+v", fx)
}
}
func TestLoadFixture_RejectsEmptyRelevant(t *testing.T) {
path := writeFixture(t, `{"queries": [{"query": "hello", "relevant": []}]}`)
if _, err := loadFixture(path); err == nil {
t.Error("expected error for query with no relevant selectors")
}
}
func TestLoadFixture_RejectsMultiFieldSelector(t *testing.T) {
path := writeFixture(t, `{"queries": [
{"query": "hello", "relevant": [{"document_id": 1, "source_path": "/x"}]}
]}`)
if _, err := loadFixture(path); err == nil {
t.Error("expected error for selector with two fields set")
}
}
func TestSelectorMatching(t *testing.T) {
doc := benchDoc{DocumentID: 42, Title: "M38T Owner's Manual", SourcePath: "/data/m38t.pdf"}
cases := []struct {
name string
sel benchSelector
want bool
}{
{"document_id match", benchSelector{DocumentID: 42}, true},
{"document_id miss", benchSelector{DocumentID: 43}, false},
{"source_path match", benchSelector{SourcePath: "/data/m38t.pdf"}, true},
{"source_path miss", benchSelector{SourcePath: "/data/other.pdf"}, false},
{"title_contains case-insensitive", benchSelector{TitleContains: "m38t owner"}, true},
{"title_contains miss", benchSelector{TitleContains: "workshop"}, false},
}
for _, tc := range cases {
if got := tc.sel.matches(doc); got != tc.want {
t.Errorf("%s: got %v, want %v", tc.name, got, tc.want)
}
}
}
func TestDedupeByDocument(t *testing.T) {
docs := []benchDoc{
{DocumentID: 1, Title: "a"},
{DocumentID: 2, Title: "b"},
{DocumentID: 1, Title: "a-again"},
{DocumentID: 3, Title: "c"},
}
out := dedupeByDocument(docs)
if len(out) != 3 || out[0].DocumentID != 1 || out[1].DocumentID != 2 || out[2].DocumentID != 3 {
t.Errorf("dedupe failed: %+v", out)
}
if out[0].Title != "a" {
t.Errorf("dedupe must keep first (best-ranked) occurrence, got %q", out[0].Title)
}
}
func approxEqual(a, b float64) bool {
return math.Abs(a-b) < 1e-9
}
func TestScoreQuery_HandComputed(t *testing.T) {
// Ranked docs: 10, 20, 30, 40. Relevant: 20 and 40.
ranked := []benchDoc{
{DocumentID: 10}, {DocumentID: 20}, {DocumentID: 30}, {DocumentID: 40},
}
relevant := []benchSelector{{DocumentID: 20}, {DocumentID: 40}}
p, r, m := scoreQuery(ranked, relevant)
if !approxEqual(p, 0.5) { // 2 of 4 returned docs are relevant
t.Errorf("precision: got %v, want 0.5", p)
}
if !approxEqual(r, 1.0) { // both relevant docs found
t.Errorf("recall: got %v, want 1.0", r)
}
if !approxEqual(m, 0.5) { // first relevant doc at rank 2
t.Errorf("mrr: got %v, want 0.5", m)
}
}
func TestScoreQuery_NoMatches(t *testing.T) {
ranked := []benchDoc{{DocumentID: 1}}
relevant := []benchSelector{{DocumentID: 99}}
p, r, m := scoreQuery(ranked, relevant)
if p != 0 || r != 0 || m != 0 {
t.Errorf("expected all-zero metrics, got p=%v r=%v mrr=%v", p, r, m)
}
}
func TestScoreQuery_PartialRecall(t *testing.T) {
// Only one of three relevant docs returned, at rank 1.
ranked := []benchDoc{{DocumentID: 5}, {DocumentID: 6}}
relevant := []benchSelector{{DocumentID: 5}, {DocumentID: 7}, {DocumentID: 8}}
p, r, m := scoreQuery(ranked, relevant)
if !approxEqual(p, 0.5) {
t.Errorf("precision: got %v, want 0.5", p)
}
if !approxEqual(r, 1.0/3.0) {
t.Errorf("recall: got %v, want 1/3", r)
}
if !approxEqual(m, 1.0) {
t.Errorf("mrr: got %v, want 1.0", m)
}
}
func TestScoreQuery_EmptyResults(t *testing.T) {
p, r, m := scoreQuery(nil, []benchSelector{{DocumentID: 1}})
if p != 0 || r != 0 || m != 0 {
t.Errorf("expected zeros for empty results, got p=%v r=%v mrr=%v", p, r, m)
}
}
+73 -4
View File
@@ -23,6 +23,8 @@ func init() {
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")
searchCmd.Flags().Bool("explain", false, "include per-result score breakdown")
searchCmd.Flags().Bool("no-rerank", false, "skip cross-encoder reranking (lower latency)")
rootCmd.AddCommand(searchCmd)
}
@@ -33,16 +35,18 @@ func runSearch(cmd *cobra.Command, args []string) error {
ftsOnly, _ := cmd.Flags().GetBool("fts-only")
vecOnly, _ := cmd.Flags().GetBool("vec-only")
threshold, _ := cmd.Flags().GetFloat64("threshold")
explain, _ := cmd.Flags().GetBool("explain")
noRerank, _ := cmd.Flags().GetBool("no-rerank")
body := map[string]interface{}{
"query": args[0],
"top": top,
}
if tags != "" {
body["tags"] = tags
body["tags"] = splitTags(tags)
}
if docType != "" {
body["type"] = docType
body["doc_type"] = docType
}
if ftsOnly {
body["fts_only"] = true
@@ -53,6 +57,12 @@ func runSearch(cmd *cobra.Command, args []string) error {
if threshold > 0 {
body["threshold"] = threshold
}
if explain {
body["explain"] = true
}
if noRerank {
body["rerank"] = false
}
client := api.NewClient()
resp, err := client.Post("/api/v1/search", body)
@@ -66,13 +76,17 @@ func runSearch(cmd *cobra.Command, args []string) error {
}
var result struct {
Results []struct {
Reranked bool `json:"reranked"`
Results []struct {
Score float64 `json:"score"`
DocumentID int64 `json:"document_id"`
Title string `json:"title"`
DocType string `json:"doc_type"`
Tags []string `json:"tags"`
TagContexts map[string]string `json:"tag_contexts"`
ChunkMetadata map[string]interface{} `json:"chunk_metadata"`
Text string `json:"text"`
Explain map[string]interface{} `json:"explain"`
} `json:"results"`
}
@@ -94,13 +108,17 @@ func runSearch(cmd *cobra.Command, args []string) error {
return nil
}
if result.Reranked {
fmt.Println("(reranked)")
}
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.Title)
fmt.Printf("\n%d. [%.4f] %s (doc:%d)\n", i+1, r.Score, r.Title, r.DocumentID)
location := ""
if page, ok := r.ChunkMetadata["page"]; ok && page != nil {
@@ -123,12 +141,63 @@ func runSearch(cmd *cobra.Command, args []string) error {
if len(r.Tags) > 0 {
fmt.Printf(" Tags: %s\n", joinStrings(r.Tags))
}
for _, tag := range r.Tags {
if desc, ok := r.TagContexts[tag]; ok && desc != "" {
fmt.Printf(" Context: %s — %s\n", tag, desc)
}
}
if r.Explain != nil {
fmt.Printf(" Score: %s\n", formatExplain(r.Explain))
}
fmt.Printf(" %s\n", snippet)
}
fmt.Println()
return nil
}
// formatExplain renders the engine's explain breakdown as one line, e.g.
// "fts score=4.213 rank=2 (rrf 0.016129) | vec score=0.512 rank=1 (rrf 0.016393) | bonus 0.05 | final 0.082522"
func formatExplain(e map[string]interface{}) string {
num := func(key string) (float64, bool) {
v, ok := e[key].(float64)
return v, ok
}
var parts []string
for _, arm := range []string{"fts", "vec"} {
score, hasScore := num(arm + "_score")
if !hasScore {
continue
}
part := fmt.Sprintf("%s score=%.4f", arm, score)
if rank, ok := num(arm + "_rank"); ok {
part += fmt.Sprintf(" rank=%d", int(rank))
}
if rrf, ok := num("rrf_" + arm); ok {
part += fmt.Sprintf(" (rrf %.6f)", rrf)
}
parts = append(parts, part)
}
if bonus, ok := num("bonus"); ok && bonus > 0 {
parts = append(parts, fmt.Sprintf("bonus %.2f", bonus))
}
for _, key := range []string{"pre_rerank_rank", "rerank_score", "blend_weight"} {
if v, ok := num(key); ok {
parts = append(parts, fmt.Sprintf("%s %.4f", key, v))
}
}
if final, ok := num("final_score"); ok {
parts = append(parts, fmt.Sprintf("final %.6f", final))
}
result := ""
for i, p := range parts {
if i > 0 {
result += " | "
}
result += p
}
return result
}
func joinStrings(ss []string) string {
result := ""
for i, s := range ss {
+67
View File
@@ -0,0 +1,67 @@
package cmd
import (
"bytes"
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"reflect"
"testing"
)
func TestSearchCmd_SendsDocTypeAndTagsList(t *testing.T) {
var captured map[string]interface{}
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/api/v1/search" {
t.Errorf("unexpected path: %s", r.URL.Path)
}
body, _ := io.ReadAll(r.Body)
if err := json.Unmarshal(body, &captured); err != nil {
t.Fatalf("failed to decode request body: %v", err)
}
w.Header().Set("Content-Type", "application/json")
w.Write([]byte(`{"query":"q","results":[],"total_matches":0,"returned":0}`))
}))
defer server.Close()
var stdout bytes.Buffer
rootCmd.SetOut(&stdout)
rootCmd.SetArgs([]string{
"search", "oil level",
"--engine", server.URL,
"--format", "json",
"--type", "pdf",
"--tags", "manuals, ops,",
})
if err := rootCmd.Execute(); err != nil {
t.Fatalf("search command failed: %v", err)
}
if captured == nil {
t.Fatal("no request captured by test server")
}
if got := captured["doc_type"]; got != "pdf" {
t.Errorf("expected doc_type=pdf in body, got %v (full body: %v)", got, captured)
}
if _, present := captured["type"]; present {
t.Error("body must not contain legacy 'type' key")
}
tags, ok := captured["tags"].([]interface{})
if !ok {
t.Fatalf("expected tags to be a JSON array, got %T (%v)", captured["tags"], captured["tags"])
}
want := []interface{}{"manuals", "ops"}
if !reflect.DeepEqual(tags, want) {
t.Errorf("expected tags %v, got %v", want, tags)
}
}
func TestSplitTags(t *testing.T) {
got := splitTags(" a ,b,, c ")
want := []string{"a", "b", "c"}
if !reflect.DeepEqual(got, want) {
t.Errorf("splitTags: expected %v, got %v", want, got)
}
}
+62
View File
@@ -0,0 +1,62 @@
package cmd
import (
"fmt"
"os"
"github.com/kb-search/kb/internal/api"
"github.com/kb-search/kb/internal/output"
"github.com/spf13/cobra"
)
var tagDescribeCmd = &cobra.Command{
Use: "tag-describe <tag> [description]",
Short: "Set a one-line context description on a tag",
Long: `Attach a short description to a tag (e.g. "Lab operations runbooks").
Descriptions are returned as tag_contexts with every search result on a
document carrying the tag, helping consumers judge relevance.
Omit the description (or pass "") to clear it.`,
Args: cobra.RangeArgs(1, 2),
RunE: runTagDescribe,
}
func init() {
rootCmd.AddCommand(tagDescribeCmd)
}
func runTagDescribe(cmd *cobra.Command, args []string) error {
description := ""
if len(args) == 2 {
description = args[1]
}
body := map[string]interface{}{"description": description}
client := api.NewClient()
resp, err := client.Put("/api/v1/tags/"+args[0]+"/description", 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)
return nil
}
if description == "" {
fmt.Printf("Description cleared for tag %q\n", args[0])
} else {
fmt.Printf("Tag %q: %s\n", args[0], description)
}
return nil
}
+5 -4
View File
@@ -41,8 +41,9 @@ func runTags(cmd *cobra.Command, args []string) error {
}
var tags []struct {
Name string `json:"name"`
Count int `json:"count"`
Name string `json:"name"`
Count int `json:"count"`
Description string `json:"description"`
}
if err := api.DecodeJSON(resp, &tags); err != nil {
return fmt.Errorf("failed to decode response: %w", err)
@@ -53,10 +54,10 @@ func runTags(cmd *cobra.Command, args []string) error {
return nil
}
headers := []string{"TAG", "COUNT"}
headers := []string{"TAG", "COUNT", "DESCRIPTION"}
var rows [][]string
for _, t := range tags {
rows = append(rows, []string{t.Name, fmt.Sprintf("%d", t.Count)})
rows = append(rows, []string{t.Name, fmt.Sprintf("%d", t.Count), t.Description})
}
output.PrintTable(headers, rows)
return nil