diff --git a/studio/backend/routes/data_recipe.py b/studio/backend/routes/data_recipe.py index 55a2e805b1..e05347b3d4 100644 --- a/studio/backend/routes/data_recipe.py +++ b/studio/backend/routes/data_recipe.py @@ -21,7 +21,11 @@ if str(backend_path) not in sys.path: sys.path.insert(0, str(backend_path)) from core.data_recipe.jobs import get_job_manager -from core.data_recipe.service import validate_recipe +from core.data_recipe.service import ( + build_config_builder, + create_data_designer, + validate_recipe, +) from models.data_recipe import ( JobCreateResponse, RecipePayload, @@ -198,6 +202,55 @@ def _read_preview_rows_from_local_file(path: Path, preview_size: int) -> list[di return _serialize_preview_rows(rows) +def _collect_validation_errors(recipe: dict[str, Any]) -> list[ValidateError]: + try: + from data_designer.engine.compiler import ( + _add_internal_row_id_column_if_needed, + _get_allowed_references, + _resolve_and_add_seed_columns, + ) + from data_designer.engine.validation import ( + ViolationLevel, + validate_data_designer_config, + ) + except Exception: + return [] + + try: + builder = build_config_builder(recipe) + designer = create_data_designer(recipe) + resource_provider = designer._create_resource_provider( # type: ignore[attr-defined] + "validate-configuration", + builder, + ) + config = builder.build() + _resolve_and_add_seed_columns(config, resource_provider.seed_reader) + _add_internal_row_id_column_if_needed(config) + violations = validate_data_designer_config( + columns=config.columns, + processor_configs=config.processors or [], + allowed_references=_get_allowed_references(config), + ) + except Exception: + return [] + + errors: list[ValidateError] = [] + for violation in violations: + if violation.level != ViolationLevel.ERROR: + continue + code = getattr(violation.type, "value", None) + path = violation.column if violation.column else None + message = str(violation.message).strip() or "Validation failed." + errors.append( + ValidateError( + message=message, + path=path, + code=code, + ) + ) + return errors + + @router.post("/seed/inspect", response_model=SeedInspectResponse) def inspect_seed_dataset(payload: SeedInspectRequest) -> SeedInspectResponse: dataset_name = payload.dataset_name.strip() @@ -327,9 +380,10 @@ def validate(payload: RecipePayload) -> ValidateResponse: raise HTTPException(status_code=503, detail=str(exc)) from exc except Exception as exc: detail = str(exc).strip() or "Validation failed." + parsed_errors = _collect_validation_errors(recipe) return ValidateResponse( valid=False, - errors=[ValidateError(message=detail)], + errors=parsed_errors or [ValidateError(message=detail)], raw_detail=detail, ) diff --git a/studio/frontend/src/features/recipe-studio/blocks/definitions.ts b/studio/frontend/src/features/recipe-studio/blocks/definitions.ts index f19b4a84f6..461d119aae 100644 --- a/studio/frontend/src/features/recipe-studio/blocks/definitions.ts +++ b/studio/frontend/src/features/recipe-studio/blocks/definitions.ts @@ -107,7 +107,7 @@ const BLOCK_DEFINITIONS: BlockDefinition[] = [ { kind: "seed", type: "seed_hf", - title: "Seed (Hugging Face)", + title: "Hugginface dataset", description: "Load real rows from HF and use them as generation context.", icon: Plant01Icon, dialogKey: "seed", @@ -116,7 +116,7 @@ const BLOCK_DEFINITIONS: BlockDefinition[] = [ { kind: "seed", type: "seed_local", - title: "Seed (Local File)", + title: "Local file", description: "Upload CSV/JSON/JSONL and use rows as seed context.", icon: DocumentCodeIcon, dialogKey: "seed", @@ -125,7 +125,7 @@ const BLOCK_DEFINITIONS: BlockDefinition[] = [ { kind: "seed", type: "seed_unstructured", - title: "Seed (Unstructured)", + title: "Unstructured documents", description: "Upload PDF/DOCX/TXT, chunk to text rows, then seed.", icon: DocumentAttachmentIcon, dialogKey: "seed", diff --git a/studio/frontend/src/features/recipe-studio/components/recipe-graph-node.tsx b/studio/frontend/src/features/recipe-studio/components/recipe-graph-node.tsx index b571fec32d..807a2ad746 100644 --- a/studio/frontend/src/features/recipe-studio/components/recipe-graph-node.tsx +++ b/studio/frontend/src/features/recipe-studio/components/recipe-graph-node.tsx @@ -338,7 +338,8 @@ function RecipeGraphNodeBase({ const showDataHandles = data.kind === "llm" || data.kind === "expression" || - data.kind === "sampler"; + data.kind === "sampler" || + data.kind === "seed"; const showSemanticIn = data.kind === "llm" || data.kind === "model_config"; const showSemanticOut = data.kind === "model_config" || data.kind === "model_provider"; const { diff --git a/studio/frontend/src/features/recipe-studio/hooks/use-recipe-executions.ts b/studio/frontend/src/features/recipe-studio/hooks/use-recipe-executions.ts index 5605d52ae9..25f6d1337a 100644 --- a/studio/frontend/src/features/recipe-studio/hooks/use-recipe-executions.ts +++ b/studio/frontend/src/features/recipe-studio/hooks/use-recipe-executions.ts @@ -75,6 +75,22 @@ type UseRecipeExecutionsResult = { loadExecutionDatasetPage: (id: string, page: number) => Promise; }; +function formatValidationMessages(input: { + errors: Array<{ message: string; path?: string | null; code?: string | null }>; +}): string[] { + return input.errors.map((item) => { + const path = item.path?.trim(); + const code = item.code?.trim(); + const prefix = [ + code ? code.toUpperCase() : null, + path ? `column ${path}` : null, + ] + .filter(Boolean) + .join(" ยท "); + return prefix ? `${prefix}: ${item.message}` : item.message; + }); +} + export function useRecipeExecutions({ recipeId, currentSignature, @@ -310,7 +326,7 @@ export function useRecipeExecutions({ try { const validation = await validateRecipe(executionPayload); if (!validation.valid) { - const errors = validation.errors.map((item) => item.message); + const errors = formatValidationMessages({ errors: validation.errors }); const fallback = validation.raw_detail ?? "Validation failed."; const nextErrors = errors.length > 0 ? errors : [fallback]; setRunErrors(nextErrors); @@ -343,6 +359,7 @@ export function useRecipeExecutions({ }, [fullRows, runWithValidation]); const runFromDialog = useCallback(async (): Promise => { + setValidateResult(null); if (runDialogKind === "preview") { return runPreview(); } @@ -350,6 +367,7 @@ export function useRecipeExecutions({ }, [runDialogKind, runFull, runPreview]); const validateFromDialog = useCallback(async (): Promise => { + setRunErrors([]); const payload = readPayload(); if (!payload) { const nextErrors = payloadResult.errors.length > 0 @@ -375,7 +393,7 @@ export function useRecipeExecutions({ setValidateLoading(true); try { const validation = await validateRecipe(executionPayload); - const errors = validation.errors.map((item) => item.message); + const errors = formatValidationMessages({ errors: validation.errors }); setValidateResult({ valid: validation.valid, errors, diff --git a/studio/frontend/src/features/recipe-studio/utils/graph/recipe-graph-connection.ts b/studio/frontend/src/features/recipe-studio/utils/graph/recipe-graph-connection.ts index 54b6f0014f..5251e508df 100644 --- a/studio/frontend/src/features/recipe-studio/utils/graph/recipe-graph-connection.ts +++ b/studio/frontend/src/features/recipe-studio/utils/graph/recipe-graph-connection.ts @@ -193,6 +193,7 @@ export function applyRecipeConnection( } if ( isLlmConfig(target) && + source.kind !== "seed" && source.kind !== "model_provider" && source.kind !== "model_config" ) { @@ -205,6 +206,7 @@ export function applyRecipeConnection( } if ( isExpressionConfig(target) && + source.kind !== "seed" && source.kind !== "model_provider" && source.kind !== "model_config" ) { diff --git a/studio/frontend/src/features/recipe-studio/utils/import/importer.ts b/studio/frontend/src/features/recipe-studio/utils/import/importer.ts index 21166e7a48..c47d2bd715 100644 --- a/studio/frontend/src/features/recipe-studio/utils/import/importer.ts +++ b/studio/frontend/src/features/recipe-studio/utils/import/importer.ts @@ -275,6 +275,11 @@ export function importRecipePayload(input: string): ImportResult { unstructured_chunk_overlap: uiUnstructuredChunkOverlap, }); if (seedConfig) { + if (nameToId.has(seedConfig.name)) { + errors.push(`Duplicate column name: ${seedConfig.name}.`); + } else { + nameToId.set(seedConfig.name, seedConfig.id); + } configs.push(seedConfig); } }