diff --git a/studio/frontend/src/features/canvas-lab/blocks/registry.tsx b/studio/frontend/src/features/canvas-lab/blocks/registry.tsx index cf0cc82096..e47d5d0812 100644 --- a/studio/frontend/src/features/canvas-lab/blocks/registry.tsx +++ b/studio/frontend/src/features/canvas-lab/blocks/registry.tsx @@ -171,7 +171,9 @@ const BLOCK_DEFINITIONS: BlockDefinition[] = [ icon: Database02Icon, createConfig: (id, existing) => makeSamplerConfig(id, "person", existing), renderDialog: ({ config, onUpdate }) => - config.kind === "sampler" && config.sampler_type === "person" ? ( + config.kind === "sampler" && + (config.sampler_type === "person" || + config.sampler_type === "person_from_faker") ? ( onUpdate(config.id, patch)} @@ -262,7 +264,11 @@ export function getBlockDefinitionForConfig( return null; } if (config.kind === "sampler") { - return getBlockDefinition("sampler", config.sampler_type); + const samplerType = + config.sampler_type === "person_from_faker" + ? "person" + : config.sampler_type; + return getBlockDefinition("sampler", samplerType); } if (config.kind === "llm") { return getBlockDefinition("llm", config.llm_type); diff --git a/studio/frontend/src/features/canvas-lab/dialogs/samplers/gaussian-dialog.tsx b/studio/frontend/src/features/canvas-lab/dialogs/samplers/gaussian-dialog.tsx index 21473751e3..75205b4853 100644 --- a/studio/frontend/src/features/canvas-lab/dialogs/samplers/gaussian-dialog.tsx +++ b/studio/frontend/src/features/canvas-lab/dialogs/samplers/gaussian-dialog.tsx @@ -1,4 +1,11 @@ import { Input } from "@/components/ui/input"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; import type { ReactElement } from "react"; import type { SamplerConfig } from "../../types"; import { NameField } from "../shared/name-field"; @@ -14,6 +21,7 @@ export function GaussianDialog({ }: GaussianDialogProps): ReactElement { const meanId = `${config.id}-gaussian-mean`; const stdId = `${config.id}-gaussian-std`; + const convertId = `${config.id}-gaussian-convert`; return (
+
+ + +
); } diff --git a/studio/frontend/src/features/canvas-lab/dialogs/samplers/person-dialog.tsx b/studio/frontend/src/features/canvas-lab/dialogs/samplers/person-dialog.tsx index 3ed696a4e9..f3c7c10b66 100644 --- a/studio/frontend/src/features/canvas-lab/dialogs/samplers/person-dialog.tsx +++ b/studio/frontend/src/features/canvas-lab/dialogs/samplers/person-dialog.tsx @@ -1,4 +1,11 @@ import { Input } from "@/components/ui/input"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; import { Switch } from "@/components/ui/switch"; import type { ReactElement } from "react"; import type { SamplerConfig } from "../../types"; @@ -17,6 +24,7 @@ export function PersonDialog({ const sexId = `${config.id}-person-sex`; const ageRangeId = `${config.id}-person-age-range`; const cityId = `${config.id}-person-city`; + const sourceId = `${config.id}-person-source`; const updateField = ( key: K, value: SamplerConfig[K], @@ -30,6 +38,31 @@ export function PersonDialog({ onChange={(value) => onUpdate({ name: value })} />
+
+ + +
- - updateField("person_sex", event.target.value) +
@@ -96,34 +137,22 @@ export function PersonDialog({ />
-
-
-

Synthetic personas

-

- Generate persona profiles. -

+ {config.sampler_type === "person" && ( +
+
+

Synthetic personas

+

+ Generate persona profiles. +

+
+ + updateField("person_with_synthetic_personas", value) + } + />
- - updateField("person_with_synthetic_personas", value) - } - /> -
-
-
-

Sample dataset

-

- Use dataset when available. -

-
- - updateField("person_sample_dataset_when_available", value) - } - /> -
+ )}
); diff --git a/studio/frontend/src/features/canvas-lab/dialogs/samplers/uniform-dialog.tsx b/studio/frontend/src/features/canvas-lab/dialogs/samplers/uniform-dialog.tsx index 9beeacc2df..8ae9ea9b48 100644 --- a/studio/frontend/src/features/canvas-lab/dialogs/samplers/uniform-dialog.tsx +++ b/studio/frontend/src/features/canvas-lab/dialogs/samplers/uniform-dialog.tsx @@ -1,4 +1,11 @@ import { Input } from "@/components/ui/input"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; import type { ReactElement } from "react"; import type { SamplerConfig } from "../../types"; import { NameField } from "../shared/name-field"; @@ -14,6 +21,7 @@ export function UniformDialog({ }: UniformDialogProps): ReactElement { const lowId = `${config.id}-uniform-low`; const highId = `${config.id}-uniform-high`; + const convertId = `${config.id}-uniform-convert`; return (
+
+ + +
); } diff --git a/studio/frontend/src/features/canvas-lab/types/index.ts b/studio/frontend/src/features/canvas-lab/types/index.ts index da79066735..873ea1e898 100644 --- a/studio/frontend/src/features/canvas-lab/types/index.ts +++ b/studio/frontend/src/features/canvas-lab/types/index.ts @@ -7,7 +7,8 @@ export type SamplerType = | "gaussian" | "datetime" | "uuid" - | "person"; + | "person" + | "person_from_faker"; export type LlmType = "text" | "structured" | "code"; @@ -28,6 +29,8 @@ export type SamplerConfig = { // biome-ignore lint/style/useNamingConvention: api schema sampler_type: SamplerType; name: string; + // biome-ignore lint/style/useNamingConvention: api schema + convert_to?: "float" | "int" | "str"; values?: string[]; weights?: Array; low?: string; @@ -53,8 +56,6 @@ export type SamplerConfig = { // biome-ignore lint/style/useNamingConvention: api schema person_with_synthetic_personas?: boolean; // biome-ignore lint/style/useNamingConvention: api schema - person_sample_dataset_when_available?: boolean; - // biome-ignore lint/style/useNamingConvention: api schema subcategory_parent?: string; // biome-ignore lint/style/useNamingConvention: api schema subcategory_mapping?: Record; diff --git a/studio/frontend/src/features/canvas-lab/utils/graph.ts b/studio/frontend/src/features/canvas-lab/utils/graph.ts index 7c9be2634e..afdee312ac 100644 --- a/studio/frontend/src/features/canvas-lab/utils/graph.ts +++ b/studio/frontend/src/features/canvas-lab/utils/graph.ts @@ -57,16 +57,7 @@ export function isValidCanvasConnection( } const source = configs[connection.source]; const target = configs[connection.target]; - if (!(source && target)) { - return false; - } - if (isSubcategoryConfig(target)) { - return isCategoryConfig(source); - } - if (isLlmConfig(target) || isExpressionConfig(target)) { - return true; - } - return false; + return Boolean(source && target); } export function applyCanvasConnection( @@ -99,7 +90,7 @@ export function applyCanvasConnection( }; return { edges: nextEdges, configs: { ...configs, [target.id]: next } }; } - if (isSubcategoryConfig(target)) { + if (isSubcategoryConfig(target) && isCategoryConfig(source)) { const next = syncSubcategoryMapping(target, source); return { edges: nextEdges, configs: { ...configs, [target.id]: next } }; } diff --git a/studio/frontend/src/features/canvas-lab/utils/import/parsers.ts b/studio/frontend/src/features/canvas-lab/utils/import/parsers.ts index b071b1fbf8..6300dd1875 100644 --- a/studio/frontend/src/features/canvas-lab/utils/import/parsers.ts +++ b/studio/frontend/src/features/canvas-lab/utils/import/parsers.ts @@ -16,6 +16,7 @@ const SAMPLER_TYPES: SamplerType[] = [ "datetime", "uuid", "person", + "person_from_faker", ]; const EXPRESSION_DTYPES: ExpressionDtype[] = ["str", "int", "float", "bool"]; @@ -31,6 +32,11 @@ function parseSampler( 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) @@ -48,6 +54,8 @@ function parseSampler( // biome-ignore lint/style/useNamingConvention: api schema sampler_type: "category", name, + // biome-ignore lint/style/useNamingConvention: api schema + convert_to: normalizedConvertTo, values, weights, }; @@ -68,6 +76,8 @@ function parseSampler( sampler_type: "subcategory", name, // 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, @@ -80,6 +90,8 @@ function parseSampler( // biome-ignore lint/style/useNamingConvention: api schema sampler_type: "uniform", name, + // biome-ignore lint/style/useNamingConvention: api schema + convert_to: normalizedConvertTo, low: readNumberString(params.low), high: readNumberString(params.high), }; @@ -91,6 +103,8 @@ function parseSampler( // biome-ignore lint/style/useNamingConvention: api schema sampler_type: "gaussian", name, + // biome-ignore lint/style/useNamingConvention: api schema + convert_to: normalizedConvertTo, mean: readNumberString(params.mean), std: readNumberString(params.std), }; @@ -103,6 +117,8 @@ function parseSampler( sampler_type: "datetime", name, // 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) ?? "", @@ -118,34 +134,45 @@ function parseSampler( sampler_type: "uuid", name, // biome-ignore lint/style/useNamingConvention: api schema + convert_to: normalizedConvertTo, + // biome-ignore lint/style/useNamingConvention: api schema uuid_format: readString(params.format) ?? "", }; } - return { + 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 = { id, kind: "sampler", - // biome-ignore lint/style/useNamingConvention: api schema - sampler_type: "person", name, // 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: readString(params.age_range) ?? "", + person_age_range: ageRange, // biome-ignore lint/style/useNamingConvention: api schema person_city: readString(params.city) ?? "", - // biome-ignore lint/style/useNamingConvention: api schema - person_with_synthetic_personas: - typeof params.with_synthetic_personas === "boolean" - ? params.with_synthetic_personas - : false, - // biome-ignore lint/style/useNamingConvention: api schema - person_sample_dataset_when_available: - typeof params.sample_dataset_when_available === "boolean" - ? params.sample_dataset_when_available - : false, }; + 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( diff --git a/studio/frontend/src/features/canvas-lab/utils/index.ts b/studio/frontend/src/features/canvas-lab/utils/index.ts index 496ceef43a..8b6dc47989 100644 --- a/studio/frontend/src/features/canvas-lab/utils/index.ts +++ b/studio/frontend/src/features/canvas-lab/utils/index.ts @@ -8,6 +8,7 @@ import type { SamplerConfig, SamplerType, } from "../types"; +export { getConfigErrors } from "./validation"; const SAMPLER_LABELS: Record = { category: "Category", @@ -17,6 +18,7 @@ const SAMPLER_LABELS: Record = { datetime: "Datetime", uuid: "UUID", person: "Person", + person_from_faker: "Person (Faker)", }; const LLM_LABELS: Record = { @@ -130,6 +132,23 @@ export function makeSamplerConfig( uuid_format: "", }; } + if (samplerType === "person_from_faker") { + return { + id, + kind: "sampler", + // biome-ignore lint/style/useNamingConvention: api schema + sampler_type: "person_from_faker", + name, + // 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", @@ -146,8 +165,6 @@ export function makeSamplerConfig( person_city: "", // biome-ignore lint/style/useNamingConvention: api schema person_with_synthetic_personas: false, - // biome-ignore lint/style/useNamingConvention: api schema - person_sample_dataset_when_available: false, }; } @@ -170,7 +187,7 @@ export function makeLlmConfig( llm_type: llmType, name, // biome-ignore lint/style/useNamingConvention: api schema - model_alias: "stepfun/step-3.5-flash:free", + model_alias: "allenai/olmo-3.1-32b-instruct", prompt: "Write a response.", // biome-ignore lint/style/useNamingConvention: api schema system_prompt: "", @@ -267,98 +284,3 @@ export function isExpressionConfig( ): config is ExpressionConfig { return Boolean(config && config.kind === "expression"); } - -function parseNumber(value?: string): number | null { - if (!value) { - return null; - } - const num = Number(value); - return Number.isFinite(num) ? num : null; -} - -// biome-ignore lint/complexity/noExcessiveCognitiveComplexity: validation rules -export function getConfigErrors(config: NodeConfig | null): string[] { - if (!config) { - return []; - } - const errors: string[] = []; - if (!config.name.trim()) { - errors.push("Name is required."); - } - if (config.kind === "sampler") { - if (config.sampler_type === "category") { - const values = config.values ?? []; - if (values.length < 2) { - errors.push("Category needs at least 2 values."); - } - const weights = config.weights ?? []; - const hasWeights = weights.some((weight) => weight !== null); - if (hasWeights && weights.some((weight) => weight === null)) { - errors.push("Weights must be set for all values."); - } - } - if (config.sampler_type === "uniform") { - const low = parseNumber(config.low); - const high = parseNumber(config.high); - if (low === null || high === null) { - errors.push("Uniform low/high must be numbers."); - } else if (low >= high) { - errors.push("Uniform low must be < high."); - } - } - if (config.sampler_type === "gaussian") { - const mean = parseNumber(config.mean); - const std = parseNumber(config.std); - if (mean === null || std === null) { - errors.push("Gaussian mean/std must be numbers."); - } else if (std <= 0) { - errors.push("Gaussian std must be > 0."); - } - } - if (config.sampler_type === "datetime") { - if (!config.datetime_unit) { - errors.push("Datetime unit required."); - } - if (config.datetime_start && config.datetime_end) { - const start = new Date(config.datetime_start).getTime(); - const end = new Date(config.datetime_end).getTime(); - if (!(Number.isFinite(start) && Number.isFinite(end))) { - errors.push("Datetime start/end must be valid."); - } else if (start >= end) { - errors.push("Datetime start must be before end."); - } - } - } - if (config.sampler_type === "subcategory" && !config.subcategory_parent) { - errors.push("Subcategory needs a parent category column."); - } - } - if (config.kind === "llm") { - if (!config.model_alias.trim()) { - errors.push("Model alias is required."); - } - if (!config.prompt.trim()) { - errors.push("Prompt is required."); - } - if (config.llm_type === "code" && !config.code_lang) { - errors.push("Code language is required."); - } - if (config.llm_type === "structured") { - if (!config.output_format?.trim()) { - errors.push("Output format is required."); - } else { - try { - JSON.parse(config.output_format); - } catch { - errors.push("Output format must be valid JSON."); - } - } - } - } - if (config.kind === "expression") { - if (!config.expr.trim()) { - errors.push("Expression is required."); - } - } - return errors; -} diff --git a/studio/frontend/src/features/canvas-lab/utils/payload.ts b/studio/frontend/src/features/canvas-lab/utils/payload.ts index 0f2d55a7e5..c5f40c1773 100644 --- a/studio/frontend/src/features/canvas-lab/utils/payload.ts +++ b/studio/frontend/src/features/canvas-lab/utils/payload.ts @@ -23,7 +23,7 @@ const DEFAULT_PROVIDER = { const DEFAULT_CONFIG = { provider: "openrouter", - model: "stepfun/step-3.5-flash:free", + model: "allenai/olmo-3.1-32b-instruct", // biome-ignore lint/style/useNamingConvention: api schema inference_parameters: { temperature: 0.7, @@ -66,6 +66,29 @@ function parseNumber(value?: string): number | null { return Number.isFinite(num) ? num : null; } +function parseAgeRange(value?: string): [number, number] | null { + if (!value) { + return null; + } + const parts = value.split(/[^0-9.]+/).filter(Boolean); + if (parts.length !== 2) { + return null; + } + const min = Number(parts[0]); + const max = Number(parts[1]); + if (!Number.isFinite(min) || !Number.isFinite(max)) { + return null; + } + return [min, max]; +} + +function isValidSex(value?: string): value is "Male" | "Female" { + if (!value) { + return false; + } + return value === "Male" || value === "Female"; +} + // biome-ignore lint/complexity/noExcessiveCognitiveComplexity: per type logic function buildSamplerParams( config: SamplerConfig, @@ -121,18 +144,37 @@ function buildSamplerParams( format: config.uuid_format ?? undefined, }; } - return { - locale: config.person_locale ?? undefined, - sex: config.person_sex ?? undefined, + const params: Record = {}; + if (config.person_locale?.trim()) { + params.locale = config.person_locale.trim(); + } + if (config.sampler_type === "person") { + if (isValidSex(config.person_sex?.trim())) { + params.sex = config.person_sex?.trim(); + } else if (config.person_sex?.trim()) { + errors.push(`Person ${config.name}: sex must be Male or Female.`); + } + } else if (config.person_sex?.trim()) { + params.sex = config.person_sex.trim(); + } + if (config.person_city?.trim()) { + params.city = config.person_city.trim(); + } + if (config.person_age_range?.trim()) { + const parsed = parseAgeRange(config.person_age_range); + if (parsed) { + // biome-ignore lint/style/useNamingConvention: api schema + params.age_range = parsed; + } else { + errors.push(`Person ${config.name}: age range must be like 18-70.`); + } + } + if (config.sampler_type === "person") { // biome-ignore lint/style/useNamingConvention: api schema - age_range: config.person_age_range ?? undefined, - city: config.person_city ?? undefined, - // biome-ignore lint/style/useNamingConvention: api schema - with_synthetic_personas: config.person_with_synthetic_personas ?? undefined, - // biome-ignore lint/style/useNamingConvention: api schema - sample_dataset_when_available: - config.person_sample_dataset_when_available ?? undefined, - }; + params.with_synthetic_personas = + config.person_with_synthetic_personas ?? undefined; + } + return params; } function buildLlmColumn( @@ -233,6 +275,8 @@ export function buildCanvasPayload( // biome-ignore lint/style/useNamingConvention: api schema sampler_type: config.sampler_type, params: buildSamplerParams(config, errors), + // biome-ignore lint/style/useNamingConvention: api schema + convert_to: config.convert_to ?? undefined, }); } else if (config.kind === "llm") { columns.push(buildLlmColumn(config, errors)); diff --git a/studio/frontend/src/features/canvas-lab/utils/validation.ts b/studio/frontend/src/features/canvas-lab/utils/validation.ts new file mode 100644 index 0000000000..30d29dbed5 --- /dev/null +++ b/studio/frontend/src/features/canvas-lab/utils/validation.ts @@ -0,0 +1,134 @@ +import type { NodeConfig } from "../types"; + +function parseNumber(value?: string): number | null { + if (!value) { + return null; + } + const num = Number(value); + return Number.isFinite(num) ? num : null; +} + +function parseAgeRange(value?: string): [number, number] | null { + if (!value) { + return null; + } + const parts = value.split(/[^0-9.]+/).filter(Boolean); + if (parts.length !== 2) { + return null; + } + const min = Number(parts[0]); + const max = Number(parts[1]); + if (!Number.isFinite(min) || !Number.isFinite(max)) { + return null; + } + return [min, max]; +} + +// biome-ignore lint/complexity/noExcessiveCognitiveComplexity: validation rules +export function getConfigErrors(config: NodeConfig | null): string[] { + if (!config) { + return []; + } + const errors: string[] = []; + if (!config.name.trim()) { + errors.push("Name is required."); + } + if (config.kind === "sampler") { + if (config.sampler_type === "category") { + const values = config.values ?? []; + if (values.length < 2) { + errors.push("Category needs at least 2 values."); + } + const weights = config.weights ?? []; + const hasWeights = weights.some((weight) => weight !== null); + if (hasWeights && weights.some((weight) => weight === null)) { + errors.push("Weights must be set for all values."); + } + } + if (config.sampler_type === "uniform") { + const low = parseNumber(config.low); + const high = parseNumber(config.high); + if (low === null || high === null) { + errors.push("Uniform low/high must be numbers."); + } else if (low >= high) { + errors.push("Uniform low must be < high."); + } + } + if (config.sampler_type === "gaussian") { + const mean = parseNumber(config.mean); + const std = parseNumber(config.std); + if (mean === null || std === null) { + errors.push("Gaussian mean/std must be numbers."); + } else if (std <= 0) { + errors.push("Gaussian std must be > 0."); + } + } + if (config.sampler_type === "datetime") { + if (!config.datetime_unit) { + errors.push("Datetime unit required."); + } + if (config.datetime_start && config.datetime_end) { + const start = new Date(config.datetime_start).getTime(); + const end = new Date(config.datetime_end).getTime(); + if (!(Number.isFinite(start) && Number.isFinite(end))) { + errors.push("Datetime start/end must be valid."); + } else if (start >= end) { + errors.push("Datetime start must be before end."); + } + } + } + if (config.sampler_type === "subcategory" && !config.subcategory_parent) { + errors.push("Subcategory needs a parent category column."); + } + if (config.sampler_type === "person") { + if (config.person_sex?.trim()) { + const normalized = config.person_sex.trim(); + if (!(normalized === "Male" || normalized === "Female")) { + errors.push("Person sex must be Male or Female."); + } + } + if (config.person_age_range?.trim()) { + const parsed = parseAgeRange(config.person_age_range); + if (!parsed) { + errors.push("Person age range must be like 18-70."); + } + } + } + if (config.sampler_type === "person_from_faker") { + if (config.person_age_range?.trim()) { + const parsed = parseAgeRange(config.person_age_range); + if (!parsed) { + errors.push("Person age range must be like 18-70."); + } + } + } + } + if (config.kind === "llm") { + if (!config.model_alias.trim()) { + errors.push("Model alias is required."); + } + if (!config.prompt.trim()) { + errors.push("Prompt is required."); + } + if (config.llm_type === "code" && !config.code_lang) { + errors.push("Code language is required."); + } + if (config.llm_type === "structured") { + if (!config.output_format?.trim()) { + errors.push("Output format is required."); + } else { + try { + JSON.parse(config.output_format); + } catch { + errors.push("Output format must be valid JSON."); + } + } + } + } + if (config.kind === "expression") { + if (!config.expr.trim()) { + errors.push("Expression is required."); + } + } + return errors; +}