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

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
// SPDX-License-Identifier: Apache-2.0

import { createEvaluation, filesListFilesetFiles } from '@nemo/sdk/generated/platform/api';
import type { ExperimentResponse } from '@nemo/sdk/generated/platform/schema';
import { buildEvalJobName } from '@studio/components/evaluation/submitEvaluationJob';

/** Experiment metadata key holding the name of the fileset that stores its eval config.
* Metadata values are plain strings, which is all a fileset name needs to be. */
export const EVAL_CONFIG_FILESET_KEY = 'eval_config_fileset';

/** Flat filename the reusable config is stored as inside its fileset. Every Experiment's
* fileset must carry one at the root — there is no per-run file picker. */
export const EVAL_CONFIG_FILENAME = 'eval-config.json';

/** The fileset an Experiment stores its eval config in, or null when it names none. */
export const experimentFilesetName = (experiment: ExperimentResponse): string | null =>
experiment.metadata?.[EVAL_CONFIG_FILESET_KEY] ?? null;

/** Why an Experiment cannot be run against, or null when it can. Two ways to be invalid:
* it names no fileset, or the fileset it names has no eval-config.json at the root. */
export const experimentConfigError = async (
workspace: string,
experiment: ExperimentResponse,
signal?: AbortSignal
): Promise<string | null> => {
const filesetName = experimentFilesetName(experiment);
if (!filesetName) {
return `Experiment "${experiment.name}" has no eval config fileset. Pick another experiment, or create one from a template.`;
}
const files = await filesListFilesetFiles(workspace, filesetName, undefined, signal).catch(
() => null
);
if (!files) {
return `Could not read fileset "${filesetName}" for experiment "${experiment.name}". Pick another experiment.`;
}
const hasConfig = (files.data ?? []).some((file) => file.path === EVAL_CONFIG_FILENAME);
return hasConfig
? null
: `Fileset "${filesetName}" has no ${EVAL_CONFIG_FILENAME} at its root, so experiment "${experiment.name}" cannot be run. Pick another experiment.`;
};

/** Create the Intake Evaluation this run publishes under, returning its **name** —
* which is what ``publication.intake.evaluation_id`` takes (the entity id is not it).
* ``experimentId`` conversely is the Experiment's **id**, not its name. */
export const createRunEvaluation = async (
workspace: string,
{
experimentId,
experimentName,
filesetName,
signal,
}: {
experimentId: string;
experimentName: string;
filesetName: string;
signal?: AbortSignal;
}
): Promise<string> => {
// Same normalisation as the job name, so the pair reads as one run.
const name = buildEvalJobName(experimentName);
await createEvaluation(
workspace,
{ name, experiment_ids: [experimentId], dataset_name: filesetName },
signal
);
return name;
};
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ import {
buildAgentEvalRequestBody,
buildAgentTarget,
buildDatasetAgentTarget,
buildDatasetEvalRequestBody,
type DatasetEvalSpec,
buildEvalJobName,
buildPersistedSpec,
injectJudgeModel,
Expand Down Expand Up @@ -143,6 +145,63 @@ describe('buildAgentEvalRequestBody', () => {
expect(body.spec.labels).toBeUndefined();
expect(body.name).toBeUndefined();
});

it('names the job after the experiment, falling back to the fileset', () => {
const withExperiment = buildAgentEvalRequestBody(persisted(), {
workspace: 'ws-a',
agent: 'a',
filesetName: 'wise-blue-data',
experimentName: 'wise-blue',
});
expect(withExperiment.name).toMatch(/^wise-blue-[a-z0-9]{8}$/);

const filesetOnly = buildAgentEvalRequestBody(persisted(), {
workspace: 'ws-a',
agent: 'a',
filesetName: 'wise-blue-data',
});
expect(filesetOnly.name).toMatch(/^wise-blue-data-[a-z0-9]{8}$/);
});

it('publishes to the named evaluation when one is selected', () => {
const body = buildAgentEvalRequestBody(persisted(), {
workspace: 'ws-a',
agent: 'a',
evaluationId: 'nightly-eval-a3f2',
});
// agent_name is omitted deliberately: the backend derives it from the agent target.
expect(body.spec.publication).toEqual({ intake: { evaluation_id: 'nightly-eval-a3f2' } });
});

it('omits publication entirely when no evaluation is selected', () => {
const body = buildAgentEvalRequestBody(persisted(), { workspace: 'ws-a', agent: 'a' });
expect(body.spec.publication).toBeUndefined();
expect('publication' in body.spec).toBe(false);
});
});

