From 6c96eaffbebfae8b3d70b8a842e450bc24a64bc2 Mon Sep 17 00:00:00 2001 From: Sunny Aggarwal Date: Mon, 5 Oct 2026 20:59:10 +0530 Subject: [PATCH 1/4] OpenConceptLab/ocl_issues#2872 | auto match should not run when algos are configured without any existing candidates --- .../map-projects/AutoMatchDialog.jsx | 111 +++++++++++++----- src/components/map-projects/MapProject.jsx | 55 +++++++-- .../__tests__/autoMatchRows.test.js | 38 +++++- src/components/map-projects/autoMatchRows.js | 29 +++++ src/i18n/locales/en/translations.json | 12 ++ src/i18n/locales/es/translations.json | 12 ++ src/i18n/locales/zh/translations.json | 12 ++ src/services/__tests__/attribution.test.js | 25 ++++ src/services/attribution.js | 7 +- 9 files changed, 264 insertions(+), 37 deletions(-) diff --git a/src/components/map-projects/AutoMatchDialog.jsx b/src/components/map-projects/AutoMatchDialog.jsx index b78b73c6..2a03d2cd 100644 --- a/src/components/map-projects/AutoMatchDialog.jsx +++ b/src/components/map-projects/AutoMatchDialog.jsx @@ -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 = ({ @@ -49,7 +49,9 @@ const AutoMatchDialog = ({ algosSelected, canSelectAIModel, previewEligibleRowIndexes, - matchAlgorithmIds + matchAlgorithmIds, + aiOnlyRowCounts, + onConfigure }) => { const { t } = useTranslation() const [algos, setAlgos] = React.useState(true) @@ -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 @@ -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') @@ -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(' ') @@ -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 ( @@ -266,19 +291,36 @@ const AutoMatchDialog = ({ - setAlgos(!algos)} />} label={t('map_project.retrieve_candidates')} /> - - {t('map_project.retrieve_candidates_helper_text')} - -
- { - algosSelected.map(algo => { - return ( - - ) - }) - } -
+ setAlgos(!algos)} />} label={t('map_project.retrieve_candidates')} /> + { + hasAlgorithms ? + <> + + {t('map_project.retrieve_candidates_helper_text')} + +
+ { + algosSelected.map(algo => { + return ( + + ) + }) + } +
+ : + + {t('map_project.auto_match_configure_algorithms')} + + } + > + {t('map_project.auto_match_no_algorithms')} + + }
{ @@ -299,6 +341,15 @@ const AutoMatchDialog = ({ {t('map_project.run_ai_analysis_note')} + { + isAIOnly && rowsToMatchCount > 0 && aiOnlyRowCounts && + + {t('map_project.auto_match_ai_only_rows', { + analyse: aiOnlyRowCounts.analyse.toLocaleString(), + skip: aiOnlyRowCounts.skip.toLocaleString() + })} + + } { autoRunAIAnalysis && canSelectAIModel && + { + blocker && + + {blockerMessages[blocker]} + + } diff --git a/src/components/map-projects/MapProject.jsx b/src/components/map-projects/MapProject.jsx index 668a79b6..534566f4 100644 --- a/src/components/map-projects/MapProject.jsx +++ b/src/components/map-projects/MapProject.jsx @@ -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' @@ -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) @@ -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) { @@ -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 @@ -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) @@ -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), @@ -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, @@ -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 @@ -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) @@ -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())) @@ -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; @@ -6348,7 +6385,9 @@ const MapProject = () => { algosSelected, canSelectAIModel, previewEligibleRowIndexes, - matchAlgorithmIds + matchAlgorithmIds, + aiOnlyRowCounts, + onConfigure: onAutoMatchConfigure }} /> { diff --git a/src/components/map-projects/__tests__/autoMatchRows.test.js b/src/components/map-projects/__tests__/autoMatchRows.test.js index 64e06945..7f8e0a67 100644 --- a/src/components/map-projects/__tests__/autoMatchRows.test.js +++ b/src/components/map-projects/__tests__/autoMatchRows.test.js @@ -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' @@ -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) +}) diff --git a/src/components/map-projects/autoMatchRows.js b/src/components/map-projects/autoMatchRows.js index 77ab3514..6c8c1206 100644 --- a/src/components/map-projects/autoMatchRows.js +++ b/src/components/map-projects/autoMatchRows.js @@ -54,6 +54,35 @@ export const getRowCapByMatchOperations = (matchOperations, matchAlgorithmCount) return Math.floor(Math.max(matchOperations.remaining || 0, 0) / matchAlgorithmCount) } +export const countAIOnlyRows = (rowIndexes, analysableRowIndexes) => { + const analysable = new Set((analysableRowIndexes || []).map(index => index?.toString())) + const all = rowIndexes || [] + const analyse = all.filter(index => analysable.has(index?.toString())).length + return { analyse, skip: all.length - analyse } +} + +// Why an Auto Match run would do nothing, or null (ocl_issues#2872). +export const getAutoMatchBlocker = ({ + rowsInScope = 0, + hasAlgorithms = false, + retrieveCandidates = false, + runAI = false, + aiRowsToAnalyse = 0, + aiCallsRemaining = null, +} = {}) => { + if(!rowsInScope) + return 'no_rows' + if(!retrieveCandidates && !runAI) + return hasAlgorithms ? 'no_step' : 'no_algorithms' + if(!retrieveCandidates) { + if(!aiRowsToAnalyse) + return 'no_ai_rows' + if(aiCallsRemaining !== null && aiCallsRemaining <= 0) + return 'no_ai_calls' + } + return null +} + // How many consecutive rows may fail, for a reason other than AI quota, before // a run's AI step gives up. The AI Assistant charges each call before the model // runs, so while the AI service is down every further call would spend the diff --git a/src/i18n/locales/en/translations.json b/src/i18n/locales/en/translations.json index c9595bc7..cb700991 100644 --- a/src/i18n/locales/en/translations.json +++ b/src/i18n/locales/en/translations.json @@ -518,6 +518,7 @@ "preview_estimate_ai_all": "AI recommendations for all {{count}} row(s).", "preview_estimate_ai_partial": "AI recommendations for the first {{covered}} row(s); the other {{rest}} are matched without AI.", "preview_estimate_ai_none": "No AI Assistant calls are left, so these rows are matched without AI.", + "preview_estimate_ai_only_partial": "AI recommendations for the first {{covered}} row(s); no AI Assistant calls are left for the other {{rest}}.", "preview_rows_activated_notice": "Preview projects hold {{limit}} rows - we activated the first {{limit}} of your {{total}}.", "ai_assistant_limit_reached": "You've used all of your preview AI Assistant calls. Matching will continue without AI analysis for the remaining rows.", "preview_limit_title_rows": "You've used this project's preview rows", @@ -594,6 +595,17 @@ "row_throttled": "Server busy: not matched yet. Retry later", "algorithm_throttled": "The server is busy, so this row wasn't matched this time. Nothing failed: run it again later.", "auto_match_rows_throttled": "The server was busy, so {{count}} input row(s) weren't matched: {{details}}. Nothing failed. Run Auto Match on those rows again later to finish them.", + "auto_match_no_algorithms": "No matching algorithm is configured for this project, so Auto Match can't retrieve candidates.", + "auto_match_configure_algorithms": "Configure", + "auto_match_ai_only_rows": "Without Retrieve Candidates, the AI analyzes only rows that already have candidates: it will analyze {{analyse}} row(s) and skip {{skip}} that have no candidates or already have an analysis of their current candidates.", + "auto_match_blocked_no_rows": "There are no rows to match in this scope.", + "auto_match_blocked_no_algorithms": "Configure a matching algorithm to run Auto Match.", + "auto_match_blocked_no_algorithms_ai": "Configure a matching algorithm, or select Run AI Analysis.", + "auto_match_blocked_no_step": "Select Retrieve Candidates to run Auto Match.", + "auto_match_blocked_no_step_ai": "Select Retrieve Candidates or Run AI Analysis.", + "auto_match_blocked_no_ai_rows": "The AI has no rows to analyze in this scope.", + "auto_match_blocked_no_ai_calls": "No AI Assistant calls are left, so the AI can't analyze these rows.", + "auto_match_nothing_to_run": "Auto Match had nothing to run: no matching algorithm is selected, and the AI has no rows to analyze.", "rerank_step": "Reranking", "search_throttled": "The server is busy, so the search didn't run. Try again in a few minutes.", "search_failed": "Couldn't search ({{error}}). Try again.", diff --git a/src/i18n/locales/es/translations.json b/src/i18n/locales/es/translations.json index f08cdadd..6933e8d3 100644 --- a/src/i18n/locales/es/translations.json +++ b/src/i18n/locales/es/translations.json @@ -482,6 +482,7 @@ "preview_estimate_ai_all": "Recomendaciones de IA para las {{count}} fila(s).", "preview_estimate_ai_partial": "Recomendaciones de IA para las primeras {{covered}} fila(s); las otras {{rest}} se asignan sin IA.", "preview_estimate_ai_none": "No quedan llamadas al Asistente de IA, así que estas filas se asignan sin IA.", + "preview_estimate_ai_only_partial": "Recomendaciones de IA para las primeras {{covered}} fila(s); no quedan llamadas al Asistente de IA para las otras {{rest}}.", "preview_rows_activated_notice": "Los proyectos de vista previa contienen {{limit}} filas: activamos las primeras {{limit}} de tus {{total}}.", "ai_assistant_limit_reached": "Has usado todas tus llamadas de vista previa al Asistente de IA. La asignación continuará sin análisis de IA para las filas restantes.", "preview_limit_title_rows": "Has usado las filas de vista previa de este proyecto", @@ -558,6 +559,17 @@ "row_throttled": "Servidor ocupado: aún sin procesar. Reintenta más tarde", "algorithm_throttled": "El servidor está ocupado, así que esta fila no se procesó esta vez. Nada falló: vuelve a ejecutarla más tarde.", "auto_match_rows_throttled": "El servidor estaba ocupado, así que {{count}} fila(s) de entrada no se procesaron: {{details}}. Nada falló. Vuelve a ejecutar Auto Match en esas filas más tarde para terminarlas.", + "auto_match_no_algorithms": "Este proyecto no tiene ningún algoritmo de coincidencia configurado, así que Auto Match no puede recuperar candidatos.", + "auto_match_configure_algorithms": "Configurar", + "auto_match_ai_only_rows": "Sin Recuperar candidatos, la IA analiza solo las filas que ya tienen candidatos: analizará {{analyse}} fila(s) y omitirá {{skip}} que no tienen candidatos o ya tienen un análisis de sus candidatos actuales.", + "auto_match_blocked_no_rows": "No hay filas para coincidir en este alcance.", + "auto_match_blocked_no_algorithms": "Configura un algoritmo de coincidencia para ejecutar Auto Match.", + "auto_match_blocked_no_algorithms_ai": "Configura un algoritmo de coincidencia o selecciona Ejecutar Análisis de IA.", + "auto_match_blocked_no_step": "Selecciona Recuperar candidatos para ejecutar Auto Match.", + "auto_match_blocked_no_step_ai": "Selecciona Recuperar candidatos o Ejecutar Análisis de IA.", + "auto_match_blocked_no_ai_rows": "La IA no tiene filas que analizar en este alcance.", + "auto_match_blocked_no_ai_calls": "No quedan llamadas al Asistente de IA, así que la IA no puede analizar estas filas.", + "auto_match_nothing_to_run": "Auto Match no tenía nada que ejecutar: no hay ningún algoritmo de coincidencia seleccionado y la IA no tiene filas que analizar.", "rerank_step": "Reordenamiento", "search_throttled": "El servidor está ocupado, así que la búsqueda no se ejecutó. Inténtalo de nuevo en unos minutos.", "search_failed": "No se pudo buscar ({{error}}). Inténtalo de nuevo.", diff --git a/src/i18n/locales/zh/translations.json b/src/i18n/locales/zh/translations.json index 01047477..9510d741 100644 --- a/src/i18n/locales/zh/translations.json +++ b/src/i18n/locales/zh/translations.json @@ -507,6 +507,7 @@ "preview_estimate_ai_all": "全部 {{count}} 行都将获得 AI 推荐。", "preview_estimate_ai_partial": "前 {{covered}} 行将获得 AI 推荐;其余 {{rest}} 行将在没有 AI 的情况下匹配。", "preview_estimate_ai_none": "AI 助手调用次数已用完,因此这些行将在没有 AI 的情况下匹配。", + "preview_estimate_ai_only_partial": "前 {{covered}} 行将获得 AI 推荐;其余 {{rest}} 行已没有 AI 助手调用次数。", "preview_rows_activated_notice": "预览项目最多包含 {{limit}} 行——我们已激活你 {{total}} 行中的前 {{limit}} 行。", "ai_assistant_limit_reached": "你的预览版 AI 助手调用次数已用完。匹配将继续进行,剩余行不再进行 AI 分析。", "preview_limit_title_rows": "本项目的预览行数已用完", @@ -583,6 +584,17 @@ "row_throttled": "服务器繁忙:尚未匹配。请稍后重试", "algorithm_throttled": "服务器繁忙,本次未能匹配此行。没有出错:请稍后重新运行。", "auto_match_rows_throttled": "服务器繁忙,{{count}} 个输入行未能匹配:{{details}}。没有出错。请稍后对这些行重新运行自动匹配以完成匹配。", + "auto_match_no_algorithms": "此项目未配置任何匹配算法,因此自动匹配无法获取候选项。", + "auto_match_configure_algorithms": "配置", + "auto_match_ai_only_rows": "不获取候选项时,AI 只分析已有候选项的行:将分析 {{analyse}} 行,跳过 {{skip}} 行(没有候选项,或已对其当前候选项进行过分析)。", + "auto_match_blocked_no_rows": "此范围内没有要匹配的行。", + "auto_match_blocked_no_algorithms": "请配置匹配算法以运行自动匹配。", + "auto_match_blocked_no_algorithms_ai": "请配置匹配算法,或选择运行 AI 分析。", + "auto_match_blocked_no_step": "请选择获取候选项以运行自动匹配。", + "auto_match_blocked_no_step_ai": "请选择获取候选项或运行 AI 分析。", + "auto_match_blocked_no_ai_rows": "此范围内没有可供 AI 分析的行。", + "auto_match_blocked_no_ai_calls": "AI 助手调用次数已用完,因此 AI 无法分析这些行。", + "auto_match_nothing_to_run": "自动匹配没有可运行的内容:未选择任何匹配算法,且 AI 没有可分析的行。", "rerank_step": "重新排序", "search_throttled": "服务器繁忙,搜索未能运行。请几分钟后重试。", "search_failed": "无法搜索({{error}})。请重试。", diff --git a/src/services/__tests__/attribution.test.js b/src/services/__tests__/attribution.test.js index 256de2e1..5e194af7 100644 --- a/src/services/__tests__/attribution.test.js +++ b/src/services/__tests__/attribution.test.js @@ -241,6 +241,31 @@ test('a throttled rerank (-4) makes the run partial; the row still counts as com assert.deepEqual(r, { completed_rows: 2, failed_rows: 0, completion_status: 'partial' }) }) +// ocl_issues#2872 +test('an AI-only run that analysed every row is completed', () => { + const rowStages = { 0: {recommend: 1}, 1: {recommend: 1} } + const r = summarizeRunCompletion({ rowStages, rowIndices: [0, 1], algoIds: [], aiOnly: true }) + assert.deepEqual(r, { completed_rows: 2, failed_rows: 0, completion_status: 'completed' }) +}) + +test('an AI-only run counts failed analyses, and rows it never reached as neither', () => { + const rowStages = { 0: {recommend: 1}, 1: {recommend: -2}, 2: {recommend: -1} } + const r = summarizeRunCompletion({ rowStages, rowIndices: [0, 1, 2], algoIds: [], aiOnly: true }) + assert.deepEqual(r, { completed_rows: 1, failed_rows: 1, completion_status: 'partial' }) +}) + +test('an AI-only run stopped by the AI quota is partial', () => { + const rowStages = { 0: {recommend: 1}, 1: {recommend: -3} } + const r = summarizeRunCompletion({ rowStages, rowIndices: [0, 1], algoIds: [], aiOnly: true, stoppedForQuota: true }) + assert.deepEqual(r, { completed_rows: 1, failed_rows: 0, completion_status: 'partial' }) +}) + +test('a run with match algorithms is still recorded by them, not by its AI step', () => { + const rowStages = { 0: {'ocl-search': -2, recommend: 1} } + const r = summarizeRunCompletion({ rowStages, rowIndices: [0], algoIds: ['ocl-search'] }) + assert.deepEqual(r, { completed_rows: 0, failed_rows: 1, completion_status: 'failed' }) +}) + test('capacityAware: the capacity_aware flag, as the string "true" (oclapi2 enforce_for=aware)', () => { const m = meta(buildAttributionHeaders({ runId: 7, projectId: 2, rowIndices: [1, 2], algorithmId: 'ocl-semantic', capacityAware: true })) assert.equal(m.capacity_aware, 'true') diff --git a/src/services/attribution.js b/src/services/attribution.js index 3b7c6852..05a0d58f 100644 --- a/src/services/attribution.js +++ b/src/services/attribution.js @@ -145,10 +145,13 @@ export const buildConfigSnapshot = ({ * the row wasn't run) count as NEITHER, so a cancelled run never over-reports * completed_rows (ocl_online#115 review). * + * An AI-only run is recorded by its 'recommend' stage instead (ocl_issues#2872). + * * @param {object} [opts] * @param {object[]} [opts.rowStages] array indexed by row __index → {algoId: stage} * @param {number[]} [opts.rowIndices] the run's intended row indices * @param {string[]} [opts.algoIds] the selected match-algorithm ids + * @param {boolean} [opts.aiOnly] an AI-only run, recorded by its 'recommend' stage * @param {boolean} [opts.aborted] whether the run was cancelled * @returns {{completed_rows:number, failed_rows:number, completion_status:string}} */ @@ -156,15 +159,17 @@ export const summarizeRunCompletion = ({ rowStages = [], rowIndices = [], algoIds = [], + aiOnly = false, aborted = false, stoppedForQuota = false, } = {}) => { + const stageIds = aiOnly ? ['recommend'] : algoIds let completed = 0 let failed = 0 let throttled = 0 rowIndices.forEach(idx => { const stage = rowStages[idx] || {} - const attempted = algoIds.map(id => stage[id]).filter(s => s !== undefined && s !== -1) + const attempted = stageIds.map(id => stage[id]).filter(s => s !== undefined && s !== -1) if(!attempted.length) return // A row whose rerank stayed throttled is matched but not ranked. if(attempted.some(s => s === -4) || stage.rerank === -4) throttled += 1 From 87c9775905a271ac1ba2f74d1d5ae254cd2c2af3 Mon Sep 17 00:00:00 2001 From: Sunny Aggarwal Date: Tue, 6 Oct 2026 21:06:23 +0530 Subject: [PATCH 2/4] OpenConceptLab/ocl_issues#2872 | review feedbacks --- .../map-projects/AutoMatchDialog.jsx | 19 +-- src/components/map-projects/MapProject.jsx | 122 +++++++++++------- .../__tests__/autoMatchRows.test.js | 23 +++- src/components/map-projects/autoMatchRows.js | 9 ++ src/i18n/locales/en/translations.json | 17 +-- src/i18n/locales/es/translations.json | 25 ++-- src/i18n/locales/zh/translations.json | 17 +-- src/services/__tests__/attribution.test.js | 6 + src/services/attribution.js | 5 +- 9 files changed, 155 insertions(+), 88 deletions(-) diff --git a/src/components/map-projects/AutoMatchDialog.jsx b/src/components/map-projects/AutoMatchDialog.jsx index 2a03d2cd..0b16f276 100644 --- a/src/components/map-projects/AutoMatchDialog.jsx +++ b/src/components/map-projects/AutoMatchDialog.jsx @@ -47,6 +47,9 @@ const AutoMatchDialog = ({ onSubmit, inAIAssistantGroup, algosSelected, + runnableAlgos, + retrieveCandidates: retrieveCandidatesChecked, + setRetrieveCandidates, canSelectAIModel, previewEligibleRowIndexes, matchAlgorithmIds, @@ -54,7 +57,6 @@ const AutoMatchDialog = ({ onConfigure }) => { const { t } = useTranslation() - const [algos, setAlgos] = React.useState(true) const [confirmAllIncludingApproved, setConfirmAllIncludingApproved] = React.useState(false) const previewEligibleRowIndexSet = React.useMemo( () => Array.isArray(previewEligibleRowIndexes) ? new Set(previewEligibleRowIndexes.map(id => id?.toString())) : null, @@ -82,8 +84,9 @@ const AutoMatchDialog = ({ const hasUnmappedRows = unmappedRowsCount > 0 const hasApprovedRows = reviewedRowsCount > 0 const isAllIncludingApproved = autoMatchScope === 'allIncludingApproved' - const hasAlgorithms = algosSelected.length > 0 - const retrieveCandidates = algos && hasAlgorithms + // Only the algorithms this user can run retrieve candidates (ocl_issues#2872). + const hasAlgorithms = runnableAlgos.length > 0 + const retrieveCandidates = retrieveCandidatesChecked && hasAlgorithms const runAI = Boolean(inAIAssistantGroup && autoRunAIAnalysis) const isAIOnly = runAI && !retrieveCandidates const aiRowsToAnalyse = isAIOnly ? (aiOnlyRowCounts?.analyse || 0) : 0 @@ -93,7 +96,7 @@ const AutoMatchDialog = ({ // 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 = retrieveCandidates ? algosSelected.filter(algo => matchAlgorithmIdSet.has(algo.id)).length : 0 + const matchAlgorithmCount = retrieveCandidates ? runnableAlgos.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 @@ -291,7 +294,7 @@ const AutoMatchDialog = ({ - setAlgos(!algos)} />} label={t('map_project.retrieve_candidates')} /> + setRetrieveCandidates(event.target.checked)} />} label={t('map_project.retrieve_candidates')} /> { hasAlgorithms ? <> @@ -300,7 +303,7 @@ const AutoMatchDialog = ({
{ - algosSelected.map(algo => { + runnableAlgos.map(algo => { return ( ) @@ -318,7 +321,7 @@ const AutoMatchDialog = ({ } > - {t('map_project.auto_match_no_algorithms')} + {t(algosSelected.length ? 'map_project.auto_match_no_runnable_algorithms' : 'map_project.auto_match_no_algorithms')} } @@ -378,7 +381,7 @@ const AutoMatchDialog = ({ sx={{textTransform: 'none', marginLeft: '12px'}} endIcon={} disabled={isDisabled} - onClick={event => onSubmit(event, retrieveCandidates ? map(algosSelected, val => val?.id) : [])} + onClick={event => onSubmit(event, retrieveCandidates ? map(runnableAlgos, val => val?.id) : [])} > {t('common.submit')} diff --git a/src/components/map-projects/MapProject.jsx b/src/components/map-projects/MapProject.jsx index 534566f4..c03576f5 100644 --- a/src/components/map-projects/MapProject.jsx +++ b/src/components/map-projects/MapProject.jsx @@ -87,7 +87,7 @@ import MapProjectDeleteConfirmDialog from './MapProjectDeleteConfirmDialog'; import ConfigurationForm from './ConfigurationForm' import Controls from './Controls' import DataGridControls from './DataGridControls' -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 { getPreviewEligibleRowIndexes, getRowsToProcess, countAIOnlyRows, canRunAlgorithm, 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' @@ -188,7 +188,7 @@ const MapProject = () => { // concept_key (makeConceptKey output). The two key namespaces don't // collide — opaque keys are JSON.stringify'd arrays, URLs start with '/'. // PR3 collapses to key-only when legacy save/load is dropped. - const [, setRowMatchState] = React.useState({}) + const [rowMatchState, setRowMatchState] = React.useState({}) const rowMatchStateRef = React.useRef({}) const [searchedConcepts, setSearchedConcepts] = React.useState({}); @@ -285,7 +285,7 @@ const MapProject = () => { const [scoreBucketSortBy, setScoreBucketSortBy] = React.useState('desc') const [matchDialog, setMatchDialog] = React.useState(false) - const [aiAnalysableRowIndexes, setAIAnalysableRowIndexes] = React.useState(null) + const [autoRetrieveCandidates, setAutoRetrieveCandidates] = React.useState(true) const [showItem, setShowItem] = React.useState(false) const [autoMatchScope, setAutoMatchScope] = React.useState('unmapped') const [autoRunAIAnalysis, setAutoRunAIAnalysis] = React.useState(false) @@ -625,6 +625,7 @@ const MapProject = () => { const canScispacy = Boolean( hasCapability(user, 'users.mapper_scispacy') && canBridge && SCISPACY_API_URL && toggles.SCISPACY_LOINC_TOGGLE === true ) + const runnableAlgos = filter(algosSelected, algo => canRunAlgorithm(algo, {canBridge, canScispacy})) const matchAlgorithmIds = map(filter(algosSelected, algo => spendsMatchQuota(algo, {canBridge})), 'id') const isMultiAlgo = algosSelected.length > 1 const scispacyEnabled = find(algosSelected, {type: 'ocl-scispacy'}) @@ -1927,12 +1928,16 @@ const MapProject = () => { // was never created) must not surface to the user. // Each run closes its own record, passed in: by the time a stopped run gets // here, a newer run's record may be the current one (ocl_issues#2849). - const completeAutomatchRun = async (selectedAlgos, intendedRows, run, aborted) => { + // An AI-only run is recorded from aiOutcomes, its own AI results, not the + // shared row stages a manual action or a stopped run's late call can change. + const completeAutomatchRun = async (selectedAlgos, intendedRows, run, aborted, aiOutcomes = {}) => { if(automatchRunRef.current === run) automatchRunRef.current = null if(!run?.id) return + const aiStages = {} + forEach(aiOutcomes, (stage, index) => { aiStages[index] = {recommend: stage} }) const payload = summarizeRunCompletion({ - rowStages: rowStageRef.current || [], + rowStages: run.aiOnly ? aiStages : (rowStageRef.current || []), rowIndices: map(intendedRows, '__index'), algoIds: run.algoIds?.length ? run.algoIds : map(selectedAlgos, 'id'), aiOnly: Boolean(run.aiOnly), @@ -2431,7 +2436,7 @@ const MapProject = () => { // Capped users' algorithms carry the settings they run with, so the // AutomatchRun's config snapshot records what ran (ocl_online#274). let _selectedAlgos = map( - filter(algosSelected, algo => selectedAlgos.includes(algo.id)), + filter(runnableAlgos, algo => selectedAlgos.includes(algo.id)), algo => applyRequestSettings(algo, requestLimits) ) const isAIOnly = !_selectedAlgos.length && Boolean(inAIAssistantGroup && autoRunAIAnalysis) @@ -2497,6 +2502,7 @@ const MapProject = () => { // False when the run was refused (a preview limit) or stopped before it // began: it changed nothing to save. const runStarted = !isRunStopped() + const aiOutcomes = {} try { bulkMatchAlgoIdsRef.current = map(_selectedAlgos, 'id') // Reset all algo stages to -1 for every row before starting so that @@ -2508,8 +2514,6 @@ 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 @@ -2545,7 +2549,7 @@ const MapProject = () => { if(runAIStep) await new Promise(resolve => setTimeout(resolve, 1000)) if(runAIStep && !isRunStopped()) { - await runBulkAIAnalysis(finishedRows, {isStopped: isRunStopped, isCurrent: isCurrentRun}) + await runBulkAIAnalysis(finishedRows, {isStopped: isRunStopped, isCurrent: isCurrentRun, outcomes: aiOutcomes}) } else if(isCurrentRun()) { // A newer run's loading state is its own. setIsLoadingInDecisionView(false) @@ -2611,7 +2615,7 @@ const MapProject = () => { // way (ocl_issues#2849). if(runStarted) scheduleAutoSave(isRunStopped() ? 'auto_match_stopped' : 'auto_match') - await completeAutomatchRun(_selectedAlgos, rowsToProcess, runRecord, isRunStopped()) + await completeAutomatchRun(_selectedAlgos, rowsToProcess, runRecord, isRunStopped(), aiOutcomes) refreshMapperQuotaCache() } }, 1000) @@ -2622,8 +2626,9 @@ const MapProject = () => { }, [conceptCache]); // isStopped: the run's Stop; isCurrent: false once a newer run started, whose - // loading state is its own (ocl_issues#2849). - const runBulkAIAnalysis = async (_rows, {isStopped = () => abortRef.current, isCurrent = () => true} = {}) => { + // loading state is its own (ocl_issues#2849). outcomes collects each row's + // AI result (fetchRecommendation's return) for the run's record. + const runBulkAIAnalysis = async (_rows, {isStopped = () => abortRef.current, isCurrent = () => true, outcomes = null} = {}) => { GAService.recordActionEvent('MapProject', 'ai_assistant_run', undefined, { mode: 'bulk', row_count: _rows.length @@ -2664,7 +2669,9 @@ const MapProject = () => { if (shouldStopAIStep({quotaExhausted: aiQuotaExhaustedRef.current, failuresInARow: aiFailuresInARowRef.current})) break; // A stopped run moves on; the row's call finishes in the background. - await untilCancelled(fetchRecommendation(_rows[index], resolvedPromptTemplate, true), isStopped); + const result = await untilCancelled(fetchRecommendation(_rows[index], resolvedPromptTemplate, true), isStopped); + if(outcomes && result.settled && isNumber(result.value)) + outcomes[_rows[index].__index] = result.value } if(!isCurrent()) return @@ -2896,7 +2903,6 @@ const MapProject = () => { event.stopPropagation() event.preventDefault() setAutoMatchScope(getSelectedRowIndexes().length ? 'selected' : (rowStatuses.unmapped.length ? 'unmapped' : 'all')) - setAIAnalysableRowIndexes(inAIAssistantGroup ? map(getAIAnalysableRows(data), '__index') : null) setMatchDialog(true) } @@ -2907,12 +2913,9 @@ const MapProject = () => { // A run that would do nothing opens no AutomatchRun (ocl_issues#2872). const hasAutoMatchWork = selectedAlgos => { - if(filter(algosSelected, algo => selectedAlgos.includes(algo.id)).length) + if(filter(runnableAlgos, 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 + return Boolean(inAIAssistantGroup && autoRunAIAnalysis) && (aiOnlyRowCounts?.analyse || 0) > 0 } const onGetCandidatesSubmit = (event, selectedAlgos) => { @@ -5256,11 +5259,6 @@ 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())) @@ -5667,6 +5665,16 @@ const MapProject = () => { return Boolean(concepts?.length) && !hasCurrentAnalysis(analysis[_row.__index], getCandidatePoolFingerprint(concepts)) }) + // Counted only while the dialog shows it, over the scope's rows, and again + // as their candidates or analyses change. + const isAutoMatchAIOnly = Boolean(inAIAssistantGroup && autoRunAIAnalysis) && !(autoRetrieveCandidates && runnableAlgos.length) + const aiOnlyRowCounts = React.useMemo(() => { + if(!matchDialog || !isAutoMatchAIOnly) + return null + const scopeRows = getRowsToProcess(data, rowStatuses, autoMatchScope, getSelectedRowIndexes(data), previewEligibleRowIndexes) + return countAIOnlyRows(map(scopeRows, '__index'), map(getAIAnalysableRows(scopeRows), '__index')) + }, [matchDialog, isAutoMatchAIOnly, data, rowStatuses, autoMatchScope, selectedRowIds, previewEligibleRowIndexes, analysis, rowMatchState, conceptCache, repoVersion]) + const fetchRecommendation = async (_row, resolvedPromptTemplate = null, isBulk = false) => { let __row = row; let __index = rowIndex; @@ -5674,10 +5682,20 @@ const MapProject = () => { __row = _row __index = _row.__index } + // Returns the row's AI outcome as a stage (1 analysed, -2 failed, -3 not + // run), or null when the row was skipped or the run stopped. A run's call + // that ends after a newer run started leaves that run's stages and failure + // count alone (ocl_issues#2872). + const runTicket = autoMatchRunTicketRef.current + const isSuperseded = () => isBulk && runTicket !== autoMatchRunTicketRef.current + const markRecommend = stage => { + if(!isSuperseded()) + markAlgo(__index, 'recommend', stage) + } if(!AI_ASSISTANT_API_URL) { - markAlgo(__index, 'recommend', -3) + markRecommend(-3) console.error('AI ASSISTANT is not enabled for you.') - return false + return -3 } // Auto Match (caller supplied resolvedPromptTemplate) skips a row whose // latest analysis saw this same candidate pool; a re-run whose candidates @@ -5694,7 +5712,7 @@ const MapProject = () => { await waitForLookups(getPendingRowLookups(rowMatchStateRef.current[__index], inFlightLookupsRef.current), AI_LOOKUP_WAIT_MS, stuckLookupsRef.current) // The user may have pressed Stop while this row waited. if(abortRef.current) - return false + return null } const v2 = isNumber(__index) ? buildV2RecommendationPayload(__index) : null const candidatePoolFingerprint = getCandidatePoolFingerprint(v2?.recommendable_concepts) @@ -5702,7 +5720,7 @@ const MapProject = () => { if(isNumber(__index) && repoVersion && !alreadyAnalyzed && (v2?.recommendable_concepts?.length || 0) > 0) { if(!isBulk) GAService.recordActionEvent('MapProject', 'ai_assistant_run', undefined, { mode: 'single' }) - markAlgo(__index, 'recommend', 0) + markRecommend(0) let rowData = prepareRow(__row, true, true) const requestedModel = getRequestedAIModel() @@ -5711,8 +5729,8 @@ const MapProject = () => { activePromptTemplate = resolvedPromptTemplate || await resolvePromptTemplateForInvocation() if(!activePromptTemplate?.key) { setAlert({message: 'AI Assistant prompt template is not available', severity: 'error'}) - markAlgo(__index, 'recommend', -3) - return false + markRecommend(-3) + return -3 } // Single-row invocations (no caller-supplied resolvedPromptTemplate) // should always hit the latest prompt template, NOT a pinned version. @@ -5734,12 +5752,12 @@ const MapProject = () => { } } } catch (err) { - markAlgo(__index, 'recommend', -2) + markRecommend(-2) const errorMessage = err?.message || t('unknown_error') let timestamp = moment().toDate() log({created_at: timestamp, action: 'AIRecommendation', description: errorMessage, extras: {error: errorMessage, model: requestedModel, prompt_template: getPromptTemplateRef(), prompt_template_uri: getPromptTemplateRef()?.uri}}, __index) setAlert({message: errorMessage, severity: 'error'}) - return false + return -2 } const promptTemplateRef = getPromptTemplateRef(activePromptTemplate) const payload = { @@ -5786,21 +5804,23 @@ const MapProject = () => { } ) let timestamp = moment().toDate() - if(handlePreviewLimitError(response, __index, 'recommend', {isRun: isBulk, stopPhase: 'ai'})) { - markAlgo(__index, 'recommend', -3) + if(handlePreviewLimitError(response, isSuperseded() ? null : __index, 'recommend', {isRun: isBulk, stopPhase: 'ai'})) { + markRecommend(-3) log({created_at: timestamp, action: 'AIRecommendationLimitReached', extras: {model: requestedModel, prompt_template: promptTemplateRef, prompt_template_uri: promptTemplateRef?.uri}}) - return false + return -3 } if(response?.detail) { - markAlgo(__index, 'recommend', -2) - aiFailuresInARowRef.current += 1 + markRecommend(-2) + if(!isSuperseded()) + aiFailuresInARowRef.current += 1 log({created_at: timestamp, action: 'AIRecommendation', description: response.detail, extras: {error: response.detail, model: requestedModel, prompt_template: promptTemplateRef, prompt_template_uri: promptTemplateRef?.uri}}) setAlert({message: response.detail, severity: 'error'}) - return false + return -2 } - markAlgo(__index, 'recommend', 1) - aiFailuresInARowRef.current = 0 + markRecommend(1) + if(!isSuperseded()) + aiFailuresInARowRef.current = 0 // Record the model that answered, not the one the UI shows: the AI // Assistant runs the template's default for everyone but staff. const modelUsed = getModelUsed(response.data) || requestedModel?.id || null @@ -5822,26 +5842,27 @@ const MapProject = () => { // state, possibly over a newer one. if(!isBulk && isMountedRef.current) scheduleAutoSave('ai_recommendation') - return true + return 1 } catch (err) { - if(handlePreviewLimitError(err, __index, 'recommend', {isRun: isBulk, stopPhase: 'ai'})) { - markAlgo(__index, 'recommend', -3) + if(handlePreviewLimitError(err, isSuperseded() ? null : __index, 'recommend', {isRun: isBulk, stopPhase: 'ai'})) { + markRecommend(-3) log({created_at: moment().toDate(), action: 'AIRecommendationLimitReached', extras: {model: requestedModel, prompt_template: promptTemplateRef, prompt_template_uri: promptTemplateRef?.uri}}) - return false + return -3 } - markAlgo(__index, 'recommend', -2) - aiFailuresInARowRef.current += 1 + markRecommend(-2) + if(!isSuperseded()) + aiFailuresInARowRef.current += 1 const errorMessage = err?.detail || err?.response?.data?.detail || err?.message || t('unknown_error') setAlert({message: errorMessage, severity: 'error'}) - return false + return -2 } finally { if(!isBulk) refreshMapperQuotaCache() } } else { - markAlgo(__index, 'recommend', analysis[__index]?.length > 0 ? 1 : -3) + markRecommend(analysis[__index]?.length > 0 ? 1 : -3) } - return false + return null } const getRowNameValue = _row => get(_row, find(columns, {label: 'Name'})?.dataKey) @@ -6383,6 +6404,9 @@ const MapProject = () => { repoVersion, inAIAssistantGroup, algosSelected, + runnableAlgos, + retrieveCandidates: autoRetrieveCandidates, + setRetrieveCandidates: setAutoRetrieveCandidates, canSelectAIModel, previewEligibleRowIndexes, matchAlgorithmIds, diff --git a/src/components/map-projects/__tests__/autoMatchRows.test.js b/src/components/map-projects/__tests__/autoMatchRows.test.js index 7f8e0a67..bac0743b 100644 --- a/src/components/map-projects/__tests__/autoMatchRows.test.js +++ b/src/components/map-projects/__tests__/autoMatchRows.test.js @@ -4,7 +4,7 @@ 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, - countAIOnlyRows, getAutoMatchBlocker + countAIOnlyRows, getAutoMatchBlocker, canRunAlgorithm } from '../autoMatchRows.js' import { createLimiter } from '../../../services/capacity.js' @@ -366,3 +366,24 @@ test('getAutoMatchBlocker: an AI-only run needs a row to analyse and AI calls le 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) }) + +test('canRunAlgorithm: search, semantic and custom algorithms always run', () => { + ['ocl-search', 'ocl-semantic', 'custom'].forEach(type => assert.equal(canRunAlgorithm({ type }), true)) +}) + +test('canRunAlgorithm: a bridge runs only for a user who can bridge', () => { + ['ocl-bridge', 'ocl-ciel-bridge'].forEach(type => { + assert.equal(canRunAlgorithm({ type }), false) + assert.equal(canRunAlgorithm({ type }, { canBridge: true }), true) + }) +}) + +test('canRunAlgorithm: ScispaCy runs only for a user who can run it', () => { + assert.equal(canRunAlgorithm({ type: 'ocl-scispacy' }, { canBridge: true }), false) + assert.equal(canRunAlgorithm({ type: 'ocl-scispacy' }, { canScispacy: true }), true) +}) + +test('canRunAlgorithm: an unknown or missing type doesn\'t run', () => { + assert.equal(canRunAlgorithm({ type: 'something-else' }), false) + assert.equal(canRunAlgorithm(null), false) +}) diff --git a/src/components/map-projects/autoMatchRows.js b/src/components/map-projects/autoMatchRows.js index 6c8c1206..86f5de72 100644 --- a/src/components/map-projects/autoMatchRows.js +++ b/src/components/map-projects/autoMatchRows.js @@ -46,6 +46,15 @@ export const spendsMatchQuota = (algo, { canBridge = false } = {}) => { return true } +// Whether getRowsResults runs this algorithm for this user (ocl_issues#2872). +export const canRunAlgorithm = (algo, { canBridge = false, canScispacy = false } = {}) => { + if(['ocl-bridge', 'ocl-ciel-bridge'].includes(algo?.type)) + return Boolean(canBridge) + if(algo?.type === 'ocl-scispacy') + return Boolean(canScispacy) + return ['custom', 'ocl-search', 'ocl-semantic'].includes(algo?.type) +} + // Rows a run can match before it runs out of match operations: each row costs // one operation per $match-spending algorithm. null when there is no cap. export const getRowCapByMatchOperations = (matchOperations, matchAlgorithmCount) => { diff --git a/src/i18n/locales/en/translations.json b/src/i18n/locales/en/translations.json index cb700991..af107c9e 100644 --- a/src/i18n/locales/en/translations.json +++ b/src/i18n/locales/en/translations.json @@ -515,10 +515,10 @@ "preview_estimate_will_truncate": "That covers {{allowed}} of the {{requested}} rows you've selected, so we'll match the first {{allowed}}.", "preview_estimate_match": "{{rows}} row(s) × {{algorithms}} algorithm(s) = {{used}} of your {{remaining}} match operations.", "preview_estimate_no_operations_left": "No match operations are left in your preview. You can keep reviewing, editing, and exporting this project.", - "preview_estimate_ai_all": "AI recommendations for all {{count}} row(s).", - "preview_estimate_ai_partial": "AI recommendations for the first {{covered}} row(s); the other {{rest}} are matched without AI.", - "preview_estimate_ai_none": "No AI Assistant calls are left, so these rows are matched without AI.", - "preview_estimate_ai_only_partial": "AI recommendations for the first {{covered}} row(s); no AI Assistant calls are left for the other {{rest}}.", + "preview_estimate_ai_all": "The OCL AI Assistant will analyze all {{count}} row(s).", + "preview_estimate_ai_partial": "The OCL AI Assistant will analyze the first {{covered}} row(s); the other {{rest}} are matched without it.", + "preview_estimate_ai_none": "You have no OCL AI Assistant calls left, so these rows are matched without it.", + "preview_estimate_ai_only_partial": "The OCL AI Assistant will analyze the first {{covered}} row(s); you have no OCL AI Assistant calls left for the other {{rest}}.", "preview_rows_activated_notice": "Preview projects hold {{limit}} rows - we activated the first {{limit}} of your {{total}}.", "ai_assistant_limit_reached": "You've used all of your preview AI Assistant calls. Matching will continue without AI analysis for the remaining rows.", "preview_limit_title_rows": "You've used this project's preview rows", @@ -596,16 +596,17 @@ "algorithm_throttled": "The server is busy, so this row wasn't matched this time. Nothing failed: run it again later.", "auto_match_rows_throttled": "The server was busy, so {{count}} input row(s) weren't matched: {{details}}. Nothing failed. Run Auto Match on those rows again later to finish them.", "auto_match_no_algorithms": "No matching algorithm is configured for this project, so Auto Match can't retrieve candidates.", + "auto_match_no_runnable_algorithms": "This project's matching algorithms aren't available to you, so Auto Match can't retrieve candidates.", "auto_match_configure_algorithms": "Configure", - "auto_match_ai_only_rows": "Without Retrieve Candidates, the AI analyzes only rows that already have candidates: it will analyze {{analyse}} row(s) and skip {{skip}} that have no candidates or already have an analysis of their current candidates.", + "auto_match_ai_only_rows": "Without Retrieve Candidates, the OCL AI Assistant analyzes only rows that already have candidates: it will analyze {{analyse}} row(s) and skip {{skip}} that have no candidates or were already analyzed with their current candidates.", "auto_match_blocked_no_rows": "There are no rows to match in this scope.", "auto_match_blocked_no_algorithms": "Configure a matching algorithm to run Auto Match.", "auto_match_blocked_no_algorithms_ai": "Configure a matching algorithm, or select Run AI Analysis.", "auto_match_blocked_no_step": "Select Retrieve Candidates to run Auto Match.", "auto_match_blocked_no_step_ai": "Select Retrieve Candidates or Run AI Analysis.", - "auto_match_blocked_no_ai_rows": "The AI has no rows to analyze in this scope.", - "auto_match_blocked_no_ai_calls": "No AI Assistant calls are left, so the AI can't analyze these rows.", - "auto_match_nothing_to_run": "Auto Match had nothing to run: no matching algorithm is selected, and the AI has no rows to analyze.", + "auto_match_blocked_no_ai_rows": "The OCL AI Assistant has no rows to analyze in this scope.", + "auto_match_blocked_no_ai_calls": "You have no OCL AI Assistant calls left, so it can't analyze these rows.", + "auto_match_nothing_to_run": "Auto Match had nothing to run: no matching algorithm is selected, and the OCL AI Assistant has no rows to analyze.", "rerank_step": "Reranking", "search_throttled": "The server is busy, so the search didn't run. Try again in a few minutes.", "search_failed": "Couldn't search ({{error}}). Try again.", diff --git a/src/i18n/locales/es/translations.json b/src/i18n/locales/es/translations.json index 6933e8d3..d1492aa5 100644 --- a/src/i18n/locales/es/translations.json +++ b/src/i18n/locales/es/translations.json @@ -479,10 +479,10 @@ "preview_estimate_will_truncate": "Eso cubre {{allowed}} de las {{requested}} filas que seleccionaste, así que asignaremos las primeras {{allowed}}.", "preview_estimate_match": "{{rows}} fila(s) × {{algorithms}} algoritmo(s) = {{used}} de tus {{remaining}} operaciones de coincidencia.", "preview_estimate_no_operations_left": "No quedan operaciones de coincidencia en tu vista previa. Puedes seguir revisando, editando y exportando este proyecto.", - "preview_estimate_ai_all": "Recomendaciones de IA para las {{count}} fila(s).", - "preview_estimate_ai_partial": "Recomendaciones de IA para las primeras {{covered}} fila(s); las otras {{rest}} se asignan sin IA.", - "preview_estimate_ai_none": "No quedan llamadas al Asistente de IA, así que estas filas se asignan sin IA.", - "preview_estimate_ai_only_partial": "Recomendaciones de IA para las primeras {{covered}} fila(s); no quedan llamadas al Asistente de IA para las otras {{rest}}.", + "preview_estimate_ai_all": "El asistente de IA de OCL analizará las {{count}} fila(s).", + "preview_estimate_ai_partial": "El asistente de IA de OCL analizará las primeras {{covered}} fila(s); las otras {{rest}} se asignan sin él.", + "preview_estimate_ai_none": "No le quedan llamadas al asistente de IA de OCL, así que estas filas se asignan sin él.", + "preview_estimate_ai_only_partial": "El asistente de IA de OCL analizará las primeras {{covered}} fila(s); no le quedan llamadas al asistente de IA de OCL para las otras {{rest}}.", "preview_rows_activated_notice": "Los proyectos de vista previa contienen {{limit}} filas: activamos las primeras {{limit}} de tus {{total}}.", "ai_assistant_limit_reached": "Has usado todas tus llamadas de vista previa al Asistente de IA. La asignación continuará sin análisis de IA para las filas restantes.", "preview_limit_title_rows": "Has usado las filas de vista previa de este proyecto", @@ -560,16 +560,17 @@ "algorithm_throttled": "El servidor está ocupado, así que esta fila no se procesó esta vez. Nada falló: vuelve a ejecutarla más tarde.", "auto_match_rows_throttled": "El servidor estaba ocupado, así que {{count}} fila(s) de entrada no se procesaron: {{details}}. Nada falló. Vuelve a ejecutar Auto Match en esas filas más tarde para terminarlas.", "auto_match_no_algorithms": "Este proyecto no tiene ningún algoritmo de coincidencia configurado, así que Auto Match no puede recuperar candidatos.", + "auto_match_no_runnable_algorithms": "Los algoritmos de coincidencia de este proyecto no están disponibles para usted, así que Auto Match no puede recuperar candidatos.", "auto_match_configure_algorithms": "Configurar", - "auto_match_ai_only_rows": "Sin Recuperar candidatos, la IA analiza solo las filas que ya tienen candidatos: analizará {{analyse}} fila(s) y omitirá {{skip}} que no tienen candidatos o ya tienen un análisis de sus candidatos actuales.", + "auto_match_ai_only_rows": "Sin Recuperar candidatos, el asistente de IA de OCL analiza solo las filas que ya tienen candidatos: analizará {{analyse}} fila(s) y omitirá {{skip}} que no tienen candidatos o que ya se analizaron con sus candidatos actuales.", "auto_match_blocked_no_rows": "No hay filas para coincidir en este alcance.", - "auto_match_blocked_no_algorithms": "Configura un algoritmo de coincidencia para ejecutar Auto Match.", - "auto_match_blocked_no_algorithms_ai": "Configura un algoritmo de coincidencia o selecciona Ejecutar Análisis de IA.", - "auto_match_blocked_no_step": "Selecciona Recuperar candidatos para ejecutar Auto Match.", - "auto_match_blocked_no_step_ai": "Selecciona Recuperar candidatos o Ejecutar Análisis de IA.", - "auto_match_blocked_no_ai_rows": "La IA no tiene filas que analizar en este alcance.", - "auto_match_blocked_no_ai_calls": "No quedan llamadas al Asistente de IA, así que la IA no puede analizar estas filas.", - "auto_match_nothing_to_run": "Auto Match no tenía nada que ejecutar: no hay ningún algoritmo de coincidencia seleccionado y la IA no tiene filas que analizar.", + "auto_match_blocked_no_algorithms": "Configure un algoritmo de coincidencia para ejecutar Auto Match.", + "auto_match_blocked_no_algorithms_ai": "Configure un algoritmo de coincidencia o seleccione Ejecutar Análisis de IA.", + "auto_match_blocked_no_step": "Seleccione Recuperar candidatos para ejecutar Auto Match.", + "auto_match_blocked_no_step_ai": "Seleccione Recuperar candidatos o Ejecutar Análisis de IA.", + "auto_match_blocked_no_ai_rows": "El asistente de IA de OCL no tiene filas que analizar en este alcance.", + "auto_match_blocked_no_ai_calls": "No le quedan llamadas al asistente de IA de OCL, así que no puede analizar estas filas.", + "auto_match_nothing_to_run": "Auto Match no tenía nada que ejecutar: no hay ningún algoritmo de coincidencia seleccionado y el asistente de IA de OCL no tiene filas que analizar.", "rerank_step": "Reordenamiento", "search_throttled": "El servidor está ocupado, así que la búsqueda no se ejecutó. Inténtalo de nuevo en unos minutos.", "search_failed": "No se pudo buscar ({{error}}). Inténtalo de nuevo.", diff --git a/src/i18n/locales/zh/translations.json b/src/i18n/locales/zh/translations.json index 9510d741..ea77b543 100644 --- a/src/i18n/locales/zh/translations.json +++ b/src/i18n/locales/zh/translations.json @@ -504,10 +504,10 @@ "preview_estimate_will_truncate": "这可覆盖你所选 {{requested}} 行中的 {{allowed}} 行,因此我们将匹配前 {{allowed}} 行。", "preview_estimate_match": "{{rows}} 行 × {{algorithms}} 个算法 = 你剩余 {{remaining}} 次匹配操作中的 {{used}} 次。", "preview_estimate_no_operations_left": "你的预览已没有剩余的匹配操作。你仍可以继续审核、编辑和导出此项目。", - "preview_estimate_ai_all": "全部 {{count}} 行都将获得 AI 推荐。", - "preview_estimate_ai_partial": "前 {{covered}} 行将获得 AI 推荐;其余 {{rest}} 行将在没有 AI 的情况下匹配。", - "preview_estimate_ai_none": "AI 助手调用次数已用完,因此这些行将在没有 AI 的情况下匹配。", - "preview_estimate_ai_only_partial": "前 {{covered}} 行将获得 AI 推荐;其余 {{rest}} 行已没有 AI 助手调用次数。", + "preview_estimate_ai_all": "OCL AI 助手将分析全部 {{count}} 行。", + "preview_estimate_ai_partial": "OCL AI 助手将分析前 {{covered}} 行;其余 {{rest}} 行将在没有它的情况下匹配。", + "preview_estimate_ai_none": "您的 OCL AI 助手调用次数已用完,因此这些行将在没有它的情况下匹配。", + "preview_estimate_ai_only_partial": "OCL AI 助手将分析前 {{covered}} 行;其余 {{rest}} 行已没有 OCL AI 助手调用次数。", "preview_rows_activated_notice": "预览项目最多包含 {{limit}} 行——我们已激活你 {{total}} 行中的前 {{limit}} 行。", "ai_assistant_limit_reached": "你的预览版 AI 助手调用次数已用完。匹配将继续进行,剩余行不再进行 AI 分析。", "preview_limit_title_rows": "本项目的预览行数已用完", @@ -585,16 +585,17 @@ "algorithm_throttled": "服务器繁忙,本次未能匹配此行。没有出错:请稍后重新运行。", "auto_match_rows_throttled": "服务器繁忙,{{count}} 个输入行未能匹配:{{details}}。没有出错。请稍后对这些行重新运行自动匹配以完成匹配。", "auto_match_no_algorithms": "此项目未配置任何匹配算法,因此自动匹配无法获取候选项。", + "auto_match_no_runnable_algorithms": "此项目的匹配算法对您不可用,因此自动匹配无法获取候选项。", "auto_match_configure_algorithms": "配置", - "auto_match_ai_only_rows": "不获取候选项时,AI 只分析已有候选项的行:将分析 {{analyse}} 行,跳过 {{skip}} 行(没有候选项,或已对其当前候选项进行过分析)。", + "auto_match_ai_only_rows": "不获取候选项时,OCL AI 助手只分析已有候选项的行:将分析 {{analyse}} 行,跳过 {{skip}} 行(没有候选项,或已用其当前候选项分析过)。", "auto_match_blocked_no_rows": "此范围内没有要匹配的行。", "auto_match_blocked_no_algorithms": "请配置匹配算法以运行自动匹配。", "auto_match_blocked_no_algorithms_ai": "请配置匹配算法,或选择运行 AI 分析。", "auto_match_blocked_no_step": "请选择获取候选项以运行自动匹配。", "auto_match_blocked_no_step_ai": "请选择获取候选项或运行 AI 分析。", - "auto_match_blocked_no_ai_rows": "此范围内没有可供 AI 分析的行。", - "auto_match_blocked_no_ai_calls": "AI 助手调用次数已用完,因此 AI 无法分析这些行。", - "auto_match_nothing_to_run": "自动匹配没有可运行的内容:未选择任何匹配算法,且 AI 没有可分析的行。", + "auto_match_blocked_no_ai_rows": "此范围内没有可供 OCL AI 助手分析的行。", + "auto_match_blocked_no_ai_calls": "您的 OCL AI 助手调用次数已用完,因此它无法分析这些行。", + "auto_match_nothing_to_run": "自动匹配没有可运行的内容:未选择任何匹配算法,且 OCL AI 助手没有可分析的行。", "rerank_step": "重新排序", "search_throttled": "服务器繁忙,搜索未能运行。请几分钟后重试。", "search_failed": "无法搜索({{error}})。请重试。", diff --git a/src/services/__tests__/attribution.test.js b/src/services/__tests__/attribution.test.js index 5e194af7..a7dc9058 100644 --- a/src/services/__tests__/attribution.test.js +++ b/src/services/__tests__/attribution.test.js @@ -260,6 +260,12 @@ test('an AI-only run stopped by the AI quota is partial', () => { assert.deepEqual(r, { completed_rows: 1, failed_rows: 0, completion_status: 'partial' }) }) +test('a throttled rerank doesn\'t make an AI-only run partial', () => { + const rowStages = { 0: {recommend: 1, rerank: -4}, 1: {recommend: 1} } + const r = summarizeRunCompletion({ rowStages, rowIndices: [0, 1], algoIds: [], aiOnly: true }) + assert.deepEqual(r, { completed_rows: 2, failed_rows: 0, completion_status: 'completed' }) +}) + test('a run with match algorithms is still recorded by them, not by its AI step', () => { const rowStages = { 0: {'ocl-search': -2, recommend: 1} } const r = summarizeRunCompletion({ rowStages, rowIndices: [0], algoIds: ['ocl-search'] }) diff --git a/src/services/attribution.js b/src/services/attribution.js index 05a0d58f..336ef416 100644 --- a/src/services/attribution.js +++ b/src/services/attribution.js @@ -171,8 +171,9 @@ export const summarizeRunCompletion = ({ const stage = rowStages[idx] || {} const attempted = stageIds.map(id => stage[id]).filter(s => s !== undefined && s !== -1) if(!attempted.length) return - // A row whose rerank stayed throttled is matched but not ranked. - if(attempted.some(s => s === -4) || stage.rerank === -4) throttled += 1 + // A row whose rerank stayed throttled is matched but not ranked. An AI-only + // run doesn't rerank, so a rerank stage isn't part of its outcome. + if(attempted.some(s => s === -4) || (!aiOnly && stage.rerank === -4)) throttled += 1 if(attempted.some(s => s === 1)) completed += 1 else if(attempted.every(s => s === -2)) failed += 1 }) From cf9c8921a56592dce6b19d18bd0295853d76ca6c Mon Sep 17 00:00:00 2001 From: Sunny Aggarwal Date: Tue, 6 Oct 2026 21:39:03 +0530 Subject: [PATCH 3/4] Apply batched suggestions from code review Co-authored-by: Jonathan Payne --- src/i18n/locales/es/translations.json | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/src/i18n/locales/es/translations.json b/src/i18n/locales/es/translations.json index d1492aa5..54518dd5 100644 --- a/src/i18n/locales/es/translations.json +++ b/src/i18n/locales/es/translations.json @@ -481,8 +481,8 @@ "preview_estimate_no_operations_left": "No quedan operaciones de coincidencia en tu vista previa. Puedes seguir revisando, editando y exportando este proyecto.", "preview_estimate_ai_all": "El asistente de IA de OCL analizará las {{count}} fila(s).", "preview_estimate_ai_partial": "El asistente de IA de OCL analizará las primeras {{covered}} fila(s); las otras {{rest}} se asignan sin él.", - "preview_estimate_ai_none": "No le quedan llamadas al asistente de IA de OCL, así que estas filas se asignan sin él.", - "preview_estimate_ai_only_partial": "El asistente de IA de OCL analizará las primeras {{covered}} fila(s); no le quedan llamadas al asistente de IA de OCL para las otras {{rest}}.", + "preview_estimate_ai_none": "No te quedan llamadas al asistente de IA de OCL, así que estas filas se asignan sin él.", + "preview_estimate_ai_only_partial": "El asistente de IA de OCL analizará las primeras {{covered}} fila(s); no te quedan llamadas al asistente de IA de OCL para las otras {{rest}}.", "preview_rows_activated_notice": "Los proyectos de vista previa contienen {{limit}} filas: activamos las primeras {{limit}} de tus {{total}}.", "ai_assistant_limit_reached": "Has usado todas tus llamadas de vista previa al Asistente de IA. La asignación continuará sin análisis de IA para las filas restantes.", "preview_limit_title_rows": "Has usado las filas de vista previa de este proyecto", @@ -560,16 +560,16 @@ "algorithm_throttled": "El servidor está ocupado, así que esta fila no se procesó esta vez. Nada falló: vuelve a ejecutarla más tarde.", "auto_match_rows_throttled": "El servidor estaba ocupado, así que {{count}} fila(s) de entrada no se procesaron: {{details}}. Nada falló. Vuelve a ejecutar Auto Match en esas filas más tarde para terminarlas.", "auto_match_no_algorithms": "Este proyecto no tiene ningún algoritmo de coincidencia configurado, así que Auto Match no puede recuperar candidatos.", - "auto_match_no_runnable_algorithms": "Los algoritmos de coincidencia de este proyecto no están disponibles para usted, así que Auto Match no puede recuperar candidatos.", + "auto_match_no_runnable_algorithms": "Los algoritmos de coincidencia de este proyecto no están disponibles para ti, así que Auto Match no puede recuperar candidatos.", "auto_match_configure_algorithms": "Configurar", "auto_match_ai_only_rows": "Sin Recuperar candidatos, el asistente de IA de OCL analiza solo las filas que ya tienen candidatos: analizará {{analyse}} fila(s) y omitirá {{skip}} que no tienen candidatos o que ya se analizaron con sus candidatos actuales.", "auto_match_blocked_no_rows": "No hay filas para coincidir en este alcance.", - "auto_match_blocked_no_algorithms": "Configure un algoritmo de coincidencia para ejecutar Auto Match.", - "auto_match_blocked_no_algorithms_ai": "Configure un algoritmo de coincidencia o seleccione Ejecutar Análisis de IA.", - "auto_match_blocked_no_step": "Seleccione Recuperar candidatos para ejecutar Auto Match.", - "auto_match_blocked_no_step_ai": "Seleccione Recuperar candidatos o Ejecutar Análisis de IA.", + "auto_match_blocked_no_algorithms": "Configura un algoritmo de coincidencia para ejecutar Auto Match.", + "auto_match_blocked_no_algorithms_ai": "Configura un algoritmo de coincidencia o selecciona Ejecutar Análisis de IA.", + "auto_match_blocked_no_step": "Selecciona Recuperar candidatos para ejecutar Auto Match.", + "auto_match_blocked_no_step_ai": "Selecciona Recuperar candidatos o Ejecutar Análisis de IA.", "auto_match_blocked_no_ai_rows": "El asistente de IA de OCL no tiene filas que analizar en este alcance.", - "auto_match_blocked_no_ai_calls": "No le quedan llamadas al asistente de IA de OCL, así que no puede analizar estas filas.", + "auto_match_blocked_no_ai_calls": "No te quedan llamadas al asistente de IA de OCL, así que no puede analizar estas filas.", "auto_match_nothing_to_run": "Auto Match no tenía nada que ejecutar: no hay ningún algoritmo de coincidencia seleccionado y el asistente de IA de OCL no tiene filas que analizar.", "rerank_step": "Reordenamiento", "search_throttled": "El servidor está ocupado, así que la búsqueda no se ejecutó. Inténtalo de nuevo en unos minutos.", From 7d31273de60e13d303f51a6f1c9a0287fe6490bd Mon Sep 17 00:00:00 2001 From: Sunny Aggarwal Date: Tue, 6 Oct 2026 21:39:59 +0530 Subject: [PATCH 4/4] Apply batched suggestions from code review Co-authored-by: Jonathan Payne --- src/components/map-projects/MapProject.jsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/components/map-projects/MapProject.jsx b/src/components/map-projects/MapProject.jsx index c03576f5..bd24b8bb 100644 --- a/src/components/map-projects/MapProject.jsx +++ b/src/components/map-projects/MapProject.jsx @@ -5673,7 +5673,7 @@ const MapProject = () => { return null const scopeRows = getRowsToProcess(data, rowStatuses, autoMatchScope, getSelectedRowIndexes(data), previewEligibleRowIndexes) return countAIOnlyRows(map(scopeRows, '__index'), map(getAIAnalysableRows(scopeRows), '__index')) - }, [matchDialog, isAutoMatchAIOnly, data, rowStatuses, autoMatchScope, selectedRowIds, previewEligibleRowIndexes, analysis, rowMatchState, conceptCache, repoVersion]) + }, [matchDialog, isAutoMatchAIOnly, data, rowStatuses, autoMatchScope, selectedRowIds, previewEligibleRowIndexes, analysis, rowMatchState, conceptCache, repoVersion, buildProjectContext, filters, inputLocale]) const fetchRecommendation = async (_row, resolvedPromptTemplate = null, isBulk = false) => { let __row = row; @@ -5711,7 +5711,7 @@ const MapProject = () => { if(isBulk && isNumber(__index)) { await waitForLookups(getPendingRowLookups(rowMatchStateRef.current[__index], inFlightLookupsRef.current), AI_LOOKUP_WAIT_MS, stuckLookupsRef.current) // The user may have pressed Stop while this row waited. - if(abortRef.current) + if(abortRef.current || isSuperseded()) return null } const v2 = isNumber(__index) ? buildV2RecommendationPayload(__index) : null