Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
111 changes: 84 additions & 27 deletions src/components/map-projects/AutoMatchDialog.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ import TagCountLabel from '../common/TagCountLabel'
import RepoChip from '../repos/RepoVersionChip'
import AIAssistantSelectorPanel from './AIAssistantSelectorPanel'
import { getMapperPreview } from '../../common/utils'
import { getRowCapByMatchOperations } from './autoMatchRows'
import { getRowCapByMatchOperations, getAutoMatchBlocker } from './autoMatchRows'


const AutoMatchDialog = ({
Expand All @@ -49,7 +49,9 @@ const AutoMatchDialog = ({
algosSelected,
canSelectAIModel,
previewEligibleRowIndexes,
matchAlgorithmIds
matchAlgorithmIds,
aiOnlyRowCounts,
onConfigure
}) => {
const { t } = useTranslation()
const [algos, setAlgos] = React.useState(true)
Expand Down Expand Up @@ -80,13 +82,18 @@ const AutoMatchDialog = ({
const hasUnmappedRows = unmappedRowsCount > 0
const hasApprovedRows = reviewedRowsCount > 0
const isAllIncludingApproved = autoMatchScope === 'allIncludingApproved'
const hasAlgorithms = algosSelected.length > 0
const retrieveCandidates = algos && hasAlgorithms
const runAI = Boolean(inAIAssistantGroup && autoRunAIAnalysis)
const isAIOnly = runAI && !retrieveCandidates
const aiRowsToAnalyse = isAIOnly ? (aiOnlyRowCounts?.analyse || 0) : 0

// One-time allowance (R2, no reset). Each row costs one match operation per
// selected algorithm that calls $match (TQ6); scispacy, custom algorithms
// with their own url and a bridge the user can't run don't spend any.
const preview = getMapperPreview()
const matchAlgorithmIdSet = React.useMemo(() => new Set(matchAlgorithmIds || []), [matchAlgorithmIds])
const matchAlgorithmCount = algos ? algosSelected.filter(algo => matchAlgorithmIdSet.has(algo.id)).length : 0
const matchAlgorithmCount = retrieveCandidates ? algosSelected.filter(algo => matchAlgorithmIdSet.has(algo.id)).length : 0
const operationsRemaining = (preview.matchOperations.unlimited || !matchAlgorithmCount) ? null : preview.matchOperations.remaining
const rowCap = getRowCapByMatchOperations(preview.matchOperations, matchAlgorithmCount)
const willTruncate = rowCap !== null && rowsToMatchCount > rowCap
Expand All @@ -96,7 +103,8 @@ const AutoMatchDialog = ({
// One $invoke per row. The AI quota never caps rows: once it runs out, the
// rest of the run is matched without AI recommendations.
const aiCallsRemaining = preview.aiAssistantCalls.unlimited ? null : preview.aiAssistantCalls.remaining
const aiRowsCovered = (inAIAssistantGroup && autoRunAIAnalysis && aiCallsRemaining !== null) ? Math.min(aiCallsRemaining, rowsThisRun) : null
const aiRows = isAIOnly ? aiRowsToAnalyse : rowsThisRun
const aiRowsCovered = (runAI && aiCallsRemaining !== null) ? Math.min(aiCallsRemaining, aiRows) : null
const getPreviewEstimate = () => {
if(isPreviewQuotaExhausted)
return t('map_project.preview_estimate_no_operations_left')
Expand All @@ -113,15 +121,17 @@ const AutoMatchDialog = ({
allowed: rowsThisRun.toLocaleString(),
requested: rowsToMatchCount.toLocaleString()
}))
if(aiRowsCovered !== null) {
if(aiRowsCovered >= rowsThisRun)
parts.push(t('map_project.preview_estimate_ai_all', {count: rowsThisRun.toLocaleString()}))
else if(aiRowsCovered === 0)
parts.push(t('map_project.preview_estimate_ai_none'))
if(aiRowsCovered !== null && aiRows > 0) {
if(aiRowsCovered >= aiRows)
parts.push(t('map_project.preview_estimate_ai_all', {count: aiRows.toLocaleString()}))
else if(aiRowsCovered === 0) {
if(!isAIOnly)
parts.push(t('map_project.preview_estimate_ai_none'))
}
else
parts.push(t('map_project.preview_estimate_ai_partial', {
parts.push(t(isAIOnly ? 'map_project.preview_estimate_ai_only_partial' : 'map_project.preview_estimate_ai_partial', {
covered: aiRowsCovered.toLocaleString(),
rest: (rowsThisRun - aiRowsCovered).toLocaleString()
rest: (aiRows - aiRowsCovered).toLocaleString()
}))
}
return parts.join(' ')
Expand Down Expand Up @@ -182,11 +192,26 @@ const AutoMatchDialog = ({
}
]

const blocker = getAutoMatchBlocker({
rowsInScope: rowsToMatchCount,
hasAlgorithms,
retrieveCandidates,
runAI,
aiRowsToAnalyse,
aiCallsRemaining
})
const blockerMessages = {
no_rows: t('map_project.auto_match_blocked_no_rows'),
no_algorithms: t(inAIAssistantGroup ? 'map_project.auto_match_blocked_no_algorithms_ai' : 'map_project.auto_match_blocked_no_algorithms'),
no_step: t(inAIAssistantGroup ? 'map_project.auto_match_blocked_no_step_ai' : 'map_project.auto_match_blocked_no_step'),
no_ai_rows: t('map_project.auto_match_blocked_no_ai_rows'),
no_ai_calls: t('map_project.auto_match_blocked_no_ai_calls'),
}

const isDisabled =
!repoVersion?.version_url ||
rowsToMatchCount === 0 ||
Boolean(blocker) ||
isPreviewQuotaExhausted ||
(!algos && !autoRunAIAnalysis) ||
(isAllIncludingApproved && !confirmAllIncludingApproved)

return (
Expand Down Expand Up @@ -266,19 +291,36 @@ const AutoMatchDialog = ({
</FormControl>

<FormControl sx={{marginTop: '8px'}}>
<FormControlLabel control={<Checkbox checked={algos} onChange={() => setAlgos(!algos)} />} label={t('map_project.retrieve_candidates')} />
<FormLabel id="algorithms" sx={{marginTop: '-4px', marginLeft: '12px'}}>
{t('map_project.retrieve_candidates_helper_text')}
</FormLabel>
<div className='col-xs-12 padding-0' style={{marginLeft: '8px'}}>
{
algosSelected.map(algo => {
return (
<Chip variant='outlined' size='small' color='warning' label={algo.id} key={algo.id} sx={{margin: '4px'}} />
)
})
}
</div>
<FormControlLabel control={<Checkbox checked={retrieveCandidates} disabled={!hasAlgorithms} onChange={() => setAlgos(!algos)} />} label={t('map_project.retrieve_candidates')} />
{
hasAlgorithms ?
<>
<FormLabel id="algorithms" sx={{marginTop: '-4px', marginLeft: '12px'}}>
{t('map_project.retrieve_candidates_helper_text')}
</FormLabel>
<div className='col-xs-12 padding-0' style={{marginLeft: '8px'}}>
{
algosSelected.map(algo => {
return (
<Chip variant='outlined' size='small' color='warning' label={algo.id} key={algo.id} sx={{margin: '4px'}} />
)
})
}
</div>
</> :
<Alert
severity='warning'
sx={{marginTop: '4px', marginLeft: '12px'}}
action={
onConfigure &&
<Button variant='contained' color='primary' size='small' sx={{textTransform: 'none', whiteSpace: 'nowrap'}} onClick={onConfigure}>
{t('map_project.auto_match_configure_algorithms')}
</Button>
}
>
{t('map_project.auto_match_no_algorithms')}
</Alert>
}
</FormControl>

{
Expand All @@ -299,6 +341,15 @@ const AutoMatchDialog = ({
<FormHelperText sx={{marginTop: '-4px'}}>
{t('map_project.run_ai_analysis_note')}
</FormHelperText>
{
isAIOnly && rowsToMatchCount > 0 && aiOnlyRowCounts &&
<Alert severity={aiRowsToAnalyse ? 'info' : 'warning'} sx={{marginTop: '8px'}}>
{t('map_project.auto_match_ai_only_rows', {
analyse: aiOnlyRowCounts.analyse.toLocaleString(),
skip: aiOnlyRowCounts.skip.toLocaleString()
})}
</Alert>
}
{
autoRunAIAnalysis && canSelectAIModel &&
<AIAssistantSelectorPanel
Expand All @@ -315,13 +366,19 @@ const AutoMatchDialog = ({
}
</DialogContent>
<DialogActions sx={{padding: '16px'}}>
{
blocker &&
<FormHelperText sx={{margin: 0, color: 'warning.main'}}>
{blockerMessages[blocker]}
</FormHelperText>
}
<Button
variant='contained'
size='small'
sx={{textTransform: 'none', marginLeft: '12px'}}
endIcon={<DoubleArrowIcon />}
disabled={isDisabled}
onClick={event => onSubmit(event, algos ? map(algosSelected, val => val?.id) : [])}
onClick={event => onSubmit(event, retrieveCandidates ? map(algosSelected, val => val?.id) : [])}
>
{t('common.submit')}
</Button>
Expand Down
55 changes: 47 additions & 8 deletions src/components/map-projects/MapProject.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -87,7 +87,7 @@ import MapProjectDeleteConfirmDialog from './MapProjectDeleteConfirmDialog';
import ConfigurationForm from './ConfigurationForm'
import Controls from './Controls'
import DataGridControls from './DataGridControls'
import { getPreviewEligibleRowIndexes, getRowsToProcess, spendsMatchQuota, getRowCapByMatchOperations, shouldStopAIStep, getAIRequestIdempotencyKey, getCandidatePoolFingerprint, hasCurrentAnalysis, getScispacyRowResults, getPendingRowLookups, waitForLookups, AI_LOOKUP_WAIT_MS, RERANK_LOOKUP_WAIT_MS, RERANK_MAX_IN_FLIGHT, isScispacyWarmingUp } from './autoMatchRows'
import { getPreviewEligibleRowIndexes, getRowsToProcess, countAIOnlyRows, spendsMatchQuota, getRowCapByMatchOperations, shouldStopAIStep, getAIRequestIdempotencyKey, getCandidatePoolFingerprint, hasCurrentAnalysis, getScispacyRowResults, getPendingRowLookups, waitForLookups, AI_LOOKUP_WAIT_MS, RERANK_LOOKUP_WAIT_MS, RERANK_MAX_IN_FLIGHT, isScispacyWarmingUp } from './autoMatchRows'
import { createAutosaveScheduler, createLatestSender, saveOnLeave, trackSave, whenSaved, installUnloadGuard } from './autosave'
import MatchSummaryCard from './MatchSummaryCard'
import MappingDecisionResult from './MappingDecisionResult'
Expand Down Expand Up @@ -285,6 +285,7 @@ const MapProject = () => {
const [scoreBucketSortBy, setScoreBucketSortBy] = React.useState('desc')

const [matchDialog, setMatchDialog] = React.useState(false)
const [aiAnalysableRowIndexes, setAIAnalysableRowIndexes] = React.useState(null)
const [showItem, setShowItem] = React.useState(false)
const [autoMatchScope, setAutoMatchScope] = React.useState('unmapped')
const [autoRunAIAnalysis, setAutoRunAIAnalysis] = React.useState(false)
Expand Down Expand Up @@ -1903,7 +1904,7 @@ const MapProject = () => {
const data = response?.data
const id = data?.id
if(id) {
automatchRunRef.current = {id, algoIds: map(selectedAlgos, 'id')}
automatchRunRef.current = {id, algoIds: map(selectedAlgos, 'id'), aiOnly: !selectedAlgos?.length && withAI}
return automatchRunRef.current
}
if(status === 403 && data?.error_code) {
Expand Down Expand Up @@ -1934,8 +1935,9 @@ const MapProject = () => {
rowStages: rowStageRef.current || [],
rowIndices: map(intendedRows, '__index'),
algoIds: run.algoIds?.length ? run.algoIds : map(selectedAlgos, 'id'),
aiOnly: Boolean(run.aiOnly),
aborted,
stoppedForQuota: Boolean(matchQuotaStopRef.current),
stoppedForQuota: Boolean(matchQuotaStopRef.current || (run.aiOnly && aiQuotaExhaustedRef.current)),
})
// Closing the run is idempotent, so a busy server or a gateway error is
// retried; Stop doesn't end it, so a stopped run is closed too
Expand Down Expand Up @@ -2432,6 +2434,7 @@ const MapProject = () => {
filter(algosSelected, algo => selectedAlgos.includes(algo.id)),
algo => applyRequestSettings(algo, requestLimits)
)
const isAIOnly = !_selectedAlgos.length && Boolean(inAIAssistantGroup && autoRunAIAnalysis)
let subActions = [...map(_selectedAlgos, algo => algo.name || algo.id)]
subActions.push('reranker')
if(isAutoMatchUnmappedOnly)
Expand All @@ -2457,11 +2460,12 @@ const MapProject = () => {
}
})

if(isAutoMatchAllRows)
// An AI-only run doesn't match, so nothing would set these statuses again.
if(isAutoMatchAllRows && !isAIOnly)
setRowStatuses(prev => ({...prev, readyForReview: []}))
if(isAutoMatchAllIncludingApproved)
if(isAutoMatchAllIncludingApproved && !isAIOnly)
setRowStatuses(prev => ({...prev, readyForReview: [], reviewed: []}))
if(isAutoMatchSelectedRows)
if(isAutoMatchSelectedRows && !isAIOnly)
setRowStatuses(prev => ({
...prev,
readyForReview: without(prev.readyForReview, ...selectedRowIndexes),
Expand All @@ -2483,6 +2487,8 @@ const MapProject = () => {
rowsToProcess = rowsToProcess.slice(0, rowCap)
projectLog({action: 'auto_match_pre_truncated', extras: {requested, allowed: rowCap}})
}
if(isAIOnly)
rowsToProcess = getAIAnalysableRows(rowsToProcess)

// ocl_online#105 Phase 5: open the run record, then guarantee it is
// closed out (completed / partial / failed / cancelled) via the finally,
Expand All @@ -2502,6 +2508,8 @@ const MapProject = () => {
const rowId = row.__index
const rowState = { ...(next[rowId] || {}) }
_selectedAlgos.forEach(algo => { rowState[algo.id] = -1 })
if(isAIOnly)
rowState.recommend = -1
// A rerank an earlier run left throttled is this run's to retry.
if(rowState.rerank === -4)
rowState.rerank = -1
Expand Down Expand Up @@ -2888,14 +2896,32 @@ const MapProject = () => {
event.stopPropagation()
event.preventDefault()
setAutoMatchScope(getSelectedRowIndexes().length ? 'selected' : (rowStatuses.unmapped.length ? 'unmapped' : 'all'))
setAIAnalysableRowIndexes(inAIAssistantGroup ? map(getAIAnalysableRows(data), '__index') : null)
setMatchDialog(true)
}

const onAutoMatchConfigure = () => {
setMatchDialog(false)
setConfigureWithAutosave(true)
}

// A run that would do nothing opens no AutomatchRun (ocl_issues#2872).
const hasAutoMatchWork = selectedAlgos => {
if(filter(algosSelected, algo => selectedAlgos.includes(algo.id)).length)
return true
if(!(inAIAssistantGroup && autoRunAIAnalysis))
return false
const scopeRows = getRowsToProcess(data, rowStatuses, autoMatchScope, getSelectedRowIndexes(data), getPreviewEligibleRowIndexes(data, getMapperPreview()))
return getAIAnalysableRows(scopeRows).length > 0
}

const onGetCandidatesSubmit = (event, selectedAlgos) => {
event.stopPropagation()
event.preventDefault()
setAlert(false)
if(isAnyValidColumn()){
if(!hasAutoMatchWork(selectedAlgos)) {
setAlert({message: t('map_project.auto_match_nothing_to_run'), severity: 'warning'})
} else if(isAnyValidColumn()){
setStartMatchingAt(moment())
setBulkAIAnalysisStartedAt(null)
setBulkAIAnalysisEndedAt(null)
Expand Down Expand Up @@ -5230,6 +5256,11 @@ const MapProject = () => {
const visibleRowIds = rows.map(_row => _row.__index)
const visibleRowIdKey = visibleRowIds.join(',')
const selectedRowsCount = getSelectedRowIndexes(rows).length
const aiOnlyRowCounts = (matchDialog && aiAnalysableRowIndexes) ?
countAIOnlyRows(
map(getRowsToProcess(data, rowStatuses, autoMatchScope, getSelectedRowIndexes(data), previewEligibleRowIndexes), '__index'),
aiAnalysableRowIndexes
) : null
React.useEffect(() => {
setSelectedRowIds(prev => {
const visibleRowIdSet = new Set(visibleRowIds.map(id => id?.toString()))
Expand Down Expand Up @@ -5630,6 +5661,12 @@ const MapProject = () => {
}
}

// Rows with candidates and no analysis of them yet; fetchRecommendation skips the rest.
const getAIAnalysableRows = _rows => filter(_rows, _row => {
const concepts = isNumber(_row?.__index) ? buildV2RecommendationPayload(_row.__index)?.recommendable_concepts : null
return Boolean(concepts?.length) && !hasCurrentAnalysis(analysis[_row.__index], getCandidatePoolFingerprint(concepts))
})

const fetchRecommendation = async (_row, resolvedPromptTemplate = null, isBulk = false) => {
let __row = row;
let __index = rowIndex;
Expand Down Expand Up @@ -6348,7 +6385,9 @@ const MapProject = () => {
algosSelected,
canSelectAIModel,
previewEligibleRowIndexes,
matchAlgorithmIds
matchAlgorithmIds,
aiOnlyRowCounts,
onConfigure: onAutoMatchConfigure
}}
/>
{
Expand Down
38 changes: 37 additions & 1 deletion src/components/map-projects/__tests__/autoMatchRows.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,8 @@ import assert from 'node:assert/strict'

import {
getPreviewEligibleRowIndexes, getRowsToProcess, spendsMatchQuota, getRowCapByMatchOperations, shouldStopAIStep,
getAIRequestIdempotencyKey, getCandidatePoolFingerprint, hasCurrentAnalysis, getScispacyRowResults, getPendingRowLookups, waitForLookups, RERANK_LOOKUP_WAIT_MS, AI_LOOKUP_WAIT_MS, isScispacyWarmingUp, RERANK_MAX_IN_FLIGHT
getAIRequestIdempotencyKey, getCandidatePoolFingerprint, hasCurrentAnalysis, getScispacyRowResults, getPendingRowLookups, waitForLookups, RERANK_LOOKUP_WAIT_MS, AI_LOOKUP_WAIT_MS, isScispacyWarmingUp, RERANK_MAX_IN_FLIGHT,
countAIOnlyRows, getAutoMatchBlocker
} from '../autoMatchRows.js'
import { createLimiter } from '../../../services/capacity.js'

Expand Down Expand Up @@ -330,3 +331,38 @@ test('RERANK_MAX_IN_FLIGHT: a batch\'s reranks and the sweep\'s together keep at
await Promise.all([...Array(10).keys(), 'sweep-a', 'sweep-b'].map(rerank))
assert.equal(maxInFlight, 2)
})

test('countAIOnlyRows: rows in scope the AI analyses, and the rest it skips', () => {
assert.deepEqual(countAIOnlyRows([0, 1, 2, 3], [1, 3, 7]), { analyse: 2, skip: 2 })
assert.deepEqual(countAIOnlyRows([0, 1], []), { analyse: 0, skip: 2 })
assert.deepEqual(countAIOnlyRows([], [1]), { analyse: 0, skip: 0 })
})

test('countAIOnlyRows: compares row indexes as strings', () => {
assert.deepEqual(countAIOnlyRows([0, 1], ['1']), { analyse: 1, skip: 1 })
})

test('getAutoMatchBlocker: a run that retrieves candidates for some rows does something', () => {
assert.equal(getAutoMatchBlocker({ rowsInScope: 3, hasAlgorithms: true, retrieveCandidates: true }), null)
assert.equal(getAutoMatchBlocker({ rowsInScope: 3, hasAlgorithms: true, retrieveCandidates: true, runAI: true }), null)
})

test('getAutoMatchBlocker: no rows in scope', () => {
assert.equal(getAutoMatchBlocker({ rowsInScope: 0, hasAlgorithms: true, retrieveCandidates: true }), 'no_rows')
})

test('getAutoMatchBlocker: no algorithm and no AI step', () => {
assert.equal(getAutoMatchBlocker({ rowsInScope: 3 }), 'no_algorithms')
assert.equal(getAutoMatchBlocker({ rowsInScope: 3, hasAlgorithms: true }), 'no_step')
})

test('getAutoMatchBlocker: an AI-only run needs a row to analyse and AI calls left', () => {
assert.equal(getAutoMatchBlocker({ rowsInScope: 3, runAI: true, aiRowsToAnalyse: 2 }), null)
assert.equal(getAutoMatchBlocker({ rowsInScope: 3, runAI: true, aiRowsToAnalyse: 2, aiCallsRemaining: 5 }), null)
assert.equal(getAutoMatchBlocker({ rowsInScope: 3, runAI: true, aiRowsToAnalyse: 0 }), 'no_ai_rows')
assert.equal(getAutoMatchBlocker({ rowsInScope: 3, runAI: true, aiRowsToAnalyse: 2, aiCallsRemaining: 0 }), 'no_ai_calls')
})

test('getAutoMatchBlocker: AI rows and calls don\'t matter to a run that retrieves candidates', () => {
assert.equal(getAutoMatchBlocker({ rowsInScope: 3, hasAlgorithms: true, retrieveCandidates: true, runAI: true, aiRowsToAnalyse: 0, aiCallsRemaining: 0 }), null)
})
Loading
Loading