Files
krow_backend/go-api/internal/knowledge/ollama_test.go
2026-08-28 12:21:44 +05:30

142 lines
4.9 KiB
Go

package knowledge_test
import (
"context"
"encoding/json"
"math"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/krow/krow-backend/go-api/internal/knowledge"
)
// The local embedder.
//
// Driven against a stub rather than a real Ollama, because what is being tested
// is this package's half of the contract: the request shape, the normalisation,
// and — most of all — what an operator is told when it does not work. The model
// itself is somebody else's code and testing it here would test the network.
func TestTheLocalEmbedderSendsWhatOllamaExpects(t *testing.T) {
var got map[string]any
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/api/embed" {
t.Errorf("posted to %s, want /api/embed", r.URL.Path)
}
json.NewDecoder(r.Body).Decode(&got)
json.NewEncoder(w).Encode(map[string]any{
"embeddings": [][]float32{{3, 4}, {1, 0}},
})
}))
defer srv.Close()
e := knowledge.NewOllama(srv.URL, "nomic-embed-text", 2)
out, err := e.Embed(context.Background(), []string{"a", "b"}, knowledge.KindDocument)
if err != nil {
t.Fatalf("embed: %v", err)
}
if got["model"] != "nomic-embed-text" {
t.Errorf("model = %v", got["model"])
}
if inputs, ok := got["input"].([]any); !ok || len(inputs) != 2 {
t.Errorf("input = %v; the batch should travel as a list", got["input"])
}
if len(out) != 2 {
t.Fatalf("%d vectors, want 2", len(out))
}
}
func TestTheLocalEmbedderNormalisesWhatItGetsBack(t *testing.T) {
// The schema's similarity function is a plain dot product, which equals
// cosine similarity ONLY for unit vectors. Ollama returns whatever the
// model produced. Skipping this would not error — it would rank badly,
// dominated by whichever chunks happened to have the largest magnitude,
// which is the kind of wrong that never looks broken.
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
json.NewEncoder(w).Encode(map[string]any{"embeddings": [][]float32{{3, 4}}})
}))
defer srv.Close()
out, err := knowledge.NewOllama(srv.URL, "m", 2).
Embed(context.Background(), []string{"x"}, knowledge.KindDocument)
if err != nil {
t.Fatalf("embed: %v", err)
}
var sum float64
for _, v := range out[0] {
sum += float64(v) * float64(v)
}
if math.Abs(math.Sqrt(sum)-1) > 1e-5 {
t.Errorf("vector has length %.4f, want 1 — the dot product will not be cosine similarity",
math.Sqrt(sum))
}
}
func TestOllamaNotRunningSaysWhatToDo(t *testing.T) {
// The single most likely failure, and the one where a bad message costs the
// most time: an operator reading "connection refused" goes looking for a
// network problem.
e := knowledge.NewOllama("http://127.0.0.1:1", "nomic-embed-text", 768)
_, err := e.Embed(context.Background(), []string{"x"}, knowledge.KindQuery)
if err == nil {
t.Fatal("embedding against nothing succeeded")
}
msg := err.Error()
if !strings.Contains(msg, "ollama pull") {
t.Errorf("the failure does not say how to fix it: %s", msg)
}
var kErr *knowledge.Error
if !asErr(err, &kErr) || kErr.Code != knowledge.ErrEmbedUnavailable {
t.Errorf("want %s, got %v", knowledge.ErrEmbedUnavailable, err)
}
// Retryable: a model that is starting up will answer in a moment.
if !kErr.Retryable() {
t.Error("an unreachable local model should be retryable")
}
}
func TestAMissingModelIsADifferentProblemFromADeadServer(t *testing.T) {
// Ollama up but never told to pull the model. Same symptom to a user, a
// completely different fix — and one of them is not worth retrying.
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusNotFound)
}))
defer srv.Close()
_, err := knowledge.NewOllama(srv.URL, "nomic-embed-text", 768).
Embed(context.Background(), []string{"x"}, knowledge.KindQuery)
var kErr *knowledge.Error
if !asErr(err, &kErr) {
t.Fatalf("want a knowledge error, got %v", err)
}
if kErr.Code != knowledge.ErrNotConfigured {
t.Errorf("a missing model reported %s; it is a configuration problem, not an outage", kErr.Code)
}
if kErr.Retryable() {
t.Error("a model that was never pulled will not appear by retrying")
}
if !strings.Contains(kErr.Message, "ollama pull") {
t.Errorf("the failure does not name the fix: %s", kErr.Message)
}
}
func TestALocalCorpusIsNotConfusedWithAHostedOne(t *testing.T) {
// Vectors from two models are not comparable, and the model name on the
// chunk row is the only thing standing between that and confident nonsense.
// A local `nomic-embed-text` and a hosted model of the same name must not
// share an identity.
local := knowledge.NewOllama("", "nomic-embed-text", 768)
if !strings.HasPrefix(local.Model(), "ollama/") {
t.Errorf("local model name is %q; it must be distinguishable from a hosted one", local.Model())
}
if local.Model() == knowledge.NewVoyage("k", "nomic-embed-text", 768).Model() {
t.Error("a local and a hosted model with the same name share an identity")
}
}