Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
411edbe
feat(studio): Anonymizer builder Source form [ASTD-327]
marcusds Jul 24, 2026
edd7c38
feat(studio): Anonymizer builder model settings + create wiring [ASTD…
marcusds Jul 24, 2026
d718bcc
feat(studio): per-role model settings for Anonymizer builder [ASTD-327]
marcusds Jul 24, 2026
41130ee
feat(studio): use searchable model dropdown in Anonymizer model setti…
marcusds Jul 24, 2026
1567260
feat(studio): per-role model params for Anonymizer builder [ASTD-327]
marcusds Jul 24, 2026
5cf230b
fix(studio): seed Anonymizer model defaults regardless of active tab …
marcusds Jul 24, 2026
8bab561
feat(studio): disable Anonymizer submit while models load [ASTD-327]
marcusds Jul 24, 2026
6b93efa
fix(studio): surface Anonymizer validation errors on submit [ASTD-327]
marcusds Jul 24, 2026
5178d4e
fix(studio): add noValidate to Anonymizer builder form [ASTD-327]
marcusds Jul 24, 2026
d335e60
feat(studio): lock Anonymizer strategy to Substitute for now [ASTD-327]
marcusds Jul 24, 2026
ef2da21
feat(studio): lean entity-label picker for Anonymizer builder [ASTD-327]
marcusds Jul 24, 2026
96986f7
feat(studio): use SegmentedControl for Anonymizer builder tabs [ASTD-…
marcusds Jul 24, 2026
2a6c6b1
feat(studio): introspect dataset columns for Text Column select [ASTD…
marcusds Jul 24, 2026
d853384
feat(studio): seed a suggested default model, not the first one [ASTD…
marcusds Jul 24, 2026
1a97b69
feat(studio): auto-select the only column in Text Column dropdown [AS…
marcusds Jul 24, 2026
e7f956d
fix(studio): default a generous model timeout for anonymizer jobs [AS…
marcusds Jul 24, 2026
2b92580
chore(studio): bump default anonymizer model timeout to 500s [ASTD-327]
marcusds Jul 24, 2026
0969bd4
feat(studio): redirect anonymizer create to platform job detail [ASTD…
marcusds Jul 24, 2026
4e12ab8
fix(studio): default high max_tokens for anonymizer models [ASTD-327]
marcusds Jul 24, 2026
fb7d1eb
perf(studio): memoize column options and hoist static tab items [ASTD…
marcusds Jul 24, 2026
778b7db
style(studio): prettier-format anonymizer schema test [ASTD-327]
marcusds Jul 24, 2026
8a19de6
fix(studio): reset source on type change, trim source validation [AST…
marcusds Jul 24, 2026
5e4afb5
chore(studio): remove explanatory comments from anonymizer builder [A…
marcusds Jul 24, 2026
223c68c
refactor(studio): lift trimToUndefined to shared strings util [ASTD-327]
marcusds Jul 24, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
// SPDX-License-Identifier: Apache-2.0

import { parseFilesetLocation } from '@nemo/common/src/components/DatasetFileSelect/parseFilesetLocation';
import { ControlledSelect } from '@nemo/common/src/components/form/ControlledSelect';
import { ControlledTextArea } from '@nemo/common/src/components/form/ControlledTextArea';
import { ControlledTextInput } from '@nemo/common/src/components/form/ControlledTextInput';
import { useFilesListFilesetFiles } from '@nemo/sdk/generated/platform/api';
import { Stack, Text } from '@nvidia/foundations-react-core';
import { useDatasetFileContent } from '@studio/api/datasets/useDatasetFileContent';
import { useWorkspaceFromPath } from '@studio/hooks/useWorkspaceFromPath';
import {
MAX_COLUMN_INTROSPECTION_BYTES,
SOURCE_TYPE_DATASET,
} from '@studio/routes/AnonymizerBuilderRoute/constants';
import type { AnonymizerFormData } from '@studio/routes/AnonymizerBuilderRoute/schema';
import { getContentColumns, getFileExtension } from '@studio/util/files';
import { FC, useEffect, useMemo } from 'react';
import { useFormContext, useWatch } from 'react-hook-form';

export const ColumnsSection: FC = () => {
const { control, setValue } = useFormContext<AnonymizerFormData>();
const workspace = useWorkspaceFromPath();
const source = useWatch({ control, name: 'source' });
const sourceType = useWatch({ control, name: 'sourceType' });
const textColumn = useWatch({ control, name: 'textColumn' });

const parsed = useMemo(
() =>
sourceType === SOURCE_TYPE_DATASET && source ? parseFilesetLocation(source, workspace) : null,
[sourceType, source, workspace]
);
const filesetWorkspace = parsed?.workspace ?? '';
const filesetName = parsed?.name ?? '';
const filePath = parsed?.objectPath ?? '';

const { data: filesResponse } = useFilesListFilesetFiles(
filesetWorkspace,
filesetName,
undefined,
{
query: { enabled: Boolean(filesetWorkspace && filesetName) },
}
);
const fileSize = useMemo(
() => filesResponse?.data?.find((file) => file.path === filePath)?.size ?? null,
[filesResponse?.data, filePath]
);
const tooLarge = fileSize != null && fileSize > MAX_COLUMN_INTROSPECTION_BYTES;

const isParquet = filePath.endsWith('parquet');
const canIntrospect = Boolean(filesetWorkspace && filesetName && filePath) && !tooLarge;
const { data: fileContent } = useDatasetFileContent({
workspace: filesetWorkspace,
name: filesetName,
path: filePath,
range: isParquet ? [0, 1] : undefined,
enabled: canIntrospect,
});

const columns = useMemo(() => {
if (!fileContent) return [];
const fileType = isParquet ? 'jsonl' : (getFileExtension(filePath) ?? undefined);
return getContentColumns(fileContent, fileType);
}, [fileContent, filePath, isParquet]);

const columnItems = useMemo(
() => columns.map((column) => ({ label: column, value: column })),
[columns]
);
const useColumnDropdown = canIntrospect && columns.length > 0;

useEffect(() => {
if (useColumnDropdown && columns.length === 1 && textColumn !== columns[0]) {
setValue('textColumn', columns[0], { shouldValidate: true });
}
}, [useColumnDropdown, columns, textColumn, setValue]);

return (
<Stack gap="density-lg">
<Text kind="label/bold/lg">Columns</Text>
{useColumnDropdown ? (
<ControlledSelect
aria-label="Text column"
items={columnItems}
useControllerProps={{ name: 'textColumn', control }}
formFieldProps={{
slotLabel: 'Text Column',
slotInfo: 'The column containing the text to anonymize.',
}}
/>
) : (
<ControlledTextInput
useControllerProps={{ name: 'textColumn', control }}
placeholder="e.g. biography"
formFieldProps={{
slotLabel: 'Text Column',
slotInfo: 'The column containing the text to anonymize.',
}}
/>
)}
<ControlledTextArea
useControllerProps={{ name: 'dataSummary', control }}
formFieldProps={{
slotLabel: 'Data Summary',
slotInfo: 'Optional short description of the data. Helps the LLM produce better results.',
}}
/>
</Stack>
);
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
// SPDX-License-Identifier: Apache-2.0

import { ControlledDatasetFileSelect } from '@nemo/common/src/components/DatasetFileSelect/ControlledDatasetFileSelect';
import { ControlledSelect } from '@nemo/common/src/components/form/ControlledSelect';
import { ControlledTextInput } from '@nemo/common/src/components/form/ControlledTextInput';
import { Stack, Text } from '@nvidia/foundations-react-core';
import { useWorkspaceFromPath } from '@studio/hooks/useWorkspaceFromPath';
import {
SOURCE_TYPE_DATASET,
SOURCE_TYPE_OPTIONS,
} from '@studio/routes/AnonymizerBuilderRoute/constants';
import type { AnonymizerFormData } from '@studio/routes/AnonymizerBuilderRoute/schema';
import { FC } from 'react';
import { useFormContext, useWatch } from 'react-hook-form';

export const DataSourceSection: FC = () => {
const { control, setValue, setError, clearErrors } = useFormContext<AnonymizerFormData>();
const workspace = useWorkspaceFromPath();
const sourceType = useWatch({ control, name: 'sourceType' });
const isDataset = sourceType === SOURCE_TYPE_DATASET;
Comment thread
coderabbitai[bot] marked this conversation as resolved.

return (
<Stack gap="density-lg">
<Text kind="label/bold/lg">Data Source</Text>
<ControlledSelect
aria-label="Source type"
items={SOURCE_TYPE_OPTIONS}
useControllerProps={{ name: 'sourceType', control }}
onChange={() => {
setValue('source', '');
clearErrors('source');
}}
formFieldProps={{ slotLabel: 'Source', required: true }}
/>
{isDataset ? (
<ControlledDatasetFileSelect
label="Dataset"
acceptedFileTypes={['.csv', '.parquet']}
useControllerProps={{ name: 'source', control }}
setError={(error) => setError('source', error)}
clearError={() => clearErrors('source')}
workspace={workspace}
formFieldProps={{ required: true }}
/>
) : (
<ControlledTextInput
useControllerProps={{ name: 'source', control }}
placeholder="https://example.com/data.csv"
formFieldProps={{
slotLabel: 'URL',
required: true,
slotInfo: 'HTTP(S) URL of a CSV or Parquet file to anonymize.',
}}
/>
)}
</Stack>
);
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
// SPDX-License-Identifier: Apache-2.0

import { ControlledCheckbox } from '@nemo/common/src/components/form/ControlledCheckbox';
import { ControlledCombobox } from '@nemo/common/src/components/form/ControlledCombobox';
import { ControlledSegmentedControl } from '@nemo/common/src/components/form/ControlledSegmentedControl';
import { useAnonymizerListEntityLabels } from '@nemo/sdk/generated/anonymizer/api';
import { Stack, Text } from '@nvidia/foundations-react-core';
import { useWorkspaceFromPath } from '@studio/hooks/useWorkspaceFromPath';
import {
ENTITY_MODE_AUTO,
ENTITY_MODE_CUSTOM,
ENTITY_MODE_OPTIONS,
} from '@studio/routes/AnonymizerBuilderRoute/constants';
import type { AnonymizerFormData } from '@studio/routes/AnonymizerBuilderRoute/schema';
import { FC } from 'react';
import { useFormContext, useWatch } from 'react-hook-form';

export const EntitiesSection: FC = () => {
const { control } = useFormContext<AnonymizerFormData>();
const workspace = useWorkspaceFromPath();
const entityMode = useWatch({ control, name: 'entityMode' });
const includeDefaults = useWatch({ control, name: 'includeDefaultEntities' });

const isCustom = entityMode === ENTITY_MODE_CUSTOM;
const showLabelPicker = isCustom && !includeDefaults;

const { data, isLoading } = useAnonymizerListEntityLabels(workspace, { query: {} });
const labels = data?.data ?? [];

return (
<Stack gap="density-lg">
<Text kind="label/bold/lg">Entities</Text>
<ControlledSegmentedControl
className="w-full"
size="tiny"
items={ENTITY_MODE_OPTIONS}
useControllerProps={{ name: 'entityMode', control }}
/>
<Text kind="body/regular/md">
{entityMode === ENTITY_MODE_AUTO
? 'Auto-detect lets the augmenter create additional labels beyond the defaults.'
: 'Custom mode only outputs entities you define. Use Auto-detect to allow additional labels.'}
</Text>
{isCustom && (
<ControlledCheckbox
useControllerProps={{ name: 'includeDefaultEntities', control }}
formFieldProps={{ slotLabel: 'Include all default entities' }}
/>
)}
{showLabelPicker && (
<ControlledCombobox
kind="multiple"
loading={isLoading}
items={labels}
useControllerProps={{ name: 'entityLabels', control }}
formFieldProps={{
slotLabel: 'Entity Labels',
slotInfo: 'Only these entity types will be detected and replaced.',
}}
/>
)}
</Stack>
);
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
// SPDX-License-Identifier: Apache-2.0

import { ControlledSelect } from '@nemo/common/src/components/form/ControlledSelect';
import { ControlledTextInput } from '@nemo/common/src/components/form/ControlledTextInput';
import { Stack, Text } from '@nvidia/foundations-react-core';
import {
STRATEGY_DESCRIPTIONS,
STRATEGY_OPTIONS,
} from '@studio/routes/AnonymizerBuilderRoute/constants';
import type { AnonymizerFormData } from '@studio/routes/AnonymizerBuilderRoute/schema';
import { FC } from 'react';
import { useFormContext, useWatch } from 'react-hook-form';

export const GenerationSection: FC = () => {
const { control } = useFormContext<AnonymizerFormData>();
const strategy = useWatch({ control, name: 'strategy' });

return (
<Stack gap="density-lg">
<Text kind="label/bold/lg">Generation</Text>
<ControlledSelect
aria-label="Anonymization strategy"
disabled
items={STRATEGY_OPTIONS}
useControllerProps={{ name: 'strategy', control }}
formFieldProps={{
slotLabel: 'Anonymization Strategy',
required: true,
slotInfo: 'Only Substitute is available today. Other strategies are coming soon.',
}}
/>
<Text kind="body/regular/md">{STRATEGY_DESCRIPTIONS[strategy]}</Text>
<ControlledTextInput
type="number"
min={1}
useControllerProps={{ name: 'previewRows', control }}
formFieldProps={{
slotLabel: 'Preview Rows',
slotInfo: 'Number of records to anonymize when running a preview.',
}}
/>
</Stack>
);
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
// SPDX-License-Identifier: Apache-2.0

import { ControlledSearchableSelect } from '@nemo/common/src/components/form/ControlledSearchableSelect';
import { ParamsDropdown } from '@nemo/common/src/components/ModelSelectV2/ParamsDropdown';
import { useModelsListProviders } from '@nemo/sdk/generated/platform/api';
import type { InferenceParams } from '@nemo/sdk/generated/platform/schema';
import { Divider, Flex, Stack, Text } from '@nvidia/foundations-react-core';
import { modelsFromProviders } from '@studio/components/NewDataDesignerJobForm/utils';
import { DEFAULT_LARGE_PAGE_SIZE } from '@studio/constants/constants';
import { useWorkspaceFromPath } from '@studio/hooks/useWorkspaceFromPath';
import {
activeRolesForStrategy,
GLINER_ROLE,
ROLE_LABELS,
} from '@studio/routes/AnonymizerBuilderRoute/constants';
import type { AnonymizerFormData } from '@studio/routes/AnonymizerBuilderRoute/schema';
import { pickDefaultModelName } from '@studio/util/buildSuggestedModelOptions';
import { FC, useEffect, useMemo, useState } from 'react';
import { useFormContext, useWatch } from 'react-hook-form';

const isGliner = (name: string) => /gliner/i.test(name);

export const ModelSettingsSection: FC = () => {
const { control, setValue, getValues } = useFormContext<AnonymizerFormData>();
const workspace = useWorkspaceFromPath();
const strategy = useWatch({ control, name: 'strategy' });
const roleModelsValue = useWatch({ control, name: 'roleModels' });
const [openParamsRole, setOpenParamsRole] = useState<string | null>(null);

const roles = useMemo(() => activeRolesForStrategy(strategy), [strategy]);

const { data: providersPage, isLoading } = useModelsListProviders(
workspace,
{ page_size: DEFAULT_LARGE_PAGE_SIZE },
{ query: {} }
);

const models = useMemo(
() => modelsFromProviders(providersPage?.data ?? []),
[providersPage?.data]
);
const items = useMemo(
() => models.map((model) => ({ label: model.name, value: model.id })),
[models]
);

const applyModel = (role: string, id: string) => {
const selected = models.find((model) => model.id === id);
setValue(`roleModels.${role}.model`, selected?.served_model_name ?? '', {
shouldValidate: true,
});
setValue(`roleModels.${role}.provider`, selected?.model_providers?.[0] ?? '', {
shouldValidate: true,
});
};

useEffect(() => {
if (!models.length) return;
const suggestedName = pickDefaultModelName(
models.map((model) => ({ name: model.served_model_name ?? model.name }))
);
const llm =
models.find((model) => (model.served_model_name ?? model.name) === suggestedName) ??
models.find((model) => !isGliner(model.name)) ??
models[0];
const gliner = models.find((model) => isGliner(model.name)) ?? llm;
for (const role of roles) {
const current = getValues(`roleModels.${role}.modelId`);
if (current) continue;
const pick = role === GLINER_ROLE ? gliner : llm;
setValue(`roleModels.${role}.modelId`, pick.id);
applyModel(role, pick.id);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [models, roles, getValues, setValue]);

return (
<Stack gap="density-2xl">
{roles.map((role, index) => (
<Stack key={role} gap="density-lg">
{index > 0 && <Divider orientation="horizontal" width="small" />}
<Text kind="label/bold/lg">{ROLE_LABELS[role] ?? role}</Text>
<Flex gap="density-md" align="end">
<div className="grow">
<ControlledSearchableSelect
aria-label={ROLE_LABELS[role] ?? role}
options={items}
isLoading={isLoading}
triggerPlaceholder="Select a model"
searchPlaceholder="Search models..."
emptyMessage={isLoading ? 'Loading models...' : 'No models in this workspace.'}
onChange={(value) => applyModel(role, value)}
useControllerProps={{
name: `roleModels.${role}.modelId`,
control,
}}
formFieldProps={{ slotLabel: 'Model', required: true }}
/>
</div>
<ParamsDropdown
open={openParamsRole === role}
onOpenChange={(next) => setOpenParamsRole(next ? role : null)}
inferenceParams={roleModelsValue?.[role]?.params as Partial<InferenceParams>}
onInferenceParamsChange={(params) =>
setValue(`roleModels.${role}.params`, params as Record<string, unknown>)
}
/>
</Flex>
</Stack>
))}
</Stack>
);
};
Loading