diff --git a/src/components/map-projects/AutoMatchDialog.jsx b/src/components/map-projects/AutoMatchDialog.jsx index b78b73c..2a03d2c 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 668a79b..534566f 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 64e0694..7f8e0a6 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 77ab351..6c8c120 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 c9595bc..cb70099 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 f08cdad..6933e8d 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 0104747..9510d74 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 256de2e..5e194af 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 3b7c685..05a0d58 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