feat: add Recipe Studio utilities and components for configuring synthetic data pipelines
This commit is contained in:
parent
93f45ffd07
commit
390e9ed9d2
21 changed files with 1589 additions and 1453 deletions
1
studio/frontend/.gitignore
vendored
1
studio/frontend/.gitignore
vendored
|
|
@ -27,3 +27,4 @@ test/
|
|||
*.sln
|
||||
*.sw?
|
||||
/src/features/recipe-studio/AGENTS.md
|
||||
/docs
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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 },
|
||||
};
|
||||
});
|
||||
}
|
||||
|
|
@ -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));
|
||||
}
|
||||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
};
|
||||
}
|
||||
|
|
@ -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";
|
||||
}
|
||||
|
|
@ -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");
|
||||
}
|
||||
|
|
@ -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>,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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");
|
||||
}
|
||||
|
|
|
|||
14
studio/frontend/src/features/recipe-studio/utils/naming.ts
Normal file
14
studio/frontend/src/features/recipe-studio/utils/naming.ts
Normal 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}`;
|
||||
}
|
||||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue