Files
triggerdotdev--trigger.dev/apps/webapp/evals/aiQuery.eval.ts
T
Matt Aitken 49df40cb11 TRQL and the Query page (#2843)
TRQL (pronounced Treacle like the delicious British dark sweet syrup) is
the TRiggerQueryLanguage. It allows users to safely write queries on
their data. The queries are safely turned into ClickHouse queries which
are tenant-safe and not SQL injectable.


https://github.com/user-attachments/assets/bbfca473-b3fc-4150-8fe6-79e8840a2d29

This started out as a translation of HogQL by PostHog from Python to
TypeScript.

Features
- Tenant safe queries.
- Many underlying ClickHouse features including functions and
aggregations.
- Virtual columns, which are exposed to users as real columns but are
actually expressions.
- Transformations of data types and where clauses.
- Simple JSON path querying.
- Limits on execution time.
- Reporting of query statistics.

## Query page

There’s a new Query page (currently behind a feature flag) where you can
write TRQL queries and execute them against your environment, project or
organization.

Features
- Executing TRQL queries
- Syntax highlighting and errors
- Autocomplete
- AI generation/editing of queries
- Help and examples
- Table with auto-inferred data types from the table schema
- Table cell renderers for our special types like Run ids, environments,
machines, tasks, queues, etc.
- Copy/export as CSV/JSON
- Line and bar graphs with grouping and stacking
- History of queries
2026-01-09 11:39:36 +00:00

375 lines
9.0 KiB
TypeScript

import { evalite } from "evalite";
import { Levenshtein } from "autoevals";
import { AIQueryService } from "~/v3/services/aiQueryService.server";
import { runsSchema } from "~/v3/querySchemas";
import dotenv from "dotenv";
import { traceAISDKModel } from "evalite/ai-sdk";
import { openai } from "@ai-sdk/openai";
dotenv.config({ path: "../../.env" });
// Helper to normalize queries for comparison
function normalizeQuery(query: string): string {
return query
.replace(/\s+/g, " ")
.replace(/\(\s+/g, "(")
.replace(/\s+\)/g, ")")
.trim()
.toLowerCase();
}
// Type for parsed query results
interface ParsedQueryResult {
success: boolean;
query?: string;
error?: string;
}
// Custom scorer that checks if the generated query is semantically similar
// and also syntactically valid
const QuerySimilarity = {
name: "QuerySimilarity",
scorer: async ({
input,
output,
expected,
}: {
input: string;
output: string;
expected?: string;
}) => {
if (!expected) {
return 0;
}
// Parse the output to extract the query
const outputParsed = JSON.parse(output) as ParsedQueryResult;
const expectedParsed = JSON.parse(expected) as ParsedQueryResult;
// Check success status first
if (outputParsed.success !== expectedParsed.success) {
return 0;
}
// If both failed, check if error messages are similar
if (!outputParsed.success && !expectedParsed.success) {
// Give partial credit for correctly identifying an error case
return 0.5;
}
// If both succeeded, compare the queries
if (outputParsed.success && expectedParsed.success) {
const normalizedOutput = normalizeQuery(outputParsed.query ?? "");
const normalizedExpected = normalizeQuery(expectedParsed.query ?? "");
// Key patterns to check
const patterns = [
// Table name
/from\s+runs/i,
// Status filter patterns
/status\s*=\s*'[^']+'/i,
/status\s+in\s*\([^)]+\)/i,
// Time patterns
/interval\s+\d+\s+(day|hour|minute|week|month)/i,
/triggered_at\s*>/i,
// Aggregation patterns
/count\(\)/i,
/sum\(/i,
/avg\(/i,
/group\s+by/i,
// Ordering
/order\s+by/i,
// Limit
/limit\s+\d+/i,
];
let matchScore = 0;
let totalPatterns = 0;
for (const pattern of patterns) {
const outputMatch = pattern.test(normalizedOutput);
const expectedMatch = pattern.test(normalizedExpected);
if (expectedMatch) {
totalPatterns++;
if (outputMatch) {
matchScore++;
}
}
}
// Base score from pattern matching
const patternScore = totalPatterns > 0 ? matchScore / totalPatterns : 0.5;
// Use Levenshtein for overall similarity
const levenshteinResult = await Levenshtein({
output: normalizedOutput,
expected: normalizedExpected,
});
const levenshteinScore = levenshteinResult?.score ?? 0;
// Weighted combination
return 0.6 * patternScore + 0.4 * levenshteinScore;
}
return 0;
},
};
evalite("AI Query Generator", {
data: async () => {
return [
// Basic SELECT queries
{
input: "Show me all runs",
expected: JSON.stringify({
success: true,
query: `SELECT *
FROM runs
LIMIT 100`,
}),
},
{
input: "Get the 10 most recent runs",
expected: JSON.stringify({
success: true,
query: `SELECT *
FROM runs
ORDER BY triggered_at DESC
LIMIT 10`,
}),
},
// Status filtering
{
input: "Show failed runs",
expected: JSON.stringify({
success: true,
query: `SELECT *
FROM runs
WHERE status = 'Failed'
LIMIT 100`,
}),
},
{
input: "Get all completed runs",
expected: JSON.stringify({
success: true,
query: `SELECT *
FROM runs
WHERE status = 'Completed'
LIMIT 100`,
}),
},
{
input: "Find runs that crashed or timed out",
expected: JSON.stringify({
success: true,
query: `SELECT *
FROM runs
WHERE status IN ('Crashed', 'Timed out')
LIMIT 100`,
}),
},
// Time-based filtering
{
input: "Runs from the last 7 days",
expected: JSON.stringify({
success: true,
query: `SELECT *
FROM runs
WHERE triggered_at > now() - INTERVAL 7 DAY
LIMIT 100`,
}),
},
{
input: "Show runs from the past hour",
expected: JSON.stringify({
success: true,
query: `SELECT *
FROM runs
WHERE triggered_at > now() - INTERVAL 1 HOUR
LIMIT 100`,
}),
},
{
input: "Failed runs in the last 24 hours",
expected: JSON.stringify({
success: true,
query: `SELECT *
FROM runs
WHERE status = 'Failed'
AND triggered_at > now() - INTERVAL 1 DAY
ORDER BY triggered_at DESC
LIMIT 100`,
}),
},
// Aggregations
{
input: "Count of runs by status",
expected: JSON.stringify({
success: true,
query: `SELECT status, count() AS count
FROM runs
GROUP BY status
ORDER BY count DESC`,
}),
},
{
input: "How many runs per task?",
expected: JSON.stringify({
success: true,
query: `SELECT task_identifier, count() AS run_count
FROM runs
GROUP BY task_identifier
ORDER BY run_count DESC
LIMIT 100`,
}),
},
{
input: "Average execution duration by task",
expected: JSON.stringify({
success: true,
query: `SELECT task_identifier, avg(execution_duration) AS avg_duration
FROM runs
GROUP BY task_identifier
ORDER BY avg_duration DESC
LIMIT 100`,
}),
},
{
input: "Total cost by task in the last 30 days",
expected: JSON.stringify({
success: true,
query: `SELECT task_identifier, sum(total_cost) AS total_cost
FROM runs
WHERE triggered_at > now() - INTERVAL 30 DAY
GROUP BY task_identifier
ORDER BY total_cost DESC
LIMIT 100`,
}),
},
// Complex queries
{
input: "Top 10 most expensive failed runs from last week",
expected: JSON.stringify({
success: true,
query: `SELECT run_id, task_identifier, status, total_cost, triggered_at
FROM runs
WHERE status = 'Failed'
AND triggered_at > now() - INTERVAL 7 DAY
ORDER BY total_cost DESC
LIMIT 10`,
}),
},
{
input: "Runs using large machines that took more than 5 minutes",
expected: JSON.stringify({
success: true,
query: `SELECT *
FROM runs
WHERE machine IN ('large-1x', 'large-2x')
AND usage_duration > 300000
LIMIT 100`,
}),
},
{
input: "Show p95 execution duration by task for completed runs",
expected: JSON.stringify({
success: true,
query: `SELECT task_identifier, quantile(0.95)(execution_duration) AS p95_duration
FROM runs
WHERE status = 'Completed'
AND execution_duration IS NOT NULL
GROUP BY task_identifier
ORDER BY p95_duration DESC
LIMIT 100`,
}),
},
// Specific columns
{
input: "Just show run IDs and their statuses",
expected: JSON.stringify({
success: true,
query: `SELECT run_id, status
FROM runs
LIMIT 100`,
}),
},
{
input: "Get run_id, task, status and cost for recent runs",
expected: JSON.stringify({
success: true,
query: `SELECT run_id, task_identifier, status, total_cost
FROM runs
ORDER BY triggered_at DESC
LIMIT 100`,
}),
},
// Root runs
{
input: "Show only root runs (not child runs)",
expected: JSON.stringify({
success: true,
query: `SELECT *
FROM runs
WHERE is_root_run = 1
LIMIT 100`,
}),
},
// Queue filtering
{
input: "Runs in the shared queue",
expected: JSON.stringify({
success: true,
query: `SELECT *
FROM runs
WHERE queue LIKE '%shared%'
LIMIT 100`,
}),
},
// Tags
{
input: "Find runs with tag 'important'",
expected: JSON.stringify({
success: true,
query: `SELECT *
FROM runs
WHERE has(tags, 'important')
LIMIT 100`,
}),
},
// Error cases
{
input: "Do something",
expected: JSON.stringify({
success: false,
error: "Please be more specific about what data you want to query",
}),
},
{
input: "Show me the weather",
expected: JSON.stringify({
success: false,
error: "I can only generate queries for task run data",
}),
},
];
},
task: async (input) => {
const service = new AIQueryService([runsSchema], traceAISDKModel(openai("gpt-4o-mini")));
const result = await service.call(input);
return JSON.stringify(result);
},
scorers: [QuerySimilarity, Levenshtein],
});