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,514 @@
|
||||
// Package aiapi is Track A's HTTP surface: complete, explain, fix, and
|
||||
// optimize, each a thin wrapper around api/ai/router dispatching to
|
||||
// whichever provider.Provider is configured for that operation. Mirrors
|
||||
// queryapi's shape deliberately (same auth wrapper, same request-size
|
||||
// cap, same error-response shape) since this is the same kind of
|
||||
// endpoint -- a JSON-in, JSON-out operation gated by the same RoleViewer
|
||||
// requirement /query uses, nothing AI-specific about the transport.
|
||||
//
|
||||
// What this package does NOT do: execute a query. Every operation here
|
||||
// returns text (a suggestion, an explanation, a fix) for the client to
|
||||
// review -- running anything still goes through the unchanged POST
|
||||
// /query endpoint (queryapi.Handler), never through here. See
|
||||
// /docs/phase-7-ai-design.md for why that split is load-bearing, not
|
||||
// incidental.
|
||||
package aiapi
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/sentry/sentry/api/ai/costguard"
|
||||
"github.com/sentry/sentry/api/ai/provider"
|
||||
"github.com/sentry/sentry/api/ai/router"
|
||||
"github.com/sentry/sentry/api/authz"
|
||||
"github.com/sentry/sentry/api/internal/querylang/ir"
|
||||
"github.com/sentry/sentry/api/internal/querylang/planner"
|
||||
)
|
||||
|
||||
// SchemaContextSource resolves the calling tenant's grounding data.
|
||||
// Core wires a tenant-agnostic adapter around one grounding.Service;
|
||||
// enterprise-api wires one around groundingregistry that reads the
|
||||
// tenant from request context -- same "interface in core, tenant-aware
|
||||
// implementation supplied by whoever constructs the handler" shape as
|
||||
// queryapi.AuditLogger and dashboards.PermissionStore.
|
||||
type SchemaContextSource interface {
|
||||
SchemaContext(ctx context.Context) provider.SchemaContext
|
||||
}
|
||||
|
||||
// InteractionLogger records a translate/fix/optimize suggestion's
|
||||
// accept-or-dismiss outcome into the Phase 4 audit trail (task 12) --
|
||||
// same nil-by-default, fail-open shape as queryapi.AuditLogger: a
|
||||
// single-tenant deployment with no enterprise/ configured just doesn't
|
||||
// log these, same as it doesn't log query executions today.
|
||||
// enterprise/internal/audit supplies the real implementation, writing
|
||||
// into the same append-only audit_log table query executions use
|
||||
// (a new event_type, not a new table -- see
|
||||
// metadata/migrations/0036_add_ai_interaction_event_type.sql).
|
||||
//
|
||||
// Deliberately not wired into Complete (ghost-text): that operation
|
||||
// fires on every keystroke pause, and logging each one at the same
|
||||
// weight as a deliberate Fix/Optimize/Translate review would drown the
|
||||
// signal task 12 actually wants (real accept/reject decisions) in
|
||||
// high-frequency noise. Not wired into Explain either -- it produces no
|
||||
// suggestion to accept or reject, so "accepted vs. rejected" doesn't
|
||||
// apply to it. Both are named scope boundaries, not oversights.
|
||||
type InteractionLogger interface {
|
||||
LogInteraction(ctx context.Context, entry InteractionEntry) error
|
||||
}
|
||||
|
||||
type InteractionEntry struct {
|
||||
// Operation is "translate", "fix", or "optimize" -- the three flows
|
||||
// that produce a suggestion a user explicitly accepts or dismisses.
|
||||
Operation string
|
||||
Input string
|
||||
Output string
|
||||
// Confidence is empty for fix/optimize (provider.Confidence only
|
||||
// applies to Translate/Fix results in a way the frontend surfaces
|
||||
// today -- Optimize's phrasing has no confidence concept).
|
||||
Confidence string
|
||||
// Accepted is false for a dismissed suggestion; Output/FinalQuery
|
||||
// still carry what was offered, since a rejected suggestion is
|
||||
// itself useful signal (task 12: "useful data for improving
|
||||
// grounding/prompting later").
|
||||
Accepted bool
|
||||
// Edited is only meaningful when Accepted -- did the user change
|
||||
// the suggested text before using it. False, not omitted, when
|
||||
// Accepted is false (there's nothing to have edited).
|
||||
Edited bool
|
||||
FinalQuery string
|
||||
}
|
||||
|
||||
// completeTimeout is deliberately tight -- task 5's "low enough latency
|
||||
// to feel responsive" requirement. A slow or hung provider must not
|
||||
// stall the query bar; the frontend's fallback to deterministic
|
||||
// autocomplete (Phase 2/5) kicks in on any error, including a timeout,
|
||||
// so a short timeout here fails fast toward that fallback rather than
|
||||
// making the user wait to find out AI completion isn't going to work
|
||||
// this time.
|
||||
const completeTimeout = 1500 * time.Millisecond
|
||||
|
||||
// Explain/Fix/Optimize are user-initiated (a button press, not
|
||||
// as-you-type), so a more generous budget is the right tradeoff --
|
||||
// correctness/quality over latency here, unlike Complete.
|
||||
const operationTimeout = 15 * time.Second
|
||||
|
||||
type Handler struct {
|
||||
logger *slog.Logger
|
||||
router *router.Router
|
||||
schema SchemaContextSource
|
||||
authz authz.Authorizer
|
||||
interactions InteractionLogger
|
||||
}
|
||||
|
||||
// interactions may be nil -- see InteractionLogger's doc comment.
|
||||
func NewHandler(logger *slog.Logger, r *router.Router, schema SchemaContextSource, authorizer authz.Authorizer, interactions InteractionLogger) *Handler {
|
||||
return &Handler{logger: logger, router: r, schema: schema, authz: authorizer, interactions: interactions}
|
||||
}
|
||||
|
||||
func (h *Handler) RegisterRoutes(mux *http.ServeMux) {
|
||||
mux.HandleFunc("POST /ai/complete", authz.RequireRoleOrService(h.authz, authz.RoleViewer, h.handleComplete))
|
||||
mux.HandleFunc("POST /ai/explain", authz.RequireRoleOrService(h.authz, authz.RoleViewer, h.handleExplain))
|
||||
mux.HandleFunc("POST /ai/fix", authz.RequireRoleOrService(h.authz, authz.RoleViewer, h.handleFix))
|
||||
mux.HandleFunc("POST /ai/optimize", authz.RequireRoleOrService(h.authz, authz.RoleViewer, h.handleOptimize))
|
||||
mux.HandleFunc("POST /ai/translate", authz.RequireRoleOrService(h.authz, authz.RoleViewer, h.handleTranslate))
|
||||
mux.HandleFunc("POST /ai/log-interaction", authz.RequireRoleOrService(h.authz, authz.RoleViewer, h.handleLogInteraction))
|
||||
}
|
||||
|
||||
const maxBodyBytes = 1 << 20 // 1 MiB, same cap queryapi uses -- these bodies are smaller still
|
||||
|
||||
type errorResponse struct {
|
||||
Error string `json:"error"`
|
||||
}
|
||||
|
||||
func writeJSON(w http.ResponseWriter, v any) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(v)
|
||||
}
|
||||
|
||||
func writeError(w http.ResponseWriter, status int, msg string) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(status)
|
||||
_ = json.NewEncoder(w).Encode(errorResponse{Error: msg})
|
||||
}
|
||||
|
||||
func decodeBody(w http.ResponseWriter, r *http.Request, v any) bool {
|
||||
r.Body = http.MaxBytesReader(w, r.Body, maxBodyBytes)
|
||||
if err := json.NewDecoder(r.Body).Decode(v); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid JSON body: "+err.Error())
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// ---- complete ----
|
||||
|
||||
type completeRequest struct {
|
||||
QueryPrefix string `json:"queryPrefix"`
|
||||
Language string `json:"language"`
|
||||
}
|
||||
|
||||
type completeResponse struct {
|
||||
Suggestion string `json:"suggestion"`
|
||||
}
|
||||
|
||||
func (h *Handler) handleComplete(w http.ResponseWriter, r *http.Request) {
|
||||
var req completeRequest
|
||||
if !decodeBody(w, r, &req) {
|
||||
return
|
||||
}
|
||||
if strings.TrimSpace(req.QueryPrefix) == "" {
|
||||
writeJSON(w, completeResponse{})
|
||||
return
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(r.Context(), completeTimeout)
|
||||
defer cancel()
|
||||
|
||||
result, err := h.router.For(router.OpComplete).Complete(ctx, provider.CompleteRequest{
|
||||
QueryPrefix: req.QueryPrefix,
|
||||
Language: orDefault(req.Language, "spl"),
|
||||
Schema: h.schema.SchemaContext(r.Context()),
|
||||
})
|
||||
if err != nil {
|
||||
// Complete's whole point is to degrade gracefully -- a failed or
|
||||
// slow completion is not worth a scary error response the query
|
||||
// bar has to handle specially. An empty suggestion is exactly
|
||||
// what "no good completion available right now" looks like to
|
||||
// the frontend's fallback logic (task 5).
|
||||
h.logger.Warn("ai complete failed", "error", err)
|
||||
writeJSON(w, completeResponse{})
|
||||
return
|
||||
}
|
||||
writeJSON(w, completeResponse{Suggestion: result.Suggestion})
|
||||
}
|
||||
|
||||
// ---- explain ----
|
||||
|
||||
type explainRequest struct {
|
||||
Query string `json:"query"`
|
||||
Language string `json:"language"`
|
||||
OriginalIntent string `json:"originalIntent"`
|
||||
}
|
||||
|
||||
type explainResponse struct {
|
||||
Explanation string `json:"explanation"`
|
||||
}
|
||||
|
||||
func (h *Handler) handleExplain(w http.ResponseWriter, r *http.Request) {
|
||||
var req explainRequest
|
||||
if !decodeBody(w, r, &req) {
|
||||
return
|
||||
}
|
||||
if strings.TrimSpace(req.Query) == "" {
|
||||
writeError(w, http.StatusBadRequest, "query must not be empty")
|
||||
return
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(r.Context(), operationTimeout)
|
||||
defer cancel()
|
||||
|
||||
result, err := h.router.For(router.OpExplain).Explain(ctx, provider.ExplainRequest{
|
||||
Query: req.Query,
|
||||
Language: orDefault(req.Language, "spl"),
|
||||
OriginalIntent: req.OriginalIntent,
|
||||
})
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadGateway, "explain failed: "+err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, explainResponse{Explanation: result.Explanation})
|
||||
}
|
||||
|
||||
// ---- fix ----
|
||||
|
||||
type fixRequest struct {
|
||||
Query string `json:"query"`
|
||||
Language string `json:"language"`
|
||||
ParseError string `json:"parseError"`
|
||||
ExecutionError string `json:"executionError"`
|
||||
}
|
||||
|
||||
type fixResponse struct {
|
||||
SuggestedQuery string `json:"suggestedQuery"`
|
||||
Explanation string `json:"explanation"`
|
||||
Confidence string `json:"confidence"`
|
||||
// Blocked mirrors costguard's assessment on the *suggested* query --
|
||||
// task 4's stricter AI-track treatment: a reject-level suggestion is
|
||||
// still shown (so the user understands what was tried and why it's
|
||||
// not being offered outright) but the frontend must not present a
|
||||
// plain accept-and-run action for it. CostWarnings is empty unless
|
||||
// Blocked, or the suggestion has a lesser (warn-level) concern worth
|
||||
// surfacing.
|
||||
Blocked bool `json:"blocked"`
|
||||
CostWarnings []string `json:"costWarnings,omitempty"`
|
||||
}
|
||||
|
||||
func (h *Handler) handleFix(w http.ResponseWriter, r *http.Request) {
|
||||
var req fixRequest
|
||||
if !decodeBody(w, r, &req) {
|
||||
return
|
||||
}
|
||||
if strings.TrimSpace(req.Query) == "" {
|
||||
writeError(w, http.StatusBadRequest, "query must not be empty")
|
||||
return
|
||||
}
|
||||
if req.ParseError == "" && req.ExecutionError == "" {
|
||||
writeError(w, http.StatusBadRequest, "parseError or executionError must be set")
|
||||
return
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(r.Context(), operationTimeout)
|
||||
defer cancel()
|
||||
|
||||
lang := orDefault(req.Language, "spl")
|
||||
result, err := h.router.For(router.OpFix).Fix(ctx, provider.FixRequest{
|
||||
Query: req.Query,
|
||||
Language: lang,
|
||||
ParseError: req.ParseError,
|
||||
ExecutionError: req.ExecutionError,
|
||||
Schema: h.schema.SchemaContext(r.Context()),
|
||||
})
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadGateway, "fix failed: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
resp := fixResponse{
|
||||
SuggestedQuery: result.SuggestedQuery,
|
||||
Explanation: result.Explanation,
|
||||
Confidence: string(result.Confidence),
|
||||
}
|
||||
if resp.SuggestedQuery != "" {
|
||||
if plan, err := planner.Compile(resp.SuggestedQuery, planner.Language(lang), time.Now()); err == nil {
|
||||
if assessment := costguard.Assess(plan); assessment.Level != costguard.LevelOK {
|
||||
resp.CostWarnings = assessment.Reasons
|
||||
resp.Blocked = assessment.Level == costguard.LevelReject
|
||||
}
|
||||
}
|
||||
// A suggested query that itself fails to compile is left
|
||||
// unassessed rather than treated as an error -- an unusual
|
||||
// outcome (the model produced something that doesn't parse) the
|
||||
// frontend can still show as a suggestion text, just without a
|
||||
// cost assessment attached to it.
|
||||
}
|
||||
writeJSON(w, resp)
|
||||
}
|
||||
|
||||
// ---- optimize ----
|
||||
|
||||
type optimizeRequest struct {
|
||||
Query string `json:"query"`
|
||||
Language string `json:"language"`
|
||||
}
|
||||
|
||||
type optimizeResponse struct {
|
||||
// Findings is always populated when costguard has anything to say --
|
||||
// rule-based, instant, no model call needed to produce this part.
|
||||
Findings []string `json:"findings"`
|
||||
// Phrased is the AI-phrased version of Findings (task 8: "AI layer
|
||||
// used mainly to phrase the suggestion clearly"). Empty if the
|
||||
// provider is unavailable or fails -- graceful degradation, same as
|
||||
// Complete: the raw Findings are still useful on their own, this is
|
||||
// an enhancement layered on top, not a dependency.
|
||||
Phrased string `json:"phrased"`
|
||||
// SuggestedQuery is a mechanical rewrite, not model-generated --
|
||||
// only populated for the one case this package can safely rewrite
|
||||
// unambiguously (a missing time range; see suggestFix below). Other
|
||||
// findings (an overly large time range, an unfiltered free-text
|
||||
// search) get text-only guidance, honestly, rather than a guessed
|
||||
// rewrite.
|
||||
SuggestedQuery string `json:"suggestedQuery,omitempty"`
|
||||
}
|
||||
|
||||
func (h *Handler) handleOptimize(w http.ResponseWriter, r *http.Request) {
|
||||
var req optimizeRequest
|
||||
if !decodeBody(w, r, &req) {
|
||||
return
|
||||
}
|
||||
if strings.TrimSpace(req.Query) == "" {
|
||||
writeError(w, http.StatusBadRequest, "query must not be empty")
|
||||
return
|
||||
}
|
||||
|
||||
lang := orDefault(req.Language, "spl")
|
||||
plan, err := planner.Compile(req.Query, planner.Language(lang), time.Now())
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "query does not compile: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
assessment := costguard.Assess(plan)
|
||||
resp := optimizeResponse{Findings: assessment.Reasons}
|
||||
if assessment.Level == costguard.LevelOK {
|
||||
writeJSON(w, resp)
|
||||
return
|
||||
}
|
||||
|
||||
resp.SuggestedQuery = suggestMechanicalFix(req.Query, plan)
|
||||
|
||||
ctx, cancel := context.WithTimeout(r.Context(), operationTimeout)
|
||||
defer cancel()
|
||||
if result, err := h.router.For(router.OpExplain).Explain(ctx, provider.ExplainRequest{
|
||||
Query: req.Query,
|
||||
Language: lang,
|
||||
RuleFindings: assessment.Reasons,
|
||||
}); err != nil {
|
||||
h.logger.Warn("ai optimize phrasing failed", "error", err)
|
||||
} else {
|
||||
resp.Phrased = result.Explanation
|
||||
}
|
||||
writeJSON(w, resp)
|
||||
}
|
||||
|
||||
// ---- translate (Track B, task 9) ----
|
||||
|
||||
type translateRequest struct {
|
||||
NLQuery string `json:"nlQuery"`
|
||||
}
|
||||
|
||||
type translateResponse struct {
|
||||
Query string `json:"query"`
|
||||
Confidence string `json:"confidence"`
|
||||
LowConfidenceReason string `json:"lowConfidenceReason,omitempty"`
|
||||
// Compiles is false when Query is non-empty but doesn't actually
|
||||
// parse as pipe syntax -- a real, honest outcome (the model
|
||||
// produced something invalid), not folded into "low confidence"
|
||||
// since a model can be confident and still wrong about syntax.
|
||||
// CompileError is set only then.
|
||||
Compiles bool `json:"compiles"`
|
||||
CompileError string `json:"compileError,omitempty"`
|
||||
// Blocked/CostWarnings mirror handleFix's same-named fields exactly
|
||||
// -- task 9's explicit requirement that translation results run
|
||||
// through the shared cost guard "before returning it," same
|
||||
// treatment as an AI-suggested fix gets, not a lesser one.
|
||||
Blocked bool `json:"blocked"`
|
||||
CostWarnings []string `json:"costWarnings,omitempty"`
|
||||
}
|
||||
|
||||
func (h *Handler) handleTranslate(w http.ResponseWriter, r *http.Request) {
|
||||
var req translateRequest
|
||||
if !decodeBody(w, r, &req) {
|
||||
return
|
||||
}
|
||||
if strings.TrimSpace(req.NLQuery) == "" {
|
||||
writeError(w, http.StatusBadRequest, "nlQuery must not be empty")
|
||||
return
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(r.Context(), operationTimeout)
|
||||
defer cancel()
|
||||
|
||||
result, err := h.router.For(router.OpTranslate).Translate(ctx, provider.TranslateRequest{
|
||||
NLQuery: req.NLQuery,
|
||||
Schema: h.schema.SchemaContext(r.Context()),
|
||||
})
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadGateway, "translation failed: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
resp := translateResponse{
|
||||
Query: result.Query,
|
||||
Confidence: string(result.Confidence),
|
||||
LowConfidenceReason: result.LowConfidenceReason,
|
||||
}
|
||||
if resp.Query != "" {
|
||||
// Always pipe syntax -- provider.TranslateResult's own doc
|
||||
// comment requires this (task 9: "prefer this over raw SQL...
|
||||
// narrower, safer surface"), so this always compiles as SPL,
|
||||
// never auto-detected/SQL.
|
||||
plan, compileErr := planner.Compile(resp.Query, planner.SPL, time.Now())
|
||||
if compileErr != nil {
|
||||
resp.Compiles = false
|
||||
resp.CompileError = compileErr.Error()
|
||||
} else {
|
||||
resp.Compiles = true
|
||||
if assessment := costguard.Assess(plan); assessment.Level != costguard.LevelOK {
|
||||
resp.CostWarnings = assessment.Reasons
|
||||
resp.Blocked = assessment.Level == costguard.LevelReject
|
||||
}
|
||||
}
|
||||
}
|
||||
writeJSON(w, resp)
|
||||
}
|
||||
|
||||
// suggestMechanicalFix handles exactly one case: no time bound at all.
|
||||
// Prepending "earliest=-1h " is always syntactically safe (another
|
||||
// AND'd base-search filter term, same as any other) and semantically
|
||||
// the single most common real fix for this specific finding -- not
|
||||
// attempted for any other finding (a too-large span, an unindexed
|
||||
// free-text pattern), which don't have one unambiguous correct rewrite.
|
||||
// Checked against the plan directly (not against costguard's Reasons
|
||||
// text), so this stays correct even if that phrasing changes later.
|
||||
func suggestMechanicalFix(originalQuery string, plan *ir.Plan) string {
|
||||
if plan.RawSQL != "" {
|
||||
return "" // no safe generic rewrite for arbitrary SQL
|
||||
}
|
||||
hasTimeBound := plan.TimeRange != nil && (!plan.TimeRange.From.IsZero() || !plan.TimeRange.To.IsZero())
|
||||
if hasTimeBound {
|
||||
return ""
|
||||
}
|
||||
return "earliest=-1h " + originalQuery
|
||||
}
|
||||
|
||||
func orDefault(s, def string) string {
|
||||
if s == "" {
|
||||
return def
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// ---- log-interaction (task 12) ----
|
||||
|
||||
type logInteractionRequest struct {
|
||||
Operation string `json:"operation"`
|
||||
Input string `json:"input"`
|
||||
Output string `json:"output"`
|
||||
Confidence string `json:"confidence"`
|
||||
Accepted bool `json:"accepted"`
|
||||
Edited bool `json:"edited"`
|
||||
FinalQuery string `json:"finalQuery"`
|
||||
}
|
||||
|
||||
var validInteractionOps = map[string]bool{"translate": true, "fix": true, "optimize": true}
|
||||
|
||||
// handleLogInteraction is called by the frontend at the moment a user
|
||||
// takes a terminal action on a suggestion (accept-and-use or dismiss) --
|
||||
// see InteractionLogger's doc comment for why this is a single
|
||||
// frontend-reported event rather than a backend-correlated
|
||||
// generation-plus-outcome pair. Fail-open, same posture
|
||||
// queryapi.Handler.logAudit uses: a write failure here is logged
|
||||
// server-side and otherwise ignored, never surfaced as an error to a
|
||||
// user who just clicked a button -- audit-trail completeness is a real
|
||||
// requirement, but it shouldn't be able to break the query bar.
|
||||
func (h *Handler) handleLogInteraction(w http.ResponseWriter, r *http.Request) {
|
||||
var req logInteractionRequest
|
||||
if !decodeBody(w, r, &req) {
|
||||
return
|
||||
}
|
||||
if !validInteractionOps[req.Operation] {
|
||||
writeError(w, http.StatusBadRequest, `operation must be one of "translate", "fix", "optimize"`)
|
||||
return
|
||||
}
|
||||
|
||||
if h.interactions != nil {
|
||||
err := h.interactions.LogInteraction(r.Context(), InteractionEntry{
|
||||
Operation: req.Operation,
|
||||
Input: req.Input,
|
||||
Output: req.Output,
|
||||
Confidence: req.Confidence,
|
||||
Accepted: req.Accepted,
|
||||
Edited: req.Edited,
|
||||
FinalQuery: req.FinalQuery,
|
||||
})
|
||||
if err != nil {
|
||||
h.logger.Error("ai interaction audit log write failed", "error", err)
|
||||
}
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
@@ -0,0 +1,362 @@
|
||||
package aiapi
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/sentry/sentry/api/ai/provider"
|
||||
"github.com/sentry/sentry/api/ai/router"
|
||||
)
|
||||
|
||||
type fakeProvider struct {
|
||||
translateResult provider.TranslateResult
|
||||
completeResult provider.CompleteResult
|
||||
explainResult provider.ExplainResult
|
||||
fixResult provider.FixResult
|
||||
err error
|
||||
|
||||
gotExplainReq provider.ExplainRequest
|
||||
}
|
||||
|
||||
func (f *fakeProvider) Translate(context.Context, provider.TranslateRequest) (provider.TranslateResult, error) {
|
||||
return f.translateResult, f.err
|
||||
}
|
||||
func (f *fakeProvider) Complete(context.Context, provider.CompleteRequest) (provider.CompleteResult, error) {
|
||||
return f.completeResult, f.err
|
||||
}
|
||||
func (f *fakeProvider) Explain(_ context.Context, req provider.ExplainRequest) (provider.ExplainResult, error) {
|
||||
f.gotExplainReq = req
|
||||
return f.explainResult, f.err
|
||||
}
|
||||
func (f *fakeProvider) Fix(context.Context, provider.FixRequest) (provider.FixResult, error) {
|
||||
return f.fixResult, f.err
|
||||
}
|
||||
|
||||
type fakeSchemaSource struct{}
|
||||
|
||||
func (fakeSchemaSource) SchemaContext(context.Context) provider.SchemaContext {
|
||||
return provider.SchemaContext{Services: []string{"api"}}
|
||||
}
|
||||
|
||||
func newTestHandler(p *fakeProvider) *Handler {
|
||||
r := router.New(p)
|
||||
logger := slog.New(slog.NewTextHandler(bytesDiscard{}, nil))
|
||||
return NewHandler(logger, r, fakeSchemaSource{}, nil, nil)
|
||||
}
|
||||
|
||||
type bytesDiscard struct{}
|
||||
|
||||
func (bytesDiscard) Write(p []byte) (int, error) { return len(p), nil }
|
||||
|
||||
func doRequest(t *testing.T, h *Handler, method, path string, body any) *httptest.ResponseRecorder {
|
||||
t.Helper()
|
||||
var buf bytes.Buffer
|
||||
if body != nil {
|
||||
if err := json.NewEncoder(&buf).Encode(body); err != nil {
|
||||
t.Fatalf("encoding request body: %v", err)
|
||||
}
|
||||
}
|
||||
req := httptest.NewRequest(method, path, &buf)
|
||||
mux := http.NewServeMux()
|
||||
h.RegisterRoutes(mux)
|
||||
rec := httptest.NewRecorder()
|
||||
mux.ServeHTTP(rec, req)
|
||||
return rec
|
||||
}
|
||||
|
||||
func TestHandleCompleteReturnsSuggestion(t *testing.T) {
|
||||
p := &fakeProvider{completeResult: provider.CompleteResult{Suggestion: " | stats count"}}
|
||||
h := newTestHandler(p)
|
||||
|
||||
rec := doRequest(t, h, "POST", "/ai/complete", completeRequest{QueryPrefix: "service=api"})
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, body = %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
var resp completeResponse
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("decoding response: %v", err)
|
||||
}
|
||||
if resp.Suggestion != " | stats count" {
|
||||
t.Errorf("Suggestion = %q", resp.Suggestion)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleCompleteDegradesGracefullyOnProviderError(t *testing.T) {
|
||||
p := &fakeProvider{err: errors.New("provider down")}
|
||||
h := newTestHandler(p)
|
||||
|
||||
rec := doRequest(t, h, "POST", "/ai/complete", completeRequest{QueryPrefix: "service=api"})
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200 even on provider failure (graceful degradation), body = %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
var resp completeResponse
|
||||
_ = json.Unmarshal(rec.Body.Bytes(), &resp)
|
||||
if resp.Suggestion != "" {
|
||||
t.Errorf("Suggestion = %q, want empty on provider failure", resp.Suggestion)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleCompleteEmptyPrefixSkipsProviderCall(t *testing.T) {
|
||||
p := &fakeProvider{err: errors.New("should not be called")}
|
||||
h := newTestHandler(p)
|
||||
|
||||
rec := doRequest(t, h, "POST", "/ai/complete", completeRequest{QueryPrefix: " "})
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, body = %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleExplainReturnsExplanation(t *testing.T) {
|
||||
p := &fakeProvider{explainResult: provider.ExplainResult{Explanation: "counts errors per host"}}
|
||||
h := newTestHandler(p)
|
||||
|
||||
rec := doRequest(t, h, "POST", "/ai/explain", explainRequest{Query: "severity=ERROR | stats count by host"})
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, body = %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
var resp explainResponse
|
||||
_ = json.Unmarshal(rec.Body.Bytes(), &resp)
|
||||
if resp.Explanation == "" {
|
||||
t.Error("expected a non-empty explanation")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleExplainEmptyQueryIsBadRequest(t *testing.T) {
|
||||
h := newTestHandler(&fakeProvider{})
|
||||
rec := doRequest(t, h, "POST", "/ai/explain", explainRequest{Query: ""})
|
||||
if rec.Code != http.StatusBadRequest {
|
||||
t.Errorf("status = %d, want 400", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleExplainProviderErrorIsBadGateway(t *testing.T) {
|
||||
p := &fakeProvider{err: errors.New("model unavailable")}
|
||||
h := newTestHandler(p)
|
||||
rec := doRequest(t, h, "POST", "/ai/explain", explainRequest{Query: "service=api"})
|
||||
if rec.Code != http.StatusBadGateway {
|
||||
t.Errorf("status = %d, want 502", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleFixMissingErrorFieldsIsBadRequest(t *testing.T) {
|
||||
h := newTestHandler(&fakeProvider{})
|
||||
rec := doRequest(t, h, "POST", "/ai/fix", fixRequest{Query: "service=api"})
|
||||
if rec.Code != http.StatusBadRequest {
|
||||
t.Errorf("status = %d, want 400 (neither parseError nor executionError set)", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleFixAssessesSuggestedQueryCost(t *testing.T) {
|
||||
// The provider suggests a fix that aggregates with no time bound --
|
||||
// costguard should reject it (an aggregation gets no implicit row
|
||||
// cap the way a raw-row fetch does), and the handler must mark it
|
||||
// Blocked rather than silently offering it as runnable. Needs a real
|
||||
// leading pipe stage -- bare words with no "|" parse as free-text
|
||||
// search terms, not an aggregation, per the query grammar.
|
||||
p := &fakeProvider{fixResult: provider.FixResult{
|
||||
SuggestedQuery: "service=api | stats count by host",
|
||||
Explanation: "removed the invalid field reference",
|
||||
Confidence: provider.ConfidenceHigh,
|
||||
}}
|
||||
h := newTestHandler(p)
|
||||
|
||||
rec := doRequest(t, h, "POST", "/ai/fix", fixRequest{Query: "bogus_field=1 | stats count by host", ParseError: "unknown field"})
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, body = %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
var resp fixResponse
|
||||
_ = json.Unmarshal(rec.Body.Bytes(), &resp)
|
||||
if !resp.Blocked {
|
||||
t.Errorf("resp = %+v, want Blocked=true for an unbounded aggregation suggestion", resp)
|
||||
}
|
||||
if len(resp.CostWarnings) == 0 {
|
||||
t.Error("expected non-empty CostWarnings")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleFixBoundedSuggestionIsNotBlocked(t *testing.T) {
|
||||
p := &fakeProvider{fixResult: provider.FixResult{
|
||||
SuggestedQuery: "earliest=-1h | stats count by host",
|
||||
Confidence: provider.ConfidenceHigh,
|
||||
}}
|
||||
h := newTestHandler(p)
|
||||
|
||||
rec := doRequest(t, h, "POST", "/ai/fix", fixRequest{Query: "stats count by host", ParseError: "no time range"})
|
||||
var resp fixResponse
|
||||
_ = json.Unmarshal(rec.Body.Bytes(), &resp)
|
||||
if resp.Blocked {
|
||||
t.Errorf("resp = %+v, want Blocked=false for a properly time-bounded suggestion", resp)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleOptimizeNoFindingsForBoundedQuery(t *testing.T) {
|
||||
h := newTestHandler(&fakeProvider{})
|
||||
rec := doRequest(t, h, "POST", "/ai/optimize", optimizeRequest{Query: "earliest=-1h severity=ERROR | stats count by host"})
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, body = %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
var resp optimizeResponse
|
||||
_ = json.Unmarshal(rec.Body.Bytes(), &resp)
|
||||
if len(resp.Findings) != 0 || resp.Phrased != "" {
|
||||
t.Errorf("resp = %+v, want no findings for a bounded query", resp)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleOptimizeSuggestsMechanicalFixForMissingTimeRange(t *testing.T) {
|
||||
p := &fakeProvider{explainResult: provider.ExplainResult{Explanation: "add a time range to avoid scanning everything"}}
|
||||
h := newTestHandler(p)
|
||||
|
||||
rec := doRequest(t, h, "POST", "/ai/optimize", optimizeRequest{Query: "stats count by host"})
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, body = %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
var resp optimizeResponse
|
||||
_ = json.Unmarshal(rec.Body.Bytes(), &resp)
|
||||
if len(resp.Findings) == 0 {
|
||||
t.Error("expected at least one finding for an unbounded aggregation")
|
||||
}
|
||||
if resp.SuggestedQuery != "earliest=-1h stats count by host" {
|
||||
t.Errorf("SuggestedQuery = %q", resp.SuggestedQuery)
|
||||
}
|
||||
if resp.Phrased == "" {
|
||||
t.Error("expected a phrased explanation from the (fake) provider")
|
||||
}
|
||||
if len(p.gotExplainReq.RuleFindings) == 0 {
|
||||
t.Error("expected Explain to have been called with RuleFindings set")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleOptimizeDegradesGracefullyWhenPhraseFails(t *testing.T) {
|
||||
p := &fakeProvider{err: errors.New("model down")}
|
||||
h := newTestHandler(p)
|
||||
|
||||
rec := doRequest(t, h, "POST", "/ai/optimize", optimizeRequest{Query: "stats count by host"})
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200 even when phrasing fails, body = %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
var resp optimizeResponse
|
||||
_ = json.Unmarshal(rec.Body.Bytes(), &resp)
|
||||
if len(resp.Findings) == 0 {
|
||||
t.Error("Findings should still be populated (rule-based, no model needed) even if phrasing fails")
|
||||
}
|
||||
if resp.Phrased != "" {
|
||||
t.Errorf("Phrased = %q, want empty when the provider fails", resp.Phrased)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleOptimizeInvalidQueryIsBadRequest(t *testing.T) {
|
||||
h := newTestHandler(&fakeProvider{})
|
||||
rec := doRequest(t, h, "POST", "/ai/optimize", optimizeRequest{Query: "| stats"})
|
||||
if rec.Code != http.StatusBadRequest {
|
||||
t.Errorf("status = %d, want 400 for an uncompilable query", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// ---- translate ----
|
||||
|
||||
func TestHandleTranslateEmptyNLQueryIsBadRequest(t *testing.T) {
|
||||
h := newTestHandler(&fakeProvider{})
|
||||
rec := doRequest(t, h, "POST", "/ai/translate", translateRequest{NLQuery: " "})
|
||||
if rec.Code != http.StatusBadRequest {
|
||||
t.Errorf("status = %d, want 400", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleTranslateProviderErrorIsBadGateway(t *testing.T) {
|
||||
p := &fakeProvider{err: errors.New("model unavailable")}
|
||||
h := newTestHandler(p)
|
||||
rec := doRequest(t, h, "POST", "/ai/translate", translateRequest{NLQuery: "errors in the last hour"})
|
||||
if rec.Code != http.StatusBadGateway {
|
||||
t.Errorf("status = %d, want 502", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleTranslateBoundedQueryCompilesCleanly(t *testing.T) {
|
||||
p := &fakeProvider{translateResult: provider.TranslateResult{
|
||||
Query: "earliest=-1h severity=ERROR | stats count by service",
|
||||
Confidence: provider.ConfidenceHigh,
|
||||
}}
|
||||
h := newTestHandler(p)
|
||||
rec := doRequest(t, h, "POST", "/ai/translate", translateRequest{NLQuery: "errors per service in the last hour"})
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, body = %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
var resp translateResponse
|
||||
_ = json.Unmarshal(rec.Body.Bytes(), &resp)
|
||||
if !resp.Compiles || resp.CompileError != "" {
|
||||
t.Errorf("resp = %+v, want Compiles=true, no CompileError", resp)
|
||||
}
|
||||
if resp.Blocked || len(resp.CostWarnings) != 0 {
|
||||
t.Errorf("resp = %+v, want no cost warnings for a time-bounded query", resp)
|
||||
}
|
||||
if resp.Confidence != string(provider.ConfidenceHigh) {
|
||||
t.Errorf("Confidence = %q", resp.Confidence)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleTranslateUnboundedQueryIsBlocked(t *testing.T) {
|
||||
// The model produced a syntactically valid but unbounded aggregation
|
||||
// -- task 9's explicit requirement that translation results run
|
||||
// through the same cost guard AI-suggested fixes do.
|
||||
p := &fakeProvider{translateResult: provider.TranslateResult{
|
||||
Query: "severity=ERROR | stats count by service",
|
||||
Confidence: provider.ConfidenceHigh,
|
||||
}}
|
||||
h := newTestHandler(p)
|
||||
rec := doRequest(t, h, "POST", "/ai/translate", translateRequest{NLQuery: "errors by service"})
|
||||
var resp translateResponse
|
||||
_ = json.Unmarshal(rec.Body.Bytes(), &resp)
|
||||
if !resp.Compiles {
|
||||
t.Fatalf("resp = %+v, want Compiles=true", resp)
|
||||
}
|
||||
if !resp.Blocked || len(resp.CostWarnings) == 0 {
|
||||
t.Errorf("resp = %+v, want Blocked=true with cost warnings for an unbounded aggregation", resp)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleTranslateNonCompilingQueryIsHonestlyReported(t *testing.T) {
|
||||
// The model returned something that doesn't actually parse -- a
|
||||
// real, distinct outcome from low confidence (a confident model can
|
||||
// still produce invalid syntax); the handler must say so plainly,
|
||||
// not silently drop it or crash.
|
||||
p := &fakeProvider{translateResult: provider.TranslateResult{
|
||||
Query: "| stats count",
|
||||
Confidence: provider.ConfidenceHigh,
|
||||
}}
|
||||
h := newTestHandler(p)
|
||||
rec := doRequest(t, h, "POST", "/ai/translate", translateRequest{NLQuery: "something odd"})
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200 (a non-compiling suggestion is a reportable outcome, not an HTTP error), body = %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
var resp translateResponse
|
||||
_ = json.Unmarshal(rec.Body.Bytes(), &resp)
|
||||
if resp.Compiles || resp.CompileError == "" {
|
||||
t.Errorf("resp = %+v, want Compiles=false with a CompileError", resp)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleTranslateLowConfidenceCarriesReason(t *testing.T) {
|
||||
p := &fakeProvider{translateResult: provider.TranslateResult{
|
||||
Confidence: provider.ConfidenceLow,
|
||||
LowConfidenceReason: "not sure what 'weird stuff' refers to",
|
||||
}}
|
||||
h := newTestHandler(p)
|
||||
rec := doRequest(t, h, "POST", "/ai/translate", translateRequest{NLQuery: "show me weird stuff"})
|
||||
var resp translateResponse
|
||||
_ = json.Unmarshal(rec.Body.Bytes(), &resp)
|
||||
if resp.Query != "" {
|
||||
t.Errorf("Query = %q, want empty for a low-confidence non-answer", resp.Query)
|
||||
}
|
||||
if resp.Confidence != string(provider.ConfidenceLow) || resp.LowConfidenceReason == "" {
|
||||
t.Errorf("resp = %+v, want low confidence with a reason", resp)
|
||||
}
|
||||
if resp.Compiles {
|
||||
t.Errorf("resp = %+v, want Compiles=false when Query is empty", resp)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,256 @@
|
||||
// Phase 7 task 13: end-to-end wiring tests distinct from handler_test.go
|
||||
// and ai/provider/ollama's own tests. Those two files each cover one
|
||||
// layer in isolation -- handler_test.go's fakeProvider satisfies
|
||||
// provider.Provider directly, bypassing HTTP/JSON/prompt construction
|
||||
// entirely; ollama_test.go exercises ollama.Client's wire-format parsing
|
||||
// against a stub server, but never through aiapi.Handler's actual HTTP
|
||||
// routes. Neither proves the seam between them actually works: a real
|
||||
// *ollama.Client wired through *router.Router into a real *Handler,
|
||||
// driven by real HTTP requests against the registered routes, with real
|
||||
// planner.Compile/costguard.Assess in the loop.
|
||||
//
|
||||
// No live Ollama or model is needed or used -- mockOllamaServer stands
|
||||
// in for Ollama's real /api/chat wire contract (same technique used for
|
||||
// this phase's live browser verification, see /docs/phase-7-ai-design.md,
|
||||
// just returning a fixed canned response instead of one selected by
|
||||
// inspecting the prompt) with a deterministic canned JSON body, which is
|
||||
// exactly what makes this suite fast and safe to run in CI -- see the
|
||||
// "CI testability" section of the design doc for why testing against a
|
||||
// real model is deliberately kept out of this suite instead.
|
||||
package aiapi
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/sentry/sentry/api/ai/provider/ollama"
|
||||
"github.com/sentry/sentry/api/ai/router"
|
||||
)
|
||||
|
||||
// jsonBody marshals v for use as an http.Post body -- the integration
|
||||
// tests below drive real HTTP requests against a real httptest.Server
|
||||
// (not handler_test.go's doRequest/ResponseRecorder shortcut), since the
|
||||
// point of this file is proving the routes are actually reachable over
|
||||
// real HTTP, not just that Handler's methods dispatch correctly.
|
||||
func jsonBody(t *testing.T, v any) io.Reader {
|
||||
t.Helper()
|
||||
b, err := json.Marshal(v)
|
||||
if err != nil {
|
||||
t.Fatalf("marshaling request body: %v", err)
|
||||
}
|
||||
return bytes.NewReader(b)
|
||||
}
|
||||
|
||||
type fakeInteractionLogger struct {
|
||||
entries []InteractionEntry
|
||||
}
|
||||
|
||||
func (f *fakeInteractionLogger) LogInteraction(_ context.Context, entry InteractionEntry) error {
|
||||
f.entries = append(f.entries, entry)
|
||||
return nil
|
||||
}
|
||||
|
||||
// mockOllamaServer returns an httptest.Server that answers any
|
||||
// POST /api/chat with the given assistant message content, matching
|
||||
// Ollama's real response envelope shape byte-for-byte (see
|
||||
// ollama.go's chatResponse).
|
||||
func mockOllamaServer(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 request to %s, want /api/chat", r.URL.Path)
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||
"message": map[string]string{"role": "assistant", "content": content},
|
||||
})
|
||||
}))
|
||||
t.Cleanup(srv.Close)
|
||||
return srv
|
||||
}
|
||||
|
||||
func newIntegrationHandler(t *testing.T, ollamaContent string) *Handler {
|
||||
t.Helper()
|
||||
mock := mockOllamaServer(t, ollamaContent)
|
||||
client := ollama.New(mock.URL, "test-model")
|
||||
r := router.New(client)
|
||||
logger := slog.New(slog.NewTextHandler(bytesDiscard{}, nil))
|
||||
return NewHandler(logger, r, fakeSchemaSource{}, nil, nil)
|
||||
}
|
||||
|
||||
// TestIntegrationTranslateEndToEnd proves the full path -- HTTP request
|
||||
// in, real ollama.Client HTTP call out to the mock, real JSON parsing,
|
||||
// real planner.Compile, real costguard.Assess, HTTP response out --
|
||||
// works for a query that should pass cleanly (time-bounded, no
|
||||
// aggregation).
|
||||
func TestIntegrationTranslateEndToEnd(t *testing.T) {
|
||||
h := newIntegrationHandler(t, `{"query":"earliest=-1h severity=ERROR","confidence":"high"}`)
|
||||
mux := http.NewServeMux()
|
||||
h.RegisterRoutes(mux)
|
||||
srv := httptest.NewServer(mux)
|
||||
defer srv.Close()
|
||||
|
||||
resp, err := http.Post(srv.URL+"/ai/translate", "application/json",
|
||||
jsonBody(t, map[string]string{"nlQuery": "errors in the last hour"}))
|
||||
if err != nil {
|
||||
t.Fatalf("POST /ai/translate: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200", resp.StatusCode)
|
||||
}
|
||||
|
||||
var got translateResponse
|
||||
if err := json.NewDecoder(resp.Body).Decode(&got); err != nil {
|
||||
t.Fatalf("decoding response: %v", err)
|
||||
}
|
||||
if got.Query != "earliest=-1h severity=ERROR" {
|
||||
t.Errorf("query = %q", got.Query)
|
||||
}
|
||||
if !got.Compiles {
|
||||
t.Error("compiles = false, want true -- this query is valid pipe syntax")
|
||||
}
|
||||
if got.Blocked {
|
||||
t.Errorf("blocked = true, want false: %v", got.CostWarnings)
|
||||
}
|
||||
if got.Confidence != "high" {
|
||||
t.Errorf("confidence = %q, want high", got.Confidence)
|
||||
}
|
||||
}
|
||||
|
||||
// TestIntegrationTranslateBlockedByCostGuard proves costguard is
|
||||
// actually reached through the full HTTP stack, not just unit-tested
|
||||
// against costguard.Assess in isolation -- an unbounded aggregation
|
||||
// (stats, no time filter) must come back Blocked.
|
||||
func TestIntegrationTranslateBlockedByCostGuard(t *testing.T) {
|
||||
h := newIntegrationHandler(t, `{"query":"severity=ERROR | stats count by service","confidence":"high"}`)
|
||||
mux := http.NewServeMux()
|
||||
h.RegisterRoutes(mux)
|
||||
srv := httptest.NewServer(mux)
|
||||
defer srv.Close()
|
||||
|
||||
resp, err := http.Post(srv.URL+"/ai/translate", "application/json",
|
||||
jsonBody(t, map[string]string{"nlQuery": "error count by service"}))
|
||||
if err != nil {
|
||||
t.Fatalf("POST /ai/translate: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
var got translateResponse
|
||||
if err := json.NewDecoder(resp.Body).Decode(&got); err != nil {
|
||||
t.Fatalf("decoding response: %v", err)
|
||||
}
|
||||
if !got.Compiles {
|
||||
t.Fatalf("compiles = false, want true: %s", got.CompileError)
|
||||
}
|
||||
if !got.Blocked {
|
||||
t.Error("blocked = false, want true -- unbounded aggregation should be rejected by costguard")
|
||||
}
|
||||
if len(got.CostWarnings) == 0 {
|
||||
t.Error("costWarnings is empty, want at least one reason")
|
||||
}
|
||||
}
|
||||
|
||||
// TestIntegrationFixEndToEnd proves the same seam for /ai/fix.
|
||||
func TestIntegrationFixEndToEnd(t *testing.T) {
|
||||
h := newIntegrationHandler(t, `{"suggested_query":"earliest=-1h severity=ERROR","explanation":"added a time bound","confidence":"high"}`)
|
||||
mux := http.NewServeMux()
|
||||
h.RegisterRoutes(mux)
|
||||
srv := httptest.NewServer(mux)
|
||||
defer srv.Close()
|
||||
|
||||
resp, err := http.Post(srv.URL+"/ai/fix", "application/json", jsonBody(t, map[string]string{
|
||||
"query": "severity=ERROR",
|
||||
"language": "spl",
|
||||
"executionError": "query timed out: no time range specified",
|
||||
}))
|
||||
if err != nil {
|
||||
t.Fatalf("POST /ai/fix: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200", resp.StatusCode)
|
||||
}
|
||||
|
||||
var got fixResponse
|
||||
if err := json.NewDecoder(resp.Body).Decode(&got); err != nil {
|
||||
t.Fatalf("decoding response: %v", err)
|
||||
}
|
||||
if got.SuggestedQuery != "earliest=-1h severity=ERROR" {
|
||||
t.Errorf("suggestedQuery = %q", got.SuggestedQuery)
|
||||
}
|
||||
if got.Blocked {
|
||||
t.Errorf("blocked = true, want false: %v", got.CostWarnings)
|
||||
}
|
||||
}
|
||||
|
||||
// TestIntegrationCompleteEndToEnd proves the same seam for /ai/complete
|
||||
// (Track A's ghost-text autocomplete).
|
||||
func TestIntegrationCompleteEndToEnd(t *testing.T) {
|
||||
h := newIntegrationHandler(t, `{"suggestion":" severity=ERROR"}`)
|
||||
mux := http.NewServeMux()
|
||||
h.RegisterRoutes(mux)
|
||||
srv := httptest.NewServer(mux)
|
||||
defer srv.Close()
|
||||
|
||||
resp, err := http.Post(srv.URL+"/ai/complete", "application/json", jsonBody(t, map[string]string{
|
||||
"queryPrefix": "service=api ",
|
||||
"language": "spl",
|
||||
}))
|
||||
if err != nil {
|
||||
t.Fatalf("POST /ai/complete: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
var got completeResponse
|
||||
if err := json.NewDecoder(resp.Body).Decode(&got); err != nil {
|
||||
t.Fatalf("decoding response: %v", err)
|
||||
}
|
||||
if got.Suggestion != " severity=ERROR" {
|
||||
t.Errorf("suggestion = %q", got.Suggestion)
|
||||
}
|
||||
}
|
||||
|
||||
// TestIntegrationLogInteractionEndToEnd proves /ai/log-interaction's
|
||||
// full HTTP decode+validate+dispatch path, using a fake InteractionLogger
|
||||
// (an in-process Go fake is the right seam here, not another mock HTTP
|
||||
// server -- the real implementation is enterprise/internal/audit, which
|
||||
// needs a live Postgres and is covered by that package's own tests).
|
||||
func TestIntegrationLogInteractionEndToEnd(t *testing.T) {
|
||||
logger := &fakeInteractionLogger{}
|
||||
r := router.New(&fakeProvider{})
|
||||
h := NewHandler(slog.New(slog.NewTextHandler(bytesDiscard{}, nil)), r, fakeSchemaSource{}, nil, logger)
|
||||
mux := http.NewServeMux()
|
||||
h.RegisterRoutes(mux)
|
||||
srv := httptest.NewServer(mux)
|
||||
defer srv.Close()
|
||||
|
||||
resp, err := http.Post(srv.URL+"/ai/log-interaction", "application/json", jsonBody(t, map[string]any{
|
||||
"operation": "translate",
|
||||
"input": "errors in the last hour",
|
||||
"output": "earliest=-1h severity=ERROR",
|
||||
"confidence": "high",
|
||||
"accepted": true,
|
||||
"edited": false,
|
||||
"finalQuery": "earliest=-1h severity=ERROR",
|
||||
}))
|
||||
if err != nil {
|
||||
t.Fatalf("POST /ai/log-interaction: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusNoContent {
|
||||
t.Fatalf("status = %d, want 204", resp.StatusCode)
|
||||
}
|
||||
if len(logger.entries) != 1 {
|
||||
t.Fatalf("got %d logged entries, want 1", len(logger.entries))
|
||||
}
|
||||
if logger.entries[0].Operation != "translate" || !logger.entries[0].Accepted {
|
||||
t.Errorf("logged entry = %+v", logger.entries[0])
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user