diff --git a/src/components/concepts/ConceptHeader.jsx b/src/components/concepts/ConceptHeader.jsx
index 6e9e6b3..d8222c7 100644
--- a/src/components/concepts/ConceptHeader.jsx
+++ b/src/components/concepts/ConceptHeader.jsx
@@ -141,7 +141,7 @@ const ConceptHeader = ({concept, repo, onClose, repoURL, onEdit, nested, loading
color='primary'
variant='outlined'
icon={ }
- label={t('map_project.ai_recommended')}
+ label={t('map_project.ai_pick')}
sx={{padding: '4px'}}
/>
}
diff --git a/src/components/map-projects/AIAssistantVerdict.jsx b/src/components/map-projects/AIAssistantVerdict.jsx
new file mode 100644
index 0000000..6cf0408
--- /dev/null
+++ b/src/components/map-projects/AIAssistantVerdict.jsx
@@ -0,0 +1,155 @@
+import React from 'react'
+import { useTranslation } from 'react-i18next';
+import Chip from '@mui/material/Chip'
+import CircularProgress from '@mui/material/CircularProgress'
+import IconButton from '@mui/material/IconButton'
+import Link from '@mui/material/Link'
+import Tooltip from '@mui/material/Tooltip'
+import Typography from '@mui/material/Typography'
+import AssistantIcon from '@mui/icons-material/Assistant';
+
+import { getScoreDetails } from './viewBuilders.js'
+
+// How the grid and the candidates panel put a row's recommendation
+// (getRowRecommendation, OpenConceptLab/ocl_issues#2908) into words.
+
+const codeOf = view => view?.conceptDefinition?.id || view?.conceptDefinition?.reference?.code
+const outputOf = recommendation => recommendation?.analysis?.output || recommendation?.analysis
+// The code the OCL AI Assistant named, also when it isn't one of the candidates.
+const pickCodeOf = recommendation => codeOf(recommendation?.pick) || outputOf(recommendation)?.primary_candidate?.canonical_reference?.code
+
+const CONFIDENCES = ['high', 'medium', 'low']
+const describeConfidence = (t, recommendation) => {
+ const confidence = recommendation?.confidence?.toLowerCase()
+ return CONFIDENCES.includes(confidence) ? t(`map_project.ai_confidence_${confidence}`) : null
+}
+
+const describeTopScore = (t, top, candidatesScore) => {
+ if(!top) return t('map_project.ai_no_top_score')
+ const { qualityBucket, rerankScore } = getScoreDetails({candidate: top.candidate, conceptRow: top.conceptRow}, candidatesScore)
+ return t('map_project.ai_top_score', {code: codeOf(top), bucket: t(`map_project.${qualityBucket}`), score: rerankScore})
+}
+
+const describePickRank = (t, recommendation) => recommendation.pickRank ?
+ t('map_project.ai_pick_rank', {code: pickCodeOf(recommendation), rank: recommendation.pickRank}) :
+ t('map_project.ai_pick_unscored', {code: pickCodeOf(recommendation)})
+
+const describeVerdict = (t, recommendation) => {
+ const code = pickCodeOf(recommendation)
+ const confidence = describeConfidence(t, recommendation)
+ const withConfidence = text => confidence ? `${text} (${confidence})` : text
+ switch(recommendation?.verdict) {
+ case 'RECOMMEND': return code ? withConfidence(t('map_project.ai_summary_recommend', {code})) : t('map_project.ai_summary_no_pick')
+ case 'CONDITIONAL': return code ? withConfidence(t('map_project.ai_summary_conditional', {code})) : t('map_project.ai_summary_no_pick')
+ case 'REJECT': return t('map_project.ai_summary_reject')
+ case 'INSUFFICIENT': return t('map_project.ai_summary_insufficient')
+ default: return ''
+ }
+}
+
+const describeRelation = (t, recommendation) => {
+ switch(recommendation?.agreement) {
+ case 'agrees': return t('map_project.ai_summary_agree')
+ case 'different_pick': return recommendation.pickRank ?
+ t('map_project.ai_summary_differ_rank', {rank: recommendation.pickRank}) :
+ t('map_project.ai_summary_differ_unscored')
+ case 'no_match':
+ case 'not_enough': return t('map_project.ai_summary_no_proposal')
+ case 'out_of_date': return t('map_project.ai_out_of_date_help')
+ case 'pick_not_candidate': return t('map_project.ai_pick_not_candidate_help')
+ default: return ''
+ }
+}
+
+// The status label, also the column's value for sorting and filtering.
+export const getAIStatusLabel = (t, status, recommendation) => {
+ if(!status) return ''
+ const label = t(`map_project.ai_status_${status}`)
+ return recommendation?.conditional && ['agrees', 'different_pick'].includes(status) ?
+ `${label} (${t('map_project.ai_conditional')})` : label
+}
+
+const STATUS_COLORS = {
+ agrees: 'primary',
+ different_pick: 'warning',
+ pick_not_candidate: 'warning',
+ no_match: 'error',
+ not_enough: 'error',
+}
+
+// The tooltip names the other side: the top score, and where the pick ranks.
+const getStatusTooltip = (t, status, recommendation, candidatesScore) => {
+ if(status === 'out_of_date') return t('map_project.ai_out_of_date_help')
+ if(status === 'pick_not_candidate') return t('map_project.ai_pick_not_candidate_help')
+ const lines = [describeTopScore(t, recommendation?.top, candidatesScore)]
+ if(status === 'different_pick') lines.push(describePickRank(t, recommendation))
+ const confidence = describeConfidence(t, recommendation)
+ if(confidence) lines.push(confidence)
+ return lines.map(line =>
-
+ {summary}
+ {
+ openAnalysis &&
+
+ }
{
showHeader &&
@@ -379,7 +378,7 @@ const CandidateList = ({rowViews, header, rowIndex, sortBy, order, openConceptPa
// conceptCache — project-wide ConceptDefinition store, keyed by concept_key.
// algosSelected — algorithm definitions (for headers/grouping).
// (plans/unified-mapper-model.md "How the views map onto this model".)
-const Candidates = ({rowIndex, rowState, conceptCache, targetCanonical, targetRelativeUrl, openConceptPanel, showItem, isSelectedForMap, onMap, onFetchMore, isLoading, candidatesScore, repoVersion, analysis, onFetchRecommendation, appliedFacets, setAppliedFacets, filters, facets, columns, defaultFilters, inputLocale, locales, models, selectedModel, onModelChange, promptTemplates, promptTemplate, onPromptTemplateChange, onRefreshClick, rowStage, inAIAssistantGroup, algosSelected, canSelectAIModel, capacityWait=false, throttle}) => {
+const Candidates = ({rowIndex, rowState, conceptCache, targetCanonical, targetRelativeUrl, openConceptPanel, showItem, isSelectedForMap, onMap, onFetchMore, isLoading, candidatesScore, repoVersion, analysis, recommendation, onFetchRecommendation, appliedFacets, setAppliedFacets, filters, facets, columns, defaultFilters, inputLocale, locales, models, selectedModel, onModelChange, promptTemplates, promptTemplate, onPromptTemplateChange, onRefreshClick, rowStage, inAIAssistantGroup, algosSelected, canSelectAIModel, capacityWait=false, throttle}) => {
const { t } = useTranslation();
const [sortBy, setSortBy] = React.useState('rerank_score')
const [groupBy, setGroupBy] = React.useState('quality')
@@ -388,8 +387,6 @@ const Candidates = ({rowIndex, rowState, conceptCache, targetCanonical, targetRe
const [display, setDisplay] = React.useState('card')
const [openAIAnalysis, setOpenAIAnalysis] = React.useState(undefined)
const [analysisPage, setAnalysisPage] = React.useState(0)
- const recommendedScore = candidatesScore?.recommended
- const availableScore = candidatesScore?.available
const analysisArray = Array.isArray(analysis) ? analysis : (analysis ? [analysis] : [])
const analysisCount = analysisArray.length
// Resolve the AI-recommended candidate against conceptCache: prefer the
@@ -412,16 +409,10 @@ const Candidates = ({rowIndex, rowState, conceptCache, targetCanonical, targetRe
React.useEffect(() => {
setAnalysisPage(analysisCount > 0 ? analysisCount - 1 : 0)
}, [rowIndex, analysisCount])
- React.useEffect(() => {
- if(groupBy === 'ai_analysis' && analysisCount === 0)
- setGroupBy('quality')
- }, [analysisCount, groupBy])
const selectedAnalysis = isRecommendInProgress && analysisPage >= analysisCount
? undefined
: analysisArray[analysisPage]
- const selectedAnalysisForGrouping = selectedAnalysis || analysisArray[analysisCount - 1]
const { primaryCandidateId: AIRecommendedCandidateId, alternateCandidateIds: AIAlternateCandidateIds } = getAIAnalysisCandidateIDs(selectedAnalysis, conceptCache)
- const { primaryCandidateId: groupedAnalysisPrimaryId, alternateCandidateIds: groupedAnalysisAlternateIds } = getAIAnalysisCandidateIDs(selectedAnalysisForGrouping, conceptCache)
// Quality (score-grouped) view shows ONLY target-repo concepts. Bridge
// intermediaries live in algorithm view as metadata-about-the-target;
@@ -490,11 +481,16 @@ const Candidates = ({rowIndex, rowState, conceptCache, targetCanonical, targetRe
onFetchRecommendation()
}
+ const onReadAnalysis = () => {
+ setAnalysisPage(Math.max(0, analysisCount - 1))
+ setOpenAIAnalysis(true)
+ }
+
const onGroup = option => {
setGroupBy(option)
if(option === 'algorithm')
setSortBy('algo_score')
- if(!option || option === 'quality' || option === 'ai_analysis')
+ if(!option || option === 'quality')
setSortBy('rerank_score')
}
@@ -538,66 +534,30 @@ const Candidates = ({rowIndex, rowState, conceptCache, targetCanonical, targetRe
}))
}
}
- if(groupBy === 'ai_analysis') {
- const sorted = sortRowViews(qualityRowViews, 'rerank_score', 'desc')
- const primaryCandidate = []
- const alternateCandidates = []
- const rest = []
- sorted.forEach(view => {
- const code = view?.conceptDefinition?.reference?.code
- if(groupedAnalysisPrimaryId && code === groupedAnalysisPrimaryId) {
- primaryCandidate.push(view)
- return
- }
- if(groupedAnalysisAlternateIds.includes(code)) {
- alternateCandidates.push(view)
- return
- }
- rest.push(view)
- })
- return { primaryCandidate, alternateCandidates, rest }
+ // Match Quality by score: the OCL AI Assistant's pick and its alternates
+ // when it decides, then today's buckets, as getRowRecommendation orders
+ // them (OpenConceptLab/ocl_issues#2908). By ID or name: one list, with
+ // badges only.
+ if(!byScore)
+ return { qualitySections: [{id: 'available', rowViews: sortRowViews(qualityRowViews, sortBy, order, inputLocale), header: t('map_project.all_candidates')}] }
+ const sections = recommendation?.sections || {}
+ const aiIcon = color =>
+ return {
+ qualitySections: [
+ {id: 'ai-pick', rowViews: sections.pick, header: t('map_project.ai_pick'), headerIndicatorIcon: aiIcon('primary'), onlyIfAny: true},
+ {id: 'ai-alternates', rowViews: sections.alternates, header: t('map_project.alternates'), headerIndicatorIcon: aiIcon('warning'), onlyIfAny: true},
+ {id: 'recommended', rowViews: sections.recommended, header: t('map_project.recommended_candidates'), bgColor: SCORES_COLOR.recommended},
+ {id: 'available', rowViews: sections.available, header: t('map_project.available_candidates'), bgColor: SCORES_COLOR.available},
+ {id: 'low-ranked', rowViews: sections.lowRanked, header: t('map_project.low_ranked_candidates'), bgColor: SCORES_COLOR.low_ranked},
+ // Candidates whose rerank hasn't landed yet. They move into the score
+ // buckets once a numeric rerank_score arrives on their ConceptRow.
+ // Neutral gray, so it reads as "in progress" rather than "poor match".
+ {id: 'pending-rerank', rowViews: sections.unranked, header: t('map_project.unranked_candidates', 'Unranked Candidates'), bgColor: SCORES_COLOR.pending_rerank, onlyIfAny: true},
+ ].map(section => ({...section, rowViews: section.rowViews || []})).filter(section => !section.onlyIfAny || section.rowViews.length)
}
- let recommended = []
- let available = []
- let lowRanked = []
- let pendingRerank = []
- sortRowViews(qualityRowViews, sortBy, order, inputLocale).forEach(view => {
- const score = view.conceptRow?.rerank_score
- if(byScore) {
- // Bucket on score presence first: unscored rows belong in their
- // own "pending rerank" group — not in low_ranked. Falling them
- // into low_ranked (the prior `score || 0` behavior) mis-implied
- // they were poor matches; they're simply not ranked yet. Rerank
- // landing will re-bucket them naturally on the next render.
- if(typeof score !== 'number') pendingRerank.push(view)
- else if(score >= recommendedScore) recommended.push(view)
- else if(score >= availableScore) available.push(view)
- else lowRanked.push(view)
- } else {
- available.push(view)
- }
- })
- return { recommended, available, lowRanked, pendingRerank }
}
- const { byAlgoCandidates, recommended, available, lowRanked, pendingRerank, primaryCandidate, alternateCandidates: aiAlternateCandidates, rest } = getCandidates()
- const aiPrimaryBucketId = `${rowIndex}-ai-primary`
- const aiAlternatesBucketId = `${rowIndex}-ai-alternates`
- const aiRestBucketId = `${rowIndex}-ai-rest`
- const hasAIPrimaryCandidates = (primaryCandidate || []).length > 0
- const hasAIAlternateCandidates = (aiAlternateCandidates || []).length > 0
-
- React.useEffect(() => {
- if(groupBy !== 'ai_analysis') return
- setCollapsed(prev => {
- const withoutAIBuckets = prev.filter(id => ![aiPrimaryBucketId, aiAlternatesBucketId, aiRestBucketId].includes(id))
- const nextCollapsed = [...withoutAIBuckets]
- if(hasAIPrimaryCandidates || hasAIAlternateCandidates)
- nextCollapsed.push(aiRestBucketId)
- return nextCollapsed
- })
- }, [groupBy, rowIndex, hasAIPrimaryCandidates, hasAIAlternateCandidates])
-
+ const { byAlgoCandidates, qualitySections } = getCandidates()
const getRightControls = () => {
return (
@@ -622,11 +582,8 @@ const Candidates = ({rowIndex, rowState, conceptCache, targetCanonical, targetRe
{
!noCandidatesFound &&
<>
- 0} />
- {
- groupBy !== 'ai_analysis' &&
-
- }
+
+
>
}
{
@@ -652,8 +609,11 @@ const Candidates = ({rowIndex, rowState, conceptCache, targetCanonical, targetRe
)
}
+ // The summary line stands for the analysis; its full explanation opens on
+ // "Read analysis" or a new run, and stays open across rows once opened. An
+ // analysis with no verdict has no summary line, so it opens.
React.useEffect(() => {
- setOpenAIAnalysis(isEmpty(analysis) ? false : (openAIAnalysis !== false))
+ setOpenAIAnalysis(isEmpty(analysis) ? false : (openAIAnalysis === true || !recommendation?.verdict))
}, [rowIndex])
React.useEffect(() => {
@@ -662,6 +622,32 @@ const Candidates = ({rowIndex, rowState, conceptCache, targetCanonical, targetRe
}, [isRecommendInProgress])
+ const filtersButton = (
+ setOpenFilters(!openFilters)} disabled={isEmpty(facets)}>
+ values(v))).length} color='primary'>
+
+
+
+ )
+ const toolbarProps = {
+ onDisplayChange: setDisplay,
+ toolbarControl: filtersButton,
+ alignToolbarLeft: true,
+ rightControl: getRightControls(),
+ analysis: analysis,
+ showAnalysis: true,
+ openAnalysis: Boolean(openAIAnalysis),
+ // Closing an older analysis returns the badges to the latest one, which
+ // the order and the summary line follow.
+ onCloseAnalysis: () => {
+ setOpenAIAnalysis(false)
+ setAnalysisPage(Math.max(0, analysisCount - 1))
+ },
+ isInProgress: isRecommendInProgress,
+ summary:
+ }
+ const firstQualitySectionWithViews = (qualitySections || []).findIndex(section => section.rowViews.length > 0)
+
return (
@@ -702,172 +688,30 @@ const Candidates = ({rowIndex, rowState, conceptCache, targetCanonical, targetRe
>
{
groupBy === 'quality' &&
- <>
-
- setOpenFilters(!openFilters)} disabled={isEmpty(facets)}>
- values(v))).length} color='primary'>
-
-
-
- }
- alignToolbarLeft
- rightControl={getRightControls()}
- analysis={analysis}
- showAnalysis
- openAnalysis={Boolean(openAIAnalysis)}
- onCloseAnalysis={() => setOpenAIAnalysis(false)}
- isInProgress={isRecommendInProgress}
- showAlgo
- collapsed={collapsed}
- onCollapse={setCollapsed}
- candidatesScore={candidatesScore}
- isFirst={recommended.length > 0}
- />
-
-
- {
- (isLoading && isNoneLoaded) ?
- :
- 0}
- />
- }
-
-
- {
- (isLoading && isNoneLoaded) ?
- :
- 0}
- />
- }
-
-
- {
- // Transient bucket for candidates whose rerank hasn't landed
- // yet. They reorganize into recommended/available/low_ranked
- // automatically once a numeric rerank_score arrives on their
- // ConceptRow. Neutral gray indicator so it visually reads as
- // "in progress" rather than "poor match".
- pendingRerank.length > 0 &&
-
- }
-
- >
- }
- {
- groupBy === 'ai_analysis' &&
- <>
-
- }
- onFetchMore={onFetchMore}
- bgColor={SCORES_COLOR.recommended}
- bucketId={aiPrimaryBucketId}
- noToolbar={false}
- onDisplayChange={setDisplay}
- toolbarControl={
- setOpenFilters(!openFilters)} disabled={isEmpty(facets)}>
- values(v))).length} color='primary'>
-
-
-
- }
- alignToolbarLeft
- rightControl={getRightControls()}
- analysis={analysis}
- showAnalysis
- openAnalysis={Boolean(openAIAnalysis)}
- onCloseAnalysis={() => setOpenAIAnalysis(false)}
- isInProgress={isRecommendInProgress}
- showAlgo
- showEmptyHeader
- collapsed={collapsed}
- onCollapse={setCollapsed}
- candidatesScore={candidatesScore}
- isFirst
- />
-
-
- }
- onFetchMore={onFetchMore}
- bgColor={SCORES_COLOR.available}
- bucketId={aiAlternatesBucketId}
- noToolbar
- showAlgo
- showEmptyHeader
- collapsed={collapsed}
- onCollapse={setCollapsed}
- candidatesScore={candidatesScore}
- />
-
-
-
+ qualitySections.map((section, i) => (
+
+ {
+ (i > 0 && isLoading && isNoneLoaded) ?
+ :
+
+ }
- >
+ ))
}
{
groupBy === 'algorithm' &&
@@ -900,29 +744,7 @@ const Candidates = ({rowIndex, rowState, conceptCache, targetCanonical, targetRe
collapsed={collapsed}
onCollapse={setCollapsed}
candidatesScore={candidatesScore}
- {
- ...(
- i === 0 ?
- {
- onDisplayChange: setDisplay,
- toolbarControl: (
-
setOpenFilters(!openFilters)} disabled={isEmpty(facets)}>
- values(v))).length} color='primary'>
-
-
-
- ),
- alignToolbarLeft: true,
- rightControl: getRightControls(),
- analysis: analysis,
- showAnalysis: true,
- openAnalysis: Boolean(openAIAnalysis),
- onCloseAnalysis: () => setOpenAIAnalysis(false),
- isInProgress: isRecommendInProgress
- } :
- {}
- )
- }
+ {...(i === 0 ? toolbarProps : {})}
/>
)
diff --git a/src/components/map-projects/DataGridControls.jsx b/src/components/map-projects/DataGridControls.jsx
index dbf9ce2..a00bcde 100644
--- a/src/components/map-projects/DataGridControls.jsx
+++ b/src/components/map-projects/DataGridControls.jsx
@@ -6,11 +6,13 @@ import ListItemIcon from '@mui/material/ListItemIcon';
import ListItemText from '@mui/material/ListItemText';
import MenuItem from '@mui/material/MenuItem';
import Select from '@mui/material/Select';
+import Tooltip from '@mui/material/Tooltip';
import Typography from '@mui/material/Typography';
import DoneIcon from '@mui/icons-material/Done';
import CloseIcon from '@mui/icons-material/Close';
import ClearIcon from '@mui/icons-material/Clear';
+import AssistantIcon from '@mui/icons-material/Assistant';
import debounce from 'lodash/debounce'
import filter from 'lodash/filter'
@@ -44,6 +46,9 @@ const DataGridControls = ({
recommendedCount,
availableCount,
lowRankedCount,
+ aiDisagreesCount,
+ aiDisagreesSelected,
+ onAIDisagreesClick,
decisions,
decisionFilters,
setDecisionFilters,
@@ -111,6 +116,24 @@ const DataGridControls = ({
available={availableCount}
low_ranked={lowRankedCount}
/>
+ {
+ // OpenConceptLab/ocl_issues#2908: rows marked Different pick, No
+ // match or Not enough to decide in the OCL AI Assistant column. Like
+ // the Match Quality buckets, it filters only while some row disagrees.
+ aiDisagreesCount > 0 &&
+
+ }
+ color='warning'
+ size='small'
+ variant={aiDisagreesSelected ? 'filled' : 'outlined'}
+ onClick={onAIDisagreesClick}
+ sx={{marginRight: '8px'}}
+ />
+
+ }
{
selectedRowStatus === 'unmapped' &&
diff --git a/src/components/map-projects/Discuss.jsx b/src/components/map-projects/Discuss.jsx
index 9716b40..870ed05 100644
--- a/src/components/map-projects/Discuss.jsx
+++ b/src/components/map-projects/Discuss.jsx
@@ -51,6 +51,9 @@ const Discuss = ({ logs, onAdd }) => {
description += ':'
description += ` (${log.extras.map_type || log.extras.mapType})`
}
+ // OpenConceptLab/ocl_issues#2908: AutoMatch proposed the OCL AI Assistant's pick
+ if(log.action === 'auto-matched' && log.extras?.chosen_by === 'ai_assistant')
+ description += ` · ${t('map_project.ai_pick')}`
return description
}
if (log.action === 'exclude')
@@ -84,7 +87,7 @@ const Discuss = ({ logs, onAdd }) => {
return
if(['mapped', 'map_type_changed'].includes(log.action))
return
- if(['auto-matched'].includes(log.action))
+ if(['auto-matched', 'auto_match_no_proposal'].includes(log.action))
return
if(log.action === 'unmapped')
return
diff --git a/src/components/map-projects/MapProject.jsx b/src/components/map-projects/MapProject.jsx
index a7761f9..b78ae48 100644
--- a/src/components/map-projects/MapProject.jsx
+++ b/src/components/map-projects/MapProject.jsx
@@ -13,7 +13,6 @@ import { getAppTitle } from '../app/DocumentTitle'
import Paper from '@mui/material/Paper'
import Button from '@mui/material/Button';
-import CircularProgress from '@mui/material/CircularProgress';
import Typography from '@mui/material/Typography';
import IconButton from '@mui/material/IconButton'
import Tabs from '@mui/material/Tabs';
@@ -91,7 +90,7 @@ import MapProjectDeleteConfirmDialog from './MapProjectDeleteConfirmDialog';
import ConfigurationForm from './ConfigurationForm'
import Controls from './Controls'
import DataGridControls from './DataGridControls'
-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 { 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, getAutoMatchLogExtras, getAutoMatchNoProposalReason, getMappingTarget, getRowMapping, isSameRowMapping } from './autoMatchRows'
import { aiOnByDefault, getMaxRunRows, getMapperSettings } from '@openconceptlab/oclmap-extensions'
import { createAutosaveScheduler, createLatestSender, saveOnLeave, trackSave, whenSaved, installUnloadGuard } from './autosave'
import MatchSummaryCard from './MatchSummaryCard'
@@ -107,7 +106,7 @@ import ImportToCollection from './ImportToCollection'
import ProjectLogs from './ProjectLogs';
import { useAlgos, ensureConceptIdentity } from './algorithms'
import { applyRequestSettings, getRequestLimits, getRequestSettings } from './requestLimits'
-import { countQualityBuckets, createBestCandidateScoreCache, filterRowsByQualityBucket, getRowQualities } from './rowQuality'
+import { countQualityBuckets, createRowRecommendationCache, filterRowsByQualityBucket, getRowQualities } from './rowQuality'
import AutoMatchDialog from './AutoMatchDialog'
import PreviewLimitDialog from './PreviewLimitDialog'
import QuotaDialog from '../common/QuotaDialog'
@@ -121,7 +120,8 @@ import { normalizeAlgorithmInvocation, hasSuccessfulAlgorithmResponse, getAlgori
import { parseConceptKey } from './conceptKey'
import { normalizeLookupConfig } from './lookupConfigFields'
import { getDeclaredLocales, getDefaultTargetRepoVersion, getProjectTargetRepoVersion, getSavedTargetRepoVersion, getTargetRepoVersionFromUrl, getTargetRepoVersionId } from './projectTargetRepo'
-import { buildBridgeTargetDownloadEntries, buildQualityRowViews, conceptBelongsToTargetRepo, conceptForMapping, findTargetRowView, formatBridgeTargetDownloadEntry, resolveAICandidateID, getScoreDetails, getAIAnalysisCandidateIDs, withCachedConcept, withCandidateNames, withOwnNames, withSavedMatch } from './viewBuilders.js'
+import { buildBridgeTargetDownloadEntries, buildQualityRowViews, conceptBelongsToTargetRepo, conceptForMapping, findTargetRowView, formatBridgeTargetDownloadEntry, resolveAICandidateID, getScoreDetails, getAIAnalysisCandidateIDs, getRowAIStatus, getRowRecommendation, getTargetRepoQualityViews, isAIDisagreement, isAIPickTarget, withCachedConcept, withCandidateNames, withOwnNames, withSavedMatch } from './viewBuilders.js'
+import { AIAssistantStatusCell, getAIStatusLabel } from './AIAssistantVerdict'
import { formatClockTime, getCapacityWaitLabel, mergeCapacityWaits } from './rowProgress.js'
import './MapProject.scss'
@@ -133,7 +133,7 @@ import '../common/ResizablePanel.scss'
* once: (a) the write-side parallel state was wired up in PR1, (b) PR2a
* routed all algorithm types (bridge, scispacy, AI payload v2) through the
* normalizer, (c) PR2b flipped reads (Candidates / Concept / Score /
- * setAutoMatched / setStateViews) to consume rowMatchState + conceptCache
+ * setAutoMatched) to consume rowMatchState + conceptCache
* via structured tuples, and (d) PR3-C dropped the legacy allCandidates
* state + v1 save/load format. The wire format is now v2 only
* (mapper_schema_version: 2); prod data was migrated under
@@ -290,6 +290,7 @@ const MapProject = () => {
const [decisionTab, setDecisionTab] = React.useState('candidates')
const [searchText, setSearchText] = React.useState('') // csv row search
const [selectedCandidatesScoreBucket, setSelectedCandidatesScoreBucket] = React.useState(false)
+ const [aiDisagreesFilter, setAIDisagreesFilter] = React.useState(false)
const [scoreBucketSortBy, setScoreBucketSortBy] = React.useState('desc')
const [matchDialog, setMatchDialog] = React.useState(false)
@@ -543,7 +544,7 @@ const MapProject = () => {
// Merge ConceptDefinitions into the project-wide conceptCache, keyed by
// the opaque concept_key. Prefer richer (lookup_status='full') over
// stubs ('pending'/'partial'). Update conceptCacheRef synchronously so
- // same-tick consumers (setStateViews / setAutoMatched) read the just-
+ // same-tick consumers (setAutoMatched) read the just-
// merged entries before React commits the setState.
const nextCache = { ...conceptCacheRef.current }
let cacheChanged = false
@@ -1041,18 +1042,6 @@ const MapProject = () => {
return qualityBucket ? t('map_project.' + qualityBucket) : t('map_project.unranked')
}
- const getRowAIIndicatorMeta = (index, targetConcept) => {
- const status = rowStageRef.current[index]?.recommend
- const { latestAnalysis, primaryCandidateId, alternateCandidateIds } = getAIAnalysisCandidateIDs(analysis[index], conceptCacheRef.current)
- const targetCode = targetConcept?.id || targetConcept?.reference?.code
- return {
- status,
- latestAnalysis,
- isPrimaryMatch: Boolean(targetCode && primaryCandidateId && targetCode === primaryCandidateId),
- isAlternateMatch: Boolean(targetCode && alternateCandidateIds.includes(targetCode)),
- }
- }
-
const getColumnsForTable = () => {
let cols = []
forEach(columns, (column, idx) => {
@@ -1133,30 +1122,16 @@ const MapProject = () => {
width: columnWidth['_targetCode_'] || 360,
renderCell: params => {
const targetConcept = getConcept(mapSelected[params.row.__index])
- const aiMeta = AI_ASSISTANT_API_URL ? getRowAIIndicatorMeta(params.row.__index, targetConcept) : null
if(targetConcept?.id) {
return (
{
- aiMeta?.status === 0 ?
-
+ showAIAssistantColumn && isAIPickTarget(rowRecommendation(params.row.__index), targetConcept) &&
+
-
+
- :
- aiMeta?.status === -2 ?
-
- { e.stopPropagation(); fetchRecommendation(params.row) }}>
-
-
- :
- aiMeta?.latestAnalysis ?
-
-
-
-
- :
- null
+
}
@@ -1224,6 +1199,32 @@ const MapProject = () => {
)
}
})
+ // The OCL AI Assistant's agreement with the top score, or its call running
+ // or failed (OpenConceptLab/ocl_issues#2908): for users who can run it, or
+ // on a project that has analyses.
+ if(showAIAssistantColumn)
+ cols.push({
+ field: '_aiAssistant_',
+ headerName: t('map_project.ocl_ai_assistant'),
+ width: columnWidth['_aiAssistant_'] || 210,
+ align: 'left',
+ headerAlign: 'left',
+ valueGetter: (_, row) => {
+ const recommendation = rowRecommendation(row.__index)
+ return getAIStatusLabel(t, getRowAIStatus(recommendation, rowStageRef.current[row.__index]?.recommend), recommendation)
+ },
+ renderCell: params => {
+ const recommendation = rowRecommendation(params.row.__index)
+ return (
+ fetchRecommendation(params.row)}
+ />
+ )
+ }
+ })
cols.push({
field: '_bestScore_',
headerName: t('search.search_score'),
@@ -1415,6 +1416,7 @@ const MapProject = () => {
setSelectedRowIds([])
setAlert(false)
setSelectedCandidatesScoreBucket(false)
+ setAIDisagreesFilter(false)
setScoreBucketSortBy('desc')
rowThrottlesRef.current = {}
setRowStage({})
@@ -2221,108 +2223,225 @@ const MapProject = () => {
return service
}
- // Pick the highest-scoring target-repo ConceptRow for a given rowIndex
- // from the unified-model state. Returns a RowView ({candidate,
- // conceptDefinition, conceptRow, bridgeConceptDefinition?}) or null.
- // The target canonical comes from buildProjectContext — the SAME source
- // the normalizer used to stamp ConceptDefinition.reference.url, so the
- // filter is guaranteed to agree with the data. Reading canonical from
- // the caller's `_repo` would risk the derived-vs-explicit mismatch the
- // unified-model spec exists to avoid.
- // (plans/unified-mapper-model.md — score-grouped view's bucketing rule.)
- const pickTopRowView = (rowIndex) => {
- const rowState = rowMatchStateRef.current[rowIndex]
- if(!rowState) return null
+ // Build the v2 AI Assistant payload sections (recommendable_concepts +
+ // bridge_context + target_repo) by reading directly from rowMatchState +
+ // conceptCache. See plans/unified-mapper-model.md "AI Assistant payload
+ // (match-recommend)".
+ const buildV2RecommendationPayload = (rowIndex) => {
+ const projectContext = buildProjectContext()
+ if(!projectContext?.target_repo?.canonical_url) return null
+
+ const rowState = rowMatchStateRef.current?.[rowIndex]
+ if(!rowState || !Object.keys(rowState.candidates || {}).length) return null
+
+ const allNormCandidates = Object.values(rowState.candidates)
+ const defsByKey = new Map()
+ allNormCandidates.forEach(cand => {
+ const def = conceptCacheRef.current[cand.concept_key]
+ if(!def) return
+ const existing = defsByKey.get(def.key)
+ if(!existing || lookupStatusRank(def.lookup_status) > lookupStatusRank(existing.lookup_status))
+ defsByKey.set(def.key, def)
+ if(cand.bridge_concept_key) {
+ const bridgeDef = conceptCacheRef.current[cand.bridge_concept_key]
+ if(bridgeDef && !defsByKey.has(bridgeDef.key)) defsByKey.set(bridgeDef.key, bridgeDef)
+ }
+ })
+
+ const targetCanonical = projectContext.target_repo.canonical_url
+ // Project-pinned identifying property codes. When absent (FHIR-passthrough
+ // sources or repos that haven't pinned a summary), filterPropertyBySummary
+ // passes def.property through whole.
+ const summaryPropertyCodes = repoVersion?.meta?.display?.concept_summary_properties
+ // PR3-H: union of (input_locale, filters.locale). Empty set = no locale
+ // filter applied (backwards-compatible for projects with neither set).
+ const filterLocaleString = (filters?.locale || '')
+ const filterLocales = filterLocaleString ? filterLocaleString.split(',').map(s => s.trim()).filter(Boolean) : []
+ const effectiveLocales = [
+ ...(inputLocale ? [inputLocale] : []),
+ ...filterLocales
+ ]
+ const recommendable_concepts = []
+ const bridge_context = []
+
+ defsByKey.forEach((def, key) => {
+ const isBridgeIntermediary = allNormCandidates.some(c => c.concept_key === key && c.type === 'bridge')
+
+ if(isBridgeIntermediary) {
+ // Bridges are CONTEXT only — never recommendable. Their target_concept_keys
+ // tell the AI which recommendable_concepts they justify.
+ const bridgeCandidate = allNormCandidates.find(c => c.concept_key === key && c.type === 'bridge')
+ const target_concept_keys = [...new Set(
+ allNormCandidates
+ .filter(c => c.type === 'bridge_child' && c.bridge_concept_key === key)
+ .map(c => c.concept_key)
+ )]
+ bridge_context.push({
+ concept_key: key,
+ canonical_reference: def.reference,
+ display_name: def.display_name,
+ score: bridgeCandidate?.score,
+ target_concept_keys
+ })
+ } else if(conceptBelongsToTargetRepo(def, targetCanonical, projectContext.target_repo.relative_url)) {
+ // Target-repo concepts only. Evidence shows which algorithms surfaced
+ // this concept (and via which bridge, if applicable).
+ const evidence = allNormCandidates
+ .filter(c => c.concept_key === key)
+ .map(c => {
+ const e = {
+ algorithm_id: c.algorithm_id,
+ candidate_type: c.type,
+ score: c.score,
+ highlights: c.highlights
+ }
+ if(c.type === 'bridge_child' && c.bridge_concept_key)
+ e.via = {bridge_concept_key: c.bridge_concept_key, bridge_map_type: c.map_type}
+ return e
+ })
+ recommendable_concepts.push(buildRecommendableConceptEntry({
+ def, key, evidence, rowState, summaryPropertyCodes, effectiveLocales
+ }))
+ }
+ // Else: concept from a non-target, non-bridge source — skip
+ })
+
+ // PR3-D3-lite (L-3): drop `concept_class` / `datatype` from every entry
+ // when constant across the surfaced set. LOINC always emits both as
+ // constants ("LOINC" / "Nom"); for mixed-class repos the fields stay.
+ stripConstantClassAndDatatype(recommendable_concepts)
+
+ // PR3-D3-lite (L-6): strip `canonical_url_source` from the target_repo
+ // projection. The field is admin metadata (tells UI whether the canonical
+ // was explicit on the repo vs. derived) — the LLM has no use for it.
+ // eslint-disable-next-line no-unused-vars
+ const { canonical_url_source: _src, ...aiTargetRepo } = projectContext.target_repo
+ return {
+ target_repo: aiTargetRepo,
+ recommendable_concepts,
+ bridge_context
+ }
+ }
+
+ // A row's recommendation (OpenConceptLab/ocl_issues#2908): the rule in
+ // getRowRecommendation, given the row's target-repo candidates with their
+ // scores and the row's OCL AI Assistant analyses. The candidate-pool
+ // fingerprint, which says whether the latest analysis saw these candidates,
+ // is only built for a row that has analyses. Until the target repo is known
+ // no candidate can be mapped, so there are none to recommend.
+ const getRowRecommendationFor = (index, analyses = analysis[index]) => {
const targetRepo = buildProjectContext()?.target_repo
- const targetCanonical = targetRepo?.canonical_url
- const targetRelativeUrl = targetRepo?.relative_url
- if(!targetCanonical && !targetRelativeUrl) return null
- const views = buildQualityRowViews(rowState, conceptCacheRef.current)
- // Auto-match must land on a target-repo concept. Bridge intermediaries
- // are excluded — even if their rerank_score is high, they're not
- // mappable. Score view already places bridge_child rows under the
- // target concept's ConceptRow, so filtering by canonical_url here is
- // the right invariant.
- const eligible = views.filter(v => conceptBelongsToTargetRepo(v.conceptDefinition, targetCanonical, targetRelativeUrl))
- if(!eligible.length) return null
- return orderBy(eligible, [v => v.conceptRow?.rerank_score ?? -1], ['desc'])[0]
+ const hasTargetRepo = Boolean(targetRepo?.canonical_url || targetRepo?.relative_url)
+ const history = Array.isArray(analyses) ? analyses : (analyses ? [analyses] : [])
+ return getRowRecommendation({
+ views: hasTargetRepo ? getTargetRepoQualityViews(rowMatchStateRef.current[index], conceptCacheRef.current, targetRepo.canonical_url, targetRepo.relative_url) : [],
+ analyses: history,
+ fingerprint: history.length ? getCandidatePoolFingerprint(buildV2RecommendationPayload(index)?.recommendable_concepts) : null,
+ candidatesScore,
+ conceptCache: conceptCacheRef.current,
+ })
}
- // Each row's best target-repo candidate score, for the grid's match quality
- // (ocl_issues#2837). A row is recomputed only when its candidates, a concept
- // they use, or the target repo changes.
- const bestCandidateScoreCacheRef = React.useRef(null)
- if(!bestCandidateScoreCacheRef.current)
- bestCandidateScoreCacheRef.current = createBestCandidateScoreCache()
- const getBestCandidateScoreGetter = () => {
+ // Each row's recommendation, for the grid: its match quality
+ // (ocl_issues#2837), its OCL AI Assistant column, Target Code icon and
+ // filter. A row is recomputed only when its candidates, a concept they use,
+ // its analyses or the project context change.
+ const rowRecommendationCacheRef = React.useRef(null)
+ if(!rowRecommendationCacheRef.current)
+ rowRecommendationCacheRef.current = createRowRecommendationCache()
+ const getRowRecommendationGetter = () => {
const targetRepo = buildProjectContext()?.target_repo
- return bestCandidateScoreCacheRef.current.getter({
+ return rowRecommendationCacheRef.current.getter({
rows: rowMatchStateRef.current,
concepts: conceptCacheRef.current,
- targetKey: `${targetRepo?.canonical_url || ''}|${targetRepo?.relative_url || ''}`,
- // this render's pickTopRowView, which sees this render's target repo
- computeScore: index => pickTopRowView(index)?.conceptRow?.rerank_score,
+ analyses: analysis,
+ // what the rule and the candidate-pool fingerprint read besides the row
+ contextKey: JSON.stringify([
+ targetRepo?.canonical_url, targetRepo?.relative_url, candidatesScore?.recommended, candidatesScore?.available,
+ repoVersion?.meta?.display?.concept_summary_properties, inputLocale, filters?.locale
+ ]),
+ // this render's rule, which sees this render's project context
+ compute: index => getRowRecommendationFor(index),
})
}
- const setStateViews = (data, _repo) => {
- setRowStatuses(prev => {
- forEach(data, concept => {
- const rowIdx = concept?.row?.__index
- if(!isNumber(rowIdx)) return
- const top = pickTopRowView(rowIdx)
- const topScore = top?.conceptRow?.rerank_score
- if(isNumber(topScore) && topScore >= candidatesScore.recommended) {
- const _concept = {...conceptForMapping(top), repo: {..._repo, version: repoVersion?.id || _repo.version, version_url: repoVersion?.version_url || _repo.version_url}}
- setMapSelected(_prev => {
- _prev[rowIdx] = _concept
- return _prev
- })
- const mapType = top.candidate?.map_type || 'SAME-AS'
- prev.readyForReview = uniq([...prev.readyForReview, rowIdx])
- setDecisions(p => ({...p, [rowIdx]: 'map'}))
- setMapTypes(p => ({...p, [rowIdx]: mapType}))
- log({action: 'auto-matched', extras: {repoVersion: repoVersion?.version_url || _repo.version_url, name: getConceptLabel(_concept), map_type: mapType}}, rowIdx)
- } else {
- setMapSelected(prev => omit(prev, rowIdx))
- setDecisions(prev => omit(prev, rowIdx))
- setMapTypes(prev => omit(prev, rowIdx))
- prev.unmapped = uniq([...prev.unmapped, rowIdx])
- }
+ // Each row's mapping as the user sees it, and what the current run left
+ // each of its rows with, so that the run's AI step deciding a row again
+ // leaves alone a row the user changed meanwhile (OpenConceptLab/ocl_issues#2908).
+ const rowMappingStateRef = React.useRef({})
+ rowMappingStateRef.current = {mapSelected, decisions, mapTypes, reviewed: rowStatuses.reviewed}
+ const runRowMappingsRef = React.useRef({})
+
+ // AutoMatch's proposal for a row of the run: the row's recommendation
+ // (getRowRecommendationFor) mapped, or the row left Unmapped, with who chose
+ // it in the row's log. A row the run decides is no longer Approved; a full
+ // run already clears approvals when it starts, an AI-only run doesn't.
+ const proposeRowMapping = (index, recommendation, _repo = repo) => {
+ const proposal = recommendation?.proposal
+ const logExtras = getAutoMatchLogExtras(recommendation)
+ const current = rowMappingStateRef.current
+ if(proposal) {
+ const _concept = {...conceptForMapping(proposal), repo: {..._repo, version: repoVersion?.id || _repo.version, version_url: repoVersion?.version_url || _repo.version_url}}
+ const mapType = proposal.candidate?.map_type || 'SAME-AS'
+ setMapSelected(_prev => {
+ _prev[index] = _concept
+ return _prev
})
- return prev
- })
+ setDecisions(_prev => ({..._prev, [index]: 'map'}))
+ setMapTypes(_prev => ({..._prev, [index]: mapType}))
+ setRowStatuses(prev => ({...prev, readyForReview: uniq([...prev.readyForReview, index]), unmapped: without(prev.unmapped, index), reviewed: without(prev.reviewed, index)}))
+ log({action: 'auto-matched', extras: {repoVersion: repoVersion?.version_url || _repo.version_url, name: getConceptLabel(_concept), map_type: mapType, algorithm: proposal.candidate?.algorithm_id, ...logExtras}}, index)
+ // Until the next render, the row as the run just left it.
+ rowMappingStateRef.current = {
+ mapSelected: {...current.mapSelected, [index]: _concept}, decisions: {...current.decisions, [index]: 'map'},
+ mapTypes: {...current.mapTypes, [index]: mapType}, reviewed: without(current.reviewed || [], index)
+ }
+ runRowMappingsRef.current[index] = {...getRowMapping(rowMappingStateRef.current, index), chosenBy: logExtras.chosen_by, proposed: true}
+ } else {
+ setMapSelected(prev => omit(prev, index))
+ setDecisions(prev => omit(prev, index))
+ setMapTypes(prev => omit(prev, index))
+ // A proposal the rule withdraws (the AI found no match over a Recommended
+ // score) is no longer Proposed.
+ setRowStatuses(prev => ({...prev, unmapped: uniq([...prev.unmapped, index]), readyForReview: without(prev.readyForReview, index), reviewed: without(prev.reviewed, index)}))
+ const reason = getAutoMatchNoProposalReason(recommendation)
+ if(reason)
+ log({action: 'auto_match_no_proposal', description: t(`map_project.auto_match_no_proposal_${reason}`), extras: logExtras}, index)
+ rowMappingStateRef.current = {
+ mapSelected: omit(current.mapSelected, index), decisions: omit(current.decisions, index),
+ mapTypes: omit(current.mapTypes, index), reviewed: without(current.reviewed || [], index)
+ }
+ runRowMappingsRef.current[index] = {...getRowMapping(rowMappingStateRef.current, index), chosenBy: logExtras.chosen_by, proposed: true}
+ }
}
- const setAutoMatched = (indexes) => {
+ // The rows' proposals, by the recommendation rule with any current analysis
+ // each row has (OpenConceptLab/ocl_issues#2908). A row the run already
+ // proposed, and the user changed since, is left alone.
+ const setAutoMatched = (indexes, _repo = repo) => {
forEach(indexes, index => {
- const top = pickTopRowView(index)
- const topScore = top?.conceptRow?.rerank_score
- if(isNumber(topScore) && topScore >= candidatesScore.recommended) {
- setRowStatuses(prev => {
- let newStatuses = {...prev}
- const _concept = {...conceptForMapping(top), repo: {...repo, version: repoVersion?.id || repo.version, version_url: repoVersion?.version_url || repo.version_url}}
- setMapSelected(_prev => {
- _prev[index] = _concept
- return _prev
- })
- const mapType = top.candidate?.map_type || 'SAME-AS'
- newStatuses.readyForReview = uniq([...newStatuses.readyForReview, index])
- setDecisions(_prev => ({..._prev, [index]: 'map'}))
- setMapTypes(_prev => ({..._prev, [index]: mapType}))
- log({action: 'auto-matched', extras: {repoVersion: repoVersion?.version_url || repo.version_url, name: getConceptLabel(_concept), map_type: mapType, algorithm: top.candidate?.algorithm_id}}, index)
- newStatuses.unmapped = without(newStatuses.unmapped, index)
- return newStatuses
- })
- } else {
- setMapSelected(prev => omit(prev, index))
- setDecisions(prev => omit(prev, index))
- setMapTypes(prev => omit(prev, index))
- setRowStatuses(prev => ({...prev, unmapped: uniq([...prev.unmapped, index])}))
- }
+ const runMapping = runRowMappingsRef.current[index]
+ if(runMapping?.proposed && !isSameRowMapping(runMapping, getRowMapping(rowMappingStateRef.current, index)))
+ return
+ proposeRowMapping(index, getRowRecommendationFor(index), _repo)
})
}
+ // The run's AI step returned for a row: decide it again with the new
+ // analysis, unless the user changed the row after the run set it. Nothing
+ // changes, and nothing is logged, when the outcome and who chose are the
+ // same, except that a pick that isn't a candidate is always logged.
+ const proposeRowMappingAfterAI = (index, analyses) => {
+ const runMapping = runRowMappingsRef.current[index]
+ if(!isSameRowMapping(runMapping, getRowMapping(rowMappingStateRef.current, index)))
+ return
+ const recommendation = getRowRecommendationFor(index, analyses)
+ const target = recommendation.proposal ? getMappingTarget(conceptForMapping(recommendation.proposal)) : null
+ if(target === runMapping.target && recommendation.chosenBy === runMapping.chosenBy && recommendation.agreement !== 'pick_not_candidate')
+ return
+ proposeRowMapping(index, recommendation)
+ }
+
const getAlgoLogExtras = algo => {
if(!algo)
return {}
@@ -2471,8 +2590,8 @@ const MapProject = () => {
// A batch that answers after a newer run started belongs to no run.
if(!isCurrentRun())
return
- // Populate rowMatchState before any consumer (setStateViews /
- // setAutoMatched) tries to read it. The per-row fetch flow
+ // Populate rowMatchState before any consumer (setAutoMatched)
+ // tries to read it. The per-row fetch flow
// routes through `onResponse` which already calls
// mergeIntoRowMatchState; bulk processBatch doesn't, so we
// run the normalizer here.
@@ -2500,7 +2619,7 @@ const MapProject = () => {
}
}
if(!isMultiAlgo)
- setStateViews(data, _repo)
+ setAutoMatched(filter(map(data, entry => entry?.row?.__index), isNumber), _repo)
setMatchedConcepts(prev => [...prev, ...data]);
if(Array.isArray(data) && data.length)
saveLateRunResult()
@@ -2572,6 +2691,8 @@ const MapProject = () => {
// began: it changed nothing to save.
const runStarted = !isRunStopped()
const aiOutcomes = {}
+ // What each row starts the run with; the run's proposals update it.
+ runRowMappingsRef.current = Object.fromEntries(rowsToProcess.map(row => [row.__index, getRowMapping(rowMappingStateRef.current, row.__index)]))
try {
bulkMatchAlgoIdsRef.current = map(_selectedAlgos, 'id')
// Reset all algo stages to -1 for every row before starting so that
@@ -3137,18 +3258,26 @@ const MapProject = () => {
rows = filter(rows, row => indexes.includes(row.__index.toString()))
}
rows = filterRowsByQualityBucket(rows, rowQualities, selectedCandidatesScoreBucket, scoreBucketSortBy)
+ if(aiDisagreesFilter && aiDisagreesCount)
+ rows = filter(rows, row => isAIDisagreement(rowRecommendation(row.__index)))
return rows
}
// Rows with a proposed match count by its score; rows with candidates and no
// decision yet, by their best candidate (ocl_issues#2837).
+ const rowRecommendation = getRowRecommendationGetter()
const rowQualities = getRowQualities({
rowIndexes: data?.length ? map(data, '__index') : [],
mapSelected,
decisions,
- getBestCandidateScore: getBestCandidateScoreGetter(),
+ // Match Quality stays score-only (OpenConceptLab/ocl_issues#2908)
+ getBestCandidateScore: index => rowRecommendation(index)?.top?.conceptRow?.rerank_score,
}, candidatesScore)
const { recommended: recommendedCount, available: availableCount, low_ranked: lowRankedCount } = countQualityBuckets(rowQualities)
+ // Rows where the OCL AI Assistant picked another candidate than the top
+ // score, or found no match or too little to decide.
+ const showAIAssistantColumn = inAIAssistantGroup || !isEmpty(analysis)
+ const aiDisagreesCount = showAIAssistantColumn ? filter(data, row => isAIDisagreement(rowRecommendation(row.__index))).length : 0
const getStateFromIndex = index => {
if(rowStatuses.reviewed.includes(index))
@@ -5676,106 +5805,6 @@ const MapProject = () => {
}
}
- // Build the v2 AI Assistant payload sections (recommendable_concepts +
- // bridge_context + target_repo) by reading directly from rowMatchState +
- // conceptCache. See plans/unified-mapper-model.md "AI Assistant payload
- // (match-recommend)".
- const buildV2RecommendationPayload = (rowIndex) => {
- const projectContext = buildProjectContext()
- if(!projectContext?.target_repo?.canonical_url) return null
-
- const rowState = rowMatchStateRef.current?.[rowIndex]
- if(!rowState || !Object.keys(rowState.candidates || {}).length) return null
-
- const allNormCandidates = Object.values(rowState.candidates)
- const defsByKey = new Map()
- allNormCandidates.forEach(cand => {
- const def = conceptCacheRef.current[cand.concept_key]
- if(!def) return
- const existing = defsByKey.get(def.key)
- if(!existing || lookupStatusRank(def.lookup_status) > lookupStatusRank(existing.lookup_status))
- defsByKey.set(def.key, def)
- if(cand.bridge_concept_key) {
- const bridgeDef = conceptCacheRef.current[cand.bridge_concept_key]
- if(bridgeDef && !defsByKey.has(bridgeDef.key)) defsByKey.set(bridgeDef.key, bridgeDef)
- }
- })
-
- const targetCanonical = projectContext.target_repo.canonical_url
- // Project-pinned identifying property codes. When absent (FHIR-passthrough
- // sources or repos that haven't pinned a summary), filterPropertyBySummary
- // passes def.property through whole.
- const summaryPropertyCodes = repoVersion?.meta?.display?.concept_summary_properties
- // PR3-H: union of (input_locale, filters.locale). Empty set = no locale
- // filter applied (backwards-compatible for projects with neither set).
- const filterLocaleString = (filters?.locale || '')
- const filterLocales = filterLocaleString ? filterLocaleString.split(',').map(s => s.trim()).filter(Boolean) : []
- const effectiveLocales = [
- ...(inputLocale ? [inputLocale] : []),
- ...filterLocales
- ]
- const recommendable_concepts = []
- const bridge_context = []
-
- defsByKey.forEach((def, key) => {
- const isBridgeIntermediary = allNormCandidates.some(c => c.concept_key === key && c.type === 'bridge')
-
- if(isBridgeIntermediary) {
- // Bridges are CONTEXT only — never recommendable. Their target_concept_keys
- // tell the AI which recommendable_concepts they justify.
- const bridgeCandidate = allNormCandidates.find(c => c.concept_key === key && c.type === 'bridge')
- const target_concept_keys = [...new Set(
- allNormCandidates
- .filter(c => c.type === 'bridge_child' && c.bridge_concept_key === key)
- .map(c => c.concept_key)
- )]
- bridge_context.push({
- concept_key: key,
- canonical_reference: def.reference,
- display_name: def.display_name,
- score: bridgeCandidate?.score,
- target_concept_keys
- })
- } else if(conceptBelongsToTargetRepo(def, targetCanonical, projectContext.target_repo.relative_url)) {
- // Target-repo concepts only. Evidence shows which algorithms surfaced
- // this concept (and via which bridge, if applicable).
- const evidence = allNormCandidates
- .filter(c => c.concept_key === key)
- .map(c => {
- const e = {
- algorithm_id: c.algorithm_id,
- candidate_type: c.type,
- score: c.score,
- highlights: c.highlights
- }
- if(c.type === 'bridge_child' && c.bridge_concept_key)
- e.via = {bridge_concept_key: c.bridge_concept_key, bridge_map_type: c.map_type}
- return e
- })
- recommendable_concepts.push(buildRecommendableConceptEntry({
- def, key, evidence, rowState, summaryPropertyCodes, effectiveLocales
- }))
- }
- // Else: concept from a non-target, non-bridge source — skip
- })
-
- // PR3-D3-lite (L-3): drop `concept_class` / `datatype` from every entry
- // when constant across the surfaced set. LOINC always emits both as
- // constants ("LOINC" / "Nom"); for mixed-class repos the fields stay.
- stripConstantClassAndDatatype(recommendable_concepts)
-
- // PR3-D3-lite (L-6): strip `canonical_url_source` from the target_repo
- // projection. The field is admin metadata (tells UI whether the canonical
- // was explicit on the repo vs. derived) — the LLM has no use for it.
- // eslint-disable-next-line no-unused-vars
- const { canonical_url_source: _src, ...aiTargetRepo } = projectContext.target_repo
- return {
- target_repo: aiTargetRepo,
- recommendable_concepts,
- bridge_context
- }
- }
-
// Rows with candidates and no analysis of them yet; fetchRecommendation skips the rest.
const getAIAnalysableRows = _rows => filter(_rows, _row => {
const concepts = isNumber(_row?.__index) ? buildV2RecommendationPayload(_row.__index)?.recommendable_concepts : null
@@ -5954,6 +5983,11 @@ const MapProject = () => {
}
const newEntry = {...response.data, model: modelUsed, model_name: modelUsedRef?.name, prompt_template: resolvedPromptRef, prompt_template_uri: resolvedPromptRef.uri, output_locale: promptOutputLocale || null, candidate_pool_fingerprint: candidatePoolFingerprint, timestamp: timestamp, user: user.username || user.id}
setAnalysis(prev => ({...prev, [__index]: [...(prev[__index] || []), newEntry]}))
+ // A run's analysis decides the row again (OpenConceptLab/ocl_issues#2908);
+ // one the user runs on a row reorders its candidates but changes no
+ // mapping. A stopped run proposes no more mappings.
+ if(isBulk && !isSuperseded() && !abortRef.current)
+ proposeRowMappingAfterAI(__index, [...existingAnalyses, newEntry])
// Schedule a save, or leaving loses the result
// (OpenConceptLab/ocl_issues#2833). Auto Match rows (isBulk) are saved
// when the run completes. A result that lands after the user left
@@ -6315,6 +6349,9 @@ const MapProject = () => {
recommendedCount={recommendedCount}
availableCount={availableCount}
lowRankedCount={lowRankedCount}
+ aiDisagreesCount={aiDisagreesCount}
+ aiDisagreesSelected={aiDisagreesFilter}
+ onAIDisagreesClick={() => setAIDisagreesFilter(!aiDisagreesFilter)}
decisions={decisions}
decisionFilters={decisionFilters}
setDecisionFilters={setDecisionFilters}
@@ -6674,6 +6711,7 @@ const MapProject = () => {
repoVersion={repoVersion}
onFetchRecommendation={fetchRecommendation}
analysis={analysis[rowIndex]}
+ recommendation={rowRecommendation(rowIndex)}
columns={getValidColumns()}
facets={getFacetsForRow(rowIndex)}
appliedFacets={appliedFacets[rowIndex]}
diff --git a/src/components/map-projects/Score.jsx b/src/components/map-projects/Score.jsx
index 30789bf..8b3ed42 100644
--- a/src/components/map-projects/Score.jsx
+++ b/src/components/map-projects/Score.jsx
@@ -72,7 +72,7 @@ const Score = ({candidate, conceptRow, onScoreClick, sx, isAIRecommended, isAIAl
>
{
isAIRecommended || isAIAlternate ?
-
+
diff --git a/src/components/map-projects/__tests__/recommendationRule.test.js b/src/components/map-projects/__tests__/recommendationRule.test.js
new file mode 100644
index 0000000..957ca73
--- /dev/null
+++ b/src/components/map-projects/__tests__/recommendationRule.test.js
@@ -0,0 +1,477 @@
+/**
+ * Tests for the Mapper's recommendation rule (OpenConceptLab/ocl_issues#2908):
+ * getRowRecommendation in viewBuilders.js. If the OCL AI Assistant has
+ * analysed the row's current candidates, it decides what is proposed and the
+ * score orders everything else; if it hasn't, the score decides.
+ *
+ * One test per row of the ticket's table, then the edge cases.
+ *
+ * Run with: npm test
+ */
+
+import test from 'node:test'
+import assert from 'node:assert/strict'
+
+import {
+ buildQualityRowViews,
+ getTargetRepoQualityViews,
+ getRowRecommendation,
+ conceptForMapping,
+ isAIDisagreement,
+ isAIPickTarget,
+ getRowAIStatus
+} from '../viewBuilders.js'
+import {
+ getAutoMatchLogExtras,
+ getAutoMatchNoProposalReason,
+ getRowMapping,
+ isSameRowMapping
+} from '../autoMatchRows.js'
+
+const LOINC = 'http://loinc.org'
+const keyOf = code => JSON.stringify([LOINC, code, '2.81'])
+const defOf = (code, extra = {}) => ({
+ key: keyOf(code),
+ reference: { url: LOINC, code, version: '2.81' },
+ id: code,
+ ocl_url: `/orgs/Regenstrief/sources/LOINC/concepts/${code}/`,
+ display_name: `Concept ${code}`,
+ source: 'LOINC',
+ ...extra
+})
+
+// A row's candidates, one per [code, rerank score]; a null score is a
+// candidate whose rerank hasn't landed.
+const rowOf = scored => {
+ const candidates = {}
+ const conceptRows = {}
+ const conceptCache = {}
+ scored.forEach(([code, score], i) => {
+ const key = keyOf(code)
+ candidates[`c${i}`] = { id: `c${i}`, concept_key: key, algorithm_id: 'ocl-semantic', type: 'standard', score: 1 - i / 100 }
+ conceptRows[key] = { concept_key: key, ...(score === null ? {} : { rerank_score: score }) }
+ conceptCache[key] = defOf(code)
+ })
+ return { rowState: { candidates, concept_rows: conceptRows }, conceptCache }
+}
+
+// Hemoglobin (Blood) from the 2026-10-13 LOINC 2.81 test, plus a low-ranked
+// and an unranked candidate.
+const HEMOGLOBIN = [['110885-1', 99.42], ['718-7', 97.39], ['717-9', 90], ['2823-3', 44.74], ['99999-9', null]]
+const candidatesScore = { recommended: 99, available: 70 }
+const FINGERPRINT = '5-abcdef12'
+
+const aiCandidate = (code, extra = {}) => ({ concept_key: keyOf(code), canonical_reference: { url: LOINC, code, version: '2.81' }, ...extra })
+const analysisOf = ({ recommendation, pick = null, alternates = [], confidence = 'HIGH', fingerprint = FINGERPRINT } = {}) => ({
+ output: {
+ recommendation,
+ primary_candidate: pick ? aiCandidate(pick, { confidence_level: confidence }) : null,
+ alternative_candidates: alternates.map((code, i) => aiCandidate(code, { rank: i + 1 }))
+ },
+ ...(fingerprint ? { candidate_pool_fingerprint: fingerprint } : {})
+})
+
+const decide = (scored, analyses, overrides = {}) => {
+ const { rowState, conceptCache } = rowOf(scored)
+ return getRowRecommendation({
+ views: buildQualityRowViews(rowState, conceptCache),
+ analyses,
+ fingerprint: FINGERPRINT,
+ candidatesScore,
+ conceptCache,
+ ...overrides
+ })
+}
+
+const codes = views => (views || []).map(view => view.conceptDefinition.id)
+const code = view => view?.conceptDefinition?.id ?? null
+const sectionCodes = result => Object.fromEntries(Object.entries(result.sections).map(([name, views]) => [name, codes(views)]))
+
+const SCORE_SECTIONS = {
+ pick: [],
+ alternates: [],
+ recommended: ['110885-1'],
+ available: ['718-7', '717-9'],
+ lowRanked: ['2823-3'],
+ unranked: ['99999-9']
+}
+
+// ---------- The table: no analysis, failed, or out of date ----------
+
+test('getRowRecommendation: no analysis proposes the top score, if Recommended (today\'s rule)', () => {
+ const result = decide(HEMOGLOBIN, undefined)
+ assert.equal(code(result.proposal), '110885-1')
+ assert.equal(result.chosenBy, 'score')
+ assert.equal(result.agreement, 'none')
+ assert.equal(result.verdict, null)
+ assert.deepEqual(sectionCodes(result), SCORE_SECTIONS)
+})
+
+test('getRowRecommendation: no analysis and a top score below Recommended proposes nothing', () => {
+ const result = decide([['718-7', 97.39], ['717-9', 90]], [])
+ assert.equal(result.proposal, null)
+ assert.equal(result.chosenBy, 'score')
+ assert.equal(result.agreement, 'none')
+})
+
+test('getRowRecommendation: a failed analysis leaves only an older one of other candidates, so the score decides', () => {
+ // A call that fails adds no entry: the latest is the one before it, which
+ // saw a different candidate pool.
+ const result = decide(HEMOGLOBIN, [analysisOf({ recommendation: 'RECOMMEND', pick: '718-7', fingerprint: '4-0ld0ld00' })])
+ assert.equal(code(result.proposal), '110885-1')
+ assert.equal(result.chosenBy, 'score')
+ assert.equal(result.agreement, 'out_of_date')
+ assert.deepEqual(sectionCodes(result), SCORE_SECTIONS)
+})
+
+test('getRowRecommendation: an out-of-date analysis keeps its verdict and pick for display, but the score decides', () => {
+ const result = decide(HEMOGLOBIN, [analysisOf({ recommendation: 'REJECT', fingerprint: '6-c4a4ged0' })])
+ assert.equal(result.isCurrent, false)
+ assert.equal(result.verdict, 'REJECT')
+ assert.equal(code(result.proposal), '110885-1')
+ assert.equal(result.chosenBy, 'score')
+ assert.equal(result.agreement, 'out_of_date')
+})
+
+test('getRowRecommendation: an analysis saved before candidate-pool fingerprints is out of date', () => {
+ const result = decide(HEMOGLOBIN, [analysisOf({ recommendation: 'RECOMMEND', pick: '718-7', fingerprint: null })])
+ assert.equal(result.agreement, 'out_of_date')
+ assert.equal(code(result.proposal), '110885-1')
+ assert.equal(code(result.pick), '718-7')
+})
+
+test('getRowRecommendation: without the current pool\'s fingerprint no analysis is current', () => {
+ const result = decide(HEMOGLOBIN, [analysisOf({ recommendation: 'RECOMMEND', pick: '718-7' })], { fingerprint: null })
+ assert.equal(result.agreement, 'out_of_date')
+ assert.equal(result.chosenBy, 'score')
+})
+
+// ---------- The table: RECOMMEND ----------
+
+test('getRowRecommendation: RECOMMEND of the top score proposes it and agrees', () => {
+ const result = decide(HEMOGLOBIN, [analysisOf({ recommendation: 'RECOMMEND', pick: '110885-1', alternates: ['718-7'] })])
+ assert.equal(code(result.proposal), '110885-1')
+ assert.equal(result.chosenBy, 'ai_assistant')
+ assert.equal(result.agreement, 'agrees')
+ assert.equal(result.pickRank, 1)
+ assert.equal(result.conditional, false)
+ assert.deepEqual(sectionCodes(result), {
+ pick: ['110885-1'],
+ alternates: ['718-7'],
+ recommended: [],
+ available: ['717-9'],
+ lowRanked: ['2823-3'],
+ unranked: ['99999-9']
+ })
+})
+
+test('getRowRecommendation: RECOMMEND of a lower-scored candidate proposes the AI\'s pick, a different pick', () => {
+ const result = decide(HEMOGLOBIN, [analysisOf({ recommendation: 'RECOMMEND', pick: '718-7', alternates: ['110885-1', '717-9'] })])
+ assert.equal(code(result.proposal), '718-7')
+ assert.equal(result.chosenBy, 'ai_assistant')
+ assert.equal(result.agreement, 'different_pick')
+ assert.equal(code(result.top), '110885-1')
+ assert.equal(result.pickRank, 2)
+ assert.deepEqual(sectionCodes(result), {
+ pick: ['718-7'],
+ alternates: ['110885-1', '717-9'],
+ recommended: [],
+ available: [],
+ lowRanked: ['2823-3'],
+ unranked: ['99999-9']
+ })
+})
+
+test('getRowRecommendation: RECOMMEND proposes the AI\'s pick in any score bucket, even Low Ranked', () => {
+ // Potassium (Serum): the AI's pick sat under Low Ranked at 44.74%.
+ const result = decide([['2824-1', 72.5], ['2823-3', 44.74]], [analysisOf({ recommendation: 'RECOMMEND', pick: '2823-3' })])
+ assert.equal(code(result.proposal), '2823-3')
+ assert.equal(result.chosenBy, 'ai_assistant')
+ assert.equal(result.agreement, 'different_pick')
+ assert.equal(result.pickRank, 2)
+})
+
+// ---------- The table: CONDITIONAL ----------
+
+test('getRowRecommendation: CONDITIONAL proposes the AI\'s pick, marked conditional', () => {
+ const result = decide(HEMOGLOBIN, [analysisOf({ recommendation: 'CONDITIONAL', pick: '718-7', alternates: ['110885-1', '717-9'] })])
+ assert.equal(code(result.proposal), '718-7')
+ assert.equal(result.chosenBy, 'ai_assistant')
+ assert.equal(result.agreement, 'different_pick')
+ assert.equal(result.conditional, true)
+ assert.equal(result.verdict, 'CONDITIONAL')
+})
+
+test('getRowRecommendation: CONDITIONAL of the top score agrees, marked conditional', () => {
+ const result = decide(HEMOGLOBIN, [analysisOf({ recommendation: 'CONDITIONAL', pick: '110885-1' })])
+ assert.equal(code(result.proposal), '110885-1')
+ assert.equal(result.agreement, 'agrees')
+ assert.equal(result.conditional, true)
+})
+
+// ---------- The table: REJECT and INSUFFICIENT ----------
+
+test('getRowRecommendation: REJECT proposes nothing, even when the top score is Recommended', () => {
+ const result = decide(HEMOGLOBIN, [analysisOf({ recommendation: 'REJECT', alternates: ['717-9'] })])
+ assert.equal(result.proposal, null)
+ assert.equal(result.chosenBy, 'ai_assistant')
+ assert.equal(result.agreement, 'no_match')
+ assert.equal(result.verdict, 'REJECT')
+ assert.equal(code(result.top), '110885-1')
+ assert.equal(result.pick, null)
+ assert.deepEqual(sectionCodes(result), {
+ pick: [],
+ alternates: ['717-9'],
+ recommended: ['110885-1'],
+ available: ['718-7'],
+ lowRanked: ['2823-3'],
+ unranked: ['99999-9']
+ })
+})
+
+test('getRowRecommendation: INSUFFICIENT proposes nothing, even when the top score is Recommended', () => {
+ const result = decide(HEMOGLOBIN, [analysisOf({ recommendation: 'INSUFFICIENT' })])
+ assert.equal(result.proposal, null)
+ assert.equal(result.chosenBy, 'ai_assistant')
+ assert.equal(result.agreement, 'not_enough')
+ assert.equal(result.verdict, 'INSUFFICIENT')
+ assert.deepEqual(sectionCodes(result), SCORE_SECTIONS)
+})
+
+test('getRowRecommendation: REJECT ignores a primary candidate the AI named anyway', () => {
+ const result = decide(HEMOGLOBIN, [analysisOf({ recommendation: 'REJECT', pick: '718-7' })])
+ assert.equal(result.proposal, null)
+ assert.equal(result.pick, null)
+ assert.equal(result.agreement, 'no_match')
+})
+
+// ---------- Edge cases ----------
+
+test('getRowRecommendation: an AI pick that isn\'t a target-repo candidate falls back to the score rule', () => {
+ const result = decide(HEMOGLOBIN, [analysisOf({ recommendation: 'RECOMMEND', pick: '12345-6', alternates: ['718-7'] })])
+ assert.equal(code(result.proposal), '110885-1')
+ assert.equal(result.chosenBy, 'score')
+ assert.equal(result.agreement, 'pick_not_candidate')
+ assert.equal(result.pick, null)
+ assert.deepEqual(sectionCodes(result), SCORE_SECTIONS)
+})
+
+test('getRowRecommendation: a CONDITIONAL with no primary candidate falls back to the score rule', () => {
+ const result = decide(HEMOGLOBIN, [analysisOf({ recommendation: 'CONDITIONAL' })])
+ assert.equal(code(result.proposal), '110885-1')
+ assert.equal(result.chosenBy, 'score')
+ assert.equal(result.agreement, 'pick_not_candidate')
+})
+
+test('getRowRecommendation: confidence is reported but doesn\'t change the proposal', () => {
+ const result = decide(HEMOGLOBIN, [analysisOf({ recommendation: 'RECOMMEND', pick: '718-7', confidence: 'LOW' })])
+ assert.equal(result.confidence, 'LOW')
+ assert.equal(code(result.proposal), '718-7')
+ assert.equal(result.chosenBy, 'ai_assistant')
+})
+
+test('getRowRecommendation: alternates are ordered by score, skip non-candidates and the pick itself', () => {
+ const result = decide(HEMOGLOBIN, [analysisOf({ recommendation: 'RECOMMEND', pick: '718-7', alternates: ['2823-3', '12345-6', '718-7', '110885-1'] })])
+ assert.deepEqual(codes(result.sections.alternates), ['110885-1', '2823-3'])
+ assert.deepEqual(codes(result.alternates), ['110885-1', '2823-3'])
+})
+
+test('getRowRecommendation: a pick tied with the top score agrees', () => {
+ const result = decide([['110885-1', 99.42], ['718-7', 99.42]], [analysisOf({ recommendation: 'RECOMMEND', pick: '718-7' })])
+ assert.equal(result.agreement, 'agrees')
+ assert.equal(result.pickRank, 1)
+})
+
+test('getRowRecommendation: a pick that has no score yet is a different pick with no rank', () => {
+ const result = decide(HEMOGLOBIN, [analysisOf({ recommendation: 'RECOMMEND', pick: '99999-9' })])
+ assert.equal(code(result.proposal), '99999-9')
+ assert.equal(result.agreement, 'different_pick')
+ assert.equal(result.pickRank, null)
+})
+
+test('getRowRecommendation: with no scored candidate there is no top score to disagree with', () => {
+ const result = decide([['718-7', null], ['717-9', null]], [analysisOf({ recommendation: 'RECOMMEND', pick: '717-9' })])
+ assert.equal(code(result.proposal), '717-9')
+ assert.equal(result.top, null)
+ assert.equal(result.agreement, 'agrees')
+})
+
+test('getRowRecommendation: the latest analysis decides, not an earlier one', () => {
+ const result = decide(HEMOGLOBIN, [
+ analysisOf({ recommendation: 'REJECT' }),
+ analysisOf({ recommendation: 'RECOMMEND', pick: '718-7' })
+ ])
+ assert.equal(code(result.proposal), '718-7')
+ assert.equal(result.agreement, 'different_pick')
+})
+
+test('getRowRecommendation: accepts a single analysis entry as well as the history', () => {
+ const result = decide(HEMOGLOBIN, analysisOf({ recommendation: 'RECOMMEND', pick: '718-7' }))
+ assert.equal(code(result.proposal), '718-7')
+})
+
+test('getRowRecommendation: resolves the AI\'s pick by its canonical reference when it has no concept_key', () => {
+ const entry = analysisOf({ recommendation: 'RECOMMEND' })
+ entry.output.primary_candidate = { canonical_reference: { url: LOINC, code: '718-7' } }
+ const result = decide(HEMOGLOBIN, [entry])
+ assert.equal(code(result.proposal), '718-7')
+})
+
+test('getRowRecommendation: a pick from another repository is not a target candidate with the same code', () => {
+ // The AI names a concept of another source whose code a target candidate shares.
+ const foreign = { concept_key: JSON.stringify(['https://CIELterminology.org', '718-7', null]), canonical_reference: { url: 'https://CIELterminology.org', code: '718-7' }, confidence_level: 'HIGH' }
+ const analysis = { output: { recommendation: 'RECOMMEND', primary_candidate: foreign, alternative_candidates: [] }, candidate_pool_fingerprint: FINGERPRINT }
+ const result = decide(HEMOGLOBIN, [analysis])
+ assert.equal(result.agreement, 'pick_not_candidate')
+ assert.equal(result.chosenBy, 'score')
+ assert.equal(code(result.proposal), '110885-1')
+})
+
+test('getRowRecommendation: a pick of the target repository found by its code when its concept_key differs', () => {
+ // Same source and code, another version in the key.
+ const sameSource = { concept_key: JSON.stringify([LOINC, '718-7', '2.80']), canonical_reference: { url: LOINC, code: '718-7', version: '2.80' }, confidence_level: 'HIGH' }
+ const analysis = { output: { recommendation: 'RECOMMEND', primary_candidate: sameSource, alternative_candidates: [] }, candidate_pool_fingerprint: FINGERPRINT }
+ const result = decide(HEMOGLOBIN, [analysis])
+ assert.equal(code(result.pick), '718-7')
+ assert.equal(result.chosenBy, 'ai_assistant')
+})
+
+test('getRowRecommendation: an analysis without a known verdict counts as none', () => {
+ const result = decide(HEMOGLOBIN, [{ user: 'someone', timestamp: '2026-10-13T00:00:00Z', candidate_pool_fingerprint: FINGERPRINT }])
+ assert.equal(result.agreement, 'none')
+ assert.equal(result.chosenBy, 'score')
+ assert.equal(code(result.proposal), '110885-1')
+})
+
+test('getRowRecommendation: no candidates, no proposal', () => {
+ const result = decide([], [analysisOf({ recommendation: 'RECOMMEND', pick: '718-7' })])
+ assert.equal(result.proposal, null)
+ assert.equal(result.agreement, 'pick_not_candidate')
+ assert.equal(result.top, null)
+})
+
+// ---------- getTargetRepoQualityViews ----------
+
+test('getTargetRepoQualityViews: keeps only the target repo\'s concepts', () => {
+ const { rowState, conceptCache } = rowOf([['718-7', 97.39]])
+ const bridgeKey = JSON.stringify(['https://CIELterminology.org', '21', null])
+ rowState.candidates.b0 = { id: 'b0', concept_key: bridgeKey, algorithm_id: 'ocl-bridge', type: 'bridge', score: 0.9 }
+ rowState.concept_rows[bridgeKey] = { concept_key: bridgeKey, rerank_score: 99.9 }
+ conceptCache[bridgeKey] = { key: bridgeKey, reference: { url: 'https://CIELterminology.org', code: '21' }, id: '21', ocl_url: '/orgs/CIEL/sources/CIEL/concepts/21/' }
+ assert.deepEqual(codes(getTargetRepoQualityViews(rowState, conceptCache, LOINC, '/orgs/Regenstrief/sources/LOINC/')), ['718-7'])
+ assert.equal(getTargetRepoQualityViews(rowState, conceptCache).length, 2)
+})
+
+// ---------- The grid: disagreement filter, Target Code icon, AI column ----------
+
+test('isAIDisagreement: a different pick, a pick that isn\'t a candidate, No match and Not enough to decide disagree', () => {
+ const agreementOf = analysis => decide(HEMOGLOBIN, analysis ? [analysis] : undefined)
+ assert.equal(isAIDisagreement(agreementOf(analysisOf({ recommendation: 'RECOMMEND', pick: '718-7' }))), true)
+ assert.equal(isAIDisagreement(agreementOf(analysisOf({ recommendation: 'RECOMMEND', pick: '12345-6' }))), true)
+ assert.equal(isAIDisagreement(agreementOf(analysisOf({ recommendation: 'REJECT' }))), true)
+ assert.equal(isAIDisagreement(agreementOf(analysisOf({ recommendation: 'INSUFFICIENT' }))), true)
+ assert.equal(isAIDisagreement(agreementOf(analysisOf({ recommendation: 'RECOMMEND', pick: '110885-1' }))), false)
+ assert.equal(isAIDisagreement(agreementOf(analysisOf({ recommendation: 'REJECT', fingerprint: '6-c4a4ged0' }))), false)
+ assert.equal(isAIDisagreement(agreementOf(null)), false)
+ assert.equal(isAIDisagreement(undefined), false)
+})
+
+test('isAIPickTarget: the mapped target is the AI\'s pick, by URL', () => {
+ const result = decide(HEMOGLOBIN, [analysisOf({ recommendation: 'RECOMMEND', pick: '718-7' })])
+ const { rowState, conceptCache } = rowOf(HEMOGLOBIN)
+ const mappedAs = code => conceptForMapping(buildQualityRowViews(rowState, conceptCache).find(view => view.conceptDefinition.id === code))
+ assert.equal(isAIPickTarget(result, mappedAs('718-7')), true)
+ assert.equal(isAIPickTarget(result, mappedAs('110885-1')), false)
+ // a target restored from a saved project has only its id, name and URL
+ assert.equal(isAIPickTarget(result, { id: '718-7', url: '/orgs/Regenstrief/sources/LOINC/concepts/718-7/' }), true)
+ assert.equal(isAIPickTarget(result, null), false)
+ assert.equal(isAIPickTarget(decide(HEMOGLOBIN, [analysisOf({ recommendation: 'REJECT' })]), mappedAs('718-7')), false)
+})
+
+test('isAIPickTarget: a URL whose code is encoded once or twice is the same concept', () => {
+ // A code with "&" reaches the grid as %26 from one place and %2526 from another.
+ const { rowState, conceptCache } = rowOf([['1G40&XS65', 98.9], ['1G40', 90]])
+ Object.values(conceptCache).forEach(def => { def.ocl_url = `/orgs/WHO/sources/ICD-11-WHO/concepts/${encodeURIComponent(encodeURIComponent(def.id))}/` })
+ const result = getRowRecommendation({
+ views: buildQualityRowViews(rowState, conceptCache),
+ analyses: [analysisOf({ recommendation: 'RECOMMEND', pick: '1G40&XS65' })],
+ fingerprint: FINGERPRINT, candidatesScore, conceptCache,
+ })
+ assert.equal(isAIPickTarget(result, { id: '1G40&XS65', url: '/orgs/WHO/sources/ICD-11-WHO/concepts/1G40%26XS65/' }), true)
+ assert.equal(isAIPickTarget(result, { id: '1G40', url: '/orgs/WHO/sources/ICD-11-WHO/concepts/1G40/' }), false)
+})
+
+test('isAIPickTarget: an out-of-date analysis\'s pick is still the AI\'s pick', () => {
+ const result = decide(HEMOGLOBIN, [analysisOf({ recommendation: 'RECOMMEND', pick: '718-7', fingerprint: '6-c4a4ged0' })])
+ assert.equal(isAIPickTarget(result, { id: '718-7', url: '/orgs/Regenstrief/sources/LOINC/concepts/718-7/' }), true)
+})
+
+test('getRowAIStatus: a running or failed call shows over the analysis it would replace', () => {
+ const current = decide(HEMOGLOBIN, [analysisOf({ recommendation: 'RECOMMEND', pick: '718-7' })])
+ assert.equal(getRowAIStatus(current, 0), 'running')
+ assert.equal(getRowAIStatus(current, -2), 'failed')
+ assert.equal(getRowAIStatus(undefined, 0), 'running')
+ assert.equal(getRowAIStatus(current, 1), 'different_pick')
+ assert.equal(getRowAIStatus(current, undefined), 'different_pick')
+})
+
+test('getRowAIStatus: no analysis, or one never run, is empty', () => {
+ assert.equal(getRowAIStatus(decide(HEMOGLOBIN, undefined), 1), null)
+ assert.equal(getRowAIStatus(decide(HEMOGLOBIN, undefined), -3), null)
+ assert.equal(getRowAIStatus(undefined, undefined), null)
+ assert.equal(getRowAIStatus(decide(HEMOGLOBIN, [analysisOf({ recommendation: 'REJECT', fingerprint: '6-c4a4ged0' })]), 1), 'out_of_date')
+})
+
+// ---------- AutoMatch: the log, and a row the user changed ----------
+
+test('getAutoMatchLogExtras: records who chose, and the verdict when the AI did', () => {
+ assert.deepEqual(getAutoMatchLogExtras(decide(HEMOGLOBIN, undefined)), { chosen_by: 'score' })
+ assert.deepEqual(getAutoMatchLogExtras(decide(HEMOGLOBIN, [analysisOf({ recommendation: 'RECOMMEND', pick: '718-7' })])),
+ { chosen_by: 'ai_assistant', verdict: 'RECOMMEND', confidence: 'HIGH' })
+ assert.deepEqual(getAutoMatchLogExtras(decide(HEMOGLOBIN, [analysisOf({ recommendation: 'REJECT' })])),
+ { chosen_by: 'ai_assistant', verdict: 'REJECT' })
+ assert.deepEqual(getAutoMatchLogExtras(decide(HEMOGLOBIN, [analysisOf({ recommendation: 'INSUFFICIENT' })])),
+ { chosen_by: 'ai_assistant', verdict: 'INSUFFICIENT' })
+ // an out-of-date analysis changes nothing: the score chose
+ assert.deepEqual(getAutoMatchLogExtras(decide(HEMOGLOBIN, [analysisOf({ recommendation: 'REJECT', fingerprint: '6-c4a4ged0' })])), { chosen_by: 'score' })
+})
+
+test('getAutoMatchLogExtras: a pick that isn\'t a candidate is logged, and the score chose', () => {
+ assert.deepEqual(getAutoMatchLogExtras(decide(HEMOGLOBIN, [analysisOf({ recommendation: 'RECOMMEND', pick: '12345-6' })])),
+ { chosen_by: 'score', verdict: 'RECOMMEND', ai_pick_not_candidate: true })
+})
+
+test('getAutoMatchNoProposalReason: why the AI left a row unmapped, or null when the score found nothing', () => {
+ assert.equal(getAutoMatchNoProposalReason(decide(HEMOGLOBIN, [analysisOf({ recommendation: 'REJECT' })])), 'reject')
+ assert.equal(getAutoMatchNoProposalReason(decide(HEMOGLOBIN, [analysisOf({ recommendation: 'INSUFFICIENT' })])), 'insufficient')
+ assert.equal(getAutoMatchNoProposalReason(decide([['718-7', 97.39]], [analysisOf({ recommendation: 'RECOMMEND', pick: '12345-6' })])), 'pick_not_candidate')
+ assert.equal(getAutoMatchNoProposalReason(decide([['718-7', 97.39]], undefined)), null)
+ assert.equal(getAutoMatchNoProposalReason(decide(HEMOGLOBIN, [analysisOf({ recommendation: 'RECOMMEND', pick: '718-7' })])), null)
+})
+
+test('getRowMapping / isSameRowMapping: a row is unchanged until its target, decision, map type or approval changes', () => {
+ const state = {
+ mapSelected: { 0: { url: '/concepts/718-7/' } },
+ decisions: { 0: 'map' },
+ mapTypes: { 0: 'SAME-AS' },
+ reviewed: [],
+ }
+ const proposed = getRowMapping(state, 0)
+ assert.deepEqual(proposed, { target: '/concepts/718-7/', decision: 'map', mapType: 'SAME-AS', reviewed: false })
+ assert.equal(isSameRowMapping(proposed, getRowMapping({ ...state, mapSelected: { 0: { url: '/concepts/718-7/', display_name: 'renamed' } } }, 0)), true)
+ assert.equal(isSameRowMapping(proposed, getRowMapping({ ...state, mapSelected: { 0: { url: '/concepts/717-9/' } } }, 0)), false)
+ assert.equal(isSameRowMapping(proposed, getRowMapping({ ...state, decisions: { 0: 'rejected' } }, 0)), false)
+ assert.equal(isSameRowMapping(proposed, getRowMapping({ ...state, mapTypes: { 0: 'NARROWER-THAN' } }, 0)), false)
+ assert.equal(isSameRowMapping(proposed, getRowMapping({ ...state, reviewed: [0] }, 0)), false)
+ // an unmapped row
+ assert.equal(isSameRowMapping(getRowMapping({}, 3), getRowMapping({ mapSelected: {}, decisions: {}, mapTypes: {}, reviewed: [] }, 3)), true)
+ assert.equal(isSameRowMapping(undefined, getRowMapping({}, 3)), false)
+})
+
+test('getRowMapping: a target without a URL is told apart by its source and code', () => {
+ // A bridge target can be mapped before a lookup gives it a URL.
+ const withTarget = target => getRowMapping({ mapSelected: { 0: target }, decisions: { 0: 'map' }, mapTypes: { 0: 'SAME-AS' } }, 0)
+ assert.equal(isSameRowMapping(withTarget({ id: 'A', source: 'ICD-11-WHO' }), withTarget({ id: 'B', source: 'ICD-11-WHO' })), false)
+ assert.equal(isSameRowMapping(withTarget({ id: 'A', source: 'ICD-11-WHO' }), withTarget({ id: 'A', source: 'ICD-11-WHO' })), true)
+ assert.equal(withTarget({ id: 'A', url: '/concepts/A/' }).target, '/concepts/A/')
+})
diff --git a/src/components/map-projects/__tests__/rowQuality.test.js b/src/components/map-projects/__tests__/rowQuality.test.js
index 5ab2508..680a4bc 100644
--- a/src/components/map-projects/__tests__/rowQuality.test.js
+++ b/src/components/map-projects/__tests__/rowQuality.test.js
@@ -13,7 +13,7 @@
import test from 'node:test'
import assert from 'node:assert/strict'
-import { getRowQuality, getRowQualities, countQualityBuckets, filterRowsByQualityBucket, createBestCandidateScoreCache } from '../rowQuality.js'
+import { getRowQuality, getRowQualities, countQualityBuckets, filterRowsByQualityBucket, createRowRecommendationCache } from '../rowQuality.js'
const candidatesScore = { recommended: 80, available: 50 }
const proposed = score => ({ search_meta: { search_normalized_score: score } })
@@ -110,57 +110,77 @@ test('filterRowsByQualityBucket: combined with a search or status filter, a buck
assert.deepEqual(filterRowsByQualityBucket([{ __index: 3 }], qualities, 'available', 'desc'), [])
})
-test('createBestCandidateScoreCache: recomputes only the rows whose candidates or concepts changed', () => {
+test('createRowRecommendationCache: recomputes only the rows whose candidates, concepts or analyses changed', () => {
const computed = []
- const computeScore = index => { computed.push(index); return 50 + index }
- const cache = createBestCandidateScoreCache()
+ const compute = index => { computed.push(index); return { score: 50 + index } }
+ const cache = createRowRecommendationCache()
const concepts = { a: { key: 'a' }, b: { key: 'b' }, c: { key: 'c' } }
const rows = { 0: { concept_rows: { a: {} } }, 1: { concept_rows: { b: {} } }, 2: { concept_rows: { c: {} } } }
+ const analyses = { 2: [{ output: { recommendation: 'RECOMMEND' } }] }
- let get = cache.getter({ rows, concepts, targetKey: 't', computeScore })
- assert.deepEqual([0, 1, 2].map(get), [50, 51, 52])
+ let get = cache.getter({ rows, concepts, analyses, contextKey: 't', compute })
+ assert.deepEqual([0, 1, 2].map(index => get(index).score), [50, 51, 52])
assert.deepEqual(computed, [0, 1, 2])
// nothing changed: nothing recomputed, even across renders
computed.length = 0
- get = cache.getter({ rows, concepts, targetKey: 't', computeScore })
+ get = cache.getter({ rows, concepts, analyses: { ...analyses }, contextKey: 't', compute })
;[0, 1, 2].forEach(get)
assert.deepEqual(computed, [])
// one row's candidates changed (a rerank replaced its state)
const rows2 = { ...rows, 1: { concept_rows: { b: { rerank_score: 80 } } } }
- get = cache.getter({ rows: rows2, concepts, targetKey: 't', computeScore })
+ get = cache.getter({ rows: rows2, concepts, analyses, contextKey: 't', compute })
;[0, 1, 2].forEach(get)
assert.deepEqual(computed, [1])
// a lookup filled in concept c, used only by row 2
computed.length = 0
const concepts2 = { ...concepts, c: { key: 'c', display_name: 'C' } }
- get = cache.getter({ rows: rows2, concepts: concepts2, targetKey: 't', computeScore })
+ get = cache.getter({ rows: rows2, concepts: concepts2, analyses, contextKey: 't', compute })
;[0, 1, 2].forEach(get)
assert.deepEqual(computed, [2])
- // the target repo changed: every row again
+ // an analysis landed for row 0
computed.length = 0
- get = cache.getter({ rows: rows2, concepts: concepts2, targetKey: 'u', computeScore })
+ const analyses2 = { ...analyses, 0: [{ output: { recommendation: 'REJECT' } }] }
+ get = cache.getter({ rows: rows2, concepts: concepts2, analyses: analyses2, contextKey: 't', compute })
+ ;[0, 1, 2].forEach(get)
+ assert.deepEqual(computed, [0])
+
+ // the target repo or the thresholds changed: every row again
+ computed.length = 0
+ get = cache.getter({ rows: rows2, concepts: concepts2, analyses: analyses2, contextKey: 'u', compute })
;[0, 1, 2].forEach(get)
assert.deepEqual(computed, [0, 1, 2])
})
-test('createBestCandidateScoreCache: a row with no candidates has no score and is not computed', () => {
+test('createRowRecommendationCache: a row with no candidates has no recommendation and is not computed', () => {
const computed = []
- const cache = createBestCandidateScoreCache()
- const get = cache.getter({ rows: {}, concepts: {}, targetKey: 't', computeScore: index => { computed.push(index); return 1 } })
+ const cache = createRowRecommendationCache()
+ const get = cache.getter({ rows: {}, concepts: {}, analyses: {}, contextKey: 't', compute: index => { computed.push(index); return {} } })
assert.equal(get(7), undefined)
assert.deepEqual(computed, [])
})
-test('createBestCandidateScoreCache: uses each render\'s own computeScore, not the first one it saw', () => {
- // MapProject's first render has no target repo yet, so its pickTopRowView
- // scores nothing; a later render's must be the one used.
- const cache = createBestCandidateScoreCache()
+test('createRowRecommendationCache: a row with analyses but no candidates is still computed', () => {
+ // A refresh cleared the row's candidates and the match hasn't answered yet:
+ // its saved analysis is out of date, not gone.
+ const computed = []
+ const cache = createRowRecommendationCache()
+ const analyses = { 4: [{ output: { recommendation: 'RECOMMEND' } }] }
+ const get = cache.getter({ rows: {}, concepts: {}, analyses, contextKey: 't', compute: index => { computed.push(index); return { agreement: 'out_of_date' } } })
+ assert.deepEqual(get(4), { agreement: 'out_of_date' })
+ assert.equal(get(5), undefined)
+ assert.deepEqual(computed, [4])
+})
+
+test('createRowRecommendationCache: uses each render\'s own compute, not the first one it saw', () => {
+ // MapProject's first render has no target repo yet, so its rule sees no
+ // candidates; a later render's must be the one used.
+ const cache = createRowRecommendationCache()
const rows = { 0: { concept_rows: { a: {} } } }
const concepts = { a: { key: 'a' } }
- assert.equal(cache.getter({ rows, concepts, targetKey: '|', computeScore: () => undefined })(0), undefined)
- assert.equal(cache.getter({ rows, concepts, targetKey: 'loinc|/orgs/Regenstrief/sources/LOINC/', computeScore: () => 72 })(0), 72)
+ assert.equal(cache.getter({ rows, concepts, analyses: {}, contextKey: '|', compute: () => undefined })(0), undefined)
+ assert.equal(cache.getter({ rows, concepts, analyses: {}, contextKey: 'loinc|/orgs/Regenstrief/sources/LOINC/', compute: () => 72 })(0), 72)
})
diff --git a/src/components/map-projects/autoMatchRows.js b/src/components/map-projects/autoMatchRows.js
index f01e813..55525ae 100644
--- a/src/components/map-projects/autoMatchRows.js
+++ b/src/components/map-projects/autoMatchRows.js
@@ -182,6 +182,44 @@ export const hasCurrentAnalysis = (analyses, fingerprint) => {
return Boolean(fingerprint && latest?.candidate_pool_fingerprint === fingerprint)
}
+// What a run's log records about a row's proposal (OpenConceptLab/ocl_issues#2908):
+// who chose it, and the OCL AI Assistant's verdict when it chose or when its
+// pick wasn't one of the candidates.
+export const getAutoMatchLogExtras = recommendation => {
+ const { chosenBy = 'score', verdict, confidence, agreement } = recommendation || {}
+ if(agreement === 'pick_not_candidate')
+ return { chosen_by: chosenBy, verdict, ai_pick_not_candidate: true }
+ if(chosenBy !== 'ai_assistant')
+ return { chosen_by: chosenBy }
+ return { chosen_by: chosenBy, verdict, ...(confidence ? { confidence } : {}) }
+}
+
+// Why the OCL AI Assistant's analysis left a row with no proposal, for the
+// row's log: 'reject', 'insufficient', or 'pick_not_candidate' when its pick
+// isn't a candidate and no score is Recommended. null when a match is
+// proposed, or when the score alone found none (not logged, as before).
+export const getAutoMatchNoProposalReason = recommendation => {
+ if(!recommendation || recommendation.proposal)
+ return null
+ return { no_match: 'reject', not_enough: 'insufficient', pick_not_candidate: 'pick_not_candidate' }[recommendation.agreement] || null
+}
+
+// A mapped target's identity: its URL, or its source and code while it has no
+// URL yet (a bridge target before its lookup).
+export const getMappingTarget = concept => concept?.url || (concept?.id ? `${concept.source || ''}:${concept.id}` : null)
+
+// A row's mapping: its target, decision, map type and approval. A run keeps
+// what it left each row with, so that when the AI step decides the row again
+// it leaves alone a row the user changed meanwhile.
+export const getRowMapping = ({ mapSelected = {}, decisions = {}, mapTypes = {}, reviewed = [] } = {}, index) => ({
+ target: getMappingTarget(mapSelected[index]),
+ decision: decisions[index] || null,
+ mapType: mapTypes[index] || null,
+ reviewed: reviewed.includes(index),
+})
+
+export const isSameRowMapping = (a, b) => Boolean(a && b) && ['target', 'decision', 'mapType', 'reviewed'].every(key => a[key] === b[key])
+
// The ScispaCy service keys each row's results by the itemid sent, which is
// the row's index, not its position in the run (ocl_online#258).
export const getScispacyRowResults = (responseData, rowIndex) => responseData?.[rowIndex] || []
diff --git a/src/components/map-projects/rowQuality.js b/src/components/map-projects/rowQuality.js
index 4a1547d..59f5b46 100644
--- a/src/components/map-projects/rowQuality.js
+++ b/src/components/map-projects/rowQuality.js
@@ -79,46 +79,52 @@ export const filterRowsByQualityBucket = (rows, qualities, bucket, sortBy) => {
}
/**
- * Caches each row's best candidate score across renders. A row is recomputed
- * only when its state object, a concept definition its concept rows use, or
- * the target repo changes; rows state and the concept cache are replaced,
- * never mutated, so identity checks are enough.
+ * Caches each row's recommendation (getRowRecommendation) across renders, for
+ * the grid's Match Quality, OCL AI Assistant and Target Code columns and their
+ * filters. A row with neither candidates nor analyses has none. A row is
+ * recomputed only when its state object, a concept definition its concept
+ * rows use, or its analyses change; a change of context (the target repo, the
+ * thresholds, what the candidate-pool fingerprint reads) recomputes every
+ * row. Rows state, the concept cache and the analyses are replaced, never
+ * mutated, so identity checks are enough.
*
- * @returns {{getter: function}} getter({rows, concepts, targetKey, computeScore})
- * returns rowIndex => score for one render. computeScore (rowIndex => score,
- * e.g. from pickTopRowView) is the render's own, so it sees that render's
- * project context.
+ * @returns {{getter: function}} getter({rows, concepts, analyses, contextKey, compute})
+ * returns rowIndex => value for one render. compute (rowIndex => value) is
+ * the render's own, so it sees that render's project context.
*/
-export const createBestCandidateScoreCache = () => {
+export const createRowRecommendationCache = () => {
let byRow = new Map()
- let current = { rows: null, concepts: null, targetKey: null }
+ let current = { rows: null, concepts: null, analyses: null, contextKey: null }
let checked = new Set()
return {
- getter: ({ rows, concepts, targetKey, computeScore }) => {
- if(current.targetKey !== targetKey)
+ getter: ({ rows, concepts, analyses, contextKey, compute }) => {
+ if(current.contextKey !== contextKey)
byRow = new Map()
- if(current.rows !== rows || current.concepts !== concepts || current.targetKey !== targetKey) {
- current = { rows, concepts, targetKey }
+ if(current.rows !== rows || current.concepts !== concepts || current.analyses !== analyses || current.contextKey !== contextKey) {
+ current = { rows, concepts, analyses, contextKey }
checked = new Set()
}
return index => {
if(checked.has(index))
- return byRow.get(index)?.score
+ return byRow.get(index)?.value
checked.add(index)
const rowState = rows?.[index]
- if(!rowState) {
+ const rowAnalyses = analyses?.[index]
+ // A row with analyses and no candidates (a refresh cleared them) still
+ // has a recommendation: its analysis is out of date.
+ if(!rowState && !rowAnalyses?.length) {
byRow.delete(index)
return undefined
}
- const conceptKeys = Object.keys(rowState.concept_rows || {})
+ const conceptKeys = Object.keys(rowState?.concept_rows || {})
const entry = byRow.get(index)
- const unchanged = entry && entry.rowState === rowState && entry.defs.length === conceptKeys.length &&
+ const unchanged = entry && entry.rowState === rowState && entry.analyses === rowAnalyses && entry.defs.length === conceptKeys.length &&
conceptKeys.every((key, i) => entry.defs[i] === concepts?.[key])
if(unchanged)
- return entry.score
- const score = computeScore(index)
- byRow.set(index, { rowState, defs: conceptKeys.map(key => concepts?.[key]), score })
- return score
+ return entry.value
+ const value = compute(index)
+ byRow.set(index, { rowState, analyses: rowAnalyses, defs: conceptKeys.map(key => concepts?.[key]), value })
+ return value
}
},
}
diff --git a/src/components/map-projects/viewBuilders.js b/src/components/map-projects/viewBuilders.js
index 411ce42..72d0e10 100644
--- a/src/components/map-projects/viewBuilders.js
+++ b/src/components/map-projects/viewBuilders.js
@@ -16,6 +16,7 @@
// node:test.
import { localizedDisplayName } from './conceptNames.js'
+import { hasCurrentAnalysis } from './autoMatchRows.js'
const compact = arr => (arr || []).filter(x => x != null && x !== false)
const values = obj => Object.values(obj || {})
@@ -336,6 +337,16 @@ export const buildQualityRowViews = (rowState, conceptCache) => {
}))
}
+/**
+ * The quality views of the row's target-repo concepts, the only ones that can
+ * be mapped. With no target repo known, every view.
+ */
+export const getTargetRepoQualityViews = (rowState, conceptCache, targetCanonical, targetRelativeUrl) => {
+ const views = buildQualityRowViews(rowState, conceptCache)
+ if(!targetCanonical && !targetRelativeUrl) return views
+ return views.filter(view => conceptBelongsToTargetRepo(view.conceptDefinition, targetCanonical, targetRelativeUrl))
+}
+
/**
* Sort RowViews by the chosen key. `rerank_score` and `algo_score` are
* numeric; `id` and `display_name` are alphabetical, `display_name` on the
@@ -599,3 +610,166 @@ export const getAIAnalysisCandidateIDs = (analysisProp, conceptCache) => {
alternateCandidateIds
}
}
+
+// The OCL AI Assistant's verdicts. primary_candidate is null for REJECT and
+// INSUFFICIENT.
+export const AI_VERDICTS = ['RECOMMEND', 'CONDITIONAL', 'REJECT', 'INSUFFICIENT']
+
+const rerankScoreOf = view => view?.conceptRow?.rerank_score
+const viewHasConceptKey = (view, key) =>
+ view?.candidate?.concept_key === key || (view?.contributingCandidates || []).some(candidate => candidate?.concept_key === key)
+const viewCode = view => view?.conceptDefinition?.reference?.code || view?.conceptDefinition?.id
+
+// The view of a candidate the AI named: by its concept_key, else by the code
+// resolveAICandidateID gives it, in the same source when the AI's candidate
+// names one, so another source's concept with the same code isn't taken for it.
+const findAICandidateView = (views, aiCandidate, conceptCache) => {
+ if(!aiCandidate) return null
+ const byKey = aiCandidate.concept_key ? views.find(view => viewHasConceptKey(view, aiCandidate.concept_key)) : null
+ if(byKey) return byKey
+ const code = resolveAICandidateID(aiCandidate, conceptCache)
+ const sourceUrl = conceptCache?.[aiCandidate.concept_key]?.reference?.url || aiCandidate.canonical_reference?.url
+ const sameSource = view => !sourceUrl || canonicalUrlsEqual(view?.conceptDefinition?.reference?.url, sourceUrl)
+ return (code && views.find(view => viewCode(view) === code && sameSource(view))) || null
+}
+
+// Today's Match Quality buckets, each by score.
+const toScoreSections = (views, candidatesScore) => {
+ const sections = { pick: [], alternates: [], recommended: [], available: [], lowRanked: [], unranked: [] }
+ sortRowViews(views, 'rerank_score', 'desc').forEach(view => {
+ const score = rerankScoreOf(view)
+ if(!isNumber(score)) sections.unranked.push(view)
+ else if(score >= candidatesScore?.recommended) sections.recommended.push(view)
+ else if(score >= candidatesScore?.available) sections.available.push(view)
+ else sections.lowRanked.push(view)
+ })
+ return sections
+}
+
+/**
+ * The Mapper's recommendation rule (OpenConceptLab/ocl_issues#2908). If the
+ * OCL AI Assistant has analysed the row's current candidates, it decides what
+ * is proposed, and the score orders everything else. If it hasn't, the score
+ * decides, as before.
+ *
+ * - RECOMMEND or CONDITIONAL: the AI's pick is proposed, whatever its score.
+ * A pick that isn't one of `views` falls back to the score rule
+ * (agreement 'pick_not_candidate', for the log).
+ * - REJECT or INSUFFICIENT: nothing is proposed, even over a Recommended
+ * score. The score reads names only.
+ * - No analysis, one without a known verdict, or one of an older candidate
+ * pool (the same fingerprint check Auto Match uses to re-run the AI): the
+ * top score is proposed if it's Recommended.
+ *
+ * Confidence is reported but changes nothing.
+ *
+ * @param {object} args
+ * @param {Array} args.views the row's target-repo quality views (getTargetRepoQualityViews)
+ * @param {Array|object} [args.analyses] the row's analysis history, or one entry; the latest decides
+ * @param {string} [args.fingerprint] the current candidate pool's fingerprint (getCandidatePoolFingerprint)
+ * @param {object} args.candidatesScore {recommended, available} thresholds
+ * @param {object} [args.conceptCache] resolves the AI's concept_keys to codes
+ * @returns {{
+ * sections: {pick: Array, alternates: Array, recommended: Array, available: Array, lowRanked: Array, unranked: Array},
+ * proposal: object|null,
+ * chosenBy: 'ai_assistant'|'score',
+ * agreement: 'none'|'out_of_date'|'agrees'|'different_pick'|'pick_not_candidate'|'no_match'|'not_enough',
+ * verdict: string|null, conditional: boolean, confidence: string|null,
+ * analysis: object|null, isCurrent: boolean,
+ * pick: object|null, alternates: Array, top: object|null, pickRank: number|null
+ * }}
+ * `sections` is the candidate order: the AI's pick, its alternates by score,
+ * then the rest in today's buckets by score. `pick` and `alternates` are
+ * resolved even when the analysis is out of date, for display; `top` is the
+ * highest-scored candidate and `pickRank` the pick's rank by score.
+ */
+export const getRowRecommendation = ({ views = [], analyses, fingerprint, candidatesScore, conceptCache } = {}) => {
+ const history = Array.isArray(analyses) ? analyses : (analyses ? [analyses] : [])
+ const analysis = getLatestAnalysisEntry(history) || null
+ const output = analysis?.output || analysis
+ const verdict = AI_VERDICTS.includes(output?.recommendation) ? output.recommendation : null
+ const isCurrent = hasCurrentAnalysis(history, fingerprint)
+ const byScore = sortRowViews(views, 'rerank_score', 'desc')
+ const top = byScore.find(view => isNumber(rerankScoreOf(view))) || null
+ const choosesPick = ['RECOMMEND', 'CONDITIONAL'].includes(verdict)
+ const pick = choosesPick ? findAICandidateView(views, output.primary_candidate, conceptCache) : null
+ const alternates = uniq(compact((output?.alternative_candidates || []).map(candidate => findAICandidateView(views, candidate, conceptCache))))
+ .filter(view => view !== pick)
+ const pickScore = rerankScoreOf(pick)
+ const result = {
+ verdict,
+ conditional: verdict === 'CONDITIONAL',
+ confidence: (choosesPick && output?.primary_candidate?.confidence_level) || null,
+ analysis,
+ isCurrent,
+ pick,
+ alternates: sortRowViews(alternates, 'rerank_score', 'desc'),
+ top,
+ pickRank: isNumber(pickScore) ? 1 + views.filter(view => isNumber(rerankScoreOf(view)) && rerankScoreOf(view) > pickScore).length : null,
+ }
+ const byScoreRule = agreement => ({
+ ...result,
+ sections: toScoreSections(views, candidatesScore),
+ proposal: top && rerankScoreOf(top) >= candidatesScore?.recommended ? top : null,
+ chosenBy: 'score',
+ agreement,
+ })
+ if(!verdict) return byScoreRule('none')
+ if(!isCurrent) return byScoreRule('out_of_date')
+ if(choosesPick && !pick) return byScoreRule('pick_not_candidate')
+ const sections = toScoreSections(views.filter(view => view !== pick && !alternates.includes(view)), candidatesScore)
+ sections.pick = pick ? [pick] : []
+ sections.alternates = result.alternates
+ const tiedWithTop = !top || pick === top || pickScore === rerankScoreOf(top)
+ return {
+ ...result,
+ sections,
+ proposal: pick,
+ chosenBy: 'ai_assistant',
+ agreement: { REJECT: 'no_match', INSUFFICIENT: 'not_enough' }[verdict] || (tiedWithTop ? 'agrees' : 'different_pick'),
+ }
+}
+
+// The agreement states the grid's "OCL AI Assistant disagrees" filter shows.
+const AI_DISAGREEMENTS = ['different_pick', 'pick_not_candidate', 'no_match', 'not_enough']
+
+export const isAIDisagreement = recommendation => AI_DISAGREEMENTS.includes(recommendation?.agreement)
+
+// A concept's URL can hold its code encoded once or twice (%26 or %2526 for
+// "&"), depending on where the concept came from, so URLs compare decoded.
+const decodedURL = url => {
+ let decoded = url
+ for(let i = 0; i < 3; i++) {
+ try {
+ const next = decodeURIComponent(decoded)
+ if(next === decoded) break
+ decoded = next
+ } catch (_) {
+ break
+ }
+ }
+ return decoded
+}
+
+// Whether a mapped target is the OCL AI Assistant's pick, for the grid's
+// Target Code icon: the same URL, or, when either lacks one, the same id.
+export const isAIPickTarget = (recommendation, target) => {
+ const pick = conceptForMapping(recommendation?.pick)
+ if(!target || !pick) return false
+ if(target.url && pick.url)
+ return decodedURL(target.url) === decodedURL(pick.url)
+ return pick.id === target.id
+}
+
+/**
+ * What the grid's "OCL AI Assistant" column shows for a row: 'running' or
+ * 'failed' while a call is in flight or has just failed (the row's
+ * `recommend` stage, 0 or -2), else the recommendation's agreement state, or
+ * null when the row has no analysis.
+ */
+export const getRowAIStatus = (recommendation, stage) => {
+ if(stage === 0) return 'running'
+ if(stage === -2) return 'failed'
+ const agreement = recommendation?.agreement
+ return agreement && agreement !== 'none' ? agreement : null
+}
diff --git a/src/i18n/__tests__/aiAssistantNaming.test.js b/src/i18n/__tests__/aiAssistantNaming.test.js
index c714e4e..988c4a4 100644
--- a/src/i18n/__tests__/aiAssistantNaming.test.js
+++ b/src/i18n/__tests__/aiAssistantNaming.test.js
@@ -15,8 +15,8 @@ const LOCALES = {
name: 'Assistente de IA do OCL',
banned: [/assistente de IA(?! do OCL)/i, /recomendaç(?:ão|ões)(?: de mapeamento)? (?:de|com) IA\b/i],
},
- // zh keeps "AI 推荐" as the "AI Recommended" badge, so its recommendation
- // strings are checked by key, below.
+ // zh's recommendation strings are checked by key, below: 推荐 alone is
+ // "recommended", as in the Recommended score bucket.
zh: { name: 'OCL AI 助手', banned: [/(?
diff --git a/src/i18n/locales/en/translations.json b/src/i18n/locales/en/translations.json
index 1806ef7..3d0b43e 100644
--- a/src/i18n/locales/en/translations.json
+++ b/src/i18n/locales/en/translations.json
@@ -297,8 +297,42 @@
"start_typing": "Start typing...",
"type_atleast_characters": "Type atleast {{count}} characters to search",
"source": "Source",
- "ai_recommended": "AI Recommended",
- "ai_alternate": "AI Alternate Candidate Suggestion",
+ "ai_pick": "OCL AI Assistant's pick",
+ "ai_alternate": "OCL AI Assistant alternate",
+ "ai_disagrees": "OCL AI Assistant disagrees ({{n}})",
+ "ai_disagrees_help": "Rows where the OCL AI Assistant picked another candidate than the top score, found no match, or didn't have enough to decide",
+ "ai_status_running": "Running",
+ "ai_status_failed": "Failed",
+ "ai_status_out_of_date": "Out of date",
+ "ai_status_agrees": "Agrees",
+ "ai_status_different_pick": "Different pick",
+ "ai_status_pick_not_candidate": "Pick isn't a candidate",
+ "ai_status_no_match": "No match",
+ "ai_status_not_enough": "Not enough to decide",
+ "ai_conditional": "conditional",
+ "ai_confidence_high": "High confidence",
+ "ai_confidence_medium": "Medium confidence",
+ "ai_confidence_low": "Low confidence",
+ "ai_top_score": "Top score: {{code}}, {{bucket}} {{score}}",
+ "ai_no_top_score": "No candidate has a score yet",
+ "ai_pick_rank": "The OCL AI Assistant's pick, {{code}}, ranks #{{rank}} by score",
+ "ai_pick_unscored": "The OCL AI Assistant's pick, {{code}}, has no score yet",
+ "ai_out_of_date_help": "The candidates changed after this analysis ran, so the score decides",
+ "ai_pick_not_candidate_help": "The OCL AI Assistant's pick isn't one of the target repository's candidates, so the score decides",
+ "ai_summary": "{{verdict}}. {{topScore}}. {{relation}}.",
+ "ai_summary_recommend": "The OCL AI Assistant recommends {{code}}",
+ "ai_summary_conditional": "The OCL AI Assistant recommends {{code}}, with conditions",
+ "ai_summary_no_pick": "The OCL AI Assistant names no candidate",
+ "ai_summary_reject": "The OCL AI Assistant found no match among the candidates",
+ "ai_summary_insufficient": "The OCL AI Assistant didn't have enough information to decide",
+ "ai_summary_agree": "They agree",
+ "ai_summary_differ_rank": "They differ: the pick ranks #{{rank}} by score",
+ "ai_summary_differ_unscored": "They differ: the pick has no score yet",
+ "ai_summary_no_proposal": "AutoMatch proposes no match",
+ "read_analysis": "Read analysis",
+ "auto_match_no_proposal_reject": "AutoMatch proposed no match: the OCL AI Assistant found no match among the candidates",
+ "auto_match_no_proposal_insufficient": "AutoMatch proposed no match: the OCL AI Assistant didn't have enough information to decide",
+ "auto_match_no_proposal_pick_not_candidate": "AutoMatch proposed no match: the OCL AI Assistant's pick isn't one of the candidates, and no candidate's score is Recommended",
"ai_assistant": "OCL AI Assistant",
"ai_prompt_template_unavailable": "OCL AI Assistant prompt template is not available",
"view_raw_json": "View Raw JSON",
@@ -488,7 +522,6 @@
"ocl_ai_assistant": "OCL AI Assistant",
"ocl_ai_candidates_analysis": "OCL AI Assistant: Candidate Analysis",
"group_by_match_quality": "Match Quality",
- "group_by_ai_analysis": "AI Analysis",
"ocl_semantic_algorithm": "OCL Semantic Algorithm",
"ocl_search_algorithm": "OCL Search Algorithm",
"scispacy_warming_up": "OCL's scispacy matching service is warming up. It will automatically retry in 2 minutes.",
@@ -529,10 +562,7 @@
"assessment": "Assessment",
"requested_by": "Requested by",
"primary": "Primary",
- "primary_candidate": "Primary Candidate",
"alternates": "Alternates",
- "alternate_candidates": "Alternate Candidates",
- "rest": "Rest",
"model": "Model",
"bridge_source_url": "Bridge Source URL",
"bridge_source_url_description": "The interface terminology to search through for bridge matching",
diff --git a/src/i18n/locales/es/translations.json b/src/i18n/locales/es/translations.json
index 2aa5ce3..8224171 100644
--- a/src/i18n/locales/es/translations.json
+++ b/src/i18n/locales/es/translations.json
@@ -281,8 +281,42 @@
"start_typing": "Comience a escribir...",
"type_atleast_characters": "Escriba al menos {{count}} caracteres para buscar",
"source": "Fuente",
- "ai_recommended": "Recomendado por IA",
- "ai_alternate": "Sugerencia de candidato alternativo de IA",
+ "ai_pick": "Elección del asistente de IA de OCL",
+ "ai_alternate": "Alternativa del asistente de IA de OCL",
+ "ai_disagrees": "El asistente de IA de OCL discrepa ({{n}})",
+ "ai_disagrees_help": "Filas en las que el asistente de IA de OCL eligió otro candidato que el de mayor puntuación, no encontró ninguna coincidencia o no tuvo suficiente información para decidir",
+ "ai_status_running": "En curso",
+ "ai_status_failed": "Error",
+ "ai_status_out_of_date": "Desactualizado",
+ "ai_status_agrees": "Coincide",
+ "ai_status_different_pick": "Otra elección",
+ "ai_status_pick_not_candidate": "La elección no es un candidato",
+ "ai_status_no_match": "Sin coincidencia",
+ "ai_status_not_enough": "Información insuficiente",
+ "ai_conditional": "condicional",
+ "ai_confidence_high": "Confianza alta",
+ "ai_confidence_medium": "Confianza media",
+ "ai_confidence_low": "Confianza baja",
+ "ai_top_score": "Mayor puntuación: {{code}}, {{bucket}} {{score}}",
+ "ai_no_top_score": "Ningún candidato tiene puntuación todavía",
+ "ai_pick_rank": "La elección del asistente de IA de OCL, {{code}}, ocupa el puesto n.º {{rank}} por puntuación",
+ "ai_pick_unscored": "La elección del asistente de IA de OCL, {{code}}, aún no tiene puntuación",
+ "ai_out_of_date_help": "Los candidatos cambiaron después de este análisis, así que decide la puntuación",
+ "ai_pick_not_candidate_help": "La elección del asistente de IA de OCL no es uno de los candidatos del repositorio de destino, así que decide la puntuación",
+ "ai_summary": "{{verdict}}. {{topScore}}. {{relation}}.",
+ "ai_summary_recommend": "El asistente de IA de OCL recomienda {{code}}",
+ "ai_summary_conditional": "El asistente de IA de OCL recomienda {{code}}, con condiciones",
+ "ai_summary_no_pick": "El asistente de IA de OCL no nombra ningún candidato",
+ "ai_summary_reject": "El asistente de IA de OCL no encontró ninguna coincidencia entre los candidatos",
+ "ai_summary_insufficient": "El asistente de IA de OCL no tuvo suficiente información para decidir",
+ "ai_summary_agree": "Coinciden",
+ "ai_summary_differ_rank": "Difieren: la elección ocupa el puesto n.º {{rank}} por puntuación",
+ "ai_summary_differ_unscored": "Difieren: la elección aún no tiene puntuación",
+ "ai_summary_no_proposal": "AutoMatch no propone ninguna coincidencia",
+ "read_analysis": "Leer el análisis",
+ "auto_match_no_proposal_reject": "AutoMatch no propuso ninguna coincidencia: el asistente de IA de OCL no encontró ninguna coincidencia entre los candidatos",
+ "auto_match_no_proposal_insufficient": "AutoMatch no propuso ninguna coincidencia: el asistente de IA de OCL no tuvo suficiente información para decidir",
+ "auto_match_no_proposal_pick_not_candidate": "AutoMatch no propuso ninguna coincidencia: la elección del asistente de IA de OCL no es uno de los candidatos y ningún candidato tiene una puntuación Recomendada",
"ai_assistant": "Asistente de IA de OCL",
"ai_prompt_template_unavailable": "La plantilla de prompt del asistente de IA de OCL no está disponible",
"view_raw_json": "Ver JSON Sin Procesar",
@@ -348,7 +382,6 @@
"bridge_candidates": "Candidatos Puente",
"scispacy_candidates": "Candidatos de ScispaCy LOINC",
"ai_analysis": "Análisis de IA",
- "group_by_ai_analysis": "Análisis de IA",
"view_existing_analysis": "Ver existente",
"run_new_analysis": "Ejecutar nuevo análisis",
"matched": "Coincidencias",
@@ -426,9 +459,6 @@
"bulk_log_map_type_changed": "Cambio masivo de tipo de mapeo de {{oldMapType}} a {{newMapType}} desde el panel izquierdo del conjunto de datos de entrada.",
"review": "Revisar",
"recommended_candidates": "Candidatos Recomendados",
- "primary_candidate": "Candidato principal",
- "alternate_candidates": "Candidatos alternativos",
- "rest": "Resto",
"no_candidates_found": "No pudimos encontrar candidatos para esta fila.",
"available_candidates": "Candidatos Disponibles",
"unranked_candidates": "Candidatos Sin Clasificar",
diff --git a/src/i18n/locales/pt-BR/translations.json b/src/i18n/locales/pt-BR/translations.json
index cc0cd1b..19729c3 100644
--- a/src/i18n/locales/pt-BR/translations.json
+++ b/src/i18n/locales/pt-BR/translations.json
@@ -297,8 +297,42 @@
"start_typing": "Comece a digitar...",
"type_atleast_characters": "Digite pelo menos {{count}} caracteres para buscar",
"source": "Source",
- "ai_recommended": "Recomendado por IA",
- "ai_alternate": "Sugestão de Candidato Alternativo por IA",
+ "ai_pick": "Escolha do Assistente de IA do OCL",
+ "ai_alternate": "Alternativa do Assistente de IA do OCL",
+ "ai_disagrees": "O Assistente de IA do OCL discorda ({{n}})",
+ "ai_disagrees_help": "Linhas em que o Assistente de IA do OCL escolheu outro candidato que não o de maior pontuação, não encontrou correspondência ou não teve informações suficientes para decidir",
+ "ai_status_running": "Em execução",
+ "ai_status_failed": "Falhou",
+ "ai_status_out_of_date": "Desatualizada",
+ "ai_status_agrees": "Concorda",
+ "ai_status_different_pick": "Outra escolha",
+ "ai_status_pick_not_candidate": "A escolha não é um candidato",
+ "ai_status_no_match": "Sem correspondência",
+ "ai_status_not_enough": "Informação insuficiente",
+ "ai_conditional": "condicional",
+ "ai_confidence_high": "Confiança alta",
+ "ai_confidence_medium": "Confiança média",
+ "ai_confidence_low": "Confiança baixa",
+ "ai_top_score": "Maior pontuação: {{code}}, {{bucket}} {{score}}",
+ "ai_no_top_score": "Nenhum candidato tem pontuação ainda",
+ "ai_pick_rank": "A escolha do Assistente de IA do OCL, {{code}}, está em {{rank}}º lugar pela pontuação",
+ "ai_pick_unscored": "A escolha do Assistente de IA do OCL, {{code}}, ainda não tem pontuação",
+ "ai_out_of_date_help": "Os candidatos mudaram depois desta análise, então a pontuação decide",
+ "ai_pick_not_candidate_help": "A escolha do Assistente de IA do OCL não é um dos candidatos do repositório de destino, então a pontuação decide",
+ "ai_summary": "{{verdict}}. {{topScore}}. {{relation}}.",
+ "ai_summary_recommend": "O Assistente de IA do OCL recomenda {{code}}",
+ "ai_summary_conditional": "O Assistente de IA do OCL recomenda {{code}}, com condições",
+ "ai_summary_no_pick": "O Assistente de IA do OCL não indica nenhum candidato",
+ "ai_summary_reject": "O Assistente de IA do OCL não encontrou correspondência entre os candidatos",
+ "ai_summary_insufficient": "O Assistente de IA do OCL não teve informações suficientes para decidir",
+ "ai_summary_agree": "Concordam",
+ "ai_summary_differ_rank": "Divergem: a escolha está em {{rank}}º lugar pela pontuação",
+ "ai_summary_differ_unscored": "Divergem: a escolha ainda não tem pontuação",
+ "ai_summary_no_proposal": "O AutoMatch não propõe nenhuma correspondência",
+ "read_analysis": "Ler a análise",
+ "auto_match_no_proposal_reject": "O AutoMatch não propôs nenhuma correspondência: o Assistente de IA do OCL não encontrou correspondência entre os candidatos",
+ "auto_match_no_proposal_insufficient": "O AutoMatch não propôs nenhuma correspondência: o Assistente de IA do OCL não teve informações suficientes para decidir",
+ "auto_match_no_proposal_pick_not_candidate": "O AutoMatch não propôs nenhuma correspondência: a escolha do Assistente de IA do OCL não é um dos candidatos, e nenhum candidato tem pontuação Recomendada",
"ai_assistant": "Assistente de IA do OCL",
"ai_prompt_template_unavailable": "O modelo de prompt do Assistente de IA do OCL não está disponível",
"view_raw_json": "Ver JSON Bruto",
@@ -488,7 +522,6 @@
"ocl_ai_assistant": "Assistente de IA do OCL",
"ocl_ai_candidates_analysis": "Assistente de IA do OCL: Análise de Candidatos",
"group_by_match_quality": "Qualidade de Correspondência",
- "group_by_ai_analysis": "Análise de IA",
"ocl_semantic_algorithm": "Algoritmo Semântico do OCL",
"ocl_search_algorithm": "Algoritmo de Busca do OCL",
"scispacy_warming_up": "Serviço de correspondência scispacy do OCL está aquecendo. Tentará novamente automaticamente em 2 minutos.",
@@ -529,10 +562,7 @@
"assessment": "Avaliação",
"requested_by": "Solicitado por",
"primary": "Primário",
- "primary_candidate": "Candidato Primário",
"alternates": "Alternativos",
- "alternate_candidates": "Candidatos Alternativos",
- "rest": "Restante",
"model": "Modelo",
"bridge_source_url": "URL do Source Bridge",
"bridge_source_url_description": "A terminologia de interface para buscar candidatos de correspondência",
diff --git a/src/i18n/locales/zh/translations.json b/src/i18n/locales/zh/translations.json
index feedf28..56b0bc8 100644
--- a/src/i18n/locales/zh/translations.json
+++ b/src/i18n/locales/zh/translations.json
@@ -293,8 +293,42 @@
"start_typing": "开始输入...",
"type_atleast_characters": "至少输入 {{count}} 个字符以进行搜索",
"source": "来源",
- "ai_recommended": "AI 推荐",
- "ai_alternate": "AI 备选候选建议",
+ "ai_pick": "OCL AI 助手的选择",
+ "ai_alternate": "OCL AI 助手的备选",
+ "ai_disagrees": "OCL AI 助手持不同意见({{n}})",
+ "ai_disagrees_help": "OCL AI 助手选择了与最高得分不同的候选项、未找到匹配或信息不足以做出决定的行",
+ "ai_status_running": "进行中",
+ "ai_status_failed": "失败",
+ "ai_status_out_of_date": "已过期",
+ "ai_status_agrees": "一致",
+ "ai_status_different_pick": "选择不同",
+ "ai_status_pick_not_candidate": "所选不是候选项",
+ "ai_status_no_match": "无匹配",
+ "ai_status_not_enough": "信息不足",
+ "ai_conditional": "有条件",
+ "ai_confidence_high": "高置信度",
+ "ai_confidence_medium": "中置信度",
+ "ai_confidence_low": "低置信度",
+ "ai_top_score": "最高得分:{{code}},{{bucket}} {{score}}",
+ "ai_no_top_score": "尚无候选项有得分",
+ "ai_pick_rank": "OCL AI 助手的选择 {{code}} 按得分排第 {{rank}} 位",
+ "ai_pick_unscored": "OCL AI 助手的选择 {{code}} 尚无得分",
+ "ai_out_of_date_help": "此分析运行后候选项已变化,因此由得分决定",
+ "ai_pick_not_candidate_help": "OCL AI 助手的选择不是目标存储库的候选项,因此由得分决定",
+ "ai_summary": "{{verdict}}。{{topScore}}。{{relation}}。",
+ "ai_summary_recommend": "OCL AI 助手推荐 {{code}}",
+ "ai_summary_conditional": "OCL AI 助手有条件地推荐 {{code}}",
+ "ai_summary_no_pick": "OCL AI 助手未指明候选项",
+ "ai_summary_reject": "OCL AI 助手在候选项中未找到匹配",
+ "ai_summary_insufficient": "OCL AI 助手的信息不足以做出决定",
+ "ai_summary_agree": "两者一致",
+ "ai_summary_differ_rank": "两者不同:所选项按得分排第 {{rank}} 位",
+ "ai_summary_differ_unscored": "两者不同:所选项尚无得分",
+ "ai_summary_no_proposal": "AutoMatch 不提议匹配",
+ "read_analysis": "阅读分析",
+ "auto_match_no_proposal_reject": "AutoMatch 未提议匹配:OCL AI 助手在候选项中未找到匹配",
+ "auto_match_no_proposal_insufficient": "AutoMatch 未提议匹配:OCL AI 助手的信息不足以做出决定",
+ "auto_match_no_proposal_pick_not_candidate": "AutoMatch 未提议匹配:OCL AI 助手的选择不是候选项,且没有候选项的得分达到推荐",
"ai_assistant": "OCL AI 助手",
"ai_prompt_template_unavailable": "OCL AI 助手提示模板不可用",
"view_raw_json": "查看原始 JSON",
@@ -360,7 +394,6 @@
"bridge_candidates": "桥接候选对象",
"scispacy_candidates": "ScispaCy LOINC 候选项",
"ai_analysis": "AI 分析",
- "group_by_ai_analysis": "AI 分析",
"view_existing_analysis": "查看现有",
"run_new_analysis": "运行新的分析",
"matched": "已匹配",
@@ -437,9 +470,6 @@
"bulk_log_map_type_changed": "从左侧输入数据集面板批量将映射类型从 {{oldMapType}} 更改为 {{newMapType}}。",
"review": "审查",
"recommended_candidates": "推荐候选对象",
- "primary_candidate": "主要候选项",
- "alternate_candidates": "备选候选项",
- "rest": "其余",
"no_candidates_found": "我们找不到此行的任何候选对象。",
"available_candidates": "可用候选对象",
"unranked_candidates": "未排名候选对象",