Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion internal/cli/lore_read.go
Original file line number Diff line number Diff line change
Expand Up @@ -494,7 +494,8 @@ func bindLoreRegistryVerb[I, O any](parent *cobra.Command, spec *command.Command
// tolerates a nil Embed pointer per ADR-003 nil-safety.
func buildCLILoreDeps() command.Deps {
d := command.Deps{
OpenDB: openLoreDB,
OpenDB: openLoreDB,
OpenQuestDB: openQuestDB,
ResolveProj: func(ctx context.Context, argProject string) (string, error) {
db, err := openLoreDB(ctx)
if err != nil {
Expand Down
4 changes: 4 additions & 0 deletions internal/command/command.go
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,10 @@ type Deps struct {
// (e.g. quest_post with spec=). Nil means the feature is unavailable
// for that surface / test setup.
OpenLoreDB func(ctx context.Context) (*sql.DB, error)
// OpenQuestDB, when non-nil, opens the quest SQLite database. Only
// needed by lore handlers that read quest-corpus state (e.g.
// lore_health). Nil means the quest section is rendered as empty.
OpenQuestDB func(ctx context.Context) (*sql.DB, error)
// EvaluateHints, when non-nil, is called by the MCP handler wrapper
// after each successful tool invocation. Returns a HintFire the
// wrapper formats and prepends/appends to the tool's output body.
Expand Down
58 changes: 45 additions & 13 deletions internal/lore/embedder_health_cmd.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,22 +15,25 @@ type EmbedderHealthInput struct {
Project string `json:"project,omitempty"`
}

// EmbedderHealthCmdOutput wraps the HealthReport for the command registry.
// EmbedderHealthCmdOutput wraps per-corpus HealthReports for the command registry.
type EmbedderHealthCmdOutput struct {
Report *embed.HealthReport `json:"report"`
LoreReport *embed.HealthReport `json:"lore_report"`
QuestReport *embed.HealthReport `json:"quest_report"`
}

// EmbedderHealthCommand is the registry spec for `guild lore health`.
// It reads meta rows and lore_vectors/entries counts and renders the embedder
// health section. Does not touch the existing commune/inquest/meld output.
// It reads meta rows and vector/entity counts for both lore and quest
// corpora and renders the embedder health sections. Does not touch the
// existing commune/inquest/meld output.
var EmbedderHealthCommand = &command.Command[EmbedderHealthInput, EmbedderHealthCmdOutput]{
Name: "lore_health",
CLIPath: []string{"lore", "health"},
CLIAliases: []string{"embedder-health"},
Short: "embedder health report (coverage, pending, stale, errors)",
Long: "Print the embedder health section: model_id, tokenizer_hash, runtime_version, dim, " +
"coverage (num/den and percent), pending count, stale count, last encode error " +
"(if any), last successful encode timestamp, and rolling embed_error_count.",
Long: "Print the embedder health sections for lore and quest corpora: model_id, " +
"tokenizer_hash, runtime_version, dim, coverage (num/den and percent), pending " +
"count, stale count, last encode error (if any), last successful encode " +
"timestamp, and rolling embed_error_count.",
Args: []command.ArgSpec{
{Name: "project", Short: "p", Kind: command.ArgFlag, Type: command.ArgString, Help: "project override"},
},
Expand All @@ -49,11 +52,23 @@ var EmbedderHealthCommand = &command.Command[EmbedderHealthInput, EmbedderHealth
return EmbedderHealthCmdOutput{}, err
}

report, err := embed.ReadHealthReport(ctx, db, embed.LoreCorpus{})
loreReport, err := embed.ReadHealthReport(ctx, db, embed.LoreCorpus{})
if err != nil {
return EmbedderHealthCmdOutput{}, fmt.Errorf("lore: health: %w", err)
}
return EmbedderHealthCmdOutput{Report: report}, nil

questReport := emptyQuestHealthReport()
if d.OpenQuestDB != nil {
questDB, qerr := d.OpenQuestDB(ctx)
if qerr == nil {
defer func() { _ = questDB.Close() }()
if report, rerr := embed.ReadHealthReport(ctx, questDB, embed.QuestCorpus{}); rerr == nil {
questReport = report
}
}
}

return EmbedderHealthCmdOutput{LoreReport: loreReport, QuestReport: questReport}, nil
},
CLIFormat: func(s command.CLISink, o EmbedderHealthCmdOutput) string {
return formatEmbedderHealth(s, o)
Expand All @@ -63,19 +78,36 @@ var EmbedderHealthCommand = &command.Command[EmbedderHealthInput, EmbedderHealth
},
}

// formatEmbedderHealth renders the embedder health section.
// emptyQuestHealthReport returns a zeroed report so the quest section
// renders 0/0 coverage when quest.db is unavailable or unreadable.
func emptyQuestHealthReport() *embed.HealthReport {
return &embed.HealthReport{State: embed.EmbedderStateDisabled}
}

// formatEmbedderHealth renders the lore and quest embedder health sections.
// Works for both CLI and MCP sinks (both satisfy the lineSink interface).
func formatEmbedderHealth(s lineSink, o EmbedderHealthCmdOutput) string {
r := o.Report
var b strings.Builder
b.WriteString(formatCorpusEmbedderHealth(s, "lore", o.LoreReport))
b.WriteString("\n")
b.WriteString(formatCorpusEmbedderHealth(s, "quest", o.QuestReport))
return strings.TrimRight(b.String(), "\n")
}

// formatCorpusEmbedderHealth renders one corpus embedder health section.
func formatCorpusEmbedderHealth(s lineSink, corpus string, r *embed.HealthReport) string {
if r == nil {
return strings.TrimRight(s.Line("🔮", "[health]", "embedder: no data available"), "\n")
r = emptyQuestHealthReport()
}

var b strings.Builder
b.WriteString(s.Line("🔮", "[health]", "embedder section"))
b.WriteString(s.Line("🔮", "[health]", fmt.Sprintf("%s corpus embedder section", corpus)))

// State line.
stateStr := string(r.State)
if stateStr == "" {
stateStr = string(embed.EmbedderStateDisabled)
}
sessionLine := r.SessionLine()
if sessionLine != "" {
stateStr += fmt.Sprintf(": %s", sessionLine)
Expand Down
164 changes: 164 additions & 0 deletions internal/lore/embedder_health_cmd_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,164 @@
package lore

import (
"context"
"database/sql"
"fmt"
"path/filepath"
"strings"
"testing"

"github.com/mathomhaus/guild/internal/command"
"github.com/mathomhaus/guild/internal/storage"
)

func TestEmbedderHealth_IncludesQuestCorpusSection(t *testing.T) {
ctx := context.Background()
tmp := t.TempDir()
lorePath := filepath.Join(tmp, "lore.db")
questPath := filepath.Join(tmp, "quest.db")

seedLoreHealthDB(t, lorePath, 8, 10)
seedQuestHealthDB(t, questPath, 3, 5)

deps := command.Deps{
OpenDB: func(ctx context.Context) (*sql.DB, error) {
return storage.Open(ctx, lorePath)
},
OpenQuestDB: func(ctx context.Context) (*sql.DB, error) {
return storage.Open(ctx, questPath)
},
ResolveProj: func(_ context.Context, _ string) (string, error) {
return "testproj", nil
},
}

out, err := EmbedderHealthCommand.Handler(ctx, deps, EmbedderHealthInput{})
if err != nil {
t.Fatalf("handler: %v", err)
}
if out.LoreReport == nil {
t.Fatal("expected lore report")
}
if out.LoreReport.CoverageNum != 8 || out.LoreReport.CoverageDen != 10 {
t.Fatalf("lore coverage = %d/%d, want 8/10", out.LoreReport.CoverageNum, out.LoreReport.CoverageDen)
}
if out.QuestReport == nil {
t.Fatal("expected quest report")
}
if out.QuestReport.CoverageNum != 3 || out.QuestReport.CoverageDen != 5 {
t.Fatalf("quest coverage = %d/%d, want 3/5", out.QuestReport.CoverageNum, out.QuestReport.CoverageDen)
}

text := formatEmbedderHealth(command.CLISink{NoEmoji: true}, out)
if !strings.Contains(text, "lore corpus embedder section") {
t.Fatalf("output missing lore section:\n%s", text)
}
if !strings.Contains(text, "quest corpus embedder section") {
t.Fatalf("output missing quest section:\n%s", text)
}
if !strings.Contains(text, "coverage: 8/10") {
t.Fatalf("output missing lore coverage:\n%s", text)
}
if !strings.Contains(text, "coverage: 3/5") {
t.Fatalf("output missing quest coverage:\n%s", text)
}
}

func TestEmbedderHealth_EmptyQuestCorpusRendersZeroCoverage(t *testing.T) {
ctx := context.Background()
tmp := t.TempDir()
lorePath := filepath.Join(tmp, "lore.db")

seedLoreHealthDB(t, lorePath, 0, 0)

deps := command.Deps{
OpenDB: func(ctx context.Context) (*sql.DB, error) {
return storage.Open(ctx, lorePath)
},
ResolveProj: func(_ context.Context, _ string) (string, error) {
return "testproj", nil
},
}

out, err := EmbedderHealthCommand.Handler(ctx, deps, EmbedderHealthInput{})
if err != nil {
t.Fatalf("handler: %v", err)
}
if out.QuestReport == nil {
t.Fatal("expected quest report placeholder")
}
if out.QuestReport.CoverageNum != 0 || out.QuestReport.CoverageDen != 0 {
t.Fatalf("quest coverage = %d/%d, want 0/0", out.QuestReport.CoverageNum, out.QuestReport.CoverageDen)
}

text := formatEmbedderHealth(command.CLISink{NoEmoji: true}, out)
if !strings.Contains(text, "quest corpus embedder section") {
t.Fatalf("output missing quest section:\n%s", text)
}
if !strings.Contains(text, "coverage: 0/0 (0.0%)") {
t.Fatalf("output missing zero quest coverage:\n%s", text)
}
}

func seedLoreHealthDB(t *testing.T, path string, covNum, covDen int64) {
t.Helper()
ctx := context.Background()
db, err := storage.Open(ctx, path)
if err != nil {
t.Fatalf("open lore db: %v", err)
}
defer func() { _ = db.Close() }()
if err := storage.Migrate(ctx, db, "lore"); err != nil {
t.Fatalf("migrate lore: %v", err)
}
rows := []struct{ k, v string }{
{"embedder_model_id", "bge-small-en-v1.5-int8-cls"},
{"embedder_tokenizer_hash", "abc123"},
{"embedder_runtime_version", "onnxruntime-1.23.x"},
{"embedder_dim", "384"},
{"embedder_state", "enabled"},
{"vector_epoch", "1"},
{"vector_coverage_num", fmt.Sprintf("%d", covNum)},
{"vector_coverage_den", fmt.Sprintf("%d", covDen)},
{"embed_error_count", "0"},
}
for _, r := range rows {
if _, err := db.ExecContext(ctx,
`INSERT OR REPLACE INTO meta (key, value) VALUES (?, ?)`, r.k, r.v,
); err != nil {
t.Fatalf("seed lore meta %s: %v", r.k, err)
}
}
}

func seedQuestHealthDB(t *testing.T, path string, covNum, covDen int64) {
t.Helper()
ctx := context.Background()
db, err := storage.Open(ctx, path)
if err != nil {
t.Fatalf("open quest db: %v", err)
}
defer func() { _ = db.Close() }()
if err := storage.Migrate(ctx, db, "quest"); err != nil {
t.Fatalf("migrate quest: %v", err)
}
rows := []struct{ k, v string }{
{"quest.embedder_model_id", "bge-small-en-v1.5-int8-cls"},
{"quest.embedder_tokenizer_hash", "abc123"},
{"quest.embedder_runtime_version", "onnxruntime-1.23.x"},
{"quest.embedder_dim", "384"},
{"quest.embedder_state", "enabled"},
{"quest.vector_epoch", "2"},
{"quest.vector_coverage_num", fmt.Sprintf("%d", covNum)},
{"quest.vector_coverage_den", fmt.Sprintf("%d", covDen)},
{"quest.embed_error_count", "0"},
}
for _, r := range rows {
if _, err := db.ExecContext(ctx,
`INSERT OR REPLACE INTO meta (key, value) VALUES (?, ?)`, r.k, r.v,
); err != nil {
t.Fatalf("seed quest meta %s: %v", r.k, err)
}
}
}
1 change: 1 addition & 0 deletions internal/mcp/register.go
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,7 @@ func buildMCPCommandDeps() command.Deps {
func buildMCPLoreDeps() command.Deps {
d := command.Deps{
OpenDB: openLoreDB,
OpenQuestDB: openQuestDB,
ResolveProj: resolveProjectAutoBootstrap,
Now: time.Now,
RecordTelemetry: recordMCPTelemetry,
Expand Down