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:
+467
-4
@@ -2,9 +2,23 @@
|
||||
// Extracted from the root query page in Phase 3 so the dashboard panel
|
||||
// editor and the alert rule editor can reuse the same input --
|
||||
// deliberately just the input+run affordance, not results/history,
|
||||
// which differ per consumer.
|
||||
// which differ per consumer. Phase 7 adds AI-assisted authoring
|
||||
// (explain/fix/optimize) here rather than as a separate mode/page, so
|
||||
// every consumer of this component gets it -- errorMessage/warnings
|
||||
// are optional props specifically so existing callers that don't pass
|
||||
// them see no behavior change at all.
|
||||
import type { Language } from '$lib/api';
|
||||
import { Button } from '$lib/components/ui';
|
||||
import {
|
||||
aiExplain,
|
||||
aiFix,
|
||||
aiOptimize,
|
||||
aiTranslate,
|
||||
logInteraction,
|
||||
type FixResponse,
|
||||
type OptimizeResponse,
|
||||
type TranslateResponse
|
||||
} from '$lib/api';
|
||||
import { Button, Modal } from '$lib/components/ui';
|
||||
import QueryEditor from '$lib/query-editor/QueryEditor.svelte';
|
||||
|
||||
let {
|
||||
@@ -12,13 +26,23 @@
|
||||
language = $bindable<Language>(''),
|
||||
onRun,
|
||||
loading = false,
|
||||
placeholder = 'service=api | where status>=500 | stats count by host | sort -count'
|
||||
placeholder = 'service=api | where status>=500 | stats count by host | sort -count',
|
||||
// Set by the caller after a failed run (parse or execution error)
|
||||
// -- presence alone drives the "Fix this" affordance below, this
|
||||
// component never calls runQuery itself to find out.
|
||||
errorMessage = '',
|
||||
// Set by the caller from a successful run's QueryResult.warnings
|
||||
// (costguard, Phase 7 task 4) -- presence alone drives the
|
||||
// "Optimize" affordance.
|
||||
warnings = []
|
||||
}: {
|
||||
query: string;
|
||||
language: Language;
|
||||
onRun: () => void;
|
||||
loading?: boolean;
|
||||
placeholder?: string;
|
||||
errorMessage?: string;
|
||||
warnings?: string[];
|
||||
} = $props();
|
||||
|
||||
// Client-side mirror of the backend's auto-detect heuristic
|
||||
@@ -30,10 +54,245 @@
|
||||
}
|
||||
let detected = $derived(detectedLanguage(query));
|
||||
let effectiveLanguage = $derived(language === '' ? detected : language);
|
||||
|
||||
// ---- "looks like natural language" detection (task 10) ----
|
||||
//
|
||||
// Deliberately NOT "wait for a parse error" -- the pipe grammar's
|
||||
// own free-text rule (bare words AND'd together, see
|
||||
// /docs/query-language-reference.md's "Free-text search" section)
|
||||
// means a plain-English question like "show me errors from the last
|
||||
// hour" *parses successfully* as a search for records containing
|
||||
// all of those words literally. It never fails to parse; it just
|
||||
// silently returns an unhelpful result. Waiting for a parse error
|
||||
// would miss the single most common real case this feature exists
|
||||
// for. Instead: a cheap client-side heuristic flags text that has
|
||||
// none of the pipe syntax's structural markers (`|`, a comparison
|
||||
// operator, `:`) and is long enough (4+ words) that it's very
|
||||
// unlikely to be an intentional short free-text search someone
|
||||
// actually wants run literally -- a single bare word or a quoted
|
||||
// phrase is common and legitimate, and stays untouched.
|
||||
function looksLikeNaturalLanguage(q: string): boolean {
|
||||
const trimmed = q.trim();
|
||||
if (trimmed === '' || /^\s*select\b/i.test(trimmed)) return false;
|
||||
if (/[|=<>:]/.test(trimmed)) return false;
|
||||
return trimmed.split(/\s+/).length >= 4;
|
||||
}
|
||||
let looksLikeNL = $derived(looksLikeNaturalLanguage(query));
|
||||
|
||||
// ---- interaction audit logging (task 12) ----
|
||||
// Fire-and-forget: an audit write failure here must never block or
|
||||
// surface an error on the button press that triggered it (same
|
||||
// posture as the AI operations themselves being optional). Scoped to
|
||||
// translate/fix/optimize only -- see logInteraction's/InteractionLogger's
|
||||
// doc comments for why complete and explain are excluded.
|
||||
function logFixOrOptimizeInteraction(
|
||||
operation: 'fix' | 'optimize',
|
||||
input: string,
|
||||
suggestedQuery: string,
|
||||
accepted: boolean
|
||||
) {
|
||||
logInteraction({
|
||||
operation,
|
||||
input,
|
||||
output: suggestedQuery,
|
||||
accepted,
|
||||
edited: false,
|
||||
finalQuery: suggestedQuery
|
||||
}).catch(() => {});
|
||||
}
|
||||
|
||||
// ---- explain ----
|
||||
let explainOpen = $state(false);
|
||||
let explainLoading = $state(false);
|
||||
let explainText = $state('');
|
||||
let explainError = $state('');
|
||||
|
||||
async function runExplain() {
|
||||
explainOpen = true;
|
||||
explainLoading = true;
|
||||
explainError = '';
|
||||
explainText = '';
|
||||
try {
|
||||
const res = await aiExplain(query, effectiveLanguage);
|
||||
explainText = res.explanation;
|
||||
} catch {
|
||||
// Any failure (including "AI not configured," a plain 404) is
|
||||
// shown as a quiet unavailable message, not a scary error --
|
||||
// this is an optional enhancement, not a core feature whose
|
||||
// failure should read as something broken.
|
||||
explainError = 'AI explanation is not available right now.';
|
||||
} finally {
|
||||
explainLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
// ---- fix ----
|
||||
let fixOpen = $state(false);
|
||||
let fixLoading = $state(false);
|
||||
let fixResult: FixResponse | null = $state(null);
|
||||
let fixError = $state('');
|
||||
|
||||
async function runFix() {
|
||||
fixOpen = true;
|
||||
fixLoading = true;
|
||||
fixError = '';
|
||||
fixResult = null;
|
||||
try {
|
||||
fixResult = await aiFix(query, effectiveLanguage, { executionError: errorMessage });
|
||||
} catch {
|
||||
fixError = 'AI fix suggestions are not available right now.';
|
||||
} finally {
|
||||
fixLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
function acceptFix() {
|
||||
if (!fixResult?.suggestedQuery) return;
|
||||
logFixOrOptimizeInteraction('fix', query, fixResult.suggestedQuery, true);
|
||||
query = fixResult.suggestedQuery;
|
||||
fixOpen = false;
|
||||
}
|
||||
|
||||
function dismissFix() {
|
||||
if (fixResult?.suggestedQuery) {
|
||||
logFixOrOptimizeInteraction('fix', query, fixResult.suggestedQuery, false);
|
||||
}
|
||||
fixOpen = false;
|
||||
}
|
||||
|
||||
// ---- optimize ----
|
||||
let optimizeOpen = $state(false);
|
||||
let optimizeLoading = $state(false);
|
||||
let optimizeResult: OptimizeResponse | null = $state(null);
|
||||
let optimizeError = $state('');
|
||||
|
||||
async function runOptimize() {
|
||||
optimizeOpen = true;
|
||||
optimizeLoading = true;
|
||||
optimizeError = '';
|
||||
optimizeResult = null;
|
||||
try {
|
||||
optimizeResult = await aiOptimize(query, effectiveLanguage);
|
||||
} catch {
|
||||
optimizeError = 'AI optimization suggestions are not available right now.';
|
||||
} finally {
|
||||
optimizeLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
function acceptOptimize() {
|
||||
if (!optimizeResult?.suggestedQuery) return;
|
||||
logFixOrOptimizeInteraction('optimize', query, optimizeResult.suggestedQuery, true);
|
||||
query = optimizeResult.suggestedQuery;
|
||||
optimizeOpen = false;
|
||||
}
|
||||
|
||||
function dismissOptimize() {
|
||||
if (optimizeResult?.suggestedQuery) {
|
||||
logFixOrOptimizeInteraction('optimize', query, optimizeResult.suggestedQuery, false);
|
||||
}
|
||||
optimizeOpen = false;
|
||||
}
|
||||
|
||||
// ---- translate (Track B) ----
|
||||
let translateOpen = $state(false);
|
||||
let translateLoading = $state(false);
|
||||
let translateNL = $state('');
|
||||
let translateResult: TranslateResponse | null = $state(null);
|
||||
let translateExplanation = $state('');
|
||||
let translateError = $state('');
|
||||
// Tracks edits made in the review textarea after a result arrives --
|
||||
// see editedQuery's own comment below for why this exists.
|
||||
let editedQuery = $state('');
|
||||
|
||||
function openTranslate() {
|
||||
// Pre-fill with the current query bar content -- that's exactly
|
||||
// what triggered the "looks like natural language" affordance in
|
||||
// the first place, so re-typing it would be pure friction.
|
||||
translateNL = query;
|
||||
translateOpen = true;
|
||||
runTranslate();
|
||||
}
|
||||
|
||||
async function runTranslate() {
|
||||
translateLoading = true;
|
||||
translateError = '';
|
||||
translateResult = null;
|
||||
translateExplanation = '';
|
||||
try {
|
||||
const res = await aiTranslate(translateNL);
|
||||
translateResult = res;
|
||||
editedQuery = res.query;
|
||||
if (res.query && res.compiles) {
|
||||
// Reuses Explain rather than building a separate
|
||||
// "describe the translation" mechanism -- task 10's
|
||||
// explicit instruction. OriginalIntent lets the prompt
|
||||
// speak to *how* the question became this query, not
|
||||
// just describe the query in isolation.
|
||||
try {
|
||||
const explainRes = await aiExplain(res.query, 'spl', translateNL);
|
||||
translateExplanation = explainRes.explanation;
|
||||
} catch {
|
||||
// Explanation is a nice-to-have on top of the
|
||||
// translation itself -- a failure here shouldn't
|
||||
// blank out an otherwise-successful translation.
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
translateError = 'AI translation is not available right now.';
|
||||
} finally {
|
||||
translateLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
function useTranslatedQuery() {
|
||||
if (!editedQuery.trim()) return;
|
||||
if (translateResult) {
|
||||
logInteraction({
|
||||
operation: 'translate',
|
||||
input: translateNL,
|
||||
output: translateResult.query,
|
||||
confidence: translateResult.confidence,
|
||||
accepted: true,
|
||||
edited: editedQuery !== translateResult.query,
|
||||
finalQuery: editedQuery
|
||||
}).catch(() => {});
|
||||
}
|
||||
query = editedQuery;
|
||||
translateOpen = false;
|
||||
}
|
||||
|
||||
function cancelTranslate() {
|
||||
if (translateResult?.query) {
|
||||
logInteraction({
|
||||
operation: 'translate',
|
||||
input: translateNL,
|
||||
output: translateResult.query,
|
||||
confidence: translateResult.confidence,
|
||||
accepted: false,
|
||||
edited: false,
|
||||
finalQuery: translateResult.query
|
||||
}).catch(() => {});
|
||||
}
|
||||
translateOpen = false;
|
||||
}
|
||||
|
||||
// A blocked suggestion the user has since edited is their own text
|
||||
// now, not the original flagged one -- costguard will assess
|
||||
// whatever they actually run anyway (every /query response carries
|
||||
// its own warnings, Track A task 4), so re-blocking an edit they
|
||||
// made specifically to address the concern would be actively
|
||||
// unhelpful, not extra-safe.
|
||||
let translateUseDisabled = $derived.by(() => {
|
||||
if (!editedQuery.trim()) return true;
|
||||
const result = translateResult;
|
||||
if (!result) return false;
|
||||
return result.blocked && editedQuery === result.query;
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="query-bar">
|
||||
<QueryEditor bind:value={query} {onRun} {placeholder} />
|
||||
<QueryEditor bind:value={query} {onRun} {placeholder} language={effectiveLanguage} />
|
||||
<div class="controls">
|
||||
<label>
|
||||
Language:
|
||||
@@ -49,10 +308,146 @@
|
||||
<Button variant="primary" onclick={onRun} disabled={loading || query.trim() === ''}>
|
||||
{loading ? 'Running…' : 'Run query'}
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" onclick={runExplain} disabled={query.trim() === ''}>
|
||||
Explain this query
|
||||
</Button>
|
||||
{#if errorMessage}
|
||||
<Button variant="ghost" size="sm" onclick={runFix}>Try AI fix</Button>
|
||||
{/if}
|
||||
{#if warnings.length > 0}
|
||||
<Button variant="ghost" size="sm" onclick={runOptimize}>Optimize</Button>
|
||||
{/if}
|
||||
{#if looksLikeNL}
|
||||
<Button variant="ghost" size="sm" onclick={openTranslate}>Interpret as natural language</Button>
|
||||
{/if}
|
||||
<span class="hint">⌘/Ctrl+Enter to run</span>
|
||||
</div>
|
||||
{#if warnings.length > 0}
|
||||
<p class="cost-warning">⚠ {warnings.join('; ')}</p>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<Modal bind:open={explainOpen} title="What this query does">
|
||||
{#if explainLoading}
|
||||
<p class="muted">Thinking…</p>
|
||||
{:else if explainError}
|
||||
<p class="muted">{explainError}</p>
|
||||
{:else}
|
||||
<p>{explainText}</p>
|
||||
{/if}
|
||||
</Modal>
|
||||
|
||||
<Modal bind:open={fixOpen} title="AI-suggested fix">
|
||||
{#if fixLoading}
|
||||
<p class="muted">Thinking…</p>
|
||||
{:else if fixError}
|
||||
<p class="muted">{fixError}</p>
|
||||
{:else if fixResult}
|
||||
{#if !fixResult.suggestedQuery}
|
||||
<p class="muted">
|
||||
{fixResult.explanation || "The AI couldn't determine a fix for this error."}
|
||||
</p>
|
||||
{:else}
|
||||
<div class="diff">
|
||||
<div class="diff-row removed">
|
||||
<span class="diff-label">Current</span>
|
||||
<code>{query}</code>
|
||||
</div>
|
||||
<div class="diff-row added">
|
||||
<span class="diff-label">Suggested</span>
|
||||
<code>{fixResult.suggestedQuery}</code>
|
||||
</div>
|
||||
</div>
|
||||
{#if fixResult.explanation}<p>{fixResult.explanation}</p>{/if}
|
||||
{#if fixResult.blocked}
|
||||
<p class="cost-warning">
|
||||
⚠ This suggestion isn't offered as directly runnable: {(fixResult.costWarnings ?? []).join('; ')}
|
||||
You can still copy it and adjust manually.
|
||||
</p>
|
||||
{/if}
|
||||
<div class="actions">
|
||||
<Button variant="secondary" onclick={dismissFix}>Dismiss</Button>
|
||||
<Button variant="primary" onclick={acceptFix} disabled={fixResult.blocked}>
|
||||
Accept
|
||||
</Button>
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
</Modal>
|
||||
|
||||
<Modal bind:open={optimizeOpen} title="Optimize this query">
|
||||
{#if optimizeLoading}
|
||||
<p class="muted">Thinking…</p>
|
||||
{:else if optimizeError}
|
||||
<p class="muted">{optimizeError}</p>
|
||||
{:else if optimizeResult}
|
||||
<p>{optimizeResult.phrased || optimizeResult.findings.join('; ')}</p>
|
||||
{#if optimizeResult.suggestedQuery}
|
||||
<div class="diff">
|
||||
<div class="diff-row removed">
|
||||
<span class="diff-label">Current</span>
|
||||
<code>{query}</code>
|
||||
</div>
|
||||
<div class="diff-row added">
|
||||
<span class="diff-label">Suggested</span>
|
||||
<code>{optimizeResult.suggestedQuery}</code>
|
||||
</div>
|
||||
</div>
|
||||
<div class="actions">
|
||||
<Button variant="secondary" onclick={dismissOptimize}>Dismiss</Button>
|
||||
<Button variant="primary" onclick={acceptOptimize}>Accept</Button>
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
</Modal>
|
||||
|
||||
<Modal bind:open={translateOpen} title="Ask in plain English">
|
||||
<label class="nl-label" for="nl-question">Your question</label>
|
||||
<textarea id="nl-question" class="nl-input" bind:value={translateNL} rows="2"></textarea>
|
||||
<div class="actions translate-actions">
|
||||
<Button variant="secondary" size="sm" onclick={runTranslate} disabled={translateLoading || !translateNL.trim()}>
|
||||
{translateLoading ? 'Translating…' : 'Translate again'}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{#if translateLoading && !translateResult}
|
||||
<p class="muted">Thinking…</p>
|
||||
{:else if translateError}
|
||||
<p class="muted">{translateError}</p>
|
||||
{:else if translateResult}
|
||||
{#if !translateResult.query}
|
||||
<!-- Honest low-confidence handling (task 10): no guess shown
|
||||
with false confidence, just the reason plainly stated. -->
|
||||
<p class="muted">
|
||||
{translateResult.lowConfidenceReason || "Not confident enough to guess -- try rephrasing."}
|
||||
</p>
|
||||
{:else}
|
||||
{#if translateResult.confidence === 'low'}
|
||||
<p class="cost-warning">
|
||||
⚠ Low confidence{translateResult.lowConfidenceReason ? `: ${translateResult.lowConfidenceReason}` : ''}. Review carefully before using.
|
||||
</p>
|
||||
{/if}
|
||||
<label class="nl-label" for="nl-generated">Generated query (editable)</label>
|
||||
<textarea id="nl-generated" class="nl-input mono" bind:value={editedQuery} rows="3"></textarea>
|
||||
{#if !translateResult.compiles}
|
||||
<p class="cost-warning">⚠ This doesn't parse as a valid query: {translateResult.compileError}. Edit it above before using.</p>
|
||||
{/if}
|
||||
{#if translateExplanation}<p>{translateExplanation}</p>{/if}
|
||||
{#if translateResult.blocked && editedQuery === translateResult.query}
|
||||
<p class="cost-warning">
|
||||
⚠ Not offered as directly runnable: {(translateResult.costWarnings ?? []).join('; ')} Edit the query above to address this, or copy it manually.
|
||||
</p>
|
||||
{/if}
|
||||
<div class="actions">
|
||||
<Button variant="secondary" onclick={cancelTranslate}>Cancel</Button>
|
||||
<Button variant="primary" onclick={useTranslatedQuery} disabled={translateUseDisabled}>
|
||||
Use this query
|
||||
</Button>
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
</Modal>
|
||||
|
||||
<style>
|
||||
.controls {
|
||||
margin-top: var(--space-3);
|
||||
@@ -84,4 +479,72 @@
|
||||
font-size: var(--text-sm);
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
.cost-warning {
|
||||
margin-top: var(--space-2);
|
||||
font-size: var(--text-sm);
|
||||
color: var(--color-sev-warn);
|
||||
}
|
||||
.muted {
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
.diff {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-2);
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--text-sm);
|
||||
margin-bottom: var(--space-3);
|
||||
}
|
||||
.diff-row {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-1);
|
||||
padding: var(--space-2);
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
.diff-row code {
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
.diff-row.removed {
|
||||
background: var(--color-sev-error-bg);
|
||||
}
|
||||
.diff-row.added {
|
||||
background: var(--color-sev-info-bg);
|
||||
}
|
||||
.diff-label {
|
||||
font-family: var(--font-ui);
|
||||
font-size: var(--text-xs);
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
.actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
.nl-label {
|
||||
display: block;
|
||||
font-size: var(--text-xs);
|
||||
color: var(--color-text-muted);
|
||||
margin-bottom: var(--space-1);
|
||||
}
|
||||
.nl-input {
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
background: var(--color-surface);
|
||||
color: var(--color-text);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-sm);
|
||||
padding: var(--space-2);
|
||||
font-family: var(--font-ui);
|
||||
font-size: var(--text-sm);
|
||||
resize: vertical;
|
||||
}
|
||||
.nl-input.mono {
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
.translate-actions {
|
||||
justify-content: flex-start;
|
||||
margin: var(--space-2) 0 var(--space-3);
|
||||
}
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user