feat: enhance dataset seed handling with inspection and UI improvements

This commit is contained in:
Shine1i 2026-02-22 03:39:34 +01:00
commit 869ac64e18
11 changed files with 695 additions and 256 deletions

View file

@ -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

View file

@ -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)

View file

@ -70,6 +70,29 @@ export type JobEvent = {
payload: Record<string, unknown>;
};
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<string, unknown>[];
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<JobStatusResponse>
return postJson<JobStatusResponse>(`/jobs/${jobId}/cancel`, {});
}
export async function inspectSeedDataset(
payload: SeedInspectRequest,
): Promise<SeedInspectResponse> {
return postJson<SeedInspectResponse>("/seed/inspect", payload);
}
export async function streamRecipeJobEvents(options: {
jobId: string;
signal: AbortSignal;

View file

@ -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";

View file

@ -113,26 +113,16 @@ export function CategoryDialog({
</div>
</div>
<Collapsible open={advancedOpen} onOpenChange={setAdvancedOpen}>
<div className="rounded-2xl border border-border/60">
<CollapsibleTrigger asChild={true}>
<button
type="button"
className="flex w-full items-center justify-between px-3 py-2 text-left"
>
<div>
<p className="text-xs font-semibold uppercase text-muted-foreground">
Advanced
</p>
<p className="text-xs text-muted-foreground">
Weights and conditional rules.
</p>
</div>
<span className="text-xs text-muted-foreground">
{advancedOpen ? "Hide" : "Show"}
</span>
</button>
</CollapsibleTrigger>
<CollapsibleContent className="space-y-3 border-t border-border/60 p-3">
<CollapsibleTrigger asChild={true}>
<button
type="button"
className="flex w-full items-center justify-between text-left text-xs text-muted-foreground"
>
<span className="font-semibold uppercase">Advanced</span>
<span>{advancedOpen ? "Hide" : "Show"}</span>
</button>
</CollapsibleTrigger>
<CollapsibleContent className="mt-2 space-y-3">
<div className="grid gap-2">
<FieldLabel
label="Weights (optional)"
@ -286,8 +276,7 @@ export function CategoryDialog({
</div>
</div>
))}
</CollapsibleContent>
</div>
</CollapsibleContent>
</Collapsible>
</div>
);

View file

@ -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<SeedConfig>) => 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<string | null>(null);
const [previewError, setPreviewError] = useState<string | null>(null);
const [isInspecting, setIsInspecting] = useState(false);
const [advancedOpen, setAdvancedOpen] = useState(false);
const [previewRows, setPreviewRows] = useState<Record<string, unknown>[]>([]);
const repoId = useMemo(
() => parseHfDatasetRepoId(config.hf_url ?? ""),
[config.hf_url],
);
const selectingRef = useRef(false);
const comboboxAnchorRef = useRef<HTMLDivElement>(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<void> {
setInspectError("Seed inspect disabled (backend /seed/inspect removed).");
}
async function onPreview(): Promise<void> {
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<void> {
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
<div className="space-y-4">
<div className="grid gap-2">
<FieldLabel
label="HF dataset URL"
htmlFor={urlId}
hint="Dataset URL or org/repo used to bootstrap seed columns."
label="Dataset"
hint="Search and select a Hugging Face dataset repo (org/repo)."
/>
<div className="flex items-center gap-2">
<Input
id={urlId}
className="nodrag flex-1"
placeholder="https://huggingface.co/datasets/org/repo"
value={config.hf_url ?? ""}
onChange={(e) => onUpdate({ hf_url: e.target.value })}
/>
<Button
type="button"
variant="outline"
className="nodrag"
onClick={() => void onInspect()}
disabled
<div
ref={comboboxAnchorRef}
onKeyDown={(event) => {
if (event.key !== "Enter") return;
if (!(event.target instanceof HTMLInputElement)) return;
event.preventDefault();
if (hfResults.length > 0) {
handleDatasetSelect(hfResults[0].id);
} else {
const text = event.target.value.trim();
if (text) handleDatasetSelect(text);
}
}}
>
<Combobox
items={resultIds}
filteredItems={resultIds}
filter={null}
value={dataset || null}
onValueChange={handleDatasetSelect}
onInputValueChange={handleInputChange}
itemToStringValue={(id) => id}
autoHighlight={true}
>
Load (disabled)
</Button>
<ComboboxInput placeholder="Search datasets..." className="nodrag w-full">
<InputGroupAddon>
<HugeiconsIcon icon={Search01Icon} className="size-4" />
</InputGroupAddon>
</ComboboxInput>
<ComboboxContent anchor={comboboxAnchorRef}>
{isLoading ? (
<div className="flex items-center justify-center gap-2 py-4 text-xs text-muted-foreground">
<Spinner className="size-4" /> Searching...
</div>
) : (
<ComboboxEmpty>No datasets found</ComboboxEmpty>
)}
<div
ref={scrollRef}
className="max-h-64 overflow-y-auto overscroll-contain [scrollbar-width:thin]"
>
<ComboboxList className="p-1 !max-h-none !overflow-visible">
{(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 (
<ComboboxItem key={id} value={id} className="justify-between">
<span className="min-w-0 flex-1 truncate">{id}</span>
{detail && (
<span className="shrink-0 text-[10px] text-muted-foreground">{detail}</span>
)}
</ComboboxItem>
);
}}
</ComboboxList>
<div ref={sentinelRef} className="h-px" />
{isLoadingMore && (
<div className="flex items-center justify-center py-2">
<Spinner className="size-3.5 text-muted-foreground" />
</div>
)}
</div>
</ComboboxContent>
</Combobox>
</div>
<p className="text-xs text-muted-foreground">
Repo: {repoId ?? "-"}
</p>
</div>
{inspectError && (
<p className="text-xs text-red-600">{inspectError}</p>
)}
{(config.seed_splits?.length ?? 0) > 0 && (
<div className="grid gap-2">
<FieldLabel
label="HF token (optional)"
htmlFor={tokenId}
hint="Only needed for private or gated datasets."
/>
<Input
id={tokenId}
className="nodrag"
placeholder="hf_..."
value={config.hf_token ?? ""}
onChange={(event) => onUpdate({ hf_token: event.target.value })}
/>
{(tokenValidationError ?? hfSearchError) && (
<p className="text-xs text-destructive">{tokenValidationError ?? hfSearchError}</p>
)}
{isCheckingToken && (
<p className="text-xs text-muted-foreground">Checking token</p>
)}
</div>
{splitsLoading && dataset ? (
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<Spinner className="size-3.5" /> Loading subsets and splits...
</div>
) : null}
{splitsError ? (
<p className="text-xs text-amber-700 dark:text-amber-400">
Could not fetch dataset splits: {splitsError}
</p>
) : null}
{hasMultipleSubsets && (
<div className="grid gap-2">
<FieldLabel
label="Split"
htmlFor={splitId}
hint="Dataset split to sample from (train/validation/test)."
label="Subset"
htmlFor={subsetId}
hint="Pick a dataset subset/config when multiple are available."
/>
<Select
value={config.hf_split ?? ""}
onValueChange={(value) => {
const nextPath = config.seed_globs_by_split?.[value] ?? "";
onUpdate({ hf_split: value, hf_path: nextPath || config.hf_path });
}}
value={config.hf_subset ?? ""}
onValueChange={(value) => onUpdate({ hf_subset: value || "", hf_split: "" })}
>
<SelectTrigger className="nodrag w-full" id={splitId}>
<SelectValue placeholder="Select split" />
<SelectTrigger className="nodrag w-full" id={subsetId}>
<SelectValue placeholder="Select subset" />
</SelectTrigger>
<SelectContent>
{(config.seed_splits ?? []).map((value) => (
<SelectItem key={value} value={value}>
{value}
{subsets.map((subset) => (
<SelectItem key={subset} value={subset}>
{subset}
</SelectItem>
))}
</SelectContent>
@ -185,149 +379,143 @@ export function SeedDialog({ config, onUpdate }: SeedDialogProps): ReactElement
</div>
)}
<div className="grid gap-2">
<FieldLabel
label="HF path (auto)"
htmlFor={pathId}
hint="Resolved dataset file path/pattern."
/>
<Input
id={pathId}
className="nodrag"
placeholder="datasets/org/repo/data/train-*.parquet"
value={config.hf_path}
onChange={(e) => onUpdate({ hf_path: e.target.value })}
/>
</div>
<div className="grid gap-2">
<FieldLabel
label="HF token (optional)"
htmlFor={tokenId}
hint="Optional private dataset access token."
/>
<Input
id={tokenId}
className="nodrag"
placeholder="hf_..."
value={config.hf_token ?? ""}
onChange={(e) => onUpdate({ hf_token: e.target.value })}
/>
</div>
<div className="grid gap-2">
<FieldLabel
label="Sampling strategy"
htmlFor={samplingId}
hint="Ordered keeps row order. shuffle randomizes sampled rows."
/>
<Select
value={config.sampling_strategy}
onValueChange={(value) =>
onUpdate({ sampling_strategy: value as SeedSamplingStrategy })
}
>
<SelectTrigger className="nodrag w-full" id={samplingId}>
<SelectValue placeholder="Select sampling" />
</SelectTrigger>
<SelectContent>
{SAMPLING_OPTIONS.map((opt) => (
<SelectItem key={opt.value} value={opt.value}>
{opt.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="grid gap-2">
<FieldLabel
label="Selection strategy"
htmlFor={selectionId}
hint="Select all, a row range, or partition block."
/>
<Select
value={config.selection_type}
onValueChange={(value) =>
onUpdate({ selection_type: value as SeedSelectionType })
}
>
<SelectTrigger className="nodrag w-full" id={selectionId}>
<SelectValue placeholder="Select selection" />
</SelectTrigger>
<SelectContent>
{SELECTION_OPTIONS.map((opt) => (
<SelectItem key={opt.value} value={opt.value}>
{opt.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{config.selection_type === "index_range" && (
<div className="grid grid-cols-2 gap-3">
<div className="grid gap-2">
<FieldLabel
label="Start"
hint="Inclusive start row index for index_range."
/>
<Input
className="nodrag"
inputMode="numeric"
value={config.selection_start ?? ""}
onChange={(e) => onUpdate({ selection_start: e.target.value })}
/>
</div>
<div className="grid gap-2">
<FieldLabel
label="End"
hint="Inclusive end row index for index_range."
/>
<Input
className="nodrag"
inputMode="numeric"
value={config.selection_end ?? ""}
onChange={(e) => onUpdate({ selection_end: e.target.value })}
/>
</div>
{hasMultipleSplits && (
<div className="grid gap-2">
<FieldLabel
label="Split"
htmlFor={splitId}
hint="Pick split used for preview sampling."
/>
<Select
value={config.hf_split ?? ""}
onValueChange={(value) => onUpdate({ hf_split: value || "" })}
>
<SelectTrigger className="nodrag w-full" id={splitId}>
<SelectValue placeholder="Select split" />
</SelectTrigger>
<SelectContent>
{splits.map((split) => (
<SelectItem key={split} value={split}>
{split}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
)}
{config.selection_type === "partition_block" && (
<div className="grid grid-cols-2 gap-3">
<div className="grid gap-2">
<FieldLabel
label="Index"
hint="Partition index to load."
/>
<Input
className="nodrag"
inputMode="numeric"
value={config.selection_index ?? ""}
onChange={(e) => onUpdate({ selection_index: e.target.value })}
/>
</div>
<div className="grid gap-2">
<FieldLabel
label="Partitions"
hint="Total number of partitions."
/>
<Input
className="nodrag"
inputMode="numeric"
value={config.selection_num_partitions ?? ""}
onChange={(e) =>
onUpdate({ selection_num_partitions: e.target.value })
}
/>
</div>
</div>
)}
{inspectError && <p className="text-xs text-red-600">{inspectError}</p>}
<p className="text-xs text-muted-foreground">
Seed columns auto-add. Reference by name (ex: {"{{ rubrics }}"}).
</p>
<Collapsible open={advancedOpen} onOpenChange={setAdvancedOpen}>
<CollapsibleTrigger asChild={true}>
<button
type="button"
className="flex w-full items-center justify-between text-left text-xs text-muted-foreground"
>
<span className="font-semibold uppercase">Advanced</span>
<span>{advancedOpen ? "Hide" : "Show"}</span>
</button>
</CollapsibleTrigger>
<CollapsibleContent className="mt-2 space-y-3">
<div className="grid gap-2">
<FieldLabel
label="Sampling strategy"
htmlFor={samplingId}
hint="Ordered keeps row order. Shuffle randomizes sampled rows."
/>
<Select
value={config.sampling_strategy}
onValueChange={(value) =>
onUpdate({ sampling_strategy: value as SeedSamplingStrategy })
}
>
<SelectTrigger className="nodrag w-full" id={samplingId}>
<SelectValue placeholder="Select sampling" />
</SelectTrigger>
<SelectContent>
{SAMPLING_OPTIONS.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="grid gap-2">
<FieldLabel
label="Selection strategy"
htmlFor={selectionId}
hint="Select all, a row range, or partition block."
/>
<Select
value={config.selection_type}
onValueChange={(value) =>
onUpdate({ selection_type: value as SeedSelectionType })
}
>
<SelectTrigger className="nodrag w-full" id={selectionId}>
<SelectValue placeholder="Select selection" />
</SelectTrigger>
<SelectContent>
{SELECTION_OPTIONS.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{config.selection_type === "index_range" && (
<div className="grid grid-cols-2 gap-3">
<div className="grid gap-2">
<FieldLabel label="Start" hint="Inclusive start row index for index_range." />
<Input
className="nodrag"
inputMode="numeric"
value={config.selection_start ?? ""}
onChange={(event) => onUpdate({ selection_start: event.target.value })}
/>
</div>
<div className="grid gap-2">
<FieldLabel label="End" hint="Inclusive end row index for index_range." />
<Input
className="nodrag"
inputMode="numeric"
value={config.selection_end ?? ""}
onChange={(event) => onUpdate({ selection_end: event.target.value })}
/>
</div>
</div>
)}
{config.selection_type === "partition_block" && (
<div className="grid grid-cols-2 gap-3">
<div className="grid gap-2">
<FieldLabel label="Index" hint="Partition index to load." />
<Input
className="nodrag"
inputMode="numeric"
value={config.selection_index ?? ""}
onChange={(event) => onUpdate({ selection_index: event.target.value })}
/>
</div>
<div className="grid gap-2">
<FieldLabel label="Partitions" hint="Total number of partitions." />
<Input
className="nodrag"
inputMode="numeric"
value={config.selection_num_partitions ?? ""}
onChange={(event) =>
onUpdate({ selection_num_partitions: event.target.value })
}
/>
</div>
</div>
)}
</CollapsibleContent>
</Collapsible>
</div>
</TabsContent>
@ -337,9 +525,9 @@ export function SeedDialog({ config, onUpdate }: SeedDialogProps): ReactElement
<div className="flex w-full items-center justify-center">
<Empty className="max-w-lg">
<EmptyHeader>
<EmptyTitle>Preview samples</EmptyTitle>
<EmptyTitle>Seed preview</EmptyTitle>
<EmptyDescription>
Load 10 rows to see columns and sample values.
Click load to fetch 10 rows from the selected dataset.
</EmptyDescription>
</EmptyHeader>
<EmptyContent>
@ -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"}
</Button>
</EmptyContent>
</Empty>
@ -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"}
</Button>
</div>
<Table className="border border-border/60 rounded-xl">
<Table className="rounded-xl border border-border/60">
<TableHeader>
<TableRow>
{previewColumns.map((col) => (
<TableHead key={col} className="max-w-[260px]">
{col}
{previewColumns.map((column) => (
<TableHead key={column} className="max-w-[260px]">
{column}
</TableHead>
))}
</TableRow>
</TableHeader>
<TableBody>
{previewRows.map((row, idx) => (
<TableRow key={idx}>
{previewColumns.map((col) => (
<TableCell key={col} className="max-w-[260px]">
<div className="truncate">{stringifyCell(row[col])}</div>
{previewRows.map((row, index) => (
<TableRow key={index}>
{previewColumns.map((column) => (
<TableCell key={column} className="max-w-[260px]">
<div className="truncate">{stringifyCell(row[column])}</div>
</TableCell>
))}
</TableRow>
@ -392,7 +580,7 @@ export function SeedDialog({ config, onUpdate }: SeedDialogProps): ReactElement
</Table>
</div>
)}
{previewError && <p className="text-xs text-red-600">{previewError}</p>}
{inspectError && <p className="text-xs text-red-600">{inspectError}</p>}
</div>
</TabsContent>
</Tabs>

View file

@ -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 (
<label
className="flex items-center gap-1.5 text-xs font-semibold uppercase text-muted-foreground"
htmlFor={htmlFor}
>
<span>{label}</span>
{hint && (
<Tooltip>
<TooltipTrigger asChild={true}>
<button
type="button"
className="inline-flex size-3.5 items-center justify-center rounded-full text-muted-foreground/80 hover:text-foreground"
aria-label={`More info: ${label}`}
>
<HugeiconsIcon icon={InformationCircleIcon} className="size-3.5" />
</button>
</TooltipTrigger>
<TooltipContent>{hint}</TooltipContent>
</Tooltip>
)}
</label>
);
}

View file

@ -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;

View file

@ -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: "",

View file

@ -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<SeedConfig> {
if (!isRecord(seedConfigRaw)) {
return {};
@ -45,11 +58,13 @@ function parseSeedSettings(seedConfigRaw: unknown): Partial<SeedConfig> {
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<SeedConfig> {
}
return {
hf_repo_id,
hf_path,
hf_token,
hf_endpoint,

View file

@ -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.");