Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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' }])
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
Original file line number Diff line number Diff line change
@@ -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<AnonymizerFormData>();
const [activeTab, setActiveTab] = useState<string>(TAB_SOURCE);
const [submitError, setSubmitError] = useState<string | undefined>(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 (
<form className="h-full" noValidate onSubmit={onSubmit}>
<Flex className="h-full" gap="0">
<Panel
className="w-[400px] h-full"
elevation="high"
density="standard"
attributes={{ PanelContent: { className: 'flex-1 min-h-0 overflow-auto' } }}
slotFooter={
<Flex gap="density-md" justify="end">
<Button
kind="tertiary"
type="button"
disabled={createJob.isPending}
onClick={handleCancel}
>
Cancel
</Button>
<Button
kind="primary"
color="brand"
type="submit"
disabled={createJob.isPending || isLoadingModels}
>
Full Run
</Button>
</Flex>
}
>
<Stack gap="density-2xl">
<SegmentedControl
className="w-full"
value={activeTab}
onValueChange={setActiveTab}
items={PANEL_TABS}
/>

{submitError && (
<Banner kind="inline" status="error">
{submitError}
</Banner>
)}

<div className={activeTab === TAB_SOURCE ? undefined : 'hidden'}>
<Stack gap="density-2xl">
<DataSourceSection />
<Divider orientation="horizontal" width="small" />
<GenerationSection />
<Divider orientation="horizontal" width="small" />
<ColumnsSection />
<Divider orientation="horizontal" width="small" />
<EntitiesSection />
</Stack>
</div>
<div className={activeTab === TAB_MODEL_SETTINGS ? undefined : 'hidden'}>
<ModelSettingsSection />
</div>
</Stack>
</Panel>

<Flex className="flex-1 h-full" align="center" justify="center">
<Text kind="body/regular/md">Your records preview will appear here</Text>
</Flex>
</Flex>
</form>
);
};
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -22,7 +22,7 @@ export const GenerationSection: FC = () => {
<Text kind="label/bold/lg">Generation</Text>
<ControlledSelect
aria-label="Anonymization strategy"
items={AVAILABLE_STRATEGY_OPTIONS}
items={STRATEGY_OPTIONS}
useControllerProps={{ name: 'strategy', control }}
formFieldProps={{ slotLabel: 'Anonymization Strategy', required: true }}
/>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<AnonymizerFormData>();
const workspace = useWorkspaceFromPath();
const { control, setValue } = useFormContext<AnonymizerFormData>();
const strategy = useWatch({ control, name: 'strategy' });
const roleModelsValue = useWatch({ control, name: 'roleModels' });
const [openParamsRole, setOpenParamsRole] = useState<string | null>(null);

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

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

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

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

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

return (
<Stack gap="density-2xl">
Expand Down
Original file line number Diff line number Diff line change
@@ -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<AnonymizerFormData>();
const privacyGoalMode = useWatch({ control, name: 'privacyGoalMode' });
const {
field: { onChange: onRiskToleranceChange, value: riskTolerance },
} = useController({ control, name: 'riskTolerance' });

return (
<Stack gap="density-lg">
<FormField slotLabel="Privacy Goal">
<ControlledSegmentedControl
className="w-full"
size="tiny"
items={PRIVACY_GOAL_MODE_OPTIONS}
useControllerProps={{ name: 'privacyGoalMode', control }}
/>
</FormField>
{privacyGoalMode === PRIVACY_GOAL_MODE_CUSTOM && (
<>
<ControlledTextArea
useControllerProps={{ name: 'privacyProtect', control }}
formFieldProps={{
slotLabel: 'Protect',
slotInfo: 'What to protect, such as direct and quasi-identifiers.',
}}
/>
<ControlledTextArea
useControllerProps={{ name: 'privacyPreserve', control }}
formFieldProps={{
slotLabel: 'Preserve',
slotInfo: 'What to keep intact, such as utility and semantic meaning.',
}}
/>
</>
)}
<ControlledTextArea
useControllerProps={{ name: 'rewriteInstructions', control }}
formFieldProps={{ slotLabel: 'LLM Instructions' }}
/>
<FormField slotLabel="Risk Tolerance">
<Slider
aria-label="Risk tolerance"
className="mb-5 px-6"
orientation="horizontal"
stepPosition="end"
min={0}
max={RISK_TOLERANCE_ORDER.length - 1}
step={1}
stepFormatFn={formatRiskToleranceStep}
value={RISK_TOLERANCE_ORDER.indexOf(riskTolerance)}
onValueChange={(index) => onRiskToleranceChange(RISK_TOLERANCE_ORDER[index])}
/>
</FormField>
<ControlledTextInput
type="number"
min={REWRITE_MIN_MAX_REPAIR_ROUNDS}
useControllerProps={{ name: 'maxRepairRounds', control }}
formFieldProps={{
slotLabel: 'Max Repair Rounds',
slotInfo: 'Repair passes run when leakage exceeds the tolerance. Set to 0 to disable.',
}}
/>
<ControlledCheckbox
slotLabel="Strict Entity Protection. Forces every detected entity to be protected regardless of risk. No entity can be left unchanged."
useControllerProps={{ name: 'strictEntityProtection', control }}
/>
</Stack>
);
};
Loading