feat(recipe-studio): introduce validator blocks for code validation with Python and SQL engines

This commit is contained in:
Shine1i 2026-03-01 13:01:00 +01:00
commit 761c84f92b
31 changed files with 766 additions and 107 deletions

View file

@ -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");
}

View file

@ -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" ? (
<ExpressionDialog config={config} onUpdate={update} />
) : null;
case "validator":
return config.kind === "validator" ? (
<ValidatorDialog config={config} onUpdate={update} />
) : null;
case "markdown_note":
return config.kind === "markdown_note" ? (
<MarkdownNoteDialog config={config} onUpdate={update} />

View file

@ -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<SheetView, "root">;
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<SheetView, SheetKind | null> = {
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;

View file

@ -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 =

View file

@ -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");

View file

@ -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({
</p>
)}
</div>
<div className="space-y-3 rounded-2xl border border-border/60 px-3 py-3">
{hasHfSeed && (
<div className="flex items-center justify-between gap-3">
<div>
<FieldLabel
label="Use image context"
htmlFor={imageContextToggleId}
hint="Attach one seed image column to this LLM call."
/>
{imageColumnOptions.length > 0 && (
<p className="text-xs text-muted-foreground">
Suggested image columns: {imageColumnOptions.join(", ")}
</p>
)}
</div>
<FieldLabel
label="Use image context"
htmlFor={imageContextToggleId}
hint="Attach one seed image column to this LLM call."
/>
<Switch
id={imageContextToggleId}
checked={imageContext.enabled}
@ -269,37 +264,44 @@ export function LlmGeneralTab({
}}
/>
</div>
{imageContext.enabled && (
<div className="grid gap-2">
<FieldLabel
label="Image column"
htmlFor={imageContextColumnId}
hint="Seed column containing image values."
/>
<Select
value={imageContext.column_name || ""}
onValueChange={(value) =>
onUpdate({
image_context: {
...imageContext,
// biome-ignore lint/style/useNamingConvention: api schema
column_name: value,
},
})
}
>
<SelectTrigger className="nodrag w-full" id={imageContextColumnId}>
<SelectValue placeholder="Select image column" />
</SelectTrigger>
<SelectContent>
{imageColumnOptions.map((columnName) => (
<SelectItem key={columnName} value={columnName}>
{columnName}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
)}
{config.llm_type === "structured" && (
<div className="grid gap-2">
<FieldLabel
label="Output format (JSON schema)"
htmlFor={outputFormatId}
hint="Schema used to constrain structured JSON output."
/>
<Textarea
id={outputFormatId}
className="corner-squircle nodrag"
value={config.output_format ?? ""}
onChange={(event) =>
onUpdate({ output_format: event.target.value })
}
/>
</div>
)}
<div className="grid gap-2">
<FieldLabel
label="System prompt (optional)"
htmlFor={systemPromptId}
hint="Global behavior instructions prepended before prompt."
/>
<Textarea
id={systemPromptId}
className="corner-squircle nodrag max-h-[450px] overflow-auto"
aria-invalid={invalidSystemRefs.length > 0}
value={config.system_prompt}
onChange={(event) => onUpdate({ system_prompt: event.target.value })}
/>
{invalidSystemRefs.length > 0 && (
<p className="text-xs text-destructive">
Unknown reference: {invalidSystemText}
{invalidSystemRefs.length > 3
? ` +${invalidSystemRefs.length - 3} more`
: ""}
</p>
)}
</div>
<Collapsible open={advancedOpen} onOpenChange={setAdvancedOpen}>
@ -340,14 +342,12 @@ export function LlmGeneralTab({
</SelectContent>
</Select>
</div>
<div className="flex items-center justify-between gap-3 rounded-2xl border border-border/60 px-3 py-3">
<div>
<FieldLabel
label="Extract reasoning content"
htmlFor={reasoningToggleId}
hint="Adds {column}__reasoning_content when model provides it."
/>
</div>
<div className="flex items-center justify-between gap-3">
<FieldLabel
label="Extract reasoning content"
htmlFor={reasoningToggleId}
hint="Adds {column}__reasoning_content when model provides it."
/>
<Switch
id={reasoningToggleId}
checked={config.extract_reasoning_content === true}
@ -361,45 +361,6 @@ export function LlmGeneralTab({
</div>
</CollapsibleContent>
</Collapsible>
{config.llm_type === "structured" && (
<div className="grid gap-2">
<FieldLabel
label="Output format (JSON schema)"
htmlFor={outputFormatId}
hint="Schema used to constrain structured JSON output."
/>
<Textarea
id={outputFormatId}
className="corner-squircle nodrag"
value={config.output_format ?? ""}
onChange={(event) =>
onUpdate({ output_format: event.target.value })
}
/>
</div>
)}
<div className="grid gap-2">
<FieldLabel
label="System prompt (optional)"
htmlFor={systemPromptId}
hint="Global behavior instructions prepended before prompt."
/>
<Textarea
id={systemPromptId}
className="corner-squircle nodrag max-h-[450px] overflow-auto"
aria-invalid={invalidSystemRefs.length > 0}
value={config.system_prompt}
onChange={(event) => onUpdate({ system_prompt: event.target.value })}
/>
{invalidSystemRefs.length > 0 && (
<p className="text-xs text-destructive">
Unknown reference: {invalidSystemText}
{invalidSystemRefs.length > 3
? ` +${invalidSystemRefs.length - 3} more`
: ""}
</p>
)}
</div>
</div>
);
}

View file

@ -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<ValidatorConfig>) => 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 (
<div className="space-y-4">
<NameField
value={config.name}
onChange={(value) => onUpdate({ name: value })}
/>
<div className="grid gap-2">
<FieldLabel
label="Validator engine"
hint="Built-in validator type for this block."
/>
<Input value={engineLabel} disabled={true} className="nodrag" />
</div>
<div className="grid gap-2">
<FieldLabel
label="Target code column"
htmlFor={targetColumnId}
hint="Must reference an LLM Code block."
/>
<Select
value={currentTarget || NONE_VALUE}
onValueChange={(value) => {
if (value === NONE_VALUE) {
onUpdate({
// biome-ignore lint/style/useNamingConvention: api schema
target_columns: [],
});
return;
}
const targetConfig = codeOptions.find((item) => item.name === value);
const nextCodeLang = targetConfig?.codeLang?.trim();
onUpdate({
// biome-ignore lint/style/useNamingConvention: api schema
target_columns: [value],
// biome-ignore lint/style/useNamingConvention: api schema
code_lang:
nextCodeLang && isValidatorCodeLang(nextCodeLang)
? nextCodeLang
: config.code_lang,
});
}}
>
<SelectTrigger className="nodrag w-full" id={targetColumnId}>
<SelectValue placeholder="Select code column" />
</SelectTrigger>
<SelectContent>
<SelectItem value={NONE_VALUE}>None</SelectItem>
{codeOptions.map((item) => (
<SelectItem key={item.name} value={item.name}>
{item.name}
</SelectItem>
))}
</SelectContent>
</Select>
{codeOptions.length === 0 && (
<p className="text-xs text-muted-foreground">
Add an LLM Code block first.
</p>
)}
</div>
<div className="grid gap-2">
<FieldLabel
label="Batch size"
htmlFor={batchSizeId}
hint="Records per validation batch."
/>
<Input
id={batchSizeId}
className="nodrag"
value={config.batch_size}
onChange={(event) => onUpdate({ batch_size: event.target.value })}
/>
</div>
</div>
);
}

View file

@ -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,
};
}

View file

@ -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;
}

View file

@ -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}

View file

@ -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<ValidatorConfig>).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;
}

View file

@ -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;
}

