From 869ac64e182d3309763f0187d6f84f1e9ce6d2d0 Mon Sep 17 00:00:00 2001 From: Shine1i Date: Sun, 22 Feb 2026 03:39:34 +0100 Subject: [PATCH] feat: enhance dataset seed handling with inspection and UI improvements --- studio/backend/models/data_recipe.py | 17 + studio/backend/routes/data_recipe.py | 156 ++++- .../src/features/recipe-studio/api/index.ts | 29 + .../components/recipe-graph-node.tsx | 5 +- .../dialogs/samplers/category-dialog.tsx | 33 +- .../dialogs/seed/seed-dialog.tsx | 656 +++++++++++------- .../dialogs/shared/field-label.tsx | 40 ++ .../src/features/recipe-studio/types/index.ts | 4 +- .../recipe-studio/utils/config-factories.ts | 2 +- .../import/parsers/seed-config-parser.ts | 18 +- .../recipe-studio/utils/validation.ts | 5 +- 11 files changed, 702 insertions(+), 263 deletions(-) create mode 100644 studio/frontend/src/features/recipe-studio/dialogs/shared/field-label.tsx diff --git a/studio/backend/models/data_recipe.py b/studio/backend/models/data_recipe.py index 885b6470fe..73eece0b29 100644 --- a/studio/backend/models/data_recipe.py +++ b/studio/backend/models/data_recipe.py @@ -35,3 +35,20 @@ class ValidateResponse(BaseModel): class JobCreateResponse(BaseModel): job_id: str + + +class SeedInspectRequest(BaseModel): + dataset_name: str = Field(min_length=1) + hf_token: str | None = None + subset: str | None = None + split: str | None = "train" + preview_size: int = Field(default=10, ge=1, le=50) + + +class SeedInspectResponse(BaseModel): + dataset_name: str + resolved_path: str + columns: list[str] = Field(default_factory=list) + preview_rows: list[dict[str, Any]] = Field(default_factory=list) + split: str | None = None + subset: str | None = None diff --git a/studio/backend/routes/data_recipe.py b/studio/backend/routes/data_recipe.py index 4f4cfc4290..9d3c2ae2bf 100644 --- a/studio/backend/routes/data_recipe.py +++ b/studio/backend/routes/data_recipe.py @@ -5,6 +5,7 @@ Data Recipe routes (DataDesigner runner). from __future__ import annotations import sys +from itertools import islice from pathlib import Path from typing import Any @@ -18,9 +19,162 @@ if str(backend_path) not in sys.path: from core.data_recipe.jobs import get_job_manager from core.data_recipe.service import validate_recipe -from models.data_recipe import JobCreateResponse, RecipePayload, ValidateError, ValidateResponse +from models.data_recipe import ( + JobCreateResponse, + RecipePayload, + SeedInspectRequest, + SeedInspectResponse, + ValidateError, + ValidateResponse, +) router = APIRouter() +DATA_EXTS = (".parquet", ".jsonl", ".json", ".csv") + + +def _serialize_preview_value(value: Any) -> Any: + if value is None or isinstance(value, (str, int, float, bool)): + return value + if isinstance(value, dict): + return {str(key): _serialize_preview_value(item) for key, item in value.items()} + if isinstance(value, (list, tuple)): + return [_serialize_preview_value(item) for item in value] + return str(value) + + +def _serialize_preview_rows(rows: list[dict[str, Any]]) -> list[dict[str, Any]]: + return [ + {str(key): _serialize_preview_value(value) for key, value in row.items()} + for row in rows + ] + + +def _select_best_file(data_files: list[str], split: str | None) -> str | None: + if not data_files: + return None + if not split: + return data_files[0] + split_lower = split.lower() + + def score(path: str) -> tuple[int, int]: + name = path.lower() + if f"/{split_lower}/" in name: + return (0, len(path)) + if ( + f"_{split_lower}." in name + or f"-{split_lower}." in name + or f"/{split_lower}." in name + or f"/{split_lower}_" in name + or f"/{split_lower}-" in name + ): + return (1, len(path)) + return (2, len(path)) + + return sorted(data_files, key=score)[0] + + +def _resolve_seed_hf_path(dataset_name: str, data_files: list[str], split: str | None) -> str | None: + selected = _select_best_file(data_files, split) + if not selected: + return None + + ext = Path(selected).suffix.lower() + if ext not in DATA_EXTS: + return f"datasets/{dataset_name}/{selected}" + + parent = Path(selected).parent.as_posix() + if not parent or parent == ".": + return f"datasets/{dataset_name}/**/*{ext}" + return f"datasets/{dataset_name}/{parent}/**/*{ext}" + + +@router.post("/seed/inspect", response_model=SeedInspectResponse) +def inspect_seed_dataset(payload: SeedInspectRequest) -> SeedInspectResponse: + dataset_name = payload.dataset_name.strip() + if not dataset_name or dataset_name.count("/") < 1: + raise HTTPException(status_code=400, detail="dataset_name must be a Hugging Face repo id like org/repo") + + try: + from datasets import load_dataset + from huggingface_hub import HfApi + except Exception as exc: + raise HTTPException(status_code=500, detail=f"seed inspect dependencies unavailable: {exc}") from exc + + split = (payload.split or "train").strip() or "train" + subset = payload.subset.strip() if payload.subset else None + token = payload.hf_token.strip() if payload.hf_token else None + preview_size = int(payload.preview_size) + + preview_rows: list[dict[str, Any]] = [] + data_files: list[str] = [] + + try: + api = HfApi() + repo_files = api.list_repo_files(dataset_name, repo_type="dataset", token=token) + data_files = [f for f in repo_files if f.lower().endswith(DATA_EXTS)] + except Exception: + data_files = [] + + selected_file = _select_best_file(data_files, split) + if selected_file: + try: + load_kwargs: dict[str, Any] = { + "path": dataset_name, + "data_files": [selected_file], + "split": "train", + "streaming": True, + } + if subset: + load_kwargs["name"] = subset + if token: + load_kwargs["token"] = token + streamed_ds = load_dataset(**load_kwargs) + preview_rows = [_row for _row in islice(streamed_ds, preview_size)] + except Exception: + preview_rows = [] + + if not preview_rows: + try: + load_kwargs = { + "path": dataset_name, + "split": split, + "streaming": True, + } + if subset: + load_kwargs["name"] = subset + if token: + load_kwargs["token"] = token + streamed_ds = load_dataset(**load_kwargs) + preview_rows = [_row for _row in islice(streamed_ds, preview_size)] + except Exception as exc: + raise HTTPException(status_code=422, detail=f"seed inspect failed: {exc}") from exc + + if not preview_rows: + raise HTTPException(status_code=422, detail="dataset appears empty or unreadable") + preview_rows = _serialize_preview_rows(preview_rows) + + columns_seen: dict[str, None] = {} + for row in preview_rows: + for key in row.keys(): + columns_seen[str(key)] = None + columns = list(columns_seen.keys()) + + if not data_files: + # Best effort path fallback when file list is unavailable. + resolved_path = f"datasets/{dataset_name}/**/*.parquet" + else: + resolved_path = _resolve_seed_hf_path(dataset_name, data_files, split) + if not resolved_path: + raise HTTPException(status_code=422, detail="unable to resolve seed dataset path") + + return SeedInspectResponse( + dataset_name=dataset_name, + resolved_path=resolved_path, + columns=columns, + preview_rows=preview_rows, + split=split, + subset=subset, + ) @router.post("/validate", response_model=ValidateResponse) diff --git a/studio/frontend/src/features/recipe-studio/api/index.ts b/studio/frontend/src/features/recipe-studio/api/index.ts index f166fc95cd..1dac98bbe9 100644 --- a/studio/frontend/src/features/recipe-studio/api/index.ts +++ b/studio/frontend/src/features/recipe-studio/api/index.ts @@ -70,6 +70,29 @@ export type JobEvent = { payload: Record; }; +export type SeedInspectRequest = { + // biome-ignore lint/style/useNamingConvention: api schema + dataset_name: string; + // biome-ignore lint/style/useNamingConvention: api schema + hf_token?: string; + subset?: string; + split?: string; + // biome-ignore lint/style/useNamingConvention: api schema + preview_size?: number; +}; + +export type SeedInspectResponse = { + // biome-ignore lint/style/useNamingConvention: api schema + dataset_name: string; + // biome-ignore lint/style/useNamingConvention: api schema + resolved_path: string; + columns: string[]; + // biome-ignore lint/style/useNamingConvention: api schema + preview_rows: Record[]; + split?: string | null; + subset?: string | null; +}; + export type ValidateError = { message: string; path?: string | null; @@ -203,6 +226,12 @@ export async function cancelRecipeJob(jobId: string): Promise return postJson(`/jobs/${jobId}/cancel`, {}); } +export async function inspectSeedDataset( + payload: SeedInspectRequest, +): Promise { + return postJson("/seed/inspect", payload); +} + export async function streamRecipeJobEvents(options: { jobId: string; signal: AbortSignal; 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 93fef23bd7..c0b2e8b382 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 @@ -170,10 +170,13 @@ function getConfigSummary(config: NodeConfig | undefined): string { } if (config.kind === "seed") { + if (config.hf_repo_id.trim()) { + return config.hf_repo_id.trim(); + } if (config.hf_path.trim()) { return config.hf_path.trim(); } - return "Set HF dataset path"; + return "Set HF dataset repo"; } return "Open details for config"; diff --git a/studio/frontend/src/features/recipe-studio/dialogs/samplers/category-dialog.tsx b/studio/frontend/src/features/recipe-studio/dialogs/samplers/category-dialog.tsx index ea29daf72e..f209078799 100644 --- a/studio/frontend/src/features/recipe-studio/dialogs/samplers/category-dialog.tsx +++ b/studio/frontend/src/features/recipe-studio/dialogs/samplers/category-dialog.tsx @@ -113,26 +113,16 @@ export function CategoryDialog({ -
- - - - + + + +
))} -
-
+
); diff --git a/studio/frontend/src/features/recipe-studio/dialogs/seed/seed-dialog.tsx b/studio/frontend/src/features/recipe-studio/dialogs/seed/seed-dialog.tsx index 9f46807575..e5d46234b7 100644 --- a/studio/frontend/src/features/recipe-studio/dialogs/seed/seed-dialog.tsx +++ b/studio/frontend/src/features/recipe-studio/dialogs/seed/seed-dialog.tsx @@ -1,4 +1,17 @@ import { Button } from "@/components/ui/button"; +import { + Combobox, + ComboboxContent, + ComboboxEmpty, + ComboboxInput, + ComboboxItem, + ComboboxList, +} from "@/components/ui/combobox"; +import { + Collapsible, + CollapsibleContent, + CollapsibleTrigger, +} from "@/components/ui/collapsible"; import { Empty, EmptyContent, @@ -7,6 +20,7 @@ import { EmptyTitle, } from "@/components/ui/empty"; import { Input } from "@/components/ui/input"; +import { InputGroupAddon } from "@/components/ui/input-group"; import { Select, SelectContent, @@ -14,6 +28,7 @@ import { SelectTrigger, SelectValue, } from "@/components/ui/select"; +import { Spinner } from "@/components/ui/spinner"; import { Table, TableBody, @@ -28,7 +43,18 @@ import { TabsList, TabsTrigger, } from "@/components/ui/tabs"; -import { type ReactElement, useMemo, useState } from "react"; +import { + useDebouncedValue, + useHfDatasetSearch, + useHfDatasetSplits, + useHfTokenValidation, + useInfiniteScroll, +} from "@/hooks"; +import { formatCompact } from "@/lib/utils"; +import { Search01Icon } from "@hugeicons/core-free-icons"; +import { HugeiconsIcon } from "@hugeicons/react"; +import { type ReactElement, useEffect, useMemo, useRef, useState } from "react"; +import { inspectSeedDataset } from "../../api"; import type { SeedConfig, SeedSamplingStrategy, @@ -52,26 +78,6 @@ type SeedDialogProps = { onUpdate: (patch: Partial) => void; }; -function parseHfDatasetRepoId(input: string): string | null { - const raw = input.trim(); - if (!raw) return null; - if (!raw.includes("://") && raw.split("/").length === 2) { - return raw; - } - try { - const url = new URL(raw); - const parts = url.pathname.split("/").filter(Boolean); - const datasetsIdx = parts.indexOf("datasets"); - if (datasetsIdx === -1) return null; - const org = parts[datasetsIdx + 1]; - const repo = parts[datasetsIdx + 2]; - if (!org || !repo) return null; - return `${org}/${repo}`; - } catch { - return null; - } -} - function stringifyCell(value: unknown): string { if (value === null || value === undefined) return ""; if (typeof value === "string") return value; @@ -84,34 +90,145 @@ function stringifyCell(value: unknown): string { } export function SeedDialog({ config, onUpdate }: SeedDialogProps): ReactElement { + const [inputValue, setInputValue] = useState(""); const [inspectError, setInspectError] = useState(null); - const [previewError, setPreviewError] = useState(null); + const [isInspecting, setIsInspecting] = useState(false); + const [advancedOpen, setAdvancedOpen] = useState(false); const [previewRows, setPreviewRows] = useState[]>([]); - const repoId = useMemo( - () => parseHfDatasetRepoId(config.hf_url ?? ""), - [config.hf_url], - ); + const selectingRef = useRef(false); + const comboboxAnchorRef = useRef(null); + const debouncedQuery = useDebouncedValue(inputValue); + + const hfToken = config.hf_token?.trim() ?? ""; + const dataset = config.hf_repo_id.trim(); + + const { + results: hfResults, + isLoading, + isLoadingMore, + fetchMore, + error: hfSearchError, + } = useHfDatasetSearch(debouncedQuery, { + accessToken: hfToken || undefined, + }); + + const { error: tokenValidationError, isChecking: isCheckingToken } = + useHfTokenValidation(hfToken); + + const { scrollRef, sentinelRef } = useInfiniteScroll(fetchMore, hfResults.length); + + const { + subsets, + splits, + hasMultipleSubsets, + hasMultipleSplits, + isLoading: splitsLoading, + error: splitsError, + } = useHfDatasetSplits(dataset || null, config.hf_subset || null, { + accessToken: hfToken || undefined, + }); + + useEffect(() => { + if (subsets.length === 1 && config.hf_subset !== subsets[0]) { + onUpdate({ hf_subset: subsets[0] }); + } + }, [subsets, config.hf_subset, onUpdate]); + + useEffect(() => { + if (splits.length === 0) return; + if (hasMultipleSubsets && !config.hf_subset) return; + + if (splits.length === 1 && config.hf_split !== splits[0]) { + onUpdate({ hf_split: splits[0] }); + return; + } + if (!config.hf_split && splits.includes("train")) { + onUpdate({ hf_split: "train" }); + return; + } + if (!config.hf_split) { + onUpdate({ hf_split: splits[0] }); + } + }, [ + splits, + hasMultipleSubsets, + config.hf_subset, + config.hf_split, + onUpdate, + ]); + + const resultIds = useMemo(() => { + const ids = hfResults.map((result) => result.id); + if (dataset && !ids.includes(dataset)) { + ids.push(dataset); + } + return ids; + }, [hfResults, dataset]); - const pathId = `${config.id}-hf-path`; - const urlId = `${config.id}-hf-url`; - const tokenId = `${config.id}-hf-token`; - const splitId = `${config.id}-hf-split`; const samplingId = `${config.id}-sampling`; const selectionId = `${config.id}-selection`; + const tokenId = `${config.id}-hf-token`; + const subsetId = `${config.id}-hf-subset`; + const splitId = `${config.id}-hf-split`; - async function onInspect(): Promise { - setInspectError("Seed inspect disabled (backend /seed/inspect removed)."); - } - - async function onPreview(): Promise { - setPreviewError("Seed preview disabled (backend /seed/preview removed)."); + function handleDatasetSelect(id: string | null): void { + selectingRef.current = true; + const value = id ?? ""; + onUpdate({ + hf_repo_id: value, + hf_subset: "", + hf_split: "", + hf_path: "", + seed_columns: [], + }); + setInspectError(null); setPreviewRows([]); } + function handleInputChange(value: string): void { + if (selectingRef.current) { + selectingRef.current = false; + return; + } + setInputValue(value); + } + + async function loadSeedMetadata(): Promise { + const datasetName = config.hf_repo_id.trim(); + if (!datasetName) { + setInspectError("Select a dataset first."); + return; + } + + setInspectError(null); + setIsInspecting(true); + try { + const response = await inspectSeedDataset({ + dataset_name: datasetName, + hf_token: hfToken || undefined, + subset: config.hf_subset || undefined, + split: config.hf_split || "train", + preview_size: 10, + }); + onUpdate({ + hf_path: response.resolved_path, + seed_columns: response.columns, + hf_split: response.split ?? config.hf_split ?? "", + hf_subset: response.subset ?? config.hf_subset ?? "", + }); + setPreviewRows(response.preview_rows ?? []); + } catch (error) { + setInspectError(error instanceof Error ? error.message : "Failed to load seed metadata."); + setPreviewRows([]); + } finally { + setIsInspecting(false); + } + } + const previewColumns = useMemo(() => { - const cols = config.seed_columns ?? []; - if (cols.length > 0) return cols; + const loadedColumns = config.seed_columns ?? []; + if (loadedColumns.length > 0) return loadedColumns; if (previewRows[0]) return Object.keys(previewRows[0]); return []; }, [config.seed_columns, previewRows]); @@ -127,57 +244,134 @@ export function SeedDialog({ config, onUpdate }: SeedDialogProps): ReactElement
-
- onUpdate({ hf_url: e.target.value })} - /> - + + + + + + + {isLoading ? ( +
+ Searching... +
+ ) : ( + No datasets found + )} +
+ + {(id: string) => { + const result = hfResults.find((entry) => entry.id === id); + let detail: string | null = null; + if (result?.totalExamples) { + detail = `${formatCompact(result.totalExamples)} rows`; + } else if (result?.sizeCategory) { + detail = result.sizeCategory; + } else if (result?.downloads != null) { + detail = `↓${formatCompact(result.downloads)}`; + } + return ( + + {id} + {detail && ( + {detail} + )} + + ); + }} + +
+ {isLoadingMore && ( +
+ +
+ )} +
+ +
-

- Repo: {repoId ?? "-"} -

- {inspectError && ( -

{inspectError}

- )} - {(config.seed_splits?.length ?? 0) > 0 && ( +
+ + onUpdate({ hf_token: event.target.value })} + /> + {(tokenValidationError ?? hfSearchError) && ( +

{tokenValidationError ?? hfSearchError}

+ )} + {isCheckingToken && ( +

Checking token…

+ )} +
+ + {splitsLoading && dataset ? ( +
+ Loading subsets and splits... +
+ ) : null} + + {splitsError ? ( +

+ Could not fetch dataset splits: {splitsError} +

+ ) : null} + + {hasMultipleSubsets && (
onUpdate({ hf_path: e.target.value })} - /> -
- -
- - onUpdate({ hf_token: e.target.value })} - /> -
- -
- - -
- -
- - -
- - {config.selection_type === "index_range" && ( -
-
- - onUpdate({ selection_start: e.target.value })} - /> -
-
- - onUpdate({ selection_end: e.target.value })} - /> -
+ {hasMultipleSplits && ( +
+ +
)} - {config.selection_type === "partition_block" && ( -
-
- - onUpdate({ selection_index: e.target.value })} - /> -
-
- - - onUpdate({ selection_num_partitions: e.target.value }) - } - /> -
-
- )} + {inspectError &&

{inspectError}

} -

- Seed columns auto-add. Reference by name (ex: {"{{ rubrics }}"}). -

+ + + + + +
+ + +
+ +
+ + +
+ + {config.selection_type === "index_range" && ( +
+
+ + onUpdate({ selection_start: event.target.value })} + /> +
+
+ + onUpdate({ selection_end: event.target.value })} + /> +
+
+ )} + + {config.selection_type === "partition_block" && ( +
+
+ + onUpdate({ selection_index: event.target.value })} + /> +
+
+ + + onUpdate({ selection_num_partitions: event.target.value }) + } + /> +
+
+ )} +
+
@@ -337,9 +525,9 @@ export function SeedDialog({ config, onUpdate }: SeedDialogProps): ReactElement
- Preview samples + Seed preview - Load 10 rows to see columns and sample values. + Click load to fetch 10 rows from the selected dataset. @@ -347,10 +535,10 @@ export function SeedDialog({ config, onUpdate }: SeedDialogProps): ReactElement type="button" variant="outline" className="nodrag" - onClick={() => void onPreview()} - disabled + onClick={() => void loadSeedMetadata()} + disabled={isInspecting || !dataset} > - Load 10 rows (disabled) + {isInspecting ? "Loading..." : "Load 10 rows"} @@ -362,28 +550,28 @@ export function SeedDialog({ config, onUpdate }: SeedDialogProps): ReactElement type="button" variant="outline" className="nodrag" - onClick={() => void onPreview()} - disabled + onClick={() => void loadSeedMetadata()} + disabled={isInspecting || !dataset} > - Reload 10 rows (disabled) + {isInspecting ? "Loading..." : "Reload 10 rows"}
- +
- {previewColumns.map((col) => ( - - {col} + {previewColumns.map((column) => ( + + {column} ))} - {previewRows.map((row, idx) => ( - - {previewColumns.map((col) => ( - -
{stringifyCell(row[col])}
+ {previewRows.map((row, index) => ( + + {previewColumns.map((column) => ( + +
{stringifyCell(row[column])}
))}
@@ -392,7 +580,7 @@ export function SeedDialog({ config, onUpdate }: SeedDialogProps): ReactElement
)} - {previewError &&

{previewError}

} + {inspectError &&

{inspectError}

}
diff --git a/studio/frontend/src/features/recipe-studio/dialogs/shared/field-label.tsx b/studio/frontend/src/features/recipe-studio/dialogs/shared/field-label.tsx new file mode 100644 index 0000000000..a57c8cbcda --- /dev/null +++ b/studio/frontend/src/features/recipe-studio/dialogs/shared/field-label.tsx @@ -0,0 +1,40 @@ +import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; +import { InformationCircleIcon } from "@hugeicons/core-free-icons"; +import { HugeiconsIcon } from "@hugeicons/react"; +import type { ReactElement } from "react"; + +type FieldLabelProps = { + label: string; + htmlFor?: string; + hint?: string; +}; + +export function FieldLabel({ + label, + htmlFor, + hint, +}: FieldLabelProps): ReactElement { + return ( + + ); +} + diff --git a/studio/frontend/src/features/recipe-studio/types/index.ts b/studio/frontend/src/features/recipe-studio/types/index.ts index 8197e10fa9..45c23c4954 100644 --- a/studio/frontend/src/features/recipe-studio/types/index.ts +++ b/studio/frontend/src/features/recipe-studio/types/index.ts @@ -220,8 +220,8 @@ export type SeedConfig = { name: string; drop?: boolean; // ui-only (serialized in seed_config) - hf_url?: string; - hf_repo_id?: string; + hf_repo_id: string; + hf_subset?: string; hf_split?: string; hf_path: string; hf_token?: string; diff --git a/studio/frontend/src/features/recipe-studio/utils/config-factories.ts b/studio/frontend/src/features/recipe-studio/utils/config-factories.ts index 6dab392499..ad2ed6a3e7 100644 --- a/studio/frontend/src/features/recipe-studio/utils/config-factories.ts +++ b/studio/frontend/src/features/recipe-studio/utils/config-factories.ts @@ -285,8 +285,8 @@ export function makeSeedConfig( kind: "seed", name: nextName(existing, "seed"), drop: false, - hf_url: "", hf_repo_id: "", + hf_subset: "", hf_split: "", hf_path: "", hf_token: "", diff --git a/studio/frontend/src/features/recipe-studio/utils/import/parsers/seed-config-parser.ts b/studio/frontend/src/features/recipe-studio/utils/import/parsers/seed-config-parser.ts index 9219f2d34c..15df0bf36b 100644 --- a/studio/frontend/src/features/recipe-studio/utils/import/parsers/seed-config-parser.ts +++ b/studio/frontend/src/features/recipe-studio/utils/import/parsers/seed-config-parser.ts @@ -17,8 +17,8 @@ function makeDefaultSeedConfig(id: string): SeedConfig { kind: "seed", name: "seed", drop: false, - hf_url: "", hf_repo_id: "", + hf_subset: "", hf_split: "", hf_path: "", hf_token: "", @@ -35,6 +35,19 @@ function makeDefaultSeedConfig(id: string): SeedConfig { }; } +function inferRepoIdFromSeedPath(path: string): string { + const trimmed = path.trim(); + if (!trimmed) return ""; + const parts = trimmed.split("/").filter(Boolean); + if (parts.length >= 3 && parts[0] === "datasets") { + return `${parts[1]}/${parts[2]}`; + } + if (parts.length >= 2) { + return `${parts[0]}/${parts[1]}`; + } + return ""; +} + function parseSeedSettings(seedConfigRaw: unknown): Partial { if (!isRecord(seedConfigRaw)) { return {}; @@ -45,11 +58,13 @@ function parseSeedSettings(seedConfigRaw: unknown): Partial { let hf_path = ""; let hf_token = ""; let hf_endpoint = "https://huggingface.co"; + let hf_repo_id = ""; const sourceRaw = seedConfigRaw.source; if (isRecord(sourceRaw) && readString(sourceRaw.seed_type) === "hf") { hf_path = readString(sourceRaw.path) ?? ""; hf_token = readString(sourceRaw.token) ?? ""; hf_endpoint = readString(sourceRaw.endpoint) ?? hf_endpoint; + hf_repo_id = inferRepoIdFromSeedPath(hf_path); } let selection_type: SeedSelectionType = "none"; @@ -77,6 +92,7 @@ function parseSeedSettings(seedConfigRaw: unknown): Partial { } return { + hf_repo_id, hf_path, hf_token, hf_endpoint, diff --git a/studio/frontend/src/features/recipe-studio/utils/validation.ts b/studio/frontend/src/features/recipe-studio/utils/validation.ts index 879baad976..b8e8594f2c 100644 --- a/studio/frontend/src/features/recipe-studio/utils/validation.ts +++ b/studio/frontend/src/features/recipe-studio/utils/validation.ts @@ -180,8 +180,11 @@ export function getConfigErrors(config: NodeConfig | null): string[] { } } if (config.kind === "seed") { + if (!config.hf_repo_id.trim()) { + errors.push("Seed dataset repo is required."); + } if (!config.hf_path.trim()) { - errors.push("HF dataset path is required."); + errors.push("Seed metadata not loaded. Click 'Load columns + 10 rows'."); } if (config.hf_endpoint?.trim() && !config.hf_endpoint.trim().startsWith("http")) { errors.push("HF endpoint must start with http.");