Skip to content
Merged
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
14 changes: 3 additions & 11 deletions src/common/utils.js
Original file line number Diff line number Diff line change
Expand Up @@ -434,17 +434,9 @@ export const getFHIRServerConfigFromCurrentContext = () => {
return getLocalFHIRServerConfig() || getDefaultFHIRServerConfig();
}

export const canSwitchServer = () => {
const user = getCurrentUser();

return Boolean(
getSelectedServerConfig() ||
get(user, 'is_superuser') ||
hasAuthGroup(user, 'server')
);
}

export const hasAuthGroup = (user, groupName) => Boolean(find(user?.auth_groups, group => group.includes(groupName)))
// Matches the group's exact name, never a group whose name contains it
// (OpenConceptLab/ocl_online#517).
export const hasAuthGroup = (user, groupName) => Boolean(user?.auth_groups?.includes(groupName))

export const canViewOperationsPanel = () => {
const user = getCurrentUser()
Expand Down
16 changes: 7 additions & 9 deletions src/components/map-projects/MapProject.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -91,7 +91,7 @@ import ConfigurationForm from './ConfigurationForm'
import Controls from './Controls'
import DataGridControls from './DataGridControls'
import { getPreviewEligibleRowIndexes, getRowsToProcess, countAIOnlyRows, getAutoMatchAIOnOpen, takeRunRows, clearRunRowStatuses, canRunAlgorithm, spendsMatchQuota, shouldStopAIStep, getAIRequestIdempotencyKey, getCandidatePoolFingerprint, hasCurrentAnalysis, getScispacyRowResults, getPendingRowLookups, waitForLookups, AI_LOOKUP_WAIT_MS, RERANK_LOOKUP_WAIT_MS, RERANK_MAX_IN_FLIGHT, isScispacyWarmingUp } from './autoMatchRows'
import { aiOnByDefault, getMaxRunRows } from '@openconceptlab/oclmap-extensions'
import { aiOnByDefault, getMaxRunRows, getMapperSettings } from '@openconceptlab/oclmap-extensions'
import { createAutosaveScheduler, createLatestSender, saveOnLeave, trackSave, whenSaved, installUnloadGuard } from './autosave'
import MatchSummaryCard from './MatchSummaryCard'
import MappingDecisionResult from './MappingDecisionResult'
Expand Down Expand Up @@ -628,16 +628,14 @@ const MapProject = () => {
const OCL_ONLINE_API_URL = window.OCL_ONLINE_API_URL || process.env.OCL_ONLINE_API_URL
const inAIAssistantGroup = Boolean(hasCapability(user, 'users.mapper_ai_assistant') && AI_ASSISTANT_API_URL)
const isCoreUser = hasAuthGroup(user, 'core_user')
// Batch size and concurrent requests per algorithm, by the user's tier
// (ocl_online#274, ocl_issues#2849)
const requestLimits = getRequestLimits(user)
// Batch size and concurrent requests per algorithm the user can set
// (ocl_online#274, ocl_issues#2849, ocl_online#517)
const requestLimits = getRequestLimits(getMapperSettings(user))
const mapperPreview = getMapperPreview()
// Only staff choose the AI model and prompt template (canSelectAIModel gates
// both pickers). Core, early-access and unlimited-AI users keep the output
// language; preview users get the project's template and its default model.
const { canSelectAIModel, canSetAIOutputLocale } = getAIAssistantChoices(
user, {unlimitedAICalls: mapperPreview.aiAssistantCalls.unlimited}
)
// both pickers). Users granted it keep the output language; everyone else
// gets the project's template and its default model.
const { canSelectAIModel, canSetAIOutputLocale } = getAIAssistantChoices(user)
const CANDIDATES_LIMIT = 15
const canBridge = bridgeRef?.current?.canBridge()
const canScispacy = Boolean(
Expand Down
21 changes: 10 additions & 11 deletions 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 { canSeeAIInternals, getAIAssistantChoices, getModelUsed, getPromptTemplateKey, createLatestRequestGate } from '../aiVisibility.js'
import { AI_OUTPUT_LOCALE_PERMISSION, canSeeAIInternals, getAIAssistantChoices, getModelUsed, getPromptTemplateKey, createLatestRequestGate } 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 All @@ -13,9 +13,8 @@ test('canSeeAIInternals: a non-staff core user does not', () => {
assert.equal(canSeeAIInternals({ is_staff: false, is_superuser: false, auth_groups: ['core_user'] }), false)
})

test('canSeeAIInternals: early-access, preview and signed-out users do not', () => {
assert.equal(canSeeAIInternals({ auth_groups: ['early_access'] }), false)
assert.equal(canSeeAIInternals({ auth_groups: ['preview'] }), false)
test('canSeeAIInternals: other and signed-out users do not', () => {
assert.equal(canSeeAIInternals({ auth_groups: ['mapper_ai_assistant'] }), false)
assert.equal(canSeeAIInternals(null), false)
assert.equal(canSeeAIInternals(undefined), false)
})
Expand All @@ -26,17 +25,17 @@ test('getAIAssistantChoices: staff and superusers choose the model, the prompt t
assert.deepEqual(getAIAssistantChoices({ is_superuser: true }), both)
})

test('getAIAssistantChoices: non-staff core, early-access and unlimited-AI users choose the output language only', () => {
test('getAIAssistantChoices: non-staff users granted the output language choose it only', () => {
const localeOnly = { canSelectAIModel: false, canSetAIOutputLocale: true }
assert.deepEqual(getAIAssistantChoices({ is_staff: false, auth_groups: ['core_user'] }), localeOnly)
assert.deepEqual(getAIAssistantChoices({ auth_groups: ['early_access'] }), localeOnly)
assert.deepEqual(getAIAssistantChoices({ auth_groups: ['preview'] }, { unlimitedAICalls: true }), localeOnly)
assert.deepEqual(getAIAssistantChoices({ permissions: ['users.mapper_ai_assistant', AI_OUTPUT_LOCALE_PERMISSION] }), localeOnly)
assert.equal(AI_OUTPUT_LOCALE_PERMISSION, 'users.mapper_ai_output_locale')
})

test('getAIAssistantChoices: preview and signed-out users choose nothing', () => {
test('getAIAssistantChoices: other users choose nothing, whatever their groups', () => {
const none = { canSelectAIModel: false, canSetAIOutputLocale: false }
assert.deepEqual(getAIAssistantChoices({ auth_groups: ['preview'] }), none)
assert.deepEqual(getAIAssistantChoices({ auth_groups: ['preview'] }, { unlimitedAICalls: false }), none)
assert.deepEqual(getAIAssistantChoices({ permissions: ['users.mapper_ai_assistant'] }), none)
assert.deepEqual(getAIAssistantChoices({ auth_groups: ['mapper-approved'], permissions: [] }), none)
assert.deepEqual(getAIAssistantChoices({ auth_groups: ['mapper_ai_output_locale'] }), none)
assert.deepEqual(getAIAssistantChoices(null), none)
assert.deepEqual(getAIAssistantChoices(undefined), none)
})
Expand Down
106 changes: 39 additions & 67 deletions src/components/map-projects/__tests__/requestLimits.test.js
Original file line number Diff line number Diff line change
@@ -1,112 +1,84 @@
/**
* Auto Match request limits per algorithm (ocl_online#274).
* Auto Match request settings per algorithm (ocl_online#274, #517).
*
* Non-core users can't run batches of more than 10 rows, whether they type it
* in the form or a project saved it earlier. Early-access users keep up to 5
* requests in flight; preview users 2 (ocl_issues#2849), as many previews at
* once could fill the server's matching capacity. A preview project has at
* most 25 rows (3 batches of 10), so 2 costs one more batch's time. Core users
* and staff keep the form's full range.
* The getMapperSettings checkpoint sets the most rows per request and
* requests in flight a user can choose. The open build allows the form's full
* range, and a project's saved settings run as they are. Below that range,
* saved settings run within it.
*
* Run with: npm test
*/

import test from 'node:test'
import assert from 'node:assert/strict'

import { hasFullRequestLimits, getRequestLimits, getRequestSettings, applyRequestSettings } from '../requestLimits.js'
import { getRequestLimits, getRequestSettings, applyRequestSettings } from '../requestLimits.js'
import { getMapperSettings } from '../autoMatchCheck.js'

const FULL = getRequestLimits({ is_staff: true })
const EARLY_ACCESS = getRequestLimits({ auth_groups: ['early_access'] })
const PREVIEW = getRequestLimits({ auth_groups: ['preview'] })
const OPEN = getRequestLimits(getMapperSettings(null))
const NARROW = getRequestLimits({ batchSize: 10, concurrentRequests: 5 })

test('hasFullRequestLimits: core users, staff and superusers', () => {
assert.equal(hasFullRequestLimits({ auth_groups: ['core_user'] }), true)
assert.equal(hasFullRequestLimits({ is_staff: true }), true)
assert.equal(hasFullRequestLimits({ is_superuser: true }), true)
test('getRequestLimits: the open build allows the form\'s full range', () => {
assert.deepEqual(OPEN, { batchSize: 1000, concurrentRequests: 50, capped: false })
assert.deepEqual(getRequestLimits(), OPEN)
assert.deepEqual(getRequestLimits(null), OPEN)
})

test('hasFullRequestLimits: preview, early-access and signed-out users are capped', () => {
assert.equal(hasFullRequestLimits({ auth_groups: ['early_access'] }), false)
assert.equal(hasFullRequestLimits({ auth_groups: [] }), false)
assert.equal(hasFullRequestLimits({ is_staff: false, auth_groups: ['preview'] }), false)
assert.equal(hasFullRequestLimits(null), false)
assert.equal(hasFullRequestLimits(undefined), false)
test('getRequestLimits: settings below the form\'s range cap it', () => {
assert.deepEqual(NARROW, { batchSize: 10, concurrentRequests: 5, capped: true })
assert.deepEqual(getRequestLimits({ batchSize: 1000, concurrentRequests: 2 }), { batchSize: 1000, concurrentRequests: 2, capped: true })
})

test('getRequestLimits: the form range for core users and staff', () => {
assert.deepEqual(getRequestLimits({ auth_groups: ['core_user'] }), { batchSize: 1000, concurrentRequests: 50, capped: false })
assert.deepEqual(FULL, { batchSize: 1000, concurrentRequests: 50, capped: false })
test('getRequestLimits: settings stay within the form\'s range', () => {
assert.deepEqual(getRequestLimits({ batchSize: 5000, concurrentRequests: 80 }), OPEN)
})

test('getRequestLimits: early access, 10 rows × 5 requests', () => {
assert.deepEqual(EARLY_ACCESS, { batchSize: 10, concurrentRequests: 5, capped: true })
test('getRequestLimits: missing or invalid settings fall back to the form\'s range', () => {
assert.deepEqual(getRequestLimits({ batchSize: 0, concurrentRequests: 'abc' }), OPEN)
assert.deepEqual(getRequestLimits({ batchSize: '10' }), { batchSize: 10, concurrentRequests: 50, capped: true })
})

test('getRequestLimits: preview, 10 rows × 2 requests', () => {
assert.deepEqual(PREVIEW, { batchSize: 10, concurrentRequests: 2, capped: true })
})

test('getRequestLimits: a user in no tier, or signed out, gets the preview limits', () => {
assert.deepEqual(getRequestLimits({ auth_groups: [] }), PREVIEW)
assert.deepEqual(getRequestLimits({ auth_groups: ['mapper_ai_assistant'] }), PREVIEW)
assert.deepEqual(getRequestLimits(null), PREVIEW)
})

test('getRequestLimits: the highest tier wins', () => {
assert.deepEqual(getRequestLimits({ auth_groups: ['preview', 'early_access'] }), EARLY_ACCESS)
assert.deepEqual(getRequestLimits({ auth_groups: ['early_access', 'core_user'] }), FULL)
})

test('getRequestSettings: a capped user\'s saved project runs within the cap', () => {
test('getRequestSettings: a saved project runs within capped settings', () => {
// the ticket's case: batch 1 × 25 concurrent semantic requests
assert.deepEqual(getRequestSettings({ batch_size: 1, concurrent_requests: 25 }, EARLY_ACCESS), { batchSize: 1, concurrentRequests: 5 })
assert.deepEqual(getRequestSettings({ batch_size: 1, concurrent_requests: 25 }, PREVIEW), { batchSize: 1, concurrentRequests: 2 })
assert.deepEqual(getRequestSettings({ batch_size: 1000, concurrent_requests: 50 }, EARLY_ACCESS), { batchSize: 10, concurrentRequests: 5 })
assert.deepEqual(getRequestSettings({ batch_size: 1000, concurrent_requests: 50 }, PREVIEW), { batchSize: 10, concurrentRequests: 2 })
assert.deepEqual(getRequestSettings({ batch_size: 1, concurrent_requests: 25 }, NARROW), { batchSize: 1, concurrentRequests: 5 })
assert.deepEqual(getRequestSettings({ batch_size: 1000, concurrent_requests: 50 }, NARROW), { batchSize: 10, concurrentRequests: 5 })
// ocl-search's shipped default is 50 rows per batch
assert.deepEqual(getRequestSettings({ batch_size: 50, concurrent_requests: 2 }, EARLY_ACCESS), { batchSize: 10, concurrentRequests: 2 })
assert.deepEqual(getRequestSettings({ batch_size: 50, concurrent_requests: 2 }, NARROW), { batchSize: 10, concurrentRequests: 2 })
})

test('getRequestSettings: values within the cap are kept', () => {
assert.deepEqual(getRequestSettings({ batch_size: 10, concurrent_requests: 2 }, EARLY_ACCESS), { batchSize: 10, concurrentRequests: 2 })
assert.deepEqual(getRequestSettings({ batch_size: 3, concurrent_requests: 5 }, EARLY_ACCESS), { batchSize: 3, concurrentRequests: 5 })
assert.deepEqual(getRequestSettings({ batch_size: 3, concurrent_requests: 1 }, PREVIEW), { batchSize: 3, concurrentRequests: 1 })
test('getRequestSettings: values within capped settings are kept', () => {
assert.deepEqual(getRequestSettings({ batch_size: 3, concurrent_requests: 5 }, NARROW), { batchSize: 3, concurrentRequests: 5 })
})

test('getRequestSettings: core users and staff run what the project saved, even past the form\'s range', () => {
assert.deepEqual(getRequestSettings({ batch_size: 1, concurrent_requests: 25 }, FULL), { batchSize: 1, concurrentRequests: 25 })
assert.deepEqual(getRequestSettings({ batch_size: 50, concurrent_requests: 2 }, FULL), { batchSize: 50, concurrentRequests: 2 })
assert.deepEqual(getRequestSettings({ batch_size: 2000, concurrent_requests: 75 }, FULL), { batchSize: 2000, concurrentRequests: 75 })
test('getRequestSettings: uncapped, a project runs what it saved, even past the form\'s range', () => {
assert.deepEqual(getRequestSettings({ batch_size: 1, concurrent_requests: 25 }, OPEN), { batchSize: 1, concurrentRequests: 25 })
assert.deepEqual(getRequestSettings({ batch_size: 2000, concurrent_requests: 75 }, OPEN), { batchSize: 2000, concurrentRequests: 75 })
assert.deepEqual(getRequestSettings({ batch_size: 2000, concurrent_requests: 75 }), { batchSize: 2000, concurrentRequests: 75 })
})

test('getRequestSettings: missing or invalid values fall back to 10 rows × 1 request, as before', () => {
for (const limits of [FULL, EARLY_ACCESS, PREVIEW]) {
for (const limits of [OPEN, NARROW]) {
assert.deepEqual(getRequestSettings({}, limits), { batchSize: 10, concurrentRequests: 1 })
assert.deepEqual(getRequestSettings(undefined, limits), { batchSize: 10, concurrentRequests: 1 })
assert.deepEqual(getRequestSettings({ batch_size: 0, concurrent_requests: 0 }, limits), { batchSize: 10, concurrentRequests: 1 })
assert.deepEqual(getRequestSettings({ batch_size: 'abc', concurrent_requests: -3 }, limits), { batchSize: 10, concurrentRequests: 1 })
}
})

test('getRequestSettings: without limits, the preview cap applies', () => {
assert.deepEqual(getRequestSettings({ batch_size: 50, concurrent_requests: 25 }), { batchSize: 10, concurrentRequests: 2 })
})

test('getRequestSettings: numeric strings are read as numbers', () => {
assert.deepEqual(getRequestSettings({ batch_size: '8', concurrent_requests: '9' }, EARLY_ACCESS), { batchSize: 8, concurrentRequests: 5 })
assert.deepEqual(getRequestSettings({ batch_size: '8', concurrent_requests: '9' }, NARROW), { batchSize: 8, concurrentRequests: 5 })
})

test('applyRequestSettings: a capped user\'s algorithm carries the settings the run uses, for the run and its record', () => {
test('applyRequestSettings: capped, the algorithm carries the settings the run uses, for the run and its record', () => {
const algo = { id: 'ocl-semantic', type: 'ocl-semantic', batch_size: 1, concurrent_requests: 25, query_params: { semantic: true } }
assert.deepEqual(applyRequestSettings(algo, EARLY_ACCESS), { ...algo, batch_size: 1, concurrent_requests: 5 })
assert.deepEqual(applyRequestSettings(algo, PREVIEW), { ...algo, batch_size: 1, concurrent_requests: 2 })
assert.deepEqual(applyRequestSettings({ id: 'ocl-search' }, EARLY_ACCESS), { id: 'ocl-search', batch_size: 10, concurrent_requests: 1 })
assert.deepEqual(applyRequestSettings(algo, NARROW), { ...algo, batch_size: 1, concurrent_requests: 5 })
assert.deepEqual(applyRequestSettings({ id: 'ocl-search' }, NARROW), { id: 'ocl-search', batch_size: 10, concurrent_requests: 1 })
// the saved project is not changed
assert.equal(algo.concurrent_requests, 25)
})

test('applyRequestSettings: core users and staff get the algorithm back untouched', () => {
test('applyRequestSettings: uncapped, the algorithm comes back untouched', () => {
const algo = { id: 'ocl-semantic', batch_size: 2000, concurrent_requests: 75 }
assert.equal(applyRequestSettings(algo, FULL), algo)
assert.equal(applyRequestSettings(algo, OPEN), algo)
assert.equal(applyRequestSettings(algo), algo)
})
16 changes: 8 additions & 8 deletions src/components/map-projects/aiVisibility.js
Original file line number Diff line number Diff line change
@@ -1,20 +1,20 @@
// An AI recommendation's internals are staff-only (ocl_online#254): the model,
// the prompt template and its URI, and the raw JSON that carries them. Core,
// early-access and preview users see the assessment and the candidates only.
// the prompt template and its URI, and the raw JSON that carries them.
// Everyone else sees the assessment and the candidates only.
export const canSeeAIInternals = user => Boolean(user?.is_staff || user?.is_superuser)

const inAuthGroup = (user, group) => Boolean(user?.auth_groups?.some(name => name.includes(group)))

// What a user can choose for the OCL AI Assistant (ocl_online#259):
// - the model and the prompt template: staff only, like seeing them. The AI
// Assistant honours a model override from staff only, so everyone else runs
// the project's template on that template's default model.
// - the output language: staff, core, early-access and unlimited-AI users.
export const getAIAssistantChoices = (user, { unlimitedAICalls = false } = {}) => ({
// - the output language: staff, and users an admin has granted it to
// (OpenConceptLab/ocl_online#517).
export const AI_OUTPUT_LOCALE_PERMISSION = 'users.mapper_ai_output_locale'

export const getAIAssistantChoices = user => ({
canSelectAIModel: canSeeAIInternals(user),
canSetAIOutputLocale: Boolean(
canSeeAIInternals(user) || unlimitedAICalls ||
inAuthGroup(user, 'core_user') || inAuthGroup(user, 'early_access')
canSeeAIInternals(user) || user?.permissions?.includes(AI_OUTPUT_LOCALE_PERMISSION)
),
})

Expand Down
12 changes: 9 additions & 3 deletions src/components/map-projects/autoMatchCheck.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
// Checkpoints for AutoMatch (OpenConceptLab/ocl_online#465). The app imports
// them as '@openconceptlab/oclmap-extensions'; webpack resolves that name to an installed
// package when there is one, and to this file otherwise.
// Checkpoints for the Mapper (OpenConceptLab/ocl_online#465, #517). The app
// imports them as '@openconceptlab/oclmap-extensions'; webpack resolves that
// name to an installed package when there is one, and to this file otherwise.
//
// useAutoMatchCheck receives what the user has selected in the dialog:
// {open, autoMatchScope, rowsToMatchCount, totalRows, retrieveCandidates,
Expand All @@ -20,4 +20,10 @@ export const aiOnByDefault = () => true
// eslint-disable-next-line no-unused-vars
export const getMaxRunRows = run => null

// The most rows per matching request, and requests in flight per algorithm,
// that the user can set in the form: {batchSize, concurrentRequests}. A saved
// project runs within them too. Here, the form's full range.
// eslint-disable-next-line no-unused-vars
export const getMapperSettings = user => ({batchSize: 1000, concurrentRequests: 50})

export default useAutoMatchCheck
Loading
Loading