From 449fe3b81687d86099f9682e90970fb40d6fd596 Mon Sep 17 00:00:00 2001 From: Jonathan Payne Date: Fri, 16 Oct 2026 10:00:20 -0400 Subject: [PATCH 1/7] OpenConceptLab/ocl_issues#2908 | Add the recommendation rule: the OCL AI Assistant decides when it has analysed the current candidates, the score orders the rest getRowRecommendation returns a row's candidate order (the AI's pick, its alternates by score, then today's score buckets), what to propose and who chose it, and how the AI and the score agree. RECOMMEND and CONDITIONAL propose the AI's pick in any score bucket; REJECT and INSUFFICIENT propose nothing, even over a Recommended score. Without an analysis of the current candidate pool (the fingerprint check Auto Match already uses), the top score is proposed if it's Recommended, as before. A pick that isn't a target-repo candidate falls back to the score rule. Not wired in yet: the panel, the grid and Auto Match follow in the next commits. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01TGqXPeHFfYAdqAr7rmXwCv --- .../__tests__/recommendationRule.test.js | 334 ++++++++++++++++++ src/components/map-projects/viewBuilders.js | 127 +++++++ 2 files changed, 461 insertions(+) create mode 100644 src/components/map-projects/__tests__/recommendationRule.test.js diff --git a/src/components/map-projects/__tests__/recommendationRule.test.js b/src/components/map-projects/__tests__/recommendationRule.test.js new file mode 100644 index 0000000..84335dd --- /dev/null +++ b/src/components/map-projects/__tests__/recommendationRule.test.js @@ -0,0 +1,334 @@ +/** + * Tests for the Mapper's recommendation rule (OpenConceptLab/ocl_issues#2908): + * getRowRecommendation in viewBuilders.js. If the OCL AI Assistant has + * analysed the row's current candidates, it decides what is proposed and the + * score orders everything else; if it hasn't, the score decides. + * + * One test per row of the ticket's table, then the edge cases. + * + * Run with: npm test + */ + +import test from 'node:test' +import assert from 'node:assert/strict' + +import { + buildQualityRowViews, + getTargetRepoQualityViews, + getRowRecommendation +} from '../viewBuilders.js' + +const LOINC = 'http://loinc.org' +const keyOf = code => JSON.stringify([LOINC, code, '2.81']) +const defOf = (code, extra = {}) => ({ + key: keyOf(code), + reference: { url: LOINC, code, version: '2.81' }, + id: code, + ocl_url: `/orgs/Regenstrief/sources/LOINC/concepts/${code}/`, + display_name: `Concept ${code}`, + source: 'LOINC', + ...extra +}) + +// A row's candidates, one per [code, rerank score]; a null score is a +// candidate whose rerank hasn't landed. +const rowOf = scored => { + const candidates = {} + const conceptRows = {} + const conceptCache = {} + scored.forEach(([code, score], i) => { + const key = keyOf(code) + candidates[`c${i}`] = { id: `c${i}`, concept_key: key, algorithm_id: 'ocl-semantic', type: 'standard', score: 1 - i / 100 } + conceptRows[key] = { concept_key: key, ...(score === null ? {} : { rerank_score: score }) } + conceptCache[key] = defOf(code) + }) + return { rowState: { candidates, concept_rows: conceptRows }, conceptCache } +} + +// Hemoglobin (Blood) from the 2026-10-13 LOINC 2.81 test, plus a low-ranked +// and an unranked candidate. +const HEMOGLOBIN = [['110885-1', 99.42], ['718-7', 97.39], ['717-9', 90], ['2823-3', 44.74], ['99999-9', null]] +const candidatesScore = { recommended: 99, available: 70 } +const FINGERPRINT = '5-abcdef12' + +const aiCandidate = (code, extra = {}) => ({ concept_key: keyOf(code), canonical_reference: { url: LOINC, code, version: '2.81' }, ...extra }) +const analysisOf = ({ recommendation, pick = null, alternates = [], confidence = 'HIGH', fingerprint = FINGERPRINT } = {}) => ({ + output: { + recommendation, + primary_candidate: pick ? aiCandidate(pick, { confidence_level: confidence }) : null, + alternative_candidates: alternates.map((code, i) => aiCandidate(code, { rank: i + 1 })) + }, + ...(fingerprint ? { candidate_pool_fingerprint: fingerprint } : {}) +}) + +const decide = (scored, analyses, overrides = {}) => { + const { rowState, conceptCache } = rowOf(scored) + return getRowRecommendation({ + views: buildQualityRowViews(rowState, conceptCache), + analyses, + fingerprint: FINGERPRINT, + candidatesScore, + conceptCache, + ...overrides + }) +} + +const codes = views => (views || []).map(view => view.conceptDefinition.id) +const code = view => view?.conceptDefinition?.id ?? null +const sectionCodes = result => Object.fromEntries(Object.entries(result.sections).map(([name, views]) => [name, codes(views)])) + +const SCORE_SECTIONS = { + pick: [], + alternates: [], + recommended: ['110885-1'], + available: ['718-7', '717-9'], + lowRanked: ['2823-3'], + unranked: ['99999-9'] +} + +// ---------- The table: no analysis, failed, or out of date ---------- + +test('getRowRecommendation: no analysis proposes the top score, if Recommended (today\'s rule)', () => { + const result = decide(HEMOGLOBIN, undefined) + assert.equal(code(result.proposal), '110885-1') + assert.equal(result.chosenBy, 'score') + assert.equal(result.agreement, 'none') + assert.equal(result.verdict, null) + assert.deepEqual(sectionCodes(result), SCORE_SECTIONS) +}) + +test('getRowRecommendation: no analysis and a top score below Recommended proposes nothing', () => { + const result = decide([['718-7', 97.39], ['717-9', 90]], []) + assert.equal(result.proposal, null) + assert.equal(result.chosenBy, 'score') + assert.equal(result.agreement, 'none') +}) + +test('getRowRecommendation: a failed analysis leaves only an older one of other candidates, so the score decides', () => { + // A call that fails adds no entry: the latest is the one before it, which + // saw a different candidate pool. + const result = decide(HEMOGLOBIN, [analysisOf({ recommendation: 'RECOMMEND', pick: '718-7', fingerprint: '4-0ld0ld00' })]) + assert.equal(code(result.proposal), '110885-1') + assert.equal(result.chosenBy, 'score') + assert.equal(result.agreement, 'out_of_date') + assert.deepEqual(sectionCodes(result), SCORE_SECTIONS) +}) + +test('getRowRecommendation: an out-of-date analysis keeps its verdict and pick for display, but the score decides', () => { + const result = decide(HEMOGLOBIN, [analysisOf({ recommendation: 'REJECT', fingerprint: '6-c4a4ged0' })]) + assert.equal(result.isCurrent, false) + assert.equal(result.verdict, 'REJECT') + assert.equal(code(result.proposal), '110885-1') + assert.equal(result.chosenBy, 'score') + assert.equal(result.agreement, 'out_of_date') +}) + +test('getRowRecommendation: an analysis saved before candidate-pool fingerprints is out of date', () => { + const result = decide(HEMOGLOBIN, [analysisOf({ recommendation: 'RECOMMEND', pick: '718-7', fingerprint: null })]) + assert.equal(result.agreement, 'out_of_date') + assert.equal(code(result.proposal), '110885-1') + assert.equal(code(result.pick), '718-7') +}) + +test('getRowRecommendation: without the current pool\'s fingerprint no analysis is current', () => { + const result = decide(HEMOGLOBIN, [analysisOf({ recommendation: 'RECOMMEND', pick: '718-7' })], { fingerprint: null }) + assert.equal(result.agreement, 'out_of_date') + assert.equal(result.chosenBy, 'score') +}) + +// ---------- The table: RECOMMEND ---------- + +test('getRowRecommendation: RECOMMEND of the top score proposes it and agrees', () => { + const result = decide(HEMOGLOBIN, [analysisOf({ recommendation: 'RECOMMEND', pick: '110885-1', alternates: ['718-7'] })]) + assert.equal(code(result.proposal), '110885-1') + assert.equal(result.chosenBy, 'ai_assistant') + assert.equal(result.agreement, 'agrees') + assert.equal(result.pickRank, 1) + assert.equal(result.conditional, false) + assert.deepEqual(sectionCodes(result), { + pick: ['110885-1'], + alternates: ['718-7'], + recommended: [], + available: ['717-9'], + lowRanked: ['2823-3'], + unranked: ['99999-9'] + }) +}) + +test('getRowRecommendation: RECOMMEND of a lower-scored candidate proposes the AI\'s pick, a different pick', () => { + const result = decide(HEMOGLOBIN, [analysisOf({ recommendation: 'RECOMMEND', pick: '718-7', alternates: ['110885-1', '717-9'] })]) + assert.equal(code(result.proposal), '718-7') + assert.equal(result.chosenBy, 'ai_assistant') + assert.equal(result.agreement, 'different_pick') + assert.equal(code(result.top), '110885-1') + assert.equal(result.pickRank, 2) + assert.deepEqual(sectionCodes(result), { + pick: ['718-7'], + alternates: ['110885-1', '717-9'], + recommended: [], + available: [], + lowRanked: ['2823-3'], + unranked: ['99999-9'] + }) +}) + +test('getRowRecommendation: RECOMMEND proposes the AI\'s pick in any score bucket, even Low Ranked', () => { + // Potassium (Serum): the AI's pick sat under Low Ranked at 44.74%. + const result = decide([['2824-1', 72.5], ['2823-3', 44.74]], [analysisOf({ recommendation: 'RECOMMEND', pick: '2823-3' })]) + assert.equal(code(result.proposal), '2823-3') + assert.equal(result.chosenBy, 'ai_assistant') + assert.equal(result.agreement, 'different_pick') + assert.equal(result.pickRank, 2) +}) + +// ---------- The table: CONDITIONAL ---------- + +test('getRowRecommendation: CONDITIONAL proposes the AI\'s pick, marked conditional', () => { + const result = decide(HEMOGLOBIN, [analysisOf({ recommendation: 'CONDITIONAL', pick: '718-7', alternates: ['110885-1', '717-9'] })]) + assert.equal(code(result.proposal), '718-7') + assert.equal(result.chosenBy, 'ai_assistant') + assert.equal(result.agreement, 'different_pick') + assert.equal(result.conditional, true) + assert.equal(result.verdict, 'CONDITIONAL') +}) + +test('getRowRecommendation: CONDITIONAL of the top score agrees, marked conditional', () => { + const result = decide(HEMOGLOBIN, [analysisOf({ recommendation: 'CONDITIONAL', pick: '110885-1' })]) + assert.equal(code(result.proposal), '110885-1') + assert.equal(result.agreement, 'agrees') + assert.equal(result.conditional, true) +}) + +// ---------- The table: REJECT and INSUFFICIENT ---------- + +test('getRowRecommendation: REJECT proposes nothing, even when the top score is Recommended', () => { + const result = decide(HEMOGLOBIN, [analysisOf({ recommendation: 'REJECT', alternates: ['717-9'] })]) + assert.equal(result.proposal, null) + assert.equal(result.chosenBy, 'ai_assistant') + assert.equal(result.agreement, 'no_match') + assert.equal(result.verdict, 'REJECT') + assert.equal(code(result.top), '110885-1') + assert.equal(result.pick, null) + assert.deepEqual(sectionCodes(result), { + pick: [], + alternates: ['717-9'], + recommended: ['110885-1'], + available: ['718-7'], + lowRanked: ['2823-3'], + unranked: ['99999-9'] + }) +}) + +test('getRowRecommendation: INSUFFICIENT proposes nothing, even when the top score is Recommended', () => { + const result = decide(HEMOGLOBIN, [analysisOf({ recommendation: 'INSUFFICIENT' })]) + assert.equal(result.proposal, null) + assert.equal(result.chosenBy, 'ai_assistant') + assert.equal(result.agreement, 'not_enough') + assert.equal(result.verdict, 'INSUFFICIENT') + assert.deepEqual(sectionCodes(result), SCORE_SECTIONS) +}) + +test('getRowRecommendation: REJECT ignores a primary candidate the AI named anyway', () => { + const result = decide(HEMOGLOBIN, [analysisOf({ recommendation: 'REJECT', pick: '718-7' })]) + assert.equal(result.proposal, null) + assert.equal(result.pick, null) + assert.equal(result.agreement, 'no_match') +}) + +// ---------- Edge cases ---------- + +test('getRowRecommendation: an AI pick that isn\'t a target-repo candidate falls back to the score rule', () => { + const result = decide(HEMOGLOBIN, [analysisOf({ recommendation: 'RECOMMEND', pick: '12345-6', alternates: ['718-7'] })]) + assert.equal(code(result.proposal), '110885-1') + assert.equal(result.chosenBy, 'score') + assert.equal(result.agreement, 'pick_not_candidate') + assert.equal(result.pick, null) + assert.deepEqual(sectionCodes(result), SCORE_SECTIONS) +}) + +test('getRowRecommendation: a CONDITIONAL with no primary candidate falls back to the score rule', () => { + const result = decide(HEMOGLOBIN, [analysisOf({ recommendation: 'CONDITIONAL' })]) + assert.equal(code(result.proposal), '110885-1') + assert.equal(result.chosenBy, 'score') + assert.equal(result.agreement, 'pick_not_candidate') +}) + +test('getRowRecommendation: confidence is reported but doesn\'t change the proposal', () => { + const result = decide(HEMOGLOBIN, [analysisOf({ recommendation: 'RECOMMEND', pick: '718-7', confidence: 'LOW' })]) + assert.equal(result.confidence, 'LOW') + assert.equal(code(result.proposal), '718-7') + assert.equal(result.chosenBy, 'ai_assistant') +}) + +test('getRowRecommendation: alternates are ordered by score, skip non-candidates and the pick itself', () => { + const result = decide(HEMOGLOBIN, [analysisOf({ recommendation: 'RECOMMEND', pick: '718-7', alternates: ['2823-3', '12345-6', '718-7', '110885-1'] })]) + assert.deepEqual(codes(result.sections.alternates), ['110885-1', '2823-3']) + assert.deepEqual(codes(result.alternates), ['110885-1', '2823-3']) +}) + +test('getRowRecommendation: a pick tied with the top score agrees', () => { + const result = decide([['110885-1', 99.42], ['718-7', 99.42]], [analysisOf({ recommendation: 'RECOMMEND', pick: '718-7' })]) + assert.equal(result.agreement, 'agrees') + assert.equal(result.pickRank, 1) +}) + +test('getRowRecommendation: a pick that has no score yet is a different pick with no rank', () => { + const result = decide(HEMOGLOBIN, [analysisOf({ recommendation: 'RECOMMEND', pick: '99999-9' })]) + assert.equal(code(result.proposal), '99999-9') + assert.equal(result.agreement, 'different_pick') + assert.equal(result.pickRank, null) +}) + +test('getRowRecommendation: with no scored candidate there is no top score to disagree with', () => { + const result = decide([['718-7', null], ['717-9', null]], [analysisOf({ recommendation: 'RECOMMEND', pick: '717-9' })]) + assert.equal(code(result.proposal), '717-9') + assert.equal(result.top, null) + assert.equal(result.agreement, 'agrees') +}) + +test('getRowRecommendation: the latest analysis decides, not an earlier one', () => { + const result = decide(HEMOGLOBIN, [ + analysisOf({ recommendation: 'REJECT' }), + analysisOf({ recommendation: 'RECOMMEND', pick: '718-7' }) + ]) + assert.equal(code(result.proposal), '718-7') + assert.equal(result.agreement, 'different_pick') +}) + +test('getRowRecommendation: accepts a single analysis entry as well as the history', () => { + const result = decide(HEMOGLOBIN, analysisOf({ recommendation: 'RECOMMEND', pick: '718-7' })) + assert.equal(code(result.proposal), '718-7') +}) + +test('getRowRecommendation: resolves the AI\'s pick by its canonical reference when it has no concept_key', () => { + const entry = analysisOf({ recommendation: 'RECOMMEND' }) + entry.output.primary_candidate = { canonical_reference: { url: LOINC, code: '718-7' } } + const result = decide(HEMOGLOBIN, [entry]) + assert.equal(code(result.proposal), '718-7') +}) + +test('getRowRecommendation: an analysis without a known verdict counts as none', () => { + const result = decide(HEMOGLOBIN, [{ user: 'someone', timestamp: '2026-10-13T00:00:00Z', candidate_pool_fingerprint: FINGERPRINT }]) + assert.equal(result.agreement, 'none') + assert.equal(result.chosenBy, 'score') + assert.equal(code(result.proposal), '110885-1') +}) + +test('getRowRecommendation: no candidates, no proposal', () => { + const result = decide([], [analysisOf({ recommendation: 'RECOMMEND', pick: '718-7' })]) + assert.equal(result.proposal, null) + assert.equal(result.agreement, 'pick_not_candidate') + assert.equal(result.top, null) +}) + +// ---------- getTargetRepoQualityViews ---------- + +test('getTargetRepoQualityViews: keeps only the target repo\'s concepts', () => { + const { rowState, conceptCache } = rowOf([['718-7', 97.39]]) + const bridgeKey = JSON.stringify(['https://CIELterminology.org', '21', null]) + rowState.candidates.b0 = { id: 'b0', concept_key: bridgeKey, algorithm_id: 'ocl-bridge', type: 'bridge', score: 0.9 } + rowState.concept_rows[bridgeKey] = { concept_key: bridgeKey, rerank_score: 99.9 } + conceptCache[bridgeKey] = { key: bridgeKey, reference: { url: 'https://CIELterminology.org', code: '21' }, id: '21', ocl_url: '/orgs/CIEL/sources/CIEL/concepts/21/' } + assert.deepEqual(codes(getTargetRepoQualityViews(rowState, conceptCache, LOINC, '/orgs/Regenstrief/sources/LOINC/')), ['718-7']) + assert.equal(getTargetRepoQualityViews(rowState, conceptCache).length, 2) +}) diff --git a/src/components/map-projects/viewBuilders.js b/src/components/map-projects/viewBuilders.js index 411ce42..280ab47 100644 --- a/src/components/map-projects/viewBuilders.js +++ b/src/components/map-projects/viewBuilders.js @@ -16,6 +16,7 @@ // node:test. import { localizedDisplayName } from './conceptNames.js' +import { hasCurrentAnalysis } from './autoMatchRows.js' const compact = arr => (arr || []).filter(x => x != null && x !== false) const values = obj => Object.values(obj || {}) @@ -336,6 +337,16 @@ export const buildQualityRowViews = (rowState, conceptCache) => { })) } +/** + * The quality views of the row's target-repo concepts, the only ones that can + * be mapped. With no target repo known, every view. + */ +export const getTargetRepoQualityViews = (rowState, conceptCache, targetCanonical, targetRelativeUrl) => { + const views = buildQualityRowViews(rowState, conceptCache) + if(!targetCanonical && !targetRelativeUrl) return views + return views.filter(view => conceptBelongsToTargetRepo(view.conceptDefinition, targetCanonical, targetRelativeUrl)) +} + /** * Sort RowViews by the chosen key. `rerank_score` and `algo_score` are * numeric; `id` and `display_name` are alphabetical, `display_name` on the @@ -599,3 +610,119 @@ export const getAIAnalysisCandidateIDs = (analysisProp, conceptCache) => { alternateCandidateIds } } + +// The OCL AI Assistant's verdicts. primary_candidate is null for REJECT and +// INSUFFICIENT. +export const AI_VERDICTS = ['RECOMMEND', 'CONDITIONAL', 'REJECT', 'INSUFFICIENT'] + +const rerankScoreOf = view => view?.conceptRow?.rerank_score +const viewHasConceptKey = (view, key) => + view?.candidate?.concept_key === key || (view?.contributingCandidates || []).some(candidate => candidate?.concept_key === key) +const viewCode = view => view?.conceptDefinition?.reference?.code || view?.conceptDefinition?.id + +// The view of a candidate the AI named: by its concept_key, else by the code +// resolveAICandidateID gives it. +const findAICandidateView = (views, aiCandidate, conceptCache) => { + if(!aiCandidate) return null + const byKey = aiCandidate.concept_key ? views.find(view => viewHasConceptKey(view, aiCandidate.concept_key)) : null + if(byKey) return byKey + const code = resolveAICandidateID(aiCandidate, conceptCache) + return (code && views.find(view => viewCode(view) === code)) || null +} + +// Today's Match Quality buckets, each by score. +const toScoreSections = (views, candidatesScore) => { + const sections = { pick: [], alternates: [], recommended: [], available: [], lowRanked: [], unranked: [] } + sortRowViews(views, 'rerank_score', 'desc').forEach(view => { + const score = rerankScoreOf(view) + if(!isNumber(score)) sections.unranked.push(view) + else if(score >= candidatesScore?.recommended) sections.recommended.push(view) + else if(score >= candidatesScore?.available) sections.available.push(view) + else sections.lowRanked.push(view) + }) + return sections +} + +/** + * The Mapper's recommendation rule (OpenConceptLab/ocl_issues#2908). If the + * OCL AI Assistant has analysed the row's current candidates, it decides what + * is proposed, and the score orders everything else. If it hasn't, the score + * decides, as before. + * + * - RECOMMEND or CONDITIONAL: the AI's pick is proposed, whatever its score. + * A pick that isn't one of `views` falls back to the score rule + * (agreement 'pick_not_candidate', for the log). + * - REJECT or INSUFFICIENT: nothing is proposed, even over a Recommended + * score. The score reads names only. + * - No analysis, one without a known verdict, or one of an older candidate + * pool (the same fingerprint check Auto Match uses to re-run the AI): the + * top score is proposed if it's Recommended. + * + * Confidence is reported but changes nothing. + * + * @param {object} args + * @param {Array} args.views the row's target-repo quality views (getTargetRepoQualityViews) + * @param {Array|object} [args.analyses] the row's analysis history, or one entry; the latest decides + * @param {string} [args.fingerprint] the current candidate pool's fingerprint (getCandidatePoolFingerprint) + * @param {object} args.candidatesScore {recommended, available} thresholds + * @param {object} [args.conceptCache] resolves the AI's concept_keys to codes + * @returns {{ + * sections: {pick: Array, alternates: Array, recommended: Array, available: Array, lowRanked: Array, unranked: Array}, + * proposal: object|null, + * chosenBy: 'ai_assistant'|'score', + * agreement: 'none'|'out_of_date'|'agrees'|'different_pick'|'pick_not_candidate'|'no_match'|'not_enough', + * verdict: string|null, conditional: boolean, confidence: string|null, + * analysis: object|null, isCurrent: boolean, + * pick: object|null, alternates: Array, top: object|null, pickRank: number|null + * }} + * `sections` is the candidate order: the AI's pick, its alternates by score, + * then the rest in today's buckets by score. `pick` and `alternates` are + * resolved even when the analysis is out of date, for display; `top` is the + * highest-scored candidate and `pickRank` the pick's rank by score. + */ +export const getRowRecommendation = ({ views = [], analyses, fingerprint, candidatesScore, conceptCache } = {}) => { + const history = Array.isArray(analyses) ? analyses : (analyses ? [analyses] : []) + const analysis = getLatestAnalysisEntry(history) || null + const output = analysis?.output || analysis + const verdict = AI_VERDICTS.includes(output?.recommendation) ? output.recommendation : null + const isCurrent = hasCurrentAnalysis(history, fingerprint) + const byScore = sortRowViews(views, 'rerank_score', 'desc') + const top = byScore.find(view => isNumber(rerankScoreOf(view))) || null + const choosesPick = ['RECOMMEND', 'CONDITIONAL'].includes(verdict) + const pick = choosesPick ? findAICandidateView(views, output.primary_candidate, conceptCache) : null + const alternates = uniq(compact((output?.alternative_candidates || []).map(candidate => findAICandidateView(views, candidate, conceptCache)))) + .filter(view => view !== pick) + const pickScore = rerankScoreOf(pick) + const result = { + verdict, + conditional: verdict === 'CONDITIONAL', + confidence: (choosesPick && output?.primary_candidate?.confidence_level) || null, + analysis, + isCurrent, + pick, + alternates: sortRowViews(alternates, 'rerank_score', 'desc'), + top, + pickRank: isNumber(pickScore) ? 1 + views.filter(view => isNumber(rerankScoreOf(view)) && rerankScoreOf(view) > pickScore).length : null, + } + const byScoreRule = agreement => ({ + ...result, + sections: toScoreSections(views, candidatesScore), + proposal: top && rerankScoreOf(top) >= candidatesScore?.recommended ? top : null, + chosenBy: 'score', + agreement, + }) + if(!verdict) return byScoreRule('none') + if(!isCurrent) return byScoreRule('out_of_date') + if(choosesPick && !pick) return byScoreRule('pick_not_candidate') + const sections = toScoreSections(views.filter(view => view !== pick && !alternates.includes(view)), candidatesScore) + sections.pick = pick ? [pick] : [] + sections.alternates = result.alternates + const tiedWithTop = !top || pick === top || pickScore === rerankScoreOf(top) + return { + ...result, + sections, + proposal: pick, + chosenBy: 'ai_assistant', + agreement: { REJECT: 'no_match', INSUFFICIENT: 'not_enough' }[verdict] || (tiedWithTop ? 'agrees' : 'different_pick'), + } +} From 525f1e060264351097e98a5d620e5797dee3dec6 Mon Sep 17 00:00:00 2001 From: Jonathan Payne Date: Tue, 20 Oct 2026 17:18:29 -0400 Subject: [PATCH 2/7] OpenConceptLab/ocl_issues#2908 | Show the recommendation in the candidates panel and the grid Match Quality, sorted by score, now follows getRowRecommendation: the OCL AI Assistant's pick and its alternates first when it has analysed the row's current candidates, then the score buckets. A summary line gives the AI's verdict, its pick and the top score, and says whether they differ; "Read analysis" opens the full explanation. Group > AI Analysis is gone. The grid gains an "OCL AI Assistant" column (agreement state, running or failed, sortable and filterable, with the other side in its tooltip) and an "OCL AI Assistant disagrees (n)" filter. Target Code shows the AI icon only on the AI's pick, comparing URLs decoded, since a code with "&" arrives encoded once or twice. Match Quality stays score-only. Badges read "OCL AI Assistant's pick" and "OCL AI Assistant alternate". New strings in en, es, zh and pt-BR. A per-row cache replaces the best-candidate-score cache and also keys on the row's analyses; the candidate-pool fingerprint is built only for rows with analyses. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01HvdynUEUxPByhEuU5iFvtm --- src/components/concepts/ConceptHeader.jsx | 2 +- .../map-projects/AIAssistantVerdict.jsx | 155 ++++++++ src/components/map-projects/Candidates.jsx | 373 +++++------------- .../map-projects/DataGridControls.jsx | 22 ++ src/components/map-projects/MapProject.jsx | 329 ++++++++------- src/components/map-projects/Score.jsx | 2 +- .../__tests__/recommendationRule.test.js | 66 +++- .../map-projects/__tests__/rowQuality.test.js | 48 ++- src/components/map-projects/rowQuality.js | 43 +- src/components/map-projects/viewBuilders.js | 44 +++ src/i18n/__tests__/aiAssistantNaming.test.js | 17 +- src/i18n/locales/en/translations.json | 39 +- src/i18n/locales/es/translations.json | 39 +- src/i18n/locales/pt-BR/translations.json | 39 +- src/i18n/locales/zh/translations.json | 39 +- 15 files changed, 765 insertions(+), 492 deletions(-) create mode 100644 src/components/map-projects/AIAssistantVerdict.jsx diff --git a/src/components/concepts/ConceptHeader.jsx b/src/components/concepts/ConceptHeader.jsx index 6e9e6b3..d8222c7 100644 --- a/src/components/concepts/ConceptHeader.jsx +++ b/src/components/concepts/ConceptHeader.jsx @@ -141,7 +141,7 @@ const ConceptHeader = ({concept, repo, onClose, repoURL, onEdit, nested, loading color='primary' variant='outlined' icon={} - label={t('map_project.ai_recommended')} + label={t('map_project.ai_pick')} sx={{padding: '4px'}} /> } diff --git a/src/components/map-projects/AIAssistantVerdict.jsx b/src/components/map-projects/AIAssistantVerdict.jsx new file mode 100644 index 0000000..6cf0408 --- /dev/null +++ b/src/components/map-projects/AIAssistantVerdict.jsx @@ -0,0 +1,155 @@ +import React from 'react' +import { useTranslation } from 'react-i18next'; +import Chip from '@mui/material/Chip' +import CircularProgress from '@mui/material/CircularProgress' +import IconButton from '@mui/material/IconButton' +import Link from '@mui/material/Link' +import Tooltip from '@mui/material/Tooltip' +import Typography from '@mui/material/Typography' +import AssistantIcon from '@mui/icons-material/Assistant'; + +import { getScoreDetails } from './viewBuilders.js' + +// How the grid and the candidates panel put a row's recommendation +// (getRowRecommendation, OpenConceptLab/ocl_issues#2908) into words. + +const codeOf = view => view?.conceptDefinition?.id || view?.conceptDefinition?.reference?.code +const outputOf = recommendation => recommendation?.analysis?.output || recommendation?.analysis +// The code the OCL AI Assistant named, also when it isn't one of the candidates. +const pickCodeOf = recommendation => codeOf(recommendation?.pick) || outputOf(recommendation)?.primary_candidate?.canonical_reference?.code + +const CONFIDENCES = ['high', 'medium', 'low'] +const describeConfidence = (t, recommendation) => { + const confidence = recommendation?.confidence?.toLowerCase() + return CONFIDENCES.includes(confidence) ? t(`map_project.ai_confidence_${confidence}`) : null +} + +const describeTopScore = (t, top, candidatesScore) => { + if(!top) return t('map_project.ai_no_top_score') + const { qualityBucket, rerankScore } = getScoreDetails({candidate: top.candidate, conceptRow: top.conceptRow}, candidatesScore) + return t('map_project.ai_top_score', {code: codeOf(top), bucket: t(`map_project.${qualityBucket}`), score: rerankScore}) +} + +const describePickRank = (t, recommendation) => recommendation.pickRank ? + t('map_project.ai_pick_rank', {code: pickCodeOf(recommendation), rank: recommendation.pickRank}) : + t('map_project.ai_pick_unscored', {code: pickCodeOf(recommendation)}) + +const describeVerdict = (t, recommendation) => { + const code = pickCodeOf(recommendation) + const confidence = describeConfidence(t, recommendation) + const withConfidence = text => confidence ? `${text} (${confidence})` : text + switch(recommendation?.verdict) { + case 'RECOMMEND': return code ? withConfidence(t('map_project.ai_summary_recommend', {code})) : t('map_project.ai_summary_no_pick') + case 'CONDITIONAL': return code ? withConfidence(t('map_project.ai_summary_conditional', {code})) : t('map_project.ai_summary_no_pick') + case 'REJECT': return t('map_project.ai_summary_reject') + case 'INSUFFICIENT': return t('map_project.ai_summary_insufficient') + default: return '' + } +} + +const describeRelation = (t, recommendation) => { + switch(recommendation?.agreement) { + case 'agrees': return t('map_project.ai_summary_agree') + case 'different_pick': return recommendation.pickRank ? + t('map_project.ai_summary_differ_rank', {rank: recommendation.pickRank}) : + t('map_project.ai_summary_differ_unscored') + case 'no_match': + case 'not_enough': return t('map_project.ai_summary_no_proposal') + case 'out_of_date': return t('map_project.ai_out_of_date_help') + case 'pick_not_candidate': return t('map_project.ai_pick_not_candidate_help') + default: return '' + } +} + +// The status label, also the column's value for sorting and filtering. +export const getAIStatusLabel = (t, status, recommendation) => { + if(!status) return '' + const label = t(`map_project.ai_status_${status}`) + return recommendation?.conditional && ['agrees', 'different_pick'].includes(status) ? + `${label} (${t('map_project.ai_conditional')})` : label +} + +const STATUS_COLORS = { + agrees: 'primary', + different_pick: 'warning', + pick_not_candidate: 'warning', + no_match: 'error', + not_enough: 'error', +} + +// The tooltip names the other side: the top score, and where the pick ranks. +const getStatusTooltip = (t, status, recommendation, candidatesScore) => { + if(status === 'out_of_date') return t('map_project.ai_out_of_date_help') + if(status === 'pick_not_candidate') return t('map_project.ai_pick_not_candidate_help') + const lines = [describeTopScore(t, recommendation?.top, candidatesScore)] + if(status === 'different_pick') lines.push(describePickRank(t, recommendation)) + const confidence = describeConfidence(t, recommendation) + if(confidence) lines.push(confidence) + return lines.map(line =>
{line}
) +} + +/** + * The grid's "OCL AI Assistant" cell: running, failed (click to retry), or + * the agreement state, with the other side in its tooltip. + */ +export const AIAssistantStatusCell = ({ status, recommendation, candidatesScore, onRetry }) => { + const { t } = useTranslation(); + if(!status) return null + if(status === 'running') + return ( + + + + {getAIStatusLabel(t, status)} + + + ) + if(status === 'failed') + return ( + + { event.stopPropagation(); onRetry?.() }}> + + {getAIStatusLabel(t, status)} + + + ) + return ( + + + + ) +} + +/** + * The candidates panel's summary line: the OCL AI Assistant's verdict in plain + * words, its pick and the top score, and whether they differ. Shown whenever + * the row has an analysis. + */ +export const AIAssistantSummary = ({ recommendation, candidatesScore, onReadAnalysis, sx }) => { + const { t } = useTranslation(); + if(!recommendation?.verdict) return null + const text = t('map_project.ai_summary', { + verdict: describeVerdict(t, recommendation), + topScore: describeTopScore(t, recommendation.top, candidatesScore), + relation: describeRelation(t, recommendation), + }) + return ( +
+ + + {text} + { + onReadAnalysis && + + {t('map_project.read_analysis')} + + } + +
+ ) +} diff --git a/src/components/map-projects/Candidates.jsx b/src/components/map-projects/Candidates.jsx index 96e5493..e215635 100644 --- a/src/components/map-projects/Candidates.jsx +++ b/src/components/map-projects/Candidates.jsx @@ -45,6 +45,7 @@ import ConceptIcon from '../concepts/ConceptIcon' import MapButton from './MapButton' import AICandidatesAnalysis from './AICandidatesAnalysis' import AIAssistantButton from './AIAssistantButton' +import { AIAssistantSummary } from './AIAssistantVerdict' import { buildAlgorithmRowViews, getOrphanAlgorithmIds, @@ -96,7 +97,7 @@ const Sort = ({ selected, onSort }) => { ) } -const Group = ({ selected, onGroup, hasAIAnalysis }) => { +const Group = ({ selected, onGroup }) => { const { t } = useTranslation(); const [anchorEl, setAnchorEl] = React.useState(false) const onGroupClick = event => setAnchorEl(event.currentTarget) @@ -125,12 +126,6 @@ const Group = ({ selected, onGroup, hasAIAnalysis }) => { onClick('algorithm')} selected={selected === 'algorithm'}> - { - hasAIAnalysis && - onClick('ai_analysis')} selected={selected === 'ai_analysis'}> - - - } ) @@ -162,7 +157,7 @@ const SubHeader = ({count, onClick, isCollapsed, header, indicatorColor, indicat } -const CandidateList = ({rowViews, header, rowIndex, sortBy, order, openConceptPanel, showItem, isSelectedForMap, onMap, onFetchMore, bgColor, headerIndicatorIcon, bucketId, display, onDisplayChange, noToolbar, toolbarControl, repoVersion, alignToolbarLeft, rightControl, analysis, showAnalysis, openAnalysis, onCloseAnalysis, isInProgress, AIRecommendedCandidateId, AIAlternateCandidateIds, inputLocale, locales, scispacy, showAlgo, collapsed, onCollapse, candidatesScore, algoScoreFirst, byAlgorithm, showEmptyHeader, isFirst, targetCanonical, targetRelativeUrl, analysisPage, setAnalysisPage}) => { +const CandidateList = ({rowViews, header, rowIndex, sortBy, order, openConceptPanel, showItem, isSelectedForMap, onMap, onFetchMore, bgColor, headerIndicatorIcon, bucketId, display, onDisplayChange, noToolbar, toolbarControl, repoVersion, alignToolbarLeft, rightControl, analysis, showAnalysis, openAnalysis, onCloseAnalysis, isInProgress, summary, AIRecommendedCandidateId, AIAlternateCandidateIds, inputLocale, locales, scispacy, showAlgo, collapsed, onCollapse, candidatesScore, algoScoreFirst, byAlgorithm, showEmptyHeader, isFirst, targetCanonical, targetRelativeUrl, analysisPage, setAnalysisPage}) => { // Decorate rowViews so they work for BOTH renderers: // - Table view: SearchResults/TableResults reads legacy concept fields // (id, url, names, descriptions, source, search_meta, ...) via the @@ -275,16 +270,20 @@ const CandidateList = ({rowViews, header, rowIndex, sortBy, order, openConceptPa } }} subheader={ - (showAnalysis && openAnalysis) ? ( + (showAnalysis && (openAnalysis || summary)) ? (
- + {summary} + { + openAnalysis && + + } { showHeader && @@ -379,7 +378,7 @@ const CandidateList = ({rowViews, header, rowIndex, sortBy, order, openConceptPa // conceptCache — project-wide ConceptDefinition store, keyed by concept_key. // algosSelected — algorithm definitions (for headers/grouping). // (plans/unified-mapper-model.md "How the views map onto this model".) -const Candidates = ({rowIndex, rowState, conceptCache, targetCanonical, targetRelativeUrl, openConceptPanel, showItem, isSelectedForMap, onMap, onFetchMore, isLoading, candidatesScore, repoVersion, analysis, onFetchRecommendation, appliedFacets, setAppliedFacets, filters, facets, columns, defaultFilters, inputLocale, locales, models, selectedModel, onModelChange, promptTemplates, promptTemplate, onPromptTemplateChange, onRefreshClick, rowStage, inAIAssistantGroup, algosSelected, canSelectAIModel, capacityWait=false, throttle}) => { +const Candidates = ({rowIndex, rowState, conceptCache, targetCanonical, targetRelativeUrl, openConceptPanel, showItem, isSelectedForMap, onMap, onFetchMore, isLoading, candidatesScore, repoVersion, analysis, recommendation, onFetchRecommendation, appliedFacets, setAppliedFacets, filters, facets, columns, defaultFilters, inputLocale, locales, models, selectedModel, onModelChange, promptTemplates, promptTemplate, onPromptTemplateChange, onRefreshClick, rowStage, inAIAssistantGroup, algosSelected, canSelectAIModel, capacityWait=false, throttle}) => { const { t } = useTranslation(); const [sortBy, setSortBy] = React.useState('rerank_score') const [groupBy, setGroupBy] = React.useState('quality') @@ -388,8 +387,6 @@ const Candidates = ({rowIndex, rowState, conceptCache, targetCanonical, targetRe const [display, setDisplay] = React.useState('card') const [openAIAnalysis, setOpenAIAnalysis] = React.useState(undefined) const [analysisPage, setAnalysisPage] = React.useState(0) - const recommendedScore = candidatesScore?.recommended - const availableScore = candidatesScore?.available const analysisArray = Array.isArray(analysis) ? analysis : (analysis ? [analysis] : []) const analysisCount = analysisArray.length // Resolve the AI-recommended candidate against conceptCache: prefer the @@ -412,16 +409,10 @@ const Candidates = ({rowIndex, rowState, conceptCache, targetCanonical, targetRe React.useEffect(() => { setAnalysisPage(analysisCount > 0 ? analysisCount - 1 : 0) }, [rowIndex, analysisCount]) - React.useEffect(() => { - if(groupBy === 'ai_analysis' && analysisCount === 0) - setGroupBy('quality') - }, [analysisCount, groupBy]) const selectedAnalysis = isRecommendInProgress && analysisPage >= analysisCount ? undefined : analysisArray[analysisPage] - const selectedAnalysisForGrouping = selectedAnalysis || analysisArray[analysisCount - 1] const { primaryCandidateId: AIRecommendedCandidateId, alternateCandidateIds: AIAlternateCandidateIds } = getAIAnalysisCandidateIDs(selectedAnalysis, conceptCache) - const { primaryCandidateId: groupedAnalysisPrimaryId, alternateCandidateIds: groupedAnalysisAlternateIds } = getAIAnalysisCandidateIDs(selectedAnalysisForGrouping, conceptCache) // Quality (score-grouped) view shows ONLY target-repo concepts. Bridge // intermediaries live in algorithm view as metadata-about-the-target; @@ -490,11 +481,16 @@ const Candidates = ({rowIndex, rowState, conceptCache, targetCanonical, targetRe onFetchRecommendation() } + const onReadAnalysis = () => { + setAnalysisPage(Math.max(0, analysisCount - 1)) + setOpenAIAnalysis(true) + } + const onGroup = option => { setGroupBy(option) if(option === 'algorithm') setSortBy('algo_score') - if(!option || option === 'quality' || option === 'ai_analysis') + if(!option || option === 'quality') setSortBy('rerank_score') } @@ -538,66 +534,30 @@ const Candidates = ({rowIndex, rowState, conceptCache, targetCanonical, targetRe })) } } - if(groupBy === 'ai_analysis') { - const sorted = sortRowViews(qualityRowViews, 'rerank_score', 'desc') - const primaryCandidate = [] - const alternateCandidates = [] - const rest = [] - sorted.forEach(view => { - const code = view?.conceptDefinition?.reference?.code - if(groupedAnalysisPrimaryId && code === groupedAnalysisPrimaryId) { - primaryCandidate.push(view) - return - } - if(groupedAnalysisAlternateIds.includes(code)) { - alternateCandidates.push(view) - return - } - rest.push(view) - }) - return { primaryCandidate, alternateCandidates, rest } + // Match Quality by score: the OCL AI Assistant's pick and its alternates + // when it decides, then today's buckets, as getRowRecommendation orders + // them (OpenConceptLab/ocl_issues#2908). By ID or name: one list, with + // badges only. + if(!byScore) + return { qualitySections: [{id: 'available', rowViews: sortRowViews(qualityRowViews, sortBy, order, inputLocale), header: t('map_project.all_candidates')}] } + const sections = recommendation?.sections || {} + const aiIcon = color => + return { + qualitySections: [ + {id: 'ai-pick', rowViews: sections.pick, header: t('map_project.ai_pick'), headerIndicatorIcon: aiIcon('primary'), onlyIfAny: true}, + {id: 'ai-alternates', rowViews: sections.alternates, header: t('map_project.alternates'), headerIndicatorIcon: aiIcon('warning'), onlyIfAny: true}, + {id: 'recommended', rowViews: sections.recommended, header: t('map_project.recommended_candidates'), bgColor: SCORES_COLOR.recommended}, + {id: 'available', rowViews: sections.available, header: t('map_project.available_candidates'), bgColor: SCORES_COLOR.available}, + {id: 'low-ranked', rowViews: sections.lowRanked, header: t('map_project.low_ranked_candidates'), bgColor: SCORES_COLOR.low_ranked}, + // Candidates whose rerank hasn't landed yet. They move into the score + // buckets once a numeric rerank_score arrives on their ConceptRow. + // Neutral gray, so it reads as "in progress" rather than "poor match". + {id: 'pending-rerank', rowViews: sections.unranked, header: t('map_project.unranked_candidates', 'Unranked Candidates'), bgColor: SCORES_COLOR.pending_rerank, onlyIfAny: true}, + ].map(section => ({...section, rowViews: section.rowViews || []})).filter(section => !section.onlyIfAny || section.rowViews.length) } - let recommended = [] - let available = [] - let lowRanked = [] - let pendingRerank = [] - sortRowViews(qualityRowViews, sortBy, order, inputLocale).forEach(view => { - const score = view.conceptRow?.rerank_score - if(byScore) { - // Bucket on score presence first: unscored rows belong in their - // own "pending rerank" group — not in low_ranked. Falling them - // into low_ranked (the prior `score || 0` behavior) mis-implied - // they were poor matches; they're simply not ranked yet. Rerank - // landing will re-bucket them naturally on the next render. - if(typeof score !== 'number') pendingRerank.push(view) - else if(score >= recommendedScore) recommended.push(view) - else if(score >= availableScore) available.push(view) - else lowRanked.push(view) - } else { - available.push(view) - } - }) - return { recommended, available, lowRanked, pendingRerank } } - const { byAlgoCandidates, recommended, available, lowRanked, pendingRerank, primaryCandidate, alternateCandidates: aiAlternateCandidates, rest } = getCandidates() - const aiPrimaryBucketId = `${rowIndex}-ai-primary` - const aiAlternatesBucketId = `${rowIndex}-ai-alternates` - const aiRestBucketId = `${rowIndex}-ai-rest` - const hasAIPrimaryCandidates = (primaryCandidate || []).length > 0 - const hasAIAlternateCandidates = (aiAlternateCandidates || []).length > 0 - - React.useEffect(() => { - if(groupBy !== 'ai_analysis') return - setCollapsed(prev => { - const withoutAIBuckets = prev.filter(id => ![aiPrimaryBucketId, aiAlternatesBucketId, aiRestBucketId].includes(id)) - const nextCollapsed = [...withoutAIBuckets] - if(hasAIPrimaryCandidates || hasAIAlternateCandidates) - nextCollapsed.push(aiRestBucketId) - return nextCollapsed - }) - }, [groupBy, rowIndex, hasAIPrimaryCandidates, hasAIAlternateCandidates]) - + const { byAlgoCandidates, qualitySections } = getCandidates() const getRightControls = () => { return ( @@ -622,11 +582,8 @@ const Candidates = ({rowIndex, rowState, conceptCache, targetCanonical, targetRe { !noCandidatesFound && <> - 0} /> - { - groupBy !== 'ai_analysis' && - - } + + } { @@ -652,8 +609,11 @@ const Candidates = ({rowIndex, rowState, conceptCache, targetCanonical, targetRe ) } + // The summary line stands for the analysis; its full explanation opens on + // "Read analysis" or a new run, and stays open across rows once opened. An + // analysis with no verdict has no summary line, so it opens. React.useEffect(() => { - setOpenAIAnalysis(isEmpty(analysis) ? false : (openAIAnalysis !== false)) + setOpenAIAnalysis(isEmpty(analysis) ? false : (openAIAnalysis === true || !recommendation?.verdict)) }, [rowIndex]) React.useEffect(() => { @@ -662,6 +622,27 @@ const Candidates = ({rowIndex, rowState, conceptCache, targetCanonical, targetRe }, [isRecommendInProgress]) + const filtersButton = ( + + ) + const toolbarProps = { + onDisplayChange: setDisplay, + toolbarControl: filtersButton, + alignToolbarLeft: true, + rightControl: getRightControls(), + analysis: analysis, + showAnalysis: true, + openAnalysis: Boolean(openAIAnalysis), + onCloseAnalysis: () => setOpenAIAnalysis(false), + isInProgress: isRecommendInProgress, + summary: + } + const firstQualitySectionWithViews = (qualitySections || []).findIndex(section => section.rowViews.length > 0) + return (
@@ -702,172 +683,30 @@ const Candidates = ({rowIndex, rowState, conceptCache, targetCanonical, targetRe > { groupBy === 'quality' && - <> -
  • - setOpenFilters(!openFilters)} disabled={isEmpty(facets)}> - values(v))).length} color='primary'> - - - - } - alignToolbarLeft - rightControl={getRightControls()} - analysis={analysis} - showAnalysis - openAnalysis={Boolean(openAIAnalysis)} - onCloseAnalysis={() => setOpenAIAnalysis(false)} - isInProgress={isRecommendInProgress} - showAlgo - collapsed={collapsed} - onCollapse={setCollapsed} - candidatesScore={candidatesScore} - isFirst={recommended.length > 0} - /> -
  • -
  • - { - (isLoading && isNoneLoaded) ? - : - 0} - /> - } -
  • -
  • - { - (isLoading && isNoneLoaded) ? - : - 0} - /> - } -
  • -
  • - { - // Transient bucket for candidates whose rerank hasn't landed - // yet. They reorganize into recommended/available/low_ranked - // automatically once a numeric rerank_score arrives on their - // ConceptRow. Neutral gray indicator so it visually reads as - // "in progress" rather than "poor match". - pendingRerank.length > 0 && - - } -
  • - - } - { - groupBy === 'ai_analysis' && - <> -
  • - } - onFetchMore={onFetchMore} - bgColor={SCORES_COLOR.recommended} - bucketId={aiPrimaryBucketId} - noToolbar={false} - onDisplayChange={setDisplay} - toolbarControl={ - - } - alignToolbarLeft - rightControl={getRightControls()} - analysis={analysis} - showAnalysis - openAnalysis={Boolean(openAIAnalysis)} - onCloseAnalysis={() => setOpenAIAnalysis(false)} - isInProgress={isRecommendInProgress} - showAlgo - showEmptyHeader - collapsed={collapsed} - onCollapse={setCollapsed} - candidatesScore={candidatesScore} - isFirst - /> + qualitySections.map((section, i) => ( +
  • + { + (i > 0 && isLoading && isNoneLoaded) ? + : + + }
  • -
  • - } - onFetchMore={onFetchMore} - bgColor={SCORES_COLOR.available} - bucketId={aiAlternatesBucketId} - noToolbar - showAlgo - showEmptyHeader - collapsed={collapsed} - onCollapse={setCollapsed} - candidatesScore={candidatesScore} - /> -
  • -
  • - -
  • - + )) } { groupBy === 'algorithm' && @@ -900,29 +739,7 @@ const Candidates = ({rowIndex, rowState, conceptCache, targetCanonical, targetRe collapsed={collapsed} onCollapse={setCollapsed} candidatesScore={candidatesScore} - { - ...( - i === 0 ? - { - onDisplayChange: setDisplay, - toolbarControl: ( - - ), - alignToolbarLeft: true, - rightControl: getRightControls(), - analysis: analysis, - showAnalysis: true, - openAnalysis: Boolean(openAIAnalysis), - onCloseAnalysis: () => setOpenAIAnalysis(false), - isInProgress: isRecommendInProgress - } : - {} - ) - } + {...(i === 0 ? toolbarProps : {})} /> ) diff --git a/src/components/map-projects/DataGridControls.jsx b/src/components/map-projects/DataGridControls.jsx index dbf9ce2..a5116ef 100644 --- a/src/components/map-projects/DataGridControls.jsx +++ b/src/components/map-projects/DataGridControls.jsx @@ -6,11 +6,13 @@ import ListItemIcon from '@mui/material/ListItemIcon'; import ListItemText from '@mui/material/ListItemText'; import MenuItem from '@mui/material/MenuItem'; import Select from '@mui/material/Select'; +import Tooltip from '@mui/material/Tooltip'; import Typography from '@mui/material/Typography'; import DoneIcon from '@mui/icons-material/Done'; import CloseIcon from '@mui/icons-material/Close'; import ClearIcon from '@mui/icons-material/Clear'; +import AssistantIcon from '@mui/icons-material/Assistant'; import debounce from 'lodash/debounce' import filter from 'lodash/filter' @@ -44,6 +46,9 @@ const DataGridControls = ({ recommendedCount, availableCount, lowRankedCount, + aiDisagreesCount, + aiDisagreesSelected, + onAIDisagreesClick, decisions, decisionFilters, setDecisionFilters, @@ -111,6 +116,23 @@ const DataGridControls = ({ available={availableCount} low_ranked={lowRankedCount} /> + { + // OpenConceptLab/ocl_issues#2908: rows marked Different pick, No + // match or Not enough to decide in the OCL AI Assistant column. + (aiDisagreesCount > 0 || aiDisagreesSelected) && + + } + color='warning' + size='small' + variant={aiDisagreesSelected ? 'filled' : 'outlined'} + onClick={onAIDisagreesClick} + sx={{marginRight: '8px'}} + /> + + }
    { selectedRowStatus === 'unmapped' && diff --git a/src/components/map-projects/MapProject.jsx b/src/components/map-projects/MapProject.jsx index 1aa0b99..870814a 100644 --- a/src/components/map-projects/MapProject.jsx +++ b/src/components/map-projects/MapProject.jsx @@ -13,7 +13,6 @@ import { getAppTitle } from '../app/DocumentTitle' import Paper from '@mui/material/Paper' import Button from '@mui/material/Button'; -import CircularProgress from '@mui/material/CircularProgress'; import Typography from '@mui/material/Typography'; import IconButton from '@mui/material/IconButton' import Tabs from '@mui/material/Tabs'; @@ -105,7 +104,7 @@ import ImportToCollection from './ImportToCollection' import ProjectLogs from './ProjectLogs'; import { useAlgos, ensureConceptIdentity } from './algorithms' import { applyRequestSettings, getRequestLimits, getRequestSettings } from './requestLimits' -import { countQualityBuckets, createBestCandidateScoreCache, filterRowsByQualityBucket, getRowQualities } from './rowQuality' +import { countQualityBuckets, createRowRecommendationCache, filterRowsByQualityBucket, getRowQualities } from './rowQuality' import AutoMatchDialog from './AutoMatchDialog' import PreviewLimitDialog from './PreviewLimitDialog' import QuotaDialog from '../common/QuotaDialog' @@ -119,7 +118,8 @@ import { normalizeAlgorithmInvocation, hasSuccessfulAlgorithmResponse, getAlgori import { parseConceptKey } from './conceptKey' import { normalizeLookupConfig } from './lookupConfigFields' import { getDeclaredLocales, getDefaultTargetRepoVersion, getProjectTargetRepoVersion, getSavedTargetRepoVersion, getTargetRepoVersionFromUrl, getTargetRepoVersionId } from './projectTargetRepo' -import { buildBridgeTargetDownloadEntries, buildQualityRowViews, conceptBelongsToTargetRepo, conceptForMapping, findTargetRowView, formatBridgeTargetDownloadEntry, resolveAICandidateID, getScoreDetails, getAIAnalysisCandidateIDs, withCachedConcept, withCandidateNames, withOwnNames, withSavedMatch } from './viewBuilders.js' +import { buildBridgeTargetDownloadEntries, buildQualityRowViews, conceptBelongsToTargetRepo, conceptForMapping, findTargetRowView, formatBridgeTargetDownloadEntry, resolveAICandidateID, getScoreDetails, getAIAnalysisCandidateIDs, getRowAIStatus, getRowRecommendation, getTargetRepoQualityViews, isAIDisagreement, isAIPickTarget, withCachedConcept, withCandidateNames, withOwnNames, withSavedMatch } from './viewBuilders.js' +import { AIAssistantStatusCell, getAIStatusLabel } from './AIAssistantVerdict' import { formatClockTime, getCapacityWaitLabel, mergeCapacityWaits } from './rowProgress.js' import './MapProject.scss' @@ -288,6 +288,7 @@ const MapProject = () => { const [decisionTab, setDecisionTab] = React.useState('candidates') const [searchText, setSearchText] = React.useState('') // csv row search const [selectedCandidatesScoreBucket, setSelectedCandidatesScoreBucket] = React.useState(false) + const [aiDisagreesFilter, setAIDisagreesFilter] = React.useState(false) const [scoreBucketSortBy, setScoreBucketSortBy] = React.useState('desc') const [matchDialog, setMatchDialog] = React.useState(false) @@ -1036,18 +1037,6 @@ const MapProject = () => { return qualityBucket ? t('map_project.' + qualityBucket) : t('map_project.unranked') } - const getRowAIIndicatorMeta = (index, targetConcept) => { - const status = rowStageRef.current[index]?.recommend - const { latestAnalysis, primaryCandidateId, alternateCandidateIds } = getAIAnalysisCandidateIDs(analysis[index], conceptCacheRef.current) - const targetCode = targetConcept?.id || targetConcept?.reference?.code - return { - status, - latestAnalysis, - isPrimaryMatch: Boolean(targetCode && primaryCandidateId && targetCode === primaryCandidateId), - isAlternateMatch: Boolean(targetCode && alternateCandidateIds.includes(targetCode)), - } - } - const getColumnsForTable = () => { let cols = [] forEach(columns, (column, idx) => { @@ -1128,30 +1117,16 @@ const MapProject = () => { width: columnWidth['_targetCode_'] || 360, renderCell: params => { const targetConcept = getConcept(mapSelected[params.row.__index]) - const aiMeta = AI_ASSISTANT_API_URL ? getRowAIIndicatorMeta(params.row.__index, targetConcept) : null if(targetConcept?.id) { return ( { - aiMeta?.status === 0 ? - - - - - : - aiMeta?.status === -2 ? - - { e.stopPropagation(); fetchRecommendation(params.row) }}> - - - : - aiMeta?.latestAnalysis ? - + AI_ASSISTANT_API_URL && isAIPickTarget(rowRecommendation(params.row.__index), targetConcept) && + - + - : - null + } @@ -1219,6 +1194,32 @@ const MapProject = () => { ) } }) + // The OCL AI Assistant's agreement with the top score, or its call running + // or failed (OpenConceptLab/ocl_issues#2908): for users who can run it, or + // on a project that has analyses. + if(showAIAssistantColumn) + cols.push({ + field: '_aiAssistant_', + headerName: t('map_project.ocl_ai_assistant'), + width: columnWidth['_aiAssistant_'] || 210, + align: 'left', + headerAlign: 'left', + valueGetter: (_, row) => { + const recommendation = rowRecommendation(row.__index) + return getAIStatusLabel(t, getRowAIStatus(recommendation, rowStageRef.current[row.__index]?.recommend), recommendation) + }, + renderCell: params => { + const recommendation = rowRecommendation(params.row.__index) + return ( + fetchRecommendation(params.row)} + /> + ) + } + }) cols.push({ field: '_bestScore_', headerName: t('search.search_score'), @@ -1410,6 +1411,7 @@ const MapProject = () => { setSelectedRowIds([]) setAlert(false) setSelectedCandidatesScoreBucket(false) + setAIDisagreesFilter(false) setScoreBucketSortBy('desc') rowThrottlesRef.current = {} setRowStage({}) @@ -2216,6 +2218,125 @@ const MapProject = () => { return service } + // Build the v2 AI Assistant payload sections (recommendable_concepts + + // bridge_context + target_repo) by reading directly from rowMatchState + + // conceptCache. 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 rowState = rowMatchStateRef.current?.[rowIndex] + if(!rowState || !Object.keys(rowState.candidates || {}).length) return null + + const allNormCandidates = Object.values(rowState.candidates) + const defsByKey = new Map() + allNormCandidates.forEach(cand => { + const def = conceptCacheRef.current[cand.concept_key] + if(!def) return + const existing = defsByKey.get(def.key) + 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) + } + }) + + const targetCanonical = projectContext.target_repo.canonical_url + // Project-pinned identifying property codes. When absent (FHIR-passthrough + // sources or repos that haven't pinned a summary), filterPropertyBySummary + // passes def.property through whole. + const summaryPropertyCodes = repoVersion?.meta?.display?.concept_summary_properties + // PR3-H: union of (input_locale, filters.locale). Empty set = no locale + // filter applied (backwards-compatible for projects with neither set). + const filterLocaleString = (filters?.locale || '') + const filterLocales = filterLocaleString ? filterLocaleString.split(',').map(s => s.trim()).filter(Boolean) : [] + const effectiveLocales = [ + ...(inputLocale ? [inputLocale] : []), + ...filterLocales + ] + 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(conceptBelongsToTargetRepo(def, targetCanonical, projectContext.target_repo.relative_url)) { + // 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, bridge_map_type: c.map_type} + return e + }) + recommendable_concepts.push(buildRecommendableConceptEntry({ + def, key, evidence, rowState, summaryPropertyCodes, effectiveLocales + })) + } + // Else: concept from a non-target, non-bridge source — skip + }) + + // PR3-D3-lite (L-3): drop `concept_class` / `datatype` from every entry + // when constant across the surfaced set. LOINC always emits both as + // constants ("LOINC" / "Nom"); for mixed-class repos the fields stay. + stripConstantClassAndDatatype(recommendable_concepts) + + // PR3-D3-lite (L-6): strip `canonical_url_source` from the target_repo + // projection. The field is admin metadata (tells UI whether the canonical + // was explicit on the repo vs. derived) — the LLM has no use for it. + // eslint-disable-next-line no-unused-vars + const { canonical_url_source: _src, ...aiTargetRepo } = projectContext.target_repo + return { + target_repo: aiTargetRepo, + recommendable_concepts, + bridge_context + } + } + + // A row's recommendation (OpenConceptLab/ocl_issues#2908): the rule in + // getRowRecommendation, given the row's target-repo candidates with their + // scores and the row's OCL AI Assistant analyses. The candidate-pool + // fingerprint, which says whether the latest analysis saw these candidates, + // is only built for a row that has analyses. Until the target repo is known + // no candidate can be mapped, so there are none to recommend. + const getRowRecommendationFor = (index, analyses = analysis[index]) => { + const targetRepo = buildProjectContext()?.target_repo + const hasTargetRepo = Boolean(targetRepo?.canonical_url || targetRepo?.relative_url) + const history = Array.isArray(analyses) ? analyses : (analyses ? [analyses] : []) + return getRowRecommendation({ + views: hasTargetRepo ? getTargetRepoQualityViews(rowMatchStateRef.current[index], conceptCacheRef.current, targetRepo.canonical_url, targetRepo.relative_url) : [], + analyses: history, + fingerprint: history.length ? getCandidatePoolFingerprint(buildV2RecommendationPayload(index)?.recommendable_concepts) : null, + candidatesScore, + conceptCache: conceptCacheRef.current, + }) + } + // 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. @@ -2243,20 +2364,26 @@ const MapProject = () => { return orderBy(eligible, [v => v.conceptRow?.rerank_score ?? -1], ['desc'])[0] } - // Each row's best target-repo candidate score, for the grid's match quality - // (ocl_issues#2837). A row is recomputed only when its candidates, a concept - // they use, or the target repo changes. - const bestCandidateScoreCacheRef = React.useRef(null) - if(!bestCandidateScoreCacheRef.current) - bestCandidateScoreCacheRef.current = createBestCandidateScoreCache() - const getBestCandidateScoreGetter = () => { + // Each row's recommendation, for the grid: its match quality + // (ocl_issues#2837), its OCL AI Assistant column, Target Code icon and + // filter. A row is recomputed only when its candidates, a concept they use, + // its analyses or the project context change. + const rowRecommendationCacheRef = React.useRef(null) + if(!rowRecommendationCacheRef.current) + rowRecommendationCacheRef.current = createRowRecommendationCache() + const getRowRecommendationGetter = () => { const targetRepo = buildProjectContext()?.target_repo - return bestCandidateScoreCacheRef.current.getter({ + return rowRecommendationCacheRef.current.getter({ rows: rowMatchStateRef.current, concepts: conceptCacheRef.current, - targetKey: `${targetRepo?.canonical_url || ''}|${targetRepo?.relative_url || ''}`, - // this render's pickTopRowView, which sees this render's target repo - computeScore: index => pickTopRowView(index)?.conceptRow?.rerank_score, + analyses: analysis, + // what the rule and the candidate-pool fingerprint read besides the row + contextKey: JSON.stringify([ + targetRepo?.canonical_url, targetRepo?.relative_url, candidatesScore?.recommended, candidatesScore?.available, + repoVersion?.meta?.display?.concept_summary_properties, inputLocale, filters?.locale + ]), + // this render's rule, which sees this render's project context + compute: index => getRowRecommendationFor(index), }) } @@ -3125,18 +3252,26 @@ const MapProject = () => { rows = filter(rows, row => indexes.includes(row.__index.toString())) } rows = filterRowsByQualityBucket(rows, rowQualities, selectedCandidatesScoreBucket, scoreBucketSortBy) + if(aiDisagreesFilter && aiDisagreesCount) + rows = filter(rows, row => isAIDisagreement(rowRecommendation(row.__index))) return rows } // Rows with a proposed match count by its score; rows with candidates and no // decision yet, by their best candidate (ocl_issues#2837). + const rowRecommendation = getRowRecommendationGetter() const rowQualities = getRowQualities({ rowIndexes: data?.length ? map(data, '__index') : [], mapSelected, decisions, - getBestCandidateScore: getBestCandidateScoreGetter(), + // Match Quality stays score-only (OpenConceptLab/ocl_issues#2908) + getBestCandidateScore: index => rowRecommendation(index)?.top?.conceptRow?.rerank_score, }, candidatesScore) const { recommended: recommendedCount, available: availableCount, low_ranked: lowRankedCount } = countQualityBuckets(rowQualities) + // Rows where the OCL AI Assistant picked another candidate than the top + // score, or found no match or too little to decide. + const showAIAssistantColumn = Boolean(AI_ASSISTANT_API_URL) && (inAIAssistantGroup || !isEmpty(analysis)) + const aiDisagreesCount = showAIAssistantColumn ? filter(data, row => isAIDisagreement(rowRecommendation(row.__index))).length : 0 const getStateFromIndex = index => { if(rowStatuses.reviewed.includes(index)) @@ -5656,106 +5791,6 @@ const MapProject = () => { } } - // Build the v2 AI Assistant payload sections (recommendable_concepts + - // bridge_context + target_repo) by reading directly from rowMatchState + - // conceptCache. 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 rowState = rowMatchStateRef.current?.[rowIndex] - if(!rowState || !Object.keys(rowState.candidates || {}).length) return null - - const allNormCandidates = Object.values(rowState.candidates) - const defsByKey = new Map() - allNormCandidates.forEach(cand => { - const def = conceptCacheRef.current[cand.concept_key] - if(!def) return - const existing = defsByKey.get(def.key) - 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) - } - }) - - const targetCanonical = projectContext.target_repo.canonical_url - // Project-pinned identifying property codes. When absent (FHIR-passthrough - // sources or repos that haven't pinned a summary), filterPropertyBySummary - // passes def.property through whole. - const summaryPropertyCodes = repoVersion?.meta?.display?.concept_summary_properties - // PR3-H: union of (input_locale, filters.locale). Empty set = no locale - // filter applied (backwards-compatible for projects with neither set). - const filterLocaleString = (filters?.locale || '') - const filterLocales = filterLocaleString ? filterLocaleString.split(',').map(s => s.trim()).filter(Boolean) : [] - const effectiveLocales = [ - ...(inputLocale ? [inputLocale] : []), - ...filterLocales - ] - 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(conceptBelongsToTargetRepo(def, targetCanonical, projectContext.target_repo.relative_url)) { - // 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, bridge_map_type: c.map_type} - return e - }) - recommendable_concepts.push(buildRecommendableConceptEntry({ - def, key, evidence, rowState, summaryPropertyCodes, effectiveLocales - })) - } - // Else: concept from a non-target, non-bridge source — skip - }) - - // PR3-D3-lite (L-3): drop `concept_class` / `datatype` from every entry - // when constant across the surfaced set. LOINC always emits both as - // constants ("LOINC" / "Nom"); for mixed-class repos the fields stay. - stripConstantClassAndDatatype(recommendable_concepts) - - // PR3-D3-lite (L-6): strip `canonical_url_source` from the target_repo - // projection. The field is admin metadata (tells UI whether the canonical - // was explicit on the repo vs. derived) — the LLM has no use for it. - // eslint-disable-next-line no-unused-vars - const { canonical_url_source: _src, ...aiTargetRepo } = projectContext.target_repo - return { - target_repo: aiTargetRepo, - recommendable_concepts, - bridge_context - } - } - // Rows with candidates and no analysis of them yet; fetchRecommendation skips the rest. const getAIAnalysableRows = _rows => filter(_rows, _row => { const concepts = isNumber(_row?.__index) ? buildV2RecommendationPayload(_row.__index)?.recommendable_concepts : null @@ -6295,6 +6330,9 @@ const MapProject = () => { recommendedCount={recommendedCount} availableCount={availableCount} lowRankedCount={lowRankedCount} + aiDisagreesCount={aiDisagreesCount} + aiDisagreesSelected={aiDisagreesFilter} + onAIDisagreesClick={() => setAIDisagreesFilter(!aiDisagreesFilter)} decisions={decisions} decisionFilters={decisionFilters} setDecisionFilters={setDecisionFilters} @@ -6654,6 +6692,7 @@ const MapProject = () => { repoVersion={repoVersion} onFetchRecommendation={fetchRecommendation} analysis={analysis[rowIndex]} + recommendation={rowRecommendation(rowIndex)} columns={getValidColumns()} facets={getFacetsForRow(rowIndex)} appliedFacets={appliedFacets[rowIndex]} diff --git a/src/components/map-projects/Score.jsx b/src/components/map-projects/Score.jsx index 30789bf..8b3ed42 100644 --- a/src/components/map-projects/Score.jsx +++ b/src/components/map-projects/Score.jsx @@ -72,7 +72,7 @@ const Score = ({candidate, conceptRow, onScoreClick, sx, isAIRecommended, isAIAl > { isAIRecommended || isAIAlternate ? - + diff --git a/src/components/map-projects/__tests__/recommendationRule.test.js b/src/components/map-projects/__tests__/recommendationRule.test.js index 84335dd..61fcf8f 100644 --- a/src/components/map-projects/__tests__/recommendationRule.test.js +++ b/src/components/map-projects/__tests__/recommendationRule.test.js @@ -15,7 +15,11 @@ import assert from 'node:assert/strict' import { buildQualityRowViews, getTargetRepoQualityViews, - getRowRecommendation + getRowRecommendation, + conceptForMapping, + isAIDisagreement, + isAIPickTarget, + getRowAIStatus } from '../viewBuilders.js' const LOINC = 'http://loinc.org' @@ -332,3 +336,63 @@ test('getTargetRepoQualityViews: keeps only the target repo\'s concepts', () => assert.deepEqual(codes(getTargetRepoQualityViews(rowState, conceptCache, LOINC, '/orgs/Regenstrief/sources/LOINC/')), ['718-7']) assert.equal(getTargetRepoQualityViews(rowState, conceptCache).length, 2) }) + +// ---------- The grid: disagreement filter, Target Code icon, AI column ---------- + +test('isAIDisagreement: a different pick, a pick that isn\'t a candidate, No match and Not enough to decide disagree', () => { + const agreementOf = analysis => decide(HEMOGLOBIN, analysis ? [analysis] : undefined) + assert.equal(isAIDisagreement(agreementOf(analysisOf({ recommendation: 'RECOMMEND', pick: '718-7' }))), true) + assert.equal(isAIDisagreement(agreementOf(analysisOf({ recommendation: 'RECOMMEND', pick: '12345-6' }))), true) + assert.equal(isAIDisagreement(agreementOf(analysisOf({ recommendation: 'REJECT' }))), true) + assert.equal(isAIDisagreement(agreementOf(analysisOf({ recommendation: 'INSUFFICIENT' }))), true) + assert.equal(isAIDisagreement(agreementOf(analysisOf({ recommendation: 'RECOMMEND', pick: '110885-1' }))), false) + assert.equal(isAIDisagreement(agreementOf(analysisOf({ recommendation: 'REJECT', fingerprint: '6-c4a4ged0' }))), false) + assert.equal(isAIDisagreement(agreementOf(null)), false) + assert.equal(isAIDisagreement(undefined), false) +}) + +test('isAIPickTarget: the mapped target is the AI\'s pick, by URL', () => { + const result = decide(HEMOGLOBIN, [analysisOf({ recommendation: 'RECOMMEND', pick: '718-7' })]) + const { rowState, conceptCache } = rowOf(HEMOGLOBIN) + const mappedAs = code => conceptForMapping(buildQualityRowViews(rowState, conceptCache).find(view => view.conceptDefinition.id === code)) + assert.equal(isAIPickTarget(result, mappedAs('718-7')), true) + assert.equal(isAIPickTarget(result, mappedAs('110885-1')), false) + // a target restored from a saved project has only its id, name and URL + assert.equal(isAIPickTarget(result, { id: '718-7', url: '/orgs/Regenstrief/sources/LOINC/concepts/718-7/' }), true) + assert.equal(isAIPickTarget(result, null), false) + assert.equal(isAIPickTarget(decide(HEMOGLOBIN, [analysisOf({ recommendation: 'REJECT' })]), mappedAs('718-7')), false) +}) + +test('isAIPickTarget: a URL whose code is encoded once or twice is the same concept', () => { + // A code with "&" reaches the grid as %26 from one place and %2526 from another. + const { rowState, conceptCache } = rowOf([['1G40&XS65', 98.9], ['1G40', 90]]) + Object.values(conceptCache).forEach(def => { def.ocl_url = `/orgs/WHO/sources/ICD-11-WHO/concepts/${encodeURIComponent(encodeURIComponent(def.id))}/` }) + const result = getRowRecommendation({ + views: buildQualityRowViews(rowState, conceptCache), + analyses: [analysisOf({ recommendation: 'RECOMMEND', pick: '1G40&XS65' })], + fingerprint: FINGERPRINT, candidatesScore, conceptCache, + }) + assert.equal(isAIPickTarget(result, { id: '1G40&XS65', url: '/orgs/WHO/sources/ICD-11-WHO/concepts/1G40%26XS65/' }), true) + assert.equal(isAIPickTarget(result, { id: '1G40', url: '/orgs/WHO/sources/ICD-11-WHO/concepts/1G40/' }), false) +}) + +test('isAIPickTarget: an out-of-date analysis\'s pick is still the AI\'s pick', () => { + const result = decide(HEMOGLOBIN, [analysisOf({ recommendation: 'RECOMMEND', pick: '718-7', fingerprint: '6-c4a4ged0' })]) + assert.equal(isAIPickTarget(result, { id: '718-7', url: '/orgs/Regenstrief/sources/LOINC/concepts/718-7/' }), true) +}) + +test('getRowAIStatus: a running or failed call shows over the analysis it would replace', () => { + const current = decide(HEMOGLOBIN, [analysisOf({ recommendation: 'RECOMMEND', pick: '718-7' })]) + assert.equal(getRowAIStatus(current, 0), 'running') + assert.equal(getRowAIStatus(current, -2), 'failed') + assert.equal(getRowAIStatus(undefined, 0), 'running') + assert.equal(getRowAIStatus(current, 1), 'different_pick') + assert.equal(getRowAIStatus(current, undefined), 'different_pick') +}) + +test('getRowAIStatus: no analysis, or one never run, is empty', () => { + assert.equal(getRowAIStatus(decide(HEMOGLOBIN, undefined), 1), null) + assert.equal(getRowAIStatus(decide(HEMOGLOBIN, undefined), -3), null) + assert.equal(getRowAIStatus(undefined, undefined), null) + assert.equal(getRowAIStatus(decide(HEMOGLOBIN, [analysisOf({ recommendation: 'REJECT', fingerprint: '6-c4a4ged0' })]), 1), 'out_of_date') +}) diff --git a/src/components/map-projects/__tests__/rowQuality.test.js b/src/components/map-projects/__tests__/rowQuality.test.js index 5ab2508..b4fc4de 100644 --- a/src/components/map-projects/__tests__/rowQuality.test.js +++ b/src/components/map-projects/__tests__/rowQuality.test.js @@ -13,7 +13,7 @@ import test from 'node:test' import assert from 'node:assert/strict' -import { getRowQuality, getRowQualities, countQualityBuckets, filterRowsByQualityBucket, createBestCandidateScoreCache } from '../rowQuality.js' +import { getRowQuality, getRowQualities, countQualityBuckets, filterRowsByQualityBucket, createRowRecommendationCache } from '../rowQuality.js' const candidatesScore = { recommended: 80, available: 50 } const proposed = score => ({ search_meta: { search_normalized_score: score } }) @@ -110,57 +110,65 @@ test('filterRowsByQualityBucket: combined with a search or status filter, a buck assert.deepEqual(filterRowsByQualityBucket([{ __index: 3 }], qualities, 'available', 'desc'), []) }) -test('createBestCandidateScoreCache: recomputes only the rows whose candidates or concepts changed', () => { +test('createRowRecommendationCache: recomputes only the rows whose candidates, concepts or analyses changed', () => { const computed = [] - const computeScore = index => { computed.push(index); return 50 + index } - const cache = createBestCandidateScoreCache() + const compute = index => { computed.push(index); return { score: 50 + index } } + const cache = createRowRecommendationCache() const concepts = { a: { key: 'a' }, b: { key: 'b' }, c: { key: 'c' } } const rows = { 0: { concept_rows: { a: {} } }, 1: { concept_rows: { b: {} } }, 2: { concept_rows: { c: {} } } } + const analyses = { 2: [{ output: { recommendation: 'RECOMMEND' } }] } - let get = cache.getter({ rows, concepts, targetKey: 't', computeScore }) - assert.deepEqual([0, 1, 2].map(get), [50, 51, 52]) + let get = cache.getter({ rows, concepts, analyses, contextKey: 't', compute }) + assert.deepEqual([0, 1, 2].map(index => get(index).score), [50, 51, 52]) assert.deepEqual(computed, [0, 1, 2]) // nothing changed: nothing recomputed, even across renders computed.length = 0 - get = cache.getter({ rows, concepts, targetKey: 't', computeScore }) + get = cache.getter({ rows, concepts, analyses: { ...analyses }, contextKey: 't', compute }) ;[0, 1, 2].forEach(get) assert.deepEqual(computed, []) // one row's candidates changed (a rerank replaced its state) const rows2 = { ...rows, 1: { concept_rows: { b: { rerank_score: 80 } } } } - get = cache.getter({ rows: rows2, concepts, targetKey: 't', computeScore }) + get = cache.getter({ rows: rows2, concepts, analyses, contextKey: 't', compute }) ;[0, 1, 2].forEach(get) assert.deepEqual(computed, [1]) // a lookup filled in concept c, used only by row 2 computed.length = 0 const concepts2 = { ...concepts, c: { key: 'c', display_name: 'C' } } - get = cache.getter({ rows: rows2, concepts: concepts2, targetKey: 't', computeScore }) + get = cache.getter({ rows: rows2, concepts: concepts2, analyses, contextKey: 't', compute }) ;[0, 1, 2].forEach(get) assert.deepEqual(computed, [2]) - // the target repo changed: every row again + // an analysis landed for row 0 computed.length = 0 - get = cache.getter({ rows: rows2, concepts: concepts2, targetKey: 'u', computeScore }) + const analyses2 = { ...analyses, 0: [{ output: { recommendation: 'REJECT' } }] } + get = cache.getter({ rows: rows2, concepts: concepts2, analyses: analyses2, contextKey: 't', compute }) + ;[0, 1, 2].forEach(get) + assert.deepEqual(computed, [0]) + + // the target repo or the thresholds changed: every row again + computed.length = 0 + get = cache.getter({ rows: rows2, concepts: concepts2, analyses: analyses2, contextKey: 'u', compute }) ;[0, 1, 2].forEach(get) assert.deepEqual(computed, [0, 1, 2]) }) -test('createBestCandidateScoreCache: a row with no candidates has no score and is not computed', () => { +test('createRowRecommendationCache: a row with no candidates has no recommendation and is not computed', () => { const computed = [] - const cache = createBestCandidateScoreCache() - const get = cache.getter({ rows: {}, concepts: {}, targetKey: 't', computeScore: index => { computed.push(index); return 1 } }) + const cache = createRowRecommendationCache() + const get = cache.getter({ rows: {}, concepts: {}, analyses: {}, contextKey: 't', compute: index => { computed.push(index); return {} } }) assert.equal(get(7), undefined) assert.deepEqual(computed, []) }) -test('createBestCandidateScoreCache: uses each render\'s own computeScore, not the first one it saw', () => { - // MapProject's first render has no target repo yet, so its pickTopRowView - // scores nothing; a later render's must be the one used. - const cache = createBestCandidateScoreCache() +test('createRowRecommendationCache: uses each render\'s own compute, not the first one it saw', () => { + // MapProject's first render has no target repo yet, so its rule sees no + // candidates; a later render's must be the one used. + const cache = createRowRecommendationCache() const rows = { 0: { concept_rows: { a: {} } } } const concepts = { a: { key: 'a' } } - assert.equal(cache.getter({ rows, concepts, targetKey: '|', computeScore: () => undefined })(0), undefined) - assert.equal(cache.getter({ rows, concepts, targetKey: 'loinc|/orgs/Regenstrief/sources/LOINC/', computeScore: () => 72 })(0), 72) + assert.equal(cache.getter({ rows, concepts, analyses: {}, contextKey: '|', compute: () => undefined })(0), undefined) + assert.equal(cache.getter({ rows, concepts, analyses: {}, contextKey: 'loinc|/orgs/Regenstrief/sources/LOINC/', compute: () => 72 })(0), 72) }) diff --git a/src/components/map-projects/rowQuality.js b/src/components/map-projects/rowQuality.js index 4a1547d..0144dbb 100644 --- a/src/components/map-projects/rowQuality.js +++ b/src/components/map-projects/rowQuality.js @@ -79,31 +79,33 @@ export const filterRowsByQualityBucket = (rows, qualities, bucket, sortBy) => { } /** - * Caches each row's best candidate score across renders. A row is recomputed - * only when its state object, a concept definition its concept rows use, or - * the target repo changes; rows state and the concept cache are replaced, - * never mutated, so identity checks are enough. + * Caches each row's recommendation (getRowRecommendation) across renders, for + * the grid's Match Quality, OCL AI Assistant and Target Code columns and their + * filters. A row is recomputed only when its state object, a concept + * definition its concept rows use, or its analyses change; a change of + * context (the target repo, the thresholds, what the candidate-pool + * fingerprint reads) recomputes every row. Rows state, the concept cache and + * the analyses are replaced, never mutated, so identity checks are enough. * - * @returns {{getter: function}} getter({rows, concepts, targetKey, computeScore}) - * returns rowIndex => score for one render. computeScore (rowIndex => score, - * e.g. from pickTopRowView) is the render's own, so it sees that render's - * project context. + * @returns {{getter: function}} getter({rows, concepts, analyses, contextKey, compute}) + * returns rowIndex => value for one render. compute (rowIndex => value) is + * the render's own, so it sees that render's project context. */ -export const createBestCandidateScoreCache = () => { +export const createRowRecommendationCache = () => { let byRow = new Map() - let current = { rows: null, concepts: null, targetKey: null } + let current = { rows: null, concepts: null, analyses: null, contextKey: null } let checked = new Set() return { - getter: ({ rows, concepts, targetKey, computeScore }) => { - if(current.targetKey !== targetKey) + getter: ({ rows, concepts, analyses, contextKey, compute }) => { + if(current.contextKey !== contextKey) byRow = new Map() - if(current.rows !== rows || current.concepts !== concepts || current.targetKey !== targetKey) { - current = { rows, concepts, targetKey } + if(current.rows !== rows || current.concepts !== concepts || current.analyses !== analyses || current.contextKey !== contextKey) { + current = { rows, concepts, analyses, contextKey } checked = new Set() } return index => { if(checked.has(index)) - return byRow.get(index)?.score + return byRow.get(index)?.value checked.add(index) const rowState = rows?.[index] if(!rowState) { @@ -111,14 +113,15 @@ export const createBestCandidateScoreCache = () => { return undefined } const conceptKeys = Object.keys(rowState.concept_rows || {}) + const rowAnalyses = analyses?.[index] const entry = byRow.get(index) - const unchanged = entry && entry.rowState === rowState && entry.defs.length === conceptKeys.length && + const unchanged = entry && entry.rowState === rowState && entry.analyses === rowAnalyses && entry.defs.length === conceptKeys.length && conceptKeys.every((key, i) => entry.defs[i] === concepts?.[key]) if(unchanged) - return entry.score - const score = computeScore(index) - byRow.set(index, { rowState, defs: conceptKeys.map(key => concepts?.[key]), score }) - return score + return entry.value + const value = compute(index) + byRow.set(index, { rowState, analyses: rowAnalyses, defs: conceptKeys.map(key => concepts?.[key]), value }) + return value } }, } diff --git a/src/components/map-projects/viewBuilders.js b/src/components/map-projects/viewBuilders.js index 280ab47..bfd27f0 100644 --- a/src/components/map-projects/viewBuilders.js +++ b/src/components/map-projects/viewBuilders.js @@ -726,3 +726,47 @@ export const getRowRecommendation = ({ views = [], analyses, fingerprint, candid agreement: { REJECT: 'no_match', INSUFFICIENT: 'not_enough' }[verdict] || (tiedWithTop ? 'agrees' : 'different_pick'), } } + +// The agreement states the grid's "OCL AI Assistant disagrees" filter shows. +export const AI_DISAGREEMENTS = ['different_pick', 'pick_not_candidate', 'no_match', 'not_enough'] + +export const isAIDisagreement = recommendation => AI_DISAGREEMENTS.includes(recommendation?.agreement) + +// A concept's URL can hold its code encoded once or twice (%26 or %2526 for +// "&"), depending on where the concept came from, so URLs compare decoded. +const decodedURL = url => { + let decoded = url + for(let i = 0; i < 3; i++) { + try { + const next = decodeURIComponent(decoded) + if(next === decoded) break + decoded = next + } catch (_) { + break + } + } + return decoded +} + +// Whether a mapped target is the OCL AI Assistant's pick, for the grid's +// Target Code icon: the same URL, or, when either lacks one, the same id. +export const isAIPickTarget = (recommendation, target) => { + const pick = conceptForMapping(recommendation?.pick) + if(!target || !pick) return false + if(target.url && pick.url) + return decodedURL(target.url) === decodedURL(pick.url) + return pick.id === target.id +} + +/** + * What the grid's "OCL AI Assistant" column shows for a row: 'running' or + * 'failed' while a call is in flight or has just failed (the row's + * `recommend` stage, 0 or -2), else the recommendation's agreement state, or + * null when the row has no analysis. + */ +export const getRowAIStatus = (recommendation, stage) => { + if(stage === 0) return 'running' + if(stage === -2) return 'failed' + const agreement = recommendation?.agreement + return agreement && agreement !== 'none' ? agreement : null +} diff --git a/src/i18n/__tests__/aiAssistantNaming.test.js b/src/i18n/__tests__/aiAssistantNaming.test.js index ea9a176..2f1b158 100644 --- a/src/i18n/__tests__/aiAssistantNaming.test.js +++ b/src/i18n/__tests__/aiAssistantNaming.test.js @@ -15,8 +15,8 @@ const LOCALES = { name: 'Assistente de IA do OCL', banned: [/assistente de IA(?! do OCL)/i, /recomendaç(?:ão|ões)(?: de mapeamento)? (?:de|com) IA\b/i], }, - // zh keeps "AI 推荐" as the "AI Recommended" badge, so its recommendation - // strings are checked by key, below. + // zh's recommendation strings are checked by key, below: 推荐 alone is + // "recommended", as in the Recommended score bucket. zh: { name: 'OCL AI 助手', banned: [/(? diff --git a/src/i18n/locales/en/translations.json b/src/i18n/locales/en/translations.json index 3208332..ec52265 100644 --- a/src/i18n/locales/en/translations.json +++ b/src/i18n/locales/en/translations.json @@ -298,8 +298,39 @@ "start_typing": "Start typing...", "type_atleast_characters": "Type atleast {{count}} characters to search", "source": "Source", - "ai_recommended": "AI Recommended", - "ai_alternate": "AI Alternate Candidate Suggestion", + "ai_pick": "OCL AI Assistant's pick", + "ai_alternate": "OCL AI Assistant alternate", + "ai_disagrees": "OCL AI Assistant disagrees ({{n}})", + "ai_disagrees_help": "Rows where the OCL AI Assistant picked another candidate than the top score, found no match, or didn't have enough to decide", + "ai_status_running": "Running", + "ai_status_failed": "Failed", + "ai_status_out_of_date": "Out of date", + "ai_status_agrees": "Agrees", + "ai_status_different_pick": "Different pick", + "ai_status_pick_not_candidate": "Pick isn't a candidate", + "ai_status_no_match": "No match", + "ai_status_not_enough": "Not enough to decide", + "ai_conditional": "conditional", + "ai_confidence_high": "High confidence", + "ai_confidence_medium": "Medium confidence", + "ai_confidence_low": "Low confidence", + "ai_top_score": "Top score: {{code}}, {{bucket}} {{score}}", + "ai_no_top_score": "No candidate has a score yet", + "ai_pick_rank": "The OCL AI Assistant's pick, {{code}}, ranks #{{rank}} by score", + "ai_pick_unscored": "The OCL AI Assistant's pick, {{code}}, has no score yet", + "ai_out_of_date_help": "The candidates changed after this analysis ran, so the score decides", + "ai_pick_not_candidate_help": "The OCL AI Assistant's pick isn't one of the target repository's candidates, so the score decides", + "ai_summary": "{{verdict}}. {{topScore}}. {{relation}}.", + "ai_summary_recommend": "The OCL AI Assistant recommends {{code}}", + "ai_summary_conditional": "The OCL AI Assistant recommends {{code}}, with conditions", + "ai_summary_no_pick": "The OCL AI Assistant names no candidate", + "ai_summary_reject": "The OCL AI Assistant found no match among the candidates", + "ai_summary_insufficient": "The OCL AI Assistant didn't have enough information to decide", + "ai_summary_agree": "They agree", + "ai_summary_differ_rank": "They differ: the pick ranks #{{rank}} by score", + "ai_summary_differ_unscored": "They differ: the pick has no score yet", + "ai_summary_no_proposal": "AutoMatch proposes no match", + "read_analysis": "Read analysis", "ai_assistant": "OCL AI Assistant", "ai_prompt_template_unavailable": "OCL AI Assistant prompt template is not available", "view_raw_json": "View Raw JSON", @@ -491,7 +522,6 @@ "ocl_ai_assistant": "OCL AI Assistant", "ocl_ai_candidates_analysis": "OCL AI Assistant: Candidate Analysis", "group_by_match_quality": "Match Quality", - "group_by_ai_analysis": "AI Analysis", "ocl_semantic_algorithm": "OCL Semantic Algorithm", "ocl_search_algorithm": "OCL Search Algorithm", "scispacy_warming_up": "OCL's scispacy matching service is warming up. It will automatically retry in 2 minutes.", @@ -538,10 +568,7 @@ "assessment": "Assessment", "requested_by": "Requested by", "primary": "Primary", - "primary_candidate": "Primary Candidate", "alternates": "Alternates", - "alternate_candidates": "Alternate Candidates", - "rest": "Rest", "model": "Model", "bridge_source_url": "Bridge Source URL", "bridge_source_url_description": "The interface terminology to search through for bridge matching", diff --git a/src/i18n/locales/es/translations.json b/src/i18n/locales/es/translations.json index 8a2b024..4a3fbf9 100644 --- a/src/i18n/locales/es/translations.json +++ b/src/i18n/locales/es/translations.json @@ -282,8 +282,39 @@ "start_typing": "Comience a escribir...", "type_atleast_characters": "Escriba al menos {{count}} caracteres para buscar", "source": "Fuente", - "ai_recommended": "Recomendado por IA", - "ai_alternate": "Sugerencia de candidato alternativo de IA", + "ai_pick": "Elección del asistente de IA de OCL", + "ai_alternate": "Alternativa del asistente de IA de OCL", + "ai_disagrees": "El asistente de IA de OCL discrepa ({{n}})", + "ai_disagrees_help": "Filas en las que el asistente de IA de OCL eligió otro candidato que el de mayor puntuación, no encontró ninguna coincidencia o no tuvo suficiente información para decidir", + "ai_status_running": "En curso", + "ai_status_failed": "Error", + "ai_status_out_of_date": "Desactualizado", + "ai_status_agrees": "Coincide", + "ai_status_different_pick": "Otra elección", + "ai_status_pick_not_candidate": "La elección no es un candidato", + "ai_status_no_match": "Sin coincidencia", + "ai_status_not_enough": "Información insuficiente", + "ai_conditional": "condicional", + "ai_confidence_high": "Confianza alta", + "ai_confidence_medium": "Confianza media", + "ai_confidence_low": "Confianza baja", + "ai_top_score": "Mayor puntuación: {{code}}, {{bucket}} {{score}}", + "ai_no_top_score": "Ningún candidato tiene puntuación todavía", + "ai_pick_rank": "La elección del asistente de IA de OCL, {{code}}, ocupa el puesto n.º {{rank}} por puntuación", + "ai_pick_unscored": "La elección del asistente de IA de OCL, {{code}}, aún no tiene puntuación", + "ai_out_of_date_help": "Los candidatos cambiaron después de este análisis, así que decide la puntuación", + "ai_pick_not_candidate_help": "La elección del asistente de IA de OCL no es uno de los candidatos del repositorio de destino, así que decide la puntuación", + "ai_summary": "{{verdict}}. {{topScore}}. {{relation}}.", + "ai_summary_recommend": "El asistente de IA de OCL recomienda {{code}}", + "ai_summary_conditional": "El asistente de IA de OCL recomienda {{code}}, con condiciones", + "ai_summary_no_pick": "El asistente de IA de OCL no nombra ningún candidato", + "ai_summary_reject": "El asistente de IA de OCL no encontró ninguna coincidencia entre los candidatos", + "ai_summary_insufficient": "El asistente de IA de OCL no tuvo suficiente información para decidir", + "ai_summary_agree": "Coinciden", + "ai_summary_differ_rank": "Difieren: la elección ocupa el puesto n.º {{rank}} por puntuación", + "ai_summary_differ_unscored": "Difieren: la elección aún no tiene puntuación", + "ai_summary_no_proposal": "AutoMatch no propone ninguna coincidencia", + "read_analysis": "Leer el análisis", "ai_assistant": "Asistente de IA de OCL", "ai_prompt_template_unavailable": "La plantilla de prompt del asistente de IA de OCL no está disponible", "view_raw_json": "Ver JSON Sin Procesar", @@ -356,7 +387,6 @@ "bridge_candidates": "Candidatos Puente", "scispacy_candidates": "Candidatos de ScispaCy LOINC", "ai_analysis": "Análisis de IA", - "group_by_ai_analysis": "Análisis de IA", "view_existing_analysis": "Ver existente", "run_new_analysis": "Ejecutar nuevo análisis", "matched": "Coincidencias", @@ -429,9 +459,6 @@ "bulk_log_map_type_changed": "Cambio masivo de tipo de mapeo de {{oldMapType}} a {{newMapType}} desde el panel izquierdo del conjunto de datos de entrada.", "review": "Revisar", "recommended_candidates": "Candidatos Recomendados", - "primary_candidate": "Candidato principal", - "alternate_candidates": "Candidatos alternativos", - "rest": "Resto", "no_candidates_found": "No pudimos encontrar candidatos para esta fila.", "available_candidates": "Candidatos Disponibles", "unranked_candidates": "Candidatos Sin Clasificar", diff --git a/src/i18n/locales/pt-BR/translations.json b/src/i18n/locales/pt-BR/translations.json index 75a184b..ae59854 100644 --- a/src/i18n/locales/pt-BR/translations.json +++ b/src/i18n/locales/pt-BR/translations.json @@ -298,8 +298,39 @@ "start_typing": "Comece a digitar...", "type_atleast_characters": "Digite pelo menos {{count}} caracteres para buscar", "source": "Source", - "ai_recommended": "Recomendado por IA", - "ai_alternate": "Sugestão de Candidato Alternativo por IA", + "ai_pick": "Escolha do Assistente de IA do OCL", + "ai_alternate": "Alternativa do Assistente de IA do OCL", + "ai_disagrees": "O Assistente de IA do OCL discorda ({{n}})", + "ai_disagrees_help": "Linhas em que o Assistente de IA do OCL escolheu outro candidato que não o de maior pontuação, não encontrou correspondência ou não teve informações suficientes para decidir", + "ai_status_running": "Em execução", + "ai_status_failed": "Falhou", + "ai_status_out_of_date": "Desatualizada", + "ai_status_agrees": "Concorda", + "ai_status_different_pick": "Outra escolha", + "ai_status_pick_not_candidate": "A escolha não é um candidato", + "ai_status_no_match": "Sem correspondência", + "ai_status_not_enough": "Informação insuficiente", + "ai_conditional": "condicional", + "ai_confidence_high": "Confiança alta", + "ai_confidence_medium": "Confiança média", + "ai_confidence_low": "Confiança baixa", + "ai_top_score": "Maior pontuação: {{code}}, {{bucket}} {{score}}", + "ai_no_top_score": "Nenhum candidato tem pontuação ainda", + "ai_pick_rank": "A escolha do Assistente de IA do OCL, {{code}}, está em {{rank}}º lugar pela pontuação", + "ai_pick_unscored": "A escolha do Assistente de IA do OCL, {{code}}, ainda não tem pontuação", + "ai_out_of_date_help": "Os candidatos mudaram depois desta análise, então a pontuação decide", + "ai_pick_not_candidate_help": "A escolha do Assistente de IA do OCL não é um dos candidatos do repositório de destino, então a pontuação decide", + "ai_summary": "{{verdict}}. {{topScore}}. {{relation}}.", + "ai_summary_recommend": "O Assistente de IA do OCL recomenda {{code}}", + "ai_summary_conditional": "O Assistente de IA do OCL recomenda {{code}}, com condições", + "ai_summary_no_pick": "O Assistente de IA do OCL não indica nenhum candidato", + "ai_summary_reject": "O Assistente de IA do OCL não encontrou correspondência entre os candidatos", + "ai_summary_insufficient": "O Assistente de IA do OCL não teve informações suficientes para decidir", + "ai_summary_agree": "Concordam", + "ai_summary_differ_rank": "Divergem: a escolha está em {{rank}}º lugar pela pontuação", + "ai_summary_differ_unscored": "Divergem: a escolha ainda não tem pontuação", + "ai_summary_no_proposal": "O AutoMatch não propõe nenhuma correspondência", + "read_analysis": "Ler a análise", "ai_assistant": "Assistente de IA do OCL", "ai_prompt_template_unavailable": "O modelo de prompt do Assistente de IA do OCL não está disponível", "view_raw_json": "Ver JSON Bruto", @@ -491,7 +522,6 @@ "ocl_ai_assistant": "Assistente de IA do OCL", "ocl_ai_candidates_analysis": "Assistente de IA do OCL: Análise de Candidatos", "group_by_match_quality": "Qualidade de Correspondência", - "group_by_ai_analysis": "Análise de IA", "ocl_semantic_algorithm": "Algoritmo Semântico do OCL", "ocl_search_algorithm": "Algoritmo de Busca do OCL", "scispacy_warming_up": "Serviço de correspondência scispacy do OCL está aquecendo. Tentará novamente automaticamente em 2 minutos.", @@ -538,10 +568,7 @@ "assessment": "Avaliação", "requested_by": "Solicitado por", "primary": "Primário", - "primary_candidate": "Candidato Primário", "alternates": "Alternativos", - "alternate_candidates": "Candidatos Alternativos", - "rest": "Restante", "model": "Modelo", "bridge_source_url": "URL do Source Bridge", "bridge_source_url_description": "A terminologia de interface para buscar candidatos de correspondência", diff --git a/src/i18n/locales/zh/translations.json b/src/i18n/locales/zh/translations.json index e066ecc..cdd79ad 100644 --- a/src/i18n/locales/zh/translations.json +++ b/src/i18n/locales/zh/translations.json @@ -294,8 +294,39 @@ "start_typing": "开始输入...", "type_atleast_characters": "至少输入 {{count}} 个字符以进行搜索", "source": "来源", - "ai_recommended": "AI 推荐", - "ai_alternate": "AI 备选候选建议", + "ai_pick": "OCL AI 助手的选择", + "ai_alternate": "OCL AI 助手的备选", + "ai_disagrees": "OCL AI 助手持不同意见({{n}})", + "ai_disagrees_help": "OCL AI 助手选择了与最高得分不同的候选项、未找到匹配或信息不足以做出决定的行", + "ai_status_running": "进行中", + "ai_status_failed": "失败", + "ai_status_out_of_date": "已过期", + "ai_status_agrees": "一致", + "ai_status_different_pick": "选择不同", + "ai_status_pick_not_candidate": "所选不是候选项", + "ai_status_no_match": "无匹配", + "ai_status_not_enough": "信息不足", + "ai_conditional": "有条件", + "ai_confidence_high": "高置信度", + "ai_confidence_medium": "中置信度", + "ai_confidence_low": "低置信度", + "ai_top_score": "最高得分:{{code}},{{bucket}} {{score}}", + "ai_no_top_score": "尚无候选项有得分", + "ai_pick_rank": "OCL AI 助手的选择 {{code}} 按得分排第 {{rank}} 位", + "ai_pick_unscored": "OCL AI 助手的选择 {{code}} 尚无得分", + "ai_out_of_date_help": "此分析运行后候选项已变化,因此由得分决定", + "ai_pick_not_candidate_help": "OCL AI 助手的选择不是目标存储库的候选项,因此由得分决定", + "ai_summary": "{{verdict}}。{{topScore}}。{{relation}}。", + "ai_summary_recommend": "OCL AI 助手推荐 {{code}}", + "ai_summary_conditional": "OCL AI 助手有条件地推荐 {{code}}", + "ai_summary_no_pick": "OCL AI 助手未指明候选项", + "ai_summary_reject": "OCL AI 助手在候选项中未找到匹配", + "ai_summary_insufficient": "OCL AI 助手的信息不足以做出决定", + "ai_summary_agree": "两者一致", + "ai_summary_differ_rank": "两者不同:所选项按得分排第 {{rank}} 位", + "ai_summary_differ_unscored": "两者不同:所选项尚无得分", + "ai_summary_no_proposal": "AutoMatch 不提议匹配", + "read_analysis": "阅读分析", "ai_assistant": "OCL AI 助手", "ai_prompt_template_unavailable": "OCL AI 助手提示模板不可用", "view_raw_json": "查看原始 JSON", @@ -368,7 +399,6 @@ "bridge_candidates": "桥接候选对象", "scispacy_candidates": "ScispaCy LOINC 候选项", "ai_analysis": "AI 分析", - "group_by_ai_analysis": "AI 分析", "view_existing_analysis": "查看现有", "run_new_analysis": "运行新的分析", "matched": "已匹配", @@ -441,9 +471,6 @@ "bulk_log_map_type_changed": "从左侧输入数据集面板批量将映射类型从 {{oldMapType}} 更改为 {{newMapType}}。", "review": "审查", "recommended_candidates": "推荐候选对象", - "primary_candidate": "主要候选项", - "alternate_candidates": "备选候选项", - "rest": "其余", "no_candidates_found": "我们找不到此行的任何候选对象。", "available_candidates": "可用候选对象", "unranked_candidates": "未排名候选对象", From 11c339493c05a32d9183c14be5fc820e1d64ab3c Mon Sep 17 00:00:00 2001 From: Jonathan Payne Date: Tue, 20 Oct 2026 17:32:25 -0400 Subject: [PATCH 3/7] OpenConceptLab/ocl_issues#2908 | AutoMatch proposes by the recommendation rule AutoMatch's proposal for a row now comes from getRowRecommendation, with any current analysis the row has: the OCL AI Assistant's pick when it has analysed the row's current candidates, nothing on REJECT or INSUFFICIENT, and the top score if Recommended otherwise, as before. setAutoMatched and the single-algorithm batch path share one function; pickTopRowView is gone. When the run's AI step returns for a row, the row is decided again, unless the user changed it after the run set it. An analysis the user runs on one row reorders its candidates and updates the grid but changes no mapping. A withdrawn proposal no longer stays Proposed. The row's log records who chose (chosen_by: ai_assistant | score) and the verdict; a REJECT, INSUFFICIENT or a pick that isn't a candidate that leaves the row unmapped is logged as auto_match_no_proposal. closes OpenConceptLab/ocl_issues#2908, closes OpenConceptLab/ocl_issues#2333 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01HvdynUEUxPByhEuU5iFvtm --- src/components/map-projects/Discuss.jsx | 5 +- src/components/map-projects/MapProject.jsx | 154 ++++++++---------- .../__tests__/recommendationRule.test.js | 52 ++++++ src/components/map-projects/autoMatchRows.js | 34 ++++ src/i18n/__tests__/aiAssistantNaming.test.js | 3 + src/i18n/locales/en/translations.json | 3 + src/i18n/locales/es/translations.json | 3 + src/i18n/locales/pt-BR/translations.json | 3 + src/i18n/locales/zh/translations.json | 3 + 9 files changed, 173 insertions(+), 87 deletions(-) diff --git a/src/components/map-projects/Discuss.jsx b/src/components/map-projects/Discuss.jsx index 9716b40..870ed05 100644 --- a/src/components/map-projects/Discuss.jsx +++ b/src/components/map-projects/Discuss.jsx @@ -51,6 +51,9 @@ const Discuss = ({ logs, onAdd }) => { description += ':' description += ` (${log.extras.map_type || log.extras.mapType})` } + // OpenConceptLab/ocl_issues#2908: AutoMatch proposed the OCL AI Assistant's pick + if(log.action === 'auto-matched' && log.extras?.chosen_by === 'ai_assistant') + description += ` · ${t('map_project.ai_pick')}` return description } if (log.action === 'exclude') @@ -84,7 +87,7 @@ const Discuss = ({ logs, onAdd }) => { return if(['mapped', 'map_type_changed'].includes(log.action)) return - if(['auto-matched'].includes(log.action)) + if(['auto-matched', 'auto_match_no_proposal'].includes(log.action)) return if(log.action === 'unmapped') return diff --git a/src/components/map-projects/MapProject.jsx b/src/components/map-projects/MapProject.jsx index 870814a..3ec312b 100644 --- a/src/components/map-projects/MapProject.jsx +++ b/src/components/map-projects/MapProject.jsx @@ -89,7 +89,7 @@ import MapProjectDeleteConfirmDialog from './MapProjectDeleteConfirmDialog'; import ConfigurationForm from './ConfigurationForm' import Controls from './Controls' import DataGridControls from './DataGridControls' -import { getPreviewEligibleRowIndexes, getRowsToProcess, countAIOnlyRows, canRunAlgorithm, spendsMatchQuota, getRowCapByMatchOperations, shouldStopAIStep, getAIRequestIdempotencyKey, getCandidatePoolFingerprint, hasCurrentAnalysis, getScispacyRowResults, getPendingRowLookups, waitForLookups, AI_LOOKUP_WAIT_MS, RERANK_LOOKUP_WAIT_MS, RERANK_MAX_IN_FLIGHT, isScispacyWarmingUp } from './autoMatchRows' +import { getPreviewEligibleRowIndexes, getRowsToProcess, countAIOnlyRows, canRunAlgorithm, spendsMatchQuota, getRowCapByMatchOperations, shouldStopAIStep, getAIRequestIdempotencyKey, getCandidatePoolFingerprint, hasCurrentAnalysis, getScispacyRowResults, getPendingRowLookups, waitForLookups, AI_LOOKUP_WAIT_MS, RERANK_LOOKUP_WAIT_MS, RERANK_MAX_IN_FLIGHT, isScispacyWarmingUp, getAutoMatchLogExtras, getAutoMatchNoProposalReason, getRowMapping, isSameRowMapping } from './autoMatchRows' import { createAutosaveScheduler, createLatestSender, saveOnLeave, trackSave, whenSaved, installUnloadGuard } from './autosave' import MatchSummaryCard from './MatchSummaryCard' import MappingDecisionResult from './MappingDecisionResult' @@ -131,7 +131,7 @@ import '../common/ResizablePanel.scss' * 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, (c) PR2b flipped reads (Candidates / Concept / Score / - * setAutoMatched / setStateViews) to consume rowMatchState + conceptCache + * setAutoMatched) to consume rowMatchState + conceptCache * via structured tuples, and (d) PR3-C dropped the legacy allCandidates * state + v1 save/load format. The wire format is now v2 only * (mapper_schema_version: 2); prod data was migrated under @@ -538,7 +538,7 @@ const MapProject = () => { // 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- + // same-tick consumers (setAutoMatched) read the just- // merged entries before React commits the setState. const nextCache = { ...conceptCacheRef.current } let cacheChanged = false @@ -2337,33 +2337,6 @@ const MapProject = () => { }) } - // 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 targetRepo = buildProjectContext()?.target_repo - const targetCanonical = targetRepo?.canonical_url - const targetRelativeUrl = targetRepo?.relative_url - if(!targetCanonical && !targetRelativeUrl) 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 => conceptBelongsToTargetRepo(v.conceptDefinition, targetCanonical, targetRelativeUrl)) - if(!eligible.length) return null - return orderBy(eligible, [v => v.conceptRow?.rerank_score ?? -1], ['desc'])[0] - } - // Each row's recommendation, for the grid: its match quality // (ocl_issues#2837), its OCL AI Assistant column, Target Code icon and // filter. A row is recomputed only when its candidates, a concept they use, @@ -2387,62 +2360,64 @@ const MapProject = () => { }) } - const setStateViews = (data, _repo) => { - setRowStatuses(prev => { - forEach(data, concept => { - 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) { - const _concept = {...conceptForMapping(top), repo: {..._repo, version: repoVersion?.id || _repo.version, version_url: repoVersion?.version_url || _repo.version_url}} - setMapSelected(_prev => { - _prev[rowIdx] = _concept - return _prev - }) - 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 { - setMapSelected(prev => omit(prev, rowIdx)) - setDecisions(prev => omit(prev, rowIdx)) - setMapTypes(prev => omit(prev, rowIdx)) - prev.unmapped = uniq([...prev.unmapped, rowIdx]) - } + // Each row's mapping as the user sees it, and what the current run left + // each of its rows with, so that the run's AI step deciding a row again + // leaves alone a row the user changed meanwhile (OpenConceptLab/ocl_issues#2908). + const rowMappingStateRef = React.useRef({}) + rowMappingStateRef.current = {mapSelected, decisions, mapTypes, reviewed: rowStatuses.reviewed} + const runRowMappingsRef = React.useRef({}) + + // AutoMatch's proposal for a row of the run: the row's recommendation + // (getRowRecommendationFor) mapped, or the row left Unmapped, with who chose + // it in the row's log. + const proposeRowMapping = (index, recommendation, _repo = repo) => { + const proposal = recommendation?.proposal + const reviewed = rowMappingStateRef.current.reviewed?.includes(index) || false + const logExtras = getAutoMatchLogExtras(recommendation) + if(proposal) { + const _concept = {...conceptForMapping(proposal), repo: {..._repo, version: repoVersion?.id || _repo.version, version_url: repoVersion?.version_url || _repo.version_url}} + const mapType = proposal.candidate?.map_type || 'SAME-AS' + setMapSelected(_prev => { + _prev[index] = _concept + return _prev }) - return prev - }) + setDecisions(_prev => ({..._prev, [index]: 'map'})) + setMapTypes(_prev => ({..._prev, [index]: mapType})) + setRowStatuses(prev => ({...prev, readyForReview: uniq([...prev.readyForReview, index]), unmapped: without(prev.unmapped, index)})) + log({action: 'auto-matched', extras: {repoVersion: repoVersion?.version_url || _repo.version_url, name: getConceptLabel(_concept), map_type: mapType, algorithm: proposal.candidate?.algorithm_id, ...logExtras}}, index) + runRowMappingsRef.current[index] = {url: _concept.url || null, decision: 'map', mapType, reviewed, chosenBy: logExtras.chosen_by} + } else { + setMapSelected(prev => omit(prev, index)) + setDecisions(prev => omit(prev, index)) + setMapTypes(prev => omit(prev, index)) + // A proposal the rule withdraws (the AI found no match over a Recommended + // score) is no longer Proposed. + setRowStatuses(prev => ({...prev, unmapped: uniq([...prev.unmapped, index]), readyForReview: without(prev.readyForReview, index)})) + const reason = getAutoMatchNoProposalReason(recommendation) + if(reason) + log({action: 'auto_match_no_proposal', description: t(`map_project.auto_match_no_proposal_${reason}`), extras: logExtras}, index) + runRowMappingsRef.current[index] = {url: null, decision: null, mapType: null, reviewed, chosenBy: logExtras.chosen_by} + } } - const setAutoMatched = (indexes) => { - forEach(indexes, index => { - const top = pickTopRowView(index) - const topScore = top?.conceptRow?.rerank_score - if(isNumber(topScore) && topScore >= candidatesScore.recommended) { - setRowStatuses(prev => { - let newStatuses = {...prev} - 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 = 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: top.candidate?.algorithm_id}}, index) - newStatuses.unmapped = without(newStatuses.unmapped, index) - return newStatuses - }) - } else { - setMapSelected(prev => omit(prev, index)) - setDecisions(prev => omit(prev, index)) - setMapTypes(prev => omit(prev, index)) - setRowStatuses(prev => ({...prev, unmapped: uniq([...prev.unmapped, index])})) - } - }) + // The rows' proposals, by the recommendation rule with any current analysis + // each row has (OpenConceptLab/ocl_issues#2908). + const setAutoMatched = (indexes, _repo = repo) => { + forEach(indexes, index => proposeRowMapping(index, getRowRecommendationFor(index), _repo)) + } + + // The run's AI step returned for a row: decide it again with the new + // analysis, unless the user changed the row after the run set it. Nothing + // changes, and nothing is logged, when the outcome and who chose are the same. + const proposeRowMappingAfterAI = (index, analyses) => { + const runMapping = runRowMappingsRef.current[index] + if(!isSameRowMapping(runMapping, getRowMapping(rowMappingStateRef.current, index))) + return + const recommendation = getRowRecommendationFor(index, analyses) + const url = recommendation.proposal ? (conceptForMapping(recommendation.proposal)?.url || null) : null + if(url === runMapping.url && recommendation.chosenBy === runMapping.chosenBy) + return + proposeRowMapping(index, recommendation) } const getAlgoLogExtras = algo => { @@ -2594,8 +2569,8 @@ const MapProject = () => { // A batch that answers after a newer run started belongs to no run. if(!isCurrentRun()) return - // Populate rowMatchState before any consumer (setStateViews / - // setAutoMatched) tries to read it. The per-row fetch flow + // Populate rowMatchState before any consumer (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. @@ -2623,7 +2598,7 @@ const MapProject = () => { } } if(!isMultiAlgo) - setStateViews(data, _repo) + setAutoMatched(filter(map(data, entry => entry?.row?.__index), isNumber), _repo) setMatchedConcepts(prev => [...prev, ...data]); if(Array.isArray(data) && data.length) saveLateRunResult() @@ -2715,6 +2690,8 @@ const MapProject = () => { // began: it changed nothing to save. const runStarted = !isRunStopped() const aiOutcomes = {} + // What each row starts the run with; the run's proposals update it. + runRowMappingsRef.current = Object.fromEntries(rowsToProcess.map(row => [row.__index, getRowMapping(rowMappingStateRef.current, row.__index)])) try { bulkMatchAlgoIdsRef.current = map(_selectedAlgos, 'id') // Reset all algo stages to -1 for every row before starting so that @@ -5969,6 +5946,11 @@ const MapProject = () => { } const newEntry = {...response.data, model: modelUsed, model_name: modelUsedRef?.name, prompt_template: resolvedPromptRef, prompt_template_uri: resolvedPromptRef.uri, output_locale: promptOutputLocale || null, candidate_pool_fingerprint: candidatePoolFingerprint, timestamp: timestamp, user: user.username || user.id} setAnalysis(prev => ({...prev, [__index]: [...(prev[__index] || []), newEntry]})) + // A run's analysis decides the row again (OpenConceptLab/ocl_issues#2908); + // one the user runs on a row reorders its candidates but changes no + // mapping. A stopped run proposes no more mappings. + if(isBulk && !isSuperseded() && !abortRef.current) + proposeRowMappingAfterAI(__index, [...existingAnalyses, newEntry]) // Schedule a save, or leaving loses the result // (OpenConceptLab/ocl_issues#2833). Auto Match rows (isBulk) are saved // when the run completes. A result that lands after the user left diff --git a/src/components/map-projects/__tests__/recommendationRule.test.js b/src/components/map-projects/__tests__/recommendationRule.test.js index 61fcf8f..ea5af1d 100644 --- a/src/components/map-projects/__tests__/recommendationRule.test.js +++ b/src/components/map-projects/__tests__/recommendationRule.test.js @@ -21,6 +21,12 @@ import { isAIPickTarget, getRowAIStatus } from '../viewBuilders.js' +import { + getAutoMatchLogExtras, + getAutoMatchNoProposalReason, + getRowMapping, + isSameRowMapping +} from '../autoMatchRows.js' const LOINC = 'http://loinc.org' const keyOf = code => JSON.stringify([LOINC, code, '2.81']) @@ -396,3 +402,49 @@ test('getRowAIStatus: no analysis, or one never run, is empty', () => { assert.equal(getRowAIStatus(undefined, undefined), null) assert.equal(getRowAIStatus(decide(HEMOGLOBIN, [analysisOf({ recommendation: 'REJECT', fingerprint: '6-c4a4ged0' })]), 1), 'out_of_date') }) + +// ---------- AutoMatch: the log, and a row the user changed ---------- + +test('getAutoMatchLogExtras: records who chose, and the verdict when the AI did', () => { + assert.deepEqual(getAutoMatchLogExtras(decide(HEMOGLOBIN, undefined)), { chosen_by: 'score' }) + assert.deepEqual(getAutoMatchLogExtras(decide(HEMOGLOBIN, [analysisOf({ recommendation: 'RECOMMEND', pick: '718-7' })])), + { chosen_by: 'ai_assistant', verdict: 'RECOMMEND', confidence: 'HIGH' }) + assert.deepEqual(getAutoMatchLogExtras(decide(HEMOGLOBIN, [analysisOf({ recommendation: 'REJECT' })])), + { chosen_by: 'ai_assistant', verdict: 'REJECT' }) + assert.deepEqual(getAutoMatchLogExtras(decide(HEMOGLOBIN, [analysisOf({ recommendation: 'INSUFFICIENT' })])), + { chosen_by: 'ai_assistant', verdict: 'INSUFFICIENT' }) + // an out-of-date analysis changes nothing: the score chose + assert.deepEqual(getAutoMatchLogExtras(decide(HEMOGLOBIN, [analysisOf({ recommendation: 'REJECT', fingerprint: '6-c4a4ged0' })])), { chosen_by: 'score' }) +}) + +test('getAutoMatchLogExtras: a pick that isn\'t a candidate is logged, and the score chose', () => { + assert.deepEqual(getAutoMatchLogExtras(decide(HEMOGLOBIN, [analysisOf({ recommendation: 'RECOMMEND', pick: '12345-6' })])), + { chosen_by: 'score', verdict: 'RECOMMEND', ai_pick_not_candidate: true }) +}) + +test('getAutoMatchNoProposalReason: why the AI left a row unmapped, or null when the score found nothing', () => { + assert.equal(getAutoMatchNoProposalReason(decide(HEMOGLOBIN, [analysisOf({ recommendation: 'REJECT' })])), 'reject') + assert.equal(getAutoMatchNoProposalReason(decide(HEMOGLOBIN, [analysisOf({ recommendation: 'INSUFFICIENT' })])), 'insufficient') + assert.equal(getAutoMatchNoProposalReason(decide([['718-7', 97.39]], [analysisOf({ recommendation: 'RECOMMEND', pick: '12345-6' })])), 'pick_not_candidate') + assert.equal(getAutoMatchNoProposalReason(decide([['718-7', 97.39]], undefined)), null) + assert.equal(getAutoMatchNoProposalReason(decide(HEMOGLOBIN, [analysisOf({ recommendation: 'RECOMMEND', pick: '718-7' })])), null) +}) + +test('getRowMapping / isSameRowMapping: a row is unchanged until its target, decision, map type or approval changes', () => { + const state = { + mapSelected: { 0: { url: '/concepts/718-7/' } }, + decisions: { 0: 'map' }, + mapTypes: { 0: 'SAME-AS' }, + reviewed: [], + } + const proposed = getRowMapping(state, 0) + assert.deepEqual(proposed, { url: '/concepts/718-7/', decision: 'map', mapType: 'SAME-AS', reviewed: false }) + assert.equal(isSameRowMapping(proposed, getRowMapping({ ...state, mapSelected: { 0: { url: '/concepts/718-7/', display_name: 'renamed' } } }, 0)), true) + assert.equal(isSameRowMapping(proposed, getRowMapping({ ...state, mapSelected: { 0: { url: '/concepts/717-9/' } } }, 0)), false) + assert.equal(isSameRowMapping(proposed, getRowMapping({ ...state, decisions: { 0: 'rejected' } }, 0)), false) + assert.equal(isSameRowMapping(proposed, getRowMapping({ ...state, mapTypes: { 0: 'NARROWER-THAN' } }, 0)), false) + assert.equal(isSameRowMapping(proposed, getRowMapping({ ...state, reviewed: [0] }, 0)), false) + // an unmapped row + assert.equal(isSameRowMapping(getRowMapping({}, 3), getRowMapping({ mapSelected: {}, decisions: {}, mapTypes: {}, reviewed: [] }, 3)), true) + assert.equal(isSameRowMapping(undefined, getRowMapping({}, 3)), false) +}) diff --git a/src/components/map-projects/autoMatchRows.js b/src/components/map-projects/autoMatchRows.js index 86f5de7..e642a28 100644 --- a/src/components/map-projects/autoMatchRows.js +++ b/src/components/map-projects/autoMatchRows.js @@ -156,6 +156,40 @@ export const hasCurrentAnalysis = (analyses, fingerprint) => { return Boolean(fingerprint && latest?.candidate_pool_fingerprint === fingerprint) } +// What a run's log records about a row's proposal (OpenConceptLab/ocl_issues#2908): +// who chose it, and the OCL AI Assistant's verdict when it chose or when its +// pick wasn't one of the candidates. +export const getAutoMatchLogExtras = recommendation => { + const { chosenBy = 'score', verdict, confidence, agreement } = recommendation || {} + if(agreement === 'pick_not_candidate') + return { chosen_by: chosenBy, verdict, ai_pick_not_candidate: true } + if(chosenBy !== 'ai_assistant') + return { chosen_by: chosenBy } + return { chosen_by: chosenBy, verdict, ...(confidence ? { confidence } : {}) } +} + +// Why the OCL AI Assistant's analysis left a row with no proposal, for the +// row's log: 'reject', 'insufficient', or 'pick_not_candidate' when its pick +// isn't a candidate and no score is Recommended. null when a match is +// proposed, or when the score alone found none (not logged, as before). +export const getAutoMatchNoProposalReason = recommendation => { + if(!recommendation || recommendation.proposal) + return null + return { no_match: 'reject', not_enough: 'insufficient', pick_not_candidate: 'pick_not_candidate' }[recommendation.agreement] || null +} + +// A row's mapping: its target, decision, map type and approval. A run keeps +// what it left each row with, so that when the AI step decides the row again +// it leaves alone a row the user changed meanwhile. +export const getRowMapping = ({ mapSelected = {}, decisions = {}, mapTypes = {}, reviewed = [] } = {}, index) => ({ + url: mapSelected[index]?.url || null, + decision: decisions[index] || null, + mapType: mapTypes[index] || null, + reviewed: reviewed.includes(index), +}) + +export const isSameRowMapping = (a, b) => Boolean(a && b) && ['url', 'decision', 'mapType', 'reviewed'].every(key => a[key] === b[key]) + // The ScispaCy service keys each row's results by the itemid sent, which is // the row's index, not its position in the run (ocl_online#258). export const getScispacyRowResults = (responseData, rowIndex) => responseData?.[rowIndex] || [] diff --git a/src/i18n/__tests__/aiAssistantNaming.test.js b/src/i18n/__tests__/aiAssistantNaming.test.js index 2f1b158..8a37a26 100644 --- a/src/i18n/__tests__/aiAssistantNaming.test.js +++ b/src/i18n/__tests__/aiAssistantNaming.test.js @@ -53,6 +53,9 @@ const NAMES_THE_ASSISTANT = [ 'ai_summary_no_pick', 'ai_summary_reject', 'ai_summary_insufficient', + 'auto_match_no_proposal_reject', + 'auto_match_no_proposal_insufficient', + 'auto_match_no_proposal_pick_not_candidate', ] const load = locale => diff --git a/src/i18n/locales/en/translations.json b/src/i18n/locales/en/translations.json index ec52265..967da66 100644 --- a/src/i18n/locales/en/translations.json +++ b/src/i18n/locales/en/translations.json @@ -331,6 +331,9 @@ "ai_summary_differ_unscored": "They differ: the pick has no score yet", "ai_summary_no_proposal": "AutoMatch proposes no match", "read_analysis": "Read analysis", + "auto_match_no_proposal_reject": "AutoMatch proposed no match: the OCL AI Assistant found no match among the candidates", + "auto_match_no_proposal_insufficient": "AutoMatch proposed no match: the OCL AI Assistant didn't have enough information to decide", + "auto_match_no_proposal_pick_not_candidate": "AutoMatch proposed no match: the OCL AI Assistant's pick isn't one of the candidates, and no candidate's score is Recommended", "ai_assistant": "OCL AI Assistant", "ai_prompt_template_unavailable": "OCL AI Assistant prompt template is not available", "view_raw_json": "View Raw JSON", diff --git a/src/i18n/locales/es/translations.json b/src/i18n/locales/es/translations.json index 4a3fbf9..a048cbf 100644 --- a/src/i18n/locales/es/translations.json +++ b/src/i18n/locales/es/translations.json @@ -315,6 +315,9 @@ "ai_summary_differ_unscored": "Difieren: la elección aún no tiene puntuación", "ai_summary_no_proposal": "AutoMatch no propone ninguna coincidencia", "read_analysis": "Leer el análisis", + "auto_match_no_proposal_reject": "AutoMatch no propuso ninguna coincidencia: el asistente de IA de OCL no encontró ninguna coincidencia entre los candidatos", + "auto_match_no_proposal_insufficient": "AutoMatch no propuso ninguna coincidencia: el asistente de IA de OCL no tuvo suficiente información para decidir", + "auto_match_no_proposal_pick_not_candidate": "AutoMatch no propuso ninguna coincidencia: la elección del asistente de IA de OCL no es uno de los candidatos y ningún candidato tiene una puntuación Recomendada", "ai_assistant": "Asistente de IA de OCL", "ai_prompt_template_unavailable": "La plantilla de prompt del asistente de IA de OCL no está disponible", "view_raw_json": "Ver JSON Sin Procesar", diff --git a/src/i18n/locales/pt-BR/translations.json b/src/i18n/locales/pt-BR/translations.json index ae59854..edda26d 100644 --- a/src/i18n/locales/pt-BR/translations.json +++ b/src/i18n/locales/pt-BR/translations.json @@ -331,6 +331,9 @@ "ai_summary_differ_unscored": "Divergem: a escolha ainda não tem pontuação", "ai_summary_no_proposal": "O AutoMatch não propõe nenhuma correspondência", "read_analysis": "Ler a análise", + "auto_match_no_proposal_reject": "O AutoMatch não propôs nenhuma correspondência: o Assistente de IA do OCL não encontrou correspondência entre os candidatos", + "auto_match_no_proposal_insufficient": "O AutoMatch não propôs nenhuma correspondência: o Assistente de IA do OCL não teve informações suficientes para decidir", + "auto_match_no_proposal_pick_not_candidate": "O AutoMatch não propôs nenhuma correspondência: a escolha do Assistente de IA do OCL não é um dos candidatos, e nenhum candidato tem pontuação Recomendada", "ai_assistant": "Assistente de IA do OCL", "ai_prompt_template_unavailable": "O modelo de prompt do Assistente de IA do OCL não está disponível", "view_raw_json": "Ver JSON Bruto", diff --git a/src/i18n/locales/zh/translations.json b/src/i18n/locales/zh/translations.json index cdd79ad..c94878e 100644 --- a/src/i18n/locales/zh/translations.json +++ b/src/i18n/locales/zh/translations.json @@ -327,6 +327,9 @@ "ai_summary_differ_unscored": "两者不同:所选项尚无得分", "ai_summary_no_proposal": "AutoMatch 不提议匹配", "read_analysis": "阅读分析", + "auto_match_no_proposal_reject": "AutoMatch 未提议匹配:OCL AI 助手在候选项中未找到匹配", + "auto_match_no_proposal_insufficient": "AutoMatch 未提议匹配:OCL AI 助手的信息不足以做出决定", + "auto_match_no_proposal_pick_not_candidate": "AutoMatch 未提议匹配:OCL AI 助手的选择不是候选项,且没有候选项的得分达到推荐", "ai_assistant": "OCL AI 助手", "ai_prompt_template_unavailable": "OCL AI 助手提示模板不可用", "view_raw_json": "查看原始 JSON", From de6c38d784ee8b7830bf2e81f14690d00082bde2 Mon Sep 17 00:00:00 2001 From: Jonathan Payne Date: Tue, 20 Oct 2026 17:47:04 -0400 Subject: [PATCH 4/7] OpenConceptLab/ocl_issues#2908 | Codex pass 1: an AI-only run clears an approval it replaces, later proposals keep the user's edits, a foreign pick isn't a target concept - A row the run decides is no longer Approved. A full run clears approvals when it starts; an AI-only run didn't, so a re-proposed row kept showing Approved. (F1.1) - A row the run already proposed and the user changed since is left alone by the run's later proposals too, such as the single-algorithm rerank sweep. The run's view of the row is updated as it applies a proposal, so the check doesn't wait for a render. (F1.2) - The AI's pick is matched by code only within its own source, so another source's concept with the same code isn't taken for a target candidate. (F1.3) - A pick that isn't a candidate is logged when the AI step returns, also when the score's proposal stays the same. (F1.4) Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01HvdynUEUxPByhEuU5iFvtm --- src/components/map-projects/MapProject.jsx | 37 ++++++++++++++----- .../__tests__/recommendationRule.test.js | 19 ++++++++++ src/components/map-projects/viewBuilders.js | 7 +++- 3 files changed, 51 insertions(+), 12 deletions(-) diff --git a/src/components/map-projects/MapProject.jsx b/src/components/map-projects/MapProject.jsx index 3ec312b..633f605 100644 --- a/src/components/map-projects/MapProject.jsx +++ b/src/components/map-projects/MapProject.jsx @@ -2369,11 +2369,12 @@ const MapProject = () => { // AutoMatch's proposal for a row of the run: the row's recommendation // (getRowRecommendationFor) mapped, or the row left Unmapped, with who chose - // it in the row's log. + // it in the row's log. A row the run decides is no longer Approved; a full + // run already clears approvals when it starts, an AI-only run doesn't. const proposeRowMapping = (index, recommendation, _repo = repo) => { const proposal = recommendation?.proposal - const reviewed = rowMappingStateRef.current.reviewed?.includes(index) || false const logExtras = getAutoMatchLogExtras(recommendation) + const current = rowMappingStateRef.current if(proposal) { const _concept = {...conceptForMapping(proposal), repo: {..._repo, version: repoVersion?.id || _repo.version, version_url: repoVersion?.version_url || _repo.version_url}} const mapType = proposal.candidate?.map_type || 'SAME-AS' @@ -2383,39 +2384,55 @@ const MapProject = () => { }) setDecisions(_prev => ({..._prev, [index]: 'map'})) setMapTypes(_prev => ({..._prev, [index]: mapType})) - setRowStatuses(prev => ({...prev, readyForReview: uniq([...prev.readyForReview, index]), unmapped: without(prev.unmapped, index)})) + setRowStatuses(prev => ({...prev, readyForReview: uniq([...prev.readyForReview, index]), unmapped: without(prev.unmapped, index), reviewed: without(prev.reviewed, index)})) log({action: 'auto-matched', extras: {repoVersion: repoVersion?.version_url || _repo.version_url, name: getConceptLabel(_concept), map_type: mapType, algorithm: proposal.candidate?.algorithm_id, ...logExtras}}, index) - runRowMappingsRef.current[index] = {url: _concept.url || null, decision: 'map', mapType, reviewed, chosenBy: logExtras.chosen_by} + // Until the next render, the row as the run just left it. + rowMappingStateRef.current = { + mapSelected: {...current.mapSelected, [index]: _concept}, decisions: {...current.decisions, [index]: 'map'}, + mapTypes: {...current.mapTypes, [index]: mapType}, reviewed: without(current.reviewed || [], index) + } + runRowMappingsRef.current[index] = {...getRowMapping(rowMappingStateRef.current, index), chosenBy: logExtras.chosen_by, proposed: true} } else { setMapSelected(prev => omit(prev, index)) setDecisions(prev => omit(prev, index)) setMapTypes(prev => omit(prev, index)) // A proposal the rule withdraws (the AI found no match over a Recommended // score) is no longer Proposed. - setRowStatuses(prev => ({...prev, unmapped: uniq([...prev.unmapped, index]), readyForReview: without(prev.readyForReview, index)})) + setRowStatuses(prev => ({...prev, unmapped: uniq([...prev.unmapped, index]), readyForReview: without(prev.readyForReview, index), reviewed: without(prev.reviewed, index)})) const reason = getAutoMatchNoProposalReason(recommendation) if(reason) log({action: 'auto_match_no_proposal', description: t(`map_project.auto_match_no_proposal_${reason}`), extras: logExtras}, index) - runRowMappingsRef.current[index] = {url: null, decision: null, mapType: null, reviewed, chosenBy: logExtras.chosen_by} + rowMappingStateRef.current = { + mapSelected: omit(current.mapSelected, index), decisions: omit(current.decisions, index), + mapTypes: omit(current.mapTypes, index), reviewed: without(current.reviewed || [], index) + } + runRowMappingsRef.current[index] = {...getRowMapping(rowMappingStateRef.current, index), chosenBy: logExtras.chosen_by, proposed: true} } } // The rows' proposals, by the recommendation rule with any current analysis - // each row has (OpenConceptLab/ocl_issues#2908). + // each row has (OpenConceptLab/ocl_issues#2908). A row the run already + // proposed, and the user changed since, is left alone. const setAutoMatched = (indexes, _repo = repo) => { - forEach(indexes, index => proposeRowMapping(index, getRowRecommendationFor(index), _repo)) + forEach(indexes, index => { + const runMapping = runRowMappingsRef.current[index] + if(runMapping?.proposed && !isSameRowMapping(runMapping, getRowMapping(rowMappingStateRef.current, index))) + return + proposeRowMapping(index, getRowRecommendationFor(index), _repo) + }) } // The run's AI step returned for a row: decide it again with the new // analysis, unless the user changed the row after the run set it. Nothing - // changes, and nothing is logged, when the outcome and who chose are the same. + // changes, and nothing is logged, when the outcome and who chose are the + // same, except that a pick that isn't a candidate is always logged. const proposeRowMappingAfterAI = (index, analyses) => { const runMapping = runRowMappingsRef.current[index] if(!isSameRowMapping(runMapping, getRowMapping(rowMappingStateRef.current, index))) return const recommendation = getRowRecommendationFor(index, analyses) const url = recommendation.proposal ? (conceptForMapping(recommendation.proposal)?.url || null) : null - if(url === runMapping.url && recommendation.chosenBy === runMapping.chosenBy) + if(url === runMapping.url && recommendation.chosenBy === runMapping.chosenBy && recommendation.agreement !== 'pick_not_candidate') return proposeRowMapping(index, recommendation) } diff --git a/src/components/map-projects/__tests__/recommendationRule.test.js b/src/components/map-projects/__tests__/recommendationRule.test.js index ea5af1d..b5f99a7 100644 --- a/src/components/map-projects/__tests__/recommendationRule.test.js +++ b/src/components/map-projects/__tests__/recommendationRule.test.js @@ -317,6 +317,25 @@ test('getRowRecommendation: resolves the AI\'s pick by its canonical reference w assert.equal(code(result.proposal), '718-7') }) +test('getRowRecommendation: a pick from another repository is not a target candidate with the same code', () => { + // The AI names a concept of another source whose code a target candidate shares. + const foreign = { concept_key: JSON.stringify(['https://CIELterminology.org', '718-7', null]), canonical_reference: { url: 'https://CIELterminology.org', code: '718-7' }, confidence_level: 'HIGH' } + const analysis = { output: { recommendation: 'RECOMMEND', primary_candidate: foreign, alternative_candidates: [] }, candidate_pool_fingerprint: FINGERPRINT } + const result = decide(HEMOGLOBIN, [analysis]) + assert.equal(result.agreement, 'pick_not_candidate') + assert.equal(result.chosenBy, 'score') + assert.equal(code(result.proposal), '110885-1') +}) + +test('getRowRecommendation: a pick of the target repository found by its code when its concept_key differs', () => { + // Same source and code, another version in the key. + const sameSource = { concept_key: JSON.stringify([LOINC, '718-7', '2.80']), canonical_reference: { url: LOINC, code: '718-7', version: '2.80' }, confidence_level: 'HIGH' } + const analysis = { output: { recommendation: 'RECOMMEND', primary_candidate: sameSource, alternative_candidates: [] }, candidate_pool_fingerprint: FINGERPRINT } + const result = decide(HEMOGLOBIN, [analysis]) + assert.equal(code(result.pick), '718-7') + assert.equal(result.chosenBy, 'ai_assistant') +}) + test('getRowRecommendation: an analysis without a known verdict counts as none', () => { const result = decide(HEMOGLOBIN, [{ user: 'someone', timestamp: '2026-10-13T00:00:00Z', candidate_pool_fingerprint: FINGERPRINT }]) assert.equal(result.agreement, 'none') diff --git a/src/components/map-projects/viewBuilders.js b/src/components/map-projects/viewBuilders.js index bfd27f0..bcd9623 100644 --- a/src/components/map-projects/viewBuilders.js +++ b/src/components/map-projects/viewBuilders.js @@ -621,13 +621,16 @@ const viewHasConceptKey = (view, key) => const viewCode = view => view?.conceptDefinition?.reference?.code || view?.conceptDefinition?.id // The view of a candidate the AI named: by its concept_key, else by the code -// resolveAICandidateID gives it. +// resolveAICandidateID gives it, in the same source when the AI's candidate +// names one, so another source's concept with the same code isn't taken for it. const findAICandidateView = (views, aiCandidate, conceptCache) => { if(!aiCandidate) return null const byKey = aiCandidate.concept_key ? views.find(view => viewHasConceptKey(view, aiCandidate.concept_key)) : null if(byKey) return byKey const code = resolveAICandidateID(aiCandidate, conceptCache) - return (code && views.find(view => viewCode(view) === code)) || null + const sourceUrl = conceptCache?.[aiCandidate.concept_key]?.reference?.url || aiCandidate.canonical_reference?.url + const sameSource = view => !sourceUrl || canonicalUrlsEqual(view?.conceptDefinition?.reference?.url, sourceUrl) + return (code && views.find(view => viewCode(view) === code && sameSource(view))) || null } // Today's Match Quality buckets, each by score. From 7dbbb12b4de5fd70c0e2775be310e6bd6d4935bf Mon Sep 17 00:00:00 2001 From: Jonathan Payne Date: Tue, 20 Oct 2026 22:23:41 -0400 Subject: [PATCH 5/7] OpenConceptLab/ocl_issues#2908 | Codex pass 2: the disagreement filter hides at zero, URL-less targets are told apart, saved analyses show without the AI service - The "OCL AI Assistant disagrees" filter shows only while some row disagrees, like the Match Quality buckets, so it can't look selected while filtering nothing. (F2.1) - A mapped target without a URL (a bridge target before its lookup) is told apart by its source and code, so the run's check for a row the user changed sees a switch between two such targets. (F2.2) - The OCL AI Assistant column, its filter and the Target Code icon show on a project with analyses also where the AI Assistant isn't configured; the panel and AutoMatch already use those analyses. (F2.3) Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01HvdynUEUxPByhEuU5iFvtm --- src/components/map-projects/DataGridControls.jsx | 5 +++-- src/components/map-projects/MapProject.jsx | 10 +++++----- .../map-projects/__tests__/recommendationRule.test.js | 10 +++++++++- src/components/map-projects/autoMatchRows.js | 8 ++++++-- 4 files changed, 23 insertions(+), 10 deletions(-) diff --git a/src/components/map-projects/DataGridControls.jsx b/src/components/map-projects/DataGridControls.jsx index a5116ef..a00bcde 100644 --- a/src/components/map-projects/DataGridControls.jsx +++ b/src/components/map-projects/DataGridControls.jsx @@ -118,8 +118,9 @@ const DataGridControls = ({ /> { // OpenConceptLab/ocl_issues#2908: rows marked Different pick, No - // match or Not enough to decide in the OCL AI Assistant column. - (aiDisagreesCount > 0 || aiDisagreesSelected) && + // match or Not enough to decide in the OCL AI Assistant column. Like + // the Match Quality buckets, it filters only while some row disagrees. + aiDisagreesCount > 0 && { return ( { - AI_ASSISTANT_API_URL && isAIPickTarget(rowRecommendation(params.row.__index), targetConcept) && + showAIAssistantColumn && isAIPickTarget(rowRecommendation(params.row.__index), targetConcept) && @@ -2431,8 +2431,8 @@ const MapProject = () => { if(!isSameRowMapping(runMapping, getRowMapping(rowMappingStateRef.current, index))) return const recommendation = getRowRecommendationFor(index, analyses) - const url = recommendation.proposal ? (conceptForMapping(recommendation.proposal)?.url || null) : null - if(url === runMapping.url && recommendation.chosenBy === runMapping.chosenBy && recommendation.agreement !== 'pick_not_candidate') + const target = recommendation.proposal ? getMappingTarget(conceptForMapping(recommendation.proposal)) : null + if(target === runMapping.target && recommendation.chosenBy === runMapping.chosenBy && recommendation.agreement !== 'pick_not_candidate') return proposeRowMapping(index, recommendation) } @@ -3264,7 +3264,7 @@ const MapProject = () => { const { recommended: recommendedCount, available: availableCount, low_ranked: lowRankedCount } = countQualityBuckets(rowQualities) // Rows where the OCL AI Assistant picked another candidate than the top // score, or found no match or too little to decide. - const showAIAssistantColumn = Boolean(AI_ASSISTANT_API_URL) && (inAIAssistantGroup || !isEmpty(analysis)) + const showAIAssistantColumn = inAIAssistantGroup || !isEmpty(analysis) const aiDisagreesCount = showAIAssistantColumn ? filter(data, row => isAIDisagreement(rowRecommendation(row.__index))).length : 0 const getStateFromIndex = index => { diff --git a/src/components/map-projects/__tests__/recommendationRule.test.js b/src/components/map-projects/__tests__/recommendationRule.test.js index b5f99a7..957ca73 100644 --- a/src/components/map-projects/__tests__/recommendationRule.test.js +++ b/src/components/map-projects/__tests__/recommendationRule.test.js @@ -457,7 +457,7 @@ test('getRowMapping / isSameRowMapping: a row is unchanged until its target, dec reviewed: [], } const proposed = getRowMapping(state, 0) - assert.deepEqual(proposed, { url: '/concepts/718-7/', decision: 'map', mapType: 'SAME-AS', reviewed: false }) + assert.deepEqual(proposed, { target: '/concepts/718-7/', decision: 'map', mapType: 'SAME-AS', reviewed: false }) assert.equal(isSameRowMapping(proposed, getRowMapping({ ...state, mapSelected: { 0: { url: '/concepts/718-7/', display_name: 'renamed' } } }, 0)), true) assert.equal(isSameRowMapping(proposed, getRowMapping({ ...state, mapSelected: { 0: { url: '/concepts/717-9/' } } }, 0)), false) assert.equal(isSameRowMapping(proposed, getRowMapping({ ...state, decisions: { 0: 'rejected' } }, 0)), false) @@ -467,3 +467,11 @@ test('getRowMapping / isSameRowMapping: a row is unchanged until its target, dec assert.equal(isSameRowMapping(getRowMapping({}, 3), getRowMapping({ mapSelected: {}, decisions: {}, mapTypes: {}, reviewed: [] }, 3)), true) assert.equal(isSameRowMapping(undefined, getRowMapping({}, 3)), false) }) + +test('getRowMapping: a target without a URL is told apart by its source and code', () => { + // A bridge target can be mapped before a lookup gives it a URL. + const withTarget = target => getRowMapping({ mapSelected: { 0: target }, decisions: { 0: 'map' }, mapTypes: { 0: 'SAME-AS' } }, 0) + assert.equal(isSameRowMapping(withTarget({ id: 'A', source: 'ICD-11-WHO' }), withTarget({ id: 'B', source: 'ICD-11-WHO' })), false) + assert.equal(isSameRowMapping(withTarget({ id: 'A', source: 'ICD-11-WHO' }), withTarget({ id: 'A', source: 'ICD-11-WHO' })), true) + assert.equal(withTarget({ id: 'A', url: '/concepts/A/' }).target, '/concepts/A/') +}) diff --git a/src/components/map-projects/autoMatchRows.js b/src/components/map-projects/autoMatchRows.js index e642a28..e651379 100644 --- a/src/components/map-projects/autoMatchRows.js +++ b/src/components/map-projects/autoMatchRows.js @@ -178,17 +178,21 @@ export const getAutoMatchNoProposalReason = recommendation => { return { no_match: 'reject', not_enough: 'insufficient', pick_not_candidate: 'pick_not_candidate' }[recommendation.agreement] || null } +// A mapped target's identity: its URL, or its source and code while it has no +// URL yet (a bridge target before its lookup). +export const getMappingTarget = concept => concept?.url || (concept?.id ? `${concept.source || ''}:${concept.id}` : null) + // A row's mapping: its target, decision, map type and approval. A run keeps // what it left each row with, so that when the AI step decides the row again // it leaves alone a row the user changed meanwhile. export const getRowMapping = ({ mapSelected = {}, decisions = {}, mapTypes = {}, reviewed = [] } = {}, index) => ({ - url: mapSelected[index]?.url || null, + target: getMappingTarget(mapSelected[index]), decision: decisions[index] || null, mapType: mapTypes[index] || null, reviewed: reviewed.includes(index), }) -export const isSameRowMapping = (a, b) => Boolean(a && b) && ['url', 'decision', 'mapType', 'reviewed'].every(key => a[key] === b[key]) +export const isSameRowMapping = (a, b) => Boolean(a && b) && ['target', 'decision', 'mapType', 'reviewed'].every(key => a[key] === b[key]) // The ScispaCy service keys each row's results by the itemid sent, which is // the row's index, not its position in the run (ocl_online#258). From 17f26981d73a6b15e17f57986368aed7b29cb4b0 Mon Sep 17 00:00:00 2001 From: Jonathan Payne Date: Tue, 20 Oct 2026 22:30:17 -0400 Subject: [PATCH 6/7] OpenConceptLab/ocl_issues#2908 | Codex pass 3: closing an older analysis returns the badges to the latest, a row without candidates keeps its out-of-date analysis - Closing the full explanation after paging back to an older analysis returns the candidate badges to the latest analysis, which the order, the summary line and the grid follow. (F3.1) - A row whose candidates a refresh cleared, with saved analyses, still gets a recommendation: Out of date, with its summary line, rather than nothing. (F3.2) Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01HvdynUEUxPByhEuU5iFvtm --- src/components/map-projects/Candidates.jsx | 7 ++++++- .../map-projects/__tests__/rowQuality.test.js | 12 ++++++++++++ src/components/map-projects/rowQuality.js | 19 +++++++++++-------- 3 files changed, 29 insertions(+), 9 deletions(-) diff --git a/src/components/map-projects/Candidates.jsx b/src/components/map-projects/Candidates.jsx index e215635..49bd193 100644 --- a/src/components/map-projects/Candidates.jsx +++ b/src/components/map-projects/Candidates.jsx @@ -637,7 +637,12 @@ const Candidates = ({rowIndex, rowState, conceptCache, targetCanonical, targetRe analysis: analysis, showAnalysis: true, openAnalysis: Boolean(openAIAnalysis), - onCloseAnalysis: () => setOpenAIAnalysis(false), + // Closing an older analysis returns the badges to the latest one, which + // the order and the summary line follow. + onCloseAnalysis: () => { + setOpenAIAnalysis(false) + setAnalysisPage(Math.max(0, analysisCount - 1)) + }, isInProgress: isRecommendInProgress, summary: } diff --git a/src/components/map-projects/__tests__/rowQuality.test.js b/src/components/map-projects/__tests__/rowQuality.test.js index b4fc4de..680a4bc 100644 --- a/src/components/map-projects/__tests__/rowQuality.test.js +++ b/src/components/map-projects/__tests__/rowQuality.test.js @@ -163,6 +163,18 @@ test('createRowRecommendationCache: a row with no candidates has no recommendati assert.deepEqual(computed, []) }) +test('createRowRecommendationCache: a row with analyses but no candidates is still computed', () => { + // A refresh cleared the row's candidates and the match hasn't answered yet: + // its saved analysis is out of date, not gone. + const computed = [] + const cache = createRowRecommendationCache() + const analyses = { 4: [{ output: { recommendation: 'RECOMMEND' } }] } + const get = cache.getter({ rows: {}, concepts: {}, analyses, contextKey: 't', compute: index => { computed.push(index); return { agreement: 'out_of_date' } } }) + assert.deepEqual(get(4), { agreement: 'out_of_date' }) + assert.equal(get(5), undefined) + assert.deepEqual(computed, [4]) +}) + test('createRowRecommendationCache: uses each render\'s own compute, not the first one it saw', () => { // MapProject's first render has no target repo yet, so its rule sees no // candidates; a later render's must be the one used. diff --git a/src/components/map-projects/rowQuality.js b/src/components/map-projects/rowQuality.js index 0144dbb..59f5b46 100644 --- a/src/components/map-projects/rowQuality.js +++ b/src/components/map-projects/rowQuality.js @@ -81,11 +81,12 @@ export const filterRowsByQualityBucket = (rows, qualities, bucket, sortBy) => { /** * Caches each row's recommendation (getRowRecommendation) across renders, for * the grid's Match Quality, OCL AI Assistant and Target Code columns and their - * filters. A row is recomputed only when its state object, a concept - * definition its concept rows use, or its analyses change; a change of - * context (the target repo, the thresholds, what the candidate-pool - * fingerprint reads) recomputes every row. Rows state, the concept cache and - * the analyses are replaced, never mutated, so identity checks are enough. + * filters. A row with neither candidates nor analyses has none. A row is + * recomputed only when its state object, a concept definition its concept + * rows use, or its analyses change; a change of context (the target repo, the + * thresholds, what the candidate-pool fingerprint reads) recomputes every + * row. Rows state, the concept cache and the analyses are replaced, never + * mutated, so identity checks are enough. * * @returns {{getter: function}} getter({rows, concepts, analyses, contextKey, compute}) * returns rowIndex => value for one render. compute (rowIndex => value) is @@ -108,12 +109,14 @@ export const createRowRecommendationCache = () => { return byRow.get(index)?.value checked.add(index) const rowState = rows?.[index] - if(!rowState) { + const rowAnalyses = analyses?.[index] + // A row with analyses and no candidates (a refresh cleared them) still + // has a recommendation: its analysis is out of date. + if(!rowState && !rowAnalyses?.length) { byRow.delete(index) return undefined } - const conceptKeys = Object.keys(rowState.concept_rows || {}) - const rowAnalyses = analyses?.[index] + const conceptKeys = Object.keys(rowState?.concept_rows || {}) const entry = byRow.get(index) const unchanged = entry && entry.rowState === rowState && entry.analyses === rowAnalyses && entry.defs.length === conceptKeys.length && conceptKeys.every((key, i) => entry.defs[i] === concepts?.[key]) From 6e713b64f44ee400c0d0b004ddb0ba6a19ba4d27 Mon Sep 17 00:00:00 2001 From: Jonathan Payne Date: Tue, 20 Oct 2026 22:31:20 -0400 Subject: [PATCH 7/7] OpenConceptLab/ocl_issues#2908 | Simplify: AI_DISAGREEMENTS is private to viewBuilders.js Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01HvdynUEUxPByhEuU5iFvtm --- src/components/map-projects/viewBuilders.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/map-projects/viewBuilders.js b/src/components/map-projects/viewBuilders.js index bcd9623..72d0e10 100644 --- a/src/components/map-projects/viewBuilders.js +++ b/src/components/map-projects/viewBuilders.js @@ -731,7 +731,7 @@ export const getRowRecommendation = ({ views = [], analyses, fingerprint, candid } // The agreement states the grid's "OCL AI Assistant disagrees" filter shows. -export const AI_DISAGREEMENTS = ['different_pick', 'pick_not_candidate', 'no_match', 'not_enough'] +const AI_DISAGREEMENTS = ['different_pick', 'pick_not_candidate', 'no_match', 'not_enough'] export const isAIDisagreement = recommendation => AI_DISAGREEMENTS.includes(recommendation?.agreement)