View file

@ -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;
}

View file

@ -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<NodeConfig>) => void;
loadRecipe: (snapshot: RecipeSnapshot) => void;
@ -536,6 +542,19 @@ export const useRecipeStudioStore = create<RecipeStudioState>((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) {

View file

@ -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

View file

@ -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[],

View file

@ -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");
}

View file

@ -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<string, NodeConfig>,
@ -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 = {

View file

@ -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"
);
}

View file

@ -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;

View file

@ -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<string, unknown>,
@ -20,6 +21,7 @@ const COLUMN_PARSERS: Record<string, ColumnParser> = {
"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(

View file

@ -0,0 +1,31 @@
import type { ValidatorConfig } from "../../../types";
import { readNumberString } from "../helpers";
import { normalizeValidatorCodeLang } from "../../validators/code-lang";
export function parseValidator(
column: Record<string, unknown>,
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<string, unknown>)
: {};
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",
};
}

View file

@ -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";

View file

@ -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",

View file

@ -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,

View file

@ -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<string, unknown> {
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),
};
}

View file

@ -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,

View file

@ -1,4 +1,9 @@
import type { ModelConfig, ModelProviderConfig, NodeConfig } from "../../types";
import type {
ModelConfig,
ModelProviderConfig,
NodeConfig,
ValidatorConfig,
} from "../../types";
export function validateSubcategoryConfigs(
configs: Record<string, NodeConfig>,
@ -106,3 +111,33 @@ export function validateUsedProviders(
}
}
}
export function validateValidatorConfigs(
configs: Record<string, NodeConfig>,
nameToConfig: Map<string, NodeConfig>,
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"}).`,
);
}
}
}

View file

@ -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()) {

View file

@ -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<ValidatorCodeLang>([
"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";
}

View file

@ -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();