diff --git a/src/components/map-projects/Candidates.jsx b/src/components/map-projects/Candidates.jsx index 7629ae6b..96e54935 100644 --- a/src/components/map-projects/Candidates.jsx +++ b/src/components/map-projects/Candidates.jsx @@ -40,7 +40,7 @@ import SearchResults from '../search/SearchResults'; import SearchFilters from '../search/SearchFilters' import NoResults from '../search/NoResults'; import Mappings from './Mappings' -import Concept from './Concept' +import Concept, { localizedNameColumn } from './Concept' import ConceptIcon from '../concepts/ConceptIcon' import MapButton from './MapButton' import AICandidatesAnalysis from './AICandidatesAnalysis' @@ -162,7 +162,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, 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, 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 @@ -202,6 +202,7 @@ const CandidateList = ({rowViews, header, rowIndex, sortBy, order, openConceptPa const getExtraColumns = () => { let cols = [ + localizedNameColumn(inputLocale, locales), { sortable: false, id: 'mappings', @@ -327,6 +328,7 @@ const CandidateList = ({rowViews, header, rowIndex, sortBy, order, openConceptPa repoVersion={repoVersion} AIRecommendedCandidateId={AIRecommendedCandidateId} AIAlternateCandidateIds={AIAlternateCandidateIds} + inputLocale={inputLocale} locales={locales} notClickable={Boolean(scispacy)} showAlgo={showAlgo} @@ -377,7 +379,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, 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, 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') @@ -460,6 +462,7 @@ const Candidates = ({rowIndex, rowState, conceptCache, targetCanonical, targetRe repoVersion: repoVersion, AIRecommendedCandidateId: AIRecommendedCandidateId, AIAlternateCandidateIds: AIAlternateCandidateIds, + inputLocale: inputLocale, locales: locales, candidatesScore: candidatesScore, algoScoreFirst: algoScoreFirst, @@ -527,9 +530,9 @@ const Candidates = ({rowIndex, rowState, conceptCache, targetCanonical, targetRe // key/order so the children's order tracks the parent rule. Bridge // children don't have their own raw score; sorting by algo_score // leaves them in insertion order via sortRowViews' -1 sentinel. - candidates: sortRowViews(views, sortBy, order).map(view => ( + candidates: sortRowViews(views, sortBy, order, inputLocale).map(view => ( view.type === 'bridge' && view.bridgeChildren?.length - ? { ...view, bridgeChildren: sortRowViews(view.bridgeChildren, sortBy, order) } + ? { ...view, bridgeChildren: sortRowViews(view.bridgeChildren, sortBy, order, inputLocale) } : view )) })) @@ -558,7 +561,7 @@ const Candidates = ({rowIndex, rowState, conceptCache, targetCanonical, targetRe let available = [] let lowRanked = [] let pendingRerank = [] - sortRowViews(qualityRowViews, sortBy, order).forEach(view => { + 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 diff --git a/src/components/map-projects/Concept.jsx b/src/components/map-projects/Concept.jsx index 946a6676..3dbc5a79 100644 --- a/src/components/map-projects/Concept.jsx +++ b/src/components/map-projects/Concept.jsx @@ -1,4 +1,5 @@ import React from 'react' +import { useTranslation } from 'react-i18next' import ListItemButton from '@mui/material/ListItemButton' import ListItem from '@mui/material/ListItem' import ListItemText from '@mui/material/ListItemText' @@ -6,7 +7,6 @@ import Button from '@mui/material/Button' import Chip from '@mui/material/Chip' import Tooltip from '@mui/material/Tooltip' import InfoOutlinedIcon from '@mui/icons-material/InfoOutlined' -import isString from 'lodash/isString' import map from 'lodash/map' import Retired from '../common/Retired' @@ -14,6 +14,7 @@ import Score from './Score' import MapButton from './MapButton' import ConceptSummaryProperties from '../concepts/ConceptSummaryProperties' import { conceptBelongsToTargetRepo, conceptForMapping } from './viewBuilders.js' +import { localizedDisplayName, getMatchedName } from './conceptNames.js' // Format a contributor descriptor for the convergence tooltip: // "via CIEL:1234 Activated partial thromboplastin time — SAME-AS" @@ -42,40 +43,60 @@ export const MAP_TYPE_CHIP_SX = { } } +// The name the match was made on, with its locale, as a line under the name +// a concept leads with (getMatchedName). Exported for the row panel's +// Target Code header (MappingDecisionResult), whose box has a fixed height: +// `noWrap` keeps it to one line there, with the whole of it on hover. +export const MatchedName = ({ matchedName, noWrap }) => { + const { t } = useTranslation() + if(!matchedName) return null + const label = `${t('map_project.matched_name')}${matchedName.locale ? ` (${matchedName.locale})` : ''}:` + return ( + ]*>/g, '')}` : undefined} + > + {label}  + + + ) +} -const getBestSynonym = (synonyms = []) => { - return synonyms - .map(text => { - const matches = [...text.matchAll(/(.*?)<\/em>/g)]; - const longestMatch = matches.reduce( - (a, b) => (b[1].length > a.length ? b[1] : a), - "" - ); - - const emTag = `${longestMatch}`; - return { - text, - length: longestMatch.length, - isExact: text.trim() === emTag, - startsWith: text.startsWith(emTag) - }; +// Table view's name column, naming each concept as the cards do: in the +// project's input language, with the name it matched on below. It changes +// only how the column renders: rows, which Map and the concept panel read, +// keep the concept's display_name. +export const localizedNameColumn = (inputLocale, locales) => ({ + id: 'name', + renderer: item => { + const displayName = localizedDisplayName(item, inputLocale) + const matchedName = getMatchedName({ + highlights: item.search_meta?.search_highlight, + concept: item, + displayName, + filterLocales: locales }) - .sort((a, b) => { - if (b.isExact !== a.isExact) return b.isExact ? 1 : -1; // ✅ exact ALWAYS first - if (b.length !== a.length) return b.length - a.length; // longer match - if (b.startsWith !== a.startsWith) return b.startsWith ? 1 : -1; - return 0; - })[0]?.text; -}; + return ( + + {item.retired && } {displayName} + + + ) + } +}) -const Item = ({candidate, conceptDefinition, conceptRow, bridgeConceptDefinition, bridgeContributors, contributingAlgorithms, contributingAlgorithmIds, openConceptPanel, AIRecommendedCandidateId, AIAlternateCandidateIds, onMap, isSelectedForMap, noScore, repoVersion, synonymPrefix, isAIRecommended, isAIAlternate, showAlgo, candidatesScore, algoScoreFirst, placeholderMap, bridgeChild}) => { +const Item = ({candidate, conceptDefinition, conceptRow, bridgeConceptDefinition, bridgeContributors, contributingAlgorithms, contributingAlgorithmIds, openConceptPanel, AIRecommendedCandidateId, AIAlternateCandidateIds, onMap, isSelectedForMap, noScore, repoVersion, inputLocale, matchedName, isAIRecommended, isAIAlternate, showAlgo, candidatesScore, algoScoreFirst, placeholderMap, bridgeChild}) => { const conceptToMap = conceptForMapping({candidate, conceptDefinition, conceptRow, bridgeConceptDefinition}) const idLabel = conceptDefinition?.id || conceptDefinition?.reference?.code const sourceLabel = conceptDefinition?.source + // Each concept leads with its name in the project's input language + // (OpenConceptLab/ocl_issues#2909); the name that matched comes second. + const displayName = localizedDisplayName(conceptDefinition, inputLocale) const mapTypeToApply = candidate?.map_type const bridgePrefixLabel = bridgeConceptDefinition - ? `${bridgeConceptDefinition.source || ''}:${bridgeConceptDefinition.id || bridgeConceptDefinition.reference?.code} ${bridgeConceptDefinition.display_name || ''}`.trim() + ? `${bridgeConceptDefinition.source || ''}:${bridgeConceptDefinition.id || bridgeConceptDefinition.reference?.code} ${localizedDisplayName(bridgeConceptDefinition, inputLocale) || ''}`.trim() : '' // Score chip click opens the unified ConceptDetailsPanel with the Match // Details tab focused. openConceptPanel resolves the per-concept enrichment @@ -124,21 +145,14 @@ const Item = ({candidate, conceptDefinition, conceptRow, bridgeConceptDefinition )} - {`${sourceLabel || ''}:${idLabel} ${conceptDefinition?.display_name || ''}`.trim()} + {`${sourceLabel || ''}:${idLabel} ${displayName || ''}`.trim()} ) : ( <> {`${sourceLabel || ''}:${idLabel}`} - { - !bridgeConceptDefinition && synonymPrefix && - - - → - - } - {conceptDefinition?.display_name} + {displayName} { // Legacy algo-view path: a target row with bridge @@ -151,8 +165,8 @@ const Item = ({candidate, conceptDefinition, conceptRow, bridgeConceptDefinition {candidate?.map_type ? `[${candidate.map_type}]` : ''} → - {conceptDefinition?.display_name - ? `${sourceLabel || ''}:${idLabel} ${conceptDefinition.display_name}` + {displayName + ? `${sourceLabel || ''}:${idLabel} ${displayName}` : ''} @@ -169,6 +183,7 @@ const Item = ({candidate, conceptDefinition, conceptRow, bridgeConceptDefinition } secondary={
+ {!bridgeConceptDefinition && }
@@ -318,7 +333,11 @@ const legacyToRowView = (legacy) => { // Legacy callers (Target Code column, decision tables, search results) // pass a flat concept-shape object instead — legacyToRowView wraps those // so a single render path covers both worlds while PR3 cleanup is pending. -const Concept = ({_id, firstChild, lastChild, concept, openConceptPanel, isShown, onCardClick, onMap, isSelectedForMap, noScore, repoVersion, isAIRecommended, sx, notClickable, noSynonymPrefix, locales, showAlgo, candidatesScore, algoScoreFirst, asTarget, AIRecommendedCandidateId, AIAlternateCandidateIds, targetCanonical, targetRelativeUrl}) => { +// +// `inputLocale` is the project's input language: each concept leads with its +// name in it. `locales` is the project's name-locale filter, which hides a +// matched name in any other language. +const Concept = ({_id, firstChild, lastChild, concept, openConceptPanel, isShown, onCardClick, onMap, isSelectedForMap, noScore, repoVersion, isAIRecommended, sx, notClickable, inputLocale, locales, showAlgo, candidatesScore, algoScoreFirst, asTarget, AIRecommendedCandidateId, AIAlternateCandidateIds, targetCanonical, targetRelativeUrl}) => { const rowView = concept?.conceptDefinition ? concept : legacyToRowView(concept) if(!rowView?.conceptDefinition) return null const { type, candidate, conceptDefinition, conceptRow, bridgeConceptDefinition, bridgeChildren, bridgeContributors, contributingAlgorithmIds, contributingAlgorithms } = rowView @@ -334,20 +353,12 @@ const Concept = ({_id, firstChild, lastChild, concept, openConceptPanel, isShown const isSelectedToShow = isShown ? isShown(idForUI) : false const aiAlternateIds = Array.isArray(AIAlternateCandidateIds) ? AIAlternateCandidateIds : [] - let synonymPrefix = '' - const highlights = candidate?.highlights - const synonymHighlight = highlights?.synonyms - const nameHighlight = highlights?.name - if(!nameHighlight?.length && synonymHighlight?.length && !noSynonymPrefix) { - let bestMatch = getBestSynonym(synonymHighlight) || synonymHighlight[0] - if(locales && bestMatch && conceptDefinition?.names) { - let raw = bestMatch.replaceAll("", "").replaceAll("", "") - let _locales = isString(locales) ? locales.split(',') : locales - if(_locales?.length > 0 && !_locales.includes(conceptDefinition.names.find(name => name.name.startsWith(raw))?.locale)) - bestMatch = '' - } - synonymPrefix = (bestMatch || '').replaceAll('', "").replaceAll('', '') - } + const matchedName = getMatchedName({ + highlights: candidate?.highlights, + concept: conceptDefinition, + displayName: localizedDisplayName(conceptDefinition, inputLocale), + filterLocales: locales + }) const baseProps = { id: idForUI, @@ -357,7 +368,8 @@ const Concept = ({_id, firstChild, lastChild, concept, openConceptPanel, isShown lastChild: lastChild, isSelectedToShow: isSelectedToShow, sx: sx, - onCardClick: onCardClick + onCardClick: onCardClick, + inputLocale: inputLocale } if(type === 'bridge') { @@ -389,7 +401,7 @@ const Concept = ({_id, firstChild, lastChild, concept, openConceptPanel, isShown conceptDefinition={conceptDefinition} conceptRow={conceptRow} repoVersion={repoVersion} - synonymPrefix={synonymPrefix} + matchedName={matchedName} openConceptPanel={openConceptPanel} AIRecommendedCandidateId={AIRecommendedCandidateId} AIAlternateCandidateIds={AIAlternateCandidateIds} @@ -462,7 +474,6 @@ const Concept = ({_id, firstChild, lastChild, concept, openConceptPanel, isShown conceptRow={child.conceptRow} bridgeConceptDefinition={conceptDefinition} repoVersion={repoVersion} - synonymPrefix={synonymPrefix} openConceptPanel={openConceptPanel} AIRecommendedCandidateId={AIRecommendedCandidateId} AIAlternateCandidateIds={AIAlternateCandidateIds} @@ -498,7 +509,7 @@ const Concept = ({_id, firstChild, lastChild, concept, openConceptPanel, isShown contributingAlgorithms={contributingAlgorithms} contributingAlgorithmIds={contributingAlgorithmIds} repoVersion={repoVersion} - synonymPrefix={synonymPrefix} + matchedName={matchedName} openConceptPanel={openConceptPanel} AIRecommendedCandidateId={AIRecommendedCandidateId} AIAlternateCandidateIds={AIAlternateCandidateIds} diff --git a/src/components/map-projects/ConfigurationForm.jsx b/src/components/map-projects/ConfigurationForm.jsx index 25f38f9f..8f15f848 100644 --- a/src/components/map-projects/ConfigurationForm.jsx +++ b/src/components/map-projects/ConfigurationForm.jsx @@ -220,7 +220,7 @@ const ConfigurationForm = ({ project, handleFileUpload, file, owner, setOwner, n {...params} size='small' label={t('map_project.input_language', 'Input Language')} - helperText={t('map_project.input_language_helper', 'Language of the input data. Determines which target-repo name locales are sent to the OCL AI Assistant.')} + helperText={t('map_project.input_language_helper', 'Language of the input data. Candidates lead with their name in this language, and it determines which target-repo name locales are sent to the OCL AI Assistant.')} /> )} /> diff --git a/src/components/map-projects/MapProject.jsx b/src/components/map-projects/MapProject.jsx index 4c38b599..6fa4ff3a 100644 --- a/src/components/map-projects/MapProject.jsx +++ b/src/components/map-projects/MapProject.jsx @@ -115,7 +115,7 @@ import { DEFAULT_ENCODER_MODEL } from './rerankerModels' import { normalizeAlgorithmInvocation, hasSuccessfulAlgorithmResponse, getAlgorithmStagesFromResponses, lookupStatusRank, buildRecommendableConceptEntry, stripConstantClassAndDatatype, buildLookupConceptUrl, serializeCandidates, toStoredRowMatchState } from './normalizers' import { parseConceptKey } from './conceptKey' import { getDeclaredLocales, getDefaultTargetRepoVersion, getProjectTargetRepoVersion, getSavedTargetRepoVersion, getTargetRepoVersionFromUrl, getTargetRepoVersionId } from './projectTargetRepo' -import { buildBridgeTargetDownloadEntries, buildQualityRowViews, conceptBelongsToTargetRepo, conceptForMapping, formatBridgeTargetDownloadEntry, resolveAICandidateID, getScoreDetails, getAIAnalysisCandidateIDs } from './viewBuilders.js' +import { buildBridgeTargetDownloadEntries, buildQualityRowViews, conceptBelongsToTargetRepo, conceptForMapping, findTargetRowView, formatBridgeTargetDownloadEntry, resolveAICandidateID, getScoreDetails, getAIAnalysisCandidateIDs, withCandidateNames } from './viewBuilders.js' import { formatClockTime, getCapacityWaitLabel, mergeCapacityWaits } from './rowProgress.js' import './MapProject.scss' @@ -1134,7 +1134,7 @@ const MapProject = () => { null } - + ) @@ -5342,13 +5342,7 @@ const MapProject = () => { const targetRowViews = rowMatchStateRef.current?.[rowIndex] ? buildQualityRowViews(rowMatchStateRef.current[rowIndex], conceptCacheRef.current) : [] - const selectedTargetRowView = targetConcept ? find(targetRowViews, view => { - const mapped = conceptForMapping(view) - if(!mapped) return false - if(targetConcept?.url && mapped.url) - return mapped.url === targetConcept.url - return mapped.id === targetConcept?.id - }) : false + const selectedTargetRowView = targetConcept ? findTargetRowView(targetRowViews, targetConcept) : false if(selectedTargetRowView && targetConcept) { const mergedConcept = conceptForMapping(selectedTargetRowView) targetConcept = { @@ -6501,6 +6495,8 @@ const MapProject = () => { candidatesScore={candidatesScore} targetConcept={targetConcept} repoVersion={repoVersion} + inputLocale={inputLocale} + locales={filters.locale || ''} row={row} rowIndex={rowIndex} repo={repo} @@ -6600,6 +6596,7 @@ const MapProject = () => { setAppliedFacets(() => ({...appliedFacets, [rowIndex]: filters})) fetchAllCandidatesForRow(null, 0, false, false, filters, true) }} + inputLocale={inputLocale} locales={filters.locale || ''} models={AIModels} selectedModel={AIModel} @@ -6639,6 +6636,7 @@ const MapProject = () => { setAppliedFacets({...appliedFacets, [rowIndex]: filters}) search(null, null, null, null, filters) }} + inputLocale={inputLocale} locales={filters.locale || ''} /> } diff --git a/src/components/map-projects/MappingDecisionResult.jsx b/src/components/map-projects/MappingDecisionResult.jsx index 68611b51..f1c00a63 100644 --- a/src/components/map-projects/MappingDecisionResult.jsx +++ b/src/components/map-projects/MappingDecisionResult.jsx @@ -17,9 +17,20 @@ import Retired from '../common/Retired' import ConceptSummaryProperties from '../concepts/ConceptSummaryProperties' import MapButton from './MapButton' import Score from './Score' +import { localizedDisplayName, getMatchedName } from './conceptNames.js' +import { MatchedName } from './Concept' -const MappingDecisionResult = ({targetConcept, row, rowIndex, mapTypes, allMapTypes, onMap, proposed, columns, repoVersion, openConceptPanel, candidatesScore}) => { +const MappingDecisionResult = ({targetConcept, row, rowIndex, mapTypes, allMapTypes, onMap, proposed, columns, repoVersion, inputLocale, locales, openConceptPanel, candidatesScore}) => { const { t } = useTranslation(); + // The target leads with its name in the input language, and the name its + // match was made on follows, as on a candidate (OpenConceptLab/ocl_issues#2909). + const targetName = localizedDisplayName(targetConcept, inputLocale) + const matchedName = getMatchedName({ + highlights: targetConcept?.search_meta?.search_highlight, + concept: targetConcept, + displayName: targetName, + filterLocales: locales + }) const parentParams = targetConcept?.url ? URIToParentParams(targetConcept.url) : {} const contributingAlgorithms = targetConcept?.contributingAlgorithms || targetConcept?.search_meta?.contributing_algorithms @@ -162,7 +173,7 @@ const MappingDecisionResult = ({targetConcept, row, rowIndex, mapTypes, allMapTy primary={ - {`${parentParams.repo || targetConcept.repo?.short_code || targetConcept?.repo?.id}:${targetConcept.id} ${targetConcept.display_name || ''}`} + {`${parentParams.repo || targetConcept.repo?.short_code || targetConcept?.repo?.id}:${targetConcept.id} ${targetName || ''}`} { targetConcept.retired && @@ -172,6 +183,7 @@ const MappingDecisionResult = ({targetConcept, row, rowIndex, mapTypes, allMapTy } secondary={ <> +
diff --git a/src/components/map-projects/Search.jsx b/src/components/map-projects/Search.jsx index f1876613..bf040a5e 100644 --- a/src/components/map-projects/Search.jsx +++ b/src/components/map-projects/Search.jsx @@ -20,11 +20,11 @@ import { highlightTexts } from '../../common/utils'; import SearchResults from '../search/SearchResults'; import SearchFilters from '../search/SearchFilters' import Mappings from './Mappings' -import Concept from './Concept' +import Concept, { localizedNameColumn } from './Concept' import MapButton from './MapButton' import GAService from '../../services/GAService' -const Search = ({searchStr, setSearchStr, onSearch, repo, repoVersion, concepts, openConceptPanel, showItem, isSelectedForMap, onMap, response, facets, appliedFacets, setAppliedFacets, isLoading, filters, columns, defaultFilters, locales}) => { +const Search = ({searchStr, setSearchStr, onSearch, repo, repoVersion, concepts, openConceptPanel, showItem, isSelectedForMap, onMap, response, facets, appliedFacets, setAppliedFacets, isLoading, filters, columns, defaultFilters, inputLocale, locales}) => { const { t } = useTranslation(); const [openFilters, setOpenFilters] = React.useState(false) const [display, setDisplay] = React.useState('card') @@ -61,6 +61,7 @@ const Search = ({searchStr, setSearchStr, onSearch, repo, repoVersion, concepts, const getExtraColumns = () => { let cols = [ + localizedNameColumn(inputLocale, locales), { sortable: false, id: 'mappings', @@ -209,6 +210,7 @@ const Search = ({searchStr, setSearchStr, onSearch, repo, repoVersion, concepts, isSelectedForMap={isSelectedForMap} repoVersion={repoVersion} noScore + inputLocale={inputLocale} locales={locales} /> ) diff --git a/src/components/map-projects/__tests__/conceptNames.test.js b/src/components/map-projects/__tests__/conceptNames.test.js new file mode 100644 index 00000000..8af53cc5 --- /dev/null +++ b/src/components/map-projects/__tests__/conceptNames.test.js @@ -0,0 +1,179 @@ +/** + * Tests for the candidate-name helpers in conceptNames.js + * (OpenConceptLab/ocl_issues#2909): + * - localizedDisplayName: the name a candidate leads with, in the project's + * input language, falling back to the repository's default locale + * - getMatchedName: the name that matched, shown second with its locale, + * only when it differs from the name the candidate leads with + * + * Run with: npm test + */ + +import test from 'node:test' +import assert from 'node:assert/strict' + +import { localizedDisplayName, getMatchedName } from '../conceptNames.js' + +// LOINC 89497-2 as the ticket found it: an English display name, and an +// it-IT related name that ES matched for the input "Pregnancy test". +const PRENATAL_TESTS = { + id: '89497-2', + display_name: "Mother's Prenatal tests", + names: [ + { name: "Mother's Prenatal tests", locale: 'en', locale_preferred: true, name_type: 'FULLY_SPECIFIED' }, + { name: 'Anamnesi Punto nel tempo (episodio) Test prenatale', locale: 'it-IT', locale_preferred: false } + ] +} + +const PREGNANCY_TEST = { + id: '2106-3', + display_name: 'Pregnancy test', + names: [ + { name: 'Pregnancy test', locale: 'en', locale_preferred: true }, + { name: 'HCG test', locale: 'en' }, + { name: 'Test de embarazo', locale: 'es' }, + { name: 'Prueba de embarazo', locale: 'es', locale_preferred: true }, + { name: 'Teste de gravidez', locale: 'pt-BR' } + ] +} + +// ---------- localizedDisplayName ---------- + +test('localizedDisplayName: keeps the display name when the project has no input language', () => { + assert.equal(localizedDisplayName(PREGNANCY_TEST, ''), 'Pregnancy test') + assert.equal(localizedDisplayName(PREGNANCY_TEST, undefined), 'Pregnancy test') +}) + +test('localizedDisplayName: keeps the display name when it is already in the input language', () => { + const concept = { + display_name: 'Pregnancy test', + names: [ + { name: 'HCG test', locale: 'en', locale_preferred: true }, + { name: 'Pregnancy test', locale: 'en' } + ] + } + assert.equal(localizedDisplayName(concept, 'en'), 'Pregnancy test') + assert.equal(localizedDisplayName(PRENATAL_TESTS, 'en'), "Mother's Prenatal tests") +}) + +test('localizedDisplayName: leads with the preferred name in the input language', () => { + assert.equal(localizedDisplayName(PREGNANCY_TEST, 'es'), 'Prueba de embarazo') +}) + +test('localizedDisplayName: matches the input language on its primary subtag', () => { + assert.equal(localizedDisplayName(PREGNANCY_TEST, 'pt'), 'Teste de gravidez') + assert.equal(localizedDisplayName(PRENATAL_TESTS, 'it'), 'Anamnesi Punto nel tempo (episodio) Test prenatale') +}) + +test('localizedDisplayName: without a preferred name, takes the fully specified name, then the first', () => { + const names = [ + { name: 'PE', locale: 'es', name_type: 'SHORT' }, + { name: 'Prueba de embarazo', locale: 'es', name_type: 'FULLY_SPECIFIED' } + ] + assert.equal(localizedDisplayName({ display_name: 'Pregnancy test', names }, 'es'), 'Prueba de embarazo') + assert.equal(localizedDisplayName({ display_name: 'Pregnancy test', names: names.slice(0, 1) }, 'es'), 'PE') +}) + +test('localizedDisplayName: falls back to the repository default-locale display name', () => { + assert.equal(localizedDisplayName(PREGNANCY_TEST, 'fr'), 'Pregnancy test') + assert.equal(localizedDisplayName({ display_name: 'Pregnancy test' }, 'es'), 'Pregnancy test') + assert.equal(localizedDisplayName(null, 'es'), undefined) +}) + +// ---------- getMatchedName ---------- + +test('getMatchedName: the translated name that matched comes second, with its locale (the ticket case)', () => { + const displayName = localizedDisplayName(PRENATAL_TESTS, 'en') + assert.deepEqual( + getMatchedName({ + highlights: { synonyms: ['Anamnesi Punto nel tempo (episodio) Test prenatale'] }, + concept: PRENATAL_TESTS, + displayName + }), + { + html: "Anamnesi Punto nel tempo (episodio) Test prenatale", + locale: 'it-IT' + } + ) +}) + +test('getMatchedName: nothing when the name that matched is the one the candidate leads with', () => { + assert.equal( + getMatchedName({ + highlights: { synonyms: ['Prueba de embarazo'] }, + concept: PREGNANCY_TEST, + displayName: localizedDisplayName(PREGNANCY_TEST, 'es') + }), + null + ) + assert.equal( + getMatchedName({ + highlights: { synonyms: ['pregnancy TEST'] }, + concept: PREGNANCY_TEST, + displayName: 'Pregnancy test' + }), + null + ) +}) + +test('getMatchedName: nothing when the display name matched and the candidate leads with it', () => { + assert.equal( + getMatchedName({ + highlights: { name: ['Pregnancy test'], synonyms: ['HCG test'] }, + concept: PREGNANCY_TEST, + displayName: localizedDisplayName(PREGNANCY_TEST, 'en') + }), + null + ) +}) + +test('getMatchedName: the default-locale display name that matched comes second when the candidate leads in another language', () => { + assert.deepEqual( + getMatchedName({ + highlights: { name: ['Pregnancy test'] }, + concept: PREGNANCY_TEST, + displayName: localizedDisplayName(PREGNANCY_TEST, 'es') + }), + { + html: "Pregnancy test", + locale: 'en' + } + ) +}) + +test('getMatchedName: takes the synonym with the longest highlighted match', () => { + assert.deepEqual( + getMatchedName({ + highlights: { synonyms: ['Test de embarazo', 'Prueba de embarazo'] }, + concept: PREGNANCY_TEST, + displayName: 'Pregnancy test' + }), + { html: "Prueba de embarazo", locale: 'es' } + ) +}) + +test('getMatchedName: hides a matched name outside the name-locale filter, comparing primary subtags', () => { + const args = { + highlights: { synonyms: ['Anamnesi Punto nel tempo (episodio) Test prenatale'] }, + concept: PRENATAL_TESTS, + displayName: "Mother's Prenatal tests" + } + assert.equal(getMatchedName({ ...args, filterLocales: 'en,es' }), null) + assert.equal(getMatchedName({ ...args, filterLocales: 'en, it' })?.locale, 'it-IT') + assert.equal(getMatchedName({ ...args, filterLocales: '' })?.locale, 'it-IT') +}) + +test('getMatchedName: a matched name no concept name carries shows without a locale, unless a filter is set', () => { + const args = { + highlights: { synonyms: ['Gravindex'] }, + concept: PREGNANCY_TEST, + displayName: 'Pregnancy test' + } + assert.deepEqual(getMatchedName(args), { html: "Gravindex", locale: undefined }) + assert.equal(getMatchedName({ ...args, filterLocales: 'en' }), null) +}) + +test('getMatchedName: nothing without highlights', () => { + assert.equal(getMatchedName({ concept: PREGNANCY_TEST, displayName: 'Pregnancy test' }), null) + assert.equal(getMatchedName({ highlights: { synonyms: [] }, concept: PREGNANCY_TEST, displayName: 'Pregnancy test' }), null) +}) diff --git a/src/components/map-projects/__tests__/views.test.js b/src/components/map-projects/__tests__/views.test.js index 86742fbb..fb9ddfae 100644 --- a/src/components/map-projects/__tests__/views.test.js +++ b/src/components/map-projects/__tests__/views.test.js @@ -23,7 +23,9 @@ import { buildQualityRowViews, candidateToRowView, conceptForMapping, - sortRowViews + sortRowViews, + findTargetRowView, + withCandidateNames } from '../viewBuilders.js' // ---------- Helpers ---------- @@ -648,6 +650,15 @@ test('sortRowViews: display_name asc', () => { assert.deepEqual(out.map(v => v.conceptDefinition.display_name), ['Alpha', 'Beta', 'Gamma']) }) +test('sortRowViews: display_name sorts on the name shown in the input language (OpenConceptLab/ocl_issues#2909)', () => { + const views = [ + { conceptDefinition: {id: 'glucose', display_name: 'Blood glucose', names: [{name: 'Glucosa en sangre', locale: 'es', locale_preferred: true}]} }, + { conceptDefinition: {id: 'diabetes', display_name: 'Diabetes mellitus', names: [{name: 'Diabetes mellitus', locale: 'es'}]} } + ] + assert.deepEqual(sortRowViews(views, 'display_name', 'asc', 'es').map(v => v.conceptDefinition.id), ['diabetes', 'glucose']) + assert.deepEqual(sortRowViews(views, 'display_name', 'asc').map(v => v.conceptDefinition.id), ['glucose', 'diabetes']) +}) + test('sortRowViews: undefined scores sort to the bottom in desc order', () => { const views = [ { candidate: {}, conceptDefinition: {display_name: 'x'}, conceptRow: {} }, @@ -665,3 +676,61 @@ test('sortRowViews: id falls back to reference.code when id is absent', () => { const out = sortRowViews(views, 'id', 'asc') assert.equal(out[0].conceptDefinition.reference.code, 'a-1') }) + +// ---------- findTargetRowView / withCandidateNames (OpenConceptLab/ocl_issues#2909) ---------- + +const glucoseNames = [ + { name: 'Glucose [Mass/volume] in Blood', locale: 'en', locale_preferred: true }, + { name: 'Glucosa en sangre', locale: 'es', locale_preferred: true } +] +const namedCache = { + [KEY_LOINC_GLUCOSE]: { ...defLOINCGlucose, names: glucoseNames }, + [KEY_LOINC_CHOL]: defLOINCChol +} +const namedRowState = { + candidates: { + c1: { id: 'c1', algorithm_id: 'ocl-search', concept_key: KEY_LOINC_GLUCOSE, type: 'standard', score: 0.85, highlights: { synonyms: ['Glucosa en sangre'] } }, + c2: { id: 'c2', algorithm_id: 'ocl-search', concept_key: KEY_LOINC_CHOL, type: 'standard', score: 0.6 } + }, + concept_rows: { + [KEY_LOINC_GLUCOSE]: { concept_key: KEY_LOINC_GLUCOSE, rerank_score: 87 }, + [KEY_LOINC_CHOL]: { concept_key: KEY_LOINC_CHOL, rerank_score: 55 } + } +} +// A mapped target as a reopened project restores it from its CSV: id, name, +// URL and scores, without names or highlights. +const restoredTarget = { + id: '49494-3', + display_name: 'Glucose [Mass/volume] in Blood', + url: '/orgs/Regenstrief/sources/LOINC/concepts/49494-3/', + search_meta: { search_normalized_score: '87', algorithm: 'ocl-search' }, + repo: { id: 'LOINC' } +} + +test('findTargetRowView: matches a target on its OCL URL, or on its id when either lacks one', () => { + const views = buildQualityRowViews(namedRowState, namedCache) + assert.equal(findTargetRowView(views, restoredTarget)?.conceptDefinition.id, '49494-3') + assert.equal(findTargetRowView(views, { id: '2093-3' })?.conceptDefinition.id, '2093-3') + assert.equal(findTargetRowView(views, { id: '49494-3', url: '/orgs/Other/sources/X/concepts/49494-3/' }), undefined) +}) + +test('withCandidateNames: a restored target gets the names and highlights its row\'s candidates hold for it', () => { + const out = withCandidateNames(restoredTarget, namedRowState, namedCache) + assert.deepEqual(out.names, glucoseNames) + assert.deepEqual(out.search_meta, { + search_normalized_score: '87', + algorithm: 'ocl-search', + search_highlight: { synonyms: ['Glucosa en sangre'] } + }) + assert.equal(out.display_name, restoredTarget.display_name) + assert.equal(out.repo, restoredTarget.repo) +}) + +test('withCandidateNames: keeps a target\'s own names and highlights, and a target no candidate holds', () => { + const own = { ...restoredTarget, names: [{ name: 'own', locale: 'es' }], search_meta: { search_highlight: { name: ['own'] } } } + assert.equal(withCandidateNames(own, namedRowState, namedCache).names, own.names) + assert.deepEqual(withCandidateNames(own, namedRowState, namedCache).search_meta.search_highlight, { name: ['own'] }) + const searched = { id: '718-7', url: '/orgs/Regenstrief/sources/LOINC/concepts/718-7/' } + assert.equal(withCandidateNames(searched, namedRowState, namedCache), searched) + assert.equal(withCandidateNames(restoredTarget, undefined, namedCache), restoredTarget) +}) diff --git a/src/components/map-projects/conceptNames.js b/src/components/map-projects/conceptNames.js new file mode 100644 index 00000000..5beea67b --- /dev/null +++ b/src/components/map-projects/conceptNames.js @@ -0,0 +1,94 @@ +/** + * Which of a concept's names a candidate shows (OpenConceptLab/ocl_issues#2909). + * + * A candidate leads with its display name in the project's input language, + * falling back to the display name OCL computes from the repository's default + * locale. The name the match was made on comes second, with its locale, and + * only when it differs from the name the candidate leads with. + * + * Pure — no React, no lodash — so the rules are unit-testable. + */ + +import { primarySubtag } from './normalizers.js' + +const FULLY_SPECIFIED = /^fully[\s_-]?specified$/i + +const sameName = (a, b) => { + const normalize = text => (text || '').replace(/\s+/g, ' ').trim().toLowerCase() + return normalize(a) === normalize(b) +} + +// The highlighted synonym with the best match: an exact match first, then the +// longest highlighted run, then one that starts with it. +const getBestSynonym = (synonyms = []) => { + return synonyms + .map(text => { + const matches = [...text.matchAll(/(.*?)<\/em>/g)]; + const longestMatch = matches.reduce( + (a, b) => (b[1].length > a.length ? b[1] : a), + "" + ); + + const emTag = `${longestMatch}`; + return { + text, + length: longestMatch.length, + isExact: text.trim() === emTag, + startsWith: text.startsWith(emTag) + }; + }) + .sort((a, b) => { + if (b.isExact !== a.isExact) return b.isExact ? 1 : -1; // ✅ exact ALWAYS first + if (b.length !== a.length) return b.length - a.length; // longer match + if (b.startsWith !== a.startsWith) return b.startsWith ? 1 : -1; + return 0; + })[0]?.text; +}; + +/** + * The name a concept leads with in `inputLocale` (matched on its primary + * subtag, so `pt` takes a `pt-BR` name): the display name when it is already + * in that language, else that language's preferred name, then its fully + * specified name, then its first. With no name in that language, or no input + * language, it is the concept's `display_name`, which OCL computes from the + * repository's default locale. + */ +export const localizedDisplayName = (concept, inputLocale) => { + const displayName = concept?.display_name + const language = primarySubtag(inputLocale) + const names = language && Array.isArray(concept?.names) ? concept.names : [] + const inLanguage = names.filter(name => name?.name && primarySubtag(name.locale) === language) + if(!inLanguage.length || inLanguage.some(name => name.name === displayName)) + return displayName + return ( + inLanguage.find(name => name.locale_preferred) || + inLanguage.find(name => FULLY_SPECIFIED.test(name.name_type || '')) || + inLanguage[0] + ).name +} + +/** + * The name the match was made on, to show after `displayName` (the name the + * candidate leads with): `{html, locale}`, with the highlighted words in bold, + * or null when nothing matched or the match is `displayName` itself. A + * display-name (`name`) highlight means the concept's `display_name` matched; + * otherwise it is the best `synonyms` highlight. `filterLocales` (the + * project's name-locale filter, a comma-separated string) hides a + * matched name in any other language. + */ +export const getMatchedName = ({highlights, concept, displayName, filterLocales}) => { + const fragment = highlights?.name?.[0] || getBestSynonym(highlights?.synonyms) + if(!fragment) return null + const text = fragment.replace(/<\/?em>/g, '') + const names = Array.isArray(concept?.names) ? concept.names : [] + const entry = names.find(name => name?.name === text) || names.find(name => name?.name?.startsWith(text)) + if(sameName(text, displayName) || (entry && sameName(entry.name, displayName))) + return null + const allowed = (filterLocales || '').split(',').map(locale => primarySubtag(locale.trim())).filter(Boolean) + if(allowed.length && !allowed.includes(primarySubtag(entry?.locale))) + return null + return { + html: fragment.replaceAll('', "").replaceAll('', ''), + locale: entry?.locale + } +} diff --git a/src/components/map-projects/viewBuilders.js b/src/components/map-projects/viewBuilders.js index 719a3dd2..76997ce2 100644 --- a/src/components/map-projects/viewBuilders.js +++ b/src/components/map-projects/viewBuilders.js @@ -15,6 +15,8 @@ // imports, so the module is loadable by Node's native ESM resolver under // node:test. +import { localizedDisplayName } from './conceptNames.js' + const compact = arr => (arr || []).filter(x => x != null && x !== false) const values = obj => Object.values(obj || {}) const isNumber = v => typeof v === 'number' && !Number.isNaN(v) @@ -336,16 +338,17 @@ export const buildQualityRowViews = (rowState, conceptCache) => { /** * Sort RowViews by the chosen key. `rerank_score` and `algo_score` are - * numeric; `id` and `display_name` are alphabetical. Missing scores sort - * to the bottom in `desc` order via a -1 sentinel. + * numeric; `id` and `display_name` are alphabetical, `display_name` on the + * name shown in `inputLocale` (the project's input language). Missing scores + * sort to the bottom in `desc` order via a -1 sentinel. */ -export const sortRowViews = (views, sortBy, order) => { +export const sortRowViews = (views, sortBy, order, inputLocale) => { const valueFor = view => { switch(sortBy) { case 'rerank_score': return view.conceptRow?.rerank_score ?? -1 case 'algo_score': return view.candidate?.score ?? -1 case 'id': return view.conceptDefinition?.id || view.conceptDefinition?.reference?.code || '' - case 'display_name': return view.conceptDefinition?.display_name || '' + case 'display_name': return localizedDisplayName(view.conceptDefinition, inputLocale) || '' default: return 0 } } @@ -411,6 +414,41 @@ export const conceptForMapping = (rowView) => { } } +/** + * The row view of a mapped target: the one whose concept has the target's + * OCL URL or, when either lacks one, its id. + */ +export const findTargetRowView = (views, target) => { + return views.find(view => { + const mapped = conceptForMapping(view) + if(!mapped) return false + if(target.url && mapped.url) + return mapped.url === target.url + return mapped.id === target.id + }) +} + +/** + * A mapped target with the `names` and the match highlights its row's + * candidates hold for it, so it can be named in the project's input language + * with the name it matched on (OpenConceptLab/ocl_issues#2909). A target + * restored from a saved project has only its id, name and URL. What the + * target has of its own is kept; a target no candidate holds is returned as + * it is. + */ +export const withCandidateNames = (target, rowState, conceptCache) => { + const view = findTargetRowView(buildQualityRowViews(rowState, conceptCache), target) + if(!view) return target + return { + ...target, + names: target.names || view.conceptDefinition.names, + search_meta: { + ...target.search_meta, + search_highlight: target.search_meta?.search_highlight || view.candidate?.highlights + } + } +} + /** * Compute the unified score (0-100 percentile) and the raw algorithm score * for a candidate+row. Unified score = per-(row, concept) rerank score diff --git a/src/components/search/TableResults.jsx b/src/components/search/TableResults.jsx index ed4d83f3..934f3662 100644 --- a/src/components/search/TableResults.jsx +++ b/src/components/search/TableResults.jsx @@ -14,6 +14,7 @@ import { visuallyHidden } from '@mui/utils'; import { filter, reject, get, sortBy, without, startCase, find, keys, map, times } from 'lodash' import { SECONDARY_COLORS } from '../../common/colors'; import { ALL_COLUMNS } from './ResultConstants'; +import { withExtraColumns } from './tableColumns.js'; const EnhancedTableHead = props => { const { t } = useTranslation() @@ -96,8 +97,7 @@ const TableResults = ({selected, bgColor, handleClick, handleRowClick, handleSel ALL_COLUMNS[resource] || [], column => nested ? column.nested !== false : column.global !== false ); - if(extraColumns?.length) - columns = [...columns, ...extraColumns] + columns = withExtraColumns(columns, extraColumns) columns = excludedColumns?.length ? reject(columns, column => excludedColumns?.includes(column.id)) : columns diff --git a/src/components/search/__tests__/tableColumns.test.js b/src/components/search/__tests__/tableColumns.test.js new file mode 100644 index 00000000..024a22e3 --- /dev/null +++ b/src/components/search/__tests__/tableColumns.test.js @@ -0,0 +1,32 @@ +/** + * Tests for withExtraColumns (tableColumns.js), which TableResults uses to add + * a caller's columns to a resource's base columns. + * + * Run with: npm test + */ + +import test from 'node:test' +import assert from 'node:assert/strict' + +import { withExtraColumns } from '../tableColumns.js' + +const BASE = [ + { id: 'id', value: 'id', permanent: true }, + { id: 'name', value: 'display_name', permanent: true, renderer: () => 'base' }, + { id: 'datatype', value: 'datatype' } +] + +test("withExtraColumns: an extra column with a base column's id replaces it in place (OpenConceptLab/ocl_issues#2909)", () => { + const name = { id: 'name', renderer: () => 'localized' } + const columns = withExtraColumns(BASE, [name, { id: 'mappings' }]) + assert.deepEqual(columns.map(column => column.id), ['id', 'name', 'datatype', 'mappings']) + assert.equal(columns[1].renderer(), 'localized') + assert.equal(columns[1].value, 'display_name') + assert.equal(columns[1].permanent, true) + assert.equal(BASE[1].renderer(), 'base') +}) + +test('withExtraColumns: no extra columns leaves the base ones', () => { + assert.deepEqual(withExtraColumns(BASE, undefined), BASE) + assert.deepEqual(withExtraColumns(BASE, []), BASE) +}) diff --git a/src/components/search/tableColumns.js b/src/components/search/tableColumns.js new file mode 100644 index 00000000..6490805d --- /dev/null +++ b/src/components/search/tableColumns.js @@ -0,0 +1,15 @@ +/** + * Add a caller's `extraColumns` to a resource's base table columns. An extra + * column with a base column's id replaces it where it stands, keeping the + * base column's other fields, so a caller can change how one column renders + * (OpenConceptLab/ocl_issues#2909: the Mapper names concepts in the project's + * input language). The rest are appended. Pure. + */ +export const withExtraColumns = (columns, extraColumns) => { + if(!extraColumns?.length) return columns + const byId = new Map(extraColumns.map(column => [column.id, column])) + return [ + ...columns.map(column => byId.has(column.id) ? {...column, ...byId.get(column.id)} : column), + ...extraColumns.filter(column => !columns.some(base => base.id === column.id)) + ] +} diff --git a/src/i18n/locales/en/translations.json b/src/i18n/locales/en/translations.json index f7e979ab..d4e5e235 100644 --- a/src/i18n/locales/en/translations.json +++ b/src/i18n/locales/en/translations.json @@ -429,7 +429,7 @@ "filter_name_locales_default_only": "This target declares only its default locale, so there are no other locales to filter by.", "filter_name_locales_open_settings": "Declare more locales in the repository's settings in TBv3.", "input_language": "Input Language", - "input_language_helper": "Language of the input data. Determines which target-repo name locales are sent to the OCL AI Assistant.", + "input_language_helper": "Language of the input data. Candidates lead with their name in this language, and it determines which target-repo name locales are sent to the OCL AI Assistant.", "ai_effective_locales": "AI will receive names in: {{locales}}", "ai_all_locales": "AI will receive names in: all locales (no filter)", "default_filter": "Default Filter", @@ -582,6 +582,7 @@ "view_existing_analysis": "View Existing", "run_new_analysis": "Run New Analysis", "matched": "Matched", + "matched_name": "Matched name", "dates": "Dates", "none": "None", "decision_changed_to_none": "Decision Changed to None", diff --git a/src/i18n/locales/es/translations.json b/src/i18n/locales/es/translations.json index 48768181..9324b98a 100644 --- a/src/i18n/locales/es/translations.json +++ b/src/i18n/locales/es/translations.json @@ -404,7 +404,7 @@ "filter_name_locales_default_only": "Este destino solo declara su locale predeterminado, así que no hay otros locales por los que filtrar.", "filter_name_locales_open_settings": "Declara más locales en la configuración del repositorio en TBv3.", "input_language": "Idioma de entrada", - "input_language_helper": "Idioma de los datos de entrada. Determina qué configuraciones regionales de los nombres del repositorio objetivo se envían al asistente de IA de OCL.", + "input_language_helper": "Idioma de los datos de entrada. Los candidatos muestran primero su nombre en este idioma, y determina qué configuraciones regionales de los nombres del repositorio objetivo se envían al asistente de IA de OCL.", "ai_effective_locales": "La IA recibirá los nombres en: {{locales}}", "ai_all_locales": "La IA recibirá los nombres en: todas las configuraciones regionales (sin filtro)", "default_filter": "Filtro Predeterminado", @@ -553,6 +553,7 @@ "view_existing_analysis": "Ver existente", "run_new_analysis": "Ejecutar nuevo análisis", "matched": "Coincidencias", + "matched_name": "Nombre coincidente", "dates": "Fechas", "none": "Ninguno", "decision_changed_to_none": "Decisión cambiada a Ninguno", diff --git a/src/i18n/locales/pt-BR/translations.json b/src/i18n/locales/pt-BR/translations.json index 0fa93c58..e784eea6 100644 --- a/src/i18n/locales/pt-BR/translations.json +++ b/src/i18n/locales/pt-BR/translations.json @@ -429,7 +429,7 @@ "filter_name_locales_default_only": "Este destino declara apenas seu idioma padrão, portanto não há outros idiomas para filtrar.", "filter_name_locales_open_settings": "Declare mais idiomas nas configurações do repositório no TBv3.", "input_language": "Idioma de entrada", - "input_language_helper": "Idioma dos dados de entrada. Determina quais idiomas de nome do repositório de destino são enviados ao Assistente de IA do OCL.", + "input_language_helper": "Idioma dos dados de entrada. Os candidatos mostram primeiro o nome neste idioma, e ele determina quais idiomas de nome do repositório de destino são enviados ao Assistente de IA do OCL.", "ai_effective_locales": "IA receberá nomes em: {{locales}}", "ai_all_locales": "IA receberá nomes em: todos os idiomas (sem filtro)", "default_filter": "Filtro Padrão", @@ -582,6 +582,7 @@ "view_existing_analysis": "Ver Existente", "run_new_analysis": "Executar Nova Análise", "matched": "Correspondido", + "matched_name": "Nome correspondente", "dates": "Datas", "none": "Nenhum", "decision_changed_to_none": "Decisão Alterada para Nenhum", diff --git a/src/i18n/locales/zh/translations.json b/src/i18n/locales/zh/translations.json index 4348e8b5..9e8cef0a 100644 --- a/src/i18n/locales/zh/translations.json +++ b/src/i18n/locales/zh/translations.json @@ -429,7 +429,7 @@ "filter_name_locales_default_only": "该目标仅声明了其默认语言环境,因此没有其他可用于筛选的语言环境。", "filter_name_locales_open_settings": "请在 TBv3 中的存储库设置里声明更多语言环境。", "input_language": "输入语言", - "input_language_helper": "输入数据的语言。决定将目标存储库名称的哪些区域设置发送给 OCL AI 助手。", + "input_language_helper": "输入数据的语言。候选项优先显示该语言的名称,并决定将目标存储库名称的哪些区域设置发送给 OCL AI 助手。", "ai_effective_locales": "AI 将收到以下区域设置的名称:{{locales}}", "ai_all_locales": "AI 将收到所有区域设置的名称(不筛选)", "default_filter": "默认筛选器", @@ -578,6 +578,7 @@ "view_existing_analysis": "查看现有", "run_new_analysis": "运行新的分析", "matched": "已匹配", + "matched_name": "匹配名称", "dates": "日期", "none": "无", "decision_changed_to_none": "决策已更改为无",