diff --git a/apps/opik-frontend/src/components/pages/CompareOptimizationsPage/BestPrompt.tsx b/apps/opik-frontend/src/components/pages/CompareOptimizationsPage/BestPrompt.tsx index c845b534249..a48beef5248 100644 --- a/apps/opik-frontend/src/components/pages/CompareOptimizationsPage/BestPrompt.tsx +++ b/apps/opik-frontend/src/components/pages/CompareOptimizationsPage/BestPrompt.tsx @@ -1,8 +1,9 @@ -import React, { useMemo } from "react"; +import React, { useMemo, useState } from "react"; import { Link } from "@tanstack/react-router"; -import { ArrowRight } from "lucide-react"; +import { ArrowRight, Split } from "lucide-react"; import isUndefined from "lodash/isUndefined"; import isObject from "lodash/isObject"; +import isArray from "lodash/isArray"; import get from "lodash/get"; import { OPTIMIZATION_PROMPT_KEY } from "@/constants/experiments"; @@ -20,20 +21,65 @@ import { Button } from "@/components/ui/button"; import { formatNumericData, toString } from "@/lib/utils"; import ColoredTagNew from "@/components/shared/ColoredTag/ColoredTagNew"; import TooltipWrapper from "@/components/shared/TooltipWrapper/TooltipWrapper"; -import PercentageTrend from "@/components/shared/PercentageTrend/PercentageTrend"; +import { LLM_MESSAGE_ROLE_NAME_MAP } from "@/constants/llm"; +import MarkdownPreview from "@/components/shared/MarkdownPreview/MarkdownPreview"; +import { cn } from "@/lib/utils"; +import { + extractOpenAIMessages, + formatMessagesAsText, + extractMessageContent, + OpenAIMessage, +} from "@/lib/prompt"; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import TextDiff from "@/components/shared/CodeDiff/TextDiff"; type BestPromptProps = { optimization: Optimization; experiment: Experiment; scoreMap: Record; + baselineExperiment?: Experiment | null; +}; + +/** + * Read-only message component similar to LLMPromptMessage but simplified. + */ +const ReadOnlyMessage: React.FC<{ message: OpenAIMessage; index: number }> = ({ + message, + index, +}) => { + const roleName = + LLM_MESSAGE_ROLE_NAME_MAP[ + message.role as keyof typeof LLM_MESSAGE_ROLE_NAME_MAP + ] || message.role; + const content = extractMessageContent(message.content); + + return ( + 0 && "mt-2")}> + +
+ {roleName} +
+
+ {content || ""} +
+
+
+ ); }; const BestPrompt: React.FC = ({ optimization, experiment, scoreMap, + baselineExperiment, }) => { const workspaceName = useAppStore((state) => state.activeWorkspaceName); + const [diffOpen, setDiffOpen] = useState(false); const { score, percentage } = useMemo(() => { const retVal: { @@ -53,37 +99,87 @@ const BestPrompt: React.FC = ({ return retVal; }, [experiment.id, scoreMap]); - const prompt = useMemo(() => { - const val = get(experiment.metadata ?? {}, OPTIMIZATION_PROMPT_KEY, "-"); + const promptData = useMemo(() => { + return get(experiment.metadata ?? {}, OPTIMIZATION_PROMPT_KEY, "-"); + }, [experiment]); + + const messages = useMemo((): OpenAIMessage[] | null => { + if (!isObject(promptData) && !isArray(promptData)) { + return null; + } + + return extractOpenAIMessages(promptData); + }, [promptData]); + + const fallbackPrompt = useMemo(() => { + if (messages) { + return null; + } + return isObject(promptData) + ? JSON.stringify(promptData, null, 2) + : toString(promptData); + }, [promptData, messages]); + + const baselinePrompt = useMemo(() => { + if (!baselineExperiment) return null; + const val = get( + baselineExperiment.metadata ?? {}, + OPTIMIZATION_PROMPT_KEY, + null, + ); + if (!val) return null; + + const extractedMessages = extractOpenAIMessages(val); + if (extractedMessages) { + return formatMessagesAsText(extractedMessages); + } return isObject(val) ? JSON.stringify(val, null, 2) : toString(val); - }, [experiment]); + }, [baselineExperiment]); + + const currentPromptText = useMemo(() => { + if (messages) { + return formatMessagesAsText(messages); + } + return fallbackPrompt || ""; + }, [messages, fallbackPrompt]); return ( - + - Best prompt - - - - - -
-
- {isUndefined(score) ? "-" : formatNumericData(score)} +
+
+ Best prompt + + +
- -
- -
- {prompt} +
+
+ {isUndefined(score) ? "-" : formatNumericData(score)} +
+ {!isUndefined(percentage) && ( +
0 && "text-success", + percentage < 0 && "text-destructive", + percentage === 0 && "text-muted-slate", + )} + > + {percentage > 0 ? "+" : ""} + {formatNumericData(percentage)}% +
+ )}
- -
+
+ + +
= ({ }} search={{ trials: [experiment.id] }} > - + {baselinePrompt && ( + <> + + + + + + + Compare prompts + +
+
+
+ Baseline +
+
+ {baselinePrompt} +
+
+
+
+ Current +
+
+ +
+
+
+
+
+ + )} +
+
+ {messages ? ( +
+ {messages.map((message, index) => ( + + ))} +
+ ) : ( + +
+ {fallbackPrompt} +
+
+ )}
diff --git a/apps/opik-frontend/src/components/pages/CompareOptimizationsPage/CompareOptimizationsPage.tsx b/apps/opik-frontend/src/components/pages/CompareOptimizationsPage/CompareOptimizationsPage.tsx index 755ce4900e0..1b5f083d380 100644 --- a/apps/opik-frontend/src/components/pages/CompareOptimizationsPage/CompareOptimizationsPage.tsx +++ b/apps/opik-frontend/src/components/pages/CompareOptimizationsPage/CompareOptimizationsPage.tsx @@ -33,6 +33,7 @@ import { convertColumnDataToColumn, mapColumnDataFields } from "@/lib/table"; import useAppStore from "@/store/AppStore"; import { Button } from "@/components/ui/button"; import { Tag } from "@/components/ui/tag"; +import { Card } from "@/components/ui/card"; import useOptimizationById from "@/api/optimizations/useOptimizationById"; import useExperimentsList from "@/api/datasets/useExperimentsList"; import useBreadcrumbsStore from "@/store/BreadcrumbsStore"; @@ -44,9 +45,10 @@ import SearchInput from "@/components/shared/SearchInput/SearchInput"; import TooltipWrapper from "@/components/shared/TooltipWrapper/TooltipWrapper"; import DataTableRowHeightSelector from "@/components/shared/DataTableRowHeightSelector/DataTableRowHeightSelector"; import DataTableNoData from "@/components/shared/DataTableNoData/DataTableNoData"; -import PageBodyStickyTableWrapper from "@/components/layout/PageBodyStickyTableWrapper/PageBodyStickyTableWrapper"; +import { TABLE_WRAPPER_ATTRIBUTE } from "@/components/layout/PageBodyStickyTableWrapper/PageBodyStickyTableWrapper"; import DataTableVirtualBody from "@/components/shared/DataTable/DataTableVirtualBody"; import DataTable from "@/components/shared/DataTable/DataTable"; +import { DataTableWrapperProps } from "@/components/shared/DataTable/DataTableWrapper"; import IdCell from "@/components/shared/DataTableCells/IdCell"; import ResourceCell from "@/components/shared/DataTableCells/ResourceCell"; import ObjectiveScoreCell from "@/components/pages/CompareOptimizationsPage/ObjectiveScoreCell"; @@ -72,12 +74,26 @@ export const DEFAULT_COLUMN_PINNING: ColumnPinningState = { }; export const DEFAULT_SELECTED_COLUMNS: string[] = [ - "optimizer", "prompt", "objective_name", COLUMN_CREATED_AT_ID, ]; +const StickyTableWrapperWithBorder: React.FC = ({ + children, +}) => { + return ( +
+ {children} +
+ ); +}; + const CompareOptimizationsPage: React.FC = () => { const navigate = useNavigate(); const workspaceName = useAppStore((state) => state.activeWorkspaceName); @@ -192,11 +208,12 @@ const CompareOptimizationsPage: React.FC = () => { [experiments, search], ); - const { scoreMap, bestExperiment } = useMemo(() => { + const { scoreMap, bestExperiment, baselineExperiment } = useMemo(() => { const retVal: { scoreMap: Record; baseScore: number; bestExperiment?: Experiment; + baselineExperiment?: Experiment; } = { scoreMap: {}, baseScore: 0, @@ -207,6 +224,8 @@ const CompareOptimizationsPage: React.FC = () => { .slice() .sort((e1, e2) => e1.created_at.localeCompare(e2.created_at)); + retVal.baselineExperiment = sortedRows[0]; + if ( !optimization?.objective_name || !experiments.length || @@ -380,104 +399,119 @@ const CompareOptimizationsPage: React.FC = () => { } return ( - - -
-

{title}

- {optimization?.status && ( - - {optimization.status} - - )} -
-
- -
- -
-
- - - - + + +
+

{title}

+ {optimization?.status && ( + + {optimization.status} + + )} +
+
+ + - -
-
- - - {bestExperiment && optimization ? ( - - ) : null} - - } - TableWrapper={PageBodyStickyTableWrapper} - TableBody={DataTableVirtualBody} - stickyHeader - /> - -
+ + +
+ +
+
+ + + + + +
+
+ +
+ + } + TableWrapper={StickyTableWrapperWithBorder} + TableBody={DataTableVirtualBody} + stickyHeader + /> + +
+
+ {bestExperiment && optimization ? ( + + ) : null} +
+
+ + + ); }; diff --git a/apps/opik-frontend/src/lib/prompt.test.ts b/apps/opik-frontend/src/lib/prompt.test.ts new file mode 100644 index 00000000000..cf14e0f8be5 --- /dev/null +++ b/apps/opik-frontend/src/lib/prompt.test.ts @@ -0,0 +1,87 @@ +import { describe, expect, it } from "vitest"; + +import { + extractMessageContent, + extractOpenAIMessages, + formatMessagesAsText, + isValidOpenAIMessages, + OpenAIMessage, +} from "./prompt"; + +describe("prompt utilities", () => { + describe("extractMessageContent", () => { + it("returns string content as-is", () => { + expect(extractMessageContent("Hello world")).toBe("Hello world"); + }); + + it("concatenates text entries from array content", () => { + const content = [ + { type: "text", text: "Hello" }, + { type: "text", text: "world" }, + ]; + expect(extractMessageContent(content)).toBe("Hello\nworld"); + }); + + it("skips non-text entries", () => { + const content = [ + { type: "image_url", image_url: "https://example.com" }, + { type: "text", text: "Visible text" }, + ]; + expect(extractMessageContent(content)).toBe("Visible text"); + }); + + it("returns empty string for unsupported input", () => { + expect(extractMessageContent({})).toBe(""); + expect(extractMessageContent(undefined)).toBe(""); + }); + }); + + describe("isValidOpenAIMessages", () => { + it("returns true for valid messages", () => { + const messages: OpenAIMessage[] = [ + { role: "user", content: "Hello" }, + { role: "assistant", content: [{ type: "text", text: "Hi!" }] }, + ]; + expect(isValidOpenAIMessages(messages)).toBe(true); + }); + + it("returns false when role missing", () => { + const invalidMessages = [{ content: "Hello" }]; + expect(isValidOpenAIMessages(invalidMessages as unknown[])).toBe(false); + }); + + it("returns false when content missing", () => { + const invalidMessages = [{ role: "user" }]; + expect(isValidOpenAIMessages(invalidMessages as unknown[])).toBe(false); + }); + }); + + describe("extractOpenAIMessages", () => { + it("returns messages when input is already an array", () => { + const messages: OpenAIMessage[] = [{ role: "system", content: "Rules" }]; + expect(extractOpenAIMessages(messages)).toEqual(messages); + }); + + it("extracts messages from object with messages property", () => { + const messages: OpenAIMessage[] = [{ role: "user", content: "Hi" }]; + expect(extractOpenAIMessages({ messages })).toEqual(messages); + }); + + it("returns null for invalid structures", () => { + expect(extractOpenAIMessages({})).toBeNull(); + expect(extractOpenAIMessages("invalid")).toBeNull(); + }); + }); + + describe("formatMessagesAsText", () => { + it("formats messages with role display names and content", () => { + const messages: OpenAIMessage[] = [ + { role: "system", content: "You are ChatGPT." }, + { role: "user", content: [{ type: "text", text: "Hello" }] }, + ]; + expect(formatMessagesAsText(messages)).toBe( + "System: You are ChatGPT.\n\nUser: Hello", + ); + }); + }); +}); diff --git a/apps/opik-frontend/src/lib/prompt.ts b/apps/opik-frontend/src/lib/prompt.ts index de5ab6733bf..94f040ffcda 100644 --- a/apps/opik-frontend/src/lib/prompt.ts +++ b/apps/opik-frontend/src/lib/prompt.ts @@ -1,4 +1,9 @@ import mustache from "mustache"; +import isString from "lodash/isString"; +import isArray from "lodash/isArray"; +import isObject from "lodash/isObject"; + +import { LLM_MESSAGE_ROLE_NAME_MAP } from "@/constants/llm"; export const getPromptMustacheTags = (template: string) => { const parsedTemplate = mustache.parse(template); @@ -19,3 +24,100 @@ export const safelyGetPromptMustacheTags = (template: string) => { return false; } }; + +export type OpenAIMessage = { + role: string; + content: + | string + | Array<{ type: string; text?: string; [key: string]: unknown }>; +}; + +/** + * Extracts text content from OpenAI message format. + * Handles both string content and array content (extracts text from {type: "text", text: "..."} items). + */ +export const extractMessageContent = ( + content: + | string + | Array<{ type: string; text?: string; [key: string]: unknown }> + | unknown, +): string => { + if (isString(content)) { + return content; + } + + if (isArray(content)) { + const textParts: string[] = []; + for (const item of content) { + if ( + isObject(item) && + "type" in item && + item.type === "text" && + "text" in item && + isString(item.text) + ) { + textParts.push(item.text); + } + } + return textParts.join("\n"); + } + + return ""; +}; + +/** + * Validates if an array contains valid OpenAI message objects. + */ +export const isValidOpenAIMessages = ( + messages: unknown[], +): messages is OpenAIMessage[] => { + return messages.every( + (msg: unknown) => + isObject(msg) && + "role" in msg && + isString((msg as { role: unknown }).role) && + "content" in msg, + ); +}; + +/** + * Extracts OpenAI messages from various data formats. + * Handles both array format and object with messages property. + */ +export const extractOpenAIMessages = ( + data: unknown, +): OpenAIMessage[] | null => { + // Check if it's an array of messages (OpenAI format) + if (isArray(data) && isValidOpenAIMessages(data)) { + return data; + } + + // Check if it's an object with a messages array + if (isObject(data) && "messages" in data) { + const promptObj = data as { messages?: unknown }; + if ( + isArray(promptObj.messages) && + isValidOpenAIMessages(promptObj.messages) + ) { + return promptObj.messages; + } + } + + return null; +}; + +/** + * Formats OpenAI messages as readable text. + */ +export const formatMessagesAsText = (messages: OpenAIMessage[]): string => { + return messages + .map((msg) => { + const roleName = + LLM_MESSAGE_ROLE_NAME_MAP[ + msg.role as keyof typeof LLM_MESSAGE_ROLE_NAME_MAP + ] || msg.role; + const content = extractMessageContent(msg.content); + return `${roleName}: ${content}`; + }) + .join("\n\n"); +}; diff --git a/apps/opik-frontend/src/main.scss b/apps/opik-frontend/src/main.scss index 4360468abf3..175d8bccade 100644 --- a/apps/opik-frontend/src/main.scss +++ b/apps/opik-frontend/src/main.scss @@ -586,6 +586,20 @@ } } +.comet-compare-optimizations-table { + thead[data-sticky-vertical] { + top: 0 !important; + } + + thead:before { + height: 0 !important; + } + + table { + border-bottom: 1px solid hsl(var(--border)) !important; + } +} + .comet-table { /* resets padding for all nested cells wrappers */ td [data-cell-wrapper="true"] [data-cell-wrapper="true"], diff --git a/apps/opik-frontend/tailwind.config.ts b/apps/opik-frontend/tailwind.config.ts index 5e447718443..493b975451d 100644 --- a/apps/opik-frontend/tailwind.config.ts +++ b/apps/opik-frontend/tailwind.config.ts @@ -160,5 +160,6 @@ module.exports = { ], safelist: [ "playground-table", + "comet-compare-optimizations-table", ], };