diff --git a/web/packages/studio/src/routes/AnonymizerBuilderRoute/apiErrors.test.ts b/web/packages/studio/src/routes/AnonymizerBuilderRoute/apiErrors.test.ts index 4a38b4012f..3127f8b122 100644 --- a/web/packages/studio/src/routes/AnonymizerBuilderRoute/apiErrors.test.ts +++ b/web/packages/studio/src/routes/AnonymizerBuilderRoute/apiErrors.test.ts @@ -31,6 +31,24 @@ describe('parseAnonymizerApiError', () => { expect(fieldErrors.map((f) => f.field)).toEqual(['hashDigestLength', 'source']); }); + it('maps rewrite params, including nested privacy goal fields', () => { + const rewriteLoc = (...tail: string[]) => ['body', 'spec', 'config', 'rewrite', ...tail]; + const { fieldErrors } = parseAnonymizerApiError( + apiError([ + { loc: rewriteLoc('privacy_goal', 'protect'), msg: 'too short' }, + { loc: rewriteLoc('privacy_goal', 'preserve'), msg: 'too short' }, + { loc: rewriteLoc('max_repair_iterations'), msg: 'negative' }, + { loc: rewriteLoc('risk_tolerance'), msg: 'bad preset' }, + ]) + ); + expect(fieldErrors.map((f) => f.field)).toEqual([ + 'privacyProtect', + 'privacyPreserve', + 'maxRepairRounds', + 'riskTolerance', + ]); + }); + it('collects unmapped errors as general messages', () => { const { fieldErrors, generalMessages } = parseAnonymizerApiError( apiError([{ loc: ['body', 'spec', 'model_configs', 0, 'provider'], msg: 'bad provider' }]) diff --git a/web/packages/studio/src/routes/AnonymizerBuilderRoute/apiErrors.ts b/web/packages/studio/src/routes/AnonymizerBuilderRoute/apiErrors.ts index 79cf03523b..4a059fc8b5 100644 --- a/web/packages/studio/src/routes/AnonymizerBuilderRoute/apiErrors.ts +++ b/web/packages/studio/src/routes/AnonymizerBuilderRoute/apiErrors.ts @@ -40,6 +40,14 @@ const fieldForLoc = (loc: (string | number)[]): FormField | null => { if (last === 'algorithm') return 'hashAlgorithm'; if (last === 'normalize_label') return 'redactNormalizeLabel'; } + if (segments.includes('rewrite')) { + if (last === 'protect') return 'privacyProtect'; + if (last === 'preserve') return 'privacyPreserve'; + if (last === 'instructions') return 'rewriteInstructions'; + if (last === 'risk_tolerance') return 'riskTolerance'; + if (last === 'max_repair_iterations') return 'maxRepairRounds'; + if (last === 'strict_entity_protection') return 'strictEntityProtection'; + } if (segments.includes('data')) { if (last === 'source') return 'source'; if (last === 'text_column') return 'textColumn'; diff --git a/web/packages/studio/src/routes/AnonymizerBuilderRoute/components/AnonymizerBuilderForm.tsx b/web/packages/studio/src/routes/AnonymizerBuilderRoute/components/AnonymizerBuilderForm.tsx new file mode 100644 index 0000000000..f4b84f13aa --- /dev/null +++ b/web/packages/studio/src/routes/AnonymizerBuilderRoute/components/AnonymizerBuilderForm.tsx @@ -0,0 +1,156 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { useAnonymizerCreateRunJob } from '@nemo/sdk/generated/anonymizer/api'; +import type { RunJob } from '@nemo/sdk/generated/anonymizer/schema'; +import { + Banner, + Button, + Divider, + Flex, + Panel, + SegmentedControl, + Stack, + Text, +} from '@nvidia/foundations-react-core'; +import { getErrorMessage } from '@studio/api/common/utils'; +import { useWorkspaceFromPath } from '@studio/hooks/useWorkspaceFromPath'; +import { parseAnonymizerApiError } from '@studio/routes/AnonymizerBuilderRoute/apiErrors'; +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 { + buildAnonymizerJobRequest, + type AnonymizerFormData, +} from '@studio/routes/AnonymizerBuilderRoute/schema'; +import { useDefaultRoleModels } from '@studio/routes/AnonymizerBuilderRoute/useDefaultRoleModels'; +import { getWorkspaceAnonymizerRoute, getWorkspaceJobDetailRoute } from '@studio/routes/utils'; +import { useState, type FC } from 'react'; +import { useFormContext } 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 AnonymizerBuilderForm: FC = () => { + const navigate = useNavigate(); + const workspace = useWorkspaceFromPath(); + const form = useFormContext(); + const [activeTab, setActiveTab] = useState(TAB_SOURCE); + const [submitError, setSubmitError] = useState(undefined); + + const { isLoading: isLoadingModels } = useDefaultRoleModels(); + + const createJob = useAnonymizerCreateRunJob({ + mutation: { + onSuccess: (job: RunJob) => + navigate( + job.name + ? getWorkspaceJobDetailRoute(workspace, job.name) + : getWorkspaceAnonymizerRoute(workspace) + ), + onError: (error) => { + const { fieldErrors, generalMessages } = parseAnonymizerApiError(error); + fieldErrors.forEach(({ field, message }) => + form.setError(field, { type: 'server', message }) + ); + if (fieldErrors.length) setActiveTab(TAB_SOURCE); + setSubmitError( + generalMessages.length + ? generalMessages.join(' ') + : fieldErrors.length + ? undefined + : 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/components/GenerationSection.tsx b/web/packages/studio/src/routes/AnonymizerBuilderRoute/components/GenerationSection.tsx index 35ab465932..6894c9b4c9 100644 --- a/web/packages/studio/src/routes/AnonymizerBuilderRoute/components/GenerationSection.tsx +++ b/web/packages/studio/src/routes/AnonymizerBuilderRoute/components/GenerationSection.tsx @@ -6,8 +6,8 @@ import { ControlledTextInput } from '@nemo/common/src/components/form/Controlled import { Stack, Text } from '@nvidia/foundations-react-core'; import { StrategyParamsSection } from '@studio/routes/AnonymizerBuilderRoute/components/StrategyParamsSection'; import { - AVAILABLE_STRATEGY_OPTIONS, STRATEGY_DESCRIPTIONS, + STRATEGY_OPTIONS, } from '@studio/routes/AnonymizerBuilderRoute/constants'; import type { AnonymizerFormData } from '@studio/routes/AnonymizerBuilderRoute/schema'; import { FC } from 'react'; @@ -22,7 +22,7 @@ export const GenerationSection: FC = () => { Generation diff --git a/web/packages/studio/src/routes/AnonymizerBuilderRoute/components/ModelSettingsSection.tsx b/web/packages/studio/src/routes/AnonymizerBuilderRoute/components/ModelSettingsSection.tsx index 5e18c313ee..3b40215eb2 100644 --- a/web/packages/studio/src/routes/AnonymizerBuilderRoute/components/ModelSettingsSection.tsx +++ b/web/packages/studio/src/routes/AnonymizerBuilderRoute/components/ModelSettingsSection.tsx @@ -3,77 +3,25 @@ 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 { useAnonymizerModels } from '@studio/routes/AnonymizerBuilderRoute/useAnonymizerModels'; +import { useMemo, useState, type FC } 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 { control, setValue } = useFormContext(); 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]); + const { items, isLoading, applyModel } = useAnonymizerModels(); return ( diff --git a/web/packages/studio/src/routes/AnonymizerBuilderRoute/components/RewriteParamsSection.tsx b/web/packages/studio/src/routes/AnonymizerBuilderRoute/components/RewriteParamsSection.tsx new file mode 100644 index 0000000000..1eebca0005 --- /dev/null +++ b/web/packages/studio/src/routes/AnonymizerBuilderRoute/components/RewriteParamsSection.tsx @@ -0,0 +1,91 @@ +// 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 { ControlledTextArea } from '@nemo/common/src/components/form/ControlledTextArea'; +import { ControlledTextInput } from '@nemo/common/src/components/form/ControlledTextInput'; +import { FormField, Slider, Stack } from '@nvidia/foundations-react-core'; +import { + PRIVACY_GOAL_MODE_CUSTOM, + PRIVACY_GOAL_MODE_OPTIONS, + REWRITE_MIN_MAX_REPAIR_ROUNDS, + RISK_TOLERANCE_LABELS, + RISK_TOLERANCE_ORDER, +} from '@studio/routes/AnonymizerBuilderRoute/constants'; +import type { AnonymizerFormData } from '@studio/routes/AnonymizerBuilderRoute/schema'; +import type { FC } from 'react'; +import { useController, useFormContext, useWatch } from 'react-hook-form'; + +const formatRiskToleranceStep = (index: number) => + RISK_TOLERANCE_LABELS[RISK_TOLERANCE_ORDER[index]]; + +export const RewriteParamsSection: FC = () => { + const { control } = useFormContext(); + const privacyGoalMode = useWatch({ control, name: 'privacyGoalMode' }); + const { + field: { onChange: onRiskToleranceChange, value: riskTolerance }, + } = useController({ control, name: 'riskTolerance' }); + + return ( + + + + + {privacyGoalMode === PRIVACY_GOAL_MODE_CUSTOM && ( + <> + + + + )} + + + onRiskToleranceChange(RISK_TOLERANCE_ORDER[index])} + /> + + + + + ); +}; diff --git a/web/packages/studio/src/routes/AnonymizerBuilderRoute/components/StrategyParamsSection.tsx b/web/packages/studio/src/routes/AnonymizerBuilderRoute/components/StrategyParamsSection.tsx index 8b038ce9d1..b20adf2b4a 100644 --- a/web/packages/studio/src/routes/AnonymizerBuilderRoute/components/StrategyParamsSection.tsx +++ b/web/packages/studio/src/routes/AnonymizerBuilderRoute/components/StrategyParamsSection.tsx @@ -5,11 +5,13 @@ import { ControlledCheckbox } from '@nemo/common/src/components/form/ControlledC import { ControlledSelect } from '@nemo/common/src/components/form/ControlledSelect'; import { ControlledTextInput } from '@nemo/common/src/components/form/ControlledTextInput'; import { Stack } from '@nvidia/foundations-react-core'; +import { RewriteParamsSection } from '@studio/routes/AnonymizerBuilderRoute/components/RewriteParamsSection'; import { HASH_ALGORITHM_OPTIONS, STRATEGY_ANNOTATE, STRATEGY_HASH, STRATEGY_REDACT, + STRATEGY_REWRITE, } from '@studio/routes/AnonymizerBuilderRoute/constants'; import type { AnonymizerFormData } from '@studio/routes/AnonymizerBuilderRoute/schema'; import { FC } from 'react'; @@ -49,6 +51,10 @@ export const StrategyParamsSection: FC = () => { ); } + if (strategy === STRATEGY_REWRITE) { + return ; + } + if (strategy === STRATEGY_HASH) { return ( diff --git a/web/packages/studio/src/routes/AnonymizerBuilderRoute/constants.ts b/web/packages/studio/src/routes/AnonymizerBuilderRoute/constants.ts index 591d85be04..271a29c46d 100644 --- a/web/packages/studio/src/routes/AnonymizerBuilderRoute/constants.ts +++ b/web/packages/studio/src/routes/AnonymizerBuilderRoute/constants.ts @@ -1,14 +1,16 @@ // SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 +import { RiskTolerance } from '@nemo/sdk/generated/anonymizer/schema'; + 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 SOURCE_TYPE_OPTIONS: { children: string; value: SourceType }[] = [ + { children: 'Dataset', value: SOURCE_TYPE_DATASET }, + { children: 'URL', value: SOURCE_TYPE_URL }, ]; export const STRATEGY_SUBSTITUTE = 'substitute'; @@ -26,18 +28,14 @@ export type Strategy = 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_OPTIONS: { children: string; value: Strategy }[] = [ + { children: 'Substitute', value: STRATEGY_SUBSTITUTE }, + { children: 'Redact', value: STRATEGY_REDACT }, + { children: 'Annotate', value: STRATEGY_ANNOTATE }, + { children: 'Hash', value: STRATEGY_HASH }, + { children: 'Rewrite', value: STRATEGY_REWRITE }, ]; -export const AVAILABLE_STRATEGY_OPTIONS = STRATEGY_OPTIONS.filter( - (option) => option.value !== STRATEGY_REWRITE -); - export const STRATEGY_DESCRIPTIONS: Record = { [STRATEGY_SUBSTITUTE]: 'Replace detected entities with LLM-generated synthetic values for names, cities, dates, etc.', @@ -64,8 +62,36 @@ const HASH_ALGORITHM_LABELS: Record = { sha1: 'SHA-1', md5: 'MD5', }; -export const HASH_ALGORITHM_OPTIONS: { label: string; value: HashAlgorithmOption }[] = - HASH_ALGORITHM_VALUES.map((value) => ({ label: HASH_ALGORITHM_LABELS[value], value })); +export const HASH_ALGORITHM_OPTIONS: { children: string; value: HashAlgorithmOption }[] = + HASH_ALGORITHM_VALUES.map((value) => ({ children: HASH_ALGORITHM_LABELS[value], value })); + +export const PRIVACY_GOAL_MODE_DEFAULT = 'default'; +export const PRIVACY_GOAL_MODE_CUSTOM = 'custom'; + +export type PrivacyGoalMode = typeof PRIVACY_GOAL_MODE_DEFAULT | typeof PRIVACY_GOAL_MODE_CUSTOM; + +export const PRIVACY_GOAL_MODE_OPTIONS: { value: PrivacyGoalMode; children: string }[] = [ + { value: PRIVACY_GOAL_MODE_DEFAULT, children: 'Default' }, + { value: PRIVACY_GOAL_MODE_CUSTOM, children: 'Custom' }, +]; + +export const RISK_TOLERANCE_ORDER = [ + RiskTolerance.minimal, + RiskTolerance.low, + RiskTolerance.moderate, + RiskTolerance.high, +] as const; + +export const RISK_TOLERANCE_LABELS: Record = { + minimal: 'Minimal', + low: 'Low', + moderate: 'Moderate', + high: 'High', +}; + +export const RISK_TOLERANCE_DEFAULT: RiskTolerance = RiskTolerance.low; +export const REWRITE_DEFAULT_MAX_REPAIR_ROUNDS = 3; +export const REWRITE_MIN_MAX_REPAIR_ROUNDS = 0; export const ENTITY_MODE_CUSTOM = 'custom'; export const ENTITY_MODE_AUTO = 'auto'; @@ -120,7 +146,8 @@ export const ROLE_LABELS: Record = { export const GLINER_ROLE = 'entity_detector'; export const activeRolesForStrategy = (strategy: Strategy): string[] => { - if (strategy === STRATEGY_REWRITE) return [...DETECTION_ROLES, ...REWRITE_ROLES]; + // rewrite reuses the replacement generator, so the backend validates that role too + if (strategy === STRATEGY_REWRITE) return [...DETECTION_ROLES, ...REWRITE_ROLES, REPLACE_ROLE]; 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 64f1c1d3bb..dca9e3f27a 100644 --- a/web/packages/studio/src/routes/AnonymizerBuilderRoute/index.tsx +++ b/web/packages/studio/src/routes/AnonymizerBuilderRoute/index.tsx @@ -2,62 +2,19 @@ // SPDX-License-Identifier: Apache-2.0 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 { parseAnonymizerApiError } from '@studio/routes/AnonymizerBuilderRoute/apiErrors'; -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 { AnonymizerBuilderForm } from '@studio/routes/AnonymizerBuilderRoute/components/AnonymizerBuilderForm'; import { anonymizerFormSchema, - buildAnonymizerJobRequest, getAnonymizerFormDefaults, } from '@studio/routes/AnonymizerBuilderRoute/schema'; -import { getWorkspaceAnonymizerRoute, getWorkspaceJobDetailRoute } from '@studio/routes/utils'; -import { FC, useState } from 'react'; +import type { FC } 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' }], }); @@ -68,111 +25,10 @@ export const AnonymizerBuilderRoute: FC | null = ANONYMIZER_ENABLED defaultValues: getAnonymizerFormDefaults(), }); - const createJob = useAnonymizerCreateRunJob({ - mutation: { - onSuccess: (job: RunJob) => - navigate( - job.name - ? getWorkspaceJobDetailRoute(workspace, job.name) - : getWorkspaceAnonymizerRoute(workspace) - ), - onError: (error) => { - const { fieldErrors, generalMessages } = parseAnonymizerApiError(error); - fieldErrors.forEach(({ field, message }) => - form.setError(field, { type: 'server', message }) - ); - if (fieldErrors.length) setActiveTab(TAB_SOURCE); - setSubmitError( - generalMessages.length - ? generalMessages.join(' ') - : fieldErrors.length - ? undefined - : 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 index b6b365b8c8..bc0d63cc26 100644 --- a/web/packages/studio/src/routes/AnonymizerBuilderRoute/schema.test.ts +++ b/web/packages/studio/src/routes/AnonymizerBuilderRoute/schema.test.ts @@ -58,9 +58,65 @@ describe('buildAnonymizerJobRequest', () => { }); }); - it('routes rewrite to config.rewrite', () => { + it('routes rewrite to config.rewrite with the library defaults', () => { const req = buildAnonymizerJobRequest(form({ strategy: 'rewrite' })); - expect(req.spec.config).toEqual({ rewrite: {} }); + expect(req.spec.config).toEqual({ + rewrite: { + risk_tolerance: 'low', + max_repair_iterations: 3, + strict_entity_protection: false, + }, + }); + }); + + it('sends privacy_goal only in custom mode, trimming both fields', () => { + const custom = buildAnonymizerJobRequest( + form({ + strategy: 'rewrite', + privacyGoalMode: 'custom', + privacyProtect: ' patient identifiers ', + privacyPreserve: ' clinical findings ', + }) + ); + expect(custom.spec.config.rewrite?.privacy_goal).toEqual({ + protect: 'patient identifiers', + preserve: 'clinical findings', + }); + + const defaults = buildAnonymizerJobRequest( + form({ strategy: 'rewrite', privacyGoalMode: 'default', privacyProtect: 'ignored' }) + ); + expect(defaults.spec.config.rewrite?.privacy_goal).toBeUndefined(); + }); + + it('omits blank rewrite instructions and carries the tuned params', () => { + const blank = buildAnonymizerJobRequest( + form({ strategy: 'rewrite', rewriteInstructions: ' ' }) + ); + expect(blank.spec.config.rewrite?.instructions).toBeUndefined(); + + const tuned = buildAnonymizerJobRequest( + form({ + strategy: 'rewrite', + rewriteInstructions: ' keep the tone ', + riskTolerance: 'minimal', + maxRepairRounds: 0, + strictEntityProtection: true, + }) + ); + expect(tuned.spec.config.rewrite).toEqual({ + instructions: 'keep the tone', + risk_tolerance: 'minimal', + max_repair_iterations: 0, + strict_entity_protection: true, + }); + }); + + it('drops rewrite params when a replace strategy is selected', () => { + const req = buildAnonymizerJobRequest( + form({ strategy: 'redact', privacyGoalMode: 'custom', privacyProtect: 'names' }) + ); + expect(req.spec.config.rewrite).toBeUndefined(); }); it('trims the source and omits empty optional fields', () => { @@ -107,7 +163,11 @@ describe('buildAnonymizerJobRequest', () => { 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 the replacement generator for rewrite so the backend alias check passes', () => { + const rew = buildAnonymizerJobRequest(form({ strategy: 'rewrite' })).spec.selected_models; + expect(rew?.replace?.[REPLACE_ROLE]).toBe('model-1'); }); it('maps only detection roles for redact/annotate/hash', () => { diff --git a/web/packages/studio/src/routes/AnonymizerBuilderRoute/schema.ts b/web/packages/studio/src/routes/AnonymizerBuilderRoute/schema.ts index 765f3150eb..81b1ed4528 100644 --- a/web/packages/studio/src/routes/AnonymizerBuilderRoute/schema.ts +++ b/web/packages/studio/src/routes/AnonymizerBuilderRoute/schema.ts @@ -5,6 +5,7 @@ import { generateDefaultName } from '@nemo/common/src/utils/generateDefaultName' import type { AnonymizerConfigInput, ModelConfig, + Rewrite, RunJobRequest, SelectedModelsOverrides, } from '@nemo/sdk/generated/anonymizer/schema'; @@ -20,10 +21,16 @@ import { HASH_ALGORITHM_VALUES, HASH_DEFAULT_DIGEST_LENGTH, HASH_DEFAULT_TEMPLATE, + PRIVACY_GOAL_MODE_CUSTOM, + PRIVACY_GOAL_MODE_DEFAULT, REDACT_DEFAULT_TEMPLATE, REPLACE_ROLE, + REWRITE_DEFAULT_MAX_REPAIR_ROUNDS, + REWRITE_MIN_MAX_REPAIR_ROUNDS, REWRITE_ROLES, REWRITE_STRATEGY, + RISK_TOLERANCE_DEFAULT, + RISK_TOLERANCE_ORDER, SOURCE_TYPE_DATASET, STRATEGY_ANNOTATE, STRATEGY_HASH, @@ -58,6 +65,13 @@ export const anonymizerFormSchema = z hashAlgorithm: z.enum(HASH_ALGORITHM_VALUES), hashDigestLength: z.number().int().min(6).max(64), hashTemplate: z.string(), + privacyGoalMode: z.enum([PRIVACY_GOAL_MODE_DEFAULT, PRIVACY_GOAL_MODE_CUSTOM]), + privacyProtect: z.string(), + privacyPreserve: z.string(), + rewriteInstructions: z.string(), + riskTolerance: z.enum(RISK_TOLERANCE_ORDER), + maxRepairRounds: z.number().int().min(REWRITE_MIN_MAX_REPAIR_ROUNDS), + strictEntityProtection: z.boolean(), roleModels: z.record(z.string(), roleModelSchema), }) .superRefine((data, ctx) => { @@ -92,6 +106,13 @@ export const getAnonymizerFormDefaults = (): AnonymizerFormData => ({ hashAlgorithm: HASH_ALGORITHM_DEFAULT, hashDigestLength: HASH_DEFAULT_DIGEST_LENGTH, hashTemplate: HASH_DEFAULT_TEMPLATE, + privacyGoalMode: PRIVACY_GOAL_MODE_DEFAULT, + privacyProtect: '', + privacyPreserve: '', + rewriteInstructions: '', + riskTolerance: RISK_TOLERANCE_DEFAULT, + maxRepairRounds: REWRITE_DEFAULT_MAX_REPAIR_ROUNDS, + strictEntityProtection: false, roleModels: {}, }); @@ -126,9 +147,32 @@ const buildReplaceConfig = (form: AnonymizerFormData): AnonymizerConfigInput['re return replace as AnonymizerConfigInput['replace']; }; +const buildRewriteConfig = (form: AnonymizerFormData): Rewrite => { + const rewrite: Rewrite = { + risk_tolerance: form.riskTolerance, + max_repair_iterations: form.maxRepairRounds, + strict_entity_protection: form.strictEntityProtection, + }; + + const instructions = trimToUndefined(form.rewriteInstructions); + if (instructions) { + rewrite.instructions = instructions; + } + if (form.privacyGoalMode === PRIVACY_GOAL_MODE_CUSTOM) { + rewrite.privacy_goal = { + protect: form.privacyProtect.trim(), + preserve: form.privacyPreserve.trim(), + }; + } + + return rewrite; +}; + export const buildAnonymizerJobRequest = (form: AnonymizerFormData): RunJobRequest => { const config: AnonymizerConfigInput = - form.strategy === REWRITE_STRATEGY ? { rewrite: {} } : { replace: buildReplaceConfig(form) }; + form.strategy === REWRITE_STRATEGY + ? { rewrite: buildRewriteConfig(form) } + : { replace: buildReplaceConfig(form) }; const useCustomLabels = form.entityMode === ENTITY_MODE_CUSTOM && @@ -172,7 +216,8 @@ export const buildAnonymizerJobRequest = (form: AnonymizerFormData): RunJobReque const selectedModels: SelectedModelsOverrides = { detection: toRoleMap(DETECTION_ROLES) }; if (form.strategy === REWRITE_STRATEGY) { selectedModels.rewrite = toRoleMap(REWRITE_ROLES); - } else if (form.strategy === STRATEGY_SUBSTITUTE) { + } + if (form.strategy === REWRITE_STRATEGY || form.strategy === STRATEGY_SUBSTITUTE) { selectedModels.replace = { [REPLACE_ROLE]: aliasForRole[REPLACE_ROLE] }; } diff --git a/web/packages/studio/src/routes/AnonymizerBuilderRoute/useAnonymizerModels.ts b/web/packages/studio/src/routes/AnonymizerBuilderRoute/useAnonymizerModels.ts new file mode 100644 index 0000000000..9aac52a0ce --- /dev/null +++ b/web/packages/studio/src/routes/AnonymizerBuilderRoute/useAnonymizerModels.ts @@ -0,0 +1,56 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { useModelsListProviders } from '@nemo/sdk/generated/platform/api'; +import { + modelsFromProviders, + type DataDesignerModelOption, +} 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 { useCallback, useMemo } from 'react'; +import { useFormContext } from 'react-hook-form'; + +interface AnonymizerModels { + readonly models: DataDesignerModelOption[]; + readonly items: { label: string; value: string }[]; + readonly isLoading: boolean; + readonly applyModel: (role: string, id: string) => void; +} + +/** Workspace models plus the setter that writes a role's pick into the form. */ +export const useAnonymizerModels = (): AnonymizerModels => { + const { 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 applyModel = useCallback( + (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, + }); + }, + [models, setValue] + ); + + return { models, items, isLoading, applyModel }; +}; diff --git a/web/packages/studio/src/routes/AnonymizerBuilderRoute/useDefaultRoleModels.ts b/web/packages/studio/src/routes/AnonymizerBuilderRoute/useDefaultRoleModels.ts new file mode 100644 index 0000000000..e41a690fd7 --- /dev/null +++ b/web/packages/studio/src/routes/AnonymizerBuilderRoute/useDefaultRoleModels.ts @@ -0,0 +1,48 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { + activeRolesForStrategy, + GLINER_ROLE, +} from '@studio/routes/AnonymizerBuilderRoute/constants'; +import type { AnonymizerFormData } from '@studio/routes/AnonymizerBuilderRoute/schema'; +import { useAnonymizerModels } from '@studio/routes/AnonymizerBuilderRoute/useAnonymizerModels'; +import { pickDefaultModelName } from '@studio/util/buildSuggestedModelOptions'; +import { useEffect, useMemo } from 'react'; +import { useFormContext, useWatch } from 'react-hook-form'; + +const isGliner = (name: string) => /gliner/i.test(name); + +/** + * Seeds a model for every role the strategy needs. Lives at the route rather than in + * ModelSettingsSection so the defaults still land when that tab is never opened. + */ +export const useDefaultRoleModels = (): { isLoading: boolean } => { + const { control, setValue, getValues } = useFormContext(); + const strategy = useWatch({ control, name: 'strategy' }); + const { models, isLoading, applyModel } = useAnonymizerModels(); + + const roles = useMemo(() => activeRolesForStrategy(strategy), [strategy]); + + 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 { isLoading }; +};