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..0765f4f7e6 --- /dev/null +++ b/web/packages/studio/src/routes/AnonymizerBuilderRoute/components/ColumnsSection.tsx @@ -0,0 +1,111 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { parseFilesetLocation } from '@nemo/common/src/components/DatasetFileSelect/parseFilesetLocation'; +import { ControlledSelect } from '@nemo/common/src/components/form/ControlledSelect'; +import { ControlledTextArea } from '@nemo/common/src/components/form/ControlledTextArea'; +import { ControlledTextInput } from '@nemo/common/src/components/form/ControlledTextInput'; +import { useFilesListFilesetFiles } from '@nemo/sdk/generated/platform/api'; +import { Stack, Text } from '@nvidia/foundations-react-core'; +import { useDatasetFileContent } from '@studio/api/datasets/useDatasetFileContent'; +import { useWorkspaceFromPath } from '@studio/hooks/useWorkspaceFromPath'; +import { + MAX_COLUMN_INTROSPECTION_BYTES, + SOURCE_TYPE_DATASET, +} from '@studio/routes/AnonymizerBuilderRoute/constants'; +import type { AnonymizerFormData } from '@studio/routes/AnonymizerBuilderRoute/schema'; +import { getContentColumns, getFileExtension } from '@studio/util/files'; +import { FC, useEffect, useMemo } from 'react'; +import { useFormContext, useWatch } from 'react-hook-form'; + +export const ColumnsSection: FC = () => { + const { control, setValue } = useFormContext(); + const workspace = useWorkspaceFromPath(); + const source = useWatch({ control, name: 'source' }); + const sourceType = useWatch({ control, name: 'sourceType' }); + const textColumn = useWatch({ control, name: 'textColumn' }); + + const parsed = useMemo( + () => + sourceType === SOURCE_TYPE_DATASET && source ? parseFilesetLocation(source, workspace) : null, + [sourceType, source, workspace] + ); + const filesetWorkspace = parsed?.workspace ?? ''; + const filesetName = parsed?.name ?? ''; + const filePath = parsed?.objectPath ?? ''; + + const { data: filesResponse } = useFilesListFilesetFiles( + filesetWorkspace, + filesetName, + undefined, + { + query: { enabled: Boolean(filesetWorkspace && filesetName) }, + } + ); + const fileSize = useMemo( + () => filesResponse?.data?.find((file) => file.path === filePath)?.size ?? null, + [filesResponse?.data, filePath] + ); + const tooLarge = fileSize != null && fileSize > MAX_COLUMN_INTROSPECTION_BYTES; + + const isParquet = filePath.endsWith('parquet'); + const canIntrospect = Boolean(filesetWorkspace && filesetName && filePath) && !tooLarge; + const { data: fileContent } = useDatasetFileContent({ + workspace: filesetWorkspace, + name: filesetName, + path: filePath, + range: isParquet ? [0, 1] : undefined, + enabled: canIntrospect, + }); + + const columns = useMemo(() => { + if (!fileContent) return []; + const fileType = isParquet ? 'jsonl' : (getFileExtension(filePath) ?? undefined); + return getContentColumns(fileContent, fileType); + }, [fileContent, filePath, isParquet]); + + const columnItems = useMemo( + () => columns.map((column) => ({ label: column, value: column })), + [columns] + ); + const useColumnDropdown = canIntrospect && columns.length > 0; + + useEffect(() => { + if (useColumnDropdown && columns.length === 1 && textColumn !== columns[0]) { + setValue('textColumn', columns[0], { shouldValidate: true }); + } + }, [useColumnDropdown, columns, textColumn, setValue]); + + return ( + + Columns + {useColumnDropdown ? ( + + ) : ( + + )} + + + ); +}; 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..066710342d --- /dev/null +++ b/web/packages/studio/src/routes/AnonymizerBuilderRoute/components/DataSourceSection.tsx @@ -0,0 +1,59 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { ControlledDatasetFileSelect } from '@nemo/common/src/components/DatasetFileSelect/ControlledDatasetFileSelect'; +import { ControlledSelect } from '@nemo/common/src/components/form/ControlledSelect'; +import { ControlledTextInput } from '@nemo/common/src/components/form/ControlledTextInput'; +import { Stack, Text } from '@nvidia/foundations-react-core'; +import { useWorkspaceFromPath } from '@studio/hooks/useWorkspaceFromPath'; +import { + SOURCE_TYPE_DATASET, + SOURCE_TYPE_OPTIONS, +} from '@studio/routes/AnonymizerBuilderRoute/constants'; +import type { AnonymizerFormData } from '@studio/routes/AnonymizerBuilderRoute/schema'; +import { FC } from 'react'; +import { useFormContext, useWatch } from 'react-hook-form'; + +export const DataSourceSection: FC = () => { + const { control, setValue, setError, clearErrors } = useFormContext(); + const workspace = useWorkspaceFromPath(); + const sourceType = useWatch({ control, name: 'sourceType' }); + const isDataset = sourceType === SOURCE_TYPE_DATASET; + + return ( + + Data Source + { + setValue('source', ''); + clearErrors('source'); + }} + formFieldProps={{ slotLabel: 'Source', required: true }} + /> + {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..e700ce7cc0 --- /dev/null +++ b/web/packages/studio/src/routes/AnonymizerBuilderRoute/components/EntitiesSection.tsx @@ -0,0 +1,65 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { ControlledCheckbox } from '@nemo/common/src/components/form/ControlledCheckbox'; +import { ControlledCombobox } from '@nemo/common/src/components/form/ControlledCombobox'; +import { ControlledSegmentedControl } from '@nemo/common/src/components/form/ControlledSegmentedControl'; +import { useAnonymizerListEntityLabels } from '@nemo/sdk/generated/anonymizer/api'; +import { Stack, Text } from '@nvidia/foundations-react-core'; +import { useWorkspaceFromPath } from '@studio/hooks/useWorkspaceFromPath'; +import { + ENTITY_MODE_AUTO, + ENTITY_MODE_CUSTOM, + ENTITY_MODE_OPTIONS, +} from '@studio/routes/AnonymizerBuilderRoute/constants'; +import type { AnonymizerFormData } from '@studio/routes/AnonymizerBuilderRoute/schema'; +import { FC } from 'react'; +import { useFormContext, useWatch } from 'react-hook-form'; + +export const EntitiesSection: FC = () => { + const { control } = useFormContext(); + 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 ( + + 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.'} + + {isCustom && ( + + )} + {showLabelPicker && ( + + )} + + ); +}; 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..6dbf0ec8a2 --- /dev/null +++ b/web/packages/studio/src/routes/AnonymizerBuilderRoute/components/GenerationSection.tsx @@ -0,0 +1,45 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { ControlledSelect } from '@nemo/common/src/components/form/ControlledSelect'; +import { ControlledTextInput } from '@nemo/common/src/components/form/ControlledTextInput'; +import { Stack, Text } from '@nvidia/foundations-react-core'; +import { + STRATEGY_DESCRIPTIONS, + STRATEGY_OPTIONS, +} from '@studio/routes/AnonymizerBuilderRoute/constants'; +import type { AnonymizerFormData } from '@studio/routes/AnonymizerBuilderRoute/schema'; +import { FC } from 'react'; +import { useFormContext, useWatch } from 'react-hook-form'; + +export const GenerationSection: FC = () => { + const { control } = useFormContext(); + const strategy = useWatch({ control, name: 'strategy' }); + + return ( + + Generation + + {STRATEGY_DESCRIPTIONS[strategy]} + + + ); +}; 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..5e18c313ee --- /dev/null +++ b/web/packages/studio/src/routes/AnonymizerBuilderRoute/components/ModelSettingsSection.tsx @@ -0,0 +1,114 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { ControlledSearchableSelect } from '@nemo/common/src/components/form/ControlledSearchableSelect'; +import { ParamsDropdown } from '@nemo/common/src/components/ModelSelectV2/ParamsDropdown'; +import { useModelsListProviders } from '@nemo/sdk/generated/platform/api'; +import type { InferenceParams } from '@nemo/sdk/generated/platform/schema'; +import { Divider, Flex, Stack, Text } from '@nvidia/foundations-react-core'; +import { modelsFromProviders } from '@studio/components/NewDataDesignerJobForm/utils'; +import { DEFAULT_LARGE_PAGE_SIZE } from '@studio/constants/constants'; +import { useWorkspaceFromPath } from '@studio/hooks/useWorkspaceFromPath'; +import { + activeRolesForStrategy, + GLINER_ROLE, + ROLE_LABELS, +} from '@studio/routes/AnonymizerBuilderRoute/constants'; +import type { AnonymizerFormData } from '@studio/routes/AnonymizerBuilderRoute/schema'; +import { pickDefaultModelName } from '@studio/util/buildSuggestedModelOptions'; +import { FC, useEffect, useMemo, useState } from 'react'; +import { useFormContext, useWatch } from 'react-hook-form'; + +const isGliner = (name: string) => /gliner/i.test(name); + +export const ModelSettingsSection: FC = () => { + const { control, setValue, getValues } = useFormContext(); + 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]); + + const { data: providersPage, isLoading } = useModelsListProviders( + workspace, + { page_size: DEFAULT_LARGE_PAGE_SIZE }, + { query: {} } + ); + + const models = useMemo( + () => modelsFromProviders(providersPage?.data ?? []), + [providersPage?.data] + ); + const items = useMemo( + () => models.map((model) => ({ label: model.name, value: model.id })), + [models] + ); + + const applyModel = (role: string, id: string) => { + const selected = models.find((model) => model.id === id); + setValue(`roleModels.${role}.model`, selected?.served_model_name ?? '', { + shouldValidate: true, + }); + setValue(`roleModels.${role}.provider`, selected?.model_providers?.[0] ?? '', { + shouldValidate: true, + }); + }; + + useEffect(() => { + if (!models.length) return; + const suggestedName = pickDefaultModelName( + models.map((model) => ({ name: model.served_model_name ?? model.name })) + ); + const llm = + models.find((model) => (model.served_model_name ?? model.name) === suggestedName) ?? + models.find((model) => !isGliner(model.name)) ?? + models[0]; + const gliner = models.find((model) => isGliner(model.name)) ?? llm; + for (const role of roles) { + const current = getValues(`roleModels.${role}.modelId`); + if (current) continue; + const pick = role === GLINER_ROLE ? gliner : llm; + setValue(`roleModels.${role}.modelId`, pick.id); + applyModel(role, pick.id); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [models, roles, getValues, setValue]); + + return ( + + {roles.map((role, index) => ( + + {index > 0 && } + {ROLE_LABELS[role] ?? role} + +
+ 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/constants.ts b/web/packages/studio/src/routes/AnonymizerBuilderRoute/constants.ts new file mode 100644 index 0000000000..62a08dd80c --- /dev/null +++ b/web/packages/studio/src/routes/AnonymizerBuilderRoute/constants.ts @@ -0,0 +1,106 @@ +// 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; + +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; + +export const MAX_COLUMN_INTROSPECTION_BYTES = 50 * 1024 * 1024; + +export const DEFAULT_MODEL_TIMEOUT_SECONDS = 500; + +export const DEFAULT_MODEL_MAX_TOKENS = 16384; + +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', +]; + +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', +}; + +export const GLINER_ROLE = 'entity_detector'; + +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 2a7375b01b..dbf4a50656 100644 --- a/web/packages/studio/src/routes/AnonymizerBuilderRoute/index.tsx +++ b/web/packages/studio/src/routes/AnonymizerBuilderRoute/index.tsx @@ -1,27 +1,166 @@ // 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 { useModelsListProviders } from '@nemo/sdk/generated/platform/api'; +import { + Banner, + Button, + Divider, + Flex, + Panel, + SegmentedControl, + Stack, + Text, +} 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'; -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 { ModelSettingsSection } from '@studio/routes/AnonymizerBuilderRoute/components/ModelSettingsSection'; +import { + anonymizerFormSchema, + buildAnonymizerJobRequest, + getAnonymizerFormDefaults, +} from '@studio/routes/AnonymizerBuilderRoute/schema'; +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'; + +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(); + const workspace = useWorkspaceFromPath(); + const [activeTab, setActiveTab] = useState(TAB_SOURCE); + const [submitError, setSubmitError] = useState(undefined); + + const { isLoading: isLoadingModels } = useModelsListProviders( + workspace, + { page_size: DEFAULT_LARGE_PAGE_SIZE }, + { query: {} } + ); + 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 + ? getWorkspaceJobDetailRoute(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) }); + }, + (errors) => { + const onlyModelErrors = Object.keys(errors).every((key) => key === 'roleModels'); + setActiveTab(onlyModelErrors ? TAB_MODEL_SETTINGS : TAB_SOURCE); + setSubmitError('Please complete the required fields highlighted below.'); + } + ); + + const handleCancel = () => navigate(getWorkspaceAnonymizerRoute(workspace)); + return ( - - - + +
+ + + + + + } + > + + + + {submitError && ( + + {submitError} + + )} + +
+ + + + + + + + + +
+
+ +
+
+ + + + 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..b46d13bd7e --- /dev/null +++ b/web/packages/studio/src/routes/AnonymizerBuilderRoute/schema.test.ts @@ -0,0 +1,137 @@ +// 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): AnonymizerFormData['roleModels'] => + Object.fromEntries(ALL_ROLES.map((role) => [role, { modelId: role, model, provider }])); + +const form = (overrides: Partial = {}): AnonymizerFormData => ({ + ...getAnonymizerFormDefaults(), + source: 'https://example.com/data.csv', + roleModels: roleModels('openai/gpt-oss-120b', 'default/nvidia'), + ...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('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', + inference_parameters: { timeout: 500, max_tokens: 16384 }, + }, + ]); + }); + + 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('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('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('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(); + } + }); + + 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]] = { + modelId: 'gpt', + model: 'openai/gpt-oss-120b', + provider: 'default/nvidia', + params: { temperature: 0.1 }, + }; + const req = buildAnonymizerJobRequest(form({ strategy: 'substitute', roleModels: models })); + expect(req.spec.model_configs).toHaveLength(2); + 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, + }); + }); +}); 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..59033e20d6 --- /dev/null +++ b/web/packages/studio/src/routes/AnonymizerBuilderRoute/schema.ts @@ -0,0 +1,142 @@ +// 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, + ModelConfig, + RunJobRequest, + SelectedModelsOverrides, +} from '@nemo/sdk/generated/anonymizer/schema'; +import { + activeRolesForStrategy, + DETECTION_ROLES, + DEFAULT_MODEL_MAX_TOKENS, + DEFAULT_MODEL_TIMEOUT_SECONDS, + DEFAULT_PREVIEW_ROWS, + ENTITY_MODE_CUSTOM, + REPLACE_ROLE, + REWRITE_ROLES, + REWRITE_STRATEGY, + SOURCE_TYPE_DATASET, + STRATEGY_SUBSTITUTE, +} from '@studio/routes/AnonymizerBuilderRoute/constants'; +import { trimToUndefined } from '@studio/util/strings'; +import { z } from 'zod'; + +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 + .object({ + name: z.string().optional(), + sourceType: z.enum(['url', 'dataset']), + 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(), + 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) => { + 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 => ({ + name: generateDefaultName(), + sourceType: SOURCE_TYPE_DATASET, + source: '', + strategy: STRATEGY_SUBSTITUTE, + previewRows: DEFAULT_PREVIEW_ROWS, + textColumn: '', + dataSummary: '', + entityMode: ENTITY_MODE_CUSTOM, + includeDefaultEntities: true, + entityLabels: [], + roleModels: {}, +}); + +export const buildAnonymizerJobRequest = (form: AnonymizerFormData): RunJobRequest => { + const config: AnonymizerConfigInput = + form.strategy === REWRITE_STRATEGY + ? { rewrite: {} } + : { replace: { kind: form.strategy } as AnonymizerConfigInput['replace'] }; + + 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 = {}; + + for (const role of activeRolesForStrategy(form.strategy)) { + const roleModel = form.roleModels[role]; + const model = roleModel?.model.trim() ?? ''; + const provider = roleModel?.provider.trim() ?? ''; + 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) { + alias = `model-${aliasByModel.size + 1}`; + aliasByModel.set(key, alias); + modelConfigs.push({ + alias, + model, + provider, + inference_parameters: params as ModelConfig['inference_parameters'], + }); + } + 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), + spec: { + config, + data: { + source: form.source.trim(), + text_column: trimToUndefined(form.textColumn), + data_summary: trimToUndefined(form.dataSummary), + }, + model_configs: modelConfigs, + selected_models: selectedModels, + }, + }; +}; 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")