Files
cairnobs/api/ai/provider/ollama/ollama.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

218 lines
7.2 KiB
Go

// Package ollama implements provider.Provider against a local Ollama
// server -- the default, self-hosted primary provider (Phase 7 task 2;
// see /docs/phase-7-ai-design.md for why Ollama over vLLM and why
// qwen2.5-coder is the recommended model). Same thin-HTTP-client shape
// as alerting/internal/queryclient -- net/http + encoding/json, no new
// HTTP client dependency, matching this codebase's "boring,
// well-understood dependencies" convention.
package ollama
import (
"bytes"
"context"
"encoding/json"
"fmt"
"net/http"
"strings"
"github.com/sentry/sentry/api/ai/provider"
)
// Client implements provider.Provider against one Ollama server and one
// model. The per-operation routing layer (task 2's "per-operation
// provider/model configuration") constructs one Client per distinct
// model a deployment configures -- e.g. one for qwen2.5-coder:1.5b
// (Complete's fast path) and one for qwen2.5-coder:7b (everything else)
// -- rather than this package knowing anything about operation-to-model
// routing itself.
type Client struct {
baseURL string
model string
http *http.Client
}
// New builds a Client. baseURL defaults to Ollama's standard local
// address if empty -- the common case for the primary, self-hosted
// deployment target.
func New(baseURL, model string) *Client {
if baseURL == "" {
baseURL = "http://localhost:11434"
}
return &Client{baseURL: strings.TrimSuffix(baseURL, "/"), model: model, http: &http.Client{}}
}
type chatMessage struct {
Role string `json:"role"`
Content string `json:"content"`
}
type chatRequest struct {
Model string `json:"model"`
Messages []chatMessage `json:"messages"`
Stream bool `json:"stream"`
Format string `json:"format,omitempty"`
}
type chatResponse struct {
Message chatMessage `json:"message"`
}
// chat calls Ollama's POST /api/chat, non-streaming, and returns the
// assistant message content. jsonMode requests Ollama's JSON-constrained
// output format -- used by every operation except Explain, which just
// wants prose back.
func (c *Client) chat(ctx context.Context, system, user string, jsonMode bool) (string, error) {
req := chatRequest{
Model: c.model,
Messages: []chatMessage{
{Role: "system", Content: system},
{Role: "user", Content: user},
},
Stream: false,
}
if jsonMode {
req.Format = "json"
}
body, err := json.Marshal(req)
if err != nil {
return "", fmt.Errorf("ollama: encoding request: %w", err)
}
httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, c.baseURL+"/api/chat", bytes.NewReader(body))
if err != nil {
return "", fmt.Errorf("ollama: building request: %w", err)
}
httpReq.Header.Set("Content-Type", "application/json")
resp, err := c.http.Do(httpReq)
if err != nil {
return "", fmt.Errorf("ollama: calling %s: %w", c.baseURL, err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
var errBody struct {
Error string `json:"error"`
}
_ = json.NewDecoder(resp.Body).Decode(&errBody)
if errBody.Error != "" {
return "", fmt.Errorf("ollama: request failed (%d): %s", resp.StatusCode, errBody.Error)
}
return "", fmt.Errorf("ollama: request failed with status %d", resp.StatusCode)
}
var chatResp chatResponse
if err := json.NewDecoder(resp.Body).Decode(&chatResp); err != nil {
return "", fmt.Errorf("ollama: decoding response: %w", err)
}
return chatResp.Message.Content, nil
}
// stripCodeFence handles the common small-model habit of wrapping JSON
// output in ```json ... ``` even when explicitly told not to -- a
// best-effort cleanup, not a guarantee; a model that returns genuinely
// malformed JSON still surfaces as a real decode error to the caller,
// which is the correct behavior (better an explicit error than silently
// fabricating a result).
func stripCodeFence(s string) string {
s = strings.TrimSpace(s)
if !strings.HasPrefix(s, "```") {
return s
}
s = strings.TrimPrefix(s, "```json")
s = strings.TrimPrefix(s, "```")
s = strings.TrimSuffix(s, "```")
return strings.TrimSpace(s)
}
func parseConfidence(s string) provider.Confidence {
switch strings.ToLower(strings.TrimSpace(s)) {
case "high":
return provider.ConfidenceHigh
case "medium":
return provider.ConfidenceMedium
default:
// Unrecognized or missing confidence fails toward caution, not
// toward assumed correctness -- an empty/garbled confidence
// field from the model is itself a signal something's off.
return provider.ConfidenceLow
}
}
func (c *Client) Translate(ctx context.Context, req provider.TranslateRequest) (provider.TranslateResult, error) {
raw, err := c.chat(ctx, translateSystemPrompt(req.Schema), req.NLQuery, true)
if err != nil {
return provider.TranslateResult{}, err
}
var parsed struct {
Query string `json:"query"`
Confidence string `json:"confidence"`
Reason string `json:"reason"`
}
if err := json.Unmarshal([]byte(stripCodeFence(raw)), &parsed); err != nil {
return provider.TranslateResult{}, fmt.Errorf("ollama: parsing translate response: %w", err)
}
return provider.TranslateResult{
Query: parsed.Query,
Confidence: parseConfidence(parsed.Confidence),
LowConfidenceReason: parsed.Reason,
}, nil
}
func (c *Client) Complete(ctx context.Context, req provider.CompleteRequest) (provider.CompleteResult, error) {
raw, err := c.chat(ctx, completeSystemPrompt(req.Schema), req.QueryPrefix, true)
if err != nil {
return provider.CompleteResult{}, err
}
var parsed struct {
Suggestion string `json:"suggestion"`
}
if err := json.Unmarshal([]byte(stripCodeFence(raw)), &parsed); err != nil {
return provider.CompleteResult{}, fmt.Errorf("ollama: parsing complete response: %w", err)
}
return provider.CompleteResult{Suggestion: parsed.Suggestion}, nil
}
func (c *Client) Explain(ctx context.Context, req provider.ExplainRequest) (provider.ExplainResult, error) {
user := req.Query
switch {
case len(req.RuleFindings) > 0:
user = fmt.Sprintf("Query: %s\nFindings: %s", req.Query, strings.Join(req.RuleFindings, "; "))
case req.OriginalIntent != "":
user = fmt.Sprintf("Original request: %q\nGenerated query: %s", req.OriginalIntent, req.Query)
}
raw, err := c.chat(ctx, explainSystemPrompt(req.OriginalIntent != "", len(req.RuleFindings) > 0), user, false)
if err != nil {
return provider.ExplainResult{}, err
}
return provider.ExplainResult{Explanation: strings.TrimSpace(raw)}, nil
}
func (c *Client) Fix(ctx context.Context, req provider.FixRequest) (provider.FixResult, error) {
errText := req.ParseError
if errText == "" {
errText = req.ExecutionError
}
user := fmt.Sprintf("Query: %s\nError: %s", req.Query, errText)
raw, err := c.chat(ctx, fixSystemPrompt(req.Schema), user, true)
if err != nil {
return provider.FixResult{}, err
}
var parsed struct {
SuggestedQuery string `json:"suggested_query"`
Explanation string `json:"explanation"`
Confidence string `json:"confidence"`
}
if err := json.Unmarshal([]byte(stripCodeFence(raw)), &parsed); err != nil {
return provider.FixResult{}, fmt.Errorf("ollama: parsing fix response: %w", err)
}
return provider.FixResult{
SuggestedQuery: parsed.SuggestedQuery,
Explanation: parsed.Explanation,
Confidence: parseConfidence(parsed.Confidence),
}, nil
}
var _ provider.Provider = (*Client)(nil)