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>
|
||||
|
||||
+119
-1
@@ -16,7 +16,13 @@ export const enterpriseAuthBase = import.meta.env.VITE_ENTERPRISE_AUTH_BASE_URL
|
||||
|
||||
export type Language = '' | 'sql' | 'spl';
|
||||
|
||||
export type QueryResult = { columns: string[]; rows: unknown[][] };
|
||||
// warnings (Phase 7) is populated by the shared costguard package's
|
||||
// assessment of every query, hand-written or AI-suggested alike --
|
||||
// informational only, never a reason a query didn't run. Optional/absent
|
||||
// (not an empty array) when there's nothing to say, matching the
|
||||
// backend's `omitempty` -- see api/queryapi/handler.go's doc comment on
|
||||
// why this is additive, not new enforcement.
|
||||
export type QueryResult = { columns: string[]; rows: unknown[][]; warnings?: string[] };
|
||||
|
||||
export type VizType = 'table' | 'line' | 'bar' | 'single_stat' | 'top_n' | 'heatmap';
|
||||
|
||||
@@ -83,6 +89,117 @@ export function runQuery(query: string, language: Language): Promise<QueryResult
|
||||
return request('/query', { method: 'POST', body: JSON.stringify({ query, language }) });
|
||||
}
|
||||
|
||||
// ---- AI-assisted query authoring (Phase 7 Track A) ----
|
||||
// Every function here returns text/suggestions only -- running anything
|
||||
// still goes through runQuery above, unchanged, per the phase's
|
||||
// non-negotiable "no parallel execution path" design principle. A
|
||||
// deployment with no OLLAMA_BASE_URL configured has these routes
|
||||
// entirely unregistered server-side (api/cmd/api's main.go), so a 404
|
||||
// here is a normal, expected "AI isn't enabled" response, not a bug --
|
||||
// callers (QueryBar.svelte) treat any error from these functions as
|
||||
// "AI unavailable right now," never a user-facing failure.
|
||||
|
||||
export type Confidence = 'high' | 'medium' | 'low';
|
||||
|
||||
export function aiComplete(queryPrefix: string, language: string): Promise<{ suggestion: string }> {
|
||||
return request('/ai/complete', { method: 'POST', body: JSON.stringify({ queryPrefix, language }) });
|
||||
}
|
||||
|
||||
export function aiExplain(
|
||||
query: string,
|
||||
language: string,
|
||||
originalIntent?: string
|
||||
): Promise<{ explanation: string }> {
|
||||
return request('/ai/explain', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ query, language, originalIntent: originalIntent ?? '' })
|
||||
});
|
||||
}
|
||||
|
||||
export type FixResponse = {
|
||||
suggestedQuery: string;
|
||||
explanation: string;
|
||||
confidence: Confidence | '';
|
||||
blocked: boolean;
|
||||
costWarnings?: string[];
|
||||
};
|
||||
|
||||
export function aiFix(
|
||||
query: string,
|
||||
language: string,
|
||||
opts: { parseError?: string; executionError?: string }
|
||||
): Promise<FixResponse> {
|
||||
return request('/ai/fix', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
query,
|
||||
language,
|
||||
parseError: opts.parseError ?? '',
|
||||
executionError: opts.executionError ?? ''
|
||||
})
|
||||
});
|
||||
}
|
||||
|
||||
export type OptimizeResponse = {
|
||||
findings: string[];
|
||||
phrased: string;
|
||||
suggestedQuery?: string;
|
||||
};
|
||||
|
||||
export function aiOptimize(query: string, language: string): Promise<OptimizeResponse> {
|
||||
return request('/ai/optimize', { method: 'POST', body: JSON.stringify({ query, language }) });
|
||||
}
|
||||
|
||||
// ---- Natural language translation (Phase 7 Track B) ----
|
||||
// Same non-negotiable split as every other AI operation: this returns a
|
||||
// query for review, never executes it. Running the result is the exact
|
||||
// same runQuery() above, reused unchanged -- task 9's explicit
|
||||
// requirement.
|
||||
export type TranslateResponse = {
|
||||
query: string;
|
||||
confidence: Confidence | '';
|
||||
lowConfidenceReason?: string;
|
||||
compiles: boolean;
|
||||
compileError?: string;
|
||||
blocked: boolean;
|
||||
costWarnings?: string[];
|
||||
};
|
||||
|
||||
export function aiTranslate(nlQuery: string): Promise<TranslateResponse> {
|
||||
return request('/ai/translate', { method: 'POST', body: JSON.stringify({ nlQuery }) });
|
||||
}
|
||||
|
||||
// ---- Interaction audit logging (task 12) ----
|
||||
// Fire-and-forget: a failure here (including AI being unconfigured
|
||||
// server-side, same 404-is-normal posture as every other /ai/* call)
|
||||
// must never block or surface an error for the accept/dismiss action
|
||||
// that triggered it, so callers should not await rejection handling
|
||||
// beyond a swallowed .catch(() => {}).
|
||||
export type InteractionOperation = 'translate' | 'fix' | 'optimize';
|
||||
|
||||
export function logInteraction(entry: {
|
||||
operation: InteractionOperation;
|
||||
input: string;
|
||||
output: string;
|
||||
confidence?: Confidence | '';
|
||||
accepted: boolean;
|
||||
edited: boolean;
|
||||
finalQuery?: string;
|
||||
}): Promise<void> {
|
||||
return request('/ai/log-interaction', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
operation: entry.operation,
|
||||
input: entry.input,
|
||||
output: entry.output,
|
||||
confidence: entry.confidence ?? '',
|
||||
accepted: entry.accepted,
|
||||
edited: entry.edited,
|
||||
finalQuery: entry.finalQuery ?? ''
|
||||
})
|
||||
});
|
||||
}
|
||||
|
||||
export function listDashboards(): Promise<Dashboard[]> {
|
||||
return request('/dashboards').then((d) => (d as Dashboard[]) ?? []);
|
||||
}
|
||||
@@ -355,3 +472,4 @@ export function createNotificationTarget(input: {
|
||||
}): Promise<NotificationTarget> {
|
||||
return alertingRequest('/targets', { method: 'POST', body: JSON.stringify(input) });
|
||||
}
|
||||
|
||||
|
||||
@@ -7,18 +7,161 @@
|
||||
// well-established dependency for exactly this job (the same
|
||||
// "reach for a real editor primitive" reasoning ECharts and GridStack
|
||||
// already followed elsewhere in Phase 5).
|
||||
import { EditorView, keymap, placeholder as placeholderExt } from '@codemirror/view';
|
||||
import { untrack } from 'svelte';
|
||||
import {
|
||||
EditorView,
|
||||
keymap,
|
||||
placeholder as placeholderExt,
|
||||
Decoration,
|
||||
WidgetType,
|
||||
type DecorationSet
|
||||
} from '@codemirror/view';
|
||||
import { StateField, StateEffect } from '@codemirror/state';
|
||||
import { autocompletion, closeBrackets, completionKeymap } from '@codemirror/autocomplete';
|
||||
import { defaultKeymap, history, historyKeymap } from '@codemirror/commands';
|
||||
import { pipeLanguage, pipeSyntaxHighlighting } from './language';
|
||||
import { pipeCompletions } from './completions';
|
||||
import { aiComplete } from '$lib/api';
|
||||
|
||||
let {
|
||||
value = $bindable(''),
|
||||
onRun,
|
||||
placeholder = '',
|
||||
ariaLabel = 'Query'
|
||||
}: { value?: string; onRun?: () => void; placeholder?: string; ariaLabel?: string } = $props();
|
||||
ariaLabel = 'Query',
|
||||
language = 'spl',
|
||||
aiCompleteEnabled = true
|
||||
}: {
|
||||
value?: string;
|
||||
onRun?: () => void;
|
||||
placeholder?: string;
|
||||
ariaLabel?: string;
|
||||
// language, not the bindable Language type QueryBar owns -- this
|
||||
// component only needs the string to pass through to /ai/complete,
|
||||
// never interprets it itself.
|
||||
language?: string;
|
||||
// Baseline autocomplete (pipeCompletions, above) is always on --
|
||||
// this only gates the AI ghost-text layer, so a caller embedding
|
||||
// QueryEditor somewhere AI assistance doesn't make sense (if any)
|
||||
// can opt out without losing deterministic completion. Task 5's
|
||||
// "AI assistance augments, never replaces" holds either way: the
|
||||
// two mechanisms are fully independent extensions, not one
|
||||
// swapped for the other.
|
||||
aiCompleteEnabled?: boolean;
|
||||
} = $props();
|
||||
|
||||
// ---- ghost-text AI completion (task 5) ----
|
||||
// A StateField + a Decoration.widget, not @codemirror/autocomplete's
|
||||
// dropdown machinery -- ghost text renders inline after the cursor
|
||||
// and accepts on Tab, a genuinely different interaction from a
|
||||
// completion list, so it gets its own small extension rather than
|
||||
// contorting autocompletion() into rendering it. Hand-built on
|
||||
// CodeMirror's own primitives (already a Phase 5 dependency) rather
|
||||
// than pulling in a new inline-completion package -- this codebase's
|
||||
// "boring, well-understood dependencies" convention, and the
|
||||
// primitives involved (StateField, Decoration.widget) are standard,
|
||||
// commonly-used CodeMirror 6 building blocks for exactly this pattern.
|
||||
const setGhost = StateEffect.define<string | null>();
|
||||
|
||||
class GhostTextWidget extends WidgetType {
|
||||
text: string;
|
||||
constructor(text: string) {
|
||||
super();
|
||||
this.text = text;
|
||||
}
|
||||
eq(other: GhostTextWidget) {
|
||||
return other.text === this.text;
|
||||
}
|
||||
toDOM() {
|
||||
const span = document.createElement('span');
|
||||
span.className = 'cm-ghost-text';
|
||||
span.textContent = this.text;
|
||||
span.setAttribute('aria-hidden', 'true');
|
||||
return span;
|
||||
}
|
||||
}
|
||||
|
||||
// The field stores a positioned DecorationSet directly, not a bare
|
||||
// string -- computing the widget's position (tr.state.doc.length,
|
||||
// the end of the document, since ghost text is only ever set when
|
||||
// the cursor is at the end -- see scheduleCompletion) has to happen
|
||||
// here, inside update(), where the *current* document length is
|
||||
// available. An earlier version of this field stored just the
|
||||
// suggestion string and hardcoded the widget at position 0 in a
|
||||
// separate `provide` callback that only receives the field's value,
|
||||
// not the document -- a real bug (ghost text rendered at the start
|
||||
// of the query, not after what was typed), caught by live-browser
|
||||
// verification, not by type-checking, since both are type-correct
|
||||
// CodeMirror usage.
|
||||
const ghostField = StateField.define<DecorationSet>({
|
||||
create: () => Decoration.none,
|
||||
update(deco, tr) {
|
||||
for (const effect of tr.effects) {
|
||||
if (effect.is(setGhost)) {
|
||||
if (effect.value === null) return Decoration.none;
|
||||
const pos = tr.state.doc.length;
|
||||
return Decoration.set([Decoration.widget({ widget: new GhostTextWidget(effect.value), side: 1 }).range(pos)]);
|
||||
}
|
||||
}
|
||||
// Any document change or selection move that isn't the ghost
|
||||
// effect itself invalidates a stale suggestion -- showing
|
||||
// ghost text for text that no longer reflects what's actually
|
||||
// typed would be actively misleading, worse than showing
|
||||
// nothing.
|
||||
if (tr.docChanged || tr.selection) return Decoration.none;
|
||||
return deco;
|
||||
},
|
||||
provide: (field) => EditorView.decorations.from(field)
|
||||
});
|
||||
|
||||
function currentGhostText(view: EditorView): string | null {
|
||||
let found: string | null = null;
|
||||
view.state.field(ghostField).between(0, view.state.doc.length, (_from, _to, deco) => {
|
||||
if (deco.spec.widget instanceof GhostTextWidget) found = deco.spec.widget.text;
|
||||
});
|
||||
return found;
|
||||
}
|
||||
|
||||
let completeGeneration = 0;
|
||||
let completeTimer: ReturnType<typeof setTimeout> | undefined;
|
||||
const completeDebounceMs = 300;
|
||||
|
||||
function scheduleCompletion(view: EditorView) {
|
||||
if (completeTimer) clearTimeout(completeTimer);
|
||||
const gen = ++completeGeneration;
|
||||
const sel = view.state.selection.main;
|
||||
const atEnd = sel.empty && sel.head === view.state.doc.length;
|
||||
const text = view.state.doc.toString();
|
||||
if (!atEnd || text.trim() === '') return;
|
||||
|
||||
completeTimer = setTimeout(async () => {
|
||||
let result: { suggestion: string };
|
||||
try {
|
||||
result = await aiComplete(text, language);
|
||||
} catch {
|
||||
return; // provider unavailable/slow/erroring -- silently no ghost text, never a user-facing error (task 5's graceful degradation)
|
||||
}
|
||||
// Stale response guard: the user kept typing (or the
|
||||
// component unmounted) while this request was in flight.
|
||||
if (gen !== completeGeneration || !view.hasFocus) return;
|
||||
const curSel = view.state.selection.main;
|
||||
const stillAtEnd =
|
||||
curSel.empty && curSel.head === view.state.doc.length && view.state.doc.toString() === text;
|
||||
if (!stillAtEnd || !result.suggestion) return;
|
||||
view.dispatch({ effects: setGhost.of(result.suggestion) });
|
||||
}, completeDebounceMs);
|
||||
}
|
||||
|
||||
function acceptGhost(view: EditorView): boolean {
|
||||
const ghost = currentGhostText(view);
|
||||
if (!ghost) return false;
|
||||
const end = view.state.doc.length;
|
||||
view.dispatch({
|
||||
changes: { from: end, to: end, insert: ghost },
|
||||
selection: { anchor: end + ghost.length },
|
||||
effects: setGhost.of(null)
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
let container: HTMLDivElement | undefined = $state();
|
||||
let view: EditorView | undefined;
|
||||
@@ -60,14 +203,38 @@
|
||||
backgroundColor: 'var(--color-accent)',
|
||||
color: 'var(--color-on-accent)'
|
||||
},
|
||||
'.cm-selectionBackground': { backgroundColor: 'color-mix(in srgb, var(--color-accent) 25%, transparent) !important' }
|
||||
'.cm-selectionBackground': { backgroundColor: 'color-mix(in srgb, var(--color-accent) 25%, transparent) !important' },
|
||||
'.cm-ghost-text': {
|
||||
color: 'var(--color-text-faint)',
|
||||
// Not user-selectable/editable -- it's a suggestion, not real
|
||||
// document content, and must never end up copy-pasted or
|
||||
// merged into a selection as if it were part of the query.
|
||||
userSelect: 'none',
|
||||
pointerEvents: 'none'
|
||||
}
|
||||
});
|
||||
|
||||
// Reactive dependency on `container` only -- deliberately not on
|
||||
// `value`, even though the view's initial doc needs it. Reading
|
||||
// `value` normally here would make this effect a dependent of it,
|
||||
// and the updateListener below writes `value` on every keystroke to
|
||||
// keep the bindable prop in sync -- if that write re-triggered this
|
||||
// effect, it would destroy and recreate the *entire* EditorView on
|
||||
// every keystroke. That's not just wasteful: a real, confirmed bug
|
||||
// found while building task 5's ghost-text completion --
|
||||
// `scheduleCompletion`'s debounce timer lives on `view` and gets
|
||||
// silently cancelled by this effect's own cleanup
|
||||
// (`clearTimeout(completeTimer)`) moments after being set, because
|
||||
// the effect re-runs right after the triggering keystroke. Wrapping
|
||||
// the initial `value` read in `untrack` breaks that dependency --
|
||||
// the effect now only re-runs when `container` itself changes
|
||||
// (mount), matching what it actually needs to do.
|
||||
$effect(() => {
|
||||
if (!container) return;
|
||||
lastEmitted = value;
|
||||
const initialValue = untrack(() => value);
|
||||
lastEmitted = initialValue;
|
||||
view = new EditorView({
|
||||
doc: value,
|
||||
doc: initialValue,
|
||||
parent: container,
|
||||
extensions: [
|
||||
pipeLanguage,
|
||||
@@ -78,7 +245,17 @@
|
||||
autocompletion({ override: [pipeCompletions] }),
|
||||
placeholderExt(placeholder),
|
||||
EditorView.contentAttributes.of({ 'aria-label': ariaLabel }),
|
||||
ghostField,
|
||||
keymap.of([
|
||||
// Tab accepts ghost text when one is showing -- checked
|
||||
// first, ahead of completionKeymap/defaultKeymap, so it
|
||||
// never competes with the deterministic dropdown's own
|
||||
// Tab/Enter handling for which one wins; the two
|
||||
// mechanisms are mutually exclusive at any given moment
|
||||
// in practice (a dropdown being open is itself a
|
||||
// docChanged-adjacent state ghost text's own
|
||||
// invalidation logic tends to have already cleared).
|
||||
{ key: 'Tab', run: acceptGhost },
|
||||
...completionKeymap,
|
||||
...defaultKeymap,
|
||||
...historyKeymap,
|
||||
@@ -95,11 +272,15 @@
|
||||
if (update.docChanged) {
|
||||
lastEmitted = update.state.doc.toString();
|
||||
value = lastEmitted;
|
||||
if (aiCompleteEnabled) scheduleCompletion(update.view);
|
||||
}
|
||||
})
|
||||
]
|
||||
});
|
||||
return () => view?.destroy();
|
||||
return () => {
|
||||
if (completeTimer) clearTimeout(completeTimer);
|
||||
view?.destroy();
|
||||
};
|
||||
});
|
||||
|
||||
// External changes to `value` (e.g. clicking a history entry) need to
|
||||
|
||||
@@ -23,6 +23,7 @@
|
||||
let columns = $state<string[]>([]);
|
||||
let rows = $state<unknown[][]>([]);
|
||||
let error = $state('');
|
||||
let warnings = $state<string[]>([]);
|
||||
let loading = $state(false);
|
||||
let hasRun = $state(false);
|
||||
let history = $state<HistoryEntry[]>(loadHistory());
|
||||
@@ -55,10 +56,12 @@
|
||||
async function runQuery() {
|
||||
loading = true;
|
||||
error = '';
|
||||
warnings = [];
|
||||
try {
|
||||
const result = await apiRunQuery(query, language);
|
||||
columns = result.columns ?? [];
|
||||
rows = result.rows ?? [];
|
||||
warnings = result.warnings ?? [];
|
||||
saveHistory({ query, language, at: Date.now() });
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : String(e);
|
||||
@@ -98,7 +101,7 @@
|
||||
sheet below. Build reusable queries into a <a href="/dashboards">dashboard</a>.
|
||||
</p>
|
||||
|
||||
<QueryBar bind:query bind:language onRun={runQuery} {loading} />
|
||||
<QueryBar bind:query bind:language onRun={runQuery} {loading} errorMessage={error} {warnings} />
|
||||
|
||||
{#if error}
|
||||
<p class="error">Error: {error}</p>
|
||||
|
||||
Reference in New Issue
Block a user