new blocks timedelta, and some tweaks
This commit is contained in:
parent
2a9a332ce3
commit
d67efb8516
17 changed files with 918 additions and 71 deletions
|
|
@ -99,6 +99,7 @@ File:
|
|||
Responsibilities:
|
||||
- create default config objects (`makeSamplerConfig`, `makeLlmConfig`, `makeModelProviderConfig`, `makeModelConfig`, `makeExpressionConfig`)
|
||||
- map `NodeConfig -> CanvasNodeData` via `nodeDataFromConfig`
|
||||
- sampler set includes `category`, `subcategory`, `uniform`, `gaussian`, `bernoulli`, `datetime`, `timedelta`, `uuid`, `person`, `person_from_faker`
|
||||
|
||||
Example mapping:
|
||||
|
||||
|
|
@ -148,15 +149,19 @@ Semantic edge classifier:
|
|||
```ts
|
||||
function isSemanticEdge(source: NodeConfig, target: NodeConfig): boolean {
|
||||
if (source.kind === "model_provider" && target.kind === "model_config") return true;
|
||||
if (source.kind === "model_config" && target.kind === "llm") return true;
|
||||
return source.kind === "sampler" && source.sampler_type === "category"
|
||||
&& target.kind === "sampler" && target.sampler_type === "subcategory";
|
||||
return source.kind === "model_config" && target.kind === "llm";
|
||||
}
|
||||
```
|
||||
|
||||
Handle lanes:
|
||||
- data edges use `data-out -> data-in` (`right -> left`)
|
||||
- semantic edges use `semantic-out -> semantic-in` (`bottom -> top`)
|
||||
- semantic lane only used for `model_provider -> model_config -> llm`
|
||||
|
||||
Connection side effects:
|
||||
- `model_provider -> model_config`: set `model_config.provider = source.name`
|
||||
- `model_config -> llm`: set `llm.model_alias = source.name`
|
||||
- `datetime -> timedelta`: set `timedelta.reference_column_name = source.name`
|
||||
- regular data edges into LLM/expression append `{{ source_name }}` refs
|
||||
|
||||
Edge rendering (dotted semantic edges):
|
||||
|
|
@ -208,7 +213,9 @@ How relation is enforced:
|
|||
- collect `model_alias` values used by LLM columns
|
||||
- ensure each alias exists in `recipe.model_configs`
|
||||
- validate `model_config.provider` points to existing provider
|
||||
- validate `timedelta.reference_column_name` points to a datetime sampler
|
||||
- require endpoint/provider_type only for providers that are actually referenced
|
||||
- category sampler supports typed `conditional_params` in payload output
|
||||
|
||||
This is why unused provider/config blocks can exist without blocking preview.
|
||||
|
||||
|
|
@ -227,10 +234,11 @@ Order of reconstruction:
|
|||
Edge inference file:
|
||||
`/Volumes/Expansion/projects/new-ui-prototype/studio/frontend/src/features/canvas-lab/utils/import/edges.ts`
|
||||
|
||||
If UI edges missing, infer semantic edges from fields:
|
||||
- `subcategory_parent`
|
||||
If UI edges missing, infer edges from fields:
|
||||
- `subcategory_parent` (canvas edge)
|
||||
- `model_config.provider`
|
||||
- `llm.model_alias`
|
||||
- infer data edge from `timedelta.reference_column_name`
|
||||
|
||||
## 11) Dialog routing and edit UIs
|
||||
|
||||
|
|
@ -249,6 +257,7 @@ Model dialogs:
|
|||
`ModelConfigDialog` and `LlmDialog` use shadcn `Combobox` fed from store configs:
|
||||
- model config `provider` suggests model-provider node names
|
||||
- llm `model_alias` suggests model-config aliases
|
||||
- timedelta dialog suggests datetime columns for `reference_column_name`
|
||||
|
||||
## 12) How to add a new block (checklist)
|
||||
|
||||
|
|
|
|||
|
|
@ -30,9 +30,11 @@ import { ModelConfigDialog } from "../dialogs/models/model-config-dialog";
|
|||
import { ModelProviderDialog } from "../dialogs/models/model-provider-dialog";
|
||||
import { CategoryDialog } from "../dialogs/samplers/category-dialog";
|
||||
import { DatetimeDialog } from "../dialogs/samplers/datetime-dialog";
|
||||
import { BernoulliDialog } from "../dialogs/samplers/bernoulli-dialog";
|
||||
import { GaussianDialog } from "../dialogs/samplers/gaussian-dialog";
|
||||
import { PersonDialog } from "../dialogs/samplers/person-dialog";
|
||||
import { SubcategoryDialog } from "../dialogs/samplers/subcategory-dialog";
|
||||
import { TimedeltaDialog } from "../dialogs/samplers/timedelta-dialog";
|
||||
import { UniformDialog } from "../dialogs/samplers/uniform-dialog";
|
||||
import { UuidDialog } from "../dialogs/samplers/uuid-dialog";
|
||||
|
||||
|
|
@ -101,6 +103,7 @@ const BLOCK_DEFINITIONS: BlockDefinition[] = [
|
|||
renderDialog: ({ config, onUpdate }) =>
|
||||
config.kind === "sampler" && config.sampler_type === "category" ? (
|
||||
<CategoryDialog
|
||||
key={config.id}
|
||||
config={config}
|
||||
onUpdate={(patch) => onUpdate(config.id, patch)}
|
||||
/>
|
||||
|
|
@ -153,6 +156,22 @@ const BLOCK_DEFINITIONS: BlockDefinition[] = [
|
|||
/>
|
||||
) : null,
|
||||
},
|
||||
{
|
||||
kind: "sampler",
|
||||
type: "bernoulli",
|
||||
title: "Bernoulli",
|
||||
description: "Binary sampler with probability.",
|
||||
icon: EqualSignIcon,
|
||||
createConfig: (id, existing) =>
|
||||
makeSamplerConfig(id, "bernoulli", existing),
|
||||
renderDialog: ({ config, onUpdate }) =>
|
||||
config.kind === "sampler" && config.sampler_type === "bernoulli" ? (
|
||||
<BernoulliDialog
|
||||
config={config}
|
||||
onUpdate={(patch) => onUpdate(config.id, patch)}
|
||||
/>
|
||||
) : null,
|
||||
},
|
||||
{
|
||||
kind: "sampler",
|
||||
type: "datetime",
|
||||
|
|
@ -168,6 +187,22 @@ const BLOCK_DEFINITIONS: BlockDefinition[] = [
|
|||
/>
|
||||
) : null,
|
||||
},
|
||||
{
|
||||
kind: "sampler",
|
||||
type: "timedelta",
|
||||
title: "Timedelta",
|
||||
description: "Offset from datetime column.",
|
||||
icon: Clock01Icon,
|
||||
createConfig: (id, existing) =>
|
||||
makeSamplerConfig(id, "timedelta", existing),
|
||||
renderDialog: ({ config, onUpdate }) =>
|
||||
config.kind === "sampler" && config.sampler_type === "timedelta" ? (
|
||||
<TimedeltaDialog
|
||||
config={config}
|
||||
onUpdate={(patch) => onUpdate(config.id, patch)}
|
||||
/>
|
||||
) : null,
|
||||
},
|
||||
{
|
||||
kind: "sampler",
|
||||
type: "uuid",
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { BaseEdge, type EdgeProps, getSmoothStepPath } from "@xyflow/react";
|
||||
import { memo } from "react";
|
||||
import { memo, type ReactElement } from "react";
|
||||
|
||||
export const CanvasEdge = memo(function CanvasEdge({
|
||||
id,
|
||||
|
|
@ -11,7 +11,7 @@ export const CanvasEdge = memo(function CanvasEdge({
|
|||
targetPosition,
|
||||
style,
|
||||
type,
|
||||
}: EdgeProps): JSX.Element {
|
||||
}: EdgeProps): ReactElement {
|
||||
const [path] = getSmoothStepPath({
|
||||
sourceX,
|
||||
sourceY,
|
||||
|
|
|
|||
|
|
@ -25,6 +25,7 @@ import type {
|
|||
LlmType,
|
||||
SamplerType,
|
||||
} from "../types";
|
||||
import { HANDLE_IDS } from "../utils/handles";
|
||||
|
||||
type IconType = typeof CodeIcon;
|
||||
|
||||
|
|
@ -51,7 +52,9 @@ const SAMPLER_ICONS: Record<SamplerType, IconType> = {
|
|||
subcategory: TagsIcon,
|
||||
uniform: EqualSignIcon,
|
||||
gaussian: Parabola02Icon,
|
||||
bernoulli: EqualSignIcon,
|
||||
datetime: Clock01Icon,
|
||||
timedelta: Clock01Icon,
|
||||
uuid: FingerPrintIcon,
|
||||
person: UserAccountIcon,
|
||||
person_from_faker: UserAccountIcon,
|
||||
|
|
@ -94,13 +97,26 @@ function CanvasNodeBase({
|
|||
const meta = NODE_META[data.kind];
|
||||
const icon = resolveNodeIcon(data.kind, data.blockType);
|
||||
const layoutDirection = data.layoutDirection ?? "LR";
|
||||
const isHorizontal = layoutDirection === "LR";
|
||||
const updateNodeInternals = useUpdateNodeInternals();
|
||||
|
||||
useEffect(() => {
|
||||
updateNodeInternals(id);
|
||||
}, [id, layoutDirection, updateNodeInternals]);
|
||||
|
||||
const showDataHandles =
|
||||
data.kind === "llm" ||
|
||||
data.kind === "expression" ||
|
||||
(data.kind === "sampler" &&
|
||||
data.blockType !== "model_provider" &&
|
||||
data.blockType !== "model_config");
|
||||
const showSemanticIn =
|
||||
data.kind === "llm" ||
|
||||
data.kind === "model_config";
|
||||
const showSemanticOut =
|
||||
data.kind === "llm" ||
|
||||
data.kind === "model_config" ||
|
||||
data.kind === "model_provider";
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
|
|
@ -126,16 +142,38 @@ function CanvasNodeBase({
|
|||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<Handle
|
||||
type="target"
|
||||
position={isHorizontal ? Position.Left : Position.Top}
|
||||
className="size-2 border border-border bg-white"
|
||||
/>
|
||||
<Handle
|
||||
type="source"
|
||||
position={isHorizontal ? Position.Right : Position.Bottom}
|
||||
className="size-2 border border-border bg-white"
|
||||
/>
|
||||
{showDataHandles && (
|
||||
<>
|
||||
<Handle
|
||||
id={HANDLE_IDS.dataIn}
|
||||
type="target"
|
||||
position={Position.Left}
|
||||
className="size-2 border border-border bg-white"
|
||||
/>
|
||||
<Handle
|
||||
id={HANDLE_IDS.dataOut}
|
||||
type="source"
|
||||
position={Position.Right}
|
||||
className="size-2 border border-border bg-white"
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
{showSemanticIn && (
|
||||
<Handle
|
||||
id={HANDLE_IDS.semanticIn}
|
||||
type="target"
|
||||
position={Position.Top}
|
||||
className="size-2 border border-border bg-white"
|
||||
/>
|
||||
)}
|
||||
{showSemanticOut && (
|
||||
<Handle
|
||||
id={HANDLE_IDS.semanticOut}
|
||||
type="source"
|
||||
position={Position.Bottom}
|
||||
className="size-2 border border-border bg-white"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,42 @@
|
|||
import { Input } from "@/components/ui/input";
|
||||
import type { ReactElement } from "react";
|
||||
import type { SamplerConfig } from "../../types";
|
||||
import { NameField } from "../shared/name-field";
|
||||
|
||||
type BernoulliDialogProps = {
|
||||
config: SamplerConfig;
|
||||
onUpdate: (patch: Partial<SamplerConfig>) => void;
|
||||
};
|
||||
|
||||
export function BernoulliDialog({
|
||||
config,
|
||||
onUpdate,
|
||||
}: BernoulliDialogProps): ReactElement {
|
||||
const pId = `${config.id}-bernoulli-p`;
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<NameField
|
||||
value={config.name}
|
||||
onChange={(value) => onUpdate({ name: value })}
|
||||
/>
|
||||
<div className="grid gap-2">
|
||||
<label
|
||||
className="text-xs font-semibold uppercase text-muted-foreground"
|
||||
htmlFor={pId}
|
||||
>
|
||||
Probability (p)
|
||||
</label>
|
||||
<Input
|
||||
id={pId}
|
||||
type="number"
|
||||
min="0"
|
||||
max="1"
|
||||
step="0.01"
|
||||
className="nodrag"
|
||||
value={config.p ?? ""}
|
||||
onChange={(event) => onUpdate({ p: event.target.value })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,7 +1,7 @@
|
|||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { type ReactElement, useEffect, useState } from "react";
|
||||
import { type ReactElement, useState } from "react";
|
||||
import type { SamplerConfig } from "../../types";
|
||||
import { NameField } from "../shared/name-field";
|
||||
|
||||
|
|
@ -15,13 +15,14 @@ export function CategoryDialog({
|
|||
onUpdate,
|
||||
}: CategoryDialogProps): ReactElement {
|
||||
const [valueDraft, setValueDraft] = useState("");
|
||||
const [conditionDraft, setConditionDraft] = useState("");
|
||||
const [conditionalValueDrafts, setConditionalValueDrafts] = useState<
|
||||
Record<string, string>
|
||||
>({});
|
||||
const valuesInputId = `${config.id}-values`;
|
||||
const conditionInputId = `${config.id}-conditional-rule`;
|
||||
|
||||
useEffect(() => {
|
||||
if (config.id) {
|
||||
setValueDraft("");
|
||||
}
|
||||
}, [config.id]);
|
||||
const conditional = config.conditional_params ?? {};
|
||||
|
||||
const handleAddValue = () => {
|
||||
const nextValue = valueDraft.trim();
|
||||
|
|
@ -36,6 +37,58 @@ export function CategoryDialog({
|
|||
setValueDraft("");
|
||||
};
|
||||
|
||||
const handleAddCondition = () => {
|
||||
const condition = conditionDraft.trim();
|
||||
if (!condition || conditional[condition]) {
|
||||
return;
|
||||
}
|
||||
onUpdate({
|
||||
// biome-ignore lint/style/useNamingConvention: api schema
|
||||
conditional_params: {
|
||||
...conditional,
|
||||
[condition]: {
|
||||
// biome-ignore lint/style/useNamingConvention: api schema
|
||||
sampler_type: "category",
|
||||
values: [],
|
||||
weights: [],
|
||||
},
|
||||
},
|
||||
});
|
||||
setConditionDraft("");
|
||||
};
|
||||
|
||||
const removeCondition = (condition: string) => {
|
||||
const next = { ...conditional };
|
||||
delete next[condition];
|
||||
onUpdate({
|
||||
// biome-ignore lint/style/useNamingConvention: api schema
|
||||
conditional_params: Object.keys(next).length > 0 ? next : undefined,
|
||||
});
|
||||
};
|
||||
|
||||
const addConditionalValue = (condition: string) => {
|
||||
const draft = conditionalValueDrafts[condition]?.trim();
|
||||
if (!draft) {
|
||||
return;
|
||||
}
|
||||
const current = conditional[condition] ?? {
|
||||
// biome-ignore lint/style/useNamingConvention: api schema
|
||||
sampler_type: "category" as const,
|
||||
values: [],
|
||||
weights: [],
|
||||
};
|
||||
const values = [...(current.values ?? []), draft];
|
||||
const weights = [...(current.weights ?? []), null];
|
||||
onUpdate({
|
||||
// biome-ignore lint/style/useNamingConvention: api schema
|
||||
conditional_params: {
|
||||
...conditional,
|
||||
[condition]: { ...current, values, weights },
|
||||
},
|
||||
});
|
||||
setConditionalValueDrafts((prev) => ({ ...prev, [condition]: "" }));
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<NameField
|
||||
|
|
@ -117,6 +170,149 @@ export function CategoryDialog({
|
|||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-3 rounded-2xl border border-border/60 p-3">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<p className="text-xs font-semibold uppercase text-muted-foreground">
|
||||
Conditional params (category)
|
||||
</p>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{Object.keys(conditional).length} rules
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
id={conditionInputId}
|
||||
className="nodrag"
|
||||
placeholder="Condition (e.g., {{ region }} == 'US')"
|
||||
value={conditionDraft}
|
||||
onChange={(event) => setConditionDraft(event.target.value)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Enter") {
|
||||
event.preventDefault();
|
||||
handleAddCondition();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<Button type="button" size="sm" onClick={handleAddCondition}>
|
||||
Add rule
|
||||
</Button>
|
||||
</div>
|
||||
{Object.entries(conditional).map(([condition, params]) => (
|
||||
<div
|
||||
key={condition}
|
||||
className="space-y-3 rounded-2xl border border-border/60 p-3"
|
||||
>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<p className="text-xs font-semibold text-foreground">{condition}</p>
|
||||
<Button
|
||||
type="button"
|
||||
size="xs"
|
||||
variant="ghost"
|
||||
onClick={() => removeCondition(condition)}
|
||||
>
|
||||
Remove
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
className="nodrag"
|
||||
placeholder="Add conditional value"
|
||||
value={conditionalValueDrafts[condition] ?? ""}
|
||||
onChange={(event) =>
|
||||
setConditionalValueDrafts((prev) => ({
|
||||
...prev,
|
||||
[condition]: event.target.value,
|
||||
}))
|
||||
}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Enter") {
|
||||
event.preventDefault();
|
||||
addConditionalValue(condition);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
onClick={() => addConditionalValue(condition)}
|
||||
>
|
||||
Add
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{(params.values ?? []).map((value, index) => (
|
||||
<Badge
|
||||
key={`${condition}-${value}-${index}`}
|
||||
variant="secondary"
|
||||
>
|
||||
<span>{value}</span>
|
||||
<button
|
||||
type="button"
|
||||
className="ml-2 text-xs"
|
||||
onClick={() => {
|
||||
const values = [...(params.values ?? [])];
|
||||
const weights = [...(params.weights ?? [])];
|
||||
values.splice(index, 1);
|
||||
weights.splice(index, 1);
|
||||
onUpdate({
|
||||
// biome-ignore lint/style/useNamingConvention: api schema
|
||||
conditional_params: {
|
||||
...conditional,
|
||||
[condition]: { ...params, values, weights },
|
||||
},
|
||||
});
|
||||
}}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<p className="text-xs font-semibold uppercase text-muted-foreground">
|
||||
Rule weights (optional)
|
||||
</p>
|
||||
<div className="grid gap-2">
|
||||
{(params.values ?? []).map((value, index) => (
|
||||
<div
|
||||
key={`${condition}-${value}-${index}-weight`}
|
||||
className="flex items-center gap-3"
|
||||
>
|
||||
<span className="text-xs text-muted-foreground w-28 truncate">
|
||||
{value}
|
||||
</span>
|
||||
<Input
|
||||
type="number"
|
||||
className="nodrag"
|
||||
placeholder="Weight"
|
||||
value={params.weights?.[index] ?? ""}
|
||||
onChange={(event) => {
|
||||
const weights = [
|
||||
...(params.weights ??
|
||||
Array.from(
|
||||
{ length: (params.values ?? []).length },
|
||||
() => null,
|
||||
)),
|
||||
];
|
||||
weights[index] = event.target.value
|
||||
? Number(event.target.value)
|
||||
: null;
|
||||
onUpdate({
|
||||
// biome-ignore lint/style/useNamingConvention: api schema
|
||||
conditional_params: {
|
||||
...conditional,
|
||||
[condition]: { ...params, weights },
|
||||
},
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,137 @@
|
|||
import { Input } from "@/components/ui/input";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { type ReactElement, useMemo } from "react";
|
||||
import { useCanvasLabStore } from "../../stores/canvas-lab";
|
||||
import type { SamplerConfig } from "../../types";
|
||||
import { NameField } from "../shared/name-field";
|
||||
|
||||
const TIMEDELTA_UNITS: Array<"D" | "h" | "m" | "s"> = ["D", "h", "m", "s"];
|
||||
const NONE_VALUE = "__none";
|
||||
|
||||
type TimedeltaDialogProps = {
|
||||
config: SamplerConfig;
|
||||
onUpdate: (patch: Partial<SamplerConfig>) => void;
|
||||
};
|
||||
|
||||
export function TimedeltaDialog({
|
||||
config,
|
||||
onUpdate,
|
||||
}: TimedeltaDialogProps): ReactElement {
|
||||
const configs = useCanvasLabStore((state) => state.configs);
|
||||
const datetimeOptions = useMemo(
|
||||
() =>
|
||||
Object.values(configs)
|
||||
.filter(
|
||||
(item) => item.kind === "sampler" && item.sampler_type === "datetime",
|
||||
)
|
||||
.map((item) => item.name),
|
||||
[configs],
|
||||
);
|
||||
const dtMinId = `${config.id}-timedelta-min`;
|
||||
const dtMaxId = `${config.id}-timedelta-max`;
|
||||
const unitId = `${config.id}-timedelta-unit`;
|
||||
const referenceId = `${config.id}-timedelta-reference`;
|
||||
const updateField = <K extends keyof SamplerConfig>(
|
||||
key: K,
|
||||
value: SamplerConfig[K],
|
||||
) => {
|
||||
onUpdate({ [key]: value } as Partial<SamplerConfig>);
|
||||
};
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<NameField
|
||||
value={config.name}
|
||||
onChange={(value) => onUpdate({ name: value })}
|
||||
/>
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<div className="grid gap-2">
|
||||
<label
|
||||
className="text-xs font-semibold uppercase text-muted-foreground"
|
||||
htmlFor={dtMinId}
|
||||
>
|
||||
dt_min
|
||||
</label>
|
||||
<Input
|
||||
id={dtMinId}
|
||||
type="number"
|
||||
className="nodrag"
|
||||
value={config.dt_min ?? ""}
|
||||
onChange={(event) => updateField("dt_min", event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<label
|
||||
className="text-xs font-semibold uppercase text-muted-foreground"
|
||||
htmlFor={dtMaxId}
|
||||
>
|
||||
dt_max
|
||||
</label>
|
||||
<Input
|
||||
id={dtMaxId}
|
||||
type="number"
|
||||
className="nodrag"
|
||||
value={config.dt_max ?? ""}
|
||||
onChange={(event) => updateField("dt_max", event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<label
|
||||
className="text-xs font-semibold uppercase text-muted-foreground"
|
||||
htmlFor={unitId}
|
||||
>
|
||||
Unit
|
||||
</label>
|
||||
<Select
|
||||
value={config.timedelta_unit ?? "D"}
|
||||
onValueChange={(value) =>
|
||||
updateField("timedelta_unit", value as "D" | "h" | "m" | "s")
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="nodrag w-full" id={unitId}>
|
||||
<SelectValue placeholder="Select unit" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{TIMEDELTA_UNITS.map((unit) => (
|
||||
<SelectItem key={unit} value={unit}>
|
||||
{unit}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<label
|
||||
className="text-xs font-semibold uppercase text-muted-foreground"
|
||||
htmlFor={referenceId}
|
||||
>
|
||||
Reference datetime column
|
||||
</label>
|
||||
<Select
|
||||
value={config.reference_column_name?.trim() || NONE_VALUE}
|
||||
onValueChange={(value) =>
|
||||
updateField("reference_column_name", value === NONE_VALUE ? "" : value)
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="nodrag w-full" id={referenceId}>
|
||||
<SelectValue placeholder="Select datetime column" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value={NONE_VALUE}>None</SelectItem>
|
||||
{datetimeOptions.map((name) => (
|
||||
<SelectItem key={name} value={name}>
|
||||
{name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,4 +1,11 @@
|
|||
import type { CanvasNode, LayoutDirection, NodeConfig } from "../types";
|
||||
import type {
|
||||
CanvasNode,
|
||||
LayoutDirection,
|
||||
LlmConfig,
|
||||
ModelConfig,
|
||||
NodeConfig,
|
||||
SamplerConfig,
|
||||
} from "../types";
|
||||
import { nodeDataFromConfig } from "../utils";
|
||||
import { removeRef, replaceRef } from "../utils/refs";
|
||||
|
||||
|
|
@ -129,19 +136,31 @@ export function applyRenameToConfig(
|
|||
config.sampler_type === "subcategory" &&
|
||||
config.subcategory_parent === from
|
||||
) {
|
||||
const base = next === config ? config : next;
|
||||
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 === config ? config : next;
|
||||
const base = next as ModelConfig;
|
||||
next = { ...base, provider: to };
|
||||
}
|
||||
if (config.kind === "llm" && config.model_alias === from) {
|
||||
const base = next === config ? config : next;
|
||||
const base = next as LlmConfig;
|
||||
next = { ...base, model_alias: to };
|
||||
}
|
||||
return next;
|
||||
|
|
@ -157,7 +176,7 @@ export function applyRemovalToConfig(
|
|||
config.sampler_type === "subcategory" &&
|
||||
config.subcategory_parent === ref
|
||||
) {
|
||||
const base = next === config ? config : next;
|
||||
const base = next as SamplerConfig;
|
||||
next = {
|
||||
...base,
|
||||
// biome-ignore lint/style/useNamingConvention: api schema
|
||||
|
|
@ -166,12 +185,24 @@ export function applyRemovalToConfig(
|
|||
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 === config ? config : next;
|
||||
const base = next as ModelConfig;
|
||||
next = { ...base, provider: "" };
|
||||
}
|
||||
if (config.kind === "llm" && config.model_alias === ref) {
|
||||
const base = next === config ? config : next;
|
||||
const base = next as LlmConfig;
|
||||
next = { ...base, model_alias: "" };
|
||||
}
|
||||
return next;
|
||||
|
|
|
|||
|
|
@ -21,6 +21,7 @@ import type {
|
|||
import { getBlockDefinition } from "../blocks/registry";
|
||||
import { isCategoryConfig, isSubcategoryConfig } from "../utils";
|
||||
import { applyCanvasConnection, isValidCanvasConnection } from "../utils/graph";
|
||||
import { HANDLE_IDS } from "../utils/handles";
|
||||
import type { CanvasSnapshot } from "../utils/import";
|
||||
import { getLayoutedElements } from "../utils/layout";
|
||||
import {
|
||||
|
|
@ -212,9 +213,9 @@ export const useCanvasLabStore = create<CanvasLabState>((set, get) => ({
|
|||
{
|
||||
source: parentId,
|
||||
target: id,
|
||||
sourceHandle: null,
|
||||
targetHandle: null,
|
||||
type: "semantic",
|
||||
sourceHandle: HANDLE_IDS.dataOut,
|
||||
targetHandle: HANDLE_IDS.dataIn,
|
||||
type: "canvas",
|
||||
},
|
||||
edges,
|
||||
);
|
||||
|
|
@ -241,8 +242,8 @@ export const useCanvasLabStore = create<CanvasLabState>((set, get) => ({
|
|||
{
|
||||
source: providerId,
|
||||
target: id,
|
||||
sourceHandle: null,
|
||||
targetHandle: null,
|
||||
sourceHandle: HANDLE_IDS.semanticOut,
|
||||
targetHandle: HANDLE_IDS.semanticIn,
|
||||
type: "semantic",
|
||||
},
|
||||
edges,
|
||||
|
|
@ -251,6 +252,51 @@ export const useCanvasLabStore = create<CanvasLabState>((set, get) => ({
|
|||
}
|
||||
}
|
||||
|
||||
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 ?? "";
|
||||
edges = edges.filter((edge) => {
|
||||
if (edge.target !== id) {
|
||||
return true;
|
||||
}
|
||||
const source = configs[edge.source];
|
||||
return !(
|
||||
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"
|
||||
) {
|
||||
edges = addEdge(
|
||||
{
|
||||
source: referenceId,
|
||||
target: id,
|
||||
sourceHandle: HANDLE_IDS.dataOut,
|
||||
targetHandle: HANDLE_IDS.dataIn,
|
||||
type: "canvas",
|
||||
},
|
||||
edges,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const hasModelAliasPatch = Object.prototype.hasOwnProperty.call(
|
||||
patch,
|
||||
"model_alias",
|
||||
|
|
@ -272,8 +318,8 @@ export const useCanvasLabStore = create<CanvasLabState>((set, get) => ({
|
|||
{
|
||||
source: modelConfigId,
|
||||
target: id,
|
||||
sourceHandle: null,
|
||||
targetHandle: null,
|
||||
sourceHandle: HANDLE_IDS.semanticOut,
|
||||
targetHandle: HANDLE_IDS.semanticIn,
|
||||
type: "semantic",
|
||||
},
|
||||
edges,
|
||||
|
|
@ -419,5 +465,13 @@ export const useCanvasLabStore = create<CanvasLabState>((set, get) => ({
|
|||
});
|
||||
},
|
||||
isValidConnection: (connection) =>
|
||||
isValidCanvasConnection(connection, get().configs),
|
||||
isValidCanvasConnection(
|
||||
{
|
||||
source: connection.source ?? null,
|
||||
target: connection.target ?? null,
|
||||
sourceHandle: connection.sourceHandle ?? null,
|
||||
targetHandle: connection.targetHandle ?? null,
|
||||
},
|
||||
get().configs,
|
||||
),
|
||||
}));
|
||||
|
|
|
|||
|
|
@ -5,7 +5,9 @@ export type SamplerType =
|
|||
| "subcategory"
|
||||
| "uniform"
|
||||
| "gaussian"
|
||||
| "bernoulli"
|
||||
| "datetime"
|
||||
| "timedelta"
|
||||
| "uuid"
|
||||
| "person"
|
||||
| "person_from_faker";
|
||||
|
|
@ -32,6 +34,13 @@ export type CanvasNodeData = {
|
|||
|
||||
export type CanvasNode = Node<CanvasNodeData, "builder">;
|
||||
|
||||
export type CategoryConditionalParams = {
|
||||
// biome-ignore lint/style/useNamingConvention: api schema
|
||||
sampler_type: "category";
|
||||
values: string[];
|
||||
weights?: Array<number | null>;
|
||||
};
|
||||
|
||||
export type SamplerConfig = {
|
||||
id: string;
|
||||
kind: "sampler";
|
||||
|
|
@ -46,6 +55,7 @@ export type SamplerConfig = {
|
|||
high?: string;
|
||||
mean?: string;
|
||||
std?: string;
|
||||
p?: string;
|
||||
// biome-ignore lint/style/useNamingConvention: api schema
|
||||
datetime_start?: string;
|
||||
// biome-ignore lint/style/useNamingConvention: api schema
|
||||
|
|
@ -53,6 +63,14 @@ export type SamplerConfig = {
|
|||
// biome-ignore lint/style/useNamingConvention: api schema
|
||||
datetime_unit?: string;
|
||||
// biome-ignore lint/style/useNamingConvention: api schema
|
||||
dt_min?: string;
|
||||
// biome-ignore lint/style/useNamingConvention: api schema
|
||||
dt_max?: string;
|
||||
// biome-ignore lint/style/useNamingConvention: api schema
|
||||
reference_column_name?: string;
|
||||
// biome-ignore lint/style/useNamingConvention: api schema
|
||||
timedelta_unit?: "D" | "h" | "m" | "s";
|
||||
// biome-ignore lint/style/useNamingConvention: api schema
|
||||
uuid_format?: string;
|
||||
// biome-ignore lint/style/useNamingConvention: api schema
|
||||
person_locale?: string;
|
||||
|
|
@ -68,6 +86,8 @@ export type SamplerConfig = {
|
|||
subcategory_parent?: string;
|
||||
// biome-ignore lint/style/useNamingConvention: api schema
|
||||
subcategory_mapping?: Record<string, string[]>;
|
||||
// biome-ignore lint/style/useNamingConvention: api schema
|
||||
conditional_params?: Record<string, CategoryConditionalParams>;
|
||||
};
|
||||
|
||||
export type ScoreOption = {
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import { type Connection, type Edge, addEdge } from "@xyflow/react";
|
||||
import type { NodeConfig, SamplerConfig } from "../types";
|
||||
import { HANDLE_IDS } from "./handles";
|
||||
import {
|
||||
isCategoryConfig,
|
||||
isExpressionConfig,
|
||||
|
|
@ -45,18 +46,28 @@ function syncSubcategoryMapping(
|
|||
};
|
||||
}
|
||||
|
||||
function isSemanticEdge(source: NodeConfig, target: NodeConfig): boolean {
|
||||
function isSemanticRelation(source: NodeConfig, target: NodeConfig): boolean {
|
||||
if (source.kind === "model_provider" && target.kind === "model_config") {
|
||||
return true;
|
||||
}
|
||||
if (source.kind === "model_config" && target.kind === "llm") {
|
||||
return true;
|
||||
}
|
||||
return source.kind === "model_config" && target.kind === "llm";
|
||||
}
|
||||
|
||||
function isModelInfraNode(config: NodeConfig): boolean {
|
||||
return config.kind === "model_provider" || config.kind === "model_config";
|
||||
}
|
||||
|
||||
function isSemanticLane(connection: Connection): boolean {
|
||||
return (
|
||||
source.kind === "sampler" &&
|
||||
source.sampler_type === "category" &&
|
||||
target.kind === "sampler" &&
|
||||
target.sampler_type === "subcategory"
|
||||
connection.sourceHandle === HANDLE_IDS.semanticOut &&
|
||||
connection.targetHandle === HANDLE_IDS.semanticIn
|
||||
);
|
||||
}
|
||||
|
||||
function isDataLane(connection: Connection): boolean {
|
||||
return (
|
||||
connection.sourceHandle === HANDLE_IDS.dataOut &&
|
||||
connection.targetHandle === HANDLE_IDS.dataIn
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -72,7 +83,17 @@ export function isValidCanvasConnection(
|
|||
}
|
||||
const source = configs[connection.source];
|
||||
const target = configs[connection.target];
|
||||
return Boolean(source && target);
|
||||
if (!(source && target)) {
|
||||
return false;
|
||||
}
|
||||
const semanticRelation = isSemanticRelation(source, target);
|
||||
if (semanticRelation) {
|
||||
return isSemanticLane(connection);
|
||||
}
|
||||
if (isModelInfraNode(source) || isModelInfraNode(target)) {
|
||||
return false;
|
||||
}
|
||||
return isDataLane(connection);
|
||||
}
|
||||
|
||||
export function applyCanvasConnection(
|
||||
|
|
@ -88,8 +109,9 @@ export function applyCanvasConnection(
|
|||
if (!(source && target)) {
|
||||
return { edges };
|
||||
}
|
||||
const semanticRelation = isSemanticRelation(source, target);
|
||||
const nextEdges = addEdge(
|
||||
{ ...connection, type: isSemanticEdge(source, target) ? "semantic" : "canvas" },
|
||||
{ ...connection, type: semanticRelation ? "semantic" : "canvas" },
|
||||
edges,
|
||||
);
|
||||
if (source.kind === "model_provider" && target.kind === "model_config") {
|
||||
|
|
@ -100,6 +122,19 @@ export function applyCanvasConnection(
|
|||
const next = { ...target, model_alias: source.name };
|
||||
return { edges: nextEdges, configs: { ...configs, [target.id]: next } };
|
||||
}
|
||||
if (
|
||||
source.kind === "sampler" &&
|
||||
source.sampler_type === "datetime" &&
|
||||
target.kind === "sampler" &&
|
||||
target.sampler_type === "timedelta"
|
||||
) {
|
||||
const next = {
|
||||
...target,
|
||||
// biome-ignore lint/style/useNamingConvention: api schema
|
||||
reference_column_name: source.name,
|
||||
};
|
||||
return { edges: nextEdges, configs: { ...configs, [target.id]: next } };
|
||||
}
|
||||
if (isLlmConfig(target) && source.kind !== "model_provider" && source.kind !== "model_config") {
|
||||
const ref = `{{ ${source.name} }}`;
|
||||
const next = {
|
||||
|
|
|
|||
10
studio/frontend/src/features/canvas-lab/utils/handles.ts
Normal file
10
studio/frontend/src/features/canvas-lab/utils/handles.ts
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
export const HANDLE_IDS = {
|
||||
// data flow lanes
|
||||
dataIn: "data-in",
|
||||
dataOut: "data-out",
|
||||
// semantic dependency lanes
|
||||
semanticIn: "semantic-in",
|
||||
semanticOut: "semantic-out",
|
||||
} as const;
|
||||
|
||||
export type CanvasHandleId = (typeof HANDLE_IDS)[keyof typeof HANDLE_IDS];
|
||||
|
|
@ -1,20 +1,13 @@
|
|||
import type { Edge } from "@xyflow/react";
|
||||
import type { NodeConfig } from "../../types";
|
||||
import { HANDLE_IDS } from "../handles";
|
||||
import { extractRefs } from "./helpers";
|
||||
|
||||
function isSemanticConnection(source: NodeConfig, target: NodeConfig): boolean {
|
||||
if (source.kind === "model_provider" && target.kind === "model_config") {
|
||||
return true;
|
||||
}
|
||||
if (source.kind === "model_config" && target.kind === "llm") {
|
||||
return true;
|
||||
}
|
||||
return (
|
||||
source.kind === "sampler" &&
|
||||
source.sampler_type === "category" &&
|
||||
target.kind === "sampler" &&
|
||||
target.sampler_type === "subcategory"
|
||||
);
|
||||
return source.kind === "model_config" && target.kind === "llm";
|
||||
}
|
||||
|
||||
export function buildEdges(
|
||||
|
|
@ -42,11 +35,22 @@ export function buildEdges(
|
|||
source && target && isSemanticConnection(source, target)
|
||||
? "semantic"
|
||||
: (type ?? "canvas");
|
||||
const handles =
|
||||
normalizedType === "semantic"
|
||||
? {
|
||||
sourceHandle: HANDLE_IDS.semanticOut,
|
||||
targetHandle: HANDLE_IDS.semanticIn,
|
||||
}
|
||||
: {
|
||||
sourceHandle: HANDLE_IDS.dataOut,
|
||||
targetHandle: HANDLE_IDS.dataIn,
|
||||
};
|
||||
edges.push({
|
||||
id: `e-${key}`,
|
||||
source: sourceId,
|
||||
target: targetId,
|
||||
type: normalizedType,
|
||||
...handles,
|
||||
});
|
||||
};
|
||||
|
||||
|
|
@ -76,11 +80,18 @@ export function buildEdges(
|
|||
config.sampler_type === "subcategory" &&
|
||||
config.subcategory_parent
|
||||
) {
|
||||
addEdgeByName(config.subcategory_parent, config.name, "semantic");
|
||||
addEdgeByName(config.subcategory_parent, config.name, "canvas");
|
||||
}
|
||||
if (config.kind === "model_config" && config.provider) {
|
||||
addEdgeByName(config.provider, config.name, "semantic");
|
||||
}
|
||||
if (
|
||||
config.kind === "sampler" &&
|
||||
config.sampler_type === "timedelta" &&
|
||||
config.reference_column_name
|
||||
) {
|
||||
addEdgeByName(config.reference_column_name, config.name, "canvas");
|
||||
}
|
||||
if (config.kind === "llm" && config.model_alias) {
|
||||
addEdgeByName(config.model_alias, config.name, "semantic");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -22,13 +22,49 @@ const SAMPLER_TYPES: SamplerType[] = [
|
|||
"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>,
|
||||
|
|
@ -67,6 +103,8 @@ function parseSampler(
|
|||
convert_to: normalizedConvertTo,
|
||||
values,
|
||||
weights,
|
||||
// biome-ignore lint/style/useNamingConvention: api schema
|
||||
conditional_params: parseCategoryConditionalParams(column),
|
||||
};
|
||||
}
|
||||
if (samplerType === "subcategory") {
|
||||
|
|
@ -118,6 +156,18 @@ function parseSampler(
|
|||
std: readNumberString(params.std),
|
||||
};
|
||||
}
|
||||
if (samplerType === "bernoulli") {
|
||||
return {
|
||||
id,
|
||||
kind: "sampler",
|
||||
// biome-ignore lint/style/useNamingConvention: api schema
|
||||
sampler_type: "bernoulli",
|
||||
name,
|
||||
// biome-ignore lint/style/useNamingConvention: api schema
|
||||
convert_to: normalizedConvertTo,
|
||||
p: readNumberString(params.p),
|
||||
};
|
||||
}
|
||||
if (samplerType === "datetime") {
|
||||
return {
|
||||
id,
|
||||
|
|
@ -135,6 +185,30 @@ function parseSampler(
|
|||
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,
|
||||
// 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,
|
||||
|
|
@ -154,7 +228,7 @@ function parseSampler(
|
|||
params.age_range.every((item) => typeof item === "number")
|
||||
? `${params.age_range[0]}-${params.age_range[1]}`
|
||||
: readString(params.age_range) ?? "";
|
||||
const base = {
|
||||
const base: SamplerConfig = {
|
||||
id,
|
||||
kind: "sampler",
|
||||
name,
|
||||
|
|
|
|||
|
|
@ -18,7 +18,9 @@ const SAMPLER_LABELS: Record<SamplerType, string> = {
|
|||
subcategory: "Subcategory",
|
||||
uniform: "Uniform",
|
||||
gaussian: "Gaussian",
|
||||
bernoulli: "Bernoulli",
|
||||
datetime: "Datetime",
|
||||
timedelta: "Timedelta",
|
||||
uuid: "UUID",
|
||||
person: "Person",
|
||||
person_from_faker: "Person (Faker)",
|
||||
|
|
@ -110,6 +112,16 @@ export function makeSamplerConfig(
|
|||
std: "1",
|
||||
};
|
||||
}
|
||||
if (samplerType === "bernoulli") {
|
||||
return {
|
||||
id,
|
||||
kind: "sampler",
|
||||
// biome-ignore lint/style/useNamingConvention: api schema
|
||||
sampler_type: "bernoulli",
|
||||
name,
|
||||
p: "0.5",
|
||||
};
|
||||
}
|
||||
if (samplerType === "datetime") {
|
||||
return {
|
||||
id,
|
||||
|
|
@ -125,6 +137,23 @@ export function makeSamplerConfig(
|
|||
datetime_unit: "day",
|
||||
};
|
||||
}
|
||||
if (samplerType === "timedelta") {
|
||||
return {
|
||||
id,
|
||||
kind: "sampler",
|
||||
// biome-ignore lint/style/useNamingConvention: api schema
|
||||
sampler_type: "timedelta",
|
||||
name,
|
||||
// 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,
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import type { Edge } from "@xyflow/react";
|
||||
import type {
|
||||
CategoryConditionalParams,
|
||||
CanvasNode,
|
||||
ExpressionConfig,
|
||||
LlmConfig,
|
||||
|
|
@ -43,15 +44,7 @@ function isSemanticRelation(
|
|||
if (source.kind === "model_provider" && target.kind === "model_config") {
|
||||
return true;
|
||||
}
|
||||
if (source.kind === "model_config" && target.kind === "llm") {
|
||||
return true;
|
||||
}
|
||||
return (
|
||||
source.kind === "sampler" &&
|
||||
source.sampler_type === "category" &&
|
||||
target.kind === "sampler" &&
|
||||
target.sampler_type === "subcategory"
|
||||
);
|
||||
return source.kind === "model_config" && target.kind === "llm";
|
||||
}
|
||||
|
||||
function parseNumber(value?: string): number | null {
|
||||
|
|
@ -99,6 +92,46 @@ function parseJsonObject(
|
|||
return undefined;
|
||||
}
|
||||
|
||||
function buildCategoryConditionalParams(
|
||||
config: SamplerConfig,
|
||||
errors: string[],
|
||||
): Record<string, CategoryConditionalParams> | undefined {
|
||||
const conditional = config.conditional_params ?? {};
|
||||
const output: Record<string, CategoryConditionalParams> = {};
|
||||
for (const [rawCondition, params] of Object.entries(conditional)) {
|
||||
const condition = rawCondition.trim();
|
||||
if (!condition) {
|
||||
errors.push(`Sampler ${config.name}: conditional rule needs condition text.`);
|
||||
continue;
|
||||
}
|
||||
const values = (params.values ?? [])
|
||||
.map((value) => value.trim())
|
||||
.filter(Boolean);
|
||||
if (values.length === 0) {
|
||||
errors.push(`Sampler ${config.name}: conditional '${condition}' needs values.`);
|
||||
continue;
|
||||
}
|
||||
const weights = params.weights ?? [];
|
||||
const hasWeights = weights.some((weight) => weight !== null);
|
||||
if (
|
||||
hasWeights &&
|
||||
(weights.length !== values.length || weights.some((weight) => weight === null))
|
||||
) {
|
||||
errors.push(`Sampler ${config.name}: conditional '${condition}' weights invalid.`);
|
||||
continue;
|
||||
}
|
||||
output[condition] = {
|
||||
// biome-ignore lint/style/useNamingConvention: api schema
|
||||
sampler_type: "category",
|
||||
values,
|
||||
weights: hasWeights
|
||||
? weights.filter((weight): weight is number => weight !== null)
|
||||
: undefined,
|
||||
};
|
||||
}
|
||||
return Object.keys(output).length > 0 ? output : undefined;
|
||||
}
|
||||
|
||||
function buildModelProvider(
|
||||
config: ModelProviderConfig,
|
||||
errors: string[],
|
||||
|
|
@ -216,6 +249,11 @@ function buildSamplerParams(
|
|||
std: parseNumber(config.std),
|
||||
};
|
||||
}
|
||||
if (config.sampler_type === "bernoulli") {
|
||||
return {
|
||||
p: parseNumber(config.p),
|
||||
};
|
||||
}
|
||||
if (config.sampler_type === "datetime") {
|
||||
return {
|
||||
start: config.datetime_start ?? undefined,
|
||||
|
|
@ -223,6 +261,17 @@ function buildSamplerParams(
|
|||
unit: config.datetime_unit ?? undefined,
|
||||
};
|
||||
}
|
||||
if (config.sampler_type === "timedelta") {
|
||||
return {
|
||||
// biome-ignore lint/style/useNamingConvention: api schema
|
||||
dt_min: parseNumber(config.dt_min),
|
||||
// biome-ignore lint/style/useNamingConvention: api schema
|
||||
dt_max: parseNumber(config.dt_max),
|
||||
// biome-ignore lint/style/useNamingConvention: api schema
|
||||
reference_column_name: config.reference_column_name || undefined,
|
||||
unit: config.timedelta_unit || undefined,
|
||||
};
|
||||
}
|
||||
if (config.sampler_type === "uuid") {
|
||||
return {
|
||||
format: config.uuid_format ?? undefined,
|
||||
|
|
@ -389,7 +438,7 @@ export function buildCanvasPayload(
|
|||
|
||||
if (config.kind === "sampler") {
|
||||
nameToConfig.set(config.name, config);
|
||||
columns.push({
|
||||
const samplerColumn: Record<string, unknown> = {
|
||||
// biome-ignore lint/style/useNamingConvention: api schema
|
||||
column_type: "sampler",
|
||||
name: config.name,
|
||||
|
|
@ -398,7 +447,15 @@ export function buildCanvasPayload(
|
|||
params: buildSamplerParams(config, errors),
|
||||
// biome-ignore lint/style/useNamingConvention: api schema
|
||||
convert_to: config.convert_to ?? undefined,
|
||||
});
|
||||
};
|
||||
if (config.sampler_type === "category") {
|
||||
const conditionalParams = buildCategoryConditionalParams(config, errors);
|
||||
if (conditionalParams) {
|
||||
// biome-ignore lint/style/useNamingConvention: api schema
|
||||
samplerColumn.conditional_params = conditionalParams;
|
||||
}
|
||||
}
|
||||
columns.push(samplerColumn);
|
||||
} else if (config.kind === "llm") {
|
||||
columns.push(buildLlmColumn(config, errors));
|
||||
if (config.model_alias) {
|
||||
|
|
@ -442,6 +499,24 @@ export function buildCanvasPayload(
|
|||
}
|
||||
}
|
||||
}
|
||||
for (const config of Object.values(configs)) {
|
||||
if (config.kind !== "sampler" || config.sampler_type !== "timedelta") {
|
||||
continue;
|
||||
}
|
||||
const reference = config.reference_column_name?.trim() ?? "";
|
||||
if (!reference) {
|
||||
errors.push(`Timedelta ${config.name}: reference datetime column required.`);
|
||||
continue;
|
||||
}
|
||||
const parent = nameToConfig.get(reference);
|
||||
if (
|
||||
!parent ||
|
||||
parent.kind !== "sampler" ||
|
||||
parent.sampler_type !== "datetime"
|
||||
) {
|
||||
errors.push(`Timedelta ${config.name}: reference '${reference}' must be datetime.`);
|
||||
}
|
||||
}
|
||||
|
||||
for (const alias of modelAliases) {
|
||||
if (
|
||||
|
|
|
|||
|
|
@ -44,6 +44,34 @@ export function getConfigErrors(config: NodeConfig | null): string[] {
|
|||
if (hasWeights && weights.some((weight) => weight === null)) {
|
||||
errors.push("Weights must be set for all values.");
|
||||
}
|
||||
for (const [condition, params] of Object.entries(
|
||||
config.conditional_params ?? {},
|
||||
)) {
|
||||
if (!condition.trim()) {
|
||||
errors.push("Category conditional rule needs condition text.");
|
||||
continue;
|
||||
}
|
||||
const conditionalValues = (params.values ?? [])
|
||||
.map((value) => value.trim())
|
||||
.filter(Boolean);
|
||||
if (conditionalValues.length === 0) {
|
||||
errors.push(`Category conditional '${condition}' needs values.`);
|
||||
continue;
|
||||
}
|
||||
const conditionalWeights = params.weights ?? [];
|
||||
const hasConditionalWeights = conditionalWeights.some(
|
||||
(weight) => weight !== null,
|
||||
);
|
||||
if (
|
||||
hasConditionalWeights &&
|
||||
(conditionalWeights.length !== conditionalValues.length ||
|
||||
conditionalWeights.some((weight) => weight === null))
|
||||
) {
|
||||
errors.push(
|
||||
`Category conditional '${condition}' weights must be set for all values.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (config.sampler_type === "uniform") {
|
||||
const low = parseNumber(config.low);
|
||||
|
|
@ -63,6 +91,14 @@ export function getConfigErrors(config: NodeConfig | null): string[] {
|
|||
errors.push("Gaussian std must be > 0.");
|
||||
}
|
||||
}
|
||||
if (config.sampler_type === "bernoulli") {
|
||||
const p = parseNumber(config.p);
|
||||
if (p === null) {
|
||||
errors.push("Bernoulli p must be a number.");
|
||||
} else if (p < 0 || p > 1) {
|
||||
errors.push("Bernoulli p must be between 0 and 1.");
|
||||
}
|
||||
}
|
||||
if (config.sampler_type === "datetime") {
|
||||
if (!config.datetime_unit) {
|
||||
errors.push("Datetime unit required.");
|
||||
|
|
@ -77,6 +113,21 @@ export function getConfigErrors(config: NodeConfig | null): string[] {
|
|||
}
|
||||
}
|
||||
}
|
||||
if (config.sampler_type === "timedelta") {
|
||||
const min = parseNumber(config.dt_min);
|
||||
const max = parseNumber(config.dt_max);
|
||||
if (min === null || max === null) {
|
||||
errors.push("Timedelta dt_min/dt_max must be numbers.");
|
||||
} else if (min >= max) {
|
||||
errors.push("Timedelta dt_min must be < dt_max.");
|
||||
}
|
||||
if (!config.reference_column_name?.trim()) {
|
||||
errors.push("Timedelta reference datetime column required.");
|
||||
}
|
||||
if (!config.timedelta_unit) {
|
||||
errors.push("Timedelta unit required.");
|
||||
}
|
||||
}
|
||||
if (config.sampler_type === "subcategory" && !config.subcategory_parent) {
|
||||
errors.push("Subcategory needs a parent category column.");
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue