diff --git a/agenta-web/src/components/GenericDrawer/index.tsx b/agenta-web/src/components/GenericDrawer/index.tsx index 8209095527..323aa76f5d 100644 --- a/agenta-web/src/components/GenericDrawer/index.tsx +++ b/agenta-web/src/components/GenericDrawer/index.tsx @@ -7,10 +7,12 @@ type GenericDrawerProps = { headerExtra?: ReactNode mainContent: ReactNode sideContent?: ReactNode + initialWidth?: number } & React.ComponentProps const GenericDrawer = ({...props}: GenericDrawerProps) => { - const [drawerWidth, setDrawerWidth] = useState(1200) + const initialWidth = props.initialWidth || 1200 + const [drawerWidth, setDrawerWidth] = useState(initialWidth) return ( { {props.expandable && ( + {
[]} dataSource={traces} @@ -615,6 +650,17 @@ const ObservabilityDashboard = () => { /> + {testsetDrawerData.length > 0 && ( + 0} + data={testsetDrawerData} + onClose={() => { + setTestsetDrawerData([]) + setSelectedRowKeys([]) + }} + /> + )} + {activeTrace && !!traces?.length && ( ({ + editor: { + border: `1px solid ${theme.colorBorder}`, + borderRadius: theme.borderRadius, + overflow: "hidden", + "& .monaco-editor": { + width: "0 !important", + }, + }, + drawerHeading: { + fontSize: theme.fontSizeLG, + lineHeight: theme.lineHeightLG, + fontWeight: theme.fontWeightMedium, + }, + container: { + display: "flex", + flexDirection: "column", + gap: 4, + }, + label: { + fontWeight: theme.fontWeightMedium, + }, +})) + +type Mapping = {data: string; column: string; newColumn?: string} +type Preview = {key: string; data: KeyValuePair[]} +export type TestsetTraceData = {key: string; data: KeyValuePair; id: number} +type Props = { + onClose: () => void + data: TestsetTraceData[] +} & React.ComponentProps + +const TestsetDrawer = ({onClose, data, ...props}: Props) => { + const {appTheme} = useAppTheme() + const classes = useStyles() + const {testsets: listOfTestsets, isTestsetsLoading} = useLoadTestsetsList() + const elemRef = useResizeObserver((rect) => { + setIsDrawerExtended(rect.width > 640) + }) + + const [isDrawerExtended, setIsDrawerExtended] = useState(false) + const [isLoading, setIsLoading] = useState(false) + const [traceData, setTraceData] = useState(data.length > 0 ? data : []) + const [testset, setTestset] = useState({name: "", id: ""}) + const [newTestsetName, setNewTestsetName] = useState("") + const [editorFormat, setEditorFormat] = useState("JSON") + const [selectedTestsetColumns, setSelectedTestsetColumns] = useState([]) + const [selectedTestsetRows, setSelectedTestsetRows] = useState([]) + const [showLastFiveRows, setShowLastFiveRows] = useState(false) + const [rowDataPreview, setRowDataPreview] = useState(traceData[0]?.key || "") + const [mappingData, setMappingData] = useState([]) + const [preview, setPreview] = useState({key: traceData[0]?.key || "", data: []}) + const [hasDuplicateColumns, setHasDuplicateColumns] = useState(false) + + const isNewTestset = testset.id === "create" + const elementWidth = isDrawerExtended ? 200 * 2 : 200 + const selectedTestsetTestCases = selectedTestsetRows.slice(-5) + const isMapColumnExist = mappingData.some((mapping) => + mapping.column === "create" || !mapping.column ? !!mapping?.newColumn : !!mapping.column, + ) + + // predefind options + const customSelectOptions = (divider = true) => { + return [ + {value: "create", label: "Create New"}, + ...(divider + ? [ + { + value: "divider", + label: , + className: "!p-0 !m-0 !min-h-0.5 !cursor-default", + disabled: true, + }, + ] + : []), + ] + } + + const onTestsetOptionChange = async (option: {label: string; value: string}) => { + const {value, label} = option + + try { + resetStates() + setTestset({name: label, id: value}) + + if (value && value !== "create") { + const data = await fetchTestset(value) + if (data?.csvdata?.length) { + setSelectedTestsetColumns(Object.keys(data.csvdata[0])) + setSelectedTestsetRows(data.csvdata) + } + } + + if (mappingOptions.length > 0 && value) { + setMappingData((prevMappingData) => + mappingOptions.map((item, index) => ({ + ...prevMappingData[index], + data: item.value, + })), + ) + } + } catch (error) { + message.error("Failed to laod Test sets!") + } + } + + const onRemoveTraceData = () => { + const removeTrace = traceData.filter((trace) => trace.key !== rowDataPreview) + setTraceData(removeTrace) + + if (removeTrace.length > 0) { + const currentIndex = traceData.findIndex((trace) => trace.key === rowDataPreview) + // [currentIndex]: Next option in list | [currentIndex - 1]: Previous option if next doesn't exist | [0]: Default to first option + const nextPreview = + removeTrace[currentIndex] || removeTrace[currentIndex - 1] || removeTrace[0] + + setRowDataPreview(nextPreview.key) + + if (rowDataPreview === preview.key) { + onPreviewOptionChange(nextPreview.key) + } + } else { + setRowDataPreview("") + } + } + + const formatDataPreview = useMemo(() => { + if (!traceData?.length) return "" + + const jsonObject = { + data: + traceData.find((trace) => trace?.key === rowDataPreview)?.data || + traceData[0]?.data, + } + if (!jsonObject) return "" + + try { + return editorFormat === "YAML" ? yaml.dump(jsonObject) : getStringOrJson(jsonObject) + } catch (error) { + message.error("Failed to convert JSON to YAML. Please ensure the data is valid.") + return getStringOrJson(jsonObject) + } + }, [editorFormat, traceData, rowDataPreview]) + + const mappingOptions = useMemo(() => { + const uniquePaths = new Set() + + traceData.forEach((traceItem) => { + const traceKeys = collectKeyPathsFromObject(traceItem?.data, "data") + traceKeys.forEach((key) => uniquePaths.add(key)) + }) + + return Array.from(uniquePaths).map((item) => ({value: item})) + }, [traceData]) + + const columnOptions = useMemo(() => { + const selectedColumns = mappingData + .map((item) => item.column) + .filter((col) => col !== "create") + return selectedTestsetColumns.filter((column) => !selectedColumns.includes(column)) + }, [mappingData, selectedTestsetColumns]) + + const onMappingOptionChange = ({ + pathName, + value, + idx, + }: { + pathName: keyof Mapping + value: string + idx: number + }) => { + setMappingData((prev) => { + const newData = [...prev] + newData[idx] = {...newData[idx], [pathName]: value} + return newData + }) + } + + const onPreviewOptionChange = (value: string) => { + let newTestsetData + if (value === "all") { + newTestsetData = mapAndConvertDataInCsvFormat(traceData, "preview") + } else { + const selectedTraceData = traceData.filter((trace) => trace.key === value) + newTestsetData = mapAndConvertDataInCsvFormat(selectedTraceData, "preview") + } + + setPreview({key: value, data: newTestsetData}) + } + + useUpdateEffect(() => { + const duplicatesExist = hasDuplicateColumnNames() + setHasDuplicateColumns(duplicatesExist) + + if (!duplicatesExist && isMapColumnExist) { + onPreviewOptionChange(preview.key) + } + }, [mappingData]) + + const resetStates = () => { + setSelectedTestsetColumns([]) + setSelectedTestsetRows([]) + setShowLastFiveRows(false) + setMappingData((prev) => prev.map((item) => ({...item, column: "", newColumn: ""}))) + setPreview({key: traceData[0]?.key || "", data: []}) + setNewTestsetName("") + } + + const mapAndConvertDataInCsvFormat = ( + traceData: TestsetTraceData[], + type: "preview" | "export", + ) => { + const formattedData = traceData.map((item) => { + const formattedItem: Record = {} + + for (const mapping of mappingData) { + const keys = mapping.data.split(".") + let value = keys.reduce((acc: any, key) => acc?.[key], item) + + const targetKey = + mapping.column === "create" || !mapping.column + ? mapping.newColumn + : mapping.column + + if (targetKey) { + formattedItem[targetKey] = + value === undefined || value === null + ? "" + : typeof value === "string" + ? value + : JSON.stringify(value) + } + } + + for (const column of selectedTestsetColumns) { + if (!(column in formattedItem)) { + formattedItem[column] = "" + } + } + + return formattedItem + }) + + if (type === "export" && !isNewTestset) { + // add all previous test cases + const allKeys = Array.from(new Set(formattedData.flatMap((item) => Object.keys(item)))) + + selectedTestsetRows.forEach((row) => { + const formattedRow: Record = {} + for (const key of allKeys) { + formattedRow[key] = row[key] ?? "" + } + + formattedData.push(formattedRow) + }) + } + + return formattedData + } + + const onSaveTestset = async () => { + try { + setIsLoading(true) + + const newTestsetData = mapAndConvertDataInCsvFormat(traceData, "export") + + if (isNewTestset) { + if (!newTestsetName) { + message.error("Please add a Test set name before saving it") + return + } + + await createNewTestset(newTestsetName, newTestsetData) + message.success("Test set created successfully") + } else { + await updateTestset(testset.id as string, testset.name, newTestsetData) + message.success("Test set updated successfully") + } + + onClose() + } catch (error) { + console.log(error) + message.error("Something went wrong. Please try again later") + } finally { + setIsLoading(false) + } + } + + const hasDuplicateColumnNames = () => { + const seenValues = new Set() + + return mappingData.some((item) => { + const columnValues = [item.column, item.newColumn] + .filter(Boolean) + .filter((value) => value !== "create") + + return columnValues.some((value) => { + if (seenValues.has(value as string)) return true + seenValues.add(value as string) + return false + }) + }) + } + + const tableColumns = useMemo(() => { + const mappedColumns = mappingData.map((data, idx) => { + const columnData = + data.column === "create" || !data.column ? data.newColumn : data.column + + return { + title: columnData, + dataIndex: columnData, + key: idx, + width: 250, + onHeaderCell: () => ({style: {minWidth: 200}}), + } + }) + + const testsetColumns = showLastFiveRows + ? selectedTestsetColumns.map((item) => ({ + title: item, + dataIndex: item, + key: item, + width: 250, + onHeaderCell: () => ({style: {minWidth: 200}}), + })) + : [] + + // Remove duplicate columns and filter out columns without dataIndex + return [...mappedColumns, ...testsetColumns].filter( + (column, index, self) => + column.dataIndex && + self.findIndex((c) => c.dataIndex === column.dataIndex) === index, + ) + }, [mappingData, selectedTestsetColumns, showLastFiveRows]) + + return ( + <> + + + + + } + mainContent={ +
+ + Spans selected {traceData.length} + + +
+ Test set +
+ setNewTestsetName(e.target.value)} + placeholder="Test set name" + /> + +
+ )} +
+ + +
+ + Data preview + + +
+ + onMappingOptionChange({ + pathName: "data", + value, + idx, + }) + } + options={mappingOptions} + /> + +
+ {!isNewTestset && ( + + onMappingOptionChange({ + pathName: "newColumn", + value: e.target.value, + idx, + }) + } + placeholder="Column name" + /> + +
+ ) : null} +
+ +
+ ))} + + + + + ) : ( + + Please select a test set to create mappings + + )} + + +
+ Preview + {isMapColumnExist ? ( + <> +
+
{ + if (showLastFiveRows) { + const totalRows = + preview.data.length + + selectedTestsetTestCases.length + + if ( + index >= + totalRows - selectedTestsetTestCases.length + ) { + return "!bg-[#fafafa]" + } + } + return "" + }} + scroll={{x: "max-content"}} + bordered + pagination={false} + /> + + + ) : ( + + Please select test set to view test set preview. + + )} + + + } + /> + + ) +} + +export default TestsetDrawer diff --git a/agenta-web/src/components/pages/observability/drawer/TraceContent.tsx b/agenta-web/src/components/pages/observability/drawer/TraceContent.tsx index fab6e3e258..bcc1754a6a 100644 --- a/agenta-web/src/components/pages/observability/drawer/TraceContent.tsx +++ b/agenta-web/src/components/pages/observability/drawer/TraceContent.tsx @@ -1,5 +1,5 @@ import ResultTag from "@/components/ResultTag/ResultTag" -import {JSSTheme} from "@/lib/Types" +import {JSSTheme, KeyValuePair} from "@/lib/Types" import {ArrowRight, Database, PlusCircle, Rocket, Timer} from "@phosphor-icons/react" import {Button, Divider, Space, Tabs, TabsProps, Typography} from "antd" import React, {useState} from "react" @@ -11,6 +11,7 @@ import {statusMapper} from "../components/AvatarTreeContent" import {formatCurrency, formatLatency, formatTokenUsage} from "@/lib/helpers/formatters" import StatusRenderer from "../components/StatusRenderer" import AccordionTreePanel from "../components/AccordionTreePanel" +import TestsetDrawer from "./TestsetDrawer" interface TraceContentProps { activeTrace: _AgentaRootsResponse @@ -85,6 +86,7 @@ const TraceContent = ({activeTrace}: TraceContentProps) => { const classes = useStyles() const [tab, setTab] = useState("overview") const {icon, bgColor, color} = statusMapper(activeTrace.node.type) + const [isTestsetDrawerOpen, setIsTestsetDrawerOpen] = useState(false) const transformDataInputs = (data: any) => { return Object.keys(data).reduce((acc, curr) => { @@ -260,21 +262,16 @@ const TraceContent = ({activeTrace}: TraceContentProps) => { {activeTrace.node.name} - {/* - {!activeTrace.parent && activeTrace.refs?.application?.id && ( - - )} - - */} + @@ -356,14 +353,13 @@ const TraceContent = ({activeTrace}: TraceContentProps) => { /> - {/* -
- Evaluation - - - - -
*/} + {isTestsetDrawerOpen && ( + setIsTestsetDrawerOpen(false)} + /> + )} ) } diff --git a/agenta-web/src/lib/helpers/utils.ts b/agenta-web/src/lib/helpers/utils.ts index 01fac1df35..06d1f52738 100644 --- a/agenta-web/src/lib/helpers/utils.ts +++ b/agenta-web/src/lib/helpers/utils.ts @@ -359,3 +359,20 @@ export const filterVariantParameters = ({ export const formatVariantIdWithHash = (variantId: string) => { return `# ${variantId.split("-")[0]}` } + +export const collectKeyPathsFromObject = (obj: any, prefix = ""): string[] => { + const paths: string[] = [] + + for (const [key, value] of Object.entries(obj)) { + const fullPath = prefix ? `${prefix}.${key}` : key + + if (value && typeof value === "object" && !Array.isArray(value)) { + const nestedPaths = collectKeyPathsFromObject(value, fullPath) + paths.push(...nestedPaths) + } else { + paths.push(fullPath) + } + } + + return paths +}