feat: add Recipe Studio utilities and components for configuring synthetic data pipelines

This commit is contained in:
Shine1i 2026-02-12 01:03:47 +01:00
commit 390e9ed9d2
21 changed files with 1589 additions and 1453 deletions

View file

@ -27,3 +27,4 @@ test/
*.sln
*.sw?
/src/features/recipe-studio/AGENTS.md
/docs

View file

@ -0,0 +1,60 @@
import { type ReactElement, useCallback } from "react";
import {
Panel,
useReactFlow,
useUpdateNodeInternals,
} from "@xyflow/react";
import { Button } from "@/components/ui/button";
type LayoutControlsProps = {
direction: "LR" | "TB";
onLayout: () => void;
onToggleDirection: () => void;
};
export function LayoutControls({
direction,
onLayout,
onToggleDirection,
}: LayoutControlsProps): ReactElement {
const { fitView, getNodes } = useReactFlow();
const updateNodeInternals = useUpdateNodeInternals();
const refreshNodeInternals = useCallback(() => {
const nodeIds = getNodes().map((node) => node.id);
if (nodeIds.length > 0) {
updateNodeInternals(nodeIds);
}
}, [getNodes, updateNodeInternals]);
const handleLayout = useCallback(() => {
onLayout();
requestAnimationFrame(() => {
refreshNodeInternals();
requestAnimationFrame(() => {
fitView({ duration: 250 });
});
});
}, [fitView, onLayout, refreshNodeInternals]);
const handleToggleDirection = useCallback(() => {
onToggleDirection();
requestAnimationFrame(() => {
refreshNodeInternals();
requestAnimationFrame(() => {
refreshNodeInternals();
});
});
}, [onToggleDirection, refreshNodeInternals]);
return (
<Panel position="top-left" className="m-3 flex items-center gap-2">
<Button size="sm" className="corner-squircle" variant="secondary" onClick={handleLayout}>
Auto layout
</Button>
<Button size="sm" className="corner-squircle" variant="outline" onClick={handleToggleDirection}>
{direction}
</Button>
</Panel>
);
}

View file

@ -0,0 +1,74 @@
import { type ReactElement, useCallback } from "react";
import { Lock, LockOpen, Maximize2, Minus, Plus } from "lucide-react";
import { Panel, useReactFlow } from "@xyflow/react";
import { Button } from "@/components/ui/button";
import { RECIPE_FLOATING_ICON_BUTTON_CLASS } from "../recipe-floating-icon-button-class";
type ViewportControlsProps = {
interactive: boolean;
onToggleInteractive: () => void;
};
export function ViewportControls({
interactive,
onToggleInteractive,
}: ViewportControlsProps): ReactElement {
const { zoomIn, zoomOut, fitView } = useReactFlow();
const handleZoomIn = useCallback(() => {
zoomIn({ duration: 150 });
}, [zoomIn]);
const handleZoomOut = useCallback(() => {
zoomOut({ duration: 150 });
}, [zoomOut]);
const handleFitView = useCallback(() => {
fitView({ duration: 250 });
}, [fitView]);
return (
<Panel position="bottom-left" className="m-3 flex items-center gap-2">
<Button
type="button"
variant="ghost"
size="icon"
className={RECIPE_FLOATING_ICON_BUTTON_CLASS}
onClick={handleZoomIn}
aria-label="Zoom in"
>
<Plus className="size-4" />
</Button>
<Button
type="button"
variant="ghost"
size="icon"
className={RECIPE_FLOATING_ICON_BUTTON_CLASS}
onClick={handleZoomOut}
aria-label="Zoom out"
>
<Minus className="size-4" />
</Button>
<Button
type="button"
variant="ghost"
size="icon"
className={RECIPE_FLOATING_ICON_BUTTON_CLASS}
onClick={handleFitView}
aria-label="Fit view"
>
<Maximize2 className="size-4" />
</Button>
<Button
type="button"
variant="ghost"
size="icon"
className={RECIPE_FLOATING_ICON_BUTTON_CLASS}
onClick={onToggleInteractive}
aria-label={interactive ? "Lock interaction" : "Unlock interaction"}
>
{interactive ? <LockOpen className="size-4" /> : <Lock className="size-4" />}
</Button>
</Panel>
);
}

View file

@ -0,0 +1,24 @@
import { useUpdateNodeInternals } from "@xyflow/react";
import { useEffect } from "react";
type InternalsSyncProps = {
nodeIds: string[];
};
export function InternalsSync({ nodeIds }: InternalsSyncProps): null {
const updateNodeInternals = useUpdateNodeInternals();
useEffect(() => {
if (nodeIds.length === 0) {
return;
}
requestAnimationFrame(() => {
updateNodeInternals(nodeIds);
requestAnimationFrame(() => {
updateNodeInternals(nodeIds);
});
});
}, [nodeIds, updateNodeInternals]);
return null;
}

View file

@ -0,0 +1,61 @@
import type { ReactElement } from "react";
import { EyeIcon } from "@hugeicons/core-free-icons";
import { HugeiconsIcon } from "@hugeicons/react";
import { Button } from "@/components/ui/button";
import { Spinner } from "@/components/ui/spinner";
type StatusTone = "success" | "error";
type RecipeStudioHeaderProps = {
previewLoading: boolean;
statusMessage: {
tone: StatusTone;
text: string;
} | null;
onPreview: () => void;
};
const STATUS_MESSAGE_CLASS: Record<StatusTone, string> = {
success: "mt-2 text-xs text-emerald-600",
error: "mt-2 text-xs text-rose-600",
};
export function RecipeStudioHeader({
previewLoading,
statusMessage,
onPreview,
}: RecipeStudioHeaderProps): ReactElement {
return (
<div className="mb-6 flex flex-col gap-4">
<div className="flex flex-col gap-4 lg:grid lg:grid-cols-[1fr_auto] lg:items-center">
<div>
<h1 className="text-2xl font-semibold tracking-tight">Create Data Recipe</h1>
<p className="text-sm text-muted-foreground">
Design synthetic-data pipelines with Data Designer.
</p>
{statusMessage && (
<p className={STATUS_MESSAGE_CLASS[statusMessage.tone]}>
{statusMessage.text}
</p>
)}
</div>
<div className="flex items-center justify-start gap-2 lg:justify-end">
<Button
type="button"
size="sm"
onClick={onPreview}
disabled={previewLoading}
className="gap-2 text-xs"
>
{previewLoading ? (
<Spinner className="size-3.5" />
) : (
<HugeiconsIcon icon={EyeIcon} className="size-3.5" />
)}
Preview
</Button>
</div>
</div>
</div>
);
}

View file

