Files
silo-server/internal/catalog/semantic_capability_test.go
QuickandClaude Opus 4.8 a5ef464d95 feat(search): validate embedder capability and expose semantic status
Add a rate-limited Meilisearch embedder capability check and surface a
semantic-coverage block in the admin search status.

Capability validation confirms the active index declares the configured
embedder with source="userProvided" and dimensions matching the canonical
embedding dimension, then runs a unit-vector hybrid probe to confirm the
index accepts the request shape. The whole path (GetSettings + evaluate +
probe) is cached for 5m and NEVER trips the circuit, marks a fallback, or
modifies unhealthyUntil, so a misconfigured embedder cannot take keyword
search down. The pure evaluator (evaluateEmbedderSettings) is unit-tested
across missing/wrong-source/wrong-dimensions/ok with distinct reasons.

The admin status gains a Semantic block read entirely from the in-memory
coverage snapshot (Snapshot + CoverageReady) with no new DB query in
Status(); document_count/vector_document_count are unchanged (additive
only). buildSemanticStatus is pure and unit-tested (nil snapshot and a
populated, deterministically-sorted snapshot).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-25 23:43:21 -04:00

179 lines
6.4 KiB
Go

package catalog
import (
"context"
"strings"
"testing"
"time"
"github.com/Silo-Server/silo-server/internal/embeddingvectors"
)
// TestEvaluateEmbedderSettingsCapability exercises the pure capability
// evaluation across its four outcomes, asserting a distinct reason (or OK) for
// each. This is the seam that lets capability validation be tested without
// faking the concrete *meilisearchClient.
func TestEvaluateEmbedderSettingsCapability(t *testing.T) {
const embedder = "silo_recommendations"
wantDims := embeddingvectors.CanonicalDimensions
t.Run("missing embedder", func(t *testing.T) {
settings := meilisearchIndexSettings{Embedders: map[string]meilisearchEmbedderSettings{}}
got := evaluateEmbedderSettings(settings, embedder, wantDims)
if got.OK {
t.Fatalf("expected not OK, got %+v", got)
}
if !strings.Contains(got.Reason, "not configured") {
t.Fatalf("expected 'not configured' reason, got %q", got.Reason)
}
if got.Embedder != embedder {
t.Fatalf("expected embedder %q, got %q", embedder, got.Embedder)
}
})
t.Run("wrong source", func(t *testing.T) {
settings := meilisearchIndexSettings{Embedders: map[string]meilisearchEmbedderSettings{
embedder: {Source: "openAi", Dimensions: wantDims},
}}
got := evaluateEmbedderSettings(settings, embedder, wantDims)
if got.OK {
t.Fatalf("expected not OK, got %+v", got)
}
if !strings.Contains(got.Reason, "source") || !strings.Contains(got.Reason, "userProvided") {
t.Fatalf("expected source/userProvided reason, got %q", got.Reason)
}
})
t.Run("wrong dimensions", func(t *testing.T) {
settings := meilisearchIndexSettings{Embedders: map[string]meilisearchEmbedderSettings{
embedder: {Source: "userProvided", Dimensions: wantDims - 1},
}}
got := evaluateEmbedderSettings(settings, embedder, wantDims)
if got.OK {
t.Fatalf("expected not OK, got %+v", got)
}
if !strings.Contains(got.Reason, "dimensions") {
t.Fatalf("expected dimensions reason, got %q", got.Reason)
}
if got.Dimensions != wantDims-1 {
t.Fatalf("expected reported dimensions %d, got %d", wantDims-1, got.Dimensions)
}
})
t.Run("ok", func(t *testing.T) {
settings := meilisearchIndexSettings{Embedders: map[string]meilisearchEmbedderSettings{
embedder: {Source: "userProvided", Dimensions: wantDims},
}}
got := evaluateEmbedderSettings(settings, embedder, wantDims)
if !got.OK {
t.Fatalf("expected OK, got %+v", got)
}
if got.Reason != "" {
t.Fatalf("expected empty reason, got %q", got.Reason)
}
if got.Embedder != embedder || got.Dimensions != wantDims {
t.Fatalf("expected embedder %q dims %d, got %+v", embedder, wantDims, got)
}
})
}
// TestSemanticCapabilityNeverTouchesCircuit verifies that requesting the
// capability via a nil-client provider returns a deterministic unavailable
// result and leaves the circuit-breaker fields untouched. Capability failures
// must never down keyword search.
func TestSemanticCapabilityNeverTouchesCircuit(t *testing.T) {
p := &MeilisearchSearchProvider{}
if !p.unhealthyUntil.IsZero() || p.unhealthyReason != "" {
t.Fatalf("precondition: circuit fields not zero: until=%v reason=%q", p.unhealthyUntil, p.unhealthyReason)
}
cap := p.SemanticCapability(context.Background())
if cap.OK {
t.Fatalf("expected not OK for nil client, got %+v", cap)
}
if !strings.Contains(cap.Reason, "client unavailable") {
t.Fatalf("expected client-unavailable reason, got %q", cap.Reason)
}
if !p.unhealthyUntil.IsZero() {
t.Fatalf("SemanticCapability tripped the circuit: unhealthyUntil=%v", p.unhealthyUntil)
}
if p.unhealthyReason != "" {
t.Fatalf("SemanticCapability set unhealthyReason=%q", p.unhealthyReason)
}
if p.lastFallback != "" {
t.Fatalf("SemanticCapability set lastFallback=%q", p.lastFallback)
}
}
// TestBuildSemanticStatusNilSnapshot covers the disabled/no-coverage path: a nil
// snapshot yields not-ready with the supplied reason and no per-type rows.
func TestBuildSemanticStatusNilSnapshot(t *testing.T) {
st := buildSemanticStatus(nil, false, "coverage not yet computed")
if st.Ready {
t.Fatalf("expected not ready, got %+v", st)
}
if st.DisabledReason != "coverage not yet computed" {
t.Fatalf("expected reason carried, got %q", st.DisabledReason)
}
if len(st.PerType) != 0 {
t.Fatalf("expected empty per-type, got %+v", st.PerType)
}
if st.CoverageUpdatedAt != nil {
t.Fatalf("expected nil CoverageUpdatedAt for nil snapshot, got %v", st.CoverageUpdatedAt)
}
}
// TestBuildSemanticStatusFromSnapshot covers the populated path: overall ratio,
// updated-at, and a deterministically sorted per-type list with ratio/ready
// mapped through. ready/reason are taken from the passed-in gate decision.
func TestBuildSemanticStatusFromSnapshot(t *testing.T) {
now := time.Now().UTC()
snap := &semanticCoverageSnapshot{
Overall: 0.5,
Model: "model-x",
UpdatedAt: now,
PerType: map[string]catalogTypeCoverage{
"series": {Type: "series", Eligible: 10, Vectorized: 5, Ratio: 0.5, Ready: false},
"movie": {Type: "movie", Eligible: 4, Vectorized: 4, Ratio: 1.0, Ready: true},
},
}
st := buildSemanticStatus(snap, false, `type "series" coverage 50% below threshold`)
if st.Ready {
t.Fatalf("expected not ready (gate said so), got %+v", st)
}
if st.DisabledReason == "" {
t.Fatalf("expected disabled reason carried when not ready")
}
if st.CoverageRatio != 0.5 {
t.Fatalf("expected CoverageRatio == Overall 0.5, got %v", st.CoverageRatio)
}
if st.CoverageUpdatedAt == nil || !st.CoverageUpdatedAt.Equal(now) {
t.Fatalf("expected CoverageUpdatedAt == snapshot UpdatedAt, got %v", st.CoverageUpdatedAt)
}
if len(st.PerType) != 2 {
t.Fatalf("expected 2 per-type rows, got %d", len(st.PerType))
}
// Deterministic sort by Type: movie before series.
if st.PerType[0].Type != "movie" || st.PerType[1].Type != "series" {
t.Fatalf("expected per-type sorted by Type [movie, series], got %q,%q", st.PerType[0].Type, st.PerType[1].Type)
}
if !st.PerType[0].Ready || st.PerType[0].CoverageRatio != 1.0 {
t.Fatalf("movie row mismatch: %+v", st.PerType[0])
}
if st.PerType[1].Ready || st.PerType[1].CoverageRatio != 0.5 {
t.Fatalf("series row mismatch: %+v", st.PerType[1])
}
if st.PerType[1].Eligible != 10 || st.PerType[1].Vectorized != 5 {
t.Fatalf("series counts mismatch: %+v", st.PerType[1])
}
// Ready path carries no disabled reason.
ready := buildSemanticStatus(snap, true, "")
if !ready.Ready || ready.DisabledReason != "" {
t.Fatalf("expected ready with no disabled reason, got %+v", ready)
}
}