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.
This commit is contained in:
@@ -0,0 +1,62 @@
|
||||
// Package router is the per-operation provider/model dispatch layer
|
||||
// /docs/phase-7-ai-design.md's "per-operation provider/model
|
||||
// configuration" section decided to build now rather than defer --
|
||||
// Complete's tight latency budget and Translate/Fix's quality needs are
|
||||
// already in tension under a single-model-for-everything design, not a
|
||||
// hypothetical future conflict.
|
||||
//
|
||||
// Deliberately thin: a lookup table from Operation to whichever
|
||||
// provider.Provider was configured for it, falling back to one default
|
||||
// when an operation has no specific override. This package makes no
|
||||
// decisions about *which* provider is good for an operation -- that's
|
||||
// deployment configuration, resolved once at startup by whoever
|
||||
// constructs a Router (main.go, once the AI HTTP handlers exist to
|
||||
// consume it).
|
||||
package router
|
||||
|
||||
import "github.com/sentry/sentry/api/ai/provider"
|
||||
|
||||
type Operation string
|
||||
|
||||
const (
|
||||
OpTranslate Operation = "translate"
|
||||
OpComplete Operation = "complete"
|
||||
OpExplain Operation = "explain"
|
||||
OpFix Operation = "fix"
|
||||
)
|
||||
|
||||
// Router selects a provider.Provider per Operation. Not itself a
|
||||
// provider.Provider -- callers ask For(op) and then call the operation
|
||||
// they actually need on the result, rather than this type trying to
|
||||
// implement all four methods and dispatch internally, which would just
|
||||
// be an extra layer of indirection for no benefit.
|
||||
type Router struct {
|
||||
byOp map[Operation]provider.Provider
|
||||
fallback provider.Provider
|
||||
}
|
||||
|
||||
// New builds a Router. fallback must not be nil -- every operation
|
||||
// resolves to *some* provider, even a deployment that never calls
|
||||
// SetOperation and just wants one model for everything.
|
||||
func New(fallback provider.Provider) *Router {
|
||||
return &Router{byOp: make(map[Operation]provider.Provider), fallback: fallback}
|
||||
}
|
||||
|
||||
// SetOperation overrides which provider handles op. Call once per
|
||||
// operation that needs a non-default model at startup configuration
|
||||
// time -- not intended to change at runtime (a Router isn't
|
||||
// synchronized for concurrent SetOperation/For calls, matching every
|
||||
// other "assembled once in main.go, read-only after that" config shape
|
||||
// in this codebase, e.g. Handler's fields in queryapi).
|
||||
func (r *Router) SetOperation(op Operation, p provider.Provider) {
|
||||
r.byOp[op] = p
|
||||
}
|
||||
|
||||
// For returns the provider configured for op, or the fallback if none
|
||||
// was set specifically.
|
||||
func (r *Router) For(op Operation) provider.Provider {
|
||||
if p, ok := r.byOp[op]; ok {
|
||||
return p
|
||||
}
|
||||
return r.fallback
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
package router
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/sentry/sentry/api/ai/provider"
|
||||
)
|
||||
|
||||
// namedFakeProvider lets a test tell which configured provider actually
|
||||
// answered a call -- the thing router.For needs to get right.
|
||||
type namedFakeProvider struct {
|
||||
name string
|
||||
}
|
||||
|
||||
func (f *namedFakeProvider) Translate(context.Context, provider.TranslateRequest) (provider.TranslateResult, error) {
|
||||
return provider.TranslateResult{Query: f.name}, nil
|
||||
}
|
||||
func (f *namedFakeProvider) Complete(context.Context, provider.CompleteRequest) (provider.CompleteResult, error) {
|
||||
return provider.CompleteResult{Suggestion: f.name}, nil
|
||||
}
|
||||
func (f *namedFakeProvider) Explain(context.Context, provider.ExplainRequest) (provider.ExplainResult, error) {
|
||||
return provider.ExplainResult{Explanation: f.name}, nil
|
||||
}
|
||||
func (f *namedFakeProvider) Fix(context.Context, provider.FixRequest) (provider.FixResult, error) {
|
||||
return provider.FixResult{SuggestedQuery: f.name}, nil
|
||||
}
|
||||
|
||||
var _ provider.Provider = (*namedFakeProvider)(nil)
|
||||
|
||||
func TestForReturnsFallbackWhenUnconfigured(t *testing.T) {
|
||||
fallback := &namedFakeProvider{name: "default"}
|
||||
r := New(fallback)
|
||||
|
||||
if got := r.For(OpTranslate); got != fallback {
|
||||
t.Errorf("For(OpTranslate) = %v, want the fallback provider", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetOperationOverridesFallback(t *testing.T) {
|
||||
fallback := &namedFakeProvider{name: "default"}
|
||||
fast := &namedFakeProvider{name: "fast"}
|
||||
r := New(fallback)
|
||||
r.SetOperation(OpComplete, fast)
|
||||
|
||||
if got := r.For(OpComplete); got != fast {
|
||||
t.Errorf("For(OpComplete) = %v, want the fast override", got)
|
||||
}
|
||||
if got := r.For(OpTranslate); got != fallback {
|
||||
t.Errorf("For(OpTranslate) = %v, want unaffected fallback", got)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user