Skip to content
Open
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
@@ -0,0 +1,61 @@
// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
// SPDX-License-Identifier: Apache-2.0

import { DATA_DESIGNER_JOB_GENERATOR_SYSTEM_PROMPT } from '@studio/components/NewDataDesignerJobForm/constants';
import type { ChatCompletionMessageParam } from 'openai/resources/index.mjs';

export interface FixRequestInput {
/** The description the user originally wrote, so the retry keeps the same intent. */
prompt: string;
/** The config the model produced last time, verbatim (its tool-call arguments). */
config: string;
/** Issues that block loading; empty when the draft was merely lossy. */
errors: string[];
/** Issues that don't block loading but lost something (skipped columns, substitutions). */
warnings: string[];
}

const bulletList = (items: string[]): string => items.map((item) => `- ${item}`).join('\n');

/**
* Builds the follow-up conversation that asks the model to repair its own draft: the original
* request, the config it returned, and what the builder found wrong with it.
*
* The previous config is replayed as a plain assistant turn rather than a tool call — a
* `tool_calls` message would need a matching `tool` response, which providers enforce
* inconsistently, and the model only needs to see the JSON it wrote.
*/
export const buildFixMessages = ({
prompt,
config,
errors,
warnings,
}: FixRequestInput): ChatCompletionMessageParam[] => {
const sections = [
errors.length > 0
? `Errors — the builder cannot load this config until these are fixed:\n${bulletList(errors)}`
: '',
warnings.length > 0
? `Warnings — the config loads, but something was lost or substituted:\n${bulletList(warnings)}`
: '',
].filter(Boolean);

return [
{ role: 'system', content: DATA_DESIGNER_JOB_GENERATOR_SYSTEM_PROMPT },
{ role: 'user', content: prompt },
{ role: 'assistant', content: config },
{
role: 'user',
content: [
'That config was checked against the visual builder and these issues came back:',
'',
sections.join('\n\n'),
'',
'Call the tool again with a corrected job config. Keep the intent, column names, and',
'anything already working unchanged — change only what is needed to resolve the issues',
'above. If a column type was skipped because the builder cannot edit it, replace it with',
'the closest supported column type rather than dropping the data it produced.',
].join('\n'),
},
];
};
Original file line number Diff line number Diff line change
Expand Up @@ -5,25 +5,30 @@ import { CreateFilesetStart } from '@studio/components/CreateFilesetStart';
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';

const renderStart = () => {
const onContinue = vi.fn();
render(<CreateFilesetStart onContinue={onContinue} />);
return { onContinue };
};

