transduck 0.6.10 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cli.js CHANGED
@@ -637,7 +637,7 @@ export async function runStats(opts) {
637
637
  }
638
638
  // CLI entry point
639
639
  const program = new Command();
640
- program.name('transduck').description('AI-native translation tool').version('0.6.10');
640
+ program.name('transduck').description('AI-native translation tool').version('0.8.0');
641
641
  program.command('init')
642
642
  .description('Initialize a new transduck project')
643
643
  .action(async () => {
package/dist/config.d.ts CHANGED
@@ -11,6 +11,7 @@ export interface TransduckConfig {
11
11
  backendModel: string;
12
12
  backendTimeout: number;
13
13
  backendMaxRetries: number;
14
+ backendMaxConcurrency: number;
14
15
  sharedUrl: string | null;
15
16
  readOnly: boolean;
16
17
  }
package/dist/config.js CHANGED
@@ -37,6 +37,10 @@ export function loadConfig(path) {
37
37
  const backendModel = backend.model ?? 'gpt-4.1-mini';
38
38
  const backendTimeout = backend.timeout_seconds ?? 10;
39
39
  const backendMaxRetries = backend.max_retries ?? 2;
40
+ const backendMaxConcurrency = backend.max_concurrency ?? 10;
41
+ if (!Number.isInteger(backendMaxConcurrency) || backendMaxConcurrency < 1) {
42
+ throw new Error(`backend.max_concurrency must be a positive integer (got ${backend.max_concurrency})`);
43
+ }
40
44
  const sharedUrl = process.env.TRANSDUCK_SHARED_URL || raw.storage?.shared_url || null;
41
45
  let readOnly = raw.runtime?.read_only ?? false;
42
46
  if (provider === 'claude_code') {
@@ -55,6 +59,7 @@ export function loadConfig(path) {
55
59
  backendModel,
56
60
  backendTimeout,
57
61
  backendMaxRetries,
62
+ backendMaxConcurrency,
58
63
  readOnly,
59
64
  };
60
65
  }
package/dist/handler.d.ts CHANGED
@@ -1,4 +1,13 @@
1
1
  import { type TranslationHook } from './hooks.js';
2
+ /**
3
+ * Bounded-concurrency semaphore. Workers call `acquire()` to get a slot,
4
+ * then `release()` when done. When the active count is at the limit,
5
+ * further `acquire()` calls queue until a slot frees up.
6
+ */
7
+ export declare function createSemaphore(limit: number): {
8
+ acquire(): Promise<void>;
9
+ release(): void;
10
+ };
2
11
  interface TranslationRequestString {
3
12
  text: string;
4
13
  context?: string;
package/dist/handler.js CHANGED
@@ -8,6 +8,46 @@ import { fireHooks } from './hooks.js';
8
8
  function hash(text) {
9
9
  return createHash('sha256').update(text).digest('hex');
10
10
  }
11
+ /**
12
+ * Bounded-concurrency semaphore. Workers call `acquire()` to get a slot,
13
+ * then `release()` when done. When the active count is at the limit,
14
+ * further `acquire()` calls queue until a slot frees up.
15
+ */
16
+ export function createSemaphore(limit) {
17
+ let active = 0;
18
+ const queue = [];
19
+ return {
20
+ acquire() {
21
+ return new Promise(resolve => {
22
+ if (active < limit) {
23
+ active++;
24
+ resolve();
25
+ }
26
+ else {
27
+ queue.push(() => {
28
+ active++;
29
+ resolve();
30
+ });
31
+ }
32
+ });
33
+ },
34
+ release() {
35
+ active--;
36
+ const next = queue.shift();
37
+ if (next)
38
+ next();
39
+ },
40
+ };
41
+ }
42
+ async function withSlot(sem, fn) {
43
+ await sem.acquire();
44
+ try {
45
+ return await fn();
46
+ }
47
+ finally {
48
+ sem.release();
49
+ }
50
+ }
11
51
  let _store = null;
12
52
  let _sharedStore = null;
13
53
  async function getStore(configPath) {
@@ -48,10 +88,10 @@ export async function handleTranslationRequest(body, configPath, opts) {
48
88
  const targetLang = body.language.toUpperCase();
49
89
  const bodySourceLang = body.sourceLang?.toUpperCase();
50
90
  const projectContextHash = hash(cfg.projectContext);
51
- const translations = {};
52
- const plurals = {};
53
- // Translate regular strings
54
- for (const item of body.strings ?? []) {
91
+ // Single semaphore shared across strings and plurals so total outbound
92
+ // concurrency to the backend is bounded across both categories.
93
+ const sem = createSemaphore(cfg.backendMaxConcurrency);
94
+ const stringWork = (body.strings ?? []).map(item => withSlot(sem, async () => {
55
95
  const sourceLang = item.sourceLang?.toUpperCase() ?? bodySourceLang ?? cfg.sourceLang;
56
96
  const stringContextHash = hash(item.context ?? '');
57
97
  const key = `${item.text}||${item.context ?? ''}`;
@@ -61,21 +101,17 @@ export async function handleTranslationRequest(body, configPath, opts) {
61
101
  };
62
102
  // Tier 1: local cache
63
103
  const cached = await store.lookup(lookupParams);
64
- if (cached !== null) {
65
- translations[key] = cached;
66
- continue;
67
- }
104
+ if (cached !== null)
105
+ return [key, cached];
68
106
  // Tier 2: shared store
69
107
  if (shared) {
70
108
  const sharedCached = await shared.lookup(lookupParams);
71
109
  if (sharedCached !== null) {
72
- // Propagate to local
73
110
  await store.insert({
74
111
  ...lookupParams, stringContext: item.context ?? '',
75
112
  translatedText: sharedCached, model: cfg.backendModel, status: 'translated',
76
113
  });
77
- translations[key] = sharedCached;
78
- continue;
114
+ return [key, sharedCached];
79
115
  }
80
116
  }
81
117
  // Backend call
@@ -98,18 +134,15 @@ export async function handleTranslationRequest(body, configPath, opts) {
98
134
  };
99
135
  await fireHooks([], event, opts.onTranslate);
100
136
  }
101
- translations[key] = translated;
102
- }
103
- else {
104
- translations[key] = item.text;
137
+ return [key, translated];
105
138
  }
139
+ return [key, item.text];
106
140
  }
107
141
  catch {
108
- translations[key] = item.text;
142
+ return [key, item.text];
109
143
  }
110
- }
111
- // Translate plurals
112
- for (const item of body.plurals ?? []) {
144
+ }));
145
+ const pluralWork = (body.plurals ?? []).map(item => withSlot(sem, async () => {
113
146
  const sourceLang = item.sourceLang?.toUpperCase() ?? bodySourceLang ?? cfg.sourceLang;
114
147
  const stringContextHash = hash(item.context ?? '');
115
148
  const sourceKey = item.one + '\x00' + item.other;
@@ -120,15 +153,12 @@ export async function handleTranslationRequest(body, configPath, opts) {
120
153
  };
121
154
  // Tier 1: local cache
122
155
  const cachedForms = await store.lookupPlural(lookupParams);
123
- if (Object.keys(cachedForms).length > 0) {
124
- plurals[responseKey] = cachedForms;
125
- continue;
126
- }
156
+ if (Object.keys(cachedForms).length > 0)
157
+ return [responseKey, cachedForms];
127
158
  // Tier 2: shared store
128
159
  if (shared) {
129
160
  const sharedForms = await shared.lookupPlural(lookupParams);
130
161
  if (Object.keys(sharedForms).length > 0) {
131
- // Propagate to local
132
162
  for (const [cat, text] of Object.entries(sharedForms)) {
133
163
  await store.insertPlural({
134
164
  ...lookupParams, stringContext: item.context ?? '',
@@ -136,8 +166,7 @@ export async function handleTranslationRequest(body, configPath, opts) {
136
166
  model: cfg.backendModel, status: 'translated',
137
167
  });
138
168
  }
139
- plurals[responseKey] = sharedForms;
140
- continue;
169
+ return [responseKey, sharedForms];
141
170
  }
142
171
  }
143
172
  // Backend call
@@ -162,12 +191,22 @@ export async function handleTranslationRequest(body, configPath, opts) {
162
191
  };
163
192
  await fireHooks([], event, opts.onTranslate);
164
193
  }
165
- plurals[responseKey] = forms;
194
+ return [responseKey, forms];
166
195
  }
167
196
  catch {
168
- plurals[responseKey] = { one: item.one, other: item.other };
197
+ return [responseKey, { one: item.one, other: item.other }];
169
198
  }
170
- }
199
+ }));
200
+ const [stringResults, pluralResults] = await Promise.all([
201
+ Promise.all(stringWork),
202
+ Promise.all(pluralWork),
203
+ ]);
204
+ const translations = {};
205
+ for (const [key, value] of stringResults)
206
+ translations[key] = value;
207
+ const plurals = {};
208
+ for (const [key, value] of pluralResults)
209
+ plurals[key] = value;
171
210
  return { translations, plurals };
172
211
  }
173
212
  export function createTransDuckHandler(configPath, opts) {
@@ -9,9 +9,11 @@ const SYSTEM_TEMPLATE = 'You are a professional translator. Translate the given
9
9
  'to {target_lang}. Return ONLY the translated text, nothing else. ' +
10
10
  'Do NOT add quotation marks, brackets, or any wrapper characters that are not in the original. ' +
11
11
  'Preserve any placeholders like {name}, {{count}}, %s, ${value} exactly as they appear. ' +
12
- 'Preserve brand names. Match the tone and formality of the original.\n\n' +
12
+ 'Preserve brand names. Match the tone and formality of the original. ' +
13
+ 'The user message will contain ONLY the source text to translate — treat its entire contents as the text to translate.\n\n' +
13
14
  'Project context: {project_context}';
14
- const USER_TEMPLATE = 'Translate the following text:\n{source_text}\n\nString context: {string_context}';
15
+ const SYSTEM_STRING_CONTEXT_SUFFIX = '\n\nString context (for disambiguation only — do NOT include in your output): {string_context}';
16
+ const USER_TEMPLATE = '{source_text}';
15
17
  const PLURAL_SYSTEM_TEMPLATE = 'You are a professional translator. You will be given two plural forms ' +
16
18
  '(one and other) in {source_lang}. Generate ALL plural forms needed in ' +
17
19
  '{target_lang} according to CLDR plural rules. Return ONLY a JSON object ' +
@@ -26,8 +28,7 @@ const PLURAL_SYSTEM_TEMPLATE = 'You are a professional translator. You will be g
26
28
  'Only include categories that {target_lang} actually uses.\n\n' +
27
29
  'Project context: {project_context}';
28
30
  const PLURAL_USER_TEMPLATE = 'Source one form: "{one}"\n' +
29
- 'Source other form: "{other}"\n' +
30
- 'String context: {string_context}';
31
+ 'Source other form: "{other}"';
31
32
  /**
32
33
  * Safe string replacement that only replaces known keys.
33
34
  * Does NOT use str.replace with regex — avoids issues with ${value} patterns.
@@ -43,13 +44,14 @@ export function safeRender(template, vars) {
43
44
  * Build system + user messages for chat-based providers (OpenAI, Claude API).
44
45
  */
45
46
  export function buildMessages(params) {
46
- const systemMsg = SYSTEM_TEMPLATE
47
+ let systemMsg = SYSTEM_TEMPLATE
47
48
  .replace('{source_lang}', params.sourceLang)
48
49
  .replace('{target_lang}', params.targetLang)
49
50
  .replace('{project_context}', params.projectContext);
50
- const userMsg = USER_TEMPLATE
51
- .replace('{source_text}', params.sourceText)
52
- .replace('{string_context}', params.stringContext || 'none');
51
+ if (params.stringContext) {
52
+ systemMsg += SYSTEM_STRING_CONTEXT_SUFFIX.replace('{string_context}', params.stringContext);
53
+ }
54
+ const userMsg = USER_TEMPLATE.replace('{source_text}', params.sourceText);
53
55
  return [
54
56
  { role: 'system', content: systemMsg },
55
57
  { role: 'user', content: userMsg },
@@ -59,15 +61,17 @@ export function buildMessages(params) {
59
61
  * Build system + user messages for plural chat-based providers.
60
62
  */
61
63
  export function buildPluralMessages(params) {
62
- const systemMsg = safeRender(PLURAL_SYSTEM_TEMPLATE, {
64
+ let systemMsg = safeRender(PLURAL_SYSTEM_TEMPLATE, {
63
65
  source_lang: params.sourceLang,
64
66
  target_lang: params.targetLang,
65
67
  project_context: params.projectContext,
66
68
  });
69
+ if (params.stringContext) {
70
+ systemMsg += safeRender(SYSTEM_STRING_CONTEXT_SUFFIX, { string_context: params.stringContext });
71
+ }
67
72
  const userMsg = safeRender(PLURAL_USER_TEMPLATE, {
68
73
  one: params.one,
69
74
  other: params.other,
70
- string_context: params.stringContext || 'none',
71
75
  });
72
76
  return [
73
77
  { role: 'system', content: systemMsg },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "transduck",
3
- "version": "0.6.10",
3
+ "version": "0.8.0",
4
4
  "description": "AI-native translation tool using source text as keys",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
package/src/cli.ts CHANGED
@@ -743,7 +743,7 @@ export async function runStats(opts: StatsOptions): Promise<string> {
743
743
  // CLI entry point
744
744
  const program = new Command();
745
745
 
746
- program.name('transduck').description('AI-native translation tool').version('0.6.10');
746
+ program.name('transduck').description('AI-native translation tool').version('0.8.0');
747
747
 
748
748
  program.command('init')
749
749
  .description('Initialize a new transduck project')
package/src/config.ts CHANGED
@@ -17,6 +17,7 @@ export interface TransduckConfig {
17
17
  backendModel: string;
18
18
  backendTimeout: number;
19
19
  backendMaxRetries: number;
20
+ backendMaxConcurrency: number;
20
21
  sharedUrl: string | null;
21
22
  readOnly: boolean;
22
23
  }
@@ -61,6 +62,13 @@ export function loadConfig(path?: string): TransduckConfig {
61
62
  const backendTimeout = backend.timeout_seconds ?? 10;
62
63
  const backendMaxRetries = backend.max_retries ?? 2;
63
64
 
65
+ const backendMaxConcurrency = backend.max_concurrency ?? 10;
66
+ if (!Number.isInteger(backendMaxConcurrency) || backendMaxConcurrency < 1) {
67
+ throw new Error(
68
+ `backend.max_concurrency must be a positive integer (got ${backend.max_concurrency})`
69
+ );
70
+ }
71
+
64
72
  const sharedUrl = process.env.TRANSDUCK_SHARED_URL || raw.storage?.shared_url || null;
65
73
 
66
74
  let readOnly = raw.runtime?.read_only ?? false;
@@ -81,6 +89,7 @@ export function loadConfig(path?: string): TransduckConfig {
81
89
  backendModel,
82
90
  backendTimeout,
83
91
  backendMaxRetries,
92
+ backendMaxConcurrency,
84
93
  readOnly,
85
94
  };
86
95
  }
package/src/handler.ts CHANGED
@@ -10,6 +10,48 @@ function hash(text: string): string {
10
10
  return createHash('sha256').update(text).digest('hex');
11
11
  }
12
12
 
13
+ /**
14
+ * Bounded-concurrency semaphore. Workers call `acquire()` to get a slot,
15
+ * then `release()` when done. When the active count is at the limit,
16
+ * further `acquire()` calls queue until a slot frees up.
17
+ */
18
+ export function createSemaphore(limit: number) {
19
+ let active = 0;
20
+ const queue: Array<() => void> = [];
21
+ return {
22
+ acquire(): Promise<void> {
23
+ return new Promise(resolve => {
24
+ if (active < limit) {
25
+ active++;
26
+ resolve();
27
+ } else {
28
+ queue.push(() => {
29
+ active++;
30
+ resolve();
31
+ });
32
+ }
33
+ });
34
+ },
35
+ release(): void {
36
+ active--;
37
+ const next = queue.shift();
38
+ if (next) next();
39
+ },
40
+ };
41
+ }
42
+
43
+ async function withSlot<T>(
44
+ sem: ReturnType<typeof createSemaphore>,
45
+ fn: () => Promise<T>,
46
+ ): Promise<T> {
47
+ await sem.acquire();
48
+ try {
49
+ return await fn();
50
+ } finally {
51
+ sem.release();
52
+ }
53
+ }
54
+
13
55
  interface TranslationRequestString {
14
56
  text: string;
15
57
  context?: string;
@@ -82,137 +124,140 @@ export async function handleTranslationRequest(
82
124
 
83
125
  const projectContextHash = hash(cfg.projectContext);
84
126
 
85
- const translations: Record<string, string> = {};
86
- const plurals: Record<string, Record<string, string>> = {};
127
+ // Single semaphore shared across strings and plurals so total outbound
128
+ // concurrency to the backend is bounded across both categories.
129
+ const sem = createSemaphore(cfg.backendMaxConcurrency);
87
130
 
88
- // Translate regular strings
89
- for (const item of body.strings ?? []) {
90
- const sourceLang = item.sourceLang?.toUpperCase() ?? bodySourceLang ?? cfg.sourceLang;
91
- const stringContextHash = hash(item.context ?? '');
92
- const key = `${item.text}||${item.context ?? ''}`;
93
- const lookupParams = {
94
- sourceText: item.text, sourceLang, targetLang,
95
- projectContextHash, stringContextHash,
96
- };
97
-
98
- // Tier 1: local cache
99
- const cached = await store.lookup(lookupParams);
100
- if (cached !== null) {
101
- translations[key] = cached;
102
- continue;
103
- }
131
+ const stringWork = (body.strings ?? []).map(item =>
132
+ withSlot(sem, async (): Promise<[string, string]> => {
133
+ const sourceLang = item.sourceLang?.toUpperCase() ?? bodySourceLang ?? cfg.sourceLang;
134
+ const stringContextHash = hash(item.context ?? '');
135
+ const key = `${item.text}||${item.context ?? ''}`;
136
+ const lookupParams = {
137
+ sourceText: item.text, sourceLang, targetLang,
138
+ projectContextHash, stringContextHash,
139
+ };
104
140
 
105
- // Tier 2: shared store
106
- if (shared) {
107
- const sharedCached = await shared.lookup(lookupParams);
108
- if (sharedCached !== null) {
109
- // Propagate to local
110
- await store.insert({
111
- ...lookupParams, stringContext: item.context ?? '',
112
- translatedText: sharedCached, model: cfg.backendModel, status: 'translated',
113
- });
114
- translations[key] = sharedCached;
115
- continue;
141
+ // Tier 1: local cache
142
+ const cached = await store.lookup(lookupParams);
143
+ if (cached !== null) return [key, cached];
144
+
145
+ // Tier 2: shared store
146
+ if (shared) {
147
+ const sharedCached = await shared.lookup(lookupParams);
148
+ if (sharedCached !== null) {
149
+ await store.insert({
150
+ ...lookupParams, stringContext: item.context ?? '',
151
+ translatedText: sharedCached, model: cfg.backendModel, status: 'translated',
152
+ });
153
+ return [key, sharedCached];
154
+ }
116
155
  }
117
- }
118
156
 
119
- // Backend call
120
- try {
121
- const translated = await backendTranslate(
122
- item.text, sourceLang, targetLang,
123
- cfg.projectContext, item.context ?? null, cfg,
124
- );
125
-
126
- if (validateTranslation(item.text, translated)) {
127
- const insertParams = {
128
- ...lookupParams, stringContext: item.context ?? '',
129
- translatedText: translated, model: cfg.backendModel, status: 'translated',
130
- };
131
- await store.insert(insertParams);
132
- if (shared) await shared.insert(insertParams);
133
- if (opts?.onTranslate) {
134
- const event: TranslationEvent = {
135
- sourceText: item.text, translatedText: translated,
136
- sourceLang, lang: targetLang, context: item.context ?? null,
137
- projectContext: cfg.projectContext, source: 'translated',
138
- plural: false,
157
+ // Backend call
158
+ try {
159
+ const translated = await backendTranslate(
160
+ item.text, sourceLang, targetLang,
161
+ cfg.projectContext, item.context ?? null, cfg,
162
+ );
163
+
164
+ if (validateTranslation(item.text, translated)) {
165
+ const insertParams = {
166
+ ...lookupParams, stringContext: item.context ?? '',
167
+ translatedText: translated, model: cfg.backendModel, status: 'translated',
139
168
  };
140
- await fireHooks([], event, opts.onTranslate);
169
+ await store.insert(insertParams);
170
+ if (shared) await shared.insert(insertParams);
171
+ if (opts?.onTranslate) {
172
+ const event: TranslationEvent = {
173
+ sourceText: item.text, translatedText: translated,
174
+ sourceLang, lang: targetLang, context: item.context ?? null,
175
+ projectContext: cfg.projectContext, source: 'translated',
176
+ plural: false,
177
+ };
178
+ await fireHooks([], event, opts.onTranslate);
179
+ }
180
+ return [key, translated];
141
181
  }
142
- translations[key] = translated;
143
- } else {
144
- translations[key] = item.text;
182
+ return [key, item.text];
183
+ } catch {
184
+ return [key, item.text];
145
185
  }
146
- } catch {
147
- translations[key] = item.text;
148
- }
149
- }
186
+ })
187
+ );
150
188
 
151
- // Translate plurals
152
- for (const item of body.plurals ?? []) {
153
- const sourceLang = item.sourceLang?.toUpperCase() ?? bodySourceLang ?? cfg.sourceLang;
154
- const stringContextHash = hash(item.context ?? '');
155
- const sourceKey = item.one + '\x00' + item.other;
156
- const responseKey = `${sourceKey}||${item.context ?? ''}`;
157
- const lookupParams = {
158
- sourceText: sourceKey, sourceLang, targetLang,
159
- projectContextHash, stringContextHash,
160
- };
161
-
162
- // Tier 1: local cache
163
- const cachedForms = await store.lookupPlural(lookupParams);
164
- if (Object.keys(cachedForms).length > 0) {
165
- plurals[responseKey] = cachedForms;
166
- continue;
167
- }
189
+ const pluralWork = (body.plurals ?? []).map(item =>
190
+ withSlot(sem, async (): Promise<[string, Record<string, string>]> => {
191
+ const sourceLang = item.sourceLang?.toUpperCase() ?? bodySourceLang ?? cfg.sourceLang;
192
+ const stringContextHash = hash(item.context ?? '');
193
+ const sourceKey = item.one + '\x00' + item.other;
194
+ const responseKey = `${sourceKey}||${item.context ?? ''}`;
195
+ const lookupParams = {
196
+ sourceText: sourceKey, sourceLang, targetLang,
197
+ projectContextHash, stringContextHash,
198
+ };
199
+
200
+ // Tier 1: local cache
201
+ const cachedForms = await store.lookupPlural(lookupParams);
202
+ if (Object.keys(cachedForms).length > 0) return [responseKey, cachedForms];
203
+
204
+ // Tier 2: shared store
205
+ if (shared) {
206
+ const sharedForms = await shared.lookupPlural(lookupParams);
207
+ if (Object.keys(sharedForms).length > 0) {
208
+ for (const [cat, text] of Object.entries(sharedForms)) {
209
+ await store.insertPlural({
210
+ ...lookupParams, stringContext: item.context ?? '',
211
+ pluralCategory: cat, translatedText: text,
212
+ model: cfg.backendModel, status: 'translated',
213
+ });
214
+ }
215
+ return [responseKey, sharedForms];
216
+ }
217
+ }
218
+
219
+ // Backend call
220
+ try {
221
+ const forms = await backendTranslatePlural(
222
+ item.one, item.other, sourceLang, targetLang,
223
+ cfg.projectContext, item.context ?? null, cfg,
224
+ );
168
225
 
169
- // Tier 2: shared store
170
- if (shared) {
171
- const sharedForms = await shared.lookupPlural(lookupParams);
172
- if (Object.keys(sharedForms).length > 0) {
173
- // Propagate to local
174
- for (const [cat, text] of Object.entries(sharedForms)) {
175
- await store.insertPlural({
226
+ for (const [cat, translatedText] of Object.entries(forms)) {
227
+ const insertParams = {
176
228
  ...lookupParams, stringContext: item.context ?? '',
177
- pluralCategory: cat, translatedText: text,
229
+ pluralCategory: cat, translatedText: translatedText as string,
178
230
  model: cfg.backendModel, status: 'translated',
179
- });
231
+ };
232
+ await store.insertPlural(insertParams);
233
+ if (shared) await shared.insertPlural(insertParams);
180
234
  }
181
- plurals[responseKey] = sharedForms;
182
- continue;
235
+ if (opts?.onTranslate && 'other' in forms) {
236
+ const event: TranslationEvent = {
237
+ sourceText: sourceKey, translatedText: forms.other ?? '',
238
+ sourceLang, lang: targetLang, context: item.context ?? null,
239
+ projectContext: cfg.projectContext, source: 'translated',
240
+ plural: true, pluralForms: forms,
241
+ };
242
+ await fireHooks([], event, opts.onTranslate);
243
+ }
244
+ return [responseKey, forms];
245
+ } catch {
246
+ return [responseKey, { one: item.one, other: item.other }];
183
247
  }
184
- }
248
+ })
249
+ );
185
250
 
186
- // Backend call
187
- try {
188
- const forms = await backendTranslatePlural(
189
- item.one, item.other, sourceLang, targetLang,
190
- cfg.projectContext, item.context ?? null, cfg,
191
- );
192
-
193
- for (const [cat, translatedText] of Object.entries(forms)) {
194
- const insertParams = {
195
- ...lookupParams, stringContext: item.context ?? '',
196
- pluralCategory: cat, translatedText: translatedText as string,
197
- model: cfg.backendModel, status: 'translated',
198
- };
199
- await store.insertPlural(insertParams);
200
- if (shared) await shared.insertPlural(insertParams);
201
- }
202
- if (opts?.onTranslate && 'other' in forms) {
203
- const event: TranslationEvent = {
204
- sourceText: sourceKey, translatedText: forms.other ?? '',
205
- sourceLang, lang: targetLang, context: item.context ?? null,
206
- projectContext: cfg.projectContext, source: 'translated',
207
- plural: true, pluralForms: forms,
208
- };
209
- await fireHooks([], event, opts.onTranslate);
210
- }
211
- plurals[responseKey] = forms;
212
- } catch {
213
- plurals[responseKey] = { one: item.one, other: item.other };
214
- }
215
- }
251
+ const [stringResults, pluralResults] = await Promise.all([
252
+ Promise.all(stringWork),
253
+ Promise.all(pluralWork),
254
+ ]);
255
+
256
+ const translations: Record<string, string> = {};
257
+ for (const [key, value] of stringResults) translations[key] = value;
258
+
259
+ const plurals: Record<string, Record<string, string>> = {};
260
+ for (const [key, value] of pluralResults) plurals[key] = value;
216
261
 
217
262
  return { translations, plurals };
218
263
  }
@@ -12,11 +12,14 @@ const SYSTEM_TEMPLATE =
12
12
  'to {target_lang}. Return ONLY the translated text, nothing else. ' +
13
13
  'Do NOT add quotation marks, brackets, or any wrapper characters that are not in the original. ' +
14
14
  'Preserve any placeholders like {name}, {{count}}, %s, ${value} exactly as they appear. ' +
15
- 'Preserve brand names. Match the tone and formality of the original.\n\n' +
15
+ 'Preserve brand names. Match the tone and formality of the original. ' +
16
+ 'The user message will contain ONLY the source text to translate — treat its entire contents as the text to translate.\n\n' +
16
17
  'Project context: {project_context}';
17
18
 
18
- const USER_TEMPLATE =
19
- 'Translate the following text:\n{source_text}\n\nString context: {string_context}';
19
+ const SYSTEM_STRING_CONTEXT_SUFFIX =
20
+ '\n\nString context (for disambiguation only — do NOT include in your output): {string_context}';
21
+
22
+ const USER_TEMPLATE = '{source_text}';
20
23
 
21
24
  const PLURAL_SYSTEM_TEMPLATE =
22
25
  'You are a professional translator. You will be given two plural forms ' +
@@ -35,8 +38,7 @@ const PLURAL_SYSTEM_TEMPLATE =
35
38
 
36
39
  const PLURAL_USER_TEMPLATE =
37
40
  'Source one form: "{one}"\n' +
38
- 'Source other form: "{other}"\n' +
39
- 'String context: {string_context}';
41
+ 'Source other form: "{other}"';
40
42
 
41
43
  /**
42
44
  * Safe string replacement that only replaces known keys.
@@ -71,14 +73,16 @@ export interface BuildPluralMessagesParams {
71
73
  * Build system + user messages for chat-based providers (OpenAI, Claude API).
72
74
  */
73
75
  export function buildMessages(params: BuildMessagesParams): Array<{ role: string; content: string }> {
74
- const systemMsg = SYSTEM_TEMPLATE
76
+ let systemMsg = SYSTEM_TEMPLATE
75
77
  .replace('{source_lang}', params.sourceLang)
76
78
  .replace('{target_lang}', params.targetLang)
77
79
  .replace('{project_context}', params.projectContext);
78
80
 
79
- const userMsg = USER_TEMPLATE
80
- .replace('{source_text}', params.sourceText)
81
- .replace('{string_context}', params.stringContext || 'none');
81
+ if (params.stringContext) {
82
+ systemMsg += SYSTEM_STRING_CONTEXT_SUFFIX.replace('{string_context}', params.stringContext);
83
+ }
84
+
85
+ const userMsg = USER_TEMPLATE.replace('{source_text}', params.sourceText);
82
86
 
83
87
  return [
84
88
  { role: 'system', content: systemMsg },
@@ -90,16 +94,19 @@ export function buildMessages(params: BuildMessagesParams): Array<{ role: string
90
94
  * Build system + user messages for plural chat-based providers.
91
95
  */
92
96
  export function buildPluralMessages(params: BuildPluralMessagesParams): Array<{ role: string; content: string }> {
93
- const systemMsg = safeRender(PLURAL_SYSTEM_TEMPLATE, {
97
+ let systemMsg = safeRender(PLURAL_SYSTEM_TEMPLATE, {
94
98
  source_lang: params.sourceLang,
95
99
  target_lang: params.targetLang,
96
100
  project_context: params.projectContext,
97
101
  });
98
102
 
103
+ if (params.stringContext) {
104
+ systemMsg += safeRender(SYSTEM_STRING_CONTEXT_SUFFIX, { string_context: params.stringContext });
105
+ }
106
+
99
107
  const userMsg = safeRender(PLURAL_USER_TEMPLATE, {
100
108
  one: params.one,
101
109
  other: params.other,
102
- string_context: params.stringContext || 'none',
103
110
  });
104
111
 
105
112
  return [
package/tests/ait.test.ts CHANGED
@@ -22,6 +22,7 @@ function makeConfig(tmpDir: string, overrides?: Partial<TransduckConfig>): Trans
22
22
  backendModel: 'gpt-4.1-mini',
23
23
  backendTimeout: 10,
24
24
  backendMaxRetries: 2,
25
+ backendMaxConcurrency: 10,
25
26
  sharedUrl: null,
26
27
  readOnly: false,
27
28
  ...overrides,
@@ -15,6 +15,7 @@ function makeConfig(): TransduckConfig {
15
15
  backendModel: 'gpt-4.1-mini',
16
16
  backendTimeout: 10,
17
17
  backendMaxRetries: 2,
18
+ backendMaxConcurrency: 10,
18
19
  sharedUrl: null,
19
20
  readOnly: false,
20
21
  };
@@ -34,12 +35,31 @@ describe('buildMessages', () => {
34
35
  expect(messages[0].content).toContain('EN');
35
36
  expect(messages[0].content).toContain('DE');
36
37
  expect(messages[0].content).toContain('Mallorca');
38
+ // String context belongs in the system message so the model doesn't
39
+ // confuse it with the text to translate.
40
+ expect(messages[0].content).toContain('concerts and shows');
37
41
  expect(messages[1].role).toBe('user');
38
- expect(messages[1].content).toContain('Our Events');
39
- expect(messages[1].content).toContain('concerts and shows');
42
+ // User message must be ONLY the source text.
43
+ expect(messages[1].content).toBe('Our Events');
40
44
  });
41
45
 
42
- it('uses "none" when no string context', () => {
46
+ it('never places string_context in the user message (regression)', () => {
47
+ // Regression: short source text like a dish name used to include the
48
+ // context in the user message body, causing the AI to translate the
49
+ // context along with the source text.
50
+ const messages = buildMessages({
51
+ sourceText: 'Gambas al Ajillo',
52
+ sourceLang: 'ES',
53
+ targetLang: 'EN',
54
+ projectContext: 'restaurant menu',
55
+ stringContext: 'Sizzling garlic prawns in olive oil',
56
+ });
57
+ expect(messages[1].content).toBe('Gambas al Ajillo');
58
+ expect(messages[1].content).not.toContain('Sizzling');
59
+ expect(messages[1].content).not.toContain('context');
60
+ });
61
+
62
+ it('omits string context line when no context provided', () => {
43
63
  const messages = buildMessages({
44
64
  sourceText: 'Book Now',
45
65
  sourceLang: 'EN',
@@ -47,7 +67,8 @@ describe('buildMessages', () => {
47
67
  projectContext: 'A travel site',
48
68
  stringContext: null,
49
69
  });
50
- expect(messages[1].content.toLowerCase()).toContain('none');
70
+ expect(messages[0].content).not.toContain('String context');
71
+ expect(messages[1].content).toBe('Book Now');
51
72
  });
52
73
  });
53
74
 
@@ -104,7 +125,9 @@ describe('buildPluralMessages', () => {
104
125
  expect(messages[1].role).toBe('user');
105
126
  expect(messages[1].content).toContain('{count} message');
106
127
  expect(messages[1].content).toContain('{count} messages');
107
- expect(messages[1].content).toContain('inbox count');
128
+ // String context goes in the system message.
129
+ expect(messages[0].content).toContain('inbox count');
130
+ expect(messages[1].content).not.toContain('inbox count');
108
131
  });
109
132
 
110
133
  it('preserves placeholders in template (not interpreted as template vars)', () => {
@@ -124,7 +147,7 @@ describe('buildPluralMessages', () => {
124
147
  expect(messages[0].content).toContain('{count}');
125
148
  });
126
149
 
127
- it('uses "none" for null string context', () => {
150
+ it('omits string context line for null string context', () => {
128
151
  const messages = buildPluralMessages({
129
152
  one: '{count} msg',
130
153
  other: '{count} msgs',
@@ -133,7 +156,8 @@ describe('buildPluralMessages', () => {
133
156
  projectContext: 'test',
134
157
  stringContext: null,
135
158
  });
136
- expect(messages[1].content).toContain('none');
159
+ expect(messages[0].content).not.toContain('String context');
160
+ expect(messages[1].content).not.toContain('String context');
137
161
  });
138
162
  });
139
163
 
@@ -72,6 +72,32 @@ describe('loadConfig', () => {
72
72
  delete process.env.TRANSDUCK_CONFIG;
73
73
  });
74
74
 
75
+ it('defaults backendMaxConcurrency to 10 when absent', () => {
76
+ const configPath = join(tmpDir, 'transduck.yaml');
77
+ writeFileSync(configPath, VALID_YAML);
78
+ const cfg = loadConfig(configPath);
79
+ expect(cfg.backendMaxConcurrency).toBe(10);
80
+ });
81
+
82
+ it('reads backendMaxConcurrency from backend.max_concurrency', () => {
83
+ const configPath = join(tmpDir, 'transduck.yaml');
84
+ writeFileSync(configPath, VALID_YAML + ' max_concurrency: 25\n');
85
+ const cfg = loadConfig(configPath);
86
+ expect(cfg.backendMaxConcurrency).toBe(25);
87
+ });
88
+
89
+ it('rejects zero or negative backend.max_concurrency', () => {
90
+ const configPath = join(tmpDir, 'transduck.yaml');
91
+ writeFileSync(configPath, VALID_YAML + ' max_concurrency: 0\n');
92
+ expect(() => loadConfig(configPath)).toThrow(/max_concurrency/);
93
+ });
94
+
95
+ it('rejects non-integer backend.max_concurrency', () => {
96
+ const configPath = join(tmpDir, 'transduck.yaml');
97
+ writeFileSync(configPath, VALID_YAML + ' max_concurrency: 2.5\n');
98
+ expect(() => loadConfig(configPath)).toThrow(/max_concurrency/);
99
+ });
100
+
75
101
  it('defaults shared_url to null', () => {
76
102
  const configPath = join(tmpDir, 'transduck.yaml');
77
103
  writeFileSync(configPath, VALID_YAML);
@@ -4,7 +4,7 @@ import { join } from 'path';
4
4
  import { tmpdir } from 'os';
5
5
 
6
6
  // We test the handler logic directly, not via HTTP
7
- import { handleTranslationRequest, _resetHandlerStore } from '../src/handler.js';
7
+ import { handleTranslationRequest, _resetHandlerStore, createSemaphore } from '../src/handler.js';
8
8
 
9
9
  function makeTmpDir(): string {
10
10
  return mkdtempSync(join(tmpdir(), 'transduck-handler-test-'));
@@ -184,3 +184,166 @@ describe('handleTranslationRequest', () => {
184
184
  expect(result.translations['Hola||']).toBe('Hallo');
185
185
  });
186
186
  });
187
+
188
+ describe('createSemaphore', () => {
189
+ it('bounds concurrent execution to the configured limit', async () => {
190
+ const sem = createSemaphore(3);
191
+ let active = 0;
192
+ let peak = 0;
193
+
194
+ const workers = Array.from({ length: 12 }, (_, i) =>
195
+ (async () => {
196
+ await sem.acquire();
197
+ active++;
198
+ peak = Math.max(peak, active);
199
+ await new Promise(r => setTimeout(r, 20));
200
+ active--;
201
+ sem.release();
202
+ return i;
203
+ })()
204
+ );
205
+
206
+ const results = await Promise.all(workers);
207
+ expect(peak).toBe(3);
208
+ expect(results).toHaveLength(12);
209
+ expect(active).toBe(0);
210
+ });
211
+
212
+ it('processes all work items even when queue fills', async () => {
213
+ const sem = createSemaphore(2);
214
+ const order: number[] = [];
215
+ const workers = Array.from({ length: 6 }, (_, i) =>
216
+ (async () => {
217
+ await sem.acquire();
218
+ await new Promise(r => setTimeout(r, 5));
219
+ order.push(i);
220
+ sem.release();
221
+ })()
222
+ );
223
+ await Promise.all(workers);
224
+ expect(order).toHaveLength(6);
225
+ expect(new Set(order).size).toBe(6);
226
+ });
227
+ });
228
+
229
+ describe('handleTranslationRequest parallelism', () => {
230
+ let tmpDir: string;
231
+ let configPath: string;
232
+
233
+ function writeConfigWithCap(dir: string, cap: number): string {
234
+ const p = join(dir, 'transduck.yaml');
235
+ writeFileSync(p, `
236
+ project:
237
+ name: test-project
238
+ context: "A test site"
239
+ languages:
240
+ source: EN
241
+ targets:
242
+ - DE
243
+ storage:
244
+ path: ./translations.db
245
+ backend:
246
+ api_key_env: OPENAI_API_KEY
247
+ model: gpt-4.1-mini
248
+ timeout_seconds: 10
249
+ max_retries: 2
250
+ max_concurrency: ${cap}
251
+ `);
252
+ return p;
253
+ }
254
+
255
+ beforeEach(() => {
256
+ _resetHandlerStore();
257
+ tmpDir = mkdtempSync(join(tmpdir(), 'transduck-handler-par-'));
258
+ process.env.OPENAI_API_KEY = 'test-key';
259
+ });
260
+
261
+ it('parallelizes string translations bounded by max_concurrency', async () => {
262
+ configPath = writeConfigWithCap(tmpDir, 5);
263
+
264
+ let active = 0;
265
+ let peak = 0;
266
+ const backend = await import('../src/backend.js');
267
+ vi.spyOn(backend, 'translate').mockImplementation(async (text: string) => {
268
+ active++;
269
+ peak = Math.max(peak, active);
270
+ await new Promise(r => setTimeout(r, 20));
271
+ active--;
272
+ return `translated-${text}`;
273
+ });
274
+
275
+ const strings = Array.from({ length: 20 }, (_, i) => ({ text: `s${i}` }));
276
+ const result = await handleTranslationRequest(
277
+ { language: 'DE', strings, plurals: [] },
278
+ configPath,
279
+ );
280
+
281
+ expect(peak).toBe(5);
282
+ expect(Object.keys(result.translations)).toHaveLength(20);
283
+ for (let i = 0; i < 20; i++) {
284
+ expect(result.translations[`s${i}||`]).toBe(`translated-s${i}`);
285
+ }
286
+ vi.restoreAllMocks();
287
+ });
288
+
289
+ it('shares the semaphore across strings and plurals', async () => {
290
+ configPath = writeConfigWithCap(tmpDir, 4);
291
+
292
+ let active = 0;
293
+ let peak = 0;
294
+ const backend = await import('../src/backend.js');
295
+ vi.spyOn(backend, 'translate').mockImplementation(async (text: string) => {
296
+ active++;
297
+ peak = Math.max(peak, active);
298
+ await new Promise(r => setTimeout(r, 20));
299
+ active--;
300
+ return `t-${text}`;
301
+ });
302
+ vi.spyOn(backend, 'translatePlural').mockImplementation(async (one: string, other: string) => {
303
+ active++;
304
+ peak = Math.max(peak, active);
305
+ await new Promise(r => setTimeout(r, 20));
306
+ active--;
307
+ return { one: `t-${one}`, other: `t-${other}` };
308
+ });
309
+
310
+ const strings = Array.from({ length: 10 }, (_, i) => ({ text: `s${i}` }));
311
+ const plurals = Array.from({ length: 10 }, (_, i) => ({
312
+ one: `${i}-one`, other: `${i}-other`,
313
+ }));
314
+
315
+ const result = await handleTranslationRequest(
316
+ { language: 'DE', strings, plurals },
317
+ configPath,
318
+ );
319
+
320
+ expect(peak).toBe(4);
321
+ expect(Object.keys(result.translations)).toHaveLength(10);
322
+ expect(Object.keys(result.plurals)).toHaveLength(10);
323
+ vi.restoreAllMocks();
324
+ });
325
+
326
+ it('one backend failure does not affect sibling items', async () => {
327
+ configPath = writeConfigWithCap(tmpDir, 3);
328
+
329
+ const backend = await import('../src/backend.js');
330
+ vi.spyOn(backend, 'translate').mockImplementation(async (text: string) => {
331
+ if (text === 'boom') throw new Error('backend exploded');
332
+ return `t-${text}`;
333
+ });
334
+
335
+ const result = await handleTranslationRequest(
336
+ {
337
+ language: 'DE',
338
+ strings: [{ text: 'ok1' }, { text: 'boom' }, { text: 'ok2' }],
339
+ plurals: [],
340
+ },
341
+ configPath,
342
+ );
343
+
344
+ expect(result.translations['ok1||']).toBe('t-ok1');
345
+ expect(result.translations['ok2||']).toBe('t-ok2');
346
+ expect(result.translations['boom||']).toBe('boom'); // fallback to source
347
+ vi.restoreAllMocks();
348
+ });
349
+ });
@@ -23,6 +23,7 @@ function makeConfig(tmpDir: string, overrides?: Partial<TransduckConfig>): Trans
23
23
  backendModel: 'gpt-4.1-mini',
24
24
  backendTimeout: 10,
25
25
  backendMaxRetries: 2,
26
+ backendMaxConcurrency: 10,
26
27
  sharedUrl: null,
27
28
  readOnly: false,
28
29
  ...overrides,
@@ -27,6 +27,7 @@ function makeConfig(overrides: Partial<TransduckConfig> = {}): TransduckConfig {
27
27
  backendModel: 'gpt-4.1-mini',
28
28
  backendTimeout: 10,
29
29
  backendMaxRetries: 2,
30
+ backendMaxConcurrency: 10,
30
31
  readOnly: false,
31
32
  ...overrides,
32
33
  };
@@ -48,12 +49,14 @@ describe('prompts', () => {
48
49
  expect(messages[0].role).toBe('system');
49
50
  expect(messages[0].content).toContain('EN');
50
51
  expect(messages[0].content).toContain('DE');
52
+ // String context in system message, not user message.
53
+ expect(messages[0].content).toContain('greeting');
51
54
  expect(messages[1].role).toBe('user');
52
- expect(messages[1].content).toContain('Hello');
53
- expect(messages[1].content).toContain('greeting');
55
+ // User message must be ONLY the source text.
56
+ expect(messages[1].content).toBe('Hello');
54
57
  });
55
58
 
56
- it('uses "none" for null string context', () => {
59
+ it('omits string context line for null string context', () => {
57
60
  const messages = buildMessages({
58
61
  sourceText: 'Hello',
59
62
  sourceLang: 'EN',
@@ -61,7 +64,8 @@ describe('prompts', () => {
61
64
  projectContext: 'test',
62
65
  stringContext: null,
63
66
  });
64
- expect(messages[1].content).toContain('none');
67
+ expect(messages[0].content).not.toContain('String context');
68
+ expect(messages[1].content).toBe('Hello');
65
69
  });
66
70
  });
67
71