Skip to content

Commit d3569d5

Browse files
committed
AI filtering is working pretty well ✨
1 parent b238cda commit d3569d5

File tree

6 files changed

+296
-52
lines changed

6 files changed

+296
-52
lines changed

apps/webapp/app/components/runs/v3/AIFilterInput.tsx

Lines changed: 24 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,8 @@ import { useProject } from "~/hooks/useProject";
1010
import { useSearchParams } from "~/hooks/useSearchParam";
1111
import { objectToSearchParams } from "~/utils/searchParams";
1212
import { type TaskRunListSearchFilters } from "./RunFilters";
13+
import { cn } from "~/utils/cn";
14+
import { motion } from "framer-motion";
1315

1416
type AIFilterResult =
1517
| {
@@ -25,6 +27,7 @@ type AIFilterResult =
2527

2628
export function AIFilterInput() {
2729
const [text, setText] = useState("");
30+
const [isFocused, setIsFocused] = useState(false);
2831
const navigate = useNavigate();
2932
const organization = useOrganization();
3033
const project = useProject();
@@ -65,7 +68,15 @@ export function AIFilterInput() {
6568
action={`/resources/orgs/${organization.slug}/projects/${project.slug}/env/${environment.slug}/runs/ai-filter`}
6669
method="post"
6770
>
68-
<div className="relative flex-1">
71+
<motion.div
72+
initial={{ width: "auto" }}
73+
animate={{ width: isFocused ? "24rem" : "auto" }}
74+
transition={{
75+
type: "spring",
76+
stiffness: 300,
77+
damping: 30,
78+
}}
79+
>
6980
<Input
7081
type="text"
7182
name="text"
@@ -74,7 +85,7 @@ export function AIFilterInput() {
7485
value={text}
7586
onChange={(e) => setText(e.target.value)}
7687
disabled={isLoading}
77-
className="pr-10"
88+
fullWidth
7889
onKeyDown={(e) => {
7990
if (e.key === "Enter" && text.trim() && !isLoading) {
8091
e.preventDefault();
@@ -84,19 +95,22 @@ export function AIFilterInput() {
8495
}
8596
}
8697
}}
98+
onFocus={() => setIsFocused(true)}
99+
onBlur={() => setIsFocused(false)}
87100
icon={<AISparkleIcon className="size-4" />}
88101
accessory={
89-
text.length > 0 ? (
90-
<ShortcutKey shortcut={{ key: "enter" }} variant="small" />
102+
isLoading ? (
103+
<Spinner color="muted" className="size-4" />
104+
) : text.length > 0 ? (
105+
<ShortcutKey
106+
shortcut={{ key: "enter" }}
107+
variant="small"
108+
className={cn("transition-opacity", text.length === 0 && "opacity-0")}
109+
/>
91110
) : undefined
92111
}
93112
/>
94-
{isLoading && (
95-
<div className="absolute right-3 top-1/2 -translate-y-1/2">
96-
<Spinner color="muted" />
97-
</div>
98-
)}
99-
</div>
113+
</motion.div>
100114
</fetcher.Form>
101115
);
102116
}

apps/webapp/app/components/runs/v3/RunFilters.tsx

Lines changed: 65 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -112,37 +112,74 @@ export const MachinePresetOrMachinePresetArray = z.preprocess((value) => {
112112
}, MachinePresetName.array().optional());
113113

114114
export const TaskRunListSearchFilters = z.object({
115-
cursor: z.string().optional(),
116-
direction: z.enum(["forward", "backward"]).optional(),
117-
environments: StringOrStringArray,
118-
tasks: StringOrStringArray,
119-
versions: StringOrStringArray,
120-
statuses: z.preprocess((value) => {
121-
if (typeof value === "string") {
122-
if (value.length > 0) {
123-
return [value];
124-
}
115+
cursor: z.string().optional().describe("Cursor for pagination - used internally for navigation"),
116+
direction: z
117+
.enum(["forward", "backward"])
118+
.optional()
119+
.describe("Pagination direction - forward or backward. Used internally for navigation"),
120+
environments: StringOrStringArray.describe(
121+
"Environment names to filter by (DEVELOPMENT, STAGING, PREVIEW, PRODUCTION)"
122+
),
123+
tasks: StringOrStringArray.describe(
124+
"Task identifiers to filter by (these are user-defined names)"
125+
),
126+
versions: StringOrStringArray.describe(
127+
"Version identifiers to filter by (these are in this format 20250718.1). Needs to be looked up."
128+
),
129+
statuses: z
130+
.preprocess((value) => {
131+
if (typeof value === "string") {
132+
if (value.length > 0) {
133+
return [value];
134+
}
125135

126-
return undefined;
127-
}
136+
return undefined;
137+
}
128138

129-
if (Array.isArray(value)) {
130-
return value.filter((v) => typeof v === "string" && v.length > 0);
131-
}
139+
if (Array.isArray(value)) {
140+
return value.filter((v) => typeof v === "string" && v.length > 0);
141+
}
132142

133-
return undefined;
134-
}, RunStatus.array().optional()),
135-
tags: StringOrStringArray,
136-
bulkId: z.string().optional(),
137-
period: z.preprocess((value) => (value === "all" ? undefined : value), z.string().optional()),
138-
from: z.coerce.number().optional(),
139-
to: z.coerce.number().optional(),
140-
rootOnly: z.coerce.boolean().optional(),
141-
batchId: z.string().optional(),
142-
runId: StringOrStringArray,
143-
scheduleId: z.string().optional(),
144-
queues: StringOrStringArray,
145-
machines: MachinePresetOrMachinePresetArray,
143+
return undefined;
144+
}, RunStatus.array().optional())
145+
.describe(`Run statuses to filter by (${filterableTaskRunStatuses.join(", ")})`),
146+
tags: StringOrStringArray.describe("Tag names to filter by (these are user-defined names)"),
147+
bulkId: z
148+
.string()
149+
.optional()
150+
.describe("Bulk action ID to filter by - shows runs from a specific bulk operation"),
151+
period: z
152+
.preprocess((value) => (value === "all" ? undefined : value), z.string().optional())
153+
.describe("Time period string (e.g., '1h', '7d', '30d', '1y') for relative time filtering"),
154+
from: z.coerce
155+
.number()
156+
.optional()
157+
.describe("Unix timestamp for start of time range - absolute time filtering"),
158+
to: z.coerce
159+
.number()
160+
.optional()
161+
.describe("Unix timestamp for end of time range - absolute time filtering"),
162+
rootOnly: z.coerce
163+
.boolean()
164+
.optional()
165+
.describe("Show only root runs (not child runs) - set to true to exclude sub-runs"),
166+
batchId: z
167+
.string()
168+
.optional()
169+
.describe(
170+
"Batch ID to filter by - shows runs from a specific batch operation. They start with batch_"
171+
),
172+
runId: StringOrStringArray.describe("Specific run IDs to filter by. They start with run_"),
173+
scheduleId: z
174+
.string()
175+
.optional()
176+
.describe(
177+
"Schedule ID to filter by - shows runs from a specific schedule. They start with sched_"
178+
),
179+
queues: StringOrStringArray.describe("Queue names to filter by (these are user-defined names)"),
180+
machines: MachinePresetOrMachinePresetArray.describe(
181+
`Machine presets to filter by (${machines.join(", ")})`
182+
),
146183
});
147184

148185
export type TaskRunListSearchFilters = z.infer<typeof TaskRunListSearchFilters>;

apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs.ai-filter.tsx

Lines changed: 9 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,10 @@ import { type ActionFunctionArgs, json } from "@remix-run/server-runtime";
22
import { z } from "zod";
33
import { requireUserId } from "~/services/session.server";
44
import { EnvironmentParamSchema } from "~/utils/pathBuilder";
5-
import { type TaskRunListSearchFilters } from "~/components/runs/v3/RunFilters";
65
import { findProjectBySlug } from "~/models/project.server";
76
import { findEnvironmentBySlug } from "~/models/runtimeEnvironment.server";
7+
import { processAIFilter } from "~/v3/services/aiRunFilterService.server";
8+
import { type TaskRunListSearchFilters } from "~/components/runs/v3/RunFilters";
89

910
const RequestSchema = z.object({
1011
text: z.string().min(1),
@@ -46,17 +47,11 @@ export async function action({ request, params }: ActionFunctionArgs) {
4647

4748
const { text } = submission.data;
4849

49-
// TODO: Replace this with actual AI processing
50-
// For now, return fake successful data
51-
const fakeFilters: TaskRunListSearchFilters = {
52-
statuses: ["COMPLETED_WITH_ERRORS", "COMPLETED_SUCCESSFULLY"],
53-
machines: ["small-2x"],
54-
period: "7d",
55-
};
56-
57-
return json<{ success: true; filters: TaskRunListSearchFilters; explanation: string }>({
58-
success: true,
59-
filters: fakeFilters,
60-
explanation: `Applied filters: failed status, last 7 days, with tag "test-tag"`,
61-
});
50+
const result = await processAIFilter(text, environment.id);
51+
52+
if (result.success) {
53+
return json(result);
54+
} else {
55+
return json(result, { status: 400 });
56+
}
6257
}
Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
import { openai } from "@ai-sdk/openai";
2+
import { generateObject } from "ai";
3+
import { z } from "zod";
4+
import { TaskRunListSearchFilters } from "~/components/runs/v3/RunFilters";
5+
import { env } from "~/env.server";
6+
import { logger } from "~/services/logger.server";
7+
8+
const AIFilterResponseSchema = z.object({
9+
filters: TaskRunListSearchFilters,
10+
explanation: z
11+
.string()
12+
.describe("A short human-readable explanation of what filters were applied"),
13+
});
14+
15+
export type AIFilterResult =
16+
| {
17+
success: true;
18+
filters: TaskRunListSearchFilters;
19+
explanation: string;
20+
}
21+
| {
22+
success: false;
23+
error: string;
24+
suggestions?: string[];
25+
};
26+
27+
export async function processAIFilter(
28+
text: string,
29+
environmentId: string
30+
): Promise<AIFilterResult> {
31+
if (!env.OPENAI_API_KEY) {
32+
return {
33+
success: false,
34+
error: "OpenAI API key is not configured",
35+
suggestions: ["Contact your administrator to configure AI features"],
36+
};
37+
}
38+
39+
try {
40+
const result = await generateObject({
41+
model: openai("gpt-4o"),
42+
schema: AIFilterResponseSchema,
43+
prompt: `You are an AI assistant that converts natural language descriptions into structured filter parameters for a task run filtering system.
44+
45+
Available filter options:
46+
- statuses: Array of run statuses (PENDING, EXECUTING, COMPLETED_SUCCESSFULLY, COMPLETED_WITH_ERRORS, CANCELED, TIMED_OUT, CRASHED, etc.)
47+
- period: Time period string (e.g., "1h", "7d", "30d", "1y")
48+
- from/to: Unix ms timestamps for specific time ranges. You'll need to use a converter if they give you a date. Today's date is ${new Date().toISOString()}, if they only specify a day use the current month. If they don't specify a year use the current year. If they don't specify a time of day use midnight to midnight.
49+
- tags: Array of tag names to filter by
50+
- tasks: Array of task identifiers to filter by
51+
- machines: Array of machine presets (micro, small, small-2x, medium, large, xlarge, etc.)
52+
- queues: Array of queue names to filter by
53+
- versions: Array of version identifiers to filter by
54+
- rootOnly: Boolean to show only root runs (not child runs)
55+
- runId: Array of specific run IDs to filter by
56+
- batchId: Specific batch ID to filter by
57+
- scheduleId: Specific schedule ID to filter by
58+
59+
Common patterns to recognize:
60+
- "failed runs" → statuses: ["COMPLETED_WITH_ERRORS", "CRASHED", "TIMED_OUT", "SYSTEM_FAILURE"].
61+
- If they say "only failed" then only use "COMPLETED_WITH_ERRORS".
62+
- "successful runs" → statuses: ["COMPLETED_SUCCESSFULLY"]
63+
- "running runs" → statuses: ["EXECUTING", "RETRYING_AFTER_FAILURE", "WAITING_TO_RESUME"]
64+
- "pending runs" → statuses: ["PENDING", "PENDING_VERSION", "DELAYED"]
65+
- "past 7 days" → period: "7d"
66+
- "last hour" → period: "1h"
67+
- "this month" → period: "30d"
68+
- "with tag X" → tags: ["X"]
69+
- "from task Y" → tasks: ["Y"]
70+
- "using large machine" → machines: ["large-1x", "large-2x"]
71+
- "root only" → rootOnly: true
72+
73+
Unless they specify they only want root runs, set rootOnly to false.
74+
75+
Convert the following natural language description into structured filters:
76+
77+
"${text}"
78+
79+
Return only the filters that are explicitly mentioned or can be reasonably inferred. If the description is unclear or doesn't match any known patterns, return an empty filters object and explain why in the explanation field.`,
80+
});
81+
82+
return {
83+
success: true,
84+
filters: result.object.filters,
85+
explanation: result.object.explanation,
86+
};
87+
} catch (error) {
88+
logger.error("AI filter processing failed", { error, text, environmentId });
89+
90+
return {
91+
success: false,
92+
error: "Failed to process AI filter request",
93+
suggestions: [
94+
"Try being more specific about what you want to filter",
95+
"Use common terms like 'failed runs', 'last 7 days', 'with tag X'",
96+
"Check that your description is clear and unambiguous",
97+
],
98+
};
99+
}
100+
}

apps/webapp/package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@
3131
"/public/build"
3232
],
3333
"dependencies": {
34+
"@ai-sdk/openai": "^1.3.23",
3435
"@ariakit/react": "^0.4.6",
3536
"@ariakit/react-core": "^0.4.6",
3637
"@aws-sdk/client-ecr": "^3.839.0",
@@ -120,6 +121,7 @@
120121
"@unkey/error": "^0.2.0",
121122
"@upstash/ratelimit": "^1.1.3",
122123
"@whatwg-node/fetch": "^0.9.14",
124+
"ai": "^4.3.19",
123125
"assert-never": "^1.2.1",
124126
"aws4fetch": "^1.0.18",
125127
"class-variance-authority": "^0.5.2",

0 commit comments

Comments
 (0)