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
4 changes: 2 additions & 2 deletions src/components/map-projects/Candidates.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -377,7 +377,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}) => {
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 { t } = useTranslation();
const [sortBy, setSortBy] = React.useState('rerank_score')
const [groupBy, setGroupBy] = React.useState('quality')
Expand Down Expand Up @@ -436,7 +436,7 @@ const Candidates = ({rowIndex, rowState, conceptCache, targetCanonical, targetRe
const canFetchMore = hasAnyView
const algoStagesValue = values(rowStage || {}).filter((_, i) => Object.keys(rowStage || {})[i] !== 'recommend')
const areAlgoRun = algoStagesValue.length > 0 && algoStagesValue.every(v => v === 1)
const { label, status: progressStatus } = getRowProgressLabel(rowStage, algosSelected, {t, capacityWait});
const { label, status: progressStatus } = getRowProgressLabel(rowStage, algosSelected, {t, capacityWait, throttle})
// Waiting out a busy server, or given up on for now: not a spinner (ocl_issues#2849).
const isCapacityStatus = ['capacity_wait', 'throttled'].includes(progressStatus)

Expand Down
133 changes: 85 additions & 48 deletions src/components/map-projects/MapProject.jsx

Large diffs are not rendered by default.

70 changes: 69 additions & 1 deletion src/components/map-projects/__tests__/rowProgress.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,8 @@
import test from 'node:test'
import assert from 'node:assert/strict'

import { getRowProgressLabel } from '../rowProgress.js'
import { formatClockTime, getCapacityWaitLabel, getRowProgressLabel, mergeCapacityWaits } from '../rowProgress.js'
import { CAPACITY_LIMIT, RATE_LIMIT } from '../../../services/capacity.js'

const ALGOS = [{id: 'ocl-semantic'}, {id: 'ocl-bridge'}]
const t = key => key
Expand Down Expand Up @@ -71,3 +72,70 @@ test('getRowProgressLabel: a done or failed rerank leaves the label as before',
assert.equal(getRowProgressLabel({'ocl-semantic': 1, 'ocl-bridge': 1, rerank: 1}, ALGOS, {t}).label, undefined)
assert.equal(getRowProgressLabel({'ocl-semantic': 1, 'ocl-bridge': 1, rerank: -2}, ALGOS, {t}).label, undefined)
})

// ── ocl_issues#2865: capacity limit vs rate limit ───────────────────────────

const tWith = (key, values) => values ? `${key} ${JSON.stringify(values)}` : key
const formatTime = ms => `t+${ms}`
const capacityWait = {limit: CAPACITY_LIMIT, retryAt: 20000}
const rateLimitWait = {limit: RATE_LIMIT, retryAt: 20000}

test('getCapacityWaitLabel: a capacity 429 keeps today\'s wording', () => {
assert.equal(getCapacityWaitLabel(capacityWait, {t: tWith, formatTime}), 'map_project.waiting_for_capacity')
assert.equal(getCapacityWaitLabel(capacityWait, {t: tWith, formatTime, short: true}), 'map_project.waiting_for_capacity_short')
assert.equal(getCapacityWaitLabel(true, {t: tWith, formatTime}), 'map_project.waiting_for_capacity')
})

test('getCapacityWaitLabel: a rate-limit 429 says the user is sending requests too quickly, and when it retries', () => {
assert.equal(getCapacityWaitLabel(rateLimitWait, {t: tWith, formatTime}), 'map_project.rate_limited {"time":"t+20000"}')
assert.equal(getCapacityWaitLabel(rateLimitWait, {t: tWith, formatTime, short: true}), 'map_project.rate_limited_short')
})

test('getRowProgressLabel: a row waiting out a rate-limit 429 says so, not "Waiting for capacity"', () => {
assert.deepEqual(
getRowProgressLabel({'ocl-semantic': 0, 'ocl-bridge': -1}, ALGOS, {t: tWith, capacityWait: rateLimitWait, formatTime}),
{label: 'map_project.rate_limited {"time":"t+20000"}', status: 'capacity_wait'},
)
assert.deepEqual(
getRowProgressLabel({'ocl-semantic': 0, 'ocl-bridge': -1}, ALGOS, {t: tWith, capacityWait, formatTime}),
{label: 'map_project.waiting_for_capacity', status: 'capacity_wait'},
)
})

test('mergeCapacityWaits: a capacity wait wins; between rate-limit waits, the later retry', () => {
const later = {limit: RATE_LIMIT, retryAt: 50000}
assert.equal(mergeCapacityWaits(null, rateLimitWait), rateLimitWait)
assert.equal(mergeCapacityWaits(rateLimitWait, undefined), rateLimitWait)
assert.equal(mergeCapacityWaits(rateLimitWait, capacityWait), capacityWait)
assert.equal(mergeCapacityWaits(capacityWait, later), capacityWait)
assert.equal(mergeCapacityWaits(rateLimitWait, later), later)
assert.equal(mergeCapacityWaits(later, rateLimitWait), later)
})

// Refused rows retain the limit even when no wait was announced (ocl_issues#2865).
test('getRowProgressLabel: a refused rate limit shows when the row or rerank can run again', () => {
for(const stages of [
{'ocl-semantic': -4, 'ocl-bridge': 1},
{'ocl-semantic': 1, 'ocl-bridge': 1, rerank: -4},
]) {
assert.deepEqual(getRowProgressLabel(stages, ALGOS, {t: tWith, throttle: rateLimitWait, formatTime}), {
label: 'map_project.row_rate_limited {"time":"t+20000"}', status: 'throttled',
})
assert.deepEqual(getRowProgressLabel(stages, ALGOS, {t: tWith, throttle: capacityWait, formatTime}), {
label: 'map_project.row_throttled', status: 'throttled',
})
}
})

test('formatClockTime: a retry today is a clock time; another day includes the weekday', () => {
const now = () => new Date(2026, 9, 7, 12).getTime()
const format = (date, options) => ({day: date.getDate(), ...options})
assert.deepEqual(formatClockTime(new Date(2026, 9, 7, 15).getTime(), {now, format}), {
day: 7, hour: 'numeric', minute: '2-digit', second: '2-digit',
})
for(const [date, day] of [[new Date(2026, 9, 8, 15), 8], [new Date(2026, 10, 7, 15), 7]]) {
assert.deepEqual(formatClockTime(date.getTime(), {now, format}), {
day, hour: 'numeric', minute: '2-digit', second: '2-digit', weekday: 'short',
})
}
})
9 changes: 5 additions & 4 deletions src/components/map-projects/matchBatch.js
Original file line number Diff line number Diff line change
Expand Up @@ -48,10 +48,10 @@ const getFailure = (err, attempts) => ({
* @param {object} opts
* @param {number[]} opts.rowIndexes the batch's row __index values
* @param {function} opts.send attempt => Promise<axios response>; must reject on failure
* @param {function} opts.setStage (rowIndex, stage) => void
* @param {function} opts.setStage (rowIndex, stage, throttle) => void
* @param {function} opts.onRowFinished rowIndex => void
* @param {function} opts.onRowFailed (rowIndex, {error, status, attempts, previewLimit}) => void
* @param {function} [opts.onRowThrottled] (rowIndex, {attempts, waitedMs}) => void
* @param {function} [opts.onRowThrottled] (rowIndex, {attempts, waitedMs, limit, retryAt}) => void
* @param {function} [opts.onPreviewLimit] err => void, for a preview-limit 403 (never retried)
* @param {function} [opts.onWait] info => void, as each wait starts (see requestWithCapacityRetry)
* @param {function} [opts.onWaitEnd] () => void, as each wait ends
Expand Down Expand Up @@ -84,8 +84,8 @@ export const runMatchBatch = async ({
}
if(result.reason === 'throttled') {
rowIndexes.forEach(index => {
setStage(index, -4)
onRowThrottled?.(index, {attempts: result.attempts, waitedMs: result.waitedMs})
setStage(index, -4, {limit: result.limit, retryAt: result.retryAt})
onRowThrottled?.(index, {attempts: result.attempts, waitedMs: result.waitedMs, limit: result.limit, retryAt: result.retryAt})
})
return []
}
Expand Down Expand Up @@ -199,5 +199,6 @@ export const requestSingleMatch = async (send, { retryOptions = {} } = {}) => {
errorBody: hasServerBody ? data : { detail: error, status },
previewLimit: isPreviewLimitError(err),
throttled: result.reason === 'throttled',
...(result.reason === 'throttled' ? {limit: result.limit, retryAt: result.retryAt} : {}),
}
}
41 changes: 37 additions & 4 deletions src/components/map-projects/rowProgress.js
Original file line number Diff line number Diff line change
@@ -1,3 +1,34 @@
import { RATE_LIMIT } from '../../services/capacity.js'

// Day limits can reopen tomorrow (ocl_issues#2865).
export const formatClockTime = (ms, { now = Date.now, format = (date, options) => date.toLocaleTimeString([], options) } = {}) => {
const date = new Date(ms)
return format(date, {
hour: 'numeric', minute: '2-digit', second: '2-digit',
...(date.toDateString() === new Date(now()).toDateString() ? {} : {weekday: 'short'}),
})
}

// wait is {limit, retryAt}; anything but a rate limit reads as a capacity wait (ocl_issues#2865).
export const getCapacityWaitLabel = (wait, { t, short = false, formatTime = formatClockTime } = {}) => {
if(wait?.limit === RATE_LIMIT)
return short ?
t('map_project.rate_limited_short') :
t('map_project.rate_limited', {time: formatTime(wait.retryAt)})
return t(short ? 'map_project.waiting_for_capacity_short' : 'map_project.waiting_for_capacity')
}

// A capacity wait wins; between two rate-limit waits, the later retry.
export const mergeCapacityWaits = (a, b) => {
if(!a || !b)
return a || b
if(a.limit !== RATE_LIMIT)
return a
if(b.limit !== RATE_LIMIT)
return b
return b.retryAt > a.retryAt ? b : a
}

/**
* The row panel's progress chip: which of the row's algorithms is running or
* still to run. {label: false} before the row has stages; no label once every
Expand All @@ -8,13 +39,15 @@
* server stayed too busy for (-4) asks for a retry: it wasn't run, it didn't
* fail.
*/
export const getRowProgressLabel = (stageMap, algos, { t, capacityWait = false } = {}) => {
export const getRowProgressLabel = (stageMap, algos, { t, capacityWait = false, throttle, formatTime = formatClockTime } = {}) => {
if(stageMap === undefined)
return {label: false}
if(capacityWait)
return {label: t('map_project.waiting_for_capacity'), status: 'capacity_wait'}
return {label: getCapacityWaitLabel(capacityWait, {t, formatTime}), status: 'capacity_wait'}
if(!stageMap)
return {label: 'Preparing...', status: 'partial'}
const throttledLabel = () => throttle?.limit === RATE_LIMIT ?
t('map_project.row_rate_limited', {time: formatTime(throttle.retryAt)}) : t('map_project.row_throttled')

const stages = algos.map(k => stageMap[k.id]);

Expand All @@ -34,7 +67,7 @@ export const getRowProgressLabel = (stageMap, algos, { t, capacityWait = false }
if (stages.every(v => v === 1 || v === -3)) {
// The candidates are in, but the server stayed too busy to rank them.
if(stageMap.rerank === -4)
return {label: t('map_project.row_throttled'), status: 'throttled'}
return {label: throttledLabel(), status: 'throttled'}
return true
}

Expand All @@ -44,7 +77,7 @@ export const getRowProgressLabel = (stageMap, algos, { t, capacityWait = false }
}

if(stages.some(v => v === -4))
return {label: t('map_project.row_throttled'), status: 'throttled'}
return {label: throttledLabel(), status: 'throttled'}

return { label: 'Partially completed', status: 'partial' };
}
7 changes: 7 additions & 0 deletions src/i18n/locales/en/translations.json
Original file line number Diff line number Diff line change
Expand Up @@ -591,6 +591,13 @@
"match_request_failed": "Couldn't get candidates ({{error}}). Try again.",
"waiting_for_capacity": "Waiting for capacity: results may take longer due to demand",
"waiting_for_capacity_short": "Waiting for capacity",
"rate_limited": "You're sending requests too quickly: retrying at {{time}}",
"rate_limited_short": "Too many requests",
Comment thread
snyaggarwal marked this conversation as resolved.
"row_rate_limited": "Request limit reached: not matched yet. Try again after {{time}}",
"algorithm_rate_limited": "You've reached your request limit, so this row wasn't matched. Nothing failed: run it again after {{time}}.",
"auto_match_rows_rate_limited": "You've reached your request limit, so {{count}} input row(s) weren't matched: {{details}}. Nothing failed. Run Auto Match on those rows again after {{time}}.",
"row_rate_limited_log": "Request limit reached: not matched yet. Retry later",
"auto_match_rows_rate_limited_log": "You've reached your request limit, so {{count}} input row(s) weren't matched: {{details}}. Nothing failed. Run Auto Match on those rows again later.",
"row_throttled": "Server busy: not matched yet. Retry later",
"algorithm_throttled": "The server is busy, so this row wasn't matched this time. Nothing failed: run it again later.",
"auto_match_rows_throttled": "The server was busy, so {{count}} input row(s) weren't matched: {{details}}. Nothing failed. Run Auto Match on those rows again later to finish them.",
Expand Down
15 changes: 15 additions & 0 deletions src/i18n/locales/es/translations.json
Original file line number Diff line number Diff line change
Expand Up @@ -358,6 +358,7 @@
"score": "Puntaje",
"search_highlight": "Detalles de Coincidencia",
"matched_attributes": "Atributos Coincidentes",
"no_match_metrics": "No hay métricas de coincidencia de algoritmos disponibles para este concepto.",
"list_view": "Vista de lista",
"table_view": "Vista de tabla",
"select_all_rows": "Seleccionar todas las filas"
Expand Down Expand Up @@ -394,6 +395,10 @@
"filter_name_locales": "Filtrar Locales de Nombre",
"loading_name_locales": "Cargando Locales de Nombre...",
"filter_name_locales_helper": "Nota: Restringir candidatos a idiomas específicos limita las capacidades de coincidencia entre idiomas, pero puede mejorar la precisión de los resultados para ciertos conjuntos de datos, especialmente si los datos de entrada están en el mismo idioma que el idioma predeterminado del repositorio objetivo.",
"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.",
"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",
"matching_algorithm": "Algoritmo de Coincidencia",
"matching_algorithm_description": "Seleccione un algoritmo de coincidencia. <0>Aprenda más</0>",
Expand Down Expand Up @@ -491,6 +496,7 @@
"preview_limit_title_access": "El acceso al Mapper está limitado por ahora",
"preview_limit_title_custom_algorithms": "Los algoritmos externos no están en la vista previa",
"preview_limit_title_ai_assistant": "El Asistente de IA aún no está disponible",
"preview_limit_title_scispacy": "ScispaCy LOINC aún no está disponible",
"preview_limit_title_rows_not_entitled": "Las filas de este proyecto aún no forman parte de tu vista previa",
"preview_limit_title_match_operations_not_entitled": "Las operaciones de coincidencia aún no forman parte de tu cuenta",
"preview_limit_title_projects_not_entitled": "Los proyectos de mapeo aún no forman parte de tu cuenta",
Expand All @@ -508,6 +514,7 @@
"preview_limit_body_custom_algorithms_no_count": "Los algoritmos externos aún no forman parte de la vista previa. Usa los algoritmos integrados de OCL, o solicita más acceso abajo.",
"preview_limit_body_ai_assistant": "Tu vista previa gratuita cubre {{limit}} llamadas al Asistente de IA: has usado {{used}} de {{limit}}. La asignación puede continuar sin análisis de IA para las filas restantes.",
"preview_limit_body_ai_assistant_no_count": "El Asistente de IA no está disponible para tu cuenta. Solicita más acceso abajo y lo resolveremos.",
"preview_limit_body_scispacy_no_count": "La coincidencia con ScispaCy LOINC no está disponible para tu cuenta. Solicita más acceso abajo y lo resolveremos.",
"preview_limit_body_rows_not_entitled_no_count": "Este proyecto aún no tiene una asignación de filas configurada. Solicita más acceso abajo y lo resolveremos.",
"preview_limit_body_match_operations_not_entitled_no_count": "Tu cuenta aún no tiene acceso a operaciones de coincidencia configurado. Solicita más acceso abajo y lo resolveremos.",
"preview_limit_body_projects_not_entitled_no_count": "Tu cuenta aún no tiene acceso a proyectos de mapeo configurado. Solicita más acceso abajo y lo resolveremos.",
Expand Down Expand Up @@ -555,6 +562,13 @@
"match_request_failed": "No se pudieron obtener candidatos ({{error}}). Inténtalo de nuevo.",
"waiting_for_capacity": "Esperando capacidad: los resultados pueden tardar más debido a la demanda",
"waiting_for_capacity_short": "Esperando capacidad",
"rate_limited": "Estás enviando solicitudes demasiado rápido: se reintentará a las {{time}}",
"rate_limited_short": "Demasiadas solicitudes",
Comment thread
snyaggarwal marked this conversation as resolved.
"row_rate_limited": "Límite de solicitudes alcanzado: aún sin procesar. Reintenta después de {{time}}",
"algorithm_rate_limited": "Has alcanzado tu límite de solicitudes, así que esta fila no se procesó. Nada falló: vuelve a ejecutarla después de {{time}}.",
"auto_match_rows_rate_limited": "Has alcanzado tu límite de solicitudes, así que {{count}} fila(s) de entrada no se procesaron: {{details}}. Nada falló. Vuelve a ejecutar Auto Match en esas filas después de {{time}}.",
"row_rate_limited_log": "Límite de solicitudes alcanzado: aún sin procesar. Reintenta más tarde",
"auto_match_rows_rate_limited_log": "Has alcanzado tu límite de solicitudes, así que {{count}} fila(s) de entrada no se procesaron: {{details}}. Nada falló. Vuelve a ejecutar Auto Match en esas filas más tarde.",
"row_throttled": "Servidor ocupado: aún sin procesar. Reintenta más tarde",
"algorithm_throttled": "El servidor está ocupado, así que esta fila no se procesó esta vez. Nada falló: vuelve a ejecutarla más tarde.",
"auto_match_rows_throttled": "El servidor estaba ocupado, así que {{count}} fila(s) de entrada no se procesaron: {{details}}. Nada falló. Vuelve a ejecutar Auto Match en esas filas más tarde para terminarlas.",
Expand Down Expand Up @@ -685,6 +699,7 @@
"ai_prompt_template_default_model": "Modelo predeterminado",
"set_ai_assistant_output_language": "Establecer el idioma de salida del asistente de IA",
"ai_assistant_output_locale": "Configuración regional de salida",
"ai_assistant_output_locale_helper": "Elige de la lista o escribe un código BCP-47 regional (p. ej., pt-BR, fr-CA).",
"output_locale": "Idioma de salida",
"reranker_configuration": "Configuración del reranker",
"reranker_configuration_description": "Elija el modelo de reranker utilizado para calcular las puntuaciones unificadas de este proyecto. El modelo predeterminado se selecciona automáticamente, o puede ingresar un nombre de modelo personalizado.",
Expand Down
Loading
Loading