From 390e9ed9d28cf6d881061df11190a0c91c8cf6ac Mon Sep 17 00:00:00 2001 From: Shine1i Date: Thu, 12 Feb 2026 01:03:47 +0100 Subject: [PATCH] feat: add Recipe Studio utilities and components for configuring synthetic data pipelines --- studio/frontend/.gitignore | 1 + .../components/controls/layout-controls.tsx | 60 +++ .../components/controls/viewport-controls.tsx | 74 +++ .../components/graph/internals-sync.tsx | 24 + .../components/recipe-studio-header.tsx | 61 +++ .../recipe-studio/recipe-studio-page.tsx | 201 +------- .../recipe-studio/stores/helpers/edge-sync.ts | 206 ++++++++ .../stores/helpers/node-updates.ts | 78 +++ .../stores/helpers/reference-sync.ts | 169 +++++++ .../stores/recipe-studio-helpers.ts | 461 +----------------- .../recipe-studio/utils/config-factories.ts | 277 +++++++++++ .../recipe-studio/utils/config-labels.ts | 44 ++ .../recipe-studio/utils/config-type-guards.ts | 42 ++ .../recipe-studio/utils/import/parsers.ts | 402 +-------------- .../utils/import/parsers/expression-parser.ts | 26 + .../utils/import/parsers/llm-parser.ts | 65 +++ .../utils/import/parsers/model-parser.ts | 64 +++ .../utils/import/parsers/sampler-parser.ts | 271 ++++++++++ .../src/features/recipe-studio/utils/index.ts | 442 +---------------- .../features/recipe-studio/utils/naming.ts | 14 + .../features/recipe-studio/utils/node-data.ts | 60 +++ 21 files changed, 1589 insertions(+), 1453 deletions(-) create mode 100644 studio/frontend/src/features/recipe-studio/components/controls/layout-controls.tsx create mode 100644 studio/frontend/src/features/recipe-studio/components/controls/viewport-controls.tsx create mode 100644 studio/frontend/src/features/recipe-studio/components/graph/internals-sync.tsx create mode 100644 studio/frontend/src/features/recipe-studio/components/recipe-studio-header.tsx create mode 100644 studio/frontend/src/features/recipe-studio/stores/helpers/edge-sync.ts create mode 100644 studio/frontend/src/features/recipe-studio/stores/helpers/node-updates.ts create mode 100644 studio/frontend/src/features/recipe-studio/stores/helpers/reference-sync.ts create mode 100644 studio/frontend/src/features/recipe-studio/utils/config-factories.ts create mode 100644 studio/frontend/src/features/recipe-studio/utils/config-labels.ts create mode 100644 studio/frontend/src/features/recipe-studio/utils/config-type-guards.ts create mode 100644 studio/frontend/src/features/recipe-studio/utils/import/parsers/expression-parser.ts create mode 100644 studio/frontend/src/features/recipe-studio/utils/import/parsers/llm-parser.ts create mode 100644 studio/frontend/src/features/recipe-studio/utils/import/parsers/model-parser.ts create mode 100644 studio/frontend/src/features/recipe-studio/utils/import/parsers/sampler-parser.ts create mode 100644 studio/frontend/src/features/recipe-studio/utils/naming.ts create mode 100644 studio/frontend/src/features/recipe-studio/utils/node-data.ts diff --git a/studio/frontend/.gitignore b/studio/frontend/.gitignore index d87ba2c95d..b372112949 100644 --- a/studio/frontend/.gitignore +++ b/studio/frontend/.gitignore @@ -27,3 +27,4 @@ test/ *.sln *.sw? /src/features/recipe-studio/AGENTS.md +/docs diff --git a/studio/frontend/src/features/recipe-studio/components/controls/layout-controls.tsx b/studio/frontend/src/features/recipe-studio/components/controls/layout-controls.tsx new file mode 100644 index 0000000000..a4f039d6d1 --- /dev/null +++ b/studio/frontend/src/features/recipe-studio/components/controls/layout-controls.tsx @@ -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 ( + + + + + ); +} diff --git a/studio/frontend/src/features/recipe-studio/components/controls/viewport-controls.tsx b/studio/frontend/src/features/recipe-studio/components/controls/viewport-controls.tsx new file mode 100644 index 0000000000..27e225f39e --- /dev/null +++ b/studio/frontend/src/features/recipe-studio/components/controls/viewport-controls.tsx @@ -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 ( + + + + + + + ); +} diff --git a/studio/frontend/src/features/recipe-studio/components/graph/internals-sync.tsx b/studio/frontend/src/features/recipe-studio/components/graph/internals-sync.tsx new file mode 100644 index 0000000000..69a8d45dfa --- /dev/null +++ b/studio/frontend/src/features/recipe-studio/components/graph/internals-sync.tsx @@ -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; +} diff --git a/studio/frontend/src/features/recipe-studio/components/recipe-studio-header.tsx b/studio/frontend/src/features/recipe-studio/components/recipe-studio-header.tsx new file mode 100644 index 0000000000..d72346849d --- /dev/null +++ b/studio/frontend/src/features/recipe-studio/components/recipe-studio-header.tsx @@ -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 = { + 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 ( +
+
+
+

Create Data Recipe

+

+ Design synthetic-data pipelines with Data Designer. +

+ {statusMessage && ( +

+ {statusMessage.text} +

+ )} +
+
+ +
+
+
+ ); +} diff --git a/studio/frontend/src/features/recipe-studio/recipe-studio-page.tsx b/studio/frontend/src/features/recipe-studio/recipe-studio-page.tsx index ee50a16435..870c48a5df 100644 --- a/studio/frontend/src/features/recipe-studio/recipe-studio-page.tsx +++ b/studio/frontend/src/features/recipe-studio/recipe-studio-page.tsx @@ -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 = { - 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 ( - - - - - ); -} - -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 ( - - - - - - - ); -} - export function RecipeStudioPage(): ReactElement { const { nodes, @@ -495,37 +338,11 @@ export function RecipeStudioPage(): ReactElement { return (
-
-
-
-

Create Data Recipe

-

- Design synthetic-data pipelines with Data Designer. -

- {statusMessage && ( -

- {statusMessage.text} -

- )} -
-
- -
-
-
+
, + 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, + 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, + configs: Record, + edges: Edge[], +): Edge[] { + let nextEdges = edges; + + const hasParentPatch = Object.prototype.hasOwnProperty.call( + patch, + "subcategory_parent", + ); + if (isSubcategoryConfig(current) && hasParentPatch) { + const nextParent = (patch as Partial).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).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).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 & { 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, + oldName: string, + newName: string, + nameChanged: boolean, +): Record { + 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 = {}; + 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; +} diff --git a/studio/frontend/src/features/recipe-studio/stores/helpers/node-updates.ts b/studio/frontend/src/features/recipe-studio/stores/helpers/node-updates.ts new file mode 100644 index 0000000000..6d17986595 --- /dev/null +++ b/studio/frontend/src/features/recipe-studio/stores/helpers/node-updates.ts @@ -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; + nodes: RecipeNode[]; + nextId: number; + nextY: number; +}; + +export type NodeUpdateResult = { + configs: Record; + 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, + 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 }, + }; + }); +} diff --git a/studio/frontend/src/features/recipe-studio/stores/helpers/reference-sync.ts b/studio/frontend/src/features/recipe-studio/stores/helpers/reference-sync.ts new file mode 100644 index 0000000000..d3245e513f --- /dev/null +++ b/studio/frontend/src/features/recipe-studio/stores/helpers/reference-sync.ts @@ -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, + transform: (config: NodeConfig) => NodeConfig, +): Record { + 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, + from: string, + to: string, +): Record { + if (!from || from === to) { + return configs; + } + return applyConfigTransform(configs, (config) => + applyRenameToConfig(config, from, to), + ); +} + +export function applyRemovalToConfigs( + configs: Record, + ref: string, +): Record { + if (!ref) { + return configs; + } + return applyConfigTransform(configs, (config) => applyRemovalToConfig(config, ref)); +} diff --git a/studio/frontend/src/features/recipe-studio/stores/recipe-studio-helpers.ts b/studio/frontend/src/features/recipe-studio/stores/recipe-studio-helpers.ts index 9a14791156..4515ba1e69 100644 --- a/studio/frontend/src/features/recipe-studio/stores/recipe-studio-helpers.ts +++ b/studio/frontend/src/features/recipe-studio/stores/recipe-studio-helpers.ts @@ -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; - nodes: RecipeNode[]; - nextId: number; - nextY: number; -}; - -type NodeUpdateResult = { - configs: Record; - 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, - 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, - 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, - 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, - configs: Record, - edges: Edge[], -): Edge[] { - let nextEdges = edges; - - const hasParentPatch = Object.prototype.hasOwnProperty.call( - patch, - "subcategory_parent", - ); - if (isSubcategoryConfig(current) && hasParentPatch) { - const nextParent = (patch as Partial).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).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).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 & { 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, - oldName: string, - newName: string, - nameChanged: boolean, -): Record { - 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 = {}; - 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, - from: string, - to: string, -): Record { - if (!from || from === to) { - return configs; - } - return applyConfigTransform(configs, (config) => - applyRenameToConfig(config, from, to), - ); -} - -function applyConfigTransform( - configs: Record, - transform: (config: NodeConfig) => NodeConfig, -): Record { - 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, - ref: string, -): Record { - 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"; diff --git a/studio/frontend/src/features/recipe-studio/utils/config-factories.ts b/studio/frontend/src/features/recipe-studio/utils/config-factories.ts new file mode 100644 index 0000000000..7b8f0814a9 --- /dev/null +++ b/studio/frontend/src/features/recipe-studio/utils/config-factories.ts @@ -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", + }; +} diff --git a/studio/frontend/src/features/recipe-studio/utils/config-labels.ts b/studio/frontend/src/features/recipe-studio/utils/config-labels.ts new file mode 100644 index 0000000000..b70d7566b1 --- /dev/null +++ b/studio/frontend/src/features/recipe-studio/utils/config-labels.ts @@ -0,0 +1,44 @@ +import type { + ExpressionDtype, + LlmType, + SamplerType, +} from "../types"; + +const SAMPLER_LABELS: Record = { + 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 = { + text: "LLM Text", + structured: "LLM Structured", + code: "LLM Code", + judge: "LLM Judge", +}; + +const EXPRESSION_LABELS: Record = { + 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"; +} diff --git a/studio/frontend/src/features/recipe-studio/utils/config-type-guards.ts b/studio/frontend/src/features/recipe-studio/utils/config-type-guards.ts new file mode 100644 index 0000000000..855cca8c6a --- /dev/null +++ b/studio/frontend/src/features/recipe-studio/utils/config-type-guards.ts @@ -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"); +} diff --git a/studio/frontend/src/features/recipe-studio/utils/import/parsers.ts b/studio/frontend/src/features/recipe-studio/utils/import/parsers.ts index 349da4e9a8..edc3580aa1 100644 --- a/studio/frontend/src/features/recipe-studio/utils/import/parsers.ts +++ b/studio/frontend/src/features/recipe-studio/utils/import/parsers.ts @@ -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, -): SamplerConfig["conditional_params"] { - if (!isRecord(column.conditional_params)) { - return undefined; - } - const conditional: NonNullable = {}; - 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, - 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) - : {}; - 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 = {}; - 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, - 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, - 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, - name: string, - id: string, -): ModelConfig { - const inference = isRecord(model.inference_parameters) - ? (model.inference_parameters as Record) - : {}; - 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, - 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, diff --git a/studio/frontend/src/features/recipe-studio/utils/import/parsers/expression-parser.ts b/studio/frontend/src/features/recipe-studio/utils/import/parsers/expression-parser.ts new file mode 100644 index 0000000000..03585a1f11 --- /dev/null +++ b/studio/frontend/src/features/recipe-studio/utils/import/parsers/expression-parser.ts @@ -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, + 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, + }; +} diff --git a/studio/frontend/src/features/recipe-studio/utils/import/parsers/llm-parser.ts b/studio/frontend/src/features/recipe-studio/utils/import/parsers/llm-parser.ts new file mode 100644 index 0000000000..6d37cb832c --- /dev/null +++ b/studio/frontend/src/features/recipe-studio/utils/import/parsers/llm-parser.ts @@ -0,0 +1,65 @@ +import type { + LlmConfig, + Score, + ScoreOption, +} from "../../../types"; +import { + isRecord, + normalizeOutputFormat, + readString, +} from "../helpers"; + +export function parseLlm( + column: Record, + 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, + }; +} diff --git a/studio/frontend/src/features/recipe-studio/utils/import/parsers/model-parser.ts b/studio/frontend/src/features/recipe-studio/utils/import/parsers/model-parser.ts new file mode 100644 index 0000000000..18632d5c03 --- /dev/null +++ b/studio/frontend/src/features/recipe-studio/utils/import/parsers/model-parser.ts @@ -0,0 +1,64 @@ +import type { + ModelConfig, + ModelProviderConfig, +} from "../../../types"; +import { + isRecord, + readNumberString, + readString, +} from "../helpers"; + +export function parseModelProvider( + provider: Record, + 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, + name: string, + id: string, +): ModelConfig { + const inference = isRecord(model.inference_parameters) + ? (model.inference_parameters as Record) + : {}; + 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, + }; +} diff --git a/studio/frontend/src/features/recipe-studio/utils/import/parsers/sampler-parser.ts b/studio/frontend/src/features/recipe-studio/utils/import/parsers/sampler-parser.ts new file mode 100644 index 0000000000..067216af1a --- /dev/null +++ b/studio/frontend/src/features/recipe-studio/utils/import/parsers/sampler-parser.ts @@ -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, +): SamplerConfig["conditional_params"] { + if (!isRecord(column.conditional_params)) { + return undefined; + } + const conditional: NonNullable = {}; + 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, + 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) + : {}; + + 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 = {}; + 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; +} diff --git a/studio/frontend/src/features/recipe-studio/utils/index.ts b/studio/frontend/src/features/recipe-studio/utils/index.ts index 5a58103683..f4256047d8 100644 --- a/studio/frontend/src/features/recipe-studio/utils/index.ts +++ b/studio/frontend/src/features/recipe-studio/utils/index.ts @@ -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 = { - 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 = { - text: "LLM Text", - structured: "LLM Structured", - code: "LLM Code", - judge: "LLM Judge", -}; - -const EXPRESSION_LABELS: Record = { - 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"); -} diff --git a/studio/frontend/src/features/recipe-studio/utils/naming.ts b/studio/frontend/src/features/recipe-studio/utils/naming.ts new file mode 100644 index 0000000000..664ed62c08 --- /dev/null +++ b/studio/frontend/src/features/recipe-studio/utils/naming.ts @@ -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}`; +} diff --git a/studio/frontend/src/features/recipe-studio/utils/node-data.ts b/studio/frontend/src/features/recipe-studio/utils/node-data.ts new file mode 100644 index 0000000000..8b0bf5cf35 --- /dev/null +++ b/studio/frontend/src/features/recipe-studio/utils/node-data.ts @@ -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, + }; +}