diff --git a/src/common/colors.jsx b/src/common/colors.jsx index d071952..f9b1e3a 100644 --- a/src/common/colors.jsx +++ b/src/common/colors.jsx @@ -9,6 +9,10 @@ export const VERY_LIGHT_GRAY = '#5e5c71' export const RECOMMEND_COLOR = 'rgb(172,212,182)' export const AVAILABLE_COLOR = 'rgb(250,224,148)' export const UNRANKED_COLOR = 'rgb(235, 162, 150)' +// Neutral gray for candidates that haven't been ranked yet (rerank pending). +// Distinct from UNRANKED_COLOR (which is misnamed historically — it's the +// pink used for the low-ranked / not-recommended bucket). +export const PENDING_RERANK_COLOR = 'rgb(189, 189, 189)' export const PRIMARY_COLORS = { main: '#4836ff', light: '#4836ff', diff --git a/src/components/map-projects/AdvancedSettings.jsx b/src/components/map-projects/AdvancedSettings.jsx new file mode 100644 index 0000000..e5f7f05 --- /dev/null +++ b/src/components/map-projects/AdvancedSettings.jsx @@ -0,0 +1,40 @@ +import React from 'react' +import { useTranslation } from 'react-i18next' +import TextField from '@mui/material/TextField' +import Collapse from '@mui/material/Collapse' +import Button from '@mui/material/Button' +import UpIcon from '@mui/icons-material/ArrowDropUp'; +import DownIcon from '@mui/icons-material/ArrowDropDown'; + + +const AdvancedSettings = ({ namespaceValue, setNamespace, defaultNamespace }) => { + const { t } = useTranslation() + const [open, setOpen] = React.useState(false) + + return ( +
+ + +
+ setNamespace(event.target.value || '')} + placeholder={defaultNamespace} + helperText={t('map_project.resolution_namespace_description', { + owner: defaultNamespace || 'the project owner', + defaultValue: 'Namespace passed to $resolveReference. When blank, defaults to {{owner}}. Drives which URL Registry entries apply when resolving canonical URLs.' + })} + /> +
+
+
+ ) +} + +export default AdvancedSettings; diff --git a/src/components/map-projects/Candidates.jsx b/src/components/map-projects/Candidates.jsx index 4fa61d9..5bd4736 100644 --- a/src/components/map-projects/Candidates.jsx +++ b/src/components/map-projects/Candidates.jsx @@ -26,20 +26,13 @@ import RefreshIcon from '@mui/icons-material/Refresh'; import SortIcon from '@mui/icons-material/Sort'; import GroupIcon from '@mui/icons-material/Layers'; -import find from 'lodash/find' import isEmpty from 'lodash/isEmpty' import flatten from 'lodash/flatten' import values from 'lodash/values' -import forEach from 'lodash/forEach' -import uniq from 'lodash/uniq' import without from 'lodash/without' import orderBy from 'lodash/orderBy' import map from 'lodash/map' -import compact from 'lodash/compact' -import every from 'lodash/every' import times from 'lodash/times' -import filter from 'lodash/filter' -import omit from 'lodash/omit' import { highlightTexts } from '../../common/utils'; import { PRIMARY_COLORS } from '../../common/colors' @@ -53,6 +46,13 @@ import ConceptIcon from '../concepts/ConceptIcon' import MapButton from './MapButton' import AICandidatesAnalysis from './AICandidatesAnalysis' import AIAssistantButton from './AIAssistantButton' +import { + buildAlgorithmRowViews, + buildQualityRowViews, + sortRowViews, + conceptForMapping, + resolveAICandidateID +} from './viewBuilders.js' const getRowProgressLabel = (stageMap, algos) => { if(stageMap === undefined) @@ -108,10 +108,10 @@ const Sort = ({ selected, onSort }) => { onClose={() => setAnchorEl(false)} sx={{'.MuiPaper-root': {backgroundColor: 'surface.n94'}}} > - onClick('search_meta.search_normalized_score')} selected={selected === 'search_meta.search_normalized_score'}> + onClick('rerank_score')} selected={selected === 'rerank_score'}> - onClick('search_meta.search_score')} selected={selected === 'search_meta.search_score'}> + onClick('algo_score')} selected={selected === 'algo_score'}> @@ -183,8 +183,39 @@ const SubHeader = ({count, onClick, isCollapsed, header, indicatorColor, isFirst } -const CandidateList = ({candidates, header, rowIndex, orderBy, order, setShowItem, showItem, setShowHighlights, isSelectedForMap, onMap, onFetchMore, bgColor, bucketId, display, onDisplayChange, noToolbar, toolbarControl, repoVersion, alignToolbarLeft, rightControl, analysis, showAnalysis, openAnalysis, onCloseAnalysis, AIRecommendedCandidateId, locales, scispacy, showAlgo, collapsed, onCollapse, candidatesScore, algoScoreFirst, conceptCache, byAlgorithm, isFirst, isCoreUser}) => { - const results = {total: onFetchMore ? candidates?.length : 1, results: candidates || []} +const CandidateList = ({rowViews, header, rowIndex, sortBy, order, setShowItem, showItem, setShowHighlights, isSelectedForMap, onMap, onFetchMore, bgColor, bucketId, display, onDisplayChange, noToolbar, toolbarControl, repoVersion, alignToolbarLeft, rightControl, analysis, showAnalysis, openAnalysis, onCloseAnalysis, AIRecommendedCandidateId, locales, scispacy, showAlgo, collapsed, onCollapse, candidatesScore, algoScoreFirst, byAlgorithm, isFirst, isCoreUser, targetCanonical}) => { + // 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 + // ALL_COLUMNS['concepts'] paths. Spread conceptForMapping first so + // those fields exist at top level. + // - Card view: the renderer reads candidate/conceptDefinition/conceptRow. + // Spread the rowView after legacy so the tuple fields survive. + // - Click lookup: SearchResults.handleRowClick uses + // `row.version_url || row.url || row.id`. Set those explicitly at the + // end so neither earlier spread can clobber them. + const rowsForTable = (rowViews || []).map(view => { + const legacy = conceptForMapping(view) || {} + const def = view?.conceptDefinition + const idForLookup = legacy.id || def?.ocl_url || def?.reference?.code + return { + ...legacy, + ...view, + id: idForLookup, + url: legacy.url || def?.ocl_url, + version_url: legacy.url || def?.ocl_url + } + }) + // Helper: a rowView is mappable only when its concept belongs to the + // project's target repo (cascade targets in bridge flows DO; bridge + // intermediaries don't). targetCanonical comes from buildProjectContext + // in MapProject — the same canonical the normalizer used to stamp the + // ConceptDefinition.reference.url, so the comparison is exact. + const isTargetRepoView = (view) => { + if(!targetCanonical) return true + return view?.conceptDefinition?.reference?.url === targetCanonical + } + const results = {total: onFetchMore ? rowsForTable.length : 1, results: rowsForTable} const isCollapsed = collapsed.includes(bucketId) const onCollapseToggle = () => { onCollapse(isCollapsed ? without(collapsed, bucketId): [...collapsed, bucketId]) @@ -197,13 +228,18 @@ const CandidateList = ({candidates, header, rowIndex, orderBy, order, setShowIte id: 'mappings', labelKey: 'common.action', align: 'center', - renderer: item => { + renderer: rowView => { + // Map action only renders for target-repo concepts. Bridge + // intermediaries (CIEL when target is ICD/LOINC) are reference + // metadata about the cascade — they're not mappable themselves. + if(!isTargetRepoView(rowView)) return null + const conceptToMap = conceptForMapping(rowView) return ( onMap(event, item, !applied, mapType)} - isMapped={isSelectedForMap(item)} + selected={rowView?.candidate?.map_type} + onClick={(event, applied, mapType) => onMap(event, conceptToMap, !applied, mapType)} + isMapped={isSelectedForMap(conceptToMap)} sx={{marginLeft: '8px'}} /> ) @@ -216,13 +252,27 @@ const CandidateList = ({candidates, header, rowIndex, orderBy, order, setShowIte sortable: false, id: 'mappings', labelKey: 'mapping.same_as_mappings', - renderer: item => , + renderer: rowView => { + // Bridge mappings panel: list bridge_children as cascade-style + // mappings. For non-bridge rows the panel is empty. + const bridgeChildren = rowView?.bridgeChildren || [] + if(!bridgeChildren.length) return null + const synthetic = { + mappings: bridgeChildren.map(child => ({ + map_type: child.candidate?.map_type, + cascade_target_source_name: child.conceptDefinition?.source, + cascade_target_concept_name: child.conceptDefinition?.display_name, + to_concept_code: child.conceptDefinition?.id || child.conceptDefinition?.reference?.code + })) + } + return + }, }, ...cols ] return cols } - const count = candidates.length + const count = rowViews.length const showHeader = byAlgorithm || count > 0 return (
    @@ -260,7 +310,26 @@ const CandidateList = ({candidates, header, rowIndex, orderBy, order, setShowIte ) } title=' ' - renderer={props => } + renderer={props => { + const rowView = props?.concept + const key = `${bucketId}-${rowView?.candidate?.id || rowView?.conceptDefinition?.key}-${Math.random(100).toString()}` + return + }} display={display} onDisplayChange={onDisplayChange} nested @@ -273,13 +342,13 @@ const CandidateList = ({candidates, header, rowIndex, orderBy, order, setShowIte toolbarControl={toolbarControl} alignToolbarLeft={alignToolbarLeft} rightControl={rightControl} - orderBy={orderBy} + orderBy={sortBy} order={order} resultContainerStyle={{height: 'auto', '.MuiTable-root': {tableLayout: 'fixed'}}} - onShowItemSelect={item => { - setShowItem(item) + onShowItemSelect={rowView => { + setShowItem(conceptForMapping(rowView)) setTimeout(() => { - highlightTexts([item], null, false) + highlightTexts([rowView?.conceptDefinition], null, false) }, 100) }} selectedToShow={showItem} @@ -292,9 +361,16 @@ const CandidateList = ({candidates, header, rowIndex, orderBy, order, setShowIte ) } -const Candidates = ({rowIndex, alert, setAlert, candidates, setShowItem, showItem, setShowHighlights, isSelectedForMap, onMap, onFetchMore, isLoading, candidatesScore, repoVersion, analysis, onFetchRecommendation, appliedFacets, setAppliedFacets, filters, facets, columns, defaultFilters, locales, models, selectedModel, onModelChange, promptTemplates, promptTemplate, onPromptTemplateChange, onRefreshClick, rowStage, inAIAssistantGroup, algosSelected, conceptCache, isCoreUser}) => { +// Candidates reads exclusively from the unified-model state: +// rowState — RowState for `rowIndex` (algorithm_responses, candidates, +// concept_rows). Provided by MapProject via +// rowMatchStateRef.current[rowIndex]. +// 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, alert, setAlert, rowState, conceptCache, targetCanonical, setShowItem, showItem, setShowHighlights, isSelectedForMap, onMap, onFetchMore, isLoading, candidatesScore, repoVersion, analysis, onFetchRecommendation, appliedFacets, setAppliedFacets, filters, facets, columns, defaultFilters, locales, models, selectedModel, onModelChange, promptTemplates, promptTemplate, onPromptTemplateChange, onRefreshClick, rowStage, inAIAssistantGroup, algosSelected, isCoreUser}) => { const { t } = useTranslation(); - const [sortBy, setSortBy] = React.useState('search_meta.search_normalized_score') + const [sortBy, setSortBy] = React.useState('rerank_score') const [groupBy, setGroupBy] = React.useState('quality') const [collapsed, setCollapsed] = React.useState([`${rowIndex}-low-ranked`]) const [openFilters, setOpenFilters] = React.useState(false) @@ -302,25 +378,44 @@ const Candidates = ({rowIndex, alert, setAlert, candidates, setShowItem, showIte const [openAIAnalysis, setOpenAIAnalysis] = React.useState(undefined) const recommendedScore = candidatesScore?.recommended const availableScore = candidatesScore?.available - const rawResults = flatten(map(candidates, _candidates => find(_candidates, c => c.row?.__index === rowIndex)?.results)) - let allCandidates = compact(rawResults) - const isNoneLoaded = every(rawResults, r => r === null) - const canFetchMore = allCandidates?.length > 0 - let AIRecommendedCandidateId = analysis?.output?.primary_candidate?.concept_id || analysis?.primary_candidate?.concept_id - const algoStagesValue = values(omit(rowStage, 'recommend')) - const areAlgoRun = uniq(algoStagesValue).length === 1 && algoStagesValue[0] === 1 + // Resolve the AI-recommended candidate against conceptCache: prefer the + // v2 concept_key passthrough, then canonical_reference.code (PR2a shim), + // then the legacy concept_id/id. The resolved code is matched against + // ConceptDefinition.reference.code in Concept.jsx for highlighting. + const primary = analysis?.output?.primary_candidate || analysis?.primary_candidate + const AIRecommendedCandidateId = resolveAICandidateID(primary, conceptCache) + + // Quality (score-grouped) view shows ONLY target-repo concepts. Bridge + // intermediaries live in algorithm view as metadata-about-the-target; + // surfacing them in Quality view double-counts and confuses the bucketing. + // Algorithm view (buildAlgorithmRowViews) keeps the full mix for + // by-algorithm browsing. + const qualityRowViews = React.useMemo(() => { + const views = buildQualityRowViews(rowState, conceptCache) + if(!targetCanonical) return views + return views.filter(v => v.conceptDefinition?.reference?.url === targetCanonical) + }, [rowState, conceptCache, targetCanonical]) + const hasAnyView = qualityRowViews.length > 0 + const isNoneLoaded = !rowState || (isEmpty(rowState.candidates) && isEmpty(rowState.algorithm_responses)) + const canFetchMore = hasAnyView + const algoStagesValue = values(rowStage || {}).filter((_, i) => Object.keys(rowStage || {})[i] !== 'recommend') + const areAlgoRun = algoStagesValue.length > 0 && algoStagesValue.every(v => v === 1) const { label } = getRowProgressLabel(rowStage, algosSelected); - const byScore = sortBy.includes('score') - const noCandidatesFound = !isLoading && !isNoneLoaded && allCandidates.length === 0 && !label - const algoScoreFirst = sortBy === 'search_meta.search_score' - let props = { + const byAlgoScore = sortBy === 'algo_score' + const byRerankScore = sortBy === 'rerank_score' + const byScore = byAlgoScore || byRerankScore + const noCandidatesFound = !isLoading && !isNoneLoaded && !hasAnyView && !label + const algoScoreFirst = byAlgoScore + const order = byScore ? 'desc' : 'asc' + + const baseProps = { rowIndex: rowIndex, onMap: onMap, isSelectedForMap: isSelectedForMap, setShowHighlights: setShowHighlights, - orderBy: sortBy, - order: !byScore ? 'asc' : 'desc', + sortBy: sortBy, + order: order, setShowItem: setShowItem, showItem: showItem, isLoading: isLoading, @@ -330,19 +425,21 @@ const Candidates = ({rowIndex, alert, setAlert, candidates, setShowItem, showIte locales: locales, candidatesScore: candidatesScore, algoScoreFirst: algoScoreFirst, - conceptCache: conceptCache, - isCoreUser: isCoreUser + isCoreUser: isCoreUser, + targetCanonical: targetCanonical } const onSort = option => { - if(option === sortBy || option === 'search_meta.search_normalized_score') { - setSortBy('search_meta.search_normalized_score') - } else if(option === 'search_meta.search_score') { - setSortBy(option) + // Plain "click to select" — no implicit fallbacks. The previous + // `option === sortBy` branch silently flipped any same-option click + // back to 'rerank_score', so re-clicking Raw in algo view kicked + // the user into Unified mode. Picking Raw still implies the algo + // view (raw scores aren't comparable across algos in the Quality + // grouping), so that side-effect stays. + if(!option) return + setSortBy(option) + if(option === 'algo_score' && groupBy !== 'algorithm') setGroupBy('algorithm') - } else { - setSortBy(option) - } } const onRecommend = () => { @@ -353,67 +450,62 @@ const Candidates = ({rowIndex, alert, setAlert, candidates, setShowItem, showIte const onGroup = option => { setGroupBy(option) if(option === 'algorithm') - setSortBy('search_meta.search_score') + setSortBy('algo_score') if(!option || option === 'quality') - setSortBy('search_meta.search_normalized_score') + setSortBy('rerank_score') } + const getCandidates = () => { - const order = byScore ? 'desc' : 'asc' if(groupBy === 'algorithm') { - let byAlgoCandidates = [] const sortedAlgos = orderBy( algosSelected.map(algo => { - const results = flatten( - map( - filter(candidates[algo.id] || [], c => c?.row?.__index === rowIndex), - 'results' - ) - ) || []; - - return { - algo, - results, - hasCandidates: results.length > 0, - }; + const views = buildAlgorithmRowViews(rowState, conceptCache, algo.id) + return { algo, views, hasCandidates: views.length > 0 } }), ['hasCandidates', 'algo.order'], ['desc', 'asc'] - ); - forEach(sortedAlgos, ({ algo, results }) => { - byAlgoCandidates.push({ - algo, - candidates: orderBy(results, sortBy, order), - }); - }); - - return { - byAlgoCandidates - } - } else { - let recommended = [] - let available = [] - let lowRanked = [] - forEach(orderBy(allCandidates, sortBy, order), concept => { - let score = concept?.search_meta?.search_normalized_score || 0 - if(byScore) { - if (score >= recommendedScore) - recommended.push(concept) - else if (score >= availableScore) - available.push(concept) - else - lowRanked.push(concept) - } else { - available.push(concept) - } - }) + ) return { - recommended, available, lowRanked + byAlgoCandidates: sortedAlgos.map(({algo, views}) => ({ + algo, + // Sort the top-level (bridge intermediaries and standard candidates) + // and, for each bridge, sort its nested cascade targets by the same + // key/order so the children's order tracks the parent rule. Bridge + // children don't have their own raw score; sorting by algo_score + // leaves them in insertion order via sortRowViews' -1 sentinel. + candidates: sortRowViews(views, sortBy, order).map(view => ( + view.type === 'bridge' && view.bridgeChildren?.length + ? { ...view, bridgeChildren: sortRowViews(view.bridgeChildren, sortBy, order) } + : view + )) + })) } } + let recommended = [] + let available = [] + let lowRanked = [] + let pendingRerank = [] + sortRowViews(qualityRowViews, sortBy, order).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 } = getCandidates() - const { recommended, available, lowRanked } = getCandidates() + const { byAlgoCandidates, recommended, available, lowRanked, pendingRerank } = getCandidates() + const getRightControls = () => { return ( @@ -421,16 +513,23 @@ const Candidates = ({rowIndex, alert, setAlert, candidates, setShowItem, showIte !areAlgoRun && label && } variant='outlined' color='warning' size='small' label={label} sx={{margin: '0 8px'}} /> } + { + // Refresh stays visible even when there are zero candidates so + // the user can retry an algorithm that errored out (e.g. scispacy + // returns 503 during its 2-5 min cold-start; previously the + // toolbar disappeared and the row had no recovery path). + // Group/Sort only render when there are candidates to organize. + } + + + + + + + { !noCandidatesFound && <> - - - - - - - @@ -520,8 +619,8 @@ const Candidates = ({rowIndex, alert, setAlert, candidates, setShowItem, showIte <>
  • : : 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 && + + }
  • } @@ -608,9 +731,9 @@ const Candidates = ({rowIndex, alert, setAlert, candidates, setShowItem, showIte return (
  • { + const def = entry?.bridgeConceptDefinition + if(!def) return '' + const code = def.id || def.reference?.code || '' + const head = `via ${def.source || ''}:${code} ${def.display_name || ''}`.trim() + return entry.map_type ? `${head} — ${entry.map_type}` : head +} + +// Inline map-type chip — sized to fit the surrounding text line-height so +// it doesn't disrupt vertical flow when embedded mid-sentence (e.g. +// "CIEL:1234 ... [SAME-AS] LOINC:52767-1 ..."). +const MAP_TYPE_CHIP_SX = { + height: '18px', + borderRadius: '4px', + margin: '0 6px', + verticalAlign: 'baseline', + '.MuiChip-label': { + padding: '0 6px', + fontSize: '11px', + fontWeight: 500, + letterSpacing: '0.02em' + } +} const getBestSynonym = (synonyms = []) => { @@ -41,39 +68,24 @@ const getBestSynonym = (synonyms = []) => { }; -const Item = ({concept, setShowHighlights, onMap, isSelectedForMap, noScore, repoVersion, synonymPrefix, isAIRecommended, bridge, bridgeChild, mapping, showAlgo, candidatesScore, algoScoreFirst, placeholderMap, conceptCache}) => { - const isValidBridge = Boolean(bridge && mapping.cascade_target_concept_code) - const findConceptInCache = resource => { - let id = resource?.id || resource?.code - if(id) { - let url = find(keys(conceptCache), url => url.endsWith(`/concepts/${id}/`)) - if(url) - return conceptCache[url] - } - return null - } - const conceptToMap = isValidBridge ? - { - id: mapping.cascade_target_concept_code, - name: mapping.cascade_target_concept_name, - display_name: mapping.cascade_target_concept_name, - url: mapping.cascade_target_concept_url, - source: mapping.cascade_target_source_name, - type: 'Concept', - search_meta: concept.search_meta - } : concept - const cached = findConceptInCache(conceptToMap) - if(cached) - isValidBridge ? conceptToMap.target_concept = cached : conceptToMap._source = cached - const getConceptDisplay = () => { - if(bridge) { - if(conceptToMap?.target_concept?.id) - return `${conceptToMap.source || conceptToMap.target_concept.source}:${conceptToMap.target_concept.id} ${conceptToMap.target_concept.display_name}` - return `${conceptToMap.source}:${conceptToMap.id} ${conceptToMap.display_name || ''}` - } - } - let bridgeMappingPrefix = bridge && mapping.cascade_target_concept_code ? `${mapping.cascade_target_source_name}:${mapping.cascade_target_concept_code} ${mapping.cascade_target_concept_name || ''}` : false - const mapTypeToApply = isValidBridge ? mapping?.map_type || concept?.search_meta?.map_type : concept?.search_meta?.map_type +const Item = ({candidate, conceptDefinition, conceptRow, bridgeConceptDefinition, bridgeContributors, setShowHighlights, onMap, isSelectedForMap, noScore, repoVersion, synonymPrefix, isAIRecommended, showAlgo, candidatesScore, algoScoreFirst, placeholderMap, bridgeChild}) => { + const conceptToMap = conceptForMapping({candidate, conceptDefinition, conceptRow, bridgeConceptDefinition}) + const idLabel = conceptDefinition?.id || conceptDefinition?.reference?.code + const sourceLabel = conceptDefinition?.source + const mapTypeToApply = candidate?.map_type + const bridgePrefixLabel = bridgeConceptDefinition + ? `${bridgeConceptDefinition.source || ''}:${bridgeConceptDefinition.id || bridgeConceptDefinition.reference?.code} ${bridgeConceptDefinition.display_name || ''}`.trim() + : '' + // SearchHighlightsDialog reads concept.search_meta.search_highlight and + // calls getScoreDetails on the same shape. Project the tuple through + // conceptForMapping so the dialog gets the legacy concept shape it + // expects (search_meta.search_highlight comes from candidate.highlights). + const showHighlightsPayload = setShowHighlights + ? () => setShowHighlights(conceptToMap) + : null + const convergenceTooltip = (bridgeContributors || []).length + ? bridgeContributors.map(formatBridgeContributor).filter(Boolean).join('\n') + : '' return ( <> { - !bridgeChild && - {`${concept?.source || concept?.repo?.short_code || concept?.repo?.id || concept?.search_meta?.source}:${concept?.id}`} - } - { - !bridgeChild && - + bridgeChild ? ( + // Two cases: + // - Quality view (algoScoreFirst=false): render full cascade + // inline so the user sees both ends (CIEL:1234 [SAME-AS] + // LOINC:52767-1 ...). bridgePrefixLabel carries the + // intermediary's source:id name. + // - Algo view (algoScoreFirst=true): the parent bridge + // intermediary is already rendered as the ConceptItem + // above this nested child, so repeating its source:id + // name on every child line is noise. Suppress the prefix. + <> + {!algoScoreFirst && bridgePrefixLabel && ( + {bridgePrefixLabel} + )} + {candidate?.map_type && ( + + )} + + {`${sourceLabel || ''}:${idLabel} ${conceptDefinition?.display_name || ''}`.trim()} + + + ) : ( + <> + {`${sourceLabel || ''}:${idLabel}`} + + { + !bridgeConceptDefinition && synonymPrefix && + + + + + } + {conceptDefinition?.display_name} + { - !bridge && synonymPrefix && - - + // Legacy algo-view path: a target row with bridge + // metadata attached. Renders target → [maptype] → target + // (the duplication is preserved from the prior render — + // touched-up here only structurally). + bridgePrefixLabel && + + {candidate?.map_type ? `[${candidate.map_type}]` : ''} + + + {conceptDefinition?.display_name + ? `${sourceLabel || ''}:${idLabel} ${conceptDefinition.display_name}` + : ''} + } - {concept?.display_name} - - } - { - bridgeMappingPrefix && - - {!bridgeChild && } - - { - bridgeChild ? - : - (`[${mapping.map_type}]`) - } - - {!bridgeChild && } - {getConceptDisplay()} - + + ) } { - concept?.retired && + conceptDefinition?.retired && } @@ -122,12 +157,18 @@ const Item = ({concept, setShowHighlights, onMap, isSelectedForMap, noScore, rep secondary={
    - +
    { - showAlgo && concept?.search_meta?.algorithm ? -
    - + showAlgo && candidate?.algorithm_id ? +
    + + { + convergenceTooltip && + {convergenceTooltip}} placement='top'> + + + }
    : null }
    @@ -137,14 +178,23 @@ const Item = ({concept, setShowHighlights, onMap, isSelectedForMap, noScore, rep { !noScore && - + } { - isSelectedForMap && + isSelectedForMap && conceptToMap && onMap(event, conceptToMap, !applied, mapping?.map_type || mapType)} + onClick={(event, applied, mapType) => onMap(event, conceptToMap, !applied, candidate?.map_type || mapType)} isMapped={isSelectedForMap(conceptToMap)} sx={{marginLeft: '8px'}} /> @@ -183,119 +233,194 @@ const ConceptItem = ({_id, notClickable, isSelectedToShow, firstChild, lastChild } -const Concept = ({_id, firstChild, lastChild, concept, setShowHighlights, isShown, onCardClick, onMap, isSelectedForMap, noScore, repoVersion, isAIRecommended, AIRecommendedCandidateId, sx, notClickable, noSynonymPrefix, locales, showAlgo, candidatesScore, algoScoreFirst, asTarget, conceptCache}) => { - const bridge = concept?.search_meta?.algorithm?.includes('bridge') - const scispacy = concept?.search_meta?.algorithm === 'ocl-scispacy-loinc' - const id = concept?.version_url || concept?.url || concept?.id - const isSelectedToShow = isShown ? isShown(id) : false +// Wrap a legacy concept-shape object (id/display_name/url/search_meta) into +// a synthetic rowView so Concept renders identically whether it gets a +// unified-model tuple from Candidates or a mapSelected/searchedConcepts +// projection from the Target Code column / decision tables. The legacy +// callers don't have candidate/conceptRow context, so we synthesize +// minimal stand-ins from search_meta. +const legacyToRowView = (legacy) => { + if(!legacy || typeof legacy !== 'object') return null + const meta = legacy.search_meta || {} + return { + type: 'standard', + candidate: { + algorithm_id: meta.algorithm, + score: meta.search_score, + highlights: meta.search_highlight, + map_type: meta.map_type + }, + conceptDefinition: { + reference: legacy.reference, + ocl_url: legacy.url || legacy.ocl_url, + id: legacy.id, + display_name: legacy.display_name, + source: legacy.source || legacy.repo?.short_code || legacy.repo?.id, + owner: legacy.owner, + names: legacy.names, + descriptions: legacy.descriptions, + concept_class: legacy.concept_class, + datatype: legacy.datatype, + retired: legacy.retired, + properties: legacy.properties + }, + conceptRow: { + rerank_score: meta.search_normalized_score + } + } +} + +// `concept` here is normally a row view object built by Candidates.jsx from +// rowMatchState + conceptCache: +// { +// type: 'standard' | 'bridge' | 'bridge_child', +// candidate, conceptDefinition, conceptRow, +// bridgeConceptDefinition?, // when type='bridge_child' +// bridgeChildren? // when type='bridge' (algo view nested rendering) +// } +// Legacy callers (Target Code column, decision tables, search results) +// pass a flat concept-shape object instead — legacyToRowView wraps those +// so a single render path covers both worlds while PR3 cleanup is pending. +const Concept = ({_id, firstChild, lastChild, concept, setShowHighlights, isShown, onCardClick, onMap, isSelectedForMap, noScore, repoVersion, isAIRecommended, sx, notClickable, noSynonymPrefix, locales, showAlgo, candidatesScore, algoScoreFirst, asTarget, AIRecommendedCandidateId, targetCanonical}) => { + const rowView = concept?.conceptDefinition ? concept : legacyToRowView(concept) + if(!rowView?.conceptDefinition) return null + const { type, candidate, conceptDefinition, conceptRow, bridgeConceptDefinition, bridgeChildren, bridgeContributors } = rowView + // Map action is gated on canonical alignment with the project's target + // repo. Bridge intermediaries (e.g. CIEL when target is ICD) aren't + // mappable themselves — they're reference metadata about the cascade. + // Pass isSelectedForMap=false to Item so it renders the placeholder + // spacer instead of a MapButton. + const isMappable = !targetCanonical || conceptDefinition?.reference?.url === targetCanonical + const effectiveIsSelectedForMap = isMappable ? isSelectedForMap : false + const idForUI = conceptDefinition.ocl_url || conceptDefinition.id || conceptDefinition.reference?.code + const isSelectedToShow = isShown ? isShown(idForUI) : false let synonymPrefix = '' - const highlights = concept?.search_meta?.search_highlight + const highlights = candidate?.highlights const synonymHighlight = highlights?.synonyms const nameHighlight = highlights?.name if(!nameHighlight?.length && synonymHighlight?.length && !noSynonymPrefix) { let bestMatch = getBestSynonym(synonymHighlight) || synonymHighlight[0] - if(locales && bestMatch) { + if(locales && bestMatch && conceptDefinition?.names) { let raw = bestMatch.replaceAll("", "").replaceAll("", "") let _locales = isString(locales) ? locales.split(',') : locales - if(_locales?.length > 0 && !_locales.includes(concept.names.find(name => name.name.startsWith(raw))?.locale)) + if(_locales?.length > 0 && !_locales.includes(conceptDefinition.names.find(name => name.name.startsWith(raw))?.locale)) bestMatch = '' } - synonymPrefix = bestMatch.replaceAll('', "").replaceAll('', '') + synonymPrefix = (bestMatch || '').replaceAll('', "").replaceAll('', '') } - const props = { - id: id, + const baseProps = { + id: idForUI, _id: _id, notClickable: notClickable, firstChild: firstChild, lastChild: lastChild, isSelectedToShow: isSelectedToShow, sx: sx, - onCardClick: onCardClick, - conceptCache: conceptCache + onCardClick: onCardClick } - if(bridge) { + if(type === 'bridge') { + const isBridgeAIRecommended = AIRecommendedCandidateId && conceptDefinition.reference?.code === AIRecommendedCandidateId return ( <> - { - algoScoreFirst && - - } + { + algoScoreFirst && + + } { asTarget ? : - -
    - { - map(concept?.mappings, (mapping, index) => { - return - }) - } -
    +
    + { + map(bridgeChildren || [], (child, index) => { + const childAIRecommended = AIRecommendedCandidateId && child.conceptDefinition?.reference?.code === AIRecommendedCandidateId + return + }) + } +
    } ) } + // type === 'standard' or 'bridge_child' (the latter only when rendered + // directly, i.e. in the score-grouped view as the target concept). + const isAIMatch = AIRecommendedCandidateId && conceptDefinition.reference?.code === AIRecommendedCandidateId return + {...baseProps} + candidate={candidate} + conceptDefinition={conceptDefinition} + conceptRow={conceptRow} + bridgeConceptDefinition={bridgeConceptDefinition} + bridgeContributors={bridgeContributors} + repoVersion={repoVersion} + synonymPrefix={synonymPrefix} + setShowHighlights={setShowHighlights} + isAIRecommended={isAIRecommended || isAIMatch} + isSelectedForMap={effectiveIsSelectedForMap} + placeholderMap={!isMappable} + onMap={onMap} + noScore={noScore} + bridgeChild={type === 'bridge_child' && Boolean(bridgeConceptDefinition)} + showAlgo={showAlgo} + candidatesScore={candidatesScore} + algoScoreFirst={algoScoreFirst} + /> } export default Concept; diff --git a/src/components/map-projects/ConfigurationForm.jsx b/src/components/map-projects/ConfigurationForm.jsx index bd07a2f..bb161b4 100644 --- a/src/components/map-projects/ConfigurationForm.jsx +++ b/src/components/map-projects/ConfigurationForm.jsx @@ -18,10 +18,17 @@ import SettingsIcon from '@mui/icons-material/Settings'; import SaveIcon from '@mui/icons-material/Save'; import OpenInNewIcon from '@mui/icons-material/OpenInNew'; +import Stack from '@mui/material/Stack' +import Alert from '@mui/material/Alert' +import Tooltip from '@mui/material/Tooltip' +import InfoOutlinedIcon from '@mui/icons-material/InfoOutlined' + import isEmpty from 'lodash/isEmpty' import omit from 'lodash/omit' +import filter from 'lodash/filter' import { toV3URL } from '../../common/utils' +import { getProjectConfigErrors } from './algorithms' import NamespaceDropdown from '../common/NamespaceDropdown' import RepoSearchAutocomplete from '../repos/RepoSearchAutocomplete' import RepoVersionSearchAutocomplete from '../repos/RepoVersionSearchAutocomplete' @@ -32,6 +39,7 @@ import { SCORES_COLOR } from './constants' import FilterTable from './FilterTable' import MultiAlgoSelector from './MultiAlgoSelector' import LookupConfig from './LookupConfig' +import AdvancedSettings from './AdvancedSettings' import RerankerConfig from './RerankerConfig' import AIAssistantSelectorPanel from './AIAssistantSelectorPanel' @@ -48,10 +56,24 @@ const VisuallyHiddenInput = styled('input')({ }); -const ConfigurationForm = ({ project, handleFileUpload, file, owner, setOwner, name, setName, description, setDescription, repo, onRepoChange, repoVersion, setRepoVersion, versions, mappedSources, targetSourcesFromRows, algosSelected, setAlgosSelected, sx, algos, validColumns, columns, isValidColumnValue, updateColumn, configure, setConfigure, columnVisibilityModel, setColumnVisibilityModel, onSave, isSaving, candidatesScore, onScoreChange, includeDefaultFilter, setIncludeDefaultFilter, filters, setFilters, locales, isLoadingLocales, setAIAssistantColumns, AIAssistantColumns, inAIAssistantGroup, lookupConfig, setLookupConfig, encoderModel, setEncoderModel, isCoreUser, canBridge, canScispacy, promptTemplates, promptTemplate, onPromptTemplateChange, AIModels, AIModel, setAIModel }) => { +const deriveCanonicalUrl = relativeUrl => relativeUrl ? `https://ns.openconceptlab.org${relativeUrl}` : '' + +const ConfigurationForm = ({ project, handleFileUpload, file, owner, setOwner, name, setName, description, setDescription, repo, onRepoChange, repoVersion, setRepoVersion, versions, mappedSources, targetSourcesFromRows, algosSelected, setAlgosSelected, sx, algos, validColumns, columns, isValidColumnValue, updateColumn, configure, setConfigure, columnVisibilityModel, setColumnVisibilityModel, onSave, isSaving, candidatesScore, onScoreChange, includeDefaultFilter, setIncludeDefaultFilter, filters, setFilters, locales, isLoadingLocales, setAIAssistantColumns, AIAssistantColumns, inAIAssistantGroup, lookupConfig, setLookupConfig, encoderModel, setEncoderModel, isCoreUser, canBridge, canScispacy, promptTemplates, promptTemplate, onPromptTemplateChange, AIModels, AIModel, setAIModel, namespace, setNamespace }) => { const { t } = useTranslation(); const isLLMAlgoNotAllowed = !repoVersion?.match_algorithms?.includes('llm') const appliedLocales = filters?.locale ? filters?.locale?.split(',') : [] + const targetCanonicalFromRepo = repo?.canonical_url + const targetCanonicalDerived = !targetCanonicalFromRepo && repo?.url ? deriveCanonicalUrl(repo.url) : '' + const effectiveTargetCanonical = targetCanonicalFromRepo || targetCanonicalDerived + const bridgeAlgos = filter(algosSelected || [], a => a?.type && a.type.includes('bridge')) + const defaultNamespace = owner || '' + const namespaceValue = namespace || '' + + // Project-config validation. Currently flags custom algos with missing / + // malformed canonical_url. Returned as a structured list so the banner + // can name the specific algorithms. + const configErrors = getProjectConfigErrors(algosSelected) + const hasConfigErrors = configErrors.length > 0 const getAlgos = () => { return algos.map(algo => { if(algo.type === 'ocl-semantic') @@ -151,6 +173,79 @@ const ConfigurationForm = ({ project, handleFileUpload, file, owner, setOwner, n onRepoChange(item)} value={repo} sx={{marginTop: '12px'}}/> setRepoVersion(item)} value={repoVersion} sx={{marginTop: '12px'}} /> + { + effectiveTargetCanonical && + + + + {t('map_project.target_canonical_url', 'Canonical:')} + + + + {effectiveTargetCanonical} + + + { + !targetCanonicalFromRepo && + + · {t('map_project.canonical_auto_derived_short', 'derived')} + + } + + } + { + bridgeAlgos.length > 0 && + + { + bridgeAlgos.map(b => { + const bridgeCanonical = b?.bridge_repo?.canonical_url || deriveCanonicalUrl(b?.target_repo_url) + const isDerived = !b?.bridge_repo?.canonical_url + const tooltipText = `${b.target_repo_url || ''} → ${bridgeCanonical || ''}` + return ( + + + + {t('map_project.bridge_canonical_short', 'Bridge:')} + + + + {b.target_repo_url || '—'} → {bridgeCanonical} + + + { + isDerived && + + · {t('map_project.canonical_auto_derived_short', 'derived')} + + } + + ) + }) + } + + } { isLLMAlgoNotAllowed && repoVersion?.version_url && @@ -211,7 +306,10 @@ const ConfigurationForm = ({ project, handleFileUpload, file, owner, setOwner, n } - + { + setNamespace && + + } { inAIAssistantGroup && isCoreUser && promptTemplates?.length > 0 && <> @@ -295,6 +393,29 @@ const ConfigurationForm = ({ project, handleFileUpload, file, owner, setOwner, n /> } + { + hasConfigErrors && + + + {t('map_project.config_errors_title', 'Project configuration is incomplete')} + +
      + { + configErrors.map(({algo, reason}) => ( +
    • + {reason === 'missing_canonical_url' + ? t('map_project.config_error_missing_canonical', { + name: algo.name || algo.id, + defaultValue: `Custom algorithm "${algo.name || algo.id}" is missing a valid canonical URL.` + }) + : reason + } +
    • + )) + } +
    +
    + }
    - + { targetConcept?.search_meta?.algorithm && diff --git a/src/components/map-projects/MultiAlgoSelector.jsx b/src/components/map-projects/MultiAlgoSelector.jsx index fb9a8cc..f46e94b 100644 --- a/src/components/map-projects/MultiAlgoSelector.jsx +++ b/src/components/map-projects/MultiAlgoSelector.jsx @@ -17,8 +17,7 @@ import { ListItemText, ListItemIcon, ListItemButton, - FormControlLabel, - Checkbox + Chip } from "@mui/material"; import DeleteOutlineRoundedIcon from "@mui/icons-material/DeleteOutlineRounded"; import ExpandMoreRoundedIcon from "@mui/icons-material/ExpandMoreRounded"; @@ -34,8 +33,22 @@ import orderBy from 'lodash/orderBy' import ConceptIcon from '../concepts/ConceptIcon' +import { isLikelyCanonicalUrl } from './algorithms' import APIService from '../../services/APIService'; +// Cached lookup of a bridge source repo's canonical_url. Canonical URLs are +// stable, so a single fetch per relative URL is sufficient for the session. +const bridgeCanonicalCache = new Map() +const fetchBridgeCanonical = (url) => { + if(!url) return Promise.resolve(null) + if(bridgeCanonicalCache.has(url)) return bridgeCanonicalCache.get(url) + const promise = APIService.new().overrideURL(url).get() + .then(r => r?.data?.canonical_url || null) + .catch(() => null) + bridgeCanonicalCache.set(url, promise) + return promise +} + /** * MultiAlgoSelector (MUI5) * @@ -152,6 +165,30 @@ export default function MultiAlgoSelector({ } }; + // For each selected bridge algo, fetch the source repo's canonical_url from + // the OCL API and populate `bridge_repo.canonical_url`. Without this, the + // downstream derivation falls back to https://ns.openconceptlab.org{relurl} + // even when the repo has a real canonical (e.g. CIEL -> CIELterminology.org). + // User-typed values are preserved: the sync only runs once per (key, url), + // so editing the canonical field afterwards is sticky until the source URL + // changes. + const syncedBridgeUrlRef = React.useRef(new Map()) + React.useEffect(() => { + for (const sel of value || []) { + if(!sel?.type?.includes('bridge')) continue + const url = sel.target_repo_url + if(!url) continue + if(syncedBridgeUrlRef.current.get(sel.__key) === url) continue + syncedBridgeUrlRef.current.set(sel.__key, url) + fetchBridgeCanonical(url).then(canonical => { + if(!canonical) return + updateSelected(sel.__key, { + bridge_repo: { canonical_url: canonical, canonical_url_source: 'repo' } + }) + }) + } + }, [value]) + const removeSelected = (key) => { const next = (value || []).filter((v) => v.__key !== key); onChange(next); @@ -196,7 +233,6 @@ export default function MultiAlgoSelector({ name: name, batch_size: algo.batch_size ?? 10, concurrent_requests: algo.concurrent_requests ?? 1, - lookup_required: algo.lookup_required, __key: Math.random(100).toString() }; @@ -466,6 +502,22 @@ export default function MultiAlgoSelector({ updateSelected(sel.__key, { description: e.target.value || '' }) } /> + updateSelected(sel.__key, { canonical_url: e.target.value || '' })} + placeholder="http://loinc.org" + helperText={ + !sel.canonical_url + ? t('map_project.algo_canonical_url_required', 'Canonical URL is required for custom algorithms (e.g. http://loinc.org).') + : !isLikelyCanonicalUrl(sel.canonical_url) + ? t('map_project.algo_canonical_url_invalid', 'Enter a full URL starting with http:// or https://') + : t('map_project.algo_canonical_url_description', 'Canonical URL of the code system this algorithm matches against (e.g. http://loinc.org).') + } + error={!isLikelyCanonicalUrl(sel.canonical_url)} + /> - } label={t('map_project.lookup_required')} onChange={e => updateSelected(sel.__key, {lookup_required: e.target.checked})} /> @@ -507,14 +558,33 @@ export default function MultiAlgoSelector({ ) : algo.type?.includes('bridge') ? ( {isCoreUser && ( - updateSelected(sel.__key, { target_repo_url: e.target.value })} - placeholder="/orgs/CIEL/sources/CIEL/" - helperText={t('map_project.bridge_source_url_description') || 'The interface terminology to search through for bridge matching'} - /> + <> + updateSelected(sel.__key, { target_repo_url: e.target.value })} + placeholder="/orgs/CIEL/sources/CIEL/" + helperText={t('map_project.bridge_source_url_description', 'The interface terminology to search through for bridge matching')} + /> + updateSelected(sel.__key, { + bridge_repo: { + ...(sel.bridge_repo || {}), + canonical_url: e.target.value || '', + canonical_url_source: e.target.value ? 'repo' : 'derived' + } + })} + placeholder="https://CIELterminology.org" + helperText={t('map_project.bridge_canonical_url_description', 'Canonical URL of the bridge code system (leave blank to derive from the relative URL).')} + /> + {!sel.bridge_repo?.canonical_url && ( + + )} + )} - } label={t('map_project.lookup_required')} onChange={e => updateSelected(sel.__key, {lookup_required: e.target.checked})} /> )} @@ -640,3 +709,4 @@ function clampInt(value, min, max) { function eHasValue(value) { return Boolean(value && String(value).trim()); } + diff --git a/src/components/map-projects/Score.jsx b/src/components/map-projects/Score.jsx index 44df55f..167def5 100644 --- a/src/components/map-projects/Score.jsx +++ b/src/components/map-projects/Score.jsx @@ -9,41 +9,17 @@ import Tooltip from '@mui/material/Tooltip' import Chip from '@mui/material/Chip' import Box from '@mui/material/Box' import AssistantIcon from '@mui/icons-material/Assistant'; -import isNumber from 'lodash/isNumber' -import isNaN from 'lodash/isNaN' import ConceptIcon from '../concepts/ConceptIcon' +import { getScoreDetails as pureGetScoreDetails } from './viewBuilders.js' -export const getScoreDetails = (concept, candidatesScore) => { - let percentile = concept?.search_meta?.search_normalized_score || ((concept?.search_meta?.search_rerank_score || concept?.search_meta?.search_score) * 100) - if(percentile && !isNumber(percentile)) - percentile = parseFloat(percentile) - - const score = concept?.search_meta?.search_score - const hasPercentile = isNumber(percentile) - const recommendedScore = candidatesScore?.recommended - const availableScore = candidatesScore?.available - - let qualityBucket; - if(hasPercentile) { - if (percentile >= recommendedScore) - qualityBucket = 'recommended' - else if (percentile >= availableScore) - qualityBucket = 'available' - else - qualityBucket = 'low_ranked' - } - - return { - score, - percentile, - hasPercentile, - qualityBucket, - bucketColor: qualityBucket ? SCORES_COLOR[qualityBucket] : false, - rerankScore: `${parseFloat(hasPercentile ? percentile : score).toFixed(2)}%`, - algoScore: `${parseFloat(score).toFixed(2)}` - } +// Wrap the pure getScoreDetails (from viewBuilders.js) with the +// SCORES_COLOR mapping. The pure function is testable without React/JSX +// imports; this wrapper layers on the UI color affordance. +export const getScoreDetails = (input, candidatesScore) => { + const details = pureGetScoreDetails(input, candidatesScore) + return { ...details, bucketColor: details.qualityBucket ? SCORES_COLOR[details.qualityBucket] : false } } export const ScoreValueChip = ({ bucketColor, label, size='medium', showIndicator=true, sx }) => ( @@ -66,7 +42,7 @@ export const ScoreValueChip = ({ bucketColor, label, size='medium', showIndicato /> ) -const Score = ({concept, setShowHighlights, sx, isAIRecommended, candidatesScore, algoScoreFirst, size}) => { +const Score = ({candidate, conceptRow, setShowHighlights, sx, isAIRecommended, candidatesScore, algoScoreFirst, size, onHighlightClick}) => { const { t } = useTranslation(); const { score, @@ -74,18 +50,23 @@ const Score = ({concept, setShowHighlights, sx, isAIRecommended, candidatesScore bucketColor, rerankScore, algoScore - } = getScoreDetails(concept, candidatesScore) + } = getScoreDetails({candidate, conceptRow}, candidatesScore) + const hasRawScore = algoScore !== '' && score !== null return ( { event.preventDefault() event.stopPropagation() - setShowHighlights(concept) + // Caller (Concept.jsx) decides what payload to surface to the + // highlights dialog; pass through onHighlightClick if provided, + // otherwise default to no-op. + if(onHighlightClick) onHighlightClick(event) + else setShowHighlights({candidate, conceptRow}) return false } : undefined} > @@ -105,11 +86,17 @@ const Score = ({concept, setShowHighlights, sx, isAIRecommended, candidatesScore bucketColor={bucketColor} label={ - {algoScoreFirst ? algoScore : rerankScore} + + { + algoScoreFirst && hasRawScore + ? algoScore + : (rerankScore || ) + } + { - hasPercentile && !isNaN(score) && score ? + hasPercentile && hasRawScore ? - {`(${algoScoreFirst ? rerankScore : algoScore})`} + {`(${algoScoreFirst && hasRawScore ? rerankScore : algoScore})`} : '' } diff --git a/src/components/map-projects/__tests__/normalizeLegacyAllCandidates.test.js b/src/components/map-projects/__tests__/normalizeLegacyAllCandidates.test.js new file mode 100644 index 0000000..4ac1a02 --- /dev/null +++ b/src/components/map-projects/__tests__/normalizeLegacyAllCandidates.test.js @@ -0,0 +1,251 @@ +/** + * Tests for the legacy-shape -> unified-model backfill used at project + * load time. Critical for the flag flip — without correct backfill, + * reloaded v1 projects render zero candidates under + * UNIFIED_MODEL_ENABLED=true. + * + * Run with: npm test + */ + +import test from 'node:test' +import assert from 'node:assert/strict' + +import { normalizeLegacyAllCandidates } from '../normalizers.js' + +const CONCEPT_IDENTITY_BY_TYPE = { + 'ocl-search': { + reference_source: 'target_repo', + code_field: 'id', + ocl_url_field: 'url' + }, + 'ocl-semantic': { + reference_source: 'target_repo', + code_field: 'id', + ocl_url_field: 'url' + }, + 'ocl-bridge': { + reference_source: 'bridge_repo', + code_field: 'id', + ocl_url_field: 'url', + cascade_target: { + reference_source: 'target_repo', + code_field: 'cascade_target_concept_code', + ocl_url_field: 'cascade_target_concept_url' + } + } +} + +const projectContext = { + namespace: '/orgs/MyOrg/', + target_repo: { + relative_url: '/orgs/Regenstrief/sources/LOINC/', + canonical_url: 'http://loinc.org', + canonical_url_source: 'repo' + }, + bridge_repo: { + relative_url: '/orgs/CIEL/sources/CIEL/', + canonical_url: 'https://CIELterminology.org', + canonical_url_source: 'repo' + } +} + +const oclSearchAlgo = { id: 'ocl-search', type: 'ocl-search' } +const oclBridgeAlgo = { id: 'ocl-bridge', type: 'ocl-bridge' } + +const glucose = { + id: '49494-3', + display_name: 'Glucose [Mass/volume] in Blood', + url: '/orgs/Regenstrief/sources/LOINC/concepts/49494-3/', + source: 'LOINC', + owner: 'Regenstrief', + names: [{ name: 'Glucose [Mass/volume] in Blood', locale: 'en', preferred: true }], + descriptions: [{ description: 'Glucose mass per volume in blood', locale: 'en' }], + search_meta: { search_score: 0.85, search_normalized_score: 87, algorithm: 'ocl-search' } +} + +const cholesterol = { + id: '2093-3', + display_name: 'Cholesterol in Serum or Plasma', + url: '/orgs/Regenstrief/sources/LOINC/concepts/2093-3/', + source: 'LOINC', + owner: 'Regenstrief', + names: [{ name: 'Cholesterol', locale: 'en', preferred: true }], + descriptions: [{ description: 'Total cholesterol', locale: 'en' }], + search_meta: { search_score: 0.6, search_normalized_score: 55, algorithm: 'ocl-search' } +} + +// ---------- Empty / null inputs ---------- + +test('normalizeLegacyAllCandidates handles null input', () => { + const out = normalizeLegacyAllCandidates(null, projectContext, [], {}) + assert.deepEqual(out.rowMatchState, {}) + assert.equal(out.conceptDefinitionsByKey.size, 0) +}) + +test('normalizeLegacyAllCandidates handles missing projectContext', () => { + const out = normalizeLegacyAllCandidates({'ocl-search': [{row: {__index: 0}, results: [glucose]}]}, null, [oclSearchAlgo], CONCEPT_IDENTITY_BY_TYPE) + assert.deepEqual(out.rowMatchState, {}) +}) + +test('normalizeLegacyAllCandidates handles empty allCandidates', () => { + const out = normalizeLegacyAllCandidates({}, projectContext, [oclSearchAlgo], CONCEPT_IDENTITY_BY_TYPE) + assert.deepEqual(out.rowMatchState, {}) +}) + +// ---------- Single algo, single row ---------- + +test('single ocl-search row produces one RowMatchState entry with normalized candidates', () => { + const allCandidates = { + 'ocl-search': [{ row: { __index: 0, name: 'glucose' }, results: [glucose] }] + } + const { rowMatchState, conceptDefinitionsByKey } = normalizeLegacyAllCandidates( + allCandidates, projectContext, [oclSearchAlgo], CONCEPT_IDENTITY_BY_TYPE + ) + assert.equal(Object.keys(rowMatchState).length, 1) + const row0 = rowMatchState[0] + assert.equal(Object.keys(row0.candidates).length, 1) + assert.equal(Object.keys(row0.concept_rows).length, 1) + assert.equal(conceptDefinitionsByKey.size, 1) +}) + +test('single-algo rerank score is carried onto ConceptRow.rerank_score', () => { + const allCandidates = { + 'ocl-search': [{ row: { __index: 0 }, results: [glucose] }] + } + const { rowMatchState } = normalizeLegacyAllCandidates( + allCandidates, projectContext, [oclSearchAlgo], CONCEPT_IDENTITY_BY_TYPE + ) + const cr = Object.values(rowMatchState[0].concept_rows)[0] + assert.equal(cr.rerank_score, 87) +}) + +// ---------- Multi-algo + multi-row ---------- + +test('two algos returning the same concept dedupe into one ConceptRow with two Candidates', () => { + const semanticHit = {...glucose, search_meta: { search_score: 0.78, search_normalized_score: 87, algorithm: 'ocl-semantic' }} + const allCandidates = { + 'ocl-search': [{ row: { __index: 0 }, results: [glucose] }], + 'ocl-semantic': [{ row: { __index: 0 }, results: [semanticHit] }] + } + const { rowMatchState } = normalizeLegacyAllCandidates( + allCandidates, projectContext, + [oclSearchAlgo, {id: 'ocl-semantic', type: 'ocl-semantic'}], + CONCEPT_IDENTITY_BY_TYPE + ) + const row = rowMatchState[0] + assert.equal(Object.keys(row.candidates).length, 2) + assert.equal(Object.keys(row.concept_rows).length, 1, 'one ConceptRow for the converged concept') + const algos = Object.values(row.candidates).map(c => c.algorithm_id).sort() + assert.deepEqual(algos, ['ocl-search', 'ocl-semantic']) +}) + +test('different rows do not contaminate each other', () => { + const allCandidates = { + 'ocl-search': [ + { row: { __index: 0 }, results: [glucose] }, + { row: { __index: 1 }, results: [cholesterol] } + ] + } + const { rowMatchState } = normalizeLegacyAllCandidates( + allCandidates, projectContext, [oclSearchAlgo], CONCEPT_IDENTITY_BY_TYPE + ) + assert.equal(Object.keys(rowMatchState[0].concept_rows).length, 1) + assert.equal(Object.keys(rowMatchState[1].concept_rows).length, 1) + const row0Key = Object.keys(rowMatchState[0].concept_rows)[0] + const row1Key = Object.keys(rowMatchState[1].concept_rows)[0] + assert.notEqual(row0Key, row1Key, 'two different concepts → two different keys') +}) + +// ---------- Bridge backfill ---------- + +test('bridge result backfill creates 2 ConceptRows + 2 Candidates per cascade target', () => { + const bridgeHit = { + id: 'CIEL_12345', + display_name: 'Blood glucose measurement', + url: '/orgs/CIEL/sources/CIEL/concepts/CIEL_12345/', + source: 'CIEL', + owner: 'CIEL', + search_meta: { search_score: 0.92, search_normalized_score: 91, algorithm: 'ocl-bridge' }, + mappings: [{ + cascade_target_concept_code: '49494-3', + cascade_target_concept_url: '/orgs/Regenstrief/sources/LOINC/concepts/49494-3/', + cascade_target_concept_name: 'Glucose [Mass/volume] in Blood', + cascade_target_source_name: 'LOINC', + map_type: 'SAME-AS' + }] + } + const allCandidates = { + 'ocl-bridge': [{ row: { __index: 0 }, results: [bridgeHit] }] + } + const { rowMatchState, conceptDefinitionsByKey } = normalizeLegacyAllCandidates( + allCandidates, projectContext, [oclBridgeAlgo], CONCEPT_IDENTITY_BY_TYPE + ) + const row = rowMatchState[0] + assert.equal(Object.keys(row.candidates).length, 2, '1 bridge + 1 bridge_child') + assert.equal(Object.keys(row.concept_rows).length, 2, 'bridge intermediary + cascade target') + assert.equal(conceptDefinitionsByKey.size, 2) + const types = Object.values(row.candidates).map(c => c.type).sort() + assert.deepEqual(types, ['bridge', 'bridge_child']) +}) + +// ---------- Concept identity injection fallback ---------- + +test('algorithm without concept_identity has it injected from conceptIdentityByType', () => { + // ocl-bridge entries loaded from the API don't carry concept_identity — + // CONCEPT_IDENTITY_BY_TYPE is the fallback map. This mirrors getAlgoDef's + // behavior in MapProject. + const algoWithoutIdentity = { id: 'ocl-bridge', type: 'ocl-bridge' } + const bridgeHit = { + id: 'CIEL_999', + display_name: 'Test', + url: '/orgs/CIEL/sources/CIEL/concepts/CIEL_999/', + source: 'CIEL', + search_meta: { search_score: 0.7 }, + mappings: [] + } + const out = normalizeLegacyAllCandidates( + { 'ocl-bridge': [{ row: { __index: 0 }, results: [bridgeHit] }] }, + projectContext, + [algoWithoutIdentity], + CONCEPT_IDENTITY_BY_TYPE + ) + assert.equal(out.conceptDefinitionsByKey.size, 1, 'identity injection worked') +}) + +test('algorithm with no concept_identity AND no fallback is silently skipped', () => { + const unknownAlgo = { id: 'mystery', type: 'mystery' } + const out = normalizeLegacyAllCandidates( + { 'mystery': [{ row: { __index: 0 }, results: [glucose] }] }, + projectContext, + [unknownAlgo], + {} // empty fallback map + ) + assert.deepEqual(out.rowMatchState, {}, 'no entries when identity cannot be resolved') +}) + +test('rowEntry without a row index is silently skipped', () => { + const out = normalizeLegacyAllCandidates( + { 'ocl-search': [{ row: null, results: [glucose] }, { row: { __index: 0 }, results: [glucose] }] }, + projectContext, [oclSearchAlgo], CONCEPT_IDENTITY_BY_TYPE + ) + assert.equal(Object.keys(out.rowMatchState).length, 1) + assert.ok(out.rowMatchState[0]) +}) + +test('ConceptDefinitions are deduped across rows by key with richer-wins', () => { + // Same concept appears in two rows: should appear once in + // conceptDefinitionsByKey (it's project-wide), but in both rows' + // concept_rows (per-row). + const allCandidates = { + 'ocl-search': [ + { row: { __index: 0 }, results: [glucose] }, + { row: { __index: 1 }, results: [glucose] } + ] + } + const { rowMatchState, conceptDefinitionsByKey } = normalizeLegacyAllCandidates( + allCandidates, projectContext, [oclSearchAlgo], CONCEPT_IDENTITY_BY_TYPE + ) + assert.equal(conceptDefinitionsByKey.size, 1, 'project-wide dedup') + assert.equal(Object.keys(rowMatchState[0].concept_rows).length, 1) + assert.equal(Object.keys(rowMatchState[1].concept_rows).length, 1) +}) diff --git a/src/components/map-projects/__tests__/normalizers.test.js b/src/components/map-projects/__tests__/normalizers.test.js index 75d40d8..4fa7cb9 100644 --- a/src/components/map-projects/__tests__/normalizers.test.js +++ b/src/components/map-projects/__tests__/normalizers.test.js @@ -294,16 +294,55 @@ test('Candidate.concept_key matches ConceptDefinition.key', () => { assert.equal(out.candidates[0].concept_key, out.concept_definitions[0].key) }) -test('ConceptRow is created with rerank_score=undefined for the matched concept', () => { +test('ConceptRow picks up search_normalized_score as rerank_score (single-algo reranker:true path)', () => { const out = normalizeAlgoResult(oclSearchResult_LOINC_glucose_full, { algorithmId: 'ocl-search', algorithmConfig: oclSearchAlgo, algorithmResponseId: 'ar-1', - projectContext + projectContext, + // The caller must opt in: only trust the server's normalized score when + // it was produced by reranker:true (single-algo native OCL path). + trustServerRerank: true }) const [row] = out.concept_rows assert.equal(row.concept_key, out.concept_definitions[0].key) - assert.equal(row.rerank_score, undefined) + // The fixture has search_normalized_score=85 (set by $match's + // reranker:true). The normalizer carries that onto the ConceptRow so no + // separate $rerank round-trip is needed for the single-algo OCL path. + assert.equal(row.rerank_score, 85) +}) + +test('ConceptRow.rerank_score ignored when the caller did not opt in (multi-algo path)', () => { + // OCL $match emits search_normalized_score unconditionally — for top + // FAISS hits the value is ~100. Without the trustServerRerank opt-in + // (multi-algo, bridge, scispacy, custom paths) the normalizer must NOT + // propagate it: the value isn't a unified rerank score, just a per-algo + // native score, and treating it as rerank produces a misleading "100%" + // chip until the debounced $rerank/ pass runs. + const out = normalizeAlgoResult(oclSearchResult_LOINC_glucose_full, { + algorithmId: 'ocl-search', + algorithmConfig: oclSearchAlgo, + algorithmResponseId: 'ar-multi', + projectContext + // trustServerRerank omitted — defaults to falsy + }) + assert.equal(out.concept_rows[0].rerank_score, undefined, + 'response had search_normalized_score=85 but caller did not opt in → ignored') +}) + +test('ConceptRow.rerank_score is undefined when the algorithm did not provide search_normalized_score', () => { + const noScoreResult = { + ...oclSearchResult_LOINC_glucose_full, + search_meta: { ...oclSearchResult_LOINC_glucose_full.search_meta, search_normalized_score: undefined } + } + const out = normalizeAlgoResult(noScoreResult, { + algorithmId: 'ocl-search', + algorithmConfig: oclSearchAlgo, + algorithmResponseId: 'ar-1b', + projectContext, + trustServerRerank: true + }) + assert.equal(out.concept_rows[0].rerank_score, undefined) }) // ---------- normalizeAlgoResult: scispacy (no url, no ocl_url) ---------- @@ -345,6 +384,86 @@ test('scispacy result merges with ocl-search result on the same canonical refere 'same canonical reference => same key, regardless of algorithm') }) +// ---------- normalizeAlgoResult: schema-specific property capture ---------- + +test('verbose response (with `property` array) → ConceptDefinition captures it and lookup_status=full', () => { + // OCL ConceptDetailSerializer (?verbose=true on $match) emits `property` + // sourced from the model's `properties` getter — schema-specific dict for + // sources like LOINC: [{code: 'COMPONENT', valueString: 'X'}, ...]. The + // UI's ConceptSummaryProperties reads `concept.property` directly. + const verboseResult = { + id: '49494-3', + display_name: 'Glucose [Mass/volume] in Blood', + url: '/orgs/Regenstrief/sources/LOINC/concepts/49494-3/', + source: 'LOINC', + names: [{ name: 'Glucose [Mass/volume] in Blood', locale: 'en', preferred: true }], + // no descriptions — many LOINC concepts have none + property: [ + { code: 'COMPONENT', valueString: 'Glucose' }, + { code: 'PROPERTY', valueString: 'MCnc' }, + { code: 'TIME_ASPCT', valueString: 'Pt' } + ], + extras: { LOINC_NUM: '49494-3' }, + search_meta: { search_score: 0.85, algorithm: 'ocl-semantic' } + } + const out = normalizeAlgoResult(verboseResult, { + algorithmId: 'ocl-semantic', + algorithmConfig: oclSemanticAlgo, + algorithmResponseId: 'ar-verbose', + projectContext + }) + const [def] = out.concept_definitions + assert.equal(def.lookup_status, 'full', + 'response carries `property` array → full, even without descriptions') + assert.equal(def.property.length, 3, 'property array survives normalization') + assert.equal(def.property[0].code, 'COMPONENT') + assert.deepEqual(def.extras, { LOINC_NUM: '49494-3' }, 'extras survives normalization') +}) + +test('verbose response with empty `property` array still promotes to lookup_status=full', () => { + // A source with no schema-property definitions returns `property: []`. + // We still have full payload data — ensureLoaded shouldn't refetch. + const result = { + id: 'X-1', + display_name: 'No-Schema Concept', + url: '/orgs/Test/sources/Plain/concepts/X-1/', + source: 'Plain', + property: [], + search_meta: { search_score: 0.7, algorithm: 'ocl-search' } + } + const out = normalizeAlgoResult(result, { + algorithmId: 'ocl-search', + algorithmConfig: oclSearchAlgo, + algorithmResponseId: 'ar-verbose-empty', + projectContext + }) + assert.equal(out.concept_definitions[0].lookup_status, 'full', + 'verbose payload marker is property field presence, not its length') +}) + +test('brief response (no `property`, no `names`) stays at lookup_status=partial', () => { + // ConceptMinimalSerializer omits `property` entirely. We have id + + // display_name but no schema data — ensureLoaded should fire. + const briefResult = { + id: '49494-3', + display_name: 'Glucose [Mass/volume] in Blood', + url: '/orgs/Regenstrief/sources/LOINC/concepts/49494-3/', + source: 'LOINC', + // no property, no names, no descriptions + search_meta: { search_score: 0.85, algorithm: 'ocl-search' } + } + const out = normalizeAlgoResult(briefResult, { + algorithmId: 'ocl-search', + algorithmConfig: oclSearchAlgo, + algorithmResponseId: 'ar-brief', + projectContext + }) + assert.equal(out.concept_definitions[0].lookup_status, 'partial', + 'no verbose-payload marker, no names → still partial → ensureLoaded eligible') + assert.equal(out.concept_definitions[0].property, undefined, + 'no property in response → field stays undefined on the ConceptDefinition') +}) + // ---------- normalizeAlgoResult: missing data ---------- test('result without id (missing code field) returns empty entities', () => { @@ -444,12 +563,22 @@ test('bridge result creates ConceptRows for BOTH intermediary and target', () => algorithmConfig: oclBridgeAlgo, algorithmResponseId: 'ar-3', projectContext + // No trustServerRerank — bridge scores are unreliable as unified rerank + // (the bridge endpoint scores intermediaries against a vector index that + // doesn't speak the project's query semantics). Client-side $rerank/ + // fills both ConceptRows once they're eligible. }) assert.equal(out.concept_rows.length, 2) - for (const row of out.concept_rows) { - assert.equal(row.rerank_score, undefined) - } + // Bridge intermediary's row stays unscored: even though the response + // carries search_normalized_score, the bridge path doesn't opt into + // trustServerRerank so it's ignored. Cascade target was already unscored + // (bridge response doesn't score cascade targets at all). Both fill in + // when $rerank/ lands. + const bridgeRow = out.concept_rows.find(r => r.concept_key === out.concept_definitions.find(d => d.reference.url === 'https://CIELterminology.org').key) + const targetRow = out.concept_rows.find(r => r.concept_key === out.concept_definitions.find(d => d.reference.url === 'http://loinc.org').key) + assert.equal(bridgeRow.rerank_score, undefined) + assert.equal(targetRow.rerank_score, undefined) }) test('bridge result with multiple cascade targets fans out 1 + N candidates', () => { diff --git a/src/components/map-projects/__tests__/viewHelpers.test.js b/src/components/map-projects/__tests__/viewHelpers.test.js new file mode 100644 index 0000000..223e327 --- /dev/null +++ b/src/components/map-projects/__tests__/viewHelpers.test.js @@ -0,0 +1,223 @@ +/** + * Tests for the pure helpers in viewBuilders.js: + * - getScoreDetails (used by Score.jsx) + * - conceptForMapping (the tuple -> legacy concept projection at the + * onMap / isSelectedForMap boundary) + * - resolveAICandidateID (AI Assistant response resolution chain) + * + * Run with: npm test + */ + +import test from 'node:test' +import assert from 'node:assert/strict' + +import { + getScoreDetails, + conceptForMapping, + resolveAICandidateID +} from '../viewBuilders.js' + +const candidatesScore = { recommended: 80, available: 60 } + +// ---------- getScoreDetails ---------- + +test('getScoreDetails: rerank_score in recommended bucket', () => { + const out = getScoreDetails( + { candidate: { score: 0.85 }, conceptRow: { rerank_score: 90 } }, + candidatesScore + ) + assert.equal(out.qualityBucket, 'recommended') + assert.equal(out.percentile, 90) + assert.equal(out.score, 0.85) + assert.equal(out.rerankScore, '90.00%') + assert.equal(out.algoScore, '0.85') +}) + +test('getScoreDetails: rerank_score in available bucket', () => { + const out = getScoreDetails( + { candidate: { score: 0.6 }, conceptRow: { rerank_score: 70 } }, + candidatesScore + ) + assert.equal(out.qualityBucket, 'available') +}) + +test('getScoreDetails: rerank_score in low_ranked bucket', () => { + const out = getScoreDetails( + { candidate: { score: 0.3 }, conceptRow: { rerank_score: 40 } }, + candidatesScore + ) + assert.equal(out.qualityBucket, 'low_ranked') +}) + +test('getScoreDetails: no rerank_score leaves percentile undefined (no score*100 fallback)', () => { + // Interim state: an algo (e.g. ocl-semantic) returned candidates with a + // raw score but the debounced $rerank/ pass hasn't landed yet. Scaling + // raw to a 0-100 percentile would mislead (semantic raw scores cluster + // near 1.0); leave it undefined so the UI shows a placeholder. + const out = getScoreDetails( + { candidate: { score: 0.85 }, conceptRow: {} }, + candidatesScore + ) + assert.equal(out.percentile, undefined) + assert.equal(out.hasPercentile, false) + assert.equal(out.qualityBucket, undefined) + assert.equal(out.algoScore, '0.85', 'raw score still surfaces for the algo-score chip') + assert.equal(out.rerankScore, '', 'no unified score string when rerank is pending') +}) + +test('getScoreDetails: no scores at all yields hasPercentile=false and no bucket', () => { + const out = getScoreDetails( + { candidate: {}, conceptRow: {} }, + candidatesScore + ) + assert.equal(out.hasPercentile, false) + assert.equal(out.qualityBucket, undefined) +}) + +test('getScoreDetails: bridge_child candidate (no own score) uses rerank_score only', () => { + // bridge_child has no algorithm score (the bridge response didn't score + // the cascade target). But the ConceptRow has a rerank_score from the + // debounced rerank pass. + const out = getScoreDetails( + { candidate: { score: undefined }, conceptRow: { rerank_score: 75 } }, + candidatesScore + ) + assert.equal(out.percentile, 75) + assert.equal(out.qualityBucket, 'available') + assert.equal(out.algoScore, '', 'no raw score when candidate.score is undefined') +}) + +test('getScoreDetails: percentile exactly at threshold lands in the higher bucket', () => { + const out = getScoreDetails( + { candidate: {}, conceptRow: { rerank_score: 80 } }, + candidatesScore + ) + assert.equal(out.qualityBucket, 'recommended', '80 is >= 80 so recommended') +}) + +// ---------- conceptForMapping ---------- + +const sampleDef = { + reference: { url: 'http://loinc.org', code: '49494-3' }, + key: 'k1', + ocl_url: '/orgs/Regenstrief/sources/LOINC/concepts/49494-3/', + id: '49494-3', + display_name: 'Glucose [Mass/volume] in Blood', + source: 'LOINC', + owner: 'Regenstrief', + names: [{ name: 'Glucose', locale: 'en' }], + descriptions: [{ description: 'Test', locale: 'en' }], + concept_class: 'LOINC', + datatype: 'Numeric', + retired: false, + properties: [] +} + +test('conceptForMapping: returns null when conceptDefinition is missing', () => { + assert.equal(conceptForMapping(null), null) + assert.equal(conceptForMapping({}), null) +}) + +test('conceptForMapping: projects a standard tuple to the legacy concept shape', () => { + const out = conceptForMapping({ + candidate: { algorithm_id: 'ocl-search', score: 0.85, highlights: {name: ['glucose']} }, + conceptDefinition: sampleDef, + conceptRow: { rerank_score: 87 } + }) + assert.equal(out.id, '49494-3') + assert.equal(out.display_name, 'Glucose [Mass/volume] in Blood') + assert.equal(out.url, '/orgs/Regenstrief/sources/LOINC/concepts/49494-3/') + assert.equal(out.source, 'LOINC') + assert.equal(out.type, 'Concept') + assert.equal(out.search_meta.algorithm, 'ocl-search') + assert.equal(out.search_meta.search_score, 0.85) + assert.equal(out.search_meta.search_normalized_score, 87) + assert.deepEqual(out.search_meta.search_highlight, {name: ['glucose']}) + assert.equal(out.bridge_concept, undefined) +}) + +test('conceptForMapping: falls back to reference.code when conceptDefinition.id is absent', () => { + const def = { ...sampleDef, id: undefined } + const out = conceptForMapping({ + candidate: { algorithm_id: 'ocl-search' }, + conceptDefinition: def, + conceptRow: {} + }) + assert.equal(out.id, '49494-3', 'reference.code is the fallback id') +}) + +test('conceptForMapping: bridge_child includes bridge_concept summary', () => { + const bridgeDef = { + reference: { url: 'https://CIELterminology.org', code: 'CIEL_12345' }, + id: 'CIEL_12345', + display_name: 'Blood glucose measurement', + ocl_url: '/orgs/CIEL/sources/CIEL/concepts/CIEL_12345/', + source: 'CIEL' + } + const out = conceptForMapping({ + candidate: { algorithm_id: 'ocl-bridge', map_type: 'SAME-AS' }, + conceptDefinition: sampleDef, + conceptRow: { rerank_score: 87 }, + bridgeConceptDefinition: bridgeDef + }) + assert.ok(out.bridge_concept) + assert.equal(out.bridge_concept.id, 'CIEL_12345') + assert.equal(out.bridge_concept.display_name, 'Blood glucose measurement') + assert.equal(out.bridge_concept.source, 'CIEL') + assert.equal(out.search_meta.map_type, 'SAME-AS') +}) + +test('conceptForMapping: search_meta carries algorithm_id even when no scores', () => { + const out = conceptForMapping({ + candidate: { algorithm_id: 'ocl-bridge' }, + conceptDefinition: sampleDef, + conceptRow: {} + }) + assert.equal(out.search_meta.algorithm, 'ocl-bridge') + assert.equal(out.search_meta.search_score, undefined) +}) + +// ---------- resolveAICandidateID ---------- + +test('resolveAICandidateID: null candidate returns null', () => { + assert.equal(resolveAICandidateID(null, {}), null) +}) + +test('resolveAICandidateID: concept_key resolves via conceptCache (preferred)', () => { + const cache = { + k1: { reference: { url: 'http://loinc.org', code: '49494-3' } } + } + const candidate = { + concept_key: 'k1', + canonical_reference: { code: 'WRONG' }, // should be ignored when concept_key works + concept_id: 'ALSO-WRONG' + } + assert.equal(resolveAICandidateID(candidate, cache), '49494-3') +}) + +test('resolveAICandidateID: falls back to canonical_reference.code when concept_key is absent (PR2a shim)', () => { + const cache = {} + const candidate = { canonical_reference: { code: '49494-3' }, concept_id: 'LEGACY' } + assert.equal(resolveAICandidateID(candidate, cache), '49494-3') +}) + +test('resolveAICandidateID: falls back to concept_id when v2 fields are absent (legacy v1)', () => { + const candidate = { concept_id: '49494-3' } + assert.equal(resolveAICandidateID(candidate, {}), '49494-3') +}) + +test('resolveAICandidateID: falls back to id when concept_id absent', () => { + const candidate = { id: '49494-3' } + assert.equal(resolveAICandidateID(candidate, {}), '49494-3') +}) + +test('resolveAICandidateID: concept_key present but not in cache falls through to canonical_reference', () => { + // If the AI returns a concept_key the client doesn't know (e.g. cache + // hasn't loaded yet, or there's a mismatch), fall through gracefully. + const candidate = { concept_key: 'missing', canonical_reference: { code: 'FALLBACK' } } + assert.equal(resolveAICandidateID(candidate, {}), 'FALLBACK') +}) + +test('resolveAICandidateID: completely unidentified candidate returns null', () => { + assert.equal(resolveAICandidateID({}, {}), null) +}) diff --git a/src/components/map-projects/__tests__/views.test.js b/src/components/map-projects/__tests__/views.test.js new file mode 100644 index 0000000..cc87c5d --- /dev/null +++ b/src/components/map-projects/__tests__/views.test.js @@ -0,0 +1,455 @@ +/** + * Unit tests for the unified-model view builders exported by Candidates.jsx. + * + * These exercise the read-flip plumbing the UNIFIED_MODEL_ENABLED flag now + * depends on: turning a RowState + conceptCache into the algorithm-grouped + * and quality-grouped row views Candidates renders. + * + * Bridge fan-out at the view layer is tested here because the live bridge + * algorithm only attaches in production builds (PRIVATE_PACKAGES_GIT) and + * cannot be exercised locally. The data shape is the same in both cases. + * + * Run with: npm test + */ + +import test from 'node:test' +import assert from 'node:assert/strict' + +import { + buildAlgorithmRowViews, + buildQualityRowViews, + candidateToRowView, + sortRowViews +} from '../viewBuilders.js' + +// ---------- Helpers ---------- + +const KEY_LOINC_GLUCOSE = JSON.stringify(['http://loinc.org', '49494-3', null]) +const KEY_LOINC_CHOL = JSON.stringify(['http://loinc.org', '2093-3', null]) +const KEY_CIEL_BRIDGE = JSON.stringify(['https://CIELterminology.org', 'CIEL_12345', null]) +const KEY_SNOMED_BRIDGE = JSON.stringify(['http://snomed.info/sct', '74521008', null]) + +const defLOINCGlucose = { + reference: { url: 'http://loinc.org', code: '49494-3' }, + key: KEY_LOINC_GLUCOSE, + ocl_url: '/orgs/Regenstrief/sources/LOINC/concepts/49494-3/', + id: '49494-3', + display_name: 'Glucose [Mass/volume] in Blood', + source: 'LOINC', + owner: 'Regenstrief', + retired: false, + lookup_status: 'full', + lookup_source_type: 'algorithm', + lookup_source: 'ocl-search' +} + +const defLOINCChol = { + reference: { url: 'http://loinc.org', code: '2093-3' }, + key: KEY_LOINC_CHOL, + ocl_url: '/orgs/Regenstrief/sources/LOINC/concepts/2093-3/', + id: '2093-3', + display_name: 'Cholesterol in Serum or Plasma', + source: 'LOINC', + owner: 'Regenstrief', + retired: false, + lookup_status: 'full', + lookup_source_type: 'algorithm', + lookup_source: 'ocl-search' +} + +const defCIELBridge = { + reference: { url: 'https://CIELterminology.org', code: 'CIEL_12345' }, + key: KEY_CIEL_BRIDGE, + ocl_url: '/orgs/CIEL/sources/CIEL/concepts/CIEL_12345/', + id: 'CIEL_12345', + display_name: 'Blood glucose measurement', + source: 'CIEL', + owner: 'CIEL', + retired: false, + lookup_status: 'full', + lookup_source_type: 'algorithm', + lookup_source: 'ocl-bridge' +} + +const defSnomedBridge = { + reference: { url: 'http://snomed.info/sct', code: '74521008' }, + key: KEY_SNOMED_BRIDGE, + ocl_url: '/orgs/IHTSDO/sources/SNOMED-CT/concepts/74521008/', + id: '74521008', + display_name: 'Blood glucose level', + source: 'SNOMED-CT', + owner: 'IHTSDO', + retired: false, + lookup_status: 'partial', + lookup_source_type: 'algorithm', + lookup_source: 'ocl-bridge' +} + +// ---------- candidateToRowView ---------- + +test('candidateToRowView returns null when concept_key is not in cache', () => { + const rv = candidateToRowView( + { id: 'c1', algorithm_id: 'ocl-search', concept_key: 'missing', type: 'standard' }, + {}, + { concept_rows: {} } + ) + assert.equal(rv, null) +}) + +test('candidateToRowView joins a standard candidate with its definition and row', () => { + const cache = { [KEY_LOINC_GLUCOSE]: defLOINCGlucose } + const rowState = { + concept_rows: { [KEY_LOINC_GLUCOSE]: { concept_key: KEY_LOINC_GLUCOSE, rerank_score: 87 } } + } + const candidate = { id: 'c1', algorithm_id: 'ocl-search', concept_key: KEY_LOINC_GLUCOSE, type: 'standard', score: 0.85 } + const rv = candidateToRowView(candidate, cache, rowState) + assert.equal(rv.type, 'standard') + assert.equal(rv.candidate, candidate) + assert.equal(rv.conceptDefinition, defLOINCGlucose) + assert.equal(rv.conceptRow.rerank_score, 87) + assert.equal(rv.bridgeConceptDefinition, undefined) +}) + +test('candidateToRowView attaches bridgeConceptDefinition for bridge_child', () => { + const cache = { [KEY_LOINC_GLUCOSE]: defLOINCGlucose, [KEY_CIEL_BRIDGE]: defCIELBridge } + const rowState = { concept_rows: {} } + const child = { + id: 'c2', + algorithm_id: 'ocl-bridge', + concept_key: KEY_LOINC_GLUCOSE, + type: 'bridge_child', + bridge_concept_key: KEY_CIEL_BRIDGE, + parent_candidate_id: 'c1', + map_type: 'SAME-AS' + } + const rv = candidateToRowView(child, cache, rowState) + assert.equal(rv.bridgeConceptDefinition, defCIELBridge) +}) + +test('candidateToRowView returns null for null candidate', () => { + assert.equal(candidateToRowView(null, {}, {}), null) +}) + +// ---------- buildAlgorithmRowViews ---------- + +test('buildAlgorithmRowViews returns empty array when rowState is null', () => { + assert.deepEqual(buildAlgorithmRowViews(null, {}, 'ocl-search'), []) +}) + +test('buildAlgorithmRowViews filters by algorithm_id and excludes bridge_child at top level', () => { + const cache = { + [KEY_LOINC_GLUCOSE]: defLOINCGlucose, + [KEY_CIEL_BRIDGE]: defCIELBridge + } + const rowState = { + candidates: { + c1: { id: 'c1', algorithm_id: 'ocl-search', concept_key: KEY_LOINC_GLUCOSE, type: 'standard', score: 0.85 }, + c2: { id: 'c2', algorithm_id: 'ocl-bridge', concept_key: KEY_CIEL_BRIDGE, type: 'bridge', score: 0.92 }, + c3: { id: 'c3', algorithm_id: 'ocl-bridge', concept_key: KEY_LOINC_GLUCOSE, type: 'bridge_child', parent_candidate_id: 'c2', bridge_concept_key: KEY_CIEL_BRIDGE, map_type: 'SAME-AS' } + }, + concept_rows: {} + } + const searchViews = buildAlgorithmRowViews(rowState, cache, 'ocl-search') + assert.equal(searchViews.length, 1) + assert.equal(searchViews[0].candidate.id, 'c1') + + const bridgeViews = buildAlgorithmRowViews(rowState, cache, 'ocl-bridge') + // bridge_child does NOT appear at top level — it's nested under the bridge + assert.equal(bridgeViews.length, 1) + assert.equal(bridgeViews[0].type, 'bridge') + assert.equal(bridgeViews[0].bridgeChildren.length, 1) + assert.equal(bridgeViews[0].bridgeChildren[0].candidate.id, 'c3') +}) + +test('buildAlgorithmRowViews: bridge with multiple cascade targets fans out 1 + N children', () => { + const KEY_LOINC_A = JSON.stringify(['http://loinc.org', 'A-1', null]) + const KEY_LOINC_B = JSON.stringify(['http://loinc.org', 'B-2', null]) + const cache = { + [KEY_CIEL_BRIDGE]: defCIELBridge, + [KEY_LOINC_A]: { ...defLOINCGlucose, key: KEY_LOINC_A, id: 'A-1', reference: {url: 'http://loinc.org', code: 'A-1'} }, + [KEY_LOINC_B]: { ...defLOINCGlucose, key: KEY_LOINC_B, id: 'B-2', reference: {url: 'http://loinc.org', code: 'B-2'} } + } + const rowState = { + candidates: { + bridge1: { id: 'bridge1', algorithm_id: 'ocl-bridge', concept_key: KEY_CIEL_BRIDGE, type: 'bridge', score: 0.91 }, + childA: { id: 'childA', algorithm_id: 'ocl-bridge', concept_key: KEY_LOINC_A, type: 'bridge_child', parent_candidate_id: 'bridge1', bridge_concept_key: KEY_CIEL_BRIDGE, map_type: 'SAME-AS' }, + childB: { id: 'childB', algorithm_id: 'ocl-bridge', concept_key: KEY_LOINC_B, type: 'bridge_child', parent_candidate_id: 'bridge1', bridge_concept_key: KEY_CIEL_BRIDGE, map_type: 'NARROWER-THAN' } + }, + concept_rows: {} + } + const views = buildAlgorithmRowViews(rowState, cache, 'ocl-bridge') + assert.equal(views.length, 1) + assert.equal(views[0].bridgeChildren.length, 2) + assert.deepEqual( + views[0].bridgeChildren.map(c => c.candidate.map_type).sort(), + ['NARROWER-THAN', 'SAME-AS'] + ) +}) + +test('buildAlgorithmRowViews skips candidates whose ConceptDefinition is missing from cache', () => { + const rowState = { + candidates: { + c1: { id: 'c1', algorithm_id: 'ocl-search', concept_key: 'orphan', type: 'standard', score: 0.5 } + }, + concept_rows: {} + } + assert.deepEqual(buildAlgorithmRowViews(rowState, {}, 'ocl-search'), []) +}) + +// ---------- buildQualityRowViews ---------- + +test('buildQualityRowViews returns empty array when rowState is null', () => { + assert.deepEqual(buildQualityRowViews(null, {}), []) +}) + +test('buildQualityRowViews returns one entry per ConceptRow', () => { + const cache = { [KEY_LOINC_GLUCOSE]: defLOINCGlucose, [KEY_LOINC_CHOL]: defLOINCChol } + const rowState = { + candidates: { + c1: { id: 'c1', algorithm_id: 'ocl-search', concept_key: KEY_LOINC_GLUCOSE, type: 'standard', score: 0.85 }, + c2: { id: 'c2', algorithm_id: 'ocl-search', concept_key: KEY_LOINC_CHOL, type: 'standard', score: 0.6 } + }, + concept_rows: { + [KEY_LOINC_GLUCOSE]: { concept_key: KEY_LOINC_GLUCOSE, rerank_score: 87 }, + [KEY_LOINC_CHOL]: { concept_key: KEY_LOINC_CHOL, rerank_score: 55 } + } + } + const views = buildQualityRowViews(rowState, cache) + assert.equal(views.length, 2) +}) + +test('buildQualityRowViews dedupes multi-algo convergence (one ConceptRow, multiple contributing candidates)', () => { + // Both ocl-search and ocl-semantic returned LOINC glucose — should appear + // ONCE in quality view with both candidates surfaced as contributingCandidates. + const cache = { [KEY_LOINC_GLUCOSE]: defLOINCGlucose } + const rowState = { + candidates: { + c1: { id: 'c1', algorithm_id: 'ocl-search', concept_key: KEY_LOINC_GLUCOSE, type: 'standard', score: 0.85 }, + c2: { id: 'c2', algorithm_id: 'ocl-semantic', concept_key: KEY_LOINC_GLUCOSE, type: 'standard', score: 0.78 } + }, + concept_rows: { + [KEY_LOINC_GLUCOSE]: { concept_key: KEY_LOINC_GLUCOSE, rerank_score: 87 } + } + } + const views = buildQualityRowViews(rowState, cache) + assert.equal(views.length, 1) + assert.equal(views[0].contributingCandidates.length, 2) + assert.deepEqual( + views[0].contributingCandidates.map(c => c.algorithm_id).sort(), + ['ocl-search', 'ocl-semantic'] + ) + // Primary is the highest-scoring standard candidate (ocl-search at 0.85), + // not whichever shows up first in Object.values() iteration order. Without + // the score-desc sort the choice depended on insertion order and the UI's + // "primary algorithm" chip flipped between renders. + assert.equal(views[0].candidate.id, 'c1') + assert.equal(views[0].candidate.algorithm_id, 'ocl-search') +}) + +test('buildQualityRowViews: multi-algo convergence — primary is the higher-scoring standard candidate regardless of insertion order', () => { + // Same as the dedup test, but with the lower-scoring candidate inserted + // FIRST. Pre-fix, find() returned whichever came first; post-fix, the + // score-desc sort picks ocl-semantic (0.92) over ocl-search (0.71). + const cache = { [KEY_LOINC_GLUCOSE]: defLOINCGlucose } + const rowState = { + candidates: { + c1: { id: 'c1', algorithm_id: 'ocl-search', concept_key: KEY_LOINC_GLUCOSE, type: 'standard', score: 0.71 }, + c2: { id: 'c2', algorithm_id: 'ocl-semantic', concept_key: KEY_LOINC_GLUCOSE, type: 'standard', score: 0.92 } + }, + concept_rows: { + [KEY_LOINC_GLUCOSE]: { concept_key: KEY_LOINC_GLUCOSE, rerank_score: 87 } + } + } + const views = buildQualityRowViews(rowState, cache) + assert.equal(views[0].candidate.algorithm_id, 'ocl-semantic') +}) + +test('buildQualityRowViews: bridge cascade target converges with direct match into ONE ConceptRow', () => { + // The same LOINC concept is reached via ocl-search (direct) AND via + // ocl-bridge (cascade). Quality view shows it once with both candidates + // contributing. (Multi-source convergence — spec section "Multi-source + // convergence".) + const cache = { + [KEY_LOINC_GLUCOSE]: defLOINCGlucose, + [KEY_CIEL_BRIDGE]: defCIELBridge + } + const rowState = { + candidates: { + direct: { id: 'direct', algorithm_id: 'ocl-search', concept_key: KEY_LOINC_GLUCOSE, type: 'standard', score: 0.85 }, + bridge: { id: 'bridge', algorithm_id: 'ocl-bridge', concept_key: KEY_CIEL_BRIDGE, type: 'bridge', score: 0.92 }, + child: { id: 'child', algorithm_id: 'ocl-bridge', concept_key: KEY_LOINC_GLUCOSE, type: 'bridge_child', parent_candidate_id: 'bridge', bridge_concept_key: KEY_CIEL_BRIDGE, map_type: 'SAME-AS' } + }, + concept_rows: { + [KEY_LOINC_GLUCOSE]: { concept_key: KEY_LOINC_GLUCOSE, rerank_score: 88 }, + [KEY_CIEL_BRIDGE]: { concept_key: KEY_CIEL_BRIDGE, rerank_score: 91 } + } + } + const views = buildQualityRowViews(rowState, cache) + assert.equal(views.length, 2) + + const loincView = views.find(v => v.conceptDefinition.key === KEY_LOINC_GLUCOSE) + // The LOINC target should prefer the 'standard' candidate as primary + // (per the spec: a direct hit is the user-facing primary) + assert.equal(loincView.type, 'standard') + assert.equal(loincView.candidate.id, 'direct') + // But the bridge_child also contributes + assert.equal(loincView.contributingCandidates.length, 2) + + const bridgeView = views.find(v => v.conceptDefinition.key === KEY_CIEL_BRIDGE) + assert.ok(bridgeView, 'bridge intermediary surfaces as its own row in quality view') +}) + +test('buildQualityRowViews: ConceptRow without any candidate is excluded', () => { + const cache = { [KEY_LOINC_GLUCOSE]: defLOINCGlucose } + const rowState = { + candidates: {}, + concept_rows: { + [KEY_LOINC_GLUCOSE]: { concept_key: KEY_LOINC_GLUCOSE, rerank_score: 50 } + } + } + assert.deepEqual(buildQualityRowViews(rowState, cache), []) +}) + +test('buildQualityRowViews: bridge_child becomes the primary when no standard candidate exists', () => { + // If the LOINC concept is reached ONLY via bridge (no direct ocl-search + // hit), the bridge_child becomes the primary candidate for that + // ConceptRow. Its type is 'bridge_child' so Concept.jsx renders the + // map_type chip and bridge prefix. + const cache = { [KEY_LOINC_GLUCOSE]: defLOINCGlucose, [KEY_CIEL_BRIDGE]: defCIELBridge } + const rowState = { + candidates: { + bridge: { id: 'bridge', algorithm_id: 'ocl-bridge', concept_key: KEY_CIEL_BRIDGE, type: 'bridge', score: 0.92 }, + child: { id: 'child', algorithm_id: 'ocl-bridge', concept_key: KEY_LOINC_GLUCOSE, type: 'bridge_child', parent_candidate_id: 'bridge', bridge_concept_key: KEY_CIEL_BRIDGE, map_type: 'SAME-AS' } + }, + concept_rows: { + [KEY_LOINC_GLUCOSE]: { concept_key: KEY_LOINC_GLUCOSE, rerank_score: 84 }, + [KEY_CIEL_BRIDGE]: { concept_key: KEY_CIEL_BRIDGE, rerank_score: 91 } + } + } + const views = buildQualityRowViews(rowState, cache) + const loincView = views.find(v => v.conceptDefinition.key === KEY_LOINC_GLUCOSE) + assert.equal(loincView.type, 'bridge_child') + assert.equal(loincView.bridgeConceptDefinition, defCIELBridge) +}) + +test('buildQualityRowViews: convergence — bridgeContributors lists non-primary bridge candidates', () => { + // When a target is reached by BOTH a standard algo (primary) AND a bridge, + // the rowView carries a bridgeContributors entry so the UI can render an + // [i] indicator next to the algo chip with bridge intermediary + map_type. + const cache = { + [KEY_LOINC_GLUCOSE]: defLOINCGlucose, + [KEY_CIEL_BRIDGE]: defCIELBridge + } + const rowState = { + candidates: { + direct: { id: 'direct', algorithm_id: 'ocl-search', concept_key: KEY_LOINC_GLUCOSE, type: 'standard', score: 0.85 }, + bridge: { id: 'bridge', algorithm_id: 'ocl-ciel-bridge', concept_key: KEY_CIEL_BRIDGE, type: 'bridge', score: 0.92 }, + child: { id: 'child', algorithm_id: 'ocl-ciel-bridge', concept_key: KEY_LOINC_GLUCOSE, type: 'bridge_child', parent_candidate_id: 'bridge', bridge_concept_key: KEY_CIEL_BRIDGE, map_type: 'SAME-AS' } + }, + concept_rows: { + [KEY_LOINC_GLUCOSE]: { concept_key: KEY_LOINC_GLUCOSE, rerank_score: 88 }, + [KEY_CIEL_BRIDGE]: { concept_key: KEY_CIEL_BRIDGE, rerank_score: 91 } + } + } + const views = buildQualityRowViews(rowState, cache) + const loincView = views.find(v => v.conceptDefinition.key === KEY_LOINC_GLUCOSE) + assert.equal(loincView.type, 'standard') + assert.equal(loincView.bridgeContributors.length, 1) + assert.equal(loincView.bridgeContributors[0].bridgeConceptDefinition, defCIELBridge) + assert.equal(loincView.bridgeContributors[0].map_type, 'SAME-AS') + assert.equal(loincView.bridgeContributors[0].algorithm_id, 'ocl-ciel-bridge') +}) + +test('buildQualityRowViews: bridge-only target has empty bridgeContributors (primary excluded)', () => { + // Bridge-only case: the primary IS a bridge_child, so it's NOT also in + // bridgeContributors. Inline framing in Concept.jsx already shows the + // bridge intermediary; a duplicate [i] indicator would be noise. + const cache = { [KEY_LOINC_GLUCOSE]: defLOINCGlucose, [KEY_CIEL_BRIDGE]: defCIELBridge } + const rowState = { + candidates: { + bridge: { id: 'bridge', algorithm_id: 'ocl-ciel-bridge', concept_key: KEY_CIEL_BRIDGE, type: 'bridge', score: 0.92 }, + child: { id: 'child', algorithm_id: 'ocl-ciel-bridge', concept_key: KEY_LOINC_GLUCOSE, type: 'bridge_child', parent_candidate_id: 'bridge', bridge_concept_key: KEY_CIEL_BRIDGE, map_type: 'SAME-AS' } + }, + concept_rows: { + [KEY_LOINC_GLUCOSE]: { concept_key: KEY_LOINC_GLUCOSE, rerank_score: 84 }, + [KEY_CIEL_BRIDGE]: { concept_key: KEY_CIEL_BRIDGE, rerank_score: 91 } + } + } + const views = buildQualityRowViews(rowState, cache) + const loincView = views.find(v => v.conceptDefinition.key === KEY_LOINC_GLUCOSE) + assert.equal(loincView.type, 'bridge_child') + assert.equal(loincView.bridgeContributors.length, 0) +}) + +test('buildQualityRowViews: multi-bridge case — distinct bridge intermediaries do not collide', () => { + // CIEL + SNOMED-CT both bridge to the same LOINC target. Quality view: + // ONE LOINC ConceptRow, TWO bridge ConceptRows (one per namespace). + const cache = { + [KEY_LOINC_GLUCOSE]: defLOINCGlucose, + [KEY_CIEL_BRIDGE]: defCIELBridge, + [KEY_SNOMED_BRIDGE]: defSnomedBridge + } + const rowState = { + candidates: { + bridgeC: { id: 'bC', algorithm_id: 'ocl-bridge', concept_key: KEY_CIEL_BRIDGE, type: 'bridge', score: 0.92 }, + childC: { id: 'cC', algorithm_id: 'ocl-bridge', concept_key: KEY_LOINC_GLUCOSE, type: 'bridge_child', parent_candidate_id: 'bC', bridge_concept_key: KEY_CIEL_BRIDGE, map_type: 'SAME-AS' }, + bridgeS: { id: 'bS', algorithm_id: 'ocl-snomed-bridge', concept_key: KEY_SNOMED_BRIDGE, type: 'bridge', score: 0.88 }, + childS: { id: 'cS', algorithm_id: 'ocl-snomed-bridge', concept_key: KEY_LOINC_GLUCOSE, type: 'bridge_child', parent_candidate_id: 'bS', bridge_concept_key: KEY_SNOMED_BRIDGE, map_type: 'SAME-AS' } + }, + concept_rows: { + [KEY_LOINC_GLUCOSE]: { concept_key: KEY_LOINC_GLUCOSE, rerank_score: 90 }, + [KEY_CIEL_BRIDGE]: { concept_key: KEY_CIEL_BRIDGE, rerank_score: 91 }, + [KEY_SNOMED_BRIDGE]: { concept_key: KEY_SNOMED_BRIDGE, rerank_score: 87 } + } + } + const views = buildQualityRowViews(rowState, cache) + assert.equal(views.length, 3, 'two bridges + one target') + const loincView = views.find(v => v.conceptDefinition.key === KEY_LOINC_GLUCOSE) + assert.equal(loincView.contributingCandidates.length, 2, 'both bridge_children point to the same LOINC concept') +}) + +// ---------- sortRowViews ---------- + +const fixtureViews = [ + { candidate: {score: 0.6}, conceptDefinition: {id: 'b', display_name: 'Beta'}, conceptRow: {rerank_score: 70} }, + { candidate: {score: 0.9}, conceptDefinition: {id: 'a', display_name: 'Alpha'}, conceptRow: {rerank_score: 50} }, + { candidate: {score: 0.7}, conceptDefinition: {id: 'c', display_name: 'Gamma'}, conceptRow: {rerank_score: 90} } +] + +test('sortRowViews: rerank_score desc', () => { + const out = sortRowViews(fixtureViews, 'rerank_score', 'desc') + assert.deepEqual(out.map(v => v.conceptDefinition.id), ['c', 'b', 'a']) +}) + +test('sortRowViews: algo_score desc', () => { + const out = sortRowViews(fixtureViews, 'algo_score', 'desc') + assert.deepEqual(out.map(v => v.conceptDefinition.id), ['a', 'c', 'b']) +}) + +test('sortRowViews: id asc', () => { + const out = sortRowViews(fixtureViews, 'id', 'asc') + assert.deepEqual(out.map(v => v.conceptDefinition.id), ['a', 'b', 'c']) +}) + +test('sortRowViews: display_name asc', () => { + const out = sortRowViews(fixtureViews, 'display_name', 'asc') + assert.deepEqual(out.map(v => v.conceptDefinition.display_name), ['Alpha', 'Beta', 'Gamma']) +}) + +test('sortRowViews: undefined scores sort to the bottom in desc order', () => { + const views = [ + { candidate: {}, conceptDefinition: {display_name: 'x'}, conceptRow: {} }, + { candidate: {}, conceptDefinition: {display_name: 'y'}, conceptRow: {rerank_score: 50} } + ] + const out = sortRowViews(views, 'rerank_score', 'desc') + assert.equal(out[0].conceptDefinition.display_name, 'y') +}) + +test('sortRowViews: id falls back to reference.code when id is absent', () => { + const views = [ + { candidate: {}, conceptDefinition: {reference: {code: 'z-1'}}, conceptRow: {} }, + { candidate: {}, conceptDefinition: {reference: {code: 'a-1'}}, conceptRow: {} } + ] + const out = sortRowViews(views, 'id', 'asc') + assert.equal(out[0].conceptDefinition.reference.code, 'a-1') +}) diff --git a/src/components/map-projects/algorithms.jsx b/src/components/map-projects/algorithms.jsx index f4f0dab..a5e9bc0 100644 --- a/src/components/map-projects/algorithms.jsx +++ b/src/components/map-projects/algorithms.jsx @@ -47,6 +47,47 @@ export const CONCEPT_IDENTITY_BY_TYPE = { } } +// Cheap structural validator for canonical URLs. The unified mapper model +// requires every algorithm's concept_identity.canonical_url to be a URL — +// custom algos rely on user input for this, so the form validates here. +export const isLikelyCanonicalUrl = (value) => { + if(!value || typeof value !== 'string') return false + return /^https?:\/\/[^\s]+$/i.test(value.trim()) +} + +// Walk algosSelected and return the subset that fails project-config +// validation. Currently: custom algos must have a valid canonical_url. +// (plans/unified-mapper-model.md "Algorithm concept_identity configuration — +// Custom algorithm".) Returns [{algo, reason}] for diagnostic banners. +export const getProjectConfigErrors = (algosSelected) => { + const errors = [] + ;(algosSelected || []).forEach(algo => { + if(algo?.type === 'custom' && !isLikelyCanonicalUrl(algo.canonical_url)) + errors.push({ algo, reason: 'missing_canonical_url' }) + }) + return errors +} + +// Return a copy of `algo` with `concept_identity` populated. Three sources, +// in priority order: +// 1. algo.concept_identity already set (native built-in algos define this +// inline in useAlgos below) +// 2. CONCEPT_IDENTITY_BY_TYPE[algo.type] (API-loaded algos: bridge, scispacy) +// 3. For custom algos: derive from algo.canonical_url (user-entered) +// Returns null when none of these can produce a usable concept_identity — +// callers must skip normalization for that algo. This is the single +// integration point for all read/write paths (getAlgoDef, normalizer load, +// bulk processBatch, lookupCandidates). +export const ensureConceptIdentity = (algo) => { + if(!algo) return null + if(algo.concept_identity) return algo + if(CONCEPT_IDENTITY_BY_TYPE[algo.type]) + return { ...algo, concept_identity: CONCEPT_IDENTITY_BY_TYPE[algo.type] } + if(algo.type === 'custom' && algo.canonical_url) + return { ...algo, concept_identity: { reference_source: 'fixed', canonical_url: algo.canonical_url, code_field: 'id' } } + return null +} + export const useAlgos = (t, toggles) => { const algos = [ { diff --git a/src/components/map-projects/constants.jsx b/src/components/map-projects/constants.jsx index 4b5747d..d8ae506 100644 --- a/src/components/map-projects/constants.jsx +++ b/src/components/map-projects/constants.jsx @@ -3,7 +3,7 @@ import ListIcon from '@mui/icons-material/FormatListNumbered'; import UnMappedIcon from '@mui/icons-material/LinkOff'; import MappedIcon from '@mui/icons-material/Link'; import ReviewedIcon from '@mui/icons-material/FactCheckOutlined'; -import { RECOMMEND_COLOR, AVAILABLE_COLOR, UNRANKED_COLOR } from '../../common/colors' +import { RECOMMEND_COLOR, AVAILABLE_COLOR, UNRANKED_COLOR, PENDING_RERANK_COLOR } from '../../common/colors' const ID_HEADER = {id: 'id', label: 'ID', description: 'Exact match on concept ID'} const COMMON_HEADERS = [ @@ -57,7 +57,8 @@ export const DECISION_TABS = ['candidates', 'search', 'propose', 'discuss'] export const SCORES_COLOR = { recommended: RECOMMEND_COLOR, available: AVAILABLE_COLOR, - low_ranked: UNRANKED_COLOR + low_ranked: UNRANKED_COLOR, + pending_rerank: PENDING_RERANK_COLOR } export const SEMANTIC_BATCH_SIZE = 10 diff --git a/src/components/map-projects/normalizers.js b/src/components/map-projects/normalizers.js index 8422e0b..8d3a42c 100644 --- a/src/components/map-projects/normalizers.js +++ b/src/components/map-projects/normalizers.js @@ -49,13 +49,23 @@ export const createAlgorithmResponse = (rawResponse, algorithmId, options = {}) /** * Decide a concept's lookup_status based on which fields the algorithm response - * populated. + * populated. 'full' means the response carries enough data for the UI: + * - `property` field present (the verbose-payload marker — OCL's + * ConceptDetailSerializer always emits it, even as an empty array), OR + * - a populated `names` array (the list-serializer signal — names are + * enough to render the row's display). + * Many concepts (LOINC especially) have no separate `descriptions`, so + * requiring descriptions for 'full' stranded verbose-loaded concepts at + * 'partial' and made the UI's summary chips disappear. We don't check + * `extras` because scispacy synthesizes that field with internal metadata + * (LOINC_NUM, composite_score) — not the OCL schema property dict. */ const inferLookupStatus = (result) => { if (!result) return 'pending' const hasNames = Array.isArray(result.names) && result.names.length > 0 - const hasDescriptions = Array.isArray(result.descriptions) && result.descriptions.length > 0 - if (hasNames && hasDescriptions) return 'full' + const hasVerbosePayload = Array.isArray(result.property) + || (Array.isArray(result.properties) && result.properties.length > 0) + if (result.id && result.display_name && (hasVerbosePayload || hasNames)) return 'full' if (result.id && result.display_name) return 'partial' return 'pending' } @@ -120,6 +130,14 @@ const toConceptDefinition = (result, reference, identityConfig, { algorithmId } datatype: result?.datatype, retired: result?.retired, properties: result?.properties, + // `property` (singular) is the schema-specific property dict OCL returns + // for sources like LOINC (COMPONENT/PROPERTY/TIME_ASPCT/etc.) sourced from + // ConceptDetailSerializer.property = JSONField(source='properties'). + // ConceptSummaryProperties.jsx reads this field directly. Without it the + // verbose payload's schema chips never reach the UI even when $match + // returns them. + property: result?.property, + extras: result?.extras, lookup_status: inferLookupStatus(result), lookup_source_type: 'algorithm', lookup_source: algorithmId @@ -163,15 +181,28 @@ const cascadeTargetToConceptDefinition = (mapping, cascadeIdentity, projectConte datatype: undefined, retired: undefined, properties: undefined, + property: undefined, + extras: undefined, lookup_status: 'pending', lookup_source_type: undefined, lookup_source: undefined } } -const newConceptRow = (conceptKey) => ({ +// rerank_score on ConceptRow comes from search_normalized_score ONLY when +// the caller signals it came from a reranker-true single-algo $match path +// — that's the path where the server-side scores ARE the unified rerank. +// In all other paths (multi-algo, bridge, scispacy, custom) the response's +// search_normalized_score is just the per-algo native score (e.g. FAISS +// similarity × 100), which the OCL server emits unconditionally. Treating +// that as a unified rerank score yields chip values like "100.00%" for +// every top semantic candidate until the debounced $rerank/ pipeline runs +// and overwrites it — misleading during the interim window. +const newConceptRow = (conceptKey, result, trustServerRerank = false) => ({ concept_key: conceptKey, - rerank_score: undefined + rerank_score: trustServerRerank && typeof result?.search_meta?.search_normalized_score === 'number' + ? result.search_meta.search_normalized_score + : undefined }) const isBridgeResult = (identityConfig) => @@ -192,7 +223,7 @@ export const normalizeAlgoResult = (result, ctx = {}) => { const empty = { candidates: [], concept_definitions: [], concept_rows: [] } if (!result) return empty - const { algorithmId, algorithmConfig, algorithmResponseId, projectContext } = ctx + const { algorithmId, algorithmConfig, algorithmResponseId, projectContext, trustServerRerank } = ctx const identityConfig = algorithmConfig?.concept_identity if (!identityConfig) return empty @@ -209,7 +240,7 @@ export const normalizeAlgoResult = (result, ctx = {}) => { const primaryDef = toConceptDefinition(result, primaryReference, identityConfig, { algorithmId }) conceptDefinitions.push(primaryDef) - conceptRows.push(newConceptRow(primaryDef.key)) + conceptRows.push(newConceptRow(primaryDef.key, result, trustServerRerank)) const primaryCandidate = { id: newId(), @@ -235,6 +266,9 @@ export const normalizeAlgoResult = (result, ctx = {}) => { // Avoid duplicate ConceptDefinition entries within the same result. if (!conceptDefinitions.some(cd => cd.key === targetDef.key)) { conceptDefinitions.push(targetDef) + // Cascade target's row carries no rerank_score yet — the bridge + // response doesn't score targets, so the debounced rerank pass + // will fill it once the row is eligible. conceptRows.push(newConceptRow(targetDef.key)) } @@ -283,7 +317,8 @@ export const normalizeAlgorithmInvocation = (rawPayload, ctx = {}) => { rowIndex, status = 'success', error, - rawResponse + rawResponse, + trustServerRerank } = ctx const algorithmResponse = createAlgorithmResponse( @@ -303,7 +338,8 @@ export const normalizeAlgorithmInvocation = (rawPayload, ctx = {}) => { algorithmId, algorithmConfig, algorithmResponseId: algorithmResponse.id, - projectContext + projectContext, + trustServerRerank }) allCandidates.push(...candidates) concept_definitions.forEach(cd => { @@ -330,3 +366,99 @@ export const normalizeAlgorithmInvocation = (rawPayload, ctx = {}) => { const LOOKUP_RANK = { pending: 0, failed: 0, partial: 1, full: 2 } export const lookupStatusRank = (status) => LOOKUP_RANK[status] ?? 0 + +/** + * Backfill rowMatchState + ConceptDefinitions from the legacy + * `allCandidates` shape (a saved-project artifact: `{ [algoId]: [{row, + * results}, ...] }`). Called on project load so that v1-saved projects + * render correctly under UNIFIED_MODEL_ENABLED=true. A precursor to + * PR3's `normalizeLegacy.js`. + * + * Pure function: no React, no APIService, no mutation of inputs. + * + * @param {Object} allCandidates { [algoId]: [{row, results}, ...] } + * @param {Object} projectContext {namespace, target_repo, bridge_repo?} + * @param {Array} algorithms algo defs (may carry concept_identity) + * @param {Object} [conceptIdentityByType] optional fallback map for algos + * missing concept_identity (e.g. API- + * loaded bridge/scispacy variants). + * @returns {{ + * rowMatchState: Object, keyed by row __index + * conceptDefinitionsByKey: Map + * }} + */ +export const normalizeLegacyAllCandidates = ( + allCandidates, + projectContext, + algorithms, + conceptIdentityByType = {} +) => { + const rowMatchState = {} + const conceptDefinitionsByKey = new Map() + if(!allCandidates || !projectContext) return { rowMatchState, conceptDefinitionsByKey } + + const algoById = new Map((algorithms || []).map(a => [a.id, a])) + + Object.entries(allCandidates).forEach(([algoId, rowEntries]) => { + const algoDef = algoById.get(algoId) + if(!algoDef) return + const algoConfig = algoDef.concept_identity + ? algoDef + : (conceptIdentityByType[algoDef.type] + ? { ...algoDef, concept_identity: conceptIdentityByType[algoDef.type] } + : null) + if(!algoConfig) return + + ;(rowEntries || []).forEach(rowEntry => { + const idx = rowEntry?.row?.__index + if(idx === undefined || idx === null) return + + const normalized = normalizeAlgorithmInvocation( + { row: rowEntry.row, results: rowEntry.results || [] }, + { + algorithmId: algoId, + algorithmConfig: algoConfig, + projectContext, + rowIndex: idx, + // Saved-project legacy data: search_normalized_score was persisted + // from a prior session's $rerank/ output, so it IS the canonical + // rerank score. Honor it (no client-side rerank will fire for + // already-loaded rows). + trustServerRerank: true + } + ) + + const prevRow = rowMatchState[idx] || { + algorithm_responses: {}, + candidates: {}, + concept_rows: {} + } + const nextRow = { + algorithm_responses: { + ...prevRow.algorithm_responses, + [normalized.algorithm_response.id]: normalized.algorithm_response + }, + candidates: { ...prevRow.candidates }, + concept_rows: { ...prevRow.concept_rows } + } + normalized.candidates.forEach(c => { nextRow.candidates[c.id] = c }) + normalized.concept_rows.forEach(cr => { + const existing = nextRow.concept_rows[cr.concept_key] + // Existing entry keeps its rerank_score (richer wins); new arrivals + // are taken only when no entry exists yet. + if(!existing) nextRow.concept_rows[cr.concept_key] = cr + else if(existing.rerank_score === undefined && cr.rerank_score !== undefined) + nextRow.concept_rows[cr.concept_key] = cr + }) + rowMatchState[idx] = nextRow + + normalized.concept_definitions.forEach(def => { + const existing = conceptDefinitionsByKey.get(def.key) + if(!existing || lookupStatusRank(def.lookup_status) > lookupStatusRank(existing.lookup_status)) + conceptDefinitionsByKey.set(def.key, def) + }) + }) + }) + + return { rowMatchState, conceptDefinitionsByKey } +} diff --git a/src/components/map-projects/viewBuilders.js b/src/components/map-projects/viewBuilders.js new file mode 100644 index 0000000..bda9d16 --- /dev/null +++ b/src/components/map-projects/viewBuilders.js @@ -0,0 +1,286 @@ +/** + * Pure functions that project the unified-model state (RowMatchState + + * conceptCache) into the row-view tuples consumed by Candidates.jsx / + * Concept.jsx / Score.jsx. + * + * Kept in a plain .js file (no JSX) so they're loadable by Node's native + * ESM resolver under node:test. Components import these from + * `./viewBuilders.js`; tests in `__tests__/views.test.js` exercise them + * directly without webpack/babel. + * + * See plans/unified-mapper-model.md "How the views map onto this model". + */ + +// Match normalizers.js / conceptKey.js — pure JS, no lodash deps, no JSX +// imports, so the module is loadable by Node's native ESM resolver under +// node:test. + +const compact = arr => (arr || []).filter(x => x != null && x !== false) +const values = obj => Object.values(obj || {}) +const isNumber = v => typeof v === 'number' && !Number.isNaN(v) + +/** + * Build a "RowView" object for a single Candidate. Joins the Candidate + * with its ConceptDefinition from conceptCache and its ConceptRow from + * the row's state. Returns null if the ConceptDefinition is missing. + */ +export const candidateToRowView = (candidate, conceptCache, rowState) => { + if(!candidate) return null + const conceptDefinition = conceptCache?.[candidate.concept_key] + if(!conceptDefinition) return null + const conceptRow = rowState?.concept_rows?.[candidate.concept_key] + let bridgeConceptDefinition + if(candidate.type === 'bridge_child' && candidate.bridge_concept_key) + bridgeConceptDefinition = conceptCache[candidate.bridge_concept_key] + return { + type: candidate.type, + candidate, + conceptDefinition, + conceptRow, + bridgeConceptDefinition + } +} + +/** + * Algorithm-grouped view: iterate `RowState.candidates` filtered by + * algorithm_id. Bridge candidates carry their bridge_children for nested + * rendering. bridge_child candidates do NOT appear at the top level — + * they are nested under their parent bridge. + */ +// Deterministic order for bridge_children under a bridge intermediary: +// primary key is rerank_score desc (when both have one), secondary is the +// concept code/id ascending. Without this, children appeared in +// Object.values() insertion order, which flipped between renders and made +// the same project look different each refresh. +const sortBridgeChildren = (rowViews) => { + const codeOf = (v) => v?.conceptDefinition?.id || v?.conceptDefinition?.reference?.code || '' + return [...(rowViews || [])].sort((a, b) => { + const sa = a?.conceptRow?.rerank_score + const sb = b?.conceptRow?.rerank_score + const haveA = typeof sa === 'number' && !Number.isNaN(sa) + const haveB = typeof sb === 'number' && !Number.isNaN(sb) + if(haveA && haveB && sa !== sb) return sb - sa + if(haveA && !haveB) return -1 + if(!haveA && haveB) return 1 + return codeOf(a).localeCompare(codeOf(b)) + }) +} + +export const buildAlgorithmRowViews = (rowState, conceptCache, algoId) => { + if(!rowState) return [] + const all = values(rowState.candidates || {}).filter(c => c.algorithm_id === algoId) + const standalone = all.filter(c => c.type !== 'bridge_child') + return compact(standalone.map(candidate => { + const view = candidateToRowView(candidate, conceptCache, rowState) + if(!view) return null + if(view.type === 'bridge') { + const children = all.filter(c => c.type === 'bridge_child' && c.parent_candidate_id === candidate.id) + view.bridgeChildren = sortBridgeChildren(compact(children.map(c => candidateToRowView(c, conceptCache, rowState)))) + } + return view + })) +} + +/** + * Quality-grouped view: iterate `RowState.concept_rows`. One entry per + * concept_key (the per-row presence of a concept). Each entry exposes + * its rerank_score plus a list of contributing candidates so the UI can + * show algorithm provenance. Bridge children surface here as their + * target concept (no special-casing needed — the bridge_child Candidate's + * concept_key IS the target concept's key). + */ +export const buildQualityRowViews = (rowState, conceptCache) => { + if(!rowState) return [] + const allCandidates = values(rowState.candidates || {}) + const conceptRows = values(rowState.concept_rows || {}) + return compact(conceptRows.map(conceptRow => { + const conceptDefinition = conceptCache?.[conceptRow.concept_key] + if(!conceptDefinition) return null + const contributing = allCandidates.filter(c => c.concept_key === conceptRow.concept_key) + // Prefer a 'standard' candidate as the primary; else any bridge_child + // (with its bridge intermediary attached); else whatever's there. + // Within each type group, pick the highest-scoring candidate so the + // primary is deterministic — without this, multi-algo convergence + // (e.g. both ocl-search and ocl-semantic returning the same concept) + // selected by Object.values() iteration order and the "primary algorithm" + // chip flipped between renders. Falls back to -Infinity for unscored + // candidates (notably bridge_child, which has no own score). + const byScoreDesc = (a, b) => (b?.score ?? -Infinity) - (a?.score ?? -Infinity) + const primary = [...contributing.filter(c => c.type === 'standard')].sort(byScoreDesc)[0] + || [...contributing.filter(c => c.type === 'bridge_child')].sort(byScoreDesc)[0] + || contributing[0] + if(!primary) return null + let bridgeConceptDefinition + if(primary.type === 'bridge_child' && primary.bridge_concept_key) + bridgeConceptDefinition = conceptCache[primary.bridge_concept_key] + // Collect bridge contributors OTHER than the primary so the convergence + // case (target reached by both a standard algo AND one or more bridges) + // can surface the bridge story on the algo-chip line via [i] tooltip + // without taking over the row's primary framing. + const bridgeContributors = compact(contributing + .filter(c => c.type === 'bridge_child' && c !== primary) + .map(c => { + const bridgeDef = c.bridge_concept_key ? conceptCache?.[c.bridge_concept_key] : null + return bridgeDef ? { + bridgeConceptDefinition: bridgeDef, + map_type: c.map_type, + algorithm_id: c.algorithm_id + } : null + })) + return { + type: primary.type === 'bridge_child' ? 'bridge_child' : 'standard', + candidate: primary, + conceptDefinition, + conceptRow, + bridgeConceptDefinition, + bridgeContributors, + contributingCandidates: contributing + } + })) +} + +/** + * Sort RowViews by the chosen key. `rerank_score` and `algo_score` are + * numeric; `id` and `display_name` are alphabetical. Missing scores sort + * to the bottom in `desc` order via a -1 sentinel. + */ +export const sortRowViews = (views, sortBy, order) => { + const valueFor = view => { + switch(sortBy) { + case 'rerank_score': return view.conceptRow?.rerank_score ?? -1 + case 'algo_score': return view.candidate?.score ?? -1 + case 'id': return view.conceptDefinition?.id || view.conceptDefinition?.reference?.code || '' + case 'display_name': return view.conceptDefinition?.display_name || '' + default: return 0 + } + } + const dir = order === 'desc' ? -1 : 1 + const compare = (a, b) => { + const va = valueFor(a) + const vb = valueFor(b) + if(va < vb) return -1 * dir + if(va > vb) return 1 * dir + return 0 + } + return [...(views || [])].sort(compare) +} + +/** + * Project a unified-model tuple back into a legacy concept-shaped object, + * used at the onMap / isSelectedForMap boundary. mapSelected and + * downstream consumers (decision view, getRows, save format) continue to + * expect the legacy shape; PR3 migrates them to consume the tuple + * directly. + */ +export const conceptForMapping = (rowView) => { + if(!rowView) return null + const { candidate, conceptDefinition, conceptRow, bridgeConceptDefinition } = rowView + if(!conceptDefinition) return null + return { + id: conceptDefinition.id || conceptDefinition.reference?.code, + display_name: conceptDefinition.display_name, + url: conceptDefinition.ocl_url, + source: conceptDefinition.source, + owner: conceptDefinition.owner, + names: conceptDefinition.names, + descriptions: conceptDefinition.descriptions, + concept_class: conceptDefinition.concept_class, + datatype: conceptDefinition.datatype, + retired: conceptDefinition.retired, + properties: conceptDefinition.properties, + // `property` (singular) is the schema-specific dict — LOINC's + // COMPONENT/PROPERTY/TIME_ASPCT/etc. ConceptSummaryProperties reads + // this directly. Without it in the projection, table view rows render + // without the schema chips (the card view works because it gets the + // ConceptDefinition directly). + property: conceptDefinition.property, + extras: conceptDefinition.extras, + type: 'Concept', + search_meta: { + algorithm: candidate?.algorithm_id, + search_score: candidate?.score, + search_normalized_score: conceptRow?.rerank_score, + search_highlight: candidate?.highlights, + map_type: candidate?.map_type + }, + bridge_concept: bridgeConceptDefinition ? { + id: bridgeConceptDefinition.id || bridgeConceptDefinition.reference?.code, + url: bridgeConceptDefinition.ocl_url, + display_name: bridgeConceptDefinition.display_name, + source: bridgeConceptDefinition.source + } : undefined + } +} + +/** + * Compute the unified score (0-100 percentile) and the raw algorithm score + * for a candidate+row. Unified score = per-(row, concept) rerank score + * from the ConceptRow. Raw score = per-algorithm score on the Candidate. + * + * Accepts either shape: + * - {candidate, conceptRow} — the unified-model tuple + * - {search_meta: {search_normalized_score, search_score}} — the legacy + * concept shape (also produced by conceptForMapping projection). + * Both shapes coexist while PR3-era cleanup is pending; the dialog passing + * `conceptForMapping(tuple)` to setShowHighlights needs the legacy path. + * + * Pure — caller maps qualityBucket -> bucketColor via SCORES_COLOR. + */ +export const getScoreDetails = (input = {}, candidatesScore = {}) => { + const {candidate, conceptRow} = input || {} + if(!input?.search_meta && candidate?.search_meta?.search_score) { + candidate.search_meta.search_score = parseFloat(candidate.search_meta.search_score) + candidate.search_meta.search_normalized_score = candidate.search_meta.search_normalized_score ? parseFloat(candidate.search_meta.search_normalized_score) : candidate.search_meta.search_normalized_score + } + const searchMeta = input?.search_meta || candidate?.search_meta + const rerankFloat = isNumber(conceptRow?.rerank_score) + ? conceptRow.rerank_score + : (isNumber(searchMeta?.search_normalized_score) ? searchMeta.search_normalized_score : null) + const score = isNumber(candidate?.score) + ? candidate.score + : (isNumber(searchMeta?.search_score) ? searchMeta.search_score : null) + // ConceptRow.rerank_score is already on the 0-100 scale (the rerank API + // returns search_normalized_score in that range). Display directly. + // No fallback to `score * 100`: an interim semantic-search candidate has + // candidate.score ≈ 1.0 and a pending rerank — scaling that produces a + // misleading 100% chip until rerank lands. Leave percentile undefined so + // the UI can render a placeholder. + const percentile = rerankFloat !== null ? rerankFloat : undefined + + const hasPercentile = isNumber(percentile) + const recommendedScore = candidatesScore?.recommended + const availableScore = candidatesScore?.available + + let qualityBucket + if(hasPercentile) { + if(percentile >= recommendedScore) qualityBucket = 'recommended' + else if(percentile >= availableScore) qualityBucket = 'available' + else qualityBucket = 'low_ranked' + } + + const rerankScore = hasPercentile ? `${parseFloat(percentile).toFixed(2)}%` : '' + const algoScore = isNumber(score) ? `${parseFloat(score).toFixed(2)}` : '' + + return { + score, + percentile, + hasPercentile, + qualityBucket, + rerankScore, + algoScore + } +} + +/** + * Resolve an AI Assistant primary_candidate / alternative_candidate to a + * displayable concept code. Resolution order: + * 1. concept_key -> conceptCache[key].reference.code (v2, preferred) + * 2. canonical_reference.code (v2 fallback, PR2a shim) + * 3. concept_id / id (legacy v1) + */ +export const resolveAICandidateID = (candidate, conceptCache) => { + if(!candidate) return null + if(candidate.concept_key && conceptCache?.[candidate.concept_key]?.reference?.code) + return conceptCache[candidate.concept_key].reference.code + return candidate.canonical_reference?.code || candidate.concept_id || candidate.id || null +} diff --git a/src/i18n/config.js b/src/i18n/config.js index d84ca33..e7d166e 100644 --- a/src/i18n/config.js +++ b/src/i18n/config.js @@ -19,7 +19,8 @@ i18n.use(initReactI18next).init({ } }, ns: ['translations'], - defaultNS: 'translations' + defaultNS: 'translations', + interpolation: { escapeValue: false } }); i18n.languages = ['en', 'es', 'zh']; diff --git a/src/i18n/locales/en/translations.json b/src/i18n/locales/en/translations.json index e78248f..361b47a 100644 --- a/src/i18n/locales/en/translations.json +++ b/src/i18n/locales/en/translations.json @@ -608,6 +608,22 @@ "model": "Model", "bridge_source_url": "Bridge Source URL", "bridge_source_url_description": "The interface terminology to search through for bridge matching", + "target_canonical_url": "Canonical:", + "canonical_auto_derived": "Auto-derived", + "canonical_auto_derived_short": "derived", + "bridge_canonical_short": "Bridge:", + "bridge_canonical_url": "Bridge Canonical URL", + "bridge_canonical_url_description": "Canonical URL of the bridge code system (leave blank to derive from the relative URL).", + "advanced_settings": "Advanced settings", + "resolution_namespace": "Resolution Namespace", + "resolution_namespace_description": "Namespace passed to $resolveReference. When blank, defaults to {{owner}}. Drives which URL Registry entries apply when resolving canonical URLs.", + "algo_canonical_url": "Canonical URL", + "algo_canonical_url_required": "Canonical URL is required for custom algorithms (e.g. http://loinc.org).", + "algo_canonical_url_invalid": "Enter a full URL starting with http:// or https://", + "algo_canonical_url_description": "Canonical URL of the code system this algorithm matches against (e.g. http://loinc.org).", + "config_errors_title": "Project configuration is incomplete", + "config_error_missing_canonical": "Custom algorithm \"{{name}}\" is missing a valid canonical URL.", + "target_repo_required_on_load": "This project is missing a target repository. Configure the target repository to see saved candidates.", "create_similar": "Create similar", "create_similar_name": "Copy of {{name}}" },