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": "字段配置",