From 761c84f92beaa480e4fde8929d771bac539aa45e Mon Sep 17 00:00:00 2001
From: Shine1i
Date: Sun, 1 Mar 2026 13:01:00 +0100
Subject: [PATCH] feat(recipe-studio): introduce validator blocks for code
validation with Python and SQL engines
---
.../recipe-studio/blocks/definitions.ts | 51 +++++-
.../recipe-studio/blocks/render-dialog.tsx | 5 +
.../recipe-studio/components/block-sheet.tsx | 20 ++-
.../components/recipe-graph-node.tsx | 21 ++-
.../recipe-studio/dialogs/config-dialog.tsx | 1 +
.../recipe-studio/dialogs/llm/general-tab.tsx | 145 +++++++-----------
.../dialogs/validators/validator-dialog.tsx | 126 +++++++++++++++
.../hooks/use-recipe-editor-graph.ts | 27 ++++
.../hooks/use-recipe-runtime-visuals.ts | 3 +
.../recipe-studio/recipe-studio-page.tsx | 5 +
.../recipe-studio/stores/helpers/edge-sync.ts | 54 +++++++
.../stores/helpers/reference-sync.ts | 22 +++
.../recipe-studio/stores/helpers/removals.ts | 13 ++
.../recipe-studio/stores/recipe-studio.ts | 19 +++
.../src/features/recipe-studio/types/index.ts | 25 +++
.../recipe-studio/utils/config-factories.ts | 21 +++
.../recipe-studio/utils/config-type-guards.ts | 7 +
.../utils/graph/recipe-graph-connection.ts | 79 +++++++++-
.../recipe-studio/utils/graph/relations.ts | 17 +-
.../recipe-studio/utils/import/edges.ts | 23 ++-
.../recipe-studio/utils/import/parsers.ts | 2 +
.../utils/import/parsers/validator-parser.ts | 31 ++++
.../src/features/recipe-studio/utils/index.ts | 2 +
.../features/recipe-studio/utils/node-data.ts | 12 ++
.../utils/payload/build-payload.ts | 8 +
.../utils/payload/builders-validator.ts | 38 +++++
.../recipe-studio/utils/payload/builders.ts | 1 +
.../recipe-studio/utils/payload/validate.ts | 37 ++++-
.../recipe-studio/utils/validation.ts | 15 ++
.../utils/validators/code-lang.ts | 38 +++++
.../features/recipe-studio/utils/variables.ts | 5 +
31 files changed, 766 insertions(+), 107 deletions(-)
create mode 100644 studio/frontend/src/features/recipe-studio/dialogs/validators/validator-dialog.tsx
create mode 100644 studio/frontend/src/features/recipe-studio/utils/import/parsers/validator-parser.ts
create mode 100644 studio/frontend/src/features/recipe-studio/utils/payload/builders-validator.ts
create mode 100644 studio/frontend/src/features/recipe-studio/utils/validators/code-lang.ts
diff --git a/studio/frontend/src/features/recipe-studio/blocks/definitions.ts b/studio/frontend/src/features/recipe-studio/blocks/definitions.ts
index 073cce928e..a37a24ad3b 100644
--- a/studio/frontend/src/features/recipe-studio/blocks/definitions.ts
+++ b/studio/frontend/src/features/recipe-studio/blocks/definitions.ts
@@ -17,7 +17,12 @@ import {
TagsIcon,
UserAccountIcon,
} from "@hugeicons/core-free-icons";
-import type { LlmType, NodeConfig, SamplerType, SeedSourceType } from "../types";
+import type {
+ LlmType,
+ NodeConfig,
+ SamplerType,
+ SeedSourceType,
+} from "../types";
import {
makeExpressionConfig,
makeLlmConfig,
@@ -26,12 +31,21 @@ import {
makeModelProviderConfig,
makeSamplerConfig,
makeSeedConfig,
+ makeValidatorConfig,
} from "../utils";
-export type BlockKind = "sampler" | "llm" | "expression" | "seed" | "note";
+export type BlockKind =
+ | "sampler"
+ | "llm"
+ | "validator"
+ | "expression"
+ | "seed"
+ | "note";
export type BlockType =
| SamplerType
| LlmType
+ | "validator_python"
+ | "validator_sql"
| "expression"
| "markdown_note"
| "seed"
@@ -65,6 +79,7 @@ export type BlockDialogKey =
| "uuid"
| "person"
| "llm"
+ | "validator"
| "model_provider"
| "model_config"
| "expression";
@@ -98,6 +113,12 @@ export const BLOCK_GROUPS: BlockGroup[] = [
description: "Generation, providers, and model aliases.",
icon: PencilEdit02Icon,
},
+ {
+ kind: "validator",
+ title: "Validators",
+ description: "Validate generated code outputs with built-in engines.",
+ icon: Shield02Icon,
+ },
{
kind: "expression",
title: "Expression",
@@ -275,6 +296,25 @@ const BLOCK_DEFINITIONS: BlockDefinition[] = [
dialogKey: "model_config",
createConfig: (id, existing) => makeModelConfig(id, existing),
},
+ {
+ kind: "validator",
+ type: "validator_python",
+ title: "Python Validator",
+ description: "Validate Python code columns.",
+ icon: Shield02Icon,
+ dialogKey: "validator",
+ createConfig: (id, existing) => makeValidatorConfig(id, "python", existing),
+ },
+ {
+ kind: "validator",
+ type: "validator_sql",
+ title: "SQL Validator",
+ description: "Validate SQL code columns.",
+ icon: Shield02Icon,
+ dialogKey: "validator",
+ createConfig: (id, existing) =>
+ makeValidatorConfig(id, "sql:sqlite", existing),
+ },
{
kind: "expression",
type: "expression",
@@ -331,6 +371,13 @@ export function getBlockDefinitionForConfig(
if (config.kind === "llm") {
return getBlockDefinition("llm", config.llm_type);
}
+ if (config.kind === "validator") {
+ const isSql = config.code_lang.startsWith("sql:");
+ return getBlockDefinition(
+ "validator",
+ isSql ? "validator_sql" : "validator_python",
+ );
+ }
if (config.kind === "model_provider") {
return getBlockDefinition("llm", "model_provider");
}
diff --git a/studio/frontend/src/features/recipe-studio/blocks/render-dialog.tsx b/studio/frontend/src/features/recipe-studio/blocks/render-dialog.tsx
index f99c1c78da..d07aa6b627 100644
--- a/studio/frontend/src/features/recipe-studio/blocks/render-dialog.tsx
+++ b/studio/frontend/src/features/recipe-studio/blocks/render-dialog.tsx
@@ -16,6 +16,7 @@ import { TimedeltaDialog } from "../dialogs/samplers/timedelta-dialog";
import { UniformDialog } from "../dialogs/samplers/uniform-dialog";
import { UuidDialog } from "../dialogs/samplers/uuid-dialog";
import { MarkdownNoteDialog } from "../dialogs/markdown-note/markdown-note-dialog";
+import { ValidatorDialog } from "../dialogs/validators/validator-dialog";
export function renderBlockDialog(
config: NodeConfig | null,
@@ -109,6 +110,10 @@ export function renderBlockDialog(
return config.kind === "expression" ? (
) : null;
+ case "validator":
+ return config.kind === "validator" ? (
+
+ ) : null;
case "markdown_note":
return config.kind === "markdown_note" ? (
diff --git a/studio/frontend/src/features/recipe-studio/components/block-sheet.tsx b/studio/frontend/src/features/recipe-studio/components/block-sheet.tsx
index be301a36fd..f80f309f90 100644
--- a/studio/frontend/src/features/recipe-studio/components/block-sheet.tsx
+++ b/studio/frontend/src/features/recipe-studio/components/block-sheet.tsx
@@ -39,10 +39,17 @@ type SheetView =
| "sampler"
| "seed"
| "llm"
+ | "validator"
| "expression"
| "note"
| "processor";
-type SheetKind = "sampler" | "seed" | "llm" | "expression" | "note";
+type SheetKind =
+ | "sampler"
+ | "seed"
+ | "llm"
+ | "validator"
+ | "expression"
+ | "note";
type RootSheetView = Exclude;
type RootGroup = {
kind: RootSheetView;
@@ -63,6 +70,7 @@ type BlockSheetProps = {
onAddModelProvider: () => void;
onAddModelConfig: () => void;
onAddExpression: () => void;
+ onAddValidator: (type: "validator_python" | "validator_sql") => void;
onAddMarkdownNote: () => void;
onOpenProcessors: () => void;
copied: boolean;
@@ -89,6 +97,9 @@ function getSheetTitle(sheetView: SheetView): string {
if (sheetView === "expression") {
return "Expression blocks";
}
+ if (sheetView === "validator") {
+ return "Validator blocks";
+ }
if (sheetView === "note") {
return "Note blocks";
}
@@ -103,6 +114,7 @@ const VIEW_KIND: Record = {
sampler: "sampler",
seed: "seed",
llm: "llm",
+ validator: "validator",
expression: "expression",
note: "note",
processor: null,
@@ -121,6 +133,7 @@ const SEARCHABLE_KINDS: SheetKind[] = [
"sampler",
"seed",
"llm",
+ "validator",
"expression",
"note",
];
@@ -193,6 +206,7 @@ export function BlockSheet({
onAddModelProvider,
onAddModelConfig,
onAddExpression,
+ onAddValidator,
onAddMarkdownNote,
onOpenProcessors,
copied,
@@ -302,6 +316,10 @@ export function BlockSheet({
onAddLlm(type as LlmType);
return;
}
+ if (kind === "validator") {
+ onAddValidator(type as "validator_python" | "validator_sql");
+ return;
+ }
if (kind === "expression") {
onAddExpression();
return;
diff --git a/studio/frontend/src/features/recipe-studio/components/recipe-graph-node.tsx b/studio/frontend/src/features/recipe-studio/components/recipe-graph-node.tsx
index 1effe96fc4..35b52b75c5 100644
--- a/studio/frontend/src/features/recipe-studio/components/recipe-graph-node.tsx
+++ b/studio/frontend/src/features/recipe-studio/components/recipe-graph-node.tsx
@@ -81,6 +81,9 @@ const NODE_META = {
llm: {
tone: "bg-sky-50 text-sky-600 border-sky-100",
},
+ validator: {
+ tone: "bg-rose-50 text-rose-600 border-rose-100",
+ },
expression: {
tone: "bg-indigo-50 text-indigo-600 border-indigo-100",
},
@@ -130,6 +133,9 @@ function resolveNodeIcon(
if (kind === "llm" && blockType in LLM_ICONS) {
return LLM_ICONS[blockType as LlmType];
}
+ if (kind === "validator") {
+ return Shield02Icon;
+ }
if (kind === "expression") {
return FunctionIcon;
}
@@ -200,6 +206,14 @@ function getConfigSummary(config: NodeConfig | undefined): string {
return "Prompt/system via linked input nodes";
}
+ if (config.kind === "validator") {
+ const target = config.target_columns[0]?.trim();
+ if (target) {
+ return `Target: ${target}`;
+ }
+ return "Pick LLM code target";
+ }
+
if (config.kind === "seed") {
const seedSourceType = config.seed_source_type ?? "hf";
if (seedSourceType === "hf" && config.hf_repo_id.trim()) {
@@ -333,12 +347,15 @@ function RecipeGraphNodeBase({
const showDataHandles =
data.kind === "llm" ||
+ data.kind === "validator" ||
data.kind === "expression" ||
data.kind === "sampler" ||
data.kind === "seed";
- const showSemanticIn = data.kind === "model_config";
+ const showSemanticIn = data.kind === "model_config" || data.kind === "validator";
const showSemanticOut =
- data.kind === "model_config" || data.kind === "model_provider";
+ data.kind === "model_config" ||
+ data.kind === "model_provider" ||
+ data.kind === "validator";
const summary = getConfigSummary(config);
const nodeBody = renderNodeBody(config, summary, updateConfig);
const canShowLlmAux =
diff --git a/studio/frontend/src/features/recipe-studio/dialogs/config-dialog.tsx b/studio/frontend/src/features/recipe-studio/dialogs/config-dialog.tsx
index 6f9c20c58b..34b8ea95c3 100644
--- a/studio/frontend/src/features/recipe-studio/dialogs/config-dialog.tsx
+++ b/studio/frontend/src/features/recipe-studio/dialogs/config-dialog.tsx
@@ -37,6 +37,7 @@ export function ConfigDialog({
const showDropToggle =
config?.kind === "sampler" ||
config?.kind === "llm" ||
+ config?.kind === "validator" ||
config?.kind === "expression" ||
(config?.kind === "seed" &&
(config.seed_source_type ?? "hf") === "unstructured");
diff --git a/studio/frontend/src/features/recipe-studio/dialogs/llm/general-tab.tsx b/studio/frontend/src/features/recipe-studio/dialogs/llm/general-tab.tsx
index c974944491..c4b92489de 100644
--- a/studio/frontend/src/features/recipe-studio/dialogs/llm/general-tab.tsx
+++ b/studio/frontend/src/features/recipe-studio/dialogs/llm/general-tab.tsx
@@ -105,6 +105,9 @@ export function LlmGeneralTab({
() => Object.values(configs).find((item) => item.kind === "seed"),
[configs],
);
+ const hasHfSeed = Boolean(
+ seedConfig && (seedConfig.seed_source_type ?? "hf") === "hf",
+ );
const seedColumns = seedConfig?.seed_columns ?? [];
const seedPreviewRows = seedConfig?.seed_preview_rows ?? [];
const imageColumnOptions = useMemo(() => {
@@ -133,7 +136,6 @@ export function LlmGeneralTab({
column_name: "",
};
const imageContextToggleId = `${config.id}-image-context-enabled`;
- const imageContextColumnId = `${config.id}-image-context-column`;
const traceModeId = `${config.id}-trace-mode`;
const reasoningToggleId = `${config.id}-reasoning-content`;
const [advancedOpen, setAdvancedOpen] = useState(false);
@@ -237,20 +239,13 @@ export function LlmGeneralTab({
)}
-
+ {hasHfSeed && (
-
-
- {imageColumnOptions.length > 0 && (
-
- Suggested image columns: {imageColumnOptions.join(", ")}
-
- )}
-
+
- {imageContext.enabled && (
-
-
-
-
+ )}
+ {config.llm_type === "structured" && (
+
+
+
+ )}
+
@@ -340,14 +342,12 @@ export function LlmGeneralTab({
-
-
-
-
+
+
- {config.llm_type === "structured" && (
-
-
-
- )}
-
);
}
diff --git a/studio/frontend/src/features/recipe-studio/dialogs/validators/validator-dialog.tsx b/studio/frontend/src/features/recipe-studio/dialogs/validators/validator-dialog.tsx
new file mode 100644
index 0000000000..57c9b0772a
--- /dev/null
+++ b/studio/frontend/src/features/recipe-studio/dialogs/validators/validator-dialog.tsx
@@ -0,0 +1,126 @@
+import { Input } from "@/components/ui/input";
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from "@/components/ui/select";
+import { type ReactElement, useMemo } from "react";
+import { useRecipeStudioStore } from "../../stores/recipe-studio";
+import type { ValidatorConfig } from "../../types";
+import { isValidatorCodeLang } from "../../utils/validators/code-lang";
+import { FieldLabel } from "../shared/field-label";
+import { NameField } from "../shared/name-field";
+
+type ValidatorDialogProps = {
+ config: ValidatorConfig;
+ onUpdate: (patch: Partial
) => void;
+};
+
+const NONE_VALUE = "__none__";
+
+export function ValidatorDialog({
+ config,
+ onUpdate,
+}: ValidatorDialogProps): ReactElement {
+ const configs = useRecipeStudioStore((state) => state.configs);
+ const targetColumnId = `${config.id}-target-column`;
+ const batchSizeId = `${config.id}-batch-size`;
+ const codeOptions = useMemo(
+ () =>
+ Object.values(configs)
+ .flatMap((item) => {
+ if (!(item.kind === "llm" && item.llm_type === "code")) {
+ return [];
+ }
+ return [
+ {
+ name: item.name,
+ codeLang: item.code_lang?.trim() ?? "",
+ },
+ ];
+ })
+ .filter((item) => item.name.trim())
+ .sort((a, b) => a.name.localeCompare(b.name)),
+ [configs],
+ );
+ const currentTarget = config.target_columns[0] ?? "";
+ const engineLabel = config.code_lang.startsWith("sql:") ? "SQL" : "Python";
+
+ return (
+
+
onUpdate({ name: value })}
+ />
+
+
+
+
+
+
+
+ {codeOptions.length === 0 && (
+
+ Add an LLM Code block first.
+
+ )}
+
+
+
+ onUpdate({ batch_size: event.target.value })}
+ />
+
+
+ );
+}
diff --git a/studio/frontend/src/features/recipe-studio/hooks/use-recipe-editor-graph.ts b/studio/frontend/src/features/recipe-studio/hooks/use-recipe-editor-graph.ts
index cdefd6c8ef..d0837c81f5 100644
--- a/studio/frontend/src/features/recipe-studio/hooks/use-recipe-editor-graph.ts
+++ b/studio/frontend/src/features/recipe-studio/hooks/use-recipe-editor-graph.ts
@@ -28,6 +28,7 @@ const SUPPORTED_DRAG_KINDS: RecipeBlockDragPayload["kind"][] = [
"sampler",
"seed",
"llm",
+ "validator",
"expression",
"note",
];
@@ -67,6 +68,11 @@ type UseRecipeEditorGraphArgs = {
addModelProviderNode: (position?: XYPosition, openDialog?: boolean) => void;
addModelConfigNode: (position?: XYPosition, openDialog?: boolean) => void;
addExpressionNode: (position?: XYPosition, openDialog?: boolean) => void;
+ addValidatorNode: (
+ type: "validator_python" | "validator_sql",
+ position?: XYPosition,
+ openDialog?: boolean,
+ ) => void;
addMarkdownNoteNode: (position?: XYPosition, openDialog?: boolean) => void;
};
@@ -85,6 +91,9 @@ type UseRecipeEditorGraphResult = {
handleAddModelProviderFromSheet: () => void;
handleAddModelConfigFromSheet: () => void;
handleAddExpressionFromSheet: () => void;
+ handleAddValidatorFromSheet: (
+ type: "validator_python" | "validator_sql",
+ ) => void;
handleAddMarkdownNoteFromSheet: () => void;
};
@@ -105,6 +114,7 @@ export function useRecipeEditorGraph({
addModelProviderNode,
addModelConfigNode,
addExpressionNode,
+ addValidatorNode,
addMarkdownNoteNode,
}: UseRecipeEditorGraphArgs): UseRecipeEditorGraphResult {
const baseNodeIds = useMemo(() => new Set(nodes.map((node) => node.id)), [nodes]);
@@ -201,6 +211,14 @@ export function useRecipeEditorGraph({
addExpressionNode(position, false);
return;
}
+ if (payload.kind === "validator") {
+ addValidatorNode(
+ payload.type as "validator_python" | "validator_sql",
+ position,
+ false,
+ );
+ return;
+ }
if (payload.kind === "note") {
addMarkdownNoteNode(position, false);
return;
@@ -223,6 +241,7 @@ export function useRecipeEditorGraph({
addModelProviderNode,
addSamplerNode,
addSeedNode,
+ addValidatorNode,
reactFlowInstance,
],
);
@@ -271,6 +290,13 @@ export function useRecipeEditorGraph({
addExpressionNode(getViewportCenterPosition());
}, [addExpressionNode, getViewportCenterPosition]);
+ const handleAddValidatorFromSheet = useCallback(
+ (type: "validator_python" | "validator_sql") => {
+ addValidatorNode(type, getViewportCenterPosition());
+ },
+ [addValidatorNode, getViewportCenterPosition],
+ );
+
const handleAddMarkdownNoteFromSheet = useCallback(() => {
addMarkdownNoteNode(getViewportCenterPosition());
}, [addMarkdownNoteNode, getViewportCenterPosition]);
@@ -288,6 +314,7 @@ export function useRecipeEditorGraph({
handleAddModelProviderFromSheet,
handleAddModelConfigFromSheet,
handleAddExpressionFromSheet,
+ handleAddValidatorFromSheet,
handleAddMarkdownNoteFromSheet,
};
}
diff --git a/studio/frontend/src/features/recipe-studio/hooks/use-recipe-runtime-visuals.ts b/studio/frontend/src/features/recipe-studio/hooks/use-recipe-runtime-visuals.ts
index 316ccdc6c0..7d0b259181 100644
--- a/studio/frontend/src/features/recipe-studio/hooks/use-recipe-runtime-visuals.ts
+++ b/studio/frontend/src/features/recipe-studio/hooks/use-recipe-runtime-visuals.ts
@@ -66,6 +66,9 @@ function resolveExecutionColumnIcon(config: NodeConfig | null): IconType {
if (config.kind === "expression") {
return FunctionIcon;
}
+ if (config.kind === "validator") {
+ return Shield02Icon;
+ }
if (config.kind === "seed") {
return Plant01Icon;
}
diff --git a/studio/frontend/src/features/recipe-studio/recipe-studio-page.tsx b/studio/frontend/src/features/recipe-studio/recipe-studio-page.tsx
index 27806f8e37..cadb472da7 100644
--- a/studio/frontend/src/features/recipe-studio/recipe-studio-page.tsx
+++ b/studio/frontend/src/features/recipe-studio/recipe-studio-page.tsx
@@ -100,6 +100,7 @@ export function RecipeStudioPage({
addModelProviderNode,
addModelConfigNode,
addExpressionNode,
+ addValidatorNode,
addMarkdownNoteNode,
selectConfig,
openConfig,
@@ -136,6 +137,7 @@ export function RecipeStudioPage({
addModelProviderNode: state.addModelProviderNode,
addModelConfigNode: state.addModelConfigNode,
addExpressionNode: state.addExpressionNode,
+ addValidatorNode: state.addValidatorNode,
addMarkdownNoteNode: state.addMarkdownNoteNode,
selectConfig: state.selectConfig,
openConfig: state.openConfig,
@@ -186,6 +188,7 @@ export function RecipeStudioPage({
handleAddModelProviderFromSheet,
handleAddModelConfigFromSheet,
handleAddExpressionFromSheet,
+ handleAddValidatorFromSheet,
handleAddMarkdownNoteFromSheet,
} = useRecipeEditorGraph({
nodes,
@@ -204,6 +207,7 @@ export function RecipeStudioPage({
addModelProviderNode,
addModelConfigNode,
addExpressionNode,
+ addValidatorNode,
addMarkdownNoteNode,
});
@@ -521,6 +525,7 @@ export function RecipeStudioPage({
onAddModelProvider={handleAddModelProviderFromSheet}
onAddModelConfig={handleAddModelConfigFromSheet}
onAddExpression={handleAddExpressionFromSheet}
+ onAddValidator={handleAddValidatorFromSheet}
onAddMarkdownNote={handleAddMarkdownNoteFromSheet}
onOpenProcessors={openProcessorsFromSheet}
copied={copied}
diff --git a/studio/frontend/src/features/recipe-studio/stores/helpers/edge-sync.ts b/studio/frontend/src/features/recipe-studio/stores/helpers/edge-sync.ts
index aee72038cd..112a7c849b 100644
--- a/studio/frontend/src/features/recipe-studio/stores/helpers/edge-sync.ts
+++ b/studio/frontend/src/features/recipe-studio/stores/helpers/edge-sync.ts
@@ -3,6 +3,7 @@ import type {
ModelConfig,
NodeConfig,
SamplerConfig,
+ ValidatorConfig,
} from "../../types";
import { isCategoryConfig, isSubcategoryConfig } from "../../utils";
import { HANDLE_IDS } from "../../utils/handles";
@@ -43,6 +44,23 @@ function addSemanticEdge(edges: Edge[], source: string, target: string): Edge[]
);
}
+function addValidatorSemanticEdge(
+ edges: Edge[],
+ source: string,
+ target: string,
+): Edge[] {
+ return addEdge(
+ {
+ source,
+ target,
+ sourceHandle: HANDLE_IDS.dataOut,
+ targetHandle: HANDLE_IDS.dataIn,
+ type: "semantic",
+ },
+ edges,
+ );
+}
+
function removeTargetEdges(edges: Edge[], targetId: string): Edge[] {
return edges.filter((edge) => edge.target !== targetId);
}
@@ -159,6 +177,42 @@ export function syncEdgesForConfigPatch(
}
}
+ const hasValidatorTargetsPatch = Object.prototype.hasOwnProperty.call(
+ patch,
+ "target_columns",
+ );
+ if (current.kind === "validator" && hasValidatorTargetsPatch) {
+ const nextTargets =
+ ((patch as Partial).target_columns ?? [])
+ .map((value) => value.trim())
+ .filter(Boolean);
+ nextEdges = nextEdges.filter((edge) => {
+ if (edge.source !== current.id && edge.target !== current.id) {
+ return true;
+ }
+ const otherId = edge.source === current.id ? edge.target : edge.source;
+ const other = configs[otherId];
+ return !(
+ other &&
+ other.kind === "llm" &&
+ other.llm_type === "code"
+ );
+ });
+ const nextTargetName = nextTargets[0];
+ if (nextTargetName) {
+ const targetId = findNodeIdByName(configs, nextTargetName);
+ const target = targetId ? configs[targetId] : null;
+ if (
+ targetId &&
+ target &&
+ target.kind === "llm" &&
+ target.llm_type === "code"
+ ) {
+ nextEdges = addValidatorSemanticEdge(nextEdges, targetId, current.id);
+ }
+ }
+ }
+
return nextEdges;
}
diff --git a/studio/frontend/src/features/recipe-studio/stores/helpers/reference-sync.ts b/studio/frontend/src/features/recipe-studio/stores/helpers/reference-sync.ts
index d3245e513f..736745ac4f 100644
--- a/studio/frontend/src/features/recipe-studio/stores/helpers/reference-sync.ts
+++ b/studio/frontend/src/features/recipe-studio/stores/helpers/reference-sync.ts
@@ -83,6 +83,17 @@ export function applyRenameToConfig(
const base = next as LlmConfig;
next = { ...base, model_alias: to };
}
+ if (config.kind === "validator") {
+ const targets = config.target_columns ?? [];
+ if (targets.includes(from)) {
+ const base = next as typeof config;
+ next = {
+ ...base,
+ // biome-ignore lint/style/useNamingConvention: api schema
+ target_columns: targets.map((target) => (target === from ? to : target)),
+ };
+ }
+ }
return next;
}
@@ -125,6 +136,17 @@ export function applyRemovalToConfig(
const base = next as LlmConfig;
next = { ...base, model_alias: "" };
}
+ if (config.kind === "validator") {
+ const targets = (config.target_columns ?? []).filter((target) => target !== ref);
+ if (targets.length !== (config.target_columns ?? []).length) {
+ const base = next as typeof config;
+ next = {
+ ...base,
+ // biome-ignore lint/style/useNamingConvention: api schema
+ target_columns: targets,
+ };
+ }
+ }
return next;
}
diff --git a/studio/frontend/src/features/recipe-studio/stores/helpers/removals.ts b/studio/frontend/src/features/recipe-studio/stores/helpers/removals.ts
index fa25161f4e..5c78d8b578 100644
--- a/studio/frontend/src/features/recipe-studio/stores/helpers/removals.ts
+++ b/studio/frontend/src/features/recipe-studio/stores/helpers/removals.ts
@@ -73,6 +73,19 @@ export function applyEdgeRemovals(
}
next[target.id] = updated;
}
+ if (
+ source.kind === "validator" &&
+ target.kind === "llm" &&
+ target.llm_type === "code"
+ ) {
+ const sourceUpdated = applyRemovalToConfig(source, target.name);
+ if (sourceUpdated !== source) {
+ if (next === configs) {
+ next = { ...configs };
+ }
+ next[source.id] = sourceUpdated;
+ }
+ }
}
return next;
}
diff --git a/studio/frontend/src/features/recipe-studio/stores/recipe-studio.ts b/studio/frontend/src/features/recipe-studio/stores/recipe-studio.ts
index 96e4bbb2c6..f88b117af8 100644
--- a/studio/frontend/src/features/recipe-studio/stores/recipe-studio.ts
+++ b/studio/frontend/src/features/recipe-studio/stores/recipe-studio.ts
@@ -52,6 +52,7 @@ type SheetView =
| "sampler"
| "seed"
| "llm"
+ | "validator"
| "expression"
| "note"
| "processor";
@@ -95,6 +96,11 @@ type RecipeStudioState = {
addModelProviderNode: (position?: XYPosition, openDialog?: boolean) => void;
addModelConfigNode: (position?: XYPosition, openDialog?: boolean) => void;
addExpressionNode: (position?: XYPosition, openDialog?: boolean) => void;
+ addValidatorNode: (
+ type: "validator_python" | "validator_sql",
+ position?: XYPosition,
+ openDialog?: boolean,
+ ) => void;
addMarkdownNoteNode: (position?: XYPosition, openDialog?: boolean) => void;
updateConfig: (id: string, patch: Partial) => void;
loadRecipe: (snapshot: RecipeSnapshot) => void;
@@ -536,6 +542,19 @@ export const useRecipeStudioStore = create((set, get) => ({
openDialog,
);
}),
+ addValidatorNode: (type, position, openDialog = true) =>
+ set((state) => {
+ if (state.executionLocked) {
+ return state;
+ }
+ return buildAddedNodeState(
+ state,
+ "validator",
+ type,
+ position,
+ openDialog,
+ );
+ }),
addMarkdownNoteNode: (position, openDialog = true) =>
set((state) => {
if (state.executionLocked) {
diff --git a/studio/frontend/src/features/recipe-studio/types/index.ts b/studio/frontend/src/features/recipe-studio/types/index.ts
index c6f5b17775..70bd4edba6 100644
--- a/studio/frontend/src/features/recipe-studio/types/index.ts
+++ b/studio/frontend/src/features/recipe-studio/types/index.ts
@@ -13,6 +13,14 @@ export type SamplerType =
| "person_from_faker";
export type LlmType = "text" | "structured" | "code" | "judge";
+export type ValidatorCodeLang =
+ | "python"
+ | "sql:sqlite"
+ | "sql:postgres"
+ | "sql:mysql"
+ | "sql:tsql"
+ | "sql:bigquery"
+ | "sql:ansi";
export type ExpressionDtype = "str" | "int" | "float" | "bool";
@@ -28,6 +36,7 @@ export type RecipeNodeData = {
kind:
| "sampler"
| "llm"
+ | "validator"
| "expression"
| "seed"
| "note"
@@ -37,6 +46,8 @@ export type RecipeNodeData = {
blockType:
| SamplerType
| LlmType
+ | "validator_python"
+ | "validator_sql"
| "expression"
| "seed"
| "markdown_note"
@@ -234,6 +245,19 @@ export type ExpressionConfig = {
dtype: ExpressionDtype;
};
+export type ValidatorConfig = {
+ id: string;
+ kind: "validator";
+ name: string;
+ drop?: boolean;
+ // biome-ignore lint/style/useNamingConvention: api schema
+ target_columns: string[];
+ // biome-ignore lint/style/useNamingConvention: api schema
+ code_lang: ValidatorCodeLang;
+ // ui ergonomics (serialized to int in payload)
+ batch_size: string;
+};
+
export type MarkdownNoteConfig = {
id: string;
kind: "markdown_note";
@@ -294,6 +318,7 @@ export type RecipeProcessorConfig = SchemaTransformProcessorConfig;
export type NodeConfig =
| SamplerConfig
| LlmConfig
+ | ValidatorConfig
| ExpressionConfig
| MarkdownNoteConfig
| SeedConfig
diff --git a/studio/frontend/src/features/recipe-studio/utils/config-factories.ts b/studio/frontend/src/features/recipe-studio/utils/config-factories.ts
index bced2fc43f..f22cde4264 100644
--- a/studio/frontend/src/features/recipe-studio/utils/config-factories.ts
+++ b/studio/frontend/src/features/recipe-studio/utils/config-factories.ts
@@ -10,6 +10,8 @@ import type {
SeedSourceType,
SamplerConfig,
SamplerType,
+ ValidatorCodeLang,
+ ValidatorConfig,
} from "../types";
import { nextName } from "./naming";
@@ -288,6 +290,25 @@ export function makeExpressionConfig(
};
}
+export function makeValidatorConfig(
+ id: string,
+ codeLang: ValidatorCodeLang,
+ existing: NodeConfig[],
+): ValidatorConfig {
+ const isSql = codeLang.startsWith("sql:");
+ return {
+ id,
+ kind: "validator",
+ name: nextName(existing, isSql ? "validator_sql" : "validator_python"),
+ drop: false,
+ // biome-ignore lint/style/useNamingConvention: api schema
+ target_columns: [],
+ // biome-ignore lint/style/useNamingConvention: api schema
+ code_lang: codeLang,
+ batch_size: "10",
+ };
+}
+
export function makeMarkdownNoteConfig(
id: string,
existing: NodeConfig[],
diff --git a/studio/frontend/src/features/recipe-studio/utils/config-type-guards.ts b/studio/frontend/src/features/recipe-studio/utils/config-type-guards.ts
index 855cca8c6a..af40b524b0 100644
--- a/studio/frontend/src/features/recipe-studio/utils/config-type-guards.ts
+++ b/studio/frontend/src/features/recipe-studio/utils/config-type-guards.ts
@@ -3,6 +3,7 @@ import type {
LlmConfig,
NodeConfig,
SamplerConfig,
+ ValidatorConfig,
} from "../types";
export function isSamplerConfig(
@@ -40,3 +41,9 @@ export function isExpressionConfig(
): config is ExpressionConfig {
return Boolean(config && config.kind === "expression");
}
+
+export function isValidatorConfig(
+ config: NodeConfig | null | undefined,
+): config is ValidatorConfig {
+ return Boolean(config && config.kind === "validator");
+}
diff --git a/studio/frontend/src/features/recipe-studio/utils/graph/recipe-graph-connection.ts b/studio/frontend/src/features/recipe-studio/utils/graph/recipe-graph-connection.ts
index 67ecf4e29d..0cfb9a3fcd 100644
--- a/studio/frontend/src/features/recipe-studio/utils/graph/recipe-graph-connection.ts
+++ b/studio/frontend/src/features/recipe-studio/utils/graph/recipe-graph-connection.ts
@@ -78,7 +78,8 @@ type SingleRefRelation =
| "provider"
| "model_alias"
| "reference_column_name"
- | "subcategory_parent";
+ | "subcategory_parent"
+ | "validator_target_columns";
function getSingleRefRelation(
source: NodeConfig,
@@ -101,6 +102,13 @@ function getSingleRefRelation(
if (isCategoryConfig(source) && isSubcategoryConfig(target)) {
return "subcategory_parent";
}
+ if (
+ source.kind === "llm" &&
+ source.llm_type === "code" &&
+ target.kind === "validator"
+ ) {
+ return "validator_target_columns";
+ }
return null;
}
@@ -126,6 +134,9 @@ function isCompetingIncomingEdge(
if (relation === "subcategory_parent") {
return isCategoryConfig(source);
}
+ if (relation === "validator_target_columns") {
+ return source.kind === "llm" && source.llm_type === "code";
+ }
return source.kind === "sampler" && source.sampler_type === "datetime";
}
@@ -220,6 +231,27 @@ function chooseModelSemanticHandles(
};
}
+function normalizeValidatorSemanticConnection(
+ connection: Connection,
+ source: NodeConfig,
+ target: NodeConfig,
+): Connection {
+ if (
+ source.kind === "validator" &&
+ target.kind === "llm" &&
+ target.llm_type === "code"
+ ) {
+ return {
+ ...connection,
+ source: target.id,
+ target: source.id,
+ sourceHandle: HANDLE_IDS.dataOut,
+ targetHandle: HANDLE_IDS.dataIn,
+ };
+ }
+ return connection;
+}
+
export function isValidRecipeConnection(
connection: Connection,
configs: Record,
@@ -253,15 +285,30 @@ export function applyRecipeConnection(
if (!isValidRecipeConnection(connection, configs)) {
return { edges };
}
- const source = connection.source
+ const initialSource = connection.source
? configs[connection.source]
: null;
- const target = connection.target
+ const initialTarget = connection.target
? configs[connection.target]
: null;
+ if (!(initialSource && initialTarget)) {
+ return { edges };
+ }
+ const normalizedConnection = normalizeValidatorSemanticConnection(
+ connection,
+ initialSource,
+ initialTarget,
+ );
+ const source = normalizedConnection.source
+ ? configs[normalizedConnection.source]
+ : null;
+ const target = normalizedConnection.target
+ ? configs[normalizedConnection.target]
+ : null;
if (!(source && target)) {
return { edges };
}
+
const semanticRelation = isSemanticRelation(source, target);
const singleRefRelation = getSingleRefRelation(source, target);
const nextBaseEdges = singleRefRelation
@@ -271,7 +318,7 @@ export function applyRecipeConnection(
)
: edges;
const resolvedConnection = chooseModelSemanticHandles(
- connection,
+ normalizedConnection,
source,
target,
nextBaseEdges,
@@ -301,11 +348,29 @@ export function applyRecipeConnection(
};
return { edges: nextEdges, configs: { ...configs, [target.id]: next } };
}
+ if (
+ source.kind === "llm" &&
+ source.llm_type === "code" &&
+ target.kind === "validator"
+ ) {
+ const nextCodeLang = (source.code_lang ?? "").trim();
+ const next = {
+ ...target,
+ // biome-ignore lint/style/useNamingConvention: api schema
+ target_columns: [source.name],
+ // biome-ignore lint/style/useNamingConvention: api schema
+ code_lang:
+ (nextCodeLang || target.code_lang) as typeof target.code_lang,
+ };
+ return { edges: nextEdges, configs: { ...configs, [target.id]: next } };
+ }
if (
isLlmConfig(target) &&
+ !semanticRelation &&
source.kind !== "seed" &&
source.kind !== "model_provider" &&
- source.kind !== "model_config"
+ source.kind !== "model_config" &&
+ source.kind !== "validator"
) {
const ref = `{{ ${source.name} }}`;
const next = {
@@ -316,9 +381,11 @@ export function applyRecipeConnection(
}
if (
isExpressionConfig(target) &&
+ !semanticRelation &&
source.kind !== "seed" &&
source.kind !== "model_provider" &&
- source.kind !== "model_config"
+ source.kind !== "model_config" &&
+ source.kind !== "validator"
) {
const ref = `{{ ${source.name} }}`;
const next = {
diff --git a/studio/frontend/src/features/recipe-studio/utils/graph/relations.ts b/studio/frontend/src/features/recipe-studio/utils/graph/relations.ts
index 9efdcb55d0..8513002d9b 100644
--- a/studio/frontend/src/features/recipe-studio/utils/graph/relations.ts
+++ b/studio/frontend/src/features/recipe-studio/utils/graph/relations.ts
@@ -7,6 +7,19 @@ export function isSemanticRelation(
if (source.kind === "model_provider" && target.kind === "model_config") {
return true;
}
- return source.kind === "model_config" && target.kind === "llm";
+ if (source.kind === "model_config" && target.kind === "llm") {
+ return true;
+ }
+ if (
+ source.kind === "llm" &&
+ source.llm_type === "code" &&
+ target.kind === "validator"
+ ) {
+ return true;
+ }
+ return (
+ source.kind === "validator" &&
+ target.kind === "llm" &&
+ target.llm_type === "code"
+ );
}
-
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 59e022063d..4e02877b6c 100644
--- a/studio/frontend/src/features/recipe-studio/utils/import/edges.ts
+++ b/studio/frontend/src/features/recipe-studio/utils/import/edges.ts
@@ -17,7 +17,21 @@ function isSemanticConnection(source: NodeConfig, target: NodeConfig): boolean {
if (source.kind === "model_provider" && target.kind === "model_config") {
return true;
}
- return source.kind === "model_config" && target.kind === "llm";
+ if (source.kind === "model_config" && target.kind === "llm") {
+ return true;
+ }
+ if (
+ source.kind === "llm" &&
+ source.llm_type === "code" &&
+ target.kind === "validator"
+ ) {
+ return true;
+ }
+ return (
+ source.kind === "validator" &&
+ target.kind === "llm" &&
+ target.llm_type === "code"
+ );
}
export function buildEdges(
@@ -149,6 +163,13 @@ export function buildEdges(
if (config.kind === "llm" && config.model_alias) {
addEdgeByName(config.model_alias, config.name);
}
+ if (config.kind === "validator") {
+ for (const targetColumn of config.target_columns ?? []) {
+ if (targetColumn.trim()) {
+ addEdgeByName(targetColumn, config.name);
+ }
+ }
+ }
}
return edges;
diff --git a/studio/frontend/src/features/recipe-studio/utils/import/parsers.ts b/studio/frontend/src/features/recipe-studio/utils/import/parsers.ts
index 664037d8d2..3b7d445011 100644
--- a/studio/frontend/src/features/recipe-studio/utils/import/parsers.ts
+++ b/studio/frontend/src/features/recipe-studio/utils/import/parsers.ts
@@ -4,6 +4,7 @@ import { parseExpression } from "./parsers/expression-parser";
import { parseLlm } from "./parsers/llm-parser";
export { parseModelConfig, parseModelProvider } from "./parsers/model-parser";
import { parseSampler } from "./parsers/sampler-parser";
+import { parseValidator } from "./parsers/validator-parser";
type ColumnParser = (
column: Record,
@@ -20,6 +21,7 @@ const COLUMN_PARSERS: Record = {
"llm-structured": (column, name, id) => parseLlm(column, name, id),
"llm-code": (column, name, id) => parseLlm(column, name, id),
"llm-judge": (column, name, id) => parseLlm(column, name, id),
+ validation: (column, name, id) => parseValidator(column, name, id),
};
export function parseColumn(
diff --git a/studio/frontend/src/features/recipe-studio/utils/import/parsers/validator-parser.ts b/studio/frontend/src/features/recipe-studio/utils/import/parsers/validator-parser.ts
new file mode 100644
index 0000000000..466b20dafd
--- /dev/null
+++ b/studio/frontend/src/features/recipe-studio/utils/import/parsers/validator-parser.ts
@@ -0,0 +1,31 @@
+import type { ValidatorConfig } from "../../../types";
+import { readNumberString } from "../helpers";
+import { normalizeValidatorCodeLang } from "../../validators/code-lang";
+
+export function parseValidator(
+ column: Record,
+ name: string,
+ id: string,
+): ValidatorConfig {
+ const targetColumns = Array.isArray(column.target_columns)
+ ? column.target_columns
+ .filter((value): value is string => typeof value === "string")
+ .map((value) => value.trim())
+ .filter(Boolean)
+ : [];
+ const params =
+ column.validator_params && typeof column.validator_params === "object"
+ ? (column.validator_params as Record)
+ : {};
+ return {
+ id,
+ kind: "validator",
+ name,
+ drop: column.drop === true,
+ // biome-ignore lint/style/useNamingConvention: api schema
+ target_columns: targetColumns,
+ // biome-ignore lint/style/useNamingConvention: api schema
+ code_lang: normalizeValidatorCodeLang(params.code_lang),
+ batch_size: readNumberString(column.batch_size) || "10",
+ };
+}
diff --git a/studio/frontend/src/features/recipe-studio/utils/index.ts b/studio/frontend/src/features/recipe-studio/utils/index.ts
index 7ff7a5fe57..fa03a50e40 100644
--- a/studio/frontend/src/features/recipe-studio/utils/index.ts
+++ b/studio/frontend/src/features/recipe-studio/utils/index.ts
@@ -6,6 +6,7 @@ export {
makeModelProviderConfig,
makeSamplerConfig,
makeSeedConfig,
+ makeValidatorConfig,
} from "./config-factories";
export {
labelForExpression,
@@ -18,6 +19,7 @@ export {
isLlmConfig,
isSamplerConfig,
isSubcategoryConfig,
+ isValidatorConfig,
} from "./config-type-guards";
export { nextName } from "./naming";
export { nodeDataFromConfig } from "./node-data";
diff --git a/studio/frontend/src/features/recipe-studio/utils/node-data.ts b/studio/frontend/src/features/recipe-studio/utils/node-data.ts
index 76b1f5ad89..1f71481d9a 100644
--- a/studio/frontend/src/features/recipe-studio/utils/node-data.ts
+++ b/studio/frontend/src/features/recipe-studio/utils/node-data.ts
@@ -29,6 +29,18 @@ export function nodeDataFromConfig(
layoutDirection,
};
}
+ if (config.kind === "validator") {
+ return {
+ title: "Validator",
+ kind: "validator",
+ subtype: config.code_lang.startsWith("sql:") ? "SQL" : "Python",
+ blockType: config.code_lang.startsWith("sql:")
+ ? "validator_sql"
+ : "validator_python",
+ name: config.name,
+ layoutDirection,
+ };
+ }
if (config.kind === "markdown_note") {
return {
title: "Note",
diff --git a/studio/frontend/src/features/recipe-studio/utils/payload/build-payload.ts b/studio/frontend/src/features/recipe-studio/utils/payload/build-payload.ts
index 93432be0c0..7f97547549 100644
--- a/studio/frontend/src/features/recipe-studio/utils/payload/build-payload.ts
+++ b/studio/frontend/src/features/recipe-studio/utils/payload/build-payload.ts
@@ -32,6 +32,7 @@ import {
buildSamplerColumn,
buildSeedConfig,
buildSeedDropProcessor,
+ buildValidatorColumn,
pickFirstSeedConfig,
} from "./builders";
import type { RecipePayloadResult } from "./types";
@@ -40,6 +41,7 @@ import {
validateModelConfigProviders,
validateSubcategoryConfigs,
validateTimedeltaConfigs,
+ validateValidatorConfigs,
validateUsedProviders,
} from "./validate";
import { isLikelyImageValue } from "../image-preview";
@@ -179,6 +181,11 @@ export function buildRecipePayload(
nameToConfig.set(config.name, config);
continue;
}
+ if (config.kind === "validator") {
+ columns.push(buildValidatorColumn(config, errors));
+ nameToConfig.set(config.name, config);
+ continue;
+ }
if (config.kind === "seed") {
// SeedConfig is global config (seed_config); seed-dataset columns are added by DataDesigner.
continue;
@@ -198,6 +205,7 @@ export function buildRecipePayload(
validateSubcategoryConfigs(configs, nameToConfig, errors);
validateTimedeltaConfigs(configs, nameToConfig, errors);
+ validateValidatorConfigs(configs, nameToConfig, errors);
validateModelAliasLinks(modelAliases, modelConfigConfigs, errors);
validateModelConfigProviders(
modelConfigConfigs,
diff --git a/studio/frontend/src/features/recipe-studio/utils/payload/builders-validator.ts b/studio/frontend/src/features/recipe-studio/utils/payload/builders-validator.ts
new file mode 100644
index 0000000000..6fd8ccea37
--- /dev/null
+++ b/studio/frontend/src/features/recipe-studio/utils/payload/builders-validator.ts
@@ -0,0 +1,38 @@
+import type { ValidatorConfig } from "../../types";
+
+function parseBatchSize(value: string): number {
+ const parsed = Number.parseInt(value, 10);
+ if (!Number.isFinite(parsed) || parsed < 1) {
+ return 10;
+ }
+ return parsed;
+}
+
+export function buildValidatorColumn(
+ config: ValidatorConfig,
+ errors: string[],
+): Record {
+ const targetColumns = (config.target_columns ?? [])
+ .map((value) => value.trim())
+ .filter(Boolean);
+ if (targetColumns.length === 0) {
+ errors.push(`Validator ${config.name}: target code column required.`);
+ }
+ return {
+ // biome-ignore lint/style/useNamingConvention: api schema
+ column_type: "validation",
+ name: config.name,
+ drop: config.drop ?? false,
+ // biome-ignore lint/style/useNamingConvention: api schema
+ target_columns: targetColumns,
+ // biome-ignore lint/style/useNamingConvention: api schema
+ validator_type: "code",
+ // biome-ignore lint/style/useNamingConvention: api schema
+ validator_params: {
+ // biome-ignore lint/style/useNamingConvention: api schema
+ code_lang: config.code_lang,
+ },
+ // biome-ignore lint/style/useNamingConvention: api schema
+ batch_size: parseBatchSize(config.batch_size),
+ };
+}
diff --git a/studio/frontend/src/features/recipe-studio/utils/payload/builders.ts b/studio/frontend/src/features/recipe-studio/utils/payload/builders.ts
index b757650e31..fb756cd159 100644
--- a/studio/frontend/src/features/recipe-studio/utils/payload/builders.ts
+++ b/studio/frontend/src/features/recipe-studio/utils/payload/builders.ts
@@ -2,6 +2,7 @@ export { buildLlmColumn, buildLlmMcpProvider, buildLlmToolConfig } from "./build
export { buildModelConfig, buildModelProvider } from "./builders-model";
export { buildExpressionColumn, buildProcessors } from "./builders-processors";
export { buildSamplerColumn } from "./builders-sampler";
+export { buildValidatorColumn } from "./builders-validator";
export {
buildSeedConfig,
buildSeedDropProcessor,
diff --git a/studio/frontend/src/features/recipe-studio/utils/payload/validate.ts b/studio/frontend/src/features/recipe-studio/utils/payload/validate.ts
index f4348886eb..446e61c7a3 100644
--- a/studio/frontend/src/features/recipe-studio/utils/payload/validate.ts
+++ b/studio/frontend/src/features/recipe-studio/utils/payload/validate.ts
@@ -1,4 +1,9 @@
-import type { ModelConfig, ModelProviderConfig, NodeConfig } from "../../types";
+import type {
+ ModelConfig,
+ ModelProviderConfig,
+ NodeConfig,
+ ValidatorConfig,
+} from "../../types";
export function validateSubcategoryConfigs(
configs: Record,
@@ -106,3 +111,33 @@ export function validateUsedProviders(
}
}
}
+
+export function validateValidatorConfigs(
+ configs: Record,
+ nameToConfig: Map,
+ errors: string[],
+): void {
+ for (const config of Object.values(configs)) {
+ if (config.kind !== "validator") {
+ continue;
+ }
+ const target = (config as ValidatorConfig).target_columns[0]?.trim();
+ if (!target) {
+ continue;
+ }
+ const targetConfig = nameToConfig.get(target);
+ if (!targetConfig) {
+ errors.push(`Validator ${config.name}: target '${target}' not found.`);
+ continue;
+ }
+ if (targetConfig.kind !== "llm" || targetConfig.llm_type !== "code") {
+ errors.push(`Validator ${config.name}: target '${target}' must be LLM Code.`);
+ continue;
+ }
+ if ((targetConfig.code_lang ?? "").trim() !== config.code_lang.trim()) {
+ errors.push(
+ `Validator ${config.name}: code_lang '${config.code_lang}' must match target '${target}' (${targetConfig.code_lang ?? "unknown"}).`,
+ );
+ }
+ }
+}
diff --git a/studio/frontend/src/features/recipe-studio/utils/validation.ts b/studio/frontend/src/features/recipe-studio/utils/validation.ts
index 9950f19937..4030d922be 100644
--- a/studio/frontend/src/features/recipe-studio/utils/validation.ts
+++ b/studio/frontend/src/features/recipe-studio/utils/validation.ts
@@ -192,6 +192,21 @@ export function getConfigErrors(config: NodeConfig | null): string[] {
errors.push("Expression is required.");
}
}
+ if (config.kind === "validator") {
+ const targets = (config.target_columns ?? [])
+ .map((value) => value.trim())
+ .filter(Boolean);
+ if (targets.length === 0) {
+ errors.push("Target code column is required.");
+ }
+ const batch = parseIntNumber(config.batch_size);
+ if (batch === null || batch < 1) {
+ errors.push("Batch size must be an integer >= 1.");
+ }
+ if (!config.code_lang.trim()) {
+ errors.push("Validator code language is required.");
+ }
+ }
if (config.kind === "seed") {
const seedSourceType = config.seed_source_type ?? "hf";
if (seedSourceType === "hf" && !config.hf_repo_id.trim()) {
diff --git a/studio/frontend/src/features/recipe-studio/utils/validators/code-lang.ts b/studio/frontend/src/features/recipe-studio/utils/validators/code-lang.ts
new file mode 100644
index 0000000000..1b2778f8b7
--- /dev/null
+++ b/studio/frontend/src/features/recipe-studio/utils/validators/code-lang.ts
@@ -0,0 +1,38 @@
+import type { ValidatorCodeLang } from "../../types";
+
+export const VALIDATOR_SQL_CODE_LANGS: ValidatorCodeLang[] = [
+ "sql:sqlite",
+ "sql:postgres",
+ "sql:mysql",
+ "sql:tsql",
+ "sql:bigquery",
+ "sql:ansi",
+];
+
+const VALIDATOR_CODE_LANG_SET = new Set([
+ "python",
+ ...VALIDATOR_SQL_CODE_LANGS,
+]);
+
+export function isValidatorCodeLang(value: string): value is ValidatorCodeLang {
+ return VALIDATOR_CODE_LANG_SET.has(value as ValidatorCodeLang);
+}
+
+export function normalizeValidatorCodeLang(
+ value: unknown,
+): ValidatorCodeLang {
+ const raw = typeof value === "string" ? value.trim() : "";
+ if (!raw) {
+ return "python";
+ }
+ if (raw === "python") {
+ return "python";
+ }
+ if (raw.startsWith("sql:")) {
+ if (VALIDATOR_SQL_CODE_LANGS.includes(raw as ValidatorCodeLang)) {
+ return raw as ValidatorCodeLang;
+ }
+ return "sql:sqlite";
+ }
+ return "python";
+}
diff --git a/studio/frontend/src/features/recipe-studio/utils/variables.ts b/studio/frontend/src/features/recipe-studio/utils/variables.ts
index a59cd4453c..f98f0662bc 100644
--- a/studio/frontend/src/features/recipe-studio/utils/variables.ts
+++ b/studio/frontend/src/features/recipe-studio/utils/variables.ts
@@ -43,6 +43,11 @@ export function getAvailableVariableEntries(
continue;
}
+ if (config.kind === "validator") {
+ vars.push({ name: config.name, source: "column" });
+ continue;
+ }
+
if (config.kind === "seed") {
for (const col of config.seed_columns ?? []) {
const name = col.trim();