Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion src/components/common/__tests__/quotaErrors.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,10 @@ test('getQuotaError: maps quota and cap error codes to a meter and kind', () =>
})
assert.equal(getQuotaError({error_code: 'ai_assistant_calls_limit_reached'}).meter, 'ai_assistant_calls')
assert.equal(getQuotaError({error_code: 'ai_assistant_change_comments_limit_reached'}).meter, 'ai_assistant_change_comments')
// A decision call is an OCL AI Assistant call to the user: same meter label and copy.
assert.deepEqual(getQuotaError({error_code: 'ai_assistant_decision_calls_limit_reached', limit: 100, used: 100}), {
meter: 'ai_assistant_calls', kind: 'quota', errorCode: 'ai_assistant_decision_calls_limit_reached',
})
assert.equal(getQuotaError({error_code: 'mapper_projects_limit_reached'}).kind, 'cap')
assert.equal(getQuotaError({error_code: 'mapper_rows_per_project_limit_reached'}).kind, 'cap')
})
Expand All @@ -19,7 +23,7 @@ test('getQuotaError: unwraps axios error responses', () => {
})

test('getQuotaError: ignores permission errors and unrelated payloads', () => {
for (const error_code of ['mapper_access_denied', 'mapper_projects_not_entitled', 'ai_assistant_calls_not_entitled', 'validation_error'])
for (const error_code of ['mapper_access_denied', 'mapper_projects_not_entitled', 'ai_assistant_calls_not_entitled', 'ai_assistant_decision_calls_not_entitled', 'validation_error'])
assert.equal(isQuotaError({error_code}), false, error_code)
assert.equal(getQuotaError(null), null)
assert.equal(getQuotaError({detail: 'Nope'}), null)
Expand Down
2 changes: 2 additions & 0 deletions src/components/common/limitAction.js
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@ const LIMIT_SUBJECTS = {
mapper_projects_not_entitled: {capability: 'mapper.projects'},
ai_assistant_calls_limit_reached: {capability: 'ai_assistant.calls'},
ai_assistant_calls_not_entitled: {capability: 'ai_assistant.calls'},
ai_assistant_decision_calls_limit_reached: {capability: 'ai_assistant.decision_calls'},
ai_assistant_decision_calls_not_entitled: {capability: 'ai_assistant.decision_calls'},
ai_assistant_change_comments_limit_reached: {capability: 'ai_assistant.change_comments'},
mapper_access_denied: {permission: 'users.mapper_use'},
mapper_org_projects_denied: {permission: 'users.mapper_org_projects'},
Expand Down
2 changes: 2 additions & 0 deletions src/components/common/quotaErrors.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
const QUOTA_ERRORS = {
mapper_match_operations_limit_reached: {meter: 'match_operations', kind: 'quota'},
ai_assistant_calls_limit_reached: {meter: 'ai_assistant_calls', kind: 'quota'},
// A decision call is an AI Assistant call to the user, so it shares the meter label and copy.
ai_assistant_decision_calls_limit_reached: {meter: 'ai_assistant_calls', kind: 'quota'},
ai_assistant_change_comments_limit_reached: {meter: 'ai_assistant_change_comments', kind: 'quota'},
mapper_projects_limit_reached: {meter: 'projects', kind: 'cap'},
mapper_rows_per_project_limit_reached: {meter: 'rows', kind: 'cap'},
Expand Down
4 changes: 4 additions & 0 deletions src/components/map-projects/AICandidatesAnalysis.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,9 @@ const AICandidatesAnalysis = ({ analysis: analysisProp, onClose, sx, isInProgres
{getAlternateIds() || '-'}
</Field>
</div>
{
// A decision model writes no narrative: no empty block.
narrative &&
<div style={{position: 'relative'}}>
<Typography
ref={narrativeRef}
Expand All @@ -140,6 +143,7 @@ const AICandidatesAnalysis = ({ analysis: analysisProp, onClose, sx, isInProgres
</Link>
}
</div>
}
{
expanded &&
<Link component='button' type='button' underline='hover' sx={{fontSize: 12}} onClick={() => setExpanded(false)}>
Expand Down
6 changes: 4 additions & 2 deletions src/components/map-projects/Discuss.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -60,8 +60,10 @@ const Discuss = ({ logs, onAdd }) => {
const newMapType = log.extras?.new_map_type || log.extras?.newMapType || log.extras?.map_type || log.extras?.mapType || '-'
return t('map_project.bulk_log_map_type_changed', {oldMapType, newMapType})
}
if(['AIRecommendation'].includes(log.action))
return `${log.action}: ${log.description?.narrative || log.description}`
if(['AIRecommendation'].includes(log.action)) {
const text = log.description?.narrative || log.description
return text ? `${log.action}: ${text}` : log.action
}
if(['commented'].includes(log.action)) {
return <b>{log.description}</b>
}
Expand Down
29 changes: 16 additions & 13 deletions src/components/map-projects/MapProject.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -83,7 +83,7 @@ import ConceptDetailsPanel from './ConceptDetailsPanel'
import { buildPanelPayload } from './openConceptPanel'
import LoaderDialog from '../common/LoaderDialog'
import Error403 from '../errors/Error403'
import { HEADERS, SEMANTIC_SEARCH_HEADERS, ROW_STATES, VIEWS, DECISION_TABS, ROW_STAGES, PROMPTS_KEY_DEFAULT, PROMPTS_ACTION_TYPE_DEFAULT, SCORES_COLOR } from './constants'
import { HEADERS, SEMANTIC_SEARCH_HEADERS, ROW_STATES, VIEWS, DECISION_TABS, ROW_STAGES, PROMPTS_KEY_DEFAULT, PROMPTS_DECISION_KEY_DEFAULT, PROMPTS_ACTION_TYPE_DEFAULT, SCORES_COLOR } from './constants'
import { updateColumnState, resuggestColumns, applyTemplateColumns } from './columnSuggestions'
import MapProjectDeleteConfirmDialog from './MapProjectDeleteConfirmDialog';
import ConfigurationForm from './ConfigurationForm'
Expand Down Expand Up @@ -115,7 +115,7 @@ import MapperQuotaChip from './MapperQuotaChip'
import MapProjectBreadcrumb from './MapProjectBreadcrumb'
import { getPreviewLimitError, isAIPreviewLimitError } from './previewLimits'
import { formatRowNumbers, rowNumberOf, runMatchBatch, requestSingleMatch, runWithConcurrency, INTERACTIVE_WAIT_CAP_MS } from './matchBatch'
import { getAIAssistantChoices, getModelUsed, getPromptTemplateKey, createLatestRequestGate } from './aiVisibility'
import { getAIAssistantChoices, getModelUsed, getPromptTemplateKey, createLatestRequestGate, getDefaultPromptTemplateKey, getAIRecommendationDescription } from './aiVisibility'
import { DEFAULT_ENCODER_MODEL } from './rerankerModels'
import { normalizeAlgorithmInvocation, hasSuccessfulAlgorithmResponse, getAlgorithmStagesFromResponses, lookupStatusRank, buildRecommendableConceptEntry, stripConstantClassAndDatatype, buildLookupConceptUrl, serializeCandidates, toStoredRowMatchState } from './normalizers'
import { createLookupSession, LOOKUP_NOT_FOUND } from './lookupSession'
Expand Down Expand Up @@ -5692,9 +5692,12 @@ const MapProject = () => {
return /^https?:\/\//.test(invokeURI) ? invokeURI : new URL(invokeURI, AI_ASSISTANT_API_URL).toString()
}, [AI_ASSISTANT_API_URL, getResolvedPromptTemplateURI])

// A project that names no template runs the default its user is entitled to.
const defaultPromptTemplateKey = getDefaultPromptTemplateKey({user, chatKey: PROMPTS_KEY_DEFAULT, decisionKey: PROMPTS_DECISION_KEY_DEFAULT})

const getConfiguredPromptTemplateKey = React.useCallback(() => (
projectPromptTemplateKey || PROMPTS_KEY_DEFAULT
), [projectPromptTemplateKey])
projectPromptTemplateKey || defaultPromptTemplateKey
), [projectPromptTemplateKey, defaultPromptTemplateKey])

const fetchAIModels = () => {
if(!AIModels.length) {
Expand All @@ -5719,7 +5722,7 @@ const MapProject = () => {

const getPromptTemplateRef = React.useCallback((template = promptTemplate) => {
const key = getPromptTemplateKey({
canSelect: canSelectAIModel, selectedKey: template?.key, configuredKey: projectPromptTemplateKey, fallbackKey: template?.key || PROMPTS_KEY_DEFAULT
canSelect: canSelectAIModel, selectedKey: template?.key, configuredKey: projectPromptTemplateKey, fallbackKey: template?.key || defaultPromptTemplateKey
})
if(!key)
return undefined
Expand All @@ -5733,7 +5736,7 @@ const MapProject = () => {
version: runTemplate?.version || getPromptTemplateVersionFromURL(uri) || null,
uri: uri || null
}
}, [canSelectAIModel, getPromptTemplateVersionFromURL, projectPromptTemplateKey, promptTemplate])
}, [canSelectAIModel, defaultPromptTemplateKey, getPromptTemplateVersionFromURL, projectPromptTemplateKey, promptTemplate])

const getSelectedAIModel = React.useCallback((modelId = AIModel) => (
find(AIModels, {id: modelId})
Expand Down Expand Up @@ -5812,17 +5815,17 @@ const MapProject = () => {
return

const configuredKey = getConfiguredPromptTemplateKey()
const nextTemplate = find(promptTemplates, {key: configuredKey}) || find(promptTemplates, {key: PROMPTS_KEY_DEFAULT}) || promptTemplates[0]
const nextTemplate = find(promptTemplates, {key: configuredKey}) || find(promptTemplates, {key: defaultPromptTemplateKey}) || promptTemplates[0]
if(!nextTemplate)
return

setPromptTemplate(nextTemplate)
setAIModel(getDefaultAIModelId(nextTemplate))
}, [getConfiguredPromptTemplateKey, getDefaultAIModelId, canSelectAIModel, promptTemplates])
}, [getConfiguredPromptTemplateKey, getDefaultAIModelId, canSelectAIModel, promptTemplates, defaultPromptTemplateKey])

const resolvePromptTemplateForInvocation = React.useCallback(async (template = promptTemplate) => {
const key = getPromptTemplateKey({
canSelect: canSelectAIModel, selectedKey: template?.key, configuredKey: projectPromptTemplateKey, fallbackKey: PROMPTS_KEY_DEFAULT
canSelect: canSelectAIModel, selectedKey: template?.key, configuredKey: projectPromptTemplateKey, fallbackKey: defaultPromptTemplateKey
})
if(!key || !AI_ASSISTANT_API_URL)
throw new Error(t('map_project.ai_prompt_template_unavailable'))
Expand All @@ -5841,7 +5844,7 @@ const MapProject = () => {
uri: getResolvedPromptTemplateURI(resolvedTemplate, key),
version: resolvedTemplate?.version || getPromptTemplateVersionFromURL(getResolvedPromptTemplateURI(resolvedTemplate, key)) || null
}
}, [AI_ASSISTANT_API_URL, canSelectAIModel, getPromptTemplateVersionFromURL, getResolvedPromptTemplateURI, projectPromptTemplateKey, promptTemplate, t])
}, [AI_ASSISTANT_API_URL, canSelectAIModel, defaultPromptTemplateKey, getPromptTemplateVersionFromURL, getResolvedPromptTemplateURI, projectPromptTemplateKey, promptTemplate, t])


const getProjectMetadata = () => {
Expand Down Expand Up @@ -5981,7 +5984,7 @@ const MapProject = () => {
// 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
return Boolean(concepts?.length) && !hasCurrentAnalysis(analysis[_row.__index], getCandidatePoolFingerprint(concepts))
return Boolean(concepts?.length) && !hasCurrentAnalysis(analysis[_row.__index], getCandidatePoolFingerprint(concepts), getPromptTemplateRef())
})

// Counted only while the dialog shows it, over the scope's rows, and again
Expand Down Expand Up @@ -6037,7 +6040,7 @@ const MapProject = () => {
}
const v2 = isNumber(__index) ? buildV2RecommendationPayload(__index) : null
const candidatePoolFingerprint = getCandidatePoolFingerprint(v2?.recommendable_concepts)
const alreadyAnalyzed = isAutoMatch && hasCurrentAnalysis(existingAnalyses, candidatePoolFingerprint)
const alreadyAnalyzed = isAutoMatch && hasCurrentAnalysis(existingAnalyses, candidatePoolFingerprint, resolvedPromptTemplate)
if(isNumber(__index) && repoVersion && !alreadyAnalyzed && (v2?.recommendable_concepts?.length || 0) > 0) {
if(!isBulk)
GAService.recordActionEvent('MapProject', 'ai_assistant_run', undefined, { mode: 'single' })
Expand Down Expand Up @@ -6146,7 +6149,7 @@ const MapProject = () => {
// Assistant runs the template's default for everyone but staff.
const modelUsed = getModelUsed(response.data) || requestedModel?.id || null
const modelUsedRef = modelUsed ? (find(AIModels, {id: modelUsed}) || {id: modelUsed, name: modelUsed}) : null
log({created_at: timestamp, action: 'AIRecommendation', description: get(response.data, 'output.rationale') || get(response.data, 'rationale'), extras: {...response.data, model: modelUsedRef, prompt_template: promptTemplateRef, prompt_template_uri: promptTemplateRef?.uri}}, __index)
log({created_at: timestamp, action: 'AIRecommendation', description: getAIRecommendationDescription(response.data), extras: {...response.data, model: modelUsedRef, prompt_template: promptTemplateRef, prompt_template_uri: promptTemplateRef?.uri}}, __index)
const resolvedTemplate = response.data?.template || {}
const resolvedVersion = resolvedTemplate.version || promptTemplateRef?.version || null
const resolvedPromptRef = {
Expand Down
36 changes: 35 additions & 1 deletion src/components/map-projects/__tests__/aiVisibility.test.js
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import test from 'node:test'
import assert from 'node:assert/strict'

import { AI_OUTPUT_LOCALE_PERMISSION, canSeeAIInternals, getAIAssistantChoices, getModelUsed, getPromptTemplateKey, createLatestRequestGate } from '../aiVisibility.js'
import { AI_OUTPUT_LOCALE_PERMISSION, canSeeAIInternals, getAIAssistantChoices, getModelUsed, getPromptTemplateKey, createLatestRequestGate, getDefaultPromptTemplateKey, getAIRecommendationDescription } from '../aiVisibility.js'

test('canSeeAIInternals: staff and superusers see the model, prompt template, URI and raw JSON', () => {
assert.equal(canSeeAIInternals({ is_staff: true }), true)
Expand Down Expand Up @@ -96,3 +96,37 @@ test('regression: AI models load before the project, and the late default respon
assert.deepEqual(loaded, { key: 'custom' }, 'the stale default response must not replace the project template')
assert.equal(keyFor('custom'), 'custom')
})

const KEYS = { chatKey: 'chat-key', decisionKey: 'decision-key' }
const withCapabilities = capabilities => ({ capabilities })

test('getDefaultPromptTemplateKey: a user with generative calls runs the chat template', () => {
assert.equal(getDefaultPromptTemplateKey({ user: withCapabilities([{ name: 'ai_assistant.calls', limit: 6, used: 0 }, { name: 'ai_assistant.decision_calls', limit: 100, used: 0 }]), ...KEYS }), 'chat-key')
})

test('getDefaultPromptTemplateKey: decision calls without generative calls runs the decision template', () => {
assert.equal(getDefaultPromptTemplateKey({ user: withCapabilities([{ name: 'ai_assistant.decision_calls', limit: 100, used: 0 }]), ...KEYS }), 'decision-key')
})

test('getDefaultPromptTemplateKey: a blocked (-1) or missing allowance does not count as entitled', () => {
assert.equal(getDefaultPromptTemplateKey({ user: withCapabilities([{ name: 'ai_assistant.calls', limit: -1, used: 0 }, { name: 'ai_assistant.decision_calls', limit: 5, used: 0 }]), ...KEYS }), 'decision-key')
assert.equal(getDefaultPromptTemplateKey({ user: withCapabilities([]), ...KEYS }), 'chat-key')
assert.equal(getDefaultPromptTemplateKey({ user: undefined, ...KEYS }), 'chat-key')
})

test('getAIRecommendationDescription: keeps the rationale when there is one, string or narrative object', () => {
assert.equal(getAIRecommendationDescription({ output: { rationale: 'because' } }), 'because')
assert.deepEqual(getAIRecommendationDescription({ output: { rationale: { narrative: 'n' } } }), { narrative: 'n' })
assert.equal(getAIRecommendationDescription({ rationale: 'top level' }), 'top level')
})

test('getAIRecommendationDescription: a decision answer without rationale describes the recommendation and primary code', () => {
const data = { output: { recommendation: 'RECOMMEND', primary_candidate: { canonical_reference: { code: 'E11.9' } } } }
assert.equal(getAIRecommendationDescription(data), 'RECOMMEND E11.9')
})

test('getAIRecommendationDescription: never undefined', () => {
assert.equal(getAIRecommendationDescription({ output: { recommendation: 'REJECT' } }), 'REJECT')
assert.equal(getAIRecommendationDescription({ output: {} }), '')
assert.equal(getAIRecommendationDescription(undefined), '')
})
26 changes: 26 additions & 0 deletions src/components/map-projects/__tests__/autoMatchRows.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -235,6 +235,32 @@ test('hasCurrentAnalysis: an analysis saved before fingerprints existed is redon
assert.equal(hasCurrentAnalysis([{ output: { recommendation: 'RECOMMEND' } }], fp), false)
})

test('hasCurrentAnalysis: same pool but another template version is redone, the same version is current', () => {
const fp = getCandidatePoolFingerprint([{ concept_key: 'a' }])
const saved = version => [{ candidate_pool_fingerprint: fp, prompt_template: { key: 'match-recommend-normalized', version } }]
assert.equal(hasCurrentAnalysis(saved('2'), fp, { key: 'match-recommend-normalized', version: '2' }), true)
assert.equal(hasCurrentAnalysis(saved('1'), fp, { key: 'match-recommend-normalized', version: '2' }), false)
})

test('hasCurrentAnalysis: same pool but another template key (chat vs decision) is redone', () => {
const fp = getCandidatePoolFingerprint([{ concept_key: 'a' }])
const saved = [{ candidate_pool_fingerprint: fp, prompt_template: { key: 'match-recommend-normalized', version: '2' } }]
assert.equal(hasCurrentAnalysis(saved, fp, { key: 'match-recommend-decision', version: '2' }), false)
})

test('hasCurrentAnalysis: an analysis without a recorded template version, or a run without a known one, is redone', () => {
const fp = getCandidatePoolFingerprint([{ concept_key: 'a' }])
const noVersion = [{ candidate_pool_fingerprint: fp, prompt_template: { key: 'k' } }]
assert.equal(hasCurrentAnalysis(noVersion, fp, { key: 'k', version: '2' }), false)
assert.equal(hasCurrentAnalysis([{ candidate_pool_fingerprint: fp, prompt_template: { key: 'k', version: '2' } }], fp, { key: 'k', version: null }), false)
assert.equal(hasCurrentAnalysis([{ candidate_pool_fingerprint: fp }], fp, { key: 'k', version: '2' }), false)
})

test('hasCurrentAnalysis: without a template to compare, only the pool decides (as before)', () => {
const fp = getCandidatePoolFingerprint([{ concept_key: 'a' }])
assert.equal(hasCurrentAnalysis([{ candidate_pool_fingerprint: fp }], fp), true)
})

test('hasCurrentAnalysis: no analysis yet, or no pool to compare, is not current', () => {
const fp = getCandidatePoolFingerprint(pool(['loinc|2336-6', 'ocl-search']))
assert.equal(hasCurrentAnalysis([], fp), false)
Expand Down
9 changes: 9 additions & 0 deletions src/components/map-projects/__tests__/previewLimits.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@ test('isPreviewLimitError: recognizes mapper match, rerank access, and AI previe
'mapper_access_denied',
'ai_assistant_calls_limit_reached',
'ai_assistant_calls_not_entitled',
'ai_assistant_decision_calls_limit_reached',
'ai_assistant_decision_calls_not_entitled',
'mapper_ai_assistant_denied',
'mapper_scispacy_denied',
]
Expand Down Expand Up @@ -52,6 +54,13 @@ test('AI preview errors use AI Assistant dialog copy', () => {
assert.equal(isAIPreviewLimitError('mapper_match_operations_limit_reached'), false)
})

test('decision call quota errors are AI preview limits and reuse the AI Assistant copy', () => {
for (const error_code of ['ai_assistant_decision_calls_limit_reached', 'ai_assistant_decision_calls_not_entitled']) {
assert.equal(isAIPreviewLimitError(error_code), true, error_code)
assert.equal(PREVIEW_LIMIT_COPY_KEY[error_code], 'ai_assistant', error_code)
}
})

// Limits reached go to QuotaDialog; PreviewLimitDialog shows the rest.
test('every refusal PreviewLimitDialog shows has its own message in each language', () => {
for (const locale of ['en', 'es', 'pt-BR', 'zh']) {
Expand Down
Loading
Loading