@ -9,8 +9,6 @@ import {
type NodeTypes,
Panel,
ReactFlow,
useReactFlow,
useUpdateNodeInternals,
} from "@xyflow/react";
import {
type ReactElement,
@ -21,15 +19,13 @@ import {
} from "react";
import { useShallow } from "zustand/react/shallow";
import "@xyflow/react/dist/style.css";
import { Button } from "@/components/ui/button";
import { Spinner } from "@/components/ui/spinner";
import { EyeIcon } from "@hugeicons/core-free-icons";
import { HugeiconsIcon } from "@hugeicons/react";
import { Lock, LockOpen, Maximize2, Minus, Plus } from "lucide-react";
import { previewRecipe } from "./api";
import { RecipeGraphAuxNode, type RecipeGraphAuxNodeData } from "./components/recipe-graph-aux-node";
import { BlockSheet } from "./components/block-sheet";
import { RECIPE_FLOATING_ICON_BUTTON_CLASS } from "./components/recipe-floating-icon-button-class";
import { LayoutControls } from "./components/controls/layout-controls";
import { ViewportControls } from "./components/controls/viewport-controls";
import { InternalsSync } from "./components/graph/internals-sync";
import { RecipeStudioHeader } from "./components/recipe-studio-header";
import { RecipeNode } from "./components/recipe-graph-node";
import { RecipeGraphSemanticEdge } from "./components/recipe-graph-semantic-edge";
import { DataEdge } from "./components/rf-ui/data-edge";
@ -51,165 +47,12 @@ import { buildDefaultSchemaTransform } from "./utils/processors";
const NODE_TYPES: NodeTypes = { builder: RecipeNode, aux: RecipeGraphAuxNode };
const EDGE_TYPES: EdgeTypes = { canvas: DataEdge, semantic: RecipeGraphSemanticEdge };
type LayoutControlsProps = {
direction: "LR" | "TB";
onLayout: () => void;
onToggleDirection: () => void;
};
type ViewportControlsProps = {
interactive: boolean;
onToggleInteractive: () => void;
};
type InternalsSyncProps = {
nodeIds: string[];
};
type StatusTone = "success" | "error";
type StatusMessage = {
tone: StatusTone;
text: string;
};
const STATUS_MESSAGE_CLASS: Record<StatusTone, string> = {
success: "mt-2 text-xs text-emerald-600",
error: "mt-2 text-xs text-rose-600",
};
function InternalsSync({ nodeIds }: InternalsSyncProps): null {
const updateNodeInternals = useUpdateNodeInternals();
useEffect(() => {
if (nodeIds.length === 0) {
return;
}
requestAnimationFrame(() => {
updateNodeInternals(nodeIds);
requestAnimationFrame(() => {
updateNodeInternals(nodeIds);
});
});
}, [nodeIds, updateNodeInternals]);
return null;
}
function LayoutControls({
direction,
onLayout,
onToggleDirection,
}: LayoutControlsProps): ReactElement {
const { fitView, getNodes } = useReactFlow();
const updateNodeInternals = useUpdateNodeInternals();
const refreshNodeInternals = useCallback(() => {
const nodeIds = getNodes().map((node) => node.id);
if (nodeIds.length > 0) {
updateNodeInternals(nodeIds);
}
}, [getNodes, updateNodeInternals]);
const handleLayout = useCallback(() => {
onLayout();
requestAnimationFrame(() => {
refreshNodeInternals();
requestAnimationFrame(() => {
fitView({ duration: 250 });
});
});
}, [fitView, onLayout, refreshNodeInternals]);
const handleToggleDirection = useCallback(() => {
onToggleDirection();
requestAnimationFrame(() => {
refreshNodeInternals();
requestAnimationFrame(() => {
refreshNodeInternals();
});
});
}, [onToggleDirection, refreshNodeInternals]);
return (
<Panel position="top-left" className="m-3 flex items-center gap-2">
<Button size="sm" className="corner-squircle" variant="secondary" onClick={handleLayout}>
Auto layout
</Button>
<Button size="sm" className="corner-squircle" variant="outline" onClick={handleToggleDirection}>
{direction}
</Button>
</Panel>
);
}
function ViewportControls({
interactive,
onToggleInteractive,
}: ViewportControlsProps): ReactElement {
const { zoomIn, zoomOut, fitView } = useReactFlow();
const handleZoomIn = useCallback(() => {
zoomIn({ duration: 150 });
}, [zoomIn]);
const handleZoomOut = useCallback(() => {
zoomOut({ duration: 150 });
}, [zoomOut]);
const handleFitView = useCallback(() => {
fitView({ duration: 250 });
}, [fitView]);
return (
<Panel position="bottom-left" className="m-3 flex items-center gap-2">
<Button
type="button"
variant="ghost"
size="icon"
className={RECIPE_FLOATING_ICON_BUTTON_CLASS}
onClick={handleZoomIn}
aria-label="Zoom in"
>
<Plus className="size-4" />
</Button>
<Button
type="button"
variant="ghost"
size="icon"
className={RECIPE_FLOATING_ICON_BUTTON_CLASS}
onClick={handleZoomOut}
aria-label="Zoom out"
>
<Minus className="size-4" />
</Button>
<Button
type="button"
variant="ghost"
size="icon"
className={RECIPE_FLOATING_ICON_BUTTON_CLASS}
onClick={handleFitView}
aria-label="Fit view"
>
<Maximize2 className="size-4" />
</Button>
<Button
type="button"
variant="ghost"
size="icon"
className={RECIPE_FLOATING_ICON_BUTTON_CLASS}
onClick={onToggleInteractive}
aria-label={interactive ? "Lock interaction" : "Unlock interaction"}
>
{interactive ? (
<LockOpen className="size-4" />
) : (
<Lock className="size-4" />
)}
</Button>
</Panel>
);
}
export function RecipeStudioPage(): ReactElement {
const {
nodes,
@ -495,37 +338,11 @@ export function RecipeStudioPage(): ReactElement {
return (
<div className="min-h-screen bg-background">
<main className="w-full px-6 py-8">
<div className="mb-6 flex flex-col gap-4">
<div className="flex flex-col gap-4 lg:grid lg:grid-cols-[1fr_auto] lg:items-center">
<div>
<h1 className="text-2xl font-semibold tracking-tight">Create Data Recipe</h1>
<p className="text-sm text-muted-foreground">
Design synthetic-data pipelines with Data Designer.
</p>
{statusMessage && (
<p className={STATUS_MESSAGE_CLASS[statusMessage.tone]}>
{statusMessage.text}
</p>
)}
</div>
<div className="flex items-center justify-start gap-2 lg:justify-end">
<Button
type="button"
size="sm"
onClick={handlePreview}
disabled={previewLoading}
className="gap-2 text-xs"
>
{previewLoading ? (
<Spinner className="size-3.5" />
) : (
<HugeiconsIcon icon={EyeIcon} className="size-3.5" />
)}
Preview
</Button>
</div>
</div>
</div>
<RecipeStudioHeader
previewLoading={previewLoading}
statusMessage={statusMessage}
onPreview={handlePreview}
/>
<div
className="relative h-[75vh] w-full rounded-2xl corner-squircle border "
ref={setSheetContainer}

View file

@ -0,0 +1,206 @@
import { type Edge, addEdge } from "@xyflow/react";
import type {
ModelConfig,
NodeConfig,
SamplerConfig,
} from "../../types";
import { isCategoryConfig, isSubcategoryConfig } from "../../utils";
import { HANDLE_IDS } from "../../utils/handles";
function findNodeIdByName(
configs: Record<string, NodeConfig>,
name: string,
): string | null {
const entry = Object.entries(configs).find(
([, config]) => config.name === name,
);
return entry ? entry[0] : null;
}
function addRecipeEdge(edges: Edge[], source: string, target: string): Edge[] {
return addEdge(
{
source,
target,
sourceHandle: HANDLE_IDS.dataOut,
targetHandle: HANDLE_IDS.dataIn,
type: "canvas",
},
edges,
);
}
function addSemanticEdge(edges: Edge[], source: string, target: string): Edge[] {
return addEdge(
{
source,
target,
sourceHandle: HANDLE_IDS.semanticOut,
targetHandle: HANDLE_IDS.semanticIn,
type: "semantic",
},
edges,
);
}
function removeTargetEdges(edges: Edge[], targetId: string): Edge[] {
return edges.filter((edge) => edge.target !== targetId);
}
function removeTargetEdgesBySource(
edges: Edge[],
configs: Record<string, NodeConfig>,
targetId: string,
shouldRemove: (source: NodeConfig | undefined) => boolean,
): Edge[] {
return edges.filter((edge) => {
if (edge.target !== targetId) {
return true;
}
return !shouldRemove(configs[edge.source]);
});
}
export function syncEdgesForConfigPatch(
current: NodeConfig,
patch: Partial<NodeConfig>,
configs: Record<string, NodeConfig>,
edges: Edge[],
): Edge[] {
let nextEdges = edges;
const hasParentPatch = Object.prototype.hasOwnProperty.call(
patch,
"subcategory_parent",
);
if (isSubcategoryConfig(current) && hasParentPatch) {
const nextParent = (patch as Partial<SamplerConfig>).subcategory_parent ?? "";
const parentId = nextParent ? findNodeIdByName(configs, nextParent) : null;
nextEdges = removeTargetEdges(nextEdges, current.id);
if (parentId) {
nextEdges = addRecipeEdge(nextEdges, parentId, current.id);
}
}
const hasProviderPatch = Object.prototype.hasOwnProperty.call(
patch,
"provider",
);
if (current.kind === "model_config" && hasProviderPatch) {
const nextProvider = (patch as Partial<ModelConfig>).provider ?? "";
nextEdges = removeTargetEdgesBySource(
nextEdges,
configs,
current.id,
(source) => Boolean(source && source.kind === "model_provider"),
);
if (nextProvider) {
const providerId = findNodeIdByName(configs, nextProvider);
if (providerId) {
nextEdges = addSemanticEdge(nextEdges, providerId, current.id);
}
}
}
const hasReferencePatch = Object.prototype.hasOwnProperty.call(
patch,
"reference_column_name",
);
if (
current.kind === "sampler" &&
current.sampler_type === "timedelta" &&
hasReferencePatch
) {
const nextReference =
(patch as Partial<SamplerConfig>).reference_column_name ?? "";
nextEdges = removeTargetEdgesBySource(
nextEdges,
configs,
current.id,
(source) =>
Boolean(
source &&
source.kind === "sampler" &&
source.sampler_type === "datetime",
),
);
if (nextReference) {
const referenceId = findNodeIdByName(configs, nextReference);
const source = referenceId ? configs[referenceId] : null;
if (
referenceId &&
source &&
source.kind === "sampler" &&
source.sampler_type === "datetime"
) {
nextEdges = addRecipeEdge(nextEdges, referenceId, current.id);
}
}
}
const hasModelAliasPatch = Object.prototype.hasOwnProperty.call(
patch,
"model_alias",
);
if (current.kind === "llm" && hasModelAliasPatch) {
const nextAlias =
(patch as Partial<NodeConfig> & { model_alias?: string }).model_alias ?? "";
nextEdges = removeTargetEdgesBySource(
nextEdges,
configs,
current.id,
(source) => Boolean(source && source.kind === "model_config"),
);
if (nextAlias) {
const modelConfigId = findNodeIdByName(configs, nextAlias);
if (modelConfigId) {
nextEdges = addSemanticEdge(nextEdges, modelConfigId, current.id);
}
}
}
return nextEdges;
}
export function syncSubcategoryConfigsForCategoryUpdate(
current: NodeConfig,
next: NodeConfig,
configs: Record<string, NodeConfig>,
oldName: string,
newName: string,
nameChanged: boolean,
): Record<string, NodeConfig> {
if (!isCategoryConfig(current)) {
return configs;
}
const nextCategory = isCategoryConfig(next) ? next : current;
const oldValues = current.values ?? [];
const newValues = nextCategory.values ?? [];
const valuesChanged =
oldValues.length !== newValues.length ||
oldValues.some((value, index) => value !== newValues[index]);
let nextConfigs = configs;
for (const config of Object.values(configs)) {
if (!isSubcategoryConfig(config)) {
continue;
}
if (config.subcategory_parent !== oldName) {
continue;
}
const mapping = config.subcategory_mapping ?? {};
const nextMapping: Record<string, string[]> = {};
for (const value of newValues) {
nextMapping[value] = mapping[value] ?? [];
}
const updated: NodeConfig = {
...config,
// biome-ignore lint/style/useNamingConvention: api schema
subcategory_parent: nameChanged ? newName : config.subcategory_parent,
// biome-ignore lint/style/useNamingConvention: api schema
subcategory_mapping: valuesChanged ? nextMapping : mapping,
};
nextConfigs = { ...nextConfigs, [config.id]: updated };
}
return nextConfigs;
}

View file

@ -0,0 +1,78 @@
import { DEFAULT_NODE_WIDTH } from "../../constants";
import type {
RecipeNode,
LayoutDirection,
NodeConfig,
} from "../../types";
import { nodeDataFromConfig } from "../../utils";
import { getConfigUiMode } from "../../components/inline/inline-policy";
export type NodeUpdateState = {
configs: Record<string, NodeConfig>;
nodes: RecipeNode[];
nextId: number;
nextY: number;
};
export type NodeUpdateResult = {
configs: Record<string, NodeConfig>;
nodes: RecipeNode[];
nextId: number;
nextY: number;
activeConfigId: string;
dialogOpen: boolean;
};
export function updateNodeData(
nodes: RecipeNode[],
id: string,
config: NodeConfig,
layoutDirection: LayoutDirection,
): RecipeNode[] {
return nodes.map((node) =>
node.id === id
? { ...node, data: nodeDataFromConfig(config, layoutDirection) }
: node,
);
}
export function buildNodeUpdate(
state: NodeUpdateState,
config: NodeConfig,
layoutDirection: LayoutDirection,
): NodeUpdateResult {
const node: RecipeNode = {
id: config.id,
type: "builder",
position: { x: 0, y: state.nextY },
data: nodeDataFromConfig(config, layoutDirection),
style: { width: DEFAULT_NODE_WIDTH },
selected: true,
};
const mode = getConfigUiMode(config);
return {
configs: { ...state.configs, [config.id]: config },
nodes: [...state.nodes.map((item) => ({ ...item, selected: false })), node],
nextId: state.nextId + 1,
nextY: state.nextY + 140,
activeConfigId: config.id,
dialogOpen: mode === "dialog",
};
}
export function applyLayoutDirectionToNodes(
nodes: RecipeNode[],
configs: Record<string, NodeConfig>,
layoutDirection: LayoutDirection,
): RecipeNode[] {
return nodes.map((node) => {
const config = configs[node.id];
if (config) {
return { ...node, data: nodeDataFromConfig(config, layoutDirection) };
}
return {
...node,
data: { ...node.data, layoutDirection },
};
});
}

View file

@ -0,0 +1,169 @@
import type {
LlmConfig,
ModelConfig,
NodeConfig,
SamplerConfig,
} from "../../types";
import { removeRef, replaceRef } from "../../utils/refs";
function updateTemplateFields(
config: NodeConfig,
updater: (value: string) => string,
): NodeConfig {
if (config.kind === "llm") {
const nextPrompt = updater(config.prompt);
const nextSystem = updater(config.system_prompt);
const nextOutput =
typeof config.output_format === "string"
? updater(config.output_format)
: config.output_format;
if (
nextPrompt === config.prompt &&
nextSystem === config.system_prompt &&
nextOutput === config.output_format
) {
return config;
}
return {
...config,
prompt: nextPrompt,
// biome-ignore lint/style/useNamingConvention: api schema
system_prompt: nextSystem,
// biome-ignore lint/style/useNamingConvention: api schema
output_format: nextOutput,
};
}
if (config.kind === "expression") {
const nextExpr = updater(config.expr);
if (nextExpr === config.expr) {
return config;
}
return { ...config, expr: nextExpr };
}
return config;
}
export function applyRenameToConfig(
config: NodeConfig,
from: string,
to: string,
): NodeConfig {
let next = updateTemplateFields(config, (value) =>
replaceRef(value, from, to),
);
if (
config.kind === "sampler" &&
config.sampler_type === "subcategory" &&
config.subcategory_parent === from
) {
const base = next as SamplerConfig;
next = {
...base,
// biome-ignore lint/style/useNamingConvention: api schema
subcategory_parent: to,
};
}
if (
config.kind === "sampler" &&
config.sampler_type === "timedelta" &&
config.reference_column_name === from
) {
const base = next as SamplerConfig;
next = {
...base,
// biome-ignore lint/style/useNamingConvention: api schema
reference_column_name: to,
};
}
if (config.kind === "model_config" && config.provider === from) {
const base = next as ModelConfig;
next = { ...base, provider: to };
}
if (config.kind === "llm" && config.model_alias === from) {
const base = next as LlmConfig;
next = { ...base, model_alias: to };
}
return next;
}
export function applyRemovalToConfig(
config: NodeConfig,
ref: string,
): NodeConfig {
let next = updateTemplateFields(config, (value) => removeRef(value, ref));
if (
config.kind === "sampler" &&
config.sampler_type === "subcategory" &&
config.subcategory_parent === ref
) {
const base = next as SamplerConfig;
next = {
...base,
// biome-ignore lint/style/useNamingConvention: api schema
subcategory_parent: "",
// biome-ignore lint/style/useNamingConvention: api schema
subcategory_mapping: {},
};
}
if (
config.kind === "sampler" &&
config.sampler_type === "timedelta" &&
config.reference_column_name === ref
) {
const base = next as SamplerConfig;
next = {
...base,
// biome-ignore lint/style/useNamingConvention: api schema
reference_column_name: "",
};
}
if (config.kind === "model_config" && config.provider === ref) {
const base = next as ModelConfig;
next = { ...base, provider: "" };
}
if (config.kind === "llm" && config.model_alias === ref) {
const base = next as LlmConfig;
next = { ...base, model_alias: "" };
}
return next;
}
function applyConfigTransform(
configs: Record<string, NodeConfig>,
transform: (config: NodeConfig) => NodeConfig,
): Record<string, NodeConfig> {
let next = configs;
for (const [id, config] of Object.entries(configs)) {
const updated = transform(config);
if (updated !== config) {
if (next === configs) {
next = { ...configs };
}
next[id] = updated;
}
}
return next;
}
export function applyRenameToConfigs(
configs: Record<string, NodeConfig>,
from: string,
to: string,
): Record<string, NodeConfig> {
if (!from || from === to) {
return configs;
}
return applyConfigTransform(configs, (config) =>
applyRenameToConfig(config, from, to),
);
}
export function applyRemovalToConfigs(
configs: Record<string, NodeConfig>,
ref: string,
): Record<string, NodeConfig> {
if (!ref) {
return configs;
}
return applyConfigTransform(configs, (config) => applyRemovalToConfig(config, ref));
}

View file

@ -1,444 +1,17 @@
import { type Edge, addEdge } from "@xyflow/react";
import { DEFAULT_NODE_WIDTH } from "../constants";
import type {
RecipeNode,
LayoutDirection,
LlmConfig,
ModelConfig,
NodeConfig,
SamplerConfig,
} from "../types";
import { isCategoryConfig, isSubcategoryConfig, nodeDataFromConfig } from "../utils";
import { HANDLE_IDS } from "../utils/handles";
import { removeRef, replaceRef } from "../utils/refs";
import { getConfigUiMode } from "../components/inline/inline-policy";
type NodeUpdateState = {
configs: Record<string, NodeConfig>;
nodes: RecipeNode[];
nextId: number;
nextY: number;
};
type NodeUpdateResult = {
configs: Record<string, NodeConfig>;
nodes: RecipeNode[];
nextId: number;
nextY: number;
activeConfigId: string;
dialogOpen: boolean;
};
export function updateNodeData(
nodes: RecipeNode[],
id: string,
config: NodeConfig,
layoutDirection: LayoutDirection,
): RecipeNode[] {
return nodes.map((node) =>
node.id === id
? { ...node, data: nodeDataFromConfig(config, layoutDirection) }
: node,
);
}
function findNodeIdByName(
configs: Record<string, NodeConfig>,
name: string,
): string | null {
const entry = Object.entries(configs).find(
([, config]) => config.name === name,
);
return entry ? entry[0] : null;
}
function addRecipeEdge(edges: Edge[], source: string, target: string): Edge[] {
return addEdge(
{
source,
target,
sourceHandle: HANDLE_IDS.dataOut,
targetHandle: HANDLE_IDS.dataIn,
type: "canvas",
},
edges,
);
}
function addSemanticEdge(edges: Edge[], source: string, target: string): Edge[] {
return addEdge(
{
source,
target,
sourceHandle: HANDLE_IDS.semanticOut,
targetHandle: HANDLE_IDS.semanticIn,
type: "semantic",
},
edges,
);
}
function removeTargetEdges(edges: Edge[], targetId: string): Edge[] {
return edges.filter((edge) => edge.target !== targetId);
}
function removeTargetEdgesBySource(
edges: Edge[],
configs: Record<string, NodeConfig>,
targetId: string,
shouldRemove: (source: NodeConfig | undefined) => boolean,
): Edge[] {
return edges.filter((edge) => {
if (edge.target !== targetId) {
return true;
}
return !shouldRemove(configs[edge.source]);
});
}
export function buildNodeUpdate(
state: NodeUpdateState,
config: NodeConfig,
layoutDirection: LayoutDirection,
): NodeUpdateResult {
const node: RecipeNode = {
id: config.id,
type: "builder",
position: { x: 0, y: state.nextY },
data: nodeDataFromConfig(config, layoutDirection),
style: { width: DEFAULT_NODE_WIDTH },
selected: true,
};
const mode = getConfigUiMode(config);
return {
configs: { ...state.configs, [config.id]: config },
nodes: [...state.nodes.map((item) => ({ ...item, selected: false })), node],
nextId: state.nextId + 1,
nextY: state.nextY + 140,
activeConfigId: config.id,
dialogOpen: mode === "dialog",
};
}
export function applyLayoutDirectionToNodes(
nodes: RecipeNode[],
configs: Record<string, NodeConfig>,
layoutDirection: LayoutDirection,
): RecipeNode[] {
return nodes.map((node) => {
const config = configs[node.id];
if (config) {
return { ...node, data: nodeDataFromConfig(config, layoutDirection) };
}
return {
...node,
data: { ...node.data, layoutDirection },
};
});
}
export function syncEdgesForConfigPatch(
current: NodeConfig,
patch: Partial<NodeConfig>,
configs: Record<string, NodeConfig>,
edges: Edge[],
): Edge[] {
let nextEdges = edges;
const hasParentPatch = Object.prototype.hasOwnProperty.call(
patch,
"subcategory_parent",
);
if (isSubcategoryConfig(current) && hasParentPatch) {
const nextParent = (patch as Partial<SamplerConfig>).subcategory_parent ?? "";
const parentId = nextParent ? findNodeIdByName(configs, nextParent) : null;
nextEdges = removeTargetEdges(nextEdges, current.id);
if (parentId) {
nextEdges = addRecipeEdge(nextEdges, parentId, current.id);
}
}
const hasProviderPatch = Object.prototype.hasOwnProperty.call(
patch,
"provider",
);
if (current.kind === "model_config" && hasProviderPatch) {
const nextProvider = (patch as Partial<ModelConfig>).provider ?? "";
nextEdges = removeTargetEdgesBySource(
nextEdges,
configs,
current.id,
(source) => Boolean(source && source.kind === "model_provider"),
);
if (nextProvider) {
const providerId = findNodeIdByName(configs, nextProvider);
if (providerId) {
nextEdges = addSemanticEdge(nextEdges, providerId, current.id);
}
}
}
const hasReferencePatch = Object.prototype.hasOwnProperty.call(
patch,
"reference_column_name",
);
if (
current.kind === "sampler" &&
current.sampler_type === "timedelta" &&
hasReferencePatch
) {
const nextReference =
(patch as Partial<SamplerConfig>).reference_column_name ?? "";
nextEdges = removeTargetEdgesBySource(
nextEdges,
configs,
current.id,
(source) =>
Boolean(
source &&
source.kind === "sampler" &&
source.sampler_type === "datetime",
),
);
if (nextReference) {
const referenceId = findNodeIdByName(configs, nextReference);
const source = referenceId ? configs[referenceId] : null;
if (
referenceId &&
source &&
source.kind === "sampler" &&
source.sampler_type === "datetime"
) {
nextEdges = addRecipeEdge(nextEdges, referenceId, current.id);
}
}
}
const hasModelAliasPatch = Object.prototype.hasOwnProperty.call(
patch,
"model_alias",
);
if (current.kind === "llm" && hasModelAliasPatch) {
const nextAlias =
(patch as Partial<NodeConfig> & { model_alias?: string }).model_alias ?? "";
nextEdges = removeTargetEdgesBySource(
nextEdges,
configs,
current.id,
(source) => Boolean(source && source.kind === "model_config"),
);
if (nextAlias) {
const modelConfigId = findNodeIdByName(configs, nextAlias);
if (modelConfigId) {
nextEdges = addSemanticEdge(nextEdges, modelConfigId, current.id);
}
}
}
return nextEdges;
}
export function syncSubcategoryConfigsForCategoryUpdate(
current: NodeConfig,
next: NodeConfig,
configs: Record<string, NodeConfig>,
oldName: string,
newName: string,
nameChanged: boolean,
): Record<string, NodeConfig> {
if (!isCategoryConfig(current)) {
return configs;
}
const nextCategory = isCategoryConfig(next) ? next : current;
const oldValues = current.values ?? [];
const newValues = nextCategory.values ?? [];
const valuesChanged =
oldValues.length !== newValues.length ||
oldValues.some((value, index) => value !== newValues[index]);
let nextConfigs = configs;
for (const config of Object.values(configs)) {
if (!isSubcategoryConfig(config)) {
continue;
}
if (config.subcategory_parent !== oldName) {
continue;
}
const mapping = config.subcategory_mapping ?? {};
const nextMapping: Record<string, string[]> = {};
for (const value of newValues) {
nextMapping[value] = mapping[value] ?? [];
}
const updated: NodeConfig = {
...config,
// biome-ignore lint/style/useNamingConvention: api schema
subcategory_parent: nameChanged ? newName : config.subcategory_parent,
// biome-ignore lint/style/useNamingConvention: api schema
subcategory_mapping: valuesChanged ? nextMapping : mapping,
};
nextConfigs = { ...nextConfigs, [config.id]: updated };
}
return nextConfigs;
}
function updateTemplateFields(
config: NodeConfig,
updater: (value: string) => string,
): NodeConfig {
if (config.kind === "llm") {
const nextPrompt = updater(config.prompt);
const nextSystem = updater(config.system_prompt);
const nextOutput =
typeof config.output_format === "string"
? updater(config.output_format)
: config.output_format;
if (
nextPrompt === config.prompt &&
nextSystem === config.system_prompt &&
nextOutput === config.output_format
) {
return config;
}
return {
...config,
prompt: nextPrompt,
// biome-ignore lint/style/useNamingConvention: api schema
system_prompt: nextSystem,
// biome-ignore lint/style/useNamingConvention: api schema
output_format: nextOutput,
};
}
if (config.kind === "expression") {
const nextExpr = updater(config.expr);
if (nextExpr === config.expr) {
return config;
}
return { ...config, expr: nextExpr };
}
return config;
}
export function applyRenameToConfig(
config: NodeConfig,
from: string,
to: string,
): NodeConfig {
let next = updateTemplateFields(config, (value) =>
replaceRef(value, from, to),
);
if (
config.kind === "sampler" &&
config.sampler_type === "subcategory" &&
config.subcategory_parent === from
) {
const base = next as SamplerConfig;
next = {
...base,
// biome-ignore lint/style/useNamingConvention: api schema
subcategory_parent: to,
};
}
if (
config.kind === "sampler" &&
config.sampler_type === "timedelta" &&
config.reference_column_name === from
) {
const base = next as SamplerConfig;
next = {
...base,
// biome-ignore lint/style/useNamingConvention: api schema
reference_column_name: to,
};
}
if (config.kind === "model_config" && config.provider === from) {
const base = next as ModelConfig;
next = { ...base, provider: to };
}
if (config.kind === "llm" && config.model_alias === from) {
const base = next as LlmConfig;
next = { ...base, model_alias: to };
}
return next;
}
export function applyRemovalToConfig(
config: NodeConfig,
ref: string,
): NodeConfig {
let next = updateTemplateFields(config, (value) => removeRef(value, ref));
if (
config.kind === "sampler" &&
config.sampler_type === "subcategory" &&
config.subcategory_parent === ref
) {
const base = next as SamplerConfig;
next = {
...base,
// biome-ignore lint/style/useNamingConvention: api schema
subcategory_parent: "",
// biome-ignore lint/style/useNamingConvention: api schema
subcategory_mapping: {},
};
}
if (
config.kind === "sampler" &&
config.sampler_type === "timedelta" &&
config.reference_column_name === ref
) {
const base = next as SamplerConfig;
next = {
...base,
// biome-ignore lint/style/useNamingConvention: api schema
reference_column_name: "",
};
}
if (config.kind === "model_config" && config.provider === ref) {
const base = next as ModelConfig;
next = { ...base, provider: "" };
}
if (config.kind === "llm" && config.model_alias === ref) {
const base = next as LlmConfig;
next = { ...base, model_alias: "" };
}
return next;
}
export function applyRenameToConfigs(
configs: Record<string, NodeConfig>,
from: string,
to: string,
): Record<string, NodeConfig> {
if (!from || from === to) {
return configs;
}
return applyConfigTransform(configs, (config) =>
applyRenameToConfig(config, from, to),
);
}
function applyConfigTransform(
configs: Record<string, NodeConfig>,
transform: (config: NodeConfig) => NodeConfig,
): Record<string, NodeConfig> {
let next = configs;
for (const [id, config] of Object.entries(configs)) {
const updated = transform(config);
if (updated !== config) {
if (next === configs) {
next = { ...configs };
}
next[id] = updated;
}
}
return next;
}
export function applyRemovalToConfigs(
configs: Record<string, NodeConfig>,
ref: string,
): Record<string, NodeConfig> {
if (!ref) {
return configs;
}
return applyConfigTransform(configs, (config) => applyRemovalToConfig(config, ref));
}
export {
applyLayoutDirectionToNodes,
buildNodeUpdate,
type NodeUpdateResult,
type NodeUpdateState,
updateNodeData,
} from "./helpers/node-updates";
export {
syncEdgesForConfigPatch,
syncSubcategoryConfigsForCategoryUpdate,
} from "./helpers/edge-sync";
export {
applyRemovalToConfig,
applyRemovalToConfigs,
applyRenameToConfig,
applyRenameToConfigs,
} from "./helpers/reference-sync";

View file

@ -0,0 +1,277 @@
import type {
ExpressionConfig,
LlmConfig,
LlmType,
ModelConfig,
ModelProviderConfig,
NodeConfig,
SamplerConfig,
SamplerType,
} from "../types";
import { nextName } from "./naming";
export function makeSamplerConfig(
id: string,
samplerType: SamplerType,
existing: NodeConfig[],
): SamplerConfig {
const namePrefix =
samplerType === "subcategory" ? "subcategory" : samplerType;
const name = nextName(existing, namePrefix);
if (samplerType === "category") {
return {
id,
kind: "sampler",
// biome-ignore lint/style/useNamingConvention: api schema
sampler_type: "category",
name,
drop: false,
values: ["A", "B", "C"],
weights: [null, null, null],
};
}
if (samplerType === "subcategory") {
return {
id,
kind: "sampler",
// biome-ignore lint/style/useNamingConvention: api schema
sampler_type: "subcategory",
name,
drop: false,
// biome-ignore lint/style/useNamingConvention: api schema
subcategory_parent: "",
// biome-ignore lint/style/useNamingConvention: api schema
subcategory_mapping: {
// biome-ignore lint/style/useNamingConvention: sample values
A: ["A1", "A2"],
// biome-ignore lint/style/useNamingConvention: sample values
B: ["B1", "B2"],
},
};
}
if (samplerType === "uniform") {
return {
id,
kind: "sampler",
// biome-ignore lint/style/useNamingConvention: api schema
sampler_type: "uniform",
name,
drop: false,
low: "0",
high: "1",
};
}
if (samplerType === "gaussian") {
return {
id,
kind: "sampler",
// biome-ignore lint/style/useNamingConvention: api schema
sampler_type: "gaussian",
name,
drop: false,
mean: "0",
std: "1",
};
}
if (samplerType === "bernoulli") {
return {
id,
kind: "sampler",
// biome-ignore lint/style/useNamingConvention: api schema
sampler_type: "bernoulli",
name,
drop: false,
p: "0.5",
};
}
if (samplerType === "datetime") {
return {
id,
kind: "sampler",
// biome-ignore lint/style/useNamingConvention: api schema
sampler_type: "datetime",
name,
drop: false,
// biome-ignore lint/style/useNamingConvention: api schema
datetime_start: "",
// biome-ignore lint/style/useNamingConvention: api schema
datetime_end: "",
// biome-ignore lint/style/useNamingConvention: api schema
datetime_unit: "day",
};
}
if (samplerType === "timedelta") {
return {
id,
kind: "sampler",
// biome-ignore lint/style/useNamingConvention: api schema
sampler_type: "timedelta",
name,
drop: false,
// biome-ignore lint/style/useNamingConvention: api schema
dt_min: "0",
// biome-ignore lint/style/useNamingConvention: api schema
dt_max: "1",
// biome-ignore lint/style/useNamingConvention: api schema
reference_column_name: "",
// biome-ignore lint/style/useNamingConvention: api schema
timedelta_unit: "D",
};
}
if (samplerType === "uuid") {
return {
id,
kind: "sampler",
// biome-ignore lint/style/useNamingConvention: api schema
sampler_type: "uuid",
name,
drop: false,
// biome-ignore lint/style/useNamingConvention: api schema
uuid_format: "",
};
}
if (samplerType === "person_from_faker") {
return {
id,
kind: "sampler",
// biome-ignore lint/style/useNamingConvention: api schema
sampler_type: "person_from_faker",
name,
drop: false,
// biome-ignore lint/style/useNamingConvention: api schema
person_locale: "",
// biome-ignore lint/style/useNamingConvention: api schema
person_sex: "",
// biome-ignore lint/style/useNamingConvention: api schema
person_age_range: "",
// biome-ignore lint/style/useNamingConvention: api schema
person_city: "",
};
}
return {
id,
kind: "sampler",
// biome-ignore lint/style/useNamingConvention: api schema
sampler_type: "person",
name,
drop: false,
// biome-ignore lint/style/useNamingConvention: api schema
person_locale: "",
// biome-ignore lint/style/useNamingConvention: api schema
person_sex: "",
// biome-ignore lint/style/useNamingConvention: api schema
person_age_range: "",
// biome-ignore lint/style/useNamingConvention: api schema
person_city: "",
// biome-ignore lint/style/useNamingConvention: api schema
person_with_synthetic_personas: false,
};
}
export function makeLlmConfig(
id: string,
llmType: LlmType,
existing: NodeConfig[],
): LlmConfig {
let namePrefix = "llm_text";
if (llmType === "structured") {
namePrefix = "llm_structured";
} else if (llmType === "code") {
namePrefix = "llm_code";
} else if (llmType === "judge") {
namePrefix = "llm_judge";
}
const name = nextName(existing, namePrefix);
return {
id,
kind: "llm",
// biome-ignore lint/style/useNamingConvention: api schema
llm_type: llmType,
name,
drop: false,
// biome-ignore lint/style/useNamingConvention: api schema
model_alias: "allenai/olmo-3.1-32b-instruct",
prompt:
llmType === "judge"
? "Evaluate the content using the scoring criteria below."
: "Write a response.",
// biome-ignore lint/style/useNamingConvention: api schema
system_prompt: "",
// biome-ignore lint/style/useNamingConvention: api schema
code_lang: llmType === "code" ? "python" : undefined,
// biome-ignore lint/style/useNamingConvention: api schema
output_format:
llmType === "structured" ? '{\n "field": "string"\n}' : undefined,
scores:
llmType === "judge"
? [
{
name: "Quality",
description: "Overall quality based on the criteria.",
options: [
{ value: "1", description: "Poor" },
{ value: "3", description: "Acceptable" },
{ value: "5", description: "Excellent" },
],
},
]
: undefined,
};
}
export function makeModelProviderConfig(
id: string,
existing: NodeConfig[],
): ModelProviderConfig {
return {
id,
kind: "model_provider",
name: nextName(existing, "provider"),
endpoint: "",
// biome-ignore lint/style/useNamingConvention: api schema
provider_type: "openai",
// biome-ignore lint/style/useNamingConvention: api schema
api_key_env: "",
// biome-ignore lint/style/useNamingConvention: api schema
api_key: "",
// biome-ignore lint/style/useNamingConvention: api schema
extra_headers: "",
// biome-ignore lint/style/useNamingConvention: api schema
extra_body: "",
};
}
export function makeModelConfig(
id: string,
existing: NodeConfig[],
): ModelConfig {
return {
id,
kind: "model_config",
name: nextName(existing, "model"),
model: "",
provider: "",
// biome-ignore lint/style/useNamingConvention: api schema
inference_temperature: "0.7",
// biome-ignore lint/style/useNamingConvention: api schema
inference_max_tokens: "256",
// biome-ignore lint/style/useNamingConvention: api schema
inference_top_p: "",
// biome-ignore lint/style/useNamingConvention: api schema
skip_health_check: false,
};
}
export function makeExpressionConfig(
id: string,
existing: NodeConfig[],
): ExpressionConfig {
return {
id,
kind: "expression",
name: nextName(existing, "expr"),
drop: false,
expr: "",
dtype: "str",
};
}

View file

@ -0,0 +1,44 @@
import type {
ExpressionDtype,
LlmType,
SamplerType,
} from "../types";
const SAMPLER_LABELS: Record<SamplerType, string> = {
category: "Category",
subcategory: "Subcategory",
uniform: "Uniform",
gaussian: "Gaussian",
bernoulli: "Bernoulli",
datetime: "Datetime",
timedelta: "Timedelta",
uuid: "UUID",
person: "Person",
person_from_faker: "Person (Faker)",
};
const LLM_LABELS: Record<LlmType, string> = {
text: "LLM Text",
structured: "LLM Structured",
code: "LLM Code",
judge: "LLM Judge",
};
const EXPRESSION_LABELS: Record<ExpressionDtype, string> = {
str: "Text",
int: "Int",
float: "Float",
bool: "Bool",
};
export function labelForSampler(type: SamplerType): string {
return SAMPLER_LABELS[type] ?? "Sampler";
}
export function labelForLlm(type: LlmType): string {
return LLM_LABELS[type] ?? "LLM";
}
export function labelForExpression(type: ExpressionDtype): string {
return EXPRESSION_LABELS[type] ?? "Expression";
}

View file

@ -0,0 +1,42 @@
import type {
ExpressionConfig,
LlmConfig,
NodeConfig,
SamplerConfig,
} from "../types";
export function isSamplerConfig(
config: NodeConfig | null | undefined,
): config is SamplerConfig {
return Boolean(config && config.kind === "sampler");
}
export function isCategoryConfig(
config: NodeConfig | null | undefined,
): config is SamplerConfig {
return Boolean(
config && config.kind === "sampler" && config.sampler_type === "category",
);
}
export function isSubcategoryConfig(
config: NodeConfig | null | undefined,
): config is SamplerConfig {
return Boolean(
config &&
config.kind === "sampler" &&
config.sampler_type === "subcategory",
);
}
export function isLlmConfig(
config: NodeConfig | null | undefined,
): config is LlmConfig {
return Boolean(config && config.kind === "llm");
}
export function isExpressionConfig(
config: NodeConfig | null | undefined,
): config is ExpressionConfig {
return Boolean(config && config.kind === "expression");
}

View file

@ -1,399 +1,9 @@
import type {
ExpressionConfig,
ExpressionDtype,
LlmConfig,
ModelConfig,
ModelProviderConfig,
NodeConfig,
SamplerConfig,
SamplerType,
Score,
ScoreOption,
} from "../../types";
import {
isRecord,
normalizeOutputFormat,
readNumberString,
readString,
} from "./helpers";
const SAMPLER_TYPES: SamplerType[] = [
"category",
"subcategory",
"uniform",
"gaussian",
"bernoulli",
"datetime",
"timedelta",
"uuid",
"person",
"person_from_faker",
];
const EXPRESSION_DTYPES: ExpressionDtype[] = ["str", "int", "float", "bool"];
const TIMEDELTA_UNITS = new Set(["D", "h", "m", "s"]);
function parseCategoryConditionalParams(
column: Record<string, unknown>,
): SamplerConfig["conditional_params"] {
if (!isRecord(column.conditional_params)) {
return undefined;
}
const conditional: NonNullable<SamplerConfig["conditional_params"]> = {};
for (const [condition, rawParams] of Object.entries(column.conditional_params)) {
if (!isRecord(rawParams)) {
continue;
}
if (readString(rawParams.sampler_type) !== "category") {
continue;
}
const values = Array.isArray(rawParams.values)
? rawParams.values.filter((item) => typeof item === "string")
: [];
if (values.length === 0) {
continue;
}
const weights = Array.isArray(rawParams.weights)
? rawParams.weights.map((item) => (typeof item === "number" ? item : null))
: undefined;
conditional[condition] = {
// biome-ignore lint/style/useNamingConvention: api schema
sampler_type: "category",
values,
weights,
};
}
return Object.keys(conditional).length > 0 ? conditional : undefined;
}
function parseSampler(
column: Record<string, unknown>,
name: string,
id: string,
errors: string[],
): SamplerConfig | null {
const drop = column.drop === true;
const samplerType = readString(column.sampler_type);
if (!samplerType || !SAMPLER_TYPES.includes(samplerType as SamplerType)) {
errors.push(`Sampler ${name}: unsupported sampler_type.`);
return null;
}
const convertTo = readString(column.convert_to);
const normalizedConvertTo =
convertTo && ["float", "int", "str"].includes(convertTo)
? (convertTo as "float" | "int" | "str")
: undefined;
const params =
typeof column.params === "object" && column.params
? (column.params as Record<string, unknown>)
: {};
if (samplerType === "category") {
const values = Array.isArray(params.values)
? params.values.filter((item) => typeof item === "string")
: [];
const weights = Array.isArray(params.weights)
? params.weights.map((item) => (typeof item === "number" ? item : null))
: [];
return {
id,
kind: "sampler",
// biome-ignore lint/style/useNamingConvention: api schema
sampler_type: "category",
name,
drop,
// biome-ignore lint/style/useNamingConvention: api schema
convert_to: normalizedConvertTo,
values,
weights,
// biome-ignore lint/style/useNamingConvention: api schema
conditional_params: parseCategoryConditionalParams(column),
};
}
if (samplerType === "subcategory") {
const mapping: Record<string, string[]> = {};
if (params.values && typeof params.values === "object") {
for (const [key, value] of Object.entries(params.values)) {
if (Array.isArray(value)) {
mapping[key] = value.filter((item) => typeof item === "string");
}
}
}
return {
id,
kind: "sampler",
// biome-ignore lint/style/useNamingConvention: api schema
sampler_type: "subcategory",
name,
drop,
// biome-ignore lint/style/useNamingConvention: api schema
convert_to: normalizedConvertTo,
// biome-ignore lint/style/useNamingConvention: api schema
subcategory_parent: readString(params.category) ?? "",
// biome-ignore lint/style/useNamingConvention: api schema
subcategory_mapping: mapping,
};
}
if (samplerType === "uniform") {
return {
id,
kind: "sampler",
// biome-ignore lint/style/useNamingConvention: api schema
sampler_type: "uniform",
name,
drop,
// biome-ignore lint/style/useNamingConvention: api schema
convert_to: normalizedConvertTo,
low: readNumberString(params.low),
high: readNumberString(params.high),
};
}
if (samplerType === "gaussian") {
return {
id,
kind: "sampler",
// biome-ignore lint/style/useNamingConvention: api schema
sampler_type: "gaussian",
name,
drop,
// biome-ignore lint/style/useNamingConvention: api schema
convert_to: normalizedConvertTo,
mean: readNumberString(params.mean),
std: readNumberString(params.std),
};
}
if (samplerType === "bernoulli") {
return {
id,
kind: "sampler",
// biome-ignore lint/style/useNamingConvention: api schema
sampler_type: "bernoulli",
name,
drop,
// biome-ignore lint/style/useNamingConvention: api schema
convert_to: normalizedConvertTo,
p: readNumberString(params.p),
};
}
if (samplerType === "datetime") {
return {
id,
kind: "sampler",
// biome-ignore lint/style/useNamingConvention: api schema
sampler_type: "datetime",
name,
drop,
// biome-ignore lint/style/useNamingConvention: api schema
convert_to: normalizedConvertTo,
// biome-ignore lint/style/useNamingConvention: api schema
datetime_start: readString(params.start) ?? "",
// biome-ignore lint/style/useNamingConvention: api schema
datetime_end: readString(params.end) ?? "",
// biome-ignore lint/style/useNamingConvention: api schema
datetime_unit: readString(params.unit) ?? "",
};
}
if (samplerType === "timedelta") {
const rawUnit = readString(params.unit);
const unit =
rawUnit && TIMEDELTA_UNITS.has(rawUnit)
? (rawUnit as "D" | "h" | "m" | "s")
: "D";
return {
id,
kind: "sampler",
// biome-ignore lint/style/useNamingConvention: api schema
sampler_type: "timedelta",
name,
drop,
// biome-ignore lint/style/useNamingConvention: api schema
convert_to: normalizedConvertTo,
// biome-ignore lint/style/useNamingConvention: api schema
dt_min: readNumberString(params.dt_min),
// biome-ignore lint/style/useNamingConvention: api schema
dt_max: readNumberString(params.dt_max),
// biome-ignore lint/style/useNamingConvention: api schema
reference_column_name: readString(params.reference_column_name) ?? "",
// biome-ignore lint/style/useNamingConvention: api schema
timedelta_unit: unit,
};
}
if (samplerType === "uuid") {
return {
id,
kind: "sampler",
// biome-ignore lint/style/useNamingConvention: api schema
sampler_type: "uuid",
name,
drop,
// biome-ignore lint/style/useNamingConvention: api schema
convert_to: normalizedConvertTo,
// biome-ignore lint/style/useNamingConvention: api schema
uuid_format: readString(params.format) ?? "",
};
}
const ageRange =
Array.isArray(params.age_range) &&
params.age_range.length === 2 &&
params.age_range.every((item) => typeof item === "number")
? `${params.age_range[0]}-${params.age_range[1]}`
: readString(params.age_range) ?? "";
const base: SamplerConfig = {
id,
kind: "sampler",
name,
drop,
// biome-ignore lint/style/useNamingConvention: api schema
sampler_type: samplerType as SamplerType,
// biome-ignore lint/style/useNamingConvention: api schema
convert_to: normalizedConvertTo,
// biome-ignore lint/style/useNamingConvention: api schema
person_locale: readString(params.locale) ?? "",
// biome-ignore lint/style/useNamingConvention: api schema
person_sex: readString(params.sex) ?? "",
// biome-ignore lint/style/useNamingConvention: api schema
person_age_range: ageRange,
// biome-ignore lint/style/useNamingConvention: api schema
person_city: readString(params.city) ?? "",
};
if (samplerType === "person") {
return {
...base,
// biome-ignore lint/style/useNamingConvention: api schema
person_with_synthetic_personas:
typeof params.with_synthetic_personas === "boolean"
? params.with_synthetic_personas
: false,
};
}
return base;
}
function parseLlm(
column: Record<string, unknown>,
name: string,
id: string,
): LlmConfig {
const columnType = readString(column.column_type) ?? "llm-text";
let llmType: LlmConfig["llm_type"] = "text";
if (columnType === "llm-structured") {
llmType = "structured";
} else if (columnType === "llm-code") {
llmType = "code";
} else if (columnType === "llm-judge") {
llmType = "judge";
}
const scores: Score[] =
columnType === "llm-judge" && Array.isArray(column.scores)
? column.scores
.filter((score) => isRecord(score))
.map((score) => {
const options: ScoreOption[] = [];
const rawOptions = isRecord(score.options) ? score.options : {};
for (const [key, value] of Object.entries(rawOptions)) {
const description =
typeof value === "string" ? value : JSON.stringify(value);
options.push({ value: String(key), description });
}
return {
name: readString(score.name) ?? "",
description: readString(score.description) ?? "",
options,
};
})
: [];
return {
id,
kind: "llm",
// biome-ignore lint/style/useNamingConvention: api schema
llm_type: llmType,
name,
drop: column.drop === true,
// biome-ignore lint/style/useNamingConvention: api schema
model_alias: readString(column.model_alias) ?? "",
prompt: readString(column.prompt) ?? "",
// biome-ignore lint/style/useNamingConvention: api schema
system_prompt: readString(column.system_prompt) ?? "",
// biome-ignore lint/style/useNamingConvention: api schema
code_lang: readString(column.code_lang) ?? "",
// biome-ignore lint/style/useNamingConvention: api schema
output_format: normalizeOutputFormat(column.output_format),
scores: llmType === "judge" ? scores : undefined,
};
}
export function parseModelProvider(
provider: Record<string, unknown>,
name: string,
id: string,
): ModelProviderConfig {
return {
id,
kind: "model_provider",
name,
endpoint: readString(provider.endpoint) ?? "",
// biome-ignore lint/style/useNamingConvention: api schema
provider_type: readString(provider.provider_type) ?? "openai",
// biome-ignore lint/style/useNamingConvention: api schema
api_key_env: readString(provider.api_key_env) ?? "",
// biome-ignore lint/style/useNamingConvention: api schema
api_key: readString(provider.api_key) ?? "",
// biome-ignore lint/style/useNamingConvention: api schema
extra_headers: isRecord(provider.extra_headers)
? JSON.stringify(provider.extra_headers, null, 2)
: "",
// biome-ignore lint/style/useNamingConvention: api schema
extra_body: isRecord(provider.extra_body)
? JSON.stringify(provider.extra_body, null, 2)
: "",
};
}
export function parseModelConfig(
model: Record<string, unknown>,
name: string,
id: string,
): ModelConfig {
const inference = isRecord(model.inference_parameters)
? (model.inference_parameters as Record<string, unknown>)
: {};
return {
id,
kind: "model_config",
name,
model: readString(model.model) ?? "",
provider: readString(model.provider) ?? "",
// biome-ignore lint/style/useNamingConvention: api schema
inference_temperature: readNumberString(inference.temperature),
// biome-ignore lint/style/useNamingConvention: api schema
inference_top_p: readNumberString(inference.top_p),
// biome-ignore lint/style/useNamingConvention: api schema
inference_max_tokens: readNumberString(inference.max_tokens),
// biome-ignore lint/style/useNamingConvention: api schema
skip_health_check:
typeof model.skip_health_check === "boolean"
? model.skip_health_check
: false,
};
}
function parseExpression(
column: Record<string, unknown>,
name: string,
id: string,
): ExpressionConfig {
const dtype = readString(column.dtype);
const normalized = EXPRESSION_DTYPES.includes(dtype as ExpressionDtype)
? (dtype as ExpressionDtype)
: "str";
return {
id,
kind: "expression",
name,
drop: column.drop === true,
expr: readString(column.expr) ?? "",
dtype: normalized,
};
}
import type { NodeConfig } from "../../types";
import { readString } from "./helpers";
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";
type ColumnParser = (
column: Record<string, unknown>,

View file

@ -0,0 +1,26 @@
import type {
ExpressionConfig,
ExpressionDtype,
} from "../../../types";
import { readString } from "../helpers";
const EXPRESSION_DTYPES: ExpressionDtype[] = ["str", "int", "float", "bool"];
export function parseExpression(
column: Record<string, unknown>,
name: string,
id: string,
): ExpressionConfig {
const dtype = readString(column.dtype);
const normalized = EXPRESSION_DTYPES.includes(dtype as ExpressionDtype)
? (dtype as ExpressionDtype)
: "str";
return {
id,
kind: "expression",
name,
drop: column.drop === true,
expr: readString(column.expr) ?? "",
dtype: normalized,
};
}

View file

@ -0,0 +1,65 @@
import type {
LlmConfig,
Score,
ScoreOption,
} from "../../../types";
import {
isRecord,
normalizeOutputFormat,
readString,
} from "../helpers";
export function parseLlm(
column: Record<string, unknown>,
name: string,
id: string,
): LlmConfig {
const columnType = readString(column.column_type) ?? "llm-text";
let llmType: LlmConfig["llm_type"] = "text";
if (columnType === "llm-structured") {
llmType = "structured";
} else if (columnType === "llm-code") {
llmType = "code";
} else if (columnType === "llm-judge") {
llmType = "judge";
}
const scores: Score[] =
columnType === "llm-judge" && Array.isArray(column.scores)
? column.scores
.filter((score) => isRecord(score))
.map((score) => {
const options: ScoreOption[] = [];
const rawOptions = isRecord(score.options) ? score.options : {};
for (const [key, value] of Object.entries(rawOptions)) {
const description =
typeof value === "string" ? value : JSON.stringify(value);
options.push({ value: String(key), description });
}
return {
name: readString(score.name) ?? "",
description: readString(score.description) ?? "",
options,
};
})
: [];
return {
id,
kind: "llm",
// biome-ignore lint/style/useNamingConvention: api schema
llm_type: llmType,
name,
drop: column.drop === true,
// biome-ignore lint/style/useNamingConvention: api schema
model_alias: readString(column.model_alias) ?? "",
prompt: readString(column.prompt) ?? "",
// biome-ignore lint/style/useNamingConvention: api schema
system_prompt: readString(column.system_prompt) ?? "",
// biome-ignore lint/style/useNamingConvention: api schema
code_lang: readString(column.code_lang) ?? "",
// biome-ignore lint/style/useNamingConvention: api schema
output_format: normalizeOutputFormat(column.output_format),
scores: llmType === "judge" ? scores : undefined,
};
}

View file

@ -0,0 +1,64 @@
import type {
ModelConfig,
ModelProviderConfig,
} from "../../../types";
import {
isRecord,
readNumberString,
readString,
} from "../helpers";
export function parseModelProvider(
provider: Record<string, unknown>,
name: string,
id: string,
): ModelProviderConfig {
return {
id,
kind: "model_provider",
name,
endpoint: readString(provider.endpoint) ?? "",
// biome-ignore lint/style/useNamingConvention: api schema
provider_type: readString(provider.provider_type) ?? "openai",
// biome-ignore lint/style/useNamingConvention: api schema
api_key_env: readString(provider.api_key_env) ?? "",
// biome-ignore lint/style/useNamingConvention: api schema
api_key: readString(provider.api_key) ?? "",
// biome-ignore lint/style/useNamingConvention: api schema
extra_headers: isRecord(provider.extra_headers)
? JSON.stringify(provider.extra_headers, null, 2)
: "",
// biome-ignore lint/style/useNamingConvention: api schema
extra_body: isRecord(provider.extra_body)
? JSON.stringify(provider.extra_body, null, 2)
: "",
};
}
export function parseModelConfig(
model: Record<string, unknown>,
name: string,
id: string,
): ModelConfig {
const inference = isRecord(model.inference_parameters)
? (model.inference_parameters as Record<string, unknown>)
: {};
return {
id,
kind: "model_config",
name,
model: readString(model.model) ?? "",
provider: readString(model.provider) ?? "",
// biome-ignore lint/style/useNamingConvention: api schema
inference_temperature: readNumberString(inference.temperature),
// biome-ignore lint/style/useNamingConvention: api schema
inference_top_p: readNumberString(inference.top_p),
// biome-ignore lint/style/useNamingConvention: api schema
inference_max_tokens: readNumberString(inference.max_tokens),
// biome-ignore lint/style/useNamingConvention: api schema
skip_health_check:
typeof model.skip_health_check === "boolean"
? model.skip_health_check
: false,
};
}

View file

@ -0,0 +1,271 @@
import type {
SamplerConfig,
SamplerType,
} from "../../../types";
import {
isRecord,
readNumberString,
readString,
} from "../helpers";
const SAMPLER_TYPES: SamplerType[] = [
"category",
"subcategory",
"uniform",
"gaussian",
"bernoulli",
"datetime",
"timedelta",
"uuid",
"person",
"person_from_faker",
];
const TIMEDELTA_UNITS = new Set(["D", "h", "m", "s"]);
function parseCategoryConditionalParams(
column: Record<string, unknown>,
): SamplerConfig["conditional_params"] {
if (!isRecord(column.conditional_params)) {
return undefined;
}
const conditional: NonNullable<SamplerConfig["conditional_params"]> = {};
for (const [condition, rawParams] of Object.entries(column.conditional_params)) {
if (!isRecord(rawParams)) {
continue;
}
if (readString(rawParams.sampler_type) !== "category") {
continue;
}
const values = Array.isArray(rawParams.values)
? rawParams.values.filter((item) => typeof item === "string")
: [];
if (values.length === 0) {
continue;
}
const weights = Array.isArray(rawParams.weights)
? rawParams.weights.map((item) => (typeof item === "number" ? item : null))
: undefined;
conditional[condition] = {
// biome-ignore lint/style/useNamingConvention: api schema
sampler_type: "category",
values,
weights,
};
}
return Object.keys(conditional).length > 0 ? conditional : undefined;
}
export function parseSampler(
column: Record<string, unknown>,
name: string,
id: string,
errors: string[],
): SamplerConfig | null {
const drop = column.drop === true;
const samplerType = readString(column.sampler_type);
if (!samplerType || !SAMPLER_TYPES.includes(samplerType as SamplerType)) {
errors.push(`Sampler ${name}: unsupported sampler_type.`);
return null;
}
const convertTo = readString(column.convert_to);
const normalizedConvertTo =
convertTo && ["float", "int", "str"].includes(convertTo)
? (convertTo as "float" | "int" | "str")
: undefined;
const params =
typeof column.params === "object" && column.params
? (column.params as Record<string, unknown>)
: {};
if (samplerType === "category") {
const values = Array.isArray(params.values)
? params.values.filter((item) => typeof item === "string")
: [];
const weights = Array.isArray(params.weights)
? params.weights.map((item) => (typeof item === "number" ? item : null))
: [];
return {
id,
kind: "sampler",
// biome-ignore lint/style/useNamingConvention: api schema
sampler_type: "category",
name,
drop,
// biome-ignore lint/style/useNamingConvention: api schema
convert_to: normalizedConvertTo,
values,
weights,
// biome-ignore lint/style/useNamingConvention: api schema
conditional_params: parseCategoryConditionalParams(column),
};
}
if (samplerType === "subcategory") {
const mapping: Record<string, string[]> = {};
if (params.values && typeof params.values === "object") {
for (const [key, value] of Object.entries(params.values)) {
if (Array.isArray(value)) {
mapping[key] = value.filter((item) => typeof item === "string");
}
}
}
return {
id,
kind: "sampler",
// biome-ignore lint/style/useNamingConvention: api schema
sampler_type: "subcategory",
name,
drop,
// biome-ignore lint/style/useNamingConvention: api schema
convert_to: normalizedConvertTo,
// biome-ignore lint/style/useNamingConvention: api schema
subcategory_parent: readString(params.category) ?? "",
// biome-ignore lint/style/useNamingConvention: api schema
subcategory_mapping: mapping,
};
}
if (samplerType === "uniform") {
return {
id,
kind: "sampler",
// biome-ignore lint/style/useNamingConvention: api schema
sampler_type: "uniform",
name,
drop,
// biome-ignore lint/style/useNamingConvention: api schema
convert_to: normalizedConvertTo,
low: readNumberString(params.low),
high: readNumberString(params.high),
};
}
if (samplerType === "gaussian") {
return {
id,
kind: "sampler",
// biome-ignore lint/style/useNamingConvention: api schema
sampler_type: "gaussian",
name,
drop,
// biome-ignore lint/style/useNamingConvention: api schema
convert_to: normalizedConvertTo,
mean: readNumberString(params.mean),
std: readNumberString(params.std),
};
}
if (samplerType === "bernoulli") {
return {
id,
kind: "sampler",
// biome-ignore lint/style/useNamingConvention: api schema
sampler_type: "bernoulli",
name,
drop,
// biome-ignore lint/style/useNamingConvention: api schema
convert_to: normalizedConvertTo,
p: readNumberString(params.p),
};
}
if (samplerType === "datetime") {
return {
id,
kind: "sampler",
// biome-ignore lint/style/useNamingConvention: api schema
sampler_type: "datetime",
name,
drop,
// biome-ignore lint/style/useNamingConvention: api schema
convert_to: normalizedConvertTo,
// biome-ignore lint/style/useNamingConvention: api schema
datetime_start: readString(params.start) ?? "",
// biome-ignore lint/style/useNamingConvention: api schema
datetime_end: readString(params.end) ?? "",
// biome-ignore lint/style/useNamingConvention: api schema
datetime_unit: readString(params.unit) ?? "",
};
}
if (samplerType === "timedelta") {
const rawUnit = readString(params.unit);
const unit =
rawUnit && TIMEDELTA_UNITS.has(rawUnit)
? (rawUnit as "D" | "h" | "m" | "s")
: "D";
return {
id,
kind: "sampler",
// biome-ignore lint/style/useNamingConvention: api schema
sampler_type: "timedelta",
name,
drop,
// biome-ignore lint/style/useNamingConvention: api schema
convert_to: normalizedConvertTo,
// biome-ignore lint/style/useNamingConvention: api schema
dt_min: readNumberString(params.dt_min),
// biome-ignore lint/style/useNamingConvention: api schema
dt_max: readNumberString(params.dt_max),
// biome-ignore lint/style/useNamingConvention: api schema
reference_column_name: readString(params.reference_column_name) ?? "",
// biome-ignore lint/style/useNamingConvention: api schema
timedelta_unit: unit,
};
}
if (samplerType === "uuid") {
return {
id,
kind: "sampler",
// biome-ignore lint/style/useNamingConvention: api schema
sampler_type: "uuid",
name,
drop,
// biome-ignore lint/style/useNamingConvention: api schema
convert_to: normalizedConvertTo,
// biome-ignore lint/style/useNamingConvention: api schema
uuid_format: readString(params.format) ?? "",
};
}
const ageRange =
Array.isArray(params.age_range) &&
params.age_range.length === 2 &&
params.age_range.every((item) => typeof item === "number")
? `${params.age_range[0]}-${params.age_range[1]}`
: readString(params.age_range) ?? "";
const base: SamplerConfig = {
id,
kind: "sampler",
name,
drop,
// biome-ignore lint/style/useNamingConvention: api schema
sampler_type: samplerType as SamplerType,
// biome-ignore lint/style/useNamingConvention: api schema
convert_to: normalizedConvertTo,
// biome-ignore lint/style/useNamingConvention: api schema
person_locale: readString(params.locale) ?? "",
// biome-ignore lint/style/useNamingConvention: api schema
person_sex: readString(params.sex) ?? "",
// biome-ignore lint/style/useNamingConvention: api schema
person_age_range: ageRange,
// biome-ignore lint/style/useNamingConvention: api schema
person_city: readString(params.city) ?? "",
};
if (samplerType === "person") {
return {
...base,
// biome-ignore lint/style/useNamingConvention: api schema
person_with_synthetic_personas:
typeof params.with_synthetic_personas === "boolean"
? params.with_synthetic_personas
: false,
};
}
return base;
}

View file

@ -1,422 +1,22 @@
import type {
RecipeNodeData,
ExpressionConfig,
ExpressionDtype,
LayoutDirection,
LlmConfig,
LlmType,
ModelConfig,
ModelProviderConfig,
NodeConfig,
SamplerConfig,
SamplerType,
} from "../types";
export {
makeExpressionConfig,
makeLlmConfig,
makeModelConfig,
makeModelProviderConfig,
makeSamplerConfig,
} from "./config-factories";
export {
labelForExpression,
labelForLlm,
labelForSampler,
} from "./config-labels";
export {
isCategoryConfig,
isExpressionConfig,
isLlmConfig,
isSamplerConfig,
isSubcategoryConfig,
} from "./config-type-guards";
export { nextName } from "./naming";
export { nodeDataFromConfig } from "./node-data";
export { getConfigErrors } from "./validation";
const SAMPLER_LABELS: Record<SamplerType, string> = {
category: "Category",
subcategory: "Subcategory",
uniform: "Uniform",
gaussian: "Gaussian",
bernoulli: "Bernoulli",
datetime: "Datetime",
timedelta: "Timedelta",
uuid: "UUID",
person: "Person",
person_from_faker: "Person (Faker)",
};
const LLM_LABELS: Record<LlmType, string> = {
text: "LLM Text",
structured: "LLM Structured",
code: "LLM Code",
judge: "LLM Judge",
};
const EXPRESSION_LABELS: Record<ExpressionDtype, string> = {
str: "Text",
int: "Int",
float: "Float",
bool: "Bool",
};
export function nextName(existing: NodeConfig[], prefix: string): string {
const counts = existing
.map((item) => item.name)
.filter((name) => name.startsWith(prefix))
.map((name) => {
const suffix = name.slice(prefix.length);
const num = Number.parseInt(suffix.replace("_", ""), 10);
return Number.isNaN(num) ? 0 : num;
});
const next = counts.length > 0 ? Math.max(...counts) + 1 : 1;
return `${prefix}_${next}`;
}
export function makeSamplerConfig(
id: string,
samplerType: SamplerType,
existing: NodeConfig[],
): SamplerConfig {
const namePrefix =
samplerType === "subcategory" ? "subcategory" : samplerType;
const name = nextName(existing, namePrefix);
if (samplerType === "category") {
return {
id,
kind: "sampler",
// biome-ignore lint/style/useNamingConvention: api schema
sampler_type: "category",
name,
drop: false,
values: ["A", "B", "C"],
weights: [null, null, null],
};
}
if (samplerType === "subcategory") {
return {
id,
kind: "sampler",
// biome-ignore lint/style/useNamingConvention: api schema
sampler_type: "subcategory",
name,
drop: false,
// biome-ignore lint/style/useNamingConvention: api schema
subcategory_parent: "",
// biome-ignore lint/style/useNamingConvention: api schema
subcategory_mapping: {
// biome-ignore lint/style/useNamingConvention: sample values
A: ["A1", "A2"],
// biome-ignore lint/style/useNamingConvention: sample values
B: ["B1", "B2"],
},
};
}
if (samplerType === "uniform") {
return {
id,
kind: "sampler",
// biome-ignore lint/style/useNamingConvention: api schema
sampler_type: "uniform",
name,
drop: false,
low: "0",
high: "1",
};
}
if (samplerType === "gaussian") {
return {
id,
kind: "sampler",
// biome-ignore lint/style/useNamingConvention: api schema
sampler_type: "gaussian",
name,
drop: false,
mean: "0",
std: "1",
};
}
if (samplerType === "bernoulli") {
return {
id,
kind: "sampler",
// biome-ignore lint/style/useNamingConvention: api schema
sampler_type: "bernoulli",
name,
drop: false,
p: "0.5",
};
}
if (samplerType === "datetime") {
return {
id,
kind: "sampler",
// biome-ignore lint/style/useNamingConvention: api schema
sampler_type: "datetime",
name,
drop: false,
// biome-ignore lint/style/useNamingConvention: api schema
datetime_start: "",
// biome-ignore lint/style/useNamingConvention: api schema
datetime_end: "",
// biome-ignore lint/style/useNamingConvention: api schema
datetime_unit: "day",
};
}
if (samplerType === "timedelta") {
return {
id,
kind: "sampler",
// biome-ignore lint/style/useNamingConvention: api schema
sampler_type: "timedelta",
name,
drop: false,
// biome-ignore lint/style/useNamingConvention: api schema
dt_min: "0",
// biome-ignore lint/style/useNamingConvention: api schema
dt_max: "1",
// biome-ignore lint/style/useNamingConvention: api schema
reference_column_name: "",
// biome-ignore lint/style/useNamingConvention: api schema
timedelta_unit: "D",
};
}
if (samplerType === "uuid") {
return {
id,
kind: "sampler",
// biome-ignore lint/style/useNamingConvention: api schema
sampler_type: "uuid",
name,
drop: false,
// biome-ignore lint/style/useNamingConvention: api schema
uuid_format: "",
};
}
if (samplerType === "person_from_faker") {
return {
id,
kind: "sampler",
// biome-ignore lint/style/useNamingConvention: api schema
sampler_type: "person_from_faker",
name,
drop: false,
// biome-ignore lint/style/useNamingConvention: api schema
person_locale: "",
// biome-ignore lint/style/useNamingConvention: api schema
person_sex: "",
// biome-ignore lint/style/useNamingConvention: api schema
person_age_range: "",
// biome-ignore lint/style/useNamingConvention: api schema
person_city: "",
};
}
return {
id,
kind: "sampler",
// biome-ignore lint/style/useNamingConvention: api schema
sampler_type: "person",
name,
drop: false,
// biome-ignore lint/style/useNamingConvention: api schema
person_locale: "",
// biome-ignore lint/style/useNamingConvention: api schema
person_sex: "",
// biome-ignore lint/style/useNamingConvention: api schema
person_age_range: "",
// biome-ignore lint/style/useNamingConvention: api schema
person_city: "",
// biome-ignore lint/style/useNamingConvention: api schema
person_with_synthetic_personas: false,
};
}
export function makeLlmConfig(
id: string,
llmType: LlmType,
existing: NodeConfig[],
): LlmConfig {
let namePrefix = "llm_text";
if (llmType === "structured") {
namePrefix = "llm_structured";
} else if (llmType === "code") {
namePrefix = "llm_code";
} else if (llmType === "judge") {
namePrefix = "llm_judge";
}
const name = nextName(existing, namePrefix);
return {
id,
kind: "llm",
// biome-ignore lint/style/useNamingConvention: api schema
llm_type: llmType,
name,
drop: false,
// biome-ignore lint/style/useNamingConvention: api schema
model_alias: "allenai/olmo-3.1-32b-instruct",
prompt:
llmType === "judge"
? "Evaluate the content using the scoring criteria below."
: "Write a response.",
// biome-ignore lint/style/useNamingConvention: api schema
system_prompt: "",
// biome-ignore lint/style/useNamingConvention: api schema
code_lang: llmType === "code" ? "python" : undefined,
// biome-ignore lint/style/useNamingConvention: api schema
output_format:
llmType === "structured" ? '{\n "field": "string"\n}' : undefined,
scores:
llmType === "judge"
? [
{
name: "Quality",
description: "Overall quality based on the criteria.",
options: [
{ value: "1", description: "Poor" },
{ value: "3", description: "Acceptable" },
{ value: "5", description: "Excellent" },
],
},
]
: undefined,
};
}
export function makeModelProviderConfig(
id: string,
existing: NodeConfig[],
): ModelProviderConfig {
return {
id,
kind: "model_provider",
name: nextName(existing, "provider"),
endpoint: "",
// biome-ignore lint/style/useNamingConvention: api schema
provider_type: "openai",
// biome-ignore lint/style/useNamingConvention: api schema
api_key_env: "",
// biome-ignore lint/style/useNamingConvention: api schema
api_key: "",
// biome-ignore lint/style/useNamingConvention: api schema
extra_headers: "",
// biome-ignore lint/style/useNamingConvention: api schema
extra_body: "",
};
}
export function makeModelConfig(
id: string,
existing: NodeConfig[],
): ModelConfig {
return {
id,
kind: "model_config",
name: nextName(existing, "model"),
model: "",
provider: "",
// biome-ignore lint/style/useNamingConvention: api schema
inference_temperature: "0.7",
// biome-ignore lint/style/useNamingConvention: api schema
inference_max_tokens: "256",
// biome-ignore lint/style/useNamingConvention: api schema
inference_top_p: "",
// biome-ignore lint/style/useNamingConvention: api schema
skip_health_check: false,
};
}
export function makeExpressionConfig(
id: string,
existing: NodeConfig[],
): ExpressionConfig {
return {
id,
kind: "expression",
name: nextName(existing, "expr"),
drop: false,
expr: "",
dtype: "str",
};
}
export function labelForSampler(type: SamplerType): string {
return SAMPLER_LABELS[type] ?? "Sampler";
}
export function labelForLlm(type: LlmType): string {
return LLM_LABELS[type] ?? "LLM";
}
export function labelForExpression(type: ExpressionDtype): string {
return EXPRESSION_LABELS[type] ?? "Expression";
}
export function nodeDataFromConfig(
config: NodeConfig,
layoutDirection: LayoutDirection = "LR",
): RecipeNodeData {
if (config.kind === "sampler") {
return {
title: "Sampler",
kind: "sampler",
subtype: labelForSampler(config.sampler_type),
blockType: config.sampler_type,
name: config.name,
layoutDirection,
};
}
if (config.kind === "expression") {
return {
title: "Expression",
kind: "expression",
subtype: labelForExpression(config.dtype),
blockType: "expression",
name: config.name,
layoutDirection,
};
}
if (config.kind === "model_provider") {
return {
title: "Model Provider",
kind: "model_provider",
subtype: config.provider_type || "Provider",
blockType: "model_provider",
name: config.name,
layoutDirection,
};
}
if (config.kind === "model_config") {
return {
title: "Model Config",
kind: "model_config",
subtype: config.model || "Model",
blockType: "model_config",
name: config.name,
layoutDirection,
};
}
return {
title: "LLM",
kind: "llm",
subtype: labelForLlm(config.llm_type),
blockType: config.llm_type,
name: config.name,
layoutDirection,
};
}
export function isSamplerConfig(
config: NodeConfig | null | undefined,
): config is SamplerConfig {
return Boolean(config && config.kind === "sampler");
}
export function isCategoryConfig(
config: NodeConfig | null | undefined,
): config is SamplerConfig {
return Boolean(
config && config.kind === "sampler" && config.sampler_type === "category",
);
}
export function isSubcategoryConfig(
config: NodeConfig | null | undefined,
): config is SamplerConfig {
return Boolean(
config &&
config.kind === "sampler" &&
config.sampler_type === "subcategory",
);
}
export function isLlmConfig(
config: NodeConfig | null | undefined,
): config is LlmConfig {
return Boolean(config && config.kind === "llm");
}
export function isExpressionConfig(
config: NodeConfig | null | undefined,
): config is ExpressionConfig {
return Boolean(config && config.kind === "expression");
}

View file

@ -0,0 +1,14 @@
import type { NodeConfig } from "../types";
export function nextName(existing: NodeConfig[], prefix: string): string {
const counts = existing
.map((item) => item.name)
.filter((name) => name.startsWith(prefix))
.map((name) => {
const suffix = name.slice(prefix.length);
const num = Number.parseInt(suffix.replace("_", ""), 10);
return Number.isNaN(num) ? 0 : num;
});
const next = counts.length > 0 ? Math.max(...counts) + 1 : 1;
return `${prefix}_${next}`;
}

View file

@ -0,0 +1,60 @@
import type { RecipeNodeData, LayoutDirection, NodeConfig } from "../types";
import {
labelForExpression,
labelForLlm,
labelForSampler,
} from "./config-labels";
export function nodeDataFromConfig(
config: NodeConfig,
layoutDirection: LayoutDirection = "LR",
): RecipeNodeData {
if (config.kind === "sampler") {
return {
title: "Sampler",
kind: "sampler",
subtype: labelForSampler(config.sampler_type),
blockType: config.sampler_type,
name: config.name,
layoutDirection,
};
}
if (config.kind === "expression") {
return {
title: "Expression",
kind: "expression",
subtype: labelForExpression(config.dtype),
blockType: "expression",
name: config.name,
layoutDirection,
};
}
if (config.kind === "model_provider") {
return {
title: "Model Provider",
kind: "model_provider",
subtype: config.provider_type || "Provider",
blockType: "model_provider",
name: config.name,
layoutDirection,
};
}
if (config.kind === "model_config") {
return {
title: "Model Config",
kind: "model_config",
subtype: config.model || "Model",
blockType: "model_config",
name: config.name,
layoutDirection,
};
}
return {
title: "LLM",
kind: "llm",
subtype: labelForLlm(config.llm_type),
blockType: config.llm_type,
name: config.name,
layoutDirection,
};
}