diff --git a/internal/cli/lore_read.go b/internal/cli/lore_read.go index 155b6e8..0503223 100644 --- a/internal/cli/lore_read.go +++ b/internal/cli/lore_read.go @@ -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 { diff --git a/internal/command/command.go b/internal/command/command.go index ba15b35..783ca86 100644 --- a/internal/command/command.go +++ b/internal/command/command.go @@ -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. diff --git a/internal/lore/embedder_health_cmd.go b/internal/lore/embedder_health_cmd.go index f13854b..4592195 100644 --- a/internal/lore/embedder_health_cmd.go +++ b/internal/lore/embedder_health_cmd.go @@ -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"}, }, @@ -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) @@ -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) diff --git a/internal/lore/embedder_health_cmd_test.go b/internal/lore/embedder_health_cmd_test.go new file mode 100644 index 0000000..44320e0 --- /dev/null +++ b/internal/lore/embedder_health_cmd_test.go @@ -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) + } + } +} diff --git a/internal/mcp/register.go b/internal/mcp/register.go index a83f94c..a93bd1b 100644 --- a/internal/mcp/register.go +++ b/internal/mcp/register.go @@ -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,