Files
openobserve/web/src/composables/useNLQuery.ts
T
ktx-vaidehiandgithub-actions[bot] c53a16b262 fix: script i18n (#13840)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-08-18 10:34:04 +00:00

719 lines
25 KiB
TypeScript

// Copyright 2026 OpenObserve Inc.
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
import { ref } from "vue";
import type { TranslateFn } from "@/types/i18n";
import useAiChat from "@/composables/useAiChat";
import useSuggestions from "@/composables/useSuggestions";
import { parsePromQlQuery } from "@/utils/query/promQLUtils";
import { UNAUTHORIZED_MESSAGE_KEY, isAuthError } from "@/utils/authErrors";
/**
* Composable for Natural Language to SQL Query transformation
* Provides auto-detection and generation capabilities without UI
*/
export function useNLQuery(t: TranslateFn) {
const { fetchAiChat, getStructuredContext } = useAiChat();
const isGenerating = ref(false);
const streamingResponse = ref(""); // Real-time streaming response for user engagement
/**
* Extracts function names from autocomplete suggestions dynamically
* Reuses existing suggestion data to avoid code duplication
*
* This function parses the useSuggestions composable's defaultSuggestions
* to extract all quick mode function names automatically
*/
const getQuickModeFunctionNames = (): string[] => {
const { defaultSuggestions } = useSuggestions();
const functionNames = new Set<string>();
// Entries carry a bare `name` ("approx_topk"). Labels used to embed a call
// shape ("match_all('x')") and were parsed with this regex; they are now
// static bare names, so `name` is the primary source and the regex only
// covers any legacy caller still supplying a callable/decorated label.
const functionPattern = /^([a-z_][a-z0-9_]*)\(/i;
defaultSuggestions.forEach((suggestion: any) => {
if (typeof suggestion?.name === "string" && suggestion.name) {
functionNames.add(suggestion.name.toLowerCase());
return;
}
const labelText =
typeof suggestion?.label === "function" ? suggestion.label("") : suggestion?.label;
if (typeof labelText !== "string") return;
const match = labelText.match(functionPattern);
if (match?.[1]) functionNames.add(match[1].toLowerCase());
});
return Array.from(functionNames);
};
/**
* Detects if the given text is natural language (not SQL/PromQL/VRL/JS)
*
* @param text - The text to analyze
* @param language - Current editor language (sql, promql, vrl, javascript)
* @param depth - Internal parameter to prevent unbounded recursion (max 10 levels)
* @returns true if text is natural language, false if it's valid query syntax
*
* Detection logic:
* - Returns false for empty/whitespace text
* - Returns false if matches language-specific syntax patterns
* - Returns true otherwise (natural language)
*/
const detectNaturalLanguage = (
text: string,
language: string = "sql",
depth: number = 0,
): boolean => {
// Prevent stack overflow from deeply nested expressions like ((((expr))))
const MAX_RECURSION_DEPTH = 10;
if (depth >= MAX_RECURSION_DEPTH) {
console.warn(
"[NL2Q] Maximum recursion depth reached in detectNaturalLanguage, treating as natural language",
);
return true; // Assume natural language if too deeply nested
}
const trimmed = text.trim();
// Empty text is not natural language
if (!trimmed) return false;
const lang = language.toLowerCase();
// PromQL detection
if (lang === "promql") {
// Strategy 1: Try parsing as PromQL using the parser
// If the parser can extract a metric name or labels, it's valid PromQL
try {
const parsed = parsePromQlQuery(trimmed);
if (parsed.metricName || parsed.label.hasLabels) {
// Successfully parsed as PromQL with metric name or labels
return false;
}
} catch (e) {
// Parser failed, continue with pattern matching
}
// Strategy 2: Pattern-based detection for queries parser might not fully handle
// Check for PromQL aggregation operators at start
const promqlAggregations =
/^(sum|min|max|avg|group|stddev|stdvar|count|count_values|bottomk|topk|quantile)\s*\(/i;
if (promqlAggregations.test(trimmed)) {
return false;
}
// Check for PromQL metric selectors with curly braces: metric_name{label="value"}
const metricSelector = /[a-zA-Z_:][a-zA-Z0-9_:]*\s*\{[^}]*\}/;
if (metricSelector.test(trimmed)) {
return false;
}
// Check for PromQL range selectors: [5m], [1h], etc.
const rangeSelector = /\[[0-9]+[smhdwy]\]/;
if (rangeSelector.test(trimmed)) {
return false;
}
// Check for PromQL functions
const promqlFunctions =
/\b(rate|increase|delta|idelta|irate|avg_over_time|min_over_time|max_over_time|sum_over_time|count_over_time|quantile_over_time|stddev_over_time|stdvar_over_time|histogram_quantile|label_join|label_replace|abs|ceil|floor|round|sqrt|exp|ln|log2|log10|clamp|clamp_max|clamp_min|sort|sort_desc|time|timestamp|vector|scalar|changes|deriv|predict_linear|holt_winters|resets)\s*\(/i;
if (promqlFunctions.test(trimmed)) {
return false;
}
// Check for PromQL aggregation modifiers (by, without)
const aggregationModifiers = /\b(by|without)\s*\(/i;
if (aggregationModifiers.test(trimmed)) {
return false;
}
// Check for simple metric names (without selectors) - common PromQL pattern
// Examples: cpu_usage, http_requests_total
const simpleMetric = /^[a-zA-Z_:][a-zA-Z0-9_:]*$/;
if (simpleMetric.test(trimmed)) {
return false;
}
// Check for parentheses wrapping expressions (common in PromQL)
// Examples: (metric_name), (avg(metric))
const wrappedExpression = /^\(.+\)$/;
if (wrappedExpression.test(trimmed)) {
// Check if content inside looks like PromQL
const innerContent = trimmed.slice(1, -1).trim();
// Recursively check inner content with depth tracking
if (innerContent) {
const isInnerNL = detectNaturalLanguage(innerContent, lang, depth + 1);
if (!isInnerNL) {
return false; // Inner content is PromQL, so outer is also PromQL
}
}
}
}
// VRL detection
if (lang === "vrl") {
const vrlPatterns = /(\.|=\s|,\s*\{|\}|->|\.parse_|\.to_)/;
if (vrlPatterns.test(trimmed)) {
return false;
}
}
// JavaScript detection
if (lang === "javascript" || lang === "js") {
const jsPatterns = /^(function|const|let|var|class|if|for|while|return|=>|\{|\})/i;
if (jsPatterns.test(trimmed)) {
return false;
}
}
// SQL detection (applies to all languages as fallback)
const sqlKeywords = [
"SELECT",
"FROM",
"WHERE",
"INSERT",
"UPDATE",
"DELETE",
"CREATE",
"ALTER",
"DROP",
"JOIN",
"INNER",
"LEFT",
"RIGHT",
"OUTER",
"UNION",
"GROUP",
"ORDER",
"HAVING",
"LIMIT",
];
const firstWord = trimmed.split(/\s+/)[0].toUpperCase();
if (sqlKeywords.includes(firstWord)) {
return false;
}
// If starts with SQL comment, it's SQL (not NL)
if (trimmed.startsWith("--")) {
return false;
}
// Quick mode detection: Check for quick mode query patterns
const quickModeFunctions = getQuickModeFunctionNames();
const lowerText = trimmed.toLowerCase();
for (const func of quickModeFunctions) {
if (lowerText.includes(`${func}(`)) {
return false;
}
}
// Check for field comparison operators (=, !=, >, <, >=, <=, <>)
const comparisonOperators = /[a-zA-Z_][a-zA-Z0-9_]*\s*(=|!=|>|<|>=|<=|<>)\s*.+/;
if (comparisonOperators.test(trimmed)) {
return false;
}
// Check for boolean operators (and, or, not) which are common in quick mode
const hasBooleanOps = /\b(and|or|not)\b/i.test(trimmed);
if (hasBooleanOps && comparisonOperators.test(trimmed)) {
return false;
}
// Check for SQL operators like LIKE, IN, BETWEEN (case-insensitive)
const sqlOperators = /\b(like|in|not in|between|is null|is not null)\b/i;
if (sqlOperators.test(trimmed)) {
return false;
}
// Otherwise, it's natural language
return true;
};
/**
* Extracts clean SQL query from AI response that may contain explanatory text
*
* @param response - Raw AI response text
* @returns Extracted SQL query, or null if no SQL found
*
* Handles:
* - Markdown code blocks: ```sql ... ```
* - Inline SQL statements (SELECT, WITH, etc.)
* - Filters out explanatory text before/after SQL
*/
/**
* Helper function to normalize VRL comments from // to #
* @param vrlCode - VRL code that might contain // comments
* @returns VRL code with // comments converted to #
*/
const normalizeVrlComments = (vrlCode: string): string => {
// Replace single-line // comments with # comments
// This regex matches // at the start or after whitespace, but not in strings
return vrlCode
.split("\n")
.map((line) => {
// Simple heuristic: if line contains //, check if it's a comment
// Skip if // appears inside quotes (very basic check)
const quoteCount = (line.match(/"/g) || []).length;
const hasEvenQuotes = quoteCount % 2 === 0;
// Only replace // with # if:
// 1. Line starts with optional whitespace then //
// 2. Or has even quotes (meaning // is likely not in a string)
if (hasEvenQuotes && line.includes("//")) {
// Replace // comments (with optional leading whitespace)
return line.replace(/^(\s*)\/\/\s?/, "$1# ").replace(/(\s+)\/\/\s?/, "$1# ");
}
return line;
})
.join("\n");
};
const extractSQLFromResponse = (response: string): string | null => {
// Strategy 1: Extract from markdown code blocks (SQL, PromQL, VRL, JavaScript)
// Try language-specific code blocks first
const codeBlockPatterns = [
{ pattern: /```sql\s+([\s\S]+?)```/i, type: "sql" },
{ pattern: /```promql\s+([\s\S]+?)```/i, type: "promql" },
{ pattern: /```vrl\s+([\s\S]+?)```/i, type: "vrl" },
{ pattern: /```javascript\s+([\s\S]+?)```/i, type: "javascript" },
{ pattern: /```js\s+([\s\S]+?)```/i, type: "javascript" },
];
for (const { pattern, type } of codeBlockPatterns) {
const match = response.match(pattern);
if (match && match[1]) {
let extracted = match[1].trim();
// Normalize VRL comments from // to #
if (type === "vrl") {
extracted = normalizeVrlComments(extracted);
}
return extracted;
}
}
// Strategy 2: Extract from generic code block (no language specified)
// This handles cases like ```\navg(metric_name)\n```
const genericCodeBlockMatch = response.match(/```\s*\n?([\s\S]+?)\n?```/);
if (genericCodeBlockMatch && genericCodeBlockMatch[1]) {
const extracted = genericCodeBlockMatch[1].trim();
return extracted;
}
// Strategy 3: Find SQL statement by keywords (SELECT, WITH, etc.)
// NOTE: This strategy is SQL-specific and won't work for PromQL/VRL/JavaScript
// It's a fallback for cases where the AI doesn't use code blocks
// For non-SQL languages, we rely on Strategies 1 & 2 (code blocks)
const sqlKeywords = ["SELECT", "WITH", "INSERT", "UPDATE", "DELETE", "CREATE"];
const lines = response.split("\n");
let sqlStart = -1;
let sqlEnd = -1;
// Find start of SQL statement
for (let i = 0; i < lines.length; i++) {
const trimmedLine = lines[i].trim().toUpperCase();
if (sqlKeywords.some((keyword) => trimmedLine.startsWith(keyword))) {
sqlStart = i;
break;
}
}
if (sqlStart === -1) {
return null; // No SQL found
}
// Find end of SQL statement (empty line or text that doesn't look like SQL)
sqlEnd = sqlStart;
for (let i = sqlStart + 1; i < lines.length; i++) {
const line = lines[i].trim();
// Empty line might indicate end
if (!line) {
sqlEnd = i - 1;
break;
}
// Line that looks like explanatory text (starts with lowercase or markdown)
if (/^[a-z*-]/.test(line)) {
sqlEnd = i - 1;
break;
}
sqlEnd = i;
}
// Extract SQL lines
const sqlLines = lines.slice(sqlStart, sqlEnd + 1);
const extractedSQL = sqlLines.join("\n").trim();
return extractedSQL || null;
};
/**
* Generates SQL query from natural language prompt using AI assistant
*
* @param prompt - Natural language prompt describing the query
* @param orgId - Organization identifier
* @returns Generated SQL query string, or null if generation fails
*
* Example:
* ```typescript
* const sql = await generateSQL('show errors from last hour', 'default');
* // Returns: "SELECT * FROM logs WHERE level = 'error' AND _timestamp > now() - INTERVAL '1 hour'"
* ```
*/
const generateSQL = async (
prompt: string,
orgId: string,
abortSignal?: AbortSignal,
sessionId?: string,
): Promise<string | null> => {
if (!prompt.trim()) {
console.warn("[NL2Q] Empty prompt provided");
return null;
}
isGenerating.value = true;
streamingResponse.value = ""; // Reset streaming response
try {
// Get page context (stream, time range, filters, etc.)
// The logsContextProvider returns data in the correct format already
const pageContext = await getStructuredContext();
// Build context for AI assistant matching backend expectations
// pageContext already has: currentPage, organization_identifier, selectedStreams, sqlMode, etc.
const context = {
agent_type: "query_assistant", // Use query assistant agent
currentPage: pageContext?.currentPage || "logs",
organization_identifier: pageContext?.organization_identifier || orgId,
selectedStreams: pageContext?.selectedStreams || ["default"],
sqlMode: pageContext?.sqlMode || false,
streamType: pageContext?.streamType || "logs",
timeRange: pageContext?.timeRange || {
type: "relative",
relativeTimePeriod: "15m",
},
quickMode: pageContext?.quickMode || false,
interestingFields: pageContext?.interestingFields || {},
currentSQLQuery: pageContext?.currentSQLQuery || "",
currentVRLQuery: pageContext?.currentVRLQuery || "",
};
// Prepare messages for AI
const messages = [
{
role: "user",
content: prompt, // Send the prompt as-is, let the AI assistant handle it with context
},
];
// Call AI assistant with streaming
const response = await fetchAiChat(
messages,
"", // Use default model from server config
orgId,
abortSignal, // Abort signal for request cancellation
context, // Explicit context with agent_type
sessionId, // Session ID for tracking across requests
);
if (!response || (response as any).cancelled) {
return null;
}
if (!(response as Response).ok) {
console.error("[NL2Q] AI assistant returned error:", (response as Response).status);
if ((response as Response).status === 403) {
streamingResponse.value = t(UNAUTHORIZED_MESSAGE_KEY);
}
return null;
}
// Read streaming response
const reader = (response as Response).body?.getReader();
if (!reader) {
console.error("[NL2Q] No reader available from response");
return null;
}
const decoder = new TextDecoder();
let generatedQuery = "";
const toolCalls: Array<{ tool: string; message: string; success?: boolean }> = [];
const toolResults: Array<{ tool: string; success: boolean; message: string }> = [];
let hasError = false;
let errorMessage = "";
let lastMessageContent = ""; // Track latest message events for dashboard URLs
try {
while (true) {
const { done, value } = await reader.read();
if (done) {
break;
}
const chunk = decoder.decode(value, { stream: true });
// Parse SSE format: data: {...}\n\n
const lines = chunk.split("\n");
for (const line of lines) {
if (line.startsWith("data: ")) {
try {
const data = JSON.parse(line.substring(6));
// Handle different event types
if (data.type === "status") {
// Status event (processing, etc.)
streamingResponse.value = data.message || t("nlMode.processing");
} else if (data.type === "message") {
// Message event (AI planning/explanation text)
lastMessageContent = data.content || "";
streamingResponse.value =
data.content?.substring(0, 100) || t("nlMode.processing");
} else if (data.type === "tool_call") {
// Track tool execution (dashboard/alert creation)
toolCalls.push({
tool: data.tool || "unknown",
message: data.message || "",
success: undefined, // Will be updated by tool_result
});
streamingResponse.value =
data.message || t("nlMode.executingTool", { tool: data.tool });
} else if (data.type === "tool_result") {
// Tool execution result
toolResults.push({
tool: data.tool || "unknown",
success: data.success || false,
message: data.message || "",
});
// Update corresponding tool call with success status
const lastToolCall = toolCalls[toolCalls.length - 1];
if (lastToolCall) {
lastToolCall.success = data.success;
}
if (!data.success) {
streamingResponse.value = t("nlMode.toolFailed", { message: data.message });
}
} else if (data.type === "error") {
// Error event
console.error("[NL2Q] Error event:", data.error || data.message);
hasError = true;
const rawErr = data.error || data.message || t("onlineEvals.unknownError");
errorMessage = isAuthError(rawErr, data.error_type)
? t(UNAUTHORIZED_MESSAGE_KEY)
: rawErr;
streamingResponse.value = errorMessage;
} else if (data.type === "complete") {
// Completion event - may contain full response in history
// Extract final response from history if available
if (data.history && Array.isArray(data.history)) {
const lastMessage = data.history[data.history.length - 1];
if (lastMessage && lastMessage.role === "assistant" && lastMessage.content) {
// If we haven't accumulated content yet, use the history
if (!generatedQuery.trim()) {
generatedQuery = lastMessage.content;
}
// Also update lastMessageContent for URL extraction
lastMessageContent = lastMessage.content;
}
}
} else if (data.type === "title") {
// Title event (ignore for now)
} else if (data.content) {
// Content event (regular text)
generatedQuery += data.content;
streamingResponse.value = data.content;
}
} catch (e) {
console.warn("[NL2Q] Failed to parse chunk JSON:", e);
}
}
}
}
const rawResponse = generatedQuery.trim();
// Check for error first
if (hasError) {
console.error("[NL2Q] Response contains error:", errorMessage);
return null;
}
if (!rawResponse && toolCalls.length === 0) {
console.warn("[NL2Q] AI assistant returned empty response");
return null;
}
// PRIORITY 1: Try to extract SQL from AI response first
const extractedSQL = extractSQLFromResponse(rawResponse);
// If SQL was successfully extracted, return it (this is the primary goal)
if (extractedSQL) {
return extractedSQL;
}
// PRIORITY 2: Check if this is a non-SQL action (dashboard/alert creation)
if (toolCalls.length > 0) {
// Use lastMessageContent if available (has formatted response with URLs)
const responseToReturn = lastMessageContent || rawResponse;
// Check for successful dashboard creation (case-insensitive)
const dashboardTool = toolCalls.find((tc) => {
const toolLower = tc.tool.toLowerCase();
return (
(toolLower === "createdashboard" ||
toolLower === "create_dashboard" ||
toolLower.includes("dashboard")) &&
tc.success !== false // Only if not explicitly failed
);
});
if (dashboardTool) {
return "✓ DASHBOARD_CREATED: " + responseToReturn;
}
// Check for successful alert creation (case-insensitive)
const alertTool = toolCalls.find((tc) => {
const toolLower = tc.tool.toLowerCase();
return (
(toolLower === "createalert" ||
toolLower === "create_alert" ||
toolLower.includes("alert")) &&
tc.success !== false
);
});
if (alertTool) {
return "✓ ALERT_CREATED: " + responseToReturn;
}
// Check if any tool succeeded
const hasSuccessfulTool = toolCalls.some((tc) => tc.success === true);
if (hasSuccessfulTool) {
return "✓ ACTION_COMPLETED: " + responseToReturn;
}
// All tools failed
console.warn("[NL2Q] All tool executions failed");
return null;
}
// Only if SQL extraction failed, check if AI is asking questions
const questionPatterns = [
/what is your organization/i,
/I need to check/i,
/I need the organization/i,
/which stream/i,
/what stream/i,
/could you provide/i,
/can you specify/i,
];
const isQuestion = questionPatterns.some((pattern) => pattern.test(rawResponse));
if (isQuestion) {
console.error(
"[NL2Q] AI returned questions instead of query. Context may be incomplete:",
rawResponse,
);
return null;
}
// No SQL, no tool calls, no questions - ambiguous response
console.warn("[NL2Q] Could not determine response type. Full response:", rawResponse);
return null;
} finally {
// Always release the reader to prevent memory leaks
reader.releaseLock();
}
} catch (error) {
console.error("[NL2Q] Error generating SQL:", error);
return null;
} finally {
isGenerating.value = false;
}
};
/**
* Transforms editor content by converting natural language to language-specific comments
* and appending the generated query
*
* @param originalNL - Original natural language text from editor
* @param generatedQuery - Generated query from AI
* @param language - Query language (sql, promql, vrl, javascript)
* @returns Transformed text with NL as comments and query below
*
* Example:
* ```typescript
* const transformed = transformToSQL(
* 'show errors from last hour',
* 'SELECT * FROM logs WHERE level = \'error\'',
* 'sql'
* );
* // Returns:
* // -- show errors from last hour
* // SELECT * FROM logs WHERE level = 'error'
* ```
*/
const transformToSQL = (
originalNL: string,
generatedQuery: string,
language: string = "sql",
): string => {
// Determine comment prefix based on language
let commentPrefix = "--"; // Default: SQL
const lang = language.toLowerCase();
if (lang === "promql" || lang === "vrl") {
commentPrefix = "#"; // PromQL and VRL use # for comments
} else if (lang === "javascript" || lang === "js") {
commentPrefix = "//"; // JavaScript uses // for comments
}
// Convert each line of natural language to language-specific comment
const commentedNL = originalNL
.split("\n")
.map((line) => `${commentPrefix} ${line}`)
.join("\n");
// Return commented NL + blank line + generated query
return `${commentedNL}\n${generatedQuery}`;
};
return {
detectNaturalLanguage,
generateSQL,
transformToSQL,
isGenerating,
streamingResponse, // Expose streaming response for real-time display
};
}