diff --git a/js/app/src/Routes.tsx b/js/app/src/Routes.tsx index 7c8fb68c623..69831b40207 100644 --- a/js/app/src/Routes.tsx +++ b/js/app/src/Routes.tsx @@ -9,6 +9,7 @@ import { RouterProvider } from "react-router/dom"; import { buildRouteInfoCatalog } from "@phoenix/agent/tools/getRouteInfo/catalog"; import { registerRouteInfoCatalog } from "@phoenix/agent/tools/getRouteInfo/routeCatalogRegistry"; +import type { ParentCrumbToFn } from "@phoenix/hooks/useMatchesWithCrumb"; import type { DatasetEvaluatorDetailsLoaderData } from "@phoenix/pages/dataset/evaluators/datasetEvaluatorDetailsLoader"; import { datasetEvaluatorDetailsLoader } from "@phoenix/pages/dataset/evaluators/datasetEvaluatorDetailsLoader"; import { DatasetEvaluatorDetailsPage } from "@phoenix/pages/dataset/evaluators/DatasetEvaluatorDetailsPage"; @@ -85,6 +86,10 @@ import { LoggedOutPage, LoginPage, NewCodeProjectEvaluatorPage, + NewGalleryCodeProjectEvaluatorPage, + NewGalleryLlmFromTemplateProjectEvaluatorPage, + NewGalleryLlmProjectEvaluatorPage, + NewLlmFromTemplateProjectEvaluatorPage, NewLlmProjectEvaluatorPage, OAuth2ConsentPage, PlaygroundPage, @@ -96,6 +101,7 @@ import { ProfilePage, ProfilePreferencesPage, ProjectEvaluatorsPage, + ProjectEvaluatorGalleryPage, projectEvaluatorsLoader, ProjectIndexPage, projectLoader, @@ -496,6 +502,17 @@ export const appRouteObjects = createRoutesFromElements( }, }} /> + } + handle={{ + agentRoute: { + label: "New Project Evaluator From Template", + description: + "Create a project LLM evaluator seeded from a classification evaluator template. The templateName route param is the template's name.", + }, + }} + /> } @@ -520,6 +537,56 @@ export const appRouteObjects = createRoutesFromElements( /> + } + handle={{ + crumb: () => "Evaluator gallery", + parentCrumbTo: (({ parentPathname, search }) => ({ + pathname: `${parentPathname}/evaluators`, + search, + })) satisfies ParentCrumbToFn, + agentRoute: { + label: "Project Evaluator Gallery", + description: + "Browse evaluator templates and start a project evaluator from a template or from scratch.", + }, + }} + > + } + handle={{ + agentRoute: { + label: "New Project LLM Evaluator From Gallery", + description: + "Author a new LLM-as-a-judge evaluator from scratch while browsing the evaluator gallery.", + }, + }} + /> + } + handle={{ + agentRoute: { + label: "New Project Code Evaluator From Gallery", + description: + "Author a new Python or TypeScript code evaluator from scratch while browsing the evaluator gallery.", + }, + }} + /> + } + handle={{ + agentRoute: { + label: "New Project Evaluator From Gallery Template", + description: + "Create a project LLM evaluator seeded from the selected evaluator gallery template.", + }, + }} + /> + {/* The evaluator details page is a full page rather than a tab, mirroring the dataset evaluator details route. The edit slideover nests beneath it so it opens over the details view. */} diff --git a/js/app/src/components/nav/NavBreadcrumb.tsx b/js/app/src/components/nav/NavBreadcrumb.tsx index 7a6dc70f9c0..c6ffc6b2fca 100644 --- a/js/app/src/components/nav/NavBreadcrumb.tsx +++ b/js/app/src/components/nav/NavBreadcrumb.tsx @@ -1,4 +1,4 @@ -import { Link } from "react-router"; +import { Link, useLocation } from "react-router"; import { Breadcrumb, @@ -10,6 +10,7 @@ import { useMatchesWithCrumb } from "@phoenix/hooks/useMatchesWithCrumb"; export function NavBreadcrumb() { const matchesWithCrumb = useMatchesWithCrumb(); + const { search } = useLocation(); const numMatches = matchesWithCrumb.length; return ( @@ -18,12 +19,18 @@ export function NavBreadcrumb() { const copyableItems = match.handle.copy ? match.handle?.copy(match.loaderData) : []; + const nextMatch = matchesWithCrumb[index + 1]; + const breadcrumbDestination = + nextMatch?.handle.parentCrumbTo?.({ + parentPathname: match.pathname, + search, + }) ?? match.pathname; const isLastCrumb = index === numMatches - 1; const showCopyableItems = isLastCrumb && copyableItems.length; return ( - + {crumb} {showCopyableItems ? ( diff --git a/js/app/src/hooks/useMatchesWithCrumb.ts b/js/app/src/hooks/useMatchesWithCrumb.ts index 9063167f0a8..7a97c57236d 100644 --- a/js/app/src/hooks/useMatchesWithCrumb.ts +++ b/js/app/src/hooks/useMatchesWithCrumb.ts @@ -1,4 +1,4 @@ -import { useMatches } from "react-router"; +import { type To, useMatches } from "react-router"; import type { CopyActionMenuItem } from "@phoenix/components"; @@ -8,12 +8,23 @@ export type CrumbFn = (data: unknown) => string; */ export type CopyItem = CopyActionMenuItem; export type CopyFn = (data: unknown) => CopyActionMenuItem[]; +/** + * Resolves a custom destination for the preceding breadcrumb. + * @param params - Breadcrumb navigation context. + * @param params.parentPathname - The matched pathname of the preceding crumb. + * @param params.search - The current URL search string. + */ +export type ParentCrumbToFn = (params: { + parentPathname: string; + search: string; +}) => To; type Matches = ReturnType; type Match = Matches[number]; type RouteMatchWithCrumb = Match & { handle: { crumb: CrumbFn; copy?: CopyFn; + parentCrumbTo?: ParentCrumbToFn; }; }; diff --git a/js/app/src/pages/project/evaluators/AddProjectEvaluatorMenu.tsx b/js/app/src/pages/project/evaluators/AddProjectEvaluatorMenu.tsx index 52d48e376e3..dc14648a664 100644 --- a/js/app/src/pages/project/evaluators/AddProjectEvaluatorMenu.tsx +++ b/js/app/src/pages/project/evaluators/AddProjectEvaluatorMenu.tsx @@ -72,13 +72,23 @@ function AddProjectEvaluatorMenuItems() { { - if (action === "createEvaluator") { + if (action === "browseGallery") { + navigate(paths.gallery); + } else if (action === "createEvaluator") { navigate(paths.newLlm); } else if (action === "createCodeEvaluator") { navigate(paths.newCode); } }} > + + } />} + id="browseGallery" + > + Browse the whole library + + [0] ) { const { creationMode } = props; - if (creationMode.kind === "scratch" || creationMode.kind === "copy") { + if ( + creationMode.kind === "scratch" || + creationMode.kind === "copy" || + creationMode.kind === "template" + ) { const defaultMessages = - creationMode.kind === "copy" - ? creationMode.initialState.defaultMessages - : getSpanEvaluatorDefaultMessages(); + creationMode.kind === "scratch" + ? getSpanEvaluatorDefaultMessages() + : creationMode.initialState.defaultMessages; const templateFormat = - creationMode.kind === "copy" - ? creationMode.initialState.templateFormat - : undefined; + creationMode.kind === "scratch" + ? undefined + : creationMode.initialState.templateFormat; return ( ; +}; + +export function ProjectEvaluatorGalleryPage() { + return ( +
+ + }> + + + + + + +
+ ); +} + +function EvaluatorGallery() { + const navigate = useNavigate(); + const paths = useProjectEvaluatorPaths(); + const [selectedCategory, setSelectedCategory] = + useState(RECOMMENDED_CATEGORY); + const [selectedTemplateName, setSelectedTemplateName] = useState< + string | null + >(null); + const data = useLazyLoadQuery( + projectEvaluatorTemplatesQuery, + {}, + { fetchPolicy: "store-and-network" } + ); + const templates: TemplateWithMetadata[] = + data.classificationEvaluatorConfigs.map((config) => ({ + config, + metadata: getProjectEvaluatorTemplateMetadata(config.name), + })); + const categories = Array.from( + new Set( + templates.map(({ metadata }) => + getProjectEvaluatorTemplateCategoryLabel(metadata.category) + ) + ) + ); + const recommendedTemplateCount = templates.filter( + ({ metadata }) => metadata.recommended + ).length; + const categoryItems = [ + { + name: RECOMMENDED_CATEGORY, + count: recommendedTemplateCount, + }, + ...categories.map((category) => ({ + name: category, + count: templates.filter( + ({ metadata }) => + getProjectEvaluatorTemplateCategoryLabel(metadata.category) === + category + ).length, + })), + ]; + const activeCategory = categoryItems.some( + ({ name }) => name === selectedCategory + ) + ? selectedCategory + : (categoryItems[0]?.name ?? RECOMMENDED_CATEGORY); + const visibleTemplates = templates.filter(({ metadata }) => + activeCategory === RECOMMENDED_CATEGORY + ? metadata.recommended + : getProjectEvaluatorTemplateCategoryLabel(metadata.category) === + activeCategory + ); + const selectedTemplate = + visibleTemplates.find( + ({ config }) => config.name === selectedTemplateName + ) ?? visibleTemplates[0]; + + return ( +
+ + +
+ + {activeCategory} + +
    + {visibleTemplates.map(({ config, metadata }) => { + const isSelected = config.name === selectedTemplate?.config.name; + return ( +
  • + +
  • + ); + })} +
+
+ + +
+ ); +} + +function EvaluatorTemplateDetails({ + template, + onUseTemplate, +}: { + template: TemplateWithMetadata; + onUseTemplate: () => void; +}) { + const { config, metadata } = template; + const choices = getProjectEvaluatorTemplateChoices(config); + return ( + + + {config.name} + + {metadata.kind} + + {getProjectEvaluatorTemplateCategoryLabel(metadata.category)} + + + + {config.description} + + {metadata.details ? ( + + {metadata.details} + + ) : null} + +
+
+
Scope
+
+ {metadata.scope ? capitalize(metadata.scope.toLowerCase()) : "—"} +
+
+
+
Optimization
+
{capitalize(config.optimizationDirection.toLowerCase())}
+
+
+ + + Output choices + +
    + {choices.map(({ label, score }) => ( +
  • + {label} + + {score} + +
  • + ))} +
+
+ +
+ ); +} + +function capitalize(value: string): string { + return `${value.charAt(0).toUpperCase()}${value.slice(1)}`; +} + +function EvaluatorGallerySkeleton() { + return ( + + ); +} + +function EvaluatorGalleryError() { + return ( + + Evaluator templates could not be loaded. + + ); +} + +const galleryCSS = css` + box-sizing: border-box; + display: grid; + grid-template-columns: minmax(160px, 0.65fr) minmax(320px, 1.5fr) minmax( + 260px, + 1fr + ); + height: 100%; + min-height: ${GALLERY_SKELETON_HEIGHT}px; + overflow: hidden; + background-color: var(--global-background-color-default); + + .project-evaluator-gallery__categories, + .project-evaluator-gallery__templates, + .project-evaluator-gallery__details { + padding: var(--global-dimension-size-200); + } + + .project-evaluator-gallery__categories { + display: flex; + flex-direction: column; + } + + .project-evaluator-gallery__categories, + .project-evaluator-gallery__templates { + border-right: var(--global-border-size-thin) solid + var(--global-border-color-default); + } + + .project-evaluator-gallery__category-list, + .project-evaluator-gallery__template-list, + .project-evaluator-gallery__choice-list { + list-style: none; + margin: 0; + padding: 0; + } + + .project-evaluator-gallery__category-list { + display: flex; + flex-direction: column; + gap: var(--global-dimension-size-50); + margin-top: var(--global-dimension-size-100); + } + + .project-evaluator-gallery__scratch-actions { + margin-top: auto; + padding-top: var(--global-dimension-size-200); + border-top: var(--global-border-size-thin) solid + var(--global-border-color-default); + } + + .project-evaluator-gallery__category-button, + .project-evaluator-gallery__template-card { + width: 100%; + border: var(--global-border-size-thin) solid transparent; + border-radius: var(--global-rounding-small); + background-color: transparent; + color: inherit; + cursor: pointer; + text-align: left; + + &:hover { + background-color: var(--global-list-item-hover-background-color); + } + + &:focus-visible { + outline: var(--focus-ring-thickness) solid var(--focus-ring-color); + outline-offset: var(--focus-ring-offset); + } + + &[aria-pressed="true"] { + border-color: var(--global-border-color-default); + background-color: var(--global-list-item-selected-background-color); + } + } + + .project-evaluator-gallery__category-button { + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--global-dimension-size-100); + padding: var(--global-dimension-size-75) var(--global-dimension-size-100); + } + + .project-evaluator-gallery__template-list { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(190px, 1fr)); + gap: var(--global-dimension-size-100); + margin-top: var(--global-dimension-size-100); + } + + .project-evaluator-gallery__template-card { + display: flex; + min-height: var(--global-dimension-size-1400); + flex-direction: column; + gap: var(--global-dimension-size-100); + padding: var(--global-dimension-size-150); + border-color: var(--global-border-color-default); + } + + .project-evaluator-gallery__definition-list { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: var(--global-dimension-size-100); + margin: 0; + + div { + display: flex; + flex-direction: column; + gap: var(--global-dimension-size-25); + } + + dt { + color: var(--global-text-color-500); + font-size: var(--global-font-size-xs); + } + + dd { + margin: 0; + font-size: var(--global-font-size-s); + } + } + + .project-evaluator-gallery__choice-list { + display: flex; + flex-direction: column; + gap: var(--global-dimension-size-50); + + li { + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--global-dimension-size-100); + padding-bottom: var(--global-dimension-size-50); + border-bottom: var(--global-border-size-thin) solid + var(--global-border-color-default); + } + } + + @media (max-width: 900px) { + overflow-x: hidden; + overflow-y: auto; + grid-template-columns: minmax(160px, 0.65fr) minmax(320px, 1.5fr); + + .project-evaluator-gallery__templates { + border-right: 0; + } + + .project-evaluator-gallery__details { + grid-column: 1 / -1; + border-top: var(--global-border-size-thin) solid + var(--global-border-color-default); + } + } + + @media (max-width: 600px) { + grid-template-columns: 1fr; + + .project-evaluator-gallery__categories, + .project-evaluator-gallery__templates { + border-right: 0; + border-bottom: var(--global-border-size-thin) solid + var(--global-border-color-default); + } + + .project-evaluator-gallery__details { + grid-column: auto; + border-top: 0; + } + } +`; + +const galleryOuterCSS = css` + box-sizing: border-box; + flex: 1 1 auto; + width: 100%; + height: 100%; + min-height: ${GALLERY_SKELETON_HEIGHT}px; + overflow: hidden; +`; + +const sectionHeadingCSS = css` + font-size: var(--global-font-size-s); + line-height: var(--global-line-height-s); + font-weight: 600; +`; diff --git a/js/app/src/pages/project/evaluators/ProjectEvaluatorSlideoverRoutes.tsx b/js/app/src/pages/project/evaluators/ProjectEvaluatorSlideoverRoutes.tsx index 7e522d08900..f1b03f61d7e 100644 --- a/js/app/src/pages/project/evaluators/ProjectEvaluatorSlideoverRoutes.tsx +++ b/js/app/src/pages/project/evaluators/ProjectEvaluatorSlideoverRoutes.tsx @@ -4,6 +4,7 @@ import { useNavigate, useParams } from "react-router"; import invariant from "tiny-invariant"; import type { projectEvaluatorDetailsQuery } from "@phoenix/pages/project/evaluators/__generated__/projectEvaluatorDetailsQuery.graphql"; +import type { projectEvaluatorTemplatesQuery as ProjectEvaluatorTemplatesQueryType } from "@phoenix/pages/project/evaluators/__generated__/projectEvaluatorTemplatesQuery.graphql"; import { CreateProjectEvaluatorSlideover } from "@phoenix/pages/project/evaluators/CreateProjectEvaluatorSlideover"; import { EditProjectEvaluatorSlideover, @@ -19,6 +20,10 @@ import { } from "@phoenix/pages/project/evaluators/projectEvaluatorOptions"; import { useProjectEvaluatorPaths } from "@phoenix/pages/project/evaluators/projectEvaluatorPaths"; import { ProjectEvaluatorSlideoverError } from "@phoenix/pages/project/evaluators/ProjectEvaluatorSlideoverError"; +import { + buildTemplateCreationMode, + projectEvaluatorTemplatesQuery, +} from "@phoenix/pages/project/evaluators/projectEvaluatorTemplates"; /** * Closes the slideover by leaving its route — to the evaluators list unless @@ -72,6 +77,20 @@ export function NewLlmProjectEvaluatorPage() { ); } +export function NewGalleryLlmProjectEvaluatorPage() { + const projectId = useRouteProjectId(); + const { gallery } = useProjectEvaluatorPaths(); + const onOpenChange = useCloseSlideover(gallery); + return ( + + ); +} + export function NewCodeProjectEvaluatorPage() { const projectId = useRouteProjectId(); const onOpenChange = useCloseSlideover(); @@ -85,6 +104,65 @@ export function NewCodeProjectEvaluatorPage() { ); } +export function NewGalleryCodeProjectEvaluatorPage() { + const projectId = useRouteProjectId(); + const { gallery } = useProjectEvaluatorPaths(); + const onOpenChange = useCloseSlideover(gallery); + return ( + + ); +} + +export function NewLlmFromTemplateProjectEvaluatorPage() { + return ; +} + +export function NewGalleryLlmFromTemplateProjectEvaluatorPage() { + const { gallery } = useProjectEvaluatorPaths(); + return ; +} + +function NewLlmFromTemplateProjectEvaluatorSlideover({ + closeTo, +}: { + closeTo?: string; +}) { + const projectId = useRouteProjectId(); + const { templateName } = useParams(); + invariant(templateName, "templateName is required"); + const onOpenChange = useCloseSlideover(closeTo); + const data = useLazyLoadQuery( + projectEvaluatorTemplatesQuery, + {}, + { fetchPolicy: "store-and-network" } + ); + const template = data.classificationEvaluatorConfigs.find( + (config) => config.name === templateName + ); + if (!template) { + return ( + + ); + } + return ( + + ); +} + export function CopyLlmProjectEvaluatorPage() { const projectId = useRouteProjectId(); const onOpenChange = useCloseSlideover(); diff --git a/js/app/src/pages/project/evaluators/ProjectEvaluatorsEmptyState.tsx b/js/app/src/pages/project/evaluators/ProjectEvaluatorsEmptyState.tsx index f3e9dcad768..b4c61398fd7 100644 --- a/js/app/src/pages/project/evaluators/ProjectEvaluatorsEmptyState.tsx +++ b/js/app/src/pages/project/evaluators/ProjectEvaluatorsEmptyState.tsx @@ -21,6 +21,8 @@ const MAX_ATTACH_CARDS = 3; const EVALUATOR_CARD_HEIGHT = 90; export function ProjectEvaluatorsEmptyState() { + const navigate = useNavigate(); + const paths = useProjectEvaluatorPaths(); return ( } title="No evaluators for this project" description="Add an evaluator to score spans, traces, or sessions automatically as they arrive." + action={{ + type: "strip", + items: [ + { + kind: "button", + variant: "primary", + children: "Browse the whole library", + onPress: () => navigate(paths.gallery), + }, + ], + }} /> - - }> - + + }> + @@ -45,7 +58,7 @@ export function ProjectEvaluatorsEmptyState() { ); } -function EvaluatorGallery() { +function EvaluatorOptions() { const navigate = useNavigate(); const paths = useProjectEvaluatorPaths(); const data = useLazyLoadQuery( @@ -126,7 +139,7 @@ function EvaluatorCard({ ); } -function EvaluatorGallerySkeleton() { +function EvaluatorOptionsSkeleton() { const column = (
@@ -141,7 +154,7 @@ function EvaluatorGallerySkeleton() { ); } -function EvaluatorGalleryError() { +function EvaluatorOptionsError() { return ( Existing evaluators could not be loaded. diff --git a/js/app/src/pages/project/evaluators/__generated__/projectEvaluatorTemplatesQuery.graphql.ts b/js/app/src/pages/project/evaluators/__generated__/projectEvaluatorTemplatesQuery.graphql.ts new file mode 100644 index 00000000000..f6a20f09bc6 --- /dev/null +++ b/js/app/src/pages/project/evaluators/__generated__/projectEvaluatorTemplatesQuery.graphql.ts @@ -0,0 +1,216 @@ +/** + * @generated SignedSource<> + * @lightSyntaxTransform + */ + +/* tslint:disable */ +/* eslint-disable */ +// @ts-nocheck + +import { ConcreteRequest } from 'relay-runtime'; +import { FragmentRefs } from "relay-runtime"; +export type OptimizationDirection = "MAXIMIZE" | "MINIMIZE" | "NONE"; +export type projectEvaluatorTemplatesQuery$variables = Record; +export type projectEvaluatorTemplatesQuery$data = { + readonly classificationEvaluatorConfigs: ReadonlyArray<{ + readonly choices: any; + readonly description: string | null; + readonly messages: ReadonlyArray<{ + readonly " $fragmentSpreads": FragmentRefs<"promptUtils_promptMessages">; + }>; + readonly name: string; + readonly optimizationDirection: OptimizationDirection; + }>; +}; +export type projectEvaluatorTemplatesQuery = { + response: projectEvaluatorTemplatesQuery$data; + variables: projectEvaluatorTemplatesQuery$variables; +}; + +const node: ConcreteRequest = (function(){ +var v0 = { + "alias": null, + "args": null, + "kind": "ScalarField", + "name": "name", + "storageKey": null +}, +v1 = { + "alias": null, + "args": null, + "kind": "ScalarField", + "name": "description", + "storageKey": null +}, +v2 = { + "alias": null, + "args": null, + "kind": "ScalarField", + "name": "choices", + "storageKey": null +}, +v3 = { + "alias": null, + "args": null, + "kind": "ScalarField", + "name": "optimizationDirection", + "storageKey": null +}, +v4 = { + "kind": "InlineFragment", + "selections": [ + { + "alias": null, + "args": null, + "concreteType": "TextContentValue", + "kind": "LinkedField", + "name": "text", + "plural": false, + "selections": [ + { + "alias": null, + "args": null, + "kind": "ScalarField", + "name": "text", + "storageKey": null + } + ], + "storageKey": null + } + ], + "type": "TextContentPart", + "abstractKey": null +}, +v5 = { + "alias": null, + "args": null, + "kind": "ScalarField", + "name": "role", + "storageKey": null +}; +return { + "fragment": { + "argumentDefinitions": [], + "kind": "Fragment", + "metadata": null, + "name": "projectEvaluatorTemplatesQuery", + "selections": [ + { + "alias": null, + "args": null, + "concreteType": "ClassificationEvaluatorConfig", + "kind": "LinkedField", + "name": "classificationEvaluatorConfigs", + "plural": true, + "selections": [ + (v0/*:: as any*/), + (v1/*:: as any*/), + (v2/*:: as any*/), + (v3/*:: as any*/), + { + "alias": null, + "args": null, + "concreteType": "PromptMessage", + "kind": "LinkedField", + "name": "messages", + "plural": true, + "selections": [ + { + "kind": "InlineDataFragmentSpread", + "name": "promptUtils_promptMessages", + "selections": [ + { + "alias": null, + "args": null, + "concreteType": null, + "kind": "LinkedField", + "name": "content", + "plural": true, + "selections": [ + (v4/*:: as any*/) + ], + "storageKey": null + }, + (v5/*:: as any*/) + ], + "args": null, + "argumentDefinitions": [] + } + ], + "storageKey": null + } + ], + "storageKey": null + } + ], + "type": "Query", + "abstractKey": null + }, + "kind": "Request", + "operation": { + "argumentDefinitions": [], + "kind": "Operation", + "name": "projectEvaluatorTemplatesQuery", + "selections": [ + { + "alias": null, + "args": null, + "concreteType": "ClassificationEvaluatorConfig", + "kind": "LinkedField", + "name": "classificationEvaluatorConfigs", + "plural": true, + "selections": [ + (v0/*:: as any*/), + (v1/*:: as any*/), + (v2/*:: as any*/), + (v3/*:: as any*/), + { + "alias": null, + "args": null, + "concreteType": "PromptMessage", + "kind": "LinkedField", + "name": "messages", + "plural": true, + "selections": [ + { + "alias": null, + "args": null, + "concreteType": null, + "kind": "LinkedField", + "name": "content", + "plural": true, + "selections": [ + { + "alias": null, + "args": null, + "kind": "ScalarField", + "name": "__typename", + "storageKey": null + }, + (v4/*:: as any*/) + ], + "storageKey": null + }, + (v5/*:: as any*/) + ], + "storageKey": null + } + ], + "storageKey": null + } + ] + }, + "params": { + "cacheID": "c6c80e4cdc5351aba88d5ba1f49b456f", + "id": null, + "metadata": {}, + "name": "projectEvaluatorTemplatesQuery", + "operationKind": "query", + "text": "query projectEvaluatorTemplatesQuery {\n classificationEvaluatorConfigs {\n name\n description\n choices\n optimizationDirection\n messages {\n ...promptUtils_promptMessages\n }\n }\n}\n\nfragment promptUtils_promptMessages on PromptMessage {\n content {\n __typename\n ... on TextContentPart {\n text {\n text\n }\n }\n }\n role\n}\n" + } +}; +})(); + +(node as any).hash = "b938efd265919dc8c2d52b8aa9d45a15"; + +export default node; diff --git a/js/app/src/pages/project/evaluators/projectEvaluatorPaths.ts b/js/app/src/pages/project/evaluators/projectEvaluatorPaths.ts index 592c8ccea33..1420a953b70 100644 --- a/js/app/src/pages/project/evaluators/projectEvaluatorPaths.ts +++ b/js/app/src/pages/project/evaluators/projectEvaluatorPaths.ts @@ -6,6 +6,9 @@ import { useProjectRootPath } from "@phoenix/hooks/useProjectRootPath"; const projectEvaluatorsPath = (projectRootPath: string) => `${projectRootPath}/evaluators`; +const projectEvaluatorGalleryPath = (projectRootPath: string) => + `${projectRootPath}/evaluator-gallery`; + /** * Exported for the loader that forwards the legacy `?createLlmEvaluator` and * `?createCodeEvaluator` links, which has a path rather than a project root. @@ -27,18 +30,30 @@ export const newCodeProjectEvaluatorPath = (projectRootPath: string) => */ export function useProjectEvaluatorPaths() { const { rootPath } = useProjectRootPath(); - // A slideover is a sub-view of the list, not a new destination, so opening - // and closing one carries the page's URL state -- above all a custom time - // range, which would otherwise be dropped on the way in and again on the way - // out. + // A slideover is a sub-view of its parent page, not a new destination, so + // opening and closing one carries the page's URL state -- above all a custom + // time range, which would otherwise be dropped on the way in and again on + // the way out. const { search } = useLocation(); return useMemo(() => { const list = projectEvaluatorsPath(rootPath); + const gallery = projectEvaluatorGalleryPath(rootPath); const withCurrentSearch = (path: string) => `${path}${search}`; return { list: withCurrentSearch(list), + gallery: withCurrentSearch(gallery), newLlm: withCurrentSearch(newLlmProjectEvaluatorPath(rootPath)), + newLlmFromTemplate: (templateName: string) => + withCurrentSearch( + `${list}/new/template/${encodeURIComponent(templateName)}` + ), newCode: withCurrentSearch(newCodeProjectEvaluatorPath(rootPath)), + galleryNewLlm: withCurrentSearch(`${gallery}/new/llm`), + galleryNewLlmFromTemplate: (templateName: string) => + withCurrentSearch( + `${gallery}/new/template/${encodeURIComponent(templateName)}` + ), + galleryNewCode: withCurrentSearch(`${gallery}/new/code`), copyLlm: (evaluatorId: string) => withCurrentSearch( `${list}/new/copy/${encodeURIComponent(evaluatorId)}` diff --git a/js/app/src/pages/project/evaluators/projectEvaluatorTemplates.ts b/js/app/src/pages/project/evaluators/projectEvaluatorTemplates.ts new file mode 100644 index 00000000000..93d50415914 --- /dev/null +++ b/js/app/src/pages/project/evaluators/projectEvaluatorTemplates.ts @@ -0,0 +1,373 @@ +import { graphql } from "react-relay"; +import z from "zod"; + +import type { projectEvaluatorTemplatesQuery$data } from "@phoenix/pages/project/evaluators/__generated__/projectEvaluatorTemplatesQuery.graphql"; +import type { ProjectEvaluatorCreationMode } from "@phoenix/pages/project/evaluators/CreateProjectEvaluatorSlideover"; +import { convertPromptVersionMessagesToPlaygroundInstanceMessages } from "@phoenix/utils/promptUtils"; + +export const projectEvaluatorTemplatesQuery = graphql` + query projectEvaluatorTemplatesQuery { + classificationEvaluatorConfigs { + name + description + choices + optimizationDirection + messages { + ...promptUtils_promptMessages + } + } + } +`; + +export type ProjectEvaluatorTemplate = + projectEvaluatorTemplatesQuery$data["classificationEvaluatorConfigs"][number]; + +export type EvaluatorTemplateCategory = + | "GROUNDING_AND_RETRIEVAL" + | "AGENTS" + | "RESPONSE_QUALITY" + | "SAFETY_AND_SECURITY" + | "USER_EXPERIENCE"; + +export type EvaluatorTemplateMetadata = { + scope: "SPAN" | "TRACE" | "SESSION" | null; + recommended: boolean; + category: EvaluatorTemplateCategory | null; + kind: "LLM" | "CODE"; + details: string | null; + inputs: ReadonlyArray<{ + name: string; + description: string; + format: string | null; + }> | null; +}; + +const DEFAULT_TEMPLATE_METADATA = { + scope: null, + recommended: false, + category: null, + kind: "LLM", + details: null, + inputs: null, +} as const satisfies EvaluatorTemplateMetadata; + +// TODO: Remove this mock metadata once https://github.com/Arize-ai/phoenix/pull/15495 +// merges and these fields are available from evaluatorGalleryConfigs. +const PROJECT_EVALUATOR_TEMPLATE_METADATA: Partial< + Record +> = { + conciseness: { + scope: "SPAN", + recommended: false, + category: "RESPONSE_QUALITY", + kind: "LLM", + details: + "Assesses whether an LLM's response uses the minimum number of words necessary to fully answer the question. It detects unnecessary pleasantries, hedging language, meta-commentary, redundant restatements, and unsolicited explanations.", + inputs: [ + { + name: "input", + description: + "The conversational context, whether that is a single input query or a full turn-by-turn conversation.", + format: null, + }, + { + name: "output", + description: "The LLM's output response to be evaluated.", + format: null, + }, + ], + }, + correctness: { + scope: "SPAN", + recommended: false, + category: "RESPONSE_QUALITY", + kind: "LLM", + details: + "A broad, general purpose metric to determine whether an LLM's response is factually accurate, complete, and logically consistent. It evaluates answer quality without requiring external context or reference responses.", + inputs: [ + { + name: "input", + description: + "The conversational context, whether that is a single input query or a full turn-by-turn conversation.", + format: null, + }, + { + name: "output", + description: "The LLM's output response to be evaluated.", + format: null, + }, + ], + }, + document_relevance: { + scope: "SPAN", + recommended: false, + category: "GROUNDING_AND_RETRIEVAL", + kind: "LLM", + details: + "Determines whether a retrieved document contains information relevant to answering the input query. This is essential for evaluating RAG systems, where document quality directly impacts response quality.", + inputs: [ + { + name: "document_text", + description: "The content of the retrieved document or context.", + format: null, + }, + { + name: "input", + description: "The input query or conversational context.", + format: null, + }, + ], + }, + faithfulness: { + scope: "SPAN", + recommended: false, + category: "GROUNDING_AND_RETRIEVAL", + kind: "LLM", + details: + "Determines whether an LLM's response is grounded in and faithful to the provided context. It detects information that is unsupported by or contradicts the reference context and is intended for grounded responses such as RAG outputs.", + inputs: [ + { + name: "context", + description: "The content of the retrieved documents or context.", + format: null, + }, + { + name: "input", + description: "The input query or conversational context.", + format: null, + }, + { + name: "output", + description: "The LLM's output response to be evaluated.", + format: null, + }, + ], + }, + hallucination: { + scope: "SPAN", + recommended: true, + category: "GROUNDING_AND_RETRIEVAL", + kind: "LLM", + details: + "Determines whether an assistant's response contains claims unsupported by or contradictory to the conversation it had access to. Unlike Faithfulness, which grounds a response in one retrieved context block, Hallucination uses the broader conversation, including earlier turns, tool calls, tool results, and retrieved context.", + inputs: [ + { + name: "input", + description: + "The entire conversational context, including all messages, tool calls, and tool results.", + format: null, + }, + { + name: "output", + description: + "The LLM's output response (messages and tool calls) to be evaluated.", + format: null, + }, + ], + }, + refusal: { + scope: "SPAN", + recommended: false, + category: "USER_EXPERIENCE", + kind: "LLM", + details: + "Detects when an LLM refuses, declines, or avoids answering a user query. It captures explicit refusals, scope disclaimers, lack-of-information responses, safety refusals, redirections, and apologetic non-answers. It does not judge whether the refusal was the appropriate response.", + inputs: [ + { + name: "input", + description: + "The conversational context, whether that is a single input query or a full turn-by-turn conversation.", + format: null, + }, + { + name: "output", + description: "The LLM's output response to be evaluated.", + format: null, + }, + ], + }, + tool_invocation: { + scope: "SPAN", + recommended: false, + category: "AGENTS", + kind: "LLM", + details: + "Determines whether an LLM invoked a tool correctly with proper arguments, formatting, and safe content. It focuses on how the tool was called rather than whether the right tool was selected. It works even if no tools were called.", + inputs: [ + { + name: "available_tools", + description: + "The list of available tools, including names and descriptions. A simple human-readable list is better than including the full tool schemas.", + format: null, + }, + { + name: "input", + description: + "The conversational context, whether that is a single input query or a full turn-by-turn conversation.", + format: null, + }, + { + name: "tool_selection", + description: + "The LLM's output response (including messages and tool calls) to be evaluated.", + format: null, + }, + ], + }, + tool_selection: { + scope: "SPAN", + recommended: true, + category: "AGENTS", + kind: "LLM", + details: + "Determines whether an LLM selected the most appropriate tool or tools for a given task. It focuses on what tool was chosen rather than whether the invocation arguments were correct.", + inputs: [ + { + name: "available_tools", + description: + "The list of available tools, including names and descriptions. A simple human-readable list is better than including the full tool schemas as JSON.", + format: null, + }, + { + name: "input", + description: + "The conversational context, whether that is a single input query or a full turn-by-turn conversation. Intermediate tool calls/results are not required.", + format: null, + }, + { + name: "tool_selection", + description: "The tool or tools called by the LLM.", + format: null, + }, + ], + }, + tool_response_handling: { + scope: "TRACE", + recommended: false, + category: "AGENTS", + kind: "LLM", + details: + "Determines whether an AI agent correctly processed a tool's result to produce an appropriate output. It focuses on what happens after a tool call by checking that the agent used the result accurately, handled errors, and disclosed information safely.", + inputs: [ + { + name: "input", + description: + "The conversational context, whether that is a single input query or a full turn-by-turn conversation. Intermediate tool calls/results are not required.", + format: null, + }, + { + name: "tool_call", + description: + "Details of the tool or tools that were called, including name and parameters.", + format: null, + }, + { + name: "tool_result", + description: "The complete tool results, including any errors.", + format: null, + }, + { + name: "output", + description: + "The LLM's output messages after the tool call (including messages and tool calls).", + format: null, + }, + ], + }, + toxicity: { + scope: "SPAN", + recommended: false, + category: "SAFETY_AND_SECURITY", + kind: "LLM", + details: + "Classifies a single piece of text as toxic or non-toxic. Text is toxic when it makes hateful or discriminatory statements about a person or group, demeans or insults someone, uses abusive language directed at a person, or threatens or incites harm.", + inputs: [ + { + name: "text", + description: + "The text to be evaluated for toxicty. This could be either an input (user message) or an output (LLM message).", + format: null, + }, + ], + }, + user_friction: { + scope: "TRACE", + recommended: false, + category: "USER_EXPERIENCE", + kind: "LLM", + details: + "Classifies whether the latest user message expresses friction with an assistant's preceding behavior. It detects corrections, retries after an unsuccessful response, frustration, and challenges to unrequested or unexplained actions.", + inputs: [ + { + name: "conversation", + description: + "The complete conversational context, including user/assistant messages. Intermediate tool calls/results are optional but may help, especially those from the most recent turn.", + format: null, + }, + { + name: "user_message", + description: "The latest user message to be evaluated.", + format: null, + }, + ], + }, +}; + +const EVALUATOR_CATEGORY_LABELS: Record = { + GROUNDING_AND_RETRIEVAL: "Grounding & retrieval", + AGENTS: "Agents", + RESPONSE_QUALITY: "Response quality", + SAFETY_AND_SECURITY: "Safety & security", + USER_EXPERIENCE: "User experience", +}; + +export function getProjectEvaluatorTemplateMetadata( + templateName: string +): EvaluatorTemplateMetadata { + return ( + PROJECT_EVALUATOR_TEMPLATE_METADATA[templateName] ?? + DEFAULT_TEMPLATE_METADATA + ); +} + +export function getProjectEvaluatorTemplateCategoryLabel( + category: EvaluatorTemplateCategory | null +): string { + return category ? EVALUATOR_CATEGORY_LABELS[category] : "Other"; +} + +export function getProjectEvaluatorTemplateChoices(config: { + choices: unknown; +}): { label: string; score: number }[] { + const parsedChoices = z + .record(z.string(), z.number()) + .safeParse(config.choices); + const choices = parsedChoices.success ? parsedChoices.data : {}; + return Object.entries(choices).map(([label, score]) => ({ label, score })); +} + +export function buildTemplateCreationMode( + config: ProjectEvaluatorTemplate +): ProjectEvaluatorCreationMode { + return { + kind: "template", + initialState: { + name: config.name, + description: config.description ?? "", + outputConfigs: [ + { + name: config.name, + optimizationDirection: config.optimizationDirection, + values: getProjectEvaluatorTemplateChoices(config), + }, + ], + defaultMessages: convertPromptVersionMessagesToPlaygroundInstanceMessages( + { + promptMessagesRefs: config.messages, + } + ), + templateFormat: "MUSTACHE", + includeExplanation: true, + }, + }; +} diff --git a/js/app/src/pages/project/index.tsx b/js/app/src/pages/project/index.tsx index 9e7da0860de..5e1a7f477bd 100644 --- a/js/app/src/pages/project/index.tsx +++ b/js/app/src/pages/project/index.tsx @@ -5,6 +5,7 @@ export * from "./ProjectSpansPage"; export * from "./ProjectTracesPage"; export * from "./projectLoader"; export * from "./evaluators/ProjectEvaluatorsPage"; +export * from "./evaluators/ProjectEvaluatorGalleryPage"; export * from "./evaluators/projectEvaluatorsLoader"; export * from "./evaluators/ProjectEvaluatorSlideoverRoutes"; export * from "./metrics/ProjectMetricsPage";