From 41057bfd8ad36ec5dcd62ef598216db0f743871f Mon Sep 17 00:00:00 2001 From: Jonathan Payne Date: Mon, 11 May 2026 17:31:24 -0400 Subject: [PATCH 01/29] OpenConceptLab/ocl_issues#2337 | PR2b: read-side flip + ensureLoaded + UI config + flag ON Flips reads from `allCandidates` to `rowMatchState + conceptCache` via structured tuples, lands $resolveReference-backed $lookup as `ensureLoaded`, adds canonical_url / namespace / bridge_repos[] to the project config UI, debounces the rerank trigger, and turns UNIFIED_MODEL_ENABLED to true as the last step. Stage-by-stage: - A: fix latent PR1 keying bug in mergeIntoRowMatchState (def.url -> def.key, cr.concept_url -> cr.concept_key); extract normalizeLegacyAllCandidates into normalizers.js and call it from fetchAndSetProject so reloaded v1 projects backfill rowMatchState under the flag. - B: ensureLoaded(conceptKeys) over batched POST /$resolveReference/ with in-flight Promise dedup; replaces lookupCandidates/lookupCode. - C: MultiAlgoSelector adds canonical_url for custom algos and bridge canonical_url, drops lookup_required; ConfigurationForm surfaces target_repo canonical (with Auto-derived badge), bridge_repos[], and namespace under an Advanced disclosure; namespace persisted on save/load. - D: extract pure builders to viewBuilders.js (buildAlgorithmRowViews, buildQualityRowViews, candidateToRowView, sortRowViews, conceptForMapping, getScoreDetails, resolveAICandidateID); refactor Candidates/Concept/Score to consume {candidate, conceptDefinition, conceptRow, bridgeConceptDefinition?} tuples directly; rewrite setAutoMatched + setStateViews to read from rowMatchState + conceptCache via pickTopRowView; bulk processBatch path now merges into rowMatchState before setStateViews consumes it. - E: rerank trigger is debounce + in-flight check (scheduleRerank from mergeIntoRowMatchState); rerank writes rerank_score directly onto ConceptRow; normalizer lifts search_normalized_score onto ConceptRow.rerank_score for the single-algo reranker:true path (no separate $rerank round-trip). - F: resolveAICandidateID resolves primary_candidate.concept_key via conceptCache (preferred) with PR2a's canonical_reference.code + legacy concept_id/id fallbacks. - G: UNIFIED_MODEL_ENABLED = true. Tests: 27 -> 79. New suites in __tests__/views.test.js, __tests__/normalizeLegacyAllCandidates.test.js, __tests__/viewHelpers.test.js cover view-layer joins (incl. bridge fan-out + multi-algo + multi-bridge convergence), legacy backfill round-trip, score-bucketing, the legacy-shape projection, and AI ID resolution. Bridge fan-out at the view layer is now unit-tested even though the live bridge algo only attaches in production builds. Browser-verified against prod API from local dev: save/load round-trip is byte-identical on rerank_scores; no $match re-fire for previously-matched rows after reload. Not in this PR (PR3 work): - Drop legacy allCandidates state, schema-v2 save format, normalizeLegacy.js for v1 backward compat - Drop legacy `candidates` field + payload_version + concept_id/id shims from AI Assistant payload - Pagination append path under unified model Bridge code path untested locally (BridgeMatchStub.canBridge() false in OSS builds); staging verification is the gate before merging to main. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/components/map-projects/Candidates.jsx | 221 +++--- src/components/map-projects/Concept.jsx | 263 +++---- .../map-projects/ConfigurationForm.jsx | 84 ++- src/components/map-projects/MapProject.jsx | 684 ++++++++++++++---- .../map-projects/MultiAlgoSelector.jsx | 56 +- src/components/map-projects/Score.jsx | 57 +- .../normalizeLegacyAllCandidates.test.js | 251 +++++++ .../__tests__/normalizers.test.js | 33 +- .../__tests__/viewHelpers.test.js | 216 ++++++ .../map-projects/__tests__/views.test.js | 382 ++++++++++ src/components/map-projects/normalizers.js | 106 ++- src/components/map-projects/viewBuilders.js | 216 ++++++ 12 files changed, 2128 insertions(+), 441 deletions(-) create mode 100644 src/components/map-projects/__tests__/normalizeLegacyAllCandidates.test.js create mode 100644 src/components/map-projects/__tests__/viewHelpers.test.js create mode 100644 src/components/map-projects/__tests__/views.test.js create mode 100644 src/components/map-projects/viewBuilders.js diff --git a/src/components/map-projects/Candidates.jsx b/src/components/map-projects/Candidates.jsx index 4fa61d9..7742f19 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,14 @@ import ConceptIcon from '../concepts/ConceptIcon' import MapButton from './MapButton' import AICandidatesAnalysis from './AICandidatesAnalysis' import AIAssistantButton from './AIAssistantButton' +import { + buildAlgorithmRowViews, + buildQualityRowViews, + candidateToRowView, + sortRowViews, + conceptForMapping, + resolveAICandidateID +} from './viewBuilders.js' const getRowProgressLabel = (stageMap, algos) => { if(stageMap === undefined) @@ -108,10 +109,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 +184,8 @@ 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}) => { + const results = {total: onFetchMore ? rowViews?.length : 1, results: rowViews || []} const isCollapsed = collapsed.includes(bucketId) const onCollapseToggle = () => { onCollapse(isCollapsed ? without(collapsed, bucketId): [...collapsed, bucketId]) @@ -197,13 +198,14 @@ const CandidateList = ({candidates, header, rowIndex, orderBy, order, setShowIte id: 'mappings', labelKey: 'common.action', align: 'center', - renderer: item => { + renderer: rowView => { + 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 +218,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 +276,25 @@ 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 +307,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 +326,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, 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 +343,35 @@ 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) + + const qualityRowViews = React.useMemo(() => buildQualityRowViews(rowState, conceptCache), [rowState, conceptCache]) + const hasAnyView = qualityRowViews.length > 0 + const isNoneLoaded = !rowState || (isEmpty(rowState.candidates) && isEmpty(rowState.algorithm_responses)) + const canFetchMore = hasAnyView + const algoStagesValue = values(rowStage || {}).filter((_, i, arr) => 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,14 +381,13 @@ const Candidates = ({rowIndex, alert, setAlert, candidates, setShowItem, showIte locales: locales, candidatesScore: candidatesScore, algoScoreFirst: algoScoreFirst, - conceptCache: conceptCache, isCoreUser: isCoreUser } 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') { + if(option === sortBy || option === 'rerank_score') { + setSortBy('rerank_score') + } else if(option === 'algo_score') { setSortBy(option) setGroupBy('algorithm') } else { @@ -353,67 +403,46 @@ 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, + candidates: sortRowViews(views, sortBy, order) + })) } } + let recommended = [] + let available = [] + let lowRanked = [] + sortRowViews(qualityRowViews, sortBy, order).forEach(view => { + let score = view.conceptRow?.rerank_score || 0 + if(byScore) { + 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 } } - const { byAlgoCandidates } = getCandidates() - const { recommended, available, lowRanked } = getCandidates() + const { byAlgoCandidates, recommended, available, lowRanked } = getCandidates() + const getRightControls = () => { return ( @@ -520,8 +549,8 @@ const Candidates = ({rowIndex, alert, setAlert, candidates, setShowItem, showIte <>
  • : : { @@ -41,39 +40,17 @@ 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, 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 bridgeMappingPrefix = bridgeConceptDefinition + ? `${bridgeConceptDefinition.source || ''}:${bridgeConceptDefinition.id || bridgeConceptDefinition.reference?.code} ${bridgeConceptDefinition.display_name || ''}` + : false + const showHighlightsPayload = setShowHighlights + ? () => setShowHighlights({candidate, conceptDefinition, conceptRow, bridgeConceptDefinition}) + : null return ( <> { !bridgeChild && - {`${concept?.source || concept?.repo?.short_code || concept?.repo?.id || concept?.search_meta?.source}:${concept?.id}`} + {`${sourceLabel || ''}:${idLabel}`} } { - !bridgeChild && + !bridgeChild && { - !bridge && synonymPrefix && + !bridgeConceptDefinition && synonymPrefix && } - {concept?.display_name} + {conceptDefinition?.display_name} } { @@ -103,18 +80,27 @@ const Item = ({concept, setShowHighlights, onMap, isSelectedForMap, noScore, rep {!bridgeChild && } { - bridgeChild ? - : - (`[${mapping.map_type}]`) + bridgeChild && candidate?.map_type ? + : + (candidate?.map_type ? `[${candidate.map_type}]` : '') } {!bridgeChild && } - {getConceptDisplay()} + + {bridgeChild + ? `${sourceLabel || ''}:${idLabel} ${conceptDefinition?.display_name || ''}` + : ( + conceptDefinition?.display_name + ? `${sourceLabel || ''}:${idLabel} ${conceptDefinition.display_name}` + : '' + ) + } + } { - concept?.retired && + conceptDefinition?.retired && } @@ -122,12 +108,12 @@ const Item = ({concept, setShowHighlights, onMap, isSelectedForMap, noScore, rep secondary={
    - +
    { - showAlgo && concept?.search_meta?.algorithm ? + showAlgo && candidate?.algorithm_id ?
    - +
    : null }
    @@ -137,14 +123,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 +178,135 @@ 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 +// `concept` here is a row view object built by Candidates.jsx from +// rowMatchState + conceptCache. Shape: +// { +// type: 'standard' | 'bridge' | 'bridge_child', +// candidate, conceptDefinition, conceptRow, +// bridgeConceptDefinition?, // when type='bridge_child' +// bridgeChildren? // when type='bridge' (algo view nested rendering) +// } +const Concept = ({_id, firstChild, lastChild, concept, setShowHighlights, isShown, onCardClick, onMap, isSelectedForMap, noScore, repoVersion, isAIRecommended, sx, notClickable, noSynonymPrefix, locales, showAlgo, candidatesScore, algoScoreFirst, asTarget, AIRecommendedCandidateId}) => { + if(!concept?.conceptDefinition) return null + const { type, candidate, conceptDefinition, conceptRow, bridgeConceptDefinition, bridgeChildren } = concept + 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} + repoVersion={repoVersion} + synonymPrefix={synonymPrefix} + setShowHighlights={setShowHighlights} + isAIRecommended={isAIRecommended || isAIMatch} + isSelectedForMap={isSelectedForMap} + 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..591ea4f 100644 --- a/src/components/map-projects/ConfigurationForm.jsx +++ b/src/components/map-projects/ConfigurationForm.jsx @@ -18,8 +18,16 @@ import SettingsIcon from '@mui/icons-material/Settings'; import SaveIcon from '@mui/icons-material/Save'; import OpenInNewIcon from '@mui/icons-material/OpenInNew'; +import Accordion from '@mui/material/Accordion' +import AccordionSummary from '@mui/material/AccordionSummary' +import AccordionDetails from '@mui/material/AccordionDetails' +import Chip from '@mui/material/Chip' +import Stack from '@mui/material/Stack' +import ExpandMoreIcon from '@mui/icons-material/ExpandMore' + import isEmpty from 'lodash/isEmpty' import omit from 'lodash/omit' +import filter from 'lodash/filter' import { toV3URL } from '../../common/utils' import NamespaceDropdown from '../common/NamespaceDropdown' @@ -48,10 +56,18 @@ 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 || '' const getAlgos = () => { return algos.map(algo => { if(algo.type === 'ocl-semantic') @@ -151,6 +167,49 @@ 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 URL:'} + + + {effectiveTargetCanonical} + + { + !targetCanonicalFromRepo && + + } + + } + { + bridgeAlgos.length > 0 && + + + {t('map_project.bridge_repos') || 'Bridge repos:'} + + { + bridgeAlgos.map(b => { + const bridgeCanonical = b?.bridge_repo?.canonical_url || deriveCanonicalUrl(b?.target_repo_url) + return ( + + + {b.target_repo_url || '—'} + + + + {bridgeCanonical} + + { + !b?.bridge_repo?.canonical_url && + + } + + ) + }) + } + + } { isLLMAlgoNotAllowed && repoVersion?.version_url && @@ -212,6 +271,29 @@ const ConfigurationForm = ({ project, handleFileUpload, file, owner, setOwner, n + { + setNamespace && + + } sx={{padding: 0, minHeight: 'auto', '.MuiAccordionSummary-content': {margin: '4px 0'}}}> + + {t('map_project.advanced_settings') || 'Advanced settings'} + + + + setNamespace(event.target.value || '')} + placeholder={defaultNamespace} + helperText={t('map_project.resolution_namespace_description') || 'Namespace passed to $resolveReference (defaults to the project owner). Drives which URL Registry entries apply when resolving canonical URLs.'} + /> + + + } + { inAIAssistantGroup && isCoreUser && promptTemplates?.length > 0 && <> diff --git a/src/components/map-projects/MapProject.jsx b/src/components/map-projects/MapProject.jsx index f3fcaf2..f15c0aa 100644 --- a/src/components/map-projects/MapProject.jsx +++ b/src/components/map-projects/MapProject.jsx @@ -69,7 +69,7 @@ import pick from 'lodash/pick' import { OperationsContext } from '../app/LayoutContext'; import APIService from '../../services/APIService'; -import { highlightTexts, dropVersion, getCurrentUser, URIToParentParams, hasAuthGroup, downloadObject } from '../../common/utils'; +import { highlightTexts, dropVersion, getCurrentUser, URIToParentParams, hasAuthGroup, downloadObject, currentUserToken } from '../../common/utils'; import { WHITE, SURFACE_COLORS } from '../../common/colors'; import { useDoubleClick } from '../common/useDoubleClick' @@ -99,21 +99,26 @@ import ProjectLogs from './ProjectLogs'; import { useAlgos, CONCEPT_IDENTITY_BY_TYPE } from './algorithms' import AutoMatchDialog from './AutoMatchDialog' import { DEFAULT_ENCODER_MODEL } from './rerankerModels' -import { normalizeAlgorithmInvocation, lookupStatusRank } from './normalizers' +import { normalizeAlgorithmInvocation, lookupStatusRank, normalizeLegacyAllCandidates } from './normalizers' +import { parseConceptKey } from './conceptKey' +import { buildQualityRowViews, conceptForMapping, resolveAICandidateID } from './viewBuilders.js' import './MapProject.scss' import '../common/ResizablePanel.scss' /** * Feature flag for the unified candidate/concept data model - * (plans/unified-mapper-model.md, ocl_issues#2337). When OFF (default), this - * file behaves exactly as before. When ON, algorithm responses are *also* - * normalized into the new RowMatchState shape (in parallel with the legacy - * allCandidates state). PR 2 will flip reads to consume the new state and - * remove the legacy path. PR 1 deliberately keeps reads on the legacy state - * so behavior is unchanged. + * (plans/unified-mapper-model.md, ocl_issues#2337). Flipped to true in PR2b + * once: (a) the write-side parallel state was wired up in PR1, (b) PR2a + * routed all algorithm types (bridge, scispacy, AI payload v2) through the + * normalizer, and (c) PR2b flipped reads (Candidates / Concept / Score / + * setAutoMatched / setStateViews) to consume rowMatchState + conceptCache + * via structured tuples. The legacy allCandidates write path is still + * populated so save/load works with the existing schema; PR3 drops it + * along with the legacy `candidates` field in the AI payload and the + * `concept_id` / `id` response shims. */ -const UNIFIED_MODEL_ENABLED = false +const UNIFIED_MODEL_ENABLED = true // const LOG = { // action: '', @@ -159,9 +164,14 @@ const MapProject = () => { // { [rowIndex]: { // algorithm_responses: { [id]: AlgorithmResponse }, // candidates: { [id]: Candidate }, - // concept_rows: { [concept_url]: ConceptRow }, + // concept_rows: { [concept_key]: ConceptRow }, // } } - // ConceptDefinitions live in the project-wide conceptCache (already URL-keyed). + // ConceptDefinitions live in the project-wide conceptCache. The legacy + // population of conceptCache is URL-keyed; new ConceptDefinitions written + // via mergeIntoRowMatchState / ensureLoaded are keyed by the opaque + // concept_key (makeConceptKey output). The two key namespaces don't + // collide — opaque keys are JSON.stringify'd arrays, URLs start with '/'. + // PR3 collapses to key-only when legacy save/load is dropped. const [, setRowMatchState] = React.useState({}) const rowMatchStateRef = React.useRef({}) @@ -238,6 +248,10 @@ const MapProject = () => { const [AIModels, setAIModels] = React.useState([]) const [lookupConfig, setLookupConfig] = React.useState({}) const [encoderModel, setEncoderModel] = React.useState(DEFAULT_ENCODER_MODEL) + // Project canonical-resolution context (plans/unified-mapper-model.md + // "Project configuration: explicit canonical context"). Empty = use the + // project owner as the default resolution namespace. + const [namespace, setNamespace] = React.useState('') // import const [openImportToCollection, setOpenImportToCollection] = React.useState(false) @@ -284,9 +298,9 @@ const MapProject = () => { } concept_rows.forEach(cr => { - const existing = nextRow.concept_rows[cr.concept_url] + const existing = nextRow.concept_rows[cr.concept_key] // Preserve any existing rerank_score; otherwise take the new entry. - nextRow.concept_rows[cr.concept_url] = existing && existing.rerank_score !== undefined + nextRow.concept_rows[cr.concept_key] = existing && existing.rerank_score !== undefined ? existing : cr }) @@ -294,21 +308,54 @@ const MapProject = () => { rowMatchStateRef.current = { ...prevAll, [rowIndex]: nextRow } setRowMatchState(rowMatchStateRef.current) - // Merge ConceptDefinitions into the project-wide conceptCache. Prefer - // richer (lookup_status='full') over stubs ('pending'/'partial'). - setConceptCache(prev => { - const next = { ...prev } - concept_definitions.forEach(def => { - const existing = next[def.url] - if(!existing || lookupStatusRank(def.lookup_status) > lookupStatusRank(existing.lookup_status)) { - next[def.url] = def - } - }) - return next + // Merge ConceptDefinitions into the project-wide conceptCache, keyed by + // the opaque concept_key. Prefer richer (lookup_status='full') over + // stubs ('pending'/'partial'). Update conceptCacheRef synchronously so + // same-tick consumers (setStateViews / setAutoMatched) read the just- + // merged entries before React commits the setState. + const nextCache = { ...conceptCacheRef.current } + let cacheChanged = false + concept_definitions.forEach(def => { + const existing = nextCache[def.key] + if(!existing || lookupStatusRank(def.lookup_status) > lookupStatusRank(existing.lookup_status)) { + nextCache[def.key] = def + cacheChanged = true + } }) + if(cacheChanged) { + conceptCacheRef.current = nextCache + setConceptCache(nextCache) + } + + // Trigger a rerank if any newly-arrived ConceptRow lacks a rerank_score + // (plans/unified-mapper-model.md — rerank trigger is debounce + + // in-flight check, not "wait for all algos"). + const anyPending = concept_rows.some(cr => !isNumber(cr.rerank_score)) + if(anyPending && scheduleRerankRef.current) + scheduleRerankRef.current(rowIndex) }, []) const allCandidatesRef = React.useRef({}) + const conceptCacheRef = React.useRef({}) + + // In-flight $lookup tracking (plans/unified-mapper-model.md "$lookup — + // built on $resolveReference"). Map. ensureLoaded + // dedupes concurrent calls for the same key by awaiting the existing + // Promise instead of issuing a duplicate fetch. + const inFlightLookupsRef = React.useRef(new Map()) + + // Rerank scheduling (plans/unified-mapper-model.md "Rerank — debounce + + // in-flight check"). Replaces the legacy "wait for every algo to + // complete" trigger with: any ConceptRow with rerank_score === undefined + // makes its row eligible; debounce coalesces rapid algo completions; + // in-flight set prevents double-firing. + const inFlightRerankRef = React.useRef(new Set()) + const rerankDebounceRef = React.useRef({}) + const rerankRerunNeededRef = React.useRef(new Set()) + // Forward-ref pointer for mergeIntoRowMatchState (declared earlier in + // the component) to call scheduleRerank (declared later). The ref is + // wired up via useEffect once scheduleRerank exists. + const scheduleRerankRef = React.useRef(null) /*eslint no-undef: 0*/ const AI_ASSISTANT_API_URL = window.AI_ASSISTANT_API_URL || process.env.AI_ASSISTANT_API_URL @@ -333,7 +380,7 @@ const MapProject = () => { if(!repo?.url) return null const targetCanonical = repo.canonical_url || `https://ns.openconceptlab.org${repo.url}` const ctx = { - namespace: get(project, 'owner_url') || owner, + namespace: namespace || get(project, 'owner_url') || owner, target_repo: { relative_url: repo.url, canonical_url: targetCanonical, @@ -341,20 +388,21 @@ const MapProject = () => { version: repoVersion?.id || repo.version } } - // bridge_repo when a bridge algo is in use. The bridge repo's relative URL - // lives on the algo as `target_repo_url` (legacy naming — the bridge repo - // is the *source* of the bridge mappings, e.g. CIEL). PR2a derives the - // canonical URL from the relative URL; PR2b will read explicit canonical - // from bridge repo metadata once ConfigurationForm carries it. + // bridge_repo when a bridge algo is in use. Prefer the explicit canonical + // URL captured on the algo's bridge_repo metadata (set via the + // MultiAlgoSelector bridge canonical_url field, PR2b); fall back to the + // derived form (https://ns.openconceptlab.org + relative_url) when only + // the relative URL is known. if(bridgeAlgo?.target_repo_url) { + const explicitBridgeCanonical = bridgeAlgo?.bridge_repo?.canonical_url ctx.bridge_repo = { relative_url: bridgeAlgo.target_repo_url, - canonical_url: `https://ns.openconceptlab.org${bridgeAlgo.target_repo_url}`, - canonical_url_source: 'derived' + canonical_url: explicitBridgeCanonical || `https://ns.openconceptlab.org${bridgeAlgo.target_repo_url}`, + canonical_url_source: explicitBridgeCanonical ? 'repo' : 'derived' } } return ctx - }, [project, owner, repo, repoVersion, bridgeAlgo]) + }, [project, owner, repo, repoVersion, bridgeAlgo, namespace]) const baseAlgos = useAlgos(t, toggles) const [apiAlgos, setApiAlgos] = React.useState([]); @@ -449,6 +497,7 @@ const MapProject = () => { setName(copiedProject.name ? t('map_project.create_similar_name', {name: copiedProject.name}) : '') setFilters(copiedProject.filters || {}) setLookupConfig(copiedProject.lookup_config || {}) + setNamespace(copiedProject.namespace || '') setCandidatesScore(copiedProject.score_configuration || {recommended: 99, available: 70}) setRetired(Boolean(copiedProject.include_retired || false)) setAlgosSelected(copiedProject.algorithms || []) @@ -563,12 +612,62 @@ const MapProject = () => { setAllCandidates(_allCandidates) setAlgosSelected(response.data.algorithms) setRowStage(_rowStage) + + // Backfill rowMatchState + conceptCache (keyed by concept_key) from + // the legacy `_allCandidates` we just hydrated. Required so that + // reloaded projects render correctly under UNIFIED_MODEL_ENABLED=true. + // PR3's normalizeLegacy.js will subsume this when schema-v2 load + // arrives. See plans/unified-mapper-model.md "Migration / Save-Load". + const loadedRelativeURL = dropVersion(response.data?.target_repo_url || '') || response.data?.target_repo_url + const loadedTargetCanonical = response.data?.target_repo?.canonical_url || + (loadedRelativeURL ? `https://ns.openconceptlab.org${loadedRelativeURL}` : null) + const loadedAlgos = response.data?.algorithms || [] + const loadedBridge = find(loadedAlgos, a => ['ocl-bridge', 'ocl-ciel-bridge'].includes(a.type)) + const loadProjectContext = loadedTargetCanonical ? { + namespace: response.data?.namespace || response.data?.owner_url || '', + target_repo: { + relative_url: loadedRelativeURL, + canonical_url: loadedTargetCanonical, + canonical_url_source: response.data?.target_repo?.canonical_url ? 'repo' : 'derived', + version: URIToParentParams(response.data?.target_repo_url, true)?.repoVersion || undefined + }, + ...(loadedBridge?.target_repo_url ? { + bridge_repo: { + relative_url: loadedBridge.target_repo_url, + canonical_url: loadedBridge?.bridge_repo?.canonical_url || `https://ns.openconceptlab.org${loadedBridge.target_repo_url}`, + canonical_url_source: loadedBridge?.bridge_repo?.canonical_url ? 'repo' : 'derived' + } + } : {}) + } : null + + if(loadProjectContext) { + const { rowMatchState: loadedRowMatchState, conceptDefinitionsByKey: loadedDefsByKey } = + normalizeLegacyAllCandidates(_allCandidates, loadProjectContext, loadedAlgos, CONCEPT_IDENTITY_BY_TYPE) + rowMatchStateRef.current = loadedRowMatchState + setRowMatchState(loadedRowMatchState) + if(loadedDefsByKey.size > 0) { + const next = { ..._cache } + loadedDefsByKey.forEach((def, key) => { + const existing = next[key] + if(!existing || lookupStatusRank(def.lookup_status) > lookupStatusRank(existing.lookup_status)) + next[key] = def + }) + conceptCacheRef.current = next + setConceptCache(next) + } else { + conceptCacheRef.current = _cache + } + } else { + conceptCacheRef.current = _cache + } + setName(response.data?.name || '') setDescription(response.data?.description || '') setOwner(response.data?.owner_url) setRetired(Boolean(response.data?.include_retired)) setCandidatesScore(response.data?.score_configuration) setLookupConfig(response.data?.lookup_config) + setNamespace(response.data?.namespace || '') setEncoderModel(response.data?.encoder_model || DEFAULT_ENCODER_MODEL) setProjectPromptTemplateKey(response.data?.prompt_template_key || '') setAnalysis(response.data?.analysis || {}) @@ -1017,6 +1116,8 @@ const MapProject = () => { formData.append('algorithms', JSON.stringify(map(algosSelected, algo => omit(algo, ['__key'])))) formData.append('score_configuration', JSON.stringify(candidatesScore)) formData.append('lookup_config', JSON.stringify(lookupConfig)) + if(namespace) + formData.append('namespace', namespace) formData.append('encoder_model', encoderModel || DEFAULT_ENCODER_MODEL) formData.append('include_retired', retired) formData.append('filters', JSON.stringify(getFilters())) @@ -1148,23 +1249,46 @@ const MapProject = () => { return service } + // Pick the highest-scoring target-repo ConceptRow for a given rowIndex + // from the unified-model state. Returns a RowView ({candidate, + // conceptDefinition, conceptRow, bridgeConceptDefinition?}) or null. + // (plans/unified-mapper-model.md — score-grouped view's bucketing rule.) + const pickTopRowView = (rowIndex, _repo) => { + const rowState = rowMatchStateRef.current[rowIndex] + if(!rowState) return null + const views = buildQualityRowViews(rowState, conceptCacheRef.current) + const targetCanonical = _repo?.canonical_url || (_repo?.url ? `https://ns.openconceptlab.org${_repo.url}` : null) + // Auto-match must land on a target-repo concept. Bridge intermediaries + // are excluded — even if their rerank_score is high, they're not + // mappable. Score view already places bridge_child rows under the + // target concept's ConceptRow, so filtering by canonical_url here is + // the right invariant. + const eligible = views.filter(v => v.conceptDefinition?.reference?.url === targetCanonical) + if(!eligible.length) return null + return orderBy(eligible, [v => v.conceptRow?.rerank_score ?? -1], ['desc'])[0] + } + const setStateViews = (data, _repo) => { setRowStatuses(prev => { forEach(data, concept => { - const topScore = get(concept, 'results.0.search_meta.search_normalized_score') + const rowIdx = concept?.row?.__index + if(!isNumber(rowIdx)) return + const top = pickTopRowView(rowIdx, _repo) + const topScore = top?.conceptRow?.rerank_score if(isNumber(topScore) && topScore >= candidatesScore.recommended) { - let _concept = {...concept.results[0], repo: {..._repo, version: repoVersion?.id || _repo.version, version_url: repoVersion?.version_url || _repo.version_url}} + const _concept = {...conceptForMapping(top), repo: {..._repo, version: repoVersion?.id || _repo.version, version_url: repoVersion?.version_url || _repo.version_url}} setMapSelected(_prev => { - _prev[concept.row.__index] = _concept + _prev[rowIdx] = _concept return _prev }) - const mapType = get(concept, 'results.0.search_meta.map_type') || 'SAME-AS' - prev.readyForReview = uniq([...prev.readyForReview, concept.row.__index]) - setDecisions(prev => ({...prev, [concept.row.__index]: 'map'})) - setMapTypes(prev => ({...prev, [concept.row.__index]: mapType})) - log({action: 'auto-matched', extras: {repoVersion: repoVersion?.version_url || _repo.version_url, name: getConceptLabel(_concept), map_type: mapType}}, concept.row.__index) - } else - prev.unmapped = uniq([...prev.unmapped, concept.row.__index]) + const mapType = top.candidate?.map_type || 'SAME-AS' + prev.readyForReview = uniq([...prev.readyForReview, rowIdx]) + setDecisions(p => ({...p, [rowIdx]: 'map'})) + setMapTypes(p => ({...p, [rowIdx]: mapType})) + log({action: 'auto-matched', extras: {repoVersion: repoVersion?.version_url || _repo.version_url, name: getConceptLabel(_concept), map_type: mapType}}, rowIdx) + } else { + prev.unmapped = uniq([...prev.unmapped, rowIdx]) + } }) return prev }) @@ -1172,40 +1296,21 @@ const MapProject = () => { const setAutoMatched = (indexes) => { forEach(indexes, index => { - const topCandidate = orderBy( - flatten( - map(filter(flatten(values(allCandidatesRef.current)), candidate => candidate?.row?.__index === index), _candidate => _candidate?.results || []) - ), - 'search_meta.search_normalized_score', 'desc' - )[0] - if(topCandidate?.search_meta?.search_normalized_score >= candidatesScore.recommended) { - const isBridge = topCandidate.search_meta.algorithm.includes('bridge') - let conceptToMap = topCandidate - if(isBridge) { - let mapping = find(topCandidate.mappings, mapping => mapping.map_type.toLowerCase().replace(' ', '').replace('_', '').replace('-', '') === 'sameas') || get(topCandidate.mappings, '0') - if(mapping) - conceptToMap = { - 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: {...topCandidate.search_meta, map_type: mapping.map_type || topCandidate.search_meta.map_type }, - } - } + const top = pickTopRowView(index, repo) + const topScore = top?.conceptRow?.rerank_score + if(isNumber(topScore) && topScore >= candidatesScore.recommended) { setRowStatuses(prev => { let newStatuses = {...prev} - let _concept = {...conceptToMap, repo: {...repo, version: repoVersion?.id || repo.version, version_url: repoVersion?.version_url || repo.version_url}} + const _concept = {...conceptForMapping(top), repo: {...repo, version: repoVersion?.id || repo.version, version_url: repoVersion?.version_url || repo.version_url}} setMapSelected(_prev => { _prev[index] = _concept return _prev }) - const mapType = get(conceptToMap, 'search_meta.map_type') || 'SAME-AS' + const mapType = top.candidate?.map_type || 'SAME-AS' newStatuses.readyForReview = uniq([...newStatuses.readyForReview, index]) setDecisions(_prev => ({..._prev, [index]: 'map'})) setMapTypes(_prev => ({..._prev, [index]: mapType})) - log({action: 'auto-matched', extras: {repoVersion: repoVersion?.version_url || repo.version_url, name: getConceptLabel(_concept), map_type: mapType, algorithm: _concept.search_meta?.algorithm}}, index) + log({action: 'auto-matched', extras: {repoVersion: repoVersion?.version_url || repo.version_url, name: getConceptLabel(_concept), map_type: mapType, algorithm: top.candidate?.algorithm_id}}, index) newStatuses.unmapped = without(newStatuses.unmapped, index) return newStatuses }) @@ -1293,6 +1398,33 @@ const MapProject = () => { }; const rowBatch = queue.shift(); const promise = processBatch(_repo, rowBatch, algo).then((data) => { + // Under the unified model, populate rowMatchState before any + // consumer (setStateViews / setAutoMatched) tries to read it. + // The per-row fetch flow routes through `onResponse` which + // already calls mergeIntoRowMatchState; bulk processBatch + // doesn't, so we run the normalizer here. + if(UNIFIED_MODEL_ENABLED && Array.isArray(data) && data.length) { + const projectCtx = buildProjectContext() + if(projectCtx) { + const algoCfg = algo.concept_identity + ? algo + : (CONCEPT_IDENTITY_BY_TYPE[algo.type] + ? { ...algo, concept_identity: CONCEPT_IDENTITY_BY_TYPE[algo.type] } + : null) + if(algoCfg) { + data.forEach(rowEntry => { + const idx = rowEntry?.row?.__index + if(!isNumber(idx)) return + mergeIntoRowMatchState(idx, normalizeAlgorithmInvocation(rowEntry, { + algorithmId: algo.id, + algorithmConfig: algoCfg, + projectContext: projectCtx, + rowIndex: idx + })) + }) + } + } + } if(!isMultiAlgo) setStateViews(data, _repo) if(!data || !data.length) { @@ -1417,6 +1549,10 @@ const MapProject = () => { allCandidatesRef.current = allCandidates; }, [allCandidates]); + React.useEffect(() => { + conceptCacheRef.current = conceptCache; + }, [conceptCache]); + const runBulkAIAnalysis = async (_rows) => { setLoadingMatches(true) setBulkAIAnalysisStartedAt(moment()) @@ -1924,8 +2060,10 @@ const MapProject = () => { let _repo = concept?.repo const aiRecommendation = get(analysis, index)?.output || get(analysis, index) const aiCandidate = get(aiRecommendation, 'primary_candidate') - // v2 response prefers canonical_reference.code; legacy shape used concept_id. - const aiCandidateID = aiCandidate?.canonical_reference?.code || aiCandidate?.concept_id + // v2 response: prefer concept_key (resolves via conceptCache for an + // unambiguous match), then canonical_reference.code (the PR2a shim); + // fall back to legacy concept_id/id when the v2 fields are absent. + const aiCandidateID = resolveAICandidateID(aiCandidate, conceptCacheRef.current) const aiScore = compact([aiCandidate?.confidence_level, aiCandidate?.match_strength]).join(':') let candidates = getRowCandidatesForDownload(index) const getOutOfScopeSuggestions = () => { @@ -1958,7 +2096,7 @@ const MapProject = () => { '__oclai_confidence_score__': aiScore || null, '__oclai_rec_concept_id__': aiCandidateID || null, '__oclai_rec_concept_name__': get(aiCandidate, 'name') || null, - '__oclai_alt_concepts__': map(get(aiRecommendation, 'alternative_candidates', []), 'concept_id').join('\n') || null, + '__oclai_alt_concepts__': compact(map(get(aiRecommendation, 'alternative_candidates', []), c => resolveAICandidateID(c, conceptCacheRef.current))).join('\n') || null, '__oclai_oos_suggestions__': getOutOfScopeSuggestions() || null, '__oclai_rationale__': get(aiRecommendation, 'rationale') || null, ...candidates, @@ -2259,12 +2397,11 @@ const MapProject = () => { markAlgo(__row.__index, nextAlgo.id, 0) fetchAllCandidatesForRow(nextAlgo.id, __row, offset, _retired, scrollToBottom, _filters, forceReload) } else { - if(![0, 1].includes(get(rowStageRef.current, `${__row.__index}.rerank`)) && some(getAllCandidatesForRow(__row.__index), r => !isNumber(r.search_meta.search_rerank_score))) { - markAlgo(__row.__index, 'rerank', -1) - rerank(__row.__index) - } else { - markAlgo(__row.__index, 'rerank', 1) - } + // Rerank is now debounce-driven from mergeIntoRowMatchState. If + // any cached ConceptRow still lacks a rerank_score, scheduleRerank + // picks it up; otherwise it's a no-op. + markAlgo(__row.__index, 'rerank', 1) + scheduleRerank(__row.__index) } return } @@ -2334,12 +2471,14 @@ const MapProject = () => { fetchAllCandidatesForRow(nextAlgo.id, __row, offset, _retired, scrollToBottom, _filters, forceReload) } else { const currentAlgo = algoId ? getAlgoDef(algoId) : null + // Single-algo native path: $match's reranker:true returns scores + // inline, so mergeIntoRowMatchState already wrote rerank_score on + // the ConceptRows. Mark rerank done. Other paths: scheduleRerank + // picks up pending ConceptRows via the debounced trigger. if(!isMultiAlgo && (currentAlgo?.provider === 'ocl' && !['ocl-bridge', 'ocl-ciel-bridge', 'ocl-scispacy'].includes(currentAlgo.type))) markAlgo(__row.__index, 'rerank', 1) - else { - markAlgo(__row.__index, 'rerank', -1) - rerank(__row.__index) - } + else + scheduleRerank(__row.__index) } if(scrollToBottom) { setTimeout(() => { @@ -2396,59 +2535,164 @@ const MapProject = () => { } } + // Build the deduplicated rerank request body from the row's ConceptRows + + // their ConceptDefinitions. Each row carries concept_key as a passthrough + // anchor so we can match scored results back unambiguously, plus the + // legacy concept-shaped fields the server expects. + const buildRerankRowsForRow = (rowIndex) => { + const rowState = rowMatchStateRef.current[rowIndex] + if(!rowState) return [] + const seen = new Set() + const rows = [] + Object.values(rowState.concept_rows || {}).forEach(conceptRow => { + const key = conceptRow.concept_key + if(seen.has(key)) return + const def = conceptCacheRef.current[key] + if(!def) return + seen.add(key) + rows.push({ + concept_key: key, + id: def.id || def.reference?.code, + url: def.ocl_url, + display_name: def.display_name, + names: def.names, + descriptions: def.descriptions, + source: def.source, + owner: def.owner + }) + }) + return rows + } + + // Match a rerank response item back to a ConceptRow concept_key. + // Preference order: concept_key passthrough -> ocl_url -> (id, source). + const matchRerankResultToKey = (result) => { + if(result?.concept_key && conceptCacheRef.current[result.concept_key]) + return result.concept_key + if(result?.url) { + const byOclUrl = Object.entries(conceptCacheRef.current).find(([, def]) => def?.ocl_url === result.url) + if(byOclUrl) return byOclUrl[0] + } + if(result?.id) { + const byCode = Object.entries(conceptCacheRef.current).find(([, def]) => ( + (def?.id === result.id || def?.reference?.code === result.id) && + (!result.source || def?.source === result.source) + )) + if(byCode) return byCode[0] + } + return null + } + const rerank = async (_index, isBulk=false) => { const index = isNumber(_index) ? _index : rowIndex - if(isNumber(index) && (isBulk || isReadyForRerank(index))) { - const candidates = getAllCandidatesForRow(index) || [] - let row = data[index] - const query = get(prepareRow(row), 'name') - if(!candidates.length || !query) - return - markAlgo(index, 'rerank', 0) - const service = APIService.concepts().appendToUrl('$rerank/') - try { - const response = await service.post({ - q: query, - rows: candidates, - ...(encoderModel ? { encoder_model: encoderModel } : {}) - }); - - setAllCandidates(prev => { - const newCandidates = {...prev} - forEach(keys(prev), algoId => { - const existingCandidates = [...(prev[algoId] || [])] - const ranked = filter(response.data, result => { - if(algoId === 'ocl-ciel-bridge' && result.search_meta.algorithm === 'ocl-bridge') - return result.owner_url === '/orgs/CIEL/' - return result.search_meta.algorithm === algoId - }) - if(ranked.length > 0) { - const matchIndex = findIndex(existingCandidates, match => match.row.__index === index) - if(matchIndex > -1) { - existingCandidates[matchIndex] = { - ...existingCandidates[matchIndex], - results: ranked - } - newCandidates[algoId] = existingCandidates + if(!isNumber(index)) return null + if(inFlightRerankRef.current.has(index)) { + // Another rerank is in flight for this row; flag a rerun and bail. + rerankRerunNeededRef.current.add(index) + return null + } + const rerankRows = buildRerankRowsForRow(index) + const row = data[index] + const query = get(prepareRow(row), 'name') + if(!rerankRows.length || !query) return null + inFlightRerankRef.current.add(index) + markAlgo(index, 'rerank', 0) + const service = APIService.concepts().appendToUrl('$rerank/') + try { + const response = await service.post({ + q: query, + rows: rerankRows, + ...(encoderModel ? { encoder_model: encoderModel } : {}) + }) + + // Write rerank_score into the row's ConceptRows. + const resultsByKey = new Map() + forEach(response?.data || [], result => { + const key = matchRerankResultToKey(result) + if(!key) return + const score = result?.search_meta?.search_normalized_score + const rawScore = result?.search_meta?.search_rerank_score + resultsByKey.set(key, { rerank_score: isNumber(score) ? score : (isNumber(rawScore) ? rawScore * 100 : undefined) }) + }) + const prevRow = rowMatchStateRef.current[index] + if(prevRow) { + const nextConceptRows = { ...prevRow.concept_rows } + resultsByKey.forEach((patch, key) => { + const existing = nextConceptRows[key] + if(existing) nextConceptRows[key] = { ...existing, ...patch } + }) + const nextRow = { ...prevRow, concept_rows: nextConceptRows } + rowMatchStateRef.current = { ...rowMatchStateRef.current, [index]: nextRow } + setRowMatchState(rowMatchStateRef.current) + } + + // Legacy allCandidates write — preserves rerank scores in the saved + // project JSON until PR3 lands schema-v2 save/load. + setAllCandidates(prev => { + const newCandidates = {...prev} + forEach(keys(prev), algoId => { + const existingCandidates = [...(prev[algoId] || [])] + const ranked = filter(response.data, result => { + if(algoId === 'ocl-ciel-bridge' && result.search_meta?.algorithm === 'ocl-bridge') + return result.owner_url === '/orgs/CIEL/' + return result.search_meta?.algorithm === algoId + }) + if(ranked.length > 0) { + const matchIndex = findIndex(existingCandidates, match => match.row.__index === index) + if(matchIndex > -1) { + existingCandidates[matchIndex] = { + ...existingCandidates[matchIndex], + results: ranked } + newCandidates[algoId] = existingCandidates } - }) - allCandidatesRef.current = newCandidates - return newCandidates + } }) - markAlgo(index, 'rerank', 1) - log({action: 'rerank_finished', description: `Reranked with ${encoderModel}`}, index) - if(isBulk) - setTimeout(() => setAutoMatched([index]), 1000) - return response - } catch (e) { - log({action: 'rerank_failed', description: `Rerank failed with ${encoderModel}`}, index) - markAlgo(index, 'rerank', -2); // optional: failed state - return null; + allCandidatesRef.current = newCandidates + return newCandidates + }) + + markAlgo(index, 'rerank', 1) + log({action: 'rerank_finished', description: `Reranked with ${encoderModel}`}, index) + if(isBulk) + setTimeout(() => setAutoMatched([index]), 1000) + return response + } catch (e) { + log({action: 'rerank_failed', description: `Rerank failed with ${encoderModel}`}, index) + markAlgo(index, 'rerank', -2) + return null + } finally { + inFlightRerankRef.current.delete(index) + // If new ConceptRows arrived while we were in flight, fire again. + if(rerankRerunNeededRef.current.has(index)) { + rerankRerunNeededRef.current.delete(index) + scheduleRerank(index) } } } + // scheduleRerank — debounced trigger. Coalesces rapid algo-completion + // events for a given row into a single rerank call. The "all algos done" + // implicit batch goes away; instead, any new ConceptRow with + // rerank_score === undefined eventually drives a rerank. + const RERANK_DEBOUNCE_MS = 300 + const scheduleRerank = (rowIndex) => { + if(!isNumber(rowIndex)) return + const rowState = rowMatchStateRef.current[rowIndex] + if(!rowState) return + const hasPending = Object.values(rowState.concept_rows || {}).some(cr => !isNumber(cr.rerank_score)) + if(!hasPending) return + if(rerankDebounceRef.current[rowIndex]) + clearTimeout(rerankDebounceRef.current[rowIndex]) + rerankDebounceRef.current[rowIndex] = setTimeout(() => { + delete rerankDebounceRef.current[rowIndex] + rerank(rowIndex) + }, RERANK_DEBOUNCE_MS) + } + // Keep the forward-ref consumed by mergeIntoRowMatchState fresh so the + // current-closure scheduleRerank is what gets called. + scheduleRerankRef.current = scheduleRerank + const getCandidatesForRow = (index, _candidates, fullObject=false) => { const result = find(_candidates, candidate => candidate.row.__index === index) return fullObject ? result : (result?.results || []) @@ -2474,14 +2718,6 @@ const MapProject = () => { })) } - const isReadyForRerank = _index => { - const index = isNumber(_index) ? _index : rowIndex - if(isNumber(index) && get(rowStageRef.current, `${index}.rerank`) !== 0) { - return Boolean(every(selectedAlgoIds, algoId => rowStageRef.current[index][algoId] === 1)) - } - return false - } - const fromScispacyResultsToConcepts = results => { let formatted = [] forEach(results, (result) => { @@ -2532,21 +2768,6 @@ const MapProject = () => { ); } - const lookupCandidates = (algoId, candidates) => { - const algo = algoId ? getAlgoDef(algoId) : null - if(algo?.lookup_required && (lookupConfig?.url || repoVersion.url) && candidates && isArray(candidates) && candidates.length) { - candidates.forEach(concept => { - if(['ocl-bridge', 'ocl-ciel-bridge'].includes(algo.type)) { - forEach(concept.mappings, mapping => { - lookupCode(mapping.cascade_target_concept_code) - }) - } else { - lookupCode(concept.id) - } - }) - } - } - const findConceptByIdOrURLFromCache = (id) => { let key = getKeyFromCache(id) let _cached = key ? conceptCache[key] : false @@ -2576,16 +2797,159 @@ const MapProject = () => { return service.appendToUrl('concepts/') } - const lookupCode = (code) => { - if (getKeyFromCache(code)) - return - if(code && (lookupConfig?.url || repoVersion?.version_url)) { - let service = getLookupService() - service.appendToUrl(`${code}/`).get(lookupConfig?.token).then(response => { - if(response?.data?.url) - setConceptCache(prev => ({...prev, [response.data.url]: response.data})) - }) + // ensureLoaded — state-driven $lookup over $resolveReference + // (plans/unified-mapper-model.md "$lookup — built on $resolveReference"). + // Idempotent over concept_keys: skips concepts already 'full' in the + // conceptCache and dedupes concurrent calls via inFlightLookupsRef. + // Branch 1: concepts with a known ocl_url -> direct GET on that URL. + // Branch 2: concepts without an ocl_url -> batched POST /$resolveReference/ + // (?namespace=) followed by per-resolved concept fetch from the repo + // the registry pointed at. Writes back into conceptCache keyed by + // concept_key with lookup_status='full'|'failed' and a lookup_source. + // Apply a {[key]: mergedDef} update to both conceptCacheRef (synchronous + // — so same-tick consumers read fresh data) and conceptCache state (so + // React rerenders). Used by ensureLoaded. + const writeConceptCachePatch = React.useCallback((key, def) => { + if(!key || !def) return + const next = { ...conceptCacheRef.current, [key]: def } + conceptCacheRef.current = next + setConceptCache(next) + }, []) + const writeLookupFailure = React.useCallback((key) => { + const existing = conceptCacheRef.current[key] + if(!existing) return + writeConceptCachePatch(key, { ...existing, lookup_status: 'failed' }) + }, [writeConceptCachePatch]) + + const ensureLoaded = React.useCallback(async (conceptKeys) => { + if(!Array.isArray(conceptKeys) || conceptKeys.length === 0) return + const ctx = buildProjectContext() + const resolveNamespace = ctx?.namespace + const cache = conceptCacheRef.current + + const directFetches = [] + const toResolve = [] + const settlers = new Map() + const pending = [] + + conceptKeys.forEach(key => { + if(!key) return + const def = cache[key] + if(def?.lookup_status === 'full') return + if(inFlightLookupsRef.current.has(key)) { + pending.push(inFlightLookupsRef.current.get(key)) + return + } + const promise = new Promise(resolve => settlers.set(key, resolve)) + inFlightLookupsRef.current.set(key, promise) + pending.push(promise) + + if(def?.ocl_url) { + directFetches.push({key, oclUrl: def.ocl_url}) + } else { + try { + const reference = parseConceptKey(key) + toResolve.push({key, reference}) + } catch (_) { + inFlightLookupsRef.current.delete(key) + settlers.get(key)() + } + } + }) + + const settle = (key) => { + inFlightLookupsRef.current.delete(key) + const fn = settlers.get(key) + if(fn) fn() + } + + const fetchConceptByOclUrl = async (key, oclUrl, source) => { + try { + const response = await APIService.new() + .overrideURL(oclUrl) + .get(currentUserToken(), null, {includeMappings: true, mappingBrief: true, mapTypes: 'SAME-AS,SAME AS,SAME_AS', verbose: true}) + const data = response?.data + if(data?.id) { + const existing = conceptCacheRef.current[key] || {} + writeConceptCachePatch(key, { + ...existing, + ...data, + ocl_url: oclUrl, + lookup_status: 'full', + lookup_source_type: '$lookup', + lookup_source: source || oclUrl + }) + } else { + writeLookupFailure(key) + } + } catch (_) { + writeLookupFailure(key) + } finally { + settle(key) + } + } + + const directPromise = Promise.all(directFetches.map(({key, oclUrl}) => fetchConceptByOclUrl(key, oclUrl))) + + let resolvePromise = Promise.resolve() + if(toResolve.length) { + const body = toResolve.map(({reference}) => reference.version + ? {url: reference.url, version: reference.version} + : {url: reference.url}) + resolvePromise = APIService.new() + .overrideURL('/$resolveReference/') + .post(body, currentUserToken(), null, resolveNamespace ? {namespace: resolveNamespace} : undefined) + .then(async response => { + const items = Array.isArray(response?.data) ? response.data : [] + await Promise.all(toResolve.map(async ({key, reference}, i) => { + const item = items[i] + const repoUrl = item?.resolved === true && item?.result?.url + if(!repoUrl) { + writeLookupFailure(key) + settle(key) + return + } + const base = repoUrl.endsWith('/') ? repoUrl : `${repoUrl}/` + const conceptUrl = `${base}concepts/${encodeURIComponent(reference.code)}/` + await fetchConceptByOclUrl(key, conceptUrl, `$resolveReference -> ${base}`) + })) + }) + .catch(() => { + toResolve.forEach(({key}) => { + writeLookupFailure(key) + settle(key) + }) + }) } + + await Promise.all([directPromise, resolvePromise, ...pending]) + }, [buildProjectContext, writeConceptCachePatch, writeLookupFailure]) + + // Thin convenience wrapper preserved at the legacy call sites: derive + // concept_keys for the just-arrived results via the algo's + // concept_identity, then delegate to ensureLoaded. Replaces the legacy + // `algo.lookup_required` gate — every concept that isn't already 'full' + // is eligible for $lookup, in line with the unified-model spec. + const lookupCandidates = (algoId, candidates) => { + if(!algoId || !Array.isArray(candidates) || candidates.length === 0) return + const ctx = buildProjectContext() + if(!ctx?.target_repo?.canonical_url) return + const algo = getAlgoDef(algoId) + if(!algo) return + const algoConfig = algo.concept_identity + ? algo + : (CONCEPT_IDENTITY_BY_TYPE[algo.type] + ? { ...algo, concept_identity: CONCEPT_IDENTITY_BY_TYPE[algo.type] } + : null) + if(!algoConfig) return + const normalized = normalizeAlgorithmInvocation( + {row: {__index: -1}, results: candidates}, + {algorithmId: algoId, algorithmConfig: algoConfig, projectContext: ctx, rowIndex: -1} + ) + const keysToLoad = normalized.concept_definitions + .filter(d => d.lookup_status !== 'full') + .map(d => d.key) + if(keysToLoad.length) ensureLoaded(keysToLoad) } const onFetchMoreCandidates = () => { @@ -3163,6 +3527,8 @@ const MapProject = () => { AIModels={AIModels} AIModel={AIModel} setAIModel={setAIModel} + namespace={namespace} + setNamespace={setNamespace} /> ) @@ -3650,7 +4016,8 @@ const MapProject = () => { rowStage={rowStageRef.current[rowIndex]} alert={alert} setAlert={setAlert} - candidates={allCandidatesRef.current} + rowState={rowMatchStateRef.current[rowIndex]} + conceptCache={conceptCache} setShowItem={setShowItem} showItem={showItem} setShowHighlights={setShowHighlights} @@ -3680,7 +4047,6 @@ const MapProject = () => { onRefreshClick={onRefreshClick} inAIAssistantGroup={inAIAssistantGroup} algosSelected={algosSelected} - conceptCache={conceptCache} isCoreUser={isCoreUser} /> } diff --git a/src/components/map-projects/MultiAlgoSelector.jsx b/src/components/map-projects/MultiAlgoSelector.jsx index fb9a8cc..561cec4 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"; @@ -196,7 +195,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 +464,16 @@ export default function MultiAlgoSelector({ updateSelected(sel.__key, { description: e.target.value || '' }) } /> + updateSelected(sel.__key, { canonical_url: e.target.value || '' })} + placeholder="http://loinc.org" + helperText={t('map_project.algo_canonical_url_description') || 'Canonical URL of the code system this algorithm matches against (e.g. http://loinc.org).'} + error={Boolean(sel.canonical_url && !isLikelyCanonicalUrl(sel.canonical_url))} + /> - } label={t('map_project.lookup_required')} onChange={e => updateSelected(sel.__key, {lookup_required: e.target.checked})} /> @@ -507,14 +514,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 +665,8 @@ function clampInt(value, min, max) { function eHasValue(value) { return Boolean(value && String(value).trim()); } + +function isLikelyCanonicalUrl(value) { + if(!value || typeof value !== 'string') return false; + return /^https?:\/\/[^\s]+$/i.test(value.trim()); +} diff --git a/src/components/map-projects/Score.jsx b/src/components/map-projects/Score.jsx index 44df55f..4f04548 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,11 @@ 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..771595e 100644 --- a/src/components/map-projects/__tests__/normalizers.test.js +++ b/src/components/map-projects/__tests__/normalizers.test.js @@ -294,7 +294,7 @@ 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, @@ -303,7 +303,24 @@ test('ConceptRow is created with rerank_score=undefined for the matched concept' }) 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 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 + }) + assert.equal(out.concept_rows[0].rerank_score, undefined) }) // ---------- normalizeAlgoResult: scispacy (no url, no ocl_url) ---------- @@ -447,9 +464,15 @@ test('bridge result creates ConceptRows for BOTH intermediary and target', () => }) assert.equal(out.concept_rows.length, 2) - for (const row of out.concept_rows) { - assert.equal(row.rerank_score, undefined) - } + // Bridge intermediary carries the bridge response's + // search_normalized_score (the algo did score the intermediary). + // Cascade target gets no rerank_score yet — the bridge response only + // scores the bridge concept; the debounced rerank pass fills the target + // once the row is eligible. + 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, 92) + 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..95100ac --- /dev/null +++ b/src/components/map-projects/__tests__/viewHelpers.test.js @@ -0,0 +1,216 @@ +/** + * 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 falls back to candidate.score scaled to 0-100', () => { + const out = getScoreDetails( + { candidate: { score: 0.85 }, conceptRow: {} }, + candidatesScore + ) + assert.equal(out.percentile, 85) + assert.equal(out.qualityBucket, 'recommended') +}) + +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..a825ca9 --- /dev/null +++ b/src/components/map-projects/__tests__/views.test.js @@ -0,0 +1,382 @@ +/** + * 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'] + ) +}) + +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: 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/normalizers.js b/src/components/map-projects/normalizers.js index 8422e0b..bf1dd76 100644 --- a/src/components/map-projects/normalizers.js +++ b/src/components/map-projects/normalizers.js @@ -169,9 +169,15 @@ const cascadeTargetToConceptDefinition = (mapping, cascadeIdentity, projectConte } } -const newConceptRow = (conceptKey) => ({ +// rerank_score on ConceptRow comes from the result's search_normalized_score +// when present (set by $match's reranker:true single-algo path) — no +// separate $rerank round-trip needed for that case. Otherwise undefined; +// it gets filled in by the debounced rerank pipeline. +const newConceptRow = (conceptKey, result) => ({ concept_key: conceptKey, - rerank_score: undefined + rerank_score: typeof result?.search_meta?.search_normalized_score === 'number' + ? result.search_meta.search_normalized_score + : undefined }) const isBridgeResult = (identityConfig) => @@ -209,7 +215,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)) const primaryCandidate = { id: newId(), @@ -235,6 +241,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)) } @@ -330,3 +339,94 @@ 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 + } + ) + + 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..9a501e5 --- /dev/null +++ b/src/components/map-projects/viewBuilders.js @@ -0,0 +1,216 @@ +/** + * 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. + */ +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 = 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. + const primary = contributing.find(c => c.type === 'standard') + || contributing.find(c => c.type === 'bridge_child') + || contributing[0] + if(!primary) return null + let bridgeConceptDefinition + if(primary.type === 'bridge_child' && primary.bridge_concept_key) + bridgeConceptDefinition = conceptCache[primary.bridge_concept_key] + return { + type: primary.type === 'bridge_child' ? 'bridge_child' : 'standard', + candidate: primary, + conceptDefinition, + conceptRow, + bridgeConceptDefinition, + 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, + 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. + * Pure — caller maps qualityBucket -> bucketColor via SCORES_COLOR. + */ +export const getScoreDetails = ({candidate, conceptRow} = {}, candidatesScore = {}) => { + const rerankFloat = isNumber(conceptRow?.rerank_score) ? conceptRow.rerank_score : null + const score = isNumber(candidate?.score) ? candidate.score : null + // ConceptRow.rerank_score is already on the 0-100 scale (the rerank API + // returns search_normalized_score in that range). Display directly; + // when unavailable, scale the candidate's raw score from 0-1 to 0-100. + let percentile + if(rerankFloat !== null) percentile = rerankFloat + else if(score !== null) percentile = score * 100 + + 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, + rerankScore: `${parseFloat(hasPercentile ? percentile : score).toFixed(2)}%`, + algoScore: score === null ? '' : `${parseFloat(score).toFixed(2)}` + } +} + +/** + * 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 +} From d7180d76c9bfb7d07960c7024dccfbf9feed5ce1 Mon Sep 17 00:00:00 2001 From: Jonathan Payne Date: Tue, 12 May 2026 07:05:12 -0400 Subject: [PATCH 02/29] OpenConceptLab/ocl_issues#2337 | PR2b review feedback: correctness + UX polish MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses review issues found before staging promotion. Most are wiring gaps exposed once the flag was flipped ON and the read path started actually consuming rowMatchState + conceptCache. Correctness: - pickTopRowView reads target canonical from buildProjectContext() (the same source the normalizer used to stamp ConceptDefinition.reference.url), not from the caller's _repo arg. Eliminates the derived-vs-explicit canonical mismatch that silently emptied auto-match. - Centralize concept_identity injection in ensureConceptIdentity() inside algorithms.jsx. Handles built-ins, API-loaded bridge/scispacy, and custom algos via user-entered canonical_url. Replaces three inline reconstructions in MapProject.jsx (getAlgoDef, bulk processBatch path, lookupCandidates) and the legacy load path. Also fixes a latent bug where custom-algo canonical_url was never wired into a concept_identity block so the field had no runtime effect. - matchRerankResultToKey throws on canonical-identity miss; the rerank loop collects errors, logs them, and surfaces a single summary alert. Dropped the fuzzy ocl_url / id+source fallbacks — the unified-model spec relies on canonical identity and silent fallback was masking config gaps. - mergeIntoRowMatchState auto-triggers ensureLoaded for newly-arrived ConceptDefinitions with lookup_status !== 'full' (bridge cascade targets, sparse algorithm results). Plan called for state-driven $lookup; PR2b had the helper but no automatic trigger. Forward-ref pattern (ensureLoadedRef) matches existing scheduleRerankRef. - fetchAndSetProject: when a saved project has match data but no target canonical can be resolved, surface an error alert and force-open the configuration drawer (setConfigure(true)) instead of silently rendering an empty candidate list under the flag-ON read path. Click-path wiring (rowView <-> legacy concept shape at the seam): - Candidates.jsx decorates rowViews with top-level id/url/version_url so SearchResults.handleRowClick (which looks up rows by those fields) can resolve clicks back to the rowView. Without this, the candidate row click never fired onShowItemSelect. - Concept.jsx passes the conceptForMapping projection (not the raw tuple) to setShowHighlights so SearchHighlightsDialog finds search_meta.search_highlight (Matched Attributes). - viewBuilders.getScoreDetails accepts either the {candidate, conceptRow} tuple or the legacy {search_meta} shape, so the dialog's score chip works regardless of which form the caller sends. Save-time validation: - Custom algos require a valid canonical_url (URL-pattern check). Save button disabled and a structured Alert lists offending algos. The inline TextField helperText also distinguishes missing vs malformed. Exported getProjectConfigErrors() + isLikelyCanonicalUrl() from algorithms.jsx as the single source of truth. CSV export: - __map_repo_url__ and __map_repo_id__ are now gated on `concept` (the mapSelected entry) so they're blank on unmapped rows. Matches the rest of the __map_* namespace (concept_id/name/url were already empty on unmapped rows) — drops the asymmetric project-repo fallback that predated PR2b. UI polish + i18n: - Compact canonical-URL caption under target repo (info icon + monospace URL with ellipsis truncation + tooltip + muted 'derived' suffix). Bridge repos use the same line per bridge. Replaces the orange chip that read like an error. - All PR2b-introduced t() calls switched from `t(key) || 'default'` (which never falls through because i18next returns the truthy key string) to `t(key, 'default')` / `t(key, {defaultValue, ...interp})`. - Added the PR2b string set to en/translations.json (target_canonical_url, canonical_auto_derived_short, bridge_canonical_short, advanced_settings, resolution_namespace + description with {{owner}} interpolation, the algo_canonical_url_* family, config_errors_title, target_repo_required_ on_load, etc.). resolution_namespace_description now spells out the default ('When blank, defaults to {{owner}}'). Other locales inherit via fallbackLng='en'. No new tests required — covered by existing 79/79 suite. Bridge / scispacy / AI Assistant paths still need staging verification (per the PR description). Co-Authored-By: Claude Opus 4.7 (1M context) --- src/components/map-projects/Candidates.jsx | 12 +- src/components/map-projects/Concept.jsx | 6 +- .../map-projects/ConfigurationForm.jsx | 115 +++++++++++--- src/components/map-projects/MapProject.jsx | 147 ++++++++++++------ .../map-projects/MultiAlgoSelector.jsx | 27 ++-- src/components/map-projects/algorithms.jsx | 41 +++++ src/components/map-projects/viewBuilders.js | 19 ++- src/i18n/locales/en/translations.json | 16 ++ 8 files changed, 296 insertions(+), 87 deletions(-) diff --git a/src/components/map-projects/Candidates.jsx b/src/components/map-projects/Candidates.jsx index 7742f19..3d1e46e 100644 --- a/src/components/map-projects/Candidates.jsx +++ b/src/components/map-projects/Candidates.jsx @@ -185,7 +185,17 @@ const SubHeader = ({count, onClick, isCollapsed, header, indicatorColor, isFirst 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}) => { - const results = {total: onFetchMore ? rowViews?.length : 1, results: rowViews || []} + // Decorate rowViews with top-level id/url/version_url so SearchResults' + // handleRowClick (which looks up rows by `row.version_url || row.url || + // row.id`) can resolve the click back to the rowView. Without this the + // table click never fires onShowItemSelect because rowView fields live + // on view.conceptDefinition, not at top level. + const rowsForTable = (rowViews || []).map(view => { + const def = view?.conceptDefinition + const idForLookup = def?.ocl_url || def?.id || def?.reference?.code + return { ...view, id: idForLookup, url: def?.ocl_url, version_url: def?.ocl_url } + }) + const results = {total: onFetchMore ? rowsForTable.length : 1, results: rowsForTable} const isCollapsed = collapsed.includes(bucketId) const onCollapseToggle = () => { onCollapse(isCollapsed ? without(collapsed, bucketId): [...collapsed, bucketId]) diff --git a/src/components/map-projects/Concept.jsx b/src/components/map-projects/Concept.jsx index 8799dca..415990e 100644 --- a/src/components/map-projects/Concept.jsx +++ b/src/components/map-projects/Concept.jsx @@ -48,8 +48,12 @@ const Item = ({candidate, conceptDefinition, conceptRow, bridgeConceptDefinition const bridgeMappingPrefix = bridgeConceptDefinition ? `${bridgeConceptDefinition.source || ''}:${bridgeConceptDefinition.id || bridgeConceptDefinition.reference?.code} ${bridgeConceptDefinition.display_name || ''}` : false + // 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({candidate, conceptDefinition, conceptRow, bridgeConceptDefinition}) + ? () => setShowHighlights(conceptToMap) : null return ( <> diff --git a/src/components/map-projects/ConfigurationForm.jsx b/src/components/map-projects/ConfigurationForm.jsx index 591ea4f..96aae12 100644 --- a/src/components/map-projects/ConfigurationForm.jsx +++ b/src/components/map-projects/ConfigurationForm.jsx @@ -21,15 +21,18 @@ import OpenInNewIcon from '@mui/icons-material/OpenInNew'; import Accordion from '@mui/material/Accordion' import AccordionSummary from '@mui/material/AccordionSummary' import AccordionDetails from '@mui/material/AccordionDetails' -import Chip from '@mui/material/Chip' import Stack from '@mui/material/Stack' +import Alert from '@mui/material/Alert' +import Tooltip from '@mui/material/Tooltip' import ExpandMoreIcon from '@mui/icons-material/ExpandMore' +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' @@ -68,6 +71,12 @@ const ConfigurationForm = ({ project, handleFileUpload, file, owner, setOwner, n 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') @@ -169,40 +178,70 @@ const ConfigurationForm = ({ project, handleFileUpload, file, owner, setOwner, n setRepoVersion(item)} value={repoVersion} sx={{marginTop: '12px'}} /> { effectiveTargetCanonical && - - - {t('map_project.target_canonical_url') || 'Canonical URL:'} - - - {effectiveTargetCanonical} + + + + {t('map_project.target_canonical_url', 'Canonical:')} + + + {effectiveTargetCanonical} + + { !targetCanonicalFromRepo && - + + · {t('map_project.canonical_auto_derived_short', 'derived')} + } } { bridgeAlgos.length > 0 && - - - {t('map_project.bridge_repos') || 'Bridge repos:'} - + { 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 ( - - - {b.target_repo_url || '—'} - - - - {bridgeCanonical} + + + + {t('map_project.bridge_canonical_short', 'Bridge:')} + + + {b.target_repo_url || '—'} → {bridgeCanonical} + + { - !b?.bridge_repo?.canonical_url && - + isDerived && + + · {t('map_project.canonical_auto_derived_short', 'derived')} + } ) @@ -276,7 +315,7 @@ const ConfigurationForm = ({ project, handleFileUpload, file, owner, setOwner, n } sx={{padding: 0, minHeight: 'auto', '.MuiAccordionSummary-content': {margin: '4px 0'}}}> - {t('map_project.advanced_settings') || 'Advanced settings'} + {t('map_project.advanced_settings', 'Advanced settings')} @@ -284,11 +323,14 @@ const ConfigurationForm = ({ project, handleFileUpload, file, owner, setOwner, n fullWidth size='small' sx={{marginTop: '4px'}} - label={t('map_project.resolution_namespace') || 'Resolution Namespace'} + label={t('map_project.resolution_namespace', 'Resolution Namespace')} value={namespaceValue} onChange={event => setNamespace(event.target.value || '')} placeholder={defaultNamespace} - helperText={t('map_project.resolution_namespace_description') || 'Namespace passed to $resolveReference (defaults to the project owner). Drives which URL Registry entries apply when resolving canonical URLs.'} + 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.' + })} /> @@ -377,6 +419,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 + } +
    • + )) + } +
    +
    + }
    { showAlgo && candidate?.algorithm_id ? -
    +
    + { + convergenceTooltip && + {convergenceTooltip}} placement='top'> + + + }
    : null }
    @@ -233,7 +262,7 @@ const legacyToRowView = (legacy) => { 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 } = rowView + 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. @@ -350,6 +379,7 @@ const Concept = ({_id, firstChild, lastChild, concept, setShowHighlights, isShow conceptDefinition={conceptDefinition} conceptRow={conceptRow} bridgeConceptDefinition={bridgeConceptDefinition} + bridgeContributors={bridgeContributors} repoVersion={repoVersion} synonymPrefix={synonymPrefix} setShowHighlights={setShowHighlights} diff --git a/src/components/map-projects/__tests__/views.test.js b/src/components/map-projects/__tests__/views.test.js index a825ca9..437582b 100644 --- a/src/components/map-projects/__tests__/views.test.js +++ b/src/components/map-projects/__tests__/views.test.js @@ -308,6 +308,55 @@ test('buildQualityRowViews: bridge_child becomes the primary when no standard ca 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). diff --git a/src/components/map-projects/viewBuilders.js b/src/components/map-projects/viewBuilders.js index 61e67ea..2e08bbc 100644 --- a/src/components/map-projects/viewBuilders.js +++ b/src/components/map-projects/viewBuilders.js @@ -87,12 +87,27 @@ export const buildQualityRowViews = (rowState, conceptCache) => { 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 } })) From 8b41e72f109db612358b65cd8e8546f8eaa25245 Mon Sep 17 00:00:00 2001 From: Jonathan Payne Date: Tue, 12 May 2026 13:25:51 -0400 Subject: [PATCH 12/29] OpenConceptLab/ocl_issues#2337 | PR2b: capture schema-specific property on algo responses MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Half of every input row's candidates rendered without their LOINC summary chips (COMPONENT/PROPERTY/TIME_ASPCT/etc.) in the card view. Diagnostic logging in Concept.jsx (added then removed in this branch) confirmed all affected candidates had: - algorithm_id: 'ocl-semantic' - lookup_source_type: 'algorithm' (never upgraded by ensureLoaded) - empty `concept.property` and empty `concept.properties` Root cause was client-side, not server-side. OCL $match with verbose=true already returns ConceptDetailSerializer output, which includes `property = JSONField(source='properties')` — the schema dict UI reads at ConceptSummaryProperties.jsx:22. oclmap was already passing verbose=true on every $match call. Two bugs in normalizers.js dropped the data on ingest: 1. toConceptDefinition's explicit field-copy list (lines 105-127) didn't include `property` or `extras`. Even when the verbose response had them, the normalizer threw them away before they reached conceptCache. Added both fields to the copy list with a comment pointing at the API surface they correspond to. 2. inferLookupStatus required BOTH `names` AND `descriptions` arrays for 'full' status. Many LOINC concepts have no separate `descriptions` (the LONG_COMMON_NAME is in `names`, schema fields are in `property`/`extras`) — they stayed stuck at 'partial' even when verbose-loaded, which triggered spurious ensureLoaded retries and produced misleading log signal. Reworked the heuristic: 'full' now means the response carries a verbose-payload marker (`property` field present — ConceptDetailSerializer always emits it, even as []), OR has populated `names` (the list- serializer signal). Explicitly DOESN'T check `extras` because scispacy synthesizes that field with internal metadata (LOINC_NUM, composite_score) and would false-positive into 'full'. Tests added in normalizers.test.js: - verbose response with populated `property` → full + property captured - verbose response with `property: []` → still full (marker, not length) - brief response without `property` or `names` → stays partial No server-side change needed. The "smart enough to detect partial vs full and do the lookup" behavior that was already in place now works because the lookup_status field accurately reflects payload completeness. Verified: 84/84 tests pass, eslint clean, production build green. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../__tests__/normalizers.test.js | 80 +++++++++++++++++++ src/components/map-projects/normalizers.js | 26 +++++- 2 files changed, 103 insertions(+), 3 deletions(-) diff --git a/src/components/map-projects/__tests__/normalizers.test.js b/src/components/map-projects/__tests__/normalizers.test.js index 771595e..6e2723b 100644 --- a/src/components/map-projects/__tests__/normalizers.test.js +++ b/src/components/map-projects/__tests__/normalizers.test.js @@ -362,6 +362,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', () => { diff --git a/src/components/map-projects/normalizers.js b/src/components/map-projects/normalizers.js index bf1dd76..f49b7e5 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,6 +181,8 @@ 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 From 0f122e526bb5822734db166d81adbd26e274b059 Mon Sep 17 00:00:00 2001 From: Jonathan Payne Date: Tue, 12 May 2026 13:55:20 -0400 Subject: [PATCH 13/29] OpenConceptLab/ocl_issues#2337 | PR2b: ignore per-algo search_normalized_score in multi-algo mode MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Interim semantic candidates still rendered "100.00%" unified-score chips until $rerank/ completed — the score*100 fallback drop from 9fcdbb2 didn't fix this case because the score wasn't coming from the fallback. It was coming from search_meta.search_normalized_score, which the OCL $match endpoint emits UNCONDITIONALLY (line 878 of oclapi2 views.py sets it to FAISS normalized_score × 100). For top semantic hits the value rounds to 100. The normalizer at newConceptRow read that straight into ConceptRow.rerank_score even in multi-algo mode where it's just a per-algo native score, not a unified rerank. Threaded trustServerRerank through the normalizer context: normalizeAlgorithmInvocation(payload, { trustServerRerank }) → normalizeAlgoResult(result, { trustServerRerank }) → newConceptRow(key, result, trustServerRerank) newConceptRow now propagates search_normalized_score to rerank_score ONLY when trustServerRerank is true. Call-site values: - Bulk auto-match processBatch (line 1496): !isMultiAlgo. The $match request at line 1433 sends reranker=!isMultiAlgo, so the response's normalized score is a real rerank only when single-algo. - Per-row $match (lines 2528, 2551): !isMultiAlgo && provider==='ocl'. Matches the line 2452 request flag and the line 2579 "rerank done" condition. - Bulk bridge (line 1687) and bulk scispacy (line 1731): omitted → defaults to false. Bridge's normalized score doesn't speak the project's query semantics; scispacy never had a real rerank. - normalizeLegacyAllCandidates: true. Saved-project rerank scores were persisted from a prior session's $rerank/ output, so they ARE the canonical rerank. UX consequence with the 9fcdbb2 fix in place: during the gap between multi-algo $match returning and $rerank/ completing, the unified chip renders a muted em-dash instead of a misleading 100.00%. When rerank lands, real scores appear. When a slow algo (scispacy) returns later, its rows briefly show "—" again until the next rerank pass — that's expected behavior given the staggered timing; the double-rerank itself is a known cost (Bug 9 follow-up: debounce more aggressively or wait for all-algos-complete). Tests updated: - normalizers.test.js: added a multi-algo case asserting search_ normalized_score is ignored when the caller doesn't opt in; updated the bridge ConceptRows test to expect undefined rerank_score (bridge path doesn't opt in); updated the single-algo carry-over test to pass trustServerRerank: true. - normalizeLegacyAllCandidates.test.js: unchanged — the legacy loader passes trustServerRerank: true, so its tests still see baked-in rerank scores. Verified: 85/85 tests pass. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/components/map-projects/MapProject.jsx | 16 +++++-- .../__tests__/normalizers.test.js | 42 +++++++++++++++---- src/components/map-projects/normalizers.js | 34 ++++++++++----- 3 files changed, 70 insertions(+), 22 deletions(-) diff --git a/src/components/map-projects/MapProject.jsx b/src/components/map-projects/MapProject.jsx index 18756d6..9ddecb6 100644 --- a/src/components/map-projects/MapProject.jsx +++ b/src/components/map-projects/MapProject.jsx @@ -1497,7 +1497,12 @@ const MapProject = () => { algorithmId: algo.id, algorithmConfig: algoCfg, projectContext: projectCtx, - rowIndex: idx + rowIndex: idx, + // Bulk auto-match's $match request sends reranker=!isMultiAlgo + // (line 1433). Trust the server's normalized score only when + // that flag was true — otherwise it's a per-algo native score + // (e.g. FAISS similarity × 100) masquerading as a rerank. + trustServerRerank: !isMultiAlgo })) }) } @@ -2530,7 +2535,11 @@ const MapProject = () => { algorithmConfig: algoDef, projectContext, rowIndex: __row.__index, - rawResponse: response + rawResponse: response, + // Mirrors the line 2452 reranker flag on the $match request. + // Only trust when single-algo native OCL — same condition that + // markAlgo('rerank', 1)s without firing a separate $rerank/. + trustServerRerank: !isMultiAlgo && algoDef.provider === 'ocl' })) } } @@ -2553,7 +2562,8 @@ const MapProject = () => { algorithmConfig: algoDef, projectContext, rowIndex: __row.__index, - rawResponse: response + rawResponse: response, + trustServerRerank: !isMultiAlgo && algoDef.provider === 'ocl' }), {append: true}) } } diff --git a/src/components/map-projects/__tests__/normalizers.test.js b/src/components/map-projects/__tests__/normalizers.test.js index 6e2723b..4fa7cb9 100644 --- a/src/components/map-projects/__tests__/normalizers.test.js +++ b/src/components/map-projects/__tests__/normalizers.test.js @@ -299,7 +299,10 @@ test('ConceptRow picks up search_normalized_score as rerank_score (single-algo r 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) @@ -309,6 +312,24 @@ test('ConceptRow picks up search_normalized_score as rerank_score (single-algo r 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, @@ -318,7 +339,8 @@ test('ConceptRow.rerank_score is undefined when the algorithm did not provide se algorithmId: 'ocl-search', algorithmConfig: oclSearchAlgo, algorithmResponseId: 'ar-1b', - projectContext + projectContext, + trustServerRerank: true }) assert.equal(out.concept_rows[0].rerank_score, undefined) }) @@ -541,17 +563,21 @@ 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) - // Bridge intermediary carries the bridge response's - // search_normalized_score (the algo did score the intermediary). - // Cascade target gets no rerank_score yet — the bridge response only - // scores the bridge concept; the debounced rerank pass fills the target - // once the row is eligible. + // 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, 92) + assert.equal(bridgeRow.rerank_score, undefined) assert.equal(targetRow.rerank_score, undefined) }) diff --git a/src/components/map-projects/normalizers.js b/src/components/map-projects/normalizers.js index f49b7e5..8d3a42c 100644 --- a/src/components/map-projects/normalizers.js +++ b/src/components/map-projects/normalizers.js @@ -189,13 +189,18 @@ const cascadeTargetToConceptDefinition = (mapping, cascadeIdentity, projectConte } } -// rerank_score on ConceptRow comes from the result's search_normalized_score -// when present (set by $match's reranker:true single-algo path) — no -// separate $rerank round-trip needed for that case. Otherwise undefined; -// it gets filled in by the debounced rerank pipeline. -const newConceptRow = (conceptKey, result) => ({ +// 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: typeof result?.search_meta?.search_normalized_score === 'number' + rerank_score: trustServerRerank && typeof result?.search_meta?.search_normalized_score === 'number' ? result.search_meta.search_normalized_score : undefined }) @@ -218,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 @@ -235,7 +240,7 @@ export const normalizeAlgoResult = (result, ctx = {}) => { const primaryDef = toConceptDefinition(result, primaryReference, identityConfig, { algorithmId }) conceptDefinitions.push(primaryDef) - conceptRows.push(newConceptRow(primaryDef.key, result)) + conceptRows.push(newConceptRow(primaryDef.key, result, trustServerRerank)) const primaryCandidate = { id: newId(), @@ -312,7 +317,8 @@ export const normalizeAlgorithmInvocation = (rawPayload, ctx = {}) => { rowIndex, status = 'success', error, - rawResponse + rawResponse, + trustServerRerank } = ctx const algorithmResponse = createAlgorithmResponse( @@ -332,7 +338,8 @@ export const normalizeAlgorithmInvocation = (rawPayload, ctx = {}) => { algorithmId, algorithmConfig, algorithmResponseId: algorithmResponse.id, - projectContext + projectContext, + trustServerRerank }) allCandidates.push(...candidates) concept_definitions.forEach(cd => { @@ -412,7 +419,12 @@ export const normalizeLegacyAllCandidates = ( algorithmId: algoId, algorithmConfig: algoConfig, projectContext, - rowIndex: idx + 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 } ) From 0ae57dddfbf3174dd80065f5b47f49b650b54093 Mon Sep 17 00:00:00 2001 From: Jonathan Payne Date: Tue, 12 May 2026 14:00:19 -0400 Subject: [PATCH 14/29] OpenConceptLab/ocl_issues#2337 | PR2b: rerank only ConceptRows that lack a score MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit scheduleRerank's debounce window (~200ms) is far shorter than the gap between fast algos (semantic, bridge — seconds) and slow ones (scispacy — minutes). When scispacy lands late, a second $rerank/ fires for the same row — but the implementation re-reranks EVERY ConceptRow, not just the new ones. For a row with 50 candidates from semantic + 20 from scispacy, the second pass redoes the 50 already-scored rows for no reason. buildRerankRowsForRow now skips ConceptRows whose rerank_score is already a number. The cross-encoder reranker is per-(query, candidate) so scores from successive partial batches stay on the same scale; the filter is safe. Net effect: - Rerank #1 (after semantic+bridge): scores all available ConceptRows. - Scispacy arrives 2 minutes later: its new ConceptRows have rerank_score=undefined. - Rerank #2: payload includes ONLY the new scispacy rows. The old semantic+bridge rows are skipped — their scores persist. If a previously-scored row's score is somehow cleared (e.g. cache reset), it becomes eligible again on the next scheduleRerank fire. Refresh path still works: mergeIntoRowMatchState's drop-and-replace from 2d96c09 prunes ConceptRows whose candidates were removed, so refresh creates fresh unscored rows and rerank picks them up. Verified: 85/85 tests pass. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/components/map-projects/MapProject.jsx | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/src/components/map-projects/MapProject.jsx b/src/components/map-projects/MapProject.jsx index 9ddecb6..91b0318 100644 --- a/src/components/map-projects/MapProject.jsx +++ b/src/components/map-projects/MapProject.jsx @@ -2657,6 +2657,12 @@ const MapProject = () => { // their ConceptDefinitions. Each row carries concept_key as a passthrough // anchor so we can match scored results back unambiguously, plus the // legacy concept-shaped fields the server expects. + // + // Only ConceptRows whose rerank_score is undefined are eligible — already- + // scored rows are skipped so a late-arriving algo (e.g. scispacy 2+ min + // after semantic+bridge) doesn't trigger a full re-rerank of every + // candidate. The cross-encoder reranker is per-(query, candidate) so + // scores from successive partial batches stay on the same scale. const buildRerankRowsForRow = (rowIndex) => { const rowState = rowMatchStateRef.current[rowIndex] if(!rowState) return [] @@ -2665,6 +2671,12 @@ const MapProject = () => { Object.values(rowState.concept_rows || {}).forEach(conceptRow => { const key = conceptRow.concept_key if(seen.has(key)) return + // Skip ConceptRows that already have a rerank_score. The debounced + // scheduler can fire multiple times as algos complete at different + // wall-clock times; sending already-scored rows back to $rerank/ is + // wasted compute (and network bandwidth — a row's candidate list can + // be hundreds of entries). + if(typeof conceptRow.rerank_score === 'number') return const def = conceptCacheRef.current[key] if(!def) return // Skip concepts whose ConceptDefinition has no usable display_name — From 6697c525e7ed554c2121b2ddeaa734874e69c667 Mon Sep 17 00:00:00 2001 From: Jonathan Payne Date: Tue, 12 May 2026 14:08:38 -0400 Subject: [PATCH 15/29] OpenConceptLab/ocl_issues#2337 | PR2b: align fetchAllCandidatesForRow's reuse check with the inner fetchers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Companion to b0e8d52 (scispacy fix). That commit only patched fetchScispacyCandidates's gate; the outer fetchAllCandidatesForRow still gated on results.length > 0. Sequence when scispacy returned zero matches on a prior fetch and the user clicks the row again: fetchAllCandidatesForRow(scispacy) sees results.length === 0 → canReuseExistingCandidates = false → markAlgo(scispacy, 0) // "Running: ocl-scispacy-loinc" indicator on → dispatches to fetchScispacyCandidates fetchScispacyCandidates sees existingEntry !== undefined (correct) → returns { skipped: true }, no callback fired onResponse never invoked → no algo_finished log → markAlgo stays at 0 → indicator pinned forever; in worst case scheduleRerank also stuck waiting for the marker that never lands Same fix: gate reuse on entry presence, not result count. Empty-result rows now correctly short-circuit through the reuse branch — markAlgo(1), recurse to next algo, schedule rerank — exactly like a non-empty-result reuse would. forceReload still bypasses (user-driven refresh). offset > 0 still bypasses (Fetch More pagination). _retired flip still bypasses (user toggled retired-concept inclusion). Verified: 85/85 tests pass. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/components/map-projects/MapProject.jsx | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/src/components/map-projects/MapProject.jsx b/src/components/map-projects/MapProject.jsx index 91b0318..70dc1b6 100644 --- a/src/components/map-projects/MapProject.jsx +++ b/src/components/map-projects/MapProject.jsx @@ -2475,10 +2475,18 @@ const MapProject = () => { let __row = isEmpty(_row) ? row : _row const existingCandidates = find(allCandidatesRef.current[algoId], c => c.row.__index === __row.__index) + // Reuse when the algo's invocation completed for this row, regardless + // of whether it returned matches. Gating on results.length > 0 made + // any algo that legitimately returned zero matches (e.g. scispacy on + // a row with no in-vocabulary terms) look like it had never run — + // fetchAllCandidatesForRow would then re-dispatch and the inner + // fetcher (which DOES short-circuit on entry presence) would skip + // silently without firing onResponse, leaving the "Running: …" + // indicator pinned forever. const canReuseExistingCandidates = !forceReload && offset === 0 && !_retired && - existingCandidates?.results?.length > 0 + existingCandidates !== undefined if(canReuseExistingCandidates) { markAlgo(__row.__index, algoId, 1) From ef22c1848215316d915a7f5cd92167f0792749ad Mon Sep 17 00:00:00 2001 From: Jonathan Payne Date: Tue, 12 May 2026 14:12:40 -0400 Subject: [PATCH 16/29] OpenConceptLab/ocl_issues#2337 | PR2b: onDecisionTabChange typo + in-flight guard MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Decision panel's tab handler was re-firing the full algo chain on every switch back to the Candidates tab. Two compounding bugs: const firstAlgo = getFirstAlgoDef()?.id // already a string if(... && !find(allCandidatesRef.current[firstAlgo?.id], ...)?.results?.length) { // ^^^ string?.id is undefined fetchAllCandidatesForRow(firstAlgo.id) // ^^^^^^^^^^^ also undefined → falls through // to 'use first algo if no algoId' } The cache-presence test always missed (allCandidatesRef.current[undefined] returns undefined), so the refetch always fired even when candidates were already loaded. fetchAllCandidatesForRow(undefined) → uses the first algo anyway, so the user-visible effect was the chain re-running. Additionally: no in-flight guard. If the user clicked a row, switched to Discuss before the first algo finished, then switched back to Candidates, the chain refired CONCURRENTLY with the still-running first chain. Result: duplicate algo_finished log entries (ocl-semantic logged at 2:01:18 and 2:01:19 for the same row) and double rerank invocations. Fix: - Compute firstAlgoId once and use it directly (no .id chain). - Skip the refetch when any selected algo for this row is currently in flight (rowStageRef.current[rowIndex][id] === 0). The status-0 marker is set at fetchAllCandidatesForRow line 2499 the moment a dispatch begins, so this catches the race window cleanly. - Use Boolean(find(...)) instead of .results.length to test presence — same fix class as 6697c52 and b0e8d52 (an algo that returned zero matches is still "loaded"). Verified: 85/85 tests pass, production build green. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/components/map-projects/MapProject.jsx | 21 ++++++++++++++++++--- 1 file changed, 18 insertions(+), 3 deletions(-) diff --git a/src/components/map-projects/MapProject.jsx b/src/components/map-projects/MapProject.jsx index 70dc1b6..bf6152d 100644 --- a/src/components/map-projects/MapProject.jsx +++ b/src/components/map-projects/MapProject.jsx @@ -2327,9 +2327,24 @@ const MapProject = () => { const onDecisionTabChange = (event, newValue) => { setShowItem(false) setDecisionTab(newValue) - const firstAlgo = getFirstAlgoDef()?.id - if(newValue === 'candidates' && repo?.id && !find(allCandidatesRef.current[firstAlgo?.id], c => c.row.__index === rowIndex)?.results?.length) { - fetchAllCandidatesForRow(firstAlgo.id) + if(newValue === 'candidates' && repo?.id) { + // Two prior bugs in this guard: + // 1. `firstAlgo` was getFirstAlgoDef()?.id (already a string), but the + // condition then did `allCandidatesRef.current[firstAlgo?.id]` — + // string?.id is undefined, so the cache lookup always missed and + // the refetch always fired even when candidates were cached. + // 2. No in-flight guard. If the user clicked the row, switched to + // Discuss before semantic finished, and switched back, the chain + // re-fired concurrently with the still-running first chain → + // duplicate algo_finished logs + double rerank. + const firstAlgoId = getFirstAlgoDef()?.id + const rowStageForRow = rowStageRef.current?.[rowIndex] || {} + const anyAlgoInFlight = selectedAlgoIds?.some(id => rowStageForRow[id] === 0) + const hasCandidates = Boolean( + find(allCandidatesRef.current[firstAlgoId], c => c.row.__index === rowIndex) + ) + if(firstAlgoId && !anyAlgoInFlight && !hasCandidates) + fetchAllCandidatesForRow(firstAlgoId) } if(['candidates', 'search'].includes(newValue) && isEmpty(getFacetsForRow(rowIndex))) getFacets(true) From eb5e3d63427d9b15cdc2bf04ae3a17f2775d5982 Mon Sep 17 00:00:00 2001 From: Jonathan Payne Date: Tue, 12 May 2026 14:33:54 -0400 Subject: [PATCH 17/29] OpenConceptLab/ocl_issues#2337 | PR2b: fix auto-match regression after Bug 9 rerank filter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bug 9 (0ae57dd) made buildRerankRowsForRow skip ConceptRows that already have a rerank_score, so the second rerank wouldn't redundantly re-score the first batch. That filter applied to ALL rerank invocations, including the explicit bulk-auto-match call. Race that caused the regression: 1. Bulk auto-match runs every selected algo (Promise.all on algoPromises). 2. Each algo's onResponse calls scheduleRerank(__row.__index) — debounced, isBulk=false. 3. The debounced rerank fires and scores every ConceptRow for the row. 4. processRerankWithConcurrency loops over rows and calls rerank(idx, true) — isBulk=true. 5. buildRerankRowsForRow finds zero unscored rows (the debounced pass scored them all). rerank() short-circuits at the empty-rerankRows guard and returns null. 6. setAutoMatched is wired to the isBulk=true success path (line ~2829 setTimeout setAutoMatched), so the short-circuit drops the side effect. User clicks Auto-Match, sees algos finish, sees rerank finish — and no row with a clearly-recommended top candidate gets proposed. Fix: when rerank() short-circuits with isBulk=true (no work needed, the debounced pass already did it), still schedule setAutoMatched. The rows ARE scored; the bulk caller just needs to be told to propose mappings. Verified: 85/85 tests pass. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/components/map-projects/MapProject.jsx | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/src/components/map-projects/MapProject.jsx b/src/components/map-projects/MapProject.jsx index bf6152d..6667524 100644 --- a/src/components/map-projects/MapProject.jsx +++ b/src/components/map-projects/MapProject.jsx @@ -2752,7 +2752,17 @@ const MapProject = () => { const rerankRows = buildRerankRowsForRow(index) const row = data[index] const query = get(prepareRow(row), 'name') - if(!rerankRows.length || !query) return null + if(!rerankRows.length || !query) { + // Nothing to rerank, but bulk auto-match still needs its side effect. + // After the Bug 9 filter (skip already-scored rows), this branch fires + // every time bulk processRerankWithConcurrency races a debounced + // scheduleRerank that already scored the row from algo onResponse. + // Without the setAutoMatched trigger, auto-match would never propose + // a mapping even for rows with a clearly-recommended top candidate. + if(isBulk && isNumber(index)) + setTimeout(() => setAutoMatched([index]), 1000) + return null + } inFlightRerankRef.current.add(index) markAlgo(index, 'rerank', 0) const service = APIService.concepts().appendToUrl('$rerank/') From 22eaa6a2ced3e76eed2cbe85ec0b4b65bc1e3d98 Mon Sep 17 00:00:00 2001 From: Jonathan Payne Date: Tue, 12 May 2026 14:36:08 -0400 Subject: [PATCH 18/29] OpenConceptLab/ocl_issues#2337 | PR2b: compact map-type chip + algo-view bridge polish MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three small Concept.jsx fixes uncovered in browser testing. map-type chip styling. The MUI Chip with size='small' was still tall enough to interrupt the surrounding text's line-height — visible as a vertical bump in cascade rows like "CIEL:1234 ... [SAME-AS] LOINC:52767-1 ...". Pulled out a MAP_TYPE_CHIP_SX constant: 18px height, 11px label, tight 0/6px label padding, baseline vertical alignment, 4px radius. Sits inline with the text now. Algo-view bridge children no longer repeat the bridge prefix. In Group by Algorithm, the parent bridge intermediary ConceptItem is already rendered above its nested cascade targets. The bridgeChild Item branch (added in 571a12b for Quality view) was rendering the prefix on every nested line too, so the same "CIEL:166016 Brain natriuretic..." appeared three times in a row. Gate the prefix render on !algoScoreFirst — Quality view still gets the full cascade inline, Algo view gets the cleaner "[BROADER-THAN] LOINC:42637-9 ..." per child. First bridge-child row in algo-view was missing its top divider. ConceptItem suppresses borderTop when firstChild={true}, and bridge children inherited that flag through baseProps when the bridge group itself was the first ConceptItem in the algorithm bucket. Explicitly pass firstChild={false} on every bridge-child render — there's always a parent intermediary above them, so the divider belongs. Verified: 85/85 tests pass. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/components/map-projects/Concept.jsx | 38 +++++++++++++++++++++---- 1 file changed, 33 insertions(+), 5 deletions(-) diff --git a/src/components/map-projects/Concept.jsx b/src/components/map-projects/Concept.jsx index ac2a36e..09c4742 100644 --- a/src/components/map-projects/Concept.jsx +++ b/src/components/map-projects/Concept.jsx @@ -25,6 +25,22 @@ const formatBridgeContributor = (entry) => { 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 = []) => { return synonyms @@ -78,15 +94,21 @@ const Item = ({candidate, conceptDefinition, conceptRow, bridgeConceptDefinition { bridgeChild ? ( - // Quality view, bridge-only convergence: render the bridge - // intermediary inline so the user sees both ends of the - // cascade (CIEL:1234 [SAME-AS] LOINC:52767-1 ...). + // 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. <> - {bridgePrefixLabel && ( + {!algoScoreFirst && bridgePrefixLabel && ( {bridgePrefixLabel} )} {candidate?.map_type && ( - + )} {`${sourceLabel || ''}:${idLabel} ${conceptDefinition?.display_name || ''}`.trim()} @@ -347,6 +369,12 @@ const Concept = ({_id, firstChild, lastChild, concept, setShowHighlights, isShow return Date: Tue, 12 May 2026 14:39:19 -0400 Subject: [PATCH 19/29] OpenConceptLab/ocl_issues#2337 | PR2b: Unranked Candidates bucket in Quality view MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Candidates arriving before $rerank/ completes were being shunted into Low Ranked Candidates. The bucketing logic at getCandidates used `score = view.conceptRow?.rerank_score || 0`, which coerced undefined to 0 — every unscored row failed the recommendedScore / availableScore thresholds and landed in low_ranked. Visually that read as "these are poor matches" when the truth is "we don't know yet, rerank is pending". Bucketing now keys on score presence first: if typeof score !== 'number' → pendingRerank else recommended / available / low_ranked Rendered as a fourth section at the bottom of Quality view with a neutral gray PENDING_RERANK_COLOR indicator. Header reads "Unranked Candidates" (translation key already existed at en.json:514 unused). Section only renders when pendingRerank.length > 0; once rerank lands on every ConceptRow it disappears and rows have already redistributed into their proper score buckets. Translation key was already in en/translations.json; no other locales touched (fallbackLng='en' covers them). Verified: 85/85 tests pass, production build green. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/common/colors.jsx | 4 +++ src/components/map-projects/Candidates.jsx | 39 +++++++++++++++++++--- src/components/map-projects/constants.jsx | 5 +-- 3 files changed, 42 insertions(+), 6 deletions(-) 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/Candidates.jsx b/src/components/map-projects/Candidates.jsx index 2da883f..3c6c941 100644 --- a/src/components/map-projects/Candidates.jsx +++ b/src/components/map-projects/Candidates.jsx @@ -485,20 +485,27 @@ const Candidates = ({rowIndex, alert, setAlert, rowState, conceptCache, targetCa let recommended = [] let available = [] let lowRanked = [] + let pendingRerank = [] sortRowViews(qualityRowViews, sortBy, order).forEach(view => { - let score = view.conceptRow?.rerank_score || 0 + const score = view.conceptRow?.rerank_score if(byScore) { - if(score >= recommendedScore) recommended.push(view) + // 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 } + return { recommended, available, lowRanked, pendingRerank } } - const { byAlgoCandidates, recommended, available, lowRanked } = getCandidates() + const { byAlgoCandidates, recommended, available, lowRanked, pendingRerank } = getCandidates() const getRightControls = () => { return ( @@ -673,6 +680,30 @@ const Candidates = ({rowIndex, alert, setAlert, rowState, conceptCache, targetCa isFirst={recommended.length === 0 && available.length === 0 && lowRanked.length > 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 && + + }
  • } 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 From 45416ea52c112a79d5f03709193b6c0c23edd63e Mon Sep 17 00:00:00 2001 From: Jonathan Payne Date: Tue, 12 May 2026 14:51:30 -0400 Subject: [PATCH 20/29] OpenConceptLab/ocl_issues#2337 | PR2b: re-normalize legacy candidates when repo canonical resolves MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Saved projects reloaded with non-scispacy candidates missing from every row. Quality view rendered empty or scispacy-only; the data was loaded correctly into allCandidates and project history was intact, but Candidates.jsx filters Quality-view rows on view.conceptDefinition.reference.url === targetCanonical and the references didn't match. Root cause is an async ordering race in fetchAndSetProject: 1. Project GET returns. response.data has target_repo_url but the save format never persisted target_repo.canonical_url. 2. setProjectFromData fires fetchRepo(repoURL, _repo) — promise NOT awaited. 3. Same tick: loadProjectContext is built with the FALLBACK derived canonical (https://ns.openconceptlab.org{relurl}). normalizeLegacy- AllCandidates stamps every ConceptDefinition.reference.url with it. 4. Later: fetchRepo resolves, repo state lands with the REAL canonical (e.g. http://loinc.org for LOINC). buildProjectContext computes targetCanonical from this real value, passed as a prop to Candidates. 5. Quality view filters by reference.url === targetCanonical: 'https://ns.openconceptlab.org/orgs/Regenstrief/sources/LOINC/' vs 'http://loinc.org' → no match → empty list. 6. Scispacy uses reference_source: 'fixed' with a hardcoded canonical_url, so its references are stamped 'http://loinc.org' regardless of projectContext — and survive the filter. Hence the "only scispacy shows" symptom. Two-part fix: (a) On save, persist target_repo.canonical_url (plus owner/source/version metadata) alongside target_repo_url. Future loads pick up the canonical on first read; no fetchRepo wait. (b) For older saves without target_repo.canonical_url (and for any post-load repo change), a useEffect on buildProjectContext re-runs normalizeLegacyAllCandidates whenever the live target canonical differs from the one used for the last normalize. lastNormalizedCanonicalRef guards against re-running on identical canonicals — idempotent. normalizeLegacyAllCandidates already takes a projectContext arg, so the fix is plumbing only — no normalizer changes. Verified: 85/85 tests pass, production build green. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/components/map-projects/MapProject.jsx | 55 ++++++++++++++++++++++ 1 file changed, 55 insertions(+) diff --git a/src/components/map-projects/MapProject.jsx b/src/components/map-projects/MapProject.jsx index 6667524..e3053bf 100644 --- a/src/components/map-projects/MapProject.jsx +++ b/src/components/map-projects/MapProject.jsx @@ -1189,6 +1189,19 @@ const MapProject = () => { formData.append('columns', JSON.stringify(map(columns, col => ({...col, hidden: columnVisibilityModel[col.dataKey] === false, width: columnWidth[col.dataKey] || undefined, ai_assistant_hidden: AIAssistantColumns[col.dataKey] === false})))) if(repoVersion?.version_url) formData.append('target_repo_url', repoVersion.version_url) + // Persist the live target_repo canonical so reload doesn't need to wait + // for fetchRepo. Without this the load path falls back to a derived + // canonical (https://ns.openconceptlab.org/...) that doesn't match the + // real repo canonical (e.g. http://loinc.org), causing the Quality-view + // filter on conceptDefinition.reference.url to reject all candidates. + if(repo?.canonical_url) + formData.append('target_repo', JSON.stringify({ + canonical_url: repo.canonical_url, + owner: repo.owner, + owner_type: repo.owner_type, + source: repo.short_code || repo.id, + source_version: repoVersion?.id || repo.version || repo.id + })) formData.append('algorithms', JSON.stringify(map(algosSelected, algo => omit(algo, ['__key'])))) formData.append('score_configuration', JSON.stringify(candidatesScore)) formData.append('lookup_config', JSON.stringify(lookupConfig)) @@ -1636,6 +1649,48 @@ const MapProject = () => { conceptCacheRef.current = conceptCache; }, [conceptCache]); + // Re-normalize legacy allCandidates when the target repo's real canonical + // URL arrives. fetchAndSetProject runs synchronously and falls back to a + // derived canonical (https://ns.openconceptlab.org{relurl}) because the + // save format never persisted target_repo.canonical_url. Once fetchRepo + // resolves and `repo.canonical_url` lands (e.g. 'http://loinc.org' for + // LOINC), the ConceptDefinitions on rowMatchState are still stamped with + // the derived URL — and Candidates.jsx's Quality view filters by + // `view.conceptDefinition.reference.url === targetCanonical` (the live + // value), so nothing matches and the panel renders empty. Re-running + // normalizeLegacyAllCandidates with the live projectContext re-stamps the + // references to match. + const lastNormalizedCanonicalRef = React.useRef(null) + React.useEffect(() => { + const ctx = buildProjectContext() + const liveCanonical = ctx?.target_repo?.canonical_url + if(!liveCanonical) return + if(lastNormalizedCanonicalRef.current === liveCanonical) return + const allCands = allCandidatesRef.current + if(!allCands || isEmpty(allCands)) { + // No legacy data yet — record the canonical and exit. The initial + // load path will normalize once data arrives. + lastNormalizedCanonicalRef.current = liveCanonical + return + } + const enrichedAlgos = (algosSelected || []).map(a => ensureConceptIdentity(a) || a) + const { rowMatchState: newRowMatchState, conceptDefinitionsByKey: newDefsByKey } = + normalizeLegacyAllCandidates(allCands, ctx, enrichedAlgos, CONCEPT_IDENTITY_BY_TYPE) + rowMatchStateRef.current = newRowMatchState + setRowMatchState(newRowMatchState) + if(newDefsByKey.size > 0) { + const next = { ...conceptCacheRef.current } + newDefsByKey.forEach((def, key) => { + const existing = next[key] + if(!existing || lookupStatusRank(def.lookup_status) > lookupStatusRank(existing.lookup_status)) + next[key] = def + }) + conceptCacheRef.current = next + setConceptCache(next) + } + lastNormalizedCanonicalRef.current = liveCanonical + }, [buildProjectContext, algosSelected]) + const runBulkAIAnalysis = async (_rows) => { setLoadingMatches(true) setBulkAIAnalysisStartedAt(moment()) From a7d827c98d21ecc8bff4c91f520f852453e7e01f Mon Sep 17 00:00:00 2001 From: Jonathan Payne Date: Tue, 12 May 2026 15:33:09 -0400 Subject: [PATCH 21/29] OpenConceptLab/ocl_issues#2337 | PR2b: three small follow-ups from gap review MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three of the four "significant but cheap to land" items from the gap audit. The fourth (scispacy lookup_status='partial') is code-path resolved already — mergeIntoRowMatchState auto-fires ensureLoaded for non-'full' definitions and ensureLoaded handles missing ocl_url via $resolveReference — pending only E2E verification. #6 — Cleared namespace not sent on save. formData.append('namespace', namespace || '') unconditionally. The previous `if(namespace)` guard meant that clearing the field in the Advanced settings drawer silently dropped the change; server kept the stale value forever. Empty string is the server-side signal to use the project owner default, so always sending it is the correct behavior. #10 — buildQualityRowViews non-deterministic primary candidate. Multi-algo convergence (e.g. ocl-search + ocl-semantic both returning the same LOINC concept) picked the primary via `find()` first-hit on Object.values(rowState.candidates) iteration order, which flipped between renders. The UI's "primary algorithm" chip blinked between 'ocl-search' and 'ocl-semantic'. Now sorts by candidate.score desc within each type group (standard first, then bridge_child), with -Infinity fallback for unscored candidates (notably bridge_child). Added a test that locks in the score-based selection regardless of insertion order. #12 — ensureLoaded honored session token instead of lookupConfig.token. The legacy Search-tab and decision-view fetches passed `lookupConfig?.token` to getLookupService.get(), but the new unified ensureLoaded path silently used currentUserToken() for both the $resolveReference call and the per-concept fetch — leaving the LookupConfig widget half-decorative. Now reads `lookupConfig?.token || currentUserToken()` once and threads `authToken` through both fetches. Added lookupConfig?.token to the useCallback deps so token changes from the settings drawer immediately rebind. (Note: ensureLoaded still uses the response's ocl_url for the per- concept fetch URL; lookupConfig.url support would require redirecting those URLs to a custom server, which is a bigger architectural change and out of scope here — track separately if users ask.) Tests: 86/86 (was 85; +1 new convergence-primary test). Co-Authored-By: Claude Opus 4.7 (1M context) --- src/components/map-projects/MapProject.jsx | 20 ++++++++++++---- .../map-projects/__tests__/views.test.js | 24 +++++++++++++++++++ src/components/map-projects/viewBuilders.js | 11 +++++++-- 3 files changed, 48 insertions(+), 7 deletions(-) diff --git a/src/components/map-projects/MapProject.jsx b/src/components/map-projects/MapProject.jsx index e3053bf..0c38262 100644 --- a/src/components/map-projects/MapProject.jsx +++ b/src/components/map-projects/MapProject.jsx @@ -1205,8 +1205,11 @@ const MapProject = () => { formData.append('algorithms', JSON.stringify(map(algosSelected, algo => omit(algo, ['__key'])))) formData.append('score_configuration', JSON.stringify(candidatesScore)) formData.append('lookup_config', JSON.stringify(lookupConfig)) - if(namespace) - formData.append('namespace', namespace) + // Always send `namespace` (including empty string) so clearing the field + // in the UI actually propagates to the server. The server reads empty + // as "use the project owner default"; gating the append on truthiness + // silently dropped the clear and kept the stale value in storage. + formData.append('namespace', namespace || '') formData.append('encoder_model', encoderModel || DEFAULT_ENCODER_MODEL) formData.append('include_retired', retired) formData.append('filters', JSON.stringify(getFilters())) @@ -3100,6 +3103,13 @@ const MapProject = () => { const ctx = buildProjectContext() const resolveNamespace = ctx?.namespace const cache = conceptCacheRef.current + // Honor the user-configured lookup token when present (LookupConfig in + // the project settings drawer). Falls back to the session's user token + // for projects that don't override. Without this gate, the legacy + // Search-tab fetches respected the user's token but the new unified + // ensureLoaded path silently used the session token instead — leaving + // the LookupConfig widget half-decorative. + const authToken = lookupConfig?.token || currentUserToken() const directFetches = [] const toResolve = [] @@ -3141,7 +3151,7 @@ const MapProject = () => { try { const response = await APIService.new() .overrideURL(oclUrl) - .get(currentUserToken(), null, {includeMappings: true, mappingBrief: true, mapTypes: 'SAME-AS,SAME AS,SAME_AS', verbose: true}) + .get(authToken, null, {includeMappings: true, mappingBrief: true, mapTypes: 'SAME-AS,SAME AS,SAME_AS', verbose: true}) const data = response?.data if(data?.id) { const existing = conceptCacheRef.current[key] || {} @@ -3172,7 +3182,7 @@ const MapProject = () => { : {url: reference.url}) resolvePromise = APIService.new() .overrideURL('/$resolveReference/') - .post(body, currentUserToken(), null, resolveNamespace ? {namespace: resolveNamespace} : undefined) + .post(body, authToken, null, resolveNamespace ? {namespace: resolveNamespace} : undefined) .then(async response => { const items = Array.isArray(response?.data) ? response.data : [] await Promise.all(toResolve.map(async ({key, reference}, i) => { @@ -3197,7 +3207,7 @@ const MapProject = () => { } await Promise.all([directPromise, resolvePromise, ...pending]) - }, [buildProjectContext, writeConceptCachePatch, writeLookupFailure]) + }, [buildProjectContext, writeConceptCachePatch, writeLookupFailure, lookupConfig?.token]) // Wire the forward-ref consumed by mergeIntoRowMatchState. useEffect // so the ref always points at the latest closure (ensureLoaded captures diff --git a/src/components/map-projects/__tests__/views.test.js b/src/components/map-projects/__tests__/views.test.js index 437582b..cc87c5d 100644 --- a/src/components/map-projects/__tests__/views.test.js +++ b/src/components/map-projects/__tests__/views.test.js @@ -238,6 +238,30 @@ test('buildQualityRowViews dedupes multi-algo convergence (one ConceptRow, multi 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', () => { diff --git a/src/components/map-projects/viewBuilders.js b/src/components/map-projects/viewBuilders.js index 2e08bbc..3d9fea4 100644 --- a/src/components/map-projects/viewBuilders.js +++ b/src/components/map-projects/viewBuilders.js @@ -80,8 +80,15 @@ export const buildQualityRowViews = (rowState, conceptCache) => { 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. - const primary = contributing.find(c => c.type === 'standard') - || contributing.find(c => c.type === 'bridge_child') + // 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 From e2ccf71e5762f9a26d51d60b17ff9cd55785408c Mon Sep 17 00:00:00 2001 From: Jonathan Payne Date: Tue, 12 May 2026 16:17:55 -0400 Subject: [PATCH 22/29] OpenConceptLab/ocl_issues#2337 | PR2b: round 4 of engineer review fixes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Four fixes from @paynejd's local prod build exercise. Bridge children sort + table-view properties are the cosmetic items from Test B; AI Assistant POST + auto-fetch of target_repo canonical are the load- bearing functional fixes. Test B indent/wrap deferred (CSS-only, needs DOM probing). #F — AI Assistant POST never fires (GET works). fetchRecommendation's candidate-count gate sourced from legacy allCandidates only. Any flow that ends with populated rowMatchState but empty allCandidates (saved-project load races, future paths that skip the parallel legacy write) bailed at `_candidates.length > 0` before the POST. Now falls back to projecting unified state via conceptForMapping when legacy is empty. buildV2RecommendationPayload gets the same treatment: prefers rowMatchState + conceptCache directly (the authoritative source under UNIFIED_MODEL_ENABLED), with the legacy normalize-over-allCandidates path as fallback. Test B — Bridge children non-deterministic sort. buildAlgorithmRowViews preserved Object.values(rowState.candidates) insertion order for nested bridge children. Same project rendered differently between refreshes. New sortBridgeChildren helper orders by rerank_score desc (when present), then concept code/id ascending. Stable + meaningful — high-scoring cascade targets surface first. Test B — Table view rendered without LOINC schema properties. conceptForMapping projected `properties` (plural) but ConceptSummaryProperties reads `property` (singular, the schema- specific dict OCL returns for LOINC: COMPONENT/PROPERTY/TIME_ASPCT/...). Card view worked because it gets the ConceptDefinition directly; table view's rowsForTable spread only had what conceptForMapping returned. Adding `property` and `extras` to the projection puts the schema chips back in the table. Test E — Scispacy candidates returned by API but not shown in UI. Root cause: Quality view filter compares `ConceptDefinition.reference.url === targetCanonical`. Scispacy uses a FIXED canonical ('http://loinc.org', per CONCEPT_IDENTITY_BY_TYPE). When the project's target_repo lacks canonical_url in its loaded metadata (RepoSearchAutocomplete returns brief data), the live canonical falls back to `https://ns.openconceptlab.org{relurl}` — mismatch, filter excludes every scispacy candidate. Fix: auto-fetch the target repo's canonical_url on selection (mirrors MultiAlgoSelector's bridge_repo canonical fetch in fdc60b8). The re-normalize useEffect from 45416ea then re-stamps any already- normalized ConceptDefinitions to the live canonical. fetchedRepoCanonicalUrlRef guards against re-fetches when the user toggles between repos. For projects targeting a real LOINC repo (canonical_url=http://loinc.org in OCL metadata), this restores parity: scispacy results pass the Quality view filter. Projects targeting a custom LOINC subset (no canonical_url, falls back to derived) still need a follow-up — the fundamental issue is that scispacy's "I always return LOINC codes" shouldn't be filtered out just because the target repo happens to be a LOINC variant — track separately if reported. Tests: 86/86 pass, eslint clean, production webpack build green. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/components/map-projects/MapProject.jsx | 101 ++++++++++++++++---- src/components/map-projects/viewBuilders.js | 28 +++++- 2 files changed, 111 insertions(+), 18 deletions(-) diff --git a/src/components/map-projects/MapProject.jsx b/src/components/map-projects/MapProject.jsx index 0c38262..2f2e161 100644 --- a/src/components/map-projects/MapProject.jsx +++ b/src/components/map-projects/MapProject.jsx @@ -1840,6 +1840,33 @@ const MapProject = () => { } } + // Fetch the target repo's canonical_url when it isn't already present. + // RepoSearchAutocomplete returns brief metadata that may omit it, and + // saved-project loads may pre-date the canonical persistence in onSave. + // Without this fetch, buildProjectContext falls back to a derived + // ns.openconceptlab.org URL, the unified-model normalizer stamps every + // ConceptDefinition.reference.url with the derived form, and any + // fixed-canonical algorithm (scispacy → http://loinc.org) ends up with a + // reference.url that doesn't match the live target canonical — so the + // Quality view filter excludes the candidates even though they were + // fetched successfully. Mirrors the bridge_repo canonical fetch in + // MultiAlgoSelector.jsx (commit fdc60b8). + const fetchedRepoCanonicalUrlRef = React.useRef(new Set()) + React.useEffect(() => { + if(!repo?.url) return + if(repo.canonical_url) return + if(fetchedRepoCanonicalUrlRef.current.has(repo.url)) return + fetchedRepoCanonicalUrlRef.current.add(repo.url) + APIService.new() + .overrideURL(repo.url) + .get() + .then(response => { + const canonical = response?.data?.canonical_url + if(canonical) setRepo(prev => prev?.url === repo.url ? {...prev, canonical_url: canonical} : prev) + }) + .catch(() => {}) + }, [repo?.url, repo?.canonical_url]) + const prepareRow = (csvRow, additional=false, forRecommendation=false) => { let row = {} let metadata = {} @@ -3573,29 +3600,50 @@ const MapProject = () => { const projectContext = buildProjectContext() if(!projectContext?.target_repo?.canonical_url) return null - const allNormCandidates = [] + let allNormCandidates = [] const defsByKey = new Map() - selectedAlgoIds.forEach(algoId => { - const algoDef = getAlgoDef(algoId) - if(!algoDef?.concept_identity) return - const rowEntry = find(allCandidatesRef.current[algoId], c => c.row?.__index === rowIndex) - if(!rowEntry?.results?.length) return - - const normalized = normalizeAlgorithmInvocation( - {row: rowEntry.row, results: rowEntry.results}, - {algorithmId: algoId, algorithmConfig: algoDef, projectContext, rowIndex} - ) - - allNormCandidates.push(...normalized.candidates) - normalized.concept_definitions.forEach(def => { + // Prefer reading directly from unified rowMatchState + conceptCache when + // it's populated (the authoritative source under UNIFIED_MODEL_ENABLED). + // Falls back to re-normalizing allCandidates only if the unified state + // is empty — keeps the PR2a path alive for any pre-flag flows. + const rowState = rowMatchStateRef.current?.[rowIndex] + const haveUnified = rowState && Object.keys(rowState.candidates || {}).length > 0 + if(haveUnified) { + allNormCandidates = Object.values(rowState.candidates) + Object.values(rowState.candidates).forEach(cand => { + const def = conceptCacheRef.current[cand.concept_key] + if(!def) return const existing = defsByKey.get(def.key) - // Prefer richer definitions (full > partial > pending), matching the - // mergeIntoRowMatchState rule. if(!existing || lookupStatusRank(def.lookup_status) > lookupStatusRank(existing.lookup_status)) defsByKey.set(def.key, def) + if(cand.bridge_concept_key) { + const bridgeDef = conceptCacheRef.current[cand.bridge_concept_key] + if(bridgeDef && !defsByKey.has(bridgeDef.key)) defsByKey.set(bridgeDef.key, bridgeDef) + } }) - }) + } else { + selectedAlgoIds.forEach(algoId => { + const algoDef = getAlgoDef(algoId) + if(!algoDef?.concept_identity) return + const rowEntry = find(allCandidatesRef.current[algoId], c => c.row?.__index === rowIndex) + if(!rowEntry?.results?.length) return + + const normalized = normalizeAlgorithmInvocation( + {row: rowEntry.row, results: rowEntry.results}, + {algorithmId: algoId, algorithmConfig: algoDef, projectContext, rowIndex} + ) + + allNormCandidates.push(...normalized.candidates) + normalized.concept_definitions.forEach(def => { + const existing = defsByKey.get(def.key) + // Prefer richer definitions (full > partial > pending), matching the + // mergeIntoRowMatchState rule. + if(!existing || lookupStatusRank(def.lookup_status) > lookupStatusRank(existing.lookup_status)) + defsByKey.set(def.key, def) + }) + }) + } const targetCanonical = projectContext.target_repo.canonical_url const recommendable_concepts = [] @@ -3671,7 +3719,26 @@ const MapProject = () => { console.error('AI ASSISTANT is not enabled for you.') return false } + // Source the legacy `candidates[]` array first from allCandidates (the + // PR2a path), and fall back to projecting from the unified rowMatchState + // when legacy is empty. Without the fallback, any flow that ends with + // populated rowMatchState but empty allCandidates (e.g. flag-on saved- + // project load races, or future paths that skip the parallel legacy + // write) bails before the POST fires — user sees no AI Assistant + // response even though the row clearly has candidates on screen. let _candidates = flatten(map(selectedAlgoIds, algoId => find(allCandidatesRef.current[algoId], c => c.row?.__index === __index)?.results || [])) + if(_candidates.length === 0 && rowMatchStateRef.current?.[__index]) { + const rowState = rowMatchStateRef.current[__index] + _candidates = compact( + Object.values(rowState.candidates || {}).map(cand => { + const def = conceptCacheRef.current[cand.concept_key] + if(!def) return null + const conceptRow = rowState.concept_rows?.[cand.concept_key] + const bridgeDef = cand.bridge_concept_key ? conceptCacheRef.current[cand.bridge_concept_key] : undefined + return conceptForMapping({candidate: cand, conceptDefinition: def, conceptRow, bridgeConceptDefinition: bridgeDef}) + }) + ) + } if(isNumber(__index) && repoVersion && !analysis[__index] && _candidates?.length > 0) { if(!promptTemplate?.key) { setAlert({message: 'AI Assistant prompt template is not available', severity: 'error'}) diff --git a/src/components/map-projects/viewBuilders.js b/src/components/map-projects/viewBuilders.js index 3d9fea4..31e4721 100644 --- a/src/components/map-projects/viewBuilders.js +++ b/src/components/map-projects/viewBuilders.js @@ -47,6 +47,25 @@ export const candidateToRowView = (candidate, conceptCache, rowState) => { * 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) @@ -56,7 +75,7 @@ export const buildAlgorithmRowViews = (rowState, conceptCache, algoId) => { 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 = compact(children.map(c => candidateToRowView(c, conceptCache, rowState))) + view.bridgeChildren = sortBridgeChildren(compact(children.map(c => candidateToRowView(c, conceptCache, rowState)))) } return view })) @@ -169,6 +188,13 @@ export const conceptForMapping = (rowView) => { 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, From 48dc6b5ab06ff577b50c69d14f0bf95995dd0713 Mon Sep 17 00:00:00 2001 From: Jonathan Payne Date: Tue, 12 May 2026 16:41:59 -0400 Subject: [PATCH 23/29] OpenConceptLab/ocl_issues#2337 | PR2b: round 5 of engineer review fixes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Four fixes from @paynejd's prod-build exercise. Two functional (AI POST 404, scispacy 503 cache-poisoning), two UI (refresh button visibility, bridge_child secondary indent + word-break). #F — AI POST 404: prompts///invoke/. fetchRecommendation pinned the resolved prompt template's version into the invoke URL for every call. Bulk auto-match runs need that pin so a mid-run template publish can't shift behavior. Single-row invocations (AI Assistant button on one row) have no such consistency requirement and the user never picks a specific version — pinning made the URL hit a path the server may not host, producing a 404. Fix: when no caller- supplied resolvedPromptTemplate is provided (the single-row path), strip version + uri/url/prompt_template_uri from the resolved template so getResolvedPromptTemplateURI falls through to '/prompts//' and the invoke URL becomes '/prompts//invoke/'. Bulk path (caller passes its pre-resolved template) is unchanged. Scispacy 503 retry + loading-state race. Two bugs in fetchScispacyCandidates: 1. The .then was detached and the try/finally wrapper ran synchronously while the POST was still pending. setIsLoadingInDecisionView(false) fired immediately, the Candidates panel rendered "no candidates" before the server responded. 2. A 503 from the scispacy service (returned during its 2-5 min wake- from-sleep) flowed into the callback as if it were a successful empty response. fromScispacyResultsToConcepts produced [], the reuse-check entry was written with results:[], and the next row visit skipped the retry (Bug 2's existingEntry !== undefined gate). The user was stuck with permanently-empty scispacy candidates until they hard-refreshed. Fix: clear isLoading inside the response handler (success OR error branch). Detect error responses (response?.detail, status >= 400, missing data) and mark the algo as failed without writing the cached empty entry. Wire a .catch for hard network errors. Subsequent visits re-fetch. User sees a warning alert with retry guidance. Refresh button visible on no-candidates state. The toolbar (refresh + Group + Sort) was hidden whenever noCandidatesFound was true — leaving error states with no recovery path. Now refresh shows always; Group/Sort still hide when there are no candidates to organize. Click counts as a row-level retry which re-fires any failed algorithm. Bridge_child secondary text indent + word-break (Test B cosmetic). Bridge_child rows render with a leading [SAME-AS] chip on the primary line. ListItemText's secondary defaults to flush-left, so the secondary content (LOINC schema chips: COMPONENT/PROPERTY/...) rendered visually LEFT of the chip — the artifact in @paynejd's screenshot. Add 6px paddingLeft when bridgeChild=true so the secondary aligns with the chip's left edge. Also set overflowWrap: break-word + wordBreak: normal on the secondary container so LOINC's '^' and '/' separators (and stretches like "peptide.B") don't trigger mid-word breaks when there's whitespace available. Not addressed in this commit (need diagnostics): - Bridge as FIRST algo doesn't fire fetch (works when second). Cannot reproduce from code review. Could be bridge module init timing (bridgeRef.current undefined during first dispatch) or a state-order issue. Need a console+network log from a fresh "bridge as first algo" click — ideally with `console.log(bridgeRef.current?.canBridge())` fired at the moment of the click. - Table view sparse for bridge intermediaries / bridge_children. Need a screenshot pinpointing which columns are empty + which view mode (Algorithm/Quality grouping). My hypothesis is that the rows shown in table view are bridge primaries (CIEL) which lack LOINC schema property dict; bridge_children with full LOINC data should populate when they're the iterated row. Tests: 86/86 pass, eslint clean, production webpack build green. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/components/map-projects/Candidates.jsx | 21 ++++--- src/components/map-projects/Concept.jsx | 15 ++++- src/components/map-projects/MapProject.jsx | 66 ++++++++++++++++++---- 3 files changed, 83 insertions(+), 19 deletions(-) diff --git a/src/components/map-projects/Candidates.jsx b/src/components/map-projects/Candidates.jsx index 3c6c941..6cbefb6 100644 --- a/src/components/map-projects/Candidates.jsx +++ b/src/components/map-projects/Candidates.jsx @@ -514,16 +514,23 @@ const Candidates = ({rowIndex, alert, setAlert, rowState, conceptCache, targetCa !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 && <> - - - - - - - diff --git a/src/components/map-projects/Concept.jsx b/src/components/map-projects/Concept.jsx index 09c4742..3feb5ec 100644 --- a/src/components/map-projects/Concept.jsx +++ b/src/components/map-projects/Concept.jsx @@ -155,7 +155,20 @@ const Item = ({candidate, conceptDefinition, conceptRow, bridgeConceptDefinition
    } secondary={ -
    + // For bridge_child rows the primary line starts with a leading + // [SAME-AS] chip (margin 0 6px). Without a matching left indent, + // the secondary text aligns flush left and renders *to the left* + // of the chip, producing the visual artifact reported in PR2b + // testing. The chip has 6px left margin + ~50px label width, so + // a 6px left padding under bridgeChild keeps the secondary text + // aligned with the chip's left edge. overflowWrap+wordBreak + // prevents LOINC's `^` and `/` separators (and stretches like + // "peptide.B" with embedded periods) from word-breaking even when + // there's whitespace to spare. +
    diff --git a/src/components/map-projects/MapProject.jsx b/src/components/map-projects/MapProject.jsx index 2f2e161..4f1d412 100644 --- a/src/components/map-projects/MapProject.jsx +++ b/src/components/map-projects/MapProject.jsx @@ -2746,19 +2746,44 @@ const MapProject = () => { const payload = {rows: [{label: inputRow.name, itemid: __row.__index}]} const service = APIService.new() service.URL = SCISPACY_API_URL - try { - service.appendToUrl('/$match-scispacy-loinc/').post(payload).then(response => { - if(callback) - callback(response, payload) + // Note: the previous shape had `setIsLoadingInDecisionView(false)` inside + // a `finally` block that fired synchronously *before* the POST resolved + // (the .then was detached, not awaited). Result: isLoading flipped back + // to false immediately, the Candidates panel rendered "no candidates" + // before any response arrived. Now the loading flag is cleared inside + // the response handler instead, after the actual response (success OR + // error) arrives. 5xx responses (the scispacy service taking 2-5 min + // to wake up returns 503) no longer get written as `results: []` — + // we mark the algo as failed without persisting the empty entry, so + // the next row visit retries. + service.appendToUrl('/$match-scispacy-loinc/').post(payload) + .then(response => { + const isError = response?.detail + || response?.status >= 400 + || (response && response.data === undefined && response.status !== 200) + if(isError) { + markAlgo(__row.__index, 'ocl-scispacy-loinc', -2) + log({action: 'algo_failed', extras: {algo: 'ocl-scispacy-loinc', status: response?.status, detail: response?.detail}}, __row.__index) + setAlert({ + message: response?.detail || `Scispacy service unavailable (HTTP ${response?.status || '???'}). The service can take 2–5 min to start up after sleep — click Refresh on this row to retry.`, + severity: 'warning' + }) + setIsLoadingInDecisionView(false) + return response + } + if(callback) callback(response, payload) + setIsLoadingInDecisionView(false) return response }) - } catch (err) { - markAlgo(__row.__index, 'ocl-scispacy-loinc', -2) - log({action: 'algo_failed', extras: {algo: 'ocl-scispacy-loinc'}}, __row.__index) - throw err; - } finally { - setIsLoadingInDecisionView(false); - } + .catch(err => { + markAlgo(__row.__index, 'ocl-scispacy-loinc', -2) + log({action: 'algo_failed', extras: {algo: 'ocl-scispacy-loinc', error: err?.message}}, __row.__index) + setAlert({ + message: `Scispacy service unavailable. The service can take 2–5 min to start up after sleep — click Refresh on this row to retry.`, + severity: 'warning' + }) + setIsLoadingInDecisionView(false) + }) } // Build the deduplicated rerank request body from the row's ConceptRows + @@ -3759,6 +3784,25 @@ const MapProject = () => { let activePromptTemplate try { activePromptTemplate = resolvedPromptTemplate || await resolvePromptTemplateForInvocation() + // Single-row invocations (no caller-supplied resolvedPromptTemplate) + // should always hit the latest prompt template, NOT a pinned version. + // Version pinning matters for bulk auto-match runs (the resolved + // template is captured once at the start and reused for every row so + // a mid-run template publish can't shift behavior). For single-row + // there's no such consistency requirement; pinning made the invoke + // URL '/prompts///invoke/' which 404s when the server + // doesn't host that specific version path. Clear `version` and the + // already-version-bearing `uri`/`url`/`prompt_template_uri` so + // getResolvedPromptTemplateURI falls through to '/prompts//'. + if(!resolvedPromptTemplate && activePromptTemplate) { + activePromptTemplate = { + ...activePromptTemplate, + version: null, + uri: null, + url: null, + prompt_template_uri: null + } + } } catch (err) { markAlgo(__index, 'recommend', -2) const errorMessage = err?.message || t('unknown_error') From 0e5805eccb00733f9c57b32af183b94f31ea59e4 Mon Sep 17 00:00:00 2001 From: Jonathan Payne Date: Tue, 12 May 2026 19:24:27 -0400 Subject: [PATCH 24/29] OpenConceptLab/ocl_issues#2337 | PR2b: round 6 of engineer review fixes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three fixes from @paynejd's round-5 verification: AI payload metadata trim, scispacy alert copy update, and bridge_child indent (finally — the previous CSS-padding shot did not match what the user wanted). AI Assistant — strip heavy metadata from legacy candidates payload. The "max_tokens 64000 / can only afford 62098" error is a server-side output-budget / credits limit (the prompt template's max_tokens param in ocl-ai-assistant), not an input-size issue per se. Verified on the client side anyway: the legacy `candidates[]` field only stripped `_source`, leaving `extras` (LOINC source-specific data, huge), `names` (multiple locales), `descriptions` (multiple, often long) in every candidate sent to the LLM. The v2 `recommendable_concepts` field already omits these. Now `stripHeavyFields` drops `_source`, `extras`, `names`, `descriptions` from each legacy candidate. Keeps the fields the legacy prompt template needs (id, display_name, source, search_meta, url, concept_class, datatype, retired, mappings, property). Reduces tokens regardless of whether it solves the specific credit error. Scispacy alert copy. Replaced the previous "Scispacy service unavailable (HTTP 503)..." message with the user's preferred phrasing: "OCL's scispacy matching service is starting up. This may take a couple minutes. You can safely leave this row and come back. Click Refresh if results aren't here in a couple of minutes." Applied to both the response-handler error branch and the .catch network-error branch. Bridge_child indent — proper flex layout (not CSS padding). The previous attempt (paddingLeft: 6px on the secondary div) didn't work because the BROADER-THAN chip is ~100px wide, the SAME-AS chip ~70px; a fixed 6px padding can't compensate. Restructured Item's return so when useFlexLayout is true (bridge_child with map_type), the chip becomes a SIBLING flex column to ListItemText. Both the primary text AND the secondary text inside ListItemText then align to the chip's right edge, regardless of chip label width: ┌──────────────┬────────────────────────────────────┐ │ [BROADER-THAN]│ LOINC:14635-7 25-hydroxyvitamin D3 │ │ │ COMPONENT: Calcidiol PROPERTY: ... │ └──────────────┴────────────────────────────────────┘ The chip is dropped from the primary span when useFlexLayout is on (the bridgeChild path checks `!useFlexLayout && candidate?.map_type` before rendering the inline chip). Non-bridge rows are unchanged — ListItemText renders directly without the flex wrapper. The overflowWrap/wordBreak rules from the prior commit remain. Tests: 86/86 pass, eslint clean, production webpack build green. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/components/map-projects/Concept.jsx | 50 ++++++++++++++++------ src/components/map-projects/MapProject.jsx | 21 +++++++-- 2 files changed, 54 insertions(+), 17 deletions(-) diff --git a/src/components/map-projects/Concept.jsx b/src/components/map-projects/Concept.jsx index 3feb5ec..5a0f176 100644 --- a/src/components/map-projects/Concept.jsx +++ b/src/components/map-projects/Concept.jsx @@ -86,8 +86,15 @@ const Item = ({candidate, conceptDefinition, conceptRow, bridgeConceptDefinition const convergenceTooltip = (bridgeContributors || []).length ? bridgeContributors.map(formatBridgeContributor).filter(Boolean).join('\n') : '' - return ( - <> + // Bridge_child cards: extract the [SAME-AS] map-type chip into its own + // flex column so primary + secondary text both align to the chip's right + // edge. Previously the chip lived inline inside the primary span, which + // meant secondary text (LOINC schema chips: COMPONENT/PROPERTY/...) flowed + // flush-left and rendered visually LEFT of the chip — the artifact + // @paynejd flagged. The flex layout fixes the alignment regardless of + // chip label width (SAME-AS vs BROADER-THAN vs longer map_types). + const useFlexLayout = bridgeChild && candidate?.map_type + const listItemText = ( @@ -107,7 +114,10 @@ const Item = ({candidate, conceptDefinition, conceptRow, bridgeConceptDefinition {!algoScoreFirst && bridgePrefixLabel && ( {bridgePrefixLabel} )} - {candidate?.map_type && ( + {/* When useFlexLayout, the chip is rendered as a sibling + flex column below (so primary + secondary align). In + the legacy non-flex path the chip stayed inline here. */} + {!useFlexLayout && candidate?.map_type && ( )} @@ -155,19 +165,16 @@ const Item = ({candidate, conceptDefinition, conceptRow, bridgeConceptDefinition } secondary={ - // For bridge_child rows the primary line starts with a leading - // [SAME-AS] chip (margin 0 6px). Without a matching left indent, - // the secondary text aligns flush left and renders *to the left* - // of the chip, producing the visual artifact reported in PR2b - // testing. The chip has 6px left margin + ~50px label width, so - // a 6px left padding under bridgeChild keeps the secondary text - // aligned with the chip's left edge. overflowWrap+wordBreak - // prevents LOINC's `^` and `/` separators (and stretches like - // "peptide.B" with embedded periods) from word-breaking even when - // there's whitespace to spare. + // overflowWrap/wordBreak prevent LOINC's `^` and `/` separators + // (and stretches like "peptide.B" with embedded periods) from + // word-breaking even when there's whitespace to spare. Bridge_child + // alignment is handled by the outer flex layout (useFlexLayout + // branch above) — the chip lives in its own column so primary + + // secondary both align to the right of it, no manual padding + // needed here.
    @@ -188,6 +195,21 @@ const Item = ({candidate, conceptDefinition, conceptRow, bridgeConceptDefinition } sx={{margin: '2px 0', '.MuiListItemText-primary': {fontSize: '14px'}, '.MuiListItemText-secondary': {fontSize: '12px', overflow: 'scroll'}}} /> + ) + return ( + <> + {useFlexLayout ? ( +
    + +
    + {listItemText} +
    +
    + ) : listItemText} { !noScore && diff --git a/src/components/map-projects/MapProject.jsx b/src/components/map-projects/MapProject.jsx index 4f1d412..9a7c8fd 100644 --- a/src/components/map-projects/MapProject.jsx +++ b/src/components/map-projects/MapProject.jsx @@ -2765,7 +2765,7 @@ const MapProject = () => { markAlgo(__row.__index, 'ocl-scispacy-loinc', -2) log({action: 'algo_failed', extras: {algo: 'ocl-scispacy-loinc', status: response?.status, detail: response?.detail}}, __row.__index) setAlert({ - message: response?.detail || `Scispacy service unavailable (HTTP ${response?.status || '???'}). The service can take 2–5 min to start up after sleep — click Refresh on this row to retry.`, + message: response?.detail || "OCL's scispacy matching service is starting up. This may take a couple minutes. You can safely leave this row and come back. Click Refresh if results aren't here in a couple of minutes.", severity: 'warning' }) setIsLoadingInDecisionView(false) @@ -2779,7 +2779,7 @@ const MapProject = () => { markAlgo(__row.__index, 'ocl-scispacy-loinc', -2) log({action: 'algo_failed', extras: {algo: 'ocl-scispacy-loinc', error: err?.message}}, __row.__index) setAlert({ - message: `Scispacy service unavailable. The service can take 2–5 min to start up after sleep — click Refresh on this row to retry.`, + message: "OCL's scispacy matching service is starting up. This may take a couple minutes. You can safely leave this row and come back. Click Refresh if results aren't here in a couple of minutes.", severity: 'warning' }) setIsLoadingInDecisionView(false) @@ -3812,12 +3812,27 @@ const MapProject = () => { return false } const promptTemplateRef = getPromptTemplateRef(activePromptTemplate) + // Strip heavy fields before sending to the LLM. These are useful for + // the in-app UI (Table view chips, hover details) but burn tokens + // without adding signal for matching judgments. Keep the things the + // legacy prompt template actually reads (id, display_name, source, + // search_meta, url, concept_class, datatype, retired, mappings, + // property), drop the bulky ones. + // extras - LOINC source-specific JSON; very large per concept + // names - multiple locales, redundant with display_name + // descriptions - multiple, often long; not used for matching + // The v2 `recommendable_concepts` already omits these (see + // buildV2RecommendationPayload). NB: if you see a literal "max_tokens" + // error from the model, that's the server-side prompt template's + // output budget — check ocl-ai-assistant's template config, not this + // file. + const stripHeavyFields = (c) => omit(c, ['_source', 'extras', 'names', 'descriptions']) const payload = { variables: { project: getProjectMetadata(), row: rowData.row, metadata: rowData.metadata, - candidates: [..._candidates.map(c => omit(c, '_source'))], + candidates: [..._candidates.map(stripHeavyFields)], ...(v2 ? { payload_version: 'v2', target_repo: v2.target_repo, From f0681c4394764d51c5673dc4ee7fecc256fe28cf Mon Sep 17 00:00:00 2001 From: Jonathan Payne Date: Tue, 12 May 2026 19:42:27 -0400 Subject: [PATCH 25/29] OpenConceptLab/ocl_issues#2337 | PR2b: revert bridge_child indent attempts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per @paynejd: "revert the bridge-child indent -- this is not what i asked for and does not work. keep the other two." Reverts Concept.jsx to its state at 22eaa6a (pre-attempt). Drops both attempts at fixing the bridge_child secondary alignment: - 48dc6b5 (round 5): paddingLeft: 6px hack on the secondary div. Wrong magnitude — the chip is wider than 6px and varies by label. - 0e5805e (round 6): flex layout extracting the chip into a sibling column. Tested green but the user reports it does not match their intent. The other two round-6 fixes — MapProject.jsx AI payload trim and the scispacy alert copy — are unchanged. Status: the bridge_child secondary alignment remains an open visual issue. Need clarification on the desired layout (where should the COMPONENT/PROPERTY/etc. line start relative to the [SAME-AS] chip and the LOINC code text?) before another attempt. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/components/map-projects/Concept.jsx | 43 +++---------------------- 1 file changed, 4 insertions(+), 39 deletions(-) diff --git a/src/components/map-projects/Concept.jsx b/src/components/map-projects/Concept.jsx index 5a0f176..09c4742 100644 --- a/src/components/map-projects/Concept.jsx +++ b/src/components/map-projects/Concept.jsx @@ -86,15 +86,8 @@ const Item = ({candidate, conceptDefinition, conceptRow, bridgeConceptDefinition const convergenceTooltip = (bridgeContributors || []).length ? bridgeContributors.map(formatBridgeContributor).filter(Boolean).join('\n') : '' - // Bridge_child cards: extract the [SAME-AS] map-type chip into its own - // flex column so primary + secondary text both align to the chip's right - // edge. Previously the chip lived inline inside the primary span, which - // meant secondary text (LOINC schema chips: COMPONENT/PROPERTY/...) flowed - // flush-left and rendered visually LEFT of the chip — the artifact - // @paynejd flagged. The flex layout fixes the alignment regardless of - // chip label width (SAME-AS vs BROADER-THAN vs longer map_types). - const useFlexLayout = bridgeChild && candidate?.map_type - const listItemText = ( + return ( + <> @@ -114,10 +107,7 @@ const Item = ({candidate, conceptDefinition, conceptRow, bridgeConceptDefinition {!algoScoreFirst && bridgePrefixLabel && ( {bridgePrefixLabel} )} - {/* When useFlexLayout, the chip is rendered as a sibling - flex column below (so primary + secondary align). In - the legacy non-flex path the chip stayed inline here. */} - {!useFlexLayout && candidate?.map_type && ( + {candidate?.map_type && ( )} @@ -165,17 +155,7 @@ const Item = ({candidate, conceptDefinition, conceptRow, bridgeConceptDefinition } secondary={ - // overflowWrap/wordBreak prevent LOINC's `^` and `/` separators - // (and stretches like "peptide.B" with embedded periods) from - // word-breaking even when there's whitespace to spare. Bridge_child - // alignment is handled by the outer flex layout (useFlexLayout - // branch above) — the chip lives in its own column so primary + - // secondary both align to the right of it, no manual padding - // needed here. -
    +
    @@ -195,21 +175,6 @@ const Item = ({candidate, conceptDefinition, conceptRow, bridgeConceptDefinition } sx={{margin: '2px 0', '.MuiListItemText-primary': {fontSize: '14px'}, '.MuiListItemText-secondary': {fontSize: '12px', overflow: 'scroll'}}} /> - ) - return ( - <> - {useFlexLayout ? ( -
    - -
    - {listItemText} -
    -
    - ) : listItemText} { !noScore && From aecbebc6ef7cd5dbcc9290e46d854a6265ae8ab4 Mon Sep 17 00:00:00 2001 From: Sunny Aggarwal Date: Wed, 13 May 2026 07:51:23 +0530 Subject: [PATCH 26/29] OpenConceptLab/ocl_issues#2337 | fixing score on top --- src/components/map-projects/MappingDecisionResult.jsx | 2 +- src/components/map-projects/viewBuilders.js | 7 ++++++- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/src/components/map-projects/MappingDecisionResult.jsx b/src/components/map-projects/MappingDecisionResult.jsx index f01a410..69f302f 100644 --- a/src/components/map-projects/MappingDecisionResult.jsx +++ b/src/components/map-projects/MappingDecisionResult.jsx @@ -170,7 +170,7 @@ const MappingDecisionResult = ({targetConcept, row, rowIndex, mapTypes, allMapTy
    - + { targetConcept?.search_meta?.algorithm && diff --git a/src/components/map-projects/viewBuilders.js b/src/components/map-projects/viewBuilders.js index 31e4721..bda9d16 100644 --- a/src/components/map-projects/viewBuilders.js +++ b/src/components/map-projects/viewBuilders.js @@ -227,7 +227,12 @@ export const conceptForMapping = (rowView) => { * Pure — caller maps qualityBucket -> bucketColor via SCORES_COLOR. */ export const getScoreDetails = (input = {}, candidatesScore = {}) => { - const {candidate, conceptRow, search_meta: searchMeta} = input || {} + 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) From 0993ffbfdfd53aaebc4cedf0cce73093c80d2265 Mon Sep 17 00:00:00 2001 From: Sunny Aggarwal Date: Wed, 13 May 2026 07:54:54 +0530 Subject: [PATCH 27/29] OpenConceptLab/ocl_issues#2337 | removed unused --- src/components/map-projects/Candidates.jsx | 3 +-- src/components/map-projects/MapProject.jsx | 1 - 2 files changed, 1 insertion(+), 3 deletions(-) diff --git a/src/components/map-projects/Candidates.jsx b/src/components/map-projects/Candidates.jsx index 6cbefb6..5bd4736 100644 --- a/src/components/map-projects/Candidates.jsx +++ b/src/components/map-projects/Candidates.jsx @@ -49,7 +49,6 @@ import AIAssistantButton from './AIAssistantButton' import { buildAlgorithmRowViews, buildQualityRowViews, - candidateToRowView, sortRowViews, conceptForMapping, resolveAICandidateID @@ -399,7 +398,7 @@ const Candidates = ({rowIndex, alert, setAlert, rowState, conceptCache, targetCa const hasAnyView = qualityRowViews.length > 0 const isNoneLoaded = !rowState || (isEmpty(rowState.candidates) && isEmpty(rowState.algorithm_responses)) const canFetchMore = hasAnyView - const algoStagesValue = values(rowStage || {}).filter((_, i, arr) => Object.keys(rowStage || {})[i] !== 'recommend') + 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); diff --git a/src/components/map-projects/MapProject.jsx b/src/components/map-projects/MapProject.jsx index 9a7c8fd..f6ae76a 100644 --- a/src/components/map-projects/MapProject.jsx +++ b/src/components/map-projects/MapProject.jsx @@ -63,7 +63,6 @@ import isArray from 'lodash/isArray' import isBoolean from 'lodash/isBoolean' import isNumber from 'lodash/isNumber' import times from 'lodash/times' -import some from 'lodash/some' import pick from 'lodash/pick' import { OperationsContext } from '../app/LayoutContext'; From 61ef1e97f88500a5ac2d4fddb64cc070930d000c Mon Sep 17 00:00:00 2001 From: Sunny Aggarwal Date: Wed, 13 May 2026 07:57:49 +0530 Subject: [PATCH 28/29] OpenConceptLab/ocl_issues#2337 | removed hardcoded fallback text --- src/components/map-projects/ConfigurationForm.jsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/map-projects/ConfigurationForm.jsx b/src/components/map-projects/ConfigurationForm.jsx index 96aae12..76f1b74 100644 --- a/src/components/map-projects/ConfigurationForm.jsx +++ b/src/components/map-projects/ConfigurationForm.jsx @@ -315,7 +315,7 @@ const ConfigurationForm = ({ project, handleFileUpload, file, owner, setOwner, n } sx={{padding: 0, minHeight: 'auto', '.MuiAccordionSummary-content': {margin: '4px 0'}}}> - {t('map_project.advanced_settings', 'Advanced settings')} + {t('map_project.advanced_settings')} From 5f31a96754845f45448214e4d357d25f455c7f19 Mon Sep 17 00:00:00 2001 From: Sunny Aggarwal Date: Wed, 13 May 2026 08:06:30 +0530 Subject: [PATCH 29/29] OpenConceptLab/ocl_issues#2337 | fixing lookup config and advanced settings overlay | extracted advanced settings component --- .../map-projects/AdvancedSettings.jsx | 40 +++++++++++++++++++ .../map-projects/ConfigurationForm.jsx | 30 +------------- 2 files changed, 42 insertions(+), 28 deletions(-) create mode 100644 src/components/map-projects/AdvancedSettings.jsx 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/ConfigurationForm.jsx b/src/components/map-projects/ConfigurationForm.jsx index 76f1b74..bb161b4 100644 --- a/src/components/map-projects/ConfigurationForm.jsx +++ b/src/components/map-projects/ConfigurationForm.jsx @@ -18,13 +18,9 @@ import SettingsIcon from '@mui/icons-material/Settings'; import SaveIcon from '@mui/icons-material/Save'; import OpenInNewIcon from '@mui/icons-material/OpenInNew'; -import Accordion from '@mui/material/Accordion' -import AccordionSummary from '@mui/material/AccordionSummary' -import AccordionDetails from '@mui/material/AccordionDetails' import Stack from '@mui/material/Stack' import Alert from '@mui/material/Alert' import Tooltip from '@mui/material/Tooltip' -import ExpandMoreIcon from '@mui/icons-material/ExpandMore' import InfoOutlinedIcon from '@mui/icons-material/InfoOutlined' import isEmpty from 'lodash/isEmpty' @@ -43,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' @@ -309,33 +306,10 @@ const ConfigurationForm = ({ project, handleFileUpload, file, owner, setOwner, n } - { setNamespace && - - } sx={{padding: 0, minHeight: 'auto', '.MuiAccordionSummary-content': {margin: '4px 0'}}}> - - {t('map_project.advanced_settings')} - - - - 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.' - })} - /> - - + } - { inAIAssistantGroup && isCoreUser && promptTemplates?.length > 0 && <>