Files
cairnobs/api/ai/provider/ollama/ollama_test.go
T
jcoffey-dev 7d316f92db Phase 7: AI-assisted query authoring (autocomplete, explain, fix, optimize, NL translation)
Adds a self-hosted (Ollama, qwen2.5-coder) model provider abstraction
with a pluggable opt-in cloud adapter, schema grounding, and a shared
cost/safety guard every AI-suggested query is assessed against --
compiling to and executing through the same unchanged Phase 2 IR/
compiler and Phase 4 tenant scoping as a hand-written query, no
parallel execution path.

Track A (built into the query bar): inline ghost-text autocomplete,
"Explain this query", "Fix this query" with a diff view, and a
rule-based "Optimize" suggestion. Track B: natural-language-to-query
translation, always a separate review step from execution, with
`sentryctl query --nl` requiring explicit confirmation to run.
Every accepted/dismissed translate-fix-optimize interaction is logged
into the same append-only audit_log table Phase 4 built.

Two real product bugs were found and fixed via live browser
verification (a Svelte effect re-running on every keystroke that
silently cancelled the ghost-text debounce; a ghost-text widget
positioned at document offset 0 instead of the cursor), and a real
costguard logic bug (unbounded-aggregation vs. raw-row) was caught by
its own test suite. New integration tests wire a real Ollama client
through the real HTTP handler against a mock server matching Ollama's
wire contract (hack/mock-ollama), keeping model-quality verification
out of CI as a disclosed, periodic human-run check instead.

See /docs/phase-7-ai-design.md and /docs/phase-7-runbook.md.
2026-08-16 18:06:27 -07:00

153 lines
5.1 KiB
Go

package ollama
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/sentry/sentry/api/ai/provider"
)
// fakeOllamaServer stands in for a real Ollama server, returning the
// given assistant-message content verbatim -- same reasoning
// queryclient's tests use httptest against a fake api instead of a real
// one: this package's own logic (request shape, response parsing,
// JSON-mode contract) is what's under test, not Ollama itself.
func fakeOllamaServer(t *testing.T, content string) *httptest.Server {
t.Helper()
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/api/chat" {
t.Errorf("unexpected path %s", r.URL.Path)
}
var req chatRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
t.Fatalf("decoding request: %v", err)
}
if len(req.Messages) != 2 || req.Messages[0].Role != "system" || req.Messages[1].Role != "user" {
t.Errorf("unexpected messages shape: %+v", req.Messages)
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(chatResponse{Message: chatMessage{Role: "assistant", Content: content}})
}))
t.Cleanup(srv.Close)
return srv
}
func TestTranslateParsesJSONResponse(t *testing.T) {
srv := fakeOllamaServer(t, `{"query": "earliest=-1h severity=ERROR | stats count by service", "confidence": "high", "reason": ""}`)
c := New(srv.URL, "test-model")
got, err := c.Translate(context.Background(), provider.TranslateRequest{NLQuery: "errors in the last hour by service"})
if err != nil {
t.Fatalf("Translate: %v", err)
}
if got.Confidence != provider.ConfidenceHigh {
t.Errorf("Confidence = %v, want high", got.Confidence)
}
if got.Query == "" {
t.Error("expected a non-empty query")
}
}
func TestTranslateHandlesCodeFencedJSON(t *testing.T) {
srv := fakeOllamaServer(t, "```json\n{\"query\": \"service=api\", \"confidence\": \"medium\", \"reason\": \"\"}\n```")
c := New(srv.URL, "test-model")
got, err := c.Translate(context.Background(), provider.TranslateRequest{NLQuery: "api logs"})
if err != nil {
t.Fatalf("Translate with fenced JSON: %v", err)
}
if got.Query != "service=api" {
t.Errorf("Query = %q, want %q", got.Query, "service=api")
}
}
func TestTranslateLowConfidenceCarriesReason(t *testing.T) {
srv := fakeOllamaServer(t, `{"query": "", "confidence": "low", "reason": "not sure what 'weird stuff' refers to"}`)
c := New(srv.URL, "test-model")
got, err := c.Translate(context.Background(), provider.TranslateRequest{NLQuery: "show me weird stuff"})
if err != nil {
t.Fatalf("Translate: %v", err)
}
if got.Confidence != provider.ConfidenceLow || got.LowConfidenceReason == "" {
t.Errorf("got = %+v, want low confidence with a reason", got)
}
}
func TestTranslateMalformedJSONIsAnError(t *testing.T) {
srv := fakeOllamaServer(t, "not json at all, sorry")
c := New(srv.URL, "test-model")
_, err := c.Translate(context.Background(), provider.TranslateRequest{NLQuery: "anything"})
if err == nil {
t.Fatal("expected an error for unparseable model output, got nil")
}
}
func TestCompleteReturnsSuggestionOnly(t *testing.T) {
srv := fakeOllamaServer(t, `{"suggestion": " | stats count by host"}`)
c := New(srv.URL, "test-model")
got, err := c.Complete(context.Background(), provider.CompleteRequest{QueryPrefix: "service=api", Language: "spl"})
if err != nil {
t.Fatalf("Complete: %v", err)
}
if got.Suggestion != " | stats count by host" {
t.Errorf("Suggestion = %q", got.Suggestion)
}
}
func TestExplainReturnsPlainText(t *testing.T) {
srv := fakeOllamaServer(t, "This counts events per host over the last hour.")
c := New(srv.URL, "test-model")
got, err := c.Explain(context.Background(), provider.ExplainRequest{Query: "earliest=-1h | stats count by host"})
if err != nil {
t.Fatalf("Explain: %v", err)
}
if got.Explanation == "" {
t.Error("expected a non-empty explanation")
}
}
func TestFixParsesJSONResponse(t *testing.T) {
srv := fakeOllamaServer(t, `{"suggested_query": "earliest=-1h | stats count", "explanation": "added a required time range", "confidence": "high"}`)
c := New(srv.URL, "test-model")
got, err := c.Fix(context.Background(), provider.FixRequest{Query: "stats count", ParseError: "no time range"})
if err != nil {
t.Fatalf("Fix: %v", err)
}
if got.SuggestedQuery == "" || got.Explanation == "" {
t.Errorf("got = %+v, want both fields populated", got)
}
}
func TestNonOKStatusIsAnError(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusInternalServerError)
_ = json.NewEncoder(w).Encode(map[string]string{"error": "model not found"})
}))
t.Cleanup(srv.Close)
c := New(srv.URL, "missing-model")
_, err := c.Explain(context.Background(), provider.ExplainRequest{Query: "x"})
if err == nil {
t.Fatal("expected an error for a non-200 response")
}
if got := err.Error(); !strings.Contains(got, "model not found") {
t.Errorf("error = %q, want it to include the server's error message", got)
}
}
func TestDefaultBaseURL(t *testing.T) {
c := New("", "m")
if c.baseURL != "http://localhost:11434" {
t.Errorf("default baseURL = %q", c.baseURL)
}
}