diff --git a/src/components/map-projects/Candidates.jsx b/src/components/map-projects/Candidates.jsx index 1aadd65..c08d9ad 100644 --- a/src/components/map-projects/Candidates.jsx +++ b/src/components/map-projects/Candidates.jsx @@ -54,7 +54,7 @@ import { conceptBelongsToTargetRepo, sortRowViews, conceptForMapping, - resolveAICandidateID + getAIAnalysisCandidateIDs } from './viewBuilders.js' const getRowProgressLabel = (stageMap, algos) => { @@ -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; diff --git a/src/components/map-projects/MapProject.jsx b/src/components/map-projects/MapProject.jsx index 0fb22b7..897609f 100644 --- a/src/components/map-projects/MapProject.jsx +++ b/src/components/map-projects/MapProject.jsx @@ -76,7 +76,7 @@ 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'; @@ -84,7 +84,7 @@ 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' @@ -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' @@ -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) => { @@ -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 + return ( + + { + aiMeta?.status === 0 ? + + + + + : + aiMeta?.status === -2 ? + + { e.stopPropagation(); fetchRecommendation(params.row) }}> + + + : + aiMeta?.latestAnalysis ? + + + + + : + null + } + + + + + ) } } }) - 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 ( - - - - ) - if(status === -2) - return ( - - { e.stopPropagation(); fetchRecommendation(params.row) }}> - - - - ) + 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 ( + + ) + } + }) + cols.push({ + field: '_matchQuality_', + 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 ( + + ) + } + }) + 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 ? ( + + ) : 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 ( + + {algoIds.map(algoId => )} + + ) + } + }) return cols } @@ -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 = {}) => { diff --git a/src/components/map-projects/MapProject.scss b/src/components/map-projects/MapProject.scss index ed9cc41..d171fa0 100644 --- a/src/components/map-projects/MapProject.scss +++ b/src/components/map-projects/MapProject.scss @@ -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; +} diff --git a/src/components/map-projects/__tests__/viewHelpers.test.js b/src/components/map-projects/__tests__/viewHelpers.test.js index 2088127..2412bfb 100644 --- a/src/components/map-projects/__tests__/viewHelpers.test.js +++ b/src/components/map-projects/__tests__/viewHelpers.test.js @@ -15,7 +15,9 @@ import { conceptBelongsToTargetRepo, getScoreDetails, conceptForMapping, - resolveAICandidateID + resolveAICandidateID, + getLatestAnalysisEntry, + getAIAnalysisCandidateIDs } from '../viewBuilders.js' const candidatesScore = { recommended: 80, available: 60 } @@ -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]) +}) diff --git a/src/components/map-projects/viewBuilders.js b/src/components/map-projects/viewBuilders.js index e5ecad8..719a3dd 100644 --- a/src/components/map-projects/viewBuilders.js +++ b/src/components/map-projects/viewBuilders.js @@ -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 + } +} diff --git a/src/i18n/locales/en/translations.json b/src/i18n/locales/en/translations.json index 3c887d5..566fde8 100644 --- a/src/i18n/locales/en/translations.json +++ b/src/i18n/locales/en/translations.json @@ -442,6 +442,7 @@ "not_recommended": "Not Recommended", "available": "Available", "recommended": "Recommended", + "state": "State", "unranked": "Unranked", "low_ranked": "Low Ranked", "field_configuration": "Field Configuration", diff --git a/src/i18n/locales/es/translations.json b/src/i18n/locales/es/translations.json index deee65d..355ebc3 100644 --- a/src/i18n/locales/es/translations.json +++ b/src/i18n/locales/es/translations.json @@ -407,6 +407,7 @@ "not_recommended": "No Recomendado", "available": "Disponible", "recommended": "Recomendado", + "state": "Estado", "unranked": "Sin Clasificar", "low_ranked": "De baja clasificación", "field_configuration": "Configuración de Campo", diff --git a/src/i18n/locales/zh/translations.json b/src/i18n/locales/zh/translations.json index 6aaafab..3148e4e 100644 --- a/src/i18n/locales/zh/translations.json +++ b/src/i18n/locales/zh/translations.json @@ -432,6 +432,7 @@ "not_recommended": "不推荐", "available": "可用", "recommended": "推荐", + "state": "状态", "unranked": "未排名", "low_ranked": "低排名", "field_configuration": "字段配置",