From 1d0dfec9a727f6115c3c4ea18f1cf362a0cde450 Mon Sep 17 00:00:00 2001 From: Jonathan Payne Date: Sat, 9 May 2026 10:57:17 -0400 Subject: [PATCH 1/4] OpenConceptLab/ocl_issues#2337 | PR2a: remove match_type and matchTypes state The legacy 5-bucket match_type enum (very_high/high/medium/low/no_match) is superseded by the 3-bucket score grouping (recommended/available/low_ranked) already driven by candidatesScore thresholds. Maintaining both invited drift and added surface area for no benefit. Removed: - MATCH_TYPES constant in constants.jsx (and orphan AutoMatch/MediumMatch/ LowMatch/NoMatch icon imports) - matchTypes state and selectedMatchBucket state in MapProject.jsx - updateMatchTypeCounts() and all its call sites - onMatchTypeChange handler and the selectedMatchBucket filter in getRows - The Badge + Switch UI for the very_high filter - showMatchSummary (orphan) and orphan FormControlLabel/Switch/Badge/countBy/ sum imports - match_type read in Score.jsx (color now derived from bucketColor) - Orphan setMatchTypes call inside setAutoMatched Refactored: - setStateViews now derives auto-match decisions from search_meta.search_normalized_score >= candidatesScore.recommended, matching the existing setAutoMatched threshold The __match_type__ / __Match Type__ entries in the save-format omit lists are kept as defensive cleanup against legacy data. No behavior change visible to users beyond the removal of the very_high filter Switch (which is replaced by the existing ScoreBucketButton filtering on 'recommended'). Co-Authored-By: Claude Opus 4.7 (1M context) --- src/components/map-projects/MapProject.jsx | 67 +--------------------- src/components/map-projects/Score.jsx | 6 +- src/components/map-projects/constants.jsx | 32 ----------- 3 files changed, 5 insertions(+), 100 deletions(-) diff --git a/src/components/map-projects/MapProject.jsx b/src/components/map-projects/MapProject.jsx index bd454f4..a656c0d 100644 --- a/src/components/map-projects/MapProject.jsx +++ b/src/components/map-projects/MapProject.jsx @@ -17,14 +17,11 @@ import IconButton from '@mui/material/IconButton' import Tabs from '@mui/material/Tabs'; import Tab from '@mui/material/Tab'; import Chip from '@mui/material/Chip'; -import FormControlLabel from '@mui/material/FormControlLabel'; import FormControl from '@mui/material/FormControl'; -import Switch from '@mui/material/Switch'; import Tooltip from '@mui/material/Tooltip'; import Alert from '@mui/material/Alert'; import Collapse from '@mui/material/Collapse'; import Divider from '@mui/material/Divider'; -import Badge from '@mui/material/Badge'; import { DataGrid } from '@mui/x-data-grid'; @@ -48,8 +45,6 @@ import without from 'lodash/without' import has from 'lodash/has' import chunk from 'lodash/chunk' import get from 'lodash/get' -import countBy from 'lodash/countBy' -import sum from 'lodash/sum' import omit from 'lodash/omit' import omitBy from 'lodash/omitBy' import reject from 'lodash/reject' @@ -154,7 +149,6 @@ const MapProject = () => { const [rowStatuses, setRowStatuses] = React.useState({reviewed: [], readyForReview: [], unmapped: []}) const [decisions, setDecisions] = React.useState({}) const [decisionFilters, setDecisionFilters] = React.useState([]) - const [matchTypes, setMatchTypes] = React.useState({very_high: 0, high: 0, medium: 0, low: 0, no_match: 0}) const [matchedConcepts, setMatchedConcepts] = React.useState([]); // Algo Candidates @@ -203,7 +197,6 @@ const MapProject = () => { const [edit, setEdit] = React.useState([]); const [configure, setConfigure] = React.useState(!params.projectId); const [selectedRowStatus, setSelectedRowStatus] = React.useState('all') - const [selectedMatchBucket, setSelectedMatchBucket] = React.useState(false) const [decisionTab, setDecisionTab] = React.useState('candidates') const [searchText, setSearchText] = React.useState('') // csv row search const [selectedCandidatesScoreBucket, setSelectedCandidatesScoreBucket] = React.useState(false) @@ -724,7 +717,6 @@ const MapProject = () => { setRowStatuses({reviewed: [], readyForReview: [], unmapped: []}) setDecisions({}) setDecisionFilters([]) - setMatchTypes({very_high: 0, high: 0, medium: 0, low: 0, no_match: 0}) setMatchedConcepts([]) setAllCandidates({}) setSearchedConcepts({}) @@ -1139,18 +1131,10 @@ const MapProject = () => { } const setStateViews = (data, _repo) => { - let matchTypes = map(data, 'results.0.search_meta.match_type') - let counts = countBy(matchTypes) - setMatchTypes(prev => ({ - very_high: prev.very_high + (counts?.very_high || 0), - high: prev.high + (counts?.high || 0), - medium: prev.medium + (counts?.medium || 0), - low: prev.low + (counts?.low || 0), - no_match: prev.no_match + (sum(values(omit(counts, ['very_high', 'high', 'medium', 'low']))) || 0) - })); setRowStatuses(prev => { forEach(data, concept => { - if(get(concept, 'results.0.search_meta.match_type') === 'very_high') { + const topScore = get(concept, 'results.0.search_meta.search_normalized_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}} setMapSelected(_prev => { _prev[concept.row.__index] = _concept @@ -1192,7 +1176,6 @@ const MapProject = () => { search_meta: {...topCandidate.search_meta, map_type: mapping.map_type || topCandidate.search_meta.map_type }, } } - setMatchTypes(prev => prev.very_high + 1) setRowStatuses(prev => { let newStatuses = {...prev} let _concept = {...conceptToMap, repo: {...repo, version: repoVersion?.id || repo.version, version_url: repoVersion?.version_url || repo.version_url}} @@ -1591,8 +1574,6 @@ const MapProject = () => { setMatchDialog(false) } - const showMatchSummary = Boolean(data?.length && (loadingMatches || matchedConcepts?.length)) - const [_now, set_Now] = React.useState(() => moment()); React.useEffect(() => { @@ -1651,21 +1632,10 @@ const MapProject = () => { return t('map_project.ai_analysis') } - const onMatchTypeChange = bucket => setSelectedMatchBucket(prev => prev === bucket ? false : bucket) - const getRows = () => { let rows = data?.length ? [...data] : [] if(selectedRowStatus !== 'all') rows = filter(rows, r => rowStatuses[selectedRowStatus].includes(r.__index)) - if(selectedMatchBucket) { - let getIndex = concept => { - if(selectedMatchBucket === 'no_match') - return (!concept?.results?.length || !['very_high', 'high', 'medium', 'low'].includes(concept.results[0].search_meta.match_type)) ? concept.row.__index : null - return (concept?.results?.length && concept.results[0].search_meta.match_type === selectedMatchBucket) ? concept.row.__index : null - } - const rowIndexes = map(matchedConcepts, getIndex) - rows = filter(rows, r => rowIndexes.includes(r.__index)) - } if(searchText) rows = filter(rows, row => find(values(row), v => @@ -1998,7 +1968,6 @@ const MapProject = () => { setMapTypes({...mapTypes, [rowIndex]: mapType}) setTimeout(() => highlightTexts([concept], null, false), 100) } - updateMatchTypeCounts(null, prev) if(closeConcept) setShowItem(false) return prev @@ -2017,7 +1986,6 @@ const MapProject = () => { const onReviewDone = (next = false) => { const newRowStatuses = {...rowStatuses, reviewed: uniq([...rowStatuses.reviewed, rowIndex]), readyForReview: without(rowStatuses.readyForReview, rowIndex), unmapped: without(rowStatuses.unmapped, rowIndex)} setRowStatuses(newRowStatuses) - updateMatchTypeCounts('reviewed', newRowStatuses) log({'action': 'approved'}) if(next){ const nextRow = data[selectedRowStatus === 'all' ? rowIndex + 1 : find(rowStatuses[selectedRowStatus], idx => idx > rowIndex)] @@ -2044,24 +2012,7 @@ const MapProject = () => { const onStateTabChange = newValue => { setSelectedRowStatus(newValue) - updateMatchTypeCounts(newValue) setDecisionFilters([]) - if(newValue === 'unmapped') - setSelectedMatchBucket(false) - } - - const updateMatchTypeCounts = (newRowStatus, newRowStatuses) => { - let rowStatus = newRowStatus || selectedRowStatus - let rows = rowStatus === 'all' ? matchedConcepts : filter(matchedConcepts, concept => (newRowStatuses || rowStatuses)[rowStatus].includes(concept.row.__index)); - let matchTypes = map(rows, 'results.0.search_meta.match_type') - let counts = countBy(matchTypes) - setMatchTypes({ - very_high: (counts?.very_high || 0), - high: (counts?.high || 0), - medium: (counts?.medium || 0), - low: (counts?.low || 0), - no_match: sum(values(omit(counts, ['very_high', 'high', 'medium', 'low']))) || 0 - }); } const onDecisionTabChange = (event, newValue) => { @@ -2115,7 +2066,6 @@ const MapProject = () => { prev.readyForReview = without(prev.readyForReview, rowIndex) prev.unmapped = uniq([...prev.unmapped, rowIndex]) } - updateMatchTypeCounts(null, prev) return prev }) if(newValue !== 'map' && !logged) @@ -3034,7 +2984,7 @@ const MapProject = () => { { - (Boolean(rows?.length) || selectedMatchBucket || ROW_STATES.includes(selectedRowStatus) || searchText) && + (Boolean(rows?.length) || ROW_STATES.includes(selectedRowStatus) || searchText) &&
{ @@ -3072,17 +3022,6 @@ const MapProject = () => { setSearchText(val || ''))} /> - - onMatchTypeChange('very_high')} />} - label={t('map_project.auto_match')} - /> - setScoreBucketSortBy(scoreBucketSortBy === 'desc' ? 'asc' : 'desc')} diff --git a/src/components/map-projects/Score.jsx b/src/components/map-projects/Score.jsx index 399ec8c..44df55f 100644 --- a/src/components/map-projects/Score.jsx +++ b/src/components/map-projects/Score.jsx @@ -1,6 +1,6 @@ import React from 'react' import { useTranslation } from 'react-i18next'; -import { MATCH_TYPES, SCORES_COLOR } from './constants' +import { SCORES_COLOR } from './constants' import ListItem from '@mui/material/ListItem' import ListItemButton from '@mui/material/ListItemButton' import ListItemIcon from '@mui/material/ListItemIcon' @@ -75,13 +75,11 @@ const Score = ({concept, setShowHighlights, sx, isAIRecommended, candidatesScore rerankScore, algoScore } = getScoreDetails(concept, candidatesScore) - const { color } = MATCH_TYPES[concept?.search_meta?.match_type || 'no_match'] return ( { diff --git a/src/components/map-projects/constants.jsx b/src/components/map-projects/constants.jsx index 9a9aaed..869ba21 100644 --- a/src/components/map-projects/constants.jsx +++ b/src/components/map-projects/constants.jsx @@ -3,10 +3,6 @@ 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 AutoMatchIcon from '@mui/icons-material/MotionPhotosAutoOutlined'; -import MediumMatchIcon from '@mui/icons-material/Rule'; -import LowMatchIcon from '@mui/icons-material/DynamicForm'; -import NoMatchIcon from '@mui/icons-material/RemoveRoad'; import { RECOMMEND_COLOR, AVAILABLE_COLOR, UNRANKED_COLOR } from '../../common/colors' const ID_HEADER = {id: 'id', label: 'ID', description: 'Exact match on concept ID'} @@ -56,34 +52,6 @@ export const VIEWS = { }, } -export const MATCH_TYPES = { - very_high: { - label: 'Auto Match', - icon: , - color: 'primary', - }, - high: { - label: 'High Match', - icon: , - color: 'warning', - }, - medium: { - label: 'Medium Match', - icon: , - color: 'warning', - }, - low: { - label: 'Low Match', - icon: , - color: 'secondary', - }, - no_match: { - label: 'No Match', - icon: , - color: 'error', - }, -} - export const DECISION_TABS = ['candidates', 'search', 'propose', 'discuss'] export const SCORES_COLOR = { From 0b881efb97d8ba02b26bd7d2a432af1b3b45f0a6 Mon Sep 17 00:00:00 2001 From: Jonathan Payne Date: Sat, 9 May 2026 11:05:51 -0400 Subject: [PATCH 2/4] OpenConceptLab/ocl_issues#2337 | PR2a: wire bridge results into the unified-model normalizer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bridge algorithms (ocl-bridge / ocl-ciel-bridge) come from the OCL Online API and don't carry the concept_identity config the normalizer needs. Their per-row fetch path already routes through onResponse via the fetchBridgeCandidates callback chain, but the bulk fetch path bypasses onResponse with its own callback. Both paths now feed the normalizer when UNIFIED_MODEL_ENABLED is true. algorithms.jsx: - New CONCEPT_IDENTITY_BY_TYPE export — single source of truth for per-algo-type concept identity, covering ocl-search, ocl-semantic, ocl-bridge, ocl-ciel-bridge. Bridge entries declare reference_source: 'bridge_repo' for the intermediary plus a cascade_target block that resolves the cascade to target_repo. - useAlgos now references the map for the inline ocl-search / ocl-semantic concept_identity (no behavior change; deduplication). MapProject.jsx: - getAlgoDef injects concept_identity from CONCEPT_IDENTITY_BY_TYPE when the algo (typically API-loaded bridge variants) doesn't carry it. - buildProjectContext now includes bridge_repo when a bridge algo is selected, derived from algo.target_repo_url. PR2b will read explicit canonical from bridge repo metadata once ConfigurationForm carries it. Reordered so it sits below bridgeAlgo in the component body to satisfy TDZ for the new useCallback dep. - fetchBulkBridgeCandidates: callback adds the normalizeAlgorithmInvocation + mergeIntoRowMatchState wiring (gated by UNIFIED_MODEL_ENABLED) so the bulk path mirrors what onResponse does on the per-row path. Feature flag remains OFF by default; this is dark-launch scaffolding. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/components/map-projects/MapProject.jsx | 93 +++++++++++++++------- src/components/map-projects/algorithms.jsx | 53 +++++++++--- 2 files changed, 104 insertions(+), 42 deletions(-) diff --git a/src/components/map-projects/MapProject.jsx b/src/components/map-projects/MapProject.jsx index a656c0d..6c8284c 100644 --- a/src/components/map-projects/MapProject.jsx +++ b/src/components/map-projects/MapProject.jsx @@ -96,7 +96,7 @@ import ScoreBucketButton from './ScoreBucketButton' import Concept from './Concept' import ImportToCollection from './ImportToCollection' import ProjectLogs from './ProjectLogs'; -import { useAlgos } from './algorithms' +import { useAlgos, CONCEPT_IDENTITY_BY_TYPE } from './algorithms' import AutoMatchDialog from './AutoMatchDialog' import { DEFAULT_ENCODER_MODEL } from './rerankerModels' import { normalizeAlgorithmInvocation, lookupStatusRank } from './normalizers' @@ -306,26 +306,6 @@ const MapProject = () => { }) }, []) - // Build projectContext for the unified-model normalizer. - // Target repo canonical URL is read from repo metadata; if absent, derive - // 'https://ns.openconceptlab.org' + relative URL (per OCL canonical - // conventions — see plans/unified-mapper-model.md). - const buildProjectContext = React.useCallback(() => { - if(!repo?.url) return null - const targetCanonical = repo.canonical_url || `https://ns.openconceptlab.org${repo.url}` - return { - namespace: get(project, 'owner_url') || owner, - target_repo: { - relative_url: repo.url, - canonical_url: targetCanonical, - canonical_url_source: repo.canonical_url ? 'repo' : 'derived', - version: repoVersion?.id || repo.version - } - // bridge_repo is set per-invocation when the algo is a bridge algo - // (bridge path doesn't flow through this onResponse handler in PR 1). - } - }, [project, owner, repo, repoVersion]) - const allCandidatesRef = React.useRef({}) /*eslint no-undef: 0*/ @@ -342,6 +322,38 @@ const MapProject = () => { const bridgeAlgo = find(algosSelected, a => ['ocl-bridge', 'ocl-ciel-bridge'].includes(a.type)) const bridgeEnabled = Boolean(bridgeAlgo) + // Build projectContext for the unified-model normalizer. Reads target repo + // canonical_url from repo metadata; if absent, derives + // 'https://ns.openconceptlab.org' + relative URL (per OCL canonical + // conventions — see plans/unified-mapper-model.md). When a bridge algo is + // selected, includes bridge_repo derived from algo.target_repo_url. + const buildProjectContext = React.useCallback(() => { + if(!repo?.url) return null + const targetCanonical = repo.canonical_url || `https://ns.openconceptlab.org${repo.url}` + const ctx = { + namespace: get(project, 'owner_url') || owner, + target_repo: { + relative_url: repo.url, + canonical_url: targetCanonical, + canonical_url_source: repo.canonical_url ? 'repo' : 'derived', + 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) { + ctx.bridge_repo = { + relative_url: bridgeAlgo.target_repo_url, + canonical_url: `https://ns.openconceptlab.org${bridgeAlgo.target_repo_url}`, + canonical_url_source: 'derived' + } + } + return ctx + }, [project, owner, repo, repoVersion, bridgeAlgo]) + const baseAlgos = useAlgos(t, toggles) const [apiAlgos, setApiAlgos] = React.useState([]); React.useEffect(() => { @@ -1404,15 +1416,29 @@ const MapProject = () => { await fetchBridgeCandidates(_rows[index], 0, undefined, undefined, undefined, false, true, ((response, payload) => { const index = payload.rows[0].__index + const results = (isArray(response) ? response : response?.data) log({action: 'algo_finished', extras: {algo: algo.id}}, index) markAlgo(index, algo.id, 1) - setAllCandidates(prev => { - const newCandidates = {...prev} - const results = (isArray(response) ? response : response?.data) - newCandidates[algo.id] = [...reject(prev[algo.id], c => c.row.__index === index), ...(results || [])] - lookupCandidates(algo.id, results) - return newCandidates - }) + setAllCandidates(prev => ({ + ...prev, + [algo.id]: [...reject(prev[algo.id], c => c.row.__index === index), ...(results || [])] + })) + lookupCandidates(algo.id, results) + if(UNIFIED_MODEL_ENABLED) { + // Route the bridge invocation through the normalizer (the per-row + // path goes via onResponse, but the bulk path lives here). + const algoDef = getAlgoDef(algo.id) + const rowPayload = find(results, r => r?.row?.__index === index) + if(rowPayload && algoDef) { + mergeIntoRowMatchState(index, normalizeAlgorithmInvocation(rowPayload, { + algorithmId: algo.id, + algorithmConfig: algoDef, + projectContext: buildProjectContext(), + rowIndex: index, + rawResponse: response + })) + } + } })); // wait for completion await new Promise(resolve => setTimeout(resolve, 200)); // 1s delay } @@ -2097,7 +2123,16 @@ const MapProject = () => { }); }; - const getAlgoDef = algoId => find(algosSelected, {id: algoId}) + 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 + } const getNextAlgoDef = (algoId) => { const algoDef = getAlgoDef(algoId); if (!algoDef) return; diff --git a/src/components/map-projects/algorithms.jsx b/src/components/map-projects/algorithms.jsx index 1ef2589..575c373 100644 --- a/src/components/map-projects/algorithms.jsx +++ b/src/components/map-projects/algorithms.jsx @@ -1,6 +1,44 @@ import React from 'react' import MatchingIcon from '@mui/icons-material/DeviceHub'; +// Canonical concept-identity config per algorithm type +// (plans/unified-mapper-model.md). Single source of truth shared between +// algorithms defined here (ocl-semantic, ocl-search) and algorithms loaded +// from the OCL Online API (ocl-bridge, ocl-ciel-bridge, ocl-scispacy); +// MapProject's getAlgoDef merges the missing concept_identity at lookup time. +export const CONCEPT_IDENTITY_BY_TYPE = { + 'ocl-semantic': { + 'reference_source': 'target_repo', + 'code_field': 'id', + 'ocl_url_field': 'url' + }, + 'ocl-search': { + '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' + } + }, + 'ocl-ciel-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' + } + } +} + export const useAlgos = (t, toggles) => { const algos = [ { @@ -18,14 +56,7 @@ export const useAlgos = (t, toggles) => { 'disabled': !toggles.SEMANTIC_SEARCH_TOGGLE, 'allow_multiple': false, 'lookup_required': false, - // Canonical concept identity (plans/unified-mapper-model.md). Concepts - // returned by ocl-semantic come from the project's target repo, so the - // canonical URL of each concept is the target repo's canonical URL. - 'concept_identity': { - 'reference_source': 'target_repo', - 'code_field': 'id', - 'ocl_url_field': 'url' - } + 'concept_identity': CONCEPT_IDENTITY_BY_TYPE['ocl-semantic'] }, { 'id': 'ocl-search', @@ -39,11 +70,7 @@ export const useAlgos = (t, toggles) => { 'disabled': false, 'allow_multiple': false, 'lookup_required': false, - 'concept_identity': { - 'reference_source': 'target_repo', - 'code_field': 'id', - 'ocl_url_field': 'url' - } + 'concept_identity': CONCEPT_IDENTITY_BY_TYPE['ocl-search'] }, ] return algos From b29d490c149199ba146e96983a6cd3670ee238e5 Mon Sep 17 00:00:00 2001 From: Jonathan Payne Date: Sat, 9 May 2026 11:19:35 -0400 Subject: [PATCH 3/4] OpenConceptLab/ocl_issues#2337 | PR2a: wire scispacy results into the unified-model normalizer The single-row scispacy path already routed through onResponse and the inline fromScispacyResultsToConcepts transform was already inside onResponse. The two missing pieces: - concept_identity for ocl-scispacy: added to CONCEPT_IDENTITY_BY_TYPE with reference_source='fixed', canonical_url='http://loinc.org', code_field='id'. getAlgoDef injects it on lookup since the scispacy algo definition comes from the OCL Online API and doesn't carry its own concept_identity. - Bulk path (fetchBulkScispacyCandidates) had its own callback that bypassed the normalizer. Added the normalizeAlgorithmInvocation + mergeIntoRowMatchState wiring (gated by UNIFIED_MODEL_ENABLED) to mirror what we did for the bulk bridge path in 0b881ef. Feature flag remains OFF. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/components/map-projects/MapProject.jsx | 28 ++++++++++++++++------ src/components/map-projects/algorithms.jsx | 8 +++++++ 2 files changed, 29 insertions(+), 7 deletions(-) diff --git a/src/components/map-projects/MapProject.jsx b/src/components/map-projects/MapProject.jsx index 6c8284c..f54b45c 100644 --- a/src/components/map-projects/MapProject.jsx +++ b/src/components/map-projects/MapProject.jsx @@ -1460,15 +1460,29 @@ const MapProject = () => { setLoadingMatches(true) await fetchScispacyCandidates(_rows[index], false, false, true, (response => { const _index = _rows[index].__index + const results = [{row: _rows[index], results: fromScispacyResultsToConcepts(get(response.data, index) || [])}] log({action: 'algo_finished', extras: {algo: algo.id}}, _index) markAlgo(_index, algo.id, 1) - setAllCandidates(prev => { - const newCandidates = {...prev} - const results = [{row: _rows[index], results: fromScispacyResultsToConcepts(get(response.data, index) || [])}] - newCandidates[algo.id] = [...reject(prev[algo.id], c => c.row.__index === _index), ...(results || [])] - lookupCandidates(algo.id, results) - return newCandidates - }) + setAllCandidates(prev => ({ + ...prev, + [algo.id]: [...reject(prev[algo.id], c => c.row.__index === _index), ...(results || [])] + })) + lookupCandidates(algo.id, results) + if(UNIFIED_MODEL_ENABLED) { + // Mirror the bulk-bridge wiring — the per-row scispacy path goes via + // onResponse, but the bulk path lives here. + const algoDef = getAlgoDef(algo.id) + const rowPayload = results[0] + if(rowPayload && algoDef) { + mergeIntoRowMatchState(_index, normalizeAlgorithmInvocation(rowPayload, { + algorithmId: algo.id, + algorithmConfig: algoDef, + projectContext: buildProjectContext(), + rowIndex: _index, + rawResponse: response + })) + } + } })); // wait for completion await new Promise(resolve => setTimeout(resolve, 500)); // 1s delay } diff --git a/src/components/map-projects/algorithms.jsx b/src/components/map-projects/algorithms.jsx index 575c373..f4f0dab 100644 --- a/src/components/map-projects/algorithms.jsx +++ b/src/components/map-projects/algorithms.jsx @@ -36,6 +36,14 @@ export const CONCEPT_IDENTITY_BY_TYPE = { 'code_field': 'cascade_target_concept_code', 'ocl_url_field': 'cascade_target_concept_url' } + }, + // Scispacy returns LOINC codes only — fixed canonical, no OCL URL on the + // result. fromScispacyResultsToConcepts in MapProject.jsx maps LOINC_NUM + // into `id` before normalization, so code_field='id' resolves correctly. + 'ocl-scispacy': { + 'reference_source': 'fixed', + 'canonical_url': 'http://loinc.org', + 'code_field': 'id' } } From 8aafd4e3b8fcd025f9952ad9e1990e3fa6e1cf3f Mon Sep 17 00:00:00 2001 From: Jonathan Payne Date: Sat, 9 May 2026 11:33:57 -0400 Subject: [PATCH 4/4] OpenConceptLab/ocl_issues#2337 | PR2a: AI Assistant payload v2 (Option A: additive) Add the v2 payload structure alongside the legacy `candidates` field in fetchRecommendation, sourced from allCandidates via the unified-model normalizer (so it works with the feature flag OFF). The current prompt template ignores the new fields and continues working unchanged. Once the prompt template is revised to read recommendable_concepts / bridge_context, the bridge-recommendation bug is fixed structurally: bridges live in bridge_context (never recommendable), and target-repo concepts in recommendable_concepts are deduped across algorithms with per-source evidence. MapProject.jsx: - New buildV2RecommendationPayload(rowIndex) helper inline before fetchRecommendation. Iterates selectedAlgoIds, runs normalizeAlgorithmInvocation per algo against allCandidates for the row, aggregates with richer-wins dedup, then projects into: - target_repo: from buildProjectContext (canonical_url + version) - recommendable_concepts: deduped target-repo concepts with per- source evidence[] including bridge provenance via 'via' - bridge_context: bridge intermediaries with target_concept_keys pointing back to recommendable_concepts entries they justify - fetchRecommendation payload spreads v2 fields when constructible, with payload_version: 'v2' so the prompt template can branch. - aiCandidateID export now reads canonical_reference.code first, falls back to legacy concept_id (for the period both prompt-template versions may be in flight). AICandidatesAnalysis.jsx: - getAlternateIds() and the primary_candidate display read canonical_reference.code first, fall back to legacy concept_id/id. The legacy `candidates` field, the concept_id fallback shims, and payload_version itself can all be removed in PR3 alongside the other legacy-shape cleanup once the prompt template revision is stable. Note for ocl-ai-assistant coordination: the server-side _to_essential allow-list at core/prompts/services.py:251 should add 'recommendable_ concepts' and 'bridge_context' so the new fields get the same field- stripping pass as 'candidates' / 'bridge_candidates'. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../map-projects/AICandidatesAnalysis.jsx | 5 +- src/components/map-projects/MapProject.jsx | 112 +++++++++++++++++- 2 files changed, 114 insertions(+), 3 deletions(-) diff --git a/src/components/map-projects/AICandidatesAnalysis.jsx b/src/components/map-projects/AICandidatesAnalysis.jsx index 1684629..47478a1 100644 --- a/src/components/map-projects/AICandidatesAnalysis.jsx +++ b/src/components/map-projects/AICandidatesAnalysis.jsx @@ -35,7 +35,8 @@ const AICandidatesAnalysis = ({ analysis, onClose, sx, isCoreUser }) => { const getAlternateIds = () => { const alternates = output?.alternative_candidates || [] - return compact(map(alternates, a => a?.concept_id || a?.id)).join(', ') + // v2 response prefers canonical_reference.code; legacy shape used concept_id/id. + return compact(map(alternates, a => a?.canonical_reference?.code || a?.concept_id || a?.id)).join(', ') } return ( @@ -83,7 +84,7 @@ const AICandidatesAnalysis = ({ analysis, onClose, sx, isCoreUser }) => { {t('map_project.primary')}: - {output?.primary_candidate?.concept_id || output?.primary_candidate?.id || '-'} + {output?.primary_candidate?.canonical_reference?.code || output?.primary_candidate?.concept_id || output?.primary_candidate?.id || '-'} diff --git a/src/components/map-projects/MapProject.jsx b/src/components/map-projects/MapProject.jsx index f54b45c..96b83b0 100644 --- a/src/components/map-projects/MapProject.jsx +++ b/src/components/map-projects/MapProject.jsx @@ -1884,7 +1884,8 @@ const MapProject = () => { let _repo = concept?.repo const aiRecommendation = get(analysis, index)?.output || get(analysis, index) const aiCandidate = get(aiRecommendation, 'primary_candidate') - const aiCandidateID = aiCandidate?.concept_id + // v2 response prefers canonical_reference.code; legacy shape used concept_id. + const aiCandidateID = aiCandidate?.canonical_reference?.code || aiCandidate?.concept_id const aiScore = compact([aiCandidate?.confidence_level, aiCandidate?.match_strength]).join(':') let candidates = getRowCandidatesForDownload(index) const getOutOfScopeSuggestions = () => { @@ -2740,6 +2741,103 @@ const MapProject = () => { } } + // Build the v2 AI Assistant payload sections (recommendable_concepts + + // bridge_context + target_repo) by running the unified-model normalizer + // over the legacy allCandidates for the row. Sourcing from allCandidates + // (rather than rowMatchState) means this works regardless of the + // UNIFIED_MODEL_ENABLED flag — the bridge-recommendation bug fix can ship + // with PR2a even though reads are still on legacy state. + // See plans/unified-mapper-model.md "AI Assistant payload (match-recommend)". + const buildV2RecommendationPayload = (rowIndex) => { + const projectContext = buildProjectContext() + if(!projectContext?.target_repo?.canonical_url) return null + + const 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 => { + 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 = [] + const bridge_context = [] + + defsByKey.forEach((def, key) => { + const isBridgeIntermediary = allNormCandidates.some(c => c.concept_key === key && c.type === 'bridge') + + if(isBridgeIntermediary) { + // Bridges are CONTEXT only — never recommendable. Their target_concept_keys + // tell the AI which recommendable_concepts they justify. + const bridgeCandidate = allNormCandidates.find(c => c.concept_key === key && c.type === 'bridge') + const target_concept_keys = [...new Set( + allNormCandidates + .filter(c => c.type === 'bridge_child' && c.bridge_concept_key === key) + .map(c => c.concept_key) + )] + bridge_context.push({ + concept_key: key, + canonical_reference: def.reference, + display_name: def.display_name, + score: bridgeCandidate?.score, + target_concept_keys + }) + } else if(def.reference?.url === targetCanonical) { + // Target-repo concepts only. Evidence shows which algorithms surfaced + // this concept (and via which bridge, if applicable). + const evidence = allNormCandidates + .filter(c => c.concept_key === key) + .map(c => { + const e = { + algorithm_id: c.algorithm_id, + candidate_type: c.type, + score: c.score, + highlights: c.highlights + } + if(c.type === 'bridge_child' && c.bridge_concept_key) + e.via = {bridge_concept_key: c.bridge_concept_key, map_type: c.map_type} + return e + }) + recommendable_concepts.push({ + concept_key: key, + canonical_reference: def.reference, + ocl_url: def.ocl_url, + display_name: def.display_name, + names: def.names, + descriptions: def.descriptions, + concept_class: def.concept_class, + datatype: def.datatype, + properties: def.properties, + evidence + }) + } + // Else: concept from a non-target, non-bridge source — skip + }) + + return { + target_repo: projectContext.target_repo, + recommendable_concepts, + bridge_context + } + } + const fetchRecommendation = async (_row) => { let __row = row; let __index = rowIndex; @@ -2762,12 +2860,24 @@ const MapProject = () => { markAlgo(__index, 'recommend', 0) let rowData = prepareRow(__row, true, true) + // Option A: additive. Keep the legacy `candidates` field for the + // current prompt template; spread v2 fields alongside for the next + // prompt-template revision (which will read recommendable_concepts + + // bridge_context and structurally exclude bridges from the + // recommendation pool, fixing the bridge-recommendation bug). + const v2 = buildV2RecommendationPayload(__index) const payload = { variables: { project: getProjectMetadata(), row: rowData.row, metadata: rowData.metadata, candidates: [..._candidates.map(c => omit(c, '_source'))], + ...(v2 ? { + payload_version: 'v2', + target_repo: v2.target_repo, + recommendable_concepts: v2.recommendable_concepts, + bridge_context: v2.bridge_context + } : {}) } } const service = APIService.new()