From b8efe27b52c8a5a5453aa1d3d67ee49ede74e7f1 Mon Sep 17 00:00:00 2001 From: Sunny Aggarwal Date: Mon, 19 Oct 2026 07:51:55 +0530 Subject: [PATCH 1/5] OpenConceptLab/ocl_online#465 | AutoMatch dialog: first run includes the OCL AI Assistant, notices move to a footer, and a checkpoint - The dialog's top holds only the choices; "Retrieve candidates" and "Recommend matches with the OCL AI Assistant" each get a short note. - Notices (replacing proposals, overwriting approved rows with its tick, AI-only rows, nothing to run) move to a footer above Submit. A notice can ask for a tick or block Submit; changing the selection clears the ticks. - The OCL AI Assistant starts ticked on a project's first AutoMatch; later runs keep the choice of the project's last run. - New checkpoint, src/components/map-projects/autoMatchCheck.js (useAutoMatchCheck and aiOnByDefault), imported as 'ocl-online-mapper'. webpack resolves that name to an installed package when there is one, and to the open stub otherwise; the Dockerfile and build.yml can install one. - New strings in en, es, zh and pt-BR; unused ones removed. The catalog test now understands plural keys. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/build.yml | 12 +- Dockerfile | 19 + docker-compose.override.yml | 1 + docker-compose.sso.yml | 1 + docker-compose.yml | 1 + .../map-projects/AutoMatchDialog.jsx | 332 ++++++++---------- src/components/map-projects/MapProject.jsx | 7 +- .../__tests__/autoMatchRows.test.js | 25 +- src/components/map-projects/autoMatchCheck.js | 18 + src/components/map-projects/autoMatchRows.js | 20 +- src/i18n/__tests__/aiAssistantNaming.test.js | 8 +- src/i18n/__tests__/catalogKeys.test.js | 6 +- src/i18n/locales/en/translations.json | 37 +- src/i18n/locales/es/translations.json | 37 +- src/i18n/locales/pt-BR/translations.json | 37 +- src/i18n/locales/zh/translations.json | 36 +- webpack.config.js | 20 ++ 17 files changed, 319 insertions(+), 298 deletions(-) create mode 100644 src/components/map-projects/autoMatchCheck.js diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 5fd498c1..b390f49a 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -49,6 +49,8 @@ jobs: needs: [lint-and-test] name: Build and push image runs-on: ubuntu-latest + env: + HAS_PRIVATE_PACKAGES: ${{ secrets.PRIVATE_GIT_PACKAGES_DEPLOY_KEY != '' }} steps: - name: Checkout repo @@ -69,6 +71,11 @@ jobs: # registry: ghcr.io # username: ${{ github.actor }} # password: ${{ secrets.GITHUB_TOKEN }} + - name: Setup SSH agent for private packages + if: env.HAS_PRIVATE_PACKAGES == 'true' + uses: webfactory/ssh-agent@v0.9.0 + with: + ssh-private-key: ${{ secrets.PRIVATE_GIT_PACKAGES_DEPLOY_KEY }} - name: Set up Docker Buildx uses: docker/setup-buildx-action@v3 - name: Build and push Docker image @@ -77,7 +84,10 @@ jobs: context: . push: true tags: openconceptlab/oclmap:nightly, openconceptlab/oclmap:${{ steps.version.outputs.tag }} - build-args: SOURCE_COMMIT=${{ steps.version.outputs.sha }} + ssh: ${{ env.HAS_PRIVATE_PACKAGES == 'true' && 'default' || '' }} + build-args: | + SOURCE_COMMIT=${{ steps.version.outputs.sha }} + PRIVATE_PACKAGES_GIT=${{ secrets.PRIVATE_PACKAGES_GIT }} cache-from: type=gha cache-to: type=gha,mode=max diff --git a/Dockerfile b/Dockerfile index b9411f86..c1aa9354 100644 --- a/Dockerfile +++ b/Dockerfile @@ -27,6 +27,25 @@ ADD package-lock.json /app/ RUN npm ci --production=false +# OPTIONAL: install the private Mapper package during the image build +# (OpenConceptLab/ocl_online#465). webpack.config.js uses it when it's in +# node_modules, and the open stubs otherwise. --install-links copies it in, so +# its imports resolve to this app's packages. SOURCE_COMMIT refreshes the layer +# on every commit, so a cached build doesn't keep an old copy. +ARG PRIVATE_PACKAGES_GIT +ARG PRIVATE_PACKAGES_REF=main +ARG PRIVATE_PACKAGES_PATH=oclmap-plugin +ARG SOURCE_COMMIT +RUN --mount=type=ssh \ + if [ -n "$PRIVATE_PACKAGES_GIT" ]; then \ + mkdir -p /root/.ssh && \ + ssh-keyscan github.com >> /root/.ssh/known_hosts && \ + echo "Installing private packages (oclmap commit $SOURCE_COMMIT):" $PRIVATE_PACKAGES_GIT $PRIVATE_PACKAGES_REF $PRIVATE_PACKAGES_PATH && \ + git clone --depth 1 --branch "$PRIVATE_PACKAGES_REF" "$PRIVATE_PACKAGES_GIT" /tmp/private-packages && \ + npm i --no-save --install-links --production=false "/tmp/private-packages/$PRIVATE_PACKAGES_PATH" && \ + rm -rf /tmp/private-packages; \ + fi + ADD webpack.config.js /app/ ADD .babelrc /app/ ADD src /app/src/ diff --git a/docker-compose.override.yml b/docker-compose.override.yml index 98e2121e..a86143ed 100644 --- a/docker-compose.override.yml +++ b/docker-compose.override.yml @@ -8,6 +8,7 @@ services: target: build args: NODE_ENV: development + PRIVATE_PACKAGES_GIT: ports: - "4004:4004" - "4005:35729" diff --git a/docker-compose.sso.yml b/docker-compose.sso.yml index 717ed661..495b1d72 100644 --- a/docker-compose.sso.yml +++ b/docker-compose.sso.yml @@ -8,6 +8,7 @@ services: target: build args: NODE_ENV: development + PRIVATE_PACKAGES_GIT: ports: - "4004:4004" - "4005:35729" diff --git a/docker-compose.yml b/docker-compose.yml index 6688a181..514ea2ac 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -7,6 +7,7 @@ services: context: . args: NODE_ENV: production + PRIVATE_PACKAGES_GIT: ports: - "4004:4004" restart: on-failure diff --git a/src/components/map-projects/AutoMatchDialog.jsx b/src/components/map-projects/AutoMatchDialog.jsx index 0b16f276..b9b492e2 100644 --- a/src/components/map-projects/AutoMatchDialog.jsx +++ b/src/components/map-projects/AutoMatchDialog.jsx @@ -1,10 +1,9 @@ import React from 'react' -import { useTranslation } from 'react-i18next' +import { useTranslation, Trans } from 'react-i18next' import Dialog from '@mui/material/Dialog'; import DialogContent from '@mui/material/DialogContent'; import DialogTitle from '@mui/material/DialogTitle'; -import DialogActions from '@mui/material/DialogActions'; import FormControlLabel from '@mui/material/FormControlLabel'; import FormControl from '@mui/material/FormControl'; import Checkbox from '@mui/material/Checkbox'; @@ -15,6 +14,8 @@ import Radio from '@mui/material/Radio'; import FormLabel from '@mui/material/FormLabel'; import Chip from '@mui/material/Chip' import Alert from '@mui/material/Alert'; +import Box from '@mui/material/Box'; +import Tooltip from '@mui/material/Tooltip'; import DoubleArrowIcon from '@mui/icons-material/DoubleArrow'; @@ -24,9 +25,12 @@ import CloseIconButton from '../common/CloseIconButton' import TagCountLabel from '../common/TagCountLabel' import RepoChip from '../repos/RepoVersionChip' import AIAssistantSelectorPanel from './AIAssistantSelectorPanel' -import { getMapperPreview } from '../../common/utils' -import { getRowCapByMatchOperations, getAutoMatchBlocker } from './autoMatchRows' +import { getAutoMatchBlocker } from './autoMatchRows' +// A private package can fill this checkpoint in; see autoMatchCheck.js. +import useAutoMatchCheck from 'ocl-online-mapper' +// Lines an option's sub-text up with its checkbox label. +const SUBTEXT_SX = {margin: '-6px 0 0 32px'} const AutoMatchDialog = ({ open, @@ -51,22 +55,22 @@ const AutoMatchDialog = ({ retrieveCandidates: retrieveCandidatesChecked, setRetrieveCandidates, canSelectAIModel, - previewEligibleRowIndexes, + eligibleRowIndexes, matchAlgorithmIds, aiOnlyRowCounts, onConfigure }) => { const { t } = useTranslation() - const [confirmAllIncludingApproved, setConfirmAllIncludingApproved] = React.useState(false) - const previewEligibleRowIndexSet = React.useMemo( - () => Array.isArray(previewEligibleRowIndexes) ? new Set(previewEligibleRowIndexes.map(id => id?.toString())) : null, - [previewEligibleRowIndexes] + // Rows a run may include; null means every row. + const eligibleRowIndexSet = React.useMemo( + () => Array.isArray(eligibleRowIndexes) ? new Set(eligibleRowIndexes.map(id => id?.toString())) : null, + [eligibleRowIndexes] ) const countEligible = React.useCallback( - rowIndexes => previewEligibleRowIndexSet ? - rowIndexes.filter(index => previewEligibleRowIndexSet.has(index?.toString())).length : + rowIndexes => eligibleRowIndexSet ? + rowIndexes.filter(index => eligibleRowIndexSet.has(index?.toString())).length : rowIndexes.length, - [previewEligibleRowIndexSet] + [eligibleRowIndexSet] ) const unmappedRowsCount = countEligible(rowStatuses.unmapped) const readyForReviewRowsCount = countEligible(rowStatuses.readyForReview) @@ -91,55 +95,13 @@ const AutoMatchDialog = ({ 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() + // Selected algorithms that call $match. const matchAlgorithmIdSet = React.useMemo(() => new Set(matchAlgorithmIds || []), [matchAlgorithmIds]) 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 - const isPreviewQuotaExhausted = willTruncate && rowCap <= 0 - const rowsThisRun = willTruncate ? rowCap : rowsToMatchCount - const estimatedOperations = rowsThisRun * matchAlgorithmCount - // 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 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') - const parts = [] - if(operationsRemaining !== null) - parts.push(t('map_project.preview_estimate_match', { - rows: rowsThisRun.toLocaleString(), - algorithms: matchAlgorithmCount.toLocaleString(), - used: estimatedOperations.toLocaleString(), - remaining: operationsRemaining.toLocaleString() - })) - if(willTruncate) - parts.push(t('map_project.preview_estimate_will_truncate', { - allowed: rowsThisRun.toLocaleString(), - requested: rowsToMatchCount.toLocaleString() - })) - 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(isAIOnly ? 'map_project.preview_estimate_ai_only_partial' : 'map_project.preview_estimate_ai_partial', { - covered: aiRowsCovered.toLocaleString(), - rest: (aiRows - aiRowsCovered).toLocaleString() - })) - } - return parts.join(' ') - } - const previewEstimate = rowsToMatchCount > 0 && (operationsRemaining !== null || aiRowsCovered !== null) ? getPreviewEstimate() : '' + const check = useAutoMatchCheck({ + open, autoMatchScope, rowsToMatchCount, totalRows, retrieveCandidates, matchAlgorithmCount, + runAI, isAIOnly, aiRowsToAnalyse, inAIAssistantGroup + }) React.useEffect(() => { if (autoMatchScope === 'unmapped' && !hasUnmappedRows) { @@ -150,49 +112,23 @@ const AutoMatchDialog = ({ } }, [autoMatchScope, hasApprovedRows, hasUnmappedRows, setAutoMatchScope]) - React.useEffect(() => { - if(!open || !isAllIncludingApproved) - setConfirmAllIncludingApproved(false) - }, [isAllIncludingApproved, open]) + // A notice that changes what the run does needs a tick before Submit. Any + // change to the selection clears the ticks. + const [confirmed, setConfirmed] = React.useState({}) + const confirmationKey = [open, autoMatchScope, retrieveCandidates, runAI, rowsToMatchCount, ...(check.notices || []).map(notice => notice.text)].join('|') + React.useEffect(() => setConfirmed({}), [confirmationKey]) const scopeOptions = [ { value: 'selected', disabled: !hasSelectedRows, - label: t('map_project.selected_rows'), + label: t('map_project.auto_match_scope_selected'), count: hasSelectedRows ? selectedRowCount : false, - helperText: hasSelectedRows ? - t('map_project.auto_match_selected_rows_note', {count: selectedRowCount.toLocaleString()}) : - t('map_project.auto_match_selected_rows_note_no_count') - }, - { - value: 'unmapped', - disabled: !hasUnmappedRows, - count: unmappedRowsCount, - label: t('map_project.unmapped_only'), - helperText: t('map_project.auto_match_unmapped_only_note') + disabledTooltip: t('map_project.auto_match_selected_rows_note_no_count') }, - { - value: 'all', - disabled: false, - count: allRowsCount, - label: t('map_project.unmapped_and_proposed'), - helperText: t('map_project.auto_match_note', { - approvedCount: reviewedRowsCount.toLocaleString(), - proposedCount: readyForReviewRowsCount.toLocaleString() - }) - }, - { - value: 'allIncludingApproved', - disabled: !hasApprovedRows, - count: totalRows, - label: t('map_project.all_including_approved'), - warning: true, - helperText: t('map_project.auto_match_all_including_approved_note', { - approvedCount: reviewedRowsCount.toLocaleString(), - proposedCount: readyForReviewRowsCount.toLocaleString() - }) - } + {value: 'unmapped', disabled: !hasUnmappedRows, label: t('map_project.auto_match_scope_unmapped'), count: unmappedRowsCount}, + {value: 'all', disabled: false, label: t('map_project.auto_match_scope_unmapped_proposed'), count: allRowsCount}, + {value: 'allIncludingApproved', disabled: !hasApprovedRows, label: t('map_project.auto_match_scope_all'), count: totalRows} ] const blocker = getAutoMatchBlocker({ @@ -200,34 +136,56 @@ const AutoMatchDialog = ({ hasAlgorithms, retrieveCandidates, runAI, - aiRowsToAnalyse, - aiCallsRemaining + aiRowsToAnalyse }) 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_algorithms: t(inAIAssistantGroup ? 'map_project.auto_match_blocked_no_algorithms_ai_v2' : 'map_project.auto_match_blocked_no_algorithms'), + no_step: t(inAIAssistantGroup ? 'map_project.auto_match_blocked_no_step_ai_v2' : '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'), } + // What the selection will do, shown by Submit rather than next to each option. + const notices = [] + if(blocker) + notices.push({key: 'blocker', severity: 'warning', text: blockerMessages[blocker]}) + if(autoMatchScope === 'all' && readyForReviewRowsCount > 0) + notices.push({key: 'replaces_proposed', severity: 'info', text: t('map_project.auto_match_notice_replaces_proposed', {count: readyForReviewRowsCount})}) + if(isAllIncludingApproved) + notices.push({ + key: 'overwrites_approved', + severity: 'warning', + text: t('map_project.auto_match_notice_overwrites', {approved: reviewedRowsCount.toLocaleString(), proposed: readyForReviewRowsCount.toLocaleString()}), + confirm: t('map_project.auto_match_all_including_approved_confirm', {approvedCount: reviewedRowsCount.toLocaleString()}) + }) + if(isAIOnly && rowsToMatchCount > 0 && aiOnlyRowCounts && aiRowsToAnalyse > 0) + notices.push({ + key: 'ai_only_rows', + severity: 'info', + text: t('map_project.auto_match_ai_only_rows', {analyse: aiOnlyRowCounts.analyse.toLocaleString(), skip: aiOnlyRowCounts.skip.toLocaleString()}) + }) + + notices.push(...(check.notices || [])) + const isDisabled = !repoVersion?.version_url || Boolean(blocker) || - isPreviewQuotaExhausted || - (isAllIncludingApproved && !confirmAllIncludingApproved) + notices.some(notice => notice.blocks || (notice.confirm && !confirmed[notice.key])) return ( @@ -235,85 +193,72 @@ const AutoMatchDialog = ({ {t('map_project.auto_match')} - - { - previewEstimate && - - {previewEstimate} - - } +
- {t('map_project.target_repository')} + {t('map_project.auto_match_target_repository')} { repoVersion?.id && - + }
- - {`${t('map_project.rows_to_match')}: ${rowsToMatchCount.toLocaleString()} ${t('map_project.out_of')} ${totalRows.toLocaleString()}` } + + + }} + /> + setAutoMatchScope(event.target.value)} > { - scopeOptions.map(option => ( -
+ scopeOptions.map(option => { + const control = ( } + control={} label={} sx={{marginRight: 0}} /> - - {option.helperText} - - { - autoMatchScope === option.value && option.value === 'allIncludingApproved' && - setConfirmAllIncludingApproved(event.target.checked)} - /> - } - label={t('map_project.auto_match_all_including_approved_confirm', { - approvedCount: reviewedRowsCount.toLocaleString() - })} - /> - } -
- )) + ) + return option.disabled && option.disabledTooltip ? + {control} : + control + }) }
- - setRetrieveCandidates(event.target.checked)} />} label={t('map_project.retrieve_candidates')} /> + + setRetrieveCandidates(event.target.checked)} />} + label={t('map_project.auto_match_retrieve_candidates')} + /> { hasAlgorithms ? <> - + {t('map_project.retrieve_candidates_helper_text')} - -
+ + { - runnableAlgos.map(algo => { - return ( - - ) - }) + runnableAlgos.map(algo => ( + + )) } -
+ : @@ -328,31 +273,20 @@ const AutoMatchDialog = ({ { inAIAssistantGroup && - <> + setAutoRunAIAnalysis(event.target.checked)} /> } - label={ - {t('map_project.run_ai_analysis')} - } + label={t('map_project.auto_match_ai_label')} /> - - {t('map_project.run_ai_analysis_note')} + + {t('map_project.auto_match_ai_description')} - { - 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]} - + notices.length > 0 && + + { + notices.map(notice => ( + + {notice.text} + { + notice.confirm && + setConfirmed(prev => ({...prev, [notice.key]: event.target.checked}))} + /> + } + label={{notice.confirm}} + /> + } + + )) + } + } - - + + {check.summary && {check.summary}} + + +
) } diff --git a/src/components/map-projects/MapProject.jsx b/src/components/map-projects/MapProject.jsx index 1958e5cc..b7ba84b7 100644 --- a/src/components/map-projects/MapProject.jsx +++ b/src/components/map-projects/MapProject.jsx @@ -89,7 +89,8 @@ import MapProjectDeleteConfirmDialog from './MapProjectDeleteConfirmDialog'; import ConfigurationForm from './ConfigurationForm' import Controls from './Controls' import DataGridControls from './DataGridControls' -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 { getPreviewEligibleRowIndexes, getRowsToProcess, countAIOnlyRows, getAutoMatchAIDefault, 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 { aiOnByDefault } from 'ocl-online-mapper' import { createAutosaveScheduler, createLatestSender, saveOnLeave, trackSave, whenSaved, installUnloadGuard } from './autosave' import MatchSummaryCard from './MatchSummaryCard' import MappingDecisionResult from './MappingDecisionResult' @@ -2959,6 +2960,8 @@ const MapProject = () => { event.stopPropagation() event.preventDefault() setAutoMatchScope(getSelectedRowIndexes().length ? 'selected' : (rowStatuses.unmapped.length ? 'unmapped' : 'all')) + if(inAIAssistantGroup) + setAutoRunAIAnalysis(getAutoMatchAIDefault(projectLogsRef.current, aiOnByDefault())) setMatchDialog(true) } @@ -6461,7 +6464,7 @@ const MapProject = () => { retrieveCandidates: autoRetrieveCandidates, setRetrieveCandidates: setAutoRetrieveCandidates, canSelectAIModel, - previewEligibleRowIndexes, + eligibleRowIndexes: previewEligibleRowIndexes, 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 bac0743b..62d85619 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, canRunAlgorithm + countAIOnlyRows, getAutoMatchBlocker, getAutoMatchAIDefault, canRunAlgorithm } from '../autoMatchRows.js' import { createLimiter } from '../../../services/capacity.js' @@ -356,15 +356,28 @@ test('getAutoMatchBlocker: no algorithm and no AI step', () => { assert.equal(getAutoMatchBlocker({ rowsInScope: 3, hasAlgorithms: true }), 'no_step') }) -test('getAutoMatchBlocker: an AI-only run needs a row to analyse and AI calls left', () => { +test('getAutoMatchBlocker: an AI-only run needs a row to analyse', () => { 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) +test('getAutoMatchBlocker: AI rows don\'t matter to a run that retrieves candidates', () => { + assert.equal(getAutoMatchBlocker({ rowsInScope: 3, hasAlgorithms: true, retrieveCandidates: true, runAI: true, aiRowsToAnalyse: 0 }), null) +}) + +test('getAutoMatchAIDefault: a project that was never auto-matched takes the first-run default', () => { + assert.equal(getAutoMatchAIDefault([], true), true) + assert.equal(getAutoMatchAIDefault(undefined, true), true) + assert.equal(getAutoMatchAIDefault([{ action: 'Opened' }], false), false) +}) + +test('getAutoMatchAIDefault: later runs keep the choice of the last run', () => { + const withAI = { action: 'auto_match_started', extras: { sub_actions: ['ocl-semantic', 'with_ai_analysis'] } } + const withoutAI = { action: 'auto_match_started', extras: { sub_actions: ['ocl-semantic'] } } + assert.equal(getAutoMatchAIDefault([withAI], false), true) + assert.equal(getAutoMatchAIDefault([withoutAI], true), false) + assert.equal(getAutoMatchAIDefault([{ action: 'Opened' }, { action: 'auto_match_finished' }, withoutAI, withAI], true), false) + assert.equal(getAutoMatchAIDefault([{ action: 'auto_match_started' }], true), false) }) test('canRunAlgorithm: search, semantic and custom algorithms always run', () => { diff --git a/src/components/map-projects/autoMatchCheck.js b/src/components/map-projects/autoMatchCheck.js new file mode 100644 index 00000000..3cc86a09 --- /dev/null +++ b/src/components/map-projects/autoMatchCheck.js @@ -0,0 +1,18 @@ +// Checkpoint for the AutoMatch dialog (OpenConceptLab/ocl_online#465). The +// dialog imports it as 'ocl-online-mapper'; webpack resolves that name to an +// installed private package when there is one, and to this file otherwise. +// +// useAutoMatchCheck receives what the user has selected: +// {open, autoMatchScope, rowsToMatchCount, totalRows, retrieveCandidates, +// matchAlgorithmCount, runAI, isAIOnly, aiRowsToAnalyse, inAIAssistantGroup} +// and returns {summary: ReactNode | null, notices: [{key, severity, text, +// confirm?, blocks?}]}: a summary shown next to Submit, and notices that can +// ask the user to confirm or block the run. Here it adds nothing and never +// blocks. +// eslint-disable-next-line no-unused-vars +const useAutoMatchCheck = selection => ({summary: null, notices: []}) + +// Whether the OCL AI Assistant starts ticked on a project's first AutoMatch. +export const aiOnByDefault = () => true + +export default useAutoMatchCheck diff --git a/src/components/map-projects/autoMatchRows.js b/src/components/map-projects/autoMatchRows.js index 86f5de72..1fa867ba 100644 --- a/src/components/map-projects/autoMatchRows.js +++ b/src/components/map-projects/autoMatchRows.js @@ -77,21 +77,27 @@ export const getAutoMatchBlocker = ({ 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' - } + if(!retrieveCandidates && !aiRowsToAnalyse) + return 'no_ai_rows' return null } +// Whether the OCL AI Assistant starts ticked when the AutoMatch dialog opens +// (OpenConceptLab/ocl_online#465): as on the project's last run, or +// firstRunDefault when it has never been auto-matched. projectLogs is newest +// first. +export const getAutoMatchAIDefault = (projectLogs, firstRunDefault) => { + const lastRun = (projectLogs || []).find(log => log?.action === 'auto_match_started') + if(!lastRun) + return Boolean(firstRunDefault) + return (lastRun.extras?.sub_actions || []).includes('with_ai_analysis') +} + // 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/__tests__/aiAssistantNaming.test.js b/src/i18n/__tests__/aiAssistantNaming.test.js index ea9a1765..c714e4e9 100644 --- a/src/i18n/__tests__/aiAssistantNaming.test.js +++ b/src/i18n/__tests__/aiAssistantNaming.test.js @@ -28,10 +28,6 @@ const NAMES_THE_ASSISTANT = [ 'input_language_helper', 'ai_assistant', 'preview_quota_ai_calls', - 'preview_estimate_ai_all', - 'preview_estimate_ai_partial', - 'preview_estimate_ai_none', - 'preview_estimate_ai_only_partial', 'preview_limit_title_ai_assistant', 'preview_limit_body_ai_assistant', 'preview_limit_body_ai_assistant_no_count', @@ -40,6 +36,10 @@ const NAMES_THE_ASSISTANT = [ 'set_ai_assistant_output_language', 'ai_recommendation_failed_retry', 'ai_recommendation_running', + 'auto_match_ai_label', + 'auto_match_ai_description', + 'auto_match_blocked_no_algorithms_ai_v2', + 'auto_match_blocked_no_step_ai_v2', ] const load = locale => diff --git a/src/i18n/__tests__/catalogKeys.test.js b/src/i18n/__tests__/catalogKeys.test.js index 160aaced..ac179388 100644 --- a/src/i18n/__tests__/catalogKeys.test.js +++ b/src/i18n/__tests__/catalogKeys.test.js @@ -30,6 +30,10 @@ for (const locale of LOCALES) // Keys built at run time ('a.' + id, `a.${id}`) aren't read. const LITERAL_KEY = /(?:\bt\(\s*|\bi18nKey=\{?\s*)(['"`])([A-Za-z_]+(?:\.[A-Za-z0-9_]+)+)\1(?!\s*\+)/g +// A plural key, t('a.b', {count}), is in the catalog as a.b_one, a.b_other and +// so on. +const inEnglish = key => english.has(key) || english.has(`${key}_other`) + // The .js and .jsx files under src/, outside the test folders. const SOURCES = readdirSync(SRC_DIR, { recursive: true }) .filter(file => /\.jsx?$/.test(file) && !/(^|[\\/])__(tests|integration)__[\\/]/.test(file)) @@ -38,7 +42,7 @@ test('every literal key in the source is in the English catalog', () => { const missing = SOURCES.flatMap(file => [...readFileSync(path.join(SRC_DIR, file), 'utf8').matchAll(LITERAL_KEY)] .map(match => match[2]) - .filter(key => !english.has(key)) + .filter(key => !inEnglish(key)) .map(key => `${file}: ${key}`) ) assert.deepEqual(missing, []) diff --git a/src/i18n/locales/en/translations.json b/src/i18n/locales/en/translations.json index a94f9510..eafc8ee8 100644 --- a/src/i18n/locales/en/translations.json +++ b/src/i18n/locales/en/translations.json @@ -284,7 +284,6 @@ "clear_filter": "Clear Filter", "configure_mapping_project_tooltip": "Configure Mapping Project", "auto_match": "AutoMatch", - "unmapped_only": "Unmapped Only", "propose": "Propose", "proposal_note": "Proposal note", "target_source": "Target Source", @@ -316,13 +315,6 @@ "preview_quota_match_operations": "Match operations", "preview_quota_ai_calls": "OCL AI Assistant calls", "preview_quota_used_of_limit": "{{used}} of {{limit}} used", - "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": "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}}.", "preview_limit_title_rows": "You've used this project's preview rows", "preview_limit_title_match_operations": "You've used your preview match operations", @@ -380,11 +372,7 @@ "loading_project": "Loading project...", "stopping_gracefully": "Stopping gracefully...", "stop_processing": "Stop Processing", - "auto_match_unmapped_only_note": "Note: Only unmapped rows will be matched", - "auto_match_note": "Note: This will not affect {{approvedCount}} approved input rows but will override {{proposedCount}} proposed input rows", - "auto_match_all_including_approved_note": "Warning: This will re-run every input row from scratch and overwrite {{approvedCount}} approved and {{proposedCount}} proposed decisions", "auto_match_all_including_approved_confirm": "I understand that {{approvedCount}} approved decisions will be overwritten", - "auto_match_selected_rows_note": "Note: This will run AutoMatch for {{count}} selected input rows", "auto_match_selected_rows_note_no_count": "Note: Select rows in the left panel to enable this option", "auto_match_rows_failed": "Matching failed for {{count}} input row(s), so they're missing candidates: {{details}}. Rows count from the first data row of your file. Run AutoMatch on those rows again to retry them.", "auto_match_rows_failed_algorithm": "{{algorithm}} on row(s) {{rows}}", @@ -407,17 +395,26 @@ "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 AutoMatch.", - "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 AutoMatch.", - "auto_match_blocked_no_step_ai": "Select Retrieve Candidates or Run AI Analysis.", "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": "AutoMatch had nothing to run: no matching algorithm is selected, and the OCL AI Assistant has no rows to analyze.", + "auto_match_target_repository": "Target repository:", + "auto_match_input_rows": "Input rows to match: {{rows}} out of {{total}} total", + "auto_match_scope_selected": "Selected rows only", + "auto_match_scope_unmapped": "Unmapped rows only", + "auto_match_scope_unmapped_proposed": "Unmapped & proposed rows", + "auto_match_scope_all": "All rows, including approved", + "auto_match_retrieve_candidates": "Retrieve candidates", + "auto_match_ai_label": "Recommend matches with the OCL AI Assistant", + "auto_match_ai_description": "For each row, the OCL AI Assistant weighs the candidates against all of the row's fields, recommends a match and alternates, or none if nothing fits, and explains why.", + "auto_match_blocked_no_algorithms_ai_v2": "Configure a matching algorithm, or select Recommend matches with the OCL AI Assistant.", + "auto_match_blocked_no_step_ai_v2": "Select Retrieve candidates or Recommend matches with the OCL AI Assistant.", + "auto_match_notice_replaces_proposed_one": "Replaces the proposal on {{count}} proposed row. Approved rows aren't changed.", + "auto_match_notice_replaces_proposed_other": "Replaces the proposals on {{count}} proposed rows. Approved rows aren't changed.", + "auto_match_notice_overwrites": "Re-matches every row from scratch, overwriting {{approved}} approved and {{proposed}} proposed decisions.", "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.", - "run_ai_analysis": "Run AI Analysis", - "run_ai_analysis_note": "Note: Enabling this feature will run AI Analysis on results of each row. This has direct cost implications.", "decision": "Decision", "decision_map": "Map", "decision_exclude": "Exclude", @@ -521,12 +518,6 @@ "candidates_metadata": "Candidates Metadata", "full_project_export": "Full Project Export", "select_an_algo": "Select an algorithm", - "selected_rows": "Selected Rows", - "all_including_approved": "All (incl. Approved)", - "unmapped_and_proposed": "Unmapped & Proposed", - "rows_to_match": "Rows to Match", - "out_of": "out of", - "retrieve_candidates": "Retrieve Candidates", "retrieve_candidates_helper_text": "Your project is configured to use the following match algorithms:", "all_candidates": "All Candidates", "rows": "rows", diff --git a/src/i18n/locales/es/translations.json b/src/i18n/locales/es/translations.json index 1c56d757..78fd023d 100644 --- a/src/i18n/locales/es/translations.json +++ b/src/i18n/locales/es/translations.json @@ -268,7 +268,6 @@ "clear_filter": "Limpiar Filtro", "configure_mapping_project_tooltip": "Configurar Proyecto de Mapeo", "auto_match": "AutoMatch", - "unmapped_only": "Solo No Mapeados", "propose": "Proponer", "proposal_note": "Nota de propuesta", "target_source": "Fuente Objetivo", @@ -300,13 +299,6 @@ "preview_quota_match_operations": "Operaciones de coincidencia", "preview_quota_ai_calls": "Llamadas al asistente de IA de OCL", "preview_quota_used_of_limit": "{{used}} de {{limit}} usados", - "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": "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 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}}.", "preview_limit_title_rows": "Has usado las filas de vista previa de este proyecto", "preview_limit_title_match_operations": "Has usado tus operaciones de coincidencia de vista previa", @@ -366,11 +358,7 @@ "loading_project": "Cargando proyecto...", "stopping_gracefully": "Deteniendo suavemente...", "stop_processing": "Detener Procesamiento", - "auto_match_unmapped_only_note": "Nota: Solo se emparejarán las filas no mapeadas", - "auto_match_note": "Nota: Esto no afectará {{approvedCount}} filas de entrada aprobadas pero sobrescribirá {{proposedCount}} filas de entrada propuestas", - "auto_match_all_including_approved_note": "Advertencia: Esto volverá a ejecutar cada fila de entrada desde cero y sobrescribirá {{approvedCount}} decisiones aprobadas y {{proposedCount}} propuestas", "auto_match_all_including_approved_confirm": "Entiendo que se sobrescribirán {{approvedCount}} decisiones aprobadas", - "auto_match_selected_rows_note": "Nota: Esto ejecutará AutoMatch para {{count}} fila(s) seleccionada(s)", "auto_match_selected_rows_note_no_count": "Nota: Seleccione filas en el panel izquierdo para habilitar esta opción", "auto_match_rows_failed": "La coincidencia falló en {{count}} fila(s) de entrada, así que les faltan candidatos: {{details}}. Las filas se cuentan desde la primera fila de datos de tu archivo. Vuelve a ejecutar AutoMatch en esas filas para reintentarlo.", "auto_match_rows_failed_algorithm": "{{algorithm}} en la(s) fila(s) {{rows}}", @@ -393,17 +381,26 @@ "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 AutoMatch.", - "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 AutoMatch.", - "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 te quedan llamadas al asistente de IA de OCL, así que no puede analizar estas filas.", "auto_match_nothing_to_run": "AutoMatch 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.", + "auto_match_target_repository": "Repositorio objetivo:", + "auto_match_input_rows": "Filas de entrada para coincidir: {{rows}} de {{total}} en total", + "auto_match_scope_selected": "Solo filas seleccionadas", + "auto_match_scope_unmapped": "Solo filas no mapeadas", + "auto_match_scope_unmapped_proposed": "Filas no mapeadas y propuestas", + "auto_match_scope_all": "Todas las filas, incluidas las aprobadas", + "auto_match_retrieve_candidates": "Recuperar candidatos", + "auto_match_ai_label": "Recomendar coincidencias con el asistente de IA de OCL", + "auto_match_ai_description": "Para cada fila, el asistente de IA de OCL compara los candidatos con todos los campos de la fila, recomienda una coincidencia y alternativas, o ninguna si nada encaja, y explica por qué.", + "auto_match_blocked_no_algorithms_ai_v2": "Configura un algoritmo de coincidencia o selecciona Recomendar coincidencias con el asistente de IA de OCL.", + "auto_match_blocked_no_step_ai_v2": "Selecciona Recuperar candidatos o Recomendar coincidencias con el asistente de IA de OCL.", + "auto_match_notice_replaces_proposed_one": "Reemplaza la propuesta de {{count}} fila propuesta. Las filas aprobadas no cambian.", + "auto_match_notice_replaces_proposed_other": "Reemplaza las propuestas de {{count}} filas propuestas. Las filas aprobadas no cambian.", + "auto_match_notice_overwrites": "Vuelve a coincidir cada fila desde cero y sobrescribe {{approved}} decisiones aprobadas y {{proposed}} propuestas.", "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.", - "run_ai_analysis": "Ejecutar Análisis de IA", - "run_ai_analysis_note": "Nota: Habilitar esta función ejecutará Análisis de IA en los resultados de cada fila. Esto tiene implicaciones de costo directas.", "decision": "Decisión", "decision_map": "Mapear", "decision_exclude": "Excluir", @@ -517,12 +514,6 @@ "candidates_metadata": "Metadatos de candidatos", "full_project_export": "Exportación completa del proyecto", "select_an_algo": "Seleccione un algoritmo", - "selected_rows": "Filas seleccionadas", - "all_including_approved": "Todas (incl. aprobadas)", - "unmapped_and_proposed": "No mapeadas y propuestas", - "rows_to_match": "Filas para coincidir", - "out_of": "de", - "retrieve_candidates": "Recuperar candidatos", "retrieve_candidates_helper_text": "Su proyecto está configurado para usar los siguientes algoritmos de coincidencia:", "all_candidates": "Todos los candidatos", "rows": "filas", diff --git a/src/i18n/locales/pt-BR/translations.json b/src/i18n/locales/pt-BR/translations.json index 85a50d4e..cd0fb85c 100644 --- a/src/i18n/locales/pt-BR/translations.json +++ b/src/i18n/locales/pt-BR/translations.json @@ -284,7 +284,6 @@ "clear_filter": "Limpar Filtro", "configure_mapping_project_tooltip": "Configurar Projeto de Mapeamento", "auto_match": "AutoMatch", - "unmapped_only": "Apenas Não Mapeados", "propose": "Propor", "proposal_note": "Nota da proposta", "target_source": "Source de Destino", @@ -316,13 +315,6 @@ "preview_quota_match_operations": "Operações de correspondência", "preview_quota_ai_calls": "Chamadas do Assistente de IA do OCL", "preview_quota_used_of_limit": "{{used}} de {{limit}} usado(s)", - "preview_estimate_will_truncate": "Isso cobre {{allowed}} das {{requested}} linhas que você selecionou, então corresponderemos as primeiras {{allowed}}.", - "preview_estimate_match": "{{rows}} linha(s) × {{algorithms}} algoritmo(s) = {{used}} de suas {{remaining}} operações de correspondência.", - "preview_estimate_no_operations_left": "Nenhuma operação de correspondência sobrou em sua prévia. Você pode continuar revisando, editando e exportando este projeto.", - "preview_estimate_ai_all": "O Assistente de IA do OCL analisará todas as {{count}} linha(s).", - "preview_estimate_ai_partial": "O Assistente de IA do OCL analisará a(s) primeira(s) {{covered}} linha(s); as outras {{rest}} são correspondidas sem ele.", - "preview_estimate_ai_none": "Você não tem mais chamadas do Assistente de IA do OCL, então estas linhas são correspondidas sem ele.", - "preview_estimate_ai_only_partial": "O Assistente de IA do OCL analisará a(s) primeira(s) {{covered}} linha(s); você não tem mais chamadas do Assistente de IA do OCL para as outras {{rest}}.", "preview_rows_activated_notice": "Projetos de prévia contêm {{limit}} linhas - ativamos as primeiras {{limit}} de suas {{total}}.", "preview_limit_title_rows": "Você usou as linhas de prévia deste projeto", "preview_limit_title_match_operations": "Você usou suas operações de correspondência de prévia", @@ -380,11 +372,7 @@ "loading_project": "Carregando projeto...", "stopping_gracefully": "Encerrando com segurança...", "stop_processing": "Parar Processamento", - "auto_match_unmapped_only_note": "Nota: Apenas linhas não mapeadas serão correspondidas", - "auto_match_note": "Nota: Isso não afetará {{approvedCount}} linhas de entrada aprovadas mas substituirá {{proposedCount}} linhas de entrada propostas", - "auto_match_all_including_approved_note": "Aviso: Isso executará novamente cada linha de entrada do zero e sobrescreverá {{approvedCount}} decisões aprovadas e {{proposedCount}} propostas", "auto_match_all_including_approved_confirm": "Entendo que {{approvedCount}} decisões aprovadas serão sobrescritas", - "auto_match_selected_rows_note": "Nota: isso executará AutoMatch para {{count}} linha(s) de entrada selecionada(s)", "auto_match_selected_rows_note_no_count": "Nota: Selecione linhas no painel esquerdo para habilitar esta opção", "auto_match_rows_failed": "O AutoMatch falhou para {{count}} linha(s) de entrada, então elas estão sem candidatos: {{details}}. As linhas contam a partir da primeira linha de dados do seu arquivo. Execute o AutoMatch nessas linhas novamente para tentar de novo.", "auto_match_rows_failed_algorithm": "{{algorithm}} na(s) linha(s) {{rows}}", @@ -407,17 +395,26 @@ "auto_match_ai_only_rows": "Sem Recuperar Candidatos, o Assistente de IA do OCL analisa apenas as linhas que já têm candidatos: ele analisará {{analyse}} linha(s) e ignorará {{skip}} que não têm candidatos ou que já foram analisadas com os candidatos atuais.", "auto_match_blocked_no_rows": "Não há linhas para corresponder neste escopo.", "auto_match_blocked_no_algorithms": "Configure um algoritmo de correspondência para executar o AutoMatch.", - "auto_match_blocked_no_algorithms_ai": "Configure um algoritmo de correspondência ou selecione Executar Análise de IA.", "auto_match_blocked_no_step": "Selecione Recuperar Candidatos para executar o AutoMatch.", - "auto_match_blocked_no_step_ai": "Selecione Recuperar Candidatos ou Executar Análise de IA.", "auto_match_blocked_no_ai_rows": "O Assistente de IA do OCL não tem linhas para analisar neste escopo.", - "auto_match_blocked_no_ai_calls": "Você não tem mais chamadas do Assistente de IA do OCL, então ele não pode analisar estas linhas.", "auto_match_nothing_to_run": "O AutoMatch não tinha nada para executar: nenhum algoritmo de correspondência está selecionado e o Assistente de IA do OCL não tem linhas para analisar.", + "auto_match_target_repository": "Repositório de destino:", + "auto_match_input_rows": "Linhas de entrada para correspondência: {{rows}} de {{total}} no total", + "auto_match_scope_selected": "Somente linhas selecionadas", + "auto_match_scope_unmapped": "Somente linhas não mapeadas", + "auto_match_scope_unmapped_proposed": "Linhas não mapeadas e propostas", + "auto_match_scope_all": "Todas as linhas, incluindo as aprovadas", + "auto_match_retrieve_candidates": "Recuperar candidatos", + "auto_match_ai_label": "Recomendar correspondências com o Assistente de IA do OCL", + "auto_match_ai_description": "Para cada linha, o Assistente de IA do OCL compara os candidatos com todos os campos da linha, recomenda uma correspondência e alternativas, ou nenhuma se nada servir, e explica por quê.", + "auto_match_blocked_no_algorithms_ai_v2": "Configure um algoritmo de correspondência ou selecione Recomendar correspondências com o Assistente de IA do OCL.", + "auto_match_blocked_no_step_ai_v2": "Selecione Recuperar candidatos ou Recomendar correspondências com o Assistente de IA do OCL.", + "auto_match_notice_replaces_proposed_one": "Substitui a proposta de {{count}} linha proposta. As linhas aprovadas não são alteradas.", + "auto_match_notice_replaces_proposed_other": "Substitui as propostas de {{count}} linhas propostas. As linhas aprovadas não são alteradas.", + "auto_match_notice_overwrites": "Refaz a correspondência de cada linha do zero, sobrescrevendo {{approved}} decisões aprovadas e {{proposed}} propostas.", "rerank_step": "Reclassificação", "search_throttled": "O servidor está ocupado, então a busca não foi executada. Tente novamente em alguns minutos.", "search_failed": "Não foi possível pesquisar ({{error}}). Tente novamente.", - "run_ai_analysis": "Executar Análise de IA", - "run_ai_analysis_note": "Nota: Habilitar este recurso executará Análise de IA nos resultados de cada linha. Isso tem implicações diretas de custo.", "decision": "Decisão", "decision_map": "Mapear", "decision_exclude": "Desconsiderar", @@ -521,12 +518,6 @@ "candidates_metadata": "Metadados de Candidatos", "full_project_export": "Exportação Completa do Projeto", "select_an_algo": "Selecionar um algoritmo", - "selected_rows": "Linhas Selecionadas", - "all_including_approved": "Todas (incl. Aprovadas)", - "unmapped_and_proposed": "Não Mapeadas e Propostas", - "rows_to_match": "Linhas para Correspondência", - "out_of": "de", - "retrieve_candidates": "Recuperar Candidatos", "retrieve_candidates_helper_text": "Seu projeto está configurado para usar os seguintes algoritmos de correspondência:", "all_candidates": "Todos os Candidatos", "rows": "linhas", diff --git a/src/i18n/locales/zh/translations.json b/src/i18n/locales/zh/translations.json index 7ab5fac7..1b8ec4b6 100644 --- a/src/i18n/locales/zh/translations.json +++ b/src/i18n/locales/zh/translations.json @@ -280,7 +280,6 @@ "clear_filter": "清除筛选器", "configure_mapping_project_tooltip": "配置映射项目", "auto_match": "AutoMatch", - "unmapped_only": "仅未映射", "propose": "提议", "proposal_note": "提议说明", "target_source": "目标来源", @@ -312,13 +311,6 @@ "preview_quota_match_operations": "匹配操作", "preview_quota_ai_calls": "OCL AI 助手调用", "preview_quota_used_of_limit": "已用 {{used}} / {{limit}}", - "preview_estimate_will_truncate": "这可覆盖你所选 {{requested}} 行中的 {{allowed}} 行,因此我们将匹配前 {{allowed}} 行。", - "preview_estimate_match": "{{rows}} 行 × {{algorithms}} 个算法 = 你剩余 {{remaining}} 次匹配操作中的 {{used}} 次。", - "preview_estimate_no_operations_left": "你的预览已没有剩余的匹配操作。你仍可以继续审核、编辑和导出此项目。", - "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}} 行。", "preview_limit_title_rows": "本项目的预览行数已用完", "preview_limit_title_match_operations": "预览匹配操作次数已用完", @@ -378,11 +370,7 @@ "loading_project": "正在加载项目...", "stopping_gracefully": "正在优雅地停止...", "stop_processing": "停止处理", - "auto_match_unmapped_only_note": "注意:只会匹配未映射的输入行", - "auto_match_note": "注意:这不会影响 {{approvedCount}} 个已批准的输入行,但会覆盖 {{proposedCount}} 个已提议的输入行", - "auto_match_all_including_approved_note": "警告:这将从头重新运行每一条输入行,并覆盖 {{approvedCount}} 个已批准和 {{proposedCount}} 个已提议的决策", "auto_match_all_including_approved_confirm": "我明白将覆盖 {{approvedCount}} 个已批准的决策", - "auto_match_selected_rows_note": "注意:这将对 {{count}} 个已选输入行运行 AutoMatch", "auto_match_selected_rows_note_no_count": "注意:请在左侧面板选择行以启用此选项", "auto_match_rows_failed": "{{count}} 个输入行匹配失败,缺少候选项:{{details}}。行号从文件的第一个数据行开始计数。请对这些行重新运行 AutoMatch 以重试。", "auto_match_rows_failed_algorithm": "{{algorithm}}:第 {{rows}} 行", @@ -405,17 +393,25 @@ "auto_match_ai_only_rows": "不获取候选项时,OCL AI 助手只分析已有候选项的行:将分析 {{analyse}} 行,跳过 {{skip}} 行(没有候选项,或已用其当前候选项分析过)。", "auto_match_blocked_no_rows": "此范围内没有要匹配的行。", "auto_match_blocked_no_algorithms": "请配置匹配算法以运行 AutoMatch。", - "auto_match_blocked_no_algorithms_ai": "请配置匹配算法,或选择运行 AI 分析。", "auto_match_blocked_no_step": "请选择获取候选项以运行 AutoMatch。", - "auto_match_blocked_no_step_ai": "请选择获取候选项或运行 AI 分析。", "auto_match_blocked_no_ai_rows": "此范围内没有可供 OCL AI 助手分析的行。", - "auto_match_blocked_no_ai_calls": "您的 OCL AI 助手调用次数已用完,因此它无法分析这些行。", "auto_match_nothing_to_run": "AutoMatch 没有可运行的内容:未选择任何匹配算法,且 OCL AI 助手没有可分析的行。", + "auto_match_target_repository": "目标存储库:", + "auto_match_input_rows": "要匹配的输入行:{{rows}} / 共 {{total}} 行", + "auto_match_scope_selected": "仅已选行", + "auto_match_scope_unmapped": "仅未映射的行", + "auto_match_scope_unmapped_proposed": "未映射和已提议的行", + "auto_match_scope_all": "所有行,包括已批准的行", + "auto_match_retrieve_candidates": "获取候选项", + "auto_match_ai_label": "使用 OCL AI 助手推荐匹配", + "auto_match_ai_description": "OCL AI 助手会针对每一行,将候选项与该行的所有字段进行比较,推荐一个匹配项及备选项(若都不合适则不推荐),并说明理由。", + "auto_match_blocked_no_algorithms_ai_v2": "请配置匹配算法,或选择使用 OCL AI 助手推荐匹配。", + "auto_match_blocked_no_step_ai_v2": "请选择获取候选项或使用 OCL AI 助手推荐匹配。", + "auto_match_notice_replaces_proposed_other": "将替换 {{count}} 个已提议行的提议。已批准的行不会更改。", + "auto_match_notice_overwrites": "从头重新匹配每一行,覆盖 {{approved}} 个已批准和 {{proposed}} 个已提议的决策。", "rerank_step": "重新排序", "search_throttled": "服务器繁忙,搜索未能运行。请几分钟后重试。", "search_failed": "无法搜索({{error}})。请重试。", - "run_ai_analysis": "运行 AI 分析", - "run_ai_analysis_note": "注意:启用此功能将对每行的结果运行 AI 分析。这会产生直接的成本影响。", "decision": "决策", "decision_map": "映射", "decision_exclude": "排除", @@ -529,12 +525,6 @@ "candidates_metadata": "候选项元数据", "full_project_export": "完整项目导出", "select_an_algo": "选择算法", - "selected_rows": "已选行", - "all_including_approved": "全部(含已批准)", - "unmapped_and_proposed": "未映射和已提议", - "rows_to_match": "要匹配的行", - "out_of": "共", - "retrieve_candidates": "获取候选项", "retrieve_candidates_helper_text": "你的项目已配置为使用以下匹配算法:", "all_candidates": "所有候选项", "rows": "行", diff --git a/webpack.config.js b/webpack.config.js index 36f55166..2b8e82c2 100644 --- a/webpack.config.js +++ b/webpack.config.js @@ -1,9 +1,19 @@ +const fs = require('fs'); const path = require('path'); const HtmlWebpackPlugin = require('html-webpack-plugin'); const MiniCssExtractPlugin = require('mini-css-extract-plugin'); const CopyWebpackPlugin = require('copy-webpack-plugin'); const { ProvidePlugin, DefinePlugin, IgnorePlugin } = require('webpack'); +// Checkpoints a private package can fill in at build time +// (OpenConceptLab/ocl_online#465). The Dockerfile installs the package when +// PRIVATE_PACKAGES_GIT is set; without it, the open stub is used. +const PRIVATE_PACKAGE = 'ocl-online-mapper'; +const privatePackagePath = path.resolve(__dirname, 'node_modules', PRIVATE_PACKAGE); +const privatePackageAlias = fs.existsSync(privatePackagePath) ? + privatePackagePath : + path.resolve(__dirname, 'src/components/map-projects/autoMatchCheck.js'); + module.exports = (env) => { const isProduction = env.NODE_ENV === 'production'; return { @@ -26,6 +36,13 @@ module.exports = (env) => { use: ['babel-loader'], exclude: /node_modules/, }, + { + // The private package ships JSX source. A .babelrc doesn't reach + // files in another package, so this rule extends ours. + test: /\.(js|jsx)$/, + include: new RegExp(`node_modules[\\\\/]${PRIVATE_PACKAGE}[\\\\/]`), + use: [{loader: 'babel-loader', options: {extends: path.resolve(__dirname, '.babelrc')}}], + }, { test: /\.(png|jpg|woff|woff2|eot|ttf|svg)$/, loader: 'url-loader' @@ -143,6 +160,9 @@ module.exports = (env) => { ], resolve: { extensions: ['.js', '.jsx'], + alias: { + [`${PRIVATE_PACKAGE}$`]: privatePackageAlias, + }, }, }; }; From 968a45b7d09672c29cca88bf70f3d6d33c9e086d Mon Sep 17 00:00:00 2001 From: Sunny Aggarwal Date: Mon, 19 Oct 2026 08:14:07 +0530 Subject: [PATCH 2/5] OpenConceptLab/ocl_online#465 | review feedback: keep keyboard focus trapped in the AutoMatch dialog Drops disableEnforceFocus, which let focus move to controls behind the modal. Co-Authored-By: Claude Opus 5.5 --- src/components/map-projects/AutoMatchDialog.jsx | 1 - 1 file changed, 1 deletion(-) diff --git a/src/components/map-projects/AutoMatchDialog.jsx b/src/components/map-projects/AutoMatchDialog.jsx index b9b492e2..aaf3d4c8 100644 --- a/src/components/map-projects/AutoMatchDialog.jsx +++ b/src/components/map-projects/AutoMatchDialog.jsx @@ -177,7 +177,6 @@ const AutoMatchDialog = ({ open={open} onClose={onClose} scroll='paper' - disableEnforceFocus sx={{ '& .MuiDialog-paper': { borderRadius: '28px', From 82ffbded0aa5b853adaa6beea42bde87ae7b0bf0 Mon Sep 17 00:00:00 2001 From: Sunny Aggarwal Date: Mon, 19 Oct 2026 10:32:03 +0530 Subject: [PATCH 3/5] OpenConceptLab/ocl_online#465 | Dockerfile: install the private UI package the way oclweb3 does PRIVATE_PACKAGES_GIT is the whole npm install spec, so the image build needs no other arguments about where the package lives. Co-Authored-By: Claude Opus 5.5 --- Dockerfile | 19 ++++++------------- 1 file changed, 6 insertions(+), 13 deletions(-) diff --git a/Dockerfile b/Dockerfile index c1aa9354..04fe9532 100644 --- a/Dockerfile +++ b/Dockerfile @@ -27,23 +27,16 @@ ADD package-lock.json /app/ RUN npm ci --production=false -# OPTIONAL: install the private Mapper package during the image build -# (OpenConceptLab/ocl_online#465). webpack.config.js uses it when it's in -# node_modules, and the open stubs otherwise. --install-links copies it in, so -# its imports resolve to this app's packages. SOURCE_COMMIT refreshes the layer -# on every commit, so a cached build doesn't keep an old copy. +# OPTIONAL: install a private UI package during image build +# PRIVATE_PACKAGES_GIT is an npm install spec, e.g. a git+ssh URL ARG PRIVATE_PACKAGES_GIT -ARG PRIVATE_PACKAGES_REF=main -ARG PRIVATE_PACKAGES_PATH=oclmap-plugin ARG SOURCE_COMMIT RUN --mount=type=ssh \ + mkdir -p /root/.ssh && \ + ssh-keyscan github.com >> /root/.ssh/known_hosts && \ if [ -n "$PRIVATE_PACKAGES_GIT" ]; then \ - mkdir -p /root/.ssh && \ - ssh-keyscan github.com >> /root/.ssh/known_hosts && \ - echo "Installing private packages (oclmap commit $SOURCE_COMMIT):" $PRIVATE_PACKAGES_GIT $PRIVATE_PACKAGES_REF $PRIVATE_PACKAGES_PATH && \ - git clone --depth 1 --branch "$PRIVATE_PACKAGES_REF" "$PRIVATE_PACKAGES_GIT" /tmp/private-packages && \ - npm i --no-save --install-links --production=false "/tmp/private-packages/$PRIVATE_PACKAGES_PATH" && \ - rm -rf /tmp/private-packages; \ + echo "Installing private UI packages (oclmap commit $SOURCE_COMMIT)" && \ + npm i $PRIVATE_PACKAGES_GIT --no-save --production=false; \ fi ADD webpack.config.js /app/ From 776e6af7bc6c6dc76e1ab7475aa4a83d4c19b414 Mon Sep 17 00:00:00 2001 From: Sunny Aggarwal Date: Mon, 19 Oct 2026 12:58:17 +0530 Subject: [PATCH 4/5] OpenConceptLab/ocl_online#465 | review feedback: only set up SSH when a private UI package is configured Without PRIVATE_PACKAGES_GIT the image build no longer runs ssh-keyscan, so it doesn't need outbound SSH to GitHub. Co-Authored-By: Claude Opus 5.5 --- Dockerfile | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Dockerfile b/Dockerfile index 04fe9532..0da905e9 100644 --- a/Dockerfile +++ b/Dockerfile @@ -32,9 +32,9 @@ RUN npm ci --production=false ARG PRIVATE_PACKAGES_GIT ARG SOURCE_COMMIT RUN --mount=type=ssh \ - mkdir -p /root/.ssh && \ - ssh-keyscan github.com >> /root/.ssh/known_hosts && \ if [ -n "$PRIVATE_PACKAGES_GIT" ]; then \ + mkdir -p /root/.ssh && \ + ssh-keyscan github.com >> /root/.ssh/known_hosts && \ echo "Installing private UI packages (oclmap commit $SOURCE_COMMIT)" && \ npm i $PRIVATE_PACKAGES_GIT --no-save --production=false; \ fi From a686d457f04d376bf4e835437f7fa378923d90eb Mon Sep 17 00:00:00 2001 From: Sunny Aggarwal Date: Wed, 21 Oct 2026 07:54:34 +0530 Subject: [PATCH 5/5] OpenConceptLab/ocl_online#465 | review feedback: runs change only the rows they include, AI default from loaded logs, open image and deploy image built apart --- .github/workflows/build.yml | 102 ++++++++++++------ Dockerfile | 10 +- .../map-projects/AutoMatchDialog.jsx | 2 +- src/components/map-projects/MapProject.jsx | 80 ++++++++------ .../__tests__/autoMatchRows.test.js | 50 +++++++-- src/components/map-projects/autoMatchCheck.js | 15 ++- src/components/map-projects/autoMatchRows.js | 34 ++++-- src/i18n/locales/en/translations.json | 2 +- src/i18n/locales/es/translations.json | 2 +- src/i18n/locales/pt-BR/translations.json | 2 +- src/i18n/locales/zh/translations.json | 2 +- webpack.config.js | 8 +- 12 files changed, 212 insertions(+), 97 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index b390f49a..5040d88a 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -49,8 +49,6 @@ jobs: needs: [lint-and-test] name: Build and push image runs-on: ubuntu-latest - env: - HAS_PRIVATE_PACKAGES: ${{ secrets.PRIVATE_GIT_PACKAGES_DEPLOY_KEY != '' }} steps: - name: Checkout repo @@ -71,11 +69,6 @@ jobs: # registry: ghcr.io # username: ${{ github.actor }} # password: ${{ secrets.GITHUB_TOKEN }} - - name: Setup SSH agent for private packages - if: env.HAS_PRIVATE_PACKAGES == 'true' - uses: webfactory/ssh-agent@v0.9.0 - with: - ssh-private-key: ${{ secrets.PRIVATE_GIT_PACKAGES_DEPLOY_KEY }} - name: Set up Docker Buildx uses: docker/setup-buildx-action@v3 - name: Build and push Docker image @@ -84,15 +77,68 @@ jobs: context: . push: true tags: openconceptlab/oclmap:nightly, openconceptlab/oclmap:${{ steps.version.outputs.tag }} - ssh: ${{ env.HAS_PRIVATE_PACKAGES == 'true' && 'default' || '' }} + build-args: SOURCE_COMMIT=${{ steps.version.outputs.sha }} + cache-from: type=gha + cache-to: type=gha,mode=max + + # The image the deploy jobs below run: the open build plus a private UI + # package, pushed only to the private ECR repository. Docker Hub gets the + # open image above. The private-packages environment holds the deploy key + # and PRIVATE_PACKAGES_GIT, and only main may use it. No cache is written, + # so no layer with the package lands in this repo's Actions cache. + build-private: + needs: [lint-and-test] + name: Build and push deploy image + runs-on: ubuntu-latest + environment: private-packages + + steps: + - name: Checkout repo + uses: actions/checkout@v3 + - name: Compute version and short sha + id: version + run: | + echo "tag=$(bash release_version.sh full-version)" >> $GITHUB_OUTPUT + echo "sha=$(bash release_version.sh sha)" >> $GITHUB_OUTPUT + - name: Fail without the private package settings + env: + HAS_KEY: ${{ secrets.PRIVATE_GIT_PACKAGES_DEPLOY_KEY != '' }} + HAS_SPEC: ${{ secrets.PRIVATE_PACKAGES_GIT != '' }} + run: | + if [ "$HAS_KEY" != "true" ] || [ "$HAS_SPEC" != "true" ]; then + echo "::error::Set PRIVATE_GIT_PACKAGES_DEPLOY_KEY and PRIVATE_PACKAGES_GIT in the private-packages environment." + exit 1 + fi + - name: Configure AWS credentials + uses: aws-actions/configure-aws-credentials@v1 + with: + aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }} + aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }} + aws-region: ${{ env.AWS_REGION }} + - name: Login to Amazon ECR + id: login-ecr + uses: aws-actions/amazon-ecr-login@v1 + - name: Setup SSH agent + uses: webfactory/ssh-agent@dc588b651fe13675774614f8e6a936a468676387 # v0.9.0 + with: + ssh-private-key: ${{ secrets.PRIVATE_GIT_PACKAGES_DEPLOY_KEY }} + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + - name: Build and push deploy image + uses: docker/build-push-action@3b5e8027fcad23fda98b2e3ac259d8d67585f671 + with: + context: . + push: true + ssh: default + provenance: false + tags: ${{ steps.login-ecr.outputs.registry }}/${{ env.ECR_REPOSITORY }}:${{ steps.version.outputs.tag }} build-args: | SOURCE_COMMIT=${{ steps.version.outputs.sha }} PRIVATE_PACKAGES_GIT=${{ secrets.PRIVATE_PACKAGES_GIT }} cache-from: type=gha - cache-to: type=gha,mode=max release: - needs: [build] + needs: [build, build-private] runs-on: ubuntu-latest name: Release environment: rc @@ -136,13 +182,11 @@ jobs: ECR_REGISTRY: ${{ steps.login-ecr.outputs.registry }} IMAGE_TAG: ${{ steps.version.outputs.tag }} run: | - # Build a docker container and - # push it to ECR so that it can + # Tag the image build-private pushed to ECR + # for this environment, so that it can # be deployed to ECS. - docker pull openconceptlab/oclmap:$IMAGE_TAG - docker tag openconceptlab/oclmap:$IMAGE_TAG $ECR_REGISTRY/$ECR_REPOSITORY:$IMAGE_TAG - docker tag openconceptlab/oclmap:$IMAGE_TAG $ECR_REGISTRY/$ECR_REPOSITORY:dev - docker push $ECR_REGISTRY/$ECR_REPOSITORY:$IMAGE_TAG + docker pull $ECR_REGISTRY/$ECR_REPOSITORY:$IMAGE_TAG + docker tag $ECR_REGISTRY/$ECR_REPOSITORY:$IMAGE_TAG $ECR_REGISTRY/$ECR_REPOSITORY:dev docker push $ECR_REGISTRY/$ECR_REPOSITORY:dev echo "image=$ECR_REGISTRY/$ECR_REPOSITORY:$IMAGE_TAG" >> $GITHUB_OUTPUT - name: Download task definition @@ -189,13 +233,11 @@ jobs: ECR_REGISTRY: ${{ steps.login-ecr.outputs.registry }} IMAGE_TAG: ${{ steps.version.outputs.tag }} run: | - # Build a docker container and - # push it to ECR so that it can + # Tag the image build-private pushed to ECR + # for this environment, so that it can # be deployed to ECS. - docker pull openconceptlab/oclmap:$IMAGE_TAG - docker tag openconceptlab/oclmap:$IMAGE_TAG $ECR_REGISTRY/$ECR_REPOSITORY:$IMAGE_TAG - docker tag openconceptlab/oclmap:$IMAGE_TAG $ECR_REGISTRY/$ECR_REPOSITORY:qa - docker push $ECR_REGISTRY/$ECR_REPOSITORY:$IMAGE_TAG + docker pull $ECR_REGISTRY/$ECR_REPOSITORY:$IMAGE_TAG + docker tag $ECR_REGISTRY/$ECR_REPOSITORY:$IMAGE_TAG $ECR_REGISTRY/$ECR_REPOSITORY:qa docker push $ECR_REGISTRY/$ECR_REPOSITORY:qa echo "image=$ECR_REGISTRY/$ECR_REPOSITORY:$IMAGE_TAG" >> $GITHUB_OUTPUT - name: Download task definition @@ -242,11 +284,11 @@ jobs: ECR_REGISTRY: ${{ steps.login-ecr.outputs.registry }} IMAGE_TAG: ${{ steps.version.outputs.tag }} run: | - # Build a docker container and - # push it to ECR so that it can + # Tag the image build-private pushed to ECR + # for this environment, so that it can # be deployed to ECS. - docker pull openconceptlab/oclmap:$IMAGE_TAG - docker tag openconceptlab/oclmap:$IMAGE_TAG $ECR_REGISTRY/$ECR_REPOSITORY:staging + docker pull $ECR_REGISTRY/$ECR_REPOSITORY:$IMAGE_TAG + docker tag $ECR_REGISTRY/$ECR_REPOSITORY:$IMAGE_TAG $ECR_REGISTRY/$ECR_REPOSITORY:staging docker push $ECR_REGISTRY/$ECR_REPOSITORY:staging echo "image=$ECR_REGISTRY/$ECR_REPOSITORY:$IMAGE_TAG" >> $GITHUB_OUTPUT - name: Download task definition @@ -293,11 +335,11 @@ jobs: ECR_REGISTRY: ${{ steps.login-ecr.outputs.registry }} IMAGE_TAG: ${{ steps.version.outputs.tag }} run: | - # Build a docker container and - # push it to ECR so that it can + # Tag the image build-private pushed to ECR + # for this environment, so that it can # be deployed to ECS. - docker pull openconceptlab/oclmap:$IMAGE_TAG - docker tag openconceptlab/oclmap:$IMAGE_TAG $ECR_REGISTRY/$ECR_REPOSITORY:production + docker pull $ECR_REGISTRY/$ECR_REPOSITORY:$IMAGE_TAG + docker tag $ECR_REGISTRY/$ECR_REPOSITORY:$IMAGE_TAG $ECR_REGISTRY/$ECR_REPOSITORY:production docker push $ECR_REGISTRY/$ECR_REPOSITORY:production echo "image=$ECR_REGISTRY/$ECR_REPOSITORY:$IMAGE_TAG" >> $GITHUB_OUTPUT - name: Download task definition diff --git a/Dockerfile b/Dockerfile index 0da905e9..ba941e20 100644 --- a/Dockerfile +++ b/Dockerfile @@ -28,15 +28,19 @@ ADD package-lock.json /app/ RUN npm ci --production=false # OPTIONAL: install a private UI package during image build -# PRIVATE_PACKAGES_GIT is an npm install spec, e.g. a git+ssh URL +# PRIVATE_PACKAGES_GIT is an npm install spec, e.g. a git+ssh URL. The package +# fills webpack.config.js's checkpoints; the build fails if it doesn't land. +# GitHub's SSH host keys are pinned (https://api.github.com/meta). ARG PRIVATE_PACKAGES_GIT ARG SOURCE_COMMIT RUN --mount=type=ssh \ if [ -n "$PRIVATE_PACKAGES_GIT" ]; then \ mkdir -p /root/.ssh && \ - ssh-keyscan github.com >> /root/.ssh/known_hosts && \ + echo "github.com ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIOMqqnkVzrm0SdG6UOoqKLsabgH5C9okWi0dh2l9GKJl" >> /root/.ssh/known_hosts && \ + echo "github.com ecdsa-sha2-nistp256 AAAAE2VjZHNhLXNoYTItbmlzdHAyNTYAAAAIbmlzdHAyNTYAAABBBEmKSENjQEezOmxkZMy7opKgwFB9nkt5YRrYMjNuG5N87uRgg6CLrbo5wAdT/y6v0mKV0U2w0WZ2YB/++Tpockg=" >> /root/.ssh/known_hosts && \ echo "Installing private UI packages (oclmap commit $SOURCE_COMMIT)" && \ - npm i $PRIVATE_PACKAGES_GIT --no-save --production=false; \ + npm i $PRIVATE_PACKAGES_GIT --no-save --ignore-scripts --production=false && \ + node -e "const p = require('./node_modules/.package-lock.json').packages['node_modules/@openconceptlab/oclmap-extensions']; if(!p) { console.error('The private UI package did not install as @openconceptlab/oclmap-extensions'); process.exit(1) } console.log('Installed', p.version, p.resolved)"; \ fi ADD webpack.config.js /app/ diff --git a/src/components/map-projects/AutoMatchDialog.jsx b/src/components/map-projects/AutoMatchDialog.jsx index aaf3d4c8..9a7b0038 100644 --- a/src/components/map-projects/AutoMatchDialog.jsx +++ b/src/components/map-projects/AutoMatchDialog.jsx @@ -27,7 +27,7 @@ import RepoChip from '../repos/RepoVersionChip' import AIAssistantSelectorPanel from './AIAssistantSelectorPanel' import { getAutoMatchBlocker } from './autoMatchRows' // A private package can fill this checkpoint in; see autoMatchCheck.js. -import useAutoMatchCheck from 'ocl-online-mapper' +import useAutoMatchCheck from '@openconceptlab/oclmap-extensions' // Lines an option's sub-text up with its checkbox label. const SUBTEXT_SX = {margin: '-6px 0 0 32px'} diff --git a/src/components/map-projects/MapProject.jsx b/src/components/map-projects/MapProject.jsx index b7ba84b7..c70ef3b2 100644 --- a/src/components/map-projects/MapProject.jsx +++ b/src/components/map-projects/MapProject.jsx @@ -89,8 +89,8 @@ import MapProjectDeleteConfirmDialog from './MapProjectDeleteConfirmDialog'; import ConfigurationForm from './ConfigurationForm' import Controls from './Controls' import DataGridControls from './DataGridControls' -import { getPreviewEligibleRowIndexes, getRowsToProcess, countAIOnlyRows, getAutoMatchAIDefault, 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 { aiOnByDefault } from 'ocl-online-mapper' +import { getPreviewEligibleRowIndexes, getRowsToProcess, countAIOnlyRows, getAutoMatchAIOnOpen, takeRunRows, clearRunRowStatuses, canRunAlgorithm, spendsMatchQuota, shouldStopAIStep, getAIRequestIdempotencyKey, getCandidatePoolFingerprint, hasCurrentAnalysis, getScispacyRowResults, getPendingRowLookups, waitForLookups, AI_LOOKUP_WAIT_MS, RERANK_LOOKUP_WAIT_MS, RERANK_MAX_IN_FLIGHT, isScispacyWarmingUp } from './autoMatchRows' +import { aiOnByDefault, getMaxRunRows } from '@openconceptlab/oclmap-extensions' import { createAutosaveScheduler, createLatestSender, saveOnLeave, trackSave, whenSaved, installUnloadGuard } from './autosave' import MatchSummaryCard from './MatchSummaryCard' import MappingDecisionResult from './MappingDecisionResult' @@ -302,6 +302,10 @@ const MapProject = () => { const [projectLogs, setProjectLogs] = React.useState([]) const logsRef = React.useRef({}) const projectLogsRef = React.useRef([]) + // A saved project's logs load after it opens. Until they have, AutoMatch + // can't tell a project's first run from a later one. + const projectLogsLoadedRef = React.useRef(!params.projectId) + const autoMatchAIDefaultAppliedRef = React.useRef(false) const configSnapshotOnOpenRef = React.useRef(null) const projectIdRef = React.useRef(null) const onSaveRef = React.useRef(null) @@ -854,6 +858,7 @@ const MapProject = () => { projectLogsRef.current = savedProjectLogs setLogs(rowLogs) setProjectLogs(savedProjectLogs) + projectLogsLoadedRef.current = true projectLog({action: 'Opened'}) }) } @@ -2286,14 +2291,13 @@ const MapProject = () => { } - const getRowsResults = async (rows, selectedAlgos) => { + const getRowsResults = async (rows, selectedAlgos, { runRows, requested, maxRows }) => { abortRef.current = false; matchQuotaStopRef.current = null; rerankQuotaStopRef.current = null; runPreviewLimitShownRef.current = false; const selectedRowIndexes = getSelectedRowIndexes(rows) const isAutoMatchUnmappedOnly = autoMatchScope === 'unmapped' - const isAutoMatchAllRows = autoMatchScope === 'all' const isAutoMatchAllIncludingApproved = autoMatchScope === 'allIncludingApproved' const isAutoMatchSelectedRows = autoMatchScope === 'selected' const selectedRowsLogExtras = isAutoMatchSelectedRows ? { @@ -2510,35 +2514,15 @@ const MapProject = () => { } }) - // An AI-only run doesn't match, so nothing would set these statuses again. - if(isAutoMatchAllRows && !isAIOnly) - setRowStatuses(prev => ({...prev, readyForReview: []})) - if(isAutoMatchAllIncludingApproved && !isAIOnly) - setRowStatuses(prev => ({...prev, readyForReview: [], reviewed: []})) - if(isAutoMatchSelectedRows && !isAIOnly) - setRowStatuses(prev => ({ - ...prev, - readyForReview: without(prev.readyForReview, ...selectedRowIndexes), - reviewed: without(prev.reviewed, ...selectedRowIndexes) - })) + // Only the rows this run matches lose their statuses: the run sets them + // again. An AI-only run doesn't match, so it changes none. + if(!isAIOnly) + setRowStatuses(prev => clearRunRowStatuses(prev, map(runRows, '__index'))) + if(maxRows !== null) + projectLog({action: 'auto_match_pre_truncated', extras: {requested, allowed: maxRows}}) setTimeout(async () => { - const preview = getMapperPreview() - const previewEligibleRowIndexes = getPreviewEligibleRowIndexes(rows, preview) - let rowsToProcess = getRowsToProcess(rows, rowStatuses, autoMatchScope, selectedRowIndexes, previewEligibleRowIndexes) - - // Rows per project is already applied by previewEligibleRowIndexes; the - // only spendable cap is match operations, counted over the algorithms - // that actually call $match. - const matchAlgorithmCount = filter(_selectedAlgos, algo => spendsMatchQuota(algo, {canBridge})).length - const rowCap = getRowCapByMatchOperations(preview.matchOperations, matchAlgorithmCount) - if(rowCap !== null && rowsToProcess.length > rowCap) { - const requested = rowsToProcess.length - rowsToProcess = rowsToProcess.slice(0, rowCap) - projectLog({action: 'auto_match_pre_truncated', extras: {requested, allowed: rowCap}}) - } - if(isAIOnly) - rowsToProcess = getAIAnalysableRows(rowsToProcess) + const rowsToProcess = runRows // ocl_online#105 Phase 5: open the run record, then guarantee it is // closed out (completed / partial / failed / cancelled) via the finally, @@ -2960,8 +2944,17 @@ const MapProject = () => { event.stopPropagation() event.preventDefault() setAutoMatchScope(getSelectedRowIndexes().length ? 'selected' : (rowStatuses.unmapped.length ? 'unmapped' : 'all')) - if(inAIAssistantGroup) - setAutoRunAIAnalysis(getAutoMatchAIDefault(projectLogsRef.current, aiOnByDefault())) + if(inAIAssistantGroup) { + const { runAI, applied } = getAutoMatchAIOnOpen({ + current: autoRunAIAnalysis, + applied: autoMatchAIDefaultAppliedRef.current, + logsLoaded: projectLogsLoadedRef.current, + projectLogs: projectLogsRef.current, + aiOnByDefault: aiOnByDefault(), + }) + autoMatchAIDefaultAppliedRef.current = applied + setAutoRunAIAnalysis(runAI) + } setMatchDialog(true) } @@ -2977,11 +2970,28 @@ const MapProject = () => { return Boolean(inAIAssistantGroup && autoRunAIAnalysis) && (aiOnlyRowCounts?.analyse || 0) > 0 } + // The rows a run includes, worked out before it changes anything: the + // scope's rows, at most getMaxRunRows of them, and for a run without + // algorithms the rows the OCL AI Assistant can analyse. + const getAutoMatchRunRows = (rows, selectedAlgos) => { + const algos = filter(runnableAlgos, algo => selectedAlgos.includes(algo.id)) + const scopeRows = getRowsToProcess(rows, rowStatuses, autoMatchScope, getSelectedRowIndexes(rows), getPreviewEligibleRowIndexes(rows, getMapperPreview())) + const maxRows = getMaxRunRows({matchAlgorithmCount: filter(algos, algo => spendsMatchQuota(algo, {canBridge})).length}) + const firstRows = takeRunRows(scopeRows, maxRows) + const isAIOnly = !algos.length && Boolean(inAIAssistantGroup && autoRunAIAnalysis) + return { + runRows: isAIOnly ? getAIAnalysableRows(firstRows) : firstRows, + requested: scopeRows.length, + maxRows: firstRows.length < scopeRows.length ? maxRows : null, + } + } + const onGetCandidatesSubmit = (event, selectedAlgos) => { event.stopPropagation() event.preventDefault() setAlert(false) - if(!hasAutoMatchWork(selectedAlgos)) { + const run = hasAutoMatchWork(selectedAlgos) ? getAutoMatchRunRows(data, selectedAlgos) : null + if(!run?.runRows.length) { setAlert({message: t('map_project.auto_match_nothing_to_run'), severity: 'warning'}) } else if(isAnyValidColumn()){ setStartMatchingAt(moment()) @@ -2992,7 +3002,7 @@ const MapProject = () => { setScispacyCandidatesStartedAt(null) setScispacyCandidatesEndedAt(null) setLoadingMatches(true) - getRowsResults(data, selectedAlgos) + getRowsResults(data, selectedAlgos, run) } else { setAlert({message: t('map_project.no_valid_columns_for_matching')}) setTimeout(() => setAlert(false), 10000) diff --git a/src/components/map-projects/__tests__/autoMatchRows.test.js b/src/components/map-projects/__tests__/autoMatchRows.test.js index 62d85619..79249f4c 100644 --- a/src/components/map-projects/__tests__/autoMatchRows.test.js +++ b/src/components/map-projects/__tests__/autoMatchRows.test.js @@ -2,9 +2,9 @@ import test from 'node:test' import assert from 'node:assert/strict' import { - getPreviewEligibleRowIndexes, getRowsToProcess, spendsMatchQuota, getRowCapByMatchOperations, shouldStopAIStep, + getPreviewEligibleRowIndexes, getRowsToProcess, spendsMatchQuota, takeRunRows, clearRunRowStatuses, shouldStopAIStep, getAIRequestIdempotencyKey, getCandidatePoolFingerprint, hasCurrentAnalysis, getScispacyRowResults, getPendingRowLookups, waitForLookups, RERANK_LOOKUP_WAIT_MS, AI_LOOKUP_WAIT_MS, isScispacyWarmingUp, RERANK_MAX_IN_FLIGHT, - countAIOnlyRows, getAutoMatchBlocker, getAutoMatchAIDefault, canRunAlgorithm + countAIOnlyRows, getAutoMatchBlocker, getAutoMatchAIDefault, getAutoMatchAIOnOpen, canRunAlgorithm } from '../autoMatchRows.js' import { createLimiter } from '../../../services/capacity.js' @@ -130,12 +130,25 @@ test('spendsMatchQuota: only algorithms that call $match spend match quota', () assert.equal(spendsMatchQuota(null), false) }) -test('getRowCapByMatchOperations: divides remaining operations by $match algorithms', () => { - assert.equal(getRowCapByMatchOperations({ remaining: 50 }, 2), 25) - assert.equal(getRowCapByMatchOperations({ remaining: 5 }, 2), 2) - assert.equal(getRowCapByMatchOperations({ remaining: 0 }, 1), 0) - assert.equal(getRowCapByMatchOperations({ unlimited: true }, 2), null) - assert.equal(getRowCapByMatchOperations({ remaining: 10 }, 0), null) +test('takeRunRows: takes the first maxRows rows, or all of them when maxRows is null', () => { + const scope = [{ __index: 3 }, { __index: 5 }, { __index: 8 }] + assert.deepEqual(takeRunRows(scope, null), scope) + assert.deepEqual(takeRunRows(scope, 5), scope) + assert.deepEqual(takeRunRows(scope, 2).map(row => row.__index), [3, 5]) + assert.deepEqual(takeRunRows(scope, 0), []) + assert.deepEqual(takeRunRows(null, 2), []) +}) + +test('clearRunRowStatuses: only the rows the run includes lose their statuses', () => { + const statuses = { unmapped: [0], readyForReview: [1, 2, 3], reviewed: [4, 5] } + assert.deepEqual(clearRunRowStatuses(statuses, [1, 4]), { unmapped: [0], readyForReview: [2, 3], reviewed: [5] }) + assert.deepEqual(clearRunRowStatuses(statuses, []), statuses) +}) + +test('clearRunRowStatuses: a run cut to its first rows keeps the proposals on the rest', () => { + const scope = [{ __index: 1 }, { __index: 2 }, { __index: 3 }] + const statuses = { unmapped: [], readyForReview: [1, 2, 3], reviewed: [] } + assert.deepEqual(clearRunRowStatuses(statuses, takeRunRows(scope, 1).map(row => row.__index)).readyForReview, [2, 3]) }) test('shouldStopAIStep: keeps going with no failures', () => { @@ -400,3 +413,24 @@ test('canRunAlgorithm: an unknown or missing type doesn\'t run', () => { assert.equal(canRunAlgorithm({ type: 'something-else' }), false) assert.equal(canRunAlgorithm(null), false) }) + +test('getAutoMatchAIOnOpen: the first open after the logs load takes the project\'s default', () => { + const lastRunWithoutAI = [{ action: 'auto_match_started', extras: { sub_actions: ['reranker'] } }] + assert.deepEqual(getAutoMatchAIOnOpen({ current: false, logsLoaded: true, projectLogs: [] }), { runAI: true, applied: true }) + assert.deepEqual(getAutoMatchAIOnOpen({ current: true, logsLoaded: true, projectLogs: lastRunWithoutAI }), { runAI: false, applied: true }) +}) + +test('getAutoMatchAIOnOpen: before the logs load, the box is left as it is', () => { + assert.deepEqual(getAutoMatchAIOnOpen({ current: false, logsLoaded: false, projectLogs: [] }), { runAI: false, applied: false }) + assert.deepEqual(getAutoMatchAIOnOpen({ current: true, logsLoaded: false }), { runAI: true, applied: false }) +}) + +test('getAutoMatchAIOnOpen: later opens keep what the user left', () => { + assert.deepEqual(getAutoMatchAIOnOpen({ current: false, applied: true, logsLoaded: true, projectLogs: [] }), { runAI: false, applied: true }) + assert.deepEqual(getAutoMatchAIOnOpen({ current: true, applied: true, logsLoaded: true, projectLogs: [] }), { runAI: true, applied: true }) +}) + +test('getAutoMatchAIOnOpen: aiOnByDefault false opens it unticked every time', () => { + assert.deepEqual(getAutoMatchAIOnOpen({ current: false, logsLoaded: true, projectLogs: [], aiOnByDefault: false }), { runAI: false, applied: true }) + assert.deepEqual(getAutoMatchAIOnOpen({ current: true, applied: true, logsLoaded: true, aiOnByDefault: false }), { runAI: false, applied: true }) +}) diff --git a/src/components/map-projects/autoMatchCheck.js b/src/components/map-projects/autoMatchCheck.js index 3cc86a09..2fbc0433 100644 --- a/src/components/map-projects/autoMatchCheck.js +++ b/src/components/map-projects/autoMatchCheck.js @@ -1,8 +1,8 @@ -// Checkpoint for the AutoMatch dialog (OpenConceptLab/ocl_online#465). The -// dialog imports it as 'ocl-online-mapper'; webpack resolves that name to an -// installed private package when there is one, and to this file otherwise. +// Checkpoints for AutoMatch (OpenConceptLab/ocl_online#465). The app imports +// them as '@openconceptlab/oclmap-extensions'; webpack resolves that name to an installed +// package when there is one, and to this file otherwise. // -// useAutoMatchCheck receives what the user has selected: +// useAutoMatchCheck receives what the user has selected in the dialog: // {open, autoMatchScope, rowsToMatchCount, totalRows, retrieveCandidates, // matchAlgorithmCount, runAI, isAIOnly, aiRowsToAnalyse, inAIAssistantGroup} // and returns {summary: ReactNode | null, notices: [{key, severity, text, @@ -12,7 +12,12 @@ // eslint-disable-next-line no-unused-vars const useAutoMatchCheck = selection => ({summary: null, notices: []}) -// Whether the OCL AI Assistant starts ticked on a project's first AutoMatch. +// Whether the OCL AI Assistant may start ticked when the dialog opens. export const aiOnByDefault = () => true +// The most rows a run may include, given {matchAlgorithmCount}, or null for +// all of the rows in its scope. A run takes its scope's first rows. +// eslint-disable-next-line no-unused-vars +export const getMaxRunRows = run => null + export default useAutoMatchCheck diff --git a/src/components/map-projects/autoMatchRows.js b/src/components/map-projects/autoMatchRows.js index 1fa867ba..f01e8139 100644 --- a/src/components/map-projects/autoMatchRows.js +++ b/src/components/map-projects/autoMatchRows.js @@ -55,12 +55,22 @@ export const canRunAlgorithm = (algo, { canBridge = false, canScispacy = false } 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) => { - if(!matchOperations || matchOperations.unlimited || !matchAlgorithmCount) - return null - return Math.floor(Math.max(matchOperations.remaining || 0, 0) / matchAlgorithmCount) +// The rows a run includes: the first maxRows of them, or all of them when +// maxRows is null. +export const takeRunRows = (rows, maxRows) => { + if(!Array.isArray(rows)) + return [] + if(!Number.isInteger(maxRows) || rows.length <= maxRows) + return rows + return rows.slice(0, Math.max(maxRows, 0)) +} + +// Row statuses once a run starts matching runRowIndexes: those rows lose their +// proposed and approved statuses, and every other row keeps its own. +export const clearRunRowStatuses = (rowStatuses, runRowIndexes) => { + const runRows = new Set((runRowIndexes || []).map(index => index?.toString())) + const keep = indexes => (indexes || []).filter(index => !runRows.has(index?.toString())) + return {...rowStatuses, readyForReview: keep(rowStatuses?.readyForReview), reviewed: keep(rowStatuses?.reviewed)} } export const countAIOnlyRows = (rowIndexes, analysableRowIndexes) => { @@ -87,7 +97,7 @@ export const getAutoMatchBlocker = ({ return null } -// Whether the OCL AI Assistant starts ticked when the AutoMatch dialog opens +// Whether the OCL AI Assistant starts ticked on the project's AutoMatch // (OpenConceptLab/ocl_online#465): as on the project's last run, or // firstRunDefault when it has never been auto-matched. projectLogs is newest // first. @@ -98,6 +108,16 @@ export const getAutoMatchAIDefault = (projectLogs, firstRunDefault) => { return (lastRun.extras?.sub_actions || []).includes('with_ai_analysis') } +// The OCL AI Assistant box when the AutoMatch dialog opens. Once the project's +// logs have loaded, the first open takes the project's default; later opens +// keep what the user left. aiOnByDefault false always opens it unticked. +// Returns {runAI, applied}; applied says the project's default has been used. +export const getAutoMatchAIOnOpen = ({ current = false, applied = false, logsLoaded = false, projectLogs = [], aiOnByDefault = true } = {}) => { + const useDefault = logsLoaded && !applied + const runAI = useDefault ? getAutoMatchAIDefault(projectLogs, true) : Boolean(current) + return {runAI: Boolean(aiOnByDefault) && runAI, applied: applied || useDefault} +} + // 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 eafc8ee8..9c6979d7 100644 --- a/src/i18n/locales/en/translations.json +++ b/src/i18n/locales/en/translations.json @@ -373,7 +373,7 @@ "stopping_gracefully": "Stopping gracefully...", "stop_processing": "Stop Processing", "auto_match_all_including_approved_confirm": "I understand that {{approvedCount}} approved decisions will be overwritten", - "auto_match_selected_rows_note_no_count": "Note: Select rows in the left panel to enable this option", + "auto_match_selected_rows_note_no_count": "Select rows in the left panel to enable this option", "auto_match_rows_failed": "Matching failed for {{count}} input row(s), so they're missing candidates: {{details}}. Rows count from the first data row of your file. Run AutoMatch on those rows again to retry them.", "auto_match_rows_failed_algorithm": "{{algorithm}} on row(s) {{rows}}", "match_request_failed": "Couldn't get candidates ({{error}}). Try again.", diff --git a/src/i18n/locales/es/translations.json b/src/i18n/locales/es/translations.json index 78fd023d..9522aa38 100644 --- a/src/i18n/locales/es/translations.json +++ b/src/i18n/locales/es/translations.json @@ -359,7 +359,7 @@ "stopping_gracefully": "Deteniendo suavemente...", "stop_processing": "Detener Procesamiento", "auto_match_all_including_approved_confirm": "Entiendo que se sobrescribirán {{approvedCount}} decisiones aprobadas", - "auto_match_selected_rows_note_no_count": "Nota: Seleccione filas en el panel izquierdo para habilitar esta opción", + "auto_match_selected_rows_note_no_count": "Seleccione filas en el panel izquierdo para habilitar esta opción", "auto_match_rows_failed": "La coincidencia falló en {{count}} fila(s) de entrada, así que les faltan candidatos: {{details}}. Las filas se cuentan desde la primera fila de datos de tu archivo. Vuelve a ejecutar AutoMatch en esas filas para reintentarlo.", "auto_match_rows_failed_algorithm": "{{algorithm}} en la(s) fila(s) {{rows}}", "match_request_failed": "No se pudieron obtener candidatos ({{error}}). Inténtalo de nuevo.", diff --git a/src/i18n/locales/pt-BR/translations.json b/src/i18n/locales/pt-BR/translations.json index cd0fb85c..b934c26d 100644 --- a/src/i18n/locales/pt-BR/translations.json +++ b/src/i18n/locales/pt-BR/translations.json @@ -373,7 +373,7 @@ "stopping_gracefully": "Encerrando com segurança...", "stop_processing": "Parar Processamento", "auto_match_all_including_approved_confirm": "Entendo que {{approvedCount}} decisões aprovadas serão sobrescritas", - "auto_match_selected_rows_note_no_count": "Nota: Selecione linhas no painel esquerdo para habilitar esta opção", + "auto_match_selected_rows_note_no_count": "Selecione linhas no painel esquerdo para habilitar esta opção", "auto_match_rows_failed": "O AutoMatch falhou para {{count}} linha(s) de entrada, então elas estão sem candidatos: {{details}}. As linhas contam a partir da primeira linha de dados do seu arquivo. Execute o AutoMatch nessas linhas novamente para tentar de novo.", "auto_match_rows_failed_algorithm": "{{algorithm}} na(s) linha(s) {{rows}}", "match_request_failed": "Não foi possível obter candidatos ({{error}}). Tente novamente.", diff --git a/src/i18n/locales/zh/translations.json b/src/i18n/locales/zh/translations.json index 1b8ec4b6..ba6be655 100644 --- a/src/i18n/locales/zh/translations.json +++ b/src/i18n/locales/zh/translations.json @@ -371,7 +371,7 @@ "stopping_gracefully": "正在优雅地停止...", "stop_processing": "停止处理", "auto_match_all_including_approved_confirm": "我明白将覆盖 {{approvedCount}} 个已批准的决策", - "auto_match_selected_rows_note_no_count": "注意:请在左侧面板选择行以启用此选项", + "auto_match_selected_rows_note_no_count": "请在左侧面板选择行以启用此选项", "auto_match_rows_failed": "{{count}} 个输入行匹配失败,缺少候选项:{{details}}。行号从文件的第一个数据行开始计数。请对这些行重新运行 AutoMatch 以重试。", "auto_match_rows_failed_algorithm": "{{algorithm}}:第 {{rows}} 行", "match_request_failed": "无法获取候选项({{error}})。请重试。", diff --git a/webpack.config.js b/webpack.config.js index 2b8e82c2..3d6f11b1 100644 --- a/webpack.config.js +++ b/webpack.config.js @@ -8,8 +8,8 @@ const { ProvidePlugin, DefinePlugin, IgnorePlugin } = require('webpack'); // Checkpoints a private package can fill in at build time // (OpenConceptLab/ocl_online#465). The Dockerfile installs the package when // PRIVATE_PACKAGES_GIT is set; without it, the open stub is used. -const PRIVATE_PACKAGE = 'ocl-online-mapper'; -const privatePackagePath = path.resolve(__dirname, 'node_modules', PRIVATE_PACKAGE); +const PRIVATE_PACKAGE = '@openconceptlab/oclmap-extensions'; +const privatePackagePath = path.resolve(__dirname, 'node_modules', ...PRIVATE_PACKAGE.split('/')); const privatePackageAlias = fs.existsSync(privatePackagePath) ? privatePackagePath : path.resolve(__dirname, 'src/components/map-projects/autoMatchCheck.js'); @@ -40,7 +40,7 @@ module.exports = (env) => { // The private package ships JSX source. A .babelrc doesn't reach // files in another package, so this rule extends ours. test: /\.(js|jsx)$/, - include: new RegExp(`node_modules[\\\\/]${PRIVATE_PACKAGE}[\\\\/]`), + include: new RegExp(`node_modules[\\\\/]${PRIVATE_PACKAGE.replace('/', '[\\\\/]')}[\\\\/]`), use: [{loader: 'babel-loader', options: {extends: path.resolve(__dirname, '.babelrc')}}], }, { @@ -123,7 +123,7 @@ module.exports = (env) => { disableDotRule: true, } }, - devtool: env.NODE_ENV == 'production' ? "source-map" : undefined, + devtool: env.NODE_ENV == 'production' ? "nosources-source-map" : undefined, plugins: [ new HtmlWebpackPlugin({ template: './public/index.html',