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:
2026-08-16 18:06:27 -07:00
parent 661568085e
commit 7d316f92db
37 changed files with 5230 additions and 20 deletions
+160 -5
View File
@@ -1,6 +1,7 @@
package main
import (
"bufio"
"bytes"
"encoding/json"
"fmt"
@@ -16,6 +17,13 @@ type queryArgs struct {
jsonOut bool
language string
query string
// nlQuery, execute (Phase 7 task 11): --nl routes through
// POST /ai/translate instead of running query text directly.
// execute is the same "explicit opt-in to actually run this"
// posture the web UI's confirm-to-run action enforces -- a
// translated query never runs itself, here or there.
nlQuery string
execute bool
}
// parseQueryArgs is pure (env passed in, no I/O), same testability
@@ -41,6 +49,13 @@ func parseQueryArgs(args []string, env func(string) string) queryArgs {
qa.language = args[i+1]
i++
}
case "--nl":
if i+1 < len(args) {
qa.nlQuery = args[i+1]
i++
}
case "--execute":
qa.execute = true
default:
rest = append(rest, args[i])
}
@@ -55,24 +70,161 @@ type queryRequestBody struct {
}
type queryResponseBody struct {
Columns []string `json:"columns"`
Rows [][]any `json:"rows"`
Columns []string `json:"columns"`
Rows [][]any `json:"rows"`
Warnings []string `json:"warnings"`
}
type translateRequestBody struct {
NLQuery string `json:"nlQuery"`
}
type translateResponseBody struct {
Query string `json:"query"`
Confidence string `json:"confidence"`
LowConfidenceReason string `json:"lowConfidenceReason"`
Compiles bool `json:"compiles"`
CompileError string `json:"compileError"`
Blocked bool `json:"blocked"`
CostWarnings []string `json:"costWarnings"`
}
func cmdQuery(args []string, stdout, stderr io.Writer) int {
qa := parseQueryArgs(args, os.Getenv)
if qa.nlQuery != "" {
return cmdQueryNL(qa, stdout, stderr, os.Stdin)
}
if strings.TrimSpace(qa.query) == "" {
fmt.Fprintln(stderr, "sentryctl query: missing query string")
return 1
}
return runAndPrintQuery(qa.apiURL, qa.query, qa.language, qa.jsonOut, stdout, stderr)
}
reqBody, err := json.Marshal(queryRequestBody{Query: qa.query, Language: qa.language})
// cmdQueryNL implements --nl: translate, show the result, then only run
// it with explicit opt-in (--execute, or an interactive "y" confirmation
// -- never a bare unattended run). stdin is a parameter (not read from
// os.Stdin directly) so the confirmation prompt is testable the same
// way parseQueryArgs's env injection is.
func cmdQueryNL(qa queryArgs, stdout, stderr io.Writer, stdin io.Reader) int {
reqBody, err := json.Marshal(translateRequestBody{NLQuery: qa.nlQuery})
if err != nil {
fmt.Fprintf(stderr, "encoding request: %v\n", err)
return 1
}
req, err := http.NewRequest(http.MethodPost, qa.apiURL+"/ai/translate", bytes.NewReader(reqBody))
if err != nil {
fmt.Fprintf(stderr, "building request: %v\n", err)
return 1
}
req.Header.Set("Content-Type", "application/json")
setAuth(req, resolveToken(os.Getenv))
client := &http.Client{Timeout: 30 * time.Second}
resp, err := client.Do(req)
if err != nil {
fmt.Fprintf(stderr, "translation failed: %v\n", err)
return 1
}
defer resp.Body.Close()
respBody, err := io.ReadAll(resp.Body)
if err != nil {
fmt.Fprintf(stderr, "reading response: %v\n", err)
return 1
}
if resp.StatusCode != http.StatusOK {
var errResp errorResponseBody
if json.Unmarshal(respBody, &errResp) == nil && errResp.Error != "" {
fmt.Fprintf(stderr, "translation failed: %s\n", errResp.Error)
} else {
fmt.Fprintf(stderr, "translation failed: api returned status %d\n", resp.StatusCode)
}
return 1
}
var t translateResponseBody
if err := json.Unmarshal(respBody, &t); err != nil {
fmt.Fprintf(stderr, "decoding response: %v\n", err)
return 1
}
if t.Query == "" {
reason := t.LowConfidenceReason
if reason == "" {
reason = "the model did not return a query"
}
fmt.Fprintf(stdout, "No confident translation available: %s\n", reason)
return 1
}
fmt.Fprintf(stdout, "Translated query (%s confidence):\n %s\n", t.Confidence, t.Query)
if !t.Compiles {
fmt.Fprintf(stdout, "This does not parse as a valid query: %s\n", t.CompileError)
fmt.Fprintln(stdout, "Not running it -- copy, fix, and run manually if you want to use it.")
return 1
}
if len(t.CostWarnings) > 0 {
fmt.Fprintf(stdout, "Cost guard: %s\n", strings.Join(t.CostWarnings, "; "))
}
if t.Blocked {
fmt.Fprintln(stdout, "Not offered as directly runnable -- copy and adjust manually if you want to use it.")
return 1
}
if !qa.execute {
if !isInteractive(stdin) {
fmt.Fprintln(stdout, "Not running (pass --execute to run automatically, or run this interactively to confirm).")
return 0
}
if !confirmRun(stdin, stdout, "Run this query?") {
fmt.Fprintln(stdout, "Not running.")
return 0
}
}
return runAndPrintQuery(qa.apiURL, t.Query, "spl", qa.jsonOut, stdout, stderr)
}
// confirmRun prompts stdin for a y/N answer -- only "y"/"yes"
// (case-insensitive) counts as confirmation, matching the web UI's
// posture that running an AI-generated query is opt-in, never a
// default a blank Enter press could accidentally trigger.
func confirmRun(stdin io.Reader, stdout io.Writer, prompt string) bool {
fmt.Fprintf(stdout, "%s [y/N] ", prompt)
line, _ := bufio.NewReader(stdin).ReadString('\n')
answer := strings.ToLower(strings.TrimSpace(line))
return answer == "y" || answer == "yes"
}
// isInteractive reports whether stdin looks like a real terminal --
// used to decide whether a confirmation prompt makes sense at all
// (a non-interactive/piped invocation with no --execute would otherwise
// hang forever waiting for an answer nobody can give; refusing to run
// and exiting cleanly is the safe default there, matching --execute's
// own opt-in-required posture rather than silently running).
func isInteractive(stdin io.Reader) bool {
f, ok := stdin.(*os.File)
if !ok {
return false
}
info, err := f.Stat()
if err != nil {
return false
}
return info.Mode()&os.ModeCharDevice != 0
}
func runAndPrintQuery(apiURL, query, language string, jsonOut bool, stdout, stderr io.Writer) int {
reqBody, err := json.Marshal(queryRequestBody{Query: query, Language: language})
if err != nil {
fmt.Fprintf(stderr, "encoding request: %v\n", err)
return 1
}
req, err := http.NewRequest(http.MethodPost, qa.apiURL+"/query", bytes.NewReader(reqBody))
req, err := http.NewRequest(http.MethodPost, apiURL+"/query", bytes.NewReader(reqBody))
if err != nil {
fmt.Fprintf(stderr, "building request: %v\n", err)
return 1
@@ -104,7 +256,7 @@ func cmdQuery(args []string, stdout, stderr io.Writer) int {
return 1
}
if qa.jsonOut {
if jsonOut {
_, _ = stdout.Write(respBody)
fmt.Fprintln(stdout)
return 0
@@ -116,5 +268,8 @@ func cmdQuery(args []string, stdout, stderr io.Writer) int {
return 1
}
printTable(stdout, result.Columns, result.Rows)
if len(result.Warnings) > 0 {
fmt.Fprintf(stdout, "\nWarning: %s\n", strings.Join(result.Warnings, "; "))
}
return 0
}
+145
View File
@@ -0,0 +1,145 @@
package main
import (
"bytes"
"net/http"
"net/http/httptest"
"strings"
"testing"
)
func TestParseQueryArgsNL(t *testing.T) {
qa := parseQueryArgs([]string{"--nl", "errors in the last hour", "--execute"}, func(string) string { return "" })
if qa.nlQuery != "errors in the last hour" {
t.Errorf("nlQuery = %q", qa.nlQuery)
}
if !qa.execute {
t.Error("execute = false, want true")
}
}
func TestCmdQueryNLLowConfidenceDoesNotRun(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/ai/translate" {
t.Errorf("unexpected request to %s, want only /ai/translate (never /query)", r.URL.Path)
}
w.Header().Set("Content-Type", "application/json")
w.Write([]byte(`{"query":"","confidence":"low","lowConfidenceReason":"not sure what that means"}`))
}))
defer srv.Close()
var stdout, stderr bytes.Buffer
code := cmdQuery([]string{"--nl", "show me weird stuff", "--execute", "--api", srv.URL}, &stdout, &stderr)
if code != 1 {
t.Errorf("code = %d, want 1 (no confident translation)", code)
}
if !strings.Contains(stdout.String(), "not sure what that means") {
t.Errorf("stdout = %q, want the low-confidence reason", stdout.String())
}
}
func TestCmdQueryNLBlockedIsNotRunEvenWithExecute(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/query" {
t.Error("a blocked translation must never reach /query, even with --execute")
}
w.Header().Set("Content-Type", "application/json")
w.Write([]byte(`{"query":"severity=ERROR | stats count by service","confidence":"high","compiles":true,"blocked":true,"costWarnings":["no time range filter, and this query aggregates"]}`))
}))
defer srv.Close()
var stdout, stderr bytes.Buffer
code := cmdQuery([]string{"--nl", "errors by service", "--execute", "--api", srv.URL}, &stdout, &stderr)
if code != 1 {
t.Errorf("code = %d, want 1 (blocked)", code)
}
if !strings.Contains(stdout.String(), "Not offered as directly runnable") {
t.Errorf("stdout = %q", stdout.String())
}
}
func TestCmdQueryNLNonCompilingDoesNotRun(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/query" {
t.Error("a non-compiling translation must never reach /query")
}
w.Header().Set("Content-Type", "application/json")
w.Write([]byte(`{"query":"| stats count","confidence":"high","compiles":false,"compileError":"expected a filter, comparison, or search term, got PIPE"}`))
}))
defer srv.Close()
var stdout, stderr bytes.Buffer
code := cmdQuery([]string{"--nl", "something odd", "--execute", "--api", srv.URL}, &stdout, &stderr)
if code != 1 {
t.Errorf("code = %d, want 1", code)
}
if !strings.Contains(stdout.String(), "does not parse") {
t.Errorf("stdout = %q", stdout.String())
}
}
func TestCmdQueryNLWithExecuteRunsTheQuery(t *testing.T) {
var sawQuery string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
switch r.URL.Path {
case "/ai/translate":
w.Write([]byte(`{"query":"earliest=-1h severity=ERROR | stats count by service","confidence":"high","compiles":true,"blocked":false}`))
case "/query":
sawQuery = "called"
w.Write([]byte(`{"columns":["service","count"],"rows":[["api",5]]}`))
default:
t.Errorf("unexpected request to %s", r.URL.Path)
}
}))
defer srv.Close()
var stdout, stderr bytes.Buffer
code := cmdQuery([]string{"--nl", "errors per service in the last hour", "--execute", "--api", srv.URL}, &stdout, &stderr)
if code != 0 {
t.Fatalf("code = %d, want 0; stderr=%s", code, stderr.String())
}
if sawQuery != "called" {
t.Error("expected /query to be called with --execute set")
}
if !strings.Contains(stdout.String(), "api") {
t.Errorf("stdout = %q, want the query results printed", stdout.String())
}
}
func TestCmdQueryNLWithoutExecuteNonInteractiveDoesNotRun(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/query" {
t.Error("must not run without --execute when stdin isn't a terminal")
}
w.Header().Set("Content-Type", "application/json")
w.Write([]byte(`{"query":"earliest=-1h | stats count","confidence":"high","compiles":true,"blocked":false}`))
}))
defer srv.Close()
var stdout, stderr bytes.Buffer
code := cmdQuery([]string{"--nl", "how many events", "--api", srv.URL}, &stdout, &stderr)
if code != 0 {
t.Errorf("code = %d, want 0 (declining to run isn't a failure)", code)
}
if !strings.Contains(stdout.String(), "Not running") {
t.Errorf("stdout = %q", stdout.String())
}
}
func TestConfirmRunAcceptsY(t *testing.T) {
var stdout bytes.Buffer
if !confirmRun(strings.NewReader("y\n"), &stdout, "Run?") {
t.Error("expected 'y' to confirm")
}
}
func TestConfirmRunRejectsBlankAndOther(t *testing.T) {
var stdout bytes.Buffer
if confirmRun(strings.NewReader("\n"), &stdout, "Run?") {
t.Error("expected a blank line to NOT confirm")
}
if confirmRun(strings.NewReader("sure\n"), &stdout, "Run?") {
t.Error("expected an unrecognized answer to NOT confirm")
}
}