describe('buildDatasetEvalRequestBody', () => {
const datasetConfig: DatasetEvalSpec = {
dataset: [{ prompt: '2+2?' }],
metrics: [metric],
prompt_template: '{{item.prompt}}',
};

it('carries publication through the dataset path too, and omits it otherwise', () => {
const withPublication = buildDatasetEvalRequestBody(
datasetConfig,
{ workspace: 'ws-a', agent: 'a', evaluationId: 'eval-1' },
null
);
expect(withPublication.spec.publication).toEqual({ intake: { evaluation_id: 'eval-1' } });

const without = buildDatasetEvalRequestBody(
datasetConfig,
{ workspace: 'ws-a', agent: 'a' },
null
);
expect(without.spec.publication).toBeUndefined();
});
});

describe('buildEvalJobName', () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,11 +9,16 @@ import { PLATFORM_BASE_URL } from '@studio/constants/environment';
export const CREATE_NEW = '__create_new__';

export const MODE_DEFAULT = 'default';
export const MODE_FILESET = 'fileset';
/** Re-run an existing Experiment, which owns the fileset holding its eval config. */
export const MODE_EXPERIMENT = 'experiment';

/** Suggested name for a new eval-config fileset (e.g. "wise-blue"). */
/** Suggested name for a new experiment (e.g. "wise-blue"). */
export const generateEvalConfigName = (): string => generateDefaultName({ length: 2 });

/** The fileset that stores an experiment's eval config and data artifacts. */
export const filesetNameForExperiment = (experimentName: string): string =>
`${experimentName}-data`;

/** Default parallelism for a submitted eval (Studio default; the config value is a hint). */
export const DEFAULT_MAX_CONCURRENT_TASKS = 1;

Expand Down Expand Up @@ -100,8 +105,25 @@ export interface SubmitSelections {
agent: string;
/** Eval-config fileset name, stored under spec.labels.eval_config_fileset for display. */
filesetName?: string;
/** Experiment this run belongs to; names the job so it reads as one of that experiment's runs. */
experimentName?: string;
/** Name of an existing Intake Evaluation to publish results under. The job fails if it
* names nothing — the worker never creates it. Omitted means the run publishes nowhere. */
evaluationId?: string;
}

/** ``spec.publication`` for a run that asked to publish, or nothing at all. ``agent_name`` is
* left off deliberately: the backend derives it from the agent target. */
const publicationSpec = (evaluationId: string | undefined) =>
evaluationId ? { publication: { intake: { evaluation_id: evaluationId } } } : {};

/** ``{ name }`` for the job, stemmed from the experiment it belongs to and falling back to the
* fileset for a submit that names no experiment. Absent when neither is known. */
const jobName = (selections: SubmitSelections) => {
const stem = selections.experimentName ?? selections.filesetName;
return stem ? { name: buildEvalJobName(stem) } : {};
};

/** Strip an optional ``workspace/`` prefix, returning the bare model/agent name. */
export const bareName = (value: string): string =>
value.includes('/') ? (value.split('/').pop() ?? value) : value;
Expand Down Expand Up @@ -171,12 +193,13 @@ export const buildAgentEvalRequestBody = (
spec: PersistedEvalSpec,
selections: SubmitSelections
) => ({
...(selections.filesetName ? { name: buildEvalJobName(selections.filesetName) } : {}),
...jobName(selections),
spec: {
tasks: spec.tasks,
target: buildAgentTarget(selections.workspace, selections.agent),
max_concurrent_tasks: spec.max_concurrent_tasks ?? DEFAULT_MAX_CONCURRENT_TASKS,
...(selections.filesetName ? { labels: { eval_config_fileset: selections.filesetName } } : {}),
...publicationSpec(selections.evaluationId),
},
});

Expand All @@ -195,7 +218,7 @@ export const buildDatasetEvalRequestBody = (
selections: SubmitSelections,
judgeModel: string | null
) => ({
...(selections.filesetName ? { name: buildEvalJobName(selections.filesetName) } : {}),
...jobName(selections),
spec: {
dataset: spec.dataset,
metrics: judgeModel
Expand All @@ -205,6 +228,7 @@ export const buildDatasetEvalRequestBody = (
prompt_template: spec.prompt_template,
...(spec.field_mapping ? { field_mapping: spec.field_mapping } : {}),
params: AGENT_RUN_PARAMS,
...publicationSpec(selections.evaluationId),
},
});

Expand Down
4 changes: 4 additions & 0 deletions web/packages/studio/src/mocks/handlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import {
mockEvaluationSessionsPage,
mockEvaluationsPage,
mockExperiment,
mockExperimentsPage,
} from '@studio/mocks/intake/experiments';
import {
createMockAnnotation,
Expand Down Expand Up @@ -483,6 +484,9 @@ export const handlers = [
const session = mockSessionById(String(params['sessionId']));
return session ? HttpResponse.json(session) : new HttpResponse(null, { status: 404 });
}),
http.get('*/apis/intake/v2/workspaces/:workspace/experiments', () =>
HttpResponse.json(mockExperimentsPage())
),
http.get('*/apis/intake/v2/workspaces/:workspace/experiments/:name', ({ params }) =>
HttpResponse.json(mockExperiment(String(params['name'])))
),
Expand Down
6 changes: 6 additions & 0 deletions web/packages/studio/src/mocks/intake/experiments.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import type {
EvaluationSessionResponse,
EvaluationSessionResponsesPage,
ExperimentResponse,
ExperimentResponsesPage,
} from '@nemo/sdk/generated/platform/schema';

const WORKSPACE = 'default';
Expand Down Expand Up @@ -37,6 +38,11 @@ export const mockEvaluationsPage = (): EvaluationResponsesPage => ({
data: MOCK_EVALUATION_NAMES.map(mockEvaluation),
});

/** The group the mock evaluations belong to, so a caller resolving experiment_ids finds a name. */
export const mockExperimentsPage = (): ExperimentResponsesPage => ({
data: [{ ...mockExperiment('my-group'), id: 'grp_my-group' }],
});

const mockRun = (
evaluationName: string,
sessionId: string,
Expand Down
Original file line number Diff line number Diff line change
@@ -1,93 +1,49 @@
// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
// SPDX-License-Identifier: Apache-2.0

import { RelativeTime } from '@nemo/common/src/components/RelativeTime';
import { StatusBadge } from '@nemo/common/src/components/StatusBadge';
import { Button, Flex, Stack, Text } from '@nvidia/foundations-react-core';
import {
EVAL_JOB_KIND_LABEL,
evalJobDetailRoute,
type EvalJobRow,
hasMixedEvalKinds,
} from '@studio/api/evaluation/utils';
import { SegmentedControl, Stack } from '@nvidia/foundations-react-core';
import { EvaluationsTable } from '@studio/routes/agents/AgentDetailRoute/evaluations/EvaluationsTable';
import { ExperimentsTable } from '@studio/routes/agents/AgentDetailRoute/evaluations/ExperimentsTable';
import { groupByExperiment } from '@studio/routes/agents/AgentDetailRoute/evaluations/groupByExperiment';
import { DetailPanel } from '@studio/routes/agents/AgentDetailRoute/overview/DetailPanel';
import { getAgentEvaluationsListRoute } from '@studio/routes/utils';
import type { FC } from 'react';
import { Link } from 'react-router';
import type { AgentEvaluationRow } from '@studio/routes/agents/AgentDetailRoute/useAgentDetails';
import { type FC, useMemo, useState } from 'react';

const VIEW_EVALUATIONS = 'evaluations';
const VIEW_EXPERIMENTS = 'experiments';

const VIEW_ITEMS = [
{ value: VIEW_EVALUATIONS, children: 'Evaluations' },
{ value: VIEW_EXPERIMENTS, children: 'Experiments' },
];

interface EvaluationsTabProps {
workspace: string;
evals: EvalJobRow[];
onRunEvaluation: () => void;
evals: AgentEvaluationRow[];
}

/** Recent evaluation jobs for the agent, linking through to each run. */
export const EvaluationsTab: FC<EvaluationsTabProps> = ({ workspace, evals, onRunEvaluation }) => {
const showKind = hasMixedEvalKinds(evals);
/** The agent's published evaluations, either flat or rolled up by experiment. Both views read the
* same evaluations; only the grouping differs, which is why this is a toggle and not a tab. */
export const EvaluationsTab: FC<EvaluationsTabProps> = ({ workspace, evals }) => {
const [view, setView] = useState<string>(VIEW_EVALUATIONS);
const experiments = useMemo(() => groupByExperiment(evals), [evals]);

return (
<DetailPanel
title="Recent evaluations"
flush
slotAction={
<Button kind="secondary" size="small" onClick={onRunEvaluation}>
Run evaluation
</Button>
}
>
{evals.length === 0 ? (
<Stack gap="2" className="p-4">
<Text color="secondary">No evaluation jobs found for this agent.</Text>
<Link to={getAgentEvaluationsListRoute(workspace)} className="text-xs">
View all evaluations →
</Link>
</Stack>
) : (
<Stack gap="0">
{evals.map((job, index) => (
<Link
key={job.id}
to={evalJobDetailRoute(workspace, job)}
className="text-inherit no-underline"
>
<Flex
align="center"
gap="2"
className={`px-4 py-4 hover:bg-surface-hover ${index > 0 ? 'border-t border-base' : ''}`}
>
<Stack gap="1" className="min-w-0 flex-1">
<Flex align="baseline" gap="2" className="min-w-0">
<Text kind="body/semibold/md" className="truncate">
{job.name}
</Text>
{showKind && (
<Text kind="body/regular/sm" color="secondary" className="shrink-0">
({EVAL_JOB_KIND_LABEL[job.kind]})
</Text>
)}
</Flex>
{job.configLabel && (
<Text kind="body/regular/sm" color="secondary" className="truncate">
Eval Config: {job.configLabel}
</Text>
)}
</Stack>
<Stack gap="1" align="end" className="shrink-0">
<StatusBadge status={job.status} />
<Text kind="body/regular/sm" color="secondary">
{job.created_at ? <RelativeTime datetime={job.created_at} /> : '—'}
</Text>
</Stack>
</Flex>
</Link>
))}
<div className="border-t border-base px-4 py-3">
<Link to={getAgentEvaluationsListRoute(workspace)} className="text-xs">
View all evaluations →
</Link>
</div>
</Stack>
)}
<DetailPanel title="Evaluations" flush>
<Stack gap="density-lg" className="p-4">
<SegmentedControl
className="w-fit"
aria-label="Group evaluations"
value={view}
onValueChange={setView}
items={VIEW_ITEMS}
/>
{view === VIEW_EXPERIMENTS ? (
<ExperimentsTable workspace={workspace} experiments={experiments} />
) : (
<EvaluationsTable workspace={workspace} evaluations={evals} />
)}
</Stack>
</DetailPanel>
);
};
Loading
Loading