Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 3 additions & 16 deletions src/components/map-projects/Candidates.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,7 @@ import {
conceptBelongsToTargetRepo,
sortRowViews,
conceptForMapping,
resolveAICandidateID
getAIAnalysisCandidateIDs
} from './viewBuilders.js'

const getRowProgressLabel = (stageMap, algos) => {
Expand Down Expand Up @@ -452,21 +452,8 @@ const Candidates = ({rowIndex, alert, setAlert, rowState, conceptCache, targetCa
? undefined
: analysisArray[analysisPage]
const selectedAnalysisForGrouping = selectedAnalysis || analysisArray[analysisCount - 1]
const primary = selectedAnalysis?.output?.primary_candidate || selectedAnalysis?.primary_candidate
const AIRecommendedCandidateId = resolveAICandidateID(primary, conceptCache)
const alternateCandidates = selectedAnalysis?.output?.alternative_candidates || selectedAnalysis?.alternative_candidates || []
const AIAlternateCandidateIds = [...new Set(alternateCandidates
.map(candidate => resolveAICandidateID(candidate, conceptCache))
.filter(id => id && id !== AIRecommendedCandidateId))]
const groupedAnalysisPrimaryId = resolveAICandidateID(
selectedAnalysisForGrouping?.output?.primary_candidate || selectedAnalysisForGrouping?.primary_candidate,
conceptCache
)
const groupedAnalysisAlternateIds = [...new Set(
(selectedAnalysisForGrouping?.output?.alternative_candidates || selectedAnalysisForGrouping?.alternative_candidates || [])
.map(candidate => resolveAICandidateID(candidate, conceptCache))
.filter(id => id && id !== groupedAnalysisPrimaryId)
)]
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;
Expand Down
218 changes: 173 additions & 45 deletions src/components/map-projects/MapProject.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -76,15 +76,15 @@ import { OperationsContext } from '../app/LayoutContext';
import APIService, { isTransientNetworkError, retryWithBackoff } from '../../services/APIService';
import { buildAttributionHeaders, buildConfigSnapshot, summarizeRunCompletion } from '../../services/attribution'
import { highlightTexts, dropVersion, getCurrentUser, hasAuthGroup, downloadObject, currentUserToken } from '../../common/utils';
import { WHITE, SURFACE_COLORS } from '../../common/colors';
import { WHITE, SURFACE_COLORS, TEXT_GRAY } from '../../common/colors';

import { useDoubleClick } from '../common/useDoubleClick'
import CloseIconButton from '../common/CloseIconButton';
import ConceptDetailsPanel from './ConceptDetailsPanel'
import { buildPanelPayload } from './openConceptPanel'
import LoaderDialog from '../common/LoaderDialog'
import Error403 from '../errors/Error403'
import { HEADERS, SEMANTIC_SEARCH_HEADERS, ROW_STATES, VIEWS, DECISION_TABS, ROW_STAGES, PROMPTS_KEY_DEFAULT, PROMPTS_ACTION_TYPE_DEFAULT } from './constants'
import { HEADERS, SEMANTIC_SEARCH_HEADERS, ROW_STATES, VIEWS, DECISION_TABS, ROW_STAGES, PROMPTS_KEY_DEFAULT, PROMPTS_ACTION_TYPE_DEFAULT, SCORES_COLOR } from './constants'
import MapProjectDeleteConfirmDialog from './MapProjectDeleteConfirmDialog';
import ConfigurationForm from './ConfigurationForm'
import Controls from './Controls'
Expand All @@ -107,7 +107,7 @@ import { DEFAULT_ENCODER_MODEL } from './rerankerModels'
import { normalizeAlgorithmInvocation, lookupStatusRank, buildRecommendableConceptEntry, stripConstantClassAndDatatype, buildLookupConceptUrl } from './normalizers'
import { parseConceptKey } from './conceptKey'
import { getDefaultTargetRepoVersion, getProjectTargetRepoVersion, getTargetRepoVersionFromUrl, getTargetRepoVersionId } from './projectTargetRepo'
import { buildBridgeTargetDownloadEntries, buildQualityRowViews, conceptBelongsToTargetRepo, conceptForMapping, formatBridgeTargetDownloadEntry, resolveAICandidateID } from './viewBuilders.js'
import { buildBridgeTargetDownloadEntries, buildQualityRowViews, conceptBelongsToTargetRepo, conceptForMapping, formatBridgeTargetDownloadEntry, resolveAICandidateID, getScoreDetails, getAIAnalysisCandidateIDs } from './viewBuilders.js'

import './MapProject.scss'
import '../common/ResizablePanel.scss'
Expand Down Expand Up @@ -822,6 +822,25 @@ const MapProject = () => {
return _columns
}

const getRowStateLabel = index => VIEWS[getStateFromIndex(index)]?.label || ''
const getRowScoreDetails = index => getScoreDetails({search_meta: mapSelected[index]?.search_meta}, candidatesScore)
const getRowMatchQualityLabel = index => {
const qualityBucket = getRowScoreDetails(index).qualityBucket
return qualityBucket ? startCase(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) => {
Expand Down Expand Up @@ -897,42 +916,156 @@ const MapProject = () => {
cols.push({
field: '_targetCode_',
headerName: t('map_project.target_code'),
width: columnWidth['_targetCode_'] || 300,
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 <Concept key={`${params.row.__index}-${targetConcept.id}`} sx={{padding: 0}} repoVersion={repoVersion} notClickable firstChild concept={targetConcept} noScore onCardClick={false} noSynonymPrefix asTarget />
return (
<span style={{display: 'flex', alignItems: 'flex-start', gap: '6px', width: '100%'}}>
{
aiMeta?.status === 0 ?
<Tooltip title={t('map_project.ai_recommendation_running')}>
<span style={{display: 'inline-flex', paddingTop: '7px'}}>
<CircularProgress size={14} />
</span>
</Tooltip> :
aiMeta?.status === -2 ?
<Tooltip title={t('map_project.ai_recommendation_failed_retry')}>
<IconButton size='small' sx={{padding: '4px', marginTop: '3px'}} onClick={e => { e.stopPropagation(); fetchRecommendation(params.row) }}>
<AssistantIcon color='error' fontSize='small' />
</IconButton>
</Tooltip> :
aiMeta?.latestAnalysis ?
<Tooltip title={aiMeta.isPrimaryMatch ? t('map_project.ai_recommended') : (aiMeta.isAlternateMatch ? t('map_project.ai_alternate') : t('map_project.ai_analysis'))}>
<span style={{display: 'inline-flex', paddingTop: '7px'}}>
<AssistantIcon color={aiMeta.isPrimaryMatch ? 'primary' : 'warning'} fontSize='small' />
</span>
</Tooltip> :
null
}
<span style={{minWidth: 0, flex: 1}}>
<Concept key={`${params.row.__index}-${targetConcept.id}`} sx={{padding: 0}} repoVersion={repoVersion} notClickable firstChild concept={targetConcept} noScore onCardClick={false} noSynonymPrefix asTarget />
</span>
</span>
)
}
}
})
if(AI_ASSISTANT_API_URL) {
cols.push({
field: '_aiRecommendStatus_',
headerName: '',
width: 48,
sortable: false,
filterable: false,
disableColumnMenu: true,
renderCell: params => {
const status = rowStageRef.current[params.row.__index]?.recommend
if(status === 0)
return (
<Tooltip title={t('map_project.ai_recommendation_running')}>
<CircularProgress size={16} />
</Tooltip>
)
if(status === -2)
return (
<Tooltip title={t('map_project.ai_recommendation_failed_retry')}>
<IconButton size='small' onClick={e => { e.stopPropagation(); fetchRecommendation(params.row) }}>
<AssistantIcon color='error' fontSize='small' />
</IconButton>
</Tooltip>
)
cols.push({
field: '_rowState_',
headerName: t('map_project.state'),
width: columnWidth['_rowState_'] || 130,
valueGetter: (_, row) => getRowStateLabel(row.__index),
renderCell: params => {
const state = VIEWS[getStateFromIndex(params.row.__index)]
return (
<Chip
size='small'
icon={state?.icon}
label={state?.label || ''}
color={state?.color || 'default'}
variant='outlined'
/>
)
}
})
cols.push({
field: '_matchQuality_',
Comment thread
snyaggarwal marked this conversation as resolved.
headerName: t('map_project.group_by_match_quality'),
width: columnWidth['_matchQuality_'] || 160,
align: 'left',
headerAlign: 'left',
valueGetter: (_, row) => getRowMatchQualityLabel(row.__index),
renderCell: params => {
const details = getRowScoreDetails(params.row.__index)
const label = getRowMatchQualityLabel(params.row.__index)
return (
<Chip
size='small'
label={label}
sx={details.qualityBucket ? {backgroundColor: SCORES_COLOR[details.qualityBucket], color: TEXT_GRAY} : undefined}
variant={details.qualityBucket ? 'filled' : 'outlined'}
/>
)
}
})
cols.push({
field: '_bestScore_',
headerName: t('search.search_score'),
width: columnWidth['_bestScore_'] || 130,
align: 'left',
headerAlign: 'left',
type: 'number',
valueGetter: (_, row) => getRowScoreDetails(row.__index).percentile,
renderCell: params => {
const details = getRowScoreDetails(params.row.__index)
return details.rerankScore || ''
}
})
cols.push({
field: '_rawScore_',
headerName: t('search.search_raw_score'),
width: columnWidth['_rawScore_'] || 130,
align: 'left',
headerAlign: 'left',
type: 'number',
valueGetter: (_, row) => getRowScoreDetails(row.__index).score,
renderCell: params => {
const details = getRowScoreDetails(params.row.__index)
return details.algoScore || ''
}
})
cols.push({
field: '_mapType_',
headerName: t('map_project.map_type'),
width: columnWidth['_mapType_'] || 150,
valueGetter: (_, row) => mapSelected[row.__index] ? (mapTypes[row.__index] || '') : '',
renderCell: params => {
if(!mapSelected[params.row.__index])
return null
}
})
}
const mapType = mapTypes[params.row.__index]
return mapType ? (
<Chip
size='small'
label={mapType}
variant='outlined'
color='primary'
/>
) : null
}
})
cols.push({
field: '_algorithms_',
headerName: t('map_project.algorithms'),
width: columnWidth['_algorithms_'] || 220,
sortable: false,
valueGetter: (_, row) => {
const targetConcept = getConcept(mapSelected[row.__index])
const contributingAlgorithms = targetConcept?.search_meta?.contributing_algorithms || []
const contributingAlgorithmIds = targetConcept?.search_meta?.contributing_algorithm_ids || targetConcept?.contributingAlgorithmIds || []
return compact([
...contributingAlgorithms.map(algo => algo?.algorithm_id),
...contributingAlgorithmIds,
targetConcept?.search_meta?.algorithm
]).join(', ')
},
renderCell: params => {
const targetConcept = getConcept(mapSelected[params.row.__index])
const contributingAlgorithms = targetConcept?.search_meta?.contributing_algorithms || []
const contributingAlgorithmIds = targetConcept?.search_meta?.contributing_algorithm_ids || targetConcept?.contributingAlgorithmIds || []
const algoIds = uniq(compact([
...contributingAlgorithms.map(algo => algo?.algorithm_id),
...contributingAlgorithmIds,
targetConcept?.search_meta?.algorithm
]))
return (
<span style={{display: 'inline-flex', gap: '4px', flexWrap: 'wrap', padding: '4px 0'}}>
{algoIds.map(algoId => <Chip key={algoId} size='small' label={algoId} variant='outlined' color='warning' />)}
</span>
)
}
})
return cols
}

Expand Down Expand Up @@ -3446,19 +3579,14 @@ const MapProject = () => {
// AI chip state as the current row. Fall back to the latest analysis
// available for this row when callers don't supply explicit ids.
const getCurrentRowAIIds = () => {
const rowAnalysis = analysis?.[rowIndex]
const analysisArray = Array.isArray(rowAnalysis) ? rowAnalysis : (rowAnalysis ? [rowAnalysis] : [])
const selectedAnalysis = analysisArray[analysisArray.length - 1]
if(!selectedAnalysis) return { recommendedId: undefined, alternateIds: [] }

const primary = selectedAnalysis?.output?.primary_candidate || selectedAnalysis?.primary_candidate
const recommendedId = resolveAICandidateID(primary, conceptCacheRef.current)
const alternateCandidates = selectedAnalysis?.output?.alternative_candidates || selectedAnalysis?.alternative_candidates || []
const alternateIds = [...new Set(alternateCandidates
.map(candidate => resolveAICandidateID(candidate, conceptCacheRef.current))
.filter(id => id && id !== recommendedId))]

return { recommendedId, alternateIds }
const { primaryCandidateId, alternateCandidateIds } = getAIAnalysisCandidateIDs(
analysis?.[rowIndex],
conceptCacheRef.current
)
return {
recommendedId: primaryCandidateId ?? undefined,
alternateIds: alternateCandidateIds
}
}

const openConceptPanel = (concept, options = {}) => {
Expand Down
13 changes: 13 additions & 0 deletions src/components/map-projects/MapProject.scss
Original file line number Diff line number Diff line change
Expand Up @@ -17,3 +17,16 @@
.unmatched-row {
border-left: 8px solid #bdbdbd;
}

.MuiDataGrid-panel .MuiDataGrid-filterFormValueInput .MuiChip-root {
background-color: #e5e1e6 !important;
color: #47464f !important;
}

.MuiDataGrid-panel .MuiDataGrid-filterFormValueInput .MuiChip-root .MuiChip-label {
color: #47464f !important;
}

.MuiDataGrid-panel .MuiDataGrid-filterFormValueInput .MuiChip-root .MuiChip-deleteIcon {
color: #76758b !important;
}
35 changes: 34 additions & 1 deletion src/components/map-projects/__tests__/viewHelpers.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,9 @@ import {
conceptBelongsToTargetRepo,
getScoreDetails,
conceptForMapping,
resolveAICandidateID
resolveAICandidateID,
getLatestAnalysisEntry,
getAIAnalysisCandidateIDs
} from '../viewBuilders.js'

const candidatesScore = { recommended: 80, available: 60 }
Expand Down Expand Up @@ -225,3 +227,34 @@ test('resolveAICandidateID: concept_key present but not in cache falls through t
test('resolveAICandidateID: completely unidentified candidate returns null', () => {
assert.equal(resolveAICandidateID({}, {}), null)
})

test('getLatestAnalysisEntry: returns the last entry from analysis history', () => {
const analysis = [{output: {recommendation: 'first'}}, {output: {recommendation: 'latest'}}]
assert.deepEqual(getLatestAnalysisEntry(analysis), analysis[1])
assert.equal(getLatestAnalysisEntry(null), null)
})

test('getAIAnalysisCandidateIDs: resolves primary + alternate ids from latest analysis entry', () => {
const analysis = [
{output: {primary_candidate: {canonical_reference: {code: 'OLD'}}}},
{
output: {
primary_candidate: {concept_key: 'k1'},
alternative_candidates: [
{canonical_reference: {code: 'ALT-1'}},
{concept_key: 'k2'},
{canonical_reference: {code: 'ALT-1'}},
]
}
}
]
const conceptCache = {
k1: {reference: {code: 'PRIMARY'}},
k2: {reference: {code: 'ALT-2'}},
}

const out = getAIAnalysisCandidateIDs(analysis, conceptCache)
assert.equal(out.primaryCandidateId, 'PRIMARY')
assert.deepEqual(out.alternateCandidateIds, ['ALT-1', 'ALT-2'])
assert.equal(out.latestAnalysis, analysis[1])
})
19 changes: 19 additions & 0 deletions src/components/map-projects/viewBuilders.js
Original file line number Diff line number Diff line change
Expand Up @@ -488,3 +488,22 @@ export const resolveAICandidateID = (candidate, conceptCache) => {
return conceptCache[candidate.concept_key].reference.code
return candidate.canonical_reference?.code || null
}

export const getLatestAnalysisEntry = (analysisProp) => {
if(Array.isArray(analysisProp)) return analysisProp[analysisProp.length - 1]
return analysisProp || null
}

export const getAIAnalysisCandidateIDs = (analysisProp, conceptCache) => {
const latestAnalysis = getLatestAnalysisEntry(analysisProp)
const output = latestAnalysis?.output || latestAnalysis
const primaryCandidateId = resolveAICandidateID(output?.primary_candidate, conceptCache)
const alternateCandidateIds = uniq((output?.alternative_candidates || [])
.map(candidate => resolveAICandidateID(candidate, conceptCache))
.filter(id => id && id !== primaryCandidateId))
return {
latestAnalysis,
primaryCandidateId,
alternateCandidateIds
}
}
1 change: 1 addition & 0 deletions src/i18n/locales/en/translations.json
Original file line number Diff line number Diff line change
Expand Up @@ -442,6 +442,7 @@
"not_recommended": "Not Recommended",
"available": "Available",
"recommended": "Recommended",
"state": "State",
"unranked": "Unranked",
"low_ranked": "Low Ranked",
"field_configuration": "Field Configuration",
Expand Down
Loading