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> | |
)} | |
</> | |
); | |
} | |