Spaces:
Running
Running
| /** | |
| * | |
| * Copyright 2023-2025 InspectorRAGet Team | |
| * | |
| * Licensed under the Apache License, Version 2.0 (the "License"); | |
| * you may not use this file except in compliance with the License. | |
| * You may obtain a copy of the License at | |
| * | |
| * http://www.apache.org/licenses/LICENSE-2.0 | |
| * | |
| * Unless required by applicable law or agreed to in writing, software | |
| * distributed under the License is distributed on an "AS IS" BASIS, | |
| * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. | |
| * See the License for the specific language governing permissions and | |
| * limitations under the License. | |
| * | |
| **/ | |
| 'use client'; | |
| import { useState, useEffect, useMemo } from 'react'; | |
| import { isEmpty } from 'lodash'; | |
| import { | |
| DataTable, | |
| TableContainer, | |
| Table, | |
| TableToolbar, | |
| TableBatchActions, | |
| TableBatchAction, | |
| TableToolbarContent, | |
| TableToolbarSearch, | |
| TableHead, | |
| TableRow, | |
| TableSelectAll, | |
| TableSelectRow, | |
| TableHeader, | |
| TableBody, | |
| TableCell, | |
| Pagination, | |
| Button, | |
| Tooltip, | |
| } from '@carbon/react'; | |
| import { Flag, FlagFilled, DocumentExport, Chat } from '@carbon/icons-react'; | |
| import { SimpleBarChart } from '@carbon/charts-react'; | |
| import { ScaleTypes } from '@carbon/charts'; | |
| import { useTheme } from '@/src/theme'; | |
| import { Metric, Model, Task, TaskEvaluation } from '@/src/types'; | |
| import { extractMetricDisplayValue } from '@/src/utilities/metrics'; | |
| import { truncate } from '@/src/utilities/strings'; | |
| import { useDataStore } from '@/src/store'; | |
| import { useNotification } from '@/src/components/notification/Notification'; | |
| import { exportData } from '@/src/processor'; | |
| import classes from './TasksTable.module.scss'; | |
| // =================================================================================== | |
| // TYPES | |
| // =================================================================================== | |
| type EvaluationRow = { | |
| id: string; | |
| taskId: string; | |
| }; | |
| interface Props { | |
| metrics: Metric[]; | |
| evaluations: TaskEvaluation[]; | |
| models: Model[]; | |
| filters: { [key: string]: string[] }; | |
| annotator?: string; | |
| onClick: Function; | |
| } | |
| // =================================================================================== | |
| // COMPUTE FUNCTIONS | |
| // =================================================================================== | |
| /** | |
| * Helper function to compute evaluation table headers and rows | |
| * @param evaluations full set of evaluations | |
| * @param metric metric under consideration | |
| * @returns | |
| */ | |
| function populateTable( | |
| evaluations: TaskEvaluation[], | |
| metrics: Metric[], | |
| models: Model[], | |
| taskInputMap: { [key: string]: any }, | |
| filters: { [key: string]: string[] }, | |
| annotator?: string, | |
| ): [{ key: string; header: string }[], EvaluationRow[]] { | |
| const modelIds = new Set<string>(); | |
| const applicableFilters = new Set<string>(); | |
| const headers = [ | |
| { | |
| key: 'task', | |
| header: 'Task', | |
| }, | |
| ]; | |
| const rows: EvaluationRow[] = []; | |
| // Step 1: Build evaluations map combining different model evaluations | |
| const evaluationsMap = new Map<string, any>(); | |
| evaluations.forEach((evaluation) => { | |
| const entry = evaluationsMap.get(evaluation.taskId) || {}; | |
| // Step 1.a: Add filter value, if exists in evaluation and not already added | |
| if (isEmpty(entry) && !isEmpty(filters)) { | |
| for (const filter of Object.keys(filters)) { | |
| if (evaluation.hasOwnProperty(filter)) { | |
| entry[filter] = evaluation[filter]; | |
| // Add to list of applicable filters | |
| applicableFilters.add(filter); | |
| } | |
| } | |
| } | |
| // Add annotations | |
| entry[`${evaluation.modelId}::value`] = {}; | |
| metrics.forEach((metric) => { | |
| if (annotator && evaluation.hasOwnProperty(metric.name)) { | |
| entry[`${evaluation.modelId}::value`][metric.name] = | |
| extractMetricDisplayValue( | |
| evaluation[metric.name][annotator].value, | |
| metric.values, | |
| ); | |
| } else if (evaluation.hasOwnProperty(`${metric.name}_agg`)) { | |
| entry[`${evaluation.modelId}::value`][metric.name] = | |
| extractMetricDisplayValue( | |
| evaluation[`${metric.name}_agg`].value, | |
| metric.values, | |
| ); | |
| } else { | |
| entry[`${evaluation.modelId}::value`][metric.name] = '-'; | |
| } | |
| }); | |
| // Step 1.b: Save updated entry into evaluations map | |
| evaluationsMap.set(evaluation.taskId, entry); | |
| // Step 1.c: Add model id to set of model ids | |
| modelIds.add(evaluation.modelId); | |
| }); | |
| // Step 2: Update evaluation table header based on filters | |
| applicableFilters.forEach((filter) => { | |
| const header = filter.split('_').join(' '); | |
| headers.push({ | |
| key: filter, | |
| header: header.charAt(0).toUpperCase() + header.slice(1).toLowerCase(), | |
| }); | |
| }); | |
| // Step 3: Update evaluation table headers based on model ids | |
| modelIds.forEach((modelId) => { | |
| headers.push({ | |
| key: `${modelId}::value`, | |
| header: | |
| models.find((model) => model.modelId === modelId)?.name || modelId, | |
| }); | |
| }); | |
| // Step 4: Populate evaluation table rows | |
| evaluationsMap.forEach((record, taskId) => { | |
| rows.push({ id: taskId, task: taskInputMap[taskId] || taskId, ...record }); | |
| }); | |
| // Step 3: Populate evaluation table rows | |
| return [headers, rows]; | |
| } | |
| // =================================================================================== | |
| // RENDER FUNCTIONS | |
| // =================================================================================== | |
| /** | |
| * Build sparkline graph | |
| */ | |
| function sparkline( | |
| annotations: { | |
| [key: string]: { timestamp: number; value: string }; | |
| }, | |
| metric: Metric, | |
| key: string, | |
| theme?: string, | |
| ) { | |
| if (annotations) { | |
| // Initialize distribution with potential values for x-axis | |
| const distribution: { [key: string]: number } = metric.values | |
| ? Object.fromEntries( | |
| metric.values.map((entry) => [entry.displayValue, 0]), | |
| ) | |
| : {}; | |
| // Iterate over annotations to populate distribution (y-axis) | |
| for (const [, entries] of Object.entries(annotations)) { | |
| const displayValue = extractMetricDisplayValue( | |
| entries.value, | |
| metric.values, | |
| ); | |
| if (distribution.hasOwnProperty(displayValue)) { | |
| distribution[displayValue] += 1; | |
| } else { | |
| distribution[displayValue] = 1; | |
| } | |
| } | |
| // Transform distribution into data type suitable for SimpleBarChart | |
| const data = Object.entries(distribution).map(([value, count]) => { | |
| return { group: value, value: count }; | |
| }); | |
| // Identify number of unique used values | |
| const numOfUsedValues = data.filter((entry) => entry.value !== 0).length; | |
| return ( | |
| <div key={key}> | |
| <SimpleBarChart | |
| data={data} | |
| options={{ | |
| color: { | |
| scale: Object.fromEntries( | |
| data.map((entry) => [ | |
| entry.group, | |
| numOfUsedValues === 1 | |
| ? '#42be65' | |
| : numOfUsedValues >= 3 | |
| ? '#fa4d56' | |
| : '#f1c21b', | |
| ]), | |
| ), | |
| }, | |
| axes: { | |
| left: { | |
| mapsTo: 'value', | |
| visible: false, | |
| scaleType: ScaleTypes.LINEAR, | |
| }, | |
| bottom: { | |
| mapsTo: 'group', | |
| visible: false, | |
| scaleType: ScaleTypes.LABELS, | |
| }, | |
| }, | |
| grid: { | |
| y: { | |
| enabled: false, | |
| }, | |
| x: { | |
| enabled: false, | |
| }, | |
| }, | |
| legend: { | |
| enabled: false, | |
| }, | |
| toolbar: { | |
| enabled: false, | |
| }, | |
| height: '24px', | |
| width: '48px', | |
| theme: theme, | |
| }} | |
| ></SimpleBarChart> | |
| </div> | |
| ); | |
| } | |
| return null; | |
| } | |
| // =================================================================================== | |
| // MAIN FUNCTION | |
| // =================================================================================== | |
| export default function TasksTable({ | |
| metrics, | |
| evaluations, | |
| models, | |
| filters, | |
| annotator, | |
| onClick, | |
| }: Props) { | |
| // Step 1: Initialize state and necessary variables | |
| const [page, setPage] = useState(1); | |
| const [pageSize, setPageSize] = useState(10); | |
| const [visibleRows, setVisibleRows] = useState<EvaluationRow[]>([]); | |
| // Step 2: Run effects | |
| // Step 2.a: Fetch theme | |
| const { theme } = useTheme(); | |
| // Step 2.b: Fetch data from data store | |
| const { item, taskMap, updateTask: updateTask } = useDataStore(); | |
| // Step 2.c: Notification hook | |
| const { createNotification } = useNotification(); | |
| // Step 2.d: Build evaluations map | |
| const evaluationsMap = useMemo(() => { | |
| return Object.fromEntries( | |
| evaluations.map((evaluation) => [ | |
| `${evaluation.taskId}:${evaluation.modelId}`, | |
| Object.fromEntries( | |
| metrics.map((metric) => [metric.name, evaluation[metric.name]]), | |
| ), | |
| ]), | |
| ); | |
| }, [evaluations, metrics]); | |
| // Step 2.e: Build tasks map | |
| const taskInputMap = useMemo(() => { | |
| return Object.fromEntries( | |
| evaluations.map((evaluation) => [ | |
| `${evaluation.taskId}`, | |
| evaluation.query, | |
| ]), | |
| ); | |
| }, [evaluations]); | |
| // Step 2.f: Populate table header and rows | |
| var [headers, rows]: [{ key: string; header: string }[], EvaluationRow[]] = | |
| useMemo( | |
| () => | |
| populateTable( | |
| evaluations, | |
| metrics, | |
| models, | |
| taskInputMap, | |
| filters, | |
| annotator, | |
| ), | |
| [evaluations, metrics, models, filters, taskInputMap, annotator], | |
| ); | |
| // Step 2.g: Identify visible rows | |
| useEffect(() => { | |
| // Set visible rows | |
| setVisibleRows(() => { | |
| if (rows.length <= pageSize) { | |
| setPage(1); | |
| } | |
| return rows.slice( | |
| (page - 1) * pageSize, | |
| (page - 1) * pageSize + pageSize, | |
| ); | |
| }); | |
| }, [rows, page, pageSize]); | |
| // Step 3: Render | |
| return ( | |
| <> | |
| {headers && rows && ( | |
| <div> | |
| <DataTable | |
| rows={visibleRows} | |
| headers={headers} | |
| isSortable | |
| sortRow={(cellA, cellB, { sortDirection, sortStates }) => { | |
| // Step 1: Check if cell values are objects | |
| if (typeof cellA === 'object' && typeof cellB === 'object') { | |
| // Step 1.a: Get first value for each cell object | |
| const valueA = Object.values(cellA)[0]; | |
| const valueB = Object.values(cellB)[0]; | |
| // Step 1.b: Check if both values are of "string" type | |
| if (typeof valueA === 'string' && typeof valueB === 'string') { | |
| // Step 1.b.i: Check if both values are purely numeric | |
| if ( | |
| !isNaN(parseFloat(valueA)) && | |
| !isNaN(parseFloat(valueB)) | |
| ) { | |
| if (sortDirection === sortStates.DESC) { | |
| return parseFloat(valueA) - parseFloat(valueB); | |
| } | |
| return parseFloat(valueB) - parseFloat(valueA); | |
| } else { | |
| if (sortDirection === sortStates.DESC) { | |
| return valueA.localeCompare(valueB); | |
| } | |
| return valueB.localeCompare(valueA); | |
| } | |
| } | |
| // Step 1.c: Check if both values are of "number" type | |
| else if ( | |
| typeof valueA === 'number' && | |
| typeof valueB === 'number' | |
| ) { | |
| if (sortDirection === sortStates.DESC) { | |
| return valueA - valueB; | |
| } | |
| return valueB - valueA; | |
| } | |
| } | |
| // Step 2: cell values are assumed to be of "string" type | |
| else { | |
| if (sortDirection === sortStates.DESC) { | |
| return cellA.localeCompare(cellB); | |
| } | |
| return cellB.localeCompare(cellA); | |
| } | |
| }} | |
| > | |
| {({ | |
| rows, | |
| headers, | |
| getHeaderProps, | |
| getRowProps, | |
| getToolbarProps, | |
| getSelectionProps, | |
| getBatchActionProps, | |
| selectedRows, | |
| getTableProps, | |
| getTableContainerProps, | |
| onInputChange, | |
| selectRow, | |
| }) => { | |
| const batchActionProps = { | |
| ...getBatchActionProps({ | |
| onSelectAll: () => { | |
| rows.map((row) => { | |
| if (!row.isSelected) { | |
| selectRow(row.id); | |
| } | |
| }); | |
| }, | |
| }), | |
| }; | |
| return ( | |
| <TableContainer | |
| className={classes.table} | |
| {...getTableContainerProps()} | |
| > | |
| <TableToolbar {...getToolbarProps()}> | |
| <TableBatchActions {...batchActionProps}> | |
| <TableBatchAction | |
| tabIndex={ | |
| batchActionProps.shouldShowBatchActions ? 0 : -1 | |
| } | |
| renderIcon={FlagFilled} | |
| onClick={() => { | |
| selectedRows.forEach((entry) => | |
| updateTask(entry.id, { | |
| flagged: true, | |
| }), | |
| ); | |
| }} | |
| > | |
| Flag | |
| </TableBatchAction> | |
| <TableBatchAction | |
| tabIndex={ | |
| batchActionProps.shouldShowBatchActions ? 0 : -1 | |
| } | |
| renderIcon={Flag} | |
| onClick={() => { | |
| selectedRows.forEach((entry) => | |
| updateTask(entry.id, { | |
| flagged: false, | |
| }), | |
| ); | |
| }} | |
| > | |
| Unflag | |
| </TableBatchAction> | |
| <TableBatchAction | |
| tabIndex={ | |
| batchActionProps.shouldShowBatchActions ? 0 : -1 | |
| } | |
| renderIcon={DocumentExport} | |
| onClick={() => { | |
| const success = exportData( | |
| item, | |
| selectedRows.map((entry) => taskMap?.get(entry.id)), | |
| ); | |
| if (success) { | |
| // Notify user about successfuly export | |
| createNotification({ | |
| kind: 'success', | |
| title: 'Export successful.', | |
| subtitle: | |
| 'Please look into browser default save location.', | |
| }); | |
| } else { | |
| // Notify user about invalid request | |
| createNotification({ | |
| kind: 'error', | |
| title: 'Export unsuccessful.', | |
| subtitle: 'Please contact us.', | |
| }); | |
| } | |
| }} | |
| > | |
| Export | |
| </TableBatchAction> | |
| </TableBatchActions> | |
| <TableToolbarContent className={classes.toolbar}> | |
| <TableToolbarSearch | |
| className={classes.toolbarSearch} | |
| onChange={onInputChange} | |
| /> | |
| </TableToolbarContent> | |
| </TableToolbar> | |
| <Table {...getTableProps()}> | |
| <TableHead> | |
| <TableRow> | |
| <TableSelectAll {...getSelectionProps()} /> | |
| {headers.map((header, index) => ( | |
| <TableHeader | |
| key={'header--' + index} | |
| {...getHeaderProps({ header })} | |
| > | |
| {header.header} | |
| </TableHeader> | |
| ))} | |
| </TableRow> | |
| </TableHead> | |
| <TableBody> | |
| {rows.map((row, index) => ( | |
| <TableRow | |
| key={'row--' + index} | |
| {...getRowProps({ row })} | |
| > | |
| <TableSelectRow | |
| {...getSelectionProps({ | |
| row, | |
| })} | |
| /> | |
| {row.cells.map((cell) => | |
| cell.info.header === 'task' ? ( | |
| <TableCell key={cell.id}> | |
| <div className={classes.taskCell}> | |
| <span | |
| className={classes.link} | |
| onClick={() => { | |
| onClick(row.id); | |
| }} | |
| > | |
| {truncate(cell.value, 80)} | |
| </span> | |
| <div className={classes.taskCellDetails}> | |
| {taskMap?.get(cell.id.split(':task')[0]) | |
| ?.comments && ( | |
| <Tooltip | |
| align={'bottom'} | |
| label={ | |
| 'Click to view task with comments' | |
| } | |
| > | |
| <Button | |
| id="comments-btn" | |
| className={classes.ViewCommentsBtn} | |
| kind={'ghost'} | |
| onClick={() => { | |
| onClick(row.id); | |
| }} | |
| > | |
| <Chat /> | |
| { | |
| taskMap?.get( | |
| cell.id.split(':task')[0], | |
| )?.comments?.length | |
| } | |
| </Button> | |
| </Tooltip> | |
| )} | |
| <Tooltip | |
| align={'bottom-right'} | |
| label={'Click to flag task'} | |
| > | |
| <Button | |
| key={`${cell.value}__flag-btn`} | |
| className={classes.flagTaskBtn} | |
| kind={'ghost'} | |
| onClick={() => { | |
| const taskId = | |
| cell.id.split(':task')[0]; | |
| // Step 0: Fetch task to update | |
| const task: Task | undefined = | |
| taskMap?.get(taskId); | |
| // Step 1: Update, if possible | |
| if (task) { | |
| // Step 1.a: Update global copy | |
| updateTask(taskId, { | |
| flagged: !task?.flagged, | |
| }); | |
| } | |
| }} | |
| > | |
| {taskMap?.get(cell.id.split(':task')[0]) | |
| ?.flagged ? ( | |
| <FlagFilled /> | |
| ) : ( | |
| <Flag /> | |
| )} | |
| </Button> | |
| </Tooltip> | |
| </div> | |
| </div> | |
| </TableCell> | |
| ) : ( | |
| <TableCell key={cell.id}> | |
| <div className={classes.tableCell}> | |
| <> | |
| {cell.value ? ( | |
| typeof cell.value === 'object' ? ( | |
| <> | |
| {Array.isArray(cell.value) | |
| ? !isEmpty(cell.value) | |
| ? cell.value.join(', ') | |
| : '-' | |
| : metrics.map((metric) => { | |
| return ( | |
| <> | |
| <div | |
| className={ | |
| classes.tableCellValue | |
| } | |
| key={`${cell.id}::${metric.name}`} | |
| > | |
| <div | |
| className={ | |
| classes.majorityValue | |
| } | |
| > | |
| { | |
| cell.value[ | |
| metric.name | |
| ] | |
| } | |
| </div> | |
| {!annotator && | |
| evaluationsMap[ | |
| cell.id.split( | |
| '::value', | |
| 1, | |
| )[0] | |
| ] | |
| ? sparkline( | |
| evaluationsMap[ | |
| cell.id.split( | |
| '::value', | |
| 1, | |
| )[0] | |
| ][metric.name], | |
| metric, | |
| cell.id, | |
| theme, | |
| ) | |
| : null} | |
| </div> | |
| </> | |
| ); | |
| })} | |
| </> | |
| ) : ( | |
| <div className={classes.majorityValue}> | |
| {Array.isArray(cell.value) | |
| ? !isEmpty(cell.value) | |
| ? cell.value.join(', ') | |
| : '-' | |
| : cell.value} | |
| </div> | |
| ) | |
| ) : ( | |
| <div className={classes.majorityValue}> | |
| - | |
| </div> | |
| )} | |
| </> | |
| </div> | |
| </TableCell> | |
| ), | |
| )} | |
| </TableRow> | |
| ))} | |
| </TableBody> | |
| </Table> | |
| </TableContainer> | |
| ); | |
| }} | |
| </DataTable> | |
| <Pagination | |
| pageSizes={[10, 25, 50]} | |
| totalItems={rows.length} | |
| onChange={(event: any) => { | |
| // Step 1: Update page size | |
| setPageSize(event.pageSize); | |
| // Step 2: Update page | |
| setPage(event.page); | |
| }} | |
| ></Pagination> | |
| </div> | |
| )} | |
| </> | |
| ); | |
| } | |