From 411edbe031b0658fbc6b894072e57d260e063098 Mon Sep 17 00:00:00 2001 From: mschwab Date: Thu, 23 Jul 2026 22:19:48 -0700 Subject: [PATCH 01/24] feat(studio): Anonymizer builder Source form [ASTD-327] Replace the /anonymizer/new placeholder with the builder shell: a left config panel (Source / Model Settings tabs) + a preview-panel placeholder. Source tab (react-hook-form + Zod -> AnonymizerRequest): - Data Source: source type (URL | Dataset) + reference input - Generation: strategy select (substitute/redact/annotate/hash/rewrite) + description + preview rows - Columns: text column + data summary - Entities: Custom | Auto-detect toggle + include-defaults checkbox Submit via useAnonymizerCreateRunJob -> navigate to the job. Strategy is sent at its no-arg default (rewrite -> config.rewrite, others -> config.replace); strategy params, entity-label picker, model settings, and live preview are follow-ups. Gated behind VITE_FF_ANONYMIZER_ENABLED (default false). Signed-off-by: mschwab --- .../components/ColumnsSection.tsx | 34 +++++ .../components/DataSourceSection.tsx | 55 +++++++ .../components/EntitiesSection.tsx | 39 +++++ .../components/GenerationSection.tsx | 40 +++++ .../AnonymizerBuilderRoute/constants.ts | 61 ++++++++ .../routes/AnonymizerBuilderRoute/index.tsx | 144 ++++++++++++++++-- .../AnonymizerBuilderRoute/schema.test.ts | 46 ++++++ .../routes/AnonymizerBuilderRoute/schema.ts | 70 +++++++++ 8 files changed, 480 insertions(+), 9 deletions(-) create mode 100644 web/packages/studio/src/routes/AnonymizerBuilderRoute/components/ColumnsSection.tsx create mode 100644 web/packages/studio/src/routes/AnonymizerBuilderRoute/components/DataSourceSection.tsx create mode 100644 web/packages/studio/src/routes/AnonymizerBuilderRoute/components/EntitiesSection.tsx create mode 100644 web/packages/studio/src/routes/AnonymizerBuilderRoute/components/GenerationSection.tsx create mode 100644 web/packages/studio/src/routes/AnonymizerBuilderRoute/constants.ts create mode 100644 web/packages/studio/src/routes/AnonymizerBuilderRoute/schema.test.ts create mode 100644 web/packages/studio/src/routes/AnonymizerBuilderRoute/schema.ts diff --git a/web/packages/studio/src/routes/AnonymizerBuilderRoute/components/ColumnsSection.tsx b/web/packages/studio/src/routes/AnonymizerBuilderRoute/components/ColumnsSection.tsx new file mode 100644 index 0000000000..0bcde4e8d7 --- /dev/null +++ b/web/packages/studio/src/routes/AnonymizerBuilderRoute/components/ColumnsSection.tsx @@ -0,0 +1,34 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { ControlledTextArea } from '@nemo/common/src/components/form/ControlledTextArea'; +import { ControlledTextInput } from '@nemo/common/src/components/form/ControlledTextInput'; +import { Stack, Text } from '@nvidia/foundations-react-core'; +import type { AnonymizerFormData } from '@studio/routes/AnonymizerBuilderRoute/schema'; +import { FC } from 'react'; +import { useFormContext } from 'react-hook-form'; + +export const ColumnsSection: FC = () => { + const { control } = useFormContext(); + + return ( + + Columns + + + + ); +}; diff --git a/web/packages/studio/src/routes/AnonymizerBuilderRoute/components/DataSourceSection.tsx b/web/packages/studio/src/routes/AnonymizerBuilderRoute/components/DataSourceSection.tsx new file mode 100644 index 0000000000..6b1a2674ec --- /dev/null +++ b/web/packages/studio/src/routes/AnonymizerBuilderRoute/components/DataSourceSection.tsx @@ -0,0 +1,55 @@ +// 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, setError, clearErrors } = useFormContext(); + const workspace = useWorkspaceFromPath(); + const sourceType = useWatch({ control, name: 'sourceType' }); + const isDataset = sourceType === SOURCE_TYPE_DATASET; + + return ( + + Data Source + + {isDataset ? ( + setError('source', error)} + clearError={() => clearErrors('source')} + workspace={workspace} + formFieldProps={{ required: true }} + /> + ) : ( + + )} + + ); +}; diff --git a/web/packages/studio/src/routes/AnonymizerBuilderRoute/components/EntitiesSection.tsx b/web/packages/studio/src/routes/AnonymizerBuilderRoute/components/EntitiesSection.tsx new file mode 100644 index 0000000000..da01b1d18d --- /dev/null +++ b/web/packages/studio/src/routes/AnonymizerBuilderRoute/components/EntitiesSection.tsx @@ -0,0 +1,39 @@ +// 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 { ControlledSegmentedControl } from '@nemo/common/src/components/form/ControlledSegmentedControl'; +import { Stack, Text } from '@nvidia/foundations-react-core'; +import { + ENTITY_MODE_AUTO, + 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(); + const entityMode = useWatch({ control, name: 'entityMode' }); + + return ( + + Entities + + + {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.'} + + + + ); +}; diff --git a/web/packages/studio/src/routes/AnonymizerBuilderRoute/components/GenerationSection.tsx b/web/packages/studio/src/routes/AnonymizerBuilderRoute/components/GenerationSection.tsx new file mode 100644 index 0000000000..c0e3de8a22 --- /dev/null +++ b/web/packages/studio/src/routes/AnonymizerBuilderRoute/components/GenerationSection.tsx @@ -0,0 +1,40 @@ +// 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(); + const strategy = useWatch({ control, name: 'strategy' }); + + return ( + + Generation + + {STRATEGY_DESCRIPTIONS[strategy]} + + + ); +}; diff --git a/web/packages/studio/src/routes/AnonymizerBuilderRoute/constants.ts b/web/packages/studio/src/routes/AnonymizerBuilderRoute/constants.ts new file mode 100644 index 0000000000..3b35baf87c --- /dev/null +++ b/web/packages/studio/src/routes/AnonymizerBuilderRoute/constants.ts @@ -0,0 +1,61 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +export const SOURCE_TYPE_URL = 'url'; +export const SOURCE_TYPE_DATASET = 'dataset'; + +export type SourceType = typeof SOURCE_TYPE_URL | typeof SOURCE_TYPE_DATASET; + +export const SOURCE_TYPE_OPTIONS: { label: string; value: SourceType }[] = [ + { label: 'Dataset', value: SOURCE_TYPE_DATASET }, + { label: 'URL', value: SOURCE_TYPE_URL }, +]; + +export const STRATEGY_SUBSTITUTE = 'substitute'; +export const STRATEGY_REDACT = 'redact'; +export const STRATEGY_ANNOTATE = 'annotate'; +export const STRATEGY_HASH = 'hash'; +export const STRATEGY_REWRITE = 'rewrite'; + +export type Strategy = + | typeof STRATEGY_SUBSTITUTE + | typeof STRATEGY_REDACT + | typeof STRATEGY_ANNOTATE + | typeof STRATEGY_HASH + | typeof STRATEGY_REWRITE; + +/** Rewrite is applied via `config.rewrite`; the other four via `config.replace`. */ +export const REWRITE_STRATEGY: Strategy = STRATEGY_REWRITE; + +export const STRATEGY_OPTIONS: { label: string; value: Strategy }[] = [ + { label: 'Substitute', value: STRATEGY_SUBSTITUTE }, + { label: 'Redact', value: STRATEGY_REDACT }, + { label: 'Annotate', value: STRATEGY_ANNOTATE }, + { label: 'Hash', value: STRATEGY_HASH }, + { label: 'Rewrite', value: STRATEGY_REWRITE }, +]; + +export const STRATEGY_DESCRIPTIONS: Record = { + [STRATEGY_SUBSTITUTE]: + 'Replace detected entities with LLM-generated synthetic values for names, cities, dates, etc.', + [STRATEGY_REDACT]: + 'Replace entities with a label-based marker. The original text is removed entirely.', + [STRATEGY_ANNOTATE]: + 'Tag entities with their label but preserve the original text. Useful for review and debugging.', + [STRATEGY_HASH]: + 'Replace entities with a deterministic hash digest. The same entity text always produces the same hash.', + [STRATEGY_REWRITE]: + 'Transform the entire text to produce a privacy-safe version that reduces explicit and inferable identifiers.', +}; + +export const ENTITY_MODE_CUSTOM = 'custom'; +export const ENTITY_MODE_AUTO = 'auto'; + +export type EntityMode = typeof ENTITY_MODE_CUSTOM | typeof ENTITY_MODE_AUTO; + +export const ENTITY_MODE_OPTIONS: { value: EntityMode; children: string }[] = [ + { value: ENTITY_MODE_CUSTOM, children: 'Custom' }, + { value: ENTITY_MODE_AUTO, children: 'Auto-detect' }, +]; + +export const DEFAULT_PREVIEW_ROWS = 1; diff --git a/web/packages/studio/src/routes/AnonymizerBuilderRoute/index.tsx b/web/packages/studio/src/routes/AnonymizerBuilderRoute/index.tsx index 2a7375b01b..05116652b8 100644 --- a/web/packages/studio/src/routes/AnonymizerBuilderRoute/index.tsx +++ b/web/packages/studio/src/routes/AnonymizerBuilderRoute/index.tsx @@ -1,27 +1,153 @@ // SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 -import { PageHeader, Stack } from '@nvidia/foundations-react-core'; +import { zodResolver } from '@hookform/resolvers/zod'; +import { useAnonymizerCreateRunJob } from '@nemo/sdk/generated/anonymizer/api'; +import type { RunJob } from '@nemo/sdk/generated/anonymizer/schema'; +import { + Banner, + Button, + Divider, + Flex, + Panel, + Stack, + TabsList, + TabsRoot, + TabsTrigger, + Text, +} from '@nvidia/foundations-react-core'; +import { getErrorMessage } from '@studio/api/common/utils'; import { AccessibleTitle } from '@studio/components/AccessibleTitle'; import { ANONYMIZER_ENABLED } from '@studio/constants/environment'; +import { useWorkspaceFromPath } from '@studio/hooks/useWorkspaceFromPath'; import { useBreadcrumbs } from '@studio/providers/breadcrumbs/useBreadcrumbs'; -import { FC } from 'react'; +import { ColumnsSection } from '@studio/routes/AnonymizerBuilderRoute/components/ColumnsSection'; +import { DataSourceSection } from '@studio/routes/AnonymizerBuilderRoute/components/DataSourceSection'; +import { EntitiesSection } from '@studio/routes/AnonymizerBuilderRoute/components/EntitiesSection'; +import { GenerationSection } from '@studio/routes/AnonymizerBuilderRoute/components/GenerationSection'; +import { + anonymizerFormSchema, + buildAnonymizerJobRequest, + getAnonymizerFormDefaults, +} from '@studio/routes/AnonymizerBuilderRoute/schema'; +import { getAnonymizerJobRoute, getWorkspaceAnonymizerRoute } from '@studio/routes/utils'; +import { FC, useState } from 'react'; +import { FormProvider, useForm } from 'react-hook-form'; +import { useNavigate } from 'react-router-dom'; + +const TAB_SOURCE = 'source'; +const TAB_MODEL_SETTINGS = 'model-settings'; export const AnonymizerBuilderRoute: FC | null = ANONYMIZER_ENABLED ? () => { + const navigate = useNavigate(); + const workspace = useWorkspaceFromPath(); + const [activeTab, setActiveTab] = useState(TAB_SOURCE); + const [submitError, setSubmitError] = useState(undefined); + useBreadcrumbs({ items: [{ slotLabel: 'Anonymizer' }, { slotLabel: 'Anonymize Data' }], }); + const form = useForm({ + mode: 'onChange', + resolver: zodResolver(anonymizerFormSchema), + defaultValues: getAnonymizerFormDefaults(), + }); + + const createJob = useAnonymizerCreateRunJob({ + mutation: { + onSuccess: (job: RunJob) => + navigate( + job.name + ? getAnonymizerJobRoute(workspace, job.name) + : getWorkspaceAnonymizerRoute(workspace) + ), + onError: (error) => + setSubmitError(getErrorMessage(error, 'Failed to create anonymizer job')), + }, + }); + + const onSubmit = form.handleSubmit((values) => { + setSubmitError(undefined); + createJob.mutate({ workspace, data: buildAnonymizerJobRequest(values) }); + }); + + const handleCancel = () => navigate(getWorkspaceAnonymizerRoute(workspace)); + return ( - - - + +
+ + + + + + } + > + + + + setActiveTab(TAB_SOURCE)}> + Source + + setActiveTab(TAB_MODEL_SETTINGS)} + > + Model Settings + + + + + {submitError && ( + + {submitError} + + )} + + {activeTab === TAB_SOURCE ? ( + + + + + + + + + + ) : ( + Model settings coming soon. + )} + + + + + Your records preview will appear here + + +
+
); } diff --git a/web/packages/studio/src/routes/AnonymizerBuilderRoute/schema.test.ts b/web/packages/studio/src/routes/AnonymizerBuilderRoute/schema.test.ts new file mode 100644 index 0000000000..c4d3ea87dc --- /dev/null +++ b/web/packages/studio/src/routes/AnonymizerBuilderRoute/schema.test.ts @@ -0,0 +1,46 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { + AnonymizerFormData, + buildAnonymizerJobRequest, + getAnonymizerFormDefaults, +} from '@studio/routes/AnonymizerBuilderRoute/schema'; + +const form = (overrides: Partial = {}): AnonymizerFormData => ({ + ...getAnonymizerFormDefaults(), + source: 'https://example.com/data.csv', + ...overrides, +}); + +describe('buildAnonymizerJobRequest', () => { + it('routes the four replace strategies to config.replace with the kind tag', () => { + for (const strategy of ['substitute', 'redact', 'annotate', 'hash'] as const) { + const req = buildAnonymizerJobRequest(form({ strategy })); + expect(req.spec.config).toEqual({ replace: { kind: strategy } }); + } + }); + + it('routes rewrite to config.rewrite', () => { + const req = buildAnonymizerJobRequest(form({ strategy: 'rewrite' })); + expect(req.spec.config).toEqual({ rewrite: {} }); + }); + + it('trims the source and omits empty optional fields', () => { + const req = buildAnonymizerJobRequest( + form({ source: ' s3://x.csv ', textColumn: '', dataSummary: ' ' }) + ); + expect(req.spec.data.source).toBe('s3://x.csv'); + expect(req.spec.data.text_column).toBeUndefined(); + expect(req.spec.data.data_summary).toBeUndefined(); + }); + + it('passes through populated columns and a trimmed name', () => { + const req = buildAnonymizerJobRequest( + form({ name: ' job-1 ', textColumn: 'biography', dataSummary: 'profiles' }) + ); + expect(req.name).toBe('job-1'); + expect(req.spec.data.text_column).toBe('biography'); + expect(req.spec.data.data_summary).toBe('profiles'); + }); +}); diff --git a/web/packages/studio/src/routes/AnonymizerBuilderRoute/schema.ts b/web/packages/studio/src/routes/AnonymizerBuilderRoute/schema.ts new file mode 100644 index 0000000000..e1d985185d --- /dev/null +++ b/web/packages/studio/src/routes/AnonymizerBuilderRoute/schema.ts @@ -0,0 +1,70 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { generateDefaultName } from '@nemo/common/src/utils/generateDefaultName'; +import type { AnonymizerConfigInput, RunJobRequest } from '@nemo/sdk/generated/anonymizer/schema'; +import { + DEFAULT_PREVIEW_ROWS, + ENTITY_MODE_CUSTOM, + REWRITE_STRATEGY, + SOURCE_TYPE_DATASET, + STRATEGY_SUBSTITUTE, +} from '@studio/routes/AnonymizerBuilderRoute/constants'; +import { z } from 'zod'; + +export const anonymizerFormSchema = z.object({ + name: z.string().optional(), + sourceType: z.enum(['url', 'dataset']), + source: z.string().min(1, 'A data source is required'), + strategy: z.enum(['substitute', 'redact', 'annotate', 'hash', 'rewrite']), + previewRows: z.number().int().min(1), + textColumn: z.string().optional(), + dataSummary: z.string().optional(), + entityMode: z.enum([ENTITY_MODE_CUSTOM, 'auto']), + includeDefaultEntities: z.boolean(), +}); + +export type AnonymizerFormData = z.infer; + +export const getAnonymizerFormDefaults = (): AnonymizerFormData => ({ + name: generateDefaultName(), + sourceType: SOURCE_TYPE_DATASET, + source: '', + strategy: STRATEGY_SUBSTITUTE, + previewRows: DEFAULT_PREVIEW_ROWS, + textColumn: '', + dataSummary: '', + entityMode: ENTITY_MODE_CUSTOM, + includeDefaultEntities: true, +}); + +const trimToUndefined = (value: string | undefined): string | undefined => { + const trimmed = value?.trim(); + return trimmed ? trimmed : undefined; +}; + +/** + * Build the create-job request. The strategy is applied via `config.rewrite` + * for Rewrite and `config.replace` for the other four. `replace` is a + * `kind`-discriminated union server-side, so the tag must be sent even though + * it isn't a modelled field on the SDK types. Strategy-specific parameters + * (templates, hash length, risk tolerance, …) are added in a follow-up. + */ +export const buildAnonymizerJobRequest = (form: AnonymizerFormData): RunJobRequest => { + const config: AnonymizerConfigInput = + form.strategy === REWRITE_STRATEGY + ? { rewrite: {} } + : { replace: { kind: form.strategy } as AnonymizerConfigInput['replace'] }; + + return { + name: trimToUndefined(form.name), + spec: { + config, + data: { + source: form.source.trim(), + text_column: trimToUndefined(form.textColumn), + data_summary: trimToUndefined(form.dataSummary), + }, + }, + }; +}; From edd7c3810e3e7e1cc271b9d3a3dd25923b73efb8 Mon Sep 17 00:00:00 2001 From: mschwab Date: Thu, 23 Jul 2026 23:15:44 -0700 Subject: [PATCH 02/24] feat(studio): Anonymizer builder model settings + create wiring [ASTD-327] MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Model Settings tab selects an inference provider model (via useModelsListProviders / modelsFromProviders) and the create request now sends spec.model_configs plus spec.selected_models, mapping the active strategy's workflow roles (detection always; +replace for substitute; +rewrite roles for rewrite) to the single chosen model — required for remote execution. Also send the replace 'kind' discriminator tag. Verified end-to-end against a local platform: create returns 201. Signed-off-by: mschwab --- .../components/ModelSettingsSection.tsx | 56 +++++++++++++++++++ .../AnonymizerBuilderRoute/constants.ts | 21 +++++++ .../routes/AnonymizerBuilderRoute/index.tsx | 16 ++++-- .../AnonymizerBuilderRoute/schema.test.ts | 31 ++++++++++ .../routes/AnonymizerBuilderRoute/schema.ts | 42 +++++++++++++- 5 files changed, 160 insertions(+), 6 deletions(-) create mode 100644 web/packages/studio/src/routes/AnonymizerBuilderRoute/components/ModelSettingsSection.tsx diff --git a/web/packages/studio/src/routes/AnonymizerBuilderRoute/components/ModelSettingsSection.tsx b/web/packages/studio/src/routes/AnonymizerBuilderRoute/components/ModelSettingsSection.tsx new file mode 100644 index 0000000000..109926364b --- /dev/null +++ b/web/packages/studio/src/routes/AnonymizerBuilderRoute/components/ModelSettingsSection.tsx @@ -0,0 +1,56 @@ +// 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 { useModelsListProviders } from '@nemo/sdk/generated/platform/api'; +import { 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 type { AnonymizerFormData } from '@studio/routes/AnonymizerBuilderRoute/schema'; +import { FC, useMemo } from 'react'; +import { useFormContext } from 'react-hook-form'; + +export const ModelSettingsSection: FC = () => { + const { control, setValue } = useFormContext(); + const workspace = useWorkspaceFromPath(); + + 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 handleChange = (id: string) => { + const selected = models.find((model) => model.id === id); + setValue('model', selected?.served_model_name ?? '', { shouldValidate: true }); + setValue('provider', selected?.model_providers?.[0] ?? '', { shouldValidate: true }); + }; + + return ( + + Model Settings + + Select the inference provider model the Anonymizer uses for entity detection and generation. + + handleChange(value as string)} + useControllerProps={{ name: 'modelId', control }} + formFieldProps={{ slotLabel: 'Model', required: true }} + /> + + ); +}; diff --git a/web/packages/studio/src/routes/AnonymizerBuilderRoute/constants.ts b/web/packages/studio/src/routes/AnonymizerBuilderRoute/constants.ts index 3b35baf87c..e391756ea5 100644 --- a/web/packages/studio/src/routes/AnonymizerBuilderRoute/constants.ts +++ b/web/packages/studio/src/routes/AnonymizerBuilderRoute/constants.ts @@ -59,3 +59,24 @@ export const ENTITY_MODE_OPTIONS: { value: EntityMode; children: string }[] = [ ]; export const DEFAULT_PREVIEW_ROWS = 1; + +/** Alias assigned to the single user-selected model in `model_configs`. */ +export const MODEL_ALIAS = 'anonymizer-model'; + +/** Role names the anonymizer workflows resolve against `model_configs` aliases. */ +export const DETECTION_ROLES = [ + 'entity_detector', + 'entity_validator', + 'entity_augmenter', + 'latent_detector', +]; +export const REPLACE_ROLE = 'replacement_generator'; +export const REWRITE_ROLES = [ + 'domain_classifier', + 'disposition_analyzer', + 'meaning_extractor', + 'qa_generator', + 'rewriter', + 'repairer', + 'evaluator', +]; diff --git a/web/packages/studio/src/routes/AnonymizerBuilderRoute/index.tsx b/web/packages/studio/src/routes/AnonymizerBuilderRoute/index.tsx index 05116652b8..1799390cb8 100644 --- a/web/packages/studio/src/routes/AnonymizerBuilderRoute/index.tsx +++ b/web/packages/studio/src/routes/AnonymizerBuilderRoute/index.tsx @@ -25,6 +25,7 @@ import { ColumnsSection } from '@studio/routes/AnonymizerBuilderRoute/components import { DataSourceSection } from '@studio/routes/AnonymizerBuilderRoute/components/DataSourceSection'; import { EntitiesSection } from '@studio/routes/AnonymizerBuilderRoute/components/EntitiesSection'; import { GenerationSection } from '@studio/routes/AnonymizerBuilderRoute/components/GenerationSection'; +import { ModelSettingsSection } from '@studio/routes/AnonymizerBuilderRoute/components/ModelSettingsSection'; import { anonymizerFormSchema, buildAnonymizerJobRequest, @@ -68,10 +69,15 @@ export const AnonymizerBuilderRoute: FC | null = ANONYMIZER_ENABLED }, }); - const onSubmit = form.handleSubmit((values) => { - setSubmitError(undefined); - createJob.mutate({ workspace, data: buildAnonymizerJobRequest(values) }); - }); + const onSubmit = form.handleSubmit( + (values) => { + setSubmitError(undefined); + createJob.mutate({ workspace, data: buildAnonymizerJobRequest(values) }); + }, + (errors) => { + if (errors.modelId) setActiveTab(TAB_MODEL_SETTINGS); + } + ); const handleCancel = () => navigate(getWorkspaceAnonymizerRoute(workspace)); @@ -137,7 +143,7 @@ export const AnonymizerBuilderRoute: FC | null = ANONYMIZER_ENABLED ) : ( - Model settings coming soon. + )} diff --git a/web/packages/studio/src/routes/AnonymizerBuilderRoute/schema.test.ts b/web/packages/studio/src/routes/AnonymizerBuilderRoute/schema.test.ts index c4d3ea87dc..a500967e97 100644 --- a/web/packages/studio/src/routes/AnonymizerBuilderRoute/schema.test.ts +++ b/web/packages/studio/src/routes/AnonymizerBuilderRoute/schema.test.ts @@ -10,6 +10,9 @@ import { const form = (overrides: Partial = {}): AnonymizerFormData => ({ ...getAnonymizerFormDefaults(), source: 'https://example.com/data.csv', + modelId: 'default/gpt', + model: 'openai/gpt-oss-120b', + provider: 'default/nvidia', ...overrides, }); @@ -43,4 +46,32 @@ describe('buildAnonymizerJobRequest', () => { expect(req.spec.data.text_column).toBe('biography'); expect(req.spec.data.data_summary).toBe('profiles'); }); + + it('emits a single model_config from the selected model and provider', () => { + const req = buildAnonymizerJobRequest(form()); + expect(req.spec.model_configs).toEqual([ + { alias: 'anonymizer-model', model: 'openai/gpt-oss-120b', provider: 'default/nvidia' }, + ]); + }); + + it('maps detection + replace roles for substitute, detection + rewrite for rewrite', () => { + const sub = buildAnonymizerJobRequest(form({ strategy: 'substitute' })).spec.selected_models; + expect(sub?.detection?.entity_detector).toBe('anonymizer-model'); + expect(sub?.replace?.replacement_generator).toBe('anonymizer-model'); + expect(sub?.rewrite).toBeUndefined(); + + const rew = buildAnonymizerJobRequest(form({ strategy: 'rewrite' })).spec.selected_models; + expect(rew?.detection?.entity_detector).toBe('anonymizer-model'); + expect(rew?.rewrite?.rewriter).toBe('anonymizer-model'); + expect(rew?.replace).toBeUndefined(); + }); + + it('maps only detection roles for redact/annotate/hash', () => { + for (const strategy of ['redact', 'annotate', 'hash'] as const) { + const selected = buildAnonymizerJobRequest(form({ strategy })).spec.selected_models; + expect(selected?.detection?.entity_detector).toBe('anonymizer-model'); + expect(selected?.replace).toBeUndefined(); + expect(selected?.rewrite).toBeUndefined(); + } + }); }); diff --git a/web/packages/studio/src/routes/AnonymizerBuilderRoute/schema.ts b/web/packages/studio/src/routes/AnonymizerBuilderRoute/schema.ts index e1d985185d..f7587d6a5a 100644 --- a/web/packages/studio/src/routes/AnonymizerBuilderRoute/schema.ts +++ b/web/packages/studio/src/routes/AnonymizerBuilderRoute/schema.ts @@ -2,10 +2,19 @@ // SPDX-License-Identifier: Apache-2.0 import { generateDefaultName } from '@nemo/common/src/utils/generateDefaultName'; -import type { AnonymizerConfigInput, RunJobRequest } from '@nemo/sdk/generated/anonymizer/schema'; +import type { + AnonymizerConfigInput, + ModelConfig, + RunJobRequest, + SelectedModelsOverrides, +} from '@nemo/sdk/generated/anonymizer/schema'; import { + DETECTION_ROLES, DEFAULT_PREVIEW_ROWS, ENTITY_MODE_CUSTOM, + MODEL_ALIAS, + REPLACE_ROLE, + REWRITE_ROLES, REWRITE_STRATEGY, SOURCE_TYPE_DATASET, STRATEGY_SUBSTITUTE, @@ -22,6 +31,9 @@ export const anonymizerFormSchema = z.object({ dataSummary: z.string().optional(), entityMode: z.enum([ENTITY_MODE_CUSTOM, 'auto']), includeDefaultEntities: z.boolean(), + modelId: z.string().min(1, 'Select a model in the Model Settings tab'), + model: z.string().optional(), + provider: z.string().optional(), }); export type AnonymizerFormData = z.infer; @@ -36,8 +48,30 @@ export const getAnonymizerFormDefaults = (): AnonymizerFormData => ({ dataSummary: '', entityMode: ENTITY_MODE_CUSTOM, includeDefaultEntities: true, + modelId: '', + model: '', + provider: '', }); +/** + * Map every role of the strategy's active workflow(s) to the single selected + * model alias, so the merged library defaults don't reference aliases missing + * from `model_configs`. Detection runs for all strategies; substitute adds the + * replace role; rewrite adds the rewrite roles. + */ +const buildSelectedModels = (strategy: AnonymizerFormData['strategy']): SelectedModelsOverrides => { + const toRoleMap = (roles: string[]) => + Object.fromEntries(roles.map((role) => [role, MODEL_ALIAS])); + + const selected: SelectedModelsOverrides = { detection: toRoleMap(DETECTION_ROLES) }; + if (strategy === REWRITE_STRATEGY) { + selected.rewrite = toRoleMap(REWRITE_ROLES); + } else if (strategy === STRATEGY_SUBSTITUTE) { + selected.replace = { [REPLACE_ROLE]: MODEL_ALIAS }; + } + return selected; +}; + const trimToUndefined = (value: string | undefined): string | undefined => { const trimmed = value?.trim(); return trimmed ? trimmed : undefined; @@ -56,6 +90,10 @@ export const buildAnonymizerJobRequest = (form: AnonymizerFormData): RunJobReque ? { rewrite: {} } : { replace: { kind: form.strategy } as AnonymizerConfigInput['replace'] }; + const modelConfigs: ModelConfig[] = [ + { alias: MODEL_ALIAS, model: form.model?.trim() ?? '', provider: form.provider?.trim() ?? '' }, + ]; + return { name: trimToUndefined(form.name), spec: { @@ -65,6 +103,8 @@ export const buildAnonymizerJobRequest = (form: AnonymizerFormData): RunJobReque text_column: trimToUndefined(form.textColumn), data_summary: trimToUndefined(form.dataSummary), }, + model_configs: modelConfigs, + selected_models: buildSelectedModels(form.strategy), }, }; }; From d718bcc07b525fed5431dde88c1048cbe1d50b2f Mon Sep 17 00:00:00 2001 From: mschwab Date: Fri, 24 Jul 2026 09:12:39 -0700 Subject: [PATCH 03/24] feat(studio): per-role model settings for Anonymizer builder [ASTD-327] Model Settings tab now exposes one model select per workflow role (Entity Detector / Validator / Augmenter / Latent Detector, plus Replacement Generator for substitute or the rewrite roles for rewrite), matching the Figma. Roles are seeded with sensible defaults on load (GLiNER for the detector, an LLM for the rest) and validated per the selected strategy. Build dedups identical role models into a model_configs pool (one entry per unique model+provider) and maps selected_models role -> alias. Per-role inference Params dropdown is a follow-up. Signed-off-by: mschwab --- .../components/ModelSettingsSection.tsx | 80 +++++++++---- .../AnonymizerBuilderRoute/constants.ts | 28 ++++- .../routes/AnonymizerBuilderRoute/index.tsx | 2 +- .../AnonymizerBuilderRoute/schema.test.ts | 60 ++++++---- .../routes/AnonymizerBuilderRoute/schema.ts | 106 +++++++++++------- 5 files changed, 189 insertions(+), 87 deletions(-) diff --git a/web/packages/studio/src/routes/AnonymizerBuilderRoute/components/ModelSettingsSection.tsx b/web/packages/studio/src/routes/AnonymizerBuilderRoute/components/ModelSettingsSection.tsx index 109926364b..ff51cb3177 100644 --- a/web/packages/studio/src/routes/AnonymizerBuilderRoute/components/ModelSettingsSection.tsx +++ b/web/packages/studio/src/routes/AnonymizerBuilderRoute/components/ModelSettingsSection.tsx @@ -3,17 +3,27 @@ import { ControlledSelect } from '@nemo/common/src/components/form/ControlledSelect'; import { useModelsListProviders } from '@nemo/sdk/generated/platform/api'; -import { Stack, Text } from '@nvidia/foundations-react-core'; +import { Divider, 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 { FC, useMemo } from 'react'; -import { useFormContext } from 'react-hook-form'; +import { FC, useEffect, useMemo } from 'react'; +import { type Path, useFormContext, useWatch } from 'react-hook-form'; + +const isGliner = (name: string) => /gliner/i.test(name); export const ModelSettingsSection: FC = () => { - const { control, setValue } = useFormContext(); + const { control, setValue, getValues } = useFormContext(); const workspace = useWorkspaceFromPath(); + const strategy = useWatch({ control, name: 'strategy' }); + + const roles = useMemo(() => activeRolesForStrategy(strategy), [strategy]); const { data: providersPage, isLoading } = useModelsListProviders( workspace, @@ -30,27 +40,55 @@ export const ModelSettingsSection: FC = () => { [models] ); - const handleChange = (id: string) => { + const applyModel = (role: string, id: string) => { const selected = models.find((model) => model.id === id); - setValue('model', selected?.served_model_name ?? '', { shouldValidate: true }); - setValue('provider', selected?.model_providers?.[0] ?? '', { shouldValidate: true }); + setValue( + `roleModels.${role}.model` as Path, + selected?.served_model_name ?? '', + { shouldValidate: true } + ); + setValue( + `roleModels.${role}.provider` as Path, + selected?.model_providers?.[0] ?? '', + { shouldValidate: true } + ); }; + // Seed sensible defaults once models load: GLiNER for the detector, an LLM for the rest. + useEffect(() => { + if (!models.length) return; + const gliner = models.find((model) => isGliner(model.name)) ?? models[0]; + const llm = models.find((model) => !isGliner(model.name)) ?? models[0]; + for (const role of roles) { + const current = getValues(`roleModels.${role}.modelId` as Path); + if (current) continue; + const pick = role === GLINER_ROLE ? gliner : llm; + setValue(`roleModels.${role}.modelId` as Path, pick.id); + applyModel(role, pick.id); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [models, roles, getValues, setValue]); + return ( - - Model Settings - - Select the inference provider model the Anonymizer uses for entity detection and generation. - - handleChange(value as string)} - useControllerProps={{ name: 'modelId', control }} - formFieldProps={{ slotLabel: 'Model', required: true }} - /> + + {roles.map((role, index) => ( + + {index > 0 && } + {ROLE_LABELS[role] ?? role} + applyModel(role, value as string)} + useControllerProps={{ + name: `roleModels.${role}.modelId` as Path, + control, + }} + formFieldProps={{ slotLabel: 'Model', required: true }} + /> + + ))} ); }; diff --git a/web/packages/studio/src/routes/AnonymizerBuilderRoute/constants.ts b/web/packages/studio/src/routes/AnonymizerBuilderRoute/constants.ts index e391756ea5..de4f998f76 100644 --- a/web/packages/studio/src/routes/AnonymizerBuilderRoute/constants.ts +++ b/web/packages/studio/src/routes/AnonymizerBuilderRoute/constants.ts @@ -60,9 +60,6 @@ export const ENTITY_MODE_OPTIONS: { value: EntityMode; children: string }[] = [ export const DEFAULT_PREVIEW_ROWS = 1; -/** Alias assigned to the single user-selected model in `model_configs`. */ -export const MODEL_ALIAS = 'anonymizer-model'; - /** Role names the anonymizer workflows resolve against `model_configs` aliases. */ export const DETECTION_ROLES = [ 'entity_detector', @@ -80,3 +77,28 @@ export const REWRITE_ROLES = [ 'repairer', 'evaluator', ]; + +export const ROLE_LABELS: Record = { + entity_detector: 'Entity Detector', + entity_validator: 'Entity Validator', + entity_augmenter: 'Entity Augmenter', + latent_detector: 'Latent Detector', + replacement_generator: 'Replacement Generator', + domain_classifier: 'Domain Classifier', + disposition_analyzer: 'Disposition Analyzer', + meaning_extractor: 'Meaning Extractor', + qa_generator: 'QA Generator', + rewriter: 'Rewriter', + repairer: 'Repairer', + evaluator: 'Evaluator', +}; + +/** The role that expects a GLiNER PII detection model rather than an LLM. */ +export const GLINER_ROLE = 'entity_detector'; + +/** Roles configured for a given strategy: detection always, plus the strategy's generator roles. */ +export const activeRolesForStrategy = (strategy: Strategy): string[] => { + if (strategy === STRATEGY_REWRITE) return [...DETECTION_ROLES, ...REWRITE_ROLES]; + if (strategy === STRATEGY_SUBSTITUTE) return [...DETECTION_ROLES, REPLACE_ROLE]; + return [...DETECTION_ROLES]; +}; diff --git a/web/packages/studio/src/routes/AnonymizerBuilderRoute/index.tsx b/web/packages/studio/src/routes/AnonymizerBuilderRoute/index.tsx index 1799390cb8..14cb597f4c 100644 --- a/web/packages/studio/src/routes/AnonymizerBuilderRoute/index.tsx +++ b/web/packages/studio/src/routes/AnonymizerBuilderRoute/index.tsx @@ -75,7 +75,7 @@ export const AnonymizerBuilderRoute: FC | null = ANONYMIZER_ENABLED createJob.mutate({ workspace, data: buildAnonymizerJobRequest(values) }); }, (errors) => { - if (errors.modelId) setActiveTab(TAB_MODEL_SETTINGS); + if (errors.roleModels) setActiveTab(TAB_MODEL_SETTINGS); } ); diff --git a/web/packages/studio/src/routes/AnonymizerBuilderRoute/schema.test.ts b/web/packages/studio/src/routes/AnonymizerBuilderRoute/schema.test.ts index a500967e97..c2e7faae4c 100644 --- a/web/packages/studio/src/routes/AnonymizerBuilderRoute/schema.test.ts +++ b/web/packages/studio/src/routes/AnonymizerBuilderRoute/schema.test.ts @@ -1,18 +1,27 @@ // SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 +import { + DETECTION_ROLES, + REPLACE_ROLE, + REWRITE_ROLES, + ROLE_LABELS, +} from '@studio/routes/AnonymizerBuilderRoute/constants'; import { AnonymizerFormData, buildAnonymizerJobRequest, getAnonymizerFormDefaults, } from '@studio/routes/AnonymizerBuilderRoute/schema'; +const ALL_ROLES = [...DETECTION_ROLES, REPLACE_ROLE, ...REWRITE_ROLES]; + +const roleModels = (model: string, provider: string) => + Object.fromEntries(ALL_ROLES.map((role) => [role, { modelId: role, model, provider }])); + const form = (overrides: Partial = {}): AnonymizerFormData => ({ ...getAnonymizerFormDefaults(), source: 'https://example.com/data.csv', - modelId: 'default/gpt', - model: 'openai/gpt-oss-120b', - provider: 'default/nvidia', + roleModels: roleModels('openai/gpt-oss-120b', 'default/nvidia'), ...overrides, }); @@ -38,40 +47,51 @@ describe('buildAnonymizerJobRequest', () => { expect(req.spec.data.data_summary).toBeUndefined(); }); - it('passes through populated columns and a trimmed name', () => { - const req = buildAnonymizerJobRequest( - form({ name: ' job-1 ', textColumn: 'biography', dataSummary: 'profiles' }) - ); - expect(req.name).toBe('job-1'); - expect(req.spec.data.text_column).toBe('biography'); - expect(req.spec.data.data_summary).toBe('profiles'); - }); - - it('emits a single model_config from the selected model and provider', () => { - const req = buildAnonymizerJobRequest(form()); + it('deduplicates identical role models into a single model_config', () => { + const req = buildAnonymizerJobRequest(form({ strategy: 'substitute' })); expect(req.spec.model_configs).toEqual([ - { alias: 'anonymizer-model', model: 'openai/gpt-oss-120b', provider: 'default/nvidia' }, + { alias: 'model-1', model: 'openai/gpt-oss-120b', provider: 'default/nvidia' }, ]); }); + it('emits one model_config per unique model+provider', () => { + const models = roleModels('openai/gpt-oss-120b', 'default/nvidia'); + models[DETECTION_ROLES[0]] = { + modelId: 'gliner', + model: 'nvidia/gliner-pii', + provider: 'default/nvidia', + }; + const req = buildAnonymizerJobRequest(form({ strategy: 'substitute', roleModels: models })); + expect(req.spec.model_configs).toHaveLength(2); + expect(req.spec.selected_models?.detection?.[DETECTION_ROLES[0]]).not.toBe( + req.spec.selected_models?.replace?.[REPLACE_ROLE] + ); + }); + it('maps detection + replace roles for substitute, detection + rewrite for rewrite', () => { const sub = buildAnonymizerJobRequest(form({ strategy: 'substitute' })).spec.selected_models; - expect(sub?.detection?.entity_detector).toBe('anonymizer-model'); - expect(sub?.replace?.replacement_generator).toBe('anonymizer-model'); + expect(sub?.detection?.entity_detector).toBe('model-1'); + expect(sub?.replace?.replacement_generator).toBe('model-1'); expect(sub?.rewrite).toBeUndefined(); const rew = buildAnonymizerJobRequest(form({ strategy: 'rewrite' })).spec.selected_models; - expect(rew?.detection?.entity_detector).toBe('anonymizer-model'); - expect(rew?.rewrite?.rewriter).toBe('anonymizer-model'); + expect(rew?.detection?.entity_detector).toBe('model-1'); + expect(rew?.rewrite?.rewriter).toBe('model-1'); expect(rew?.replace).toBeUndefined(); }); it('maps only detection roles for redact/annotate/hash', () => { for (const strategy of ['redact', 'annotate', 'hash'] as const) { const selected = buildAnonymizerJobRequest(form({ strategy })).spec.selected_models; - expect(selected?.detection?.entity_detector).toBe('anonymizer-model'); + expect(selected?.detection?.entity_detector).toBe('model-1'); expect(selected?.replace).toBeUndefined(); expect(selected?.rewrite).toBeUndefined(); } }); + + it('exposes a label for every configurable role', () => { + for (const role of ALL_ROLES) { + expect(ROLE_LABELS[role]).toBeTruthy(); + } + }); }); diff --git a/web/packages/studio/src/routes/AnonymizerBuilderRoute/schema.ts b/web/packages/studio/src/routes/AnonymizerBuilderRoute/schema.ts index f7587d6a5a..2a48192b9e 100644 --- a/web/packages/studio/src/routes/AnonymizerBuilderRoute/schema.ts +++ b/web/packages/studio/src/routes/AnonymizerBuilderRoute/schema.ts @@ -9,10 +9,10 @@ import type { SelectedModelsOverrides, } from '@nemo/sdk/generated/anonymizer/schema'; import { + activeRolesForStrategy, DETECTION_ROLES, DEFAULT_PREVIEW_ROWS, ENTITY_MODE_CUSTOM, - MODEL_ALIAS, REPLACE_ROLE, REWRITE_ROLES, REWRITE_STRATEGY, @@ -21,21 +21,38 @@ import { } from '@studio/routes/AnonymizerBuilderRoute/constants'; import { z } from 'zod'; -export const anonymizerFormSchema = z.object({ - name: z.string().optional(), - sourceType: z.enum(['url', 'dataset']), - source: z.string().min(1, 'A data source is required'), - strategy: z.enum(['substitute', 'redact', 'annotate', 'hash', 'rewrite']), - previewRows: z.number().int().min(1), - textColumn: z.string().optional(), - dataSummary: z.string().optional(), - entityMode: z.enum([ENTITY_MODE_CUSTOM, 'auto']), - includeDefaultEntities: z.boolean(), - modelId: z.string().min(1, 'Select a model in the Model Settings tab'), - model: z.string().optional(), - provider: z.string().optional(), +const roleModelSchema = z.object({ + modelId: z.string(), + model: z.string(), + provider: z.string(), }); +export const anonymizerFormSchema = z + .object({ + name: z.string().optional(), + sourceType: z.enum(['url', 'dataset']), + source: z.string().min(1, 'A data source is required'), + strategy: z.enum(['substitute', 'redact', 'annotate', 'hash', 'rewrite']), + previewRows: z.number().int().min(1), + textColumn: z.string().optional(), + dataSummary: z.string().optional(), + entityMode: z.enum([ENTITY_MODE_CUSTOM, 'auto']), + includeDefaultEntities: z.boolean(), + roleModels: z.record(z.string(), roleModelSchema), + }) + .superRefine((data, ctx) => { + for (const role of activeRolesForStrategy(data.strategy)) { + const roleModel = data.roleModels[role]; + if (!roleModel?.model || !roleModel?.provider) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ['roleModels', role, 'modelId'], + message: 'Select a model', + }); + } + } + }); + export type AnonymizerFormData = z.infer; export const getAnonymizerFormDefaults = (): AnonymizerFormData => ({ @@ -48,30 +65,9 @@ export const getAnonymizerFormDefaults = (): AnonymizerFormData => ({ dataSummary: '', entityMode: ENTITY_MODE_CUSTOM, includeDefaultEntities: true, - modelId: '', - model: '', - provider: '', + roleModels: {}, }); -/** - * Map every role of the strategy's active workflow(s) to the single selected - * model alias, so the merged library defaults don't reference aliases missing - * from `model_configs`. Detection runs for all strategies; substitute adds the - * replace role; rewrite adds the rewrite roles. - */ -const buildSelectedModels = (strategy: AnonymizerFormData['strategy']): SelectedModelsOverrides => { - const toRoleMap = (roles: string[]) => - Object.fromEntries(roles.map((role) => [role, MODEL_ALIAS])); - - const selected: SelectedModelsOverrides = { detection: toRoleMap(DETECTION_ROLES) }; - if (strategy === REWRITE_STRATEGY) { - selected.rewrite = toRoleMap(REWRITE_ROLES); - } else if (strategy === STRATEGY_SUBSTITUTE) { - selected.replace = { [REPLACE_ROLE]: MODEL_ALIAS }; - } - return selected; -}; - const trimToUndefined = (value: string | undefined): string | undefined => { const trimmed = value?.trim(); return trimmed ? trimmed : undefined; @@ -81,8 +77,11 @@ const trimToUndefined = (value: string | undefined): string | undefined => { * Build the create-job request. The strategy is applied via `config.rewrite` * for Rewrite and `config.replace` for the other four. `replace` is a * `kind`-discriminated union server-side, so the tag must be sent even though - * it isn't a modelled field on the SDK types. Strategy-specific parameters - * (templates, hash length, risk tolerance, …) are added in a follow-up. + * it isn't a modelled field on the SDK types. + * + * Each active role's model is deduplicated into a `model_configs` pool (one + * entry per unique model+provider) and `selected_models` maps every role of + * the strategy's workflow(s) to the matching alias. */ export const buildAnonymizerJobRequest = (form: AnonymizerFormData): RunJobRequest => { const config: AnonymizerConfigInput = @@ -90,9 +89,32 @@ export const buildAnonymizerJobRequest = (form: AnonymizerFormData): RunJobReque ? { rewrite: {} } : { replace: { kind: form.strategy } as AnonymizerConfigInput['replace'] }; - const modelConfigs: ModelConfig[] = [ - { alias: MODEL_ALIAS, model: form.model?.trim() ?? '', provider: form.provider?.trim() ?? '' }, - ]; + const aliasByModel = new Map(); + const modelConfigs: ModelConfig[] = []; + const aliasForRole: Record = {}; + + for (const role of activeRolesForStrategy(form.strategy)) { + const model = form.roleModels[role]?.model.trim() ?? ''; + const provider = form.roleModels[role]?.provider.trim() ?? ''; + const key = `${provider}::${model}`; + let alias = aliasByModel.get(key); + if (!alias) { + alias = `model-${aliasByModel.size + 1}`; + aliasByModel.set(key, alias); + modelConfigs.push({ alias, model, provider }); + } + aliasForRole[role] = alias; + } + + const toRoleMap = (roles: string[]) => + Object.fromEntries(roles.map((role) => [role, aliasForRole[role]])); + + const selectedModels: SelectedModelsOverrides = { detection: toRoleMap(DETECTION_ROLES) }; + if (form.strategy === REWRITE_STRATEGY) { + selectedModels.rewrite = toRoleMap(REWRITE_ROLES); + } else if (form.strategy === STRATEGY_SUBSTITUTE) { + selectedModels.replace = { [REPLACE_ROLE]: aliasForRole[REPLACE_ROLE] }; + } return { name: trimToUndefined(form.name), @@ -104,7 +126,7 @@ export const buildAnonymizerJobRequest = (form: AnonymizerFormData): RunJobReque data_summary: trimToUndefined(form.dataSummary), }, model_configs: modelConfigs, - selected_models: buildSelectedModels(form.strategy), + selected_models: selectedModels, }, }; }; From 41130eef87f91f0a71de5e05cc8e4573623f6e37 Mon Sep 17 00:00:00 2001 From: mschwab Date: Fri, 24 Jul 2026 09:15:37 -0700 Subject: [PATCH 04/24] feat(studio): use searchable model dropdown in Anonymizer model settings [ASTD-327] Swap the per-role model picker to ControlledSearchableSelect (the same component as the Clone Agent modal) for a searchable, grouped dropdown. Keep provider-aware options from modelsFromProviders rather than buildSuggestedModelOptions, since the latter filters out the GLiNER detector model and drops the provider that model_configs requires. Signed-off-by: mschwab --- .../components/ModelSettingsSection.tsx | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/web/packages/studio/src/routes/AnonymizerBuilderRoute/components/ModelSettingsSection.tsx b/web/packages/studio/src/routes/AnonymizerBuilderRoute/components/ModelSettingsSection.tsx index ff51cb3177..fa9ab28fb0 100644 --- a/web/packages/studio/src/routes/AnonymizerBuilderRoute/components/ModelSettingsSection.tsx +++ b/web/packages/studio/src/routes/AnonymizerBuilderRoute/components/ModelSettingsSection.tsx @@ -1,7 +1,7 @@ // 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 { ControlledSearchableSelect } from '@nemo/common/src/components/form/ControlledSearchableSelect'; import { useModelsListProviders } from '@nemo/sdk/generated/platform/api'; import { Divider, Stack, Text } from '@nvidia/foundations-react-core'; import { modelsFromProviders } from '@studio/components/NewDataDesignerJobForm/utils'; @@ -75,12 +75,14 @@ export const ModelSettingsSection: FC = () => { {index > 0 && } {ROLE_LABELS[role] ?? role} - applyModel(role, value as string)} + 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` as Path, control, From 1567260336abb0cdc2f62238dc4277a602aa4e84 Mon Sep 17 00:00:00 2001 From: mschwab Date: Fri, 24 Jul 2026 09:26:02 -0700 Subject: [PATCH 05/24] feat(studio): per-role model params for Anonymizer builder [ASTD-327] Add a Params dropdown next to each role's model select (reusing ModelSelectV2 ParamsDropdown), storing inference parameters per role. Build attaches inference_parameters to the model_config and splits the dedup so a shared model with different params yields distinct configs. Signed-off-by: mschwab --- .../components/ModelSettingsSection.tsx | 61 ++++++++++++------- .../AnonymizerBuilderRoute/schema.test.ts | 15 +++++ .../routes/AnonymizerBuilderRoute/schema.ts | 19 ++++-- 3 files changed, 70 insertions(+), 25 deletions(-) diff --git a/web/packages/studio/src/routes/AnonymizerBuilderRoute/components/ModelSettingsSection.tsx b/web/packages/studio/src/routes/AnonymizerBuilderRoute/components/ModelSettingsSection.tsx index fa9ab28fb0..a00a01d3ee 100644 --- a/web/packages/studio/src/routes/AnonymizerBuilderRoute/components/ModelSettingsSection.tsx +++ b/web/packages/studio/src/routes/AnonymizerBuilderRoute/components/ModelSettingsSection.tsx @@ -2,8 +2,10 @@ // 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 { Divider, Stack, Text } from '@nvidia/foundations-react-core'; +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'; @@ -13,8 +15,8 @@ import { ROLE_LABELS, } from '@studio/routes/AnonymizerBuilderRoute/constants'; import type { AnonymizerFormData } from '@studio/routes/AnonymizerBuilderRoute/schema'; -import { FC, useEffect, useMemo } from 'react'; -import { type Path, useFormContext, useWatch } from 'react-hook-form'; +import { FC, useEffect, useMemo, useState } from 'react'; +import { useFormContext, useWatch } from 'react-hook-form'; const isGliner = (name: string) => /gliner/i.test(name); @@ -22,6 +24,8 @@ export const ModelSettingsSection: FC = () => { const { control, setValue, getValues } = useFormContext(); const workspace = useWorkspaceFromPath(); const strategy = useWatch({ control, name: 'strategy' }); + const roleModelsValue = useWatch({ control, name: 'roleModels' }); + const [openParamsRole, setOpenParamsRole] = useState(null); const roles = useMemo(() => activeRolesForStrategy(strategy), [strategy]); @@ -43,12 +47,12 @@ export const ModelSettingsSection: FC = () => { const applyModel = (role: string, id: string) => { const selected = models.find((model) => model.id === id); setValue( - `roleModels.${role}.model` as Path, + `roleModels.${role}.model`, selected?.served_model_name ?? '', { shouldValidate: true } ); setValue( - `roleModels.${role}.provider` as Path, + `roleModels.${role}.provider`, selected?.model_providers?.[0] ?? '', { shouldValidate: true } ); @@ -60,10 +64,10 @@ export const ModelSettingsSection: FC = () => { const gliner = models.find((model) => isGliner(model.name)) ?? models[0]; const llm = models.find((model) => !isGliner(model.name)) ?? models[0]; for (const role of roles) { - const current = getValues(`roleModels.${role}.modelId` as Path); + const current = getValues(`roleModels.${role}.modelId`); if (current) continue; const pick = role === GLINER_ROLE ? gliner : llm; - setValue(`roleModels.${role}.modelId` as Path, pick.id); + setValue(`roleModels.${role}.modelId`, pick.id); applyModel(role, pick.id); } // eslint-disable-next-line react-hooks/exhaustive-deps @@ -75,20 +79,35 @@ export const ModelSettingsSection: FC = () => { {index > 0 && } {ROLE_LABELS[role] ?? role} - applyModel(role, value)} - useControllerProps={{ - name: `roleModels.${role}.modelId` as Path, - control, - }} - formFieldProps={{ slotLabel: 'Model', required: true }} - /> + +
+ applyModel(role, value)} + useControllerProps={{ + name: `roleModels.${role}.modelId`, + control, + }} + formFieldProps={{ slotLabel: 'Model', required: true }} + /> +
+ setOpenParamsRole(next ? role : null)} + inferenceParams={roleModelsValue?.[role]?.params as Partial} + onInferenceParamsChange={(params) => + setValue( + `roleModels.${role}.params`, + params as Record + ) + } + /> +
))}
diff --git a/web/packages/studio/src/routes/AnonymizerBuilderRoute/schema.test.ts b/web/packages/studio/src/routes/AnonymizerBuilderRoute/schema.test.ts index c2e7faae4c..e5c7653853 100644 --- a/web/packages/studio/src/routes/AnonymizerBuilderRoute/schema.test.ts +++ b/web/packages/studio/src/routes/AnonymizerBuilderRoute/schema.test.ts @@ -94,4 +94,19 @@ describe('buildAnonymizerJobRequest', () => { expect(ROLE_LABELS[role]).toBeTruthy(); } }); + + it('attaches inference_parameters and splits configs when params differ', () => { + const models = roleModels('openai/gpt-oss-120b', 'default/nvidia'); + models[DETECTION_ROLES[0]] = { + modelId: 'gpt', + model: 'openai/gpt-oss-120b', + provider: 'default/nvidia', + params: { temperature: 0.1 }, + }; + const req = buildAnonymizerJobRequest(form({ strategy: 'substitute', roleModels: models })); + // Same model+provider but one role has params → two distinct configs. + expect(req.spec.model_configs).toHaveLength(2); + const withParams = req.spec.model_configs?.find((c) => c.inference_parameters); + expect(withParams?.inference_parameters).toEqual({ temperature: 0.1 }); + }); }); diff --git a/web/packages/studio/src/routes/AnonymizerBuilderRoute/schema.ts b/web/packages/studio/src/routes/AnonymizerBuilderRoute/schema.ts index 2a48192b9e..8077bc03ea 100644 --- a/web/packages/studio/src/routes/AnonymizerBuilderRoute/schema.ts +++ b/web/packages/studio/src/routes/AnonymizerBuilderRoute/schema.ts @@ -25,6 +25,7 @@ const roleModelSchema = z.object({ modelId: z.string(), model: z.string(), provider: z.string(), + params: z.record(z.string(), z.unknown()).optional(), }); export const anonymizerFormSchema = z @@ -94,14 +95,24 @@ export const buildAnonymizerJobRequest = (form: AnonymizerFormData): RunJobReque const aliasForRole: Record = {}; for (const role of activeRolesForStrategy(form.strategy)) { - const model = form.roleModels[role]?.model.trim() ?? ''; - const provider = form.roleModels[role]?.provider.trim() ?? ''; - const key = `${provider}::${model}`; + const roleModel = form.roleModels[role]; + const model = roleModel?.model.trim() ?? ''; + const provider = roleModel?.provider.trim() ?? ''; + const params = roleModel?.params; + const hasParams = params != null && Object.keys(params).length > 0; + const key = `${provider}::${model}::${hasParams ? JSON.stringify(params) : ''}`; let alias = aliasByModel.get(key); if (!alias) { alias = `model-${aliasByModel.size + 1}`; aliasByModel.set(key, alias); - modelConfigs.push({ alias, model, provider }); + modelConfigs.push({ + alias, + model, + provider, + ...(hasParams + ? { inference_parameters: params as ModelConfig['inference_parameters'] } + : {}), + }); } aliasForRole[role] = alias; } From 5cf230bb207335d4c9bd2bbea69f379da4bc28a2 Mon Sep 17 00:00:00 2001 From: mschwab Date: Fri, 24 Jul 2026 09:50:40 -0700 Subject: [PATCH 06/24] fix(studio): seed Anonymizer model defaults regardless of active tab [ASTD-327] MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Model Settings only mounted when its tab was open, so its default-model seeding never ran if the user submitted from the Source tab — surfacing a spurious 'Select a model' error. Keep both tab panels mounted (hide the inactive one) so seeding runs on load. Signed-off-by: mschwab --- .../studio/src/routes/AnonymizerBuilderRoute/index.tsx | 9 ++++++--- .../src/routes/AnonymizerBuilderRoute/schema.test.ts | 2 +- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/web/packages/studio/src/routes/AnonymizerBuilderRoute/index.tsx b/web/packages/studio/src/routes/AnonymizerBuilderRoute/index.tsx index 14cb597f4c..086999c2ca 100644 --- a/web/packages/studio/src/routes/AnonymizerBuilderRoute/index.tsx +++ b/web/packages/studio/src/routes/AnonymizerBuilderRoute/index.tsx @@ -132,7 +132,9 @@ export const AnonymizerBuilderRoute: FC | null = ANONYMIZER_ENABLED )} - {activeTab === TAB_SOURCE ? ( + {/* Both panels stay mounted so Model Settings can seed its + defaults even before the tab is opened. */} +
@@ -142,9 +144,10 @@ export const AnonymizerBuilderRoute: FC | null = ANONYMIZER_ENABLED - ) : ( +
+
- )} +
diff --git a/web/packages/studio/src/routes/AnonymizerBuilderRoute/schema.test.ts b/web/packages/studio/src/routes/AnonymizerBuilderRoute/schema.test.ts index e5c7653853..f3165f4f8c 100644 --- a/web/packages/studio/src/routes/AnonymizerBuilderRoute/schema.test.ts +++ b/web/packages/studio/src/routes/AnonymizerBuilderRoute/schema.test.ts @@ -15,7 +15,7 @@ import { const ALL_ROLES = [...DETECTION_ROLES, REPLACE_ROLE, ...REWRITE_ROLES]; -const roleModels = (model: string, provider: string) => +const roleModels = (model: string, provider: string): AnonymizerFormData['roleModels'] => Object.fromEntries(ALL_ROLES.map((role) => [role, { modelId: role, model, provider }])); const form = (overrides: Partial = {}): AnonymizerFormData => ({ From 8bab5614b6ac5d0f44bf2738a1ebbed22b410600 Mon Sep 17 00:00:00 2001 From: mschwab Date: Fri, 24 Jul 2026 09:52:59 -0700 Subject: [PATCH 07/24] feat(studio): disable Anonymizer submit while models load [ASTD-327] Gate the Full Run button on the providers query loading state (shared with ModelSettingsSection via react-query dedup) so the user can't submit before role model defaults are seeded. Signed-off-by: mschwab --- .../src/routes/AnonymizerBuilderRoute/index.tsx | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/web/packages/studio/src/routes/AnonymizerBuilderRoute/index.tsx b/web/packages/studio/src/routes/AnonymizerBuilderRoute/index.tsx index 086999c2ca..fc0e109a1f 100644 --- a/web/packages/studio/src/routes/AnonymizerBuilderRoute/index.tsx +++ b/web/packages/studio/src/routes/AnonymizerBuilderRoute/index.tsx @@ -4,6 +4,7 @@ import { zodResolver } from '@hookform/resolvers/zod'; import { useAnonymizerCreateRunJob } from '@nemo/sdk/generated/anonymizer/api'; import type { RunJob } from '@nemo/sdk/generated/anonymizer/schema'; +import { useModelsListProviders } from '@nemo/sdk/generated/platform/api'; import { Banner, Button, @@ -18,6 +19,7 @@ import { } from '@nvidia/foundations-react-core'; import { getErrorMessage } from '@studio/api/common/utils'; import { AccessibleTitle } from '@studio/components/AccessibleTitle'; +import { DEFAULT_LARGE_PAGE_SIZE } from '@studio/constants/constants'; import { ANONYMIZER_ENABLED } from '@studio/constants/environment'; import { useWorkspaceFromPath } from '@studio/hooks/useWorkspaceFromPath'; import { useBreadcrumbs } from '@studio/providers/breadcrumbs/useBreadcrumbs'; @@ -46,6 +48,14 @@ export const AnonymizerBuilderRoute: FC | null = ANONYMIZER_ENABLED const [activeTab, setActiveTab] = useState(TAB_SOURCE); const [submitError, setSubmitError] = useState(undefined); + // Shared with ModelSettingsSection (react-query dedups) — gate submit until + // the model list has loaded and its role defaults have been seeded. + const { isLoading: isLoadingModels } = useModelsListProviders( + workspace, + { page_size: DEFAULT_LARGE_PAGE_SIZE }, + { query: {} } + ); + useBreadcrumbs({ items: [{ slotLabel: 'Anonymizer' }, { slotLabel: 'Anonymize Data' }], }); @@ -104,7 +114,7 @@ export const AnonymizerBuilderRoute: FC | null = ANONYMIZER_ENABLED kind="primary" color="brand" type="submit" - disabled={createJob.isPending} + disabled={createJob.isPending || isLoadingModels} > Full Run From 6b93efa5dfe1f95203504393189ae3ecb8b01f4d Mon Sep 17 00:00:00 2001 From: mschwab Date: Fri, 24 Jul 2026 10:15:58 -0700 Subject: [PATCH 08/24] fix(studio): surface Anonymizer validation errors on submit [ASTD-327] onInvalid only switched tabs for model errors, so a Source-tab error (e.g. missing data source) while on the Model Settings tab left the submit doing nothing visible. Always jump to the offending tab and show an inline banner. Signed-off-by: mschwab --- .../studio/src/routes/AnonymizerBuilderRoute/index.tsx | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/web/packages/studio/src/routes/AnonymizerBuilderRoute/index.tsx b/web/packages/studio/src/routes/AnonymizerBuilderRoute/index.tsx index fc0e109a1f..e1686ad53c 100644 --- a/web/packages/studio/src/routes/AnonymizerBuilderRoute/index.tsx +++ b/web/packages/studio/src/routes/AnonymizerBuilderRoute/index.tsx @@ -85,7 +85,10 @@ export const AnonymizerBuilderRoute: FC | null = ANONYMIZER_ENABLED createJob.mutate({ workspace, data: buildAnonymizerJobRequest(values) }); }, (errors) => { - if (errors.roleModels) setActiveTab(TAB_MODEL_SETTINGS); + // Jump to whichever tab holds the first error so it's never hidden. + const onlyModelErrors = Object.keys(errors).every((key) => key === 'roleModels'); + setActiveTab(onlyModelErrors ? TAB_MODEL_SETTINGS : TAB_SOURCE); + setSubmitError('Please complete the required fields highlighted below.'); } ); From 5178d4e9981db96c6e3d87beba40485f5d81094f Mon Sep 17 00:00:00 2001 From: mschwab Date: Fri, 24 Jul 2026 10:25:09 -0700 Subject: [PATCH 09/24] fix(studio): add noValidate to Anonymizer builder form [ASTD-327] The searchable model selects render a hidden required search input; native HTML5 validation tried to focus it on submit and blocked the form with 'invalid form control is not focusable'. Disable native validation and rely on the RHF/Zod resolver. Signed-off-by: mschwab --- web/packages/studio/src/routes/AnonymizerBuilderRoute/index.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web/packages/studio/src/routes/AnonymizerBuilderRoute/index.tsx b/web/packages/studio/src/routes/AnonymizerBuilderRoute/index.tsx index e1686ad53c..7bf9052a80 100644 --- a/web/packages/studio/src/routes/AnonymizerBuilderRoute/index.tsx +++ b/web/packages/studio/src/routes/AnonymizerBuilderRoute/index.tsx @@ -97,7 +97,7 @@ export const AnonymizerBuilderRoute: FC | null = ANONYMIZER_ENABLED return ( -
+ Date: Fri, 24 Jul 2026 11:36:55 -0700 Subject: [PATCH 10/24] feat(studio): lock Anonymizer strategy to Substitute for now [ASTD-327] Disable the Anonymization Strategy select until the other strategies' parameter UIs are built. Substitute is the default and only selectable option. Signed-off-by: mschwab --- .../components/GenerationSection.tsx | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/web/packages/studio/src/routes/AnonymizerBuilderRoute/components/GenerationSection.tsx b/web/packages/studio/src/routes/AnonymizerBuilderRoute/components/GenerationSection.tsx index c0e3de8a22..6dbf0ec8a2 100644 --- a/web/packages/studio/src/routes/AnonymizerBuilderRoute/components/GenerationSection.tsx +++ b/web/packages/studio/src/routes/AnonymizerBuilderRoute/components/GenerationSection.tsx @@ -21,9 +21,14 @@ export const GenerationSection: FC = () => { Generation {STRATEGY_DESCRIPTIONS[strategy]} Date: Fri, 24 Jul 2026 11:52:38 -0700 Subject: [PATCH 11/24] feat(studio): lean entity-label picker for Anonymizer builder [ASTD-327] Custom mode (without 'include all defaults') shows a multi-select of the default entity labels from useAnonymizerListEntityLabels; the selection restricts detection via config.detect.entity_labels. Auto-detect and include-defaults leave detect unset (library default set). Categorized grouping, custom-typed labels, and chip coloring are a follow-up. Signed-off-by: mschwab --- .../components/EntitiesSection.tsx | 34 ++++++++++++++++--- .../AnonymizerBuilderRoute/schema.test.ts | 15 ++++++++ .../routes/AnonymizerBuilderRoute/schema.ts | 12 +++++++ 3 files changed, 57 insertions(+), 4 deletions(-) diff --git a/web/packages/studio/src/routes/AnonymizerBuilderRoute/components/EntitiesSection.tsx b/web/packages/studio/src/routes/AnonymizerBuilderRoute/components/EntitiesSection.tsx index da01b1d18d..e700ce7cc0 100644 --- a/web/packages/studio/src/routes/AnonymizerBuilderRoute/components/EntitiesSection.tsx +++ b/web/packages/studio/src/routes/AnonymizerBuilderRoute/components/EntitiesSection.tsx @@ -2,10 +2,14 @@ // 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'; @@ -14,7 +18,15 @@ import { useFormContext, useWatch } from 'react-hook-form'; export const EntitiesSection: FC = () => { const { control } = useFormContext(); + 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 ( @@ -30,10 +42,24 @@ export const EntitiesSection: FC = () => { ? '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.'} - + {isCustom && ( + + )} + {showLabelPicker && ( + + )} ); }; diff --git a/web/packages/studio/src/routes/AnonymizerBuilderRoute/schema.test.ts b/web/packages/studio/src/routes/AnonymizerBuilderRoute/schema.test.ts index f3165f4f8c..866bcf35c6 100644 --- a/web/packages/studio/src/routes/AnonymizerBuilderRoute/schema.test.ts +++ b/web/packages/studio/src/routes/AnonymizerBuilderRoute/schema.test.ts @@ -95,6 +95,21 @@ describe('buildAnonymizerJobRequest', () => { } }); + it('sets config.detect.entity_labels only for custom labels without defaults', () => { + const custom = buildAnonymizerJobRequest( + form({ entityMode: 'custom', includeDefaultEntities: false, entityLabels: ['email', 'ssn'] }) + ); + expect(custom.spec.config.detect).toEqual({ entity_labels: ['email', 'ssn'] }); + + const withDefaults = buildAnonymizerJobRequest( + form({ entityMode: 'custom', includeDefaultEntities: true, entityLabels: ['email'] }) + ); + expect(withDefaults.spec.config.detect).toBeUndefined(); + + const auto = buildAnonymizerJobRequest(form({ entityMode: 'auto', entityLabels: ['email'] })); + expect(auto.spec.config.detect).toBeUndefined(); + }); + it('attaches inference_parameters and splits configs when params differ', () => { const models = roleModels('openai/gpt-oss-120b', 'default/nvidia'); models[DETECTION_ROLES[0]] = { diff --git a/web/packages/studio/src/routes/AnonymizerBuilderRoute/schema.ts b/web/packages/studio/src/routes/AnonymizerBuilderRoute/schema.ts index 8077bc03ea..be5c007cd3 100644 --- a/web/packages/studio/src/routes/AnonymizerBuilderRoute/schema.ts +++ b/web/packages/studio/src/routes/AnonymizerBuilderRoute/schema.ts @@ -39,6 +39,7 @@ export const anonymizerFormSchema = z dataSummary: z.string().optional(), entityMode: z.enum([ENTITY_MODE_CUSTOM, 'auto']), includeDefaultEntities: z.boolean(), + entityLabels: z.array(z.string()), roleModels: z.record(z.string(), roleModelSchema), }) .superRefine((data, ctx) => { @@ -66,6 +67,7 @@ export const getAnonymizerFormDefaults = (): AnonymizerFormData => ({ dataSummary: '', entityMode: ENTITY_MODE_CUSTOM, includeDefaultEntities: true, + entityLabels: [], roleModels: {}, }); @@ -90,6 +92,16 @@ export const buildAnonymizerJobRequest = (form: AnonymizerFormData): RunJobReque ? { rewrite: {} } : { replace: { kind: form.strategy } as AnonymizerConfigInput['replace'] }; + // Custom mode with a specific label set restricts detection; otherwise the + // library default set is used (detect omitted). + const useCustomLabels = + form.entityMode === ENTITY_MODE_CUSTOM && + !form.includeDefaultEntities && + form.entityLabels.length > 0; + if (useCustomLabels) { + config.detect = { entity_labels: form.entityLabels }; + } + const aliasByModel = new Map(); const modelConfigs: ModelConfig[] = []; const aliasForRole: Record = {}; From 96986f7fd3b088eb0f33a8aa4ca5e1f67dc631e7 Mon Sep 17 00:00:00 2001 From: mschwab Date: Fri, 24 Jul 2026 11:54:33 -0700 Subject: [PATCH 12/24] feat(studio): use SegmentedControl for Anonymizer builder tabs [ASTD-327] Swap the Source / Model Settings tab bar from Tabs to SegmentedControl. Signed-off-by: mschwab --- .../routes/AnonymizerBuilderRoute/index.tsx | 26 +++++++------------ 1 file changed, 10 insertions(+), 16 deletions(-) diff --git a/web/packages/studio/src/routes/AnonymizerBuilderRoute/index.tsx b/web/packages/studio/src/routes/AnonymizerBuilderRoute/index.tsx index 7bf9052a80..84bfa81ce8 100644 --- a/web/packages/studio/src/routes/AnonymizerBuilderRoute/index.tsx +++ b/web/packages/studio/src/routes/AnonymizerBuilderRoute/index.tsx @@ -11,10 +11,8 @@ import { Divider, Flex, Panel, + SegmentedControl, Stack, - TabsList, - TabsRoot, - TabsTrigger, Text, } from '@nvidia/foundations-react-core'; import { getErrorMessage } from '@studio/api/common/utils'; @@ -125,19 +123,15 @@ export const AnonymizerBuilderRoute: FC | null = ANONYMIZER_ENABLED } > - - - setActiveTab(TAB_SOURCE)}> - Source - - setActiveTab(TAB_MODEL_SETTINGS)} - > - Model Settings - - - + {submitError && ( From 2a6c6b1b86d0a7f12b7970415acabe51fbc23450 Mon Sep 17 00:00:00 2001 From: mschwab Date: Fri, 24 Jul 2026 12:07:14 -0700 Subject: [PATCH 13/24] feat(studio): introspect dataset columns for Text Column select [ASTD-327] When a Dataset source file is selected, read its columns (via the size-capped useDatasetFileContent + getContentColumns) and render Text Column as a dropdown of those columns. Falls back to a text input for URL sources or when the file exceeds MAX_COLUMN_INTROSPECTION_BYTES (50MB). Signed-off-by: mschwab --- .../components/ColumnsSection.tsx | 86 ++++++++++++++++--- .../AnonymizerBuilderRoute/constants.ts | 3 + 2 files changed, 79 insertions(+), 10 deletions(-) diff --git a/web/packages/studio/src/routes/AnonymizerBuilderRoute/components/ColumnsSection.tsx b/web/packages/studio/src/routes/AnonymizerBuilderRoute/components/ColumnsSection.tsx index 0bcde4e8d7..fe38fb33d4 100644 --- a/web/packages/studio/src/routes/AnonymizerBuilderRoute/components/ColumnsSection.tsx +++ b/web/packages/studio/src/routes/AnonymizerBuilderRoute/components/ColumnsSection.tsx @@ -1,27 +1,93 @@ // 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 { FC } from 'react'; -import { useFormContext } from 'react-hook-form'; +import { getContentColumns, getFileExtension } from '@studio/util/files'; +import { FC, useMemo } from 'react'; +import { useFormContext, useWatch } from 'react-hook-form'; export const ColumnsSection: FC = () => { const { control } = useFormContext(); + const workspace = useWorkspaceFromPath(); + const source = useWatch({ control, name: 'source' }); + const sourceType = useWatch({ control, name: 'sourceType' }); + + 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 useColumnDropdown = canIntrospect && columns.length > 0; return ( Columns - + {useColumnDropdown ? ( + ({ label: column, value: column }))} + useControllerProps={{ name: 'textColumn', control }} + formFieldProps={{ + slotLabel: 'Text Column', + slotInfo: 'The column containing the text to anonymize.', + }} + /> + ) : ( + + )} Date: Fri, 24 Jul 2026 12:12:09 -0700 Subject: [PATCH 14/24] feat(studio): seed a suggested default model, not the first one [ASTD-327] The role-model seed fell back to models[0], which could be a flaky model (e.g. 01-ai/yi-large failing health checks). Use pickDefaultModelName to prefer a suggested chat model for the LLM roles. Signed-off-by: mschwab --- .../components/ModelSettingsSection.tsx | 35 ++++++++++--------- 1 file changed, 18 insertions(+), 17 deletions(-) diff --git a/web/packages/studio/src/routes/AnonymizerBuilderRoute/components/ModelSettingsSection.tsx b/web/packages/studio/src/routes/AnonymizerBuilderRoute/components/ModelSettingsSection.tsx index a00a01d3ee..52ad0e7947 100644 --- a/web/packages/studio/src/routes/AnonymizerBuilderRoute/components/ModelSettingsSection.tsx +++ b/web/packages/studio/src/routes/AnonymizerBuilderRoute/components/ModelSettingsSection.tsx @@ -15,6 +15,7 @@ import { 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'; @@ -46,23 +47,26 @@ export const ModelSettingsSection: FC = () => { 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 } - ); + setValue(`roleModels.${role}.model`, selected?.served_model_name ?? '', { + shouldValidate: true, + }); + setValue(`roleModels.${role}.provider`, selected?.model_providers?.[0] ?? '', { + shouldValidate: true, + }); }; - // Seed sensible defaults once models load: GLiNER for the detector, an LLM for the rest. + // Seed sensible defaults once models load: GLiNER for the detector, a + // suggested chat model for the rest (avoids defaulting to a flaky first model). useEffect(() => { if (!models.length) return; - const gliner = models.find((model) => isGliner(model.name)) ?? models[0]; - const llm = models.find((model) => !isGliner(model.name)) ?? models[0]; + 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; @@ -101,10 +105,7 @@ export const ModelSettingsSection: FC = () => { onOpenChange={(next) => setOpenParamsRole(next ? role : null)} inferenceParams={roleModelsValue?.[role]?.params as Partial} onInferenceParamsChange={(params) => - setValue( - `roleModels.${role}.params`, - params as Record - ) + setValue(`roleModels.${role}.params`, params as Record) } /> From 1a97b69523e6cae89ec7ff89e07991c8b104da64 Mon Sep 17 00:00:00 2001 From: mschwab Date: Fri, 24 Jul 2026 12:22:12 -0700 Subject: [PATCH 15/24] feat(studio): auto-select the only column in Text Column dropdown [ASTD-327] Signed-off-by: mschwab --- .../components/ColumnsSection.tsx | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/web/packages/studio/src/routes/AnonymizerBuilderRoute/components/ColumnsSection.tsx b/web/packages/studio/src/routes/AnonymizerBuilderRoute/components/ColumnsSection.tsx index fe38fb33d4..01b4294582 100644 --- a/web/packages/studio/src/routes/AnonymizerBuilderRoute/components/ColumnsSection.tsx +++ b/web/packages/studio/src/routes/AnonymizerBuilderRoute/components/ColumnsSection.tsx @@ -15,14 +15,15 @@ import { } from '@studio/routes/AnonymizerBuilderRoute/constants'; import type { AnonymizerFormData } from '@studio/routes/AnonymizerBuilderRoute/schema'; import { getContentColumns, getFileExtension } from '@studio/util/files'; -import { FC, useMemo } from 'react'; +import { FC, useEffect, useMemo } from 'react'; import { useFormContext, useWatch } from 'react-hook-form'; export const ColumnsSection: FC = () => { - const { control } = useFormContext(); + const { control, setValue } = useFormContext(); const workspace = useWorkspaceFromPath(); const source = useWatch({ control, name: 'source' }); const sourceType = useWatch({ control, name: 'sourceType' }); + const textColumn = useWatch({ control, name: 'textColumn' }); const parsed = useMemo( () => @@ -65,6 +66,13 @@ export const ColumnsSection: FC = () => { const useColumnDropdown = canIntrospect && columns.length > 0; + // Auto-select the only column when there's exactly one. + useEffect(() => { + if (useColumnDropdown && columns.length === 1 && textColumn !== columns[0]) { + setValue('textColumn', columns[0], { shouldValidate: true }); + } + }, [useColumnDropdown, columns, textColumn, setValue]); + return ( Columns From e7f956d489ee5be1b5eae50453555bfc3ac9d887 Mon Sep 17 00:00:00 2001 From: mschwab Date: Fri, 24 Jul 2026 12:40:10 -0700 Subject: [PATCH 16/24] fix(studio): default a generous model timeout for anonymizer jobs [ASTD-327] Reasoning models (e.g. nemotron-nano) exceeded the anonymizer's default per-request timeout during entity validation (ModelTimeoutError, rows dropped). Default inference_parameters.timeout to 300s on every model_config; user-supplied params still override. Signed-off-by: mschwab --- .../routes/AnonymizerBuilderRoute/constants.ts | 7 +++++++ .../routes/AnonymizerBuilderRoute/schema.test.ts | 15 +++++++++++---- .../src/routes/AnonymizerBuilderRoute/schema.ts | 11 +++++------ 3 files changed, 23 insertions(+), 10 deletions(-) diff --git a/web/packages/studio/src/routes/AnonymizerBuilderRoute/constants.ts b/web/packages/studio/src/routes/AnonymizerBuilderRoute/constants.ts index 9e04fd9660..be022fecd8 100644 --- a/web/packages/studio/src/routes/AnonymizerBuilderRoute/constants.ts +++ b/web/packages/studio/src/routes/AnonymizerBuilderRoute/constants.ts @@ -63,6 +63,13 @@ export const DEFAULT_PREVIEW_ROWS = 1; /** Above this file size, skip column introspection and fall back to a text input. */ export const MAX_COLUMN_INTROSPECTION_BYTES = 50 * 1024 * 1024; +/** + * Default per-request model timeout (seconds). Generous so slower reasoning + * models don't hit ModelTimeoutError during entity validation. User-supplied + * inference params override it. + */ +export const DEFAULT_MODEL_TIMEOUT_SECONDS = 300; + /** Role names the anonymizer workflows resolve against `model_configs` aliases. */ export const DETECTION_ROLES = [ 'entity_detector', diff --git a/web/packages/studio/src/routes/AnonymizerBuilderRoute/schema.test.ts b/web/packages/studio/src/routes/AnonymizerBuilderRoute/schema.test.ts index 866bcf35c6..423b01baa6 100644 --- a/web/packages/studio/src/routes/AnonymizerBuilderRoute/schema.test.ts +++ b/web/packages/studio/src/routes/AnonymizerBuilderRoute/schema.test.ts @@ -47,10 +47,15 @@ describe('buildAnonymizerJobRequest', () => { expect(req.spec.data.data_summary).toBeUndefined(); }); - it('deduplicates identical role models into a single model_config', () => { + it('deduplicates identical role models into a single model_config with a default timeout', () => { const req = buildAnonymizerJobRequest(form({ strategy: 'substitute' })); expect(req.spec.model_configs).toEqual([ - { alias: 'model-1', model: 'openai/gpt-oss-120b', provider: 'default/nvidia' }, + { + alias: 'model-1', + model: 'openai/gpt-oss-120b', + provider: 'default/nvidia', + inference_parameters: { timeout: 300 }, + }, ]); }); @@ -121,7 +126,9 @@ describe('buildAnonymizerJobRequest', () => { const req = buildAnonymizerJobRequest(form({ strategy: 'substitute', roleModels: models })); // Same model+provider but one role has params → two distinct configs. expect(req.spec.model_configs).toHaveLength(2); - const withParams = req.spec.model_configs?.find((c) => c.inference_parameters); - expect(withParams?.inference_parameters).toEqual({ temperature: 0.1 }); + const withTemp = req.spec.model_configs?.find( + (c) => (c.inference_parameters as { temperature?: number })?.temperature != null + ); + expect(withTemp?.inference_parameters).toEqual({ timeout: 300, temperature: 0.1 }); }); }); diff --git a/web/packages/studio/src/routes/AnonymizerBuilderRoute/schema.ts b/web/packages/studio/src/routes/AnonymizerBuilderRoute/schema.ts index be5c007cd3..21a2fae908 100644 --- a/web/packages/studio/src/routes/AnonymizerBuilderRoute/schema.ts +++ b/web/packages/studio/src/routes/AnonymizerBuilderRoute/schema.ts @@ -11,6 +11,7 @@ import type { import { activeRolesForStrategy, DETECTION_ROLES, + DEFAULT_MODEL_TIMEOUT_SECONDS, DEFAULT_PREVIEW_ROWS, ENTITY_MODE_CUSTOM, REPLACE_ROLE, @@ -110,9 +111,9 @@ export const buildAnonymizerJobRequest = (form: AnonymizerFormData): RunJobReque const roleModel = form.roleModels[role]; const model = roleModel?.model.trim() ?? ''; const provider = roleModel?.provider.trim() ?? ''; - const params = roleModel?.params; - const hasParams = params != null && Object.keys(params).length > 0; - const key = `${provider}::${model}::${hasParams ? JSON.stringify(params) : ''}`; + // Default a generous timeout; user-supplied params override it. + const params = { timeout: DEFAULT_MODEL_TIMEOUT_SECONDS, ...(roleModel?.params ?? {}) }; + const key = `${provider}::${model}::${JSON.stringify(params)}`; let alias = aliasByModel.get(key); if (!alias) { alias = `model-${aliasByModel.size + 1}`; @@ -121,9 +122,7 @@ export const buildAnonymizerJobRequest = (form: AnonymizerFormData): RunJobReque alias, model, provider, - ...(hasParams - ? { inference_parameters: params as ModelConfig['inference_parameters'] } - : {}), + inference_parameters: params as ModelConfig['inference_parameters'], }); } aliasForRole[role] = alias; From 2b92580b2cfe97398d3de973f7a20b9f6c327e51 Mon Sep 17 00:00:00 2001 From: mschwab Date: Fri, 24 Jul 2026 12:47:50 -0700 Subject: [PATCH 17/24] chore(studio): bump default anonymizer model timeout to 500s [ASTD-327] Signed-off-by: mschwab --- .../studio/src/routes/AnonymizerBuilderRoute/constants.ts | 2 +- .../studio/src/routes/AnonymizerBuilderRoute/schema.test.ts | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/web/packages/studio/src/routes/AnonymizerBuilderRoute/constants.ts b/web/packages/studio/src/routes/AnonymizerBuilderRoute/constants.ts index be022fecd8..5693fd6068 100644 --- a/web/packages/studio/src/routes/AnonymizerBuilderRoute/constants.ts +++ b/web/packages/studio/src/routes/AnonymizerBuilderRoute/constants.ts @@ -68,7 +68,7 @@ export const MAX_COLUMN_INTROSPECTION_BYTES = 50 * 1024 * 1024; * models don't hit ModelTimeoutError during entity validation. User-supplied * inference params override it. */ -export const DEFAULT_MODEL_TIMEOUT_SECONDS = 300; +export const DEFAULT_MODEL_TIMEOUT_SECONDS = 500; /** Role names the anonymizer workflows resolve against `model_configs` aliases. */ export const DETECTION_ROLES = [ diff --git a/web/packages/studio/src/routes/AnonymizerBuilderRoute/schema.test.ts b/web/packages/studio/src/routes/AnonymizerBuilderRoute/schema.test.ts index 423b01baa6..f14219c59d 100644 --- a/web/packages/studio/src/routes/AnonymizerBuilderRoute/schema.test.ts +++ b/web/packages/studio/src/routes/AnonymizerBuilderRoute/schema.test.ts @@ -54,7 +54,7 @@ describe('buildAnonymizerJobRequest', () => { alias: 'model-1', model: 'openai/gpt-oss-120b', provider: 'default/nvidia', - inference_parameters: { timeout: 300 }, + inference_parameters: { timeout: 500 }, }, ]); }); @@ -129,6 +129,6 @@ describe('buildAnonymizerJobRequest', () => { const withTemp = req.spec.model_configs?.find( (c) => (c.inference_parameters as { temperature?: number })?.temperature != null ); - expect(withTemp?.inference_parameters).toEqual({ timeout: 300, temperature: 0.1 }); + expect(withTemp?.inference_parameters).toEqual({ timeout: 500, temperature: 0.1 }); }); }); From 0969bd464f744d7b39e8eab3e9d7121771b1ffa4 Mon Sep 17 00:00:00 2001 From: mschwab Date: Fri, 24 Jul 2026 12:53:41 -0700 Subject: [PATCH 18/24] feat(studio): redirect anonymizer create to platform job detail [ASTD-327] The dedicated Anonymizer job page is still a placeholder (ASTD-330), so route create-success to the generic /jobs/:name detail (anonymizer jobs are platform jobs) which shows real status and logs. Signed-off-by: mschwab --- .../studio/src/routes/AnonymizerBuilderRoute/index.tsx | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/web/packages/studio/src/routes/AnonymizerBuilderRoute/index.tsx b/web/packages/studio/src/routes/AnonymizerBuilderRoute/index.tsx index 84bfa81ce8..b6e2838c80 100644 --- a/web/packages/studio/src/routes/AnonymizerBuilderRoute/index.tsx +++ b/web/packages/studio/src/routes/AnonymizerBuilderRoute/index.tsx @@ -31,7 +31,7 @@ import { buildAnonymizerJobRequest, getAnonymizerFormDefaults, } from '@studio/routes/AnonymizerBuilderRoute/schema'; -import { getAnonymizerJobRoute, getWorkspaceAnonymizerRoute } from '@studio/routes/utils'; +import { getWorkspaceAnonymizerRoute, getWorkspaceJobDetailRoute } from '@studio/routes/utils'; import { FC, useState } from 'react'; import { FormProvider, useForm } from 'react-hook-form'; import { useNavigate } from 'react-router-dom'; @@ -67,9 +67,11 @@ export const AnonymizerBuilderRoute: FC | null = ANONYMIZER_ENABLED const createJob = useAnonymizerCreateRunJob({ mutation: { onSuccess: (job: RunJob) => + // Anonymizer jobs are platform jobs; route to the generic job + // detail until the dedicated Anonymizer job page (ASTD-330) lands. navigate( job.name - ? getAnonymizerJobRoute(workspace, job.name) + ? getWorkspaceJobDetailRoute(workspace, job.name) : getWorkspaceAnonymizerRoute(workspace) ), onError: (error) => From 4e12ab8ed18ab3bce08cc35bf2e2debec1543a3f Mon Sep 17 00:00:00 2001 From: mschwab Date: Fri, 24 Jul 2026 13:02:30 -0700 Subject: [PATCH 19/24] fix(studio): default high max_tokens for anonymizer models [ASTD-327] Reasoning models truncated their structured-output validation responses. Default inference_parameters.max_tokens to 16384 (matching the library gpt-oss default) so they can complete; user params override. Signed-off-by: mschwab --- .../src/routes/AnonymizerBuilderRoute/constants.ts | 7 +++++++ .../src/routes/AnonymizerBuilderRoute/schema.test.ts | 4 ++-- .../studio/src/routes/AnonymizerBuilderRoute/schema.ts | 9 +++++++-- 3 files changed, 16 insertions(+), 4 deletions(-) diff --git a/web/packages/studio/src/routes/AnonymizerBuilderRoute/constants.ts b/web/packages/studio/src/routes/AnonymizerBuilderRoute/constants.ts index 5693fd6068..02c7aa2d9d 100644 --- a/web/packages/studio/src/routes/AnonymizerBuilderRoute/constants.ts +++ b/web/packages/studio/src/routes/AnonymizerBuilderRoute/constants.ts @@ -70,6 +70,13 @@ export const MAX_COLUMN_INTROSPECTION_BYTES = 50 * 1024 * 1024; */ export const DEFAULT_MODEL_TIMEOUT_SECONDS = 500; +/** + * Default per-request max output tokens. High enough for reasoning models to + * finish their structured-output response without truncation. User-supplied + * inference params override it. + */ +export const DEFAULT_MODEL_MAX_TOKENS = 16384; + /** Role names the anonymizer workflows resolve against `model_configs` aliases. */ export const DETECTION_ROLES = [ 'entity_detector', diff --git a/web/packages/studio/src/routes/AnonymizerBuilderRoute/schema.test.ts b/web/packages/studio/src/routes/AnonymizerBuilderRoute/schema.test.ts index f14219c59d..14404228e9 100644 --- a/web/packages/studio/src/routes/AnonymizerBuilderRoute/schema.test.ts +++ b/web/packages/studio/src/routes/AnonymizerBuilderRoute/schema.test.ts @@ -54,7 +54,7 @@ describe('buildAnonymizerJobRequest', () => { alias: 'model-1', model: 'openai/gpt-oss-120b', provider: 'default/nvidia', - inference_parameters: { timeout: 500 }, + inference_parameters: { timeout: 500, max_tokens: 16384 }, }, ]); }); @@ -129,6 +129,6 @@ describe('buildAnonymizerJobRequest', () => { const withTemp = req.spec.model_configs?.find( (c) => (c.inference_parameters as { temperature?: number })?.temperature != null ); - expect(withTemp?.inference_parameters).toEqual({ timeout: 500, temperature: 0.1 }); + expect(withTemp?.inference_parameters).toEqual({ timeout: 500, max_tokens: 16384, temperature: 0.1 }); }); }); diff --git a/web/packages/studio/src/routes/AnonymizerBuilderRoute/schema.ts b/web/packages/studio/src/routes/AnonymizerBuilderRoute/schema.ts index 21a2fae908..9cb6ecd549 100644 --- a/web/packages/studio/src/routes/AnonymizerBuilderRoute/schema.ts +++ b/web/packages/studio/src/routes/AnonymizerBuilderRoute/schema.ts @@ -11,6 +11,7 @@ import type { import { activeRolesForStrategy, DETECTION_ROLES, + DEFAULT_MODEL_MAX_TOKENS, DEFAULT_MODEL_TIMEOUT_SECONDS, DEFAULT_PREVIEW_ROWS, ENTITY_MODE_CUSTOM, @@ -111,8 +112,12 @@ export const buildAnonymizerJobRequest = (form: AnonymizerFormData): RunJobReque const roleModel = form.roleModels[role]; const model = roleModel?.model.trim() ?? ''; const provider = roleModel?.provider.trim() ?? ''; - // Default a generous timeout; user-supplied params override it. - const params = { timeout: DEFAULT_MODEL_TIMEOUT_SECONDS, ...(roleModel?.params ?? {}) }; + // Default a generous timeout and max_tokens; user params override. + const params = { + timeout: DEFAULT_MODEL_TIMEOUT_SECONDS, + max_tokens: DEFAULT_MODEL_MAX_TOKENS, + ...(roleModel?.params ?? {}), + }; const key = `${provider}::${model}::${JSON.stringify(params)}`; let alias = aliasByModel.get(key); if (!alias) { From fb7d1eb7e803d2ea3dd4c88cf1bf5771721105f1 Mon Sep 17 00:00:00 2001 From: mschwab Date: Fri, 24 Jul 2026 13:19:04 -0700 Subject: [PATCH 20/24] perf(studio): memoize column options and hoist static tab items [ASTD-327] Per Vercel React best practices: memoize the Text Column select options (new array each render) and hoist the static SegmentedControl tab items to module scope. Signed-off-by: mschwab --- .../components/ColumnsSection.tsx | 6 +++++- .../studio/src/routes/AnonymizerBuilderRoute/index.tsx | 10 ++++++---- 2 files changed, 11 insertions(+), 5 deletions(-) diff --git a/web/packages/studio/src/routes/AnonymizerBuilderRoute/components/ColumnsSection.tsx b/web/packages/studio/src/routes/AnonymizerBuilderRoute/components/ColumnsSection.tsx index 01b4294582..bb60b640a9 100644 --- a/web/packages/studio/src/routes/AnonymizerBuilderRoute/components/ColumnsSection.tsx +++ b/web/packages/studio/src/routes/AnonymizerBuilderRoute/components/ColumnsSection.tsx @@ -64,6 +64,10 @@ export const ColumnsSection: FC = () => { return getContentColumns(fileContent, fileType); }, [fileContent, filePath, isParquet]); + const columnItems = useMemo( + () => columns.map((column) => ({ label: column, value: column })), + [columns] + ); const useColumnDropdown = canIntrospect && columns.length > 0; // Auto-select the only column when there's exactly one. @@ -79,7 +83,7 @@ export const ColumnsSection: FC = () => { {useColumnDropdown ? ( ({ label: column, value: column }))} + items={columnItems} useControllerProps={{ name: 'textColumn', control }} formFieldProps={{ slotLabel: 'Text Column', diff --git a/web/packages/studio/src/routes/AnonymizerBuilderRoute/index.tsx b/web/packages/studio/src/routes/AnonymizerBuilderRoute/index.tsx index b6e2838c80..10d7d590fa 100644 --- a/web/packages/studio/src/routes/AnonymizerBuilderRoute/index.tsx +++ b/web/packages/studio/src/routes/AnonymizerBuilderRoute/index.tsx @@ -39,6 +39,11 @@ import { useNavigate } from 'react-router-dom'; const TAB_SOURCE = 'source'; const TAB_MODEL_SETTINGS = 'model-settings'; +const PANEL_TABS = [ + { value: TAB_SOURCE, children: 'Source' }, + { value: TAB_MODEL_SETTINGS, children: 'Model Settings' }, +]; + export const AnonymizerBuilderRoute: FC | null = ANONYMIZER_ENABLED ? () => { const navigate = useNavigate(); @@ -129,10 +134,7 @@ export const AnonymizerBuilderRoute: FC | null = ANONYMIZER_ENABLED className="w-full" value={activeTab} onValueChange={setActiveTab} - items={[ - { value: TAB_SOURCE, children: 'Source' }, - { value: TAB_MODEL_SETTINGS, children: 'Model Settings' }, - ]} + items={PANEL_TABS} /> {submitError && ( From 778b7db371cfd7125dd0e2f7e5e1895751fbfce3 Mon Sep 17 00:00:00 2001 From: mschwab Date: Fri, 24 Jul 2026 13:33:23 -0700 Subject: [PATCH 21/24] style(studio): prettier-format anonymizer schema test [ASTD-327] Signed-off-by: mschwab --- .../studio/src/routes/AnonymizerBuilderRoute/schema.test.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/web/packages/studio/src/routes/AnonymizerBuilderRoute/schema.test.ts b/web/packages/studio/src/routes/AnonymizerBuilderRoute/schema.test.ts index 14404228e9..c4a220da80 100644 --- a/web/packages/studio/src/routes/AnonymizerBuilderRoute/schema.test.ts +++ b/web/packages/studio/src/routes/AnonymizerBuilderRoute/schema.test.ts @@ -129,6 +129,10 @@ describe('buildAnonymizerJobRequest', () => { const withTemp = req.spec.model_configs?.find( (c) => (c.inference_parameters as { temperature?: number })?.temperature != null ); - expect(withTemp?.inference_parameters).toEqual({ timeout: 500, max_tokens: 16384, temperature: 0.1 }); + expect(withTemp?.inference_parameters).toEqual({ + timeout: 500, + max_tokens: 16384, + temperature: 0.1, + }); }); }); From 8a19de6134391c37409f3834111ae72c9fe8eda4 Mon Sep 17 00:00:00 2001 From: mschwab Date: Fri, 24 Jul 2026 13:38:33 -0700 Subject: [PATCH 22/24] fix(studio): reset source on type change, trim source validation [ASTD-327] Address CodeRabbit: clear the shared source field (and its error) when the source type toggles between URL and Dataset, and trim the source in the Zod schema so whitespace-only input fails validation. Signed-off-by: mschwab --- .../components/DataSourceSection.tsx | 8 +++++++- .../studio/src/routes/AnonymizerBuilderRoute/schema.ts | 2 +- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/web/packages/studio/src/routes/AnonymizerBuilderRoute/components/DataSourceSection.tsx b/web/packages/studio/src/routes/AnonymizerBuilderRoute/components/DataSourceSection.tsx index 6b1a2674ec..5bff9b0c49 100644 --- a/web/packages/studio/src/routes/AnonymizerBuilderRoute/components/DataSourceSection.tsx +++ b/web/packages/studio/src/routes/AnonymizerBuilderRoute/components/DataSourceSection.tsx @@ -15,7 +15,7 @@ import { FC } from 'react'; import { useFormContext, useWatch } from 'react-hook-form'; export const DataSourceSection: FC = () => { - const { control, setError, clearErrors } = useFormContext(); + const { control, setValue, setError, clearErrors } = useFormContext(); const workspace = useWorkspaceFromPath(); const sourceType = useWatch({ control, name: 'sourceType' }); const isDataset = sourceType === SOURCE_TYPE_DATASET; @@ -27,6 +27,12 @@ export const DataSourceSection: FC = () => { aria-label="Source type" items={SOURCE_TYPE_OPTIONS} useControllerProps={{ name: 'sourceType', control }} + onChange={() => { + // A URL and a fileset ref aren't interchangeable — reset the shared + // source field (and its error) when the source type changes. + setValue('source', ''); + clearErrors('source'); + }} formFieldProps={{ slotLabel: 'Source', required: true }} /> {isDataset ? ( diff --git a/web/packages/studio/src/routes/AnonymizerBuilderRoute/schema.ts b/web/packages/studio/src/routes/AnonymizerBuilderRoute/schema.ts index 9cb6ecd549..3bec9df057 100644 --- a/web/packages/studio/src/routes/AnonymizerBuilderRoute/schema.ts +++ b/web/packages/studio/src/routes/AnonymizerBuilderRoute/schema.ts @@ -34,7 +34,7 @@ export const anonymizerFormSchema = z .object({ name: z.string().optional(), sourceType: z.enum(['url', 'dataset']), - source: z.string().min(1, 'A data source is required'), + source: z.string().trim().min(1, 'A data source is required'), strategy: z.enum(['substitute', 'redact', 'annotate', 'hash', 'rewrite']), previewRows: z.number().int().min(1), textColumn: z.string().optional(), From 5e4afb55dc99d80f5a2aebd27ec9c455ade342f6 Mon Sep 17 00:00:00 2001 From: mschwab Date: Fri, 24 Jul 2026 14:01:51 -0700 Subject: [PATCH 23/24] chore(studio): remove explanatory comments from anonymizer builder [ASTD-327] Signed-off-by: mschwab --- .../components/ColumnsSection.tsx | 1 - .../components/DataSourceSection.tsx | 2 -- .../components/ModelSettingsSection.tsx | 2 -- .../routes/AnonymizerBuilderRoute/constants.ts | 15 --------------- .../src/routes/AnonymizerBuilderRoute/index.tsx | 7 ------- .../routes/AnonymizerBuilderRoute/schema.test.ts | 1 - .../src/routes/AnonymizerBuilderRoute/schema.ts | 13 ------------- 7 files changed, 41 deletions(-) diff --git a/web/packages/studio/src/routes/AnonymizerBuilderRoute/components/ColumnsSection.tsx b/web/packages/studio/src/routes/AnonymizerBuilderRoute/components/ColumnsSection.tsx index bb60b640a9..0765f4f7e6 100644 --- a/web/packages/studio/src/routes/AnonymizerBuilderRoute/components/ColumnsSection.tsx +++ b/web/packages/studio/src/routes/AnonymizerBuilderRoute/components/ColumnsSection.tsx @@ -70,7 +70,6 @@ export const ColumnsSection: FC = () => { ); const useColumnDropdown = canIntrospect && columns.length > 0; - // Auto-select the only column when there's exactly one. useEffect(() => { if (useColumnDropdown && columns.length === 1 && textColumn !== columns[0]) { setValue('textColumn', columns[0], { shouldValidate: true }); diff --git a/web/packages/studio/src/routes/AnonymizerBuilderRoute/components/DataSourceSection.tsx b/web/packages/studio/src/routes/AnonymizerBuilderRoute/components/DataSourceSection.tsx index 5bff9b0c49..066710342d 100644 --- a/web/packages/studio/src/routes/AnonymizerBuilderRoute/components/DataSourceSection.tsx +++ b/web/packages/studio/src/routes/AnonymizerBuilderRoute/components/DataSourceSection.tsx @@ -28,8 +28,6 @@ export const DataSourceSection: FC = () => { items={SOURCE_TYPE_OPTIONS} useControllerProps={{ name: 'sourceType', control }} onChange={() => { - // A URL and a fileset ref aren't interchangeable — reset the shared - // source field (and its error) when the source type changes. setValue('source', ''); clearErrors('source'); }} diff --git a/web/packages/studio/src/routes/AnonymizerBuilderRoute/components/ModelSettingsSection.tsx b/web/packages/studio/src/routes/AnonymizerBuilderRoute/components/ModelSettingsSection.tsx index 52ad0e7947..5e18c313ee 100644 --- a/web/packages/studio/src/routes/AnonymizerBuilderRoute/components/ModelSettingsSection.tsx +++ b/web/packages/studio/src/routes/AnonymizerBuilderRoute/components/ModelSettingsSection.tsx @@ -55,8 +55,6 @@ export const ModelSettingsSection: FC = () => { }); }; - // Seed sensible defaults once models load: GLiNER for the detector, a - // suggested chat model for the rest (avoids defaulting to a flaky first model). useEffect(() => { if (!models.length) return; const suggestedName = pickDefaultModelName( diff --git a/web/packages/studio/src/routes/AnonymizerBuilderRoute/constants.ts b/web/packages/studio/src/routes/AnonymizerBuilderRoute/constants.ts index 02c7aa2d9d..62a08dd80c 100644 --- a/web/packages/studio/src/routes/AnonymizerBuilderRoute/constants.ts +++ b/web/packages/studio/src/routes/AnonymizerBuilderRoute/constants.ts @@ -24,7 +24,6 @@ export type Strategy = | typeof STRATEGY_HASH | typeof STRATEGY_REWRITE; -/** Rewrite is applied via `config.rewrite`; the other four via `config.replace`. */ export const REWRITE_STRATEGY: Strategy = STRATEGY_REWRITE; export const STRATEGY_OPTIONS: { label: string; value: Strategy }[] = [ @@ -60,24 +59,12 @@ export const ENTITY_MODE_OPTIONS: { value: EntityMode; children: string }[] = [ export const DEFAULT_PREVIEW_ROWS = 1; -/** Above this file size, skip column introspection and fall back to a text input. */ export const MAX_COLUMN_INTROSPECTION_BYTES = 50 * 1024 * 1024; -/** - * Default per-request model timeout (seconds). Generous so slower reasoning - * models don't hit ModelTimeoutError during entity validation. User-supplied - * inference params override it. - */ export const DEFAULT_MODEL_TIMEOUT_SECONDS = 500; -/** - * Default per-request max output tokens. High enough for reasoning models to - * finish their structured-output response without truncation. User-supplied - * inference params override it. - */ export const DEFAULT_MODEL_MAX_TOKENS = 16384; -/** Role names the anonymizer workflows resolve against `model_configs` aliases. */ export const DETECTION_ROLES = [ 'entity_detector', 'entity_validator', @@ -110,10 +97,8 @@ export const ROLE_LABELS: Record = { evaluator: 'Evaluator', }; -/** The role that expects a GLiNER PII detection model rather than an LLM. */ export const GLINER_ROLE = 'entity_detector'; -/** Roles configured for a given strategy: detection always, plus the strategy's generator roles. */ export const activeRolesForStrategy = (strategy: Strategy): string[] => { if (strategy === STRATEGY_REWRITE) return [...DETECTION_ROLES, ...REWRITE_ROLES]; if (strategy === STRATEGY_SUBSTITUTE) return [...DETECTION_ROLES, REPLACE_ROLE]; diff --git a/web/packages/studio/src/routes/AnonymizerBuilderRoute/index.tsx b/web/packages/studio/src/routes/AnonymizerBuilderRoute/index.tsx index 10d7d590fa..dbf4a50656 100644 --- a/web/packages/studio/src/routes/AnonymizerBuilderRoute/index.tsx +++ b/web/packages/studio/src/routes/AnonymizerBuilderRoute/index.tsx @@ -51,8 +51,6 @@ export const AnonymizerBuilderRoute: FC | null = ANONYMIZER_ENABLED const [activeTab, setActiveTab] = useState(TAB_SOURCE); const [submitError, setSubmitError] = useState(undefined); - // Shared with ModelSettingsSection (react-query dedups) — gate submit until - // the model list has loaded and its role defaults have been seeded. const { isLoading: isLoadingModels } = useModelsListProviders( workspace, { page_size: DEFAULT_LARGE_PAGE_SIZE }, @@ -72,8 +70,6 @@ export const AnonymizerBuilderRoute: FC | null = ANONYMIZER_ENABLED const createJob = useAnonymizerCreateRunJob({ mutation: { onSuccess: (job: RunJob) => - // Anonymizer jobs are platform jobs; route to the generic job - // detail until the dedicated Anonymizer job page (ASTD-330) lands. navigate( job.name ? getWorkspaceJobDetailRoute(workspace, job.name) @@ -90,7 +86,6 @@ export const AnonymizerBuilderRoute: FC | null = ANONYMIZER_ENABLED createJob.mutate({ workspace, data: buildAnonymizerJobRequest(values) }); }, (errors) => { - // Jump to whichever tab holds the first error so it's never hidden. const onlyModelErrors = Object.keys(errors).every((key) => key === 'roleModels'); setActiveTab(onlyModelErrors ? TAB_MODEL_SETTINGS : TAB_SOURCE); setSubmitError('Please complete the required fields highlighted below.'); @@ -143,8 +138,6 @@ export const AnonymizerBuilderRoute: FC | null = ANONYMIZER_ENABLED )} - {/* Both panels stay mounted so Model Settings can seed its - defaults even before the tab is opened. */}
diff --git a/web/packages/studio/src/routes/AnonymizerBuilderRoute/schema.test.ts b/web/packages/studio/src/routes/AnonymizerBuilderRoute/schema.test.ts index c4a220da80..b46d13bd7e 100644 --- a/web/packages/studio/src/routes/AnonymizerBuilderRoute/schema.test.ts +++ b/web/packages/studio/src/routes/AnonymizerBuilderRoute/schema.test.ts @@ -124,7 +124,6 @@ describe('buildAnonymizerJobRequest', () => { params: { temperature: 0.1 }, }; const req = buildAnonymizerJobRequest(form({ strategy: 'substitute', roleModels: models })); - // Same model+provider but one role has params → two distinct configs. expect(req.spec.model_configs).toHaveLength(2); const withTemp = req.spec.model_configs?.find( (c) => (c.inference_parameters as { temperature?: number })?.temperature != null diff --git a/web/packages/studio/src/routes/AnonymizerBuilderRoute/schema.ts b/web/packages/studio/src/routes/AnonymizerBuilderRoute/schema.ts index 3bec9df057..fdc2e2cecf 100644 --- a/web/packages/studio/src/routes/AnonymizerBuilderRoute/schema.ts +++ b/web/packages/studio/src/routes/AnonymizerBuilderRoute/schema.ts @@ -78,24 +78,12 @@ const trimToUndefined = (value: string | undefined): string | undefined => { return trimmed ? trimmed : undefined; }; -/** - * Build the create-job request. The strategy is applied via `config.rewrite` - * for Rewrite and `config.replace` for the other four. `replace` is a - * `kind`-discriminated union server-side, so the tag must be sent even though - * it isn't a modelled field on the SDK types. - * - * Each active role's model is deduplicated into a `model_configs` pool (one - * entry per unique model+provider) and `selected_models` maps every role of - * the strategy's workflow(s) to the matching alias. - */ export const buildAnonymizerJobRequest = (form: AnonymizerFormData): RunJobRequest => { const config: AnonymizerConfigInput = form.strategy === REWRITE_STRATEGY ? { rewrite: {} } : { replace: { kind: form.strategy } as AnonymizerConfigInput['replace'] }; - // Custom mode with a specific label set restricts detection; otherwise the - // library default set is used (detect omitted). const useCustomLabels = form.entityMode === ENTITY_MODE_CUSTOM && !form.includeDefaultEntities && @@ -112,7 +100,6 @@ export const buildAnonymizerJobRequest = (form: AnonymizerFormData): RunJobReque const roleModel = form.roleModels[role]; const model = roleModel?.model.trim() ?? ''; const provider = roleModel?.provider.trim() ?? ''; - // Default a generous timeout and max_tokens; user params override. const params = { timeout: DEFAULT_MODEL_TIMEOUT_SECONDS, max_tokens: DEFAULT_MODEL_MAX_TOKENS, From 223c68c98c03432a0c3dd01922f439307870f4d1 Mon Sep 17 00:00:00 2001 From: mschwab Date: Fri, 24 Jul 2026 14:05:45 -0700 Subject: [PATCH 24/24] refactor(studio): lift trimToUndefined to shared strings util [ASTD-327] Address review: move the generic trim helper out of the anonymizer schema into @studio/util/strings with a test. Signed-off-by: mschwab --- .../src/routes/AnonymizerBuilderRoute/schema.ts | 6 +----- web/packages/studio/src/util/strings.test.ts | 14 +++++++++++++- web/packages/studio/src/util/strings.ts | 6 ++++++ 3 files changed, 20 insertions(+), 6 deletions(-) diff --git a/web/packages/studio/src/routes/AnonymizerBuilderRoute/schema.ts b/web/packages/studio/src/routes/AnonymizerBuilderRoute/schema.ts index fdc2e2cecf..59033e20d6 100644 --- a/web/packages/studio/src/routes/AnonymizerBuilderRoute/schema.ts +++ b/web/packages/studio/src/routes/AnonymizerBuilderRoute/schema.ts @@ -21,6 +21,7 @@ import { SOURCE_TYPE_DATASET, STRATEGY_SUBSTITUTE, } from '@studio/routes/AnonymizerBuilderRoute/constants'; +import { trimToUndefined } from '@studio/util/strings'; import { z } from 'zod'; const roleModelSchema = z.object({ @@ -73,11 +74,6 @@ export const getAnonymizerFormDefaults = (): AnonymizerFormData => ({ roleModels: {}, }); -const trimToUndefined = (value: string | undefined): string | undefined => { - const trimmed = value?.trim(); - return trimmed ? trimmed : undefined; -}; - export const buildAnonymizerJobRequest = (form: AnonymizerFormData): RunJobRequest => { const config: AnonymizerConfigInput = form.strategy === REWRITE_STRATEGY diff --git a/web/packages/studio/src/util/strings.test.ts b/web/packages/studio/src/util/strings.test.ts index 2ab8b828d7..91acb2e31b 100644 --- a/web/packages/studio/src/util/strings.test.ts +++ b/web/packages/studio/src/util/strings.test.ts @@ -1,7 +1,19 @@ // SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 -import { capitalize, formatKeyLabel, parseCSV } from '@studio/util/strings'; +import { capitalize, formatKeyLabel, parseCSV, trimToUndefined } from '@studio/util/strings'; + +describe('#trimToUndefined', () => { + it.each([ + [' hello ', 'hello'], + ['world', 'world'], + [' ', undefined], + ['', undefined], + [undefined, undefined], + ])('trims "%s" to "%s"', (input, expected) => { + expect(trimToUndefined(input)).toBe(expected); + }); +}); describe('#formatKeyLabel', () => { it.each([ diff --git a/web/packages/studio/src/util/strings.ts b/web/packages/studio/src/util/strings.ts index dcfed5e616..6863c17fbb 100644 --- a/web/packages/studio/src/util/strings.ts +++ b/web/packages/studio/src/util/strings.ts @@ -11,6 +11,12 @@ export const capitalize = (str: string) => { return str.charAt(0).toUpperCase() + str.slice(1); }; +/** Trim a string, returning undefined when it is empty or whitespace-only. */ +export const trimToUndefined = (value: string | undefined): string | undefined => { + const trimmed = value?.trim(); + return trimmed ? trimmed : undefined; +}; + /** * Formats a snake_case key into a human-readable label. * @param key - The snake_case key to format (e.g., "prompt_tokens")