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:
@@ -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
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user