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 =>
{line}
) +} + +/** + * The grid's "OCL AI Assistant" cell: running, failed (click to retry), or + * the agreement state, with the other side in its tooltip. + */ +export const AIAssistantStatusCell = ({ status, recommendation, candidatesScore, onRetry }) => { + const { t } = useTranslation(); + if(!status) return null + if(status === 'running') + return ( + + + + {getAIStatusLabel(t, status)} + + + ) + if(status === 'failed') + return ( + + { event.stopPropagation(); onRetry?.() }}> + + {getAIStatusLabel(t, status)} + + + ) + return ( + + + + ) +} + +/** + * The candidates panel's summary line: the OCL AI Assistant's verdict in plain + * words, its pick and the top score, and whether they differ. Shown whenever + * the row has an analysis. + */ +export const AIAssistantSummary = ({ recommendation, candidatesScore, onReadAnalysis, sx }) => { + const { t } = useTranslation(); + if(!recommendation?.verdict) return null + const text = t('map_project.ai_summary', { + verdict: describeVerdict(t, recommendation), + topScore: describeTopScore(t, recommendation.top, candidatesScore), + relation: describeRelation(t, recommendation), + }) + return ( +
+ + + {text} + { + onReadAnalysis && + + {t('map_project.read_analysis')} + + } + +
+ ) +} diff --git a/src/components/map-projects/Candidates.jsx b/src/components/map-projects/Candidates.jsx index 96e5493..49bd193 100644 --- a/src/components/map-projects/Candidates.jsx +++ b/src/components/map-projects/Candidates.jsx @@ -45,6 +45,7 @@ import ConceptIcon from '../concepts/ConceptIcon' import MapButton from './MapButton' import AICandidatesAnalysis from './AICandidatesAnalysis' import AIAssistantButton from './AIAssistantButton' +import { AIAssistantSummary } from './AIAssistantVerdict' import { buildAlgorithmRowViews, getOrphanAlgorithmIds, @@ -96,7 +97,7 @@ const Sort = ({ selected, onSort }) => { ) } -const Group = ({ selected, onGroup, hasAIAnalysis }) => { +const Group = ({ selected, onGroup }) => { const { t } = useTranslation(); const [anchorEl, setAnchorEl] = React.useState(false) const onGroupClick = event => setAnchorEl(event.currentTarget) @@ -125,12 +126,6 @@ const Group = ({ selected, onGroup, hasAIAnalysis }) => { onClick('algorithm')} selected={selected === 'algorithm'}> - { - hasAIAnalysis && - onClick('ai_analysis')} selected={selected === 'ai_analysis'}> - - - } ) @@ -162,7 +157,7 @@ const SubHeader = ({count, onClick, isCollapsed, header, indicatorColor, indicat } -const CandidateList = ({rowViews, header, rowIndex, sortBy, order, openConceptPanel, showItem, isSelectedForMap, onMap, onFetchMore, bgColor, headerIndicatorIcon, bucketId, display, onDisplayChange, noToolbar, toolbarControl, repoVersion, alignToolbarLeft, rightControl, analysis, showAnalysis, openAnalysis, onCloseAnalysis, isInProgress, AIRecommendedCandidateId, AIAlternateCandidateIds, inputLocale, locales, scispacy, showAlgo, collapsed, onCollapse, candidatesScore, algoScoreFirst, byAlgorithm, showEmptyHeader, isFirst, targetCanonical, targetRelativeUrl, analysisPage, setAnalysisPage}) => { +const CandidateList = ({rowViews, header, rowIndex, sortBy, order, openConceptPanel, showItem, isSelectedForMap, onMap, onFetchMore, bgColor, headerIndicatorIcon, bucketId, display, onDisplayChange, noToolbar, toolbarControl, repoVersion, alignToolbarLeft, rightControl, analysis, showAnalysis, openAnalysis, onCloseAnalysis, isInProgress, summary, AIRecommendedCandidateId, AIAlternateCandidateIds, inputLocale, locales, scispacy, showAlgo, collapsed, onCollapse, candidatesScore, algoScoreFirst, byAlgorithm, showEmptyHeader, isFirst, targetCanonical, targetRelativeUrl, analysisPage, setAnalysisPage}) => { // Decorate rowViews so they work for BOTH renderers: // - Table view: SearchResults/TableResults reads legacy concept fields // (id, url, names, descriptions, source, search_meta, ...) via the @@ -275,16 +270,20 @@ const CandidateList = ({rowViews, header, rowIndex, sortBy, order, openConceptPa } }} subheader={ - (showAnalysis && openAnalysis) ? ( + (showAnalysis && (openAnalysis || summary)) ? (
- + {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 = ( + + ) + 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={ - - } - 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: ( - - ), - 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": "未排名候选对象",