From ecf3d1fc2db1d93eda24c371e58ca30212b23919 Mon Sep 17 00:00:00 2001 From: mschwab Date: Mon, 27 Jul 2026 09:11:43 -0700 Subject: [PATCH 1/4] feat(studio): Rewrite strategy params [ASTD-334] Unlock Rewrite in the anonymization strategy select and add its parameter UI: privacy goal (default/custom with protect + preserve), LLM instructions, risk tolerance, max repair rounds, and strict entity protection, mapped onto config.rewrite. Rewrite reuses the replacement generator internally, so the backend validates replace.replacement_generator for rewrite jobs too. Without it the request kept the library default alias and preflight failed with "Selected model aliases not found in model pool". Map the replace role for rewrite as well. Also fixes two pre-existing issues surfaced while testing this form: select items carry `children`, not `label`, so triggers were rendering raw values ("rewrite", "dataset"); and the panel's scroll lived on the root, which swept the footer into the scrolling box and floated the Cancel/Full Run buttons over the form. Signed-off-by: mschwab --- .../AnonymizerBuilderRoute/apiErrors.test.ts | 18 ++++ .../AnonymizerBuilderRoute/apiErrors.ts | 8 ++ .../components/GenerationSection.tsx | 4 +- .../components/RewriteParamsSection.tsx | 89 +++++++++++++++++++ .../components/StrategyParamsSection.tsx | 6 ++ .../AnonymizerBuilderRoute/constants.ts | 59 ++++++++---- .../routes/AnonymizerBuilderRoute/index.tsx | 3 +- .../AnonymizerBuilderRoute/schema.test.ts | 66 +++++++++++++- .../routes/AnonymizerBuilderRoute/schema.ts | 49 +++++++++- 9 files changed, 278 insertions(+), 24 deletions(-) create mode 100644 web/packages/studio/src/routes/AnonymizerBuilderRoute/components/RewriteParamsSection.tsx 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/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/RewriteParamsSection.tsx b/web/packages/studio/src/routes/AnonymizerBuilderRoute/components/RewriteParamsSection.tsx new file mode 100644 index 0000000000..70edf9b83e --- /dev/null +++ b/web/packages/studio/src/routes/AnonymizerBuilderRoute/components/RewriteParamsSection.tsx @@ -0,0 +1,89 @@ +// 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 { FC } from 'react'; +import { useController, useFormContext, useWatch } from 'react-hook-form'; + +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 && ( + <> + + + + )} + + {/* end tick labels overhang the track, so inset it to keep them inside the scroll box */} + + RISK_TOLERANCE_LABELS[RISK_TOLERANCE_ORDER[index]]} + value={RISK_TOLERANCE_ORDER.indexOf(riskTolerance)} + onValueChange={(index) => 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..9645087c5b 100644 --- a/web/packages/studio/src/routes/AnonymizerBuilderRoute/index.tsx +++ b/web/packages/studio/src/routes/AnonymizerBuilderRoute/index.tsx @@ -113,9 +113,10 @@ export const AnonymizerBuilderRoute: FC | null = ANONYMIZER_ENABLED
+ + + } + > + + + + {submitError && ( + + {submitError} + + )} + +
+ + + + + + + + + +
+
+ +
+
+ + + + Your records preview will appear here + + +
+ ); +}; diff --git a/web/packages/studio/src/routes/AnonymizerBuilderRoute/components/ModelSettingsSection.tsx b/web/packages/studio/src/routes/AnonymizerBuilderRoute/components/ModelSettingsSection.tsx index 3fc6039dd0..3b40215eb2 100644 --- a/web/packages/studio/src/routes/AnonymizerBuilderRoute/components/ModelSettingsSection.tsx +++ b/web/packages/studio/src/routes/AnonymizerBuilderRoute/components/ModelSettingsSection.tsx @@ -11,7 +11,7 @@ import { } from '@studio/routes/AnonymizerBuilderRoute/constants'; import type { AnonymizerFormData } from '@studio/routes/AnonymizerBuilderRoute/schema'; import { useAnonymizerModels } from '@studio/routes/AnonymizerBuilderRoute/useAnonymizerModels'; -import { FC, useMemo, useState } from 'react'; +import { useMemo, useState, type FC } from 'react'; import { useFormContext, useWatch } from 'react-hook-form'; export const ModelSettingsSection: FC = () => { diff --git a/web/packages/studio/src/routes/AnonymizerBuilderRoute/components/RewriteParamsSection.tsx b/web/packages/studio/src/routes/AnonymizerBuilderRoute/components/RewriteParamsSection.tsx index f172d7afe1..1eebca0005 100644 --- a/web/packages/studio/src/routes/AnonymizerBuilderRoute/components/RewriteParamsSection.tsx +++ b/web/packages/studio/src/routes/AnonymizerBuilderRoute/components/RewriteParamsSection.tsx @@ -14,7 +14,7 @@ import { RISK_TOLERANCE_ORDER, } from '@studio/routes/AnonymizerBuilderRoute/constants'; import type { AnonymizerFormData } from '@studio/routes/AnonymizerBuilderRoute/schema'; -import { FC } from 'react'; +import type { FC } from 'react'; import { useController, useFormContext, useWatch } from 'react-hook-form'; const formatRiskToleranceStep = (index: number) => diff --git a/web/packages/studio/src/routes/AnonymizerBuilderRoute/components/RoleModelDefaults.tsx b/web/packages/studio/src/routes/AnonymizerBuilderRoute/components/RoleModelDefaults.tsx deleted file mode 100644 index 9578b17360..0000000000 --- a/web/packages/studio/src/routes/AnonymizerBuilderRoute/components/RoleModelDefaults.tsx +++ /dev/null @@ -1,11 +0,0 @@ -// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -// SPDX-License-Identifier: Apache-2.0 - -import { useDefaultRoleModels } from '@studio/routes/AnonymizerBuilderRoute/useDefaultRoleModels'; -import { FC } from 'react'; - -/** Seeds role model defaults from inside the form provider, independent of the active tab. */ -export const RoleModelDefaults: FC = () => { - useDefaultRoleModels(); - return null; -}; diff --git a/web/packages/studio/src/routes/AnonymizerBuilderRoute/index.tsx b/web/packages/studio/src/routes/AnonymizerBuilderRoute/index.tsx index ee3db670ec..dca9e3f27a 100644 --- a/web/packages/studio/src/routes/AnonymizerBuilderRoute/index.tsx +++ b/web/packages/studio/src/routes/AnonymizerBuilderRoute/index.tsx @@ -2,65 +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 { RoleModelDefaults } from '@studio/routes/AnonymizerBuilderRoute/components/RoleModelDefaults'; +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' }, -]; - -const PANEL_ATTRIBUTES = { PanelContent: { className: 'flex-1 min-h-0 overflow-auto' } }; 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' }], }); @@ -71,113 +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 - - - +
);