diff --git a/js/app/src/components/dataset/CreateCodeDatasetEvaluatorSlideover.tsx b/js/app/src/components/dataset/CreateCodeDatasetEvaluatorSlideover.tsx index e10544477fb..928f78b0a8b 100644 --- a/js/app/src/components/dataset/CreateCodeDatasetEvaluatorSlideover.tsx +++ b/js/app/src/components/dataset/CreateCodeDatasetEvaluatorSlideover.tsx @@ -20,12 +20,10 @@ import { import type { CreateCodeDatasetEvaluatorSlideover_createCodeEvaluatorMutation } from "@phoenix/components/dataset/__generated__/CreateCodeDatasetEvaluatorSlideover_createCodeEvaluatorMutation.graphql"; import type { CreateCodeDatasetEvaluatorSlideover_createDatasetCodeEvaluatorMutation } from "@phoenix/components/dataset/__generated__/CreateCodeDatasetEvaluatorSlideover_createDatasetCodeEvaluatorMutation.graphql"; import type { CreateCodeDatasetEvaluatorSlideoverQuery } from "@phoenix/components/dataset/__generated__/CreateCodeDatasetEvaluatorSlideoverQuery.graphql"; +import { createDefaultFreeformOutputConfig } from "@phoenix/components/evaluators/CodeEvaluatorAnnotationSection"; import { mapSandboxConfigOptions } from "@phoenix/components/evaluators/CodeEvaluatorLanguageSandboxFields"; import { getDefaultCodeEvaluatorSource } from "@phoenix/components/evaluators/codeEvaluatorUtils"; -import { - createDefaultFreeformOutputConfig, - EditCodeEvaluatorDialogContent, -} from "@phoenix/components/evaluators/EditCodeEvaluatorDialogContent"; +import { EditCodeEvaluatorDialogContent } from "@phoenix/components/evaluators/EditCodeEvaluatorDialogContent"; import { buildOutputConfigsInput } from "@phoenix/components/evaluators/utils"; import { EvaluatorStoreProvider } from "@phoenix/contexts/EvaluatorContext"; import { useNotifySuccess } from "@phoenix/contexts/NotificationContext"; diff --git a/js/app/src/components/dataset/EditCodeDatasetEvaluatorSlideover.tsx b/js/app/src/components/dataset/EditCodeDatasetEvaluatorSlideover.tsx index fc0b2b67124..aa21b541d17 100644 --- a/js/app/src/components/dataset/EditCodeDatasetEvaluatorSlideover.tsx +++ b/js/app/src/components/dataset/EditCodeDatasetEvaluatorSlideover.tsx @@ -16,11 +16,9 @@ import type { EditCodeDatasetEvaluatorSlideover_createCodeEvaluatorVersionMutati import type { EditCodeDatasetEvaluatorSlideover_datasetEvaluatorQuery } from "@phoenix/components/dataset/__generated__/EditCodeDatasetEvaluatorSlideover_datasetEvaluatorQuery.graphql"; import type { EditCodeDatasetEvaluatorSlideover_patchCodeEvaluatorMutation } from "@phoenix/components/dataset/__generated__/EditCodeDatasetEvaluatorSlideover_patchCodeEvaluatorMutation.graphql"; import type { EditCodeDatasetEvaluatorSlideover_updateDatasetCodeEvaluatorMutation } from "@phoenix/components/dataset/__generated__/EditCodeDatasetEvaluatorSlideover_updateDatasetCodeEvaluatorMutation.graphql"; +import { createDefaultFreeformOutputConfig } from "@phoenix/components/evaluators/CodeEvaluatorAnnotationSection"; import { mapSandboxConfigOptions } from "@phoenix/components/evaluators/CodeEvaluatorLanguageSandboxFields"; -import { - createDefaultFreeformOutputConfig, - EditCodeEvaluatorDialogContent, -} from "@phoenix/components/evaluators/EditCodeEvaluatorDialogContent"; +import { EditCodeEvaluatorDialogContent } from "@phoenix/components/evaluators/EditCodeEvaluatorDialogContent"; import { buildOutputConfigsInput } from "@phoenix/components/evaluators/utils"; import { EvaluatorStoreProvider } from "@phoenix/contexts/EvaluatorContext"; import { useNotifySuccess } from "@phoenix/contexts/NotificationContext"; diff --git a/js/app/src/components/evaluators/CodeAuthoringFields.tsx b/js/app/src/components/evaluators/CodeAuthoringFields.tsx new file mode 100644 index 00000000000..f12af446170 --- /dev/null +++ b/js/app/src/components/evaluators/CodeAuthoringFields.tsx @@ -0,0 +1,82 @@ +import type { ReactNode } from "react"; + +import { Flex } from "@phoenix/components"; +import { CodeEvaluatorAnnotationSection } from "@phoenix/components/evaluators/CodeEvaluatorAnnotationSection"; +import { + CodeEvaluatorLanguageField, + CodeEvaluatorSandboxField, + type SandboxConfigOption, +} from "@phoenix/components/evaluators/CodeEvaluatorLanguageSandboxFields"; +import { CodeEvaluatorSourceEditor } from "@phoenix/components/evaluators/CodeEvaluatorSourceEditor"; +import { EvaluatorSectionHeader } from "@phoenix/components/evaluators/EvaluatorSectionHeader"; +import type { CodeEvaluatorLanguage } from "@phoenix/types"; + +/** + * The "Evaluator Code" section of a code evaluator form: the section header + * hosting the compact language and sandbox pickers, the source editor, and + * the output annotation config. + */ +export const CodeAuthoringFields = ({ + language, + onLanguageChange, + sandboxConfigs, + selectedSandboxConfigId, + onSandboxChange, + sourceCode, + onSourceCodeChange, + isLanguageDisabled = false, + isSandboxRequired = true, + onFieldChange, +}: { + language: CodeEvaluatorLanguage; + onLanguageChange: (language: CodeEvaluatorLanguage) => void; + sandboxConfigs: SandboxConfigOption[]; + selectedSandboxConfigId: string | null; + onSandboxChange: (sandboxConfigId: string | null) => void; + sourceCode: string; + onSourceCodeChange: (sourceCode: string) => void; + isLanguageDisabled?: boolean; + isSandboxRequired?: boolean; + onFieldChange?: () => void; +}): ReactNode => ( + + + { + onFieldChange?.(); + onLanguageChange(nextLanguage); + }} + isDisabled={isLanguageDisabled} + isRequired + hideLabel + /> + { + onFieldChange?.(); + onSandboxChange(sandboxConfigId); + }} + isRequired={isSandboxRequired} + hideLabel + /> + + } + /> + { + onFieldChange?.(); + onSourceCodeChange(nextSourceCode); + }} + /> + + +); diff --git a/js/app/src/components/evaluators/CodeEvaluatorAnnotationSection.tsx b/js/app/src/components/evaluators/CodeEvaluatorAnnotationSection.tsx new file mode 100644 index 00000000000..4b2c086b03a --- /dev/null +++ b/js/app/src/components/evaluators/CodeEvaluatorAnnotationSection.tsx @@ -0,0 +1,238 @@ +import { css } from "@emotion/react"; +import { useEffect } from "react"; + +import { + Flex, + Heading, + Input, + Label, + NumberField, + Text, + TextField, + View, +} from "@phoenix/components"; +import { OptimizationDirectionField } from "@phoenix/components/evaluators/OptimizationDirectionField"; +import { + useEvaluatorStore, + useEvaluatorStoreInstance, +} from "@phoenix/contexts/EvaluatorContext"; +import type { FreeformEvaluatorAnnotationConfig } from "@phoenix/types"; + +export const createDefaultFreeformOutputConfig = ( + name: string +): FreeformEvaluatorAnnotationConfig => ({ + name, + optimizationDirection: "NONE", + threshold: null, + lowerBound: null, + upperBound: null, +}); + +/** + * Heading + bordered card for the evaluator's output annotation config. + */ +export const CodeEvaluatorAnnotationSection = ({ + onChange, +}: { + onChange?: () => void; +} = {}) => { + return ( + + + + Evaluator Annotation + + + Define the annotation that your evaluator will create. Optimization + direction, score range, and threshold apply only when your evaluator + returns a numeric score. + + + + + + + ); +}; + +const OutputConfigSection = ({ onChange }: { onChange?: () => void }) => { + const store = useEvaluatorStoreInstance(); + const outputConfig = useEvaluatorStore((state) => state.outputConfigs[0]); + const setOutputConfigThresholdAtIndex = useEvaluatorStore( + (state) => state.setOutputConfigThresholdAtIndex + ); + const setOutputConfigLowerBoundAtIndex = useEvaluatorStore( + (state) => state.setOutputConfigLowerBoundAtIndex + ); + const setOutputConfigUpperBoundAtIndex = useEvaluatorStore( + (state) => state.setOutputConfigUpperBoundAtIndex + ); + + useEffect(() => { + if (!outputConfig) { + const state = store.getState(); + const name = state.evaluator.name || state.evaluator.globalName; + state.setOutputConfigs([createDefaultFreeformOutputConfig(name)]); + } + }, [outputConfig, store]); + + if (!outputConfig) { + return null; + } + + if ("values" in outputConfig) { + return ( + + + + + + + + + + + {outputConfig.values.map((value, index) => ( + + ))} + + + ); + } + + const threshold = + "threshold" in outputConfig ? (outputConfig.threshold ?? null) : null; + const lowerBound = + "lowerBound" in outputConfig ? (outputConfig.lowerBound ?? null) : null; + const upperBound = + "upperBound" in outputConfig ? (outputConfig.upperBound ?? null) : null; + const optimizationDirection = outputConfig.optimizationDirection; + const isThresholdDisabled = optimizationDirection === "NONE"; + + const thresholdDescription = + optimizationDirection === "MAXIMIZE" + ? "Scores at or above this value display as good; lower scores display as bad." + : optimizationDirection === "MINIMIZE" + ? "Scores at or below this value display as good; higher scores display as bad." + : "Combined with the optimization direction, this is the cutoff used to visually distinguish “good” from “bad” scores."; + + return ( + + + + + + + + { + onChange?.(); + setOutputConfigThresholdAtIndex( + 0, + Number.isNaN(value) ? null : value + ); + }} + isDisabled={isThresholdDisabled} + > + + + {thresholdDescription} + + + + { + onChange?.(); + setOutputConfigLowerBoundAtIndex( + 0, + Number.isNaN(value) ? null : value + ); + }} + > + + + + The lowest score your evaluator is expected to produce. + + + { + onChange?.(); + setOutputConfigUpperBoundAtIndex( + 0, + Number.isNaN(value) ? null : value + ); + }} + > + + + + The highest score your evaluator is expected to produce. + + + + + ); +}; + +const OutputConfigValuesHeader = () => { + return ( +
+ Choice + Score +
+ ); +}; + +const OutputConfigValuesRow = ({ + label, + score, + index, +}: { + label: string; + score: number | null; + index: number; +}) => { + return ( +
+ + + + + + +
+ ); +}; + +const outputConfigValuesGridCSS = css` + width: 100%; + display: grid; + grid-template-columns: 3fr 1fr; + gap: var(--global-dimension-size-100); + align-items: start; +`; diff --git a/js/app/src/components/evaluators/CodeEvaluatorLanguageSandboxFields.tsx b/js/app/src/components/evaluators/CodeEvaluatorLanguageSandboxFields.tsx index 48adcde3f5f..193498a1ff5 100644 --- a/js/app/src/components/evaluators/CodeEvaluatorLanguageSandboxFields.tsx +++ b/js/app/src/components/evaluators/CodeEvaluatorLanguageSandboxFields.tsx @@ -49,6 +49,12 @@ export type CodeEvaluatorLanguageFieldProps = { onChange: (language: CodeEvaluatorLanguage) => void; isDisabled?: boolean; isRequired?: boolean; + /** + * Replaces the visible label with an aria-label, for compact placements + * such as a section header. Note the required indicator renders inside the + * visible label, so hiding it also hides that indicator. + */ + hideLabel?: boolean; }; /** @@ -59,15 +65,17 @@ export const CodeEvaluatorLanguageField = ({ onChange, isDisabled, isRequired, + hideLabel = false, }: CodeEvaluatorLanguageFieldProps) => { return ( - - - - - - {outputConfig.values.map((value, index) => ( - - ))} - - - ); - } - - const threshold = - "threshold" in outputConfig ? (outputConfig.threshold ?? null) : null; - const lowerBound = - "lowerBound" in outputConfig ? (outputConfig.lowerBound ?? null) : null; - const upperBound = - "upperBound" in outputConfig ? (outputConfig.upperBound ?? null) : null; - const optimizationDirection = outputConfig.optimizationDirection; - const isThresholdDisabled = optimizationDirection === "NONE"; - - const thresholdDescription = - optimizationDirection === "MAXIMIZE" - ? "Scores at or above this value display as good; lower scores display as bad." - : optimizationDirection === "MINIMIZE" - ? "Scores at or below this value display as good; higher scores display as bad." - : "Combined with the optimization direction, this is the cutoff used to visually distinguish “good” from “bad” scores."; - - return ( - - - - - - - - { - onChange?.(); - setOutputConfigThresholdAtIndex( - 0, - Number.isNaN(value) ? null : value - ); - }} - isDisabled={isThresholdDisabled} - > - - - {thresholdDescription} - - - - { - onChange?.(); - setOutputConfigLowerBoundAtIndex( - 0, - Number.isNaN(value) ? null : value - ); - }} - > - - - - The lowest score your evaluator is expected to produce. - - - { - onChange?.(); - setOutputConfigUpperBoundAtIndex( - 0, - Number.isNaN(value) ? null : value - ); - }} - > - - - - The highest score your evaluator is expected to produce. - - - - - ); -}; - -const OutputConfigValuesHeader = () => { - return ( -
- Choice - Score -
- ); -}; - -const OutputConfigValuesRow = ({ - label, - score, - index, -}: { - label: string; - score: number | null; - index: number; -}) => { - return ( -
- - - - - - -
- ); -}; - // Validation helper const getCodeEvaluatorValidationError = ({ outputConfigs, @@ -1289,18 +800,6 @@ const fieldsetCSS = css` overflow: hidden; `; -const metadataFormCSS = css` - display: flex; - flex-direction: row; - // The form lives in a splitter-resizable panel, so wrapping keys off the - // fields' flex bases rather than a viewport breakpoint: fields share one - // row while they fit and wrap onto additional rows as the panel narrows. - flex-wrap: wrap; - align-items: flex-start; - gap: var(--global-dimension-size-150); - flex-shrink: 0; -`; - const panelStyle = { height: "100%", display: "flex", @@ -1334,14 +833,6 @@ const sidebarPanelCSS = css` border-left: 1px solid var(--global-border-color-default); `; -const outputConfigValuesGridCSS = css` - width: 100%; - display: grid; - grid-template-columns: 3fr 1fr; - gap: var(--global-dimension-size-100); - align-items: start; -`; - // The "Test Evaluator" region grows to fill the panel and scrolls on overflow. const sidebarScrollAreaCSS = css` flex: 1 1 auto; @@ -1367,72 +858,3 @@ const sectionContentCSS = css` padding: var(--global-dimension-size-50) 0; padding-bottom: var(--global-dimension-size-150); `; - -const editorContainerCSS = css` - display: flex; - flex-direction: column; - min-height: 500px; - border: 1px solid var(--global-border-color-default); - border-radius: var(--global-rounding-medium); - overflow: hidden; - background-color: var(--code-mirror-editor-background-color); -`; - -const editorPanelStyle = { - display: "flex", - flexDirection: "column" as const, - minHeight: 0, - overflow: "hidden" as const, -}; - -const cmLineNumberGutterCSS = css` - & .cm-gutter.cm-lineNumbers .cm-gutterElement { - min-width: 2.25em; - box-sizing: border-box; - } -`; - -const editorWrapCSS = css` - flex: 1; - min-height: 0; - overflow: hidden; - display: flex; - flex-direction: column; - - & .cm-theme { - height: 100% !important; - } - - & .cm-editor { - height: 100% !important; - } - - & .cm-scroller { - overflow: auto !important; - } -`; - -const typeFooterCSS = css` - flex: 1; - min-height: 0; - overflow: hidden; - display: flex; - flex-direction: column; - - & .cm-theme { - height: 100% !important; - } - - & .cm-editor { - height: 100% !important; - background-color: var(--global-color-gray-100); - } - - & .cm-gutters { - background-color: var(--global-color-gray-100); - } - - & .cm-scroller { - overflow: auto !important; - } -`; diff --git a/js/app/src/components/evaluators/EvaluatorNameAndDescriptionFields.tsx b/js/app/src/components/evaluators/EvaluatorNameAndDescriptionFields.tsx index b174eb55703..137e7a13029 100644 --- a/js/app/src/components/evaluators/EvaluatorNameAndDescriptionFields.tsx +++ b/js/app/src/components/evaluators/EvaluatorNameAndDescriptionFields.tsx @@ -8,13 +8,25 @@ import { EvaluatorNameInput } from "@phoenix/components/evaluators/EvaluatorName */ export const EvaluatorNameAndDescriptionFields = ({ onValueChange, + isNameRequired = false, + descriptionPlaceholder, }: { onValueChange?: () => void; + /** Marks the name input as required for form submission. */ + isNameRequired?: boolean; + /** Overrides the description input's default example placeholder. */ + descriptionPlaceholder?: string; } = {}) => ( - - + + ); diff --git a/js/app/src/components/evaluators/EvaluatorSectionHeader.tsx b/js/app/src/components/evaluators/EvaluatorSectionHeader.tsx new file mode 100644 index 00000000000..3fb963e6c6c --- /dev/null +++ b/js/app/src/components/evaluators/EvaluatorSectionHeader.tsx @@ -0,0 +1,35 @@ +import type { ReactNode } from "react"; + +import { Flex, Heading, Text } from "@phoenix/components"; + +/** The heading and description an evaluator form section opens with. */ +export const EvaluatorSectionHeader = ({ + title, + description, + extra, +}: { + title: string; + description: string; + /** Controls rendered on the right side of the header, e.g. compact fields. */ + extra?: ReactNode; +}) => ( + // The row wraps so the extra controls drop onto their own line instead of + // forcing a horizontal scrollbar when the host panel is resized narrow. + + + + {title} + + + {description} + + + {extra} + +); diff --git a/js/app/src/components/evaluators/__tests__/codeEvaluatorUtils.test.ts b/js/app/src/components/evaluators/__tests__/codeEvaluatorUtils.test.ts index ca2b71b772b..ce9c976ec05 100644 --- a/js/app/src/components/evaluators/__tests__/codeEvaluatorUtils.test.ts +++ b/js/app/src/components/evaluators/__tests__/codeEvaluatorUtils.test.ts @@ -1,8 +1,33 @@ import { extractCodeEvaluatorVariables, extractRequiredCodeEvaluatorVariables, + getDefaultCodeEvaluatorSource, + getNextCodeEvaluatorSource, } from "../codeEvaluatorUtils"; +describe("getNextCodeEvaluatorSource", () => { + it("swaps a generated placeholder for the next language's placeholder", () => { + expect( + getNextCodeEvaluatorSource({ + sourceCode: getDefaultCodeEvaluatorSource("PYTHON"), + language: "PYTHON", + nextLanguage: "TYPESCRIPT", + }) + ).toEqual(getDefaultCodeEvaluatorSource("TYPESCRIPT")); + }); + + it("never overwrites user-authored code", () => { + const sourceCode = "def evaluate(output):\n return 1.0\n"; + expect( + getNextCodeEvaluatorSource({ + sourceCode, + language: "PYTHON", + nextLanguage: "TYPESCRIPT", + }) + ).toEqual(sourceCode); + }); +}); + describe("code evaluator variable extraction", () => { it.each([ { diff --git a/js/app/src/components/evaluators/codeEvaluatorUtils.ts b/js/app/src/components/evaluators/codeEvaluatorUtils.ts index 6aaf92fec40..bd893d11d83 100644 --- a/js/app/src/components/evaluators/codeEvaluatorUtils.ts +++ b/js/app/src/components/evaluators/codeEvaluatorUtils.ts @@ -38,6 +38,26 @@ export function getAllGeneratedSources( return [getDefaultCodeEvaluatorSource(language)]; } +/** + * The source code a language switch should land on: the default placeholder + * for the next language when the current source is still a generated + * placeholder, otherwise the current source untouched — never overwrite + * user-authored code. + */ +export function getNextCodeEvaluatorSource({ + sourceCode, + language, + nextLanguage, +}: { + sourceCode: string; + language: CodeEvaluatorLanguage; + nextLanguage: CodeEvaluatorLanguage; +}): string { + return getAllGeneratedSources(language).includes(sourceCode) + ? getDefaultCodeEvaluatorSource(nextLanguage) + : sourceCode; +} + export const extractCodeEvaluatorVariables = ({ language, sourceCode, diff --git a/js/app/src/pages/project/evaluators/CreateProjectCodeEvaluatorDialogContent.tsx b/js/app/src/pages/project/evaluators/CreateProjectCodeEvaluatorDialogContent.tsx index 37c73d3ef6a..f8347e19352 100644 --- a/js/app/src/pages/project/evaluators/CreateProjectCodeEvaluatorDialogContent.tsx +++ b/js/app/src/pages/project/evaluators/CreateProjectCodeEvaluatorDialogContent.tsx @@ -1,4 +1,4 @@ -import { type ReactNode, useState } from "react"; +import { useState } from "react"; import { graphql, useLazyLoadQuery, @@ -6,26 +6,18 @@ import { useRelayEnvironment, } from "react-relay"; -import { Alert, Flex, LinkButton } from "@phoenix/components"; +import { Alert, LinkButton } from "@phoenix/components"; import { useTimeRange } from "@phoenix/components/datetime"; +import { CodeAuthoringFields } from "@phoenix/components/evaluators/CodeAuthoringFields"; +import { mapSandboxConfigOptions } from "@phoenix/components/evaluators/CodeEvaluatorLanguageSandboxFields"; import { - CodeEvaluatorLanguageField, - CodeEvaluatorSandboxField, - mapSandboxConfigOptions, -} from "@phoenix/components/evaluators/CodeEvaluatorLanguageSandboxFields"; -import { - getAllGeneratedSources, - getDefaultCodeEvaluatorSource, extractCodeEvaluatorVariables, extractRequiredCodeEvaluatorVariables, + getDefaultCodeEvaluatorSource, + getNextCodeEvaluatorSource, } from "@phoenix/components/evaluators/codeEvaluatorUtils"; -import { - CodeEvaluatorAnnotationSection, - CodeEvaluatorSourceEditor, -} from "@phoenix/components/evaluators/EditCodeEvaluatorDialogContent"; import { EvaluatorFormDialogContent } from "@phoenix/components/evaluators/EvaluatorFormDialogContent"; import { CodeEvaluatorInputVariablesProvider } from "@phoenix/components/evaluators/EvaluatorInputVariablesContext/CodeEvaluatorInputVariablesProvider"; -import { EvaluatorNameAndDescriptionFields } from "@phoenix/components/evaluators/EvaluatorNameAndDescriptionFields"; import { buildOutputConfigsInput, getOutputConfigValidationErrors, @@ -147,10 +139,12 @@ export const CreateProjectCodeEvaluatorDialogContent = ({ } `); + const clearValidationMessage = () => setValidationMessage(undefined); + const handleLanguageChange = (nextLanguage: CodeEvaluatorLanguage) => { - if (getAllGeneratedSources(language).includes(sourceCode)) { - setSourceCode(getDefaultCodeEvaluatorSource(nextLanguage)); - } + setSourceCode( + getNextCodeEvaluatorSource({ sourceCode, language, nextLanguage }) + ); setLanguage(nextLanguage); }; @@ -263,6 +257,11 @@ export const CreateProjectCodeEvaluatorDialogContent = ({ )} left={ setValidationMessage(undefined)} + onFieldChange={clearValidationMessage} /> } /> @@ -281,8 +280,6 @@ export const CreateProjectCodeEvaluatorDialogContent = ({ ); }; - -export const CodeAuthoringFields = ({ - language, - onLanguageChange, - sandboxConfigs, - selectedSandboxConfigId, - onSandboxChange, - sourceCode, - onSourceCodeChange, - isLanguageDisabled = false, - onFieldChange, -}: { - language: CodeEvaluatorLanguage; - onLanguageChange: (language: CodeEvaluatorLanguage) => void; - sandboxConfigs: Parameters< - typeof CodeEvaluatorSandboxField - >[0]["sandboxConfigs"]; - selectedSandboxConfigId: string | null; - onSandboxChange: (sandboxConfigId: string | null) => void; - sourceCode: string; - onSourceCodeChange: (sourceCode: string) => void; - isLanguageDisabled?: boolean; - onFieldChange?: () => void; -}): ReactNode => ( - - - - { - onFieldChange?.(); - onLanguageChange(nextLanguage); - }} - isDisabled={isLanguageDisabled} - isRequired - /> - { - onFieldChange?.(); - onSandboxChange(sandboxConfigId); - }} - isRequired - /> - - { - onFieldChange?.(); - onSourceCodeChange(nextSourceCode); - }} - /> - - -); diff --git a/js/app/src/pages/project/evaluators/CreateProjectEvaluatorSlideover.tsx b/js/app/src/pages/project/evaluators/CreateProjectEvaluatorSlideover.tsx index 9a95859e3cc..aa250ef21b2 100644 --- a/js/app/src/pages/project/evaluators/CreateProjectEvaluatorSlideover.tsx +++ b/js/app/src/pages/project/evaluators/CreateProjectEvaluatorSlideover.tsx @@ -5,7 +5,7 @@ import invariant from "tiny-invariant"; import type { EvaluatorSubmitResult } from "@phoenix/agent/tools/llmEvaluatorDraft"; import { useTimeRange } from "@phoenix/components/datetime"; -import { createDefaultFreeformOutputConfig } from "@phoenix/components/evaluators/EditCodeEvaluatorDialogContent"; +import { createDefaultFreeformOutputConfig } from "@phoenix/components/evaluators/CodeEvaluatorAnnotationSection"; import { EditLLMEvaluatorDialogContent } from "@phoenix/components/evaluators/EditLLMEvaluatorDialogContent"; import { getSpanEvaluatorDefaultMessages } from "@phoenix/components/evaluators/EvaluatorChatTemplate/utils"; import { EvaluatorPlaygroundProvider } from "@phoenix/components/evaluators/EvaluatorPlaygroundProvider"; @@ -378,6 +378,7 @@ function AttachCodeProjectEvaluatorDialog({ onScopeChange={onScopeChange} isSubmitting={isAddingCodeEvaluator} error={error} + onFieldChange={() => setError(undefined)} onSubmit={() => { setError(undefined); addCodeEvaluator({ @@ -568,11 +569,7 @@ const ScratchLlmDialogContent = ({ /> } formRightPanel={ - + } /> ); diff --git a/js/app/src/pages/project/evaluators/EditProjectEvaluatorSlideover.tsx b/js/app/src/pages/project/evaluators/EditProjectEvaluatorSlideover.tsx index ed10fb60d16..ad5bd0ae4d4 100644 --- a/js/app/src/pages/project/evaluators/EditProjectEvaluatorSlideover.tsx +++ b/js/app/src/pages/project/evaluators/EditProjectEvaluatorSlideover.tsx @@ -5,6 +5,7 @@ import { useRevalidator } from "react-router"; import invariant from "tiny-invariant"; import type { EvaluatorSubmitResult } from "@phoenix/agent/tools/llmEvaluatorDraft"; +import { CodeAuthoringFields } from "@phoenix/components/evaluators/CodeAuthoringFields"; import type { SandboxConfigOption } from "@phoenix/components/evaluators/CodeEvaluatorLanguageSandboxFields"; import { mapSandboxConfigOptions } from "@phoenix/components/evaluators/CodeEvaluatorLanguageSandboxFields"; import { @@ -28,7 +29,6 @@ import { import type { EditProjectEvaluatorSlideoverQuery } from "@phoenix/pages/project/evaluators/__generated__/EditProjectEvaluatorSlideoverQuery.graphql"; import type { EditProjectEvaluatorSlideoverUpdateCodeMutation } from "@phoenix/pages/project/evaluators/__generated__/EditProjectEvaluatorSlideoverUpdateCodeMutation.graphql"; import type { EditProjectEvaluatorSlideoverUpdateLlmMutation } from "@phoenix/pages/project/evaluators/__generated__/EditProjectEvaluatorSlideoverUpdateLlmMutation.graphql"; -import { CodeAuthoringFields } from "@phoenix/pages/project/evaluators/CreateProjectCodeEvaluatorDialogContent"; import { ProjectCodeEvaluatorDialogContent } from "@phoenix/pages/project/evaluators/ProjectCodeEvaluatorDialogContent"; import { ProjectLlmEvaluatorFormSections } from "@phoenix/pages/project/evaluators/ProjectEvaluatorFormSections"; import { convertProjectEvaluatorOutputConfigs } from "@phoenix/pages/project/evaluators/projectEvaluatorOptions"; @@ -457,7 +457,6 @@ function EditLlmProjectEvaluatorContent({ } /> @@ -582,6 +581,7 @@ function EditCodeProjectEvaluator({ onScopeChange={setScope} isSubmitting={isUpdating} error={error} + onFieldChange={() => setError(undefined)} onSubmit={() => { setError(undefined); const state = store.getState(); diff --git a/js/app/src/pages/project/evaluators/ProjectCodeEvaluatorDialogContent.tsx b/js/app/src/pages/project/evaluators/ProjectCodeEvaluatorDialogContent.tsx index 93fc15bc2d8..03aec238ced 100644 --- a/js/app/src/pages/project/evaluators/ProjectCodeEvaluatorDialogContent.tsx +++ b/js/app/src/pages/project/evaluators/ProjectCodeEvaluatorDialogContent.tsx @@ -21,6 +21,7 @@ export const ProjectCodeEvaluatorDialogContent = ({ inlineCode, scope, onScopeChange, + onFieldChange, onSubmit, isSubmitting, error, @@ -38,6 +39,8 @@ export const ProjectCodeEvaluatorDialogContent = ({ inlineCode?: ProjectEvaluatorInlineCode; scope: ProjectEvaluatorScope; onScopeChange: (scope: ProjectEvaluatorScope) => void; + /** Fires on any form-section edit, so stale error banners can be cleared. */ + onFieldChange?: () => void; onSubmit: () => void; isSubmitting: boolean; error?: string; @@ -65,20 +68,23 @@ export const ProjectCodeEvaluatorDialogContent = ({ )} left={ } right={ } /> diff --git a/js/app/src/pages/project/evaluators/ProjectEvaluatorFormSections.tsx b/js/app/src/pages/project/evaluators/ProjectEvaluatorFormSections.tsx index 68c647c2f12..2b99d8f73cd 100644 --- a/js/app/src/pages/project/evaluators/ProjectEvaluatorFormSections.tsx +++ b/js/app/src/pages/project/evaluators/ProjectEvaluatorFormSections.tsx @@ -1,26 +1,14 @@ -import type { ReactNode } from "react"; +import { memo, type ReactNode } from "react"; -import { Flex, Heading, Text, View } from "@phoenix/components"; +import { Flex, Heading, View } from "@phoenix/components"; import { EvaluatorNameAndDescriptionFields } from "@phoenix/components/evaluators/EvaluatorNameAndDescriptionFields"; +import { EvaluatorSectionHeader } from "@phoenix/components/evaluators/EvaluatorSectionHeader"; import { LLMEvaluatorForm } from "@phoenix/components/evaluators/LLMEvaluatorForm"; import { ProjectEvaluatorScopeFieldGroup } from "@phoenix/pages/project/evaluators/ProjectEvaluatorScopeFields"; import type { ProjectEvaluatorScope } from "@phoenix/pages/project/evaluators/projectEvaluatorTypes"; -/** - * The left definition panel for an LLM project evaluator; the matching-span - * preview lives in {@link ProjectEvaluatorScopePanel}. - * - * The layout mirrors the dataset evaluator form: name and description, then - * target, sampling, and the span filter, then the prompt with the annotation - * config below it. - */ -export const ProjectLlmEvaluatorFormSections = ({ - projectId, - scope, - onScopeChange, - onFilterValidityChange, - isTargetDisabled = false, -}: { +/** Scope-editing props shared by every left definition panel. */ +type ProjectEvaluatorScopeProps = { /** The span filter autocompletes against this project's spans. */ projectId: string; /** Target, sampling, and the span filter render below the name. */ @@ -28,71 +16,106 @@ export const ProjectLlmEvaluatorFormSections = ({ onScopeChange: (scope: ProjectEvaluatorScope) => void; onFilterValidityChange?: (isValid: boolean) => void; isTargetDisabled?: boolean; -}) => { - return ( - <> - +}; + +/** + * The "Evaluator Scope" section every project evaluator form renders under + * the name and description: target, sampling, and the record filter. + */ +const ProjectEvaluatorScopeSection = memo( + function ProjectEvaluatorScopeSection({ + projectId, + scope, + onScopeChange, + onFilterValidityChange, + isTargetDisabled, + }: ProjectEvaluatorScopeProps) { + return ( - - - Evaluator Scope - - - {scope.targetType === "SESSION" + - + : "Select which spans this evaluator runs on and how often." + } + /> + ); + } +); + +/** + * The left definition panel for an LLM project evaluator; the matching-record + * test preview lives in {@link ProjectEvaluatorScopePanel}. + * + * The layout mirrors the dataset evaluator form: name and description, then + * target, sampling, and the span filter, then the prompt with the annotation + * config below it. + */ +export const ProjectLlmEvaluatorFormSections = ( + scopeProps: ProjectEvaluatorScopeProps +) => { + return ( + <> + + ); }; /** - * The left definition panel for a code project evaluator: either the code - * authoring fields, or a summary of the existing evaluator being attached. + * The left definition panel for a code project evaluator, laid out the same + * way as {@link ProjectLlmEvaluatorFormSections}: name and description, then + * the scope, then the definition — either the code authoring fields, or a + * summary of the existing evaluator being attached. */ export const ProjectCodeEvaluatorFormSections = ({ codeEvaluatorName, codeDefinition, -}: { + onFieldChange, + ...scopeProps +}: ProjectEvaluatorScopeProps & { codeEvaluatorName?: string; - /** Rendered in the definition section for an editable code evaluator. */ + /** Rendered as the definition section for an editable code evaluator. */ codeDefinition?: ReactNode; + /** Fires when the name or description changes. */ + onFieldChange?: () => void; }) => { return ( - - - - Evaluator - - {codeDefinition - ? "Define your evaluator's source code and annotation output." - : "Attach the selected code evaluator to this project."} - - - {codeDefinition ?? ( - - {codeEvaluatorName} - - )} - - + <> + {codeDefinition ? ( + + ) : ( + + + + + {codeEvaluatorName} + + + + )} + + {codeDefinition} + ); }; diff --git a/js/app/src/pages/project/evaluators/ProjectEvaluatorScopeFields.tsx b/js/app/src/pages/project/evaluators/ProjectEvaluatorScopeFields.tsx index ccc30575ea5..cec2383dd11 100644 --- a/js/app/src/pages/project/evaluators/ProjectEvaluatorScopeFields.tsx +++ b/js/app/src/pages/project/evaluators/ProjectEvaluatorScopeFields.tsx @@ -1,5 +1,4 @@ import { css } from "@emotion/react"; -import type { ReactNode } from "react"; import { Suspense, useState } from "react"; import { @@ -12,9 +11,11 @@ import { SliderNumberField, Text, } from "@phoenix/components"; +import { useEvaluatorStoreInstance } from "@phoenix/contexts/EvaluatorContext"; import { isProjectEvaluatorTarget, MIN_EVALUATION_DELAY_SECONDS, + toEvaluatorMappingSourceGrain, toProjectEvaluatorSamplingFraction, type ProjectEvaluatorScope, type ProjectEvaluatorTarget, @@ -30,36 +31,35 @@ import { type SpanFilterValidConditionArgs, } from "@phoenix/pages/project/SpanFilterConditionField"; -/** - * The target, sampling, delay, and filter fields wired to a scope object. - * `children` renders additional fields at the end of the first row, after - * every setting the scope persists. - */ +/** The target, sampling, delay, and filter fields wired to a scope object. */ export const ProjectEvaluatorScopeFieldGroup = ({ projectId, scope, onScopeChange, onFilterValidityChange, isTargetDisabled = false, - fillSampling = false, - children, }: { projectId: string; scope: ProjectEvaluatorScope; onScopeChange: (scope: ProjectEvaluatorScope) => void; onFilterValidityChange?: (isValid: boolean) => void; isTargetDisabled?: boolean; - /** Grow the sampling slider to fill the row. */ - fillSampling?: boolean; - children?: ReactNode; }) => { const isSessionTarget = scope.targetType === "SESSION"; + const evaluatorStore = useEvaluatorStoreInstance(); // Spans and sessions are filtered in different languages, so a condition // written for one target cannot carry over to the other. const handleTargetChange = (targetType: ProjectEvaluatorTarget) => { if (targetType === scope.targetType) { return; } + // Span and session contexts are structurally identical, so the store + // cannot infer the grain from one; changing the target has to say so. + evaluatorStore + .getState() + .setEvaluatorMappingSourceGrain( + toEvaluatorMappingSourceGrain(targetType) + ); onScopeChange({ ...scope, targetType, filterCondition: "" }); }; return ( @@ -73,7 +73,7 @@ export const ProjectEvaluatorScopeFieldGroup = ({ onScopeChange({ ...scope, samplingRate })} /> @@ -85,7 +85,6 @@ export const ProjectEvaluatorScopeFieldGroup = ({ } /> ) : null} - {children} {isSessionTarget ? ( diff --git a/js/app/src/pages/project/evaluators/ProjectEvaluatorScopePanel.tsx b/js/app/src/pages/project/evaluators/ProjectEvaluatorScopePanel.tsx index 388ad89de59..6424b145935 100644 --- a/js/app/src/pages/project/evaluators/ProjectEvaluatorScopePanel.tsx +++ b/js/app/src/pages/project/evaluators/ProjectEvaluatorScopePanel.tsx @@ -1,7 +1,7 @@ import { css } from "@emotion/react"; -import type { ComponentProps } from "react"; import { Suspense, + useDeferredValue, useEffect, useEffectEvent, useMemo, @@ -40,6 +40,7 @@ import { AnnotationPreviewPopoverButton, AnnotationPreviewSkeletonCard, } from "@phoenix/components/evaluators/EvaluatorOutputPreview"; +import { EvaluatorSectionHeader } from "@phoenix/components/evaluators/EvaluatorSectionHeader"; import { buildOutputConfigsInput, createLLMEvaluatorPayload, @@ -62,10 +63,8 @@ import type { import type { ProjectEvaluatorScopePanelSessionCountQuery } from "@phoenix/pages/project/evaluators/__generated__/ProjectEvaluatorScopePanelSessionCountQuery.graphql"; import type { ProjectEvaluatorScopePanelSessionsQuery } from "@phoenix/pages/project/evaluators/__generated__/ProjectEvaluatorScopePanelSessionsQuery.graphql"; import type { ProjectEvaluatorScopePanelSpansQuery } from "@phoenix/pages/project/evaluators/__generated__/ProjectEvaluatorScopePanelSpansQuery.graphql"; -import { ProjectEvaluatorScopeFieldGroup } from "@phoenix/pages/project/evaluators/ProjectEvaluatorScopeFields"; import { getProjectEvaluatorMappingDiagnostics, - toEvaluatorMappingSourceGrain, type ProjectEvaluatorScope, } from "@phoenix/pages/project/evaluators/projectEvaluatorTypes"; import { getSampleSpanEvaluationContext } from "@phoenix/pages/project/evaluators/sampleSpanEvaluationContext"; @@ -141,45 +140,37 @@ function makeTimeWindow(presetId: TimeWindowPresetId): TimeWindow { }; } -type ProjectEvaluatorScopePanelScopeFieldsProps = - | { - /** Target, sampling, and the span filter render in this panel. */ - showScopeFields?: true; - onScopeChange: (scope: ProjectEvaluatorScope) => void; - onFilterValidityChange?: (isValid: boolean) => void; - isTargetDisabled?: boolean; - } - | { - /** - * The scope fields render in the definition panel instead; the panel - * starts at the matching-span preview and edits no scope. - */ - showScopeFields: false; - }; - -/** Scope is committed by the form's create/save action, not by this panel. */ -export const ProjectEvaluatorScopePanel = ( - props: { - projectId: string; - scope: ProjectEvaluatorScope; - codeEvaluatorId?: string; - inlineCode?: ProjectEvaluatorInlineCode; - requiredVariables?: string[]; - } & ProjectEvaluatorScopePanelScopeFieldsProps -) => { - const { projectId, scope, codeEvaluatorId, inlineCode, requiredVariables } = - props; +/** + * The scope fields render in the definition panel; this panel previews the + * matching records and tests the evaluator against them. + */ +export const ProjectEvaluatorScopePanel = ({ + projectId, + scope, + codeEvaluatorId, + inlineCode, + requiredVariables, +}: { + projectId: string; + scope: ProjectEvaluatorScope; + codeEvaluatorId?: string; + inlineCode?: ProjectEvaluatorInlineCode; + requiredVariables?: string[]; +}) => { const [timeWindow, setTimeWindow] = useState(() => makeTimeWindow("7d")); const isSessionTarget = scope.targetType === "SESSION"; - // Span and session contexts are structurally identical, so the store cannot - // infer the grain from one; changing the target has to say so. - const evaluatorStore = useEvaluatorStoreInstance(); - const mappingSourceGrain = toEvaluatorMappingSourceGrain(scope.targetType); - useEffect(() => { - evaluatorStore - .getState() - .setEvaluatorMappingSourceGrain(mappingSourceGrain); - }, [evaluatorStore, mappingSourceGrain]); + const recordNoun: RecordedRunNoun = isSessionTarget ? "session" : "span"; + // A keystroke in the span filter commits a new condition per valid draft; + // deferring it keeps the current count and rows visible while the queries + // for the newer condition load, instead of collapsing to the fallbacks. + const filterCondition = useDeferredValue(scope.filterCondition); + // The span and session variants of each block are structurally identical; + // the target picks which components fill the shared layout below. + const MatchedCountLine = isSessionTarget + ? MatchedSessionCountLine + : MatchedSpanCountLine; + const CodeRunList = isSessionTarget ? SessionRunList : SpanRunList; + const LlmRunList = isSessionTarget ? LlmSessionRunList : LlmSpanRunList; // The run list below the Suspense boundary owns the records and the run // machinery; it hands the header's Test All button the latest run-all // closure through this ref and reports readiness through the state. @@ -199,182 +190,58 @@ export const ProjectEvaluatorScopePanel = ( return (
- {props.showScopeFields !== false ? ( - <> - - Scope - - {isSessionTarget - ? "Select which sessions this evaluator runs on and how often." - : "Select which spans this evaluator runs on and how often."} + {isSessionTarget ? : null} + + + + {testAllButton} + + } + /> + + Counting matching {recordNoun}s… - - + - - ) : null} - {isSessionTarget ? ( - <> - - - {props.showScopeFields !== false ? ( - - Matching sessions - {testAllButton} - - ) : ( - <> - - - Test with a Session - - - - {testAllButton} - - - - Test your evaluator on recent sessions that match your - scope. - - - )} - - Counting matching sessions… - - } - > - - - - }> - {codeEvaluatorId || inlineCode ? ( - - ) : ( - - )} - - - ) : ( - <> - - {props.showScopeFields !== false ? ( - - Matching spans - {testAllButton} - - ) : ( - <> - - - Test with a Span - - - - {testAllButton} - - - - Test your evaluator on recent spans that match your scope. - - - )} - - Counting matching spans… - - } - > - - - - }> - {codeEvaluatorId || inlineCode ? ( - - ) : ( - - )} - - - )} + + + }> + {codeEvaluatorId || inlineCode ? ( + + ) : ( + + )} +
); @@ -456,16 +323,14 @@ function useMatchedSpanCount({ function TimeWindowSegmentedControl({ value, onChange, - size, }: { value: TimeWindowPresetId; onChange: (timeWindow: TimeWindow) => void; - size?: ComponentProps["size"]; }) { return ( { if (typeof key === "string" && isTimeWindowPresetId(key)) { @@ -486,46 +351,6 @@ function TimeWindowSegmentedControl({ ); } -function ScopeEditorCard({ - projectId, - scope, - onScopeChange, - onFilterValidityChange, - timeWindow, - onTimeWindowChange, - isTargetDisabled, -}: { - projectId: string; - scope: ProjectEvaluatorScope; - onScopeChange: (scope: ProjectEvaluatorScope) => void; - onFilterValidityChange?: (isValid: boolean) => void; - timeWindow: TimeWindow; - onTimeWindowChange: (timeWindow: TimeWindow) => void; - isTargetDisabled: boolean; -}) { - return ( -
- - - - Preview window - - - - -
- ); -} - function MatchedSpanCountLine({ projectId, filterCondition, @@ -724,12 +549,6 @@ function SessionRunList({ ); } -const scopeEditorCardCSS = css` - border: 1px solid var(--global-border-color-default); - border-radius: var(--global-rounding-medium); - padding: var(--global-dimension-size-200); -`; - type RecordedRunResult = { readonly evaluatorName: string; readonly annotation: {