diff --git a/studio/frontend/src/features/data-recipes/data/recipes-db.ts b/studio/frontend/src/features/data-recipes/data/recipes-db.ts index bcc1c13d35..1fc9bb17c1 100644 --- a/studio/frontend/src/features/data-recipes/data/recipes-db.ts +++ b/studio/frontend/src/features/data-recipes/data/recipes-db.ts @@ -1,6 +1,6 @@ -import Dexie, { type EntityTable, liveQuery } from "dexie"; import { createEmptyRecipePayload } from "@/features/recipe-studio"; import { normalizeNonEmptyName } from "@/utils"; +import Dexie, { type EntityTable, liveQuery } from "dexie"; import { useEffect, useState } from "react"; import type { RecipeRecord, SaveRecipeInput } from "../types"; @@ -12,15 +12,17 @@ db.version(1).stores({ recipes: "id, name, updatedAt, createdAt", }); -export async function listRecipes(): Promise { +export function listRecipes(): Promise { return db.recipes.orderBy("updatedAt").reverse().toArray(); } -export async function getRecipe(id: string): Promise { +export function getRecipe(id: string): Promise { return db.recipes.get(id); } -export async function saveRecipe(input: SaveRecipeInput): Promise { +export async function saveRecipe( + input: SaveRecipeInput, +): Promise { const now = Date.now(); const id = input.id ?? crypto.randomUUID(); const existing = input.id ? await db.recipes.get(input.id) : undefined; @@ -30,6 +32,9 @@ export async function saveRecipe(input: SaveRecipeInput): Promise payload: input.payload, createdAt: existing?.createdAt ?? now, updatedAt: now, + learningRecipeId: input.learningRecipeId ?? existing?.learningRecipeId, + learningRecipeTitle: + input.learningRecipeTitle ?? existing?.learningRecipeTitle, }; await db.recipes.put(record); return record; @@ -39,13 +44,26 @@ export async function deleteRecipe(id: string): Promise { await db.recipes.delete(id); } -export async function createRecipeDraft(): Promise { +export function createRecipeDraft(): Promise { return saveRecipe({ name: "Unnamed", payload: createEmptyRecipePayload(), }); } +export function createRecipeFromLearningRecipe(input: { + templateId: string; + templateTitle: string; + payload: RecipeRecord["payload"]; +}): Promise { + return saveRecipe({ + name: input.templateTitle, + payload: input.payload, + learningRecipeId: input.templateId, + learningRecipeTitle: input.templateTitle, + }); +} + export function useRecipes(): RecipeRecord[] { const [recipes, setRecipes] = useState([]); diff --git a/studio/frontend/src/features/data-recipes/learning-recipes/index.ts b/studio/frontend/src/features/data-recipes/learning-recipes/index.ts index db827d5d57..a6fb28c385 100644 --- a/studio/frontend/src/features/data-recipes/learning-recipes/index.ts +++ b/studio/frontend/src/features/data-recipes/learning-recipes/index.ts @@ -1,16 +1,99 @@ +import type { RecipePayload } from "@/features/recipe-studio"; + +const structuredOutputsJinjaUrl = new URL( + "./structured-outputs-jinja.json", + import.meta.url, +).href; +const pdfGroundedQaUrl = new URL("./pdf-grounded-qa.json", import.meta.url) + .href; + +function isRecord(value: unknown): value is Record { + return !!value && typeof value === "object" && !Array.isArray(value); +} + +function toRecordArray(value: unknown): Record[] { + if (!Array.isArray(value)) { + return []; + } + return value.filter((item): item is Record => + isRecord(item), + ); +} + +function coerceRecipePayload(value: unknown): RecipePayload { + if (!isRecord(value)) { + throw new Error("Template payload is invalid JSON object."); + } + + const recipeSource = isRecord(value.recipe) ? value.recipe : value; + if (!Array.isArray(recipeSource.columns)) { + throw new Error("Template payload must include recipe.columns."); + } + + if (isRecord(value.recipe) && isRecord(value.run) && isRecord(value.ui)) { + return value as unknown as RecipePayload; + } + + const recipe: RecipePayload["recipe"] = { + // biome-ignore lint/style/useNamingConvention: api schema + model_providers: toRecordArray(recipeSource.model_providers), + // biome-ignore lint/style/useNamingConvention: api schema + mcp_providers: toRecordArray(recipeSource.mcp_providers), + // biome-ignore lint/style/useNamingConvention: api schema + model_configs: toRecordArray(recipeSource.model_configs), + // biome-ignore lint/style/useNamingConvention: api schema + seed_config: isRecord(recipeSource.seed_config) + ? recipeSource.seed_config + : undefined, + // biome-ignore lint/style/useNamingConvention: api schema + tool_configs: toRecordArray(recipeSource.tool_configs), + columns: toRecordArray(recipeSource.columns), + processors: toRecordArray(recipeSource.processors), + }; + + return { + recipe, + run: { + rows: 5, + preview: true, + // biome-ignore lint/style/useNamingConvention: api schema + output_formats: ["jsonl"], + }, + ui: { + nodes: [], + edges: [], + }, + }; +} + +async function loadPayloadFromUrl(url: string): Promise { + const response = await fetch(url); + if (!response.ok) { + throw new Error(`Failed to fetch template payload (${response.status})`); + } + const json = (await response.json()) as unknown; + return coerceRecipePayload(json); +} + export type LearningRecipeDef = { id: string; title: string; description: string; - filePath: string; + loadPayload: () => Promise; }; export const LEARNING_RECIPES: LearningRecipeDef[] = [ { id: "structured-outputs-jinja", title: "Structured Outputs + Jinja Expressions", - description: "Minimal schema + Jinja refs + if/else patterns.", - filePath: - "/src/features/data-recipes/learning-recipes/structured-outputs-jinja.json", + description: + "Support ticket triage with structured JSON outputs and Jinja conditionals.", + loadPayload: () => loadPayloadFromUrl(structuredOutputsJinjaUrl), + }, + { + id: "pdf-grounded-qa", + title: "PDF Document QA", + description: "Build grounded question-answer examples from PDF chunks.", + loadPayload: () => loadPayloadFromUrl(pdfGroundedQaUrl), }, ]; diff --git a/studio/frontend/src/features/data-recipes/pages/data-recipes-page.tsx b/studio/frontend/src/features/data-recipes/pages/data-recipes-page.tsx index 8bf44cc2ee..b1b96eff53 100644 --- a/studio/frontend/src/features/data-recipes/pages/data-recipes-page.tsx +++ b/studio/frontend/src/features/data-recipes/pages/data-recipes-page.tsx @@ -1,4 +1,18 @@ +import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; import { Empty, EmptyContent, @@ -8,16 +22,18 @@ import { EmptyTitle, } from "@/components/ui/empty"; import { ShineBorder } from "@/components/ui/shine-border"; +import { toastError } from "@/shared/toast"; import { AiChat02Icon, + ArrowDown01Icon, CodeIcon, CookBookIcon, Database02Icon, Delete02Icon, + DocumentAttachmentIcon, FunctionIcon, Plant01Icon, PlusSignIcon, - Shield02Icon, } from "@hugeicons/core-free-icons"; import { HugeiconsIcon } from "@hugeicons/react"; import { useNavigate } from "@tanstack/react-router"; @@ -25,16 +41,20 @@ import type { ReactElement } from "react"; import { useState } from "react"; import { createRecipeDraft, + createRecipeFromLearningRecipe, deleteRecipe, useRecipes, } from "../data/recipes-db"; +import { LEARNING_RECIPES } from "../learning-recipes"; type TemplateCard = { title: string; description: string; icon: typeof CookBookIcon; + difficulty: "Easy" | "Starter" | "Intermediate" | "Advanced"; surfaceClassName: string; shineColor: string[]; + learningRecipeId?: string; }; const TEMPLATE_CARDS: TemplateCard[] = [ @@ -43,24 +63,29 @@ const TEMPLATE_CARDS: TemplateCard[] = [ description: "Support ticket triage dataset with structured JSON outputs and Jinja if/else refs.", icon: FunctionIcon, + difficulty: "Advanced", surfaceClassName: "from-cyan-500/15 via-sky-500/5 to-transparent border-cyan-500/30", shineColor: ["#06b6d4", "#38bdf8", "#22d3ee"], + learningRecipeId: "structured-outputs-jinja", }, { - title: "Basic MCP Tool Use", + title: "PDF Document QA", description: - "Agent workflow starter showing tool-call patterns and grounded tool result usage.", - icon: Shield02Icon, + "Unstructured PDF chunks transformed into grounded question-answer training pairs.", + icon: DocumentAttachmentIcon, + difficulty: "Easy", surfaceClassName: "from-violet-500/15 via-fuchsia-500/5 to-transparent border-violet-500/30", shineColor: ["#8b5cf6", "#d946ef", "#a855f7"], + learningRecipeId: "pdf-grounded-qa", }, { title: "Seed Dataset", description: "Start from real rows, then expand with synthetic fields while preserving source context.", icon: Plant01Icon, + difficulty: "Starter", surfaceClassName: "from-emerald-500/15 via-green-500/5 to-transparent border-emerald-500/30", shineColor: ["#10b981", "#22c55e", "#34d399"], @@ -70,6 +95,7 @@ const TEMPLATE_CARDS: TemplateCard[] = [ description: "Instruction-to-code pairs for training models that generate clean Python implementations.", icon: CodeIcon, + difficulty: "Starter", surfaceClassName: "from-amber-500/15 via-orange-500/5 to-transparent border-amber-500/30", shineColor: ["#f59e0b", "#f97316", "#fb923c"], @@ -79,6 +105,7 @@ const TEMPLATE_CARDS: TemplateCard[] = [ description: "Natural language to SQL pairs, including schema-aware query construction patterns.", icon: Database02Icon, + difficulty: "Intermediate", surfaceClassName: "from-blue-500/15 via-indigo-500/5 to-transparent border-blue-500/30", shineColor: ["#3b82f6", "#6366f1", "#60a5fa"], @@ -88,12 +115,17 @@ const TEMPLATE_CARDS: TemplateCard[] = [ description: "Role-based multi-turn conversations for assistant behavior, memory, and response quality.", icon: AiChat02Icon, + difficulty: "Advanced", surfaceClassName: "from-rose-500/15 via-pink-500/5 to-transparent border-rose-500/30", shineColor: ["#f43f5e", "#ec4899", "#fb7185"], }, ]; +const LEARNING_RECIPE_BY_ID = new Map( + LEARNING_RECIPES.map((recipe) => [recipe.id, recipe]), +); + function formatRelativeTime(value: number): string { const now = Date.now(); const diffMs = Math.max(0, now - value); @@ -121,13 +153,87 @@ function formatRelativeTime(value: number): string { return `${weeks} week${weeks === 1 ? "" : "s"} ago`; } +function LearningRecipeCards({ + onSelect, + loadingTemplateId, +}: { + onSelect: (template: TemplateCard) => void; + loadingTemplateId: string | null; +}): ReactElement { + return ( +
+ {TEMPLATE_CARDS.map((template) => { + const learningRecipe = template.learningRecipeId + ? LEARNING_RECIPE_BY_ID.get(template.learningRecipeId) + : undefined; + const isReady = Boolean(learningRecipe); + const isLoading = + template.learningRecipeId !== undefined && + loadingTemplateId === template.learningRecipeId; + const isDisabled = !isReady || isLoading || Boolean(loadingTemplateId); + return ( + + ); + })} +
+ ); +} + export function DataRecipesPage(): ReactElement { const navigate = useNavigate(); const recipes = useRecipes(); const [creatingRecipe, setCreatingRecipe] = useState(false); + const [learningDialogOpen, setLearningDialogOpen] = useState(false); + const [loadingTemplateId, setLoadingTemplateId] = useState( + null, + ); async function openNewRecipe(): Promise { - if (creatingRecipe) { + if (creatingRecipe || loadingTemplateId) { return; } setCreatingRecipe(true); @@ -142,6 +248,43 @@ export function DataRecipesPage(): ReactElement { } } + async function openLearningRecipe(template: TemplateCard): Promise { + if (creatingRecipe || loadingTemplateId) { + return; + } + if (!template.learningRecipeId) { + toastError("Learning recipe not ready yet."); + return; + } + const recipeTemplate = LEARNING_RECIPE_BY_ID.get(template.learningRecipeId); + if (!recipeTemplate) { + toastError("Learning recipe not found."); + return; + } + + setLoadingTemplateId(template.learningRecipeId); + try { + const payload = await recipeTemplate.loadPayload(); + const recipe = await createRecipeFromLearningRecipe({ + templateId: recipeTemplate.id, + templateTitle: recipeTemplate.title, + payload, + }); + setLearningDialogOpen(false); + await navigate({ + to: "/data-recipes/$recipeId", + params: { recipeId: recipe.id }, + }); + } catch (error) { + toastError( + "Failed to start learning recipe.", + error instanceof Error ? error.message : undefined, + ); + } finally { + setLoadingTemplateId(null); + } + } + function openRecipe(recipeId: string): void { navigate({ to: "/data-recipes/$recipeId", @@ -153,6 +296,8 @@ export function DataRecipesPage(): ReactElement { await deleteRecipe(recipeId); } + const isBusy = creatingRecipe || Boolean(loadingTemplateId); + return (
@@ -165,16 +310,33 @@ export function DataRecipesPage(): ReactElement { Create and manage local recipe workflows.

- + + + + + + { + openNewRecipe().catch(() => undefined); + }} + > + + Start Empty + + { + setLearningDialogOpen(true); + }} + > + + Start from Learning Recipe + + + {recipes.length === 0 ? ( @@ -194,44 +356,18 @@ export function DataRecipesPage(): ReactElement { type="button" variant="secondary" className="mx-auto" - disabled={true} + onClick={() => setLearningDialogOpen(true)} + disabled={isBusy} > - + Start Tutorial -
- {TEMPLATE_CARDS.map((template) => ( -
- -
-
- -
-
-

- {template.title} -

-

- {template.description} -

-
- - Template - -
-
- ))} -
+ { + openLearningRecipe(template).catch(() => undefined); + }} + loadingTemplateId={loadingTemplateId} + /> ) : ( @@ -253,9 +389,14 @@ export function DataRecipesPage(): ReactElement { />
-

- {recipe.name} -

+
+

+ {recipe.name} +

+ {recipe.learningRecipeId ? ( + Learning Recipe + ) : null} +

Last updated {formatRelativeTime(recipe.updatedAt)} | Created {formatRelativeTime(recipe.createdAt)} @@ -279,6 +420,23 @@ export function DataRecipesPage(): ReactElement {

)} + + + + + Learning Recipes + + Start from a prebuilt recipe to learn patterns, then edit and run. + + + { + openLearningRecipe(template).catch(() => undefined); + }} + loadingTemplateId={loadingTemplateId} + /> + + ); } diff --git a/studio/frontend/src/features/data-recipes/types.ts b/studio/frontend/src/features/data-recipes/types.ts index 18b36552ce..c16e039d56 100644 --- a/studio/frontend/src/features/data-recipes/types.ts +++ b/studio/frontend/src/features/data-recipes/types.ts @@ -6,10 +6,14 @@ export type RecipeRecord = { payload: RecipePayload; createdAt: number; updatedAt: number; + learningRecipeId?: string; + learningRecipeTitle?: string; }; export type SaveRecipeInput = { id?: string | null; name: string; payload: RecipePayload; + learningRecipeId?: string; + learningRecipeTitle?: string; }; diff --git a/studio/frontend/src/features/recipe-studio/utils/import/edges.ts b/studio/frontend/src/features/recipe-studio/utils/import/edges.ts index 3fea3307ad..a412ff5a23 100644 --- a/studio/frontend/src/features/recipe-studio/utils/import/edges.ts +++ b/studio/frontend/src/features/recipe-studio/utils/import/edges.ts @@ -31,7 +31,9 @@ export function buildEdges( seen.add(key); const source = configByName.get(from); const target = configByName.get(to); - const isSemantic = Boolean(source && target && isSemanticConnection(source, target)); + const isSemantic = Boolean( + source && target && isSemanticConnection(source, target), + ); const normalizedType = isSemantic ? "semantic" : "canvas"; const handles = normalizedType === "semantic" @@ -56,7 +58,9 @@ export function buildEdges( for (const edge of uiEdges) { addEdgeByName(edge.from, edge.to); } - return edges; + if (edges.length > 0) { + return edges; + } } for (const config of configs) { diff --git a/studio/frontend/src/features/recipe-studio/utils/payload/builders-processors.ts b/studio/frontend/src/features/recipe-studio/utils/payload/builders-processors.ts index 62c2386ecc..e50f31db4f 100644 --- a/studio/frontend/src/features/recipe-studio/utils/payload/builders-processors.ts +++ b/studio/frontend/src/features/recipe-studio/utils/payload/builders-processors.ts @@ -44,8 +44,6 @@ export function buildProcessors( // biome-ignore lint/style/useNamingConvention: api schema processor_type: "schema_transform", name, - // biome-ignore lint/style/useNamingConvention: api schema - build_stage: "post_batch", template, }); } diff --git a/studio/frontend/src/features/recipe-studio/utils/payload/builders-seed.ts b/studio/frontend/src/features/recipe-studio/utils/payload/builders-seed.ts index 2afd02af1b..0a2c545a2f 100644 --- a/studio/frontend/src/features/recipe-studio/utils/payload/builders-seed.ts +++ b/studio/frontend/src/features/recipe-studio/utils/payload/builders-seed.ts @@ -85,7 +85,9 @@ export function buildSeedDropProcessor( } const cols = (config.seed_columns ?? []).map((c) => c.trim()).filter(Boolean); if (cols.length === 0) { - errors.push(`Seed ${config.name}: drop enabled but no seed columns loaded.`); + errors.push( + `Seed ${config.name}: drop enabled but no seed columns loaded.`, + ); return null; } return { @@ -93,8 +95,6 @@ export function buildSeedDropProcessor( processor_type: "drop_columns", name: "drop_seed_columns", // biome-ignore lint/style/useNamingConvention: api schema - build_stage: "post_batch", - // biome-ignore lint/style/useNamingConvention: api schema column_names: cols, }; }