-
+ {
+ setNamespace &&
+
+ }
{
inAIAssistantGroup && isCoreUser && promptTemplates?.length > 0 &&
<>
@@ -295,6 +393,29 @@ const ConfigurationForm = ({ project, handleFileUpload, file, owner, setOwner, n
/>
>
}
+ {
+ hasConfigErrors &&
+
+
+ {t('map_project.config_errors_title', 'Project configuration is incomplete')}
+
+
+ {
+ configErrors.map(({algo, reason}) => (
+
+ {reason === 'missing_canonical_url'
+ ? t('map_project.config_error_missing_canonical', {
+ name: algo.name || algo.id,
+ defaultValue: `Custom algorithm "${algo.name || algo.id}" is missing a valid canonical URL.`
+ })
+ : reason
+ }
+
+ ))
+ }
+
+
+ }
}
onClick={onSave}
- disabled={!name || !file?.name || !owner}
+ disabled={!name || !file?.name || !owner || hasConfigErrors}
loading={isSaving}
loadingPosition="start"
>
diff --git a/src/components/map-projects/MapProject.jsx b/src/components/map-projects/MapProject.jsx
index e40dadb..97c4a5f 100644
--- a/src/components/map-projects/MapProject.jsx
+++ b/src/components/map-projects/MapProject.jsx
@@ -63,13 +63,12 @@ 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';
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'
@@ -96,24 +95,29 @@ import ScoreBucketButton from './ScoreBucketButton'
import Concept from './Concept'
import ImportToCollection from './ImportToCollection'
import ProjectLogs from './ProjectLogs';
-import { useAlgos, CONCEPT_IDENTITY_BY_TYPE } from './algorithms'
+import { useAlgos, CONCEPT_IDENTITY_BY_TYPE, ensureConceptIdentity } 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 +163,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 +247,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)
@@ -260,9 +273,11 @@ const MapProject = () => {
* Concept definitions are merged into conceptCache (project-wide). Concept
* rows are merged per-row, preferring existing rerank_score over undefined.
*/
- const mergeIntoRowMatchState = React.useCallback((rowIndex, normalized) => {
+ const mergeIntoRowMatchState = React.useCallback((rowIndex, normalized, options = {}) => {
if(!normalized) return
+ const { append = false } = options
const { algorithm_response, candidates, concept_definitions, concept_rows } = normalized
+ const incomingAlgoId = algorithm_response?.algorithm_id
const prevAll = rowMatchStateRef.current
const prevRow = prevAll[rowIndex] || {
@@ -271,22 +286,49 @@ const MapProject = () => {
concept_rows: {},
}
+ // For fresh invocations (offset===0), drop existing candidates from
+ // THIS algorithm before merging incoming ones — a re-run replaces the
+ // previous results for the same (rowIndex, algorithmId). The legacy
+ // onResponse path does the same via `reject(...)` on allCandidates.
+ // Without this guard, repeated invocations stack candidates with
+ // fresh UUIDs but identical concept_keys (same concept appears N
+ // times in algorithm view).
+ //
+ // For pagination append (offset>0), pass append=true to preserve the
+ // earlier page's candidates and just stack the new ones on top.
+ const survivingCandidates = {}
+ Object.entries(prevRow.candidates || {}).forEach(([id, c]) => {
+ if(append || c?.algorithm_id !== incomingAlgoId) survivingCandidates[id] = c
+ })
+
const nextRow = {
algorithm_responses: {
...prevRow.algorithm_responses,
[algorithm_response.id]: algorithm_response,
},
candidates: {
- ...prevRow.candidates,
+ ...survivingCandidates,
...candidates.reduce((acc, c) => { acc[c.id] = c; return acc }, {}),
},
concept_rows: { ...prevRow.concept_rows },
}
+ // Prune concept_rows whose concept_key is no longer referenced by any
+ // surviving candidate (only meaningful for the replace path; append
+ // keeps everything). Without this, stale concept_rows from the prior
+ // run keep appearing in quality view even though their candidates
+ // were dropped.
+ if(!append) {
+ const referencedKeys = new Set(Object.values(nextRow.candidates).map(c => c?.concept_key))
+ Object.keys(nextRow.concept_rows).forEach(k => {
+ if(!referencedKeys.has(k)) delete nextRow.concept_rows[k]
+ })
+ }
+
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 +336,71 @@ 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)
+ }
+
+ // Auto-trigger ensureLoaded for any newly-arrived ConceptDefinitions
+ // that aren't yet 'full' (bridge cascade targets land as 'pending';
+ // sparse algorithm results land as 'partial'). The plan calls $lookup
+ // "state-driven on ConceptDefinition" — this is the trigger point.
+ // ensureLoaded is idempotent + in-flight-deduped so an extra call is
+ // free. Forward-ref pattern because ensureLoaded is declared later.
+ if(ensureLoadedRef.current) {
+ const keysToLoad = concept_definitions
+ .filter(d => d && d.lookup_status !== 'full')
+ .map(d => d.key)
+ if(keysToLoad.length) ensureLoadedRef.current(keysToLoad)
+ }
+
+ // 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)
+ // Same forward-ref pattern for ensureLoaded — mergeIntoRowMatchState
+ // calls it to auto-fill 'pending'/'partial' ConceptDefinitions as they
+ // arrive, but ensureLoaded is defined later in the component.
+ const ensureLoadedRef = 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 +425,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 +433,28 @@ 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.
- if(bridgeAlgo?.target_repo_url) {
+ // 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 target_repo_url is missing entirely —
+ // the algo was added without ever editing the bridge source URL field —
+ // fall back to the type's well-known default (matches the placeholder
+ // shown in MultiAlgoSelector). Without this fallback, normalization
+ // silently produces zero candidates for bridge-only flows.
+ const BRIDGE_DEFAULT_RELATIVE_URL = { 'ocl-ciel-bridge': '/orgs/CIEL/sources/CIEL/' }
+ const bridgeRelativeUrl = bridgeAlgo?.target_repo_url
+ || BRIDGE_DEFAULT_RELATIVE_URL[bridgeAlgo?.type]
+ if(bridgeAlgo && bridgeRelativeUrl) {
+ 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'
+ relative_url: bridgeRelativeUrl,
+ canonical_url: explicitBridgeCanonical || `https://ns.openconceptlab.org${bridgeRelativeUrl}`,
+ 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 +549,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 +664,85 @@ 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) {
+ // Enrich loadedAlgos with concept_identity (built-ins, API-loaded
+ // bridge/scispacy, custom). The normalizer reads algo.concept_identity
+ // directly; algos that can't be enriched fall through unchanged and
+ // the normalizer skips them.
+ const enrichedAlgos = loadedAlgos.map(a => ensureConceptIdentity(a) || a)
+ const { rowMatchState: loadedRowMatchState, conceptDefinitionsByKey: loadedDefsByKey } =
+ normalizeLegacyAllCandidates(_allCandidates, loadProjectContext, enrichedAlgos, 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
+ // No target canonical means the saved project is missing the
+ // load-bearing target_repo configuration. Under UNIFIED_MODEL_ENABLED
+ // the read path needs this canonical to resolve candidates, so the
+ // candidate list will be empty until the user fixes it. Block-and-
+ // banner: surface an error alert and pop the configuration drawer
+ // open. Only warn when there's actually saved match data — a
+ // brand-new project loading with empty allCandidates is normal.
+ if(!isEmpty(_allCandidates)) {
+ setAlert({
+ message: t(
+ 'map_project.target_repo_required_on_load',
+ 'This project is missing a target repository. Configure the target repository to see saved candidates.'
+ ),
+ severity: 'error',
+ duration: 10
+ })
+ setConfigure(true)
+ }
+ }
+
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 || {})
@@ -1014,9 +1188,27 @@ 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))
+ // 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()))
@@ -1148,23 +1340,52 @@ 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.
+ // The target canonical comes from buildProjectContext — the SAME source
+ // the normalizer used to stamp ConceptDefinition.reference.url, so the
+ // filter is guaranteed to agree with the data. Reading canonical from
+ // the caller's `_repo` would risk the derived-vs-explicit mismatch the
+ // unified-model spec exists to avoid.
+ // (plans/unified-mapper-model.md — score-grouped view's bucketing rule.)
+ const pickTopRowView = (rowIndex) => {
+ const rowState = rowMatchStateRef.current[rowIndex]
+ if(!rowState) return null
+ const targetCanonical = buildProjectContext()?.target_repo?.canonical_url
+ if(!targetCanonical) return null
+ const views = buildQualityRowViews(rowState, conceptCacheRef.current)
+ // 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)
+ 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 +1393,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)
+ 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 +1495,34 @@ 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 = ensureConceptIdentity(algo)
+ 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,
+ // 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
+ }))
+ })
+ }
+ }
+ }
if(!isMultiAlgo)
setStateViews(data, _repo)
if(!data || !data.length) {
@@ -1417,6 +1647,52 @@ const MapProject = () => {
allCandidatesRef.current = allCandidates;
}, [allCandidates]);
+ React.useEffect(() => {
+ 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())
@@ -1563,6 +1839,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 = {}
@@ -1924,8 +2227,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 = () => {
@@ -1945,8 +2250,11 @@ const MapProject = () => {
'__row_map_status__': rowStateLabel,
'__row_decision__': decisions[index] || 'None',
...rowAlgoStatuses,
- '__map_repo_url__': _repo?.version_url || repoVersion?.version_url || _repo?.url || repo?.version_url || repo?.url,
- '__map_repo_id__': _repo?.short_code || _repo?.id,
+ // __map_* columns describe the mapped concept; leave blank when
+ // the row isn't mapped (concept is undefined) so the namespace is
+ // internally consistent with __map_concept_id__ / __map_concept_name__.
+ '__map_repo_url__': concept ? (_repo?.version_url || _repo?.url) : null,
+ '__map_repo_id__': concept ? (_repo?.short_code || _repo?.id) : null,
'__map_concept_id__': concept?.id,
'__map_concept_name__': concept?.display_name,
'__map_concept_url__': concept?.url,
@@ -1958,7 +2266,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,
@@ -2059,9 +2367,12 @@ const MapProject = () => {
const _onMap = (concept, unmap=false, mapType='SAME-AS') => {
setMapSelected(prev => ({...prev, [rowIndex]: unmap ? null : {...concept, repo: {...repo, version: repoVersion?.id || repo.version, version_url: repoVersion?.version_url || repo.version_url}}}))
setDecisions(prev => ({...prev, [rowIndex]: unmap ? null : 'map'}))
- if(concept?.id)
- log({action: unmap ? 'unmapped' : 'mapped', extras: {object_url: concept?.url, id: concept?.id, map_type: mapType, name: getConceptLabel(concept), algorithm: concept?.search_meta?.algorithm}})
-
+ // Always log (don't gate on concept?.url) — bridge cascade targets may
+ // arrive without an ocl_url until $resolveReference resolves them,
+ // and dropping the log silently hides the mapping action from the
+ // project history. Fall back to concept.id when url is absent.
+ if(concept?.url || concept?.id)
+ log({action: unmap ? 'unmapped' : 'mapped', extras: {object_url: concept?.url || null, object_id: concept?.id || null, map_type: mapType, name: getConceptLabel(concept), algorithm: concept?.search_meta?.algorithm}})
}
const onReviewDone = (next = false) => {
@@ -2099,9 +2410,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)
@@ -2181,12 +2507,13 @@ const MapProject = () => {
const getAlgoDef = algoId => {
const algo = find(algosSelected, {id: algoId})
if(!algo) return algo
- // Inject concept_identity for known algo types when missing. Algorithms
- // sourced from the OCL Online API (bridge variants) don't carry it, so
- // we merge from the canonical map (plans/unified-mapper-model.md).
- if(!algo.concept_identity && CONCEPT_IDENTITY_BY_TYPE[algo.type])
- return { ...algo, concept_identity: CONCEPT_IDENTITY_BY_TYPE[algo.type] }
- return algo
+ // Inject concept_identity for known algo types when missing (algorithms
+ // sourced from the OCL Online API don't carry it) and for custom algos
+ // (derived from user-entered canonical_url). When ensureConceptIdentity
+ // can't produce one, return the raw algo so callers that don't need
+ // concept_identity (e.g. fetching) still work; the normalizer path
+ // performs its own null-guard.
+ return ensureConceptIdentity(algo) || algo
}
const getNextAlgoDef = (algoId) => {
const algoDef = getAlgoDef(algoId);
@@ -2246,10 +2573,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)
@@ -2259,12 +2594,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
}
@@ -2307,17 +2641,37 @@ 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'
}))
}
}
} else {
+ const appendedResults = get(data, '0.results') || []
const newMatches = [...(allCandidatesRef.current[algoId] || [])]
const index = findIndex(newMatches, match => match.row.__index === __row.__index)
- newMatches[index].results = [...newMatches[index].results, ...(get(data, '0.results') || [])]
- lookupCandidates(algoId, get(data, '0.results'))
+ newMatches[index].results = [...newMatches[index].results, ...appendedResults]
+ lookupCandidates(algoId, appendedResults)
nextCandidates = {...allCandidatesRef.current, [algoId]: newMatches}
- // TODO(unified-model): pagination append path. PR 2 work.
+ // Pagination append: feed just the new page's results into the
+ // unified state with append=true so existing candidates from
+ // previous pages stay put. Without this, Fetch More fires the
+ // request but the unified read path (Candidates.jsx) never sees
+ // the new results.
+ if(UNIFIED_MODEL_ENABLED) {
+ const appendPayload = {row: __row, results: appendedResults}
+ mergeIntoRowMatchState(__row.__index, normalizeAlgorithmInvocation(appendPayload, {
+ algorithmId: algoId,
+ algorithmConfig: algoDef,
+ projectContext,
+ rowIndex: __row.__index,
+ rawResponse: response,
+ trustServerRerank: !isMultiAlgo && algoDef.provider === 'ocl'
+ }), {append: true})
+ }
}
allCandidatesRef.current = nextCandidates
setAllCandidates(nextCandidates)
@@ -2334,12 +2688,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(() => {
@@ -2365,9 +2721,16 @@ const MapProject = () => {
const fetchScispacyCandidates = async (_row, scrollToBottom, forceReload=false, isBulk=false, callback) => {
let __row = isEmpty(_row) ? row : _row
- const existingCandidates = find(allCandidatesRef.current['ocl-scispacy-loinc'], c => c.row.__index === __row.__index)?.results
- if(!isBulk && !forceReload && existingCandidates?.length> 0) {
- setTimeout(() => highlightTexts(existingCandidates, null, false), 100)
+ // Gate on entry presence, not results.length: a successful invocation
+ // that returned zero matches still writes {row, results:[]} into
+ // allCandidates, and we shouldn't re-run on every tab visit just because
+ // the array is empty. Failures don't write an entry (the catch below
+ // markAlgo(-2)s without persisting), so undefined-entry correctly retries.
+ const existingEntry = find(allCandidatesRef.current['ocl-scispacy-loinc'], c => c.row.__index === __row.__index)
+ if(!isBulk && !forceReload && existingEntry !== undefined) {
+ const existingCandidates = existingEntry.results
+ if(existingCandidates?.length > 0)
+ setTimeout(() => highlightTexts(existingCandidates, null, false), 100)
return { skipped: true }
}
if(!scispacyEnabled)
@@ -2381,74 +2744,259 @@ 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 || "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)
+ 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: "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)
+ })
+ }
+
+ // 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.
+ //
+ // 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 []
+ const seen = new Set()
+ const rows = []
+ 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 —
+ // typically bridge cascade targets still in 'pending' status before
+ // ensureLoaded fills them. The reranker scores name-less entries as
+ // -100000 sentinel, which renders as garbage in the candidate list.
+ // scheduleRerank will re-fire after ensureLoaded completes (any
+ // ConceptRow with rerank_score===undefined keeps the row eligible).
+ const hasName = (typeof def.display_name === 'string' && def.display_name.trim().length > 0)
+ || (Array.isArray(def.names) && def.names.some(n => n?.name))
+ if(!hasName) 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 by the
+ // canonical concept_key passthrough that buildRerankRowsForRow sent up.
+ // Throws on miss — fuzzy fallbacks (ocl_url, id+source) are deliberately
+ // gone because the unified-model spec relies on canonical identity. A
+ // miss here means either (a) the server stripped concept_key from the
+ // response, or (b) project config is incomplete so the cache never saw
+ // the key. Both are operator-visible bugs, not silent-skip cases.
+ const matchRerankResultToKey = (result) => {
+ if(!result?.concept_key)
+ throw new Error('rerank response missing concept_key passthrough')
+ if(!conceptCacheRef.current[result.concept_key])
+ throw new Error(`rerank result references unknown concept_key: ${result.concept_key}`)
+ return result.concept_key
}
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) {
+ // 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/')
+ try {
+ const response = await service.post({
+ q: query,
+ rows: rerankRows,
+ ...(encoderModel ? { encoder_model: encoderModel } : {})
+ })
+
+ // Write rerank_score into the row's ConceptRows. matchRerankResultToKey
+ // throws on canonical-identity miss; surface to the alert state so a
+ // misconfigured project / server doesn't fail silently.
+ const resultsByKey = new Map()
+ const matchErrors = []
+ forEach(response?.data || [], result => {
+ let key
+ try {
+ key = matchRerankResultToKey(result)
+ } catch (matchErr) {
+ matchErrors.push(matchErr.message)
+ 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) })
+ })
+ if(matchErrors.length) {
+ const summary = `Rerank: ${matchErrors.length} of ${response?.data?.length || 0} results could not be matched to a candidate. Check project configuration (canonical URLs) and server response.`
+ log({action: 'rerank_match_failure', description: summary, extras: {samples: matchErrors.slice(0, 3)}}, index)
+ setAlert({message: summary, severity: 'error', duration: 8})
+ }
+ 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 that is BOTH
+ // rerank-eligible (its ConceptDefinition has a usable display_name —
+ // see buildRerankRowsForRow's filter) AND unscored drives a rerank.
+ // Gating on display_name avoids an infinite loop where rows that get
+ // dropped from the rerank payload (pending bridge cascade targets)
+ // never receive a score and keep re-triggering this scheduler. Once
+ // ensureLoaded fills the name, writeConceptCachePatch re-fires
+ // scheduleRerank for affected rows.
+ const RERANK_DEBOUNCE_MS = 300
+ const conceptDefHasUsableName = (def) =>
+ Boolean(
+ (typeof def?.display_name === 'string' && def.display_name.trim().length > 0)
+ || (Array.isArray(def?.names) && def.names.some(n => n?.name))
+ )
+ const scheduleRerank = (rowIndex) => {
+ if(!isNumber(rowIndex)) return
+ const rowState = rowMatchStateRef.current[rowIndex]
+ if(!rowState) return
+ const hasEligiblePending = Object.values(rowState.concept_rows || {}).some(cr => {
+ if(isNumber(cr.rerank_score)) return false
+ const def = conceptCacheRef.current[cr.concept_key]
+ return conceptDefHasUsableName(def)
+ })
+ if(!hasEligiblePending) 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,19 +3022,20 @@ 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) => {
+ // Don't synthesize search_normalized_score from composite_score * 100.
+ // The normalizer reads search_normalized_score straight into
+ // ConceptRow.rerank_score (normalizers.js:178), so the synthesized
+ // value masqueraded as a real rerank score — unified chips for
+ // scispacy candidates were just (raw * 100) until the debounced
+ // $rerank/ pass would have overwritten them (and didn't, since the
+ // field was already populated). Leave normalized_score off; the
+ // rerank pipeline fills rerank_score on these rows just like the
+ // other algos.
if(result?.LOINC_NUM)
- formatted.push({id: result.LOINC_NUM, display_name: result.LONG_COMMON_NAME, search_meta: {search_normalized_score: result.composite_score * 100, search_score: result.composite_score, algorithm: 'ocl-scispacy-loinc'}, extras: result, source: 'LOINC'})
+ formatted.push({id: result.LOINC_NUM, display_name: result.LONG_COMMON_NAME, search_meta: {search_score: result.composite_score, algorithm: 'ocl-scispacy-loinc'}, extras: result, source: 'LOINC'})
})
return formatted
}
@@ -2532,21 +3081,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 +3110,182 @@ 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 prev = conceptCacheRef.current[key]
+ const next = { ...conceptCacheRef.current, [key]: def }
+ conceptCacheRef.current = next
+ setConceptCache(next)
+ // If this patch transitioned the concept's lookup_status to 'full' (or
+ // any state where display_name is now usable when previously it wasn't),
+ // re-fire scheduleRerank for every row that references this key. Those
+ // rows became rerank-eligible at this moment and need a score.
+ const wasUsable = (typeof prev?.display_name === 'string' && prev.display_name.trim().length > 0)
+ || (Array.isArray(prev?.names) && prev.names.some(n => n?.name))
+ const nowUsable = (typeof def?.display_name === 'string' && def.display_name.trim().length > 0)
+ || (Array.isArray(def?.names) && def.names.some(n => n?.name))
+ if(!wasUsable && nowUsable && scheduleRerankRef.current) {
+ Object.entries(rowMatchStateRef.current).forEach(([idx, rowState]) => {
+ if(rowState?.concept_rows?.[key]) scheduleRerankRef.current(Number(idx))
})
}
+ }, [])
+ 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
+ // 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 = []
+ 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(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] || {}
+ 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, 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) => {
+ 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, lookupConfig?.token])
+
+ // Wire the forward-ref consumed by mergeIntoRowMatchState. useEffect
+ // so the ref always points at the latest closure (ensureLoaded captures
+ // buildProjectContext which can change). Effect is cheap — just a ref
+ // pointer update.
+ React.useEffect(() => {
+ ensureLoadedRef.current = ensureLoaded
+ }, [ensureLoaded])
+
+ // 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 algoConfig = ensureConceptIdentity(getAlgoDef(algoId))
+ 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 = () => {
@@ -2923,29 +3623,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 = []
@@ -3021,7 +3742,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'})
@@ -3042,6 +3782,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')
@@ -3051,12 +3810,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,
@@ -3163,6 +3937,8 @@ const MapProject = () => {
AIModels={AIModels}
AIModel={AIModel}
setAIModel={setAIModel}
+ namespace={namespace}
+ setNamespace={setNamespace}
/>
)
@@ -3650,7 +4426,9 @@ const MapProject = () => {
rowStage={rowStageRef.current[rowIndex]}
alert={alert}
setAlert={setAlert}
- candidates={allCandidatesRef.current}
+ rowState={rowMatchStateRef.current[rowIndex]}
+ conceptCache={conceptCache}
+ targetCanonical={buildProjectContext()?.target_repo?.canonical_url}
setShowItem={setShowItem}
showItem={showItem}
setShowHighlights={setShowHighlights}
@@ -3680,7 +4458,6 @@ const MapProject = () => {
onRefreshClick={onRefreshClick}
inAIAssistantGroup={inAIAssistantGroup}
algosSelected={algosSelected}
- conceptCache={conceptCache}
isCoreUser={isCoreUser}
/>
}
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/MultiAlgoSelector.jsx b/src/components/map-projects/MultiAlgoSelector.jsx
index fb9a8cc..f46e94b 100644
--- a/src/components/map-projects/MultiAlgoSelector.jsx
+++ b/src/components/map-projects/MultiAlgoSelector.jsx
@@ -17,8 +17,7 @@ import {
ListItemText,
ListItemIcon,
ListItemButton,
- FormControlLabel,
- Checkbox
+ Chip
} from "@mui/material";
import DeleteOutlineRoundedIcon from "@mui/icons-material/DeleteOutlineRounded";
import ExpandMoreRoundedIcon from "@mui/icons-material/ExpandMoreRounded";
@@ -34,8 +33,22 @@ import orderBy from 'lodash/orderBy'
import ConceptIcon from '../concepts/ConceptIcon'
+import { isLikelyCanonicalUrl } from './algorithms'
import APIService from '../../services/APIService';
+// Cached lookup of a bridge source repo's canonical_url. Canonical URLs are
+// stable, so a single fetch per relative URL is sufficient for the session.
+const bridgeCanonicalCache = new Map()
+const fetchBridgeCanonical = (url) => {
+ if(!url) return Promise.resolve(null)
+ if(bridgeCanonicalCache.has(url)) return bridgeCanonicalCache.get(url)
+ const promise = APIService.new().overrideURL(url).get()
+ .then(r => r?.data?.canonical_url || null)
+ .catch(() => null)
+ bridgeCanonicalCache.set(url, promise)
+ return promise
+}
+
/**
* MultiAlgoSelector (MUI5)
*
@@ -152,6 +165,30 @@ export default function MultiAlgoSelector({
}
};
+ // For each selected bridge algo, fetch the source repo's canonical_url from
+ // the OCL API and populate `bridge_repo.canonical_url`. Without this, the
+ // downstream derivation falls back to https://ns.openconceptlab.org{relurl}
+ // even when the repo has a real canonical (e.g. CIEL -> CIELterminology.org).
+ // User-typed values are preserved: the sync only runs once per (key, url),
+ // so editing the canonical field afterwards is sticky until the source URL
+ // changes.
+ const syncedBridgeUrlRef = React.useRef(new Map())
+ React.useEffect(() => {
+ for (const sel of value || []) {
+ if(!sel?.type?.includes('bridge')) continue
+ const url = sel.target_repo_url
+ if(!url) continue
+ if(syncedBridgeUrlRef.current.get(sel.__key) === url) continue
+ syncedBridgeUrlRef.current.set(sel.__key, url)
+ fetchBridgeCanonical(url).then(canonical => {
+ if(!canonical) return
+ updateSelected(sel.__key, {
+ bridge_repo: { canonical_url: canonical, canonical_url_source: 'repo' }
+ })
+ })
+ }
+ }, [value])
+
const removeSelected = (key) => {
const next = (value || []).filter((v) => v.__key !== key);
onChange(next);
@@ -196,7 +233,6 @@ export default function MultiAlgoSelector({
name: name,
batch_size: algo.batch_size ?? 10,
concurrent_requests: algo.concurrent_requests ?? 1,
- lookup_required: algo.lookup_required,
__key: Math.random(100).toString()
};
@@ -466,6 +502,22 @@ export default function MultiAlgoSelector({
updateSelected(sel.__key, { description: e.target.value || '' })
}
/>
+ updateSelected(sel.__key, { canonical_url: e.target.value || '' })}
+ placeholder="http://loinc.org"
+ helperText={
+ !sel.canonical_url
+ ? t('map_project.algo_canonical_url_required', 'Canonical URL is required for custom algorithms (e.g. http://loinc.org).')
+ : !isLikelyCanonicalUrl(sel.canonical_url)
+ ? t('map_project.algo_canonical_url_invalid', 'Enter a full URL starting with http:// or https://')
+ : t('map_project.algo_canonical_url_description', 'Canonical URL of the code system this algorithm matches against (e.g. http://loinc.org).')
+ }
+ error={!isLikelyCanonicalUrl(sel.canonical_url)}
+ />
- } label={t('map_project.lookup_required')} onChange={e => updateSelected(sel.__key, {lookup_required: e.target.checked})} />
@@ -507,14 +558,33 @@ export default function MultiAlgoSelector({
) : algo.type?.includes('bridge') ? (
{isCoreUser && (
- updateSelected(sel.__key, { target_repo_url: e.target.value })}
- placeholder="/orgs/CIEL/sources/CIEL/"
- helperText={t('map_project.bridge_source_url_description') || 'The interface terminology to search through for bridge matching'}
- />
+ <>
+ updateSelected(sel.__key, { target_repo_url: e.target.value })}
+ placeholder="/orgs/CIEL/sources/CIEL/"
+ helperText={t('map_project.bridge_source_url_description', 'The interface terminology to search through for bridge matching')}
+ />
+ updateSelected(sel.__key, {
+ bridge_repo: {
+ ...(sel.bridge_repo || {}),
+ canonical_url: e.target.value || '',
+ canonical_url_source: e.target.value ? 'repo' : 'derived'
+ }
+ })}
+ placeholder="https://CIELterminology.org"
+ helperText={t('map_project.bridge_canonical_url_description', 'Canonical URL of the bridge code system (leave blank to derive from the relative URL).')}
+ />
+ {!sel.bridge_repo?.canonical_url && (
+
+ )}
+ >
)}
- } label={t('map_project.lookup_required')} onChange={e => updateSelected(sel.__key, {lookup_required: e.target.checked})} />
)}
@@ -640,3 +709,4 @@ function clampInt(value, min, max) {
function eHasValue(value) {
return Boolean(value && String(value).trim());
}
+
diff --git a/src/components/map-projects/Score.jsx b/src/components/map-projects/Score.jsx
index 44df55f..167def5 100644
--- a/src/components/map-projects/Score.jsx
+++ b/src/components/map-projects/Score.jsx
@@ -9,41 +9,17 @@ import Tooltip from '@mui/material/Tooltip'
import Chip from '@mui/material/Chip'
import Box from '@mui/material/Box'
import AssistantIcon from '@mui/icons-material/Assistant';
-import isNumber from 'lodash/isNumber'
-import isNaN from 'lodash/isNaN'
import ConceptIcon from '../concepts/ConceptIcon'
+import { getScoreDetails as pureGetScoreDetails } from './viewBuilders.js'
-export const getScoreDetails = (concept, candidatesScore) => {
- let percentile = concept?.search_meta?.search_normalized_score || ((concept?.search_meta?.search_rerank_score || concept?.search_meta?.search_score) * 100)
- if(percentile && !isNumber(percentile))
- percentile = parseFloat(percentile)
-
- const score = concept?.search_meta?.search_score
- const hasPercentile = isNumber(percentile)
- const recommendedScore = candidatesScore?.recommended
- const availableScore = candidatesScore?.available
-
- let qualityBucket;
- if(hasPercentile) {
- if (percentile >= recommendedScore)
- qualityBucket = 'recommended'
- else if (percentile >= availableScore)
- qualityBucket = 'available'
- else
- qualityBucket = 'low_ranked'
- }
-
- return {
- score,
- percentile,
- hasPercentile,
- qualityBucket,
- bucketColor: qualityBucket ? SCORES_COLOR[qualityBucket] : false,
- rerankScore: `${parseFloat(hasPercentile ? percentile : score).toFixed(2)}%`,
- algoScore: `${parseFloat(score).toFixed(2)}`
- }
+// Wrap the pure getScoreDetails (from viewBuilders.js) with the
+// SCORES_COLOR mapping. The pure function is testable without React/JSX
+// imports; this wrapper layers on the UI color affordance.
+export const getScoreDetails = (input, candidatesScore) => {
+ const details = pureGetScoreDetails(input, candidatesScore)
+ return { ...details, bucketColor: details.qualityBucket ? SCORES_COLOR[details.qualityBucket] : false }
}
export const ScoreValueChip = ({ bucketColor, label, size='medium', showIndicator=true, sx }) => (
@@ -66,7 +42,7 @@ export const ScoreValueChip = ({ bucketColor, label, size='medium', showIndicato
/>
)
-const Score = ({concept, setShowHighlights, sx, isAIRecommended, candidatesScore, algoScoreFirst, size}) => {
+const Score = ({candidate, conceptRow, setShowHighlights, sx, isAIRecommended, candidatesScore, algoScoreFirst, size, onHighlightClick}) => {
const { t } = useTranslation();
const {
score,
@@ -74,18 +50,23 @@ const Score = ({concept, setShowHighlights, sx, isAIRecommended, candidatesScore
bucketColor,
rerankScore,
algoScore
- } = getScoreDetails(concept, candidatesScore)
+ } = getScoreDetails({candidate, conceptRow}, candidatesScore)
+ const hasRawScore = algoScore !== '' && score !== null
return (
{
event.preventDefault()
event.stopPropagation()
- setShowHighlights(concept)
+ // Caller (Concept.jsx) decides what payload to surface to the
+ // highlights dialog; pass through onHighlightClick if provided,
+ // otherwise default to no-op.
+ if(onHighlightClick) onHighlightClick(event)
+ else setShowHighlights({candidate, conceptRow})
return false
} : undefined}
>
@@ -105,11 +86,17 @@ const Score = ({concept, setShowHighlights, sx, isAIRecommended, candidatesScore
bucketColor={bucketColor}
label={
- {algoScoreFirst ? algoScore : rerankScore}
+
+ {
+ algoScoreFirst && hasRawScore
+ ? algoScore
+ : (rerankScore || — )
+ }
+
{
- hasPercentile && !isNaN(score) && score ?
+ hasPercentile && hasRawScore ?
- {`(${algoScoreFirst ? rerankScore : algoScore})`}
+ {`(${algoScoreFirst && hasRawScore ? rerankScore : algoScore})`}
:
''
}
diff --git a/src/components/map-projects/__tests__/normalizeLegacyAllCandidates.test.js b/src/components/map-projects/__tests__/normalizeLegacyAllCandidates.test.js
new file mode 100644
index 0000000..4ac1a02
--- /dev/null
+++ b/src/components/map-projects/__tests__/normalizeLegacyAllCandidates.test.js
@@ -0,0 +1,251 @@
+/**
+ * Tests for the legacy-shape -> unified-model backfill used at project
+ * load time. Critical for the flag flip — without correct backfill,
+ * reloaded v1 projects render zero candidates under
+ * UNIFIED_MODEL_ENABLED=true.
+ *
+ * Run with: npm test
+ */
+
+import test from 'node:test'
+import assert from 'node:assert/strict'
+
+import { normalizeLegacyAllCandidates } from '../normalizers.js'
+
+const CONCEPT_IDENTITY_BY_TYPE = {
+ 'ocl-search': {
+ reference_source: 'target_repo',
+ code_field: 'id',
+ ocl_url_field: 'url'
+ },
+ 'ocl-semantic': {
+ reference_source: 'target_repo',
+ code_field: 'id',
+ ocl_url_field: 'url'
+ },
+ 'ocl-bridge': {
+ reference_source: 'bridge_repo',
+ code_field: 'id',
+ ocl_url_field: 'url',
+ cascade_target: {
+ reference_source: 'target_repo',
+ code_field: 'cascade_target_concept_code',
+ ocl_url_field: 'cascade_target_concept_url'
+ }
+ }
+}
+
+const projectContext = {
+ namespace: '/orgs/MyOrg/',
+ target_repo: {
+ relative_url: '/orgs/Regenstrief/sources/LOINC/',
+ canonical_url: 'http://loinc.org',
+ canonical_url_source: 'repo'
+ },
+ bridge_repo: {
+ relative_url: '/orgs/CIEL/sources/CIEL/',
+ canonical_url: 'https://CIELterminology.org',
+ canonical_url_source: 'repo'
+ }
+}
+
+const oclSearchAlgo = { id: 'ocl-search', type: 'ocl-search' }
+const oclBridgeAlgo = { id: 'ocl-bridge', type: 'ocl-bridge' }
+
+const glucose = {
+ id: '49494-3',
+ display_name: 'Glucose [Mass/volume] in Blood',
+ url: '/orgs/Regenstrief/sources/LOINC/concepts/49494-3/',
+ source: 'LOINC',
+ owner: 'Regenstrief',
+ names: [{ name: 'Glucose [Mass/volume] in Blood', locale: 'en', preferred: true }],
+ descriptions: [{ description: 'Glucose mass per volume in blood', locale: 'en' }],
+ search_meta: { search_score: 0.85, search_normalized_score: 87, algorithm: 'ocl-search' }
+}
+
+const cholesterol = {
+ id: '2093-3',
+ display_name: 'Cholesterol in Serum or Plasma',
+ url: '/orgs/Regenstrief/sources/LOINC/concepts/2093-3/',
+ source: 'LOINC',
+ owner: 'Regenstrief',
+ names: [{ name: 'Cholesterol', locale: 'en', preferred: true }],
+ descriptions: [{ description: 'Total cholesterol', locale: 'en' }],
+ search_meta: { search_score: 0.6, search_normalized_score: 55, algorithm: 'ocl-search' }
+}
+
+// ---------- Empty / null inputs ----------
+
+test('normalizeLegacyAllCandidates handles null input', () => {
+ const out = normalizeLegacyAllCandidates(null, projectContext, [], {})
+ assert.deepEqual(out.rowMatchState, {})
+ assert.equal(out.conceptDefinitionsByKey.size, 0)
+})
+
+test('normalizeLegacyAllCandidates handles missing projectContext', () => {
+ const out = normalizeLegacyAllCandidates({'ocl-search': [{row: {__index: 0}, results: [glucose]}]}, null, [oclSearchAlgo], CONCEPT_IDENTITY_BY_TYPE)
+ assert.deepEqual(out.rowMatchState, {})
+})
+
+test('normalizeLegacyAllCandidates handles empty allCandidates', () => {
+ const out = normalizeLegacyAllCandidates({}, projectContext, [oclSearchAlgo], CONCEPT_IDENTITY_BY_TYPE)
+ assert.deepEqual(out.rowMatchState, {})
+})
+
+// ---------- Single algo, single row ----------
+
+test('single ocl-search row produces one RowMatchState entry with normalized candidates', () => {
+ const allCandidates = {
+ 'ocl-search': [{ row: { __index: 0, name: 'glucose' }, results: [glucose] }]
+ }
+ const { rowMatchState, conceptDefinitionsByKey } = normalizeLegacyAllCandidates(
+ allCandidates, projectContext, [oclSearchAlgo], CONCEPT_IDENTITY_BY_TYPE
+ )
+ assert.equal(Object.keys(rowMatchState).length, 1)
+ const row0 = rowMatchState[0]
+ assert.equal(Object.keys(row0.candidates).length, 1)
+ assert.equal(Object.keys(row0.concept_rows).length, 1)
+ assert.equal(conceptDefinitionsByKey.size, 1)
+})
+
+test('single-algo rerank score is carried onto ConceptRow.rerank_score', () => {
+ const allCandidates = {
+ 'ocl-search': [{ row: { __index: 0 }, results: [glucose] }]
+ }
+ const { rowMatchState } = normalizeLegacyAllCandidates(
+ allCandidates, projectContext, [oclSearchAlgo], CONCEPT_IDENTITY_BY_TYPE
+ )
+ const cr = Object.values(rowMatchState[0].concept_rows)[0]
+ assert.equal(cr.rerank_score, 87)
+})
+
+// ---------- Multi-algo + multi-row ----------
+
+test('two algos returning the same concept dedupe into one ConceptRow with two Candidates', () => {
+ const semanticHit = {...glucose, search_meta: { search_score: 0.78, search_normalized_score: 87, algorithm: 'ocl-semantic' }}
+ const allCandidates = {
+ 'ocl-search': [{ row: { __index: 0 }, results: [glucose] }],
+ 'ocl-semantic': [{ row: { __index: 0 }, results: [semanticHit] }]
+ }
+ const { rowMatchState } = normalizeLegacyAllCandidates(
+ allCandidates, projectContext,
+ [oclSearchAlgo, {id: 'ocl-semantic', type: 'ocl-semantic'}],
+ CONCEPT_IDENTITY_BY_TYPE
+ )
+ const row = rowMatchState[0]
+ assert.equal(Object.keys(row.candidates).length, 2)
+ assert.equal(Object.keys(row.concept_rows).length, 1, 'one ConceptRow for the converged concept')
+ const algos = Object.values(row.candidates).map(c => c.algorithm_id).sort()
+ assert.deepEqual(algos, ['ocl-search', 'ocl-semantic'])
+})
+
+test('different rows do not contaminate each other', () => {
+ const allCandidates = {
+ 'ocl-search': [
+ { row: { __index: 0 }, results: [glucose] },
+ { row: { __index: 1 }, results: [cholesterol] }
+ ]
+ }
+ const { rowMatchState } = normalizeLegacyAllCandidates(
+ allCandidates, projectContext, [oclSearchAlgo], CONCEPT_IDENTITY_BY_TYPE
+ )
+ assert.equal(Object.keys(rowMatchState[0].concept_rows).length, 1)
+ assert.equal(Object.keys(rowMatchState[1].concept_rows).length, 1)
+ const row0Key = Object.keys(rowMatchState[0].concept_rows)[0]
+ const row1Key = Object.keys(rowMatchState[1].concept_rows)[0]
+ assert.notEqual(row0Key, row1Key, 'two different concepts → two different keys')
+})
+
+// ---------- Bridge backfill ----------
+
+test('bridge result backfill creates 2 ConceptRows + 2 Candidates per cascade target', () => {
+ const bridgeHit = {
+ id: 'CIEL_12345',
+ display_name: 'Blood glucose measurement',
+ url: '/orgs/CIEL/sources/CIEL/concepts/CIEL_12345/',
+ source: 'CIEL',
+ owner: 'CIEL',
+ search_meta: { search_score: 0.92, search_normalized_score: 91, algorithm: 'ocl-bridge' },
+ mappings: [{
+ cascade_target_concept_code: '49494-3',
+ cascade_target_concept_url: '/orgs/Regenstrief/sources/LOINC/concepts/49494-3/',
+ cascade_target_concept_name: 'Glucose [Mass/volume] in Blood',
+ cascade_target_source_name: 'LOINC',
+ map_type: 'SAME-AS'
+ }]
+ }
+ const allCandidates = {
+ 'ocl-bridge': [{ row: { __index: 0 }, results: [bridgeHit] }]
+ }
+ const { rowMatchState, conceptDefinitionsByKey } = normalizeLegacyAllCandidates(
+ allCandidates, projectContext, [oclBridgeAlgo], CONCEPT_IDENTITY_BY_TYPE
+ )
+ const row = rowMatchState[0]
+ assert.equal(Object.keys(row.candidates).length, 2, '1 bridge + 1 bridge_child')
+ assert.equal(Object.keys(row.concept_rows).length, 2, 'bridge intermediary + cascade target')
+ assert.equal(conceptDefinitionsByKey.size, 2)
+ const types = Object.values(row.candidates).map(c => c.type).sort()
+ assert.deepEqual(types, ['bridge', 'bridge_child'])
+})
+
+// ---------- Concept identity injection fallback ----------
+
+test('algorithm without concept_identity has it injected from conceptIdentityByType', () => {
+ // ocl-bridge entries loaded from the API don't carry concept_identity —
+ // CONCEPT_IDENTITY_BY_TYPE is the fallback map. This mirrors getAlgoDef's
+ // behavior in MapProject.
+ const algoWithoutIdentity = { id: 'ocl-bridge', type: 'ocl-bridge' }
+ const bridgeHit = {
+ id: 'CIEL_999',
+ display_name: 'Test',
+ url: '/orgs/CIEL/sources/CIEL/concepts/CIEL_999/',
+ source: 'CIEL',
+ search_meta: { search_score: 0.7 },
+ mappings: []
+ }
+ const out = normalizeLegacyAllCandidates(
+ { 'ocl-bridge': [{ row: { __index: 0 }, results: [bridgeHit] }] },
+ projectContext,
+ [algoWithoutIdentity],
+ CONCEPT_IDENTITY_BY_TYPE
+ )
+ assert.equal(out.conceptDefinitionsByKey.size, 1, 'identity injection worked')
+})
+
+test('algorithm with no concept_identity AND no fallback is silently skipped', () => {
+ const unknownAlgo = { id: 'mystery', type: 'mystery' }
+ const out = normalizeLegacyAllCandidates(
+ { 'mystery': [{ row: { __index: 0 }, results: [glucose] }] },
+ projectContext,
+ [unknownAlgo],
+ {} // empty fallback map
+ )
+ assert.deepEqual(out.rowMatchState, {}, 'no entries when identity cannot be resolved')
+})
+
+test('rowEntry without a row index is silently skipped', () => {
+ const out = normalizeLegacyAllCandidates(
+ { 'ocl-search': [{ row: null, results: [glucose] }, { row: { __index: 0 }, results: [glucose] }] },
+ projectContext, [oclSearchAlgo], CONCEPT_IDENTITY_BY_TYPE
+ )
+ assert.equal(Object.keys(out.rowMatchState).length, 1)
+ assert.ok(out.rowMatchState[0])
+})
+
+test('ConceptDefinitions are deduped across rows by key with richer-wins', () => {
+ // Same concept appears in two rows: should appear once in
+ // conceptDefinitionsByKey (it's project-wide), but in both rows'
+ // concept_rows (per-row).
+ const allCandidates = {
+ 'ocl-search': [
+ { row: { __index: 0 }, results: [glucose] },
+ { row: { __index: 1 }, results: [glucose] }
+ ]
+ }
+ const { rowMatchState, conceptDefinitionsByKey } = normalizeLegacyAllCandidates(
+ allCandidates, projectContext, [oclSearchAlgo], CONCEPT_IDENTITY_BY_TYPE
+ )
+ assert.equal(conceptDefinitionsByKey.size, 1, 'project-wide dedup')
+ assert.equal(Object.keys(rowMatchState[0].concept_rows).length, 1)
+ assert.equal(Object.keys(rowMatchState[1].concept_rows).length, 1)
+})
diff --git a/src/components/map-projects/__tests__/normalizers.test.js b/src/components/map-projects/__tests__/normalizers.test.js
index 75d40d8..4fa7cb9 100644
--- a/src/components/map-projects/__tests__/normalizers.test.js
+++ b/src/components/map-projects/__tests__/normalizers.test.js
@@ -294,16 +294,55 @@ test('Candidate.concept_key matches ConceptDefinition.key', () => {
assert.equal(out.candidates[0].concept_key, out.concept_definitions[0].key)
})
-test('ConceptRow is created with rerank_score=undefined for the matched concept', () => {
+test('ConceptRow picks up search_normalized_score as rerank_score (single-algo reranker:true path)', () => {
const out = normalizeAlgoResult(oclSearchResult_LOINC_glucose_full, {
algorithmId: 'ocl-search',
algorithmConfig: oclSearchAlgo,
algorithmResponseId: 'ar-1',
- projectContext
+ projectContext,
+ // The caller must opt in: only trust the server's normalized score when
+ // it was produced by reranker:true (single-algo native OCL path).
+ trustServerRerank: true
})
const [row] = out.concept_rows
assert.equal(row.concept_key, out.concept_definitions[0].key)
- assert.equal(row.rerank_score, undefined)
+ // The fixture has search_normalized_score=85 (set by $match's
+ // reranker:true). The normalizer carries that onto the ConceptRow so no
+ // separate $rerank round-trip is needed for the single-algo OCL path.
+ assert.equal(row.rerank_score, 85)
+})
+
+test('ConceptRow.rerank_score ignored when the caller did not opt in (multi-algo path)', () => {
+ // OCL $match emits search_normalized_score unconditionally — for top
+ // FAISS hits the value is ~100. Without the trustServerRerank opt-in
+ // (multi-algo, bridge, scispacy, custom paths) the normalizer must NOT
+ // propagate it: the value isn't a unified rerank score, just a per-algo
+ // native score, and treating it as rerank produces a misleading "100%"
+ // chip until the debounced $rerank/ pass runs.
+ const out = normalizeAlgoResult(oclSearchResult_LOINC_glucose_full, {
+ algorithmId: 'ocl-search',
+ algorithmConfig: oclSearchAlgo,
+ algorithmResponseId: 'ar-multi',
+ projectContext
+ // trustServerRerank omitted — defaults to falsy
+ })
+ assert.equal(out.concept_rows[0].rerank_score, undefined,
+ 'response had search_normalized_score=85 but caller did not opt in → ignored')
+})
+
+test('ConceptRow.rerank_score is undefined when the algorithm did not provide search_normalized_score', () => {
+ const noScoreResult = {
+ ...oclSearchResult_LOINC_glucose_full,
+ search_meta: { ...oclSearchResult_LOINC_glucose_full.search_meta, search_normalized_score: undefined }
+ }
+ const out = normalizeAlgoResult(noScoreResult, {
+ algorithmId: 'ocl-search',
+ algorithmConfig: oclSearchAlgo,
+ algorithmResponseId: 'ar-1b',
+ projectContext,
+ trustServerRerank: true
+ })
+ assert.equal(out.concept_rows[0].rerank_score, undefined)
})
// ---------- normalizeAlgoResult: scispacy (no url, no ocl_url) ----------
@@ -345,6 +384,86 @@ test('scispacy result merges with ocl-search result on the same canonical refere
'same canonical reference => same key, regardless of algorithm')
})
+// ---------- normalizeAlgoResult: schema-specific property capture ----------
+
+test('verbose response (with `property` array) → ConceptDefinition captures it and lookup_status=full', () => {
+ // OCL ConceptDetailSerializer (?verbose=true on $match) emits `property`
+ // sourced from the model's `properties` getter — schema-specific dict for
+ // sources like LOINC: [{code: 'COMPONENT', valueString: 'X'}, ...]. The
+ // UI's ConceptSummaryProperties reads `concept.property` directly.
+ const verboseResult = {
+ id: '49494-3',
+ display_name: 'Glucose [Mass/volume] in Blood',
+ url: '/orgs/Regenstrief/sources/LOINC/concepts/49494-3/',
+ source: 'LOINC',
+ names: [{ name: 'Glucose [Mass/volume] in Blood', locale: 'en', preferred: true }],
+ // no descriptions — many LOINC concepts have none
+ property: [
+ { code: 'COMPONENT', valueString: 'Glucose' },
+ { code: 'PROPERTY', valueString: 'MCnc' },
+ { code: 'TIME_ASPCT', valueString: 'Pt' }
+ ],
+ extras: { LOINC_NUM: '49494-3' },
+ search_meta: { search_score: 0.85, algorithm: 'ocl-semantic' }
+ }
+ const out = normalizeAlgoResult(verboseResult, {
+ algorithmId: 'ocl-semantic',
+ algorithmConfig: oclSemanticAlgo,
+ algorithmResponseId: 'ar-verbose',
+ projectContext
+ })
+ const [def] = out.concept_definitions
+ assert.equal(def.lookup_status, 'full',
+ 'response carries `property` array → full, even without descriptions')
+ assert.equal(def.property.length, 3, 'property array survives normalization')
+ assert.equal(def.property[0].code, 'COMPONENT')
+ assert.deepEqual(def.extras, { LOINC_NUM: '49494-3' }, 'extras survives normalization')
+})
+
+test('verbose response with empty `property` array still promotes to lookup_status=full', () => {
+ // A source with no schema-property definitions returns `property: []`.
+ // We still have full payload data — ensureLoaded shouldn't refetch.
+ const result = {
+ id: 'X-1',
+ display_name: 'No-Schema Concept',
+ url: '/orgs/Test/sources/Plain/concepts/X-1/',
+ source: 'Plain',
+ property: [],
+ search_meta: { search_score: 0.7, algorithm: 'ocl-search' }
+ }
+ const out = normalizeAlgoResult(result, {
+ algorithmId: 'ocl-search',
+ algorithmConfig: oclSearchAlgo,
+ algorithmResponseId: 'ar-verbose-empty',
+ projectContext
+ })
+ assert.equal(out.concept_definitions[0].lookup_status, 'full',
+ 'verbose payload marker is property field presence, not its length')
+})
+
+test('brief response (no `property`, no `names`) stays at lookup_status=partial', () => {
+ // ConceptMinimalSerializer omits `property` entirely. We have id +
+ // display_name but no schema data — ensureLoaded should fire.
+ const briefResult = {
+ id: '49494-3',
+ display_name: 'Glucose [Mass/volume] in Blood',
+ url: '/orgs/Regenstrief/sources/LOINC/concepts/49494-3/',
+ source: 'LOINC',
+ // no property, no names, no descriptions
+ search_meta: { search_score: 0.85, algorithm: 'ocl-search' }
+ }
+ const out = normalizeAlgoResult(briefResult, {
+ algorithmId: 'ocl-search',
+ algorithmConfig: oclSearchAlgo,
+ algorithmResponseId: 'ar-brief',
+ projectContext
+ })
+ assert.equal(out.concept_definitions[0].lookup_status, 'partial',
+ 'no verbose-payload marker, no names → still partial → ensureLoaded eligible')
+ assert.equal(out.concept_definitions[0].property, undefined,
+ 'no property in response → field stays undefined on the ConceptDefinition')
+})
+
// ---------- normalizeAlgoResult: missing data ----------
test('result without id (missing code field) returns empty entities', () => {
@@ -444,12 +563,22 @@ test('bridge result creates ConceptRows for BOTH intermediary and target', () =>
algorithmConfig: oclBridgeAlgo,
algorithmResponseId: 'ar-3',
projectContext
+ // No trustServerRerank — bridge scores are unreliable as unified rerank
+ // (the bridge endpoint scores intermediaries against a vector index that
+ // doesn't speak the project's query semantics). Client-side $rerank/
+ // fills both ConceptRows once they're eligible.
})
assert.equal(out.concept_rows.length, 2)
- for (const row of out.concept_rows) {
- assert.equal(row.rerank_score, undefined)
- }
+ // Bridge intermediary's row stays unscored: even though the response
+ // carries search_normalized_score, the bridge path doesn't opt into
+ // trustServerRerank so it's ignored. Cascade target was already unscored
+ // (bridge response doesn't score cascade targets at all). Both fill in
+ // when $rerank/ lands.
+ const bridgeRow = out.concept_rows.find(r => r.concept_key === out.concept_definitions.find(d => d.reference.url === 'https://CIELterminology.org').key)
+ const targetRow = out.concept_rows.find(r => r.concept_key === out.concept_definitions.find(d => d.reference.url === 'http://loinc.org').key)
+ assert.equal(bridgeRow.rerank_score, undefined)
+ assert.equal(targetRow.rerank_score, undefined)
})
test('bridge result with multiple cascade targets fans out 1 + N candidates', () => {
diff --git a/src/components/map-projects/__tests__/viewHelpers.test.js b/src/components/map-projects/__tests__/viewHelpers.test.js
new file mode 100644
index 0000000..223e327
--- /dev/null
+++ b/src/components/map-projects/__tests__/viewHelpers.test.js
@@ -0,0 +1,223 @@
+/**
+ * Tests for the pure helpers in viewBuilders.js:
+ * - getScoreDetails (used by Score.jsx)
+ * - conceptForMapping (the tuple -> legacy concept projection at the
+ * onMap / isSelectedForMap boundary)
+ * - resolveAICandidateID (AI Assistant response resolution chain)
+ *
+ * Run with: npm test
+ */
+
+import test from 'node:test'
+import assert from 'node:assert/strict'
+
+import {
+ getScoreDetails,
+ conceptForMapping,
+ resolveAICandidateID
+} from '../viewBuilders.js'
+
+const candidatesScore = { recommended: 80, available: 60 }
+
+// ---------- getScoreDetails ----------
+
+test('getScoreDetails: rerank_score in recommended bucket', () => {
+ const out = getScoreDetails(
+ { candidate: { score: 0.85 }, conceptRow: { rerank_score: 90 } },
+ candidatesScore
+ )
+ assert.equal(out.qualityBucket, 'recommended')
+ assert.equal(out.percentile, 90)
+ assert.equal(out.score, 0.85)
+ assert.equal(out.rerankScore, '90.00%')
+ assert.equal(out.algoScore, '0.85')
+})
+
+test('getScoreDetails: rerank_score in available bucket', () => {
+ const out = getScoreDetails(
+ { candidate: { score: 0.6 }, conceptRow: { rerank_score: 70 } },
+ candidatesScore
+ )
+ assert.equal(out.qualityBucket, 'available')
+})
+
+test('getScoreDetails: rerank_score in low_ranked bucket', () => {
+ const out = getScoreDetails(
+ { candidate: { score: 0.3 }, conceptRow: { rerank_score: 40 } },
+ candidatesScore
+ )
+ assert.equal(out.qualityBucket, 'low_ranked')
+})
+
+test('getScoreDetails: no rerank_score leaves percentile undefined (no score*100 fallback)', () => {
+ // Interim state: an algo (e.g. ocl-semantic) returned candidates with a
+ // raw score but the debounced $rerank/ pass hasn't landed yet. Scaling
+ // raw to a 0-100 percentile would mislead (semantic raw scores cluster
+ // near 1.0); leave it undefined so the UI shows a placeholder.
+ const out = getScoreDetails(
+ { candidate: { score: 0.85 }, conceptRow: {} },
+ candidatesScore
+ )
+ assert.equal(out.percentile, undefined)
+ assert.equal(out.hasPercentile, false)
+ assert.equal(out.qualityBucket, undefined)
+ assert.equal(out.algoScore, '0.85', 'raw score still surfaces for the algo-score chip')
+ assert.equal(out.rerankScore, '', 'no unified score string when rerank is pending')
+})
+
+test('getScoreDetails: no scores at all yields hasPercentile=false and no bucket', () => {
+ const out = getScoreDetails(
+ { candidate: {}, conceptRow: {} },
+ candidatesScore
+ )
+ assert.equal(out.hasPercentile, false)
+ assert.equal(out.qualityBucket, undefined)
+})
+
+test('getScoreDetails: bridge_child candidate (no own score) uses rerank_score only', () => {
+ // bridge_child has no algorithm score (the bridge response didn't score
+ // the cascade target). But the ConceptRow has a rerank_score from the
+ // debounced rerank pass.
+ const out = getScoreDetails(
+ { candidate: { score: undefined }, conceptRow: { rerank_score: 75 } },
+ candidatesScore
+ )
+ assert.equal(out.percentile, 75)
+ assert.equal(out.qualityBucket, 'available')
+ assert.equal(out.algoScore, '', 'no raw score when candidate.score is undefined')
+})
+
+test('getScoreDetails: percentile exactly at threshold lands in the higher bucket', () => {
+ const out = getScoreDetails(
+ { candidate: {}, conceptRow: { rerank_score: 80 } },
+ candidatesScore
+ )
+ assert.equal(out.qualityBucket, 'recommended', '80 is >= 80 so recommended')
+})
+
+// ---------- conceptForMapping ----------
+
+const sampleDef = {
+ reference: { url: 'http://loinc.org', code: '49494-3' },
+ key: 'k1',
+ ocl_url: '/orgs/Regenstrief/sources/LOINC/concepts/49494-3/',
+ id: '49494-3',
+ display_name: 'Glucose [Mass/volume] in Blood',
+ source: 'LOINC',
+ owner: 'Regenstrief',
+ names: [{ name: 'Glucose', locale: 'en' }],
+ descriptions: [{ description: 'Test', locale: 'en' }],
+ concept_class: 'LOINC',
+ datatype: 'Numeric',
+ retired: false,
+ properties: []
+}
+
+test('conceptForMapping: returns null when conceptDefinition is missing', () => {
+ assert.equal(conceptForMapping(null), null)
+ assert.equal(conceptForMapping({}), null)
+})
+
+test('conceptForMapping: projects a standard tuple to the legacy concept shape', () => {
+ const out = conceptForMapping({
+ candidate: { algorithm_id: 'ocl-search', score: 0.85, highlights: {name: ['glucose ']} },
+ conceptDefinition: sampleDef,
+ conceptRow: { rerank_score: 87 }
+ })
+ assert.equal(out.id, '49494-3')
+ assert.equal(out.display_name, 'Glucose [Mass/volume] in Blood')
+ assert.equal(out.url, '/orgs/Regenstrief/sources/LOINC/concepts/49494-3/')
+ assert.equal(out.source, 'LOINC')
+ assert.equal(out.type, 'Concept')
+ assert.equal(out.search_meta.algorithm, 'ocl-search')
+ assert.equal(out.search_meta.search_score, 0.85)
+ assert.equal(out.search_meta.search_normalized_score, 87)
+ assert.deepEqual(out.search_meta.search_highlight, {name: ['glucose ']})
+ assert.equal(out.bridge_concept, undefined)
+})
+
+test('conceptForMapping: falls back to reference.code when conceptDefinition.id is absent', () => {
+ const def = { ...sampleDef, id: undefined }
+ const out = conceptForMapping({
+ candidate: { algorithm_id: 'ocl-search' },
+ conceptDefinition: def,
+ conceptRow: {}
+ })
+ assert.equal(out.id, '49494-3', 'reference.code is the fallback id')
+})
+
+test('conceptForMapping: bridge_child includes bridge_concept summary', () => {
+ const bridgeDef = {
+ reference: { url: 'https://CIELterminology.org', code: 'CIEL_12345' },
+ id: 'CIEL_12345',
+ display_name: 'Blood glucose measurement',
+ ocl_url: '/orgs/CIEL/sources/CIEL/concepts/CIEL_12345/',
+ source: 'CIEL'
+ }
+ const out = conceptForMapping({
+ candidate: { algorithm_id: 'ocl-bridge', map_type: 'SAME-AS' },
+ conceptDefinition: sampleDef,
+ conceptRow: { rerank_score: 87 },
+ bridgeConceptDefinition: bridgeDef
+ })
+ assert.ok(out.bridge_concept)
+ assert.equal(out.bridge_concept.id, 'CIEL_12345')
+ assert.equal(out.bridge_concept.display_name, 'Blood glucose measurement')
+ assert.equal(out.bridge_concept.source, 'CIEL')
+ assert.equal(out.search_meta.map_type, 'SAME-AS')
+})
+
+test('conceptForMapping: search_meta carries algorithm_id even when no scores', () => {
+ const out = conceptForMapping({
+ candidate: { algorithm_id: 'ocl-bridge' },
+ conceptDefinition: sampleDef,
+ conceptRow: {}
+ })
+ assert.equal(out.search_meta.algorithm, 'ocl-bridge')
+ assert.equal(out.search_meta.search_score, undefined)
+})
+
+// ---------- resolveAICandidateID ----------
+
+test('resolveAICandidateID: null candidate returns null', () => {
+ assert.equal(resolveAICandidateID(null, {}), null)
+})
+
+test('resolveAICandidateID: concept_key resolves via conceptCache (preferred)', () => {
+ const cache = {
+ k1: { reference: { url: 'http://loinc.org', code: '49494-3' } }
+ }
+ const candidate = {
+ concept_key: 'k1',
+ canonical_reference: { code: 'WRONG' }, // should be ignored when concept_key works
+ concept_id: 'ALSO-WRONG'
+ }
+ assert.equal(resolveAICandidateID(candidate, cache), '49494-3')
+})
+
+test('resolveAICandidateID: falls back to canonical_reference.code when concept_key is absent (PR2a shim)', () => {
+ const cache = {}
+ const candidate = { canonical_reference: { code: '49494-3' }, concept_id: 'LEGACY' }
+ assert.equal(resolveAICandidateID(candidate, cache), '49494-3')
+})
+
+test('resolveAICandidateID: falls back to concept_id when v2 fields are absent (legacy v1)', () => {
+ const candidate = { concept_id: '49494-3' }
+ assert.equal(resolveAICandidateID(candidate, {}), '49494-3')
+})
+
+test('resolveAICandidateID: falls back to id when concept_id absent', () => {
+ const candidate = { id: '49494-3' }
+ assert.equal(resolveAICandidateID(candidate, {}), '49494-3')
+})
+
+test('resolveAICandidateID: concept_key present but not in cache falls through to canonical_reference', () => {
+ // If the AI returns a concept_key the client doesn't know (e.g. cache
+ // hasn't loaded yet, or there's a mismatch), fall through gracefully.
+ const candidate = { concept_key: 'missing', canonical_reference: { code: 'FALLBACK' } }
+ assert.equal(resolveAICandidateID(candidate, {}), 'FALLBACK')
+})
+
+test('resolveAICandidateID: completely unidentified candidate returns null', () => {
+ assert.equal(resolveAICandidateID({}, {}), null)
+})
diff --git a/src/components/map-projects/__tests__/views.test.js b/src/components/map-projects/__tests__/views.test.js
new file mode 100644
index 0000000..cc87c5d
--- /dev/null
+++ b/src/components/map-projects/__tests__/views.test.js
@@ -0,0 +1,455 @@
+/**
+ * Unit tests for the unified-model view builders exported by Candidates.jsx.
+ *
+ * These exercise the read-flip plumbing the UNIFIED_MODEL_ENABLED flag now
+ * depends on: turning a RowState + conceptCache into the algorithm-grouped
+ * and quality-grouped row views Candidates renders.
+ *
+ * Bridge fan-out at the view layer is tested here because the live bridge
+ * algorithm only attaches in production builds (PRIVATE_PACKAGES_GIT) and
+ * cannot be exercised locally. The data shape is the same in both cases.
+ *
+ * Run with: npm test
+ */
+
+import test from 'node:test'
+import assert from 'node:assert/strict'
+
+import {
+ buildAlgorithmRowViews,
+ buildQualityRowViews,
+ candidateToRowView,
+ sortRowViews
+} from '../viewBuilders.js'
+
+// ---------- Helpers ----------
+
+const KEY_LOINC_GLUCOSE = JSON.stringify(['http://loinc.org', '49494-3', null])
+const KEY_LOINC_CHOL = JSON.stringify(['http://loinc.org', '2093-3', null])
+const KEY_CIEL_BRIDGE = JSON.stringify(['https://CIELterminology.org', 'CIEL_12345', null])
+const KEY_SNOMED_BRIDGE = JSON.stringify(['http://snomed.info/sct', '74521008', null])
+
+const defLOINCGlucose = {
+ reference: { url: 'http://loinc.org', code: '49494-3' },
+ key: KEY_LOINC_GLUCOSE,
+ ocl_url: '/orgs/Regenstrief/sources/LOINC/concepts/49494-3/',
+ id: '49494-3',
+ display_name: 'Glucose [Mass/volume] in Blood',
+ source: 'LOINC',
+ owner: 'Regenstrief',
+ retired: false,
+ lookup_status: 'full',
+ lookup_source_type: 'algorithm',
+ lookup_source: 'ocl-search'
+}
+
+const defLOINCChol = {
+ reference: { url: 'http://loinc.org', code: '2093-3' },
+ key: KEY_LOINC_CHOL,
+ ocl_url: '/orgs/Regenstrief/sources/LOINC/concepts/2093-3/',
+ id: '2093-3',
+ display_name: 'Cholesterol in Serum or Plasma',
+ source: 'LOINC',
+ owner: 'Regenstrief',
+ retired: false,
+ lookup_status: 'full',
+ lookup_source_type: 'algorithm',
+ lookup_source: 'ocl-search'
+}
+
+const defCIELBridge = {
+ reference: { url: 'https://CIELterminology.org', code: 'CIEL_12345' },
+ key: KEY_CIEL_BRIDGE,
+ ocl_url: '/orgs/CIEL/sources/CIEL/concepts/CIEL_12345/',
+ id: 'CIEL_12345',
+ display_name: 'Blood glucose measurement',
+ source: 'CIEL',
+ owner: 'CIEL',
+ retired: false,
+ lookup_status: 'full',
+ lookup_source_type: 'algorithm',
+ lookup_source: 'ocl-bridge'
+}
+
+const defSnomedBridge = {
+ reference: { url: 'http://snomed.info/sct', code: '74521008' },
+ key: KEY_SNOMED_BRIDGE,
+ ocl_url: '/orgs/IHTSDO/sources/SNOMED-CT/concepts/74521008/',
+ id: '74521008',
+ display_name: 'Blood glucose level',
+ source: 'SNOMED-CT',
+ owner: 'IHTSDO',
+ retired: false,
+ lookup_status: 'partial',
+ lookup_source_type: 'algorithm',
+ lookup_source: 'ocl-bridge'
+}
+
+// ---------- candidateToRowView ----------
+
+test('candidateToRowView returns null when concept_key is not in cache', () => {
+ const rv = candidateToRowView(
+ { id: 'c1', algorithm_id: 'ocl-search', concept_key: 'missing', type: 'standard' },
+ {},
+ { concept_rows: {} }
+ )
+ assert.equal(rv, null)
+})
+
+test('candidateToRowView joins a standard candidate with its definition and row', () => {
+ const cache = { [KEY_LOINC_GLUCOSE]: defLOINCGlucose }
+ const rowState = {
+ concept_rows: { [KEY_LOINC_GLUCOSE]: { concept_key: KEY_LOINC_GLUCOSE, rerank_score: 87 } }
+ }
+ const candidate = { id: 'c1', algorithm_id: 'ocl-search', concept_key: KEY_LOINC_GLUCOSE, type: 'standard', score: 0.85 }
+ const rv = candidateToRowView(candidate, cache, rowState)
+ assert.equal(rv.type, 'standard')
+ assert.equal(rv.candidate, candidate)
+ assert.equal(rv.conceptDefinition, defLOINCGlucose)
+ assert.equal(rv.conceptRow.rerank_score, 87)
+ assert.equal(rv.bridgeConceptDefinition, undefined)
+})
+
+test('candidateToRowView attaches bridgeConceptDefinition for bridge_child', () => {
+ const cache = { [KEY_LOINC_GLUCOSE]: defLOINCGlucose, [KEY_CIEL_BRIDGE]: defCIELBridge }
+ const rowState = { concept_rows: {} }
+ const child = {
+ id: 'c2',
+ algorithm_id: 'ocl-bridge',
+ concept_key: KEY_LOINC_GLUCOSE,
+ type: 'bridge_child',
+ bridge_concept_key: KEY_CIEL_BRIDGE,
+ parent_candidate_id: 'c1',
+ map_type: 'SAME-AS'
+ }
+ const rv = candidateToRowView(child, cache, rowState)
+ assert.equal(rv.bridgeConceptDefinition, defCIELBridge)
+})
+
+test('candidateToRowView returns null for null candidate', () => {
+ assert.equal(candidateToRowView(null, {}, {}), null)
+})
+
+// ---------- buildAlgorithmRowViews ----------
+
+test('buildAlgorithmRowViews returns empty array when rowState is null', () => {
+ assert.deepEqual(buildAlgorithmRowViews(null, {}, 'ocl-search'), [])
+})
+
+test('buildAlgorithmRowViews filters by algorithm_id and excludes bridge_child at top level', () => {
+ const cache = {
+ [KEY_LOINC_GLUCOSE]: defLOINCGlucose,
+ [KEY_CIEL_BRIDGE]: defCIELBridge
+ }
+ const rowState = {
+ candidates: {
+ c1: { id: 'c1', algorithm_id: 'ocl-search', concept_key: KEY_LOINC_GLUCOSE, type: 'standard', score: 0.85 },
+ c2: { id: 'c2', algorithm_id: 'ocl-bridge', concept_key: KEY_CIEL_BRIDGE, type: 'bridge', score: 0.92 },
+ c3: { id: 'c3', algorithm_id: 'ocl-bridge', concept_key: KEY_LOINC_GLUCOSE, type: 'bridge_child', parent_candidate_id: 'c2', bridge_concept_key: KEY_CIEL_BRIDGE, map_type: 'SAME-AS' }
+ },
+ concept_rows: {}
+ }
+ const searchViews = buildAlgorithmRowViews(rowState, cache, 'ocl-search')
+ assert.equal(searchViews.length, 1)
+ assert.equal(searchViews[0].candidate.id, 'c1')
+
+ const bridgeViews = buildAlgorithmRowViews(rowState, cache, 'ocl-bridge')
+ // bridge_child does NOT appear at top level — it's nested under the bridge
+ assert.equal(bridgeViews.length, 1)
+ assert.equal(bridgeViews[0].type, 'bridge')
+ assert.equal(bridgeViews[0].bridgeChildren.length, 1)
+ assert.equal(bridgeViews[0].bridgeChildren[0].candidate.id, 'c3')
+})
+
+test('buildAlgorithmRowViews: bridge with multiple cascade targets fans out 1 + N children', () => {
+ const KEY_LOINC_A = JSON.stringify(['http://loinc.org', 'A-1', null])
+ const KEY_LOINC_B = JSON.stringify(['http://loinc.org', 'B-2', null])
+ const cache = {
+ [KEY_CIEL_BRIDGE]: defCIELBridge,
+ [KEY_LOINC_A]: { ...defLOINCGlucose, key: KEY_LOINC_A, id: 'A-1', reference: {url: 'http://loinc.org', code: 'A-1'} },
+ [KEY_LOINC_B]: { ...defLOINCGlucose, key: KEY_LOINC_B, id: 'B-2', reference: {url: 'http://loinc.org', code: 'B-2'} }
+ }
+ const rowState = {
+ candidates: {
+ bridge1: { id: 'bridge1', algorithm_id: 'ocl-bridge', concept_key: KEY_CIEL_BRIDGE, type: 'bridge', score: 0.91 },
+ childA: { id: 'childA', algorithm_id: 'ocl-bridge', concept_key: KEY_LOINC_A, type: 'bridge_child', parent_candidate_id: 'bridge1', bridge_concept_key: KEY_CIEL_BRIDGE, map_type: 'SAME-AS' },
+ childB: { id: 'childB', algorithm_id: 'ocl-bridge', concept_key: KEY_LOINC_B, type: 'bridge_child', parent_candidate_id: 'bridge1', bridge_concept_key: KEY_CIEL_BRIDGE, map_type: 'NARROWER-THAN' }
+ },
+ concept_rows: {}
+ }
+ const views = buildAlgorithmRowViews(rowState, cache, 'ocl-bridge')
+ assert.equal(views.length, 1)
+ assert.equal(views[0].bridgeChildren.length, 2)
+ assert.deepEqual(
+ views[0].bridgeChildren.map(c => c.candidate.map_type).sort(),
+ ['NARROWER-THAN', 'SAME-AS']
+ )
+})
+
+test('buildAlgorithmRowViews skips candidates whose ConceptDefinition is missing from cache', () => {
+ const rowState = {
+ candidates: {
+ c1: { id: 'c1', algorithm_id: 'ocl-search', concept_key: 'orphan', type: 'standard', score: 0.5 }
+ },
+ concept_rows: {}
+ }
+ assert.deepEqual(buildAlgorithmRowViews(rowState, {}, 'ocl-search'), [])
+})
+
+// ---------- buildQualityRowViews ----------
+
+test('buildQualityRowViews returns empty array when rowState is null', () => {
+ assert.deepEqual(buildQualityRowViews(null, {}), [])
+})
+
+test('buildQualityRowViews returns one entry per ConceptRow', () => {
+ const cache = { [KEY_LOINC_GLUCOSE]: defLOINCGlucose, [KEY_LOINC_CHOL]: defLOINCChol }
+ const rowState = {
+ candidates: {
+ c1: { id: 'c1', algorithm_id: 'ocl-search', concept_key: KEY_LOINC_GLUCOSE, type: 'standard', score: 0.85 },
+ c2: { id: 'c2', algorithm_id: 'ocl-search', concept_key: KEY_LOINC_CHOL, type: 'standard', score: 0.6 }
+ },
+ concept_rows: {
+ [KEY_LOINC_GLUCOSE]: { concept_key: KEY_LOINC_GLUCOSE, rerank_score: 87 },
+ [KEY_LOINC_CHOL]: { concept_key: KEY_LOINC_CHOL, rerank_score: 55 }
+ }
+ }
+ const views = buildQualityRowViews(rowState, cache)
+ assert.equal(views.length, 2)
+})
+
+test('buildQualityRowViews dedupes multi-algo convergence (one ConceptRow, multiple contributing candidates)', () => {
+ // Both ocl-search and ocl-semantic returned LOINC glucose — should appear
+ // ONCE in quality view with both candidates surfaced as contributingCandidates.
+ const cache = { [KEY_LOINC_GLUCOSE]: defLOINCGlucose }
+ const rowState = {
+ candidates: {
+ c1: { id: 'c1', algorithm_id: 'ocl-search', concept_key: KEY_LOINC_GLUCOSE, type: 'standard', score: 0.85 },
+ c2: { id: 'c2', algorithm_id: 'ocl-semantic', concept_key: KEY_LOINC_GLUCOSE, type: 'standard', score: 0.78 }
+ },
+ concept_rows: {
+ [KEY_LOINC_GLUCOSE]: { concept_key: KEY_LOINC_GLUCOSE, rerank_score: 87 }
+ }
+ }
+ const views = buildQualityRowViews(rowState, cache)
+ assert.equal(views.length, 1)
+ assert.equal(views[0].contributingCandidates.length, 2)
+ assert.deepEqual(
+ views[0].contributingCandidates.map(c => c.algorithm_id).sort(),
+ ['ocl-search', 'ocl-semantic']
+ )
+ // Primary is the highest-scoring standard candidate (ocl-search at 0.85),
+ // not whichever shows up first in Object.values() iteration order. Without
+ // the score-desc sort the choice depended on insertion order and the UI's
+ // "primary algorithm" chip flipped between renders.
+ assert.equal(views[0].candidate.id, 'c1')
+ assert.equal(views[0].candidate.algorithm_id, 'ocl-search')
+})
+
+test('buildQualityRowViews: multi-algo convergence — primary is the higher-scoring standard candidate regardless of insertion order', () => {
+ // Same as the dedup test, but with the lower-scoring candidate inserted
+ // FIRST. Pre-fix, find() returned whichever came first; post-fix, the
+ // score-desc sort picks ocl-semantic (0.92) over ocl-search (0.71).
+ const cache = { [KEY_LOINC_GLUCOSE]: defLOINCGlucose }
+ const rowState = {
+ candidates: {
+ c1: { id: 'c1', algorithm_id: 'ocl-search', concept_key: KEY_LOINC_GLUCOSE, type: 'standard', score: 0.71 },
+ c2: { id: 'c2', algorithm_id: 'ocl-semantic', concept_key: KEY_LOINC_GLUCOSE, type: 'standard', score: 0.92 }
+ },
+ concept_rows: {
+ [KEY_LOINC_GLUCOSE]: { concept_key: KEY_LOINC_GLUCOSE, rerank_score: 87 }
+ }
+ }
+ const views = buildQualityRowViews(rowState, cache)
+ assert.equal(views[0].candidate.algorithm_id, 'ocl-semantic')
+})
+
+test('buildQualityRowViews: bridge cascade target converges with direct match into ONE ConceptRow', () => {
+ // The same LOINC concept is reached via ocl-search (direct) AND via
+ // ocl-bridge (cascade). Quality view shows it once with both candidates
+ // contributing. (Multi-source convergence — spec section "Multi-source
+ // convergence".)
+ const cache = {
+ [KEY_LOINC_GLUCOSE]: defLOINCGlucose,
+ [KEY_CIEL_BRIDGE]: defCIELBridge
+ }
+ const rowState = {
+ candidates: {
+ direct: { id: 'direct', algorithm_id: 'ocl-search', concept_key: KEY_LOINC_GLUCOSE, type: 'standard', score: 0.85 },
+ bridge: { id: 'bridge', algorithm_id: 'ocl-bridge', concept_key: KEY_CIEL_BRIDGE, type: 'bridge', score: 0.92 },
+ child: { id: 'child', algorithm_id: 'ocl-bridge', concept_key: KEY_LOINC_GLUCOSE, type: 'bridge_child', parent_candidate_id: 'bridge', bridge_concept_key: KEY_CIEL_BRIDGE, map_type: 'SAME-AS' }
+ },
+ concept_rows: {
+ [KEY_LOINC_GLUCOSE]: { concept_key: KEY_LOINC_GLUCOSE, rerank_score: 88 },
+ [KEY_CIEL_BRIDGE]: { concept_key: KEY_CIEL_BRIDGE, rerank_score: 91 }
+ }
+ }
+ const views = buildQualityRowViews(rowState, cache)
+ assert.equal(views.length, 2)
+
+ const loincView = views.find(v => v.conceptDefinition.key === KEY_LOINC_GLUCOSE)
+ // The LOINC target should prefer the 'standard' candidate as primary
+ // (per the spec: a direct hit is the user-facing primary)
+ assert.equal(loincView.type, 'standard')
+ assert.equal(loincView.candidate.id, 'direct')
+ // But the bridge_child also contributes
+ assert.equal(loincView.contributingCandidates.length, 2)
+
+ const bridgeView = views.find(v => v.conceptDefinition.key === KEY_CIEL_BRIDGE)
+ assert.ok(bridgeView, 'bridge intermediary surfaces as its own row in quality view')
+})
+
+test('buildQualityRowViews: ConceptRow without any candidate is excluded', () => {
+ const cache = { [KEY_LOINC_GLUCOSE]: defLOINCGlucose }
+ const rowState = {
+ candidates: {},
+ concept_rows: {
+ [KEY_LOINC_GLUCOSE]: { concept_key: KEY_LOINC_GLUCOSE, rerank_score: 50 }
+ }
+ }
+ assert.deepEqual(buildQualityRowViews(rowState, cache), [])
+})
+
+test('buildQualityRowViews: bridge_child becomes the primary when no standard candidate exists', () => {
+ // If the LOINC concept is reached ONLY via bridge (no direct ocl-search
+ // hit), the bridge_child becomes the primary candidate for that
+ // ConceptRow. Its type is 'bridge_child' so Concept.jsx renders the
+ // map_type chip and bridge prefix.
+ const cache = { [KEY_LOINC_GLUCOSE]: defLOINCGlucose, [KEY_CIEL_BRIDGE]: defCIELBridge }
+ const rowState = {
+ candidates: {
+ bridge: { id: 'bridge', algorithm_id: 'ocl-bridge', concept_key: KEY_CIEL_BRIDGE, type: 'bridge', score: 0.92 },
+ child: { id: 'child', algorithm_id: 'ocl-bridge', concept_key: KEY_LOINC_GLUCOSE, type: 'bridge_child', parent_candidate_id: 'bridge', bridge_concept_key: KEY_CIEL_BRIDGE, map_type: 'SAME-AS' }
+ },
+ concept_rows: {
+ [KEY_LOINC_GLUCOSE]: { concept_key: KEY_LOINC_GLUCOSE, rerank_score: 84 },
+ [KEY_CIEL_BRIDGE]: { concept_key: KEY_CIEL_BRIDGE, rerank_score: 91 }
+ }
+ }
+ const views = buildQualityRowViews(rowState, cache)
+ const loincView = views.find(v => v.conceptDefinition.key === KEY_LOINC_GLUCOSE)
+ assert.equal(loincView.type, 'bridge_child')
+ assert.equal(loincView.bridgeConceptDefinition, defCIELBridge)
+})
+
+test('buildQualityRowViews: convergence — bridgeContributors lists non-primary bridge candidates', () => {
+ // When a target is reached by BOTH a standard algo (primary) AND a bridge,
+ // the rowView carries a bridgeContributors entry so the UI can render an
+ // [i] indicator next to the algo chip with bridge intermediary + map_type.
+ const cache = {
+ [KEY_LOINC_GLUCOSE]: defLOINCGlucose,
+ [KEY_CIEL_BRIDGE]: defCIELBridge
+ }
+ const rowState = {
+ candidates: {
+ direct: { id: 'direct', algorithm_id: 'ocl-search', concept_key: KEY_LOINC_GLUCOSE, type: 'standard', score: 0.85 },
+ bridge: { id: 'bridge', algorithm_id: 'ocl-ciel-bridge', concept_key: KEY_CIEL_BRIDGE, type: 'bridge', score: 0.92 },
+ child: { id: 'child', algorithm_id: 'ocl-ciel-bridge', concept_key: KEY_LOINC_GLUCOSE, type: 'bridge_child', parent_candidate_id: 'bridge', bridge_concept_key: KEY_CIEL_BRIDGE, map_type: 'SAME-AS' }
+ },
+ concept_rows: {
+ [KEY_LOINC_GLUCOSE]: { concept_key: KEY_LOINC_GLUCOSE, rerank_score: 88 },
+ [KEY_CIEL_BRIDGE]: { concept_key: KEY_CIEL_BRIDGE, rerank_score: 91 }
+ }
+ }
+ const views = buildQualityRowViews(rowState, cache)
+ const loincView = views.find(v => v.conceptDefinition.key === KEY_LOINC_GLUCOSE)
+ assert.equal(loincView.type, 'standard')
+ assert.equal(loincView.bridgeContributors.length, 1)
+ assert.equal(loincView.bridgeContributors[0].bridgeConceptDefinition, defCIELBridge)
+ assert.equal(loincView.bridgeContributors[0].map_type, 'SAME-AS')
+ assert.equal(loincView.bridgeContributors[0].algorithm_id, 'ocl-ciel-bridge')
+})
+
+test('buildQualityRowViews: bridge-only target has empty bridgeContributors (primary excluded)', () => {
+ // Bridge-only case: the primary IS a bridge_child, so it's NOT also in
+ // bridgeContributors. Inline framing in Concept.jsx already shows the
+ // bridge intermediary; a duplicate [i] indicator would be noise.
+ const cache = { [KEY_LOINC_GLUCOSE]: defLOINCGlucose, [KEY_CIEL_BRIDGE]: defCIELBridge }
+ const rowState = {
+ candidates: {
+ bridge: { id: 'bridge', algorithm_id: 'ocl-ciel-bridge', concept_key: KEY_CIEL_BRIDGE, type: 'bridge', score: 0.92 },
+ child: { id: 'child', algorithm_id: 'ocl-ciel-bridge', concept_key: KEY_LOINC_GLUCOSE, type: 'bridge_child', parent_candidate_id: 'bridge', bridge_concept_key: KEY_CIEL_BRIDGE, map_type: 'SAME-AS' }
+ },
+ concept_rows: {
+ [KEY_LOINC_GLUCOSE]: { concept_key: KEY_LOINC_GLUCOSE, rerank_score: 84 },
+ [KEY_CIEL_BRIDGE]: { concept_key: KEY_CIEL_BRIDGE, rerank_score: 91 }
+ }
+ }
+ const views = buildQualityRowViews(rowState, cache)
+ const loincView = views.find(v => v.conceptDefinition.key === KEY_LOINC_GLUCOSE)
+ assert.equal(loincView.type, 'bridge_child')
+ assert.equal(loincView.bridgeContributors.length, 0)
+})
+
+test('buildQualityRowViews: multi-bridge case — distinct bridge intermediaries do not collide', () => {
+ // CIEL + SNOMED-CT both bridge to the same LOINC target. Quality view:
+ // ONE LOINC ConceptRow, TWO bridge ConceptRows (one per namespace).
+ const cache = {
+ [KEY_LOINC_GLUCOSE]: defLOINCGlucose,
+ [KEY_CIEL_BRIDGE]: defCIELBridge,
+ [KEY_SNOMED_BRIDGE]: defSnomedBridge
+ }
+ const rowState = {
+ candidates: {
+ bridgeC: { id: 'bC', algorithm_id: 'ocl-bridge', concept_key: KEY_CIEL_BRIDGE, type: 'bridge', score: 0.92 },
+ childC: { id: 'cC', algorithm_id: 'ocl-bridge', concept_key: KEY_LOINC_GLUCOSE, type: 'bridge_child', parent_candidate_id: 'bC', bridge_concept_key: KEY_CIEL_BRIDGE, map_type: 'SAME-AS' },
+ bridgeS: { id: 'bS', algorithm_id: 'ocl-snomed-bridge', concept_key: KEY_SNOMED_BRIDGE, type: 'bridge', score: 0.88 },
+ childS: { id: 'cS', algorithm_id: 'ocl-snomed-bridge', concept_key: KEY_LOINC_GLUCOSE, type: 'bridge_child', parent_candidate_id: 'bS', bridge_concept_key: KEY_SNOMED_BRIDGE, map_type: 'SAME-AS' }
+ },
+ concept_rows: {
+ [KEY_LOINC_GLUCOSE]: { concept_key: KEY_LOINC_GLUCOSE, rerank_score: 90 },
+ [KEY_CIEL_BRIDGE]: { concept_key: KEY_CIEL_BRIDGE, rerank_score: 91 },
+ [KEY_SNOMED_BRIDGE]: { concept_key: KEY_SNOMED_BRIDGE, rerank_score: 87 }
+ }
+ }
+ const views = buildQualityRowViews(rowState, cache)
+ assert.equal(views.length, 3, 'two bridges + one target')
+ const loincView = views.find(v => v.conceptDefinition.key === KEY_LOINC_GLUCOSE)
+ assert.equal(loincView.contributingCandidates.length, 2, 'both bridge_children point to the same LOINC concept')
+})
+
+// ---------- sortRowViews ----------
+
+const fixtureViews = [
+ { candidate: {score: 0.6}, conceptDefinition: {id: 'b', display_name: 'Beta'}, conceptRow: {rerank_score: 70} },
+ { candidate: {score: 0.9}, conceptDefinition: {id: 'a', display_name: 'Alpha'}, conceptRow: {rerank_score: 50} },
+ { candidate: {score: 0.7}, conceptDefinition: {id: 'c', display_name: 'Gamma'}, conceptRow: {rerank_score: 90} }
+]
+
+test('sortRowViews: rerank_score desc', () => {
+ const out = sortRowViews(fixtureViews, 'rerank_score', 'desc')
+ assert.deepEqual(out.map(v => v.conceptDefinition.id), ['c', 'b', 'a'])
+})
+
+test('sortRowViews: algo_score desc', () => {
+ const out = sortRowViews(fixtureViews, 'algo_score', 'desc')
+ assert.deepEqual(out.map(v => v.conceptDefinition.id), ['a', 'c', 'b'])
+})
+
+test('sortRowViews: id asc', () => {
+ const out = sortRowViews(fixtureViews, 'id', 'asc')
+ assert.deepEqual(out.map(v => v.conceptDefinition.id), ['a', 'b', 'c'])
+})
+
+test('sortRowViews: display_name asc', () => {
+ const out = sortRowViews(fixtureViews, 'display_name', 'asc')
+ assert.deepEqual(out.map(v => v.conceptDefinition.display_name), ['Alpha', 'Beta', 'Gamma'])
+})
+
+test('sortRowViews: undefined scores sort to the bottom in desc order', () => {
+ const views = [
+ { candidate: {}, conceptDefinition: {display_name: 'x'}, conceptRow: {} },
+ { candidate: {}, conceptDefinition: {display_name: 'y'}, conceptRow: {rerank_score: 50} }
+ ]
+ const out = sortRowViews(views, 'rerank_score', 'desc')
+ assert.equal(out[0].conceptDefinition.display_name, 'y')
+})
+
+test('sortRowViews: id falls back to reference.code when id is absent', () => {
+ const views = [
+ { candidate: {}, conceptDefinition: {reference: {code: 'z-1'}}, conceptRow: {} },
+ { candidate: {}, conceptDefinition: {reference: {code: 'a-1'}}, conceptRow: {} }
+ ]
+ const out = sortRowViews(views, 'id', 'asc')
+ assert.equal(out[0].conceptDefinition.reference.code, 'a-1')
+})
diff --git a/src/components/map-projects/algorithms.jsx b/src/components/map-projects/algorithms.jsx
index f4f0dab..a5e9bc0 100644
--- a/src/components/map-projects/algorithms.jsx
+++ b/src/components/map-projects/algorithms.jsx
@@ -47,6 +47,47 @@ export const CONCEPT_IDENTITY_BY_TYPE = {
}
}
+// Cheap structural validator for canonical URLs. The unified mapper model
+// requires every algorithm's concept_identity.canonical_url to be a URL —
+// custom algos rely on user input for this, so the form validates here.
+export const isLikelyCanonicalUrl = (value) => {
+ if(!value || typeof value !== 'string') return false
+ return /^https?:\/\/[^\s]+$/i.test(value.trim())
+}
+
+// Walk algosSelected and return the subset that fails project-config
+// validation. Currently: custom algos must have a valid canonical_url.
+// (plans/unified-mapper-model.md "Algorithm concept_identity configuration —
+// Custom algorithm".) Returns [{algo, reason}] for diagnostic banners.
+export const getProjectConfigErrors = (algosSelected) => {
+ const errors = []
+ ;(algosSelected || []).forEach(algo => {
+ if(algo?.type === 'custom' && !isLikelyCanonicalUrl(algo.canonical_url))
+ errors.push({ algo, reason: 'missing_canonical_url' })
+ })
+ return errors
+}
+
+// Return a copy of `algo` with `concept_identity` populated. Three sources,
+// in priority order:
+// 1. algo.concept_identity already set (native built-in algos define this
+// inline in useAlgos below)
+// 2. CONCEPT_IDENTITY_BY_TYPE[algo.type] (API-loaded algos: bridge, scispacy)
+// 3. For custom algos: derive from algo.canonical_url (user-entered)
+// Returns null when none of these can produce a usable concept_identity —
+// callers must skip normalization for that algo. This is the single
+// integration point for all read/write paths (getAlgoDef, normalizer load,
+// bulk processBatch, lookupCandidates).
+export const ensureConceptIdentity = (algo) => {
+ if(!algo) return null
+ if(algo.concept_identity) return algo
+ if(CONCEPT_IDENTITY_BY_TYPE[algo.type])
+ return { ...algo, concept_identity: CONCEPT_IDENTITY_BY_TYPE[algo.type] }
+ if(algo.type === 'custom' && algo.canonical_url)
+ return { ...algo, concept_identity: { reference_source: 'fixed', canonical_url: algo.canonical_url, code_field: 'id' } }
+ return null
+}
+
export const useAlgos = (t, toggles) => {
const algos = [
{
diff --git a/src/components/map-projects/constants.jsx b/src/components/map-projects/constants.jsx
index 4b5747d..d8ae506 100644
--- a/src/components/map-projects/constants.jsx
+++ b/src/components/map-projects/constants.jsx
@@ -3,7 +3,7 @@ import ListIcon from '@mui/icons-material/FormatListNumbered';
import UnMappedIcon from '@mui/icons-material/LinkOff';
import MappedIcon from '@mui/icons-material/Link';
import ReviewedIcon from '@mui/icons-material/FactCheckOutlined';
-import { RECOMMEND_COLOR, AVAILABLE_COLOR, UNRANKED_COLOR } from '../../common/colors'
+import { RECOMMEND_COLOR, AVAILABLE_COLOR, UNRANKED_COLOR, PENDING_RERANK_COLOR } from '../../common/colors'
const ID_HEADER = {id: 'id', label: 'ID', description: 'Exact match on concept ID'}
const COMMON_HEADERS = [
@@ -57,7 +57,8 @@ export const DECISION_TABS = ['candidates', 'search', 'propose', 'discuss']
export const SCORES_COLOR = {
recommended: RECOMMEND_COLOR,
available: AVAILABLE_COLOR,
- low_ranked: UNRANKED_COLOR
+ low_ranked: UNRANKED_COLOR,
+ pending_rerank: PENDING_RERANK_COLOR
}
export const SEMANTIC_BATCH_SIZE = 10
diff --git a/src/components/map-projects/normalizers.js b/src/components/map-projects/normalizers.js
index 8422e0b..8d3a42c 100644
--- a/src/components/map-projects/normalizers.js
+++ b/src/components/map-projects/normalizers.js
@@ -49,13 +49,23 @@ export const createAlgorithmResponse = (rawResponse, algorithmId, options = {})
/**
* Decide a concept's lookup_status based on which fields the algorithm response
- * populated.
+ * populated. 'full' means the response carries enough data for the UI:
+ * - `property` field present (the verbose-payload marker — OCL's
+ * ConceptDetailSerializer always emits it, even as an empty array), OR
+ * - a populated `names` array (the list-serializer signal — names are
+ * enough to render the row's display).
+ * Many concepts (LOINC especially) have no separate `descriptions`, so
+ * requiring descriptions for 'full' stranded verbose-loaded concepts at
+ * 'partial' and made the UI's summary chips disappear. We don't check
+ * `extras` because scispacy synthesizes that field with internal metadata
+ * (LOINC_NUM, composite_score) — not the OCL schema property dict.
*/
const inferLookupStatus = (result) => {
if (!result) return 'pending'
const hasNames = Array.isArray(result.names) && result.names.length > 0
- const hasDescriptions = Array.isArray(result.descriptions) && result.descriptions.length > 0
- if (hasNames && hasDescriptions) return 'full'
+ const hasVerbosePayload = Array.isArray(result.property)
+ || (Array.isArray(result.properties) && result.properties.length > 0)
+ if (result.id && result.display_name && (hasVerbosePayload || hasNames)) return 'full'
if (result.id && result.display_name) return 'partial'
return 'pending'
}
@@ -120,6 +130,14 @@ const toConceptDefinition = (result, reference, identityConfig, { algorithmId }
datatype: result?.datatype,
retired: result?.retired,
properties: result?.properties,
+ // `property` (singular) is the schema-specific property dict OCL returns
+ // for sources like LOINC (COMPONENT/PROPERTY/TIME_ASPCT/etc.) sourced from
+ // ConceptDetailSerializer.property = JSONField(source='properties').
+ // ConceptSummaryProperties.jsx reads this field directly. Without it the
+ // verbose payload's schema chips never reach the UI even when $match
+ // returns them.
+ property: result?.property,
+ extras: result?.extras,
lookup_status: inferLookupStatus(result),
lookup_source_type: 'algorithm',
lookup_source: algorithmId
@@ -163,15 +181,28 @@ const cascadeTargetToConceptDefinition = (mapping, cascadeIdentity, projectConte
datatype: undefined,
retired: undefined,
properties: undefined,
+ property: undefined,
+ extras: undefined,
lookup_status: 'pending',
lookup_source_type: undefined,
lookup_source: undefined
}
}
-const newConceptRow = (conceptKey) => ({
+// rerank_score on ConceptRow comes from search_normalized_score ONLY when
+// the caller signals it came from a reranker-true single-algo $match path
+// — that's the path where the server-side scores ARE the unified rerank.
+// In all other paths (multi-algo, bridge, scispacy, custom) the response's
+// search_normalized_score is just the per-algo native score (e.g. FAISS
+// similarity × 100), which the OCL server emits unconditionally. Treating
+// that as a unified rerank score yields chip values like "100.00%" for
+// every top semantic candidate until the debounced $rerank/ pipeline runs
+// and overwrites it — misleading during the interim window.
+const newConceptRow = (conceptKey, result, trustServerRerank = false) => ({
concept_key: conceptKey,
- rerank_score: undefined
+ rerank_score: trustServerRerank && typeof result?.search_meta?.search_normalized_score === 'number'
+ ? result.search_meta.search_normalized_score
+ : undefined
})
const isBridgeResult = (identityConfig) =>
@@ -192,7 +223,7 @@ export const normalizeAlgoResult = (result, ctx = {}) => {
const empty = { candidates: [], concept_definitions: [], concept_rows: [] }
if (!result) return empty
- const { algorithmId, algorithmConfig, algorithmResponseId, projectContext } = ctx
+ const { algorithmId, algorithmConfig, algorithmResponseId, projectContext, trustServerRerank } = ctx
const identityConfig = algorithmConfig?.concept_identity
if (!identityConfig) return empty
@@ -209,7 +240,7 @@ export const normalizeAlgoResult = (result, ctx = {}) => {
const primaryDef = toConceptDefinition(result, primaryReference, identityConfig, { algorithmId })
conceptDefinitions.push(primaryDef)
- conceptRows.push(newConceptRow(primaryDef.key))
+ conceptRows.push(newConceptRow(primaryDef.key, result, trustServerRerank))
const primaryCandidate = {
id: newId(),
@@ -235,6 +266,9 @@ export const normalizeAlgoResult = (result, ctx = {}) => {
// Avoid duplicate ConceptDefinition entries within the same result.
if (!conceptDefinitions.some(cd => cd.key === targetDef.key)) {
conceptDefinitions.push(targetDef)
+ // Cascade target's row carries no rerank_score yet — the bridge
+ // response doesn't score targets, so the debounced rerank pass
+ // will fill it once the row is eligible.
conceptRows.push(newConceptRow(targetDef.key))
}
@@ -283,7 +317,8 @@ export const normalizeAlgorithmInvocation = (rawPayload, ctx = {}) => {
rowIndex,
status = 'success',
error,
- rawResponse
+ rawResponse,
+ trustServerRerank
} = ctx
const algorithmResponse = createAlgorithmResponse(
@@ -303,7 +338,8 @@ export const normalizeAlgorithmInvocation = (rawPayload, ctx = {}) => {
algorithmId,
algorithmConfig,
algorithmResponseId: algorithmResponse.id,
- projectContext
+ projectContext,
+ trustServerRerank
})
allCandidates.push(...candidates)
concept_definitions.forEach(cd => {
@@ -330,3 +366,99 @@ export const normalizeAlgorithmInvocation = (rawPayload, ctx = {}) => {
const LOOKUP_RANK = { pending: 0, failed: 0, partial: 1, full: 2 }
export const lookupStatusRank = (status) => LOOKUP_RANK[status] ?? 0
+
+/**
+ * Backfill rowMatchState + ConceptDefinitions from the legacy
+ * `allCandidates` shape (a saved-project artifact: `{ [algoId]: [{row,
+ * results}, ...] }`). Called on project load so that v1-saved projects
+ * render correctly under UNIFIED_MODEL_ENABLED=true. A precursor to
+ * PR3's `normalizeLegacy.js`.
+ *
+ * Pure function: no React, no APIService, no mutation of inputs.
+ *
+ * @param {Object} allCandidates { [algoId]: [{row, results}, ...] }
+ * @param {Object} projectContext {namespace, target_repo, bridge_repo?}
+ * @param {Array} algorithms algo defs (may carry concept_identity)
+ * @param {Object} [conceptIdentityByType] optional fallback map for algos
+ * missing concept_identity (e.g. API-
+ * loaded bridge/scispacy variants).
+ * @returns {{
+ * rowMatchState: Object, keyed by row __index
+ * conceptDefinitionsByKey: Map
+ * }}
+ */
+export const normalizeLegacyAllCandidates = (
+ allCandidates,
+ projectContext,
+ algorithms,
+ conceptIdentityByType = {}
+) => {
+ const rowMatchState = {}
+ const conceptDefinitionsByKey = new Map()
+ if(!allCandidates || !projectContext) return { rowMatchState, conceptDefinitionsByKey }
+
+ const algoById = new Map((algorithms || []).map(a => [a.id, a]))
+
+ Object.entries(allCandidates).forEach(([algoId, rowEntries]) => {
+ const algoDef = algoById.get(algoId)
+ if(!algoDef) return
+ const algoConfig = algoDef.concept_identity
+ ? algoDef
+ : (conceptIdentityByType[algoDef.type]
+ ? { ...algoDef, concept_identity: conceptIdentityByType[algoDef.type] }
+ : null)
+ if(!algoConfig) return
+
+ ;(rowEntries || []).forEach(rowEntry => {
+ const idx = rowEntry?.row?.__index
+ if(idx === undefined || idx === null) return
+
+ const normalized = normalizeAlgorithmInvocation(
+ { row: rowEntry.row, results: rowEntry.results || [] },
+ {
+ algorithmId: algoId,
+ algorithmConfig: algoConfig,
+ projectContext,
+ rowIndex: idx,
+ // Saved-project legacy data: search_normalized_score was persisted
+ // from a prior session's $rerank/ output, so it IS the canonical
+ // rerank score. Honor it (no client-side rerank will fire for
+ // already-loaded rows).
+ trustServerRerank: true
+ }
+ )
+
+ const prevRow = rowMatchState[idx] || {
+ algorithm_responses: {},
+ candidates: {},
+ concept_rows: {}
+ }
+ const nextRow = {
+ algorithm_responses: {
+ ...prevRow.algorithm_responses,
+ [normalized.algorithm_response.id]: normalized.algorithm_response
+ },
+ candidates: { ...prevRow.candidates },
+ concept_rows: { ...prevRow.concept_rows }
+ }
+ normalized.candidates.forEach(c => { nextRow.candidates[c.id] = c })
+ normalized.concept_rows.forEach(cr => {
+ const existing = nextRow.concept_rows[cr.concept_key]
+ // Existing entry keeps its rerank_score (richer wins); new arrivals
+ // are taken only when no entry exists yet.
+ if(!existing) nextRow.concept_rows[cr.concept_key] = cr
+ else if(existing.rerank_score === undefined && cr.rerank_score !== undefined)
+ nextRow.concept_rows[cr.concept_key] = cr
+ })
+ rowMatchState[idx] = nextRow
+
+ normalized.concept_definitions.forEach(def => {
+ const existing = conceptDefinitionsByKey.get(def.key)
+ if(!existing || lookupStatusRank(def.lookup_status) > lookupStatusRank(existing.lookup_status))
+ conceptDefinitionsByKey.set(def.key, def)
+ })
+ })
+ })
+
+ return { rowMatchState, conceptDefinitionsByKey }
+}
diff --git a/src/components/map-projects/viewBuilders.js b/src/components/map-projects/viewBuilders.js
new file mode 100644
index 0000000..bda9d16
--- /dev/null
+++ b/src/components/map-projects/viewBuilders.js
@@ -0,0 +1,286 @@
+/**
+ * Pure functions that project the unified-model state (RowMatchState +
+ * conceptCache) into the row-view tuples consumed by Candidates.jsx /
+ * Concept.jsx / Score.jsx.
+ *
+ * Kept in a plain .js file (no JSX) so they're loadable by Node's native
+ * ESM resolver under node:test. Components import these from
+ * `./viewBuilders.js`; tests in `__tests__/views.test.js` exercise them
+ * directly without webpack/babel.
+ *
+ * See plans/unified-mapper-model.md "How the views map onto this model".
+ */
+
+// Match normalizers.js / conceptKey.js — pure JS, no lodash deps, no JSX
+// imports, so the module is loadable by Node's native ESM resolver under
+// node:test.
+
+const compact = arr => (arr || []).filter(x => x != null && x !== false)
+const values = obj => Object.values(obj || {})
+const isNumber = v => typeof v === 'number' && !Number.isNaN(v)
+
+/**
+ * Build a "RowView" object for a single Candidate. Joins the Candidate
+ * with its ConceptDefinition from conceptCache and its ConceptRow from
+ * the row's state. Returns null if the ConceptDefinition is missing.
+ */
+export const candidateToRowView = (candidate, conceptCache, rowState) => {
+ if(!candidate) return null
+ const conceptDefinition = conceptCache?.[candidate.concept_key]
+ if(!conceptDefinition) return null
+ const conceptRow = rowState?.concept_rows?.[candidate.concept_key]
+ let bridgeConceptDefinition
+ if(candidate.type === 'bridge_child' && candidate.bridge_concept_key)
+ bridgeConceptDefinition = conceptCache[candidate.bridge_concept_key]
+ return {
+ type: candidate.type,
+ candidate,
+ conceptDefinition,
+ conceptRow,
+ bridgeConceptDefinition
+ }
+}
+
+/**
+ * Algorithm-grouped view: iterate `RowState.candidates` filtered by
+ * algorithm_id. Bridge candidates carry their bridge_children for nested
+ * rendering. bridge_child candidates do NOT appear at the top level —
+ * they are nested under their parent bridge.
+ */
+// Deterministic order for bridge_children under a bridge intermediary:
+// primary key is rerank_score desc (when both have one), secondary is the
+// concept code/id ascending. Without this, children appeared in
+// Object.values() insertion order, which flipped between renders and made
+// the same project look different each refresh.
+const sortBridgeChildren = (rowViews) => {
+ const codeOf = (v) => v?.conceptDefinition?.id || v?.conceptDefinition?.reference?.code || ''
+ return [...(rowViews || [])].sort((a, b) => {
+ const sa = a?.conceptRow?.rerank_score
+ const sb = b?.conceptRow?.rerank_score
+ const haveA = typeof sa === 'number' && !Number.isNaN(sa)
+ const haveB = typeof sb === 'number' && !Number.isNaN(sb)
+ if(haveA && haveB && sa !== sb) return sb - sa
+ if(haveA && !haveB) return -1
+ if(!haveA && haveB) return 1
+ return codeOf(a).localeCompare(codeOf(b))
+ })
+}
+
+export const buildAlgorithmRowViews = (rowState, conceptCache, algoId) => {
+ if(!rowState) return []
+ const all = values(rowState.candidates || {}).filter(c => c.algorithm_id === algoId)
+ const standalone = all.filter(c => c.type !== 'bridge_child')
+ return compact(standalone.map(candidate => {
+ const view = candidateToRowView(candidate, conceptCache, rowState)
+ if(!view) return null
+ if(view.type === 'bridge') {
+ const children = all.filter(c => c.type === 'bridge_child' && c.parent_candidate_id === candidate.id)
+ view.bridgeChildren = sortBridgeChildren(compact(children.map(c => candidateToRowView(c, conceptCache, rowState))))
+ }
+ return view
+ }))
+}
+
+/**
+ * Quality-grouped view: iterate `RowState.concept_rows`. One entry per
+ * concept_key (the per-row presence of a concept). Each entry exposes
+ * its rerank_score plus a list of contributing candidates so the UI can
+ * show algorithm provenance. Bridge children surface here as their
+ * target concept (no special-casing needed — the bridge_child Candidate's
+ * concept_key IS the target concept's key).
+ */
+export const buildQualityRowViews = (rowState, conceptCache) => {
+ if(!rowState) return []
+ const allCandidates = values(rowState.candidates || {})
+ const conceptRows = values(rowState.concept_rows || {})
+ return compact(conceptRows.map(conceptRow => {
+ const conceptDefinition = conceptCache?.[conceptRow.concept_key]
+ if(!conceptDefinition) return null
+ const contributing = allCandidates.filter(c => c.concept_key === conceptRow.concept_key)
+ // Prefer a 'standard' candidate as the primary; else any bridge_child
+ // (with its bridge intermediary attached); else whatever's there.
+ // Within each type group, pick the highest-scoring candidate so the
+ // primary is deterministic — without this, multi-algo convergence
+ // (e.g. both ocl-search and ocl-semantic returning the same concept)
+ // selected by Object.values() iteration order and the "primary algorithm"
+ // chip flipped between renders. Falls back to -Infinity for unscored
+ // candidates (notably bridge_child, which has no own score).
+ const byScoreDesc = (a, b) => (b?.score ?? -Infinity) - (a?.score ?? -Infinity)
+ const primary = [...contributing.filter(c => c.type === 'standard')].sort(byScoreDesc)[0]
+ || [...contributing.filter(c => c.type === 'bridge_child')].sort(byScoreDesc)[0]
+ || contributing[0]
+ if(!primary) return null
+ let bridgeConceptDefinition
+ if(primary.type === 'bridge_child' && primary.bridge_concept_key)
+ bridgeConceptDefinition = conceptCache[primary.bridge_concept_key]
+ // Collect bridge contributors OTHER than the primary so the convergence
+ // case (target reached by both a standard algo AND one or more bridges)
+ // can surface the bridge story on the algo-chip line via [i] tooltip
+ // without taking over the row's primary framing.
+ const bridgeContributors = compact(contributing
+ .filter(c => c.type === 'bridge_child' && c !== primary)
+ .map(c => {
+ const bridgeDef = c.bridge_concept_key ? conceptCache?.[c.bridge_concept_key] : null
+ return bridgeDef ? {
+ bridgeConceptDefinition: bridgeDef,
+ map_type: c.map_type,
+ algorithm_id: c.algorithm_id
+ } : null
+ }))
+ return {
+ type: primary.type === 'bridge_child' ? 'bridge_child' : 'standard',
+ candidate: primary,
+ conceptDefinition,
+ conceptRow,
+ bridgeConceptDefinition,
+ bridgeContributors,
+ contributingCandidates: contributing
+ }
+ }))
+}
+
+/**
+ * Sort RowViews by the chosen key. `rerank_score` and `algo_score` are
+ * numeric; `id` and `display_name` are alphabetical. Missing scores sort
+ * to the bottom in `desc` order via a -1 sentinel.
+ */
+export const sortRowViews = (views, sortBy, order) => {
+ const valueFor = view => {
+ switch(sortBy) {
+ case 'rerank_score': return view.conceptRow?.rerank_score ?? -1
+ case 'algo_score': return view.candidate?.score ?? -1
+ case 'id': return view.conceptDefinition?.id || view.conceptDefinition?.reference?.code || ''
+ case 'display_name': return view.conceptDefinition?.display_name || ''
+ default: return 0
+ }
+ }
+ const dir = order === 'desc' ? -1 : 1
+ const compare = (a, b) => {
+ const va = valueFor(a)
+ const vb = valueFor(b)
+ if(va < vb) return -1 * dir
+ if(va > vb) return 1 * dir
+ return 0
+ }
+ return [...(views || [])].sort(compare)
+}
+
+/**
+ * Project a unified-model tuple back into a legacy concept-shaped object,
+ * used at the onMap / isSelectedForMap boundary. mapSelected and
+ * downstream consumers (decision view, getRows, save format) continue to
+ * expect the legacy shape; PR3 migrates them to consume the tuple
+ * directly.
+ */
+export const conceptForMapping = (rowView) => {
+ if(!rowView) return null
+ const { candidate, conceptDefinition, conceptRow, bridgeConceptDefinition } = rowView
+ if(!conceptDefinition) return null
+ return {
+ id: conceptDefinition.id || conceptDefinition.reference?.code,
+ display_name: conceptDefinition.display_name,
+ url: conceptDefinition.ocl_url,
+ source: conceptDefinition.source,
+ owner: conceptDefinition.owner,
+ names: conceptDefinition.names,
+ descriptions: conceptDefinition.descriptions,
+ concept_class: conceptDefinition.concept_class,
+ datatype: conceptDefinition.datatype,
+ retired: conceptDefinition.retired,
+ properties: conceptDefinition.properties,
+ // `property` (singular) is the schema-specific dict — LOINC's
+ // COMPONENT/PROPERTY/TIME_ASPCT/etc. ConceptSummaryProperties reads
+ // this directly. Without it in the projection, table view rows render
+ // without the schema chips (the card view works because it gets the
+ // ConceptDefinition directly).
+ property: conceptDefinition.property,
+ extras: conceptDefinition.extras,
+ type: 'Concept',
+ search_meta: {
+ algorithm: candidate?.algorithm_id,
+ search_score: candidate?.score,
+ search_normalized_score: conceptRow?.rerank_score,
+ search_highlight: candidate?.highlights,
+ map_type: candidate?.map_type
+ },
+ bridge_concept: bridgeConceptDefinition ? {
+ id: bridgeConceptDefinition.id || bridgeConceptDefinition.reference?.code,
+ url: bridgeConceptDefinition.ocl_url,
+ display_name: bridgeConceptDefinition.display_name,
+ source: bridgeConceptDefinition.source
+ } : undefined
+ }
+}
+
+/**
+ * Compute the unified score (0-100 percentile) and the raw algorithm score
+ * for a candidate+row. Unified score = per-(row, concept) rerank score
+ * from the ConceptRow. Raw score = per-algorithm score on the Candidate.
+ *
+ * Accepts either shape:
+ * - {candidate, conceptRow} — the unified-model tuple
+ * - {search_meta: {search_normalized_score, search_score}} — the legacy
+ * concept shape (also produced by conceptForMapping projection).
+ * Both shapes coexist while PR3-era cleanup is pending; the dialog passing
+ * `conceptForMapping(tuple)` to setShowHighlights needs the legacy path.
+ *
+ * Pure — caller maps qualityBucket -> bucketColor via SCORES_COLOR.
+ */
+export const getScoreDetails = (input = {}, candidatesScore = {}) => {
+ const {candidate, conceptRow} = input || {}
+ if(!input?.search_meta && candidate?.search_meta?.search_score) {
+ candidate.search_meta.search_score = parseFloat(candidate.search_meta.search_score)
+ candidate.search_meta.search_normalized_score = candidate.search_meta.search_normalized_score ? parseFloat(candidate.search_meta.search_normalized_score) : candidate.search_meta.search_normalized_score
+ }
+ const searchMeta = input?.search_meta || candidate?.search_meta
+ const rerankFloat = isNumber(conceptRow?.rerank_score)
+ ? conceptRow.rerank_score
+ : (isNumber(searchMeta?.search_normalized_score) ? searchMeta.search_normalized_score : null)
+ const score = isNumber(candidate?.score)
+ ? candidate.score
+ : (isNumber(searchMeta?.search_score) ? searchMeta.search_score : null)
+ // ConceptRow.rerank_score is already on the 0-100 scale (the rerank API
+ // returns search_normalized_score in that range). Display directly.
+ // No fallback to `score * 100`: an interim semantic-search candidate has
+ // candidate.score ≈ 1.0 and a pending rerank — scaling that produces a
+ // misleading 100% chip until rerank lands. Leave percentile undefined so
+ // the UI can render a placeholder.
+ const percentile = rerankFloat !== null ? rerankFloat : undefined
+
+ const hasPercentile = isNumber(percentile)
+ const recommendedScore = candidatesScore?.recommended
+ const availableScore = candidatesScore?.available
+
+ let qualityBucket
+ if(hasPercentile) {
+ if(percentile >= recommendedScore) qualityBucket = 'recommended'
+ else if(percentile >= availableScore) qualityBucket = 'available'
+ else qualityBucket = 'low_ranked'
+ }
+
+ const rerankScore = hasPercentile ? `${parseFloat(percentile).toFixed(2)}%` : ''
+ const algoScore = isNumber(score) ? `${parseFloat(score).toFixed(2)}` : ''
+
+ return {
+ score,
+ percentile,
+ hasPercentile,
+ qualityBucket,
+ rerankScore,
+ algoScore
+ }
+}
+
+/**
+ * Resolve an AI Assistant primary_candidate / alternative_candidate to a
+ * displayable concept code. Resolution order:
+ * 1. concept_key -> conceptCache[key].reference.code (v2, preferred)
+ * 2. canonical_reference.code (v2 fallback, PR2a shim)
+ * 3. concept_id / id (legacy v1)
+ */
+export const resolveAICandidateID = (candidate, conceptCache) => {
+ if(!candidate) return null
+ if(candidate.concept_key && conceptCache?.[candidate.concept_key]?.reference?.code)
+ return conceptCache[candidate.concept_key].reference.code
+ return candidate.canonical_reference?.code || candidate.concept_id || candidate.id || null
+}
diff --git a/src/i18n/config.js b/src/i18n/config.js
index d84ca33..e7d166e 100644
--- a/src/i18n/config.js
+++ b/src/i18n/config.js
@@ -19,7 +19,8 @@ i18n.use(initReactI18next).init({
}
},
ns: ['translations'],
- defaultNS: 'translations'
+ defaultNS: 'translations',
+ interpolation: { escapeValue: false }
});
i18n.languages = ['en', 'es', 'zh'];
diff --git a/src/i18n/locales/en/translations.json b/src/i18n/locales/en/translations.json
index e78248f..361b47a 100644
--- a/src/i18n/locales/en/translations.json
+++ b/src/i18n/locales/en/translations.json
@@ -608,6 +608,22 @@
"model": "Model",
"bridge_source_url": "Bridge Source URL",
"bridge_source_url_description": "The interface terminology to search through for bridge matching",
+ "target_canonical_url": "Canonical:",
+ "canonical_auto_derived": "Auto-derived",
+ "canonical_auto_derived_short": "derived",
+ "bridge_canonical_short": "Bridge:",
+ "bridge_canonical_url": "Bridge Canonical URL",
+ "bridge_canonical_url_description": "Canonical URL of the bridge code system (leave blank to derive from the relative URL).",
+ "advanced_settings": "Advanced settings",
+ "resolution_namespace": "Resolution Namespace",
+ "resolution_namespace_description": "Namespace passed to $resolveReference. When blank, defaults to {{owner}}. Drives which URL Registry entries apply when resolving canonical URLs.",
+ "algo_canonical_url": "Canonical URL",
+ "algo_canonical_url_required": "Canonical URL is required for custom algorithms (e.g. http://loinc.org).",
+ "algo_canonical_url_invalid": "Enter a full URL starting with http:// or https://",
+ "algo_canonical_url_description": "Canonical URL of the code system this algorithm matches against (e.g. http://loinc.org).",
+ "config_errors_title": "Project configuration is incomplete",
+ "config_error_missing_canonical": "Custom algorithm \"{{name}}\" is missing a valid canonical URL.",
+ "target_repo_required_on_load": "This project is missing a target repository. Configure the target repository to see saved candidates.",
"create_similar": "Create similar",
"create_similar_name": "Copy of {{name}}"
},