describe('CreateFilesetStart', () => {
it('renders all four start options', () => {
render(<CreateFilesetStart onContinue={vi.fn()} />);
it('renders all start options', () => {
renderStart();

expect(screen.getByText('Describe with AI')).toBeInTheDocument();
expect(screen.getByText('Start from a template')).toBeInTheDocument();
expect(screen.getByText('Build from scratch')).toBeInTheDocument();
});

it('shows no Continue footer until a selectable option is chosen', () => {
render(<CreateFilesetStart onContinue={vi.fn()} />);
it('shows no Continue footer until an option is chosen', () => {
renderStart();

expect(screen.queryByRole('button', { name: /continue/i })).not.toBeInTheDocument();
});

it('does not select disabled options (they are no-ops)', async () => {
const user = userEvent.setup();
const onContinue = vi.fn();
render(<CreateFilesetStart onContinue={onContinue} />);
const { onContinue } = renderStart();

await user.click(screen.getByText('Describe with AI'));

Expand All @@ -33,56 +38,57 @@ describe('CreateFilesetStart', () => {

it('selecting Build from scratch reveals Continue and invokes onContinue with "scratch"', async () => {
const user = userEvent.setup();
const onContinue = vi.fn();
render(<CreateFilesetStart onContinue={onContinue} />);
const { onContinue } = renderStart();

await user.click(screen.getByText('Build from scratch'));

const continueButton = screen.getByRole('button', { name: /continue/i });
expect(continueButton).toBeInTheDocument();
expect(continueButton).toBeEnabled();

await user.click(continueButton);
expect(onContinue).toHaveBeenCalledTimes(1);
expect(onContinue).toHaveBeenCalledWith('scratch');
expect(onContinue).toHaveBeenCalledWith({ optionId: 'scratch' });
});

it('reveals template cards but no Continue until a template is chosen', async () => {
it('reveals template cards but keeps Continue disabled until a template is chosen', async () => {
const user = userEvent.setup();
render(<CreateFilesetStart onContinue={vi.fn()} />);
renderStart();

await user.click(screen.getByText('Start from a template'));

expect(screen.getByText('Instruction fine-tuning (SFT)')).toBeInTheDocument();
expect(screen.queryByRole('button', { name: /continue/i })).not.toBeInTheDocument();
expect(screen.getByRole('button', { name: /continue/i })).toBeDisabled();
expect(screen.getByText('Pick a recipe to continue.')).toBeInTheDocument();
});

it('choosing a template reveals Continue and invokes onContinue with the template id', async () => {
it('choosing a template enables Continue and invokes onContinue with the template id', async () => {
const user = userEvent.setup();
const onContinue = vi.fn();
render(<CreateFilesetStart onContinue={onContinue} />);
const { onContinue } = renderStart();

await user.click(screen.getByText('Start from a template'));
await user.click(screen.getByText('Instruction fine-tuning (SFT)'));

const continueButton = screen.getByRole('button', { name: /continue/i });
await user.click(continueButton);
await user.click(screen.getByRole('button', { name: /continue/i }));

expect(onContinue).toHaveBeenCalledTimes(1);
expect(onContinue).toHaveBeenCalledWith('template', 'sft-instruction');
expect(onContinue).toHaveBeenCalledWith({
optionId: 'template',
templateId: 'sft-instruction',
});
});

it('switching options clears a prior template selection', async () => {
const user = userEvent.setup();
render(<CreateFilesetStart onContinue={vi.fn()} />);
renderStart();

await user.click(screen.getByText('Start from a template'));
await user.click(screen.getByText('Instruction fine-tuning (SFT)'));
expect(screen.getByRole('button', { name: /continue/i })).toBeInTheDocument();
expect(screen.getByRole('button', { name: /continue/i })).toBeEnabled();

await user.click(screen.getByText('Build from scratch'));
await user.click(screen.getByText('Start from a template'));

// Template selection was reset when the option changed, so Continue is gone again.
expect(screen.queryByRole('button', { name: /continue/i })).not.toBeInTheDocument();
// Template selection was reset when the option changed, so Continue is blocked again.
expect(screen.getByRole('button', { name: /continue/i })).toBeDisabled();
});
});
20 changes: 15 additions & 5 deletions web/packages/studio/src/components/CreateFilesetStart/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,11 @@ import type {
import { ArrowRight } from 'lucide-react';
import { useState, type FC } from 'react';

/** Why Continue is unavailable, shown next to the disabled button. */
const BLOCKED_HINT: Partial<Record<StartOptionId, string>> = {
template: 'Pick a recipe to continue.',
};

export const CreateFilesetStart: FC<CreateFilesetStartProps> = ({ onContinue }) => {
const [selectedId, setSelectedId] = useState<StartOptionId | null>(null);
const [selectedTemplateId, setSelectedTemplateId] = useState<string | null>(null);
Expand All @@ -38,9 +43,9 @@ export const CreateFilesetStart: FC<CreateFilesetStartProps> = ({ onContinue })
const handleContinue = () => {
if (!selectedOption) return;
if (selectedOption.id === 'template' && selectedTemplateId) {
onContinue(selectedOption.id, selectedTemplateId);
} else {
onContinue(selectedOption.id);
onContinue({ optionId: 'template', templateId: selectedTemplateId });
} else if (selectedOption.id === 'scratch') {
onContinue({ optionId: 'scratch' });
}
};

Expand Down Expand Up @@ -80,13 +85,18 @@ export const CreateFilesetStart: FC<CreateFilesetStartProps> = ({ onContinue })
</Stack>
</Block>

{canContinue ? (
{selectedOption ? (
<Flex
align="center"
justify="end"
className="shrink-0 gap-4 border-t border-base bg-surface-base px-10 py-4"
>
<Button color="brand" kind="primary" onClick={handleContinue}>
{!canContinue && BLOCKED_HINT[selectedOption.id] ? (
<Text kind="body/regular/sm" className="text-secondary">
{BLOCKED_HINT[selectedOption.id]}
</Text>
) : null}
<Button color="brand" kind="primary" onClick={handleContinue} disabled={!canContinue}>
Continue
<ArrowRight size={16} aria-hidden />
</Button>
Expand Down
10 changes: 5 additions & 5 deletions web/packages/studio/src/components/CreateFilesetStart/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -89,10 +89,10 @@ export interface StartOptionDetailProps {
onSelectTemplate: (templateId: string) => void;
}

/** What the user confirmed via the Continue footer, carrying that option's payload. */
export type StartSelection = { optionId: 'scratch' } | { optionId: 'template'; templateId: string };

export interface CreateFilesetStartProps {
/**
* Fired when the user confirms a selected start option via the Continue footer. For
* the "template" option, the chosen template id is passed as the second argument.
*/
onContinue: (optionId: StartOptionId, templateId?: string) => void;
/** Fired when the user confirms a selected start option via the Continue footer. */
onContinue: (selection: StartSelection) => void;
}
Loading