From 28f986d6ecd70fa7a11939622ae18aaaff0e096f Mon Sep 17 00:00:00 2001 From: Sunny Aggarwal Date: Tue, 6 Oct 2026 08:20:40 +0530 Subject: [PATCH 1/5] OpenConceptLab/ocl_issues#2865 | rate limit 429 and capacity limit 429 labeling/logging --- src/components/map-projects/MapProject.jsx | 32 ++++--- .../__tests__/rowProgress.test.js | 42 ++++++++- src/components/map-projects/rowProgress.js | 28 +++++- src/i18n/locales/en/translations.json | 2 + src/i18n/locales/es/translations.json | 2 + src/i18n/locales/zh/translations.json | 2 + src/services/__tests__/capacity.test.js | 92 +++++++++++++++++++ src/services/capacity.js | 28 ++++-- 8 files changed, 207 insertions(+), 21 deletions(-) diff --git a/src/components/map-projects/MapProject.jsx b/src/components/map-projects/MapProject.jsx index 668a79b6..74425f1d 100644 --- a/src/components/map-projects/MapProject.jsx +++ b/src/components/map-projects/MapProject.jsx @@ -116,6 +116,7 @@ import { normalizeAlgorithmInvocation, hasSuccessfulAlgorithmResponse, getAlgori import { parseConceptKey } from './conceptKey' import { getDefaultTargetRepoVersion, getProjectTargetRepoVersion, getTargetRepoVersionFromUrl, getTargetRepoVersionId } from './projectTargetRepo' import { buildBridgeTargetDownloadEntries, buildQualityRowViews, conceptBelongsToTargetRepo, conceptForMapping, formatBridgeTargetDownloadEntry, resolveAICandidateID, getScoreDetails, getAIAnalysisCandidateIDs } from './viewBuilders.js' +import { getCapacityWaitLabel, mergeCapacityWaits } from './rowProgress.js' import './MapProject.scss' import '../common/ResizablePanel.scss' @@ -240,7 +241,7 @@ const MapProject = () => { // The requests waiting for capacity now, each with its rows, for the // "Waiting for capacity" notices. const capacityWaitsRef = React.useRef(new Map()) - const [capacityWaitRows, setCapacityWaitRows] = React.useState(null) + const [capacityWaits, setCapacityWaits] = React.useState(null) // A run's rows the server stayed too busy for, by algorithm id ('rerank' // included), for the end-of-run notice. const throttledRunRowsRef = React.useRef({}) @@ -1759,31 +1760,38 @@ const MapProject = () => { } const syncCapacityWaits = () => { + if(!capacityWaitsRef.current.size) + return setCapacityWaits(null) const rows = {} - capacityWaitsRef.current.forEach(rowIndexes => rowIndexes.forEach(index => { rows[index] = true })) - setCapacityWaitRows(capacityWaitsRef.current.size ? rows : null) + let all = null + capacityWaitsRef.current.forEach(({rowIndexes, wait}) => { + rowIndexes.forEach(index => { rows[index] = mergeCapacityWaits(rows[index], wait) }) + all = mergeCapacityWaits(all, wait) + }) + setCapacityWaits({rows, wait: all}) } // onWait/onWaitEnd for one request on these rows. While it waits out a busy // server (a 429, or a pause another request's 429 started; not an error - // backoff), its rows show "Waiting for capacity", and its first wait goes in + // backoff), its rows show why (ocl_issues#2865), and its first wait goes in // each row's log with the server's capacity headers. const trackCapacityWait = (rowIndexes, logExtras = {}) => { const waitId = {} let logged = false return { - onWait: ({reason, retryAfterMs, capacity}) => { + onWait: ({reason, delayMs, retryAfterMs, capacity, limit}) => { if(reason === 'error') return - capacityWaitsRef.current.set(waitId, rowIndexes) + const wait = {limit, retryAt: Date.now() + delayMs} + capacityWaitsRef.current.set(waitId, {rowIndexes, wait}) syncCapacityWaits() if(logged) return logged = true rowIndexes.forEach(index => log({ action: 'capacity_wait', - description: t('map_project.waiting_for_capacity'), - extras: {...logExtras, reason, retry_after_ms: retryAfterMs ?? null, ...(capacity ? {capacity} : {})} + description: getCapacityWaitLabel(wait, {t}), + extras: {...logExtras, reason, limit, retry_after_ms: retryAfterMs ?? null, ...(capacity ? {capacity} : {})} }, index)) }, onWaitEnd: () => { @@ -6049,14 +6057,14 @@ const MapProject = () => { // A request is waiting out a busy server: the run is // slower, not stuck (ocl_issues#2849). // Short in the split view, where the full notice would be cut off. - capacityWaitRows && - + capacityWaits && + } color='warning' variant='outlined' size='small' - label={isSplitView ? t('map_project.waiting_for_capacity_short') : t('map_project.waiting_for_capacity')} + label={getCapacityWaitLabel(capacityWaits.wait, {t, short: isSplitView})} sx={{margin: '5px'}} /> @@ -6466,7 +6474,7 @@ const MapProject = () => { candidatesScore={candidatesScore} rowIndex={rowIndex} rowStage={rowStageRef.current[rowIndex]} - capacityWait={Boolean(capacityWaitRows?.[rowIndex])} + capacityWait={capacityWaits?.rows?.[rowIndex] || false} rowState={rowMatchStateRef.current[rowIndex]} conceptCache={conceptCache} targetCanonical={buildProjectContext()?.target_repo?.canonical_url} diff --git a/src/components/map-projects/__tests__/rowProgress.test.js b/src/components/map-projects/__tests__/rowProgress.test.js index a94a37c2..4775c978 100644 --- a/src/components/map-projects/__tests__/rowProgress.test.js +++ b/src/components/map-projects/__tests__/rowProgress.test.js @@ -10,7 +10,8 @@ import test from 'node:test' import assert from 'node:assert/strict' -import { getRowProgressLabel } from '../rowProgress.js' +import { 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 @@ -71,3 +72,42 @@ 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) +}) diff --git a/src/components/map-projects/rowProgress.js b/src/components/map-projects/rowProgress.js index 3f9d930a..5022db77 100644 --- a/src/components/map-projects/rowProgress.js +++ b/src/components/map-projects/rowProgress.js @@ -1,3 +1,27 @@ +import { RATE_LIMIT } from '../../services/capacity.js' + +const formatClockTime = ms => new Date(ms).toLocaleTimeString([], {hour: 'numeric', minute: '2-digit', second: '2-digit'}) + +// 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 @@ -8,11 +32,11 @@ * 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, formatTime } = {}) => { 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'} diff --git a/src/i18n/locales/en/translations.json b/src/i18n/locales/en/translations.json index c9595bc7..86125ce0 100644 --- a/src/i18n/locales/en/translations.json +++ b/src/i18n/locales/en/translations.json @@ -591,6 +591,8 @@ "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", "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.", diff --git a/src/i18n/locales/es/translations.json b/src/i18n/locales/es/translations.json index f08cdadd..669c8e33 100644 --- a/src/i18n/locales/es/translations.json +++ b/src/i18n/locales/es/translations.json @@ -555,6 +555,8 @@ "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", "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.", diff --git a/src/i18n/locales/zh/translations.json b/src/i18n/locales/zh/translations.json index 01047477..3de810fe 100644 --- a/src/i18n/locales/zh/translations.json +++ b/src/i18n/locales/zh/translations.json @@ -580,6 +580,8 @@ "match_request_failed": "无法获取候选项({{error}})。请重试。", "waiting_for_capacity": "正在等待容量:由于需求量大,结果可能需要更长时间", "waiting_for_capacity_short": "正在等待容量", + "rate_limited": "您发送请求的速度过快:将于 {{time}} 重试", + "rate_limited_short": "请求过多", "row_throttled": "服务器繁忙:尚未匹配。请稍后重试", "algorithm_throttled": "服务器繁忙,本次未能匹配此行。没有出错:请稍后重新运行。", "auto_match_rows_throttled": "服务器繁忙,{{count}} 个输入行未能匹配:{{details}}。没有出错。请稍后对这些行重新运行自动匹配以完成匹配。", diff --git a/src/services/__tests__/capacity.test.js b/src/services/__tests__/capacity.test.js index 062d8e5d..99a46410 100644 --- a/src/services/__tests__/capacity.test.js +++ b/src/services/__tests__/capacity.test.js @@ -16,13 +16,16 @@ import test from 'node:test' import assert from 'node:assert/strict' import { + CAPACITY_LIMIT, CAPACITY_WAIT_CAP_MS, createCapacityGate, createLimiter, getCapacityHeaders, getRetryAfterMs, + getThrottleLimit, HEAVY_REQUEST_TIMEOUT_MS, LIGHT_REQUEST_TIMEOUT_MS, + RATE_LIMIT, requestWithCapacityRetry, sleepUnlessCancelled, untilCancelled, @@ -558,3 +561,92 @@ test('requestWithCapacityRetry: an attempt that timed out (axios ECONNABORTED) i assert.equal(result.ok, true) assert.equal(sent.length, 2) }) + +// ── ocl_issues#2865: capacity limit vs rate limit ─────────────────────────── + +const capacityExceeded = retryAfter => throttled(retryAfter, {data: {error_code: 'capacity_exceeded', detail: 'Server at capacity'}}) + +test('getThrottleLimit: capacity_exceeded is the capacity limit; any other 429 is the rate limit', () => { + assert.equal(getThrottleLimit(capacityExceeded(5).response), CAPACITY_LIMIT) + assert.equal(getThrottleLimit(throttled(5, {data: {detail: 'Request was throttled.'}}).response), RATE_LIMIT) + assert.equal(getThrottleLimit(throttled(5, {data: {error_code: 'rate_limited'}}).response), RATE_LIMIT) + assert.equal(getThrottleLimit(throttled(undefined, {data: 'Too Many Requests'}).response), RATE_LIMIT) + assert.equal(getThrottleLimit(undefined), RATE_LIMIT) +}) + +test('requestWithCapacityRetry: a capacity 429 waits its Retry-After, as the capacity limit', async () => { + const clock = virtualClock() + const waits = [] + const result = await requestWithCapacityRetry(scripted(capacityExceeded(20), ok()).send, { + now: clock.now, sleep: clock.sleep, random: () => 0, onWait: info => waits.push(info), + }) + + assert.equal(result.ok, true) + assert.equal(clock.t, 20000) + assert.deepEqual(waits.map(({reason, limit, delayMs, retryAfterMs}) => ({reason, limit, delayMs, retryAfterMs})), + [{reason: 'throttled', limit: CAPACITY_LIMIT, delayMs: 20000, retryAfterMs: 20000}]) +}) + +test('requestWithCapacityRetry: a rate-limit 429 with Retry-After waits it just the same, as the rate limit', async () => { + const clock = virtualClock() + const waits = [] + const result = await requestWithCapacityRetry(scripted(throttled(20, {data: {detail: 'Request was throttled.'}}), ok()).send, { + now: clock.now, sleep: clock.sleep, random: () => 0, onWait: info => waits.push(info), + }) + + assert.equal(result.ok, true) + assert.equal(clock.t, 20000) + assert.deepEqual(waits.map(({reason, limit, delayMs, retryAfterMs}) => ({reason, limit, delayMs, retryAfterMs})), + [{reason: 'throttled', limit: RATE_LIMIT, delayMs: 20000, retryAfterMs: 20000}]) +}) + +test('requestWithCapacityRetry: a rate-limit 429 without Retry-After backs off as before, as the rate limit, with its delay', async () => { + const clock = virtualClock() + const waits = [] + const result = await requestWithCapacityRetry(scripted(throttled(), throttled(), ok()).send, { + now: clock.now, sleep: clock.sleep, random: () => 0, onWait: info => waits.push(info), + }) + + assert.equal(result.ok, true) + assert.deepEqual(waits.map(({limit, delayMs, retryAfterMs}) => ({limit, delayMs, retryAfterMs})), [ + {limit: RATE_LIMIT, delayMs: 5000, retryAfterMs: null}, + {limit: RATE_LIMIT, delayMs: 10000, retryAfterMs: null}, + ]) +}) + +test('requestWithCapacityRetry: a request held by the shared gate gets the limit of the 429 that paused it', async () => { + const clock = virtualClock() + const heldBy = async limit => { + const gate = createCapacityGate({now: clock.now}) + gate.pause(10000, limit) + const waits = [] + await requestWithCapacityRetry(scripted(ok()).send, { + gate, now: clock.now, sleep: clock.sleep, random: () => 0, onWait: info => waits.push([info.reason, info.limit]), + }) + return waits + } + + assert.deepEqual(await heldBy(RATE_LIMIT), [['paused', RATE_LIMIT]]) + assert.deepEqual(await heldBy(CAPACITY_LIMIT), [['paused', CAPACITY_LIMIT]]) +}) + +test('createCapacityGate: the limit is that of the 429 holding the gate longest', () => { + const clock = virtualClock() + const gate = createCapacityGate({now: clock.now}) + gate.pause(30000, CAPACITY_LIMIT) + gate.pause(5000, RATE_LIMIT) + assert.equal(gate.limit(), CAPACITY_LIMIT) + gate.pause(60000, RATE_LIMIT) + assert.equal(gate.limit(), RATE_LIMIT) + gate.pause(120000) + assert.equal(gate.limit(), CAPACITY_LIMIT) +}) + +test('requestWithCapacityRetry: an error backoff carries no limit', async () => { + const clock = virtualClock() + const waits = [] + await requestWithCapacityRetry(scripted(httpError(503), ok()).send, { + now: clock.now, sleep: clock.sleep, random: () => 0, onWait: info => waits.push([info.reason, info.limit]), + }) + assert.deepEqual(waits, [['error', null]]) +}) diff --git a/src/services/capacity.js b/src/services/capacity.js index b4e0ab42..75184c0e 100644 --- a/src/services/capacity.js +++ b/src/services/capacity.js @@ -77,6 +77,13 @@ export const getRetryAfterMs = (response, { now = Date.now } = {}) => { return typeof seconds === 'number' && Number.isFinite(seconds) && seconds >= 0 ? seconds * 1000 : null } +export const CAPACITY_LIMIT = 'capacity' +export const RATE_LIMIT = 'rate_limit' + +// Which limit refused a 429 (ocl_issues#2865). +export const getThrottleLimit = response => + response?.data?.error_code === 'capacity_exceeded' ? CAPACITY_LIMIT : RATE_LIMIT + const CAPACITY_HEADERS = { decision: ['x-ocl-capacity-decision', String], limit: ['x-ocl-capacity-limit', Number], @@ -130,10 +137,18 @@ export const sleepUnlessCancelled = async (ms, isCancelled = () => false, { slee */ export const createCapacityGate = ({ now = Date.now } = {}) => { let resumeAt = 0 + let pausedBy = CAPACITY_LIMIT const pausedForMs = () => Math.max(resumeAt - now(), 0) return { - pause: ms => { resumeAt = Math.max(resumeAt, now() + ms) }, + pause: (ms, limit = CAPACITY_LIMIT) => { + const at = now() + ms + if(at >= resumeAt) { + resumeAt = at + pausedBy = limit + } + }, pausedForMs, + limit: () => pausedBy, isPaused: () => pausedForMs() > 0, // Resolves true once the gate is open, false if cancelled first, or // 'timeout' once maxMs has passed with the gate still paused (other @@ -163,7 +178,7 @@ export const createCapacityGate = ({ now = Date.now } = {}) => { * @param {number} [opts.maxWaitMs] how long to wait in all before ending "throttled" * @param {number} [opts.maxRetries] retries for a network error or 502/503/504 (429s don't count) * @param {function} [opts.isRetryable] err => boolean; replaces the network-or-gateway check - * @param {function} [opts.onWait] ({reason: 'throttled'|'paused'|'error', delayMs, status, retryAfterMs, capacity}) => void + * @param {function} [opts.onWait] ({reason: 'throttled'|'paused'|'error', delayMs, status, retryAfterMs, capacity, limit}) => void * @param {function} [opts.onWaitEnd] () => void, after each wait, however it ended * @param {function} [opts.onCapacity] capacityHeaders => void, for each response that carries them * @returns {Promise<{ok: true, response, attempts, waitedMs}|{ok: false, reason: 'throttled'|'cancelled'|'error', error, attempts, waitedMs}>} @@ -215,7 +230,7 @@ export const requestWithCapacityRetry = async (send, { const budgetMs = maxWaitMs - waitedMs if(pauseMs > budgetMs) return end({ ok: false, reason: 'throttled', error: lastError }) - onWait?.({ reason: 'paused', delayMs: pauseMs, status: null, retryAfterMs: pauseMs }) + onWait?.({ reason: 'paused', delayMs: pauseMs, status: null, retryAfterMs: pauseMs, limit: gate.limit?.() ?? CAPACITY_LIMIT }) const startedAt = now() let outcome = await gate.wait(isCancelled, { sleep, pollMs, maxMs: budgetMs }) // Spread out the requests the pause held back, within the budget. @@ -241,6 +256,7 @@ export const requestWithCapacityRetry = async (send, { reportCapacity(err?.response) if(err?.response?.status === 429) { const retryAfterMs = getRetryAfterMs(err.response, { now }) + const limit = getThrottleLimit(err.response) const baseMs = retryAfterMs === null ? Math.min(DEFAULT_THROTTLE_WAIT_MS * 2 ** throttles, MAX_DEFAULT_THROTTLE_WAIT_MS) : Math.max(retryAfterMs, MIN_THROTTLE_WAIT_MS) @@ -252,8 +268,8 @@ export const requestWithCapacityRetry = async (send, { // asking for what was refused instead. if(waitedMs + delayMs > maxWaitMs) return end({ ok: false, reason: 'throttled', error: err }) - gate?.pause(baseMs) - if(!(await waitFor(delayMs, { reason: 'throttled', status: 429, retryAfterMs, capacity: getCapacityHeaders(err.response) }))) + gate?.pause(baseMs, limit) + if(!(await waitFor(delayMs, { reason: 'throttled', status: 429, retryAfterMs, capacity: getCapacityHeaders(err.response), limit }))) return cancelled() continue } @@ -263,7 +279,7 @@ export const requestWithCapacityRetry = async (send, { baseDelayMs * backoffFactor ** errorRetries * (1 - jitterFactor + random() * jitterFactor * 2) : Math.min(retryAfterMs, MAX_ERROR_RETRY_AFTER_MS) * (1 + random() * THROTTLE_JITTER) errorRetries += 1 - if(!(await waitFor(delayMs, { reason: 'error', status: err?.response?.status ?? null, retryAfterMs }))) + if(!(await waitFor(delayMs, { reason: 'error', status: err?.response?.status ?? null, retryAfterMs, limit: null }))) return cancelled() continue } From 12c5db79100fd4032c9c34024457cd94aab6620f Mon Sep 17 00:00:00 2001 From: Sunny Aggarwal Date: Thu, 8 Oct 2026 08:10:48 +0530 Subject: [PATCH 2/5] Apply batched suggestions from code review Co-authored-by: Jonathan Payne --- src/components/map-projects/rowProgress.js | 9 ++++++++- src/i18n/locales/en/translations.json | 5 +++++ src/i18n/locales/es/translations.json | 5 +++++ src/i18n/locales/zh/translations.json | 5 +++++ src/services/capacity.js | 6 +++--- 5 files changed, 26 insertions(+), 4 deletions(-) diff --git a/src/components/map-projects/rowProgress.js b/src/components/map-projects/rowProgress.js index 5022db77..730966c9 100644 --- a/src/components/map-projects/rowProgress.js +++ b/src/components/map-projects/rowProgress.js @@ -1,6 +1,13 @@ import { RATE_LIMIT } from '../../services/capacity.js' -const formatClockTime = ms => new Date(ms).toLocaleTimeString([], {hour: 'numeric', minute: '2-digit', second: '2-digit'}) +// 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 } = {}) => { diff --git a/src/i18n/locales/en/translations.json b/src/i18n/locales/en/translations.json index 86125ce0..a59a9fc3 100644 --- a/src/i18n/locales/en/translations.json +++ b/src/i18n/locales/en/translations.json @@ -593,6 +593,11 @@ "waiting_for_capacity_short": "Waiting for capacity", "rate_limited": "You're sending requests too quickly: retrying at {{time}}", "rate_limited_short": "Too many requests", + "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.", diff --git a/src/i18n/locales/es/translations.json b/src/i18n/locales/es/translations.json index 669c8e33..6fc0cd32 100644 --- a/src/i18n/locales/es/translations.json +++ b/src/i18n/locales/es/translations.json @@ -557,6 +557,11 @@ "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", + "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.", diff --git a/src/i18n/locales/zh/translations.json b/src/i18n/locales/zh/translations.json index 3de810fe..6e3b875a 100644 --- a/src/i18n/locales/zh/translations.json +++ b/src/i18n/locales/zh/translations.json @@ -582,6 +582,11 @@ "waiting_for_capacity_short": "正在等待容量", "rate_limited": "您发送请求的速度过快:将于 {{time}} 重试", "rate_limited_short": "请求过多", + "row_rate_limited": "已达到请求上限:尚未匹配。请在 {{time}} 之后重试", + "algorithm_rate_limited": "您已达到请求上限,因此此行未能匹配。没有出错:请在 {{time}} 之后重新运行。", + "auto_match_rows_rate_limited": "您已达到请求上限,{{count}} 个输入行未能匹配:{{details}}。没有出错。请在 {{time}} 之后对这些行重新运行自动匹配。", + "row_rate_limited_log": "已达到请求上限:尚未匹配。请稍后重试", + "auto_match_rows_rate_limited_log": "您已达到请求上限,{{count}} 个输入行未能匹配:{{details}}。没有出错。请稍后对这些行重新运行自动匹配。", "row_throttled": "服务器繁忙:尚未匹配。请稍后重试", "algorithm_throttled": "服务器繁忙,本次未能匹配此行。没有出错:请稍后重新运行。", "auto_match_rows_throttled": "服务器繁忙,{{count}} 个输入行未能匹配:{{details}}。没有出错。请稍后对这些行重新运行自动匹配以完成匹配。", diff --git a/src/services/capacity.js b/src/services/capacity.js index 75184c0e..51229aa7 100644 --- a/src/services/capacity.js +++ b/src/services/capacity.js @@ -181,7 +181,7 @@ export const createCapacityGate = ({ now = Date.now } = {}) => { * @param {function} [opts.onWait] ({reason: 'throttled'|'paused'|'error', delayMs, status, retryAfterMs, capacity, limit}) => void * @param {function} [opts.onWaitEnd] () => void, after each wait, however it ended * @param {function} [opts.onCapacity] capacityHeaders => void, for each response that carries them - * @returns {Promise<{ok: true, response, attempts, waitedMs}|{ok: false, reason: 'throttled'|'cancelled'|'error', error, attempts, waitedMs}>} + * @returns {Promise<{ok: true, response, attempts, waitedMs}|{ok: false, reason: 'throttled', limit: 'capacity'|'rate_limit', retryAt: number, error, attempts, waitedMs}|{ok: false, reason: 'cancelled'|'error', error, attempts, waitedMs}>} retryAt is epoch ms */ export const requestWithCapacityRetry = async (send, { gate = null, @@ -229,7 +229,7 @@ export const requestWithCapacityRetry = async (send, { const pauseMs = gate.pausedForMs() const budgetMs = maxWaitMs - waitedMs if(pauseMs > budgetMs) - return end({ ok: false, reason: 'throttled', error: lastError }) + return end({ ok: false, reason: 'throttled', error: lastError, limit: gate.limit?.() ?? CAPACITY_LIMIT, retryAt: now() + pauseMs }) onWait?.({ reason: 'paused', delayMs: pauseMs, status: null, retryAfterMs: pauseMs, limit: gate.limit?.() ?? CAPACITY_LIMIT }) const startedAt = now() let outcome = await gate.wait(isCancelled, { sleep, pollMs, maxMs: budgetMs }) @@ -267,7 +267,7 @@ export const requestWithCapacityRetry = async (send, { // algorithm, rerank, the row panel) for as long; the caller stops // asking for what was refused instead. if(waitedMs + delayMs > maxWaitMs) - return end({ ok: false, reason: 'throttled', error: err }) + return end({ ok: false, reason: 'throttled', error: err, limit, retryAt: now() + (retryAfterMs ?? delayMs) }) gate?.pause(baseMs, limit) if(!(await waitFor(delayMs, { reason: 'throttled', status: 429, retryAfterMs, capacity: getCapacityHeaders(err.response), limit }))) return cancelled() From 415217c9721597f8c34bc339ebdac2b8807e8716 Mon Sep 17 00:00:00 2001 From: Sunny Aggarwal Date: Thu, 8 Oct 2026 08:16:11 +0530 Subject: [PATCH 3/5] OpenConceptLab/ocl_issues#2865 | review feedbacks --- src/components/map-projects/Candidates.jsx | 4 +- src/components/map-projects/MapProject.jsx | 105 +++++++++++------- .../__tests__/rowProgress.test.js | 30 ++++- src/components/map-projects/matchBatch.js | 9 +- src/components/map-projects/rowProgress.js | 8 +- src/services/__tests__/capacity.test.js | 66 +++++++++++ src/services/capacity.js | 18 +-- 7 files changed, 185 insertions(+), 55 deletions(-) diff --git a/src/components/map-projects/Candidates.jsx b/src/components/map-projects/Candidates.jsx index cb2320e1..7629ae6b 100644 --- a/src/components/map-projects/Candidates.jsx +++ b/src/components/map-projects/Candidates.jsx @@ -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') @@ -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) diff --git a/src/components/map-projects/MapProject.jsx b/src/components/map-projects/MapProject.jsx index 74425f1d..15eda8c1 100644 --- a/src/components/map-projects/MapProject.jsx +++ b/src/components/map-projects/MapProject.jsx @@ -71,7 +71,7 @@ import { OperationsContext } from '../app/LayoutContext'; import APIService, { isTransientNetworkError, retryWithBackoff } from '../../services/APIService'; import { buildAttributionHeaders, buildConfigSnapshot, summarizeRunCompletion, matchesOnOCL } from '../../services/attribution' -import { CAPACITY_WAIT_CAP_MS, HEAVY_REQUEST_TIMEOUT_MS, LIGHT_REQUEST_TIMEOUT_MS, createCapacityGate, createLimiter, isRetryableError, requestWithCapacityRetry, sleepUnlessCancelled, untilCancelled } from '../../services/capacity' +import { CAPACITY_LIMIT, RATE_LIMIT, CAPACITY_WAIT_CAP_MS, HEAVY_REQUEST_TIMEOUT_MS, LIGHT_REQUEST_TIMEOUT_MS, createCapacityGate, createLimiter, isRetryableError, requestWithCapacityRetry, sleepUnlessCancelled, untilCancelled } from '../../services/capacity' import { highlightTexts, dropVersion, getCurrentUser, hasAuthGroup, hasCapability, getMapperPreview, getNewProjectBlockReason, downloadObject, currentUserToken, refreshCurrentUserCapabilitiesCache } from '../../common/utils'; import { WHITE, SURFACE_COLORS, TEXT_GRAY } from '../../common/colors'; @@ -116,7 +116,7 @@ import { normalizeAlgorithmInvocation, hasSuccessfulAlgorithmResponse, getAlgori import { parseConceptKey } from './conceptKey' import { getDefaultTargetRepoVersion, getProjectTargetRepoVersion, getTargetRepoVersionFromUrl, getTargetRepoVersionId } from './projectTargetRepo' import { buildBridgeTargetDownloadEntries, buildQualityRowViews, conceptBelongsToTargetRepo, conceptForMapping, formatBridgeTargetDownloadEntry, resolveAICandidateID, getScoreDetails, getAIAnalysisCandidateIDs } from './viewBuilders.js' -import { getCapacityWaitLabel, mergeCapacityWaits } from './rowProgress.js' +import { formatClockTime, getCapacityWaitLabel, mergeCapacityWaits } from './rowProgress.js' import './MapProject.scss' import '../common/ResizablePanel.scss' @@ -245,6 +245,7 @@ const MapProject = () => { // A run's rows the server stayed too busy for, by algorithm id ('rerank' // included), for the end-of-run notice. const throttledRunRowsRef = React.useRef({}) + const rowThrottlesRef = React.useRef({}) // One logs POST at a time per project URL (see createLatestSender). const logsSendersRef = React.useRef(new Map()) // The row a bridge $match is for, set around the call into the Bridge Match @@ -1342,6 +1343,7 @@ const MapProject = () => { setAlert(false) setSelectedCandidatesScoreBucket(false) setScoreBucketSortBy('desc') + rowThrottlesRef.current = {} setRowStage({}) } @@ -1790,7 +1792,7 @@ const MapProject = () => { logged = true rowIndexes.forEach(index => log({ action: 'capacity_wait', - description: getCapacityWaitLabel(wait, {t}), + description: t(limit === RATE_LIMIT ? 'map_project.rate_limited_short' : 'map_project.waiting_for_capacity'), extras: {...logExtras, reason, limit, retry_after_ms: retryAfterMs ?? null, ...(capacity ? {capacity} : {})} }, index)) }, @@ -1820,21 +1822,34 @@ const MapProject = () => { scheduleAutoSave('auto_match_stopped') } - const noteThrottledRunRow = (algoId, index) => { + // Refusals retain the latest rate limit, even without a wait (ocl_issues#2865). + const mergeThrottles = (a, b) => a?.limit === RATE_LIMIT && (b?.limit !== RATE_LIMIT || a.retryAt > b.retryAt) ? a : b + const getThrottleExtras = throttle => ({ + limit: throttle?.limit || CAPACITY_LIMIT, + ...(throttle?.limit === RATE_LIMIT ? {retry_at: throttle.retryAt} : {}), + }) + const getThrottledRowDescription = throttle => t(throttle?.limit === RATE_LIMIT ? 'map_project.row_rate_limited_log' : 'map_project.row_throttled') + const getThrottledAlgorithmMessage = throttle => throttle?.limit === RATE_LIMIT ? + t('map_project.algorithm_rate_limited', {time: formatClockTime(throttle.retryAt)}) : t('map_project.algorithm_throttled') + + const noteThrottledRunRow = (algoId, index, throttle) => { throttledRunRowsRef.current = { ...throttledRunRowsRef.current, - [algoId]: uniq([...(throttledRunRowsRef.current[algoId] || []), index]), + [algoId]: { + ...mergeThrottles(throttledRunRowsRef.current[algoId], {limit: throttle?.limit, retryAt: throttle?.retryAt}), + rows: uniq([...(throttledRunRowsRef.current[algoId]?.rows || []), index]), + }, } } // Once a request for an algorithm (or rerank) stayed refused for the whole // cap, the run stops asking for it: the rest of its rows end throttled at // once, "not run, retry", instead of each waiting out the cap again. - const isRunThrottled = algoId => Boolean(throttledRunRowsRef.current[algoId]?.length) - const markRowsThrottled = (indexes, algoId, logExtras = {}) => indexes.forEach(index => { - markAlgo(index, algoId, -4) - log({action: algoId === 'rerank' ? 'rerank_throttled' : 'algo_throttled', description: t('map_project.row_throttled'), extras: {...logExtras, skipped: true}}, index) - noteThrottledRunRow(algoId, index) + const isRunThrottled = algoId => Boolean(throttledRunRowsRef.current[algoId]?.rows.length) + const markRowsThrottled = (indexes, algoId, logExtras = {}, throttle = throttledRunRowsRef.current[algoId]) => indexes.forEach(index => { + markAlgo(index, algoId, -4, throttle) + log({action: algoId === 'rerank' ? 'rerank_throttled' : 'algo_throttled', description: getThrottledRowDescription(throttle), extras: {...logExtras, ...getThrottleExtras(throttle), skipped: true}}, index) + noteThrottledRunRow(algoId, index, throttle) }) // ── ocl_online#105 Phase 5: AutomatchRun attribution ────────────────────── @@ -2118,7 +2133,7 @@ const MapProject = () => { if(result.ok) return result.response if(result.reason === 'throttled') - return {detail: t('map_project.algorithm_throttled'), status: 429, throttled: true} + return {detail: getThrottledAlgorithmMessage(result), status: 429, throttled: true, limit: result.limit, retryAt: result.retryAt} if(result.reason === 'cancelled') return {detail: 'cancelled', cancelled: true} const data = result.error?.response?.data @@ -2329,9 +2344,9 @@ const MapProject = () => { retryOptions: {gate: getCapacityGate(service.URL), onCapacity: noteCapacity}, ...trackCapacityWait(rowIndexes, logExtras), // A batch that outlives its run must not touch the next run's stages. - setStage: (index, stage) => { + setStage: (index, stage, throttle) => { if(isCurrentRun()) - markAlgo(index, algo.id, stage) + markAlgo(index, algo.id, stage, throttle) }, onRowFinished: index => log({action: 'algo_finished', extras: logExtras}, index), onRowFailed: (index, {error, status, attempts, previewLimit}) => { @@ -2340,10 +2355,10 @@ const MapProject = () => { failedMatchRows[algo.id] = [...(failedMatchRows[algo.id] || []), index] }, // Still refused after the long cap: not run, and not a failure. - onRowThrottled: (index, {attempts, waitedMs}) => { - log({action: 'algo_throttled', description: t('map_project.row_throttled'), extras: {...logExtras, attempts, waited_ms: waitedMs}}, index) + onRowThrottled: (index, result) => { + log({action: 'algo_throttled', description: getThrottledRowDescription(result), extras: {...logExtras, ...getThrottleExtras(result), attempts: result.attempts, waited_ms: result.waitedMs}}, index) if(isCurrentRun()) - noteThrottledRunRow(algo.id, index) + noteThrottledRunRow(algo.id, index, result) }, // Sets matchQuotaStopRef, which stops the rest of the $match requests // but lets the run finish with what it has. @@ -2504,6 +2519,7 @@ const MapProject = () => { // Reset all algo stages to -1 for every row before starting so that // stages from a previous run don't make the "all algos done" check // pass prematurely when only the first algo has finished. + rowsToProcess.forEach(row => { delete rowThrottlesRef.current[row.__index] }) setRowStage(prev => { const next = { ...prev } rowsToProcess.forEach(row => { @@ -2580,11 +2596,17 @@ const MapProject = () => { // Rows the server stayed too busy for weren't run: say so apart from // the failures (ocl_issues#2849). if(!isRunStopped() && keys(throttledRunRowsRef.current).length) { - const rowsByAlgo = getRowsByAlgo(throttledRunRowsRef.current) - projectLog({action: 'auto_match_rows_throttled', extras: {row_indexes_by_algorithm: rowsByAlgo}}) - endOfRunNotices.push(t('map_project.auto_match_rows_throttled', { - count: uniq(flatten(values(rowsByAlgo))).length, - details: describeRows(rowsByAlgo), + const rowsByAlgo = getRowsByAlgo(Object.fromEntries(Object.entries(throttledRunRowsRef.current).map(([algoId, info]) => [algoId, info.rows]))) + const throttle = values(throttledRunRowsRef.current).reduce(mergeThrottles, null) + const rateLimited = throttle.limit === RATE_LIMIT + const details = {count: uniq(flatten(values(rowsByAlgo))).length, details: describeRows(rowsByAlgo)} + projectLog({ + action: 'auto_match_rows_throttled', + description: t(rateLimited ? 'map_project.auto_match_rows_rate_limited_log' : 'map_project.auto_match_rows_throttled', details), + extras: {row_indexes_by_algorithm: rowsByAlgo, ...getThrottleExtras(throttle)}, + }) + endOfRunNotices.push(t(rateLimited ? 'map_project.auto_match_rows_rate_limited' : 'map_project.auto_match_rows_throttled', { + ...details, ...(rateLimited ? {time: formatClockTime(throttle.retryAt)} : {}), })) } if(endOfRunNotices.length) @@ -2739,7 +2761,7 @@ const MapProject = () => { }; if(isRunThrottled(algo.id) || isRunThrottled('ocl-scispacy-loinc')) { - markRowsThrottled(map(_rows.slice(index), '__index'), algo.id, getAlgoLogExtras(algo)) + markRowsThrottled(map(_rows.slice(index), '__index'), algo.id, getAlgoLogExtras(algo), throttledRunRowsRef.current[isRunThrottled(algo.id) ? algo.id : 'ocl-scispacy-loinc']) break } markAlgo(_rows[index].__index, algo.id, 0) @@ -3714,7 +3736,11 @@ const MapProject = () => { return next; }; - const markAlgo = (rowId, algoId, value) => { + const markAlgo = (rowId, algoId, value, throttle) => { + if(value === -4 && throttle) + rowThrottlesRef.current[rowId] = mergeThrottles(rowThrottlesRef.current[rowId], {limit: throttle.limit, retryAt: throttle.retryAt}) + else if(value === 0 && !values(omit(rowStageRef.current[rowId], algoId)).includes(-4)) + delete rowThrottlesRef.current[rowId] setRowStage(prev => { const needsRerank = isMultiAlgo || find(algosSelected, { type: "custom" }) || find(algosSelected, { type: "ocl-scispacy" }); const row = ensureRow(prev, rowId, selectedAlgoIds, needsRerank); @@ -3844,11 +3870,11 @@ const MapProject = () => { gate: getCapacityGate(service.URL), onCapacity: noteCapacity, ...trackCapacityWait([__row.__index], getAlgoLogExtras(algoDef)), - }}).then(({ok, response, errorBody, previewLimit, throttled}) => { + }}).then(({ok, response, errorBody, previewLimit, throttled, limit, retryAt}) => { if(ok) return callback(response, payload) if(throttled) - return callback({detail: t('map_project.algorithm_throttled'), status: 429, throttled: true}, payload) + return callback({detail: getThrottledAlgorithmMessage({limit, retryAt}), status: 429, throttled: true, limit, retryAt}, payload) // A preview limit keeps the server's body, which opens the limit dialog. callback(previewLimit ? errorBody : {...errorBody, detail: t('map_project.match_request_failed', {error: errorBody.detail})}, payload) }) @@ -3871,6 +3897,8 @@ const MapProject = () => { if(!algoId) return let __row = isEmpty(_row) ? row : _row + if(!keepAlert) + delete rowThrottlesRef.current[__row.__index] // Reuse when the algo has already produced an AlgorithmResponse for // this row, regardless of whether it returned matches. Gating on @@ -3936,15 +3964,15 @@ const MapProject = () => { // The server stayed too busy: the algorithm didn't run, it didn't // fail, and no failed response is kept (ocl_issues#2849). const isThrottled = Boolean(response.throttled) - log({action: isThrottled ? 'algo_throttled' : 'algo_failed', ...(isThrottled ? {description: t('map_project.row_throttled')} : {}), extras: {...logExtras, error: response.detail, status: response.status, ...(offset ? {offset} : {})}}, __row.__index) + log({action: isThrottled ? 'algo_throttled' : 'algo_failed', ...(isThrottled ? {description: getThrottledRowDescription(response)} : {}), extras: {...logExtras, ...(isThrottled ? getThrottleExtras(response) : {}), error: isThrottled ? getThrottledRowDescription(response) : response.detail, status: response.status, ...(offset ? {offset} : {})}}, __row.__index) setAlert({message: response.detail, severity: isThrottled ? 'warning' : 'error'}) if(offset) { // A failed "load more" keeps the pages already loaded. The // algorithm stays done if it loaded a page; "load more" also runs // algorithms that never did, and those stay failed. - markAlgo(__row.__index, algoId, hasSuccessfulAlgorithmResponse(rowMatchStateRef.current?.[__row.__index], algoId) ? 1 : (isThrottled ? -4 : -2)) + markAlgo(__row.__index, algoId, hasSuccessfulAlgorithmResponse(rowMatchStateRef.current?.[__row.__index], algoId) ? 1 : (isThrottled ? -4 : -2), response) } else if(isThrottled) { - markAlgo(__row.__index, algoId, -4) + markAlgo(__row.__index, algoId, -4, response) } else { markAlgo(__row.__index, algoId, -2) mergeIntoRowMatchState(__row.__index, normalizeAlgorithmInvocation(null, { @@ -4146,11 +4174,11 @@ const MapProject = () => { if(isSuperseded()) return // Not run, not failed: the row can be run again. - markAlgo(__row.__index, SCISPACY_ALGO_ID, -4) - log({action: 'algo_throttled', description: t('map_project.row_throttled'), extras: {algo: SCISPACY_ALGO_ID, attempts: result.attempts, waited_ms: result.waitedMs}}, __row.__index) + markAlgo(__row.__index, SCISPACY_ALGO_ID, -4, result) + log({action: 'algo_throttled', description: getThrottledRowDescription(result), extras: {algo: SCISPACY_ALGO_ID, ...getThrottleExtras(result), attempts: result.attempts, waited_ms: result.waitedMs}}, __row.__index) if(isBulk) - noteThrottledRunRow(SCISPACY_ALGO_ID, __row.__index) - setAlert(prev => (prev?.severity === 'error' ? prev : {message: t('map_project.algorithm_throttled'), severity: 'warning'})) + noteThrottledRunRow(SCISPACY_ALGO_ID, __row.__index, result) + setAlert(prev => (prev?.severity === 'error' ? prev : {message: getThrottledAlgorithmMessage(result), severity: 'warning'})) setIsLoadingInDecisionView(false) onFailure?.() return @@ -4394,12 +4422,12 @@ const MapProject = () => { return null } if(result.reason === 'throttled') { - log({action: 'rerank_throttled', description: t('map_project.row_throttled'), extras: {attempts: result.attempts, waited_ms: result.waitedMs}}, index) + log({action: 'rerank_throttled', description: getThrottledRowDescription(result), extras: {...getThrottleExtras(result), attempts: result.attempts, waited_ms: result.waitedMs}}, index) if(isSuperseded()) return null - markAlgo(index, 'rerank', -4) + markAlgo(index, 'rerank', -4, result) if(isRunTraffic) - noteThrottledRunRow('rerank', index) + noteThrottledRunRow('rerank', index, result) return null } if(!result.ok) { @@ -4753,10 +4781,10 @@ const MapProject = () => { // The server stayed too busy: not run, not failed. if(response?.throttled) { clearRefreshRowStageSnapshot(__row.__index) - markAlgo(__row.__index, bridgeAlgoId, -4) - log({action: 'algo_throttled', description: t('map_project.row_throttled'), extras: getAlgoLogExtras(bridgeAlgo)}, __row.__index) + markAlgo(__row.__index, bridgeAlgoId, -4, response) + log({action: 'algo_throttled', description: getThrottledRowDescription(response), extras: {...getAlgoLogExtras(bridgeAlgo), ...getThrottleExtras(response)}}, __row.__index) if(isBulk) - noteThrottledRunRow(bridgeAlgoId, __row.__index) + noteThrottledRunRow(bridgeAlgoId, __row.__index, response) setAlert(prev => (prev?.severity === 'error' ? prev : {message: response.detail, severity: 'warning'})) setIsLoadingInDecisionView(false) onFailure?.() @@ -6475,6 +6503,7 @@ const MapProject = () => { rowIndex={rowIndex} rowStage={rowStageRef.current[rowIndex]} capacityWait={capacityWaits?.rows?.[rowIndex] || false} + throttle={rowThrottlesRef.current[rowIndex]} rowState={rowMatchStateRef.current[rowIndex]} conceptCache={conceptCache} targetCanonical={buildProjectContext()?.target_repo?.canonical_url} diff --git a/src/components/map-projects/__tests__/rowProgress.test.js b/src/components/map-projects/__tests__/rowProgress.test.js index 4775c978..b539e91d 100644 --- a/src/components/map-projects/__tests__/rowProgress.test.js +++ b/src/components/map-projects/__tests__/rowProgress.test.js @@ -10,7 +10,7 @@ import test from 'node:test' import assert from 'node:assert/strict' -import { getCapacityWaitLabel, getRowProgressLabel, mergeCapacityWaits } 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'}] @@ -111,3 +111,31 @@ test('mergeCapacityWaits: a capacity wait wins; between rate-limit waits, the la 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', + }) + } +}) diff --git a/src/components/map-projects/matchBatch.js b/src/components/map-projects/matchBatch.js index 1352f682..971dd7e0 100644 --- a/src/components/map-projects/matchBatch.js +++ b/src/components/map-projects/matchBatch.js @@ -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; 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 @@ -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 [] } @@ -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} : {}), } } diff --git a/src/components/map-projects/rowProgress.js b/src/components/map-projects/rowProgress.js index 730966c9..725d7439 100644 --- a/src/components/map-projects/rowProgress.js +++ b/src/components/map-projects/rowProgress.js @@ -39,13 +39,15 @@ export const mergeCapacityWaits = (a, b) => { * 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, formatTime } = {}) => { +export const getRowProgressLabel = (stageMap, algos, { t, capacityWait = false, throttle, formatTime = formatClockTime } = {}) => { if(stageMap === undefined) return {label: false} if(capacityWait) 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]); @@ -65,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 } @@ -75,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' }; } diff --git a/src/services/__tests__/capacity.test.js b/src/services/__tests__/capacity.test.js index 99a46410..610647b6 100644 --- a/src/services/__tests__/capacity.test.js +++ b/src/services/__tests__/capacity.test.js @@ -650,3 +650,69 @@ test('requestWithCapacityRetry: an error backoff carries no limit', async () => }) assert.deepEqual(waits, [['error', null]]) }) + +// Retry labels and refusals keep their time and limit (ocl_issues#2865). +test('requestWithCapacityRetry: a paused wait announces pause plus jitter, clamped to the budget', async () => { + for(const [maxWaitMs, expected] of [[20000, 12500], [11000, 11000]]) { + const clock = virtualClock() + const gate = createCapacityGate({now: clock.now}) + gate.pause(10000, RATE_LIMIT) + const waits = [] + const result = await requestWithCapacityRetry(scripted(ok()).send, { + gate, maxWaitMs, now: clock.now, sleep: clock.sleep, random: () => 0.5, + onWait: info => waits.push(info.delayMs), + }) + assert.equal(result.ok, true) + assert.deepEqual(waits, [expected]) + assert.equal(result.waitedMs, expected) + } +}) + +test('requestWithCapacityRetry: an extended gate re-announces its new limit and retry time', async () => { + const clock = virtualClock() + const gate = createCapacityGate({now: clock.now}) + gate.pause(1000, CAPACITY_LIMIT) + const events = [] + const result = await requestWithCapacityRetry(scripted(ok()).send, { + gate, now: clock.now, random: () => 0.5, + sleep: async ms => { + await clock.sleep(ms) + if(clock.t === 250) + gate.pause(2750, RATE_LIMIT) + }, + onWait: info => events.push([info.limit, clock.t + info.delayMs]), + onWaitEnd: () => events.push('end'), + }) + assert.equal(result.ok, true) + assert.deepEqual(events, [[CAPACITY_LIMIT, 1250], 'end', [RATE_LIMIT, 3500], 'end']) + assert.equal(result.waitedMs, 3500) +}) + +test('requestWithCapacityRetry: a long rate limit returns the server retry time without waiting', async () => { + const clock = virtualClock() + clock.t = 10000 + const waits = [] + const result = await requestWithCapacityRetry(scripted(throttled(86400)).send, { + now: clock.now, sleep: clock.sleep, random: () => 0.5, onWait: info => waits.push(info), + }) + assert.equal(result.reason, 'throttled') + assert.equal(result.limit, RATE_LIMIT) + assert.equal(result.retryAt, 86410000) + assert.equal(result.waitedMs, 0) + assert.deepEqual(waits, []) +}) + +test('requestWithCapacityRetry: a gate refusal before any attempt carries its limit and retry time', async () => { + for(const limit of [CAPACITY_LIMIT, RATE_LIMIT]) { + const clock = virtualClock() + clock.t = 10000 + const gate = createCapacityGate({now: clock.now}) + gate.pause(60000, limit) + const {send, sent} = scripted(ok()) + const result = await requestWithCapacityRetry(send, {gate, maxWaitMs: 1000, now: clock.now, sleep: clock.sleep}) + assert.equal(result.reason, 'throttled') + assert.equal(result.limit, limit) + assert.equal(result.retryAt, 70000) + assert.deepEqual(sent, []) + } +}) diff --git a/src/services/capacity.js b/src/services/capacity.js index 51229aa7..7cf5dbde 100644 --- a/src/services/capacity.js +++ b/src/services/capacity.js @@ -228,22 +228,26 @@ export const requestWithCapacityRetry = async (send, { if(gate?.isPaused()) { const pauseMs = gate.pausedForMs() const budgetMs = maxWaitMs - waitedMs + const limit = gate.limit?.() ?? CAPACITY_LIMIT if(pauseMs > budgetMs) - return end({ ok: false, reason: 'throttled', error: lastError, limit: gate.limit?.() ?? CAPACITY_LIMIT, retryAt: now() + pauseMs }) - onWait?.({ reason: 'paused', delayMs: pauseMs, status: null, retryAfterMs: pauseMs, limit: gate.limit?.() ?? CAPACITY_LIMIT }) + return end({ ok: false, reason: 'throttled', error: lastError, limit, retryAt: now() + pauseMs }) + // Announce the jitter too, and refresh an extended pause (ocl_issues#2865). + const jitterMs = Math.min(pauseMs * THROTTLE_JITTER * random(), budgetMs - pauseMs) + onWait?.({ reason: 'paused', delayMs: pauseMs + jitterMs, status: null, retryAfterMs: pauseMs, limit }) const startedAt = now() - let outcome = await gate.wait(isCancelled, { sleep, pollMs, maxMs: budgetMs }) + let outcome = await gate.wait(isCancelled, { sleep, pollMs, maxMs: pauseMs }) // Spread out the requests the pause held back, within the budget. if(outcome === true) { - const jitterMs = Math.min(pauseMs * THROTTLE_JITTER * random(), Math.max(budgetMs - (now() - startedAt), 0)) - outcome = await sleepUnlessCancelled(jitterMs, isCancelled, { sleep, pollMs }) + outcome = await sleepUnlessCancelled(Math.min(jitterMs, Math.max(budgetMs - (now() - startedAt), 0)), isCancelled, { sleep, pollMs }) } waitedMs += now() - startedAt onWaitEnd?.() if(outcome === false) return cancelled() - if(outcome === 'timeout' || waitedMs > maxWaitMs) - return end({ ok: false, reason: 'throttled', error: lastError }) + if(outcome === 'timeout') + continue + if(waitedMs > maxWaitMs) + return end({ ok: false, reason: 'throttled', error: lastError, limit: gate.limit?.() ?? limit, retryAt: now() + gate.pausedForMs() }) continue } From 029490946b77e581585eb86be5029173244b5316 Mon Sep 17 00:00:00 2001 From: Sunny Aggarwal Date: Thu, 8 Oct 2026 08:19:17 +0530 Subject: [PATCH 4/5] OpenConceptLab/ocl_issues#2865 | pt-BR: add the missing translations (capacity and rate-limit notices, Auto Match blocked reasons, search, pricing link) Co-Authored-By: Claude Opus 5.5 --- src/i18n/locales/pt-BR/translations.json | 33 ++++++++++++++++++++++-- 1 file changed, 31 insertions(+), 2 deletions(-) diff --git a/src/i18n/locales/pt-BR/translations.json b/src/i18n/locales/pt-BR/translations.json index ec4b7052..c861b4ca 100644 --- a/src/i18n/locales/pt-BR/translations.json +++ b/src/i18n/locales/pt-BR/translations.json @@ -517,6 +517,7 @@ "preview_estimate_ai_all": "Recomendações de IA para todas as {{count}} linha(s).", "preview_estimate_ai_partial": "Recomendações de IA para as primeiras {{covered}} linha(s); as outras {{rest}} são correspondidas sem IA.", "preview_estimate_ai_none": "Nenhuma chamada do Assistente de IA sobrou, então estas linhas são correspondidas sem IA.", + "preview_estimate_ai_only_partial": "O Assistente de IA do OCL analisará a(s) primeira(s) {{covered}} linha(s); você não tem mais chamadas do Assistente de IA do OCL para as outras {{rest}}.", "preview_rows_activated_notice": "Projetos de prévia contêm {{limit}} linhas - ativamos as primeiras {{limit}} de suas {{total}}.", "ai_assistant_limit_reached": "Você usou todas as chamadas de prévia do Assistente de IA. A correspondência continuará sem análise de IA para as linhas restantes.", "preview_limit_title_rows": "Você usou as linhas de prévia deste projeto", @@ -559,7 +560,6 @@ "preview_config_unavailable_custom_algorithms": "Algoritmos hospedados externamente não fazem parte de sua prévia. Remova o algoritmo personalizado antes de salvar.", "semantic_search_not_configured": "{{owner}}:{{repo}}:{{version}} não está configurado para executar Busca Semântica", "no_valid_columns_for_matching": "Nenhuma das colunas é válida para correspondência, por favor edite e atribua colunas válidas.", - "proposed_successfully": "Proposto com sucesso.", "algorithm_es_label": "Correspondência genérica com Elasticsearch", "algorithm_es_description": "Busca por token e palavra-chave usando Elasticsearch", "algorithm_llm_label": "Busca Semântica (all-MiniLM-L6-v2)", @@ -570,7 +570,6 @@ "bridge_candidates": "Candidatos de mapeamento cruzado (Bridge)", "scispacy_candidates": "Candidatos ScispaCy LOINC", "ai_analysis": "Análise de IA", - "group_by_ai_analysis": "Análise de IA", "view_existing_analysis": "Ver Existente", "run_new_analysis": "Executar Nova Análise", "matched": "Correspondido", @@ -590,6 +589,33 @@ "auto_match_rows_failed": "O Auto Match falhou para {{count}} linha(s) de entrada, então elas estão sem candidatos: {{details}}. As linhas contam a partir da primeira linha de dados do seu arquivo. Execute o Auto Match nessas linhas novamente para tentar de novo.", "auto_match_rows_failed_algorithm": "{{algorithm}} na(s) linha(s) {{rows}}", "match_request_failed": "Não foi possível obter candidatos ({{error}}). Tente novamente.", + "waiting_for_capacity": "Aguardando capacidade: os resultados podem demorar mais devido à demanda", + "waiting_for_capacity_short": "Aguardando capacidade", + "rate_limited": "Você está enviando solicitações rápido demais: nova tentativa às {{time}}", + "rate_limited_short": "Solicitações demais", + "row_rate_limited": "Limite de solicitações atingido: ainda não correspondida. Tente novamente após {{time}}", + "algorithm_rate_limited": "Você atingiu seu limite de solicitações, então esta linha não foi correspondida. Nada falhou: execute-a novamente após {{time}}.", + "auto_match_rows_rate_limited": "Você atingiu seu limite de solicitações, então {{count}} linha(s) de entrada não foram correspondidas: {{details}}. Nada falhou. Execute o Auto Match nessas linhas novamente após {{time}}.", + "row_rate_limited_log": "Limite de solicitações atingido: ainda não correspondida. Tente novamente mais tarde", + "auto_match_rows_rate_limited_log": "Você atingiu seu limite de solicitações, então {{count}} linha(s) de entrada não foram correspondidas: {{details}}. Nada falhou. Execute o Auto Match nessas linhas novamente mais tarde.", + "row_throttled": "Servidor ocupado: ainda não correspondida. Tente novamente mais tarde", + "algorithm_throttled": "O servidor está ocupado, então esta linha não foi correspondida desta vez. Nada falhou: execute-a novamente mais tarde.", + "auto_match_rows_throttled": "O servidor estava ocupado, então {{count}} linha(s) de entrada não foram correspondidas: {{details}}. Nada falhou. Execute o Auto Match nessas linhas novamente mais tarde para concluí-las.", + "auto_match_no_algorithms": "Nenhum algoritmo de correspondência está configurado para este projeto, então o Auto Match não pode recuperar candidatos.", + "auto_match_no_runnable_algorithms": "Os algoritmos de correspondência deste projeto não estão disponíveis para você, então o Auto Match não pode recuperar candidatos.", + "auto_match_configure_algorithms": "Configurar", + "auto_match_ai_only_rows": "Sem Recuperar Candidatos, o Assistente de IA do OCL analisa apenas as linhas que já têm candidatos: ele analisará {{analyse}} linha(s) e ignorará {{skip}} que não têm candidatos ou que já foram analisadas com os candidatos atuais.", + "auto_match_blocked_no_rows": "Não há linhas para corresponder neste escopo.", + "auto_match_blocked_no_algorithms": "Configure um algoritmo de correspondência para executar o Auto Match.", + "auto_match_blocked_no_algorithms_ai": "Configure um algoritmo de correspondência ou selecione Executar Análise de IA.", + "auto_match_blocked_no_step": "Selecione Recuperar Candidatos para executar o Auto Match.", + "auto_match_blocked_no_step_ai": "Selecione Recuperar Candidatos ou Executar Análise de IA.", + "auto_match_blocked_no_ai_rows": "O Assistente de IA do OCL não tem linhas para analisar neste escopo.", + "auto_match_blocked_no_ai_calls": "Você não tem mais chamadas do Assistente de IA do OCL, então ele não pode analisar estas linhas.", + "auto_match_nothing_to_run": "O Auto Match não tinha nada para executar: nenhum algoritmo de correspondência está selecionado e o Assistente de IA do OCL não tem linhas para analisar.", + "rerank_step": "Reclassificação", + "search_throttled": "O servidor está ocupado, então a busca não foi executada. Tente novamente em alguns minutos.", + "search_failed": "Não foi possível pesquisar ({{error}}). Tente novamente.", "run_ai_analysis": "Executar Análise de IA", "run_ai_analysis_note": "Nota: Habilitar este recurso executará Análise de IA nos resultados de cada linha. Isso tem implicações diretas de custo.", "decision": "Decisão", @@ -671,6 +697,7 @@ "ocl_ai_assistant": "Assistente de IA do OCL", "ocl_ai_candidates_analysis": "Assistente de IA do OCL: Análise de Candidatos", "group_by_match_quality": "Qualidade de Correspondência", + "group_by_ai_analysis": "Análise de IA", "ocl_semantic_algorithm": "Algoritmo Semântico do OCL", "ocl_search_algorithm": "Algoritmo de Busca do OCL", "ocl_ciel_bridge_algorithm": "Terminologia CIEL Bridge do OCL", @@ -715,6 +742,7 @@ "rows": "linhas", "finished_running": "Finalizado", "finished_reranking": "Re-ranqueamento finalizado", + "proposed_successfully": "Proposto com sucesso.", "assessment": "Avaliação", "requested_by": "Solicitado por", "primary": "Primário", @@ -769,6 +797,7 @@ "title": "Bem-vindo à prévia pública do OCL Mapper!", "text": "Experimente agora, antes do lançamento completo que chegará em breve.", "link_label": "Saiba mais", + "pricing_link_label": "Ver preços", "dismiss": "Dispensar" }, "quota": { From a0de2db543b957b32b1d3afba70f650b2940712d Mon Sep 17 00:00:00 2001 From: Sunny Aggarwal Date: Thu, 8 Oct 2026 08:20:29 +0530 Subject: [PATCH 5/5] OpenConceptLab/ocl_issues#2865 | es, zh: add the missing translations (input language, AI locales, ScispaCy preview limit, match metrics) Co-Authored-By: Claude Opus 5.5 --- src/i18n/locales/es/translations.json | 8 ++++++++ src/i18n/locales/zh/translations.json | 8 ++++++++ 2 files changed, 16 insertions(+) diff --git a/src/i18n/locales/es/translations.json b/src/i18n/locales/es/translations.json index 3fa18876..f0e1c7d7 100644 --- a/src/i18n/locales/es/translations.json +++ b/src/i18n/locales/es/translations.json @@ -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" @@ -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", @@ -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", @@ -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.", @@ -692,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.", diff --git a/src/i18n/locales/zh/translations.json b/src/i18n/locales/zh/translations.json index 4734e739..831df235 100644 --- a/src/i18n/locales/zh/translations.json +++ b/src/i18n/locales/zh/translations.json @@ -366,6 +366,7 @@ "search_score": "统一评分", "search_raw_score": "原始评分", "matched_attributes": "匹配属性", + "no_match_metrics": "此概念没有可用的算法匹配指标。", "list_view": "列表视图", "table_view": "表格视图", "select_all_rows": "选择全部行" @@ -419,6 +420,10 @@ "filter_name_locales": "筛选名称语言环境", "loading_name_locales": "正在加载名称语言环境...", "filter_name_locales_helper": "注意:将候选对象限制为特定语言会限制跨语言匹配功能,但可能会提高某些数据集的准确性,特别是如果输入数据与目标存储库的默认语言相同。", + "input_language": "输入语言", + "input_language_helper": "输入数据的语言。决定将目标存储库名称的哪些区域设置发送给 AI 助手。", + "ai_effective_locales": "AI 将收到以下区域设置的名称:{{locales}}", + "ai_all_locales": "AI 将收到所有区域设置的名称(不筛选)", "default_filter": "默认筛选器", "matching_algorithm": "匹配算法", "matching_algorithm_description": "选择匹配算法。<0>了解更多", @@ -516,6 +521,7 @@ "preview_limit_title_access": "当前 Mapper 访问受限", "preview_limit_title_custom_algorithms": "外部托管算法不在预览范围内", "preview_limit_title_ai_assistant": "AI 助手暂不可用", + "preview_limit_title_scispacy": "ScispaCy LOINC 暂不可用", "preview_limit_title_rows_not_entitled": "本项目尚未开通预览行额度", "preview_limit_title_match_operations_not_entitled": "你的账户尚未开通匹配操作权限", "preview_limit_title_projects_not_entitled": "你的账户尚未开通映射项目权限", @@ -533,6 +539,7 @@ "preview_limit_body_custom_algorithms_no_count": "外部托管算法暂未开放给预览版。请使用 OCL 内置算法,或在下方申请更多访问权限。", "preview_limit_body_ai_assistant": "你的免费预览版包含 {{limit}} 次 AI 助手调用,已使用 {{used}} / {{limit}}。剩余行仍可继续匹配,但不会进行 AI 分析。", "preview_limit_body_ai_assistant_no_count": "你的账户暂时无法使用 AI 助手。请在下方申请更多访问权限,我们会处理。", + "preview_limit_body_scispacy_no_count": "你的账户暂时无法使用 ScispaCy LOINC 匹配。请在下方申请更多访问权限,我们会处理。", "preview_limit_body_rows_not_entitled_no_count": "本项目尚未配置行额度。请在下方申请更多访问权限,我们会处理。", "preview_limit_body_match_operations_not_entitled_no_count": "你的账户尚未配置匹配操作权限。请在下方申请更多访问权限,我们会处理。", "preview_limit_body_projects_not_entitled_no_count": "你的账户尚未配置映射项目权限。请在下方申请更多访问权限,我们会处理。", @@ -717,6 +724,7 @@ "ai_prompt_template_default_model": "默认模型", "set_ai_assistant_output_language": "设置 AI 助手输出语言", "ai_assistant_output_locale": "输出区域设置", + "ai_assistant_output_locale_helper": "从列表中选择,或输入区域 BCP-47 代码(例如 pt-BR、fr-CA)。", "output_locale": "输出语言", "reranker_configuration": "重排序器配置", "reranker_configuration_description": "为此项目选择用于计算统一分数的重排序器模型。默认模型会自动选中,您也可以输入自定义模型名称。",